Skip to content

feat: add authenticated BLE and Channel Sounding evidence - #5

Merged
ruvnet merged 2 commits into
mainfrom
feat/ble-field-evidence
Aug 24, 2026
Merged

feat: add authenticated BLE and Channel Sounding evidence#5
ruvnet merged 2 commits into
mainfrom
feat/ble-field-evidence

Conversation

@ruvnet

@ruvnet ruvnet commented Aug 24, 2026

Copy link
Copy Markdown
Owner

Summary

Adds the governed RuField contract for authenticated BLE advertisement evidence and external Bluetooth Channel Sounding measurements.

  1. Adds ble_advertisement_rssi as an additive modality without renumbering existing codes.
  2. Adds typed identity evidence with deployment scoped pseudonyms, short expiry, enrollment receipts, source sequence, token epoch, and explicit P5 classification.
  3. Adds complete Channel Sounding procedure provenance with exact step, channel, source session, gateway boot scope, and timing checks.
  4. Partitions fusion by anonymous track so breathing evidence and identity evidence cannot cross tracks.
  5. Adds production allowlisting for exact device and Ed25519 signer pairs.
  6. Adds a deterministic two person crossing scenario with spoof, expiry, replay, incomplete procedure, and mixed context abstentions.
  7. Records the decision and threat model in ADR 261.

Capability boundaries

  1. BLE RSSI is proximity evidence, not coherent phase, exact range, or human identity.
  2. ESP32 S3 is not claimed to support Bluetooth Channel Sounding or raw CTE IQ.
  3. Normal phone advertisements are not durable identity credentials.
  4. Raw Channel Sounding phase remains P0 at the governed edge.
  5. Synthetic fixtures require an explicit test policy and production rejects them.

Validation

cargo fmt --all -- --check
cargo test --workspace --all-targets
cargo clippy --workspace --all-targets -- -D warnings

The crossing acceptance tests require byte identical replay, two stable pseudonym to track bindings, explicit spoof and expiry abstentions, no cross track breathing fusion, and fail closed handling for incomplete, duplicate, mixed, or oversized Channel Sounding procedures.

Related work

  1. rvCSI PR: feat: add BLE CSI fusion evidence simulation rvcsi#3
  2. RuView PR: feat: add authenticated ESP32 BLE fusion path RuView#1696

Hardware status

This PR validates contracts, simulation, provenance, and policy. It does not claim physical radio accuracy or clinical performance. Hardware acceptance remains gated on a real Channel Sounding companion, repeated crossings, coexistence measurements, and reference respiration instrumentation.

@ruvnet

ruvnet commented Aug 24, 2026

Copy link
Copy Markdown
Owner Author

Reviewed. The trust model here is genuinely good — the abstention chain is exhaustive and fail-closed, replay requires strictly-increasing sequences, capacity is bounded before insert, and TrackOccupied stops identity evidence crossing tracks. I found one blocking issue and two smaller notes.

Blocking: an unverified advertisement can permanently disable identity evidence

promote() advances the stream watermark and evicts bindings before it examines trust:

self.watermark_ns = self.watermark_ns.max(sample.timestamp_ns);   // unconditional
self.active.retain(|_, b| b.expires_ns > self.watermark_ns);      // evicts everything
self.last_sequence.retain(...);

let reason = if ... 
    else if matches!(&sample.trust, BleAnchorTrust::Unverified) { Some(Unverified) }   // too late

next_event pops every sample straight into promote, and nothing bounds timestamp_ns. So a single Unverified (or Revoked) advertisement carrying a far-future timestamp advances the watermark, evicts every active binding, and pins the clock high — after which every subsequent legitimate sample fails timestamp_ns + ttl_ns <= watermark_ns and abstains as Expired.

The sample is correctly rejected; the damage is done before the rejection.

Repro (added to ble::tests, fails on this branch)

Enrolled sample at t=100 → one Unverified sample at u64::MAX / 2 → legitimate sample at t=200:

test ble::tests::untrusted_future_timestamp_evicts_every_active_binding ... FAILED
REPRO: one unverified far-future advertisement advanced the watermark and expired
every subsequent legitimate sample. abstentions=[Unverified, Expired]

[Unverified, Expired] is the whole story: the hostile packet was refused, and the next honest one was refused too.

This matters more than a normal ordering bug because untrusted advertisements are precisely the input this module exists to filter — the attacker needs no key, no enrollment, and no valid signature, just one broadcast frame.

Suggested fix

Move the watermark update and both retain calls after the abstention gate, so only a sample that survives policy advances time. If out-of-order arrival needs to keep working, clamp instead: reject any timestamp_ns beyond a bounded skew from the current watermark, as its own abstention reason, before it can move the clock.

Either way it deserves a regression test — the repro above works as one, inverted.

Minor: unreachable! in the trust path

let BleAnchorTrust::Enrolled { binding_receipt_id } = sample.trust.clone() else {
    unreachable!("non-enrolled states abstain above");
};

