diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e4bd39c..6f5ef783 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ All notable changes to OriginWeave are documented in this file. The format follo - Real loopback rustls integration covering trusted DNS SAN, Common-Name fallback rejection, wrong-name and untrusted-root rejection, fixed-time expiry and not-yet-valid failures, exact IPv4 and IPv6 SANs, TLS 1.2/TLS 1.3, required and optional ALPN, and transport-origin binding. - Cumulative interactive-first RAM, VRAM, batch, local-model, admission, pause, and compositor-pressure mitigation plans, including active-consumer reduction at exact hard limits. - Universally value-redacted network evidence with explicit path, metadata, and provenance bounds; ambiguous path rejection; validated source URLs; lowercase SHA-256 identifiers; and verification state. +- Credential-free sensitive-model disclosure evidence linking a sensitive-data request and policy decision to the exact reviewed provider, model, processing-region, retention, training, subprocessor, and export-policy identifiers without carrying protected values, prompts, outputs, or provider credentials. - Rust 1.97.1 build contract, strict Clippy and rustdoc gates, and exact production function, line, region, and branch coverage enforcement. - Hourly bounded OpenCode product-development workflow using `NVIDIA_NIM_API_KEY`, an unprivileged disposable workspace, loopback-only model broker, independently verified patches, and publication through a dedicated `OPENCODE_PR_TOKEN` that cannot review or merge. - Architecture, agent, security, contribution, research, database naming, roadmap, quality-gate, and TLS service-identity ADR documentation. diff --git a/crates/originweave-evidence/src/lib.rs b/crates/originweave-evidence/src/lib.rs index bc2b286a..c8a2b3de 100644 --- a/crates/originweave-evidence/src/lib.rs +++ b/crates/originweave-evidence/src/lib.rs @@ -9,6 +9,7 @@ mod sensitive_access; mod sensitive_handle_lifecycle; +mod sensitive_model_disclosure; pub use sensitive_access::{ MAX_SENSITIVE_FIELD_COUNT, MAX_SENSITIVE_IDENTIFIER_BYTES, SensitiveAccessClass, @@ -18,6 +19,9 @@ pub use sensitive_access::{ pub use sensitive_handle_lifecycle::{ SensitiveHandleLifecycleEvidence, SensitiveHandleLifecycleEvidenceInput, }; +pub use sensitive_model_disclosure::{ + SensitiveModelDisclosureEvidence, SensitiveModelDisclosureEvidenceInput, +}; use std::collections::BTreeMap; diff --git a/crates/originweave-evidence/src/sensitive_model_disclosure.rs b/crates/originweave-evidence/src/sensitive_model_disclosure.rs new file mode 100644 index 00000000..f7b46ab1 --- /dev/null +++ b/crates/originweave-evidence/src/sensitive_model_disclosure.rs @@ -0,0 +1,139 @@ +//! Credential-free audit metadata for policy-approved model disclosure. +//! +//! This value object records only the reviewed route and policy identifiers +//! associated with a sensitive-data decision. It intentionally carries no +//! protected value, model prompt or output, provider credential, or execution +//! authority. + +use crate::sensitive_access::{SensitiveEvidenceError, valid_identifier}; + +/// Unvalidated metadata describing one sensitive-data model disclosure route. +/// +/// The input is restricted to correlation and policy identifiers. It cannot +/// carry protected field values, prompts, model outputs, or provider secrets. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SensitiveModelDisclosureEvidenceInput { + /// Correlation identifier for the sensitive-data access request. + pub request_id: String, + /// Identifier for the policy decision governing the disclosure. + pub decision_id: String, + /// Reviewed provider identifier selected for the model route. + pub provider_id: String, + /// Reviewed model identifier selected for the model route. + pub model_id: String, + /// Reviewed processing-region identifier for the model route. + pub region_id: String, + /// Reviewed retention-policy identifier for the model route. + pub retention_policy_id: String, + /// Reviewed provider training-policy identifier for the model route. + pub training_policy_id: String, + /// Reviewed subprocessor-policy identifier for the model route. + pub subprocessor_policy_id: String, + /// Reviewed export-policy identifier for the model route. + pub export_policy_id: String, +} + +/// Immutable credential-free evidence for one approved model disclosure route. +/// +/// Construction validates every identifier with the same bounded evidence +/// rules as other sensitive-access records. The object records policy metadata +/// only and does not authorize or execute a model disclosure. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SensitiveModelDisclosureEvidence { + request_id: String, + decision_id: String, + provider_id: String, + model_id: String, + region_id: String, + retention_policy_id: String, + training_policy_id: String, + subprocessor_policy_id: String, + export_policy_id: String, +} + +impl TryFrom for SensitiveModelDisclosureEvidence { + type Error = SensitiveEvidenceError; + + fn try_from(input: SensitiveModelDisclosureEvidenceInput) -> Result { + if !valid_identifier(&input.request_id) + || !valid_identifier(&input.decision_id) + || !valid_identifier(&input.provider_id) + || !valid_identifier(&input.model_id) + || !valid_identifier(&input.region_id) + || !valid_identifier(&input.retention_policy_id) + || !valid_identifier(&input.training_policy_id) + || !valid_identifier(&input.subprocessor_policy_id) + || !valid_identifier(&input.export_policy_id) + { + return Err(SensitiveEvidenceError::InvalidIdentifier); + } + + Ok(Self { + request_id: input.request_id, + decision_id: input.decision_id, + provider_id: input.provider_id, + model_id: input.model_id, + region_id: input.region_id, + retention_policy_id: input.retention_policy_id, + training_policy_id: input.training_policy_id, + subprocessor_policy_id: input.subprocessor_policy_id, + export_policy_id: input.export_policy_id, + }) + } +} + +impl SensitiveModelDisclosureEvidence { + /// Return the originating sensitive-data access request identifier. + #[must_use] + pub fn request_id(&self) -> &str { + &self.request_id + } + + /// Return the sensitive-data policy decision identifier. + #[must_use] + pub fn decision_id(&self) -> &str { + &self.decision_id + } + + /// Return the reviewed provider identifier. + #[must_use] + pub fn provider_id(&self) -> &str { + &self.provider_id + } + + /// Return the reviewed model identifier. + #[must_use] + pub fn model_id(&self) -> &str { + &self.model_id + } + + /// Return the reviewed processing-region identifier. + #[must_use] + pub fn region_id(&self) -> &str { + &self.region_id + } + + /// Return the reviewed retention-policy identifier. + #[must_use] + pub fn retention_policy_id(&self) -> &str { + &self.retention_policy_id + } + + /// Return the reviewed provider training-policy identifier. + #[must_use] + pub fn training_policy_id(&self) -> &str { + &self.training_policy_id + } + + /// Return the reviewed subprocessor-policy identifier. + #[must_use] + pub fn subprocessor_policy_id(&self) -> &str { + &self.subprocessor_policy_id + } + + /// Return the reviewed export-policy identifier. + #[must_use] + pub fn export_policy_id(&self) -> &str { + &self.export_policy_id + } +} diff --git a/crates/originweave-evidence/tests/sensitive_model_disclosure_evidence.rs b/crates/originweave-evidence/tests/sensitive_model_disclosure_evidence.rs new file mode 100644 index 00000000..c0971516 --- /dev/null +++ b/crates/originweave-evidence/tests/sensitive_model_disclosure_evidence.rs @@ -0,0 +1,82 @@ +use originweave_evidence::{ + MAX_SENSITIVE_IDENTIFIER_BYTES, SensitiveEvidenceError, SensitiveModelDisclosureEvidence, + SensitiveModelDisclosureEvidenceInput, +}; + +fn valid_input() -> SensitiveModelDisclosureEvidenceInput { + SensitiveModelDisclosureEvidenceInput { + request_id: "request-42".to_owned(), + decision_id: "decision-42".to_owned(), + provider_id: "provider-private".to_owned(), + model_id: "model-reviewed-v1".to_owned(), + region_id: "kr-central".to_owned(), + retention_policy_id: "ephemeral-retention".to_owned(), + training_policy_id: "no-training".to_owned(), + subprocessor_policy_id: "subprocessors-reviewed-v1".to_owned(), + export_policy_id: "no-export".to_owned(), + } +} + +#[test] +fn records_exact_model_route_policy_without_protected_values() -> Result<(), SensitiveEvidenceError> +{ + let evidence = SensitiveModelDisclosureEvidence::try_from(valid_input())?; + + assert_eq!(evidence.request_id(), "request-42"); + assert_eq!(evidence.decision_id(), "decision-42"); + assert_eq!(evidence.provider_id(), "provider-private"); + assert_eq!(evidence.model_id(), "model-reviewed-v1"); + assert_eq!(evidence.region_id(), "kr-central"); + assert_eq!(evidence.retention_policy_id(), "ephemeral-retention"); + assert_eq!(evidence.training_policy_id(), "no-training"); + assert_eq!( + evidence.subprocessor_policy_id(), + "subprocessors-reviewed-v1" + ); + assert_eq!(evidence.export_policy_id(), "no-export"); + + let debug = format!("{evidence:?}"); + assert!(!debug.contains("protected-value-must-never-enter-evidence")); + assert!(!debug.contains("provider-credential-must-never-enter-evidence")); + Ok(()) +} + +#[test] +fn rejects_invalid_model_route_evidence_identifiers() { + for field in 0..9 { + let mut input = valid_input(); + let invalid = if field == 8 { + "a".repeat(MAX_SENSITIVE_IDENTIFIER_BYTES + 1) + } else { + "bad/value".to_owned() + }; + match field { + 0 => input.request_id = invalid, + 1 => input.decision_id = invalid, + 2 => input.provider_id = invalid, + 3 => input.model_id = invalid, + 4 => input.region_id = invalid, + 5 => input.retention_policy_id = invalid, + 6 => input.training_policy_id = invalid, + 7 => input.subprocessor_policy_id = invalid, + 8 => input.export_policy_id = invalid, + _ => unreachable!(), + } + assert_eq!( + SensitiveModelDisclosureEvidence::try_from(input), + Err(SensitiveEvidenceError::InvalidIdentifier) + ); + } +} + +#[test] +fn identifiers_require_meaningful_ascii_policy_tokens() { + for invalid in [String::new(), "---".to_owned(), "with space".to_owned()] { + let mut input = valid_input(); + input.provider_id = invalid; + assert_eq!( + SensitiveModelDisclosureEvidence::try_from(input), + Err(SensitiveEvidenceError::InvalidIdentifier) + ); + } +}