# M4-D2-wire — pairing ALPN driver (JIT plan)

**Status:** not started. Crypto core (D2a–e) + rate-limit + trust store all shipped & CI-green
as of `1df7331`. This is the transport glue that runs the 4-message ceremony over a real
iroh bidi stream and activates **REQ-PAIR-1**'s wire/`int` evidence.

## Goal

Two not-yet-trusted nodes complete the TOTP-SPAKE2 ceremony over a dedicated **pre-trust
pairing ALPN**, exchange+confirm pubkeys, and each writes the peer into its `TrustStore`.
Acceptance (M4-PLAN): *two machines pair via one TOTP code; trust material stored; negative
tests pass.* Component-level (loopback, relays-disabled) like `endpoint.rs` tests; full WAN
two-host is D9.

## Pieces (build order)

1. **`SPT_PAIR_ALPN`** = `b"spt-core/pair/0"` — separate from `SPT_NET_ALPN` so the relay
   permits pre-trust contact without exposing the trusted message surface (see `pairing.rs`
   doc). Decide: dedicated pairing endpoint bound only to this ALPN during a ceremony window,
   vs. add the ALPN to `NetEndpoint` and branch on `Incoming`'s negotiated ALPN. Leaning
   **dedicated** (pre-trust accept loop must not touch the trusted path).
2. **Wire framing** for the 4 messages over one bidi stream. Reuse the length-prefixed
   convention from `transcript.rs`. Messages:
   - `Hello` (initiator→responder): `step:u64` + `initiator_pub:32` + SPAKE `msg_a`.
   - `Reply` (responder→initiator): SPAKE `msg_b` + `responder_tag:32`.
   - `Confirm` (initiator→responder): `initiator_tag:32`.
   - `Done`/ack (responder→initiator): success/failure status.
3. **Responder driver** (seed-holder): `ratelimit.begin(subnet, now)` → on Ok, look up
   `SubnetStore` seed+epoch for the subnet, enforce `accepts_step(local, peer_step)` (±1),
   compute code at the peer's announced step, build `PairingTranscript`, run
   `Responder::respond`→`confirm`. On `Ok` → `ratelimit.record_success` + `TrustStore.record`.
   On `PairError` → `ratelimit.record_failure`. Map outcomes to the `Done` status byte.
4. **Initiator driver** (joiner): build transcript (subnet-name + epoch + both pubkeys + step),
   `Initiator::start`→`finish`. On success → `TrustStore.record` (+ `SubnetStore.add_joined`
   if this is a join that learns the seed — **see open Q2**).
5. **Tests:** loopback happy-path (both pin trust + agree key), wrong-code (Confirm fail, no
   trust written, failure recorded), Busy (concurrent ceremony rejected), backoff after a
   failure. `int`-tag REQ-PAIR-1 on the loopback E2E.

## OPEN DESIGN QUESTIONS (resolve before coding — study ADR-0005 + CONTEXT "Pairing & trust")

- **Q1 — rendezvous vs iroh dial.** CONTEXT:481 says the relay routes pre-trust pairing by a
  rendezvous token `H(subnet-name ‖ epoch)`, NOT by NodeId — but iroh `connect()` dials a
  NodeId. How do two nodes that don't know each other's NodeId meet? Options: (a) relay-side
  rendezvous (needs relay support — likely D9/relay work; for now dial by a NodeId learned
  out-of-band/mDNS); (b) a discovery shim keyed by the token. **Likely defer true
  token-rendezvous to D9; component test dials by addr like `endpoint.rs`.** Confirm scope.
- **Q2 — does the joiner learn the epoch?** Joiner holds only the 6-digit code + subnet-name
  (psyche design fact). The transcript binds the epoch (responder-authoritative). If the
  rendezvous token = `H(name ‖ epoch)`, the joiner needs the epoch to even meet — contradiction
  with "code+name only." Resolve: either (a) responder announces epoch in `Reply` and the
  transcript is built/checked responder-first, or (b) epoch is implied by the current TOTP
  window. Re-read D2c's "epoch is the responder-authoritative value the ceremony binds."
