Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
4 changes: 4 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ members = [
"crates/tepp_simulation",
"crates/validation_core",
"crates/tepp_api",
"crates/selective_disclosure",
]
default-members = [
"crates/evidence_core",
Expand All @@ -23,6 +24,7 @@ default-members = [
"crates/tepp_simulation",
"crates/validation_core",
"crates/tepp_api",
"crates/selective_disclosure",
]

[workspace.package]
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -22,6 +22,7 @@ crates/corpus_split
crates/tepp_simulation
crates/validation_core
crates/tepp_api
crates/selective_disclosure
```

## Local verification
Expand Down
17 changes: 17 additions & 0 deletions crates/selective_disclosure/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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
315 changes: 315 additions & 0 deletions crates/selective_disclosure/src/disclosure.rs
Original file line number Diff line number Diff line change
@@ -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<u16>,
}

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<Self, SelectiveDisclosureError> {
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<DisclosedFieldSet, SelectiveDisclosureError> {
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<f64, SelectiveDisclosureError> {
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<Vec<u16>, 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)
);
}
}
Loading
Loading