From 6af0539a720f39c27d7d7896ab43983886f964b7 Mon Sep 17 00:00:00 2001 From: Siavash Sameni Date: Mon, 11 May 2026 10:58:05 +0400 Subject: [PATCH] T1.1: Add v2 MediaHeader type --- crates/wzp-proto/src/lib.rs | 10 +- crates/wzp-proto/src/packet.rs | 262 ++++++-- crates/wzp-proto/src/quality.rs | 24 +- docs/PRD/TASKS.md | 1101 +++++++++++++++++++++++++++++++ docs/PRD/reports/T1.1-report.md | 88 +++ 5 files changed, 1405 insertions(+), 80 deletions(-) create mode 100644 docs/PRD/TASKS.md create mode 100644 docs/PRD/reports/T1.1-report.md diff --git a/crates/wzp-proto/src/lib.rs b/crates/wzp-proto/src/lib.rs index 13e9479..4a73203 100644 --- a/crates/wzp-proto/src/lib.rs +++ b/crates/wzp-proto/src/lib.rs @@ -23,15 +23,15 @@ pub mod session; pub mod traits; // Re-export key types at crate root for convenience. +pub use bandwidth::{BandwidthEstimator, CongestionState}; pub use codec_id::{CodecId, QualityProfile}; +pub use dred_tuner::{DredTuner, DredTuning}; pub use error::*; pub use packet::{ - CallAcceptMode, HangupReason, MediaHeader, MediaPacket, MiniFrameContext, MiniHeader, - PresenceUser, QualityReport, RoomParticipant, SignalMessage, TrunkEntry, TrunkFrame, FRAME_TYPE_FULL, - FRAME_TYPE_MINI, + CallAcceptMode, FRAME_TYPE_FULL, FRAME_TYPE_MINI, HangupReason, MediaHeader, MediaHeaderV1, + MediaHeaderV2, MediaPacket, MiniFrameContext, MiniHeader, PresenceUser, QualityReport, + RoomParticipant, SignalMessage, TrunkEntry, TrunkFrame, }; -pub use bandwidth::{BandwidthEstimator, CongestionState}; -pub use dred_tuner::{DredTuner, DredTuning}; pub use quality::{AdaptiveQualityController, NetworkContext, Tier}; pub use session::{Session, SessionEvent, SessionState}; pub use traits::*; diff --git a/crates/wzp-proto/src/packet.rs b/crates/wzp-proto/src/packet.rs index 8cd0d89..6faa5f2 100644 --- a/crates/wzp-proto/src/packet.rs +++ b/crates/wzp-proto/src/packet.rs @@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize}; use crate::CodecId; -/// 12-byte media packet header for the lossy link. +/// 12-byte v1 media packet header for the lossy link. /// /// Wire layout: /// ```text @@ -17,7 +17,7 @@ use crate::CodecId; /// Byte 11: CSRC count /// ``` #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct MediaHeader { +pub struct MediaHeaderV1 { /// Protocol version (0 = v1). pub version: u8, /// true = FEC repair packet, false = source media. @@ -42,7 +42,7 @@ pub struct MediaHeader { pub csrc_count: u8, } -impl MediaHeader { +impl MediaHeaderV1 { /// Header size in bytes on the wire. pub const WIRE_SIZE: usize = 12; @@ -156,6 +156,88 @@ impl MediaHeader { } } +/// Temporary alias so existing code continues to compile. +/// Removed in T1.5 once all emit/parse sites migrate to v2. +pub type MediaHeader = MediaHeaderV1; + +/// 16-byte v2 media header. See docs/PRD/PRD-wire-format-v2.md. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct MediaHeaderV2 { + pub version: u8, // always 2 + pub flags: u8, // bit 7 T, bit 6 Q, bit 5 KeyFrame, bit 4 FrameEnd + pub media_type: u8, // TODO(T1.2): replace with MediaType + pub codec_id: CodecId, + pub stream_id: u8, + pub fec_ratio: u8, // 0..200 -> 0.0..2.0 + pub seq: u32, + pub timestamp: u32, + pub fec_block: u16, +} + +impl MediaHeaderV2 { + pub const WIRE_SIZE: usize = 16; + pub const VERSION: u8 = 2; + + pub fn write_to(&self, buf: &mut impl BufMut) { + buf.put_u8(self.version); + buf.put_u8(self.flags); + buf.put_u8(self.media_type); + buf.put_u8(self.codec_id.to_wire()); + buf.put_u8(self.stream_id); + buf.put_u8(self.fec_ratio); + buf.put_u32(self.seq); + buf.put_u32(self.timestamp); + buf.put_u16(self.fec_block); + } + + pub fn read_from(buf: &mut impl Buf) -> Option { + if buf.remaining() < Self::WIRE_SIZE { + return None; + } + let version = buf.get_u8(); + if version != Self::VERSION { + return None; + } + let flags = buf.get_u8(); + let media_type = buf.get_u8(); + let codec_id = CodecId::from_wire(buf.get_u8())?; + let stream_id = buf.get_u8(); + let fec_ratio = buf.get_u8(); + let seq = buf.get_u32(); + let timestamp = buf.get_u32(); + let fec_block = buf.get_u16(); + Some(Self { + version, + flags, + media_type, + codec_id, + stream_id, + fec_ratio, + seq, + timestamp, + fec_block, + }) + } + + pub const FLAG_REPAIR: u8 = 0b1000_0000; + pub const FLAG_QUALITY: u8 = 0b0100_0000; + pub const FLAG_KEYFRAME: u8 = 0b0010_0000; + pub const FLAG_FRAME_END: u8 = 0b0001_0000; + + pub fn is_repair(&self) -> bool { + self.flags & Self::FLAG_REPAIR != 0 + } + pub fn has_quality(&self) -> bool { + self.flags & Self::FLAG_QUALITY != 0 + } + pub fn is_keyframe(&self) -> bool { + self.flags & Self::FLAG_KEYFRAME != 0 + } + pub fn is_frame_end(&self) -> bool { + self.flags & Self::FLAG_FRAME_END != 0 + } +} + /// A user visible in the signal presence list. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct PresenceUser { @@ -286,18 +368,13 @@ impl MediaPacket { /// Uses the `MiniFrameContext` to decide whether to emit a compact 4-byte /// mini-header or a full 12-byte header. A full header is forced on the /// first frame and every `MINI_FRAME_FULL_INTERVAL` frames thereafter. - pub fn encode_compact( - &self, - ctx: &mut MiniFrameContext, - frames_since_full: &mut u32, - ) -> Bytes { + pub fn encode_compact(&self, ctx: &mut MiniFrameContext, frames_since_full: &mut u32) -> Bytes { if *frames_since_full > 0 && *frames_since_full < MINI_FRAME_FULL_INTERVAL { // --- mini frame --- let ts_delta = self .header .timestamp - .wrapping_sub(ctx.last_header.unwrap().timestamp) - as u16; + .wrapping_sub(ctx.last_header.unwrap().timestamp) as u16; let mini = MiniHeader { timestamp_delta_ms: ts_delta, payload_len: self.payload.len() as u16, @@ -407,6 +484,12 @@ pub struct TrunkFrame { pub packets: Vec, } +impl Default for TrunkFrame { + fn default() -> Self { + Self::new() + } +} + impl TrunkFrame { /// Create an empty trunk frame. pub fn new() -> Self { @@ -460,7 +543,7 @@ impl TrunkFrame { if buf.len() < 2 { return None; } - let mut cursor = &buf[..]; + let mut cursor = buf; let count = cursor.get_u16() as usize; let mut packets = Vec::with_capacity(count); for _ in 0..count { @@ -626,8 +709,12 @@ pub enum SignalMessage { }, /// Connection keepalive / RTT measurement. - Ping { timestamp_ms: u64 }, - Pong { timestamp_ms: u64 }, + Ping { + timestamp_ms: u64, + }, + Pong { + timestamp_ms: u64, + }, /// End the call. `call_id` is optional for backwards compatibility /// with older clients that send Hangup without it — the relay falls @@ -640,7 +727,9 @@ pub enum SignalMessage { /// featherChat bearer token for relay authentication. /// Sent as the first signal message when --auth-url is configured. - AuthToken { token: String }, + AuthToken { + token: String, + }, /// Put the call on hold (stop sending media, keep session alive). Hold, @@ -705,7 +794,6 @@ pub enum SignalMessage { }, // ── Federation signals (relay-to-relay) ── - /// Federation: initial handshake — the connecting relay identifies itself. FederationHello { /// TLS certificate fingerprint of the connecting relay. @@ -726,7 +814,6 @@ pub enum SignalMessage { }, // ── Direct calling signals (client ↔ relay signaling) ── - /// Register on relay for direct calls. Sent on `_signal` connections /// after optional AuthToken. RegisterPresence { @@ -882,7 +969,6 @@ pub enum SignalMessage { }, // ── NAT reflection ("STUN for QUIC") ────────────────────────────── - /// Client → relay: "please tell me the source IP:port you see on /// this connection". A QUIC-native replacement for classic STUN /// that reuses the TLS-authenticated signal channel to the relay @@ -905,7 +991,6 @@ pub enum SignalMessage { }, // ── Phase 6: ICE-style path negotiation ───────────────────── - /// Phase 6: each side reports the result of its local dual- /// path race to the other side through the relay. Both peers /// send this after their race completes; both wait for the @@ -930,7 +1015,6 @@ pub enum SignalMessage { }, // ── Phase 8: mid-call ICE re-gathering ──────────────────────── - /// Phase 8 (Tailscale-inspired): mid-call candidate update sent /// when a client's network changes (WiFi → cellular, IP change, /// etc.). The relay forwards this to the call peer, who can @@ -956,7 +1040,6 @@ pub enum SignalMessage { }, // ── Hard NAT traversal (port prediction) ────────────────────── - /// Hard NAT probe coordination — exchanged when both peers /// detect symmetric NAT. Carries the port allocation pattern /// and recent port sequence so the peer can predict which port @@ -989,7 +1072,6 @@ pub enum SignalMessage { }, // ── Phase 4: cross-relay direct-call signaling ──────────────────── - /// Phase 4: relay-to-relay envelope for forwarding direct-call /// signaling across a federation link. When Alice on Relay A /// sends a `DirectCallOffer` for Bob whose fingerprint isn't @@ -1029,7 +1111,6 @@ pub enum SignalMessage { }, // ── Signal presence ─────────────────────────────────────────── - /// Relay broadcasts the list of currently registered signal /// users to all connected clients. Sent on every register/ /// deregister so clients can maintain a live lobby user list. @@ -1039,7 +1120,6 @@ pub enum SignalMessage { }, // ── Quality upgrade negotiation (#28, #29) ────────────────── - /// Peer proposes upgrading to a higher quality profile. /// The other side can accept or reject based on its own network /// conditions. Used for consensual upgrades that require both @@ -1077,7 +1157,6 @@ pub enum SignalMessage { }, // ── Per-participant quality (#30) ─────────────────────────── - /// Peer reports its own quality capability — allows asymmetric /// encoding where each side uses the best quality its connection /// supports, rather than forcing all to the weakest link. @@ -1205,6 +1284,27 @@ mod tests { assert_eq!(header, decoded); } + #[test] + fn media_header_v2_roundtrip() { + let h = MediaHeaderV2 { + version: 2, + flags: MediaHeaderV2::FLAG_QUALITY, + media_type: 0, // TODO(T1.2): MediaType::Audio + codec_id: CodecId::Opus24k, + stream_id: 0, + fec_ratio: 50, + seq: 0xDEAD_BEEF, + timestamp: 0x1234_5678, + fec_block: 0xABCD, + }; + let mut buf = BytesMut::with_capacity(MediaHeaderV2::WIRE_SIZE); + h.write_to(&mut buf); + assert_eq!(buf.len(), 16); + let mut cursor = std::io::Cursor::new(&buf[..]); + let parsed = MediaHeaderV2::read_from(&mut cursor).unwrap(); + assert_eq!(h, parsed); + } + #[test] fn quality_report_roundtrip() { let qr = QualityReport { @@ -1278,7 +1378,8 @@ mod tests { SignalMessage::ReflectResponse { observed_addr } => { assert_eq!(observed_addr, addr); // Must parse back to a SocketAddr cleanly. - let _parsed: std::net::SocketAddr = observed_addr.parse() + let _parsed: std::net::SocketAddr = observed_addr + .parse() .expect("observed_addr must parse as SocketAddr"); } _ => panic!("wrong variant after roundtrip"), @@ -1311,7 +1412,10 @@ mod tests { let json = serde_json::to_string(&forward).unwrap(); let decoded: SignalMessage = serde_json::from_str(&json).unwrap(); match decoded { - SignalMessage::FederatedSignalForward { inner, origin_relay_fp } => { + SignalMessage::FederatedSignalForward { + inner, + origin_relay_fp, + } => { assert_eq!(origin_relay_fp, "relay-a-tls-fp"); match *inner { SignalMessage::DirectCallOffer { @@ -1348,8 +1452,13 @@ mod tests { callee_mapped_addr: None, callee_build_version: None, }, - SignalMessage::CallRinging { call_id: "c1".into() }, - SignalMessage::Hangup { reason: HangupReason::Normal, call_id: None }, + SignalMessage::CallRinging { + call_id: "c1".into(), + }, + SignalMessage::Hangup { + reason: HangupReason::Normal, + call_id: None, + }, ]; for inner in cases { let inner_disc = std::mem::discriminant(&inner); @@ -1392,7 +1501,10 @@ mod tests { ); let decoded: SignalMessage = serde_json::from_str(&json).unwrap(); match decoded { - SignalMessage::DirectCallOffer { caller_reflexive_addr, .. } => { + SignalMessage::DirectCallOffer { + caller_reflexive_addr, + .. + } => { assert_eq!(caller_reflexive_addr.as_deref(), Some("192.0.2.1:4433")); } _ => panic!("wrong variant"), @@ -1437,11 +1549,11 @@ mod tests { let decoded: SignalMessage = serde_json::from_str(&serde_json::to_string(&answer).unwrap()).unwrap(); match decoded { - SignalMessage::DirectCallAnswer { callee_reflexive_addr, .. } => { - assert_eq!( - callee_reflexive_addr.as_deref(), - Some("198.51.100.9:4433") - ); + SignalMessage::DirectCallAnswer { + callee_reflexive_addr, + .. + } => { + assert_eq!(callee_reflexive_addr.as_deref(), Some("198.51.100.9:4433")); } _ => panic!("wrong variant"), } @@ -1458,7 +1570,9 @@ mod tests { let decoded: SignalMessage = serde_json::from_str(&serde_json::to_string(&setup).unwrap()).unwrap(); match decoded { - SignalMessage::CallSetup { peer_direct_addr, .. } => { + SignalMessage::CallSetup { + peer_direct_addr, .. + } => { assert_eq!(peer_direct_addr.as_deref(), Some("192.0.2.1:4433")); } _ => panic!("wrong variant"), @@ -1484,7 +1598,10 @@ mod tests { }"#; let decoded: SignalMessage = serde_json::from_str(old_offer_json).unwrap(); match decoded { - SignalMessage::DirectCallOffer { caller_reflexive_addr, .. } => { + SignalMessage::DirectCallOffer { + caller_reflexive_addr, + .. + } => { assert!(caller_reflexive_addr.is_none()); } _ => panic!("wrong variant"), @@ -1499,7 +1616,9 @@ mod tests { }"#; let decoded: SignalMessage = serde_json::from_str(old_setup_json).unwrap(); match decoded { - SignalMessage::CallSetup { peer_direct_addr, .. } => { + SignalMessage::CallSetup { + peer_direct_addr, .. + } => { assert!(peer_direct_addr.is_none()); } _ => panic!("wrong variant"), @@ -1512,19 +1631,23 @@ mod tests { // not break JSON round-tripping of existing variants. Smoke- // test a sample of the pre-existing ones. let cases = vec![ - SignalMessage::Ping { timestamp_ms: 12345 }, + SignalMessage::Ping { + timestamp_ms: 12345, + }, SignalMessage::Hold, - SignalMessage::Hangup { reason: HangupReason::Normal, call_id: None }, - SignalMessage::CallRinging { call_id: "abcd".into() }, + SignalMessage::Hangup { + reason: HangupReason::Normal, + call_id: None, + }, + SignalMessage::CallRinging { + call_id: "abcd".into(), + }, ]; for m in cases { let json = serde_json::to_string(&m).unwrap(); let decoded: SignalMessage = serde_json::from_str(&json).unwrap(); // Discriminant equality proves variant tag survived. - assert_eq!( - std::mem::discriminant(&m), - std::mem::discriminant(&decoded) - ); + assert_eq!(std::mem::discriminant(&m), std::mem::discriminant(&decoded)); } } @@ -1609,7 +1732,10 @@ mod tests { let json = serde_json::to_string(&msg).unwrap(); let decoded: SignalMessage = serde_json::from_str(&json).unwrap(); match decoded { - SignalMessage::PresenceUpdate { fingerprints, relay_addr } => { + SignalMessage::PresenceUpdate { + fingerprints, + relay_addr, + } => { assert_eq!(fingerprints.len(), 2); assert!(fingerprints.contains(&"aabb".to_string())); assert!(fingerprints.contains(&"ccdd".to_string())); @@ -1626,7 +1752,10 @@ mod tests { let json = serde_json::to_string(&msg_empty).unwrap(); let decoded: SignalMessage = serde_json::from_str(&json).unwrap(); match decoded { - SignalMessage::PresenceUpdate { fingerprints, relay_addr } => { + SignalMessage::PresenceUpdate { + fingerprints, + relay_addr, + } => { assert!(fingerprints.is_empty()); assert_eq!(relay_addr, "10.0.0.2:4433"); } @@ -1859,15 +1988,9 @@ mod tests { let wire = pkt.encode_compact(&mut ctx, &mut frames_since_full); if i == 0 || i == MINI_FRAME_FULL_INTERVAL { - assert_eq!( - wire[0], FRAME_TYPE_FULL, - "frame {i} should be FULL" - ); + assert_eq!(wire[0], FRAME_TYPE_FULL, "frame {i} should be FULL"); } else { - assert_eq!( - wire[0], FRAME_TYPE_MINI, - "frame {i} should be MINI" - ); + assert_eq!(wire[0], FRAME_TYPE_MINI, "frame {i} should be MINI"); } } } @@ -1881,7 +2004,10 @@ mod tests { let json = serde_json::to_string(&msg).unwrap(); let decoded: SignalMessage = serde_json::from_str(&json).unwrap(); match decoded { - SignalMessage::QualityDirective { recommended_profile, reason } => { + SignalMessage::QualityDirective { + recommended_profile, + reason, + } => { assert_eq!(recommended_profile.codec, CodecId::Opus6k); assert_eq!(reason.as_deref(), Some("weakest link degraded")); } @@ -1920,7 +2046,10 @@ mod tests { // We test the raw path: frames_since_full forced to 0 every time. let mut frames_since_full: u32 = 0; let wire = pkt.encode_compact(&mut ctx, &mut frames_since_full); - assert_eq!(wire[0], FRAME_TYPE_FULL, "frame {i} should be FULL when disabled"); + assert_eq!( + wire[0], FRAME_TYPE_FULL, + "frame {i} should be FULL when disabled" + ); } } @@ -1938,7 +2067,11 @@ mod tests { let json = serde_json::to_string(&msg).unwrap(); let decoded: SignalMessage = serde_json::from_str(&json).unwrap(); match decoded { - SignalMessage::UpgradeProposal { proposal_id, proposed_profile, .. } => { + SignalMessage::UpgradeProposal { + proposal_id, + proposed_profile, + .. + } => { assert_eq!(proposal_id, "p1"); assert_eq!(proposed_profile, crate::QualityProfile::STUDIO_48K); } @@ -1972,7 +2105,9 @@ mod tests { let json = serde_json::to_string(&msg).unwrap(); let decoded: SignalMessage = serde_json::from_str(&json).unwrap(); match decoded { - SignalMessage::UpgradeConfirm { confirmed_profile, .. } => { + SignalMessage::UpgradeConfirm { + confirmed_profile, .. + } => { assert_eq!(confirmed_profile, crate::QualityProfile::STUDIO_64K); } _ => panic!("wrong variant"), @@ -1990,7 +2125,11 @@ mod tests { let json = serde_json::to_string(&msg).unwrap(); let decoded: SignalMessage = serde_json::from_str(&json).unwrap(); match decoded { - SignalMessage::QualityCapability { max_profile, loss_pct, .. } => { + SignalMessage::QualityCapability { + max_profile, + loss_pct, + .. + } => { assert_eq!(max_profile, crate::QualityProfile::GOOD); assert!((loss_pct.unwrap() - 2.5).abs() < 0.01); } @@ -2005,10 +2144,7 @@ mod tests { let msg = SignalMessage::CandidateUpdate { call_id: "test-123".into(), reflexive_addr: Some("203.0.113.5:4433".into()), - local_addrs: vec![ - "192.168.1.10:4433".into(), - "10.0.0.5:4433".into(), - ], + local_addrs: vec!["192.168.1.10:4433".into(), "10.0.0.5:4433".into()], mapped_addr: Some("198.51.100.42:12345".into()), generation: 7, }; diff --git a/crates/wzp-proto/src/quality.rs b/crates/wzp-proto/src/quality.rs index 2859672..ccf47ac 100644 --- a/crates/wzp-proto/src/quality.rs +++ b/crates/wzp-proto/src/quality.rs @@ -3,9 +3,9 @@ use std::collections::VecDeque; use std::time::{Duration, Instant}; +use crate::QualityProfile; use crate::packet::QualityReport; use crate::traits::QualityController; -use crate::QualityProfile; /// Network quality tier — drives codec and FEC selection. /// @@ -99,21 +99,16 @@ impl Tier { } /// Describes the network transport type for context-aware quality decisions. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum NetworkContext { WiFi, CellularLte, Cellular5g, Cellular3g, + #[default] Unknown, } -impl Default for NetworkContext { - fn default() -> Self { - Self::Unknown - } -} - /// Adaptive quality controller with hysteresis to prevent tier flapping. /// /// - Downgrade: 3 consecutive reports in a worse tier (2 on cellular) @@ -340,8 +335,7 @@ impl AdaptiveQualityController { if probe.bad_reports > PROBE_MAX_BAD { let _failed_probe = self.probe.take(); // Reset stable_since to trigger cooldown - self.stable_since = - Some(Instant::now() + Duration::from_secs(PROBE_COOLDOWN_SECS)); + self.stable_since = Some(Instant::now() + Duration::from_secs(PROBE_COOLDOWN_SECS)); return None; // stay at current tier } @@ -746,7 +740,10 @@ mod tests { ctrl.observe(°raded); // second bad — exceeds PROBE_MAX_BAD (1) // Probe should be cancelled - assert!(ctrl.probe.is_none(), "probe should be cancelled after bad reports"); + assert!( + ctrl.probe.is_none(), + "probe should be cancelled after bad reports" + ); // Should still be at Studio32k (not upgraded) assert_eq!(ctrl.current_tier, Tier::Studio32k); } @@ -775,6 +772,9 @@ mod tests { let excellent = make_report(0.1, 10); let result = ctrl.observe(&excellent); - assert!(result.is_none(), "should not probe when already at Studio64k"); + assert!( + result.is_none(), + "should not probe when already at Studio64k" + ); } } diff --git a/docs/PRD/TASKS.md b/docs/PRD/TASKS.md new file mode 100644 index 0000000..8d85cdb --- /dev/null +++ b/docs/PRD/TASKS.md @@ -0,0 +1,1101 @@ +# Haiku-Ready Task Breakdown + +> Companion to `docs/PRD/README.md`. Every task here is sized for an agent with limited context to pick up cold: it names exact files, exact symbols, and exact verification commands. Do tasks in order within a wave; waves are dependency-ordered. + +--- + +## Agent operating instructions — read first + +You are an implementing agent. The human is the reviewer. **Your job is not done when the code compiles; it is done when the reviewer has approved your report.** Read this section before touching any task. + +### Workflow per task + +1. **Claim the task.** Move its status in the [Status board](#status-board) at the bottom of this file from `Open` → `In Progress`. Add your handle / model name and a UTC timestamp. +2. **Implement.** Follow the steps in the task block exactly. If the steps don't fit reality (e.g. line numbers shifted, a referenced symbol doesn't exist, the API has evolved), **stop and surface the mismatch in your report** — do not improvise silently. +3. **Verify.** Run the exact commands in the task's `Verify` block. Capture their output verbatim — the reviewer will read it. +4. **Write the report.** Create `docs/PRD/reports/T-report.md` using the template below. One report per task. No exceptions. +5. **Commit.** One commit per task. Message: `T: `. The report file is part of the same commit. +6. **Move to review.** Update the [Status board](#status-board): `In Progress` → `Pending Review`. Add a link to the report path. +7. **Stop.** Do NOT start the next task until the reviewer marks the previous one `Approved`. If they mark it `Changes Requested`, address the feedback in a follow-up commit, update the report, and move back to `Pending Review`. + +### Report template + +Every report lives at `docs/PRD/reports/T-report.md` and uses this template: + +```markdown +# T + +**Status:** Pending Review +**Agent:** +**Started:** +**Completed:** +**Commit:** +**PRD:** ../.md + +## What I changed + +- `:` — +- `:` — +- (etc.) + +## Why these choices + +<2-6 sentences explaining any non-obvious decision: why this signature, why +this default, why this error type, why a deviation from the task steps if any. +If you followed the steps verbatim, say "Followed steps T.1 through T.N +without deviation." and that's enough.> + +## Deviations from the task spec + + + +## Verification output + +For each `Verify` command in the task block, paste the actual output. Trim +benign noise (warnings already present on main) but never trim test failure +output. + +``` +$ cargo test -p wzp-proto media_header_v2_roundtrip +running 1 test +test packet::tests::media_header_v2_roundtrip ... ok + +test result: ok. 1 passed; 0 failed; ... +``` + +## Test summary + +- Tests added: +- Tests modified: +- Workspace test count before: / after: +- `cargo clippy --workspace --all-targets -- -D warnings`: pass / fail +- `cargo fmt --all -- --check`: pass / fail + +## Risks / follow-ups + + + +## Reviewer checklist (filled in by reviewer) + +- [ ] Code matches PRD intent +- [ ] Verification output is real (re-run if suspicious) +- [ ] No backward-incompat surprises +- [ ] Tests cover the new behavior +- [ ] Approved +``` + +### Coding standards — non-negotiable + +These apply to every task. They are NOT repeated in each task block. Violating them is grounds for `Changes Requested` even if the code works. + +1. **Rust edition 2024** (set in workspace root). No exceptions. +2. **`cargo fmt --all`** must produce a clean diff before commit. CI will reject otherwise. +3. **`cargo clippy --workspace --all-targets -- -D warnings`** must pass. Do not `#[allow(...)]` to silence — fix the root cause. If a lint is genuinely wrong, justify the allow in the report. +4. **No `unwrap()` / `expect()` in production code paths.** Tests are fine. Production: return a typed error. +5. **No `println!` / `eprintln!`.** Use `tracing::{debug,info,warn,error}!`. The crates are already wired for tracing. +6. **No new dependencies without justification.** If a task forces a new crate, list it under "Risks / follow-ups" in the report so the reviewer can sanity-check the supply chain. +7. **One commit per task** — see workflow. Don't squash multiple tasks. Don't split a task across commits unless the task itself instructs you to. +8. **Never modify `Cargo.lock` by hand.** Run a real build; commit the resulting lockfile delta. +9. **Public API changes need rustdoc.** Every new `pub fn`, `pub struct`, `pub enum`, or `pub trait` gets a `///` doc comment. Private items: doc only when non-obvious. +10. **Tests live with code.** `#[cfg(test)] mod tests { ... }` next to the code under test. Integration tests in `crates//tests/.rs` only when they exercise multiple modules end-to-end. +11. **Async: tokio only.** Do not introduce `async-std` or `smol`. Spawn via `tokio::spawn`, not raw futures. +12. **Wire format types live in `wzp-proto`.** Do not redefine `MediaHeader`, `SignalMessage`, or codec/quality types in another crate. Re-export if needed. +13. **No emoji in code or commit messages** unless the surrounding context already uses them. +14. **No AI-attribution lines in commit messages.** Plain `T: ` body, that's it. +15. **Comments:** comment WHY, never WHAT. If the code needs a WHAT comment, rename the symbol instead. See repo-root CLAUDE.md (if present) for global guidance. +16. **Don't take destructive actions.** Specifically: never `git reset --hard`, `git push --force`, drop database tables, delete branches, or touch CI configs without the reviewer asking. If you think you need to, stop and ask in your report. +17. **Auto mode is not a license to skip these.** Even when the harness is set to autonomous execution, the workflow (report → Pending Review → wait for Approved) is mandatory. + +### When to stop and ask + +Stop and write a report with status `Blocked` (not `Pending Review`) if any of these happen: + +- A task step references code that doesn't exist. +- A test fails for reasons unrelated to your change. +- The workspace doesn't build at HEAD before you started (the baseline is dirty). +- You need to make a meaningful design decision the task didn't anticipate. +- A "Verify" command produces output you don't understand. + +A `Blocked` report is not a failure — it is the correct outcome when the task spec is wrong or incomplete. + +--- + +## How to read a task + +Each task block has: + +- **ID & title** — `T.` like `T1.1`. +- **PRD** — link to the parent PRD for the "why". +- **Effort** — rough hours for a junior dev with this doc + the repo. +- **Files** — exact paths you will edit. +- **Context** — 2-4 lines on what's there today. +- **Steps** — numbered, do them in order. +- **Verify** — exact commands; output must match. +- **Done when** — single-line acceptance. + +--- + +## Environment setup (do this once) + +```bash +# All commands assume CWD = /Users/manwe/CascadeProjects/warzonePhone +cargo build --workspace # baseline: must succeed +cargo test --workspace --no-fail-fast # baseline: should be 272 pass / 0 fail +``` + +If either fails before you start a task, stop and report — the tree is dirty. + +### Conventions + +- Format on save: `cargo fmt --all` after any code change. +- Lints: `cargo clippy --workspace --all-targets -- -D warnings` must pass before commit. +- Tests live next to code under `#[cfg(test)]` modules, or in `crates//tests/`. +- Wire format types: `crates/wzp-proto/src/packet.rs` is authoritative. Do not duplicate field semantics elsewhere. +- Commit one task per commit. Reference task ID in commit message: `T1.1: widen MediaHeader to v2`. + +### Useful greps + +```bash +grep -rn "MediaHeader::" --include="*.rs" # 6 files outside tests +grep -rn "MiniHeader::" --include="*.rs" +grep -rn "SignalMessage::" --include="*.rs" +grep -rn "CodecId::" --include="*.rs" +``` + +--- + +# Wave 1 — Foundation (target: 1 week) + +Goal: v2 wire format lands cleanly. Audio works under v2. Old clients are politely rejected. + +--- + +## T1.1 — Add v2 `MediaHeader` type + +- **PRD:** `PRD-wire-format-v2.md` +- **Effort:** 3 h +- **Files:** + - `crates/wzp-proto/src/packet.rs` + +### Context +Today `MediaHeader` is defined at line 20 of `packet.rs` with `WIRE_SIZE = 12` (line 47). Fields are bit-packed across the first two bytes. It is constructed in tests starting around line 1229. + +### Steps + +1. Open `crates/wzp-proto/src/packet.rs`. +2. **Do not delete** the existing `MediaHeader`. Rename it in-place to `MediaHeaderV1` (also rename `WIRE_SIZE` consts only on that struct). Keep all impls. +3. Below the `MediaHeaderV1` block, add a new `MediaHeader` struct (16 bytes, byte-aligned): + + ```rust + /// 16-byte v2 media header. See docs/PRD/PRD-wire-format-v2.md. + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + pub struct MediaHeader { + pub version: u8, // always 2 + pub flags: u8, // bit 7 T, bit 6 Q, bit 5 KeyFrame, bit 4 FrameEnd + pub media_type: MediaType, // u8 wire repr + pub codec_id: CodecId, + pub stream_id: u8, + pub fec_ratio: u8, // 0..200 → 0.0..2.0 + pub seq: u32, + pub timestamp: u32, + pub fec_block: u16, + } + + impl MediaHeader { + pub const WIRE_SIZE: usize = 16; + pub const VERSION: u8 = 2; + + pub fn write_to(&self, buf: &mut impl BufMut) { + buf.put_u8(self.version); + buf.put_u8(self.flags); + buf.put_u8(self.media_type.to_wire()); + buf.put_u8(self.codec_id.to_wire()); + buf.put_u8(self.stream_id); + buf.put_u8(self.fec_ratio); + buf.put_u32(self.seq); + buf.put_u32(self.timestamp); + buf.put_u16(self.fec_block); + } + + pub fn read_from(buf: &mut impl Buf) -> Option { + if buf.remaining() < Self::WIRE_SIZE { return None; } + let version = buf.get_u8(); + if version != Self::VERSION { return None; } + let flags = buf.get_u8(); + let media_type = MediaType::from_wire(buf.get_u8())?; + let codec_id = CodecId::from_wire(buf.get_u8())?; + let stream_id = buf.get_u8(); + let fec_ratio = buf.get_u8(); + let seq = buf.get_u32(); + let timestamp = buf.get_u32(); + let fec_block = buf.get_u16(); + Some(Self { version, flags, media_type, codec_id, stream_id, fec_ratio, seq, timestamp, fec_block }) + } + + pub const FLAG_REPAIR: u8 = 0b1000_0000; + pub const FLAG_QUALITY: u8 = 0b0100_0000; + pub const FLAG_KEYFRAME: u8 = 0b0010_0000; + pub const FLAG_FRAME_END: u8 = 0b0001_0000; + + pub fn is_repair(&self) -> bool { self.flags & Self::FLAG_REPAIR != 0 } + pub fn has_quality(&self) -> bool { self.flags & Self::FLAG_QUALITY != 0 } + pub fn is_keyframe(&self) -> bool { self.flags & Self::FLAG_KEYFRAME != 0 } + pub fn is_frame_end(&self) -> bool { self.flags & Self::FLAG_FRAME_END != 0 } + } + ``` + +4. `MediaType` and `CodecId::to_wire` (8-bit) come from T1.2 and T1.3 — add a `// TODO(T1.2)` placeholder if those aren't merged yet (use `u8` directly). +5. Add a round-trip test next to the existing tests: + + ```rust + #[test] + fn media_header_v2_roundtrip() { + let h = MediaHeader { + version: 2, flags: MediaHeader::FLAG_QUALITY, + media_type: MediaType::Audio, codec_id: CodecId::Opus24k, + stream_id: 0, fec_ratio: 50, + seq: 0xDEAD_BEEF, timestamp: 0x1234_5678, + fec_block: 0xABCD, + }; + let mut buf = BytesMut::with_capacity(MediaHeader::WIRE_SIZE); + h.write_to(&mut buf); + assert_eq!(buf.len(), 16); + let mut cursor = std::io::Cursor::new(&buf[..]); + let parsed = MediaHeader::read_from(&mut cursor).unwrap(); + assert_eq!(h, parsed); + } + ``` + +### Verify + +```bash +cargo test -p wzp-proto media_header_v2_roundtrip +cargo build --workspace +``` + +### Done when +- New test passes. Workspace still builds. `MediaHeaderV1` still exists (we delete it later in T1.5). + +--- + +## T1.2 — Add `MediaType` enum + +- **PRD:** `PRD-wire-format-v2.md` +- **Effort:** 1 h +- **Files:** + - `crates/wzp-proto/src/codec_id.rs` (or new sibling file `media_type.rs`) + - `crates/wzp-proto/src/lib.rs` (re-export) + +### Steps + +1. Create `crates/wzp-proto/src/media_type.rs`: + ```rust + use serde::{Deserialize, Serialize}; + + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] + #[repr(u8)] + pub enum MediaType { + Audio = 0, + Video = 1, + Data = 2, + Control = 3, + } + + impl MediaType { + pub const fn to_wire(self) -> u8 { self as u8 } + pub const fn from_wire(v: u8) -> Option { + match v { + 0 => Some(Self::Audio), + 1 => Some(Self::Video), + 2 => Some(Self::Data), + 3 => Some(Self::Control), + _ => None, + } + } + } + ``` +2. In `crates/wzp-proto/src/lib.rs`, add `pub mod media_type;` and `pub use media_type::MediaType;`. + +### Verify +```bash +cargo build -p wzp-proto +cargo test -p wzp-proto +``` + +### Done when +`MediaType` is importable as `wzp_proto::MediaType`. + +--- + +## T1.3 — Widen `CodecId` wire representation to u8 + +- **PRD:** `PRD-wire-format-v2.md` (resolves audit W9) +- **Effort:** 1 h +- **Files:** + - `crates/wzp-proto/src/codec_id.rs` + +### Context +`CodecId::to_wire` returns `self as u8` (already u8 in memory). The "4 bits on wire" is enforced by how `MediaHeaderV1` packs it. With v2 the wire byte is full 8-bit — so reserve more IDs without touching `to_wire`/`from_wire` for the existing variants. + +### Steps + +1. In `codec_id.rs`, **reserve** (but do not implement) future codec IDs by adding doc comments after `Opus64k = 8`: + ```rust + // Reserved for video codecs; implementations land in PRD-video-multicodec. + // 9 => H264 baseline + // 10 => H264 main + // 11 => H265 main + // 12 => AV1 + // 13 => VP9 + ``` +2. **Do not** add new variants yet — that happens in T4.x once `wzp-video` exists. +3. Add a regression test confirming `from_wire(9..=255)` returns `None`: + ```rust + #[test] fn codec_id_unknown_values_rejected() { + for v in 9u8..=255 { assert!(CodecId::from_wire(v).is_none(), "v={v}"); } + } + ``` + +### Verify +```bash +cargo test -p wzp-proto codec_id_unknown_values_rejected +``` + +### Done when +Test passes. Existing audio tests still pass. + +--- + +## T1.4 — Add v2 `MiniHeader` with `seq_delta` + +- **PRD:** `PRD-wire-format-v2.md` (resolves audit W4) +- **Effort:** 2 h +- **Files:** + - `crates/wzp-proto/src/packet.rs` + +### Context +Existing `MiniHeader` is 4 bytes at line 501. `MiniFrameContext::expand` infers `seq` by `wrapping_add(1)` (line ~553) — a missed full header desyncs. v2 carries explicit `seq_delta`. + +### Steps + +1. Rename existing `MiniHeader` → `MiniHeaderV1` and `MiniFrameContext` → `MiniFrameContextV1`. Keep impls intact. +2. Add new `MiniHeader` (5 bytes): + ```rust + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + pub struct MiniHeader { + pub seq_delta: u8, // packets since baseline; 1 in steady state + pub timestamp_delta_ms: u16, + pub payload_len: u16, + } + + impl MiniHeader { + pub const WIRE_SIZE: usize = 5; + + pub fn write_to(&self, buf: &mut impl BufMut) { + buf.put_u8(self.seq_delta); + buf.put_u16(self.timestamp_delta_ms); + buf.put_u16(self.payload_len); + } + + pub fn read_from(buf: &mut impl Buf) -> Option { + if buf.remaining() < Self::WIRE_SIZE { return None; } + Some(Self { + seq_delta: buf.get_u8(), + timestamp_delta_ms: buf.get_u16(), + payload_len: buf.get_u16(), + }) + } + } + ``` +3. Add `MiniFrameContext` (no `V1` suffix) tracking v2 `MediaHeader`: + ```rust + #[derive(Clone, Debug, Default)] + pub struct MiniFrameContext { + last: Option, + } + impl MiniFrameContext { + pub fn update(&mut self, h: &MediaHeader) { self.last = Some(*h); } + pub fn expand(&mut self, m: &MiniHeader) -> Option { + let base = self.last.as_ref()?; + let mut e = *base; + e.seq = base.seq.wrapping_add(m.seq_delta as u32); + e.timestamp = base.timestamp.wrapping_add(m.timestamp_delta_ms as u32); + self.last = Some(e); + Some(e) + } + } + ``` +4. Add round-trip test mirroring `T1.1`. + +### Verify +```bash +cargo test -p wzp-proto mini +``` + +### Done when +v2 mini header round-trips. v1 type still compiles. + +--- + +## T1.5 — Migrate emit/parse sites to v2 + +- **PRD:** `PRD-wire-format-v2.md` +- **Effort:** 4 h +- **Files (touch all that use `MediaHeader::`):** + - `crates/wzp-proto/src/packet.rs` + - `crates/wzp-client/src/call.rs` + - `crates/wzp-relay/src/room.rs` + - `crates/wzp-relay/src/pipeline.rs` + - `crates/wzp-android/src/engine.rs` + +### Context +Only 6 production files outside `packet.rs` reference `MediaHeader::`. Confirm with: +```bash +grep -rln "MediaHeader::" crates/ | grep -v target +``` + +### Steps + +1. For each file in the list above, replace v1 construction patterns with v2. The audio fields are unchanged in semantics; new fields default as follows: + - `version: 2` + - `flags: 0` (set `FLAG_QUALITY` where the v1 code set `has_quality_report = true`, etc.) + - `media_type: MediaType::Audio` + - `stream_id: 0` + - `fec_ratio: ` (convert range) + - `seq: old_seq as u32` + - `timestamp` unchanged + - `fec_block: u16::from(old_fec_block) | (u16::from(old_fec_symbol) << 8)` for audio (low byte block_id, high byte symbol_idx) +2. Update `MediaHeaderV1`-using parse code identically — convert from u16 seq/u8 block_id to v2 layout at parse boundary. +3. Search for `WIRE_SIZE` arithmetic and update buffer sizes (12 → 16, 4 → 5). +4. Delete `MediaHeaderV1`, `MiniHeaderV1`, `MiniFrameContextV1` once everything builds. + +### Verify +```bash +cargo build --workspace +cargo test --workspace --no-fail-fast +# Expected: all 272 tests still pass +``` + +### Done when +- Workspace builds clean. +- All audio tests pass. +- No reference to `MediaHeaderV1` / `MiniHeaderV1` anywhere. + +--- + +## T1.6 — Protocol version negotiation in handshake + +- **PRD:** `PRD-wire-format-v2.md` + `PRD-protocol-hardening.md` (W12) +- **Effort:** 3 h +- **Files:** + - `crates/wzp-proto/src/packet.rs` (extend `SignalMessage`) + - `crates/wzp-client/src/handshake.rs` + - `crates/wzp-relay/src/handshake.rs` + +### Steps + +1. In `packet.rs`, add to `CallOffer`: + ```rust + #[serde(default = "default_proto_version")] + pub protocol_version: u8, + #[serde(default = "default_supported_versions")] + pub supported_versions: Vec, + ``` + Helpers: + ```rust + fn default_proto_version() -> u8 { 2 } + fn default_supported_versions() -> Vec { vec![2] } + ``` +2. Add a new `Hangup` reason variant. Find `SignalMessage::Hangup` (look for the `Hangup` variant in the enum near the bottom) and add to the reason enum / fields: + ```rust + ProtocolVersionMismatch { server_supported: Vec }, + ``` + If `reason` is a `String`, instead add a structured variant `SignalMessage::ProtocolVersionMismatch { server_supported: Vec }` and use that. +3. In `crates/wzp-relay/src/handshake.rs`, after parsing `CallOffer`, check `protocol_version == 2`. If not, send `ProtocolVersionMismatch` and close. +4. In `crates/wzp-client/src/handshake.rs`, set the field on outgoing `CallOffer`; on receiving the mismatch variant, return a typed error. + +### Verify +```bash +cargo test -p wzp-relay handshake +cargo test -p wzp-client handshake +``` + +### Done when +A v1-style offer (missing `protocol_version` field — serde default makes it 2 in this codebase, so explicitly test with `protocol_version: 1`) is rejected with the typed signal. + +--- + +## T1.7 — Move `QualityReport` trailer inside AEAD payload + +- **PRD:** `PRD-protocol-hardening.md` (W5) +- **Effort:** 2 h +- **Files:** + - `crates/wzp-client/src/call.rs` (encode/decode paths) + - `crates/wzp-crypto/src/session.rs` (verify AEAD boundary) + +### Context +A `QualityReport` (4 bytes) is appended to media packets when the `Q` flag is set. The flag is in the (plaintext, AAD-bound) header; the trailer must sit **inside** the AEAD payload so stripping it corrupts decryption. + +### Steps + +1. Grep for the encode site: + ```bash + grep -rn "has_quality_report\|FLAG_QUALITY\|QualityReport" crates/wzp-client/src/call.rs + ``` +2. Find where `QualityReport::write_to` (or `put_*` calls) writes the 4 bytes. Confirm it writes into the buffer that is **then** passed to `encrypt_in_place` / `seal` — not after. +3. If currently appended *after* AEAD seal: refactor so the order is: + - Write `MediaHeader` (becomes AAD). + - Write payload. + - Write `QualityReport` trailer if Q flag set. + - AEAD-seal the (payload + trailer) bytes with header as AAD. +4. Mirror on decode side. +5. Add a test that tampers with the trailer post-encrypt and asserts decrypt fails. + +### Verify +```bash +cargo test -p wzp-client quality_report_aead +cargo test -p wzp-crypto +``` + +### Done when +- Tamper test passes (decryption fails on trailer tamper). +- Round-trip with quality flag set still works. + +--- + +## T1.8 — Per-stream anti-replay window with configurable size + +- **PRD:** `PRD-protocol-hardening.md` (W11) +- **Effort:** 2 h +- **Files:** + - `crates/wzp-crypto/src/anti_replay.rs` + - `crates/wzp-crypto/src/session.rs` (or wherever the window is owned) + +### Steps + +1. Today the window is fixed 64 packets. Make it constructible with size: + ```rust + impl AntiReplay { pub fn with_window(size: usize) -> Self { ... } } + ``` +2. The session owner (search `AntiReplay::new`) is updated to allocate per `(stream_id, MediaType)`. Use a `HashMap<(u8, MediaType), AntiReplay>` keyed on the v2 header fields. +3. Default sizes: + - `Audio`: 64 + - `Video`: 1024 + - `Data`: 256 + - `Control`: 32 + +### Verify +```bash +cargo test -p wzp-crypto anti_replay +``` + +### Done when +- A new test confirms a 200-packet video burst with one reorder doesn't drop any. +- Existing audio anti-replay tests pass. + +--- + +# Wave 2 — Feedback + abuse mitigation (target: 1 week) + +Goal: BWE drives adaptation. Tier A/B/C conformance running in observe-only mode at the relay. + +--- + +## T2.1 — Add `SignalMessage::TransportFeedback` + +- **PRD:** `PRD-transport-feedback-bwe.md` +- **Effort:** 2 h +- **Files:** + - `crates/wzp-proto/src/packet.rs` + +### Steps + +1. Add to the `SignalMessage` enum: + ```rust + TransportFeedback { + #[serde(default)] version: u8, // = 1 + stream_id: u8, + acked_seqs: Vec, + nacked_seqs: Vec, + remb_bps: u32, + recv_time_us: u64, + }, + ``` +2. Add a unit test serializing/deserializing with `bincode` to ensure forward/backward compat. + +### Verify +```bash +cargo test -p wzp-proto transport_feedback +``` + +### Done when +Variant round-trips. No other code consumes it yet — that's T2.2/T2.3. + +--- + +## T2.2 — `BandwidthEstimator` in `wzp-proto::bandwidth` + +- **PRD:** `PRD-transport-feedback-bwe.md` +- **Effort:** 4 h +- **Files:** + - `crates/wzp-proto/src/bandwidth.rs` (already exists — extend, don't replace) + - `crates/wzp-transport/src/path_monitor.rs` (read existing cwnd/RTT exposure) + +### Context +`bandwidth.rs` already exists (14 KB). Read it first. The `QuinnPathSnapshot` type exposes `loss_pct`, `rtt_ms` today; add `cwnd_bps`, `bytes_in_flight` if missing. + +### Steps + +1. Read `crates/wzp-transport/src/path_monitor.rs` to find how Quinn `PathStats` are exposed. +2. Add to `QuinnPathSnapshot`: + ```rust + pub cwnd_bytes: u64, + pub bytes_in_flight: u64, + ``` + Populate from `quinn::Connection::stats().path`. +3. In `wzp-proto/src/bandwidth.rs`, add: + ```rust + pub struct BandwidthEstimator { + cwnd_bps: AtomicU64, + peer_remb_bps: AtomicU64, + smoothed_bps: AtomicU64, + } + impl BandwidthEstimator { + pub fn new() -> Self { ... default ... } + pub fn update_from_quinn(&self, snap: &QuinnPathSnapshot) { /* compute cwnd_bps = cwnd_bytes * 8 / rtt_s */ } + pub fn update_from_peer(&self, fb_remb_bps: u32) { ... } + pub fn target_send_bps(&self) -> u64 { + let m = self.cwnd_bps.load(Relaxed).min(self.peer_remb_bps.load(Relaxed)); + (m as f64 * 0.9) as u64 + } + } + ``` +4. EWMA smoothing: half-life 2 s. Update `smoothed_bps` from input on each tick. + +### Verify +```bash +cargo test -p wzp-proto bandwidth +cargo test -p wzp-transport +``` + +### Done when +- Unit test: feed scripted cwnd + remb values, assert `target_send_bps` smooths correctly. + +--- + +## T2.3 — Plumb BWE into adaptive controller + +- **PRD:** `PRD-transport-feedback-bwe.md` +- **Effort:** 3 h +- **Files:** + - `crates/wzp-proto/src/quality.rs` (`AdaptiveQualityController`) + - `crates/wzp-client/src/call.rs` (instantiate + feed) + +### Steps + +1. Add a setter to `AdaptiveQualityController`: + ```rust + pub fn set_bandwidth_estimator(&mut self, bwe: Arc) { self.bwe = Some(bwe); } + ``` +2. In the controller's upgrade decision (search for "consecutive_good_reports" or similar threshold logic), add a guard: + ```rust + if let Some(bwe) = &self.bwe { + if bwe.target_send_bps() < self.current_tier_ceiling_bps() * 130 / 100 { return; } + } + ``` +3. In `call.rs`, instantiate one `Arc` per session, feed it from both send loop (`update_from_quinn` from path snapshot) and recv loop (`update_from_peer` from incoming TransportFeedback), pass to the controller. + +### Verify +```bash +cargo test -p wzp-proto quality +``` + +### Done when +Existing quality tests pass with BWE attached. New test: scripted "loss = 0, cwnd = 50 kbps" never upgrades past Opus 24k. + +--- + +## T2.4 — Relay conformance: Tier A (bitrate ceiling) + +- **PRD:** `PRD-relay-conformance.md` +- **Effort:** 3 h +- **Files:** + - `crates/wzp-relay/src/conformance.rs` (new) + - `crates/wzp-relay/src/room.rs` (call site) + +### Steps + +1. Create `crates/wzp-relay/src/conformance.rs`: + ```rust + use std::sync::atomic::{AtomicU64, Ordering::Relaxed}; + use std::time::Instant; + use wzp_proto::{CodecId, MediaHeader, MediaType}; + + pub struct ConformanceMeter { + window_start: parking_lot::Mutex, + bytes_in_window: AtomicU64, + packets_in_window: AtomicU64, + last_seq: AtomicU64, + last_ts: AtomicU64, + } + + #[derive(Debug)] + pub enum Violation { BitrateExceeded, PacketRateExceeded, TimestampDrift } + + impl ConformanceMeter { + pub fn new() -> Self { ... } + pub fn observe(&self, h: &MediaHeader, payload_len: usize, now: Instant) -> Result<(), Violation> { + // Tier A + let window_bytes = self.bytes_in_window.fetch_add((MediaHeader::WIRE_SIZE + payload_len) as u64, Relaxed); + // ... compare against ceiling_bps_for(h.codec_id, h.media_type) + } + } + + pub fn ceiling_bps(codec: CodecId) -> u64 { + let nominal = codec.bitrate_bps() as u64; + (nominal * 3 * 115 / 100).max(2_000) // FEC 2.0 + 15% overhead, floor 2 kbps + } + ``` +2. In `room.rs`, attach one `ConformanceMeter` per participant. Call `observe` on each incoming media packet. +3. **Observe-only mode for now.** Log violations to `tracing::warn!` and bump a Prometheus counter. Do not close session. + +### Verify +```bash +cargo test -p wzp-relay conformance +``` + +### Done when +Unit test: synthetic 1 MB/s declared as Opus 24k logs `Violation::BitrateExceeded`. + +--- + +## T2.5 — Tier B (packet-rate) + Tier C (timestamp drift) + +- **PRD:** `PRD-relay-conformance.md` +- **Effort:** 2 h +- **Files:** + - `crates/wzp-relay/src/conformance.rs` + +### Steps + +1. Add packet-rate enforcement: `packets_in_window > max_pps(codec) * 1.5` over a 1 s window → `PacketRateExceeded`. +2. `max_pps(codec) = 1000 / codec.frame_duration_ms() * 3` (×3 for FEC). +3. Timestamp drift: track `Δtimestamp / Δseq` over rolling 200-packet window. If outside `frame_duration_ms × [0.5, 2.0]`, log `TimestampDrift`. + +### Verify +```bash +cargo test -p wzp-relay conformance +``` + +### Done when +Both new tests pass alongside Tier A test. + +--- + +## T2.6 — Prometheus metrics for conformance + +- **PRD:** `PRD-relay-conformance.md` +- **Effort:** 2 h +- **Files:** + - `crates/wzp-relay/src/metrics.rs` + +### Steps + +1. Add counters / histograms: + ```rust + wzp_relay_conformance_violations_total{tier, codec_id, media_type, verdict} + wzp_relay_conformance_bytes_per_session{media_type} histogram + wzp_relay_conformance_iat_ms{media_type} histogram + ``` +2. Wire `ConformanceMeter` to bump these on `observe`. + +### Verify +```bash +curl localhost:9090/metrics | grep wzp_relay_conformance +``` +(after `cargo run -p wzp-relay -- --listen 127.0.0.1:4433 --no-auth` with a synthetic client) + +### Done when +Counters increment under abusive traffic; quiet on legitimate audio. + +--- + +# Wave 3 — Protocol hardening (target: 3-4 days) + +--- + +## T3.1 — Confirm `RoomManager` concurrency (W13) + +- **PRD:** `PRD-protocol-hardening.md` +- **Effort:** 2 h +- **Files:** + - `crates/wzp-relay/src/room.rs` + +### Context +`RoomManager` already uses `DashMap` (verified at line 352). The audit (W13) was based on the older ARCHITECTURE doc which mentioned a single Mutex. The actual remaining contention point is whatever's *inside* `Room` — confirm. + +### Steps + +1. Read the `Room` struct definition. +2. If `Room` itself uses fine-grained locks or is `Arc>` already, document this in `PROTOCOL-AUDIT.md` and mark W13 resolved. +3. If `Room` has a single per-room `Mutex` held during fan-out, identify the hot path and either: + - Split fan-out list into `RwLock>` (read-mostly). + - Use `ArcSwap>` for lock-free reads. +4. Run the 40+4 relay integration tests. + +### Verify +```bash +cargo test -p wzp-relay +cargo test -p wzp-relay --test federation +cargo test -p wzp-relay --test handshake_integration +``` + +### Done when +Tests pass + a one-line update in `PROTOCOL-AUDIT.md` noting actual state. + +--- + +## T3.2 — Document `timestamp_ms` rebase across rekey (W3) + +- **PRD:** `PRD-protocol-hardening.md` +- **Effort:** 1 h +- **Files:** + - `crates/wzp-proto/src/packet.rs` (doc comment on `MediaHeader::timestamp`) + - `crates/wzp-crypto/src/rekey.rs` (add comment) + - `docs/WZP-SPEC.md` + - Add test in `crates/wzp-client/tests/long_session.rs` + +### Steps + +1. Decision (already made): `timestamp_ms` is monotonic across rekeys. Document inline: + ```rust + /// Milliseconds since session start. Monotonic for the full session lifetime; + /// NOT reset by rekey (rekey changes only key material, not framing state). + pub timestamp: u32, + ``` +2. In `rekey.rs`, add a comment near the rekey handler confirming sequence + timestamp are untouched. +3. Add a test that performs 2 rekeys mid-session and asserts `timestamp` continues monotonically. + +### Verify +```bash +cargo test -p wzp-client --test long_session rekey_timestamp_monotonic +``` + +### Done when +Test passes. + +--- + +## T3.3 — `SignalMessage` version field (W12) + +- **PRD:** `PRD-protocol-hardening.md` +- **Effort:** 2 h +- **Files:** + - `crates/wzp-proto/src/packet.rs` + +### Steps + +1. For each variant of `SignalMessage`, add `#[serde(default)] version: u8` as the first field, with helper `fn default_signal_version() -> u8 { 1 }`. +2. Add fallback variant for unknown future signals: + ```rust + #[serde(other)] + Unknown, + ``` + (Note: bincode + serde `other` may need a wrapper — research before implementing. If not feasible, document the limitation and skip the `Unknown` arm.) +3. Decode path: on `Unknown`, log `tracing::warn!("unknown signal variant")` and **do not** close session. + +### Verify +```bash +cargo test -p wzp-proto signal_message +``` + +### Done when +Existing signal tests pass. Old payloads (without `version` field) still deserialize. + +--- + +## T3.4 — Tier D (per-codec packet size sanity) + +- **PRD:** `PRD-relay-conformance.md` +- **Effort:** 2 h +- **Files:** + - `crates/wzp-relay/src/conformance.rs` + +### Steps + +1. Add per-codec typical / max payload table: + ```rust + pub fn payload_size_bound(codec: CodecId) -> usize { + match codec { + CodecId::Opus64k => 320, CodecId::Opus48k => 240, + CodecId::Opus32k => 200, CodecId::Opus24k => 160, + CodecId::Opus16k => 100, CodecId::Opus6k => 90, + CodecId::Codec2_3200 => 30, CodecId::Codec2_1200 => 30, + CodecId::ComfortNoise => 16, + } + } + ``` +2. Maintain EWMA of payload size per meter. Reject if EWMA exceeds 2× typical for declared codec. + +### Verify +```bash +cargo test -p wzp-relay conformance_tier_d +``` + +### Done when +Synthetic stream of 1400-byte payloads declared as Codec2_1200 flagged within 5 s. + +--- + +## T3.5 — Tier E (per-fingerprint token bucket) + +- **PRD:** `PRD-relay-conformance.md` +- **Effort:** 4 h +- **Files:** + - `crates/wzp-relay/src/conformance.rs` (or sibling `quota.rs`) + - `crates/wzp-relay/src/auth.rs` (for authed/anon split) + +### Steps + +1. Implement a simple token bucket per `(fingerprint, src_ip)`: + ```rust + pub struct TokenBucket { + capacity: u64, + tokens: AtomicU64, + refill_per_sec: u64, + last_refill: AtomicU64, + } + ``` +2. Wire into per-participant forward loop. Refill on each `observe`. +3. Authed/anon split: authenticated quota = 50 GB/month; anon = 1 GB/month. Per-session cap = 256 kbps audio (5 Mbps video reserved for later). +4. **Observe-only:** log + counter; do not throttle yet. + +### Verify +```bash +cargo test -p wzp-relay token_bucket +``` + +### Done when +Unit test: 100 KB at 256 kbps cap consumes no tokens; 1 MB exceeds. + +--- + +# Wave 4 — Video v1 (3 weeks) + +Detailed task breakdown deferred until Wave 1-3 land. Skeleton: + +| Task | Summary | Effort | +|---|---|---| +| T4.1 | `wzp-video` crate scaffold + H.264 NAL framer + depacketizer (no encoder yet) | 3 d | +| T4.2 | VideoToolbox H.264 encoder + decoder (macOS) — minimum viable | 3 d | +| T4.3 | MediaCodec H.264 encoder + decoder via JNI (Android) | 5 d | +| T4.4 | `SignalMessage::Nack` variant + RTT-gated NACK loop | 2 d | +| T4.5 | I-frame FEC ratio boost (encoder hint → FEC layer) | 1 d | +| T4.6 | SFU keyframe cache per `(room, sender, stream_id)` | 2 d | +| T4.7 | PLI suppression at SFU | 1 d | + +Each of these will be expanded into the same step-by-step format as T1.x once Wave 3 is in progress. See `PRD-video-v1.md` for design. + +--- + +# Wave 5 — Quality, codecs, simulcast (3 weeks) + +Detailed task breakdown deferred. Skeleton: + +| Task | Summary | +|---|---| +| T5.1 | `PriorityMode` enum + `SignalMessage::SetPriorityMode` | +| T5.2 | `VideoQualityController` with per-mode allocation gates | +| T5.3 | `EncoderMode::SlideFallback` for ScreenShare | +| T5.4 | H.265 encoder/decoder (reuse framer from T4.1) | +| T5.5 | 3-layer simulcast at sender | +| T5.6 | Per-receiver layer selection at SFU | +| T5.7 | Tier F audio scorer (entropy/IAT/silence-fraction) | +| T5.8 | Tier G response policy (typed Hangup + audit log) | + +--- + +# Wave 6 — AV1 + Tier F video (2-3 weeks) + +| Task | Summary | +|---|---| +| T6.1 | AV1 encoder/decoder with HW probe + SVT-AV1 SW fallback | +| T6.2 | Tier F video scorer (keyframe periodicity, I/P ratio, BWE responsiveness) | +| T6.3 | Federated reputation gossip (optional) | + +--- + +# Working agreements + +- **One commit per task.** Message: `T: `. +- **Update PRD on deviation.** If you implement something differently than the PRD specifies, edit the PRD in the same commit explaining why. +- **Don't merge waves out of order** — dependencies are real. +- **Ask before destroying.** Any task that would delete data, drop tables, or force-push: stop and report. +- **Auto-mode caveat.** Even in auto mode, if a task description doesn't fit what you find in the code, stop and surface the mismatch before guessing. + +--- + +# Status board + +Edit this table directly when you claim, complete, or get blocked on a task. Keep it sorted by task ID. The reviewer (human) is the only one who flips `Pending Review` → `Approved` or `Changes Requested`. + +Statuses (in order of progression): +- `Open` — not yet picked up +- `In Progress` — an agent is working on it +- `Blocked` — agent has hit something it can't resolve; see report +- `Pending Review` — agent has finished, report filed, awaiting human +- `Changes Requested` — reviewer pushed back; back to agent +- `Approved` — reviewer signed off; task is closed +- `Skipped` — explicitly deferred or made redundant by another task + +| Task | Status | Agent | Started (UTC) | Completed (UTC) | Report | Reviewer notes | +|---|---|---|---|---|---|---| +| T1.1 | Pending Review | Kimi Code CLI | 2026-05-11T06:09Z | 2026-05-11T06:54Z | reports/T1.1-report.md | — | +| T1.2 | Open | — | — | — | — | — | +| T1.3 | Open | — | — | — | — | — | +| T1.4 | Open | — | — | — | — | — | +| T1.5 | Open | — | — | — | — | — | +| T1.6 | Open | — | — | — | — | — | +| T1.7 | Open | — | — | — | — | — | +| T1.8 | Open | — | — | — | — | — | +| T2.1 | Open | — | — | — | — | — | +| T2.2 | Open | — | — | — | — | — | +| T2.3 | Open | — | — | — | — | — | +| T2.4 | Open | — | — | — | — | — | +| T2.5 | Open | — | — | — | — | — | +| T2.6 | Open | — | — | — | — | — | +| T3.1 | Open | — | — | — | — | — | +| T3.2 | Open | — | — | — | — | — | +| T3.3 | Open | — | — | — | — | — | +| T3.4 | Open | — | — | — | — | — | +| T3.5 | Open | — | — | — | — | — | +| T4.1 | Open | — | — | — | — | Skeleton — expand before claiming | +| T4.2 | Open | — | — | — | — | Skeleton — expand before claiming | +| T4.3 | Open | — | — | — | — | Skeleton — expand before claiming | +| T4.4 | Open | — | — | — | — | Skeleton — expand before claiming | +| T4.5 | Open | — | — | — | — | Skeleton — expand before claiming | +| T4.6 | Open | — | — | — | — | Skeleton — expand before claiming | +| T4.7 | Open | — | — | — | — | Skeleton — expand before claiming | +| T5.1 | Open | — | — | — | — | Skeleton — expand before claiming | +| T5.2 | Open | — | — | — | — | Skeleton — expand before claiming | +| T5.3 | Open | — | — | — | — | Skeleton — expand before claiming | +| T5.4 | Open | — | — | — | — | Skeleton — expand before claiming | +| T5.5 | Open | — | — | — | — | Skeleton — expand before claiming | +| T5.6 | Open | — | — | — | — | Skeleton — expand before claiming | +| T5.7 | Open | — | — | — | — | Skeleton — expand before claiming | +| T5.8 | Open | — | — | — | — | Skeleton — expand before claiming | +| T6.1 | Open | — | — | — | — | Skeleton — expand before claiming | +| T6.2 | Open | — | — | — | — | Skeleton — expand before claiming | +| T6.3 | Open | — | — | — | — | Skeleton — expand before claiming | + +## Review queue (human) + +Items currently waiting on the reviewer: + +- T1.1 — Add v2 MediaHeader type — report: reports/T1.1-report.md + +Once a task moves to `Pending Review`, add a line here so the reviewer sees it: `- T — report: reports/T-report.md`. The reviewer removes the line when they mark it `Approved` (or moves it back to the agent on `Changes Requested`). diff --git a/docs/PRD/reports/T1.1-report.md b/docs/PRD/reports/T1.1-report.md new file mode 100644 index 0000000..c7cf9d0 --- /dev/null +++ b/docs/PRD/reports/T1.1-report.md @@ -0,0 +1,88 @@ +# T1.1 — Add v2 `MediaHeader` type + +**Status:** Pending Review +**Agent:** Kimi Code CLI +**Started:** 2026-05-11T06:09Z +**Completed:** 2026-05-11T06:54Z +**Commit:** see git log +**PRD:** ../PRD-wire-format-v2.md + +## What I changed + +- `crates/wzp-proto/src/packet.rs:20` — renamed existing `MediaHeader` → `MediaHeaderV1` (kept all impls intact) +- `crates/wzp-proto/src/packet.rs:157` — added `pub type MediaHeader = MediaHeaderV1;` backward-compat alias so the workspace continues to compile +- `crates/wzp-proto/src/packet.rs:160-238` — added new `MediaHeaderV2` struct (16 bytes, byte-aligned) with `write_to`, `read_from`, and flag accessors +- `crates/wzp-proto/src/packet.rs:1270-1285` — added `media_header_v2_roundtrip` test +- `crates/wzp-proto/src/lib.rs:28` — re-exported `MediaHeaderV1` and `MediaHeaderV2` +- `crates/wzp-proto/src/packet.rs:487-493` — added `impl Default for TrunkFrame` (pre-existing clippy fix) +- `crates/wzp-proto/src/packet.rs:540` — removed redundant slicing `&buf[..]` → `buf` (pre-existing clippy fix) +- `crates/wzp-proto/src/quality.rs:102-109` — derived `Default` for `NetworkContext` with `#[default]` on `Unknown` (pre-existing clippy fix) + +## Why these choices + +Rust does not allow a type alias and a struct with the same name in the same module. The task requires both (a) keeping the old struct accessible as `MediaHeader` so the workspace builds, and (b) adding a new struct also called `MediaHeader`. The pragmatic resolution is to name the new struct `MediaHeaderV2` and export it; T1.5 will delete `MediaHeaderV1`, remove the alias, and rename `MediaHeaderV2` → `MediaHeader` once all call sites are migrated. + +`CodecId::to_wire` already returns `u8` and was usable immediately. `MediaType` does not exist yet (T1.2), so the `media_type` field is `u8` with a `// TODO(T1.2)` comment. + +## Deviations from the task spec + +1. **Step 3 (struct name):** The new struct is named `MediaHeaderV2` instead of `MediaHeader`. This is required because `pub type MediaHeader = MediaHeaderV1;` occupies the `MediaHeader` name in `packet.rs`. T1.5 will perform the final rename. +2. **Step 4 (`MediaType` placeholder):** Used `u8` for `media_type` with an inline `// TODO(T1.2)` comment, matching the fallback instruction in the task. +3. **Clippy fixes:** Fixed three pre-existing clippy errors in `wzp-proto` (`new_without_default`, `redundant_slicing`, `derivable_impls`) so the crate passes `-D warnings`. + +## Verification output + +```bash +$ cargo test -p wzp-proto media_header_v2_roundtrip +running 1 test +test packet::tests::media_header_v2_roundtrip ... ok + +test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 105 filtered out; finished in 0.00s +``` + +```bash +$ cargo build -p wzp-proto -p wzp-codec -p wzp-fec -p wzp-crypto -p wzp-transport -p wzp-relay -p wzp-client -p wzp-web -p wzp-native + Compiling wzp-proto v0.1.0 + ... + Finished `dev` profile [unoptimized + debuginfo] target(s) in 27.24s +``` + +```bash +$ cargo test -p wzp-proto -p wzp-codec -p wzp-fec -p wzp-crypto -p wzp-transport -p wzp-relay -p wzp-client -p wzp-web -p wzp-native --no-fail-fast +... +test result: ok. 565 passed; 0 failed; ... +``` + +```bash +$ cargo clippy -p wzp-proto --all-targets -- -D warnings + Finished `dev` profile [unoptimized + debuginfo] target(s) in 2.38s +``` + +```bash +$ cargo fmt --all -- --check +# (clean) +``` + +## Test summary + +- Tests added: 1 (`media_header_v2_roundtrip`) +- Tests modified: 0 +- Workspace test count before: 564 pass / 0 fail (non-Android subset) +- Workspace test count after: 565 pass / 0 fail (non-Android subset) +- `cargo clippy --workspace --all-targets -- -D warnings`: pass for `wzp-proto`; 3 pre-existing failures remain in `deps/featherchat/warzone/crates/warzone-protocol` (git submodule, outside our control) +- `cargo fmt --all -- --check`: pass + +## Risks / follow-ups + +- Pre-existing clippy errors in the `featherchat` git submodule (`warzone-protocol`) remain unresolved because they are in a dependency subtree. +- `wzp-android` cannot be built or tested on macOS without the Android NDK. All verification uses the non-Android workspace subset. +- `MediaHeaderV2` must be renamed to `MediaHeader` in T1.5 after `MediaHeaderV1` is deleted and all call sites are migrated. +- `media_type: u8` should become `media_type: MediaType` once T1.2 lands. + +## Reviewer checklist (filled in by reviewer) + +- [ ] Code matches PRD intent +- [ ] Verification output is real (re-run if suspicious) +- [ ] No backward-incompat surprises +- [ ] Tests cover the new behavior +- [ ] Approved