<!-- [doc->REQ-DEVICE-LINK-IROH] -->
# JNI-PLAN — embedded Rust device-link slice (2026-07-06)

JIT plan per AGENTS.md. Follows HOST-PLAN (gate COMPLETE). Scope: the phone half of the
device link — the embedded Rust slice (ADR-0002) that dials the host binary's iroh node
key and speaks the v1 RPC protocol the host already serves, built for Android via
cargo-ndk with a JNI surface the Kotlin app milestone will consume.

## Scope (this milestone)

Deliver `rust/` — a Cargo workspace with three crates:

1. **`rust/link-proto`** — the shared wire contract, extracted from `host/src/link.rs`:
   `Request`/`Response` frames, u32-BE length-prefixed JSON codec (1 MiB cap), QR payload
   type, ALPN const. `host/` consumes it by path dep (both spt-mobile-owned — this is NOT
   an spt-core dependency). Pure refactor; host tests keep passing unchanged.
   [REQ-DEVICE-LINK-IROH]
2. **`rust/link-client`** — the phone-side client (desktop-testable, no Android needed):
   - `LinkClient`: dial by node key from the QR payload (iroh `=0.98.2`, ALPN
     `spt-mobile/link/0`), bearer token on every request, one bi-stream per RPC.
     Full v1 set: pair, ping, list-endpoints, send, digest-snapshot,
     digest-follow (stream), history-fetch, spool-drain. [REQ-DEVICE-LINK-IROH phone half]
   - **Digest view model**: apply `{version, from, turns}` deltas
     (truncate-to-`from` + append), snapshot re-sync via `--after` seq cursor with
     `after_predates_window` handling; exact dedup — msg-id for own sends, seq for
     digest entries, no fuzzy matching. [REQ-DIGEST-CURSOR; REQ-HAZARD-DUP-ROWS]
   - `int`: cross-process loopback — spawn the real `spt-mobile-host` binary (fake `spt`
     program via env/flag seam), dial it over real UDP, exercise every RPC + the follow
     stream. This is the proof that activates `int` on REQ-DEVICE-LINK-IROH.
3. **`rust/link-android`** — the JNI shim (thin; all logic stays in link-client):
   `jni` crate, cargo-ndk targets `aarch64-linux-android` + `x86_64-linux-android`
   (emulator). Build script `ci/build-android.sh`. No Android-side tests this milestone —
   the shim is exercised by the Kotlin app milestone; its logic is already unit-covered
   in link-client.

## Non-goals (later milestones)

- Kotlin/Compose app, foreground service, notifications (app milestone).
- Voice pipe (webhook receiver, phone spool) — REQ-VOICE-PIPE, REQ-HAZARD-DICTATION-LOSS.
- Multi-host dial-list failover (REQ-MULTI-HOST-PAIRING — app milestone; the client
  handles ONE host, the app orders and fails over).
- Host-side changes beyond the link-proto extraction and the int-test seam.

## Open design questions (resolve during build, smallest-commitment default)

- Q1 JNI surface shape: JSON-string-in/JSON-string-out blocking calls on a client handle
  + `poll_next(timeout_ms)` for stream events (default — no cross-language callbacks) vs
  JNI callbacks. Default chosen for v1.
- Q2 Async runtime in the .so: one lazily-initialized global tokio runtime owned by the
  slice. Default: yes, `OnceLock<Runtime>`.
- Q3 Host int-test seam: how the spawned host binary runs without a real spt install.
  Default: `SPT_MOBILE_SPT_PROGRAM` env override pointing at a stub script (the CliRunner
  already takes a program name).

## Waves

- **W1 — link-proto extraction**: create `rust/` workspace + `link-proto` crate; move
  frames/codec/QR/ALPN out of `host/src/link.rs`; host consumes by path dep; add `rust`
  to `[scan].roots`. Gate: both crates build, all host tests green, `traceable-reqs
  check` exit 0.
- **W2 — link-client + digest view model** *(COMPLETE 2026-07-06)*: LinkClient RPCs + follow
  stream; delta apply + cursor dedup unit tests; cross-process int test (spawn host, real
  UDP). Activates `int` on REQ-DEVICE-LINK-IROH; activates REQ-DIGEST-CURSOR (doc/impl/unit)
  and REQ-HAZARD-DUP-ROWS (doc/unit). Also landed: `event` module moved host→link-proto
  (the phone needs the same `<EVENT>` parse for echo dedup), `after` cursor field on the
  digest-snapshot RPC (host passes through as `--after`), `SPT_MOBILE_SPT_PROGRAM` seam (Q3),
  and a `device link addr:` stderr beacon for LAN/loopback dialers.
- **W3 — android shim + toolchain** *(COMPLETE 2026-07-06)*: `rust/link-android` cdylib —
  blocking JSON-string-in/out JNI exports on `com.sptmobile.link.LinkNative` (Q1), one
  global `OnceLock<Runtime>` (Q2, 2 workers), panic barrier → `RuntimeException`; follow
  stream pumped task→channel so `followNext(timeoutMs)` timeouts cancel only a channel
  recv, never a half-read frame (envelope: delta/timeout/end/error). `ci/build-android.sh`
  (cargo-ndk → `rust/target/jniLibs/{arm64-v8a,x86_64}`); release profile opt-level=z +
  fat LTO + strip (arm64 .so ≈ 5.9 MiB). Gate proven on this machine (NDK r27.3 installed
  via sdkmanager); desktop gates need no NDK — link-android builds/tests as a plain
  workspace member.

## Requirement activation (this milestone)

| REQ | Stages |
|-----|--------|
| REQ-DEVICE-LINK-IROH | + int (at W2) |
| REQ-DIGEST-CURSOR | doc, impl, unit (at W2) |
| REQ-HAZARD-DUP-ROWS | doc, unit (at W2) |

## Gate

Per wave: `cargo build` + `cargo test` green in `rust/` AND `host/`;
`traceable-reqs check` exit 0; W3 adds a successful cargo-ndk build of both Android
targets.