Correct today, because the chain abstains on both other variants. But it is an invariant the compiler does not check: adding a fourth BleAnchorTrust variant compiles cleanly and turns this into a panic rather than an abstention — failing open into a crash instead of closed into a refusal. A match with an explicit _ => abstain(Malformed) arm costs nothing and makes the exhaustiveness the compiler's problem.

Minor: the PR description mentions allowlisting

Point 5 claims "production allowlisting for exact device and Ed25519 signer pairs," but I could not find an allowlist in ble.rs. If it lives elsewhere, a pointer would help review; if it did not make this PR, the description should probably drop the claim.

Happy to re-review once the watermark ordering is addressed.

`promote()` advanced `watermark_ns` and evicted every expired binding at the
top of the function, before it examined the sample's trust. An advertisement
is attacker-supplied -- anyone can broadcast one, with no key, enrollment, or
signature -- so an `Unverified` or `Revoked` sample carrying a far-future
`timestamp_ns` pinned the clock and cleared `active` before being refused.
Every subsequent legitimate sample then failed
`timestamp_ns + ttl_ns <= watermark_ns` and abstained as `Expired`: one frame
permanently disabled identity evidence. The hostile packet was correctly
rejected; the damage happened before the rejection.

Split `promote` into two phases. Phase 1 decides everything derivable from the
sample alone -- shape, trust, confidence, and the self-contained half of the
TTL rule -- and abstains without touching shared state. Only a sample that
survives phase 1 advances the watermark and retires stale bindings; phase 2
then applies the watermark comparison and the state-dependent rules.

Precedence is unchanged. `Expired` splits across the two phases but stays in
its original position, so a sample failing several rules still reports the
same reason it did before; all 42 pre-existing adapter tests pass untouched.
The watermark comparison is also unaffected by the move: `max` makes the
watermark at least `timestamp_ns`, and with `ttl_ns > 0` already enforced,
`timestamp_ns + ttl_ns <= watermark` yields the same verdict whether or not
the sample itself is folded in first.

Also replaces the `unreachable!` guarding the enrolled-receipt destructure
with an explicit fail-closed abstention. It is total today because phase 1
abstains on every other variant, but that is an invariant of the chain rather
than one the compiler checks -- adding a `BleAnchorTrust` variant would
compile cleanly and turn a refusal into a panic.

Adds two regression tests, for `Unverified` and `Revoked`. Both fail on the
previous ordering with `abstentions=[Unverified, Expired]` -- the hostile
frame refused and the next honest one refused with it -- and pass here.

133 workspace tests pass; fmt and clippy -D warnings clean.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_016QSCkKnxDjqU49NVVpWMK5
@ruvnet

ruvnet commented Aug 24, 2026

Copy link
Copy Markdown
Owner Author

Fixed in a3af765, pushed to this branch.

The watermark ordering

promote() is now two phases. Phase 1 decides everything derivable from the sample alone — shape, trust, confidence, and the self-contained half of the TTL rule — and abstains without touching shared state. Only a sample that survives phase 1 advances the watermark and retires stale bindings; phase 2 then applies the watermark comparison and the state-dependent rules.

Two properties worth stating, because a reordering like this is easy to get subtly wrong:

Precedence is unchanged. Expired splits across the phases but keeps its original position, so a sample failing several rules reports the same reason it did before. All 42 pre-existing adapter tests pass untouched.

The watermark comparison is unaffected by the move. max makes the watermark at least timestamp_ns, and ttl_ns > 0 is already enforced, so timestamp_ns + ttl_ns <= watermark gives the same verdict whether or not the sample itself is folded in first. Folding self in was always a no-op for that test.

Regression tests

Two, covering Unverified and Revoked. I checked they actually fail on the old ordering rather than passing either way:

test an_untrusted_far_future_sample_cannot_expire_legitimate_bindings ... FAILED
test a_revoked_far_future_sample_cannot_expire_legitimate_bindings ... FAILED
abstentions=[Unverified, Expired]

They also assert the hostile frame is still refused, and refused as Unverified — the fix must not buy availability by loosening the gate.

unreachable!

Replaced with an explicit fail-closed abstention, for the reason in the earlier comment: total today, but by an invariant of the chain rather than one the compiler enforces.

Correction to my earlier review

I said I couldn't find the allowlist and suggested the description might be overclaiming. That was wrong — I looked in the wrong file. It's in rufield-fusion/src/engine.rs as BleTrustPolicy, and it is properly fail-closed: production() starts with an empty allowlist and synthetic denied, a missing signer_pubkey_hex is an error, a device/key pair not on the list is an error, and Default is production(). Point 5 of the description is accurate. Apologies for the noise.

Validation: 133 workspace tests pass, cargo fmt --check and clippy --workspace --all-targets -D warnings both clean.

@ruvnet
ruvnet merged commit 6e3dbd4 into main Aug 24, 2026
1 check passed
@ruvnet
ruvnet deleted the feat/ble-field-evidence branch August 24, 2026 17:14
ruvnet added a commit that referenced this pull request Aug 24, 2026
Textual conflict in rufield-core/src/lib.rs was a clean union -- #4 adds the
inference exports (AbstentionReason, CalibratedInference, CalibrationContext,
PredictionInterval, PredictionSet, UncertaintyEnvelope), main adds the event
exports from #5 (channel sounding, identity evidence). Each side's list is a
superset of the other's for its own module, so both were taken whole.

