diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 038e0e4..4823174 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -59,6 +59,7 @@ graph TD
FEC["wzp-fec
RaptorQ FEC"]
CRYPTO["wzp-crypto
ChaCha20 + Identity"]
TRANSPORT["wzp-transport
QUIC / Quinn"]
+ VIDEO["wzp-video
H.264 + H.265 + AV1"]
RELAY["wzp-relay
Relay Daemon"]
CLIENT["wzp-client
CLI + Call Engine"]
@@ -68,16 +69,19 @@ graph TD
PROTO --> FEC
PROTO --> CRYPTO
PROTO --> TRANSPORT
+ PROTO --> VIDEO
CODEC --> CLIENT
FEC --> CLIENT
CRYPTO --> CLIENT
TRANSPORT --> CLIENT
+ VIDEO --> CLIENT
CODEC --> RELAY
FEC --> RELAY
CRYPTO --> RELAY
TRANSPORT --> RELAY
+ VIDEO --> RELAY
CLIENT --> WEB
TRANSPORT --> WEB
@@ -90,9 +94,10 @@ graph TD
style CLIENT fill:#00b894,color:#fff
style WEB fill:#0984e3,color:#fff
style FC fill:#fd79a8,color:#fff
+ style VIDEO fill:#a29bfe,color:#fff
```
-**Star pattern**: Each leaf crate (`wzp-codec`, `wzp-fec`, `wzp-crypto`, `wzp-transport`) depends only on `wzp-proto`. No leaf depends on another leaf. Integration crates (`wzp-relay`, `wzp-client`, `wzp-web`) depend on all leaves.
+**Star pattern**: Each leaf crate (`wzp-codec`, `wzp-fec`, `wzp-crypto`, `wzp-transport`, `wzp-video`) depends only on `wzp-proto`. No leaf depends on another leaf. Integration crates (`wzp-relay`, `wzp-client`, `wzp-web`) depend on all leaves.
## Audio Encode Pipeline
@@ -106,7 +111,7 @@ sequenceDiagram
participant DT as DredTuner
(wzp-proto)
participant FEC as RaptorQ FEC
participant INT as Interleaver
(depth=3)
- participant HDR as MediaHeader
(12B or Mini 4B)
+ participant HDR as MediaHeader
(16B or Mini 5B)
participant Enc as ChaCha20-Poly1305
participant QUIC as QUIC Datagram
participant QPS as QuinnPathSnapshot
@@ -144,7 +149,7 @@ sequenceDiagram
- RNNoise processes **2 x 480** samples (ML-based noise suppression via nnnoiseless)
- Silence detection uses VAD + 100ms hangover before switching to ComfortNoise
- FEC symbols are padded to **256 bytes** with a 2-byte LE length prefix
-- MiniHeaders (4 bytes) replace full headers (12 bytes) for 49 of every 50 frames
+- MiniHeaders (5 bytes) replace full headers (16 bytes) for 49 of every 50 audio frames; video always uses full headers
- DRED tuner polls quinn path stats every 25 frames (~500ms) and adjusts DRED lookback duration continuously
- Opus tiers bypass RaptorQ entirely -- DRED handles loss recovery at the codec layer
- Opus6k DRED window: 1040ms (maximum libopus allows)
@@ -324,35 +329,29 @@ sequenceDiagram
## Wire Formats
-### MediaHeader (12 bytes)
+### `MediaHeader` v2 (16 bytes, byte-aligned)
```
-Byte 0: [V:1][T:1][CodecID:4][Q:1][FecRatioHi:1]
-Byte 1: [FecRatioLo:6][unused:2]
-Bytes 2-3: sequence (u16 BE)
-Bytes 4-7: timestamp_ms (u32 BE)
-Byte 8: fec_block_id (u8)
-Byte 9: fec_symbol_idx (u8)
-Byte 10: reserved
-Byte 11: csrc_count
+Byte 0: version (u8) 0x02
+Byte 1: flags (u8) [T:1][Q:1][KeyFrame:1][FrameEnd:1][reserved:4]
+ T = FEC repair, Q = QualityReport trailer
+ KeyFrame = packet belongs to an I-frame (video)
+ FrameEnd = last packet of an access unit (video)
+Byte 2: media_type (u8) 0=audio, 1=video, 2=data, 3=control
+Byte 3: codec_id (u8) widened from 4-bit (room for 256 codec IDs)
+Byte 4: stream_id (u8) simulcast layer; 0=base
+Byte 5: fec_ratio (u8) 0..200 → 0.0..2.0
+Bytes 6-9: sequence (u32 BE) wrapping packet sequence number
+Bytes 10-13: timestamp_ms (u32 BE) milliseconds since session start
+Bytes 14-15: fec_block_id (u16 BE)
+ audio: low 8 bits = block_id, high 8 bits = symbol_idx
+ video: full u16 block_id (large blocks for I-frames)
```
-| Field | Bits | Description |
-|-------|------|-------------|
-| V (version) | 1 | Protocol version (0 = v1) |
-| T (is_repair) | 1 | 1 = FEC repair packet, 0 = source media |
-| CodecID | 4 | Codec identifier (0-8, see table below) |
-| Q | 1 | 1 = QualityReport trailer appended |
-| FecRatio | 7 | FEC ratio encoded as 0-127 mapping to 0.0-2.0 |
-| sequence | 16 | Wrapping packet sequence number |
-| timestamp_ms | 32 | Milliseconds since session start |
-| fec_block_id | 8 | FEC source block ID (wrapping) |
-| fec_symbol_idx | 8 | Symbol index within FEC block |
-| reserved | 8 | Reserved flags |
-| csrc_count | 8 | Contributing source count (future mixing) |
-
#### CodecID Values
+**Audio codecs (media_type = 0)**
+
| Value | Codec | Bitrate | Sample Rate | Frame Duration |
|-------|-------|---------|-------------|---------------|
| 0 | Opus 24k | 24 kbps | 48 kHz | 20ms |
@@ -365,15 +364,25 @@ Byte 11: csrc_count
| 7 | Opus 48k | 48 kbps | 48 kHz | 20ms |
| 8 | Opus 64k | 64 kbps | 48 kHz | 20ms |
-### MiniHeader (4 bytes, compressed)
+**Video codecs (media_type = 1)**
+
+| Value | Codec | Notes |
+|-------|-------|-------|
+| 9 | H.264 Baseline | Universal HW encode coverage |
+| 10 | H.264 Main | Slight quality win over baseline |
+| 11 | H.265 Main | Apple A10+, Snapdragon ~2017, NVENC GTX 9xx+; ~30% better than H.264 |
+| 12 | AV1 Main | Apple M3/A17+, Snapdragon 8 Gen 3+, RTX 40+; best efficiency, narrow HW |
+
+### `MiniHeader` v2 (5 bytes)
```
-[FRAME_TYPE_MINI: 0x01]
-Bytes 0-1: timestamp_delta_ms (u16 BE)
-Bytes 2-3: payload_len (u16 BE)
+[FRAME_TYPE_MINI = 0x01]
+Byte 0: seq_delta (u8) delta from last full header's seq
+Bytes 1-2: timestamp_delta_ms (u16 BE)
+Bytes 3-4: payload_len (u16 BE)
```
-Used for 49 of every 50 frames (~1s cycle). Saves 8 bytes per packet (67% header reduction). Full header is sent every 50th frame to resynchronize state.
+Used for audio only (49 of every 50 frames). Saves 11 bytes per audio packet vs the full 16B header. Full header is sent every 50th frame to resynchronize state. Video always uses full 16B headers.
### TrunkFrame (batched datagrams)
@@ -482,9 +491,12 @@ sequenceDiagram
### Shared State & Locking
+The `RoomManager` stores `DashMap>>`. The DashMap guard is held only long enough to clone the `Arc`; all per-room operations then acquire the room-level `RwLock`. Concurrent fan-out calls share a read lock; join/leave acquire write lock.
+
| Lock | Protected Data | Hold Duration | Contention |
|------|---------------|---------------|------------|
-| `RoomManager` (Mutex) | Rooms, participants, quality tiers | ~1ms/packet | O(N) per room |
+| `DashMap>>` | Room registry | Instant (clone Arc only) | Near-zero |
+| `Room` (RwLock) | Participants, quality tiers | ~1ms/packet (read); ~1ms (write on join/leave) | Low (concurrent reads) |
| `PresenceRegistry` (Mutex) | Fingerprint registrations | ~1ms | Low (join/leave only) |
| `SessionManager` (Mutex) | Active session tracking | ~1ms | Low |
| `FederationManager.peer_links` (Mutex) | Peer connections | ~10ms during forward | Per-federation-packet |
@@ -492,15 +504,9 @@ sequenceDiagram
### Scaling Characteristics
- **Many small rooms**: Scales well across all cores (rooms are independent)
-- **Large single room (100+ participants)**: Serialized by RoomManager lock
+- **Large single room (100+ participants)**: Fan-out reads share RwLock (non-blocking); only join/leave serializes
- **Federation**: Per-peer tasks scale; `peer_links` lock held during send loop
-### Primary Bottleneck
-
-The RoomManager Mutex is acquired per-packet by every participant to get the fan-out peer list. Lock is released before I/O (sends happen outside lock), but packet processing is serialized through the lock within a room.
-
-Future optimization: per-room locks or lock-free participant lists via `DashMap`.
-
## Client Architecture
### Desktop Engine (Tauri)
@@ -553,6 +559,8 @@ Key design decisions:
### Android Engine (Kotlin + JNI)
+> **Note (2026-05-12):** The Kotlin+JNI Android app (`android/app/`) described below is superseded by the **Tauri 2.x mobile build** (`desktop/src-tauri/` + `crates/wzp-native/`). The Tauri approach uses the same Rust call engine as desktop, with Oboe audio via `wzp-native` cdylib. The Kotlin codebase is maintained for reference but the Tauri build is the live production app.
+
```mermaid
graph TB
subgraph "Compose UI"
@@ -902,6 +910,20 @@ warzonePhone/
│ │ └── rekey.rs # Forward secrecy rekeying
│ ├── wzp-transport/ # QUIC transport layer
│ │ └── src/lib.rs # QuinnTransport, send/recv media/signal/trunk
+│ ├── wzp-video/ # Video codecs + framer
+│ │ └── src/
+│ │ ├── factory.rs # VideoEncoder factory (platform dispatch)
+│ │ ├── framer.rs # NAL fragmentation (H.264/H.265)
+│ │ ├── depacketizer.rs # NAL reassembly, access unit emit
+│ │ ├── controller.rs # VideoQualityController
+│ │ ├── simulcast.rs # Simulcast layer management
+│ │ ├── encoder_mode.rs # Encoder mode selection
+│ │ ├── av1_obu.rs # AV1 OBU framing + depacketizer
+│ │ ├── dav1d.rs # dav1d AV1 software decoder
+│ │ ├── svt_av1.rs # SVT-AV1 software encoder (non-Android)
+│ │ ├── videotoolbox.rs # VideoToolbox H.265 + AV1 (macOS)
+│ │ ├── mediacodec.rs # MediaCodec H.264/H.265/AV1 (Android, NDK 0.9 migration pending)
+│ │ └── nack.rs # NACK sender/receiver framework
│ ├── wzp-relay/ # Relay daemon
│ │ └── src/
│ │ ├── main.rs # CLI, connection loop, auth + handshake
@@ -917,6 +939,10 @@ warzonePhone/
│ │ ├── presence.rs # PresenceRegistry
│ │ ├── route.rs # RouteResolver
│ │ ├── trunk.rs # TrunkBatcher
+│ │ ├── audio_scorer.rs # Per-stream audio quality scoring
+│ │ ├── response_policy.rs # Relay response policy (rate-limit, drop)
+│ │ ├── verdict.rs # Verdict enum (Allow/RateLimit/Drop/Malicious)
+│ │ ├── video_scorer.rs # VideoScorer (legitimacy scoring, keyframe regularity)
│ │ └── ws.rs # WebSocket handler for browser clients
│ ├── wzp-client/ # Call engine + CLI
│ │ └── src/
@@ -956,7 +982,7 @@ warzonePhone/
## Test Coverage
-571 tests across all crates, 0 failures:
+702 tests across all crates (excluding wzp-android), 0 failures:
| Crate | Tests | Key Coverage |
|-------|-------|-------------|
@@ -965,7 +991,8 @@ warzonePhone/
| wzp-fec | 21 | RaptorQ encode/decode, loss recovery, interleaving |
| wzp-crypto | 64 | Encrypt/decrypt, handshake, anti-replay, featherChat identity |
| wzp-transport | 11 | QUIC connection setup, path monitoring |
-| wzp-relay | 122 | Room ACL, session mgmt, metrics, probes, mesh, trunking |
+| wzp-relay | 137 | Room ACL, session mgmt, metrics, probes, mesh, trunking, scoring, verdict |
+| wzp-video | 88 | NAL framing, AV1 OBU, simulcast, quality controller, NACK |
| wzp-client | 170 | Encoder/decoder, quality adapter, silence, drift, sweep |
| wzp-web | 2 | Metrics |
| wzp-native | 0 | Native platform bindings (no unit tests) |
diff --git a/docs/AUDIT-2026-05-25.md b/docs/AUDIT-2026-05-25.md
new file mode 100644
index 0000000..8c8ff03
--- /dev/null
+++ b/docs/AUDIT-2026-05-25.md
@@ -0,0 +1,231 @@
+# WarzonePhone Protocol Audit — 2026-05-25
+
+**Auditor:** Claude Sonnet 4.6 (assisted)
+**Branch:** `experimental-ui` @ `f3e3ee5`
+**Scope:** All workspace crates (`wzp-proto`, `wzp-codec`, `wzp-fec`, `wzp-crypto`, `wzp-transport`, `wzp-relay`, `wzp-client`, `wzp-android`, `wzp-native`, `wzp-video`)
+**Test baseline:** 702 passing (excludes `wzp-android`)
+
+---
+
+## Executive Summary
+
+The audio call path is functionally correct and cryptographically sound on clean network paths. **There is a session-breaking bug in the crypto nonce derivation (C1) that will cause a permanent decryption failure on any out-of-order UDP delivery.** This is the single highest-priority fix — it will manifest as periodic session crashes under normal internet conditions. Video has a solid architectural foundation but three hard blockers remain before shipping: the AEAD coverage gap (C2), dead video scorer (C3), and Android MediaCodec compile failure (C4).
+
+The project is in good shape overall. The crypto design (X25519, HKDF, ChaCha20-Poly1305, Ed25519 identity, SAS verification) is sound. The SFU-never-decrypts architecture is rare and valuable. The codec adaptation (Opus DRED + Codec2 RaptorQ split) is genuinely innovative. The eight issues below are fixable in ~12 engineer-hours.
+
+---
+
+## Critical
+
+### C1 — Nonce derives from `recv_seq` counter, not `MediaHeader.seq`
+
+**File:** `crates/wzp-crypto/src/session.rs:132`
+**Severity:** Critical — session-breaking on any packet reorder
+
+```rust
+// decrypt()
+let nonce_bytes = nonce::build_nonce(&self.session_id, self.recv_seq, Direction::Send);
+// ...
+self.recv_seq = self.recv_seq.wrapping_add(1); // line 148
+```
+
+`recv_seq` increments once per successful `decrypt()` call. The sender's `send_seq` also increments once per `encrypt()` call (line 120). In perfect in-order delivery they stay synchronized. With any reorder or mid-stream packet loss they permanently diverge. Once diverged, every subsequent packet uses the wrong nonce → AEAD tag mismatch → every packet fails for the rest of the session.
+
+This isn't a low-probability edge case. UDP over any internet path reorders packets routinely. The `multiple_packets_roundtrip` test (line 254) only exercises in-order delivery. HANDOFF-2026-05-12.md acknowledges this as a known latent item: *"AEAD nonce derivation: switch to `MediaHeader::seq`"*.
+
+The anti-replay check at lines 152–161 already parses `MediaHeader` and has `header.seq` available. The fix is one line in `decrypt()`:
+
+```rust
+// Use sender's wire-level seq as nonce input, not a local counter.
+// This survives reordering because both sides derive the same nonce from
+// the same field. recv_seq was wrong: it diverged from send_seq on any
+// reorder, breaking all subsequent decryptions for the session.
+let header = parse_header(header_bytes)
+ .ok_or_else(|| CryptoError::Internal("header parse failed".into()))?;
+let nonce_bytes = nonce::build_nonce(&self.session_id, header.seq, Direction::Send);
+```
+
+Remove `recv_seq` field from `ChaChaSession` (it's now redundant — anti-replay uses `header.seq` directly). On the encrypt side, verify that `self.send_seq` equals the `seq` written into the `MediaHeader` at the call site.
+
+**Estimated effort:** ~1 hour including test coverage for out-of-order delivery.
+
+> **Note on rekey seq reset:** The agent initially flagged `send_seq/recv_seq = 0` in `complete_rekey()` as a separate critical issue. This is a false positive — `install_key()` rotates `session_id` (hash of new key), so pre-/post-rekey nonces live in distinct namespaces. The reset is intentional and cryptographically safe.
+
+---
+
+### C2 — AEAD not wired to every QUIC datagram send path
+
+**File:** `crates/wzp-client/src/analyzer.rs:363` (only confirmed decrypt call site)
+**Severity:** Critical — potential plaintext media leakage
+
+The HANDOFF document explicitly flags this: *"Encryption is implemented in `wzp-crypto` but not yet on every QUIC datagram path."* The `analyzer.rs` path decrypts inbound packets. What needs verification: every outbound `send_datagram()` / `write_datagram()` call across `wzp-client` and `wzp-transport` must pass through `ChaChaSession::encrypt()`.
+
+**Required action:** Grep every `send_datagram` call site. Confirm each path encrypts before transmit. Add a CI-level test or `#[forbid(dead_code)]`-style assertion that makes a plaintext send path impossible to merge. Until this is verified, the E2E security claim cannot be made.
+
+**Estimated effort:** ~1 hour audit + test.
+
+---
+
+### C3 — `VideoScorer::observe()` never called — scorer is dead code
+
+**File:** `crates/wzp-relay/src/room.rs:1263–1266`
+**Severity:** Critical — relay abuse control for video is completely absent
+
+```rust
+// T6.2-follow-up: feed video packets to VideoScorer here.
+// video_scorer.observe(&pkt.header, pkt.payload.len(), now, bwe_kbps);
+```
+
+`video_scorer.rs` was delivered in T6.2 with legitimacy scoring, keyframe regularity checks, I/P ratio analysis, and a verdict enum. The observe call was never wired into the packet forwarding loop. The scorer compiles but accumulates no data. Any participant can flood the room with malformed video or synthetic keyframe bursts and the relay will forward everything without challenge.
+
+**Fix:** Wire `video_scorer.observe(...)` at the TODO marker and integrate `legitimacy_score()` into the forwarding decision (drop or rate-limit streams with `Verdict::Malicious`). Add an integration test: synthetic high-frequency keyframe bursts should trigger a `Malicious` verdict within 2 seconds.
+
+**Estimated effort:** ~2 hours.
+
+---
+
+### C4 — `wzp-video` Android target fails to compile (31 errors)
+
+**File:** `crates/wzp-video/src/mediacodec.rs`
+**Severity:** Critical — Android video is completely blocked
+
+Five error categories from the NDK 0.9 API migration, all documented in HANDOFF-2026-05-12.md. `dav1d`/`svt-av1` were cfg-gated off Android in `f3e3ee5`; these 31 errors are the remaining MediaCodec API mismatch.
+
+| Error | Count | Root cause | Fix |
+|---|---|---|---|
+| `E0277` `NonNull` not `Send` | ~3 | Raw pointer held across `tokio::spawn` boundary | `struct SendMediaCodec(NonNull<…>); unsafe impl Send for SendMediaCodec {}` — or use `ndk::media::MediaCodec` owned type (already `Send`) |
+| `E0308` `&[MaybeUninit]` vs `&[u8]` | many | NDK 0.9 returns uninit slices | `MaybeUninit::write_slice` or transmute pattern |
+| `E0425` missing `BITRATE_MODE_CBR` | 1+ | Constant renamed in NDK 0.9 | Check `ndk` crate docs for current name |
+| `E0433` `ndk_sys` not a dep | several | Direct `ndk_sys` import; only `ndk = "0.9"` declared | Add `ndk-sys` as explicit dep or use safe `ndk` wrappers |
+| `E0599` `InputBuffer::index()` / `OutputBuffer::index()` private | 2 | API changed in NDK 0.9 | Use buffer through safe queue/dequeue API |
+
+Nothing live is blocked today — `wzp-video` is not yet consumed by Tauri Android. But video on Android cannot progress until this compiles.
+
+**Reproduce:**
+```bash
+ssh -i ~/CascadeProjects/wzp manwe@manwehs \
+ 'cd ~/wzp-builder/data/source && \
+ docker run --rm \
+ -v ~/wzp-builder/data/source:/build/source \
+ -v ~/wzp-builder/data/cache/cargo-registry:/home/builder/.cargo/registry \
+ -v ~/wzp-builder/data/cache/cargo-git:/home/builder/.cargo/git \
+ -v ~/wzp-builder/data/cache/target:/build/source/target \
+ wzp-android-builder:latest \
+ bash -c "cd /build/source && cargo build --target aarch64-linux-android -p wzp-video 2>&1 | tail -60"'
+```
+
+**Estimated effort:** ~2 hours (one commit per error category).
+
+---
+
+## High
+
+### H1 — AV1 call engine wiring missing
+
+**Source:** HANDOFF-2026-05-12.md (T6.1.2 open item)
+**File:** `crates/wzp-video/src/factory.rs`
+
+`factory.rs` and step tables landed in commit `086d0a4`. No caller yet invokes `create_video_encoder(Av1Main, ...)`. The entire AV1 path is reachable only from tests. Video on macOS/Linux desktop requires wiring `create_video_encoder` into the call engine's media negotiation path.
+
+**Estimated effort:** ~1–2 hours.
+
+---
+
+### H2 — `fec_block_id: u8` wraps every ~25 seconds
+
+**File:** `crates/wzp-fec/src/encoder.rs` (`block_id.wrapping_add(1)` on u8)
+**Reference:** PROTOCOL-AUDIT.md W2 (deferred P2)
+
+At 5 frames/block (Codec2), u8 ID wraps at block 256 ≈ 25 seconds. A slow reconstructor or late-joining peer will collide block IDs with in-flight blocks. The window distance check in `block_manager.rs` partially mitigates this but can't prevent all collisions. Widen to `u16` in the next wire-format revision.
+
+---
+
+## Medium
+
+### M1 — `SignalMessage` has no version byte
+
+**File:** `crates/wzp-proto/src/session.rs` (SignalMessage enum)
+**Reference:** PROTOCOL-AUDIT.md W12
+
+`bincode + serde(default)` handles field additions but not variant removal or semantic changes. Any variant deprecation is silent at the wire level. This becomes a correctness risk when federation routes `SignalMessage`s across relay versions. Add `version: u8` as a leading field to all variants before federation ships.
+
+---
+
+### M2 — BWE not consumed by `AdaptiveQualityController`
+
+**Reference:** PROTOCOL-AUDIT.md W6, deferred to Phase V2
+
+Quinn exposes `cwnd` and `bytes_in_flight`, but `AdaptiveQualityController` does not consume them. Loss + RTT adaptation works for audio. For video, without bandwidth estimation the encoder cannot detect available uplink capacity and will either oscillate or permanently under-utilize bandwidth. Mandatory before video production.
+
+---
+
+### M3 — PLI suppression window hardcoded at 200ms
+
+**File:** `crates/wzp-relay/src/room.rs:1060`
+
+Not adaptive to link speed. On slow links 200ms may allow multiple keyframe requests. Accept for Phase 1; make configurable in Phase 2.
+
+---
+
+### M4 — Repair packet index wrapping in FEC encoder
+
+**File:** `crates/wzp-fec/src/encoder.rs:140`
+
+```rust
+let idx = (num_source as u8).wrapping_add(i as u8);
+```
+
+If `num_source + repair_count > 255`, indices wrap silently. In practice bounded by `frames_per_block` (5–10), so max sum is ~20. Low risk today; widen to u16 when `fec_block_id` is widened (H2).
+
+---
+
+### M5 — `timestamp_ms` monotonicity after rekey not enforced
+
+**Reference:** PROTOCOL-AUDIT.md W3
+
+Spec: `timestamp_ms` must not reset on rekey. The code correctly does not reset it, but there is no assertion to prevent regression. Add a debug assert in `complete_rekey()` that `new_session.next_timestamp >= old_session.last_timestamp`.
+
+---
+
+## Low / Accepted Debt
+
+| ID | Description | File | Accepted in |
+|---|---|---|---|
+| L1 | 9 pre-existing clippy lints in `wzp-codec` | `aec.rs`, `denoise.rs`, `opus_enc.rs`, `codec2_{enc,dec}.rs`, `resample.rs` | PROTOCOL-AUDIT.md |
+| L2 | 3 clippy errors in `deps/featherchat` submodule | `ratchet.rs`, `types.rs` | PROTOCOL-AUDIT.md |
+| L3 | Audio anti-replay window 64 packets | `wzp-crypto/src/session.rs:89` | Accepted — jitter buffer + PLC masks loss |
+| L4 | Debug tap logs at INFO with no rate limiting | `wzp-relay/src/room.rs:46–59` | Safe in dev; add 1:100 sampling for prod |
+
+---
+
+## What Was Not Found
+
+These are explicitly confirmed sound after code-level verification:
+
+- **Anti-replay bitmap** — correct u32 wrapping, per-stream isolation, window sizing by `MediaType`
+- **HKDF + X25519 + Ed25519 key agreement** — standard construction, no gaps
+- **SAS code derivation** — SHA-256(shared_secret)[:4] as 4-digit voice verification code
+- **Rekey forward secrecy** — `session_id` rotation on rekey isolates nonce namespaces; seq counter reset is intentional and safe
+- **MiniHeader v2 `seq_delta`** — fully implemented at `wzp-proto/src/packet.rs:469–526` with tests; PROTOCOL-AUDIT resolution table is accurate
+- **SFU E2E preservation** — relay ciphertext passthrough, no plaintext access
+- **RaptorQ for Codec2** — correct tool for the bitrate regime
+- **DRED continuous tuning** — better than discrete tiers; 15% loss floor is empirically grounded
+- **Jitter buffer** — BTreeMap with wrapping-aware comparisons, EWMA adaptive playout delay, solid
+- **Quinn QUIC datagram transport** — correct primitives for unreliable media
+
+---
+
+## Fix Priority Table
+
+| # | Issue | Category | Effort | Blocks |
+|---|---|---|---|---|
+| 1 | C1: nonce → `MediaHeader.seq` | Crypto | 1h | All sessions on lossy paths |
+| 2 | C2: verify AEAD on all datagram send paths | Crypto | 1h | E2E security claim |
+| 3 | C3: wire `VideoScorer::observe()` into room | Relay | 2h | Relay abuse control for video |
+| 4 | C4: NDK 0.9 `mediacodec.rs` migration (5 categories) | Android | 2h | Android video |
+| 5 | H1: wire AV1 factory into call engine | Video | 2h | Desktop video |
+| 6 | H2: widen `fec_block_id` to `u16` | FEC/Wire | 30min | Next protocol release |
+| 7 | M1: `SignalMessage` version byte | Proto | 1h | Federation correctness |
+| 8 | M2: BWE into `AdaptiveQualityController` | Transport | 2–3 days | Video production quality |
+
+**Total for C1–H1 (items 1–5):** ~8 hours focused engineering.
diff --git a/docs/HANDOFF-2026-05-12.md b/docs/HANDOFF-2026-05-12.md
new file mode 100644
index 0000000..5b6ccee
--- /dev/null
+++ b/docs/HANDOFF-2026-05-12.md
@@ -0,0 +1,166 @@
+# Handoff — 2026-05-12 EOD
+
+## TL;DR
+
+Wave 5 (Phase 5) and Wave 6 (Phase 6) implementation is complete and approved on the board. Stopping for the night with one open issue: `wzp-video` does not target-compile for `aarch64-linux-android` and needs a focused `ndk = "0.9"` API migration session (~1–2 h). Nothing live is blocked — Tauri Android does not yet consume `wzp-video`.
+
+**Branch state:** local `experimental-ui` HEAD `f3e3ee5`, pushed to `github` only. **Not yet on `fj`** (deploy key was read-only). Build server (`manwe@manwehs`) is up to date via github fetch.
+
+---
+
+## What landed today
+
+| Wave | Tasks approved | New crates / files | Test delta |
+|---|---|---|---|
+| 5 | T5.1, T5.1.1, T5.2, T5.3, T5.4, T5.5, T5.6, T5.7, T5.7.1, T5.8 | `crates/wzp-relay/src/audio_scorer.rs`, `response_policy.rs`, `verdict.rs`; `wzp-video/src/controller.rs`, `simulcast.rs`, `encoder_mode.rs`; H.265 path in VT + MediaCodec | wzp-relay 99→127, wzp-video 43→71 |
+| 6 | T6.1 (+ rework), T6.1.2, T6.2 | `wzp-video/src/av1_obu.rs`, `dav1d.rs`, `svt_av1.rs`, `factory.rs`; VT AV1 decoder; MediaCodec AV1; `wzp-relay/src/video_scorer.rs` | wzp-video 76→88, wzp-relay 127→137 |
+
+Total: ~30 task units approved across the two waves. Workspace tests at 702 passing (excluding `wzp-android`).
+
+---
+
+## Open / next-up
+
+### Top of queue
+
+- **T4.3.1.1 (deferred → in-progress, blocked)** — Android target-compile of `wzp-video`. We started this tonight and hit 31 errors in `crates/wzp-video/src/mediacodec.rs` against the actual `ndk = "0.9"` API. Error categories captured below; resume with one fix-per-category commit, then attempt device instrumentation.
+- **T6.3 — federated reputation gossip.** Design exploration committed (`1e729e4`, `docs/PRD/PRD-relay-federation-gossip.md`). **Decision made: Approach 3 (Ban-List Distribution).** My answers to the 6 blocker questions are in the chat thread, awaiting conversion to a real Files/Steps/Verify/Done-when task spec for the agent. The user opted not to run the agent immediately; the task spec is a write-then-park.
+- **T5.1.1 follow-ups** — none. T5.1.1 closed clean.
+
+### Latent follow-ups from earlier waves
+
+These pre-date wave 6 and are still open:
+
+- **AEAD wired into prod send/recv path** (referenced in T1.5 / T1.6 reports). Encryption is implemented in `wzp-crypto` but not yet on every QUIC datagram path.
+- **AEAD nonce derivation: switch to `MediaHeader::seq`** (cited in T1.5.x reports). Current scheme works but isn't tied to wire-level seq.
+- **`wzp-codec` clippy debt sprint** — 9 errors documented as known debt in `docs/PROTOCOL-AUDIT.md`.
+- **T6.1.2 — wire AV1 into actual call engine.** The factory + step tables landed (commit `086d0a4`); no caller invokes `create_video_encoder(Av1Main, …)` yet. Real video sender wiring (the originally-blocked task) is unstarted.
+- **T6.2-follow-up — wire `VideoScorer::observe()` into the packet path.** TODO marker at `crates/wzp-relay/src/room.rs:1263`.
+
+### Permanently deferred
+
+- **T6.1.1 — Android MediaCodec AV1 device validation.** Deferred indefinitely: the user does not own an AV1-encode-capable Android or iPhone, and AV1 hardware will not be widespread for years. Revisit when devices land.
+
+---
+
+## The T4.3.1.1 Android build situation
+
+What we did tonight:
+
+1. Pushed `experimental-ui` to `github` (deploy key on `fj` is read-only).
+2. Added `github` as a remote on `manwe@manwehs:~/wzp-builder/data/source/` and checked out `experimental-ui`.
+3. Ran `cargo build --target aarch64-linux-android -p wzp-video` inside the `wzp-android-builder:latest` docker image.
+4. First failure: `shiguredo_dav1d` and `shiguredo_svt_av1` build scripts panic with `unsupported target: os=android, arch=aarch64`. Fixed in commit `f3e3ee5` (`fix(wzp-video): cfg-gate dav1d + svt-av1 off Android target`) — those crates now live under `[target.'cfg(not(target_os = "android"))'.dependencies]`, since Android uses MediaCodec for AV1 anyway.
+5. Re-ran the build → 31 errors in `mediacodec.rs`. **Stopped here.**
+
+### Error categories to fix tomorrow
+
+Run the same docker invocation and tackle these one fix-commit per category:
+
+| Error | Count | Root cause | Likely fix |
+|---|---|---|---|
+| `E0277` `NonNull` not `Send` | ~3 | Raw pointer field on a struct held across `tokio::spawn`-able boundaries | Wrap in `struct SendMediaCodec(NonNull<…>); unsafe impl Send for SendMediaCodec {}` or use the `ndk` crate's owned `MediaCodec` type which already implements `Send` |
+| `E0308` `&[MaybeUninit]` vs `&[u8]` | many | `ndk 0.9` returns uninitialized buffer slices; agent wrote into them as if initialized | Use `MaybeUninit::write_slice` or transmute pattern; pattern matches what `InputBuffer::write` expects |
+| `E0425` missing `BITRATE_MODE_CBR` | 1+ | Constant moved/renamed in `ndk 0.9` | Search `ndk` crate docs for current constant name (likely under `MediaCodec::set_parameters` enum) |
+| `E0433` `ndk_sys` not linked | several | Agent imported `ndk_sys` directly; it's not a dep, only `ndk = "0.9"` is | Replace direct `ndk_sys` calls with safe wrappers from the `ndk` crate, or add `ndk_sys` as an explicit dep |
+| `E0599` `InputBuffer::index()` / `OutputBuffer::index()` private | 2 | Both are private fields in `ndk 0.9`; were public methods in older versions | Either use the buffer through its safe API (queue/dequeue by handle) or expose index via a different accessor — read the `ndk` source for current API |
+
+### Reproduce the build
+
+```bash
+ssh -i ~/CascadeProjects/wzp manwe@manwehs \
+ 'cd ~/wzp-builder/data/source && \
+ docker run --rm \
+ -v ~/wzp-builder/data/source:/build/source \
+ -v ~/wzp-builder/data/cache/cargo-registry:/home/builder/.cargo/registry \
+ -v ~/wzp-builder/data/cache/cargo-git:/home/builder/.cargo/git \
+ -v ~/wzp-builder/data/cache/target:/build/source/target \
+ wzp-android-builder:latest \
+ bash -c "cd /build/source && cargo build --target aarch64-linux-android -p wzp-video 2>&1 | tail -100"'
+```
+
+After local fixes:
+
+```bash
+git push github experimental-ui && \
+ssh -i ~/CascadeProjects/wzp manwe@manwehs \
+ 'cd ~/wzp-builder/data/source && git fetch github && git reset --hard github/experimental-ui'
+# then re-run the docker build
+```
+
+### Device instrumentation half (post-compile)
+
+User has a physical Android device. Once `cargo build --target aarch64-linux-android -p wzp-video` is clean:
+
+- Build a minimal test harness binary (probably under `wzp-video/examples/` or a new `wzp-android-test/` crate) that does encode → decode of a synthetic frame via MediaCodec.
+- Use `adb push` and `adb shell run` to exercise it.
+- Compare output bytes against the dav1d/SVT-AV1 SW roundtrip from `crates/wzp-video/src/svt_av1.rs:101 svt_av1_dav1d_roundtrip_10_frames`.
+
+Out of scope for tomorrow if the API migration eats the whole session.
+
+---
+
+## T6.3 — Approach 3 decision
+
+User picked Approach 3 (Ban-List Distribution) from `docs/PRD/PRD-relay-federation-gossip.md`. My answers to the 6 open questions:
+
+1. **Trust model:** Single admin key (user). Strongest Sybil resistance, lowest complexity.
+2. **Key infra:** Reuse `wzp-crypto` Ed25519. Admin pubkey in relay config; relays verify list signatures.
+3. **Fingerprint scope:** Ed25519 pubkey, not IP. Resistant to NAT rebind evasion.
+4. **Privacy:** Publish `SHA-256(pubkey)` hashes, not raw pubkeys. Relays compute `H(observed)` and match. 256-bit space makes brute-force infeasible; loses some audit trail.
+5. **TTL:** 30-day per-entry auto-expiry. Forces ops to actively re-publish persistent bans; prevents forever-by-mistake.
+6. **Rate limiting:** N/A under Approach 3 (no gossip channel; relays poll a signed list at configurable interval, that interval is the rate limit).
+
+Next step: turn these into a Files/Steps/Verify/Done-when task spec in `docs/PRD/TASKS.md` and move T6.3 from `Blocked` → `Open` ready for the agent to claim. User did not want this kicked off tonight.
+
+---
+
+## Build / sync state
+
+| Location | Branch | HEAD |
+|---|---|---|
+| Local (Mac) | `experimental-ui` | `f3e3ee5 fix(wzp-video): cfg-gate dav1d + svt-av1 off Android target` |
+| `github` remote | `experimental-ui` | `f3e3ee5` (pushed) |
+| `fj` remote | `experimental-ui` | **not pushed** (deploy key read-only on `fj`) |
+| `origin` (git.manko.yoga) | `experimental-ui` | **not pushed** |
+| Build server `~/wzp-builder/data/source` | `experimental-ui` | `f3e3ee5` |
+
+If you want everything on `fj` / `origin` too, get the deploy key write-privileged or push from a different identity.
+
+`fj/main` and `github/main` have one commit (`9ae9441 fix(audio): check capture ring available...`) that doesn't exist on `experimental-ui` — a small audio fix from May 11. Cherry-pick or merge before merging `experimental-ui` back into `main`.
+
+### Gitleaks allowlist
+
+Added `.gitleaks.toml` in commit `f28f39d` to allowlist 4 pre-existing historical findings. Two are real tokens (paste.tbs.amn.gg and paste.dk.manko.yoga `Authorization` headers in `scripts/build*.sh`). **Rotate those tokens if those endpoints still authenticate** — the allowlist only silences the pre-push hook; the secrets are still in git history.
+
+---
+
+## Agent process notes for tomorrow
+
+The Kimi Code CLI agent on this project has a **stable, well-documented fabrication tic** — one verifiable detail per report is wrong (SHA, "updated X in same commit", fmt/clippy passes, etc.). Pattern survived an explicit CR on T6.1.
+
+**Updated policy** (in `memory/feedback_kimi_report_fabrication.md`):
+
+1. **Always verify the SHA** in the report header against `git log`.
+2. **Always run** `cargo fmt --check` and `cargo clippy -- -D warnings` yourself — don't trust the report's claims.
+3. **Don't CR fabrications anymore** — the T6.1 CR didn't change the behavior. Reviewer-fix the detail, note on the board, move on. Reserve CRs for substance issues.
+
+The substance of the code has been consistently good. Don't let the fabrication tic bias review of the code itself.
+
+### Rebase tic
+
+Agent has twice rewritten already-pushed commits to address CR feedback (T5.7.1 `d3b2da6` → `517d0eb`; T6.1 `0de9522` → `9334aa5`). Forward fix commits are the rule; rebasing wasn't asked for and breaks reviewer references. Mention this only if it happens a third time.
+
+---
+
+## Tomorrow's suggested checklist
+
+1. **(20 min)** Read this doc, the `feedback_kimi_report_fabrication.md` memory, and the T6.1 / T6.2 / T6.1.2 board rows on `docs/PRD/TASKS.md` to reload context.
+2. **(1–2 h)** Resume T4.3.1.1: ndk-0.9 API migration in `crates/wzp-video/src/mediacodec.rs`. One commit per error category.
+3. **(30 min)** If migration lands clean, attempt the minimal device test on the user's Android phone.
+4. **(20 min, optional)** Convert the T6.3 design answers into a task spec block in `TASKS.md`, leave it `Open` for the agent. Don't kick off the agent unless asked.
+5. **(parking lot)** AEAD prod wiring + nonce switch + wzp-codec clippy sprint — none urgent.
+
+---
+
+*Generated 2026-05-12, end of Wave 6 push.*
diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md
index b2a4a6b..867a28c 100644
--- a/docs/PROGRESS.md
+++ b/docs/PROGRESS.md
@@ -389,3 +389,107 @@ Run with `wzp-bench --all`. Representative results (Apple M-series, single core)
- `RegisterPresenceAck` populates `relay_region` from config, `available_relays` from federation peers
- Desktop `place_call`/`answer_call` call `acquire_port_mapping()` and fill mapped addr fields
- Legacy `build-android-docker.sh` renamed to `build-android-docker-LEGACY.sh` to prevent accidental use
+
+## Wave 5: Video Infrastructure (2026-05-12)
+
+**Tasks completed:** T5.1, T5.1.1, T5.2, T5.3, T5.4, T5.5, T5.6, T5.7, T5.7.1, T5.8
+
+### Relay: Audio + Video Scoring
+
+New files in `crates/wzp-relay/src/`:
+
+- `audio_scorer.rs` — per-stream audio quality scorer tracking packet loss, codec consistency, bitrate stability
+- `response_policy.rs` — relay response policy engine mapping scores to action thresholds
+- `verdict.rs` — `Verdict` enum: `Allow`, `RateLimit`, `Drop`, `Malicious`
+- `video_scorer.rs` — `VideoScorer` with legitimacy scoring: keyframe regularity, I/P ratio, bandwidth responsiveness. **Note: wired but `observe()` not yet called from room forwarding path — T6.2 follow-up open.**
+
+### Video: H.265 + Quality Controller
+
+New files in `crates/wzp-video/src/`:
+
+- `controller.rs` — `VideoQualityController`: maps (bwe_bps, loss_pct, rtt_ms, priority_mode) to (target_bitrate, target_fps, target_resolution, simulcast_layer)
+- `simulcast.rs` — simulcast layer management (base + enhancement layers)
+- `encoder_mode.rs` — encoder mode selection (CBR/VBR, keyframe intervals, quality presets)
+
+H.265 encode/decode path added to:
+- `videotoolbox.rs` — VideoToolbox H.265 encoder + decoder (macOS/iOS)
+- `mediacodec.rs` — MediaCodec H.265 encoder + decoder (Android; NDK 0.9 compile errors pending in T4.3.1.1)
+
+**Test delta:** wzp-relay 99→127, wzp-video 43→71
+
+---
+
+## Wave 6: AV1 + Federation Gossip Design (2026-05-12)
+
+**Tasks completed:** T6.1, T6.1.2, T6.2
+
+### Video: AV1 Codec Support
+
+New files in `crates/wzp-video/src/`:
+
+- `av1_obu.rs` — AV1 OBU (Open Bitstream Unit) framing and depacketizer
+- `dav1d.rs` — dav1d AV1 software decoder (non-Android; gated via cfg)
+- `svt_av1.rs` — SVT-AV1 software encoder (non-Android; gated via cfg)
+
+Updated files:
+- `videotoolbox.rs` — VideoToolbox AV1 decoder + encoder (macOS M3+, iOS A17+)
+- `mediacodec.rs` — MediaCodec AV1 (Android; compile errors pending)
+- `factory.rs` — `create_video_encoder(codec, platform)` dispatcher added; H.264, H.265, AV1 wired
+
+**T6.1.2 follow-up open:** `create_video_encoder(Av1Main, ...)` has no caller in the call engine yet — wiring step is unstarted.
+
+### Relay: Federation Reputation Gossip (Design Phase)
+
+- T6.3 design exploration committed at `1e729e4`
+- `docs/PRD/PRD-relay-federation-gossip.md` — Ban-List Distribution approach selected (Approach 3)
+- Implementation not started; task spec pending conversion
+
+### Test Counts
+
+**Test delta Wave 6:** wzp-video 76→88, wzp-relay 127→137
+
+**Total workspace tests: 702** (excluding `wzp-android`)
+
+| Crate | Tests |
+|---|---|
+| wzp-proto | 112 |
+| wzp-codec | 69 |
+| wzp-fec | 21 |
+| wzp-crypto | 64 |
+| wzp-transport | 11 |
+| wzp-relay | 137 |
+| wzp-client | 200 |
+| wzp-video | 88 |
+| wzp-web | 2 |
+| wzp-native | 0 |
+
+---
+
+## Current Status (2026-05-25)
+
+### What Works (Audio)
+
+All audio path items from previous status section remain working. Additionally:
+
+- MediaHeader v2 (16 bytes) deployed across all paths
+- MiniHeader v2 (5 bytes with seq_delta) deployed
+- Anti-replay windows per stream with media-type-aware sizing (audio 64, video 1024)
+- Relay DashMap + RwLock concurrency model (T3.1 resolved the Mutex bottleneck)
+
+### What Works (Video — partial)
+
+- H.264 framer/depacketizer with FU-A fragmentation handling
+- H.264, H.265, AV1 VideoToolbox encode/decode (macOS)
+- AV1 dav1d + SVT-AV1 software path (non-Android)
+- Video quality controller, simulcast, encoder mode selection (controller only; no active call wiring yet)
+- Video scorer (scoring logic complete; not yet wired into relay forwarding)
+- NACK framework (`nack.rs`; not yet wired into room forwarding)
+
+### Open Blockers
+
+- **Android video:** `mediacodec.rs` has 31 NDK 0.9 compile errors (T4.3.1.1 in progress)
+- **AV1 call wiring:** `create_video_encoder(Av1Main, ...)` has no caller (T6.1.2 follow-up)
+- **VideoScorer wiring:** `VideoScorer::observe()` commented out at `room.rs:1263` (T6.2 follow-up)
+- **NACK wiring:** NACK path not wired into room forwarding (Phase V2/V4)
+- **BWE:** `AdaptiveQualityController` does not consume `cwnd`/`bytes_in_flight` (Phase V2)
+- **Crypto nonce bug:** `decrypt()` uses `recv_seq` instead of `MediaHeader.seq` (see AUDIT-2026-05-25.md C1)
diff --git a/docs/ROAD-TO-VIDEO.md b/docs/ROAD-TO-VIDEO.md
index 1ea1a08..d373eb9 100644
--- a/docs/ROAD-TO-VIDEO.md
+++ b/docs/ROAD-TO-VIDEO.md
@@ -12,6 +12,36 @@ The transport, crypto, session, federation, and SFU layers are codec-agnostic. T
4. Keyframe semantics (PLI, NACK, keyframe cache at SFU)
5. Capture / encode pipeline (VideoToolbox / MediaCodec / NVENC)
+## Implementation Status (as of 2026-05-25)
+
+| Phase | Description | Status |
+|---|---|---|
+| V1 — Wire format | 16B MediaHeader v2, 5B MiniHeader v2, MediaType, u32 seq, 8-bit CodecID | ✅ Complete (T1.x) |
+| V2 — Transport additions | BWE, NACK loop, TransportFeedback, dynamic FEC boost on I-frames | 🔲 Not started |
+| V3 — `wzp-video` crate | H.264 baseline framer/depacketizer, VideoToolbox/MediaCodec/dav1d encoders | ✅ Substantially complete (T4.x, T5.x, T6.x) |
+| V3 — H.264 Baseline | Single-layer H.264 | ✅ Complete |
+| V3 — H.265 | VideoToolbox + MediaCodec H.265 | ✅ Complete (T5.x) |
+| V3 — AV1 | dav1d + SVT-AV1 (non-Android), VideoToolbox AV1 (macOS M3+) | ✅ Complete; Android MediaCodec AV1 compile errors pending (T4.3.1.1) |
+| V3 — Android MediaCodec | NDK 0.9 API migration for `mediacodec.rs` | 🔴 Blocked (31 compile errors) |
+| V3 — Call engine wiring | `create_video_encoder()` integrated into active call negotiation | 🔴 Not started (T6.1.2 follow-up) |
+| V4 — Keyframe & loss policy | NACK path, PLI, keyframe cache at SFU | 🟡 Framework present (`nack.rs`); not wired |
+| V5 — Video adaptive controller | `VideoQualityController` + `PriorityMode` | 🟡 Controller built (`controller.rs`); not wired into call |
+| V5 — Simulcast | Simulcast layer management | 🟡 `simulcast.rs` present; not wired |
+| V6 — SFU changes | Keyframe cache, per-receiver layer selection, PLI suppression | 🟡 PLI suppression wired; keyframe cache + layer selection not started |
+| V6 — Video scorer | `VideoScorer` legitimacy detection | 🟡 Built (`video_scorer.rs`); `observe()` not wired into room forwarding |
+| V7 — Capture pipeline | Camera capture (AVCaptureSession, Camera2, NVENC) | 🔲 Not started |
+
+**Legend:** ✅ Complete · 🟡 Partial/Framework only · 🔴 Blocked · 🔲 Not started
+
+### Critical path to first video call
+
+1. Fix Android MediaCodec compile errors (T4.3.1.1) — ~2h
+2. Wire `create_video_encoder()` into call engine codec negotiation (T6.1.2) — ~2h
+3. Fix crypto nonce bug (`decrypt()` must use `MediaHeader.seq`) — see `AUDIT-2026-05-25.md` C1 — ~1h
+4. Wire `VideoScorer::observe()` into relay room forwarding (T6.2 follow-up) — ~2h
+5. Implement Phase V2 BWE (mandatory for usable video) — ~3–4 days
+6. Implement capture pipeline for at least one platform (V7) — ~1 week
+
## Phase V1 — Wire format & negotiation (no new code paths yet)
Bump protocol version. Land all wire changes together so compat breaks exactly once.
diff --git a/docs/WZP-SPEC.md b/docs/WZP-SPEC.md
index 88b1821..816eb8c 100644
--- a/docs/WZP-SPEC.md
+++ b/docs/WZP-SPEC.md
@@ -2,7 +2,7 @@
> Distilled from `docs/ARCHITECTURE.md` and the `wzp-proto` crate. Authoritative wire details live in `crates/wzp-proto/src/packet.rs`.
>
-> **Status:** v1 (audio-only) is the deployed protocol. v2 (audio + video, 16 B header, MediaType, u32 seq, etc.) is specified in `ROAD-TO-VIDEO.md` Phase V1 and supersedes this document when implemented.
+> **Status:** v2 is the deployed protocol (audio + video, 16 B header, MediaType, u32 seq). v1 clients are rejected with `Hangup::ProtocolVersionMismatch`.
## Layer summary
@@ -16,42 +16,47 @@
| Loss recovery | **RaptorQ FEC + Opus DRED + classical PLC** | NACK / PLI + reference-picture selection |
| Adaptive | 3-tier hysteresis (Good / Degraded / Catastrophic) + continuous DRED tuner | Per-frame bitrate ladder |
| Topology | SFU rooms + inter-relay federation + P2P via ICE | Mesh ≤ ~3, SFU above, Apple relays |
-| Header | 12 B `MediaHeader` / 4 B `MiniHeader` (49 of 50), 4 B `QualityReport` trailer | RTP 12 B + extensions |
+| Header | 16 B `MediaHeader` v2 / 5 B `MiniHeader` (49 of 50), 4 B `QualityReport` trailer | RTP 12 B + extensions |
## Distinctive choices
- **QUIC datagrams instead of raw UDP + SRTP.** Brings TLS 1.3, PLPMTUD, path migration, and ACK-based RTT/loss estimation for free.
- **Continuous DRED tuning.** Maps live `(loss%, RTT, jitter)` to a continuous Opus DRED lookback window. Most stacks treat DRED as discrete tiers.
-- **MiniHeader (4 B for 49/50 packets).** Saves ~8 B/packet ≈ 400 B/s/stream at 50 pps.
+- **MiniHeader (5 B for 49/50 packets).** Saves ~11 B/packet ≈ 550 B/s/stream at 50 pps vs. the full 16 B header.
- **E2E-preserving SFU.** The relay forwards encrypted datagrams; it never decrypts media. Room membership uses SNI = `hash(room_name)`.
- **Codec coordination via `QualityReport` trailer.** Receivers attach 4-byte loss/RTT/jitter/cap to media packets; the SFU broadcasts `QualityDirective` so all senders in a room converge on the same tier.
-## Wire format (current — v1)
+## Wire format (current — v2)
-### `MediaHeader` (12 bytes)
+### `MediaHeader` v2 (16 bytes, byte-aligned)
```
-Byte 0: [V:1][T:1][CodecID:4][Q:1][FecRatioHi:1]
-Byte 1: [FecRatioLo:6][unused:2]
-Bytes 2-3: sequence (u16 BE)
-Bytes 4-7: timestamp_ms (u32 BE)
-Byte 8: fec_block_id (u8)
-Byte 9: fec_symbol_idx (u8)
-Byte 10: reserved
-Byte 11: csrc_count
+Byte 0: version (u8) 0x02
+Byte 1: flags (u8) [T:1][Q:1][KeyFrame:1][FrameEnd:1][reserved:4]
+Byte 2: media_type (u8) 0=audio, 1=video, 2=data, 3=control
+Byte 3: codec_id (u8) 0-255 (see codec table)
+Byte 4: stream_id (u8) simulcast layer; 0=base
+Byte 5: fec_ratio (u8) 0..200 → 0.0..2.0
+Bytes 6-9: sequence (u32 BE)
+Bytes 10-13: timestamp_ms (u32 BE)
+Bytes 14-15: fec_block_id (u16 BE)
```
| Field | Bits | Meaning |
|---|---|---|
-| V | 1 | Protocol version |
-| T | 1 | 1 = FEC repair packet |
-| CodecID | 4 | See codec table |
-| Q | 1 | QualityReport trailer present |
-| FecRatio | 7 | 0–127 → 0.0–2.0 |
-| sequence | 16 | Wrapping packet seq |
+| version | 8 | Must be `0x02`; v1 clients receive `Hangup::ProtocolVersionMismatch` |
+| T (bit 7 of flags) | 1 | 1 = FEC repair packet |
+| Q (bit 6 of flags) | 1 | QualityReport trailer present |
+| KeyFrame (bit 5 of flags) | 1 | Packet belongs to a video I-frame |
+| FrameEnd (bit 4 of flags) | 1 | Last packet of an access unit |
+| reserved (bits 3-0 of flags) | 4 | Must be zero |
+| media_type | 8 | 0=audio, 1=video, 2=data, 3=control |
+| codec_id | 8 | See codec table (widened from v1's 4-bit field) |
+| stream_id | 8 | Simulcast layer; 0=base layer |
+| fec_ratio | 8 | 0..200 → 0.0..2.0 |
+| sequence | 32 | Monotonically increasing packet seq (not reset by rekey) |
| timestamp_ms | 32 | ms since session start. Monotonic across the full session; **not reset by rekey** |
-| fec_block_id | 8 | FEC source block ID |
-| fec_symbol_idx | 8 | Symbol index in block |
+| fec_block_id | 16 | FEC source block ID |
### Codec table
@@ -66,13 +71,18 @@ Byte 11: csrc_count
| 6 | Opus 32k | 32 kbps | 48 kHz | 20 ms |
| 7 | Opus 48k | 48 kbps | 48 kHz | 20 ms |
| 8 | Opus 64k | 64 kbps | 48 kHz | 20 ms |
+| 9 | H.264 Baseline | — | — | — |
+| 10 | H.264 Main | — | — | — |
+| 11 | H.265 Main | — | — | — |
+| 12 | AV1 Main | — | — | — |
-### `MiniHeader` (4 bytes, compressed — 49 of every 50 packets)
+### `MiniHeader` v2 (5 bytes, compressed — 49 of every 50 packets)
```
[FRAME_TYPE_MINI = 0x01]
-Bytes 0-1: timestamp_delta_ms (u16 BE)
-Bytes 2-3: payload_len (u16 BE)
+Byte 0: seq_delta (u8)
+Bytes 1-2: timestamp_delta_ms (u16 BE)
+Bytes 3-4: payload_len (u16 BE)
```
Full header sent every 50th packet to resync.
@@ -95,6 +105,12 @@ Byte 2: jitter_ms (0-255 ms)
Byte 3: bitrate_cap_kbps (0-255 kbps)
```
+### Version negotiation
+
+- `version=0x02` in `MediaHeader` is a hard switch — there is no fallback negotiation.
+- Both endpoints must speak v2. A v1 peer receives `Hangup::ProtocolVersionMismatch` immediately.
+- Relays inspect only `version` and `media_type`; they never downgrade or translate between versions.
+
## Session lifecycle
```
diff --git a/vault/.obsidian/app.json b/vault/.obsidian/app.json
new file mode 100644
index 0000000..7c1c751
--- /dev/null
+++ b/vault/.obsidian/app.json
@@ -0,0 +1,6 @@
+{
+ "legacyEditor": false,
+ "livePreview": true,
+ "defaultViewMode": "source",
+ "promptDelete": false
+}
diff --git a/vault/.obsidian/workspace.json b/vault/.obsidian/workspace.json
new file mode 100644
index 0000000..0967ef4
--- /dev/null
+++ b/vault/.obsidian/workspace.json
@@ -0,0 +1 @@
+{}
diff --git a/vault/00 - Home.md b/vault/00 - Home.md
new file mode 100644
index 0000000..1720698
--- /dev/null
+++ b/vault/00 - Home.md
@@ -0,0 +1,128 @@
+---
+tags: [home, wzp]
+type: index
+---
+
+# WarzonePhone Vault
+
+WarzonePhone (WZP) is a custom lossy VoIP protocol and application stack built in Rust. It features a 7-crate workspace, Opus + Codec2 audio codecs, RaptorQ FEC, QUIC transport, and a Tauri-based Android client. The project spans relay infrastructure, P2P direct calling, AV1 video, and federated relay gossip.
+
+---
+
+## Architecture
+
+- [[Architecture/Architecture|Architecture Overview]]
+- [[Architecture/WZP-Spec|WZP Protocol Spec]]
+- [[Architecture/Protocol-Audit|Protocol Audit]]
+- [[Architecture/Design|Design Doc]]
+- [[Architecture/WS-Relay-Spec|WebSocket Relay Spec]]
+- [[Architecture/Extensibility|Extensibility]]
+- [[Architecture/Road-To-Video|Road to Video]]
+- [[Architecture/Attack-Surface-Relay-Abuse|Attack Surface: Relay Abuse]]
+- [[Architecture/Refactor-Codebase-Audit|Refactor: Codebase Audit]]
+- [[Architecture/Refactor-Relay-Concurrency|Refactor: Relay Concurrency]]
+- [[Architecture/Branch-Desktop-Audio-Rewrite|Branch: Desktop Audio Rewrite]]
+
+---
+
+## Active Work
+
+- [[Reference/Handoff-2026-05-12|Handoff 2026-05-12]] — current state handoff doc
+- [[PRDs/TASKS|TASKS — Status Board]]
+- [[Audit/Audit-2026-05-25|Audit 2026-05-25]]
+
+---
+
+## PRDs
+
+### Audio & Codec
+- [[PRDs/PRD-adaptive-quality|Adaptive Quality]]
+- [[PRDs/PRD-bluetooth-audio|Bluetooth Audio]]
+- [[PRDs/PRD-coordinated-codec|Coordinated Codec]]
+- [[PRDs/PRD-dred-integration|DRED Integration]]
+- [[PRDs/PRD-studio-quality|Studio Quality]]
+
+### Networking & P2P
+- [[PRDs/PRD-p2p-direct|P2P Direct Calling]]
+- [[PRDs/PRD-hard-nat|Hard NAT Traversal]]
+- [[PRDs/PRD-ice-regather|ICE Regather]]
+- [[PRDs/PRD-mtu-discovery|MTU Discovery]]
+- [[PRDs/PRD-netcheck|Network Check]]
+- [[PRDs/PRD-network-awareness|Network Awareness]]
+- [[PRDs/PRD-portmap|Port Mapping]]
+- [[PRDs/PRD-public-stun|Public STUN]]
+- [[PRDs/PRD-transport-feedback-bwe|Transport Feedback BWE]]
+
+### Relay
+- [[PRDs/PRD-relay-concurrency|Relay Concurrency]]
+- [[PRDs/PRD-relay-conformance|Relay Conformance]]
+- [[PRDs/PRD-relay-federation|Relay Federation]]
+- [[PRDs/PRD-relay-federation-gossip|Relay Federation Gossip]]
+- [[PRDs/PRD-relay-selection|Relay Selection]]
+
+### Video
+- [[PRDs/PRD-video-v1|Video V1]]
+- [[PRDs/PRD-video-multicodec|Video Multicodec]]
+- [[PRDs/PRD-video-quality-priority|Video Quality Priority]]
+- [[PRDs/PRD-video-simulcast|Video Simulcast]]
+
+### Protocol & Security
+- [[PRDs/PRD-protocol-hardening|Protocol Hardening]]
+- [[PRDs/PRD-protocol-analyzer|Protocol Analyzer]]
+- [[PRDs/PRD-wire-format-v2|Wire Format V2]]
+- [[PRDs/PRD-delegated-trust|Delegated Trust]]
+
+### Other
+- [[PRDs/PRD-engine-dedup|Engine Dedup]]
+- [[PRDs/PRD-local-recording|Local Recording]]
+
+---
+
+## Android
+
+- [[Android/Architecture|Android Architecture]]
+- [[Android/Build-Guide|Build Guide]]
+- [[Android/Roadmap|Android Roadmap]]
+- [[Android/Debugging|Debugging]]
+- [[Android/Maintenance|Maintenance]]
+- [[Android/Fix-Audio-Ring-Desync|Fix: Audio Ring Desync]]
+- [[Android/Fix-Capture-Thread-Crash|Fix: Capture Thread Crash]]
+- [[Android/README|Android README]]
+
+---
+
+## Reference
+
+- [[Reference/API|API Reference]]
+- [[Reference/Usage|Usage]]
+- [[Reference/User-Guide|User Guide]]
+- [[Reference/Administration|Administration]]
+- [[Reference/Telemetry|Telemetry]]
+- [[Reference/Progress|Progress]]
+- [[Reference/Featherchat-Integration|FeatherChat Integration]]
+- [[Reference/Featherchat|FeatherChat]]
+- [[Reference/WZP-FC-Shared-Crates|WZP-FC Shared Crates]]
+- [[Reference/Integration-Tasks|Integration Tasks]]
+
+---
+
+## Reports
+
+### Approved
+- [[Reports/T1.1-report|T1.1]] · [[Reports/T1.1.1-report|T1.1.1]] · [[Reports/T1.1.2-report|T1.1.2]]
+- [[Reports/T1.2-report|T1.2]] · [[Reports/T1.2.1-report|T1.2.1]]
+- [[Reports/T1.3-report|T1.3]] · [[Reports/T1.4-report|T1.4]] · [[Reports/T1.4.1-report|T1.4.1]]
+- [[Reports/T1.5-report|T1.5]] · [[Reports/T1.5.1-report|T1.5.1]] · [[Reports/T1.5.2-report|T1.5.2]]
+- [[Reports/T1.6-report|T1.6]] · [[Reports/T1.7-report|T1.7]] · [[Reports/T1.8-report|T1.8]]
+- [[Reports/T2.1-report|T2.1]] · [[Reports/T2.2-report|T2.2]]
+- [[Reports/T4.2-report|T4.2]] · [[Reports/T4.2.1-report|T4.2.1]] · [[Reports/T4.3-report|T4.3]] · [[Reports/T4.3.1-report|T4.3.1]]
+- [[Reports/T4.4-report|T4.4]] · [[Reports/T4.5-report|T4.5]] · [[Reports/T4.6-report|T4.6]] · [[Reports/T4.7-report|T4.7]]
+- [[Reports/T5.1-report|T5.1]] · [[Reports/T5.2-report|T5.2]] · [[Reports/T5.3-report|T5.3]]
+
+### Pending Review
+- [[Reports/T2.3-report|T2.3]] · [[Reports/T2.4-report|T2.4]] · [[Reports/T2.5-report|T2.5]] · [[Reports/T2.6-report|T2.6]]
+- [[Reports/T3.1-report|T3.1]] · [[Reports/T3.2-report|T3.2]] · [[Reports/T3.3-report|T3.3]] · [[Reports/T3.4-report|T3.4]] · [[Reports/T3.5-report|T3.5]]
+- [[Reports/T4.1-report|T4.1]]
+- [[Reports/T5.1.1-report|T5.1.1]] · [[Reports/T5.4-report|T5.4]] · [[Reports/T5.5-report|T5.5]] · [[Reports/T5.6-report|T5.6]]
+- [[Reports/T5.7-report|T5.7]] · [[Reports/T5.7.1-report|T5.7.1]] · [[Reports/T5.8-report|T5.8]]
+- [[Reports/T6.1-report|T6.1]] · [[Reports/T6.1.2-report|T6.1.2]] · [[Reports/T6.2-report|T6.2]]
diff --git a/vault/Android/Architecture.md b/vault/Android/Architecture.md
new file mode 100644
index 0000000..d5ed2e4
--- /dev/null
+++ b/vault/Android/Architecture.md
@@ -0,0 +1,405 @@
+---
+tags: [android, wzp]
+type: reference
+---
+
+# Architecture
+
+## System Overview
+
+The Android client is a four-layer stack: Kotlin UI, JNI bridge, Rust engine, and C++ audio I/O. Each layer communicates through well-defined interfaces with minimal coupling.
+
+```mermaid
+graph TB
+ subgraph "Kotlin (Main Thread)"
+ CA[CallActivity]
+ VM[CallViewModel]
+ UI[InCallScreen
Compose UI]
+ CA --> VM
+ VM --> UI
+ end
+
+ subgraph "JNI Bridge"
+ JB[jni_bridge.rs
panic-safe FFI]
+ end
+
+ subgraph "Rust Engine"
+ ENG[WzpEngine
Orchestrator]
+ CT[Codec Thread
20ms real-time loop]
+ NET[Tokio Runtime
2 async workers]
+ PIPE[Pipeline
Encode/Decode/FEC/Jitter]
+ end
+
+ subgraph "C++ Audio"
+ OBOE[Oboe Bridge
Capture + Playout callbacks]
+ RB[Ring Buffers
Lock-free SPSC]
+ end
+
+ subgraph "Network"
+ QUIC[QUIC Connection
quinn]
+ RELAY[WZP Relay
SFU Room]
+ end
+
+ VM <-->|"JNI calls
+ JSON stats"| JB
+ JB <--> ENG
+ ENG --> CT
+ ENG --> NET
+ CT <--> PIPE
+ CT <-->|"Atomic R/W"| RB
+ OBOE <-->|"Atomic R/W"| RB
+ CT <-->|"mpsc channels"| NET
+ NET <-->|"QUIC datagrams
+ streams"| QUIC
+ QUIC <--> RELAY
+```
+
+## Thread Model
+
+The engine uses four distinct thread contexts, each with specific responsibilities and real-time constraints.
+
+```mermaid
+graph LR
+ subgraph "Android Main Thread"
+ UI_T["UI + JNI calls
startCall / stopCall / getStats"]
+ end
+
+ subgraph "Oboe Audio Thread (system)"
+ AUD["Capture callback: mic → ring buf
Playout callback: ring buf → speaker
⚡ Highest priority, no allocations"]
+ end
+
+ subgraph "Codec Thread (wzp-codec)"
+ COD["20ms loop:
1. Read capture ring buf
2. AEC → AGC → Encode
3. Send to network channel
4. Recv from network channel
5. FEC → Jitter → Decode
6. Write playout ring buf
⚡ Pinned to big core, RT priority"]
+ end
+
+ subgraph "Tokio Runtime (2 workers)"
+ NET_S["Send task:
Channel → MediaPacket → QUIC datagram"]
+ NET_R["Recv task:
QUIC datagram → MediaPacket → Channel"]
+ HS["Handshake:
CallOffer → CallAnswer"]
+ end
+
+ UI_T -->|"mpsc command channel"| COD
+ COD -->|"tokio::mpsc send_tx"| NET_S
+ NET_R -->|"tokio::mpsc recv_tx"| COD
+ AUD <-->|"Atomic ring buffers"| COD
+```
+
+### Thread Priorities and Constraints
+
+| Thread | Priority | Allocations | Blocking | Lock-free |
+|--------|----------|-------------|----------|-----------|
+| Oboe audio | SCHED_FIFO (system) | None | Never | Yes |
+| Codec | RT priority, big core | Pre-allocated buffers | sleep(remainder of 20ms) | Ring buf: yes, Stats: Mutex |
+| Tokio workers | Normal | Allowed | Async only | N/A |
+| Main/JNI | Normal | Allowed | Allowed | N/A |
+
+## Call Lifecycle
+
+```mermaid
+sequenceDiagram
+ participant User
+ participant UI as InCallScreen
+ participant VM as CallViewModel
+ participant ENG as WzpEngine (JNI)
+ participant NET as Tokio Network
+ participant RELAY as WZP Relay
+
+ User->>UI: Tap CALL
+ UI->>VM: startCall()
+ VM->>ENG: init() + startCall(relay, room)
+ ENG->>ENG: Create tokio runtime
+ ENG->>NET: Spawn network task
+
+ NET->>RELAY: QUIC connect (SNI = room name)
+ RELAY-->>NET: Connection established
+
+ Note over NET,RELAY: Crypto Handshake
+ NET->>RELAY: CallOffer {identity_pub, ephemeral_pub, signature, profiles}
+ RELAY-->>NET: CallAnswer {ephemeral_pub, chosen_profile, signature}
+ NET->>NET: Derive ChaCha20-Poly1305 session
+
+ ENG->>ENG: Spawn codec thread
+ Note over ENG: State → Active
+
+ loop Every 20ms
+ ENG->>ENG: Read mic → AEC → AGC → Encode
+ ENG->>NET: Encoded frame via channel
+ NET->>RELAY: MediaPacket via QUIC DATAGRAM
+ RELAY->>NET: MediaPacket from other peer
+ NET->>ENG: MediaPacket via channel
+ ENG->>ENG: FEC → Jitter → Decode → Speaker
+ end
+
+ User->>UI: Tap END
+ UI->>VM: stopCall()
+ VM->>ENG: stopCall()
+ ENG->>ENG: Set running=false, send Stop command
+ ENG->>ENG: Join codec thread
+ ENG->>NET: Drop tokio runtime
+ NET->>RELAY: Connection close
+```
+
+## Audio Pipeline Detail
+
+```mermaid
+graph LR
+ subgraph "Capture Path"
+ MIC[Microphone] -->|"48kHz i16"| OBOE_C[Oboe Capture
Callback]
+ OBOE_C -->|"ring_write()"| RB_C[Capture
Ring Buffer]
+ RB_C -->|"read_capture()"| AEC[Echo
Canceller]
+ AEC --> AGC[Auto Gain
Control]
+ AGC --> ENC[AdaptiveEncoder
Opus 24k]
+ ENC -->|"Vec u8"| FEC_E[RaptorQ
FEC Encoder]
+ FEC_E -->|"send_tx"| CHAN_S[Send Channel]
+ end
+
+ subgraph "Network"
+ CHAN_S --> PKT_S[MediaPacket
Header + Payload]
+ PKT_S -->|"QUIC DATAGRAM"| RELAY[Relay SFU]
+ RELAY -->|"QUIC DATAGRAM"| PKT_R[MediaPacket
Deserialize]
+ PKT_R -->|"recv_tx"| CHAN_R[Recv Channel]
+ end
+
+ subgraph "Playout Path"
+ CHAN_R --> FEC_D[RaptorQ
FEC Decoder]
+ FEC_D --> JB[Jitter Buffer
10-250 pkts]
+ JB --> DEC[AdaptiveDecoder
Opus 24k]
+ DEC -->|"48kHz i16"| AEC_REF[AEC Far-End
Reference]
+ DEC -->|"write_playout()"| RB_P[Playout
Ring Buffer]
+ RB_P -->|"ring_read()"| OBOE_P[Oboe Playout
Callback]
+ OBOE_P --> SPK[Speaker]
+ end
+```
+
+### Audio Parameters
+
+| Parameter | Value | Notes |
+|-----------|-------|-------|
+| Sample rate | 48,000 Hz | Opus native rate |
+| Channels | 1 (mono) | VoIP only |
+| Frame size | 960 samples | 20ms at 48kHz |
+| Ring buffer | 7,680 samples | 160ms (8 frames) |
+| Bit depth | 16-bit signed int | PCM format |
+| AEC tail | 100ms | Echo canceller filter length |
+
+## Crypto Handshake
+
+```mermaid
+sequenceDiagram
+ participant Client as Android Client
+ participant Relay as WZP Relay
+
+ Note over Client: Identity seed (32 bytes, random per launch)
+ Note over Client: HKDF → Ed25519 signing key + X25519 static key
+
+ Client->>Client: Generate ephemeral X25519 keypair
+ Client->>Client: Sign(ephemeral_pub || "call-offer") with Ed25519
+
+ Client->>Relay: SignalMessage::CallOffer
{identity_pub, ephemeral_pub, signature, [GOOD, DEGRADED, CATASTROPHIC]}
+
+ Relay->>Relay: Verify Ed25519 signature
+ Relay->>Relay: Generate own ephemeral X25519
+ Relay->>Relay: Sign(ephemeral_pub || "call-answer")
+ Relay->>Relay: DH(relay_ephemeral, client_ephemeral) → shared secret
+ Relay->>Relay: HKDF(shared_secret) → ChaCha20-Poly1305 key
+
+ Relay->>Client: SignalMessage::CallAnswer
{identity_pub, ephemeral_pub, signature, chosen_profile=GOOD}
+
+ Client->>Client: Verify relay signature
+ Client->>Client: DH(client_ephemeral, relay_ephemeral) → same shared secret
+ Client->>Client: HKDF(shared_secret) → same ChaCha20-Poly1305 key
+
+ Note over Client,Relay: Both sides now have identical session key
+ Note over Client,Relay: Media packets can be encrypted (not yet applied)
+```
+
+### Key Derivation Chain
+
+```
+Identity Seed (32 bytes, random)
+ │
+ ├── HKDF(seed, info="warzone-ed25519") → Ed25519 signing key
+ │ └── Public key = identity_pub (32 bytes)
+ │ └── SHA-256(identity_pub)[:16] = fingerprint (16 bytes)
+ │
+ └── HKDF(seed, info="warzone-x25519") → X25519 static key (unused currently)
+
+Per-Call Ephemeral:
+ Random X25519 keypair → ephemeral_pub (sent in CallOffer)
+
+Session Key:
+ DH(our_ephemeral_secret, peer_ephemeral_pub) → shared_secret
+ HKDF(shared_secret, info="warzone-session-key") → ChaCha20-Poly1305 key (32 bytes)
+```
+
+## QUIC Transport
+
+```mermaid
+graph TB
+ subgraph "QUIC Connection"
+ EP[Client Endpoint
0.0.0.0:0 UDP]
+ CONN[Connection to Relay
SNI = room name]
+
+ subgraph "Unreliable Channel"
+ DG_S[Send DATAGRAM
MediaPacket serialized]
+ DG_R[Recv DATAGRAM
MediaPacket deserialized]
+ end
+
+ subgraph "Reliable Channel"
+ ST_S[Open bidi stream
JSON length-prefixed
SignalMessage]
+ ST_R[Accept bidi stream
JSON length-prefixed
SignalMessage]
+ end
+
+ EP --> CONN
+ CONN --> DG_S
+ CONN --> DG_R
+ CONN --> ST_S
+ CONN --> ST_R
+ end
+```
+
+### QUIC Configuration (VoIP-tuned)
+
+| Setting | Value | Rationale |
+|---------|-------|-----------|
+| ALPN | `wzp` | Protocol identification |
+| Idle timeout | 30s | Keep connection alive during silence |
+| Keep-alive | 5s | Prevent NAT timeout |
+| Datagram receive buffer | 65 KB | Buffer for burst arrivals |
+| Flow control (recv) | 256 KB | Conservative for VoIP |
+| Flow control (send) | 128 KB | Prevent bufferbloat |
+| TLS | Self-signed certs | Development mode |
+| Certificate verification | Disabled | Client accepts any cert |
+
+## MediaPacket Wire Format
+
+```
+12-byte header:
+┌─────────────────────────────────────────────────┐
+│ Byte 0: V(1) T(1) CodecID(4) Q(1) FecHi(1) │
+│ Byte 1: FecLo(6) unused(2) │
+│ Byte 2-3: Sequence number (u16 BE) │
+│ Byte 4-7: Timestamp ms (u32 BE) │
+│ Byte 8: FEC block ID │
+│ Byte 9: FEC symbol index │
+│ Byte 10: Reserved │
+│ Byte 11: CSRC count │
+├─────────────────────────────────────────────────┤
+│ Payload: Opus-encoded audio frame │
+├─────────────────────────────────────────────────┤
+│ Optional: QualityReport (4 bytes, if Q=1) │
+│ loss_pct(u8) rtt_4ms(u8) jitter_ms(u8) │
+│ bitrate_cap_kbps(u8) │
+└─────────────────────────────────────────────────┘
+```
+
+## Relay Room Mode (SFU)
+
+```mermaid
+graph LR
+ subgraph "Room: android"
+ P1[Phone A
QUIC conn] -->|MediaPacket| RELAY[Relay SFU]
+ RELAY -->|MediaPacket| P2[Phone B
QUIC conn]
+ P2 -->|MediaPacket| RELAY
+ RELAY -->|MediaPacket| P1
+ end
+
+ Note1["Room name from QUIC TLS SNI
No auth required
Packets forwarded to all others"]
+```
+
+The relay operates as a Selective Forwarding Unit:
+1. Client connects via QUIC, room name extracted from TLS SNI
+2. Crypto handshake completes (relay has its own ephemeral identity)
+3. Client joins named room
+4. All received media packets are forwarded to every other participant in the room
+5. Signaling messages are not forwarded (point-to-point with relay)
+
+## Adaptive Quality System
+
+```mermaid
+graph TD
+ QR[QualityReport
loss%, RTT, jitter] --> AQC[AdaptiveQualityController]
+
+ AQC -->|"loss<10%, RTT<400ms"| GOOD[GOOD
Opus 24kbps
FEC 20%
20ms frames]
+ AQC -->|"loss 10-40%
RTT 400-600ms"| DEG[DEGRADED
Opus 6kbps
FEC 50%
40ms frames]
+ AQC -->|"loss>40%
RTT>600ms"| CAT[CATASTROPHIC
Codec2 1.2kbps
FEC 100%
40ms frames]
+
+ GOOD -->|"Hysteresis:
sustained degradation"| DEG
+ DEG -->|"Sustained improvement"| GOOD
+ DEG -->|"Further degradation"| CAT
+ CAT -->|"Improvement"| DEG
+```
+
+| Profile | Codec | Bitrate | FEC Ratio | Frame Size | FEC Block |
+|---------|-------|---------|-----------|------------|-----------|
+| GOOD | Opus 24k | 24 kbps | 20% | 20ms | 5 frames |
+| DEGRADED | Opus 6k | 6 kbps | 50% | 40ms | 10 frames |
+| CATASTROPHIC | Codec2 1.2k | 1.2 kbps | 100% | 40ms | 8 frames |
+
+## Module Dependency Graph
+
+```mermaid
+graph BT
+ PROTO[wzp-proto
Types, traits, jitter,
quality, session]
+ CODEC[wzp-codec
Opus, Codec2, AEC,
AGC, resampling]
+ FEC[wzp-fec
RaptorQ fountain codes]
+ CRYPTO[wzp-crypto
Ed25519, X25519,
ChaCha20-Poly1305]
+ TRANSPORT[wzp-transport
QUIC, datagrams,
signaling streams]
+ ANDROID[wzp-android
Engine, JNI bridge,
Oboe audio, pipeline]
+ RELAY[wzp-relay
SFU, rooms, auth,
metrics, probes]
+
+ CODEC --> PROTO
+ FEC --> PROTO
+ CRYPTO --> PROTO
+ TRANSPORT --> PROTO
+ ANDROID --> PROTO
+ ANDROID --> CODEC
+ ANDROID --> FEC
+ ANDROID --> CRYPTO
+ ANDROID --> TRANSPORT
+ RELAY --> PROTO
+ RELAY --> CRYPTO
+ RELAY --> TRANSPORT
+```
+
+## File Map
+
+### Kotlin (`android/app/src/main/java/com/wzp/`)
+
+| File | Purpose |
+|------|---------|
+| `WzpApplication.kt` | App entry, notification channel creation |
+| `engine/WzpEngine.kt` | JNI wrapper for native engine |
+| `engine/WzpCallback.kt` | Callback interface for engine events |
+| `engine/CallStats.kt` | Stats data class with JSON deserialization |
+| `ui/call/CallActivity.kt` | Activity host, permissions, theme |
+| `ui/call/CallViewModel.kt` | MVVM state holder, stats polling |
+| `ui/call/InCallScreen.kt` | Compose UI (idle + in-call states) |
+| `service/CallService.kt` | Foreground service, wake/wifi locks |
+| `audio/AudioRouteManager.kt` | Speaker/earpiece/Bluetooth routing |
+
+### Rust (`crates/wzp-android/src/`)
+
+| File | Purpose |
+|------|---------|
+| `lib.rs` | Module declarations |
+| `jni_bridge.rs` | JNI FFI (panic-safe, proper jni crate) |
+| `engine.rs` | Call orchestrator (threads, channels, lifecycle) |
+| `pipeline.rs` | Codec pipeline (AEC, AGC, encode, FEC, jitter, decode) |
+| `audio_android.rs` | Oboe backend, SPSC ring buffers, RT scheduling |
+| `commands.rs` | Engine command enum |
+| `stats.rs` | CallState/CallStats types (serde) |
+
+### C++ (`crates/wzp-android/cpp/`)
+
+| File | Purpose |
+|------|---------|
+| `oboe_bridge.h` | FFI header for Rust-C++ audio interface |
+| `oboe_bridge.cpp` | Oboe capture/playout callbacks, ring buffer I/O |
+| `oboe_stub.cpp` | No-op stub for non-Android builds |
+
+### Build
+
+| File | Purpose |
+|------|---------|
+| `android/app/build.gradle.kts` | Android build config, cargo-ndk task |
+| `crates/wzp-android/Cargo.toml` | Rust dependencies (cdylib output) |
+| `crates/wzp-android/build.rs` | C++ compilation, Oboe fetch |
diff --git a/vault/Android/Build-Guide.md b/vault/Android/Build-Guide.md
new file mode 100644
index 0000000..ca7a181
--- /dev/null
+++ b/vault/Android/Build-Guide.md
@@ -0,0 +1,160 @@
+---
+tags: [android, wzp]
+type: reference
+---
+
+# Build Guide
+
+## Prerequisites
+
+| Tool | Version | Purpose |
+|------|---------|---------|
+| JDK | 17 | Android Gradle builds |
+| Android SDK | 34 | Compile SDK |
+| Android NDK | 26.1.10909125 | Native C++/Rust compilation |
+| Rust | 1.85+ | Native engine (edition 2024) |
+| cargo-ndk | latest | Cross-compile Rust → Android |
+| `aarch64-linux-android` target | - | Rust target for ARM64 |
+
+### Install Rust Android target
+
+```bash
+rustup target add aarch64-linux-android
+cargo install cargo-ndk
+```
+
+### Environment Variables
+
+```bash
+export JAVA_HOME="/usr/lib/jvm/java-17-openjdk-amd64"
+export ANDROID_HOME="$HOME/android-sdk"
+export ANDROID_NDK_HOME="$ANDROID_HOME/ndk/26.1.10909125"
+
+# For manual cargo-ndk builds (Gradle sets these automatically):
+export CC_aarch64_linux_android="$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android21-clang"
+export CXX_aarch64_linux_android="$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android21-clang++"
+export AR_aarch64_linux_android="$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar"
+```
+
+## Build Commands
+
+### Full Build (Gradle drives everything)
+
+```bash
+cd android
+./gradlew assembleRelease
+```
+
+This runs:
+1. `cargoNdkBuild` task: invokes `cargo ndk -t arm64-v8a -o app/src/main/jniLibs build --release -p wzp-android`
+2. Compiles Kotlin/Compose code
+3. Packages APK with signing
+
+### Native Library Only
+
+```bash
+cargo ndk -t arm64-v8a -o android/app/src/main/jniLibs build --release -p wzp-android
+```
+
+Output: `android/app/src/main/jniLibs/arm64-v8a/libwzp_android.so`
+
+### Skip Native Rebuild
+
+If the `.so` hasn't changed:
+
+```bash
+cd android
+./gradlew assembleRelease -x cargoNdkBuild
+```
+
+### Debug Build
+
+```bash
+cd android
+./gradlew assembleDebug
+```
+
+Debug APK is ~8.9 MB (unstripped `.so`), release is ~6.9 MB.
+
+## Signing
+
+### Debug
+
+```
+Keystore: android/keystore/wzp-debug.jks
+Password: android
+Key alias: wzp-debug
+```
+
+### Release
+
+```
+Keystore: android/keystore/wzp-release.jks
+Password: wzphone2024
+Key alias: wzp-release
+```
+
+Both keystores are checked into the repo for development convenience. For production, replace with proper key management.
+
+## Build Artifacts
+
+| Artifact | Path | Size |
+|----------|------|------|
+| Debug APK | `android/app/build/outputs/apk/debug/app-debug.apk` | ~8.9 MB |
+| Release APK | `android/app/build/outputs/apk/release/app-release.apk` | ~6.9 MB |
+| Native lib | `android/app/src/main/jniLibs/arm64-v8a/libwzp_android.so` | ~5 MB |
+
+## ABI Support
+
+Currently only `arm64-v8a` (ARM64) is built. This covers 95%+ of modern Android devices.
+
+To add more ABIs, edit `build.gradle.kts`:
+
+```kotlin
+ndk { abiFilters += listOf("arm64-v8a", "armeabi-v7a") }
+```
+
+And update the cargo-ndk command in `cargoNdkBuild` task:
+
+```kotlin
+commandLine("cargo", "ndk", "-t", "arm64-v8a", "-t", "armeabi-v7a", ...)
+```
+
+## Oboe Dependency
+
+The Oboe C++ audio library is fetched at build time by `build.rs`:
+
+1. Attempts `git clone` of Oboe 1.8.1 into `$OUT_DIR/oboe`
+2. If successful, compiles `oboe_bridge.cpp` with Oboe headers
+3. If clone fails (no network), falls back to `oboe_stub.cpp` (no-op audio)
+
+This means **first build requires internet** to fetch Oboe. Subsequent builds use the cached checkout.
+
+## Common Build Issues
+
+### `cargo ndk` not found
+
+```bash
+cargo install cargo-ndk
+```
+
+### Missing Android target
+
+```bash
+rustup target add aarch64-linux-android
+```
+
+### NDK not found
+
+Ensure `ANDROID_NDK_HOME` points to the NDK directory containing `toolchains/llvm/`.
+
+### C++ compilation errors
+
+Check that `CXX_aarch64_linux_android` points to a valid clang++ from the NDK.
+
+### Gradle daemon issues
+
+```bash
+./gradlew --stop
+./gradlew assembleRelease --no-daemon
+```
diff --git a/vault/Android/Debugging.md b/vault/Android/Debugging.md
new file mode 100644
index 0000000..8edc373
--- /dev/null
+++ b/vault/Android/Debugging.md
@@ -0,0 +1,219 @@
+---
+tags: [android, wzp]
+type: reference
+---
+
+# Debugging Guide
+
+## Crash on Launch
+
+### Symptom: App crashes immediately after opening
+
+**Most likely cause: Namespace mismatch in AndroidManifest.xml**
+
+The Gradle namespace is `com.wzp.phone` but all Kotlin classes are in package `com.wzp.*`. If the manifest uses shorthand names (`.WzpApplication`, `.ui.call.CallActivity`), Android resolves them as `com.wzp.phone.WzpApplication` which doesn't exist.
+
+**Fix**: Always use fully-qualified class names in the manifest:
+
+```xml
+
+
+
+
+
+
+
+```
+
+### Symptom: Crash in `System.loadLibrary("wzp_android")`
+
+The native `.so` is missing or incompatible. Check:
+
+```bash
+# Verify the .so exists in the APK
+unzip -l app-release.apk | grep libwzp
+# Should show: lib/arm64-v8a/libwzp_android.so
+
+# Verify ABI matches device
+adb shell getprop ro.product.cpu.abi
+# Should return: arm64-v8a
+```
+
+### Symptom: Crash when calling `nativeGetStats()` (returns null jstring)
+
+The JNI bridge must return a valid `jstring`, not a null pointer. The Kotlin side declares the return as `String?` (nullable) and wraps in try/catch:
+
+```kotlin
+fun getStats(): String {
+ if (nativeHandle == 0L) return "{}"
+ return try {
+ nativeGetStats(nativeHandle) ?: "{}"
+ } catch (_: Exception) {
+ "{}"
+ }
+}
+```
+
+### Symptom: Tracing subscriber panic
+
+`tracing_subscriber::fmt()` writes to stdout, which doesn't exist on Android. The init was removed. If you need logging, use `android_logger` crate instead.
+
+## Logcat Filters
+
+### View all WZP logs
+
+```bash
+adb logcat -s wzp-android:V wzp-codec:V wzp-net:V
+```
+
+### View Rust tracing output (if android_logger is added)
+
+```bash
+adb logcat | grep -E "(wzp|WzpEngine|CallActivity)"
+```
+
+### View Oboe audio logs
+
+```bash
+adb logcat -s AAudio:V oboe:V
+```
+
+### View native crashes
+
+```bash
+adb logcat -s DEBUG:V libc:V
+```
+
+Look for `signal 11 (SIGSEGV)` or `signal 6 (SIGABRT)` with a backtrace in `libwzp_android.so`.
+
+### Symbolicate native crash
+
+```bash
+# Find the .so with debug symbols (before stripping)
+SO_PATH="target/aarch64-linux-android/release/libwzp_android.so"
+
+# Use addr2line from NDK
+$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-addr2line \
+ -e $SO_PATH -f 0x
+```
+
+## Network Issues
+
+### Call stuck on "Connecting..."
+
+The QUIC handshake to the relay is failing. Common causes:
+
+1. **Relay not running**: Verify the relay is listening:
+ ```bash
+ nc -zvu 172.16.81.125 4433
+ ```
+
+2. **Wrong relay address**: Hardcoded in `CallViewModel.kt`:
+ ```kotlin
+ const val DEFAULT_RELAY = "172.16.81.125:4433"
+ ```
+
+3. **QUIC blocked by firewall**: QUIC uses UDP. Many networks block UDP traffic. Ensure UDP port 4433 is open.
+
+4. **TLS handshake failure**: The client uses `client_config()` which disables certificate verification. If the relay's QUIC config changed, this may fail.
+
+### Connected but no audio
+
+1. **Microphone permission denied**: Check Android settings. The app requests `RECORD_AUDIO` on first launch.
+
+2. **Oboe failed to start**: The codec thread logs this. Check logcat for "failed to start audio".
+
+3. **Ring buffer underrun**: The stats overlay shows "Under" count. High underruns mean the codec thread isn't keeping up.
+
+4. **Network not forwarding**: If both phones show "Active" but frame counters aren't increasing, the relay may not be forwarding. Check relay logs.
+
+### High packet loss
+
+The stats overlay shows loss percentage. Common causes:
+
+- Wi-Fi congestion (try cellular or move closer to AP)
+- UDP throttling by carrier/ISP
+- Relay overloaded (check relay metrics)
+
+## Audio Issues
+
+### Echo
+
+AEC (Acoustic Echo Cancellation) is enabled by default with a 100ms tail. If echo persists:
+
+- The AEC may need a longer tail for the specific acoustic environment
+- Speaker volume too high overwhelms the canceller
+- Check that `last_decoded_farend` is being set (playout path working)
+
+### Robot voice / glitching
+
+Usually caused by jitter buffer underruns. The jitter buffer adapts between 10-250 packets. Check:
+
+- `jitter_buffer_depth` in stats (should be > 0 during active call)
+- `underruns` counter (should not climb rapidly)
+- Network jitter (high jitter_ms causes adaptation)
+
+### No sound from speaker
+
+1. Check `isSpeaker` state in the UI
+2. Oboe playout stream may have failed — check logcat for Oboe errors
+3. Ring buffer might be empty — check `framesDecoded` counter
+
+## JNI Issues
+
+### `UnsatisfiedLinkError: No implementation found for...`
+
+The JNI function name doesn't match. JNI names must follow the pattern:
+```
+Java_com_wzp_engine_WzpEngine_
+```
+
+If the package structure changes, all JNI function names must be updated in `jni_bridge.rs`.
+
+### Panic across FFI boundary
+
+All JNI functions wrap their body in `panic::catch_unwind()`. If a Rust panic escapes to Java, it causes a `SIGABRT`. The catch_unwind returns safe defaults:
+
+| Function | Panic return |
+|----------|--------------|
+| `nativeInit` | 0 (null handle) |
+| `nativeStartCall` | -1 (error) |
+| `nativeGetStats` | `JObject::null()` |
+| Others | void (silently swallowed) |
+
+### Thread safety
+
+All JNI methods must be called from the same thread (Android main thread). The `EngineHandle` is a raw pointer — concurrent access is undefined behavior.
+
+## Stats JSON Format
+
+The `nativeGetStats()` returns JSON matching this Rust struct:
+
+```json
+{
+ "state": "Active",
+ "duration_secs": 42.5,
+ "quality_tier": 0,
+ "loss_pct": 0.5,
+ "rtt_ms": 45,
+ "jitter_ms": 12,
+ "jitter_buffer_depth": 3,
+ "frames_encoded": 2125,
+ "frames_decoded": 2100,
+ "underruns": 5
+}
+```
+
+Kotlin deserializes this via `CallStats.fromJson()` using `org.json.JSONObject` (Android built-in, no library needed).
+
+## Diagnostic Checklist
+
+When something doesn't work, check in this order:
+
+1. **APK installed for correct ABI?** (`arm64-v8a` only)
+2. **Manifest class names fully qualified?** (no dots prefix)
+3. **Relay running and reachable?** (`nc -zvu `)
+4. **Microphone permission granted?**
+5. **Stats polling working?** (check if frame counters increment)
+6. **Logcat for native crashes?** (`adb logcat -s DEBUG:V`)
+7. **Network connectivity?** (UDP port open, no firewall)
diff --git a/vault/Android/Fix-Audio-Ring-Desync.md b/vault/Android/Fix-Audio-Ring-Desync.md
new file mode 100644
index 0000000..ea87160
--- /dev/null
+++ b/vault/Android/Fix-Audio-Ring-Desync.md
@@ -0,0 +1,399 @@
+---
+tags: [android, wzp]
+type: reference
+---
+
+# Fix: AudioRing SPSC Buffer Cursor Desync
+
+## Problem
+
+A critical bug causes 10-16 seconds of bidirectional audio silence mid-call (~25-30s in). Both participants go silent at the exact same moment. The QUIC transport, relay, Opus codec, and FEC are all healthy — the bug is in the lock-free ring buffer that transfers decoded PCM from the Rust recv task to the Kotlin AudioTrack playout thread.
+
+**Root cause:** `AudioRing::write()` modifies `read_pos` from the producer thread during overflow handling (lines 68-72 of `audio_ring.rs`). This violates the SPSC invariant — only the consumer should own `read_pos`. When both threads write to `read_pos`, a race corrupts the cursor state, causing the reader to see an empty or stale buffer for 12-16 seconds.
+
+**Full forensics:** `debug/INCIDENT-2026-04-06-playout-ring-desync.md`
+
+---
+
+## Solution: Reader-Detects-Lap Architecture
+
+The writer NEVER touches `read_pos`. On overflow, the writer simply overwrites old buffer data and advances `write_pos`. The reader detects it was lapped and self-corrects by snapping its own `read_pos` forward.
+
+---
+
+## Implementation Steps
+
+### Step 1: Rewrite `AudioRing`
+
+**File:** `crates/wzp-android/src/audio_ring.rs`
+
+Replace the entire implementation with:
+
+**Constants:**
+```rust
+/// Ring buffer capacity — must be a power of 2 for bitmask indexing.
+/// 16384 samples = 341.3ms at 48kHz mono. Provides 70% more headroom
+/// than the previous 9600 (200ms) for surviving Android GC pauses.
+const RING_CAPACITY: usize = 16384; // 2^14
+const RING_MASK: usize = RING_CAPACITY - 1;
+```
+
+**Struct:**
+```rust
+pub struct AudioRing {
+ buf: Box<[i16; RING_CAPACITY]>,
+ write_pos: AtomicUsize, // monotonically increasing, ONLY written by producer
+ read_pos: AtomicUsize, // monotonically increasing, ONLY written by consumer
+ overflow_count: AtomicU64, // incremented by reader when it detects a lap
+ underrun_count: AtomicU64, // incremented by reader when ring is empty
+}
+```
+
+**`write()` — producer. Does NOT touch `read_pos`:**
+```rust
+pub fn write(&self, samples: &[i16]) -> usize {
+ let count = samples.len().min(RING_CAPACITY);
+ let w = self.write_pos.load(Ordering::Relaxed);
+
+ for i in 0..count {
+ unsafe {
+ let ptr = self.buf.as_ptr() as *mut i16;
+ *ptr.add((w + i) & RING_MASK) = samples[i];
+ }
+ }
+
+ self.write_pos.store(w.wrapping_add(count), Ordering::Release);
+ count
+}
+```
+
+**`read()` — consumer. Detects lap, self-corrects:**
+```rust
+pub fn read(&self, out: &mut [i16]) -> usize {
+ let w = self.write_pos.load(Ordering::Acquire);
+ let mut r = self.read_pos.load(Ordering::Relaxed);
+
+ let mut avail = w.wrapping_sub(r);
+
+ // Lap detection: writer has overwritten our unread data.
+ // Snap read_pos forward to oldest valid data in the buffer.
+ // Safe because we (the reader) are the sole owner of read_pos.
+ if avail > RING_CAPACITY {
+ r = w.wrapping_sub(RING_CAPACITY);
+ avail = RING_CAPACITY;
+ self.overflow_count.fetch_add(1, Ordering::Relaxed);
+ }
+
+ let count = out.len().min(avail);
+ if count == 0 {
+ if w == r {
+ self.underrun_count.fetch_add(1, Ordering::Relaxed);
+ }
+ return 0;
+ }
+
+ for i in 0..count {
+ out[i] = unsafe { *self.buf.as_ptr().add((r + i) & RING_MASK) };
+ }
+
+ self.read_pos.store(r.wrapping_add(count), Ordering::Release);
+ count
+}
+```
+
+**`available()` — clamped for external callers:**
+```rust
+pub fn available(&self) -> usize {
+ let w = self.write_pos.load(Ordering::Acquire);
+ let r = self.read_pos.load(Ordering::Relaxed);
+ w.wrapping_sub(r).min(RING_CAPACITY)
+}
+```
+
+**`free_space()` — keep for API compat:**
+```rust
+pub fn free_space(&self) -> usize {
+ RING_CAPACITY.saturating_sub(self.available())
+}
+```
+
+**Diagnostic accessors:**
+```rust
+pub fn overflow_count(&self) -> u64 {
+ self.overflow_count.load(Ordering::Relaxed)
+}
+
+pub fn underrun_count(&self) -> u64 {
+ self.underrun_count.load(Ordering::Relaxed)
+}
+```
+
+**Constructor:**
+```rust
+pub fn new() -> Self {
+ debug_assert!(RING_CAPACITY.is_power_of_two());
+ Self {
+ buf: Box::new([0i16; RING_CAPACITY]),
+ write_pos: AtomicUsize::new(0),
+ read_pos: AtomicUsize::new(0),
+ overflow_count: AtomicU64::new(0),
+ underrun_count: AtomicU64::new(0),
+ }
+}
+```
+
+**Imports to add:** `use std::sync::atomic::AtomicU64;`
+
+**Safety comment update:**
+```rust
+// SAFETY: AudioRing is SPSC — one thread writes (producer), one reads (consumer).
+// The producer only writes write_pos. The consumer only writes read_pos.
+// Neither thread writes the other's cursor. Buffer indices are derived from
+// the owning thread's cursor, ensuring no concurrent access to the same index.
+```
+
+---
+
+### Step 2: Add counter fields to `CallStats`
+
+**File:** `crates/wzp-android/src/stats.rs`
+
+Add three fields to the `CallStats` struct (after `fec_recovered`):
+
+```rust
+/// Playout ring overflow count (reader was lapped by writer).
+pub playout_overflows: u64,
+/// Playout ring underrun count (reader found empty buffer).
+pub playout_underruns: u64,
+/// Capture ring overflow count.
+pub capture_overflows: u64,
+```
+
+These derive `Default` (= 0) automatically via the existing `#[derive(Default)]`.
+
+---
+
+### Step 3: Wire ring diagnostics into engine stats + logging
+
+**File:** `crates/wzp-android/src/engine.rs`
+
+**3a.** In `get_stats()` (~line 181), populate the new fields:
+
+```rust
+stats.playout_overflows = self.state.playout_ring.overflow_count();
+stats.playout_underruns = self.state.playout_ring.underrun_count();
+stats.capture_overflows = self.state.capture_ring.overflow_count();
+```
+
+**3b.** In the recv task periodic stats log, add ring health:
+
+```rust
+info!(
+ frames_decoded,
+ fec_recovered,
+ recv_errors,
+ max_recv_gap_ms,
+ playout_avail = state.playout_ring.available(),
+ playout_overflows = state.playout_ring.overflow_count(),
+ playout_underruns = state.playout_ring.underrun_count(),
+ "recv stats"
+);
+```
+
+**3c.** In the send task periodic stats log, add capture ring health:
+
+```rust
+info!(
+ seq = s,
+ block_id,
+ frames_sent,
+ frames_dropped,
+ send_errors,
+ ring_avail = state.capture_ring.available(),
+ capture_overflows = state.capture_ring.overflow_count(),
+ "send stats"
+);
+```
+
+---
+
+### Step 4: Parse new stats in Kotlin
+
+**File:** `android/app/src/main/java/com/wzp/engine/CallStats.kt`
+
+Add fields to the data class:
+
+```kotlin
+val playoutOverflows: Long = 0,
+val playoutUnderruns: Long = 0,
+val captureOverflows: Long = 0,
+```
+
+Add parsing in `fromJson()`:
+
+```kotlin
+playoutOverflows = obj.optLong("playout_overflows", 0),
+playoutUnderruns = obj.optLong("playout_underruns", 0),
+captureOverflows = obj.optLong("capture_overflows", 0),
+```
+
+No UI changes needed — these fields will appear in debug report JSON automatically.
+
+---
+
+### Step 5: Unit tests
+
+**File:** `crates/wzp-android/src/audio_ring.rs` — add `#[cfg(test)] mod tests`
+
+```rust
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn capacity_is_power_of_two() {
+ assert!(RING_CAPACITY.is_power_of_two());
+ }
+
+ #[test]
+ fn basic_write_read() {
+ let ring = AudioRing::new();
+ let input: Vec = (0..960).map(|i| i as i16).collect();
+ ring.write(&input);
+ assert_eq!(ring.available(), 960);
+
+ let mut output = vec![0i16; 960];
+ let read = ring.read(&mut output);
+ assert_eq!(read, 960);
+ assert_eq!(output, input);
+ assert_eq!(ring.available(), 0);
+ }
+
+ #[test]
+ fn wraparound() {
+ let ring = AudioRing::new();
+ let frame = vec![42i16; 960];
+ // Write enough to wrap the buffer multiple times
+ for _ in 0..20 {
+ ring.write(&frame);
+ let mut out = vec![0i16; 960];
+ ring.read(&mut out);
+ assert!(out.iter().all(|&s| s == 42));
+ }
+ }
+
+ #[test]
+ fn overflow_detected_by_reader() {
+ let ring = AudioRing::new();
+ // Write more than RING_CAPACITY without reading
+ let big = vec![7i16; RING_CAPACITY + 960];
+ ring.write(&big[..RING_CAPACITY]);
+ ring.write(&big[RING_CAPACITY..]);
+
+ // Reader should detect lap
+ let mut out = vec![0i16; 960];
+ let read = ring.read(&mut out);
+ assert!(read > 0);
+ assert_eq!(ring.overflow_count(), 1);
+ // Data should be from the most recent writes
+ assert!(out.iter().all(|&s| s == 7));
+ }
+
+ #[test]
+ fn writer_never_modifies_read_pos() {
+ let ring = AudioRing::new();
+ // Read pos should stay at 0 until read() is called
+ let data = vec![1i16; RING_CAPACITY + 960];
+ ring.write(&data);
+ // read_pos is private, but we can check available() > CAPACITY
+ // which proves write() didn't advance read_pos
+ let w = ring.write_pos.load(std::sync::atomic::Ordering::Relaxed);
+ let r = ring.read_pos.load(std::sync::atomic::Ordering::Relaxed);
+ assert_eq!(r, 0, "write() must not modify read_pos");
+ assert!(w.wrapping_sub(r) > RING_CAPACITY);
+ }
+
+ #[test]
+ fn underrun_counted() {
+ let ring = AudioRing::new();
+ let mut out = vec![0i16; 960];
+ let read = ring.read(&mut out);
+ assert_eq!(read, 0);
+ assert_eq!(ring.underrun_count(), 1);
+ }
+
+ #[test]
+ fn overflow_recovery_reads_recent_data() {
+ let ring = AudioRing::new();
+ // Fill with old data
+ let old = vec![1i16; RING_CAPACITY];
+ ring.write(&old);
+ // Overwrite with new data (lapping the reader)
+ let new_data = vec![99i16; 960];
+ ring.write(&new_data);
+
+ // Reader should snap forward and get recent data
+ let mut out = vec![0i16; RING_CAPACITY];
+ let read = ring.read(&mut out);
+ assert_eq!(read, RING_CAPACITY);
+ // The last 960 samples should be 99
+ assert!(out[RING_CAPACITY - 960..].iter().all(|&s| s == 99));
+ assert_eq!(ring.overflow_count(), 1);
+ }
+}
+```
+
+---
+
+## Memory Ordering Reference
+
+| Operation | Ordering | Rationale |
+|-----------|----------|-----------|
+| `write_pos.store` in `write()` | Release | Buffer writes visible before cursor advances |
+| `write_pos.load` in `read()` | Acquire | Pairs with Release above — sees all buffer writes |
+| `write_pos.load` in `write()` | Relaxed | Writer is sole owner of write_pos |
+| `read_pos.load` in `read()` | Relaxed | Reader is sole owner of read_pos |
+| `read_pos.store` in `read()` | Release | Makes available() consistent from any thread |
+| `read_pos.load` in `available()` | Relaxed | Informational only, slight staleness OK |
+| All counters | Relaxed | Diagnostic only |
+
+---
+
+## Capacity Tradeoff
+
+| Capacity | Duration | Memory | Verdict |
+|----------|----------|--------|---------|
+| 8192 (2^13) | 170ms | 16KB | Less than current 200ms — risky |
+| **16384 (2^14)** | **341ms** | **32KB** | **70% more headroom, bitmask indexing** |
+| 32768 (2^15) | 682ms | 64KB | Excessive latency on overflow recovery |
+
+---
+
+## Verification
+
+1. `cargo test -p wzp-android` — new unit tests pass
+2. `cargo ndk -t arm64-v8a build --release -p wzp-android` — ARM cross-compile succeeds
+3. Build APK, install on both test devices (Nothing A059 + Pixel 6)
+4. 2+ minute call — verify no audio gaps
+5. Check debug report JSON: `playout_overflows` should be 0 or very small
+6. Check logcat `wzp_android` tag: send/recv stats show healthy ring state
+7. Stress test: play music through one device speaker while on call — forces high ring throughput
+
+---
+
+## Files to Modify
+
+| File | What changes |
+|------|-------------|
+| `crates/wzp-android/src/audio_ring.rs` | Complete rewrite — the core fix |
+| `crates/wzp-android/src/stats.rs` | Add 3 counter fields |
+| `crates/wzp-android/src/engine.rs` | Wire counters into get_stats() + periodic logs |
+| `android/app/src/main/java/com/wzp/engine/CallStats.kt` | Parse 3 new JSON fields |
+
+## What Does NOT Change
+
+- `AudioPipeline.kt` — calls `readAudio()`/`writeAudio()` unchanged; ring fix is transparent
+- `jni_bridge.rs` — JNI bridge passes through unchanged
+- `audio_android.rs` — separate Oboe-based ring, currently unused, different design
+- Relay code — relay is confirmed healthy
+- Desktop client — uses `Mutex + mpsc`, not `AudioRing`
diff --git a/vault/Android/Fix-Capture-Thread-Crash.md b/vault/Android/Fix-Capture-Thread-Crash.md
new file mode 100644
index 0000000..29c846f
--- /dev/null
+++ b/vault/Android/Fix-Capture-Thread-Crash.md
@@ -0,0 +1,154 @@
+---
+tags: [android, wzp]
+type: reference
+---
+
+# Fix: Capture/Playout Thread Use-After-Free on Hangup
+
+## Problem
+
+App crashes (SIGSEGV) when hanging up a call. The capture thread (`wzp-capture`) calls `engine.writeAudio()` via JNI after `teardown()` has freed the native engine handle. Same race exists for the playout thread's `readAudio()`.
+
+**Root cause:** TOCTOU race between the `nativeHandle == 0L` check in `WzpEngine.writeAudio()`/`readAudio()` and `destroy()` freeing the native memory on the ViewModel thread. Audio threads can't be joined (libcrypto TLS destructor crash), so there's no synchronization between `stopAudio()` and `destroy()`.
+
+**Full forensics:** `debug/INCIDENT-2026-04-06-capture-thread-use-after-free.md`
+
+---
+
+## Solution: Destroy Latch
+
+Add a `CountDownLatch(2)` that both audio threads count down after exiting their loops. `teardown()` awaits the latch (with timeout) before calling `destroy()`, guaranteeing no in-flight JNI calls.
+
+---
+
+## Implementation Steps
+
+### Step 1: Add a drain latch to `AudioPipeline`
+
+**File:** `android/app/src/main/java/com/wzp/audio/AudioPipeline.kt`
+
+Add a `CountDownLatch` field:
+
+```kotlin
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+
+class AudioPipeline(private val context: Context) {
+ // ... existing fields ...
+
+ /** Latch counted down by each audio thread after exiting its loop.
+ * stop() does NOT wait on this — teardown waits via awaitDrain(). */
+ private var drainLatch: CountDownLatch? = null
+```
+
+In `start()`, create the latch before spawning threads:
+
+```kotlin
+fun start(engine: WzpEngine) {
+ if (running) return
+ running = true
+ drainLatch = CountDownLatch(2) // one for capture, one for playout
+
+ captureThread = Thread({
+ runCapture(engine)
+ drainLatch?.countDown() // signal: capture loop exited
+ parkThread()
+ }, "wzp-capture").apply { ... }
+
+ playoutThread = Thread({
+ runPlayout(engine)
+ drainLatch?.countDown() // signal: playout loop exited
+ parkThread()
+ }, "wzp-playout").apply { ... }
+ // ...
+}
+```
+
+Add `awaitDrain()` — called by ViewModel before `destroy()`:
+
+```kotlin
+/** Block until both audio threads have exited their loops (max 200ms).
+ * After this returns, no more JNI calls to the engine will be made. */
+fun awaitDrain(): Boolean {
+ return drainLatch?.await(200, TimeUnit.MILLISECONDS) ?: true
+}
+```
+
+`stop()` remains unchanged (non-blocking, sets `running = false`).
+
+### Step 2: Update `CallViewModel.teardown()` to await drain
+
+**File:** `android/app/src/main/java/com/wzp/ui/call/CallViewModel.kt`
+
+Change teardown to wait for audio threads before destroying:
+
+```kotlin
+private fun teardown(stopService: Boolean = true) {
+ Log.i(TAG, "teardown: stopping audio, stopService=$stopService")
+ val hadCall = audioStarted
+ CallService.onStopFromNotification = null
+ stopAudio() // sets running=false (non-blocking)
+ stopStatsPolling()
+
+ // Wait for audio threads to exit their loops before destroying the engine.
+ // This guarantees no in-flight JNI calls to writeAudio/readAudio.
+ val drained = audioPipeline?.awaitDrain() ?: true
+ if (!drained) {
+ Log.w(TAG, "teardown: audio threads did not drain in time")
+ }
+ audioPipeline = null
+
+ Log.i(TAG, "teardown: stopping engine")
+ try { engine?.stopCall() } catch (e: Exception) { Log.w(TAG, "stopCall err: $e") }
+ try { engine?.destroy() } catch (e: Exception) { Log.w(TAG, "destroy err: $e") }
+ engine = null
+ engineInitialized = false
+ // ... rest unchanged
+}
+```
+
+**Key change:** `awaitDrain()` is called AFTER `stopAudio()` (which sets `running=false`) but BEFORE `engine?.destroy()`. The latch guarantees both threads have exited their `while(running)` loops and will never call `writeAudio`/`readAudio` again.
+
+Also move `audioPipeline = null` to after `awaitDrain()` to keep the reference alive for the latch call.
+
+### Step 3: Move `stopAudio()` pipeline nulling
+
+**File:** `android/app/src/main/java/com/wzp/ui/call/CallViewModel.kt`
+
+In `stopAudio()`, do NOT null out the pipeline — let `teardown()` handle it after drain:
+
+```kotlin
+private fun stopAudio() {
+ if (!audioStarted) return
+ audioPipeline?.stop() // sets running=false
+ // DON'T null audioPipeline here — teardown() needs it for awaitDrain()
+ audioRouteManager?.unregister()
+ audioRouteManager?.setSpeaker(false)
+ _isSpeaker.value = false
+ audioStarted = false
+}
+```
+
+---
+
+## Files to Modify
+
+| File | What changes |
+|------|-------------|
+| `android/.../audio/AudioPipeline.kt` | Add `CountDownLatch`, `countDown()` in threads, `awaitDrain()` method |
+| `android/.../ui/call/CallViewModel.kt` | `teardown()` calls `awaitDrain()` before `destroy()`; `stopAudio()` doesn't null pipeline |
+
+## What Does NOT Change
+
+- `WzpEngine.kt` — the `nativeHandle == 0L` guard stays as defense-in-depth
+- `jni_bridge.rs` — `panic::catch_unwind` stays as last resort
+- `AudioPipeline.stop()` — remains non-blocking
+- Thread parking — still needed to avoid libcrypto TLS crash
+
+## Verification
+
+1. Build APK, install on test device
+2. Make a call, hang up — verify no crash in logcat (`adb logcat -s AndroidRuntime:E DEBUG:F`)
+3. Rapid call/hangup/call/hangup cycles — stress the teardown path
+4. Check logcat for `teardown: audio threads did not drain in time` — should never appear under normal conditions
+5. Verify debug report still works after hangup (latch doesn't interfere with report collection)
diff --git a/vault/Android/Maintenance.md b/vault/Android/Maintenance.md
new file mode 100644
index 0000000..1ba828f
--- /dev/null
+++ b/vault/Android/Maintenance.md
@@ -0,0 +1,195 @@
+---
+tags: [android, wzp]
+type: reference
+---
+
+# Maintenance Guide
+
+## Code Map — Where to Change Things
+
+### Changing the relay address or room
+
+Edit `CallViewModel.kt`:
+```kotlin
+companion object {
+ const val DEFAULT_RELAY = "172.16.81.125:4433"
+ const val DEFAULT_ROOM = "android"
+}
+```
+
+For a proper settings screen, add a new Composable in `ui/` that persists to `SharedPreferences` and passes values to `viewModel.startCall(relay, room)`.
+
+### Adding authentication
+
+1. In `CallViewModel.startCall()`, pass a token parameter
+2. In `engine.rs`, after QUIC connect but before CallOffer, send:
+ ```rust
+ transport.send_signal(&SignalMessage::AuthToken { token: auth_token }).await?;
+ ```
+3. Wait for the relay to accept before proceeding to handshake
+4. Start relay with `--auth-url `
+
+### Enabling media encryption
+
+The crypto session is already derived in `engine.rs` but not applied to packets. To enable:
+
+1. Pass `_session` (currently unused) to the send/recv tasks
+2. Before `transport.send_media()`, encrypt the payload:
+ ```rust
+ let mut ciphertext = Vec::new();
+ session.encrypt(&header_bytes, &payload, &mut ciphertext)?;
+ packet.payload = Bytes::from(ciphertext);
+ ```
+3. After `transport.recv_media()`, decrypt:
+ ```rust
+ let mut plaintext = Vec::new();
+ session.decrypt(&header_bytes, &pkt.payload, &mut plaintext)?;
+ pkt.payload = Bytes::from(plaintext);
+ ```
+
+### Adding a new codec / quality profile
+
+1. Define the profile in `wzp-proto/src/codec_id.rs`
+2. Implement `AudioEncoder`/`AudioDecoder` traits in `wzp-codec`
+3. Register in `AdaptiveEncoder`/`AdaptiveDecoder` switch logic
+4. Add to `supported_profiles` in the CallOffer (engine.rs)
+
+### Changing audio parameters
+
+- **Sample rate**: Change `FRAME_SAMPLES` in `audio_android.rs` and `WzpOboeConfig.sample_rate` in `oboe_bridge.cpp`. Must match the codec's expected rate.
+- **Frame duration**: Change `FRAME_SAMPLES` (960 = 20ms at 48kHz, 1920 = 40ms)
+- **Ring buffer size**: Change `RING_CAPACITY` in `audio_android.rs`
+- **AEC tail length**: Change the `100` in `Pipeline::new()` → `EchoCanceller::new(48000, 100)`
+
+### Adding x86_64 support (emulator)
+
+1. `build.gradle.kts`: add `"x86_64"` to `abiFilters`
+2. `cargoNdkBuild` task: add `-t x86_64`
+3. `build.rs`: handle `x86_64-linux-android` target for Oboe
+4. Note: Oboe in the emulator uses a different audio HAL — audio quality will differ
+
+## Dependency Overview
+
+### Rust Crate Dependencies (wzp-android)
+
+| Crate | Version | Purpose | Upgrade risk |
+|-------|---------|---------|--------------|
+| `jni` | 0.21 | Java FFI | Low — stable API |
+| `tokio` | 1.x | Async runtime | Low |
+| `quinn` | 0.11 | QUIC transport | Medium — breaking changes between 0.x |
+| `rustls` | 0.23 | TLS for QUIC | Medium — tied to quinn version |
+| `serde_json` | 1.x | Stats serialization | Low |
+| `anyhow` | 1.x | Error handling | Low |
+| `tracing` | 0.1 | Logging | Low |
+| `rand` | 0.8 | Random seed generation | Low |
+
+### Workspace Crate Dependencies
+
+| Crate | Purpose | Key trait |
+|-------|---------|-----------|
+| `wzp-proto` | Shared types and traits | `MediaTransport`, `AudioEncoder`, `KeyExchange` |
+| `wzp-codec` | Opus + Codec2 + signal processing | `AdaptiveEncoder`, `EchoCanceller` |
+| `wzp-fec` | RaptorQ FEC | `RaptorQFecEncoder` |
+| `wzp-crypto` | Key exchange + encryption | `WarzoneKeyExchange`, `ChaChaSession` |
+| `wzp-transport` | QUIC connection management | `QuinnTransport`, `connect()` |
+
+### Android/Kotlin Dependencies
+
+| Library | Version | Purpose |
+|---------|---------|---------|
+| `compose-bom` | 2024.01.00 | Compose version alignment |
+| `material3` | (from BOM) | UI components |
+| `activity-compose` | 1.8.2 | Activity integration |
+| `lifecycle-runtime-ktx` | 2.7.0 | ViewModel + coroutines |
+| `core-ktx` | 1.12.0 | Kotlin extensions |
+
+## Updating Dependencies
+
+### Rust
+
+```bash
+cargo update -p wzp-android
+cargo ndk -t arm64-v8a build --release -p wzp-android
+```
+
+Watch for `quinn`/`rustls` version coupling. They must be compatible:
+- quinn 0.11 requires rustls 0.23
+
+### Android/Kotlin
+
+Update versions in `android/app/build.gradle.kts`. Key compatibility:
+- `kotlinCompilerExtensionVersion` must match the Kotlin version
+- `compose-bom` version determines all Compose library versions
+- `compileSdk` and `targetSdk` should stay in sync
+
+### NDK
+
+If upgrading the NDK:
+1. Update `ndkVersion` in `build.gradle.kts`
+2. Update `ANDROID_NDK_HOME` environment variable
+3. Update `CC_aarch64_linux_android` and friends
+4. Verify Oboe still builds with the new toolchain
+
+## Key Invariants to Preserve
+
+1. **JNI function names must match package structure**: If the Kotlin package changes, all `Java_com_wzp_engine_WzpEngine_*` functions in `jni_bridge.rs` must be renamed.
+
+2. **Manifest uses fully-qualified class names**: Never use `.ClassName` shorthand because the Gradle namespace (`com.wzp.phone`) differs from the Kotlin package (`com.wzp`).
+
+3. **Stats JSON field names are snake_case**: Rust serializes with serde defaults (snake_case). Kotlin's `CallStats.fromJson()` expects `duration_secs`, `loss_pct`, etc.
+
+4. **Ring buffer ordering**: Producer uses Release store on write index, consumer uses Acquire load. Breaking this causes torn reads.
+
+5. **Codec thread owns Pipeline**: Pipeline is `!Send` (Opus encoder state). It must never be accessed from another thread.
+
+6. **panic::catch_unwind on all JNI functions**: Rust panics unwinding across the FFI boundary is UB. Every JNI-exposed function must catch panics.
+
+7. **Channel capacity (64)**: Both `send_tx` and `recv_tx` are bounded at 64 packets. If the network is slow, packets are dropped (`try_send` best-effort).
+
+## Testing
+
+### Unit Tests (Rust)
+
+```bash
+# Run all workspace tests (host, not Android)
+cargo test
+
+# Run only wzp-android tests (uses oboe_stub.cpp on host)
+cargo test -p wzp-android
+```
+
+Note: Pipeline, codec, FEC, crypto tests run on the host. Audio tests use stubs.
+
+### On-Device Testing
+
+1. Build and install debug APK
+2. Open app, tap CALL
+3. Verify in logcat:
+ - `WzpEngine created via JNI`
+ - `connecting to relay...`
+ - `QUIC connected to relay`
+ - `CallOffer sent`
+ - `handshake complete, call active`
+ - `codec thread started`
+4. Check stats overlay: frame counters should increment
+5. Speak into mic — other connected device should hear audio
+
+### Stress Testing
+
+- Run a call for 30+ minutes — check for memory leaks (stats should be stable)
+- Kill and restart the relay — client should eventually get a connection error
+- Toggle mute rapidly — verify no crashes
+- Switch speaker on/off — verify audio route changes
+
+## Performance Monitoring
+
+Key metrics to watch during a call:
+
+| Metric | Healthy Range | Warning | Critical |
+|--------|--------------|---------|----------|
+| frames_encoded | Increasing ~50/sec | Stalled | 0 |
+| frames_decoded | Increasing ~50/sec | Stalled | 0 |
+| underruns | < 5/min | > 20/min | > 100/min |
+| jitter_buffer_depth | 2-5 | 0 or >10 | N/A |
+| loss_pct | < 5% | 5-20% | > 20% |
+| rtt_ms | < 100ms | 100-300ms | > 500ms |
diff --git a/vault/Android/README.md b/vault/Android/README.md
new file mode 100644
index 0000000..8bcdb50
--- /dev/null
+++ b/vault/Android/README.md
@@ -0,0 +1,46 @@
+---
+tags: [android, wzp]
+type: reference
+---
+
+# WarzonePhone Android Client
+
+The WZP Android client is a native VoIP application built with Kotlin/Jetpack Compose on top of a Rust audio engine. It connects to WZP relay servers over QUIC, providing encrypted voice calls with adaptive quality, forward error correction, and acoustic echo cancellation.
+
+## Quick Start
+
+1. **Build**: `cd android && ./gradlew assembleRelease` (requires NDK 26.1, cargo-ndk)
+2. **Install**: `adb install app/build/outputs/apk/release/app-release.apk`
+3. **Run**: Open "WZ Phone", tap **CALL** to connect to the hardcoded relay
+4. **Relay**: Must be running at the configured address (default `172.16.81.125:4433`)
+
+## Current State (April 2025)
+
+| Feature | Status |
+|---------|--------|
+| QUIC transport to relay | Working |
+| Crypto handshake (X25519 + Ed25519) | Working |
+| Opus 24k encoding/decoding | Working |
+| Oboe audio I/O (48kHz mono) | Working |
+| AEC / AGC signal processing | Working |
+| RaptorQ FEC | Wired (repair symbols not sent yet) |
+| Jitter buffer | Working |
+| Adaptive quality switching | Codec-ready, not network-driven yet |
+| Authentication (featherChat) | Skipped (relay has no --auth-url) |
+| Media encryption (ChaCha20-Poly1305) | Session derived but not applied to packets |
+| Foreground service / wake locks | Implemented, not started from UI |
+
+## Documentation Index
+
+- [Architecture](architecture.md) - System design, data flow diagrams, thread model
+- [Build Guide](build-guide.md) - Build environment setup, dependencies, signing
+- [Debugging](debugging.md) - Crash diagnosis, logcat filters, common issues
+- [Maintenance](maintenance.md) - Code map, dependency management, upgrade paths
+- [Roadmap](roadmap.md) - Planned work and known gaps
+
+## Key Design Decisions
+
+- **Rust native engine**: All audio processing, codecs, FEC, crypto, and networking run in Rust. Kotlin is UI-only.
+- **Lock-free audio**: SPSC ring buffers with atomic ordering between Oboe C++ callbacks and the Rust codec thread. No mutexes in the audio path.
+- **cargo-ndk**: The native library (`libwzp_android.so`) is cross-compiled for `arm64-v8a` using cargo-ndk, invoked automatically by Gradle's `cargoNdkBuild` task.
+- **Single-activity Compose**: One `CallActivity` hosts all UI via Jetpack Compose with `CallViewModel` as the state holder.
diff --git a/vault/Android/Roadmap.md b/vault/Android/Roadmap.md
new file mode 100644
index 0000000..1c06085
--- /dev/null
+++ b/vault/Android/Roadmap.md
@@ -0,0 +1,117 @@
+---
+tags: [android, wzp]
+type: reference
+---
+
+# Roadmap & Known Gaps
+
+## Current State Summary
+
+The Android client can connect to a WZP relay, complete the crypto handshake, and exchange audio in real-time. Two phones on the same network can talk to each other through the relay.
+
+## What Works (April 2025)
+
+- QUIC transport to relay with room-based SFU
+- Full crypto handshake (X25519 ephemeral + Ed25519 signatures)
+- Opus 24kbps encoding/decoding at 48kHz
+- Lock-free audio I/O via Oboe (capture + playout)
+- AEC (acoustic echo cancellation) with 100ms tail
+- AGC (automatic gain control)
+- RaptorQ FEC encoder/decoder (wired to pipeline)
+- Adaptive jitter buffer (10-250 packets)
+- UI with connect/disconnect, mute, speaker, live stats
+- Random identity seed per app launch
+
+## Known Gaps
+
+### P0 — Must fix for usable calls
+
+| Gap | Impact | Where to fix |
+|-----|--------|--------------|
+| **Media encryption not applied** | Audio sent in cleartext over QUIC | `engine.rs` — pass `_session` to send/recv, encrypt/decrypt payloads |
+| **FEC repair symbols not sent** | No loss recovery — audio gaps on packet loss | `engine.rs` send task — call `fec_encoder.generate_repair()` and send repair packets |
+| **Quality reports not sent** | Relay can't monitor quality, no adaptive switching | `engine.rs` — periodically attach `QualityReport` to MediaPacket header |
+| **CallService not started** | Call dies when app is backgrounded | `CallViewModel.startCall()` — call `CallService.start(context)` |
+
+### P1 — Important for production
+
+| Gap | Impact | Where to fix |
+|-----|--------|--------------|
+| **Hardcoded relay address** | Can't change server without rebuild | Add settings screen with `SharedPreferences` |
+| **No reconnection logic** | Connection drop = call over | `engine.rs` network task — detect disconnect, retry with backoff |
+| **No adaptive quality switching** | Stays on GOOD profile even in bad conditions | Wire `AdaptiveQualityController` to network path quality from `QuinnTransport` |
+| **Identity seed not persisted** | New identity every launch | Save seed to Android Keystore or SharedPreferences |
+| **No Bluetooth audio routing** | `AudioRouteManager` exists but not wired to UI | Add Bluetooth button to InCallScreen, call `AudioRouteManager` methods |
+| **No ringtone/notification for incoming** | Only outgoing calls supported | Need signaling for call setup (currently both sides initiate independently) |
+
+### P2 — Nice to have
+
+| Gap | Impact | Where to fix |
+|-----|--------|--------------|
+| **No android_logger** | Rust tracing output lost on Android | Add `android_logger` crate, init in `nativeInit()` |
+| **Stats don't include network metrics** | Loss/RTT/jitter always 0 | Feed `QuinnTransport.path_quality()` back to stats |
+| **No ProGuard/R8 minification** | Release APK larger than necessary | Enable `isMinifyEnabled = true` in build.gradle.kts |
+| **Single ABI (arm64-v8a)** | No support for older 32-bit devices or emulators | Add `armeabi-v7a` and `x86_64` to cargo-ndk build |
+| **No call history** | Can't see past calls | Add Room database for call log |
+| **No contact integration** | Manual relay/room entry | Add contacts with fingerprint-based identity |
+
+## Architecture Evolution Plan
+
+### Phase 1: Make Calls Reliable (current → next)
+
+```
+[x] QUIC connection to relay
+[x] Crypto handshake
+[x] Audio encode/decode pipeline
+[ ] Media encryption (ChaCha20-Poly1305)
+[ ] FEC repair packet transmission
+[ ] Foreground service for background calls
+[ ] Reconnection on network change
+```
+
+### Phase 2: Quality & Polish
+
+```
+[ ] Adaptive quality (GOOD → DEGRADED → CATASTROPHIC switching)
+[ ] Quality reports in MediaPacket headers
+[ ] Network path quality display (real RTT, loss, jitter)
+[ ] Settings screen (relay, room, seed persistence)
+[ ] Bluetooth/wired headset audio routing
+[ ] Rust android_logger for debugging
+```
+
+### Phase 3: Production Features
+
+```
+[ ] featherChat authentication
+[ ] Persistent identity (Android Keystore)
+[ ] Push notifications for incoming calls
+[ ] Multi-party rooms (already supported by relay)
+[ ] Call transfer
+[ ] End-to-end encryption (bypass relay decryption)
+```
+
+## Dependency Upgrade Path
+
+### quinn 0.11 → 0.12 (when released)
+
+Quinn 0.12 will likely require rustls 0.24. Update both together:
+1. `Cargo.toml`: bump quinn and rustls versions
+2. Check `client_config()` and `server_config()` in wzp-transport for API changes
+3. DATAGRAM API may change — check `send_datagram()` / `read_datagram()`
+
+### Compose BOM 2024.01 → 2025.x
+
+The `LinearProgressIndicator` `progress` parameter changed from `Float` to `() -> Float` in Material3 1.2+. If upgrading the BOM:
+
+```kotlin
+// Old (current):
+LinearProgressIndicator(progress = level, ...)
+
+// New (Material3 1.2+):
+LinearProgressIndicator(progress = { level }, ...)
+```
+
+### Kotlin 1.9 → 2.x
+
+Kotlin 2.0 changed the Compose compiler plugin. Update `kotlinCompilerExtensionVersion` in `composeOptions` and the Kotlin Gradle plugin version together.
diff --git a/vault/Architecture/Architecture.md b/vault/Architecture/Architecture.md
new file mode 100644
index 0000000..4823174
--- /dev/null
+++ b/vault/Architecture/Architecture.md
@@ -0,0 +1,1245 @@
+# WarzonePhone Architecture
+
+> Custom lossy VoIP protocol built in Rust. E2E encrypted, FEC-protected, adaptive quality, designed for hostile network conditions.
+
+## System Overview
+
+```mermaid
+graph TB
+ subgraph "Client A (Desktop / Android / CLI)"
+ MIC[Microphone] --> DN[NoiseSuppressor
RNNoise ML]
+ DN --> SD[SilenceDetector
VAD + Hangover]
+ SD --> ENC[CallEncoder
Opus / Codec2]
+ ENC --> FEC_E[FEC Encoder
RaptorQ]
+ FEC_E --> CRYPT_E[ChaCha20-Poly1305
Encrypt]
+ CRYPT_E --> QUIC_S[QUIC Datagram
Send]
+
+ QUIC_R[QUIC Datagram
Recv] --> CRYPT_D[ChaCha20-Poly1305
Decrypt]
+ CRYPT_D --> FEC_D[FEC Decoder
RaptorQ]
+ FEC_D --> JIT[JitterBuffer
Adaptive Playout]
+ JIT --> DEC[CallDecoder
Opus / Codec2]
+ DEC --> SPK[Speaker]
+ end
+
+ subgraph "Relay (SFU)"
+ ACCEPT[Accept QUIC] --> AUTH{Auth?}
+ AUTH -->|token| VALIDATE[POST /v1/auth/validate]
+ AUTH -->|no auth| HS
+ VALIDATE --> HS[Crypto Handshake
X25519 + Ed25519]
+ HS --> ROOM[Room Manager
Named Rooms via SNI]
+ ROOM --> FWD[Forward to
Other Participants]
+ end
+
+ subgraph "Client B"
+ B_SPK[Speaker]
+ B_MIC[Microphone]
+ end
+
+ QUIC_S -->|UDP / QUIC| ACCEPT
+ FWD -->|UDP / QUIC| QUIC_R
+ B_MIC -.->|same pipeline| ACCEPT
+ FWD -.->|same pipeline| B_SPK
+
+ style MIC fill:#4a9eff,color:#fff
+ style SPK fill:#4a9eff,color:#fff
+ style B_MIC fill:#4a9eff,color:#fff
+ style B_SPK fill:#4a9eff,color:#fff
+ style ROOM fill:#ff9f43,color:#fff
+ style CRYPT_E fill:#ee5a24,color:#fff
+ style CRYPT_D fill:#ee5a24,color:#fff
+```
+
+## Crate Dependency Graph
+
+```mermaid
+graph TD
+ PROTO["wzp-proto
Types, Traits, Wire Format"]
+
+ CODEC["wzp-codec
Opus + Codec2 + RNNoise"]
+ FEC["wzp-fec
RaptorQ FEC"]
+ CRYPTO["wzp-crypto
ChaCha20 + Identity"]
+ TRANSPORT["wzp-transport
QUIC / Quinn"]
+ VIDEO["wzp-video
H.264 + H.265 + AV1"]
+
+ RELAY["wzp-relay
Relay Daemon"]
+ CLIENT["wzp-client
CLI + Call Engine"]
+ WEB["wzp-web
Browser Bridge"]
+
+ PROTO --> CODEC
+ PROTO --> FEC
+ PROTO --> CRYPTO
+ PROTO --> TRANSPORT
+ PROTO --> VIDEO
+
+ CODEC --> CLIENT
+ FEC --> CLIENT
+ CRYPTO --> CLIENT
+ TRANSPORT --> CLIENT
+ VIDEO --> CLIENT
+
+ CODEC --> RELAY
+ FEC --> RELAY
+ CRYPTO --> RELAY
+ TRANSPORT --> RELAY
+ VIDEO --> RELAY
+
+ CLIENT --> WEB
+ TRANSPORT --> WEB
+ CRYPTO --> WEB
+
+ FC["warzone-protocol
featherChat Identity"] -.->|path dep| CRYPTO
+
+ style PROTO fill:#6c5ce7,color:#fff
+ style RELAY fill:#ff9f43,color:#fff
+ style CLIENT fill:#00b894,color:#fff
+ style WEB fill:#0984e3,color:#fff
+ style FC fill:#fd79a8,color:#fff
+ style VIDEO fill:#a29bfe,color:#fff
+```
+
+**Star pattern**: Each leaf crate (`wzp-codec`, `wzp-fec`, `wzp-crypto`, `wzp-transport`, `wzp-video`) depends only on `wzp-proto`. No leaf depends on another leaf. Integration crates (`wzp-relay`, `wzp-client`, `wzp-web`) depend on all leaves.
+
+## Audio Encode Pipeline
+
+```mermaid
+sequenceDiagram
+ participant Mic as Microphone
(48kHz)
+ participant Ring as SPSC Ring
(lock-free)
+ participant RNN as RNNoise
(2 x 480)
+ participant VAD as SilenceDetector
+ participant Codec as Opus / Codec2
+ participant DT as DredTuner
(wzp-proto)
+ participant FEC as RaptorQ FEC
+ participant INT as Interleaver
(depth=3)
+ participant HDR as MediaHeader
(16B or Mini 5B)
+ participant Enc as ChaCha20-Poly1305
+ participant QUIC as QUIC Datagram
+ participant QPS as QuinnPathSnapshot
+
+ Mic->>Ring: f32 x 512 (macOS callback)
+ Ring->>Ring: Accumulate to 960 samples
+ Ring->>RNN: PCM i16 x 960 (20ms frame)
+ RNN->>VAD: Denoised audio
+ alt Speech active (or hangover)
+ VAD->>Codec: Encode active frame
+ else Silence (>100ms)
+ VAD->>Codec: ComfortNoise (every 200ms)
+ end
+
+ Note over QPS,DT: Every 25 frames (~500ms)
+ QPS->>DT: loss_pct, rtt_ms, jitter_ms
+ DT->>Codec: set_dred_duration() + set_expected_loss()
+
+ alt Opus tier (any bitrate)
+ Codec->>HDR: Compressed bytes + DRED side-channel (no RaptorQ)
+ else Codec2 tier
+ Codec->>FEC: Compressed bytes (pad to 256B symbol)
+ FEC->>FEC: Accumulate block (5-10 symbols)
+ FEC->>INT: Source + repair symbols
+ INT->>HDR: Interleaved packets
+ end
+ HDR->>Enc: Header as AAD
+ Enc->>QUIC: Encrypted payload + 16B tag
+```
+
+### Key Details
+
+- macOS delivers **512 f32** samples per callback (not configurable to 960)
+- Ring buffer accumulates to **960 samples** (20ms at 48 kHz) for codec frame
+- RNNoise processes **2 x 480** samples (ML-based noise suppression via nnnoiseless)
+- Silence detection uses VAD + 100ms hangover before switching to ComfortNoise
+- FEC symbols are padded to **256 bytes** with a 2-byte LE length prefix
+- MiniHeaders (5 bytes) replace full headers (16 bytes) for 49 of every 50 audio frames; video always uses full headers
+- DRED tuner polls quinn path stats every 25 frames (~500ms) and adjusts DRED lookback duration continuously
+- Opus tiers bypass RaptorQ entirely -- DRED handles loss recovery at the codec layer
+- Opus6k DRED window: 1040ms (maximum libopus allows)
+
+## Audio Decode Pipeline
+
+```mermaid
+sequenceDiagram
+ participant QUIC as QUIC Datagram
+ participant Dec as ChaCha20-Poly1305
+ participant AR as Anti-Replay
(sliding window)
+ participant HDR as Header Parse
+ participant DEINT as De-interleaver
+ participant FEC as RaptorQ FEC
(reconstruct)
+ participant JIT as JitterBuffer
(BTreeMap)
+ participant Codec as Opus / Codec2
+ participant Ring as SPSC Ring
(lock-free)
+ participant SPK as Speaker
+
+ QUIC->>Dec: Encrypted packet
+ Dec->>AR: Decrypt (header = AAD)
+ AR->>AR: Check seq window (reject replay)
+ AR->>HDR: Verified packet
+
+ alt Opus packet
+ HDR->>JIT: Direct to jitter buffer (no FEC/interleave)
+ else Codec2 packet
+ HDR->>DEINT: MediaHeader + payload
+ DEINT->>FEC: Reordered symbols by block
+ FEC->>FEC: Attempt decode (need K of K+R)
+ FEC->>JIT: Recovered audio frames
+ end
+
+ JIT->>JIT: BTreeMap ordered by seq
+ JIT->>JIT: Wait until depth >= target
+
+ alt Packet present
+ JIT->>Codec: Pop lowest seq frame
+ else Packet missing (Opus)
+ JIT->>Codec: DRED reconstruction (neural)
+ alt DRED fails or unavailable
+ Codec->>Codec: Classical PLC fallback
+ end
+ else Packet missing (Codec2)
+ Codec->>Codec: Classical PLC
+ end
+
+ Codec->>Ring: PCM i16 x 960
+ Ring->>SPK: Audio callback pulls samples
+```
+
+### Key Details
+
+- Anti-replay uses a **64-packet sliding window** to reject duplicates
+- FEC decoder needs any **K of K+R** symbols to reconstruct a block
+- Jitter buffer target: **10 packets (200ms)** for client, **50 packets (1s)** for relay
+- Desktop client uses **direct playout** (no jitter buffer) with lock-free ring
+- Codec2 frames at 8 kHz are resampled to 48 kHz transparently
+- DRED reconstruction: on packet loss, decoder tries neural DRED reconstruction before falling back to classical PLC
+- Jitter-spike detection pre-emptively boosts DRED to ceiling when jitter variance spikes >30%
+
+## Relay SFU Forwarding
+
+```mermaid
+graph TB
+ subgraph "Room Mode (Default SFU)"
+ C1[Client 1
Alice] -->|"QUIC SNI=room-hash"| RM[Room Manager]
+ C2[Client 2
Bob] -->|"QUIC SNI=room-hash"| RM
+ C3[Client 3
Charlie] -->|"QUIC SNI=room-hash"| RM
+ RM --> R1["Room 'podcast'"]
+ R1 -->|"fan-out (skip sender)"| C1
+ R1 -->|"fan-out (skip sender)"| C2
+ R1 -->|"fan-out (skip sender)"| C3
+ end
+
+ subgraph "Forward Mode (--remote)"
+ C4[Client] -->|QUIC| RA[Relay A]
+ RA -->|"FEC decode
jitter buffer
FEC re-encode"| RB[Relay B
--remote]
+ RB -->|QUIC| C5[Client]
+ end
+
+ subgraph "Probe Mode (--probe)"
+ PA[Relay A] -->|"Ping 1/s
~50 bytes"| PB[Relay B]
+ PB -->|Pong| PA
+ PA --> PM[Prometheus
RTT / Loss / Jitter]
+ end
+
+ style RM fill:#ff9f43,color:#fff
+ style R1 fill:#fdcb6e
+ style PM fill:#0984e3,color:#fff
+```
+
+### SFU Fan-out Rules
+
+1. Each incoming datagram is forwarded to all other participants in the room
+2. The sender is excluded from fan-out (no echo)
+3. If one send fails, the relay continues to the next participant (best-effort)
+4. The relay never decodes or re-encodes audio (preserves E2E encryption)
+5. With trunking enabled, packets to the same receiver are batched into TrunkFrames (flushed every 5ms)
+6. Relay tracks per-participant quality from QualityReport trailers and broadcasts `QualityDirective` when the room-wide tier degrades (coordinated codec switching)
+
+## Federation Topology
+
+```mermaid
+graph TB
+ subgraph "Relay A (EU)"
+ A_R["Room Manager"]
+ A_F["Federation
Manager"]
+ A1["Alice (local)"]
+ A2["Bob (local)"]
+ end
+
+ subgraph "Relay B (US)"
+ B_R["Room Manager"]
+ B_F["Federation
Manager"]
+ B1["Charlie (local)"]
+ end
+
+ subgraph "Relay C (APAC)"
+ C_R["Room Manager"]
+ C_F["Federation
Manager"]
+ C1["Dave (local)"]
+ end
+
+ A1 -->|media| A_R
+ A2 -->|media| A_R
+ B1 -->|media| B_R
+ C1 -->|media| C_R
+
+ A_F <-->|"SNI='_federation'
GlobalRoomActive
media forward"| B_F
+ A_F <-->|"SNI='_federation'
GlobalRoomActive
media forward"| C_F
+ B_F <-->|"SNI='_federation'
GlobalRoomActive
media forward"| C_F
+
+ A_R --> A_F
+ B_R --> B_F
+ C_R --> C_F
+
+ style A_F fill:#6c5ce7,color:#fff
+ style B_F fill:#6c5ce7,color:#fff
+ style C_F fill:#6c5ce7,color:#fff
+ style A_R fill:#ff9f43,color:#fff
+ style B_R fill:#ff9f43,color:#fff
+ style C_R fill:#ff9f43,color:#fff
+```
+
+### Federation Protocol Flow
+
+```mermaid
+sequenceDiagram
+ participant RA as Relay A
+ participant RB as Relay B
+
+ Note over RA: Startup: connect to configured peers
+
+ RA->>RB: QUIC connect (SNI="_federation")
+ RA->>RB: FederationHello { tls_fingerprint }
+ RB->>RB: Verify fingerprint against [[trusted]]
+
+ Note over RA,RB: Federation link established
+
+ Note over RA: Alice joins global room "podcast"
+ RA->>RB: GlobalRoomActive { room: "podcast" }
+
+ Note over RB: Charlie joins global room "podcast"
+ RB->>RA: GlobalRoomActive { room: "podcast" }
+
+ Note over RA,RB: Media bridging active
+
+ loop Every media packet in global room
+ RA->>RB: [room_hash:8][encrypted_media]
+ RB->>RA: [room_hash:8][encrypted_media]
+ end
+
+ Note over RA: Last local participant leaves
+ RA->>RB: GlobalRoomInactive { room: "podcast" }
+```
+
+## Wire Formats
+
+### `MediaHeader` v2 (16 bytes, byte-aligned)
+
+```
+Byte 0: version (u8) 0x02
+Byte 1: flags (u8) [T:1][Q:1][KeyFrame:1][FrameEnd:1][reserved:4]
+ T = FEC repair, Q = QualityReport trailer
+ KeyFrame = packet belongs to an I-frame (video)
+ FrameEnd = last packet of an access unit (video)
+Byte 2: media_type (u8) 0=audio, 1=video, 2=data, 3=control
+Byte 3: codec_id (u8) widened from 4-bit (room for 256 codec IDs)
+Byte 4: stream_id (u8) simulcast layer; 0=base
+Byte 5: fec_ratio (u8) 0..200 → 0.0..2.0
+Bytes 6-9: sequence (u32 BE) wrapping packet sequence number
+Bytes 10-13: timestamp_ms (u32 BE) milliseconds since session start
+Bytes 14-15: fec_block_id (u16 BE)
+ audio: low 8 bits = block_id, high 8 bits = symbol_idx
+ video: full u16 block_id (large blocks for I-frames)
+```
+
+#### CodecID Values
+
+**Audio codecs (media_type = 0)**
+
+| Value | Codec | Bitrate | Sample Rate | Frame Duration |
+|-------|-------|---------|-------------|---------------|
+| 0 | Opus 24k | 24 kbps | 48 kHz | 20ms |
+| 1 | Opus 16k | 16 kbps | 48 kHz | 20ms |
+| 2 | Opus 6k | 6 kbps | 48 kHz | 40ms |
+| 3 | Codec2 3200 | 3.2 kbps | 8 kHz | 20ms |
+| 4 | Codec2 1200 | 1.2 kbps | 8 kHz | 40ms |
+| 5 | ComfortNoise | 0 | 48 kHz | 20ms |
+| 6 | Opus 32k | 32 kbps | 48 kHz | 20ms |
+| 7 | Opus 48k | 48 kbps | 48 kHz | 20ms |
+| 8 | Opus 64k | 64 kbps | 48 kHz | 20ms |
+
+**Video codecs (media_type = 1)**
+
+| Value | Codec | Notes |
+|-------|-------|-------|
+| 9 | H.264 Baseline | Universal HW encode coverage |
+| 10 | H.264 Main | Slight quality win over baseline |
+| 11 | H.265 Main | Apple A10+, Snapdragon ~2017, NVENC GTX 9xx+; ~30% better than H.264 |
+| 12 | AV1 Main | Apple M3/A17+, Snapdragon 8 Gen 3+, RTX 40+; best efficiency, narrow HW |
+
+### `MiniHeader` v2 (5 bytes)
+
+```
+[FRAME_TYPE_MINI = 0x01]
+Byte 0: seq_delta (u8) delta from last full header's seq
+Bytes 1-2: timestamp_delta_ms (u16 BE)
+Bytes 3-4: payload_len (u16 BE)
+```
+
+Used for audio only (49 of every 50 frames). Saves 11 bytes per audio packet vs the full 16B header. Full header is sent every 50th frame to resynchronize state. Video always uses full 16B headers.
+
+### TrunkFrame (batched datagrams)
+
+```
+[count: u16]
+ [session_id: 2][len: u16][payload: len] x count
+```
+
+Packs multiple session packets into one QUIC datagram. Maximum 10 entries or PMTUD-discovered MTU (starts at 1200, grows to ~1452 on Ethernet), flushed every 5ms.
+
+### QualityReport (4 bytes, optional trailer)
+
+```
+Byte 0: loss_pct (0-255 maps to 0-100%)
+Byte 1: rtt_4ms (0-255 maps to 0-1020ms, resolution 4ms)
+Byte 2: jitter_ms (0-255ms)
+Byte 3: bitrate_cap_kbps (0-255 kbps)
+```
+
+Appended to a media packet when the Q flag is set in the MediaHeader.
+
+## Path MTU Discovery
+
+Quinn's PLPMTUD is enabled with:
+- `initial_mtu`: 1200 bytes (QUIC minimum, always safe)
+- `upper_bound`: 1452 bytes (Ethernet minus IP/UDP/QUIC headers)
+- `interval`: 300s (re-probe every 5 minutes)
+- `black_hole_cooldown`: 30s (faster retry on lossy links)
+
+The discovered MTU is exposed via `QuinnPathSnapshot::current_mtu` and used by:
+- `TrunkedForwarder`: refreshes `max_bytes` on every send to fill larger datagrams
+- Future video framer: larger MTU = fewer application-layer fragments per frame
+
+## Continuous DRED Tuning
+
+Instead of locking DRED duration to 3 discrete quality tiers, the `DredTuner` (in `wzp-proto::dred_tuner`) maps live path quality to a continuous DRED duration:
+
+| Input | Source | Update Rate |
+|-------|--------|-------------|
+| Loss % | `QuinnPathSnapshot::loss_pct` (from quinn ACK frames) | Every 25 packets (~500ms) |
+| RTT ms | `QuinnPathSnapshot::rtt_ms` (quinn congestion controller) | Every 25 packets |
+| Jitter ms | `PathMonitor::jitter_ms` (EWMA of RTT variance) | Every 25 packets |
+
+### Mapping Logic
+
+- **Baseline**: codec-tier default (Studio=100ms, Good=200ms, Degraded=500ms)
+- **Ceiling**: codec-tier max (Studio=300ms, Good=500ms, Degraded=1040ms)
+- **Continuous**: linear interpolation between baseline and ceiling based on loss (0%->baseline, 40%->ceiling)
+- **RTT phantom loss**: high RTT (>200ms) adds phantom loss contribution to keep DRED generous
+- **Jitter spike**: >30% EWMA spike pre-emptively boosts to ceiling for ~5s cooldown
+
+### Output
+
+`DredTuning { dred_frames: u8, expected_loss_pct: u8 }` -> fed to `CallEncoder::apply_dred_tuning()` -> `OpusEncoder::set_dred_duration()` + `set_expected_loss()`
+
+## Signal Message Handshake Flow
+
+```mermaid
+sequenceDiagram
+ participant C as Client
+ participant R as Relay
+
+ C->>R: QUIC Connect (SNI = hashed room name)
+
+ alt Auth enabled (--auth-url)
+ C->>R: SignalMessage::AuthToken { token }
+ R->>R: POST auth_url to validate
+ R-->>C: (connection closed if invalid)
+ end
+
+ C->>R: CallOffer { identity_pub, ephemeral_pub, signature, supported_profiles }
+ R->>R: Verify Ed25519 signature
+ R->>R: Generate ephemeral X25519
+ R->>R: shared_secret = DH(eph_relay, eph_client)
+ R->>R: session_key = HKDF(shared_secret, "warzone-session-key")
+ R->>C: CallAnswer { identity_pub, ephemeral_pub, signature, chosen_profile }
+
+ C->>C: Verify signature
+ C->>C: Derive same session_key
+
+ Note over C,R: Session established -- both have ChaCha20-Poly1305 key
+
+ C->>R: RoomUpdate (join notification broadcast)
+
+ loop Media exchange
+ C->>R: QUIC Datagram (encrypted media)
+ R->>C: QUIC Datagram (forwarded from others)
+ end
+
+ opt Every 65,536 packets
+ C->>R: Rekey { new_ephemeral_pub, signature }
+ R->>C: Rekey { new_ephemeral_pub, signature }
+ Note over C,R: New session key via fresh DH
+ end
+
+ C->>R: Hangup { reason: Normal }
+ R->>R: Remove from room, broadcast RoomUpdate
+```
+
+## Relay Concurrency Model
+
+### Threading
+- Multi-threaded Tokio runtime (all available cores, work-stealing scheduler)
+- Task-per-connection: each QUIC connection gets a dedicated `tokio::spawn`
+- Task-per-participant-per-room: each participant's media forwarding loop is independent
+
+### Shared State & Locking
+
+The `RoomManager` stores `DashMap>>`. The DashMap guard is held only long enough to clone the `Arc`; all per-room operations then acquire the room-level `RwLock`. Concurrent fan-out calls share a read lock; join/leave acquire write lock.
+
+| Lock | Protected Data | Hold Duration | Contention |
+|------|---------------|---------------|------------|
+| `DashMap>>` | Room registry | Instant (clone Arc only) | Near-zero |
+| `Room` (RwLock) | Participants, quality tiers | ~1ms/packet (read); ~1ms (write on join/leave) | Low (concurrent reads) |
+| `PresenceRegistry` (Mutex) | Fingerprint registrations | ~1ms | Low (join/leave only) |
+| `SessionManager` (Mutex) | Active session tracking | ~1ms | Low |
+| `FederationManager.peer_links` (Mutex) | Peer connections | ~10ms during forward | Per-federation-packet |
+
+### Scaling Characteristics
+
+- **Many small rooms**: Scales well across all cores (rooms are independent)
+- **Large single room (100+ participants)**: Fan-out reads share RwLock (non-blocking); only join/leave serializes
+- **Federation**: Per-peer tasks scale; `peer_links` lock held during send loop
+
+## Client Architecture
+
+### Desktop Engine (Tauri)
+
+```mermaid
+graph TB
+ subgraph "Tauri Frontend (HTML/JS)"
+ UI[Connect / Call UI]
+ SET[Settings Panel]
+ end
+
+ subgraph "Tauri Rust Backend"
+ CMD[Tauri Commands
connect/disconnect/toggle]
+ ENG[WzpEngine
State Machine]
+ end
+
+ subgraph "Audio I/O"
+ CPAL_C[CPAL Capture
or VoiceProcessingIO]
+ RING_C[SPSC Ring
Capture]
+ RING_P[SPSC Ring
Playout]
+ CPAL_P[CPAL Playback
or VoiceProcessingIO]
+ end
+
+ subgraph "Network Tasks (tokio)"
+ SEND[Send Loop
encode + encrypt]
+ RECV[Recv Loop
decrypt + decode]
+ SIG[Signal Handler
room updates]
+ end
+
+ UI --> CMD
+ SET --> CMD
+ CMD --> ENG
+ ENG --> SEND
+ ENG --> RECV
+ ENG --> SIG
+
+ CPAL_C --> RING_C --> SEND
+ RECV --> RING_P --> CPAL_P
+
+ style ENG fill:#00b894,color:#fff
+ style SEND fill:#0984e3,color:#fff
+ style RECV fill:#0984e3,color:#fff
+```
+
+Key design decisions:
+- **Lock-free SPSC rings** between audio callbacks and network tasks (no mutex on audio thread)
+- **VoiceProcessingIO** on macOS for OS-level AEC (CPAL uses HalOutput which has no AEC)
+- **Direct playout** -- no jitter buffer on client; audio callback pulls from ring
+- **Release builds required** -- debug builds too slow for real-time audio
+
+### Android Engine (Kotlin + JNI)
+
+> **Note (2026-05-12):** The Kotlin+JNI Android app (`android/app/`) described below is superseded by the **Tauri 2.x mobile build** (`desktop/src-tauri/` + `crates/wzp-native/`). The Tauri approach uses the same Rust call engine as desktop, with Oboe audio via `wzp-native` cdylib. The Kotlin codebase is maintained for reference but the Tauri build is the live production app.
+
+```mermaid
+graph TB
+ subgraph "Compose UI"
+ CALL[CallActivity]
+ SET[SettingsScreen]
+ VM[CallViewModel]
+ end
+
+ subgraph "Service Layer"
+ SVC[CallService
Foreground Service]
+ PIPE[AudioPipeline
AudioTrack + AudioRecord]
+ end
+
+ subgraph "Rust Engine (JNI)"
+ JNI[WzpEngine.kt
JNI bridge]
+ NATIVE[libwzp_android.so
Rust call engine]
+ end
+
+ subgraph "Android Audio"
+ REC[AudioRecord
+ AEC effect]
+ TRK[AudioTrack
low-latency]
+ end
+
+ CALL --> VM
+ SET --> VM
+ VM --> SVC
+ SVC --> PIPE
+ PIPE --> JNI
+ JNI --> NATIVE
+
+ REC --> PIPE
+ PIPE --> TRK
+
+ style NATIVE fill:#00b894,color:#fff
+ style SVC fill:#ff9f43,color:#fff
+ style PIPE fill:#0984e3,color:#fff
+```
+
+Key design decisions:
+- **Foreground service** keeps audio alive when the screen is off
+- **AudioRecord + AudioTrack** with Android's built-in AEC (AudioEffect)
+- **Lock-free AudioRing** with preallocated Vec (not push/pop) to avoid allocation on audio thread
+- **JNI bridge** marshals PCM frames between Kotlin and Rust
+
+### CLI Architecture
+
+```mermaid
+graph TB
+ subgraph "CLI Modes"
+ LIVE[--live
Mic + Speaker]
+ TONE[--send-tone
Sine Generator]
+ FILE[--send-file
PCM Reader]
+ ECHO[--echo-test
Quality Analysis]
+ DRIFT[--drift-test
Clock Analysis]
+ SWEEP[--sweep
Buffer Sweep]
+ end
+
+ subgraph "Call Engine"
+ ENCODE[CallEncoder
codec + FEC]
+ DECODE[CallDecoder
FEC + codec]
+ QA[QualityAdapter
adaptive switching]
+ end
+
+ subgraph "Transport"
+ QUIC[QuinnTransport
send/recv media + signal]
+ HS[Handshake
X25519 + Ed25519]
+ end
+
+ LIVE --> ENCODE
+ TONE --> ENCODE
+ FILE --> ENCODE
+ ENCODE --> QUIC
+ QUIC --> DECODE
+ ECHO --> ENCODE
+ ECHO --> DECODE
+ DRIFT --> ENCODE
+ HS --> QUIC
+
+ style ENCODE fill:#00b894,color:#fff
+ style DECODE fill:#00b894,color:#fff
+ style QUIC fill:#0984e3,color:#fff
+```
+
+## Adaptive Quality System
+
+```mermaid
+graph LR
+ subgraph GOOD ["GOOD (28.8 kbps)"]
+ G_C[Opus 24kbps]
+ G_F[FEC 20%]
+ G_FR[20ms frames]
+ end
+
+ subgraph DEGRADED ["DEGRADED (9.0 kbps)"]
+ D_C[Opus 6kbps]
+ D_F[FEC 50%]
+ D_FR[40ms frames]
+ end
+
+ subgraph CATASTROPHIC ["CATASTROPHIC (2.4 kbps)"]
+ C_C[Codec2 1200bps]
+ C_F[FEC 100%]
+ C_FR[40ms frames]
+ end
+
+ GOOD -->|"loss>10% or RTT>400ms
3 consecutive reports"| DEGRADED
+ DEGRADED -->|"loss>40% or RTT>600ms
3 consecutive"| CATASTROPHIC
+ CATASTROPHIC -->|"loss<10% and RTT<400ms
10 consecutive"| DEGRADED
+ DEGRADED -->|"loss<10% and RTT<400ms
10 consecutive"| GOOD
+
+ style GOOD fill:#00b894,color:#fff
+ style DEGRADED fill:#fdcb6e
+ style CATASTROPHIC fill:#e17055,color:#fff
+```
+
+Hysteresis prevents tier flapping: **fast downgrade** (3 reports, or 2 on cellular) and **slow upgrade** (10 reports, one tier at a time).
+
+## Cryptographic Handshake
+
+```mermaid
+sequenceDiagram
+ participant C as Caller
+ participant R as Relay / Callee
+
+ Note over C: Derive identity from seed
Ed25519 + X25519 via HKDF
+
+ C->>C: Generate ephemeral X25519
+ C->>C: Sign(ephemeral_pub || "call-offer")
+ C->>R: CallOffer { identity_pub, ephemeral_pub, signature, profiles }
+
+ R->>R: Verify Ed25519 signature
+ R->>R: Generate ephemeral X25519
+ R->>R: shared_secret = DH(eph_b, eph_a)
+ R->>R: session_key = HKDF(shared_secret, "warzone-session-key")
+ R->>R: Sign(ephemeral_pub || "call-answer")
+ R->>C: CallAnswer { identity_pub, ephemeral_pub, signature, profile }
+
+ C->>C: Verify signature
+ C->>C: shared_secret = DH(eph_a, eph_b)
+ C->>C: session_key = HKDF(shared_secret)
+
+ Note over C,R: Both have identical ChaCha20-Poly1305 session key
+ C->>R: Encrypted media (QUIC datagrams)
+ R->>C: Encrypted media (QUIC datagrams)
+
+ Note over C,R: Rekey every 65,536 packets
New ephemeral DH + HKDF mix
+```
+
+## Identity Model
+
+```mermaid
+graph TD
+ SEED["32-byte Seed
(BIP39 Mnemonic: 24 words)"] --> HKDF1["HKDF
salt=None
info='warzone-ed25519'"]
+ SEED --> HKDF2["HKDF
salt=None
info='warzone-x25519'"]
+
+ HKDF1 --> ED["Ed25519 SigningKey
Digital Signatures"]
+ HKDF2 --> X25519["X25519 StaticSecret
Key Agreement"]
+
+ ED --> VKEY["Ed25519 VerifyingKey
(Public)"]
+ X25519 --> XPUB["X25519 PublicKey
(Public)"]
+
+ VKEY --> FP["Fingerprint
SHA-256(pubkey) truncated 16 bytes
xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx"]
+
+ style SEED fill:#6c5ce7,color:#fff
+ style FP fill:#fd79a8,color:#fff
+ style ED fill:#ee5a24,color:#fff
+ style X25519 fill:#00b894,color:#fff
+```
+
+## Adaptive Jitter Buffer
+
+```mermaid
+graph TD
+ PKT[Incoming Packet] --> SEQ{Sequence Check}
+ SEQ -->|Duplicate| DROP[Drop + AntiReplay]
+ SEQ -->|Valid| BUF["BTreeMap Buffer
(ordered by seq)"]
+
+ BUF --> ADAPT["AdaptivePlayoutDelay
(EMA jitter tracking)"]
+ ADAPT --> TARGET["target_delay =
ceil(jitter_ema / 20ms) + 2"]
+
+ BUF --> READY{"depth >= target?"}
+ READY -->|No| WAIT["Wait (Underrun++)"]
+ READY -->|Yes| POP[Pop lowest seq]
+ POP --> DECODE[Decode to PCM]
+ DECODE --> PLAY[Playout]
+
+ BUF --> OVERFLOW{"depth > max?"}
+ OVERFLOW -->|Yes| EVICT["Drop oldest (Overrun++)"]
+
+ style ADAPT fill:#fdcb6e
+ style DROP fill:#e17055,color:#fff
+ style EVICT fill:#e17055,color:#fff
+```
+
+## FEC Protection (RaptorQ)
+
+```mermaid
+graph LR
+ subgraph "Encoder"
+ F1[Frame 1] --> BLK["Source Block
(5-10 frames)"]
+ F2[Frame 2] --> BLK
+ F3[Frame 3] --> BLK
+ F4[Frame 4] --> BLK
+ F5[Frame 5] --> BLK
+ BLK --> SRC[5 Source Symbols]
+ BLK --> REP["1-10 Repair Symbols
(ratio dependent)"]
+ SRC --> INT["Interleaver
(depth=3)"]
+ REP --> INT
+ end
+
+ subgraph "Network"
+ INT --> LOSS{Packet Loss}
+ LOSS -->|some lost| RCV[Received Symbols]
+ end
+
+ subgraph "Decoder"
+ RCV --> DEINT[De-interleaver]
+ DEINT --> RAPTORQ["RaptorQ Decoder
Reconstruct from
any K of K+R symbols"]
+ RAPTORQ --> OUT[Original Frames]
+ end
+
+ style LOSS fill:#e17055,color:#fff
+ style RAPTORQ fill:#00b894,color:#fff
+```
+
+## Telemetry Stack
+
+```mermaid
+graph TB
+ subgraph "Relay"
+ RM["RelayMetrics
sessions, rooms, packets"]
+ SM["SessionMetrics
per-session jitter, loss, RTT"]
+ PM["ProbeMetrics
inter-relay RTT, loss"]
+ RM --> PROM1["GET /metrics :9090"]
+ SM --> PROM1
+ PM --> PROM1
+ end
+
+ subgraph "Web Bridge"
+ WM["WebMetrics
connections, frames, latency"]
+ WM --> PROM2["GET /metrics :8080"]
+ end
+
+ subgraph "Client"
+ CM["JitterStats + QualityAdapter"]
+ CM --> JSONL["--metrics-file
JSONL 1 line/sec"]
+ end
+
+ PROM1 --> GRAF["Grafana Dashboard
4 rows, 18 panels"]
+ PROM2 --> GRAF
+ JSONL --> ANALYSIS[Offline Analysis]
+
+ style GRAF fill:#ff6b6b,color:#fff
+ style PROM1 fill:#0984e3,color:#fff
+ style PROM2 fill:#0984e3,color:#fff
+```
+
+## Deployment Topology
+
+```mermaid
+graph TB
+ subgraph "Region A"
+ RA["wzp-relay A
:4433 UDP"]
+ WA["wzp-web A
:8080 HTTPS"]
+ WA --> RA
+ end
+
+ subgraph "Region B"
+ RB["wzp-relay B
:4433 UDP"]
+ WB["wzp-web B
:8080 HTTPS"]
+ WB --> RB
+ end
+
+ RA <-->|"Probe 1/s + Federation"| RB
+
+ BA[Browser A] -->|WSS| WA
+ BB[Browser B] -->|WSS| WB
+ CA[CLI Client] -->|QUIC| RA
+ DA[Desktop Client] -->|QUIC| RA
+ MA[Android Client] -->|QUIC| RB
+
+ PROM[Prometheus] -->|scrape| RA
+ PROM -->|scrape| RB
+ PROM -->|scrape| WA
+ PROM --> GRAF[Grafana]
+
+ FC[featherChat Server] -->|auth validate| RA
+ FC -->|auth validate| RB
+
+ style RA fill:#ff9f43,color:#fff
+ style RB fill:#ff9f43,color:#fff
+ style GRAF fill:#ff6b6b,color:#fff
+ style FC fill:#fd79a8,color:#fff
+```
+
+## Session State Machine
+
+```mermaid
+stateDiagram-v2
+ [*] --> Idle
+ Idle --> Connecting: connect()
+ Connecting --> Handshaking: QUIC established
+ Handshaking --> Active: CallOffer/Answer complete
+ Active --> Rekeying: 65,536 packets
+ Rekeying --> Active: new key derived
+ Active --> Closed: Hangup / Error / Timeout
+ Rekeying --> Closed: Error
+ Connecting --> Closed: Timeout
+ Handshaking --> Closed: Signature fail
+
+ note right of Active: Media flows (encrypted)
+ note right of Rekeying: Media continues while rekeying
+```
+
+## Project Structure
+
+```
+warzonePhone/
+├── Cargo.toml # Workspace root
+├── crates/
+│ ├── wzp-proto/ # Protocol types, traits, wire format
+│ │ └── src/
+│ │ ├── codec_id.rs # CodecId, QualityProfile
+│ │ ├── error.rs # Error types
+│ │ ├── jitter.rs # JitterBuffer, AdaptivePlayoutDelay
+│ │ ├── packet.rs # MediaHeader, MiniHeader, TrunkFrame, SignalMessage
+│ │ ├── quality.rs # Tier, AdaptiveQualityController
+│ │ ├── session.rs # SessionState machine
+│ │ └── traits.rs # AudioEncoder, FecEncoder, CryptoSession, etc.
+│ ├── wzp-codec/ # Audio codecs
+│ │ └── src/
+│ │ ├── adaptive.rs # AdaptiveEncoder/Decoder (Opus + Codec2)
+│ │ ├── denoise.rs # NoiseSuppressor (RNNoise / nnnoiseless)
+│ │ └── silence.rs # SilenceDetector, ComfortNoise
+│ ├── wzp-fec/ # Forward error correction
+│ │ └── src/
+│ │ ├── encoder.rs # RaptorQFecEncoder
+│ │ ├── decoder.rs # RaptorQFecDecoder
+│ │ └── interleave.rs # Interleaver (burst protection)
+│ ├── wzp-crypto/ # Cryptography + identity
+│ │ └── src/
+│ │ ├── identity.rs # Seed, Fingerprint, hash_room_name
+│ │ ├── handshake.rs # WarzoneKeyExchange (X25519 + Ed25519)
+│ │ ├── session.rs # ChaChaSession (ChaCha20-Poly1305)
+│ │ ├── nonce.rs # Deterministic nonce construction
+│ │ ├── anti_replay.rs # Sliding window replay protection
+│ │ └── rekey.rs # Forward secrecy rekeying
+│ ├── wzp-transport/ # QUIC transport layer
+│ │ └── src/lib.rs # QuinnTransport, send/recv media/signal/trunk
+│ ├── wzp-video/ # Video codecs + framer
+│ │ └── src/
+│ │ ├── factory.rs # VideoEncoder factory (platform dispatch)
+│ │ ├── framer.rs # NAL fragmentation (H.264/H.265)
+│ │ ├── depacketizer.rs # NAL reassembly, access unit emit
+│ │ ├── controller.rs # VideoQualityController
+│ │ ├── simulcast.rs # Simulcast layer management
+│ │ ├── encoder_mode.rs # Encoder mode selection
+│ │ ├── av1_obu.rs # AV1 OBU framing + depacketizer
+│ │ ├── dav1d.rs # dav1d AV1 software decoder
+│ │ ├── svt_av1.rs # SVT-AV1 software encoder (non-Android)
+│ │ ├── videotoolbox.rs # VideoToolbox H.265 + AV1 (macOS)
+│ │ ├── mediacodec.rs # MediaCodec H.264/H.265/AV1 (Android, NDK 0.9 migration pending)
+│ │ └── nack.rs # NACK sender/receiver framework
+│ ├── wzp-relay/ # Relay daemon
+│ │ └── src/
+│ │ ├── main.rs # CLI, connection loop, auth + handshake
+│ │ ├── config.rs # RelayConfig, TOML parsing
+│ │ ├── room.rs # RoomManager, TrunkedForwarder
+│ │ ├── pipeline.rs # RelayPipeline (forward mode)
+│ │ ├── session_mgr.rs # SessionManager (limits, lifecycle)
+│ │ ├── auth.rs # featherChat token validation
+│ │ ├── handshake.rs # Relay-side accept_handshake
+│ │ ├── metrics.rs # Prometheus RelayMetrics + per-session
+│ │ ├── probe.rs # Inter-relay probes + ProbeMesh
+│ │ ├── federation.rs # FederationManager, global rooms
+│ │ ├── presence.rs # PresenceRegistry
+│ │ ├── route.rs # RouteResolver
+│ │ ├── trunk.rs # TrunkBatcher
+│ │ ├── audio_scorer.rs # Per-stream audio quality scoring
+│ │ ├── response_policy.rs # Relay response policy (rate-limit, drop)
+│ │ ├── verdict.rs # Verdict enum (Allow/RateLimit/Drop/Malicious)
+│ │ ├── video_scorer.rs # VideoScorer (legitimacy scoring, keyframe regularity)
+│ │ └── ws.rs # WebSocket handler for browser clients
+│ ├── wzp-client/ # Call engine + CLI
+│ │ └── src/
+│ │ ├── cli.rs # CLI arg parsing + main
+│ │ ├── call.rs # CallEncoder, CallDecoder, QualityAdapter
+│ │ ├── handshake.rs # Client-side perform_handshake
+│ │ ├── featherchat.rs # CallSignal bridge
+│ │ ├── echo_test.rs # Automated echo quality test
+│ │ ├── drift_test.rs # Clock drift measurement
+│ │ ├── sweep.rs # Jitter buffer parameter sweep
+│ │ ├── metrics.rs # JSONL telemetry writer
+│ │ └── bench.rs # Component benchmarks
+│ └── wzp-web/ # Browser bridge
+│ ├── src/
+│ │ ├── main.rs # Axum server, WS handler, TLS
+│ │ └── metrics.rs # Prometheus WebMetrics
+│ └── static/
+│ ├── index.html # SPA UI (room, PTT, level meter)
+│ └── audio-processor.js # AudioWorklet (capture + playback)
+├── android/ # Android app (Kotlin + JNI)
+│ └── app/src/main/java/com/wzp/
+│ ├── audio/ # AudioPipeline, AudioRouteManager
+│ ├── engine/ # WzpEngine (JNI), CallStats, WzpCallback
+│ ├── ui/ # CallActivity, SettingsScreen, Identicon
+│ ├── data/ # SettingsRepository
+│ ├── net/ # RelayPinger
+│ ├── service/ # CallService (foreground)
+│ └── debug/ # DebugReporter
+├── desktop/ # Desktop app (Tauri)
+│ └── dist/ # Built frontend (HTML/JS/CSS)
+├── deps/featherchat/ # Git submodule
+├── docs/ # Documentation
+├── scripts/ # Build scripts
+│ └── build-linux.sh # Hetzner VM build
+└── tools/ # Development tools
+```
+
+## Test Coverage
+
+702 tests across all crates (excluding wzp-android), 0 failures:
+
+| Crate | Tests | Key Coverage |
+|-------|-------|-------------|
+| wzp-proto | 112 | Wire format, jitter buffer, quality tiers, mini-frames, trunking |
+| wzp-codec | 69 | Opus/Codec2 roundtrip, silence detection, noise suppression |
+| wzp-fec | 21 | RaptorQ encode/decode, loss recovery, interleaving |
+| wzp-crypto | 64 | Encrypt/decrypt, handshake, anti-replay, featherChat identity |
+| wzp-transport | 11 | QUIC connection setup, path monitoring |
+| wzp-relay | 137 | Room ACL, session mgmt, metrics, probes, mesh, trunking, scoring, verdict |
+| wzp-video | 88 | NAL framing, AV1 OBU, simulcast, quality controller, NACK |
+| wzp-client | 170 | Encoder/decoder, quality adapter, silence, drift, sweep |
+| wzp-web | 2 | Metrics |
+| wzp-native | 0 | Native platform bindings (no unit tests) |
+
+## Audio Backend Architecture (Platform Matrix)
+
+WarzonePhone's audio I/O goes through one of four backends depending on the target platform and feature flags. All backends expose the same public API (`AudioCapture::start() → AudioCapture { ring(), stop() }`) via conditional re-exports in `crates/wzp-client/src/lib.rs`, so the `CallEngine` above the audio layer doesn't know or care which backend is running.
+
+```
+ ┌─────────────────────────────────────────────┐
+ │ CallEngine (platform-agnostic) │
+ │ reads PCM from AudioCapture::ring() │
+ │ writes PCM to AudioPlayback::ring() │
+ └────────────────────┬────────────────────────┘
+ │
+ ┌─────────────────────┼─────────────────────┐
+ │ │ │
+ ▼ ▼ ▼
+ ┌───────────────┐ ┌────────────────┐ ┌───────────────┐
+ │ audio_io │ │ audio_vpio │ │ audio_wasapi │
+ │ (CPAL) │ │ (Core Audio │ │ (Windows │
+ │ │ │ VoiceProc IO) │ │ IAudioClient2│
+ │ All platforms │ │ macOS only │ │ Windows │
+ │ (baseline) │ │ feature=vpio │ │ feature= │
+ │ │ │ │ │ windows-aec │
+ └───────────────┘ └────────────────┘ └───────────────┘
+ │
+ ▼ on Android only
+ ┌───────────────┐
+ │ wzp-native │
+ │ (Oboe bridge │
+ │ via dlopen) │
+ │ │
+ │ Android only │
+ │ libloading │
+ └───────────────┘
+```
+
+### Backend selection matrix
+
+| Platform | Capture | Playback | OS AEC | Feature flags |
+|---|---|---|---|---|
+| macOS | VoiceProcessingIO (native Core Audio) | CPAL | **Yes** — Apple's hardware-accelerated AEC (same AEC as FaceTime, iMessage audio, Voice Memos) | `audio`, `vpio` |
+| Windows (AEC build) | Direct WASAPI with `AudioCategory_Communications` | CPAL | **Yes** — Windows routes the capture stream through the driver's communications APO chain (AEC + NS + AGC), driver-dependent quality | `audio`, `windows-aec` |
+| Windows (baseline) | CPAL (WASAPI shared mode) | CPAL | No | `audio` |
+| Linux | CPAL (ALSA / PulseAudio) | CPAL | No | `audio` |
+| Android (Tauri Mobile) | Oboe via `wzp-native` cdylib, `Usage::VoiceCommunication` + `MODE_IN_COMMUNICATION` | Same Oboe stream | Depends on device (some Android devices apply AEC to the voice-communication stream, most do not) | none (`wzp-client` compiled with `default-features = false`) |
+
+### Why `wzp-native` is a standalone cdylib
+
+On Android, the audio backend lives in a separate cdylib crate (`crates/wzp-native`) that `wzp-desktop`'s lib crate loads at runtime via `libloading`. It is **not** linked as a regular Rust dep.
+
+This is deliberate. rust-lang/rust#104707 documents that a crate with `crate-type = ["cdylib", "staticlib"]` leaks non-exported symbols from the staticlib into the cdylib. On Android, that caused Bionic's private `__init_tcb` / `pthread_create` symbols to be bound LOCALLY inside our `.so` instead of resolved dynamically against `libc.so` at `dlopen` time — which crashed the app at launch as soon as `tao` tried to `std::thread::spawn()` from the JNI `onCreate` callback.
+
+Keeping `wzp-native` in its own cdylib and loading it via `libloading` means:
+
+1. The app's own `.so` has `crate-type = ["cdylib", "rlib"]` only — no `staticlib`, no symbol leak.
+2. `libwzp_native.so` is loaded via `System.loadLibrary` from the JVM side (or `dlopen` from Rust), which triggers the normal Bionic resolver and binds all private symbols against `libc.so` at load time.
+3. The C/C++ Oboe bridge is fully isolated inside `libwzp_native.so`'s symbol space — no chance of its archives leaking into `wzp-desktop`'s `.so`.
+
+See `docs/BRANCH-android-rewrite.md` for the full incident postmortem and `docs/incident-tauri-android-init-tcb.md` for the debug log.
+
+### Vendored `audiopus_sys` for libopus / clang-cl cross-compile
+
+The workspace root carries a vendored copy of `audiopus_sys` at `vendor/audiopus_sys/` with a patched `opus/CMakeLists.txt`. This is needed because libopus 1.3.1 gates its per-file `-msse4.1` / `-mssse3` `COMPILE_FLAGS` behind `if(NOT MSVC)`, and under `clang-cl` (used by `cargo-xwin` for Windows cross-compiles) CMake sets `MSVC=1` unconditionally — so the SIMD source files compile without the required target feature and fail to link the intrinsic `always_inline` functions.
+
+The patch introduces an `MSVC_CL` variable that is true only for real `cl.exe` (distinguished via `CMAKE_C_COMPILER_ID STREQUAL "MSVC"`), and flips the eight `if(NOT MSVC)` SIMD guards to `if(NOT MSVC_CL)` so clang-cl gets the GCC-style per-file flags. Wired in via `[patch.crates-io] audiopus_sys = { path = "vendor/audiopus_sys" }` at the workspace root.
+
+This does not affect macOS or Linux builds — on those platforms `MSVC=0` everywhere so the patched logic behaves identically to upstream.
+
+Upstream tracking: xiph/opus#256, xiph/opus PR #257 (both stale).
+
+## Network Awareness (Android)
+
+The adaptive quality controller (`AdaptiveQualityController` in `wzp-proto`) supports proactive network-aware adaptation via `signal_network_change(NetworkContext)`. On Android, this is fed by `NetworkMonitor.kt` which wraps `ConnectivityManager.NetworkCallback`.
+
+```
+ConnectivityManager
+ │ onCapabilitiesChanged / onLost
+ ▼
+NetworkMonitor.kt ──classify──► type: Int (WiFi=0, LTE=1, 5G=2, 3G=3)
+ │ onNetworkChanged(type, bw)
+ ▼
+CallViewModel ──► WzpEngine.onNetworkChanged()
+ │ JNI
+ ▼
+ jni_bridge.rs
+ │
+ ▼
+ EngineState.pending_network_type (AtomicU8, lock-free)
+ │ polled every ~20ms
+ ▼
+ recv task: quality_ctrl.signal_network_change(ctx)
+ │
+ ├─ WiFi → Cellular: preemptive 1-tier downgrade
+ ├─ Any change: 10s FEC boost (+0.2 ratio)
+ └─ Cellular: faster downgrade thresholds (2 vs 3)
+```
+
+Cellular generation is approximated from `getLinkDownstreamBandwidthKbps()` to avoid requiring `READ_PHONE_STATE` permission.
+
+## Audio Routing (Android)
+
+Both Android app variants support 3-way audio routing: **Earpiece → Speaker → Bluetooth SCO**.
+
+### Audio Mode Lifecycle
+
+`MODE_IN_COMMUNICATION` is set by the Rust call engine (via JNI `AudioManager.setMode()`) right before Oboe streams open — NOT at app launch. Restored to `MODE_NORMAL` when the call ends. This prevents hijacking system audio routing (music, BT A2DP) before a call is active.
+
+### Native Kotlin App
+
+`AudioRouteManager.kt` handles device detection (via `AudioDeviceCallback`), SCO lifecycle, and auto-fallback on BT disconnect. `CallViewModel.cycleAudioRoute()` cycles through available routes.
+
+### Tauri Desktop App
+
+`android_audio.rs` provides JNI bridges to `AudioManager` for speakerphone and Bluetooth SCO control. After each route change, Oboe streams are stopped and restarted via `spawn_blocking`.
+
+```
+User tap ──► cycleAudioRoute()
+ │
+ ├─ Earpiece: setSpeakerphoneOn(false) + clearCommunicationDevice()
+ ├─ Speaker: setSpeakerphoneOn(true)
+ └─ BT SCO: setCommunicationDevice(bt_device) [API 31+]
+ │ fallback: startBluetoothSco() [API < 31]
+ ▼
+ Oboe stop + start_bt() for BT / start() for others
+```
+
+### BT SCO and Oboe
+
+BT SCO only supports 8/16kHz. When `bt_active=1`, Oboe capture skips `setSampleRate(48000)` and `setInputPreset(VoiceCommunication)`, letting the system choose the native BT rate. Oboe's `SampleRateConversionQuality::Best` bridges to our 48kHz ring buffers. Playout uses `Usage::Media` in BT mode to avoid conflicts with the communication device routing.
+
+### Hangup Signal Fix
+
+`SignalMessage::Hangup` now carries an optional `call_id` field. The relay uses it to end only the specific call instead of broadcasting to all active calls for the user — preventing a race where a hangup for call 1 kills a newly-placed call 2.
+
+## Phase 8: Tailscale-Inspired NAT Traversal (2026-04-14)
+
+Five new modules in `wzp-client` bring NAT traversal capability close to Tailscale's approach:
+
+```
+┌──────────────────────────────────────────────────────────────────────┐
+│ wzp-client NAT Traversal Stack │
+│ │
+│ ┌─────────────┐ ┌──────────────┐ ┌──────────────────────────┐ │
+│ │ stun.rs │ │ portmap.rs │ │ reflect.rs (existing) │ │
+│ │ RFC 5389 │ │ NAT-PMP │ │ Relay-based STUN │ │
+│ │ Public │ │ PCP │ │ Multi-relay NAT detect │ │
+│ │ STUN │ │ UPnP IGD │ │ │ │
+│ └──────┬──────┘ └──────┬───────┘ └────────────┬─────────────┘ │
+│ │ │ │ │
+│ └────────────────┼────────────────────────┘ │
+│ │ │
+│ ┌───────▼────────┐ │
+│ │ ice_agent.rs │ │
+│ │ Gather / Re- │ │
+│ │ gather / Apply│ │
+│ └───────┬────────┘ │
+│ │ │
+│ ┌───────────┼───────────┐ │
+│ │ │ │ │
+│ ┌───────▼───┐ ┌───▼───┐ ┌───▼──────────┐ │
+│ │ netcheck │ │ dual_ │ │ relay_map.rs │ │
+│ │ .rs │ │ path │ │ RTT-sorted │ │
+│ │ Diagnostic│ │ .rs │ │ relay list │ │
+│ └───────────┘ │ Race │ └──────────────┘ │
+│ └───────┘ │
+└──────────────────────────────────────────────────────────────────────┘
+```
+
+### Candidate Types
+
+| Type | Source | Priority | When Used |
+|------|--------|----------|-----------|
+| Host | `local_host_candidates()` | 1 (highest) | Same-LAN peers |
+| Port-mapped | `portmap::acquire_port_mapping()` | 2 | Router supports NAT-PMP/PCP/UPnP |
+| Server-reflexive | `stun::discover_reflexive()` or relay Reflect | 3 | Cone NAT |
+| Relay | Relay address (fallback) | 4 (lowest) | Always available |
+
+### Signal Flow for Mid-Call Re-Gathering
+
+```
+Network change (WiFi → cellular)
+ │
+ ▼
+IceAgent::re_gather()
+ ├── stun::discover_reflexive()
+ ├── portmap::acquire_port_mapping()
+ └── local_host_candidates()
+ │
+ ▼
+SignalMessage::CandidateUpdate { generation: N+1, ... }
+ │
+ ▼ (via relay)
+Peer's IceAgent::apply_peer_update()
+ │
+ ▼
+PeerCandidates { reflexive, local, mapped }
+ │
+ ▼
+dual_path::race() with new candidates (TODO: transport hot-swap)
+```
+
+### New SignalMessage Variants & Fields
+
+| Signal | New Fields | Purpose |
+|--------|-----------|---------|
+| `DirectCallOffer` | `caller_mapped_addr` | Port-mapped address from NAT-PMP/PCP/UPnP |
+| `DirectCallAnswer` | `callee_mapped_addr` | Same, callee side |
+| `CallSetup` | `peer_mapped_addr` | Relay cross-wires mapped addr to peer |
+| `CandidateUpdate` | (new variant) | Mid-call candidate re-gathering |
+| `RegisterPresenceAck` | `relay_region`, `available_relays` | Relay mesh metadata for auto-selection |
+
+All new fields use `#[serde(default, skip_serializing_if)]` for backward compatibility with older clients/relays.
+
+### Hard NAT Port Prediction
+
+For symmetric NATs that don't support port mapping, the system detects the NAT's port allocation pattern:
+
+```
+Single socket → 5 STUN servers (sequential probes)
+ │
+ ▼
+Observed ports: [40001, 40002, 40003, 40004, 40005]
+ │
+ ▼
+classify_port_allocation() → Sequential { delta: 1 }
+ │
+ ▼
+predict_ports(last=40005, delta=1, offset=0, spread=2)
+ → [40004, 40005, 40006, 40007, 40008]
+ │
+ ▼
+HardNatProbe signal → peer
+ │
+ ▼
+Peer dials predicted port range in parallel
+```
+
+| Pattern | Detection | Traversal Strategy |
+|---------|-----------|-------------------|
+| Port-preserving | All probes return same port | Standard hole-punch |
+| Sequential (delta=N) | Consistent N-increment | Predict next port, dial range |
+| Random | No pattern | Birthday attack or relay |
+| Unknown | < 3 probes succeeded | Relay fallback |
+
+The classifier tolerates:
+- **Jitter**: ±1 from dominant delta (concurrent flow grabbed a port)
+- **Wraparound**: 65535 → 1 treated as delta=+2, not -65534
+- **Noise**: 60% threshold — if most deltas agree, call it sequential
diff --git a/vault/Architecture/Attack-Surface-Relay-Abuse.md b/vault/Architecture/Attack-Surface-Relay-Abuse.md
new file mode 100644
index 0000000..51efeed
--- /dev/null
+++ b/vault/Architecture/Attack-Surface-Relay-Abuse.md
@@ -0,0 +1,233 @@
+---
+tags: [architecture, wzp]
+type: architecture
+---
+
+# Relay Abuse: Attack Surface & Mitigations
+
+> WZP is end-to-end encrypted. The relay forwards ciphertext and cannot inspect payload content. This document enumerates the abuse vectors that survive E2E and the mitigations available without breaking it.
+>
+> Motivating threat: a PoC on another project (LiveKit) showed that an E2E SFU with no conformance enforcement can be repurposed as a free arbitrary-data tunnel. WZP must not be that.
+
+## Threat model
+
+### In scope
+
+- **Bulk data tunneling.** Attacker uses a legitimate handshake, then pushes arbitrary bytes (file transfer, piracy, scraped traffic) through media datagrams.
+- **Bandwidth parasitism.** Attacker uses the relay as a cheap forwarder for unrelated traffic at scale.
+- **Quota / billing evasion.** Attacker disguises high-bandwidth use as low-bandwidth audio.
+- **DoS via amplification.** Attacker sends one packet → SFU fans out to N peers, multiplying egress cost N×.
+
+### Out of scope (cannot be solved without breaking E2E)
+
+- **Steganography inside real audio.** Modulating Opus-encoded waveforms to encode a covert channel. Information-theoretic limit; ~tens to hundreds of bps achievable; economically uninteresting.
+- **Modem-over-call.** Real audio whose semantic content is data. Same limit.
+- **Slow exfiltration under all rate caps.** Attacker who stays within audio's natural bandwidth envelope, indefinitely.
+
+### Threat actor profile
+
+We are defending against **economically motivated abuse at scale**, not against a determined nation-state covert channel. The former needs bandwidth and is loud; the latter is impossible to stop and not worth the engineering cost.
+
+## What the relay can observe
+
+Despite E2E, the relay sees a lot. None of this is encrypted to the relay:
+
+| Observable | Source | Bits available |
+|---|---|---|
+| `CodecID` (declared codec) | `MediaHeader`, AAD | 4 (today) / 6 (v2) |
+| `MediaType` (audio / video / data / control) | `MediaHeader` v2 | 2 |
+| `sequence`, `timestamp_ms` | `MediaHeader` | 32 + 32 |
+| `fec_block_id`, `fec_symbol_idx`, `FecRatio`, `T` (repair) | `MediaHeader` | varies |
+| `KeyFrame` bit | `MediaHeader` v2 | 1 |
+| `Q` flag (QualityReport trailer present) | `MediaHeader` | 1 |
+| Packet size | QUIC layer | — |
+| Packet inter-arrival timing | QUIC layer | — |
+| Aggregate bytes/sec per session | RelayMetrics | — |
+| Source fingerprint, src IP | Session state | — |
+
+This is enough surface for strong conformance enforcement without ever touching encrypted payload.
+
+## Mitigation tiers
+
+Listed in order of cost-to-implement vs. decisiveness. Tier A alone kills the gross-abuse threat. Higher tiers add defense in depth.
+
+### Tier A — Codec-conformance bitrate caps
+
+For each declared `CodecID`, the wire bitrate has a math-derivable hard ceiling:
+
+```
+ceiling_bps[CodecID] = nominal_bitrate * (1 + max_FEC_ratio) * (1 + overhead_pct)
+ = nominal * 3.0 * 1.15 // FEC max 2.0 → factor 3.0
+```
+
+| Codec | Nominal | Hard ceiling |
+|---|---|---|
+| Opus 64k | 64 kbps | ~221 kbps |
+| Opus 24k | 24 kbps | ~83 kbps |
+| Opus 6k | 6 kbps | ~21 kbps |
+| Codec2 1200 | 1.2 kbps | ~4 kbps |
+| ComfortNoise | 0 | ~2 kbps |
+
+Sliding 1 s window per session. Sustained excess → hard violation, close session.
+
+Decisive against bulk tunneling. False-positive rate negligible if ceilings set at math-derived max × 1.5.
+
+### Tier B — Packet-rate conformance
+
+Each codec has a fixed frame interval (20 ms or 40 ms), so legal `pps` is 25 or 50, plus FEC repair packets (max ~150 pps total at FEC ratio 2.0). Anything sustaining > 200 pps for an audio codec is not audio.
+
+### Tier C — Timestamp-rate consistency
+
+`timestamp_ms` advances at the declared frame interval. `Δtimestamp / Δseq` over a rolling window should match the codec's frame duration ±2×. Divergence catches abusers who send audio-rate small packets but burn fields for payload.
+
+### Tier D — Per-codec packet-size sanity
+
+EWMA of packet size per session, compared to per-codec typical:
+
+| Codec | Typical | Reject above |
+|---|---|---|
+| Opus 24k 20 ms | 60–80 B | 160 B |
+| Opus 6k 40 ms | 30–40 B | 90 B |
+| Codec2 1200 40 ms | 6 B | 30 B |
+| ComfortNoise | 0–4 B | 16 B |
+
+### Tier E — Per-fingerprint / per-IP token bucket
+
+Aggregate quota regardless of declared codec:
+
+```
+For each (fingerprint, src_ip):
+ monthly_bytes_quota authenticated = 50 GB (tune)
+ anonymous = 1 GB
+ per-session cap audio = 256 kbps
+ video = 5 Mbps
+ burst = 30 s at 2× cap
+```
+
+Won't stop a single rogue session under cap; bounds aggregate blast radius and makes relay economics predictable.
+
+### Tier F — Behavioral entropy / statistical fingerprinting
+
+The deeper layer. Computed continuously per session over 10–30 s windows. Combined score flags streams that pass declared-codec checks but do not statistically look like real media.
+
+**Why this works:** real audio and real video have very specific statistical signatures that tunneled data does not naturally produce, and that an attacker would have to deliberately and expensively mimic. The signatures differ wildly between audio and video — which is exactly why we separate them (see next section).
+
+#### Audio fingerprint features
+
+| Feature | Real Opus speech | Tunneled data |
+|---|---|---|
+| **IAT coefficient of variation** | 0.1–0.4 (clocked) | > 1.0 (bursty) |
+| **Payload-size distribution** | Bimodal: speech 60–80 B + silence/CN 0–10 B | Unimodal, large, MTU-skewed |
+| **Silence fraction** | 10–40 % (real conversation pauses) | < 2 % |
+| **Bitrate over 30 s** | Tracks nominal codec ±20 % | Often saturates ceiling |
+| **`Q` flag cadence** | Periodic, regular | Absent or random |
+| **DRED / FEC ratio response** | Tracks `QualityReport` trend | Static or noise |
+
+Single derived score: `audio_legitimacy ∈ [0, 1]`. Below threshold (e.g. 0.3) for 60 s → flag.
+
+#### Video fingerprint features (post-V1)
+
+| Feature | Real H.264 / AV1 video | Tunneled data |
+|---|---|---|
+| **Keyframe periodicity** | Regular (every 1–4 s, or on PLI) | Absent or uniform `KeyFrame=1` |
+| **Frame-size ratio (I / P)** | 5–20× | ≈ 1× |
+| **Burst structure** | One I-frame = N packets in < 5 ms, then quiet | Uniform spacing |
+| **Bitrate response to BWE feedback** | Tracks `TransportFeedback::remb_bps` | Ignores it |
+| **Resolution / FPS implied by bitrate** | Coherent (240 p ≠ 8 Mbps) | Incoherent |
+| **NACK / PLI responsiveness** | Sender produces keyframe within 200 ms | No response |
+
+Single derived score: `video_legitimacy ∈ [0, 1]`.
+
+#### Implementation shape
+
+```rust
+pub struct LegitimacyScorer {
+ media_type: MediaType,
+ iat_ewma: ExponentialMovingAverage,
+ iat_variance: ExponentialMovingVariance,
+ size_histogram: SizeBuckets<8>,
+ silence_count: u32,
+ speech_count: u32,
+ quality_reports_seen: u32,
+ keyframe_intervals: RingBuffer,
+ window_start: Instant,
+}
+
+impl LegitimacyScorer {
+ pub fn observe(&mut self, header: &MediaHeader, payload_len: usize, now: Instant);
+ pub fn score(&self) -> f32; // [0, 1]
+ pub fn verdict(&self) -> Verdict; // Legitimate | Suspect | Abusive
+}
+```
+
+Cheap: a few floats and counters per session. Update on every packet, score every 1 s, escalate over 30+ s.
+
+### Tier G — Reactive response
+
+A scoring system needs a response policy:
+
+| Verdict | Action |
+|---|---|
+| Legitimate | None |
+| Suspect | Apply tighter Tier-E quota; emit `relay_conformance_suspect_total` |
+| Abusive | Close session with `Hangup::PolicyViolation`; log to audit; cool-down fingerprint |
+| Repeat-abusive | Lower-tier quota across the federation (gossip via federation channel) |
+
+Never silent-drop. Always close with a typed reason so legitimate users hitting a bug get a clear error.
+
+## Separating audio and video
+
+**Yes — this is one of the strongest arguments for the v2 `MediaType` bit and should be a hard design rule.**
+
+Audio and video have nothing in common statistically:
+
+| Property | Audio | Video |
+|---|---|---|
+| Bitrate | 6–64 kbps | 100 kbps – 5 Mbps |
+| Packet rate | 25–50 pps | 500–2000 pps |
+| Packet size | 6–160 B | 200–1450 B |
+| Burst structure | Clocked, near-CBR | Bursty (I-frames) |
+| Silence | Common (10–40 %) | Meaningless |
+| Loss tolerance | High (PLC, DRED) | Variable (keyframes critical) |
+| Recovery primitive | FEC + DRED | NACK + PLI + keyframe cache |
+
+A single scoring model trying to cover both would have to be so permissive at the union of envelopes that it would let tunnels through. **Separation is mandatory for Tier F to work.**
+
+### What separation requires
+
+1. **`MediaType:2` in `MediaHeader` v2** (already in `ROAD-TO-VIDEO.md` Phase V1). Without this, the relay must keep a `CodecID → MediaType` table and update it every time a codec is added — fragile.
+2. **Per-`MediaType` conformance rules.** A and B and D have separate tables per type. Tier F has separate scorers.
+3. **Per-`MediaType` quotas.** Tier E uses two buckets: `audio_bps_cap`, `video_bps_cap`. A session in audio-only mode never gets to spend the video budget. A video session has both, audio-priority.
+4. **Per-`MediaType` keyframe/silence semantics.** `KeyFrame` bit is meaningless for audio; silence fraction is meaningless for video. The scorer needs to know which features apply.
+
+### Bonus: separation also helps the SFU
+
+Beyond abuse detection, the same separation makes graceful degradation cleaner: under congestion the relay can drop video packets first while preserving audio, because it knows which is which without parsing the codec table.
+
+## Open questions for later decision
+
+1. **Hard-close on first hard violation, or three-strikes?** Three-strikes is friendlier but lets twice the abuse through. Recommend hard-close + clear typed reason; legitimate users will reconnect, abusers won't try again at the same fingerprint.
+2. **Where do verdicts persist?** In-memory per relay is simplest. Federated gossip is more powerful but a new attack surface (poisoning).
+3. **Threshold tuning.** All thresholds in this doc are first-pass math. Real numbers come from a few weeks of Prometheus data on legitimate traffic before any enforcement turns on.
+4. **Anonymous vs. authenticated split.** featherChat-authed users get generous quotas; anonymous users get tight ones. This makes the economics of mass abuse hostile (need many real identities) without locking out small legitimate use.
+5. **What to log.** Conformance hits should be Prometheus counters + ringbuffer of recent violations; never log raw payload content (even encrypted) for privacy.
+
+## Suggested implementation order (whenever this is picked up)
+
+| Step | What | Why first |
+|---|---|---|
+| 1 | Land v2 wire format with `MediaType:2` | Prereq for separation; already on the road-to-video plan |
+| 2 | Tier A + B + C as `wzp-relay/src/conformance.rs` | Kills bulk tunneling; cheap; no false positives if math is right |
+| 3 | Prometheus metrics for violations + raw observables (IAT, size, silence frac) | Gather baseline of legitimate traffic before tightening |
+| 4 | Tier D + E (size sanity + token bucket) | Defense in depth |
+| 5 | Tier F scorer, audio-only first; tuned against the baseline from step 3 | Adds covert-tunnel pressure |
+| 6 | Tier F video scorer once video is in production | Same shape, different features |
+| 7 | Tier G response policy + audit log | Operationalize |
+
+Steps 1–2 are decisive against the LiveKit-style PoC. The rest is steady tightening as real traffic accumulates.
+
+## What this does NOT promise
+
+- It does not stop a patient adversary running a slow covert channel inside real audio. Nothing E2E-preserving can.
+- It does not detect content (no CSAM scan, no copyright fingerprint). Those would require breaking E2E and are out of scope by design.
+- It does not eliminate abuse — it makes abuse loud, expensive, and detectable, which is the realistic goal for any E2E system.
diff --git a/vault/Architecture/Branch-Desktop-Audio-Rewrite.md b/vault/Architecture/Branch-Desktop-Audio-Rewrite.md
new file mode 100644
index 0000000..915678b
--- /dev/null
+++ b/vault/Architecture/Branch-Desktop-Audio-Rewrite.md
@@ -0,0 +1,169 @@
+---
+tags: [architecture, wzp]
+type: architecture
+---
+
+# Branch: `feat/desktop-audio-rewrite`
+
+Home of the Tauri desktop client for macOS, Windows, and Linux. Named "audio-rewrite" because the original driver was replacing a CPAL-only audio pipeline with platform-native backends that support OS-level echo cancellation (VoiceProcessingIO on macOS, WASAPI Communications on Windows), but the branch has grown into the full desktop story — Windows cross-compilation, vendored dependencies, history UI, direct calling, the whole thing.
+
+## Purpose
+
+The desktop client shares 100% of its frontend (`desktop/src/`) and Tauri command layer (`desktop/src-tauri/src/lib.rs`, `engine.rs`, `history.rs`) with the Android build on `android-rewrite`. Differences are limited to:
+
+- **Audio backends**, which are platform-gated via Cargo target-dep sections in `desktop/src-tauri/Cargo.toml` and feature flags in `crates/wzp-client/Cargo.toml`.
+- **Identity storage paths**, which resolve via Tauri's `app_data_dir()` (`~/Library/Application Support/…` on macOS, `%APPDATA%\…` on Windows, `~/.local/share/…` on Linux).
+- **Build toolchains**: native `cargo build` on macOS/Linux, `cargo xwin` cross-compile from Linux for Windows via Docker on SepehrHomeserverdk.
+
+## Audio backend matrix
+
+| Target | Capture | Playback | AEC |
+|---|---|---|---|
+| macOS | CPAL (WASAPI/CoreAudio via cpal crate) OR VoiceProcessingIO (native Core Audio) | CPAL | VoiceProcessingIO native AEC (when `vpio` feature enabled) |
+| Windows (default) | CPAL → WASAPI shared mode | CPAL → WASAPI shared mode | None |
+| Windows (AEC build) | Direct WASAPI with `IAudioClient2::SetClientProperties(AudioCategory_Communications)` | CPAL → WASAPI shared mode | **OS-level**: Windows routes the capture stream through the driver's communications APO chain (AEC + NS + AGC) |
+| Linux | CPAL → ALSA/PulseAudio | CPAL → ALSA/PulseAudio | None |
+
+The macOS VPIO path is gated behind the `vpio` feature in `wzp-client` and the `coreaudio-rs` dep is itself `cfg(target_os = "macos")`, so enabling the feature on Windows or Linux is a no-op.
+
+The Windows AEC path is gated behind the `windows-aec` feature, also target-gated (the `windows` crate dep is only pulled in on Windows), and re-exports `WasapiAudioCapture as AudioCapture` when enabled so downstream code doesn't need to know which backend is active. The current Windows build at `target/windows-exe/wzp-desktop.exe` has `windows-aec` on; a baseline noAEC build is preserved at `target/windows-exe/wzp-desktop-noAEC.exe` for A/B comparison on real hardware.
+
+See [`BRANCH-android-rewrite.md`](BRANCH-android-rewrite.md) for Oboe audio on Android, which is its own story.
+
+## Recent major work
+
+### 1. Desktop direct calling feature (commit `2fd9465` and neighbors)
+
+Brought direct 1:1 calls to macOS with full parity to the Android client:
+
+- **Identity path fix**: the desktop `CallEngine::start` was loading seed from `$HOME/.wzp/identity` while `register_signal` used Tauri's `app_data_dir()`, producing two different fingerprints per run. Both now route through `load_or_create_seed()` which uses `app_data_dir()` everywhere.
+- **Call history with dedup**: `history.rs` stores a `Vec` with a `CallDirection` enum (`Placed | Received | Missed`). The `log` function dedupes by `call_id` so an outgoing call isn't logged twice as "missed" (when the signal loop's `DirectCallOffer` handler fires) and then again as "placed" (when `place_call` returns). Instead the entry is updated in place.
+- **Recent contacts row**: a horizontal chip UI in the direct-call panel showing the last N peers with friendly aliases, clickable to re-dial.
+- **Deregister button**: lets a user drop their signal registration without quitting the app, useful when switching identities.
+- **Random alias derivation**: a new client sees a human-friendly alias like "silent-forest-41" derived deterministically from its seed, so it's identifiable in the UI before manual naming.
+- **Default room "general"** instead of "android", since the desktop client is not Android.
+
+### 2. macOS VoiceProcessingIO integration
+
+`crates/wzp-client/src/audio_vpio.rs` — a native Core Audio implementation using `AUGraph` + `AudioComponentInstance` with the VPIO audio unit. Gives you hardware-accelerated AEC (same AEC Apple ships in FaceTime / iMessage audio / voice memos) at the cost of tight coupling to Apple frameworks. Lock-free ring pattern matches the CPAL path so the upper layers don't notice the difference.
+
+Enabled by `features = ["audio", "vpio"]` in the macOS target section of `desktop/src-tauri/Cargo.toml`.
+
+### 3. Windows cross-compilation via cargo-xwin
+
+Cross-compiling Rust + Tauri to `x86_64-pc-windows-msvc` from Linux using `cargo-xwin`, which downloads the Microsoft CRT + Windows SDK on demand and drives `clang-cl` as the compiler. No Windows machine is needed for the build itself — only for runtime testing.
+
+**Build infrastructure**:
+
+- `scripts/Dockerfile.windows-builder` — Debian bookworm + Rust + cargo-xwin + Node 20 + cmake + ninja + llvm + clang + lld + nasm. Pre-warms the xwin MSVC CRT cache at image build time (saves ~4 minutes per cold build).
+- `scripts/build-windows-docker.sh` — fire-and-forget remote build via Docker on SepehrHomeserverdk. Same pattern as `build-tauri-android.sh`. Uploads the `.exe` to rustypaste and fires an `ntfy.sh/wzp` notification on start and on completion.
+- `scripts/build-windows-cloud.sh` — alternative pipeline using a temporary Hetzner Cloud VPS. Slower (full VM spin-up), more expensive, but useful when Docker image rebuilds would be disruptive.
+
+**Two critical blockers resolved** on the way to a working `.exe`:
+
+1. **libopus SSE4.1 / SSSE3 intrinsic compile failure**. `audiopus_sys` vendors libopus 1.3.1, whose `CMakeLists.txt` gates the per-file `-msse4.1` `COMPILE_FLAGS` behind `if(NOT MSVC)`. Under `clang-cl`, CMake sets `MSVC=1` (because `CMAKE_C_COMPILER_FRONTEND_VARIANT=MSVC` triggers `Platform/Windows-MSVC.cmake` which unconditionally sets the variable), so the per-file flag is never set and the SSE4.1 source files compile without the target feature — then fail with 20+ "always_inline function '_mm_cvtepi16_epi32' requires target feature 'sse4.1'" errors.
+
+ Fixed by **vendoring audiopus_sys into `vendor/audiopus_sys/`** and patching its bundled libopus to introduce an `MSVC_CL` variable that is true only for real `cl.exe` (distinguished via `CMAKE_C_COMPILER_ID STREQUAL "MSVC"`). The eight `if(NOT MSVC)` SIMD guards are flipped to `if(NOT MSVC_CL)` and the global `/arch` block at line 445 becomes `if(MSVC_CL)`, so clang-cl gets the GCC-style per-file flags while real cl.exe keeps the `/arch:AVX` / `/arch:SSE2` globals.
+
+ Wired in via `[patch.crates-io] audiopus_sys = { path = "vendor/audiopus_sys" }` at the workspace root.
+
+ Upstream tracking: [xiph/opus#256](https://github.com/xiph/opus/issues/256), [xiph/opus PR #257](https://github.com/xiph/opus/pull/257) (both stale).
+
+2. **tauri-build needs `icons/icon.ico` for the Windows PE resource**. The desktop only had `icon.png`. Generated a multi-size ICO (16/24/32/48/64/128/256) from the existing placeholder via Pillow and committed it. Placeholder quality — real branded icons can replace it later.
+
+### 4. Windows `AudioCategory_Communications` capture path (task #24)
+
+`crates/wzp-client/src/audio_wasapi.rs` — direct WASAPI capture via `IMMDeviceEnumerator → IAudioClient2 → SetClientProperties` with `AudioCategory_Communications`. This tells Windows "this is a VoIP call" and Windows routes the capture stream through the driver's registered communications APO chain, which on most Win10/11 consumer hardware includes AEC, NS, and AGC.
+
+**Caveat**: quality is driver-dependent. On a machine with a good communications APO (Intel Smart Sound, Dolby, modern Realtek on Win11 24H2+, anything with Voice Clarity enabled) it's excellent. On generic class-compliant drivers with no communications APO registered, it's a no-op. For a guaranteed AEC regardless of driver, see task #26 which tracks implementing the classic Voice Capture DSP (`CLSID_CWMAudioAEC`) as a fallback.
+
+Gated behind the `windows-aec` feature in `wzp-client`. Enabled by default in the Windows target section of `desktop/src-tauri/Cargo.toml`.
+
+## Build pipelines
+
+### Native macOS / Linux
+
+```bash
+cd desktop
+npm install
+npm run build
+cd src-tauri
+cargo build --release --bin wzp-desktop
+```
+
+### Windows x86_64 via Docker on SepehrHomeserverdk
+
+```bash
+./scripts/build-windows-docker.sh # Full: pull + build + download
+./scripts/build-windows-docker.sh --no-pull # Skip git fetch
+./scripts/build-windows-docker.sh --rust # Force-clean Rust target
+./scripts/build-windows-docker.sh --image-build # (Re)build the Docker image (fire-and-forget)
+```
+
+Output lands at `target/windows-exe/wzp-desktop.exe`. Both `wzp-desktop.exe` and `wzp-desktop-noAEC.exe` can coexist in that directory; the script writes `wzp-desktop.exe` so renaming the prior build to `-noAEC.exe` (or any other name) before rebuilding preserves it.
+
+### Windows x86_64 via Hetzner Cloud (alternative)
+
+```bash
+./scripts/build-windows-cloud.sh # Full: create VM → build → download → destroy
+./scripts/build-windows-cloud.sh --prepare # Create VM and install deps only
+./scripts/build-windows-cloud.sh --build # Build on existing VM
+./scripts/build-windows-cloud.sh --destroy # Delete the VM
+WZP_KEEP_VM=1 ./scripts/build-windows-cloud.sh # Keep VM alive after build for debug
+```
+
+Remember to destroy the VM at end of day with `--destroy`.
+
+### Linux x86_64 (relay + CLI + bench)
+
+```bash
+./scripts/build-linux-docker.sh # Fire-and-forget remote Docker build
+./scripts/build-linux-docker.sh --install # Wait for completion and download
+```
+
+Uses the same `wzp-android-builder` Docker image as Android (not a separate image), since the deps (Rust + cmake + ring prereqs) are the same.
+
+## Testing
+
+### Direct calling parity
+
+1. Build on two machines (macOS + Windows, or two macOS, or any combination).
+2. Both machines register on the same relay.
+3. Copy one machine's fingerprint into the other's direct-call panel.
+4. Place the call. Confirm ringing UI on the callee and "calling…" UI on the caller.
+5. Answer. Confirm audio flows both ways.
+6. Hang up from either side. Confirm call-history entries are labeled correctly (`Outgoing` on caller, `Incoming` on callee, never `Missed` on a successful call).
+
+### Windows AEC A/B
+
+1. Install `wzp-desktop-noAEC.exe` and `wzp-desktop.exe` on the same Windows box.
+2. Join a call from each (separately) while a second machine plays known audio through the first machine's speakers.
+3. On the remote (listening) side: the `noAEC` call should have clear audible echo; the AEC call should have minimal or no echo after a 1–2 s convergence period.
+4. If both builds sound identical (with echo) → the `AudioCategory_Communications` switch isn't triggering the driver's APO chain. Investigate via task #26 (Voice Capture DSP fallback).
+
+## Known quirks
+
+1. **libopus vendor path is workspace-relative**. `[patch.crates-io] audiopus_sys = { path = "vendor/audiopus_sys" }` works from any crate in the workspace because Cargo resolves it against the root `Cargo.toml`'s directory. If the workspace is moved or vendored into another workspace, update the path.
+
+2. **`cargo xwin` overwrites `override.cmake` on every invocation**. Any attempt to patch `~/.cache/cargo-xwin/cmake/clang-cl/override.cmake` at Docker image build time is inert because `src/compiler/clang_cl.rs` line ~444 writes the bundled file fresh on every run. All real fixes must land in the source tree (via the vendored audiopus_sys, as done here), not in the cargo-xwin cache.
+
+3. **WebView2 runtime is a prerequisite on Windows 10**. Windows 11 ships with it. If the `.exe` launches and immediately exits with no error on a Win10 machine, that's the missing runtime — install it from [Microsoft's Evergreen bootstrapper](https://developer.microsoft.com/en-us/microsoft-edge/webview2/).
+
+4. **Rust 2024 edition `unsafe_op_in_unsafe_fn` lint**. The WASAPI backend in `audio_wasapi.rs` emits ~18 of these warnings because Rust 2024 requires explicit `unsafe { ... }` blocks inside `unsafe fn` bodies. The warnings don't block the build and don't affect runtime behavior; cleaning them up is tracked informally as tech debt.
+
+## Files of interest
+
+| Path | Purpose |
+|---|---|
+| `desktop/src/` | Shared frontend (TypeScript + HTML + CSS) |
+| `desktop/src-tauri/src/lib.rs` | Tauri commands shared with Android |
+| `desktop/src-tauri/src/engine.rs` | `CallEngine` wrapper |
+| `desktop/src-tauri/src/history.rs` | Persistent call history store with dedup |
+| `crates/wzp-client/src/audio_io.rs` | CPAL capture + playback (baseline) |
+| `crates/wzp-client/src/audio_vpio.rs` | macOS VoiceProcessingIO capture (AEC) |
+| `crates/wzp-client/src/audio_wasapi.rs` | Windows WASAPI communications capture (AEC) |
+| `vendor/audiopus_sys/opus/CMakeLists.txt` | Patched libopus for clang-cl SIMD |
+| `scripts/Dockerfile.windows-builder` | Windows cross-compile Docker image |
+| `scripts/build-windows-docker.sh` | Remote Docker build pipeline |
+| `scripts/build-windows-cloud.sh` | Hetzner VPS alternative pipeline |
+| `scripts/build-linux-docker.sh` | Linux x86_64 relay/CLI build pipeline |
diff --git a/vault/Architecture/Design.md b/vault/Architecture/Design.md
new file mode 100644
index 0000000..002f154
--- /dev/null
+++ b/vault/Architecture/Design.md
@@ -0,0 +1,666 @@
+---
+tags: [architecture, wzp]
+type: architecture
+---
+
+# WarzonePhone Design Document
+
+> Custom encrypted VoIP protocol built in Rust. Designed for hostile network conditions: 5-70% packet loss, 100-500 kbps throughput, 300-800 ms RTT. Multi-platform: Desktop (Tauri), Android, CLI, Web.
+
+## System Overview
+
+WarzonePhone is a voice-over-IP system built from scratch in Rust, targeting reliable encrypted voice communication over severely degraded networks. The protocol uses adaptive codecs (Opus + Codec2), fountain-code FEC (RaptorQ), and end-to-end ChaCha20-Poly1305 encryption over a QUIC transport layer.
+
+The system comprises three categories of components:
+
+1. **Protocol crates** -- a Rust workspace of 7 library crates with a star dependency graph enabling parallel development
+2. **Client applications** -- Desktop (Tauri), Android (Kotlin + JNI), CLI, and Web (browser bridge)
+3. **Relay infrastructure** -- SFU relay daemons with federation, health probing, and Prometheus metrics
+
+### Design Principles
+
+- **User sovereignty** -- client-driven route selection, BIP39 identity backup, no central authority
+- **End-to-end encryption** -- relays never see plaintext audio; SFU forwarding preserves E2E encryption
+- **Adaptive resilience** -- automatic codec and FEC switching based on observed network quality
+- **Parallel development** -- star dependency graph allows 5 agents/developers to work simultaneously with zero merge conflicts
+
+## Architecture
+
+### Crate Overview
+
+The workspace contains 7 core crates plus integration binaries:
+
+| Crate | Purpose | Key Dependencies |
+|-------|---------|-----------------|
+| `wzp-proto` | Protocol types, traits, wire format | serde, bytes |
+| `wzp-codec` | Audio codecs (Opus, Codec2, RNNoise) | audiopus, codec2, nnnoiseless |
+| `wzp-fec` | Forward error correction | raptorq |
+| `wzp-crypto` | Cryptography and identity | ed25519-dalek, x25519-dalek, chacha20poly1305, bip39 |
+| `wzp-transport` | QUIC transport layer | quinn, rustls |
+| `wzp-relay` | Relay daemon (SFU, federation, metrics) | tokio, prometheus |
+| `wzp-client` | Call engine and CLI | All above |
+
+Additional integration targets: `wzp-web` (browser bridge via WebSocket), Android native library (JNI), Desktop (Tauri).
+
+### Dependency Graph
+
+```mermaid
+graph TD
+ PROTO["wzp-proto
(Types, Traits, Wire Format)"]
+
+ CODEC["wzp-codec
(Opus + Codec2 + RNNoise)"]
+ FEC["wzp-fec
(RaptorQ FEC)"]
+ CRYPTO["wzp-crypto
(ChaCha20 + Identity)"]
+ TRANSPORT["wzp-transport
(QUIC / Quinn)"]
+
+ RELAY["wzp-relay
(Relay Daemon)"]
+ CLIENT["wzp-client
(CLI + Call Engine)"]
+ WEB["wzp-web
(Browser Bridge)"]
+ DESKTOP["Desktop
(Tauri + CPAL)"]
+ ANDROID["Android
(Kotlin + JNI)"]
+
+ PROTO --> CODEC
+ PROTO --> FEC
+ PROTO --> CRYPTO
+ PROTO --> TRANSPORT
+
+ CODEC --> CLIENT
+ FEC --> CLIENT
+ CRYPTO --> CLIENT
+ TRANSPORT --> CLIENT
+
+ CODEC --> RELAY
+ FEC --> RELAY
+ CRYPTO --> RELAY
+ TRANSPORT --> RELAY
+
+ CLIENT --> WEB
+ CLIENT --> DESKTOP
+ CLIENT --> ANDROID
+ TRANSPORT --> WEB
+
+ FC["warzone-protocol
(featherChat Identity)"] -.->|path dep| CRYPTO
+
+ style PROTO fill:#6c5ce7,color:#fff
+ style RELAY fill:#ff9f43,color:#fff
+ style CLIENT fill:#00b894,color:#fff
+ style WEB fill:#0984e3,color:#fff
+ style DESKTOP fill:#0984e3,color:#fff
+ style ANDROID fill:#0984e3,color:#fff
+ style FC fill:#fd79a8,color:#fff
+```
+
+The star pattern ensures each leaf crate (`wzp-codec`, `wzp-fec`, `wzp-crypto`, `wzp-transport`) depends only on `wzp-proto` and never on each other. This enables:
+
+- **Parallel development** -- 5 agents work on 5 crates with no merge conflicts
+- **Independent testing** -- each crate has self-contained tests
+- **Pluggability** -- any implementation can be swapped by implementing the same trait
+- **Fast compilation** -- changing one leaf only recompiles that leaf and integration crates
+
+## Audio Pipeline
+
+### Encode Pipeline (Mic to Network)
+
+```mermaid
+sequenceDiagram
+ participant Mic as Microphone
+ participant RNN as RNNoise Denoise
+ participant VAD as Silence Detector
+ participant ENC as Opus/Codec2 Encode
+ participant FEC as RaptorQ FEC Encode
+ participant INT as Interleaver
+ participant HDR as Header Assembly
+ participant CRYPT as ChaCha20-Poly1305
+ participant QUIC as QUIC Datagram
+
+ Mic->>RNN: PCM i16 x 960 (20ms @ 48kHz)
+ RNN->>VAD: Denoised samples (2 x 480)
+ alt Silence detected (>100ms)
+ VAD->>ENC: ComfortNoise packet (every 200ms)
+ else Active speech or hangover
+ VAD->>ENC: Active audio frame
+ end
+ ENC->>FEC: Compressed frame (padded to 256 bytes)
+ FEC->>FEC: Accumulate block (5-10 frames)
+ FEC->>INT: Source + repair symbols
+ INT->>HDR: Interleaved packets (depth=3)
+ HDR->>CRYPT: MediaHeader (12B) or MiniHeader (4B)
+ CRYPT->>QUIC: Header=AAD, Payload=encrypted
+```
+
+### Decode Pipeline (Network to Speaker)
+
+```mermaid
+sequenceDiagram
+ participant QUIC as QUIC Datagram
+ participant CRYPT as ChaCha20-Poly1305
+ participant HDR as Header Parse
+ participant DEINT as De-interleaver
+ participant FEC as RaptorQ FEC Decode
+ participant JIT as Jitter Buffer
+ participant DEC as Opus/Codec2 Decode
+ participant SPK as Speaker
+
+ QUIC->>CRYPT: Encrypted packet
+ CRYPT->>HDR: Decrypt (header=AAD)
+ HDR->>DEINT: Parsed MediaHeader + payload
+ DEINT->>FEC: Reordered symbols
+ FEC->>FEC: Reconstruct from any K of K+R symbols
+ FEC->>JIT: Recovered audio frames
+ JIT->>JIT: Sequence-ordered BTreeMap
+ JIT->>DEC: Pop when depth >= target
+ DEC->>SPK: PCM i16 x 960
+```
+
+## Codec System
+
+WarzonePhone uses a dual-codec architecture to cover the full range of network conditions:
+
+### Opus (Primary)
+
+Opus is the primary codec for normal to degraded conditions. It operates at 48 kHz natively with built-in inband FEC and DTX (discontinuous transmission). The `audiopus` crate provides mature Rust bindings to libopus.
+
+| Profile | Bitrate | Frame Duration | FEC Ratio | Total Bandwidth | Use Case |
+|---------|---------|---------------|-----------|----------------|----------|
+| Studio 64k | 64 kbps | 20ms | 10% | 70.4 kbps | LAN, excellent WiFi |
+| Studio 48k | 48 kbps | 20ms | 10% | 52.8 kbps | Good WiFi, wired |
+| Studio 32k | 32 kbps | 20ms | 10% | 35.2 kbps | WiFi, LTE |
+| Good (24k) | 24 kbps | 20ms | 20% | 28.8 kbps | WiFi, LTE, decent links |
+| Opus 16k | 16 kbps | 20ms | 20% | 19.2 kbps | 3G, moderate congestion |
+| Degraded (6k) | 6 kbps | 40ms | 50% | 9.0 kbps | 3G, congested WiFi |
+
+### Codec2 (Fallback)
+
+Codec2 is a narrowband vocoder designed for HF radio links with extreme bandwidth constraints. It operates at 8 kHz, and the adaptive layer handles 48 kHz <-> 8 kHz resampling transparently. The pure-Rust `codec2` crate means no C dependencies.
+
+| Profile | Bitrate | Frame Duration | FEC Ratio | Total Bandwidth | Use Case |
+|---------|---------|---------------|-----------|----------------|----------|
+| Codec2 3200 | 3.2 kbps | 20ms | 50% | 4.8 kbps | Poor conditions |
+| Catastrophic (1200) | 1.2 kbps | 40ms | 100% | 2.4 kbps | Satellite, extreme loss |
+
+### ComfortNoise
+
+When the silence detector identifies no speech activity for over 100ms, the encoder switches to emitting a ComfortNoise packet every 200ms instead of encoding silence. This provides approximately 50% bandwidth savings in typical conversations.
+
+### Adaptive Switching
+
+The `AdaptiveEncoder`/`AdaptiveDecoder` in `wzp-codec` hold both codec instances and switch between them based on the active `QualityProfile`. This avoids codec re-initialization latency during tier transitions. The `AdaptiveQualityController` in `wzp-proto` manages tier transitions with hysteresis:
+
+- **Downgrade**: 3 consecutive bad reports (2 on cellular networks)
+- **Upgrade**: 10 consecutive good reports (one tier at a time)
+- **Network handoff**: WiFi-to-cellular switch triggers preemptive one-tier downgrade plus a temporary 10-second FEC boost (+20%)
+
+Quality tier classification thresholds:
+
+| Tier | WiFi/Unknown | Cellular |
+|------|-------------|----------|
+| Good | loss < 10%, RTT < 400ms | loss < 8%, RTT < 300ms |
+| Degraded | loss 10-40%, RTT 400-600ms | loss 8-25%, RTT 300-500ms |
+| Catastrophic | loss > 40%, RTT > 600ms | loss > 25%, RTT > 500ms |
+
+## Forward Error Correction (FEC)
+
+### Why RaptorQ Over Reed-Solomon
+
+WarzonePhone uses RaptorQ (RFC 6330) fountain codes via the `raptorq` crate:
+
+1. **Rateless** -- generate arbitrary repair symbols on the fly; if conditions worsen mid-block, generate additional repair without re-encoding
+2. **Efficient decoding** -- decode from any K symbols with high probability (typically K + 1 or K + 2 suffice)
+3. **Lower complexity** -- O(K) encoding/decoding time vs O(K^2) for Reed-Solomon
+4. **Variable block sizes** -- 1-56,403 source symbols per block (WZP uses 5-10)
+
+### FEC Block Structure
+
+Each FEC block consists of 5-10 audio frames padded to 256-byte symbols with a 2-byte LE length prefix:
+
+```
+[len:u16 LE][audio_frame][zero_padding_to_256_bytes]
+```
+
+### Loss Survival by FEC Ratio
+
+With 5 source frames per block:
+
+| FEC Ratio | Repair Symbols | Survives Loss | Profile |
+|-----------|---------------|---------------|---------|
+| 10% | 1 | 1 of 6 (16.7%) | Studio |
+| 20% | 1 | 1 of 6 (16.7%) | Good |
+| 50% | 3 | 3 of 8 (37.5%) | Degraded |
+| 100% | 5 | 5 of 10 (50.0%) | Catastrophic |
+
+### Interleaving
+
+Burst loss protection via depth-3 interleaving: packets from 3 consecutive FEC blocks are interleaved before transmission. A burst of 3 consecutive lost packets affects 3 different blocks (1 loss each) rather than destroying 1 block entirely.
+
+```mermaid
+graph LR
+ subgraph "FEC Encoder"
+ F1[Frame 1] --> BLK[Source Block
5-10 frames]
+ F2[Frame 2] --> BLK
+ F3[Frame 3] --> BLK
+ F4[Frame 4] --> BLK
+ F5[Frame 5] --> BLK
+ BLK --> SRC[Source Symbols]
+ BLK --> REP[Repair Symbols
ratio-dependent]
+ SRC --> INT[Interleaver
depth=3]
+ REP --> INT
+ end
+
+ subgraph "Network"
+ INT --> LOSS{Packet Loss}
+ LOSS -->|some lost| RCV[Received Symbols]
+ end
+
+ subgraph "FEC Decoder"
+ RCV --> DEINT[De-interleaver]
+ DEINT --> RAPTORQ[RaptorQ Decode
Any K of K+R]
+ RAPTORQ --> OUT[Original Frames]
+ end
+
+ style LOSS fill:#e17055,color:#fff
+ style RAPTORQ fill:#00b894,color:#fff
+```
+
+## Transport Layer
+
+### Why QUIC Over Raw UDP
+
+WarzonePhone uses QUIC (via the `quinn` crate) rather than raw UDP for several reasons:
+
+| Feature | Benefit |
+|---------|---------|
+| DATAGRAM frames (RFC 9221) | Unreliable delivery without head-of-line blocking -- behaves like UDP for media |
+| Reliable streams | Multiplexed signaling (CallOffer, Hangup, Rekey) without a separate TCP connection |
+| Congestion control | Prevents overwhelming degraded links, important when chaining relays |
+| Connection migration | Connections survive IP address changes (WiFi to cellular handoff) |
+| TLS 1.3 built-in | Transport-level encryption protects headers and signaling |
+| NAT keepalive | 5-second interval maintains NAT bindings without application-level pings |
+| Firewall traversal | Runs on UDP port 443 with `wzp` ALPN identifier |
+
+The tradeoff is approximately 20-40 bytes of additional per-packet overhead compared to raw UDP.
+
+### Wire Formats
+
+#### MediaHeader (12 bytes)
+
+```
+Byte 0: [V:1][T:1][CodecID:4][Q:1][FecRatioHi:1]
+Byte 1: [FecRatioLo:6][unused:2]
+Bytes 2-3: sequence (u16 BE)
+Bytes 4-7: timestamp_ms (u32 BE)
+Byte 8: fec_block_id (u8)
+Byte 9: fec_symbol_idx (u8)
+Byte 10: reserved
+Byte 11: csrc_count
+
+V = version (0), T = is_repair, CodecID = codec, Q = quality_report appended
+```
+
+#### MiniHeader (4 bytes, compressed)
+
+```
+Bytes 0-1: timestamp_delta_ms (u16 BE)
+Bytes 2-3: payload_len (u16 BE)
+
+Preceded by FRAME_TYPE_MINI (0x01). Full header every 50 frames (~1s).
+Saves 8 bytes/packet (67% header reduction).
+```
+
+#### TrunkFrame (batched datagrams)
+
+```
+[count:u16]
+ [session_id:2][len:u16][payload:len] x count
+
+Packs multiple session packets into one QUIC datagram.
+Max 10 entries or 1200 bytes, flushed every 5ms.
+```
+
+#### QualityReport (4 bytes, optional trailer)
+
+```
+Byte 0: loss_pct (0-255 maps to 0-100%)
+Byte 1: rtt_4ms (0-255 maps to 0-1020ms)
+Byte 2: jitter_ms
+Byte 3: bitrate_cap_kbps
+```
+
+### Bandwidth Summary
+
+| Profile | Audio | FEC Overhead | Total | Silence Savings |
+|---------|-------|-------------|-------|----------------|
+| Studio 64k | 64 kbps | 10% = 6.4 kbps | **70.4 kbps** | ~50% with DTX |
+| Studio 48k | 48 kbps | 10% = 4.8 kbps | **52.8 kbps** | ~50% with DTX |
+| Studio 32k | 32 kbps | 10% = 3.2 kbps | **35.2 kbps** | ~50% with DTX |
+| Good (24k) | 24 kbps | 20% = 4.8 kbps | **28.8 kbps** | ~50% with DTX |
+| Degraded (6k) | 6 kbps | 50% = 3.0 kbps | **9.0 kbps** | ~50% with DTX |
+| Catastrophic (1.2k) | 1.2 kbps | 100% = 1.2 kbps | **2.4 kbps** | ~50% with DTX |
+
+Additional savings: MiniHeaders save 8 bytes/packet (67% header reduction). Trunking shares QUIC overhead across multiplexed sessions.
+
+## Security
+
+### Identity Model
+
+Every user has a persistent identity derived from a 32-byte seed:
+
+```mermaid
+graph TD
+ SEED["32-byte Seed
(BIP39 Mnemonic: 24 words)"] --> HKDF1["HKDF
info='warzone-ed25519'"]
+ SEED --> HKDF2["HKDF
info='warzone-x25519'"]
+
+ HKDF1 --> ED["Ed25519 SigningKey
(Digital Signatures)"]
+ HKDF2 --> X25519["X25519 StaticSecret
(Key Agreement)"]
+
+ ED --> VKEY["Ed25519 VerifyingKey
(Public)"]
+ X25519 --> XPUB["X25519 PublicKey
(Public)"]
+
+ VKEY --> FP["Fingerprint
SHA-256(pubkey), truncated 16 bytes
xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx:xxxx"]
+
+ style SEED fill:#6c5ce7,color:#fff
+ style FP fill:#fd79a8,color:#fff
+ style ED fill:#ee5a24,color:#fff
+ style X25519 fill:#00b894,color:#fff
+```
+
+**BIP39 Mnemonic Backup**: The 32-byte seed can be encoded as a 24-word BIP39 mnemonic for human-readable backup. The same seed produces the same identity on any platform.
+
+**featherChat Compatibility**: The identity derivation is compatible with the Warzone messenger (featherChat), allowing a shared identity across messaging and calling.
+
+### Cryptographic Handshake
+
+```mermaid
+sequenceDiagram
+ participant C as Caller
+ participant R as Relay / Callee
+
+ Note over C: Derive identity from seed
Ed25519 + X25519 via HKDF
+
+ C->>C: Generate ephemeral X25519 keypair
+ C->>C: Sign(ephemeral_pub || "call-offer")
+ C->>R: CallOffer { identity_pub, ephemeral_pub, signature, profiles }
+
+ R->>R: Verify Ed25519 signature
+ R->>R: Generate ephemeral X25519 keypair
+ R->>R: shared_secret = DH(eph_b, eph_a)
+ R->>R: session_key = HKDF(shared_secret, "warzone-session-key")
+ R->>R: Sign(ephemeral_pub || "call-answer")
+ R->>C: CallAnswer { identity_pub, ephemeral_pub, signature, profile }
+
+ C->>C: Verify signature
+ C->>C: shared_secret = DH(eph_a, eph_b)
+ C->>C: session_key = HKDF(shared_secret)
+
+ Note over C,R: Both have identical ChaCha20-Poly1305 session key
+ C->>R: Encrypted media (QUIC datagrams)
+ R->>C: Encrypted media (QUIC datagrams)
+
+ Note over C,R: Rekey every 65,536 packets
New ephemeral DH + HKDF mix
+```
+
+### Encryption Details
+
+| Component | Algorithm | Purpose |
+|-----------|-----------|---------|
+| Identity signing | Ed25519 | Authenticate handshake messages |
+| Key agreement | X25519 (ephemeral) | Derive shared secret |
+| Key derivation | HKDF-SHA256 | Derive session key from shared secret |
+| Media encryption | ChaCha20-Poly1305 | Encrypt audio payloads (16-byte tag) |
+| Nonce construction | Deterministic from sequence number | No nonce reuse, no state sync needed |
+| Anti-replay | Sliding window (64-packet) | Reject duplicate/old packets |
+| Forward secrecy | Rekey every 65,536 packets | New ephemeral DH + HKDF mix |
+
+**Why ChaCha20-Poly1305 over AES-GCM**:
+- Faster on hardware without AES-NI (ARM phones, Raspberry Pi relays)
+- Inherently constant-time (add-rotate-XOR only)
+- Compatible with Warzone messenger (featherChat)
+- Same 16-byte authentication tag overhead as AES-GCM
+
+**AEAD with AAD**: The MediaHeader is used as Associated Authenticated Data. The header is authenticated but not encrypted, allowing relays to read routing information (block ID, sequence number) without decrypting the payload.
+
+### Trust on First Use (TOFU)
+
+Clients remember the relay's TLS certificate fingerprint after first connection. If the fingerprint changes on a subsequent connection, the desktop client shows a "Server Key Changed" warning dialog. The relay derives its TLS certificate deterministically from its persisted identity seed, so the fingerprint is stable across restarts.
+
+## Relay Architecture
+
+### Room Mode (Default SFU)
+
+In room mode, the relay acts as a Selective Forwarding Unit. Clients join named rooms via the QUIC SNI (Server Name Indication) field. The relay forwards each participant's encrypted packets to all other participants in the room without decoding or re-encoding.
+
+```mermaid
+graph TB
+ subgraph "Room Mode (SFU)"
+ C1[Client 1] -->|"QUIC SNI=room-hash"| RM[Room Manager]
+ C2[Client 2] -->|"QUIC SNI=room-hash"| RM
+ C3[Client 3] -->|"QUIC SNI=room-hash"| RM
+ RM --> R1[Room 'podcast']
+ R1 -->|fan-out| C1
+ R1 -->|fan-out| C2
+ R1 -->|fan-out| C3
+ end
+
+ style RM fill:#ff9f43,color:#fff
+ style R1 fill:#fdcb6e
+```
+
+**SFU vs MCU trade-off**: SFU was chosen because it preserves end-to-end encryption (the relay never sees plaintext audio). An MCU would need to decode, mix, and re-encode, breaking E2E encryption. The trade-off is O(N) bandwidth at the relay for N participants.
+
+### Forward Mode
+
+With `--remote`, the relay forwards all traffic to a remote relay. Used for chaining relays across lossy or censored links:
+
+```
+Client --> Relay A (--remote B) --> Relay B --> Destination Client
+```
+
+The relay pipeline in forward mode: FEC decode, jitter buffer, then FEC re-encode for the next hop.
+
+## Federation
+
+### Overview
+
+Two or more relays form a federation mesh. Each relay is an independent SFU. When configured to trust each other, they bridge **global rooms** -- participants on relay A in a global room hear participants on relay B in the same room.
+
+### Configuration
+
+Federation uses three TOML configuration sections:
+
+- `[[peers]]` -- outbound connections to peer relays (url + TLS fingerprint)
+- `[[trusted]]` -- inbound connections accepted from relays (TLS fingerprint only)
+- `[[global_rooms]]` -- room names to bridge across all federated peers
+
+### Federation Topology
+
+```mermaid
+graph TB
+ subgraph "Relay A (EU)"
+ A_RM[Room Manager]
+ A_FM[Federation Manager]
+ A1[Alice - local]
+ A2[Bob - local]
+ A_RM --> A_FM
+ end
+
+ subgraph "Relay B (US)"
+ B_RM[Room Manager]
+ B_FM[Federation Manager]
+ B1[Charlie - local]
+ B_RM --> B_FM
+ end
+
+ A_FM <-->|"QUIC SNI='_federation'
GlobalRoomActive/Inactive
Media forwarding"| B_FM
+
+ A1 -->|media| A_RM
+ A2 -->|media| A_RM
+ B1 -->|media| B_RM
+
+ A_RM -->|"federated fan-out"| A1
+ A_RM -->|"federated fan-out"| A2
+ B_RM -->|"federated fan-out"| B1
+
+ style A_FM fill:#6c5ce7,color:#fff
+ style B_FM fill:#6c5ce7,color:#fff
+ style A_RM fill:#ff9f43,color:#fff
+ style B_RM fill:#ff9f43,color:#fff
+```
+
+### Protocol
+
+1. On startup, each relay connects to all configured `[[peers]]` via QUIC with SNI `"_federation"`
+2. After QUIC handshake, sends `FederationHello { tls_fingerprint }` for identity verification
+3. Peer verifies the fingerprint against its `[[trusted]]` or `[[peers]]` list
+4. When a local participant joins a global room, sends `GlobalRoomActive { room }` to all peers
+5. When the last local participant leaves, sends `GlobalRoomInactive { room }`
+6. Media is forwarded as `[room_hash:8][original_media_packet]` -- the relay does not decrypt
+
+### What Relays Do NOT Do
+
+- **No transcoding** -- media passes through as-is
+- **No re-encryption** -- packets are already encrypted E2E
+- **No central coordinator** -- each relay independently connects to configured peers
+- **No automatic peer discovery** -- peers must be explicitly configured
+
+### Failure Handling
+
+- If a peer goes down, local rooms continue working; federated participants disappear from presence
+- Reconnection: every 30 seconds with exponential backoff up to 5 minutes
+- If a peer restarts with a different identity, the fingerprint check fails with a clear log message
+
+## Jitter Buffer
+
+The jitter buffer balances latency vs quality:
+
+| Setting | Client | Relay |
+|---------|--------|-------|
+| Target depth | 10 packets (200ms) | 50 packets (1s) |
+| Minimum before playout | 3 packets (60ms) | 25 packets (500ms) |
+| Maximum cap | 250 packets (5s) | 250 packets (5s) |
+
+The relay uses a deeper buffer to absorb jitter from lossy inter-relay links. The client uses a shallower buffer for lower latency.
+
+The adaptive playout delay tracks jitter via exponential moving average and adjusts the target depth:
+
+```
+target_delay = ceil(jitter_ema / 20ms) + 2
+```
+
+**Known limitation**: The current jitter buffer does not use timestamp-based playout scheduling. It relies on sequence-number ordering only, which can lead to drift during long calls.
+
+## Signal Messages
+
+Signal messages are sent over reliable QUIC streams as length-prefixed JSON:
+
+```
+[4-byte length prefix][serde_json payload]
+```
+
+| Message | Purpose |
+|---------|---------|
+| `CallOffer` | Identity, ephemeral key, signature, supported profiles |
+| `CallAnswer` | Identity, ephemeral key, signature, chosen profile |
+| `AuthToken` | featherChat bearer token for relay authentication |
+| `Hangup` | Reason: Normal, Busy, Declined, Timeout, Error |
+| `Hold` / `Unhold` | Call hold state |
+| `Mute` / `Unmute` | Mic mute state |
+| `Transfer` | Call transfer to another relay/fingerprint |
+| `Rekey` | New ephemeral key for forward secrecy |
+| `QualityUpdate` | Quality report + recommended profile |
+| `Ping` / `Pong` | Latency measurement (timestamp_ms) |
+| `RoomUpdate` | Participant list changes |
+| `PresenceUpdate` | Federation presence gossip |
+| `RouteQuery` / `RouteResponse` | Presence discovery for routing |
+| `FederationHello` | Relay identity during federation setup |
+| `GlobalRoomActive` / `GlobalRoomInactive` | Federation room bridging |
+
+## Test Coverage
+
+571 tests across all crates, 0 failures:
+
+| Crate | Tests | Key Coverage |
+|-------|-------|-------------|
+| wzp-proto | 41 | Wire format, jitter buffer, quality tiers, mini-frames, trunking |
+| wzp-codec | 31 | Opus/Codec2 roundtrip, silence detection, noise suppression |
+| wzp-fec | 22 | RaptorQ encode/decode, loss recovery, interleaving |
+| wzp-crypto | 34 + 28 compat | Encrypt/decrypt, handshake, anti-replay, featherChat identity |
+| wzp-transport | 2 | QUIC connection setup |
+| wzp-relay | 40 + 4 integration | Room ACL, session mgmt, metrics, probes, mesh, trunking |
+| wzp-client | 30 + 2 integration | Encoder/decoder, quality adapter, silence, drift, sweep |
+| wzp-web | 2 | Metrics |
+
+## Audio Routing (Android)
+
+WarzonePhone supports three audio output routes on Android: **Earpiece**, **Speaker**, and **Bluetooth SCO**. The user cycles through available routes with a single button.
+
+### Audio mode lifecycle
+
+`MODE_IN_COMMUNICATION` is set **when the call engine starts** (right before Oboe `audio_start()`), not at app launch. This is critical — setting it early hijacks system audio routing (e.g. music drops from BT A2DP to earpiece). `MODE_NORMAL` is restored when the call engine stops.
+
+```
+App launch → MODE_NORMAL (other apps' audio unaffected)
+Call start → set_audio_mode_communication() → MODE_IN_COMMUNICATION
+Call end → audio_stop() → set_audio_mode_normal() → MODE_NORMAL
+```
+
+### Route lifecycle
+
+1. Call starts → Earpiece (default).
+2. User taps route button → cycles to next available route.
+3. Route change requires Oboe stream restart (~60-400ms) because AAudio silently tears down streams on some OEMs when the routing target changes mid-stream.
+4. Bluetooth disconnect mid-call → `AudioDeviceCallback.onAudioDevicesRemoved` fires → auto-fallback to Earpiece or Speaker.
+
+### Bluetooth SCO
+
+SCO (Synchronous Connection Oriented) is the correct Bluetooth profile for VoIP — it provides bidirectional mono audio at 8/16 kHz with ~30ms latency. A2DP (stereo, high-quality) is unidirectional and adds 100-200ms of buffering, making it unsuitable for real-time voice.
+
+On API 31+ (Android 12), we use the modern `setCommunicationDevice(AudioDeviceInfo)` API to route audio to the BT SCO device. The deprecated `startBluetoothSco()` + `setBluetoothScoOn()` path is used as fallback on older APIs. `setBluetoothScoOn()` is silently rejected on Android 12+ for non-system apps.
+
+BT SCO devices only support 8/16kHz sample rates, but our pipeline runs at 48kHz. When BT is active, Oboe opens in **BT mode** (`bt_active=1`): capture skips `setSampleRate(48000)` and `setInputPreset(VoiceCommunication)`, letting the system open at the device's native rate. Oboe's `SampleRateConversionQuality::Best` resamples to/from 48kHz for our ring buffers.
+
+### Two app variants
+
+Both the native Kotlin app (`AudioRouteManager.kt`) and the Tauri app (`android_audio.rs` JNI bridge) support BT SCO routing. The native app uses `AudioDeviceCallback` for automatic device detection; the Tauri app uses `getAvailableCommunicationDevices()` (API 31+) or `getDevices()` on demand.
+
+## Network Change Response
+
+The `AdaptiveQualityController` in `wzp-proto` reacts to network transport changes signaled via `signal_network_change(NetworkContext)`:
+
+| Transition | Response |
+|-----------|----------|
+| WiFi → Cellular | Preemptive 1-tier quality downgrade + 10s FEC boost |
+| Cellular → WiFi | FEC boost only (quality recovers via normal adaptive logic) |
+| Any change | Reset hysteresis counters to avoid stale state |
+
+On Android, `NetworkMonitor.kt` wraps `ConnectivityManager.NetworkCallback` and classifies the transport type using bandwidth heuristics (no `READ_PHONE_STATE` needed). The classification is delivered to the Rust engine via JNI → `AtomicU8` → recv task polling — the same lock-free cross-task signaling pattern used for adaptive profile switches.
+
+### Cellular generation heuristics
+
+| Downstream bandwidth | Classification |
+|---------------------|---------------|
+| >= 100 Mbps | 5G NR |
+| >= 10 Mbps | LTE |
+| < 10 Mbps | 3G or worse |
+
+These thresholds are conservative. Carriers over-report bandwidth, but for VoIP quality decisions the exact generation matters less than the rough category.
+
+## Build Requirements
+
+- **Rust** 1.85+ (2024 edition)
+- **Linux**: cmake, pkg-config, libasound2-dev (for audio feature)
+- **macOS**: Xcode command line tools (CoreAudio included)
+- **Android**: NDK 26.1 (r26b), cmake 3.25-3.28 (system package)
+
+### Android APK Builds
+
+```bash
+# arm64 only (default, 25MB release APK)
+./scripts/build-tauri-android.sh --init --release --arch arm64
+
+# armv7 only (smaller devices)
+./scripts/build-tauri-android.sh --init --release --arch armv7
+
+# both architectures as separate APKs
+./scripts/build-tauri-android.sh --init --release --arch all
+```
+
+Release APKs are signed with `android/keystore/wzp-release.jks` via `apksigner`. Per-arch builds produce separate APKs (~25MB each vs ~50MB universal) for easier sharing with testers.
diff --git a/vault/Architecture/Extensibility.md b/vault/Architecture/Extensibility.md
new file mode 100644
index 0000000..e94e9f7
--- /dev/null
+++ b/vault/Architecture/Extensibility.md
@@ -0,0 +1,209 @@
+---
+tags: [architecture, wzp]
+type: architecture
+---
+
+# WarzonePhone Extension Points & Future Features
+
+## Trait-Based Architecture
+
+The protocol is designed around trait interfaces defined in `crates/wzp-proto/src/traits.rs`. Any implementation that satisfies the trait contract can be plugged in without modifying other crates.
+
+### Adding a New Audio Codec
+
+Implement `AudioEncoder` and `AudioDecoder` from `wzp_proto::traits`:
+
+```rust
+pub trait AudioEncoder: Send + Sync {
+ fn encode(&mut self, pcm: &[i16], out: &mut [u8]) -> Result;
+ fn codec_id(&self) -> CodecId;
+ fn set_profile(&mut self, profile: QualityProfile) -> Result<(), CodecError>;
+ fn max_frame_bytes(&self) -> usize;
+ fn set_inband_fec(&mut self, _enabled: bool) {}
+ fn set_dtx(&mut self, _enabled: bool) {}
+}
+
+pub trait AudioDecoder: Send + Sync {
+ fn decode(&mut self, encoded: &[u8], pcm: &mut [i16]) -> Result;
+ fn decode_lost(&mut self, pcm: &mut [i16]) -> Result;
+ fn codec_id(&self) -> CodecId;
+ fn set_profile(&mut self, profile: QualityProfile) -> Result<(), CodecError>;
+}
+```
+
+Steps:
+1. Add a new variant to `CodecId` in `crates/wzp-proto/src/codec_id.rs` (uses 4-bit wire encoding, currently 5 of 16 values used)
+2. Implement `AudioEncoder` and `AudioDecoder` for your codec
+3. Register the codec in `AdaptiveEncoder`/`AdaptiveDecoder` in `crates/wzp-codec/src/adaptive.rs`
+4. Add a `QualityProfile` constant for the new codec
+
+### Adding a New FEC Scheme
+
+Implement `FecEncoder` and `FecDecoder` from `wzp_proto::traits`:
+
+```rust
+pub trait FecEncoder: Send + Sync {
+ fn add_source_symbol(&mut self, data: &[u8]) -> Result<(), FecError>;
+ fn generate_repair(&mut self, ratio: f32) -> Result)>, FecError>;
+ fn finalize_block(&mut self) -> Result;
+ fn current_block_id(&self) -> u8;
+ fn current_block_size(&self) -> usize;
+}
+
+pub trait FecDecoder: Send + Sync {
+ fn add_symbol(&mut self, block_id: u8, symbol_index: u8, is_repair: bool, data: &[u8]) -> Result<(), FecError>;
+ fn try_decode(&mut self, block_id: u8) -> Result