- **Q3 — seed transfer on join.** After a successful join, does the new node receive the
  subnet seed over the (now-confirmed, encrypted) channel so it becomes a future seed-holder?
  CONTEXT: every trusted node holds the seed. The wire driver may need a 5th post-confirm step
  delivering the seed → `SubnetStore.add_joined(name, seed, epoch)`. Confirm whether this
  lands here or in D2f.

## RESOLVED (2026-06-03 — studied ADR-0005, CONTEXT "Pairing & trust", shipped crypto)

**Q1 — rendezvous: DEFER to D9.** The driver dials by `EndpointAddr` (loopback,
`RelayPolicy::Disabled`), exactly like `endpoint.rs`. `SPT_PAIR_ALPN` is real; the relay
rendezvous token is not wired. Component-level only (not WAN) — confirmed scope.

**Q2 — joiner epoch: RESOLVED by separating two "epoch" concepts.** The plan conflated
them; the shipped code already distinguishes:
- **TOTP time-step** (`transcript.step`): clock-derived and **dialer-announced**
  (`totp.rs` doc: "the dialer announces its step in the clear"). The joiner derives it
  from its own clock → no "code+name only" contradiction. This is the `TOTP-epoch` in the
  rendezvous token `H(name‖TOTP-epoch)` (the 30s window), NOT the seed epoch.
- **Seed-rotation epoch** (`transcript.epoch`): responder-authoritative (`transcript.rs`
  doc), joiner does NOT hold it. But `Initiator::start` bakes the full transcript (incl.
  epoch) into `msg_a` — so the joiner must learn epoch **before** `start_a`. → the wire
  ceremony opens with a responder **`Announce{epoch}`** step.
  **DEVIATION from "4-message":** this forces 6 frames / 3 RTT, not the 4 guessed in piece
  #2. The SPAKE exchange is still 4 messages; we prepend the epoch-announce RTT (the
  joiner cannot construct `msg_a` without the epoch). Unavoidable given shipped binding.

**Q3 — seed transfer on join: DEFER to D2f.** D2-wire delivers ceremony + mutual
`TrustStore.record` (pubkey trust) only. Seed replication (`SubnetStore.add_joined`,
making the joiner a future seed-holder) is a distinct post-confirm step → D2f, where
multi-subnet join inputs (REQ-PAIR-4/5) land. D2-wire acceptance ("two machines pair via
one TOTP code; trust stored; negatives pass") needs no seed transfer. Seam documented.

**Frame sequence (one bidi stream, length-prefixed, `SPT_PAIR_ALPN`):**
```
F1 init→resp  Hello   { subnet_name, step }      (joiner opens stream; pubkeys via conn.remote_id())
F2 resp→init  Announce{ epoch } | Abort{reason}  (responder: subnet-lookup, rate.begin, ±1 step check)
F3 init→resp  Spake1  { msg_a }                   (joiner now has epoch → Initiator::start)
F4 resp→init  Spake2  { msg_b, responder_tag }    (responder: code@step, Responder::respond)
F5 init→resp  Confirm { initiator_tag }           (joiner finish → verifies responder_tag)
F6 resp→init  Done    { ok } | Abort              (responder confirm → record_success/failure + trust)
```
Both pubkeys come from the **iroh-authenticated** `conn.remote_id()` + local node pubkey,
never the Hello-claimed bytes (transport already binds node id to the Ed25519 key).

**int stays D9.** The loopback E2E is hermetic component-level = `unit` evidence (same as
`endpoint.rs` two_endpoints_loopback_echo). REQ-PAIR-1 activated `["impl","unit"]`; the
true two-host `int` is D9 (corrects the piece-5 "int-tag on loopback" slip).

## Conventions (carried)
- NO `cargo fmt` (no CI gate; touch only edited files).
- Tag `[impl->REQ-PAIR-1]` on the driver, `[unit->REQ-PAIR-1]` on tests; `int` stage at D9.
- `spt-net` is async (tokio); mirror `endpoint.rs` test style (loopback, RelayPolicy::Disabled).
- Commit trailer: `Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>`.

## After D2-wire
D2f (multi-subnet ceremony inputs: code+name, rendezvous token, create-new names up front —
REQ-PAIR-4/5) → D2g (elevation-gated `spt pair show-totp` — REQ-PAIR-3/6) → D3 registry.
