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:
parent
4434b235a5
commit
d7d500f5d8
|
|
@ -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]));
|
||||
}
|
||||
Loading…
Reference in New Issue