diff --git a/.codegraph/.gitignore b/.codegraph/.gitignore new file mode 100644 index 00000000..d20c0fe4 --- /dev/null +++ b/.codegraph/.gitignore @@ -0,0 +1,5 @@ +# CodeGraph data files — local to each machine, not for committing. +# Ignore everything in .codegraph/ except this file itself, so transient +# files (the database, daemon.pid, sockets, logs) never show up in git. +* +!.gitignore diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ffe514db..471be389 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 | +| `intake_authorization` | untrusted intake fails closed without a grant; bounds are not authorization | 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 36c2e8dd..64758056 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ All notable changes to TEPP are documented here. The format follows Keep a Chang ### Added +- `provider_receipt` disclosure receipt: records provider field codes and + purpose-bound receipt metadata without persisting source text or source + identity (ADR 0009). +- `intake_authorization` identity gate: documents, serialized records, checkpoints, and LLM outputs cannot be accepted without a purpose-bound grant; size/identity/provenance bounds are not that grant; recovered grant-presence flags match known truth at a higher computed rate than accepting every intake (ADR 0009). +- `persistence_postgres` retention/deletion/legal-hold (migration `0007`): policy rows, legal holds that block completed deletion, evidence tombstones without raw-source restore, analysis exclusion only for `logical_revocation`/`identity_tombstone` (not `cache_export_removal`), and deletion requests bound to the cited retention policy's tenant/class/purpose. - `tepp_api` naruon live loopback HTTP/1.1 listener: `serve_one` installs a read/write deadline, requires a loopback `Host`, refuses `Transfer-Encoding` and NIM/proxy credential headers, parses `knowledge_cutoff` as RFC 3339 and refuses a future cutoff, keys analysis-run idempotency by tenant plus key, and proves both analysis-run and export POSTs over a real `TcpStream`. Not a production TLS/`$PORT` service (ADR 0011). - `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. diff --git a/Cargo.lock b/Cargo.lock index fb502b9c..666ae797 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -584,6 +584,10 @@ dependencies = [ "hashbrown 0.17.1", ] +[[package]] +name = "intake_authorization" +version = "0.1.0" + [[package]] name = "io-uring" version = "0.7.14" @@ -856,6 +860,10 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "provider_receipt" +version = "0.1.0" + [[package]] name = "quote" version = "1.0.47" diff --git a/Cargo.toml b/Cargo.toml index 92565940..02b0e32d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,8 @@ members = [ "crates/tepp_simulation", "crates/validation_core", "crates/tepp_api", + "crates/provider_receipt", + "crates/intake_authorization", ] default-members = [ "crates/evidence_core", @@ -23,6 +25,8 @@ default-members = [ "crates/tepp_simulation", "crates/validation_core", "crates/tepp_api", + "crates/provider_receipt", + "crates/intake_authorization", ] [workspace.package] diff --git a/README.md b/README.md index ae74015d..2adc1954 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 twelve 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,8 @@ crates/corpus_split crates/tepp_simulation crates/validation_core crates/tepp_api +crates/provider_receipt +crates/intake_authorization ``` ## Local verification diff --git a/crates/intake_authorization/Cargo.toml b/crates/intake_authorization/Cargo.toml new file mode 100644 index 00000000..15c7fb39 --- /dev/null +++ b/crates/intake_authorization/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "intake_authorization" +description = "Untrusted intake fails closed without a grant; bounds are not authorization." +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/intake_authorization/src/error.rs b/crates/intake_authorization/src/error.rs new file mode 100644 index 00000000..5436108d --- /dev/null +++ b/crates/intake_authorization/src/error.rs @@ -0,0 +1,55 @@ +//! Fail-closed intake-authorization errors. + +use std::fmt; + +/// A fail-closed intake-authorization error. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum IntakeAuthorizationError { + /// Intake was attempted without a purpose-bound grant. + MissingGrant, + /// Size, identity, or provenance bounds were treated as authorization. + BoundsAreNotAuthorization, + /// A recovery slice was empty or length-mismatched. + InvalidIntakePayload, +} + +impl fmt::Display for IntakeAuthorizationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let message = match self { + Self::MissingGrant => "untrusted intake requires a purpose-bound grant", + Self::BoundsAreNotAuthorization => { + "identity, provenance, size, and depth bounds are not authorization" + } + Self::InvalidIntakePayload => "invalid intake-authorization payload", + }; + formatter.write_str(message) + } +} + +impl std::error::Error for IntakeAuthorizationError {} + +#[cfg(test)] +mod tests { + use super::IntakeAuthorizationError; + + #[test] + fn error_messages_are_stable() { + for (error, message) in [ + ( + IntakeAuthorizationError::MissingGrant, + "untrusted intake requires a purpose-bound grant", + ), + ( + IntakeAuthorizationError::BoundsAreNotAuthorization, + "identity, provenance, size, and depth bounds are not authorization", + ), + ( + IntakeAuthorizationError::InvalidIntakePayload, + "invalid intake-authorization payload", + ), + ] { + assert_eq!(error.to_string(), message); + } + } +} diff --git a/crates/intake_authorization/src/intake.rs b/crates/intake_authorization/src/intake.rs new file mode 100644 index 00000000..9bf3198a --- /dev/null +++ b/crates/intake_authorization/src/intake.rs @@ -0,0 +1,152 @@ +//! Grant presence required at untrusted intake. + +use crate::IntakeAuthorizationError; + +/// Closed vocabulary of untrusted inbound kinds that require a grant. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum IntakeKind { + /// External document bytes. + Document, + /// Serialized domain or wire record. + SerializedRecord, + /// Model checkpoint or artifact bytes. + ModelCheckpoint, + /// LLM or agent output. + LlmOutput, +} + +impl IntakeKind { + /// Return the stable wire intake-kind name. + #[must_use] + pub const fn wire_name(self) -> &'static str { + match self { + Self::Document => "document", + Self::SerializedRecord => "serialized_record", + Self::ModelCheckpoint => "model_checkpoint", + Self::LlmOutput => "llm_output", + } + } + + /// Parse a stable wire intake-kind name. + /// + /// # Errors + /// + /// Returns [`IntakeAuthorizationError::InvalidIntakePayload`] for + /// unrecognized names. + pub fn from_wire_name(name: &str) -> Result { + match name { + "document" => Ok(Self::Document), + "serialized_record" => Ok(Self::SerializedRecord), + "model_checkpoint" => Ok(Self::ModelCheckpoint), + "llm_output" => Ok(Self::LlmOutput), + _ => Err(IntakeAuthorizationError::InvalidIntakePayload), + } + } +} + +/// Whether a purpose-bound grant is present at intake. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum GrantPresence { + /// A grant exists for this intake. + Present, + /// No grant exists for this intake. + Absent, +} + +/// Refuse untrusted intake that has no purpose-bound grant. +/// +/// Cross-purpose reuse of a present grant is owned by `purpose_authorization`. +/// Identity, provenance, size, and depth are owned by `payload_bound`. +/// +/// # Errors +/// +/// Returns [`IntakeAuthorizationError::MissingGrant`] when `grant` is +/// [`GrantPresence::Absent`]. +pub fn refuse_intake_without_grant( + kind: IntakeKind, + grant: GrantPresence, +) -> Result<(), IntakeAuthorizationError> { + let _ = kind.wire_name(); + match grant { + GrantPresence::Absent => Err(IntakeAuthorizationError::MissingGrant), + GrantPresence::Present => Ok(()), + } +} + +/// Refuse to treat size, identity, or provenance bounds as authorization. +/// +/// # Errors +/// +/// Always returns [`IntakeAuthorizationError::BoundsAreNotAuthorization`]. +pub fn refuse_bounds_as_authorization() -> Result<(), IntakeAuthorizationError> { + Err(IntakeAuthorizationError::BoundsAreNotAuthorization) +} + +/// Fraction of recovered grant-presence flags that match known truth. +/// +/// # Errors +/// +/// Returns [`IntakeAuthorizationError::InvalidIntakePayload`] when either +/// slice is empty or the lengths differ. +pub fn identity_recovery_rate( + truth: &[bool], + decided: &[bool], +) -> Result { + if truth.is_empty() || truth.len() != decided.len() { + return Err(IntakeAuthorizationError::InvalidIntakePayload); + } + let mut matches = 0_u32; + for (truth_flag, decided_flag) in truth.iter().zip(decided) { + if truth_flag == decided_flag { + matches += 1; + } + } + Ok(f64::from(matches) / truth.len() as f64) +} + +#[cfg(test)] +mod tests { + use super::{ + GrantPresence, IntakeKind, identity_recovery_rate, refuse_bounds_as_authorization, + refuse_intake_without_grant, + }; + use crate::IntakeAuthorizationError; + + #[test] + fn local_branches_cover_kinds_grants_and_payloads() { + for kind in [ + IntakeKind::Document, + IntakeKind::SerializedRecord, + IntakeKind::ModelCheckpoint, + IntakeKind::LlmOutput, + ] { + assert_eq!( + refuse_intake_without_grant(kind, GrantPresence::Absent), + Err(IntakeAuthorizationError::MissingGrant) + ); + refuse_intake_without_grant(kind, GrantPresence::Present).expect("present"); + assert_eq!( + IntakeKind::from_wire_name(kind.wire_name()).expect("round-trip"), + kind + ); + } + assert_eq!( + refuse_bounds_as_authorization(), + Err(IntakeAuthorizationError::BoundsAreNotAuthorization) + ); + assert_eq!( + IntakeKind::from_wire_name("trusted"), + Err(IntakeAuthorizationError::InvalidIntakePayload) + ); + let matched = identity_recovery_rate(&[true], &[true]).expect("rate"); + assert!((matched - 1.0).abs() < f64::EPSILON); + assert_eq!( + identity_recovery_rate(&[], &[]), + Err(IntakeAuthorizationError::InvalidIntakePayload) + ); + assert_eq!( + identity_recovery_rate(&[true], &[]), + Err(IntakeAuthorizationError::InvalidIntakePayload) + ); + } +} diff --git a/crates/intake_authorization/src/lib.rs b/crates/intake_authorization/src/lib.rs new file mode 100644 index 00000000..cc7d9b67 --- /dev/null +++ b/crates/intake_authorization/src/lib.rs @@ -0,0 +1,24 @@ +#![forbid(unsafe_code)] +#![deny(missing_docs)] +#![allow(clippy::cast_precision_loss)] +//! Untrusted intake fails closed without a grant; bounds are not authorization. +//! +//! Documents, serialized records, checkpoints, and LLM outputs require a +//! purpose-bound grant at the intake boundary. Passing size or identity +//! bounds is not that grant (ADR 0009; AGENTS.md). + +mod error; +mod intake; + +/// Fail-closed intake-authorization errors. +pub use error::IntakeAuthorizationError; +/// Whether a purpose-bound grant is present at intake. +pub use intake::GrantPresence; +/// Closed vocabulary of untrusted inbound kinds that require a grant. +pub use intake::IntakeKind; +/// Fraction of recovered grant-presence flags that match known truth. +pub use intake::identity_recovery_rate; +/// Refuse to treat size, identity, or provenance bounds as authorization. +pub use intake::refuse_bounds_as_authorization; +/// Refuse untrusted intake that has no purpose-bound grant. +pub use intake::refuse_intake_without_grant; diff --git a/crates/intake_authorization/tests/crate_contract.rs b/crates/intake_authorization/tests/crate_contract.rs new file mode 100644 index 00000000..9422e5dc --- /dev/null +++ b/crates/intake_authorization/tests/crate_contract.rs @@ -0,0 +1,7 @@ +//! Integration contract for the `intake_authorization` package identity. + +#[test] +fn package_identity_is_stable() { + let observed = std::hint::black_box(env!("CARGO_PKG_NAME")); + assert_eq!(observed, "intake_authorization"); +} diff --git a/crates/intake_authorization/tests/intake_authorization_contract.rs b/crates/intake_authorization/tests/intake_authorization_contract.rs new file mode 100644 index 00000000..cf31fe9b --- /dev/null +++ b/crates/intake_authorization/tests/intake_authorization_contract.rs @@ -0,0 +1,62 @@ +//! Untrusted intake fails closed without a grant; bounds are not authorization. + +use intake_authorization::{ + GrantPresence, IntakeAuthorizationError, IntakeKind, identity_recovery_rate, + refuse_bounds_as_authorization, refuse_intake_without_grant, +}; + +#[test] +fn untrusted_intake_fails_closed_without_a_grant() { + for kind in [ + IntakeKind::Document, + IntakeKind::SerializedRecord, + IntakeKind::ModelCheckpoint, + IntakeKind::LlmOutput, + ] { + assert_eq!( + refuse_intake_without_grant(kind, GrantPresence::Absent), + Err(IntakeAuthorizationError::MissingGrant) + ); + refuse_intake_without_grant(kind, GrantPresence::Present).expect("grant present"); + } + assert_eq!( + refuse_bounds_as_authorization(), + Err(IntakeAuthorizationError::BoundsAreNotAuthorization) + ); +} + +#[test] +fn recovered_grant_flags_match_known_truth_better_than_accepting_every_intake() { + let truth = [true, false, false]; + let recovered = [true, false, false]; + let collapsed = [true, true, true]; + let recovered_rate = identity_recovery_rate(&truth, &recovered).expect("recovered"); + let collapsed_rate = identity_recovery_rate(&truth, &collapsed).expect("collapsed"); + let expected = { + let mut matches = 0_u32; + for (truth_flag, decided_flag) in truth.iter().zip(recovered.iter()) { + if truth_flag == decided_flag { + 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); +} + +#[test] +fn empty_or_mismatched_grant_flags_fail_closed() { + assert_eq!( + identity_recovery_rate(&[], &[]), + Err(IntakeAuthorizationError::InvalidIntakePayload) + ); + assert_eq!( + identity_recovery_rate(&[true], &[]), + Err(IntakeAuthorizationError::InvalidIntakePayload) + ); + assert_eq!( + identity_recovery_rate(&[true, false], &[true]), + Err(IntakeAuthorizationError::InvalidIntakePayload) + ); +} diff --git a/crates/provider_receipt/Cargo.toml b/crates/provider_receipt/Cargo.toml new file mode 100644 index 00000000..cecc91c3 --- /dev/null +++ b/crates/provider_receipt/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "provider_receipt" +description = "Provider-disclosure receipts that refuse source text and identity." +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/provider_receipt/src/error.rs b/crates/provider_receipt/src/error.rs new file mode 100644 index 00000000..805b5e8e --- /dev/null +++ b/crates/provider_receipt/src/error.rs @@ -0,0 +1,64 @@ +//! Fail-closed provider-receipt errors. + +use std::fmt; + +/// A fail-closed provider-receipt error. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum ProviderReceiptError { + /// Raw source text was supplied to a provider receipt. + SourceTextNotDisclosable, + /// Source identity was supplied to a provider receipt. + SourceIdentityNotDisclosable, + /// Blanket PII masking was treated as a disclosure grant. + BlanketMaskIsNotAuthorization, + /// A receipt or recovery slice was empty or length-mismatched. + InvalidReceiptPayload, +} + +impl fmt::Display for ProviderReceiptError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let message = match self { + Self::SourceTextNotDisclosable => "source text cannot appear in a provider receipt", + Self::SourceIdentityNotDisclosable => { + "source identity cannot appear in a provider receipt" + } + Self::BlanketMaskIsNotAuthorization => { + "blanket PII masking is not provider-disclosure authorization" + } + Self::InvalidReceiptPayload => "invalid provider-receipt payload", + }; + formatter.write_str(message) + } +} + +impl std::error::Error for ProviderReceiptError {} + +#[cfg(test)] +mod tests { + use super::ProviderReceiptError; + + #[test] + fn error_messages_are_stable() { + for (error, message) in [ + ( + ProviderReceiptError::SourceTextNotDisclosable, + "source text cannot appear in a provider receipt", + ), + ( + ProviderReceiptError::SourceIdentityNotDisclosable, + "source identity cannot appear in a provider receipt", + ), + ( + ProviderReceiptError::BlanketMaskIsNotAuthorization, + "blanket PII masking is not provider-disclosure authorization", + ), + ( + ProviderReceiptError::InvalidReceiptPayload, + "invalid provider-receipt payload", + ), + ] { + assert_eq!(error.to_string(), message); + } + } +} diff --git a/crates/provider_receipt/src/lib.rs b/crates/provider_receipt/src/lib.rs new file mode 100644 index 00000000..376aea95 --- /dev/null +++ b/crates/provider_receipt/src/lib.rs @@ -0,0 +1,24 @@ +#![forbid(unsafe_code)] +#![deny(missing_docs)] +#![allow(clippy::cast_precision_loss)] +//! Provider-disclosure receipts that refuse source text and identity. +//! +//! A receipt records which field codes were sent to a model provider under one +//! purpose. It cannot carry source text or source identity, and blanket PII +//! masking is not a disclosure grant (ADR 0009). + +mod error; +mod receipt; + +/// Fail-closed provider-receipt errors. +pub use error::ProviderReceiptError; +/// One provider-disclosure receipt of field codes under a purpose. +pub use receipt::ProviderReceipt; +/// Fraction of recovered field codes that match known truth. +pub use receipt::receipt_recovery_rate; +/// Refuse to treat a blanket PII mask as provider-disclosure authorization. +pub use receipt::refuse_blanket_mask_as_disclosure; +/// Refuse to place source identity in a provider receipt. +pub use receipt::refuse_source_identity_in_receipt; +/// Refuse to place raw source text in a provider receipt. +pub use receipt::refuse_source_text_in_receipt; diff --git a/crates/provider_receipt/src/receipt.rs b/crates/provider_receipt/src/receipt.rs new file mode 100644 index 00000000..af3d98e7 --- /dev/null +++ b/crates/provider_receipt/src/receipt.rs @@ -0,0 +1,128 @@ +//! Purpose-bound field-code receipts for provider disclosure. + +use crate::ProviderReceiptError; + +/// One provider-disclosure receipt of field codes under a purpose. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ProviderReceipt { + purpose_code: u16, + field_codes: Vec, +} + +impl ProviderReceipt { + /// Record the field codes sent to a provider under one purpose. + /// + /// # Errors + /// + /// Returns [`ProviderReceiptError::InvalidReceiptPayload`] when no field + /// codes are supplied. + pub fn new(purpose_code: u16, field_codes: &[u16]) -> Result { + if field_codes.is_empty() { + return Err(ProviderReceiptError::InvalidReceiptPayload); + } + Ok(Self { + purpose_code, + field_codes: field_codes.to_vec(), + }) + } + + /// Purpose bound to the disclosure. + #[must_use] + pub const fn purpose_code(&self) -> u16 { + self.purpose_code + } + + /// Field codes sent, never source text. + #[must_use] + pub fn field_codes(&self) -> &[u16] { + &self.field_codes + } +} + +/// Refuse to place raw source text in a provider receipt. +/// +/// # Errors +/// +/// Always returns [`ProviderReceiptError::SourceTextNotDisclosable`]. +pub fn refuse_source_text_in_receipt() -> Result<(), ProviderReceiptError> { + Err(ProviderReceiptError::SourceTextNotDisclosable) +} + +/// Refuse to place source identity in a provider receipt. +/// +/// # Errors +/// +/// Always returns [`ProviderReceiptError::SourceIdentityNotDisclosable`]. +pub fn refuse_source_identity_in_receipt() -> Result<(), ProviderReceiptError> { + Err(ProviderReceiptError::SourceIdentityNotDisclosable) +} + +/// Refuse to treat a blanket PII mask as provider-disclosure authorization. +/// +/// # Errors +/// +/// Always returns [`ProviderReceiptError::BlanketMaskIsNotAuthorization`]. +pub fn refuse_blanket_mask_as_disclosure() -> Result<(), ProviderReceiptError> { + Err(ProviderReceiptError::BlanketMaskIsNotAuthorization) +} + +/// Fraction of recovered field codes that match known truth. +/// +/// # Errors +/// +/// Returns [`ProviderReceiptError::InvalidReceiptPayload`] when the field-code +/// lengths differ. +pub fn receipt_recovery_rate( + truth: &ProviderReceipt, + decided: &ProviderReceipt, +) -> Result { + if truth.field_codes.len() != decided.field_codes.len() { + return Err(ProviderReceiptError::InvalidReceiptPayload); + } + let mut matches = 0_u32; + for (truth_field, decided_field) in truth.field_codes.iter().zip(&decided.field_codes) { + if truth_field == decided_field { + matches += 1; + } + } + Ok(f64::from(matches) / truth.field_codes.len() as f64) +} + +#[cfg(test)] +mod tests { + use super::{ + ProviderReceipt, receipt_recovery_rate, refuse_blanket_mask_as_disclosure, + refuse_source_identity_in_receipt, refuse_source_text_in_receipt, + }; + use crate::ProviderReceiptError; + + #[test] + fn local_branches_cover_construct_and_fail_closed_paths() { + let receipt = ProviderReceipt::new(7, &[1, 2]).expect("receipt"); + assert_eq!(receipt.purpose_code(), 7); + assert_eq!(receipt.field_codes(), &[1, 2]); + let matched = receipt_recovery_rate(&receipt, &receipt).expect("rate"); + assert!((matched - 1.0).abs() < f64::EPSILON); + assert_eq!( + ProviderReceipt::new(7, &[]), + Err(ProviderReceiptError::InvalidReceiptPayload) + ); + let short = ProviderReceipt::new(7, &[1]).expect("short"); + assert_eq!( + receipt_recovery_rate(&receipt, &short), + Err(ProviderReceiptError::InvalidReceiptPayload) + ); + assert_eq!( + refuse_source_text_in_receipt(), + Err(ProviderReceiptError::SourceTextNotDisclosable) + ); + assert_eq!( + refuse_source_identity_in_receipt(), + Err(ProviderReceiptError::SourceIdentityNotDisclosable) + ); + assert_eq!( + refuse_blanket_mask_as_disclosure(), + Err(ProviderReceiptError::BlanketMaskIsNotAuthorization) + ); + } +} diff --git a/crates/provider_receipt/tests/crate_contract.rs b/crates/provider_receipt/tests/crate_contract.rs new file mode 100644 index 00000000..4292829c --- /dev/null +++ b/crates/provider_receipt/tests/crate_contract.rs @@ -0,0 +1,7 @@ +//! Integration contract for the `provider_receipt` package identity. + +#[test] +fn package_identity_is_stable() { + let observed = std::hint::black_box(env!("CARGO_PKG_NAME")); + assert_eq!(observed, "provider_receipt"); +} diff --git a/crates/provider_receipt/tests/receipt_contract.rs b/crates/provider_receipt/tests/receipt_contract.rs new file mode 100644 index 00000000..44af33d6 --- /dev/null +++ b/crates/provider_receipt/tests/receipt_contract.rs @@ -0,0 +1,62 @@ +//! Provider receipts cannot carry source text, identity, or a blanket mask. + +use provider_receipt::{ + ProviderReceipt, ProviderReceiptError, receipt_recovery_rate, + refuse_blanket_mask_as_disclosure, refuse_source_identity_in_receipt, + refuse_source_text_in_receipt, +}; + +fn receipt(purpose: u16, fields: &[u16]) -> ProviderReceipt { + ProviderReceipt::new(purpose, fields).expect("receipt") +} + +#[test] +fn source_text_identity_and_blanket_mask_cannot_enter_a_receipt() { + assert_eq!( + refuse_source_text_in_receipt(), + Err(ProviderReceiptError::SourceTextNotDisclosable) + ); + assert_eq!( + refuse_source_identity_in_receipt(), + Err(ProviderReceiptError::SourceIdentityNotDisclosable) + ); + assert_eq!( + refuse_blanket_mask_as_disclosure(), + Err(ProviderReceiptError::BlanketMaskIsNotAuthorization) + ); +} + +#[test] +fn recovered_field_codes_match_known_truth_better_than_a_collapsed_set() { + let truth = receipt(7, &[1, 2, 3]); + let recovered = receipt(7, &[1, 2, 3]); + let collapsed = receipt(7, &[1, 1, 1]); + let recovered_rate = receipt_recovery_rate(&truth, &recovered).expect("recovered"); + let collapsed_rate = receipt_recovery_rate(&truth, &collapsed).expect("collapsed"); + let expected = { + let mut matches = 0_u32; + for (truth_field, decided_field) in truth.field_codes().iter().zip(recovered.field_codes()) + { + if truth_field == decided_field { + matches += 1; + } + } + f64::from(matches) / f64::from(u32::try_from(truth.field_codes().len()).expect("len")) + }; + assert!((recovered_rate - expected).abs() < f64::EPSILON); + assert!(recovered_rate > collapsed_rate); +} + +#[test] +fn empty_or_mismatched_receipt_payloads_fail_closed() { + assert_eq!( + ProviderReceipt::new(7, &[]), + Err(ProviderReceiptError::InvalidReceiptPayload) + ); + let truth = receipt(7, &[1, 2]); + let short = receipt(7, &[1]); + assert_eq!( + receipt_recovery_rate(&truth, &short), + Err(ProviderReceiptError::InvalidReceiptPayload) + ); +} diff --git a/docs/PRIVACY_DATA_GOVERNANCE.md b/docs/PRIVACY_DATA_GOVERNANCE.md index a96143ef..9719215e 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 `provider_receipt` crate is the current disclosure-audit gate; persistence of receipts remains accepted-target. \ No newline at end of file diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index a3e674cf..3687b414 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -31,10 +31,10 @@ 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 | `tepp_api` router plus future `interpretation_gateway` | partial | +| 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` | `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 | +| 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; `intake_authorization` grant-presence gate on the active PR; 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; `intake_authorization` grant-presence gate on the active PR; persistent `access_grant` storage remaining | partial | | 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 (PR #42 implemented-main); loopback live listener on the active PR; production TLS 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 88ed1341..24e98f07 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 - +**Decision status:** Accepted +**Implementation maturity:** partial — persistence retention/deletion/legal-hold (migration `0007`) and purpose-bound provider-payload minimization are implemented-main; untrusted-intake grant presence in `intake_authorization` 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 **Date:** 2026-08-10 **Supersedes:** None. diff --git a/docs/adr/0011-standalone-modular-msa-boundary.md b/docs/adr/0011-standalone-modular-msa-boundary.md index d545ee23..04181fb3 100644 --- a/docs/adr/0011-standalone-modular-msa-boundary.md +++ b/docs/adr/0011-standalone-modular-msa-boundary.md @@ -1,7 +1,7 @@ # ADR 0011 — Standalone operation and modular CWL MSA boundary **Decision status:** Accepted -**Implementation maturity:** partial — Rust crates are independently usable; naruon HTTP interchange and loopback live listener (`POST /v1/analysis-runs` and `/v1/exports`, fail-closed table-access, NIM/proxy headers, RFC 3339 cutoff, stream deadline) are on the active PR (not implemented-main); production TLS/`$PORT` and remaining persistence integrations remain accepted-target +**Implementation maturity:** partial — Rust crates are independently usable; naruon HTTP interchange and loopback live listener (`POST /v1/analysis-runs` and `/v1/exports`, fail-closed table-access, NIM/proxy headers, RFC 3339 cutoff, stream deadline) are on the active PR (not implemented-main); production TLS/`$PORT` and remaining persistence integrations remain accepted-target **Date:** 2026-08-10 **Supersedes:** The broad cross-service ownership wording in ADR 0001. ADR 0001 remains authoritative for Rust-first numerical architecture. diff --git a/docs/adr/README.md b/docs/adr/README.md index 258eb7f3..6c4164a5 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 | Persistence retention/deletion/legal-hold (`0007`) and provider-payload minimization are implemented-main; untrusted-intake grant presence is `intake_authorization` on the active PR; 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/connectors/naruon-artifact-consumer.md b/docs/connectors/naruon-artifact-consumer.md index 2e4f4d6c..5fe0424c 100644 --- a/docs/connectors/naruon-artifact-consumer.md +++ b/docs/connectors/naruon-artifact-consumer.md @@ -1,6 +1,6 @@ # naruon modular consumer contract for TEPP artifacts -**Status:** Partial — versioned DTO, HTTP interchange, and loopback live listener on the active PR; production TLS/`$PORT` remaining +**Status:** Partial — versioned DTO, HTTP interchange, and loopback live listener on the active PR; production TLS/`$PORT` remaining **Last reviewed:** 2026-08-16 ## Boundary diff --git a/docs/research/intake-authorization-identity.md b/docs/research/intake-authorization-identity.md new file mode 100644 index 00000000..40519394 --- /dev/null +++ b/docs/research/intake-authorization-identity.md @@ -0,0 +1,33 @@ +# Untrusted intake requires a grant (doctoring) + +## Scope + +`intake_authorization` keeps documents, serialized records, checkpoints, +and LLM outputs out of the analysis boundary until a purpose-bound grant +is present. Size, identity, and provenance bounds are not that grant. +Recovery is the computed share of grant-presence flags that match known +truth. + +This slice does not persist grants, allocate migration `0008`, or replace +`purpose_authorization` (one grant, one purpose) or `payload_bound` +(identity/provenance/size/depth). + +## Authority + +### Normative TEPP contract + +- `docs/adr/0009-purpose-bound-pii-governance.md` — processing is + purpose-bound; blanket masking is not authorization. +- `AGENTS.md` — documents, serialized payloads, checkpoints, and LLM + outputs are untrusted until identity, provenance, size/depth, + authorization, and scientific semantics validate. + +### Supporting literature + +Voigt and Von dem Bussche (2017) treat purpose limitation as a +processing precondition, not a post-hoc filter. A size bound is not a +purpose. + +Voigt, P., & Von dem Bussche, A. (2017). *The EU General Data Protection +Regulation (GDPR): A practical guide*. Springer. +https://doi.org/10.1007/978-3-319-57959-7 diff --git a/docs/research/provider-disclosure-receipt.md b/docs/research/provider-disclosure-receipt.md new file mode 100644 index 00000000..32031f8f --- /dev/null +++ b/docs/research/provider-disclosure-receipt.md @@ -0,0 +1,30 @@ +# Provider-disclosure receipts (doctoring) + +## Scope + +`provider_receipt` records the purpose and field codes sent to a model +provider. Source text and source identity cannot enter the receipt. +Blanket PII masking is not a disclosure grant. Recovery is the computed +share of field codes that match known truth. + +This slice does not send HTTP, persist receipts, or claim CSAP, SOC 2, or +legal sufficiency. + +## Authority + +### Normative TEPP contract + +- `docs/adr/0009-purpose-bound-pii-governance.md` — model/provider payloads + are evidence-minimized and version/audit bound. +- `docs/PRIVACY_DATA_GOVERNANCE.md` — provider payload minimization and + raw-source log absence are required tests. + +### Supporting literature + +ISO/IEC 29100 treats data minimization and purpose specification as +distinct controls. They do **not** authorize copying source text into a +provider audit artifact, 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). diff --git a/docs/research/standards-and-literature.md b/docs/research/standards-and-literature.md index 28e62d5c..a2f68a5a 100644 --- a/docs/research/standards-and-literature.md +++ b/docs/research/standards-and-literature.md @@ -96,13 +96,17 @@ 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. +International Organization for Standardization and International Electrotechnical Commission. (2011). *Information technology—Security techniques—Privacy framework* (ISO/IEC Standard No. 29100:2011). Data minimization informs `provider_receipt`; it is not a certification claim. + ## 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 +Voigt, P., & Von dem Bussche, A. (2017). *The EU General Data Protection Regulation (GDPR): A practical guide*. Springer. https://doi.org/10.1007/978-3-319-57959-7 + 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 -TEPP uses these sources, together with the AICPA Trust Services Criteria cited below, as readiness inputs for purpose-bound retention, deletion, and legal hold. They are not self-certification authority. Persistence migration `0007` records policy, hold, deletion requests, and evidence tombstones; it does not assert that a deployment is lawful under GDPR Article 17 or attested under SOC 2. +TEPP uses these sources, together with the AICPA Trust Services Criteria cited below, as readiness inputs for purpose-bound retention, deletion, and legal hold. They are not self-certification authority. Persistence migration `0007` records policy, hold, deletion requests, and evidence tombstones; it does not assert that a deployment is lawful under GDPR Article 17 or attested under SOC 2. Untrusted intake still requires a purpose-bound grant; identity and size bounds are not that grant (Voigt & Von dem Bussche, 2017). ## AI risk, management systems, and assurance readiness diff --git a/docs/validation/temporal-event-foundation.md b/docs/validation/temporal-event-foundation.md index aae1a06e..de960354 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 | +| Untrusted intake grant presence | `intake_authorization` | accepted-target | active PR | refuse missing grant + refuse bounds-as-authorization + recovery vs accept-all | ADR 0009; AGENTS.md | | 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..78569bd1 100644 --- a/scripts/check_workspace_contract.py +++ b/scripts/check_workspace_contract.py @@ -23,6 +23,8 @@ "tepp_simulation", "validation_core", "tepp_api", + "provider_receipt", + "intake_authorization", ) REQUIRED_CI_SNIPPETS: tuple[str, ...] = ( diff --git a/tests/quality/test_check_docstrings.py b/tests/quality/test_check_docstrings.py index 2c11f7a5..da59f799 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 workspace_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(workspace_contract.EXPECTED_CRATES)) self.assertTrue(set(crate_roots).issubset(sources)) self.assertGreaterEqual(len(sources), len(crate_roots)) self.assertEqual(docstrings.validate_repository(REPOSITORY_ROOT), [])