Three semantic conflicts that the textual merge could not see:

- rufield-interop and rufield-ruvector are new in #4 and match exhaustively on
  Modality, which #5 extended. Added the missing BleAdvertisementRssi arm to
  both, taking the wire string from Modality::as_str rather than retyping it --
  these functions feed serialization, so a divergent spelling would be a wire
  bug rather than a compile error.

- #5 added FieldInference::track_id; three fixtures predating it (two in
  rufield-uncertainty, one in rufield-core) construct the struct literally.
  They are not track-scoped, so None matches the field's own default.

- clippy manual is_multiple_of in rufield-ruvector's new folding code.
  Not a CI blocker -- CI runs -W clippy::all, not -D warnings -- but fixed
  while here.

184 workspace tests pass; fmt and clippy -D warnings clean.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_016QSCkKnxDjqU49NVVpWMK5
ruvnet added a commit that referenced this pull request Aug 24, 2026
…on (#4)

* feat: add governed evidence and uncertainty platform

* merge: resolve #4 against main after the BLE evidence merge

Textual conflict in rufield-core/src/lib.rs was a clean union -- #4 adds the
inference exports (AbstentionReason, CalibratedInference, CalibrationContext,
PredictionInterval, PredictionSet, UncertaintyEnvelope), main adds the event
exports from #5 (channel sounding, identity evidence). Each side's list is a
superset of the other's for its own module, so both were taken whole.

Three semantic conflicts that the textual merge could not see:

- rufield-interop and rufield-ruvector are new in #4 and match exhaustively on
  Modality, which #5 extended. Added the missing BleAdvertisementRssi arm to
  both, taking the wire string from Modality::as_str rather than retyping it --
  these functions feed serialization, so a divergent spelling would be a wire
  bug rather than a compile error.

- #5 added FieldInference::track_id; three fixtures predating it (two in
  rufield-uncertainty, one in rufield-core) construct the struct literally.
  They are not track-scoped, so None matches the field's own default.

- clippy manual is_multiple_of in rufield-ruvector's new folding code.
  Not a CI blocker -- CI runs -W clippy::all, not -D warnings -- but fixed
  while here.

184 workspace tests pass; fmt and clippy -D warnings clean.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_016QSCkKnxDjqU49NVVpWMK5
ruvnet added a commit that referenced this pull request Aug 24, 2026
… main (#9)

Re-creates #1 against a tree that exists. That PR shares no commit ancestry
with `main` -- main's history was rewritten after it opened -- so merging it
would have needed --allow-unrelated-histories and conflicted on 33 files
wholesale, including the four that #5 and #7 had just changed. This carries the
feature across instead of grafting the branch.

Wire code moved from 16 to 17. #1 assigned `Modality::QuantumRf => 16`, which
`main` now gives to `BleAdvertisementRssi` (#5). Two modalities cannot share a
code, and a renumber is not a compile error -- it is a deployed decoder reading
the wrong modality -- so 16 stays where it was published and quantum RF takes
the next free code. `recent_wire_codes_are_pinned` asserts both by value, so a
future edit cannot quietly swap them.

Carried across unchanged (13 files, none of which exist on main):
  rufield-adapters: quantum_rf_{quality,replay,support,wire}.rs, three test
  suites, and the synthetic replay fixture
  rufield-fusion:   bearing.rs, bearing_math.rs, bearing_trust.rs, and the
  quantum_bearing test suite
  docs/ADR-270-quantum-rf-vector-sensing.md

Re-applied by hand, because these files exist on main and could not be taken
from the branch without reverting recent work:
  - `Modality::QuantumRf` (code 17, `quantum_rf`) plus the registry contract
    tests, which correctly refused the addition until updated.
  - `FieldAxis::{CartesianComponent, ComplexComponent, DirectionCandidate}`.
  - Optional sensor pose on `SensorDescriptor` -- coordinate_frame, position_m,
    orientation_xyzw -- all `#[serde(default, skip_serializing_if)]`, so events
    that omit them round-trip unchanged.
  - `Observation::attributes`, likewise absent from the wire when empty.
  - `normalize_verifying_key_hex` and `verifying_key_from_hex` in
    rufield-provenance.

`SensorDescriptor` loses its `Eq` derive: the pose carries f32 coordinates and
float equality is not an equivalence relation. `PartialEq` is retained.

ADR renumbered 266 -> 270. 266 is taken on main by field-evidence-promotion
(#4).

255 workspace tests pass, 38 of them from the ported suites (13 replay, 23
bearing, 2 properties; the performance gate stays #[ignore]d by its author,
requiring a release build). fmt and clippy -D warnings clean.


Claude-Session: https://claude.ai/code/session_016QSCkKnxDjqU49NVVpWMK5
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant