Iter 36. Locks down the ADR-119 §2.1 forward-compat promise that
reserved flag bits round-trip unchanged through the parser. A future
protocol revision may light up bits 2 or 4..=15; today's parser
preserves them so a node running iter N can forward unknown bits to
a peer running iter N+M without losing information.
Added (in src/frame.rs::flags):
- pub const KNOWN_FLAGS_MASK = HAS_CSI_DELTA | PRIVACY_MODE | SELF_ONLY
(the three currently-named flags, occupying bits 0, 1, 3)
- pub const RESERVED_FLAGS_MASK = !KNOWN_FLAGS_MASK
(bit 2 + bits 4..=15 — every position not currently assigned)
- Docstrings reference ADR-119 §2.1 verbatim so a future reviewer
understands why the constants exist.
tests/reserved_flags.rs (8 named tests, all green, no_std-compatible
so they run in BOTH feature configs):
known_flags_mask_covers_exactly_three_named_flags
(count_ones() == 3 catches accidental flag additions that should
also update KNOWN_FLAGS_MASK)
reserved_and_known_masks_are_complementary
(mask | reserved == u16::MAX; mask & reserved == 0)
known_flags_do_not_overlap_with_each_other
(HAS_CSI_DELTA, PRIVACY_MODE, SELF_ONLY all on distinct bits)
header_preserves_reserved_flag_bits_through_round_trip
*** Headline test: set RESERVED_FLAGS_MASK on a header, serialize,
parse, verify the bits survived. ***
header_preserves_mixed_known_and_reserved_bits
(HAS_CSI_DELTA | PRIVACY_MODE | (1<<7) | (1<<14) — mixed case)
reserved_bits_do_not_collide_with_self_only_bit_3
(bit 2 is reserved but bit 3 is named — pins the asymmetry)
all_zero_flags_round_trip_cleanly
all_one_flags_round_trip_cleanly (stress: every bit set)
The new tests are no_std-compatible (no Vec / no serde) so they run
in both `cargo test --no-default-features` and default feature
configs. The no_default test count therefore jumps from 72 to 80.
ADR-124 status (iter step 0 sibling check):
- docs/adr/ADR-124-rvagent-mcp-ruvector-npm-integration.md unchanged
at 431 lines. SENSE-BRIDGE scope remains orthogonal.
ACs progressed:
- ADR-119 §2.1 "Reserved flag bits 2-15 lock in future-extension
order; any new bit assignment is a version bump." — the test now
enforces the OTHER half of this contract: a peer running the
future version can set a reserved bit and our parser will preserve
it through the round-trip rather than masking it off.
Test config:
- cargo test --no-default-features → 80 passed (72 + 8 no_std-compat)
- cargo test → 243 passed (235 + 8)
Out of scope (next iter target):
- PR-readiness pivot: witness bundle regeneration, CHANGELOG batch
across iters 1-36, AC closeout table for the PR description.
All in-crate ACs are now covered; remaining work is either
external-resource-gated (KIT BFId, Pi5/Nexmon) or PR-prep.
Co-Authored-By: claude-flow <ruv@ruv.net>
Iter 34. Closes the gap where BfldPipelineHandle had no path for an
operator-supplied SoulMatchOracle to reach the worker thread. The
emit_with_oracle surface added in iter 14 was unreachable through the
handle API — Soul Signature deployments (ADR-118 §1.4) had to either
drop down to BfldEmitter directly or accept Recalibrate gate-drops on
known-enrolled matches.
Added (in src/pipeline.rs):
- BfldPipeline::process_with_oracle<O: SoulMatchOracle>(
inputs, embedding, oracle,
) -> Option<BfldEvent>
Wraps emitter.emit_with_oracle then applies the same privacy_mode
post-processing as process(). Privacy_mode and oracle are independent
— class-3 demote still happens AFTER any oracle Recalibrate exemption.
Added (in src/pipeline_handle.rs):
- BfldPipelineHandle::spawn_with_oracle<P, O>(pipeline, publisher, oracle) -> Self
where O: SoulMatchOracle + Send + Sync + 'static
The worker thread owns the oracle and consults it on every recv().
Worker loop now calls pipeline.process_with_oracle(...) instead of
pipeline.process(...).
tests/handle_soul_oracle.rs (3 named tests, all green):
spawn_with_oracle_null_is_equivalent_to_spawn
Parity: 3 identical low-risk inputs through spawn() and
spawn_with_oracle(NullOracle) produce the same publish count
and the same motion-topic count.
spawn_with_always_match_oracle_lets_events_publish_under_high_risk
*** Headline test ***
3 high-risk inputs spaced > DEBOUNCE_NS apart. With AlwaysMatch
oracle, all 3 produce motion topics — the gate never reaches
Recalibrate because the oracle reports an enrolled-person match.
spawn_with_null_oracle_drops_events_under_sustained_recalibrate_score
Negative control for the above: same 3 inputs through NullOracle,
only 1 motion topic survives (the first input lands at Accept;
the second and third hit Recalibrate after debounce and are
dropped per ADR-121 §2.4).
ADR-124 status (iter step 0 sibling check):
- docs/adr/ADR-124-rvagent-mcp-ruvector-npm-integration.md unchanged
at 431 lines. SENSE-BRIDGE scope remains orthogonal to BFLD core;
no overlap with this iter.
ACs progressed:
- ADR-118 §1.4 Soul Signature companion contract end-to-end through
the public handle API. Operators wiring Soul Signature into a
RuView deployment now use:
BfldPipelineHandle::spawn_with_oracle(pipeline, publisher, my_oracle)
…and the rest of the per-frame flow stays identical to spawn().
- ADR-121 §2.6 Recalibrate exemption proven over the worker-thread
boundary, not just at the unit level (iter 12 covered the gate-only
case).
Test config:
- cargo test --no-default-features → 72 passed
- cargo test → 227 passed (224 + 3)
Out of scope (next iter target):
- GitHub Actions workflow with mosquitto Docker (lifts iters 24+29
live-broker e2e from skip-mode). Remaining unmet ACs require
either external resources (KIT BFId, Pi5/Nexmon) or CI infra.
Co-Authored-By: claude-flow <ruv@ruv.net>
Iter 29. Wires rumqttc::MqttOptions::set_last_will so the broker
auto-publishes "offline" on ruview/<node>/bfld/availability (retained,
QoS 1) when the publisher's TCP session drops without a clean
DISCONNECT. Closes the iter-28 lifecycle loop: explicit "online" on
connect + LWT-driven "offline" on session loss + explicit "offline"
on graceful shutdown.
Added (in src/rumqttc_publisher.rs, gated on `feature = "mqtt"`):
- RumqttPublisher::connect_with_lwt(node_id, opts, capacity) -> (Self, Connection)
Convenience wrapping with_lwt(opts, node_id) then Self::connect(opts, capacity).
- with_lwt(opts, node_id) -> MqttOptions free helper for operators who
build their own opts (custom TLS, credentials) and want to opt in to
the LWT without using the connect_with_lwt shortcut.
- rumqttc 0.24 LastWill::new(topic, message, qos, retain) — 4-arg form;
retain = true so HA sees "offline" on next start even if it was down
when the session dropped.
- pub use with_lwt, RumqttPublisher from lib.rs
tests/rumqttc_lwt.rs (8 named tests, all green, gated on mqtt):
with_lwt_returns_options_without_panic
connect_with_lwt_constructs_publisher_and_connection
connect_with_lwt_uses_documented_availability_topic
(constructive proof — both LWT and discovery use the same
availability_topic() function so they can't drift)
connect_with_lwt_publisher_still_publishes_state_topics
(LWT is purely additive — state topics work as before)
publisher_trait_object_constructible_with_lwt_path
with_lwt_is_idempotent_against_double_call
(rumqttc replaces the will silently — useful for wrapper libraries)
caller_built_options_can_opt_in_via_with_lwt_then_pass_to_connect
(operator pattern: build opts with TLS/creds, attach LWT, then connect)
placeholder_topicmessage_path_unaffected_by_lwt
Test bug caught:
- Initial test asserted 4 topics for Anonymous + no zone; actual is 5
(presence + motion + person_count + confidence + identity_risk).
rf_signature_hash is a BfldEvent JSON field, not its own MQTT topic.
Fixed the assertion; documented the distinction in the test comment.
ACs progressed:
- ADR-122 §2.2 availability surface now fully operational. Three paths:
1. Explicit publish_availability_online (iter 28) on connect
2. LWT auto-publishes "offline" if connection drops (this iter)
3. Explicit publish_availability_offline (iter 28) on graceful stop
HA reads the same topic in all three cases; entities grey out
device-wide via the iter-28 discovery `availability_topic` field.
Test config:
- cargo test --no-default-features → 72 passed
- cargo test → 203 passed
- cargo test --features mqtt → 220 passed (212 + 8 new)
Out of scope (next iter target):
- GitHub Actions workflow with mosquitto Docker service. With iter
24+29 now both depending on a live broker for full coverage, the
CI lift is the next highest-value step.
- Three operator-ready HA blueprints (ADR-122 §2.6): presence-driven
lighting, motion-aware HVAC, identity-risk anomaly notification.
Co-Authored-By: claude-flow <ruv@ruv.net>
Iter 28. Closes the per-node lifecycle on the MQTT side: HA can now
distinguish a node that is healthy + publishing zero events (nothing
detected) from a node that has lost the broker connection. Discovery
payloads now reference the availability topic so every entity inherits
the device-level offline marker.
Added (gated on `feature = "std"`):
- src/availability.rs:
* PAYLOAD_AVAILABLE = "online", PAYLOAD_NOT_AVAILABLE = "offline"
* availability_topic(node_id) -> "ruview/<node>/bfld/availability"
* online_message / offline_message constructors returning TopicMessage
* publish_availability_online / publish_availability_offline
bootstrap helpers through Publish trait
- pub use the full availability surface from lib.rs
Discovery integration (src/ha_discovery.rs):
- Every entity config payload now carries:
"availability_topic": "ruview/<node>/bfld/availability"
"payload_available": "online"
"payload_not_available": "offline"
HA uses these to grey out entities device-wide when the broker LWT
fires or the node explicitly publishes "offline" during shutdown.
tests/availability_topic.rs (10 named tests, all green):
availability_topic_format_matches_documented_path
online_message_is_retained_friendly_payload
offline_message_is_retained_friendly_payload
publish_online_lands_one_message
publish_offline_lands_one_message
discovery_payload_includes_availability_topic_field
(all 6 Anonymous-class discovery payloads carry the field)
discovery_payload_includes_payload_available_and_not_available_strings
restricted_class_discovery_still_carries_availability_fields
(availability is not an identity field; class 3 retains it)
bootstrap_sequence_online_then_discovery_lands_in_order
*** End-to-end bootstrap proof: publish_availability_online +
publish_discovery produces 1 + 6 = 7 messages, "online"
first, six homeassistant/.../config payloads after. ***
graceful_shutdown_sequence_publishes_offline_message_last
ACs progressed:
- ADR-122 §2.2 — availability topic now in place. Operators get HA
online/offline indication without configuring LWT explicitly on
rumqttc — the offline_message constructor + publish_availability_offline
cover the explicit-shutdown path. Real LWT wiring (rumqttc's
MqttOptions::set_last_will) is a follow-up.
- ADR-122 AC1 + AC4 — discovery now includes availability_topic, which
HA needs to render the device as a unit; iter-26 tests continue to
pass with the augmented payload (verified by full-suite count: 187 + 10).
Test config:
- cargo test --no-default-features → 72 passed (availability cfg-out)
- cargo test → 203 passed (193 + 10)
Out of scope (next iter target):
- Wire rumqttc::MqttOptions::set_last_will(...) so the broker
auto-publishes "offline" when the TCP session drops; needs a small
helper on RumqttPublisher to build options with LWT pre-configured.
- GitHub Actions workflow with mosquitto Docker so iter-24 live test
runs in CI.
Co-Authored-By: claude-flow <ruv@ruv.net>
Iter 27. The free function that closes the discovery ↔ state loop on
the publishing side. Mirrors publish_event from iter 22 but for the
HA-DISCO config payloads from iter 26.
Added (in src/ha_discovery.rs, gated on `feature = "std"`):
- publish_discovery<P: Publish>(publisher, node_id, class) -> Result<usize, P::Error>
Renders the per-class discovery payloads (iter 26) and forwards
each through publisher.publish(). Returns the count or short-
circuits on first error.
Docstring documents the canonical bootstrap pattern: separate
retain-true publisher for discovery, retain-false publisher for state,
both sharing the same broker connection if desired.
- pub use publish_discovery from lib.rs
tests/ha_discovery_publish.rs (6 named tests, all green):
publish_discovery_returns_six_for_anonymous_class
publish_discovery_returns_five_for_restricted_class
(no identity_risk in captured topics)
publish_discovery_returns_zero_for_raw_and_derived
(HA-DISCO + class gating composition: raw / derived never
advertised to HA)
publish_discovery_topics_are_homeassistant_config_format
publish_discovery_short_circuits_on_publisher_error
(FailingPub fails on 4th publish; first 3 messages land, then error)
bootstrap_pattern_publishes_discovery_then_state_through_shared_publisher
*** End-to-end bootstrap proof: one Arc<Mutex<CapturePublisher>>
used for both discovery (publish_discovery) and state
(BfldPipelineHandle::spawn + send). Asserts:
- 6 + 5 = 11 messages captured in order
- First 6 topics are homeassistant/.../config
- Next 5 topics are ruview/<node>/bfld/.../state
Validates the iter-25 Arc<Mutex<P>> Publish adapter + iter-26
discovery + iter-27 bootstrap helper compose correctly. ***
ACs progressed:
- ADR-122 §2.1 — bootstrap surface complete. Operator writes one
publish_discovery call at startup, then BfldPipelineHandle::send for
every frame. HA finds the device on first restart after discovery
was retained on the broker.
- ADR-122 AC1 (six entities per node) — discovery and state phases
share the same six-entity definition; the bootstrap test proves they
reach the broker in the documented order.
Test config:
- cargo test --no-default-features → 72 passed (publish_discovery cfg-out)
- cargo test → 193 passed (187 + 6)
Out of scope (next iter target):
- GitHub Actions workflow with mosquitto Docker service. Without this
the iter-24 live integration test stays in skip mode in CI; with it,
every PR would prove the full publish_discovery + handle stack works
end-to-end against a real broker.
- HA blueprint shipping (ADR-122 §2.6): three operator-ready YAML
blueprints (presence-driven lighting / motion-aware HVAC / identity-
risk anomaly notification) packaged in cog-ha-matter/blueprints/.
Co-Authored-By: claude-flow <ruv@ruv.net>
Iter 26. Lands ADR-122 §2.1 HA-DISCO config-message generator.
Counterpart to iter 21's state-topic router: this produces the
homeassistant/<type>/<unique_id>/config messages HA reads on
startup to auto-create the six BFLD entities as a single device.
Discovery payloads are intended to be published once per node
session with retain = true (so HA finds them on subsequent starts).
The RumqttPublisher from iter 23 already exposes with_retain(true)
for this purpose; the state-topic loop must keep retain = false to
avoid stale-state flapping.
Added (gated on `feature = "std"`):
- src/ha_discovery.rs:
* render_discovery_payloads(node_id, class) -> Vec<TopicMessage>
class < Anonymous: empty vec (HA doesn't see raw/derived)
class == Anonymous: 6 entities incl. identity_risk
class == Restricted: 5 entities, no identity_risk
* Per-entity HA metadata:
presence binary_sensor, device_class: occupancy
motion sensor, entity_category: diagnostic
person_count sensor, unit_of_measurement: people
zone_activity sensor, entity_category: diagnostic
confidence sensor, entity_category: diagnostic
identity_risk sensor, entity_category: diagnostic
* Each payload carries:
name, unique_id, state_topic (pointing at the iter-21 path),
device block with identifiers / model: "BFLD" / manufacturer: "RuView"
* Manual JSON builder with minimal escape coverage — node_id is
ASCII alphanumeric + dash by convention; full escape via
serde_json is a follow-up if operator-controlled names ever land.
- pub use render_discovery_payloads from lib.rs
tests/ha_discovery.rs (10 named tests, all green):
raw_and_derived_classes_produce_no_discovery_payloads
anonymous_class_produces_six_discovery_payloads
restricted_class_omits_identity_risk_discovery
discovery_topic_format_matches_ha_convention
(validates all six homeassistant/.../config topics exist)
presence_payload_carries_occupancy_device_class
motion_payload_marked_as_diagnostic
person_count_payload_carries_unit_of_measurement
every_payload_contains_unique_id_and_state_topic_pointing_at_correct_state_topic
(the state_topic in the discovery payload must match the topic the
state-topic router from iter 21 actually publishes on — closes
the discovery↔state loop)
unique_id_matches_topic_segment
(the unique_id baked into the payload equals the topic segment so
HA dedupe works correctly across reboot/restart)
class_2_discovery_includes_identity_risk_explicitly
ACs progressed:
- ADR-122 §2.1 — HA auto-discovery surface now complete: an operator
can start mosquitto, publish-retained discovery once, and HA spins
up the entire BFLD device on next start with zero YAML config.
- ADR-122 AC1 (six entities per node) — discovery + state-topic
publishers are now symmetric: render_discovery_payloads emits the
same six entity definitions render_events emits state messages for.
- ADR-118 §1.5 — privacy_mode = Restricted strips identity_risk at
BOTH the discovery layer (entity not advertised to HA) AND the
state layer (no state messages). Two-layer defense.
Test config:
- cargo test --no-default-features → 72 passed (ha_discovery cfg-out)
- cargo test → 187 passed (177 + 10)
Out of scope (next iter target):
- HA discovery + state publish coordinator: a small function or
BfldPipelineHandle::publish_discovery(&mut self, retained: bool)
that calls render_discovery_payloads + publish_event(retained=true)
once at startup, then enters the per-frame loop.
- GitHub Actions workflow with mosquitto Docker service so the
iter-24 integration test runs in CI with BFLD_MQTT_BROKER set.
Co-Authored-By: claude-flow <ruv@ruv.net>
Iter 25. Single-call operator surface: spawn() takes a BfldPipeline and
a Publish impl, returns a handle whose send() enqueues sensing inputs
into a worker thread. The worker drives pipeline.process() then
publish_event() per input. Drop or shutdown() joins cleanly.
Added (gated on `feature = "std"`):
- src/mqtt_topics.rs: impl<P: Publish> Publish for Arc<Mutex<P>>
Lets a publisher owned by a worker thread remain inspectable from a
test or operator post-shutdown.
- src/pipeline_handle.rs:
* PipelineInput { inputs: SensingInputs, embedding: Option<...> }
* BfldPipelineHandle { sender, worker: Option<JoinHandle<()>> }
* spawn<P: Publish + Send + 'static>(pipeline, publisher) -> Self
Worker loop: recv() → pipeline.process() → publish_event(); errors
logged to stderr (single-frame failures must not kill the loop)
* send(PipelineInput) -> Result<(), SendError<...>>
* shutdown(self) — replaces sender with a dropped channel so worker
recv() returns Err(RecvError); join propagates worker panics
* Drop impl mirrors shutdown so forgotten handles still clean up
- pub use BfldPipelineHandle, PipelineInput from lib.rs
tests/pipeline_handle_worker.rs (8 named tests, all green):
handle_publishes_single_input (5 topics for Anonymous + no zone)
handle_publishes_multiple_inputs_in_order (3 × 5 = 15 topics)
handle_send_after_shutdown_errors
(compile-time witness: shutdown(self) consumes the handle so
post-shutdown send() is structurally impossible)
handle_drop_without_explicit_shutdown_joins_worker_cleanly
(validates the Drop path completes without hanging)
handle_honors_privacy_mode_toggle_via_pipeline_state
(4 topics for Restricted; identity_risk absent)
handle_drops_event_when_gate_rejects
(5 topics from first Accept-state input + 0 from Reject)
handle_with_zone_threads_through_to_published_topics
(zone_activity payload = "\"kitchen\"")
class_3_pipeline_baseline_produces_four_topics_per_input
Test publisher pattern: Arc<Mutex<CapturePublisher>> lets the test thread
read out the worker thread's publish log post-shutdown without needing
custom channel plumbing per test.
ACs progressed:
- ADR-118 §2.1 lib.rs entry point now has the "set up MQTT and walk away"
operator surface promised in the implementation plan. Two lines:
let handle = BfldPipelineHandle::spawn(pipeline, rumqttc_pub);
handle.send(PipelineInput { inputs, embedding })?;
- ADR-122 §2.2 per-frame publish path is now structurally guarded by
worker-thread isolation: even if a Publish::publish call panics, only
the worker thread dies; the main thread sees a clean error on send().
Test config:
- cargo test --no-default-features → 72 passed
- cargo test → 177 passed (169 + 8)
- cargo test --features mqtt → 186 (178 + 8 — handle is std-only,
reachable in both feature configs)
Out of scope (next iter target):
- GitHub Actions workflow with mosquitto Docker service so the iter-24
integration test actually runs in CI with BFLD_MQTT_BROKER set.
- HA discovery payload publisher (ADR-122 §2.1) — the auto-discovery
config messages HA needs alongside the state topics this handle ships.
Co-Authored-By: claude-flow <ruv@ruv.net>
Iter 23. Production Publish trait impl using rumqttc 0.24 (same crate
version + use-rustls feature pinning as wifi-densepose-sensing-server,
so both publishers can share broker connection posture).
Added:
- rumqttc = "0.24" optional dep (default-features = false, use-rustls)
- New `mqtt` cargo feature: ["std", "dep:rumqttc"]
- src/rumqttc_publisher.rs (gated on `feature = "mqtt"`):
* RumqttPublisher wrapping rumqttc::Client + QoS + retain flag
* RumqttPublisher::new(client, qos) const constructor
* with_retain(bool) builder for availability-style topics
* RumqttPublisher::connect(opts, capacity) -> (Self, Connection)
Returns the unpumped Connection — caller spawns a thread that
iterates connection.iter() to drive the MQTT protocol. Default
QoS is AtLeastOnce (HA-DISCO recommendation for state topics).
* impl Publish with Error = rumqttc::ClientError
- pub use RumqttPublisher from lib.rs
tests/rumqttc_publisher_smoke.rs (7 named tests, all green, gated on mqtt):
rumqttc_publisher_constructs_without_broker
(uses 127.0.0.1:1 — reserved port refuses immediately; no hang)
with_retain_builder_yields_a_publisher
publish_queues_message_without_blocking_on_broker_state
*** Critical property: rumqttc's sync Client::publish queues into
an unbounded channel; publish_event returns Ok without round-
tripping to the (offline) broker. The queued packet only sends
if a thread iterates Connection::iter(). ***
restricted_event_publishes_four_messages_through_rumqttc
(class 3 + no zone: presence/motion/count/confidence — 4 topics)
publisher_trait_object_is_constructible
(Box<dyn Publish<Error = rumqttc::ClientError>> works)
direct_publish_call_through_trait_object
default_qos_is_at_least_once_via_connect
ACs progressed:
- ADR-122 §2.2 broker integration — production publisher now wired,
matching the sensing-server's TLS / version posture. The two
crates can share a single broker connection if an operator wants
both publishers in the same process.
- ADR-122 AC4 still enforced — publish_event's class-gated routing
is upstream of rumqttc, so no broker-level config can leak Raw frames.
Test config:
- cargo test --no-default-features → 72 passed (mqtt feature off)
- cargo test → 169 passed (mqtt feature off)
- cargo test --features mqtt --test rumqttc_publisher_smoke → 7 passed
- With --features mqtt: 169 + 7 = 176 total
Out of scope (next iter target):
- mosquitto integration test (env-gated MQTT_BROKER=tcp://localhost:1883):
* spawn a thread iterating Connection::iter()
* publish a BfldEvent
* subscribe in the test, await SubAck per the workspace memory note
`feedback_mqtt_integration_test_patterns`
* assert the topics received match render_events output
- BfldPipelineHandle: Arc<Mutex<BfldPipeline>> with a thread that pumps
inbound (inputs, embedding) → process → publish_event(&rumqttc_pub, &event)
for a single-call "set up MQTT publisher and walk away" API.
Co-Authored-By: claude-flow <ruv@ruv.net>
Iter 22. Abstracts the MQTT publish boundary without pulling in tokio or
rumqttc yet. The trait is sync (callers can hold &mut self without an
async runtime); the production rumqttc-backed impl in iter 23 will drive
a tokio task internally and present the same sync surface here.
Added (in src/mqtt_topics.rs, gated on `feature = "std"`):
- Publish trait with associated Error type
- CapturePublisher (Vec-backed; default-constructible) for unit tests
- publish_event<P: Publish>(publisher, event) -> Result<usize, P::Error>
Iterates render_events(event) and forwards each TopicMessage to
publisher.publish(). Returns the count actually published, or the
publisher's error short-circuited on first failure.
- pub use Publish, CapturePublisher, publish_event from lib.rs
tests/mqtt_publish_loop.rs (7 named tests, all green):
capture_publisher_records_every_message
publish_returns_zero_for_raw_and_derived_events
(parameterized — class 0 and class 1 both produce zero publishes,
reinforcing the invariant I1 surface enforcement from iter 21)
published_topics_match_render_events_ordering
(stable per-event topic sequence for MQTT consumers)
restricted_class_publishes_no_identity_risk_topic
anonymous_without_zone_publishes_five_messages (5 = no zone_activity)
publisher_error_short_circuits_publish_event
(FailingPublisher fails on 3rd publish; publish_event surfaces the
error AND leaves the first two messages durably published)
capture_publisher_error_type_is_infallible
(compile-time witness that CapturePublisher cannot panic the loop)
ACs progressed:
- ADR-122 §2.2 publisher boundary — the broker-facing surface is now a
named trait operators can mock, swap, or wrap with retries.
- ADR-122 AC4 — publish_event respects the iter-21 class gating; Raw /
Derived events produce zero broker traffic by definition.
- ADR-118 invariant I1 — even if the broker connection somehow regressed,
the trait-level publish_event cannot exfiltrate a Raw frame because
render_events returns empty first.
Test config:
- cargo test --no-default-features → 72 passed (mqtt_publish_loop cfg-out)
- cargo test → 169 passed (162 + 7)
Out of scope (next iter target):
- New `mqtt` feature gate; tokio + rumqttc deps under it
- RumqttPublisher: impl Publish that holds an MqttClient + a small tokio
block_on or oneshot send to bridge sync trait to async client
- Optional: BfldPipelineHandle that owns Arc<Mutex<BfldPipeline>> + a
spawn-and-forget tokio task pumping inbound (inputs, embedding) →
process → publish_event(&rumqtt_pub, &event)
- mosquitto integration test following the patterns from
feedback_mqtt_integration_test_patterns memory note
Co-Authored-By: claude-flow <ruv@ruv.net>
Iter 21. Lands ADR-122 §2.2 topic shape + class-gated routing as a pure
function. No broker dep yet — that lands in iter 22 with tokio + rumqttc
behind an `mqtt` feature. This iter is the routing policy, separated for
testability.
Added (gated on `feature = "std"`):
- src/mqtt_topics.rs:
* TopicMessage { topic: String, payload: String }
* TopicMessage::ruview_topic(node, entity) builds the canonical
`ruview/<node>/bfld/<entity>/state` shape
* render_events(&BfldEvent) -> Vec<TopicMessage>:
class < Anonymous (0/1): returns empty (raw/derived are local only)
class >= Anonymous (2/3): emits presence + motion + person_count +
confidence, plus zone_activity if zone_id set
class == Anonymous (2) ONLY: also emits identity_risk
class == Restricted (3): identity_risk is suppressed even with score
- pub use render_events, TopicMessage from lib.rs
Payload encoding:
- presence: "true" | "false"
- motion: "{:.6}" — fixed-precision decimal in [0.0, 1.0]
- person_count: bare integer string
- confidence: "{:.6}"
- zone_activity: JSON-string with quotes — "\"living_room\""
- identity_risk: "{:.6}"
tests/mqtt_topic_routing.rs (10 named tests, all green):
topic_format_is_ruview_node_bfld_entity_state
anonymous_class_publishes_six_topics_with_zone
(6 = presence/motion/count/conf/zone/identity_risk)
anonymous_class_without_zone_omits_zone_activity_topic (5 topics)
restricted_class_omits_identity_risk_topic (class 3 → 5 topics, no risk)
raw_and_derived_classes_publish_nothing
*** structural enforcement of "raw stays local" at the topic layer ***
presence_payload_is_lowercase_json_bool
motion_payload_is_fixed_precision_decimal
person_count_payload_is_bare_integer
zone_payload_is_json_string_with_quotes
identity_risk_payload_is_fixed_precision_decimal
ACs progressed:
- ADR-122 §2.2 topic shape now matches the documented format byte-for-byte.
- ADR-122 AC4 — per-class topic gating: classes 2 / 3 publish disjoint
sets, with identity_risk uniquely guarded.
- ADR-118 invariant I1 reaching the public surface — Raw frames produce
zero topic messages, so even a buggy publisher loop cannot leak them.
Test config:
- cargo test --no-default-features → 72 passed (mqtt_topics cfg-out)
- cargo test → 162 passed (152 + 10)
Out of scope (next iter target):
- tokio + rumqttc behind a new `mqtt` feature gate
- BfldPipelineHandle: Arc<Mutex<BfldPipeline>> + a tokio task that pumps
inbound SensingInputs, runs render_events on each emitted BfldEvent,
and calls client.publish() for each TopicMessage
- mosquitto integration test pattern (cf. feedback_mqtt_integration_test_patterns
memory: per-test client_id, pump until SubAck, wait for publisher discovery)
Co-Authored-By: claude-flow <ruv@ruv.net>
Iter 20. Adds the wire-bytes companion to BfldPipeline::process so
callers needing BfldFrame (for ESP-NOW, UDP, file dump, witness
bundles, etc.) don't have to drop down to BfldEmitter + manual
BfldFrame construction.
Added (in src/pipeline.rs):
- BfldPipeline::process_to_frame(
inputs: SensingInputs,
header_template: BfldFrameHeader,
payload: BfldPayload,
embedding: Option<IdentityEmbedding>,
) -> Option<BfldFrame>
Algorithm:
1. Cache timestamp_ns from inputs (consumed by the inner process()).
2. Call self.process(inputs, embedding) — gate logic decides drop/emit.
Returns None if the gate rejects, propagating to caller.
3. Clone header_template, override timestamp_ns and privacy_class from
the current pipeline state (privacy_mode-aware).
4. Build via BfldFrame::from_payload — CRC covers the section-prefixed
payload bytes per ADR-119 §2.2.
Separation of concerns: pipeline owns gate / ring / hasher state; caller
owns AP / STA / session identity (provided via header_template).
tests/pipeline_to_frame.rs (6 named tests, all green):
process_to_frame_emits_frame_under_low_risk
(timestamp_ns + privacy_class correctly propagated from pipeline)
process_to_frame_returns_none_under_sustained_high_risk
(gate Reject path: two consecutive high-risk calls → None)
process_to_frame_round_trips_through_bytes
(frame.to_bytes() → BfldFrame::from_bytes() → parse_payload() identity)
process_to_frame_overrides_class_in_privacy_mode
(enable_privacy_mode → frame.header.privacy_class = Restricted byte)
process_to_frame_preserves_header_template_identity_fields
(ap_hash, sta_hash, session_id, channel from template survive)
process_to_frame_uses_input_timestamp_not_template_timestamp
(template.timestamp_ns = 12345 is overridden by inputs.timestamp_ns)
ACs progressed:
- ADR-118 §2.1 wire-bytes consumer path now reachable from BfldPipeline,
not just from low-level BfldEmitter + manual frame construction.
- ADR-119 AC5/AC6 — round-trip-through-bytes test exercises the full
pipeline+frame stack, not just the frame in isolation.
- ADR-122 §2.2 prep — the BfldFrame is the wire format MQTT eventually
publishes via tokio loop (next iter pair); process_to_frame is the
per-frame producer that loop will call.
Test config:
- cargo test --no-default-features → 72 passed (pipeline_to_frame cfg-out)
- cargo test → 152 passed (146 + 6)
Out of scope (next iter target):
- BfldPipelineHandle: Arc<Mutex<BfldPipeline>> + tokio task that pumps
an inbound (SensingInputs, IdentityEmbedding) channel into MQTT
per-class topics (ADR-122 §2.2). Brings in tokio + rumqttc deps
behind a `mqtt` feature.
- Cargo benchmark: pipeline throughput target ≥ 40 frames/sec on a
Pi 5 core (ADR-118 §6 P2 effort estimate).
Co-Authored-By: claude-flow <ruv@ruv.net>
Iter 19. Public lib.rs entry point per ADR-118 §2.1. Thin facade over
BfldEmitter that adds a config-driven builder and a privacy_mode
toggle for emergency demote-to-Restricted without rebuilding the
gate/ring/hasher state.
Added (gated on `feature = "std"`):
- src/pipeline.rs:
* BfldConfig { node_id, default_zone_id, privacy_class, signature_hasher }
with new/with_zone/with_privacy_class/with_signature_hasher builder
* BfldPipeline { baseline_class, privacy_mode, emitter }
* BfldPipeline::new(config) — initializes the underlying emitter
* process(inputs, embedding) -> Option<BfldEvent>
Delegates to emitter.emit() then post-processes: if privacy_mode is
engaged, demotes the resulting event to Restricted and calls
apply_privacy_gating to strip identity fields
* enable_privacy_mode() / disable_privacy_mode() / is_privacy_mode_enabled()
* current_privacy_class() — returns Restricted when privacy_mode else baseline
* current_gate_action() — delegate diagnostic
- pub use BfldConfig, BfldPipeline from lib.rs
Design note: the privacy_mode override is applied post-emission, NOT by
rebuilding the emitter. This preserves gate state (current action,
pending transitions), ring contents, and hasher salt across the toggle —
critical for incident response where the operator needs to keep
detecting anomalies while temporarily redacting the public surface.
tests/pipeline_facade.rs (9 named tests, all green):
config_defaults_to_anonymous_no_zone_no_hasher
config_builder_methods_chain
fresh_pipeline_is_not_in_privacy_mode
pipeline_process_returns_anonymous_event_under_low_risk
enable_privacy_mode_demotes_published_events_to_restricted
(verifies BOTH identity_risk_score AND rf_signature_hash become None)
disable_privacy_mode_restores_baseline_class
(round-trip: enable → demoted → disable → restored to Anonymous)
privacy_mode_overrides_derived_baseline_too
(research-mode operator can still flip the emergency switch)
pipeline_with_hasher_emits_derived_rf_signature_hash
zone_is_threaded_from_config_to_event
ACs progressed:
- ADR-118 §2.1 — public entry point now matches the implementation
plan §1.2 sketch: BfldPipeline::new(config) → process() → BfldEvent.
Future iters add process_to_frame() and the tokio MQTT loop.
- ADR-118 §1.5 enable_privacy_mode requirement — operator can engage
Restricted-class redaction without restarting the pipeline or
losing in-flight detection state. First runtime witness of this.
Test config:
- cargo test --no-default-features → 72 passed (pipeline cfg-out)
- cargo test → 146 passed (137 + 9)
Out of scope (next iter target):
- process_to_frame(inputs, payload, embedding) -> Option<BfldFrame>
for callers that need wire-format bytes rather than JSON events.
- BfldPipelineHandle wrapping the pipeline in Arc<Mutex<...>> + a
tokio task that pumps an MQTT loop (ADR-122 §2.2 emitter half).
Co-Authored-By: claude-flow <ruv@ruv.net>
Iter 18. Consolidates the embedding-vs-risk-factor hashing-input
selection behind a single typed API. Replaces the two ad-hoc paths
that lived in emitter.rs through iter 17:
* inline `emb.as_slice().iter().flat_map(|f| f.to_le_bytes())`
* private `canonical_risk_bytes(&inputs) -> [u8; 16]`
Added (gated on `feature = "std"`):
- src/identity_features.rs:
* IdentityFeatures<'a> enum: Embedding(&'a IdentityEmbedding) |
RiskFactors { sep, stab, consist, conf }
* from_embedding / from_risk_factors const constructors
* canonical_byte_len() const fn — no allocation, predicts wire length
* write_canonical_bytes(&mut Vec<u8>) — reusable-buffer path
* canonical_bytes() -> Vec<u8> — allocating convenience
* compute_hash(&SignatureHasher, day_epoch) -> [u8; 32]
* RISK_FACTOR_BYTES const (= 16)
- pub use IdentityFeatures, RISK_FACTOR_BYTES from lib.rs
Refactor:
- src/emitter.rs: derived_hash now uses
let features = match &embedding {
Some(emb) => IdentityFeatures::from_embedding(emb),
None => IdentityFeatures::from_risk_factors(sep, stab, consist, conf),
};
features.compute_hash(h, day_epoch)
Local canonical_risk_bytes helper removed (superseded).
tests/identity_features_encoder.rs (9 named tests, all green):
embedding_canonical_length_is_dim_times_four
risk_factor_canonical_length_is_sixteen_bytes
embedding_canonical_bytes_match_manual_flatten
risk_factor_canonical_bytes_match_explicit_le_layout
write_canonical_bytes_appends_to_existing_buffer
compute_hash_matches_direct_hasher_invocation
embedding_and_risk_factors_produce_different_hashes
iter_16_wire_compat_embedding_path *** backward-compat regression ***
iter_16_wire_compat_risk_factor_path *** backward-compat regression ***
These two tests assert that the refactored encoder produces
bit-identical hashes to iter 16's inline path. Existing deployed
nodes upgrading to iter 18 see no rf_signature_hash flip.
ACs progressed:
- ADR-120 §2.3 — features canonical-bytes representation now has a
single source of truth in the codebase; future feature additions
pass through one named encoder rather than scattered byte-fiddling.
- ADR-118 invariant I2 — IdentityFeatures borrows &IdentityEmbedding,
it doesn't take ownership. The embedding's Drop / no-Serialize
guarantees continue to hold across the canonical-bytes path.
Test config:
- cargo test --no-default-features → 72 passed (identity_features cfg-out)
- cargo test → 137 passed (128 + 9)
Out of scope (next iter target):
- Wire IdentityFeatures into a public emitter input path so callers
can supply pre-constructed IdentityFeatures rather than the bare
embedding + risk factors. (Soft refactor; current API is sufficient.)
- BfldPipeline facade — single struct combining BfldEmitter +
BfldFrame producer + MQTT publisher (ADR-118 §2.1 lib.rs entry point).
Co-Authored-By: claude-flow <ruv@ruv.net>
Iter 17. Lands the BFLD JSON wire spec format for rf_signature_hash —
a "blake3:" prefix followed by 64 lowercase hex chars. Replaces the
default serde array-of-integers encoding which was unusable for
downstream consumers (HA, Matter, MQTT).
Added (in src/event.rs):
- ser_rf_signature_hash<S>(hash: &Option<[u8;32]>, s) custom serializer
- Field attribute on BfldEvent.rf_signature_hash now uses
serialize_with = "ser_rf_signature_hash" alongside skip_serializing_if
- nibble_to_hex(u8) -> char private const fn (no `hex` crate dep needed
for 32 bytes; lowercase hex is trivial)
- Output format: "blake3:deadbeef..." exactly 71 ASCII chars
tests/json_hash_format.rs (5 named tests, all green):
rf_signature_hash_serializes_as_blake3_prefixed_lowercase_hex
(expected hex built programmatically via format!("{b:02x}"))
hex_string_is_always_64_chars_when_present
(parses the JSON, isolates the hash substring, asserts exact 64
chars and lowercase-only — catches case-folding regressions)
hash_field_omitted_entirely_when_none
end_to_end_emitter_hasher_to_json_emits_blake3_hex_hash
*** Cross-iter integration test: BfldEmitter::with_signature_hasher
→ SensingInputs.rf_signature_hash = None → emit derives via
BLAKE3 → BfldEvent::to_json → contains "blake3:" prefix.
Spans iters 13, 14, 15, 16, 17 in a single assertion. ***
end_to_end_restricted_class_omits_hash_even_with_hasher_set
(class 3: even with hasher installed, JSON omits the hash)
ACs progressed:
- BFLD wire spec §6 — rf_signature_hash JSON shape now matches the
documented format ("blake3:..."); HA / Matter consumers can parse
it without custom byte-array decoding.
- ADR-118 §1 invariant I3 — visibility: the JSON wire form now
cryptographically tags the hash with its algorithm prefix, so
consumers can verify they're not parsing a different (weaker)
hash that a future PR might accidentally substitute.
Test config:
- cargo test --no-default-features → 72 passed (json_hash_format cfg-out)
- cargo test → 128 passed (123 + 5)
Out of scope (next iter target):
- IdentityFeatures typed encoder so callers feeding BfldEmitter don't
need to know that embedding bytes serve as hasher input.
- Replace the manual hex push with `hex::encode` if/when the workspace
takes on the `hex` crate dep for other reasons; current path saves
the dep without sacrificing correctness.
Co-Authored-By: claude-flow <ruv@ruv.net>
Iter 16. End-to-end ADR-120 §2.3 wiring: BfldEmitter now produces
rf_signature_hash derived from (site_salt, day_epoch, features), with
the IdentityEmbedding bytes as the preferred feature source. Closes
the gap from iter 15 — the hasher is now reachable from the pipeline.
Added (in src/emitter.rs):
- BfldEmitter.signature_hasher: Option<SignatureHasher> field
- BfldEmitter::with_signature_hasher(SignatureHasher) -> Self builder
- emit_with_oracle computes derived_hash BEFORE pushing embedding to ring:
1. unix_secs = inputs.timestamp_ns / NS_PER_SEC
2. feature bytes: embedding.as_slice() flattened to LE f32 bytes,
OR fallback canonical_risk_bytes(&inputs) (4-tuple of LE f32)
3. hasher.compute_at(unix_secs, &bytes)
- Derived hash overrides inputs.rf_signature_hash; when hasher absent
caller-supplied value passes through unchanged (backward compat)
- canonical_risk_bytes(&inputs) -> [u8; 16] private helper for fallback
tests/emitter_hasher.rs (6 named tests, all green):
no_hasher_passes_caller_supplied_hash_through
installed_hasher_overrides_caller_supplied_hash
same_emitter_same_inputs_produce_same_hash (determinism through emitter)
different_site_salts_produce_different_hashes_end_to_end
*** cross-site isolation proven via the BfldEmitter API, not just
via the SignatureHasher direct API (iter 15) ***
no_embedding_falls_back_to_risk_factor_bytes
fallback_hash_differs_from_embedding_hash
(embedding-based and fallback-based hashes are distinct paths)
ACs progressed:
- ADR-120 §2.7 AC2 — cross-site isolation now provable at the public
emitter surface, not just inside the hasher module.
- ADR-118 §2.1 pipeline integration — derived rf_signature_hash flows
through to the BfldEvent without caller participation. Operators
install the hasher once at boot; per-frame code never sees site_salt.
Test config:
- cargo test --no-default-features → 72 passed (emitter_hasher cfg-out)
- cargo test → 123 passed (117 + 6)
Out of scope (next iter target):
- IdentityFeatures struct — typed canonical-bytes encoder so callers
don't need to know that embedding bytes feed the hasher directly.
- Cross-iter integration test: BfldEmitter → BfldEvent::to_json with
derived hash, parsed back, hash field present and base64-encoded
(or hex-encoded) per the JSON wire spec.
Co-Authored-By: claude-flow <ruv@ruv.net>
Iter 15. Lands ADR-120 §2.3 — the cryptographic foundation of invariant
I3 ("cross-site identity correlation is impossible"). rf_signature_hash
is now derived from a per-site secret and a daily epoch, so two nodes
observing the same physical person produce uncorrelated 256-bit digests.
Added (no_std-compatible):
- blake3 = "1.5", default-features = false (no_std, no SIMD by default)
- src/signature_hasher.rs:
* Constants SECONDS_PER_DAY (86_400), SITE_SALT_LEN (32), RF_SIGNATURE_LEN (32)
* SignatureHasher { site_salt: [u8; 32] } with new(salt) const ctor
* compute(day_epoch, &features) -> [u8; 32] (BLAKE3 keyed mode)
* compute_at(unix_secs, &features) -> [u8; 32] convenience
* day_epoch_from_unix_secs(unix_secs) -> u32 helper (floor(t / 86400))
- pub use SignatureHasher, RF_SIGNATURE_LEN, SITE_SALT_LEN from lib.rs
tests/signature_hasher.rs (8 named tests, all green):
deterministic_under_identical_inputs
different_site_salts_produce_different_hashes
different_day_epochs_rotate_the_hash
different_features_produce_different_hashes
output_length_is_32_bytes
day_epoch_from_unix_secs_matches_floor_division
(covers 0, 86_399, 86_400, and the 1.7e9 modern timestamp)
compute_at_matches_compute_with_derived_day
cross_site_hamming_distance_is_statistically_high
*** ADR-120 §2.7 AC2 acceptance test ***
Runs 100 trials with distinct (salt_a, salt_b) pairs observing
identical features, computes per-trial Hamming distance, asserts
mean >= 120 bits and min >= 80 bits. Empirically lands at ~128 bits
mean (the expected value for two independent 256-bit hashes), with
no trial below 80 bits — i.e., zero suspicious near-collisions.
ACs progressed:
- ADR-120 §2.7 AC2 — structurally enforced cross-site isolation, now
proven empirically by the Hamming-distance test. This is the
cryptographic half of invariant I3 in code, not just docs.
- ADR-118 invariant I3 — first runtime witness that two sites with
independent site_salts cannot correlate the same person's signature.
Test config:
- cargo test --no-default-features → 72 passed (64 + 8; signature_hasher is no_std)
- cargo test → 117 passed (109 + 8)
Out of scope (next iter target):
- Wire SignatureHasher into BfldEmitter: replace caller-supplied
rf_signature_hash with hasher.compute_at(ts, &features) so the
pipeline produces correct hashes end-to-end.
- IdentityFeatures canonical-bytes encoder so callers don't need to
hand-serialize per-feature representations.
Co-Authored-By: claude-flow <ruv@ruv.net>
Iter 14. Wires every iter-1..13 primitive into a single ADR-118 §2.1
pipeline: per-frame sensing inputs go in, a privacy-gated BfldEvent
(or None) comes out. First time every constituent is exercised together.
Added (gated on `feature = "std"`):
- src/emitter.rs:
* SensingInputs struct — 11 fields: timestamp_ns, presence, motion,
person_count, sensing_confidence, sep, stab, consist, risk_conf,
rf_signature_hash (Option)
* BfldEmitter struct owning: node_id, default_zone_id, privacy_class,
CoherenceGate, EmbeddingRing
* Builder API: new(node_id) → with_zone(...) → with_privacy_class(...)
* current_action() / ring_len() diagnostic accessors
* emit(inputs, embedding) → Option<BfldEvent>
1. score = identity_risk::score(sep, stab, consist, risk_conf)
2. ring.push(embedding) if Some
3. action = gate.evaluate_with_oracle(score, ts, &NullOracle)
4. if action == Recalibrate { ring.drain() }
5. if action.drops_event() { return None }
6. else BfldEvent::with_privacy_gating(...) honoring privacy_class
* emit_with_oracle(...) variant for `--features soul-signature` callers
- pub use BfldEmitter, SensingInputs from lib.rs
tests/emitter_pipeline.rs (7 named tests, all green):
emitter_emits_event_under_low_risk
emitter_drops_event_under_sustained_high_risk (debounce honored)
emitter_drains_ring_on_recalibrate
(fills ring to 5, then Recalibrate-grade score → ring_len() == 0)
restricted_class_strips_identity_fields_in_emitted_event
(class 3: identity_risk_score AND rf_signature_hash both None)
with_zone_sets_default_zone_id_on_event
embedding_is_pushed_to_ring_even_when_event_dropped
(privacy gating drops the event but the ring still observes the
embedding so subsequent separability calculations remain valid)
ring_unchanged_when_no_embedding_supplied
ACs progressed:
- ADR-118 AC1 (BFLD core pipeline integration) — every component from
iter 1 (frame format) through iter 13 (event) is now traversed by a
single emit() call. This is the first end-to-end smoke proof.
- ADR-121 AC4 — Recalibrate-grade sustained score triggers ring drain
(verified by ring_len() going from 5 to 0).
- ADR-122 AC1 — privacy_class threaded through the pipeline so the
output event is correctly gated for HA/Matter consumption.
Test config:
- cargo test --no-default-features → 64 passed (emitter cfg-out)
- cargo test → 109 passed (102 + 7)
Out of scope (next iter target):
- Wiring rf_signature_hash computation from BLAKE3-keyed(site_salt,
features) per ADR-120 §2.3 — the SensingInputs.rf_signature_hash
is supplied by caller for now; needs a SignatureHasher with site_salt
initialization in a follow-up iter.
- Embedding ring → identity_separability_score derivation (currently
`sep` is caller-supplied; should be computed from ring contents).
- MQTT topic publisher wrapping BfldEmitter (ADR-122 §2.2) — depends
on a runtime (tokio).
Co-Authored-By: claude-flow <ruv@ruv.net>
Iter 13. Lands ADR-121 §2.1 (output event) + ADR-122 §2.1 (field-gating
policy). BfldEvent collapses the GateAction-driven sensing pipeline
into the canonical wire-format publishable on MQTT.
Added:
- serde (workspace, derive feature, optional) + serde_json (workspace, optional) deps
- New crate feature `serde-json` (default-on; requires `std`)
- src/event.rs (gated on `feature = "std"`):
* BfldEvent struct with all sensing + identity-derived fields
* with_privacy_gating(...) constructor that applies field-gating policy:
class < Restricted (3): identity_risk_score + rf_signature_hash kept
class >= Restricted (3): both nulled to None
* apply_privacy_gating() — idempotent in-place masking
* to_json() -> Result<String, serde_json::Error> (gated on serde-json)
* Custom ser_privacy_class serializer emits lowercase names
("anonymous", "restricted", etc.) per the BFLD JSON spec
* skip_serializing_if = "Option::is_none" on identity-derived fields so
privacy-gated events are observationally indistinguishable from
events that never had the field set
- pub use BfldEvent from lib.rs
tests/event_privacy_gating.rs (9 named tests, all green):
anonymous_event_retains_identity_risk_and_hash
restricted_event_strips_identity_fields (class 3 → None)
apply_privacy_gating_is_idempotent
event_type_is_always_bfld_update (parameterized over 3 classes)
json::json_round_trip_emits_type_field_first_or_last_but_present
json::anonymous_json_includes_identity_fields
json::restricted_json_omits_identity_fields_entirely
(asserts the JSON string does NOT contain identity_risk_score or
rf_signature_hash, verifying skip_serializing_if works as intended)
json::privacy_class_serializes_to_lowercase_name
json::zone_id_none_is_omitted_from_json
ACs progressed:
- ADR-121 AC6 (identity_risk score absent at class 3) — structurally
enforced by with_privacy_gating + skip_serializing_if combination.
- ADR-122 AC1 — JSON shape matches the HA-DISCO publishable event
contract; identity fields can be reliably stripped by privacy_class.
- ADR-118 AC5 — privacy_mode = engaged maps to PrivacyClass::Restricted
with no identity fields in the published event.
Test config:
- cargo test --no-default-features → 64 passed (unchanged; event cfg-out)
- cargo test → 102 passed (93 + 9)
Out of scope (next iter target):
- Emitter struct that wires GateAction + privacy class + sensing inputs
into BfldEvent construction (ADR-118 §2.1 pipeline diagram).
- MQTT topic publisher (ADR-122 §2.2) — depends on a runtime (tokio).
Co-Authored-By: claude-flow <ruv@ruv.net>
Iter 12. Wires the ADR-121 §2.6 Recalibrate exemption: when an enrolled
person_id matches the current high-separability cluster, the gate
downgrades the would-be Recalibrate to PredictOnly. The high score is
the *intended* outcome of a Soul Signature match, not an attacker-grade
sniffer arrival — so site_salt rotation is suppressed.
Added (no_std-compatible):
- src/coherence_gate.rs additions:
* MatchOutcome enum: Match { person_id: u64 } | NotEnrolled | Suppressed
* SoulMatchOracle trait with matches_enrolled() -> MatchOutcome
* NullOracle (default-constructible, always reports NotEnrolled)
* CoherenceGate::evaluate_with_oracle(score, ts, &O: SoulMatchOracle)
— same hysteresis/debounce as evaluate(), but downgrades Recalibrate
to PredictOnly when oracle returns Match { .. }
* Refactored evaluate(): extracted advance_state(target, ts) shared with
evaluate_with_oracle. evaluate is now a 4-line wrapper.
- pub use MatchOutcome, NullOracle, SoulMatchOracle from lib.rs
tests/soul_match_oracle.rs (8 named tests, all green):
null_oracle_matches_default_evaluate_behavior
(parameterized over 5 score points; oracle-aware and oracle-free
gates produce identical trajectories)
match_outcome_downgrades_recalibrate_to_predict_only
(score=0.95 pends PredictOnly instead of Recalibrate)
match_exemption_promotes_predict_only_after_debounce_not_recalibrate
(after DEBOUNCE_NS, current is PredictOnly — never Recalibrate)
match_outcome_does_not_affect_lower_actions
(Reject pending stays Reject; oracle only intercepts Recalibrate)
suppressed_outcome_does_not_exempt_recalibrate
(Suppressed is functionally equivalent to NotEnrolled at the gate)
not_enrolled_outcome_does_not_exempt_recalibrate
match_outcome_carries_person_id
null_oracle_default_constructor_works
ACs progressed:
- ADR-121 §2.6 fully covered as a stateless integration point — the
hook is in place for the `--features soul-signature` Soul Signature
crate (TBD) to plug in a real RaBitQ-backed oracle.
- ADR-118 §1.4 Soul Signature companion contract is now structurally
enforced at the gate boundary: enrolled subjects do not trigger
site_salt rotation; everyone else does.
Test config:
- cargo test --no-default-features → 64 passed (56 + 8)
- cargo test → 93 passed (85 + 8)
Out of scope (next iter target):
- BfldEvent struct (ADR-121 §2.1 output event JSON) — the downstream
consumer of GateAction. Pairs the gate decision with presence/motion/
person_count sensing fields.
- Optional: connect SoulMatchOracle into the actual `--features
soul-signature` build (compile-time gate around a re-export).
Co-Authored-By: claude-flow <ruv@ruv.net>
Iter 11. Wraps the stateless GateAction classifier from iter 10 with two
stabilizing mechanisms per ADR-121 §2.5:
* ±0.05 HYSTERESIS — a score must clear the current band's edge by
HYSTERESIS before the gate considers the next band.
* 5-second DEBOUNCE_NS — a different action must persist that long
before it becomes current; returning to the current band cancels it.
Added (no_std-compatible):
- src/coherence_gate.rs:
* HYSTERESIS const (0.05) + DEBOUNCE_NS const (5_000_000_000)
* CoherenceGate { current, pending: Option<(GateAction, u64)> }
* new() / Default / current() / pending() (diagnostic accessors)
* evaluate(score, timestamp_ns) -> GateAction
Algorithm: compute effective_target via per-direction hysteresis check,
promote pending after DEBOUNCE_NS elapsed, cancel pending on return to
current band, reset debounce clock if pending target changes
* Private helpers effective_target / action_idx / upper_edge_of / lower_edge_of
- pub use CoherenceGate from lib.rs
tests/coherence_gate.rs (13 named tests, all green):
fresh_gate_starts_in_accept_with_no_pending
low_score_stays_in_accept_with_no_pending
score_just_past_boundary_but_within_hysteresis_does_not_pend
(0.52: above 0.5 but inside hysteresis envelope — no pending)
score_clearly_past_hysteresis_starts_pending
(0.6: past 0.55 hysteresis edge — pending PredictOnly registered)
pending_action_promotes_after_full_debounce
pending_action_does_not_promote_before_debounce
(verified at DEBOUNCE_NS - 1)
returning_to_current_band_cancels_pending
changing_pending_target_resets_the_debounce_clock
(PredictOnly pending at t=0, then Recalibrate at t=1s — clock resets,
must wait until t=1s+DEBOUNCE_NS before Recalibrate is current)
downward_transitions_also_require_hysteresis
(from PredictOnly, 0.48 stays put; 0.44 pends Accept)
spike_to_one_then_back_to_zero_never_promotes_to_recalibrate
(transient spike + return to baseline produces no transition)
boundary_value_with_hysteresis_does_not_promote (0.5+0.05-epsilon)
boundary_value_at_hysteresis_exact_does_pend (0.5+0.05)
nan_score_stays_in_current_action_with_no_pending
ACs progressed:
- ADR-121 AC4 — Recalibrate fires when score >= 0.9 for >= DEBOUNCE_NS (5s).
The debounce test above directly exercises this.
- ADR-121 AC5 — hysteresis test confirms action does not oscillate across
± 0.05 of a threshold within a 5-second window.
Test config:
- cargo test --no-default-features → 56 passed (43 + 13)
- cargo test → 85 passed (72 + 13)
Out of scope (next iter target):
- SoulMatchOracle stub trait (ADR-121 §2.6) + Recalibrate exemption —
when --features soul-signature is enabled and the oracle reports a known
enrolled person_id match, the gate downgrades Recalibrate → PredictOnly.
- BfldEvent struct (ADR-121 §2.1 output event) — first downstream consumer
of the gate action.
Co-Authored-By: claude-flow <ruv@ruv.net>
Iter 9. Lands ADR-120 §2.4 — the only operation that can lower a frame's
information content. Demote is monotonic by construction (Result::Err
on non-monotone target), strips payload sections per the target class
table, and re-syncs header.privacy_class + CRC32.
Added:
- src/privacy_gate.rs (gated on `feature = "std"`):
* PrivacyGate unit struct (+ Default impl)
* PrivacyGate::demote(BfldFrame, target: PrivacyClass) -> Result<BfldFrame>
* Stripping policy:
target >= Anonymous (2): zeros + clears compressed_angle_matrix and
csi_delta; sets csi_delta = None so from_payload clears HAS_CSI_DELTA
target >= Restricted (3): also zeros + clears amplitude_proxy and phase_proxy
* zeroize_then_clear helper — overwrite with 0 then black_box then truncate
- BfldError::InvalidDemote { from: u8, to: u8 } variant
- pub use PrivacyGate from lib.rs
Note: demote does NOT zero the original Vec capacity that the heap allocator
may still hold — the buffers we own are zeroed and cleared, but the
intermediate Vec passed back to BfldFrame::from_payload reallocates anew.
For strict heap zeroization in regulated deployments, a follow-up iter can
substitute zeroize::Zeroizing<Vec<u8>>.
tests/privacy_gate_demote.rs (7 named tests, all green):
demote_to_same_class_is_identity
demote_derived_to_anonymous_strips_compressed_angle_matrix
(also asserts csi_delta dropped, snr_vector and amplitude_proxy preserved)
demote_derived_to_restricted_strips_amplitude_and_phase_too
(snr_vector and vendor_extension survive at class 3)
demote_anonymous_to_derived_is_rejected
(asserts InvalidDemote { from: 2, to: 1 })
demote_to_raw_is_rejected_from_any_higher_class
(parameterized over Derived, Anonymous, Restricted as sources)
demote_preserves_frame_crc_consistency_through_wire_roundtrip
(post-demote frame survives to_bytes -> from_bytes with no CRC error)
demote_clears_has_csi_delta_flag_bit
ACs progressed:
- AC5 ↑ — privacy_mode enforcement at the frame-class boundary now works
through PrivacyGate, not just the BfldEvent emitter (deferred). When the
active class is Anonymous (2) or Restricted (3), the angle matrix /
csi_delta / amplitude / phase sections that carry identity information
are zeroed before any downstream code sees them.
- AC4 ↑ — demoted frames retain valid CRC; the round-trip-through-bytes
test proves bit-correctness after the class transition.
Test config:
- cargo test --no-default-features → 31 passed (privacy_gate cfg-out)
- cargo test → 60 passed (53 + 7)
Out of scope (next iter target):
- SoulMatchOracle stub trait + no-op default impl (ADR-121 §2.6) so the
Recalibrate exemption hook is wireable from `--features soul-signature`.
- IdentityRiskEngine — multiplicative formula on (sep, stab, consist, conf)
with the coherence-gate GateAction enum (ADR-121 §2.2 + §2.4).
Co-Authored-By: claude-flow <ruv@ruv.net>
Iter 8. Lands the lifecycle half of ADR-120 §2.5: a bounded, in-place,
no_std-compatible ring of IdentityEmbeddings. Insertion is O(1); when
full, push evicts the oldest entry, whose Drop runs and zeroizes the
f32 storage. drain() clears the ring on the coherence-gate Recalibrate
action (ADR-121 §2.4).
Added:
- src/embedding_ring.rs (no_std-compatible; no heap):
* EmbeddingRing struct with [Option<IdentityEmbedding>; RING_CAPACITY=64]
backing array, head cursor, count
* EmbeddingRing::new() / Default impl
* push(emb) -> Option<IdentityEmbedding> (evicted oldest when full)
* len / is_empty / capacity / is_full / iter
* iter() returns occupied slots in insertion order (oldest first)
* drain() -> usize (empties the ring, returns count drained)
- pub use EmbeddingRing, RING_CAPACITY from lib.rs
Uses `[const { None }; RING_CAPACITY]` (stable since 1.79) to initialize
the slot array for a non-Copy element type.
tests/embedding_ring.rs (9 named tests, all green):
new_ring_is_empty
default_constructor_matches_new
push_below_capacity_returns_none
iter_yields_in_insertion_order
push_at_capacity_evicts_oldest_and_returns_it
(verifies eviction reports the FIRST pushed value, not the last)
push_beyond_capacity_keeps_last_n_entries
(after 74 pushes into a 64-slot ring, the surviving 64 are positions 10..74)
drain_empties_the_ring_and_returns_count
drain_on_empty_ring_returns_zero
ring_can_be_refilled_after_drain
(post-drain push lands cleanly at index 0; iter yields exactly that entry)
ACs progressed:
- I2 ↑ — ring eviction and explicit drain both drop IdentityEmbeddings,
which the iter-7 Drop impl zeroizes. The "in-RAM-only" lifecycle is now
end-to-end: bounded buffer in, FIFO out, drain on Recalibrate.
Test config:
- cargo test --no-default-features → 31 passed (22 + 9)
- cargo test → 53 passed (44 + 9)
Out of scope (next iter target):
- PrivacyGate::demote(frame, target_class) — ADR-120 §2.4 monotonic class
transition with field zeroization, refusing demote-to-Raw (compile-fail).
- SoulMatchOracle stub trait + no-op default impl (ADR-121 §2.6) so the
Recalibrate exemption hook is wireable from `--features soul-signature`.
Co-Authored-By: claude-flow <ruv@ruv.net>
Iter 7. First structural enforcement of ADR-118 invariant I2 — the
identity embedding is in-RAM-only and cannot be serialized, cloned,
or copied. Lands the type itself; ring-buffer lifecycle is next.
Added:
- src/embedding.rs (no_std-compatible; lives in the lib regardless of features):
* IdentityEmbedding wrapping [f32; EMBEDDING_DIM=128]
* from_raw(values), as_slice() -> &[f32], l2_norm(), len(), is_empty()
* NO Serialize, NO Clone, NO Copy impl
* Custom Debug emits only dim + L2 norm + "<redacted>" — never raw values
* Drop overwrites storage with 0.0 then core::hint::black_box(...) to defeat
dead-store elimination (DSE would otherwise let the compiler skip the write)
- Compile-time structural guards via static_assertions:
assert_impl_all!(IdentityEmbedding: Drop)
assert_not_impl_any!(IdentityEmbedding: Copy, Clone)
- pub use IdentityEmbedding, EMBEDDING_DIM from lib.rs
tests/identity_embedding.rs (5 named tests, all green):
from_raw_preserves_values_through_as_slice
l2_norm_is_correct
debug_output_redacts_raw_values
(asserts the formatted output does NOT contain decimal text of values)
embedding_is_not_clonable
(runtime witness; compile-time assertion lives in src/embedding.rs)
drop_overwrites_storage_with_zeros
(Drop runs without panic; bit-level zeroization is asserted by the
black_box-guarded loop. Unsafe peek-after-free is intentionally avoided.)
ACs progressed:
- AC5 ↑ — even in `privacy_mode`, the IdentityEmbedding type can't be reached
from any serialization path because the type system rejects the impl.
- I2 ↑ — Drop, no Clone, no Copy, redacted Debug are all in place as
compile-time guarantees.
Test config:
- cargo test --no-default-features → 22 passed
- cargo test → 44 passed (3 + 6 + 7 + 8 + 8 + 7 + 5)
Out of scope (next iter target):
- EmbeddingRing — 64-entry FIFO ring buffer holding IdentityEmbeddings,
drained on coherence-gate Recalibrate (ADR-121 §2.4).
- PrivacyGate::demote(frame, target_class) transformer (ADR-120 §2.4).
Co-Authored-By: claude-flow <ruv@ruv.net>
Iter 6. Connects the typed payload parser (iter 5) to the framed
wire format (iter 4): the CRC32 now covers the section-prefixed
payload bytes per ADR-119 §2.2 ("CRC32 covers all section bytes
including length prefixes").
Added:
- BfldFrame::from_payload(header, &BfldPayload) -> Self
Auto-syncs header.flags HAS_CSI_DELTA bit from payload.csi_delta.is_some(),
serializes payload via to_bytes(), feeds BfldFrame::new() which computes
payload_len + payload_crc32 over the section-prefixed bytes.
- BfldFrame::parse_payload(&self) -> Result<BfldPayload, BfldError>
Reads HAS_CSI_DELTA bit from header.flags and dispatches to
BfldPayload::from_bytes(&self.payload, expect_csi_delta).
tests/frame_payload_integration.rs (7 named tests, all green):
from_payload_then_parse_payload_is_identity
from_payload_autosets_has_csi_delta_flag
from_payload_clears_has_csi_delta_flag_when_csi_absent
(verifies the flag is cleared when csi_delta is None even if caller
pre-set the bit; other flag bits like PRIVACY_MODE are preserved)
frame_crc_covers_section_prefixed_bytes
(mutating a byte inside section body trips CRC, not magic/length)
frame_crc_covers_section_length_prefixes
(mutating a section length-prefix byte trips CRC before parser ever runs)
empty_typed_payload_roundtrips
end_to_end_wire_roundtrip_via_bytes
(BfldPayload -> from_payload -> to_bytes -> from_bytes -> parse_payload
is the identity function modulo flag auto-set)
ACs progressed:
- AC5 ↑ — full payload round-trip through the framed bytes (closes
the round-trip leg from BfldPayload through wire and back).
- AC6 ↑ — same input produces same bytes through both layers.
- AC4 ↑ — CRC mismatch on tampered section bodies and tampered section
length prefixes both surface as BfldError::Crc, not as silent acceptance
or as a deeper parser error.
Test config:
- cargo test --no-default-features → 17 passed (integration tests cfg-out)
- cargo test → 39 passed (3 + 6 + 7 + 8 + 8 + 7)
Out of scope (next iter target):
- PrivacyGate::demote(frame, target_class) — ADR-120 §2.4 class transition
transformer with subtle::Zeroize on dropped fields.
- IdentityEmbedding newtype with no Serialize impl (ADR-120 §2.5 / I2).
Co-Authored-By: claude-flow <ruv@ruv.net>
Iter 2 of the BFLD rollout. Adds the canonical little-endian wire form for
BfldFrameHeader with safe (no unsafe) encoders/decoders. Covers ADR-119 AC5
(round-trip preservation), AC6 (deterministic serialization), and partial
AC1 (constant wire size) / AC4 (rejects bad magic + bad version).
Added:
- BfldFrameHeader::empty() — convenience constructor with magic/version set
- BfldFrameHeader::to_le_bytes() -> [u8; 86]
- BfldFrameHeader::from_le_bytes(&[u8; 86]) -> Result<Self, BfldError>
- Field-level doc strings on every header field (clears all 21 missing-docs
warnings the iter 1 commit logged)
- tests/header_roundtrip.rs — 6 named tests:
header_roundtrip_preserves_all_fields
header_serialization_is_deterministic
header_magic_is_at_offset_zero_little_endian (LE byte order proof)
parsing_rejects_invalid_magic
parsing_rejects_unsupported_version
wire_size_is_constant
Implementation notes:
- Used #[derive(Default)] on BfldFrameHeader so empty() can build cleanly.
- to_le_bytes copies packed fields into locals first to dodge unaligned-
borrow lints; from_le_bytes uses try_into() on byte slices.
- All field reads/writes are #[forbid(unsafe_code)] compliant.
Out of scope (next iter targets):
- BfldFrame (header + payload sections + section-length prefixes + CRC32
computation over payload bytes only) — needs the `crc` crate dependency.
- PrivacyGate::demote(...) skeleton (ADR-120 §2.4).
- SinkMarker traits (LocalSink / NetworkSink / MatterSink) — ADR-120 §2.2.
cargo test -p wifi-densepose-bfld --no-default-features → 9 passed, 0 failed
Co-Authored-By: claude-flow <ruv@ruv.net>
Land P1 of the BFLD rollout — the wire-format primitives:
- New workspace member: v2/crates/wifi-densepose-bfld
- PrivacyClass enum (Raw/Derived/Anonymous/Restricted) with allows_network()
and allows_matter() const helpers reflecting ADR-120 §2.2 and ADR-122 §2.4
- BfldFrameHeader (#[repr(C, packed)]) per ADR-119 §2.1
- BFLD_MAGIC = 0xBF1D_0001, BFLD_VERSION = 1
- BfldError variants for InvalidMagic / UnsupportedVersion / Crc / PrivacyViolation
- soul-signature cargo feature (gated, default OFF) per ADR-118 §1.4
- Compile-time size assertion via static_assertions::const_assert_eq!
- 3 acceptance tests in tests/frame_header_size.rs (all pass)
Bug fix:
- ADR-119 AC1 claimed BfldFrameHeader is 40 bytes. Actual packed layout sums
to 86 bytes. Updated AC1 and §2.1 prose to match. const_assert in frame.rs
pins the value structurally — a future field addition that breaks the size
fails to compile.
Out of scope for this iter (deferred to later P1 commits):
- Field-level missing-docs warnings (21) — addressed alongside accessor helpers
- Payload section parsing — needs the section-length prefix tests
- Round-trip serialize/parse — covered by a fixture-based test in the next iter
cargo test -p wifi-densepose-bfld --no-default-features → 3 passed, 0 failed
Co-Authored-By: claude-flow <ruv@ruv.net>