feat(adr-118/p6.7): apply_privacy_gating irreversibility tests (255/255 GREEN)

Iter 38. Pins ADR-120 §2.4 ("There is no `promote` operation") at the
BfldEvent::apply_privacy_gating soft-mutation surface. Iter 9's
PrivacyGate::demote tests already proved this for the explicit
class-transition transformer; this iter proves it for the *soft*
in-place re-classifier used by BfldPipeline::process() under
enable_privacy_mode().

Defense-in-depth property: an attacker who manages to flip
event.privacy_class from Restricted back to Anonymous cannot then
resurrect the stripped identity fields through apply_privacy_gating
alone. They'd have to fabricate the fields via direct field assignment
or rebuild via with_privacy_gating — both of which are conspicuous in
code review (single byte flip is not).

Added (in tests/event_gating_irreversibility.rs):
- 7 named tests, all green:

  apply_at_anonymous_preserves_identity_fields
    Sanity: apply doesn't strip when class is Anonymous.

  manual_class_flip_to_restricted_then_apply_strips_both_fields
    Direct path: class Anonymous → flip to Restricted → apply
    → identity_risk_score and rf_signature_hash both None.

  one_way_strip_survives_class_flip_back_to_anonymous
    *** HEADLINE TEST ***
    Anonymous → flip to Restricted → apply (strip) → flip back to
    Anonymous → apply → fields STILL None. apply_privacy_gating
    must not resurrect.

  manual_field_restoration_after_strip_only_works_via_explicit_assignment
    The escape hatch is direct field assignment (visible in code
    review), not the soft gate. Confirms: after explicit
    Some(0.42) reassignment + class=Anonymous + apply, the
    values survive.

  apply_at_already_restricted_with_already_none_fields_is_a_noop
    Idempotency on stripped-state.

  one_way_property_holds_through_multiple_class_round_trips
    Stress: 5 Restricted→apply→Anonymous→apply cycles. Fields
    must stay None throughout — no slow-resurrection bug.

  rebuilding_via_with_privacy_gating_is_the_documented_restoration_path
    Pins the doc contract: to publish identity fields again after
    a strip, build a fresh BfldEvent. The constructor accepts
    explicit Some(...) values; apply_privacy_gating then doesn't
    strip because class is Anonymous.

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-120 §2.4 "no promote operation" now structurally proven at the
  SOFT (apply_privacy_gating) path in addition to the EXPLICIT
  (PrivacyGate::demote) path that iter 9 covered. Both layers of
  the privacy gate carry the one-way-only invariant.
- ADR-118 invariant I1 — once stripped, raw identity fields can only
  be re-introduced through paths visible in code review (direct
  field assignment, fresh constructor). No subtle byte-flip path
  resurrects them.

Test config:
- cargo test --no-default-features → 80 passed (event_gating_irreversibility cfg-out)
- cargo test                       → 255 passed (248 + 7)

Out of scope (next iter target):
- PR-readiness pivot: CHANGELOG, witness bundle, AC closeout table.
  External-resource-gated work (KIT BFId, Pi5/Nexmon) still skipped.

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
ruv 2026-05-24 19:04:00 -04:00
parent 4434b235a5
commit d7d500f5d8
1 changed files with 157 additions and 0 deletions

View File

