From d54aafec82d91bdabf899b3ae3e8dcbfcf3faaa9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 00:22:42 +0900 Subject: [PATCH] feat(privacy): refuse blanket-masked scientific field grants Scientific exports keep authorship, event-time, and membership linkage. Identity and source text require re-identification (ADR 0009). --- ARCHITECTURE.md | 1 + CHANGELOG.md | 1 + Cargo.lock | 4 + Cargo.toml | 2 + README.md | 3 +- crates/selective_disclosure/Cargo.toml | 17 + crates/selective_disclosure/src/disclosure.rs | 315 ++++++++++++++++++ crates/selective_disclosure/src/error.rs | 64 ++++ crates/selective_disclosure/src/lib.rs | 37 ++ .../tests/crate_contract.rs | 7 + .../tests/disclosure_contract.rs | 222 ++++++++++++ docs/PRIVACY_DATA_GOVERNANCE.md | 2 +- docs/TRACEABILITY.md | 4 +- docs/adr/0009-purpose-bound-pii-governance.md | 2 +- docs/adr/README.md | 2 +- docs/research/selective-disclosure-fields.md | 38 +++ docs/research/standards-and-literature.md | 6 + docs/validation/temporal-event-foundation.md | 1 + scripts/check_workspace_contract.py | 1 + tests/quality/test_check_docstrings.py | 3 +- 20 files changed, 725 insertions(+), 7 deletions(-) create mode 100644 crates/selective_disclosure/Cargo.toml create mode 100644 crates/selective_disclosure/src/disclosure.rs create mode 100644 crates/selective_disclosure/src/error.rs create mode 100644 crates/selective_disclosure/src/lib.rs create mode 100644 crates/selective_disclosure/tests/crate_contract.rs create mode 100644 crates/selective_disclosure/tests/disclosure_contract.rs create mode 100644 docs/research/selective-disclosure-fields.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ffe514db..adfc8be7 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -61,6 +61,7 @@ boundaries above remain the target modular MSA architecture. | `tepp_simulation` | known-truth temporal/event data generation | | `validation_core` | RMSE, bias, coverage, graph, and Monte Carlo metrics | | `tepp_api` | versioned DTO, schema, and export contracts | +| `selective_disclosure` | purpose-bound field grants without blanket PII masking | No crate exposes placeholder production behavior in Task 1. This prevents an empty façade from becoming a de facto public API before its invariants and tests diff --git a/CHANGELOG.md b/CHANGELOG.md index c1cc6e87..5860852a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to TEPP are documented here. The format follows Keep a Chang ### Added +- `selective_disclosure` field grants: scientific purpose keeps authorship, event-time, and membership linkage; operational monitoring and scientific exports refuse direct identity and source text; re-identification is the only identity grant; blanket PII masking is not a disclosure authorization; recovered field sets match known truth at a higher computed rate than a mask collapse (ADR 0009). - `persistence_postgres` backup/restore integrity: restored snapshots stay unusable until tenant, canonical `SHA-256`, knowledge-cutoff eligibility, temporal window order, and append-only triggers revalidate; SQL probes raise `restore integrity failed` (ADR 0013). - `persistence_postgres` concurrent document-write stress: atomic revise `DO` block that requires exactly one open `system_to` close, SQLSTATE mapping onto `ConcurrentWriteConflict` / `DuplicateDocumentRecord`, and live multi-session insert/revise/append-only proofs. No new migration number. - `tepp_api` naruon HTTP interchange: versioned `https` POST contracts for analysis-run create and modular export authorization that refuse table-access URLs, review/Copilot credential headers, reserved standard-header redefinition, principal-only export idempotency keys, and lexical inference claims (ADR 0011). diff --git a/Cargo.lock b/Cargo.lock index 372a55f4..adf98599 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -995,6 +995,10 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "selective_disclosure" +version = "0.1.0" + [[package]] name = "serde" version = "1.0.229" diff --git a/Cargo.toml b/Cargo.toml index 92565940..204da95e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ members = [ "crates/tepp_simulation", "crates/validation_core", "crates/tepp_api", + "crates/selective_disclosure", ] default-members = [ "crates/evidence_core", @@ -23,6 +24,7 @@ default-members = [ "crates/tepp_simulation", "crates/validation_core", "crates/tepp_api", + "crates/selective_disclosure", ] [workspace.package] diff --git a/README.md b/README.md index ae74015d..28f344bc 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ implemented in Rust. ## Current implementation state This branch establishes the Task 1 Rust workspace and quality-gate foundation. -The ten bounded crates compile independently but intentionally expose no +The eleven bounded crates compile independently but intentionally expose no placeholder production APIs. Domain behavior begins in Task 2 with immutable evidence identifiers and source records. @@ -22,6 +22,7 @@ crates/corpus_split crates/tepp_simulation crates/validation_core crates/tepp_api +crates/selective_disclosure ``` ## Local verification diff --git a/crates/selective_disclosure/Cargo.toml b/crates/selective_disclosure/Cargo.toml new file mode 100644 index 00000000..e85723a0 --- /dev/null +++ b/crates/selective_disclosure/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "selective_disclosure" +description = "Purpose-bound field grants refuse over-disclosure and blanket masking." +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +homepage.workspace = true +readme.workspace = true +keywords.workspace = true +categories.workspace = true +publish = false + +[lints] +workspace = true diff --git a/crates/selective_disclosure/src/disclosure.rs b/crates/selective_disclosure/src/disclosure.rs new file mode 100644 index 00000000..5fb3bd32 --- /dev/null +++ b/crates/selective_disclosure/src/disclosure.rs @@ -0,0 +1,315 @@ +//! Purpose-bound field grants for selective disclosure. + +use crate::SelectiveDisclosureError; + +/// Closed field: author or authorship role linkage. +pub const FIELD_AUTHOR_ROLE: u16 = 1; +/// Closed field: event or valid time. +pub const FIELD_EVENT_TIME: u16 = 2; +/// Closed field: membership or contextual role. +pub const FIELD_MEMBERSHIP_ROLE: u16 = 3; +/// Closed field: direct source identity. +pub const FIELD_DIRECT_IDENTITY: u16 = 4; +/// Closed field: raw source text. +pub const FIELD_SOURCE_TEXT: u16 = 5; +/// Closed field: opaque analytical identifier. +pub const FIELD_OPAQUE_ID: u16 = 6; + +/// Closed processing purpose for a disclosure decision. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum DisclosurePurpose { + /// Scientific or psychometric export that must keep measurement linkage. + ScientificValidation, + /// Operational telemetry that must not receive identity or source text. + OperationalMonitoring, + /// Explicit re-identification export of identity or source text. + ReidentificationExport, +} + +/// One purpose-bound set of disclosed field codes. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DisclosedFieldSet { + purpose: DisclosurePurpose, + fields: Vec, +} + +impl DisclosedFieldSet { + /// Bind a purpose to an already-validated, sorted field list. + /// + /// # Errors + /// + /// Returns [`SelectiveDisclosureError::InvalidDisclosurePayload`] when the + /// field list is empty, contains an unknown code, or contains duplicates. + pub fn new( + purpose: DisclosurePurpose, + fields: &[u16], + ) -> Result { + Ok(Self { + purpose, + fields: validated_fields(fields)?, + }) + } + + /// Processing purpose that authorized this field set. + #[must_use] + pub const fn purpose(&self) -> DisclosurePurpose { + self.purpose + } + + /// Sorted unique field codes that may be emitted. + #[must_use] + pub fn fields(&self) -> &[u16] { + &self.fields + } +} + +/// Disclose requested fields under a purpose-bound grant. +/// +/// # Errors +/// +/// Returns a fail-closed [`SelectiveDisclosureError`] when the payload is +/// invalid, a requested field is absent, identity/source text is unauthorized, +/// or a scientific purpose would drop required linkage. +pub fn disclose( + purpose: DisclosurePurpose, + source_fields: &[u16], + requested_fields: &[u16], +) -> Result { + let source = validated_fields(source_fields)?; + let requested = validated_fields(requested_fields)?; + let source_bits = field_bits(&source); + let requested_bits = field_bits(&requested); + for &code in &requested { + if source_bits & field_bit(code) == 0 { + return Err(SelectiveDisclosureError::MissingSourceField); + } + if is_identity_or_source(code) + && !matches!(purpose, DisclosurePurpose::ReidentificationExport) + { + return Err(SelectiveDisclosureError::UnauthorizedField); + } + } + if matches!(purpose, DisclosurePurpose::ScientificValidation) { + for &code in &source { + if is_scientific_linkage(code) && requested_bits & field_bit(code) == 0 { + return Err(SelectiveDisclosureError::BlanketMaskDestroysMeasurement); + } + } + } + DisclosedFieldSet::new(purpose, &requested) +} + +/// Refuse to treat a blanket PII mask as a disclosure grant. +/// +/// # Errors +/// +/// Always returns [`SelectiveDisclosureError::BlanketMaskDestroysMeasurement`]. +pub fn refuse_blanket_mask() -> Result<(), SelectiveDisclosureError> { + Err(SelectiveDisclosureError::BlanketMaskDestroysMeasurement) +} + +/// Fraction of disclosed field sets that match known truth. +/// +/// # Errors +/// +/// Returns [`SelectiveDisclosureError::InvalidDisclosurePayload`] when either +/// slice is empty or the lengths differ. +pub fn disclosure_recovery_rate( + truth: &[DisclosedFieldSet], + decided: &[DisclosedFieldSet], +) -> Result { + if truth.is_empty() || truth.len() != decided.len() { + return Err(SelectiveDisclosureError::InvalidDisclosurePayload); + } + let mut matches = 0_u32; + for (truth_record, decided_record) in truth.iter().zip(decided) { + if truth_record == decided_record { + matches += 1; + } + } + Ok(f64::from(matches) / truth.len() as f64) +} + +fn validated_fields(fields: &[u16]) -> Result, SelectiveDisclosureError> { + if fields.is_empty() { + return Err(SelectiveDisclosureError::InvalidDisclosurePayload); + } + let mut seen = 0_u16; + let mut sorted = Vec::with_capacity(fields.len()); + for &code in fields { + if !is_known_field(code) { + return Err(SelectiveDisclosureError::InvalidDisclosurePayload); + } + let bit = field_bit(code); + if seen & bit != 0 { + return Err(SelectiveDisclosureError::InvalidDisclosurePayload); + } + seen |= bit; + sorted.push(code); + } + sorted.sort_unstable(); + Ok(sorted) +} + +const fn is_known_field(code: u16) -> bool { + matches!( + code, + FIELD_AUTHOR_ROLE + | FIELD_EVENT_TIME + | FIELD_MEMBERSHIP_ROLE + | FIELD_DIRECT_IDENTITY + | FIELD_SOURCE_TEXT + | FIELD_OPAQUE_ID + ) +} + +const fn is_scientific_linkage(code: u16) -> bool { + matches!( + code, + FIELD_AUTHOR_ROLE | FIELD_EVENT_TIME | FIELD_MEMBERSHIP_ROLE + ) +} + +const fn is_identity_or_source(code: u16) -> bool { + matches!(code, FIELD_DIRECT_IDENTITY | FIELD_SOURCE_TEXT) +} + +const fn field_bit(code: u16) -> u16 { + 1_u16 << (code - 1) +} + +fn field_bits(fields: &[u16]) -> u16 { + fields + .iter() + .fold(0_u16, |bits, &code| bits | field_bit(code)) +} + +#[cfg(test)] +mod tests { + use super::{ + DisclosedFieldSet, DisclosurePurpose, FIELD_AUTHOR_ROLE, FIELD_DIRECT_IDENTITY, + FIELD_EVENT_TIME, FIELD_MEMBERSHIP_ROLE, FIELD_OPAQUE_ID, FIELD_SOURCE_TEXT, disclose, + disclosure_recovery_rate, refuse_blanket_mask, + }; + use crate::SelectiveDisclosureError; + + #[test] + fn local_branches_cover_authorized_paths() { + let scientific_source = [ + FIELD_AUTHOR_ROLE, + FIELD_EVENT_TIME, + FIELD_MEMBERSHIP_ROLE, + FIELD_DIRECT_IDENTITY, + FIELD_SOURCE_TEXT, + FIELD_OPAQUE_ID, + ]; + let kept = disclose( + DisclosurePurpose::ScientificValidation, + &scientific_source, + &[ + FIELD_MEMBERSHIP_ROLE, + FIELD_AUTHOR_ROLE, + FIELD_EVENT_TIME, + FIELD_OPAQUE_ID, + ], + ) + .expect("scientific"); + assert_eq!(kept.purpose(), DisclosurePurpose::ScientificValidation); + assert_eq!( + kept.fields(), + &[ + FIELD_AUTHOR_ROLE, + FIELD_EVENT_TIME, + FIELD_MEMBERSHIP_ROLE, + FIELD_OPAQUE_ID + ] + ); + let opaque_only = disclose( + DisclosurePurpose::ScientificValidation, + &[FIELD_OPAQUE_ID], + &[FIELD_OPAQUE_ID], + ) + .expect("no linkage present"); + assert_eq!(opaque_only.fields(), &[FIELD_OPAQUE_ID]); + let ops = disclose( + DisclosurePurpose::OperationalMonitoring, + &[FIELD_AUTHOR_ROLE, FIELD_OPAQUE_ID], + &[FIELD_OPAQUE_ID], + ) + .expect("ops"); + assert_eq!(ops.purpose(), DisclosurePurpose::OperationalMonitoring); + let exported = disclose( + DisclosurePurpose::ReidentificationExport, + &scientific_source, + &[FIELD_DIRECT_IDENTITY], + ) + .expect("re-id"); + assert_eq!(exported.fields(), &[FIELD_DIRECT_IDENTITY]); + let truth = [kept]; + let matched = disclosure_recovery_rate(&truth, &truth).expect("rate"); + assert!((matched - 1.0).abs() < f64::EPSILON); + let missed = disclosure_recovery_rate(&truth, &[exported]).expect("miss"); + assert!((missed - 0.0).abs() < f64::EPSILON); + } + + #[test] + fn local_branches_cover_fail_closed_paths() { + assert_eq!( + disclose( + DisclosurePurpose::ScientificValidation, + &[FIELD_AUTHOR_ROLE], + &[FIELD_OPAQUE_ID], + ), + Err(SelectiveDisclosureError::MissingSourceField) + ); + assert_eq!( + disclose( + DisclosurePurpose::OperationalMonitoring, + &[FIELD_DIRECT_IDENTITY], + &[FIELD_DIRECT_IDENTITY], + ), + Err(SelectiveDisclosureError::UnauthorizedField) + ); + assert_eq!( + disclose( + DisclosurePurpose::ScientificValidation, + &[FIELD_AUTHOR_ROLE, FIELD_EVENT_TIME, FIELD_OPAQUE_ID], + &[FIELD_AUTHOR_ROLE, FIELD_OPAQUE_ID], + ), + Err(SelectiveDisclosureError::BlanketMaskDestroysMeasurement) + ); + assert_eq!( + refuse_blanket_mask(), + Err(SelectiveDisclosureError::BlanketMaskDestroysMeasurement) + ); + assert_eq!( + DisclosedFieldSet::new(DisclosurePurpose::OperationalMonitoring, &[]), + Err(SelectiveDisclosureError::InvalidDisclosurePayload) + ); + assert_eq!( + DisclosedFieldSet::new(DisclosurePurpose::OperationalMonitoring, &[99]), + Err(SelectiveDisclosureError::InvalidDisclosurePayload) + ); + assert_eq!( + disclose( + DisclosurePurpose::ScientificValidation, + &[FIELD_OPAQUE_ID, FIELD_OPAQUE_ID], + &[FIELD_OPAQUE_ID], + ), + Err(SelectiveDisclosureError::InvalidDisclosurePayload) + ); + let truth = + [ + DisclosedFieldSet::new(DisclosurePurpose::ScientificValidation, &[FIELD_OPAQUE_ID]) + .expect("truth"), + ]; + assert_eq!( + disclosure_recovery_rate(&[], &[]), + Err(SelectiveDisclosureError::InvalidDisclosurePayload) + ); + assert_eq!( + disclosure_recovery_rate(&truth, &[]), + Err(SelectiveDisclosureError::InvalidDisclosurePayload) + ); + } +} diff --git a/crates/selective_disclosure/src/error.rs b/crates/selective_disclosure/src/error.rs new file mode 100644 index 00000000..f66d1549 --- /dev/null +++ b/crates/selective_disclosure/src/error.rs @@ -0,0 +1,64 @@ +//! Fail-closed selective-disclosure errors. + +use std::fmt; + +/// A fail-closed selective-disclosure error. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum SelectiveDisclosureError { + /// A requested field was not present on the source artifact. + MissingSourceField, + /// Direct identity or source text was requested without re-identification. + UnauthorizedField, + /// A scientific purpose omitted required authorship, time, or membership. + BlanketMaskDestroysMeasurement, + /// Field or purpose slices were empty, duplicated, or unknown. + InvalidDisclosurePayload, +} + +impl fmt::Display for SelectiveDisclosureError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let message = match self { + Self::MissingSourceField => "requested field is absent from the source artifact", + Self::UnauthorizedField => { + "direct identity and source text require re-identification purpose" + } + Self::BlanketMaskDestroysMeasurement => { + "blanket PII masking would destroy scientific linkage" + } + Self::InvalidDisclosurePayload => "invalid selective-disclosure payload", + }; + formatter.write_str(message) + } +} + +impl std::error::Error for SelectiveDisclosureError {} + +#[cfg(test)] +mod tests { + use super::SelectiveDisclosureError; + + #[test] + fn error_messages_are_stable() { + for (error, message) in [ + ( + SelectiveDisclosureError::MissingSourceField, + "requested field is absent from the source artifact", + ), + ( + SelectiveDisclosureError::UnauthorizedField, + "direct identity and source text require re-identification purpose", + ), + ( + SelectiveDisclosureError::BlanketMaskDestroysMeasurement, + "blanket PII masking would destroy scientific linkage", + ), + ( + SelectiveDisclosureError::InvalidDisclosurePayload, + "invalid selective-disclosure payload", + ), + ] { + assert_eq!(error.to_string(), message); + } + } +} diff --git a/crates/selective_disclosure/src/lib.rs b/crates/selective_disclosure/src/lib.rs new file mode 100644 index 00000000..d121fd35 --- /dev/null +++ b/crates/selective_disclosure/src/lib.rs @@ -0,0 +1,37 @@ +#![forbid(unsafe_code)] +#![deny(missing_docs)] +#![allow(clippy::cast_precision_loss)] +//! Purpose-bound selective disclosure without blanket PII masking. +//! +//! Authorized field grants may emit only the requested classes. Scientific +//! linkage (authorship, event time, membership) cannot be stripped under a +//! scientific purpose. Direct identity and source text require re-identification +//! purpose. Blanket masking is not a disclosure grant (ADR 0009). + +mod disclosure; +mod error; + +/// One purpose-bound set of disclosed field codes. +pub use disclosure::DisclosedFieldSet; +/// Closed processing purpose for a disclosure decision. +pub use disclosure::DisclosurePurpose; +/// Closed field: author or authorship role linkage. +pub use disclosure::FIELD_AUTHOR_ROLE; +/// Closed field: direct source identity. +pub use disclosure::FIELD_DIRECT_IDENTITY; +/// Closed field: event or valid time. +pub use disclosure::FIELD_EVENT_TIME; +/// Closed field: membership or contextual role. +pub use disclosure::FIELD_MEMBERSHIP_ROLE; +/// Closed field: opaque analytical identifier. +pub use disclosure::FIELD_OPAQUE_ID; +/// Closed field: raw source text. +pub use disclosure::FIELD_SOURCE_TEXT; +/// Disclose the intersection of requested fields and the purpose grant. +pub use disclosure::disclose; +/// Fraction of disclosed field sets that match known truth. +pub use disclosure::disclosure_recovery_rate; +/// Refuse to treat a blanket PII mask as a disclosure grant. +pub use disclosure::refuse_blanket_mask; +/// Fail-closed selective-disclosure errors. +pub use error::SelectiveDisclosureError; diff --git a/crates/selective_disclosure/tests/crate_contract.rs b/crates/selective_disclosure/tests/crate_contract.rs new file mode 100644 index 00000000..d541efb6 --- /dev/null +++ b/crates/selective_disclosure/tests/crate_contract.rs @@ -0,0 +1,7 @@ +//! Integration contract for the `selective_disclosure` package identity. + +#[test] +fn package_identity_is_stable() { + let observed = std::hint::black_box(env!("CARGO_PKG_NAME")); + assert_eq!(observed, "selective_disclosure"); +} diff --git a/crates/selective_disclosure/tests/disclosure_contract.rs b/crates/selective_disclosure/tests/disclosure_contract.rs new file mode 100644 index 00000000..f977c482 --- /dev/null +++ b/crates/selective_disclosure/tests/disclosure_contract.rs @@ -0,0 +1,222 @@ +//! Selective disclosure cannot leak unauthorized fields or blanket-mask measurement. + +use selective_disclosure::{ + DisclosedFieldSet, DisclosurePurpose, FIELD_AUTHOR_ROLE, FIELD_DIRECT_IDENTITY, + FIELD_EVENT_TIME, FIELD_MEMBERSHIP_ROLE, FIELD_OPAQUE_ID, FIELD_SOURCE_TEXT, + SelectiveDisclosureError, disclose, disclosure_recovery_rate, refuse_blanket_mask, +}; + +fn scientific_source() -> [u16; 5] { + [ + FIELD_AUTHOR_ROLE, + FIELD_EVENT_TIME, + FIELD_MEMBERSHIP_ROLE, + FIELD_DIRECT_IDENTITY, + FIELD_SOURCE_TEXT, + ] +} + +#[test] +fn scientific_purpose_keeps_linkage_and_refuses_identity() { + let disclosed = disclose( + DisclosurePurpose::ScientificValidation, + &scientific_source(), + &[ + FIELD_AUTHOR_ROLE, + FIELD_EVENT_TIME, + FIELD_MEMBERSHIP_ROLE, + FIELD_OPAQUE_ID, + ], + ); + assert_eq!(disclosed, Err(SelectiveDisclosureError::MissingSourceField)); +} + +#[test] +fn scientific_purpose_keeps_present_linkage() { + let source = [ + FIELD_AUTHOR_ROLE, + FIELD_EVENT_TIME, + FIELD_MEMBERSHIP_ROLE, + FIELD_DIRECT_IDENTITY, + FIELD_SOURCE_TEXT, + FIELD_OPAQUE_ID, + ]; + let disclosed = disclose( + DisclosurePurpose::ScientificValidation, + &source, + &[ + FIELD_AUTHOR_ROLE, + FIELD_EVENT_TIME, + FIELD_MEMBERSHIP_ROLE, + FIELD_OPAQUE_ID, + ], + ) + .expect("scientific linkage"); + assert_eq!(disclosed.purpose(), DisclosurePurpose::ScientificValidation); + assert_eq!( + disclosed.fields(), + &[ + FIELD_AUTHOR_ROLE, + FIELD_EVENT_TIME, + FIELD_MEMBERSHIP_ROLE, + FIELD_OPAQUE_ID + ] + ); +} + +#[test] +fn omitting_scientific_linkage_is_a_blanket_mask() { + assert_eq!( + disclose( + DisclosurePurpose::ScientificValidation, + &scientific_source(), + &[FIELD_OPAQUE_ID], + ), + Err(SelectiveDisclosureError::MissingSourceField) + ); + let source = [ + FIELD_AUTHOR_ROLE, + FIELD_EVENT_TIME, + FIELD_MEMBERSHIP_ROLE, + FIELD_OPAQUE_ID, + ]; + assert_eq!( + disclose( + DisclosurePurpose::ScientificValidation, + &source, + &[FIELD_OPAQUE_ID], + ), + Err(SelectiveDisclosureError::BlanketMaskDestroysMeasurement) + ); + assert_eq!( + refuse_blanket_mask(), + Err(SelectiveDisclosureError::BlanketMaskDestroysMeasurement) + ); +} + +#[test] +fn identity_and_source_text_require_reidentification_purpose() { + let source = scientific_source(); + assert_eq!( + disclose( + DisclosurePurpose::ScientificValidation, + &source, + &[ + FIELD_AUTHOR_ROLE, + FIELD_EVENT_TIME, + FIELD_MEMBERSHIP_ROLE, + FIELD_DIRECT_IDENTITY + ], + ), + Err(SelectiveDisclosureError::UnauthorizedField) + ); + assert_eq!( + disclose( + DisclosurePurpose::OperationalMonitoring, + &[FIELD_SOURCE_TEXT, FIELD_OPAQUE_ID], + &[FIELD_SOURCE_TEXT], + ), + Err(SelectiveDisclosureError::UnauthorizedField) + ); + let exported = disclose( + DisclosurePurpose::ReidentificationExport, + &source, + &[FIELD_DIRECT_IDENTITY, FIELD_SOURCE_TEXT], + ) + .expect("re-id"); + assert_eq!( + exported.fields(), + &[FIELD_DIRECT_IDENTITY, FIELD_SOURCE_TEXT] + ); +} + +#[test] +fn operational_monitoring_may_omit_linkage() { + let disclosed = disclose( + DisclosurePurpose::OperationalMonitoring, + &[FIELD_AUTHOR_ROLE, FIELD_OPAQUE_ID], + &[FIELD_OPAQUE_ID], + ) + .expect("ops"); + assert_eq!( + disclosed.purpose(), + DisclosurePurpose::OperationalMonitoring + ); + assert_eq!(disclosed.fields(), &[FIELD_OPAQUE_ID]); +} + +#[test] +fn unknown_empty_or_duplicate_payloads_fail_closed() { + assert_eq!( + disclose( + DisclosurePurpose::ScientificValidation, + &[], + &[FIELD_OPAQUE_ID] + ), + Err(SelectiveDisclosureError::InvalidDisclosurePayload) + ); + assert_eq!( + disclose( + DisclosurePurpose::ScientificValidation, + &[FIELD_OPAQUE_ID], + &[] + ), + Err(SelectiveDisclosureError::InvalidDisclosurePayload) + ); + assert_eq!( + disclose( + DisclosurePurpose::ScientificValidation, + &[FIELD_OPAQUE_ID, 99], + &[FIELD_OPAQUE_ID], + ), + Err(SelectiveDisclosureError::InvalidDisclosurePayload) + ); + assert_eq!( + disclose( + DisclosurePurpose::ScientificValidation, + &[FIELD_OPAQUE_ID], + &[FIELD_OPAQUE_ID, FIELD_OPAQUE_ID], + ), + Err(SelectiveDisclosureError::InvalidDisclosurePayload) + ); +} + +#[test] +fn recovered_field_sets_match_known_truth_better_than_a_mask_collapse() { + let source = [ + FIELD_AUTHOR_ROLE, + FIELD_EVENT_TIME, + FIELD_MEMBERSHIP_ROLE, + FIELD_OPAQUE_ID, + ]; + let truth = + [disclose(DisclosurePurpose::ScientificValidation, &source, &source).expect("truth")]; + let recovered = + [disclose(DisclosurePurpose::ScientificValidation, &source, &source).expect("recovered")]; + let collapsed = + [ + DisclosedFieldSet::new(DisclosurePurpose::ScientificValidation, &[FIELD_OPAQUE_ID]) + .expect("collapse"), + ]; + let recovered_rate = disclosure_recovery_rate(&truth, &recovered).expect("recovered"); + let collapsed_rate = disclosure_recovery_rate(&truth, &collapsed).expect("collapsed"); + let expected = { + let mut matches = 0_u32; + for (truth_record, decided_record) in truth.iter().zip(recovered.iter()) { + if truth_record == decided_record { + matches += 1; + } + } + f64::from(matches) / f64::from(u32::try_from(truth.len()).expect("len")) + }; + assert!((recovered_rate - expected).abs() < f64::EPSILON); + assert!(recovered_rate > collapsed_rate); + assert_eq!( + disclosure_recovery_rate(&[], &[]), + Err(SelectiveDisclosureError::InvalidDisclosurePayload) + ); + assert_eq!( + disclosure_recovery_rate(&truth, &[]), + Err(SelectiveDisclosureError::InvalidDisclosurePayload) + ); +} diff --git a/docs/PRIVACY_DATA_GOVERNANCE.md b/docs/PRIVACY_DATA_GOVERNANCE.md index 429c7687..c7b981f8 100644 --- a/docs/PRIVACY_DATA_GOVERNANCE.md +++ b/docs/PRIVACY_DATA_GOVERNANCE.md @@ -80,4 +80,4 @@ Ordinary logs contain identifiers/digests sufficient for diagnosis without copyi ## 10. Privacy validation -Required tests include cross-tenant denial, expired-purpose denial, re-identification-boundary checks, export authorization, provider payload minimization, raw-source log absence, deletion/retention behavior, audit replay, and derived-sensitive-data classification. Privacy controls must be tested with realistic author/customer/project/multiple-membership cases rather than only anonymous fixtures. \ No newline at end of file +Required tests include cross-tenant denial, expired-purpose denial, re-identification-boundary checks, export authorization, provider payload minimization, raw-source log absence, deletion/retention behavior, audit replay, and derived-sensitive-data classification. Privacy controls must be tested with realistic author/customer/project/multiple-membership cases rather than only anonymous fixtures. The in-memory `selective_disclosure` crate is the current field-grant gate; persistence of grants remains accepted-target. \ No newline at end of file diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index c29d9743..a27069bc 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -33,8 +33,8 @@ The full APA 7th standards/literature register remains `docs/research/standards- | TDT detection/tracking vs CHRONOS schema/prediction/temporal consistency | ADR 0016; PRD/research | future `event_intelligence` | accepted-target | | evidence-bounded LLM interpretation | ADR 0010/0012; PRD | future `interpretation_gateway` | accepted-target | | adaptive direct/verify/committee/conductor test-time compute | ADR 0010; `docs/LLM_ORCHESTRATION.md` | future contextual-orchestrator integration + ablation evidence | accepted-target | -| purpose-bound PII handling without blanket masking | ADR 0009; `docs/PRIVACY_DATA_GOVERNANCE.md` | future authorization/persistence/export/provider adapters | accepted-target | -| tenant/purpose/role/lifetime access and identity separation | ADR 0009; Threat Model | future service/persistence boundaries | accepted-target | +| purpose-bound PII handling without blanket masking | ADR 0009; `docs/PRIVACY_DATA_GOVERNANCE.md` | `selective_disclosure` field grants on the active PR; persistence/live HTTP/provider adapters remaining | active-PR | +| tenant/purpose/role/lifetime access and identity separation | ADR 0009; Threat Model | `selective_disclosure` refuses identity/source-text without re-identification on the active PR; tenant/role persistence remaining | active-PR | | standalone + modular CWL MSA / no cross-service DB coupling | ADR 0011; `docs/API_CONTRACT.md` | current standalone crates; future service ports | partial | | naruon modular artifact consumer boundary | ADR 0011/0012; API contract | `docs/connectors/naruon-artifact-consumer.md` + PR #22 versioned consumer contract on protected main; `tepp_api` HTTP interchange (active PR); live HTTP service remaining | partial | | contextual-orchestrator interpretation port boundary | ADR 0010/0011; LLM orchestration | `docs/connectors/contextual-orchestrator-interpretation-port.md`; live port remaining | partial | diff --git a/docs/adr/0009-purpose-bound-pii-governance.md b/docs/adr/0009-purpose-bound-pii-governance.md index 26fa3ad0..b5c85e44 100644 --- a/docs/adr/0009-purpose-bound-pii-governance.md +++ b/docs/adr/0009-purpose-bound-pii-governance.md @@ -1,7 +1,7 @@ # ADR 0009 — Purpose-bound PII governance without blanket masking **Decision status:** Accepted -**Implementation maturity:** accepted-target +**Implementation maturity:** active-PR **Date:** 2026-08-10 **Supersedes:** None. diff --git a/docs/adr/README.md b/docs/adr/README.md index 1a9a7b31..94bea0a1 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -14,7 +14,7 @@ Read [`ADR_POLICY.md`](ADR_POLICY.md) first. **Decision status and implementatio | [0006](0006-vram-gpu-nvidia-orchestration.md) | VRAM-adaptive GPU compute and model-credential boundary | Accepted | accepted-target | LLM orchestration policy superseded by ADR 0010; autonomous development authority governed by ADR 0015. | | [0007](0007-rust-workspace-quality-gates.md) | Explicit Rust workspace, pinned toolchains, and exact quality gates | Accepted | implemented-main | ADR 0014 governs scientific/product claim promotion beyond repository-quality tooling. | | [0008](0008-immutable-evidence-identities-digests-and-spans.md) | Immutable evidence identities, `SHA-256` digests, exact spans, and strict wire reconstruction | Accepted | implemented-main | ADR 0013 governs future persistence/reproducibility/split authority. | -| [0009](0009-purpose-bound-pii-governance.md) | Purpose-bound PII governance without blanket masking | Accepted | accepted-target | Controls are normative architecture; deployment/control evidence is not yet a certification claim. | +| [0009](0009-purpose-bound-pii-governance.md) | Purpose-bound PII governance without blanket masking | Accepted | active-PR | Selective field grants in `selective_disclosure` on the active PR; persistence, live HTTP, encryption, and certification evidence remain accepted-target. | | [0010](0010-adaptive-llm-orchestration.md) | Adaptive LLM orchestration and test-time compute | Accepted | accepted-target | Owns direct/verify/committee/conductor selection, budget, role/topology, and ablation policy. | | [0011](0011-standalone-modular-msa-boundary.md) | Standalone operation and modular CWL MSA boundary | Accepted | partial | Owns cross-service persistence/credential/API authority; no direct cross-service application-table coupling. | | [0012](0012-temporal-relational-shared-latent-topic-measurement.md) | Temporal Relational Shared-Latent Topic Measurement (TRSL-TM) | Accepted | accepted-target | Owns topic backend compatibility, global topic identity, method effects, K/model-selection prerequisites, and compositional topic coordinates. | diff --git a/docs/research/selective-disclosure-fields.md b/docs/research/selective-disclosure-fields.md new file mode 100644 index 00000000..2195f1e9 --- /dev/null +++ b/docs/research/selective-disclosure-fields.md @@ -0,0 +1,38 @@ +# Selective disclosure field grants (doctoring) + +## Scope + +`selective_disclosure` authorizes purpose-bound field sets. Scientific +exports keep authorship, event-time, and membership linkage present on +the source. Operational monitoring and scientific purposes refuse +direct identity and source text. Re-identification is the only identity +grant. Blanket PII masking is not a disclosure authorization. Recovery +is the computed share of disclosed field sets that match known truth. + +This slice does not persist grants, encrypt mappings, or claim CSAP, +SOC 2, or legal sufficiency. + +## Authority + +### Normative TEPP contract + +- `docs/adr/0009-purpose-bound-pii-governance.md` — selective + disclosure without blanket masking. +- `docs/PRIVACY_DATA_GOVERNANCE.md` — allowed fields/source classes + are part of every protected disclosure evaluation. + +### Supporting literature + +ISO/IEC 29100 treats use, retention and disclosure limitation, and +data minimization, as applying to disclosed as well as collected +personal data. They do **not** authorize destroying authorship, +temporal, or membership linkage in order to "mask PII," and they do +not certify TEPP. + +International Organization for Standardization and International +Electrotechnical Commission. (2011). *Information technology—Security +techniques—Privacy framework* (ISO/IEC Standard No. 29100:2011). + +National Institute of Standards and Technology. (2020). *NIST privacy +framework: A tool for improving privacy through enterprise risk +management, version 1.0*. https://doi.org/10.6028/NIST.CSWP.01162020 diff --git a/docs/research/standards-and-literature.md b/docs/research/standards-and-literature.md index b4b14468..f2a3717f 100644 --- a/docs/research/standards-and-literature.md +++ b/docs/research/standards-and-literature.md @@ -96,6 +96,12 @@ Moreau, L., & Missier, P. (Eds.). (2013). *PROV-DM: The PROV data model*. World TEPP separates stable record identity, content equality, exact text location, wire representation, authorization, and provenance. JSON wire records are explicit versioned DTOs with unknown-field rejection and reconstruct through domain validation. `SHA-256` detects content substitution but is not treated as proof of origin, authority, or chain of custody. +## Privacy and selective disclosure + +International Organization for Standardization and International Electrotechnical Commission. (2011). *Information technology—Security techniques—Privacy framework* (ISO/IEC Standard No. 29100:2011). Use, retention and disclosure limitation, plus data minimization, apply to purpose-bound field grants in `selective_disclosure`; they are not a certification claim. + +National Institute of Standards and Technology. (2020). *NIST privacy framework: A tool for improving privacy through enterprise risk management, version 1.0*. https://doi.org/10.6028/NIST.CSWP.01162020. The `CT.DM` data-processing management functions inform TEPP's refusal to emit identity/source-text without a re-identification purpose and its refusal to blanket-mask scientific linkage. + ## AI risk, management systems, and assurance readiness International Organization for Standardization. (2023a). *Information technology—Artificial intelligence—Guidance on risk management* (ISO/IEC Standard No. 23894:2023). https://www.iso.org/standard/77304.html diff --git a/docs/validation/temporal-event-foundation.md b/docs/validation/temporal-event-foundation.md index 295fbae0..aadb0ae6 100644 --- a/docs/validation/temporal-event-foundation.md +++ b/docs/validation/temporal-event-foundation.md @@ -23,6 +23,7 @@ This report tracks exact-head scientific and engineering evidence required befor | Truth corpora / manifests | `tepp_simulation` | implemented-main | — | deterministic generator tests | Task 10 / PR #18 | | Recovery metrics | `validation_core` | implemented-main | — | RMSE/bias/coverage/MC gates | Task 11 / PR #19 | | Versioned API/export contracts | `tepp_api` | implemented-main | naruon HTTP interchange | unknown-field/version/limit + naruon HTTPS interchange tests | Task 12 / PR #21; live HTTP service remaining | +| Selective disclosure field grants | `selective_disclosure` | active-PR | this PR | recovered field-set agreement vs blanket-mask collapse | ADR 0009 | | CWL modular connectors | `docs/connectors/*` | implemented-main | — | contract docs + examples | PR #22; live HTTP ports remaining | | Release SBOM/provenance generator | `scripts/release_evidence.py` | partial | — | generate+validate in CI | Task 13 partial / PR #28 | diff --git a/scripts/check_workspace_contract.py b/scripts/check_workspace_contract.py index c7b1ecf5..58ee7985 100644 --- a/scripts/check_workspace_contract.py +++ b/scripts/check_workspace_contract.py @@ -23,6 +23,7 @@ "tepp_simulation", "validation_core", "tepp_api", + "selective_disclosure", ) REQUIRED_CI_SNIPPETS: tuple[str, ...] = ( diff --git a/tests/quality/test_check_docstrings.py b/tests/quality/test_check_docstrings.py index 2c11f7a5..56d553d2 100644 --- a/tests/quality/test_check_docstrings.py +++ b/tests/quality/test_check_docstrings.py @@ -11,6 +11,7 @@ from unittest import mock from scripts import check_docstrings as docstrings +from scripts import check_workspace_contract as contract REPOSITORY_ROOT = Path(__file__).resolve().parents[2] @@ -24,7 +25,7 @@ def test_live_repository_is_documented(self) -> None: sources = docstrings.rust_sources(REPOSITORY_ROOT) crate_roots = sorted(REPOSITORY_ROOT.glob("crates/*/src/lib.rs")) - self.assertEqual(len(crate_roots), 10) + self.assertEqual(len(crate_roots), len(contract.EXPECTED_CRATES)) self.assertTrue(set(crate_roots).issubset(sources)) self.assertGreaterEqual(len(sources), len(crate_roots)) self.assertEqual(docstrings.validate_repository(REPOSITORY_ROOT), [])