docs/adr/ADR-119-bfld-frame-format-and-wire-protocol.md
| Field | Value |
|---|---|
| Status | Proposed |
| Date | 2026-05-24 |
| Deciders | ruv |
| Parent | ADR-118 |
| Relates to | ADR-028 (witness/deterministic proof), ADR-095 (rvCSI CsiFrame schema) |
| Tracking issue | TBD |
The BFLD pipeline (ADR-118) emits an over-the-wire BfldFrame consumed by the RuView aggregator, HA bridge, and witness bundle. The frame must be:
The existing rvCSI CsiFrame (ADR-095) is the closest precedent. BFLD reuses the same little-endian convention and the same "validate-before-FFI" posture.
BfldFrame header (40 bytes, little-endian, packed)#[repr(C, packed)]
pub struct BfldFrameHeader {
pub magic: u32, // 0xBF1D_0001
pub version: u16, // 1
pub flags: u16, // bit0=has_csi_delta, bit1=privacy_mode, bit2-15 reserved
pub timestamp_ns: u64, // monotonic capture clock
pub ap_hash: [u8; 16], // BLAKE3-keyed(site_salt, ap_mac)[0..16]
pub sta_hash: [u8; 16], // BLAKE3-keyed(site_salt ‖ day_epoch, sta_mac)[0..16]
pub session_id: [u8; 16], // ephemeral, rotated on capture-session boundary
pub channel: u16, // 802.11 channel number
pub bandwidth_mhz: u16, // 20 | 40 | 80 | 160
pub rssi_dbm: i16,
pub noise_floor_dbm: i16,
pub n_subcarriers: u16,
pub n_tx: u8,
pub n_rx: u8,
pub quantization: u8, // 0=f32, 1=i16, 2=i8, 3=packed (4-bit nibbles)
pub privacy_class: u8, // 0=raw, 1=derived, 2=anonymous, 3=restricted (default 2)
pub payload_len: u32,
pub payload_crc32: u32, // CRC-32/ISO-HDLC over payload bytes only
}
Total header size: 40 bytes (validated by static_assertions::const_assert_eq!).
Payload is a length-prefixed sequence of typed sections in this exact order:
payload = compressed_angle_matrix
‖ amplitude_proxy
‖ phase_proxy
‖ snr_vector
‖ optional_csi_delta (present iff flags.bit0 set)
‖ optional_vendor_extension (length 0 allowed)
Each section is [u32 len_le][bytes...]. The CRC32 covers all section bytes including length prefixes, but not the header.
The serializer enforces these rules before writing any payload bytes:
privacy_class | compressed_angle_matrix | Identity-derived fields | Notes |
|---|---|---|---|
0 (raw) | full | full | Local-only, never serialized to a network sink |
1 (derived) | downsampled to 8-bit, top-k subcarriers | full | Operator-acknowledged research mode |
2 (anonymous, default) | absent (zero-length section) | absent | Production default |
3 (restricted) | absent | absent + diagnostic-only | Equivalent to class 2 + suppresses identity_risk_score on the bus |
The serializer returns Err(BfldError::PrivacyViolation) if the caller attempts to publish a class-0 frame through a network sink. This is enforced by a sink-type marker trait (LocalSink vs NetworkSink).
Three guarantees:
#[repr(C, packed)].quantization byte values 1/2/3 use specified round-half-to-even with documented saturation; f32 (value 0) is forbidden over the wire (local-only).The witness test in tests/determinism.rs captures a 200-frame BFI fixture, serializes it 1,000 times across two threads, and verifies the BLAKE3 of the resulting byte stream is bit-identical.
0xBF1D_0001 is chosen so that bf1d reads as "BFLD" in hex-dump output, easing wireshark / xxd debugging. The final 0001 is the major version; minor revisions bump version field.
#[no_std] compatible — same code can run on ESP32-S3 (when ESP-NOW transport is added under ADR-123 P2).archive/v1/data/proof/verify.py pattern extends to a bfld_verify.py that consumes the same SHA-256 expected-hash file format.#[repr(C, packed)] on the header means consumers must use read_unaligned — small ergonomic cost, mitigated by a #[derive(BfldFrameAccess)] proc-macro.cog-pose-estimation) to attach metadata without a header change, at the cost of CRC scope creep. Vendor sections are explicitly outside the witness hash.Rejected: schema evolution overhead, witness-hash instability across protoc versions, ~3× wire bloat for the small fixed-shape fields.
Rejected: deterministic CBOR (RFC 8949 §4.2) is achievable but the parser surface is large and tag handling is a footgun for the no_std ESP32 path.
Rejected: receivers must distinguish BFLD frames from rvCSI CsiFrame and other RuView payloads on shared transports.
Rejected: CRC must be computed after the payload, so its value would otherwise force a header rewrite; placing it last avoids a buffer-pass-back.
BfldFrameHeader size is exactly 40 bytes on x86_64, aarch64, and xtensa-esp32s3.BfiCapture fixture produce a bit-identical BLAKE3 hash.privacy_class = 0 frame returned through NetworkSink::publish() returns Err(BfldError::PrivacyViolation).BfldFrame::parse() to return Err(BfldError::Crc) without exposing partial payload state.flags.bit0 = 0 (no CSI delta) and an unexpected CSI-delta section is rejected.CsiFrame (vendor/rvcsi/crates/rvcsi-core/src/frame.rs)crc = "3" crateblake3 = "1.5"