@ -0,0 +1,157 @@
//! `BfldEvent::apply_privacy_gating` one-way property. ADR-120 §2.4 "There is
//! no `promote` operation — once a field is stripped, it cannot be restored."
//!
//! `apply_privacy_gating` is the soft in-place re-classifier used by
//! [`BfldPipeline::process`] when `enable_privacy_mode()` is engaged. It
//! checks the *current* `privacy_class` byte and, if Restricted or higher,
//! nulls `identity_risk_score` and `rf_signature_hash`. Critically: it does
//! NOT carry "this event was originally class 2 with score 0.34"; once
//! stripped, a subsequent class drop back to Anonymous + another call to
//! `apply_privacy_gating` leaves the fields `None`.
//!
//! This is a structural defense-in-depth property: an attacker who flips
//! `privacy_class` back to Anonymous cannot resurrect the identity fields
//! through the soft API alone — they'd have to fabricate them via
//! `BfldEvent::with_privacy_gating` (or one of the documented constructors),
//! which is a much harder ask than a single byte mutation.
#![cfg(feature = "std")]
use wifi_densepose_bfld::{BfldEvent, PrivacyClass};
fn class_2_event_with_identity_fields() -> BfldEvent {
BfldEvent::with_privacy_gating(
"seed-01".into(),
1_700_000_000_000_000_000,
true,
0.5,
1,
0.9,
Some("kitchen".into()),
PrivacyClass::Anonymous,
Some(0.34),
Some([0xAB; 32]),
)
}
#[test]
fn apply_at_anonymous_preserves_identity_fields() {
let mut e = class_2_event_with_identity_fields();
assert!(e.identity_risk_score.is_some());
assert!(e.rf_signature_hash.is_some());
e.apply_privacy_gating();
// Class is still Anonymous → no strip.
assert!(e.identity_risk_score.is_some());
assert!(e.rf_signature_hash.is_some());
}
#[test]
fn manual_class_flip_to_restricted_then_apply_strips_both_fields() {
let mut e = class_2_event_with_identity_fields();
e.privacy_class = PrivacyClass::Restricted;
e.apply_privacy_gating();
assert!(e.identity_risk_score.is_none());
assert!(e.rf_signature_hash.is_none());
}
#[test]
fn one_way_strip_survives_class_flip_back_to_anonymous() {
// The headline test. Sequence:
// 1. Anonymous event with identity fields
// 2. Mutate to Restricted → apply_privacy_gating → fields None
// 3. Mutate back to Anonymous → apply_privacy_gating
// 4. Fields STILL None (apply doesn't resurrect)
let mut e = class_2_event_with_identity_fields();
e.privacy_class = PrivacyClass::Restricted;
e.apply_privacy_gating();
assert!(e.identity_risk_score.is_none());
e.privacy_class = PrivacyClass::Anonymous;
e.apply_privacy_gating();
assert!(
e.identity_risk_score.is_none(),
"apply_privacy_gating must NOT resurrect identity_risk_score on class downgrade",
);
assert!(
e.rf_signature_hash.is_none(),
"apply_privacy_gating must NOT resurrect rf_signature_hash on class downgrade",
);
}
#[test]
fn manual_field_restoration_after_strip_only_works_via_explicit_assignment() {
// Operators who really want a class-2 event after a strip must rebuild
// via with_privacy_gating (the documented path). Direct field assignment
// also works — but THAT mutation is visible in code review as an
// explicit "I am circumventing the soft gate" action, not a subtle
// class-byte flip.
let mut e = class_2_event_with_identity_fields();
e.privacy_class = PrivacyClass::Restricted;
e.apply_privacy_gating();
assert!(e.identity_risk_score.is_none());
// Explicit restoration:
e.privacy_class = PrivacyClass::Anonymous;
e.identity_risk_score = Some(0.42);
e.rf_signature_hash = Some([0xCD; 32]);
e.apply_privacy_gating();
// apply at class Anonymous does NOT strip the just-restored values.
assert_eq!(e.identity_risk_score, Some(0.42));
assert_eq!(e.rf_signature_hash, Some([0xCD; 32]));
}
#[test]
fn apply_at_already_restricted_with_already_none_fields_is_a_noop() {
let mut e = class_2_event_with_identity_fields();
e.privacy_class = PrivacyClass::Restricted;
e.apply_privacy_gating(); // first strip
e.apply_privacy_gating(); // second call — must remain idempotent
assert!(e.identity_risk_score.is_none());
assert!(e.rf_signature_hash.is_none());
}
#[test]
fn one_way_property_holds_through_multiple_class_round_trips() {
let mut e = class_2_event_with_identity_fields();
for _ in 0..5 {
e.privacy_class = PrivacyClass::Restricted;
e.apply_privacy_gating();
e.privacy_class = PrivacyClass::Anonymous;
e.apply_privacy_gating();
}
assert!(
e.identity_risk_score.is_none(),
"10 round-trips must not resurrect identity_risk_score",
);
assert!(
e.rf_signature_hash.is_none(),
"10 round-trips must not resurrect rf_signature_hash",
);
}
#[test]
fn rebuilding_via_with_privacy_gating_is_the_documented_restoration_path() {
// After a strip, building a fresh event via with_privacy_gating is the
// sanctioned way to publish identity fields again. This test pins the
// contract for operators reading the docs: "to restore identity fields,
// build a fresh BfldEvent."
let mut stripped = class_2_event_with_identity_fields();
stripped.privacy_class = PrivacyClass::Restricted;
stripped.apply_privacy_gating();
assert!(stripped.identity_risk_score.is_none());
let restored = BfldEvent::with_privacy_gating(
stripped.node_id.clone(),
stripped.timestamp_ns,
stripped.presence,
stripped.motion,
stripped.person_count,
stripped.confidence,
stripped.zone_id.clone(),
PrivacyClass::Anonymous,
Some(0.55),
Some([0xEF; 32]),
);
assert_eq!(restored.identity_risk_score, Some(0.55));
assert_eq!(restored.rf_signature_hash, Some([0xEF; 32]));
}