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 93891a27..8d74662a 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). - `tepp_api` adaptive orchestration router (ADR 0010): versioned `direct`/`verify`/`committee`/`conductor`/`abstain` selection from CPU `f64` risk, ambiguity, evidence, and token-budget inputs; recorded stages, recursion, decomposition, access lists, and role-specific reasoning effort; fail-closed document-controlled policy/access/credentials; LLM plans remain proposals under deterministic statistical authority; comparable-budget ablation requires a direct baseline; credential-free contextual-orchestrator binding. Live NIM HTTP remains accepted-target. - `tepp_api` purpose-bound provider-payload minimization: time-bounded `PurposeGrant` evaluation, fail-closed expired/not-yet-valid/inverted/cross-tenant/impossible-calendar denial, semantic UTC calendar validation, refusal to copy identity mappings into model-provider payloads or ordinary logs, preservation of opaque analytical identifiers and membership roles (no blanket PII mask), a separately authorized scientific re-identification path, and an internally bound FIPS 180-4 SHA-256 audit digest appended through `ReidentificationAuditSink` before disclosure. - `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). diff --git a/Cargo.lock b/Cargo.lock index 616bfd78..e0604e65 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 a96143ef..5d326745 100644 --- a/docs/PRIVACY_DATA_GOVERNANCE.md +++ b/docs/PRIVACY_DATA_GOVERNANCE.md @@ -82,4 +82,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 f6739641..5cf57463 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -31,10 +31,15 @@ The full APA 7th standards/literature register remains `docs/research/standards- | posterior ESEM / longitudinal invariance / DSEM | ADR 0005 | future `psychometric_core` | accepted-target | | CPU bounded multithreading + GPU/VRAM streaming/parity | ADR 0001/0006 | future `compute_backend` | accepted-target | | 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` | `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 | | evidence-bounded LLM interpretation | ADR 0010/0012; PRD | `tepp_api` router plus future `interpretation_gateway` | partial | | adaptive direct/verify/committee/conductor test-time compute | ADR 0010; `docs/LLM_ORCHESTRATION.md` | `tepp_api::route_orchestration` + ablation record on the active PR; live contextual-orchestrator execution remaining | partial | | purpose-bound PII handling without blanket masking | ADR 0009; `docs/PRIVACY_DATA_GOVERNANCE.md` | `tepp_api` export authorization plus provider-payload minimization / elevated re-identification implemented-main; persistence retention/deletion remaining | partial | | tenant/purpose/role/lifetime access and identity separation | ADR 0009; Threat Model | `tepp_api` time-bounded `PurposeGrant` + cross-tenant denial implemented-main; persistent `access_grant` storage remaining | partial | +| selective disclosure field-grant boundary | ADR 0009; PRD | `selective_disclosure` refuses identity/source-text without re-identification and preserves scientific linkage on the active PR | 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 b705b886..3c012241 100644 --- a/docs/adr/0009-purpose-bound-pii-governance.md +++ b/docs/adr/0009-purpose-bound-pii-governance.md @@ -1,8 +1,7 @@ # ADR 0009 — Purpose-bound PII governance without blanket masking **Decision status:** Accepted -**Implementation maturity:** partial — persistence retention/deletion/legal-hold (migration `0007`) is implemented-main; purpose-bound provider-payload minimization (expired-purpose denial, log/source separation, separately authorized re-identification) is on the active PR and is not implemented-main until exact-head checks, review, and protected-main integration complete; deployment/provider-region evidence remains accepted-target - +**Implementation maturity:** partial — persistence retention/deletion/legal-hold (migration `0007`) and provider-payload minimization are implemented-main; selective field grants are active-PR and are not implemented-main until exact-head checks, review, and protected-main integration complete; deployment/provider-region evidence remains accepted-target **Date:** 2026-08-10 **Supersedes:** None. diff --git a/docs/adr/README.md b/docs/adr/README.md index 258eb7f3..47d288cb 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 | partial | Persistence retention/deletion/legal-hold (`0007`) and provider-payload minimization implemented-main; deployment evidence remains accepted-target. | +| [0009](0009-purpose-bound-pii-governance.md) | Purpose-bound PII governance without blanket masking | Accepted | partial | Selective field grants in `selective_disclosure` are active-PR; persistence retention/deletion/legal-hold and provider-payload minimization are implemented-main; deployment evidence remains accepted-target. | | [0010](0010-adaptive-llm-orchestration.md) | Adaptive LLM orchestration and test-time compute | Accepted | partial | `tepp_api` router/ablation/orchestrator binding on the active PR; live NIM execution and production ablation evidence remain accepted-target. | | [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 bfda7a79..563c904f 100644 --- a/docs/research/standards-and-literature.md +++ b/docs/research/standards-and-literature.md @@ -96,6 +96,11 @@ 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. ## Privacy lifecycle, retention, and legal hold European Union. (2016). *Regulation (EU) 2016/679 of the European Parliament and of the Council of 27 April 2016 on the protection of natural persons with regard to the processing of personal data and on the free movement of such data (General Data Protection Regulation)*. Official Journal of the European Union, L 119, 1–88. https://eur-lex.europa.eu/eli/reg/2016/679/oj diff --git a/docs/validation/temporal-event-foundation.md b/docs/validation/temporal-event-foundation.md index aae1a06e..121493e6 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 | | Purpose-bound provider payloads | `tepp_api` | implemented-main | provider-payload minimization | expired/not-yet-valid/inverted/cross-tenant/impossible-calendar grant, mapping refusal, audited elevated re-id replay | ADR 0009; `docs/research/provider-payload-minimization.md` | | Adaptive orchestration router | `tepp_api` | accepted-target | active PR | mode selection, document-control denial, ablation, credential-free bind | ADR 0010; `docs/research/adaptive-orchestration-router.md` | | CWL modular connectors | `docs/connectors/*` | implemented-main | — | contract docs + examples | PR #22; live HTTP ports remaining | 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), [])