From 4b97c61d10172e1ad7e600f6358eab44ecfabc03 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 00:04:21 +0000 Subject: [PATCH 1/2] feat(bt-presence): Phase-2 consumer of burble's frozen BLE presence beacon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit burble froze its BLE nearby-presence wire format v1 on 2026-07-09 (PR #154, ADR-0015), explicitly to unblock neurophone. This lands the neurophone-side Phase-2 consumer the BT-PRESENCE plan called for: a pure-Rust, host-testable decode + contact-resolution + presence-decay core. - crates/bt-presence/vendor/: byte-exact vendored copies of burble's FROZEN nearby-presence.a2ml + ble-spa-v1.json (provenance pinned to burble 2b5914b + sha256). Vendored, not fetched, so the build is reproducible and offline. - build.rs: code-generates the wire constants from the vendored spec (single source of truth, no hand-copied protocol numbers) and FAILS THE BUILD LOUDLY if the vendored spec is not a frozen wire-version 1 (burble freeze covenant). - src/decode.rs: 24-byte frame decode, epoch = unix/900, and contact resolution by reproducing HMAC-SHA256(secret, "BRBL-PRES-v1" ‖ magic ‖ ver_type ‖ epoch) [0..18] with a constant-time compare and a ±1 epoch freshness window (mirrors burble's Burble.Presence.BleSpa.resolve_presence/3). - src/decay.rs: RSSI-reinforced exponential-decay presence score (0.0..=1.0). - tests/vectors.rs: replays burble's FROZEN conformance vectors and asserts the beacon id is byte-exact with burble's independent oracle (presence_beacon_id_is_byte_exact) — cross-implementation wire proof. - sensors: new SensorKind::Presence (arity 1) so the decoded score flows through the existing SensorPipeline into the LSM, sensor-class like light/proximity. Read-only on the wire: no microphone, no outbound advertising. Device-side scan glue (gossamer/JNI per #83) and burble's Phase-1 emitter are still pending, but the consumer is built, pinned to frozen v1, and proven wire-compatible now. Local: cargo test -p bt-presence (11 unit + 3 conformance) green; clippy --all-targets -D warnings clean; fmt clean; full workspace builds. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0172RBMz3qYjb1ttzD2i7RNh --- Cargo.lock | 154 ++++++++++ Cargo.toml | 1 + crates/bt-presence/Cargo.toml | 23 ++ crates/bt-presence/build.rs | 111 ++++++++ crates/bt-presence/src/decay.rs | 145 ++++++++++ crates/bt-presence/src/decode.rs | 267 ++++++++++++++++++ crates/bt-presence/src/lib.rs | 78 +++++ crates/bt-presence/tests/vectors.rs | 84 ++++++ crates/bt-presence/vendor/PROVENANCE.adoc | 57 ++++ crates/bt-presence/vendor/ble-spa-v1.json | 199 +++++++++++++ .../bt-presence/vendor/nearby-presence.a2ml | 69 +++++ crates/sensors/src/lib.rs | 7 +- 12 files changed, 1194 insertions(+), 1 deletion(-) create mode 100644 crates/bt-presence/Cargo.toml create mode 100644 crates/bt-presence/build.rs create mode 100644 crates/bt-presence/src/decay.rs create mode 100644 crates/bt-presence/src/decode.rs create mode 100644 crates/bt-presence/src/lib.rs create mode 100644 crates/bt-presence/tests/vectors.rs create mode 100644 crates/bt-presence/vendor/PROVENANCE.adoc create mode 100644 crates/bt-presence/vendor/ble-spa-v1.json create mode 100644 crates/bt-presence/vendor/nearby-presence.a2ml diff --git a/Cargo.lock b/Cargo.lock index e26f422..0dbb0a8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -134,6 +134,15 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bridge" version = "1.0.0" @@ -151,6 +160,20 @@ dependencies = [ "tracing", ] +[[package]] +name = "bt-presence" +version = "1.0.0" +dependencies = [ + "hmac", + "sensors", + "serde", + "serde_json", + "sha2", + "subtle", + "thiserror 2.0.18", + "toml", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -319,6 +342,15 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "criterion" version = "0.8.2" @@ -385,6 +417,27 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -593,6 +646,16 @@ dependencies = [ "uuid", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -671,6 +734,15 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "http" version = "1.4.0" @@ -1927,6 +1999,26 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -2181,6 +2273,47 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "tower" version = "0.5.3" @@ -2293,6 +2426,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "unarray" version = "0.1.4" @@ -2353,6 +2492,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "wait-timeout" version = "0.2.1" @@ -2835,6 +2980,15 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/Cargo.toml b/Cargo.toml index a7548c8..45fec70 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "crates/esn", "crates/bridge", "crates/sensors", + "crates/bt-presence", "crates/llm", "crates/claude-client", "crates/neurophone-core", diff --git a/crates/bt-presence/Cargo.toml b/crates/bt-presence/Cargo.toml new file mode 100644 index 0000000..6bf1ce7 --- /dev/null +++ b/crates/bt-presence/Cargo.toml @@ -0,0 +1,23 @@ +# SPDX-License-Identifier: MPL-2.0 +[package] +name = "bt-presence" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +description = "Read-only consumer of burble's frozen BLE nearby-presence beacon (wire v1, ADR-0015) — decode + contact resolution + presence decay, feeding the neurophone sensor pipeline." + +[dependencies] +sensors = { path = "../sensors" } +hmac = "0.12" +sha2 = "0.10" +subtle = "2.6" +serde = { workspace = true } +thiserror = { workspace = true } + +[build-dependencies] +toml = { workspace = true } + +[dev-dependencies] +serde_json = { workspace = true } diff --git a/crates/bt-presence/build.rs b/crates/bt-presence/build.rs new file mode 100644 index 0000000..1397d91 --- /dev/null +++ b/crates/bt-presence/build.rs @@ -0,0 +1,111 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +//! Codegen: derive the frozen BLE presence wire constants from the vendored +//! `vendor/nearby-presence.a2ml` (burble ADR-0015, wire v1) so the decoder +//! cannot fork the protocol by hand-copying numbers. If the vendored spec is +//! ever re-vendored to a different `wire-version`, the build FAILS LOUDLY here +//! rather than silently miscompiling a v1 decoder against a v2 spec. + +use std::path::Path; + +fn main() { + let manifest = "vendor/nearby-presence.a2ml"; + println!("cargo:rerun-if-changed={manifest}"); + println!("cargo:rerun-if-changed=build.rs"); + + let text = + std::fs::read_to_string(manifest).unwrap_or_else(|e| panic!("cannot read {manifest}: {e}")); + let spec: toml::Value = text + .parse() + .unwrap_or_else(|e| panic!("{manifest} is not parseable as TOML/a2ml: {e}")); + + // ── Fail-loud version guard ────────────────────────────────────────────── + let wire_version = get_int(&spec, "metadata", "wire-version"); + assert_eq!( + wire_version, 1, + "vendored {manifest} is wire-version {wire_version}, but this crate \ + implements the v1 presence decoder only. Re-vendoring to a new wire \ + version is a deliberate protocol change: update src/decode.rs and the \ + conformance vectors, then bump this guard. (burble freeze covenant.)" + ); + let status = get_str(&spec, "metadata", "status"); + assert_eq!( + status, "frozen", + "vendored {manifest} status is {status:?}, expected \"frozen\" — only a \ + frozen wire spec may be pinned against." + ); + + // ── Frozen byte layout, taken from the spec (not hand-copied) ──────────── + let magic = get_int(&spec, "presence-frame", "magic"); + let ver_type = get_int(&spec, "presence-frame", "ver_type"); + let magic_offset = get_int(&spec, "presence-frame", "magic-offset"); + let ver_type_offset = get_int(&spec, "presence-frame", "ver_type-offset"); + let epoch_offset = get_int(&spec, "presence-frame", "epoch-offset"); + let epoch_bytes = get_int(&spec, "presence-frame", "epoch-bytes"); + let beacon_id_offset = get_int(&spec, "presence-frame", "beacon_id-offset"); + let beacon_id_bytes = get_int(&spec, "presence-frame", "beacon_id-bytes"); + let epoch_seconds = get_int(&spec, "rotation", "period-seconds"); + let accept_epoch_window = get_int(&spec, "resolution", "accept-epoch-window"); + + // Frame length is the envelope (magic + ver_type + epoch) plus the beacon id. + let frame_bytes = beacon_id_offset + beacon_id_bytes; + + // Sanity: epoch must be a u32 (the decoder reads it as u32 BE). + assert_eq!( + epoch_bytes, 4, + "presence-frame epoch-bytes must be 4 (u32 BE)" + ); + + let generated = format!( + "// @generated by build.rs from vendor/nearby-presence.a2ml — do not edit.\n\ + // Frozen BLE presence wire constants (burble ADR-0015, wire v{wire_version}).\n\ + /// BLE Manufacturer-Specific-Data company id the beacon advertises under.\n\ + pub const COMPANY_ID: u16 = 0xFFFF;\n\ + /// Frame byte 0 — protocol magic.\n\ + pub const MAGIC: u8 = {magic:#04x};\n\ + /// Frame byte 1 — (wire_version << 4) | frame_type; presence = 0x12.\n\ + pub const VER_TYPE: u8 = {ver_type:#04x};\n\ + pub const MAGIC_OFFSET: usize = {magic_offset};\n\ + pub const VER_TYPE_OFFSET: usize = {ver_type_offset};\n\ + pub const EPOCH_OFFSET: usize = {epoch_offset};\n\ + pub const EPOCH_BYTES: usize = {epoch_bytes};\n\ + pub const BEACON_ID_OFFSET: usize = {beacon_id_offset};\n\ + /// HMAC-SHA256 truncation length for the contact-resolvable beacon id.\n\ + pub const BEACON_ID_BYTES: usize = {beacon_id_bytes};\n\ + /// Total presence-frame length in bytes.\n\ + pub const FRAME_BYTES: usize = {frame_bytes};\n\ + /// Beacon rotation period; epoch = unix_seconds / EPOCH_SECONDS.\n\ + pub const EPOCH_SECONDS: u64 = {epoch_seconds};\n\ + /// Accepted |carried_epoch - epoch(now)| when resolving.\n\ + pub const ACCEPT_EPOCH_WINDOW: u64 = {accept_epoch_window};\n\ + /// HMAC-SHA256 domain-separation label for the presence beacon id.\n\ + pub const LABEL_PRES: &[u8] = b\"BRBL-PRES-v1\";\n\ + /// Vendored spec wire version this decoder was generated against.\n\ + pub const WIRE_VERSION: u8 = {wire_version};\n" + ); + + let out_dir = std::env::var("OUT_DIR").expect("OUT_DIR not set"); + let dest = Path::new(&out_dir).join("wire.rs"); + std::fs::write(&dest, generated).expect("cannot write generated wire.rs"); +} + +fn section<'a>(spec: &'a toml::Value, sect: &str) -> &'a toml::value::Table { + spec.get(sect) + .and_then(|v| v.as_table()) + .unwrap_or_else(|| panic!("nearby-presence.a2ml: missing [{sect}] section")) +} + +fn get_int(spec: &toml::Value, sect: &str, key: &str) -> i64 { + section(spec, sect) + .get(key) + .and_then(|v| v.as_integer()) + .unwrap_or_else(|| panic!("nearby-presence.a2ml: [{sect}].{key} missing or not an integer")) +} + +fn get_str(spec: &toml::Value, sect: &str, key: &str) -> String { + section(spec, sect) + .get(key) + .and_then(|v| v.as_str()) + .unwrap_or_else(|| panic!("nearby-presence.a2ml: [{sect}].{key} missing or not a string")) + .to_string() +} diff --git a/crates/bt-presence/src/decay.rs b/crates/bt-presence/src/decay.rs new file mode 100644 index 0000000..79509b4 --- /dev/null +++ b/crates/bt-presence/src/decay.rs @@ -0,0 +1,145 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +//! Presence score with exponential decay. +//! +//! A resolved contact's beacon arrives intermittently (it rotates every epoch, +//! and BLE scans are duty-cycled). The LSM wants a smooth single-channel signal, +//! not a sparse train of sightings, so we hold a `0.0..=1.0` presence score that +//! is *reinforced* by each sighting (scaled by signal strength) and *decays* +//! exponentially between them. A contact that walks away fades toward 0 with a +//! configurable half-life instead of vanishing abruptly. + +use sensors::SensorError; + +/// RSSI (dBm) mapped to a `0.0..=1.0` instantaneous proximity target. Typical +/// BLE RSSI spans roughly -100 dBm (far / weak) to -30 dBm (very close). +const RSSI_FLOOR_DBM: f32 = -100.0; +const RSSI_CEIL_DBM: f32 = -30.0; + +/// Exponentially-decaying presence score for a single contact channel. +#[derive(Debug, Clone)] +pub struct PresenceDecay { + /// Time for the score to halve with no sightings, in seconds. + half_life_s: f32, + score: f32, + last_update_ms: Option, +} + +impl PresenceDecay { + /// Create a decayer with the given half-life (seconds). Errors on a + /// non-positive half-life. + pub fn new(half_life_s: f32) -> Result { + // Reject non-positive and NaN half-lives (a NaN would poison the decay). + if half_life_s <= 0.0 || half_life_s.is_nan() { + return Err(SensorError::InvalidConfig( + "half_life_s must be positive".into(), + )); + } + Ok(Self { + half_life_s, + score: 0.0, + last_update_ms: None, + }) + } + + /// Map an RSSI reading to a `0.0..=1.0` proximity target. + pub fn rssi_to_target(rssi_dbm: i16) -> f32 { + let r = rssi_dbm as f32; + ((r - RSSI_FLOOR_DBM) / (RSSI_CEIL_DBM - RSSI_FLOOR_DBM)).clamp(0.0, 1.0) + } + + /// Decay the held score forward to `now_ms` (idempotent for repeated calls + /// at the same instant). Returns the decayed score. + pub fn score_at(&mut self, now_ms: u64) -> f32 { + if let Some(last) = self.last_update_ms { + let dt_s = now_ms.saturating_sub(last) as f32 / 1000.0; + if dt_s > 0.0 { + // score *= 0.5 ^ (dt / half_life) + let factor = 0.5f32.powf(dt_s / self.half_life_s); + self.score *= factor; + self.last_update_ms = Some(now_ms); + } + } else { + self.last_update_ms = Some(now_ms); + } + self.score + } + + /// Register a sighting at `now_ms` with the given RSSI, reinforcing the + /// score toward the RSSI-derived target. Returns the updated score. + pub fn observe(&mut self, rssi_dbm: i16, now_ms: u64) -> f32 { + // Decay to now first, then take the stronger of the decayed score and + // the fresh proximity target — a closer/stronger sighting can only raise + // presence, never lower it below what we already believe. + let decayed = self.score_at(now_ms); + let target = Self::rssi_to_target(rssi_dbm); + self.score = decayed.max(target); + self.last_update_ms = Some(now_ms); + self.score + } + + /// Current score without advancing time. + pub fn score(&self) -> f32 { + self.score + } + + /// Reset to the unseen state. + pub fn reset(&mut self) { + self.score = 0.0; + self.last_update_ms = None; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rejects_nonpositive_half_life() { + assert!(PresenceDecay::new(0.0).is_err()); + assert!(PresenceDecay::new(-1.0).is_err()); + assert!(PresenceDecay::new(30.0).is_ok()); + } + + #[test] + fn rssi_mapping_clamps() { + assert_eq!(PresenceDecay::rssi_to_target(-30), 1.0); + assert_eq!(PresenceDecay::rssi_to_target(-10), 1.0); // above ceiling clamps + assert_eq!(PresenceDecay::rssi_to_target(-100), 0.0); + assert_eq!(PresenceDecay::rssi_to_target(-120), 0.0); // below floor clamps + let mid = PresenceDecay::rssi_to_target(-65); + assert!((mid - 0.5).abs() < 0.01, "midpoint ~0.5, got {mid}"); + } + + #[test] + fn observe_then_halves_after_one_half_life() { + let mut d = PresenceDecay::new(30.0).unwrap(); + d.observe(-30, 0); // target 1.0 + assert!((d.score() - 1.0).abs() < 1e-6); + let s = d.score_at(30_000); // +30s = one half-life + assert!((s - 0.5).abs() < 1e-3, "after one half-life ~0.5, got {s}"); + let s2 = d.score_at(60_000); // +another half-life + assert!( + (s2 - 0.25).abs() < 1e-3, + "after two half-lives ~0.25, got {s2}" + ); + } + + #[test] + fn unseen_contact_decays_toward_zero() { + let mut d = PresenceDecay::new(10.0).unwrap(); + d.observe(-40, 0); + let s = d.score_at(200_000); // 20 half-lives later + assert!(s < 1e-3, "should fade to ~0, got {s}"); + } + + #[test] + fn resighting_reinforces() { + let mut d = PresenceDecay::new(30.0).unwrap(); + d.observe(-50, 0); + let faded = d.score_at(60_000); // decayed + let renewed = d.observe(-30, 60_000); // strong re-sighting + assert!(renewed > faded, "re-sighting should raise the score"); + assert!((renewed - 1.0).abs() < 1e-6); + } +} diff --git a/crates/bt-presence/src/decode.rs b/crates/bt-presence/src/decode.rs new file mode 100644 index 0000000..9a4e37b --- /dev/null +++ b/crates/bt-presence/src/decode.rs @@ -0,0 +1,267 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +//! Byte-exact decoder for burble's frozen BLE presence beacon (wire v1). +//! +//! Frame (24 bytes), all offsets/sizes from the generated [`crate::wire`]: +//! +//! ```text +//! magic(1)=0x42 │ ver_type(1)=0x12 │ epoch(4, u32 BE) │ beacon_id(18) +//! ``` +//! +//! `epoch = unix_seconds / 900`. The contact-resolvable beacon id is +//! `HMAC-SHA256(presence_secret, "BRBL-PRES-v1" ‖ magic ‖ ver_type ‖ epoch(u32 BE))[0..18]` +//! (burble `Burble.Presence.BleSpa.beacon_id/2`). Resolution is: reject frames +//! whose carried epoch is more than `ACCEPT_EPOCH_WINDOW` from now, then +//! constant-time-compare the beacon against `beacon_id(secret, carried_epoch)` +//! for each held contact. + +use crate::wire; +use hmac::{Hmac, Mac}; +use sha2::Sha256; +use subtle::ConstantTimeEq; +use thiserror::Error; + +type HmacSha256 = Hmac; + +/// Structural decode failures (mirrors burble's wire checks). "Valid frame but +/// unknown/stale contact" is *not* an error — it is `resolve` returning `None`. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum DecodeError { + #[error("wrong length: expected {expected}, got {got}")] + BadLength { expected: usize, got: usize }, + #[error("bad magic byte")] + BadMagic, + #[error("unsupported wire version")] + BadVersion, + #[error("not a presence frame")] + BadFrameType, +} + +/// A held contact and the presence secret shared with them (burble ADR-0010; +/// secret distribution is out of scope here — it arrives via burble's handshake). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Contact { + pub id: String, + /// `presence_secret` — the HMAC key. 32 bytes in practice; any length valid. + pub secret: Vec, +} + +impl Contact { + pub fn new(id: impl Into, secret: impl Into>) -> Self { + Self { + id: id.into(), + secret: secret.into(), + } + } +} + +/// The 15-minute epoch index for a unix-seconds timestamp (`unix / 900`). +#[inline] +pub fn epoch_for(unix_s: u64) -> u64 { + unix_s / wire::EPOCH_SECONDS +} + +/// The 18-byte contact-resolvable beacon id for `(secret, epoch)`. +/// +/// `HMAC-SHA256(secret, "BRBL-PRES-v1" ‖ MAGIC ‖ VER_TYPE ‖ epoch(u32 BE))[0..18]`. +pub fn beacon_id(secret: &[u8], epoch: u64) -> [u8; wire::BEACON_ID_BYTES] { + // HMAC accepts a key of any length, so this never actually errors; the + // unreachable arm degrades to an all-zero id (which cannot match a real + // beacon) rather than unwrapping/panicking. + let mut mac = match HmacSha256::new_from_slice(secret) { + Ok(m) => m, + Err(_) => return [0u8; wire::BEACON_ID_BYTES], + }; + mac.update(wire::LABEL_PRES); + mac.update(&[wire::MAGIC, wire::VER_TYPE]); + mac.update(&(epoch as u32).to_be_bytes()); + let full = mac.finalize().into_bytes(); + let mut out = [0u8; wire::BEACON_ID_BYTES]; + out.copy_from_slice(&full[..wire::BEACON_ID_BYTES]); + out +} + +/// A parsed presence frame: the carried epoch and the raw beacon id bytes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PresenceFrame { + /// Epoch carried in the clear (leaks nothing; makes vectors self-contained). + pub epoch: u64, + /// The 18-byte beacon id to resolve against held contact secrets. + pub beacon_id: [u8; wire::BEACON_ID_BYTES], +} + +impl PresenceFrame { + /// Parse and structurally validate a raw advertisement payload. + pub fn parse(bytes: &[u8]) -> Result { + if bytes.len() != wire::FRAME_BYTES { + return Err(DecodeError::BadLength { + expected: wire::FRAME_BYTES, + got: bytes.len(), + }); + } + if bytes[wire::MAGIC_OFFSET] != wire::MAGIC { + return Err(DecodeError::BadMagic); + } + let vt = bytes[wire::VER_TYPE_OFFSET]; + if vt >> 4 != wire::WIRE_VERSION { + return Err(DecodeError::BadVersion); + } + if vt != wire::VER_TYPE { + // High nibble matched the version but the low nibble is not the + // presence frame type (e.g. this is a knock, 0x11). + return Err(DecodeError::BadFrameType); + } + let mut epoch_be = [0u8; wire::EPOCH_BYTES]; + epoch_be + .copy_from_slice(&bytes[wire::EPOCH_OFFSET..wire::EPOCH_OFFSET + wire::EPOCH_BYTES]); + let epoch = u32::from_be_bytes(epoch_be) as u64; + + let mut beacon = [0u8; wire::BEACON_ID_BYTES]; + beacon.copy_from_slice( + &bytes[wire::BEACON_ID_OFFSET..wire::BEACON_ID_OFFSET + wire::BEACON_ID_BYTES], + ); + Ok(Self { + epoch, + beacon_id: beacon, + }) + } + + /// Is the carried epoch within `ACCEPT_EPOCH_WINDOW` of `now`? Bounds replay + /// of stale beacons while tolerating modest clock skew. + pub fn is_fresh(&self, now_s: u64) -> bool { + epoch_for(now_s).abs_diff(self.epoch) <= wire::ACCEPT_EPOCH_WINDOW + } + + /// Resolve this frame against held `contacts`, returning the first whose + /// secret reproduces the beacon id (constant-time), or `None` if the frame + /// is stale or belongs to no known contact. + pub fn resolve<'a>(&self, contacts: &'a [Contact], now_s: u64) -> Option<&'a Contact> { + if !self.is_fresh(now_s) { + return None; + } + contacts.iter().find(|c| { + let expected = beacon_id(&c.secret, self.epoch); + expected[..].ct_eq(&self.beacon_id[..]).into() + }) + } +} + +/// Parse `bytes` and resolve in one step. `Ok(Some(id))` = a fresh beacon from a +/// known contact; `Ok(None)` = a structurally-valid beacon that is stale or from +/// a stranger; `Err(_)` = not a well-formed v1 presence frame. +pub fn decode_and_resolve( + bytes: &[u8], + contacts: &[Contact], + now_s: u64, +) -> Result, DecodeError> { + let frame = PresenceFrame::parse(bytes)?; + Ok(frame.resolve(contacts, now_s).map(|c| c.id.clone())) +} + +#[cfg(test)] +mod tests { + use super::*; + + // Round-trip: an encoded beacon resolves back to its contact within window. + fn encode(secret: &[u8], epoch: u64) -> Vec { + let mut v = Vec::with_capacity(wire::FRAME_BYTES); + v.push(wire::MAGIC); + v.push(wire::VER_TYPE); + v.extend_from_slice(&(epoch as u32).to_be_bytes()); + v.extend_from_slice(&beacon_id(secret, epoch)); + v + } + + #[test] + fn epoch_is_floor_div_900() { + assert_eq!(epoch_for(1767225600), 1963584); + assert_eq!(epoch_for(1767225600 + 899), 1963584); + assert_eq!(epoch_for(1767225600 + 900), 1963585); + } + + #[test] + fn roundtrip_resolves_owner() { + let secret = vec![7u8; 32]; + let epoch = epoch_for(1767225600); + let bytes = encode(&secret, epoch); + let contacts = vec![Contact::new("me", secret.clone())]; + let got = decode_and_resolve(&bytes, &contacts, 1767225600).unwrap(); + assert_eq!(got.as_deref(), Some("me")); + } + + #[test] + fn wrong_secret_does_not_resolve() { + let epoch = epoch_for(1767225600); + let bytes = encode(&[7u8; 32], epoch); + let contacts = vec![Contact::new("other", vec![8u8; 32])]; + assert_eq!( + decode_and_resolve(&bytes, &contacts, 1767225600).unwrap(), + None + ); + } + + #[test] + fn stale_epoch_beyond_window_rejected() { + let secret = vec![7u8; 32]; + let epoch = epoch_for(1767225600); + let bytes = encode(&secret, epoch); + let contacts = vec![Contact::new("me", secret)]; + // now is +2 epochs (1800s) later — beyond ACCEPT_EPOCH_WINDOW (1). + let now = 1767225600 + 2 * wire::EPOCH_SECONDS; + assert_eq!(decode_and_resolve(&bytes, &contacts, now).unwrap(), None); + } + + #[test] + fn within_window_still_resolves() { + let secret = vec![7u8; 32]; + let epoch = epoch_for(1767225600); + let bytes = encode(&secret, epoch); + let contacts = vec![Contact::new("me", secret)]; + // now is +1 epoch later — within ACCEPT_EPOCH_WINDOW. + let now = 1767225600 + wire::EPOCH_SECONDS; + assert_eq!( + decode_and_resolve(&bytes, &contacts, now) + .unwrap() + .as_deref(), + Some("me") + ); + } + + #[test] + fn structural_errors() { + let secret = vec![7u8; 32]; + let mut bytes = encode(&secret, epoch_for(1767225600)); + assert_eq!( + PresenceFrame::parse(&bytes[..23]).unwrap_err(), + DecodeError::BadLength { + expected: 24, + got: 23 + } + ); + let mut bad_magic = bytes.clone(); + bad_magic[0] = 0x43; + assert_eq!( + PresenceFrame::parse(&bad_magic).unwrap_err(), + DecodeError::BadMagic + ); + let mut bad_ver = bytes.clone(); + bad_ver[1] = 0x22; // version nibble 2 + assert_eq!( + PresenceFrame::parse(&bad_ver).unwrap_err(), + DecodeError::BadVersion + ); + let mut knock = bytes.clone(); + knock[1] = 0x11; // v1 but knock frame-type + assert_eq!( + PresenceFrame::parse(&knock).unwrap_err(), + DecodeError::BadFrameType + ); + // tamper a beacon byte → parses fine, resolves to nobody + bytes[10] ^= 0xff; + let contacts = vec![Contact::new("me", vec![7u8; 32])]; + assert_eq!( + decode_and_resolve(&bytes, &contacts, 1767225600).unwrap(), + None + ); + } +} diff --git a/crates/bt-presence/src/lib.rs b/crates/bt-presence/src/lib.rs new file mode 100644 index 0000000..07e5ea1 --- /dev/null +++ b/crates/bt-presence/src/lib.rs @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +//! Bluetooth Low-Energy *nearby-presence* sensor — read-only consumer of +//! burble's frozen presence beacon (wire v1, burble ADR-0015). +//! +//! # What this is +//! +//! neurophone treats a co-located burble contact's rotating BLE beacon as one +//! more **presence sensor**: a single-channel signal (a decayed presence score) +//! that feeds the existing `sensor → LSM → ESN → bridge → LLM` pipeline exactly +//! like light or proximity. It is *sensor-class only* — no microphone, no +//! outbound advertising, read-only on the wire (see `docs/BT-PRESENCE-PLAN.adoc`). +//! +//! # What this is not +//! +//! This crate does not scan the radio and does not emit anything. Acquiring the +//! raw 24-byte advertisement bytes is the platform's job (the Android/gossamer +//! surface, per #83); this crate is the pure, host-testable **decode + contact +//! resolution + presence decay** core that turns those bytes into a +//! [`BtPresenceReading`]. The upstream *emitter* is burble Phase 1, which does +//! not exist yet — so at runtime there is nothing to receive until burble ships +//! its Android client. The wire format it will emit is, however, frozen (v1), +//! which is what makes this consumer buildable and testable now. +//! +//! # Protocol provenance +//! +//! The wire constants are code-generated at build time from the byte-exact +//! vendored copy of burble's frozen spec (`vendor/nearby-presence.a2ml`) — see +//! [`wire`] and `vendor/PROVENANCE.adoc`. The decoder is proven wire-compatible +//! against burble's frozen conformance vectors in `tests/vectors.rs`. + +#![forbid(unsafe_code)] +#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used))] + +/// Frozen BLE presence wire constants, generated from `vendor/nearby-presence.a2ml` +/// by `build.rs` (burble ADR-0015, wire v1). Regenerated whenever the vendored +/// spec changes; the build fails loudly if that spec is not a frozen v1. +pub mod wire { + include!(concat!(env!("OUT_DIR"), "/wire.rs")); +} + +pub mod decay; +pub mod decode; + +pub use decay::PresenceDecay; +pub use decode::{beacon_id, epoch_for, Contact, DecodeError, PresenceFrame}; + +use sensors::{SensorError, SensorKind, SensorReading}; + +/// A resolved presence observation, ready to enter the sensor pipeline. +/// +/// `presence_score` is the decayed 0.0..=1.0 confidence that a *known* contact +/// is nearby right now — high just after a beacon from a held contact secret is +/// seen, decaying toward 0 as epochs pass without a re-sighting. +#[derive(Debug, Clone, PartialEq)] +pub struct BtPresenceReading { + /// The resolved contact id, if the beacon matched a held secret. `None` + /// means "a valid, fresh beacon was seen but it belongs to no known + /// contact" — presence of a stranger, which the score still reflects as + /// unresolved co-location if the caller chooses to feed it. + pub contact_id: Option, + /// Decayed presence confidence in `0.0..=1.0`. + pub presence_score: f32, + /// Sensor timestamp (ms) for pipeline alignment. + pub timestamp_ms: u64, +} + +impl BtPresenceReading { + /// Convert to a standard single-channel [`SensorReading`] so the presence + /// score flows through the existing `SensorPipeline` into the LSM. + pub fn to_sensor_reading(&self) -> Result { + SensorReading::new( + SensorKind::Presence, + self.timestamp_ms, + vec![self.presence_score], + ) + } +} diff --git a/crates/bt-presence/tests/vectors.rs b/crates/bt-presence/tests/vectors.rs new file mode 100644 index 0000000..801f34b --- /dev/null +++ b/crates/bt-presence/tests/vectors.rs @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +//! Conformance: replay burble's FROZEN presence vectors (`vendor/ble-spa-v1.json`, +//! ADR-0015) and assert this decoder is byte-exact with burble's independent +//! HMAC-SHA256 oracle. Any drift here is a wire break, by construction. + +use bt_presence::decode::{beacon_id, decode_and_resolve, epoch_for, Contact, PresenceFrame}; +use serde_json::Value; + +fn unhex(s: &str) -> Vec { + assert!(s.len().is_multiple_of(2), "odd-length hex: {s}"); + (0..s.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&s[i..i + 2], 16).expect("valid hex")) + .collect() +} + +fn vectors() -> Value { + let raw = include_str!("../vendor/ble-spa-v1.json"); + serde_json::from_str(raw).expect("vendor/ble-spa-v1.json is valid JSON") +} + +#[test] +fn vector_file_is_frozen_v1() { + let v = vectors(); + assert_eq!(v["wire_version"], 1); + assert_eq!(v["spec_version"], "1.0.0"); +} + +#[test] +fn presence_beacon_id_is_byte_exact() { + let v = vectors(); + let cases = v["presence"].as_array().expect("presence[] present"); + assert!(!cases.is_empty(), "expected presence vectors"); + for c in cases { + let name = c["name"].as_str().unwrap_or("?"); + let secret = unhex(c["presence_secret_hex"].as_str().expect("secret")); + let unix_s = c["unix_s"].as_u64().expect("unix_s"); + let expected_epoch = c["epoch"].as_u64().expect("epoch"); + let expected_beacon = unhex(c["beacon_id_hex"].as_str().expect("beacon_id_hex")); + + // 1. epoch derivation matches burble. + assert_eq!(epoch_for(unix_s), expected_epoch, "{name}: epoch"); + + // 2. our HMAC beacon id reproduces burble's oracle byte-for-byte. + let ours = beacon_id(&secret, expected_epoch); + assert_eq!(&ours[..], &expected_beacon[..], "{name}: beacon_id bytes"); + + // 3. the full 24-byte payload parses to the same epoch + beacon. + let payload = unhex(c["payload_hex"].as_str().expect("payload_hex")); + let frame = PresenceFrame::parse(&payload).expect("payload parses"); + assert_eq!(frame.epoch, expected_epoch, "{name}: parsed epoch"); + assert_eq!( + &frame.beacon_id[..], + &expected_beacon[..], + "{name}: parsed beacon" + ); + } +} + +#[test] +fn presence_resolves_to_named_contact() { + let v = vectors(); + for c in v["presence"].as_array().expect("presence[]") { + let name = c["name"].as_str().unwrap_or("?"); + let secret = unhex(c["presence_secret_hex"].as_str().expect("secret")); + let payload = unhex(c["payload_hex"].as_str().expect("payload_hex")); + let resolve = &c["resolve"]; + let now = resolve["now"].as_u64().expect("now"); + let contact_id = resolve["contact_id"].as_str().expect("contact_id"); + + let contacts = vec![Contact::new(contact_id, secret)]; + let got = decode_and_resolve(&payload, &contacts, now).expect("well-formed frame"); + assert_eq!(got.as_deref(), Some(contact_id), "{name}: resolves owner"); + + // A holder of a *different* secret must not resolve the same payload. + let stranger = vec![Contact::new("stranger", vec![0xABu8; 32])]; + assert_eq!( + decode_and_resolve(&payload, &stranger, now).expect("well-formed"), + None, + "{name}: stranger must not resolve" + ); + } +} diff --git a/crates/bt-presence/vendor/PROVENANCE.adoc b/crates/bt-presence/vendor/PROVENANCE.adoc new file mode 100644 index 0000000..60fc65b --- /dev/null +++ b/crates/bt-presence/vendor/PROVENANCE.adoc @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: CC-BY-SA-4.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) += Vendored burble presence artefacts — provenance +:toc: preamble + +The two files beside this one are *byte-exact vendored copies* of burble's +FROZEN BLE-presence wire artefacts. They are the single source of truth this +crate's codegen and tests pin against — vendored (not fetched) so the neurophone +build is reproducible and offline-buildable, and pinned so any upstream change +is a deliberate, reviewable re-vendor rather than silent drift. + +[cols="1,3",options="header"] +|=== +| Field | Value + +| Upstream repo | `https://github.com/hyperpolymath/burble` +| Upstream commit | `2b5914b2760bdad40d4fb7651b94c37c58f91e2d` (PR #154, "Freeze BLE presence wire format v1") +| Authority | burble ADR-0015 (BLE presence wire format v1) + ADR-0010 (presence/zones) +| Wire version | v1 (`[metadata].wire-version = 1`, `status = "frozen"`) +|=== + +== Files + +`nearby-presence.a2ml`:: + Source: `.machine_readable/descriptiles/nearby-presence.a2ml` @ the commit above. + + sha256: `687c758b0004ae14d9907d2855027f7dc6358b91130f937a1ce5d3ef4e46d559` + + The frozen presence-beacon wire spec. `build.rs` parses its `[presence-frame]` + offsets/sizes into `wire.rs` constants and *fails the build loudly* if + `[metadata].wire-version != 1` — so a v2 re-vendor cannot silently miscompile + a v1 decoder. + +`ble-spa-v1.json`:: + Source: `.machine_readable/test-vectors/ble-spa-v1.json` @ the commit above. + + sha256: `f7067587742214d979ec4fde5e5b403d8d47ca0de1d442ab3b1908ce0650dc6b` + + Frozen conformance vectors. `tests/vectors.rs` replays the `presence` and + `presence`-relevant `knock_negative` cases and asserts byte-exact `beacon_id` + and resolve outcomes, so this crate's decoder is provably wire-compatible. + +== Freeze covenant (from burble) + +Per `nearby-presence.a2ml [freeze]`: the bytes are frozen; a change requires a +superseding ADR + major `@version` bump + CHANGELOG "Protocol" entry + +regenerated vectors. Re-vendoring here is therefore a deliberate act: bump the +commit/sha above, re-copy both files, and let `build.rs`'s wire-version guard +and `tests/vectors.rs` confirm compatibility (or fail loudly). + +== Re-vendor command + +[source,sh] +---- +B=/path/to/burble ; C= +git -C "$B" show "$C:.machine_readable/descriptiles/nearby-presence.a2ml" \ + > crates/bt-presence/vendor/nearby-presence.a2ml +git -C "$B" show "$C:.machine_readable/test-vectors/ble-spa-v1.json" \ + > crates/bt-presence/vendor/ble-spa-v1.json +# then update the commit + sha256 fields above +---- diff --git a/crates/bt-presence/vendor/ble-spa-v1.json b/crates/bt-presence/vendor/ble-spa-v1.json new file mode 100644 index 0000000..539d8f3 --- /dev/null +++ b/crates/bt-presence/vendor/ble-spa-v1.json @@ -0,0 +1,199 @@ +{ + "spec": "burble-ble-spa", + "wire_version": 1, + "spec_version": "1.0.0", + "adr": "docs/decisions/0015-ble-presence-wire-format-v1.adoc", + "reference_impl": "server/lib/burble/presence/ble_spa.ex", + "generated_by": "mix burble.gen_ble_vectors (bytes independently reproduced by an HMAC-SHA256 oracle)", + "note": "Fixed non-production secrets. Any drift in these bytes is a v2 wire break (ADR-0015 D7).", + "room_secret_derivation": [ + { + "name": "alpha", + "invite_token": "test-invite-room-alpha", + "room_secret_hex": "b73d0f8b2aefa65ac5c64d526f0e97399810f533971f732030c32c5224eff090" + }, + { + "name": "bravo", + "invite_token": "test-invite-room-bravo", + "room_secret_hex": "d1c73bce7f5251cdc559cb2063ca6e424917a29faccf1ca27c89daae0ec64233" + } + ], + "knock": [ + { + "name": "alpha_basic", + "room_secret_hex": "b73d0f8b2aefa65ac5c64d526f0e97399810f533971f732030c32c5224eff090", + "ts": 1767225600, + "nonce_hex": "0102030405ff", + "payload_hex": "42116955b9000102030405ff1ac37b97bc871849d09fe042", + "verify": { + "now": 1767225605, + "result": "ok" + } + }, + { + "name": "alpha_offset", + "room_secret_hex": "b73d0f8b2aefa65ac5c64d526f0e97399810f533971f732030c32c5224eff090", + "ts": 1767226500, + "nonce_hex": "aabbccddeeff", + "payload_hex": "42116955bc84aabbccddeeff6efd5f7aeda6301df653a32e", + "verify": { + "now": 1767226505, + "result": "ok" + } + }, + { + "name": "bravo_basic", + "room_secret_hex": "d1c73bce7f5251cdc559cb2063ca6e424917a29faccf1ca27c89daae0ec64233", + "ts": 1767232800, + "nonce_hex": "101112131415", + "payload_hex": "42116955d5201011121314153eeaed0aa2d88b4f95435500", + "verify": { + "now": 1767232805, + "result": "ok" + } + } + ], + "knock_negative": [ + { + "name": "tampered_mac", + "payload_hex": "42116955b9000102030405ff1ac37b97bc871849d09fe043", + "room_secret_hex": "b73d0f8b2aefa65ac5c64d526f0e97399810f533971f732030c32c5224eff090", + "now": 1767225605, + "result": "bad_mac" + }, + { + "name": "wrong_secret", + "payload_hex": "42116955b9000102030405ff1ac37b97bc871849d09fe042", + "room_secret_hex": "d1c73bce7f5251cdc559cb2063ca6e424917a29faccf1ca27c89daae0ec64233", + "now": 1767225605, + "result": "bad_mac" + }, + { + "name": "bad_magic", + "payload_hex": "43116955b9000102030405ff1ac37b97bc871849d09fe042", + "room_secret_hex": "b73d0f8b2aefa65ac5c64d526f0e97399810f533971f732030c32c5224eff090", + "now": 1767225605, + "result": "bad_magic" + }, + { + "name": "bad_version", + "payload_hex": "42216955b9000102030405ff1ac37b97bc871849d09fe042", + "room_secret_hex": "b73d0f8b2aefa65ac5c64d526f0e97399810f533971f732030c32c5224eff090", + "now": 1767225605, + "result": "bad_version" + }, + { + "name": "bad_frame_type", + "payload_hex": "42126955b9000102030405ff1ac37b97bc871849d09fe042", + "room_secret_hex": "b73d0f8b2aefa65ac5c64d526f0e97399810f533971f732030c32c5224eff090", + "now": 1767225605, + "result": "bad_frame_type" + }, + { + "name": "stale_plus_31s", + "payload_hex": "42116955b9000102030405ff1ac37b97bc871849d09fe042", + "room_secret_hex": "b73d0f8b2aefa65ac5c64d526f0e97399810f533971f732030c32c5224eff090", + "now": 1767225631, + "result": "stale_timestamp" + }, + { + "name": "stale_minus_31s", + "payload_hex": "42116955b9000102030405ff1ac37b97bc871849d09fe042", + "room_secret_hex": "b73d0f8b2aefa65ac5c64d526f0e97399810f533971f732030c32c5224eff090", + "now": 1767225569, + "result": "stale_timestamp" + }, + { + "name": "truncated", + "payload_hex": "42116955b9000102030405ff1ac37b97bc871849d09fe0", + "room_secret_hex": "b73d0f8b2aefa65ac5c64d526f0e97399810f533971f732030c32c5224eff090", + "now": 1767225605, + "result": "bad_length" + } + ], + "response": [ + { + "name": "alpha_psm0", + "room_secret_hex": "b73d0f8b2aefa65ac5c64d526f0e97399810f533971f732030c32c5224eff090", + "knock_ts": 1767225600, + "knock_nonce_hex": "0102030405ff", + "resp_ts": 1767225602, + "psm": 0, + "token_hex": "9d948106cd6166204dfe72296b03caa1", + "payload_hex": "42136955b9029d948106cd6166204dfe72296b03caa10000", + "match": { + "now": 1767225603, + "result_psm": 0 + } + }, + { + "name": "alpha_psm129", + "room_secret_hex": "b73d0f8b2aefa65ac5c64d526f0e97399810f533971f732030c32c5224eff090", + "knock_ts": 1767225600, + "knock_nonce_hex": "0102030405ff", + "resp_ts": 1767225602, + "psm": 129, + "token_hex": "9d948106cd6166204dfe72296b03caa1", + "payload_hex": "42136955b9029d948106cd6166204dfe72296b03caa10081", + "match": { + "now": 1767225603, + "result_psm": 129 + } + }, + { + "name": "bravo_psm4097", + "room_secret_hex": "d1c73bce7f5251cdc559cb2063ca6e424917a29faccf1ca27c89daae0ec64233", + "knock_ts": 1767232800, + "knock_nonce_hex": "101112131415", + "resp_ts": 1767232803, + "psm": 4097, + "token_hex": "612b2828d522816ab4fc20e14f427bd1", + "payload_hex": "42136955d523612b2828d522816ab4fc20e14f427bd11001", + "match": { + "now": 1767232804, + "result_psm": 4097 + } + } + ], + "presence": [ + { + "name": "contact_c_epoch0", + "presence_secret_hex": "00112233445566778899aabbccddeeff0102030405060708090a0b0c0d0e0f10", + "unix_s": 1767225600, + "epoch": 1963584, + "beacon_id_hex": "92d634a38fa4f2f459464b01f441dedd7c7d", + "payload_hex": "4212001df64092d634a38fa4f2f459464b01f441dedd7c7d", + "resolve": { + "now": 1767225600, + "contact_id": "contact-c", + "result": "ok" + } + }, + { + "name": "contact_c_next", + "presence_secret_hex": "00112233445566778899aabbccddeeff0102030405060708090a0b0c0d0e0f10", + "unix_s": 1767226500, + "epoch": 1963585, + "beacon_id_hex": "d37399b1ec9af814b0daa85ca6591b1cfc7d", + "payload_hex": "4212001df641d37399b1ec9af814b0daa85ca6591b1cfc7d", + "resolve": { + "now": 1767226500, + "contact_id": "contact-c", + "result": "ok" + } + }, + { + "name": "contact_c_later", + "presence_secret_hex": "00112233445566778899aabbccddeeff0102030405060708090a0b0c0d0e0f10", + "unix_s": 1767349056, + "epoch": 1963721, + "beacon_id_hex": "0f7eb625ab851315b6d972592b6f137f6df7", + "payload_hex": "4212001df6c90f7eb625ab851315b6d972592b6f137f6df7", + "resolve": { + "now": 1767349056, + "contact_id": "contact-c", + "result": "ok" + } + } + ] +} diff --git a/crates/bt-presence/vendor/nearby-presence.a2ml b/crates/bt-presence/vendor/nearby-presence.a2ml new file mode 100644 index 0000000..ff711f0 --- /dev/null +++ b/crates/bt-presence/vendor/nearby-presence.a2ml @@ -0,0 +1,69 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# nearby-presence.a2ml — FROZEN BLE presence beacon + detection model (v1). +# Pinnable spec for the neurophone presence sensor. Authority: ADR-0015 + ADR-0010. + +[metadata] +descriptile = "nearby-presence" +spec-version = "1.0.0" +wire-version = 1 +status = "frozen" +adr = "docs/decisions/0015-ble-presence-wire-format-v1.adoc" +reference-impl = "server/lib/burble/presence/ble_spa.ex" +abi-proof = "src/Burble/ABI/NearbyPresence.idr" +test-vectors = ".machine_readable/test-vectors/ble-spa-v1.json" +sibling = ".machine_readable/descriptiles/ble-spa-knock.a2ml" + +[presence-frame] +# 24 bytes: envelope(6) ++ beacon_id(18). Find-My pattern: contact-resolvable, +# never plaintext. Legacy ADV_NONCONN_IND, MSD AD 0xFF, company 0xFFFF. +ver_type = 0x12 +magic-offset = 0 +magic = 0x42 +ver_type-offset = 1 +epoch-offset = 2 +epoch-bytes = 4 # u32 BE = floor(unix_seconds / 900) +beacon_id-offset = 6 +beacon_id-bytes = 18 +beacon_id-input = "label 'BRBL-PRES-v1' ++ magic ++ ver_type ++ epoch(u32 BE)" # payload[0..6] +primitive = "HMAC-SHA256(presence_secret, ...)[0..18]" + +[rotation] +period-seconds = 900 # 15 minutes (ADR-0010) +must = "tear down and restart the advertising set at each epoch boundary so the payload and the Resolvable Private Address rotate together" +epoch-carried-in-clear = true # derivable from time, leaks nothing; makes vectors self-contained + +[resolution] +# How a consumer (neurophone) resolves a beacon to a known contact. +accept-epoch-window = 1 # |carried_epoch - epoch(now)| <= 1 +per-secret-cost = "exactly one HMAC per held contact secret" +no-key-id = true +no-key-id-rationale = "a key-id would let bystanders observe rotation events ('someone got blocked') — covert-must-be-covert (ADR-0010)" + +[zones] +# Presence mode <-> ADR-0010 trust zones. Off is the default (public-safe). +modes = ["Off", "Silent", "Presence"] +off = "emit nothing, ever (default)" +silent = "Public zone: no standing beacon; knock-triggered rendezvous only" +presence = "Private / Trusted-by-* zones: rotating contact-resolvable beacon each epoch" +precedence = "block > allow > zone" +block-mechanism = "rotate presence_secret + re-share to all contacts EXCEPT the blocked party; from the next epoch their retained secret never matches (cryptographic, not cosmetic)" +secret-distribution = "deferred (QR / .well-known handshake) — ADR-0010" + +[detection-model] +# Resolves the PRODUCTION-BLOCKER-HANDOFF §1.2 contradiction: detection is +# knock-triggered rendezvous, NOT continuous presence sensing. +off = { sensor-observes = "nothing", power = "zero" } +silent = { sensor-observes = "discrete knock/response rendezvous events (decodable only with the room secret)", power-idle = "passive scan + hardware filter (~3mA holds only here)", power-armed = "SCAN_MODE_BALANCED+ during <=30s expected-knock windows (real battery cost)" } +presence = { sensor-observes = "standing presence field for contacts whose secret it holds; unlinkable bytes to everyone else", power = "idle passive scan (beacon repeats all epoch)" } + +[consumer] +# What neurophone matches on (the presence sensor). +filter = "manufacturer data, company 0xFFFF, prefix [0x42, 0x12]" +consumes = "client/lib/src/extensions/NeurophonePresence.affine" +bridge = "server/lib/burble/experimental/neurophone_bridge.ex (opt-in, EXPERIMENTAL, no radio)" +role = "sensor only — no mic, no voice (ADR-0010)" + +[freeze] +covenant = "See ble-spa-knock.a2ml [freeze]. Same covenant: bytes above are frozen; changes require a superseding ADR + major @version bump + CHANGELOG 'Protocol' entry + regenerated vectors." diff --git a/crates/sensors/src/lib.rs b/crates/sensors/src/lib.rs index 223992b..d1e91be 100644 --- a/crates/sensors/src/lib.rs +++ b/crates/sensors/src/lib.rs @@ -31,13 +31,17 @@ pub enum SensorKind { Magnetometer, Light, Proximity, + /// Bluetooth nearby-presence: a single-channel decayed presence score + /// (0.0..=1.0) produced by the `bt-presence` crate from burble's BLE + /// beacon. Sensor-class like light/proximity; see `docs/BT-PRESENCE-PLAN.adoc`. + Presence, } impl SensorKind { pub const fn arity(self) -> usize { match self { Self::Accelerometer | Self::Gyroscope | Self::Magnetometer => 3, - Self::Light | Self::Proximity => 1, + Self::Light | Self::Proximity | Self::Presence => 1, } } @@ -48,6 +52,7 @@ impl SensorKind { Self::Magnetometer => "magnetometer", Self::Light => "light", Self::Proximity => "proximity", + Self::Presence => "presence", } } } From 4458be265dbae58f14deae0c6e33be0ff82f3a90 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 00:04:21 +0000 Subject: [PATCH 2/2] docs: burble presence Phase-0 frozen upstream; neurophone consumer implemented Yesterday's sibling-integration audit (#178) recorded burble as BLOCKED with every consumed artefact design-only. burble froze the wire format hours later (ADR-0015), so those records were stale. Corrected to reflect reality: - SIBLING-INTEGRATIONS.adoc: burble row BLOCKED -> CONSUMER IMPLEMENTED; the EXISTS-vs-PLANNED table flips (wire format, Idris2 ABI, vectors now frozen), with a description of the crates/bt-presence implementation. Only burble's Android emitter (Phase 1) remains. - BT-PRESENCE-PLAN.adoc: upstream status BLOCKED -> Phase-0 done / consumer implemented / Phase-1 emitter pending. - ECOSYSTEM.a2ml: burble status blocked-upstream -> consumer-implemented. - STATE.a2ml: add bt-presence crate; tests 160 -> 174; recent-work + blockers refreshed (burble Phase-1 is runtime-only, non-blocking for the build). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0172RBMz3qYjb1ttzD2i7RNh --- .machine_readable/6a2/ECOSYSTEM.a2ml | 2 +- .machine_readable/6a2/STATE.a2ml | 30 ++++---- docs/BT-PRESENCE-PLAN.adoc | 61 +++++++++------- docs/governance/SIBLING-INTEGRATIONS.adoc | 86 +++++++++++++---------- 4 files changed, 102 insertions(+), 77 deletions(-) diff --git a/.machine_readable/6a2/ECOSYSTEM.a2ml b/.machine_readable/6a2/ECOSYSTEM.a2ml index d3ca8c7..474a158 100644 --- a/.machine_readable/6a2/ECOSYSTEM.a2ml +++ b/.machine_readable/6a2/ECOSYSTEM.a2ml @@ -22,7 +22,7 @@ role = "on-device Android neurosymbolic-AI app (sensor -> LSM -> ESN -> bridge - projects = [ { name = "gossamer", relationship = "dependency", status = "in-progress", note = "Android WebView/JNI runtime shell; Kotlin->Rust migration (#83). The Android surface targets gossamer, not Kotlin." }, { name = "conative-gating", relationship = "dependency", status = "blocked-external", note = "GO/NO-GO egress veto in front of claude-client (#103); upstream Rust client not yet reachable in-session." }, - { name = "burble", relationship = "potential-consumer", status = "blocked-upstream", note = "BLE nearby-presence sensor, read-only consumer. Blocked on burble Phase 0 - wire manifest + Idris2 ABI + Android emitter do not yet exist; ADR-0010 Proposed. See docs/BT-PRESENCE-PLAN.adoc." }, + { name = "burble", relationship = "consumer", status = "consumer-implemented", note = "BLE nearby-presence sensor (read-only). Wire format v1 frozen upstream (burble ADR-0015, 2026-07-09); neurophone consumer implemented + byte-exact vector-validated in crates/bt-presence. Only burble's Android emitter (Phase 1) is still pending. See docs/BT-PRESENCE-PLAN.adoc." }, { name = "panll", relationship = "precedent", status = "precedent-only", note = "Migration precedent (Tauri->Gossamer). No runtime data flow; panll's live ingest path is panll<->panic-attack event-chains, not neurophone." }, { name = "groove", relationship = "not-applicable", status = "not-applicable", note = "Runtime service-discovery protocol for HTTP services offering capabilities. neurophone runs no server and offers none." }, { name = "cleave", relationship = "not-applicable", status = "not-applicable", note = "Pre-alpha design corpus; zero implementation, no ABI/schema/SDK, no domain overlap." }, diff --git a/.machine_readable/6a2/STATE.a2ml b/.machine_readable/6a2/STATE.a2ml index 16466f5..e9613d4 100644 --- a/.machine_readable/6a2/STATE.a2ml +++ b/.machine_readable/6a2/STATE.a2ml @@ -1,24 +1,25 @@ # SPDX-License-Identifier: MPL-2.0 # STATE.a2ml — Project state checkpoint -# Updated 2026-07-01 — proofs (2.1 TLC / 2.3 typestate), JNI surface, Trustfile, -# CI green; crate statuses corrected (the neural crates were never stubs). +# Updated 2026-07-10 — bt-presence Phase-2 consumer (burble BLE presence v1, +# frozen upstream ADR-0015) implemented + vector-validated; sibling-integration +# audit + launcher carve-out; hypatia baseline + cargo-audit greened. [metadata] project = "neurophone" version = "1.0.0" -last-updated = "2026-07-01" +last-updated = "2026-07-10" status = "active" crg-grade = "C" [project-context] name = "neurophone" -completion-percentage = 80 -phase = "Core + proofs advancing; Android shell blocked on external gossamer" +completion-percentage = 82 +phase = "Core + proofs advancing; BLE presence consumer built (awaiting burble Phase-1 emitter); Android shell on external gossamer" [test-coverage] # Aggregate across the workspace (lib + integration + doc-tests). -total-tests = 160 -notes = "includes property tests (0.1/0.2/1.2/1.3), typestate compile-fail doc-tests (2.1/2.3), and the TLC lifecycle model check (2.1)" +total-tests = 174 +notes = "160 + 14 new in crates/bt-presence (11 decode/decay unit + 3 burble frozen-vector conformance, incl. byte-exact beacon-id). Also property tests (0.1/0.2/1.2/1.3), typestate compile-fail doc-tests (2.1/2.3), TLC lifecycle model check (2.1)" [test-results] build-status = "SUCCESS" @@ -38,6 +39,7 @@ llm = "complete — local inference" sensors = "complete — proptest_numeric; IIR filtering" claude-client = "complete — cloud fallback; egress-scrub test" neurophone-android = "implemented — 11-method ai.neurophone.NativeLib JNI surface (#110)" +bt-presence = "implemented — read-only consumer of burble's frozen BLE presence beacon (wire v1, ADR-0015): build.rs codegen from vendored frozen spec + byte-exact HMAC decoder + contact resolution + RSSI decay; validated against burble's frozen vectors. Feeds a new SensorKind::Presence channel. Device-side scan glue (gossamer/JNI) + burble Phase-1 emitter still pending." [proofs] # Honest obligation status; full detail in proofs/README.adoc + Trustfile PROOF_ARTIFACTS. @@ -45,11 +47,13 @@ discharged = "0.1 panic-freedom, 0.2 numeric containment, 0.3 unsafe discipline, open = "1.1 Echo State Property (formal contraction), 1.2 formal Dafny bound, 2.2 concurrency (N/A single-owner), 3.1 egress veto (external #103)" [recent-work] -session-date = "2026-07-01" -fixed = "rand 0.9/0.10 build break; retired scorecard-enforcer.yml; re-pinned standards reusables (governance/hypatia/scorecard green)" -added = "real JNI surface (#110); TLA+ Lifecycle.cfg + TLC; typestate compile-fail proofs; core panic/numeric proptests; full estate Trustfile v2026.2.5-final + trustfile.yml gate" -improved = "Cargo.toml/manifests licence normalised to MPL-2.0; proofs/README honest status; STATE corrected" +session-date = "2026-07-10" +added = "crates/bt-presence — Phase-2 BLE nearby-presence consumer (build.rs codegen from vendored frozen burble spec; byte-exact HMAC decoder + contact resolution + RSSI decay; burble frozen-vector conformance tests; SensorKind::Presence channel); docs/governance/SIBLING-INTEGRATIONS.adoc (ground-truthed groove/cleave/burble/panll dispositions + launcher carve-out); ECOSYSTEM.a2ml related-projects + carve-outs" +fixed = "greened main CI after #178 (hypatia SD022 cross-repo false-positives via module-name citations; crossbeam-epoch RUSTSEC-2026-0204 lockfile bump); groove-check test-listener false positive (source-fixed in rsr-template)" +improved = "BT-PRESENCE-PLAN.adoc corrected (burble Phase-0 now frozen; path 6a2/->descriptiles/; Kotlin->gossamer); burble ecosystem status blocked-upstream -> consumer-implemented" +prior-session = "2026-07-01: real JNI surface (#110); TLA+ Lifecycle.cfg + TLC; typestate compile-fail proofs; full estate Trustfile v2026.2.5-final; licence normalised to MPL-2.0" [blockers] -gossamer = "External: #83 Android migration (gossamer Idris2+Zig shell) blocked — repo not in session scope (403)" -conative-gating = "External: #103 egress GO/NO-GO veto (obligation 3.1 residual) blocked — repo not in session scope" +gossamer = "External: #83 Android migration (gossamer Idris2+Zig shell); also owns the device-side BLE scan glue that feeds bt-presence" +conative-gating = "External: #103 egress GO/NO-GO veto (obligation 3.1 residual)" +burble-phase1 = "External (runtime-only, non-blocking for build): burble's Android client that EMITS the presence beacon is not yet shipped (ADR-0015 Proposed; no client/android). bt-presence is built + pinned to the frozen v1 format, ready to receive once burble emits." diff --git a/docs/BT-PRESENCE-PLAN.adoc b/docs/BT-PRESENCE-PLAN.adoc index f4c0f78..90f5877 100644 --- a/docs/BT-PRESENCE-PLAN.adoc +++ b/docs/BT-PRESENCE-PLAN.adoc @@ -14,39 +14,46 @@ cross-repo Bluetooth integration. This document covers only the neurophone side. [IMPORTANT] ==== -*Upstream status — BLOCKED (ground-truthed 2026-07-09 against burble @ `0926a15`).* - -NeuroPhone's Phase-2 work cannot begin: every burble-side artifact this plan -consumes is still design-only in burble, and burble has not completed its own -Phase 0. - -* `Burble.ABI.NearbyPresence` (Idris2 wire type) — *absent* from burble's ABI - directory and from its `burble-abi.ipkg` module list; it appears only as a - planned "NEW" line in burble's `ANDROID-CLIENT.adoc`. -* `nearby-presence.a2ml` (wire format) — *does not exist* in burble, at any path. -* burble's Android client (which must emit the advertisement) — *does not exist* - (`client/android/` is absent; burble's doc says "Design only — no code yet"). -* burble `docs/decisions/0010-presence-discovery-and-trust-zones.adoc` is - `Status: Proposed` (2026-07-08) and states "Nothing here is built yet"; there - is no defined advertisement UUID or byte layout — only a prose knock-packet - sketch, explicitly *not* a continuous advertisement. - -No NeuroPhone codegen or vendored schema can be created against a format that has -never been authored. This plan resumes when burble authors and freezes the -advertisement format. Full audit: link:governance/SIBLING-INTEGRATIONS.adoc[SIBLING-INTEGRATIONS.adoc]. +*Upstream status — Phase 0 DONE; Phase-2 consumer IMPLEMENTED; Phase-1 emitter still pending (ground-truthed 2026-07-10 against burble @ `2b5914b`).* + +burble *froze the BLE presence wire format v1* on 2026-07-09 (PR #154, ADR-0015), +which unblocked NeuroPhone's build-time consumer work. The artefacts this plan +depends on now exist upstream and are frozen: + +* `Burble.ABI.NearbyPresence` (Idris2 wire type) — *exists*, now listed in + burble's `burble-abi.ipkg` module list. +* `.machine_readable/descriptiles/nearby-presence.a2ml` (wire format) — *exists*, + `status = "frozen"`, wire-version 1, with a complete 24-byte layout and a freeze + covenant. +* Frozen conformance vectors (`ble-spa-v1.json`) and an Elixir reference impl + (`ble_spa.ex`) — *exist*. + +*NeuroPhone's Phase-2 consumer is now implemented* in `crates/bt-presence/`: a +byte-exact decoder + contact resolution + presence decay, with the wire constants +code-generated from a vendored byte-exact copy of the frozen spec and validated +against burble's frozen vectors (`presence_beacon_id_is_byte_exact`). The decoded +presence score enters the pipeline via a new `SensorKind::Presence` channel. + +*What still blocks end-to-end runtime* is **Phase 1** (burble-side): burble does +not yet ship the Android client that *emits* the beacon (`client/android/` is +absent), and ADR-0015 is `Status: Proposed`. So there is nothing on the air to +receive yet — but the consumer is built, tested, and pinned to the frozen v1 +format, ready for the emitter. Full audit: +link:governance/SIBLING-INTEGRATIONS.adoc[SIBLING-INTEGRATIONS.adoc]. *Path correction.* Earlier revisions of this doc pointed the codegen at `burble/.machine_readable/6a2/nearby-presence.a2ml`. That was wrong: `6a2/` is -*NeuroPhone's* machine-readable namespace, not burble's. burble's planned -location is `.machine_readable/descriptiles/nearby-presence.a2ml`. The citations -below have been corrected. +*NeuroPhone's* machine-readable namespace, not burble's. The frozen file lives at +`.machine_readable/descriptiles/nearby-presence.a2ml` (vendored into +`crates/bt-presence/vendor/`). The citations below have been corrected. *Toolchain correction.* The Kotlin scanner files in the file-by-file plan below (`*.kt`) predate the gossamer Android migration (#83) and NeuroPhone's current -language policy, which bans Kotlin/Java for mobile. When burble unblocks, the -Android-side scanner must be expressed through the gossamer surface (Rust/JNI), -not reintroduced as Kotlin. The `.kt` entries are retained only as a -functional sketch of the scanner's responsibilities. +language policy, which bans Kotlin/Java for mobile. The implemented decode/decay +core is pure Rust (`crates/bt-presence/`); the remaining device-side scan glue +must be expressed through the gossamer surface (Rust/JNI), not reintroduced as +Kotlin. The `.kt` entries are retained only as a functional sketch of the +scanner's responsibilities. ==== == Scope diff --git a/docs/governance/SIBLING-INTEGRATIONS.adoc b/docs/governance/SIBLING-INTEGRATIONS.adoc index 8a6e709..a8b4dc3 100644 --- a/docs/governance/SIBLING-INTEGRATIONS.adoc +++ b/docs/governance/SIBLING-INTEGRATIONS.adoc @@ -38,8 +38,8 @@ upstream design doc. | Pre-alpha design corpus — zero implementation, no ABI/schema/SDK, no domain overlap. | `burble` -| BLOCKED (upstream Phase 0) -| Real future integration (BLE nearby-presence sensor), but every burble-side artifact NeuroPhone would consume is still design-only. +| CONSUMER IMPLEMENTED (Phase-1 emitter pending) +| burble froze the presence wire format v1 (ADR-0015, 2026-07-09). NeuroPhone's byte-exact consumer now ships in `crates/bt-presence/`, validated against burble's frozen vectors. Only burble's Android *emitter* (Phase 1) is still unbuilt. | `panll` | PRECEDENT-ONLY @@ -108,53 +108,67 @@ future tie is a conceptual analogy between cleave's (unbuilt) FFI-dial "kernel" and NeuroPhone's JNI/FFI boundary — an analogy, not an integration, and it cannot be specified until cleave writes its kernel. -== burble — BLOCKED (upstream Phase 0 incomplete) +== burble — CONSUMER IMPLEMENTED (Phase-1 emitter pending) *What it is.* A self-hostable voice-first communications platform. Relevant to -NeuroPhone only via a planned Bluetooth Low Energy *nearby-presence* signal that -NeuroPhone would consume read-only as one more sensor input (see -`docs/BT-PRESENCE-PLAN.adoc`). NeuroPhone's work begins at the cross-repo plan's -Phase 2 and depends on burble finishing Phases 0–1 first. +NeuroPhone via a Bluetooth Low Energy *nearby-presence* signal that NeuroPhone +consumes read-only as one more sensor input (see `docs/BT-PRESENCE-PLAN.adoc`). -*Ground truth — every consumed artifact is still PLANNED, not present:* +*What changed (2026-07-09/10).* This section previously recorded burble as +BLOCKED with every consumed artefact design-only. That flipped: burble PR #154 +(`2b5914b`) *froze the BLE presence wire format v1* under ADR-0015 and explicitly +"unblock[ed] neurophone." The artefacts now exist upstream and are frozen, and +NeuroPhone's Phase-2 consumer is now implemented and vector-validated. [cols="2,3,1",options="header"] |=== -| Artifact NeuroPhone's plan names | Reality in `burble` @ `0926a15` | Status +| Artifact NeuroPhone's plan names | Reality in `burble` @ `2b5914b` | Status -| `Burble.ABI.NearbyPresence` Idris2 wire type (burble tree) -| Absent from the ABI dir and from the `burble-abi.ipkg` module list; appears only as a "NEW" line in burble's `docs/architecture/ANDROID-CLIENT.adoc` -| PLANNED +| `Burble.ABI.NearbyPresence` Idris2 wire type +| Present; listed in `burble-abi.ipkg` module list +| EXISTS (frozen) -| `nearby-presence.a2ml` (wire format) -| Does not exist. NeuroPhone's plan cited `.machine_readable/6a2/…` — but `6a2/` is *NeuroPhone's* namespace; burble uses `descriptiles/`, and the file is absent there too -| PLANNED (+ wrong path) +| `.machine_readable/descriptiles/nearby-presence.a2ml` (wire format) +| Present; `status = "frozen"`, wire-version 1, full 24-byte layout + freeze covenant +| EXISTS (frozen) -| burble Android client emitting the advertisement (Phase-1 precondition) -| No `client/android/` exists; `ANDROID-CLIENT.adoc`: "Design only — no code yet" -| PLANNED +| Frozen conformance vectors + reference impl +| `.machine_readable/test-vectors/ble-spa-v1.json` + `server/lib/burble/presence/ble_spa.ex` +| EXISTS (frozen) -| `server/lib/burble/bridges/neurophone.ex` -| No `bridges/` directory exists -| PLANNED +| Defined advertisement byte layout +| `magic(0x42) | ver_type(0x12) | epoch:u32BE | HMAC-SHA256(secret,…)[0..18]`, company id `0xFFFF` +| DEFINED (frozen) -| Defined advertisement UUID / byte layout -| None defined anywhere; only a prose knock-packet sketch, explicitly *not* a continuous advertisement -| UNDEFINED +| burble Android client *emitting* the beacon (Phase-1 precondition) +| No `client/android/` yet; ADR-0015 is `Status: Proposed` +| STILL PENDING |=== -The governing decision, burble `docs/decisions/0010-presence-discovery-and-trust-zones.adoc`, -is `Status: Proposed` (dated 2026-07-08) and states "Nothing here is built yet." -burble's own `PRODUCTION-BLOCKER-HANDOFF.adoc` flags the Android BLE design as -internally contradictory and unbuilt. - -*Disposition.* The honest NeuroPhone-side artifact today is a *blocked-on note*, -not codegen and not a vendored schema — you cannot vendor or generate against a -wire format that has never been authored. `docs/BT-PRESENCE-PLAN.adoc` has been -annotated with this upstream status and two corrections (the `6a2/`→`descriptiles/` -path error, and the now-superseded Kotlin scanner plan, which must follow the -gossamer migration per #83 rather than reintroduce banned Kotlin). NeuroPhone -resumes Phase 2 the moment burble authors and freezes the advertisement format. +*NeuroPhone-side implementation (this repo).* `crates/bt-presence/` is a +host-testable, pure-Rust consumer of the frozen v1 beacon: + +* `build.rs` code-generates the wire constants from a byte-exact vendored copy of + burble's frozen `nearby-presence.a2ml` (`vendor/`, provenance pinned to commit + `2b5914b` + sha256), and *fails the build loudly* if the vendored spec is not a + frozen v1 — no hand-copied protocol numbers, no silent fork. +* `src/decode.rs` decodes the 24-byte frame, derives the epoch (`unix/900`), and + resolves the beacon against held contact secrets by reproducing + `HMAC-SHA256(secret, "BRBL-PRES-v1" ‖ magic ‖ ver_type ‖ epoch)[0..18]` + (constant-time compare, ±1 epoch freshness window). +* `src/decay.rs` turns intermittent sightings into a smooth `0.0..=1.0` presence + score (RSSI-reinforced, exponential decay). +* `tests/vectors.rs` replays burble's frozen vectors and asserts the beacon id is + *byte-exact* with burble's independent oracle (`presence_beacon_id_is_byte_exact`). +* The score enters the pipeline through a new `sensors::SensorKind::Presence` + channel — sensor-class only, no microphone, no outbound advertising. + +*Disposition.* Consumer implemented and pinned to the frozen format; the +integration is *wired and tested* on the NeuroPhone side. End-to-end runtime still +waits on burble Phase 1 (the Android *emitter*), which does not block NeuroPhone's +build — there is simply nothing on the air to receive until burble ships it. The +remaining device-side scan glue must ride the gossamer surface (Rust/JNI) per #83, +not Kotlin. == panll — PRECEDENT-ONLY