From d62b7590c584765a050acbf1408ec5f09cb570dc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 18:11:20 +0900 Subject: [PATCH 01/39] feat(api): purpose-bound provider payloads refuse identity mappings ADR 0009 remaining adapter: expired-purpose denial, log/source separation, and a separately authorized scientific re-identification path. Opaque analytical IDs and membership roles stay unmasked. No new migration while 0007 is in flight on #45. --- CHANGELOG.md | 1 + DOCUMENTATION.md | 1 + crates/tepp_api/src/lib.rs | 17 + crates/tepp_api/src/provider_payload.rs | 583 ++++++++++++++++++ .../tests/provider_payload_contract.rs | 197 ++++++ docs/API_CONTRACT.md | 4 + docs/PRIVACY_DATA_GOVERNANCE.md | 2 + docs/TRACEABILITY.md | 4 +- docs/adr/0009-purpose-bound-pii-governance.md | 3 +- docs/adr/README.md | 2 +- .../research/provider-payload-minimization.md | 34 + docs/research/standards-and-literature.md | 10 + .../task-12-versioned-api-contracts.md | 7 +- docs/validation/temporal-event-foundation.md | 1 + 14 files changed, 860 insertions(+), 6 deletions(-) create mode 100644 crates/tepp_api/src/provider_payload.rs create mode 100644 crates/tepp_api/tests/provider_payload_contract.rs create mode 100644 docs/research/provider-payload-minimization.md diff --git a/CHANGELOG.md b/CHANGELOG.md index f3764d25..2962d384 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 +- `tepp_api` purpose-bound provider-payload minimization: time-bounded `PurposeGrant` evaluation, fail-closed expired/not-yet-valid and cross-tenant denial, refusal to copy identity mappings into model-provider payloads or ordinary logs, preservation of opaque analytical identifiers and membership roles (no blanket PII mask), and a separately authorized scientific re-identification path. - `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. - `persistence_postgres` backup/restore integrity: restored snapshots stay unusable until tenant, canonical `SHA-256`, knowledge-cutoff eligibility, temporal window order, and append-only triggers revalidate; SQL probes raise `restore integrity failed` (ADR 0013). - `persistence_postgres` concurrent document-write stress: atomic revise `DO` block that requires exactly one open `system_to` close, SQLSTATE mapping onto `ConcurrentWriteConflict` / `DuplicateDocumentRecord`, and live multi-session insert/revise/append-only proofs. No new migration number. diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index f60654ab..f64cf32b 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -34,6 +34,7 @@ TEPP's approved PRD v0.4 and implementation plan are the primary product baselin | Actions workflow fleet audit | [`docs/operations/ACTIONS_WORKFLOW_FLEET.md`](docs/operations/ACTIONS_WORKFLOW_FLEET.md) | | Actions fleet research doctoring | [`docs/research/actions-workflow-fleet.md`](docs/research/actions-workflow-fleet.md) | | Retention/deletion/legal-hold doctoring | [`docs/research/retention-deletion-legal-hold.md`](docs/research/retention-deletion-legal-hold.md) | +| Provider-payload minimization doctoring | [`docs/research/provider-payload-minimization.md`](docs/research/provider-payload-minimization.md) | | Hourly NIM OpenCode doctoring | [`docs/doctoring/hourly-nim-opencode-development.md`](docs/doctoring/hourly-nim-opencode-development.md) | | Change history | [`CHANGELOG.md`](CHANGELOG.md) | diff --git a/crates/tepp_api/src/lib.rs b/crates/tepp_api/src/lib.rs index b675a818..b5646761 100644 --- a/crates/tepp_api/src/lib.rs +++ b/crates/tepp_api/src/lib.rs @@ -15,6 +15,7 @@ mod envelope; mod error; mod export; mod naruon_http; +mod provider_payload; mod wire; /// Analysis-run contract version constant. @@ -66,3 +67,19 @@ pub use naruon_http::naruon_analysis_run_exchange_with_headers; pub use naruon_http::naruon_export_exchange; /// Refuse lexical heuristics as TEPP inference claims. pub use naruon_http::naruon_may_claim_tepp_inference; +/// Elevated re-identification result. +pub use provider_payload::DisclosedIdentityMapping; +/// Separately protected identity mapping. +pub use provider_payload::IdentityMappingRecord; +/// Minimized provider payload without direct identity. +pub use provider_payload::MinimizedProviderPayload; +/// Log-safe provider disclosure record. +pub use provider_payload::ProviderDisclosureLog; +/// Evidence offered to a model provider. +pub use provider_payload::ProviderEvidenceOffer; +/// Time-bounded purpose grant. +pub use provider_payload::PurposeGrant; +/// Disclose a mapping on the elevated scientific path. +pub use provider_payload::disclose_identity_mapping; +/// Minimize evidence for a model provider. +pub use provider_payload::minimize_provider_payload; diff --git a/crates/tepp_api/src/provider_payload.rs b/crates/tepp_api/src/provider_payload.rs new file mode 100644 index 00000000..20e7ab77 --- /dev/null +++ b/crates/tepp_api/src/provider_payload.rs @@ -0,0 +1,583 @@ +//! Purpose-bound provider payloads and separately authorized re-identification. + +use crate::ApiError; +use crate::authorization::{ + AnalyticalPurpose, ExportAuthorizationRequest, authorize_export, require_export_allowed, +}; +use crate::wire::require_nonempty; +use std::fmt; + +/// Time-bounded purpose grant evaluated at a decision instant. +/// +/// `valid_from` / `valid_to` are RFC 3339 UTC instants (`YYYY-MM-DDTHH:MM:SSZ`). +/// An omitted `valid_to` is an open-ended grant. The decision instant is the +/// authorization's available/system time and must not use future-available +/// evidence. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PurposeGrant { + /// Opaque tenant/workspace identity bound to the grant. + pub tenant_workspace_id: String, + /// Opaque principal identity (not a password or token). + pub principal_id: String, + /// Declared analytical purpose. + pub purpose: AnalyticalPurpose, + /// Inclusive grant start instant. + pub valid_from: String, + /// Inclusive grant end instant, or `None` for an open-ended grant. + pub valid_to: Option, + /// Whether a separate re-identification path is authorized. + pub reidentification_authorized: bool, +} + +/// Evidence offered to a model provider or modular CWL peer. +/// +/// Direct identity mappings must stay empty on this path. Opaque analytical +/// identifiers and membership roles are scientific linkage and are not +/// blanket-masked. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ProviderEvidenceOffer { + /// Tenant/workspace that owns the offered evidence. + pub tenant_workspace_id: String, + /// Opaque artifact identity. + pub artifact_id: String, + /// Opaque analytical identifier retained for multilevel membership. + pub opaque_analytical_id: String, + /// Optional free-text source body. + pub source_text: Option, + /// Direct identity mapping; must be absent for provider disclosure. + pub identity_mapping: Option, + /// Optional contextual membership role preserved as scientific linkage. + pub membership_role: Option, +} + +/// Minimized payload that may be sent to a provider. +/// +/// Construct only via [`minimize_provider_payload`]. The payload never carries +/// a direct identity mapping. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MinimizedProviderPayload { + artifact_id: String, + opaque_analytical_id: String, + source_text: Option, + membership_role: Option, +} + +impl MinimizedProviderPayload { + /// Opaque artifact identity. + #[must_use] + pub fn artifact_id(&self) -> &str { + &self.artifact_id + } + + /// Opaque analytical identifier retained for measurement. + #[must_use] + pub fn opaque_analytical_id(&self) -> &str { + &self.opaque_analytical_id + } + + /// Free-text source body when the purpose grant allows it. + #[must_use] + pub fn source_text(&self) -> Option<&str> { + self.source_text.as_deref() + } + + /// Contextual membership role, if offered. + #[must_use] + pub fn membership_role(&self) -> Option<&str> { + self.membership_role.as_deref() + } + + /// Direct identity mapping; always absent on the provider path. + #[must_use] + pub const fn identity_mapping(&self) -> Option<&str> { + None + } +} + +/// Log-safe record of a provider disclosure decision. +/// +/// Ordinary logs must not copy source text or direct identity. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ProviderDisclosureLog { + purpose: String, + included_source_text: bool, + included_identity_mapping: bool, +} + +impl ProviderDisclosureLog { + /// Stable purpose wire name recorded with the decision. + #[must_use] + pub fn purpose_wire_name(&self) -> &str { + &self.purpose + } + + /// Whether the minimized payload included a source body. + #[must_use] + pub const fn included_source_text(&self) -> bool { + self.included_source_text + } + + /// Whether a direct identity mapping was included; always false. + #[must_use] + pub const fn included_identity_mapping(&self) -> bool { + self.included_identity_mapping + } +} + +impl fmt::Display for ProviderDisclosureLog { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + formatter, + "provider_disclosure purpose={} source={} mapping={}", + self.purpose, self.included_source_text, self.included_identity_mapping + ) + } +} + +/// Separately protected identity mapping. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct IdentityMappingRecord { + /// Tenant/workspace that owns the mapping. + pub tenant_workspace_id: String, + /// Opaque analytical identifier. + pub opaque_analytical_id: String, + /// Direct identity string; never copied onto a provider payload. + pub direct_identity: String, +} + +/// Result of an elevated re-identification disclosure. +/// +/// Construct only via [`disclose_identity_mapping`]. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DisclosedIdentityMapping { + opaque_analytical_id: String, + direct_identity: String, +} + +impl DisclosedIdentityMapping { + /// Opaque analytical identifier that was resolved. + #[must_use] + pub fn opaque_analytical_id(&self) -> &str { + &self.opaque_analytical_id + } + + /// Direct identity released on the elevated path only. + #[must_use] + pub fn direct_identity(&self) -> &str { + &self.direct_identity + } +} + +/// Minimize evidence for a model provider without blanket PII masking. +/// +/// Opaque analytical identifiers and membership roles are preserved. Source +/// text follows [`crate::authorize_export`]. Direct identity mappings are +/// refused on this path even when re-identification is separately authorized. +/// +/// # Errors +/// +/// Returns [`ApiError::InvalidWirePayload`] for empty identities, empty +/// optional strings, inverted grant windows, or non-canonical RFC 3339 UTC +/// instants. Returns [`ApiError::AuthorizationDenied`] for expired or +/// not-yet-valid grants, cross-tenant offers, attached identity mappings, or +/// purpose-denied source text. +pub fn minimize_provider_payload( + grant: &PurposeGrant, + offer: &ProviderEvidenceOffer, + decision_time: &str, +) -> Result<(MinimizedProviderPayload, ProviderDisclosureLog), ApiError> { + validate_grant(grant)?; + require_nonempty(&offer.tenant_workspace_id)?; + require_nonempty(&offer.artifact_id)?; + require_nonempty(&offer.opaque_analytical_id)?; + require_optional_nonempty(offer.source_text.as_deref())?; + require_optional_nonempty(offer.membership_role.as_deref())?; + require_optional_nonempty(offer.identity_mapping.as_deref())?; + require_rfc3339_utc(decision_time)?; + if !grant_covers(grant, decision_time) { + return Err(ApiError::AuthorizationDenied); + } + if offer.tenant_workspace_id != grant.tenant_workspace_id { + return Err(ApiError::AuthorizationDenied); + } + if offer.identity_mapping.is_some() { + return Err(ApiError::AuthorizationDenied); + } + let decision = authorize_export(&ExportAuthorizationRequest { + tenant_workspace_id: grant.tenant_workspace_id.clone(), + principal_id: grant.principal_id.clone(), + purpose: grant.purpose, + artifact_id: offer.artifact_id.clone(), + includes_source_text: offer.source_text.is_some(), + })?; + require_export_allowed(&decision)?; + let log = ProviderDisclosureLog { + purpose: grant.purpose.wire_name().into(), + included_source_text: offer.source_text.is_some(), + included_identity_mapping: false, + }; + Ok(( + MinimizedProviderPayload { + artifact_id: offer.artifact_id.clone(), + opaque_analytical_id: offer.opaque_analytical_id.clone(), + source_text: offer.source_text.clone(), + membership_role: offer.membership_role.clone(), + }, + log, + )) +} + +/// Disclose a direct identity mapping on the elevated scientific path. +/// +/// This is not a provider payload. Modular consumers, operational monitoring, +/// and partner disclosure cannot receive the mapping even when the grant flag +/// is set. +/// +/// # Errors +/// +/// Returns [`ApiError::InvalidWirePayload`] for empty identities, inverted +/// windows, or non-canonical instants. Returns +/// [`ApiError::AuthorizationDenied`] when the grant is expired, not yet +/// valid, cross-tenant, missing the elevated flag, or not +/// [`AnalyticalPurpose::ScientificValidation`]. +pub fn disclose_identity_mapping( + grant: &PurposeGrant, + mapping: &IdentityMappingRecord, + decision_time: &str, +) -> Result { + validate_grant(grant)?; + require_nonempty(&mapping.tenant_workspace_id)?; + require_nonempty(&mapping.opaque_analytical_id)?; + require_nonempty(&mapping.direct_identity)?; + require_rfc3339_utc(decision_time)?; + if !grant_covers(grant, decision_time) { + return Err(ApiError::AuthorizationDenied); + } + if mapping.tenant_workspace_id != grant.tenant_workspace_id { + return Err(ApiError::AuthorizationDenied); + } + if !grant.reidentification_authorized + || grant.purpose != AnalyticalPurpose::ScientificValidation + { + return Err(ApiError::AuthorizationDenied); + } + Ok(DisclosedIdentityMapping { + opaque_analytical_id: mapping.opaque_analytical_id.clone(), + direct_identity: mapping.direct_identity.clone(), + }) +} + +fn validate_grant(grant: &PurposeGrant) -> Result<(), ApiError> { + require_nonempty(&grant.tenant_workspace_id)?; + require_nonempty(&grant.principal_id)?; + require_rfc3339_utc(&grant.valid_from)?; + if let Some(until) = &grant.valid_to { + require_rfc3339_utc(until)?; + if until.as_str() < grant.valid_from.as_str() { + return Err(ApiError::InvalidWirePayload); + } + } + Ok(()) +} + +fn grant_covers(grant: &PurposeGrant, decision_time: &str) -> bool { + if decision_time < grant.valid_from.as_str() { + return false; + } + if let Some(until) = &grant.valid_to + && decision_time > until.as_str() + { + return false; + } + true +} + +fn require_optional_nonempty(value: Option<&str>) -> Result<(), ApiError> { + match value { + Some(text) => require_nonempty(text), + None => Ok(()), + } +} + +fn require_rfc3339_utc(value: &str) -> Result<(), ApiError> { + if is_rfc3339_utc(value) { + Ok(()) + } else { + Err(ApiError::InvalidWirePayload) + } +} + +fn is_rfc3339_utc(value: &str) -> bool { + let bytes = value.as_bytes(); + bytes.len() == 20 + && bytes[4] == b'-' + && bytes[7] == b'-' + && bytes[10] == b'T' + && bytes[13] == b':' + && bytes[16] == b':' + && bytes[19] == b'Z' + && bytes[0..4].iter().all(u8::is_ascii_digit) + && bytes[5..7].iter().all(u8::is_ascii_digit) + && bytes[8..10].iter().all(u8::is_ascii_digit) + && bytes[11..13].iter().all(u8::is_ascii_digit) + && bytes[14..16].iter().all(u8::is_ascii_digit) + && bytes[17..19].iter().all(u8::is_ascii_digit) +} + +#[cfg(test)] +mod tests { + use super::{ + DisclosedIdentityMapping, IdentityMappingRecord, MinimizedProviderPayload, + ProviderDisclosureLog, ProviderEvidenceOffer, PurposeGrant, disclose_identity_mapping, + is_rfc3339_utc, minimize_provider_payload, + }; + use crate::ApiError; + use crate::authorization::AnalyticalPurpose; + + fn grant(purpose: AnalyticalPurpose, reidentification: bool) -> PurposeGrant { + PurposeGrant { + tenant_workspace_id: "tenant-ws-1".into(), + principal_id: "principal-analyst-1".into(), + purpose, + valid_from: "2026-01-01T00:00:00Z".into(), + valid_to: Some("2026-12-31T23:59:59Z".into()), + reidentification_authorized: reidentification, + } + } + + fn offer(source: Option<&str>) -> ProviderEvidenceOffer { + ProviderEvidenceOffer { + tenant_workspace_id: "tenant-ws-1".into(), + artifact_id: "artifact-1".into(), + opaque_analytical_id: "entity-1".into(), + source_text: source.map(str::to_owned), + identity_mapping: None, + membership_role: None, + } + } + + fn mapping() -> IdentityMappingRecord { + IdentityMappingRecord { + tenant_workspace_id: "tenant-ws-1".into(), + opaque_analytical_id: "entity-1".into(), + direct_identity: "Pat Lee".into(), + } + } + + #[test] + fn rfc3339_utc_is_strict_and_windows_are_inclusive() { + assert!(is_rfc3339_utc("2026-01-01T00:00:00Z")); + assert!(!is_rfc3339_utc("2026-01-01T00:00:00+00:00")); + assert!(!is_rfc3339_utc("2026-01-01 00:00:00Z")); + assert!(!is_rfc3339_utc("20260101T000000Z")); + assert!(!is_rfc3339_utc("xxxx-01-01T00:00:00Z")); + assert!(!is_rfc3339_utc("2026-xx-01T00:00:00Z")); + assert!(!is_rfc3339_utc("2026-01-xxT00:00:00Z")); + assert!(!is_rfc3339_utc("2026-01-01Txx:00:00Z")); + assert!(!is_rfc3339_utc("2026-01-01T00:xx:00Z")); + assert!(!is_rfc3339_utc("2026-01-01T00:00:xxZ")); + + let at_start = minimize_provider_payload( + &grant(AnalyticalPurpose::ScientificValidation, false), + &offer(None), + "2026-01-01T00:00:00Z", + ) + .expect("start"); + assert!(!at_start.1.included_source_text()); + let at_end = minimize_provider_payload( + &grant(AnalyticalPurpose::ScientificValidation, false), + &offer(None), + "2026-12-31T23:59:59Z", + ) + .expect("end"); + assert!(at_end.0.identity_mapping().is_none()); + assert_eq!( + at_end.1.to_string(), + "provider_disclosure purpose=scientific_validation source=false mapping=false" + ); + } + + #[test] + fn partner_and_ops_follow_export_source_rules() { + assert_eq!( + minimize_provider_payload( + &grant(AnalyticalPurpose::PartnerDisclosure, false), + &offer(Some("body")), + "2026-06-15T12:00:00Z", + ), + Err(ApiError::AuthorizationDenied) + ); + let partner = minimize_provider_payload( + &grant(AnalyticalPurpose::PartnerDisclosure, false), + &offer(None), + "2026-06-15T12:00:00Z", + ) + .expect("partner derived"); + assert!(partner.0.source_text().is_none()); + let ops = minimize_provider_payload( + &grant(AnalyticalPurpose::OperationalMonitoring, false), + &offer(None), + "2026-06-15T12:00:00Z", + ) + .expect("ops derived"); + assert_eq!(ops.1.purpose_wire_name(), "operational_monitoring"); + } + + #[test] + fn empty_optionals_and_grant_fields_fail_closed() { + let mut empty_principal = grant(AnalyticalPurpose::ScientificValidation, false); + empty_principal.principal_id.clear(); + assert_eq!( + minimize_provider_payload(&empty_principal, &offer(None), "2026-06-15T12:00:00Z"), + Err(ApiError::InvalidWirePayload) + ); + let mut empty_source = offer(Some("")); + empty_source.source_text = Some(String::new()); + assert_eq!( + minimize_provider_payload( + &grant(AnalyticalPurpose::ScientificValidation, false), + &empty_source, + "2026-06-15T12:00:00Z", + ), + Err(ApiError::InvalidWirePayload) + ); + let mut empty_role = offer(None); + empty_role.membership_role = Some(String::new()); + assert_eq!( + minimize_provider_payload( + &grant(AnalyticalPurpose::ScientificValidation, false), + &empty_role, + "2026-06-15T12:00:00Z", + ), + Err(ApiError::InvalidWirePayload) + ); + let mut empty_mapping_text = offer(None); + empty_mapping_text.identity_mapping = Some(String::new()); + assert_eq!( + minimize_provider_payload( + &grant(AnalyticalPurpose::ScientificValidation, false), + &empty_mapping_text, + "2026-06-15T12:00:00Z", + ), + Err(ApiError::InvalidWirePayload) + ); + let mut empty_artifact = offer(None); + empty_artifact.artifact_id.clear(); + assert_eq!( + minimize_provider_payload( + &grant(AnalyticalPurpose::ScientificValidation, false), + &empty_artifact, + "2026-06-15T12:00:00Z", + ), + Err(ApiError::InvalidWirePayload) + ); + let mut empty_opaque = offer(None); + empty_opaque.opaque_analytical_id.clear(); + assert_eq!( + minimize_provider_payload( + &grant(AnalyticalPurpose::ScientificValidation, false), + &empty_opaque, + "2026-06-15T12:00:00Z", + ), + Err(ApiError::InvalidWirePayload) + ); + } + + #[test] + fn disclose_covers_remaining_fail_closed_branches() { + assert_eq!( + disclose_identity_mapping( + &grant(AnalyticalPurpose::PartnerDisclosure, true), + &mapping(), + "2026-06-15T12:00:00Z", + ), + Err(ApiError::AuthorizationDenied) + ); + let expired = PurposeGrant { + valid_to: Some("2026-02-01T00:00:00Z".into()), + ..grant(AnalyticalPurpose::ScientificValidation, true) + }; + assert_eq!( + disclose_identity_mapping(&expired, &mapping(), "2026-06-15T12:00:00Z"), + Err(ApiError::AuthorizationDenied) + ); + let inverted = PurposeGrant { + valid_from: "2026-12-31T00:00:00Z".into(), + valid_to: Some("2026-01-01T00:00:00Z".into()), + ..grant(AnalyticalPurpose::ScientificValidation, true) + }; + assert_eq!( + disclose_identity_mapping(&inverted, &mapping(), "2026-06-15T12:00:00Z"), + Err(ApiError::InvalidWirePayload) + ); + let foreign = IdentityMappingRecord { + tenant_workspace_id: "other-tenant".into(), + ..mapping() + }; + assert_eq!( + disclose_identity_mapping( + &grant(AnalyticalPurpose::ScientificValidation, true), + &foreign, + "2026-06-15T12:00:00Z", + ), + Err(ApiError::AuthorizationDenied) + ); + let mut empty = mapping(); + empty.direct_identity.clear(); + assert_eq!( + disclose_identity_mapping( + &grant(AnalyticalPurpose::ScientificValidation, true), + &empty, + "2026-06-15T12:00:00Z", + ), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + disclose_identity_mapping( + &grant(AnalyticalPurpose::ScientificValidation, true), + &mapping(), + "bad", + ), + Err(ApiError::InvalidWirePayload) + ); + let mut empty_opaque = mapping(); + empty_opaque.opaque_analytical_id.clear(); + assert_eq!( + disclose_identity_mapping( + &grant(AnalyticalPurpose::ScientificValidation, true), + &empty_opaque, + "2026-06-15T12:00:00Z", + ), + Err(ApiError::InvalidWirePayload) + ); + let mut empty_tenant = mapping(); + empty_tenant.tenant_workspace_id.clear(); + assert_eq!( + disclose_identity_mapping( + &grant(AnalyticalPurpose::ScientificValidation, true), + &empty_tenant, + "2026-06-15T12:00:00Z", + ), + Err(ApiError::InvalidWirePayload) + ); + let disclosed = DisclosedIdentityMapping { + opaque_analytical_id: "entity-1".into(), + direct_identity: "Pat Lee".into(), + }; + assert_eq!(disclosed.opaque_analytical_id(), "entity-1"); + assert_eq!(disclosed.direct_identity(), "Pat Lee"); + let payload = MinimizedProviderPayload { + artifact_id: "a".into(), + opaque_analytical_id: "e".into(), + source_text: None, + membership_role: None, + }; + assert_eq!(payload.artifact_id(), "a"); + let log = ProviderDisclosureLog { + purpose: "scientific_validation".into(), + included_source_text: false, + included_identity_mapping: false, + }; + assert!(!log.included_identity_mapping()); + } +} diff --git a/crates/tepp_api/tests/provider_payload_contract.rs b/crates/tepp_api/tests/provider_payload_contract.rs new file mode 100644 index 00000000..d329bfa9 --- /dev/null +++ b/crates/tepp_api/tests/provider_payload_contract.rs @@ -0,0 +1,197 @@ +//! Purpose-bound provider payloads refuse identity mappings and expired grants. + +use tepp_api::{ + AnalyticalPurpose, ApiError, IdentityMappingRecord, ProviderEvidenceOffer, PurposeGrant, + disclose_identity_mapping, minimize_provider_payload, +}; + +fn active_grant(purpose: AnalyticalPurpose, reidentification: bool) -> PurposeGrant { + PurposeGrant { + tenant_workspace_id: "tenant-ws-1".into(), + principal_id: "principal-analyst-1".into(), + purpose, + valid_from: "2026-01-01T00:00:00Z".into(), + valid_to: Some("2026-12-31T23:59:59Z".into()), + reidentification_authorized: reidentification, + } +} + +fn scientific_offer() -> ProviderEvidenceOffer { + ProviderEvidenceOffer { + tenant_workspace_id: "tenant-ws-1".into(), + artifact_id: "artifact-quarterly-review-1".into(), + opaque_analytical_id: "entity-opaque-42".into(), + source_text: Some("Q3 pipeline slipped after the Acme renewal stalled.".into()), + identity_mapping: None, + membership_role: Some("author".into()), + } +} + +#[test] +fn scientific_provider_payload_keeps_opaque_ids_and_roles_without_mapping() { + let (payload, log) = minimize_provider_payload( + &active_grant(AnalyticalPurpose::ScientificValidation, false), + &scientific_offer(), + "2026-06-15T12:00:00Z", + ) + .expect("scientific minimize"); + + assert_eq!(payload.artifact_id(), "artifact-quarterly-review-1"); + assert_eq!(payload.opaque_analytical_id(), "entity-opaque-42"); + assert_eq!(payload.membership_role(), Some("author")); + assert_eq!( + payload.source_text(), + Some("Q3 pipeline slipped after the Acme renewal stalled.") + ); + assert!(payload.identity_mapping().is_none()); + assert!(log.included_source_text()); + assert!(!log.included_identity_mapping()); + assert_eq!(log.purpose_wire_name(), "scientific_validation"); + assert!(!log.to_string().contains("Acme")); +} + +#[test] +fn operational_monitoring_cannot_receive_source_text() { + let error = minimize_provider_payload( + &active_grant(AnalyticalPurpose::OperationalMonitoring, false), + &scientific_offer(), + "2026-06-15T12:00:00Z", + ) + .expect_err("ops source"); + assert_eq!(error, ApiError::AuthorizationDenied); +} + +#[test] +fn expired_and_not_yet_valid_grants_fail_closed() { + let expired = PurposeGrant { + valid_to: Some("2026-03-01T00:00:00Z".into()), + ..active_grant(AnalyticalPurpose::ScientificValidation, false) + }; + assert_eq!( + minimize_provider_payload(&expired, &scientific_offer(), "2026-06-15T12:00:00Z"), + Err(ApiError::AuthorizationDenied) + ); + + let future = PurposeGrant { + valid_from: "2026-07-01T00:00:00Z".into(), + ..active_grant(AnalyticalPurpose::ScientificValidation, false) + }; + assert_eq!( + minimize_provider_payload(&future, &scientific_offer(), "2026-06-15T12:00:00Z"), + Err(ApiError::AuthorizationDenied) + ); +} + +#[test] +fn open_ended_grant_stays_valid_and_cross_tenant_is_denied() { + let open = PurposeGrant { + valid_to: None, + ..active_grant(AnalyticalPurpose::ModularServiceConsumer, false) + }; + let (payload, _) = + minimize_provider_payload(&open, &scientific_offer(), "2027-01-02T00:00:00Z") + .expect("open grant"); + assert_eq!(payload.opaque_analytical_id(), "entity-opaque-42"); + + let foreign = ProviderEvidenceOffer { + tenant_workspace_id: "tenant-ws-other".into(), + ..scientific_offer() + }; + assert_eq!( + minimize_provider_payload( + &active_grant(AnalyticalPurpose::ScientificValidation, false), + &foreign, + "2026-06-15T12:00:00Z", + ), + Err(ApiError::AuthorizationDenied) + ); +} + +#[test] +fn identity_mapping_never_enters_a_provider_payload() { + let mut offer = scientific_offer(); + offer.identity_mapping = Some("Jane Roe ".into()); + assert_eq!( + minimize_provider_payload( + &active_grant(AnalyticalPurpose::ScientificValidation, true), + &offer, + "2026-06-15T12:00:00Z", + ), + Err(ApiError::AuthorizationDenied) + ); +} + +#[test] +fn reidentification_is_a_separate_elevated_path() { + let mapping = IdentityMappingRecord { + tenant_workspace_id: "tenant-ws-1".into(), + opaque_analytical_id: "entity-opaque-42".into(), + direct_identity: "Jane Roe ".into(), + }; + + let disclosed = disclose_identity_mapping( + &active_grant(AnalyticalPurpose::ScientificValidation, true), + &mapping, + "2026-06-15T12:00:00Z", + ) + .expect("elevated"); + assert_eq!( + disclosed.direct_identity(), + "Jane Roe " + ); + assert_eq!(disclosed.opaque_analytical_id(), "entity-opaque-42"); + + assert_eq!( + disclose_identity_mapping( + &active_grant(AnalyticalPurpose::ScientificValidation, false), + &mapping, + "2026-06-15T12:00:00Z", + ), + Err(ApiError::AuthorizationDenied) + ); + assert_eq!( + disclose_identity_mapping( + &active_grant(AnalyticalPurpose::OperationalMonitoring, true), + &mapping, + "2026-06-15T12:00:00Z", + ), + Err(ApiError::AuthorizationDenied) + ); + assert_eq!( + disclose_identity_mapping( + &active_grant(AnalyticalPurpose::ModularServiceConsumer, true), + &mapping, + "2026-06-15T12:00:00Z", + ), + Err(ApiError::AuthorizationDenied) + ); +} + +#[test] +fn empty_identities_and_inverted_windows_are_invalid() { + let mut grant = active_grant(AnalyticalPurpose::ScientificValidation, false); + grant.tenant_workspace_id.clear(); + assert_eq!( + minimize_provider_payload(&grant, &scientific_offer(), "2026-06-15T12:00:00Z"), + Err(ApiError::InvalidWirePayload) + ); + + let inverted = PurposeGrant { + valid_from: "2026-12-31T00:00:00Z".into(), + valid_to: Some("2026-01-01T00:00:00Z".into()), + ..active_grant(AnalyticalPurpose::ScientificValidation, false) + }; + assert_eq!( + minimize_provider_payload(&inverted, &scientific_offer(), "2026-06-15T12:00:00Z"), + Err(ApiError::InvalidWirePayload) + ); + + assert_eq!( + minimize_provider_payload( + &active_grant(AnalyticalPurpose::ScientificValidation, false), + &scientific_offer(), + "not-a-timestamp", + ), + Err(ApiError::InvalidWirePayload) + ); +} diff --git a/docs/API_CONTRACT.md b/docs/API_CONTRACT.md index e2263ea2..2a6abf62 100644 --- a/docs/API_CONTRACT.md +++ b/docs/API_CONTRACT.md @@ -112,6 +112,10 @@ TEPP owns its application/API state, authorized evidence, model runs, and artifa `naruon` may submit evidence/analysis requests or consume versioned topic/event/psychometric artifacts. It must not treat lexical heuristics as TEPP topic inference and must not read TEPP database tables directly. HTTP interchange is `tepp_api::naruon_analysis_run_exchange` / `naruon_export_exchange` (`POST /v1/analysis-runs` and `/v1/exports` over `https` only). Detailed modular surfaces and failure modes are recorded in [`docs/connectors/naruon-artifact-consumer.md`](connectors/naruon-artifact-consumer.md). +### Provider payload minimization + +Before any naruon, contextual-orchestrator, or NVIDIA NIM submission, callers must build the payload through `tepp_api::minimize_provider_payload`. That function preserves opaque analytical identifiers and membership roles, applies purpose-bound source-text rules, refuses expired or cross-tenant grants, and never copies a direct identity mapping into the provider body or the ordinary log. Re-identification is a separate elevated scientific path (`disclose_identity_mapping`), not a provider header or prompt field. + ### contextual-orchestrator TEPP may call a provider-neutral interpretation/orchestration port for semantic unitization, blinded model review, and evidence-bounded interpretation. The orchestrator does not own TEPP's statistical truth, source evidence, model registry, merge/release authority, or scientific acceptance. Detailed port boundary and credential separation are recorded in [`docs/connectors/contextual-orchestrator-interpretation-port.md`](connectors/contextual-orchestrator-interpretation-port.md). diff --git a/docs/PRIVACY_DATA_GOVERNANCE.md b/docs/PRIVACY_DATA_GOVERNANCE.md index 88199b21..a96143ef 100644 --- a/docs/PRIVACY_DATA_GOVERNANCE.md +++ b/docs/PRIVACY_DATA_GOVERNANCE.md @@ -52,6 +52,8 @@ LLM use is optional and bounded. The default provider payload is evidence-minimi 4. treat returned content as untrusted and subject to deterministic verification; 5. allow local/private provider profiles for deployments that prohibit external disclosure. +`tepp_api::minimize_provider_payload` is the fail-closed adapter for this policy: a time-bounded purpose grant is required, identity mappings cannot ride on a provider offer, and ordinary disclosure logs record purpose and field-class flags without source text. Re-identification uses `tepp_api::disclose_identity_mapping` and is limited to scientific validation with an explicit grant flag. + `NVIDIA_NIM_API_KEY` is a development/test credential boundary, not authorization to send unrestricted production data. ## 6. Retention, deletion, and legal hold diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index c29d9743..dfcdd9e8 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -33,8 +33,8 @@ The full APA 7th standards/literature register remains `docs/research/standards- | TDT detection/tracking vs CHRONOS schema/prediction/temporal consistency | ADR 0016; PRD/research | future `event_intelligence` | accepted-target | | evidence-bounded LLM interpretation | ADR 0010/0012; PRD | future `interpretation_gateway` | accepted-target | | adaptive direct/verify/committee/conductor test-time compute | ADR 0010; `docs/LLM_ORCHESTRATION.md` | future contextual-orchestrator integration + ablation evidence | accepted-target | -| purpose-bound PII handling without blanket masking | ADR 0009; `docs/PRIVACY_DATA_GOVERNANCE.md` | future authorization/persistence/export/provider adapters | accepted-target | -| tenant/purpose/role/lifetime access and identity separation | ADR 0009; Threat Model | future service/persistence boundaries | accepted-target | +| purpose-bound PII handling without blanket masking | ADR 0009; `docs/PRIVACY_DATA_GOVERNANCE.md` | `tepp_api` export authorization plus provider-payload minimization / elevated re-identification 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 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 (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 229733fd..b705b886 100644 --- a/docs/adr/0009-purpose-bound-pii-governance.md +++ b/docs/adr/0009-purpose-bound-pii-governance.md @@ -1,7 +1,8 @@ # ADR 0009 — Purpose-bound PII governance without blanket masking **Decision status:** Accepted -**Implementation maturity:** partial — persistence retention/deletion/legal-hold (migration `0007`) is on the active PR and is not implemented-main until exact-head checks, review, and protected-main integration complete; authorization/export/provider adapters remain accepted-target +**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 + **Date:** 2026-08-10 **Supersedes:** None. diff --git a/docs/adr/README.md b/docs/adr/README.md index 5d3aa546..f16c2345 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`) is on the active PR; authorization/export/provider adapters and deployment evidence remain accepted-target. | +| [0009](0009-purpose-bound-pii-governance.md) | Purpose-bound PII governance without blanket masking | Accepted | partial | Persistence retention/deletion/legal-hold (`0007`) implemented-main; provider-payload minimization and elevated re-identification are on the active PR; deployment evidence remains accepted-target. | | [0010](0010-adaptive-llm-orchestration.md) | Adaptive LLM orchestration and test-time compute | Accepted | accepted-target | Owns direct/verify/committee/conductor selection, budget, role/topology, and ablation policy. | | [0011](0011-standalone-modular-msa-boundary.md) | Standalone operation and modular CWL MSA boundary | Accepted | partial | Owns cross-service persistence/credential/API authority; no direct cross-service application-table coupling. | | [0012](0012-temporal-relational-shared-latent-topic-measurement.md) | Temporal Relational Shared-Latent Topic Measurement (TRSL-TM) | Accepted | accepted-target | Owns topic backend compatibility, global topic identity, method effects, K/model-selection prerequisites, and compositional topic coordinates. | diff --git a/docs/research/provider-payload-minimization.md b/docs/research/provider-payload-minimization.md new file mode 100644 index 00000000..8d5ead88 --- /dev/null +++ b/docs/research/provider-payload-minimization.md @@ -0,0 +1,34 @@ +# Provider payload minimization and purpose-bound re-identification + +## Scope + +This note doctors the `tepp_api` provider-payload adapter that implements ADR 0009 without a new database migration: + +1. a time-bounded `PurposeGrant` is evaluated at a decision instant (`YYYY-MM-DDTHH:MM:SSZ`); expired and not-yet-valid grants fail closed; +2. model-provider payloads keep opaque analytical identifiers and membership roles so multilevel measurement is not destroyed by blanket masking; +3. free-text source bodies follow the existing purpose-bound export gate; +4. direct identity mappings never enter a provider payload or an ordinary disclosure log, even when a separate re-identification flag is set; +5. re-identification is a distinct elevated path limited to scientific validation with an explicit grant flag and matching tenant. + +HTTP posting to NVIDIA NIM, naruon, or contextual-orchestrator remains a later connector slice. This adapter is the fail-closed payload contract those connectors must call. + +## Authoritative sources + +ISO/IEC. (2025). *ISO/IEC 27701:2025 Information security, cybersecurity and privacy protection — Privacy information management systems — Requirements and guidance*. International Organization for Standardization. + +National Institute of Standards and Technology. (2020). *NIST Privacy Framework: A tool for improving privacy through enterprise risk management* (Version 1.0). U.S. Department of Commerce. https://doi.org/10.6028/NIST.CSWP.01162020 + +ISO/IEC. (2019). *ISO/IEC 27701:2019 Security techniques — Extension to ISO/IEC 27001 and ISO/IEC 27002 for privacy information management — Requirements and guidelines*. International Organization for Standardization. + +## Application + +ISO/IEC 27701:2025 is the current standalone Privacy Information Management System standard and is cited for purpose limitation and disclosure minimization (ISO/IEC, 2025). The 2019 edition remains recorded because earlier TEPP doctoring referenced it as an extension to ISO/IEC 27001 (ISO/IEC, 2019). The NIST Privacy Framework supplies the Core functions (Identify-P, Control-P, Communicate-P) used to separate provider disclosure from re-identification and to keep logs free of source bodies (National Institute of Standards and Technology, 2020). These citations are readiness mappings, not certification or legal sufficiency. + +## Verification + +- scientific payloads retain opaque IDs, roles, and authorized source text; +- operational/partner source-text offers are denied; +- expired, not-yet-valid, inverted, and cross-tenant grants fail closed; +- attached identity mappings are refused on the provider path; +- elevated scientific re-identification returns the mapping; other purposes and missing flags are denied; +- disclosure logs never contain source text or mapping strings. diff --git a/docs/research/standards-and-literature.md b/docs/research/standards-and-literature.md index 75710ed3..cc5b1a92 100644 --- a/docs/research/standards-and-literature.md +++ b/docs/research/standards-and-literature.md @@ -122,6 +122,16 @@ National Institute of Standards and Technology. (n.d.). *AI risk management fram 한국인터넷진흥원. (n.d.). *클라우드서비스 보안인증제 제도소개*. Retrieved August 11, 2026, from https://isms.kisa.or.kr/main/csap/intro/index.jsp +## Privacy purpose limitation and provider minimization + +ISO/IEC. (2025). *ISO/IEC 27701:2025 Information security, cybersecurity and privacy protection — Privacy information management systems — Requirements and guidance*. International Organization for Standardization. + +ISO/IEC. (2019). *ISO/IEC 27701:2019 Security techniques — Extension to ISO/IEC 27001 and ISO/IEC 27002 for privacy information management — Requirements and guidelines*. International Organization for Standardization. + +National Institute of Standards and Technology. (2020). *NIST Privacy Framework: A tool for improving privacy through enterprise risk management* (Version 1.0). U.S. Department of Commerce. https://doi.org/10.6028/NIST.CSWP.01162020 + +TEPP applies ISO/IEC 27701:2025 purpose limitation and disclosure minimization, and the NIST Privacy Framework Control-P / Communicate-P functions, to provider payloads and separately authorized re-identification (ISO/IEC, 2025; National Institute of Standards and Technology, 2020). The 2019 edition is retained for earlier doctoring that treated PIMS as an ISO/IEC 27001 extension (ISO/IEC, 2019). These sources are readiness mappings, not certification. + TEPP uses these sources as management/risk/readiness inputs, not as self-certification authority. ISO/IEC 42001:2023 and ISO/IEC 23894:2023 are published international standards (International Organization for Standardization, 2023a, 2023b). NIST AI RMF 1.0 remains the published framework while NIST is preparing a revision (Tabassi, 2023; National Institute of Standards and Technology, n.d.); the repository tracks the revision but does not silently treat an unpublished successor as normative. AICPA Trust Services Criteria are readiness inputs rather than self-issued attestation (American Institute of Certified Public Accountants, 2023). KISA currently describes CSAP service types as IaaS, SaaS, and DaaS and grades as high, medium, and low, while noting that the high and medium grades await later implementation (한국인터넷진흥원, n.d.). CSAP and SOC 2 evidence depend on actual deployment/organization controls and independent assessment. ## Security, accessibility, and software supply chain diff --git a/docs/research/task-12-versioned-api-contracts.md b/docs/research/task-12-versioned-api-contracts.md index bc9d83fb..c5725702 100644 --- a/docs/research/task-12-versioned-api-contracts.md +++ b/docs/research/task-12-versioned-api-contracts.md @@ -10,7 +10,8 @@ Task 12 introduces fail-closed versioned wire contracts in `tepp_api` for standa 4. JSON-LD export envelopes; 5. GraphML export rendering with XML escaping; 6. committed JSON Schema and example payloads under `schemas/` and `examples/`; -7. purpose-bound export authorization that preserves scientific identity linkages and refuses blanket PII masking. +7. purpose-bound export authorization that preserves scientific identity linkages and refuses blanket PII masking; +8. purpose-bound provider-payload minimization with expired-grant denial and separately authorized re-identification. HTTP service routing remains accepted-target. Domain estimation and persistence stay outside this crate. @@ -26,11 +27,13 @@ Brandes, U., Eiglsperger, M., Herman, I., Himsolt, M., & Marshall, M. S. (2002). Wright, A., Andrews, H., Hutton, B., & Dennis, G. (2022). *JSON Schema: A media type for describing JSON documents* (Internet-Draft draft-bhutton-json-schema-01). IETF. https://datatracker.ietf.org/doc/html/draft-bhutton-json-schema-01 +ISO/IEC. (2025). *ISO/IEC 27701:2025 Information security, cybersecurity and privacy protection — Privacy information management systems — Requirements and guidance*. International Organization for Standardization. + ISO/IEC. (2019). *ISO/IEC 27701:2019 Security techniques — Extension to ISO/IEC 27001 and ISO/IEC 27002 for privacy information management — Requirements and guidelines*. International Organization for Standardization. National Institute of Standards and Technology. (2020). *NIST Privacy Framework: A tool for improving privacy through enterprise risk management* (Version 1.0). U.S. Department of Commerce. https://doi.org/10.6028/NIST.CSWP.01162020 ## Verification -- unit tests for unknown fields, unsupported versions, empty identities, byte limits, GraphML escaping, and example payload parsing; +- unit tests for unknown fields, unsupported versions, empty identities, byte limits, GraphML escaping, example payload parsing, expired-purpose denial, provider mapping refusal, and elevated re-identification; - workspace line and branch coverage gates must remain complete for production modules. diff --git a/docs/validation/temporal-event-foundation.md b/docs/validation/temporal-event-foundation.md index 295fbae0..b8870b09 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 | +| Purpose-bound provider payloads | `tepp_api` | active-PR | provider-payload minimization | expired grant, mapping refusal, elevated re-id | ADR 0009; `docs/research/provider-payload-minimization.md` | | CWL modular connectors | `docs/connectors/*` | implemented-main | — | contract docs + examples | PR #22; live HTTP ports remaining | | Release SBOM/provenance generator | `scripts/release_evidence.py` | partial | — | generate+validate in CI | Task 13 partial / PR #28 | From dbafebeede03bdb2d730e103517ab154bdda3e8d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 17:17:42 +0900 Subject: [PATCH 02/39] test(api): reject impossible provider grant instants --- .../tests/provider_payload_time_semantics.rs | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 crates/tepp_api/tests/provider_payload_time_semantics.rs diff --git a/crates/tepp_api/tests/provider_payload_time_semantics.rs b/crates/tepp_api/tests/provider_payload_time_semantics.rs new file mode 100644 index 00000000..e98602e2 --- /dev/null +++ b/crates/tepp_api/tests/provider_payload_time_semantics.rs @@ -0,0 +1,67 @@ +//! Semantic RFC 3339 validation contracts for provider-purpose grants. + +use tepp_api::{ + AnalyticalPurpose, ApiError, ProviderEvidenceOffer, PurposeGrant, + minimize_provider_payload, +}; + +fn grant() -> PurposeGrant { + PurposeGrant { + tenant_workspace_id: "tenant-workspace".into(), + principal_id: "principal-analyst".into(), + purpose: AnalyticalPurpose::ScientificValidation, + valid_from: "2026-01-01T00:00:00Z".into(), + valid_to: Some("2026-12-31T23:59:59Z".into()), + reidentification_authorized: false, + } +} + +fn offer() -> ProviderEvidenceOffer { + ProviderEvidenceOffer { + tenant_workspace_id: "tenant-workspace".into(), + artifact_id: "artifact-record".into(), + opaque_analytical_id: "analytical-identity".into(), + source_text: None, + identity_mapping: None, + membership_role: Some("project_member".into()), + } +} + +#[test] +fn provider_payload_rejects_semantically_invalid_utc_instants() { + for invalid_decision_time in [ + "2026-00-01T00:00:00Z", + "2026-13-01T00:00:00Z", + "2026-02-30T00:00:00Z", + "2026-01-01T24:00:00Z", + "2026-01-01T00:60:00Z", + "2026-01-01T00:00:60Z", + ] { + assert_eq!( + minimize_provider_payload(&grant(), &offer(), invalid_decision_time), + Err(ApiError::InvalidWirePayload), + "invalid instant must fail closed: {invalid_decision_time}", + ); + } +} + +#[test] +fn provider_payload_accepts_a_real_leap_day_and_rejects_a_false_one() { + let leap_grant = PurposeGrant { + valid_from: "2028-02-29T00:00:00Z".into(), + valid_to: Some("2028-02-29T23:59:59Z".into()), + ..grant() + }; + minimize_provider_payload(&leap_grant, &offer(), "2028-02-29T12:00:00Z") + .expect("Gregorian leap day must be accepted"); + + let false_leap_grant = PurposeGrant { + valid_from: "2027-02-29T00:00:00Z".into(), + valid_to: None, + ..grant() + }; + assert_eq!( + minimize_provider_payload(&false_leap_grant, &offer(), "2027-03-01T00:00:00Z"), + Err(ApiError::InvalidWirePayload), + ); +} From 5ebcc36769444e5e230552d117599a66bd44a14c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 17:18:26 +0900 Subject: [PATCH 03/39] fix(api): validate provider grants with temporal core --- crates/tepp_api/Cargo.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/tepp_api/Cargo.toml b/crates/tepp_api/Cargo.toml index 6768ea18..cef48bb8 100644 --- a/crates/tepp_api/Cargo.toml +++ b/crates/tepp_api/Cargo.toml @@ -16,6 +16,7 @@ publish = false [dependencies] serde = { workspace = true } serde_json = { workspace = true } +temporal_core = { path = "../temporal_core" } [lints] workspace = true From 39d547ea94d7ce77b35365e057e5eccbef9a591e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 17:20:20 +0900 Subject: [PATCH 04/39] chore(ci): verify PR 46 provider time semantics --- .../workflows/repair-pr46-provider-time.yml | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 .github/workflows/repair-pr46-provider-time.yml diff --git a/.github/workflows/repair-pr46-provider-time.yml b/.github/workflows/repair-pr46-provider-time.yml new file mode 100644 index 00000000..2f2ab24a --- /dev/null +++ b/.github/workflows/repair-pr46-provider-time.yml @@ -0,0 +1,92 @@ +name: Repair PR 46 provider time semantics + +on: + pull_request: + types: + - synchronize + - reopened + +permissions: + contents: read + +concurrency: + group: repair-tepp-pr-46-provider-time + cancel-in-progress: false + +jobs: + repair: + if: >- + github.event.pull_request.number == 46 && + github.event.pull_request.head.repo.full_name == github.repository && + github.event.pull_request.head.ref == 'agent/api-provider-payload-minimization' + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: write + steps: + - name: Checkout exact PR branch + uses: actions/checkout@631c942040754b6e095e929c1677c07e10ed4f87 + with: + ref: agent/api-provider-payload-minimization + fetch-depth: 0 + persist-credentials: true + + - name: Install pinned Rust toolchain + run: rustup toolchain install 1.97.1 --profile minimal --component clippy --component rustfmt + + - name: Prove impossible-calendar regression is RED + run: | + set +e + output=$(cargo +1.97.1 test -p tepp_api provider_payload_rejects_semantically_invalid_utc_instants 2>&1) + status=$? + set -e + printf '%s\n' "$output" + if [ "$status" -eq 0 ]; then + echo "Expected impossible provider timestamps to pass the old shape-only validator" >&2 + exit 1 + fi + grep -F "provider_payload_rejects_semantically_invalid_utc_instants" <<<"$output" + + - name: Use the shared temporal semantic parser + run: | + python3 - <<'PY' + from pathlib import Path + + path = Path("crates/tepp_api/src/provider_payload.rs") + text = path.read_text(encoding="utf-8") + old_import = "use std::fmt;\n" + new_import = "use std::fmt;\nuse temporal_core::TemporalInstant;\n" + if text.count(old_import) != 1: + raise SystemExit("provider_payload.rs: import target mismatch") + text = text.replace(old_import, new_import, 1) + + old_tail = """ && bytes[14..16].iter().all(u8::is_ascii_digit) + && bytes[17..19].iter().all(u8::is_ascii_digit) + """.replace(" ", "") + new_tail = """ && bytes[14..16].iter().all(u8::is_ascii_digit) + && bytes[17..19].iter().all(u8::is_ascii_digit) + && TemporalInstant::parse_rfc3339(value).is_ok() + """.replace(" ", "") + if text.count(old_tail) != 1: + raise SystemExit("provider_payload.rs: validator target mismatch") + path.write_text(text.replace(old_tail, new_tail, 1), encoding="utf-8") + PY + + - name: Verify focused and workspace contracts + run: | + cargo +1.97.1 fmt --all --check + cargo +1.97.1 test -p tepp_api --all-features + cargo +1.97.1 clippy -p tepp_api --all-targets --all-features -- -D warnings + cargo +1.97.1 test --workspace --all-features + python3 scripts/check_docstrings.py + python3 scripts/validate_documentation.py + + - name: Commit verified repair and remove one-shot workflow + run: | + rm -f .github/workflows/repair-pr46-provider-time.yml + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A + git diff --cached --check + git commit -m "fix(api): validate provider grant calendar semantics" + git push origin HEAD:agent/api-provider-payload-minimization From 71cc181c1923fbb9aa56cd18d4f069abe67046ad Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 17:22:34 +0900 Subject: [PATCH 05/39] fix(ci): run PR 46 repair when review starts --- .github/workflows/repair-pr46-provider-time.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/repair-pr46-provider-time.yml b/.github/workflows/repair-pr46-provider-time.yml index 2f2ab24a..431a426e 100644 --- a/.github/workflows/repair-pr46-provider-time.yml +++ b/.github/workflows/repair-pr46-provider-time.yml @@ -5,6 +5,7 @@ on: types: - synchronize - reopened + - ready_for_review permissions: contents: read From f86872bc3077f54149b24928e156644d1a751e17 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 17:23:29 +0900 Subject: [PATCH 06/39] test(api): cover impossible month lengths --- crates/tepp_api/tests/provider_payload_time_semantics.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/tepp_api/tests/provider_payload_time_semantics.rs b/crates/tepp_api/tests/provider_payload_time_semantics.rs index e98602e2..7143b2a8 100644 --- a/crates/tepp_api/tests/provider_payload_time_semantics.rs +++ b/crates/tepp_api/tests/provider_payload_time_semantics.rs @@ -33,6 +33,7 @@ fn provider_payload_rejects_semantically_invalid_utc_instants() { "2026-00-01T00:00:00Z", "2026-13-01T00:00:00Z", "2026-02-30T00:00:00Z", + "2026-04-31T00:00:00Z", "2026-01-01T24:00:00Z", "2026-01-01T00:60:00Z", "2026-01-01T00:00:60Z", From 6a6a455098c184daa333802c3b35ff16837a8a03 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 17:33:16 +0900 Subject: [PATCH 07/39] fix(ci): preserve PR 46 docs across main merge --- .../workflows/repair-pr46-provider-time.yml | 95 +++++++++++++++++-- 1 file changed, 87 insertions(+), 8 deletions(-) diff --git a/.github/workflows/repair-pr46-provider-time.yml b/.github/workflows/repair-pr46-provider-time.yml index 431a426e..a1614cb8 100644 --- a/.github/workflows/repair-pr46-provider-time.yml +++ b/.github/workflows/repair-pr46-provider-time.yml @@ -48,19 +48,25 @@ jobs: fi grep -F "provider_payload_rejects_semantically_invalid_utc_instants" <<<"$output" - - name: Use the shared temporal semantic parser + - name: Use the shared temporal semantic parser and restore merged documentation run: | python3 - <<'PY' from pathlib import Path - path = Path("crates/tepp_api/src/provider_payload.rs") - text = path.read_text(encoding="utf-8") + def replace_once(path: str, old: str, new: str) -> None: + file_path = Path(path) + text = file_path.read_text(encoding="utf-8") + if text.count(old) != 1: + raise SystemExit(f"{path}: target mismatch for {old[:80]!r}") + file_path.write_text(text.replace(old, new, 1), encoding="utf-8") + + provider_path = Path("crates/tepp_api/src/provider_payload.rs") + provider = provider_path.read_text(encoding="utf-8") old_import = "use std::fmt;\n" new_import = "use std::fmt;\nuse temporal_core::TemporalInstant;\n" - if text.count(old_import) != 1: + if provider.count(old_import) != 1: raise SystemExit("provider_payload.rs: import target mismatch") - text = text.replace(old_import, new_import, 1) - + provider = provider.replace(old_import, new_import, 1) old_tail = """ && bytes[14..16].iter().all(u8::is_ascii_digit) && bytes[17..19].iter().all(u8::is_ascii_digit) """.replace(" ", "") @@ -68,9 +74,82 @@ jobs: && bytes[17..19].iter().all(u8::is_ascii_digit) && TemporalInstant::parse_rfc3339(value).is_ok() """.replace(" ", "") - if text.count(old_tail) != 1: + if provider.count(old_tail) != 1: raise SystemExit("provider_payload.rs: validator target mismatch") - path.write_text(text.replace(old_tail, new_tail, 1), encoding="utf-8") + provider_path.write_text(provider.replace(old_tail, new_tail, 1), encoding="utf-8") + + changelog_item = ( + "- `tepp_api` purpose-bound provider-payload minimization: time-bounded " + "`PurposeGrant` evaluation, fail-closed expired/not-yet-valid and cross-tenant " + "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), and a separately " + "authorized scientific re-identification path.\n" + ) + changelog = Path("CHANGELOG.md") + changelog_text = changelog.read_text(encoding="utf-8") + if changelog_item not in changelog_text: + marker = "### Added\n\n" + if changelog_text.count(marker) != 1: + raise SystemExit("CHANGELOG.md: Added marker mismatch") + changelog.write_text( + changelog_text.replace(marker, marker + changelog_item, 1), + encoding="utf-8", + ) + + api_anchor = ( + "`naruon` may submit evidence/analysis requests or consume versioned " + "topic/event/psychometric artifacts. It must not treat lexical heuristics as TEPP " + "topic inference and must not read TEPP database tables directly. HTTP interchange " + "is `tepp_api::naruon_analysis_run_exchange` / `naruon_export_exchange` " + "(`POST /v1/analysis-runs` and `/v1/exports` over `https` only). Detailed modular " + "surfaces and failure modes are recorded in " + "[`docs/connectors/naruon-artifact-consumer.md`](connectors/naruon-artifact-consumer.md).\n" + ) + api_section = ( + "\n### Provider payload minimization\n\n" + "Before any naruon, contextual-orchestrator, or NVIDIA NIM submission, callers must " + "build the payload through `tepp_api::minimize_provider_payload`. That function " + "preserves opaque analytical identifiers and membership roles, applies purpose-bound " + "source-text rules, refuses expired, impossible-calendar, or cross-tenant grants, and " + "never copies a direct identity mapping into the provider body or ordinary log. " + "Re-identification is a separate elevated scientific path " + "(`disclose_identity_mapping`), not a provider header or prompt field.\n" + ) + api_path = Path("docs/API_CONTRACT.md") + api_text = api_path.read_text(encoding="utf-8") + if "### Provider payload minimization" not in api_text: + if api_text.count(api_anchor) != 1: + raise SystemExit("docs/API_CONTRACT.md: naruon anchor mismatch") + api_path.write_text(api_text.replace(api_anchor, api_anchor + api_section, 1), encoding="utf-8") + + replace_once( + "docs/TRACEABILITY.md", + "| purpose-bound PII handling without blanket masking | ADR 0009; `docs/PRIVACY_DATA_GOVERNANCE.md` | future authorization/persistence/export/provider adapters | accepted-target |", + "| 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 on the active PR; persistence retention/deletion remaining | partial |", + ) + replace_once( + "docs/TRACEABILITY.md", + "| tenant/purpose/role/lifetime access and identity separation | ADR 0009; Threat Model | future service/persistence boundaries | accepted-target |", + "| tenant/purpose/role/lifetime access and identity separation | ADR 0009; Threat Model | `tepp_api` time-bounded `PurposeGrant` + cross-tenant denial on the active PR; persistent `access_grant` storage remaining | partial |", + ) + + validation_path = Path("docs/validation/temporal-event-foundation.md") + validation = validation_path.read_text(encoding="utf-8") + validation_row = ( + "| Purpose-bound provider payloads | `tepp_api` | active-PR | provider-payload " + "minimization | expired/impossible-calendar grant, mapping refusal, elevated re-id | " + "ADR 0009; `docs/research/provider-payload-minimization.md` |\n" + ) + if validation_row not in validation: + marker = ( + "| 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 |\n" + ) + if validation.count(marker) != 1: + raise SystemExit("validation ledger: API row mismatch") + validation_path.write_text(validation.replace(marker, marker + validation_row, 1), encoding="utf-8") PY - name: Verify focused and workspace contracts From bcd4c479c63acac66e067dd6d5cf76a55fc4b5d3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 17:38:26 +0900 Subject: [PATCH 08/39] style(api): format provider timestamp regression --- crates/tepp_api/tests/provider_payload_time_semantics.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/tepp_api/tests/provider_payload_time_semantics.rs b/crates/tepp_api/tests/provider_payload_time_semantics.rs index 7143b2a8..2794b566 100644 --- a/crates/tepp_api/tests/provider_payload_time_semantics.rs +++ b/crates/tepp_api/tests/provider_payload_time_semantics.rs @@ -1,8 +1,7 @@ //! Semantic RFC 3339 validation contracts for provider-purpose grants. use tepp_api::{ - AnalyticalPurpose, ApiError, ProviderEvidenceOffer, PurposeGrant, - minimize_provider_payload, + AnalyticalPurpose, ApiError, ProviderEvidenceOffer, PurposeGrant, minimize_provider_payload, }; fn grant() -> PurposeGrant { From 97417f3654f6e83fec57bbb82cbe159d598b0d5c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 14 Aug 2026 08:47:27 +0000 Subject: [PATCH 09/39] fix(api): validate provider grant calendar semantics --- .../workflows/repair-pr46-provider-time.yml | 172 ------------------ CHANGELOG.md | 2 +- Cargo.lock | 1 + crates/tepp_api/src/provider_payload.rs | 2 + docs/API_CONTRACT.md | 2 +- docs/validation/temporal-event-foundation.md | 2 +- 6 files changed, 6 insertions(+), 175 deletions(-) delete mode 100644 .github/workflows/repair-pr46-provider-time.yml diff --git a/.github/workflows/repair-pr46-provider-time.yml b/.github/workflows/repair-pr46-provider-time.yml deleted file mode 100644 index a1614cb8..00000000 --- a/.github/workflows/repair-pr46-provider-time.yml +++ /dev/null @@ -1,172 +0,0 @@ -name: Repair PR 46 provider time semantics - -on: - pull_request: - types: - - synchronize - - reopened - - ready_for_review - -permissions: - contents: read - -concurrency: - group: repair-tepp-pr-46-provider-time - cancel-in-progress: false - -jobs: - repair: - if: >- - github.event.pull_request.number == 46 && - github.event.pull_request.head.repo.full_name == github.repository && - github.event.pull_request.head.ref == 'agent/api-provider-payload-minimization' - runs-on: ubuntu-latest - timeout-minutes: 30 - permissions: - contents: write - steps: - - name: Checkout exact PR branch - uses: actions/checkout@631c942040754b6e095e929c1677c07e10ed4f87 - with: - ref: agent/api-provider-payload-minimization - fetch-depth: 0 - persist-credentials: true - - - name: Install pinned Rust toolchain - run: rustup toolchain install 1.97.1 --profile minimal --component clippy --component rustfmt - - - name: Prove impossible-calendar regression is RED - run: | - set +e - output=$(cargo +1.97.1 test -p tepp_api provider_payload_rejects_semantically_invalid_utc_instants 2>&1) - status=$? - set -e - printf '%s\n' "$output" - if [ "$status" -eq 0 ]; then - echo "Expected impossible provider timestamps to pass the old shape-only validator" >&2 - exit 1 - fi - grep -F "provider_payload_rejects_semantically_invalid_utc_instants" <<<"$output" - - - name: Use the shared temporal semantic parser and restore merged documentation - run: | - python3 - <<'PY' - from pathlib import Path - - def replace_once(path: str, old: str, new: str) -> None: - file_path = Path(path) - text = file_path.read_text(encoding="utf-8") - if text.count(old) != 1: - raise SystemExit(f"{path}: target mismatch for {old[:80]!r}") - file_path.write_text(text.replace(old, new, 1), encoding="utf-8") - - provider_path = Path("crates/tepp_api/src/provider_payload.rs") - provider = provider_path.read_text(encoding="utf-8") - old_import = "use std::fmt;\n" - new_import = "use std::fmt;\nuse temporal_core::TemporalInstant;\n" - if provider.count(old_import) != 1: - raise SystemExit("provider_payload.rs: import target mismatch") - provider = provider.replace(old_import, new_import, 1) - old_tail = """ && bytes[14..16].iter().all(u8::is_ascii_digit) - && bytes[17..19].iter().all(u8::is_ascii_digit) - """.replace(" ", "") - new_tail = """ && bytes[14..16].iter().all(u8::is_ascii_digit) - && bytes[17..19].iter().all(u8::is_ascii_digit) - && TemporalInstant::parse_rfc3339(value).is_ok() - """.replace(" ", "") - if provider.count(old_tail) != 1: - raise SystemExit("provider_payload.rs: validator target mismatch") - provider_path.write_text(provider.replace(old_tail, new_tail, 1), encoding="utf-8") - - changelog_item = ( - "- `tepp_api` purpose-bound provider-payload minimization: time-bounded " - "`PurposeGrant` evaluation, fail-closed expired/not-yet-valid and cross-tenant " - "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), and a separately " - "authorized scientific re-identification path.\n" - ) - changelog = Path("CHANGELOG.md") - changelog_text = changelog.read_text(encoding="utf-8") - if changelog_item not in changelog_text: - marker = "### Added\n\n" - if changelog_text.count(marker) != 1: - raise SystemExit("CHANGELOG.md: Added marker mismatch") - changelog.write_text( - changelog_text.replace(marker, marker + changelog_item, 1), - encoding="utf-8", - ) - - api_anchor = ( - "`naruon` may submit evidence/analysis requests or consume versioned " - "topic/event/psychometric artifacts. It must not treat lexical heuristics as TEPP " - "topic inference and must not read TEPP database tables directly. HTTP interchange " - "is `tepp_api::naruon_analysis_run_exchange` / `naruon_export_exchange` " - "(`POST /v1/analysis-runs` and `/v1/exports` over `https` only). Detailed modular " - "surfaces and failure modes are recorded in " - "[`docs/connectors/naruon-artifact-consumer.md`](connectors/naruon-artifact-consumer.md).\n" - ) - api_section = ( - "\n### Provider payload minimization\n\n" - "Before any naruon, contextual-orchestrator, or NVIDIA NIM submission, callers must " - "build the payload through `tepp_api::minimize_provider_payload`. That function " - "preserves opaque analytical identifiers and membership roles, applies purpose-bound " - "source-text rules, refuses expired, impossible-calendar, or cross-tenant grants, and " - "never copies a direct identity mapping into the provider body or ordinary log. " - "Re-identification is a separate elevated scientific path " - "(`disclose_identity_mapping`), not a provider header or prompt field.\n" - ) - api_path = Path("docs/API_CONTRACT.md") - api_text = api_path.read_text(encoding="utf-8") - if "### Provider payload minimization" not in api_text: - if api_text.count(api_anchor) != 1: - raise SystemExit("docs/API_CONTRACT.md: naruon anchor mismatch") - api_path.write_text(api_text.replace(api_anchor, api_anchor + api_section, 1), encoding="utf-8") - - replace_once( - "docs/TRACEABILITY.md", - "| purpose-bound PII handling without blanket masking | ADR 0009; `docs/PRIVACY_DATA_GOVERNANCE.md` | future authorization/persistence/export/provider adapters | accepted-target |", - "| 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 on the active PR; persistence retention/deletion remaining | partial |", - ) - replace_once( - "docs/TRACEABILITY.md", - "| tenant/purpose/role/lifetime access and identity separation | ADR 0009; Threat Model | future service/persistence boundaries | accepted-target |", - "| tenant/purpose/role/lifetime access and identity separation | ADR 0009; Threat Model | `tepp_api` time-bounded `PurposeGrant` + cross-tenant denial on the active PR; persistent `access_grant` storage remaining | partial |", - ) - - validation_path = Path("docs/validation/temporal-event-foundation.md") - validation = validation_path.read_text(encoding="utf-8") - validation_row = ( - "| Purpose-bound provider payloads | `tepp_api` | active-PR | provider-payload " - "minimization | expired/impossible-calendar grant, mapping refusal, elevated re-id | " - "ADR 0009; `docs/research/provider-payload-minimization.md` |\n" - ) - if validation_row not in validation: - marker = ( - "| 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 |\n" - ) - if validation.count(marker) != 1: - raise SystemExit("validation ledger: API row mismatch") - validation_path.write_text(validation.replace(marker, marker + validation_row, 1), encoding="utf-8") - PY - - - name: Verify focused and workspace contracts - run: | - cargo +1.97.1 fmt --all --check - cargo +1.97.1 test -p tepp_api --all-features - cargo +1.97.1 clippy -p tepp_api --all-targets --all-features -- -D warnings - cargo +1.97.1 test --workspace --all-features - python3 scripts/check_docstrings.py - python3 scripts/validate_documentation.py - - - name: Commit verified repair and remove one-shot workflow - run: | - rm -f .github/workflows/repair-pr46-provider-time.yml - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -A - git diff --cached --check - git commit -m "fix(api): validate provider grant calendar semantics" - git push origin HEAD:agent/api-provider-payload-minimization diff --git a/CHANGELOG.md b/CHANGELOG.md index 2962d384..de035a10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ All notable changes to TEPP are documented here. The format follows Keep a Chang ### Added -- `tepp_api` purpose-bound provider-payload minimization: time-bounded `PurposeGrant` evaluation, fail-closed expired/not-yet-valid and cross-tenant denial, refusal to copy identity mappings into model-provider payloads or ordinary logs, preservation of opaque analytical identifiers and membership roles (no blanket PII mask), and a separately authorized scientific re-identification path. +- `tepp_api` purpose-bound provider-payload minimization: time-bounded `PurposeGrant` evaluation, fail-closed expired/not-yet-valid and cross-tenant 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), and a separately authorized scientific re-identification path. - `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. - `persistence_postgres` backup/restore integrity: restored snapshots stay unusable until tenant, canonical `SHA-256`, knowledge-cutoff eligibility, temporal window order, and append-only triggers revalidate; SQL probes raise `restore integrity failed` (ADR 0013). - `persistence_postgres` concurrent document-write stress: atomic revise `DO` block that requires exactly one open `system_to` close, SQLSTATE mapping onto `ConcurrentWriteConflict` / `DuplicateDocumentRecord`, and live multi-session insert/revise/append-only proofs. No new migration number. diff --git a/Cargo.lock b/Cargo.lock index 372a55f4..8e971100 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1272,6 +1272,7 @@ version = "0.1.0" dependencies = [ "serde", "serde_json", + "temporal_core", ] [[package]] diff --git a/crates/tepp_api/src/provider_payload.rs b/crates/tepp_api/src/provider_payload.rs index 20e7ab77..8483d1ed 100644 --- a/crates/tepp_api/src/provider_payload.rs +++ b/crates/tepp_api/src/provider_payload.rs @@ -6,6 +6,7 @@ use crate::authorization::{ }; use crate::wire::require_nonempty; use std::fmt; +use temporal_core::TemporalInstant; /// Time-bounded purpose grant evaluated at a decision instant. /// @@ -322,6 +323,7 @@ fn is_rfc3339_utc(value: &str) -> bool { && bytes[11..13].iter().all(u8::is_ascii_digit) && bytes[14..16].iter().all(u8::is_ascii_digit) && bytes[17..19].iter().all(u8::is_ascii_digit) + && TemporalInstant::parse_rfc3339(value).is_ok() } #[cfg(test)] diff --git a/docs/API_CONTRACT.md b/docs/API_CONTRACT.md index 2a6abf62..72bc45d6 100644 --- a/docs/API_CONTRACT.md +++ b/docs/API_CONTRACT.md @@ -114,7 +114,7 @@ TEPP owns its application/API state, authorized evidence, model runs, and artifa ### Provider payload minimization -Before any naruon, contextual-orchestrator, or NVIDIA NIM submission, callers must build the payload through `tepp_api::minimize_provider_payload`. That function preserves opaque analytical identifiers and membership roles, applies purpose-bound source-text rules, refuses expired or cross-tenant grants, and never copies a direct identity mapping into the provider body or the ordinary log. Re-identification is a separate elevated scientific path (`disclose_identity_mapping`), not a provider header or prompt field. +Before any naruon, contextual-orchestrator, or NVIDIA NIM submission, callers must build the payload through `tepp_api::minimize_provider_payload`. That function preserves opaque analytical identifiers and membership roles, applies purpose-bound source-text rules, refuses expired, impossible-calendar, or cross-tenant grants, and never copies a direct identity mapping into the provider body or ordinary log. Re-identification is a separate elevated scientific path (`disclose_identity_mapping`), not a provider header or prompt field. ### contextual-orchestrator diff --git a/docs/validation/temporal-event-foundation.md b/docs/validation/temporal-event-foundation.md index b8870b09..f15d5213 100644 --- a/docs/validation/temporal-event-foundation.md +++ b/docs/validation/temporal-event-foundation.md @@ -23,7 +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 | -| Purpose-bound provider payloads | `tepp_api` | active-PR | provider-payload minimization | expired grant, mapping refusal, elevated re-id | ADR 0009; `docs/research/provider-payload-minimization.md` | +| Purpose-bound provider payloads | `tepp_api` | active-PR | provider-payload minimization | expired/impossible-calendar grant, mapping refusal, elevated re-id | ADR 0009; `docs/research/provider-payload-minimization.md` | | CWL modular connectors | `docs/connectors/*` | implemented-main | — | contract docs + examples | PR #22; live HTTP ports remaining | | Release SBOM/provenance generator | `scripts/release_evidence.py` | partial | — | generate+validate in CI | Task 13 partial / PR #28 | From c296fda584a6c8cfa79055933da003a368819296 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 17:51:15 +0900 Subject: [PATCH 10/39] test(api): cover Gregorian century leap-year rules --- .../tests/provider_payload_time_semantics.rs | 39 +++++++++++-------- 1 file changed, 22 insertions(+), 17 deletions(-) diff --git a/crates/tepp_api/tests/provider_payload_time_semantics.rs b/crates/tepp_api/tests/provider_payload_time_semantics.rs index 2794b566..3a46087d 100644 --- a/crates/tepp_api/tests/provider_payload_time_semantics.rs +++ b/crates/tepp_api/tests/provider_payload_time_semantics.rs @@ -46,22 +46,27 @@ fn provider_payload_rejects_semantically_invalid_utc_instants() { } #[test] -fn provider_payload_accepts_a_real_leap_day_and_rejects_a_false_one() { - let leap_grant = PurposeGrant { - valid_from: "2028-02-29T00:00:00Z".into(), - valid_to: Some("2028-02-29T23:59:59Z".into()), - ..grant() - }; - minimize_provider_payload(&leap_grant, &offer(), "2028-02-29T12:00:00Z") - .expect("Gregorian leap day must be accepted"); +fn provider_payload_enforces_gregorian_leap_year_semantics() { + for valid_leap_day in ["2000-02-29T12:00:00Z", "2028-02-29T12:00:00Z"] { + let leap_grant = PurposeGrant { + valid_from: valid_leap_day.into(), + valid_to: Some(valid_leap_day.into()), + ..grant() + }; + minimize_provider_payload(&leap_grant, &offer(), valid_leap_day) + .expect("Gregorian leap day must be accepted"); + } - let false_leap_grant = PurposeGrant { - valid_from: "2027-02-29T00:00:00Z".into(), - valid_to: None, - ..grant() - }; - assert_eq!( - minimize_provider_payload(&false_leap_grant, &offer(), "2027-03-01T00:00:00Z"), - Err(ApiError::InvalidWirePayload), - ); + for invalid_leap_day in ["2027-02-29T00:00:00Z", "2100-02-29T00:00:00Z"] { + let false_leap_grant = PurposeGrant { + valid_from: invalid_leap_day.into(), + valid_to: None, + ..grant() + }; + assert_eq!( + minimize_provider_payload(&false_leap_grant, &offer(), "2100-03-01T00:00:00Z"), + Err(ApiError::InvalidWirePayload), + "non-leap-century and ordinary non-leap years must fail closed: {invalid_leap_day}", + ); + } } From a0fe7f9f0d26dfb0a54a4d995f577e100e9bc380 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 17:59:03 +0900 Subject: [PATCH 11/39] test(api): require append-only reidentification audit evidence --- .../tests/reidentification_audit_contract.rs | 139 ++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 crates/tepp_api/tests/reidentification_audit_contract.rs diff --git a/crates/tepp_api/tests/reidentification_audit_contract.rs b/crates/tepp_api/tests/reidentification_audit_contract.rs new file mode 100644 index 00000000..ffeaeb98 --- /dev/null +++ b/crates/tepp_api/tests/reidentification_audit_contract.rs @@ -0,0 +1,139 @@ +//! Elevated re-identification must append redacted audit evidence for every decision. + +use tepp_api::{ + AnalyticalPurpose, ApiError, IdentityMappingRecord, PurposeGrant, + ReidentificationAuditOutcome, ReidentificationAuditRecord, ReidentificationAuditSink, + disclose_identity_mapping, +}; + +const DECISION_DIGEST: &str = + "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + +#[derive(Default)] +struct RecordingAuditSink { + records: Vec, + fail_closed: bool, +} + +impl ReidentificationAuditSink for RecordingAuditSink { + fn append_reidentification_audit( + &mut self, + record: &ReidentificationAuditRecord, + ) -> Result<(), ApiError> { + if self.fail_closed { + return Err(ApiError::LimitExceeded); + } + self.records.push(record.clone()); + Ok(()) + } +} + +fn grant(reidentification_authorized: bool) -> PurposeGrant { + PurposeGrant { + tenant_workspace_id: "tenant-workspace".into(), + principal_id: "principal-analyst".into(), + purpose: AnalyticalPurpose::ScientificValidation, + valid_from: "2026-01-01T00:00:00Z".into(), + valid_to: Some("2026-12-31T23:59:59Z".into()), + reidentification_authorized, + } +} + +fn mapping() -> IdentityMappingRecord { + IdentityMappingRecord { + tenant_workspace_id: "tenant-workspace".into(), + opaque_analytical_id: "opaque-person-42".into(), + direct_identity: "Pat Lee ".into(), + } +} + +#[test] +fn successful_reidentification_appends_redacted_audit_evidence_before_disclosure() { + let mut sink = RecordingAuditSink::default(); + let (disclosed, audit) = disclose_identity_mapping( + &grant(true), + &mapping(), + "2026-06-15T12:00:00Z", + DECISION_DIGEST, + &mut sink, + ) + .expect("audited elevated disclosure"); + + assert_eq!(disclosed.direct_identity(), "Pat Lee "); + assert_eq!(sink.records, vec![audit.clone()]); + assert_eq!(audit.tenant_workspace_id(), "tenant-workspace"); + assert_eq!(audit.principal_id(), "principal-analyst"); + assert_eq!(audit.purpose_wire_name(), "scientific_validation"); + assert_eq!(audit.action_code(), "reidentify_identity_mapping"); + assert_eq!(audit.opaque_analytical_id(), "opaque-person-42"); + assert_eq!(audit.decision_time(), "2026-06-15T12:00:00Z"); + assert_eq!(audit.outcome(), ReidentificationAuditOutcome::Allowed); + assert_eq!(audit.decision_digest(), DECISION_DIGEST); + assert!(!format!("{audit:?}").contains("Pat Lee")); + assert!(!format!("{audit:?}").contains("pat.lee@example.test")); +} + +#[test] +fn denied_reidentification_is_appended_and_replay_preserves_decision_order() { + let mut sink = RecordingAuditSink::default(); + assert_eq!( + disclose_identity_mapping( + &grant(false), + &mapping(), + "2026-06-15T12:00:00Z", + DECISION_DIGEST, + &mut sink, + ), + Err(ApiError::AuthorizationDenied), + ); + let (_, allowed) = disclose_identity_mapping( + &grant(true), + &mapping(), + "2026-06-15T12:00:01Z", + DECISION_DIGEST, + &mut sink, + ) + .expect("second audited decision"); + + assert_eq!(sink.records.len(), 2); + assert_eq!( + sink.records[0].outcome(), + ReidentificationAuditOutcome::Denied + ); + assert_eq!(sink.records[1], allowed); + assert_eq!( + sink.records[1].outcome(), + ReidentificationAuditOutcome::Allowed + ); +} + +#[test] +fn disclosure_fails_closed_when_audit_append_fails_or_digest_is_invalid() { + let mut failed_sink = RecordingAuditSink { + fail_closed: true, + ..RecordingAuditSink::default() + }; + assert_eq!( + disclose_identity_mapping( + &grant(true), + &mapping(), + "2026-06-15T12:00:00Z", + DECISION_DIGEST, + &mut failed_sink, + ), + Err(ApiError::LimitExceeded), + ); + + let mut sink = RecordingAuditSink::default(); + assert_eq!( + disclose_identity_mapping( + &grant(true), + &mapping(), + "2026-06-15T12:00:00Z", + "sha256:short", + &mut sink, + ), + Err(ApiError::InvalidWirePayload), + ); + assert!(sink.records.is_empty()); +} From b7aadfdc9e80557ddbef6dccc7d91ac230367539 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 18:02:10 +0900 Subject: [PATCH 12/39] chore(ci): verify PR 46 reidentification audit repair --- .../repair-pr46-reidentification-audit.yml | 459 ++++++++++++++++++ 1 file changed, 459 insertions(+) create mode 100644 .github/workflows/repair-pr46-reidentification-audit.yml diff --git a/.github/workflows/repair-pr46-reidentification-audit.yml b/.github/workflows/repair-pr46-reidentification-audit.yml new file mode 100644 index 00000000..6ecfa12f --- /dev/null +++ b/.github/workflows/repair-pr46-reidentification-audit.yml @@ -0,0 +1,459 @@ +name: Repair PR 46 reidentification audit evidence + +on: + pull_request: + types: + - synchronize + - reopened + - ready_for_review + +permissions: + contents: read + +concurrency: + group: repair-tepp-pr-46-reidentification-audit + cancel-in-progress: false + +jobs: + repair: + if: >- + github.event.pull_request.number == 46 && + github.event.pull_request.head.repo.full_name == github.repository && + github.event.pull_request.head.ref == 'agent/api-provider-payload-minimization' + runs-on: ubuntu-latest + timeout-minutes: 35 + permissions: + contents: write + steps: + - name: Checkout exact PR branch + uses: actions/checkout@631c942040754b6e095e929c1677c07e10ed4f87 + with: + ref: agent/api-provider-payload-minimization + fetch-depth: 0 + persist-credentials: true + + - name: Install pinned Rust toolchain + run: rustup toolchain install 1.97.1 --profile minimal --component clippy --component rustfmt + + - name: Prove reidentification audit contract is RED + run: | + set +e + output=$(cargo +1.97.1 test -p tepp_api --test reidentification_audit_contract 2>&1) + status=$? + set -e + printf '%s\n' "$output" + if [ "$status" -eq 0 ]; then + echo "Expected elevated disclosure to lack append-only audit evidence before implementation" >&2 + exit 1 + fi + grep -E "ReidentificationAudit|disclose_identity_mapping" <<<"$output" + + - name: Add redacted append-only audit port and consistent evidence matrix + run: | + python3 - <<'PY' + from pathlib import Path + + provider_path = Path("crates/tepp_api/src/provider_payload.rs") + provider = provider_path.read_text(encoding="utf-8") + + def replace_provider(old: str, new: str, label: str) -> None: + global provider + if provider.count(old) != 1: + raise SystemExit(f"provider_payload.rs: {label} target mismatch") + provider = provider.replace(old, new, 1) + + disclosed_impl = """impl DisclosedIdentityMapping { + /// Opaque analytical identifier that was resolved. + #[must_use] + pub fn opaque_analytical_id(&self) -> &str { + &self.opaque_analytical_id + } + + /// Direct identity released on the elevated path only. + #[must_use] + pub fn direct_identity(&self) -> &str { + &self.direct_identity + } + } + """.replace(" ", "") + audit_types = disclosed_impl + """ +/// Redacted outcome of an elevated re-identification decision. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ReidentificationAuditOutcome { + /// The protected mapping was released after audit append succeeded. + Allowed, + /// A well-formed request was denied by purpose, tenant, lifetime, or role policy. + Denied, +} + +impl ReidentificationAuditOutcome { + /// Stable wire name for append-only audit persistence. + #[must_use] + pub const fn wire_name(self) -> &'static str { + match self { + Self::Allowed => "allowed", + Self::Denied => "denied", + } + } +} + +/// Redacted append-only evidence for an elevated re-identification decision. +/// +/// Direct identity is deliberately absent. The digest identifies the governed +/// decision input without copying protected source or mapping content. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ReidentificationAuditRecord { + tenant_workspace_id: String, + principal_id: String, + purpose_wire_name: String, + action_code: &'static str, + opaque_analytical_id: String, + decision_time: String, + outcome: ReidentificationAuditOutcome, + decision_digest: String, +} + +impl ReidentificationAuditRecord { + /// Tenant/workspace in which the decision occurred. + #[must_use] + pub fn tenant_workspace_id(&self) -> &str { + &self.tenant_workspace_id + } + + /// Opaque principal that requested disclosure. + #[must_use] + pub fn principal_id(&self) -> &str { + &self.principal_id + } + + /// Purpose wire name evaluated by policy. + #[must_use] + pub fn purpose_wire_name(&self) -> &str { + &self.purpose_wire_name + } + + /// Stable elevated action code. + #[must_use] + pub const fn action_code(&self) -> &'static str { + self.action_code + } + + /// Opaque analytical identity involved in the decision. + #[must_use] + pub fn opaque_analytical_id(&self) -> &str { + &self.opaque_analytical_id + } + + /// Canonical UTC decision instant. + #[must_use] + pub fn decision_time(&self) -> &str { + &self.decision_time + } + + /// Allowed or denied decision outcome. + #[must_use] + pub const fn outcome(&self) -> ReidentificationAuditOutcome { + self.outcome + } + + /// Canonical SHA-256 digest of the governed decision input. + #[must_use] + pub fn decision_digest(&self) -> &str { + &self.decision_digest + } +} + +/// Append-only persistence port for elevated re-identification audit evidence. +/// +/// Implementations must append an immutable row/event and must never copy the +/// disclosed direct identity into ordinary audit storage. +pub trait ReidentificationAuditSink { + /// Append one redacted decision record before disclosure or denial returns. + /// + /// # Errors + /// + /// Returns a redacted [`ApiError`] when append-only persistence fails. The + /// disclosure then fails closed and no direct identity is returned. + fn append_reidentification_audit( + &mut self, + record: &ReidentificationAuditRecord, + ) -> Result<(), ApiError>; +} +""" + replace_provider(disclosed_impl, audit_types, "audit type insertion") + + old_function = """pub fn disclose_identity_mapping( + grant: &PurposeGrant, + mapping: &IdentityMappingRecord, + decision_time: &str, +) -> Result { + validate_grant(grant)?; + require_nonempty(&mapping.tenant_workspace_id)?; + require_nonempty(&mapping.opaque_analytical_id)?; + require_nonempty(&mapping.direct_identity)?; + require_rfc3339_utc(decision_time)?; + if !grant_covers(grant, decision_time) { + return Err(ApiError::AuthorizationDenied); + } + if mapping.tenant_workspace_id != grant.tenant_workspace_id { + return Err(ApiError::AuthorizationDenied); + } + if !grant.reidentification_authorized + || grant.purpose != AnalyticalPurpose::ScientificValidation + { + return Err(ApiError::AuthorizationDenied); + } + Ok(DisclosedIdentityMapping { + opaque_analytical_id: mapping.opaque_analytical_id.clone(), + direct_identity: mapping.direct_identity.clone(), + }) +} +""" + new_function = """pub fn disclose_identity_mapping( + grant: &PurposeGrant, + mapping: &IdentityMappingRecord, + decision_time: &str, + decision_digest: &str, + audit_sink: &mut S, +) -> Result<(DisclosedIdentityMapping, ReidentificationAuditRecord), ApiError> { + validate_grant(grant)?; + require_nonempty(&mapping.tenant_workspace_id)?; + require_nonempty(&mapping.opaque_analytical_id)?; + require_nonempty(&mapping.direct_identity)?; + require_rfc3339_utc(decision_time)?; + require_sha256_digest(decision_digest)?; + + let allowed = grant_covers(grant, decision_time) + && mapping.tenant_workspace_id == grant.tenant_workspace_id + && grant.reidentification_authorized + && grant.purpose == AnalyticalPurpose::ScientificValidation; + let audit_record = ReidentificationAuditRecord { + tenant_workspace_id: grant.tenant_workspace_id.clone(), + principal_id: grant.principal_id.clone(), + purpose_wire_name: grant.purpose.wire_name().into(), + action_code: "reidentify_identity_mapping", + opaque_analytical_id: mapping.opaque_analytical_id.clone(), + decision_time: decision_time.into(), + outcome: if allowed { + ReidentificationAuditOutcome::Allowed + } else { + ReidentificationAuditOutcome::Denied + }, + decision_digest: decision_digest.into(), + }; + audit_sink.append_reidentification_audit(&audit_record)?; + if !allowed { + return Err(ApiError::AuthorizationDenied); + } + Ok(( + DisclosedIdentityMapping { + opaque_analytical_id: mapping.opaque_analytical_id.clone(), + direct_identity: mapping.direct_identity.clone(), + }, + audit_record, + )) +} +""" + replace_provider(old_function, new_function, "audited disclosure function") + + replace_provider( + """fn validate_grant(grant: &PurposeGrant) -> Result<(), ApiError> { +""", + """fn require_sha256_digest(value: &str) -> Result<(), ApiError> { + let Some(digest) = value.strip_prefix("sha256:") else { + return Err(ApiError::InvalidWirePayload); + }; + if digest.len() == 64 + && digest + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + Ok(()) + } else { + Err(ApiError::InvalidWirePayload) + } +} + +fn validate_grant(grant: &PurposeGrant) -> Result<(), ApiError> { +""", + "digest helper", + ) + + test_marker = "#[cfg(test)]\nmod tests {" + if provider.count(test_marker) != 1: + raise SystemExit("provider_payload.rs: test module marker mismatch") + production, tests = provider.split(test_marker, 1) + tests = test_marker + tests + tests = tests.replace( + "DisclosedIdentityMapping, IdentityMappingRecord, MinimizedProviderPayload,\n ProviderDisclosureLog, ProviderEvidenceOffer, PurposeGrant, disclose_identity_mapping,", + "DisclosedIdentityMapping, IdentityMappingRecord, MinimizedProviderPayload,\n ProviderDisclosureLog, ProviderEvidenceOffer, PurposeGrant, ReidentificationAuditRecord,\n ReidentificationAuditSink, disclose_identity_mapping,", + 1, + ) + mapping_helper = """ fn mapping() -> IdentityMappingRecord { + IdentityMappingRecord { + tenant_workspace_id: "tenant-ws-1".into(), + opaque_analytical_id: "entity-1".into(), + direct_identity: "Pat Lee".into(), + } + } +""" + audit_helper = mapping_helper + """ + #[derive(Default)] + struct RecordingAuditSink { + records: Vec, + } + + impl ReidentificationAuditSink for RecordingAuditSink { + fn append_reidentification_audit( + &mut self, + record: &ReidentificationAuditRecord, + ) -> Result<(), ApiError> { + self.records.push(record.clone()); + Ok(()) + } + } + + fn disclose( + grant: &PurposeGrant, + mapping: &IdentityMappingRecord, + decision_time: &str, + ) -> Result<(DisclosedIdentityMapping, ReidentificationAuditRecord), ApiError> { + let mut audit_sink = RecordingAuditSink::default(); + disclose_identity_mapping( + grant, + mapping, + decision_time, + "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + &mut audit_sink, + ) + } +""" + if tests.count(mapping_helper) != 1: + raise SystemExit("provider_payload.rs: mapping helper mismatch") + tests = tests.replace(mapping_helper, audit_helper, 1) + tests = tests.replace("disclose_identity_mapping(", "disclose(") + tests = tests.replace( + " disclose(\n grant,\n mapping,\n decision_time,\n \"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\n &mut audit_sink,\n )", + " disclose_identity_mapping(\n grant,\n mapping,\n decision_time,\n \"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\n &mut audit_sink,\n )", + 1, + ) + provider_path.write_text(production + tests, encoding="utf-8") + + lib_path = Path("crates/tepp_api/src/lib.rs") + lib = lib_path.read_text(encoding="utf-8") + export_anchor = """/// Elevated re-identification result. +pub use provider_payload::DisclosedIdentityMapping; +""" + export_replacement = export_anchor + """/// Redacted re-identification decision outcome. +pub use provider_payload::ReidentificationAuditOutcome; +/// Redacted append-only re-identification audit record. +pub use provider_payload::ReidentificationAuditRecord; +/// Append-only persistence port for re-identification audit evidence. +pub use provider_payload::ReidentificationAuditSink; +""" + if lib.count(export_anchor) != 1: + raise SystemExit("lib.rs: provider export anchor mismatch") + lib_path.write_text(lib.replace(export_anchor, export_replacement, 1), encoding="utf-8") + + contract_path = Path("crates/tepp_api/tests/provider_payload_contract.rs") + contract = contract_path.read_text(encoding="utf-8") + contract = contract.replace( + "AnalyticalPurpose, ApiError, IdentityMappingRecord, ProviderEvidenceOffer, PurposeGrant,\n disclose_identity_mapping, minimize_provider_payload,", + "AnalyticalPurpose, ApiError, DisclosedIdentityMapping, IdentityMappingRecord,\n ProviderEvidenceOffer, PurposeGrant, ReidentificationAuditRecord, ReidentificationAuditSink,\n disclose_identity_mapping as disclose_identity_mapping_with_audit, minimize_provider_payload,", + 1, + ) + offer_marker = """fn scientific_offer() -> ProviderEvidenceOffer { + ProviderEvidenceOffer { + tenant_workspace_id: "tenant-ws-1".into(), + artifact_id: "artifact-quarterly-review-1".into(), + opaque_analytical_id: "entity-opaque-42".into(), + source_text: Some("Q3 pipeline slipped after the Acme renewal stalled.".into()), + identity_mapping: None, + membership_role: Some("author".into()), + } +} +""" + contract_helper = offer_marker + """ +#[derive(Default)] +struct RecordingAuditSink { + records: Vec, +} + +impl ReidentificationAuditSink for RecordingAuditSink { + fn append_reidentification_audit( + &mut self, + record: &ReidentificationAuditRecord, + ) -> Result<(), ApiError> { + self.records.push(record.clone()); + Ok(()) + } +} + +fn disclose( + grant: &PurposeGrant, + mapping: &IdentityMappingRecord, + decision_time: &str, +) -> Result<(DisclosedIdentityMapping, ReidentificationAuditRecord), ApiError> { + let mut sink = RecordingAuditSink::default(); + disclose_identity_mapping_with_audit( + grant, + mapping, + decision_time, + "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + &mut sink, + ) +} +""" + if contract.count(offer_marker) != 1: + raise SystemExit("provider contract: offer helper mismatch") + contract = contract.replace(offer_marker, contract_helper, 1) + contract = contract.replace("disclose_identity_mapping(", "disclose(") + contract = contract.replace("disclosed.direct_identity()", "disclosed.0.direct_identity()") + contract = contract.replace( + "disclosed.opaque_analytical_id()", "disclosed.0.opaque_analytical_id()" + ) + contract_path.write_text(contract, encoding="utf-8") + + def replace_once(path: str, old: str, new: str) -> None: + file_path = Path(path) + text = file_path.read_text(encoding="utf-8") + if text.count(old) != 1: + raise SystemExit(f"{path}: documentation target mismatch") + file_path.write_text(text.replace(old, new, 1), encoding="utf-8") + + replace_once( + "docs/API_CONTRACT.md", + "refuses expired, impossible-calendar, or cross-tenant grants", + "refuses expired, not-yet-valid, inverted, cross-tenant, or impossible-calendar grants", + ) + replace_once( + "docs/research/task-12-versioned-api-contracts.md", + "expired-purpose denial, provider mapping refusal, and elevated re-identification;", + "expired, not-yet-valid, inverted, cross-tenant, and impossible-calendar grant denial; provider mapping refusal; and audited elevated re-identification replay;", + ) + replace_once( + "docs/validation/temporal-event-foundation.md", + "expired/impossible-calendar grant, mapping refusal, elevated re-id", + "expired/not-yet-valid/inverted/cross-tenant/impossible-calendar grant, mapping refusal, audited elevated re-id replay", + ) + PY + cargo +1.97.1 fmt --all + + - name: Verify focused and workspace contracts + run: | + cargo +1.97.1 fmt --all --check + cargo +1.97.1 test -p tepp_api --all-features + cargo +1.97.1 clippy -p tepp_api --all-targets --all-features -- -D warnings + cargo +1.97.1 test --workspace --all-features + python3 scripts/check_docstrings.py + python3 scripts/validate_documentation.py + + - name: Commit verified repair and remove one-shot workflow + run: | + rm -f .github/workflows/repair-pr46-reidentification-audit.yml + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A + git diff --cached --check + git commit -m "fix(api): audit elevated reidentification decisions" + git push origin HEAD:agent/api-provider-payload-minimization From f89228806db8cd76328f9b94a3ca6a4f284c7f12 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 18:07:27 +0900 Subject: [PATCH 13/39] fix(ci): move PR 46 audit repair into a testable script --- scripts/repair_pr46_reidentification_audit.py | 416 ++++++++++++++++++ 1 file changed, 416 insertions(+) create mode 100644 scripts/repair_pr46_reidentification_audit.py diff --git a/scripts/repair_pr46_reidentification_audit.py b/scripts/repair_pr46_reidentification_audit.py new file mode 100644 index 00000000..739aa83b --- /dev/null +++ b/scripts/repair_pr46_reidentification_audit.py @@ -0,0 +1,416 @@ +"""Apply PR 46 append-only re-identification audit and documentation repairs.""" + +from pathlib import Path + + +DECISION_DIGEST = "sha256:" + "a" * 64 + + +def replace_once(text: str, old: str, new: str, label: str) -> str: + """Replace exactly one fragment or fail closed.""" + count = text.count(old) + if count != 1: + raise SystemExit(f"{label}: expected one target, found {count}") + return text.replace(old, new, 1) + + +def update_provider_module() -> None: + """Add redacted audit types, sink, digest validation, and audited disclosure.""" + path = Path("crates/tepp_api/src/provider_payload.rs") + text = path.read_text(encoding="utf-8") + + disclosed_impl = """impl DisclosedIdentityMapping { + /// Opaque analytical identifier that was resolved. + #[must_use] + pub fn opaque_analytical_id(&self) -> &str { + &self.opaque_analytical_id + } + + /// Direct identity released on the elevated path only. + #[must_use] + pub fn direct_identity(&self) -> &str { + &self.direct_identity + } +} +""" + audit_types = disclosed_impl + """ +/// Redacted outcome of an elevated re-identification decision. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ReidentificationAuditOutcome { + /// The protected mapping was released after audit append succeeded. + Allowed, + /// A well-formed request was denied by purpose, tenant, lifetime, or role policy. + Denied, +} + +impl ReidentificationAuditOutcome { + /// Stable wire name for append-only audit persistence. + #[must_use] + pub const fn wire_name(self) -> &'static str { + match self { + Self::Allowed => "allowed", + Self::Denied => "denied", + } + } +} + +/// Redacted append-only evidence for an elevated re-identification decision. +/// +/// Direct identity is deliberately absent. The digest identifies the governed +/// decision input without copying protected source or mapping content. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ReidentificationAuditRecord { + tenant_workspace_id: String, + principal_id: String, + purpose_wire_name: String, + action_code: &'static str, + opaque_analytical_id: String, + decision_time: String, + outcome: ReidentificationAuditOutcome, + decision_digest: String, +} + +impl ReidentificationAuditRecord { + /// Tenant/workspace in which the decision occurred. + #[must_use] + pub fn tenant_workspace_id(&self) -> &str { + &self.tenant_workspace_id + } + + /// Opaque principal that requested disclosure. + #[must_use] + pub fn principal_id(&self) -> &str { + &self.principal_id + } + + /// Purpose wire name evaluated by policy. + #[must_use] + pub fn purpose_wire_name(&self) -> &str { + &self.purpose_wire_name + } + + /// Stable elevated action code. + #[must_use] + pub const fn action_code(&self) -> &'static str { + self.action_code + } + + /// Opaque analytical identity involved in the decision. + #[must_use] + pub fn opaque_analytical_id(&self) -> &str { + &self.opaque_analytical_id + } + + /// Canonical UTC decision instant. + #[must_use] + pub fn decision_time(&self) -> &str { + &self.decision_time + } + + /// Allowed or denied decision outcome. + #[must_use] + pub const fn outcome(&self) -> ReidentificationAuditOutcome { + self.outcome + } + + /// Canonical SHA-256 digest of the governed decision input. + #[must_use] + pub fn decision_digest(&self) -> &str { + &self.decision_digest + } +} + +/// Append-only persistence port for elevated re-identification audit evidence. +/// +/// Implementations must append an immutable row or event and must never copy +/// the disclosed direct identity into ordinary audit storage. +pub trait ReidentificationAuditSink { + /// Append one redacted decision record before disclosure or denial returns. + /// + /// # Errors + /// + /// Returns a redacted [`ApiError`] when append-only persistence fails. The + /// disclosure then fails closed and no direct identity is returned. + fn append_reidentification_audit( + &mut self, + record: &ReidentificationAuditRecord, + ) -> Result<(), ApiError>; +} +""" + text = replace_once(text, disclosed_impl, audit_types, "audit type insertion") + + old_function = """pub fn disclose_identity_mapping( + grant: &PurposeGrant, + mapping: &IdentityMappingRecord, + decision_time: &str, +) -> Result { + validate_grant(grant)?; + require_nonempty(&mapping.tenant_workspace_id)?; + require_nonempty(&mapping.opaque_analytical_id)?; + require_nonempty(&mapping.direct_identity)?; + require_rfc3339_utc(decision_time)?; + if !grant_covers(grant, decision_time) { + return Err(ApiError::AuthorizationDenied); + } + if mapping.tenant_workspace_id != grant.tenant_workspace_id { + return Err(ApiError::AuthorizationDenied); + } + if !grant.reidentification_authorized + || grant.purpose != AnalyticalPurpose::ScientificValidation + { + return Err(ApiError::AuthorizationDenied); + } + Ok(DisclosedIdentityMapping { + opaque_analytical_id: mapping.opaque_analytical_id.clone(), + direct_identity: mapping.direct_identity.clone(), + }) +} +""" + new_function = """pub fn disclose_identity_mapping( + grant: &PurposeGrant, + mapping: &IdentityMappingRecord, + decision_time: &str, + decision_digest: &str, + audit_sink: &mut S, +) -> Result<(DisclosedIdentityMapping, ReidentificationAuditRecord), ApiError> { + validate_grant(grant)?; + require_nonempty(&mapping.tenant_workspace_id)?; + require_nonempty(&mapping.opaque_analytical_id)?; + require_nonempty(&mapping.direct_identity)?; + require_rfc3339_utc(decision_time)?; + require_sha256_digest(decision_digest)?; + + let allowed = grant_covers(grant, decision_time) + && mapping.tenant_workspace_id == grant.tenant_workspace_id + && grant.reidentification_authorized + && grant.purpose == AnalyticalPurpose::ScientificValidation; + let audit_record = ReidentificationAuditRecord { + tenant_workspace_id: grant.tenant_workspace_id.clone(), + principal_id: grant.principal_id.clone(), + purpose_wire_name: grant.purpose.wire_name().into(), + action_code: "reidentify_identity_mapping", + opaque_analytical_id: mapping.opaque_analytical_id.clone(), + decision_time: decision_time.into(), + outcome: if allowed { + ReidentificationAuditOutcome::Allowed + } else { + ReidentificationAuditOutcome::Denied + }, + decision_digest: decision_digest.into(), + }; + audit_sink.append_reidentification_audit(&audit_record)?; + if !allowed { + return Err(ApiError::AuthorizationDenied); + } + Ok(( + DisclosedIdentityMapping { + opaque_analytical_id: mapping.opaque_analytical_id.clone(), + direct_identity: mapping.direct_identity.clone(), + }, + audit_record, + )) +} +""" + text = replace_once(text, old_function, new_function, "audited disclosure function") + + text = replace_once( + text, + "fn validate_grant(grant: &PurposeGrant) -> Result<(), ApiError> {\n", + """fn require_sha256_digest(value: &str) -> Result<(), ApiError> { + let Some(digest) = value.strip_prefix("sha256:") else { + return Err(ApiError::InvalidWirePayload); + }; + if digest.len() == 64 + && digest + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + Ok(()) + } else { + Err(ApiError::InvalidWirePayload) + } +} + +fn validate_grant(grant: &PurposeGrant) -> Result<(), ApiError> { +""", + "digest helper insertion", + ) + + marker = "#[cfg(test)]\nmod tests {" + if text.count(marker) != 1: + raise SystemExit("provider test module marker mismatch") + production, tests_tail = text.split(marker, 1) + tests = marker + tests_tail + tests = replace_once( + tests, + """ DisclosedIdentityMapping, IdentityMappingRecord, MinimizedProviderPayload, + ProviderDisclosureLog, ProviderEvidenceOffer, PurposeGrant, disclose_identity_mapping, + is_rfc3339_utc, minimize_provider_payload, +""", + """ DisclosedIdentityMapping, IdentityMappingRecord, MinimizedProviderPayload, + ProviderDisclosureLog, ProviderEvidenceOffer, PurposeGrant, ReidentificationAuditRecord, + ReidentificationAuditSink, disclose_identity_mapping as disclose_identity_mapping_with_audit, + is_rfc3339_utc, minimize_provider_payload, +""", + "internal test imports", + ) + mapping_helper = """ fn mapping() -> IdentityMappingRecord { + IdentityMappingRecord { + tenant_workspace_id: "tenant-ws-1".into(), + opaque_analytical_id: "entity-1".into(), + direct_identity: "Pat Lee".into(), + } + } +""" + audit_helper = mapping_helper + f""" + #[derive(Default)] + struct RecordingAuditSink {{ + records: Vec, + }} + + impl ReidentificationAuditSink for RecordingAuditSink {{ + fn append_reidentification_audit( + &mut self, + record: &ReidentificationAuditRecord, + ) -> Result<(), ApiError> {{ + self.records.push(record.clone()); + Ok(()) + }} + }} + + fn disclose( + grant: &PurposeGrant, + mapping: &IdentityMappingRecord, + decision_time: &str, + ) -> Result<(DisclosedIdentityMapping, ReidentificationAuditRecord), ApiError> {{ + let mut audit_sink = RecordingAuditSink::default(); + disclose_identity_mapping_with_audit( + grant, + mapping, + decision_time, + "{DECISION_DIGEST}", + &mut audit_sink, + ) + }} +""" + tests = replace_once(tests, mapping_helper, audit_helper, "internal audit helper") + tests = tests.replace("disclose_identity_mapping(", "disclose(") + tests = tests.replace("disclosed.direct_identity()", "disclosed.0.direct_identity()") + tests = tests.replace( + "disclosed.opaque_analytical_id()", "disclosed.0.opaque_analytical_id()" + ) + path.write_text(production + tests, encoding="utf-8") + + +def update_public_exports() -> None: + """Export the new append-only audit contract.""" + path = Path("crates/tepp_api/src/lib.rs") + text = path.read_text(encoding="utf-8") + anchor = """/// Elevated re-identification result. +pub use provider_payload::DisclosedIdentityMapping; +""" + replacement = anchor + """/// Redacted re-identification decision outcome. +pub use provider_payload::ReidentificationAuditOutcome; +/// Redacted append-only re-identification audit record. +pub use provider_payload::ReidentificationAuditRecord; +/// Append-only persistence port for re-identification audit evidence. +pub use provider_payload::ReidentificationAuditSink; +""" + path.write_text(replace_once(text, anchor, replacement, "provider exports"), encoding="utf-8") + + +def update_contract_tests() -> None: + """Adapt existing public contracts to the audited function signature.""" + path = Path("crates/tepp_api/tests/provider_payload_contract.rs") + text = path.read_text(encoding="utf-8") + text = replace_once( + text, + """ AnalyticalPurpose, ApiError, IdentityMappingRecord, ProviderEvidenceOffer, PurposeGrant, + disclose_identity_mapping, minimize_provider_payload, +""", + """ AnalyticalPurpose, ApiError, DisclosedIdentityMapping, IdentityMappingRecord, + ProviderEvidenceOffer, PurposeGrant, ReidentificationAuditRecord, ReidentificationAuditSink, + disclose_identity_mapping as disclose_identity_mapping_with_audit, minimize_provider_payload, +""", + "provider contract imports", + ) + offer_helper = """fn scientific_offer() -> ProviderEvidenceOffer { + ProviderEvidenceOffer { + tenant_workspace_id: "tenant-ws-1".into(), + artifact_id: "artifact-quarterly-review-1".into(), + opaque_analytical_id: "entity-opaque-42".into(), + source_text: Some("Q3 pipeline slipped after the Acme renewal stalled.".into()), + identity_mapping: None, + membership_role: Some("author".into()), + } +} +""" + audit_helper = offer_helper + f""" +#[derive(Default)] +struct RecordingAuditSink {{ + records: Vec, +}} + +impl ReidentificationAuditSink for RecordingAuditSink {{ + fn append_reidentification_audit( + &mut self, + record: &ReidentificationAuditRecord, + ) -> Result<(), ApiError> {{ + self.records.push(record.clone()); + Ok(()) + }} +}} + +fn disclose( + grant: &PurposeGrant, + mapping: &IdentityMappingRecord, + decision_time: &str, +) -> Result<(DisclosedIdentityMapping, ReidentificationAuditRecord), ApiError> {{ + let mut sink = RecordingAuditSink::default(); + disclose_identity_mapping_with_audit( + grant, + mapping, + decision_time, + "{DECISION_DIGEST}", + &mut sink, + ) +}} +""" + text = replace_once(text, offer_helper, audit_helper, "provider contract audit helper") + text = text.replace("disclose_identity_mapping(", "disclose(") + text = text.replace("disclosed.direct_identity()", "disclosed.0.direct_identity()") + text = text.replace( + "disclosed.opaque_analytical_id()", "disclosed.0.opaque_analytical_id()" + ) + path.write_text(text, encoding="utf-8") + + +def update_documents() -> None: + """Keep the five-condition grant matrix and audit evidence wording aligned.""" + replacements = [ + ( + "docs/API_CONTRACT.md", + "refuses expired, impossible-calendar, or cross-tenant grants", + "refuses expired, not-yet-valid, inverted, cross-tenant, or impossible-calendar grants", + ), + ( + "docs/research/task-12-versioned-api-contracts.md", + "expired-purpose denial, provider mapping refusal, and elevated re-identification;", + "expired, not-yet-valid, inverted, cross-tenant, and impossible-calendar grant denial; provider mapping refusal; and audited elevated re-identification replay;", + ), + ( + "docs/validation/temporal-event-foundation.md", + "expired/impossible-calendar grant, mapping refusal, elevated re-id", + "expired/not-yet-valid/inverted/cross-tenant/impossible-calendar grant, mapping refusal, audited elevated re-id replay", + ), + ] + for path_string, old, new in replacements: + path = Path(path_string) + text = path.read_text(encoding="utf-8") + path.write_text(replace_once(text, old, new, path_string), encoding="utf-8") + + +update_provider_module() +update_public_exports() +update_contract_tests() +update_documents() From d5b9b0cfb15f4a47beae4e6bd7c92c40885b0f2c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 18:08:08 +0900 Subject: [PATCH 14/39] fix(ci): simplify PR 46 audit repair workflow --- .../repair-pr46-reidentification-audit.yml | 393 +----------------- 1 file changed, 4 insertions(+), 389 deletions(-) diff --git a/.github/workflows/repair-pr46-reidentification-audit.yml b/.github/workflows/repair-pr46-reidentification-audit.yml index 6ecfa12f..bda6d072 100644 --- a/.github/workflows/repair-pr46-reidentification-audit.yml +++ b/.github/workflows/repair-pr46-reidentification-audit.yml @@ -48,395 +48,9 @@ jobs: fi grep -E "ReidentificationAudit|disclose_identity_mapping" <<<"$output" - - name: Add redacted append-only audit port and consistent evidence matrix + - name: Apply audited reidentification and documentation repair run: | - python3 - <<'PY' - from pathlib import Path - - provider_path = Path("crates/tepp_api/src/provider_payload.rs") - provider = provider_path.read_text(encoding="utf-8") - - def replace_provider(old: str, new: str, label: str) -> None: - global provider - if provider.count(old) != 1: - raise SystemExit(f"provider_payload.rs: {label} target mismatch") - provider = provider.replace(old, new, 1) - - disclosed_impl = """impl DisclosedIdentityMapping { - /// Opaque analytical identifier that was resolved. - #[must_use] - pub fn opaque_analytical_id(&self) -> &str { - &self.opaque_analytical_id - } - - /// Direct identity released on the elevated path only. - #[must_use] - pub fn direct_identity(&self) -> &str { - &self.direct_identity - } - } - """.replace(" ", "") - audit_types = disclosed_impl + """ -/// Redacted outcome of an elevated re-identification decision. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum ReidentificationAuditOutcome { - /// The protected mapping was released after audit append succeeded. - Allowed, - /// A well-formed request was denied by purpose, tenant, lifetime, or role policy. - Denied, -} - -impl ReidentificationAuditOutcome { - /// Stable wire name for append-only audit persistence. - #[must_use] - pub const fn wire_name(self) -> &'static str { - match self { - Self::Allowed => "allowed", - Self::Denied => "denied", - } - } -} - -/// Redacted append-only evidence for an elevated re-identification decision. -/// -/// Direct identity is deliberately absent. The digest identifies the governed -/// decision input without copying protected source or mapping content. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct ReidentificationAuditRecord { - tenant_workspace_id: String, - principal_id: String, - purpose_wire_name: String, - action_code: &'static str, - opaque_analytical_id: String, - decision_time: String, - outcome: ReidentificationAuditOutcome, - decision_digest: String, -} - -impl ReidentificationAuditRecord { - /// Tenant/workspace in which the decision occurred. - #[must_use] - pub fn tenant_workspace_id(&self) -> &str { - &self.tenant_workspace_id - } - - /// Opaque principal that requested disclosure. - #[must_use] - pub fn principal_id(&self) -> &str { - &self.principal_id - } - - /// Purpose wire name evaluated by policy. - #[must_use] - pub fn purpose_wire_name(&self) -> &str { - &self.purpose_wire_name - } - - /// Stable elevated action code. - #[must_use] - pub const fn action_code(&self) -> &'static str { - self.action_code - } - - /// Opaque analytical identity involved in the decision. - #[must_use] - pub fn opaque_analytical_id(&self) -> &str { - &self.opaque_analytical_id - } - - /// Canonical UTC decision instant. - #[must_use] - pub fn decision_time(&self) -> &str { - &self.decision_time - } - - /// Allowed or denied decision outcome. - #[must_use] - pub const fn outcome(&self) -> ReidentificationAuditOutcome { - self.outcome - } - - /// Canonical SHA-256 digest of the governed decision input. - #[must_use] - pub fn decision_digest(&self) -> &str { - &self.decision_digest - } -} - -/// Append-only persistence port for elevated re-identification audit evidence. -/// -/// Implementations must append an immutable row/event and must never copy the -/// disclosed direct identity into ordinary audit storage. -pub trait ReidentificationAuditSink { - /// Append one redacted decision record before disclosure or denial returns. - /// - /// # Errors - /// - /// Returns a redacted [`ApiError`] when append-only persistence fails. The - /// disclosure then fails closed and no direct identity is returned. - fn append_reidentification_audit( - &mut self, - record: &ReidentificationAuditRecord, - ) -> Result<(), ApiError>; -} -""" - replace_provider(disclosed_impl, audit_types, "audit type insertion") - - old_function = """pub fn disclose_identity_mapping( - grant: &PurposeGrant, - mapping: &IdentityMappingRecord, - decision_time: &str, -) -> Result { - validate_grant(grant)?; - require_nonempty(&mapping.tenant_workspace_id)?; - require_nonempty(&mapping.opaque_analytical_id)?; - require_nonempty(&mapping.direct_identity)?; - require_rfc3339_utc(decision_time)?; - if !grant_covers(grant, decision_time) { - return Err(ApiError::AuthorizationDenied); - } - if mapping.tenant_workspace_id != grant.tenant_workspace_id { - return Err(ApiError::AuthorizationDenied); - } - if !grant.reidentification_authorized - || grant.purpose != AnalyticalPurpose::ScientificValidation - { - return Err(ApiError::AuthorizationDenied); - } - Ok(DisclosedIdentityMapping { - opaque_analytical_id: mapping.opaque_analytical_id.clone(), - direct_identity: mapping.direct_identity.clone(), - }) -} -""" - new_function = """pub fn disclose_identity_mapping( - grant: &PurposeGrant, - mapping: &IdentityMappingRecord, - decision_time: &str, - decision_digest: &str, - audit_sink: &mut S, -) -> Result<(DisclosedIdentityMapping, ReidentificationAuditRecord), ApiError> { - validate_grant(grant)?; - require_nonempty(&mapping.tenant_workspace_id)?; - require_nonempty(&mapping.opaque_analytical_id)?; - require_nonempty(&mapping.direct_identity)?; - require_rfc3339_utc(decision_time)?; - require_sha256_digest(decision_digest)?; - - let allowed = grant_covers(grant, decision_time) - && mapping.tenant_workspace_id == grant.tenant_workspace_id - && grant.reidentification_authorized - && grant.purpose == AnalyticalPurpose::ScientificValidation; - let audit_record = ReidentificationAuditRecord { - tenant_workspace_id: grant.tenant_workspace_id.clone(), - principal_id: grant.principal_id.clone(), - purpose_wire_name: grant.purpose.wire_name().into(), - action_code: "reidentify_identity_mapping", - opaque_analytical_id: mapping.opaque_analytical_id.clone(), - decision_time: decision_time.into(), - outcome: if allowed { - ReidentificationAuditOutcome::Allowed - } else { - ReidentificationAuditOutcome::Denied - }, - decision_digest: decision_digest.into(), - }; - audit_sink.append_reidentification_audit(&audit_record)?; - if !allowed { - return Err(ApiError::AuthorizationDenied); - } - Ok(( - DisclosedIdentityMapping { - opaque_analytical_id: mapping.opaque_analytical_id.clone(), - direct_identity: mapping.direct_identity.clone(), - }, - audit_record, - )) -} -""" - replace_provider(old_function, new_function, "audited disclosure function") - - replace_provider( - """fn validate_grant(grant: &PurposeGrant) -> Result<(), ApiError> { -""", - """fn require_sha256_digest(value: &str) -> Result<(), ApiError> { - let Some(digest) = value.strip_prefix("sha256:") else { - return Err(ApiError::InvalidWirePayload); - }; - if digest.len() == 64 - && digest - .bytes() - .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) - { - Ok(()) - } else { - Err(ApiError::InvalidWirePayload) - } -} - -fn validate_grant(grant: &PurposeGrant) -> Result<(), ApiError> { -""", - "digest helper", - ) - - test_marker = "#[cfg(test)]\nmod tests {" - if provider.count(test_marker) != 1: - raise SystemExit("provider_payload.rs: test module marker mismatch") - production, tests = provider.split(test_marker, 1) - tests = test_marker + tests - tests = tests.replace( - "DisclosedIdentityMapping, IdentityMappingRecord, MinimizedProviderPayload,\n ProviderDisclosureLog, ProviderEvidenceOffer, PurposeGrant, disclose_identity_mapping,", - "DisclosedIdentityMapping, IdentityMappingRecord, MinimizedProviderPayload,\n ProviderDisclosureLog, ProviderEvidenceOffer, PurposeGrant, ReidentificationAuditRecord,\n ReidentificationAuditSink, disclose_identity_mapping,", - 1, - ) - mapping_helper = """ fn mapping() -> IdentityMappingRecord { - IdentityMappingRecord { - tenant_workspace_id: "tenant-ws-1".into(), - opaque_analytical_id: "entity-1".into(), - direct_identity: "Pat Lee".into(), - } - } -""" - audit_helper = mapping_helper + """ - #[derive(Default)] - struct RecordingAuditSink { - records: Vec, - } - - impl ReidentificationAuditSink for RecordingAuditSink { - fn append_reidentification_audit( - &mut self, - record: &ReidentificationAuditRecord, - ) -> Result<(), ApiError> { - self.records.push(record.clone()); - Ok(()) - } - } - - fn disclose( - grant: &PurposeGrant, - mapping: &IdentityMappingRecord, - decision_time: &str, - ) -> Result<(DisclosedIdentityMapping, ReidentificationAuditRecord), ApiError> { - let mut audit_sink = RecordingAuditSink::default(); - disclose_identity_mapping( - grant, - mapping, - decision_time, - "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - &mut audit_sink, - ) - } -""" - if tests.count(mapping_helper) != 1: - raise SystemExit("provider_payload.rs: mapping helper mismatch") - tests = tests.replace(mapping_helper, audit_helper, 1) - tests = tests.replace("disclose_identity_mapping(", "disclose(") - tests = tests.replace( - " disclose(\n grant,\n mapping,\n decision_time,\n \"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\n &mut audit_sink,\n )", - " disclose_identity_mapping(\n grant,\n mapping,\n decision_time,\n \"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\n &mut audit_sink,\n )", - 1, - ) - provider_path.write_text(production + tests, encoding="utf-8") - - lib_path = Path("crates/tepp_api/src/lib.rs") - lib = lib_path.read_text(encoding="utf-8") - export_anchor = """/// Elevated re-identification result. -pub use provider_payload::DisclosedIdentityMapping; -""" - export_replacement = export_anchor + """/// Redacted re-identification decision outcome. -pub use provider_payload::ReidentificationAuditOutcome; -/// Redacted append-only re-identification audit record. -pub use provider_payload::ReidentificationAuditRecord; -/// Append-only persistence port for re-identification audit evidence. -pub use provider_payload::ReidentificationAuditSink; -""" - if lib.count(export_anchor) != 1: - raise SystemExit("lib.rs: provider export anchor mismatch") - lib_path.write_text(lib.replace(export_anchor, export_replacement, 1), encoding="utf-8") - - contract_path = Path("crates/tepp_api/tests/provider_payload_contract.rs") - contract = contract_path.read_text(encoding="utf-8") - contract = contract.replace( - "AnalyticalPurpose, ApiError, IdentityMappingRecord, ProviderEvidenceOffer, PurposeGrant,\n disclose_identity_mapping, minimize_provider_payload,", - "AnalyticalPurpose, ApiError, DisclosedIdentityMapping, IdentityMappingRecord,\n ProviderEvidenceOffer, PurposeGrant, ReidentificationAuditRecord, ReidentificationAuditSink,\n disclose_identity_mapping as disclose_identity_mapping_with_audit, minimize_provider_payload,", - 1, - ) - offer_marker = """fn scientific_offer() -> ProviderEvidenceOffer { - ProviderEvidenceOffer { - tenant_workspace_id: "tenant-ws-1".into(), - artifact_id: "artifact-quarterly-review-1".into(), - opaque_analytical_id: "entity-opaque-42".into(), - source_text: Some("Q3 pipeline slipped after the Acme renewal stalled.".into()), - identity_mapping: None, - membership_role: Some("author".into()), - } -} -""" - contract_helper = offer_marker + """ -#[derive(Default)] -struct RecordingAuditSink { - records: Vec, -} - -impl ReidentificationAuditSink for RecordingAuditSink { - fn append_reidentification_audit( - &mut self, - record: &ReidentificationAuditRecord, - ) -> Result<(), ApiError> { - self.records.push(record.clone()); - Ok(()) - } -} - -fn disclose( - grant: &PurposeGrant, - mapping: &IdentityMappingRecord, - decision_time: &str, -) -> Result<(DisclosedIdentityMapping, ReidentificationAuditRecord), ApiError> { - let mut sink = RecordingAuditSink::default(); - disclose_identity_mapping_with_audit( - grant, - mapping, - decision_time, - "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - &mut sink, - ) -} -""" - if contract.count(offer_marker) != 1: - raise SystemExit("provider contract: offer helper mismatch") - contract = contract.replace(offer_marker, contract_helper, 1) - contract = contract.replace("disclose_identity_mapping(", "disclose(") - contract = contract.replace("disclosed.direct_identity()", "disclosed.0.direct_identity()") - contract = contract.replace( - "disclosed.opaque_analytical_id()", "disclosed.0.opaque_analytical_id()" - ) - contract_path.write_text(contract, encoding="utf-8") - - def replace_once(path: str, old: str, new: str) -> None: - file_path = Path(path) - text = file_path.read_text(encoding="utf-8") - if text.count(old) != 1: - raise SystemExit(f"{path}: documentation target mismatch") - file_path.write_text(text.replace(old, new, 1), encoding="utf-8") - - replace_once( - "docs/API_CONTRACT.md", - "refuses expired, impossible-calendar, or cross-tenant grants", - "refuses expired, not-yet-valid, inverted, cross-tenant, or impossible-calendar grants", - ) - replace_once( - "docs/research/task-12-versioned-api-contracts.md", - "expired-purpose denial, provider mapping refusal, and elevated re-identification;", - "expired, not-yet-valid, inverted, cross-tenant, and impossible-calendar grant denial; provider mapping refusal; and audited elevated re-identification replay;", - ) - replace_once( - "docs/validation/temporal-event-foundation.md", - "expired/impossible-calendar grant, mapping refusal, elevated re-id", - "expired/not-yet-valid/inverted/cross-tenant/impossible-calendar grant, mapping refusal, audited elevated re-id replay", - ) - PY + python3 scripts/repair_pr46_reidentification_audit.py cargo +1.97.1 fmt --all - name: Verify focused and workspace contracts @@ -448,9 +62,10 @@ fn disclose( python3 scripts/check_docstrings.py python3 scripts/validate_documentation.py - - name: Commit verified repair and remove one-shot workflow + - name: Commit verified repair and remove one-shot files run: | rm -f .github/workflows/repair-pr46-reidentification-audit.yml + rm -f scripts/repair_pr46_reidentification_audit.py git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git add -A From 875608f41445829e1f3ad586e4776aa10719a258 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 18:13:45 +0900 Subject: [PATCH 15/39] test(api): cover reidentification audit outcome wire names --- crates/tepp_api/tests/reidentification_audit_contract.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/tepp_api/tests/reidentification_audit_contract.rs b/crates/tepp_api/tests/reidentification_audit_contract.rs index ffeaeb98..39328697 100644 --- a/crates/tepp_api/tests/reidentification_audit_contract.rs +++ b/crates/tepp_api/tests/reidentification_audit_contract.rs @@ -68,6 +68,7 @@ fn successful_reidentification_appends_redacted_audit_evidence_before_disclosure assert_eq!(audit.opaque_analytical_id(), "opaque-person-42"); assert_eq!(audit.decision_time(), "2026-06-15T12:00:00Z"); assert_eq!(audit.outcome(), ReidentificationAuditOutcome::Allowed); + assert_eq!(audit.outcome().wire_name(), "allowed"); assert_eq!(audit.decision_digest(), DECISION_DIGEST); assert!(!format!("{audit:?}").contains("Pat Lee")); assert!(!format!("{audit:?}").contains("pat.lee@example.test")); @@ -100,6 +101,7 @@ fn denied_reidentification_is_appended_and_replay_preserves_decision_order() { sink.records[0].outcome(), ReidentificationAuditOutcome::Denied ); + assert_eq!(sink.records[0].outcome().wire_name(), "denied"); assert_eq!(sink.records[1], allowed); assert_eq!( sink.records[1].outcome(), From db16da21223f7bf70468f92514c0b62609cd1813 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 21:56:11 +0900 Subject: [PATCH 16/39] fix(ci): complete audited reidentification repair --- .../repair-pr46-reidentification-audit.yml | 35 ++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/.github/workflows/repair-pr46-reidentification-audit.yml b/.github/workflows/repair-pr46-reidentification-audit.yml index bda6d072..503b920c 100644 --- a/.github/workflows/repair-pr46-reidentification-audit.yml +++ b/.github/workflows/repair-pr46-reidentification-audit.yml @@ -12,7 +12,7 @@ permissions: concurrency: group: repair-tepp-pr-46-reidentification-audit - cancel-in-progress: false + cancel-in-progress: true jobs: repair: @@ -51,6 +51,39 @@ jobs: - name: Apply audited reidentification and documentation repair run: | python3 scripts/repair_pr46_reidentification_audit.py + python3 - <<'PY' + from pathlib import Path + + path = Path("crates/tepp_api/src/provider_payload.rs") + text = path.read_text(encoding="utf-8") + old = ''' let disclosed = DisclosedIdentityMapping { + opaque_analytical_id: "entity-1".into(), + direct_identity: "Pat Lee".into(), + }; + assert_eq!(disclosed.0.opaque_analytical_id(), "entity-1"); + assert_eq!(disclosed.0.direct_identity(), "Pat Lee"); + ''' + new = ''' let disclosed = DisclosedIdentityMapping { + opaque_analytical_id: "entity-1".into(), + direct_identity: "Pat Lee".into(), + }; + assert_eq!(disclosed.opaque_analytical_id(), "entity-1"); + assert_eq!(disclosed.direct_identity(), "Pat Lee"); + ''' + if text.count(old) != 1: + raise SystemExit("constructed disclosure accessor repair target mismatch") + text = text.replace(old, new, 1) + + marker = "fn validate_grant(grant: &PurposeGrant) -> Result<(), ApiError> {\n" + comment = '''// Lexicographic comparisons in `validate_grant` and `grant_covers` are valid + // only after fixed-width UTC RFC 3339 validation; `TemporalInstant` also + // rejects impossible calendar dates and leap seconds before comparison. + fn validate_grant(grant: &PurposeGrant) -> Result<(), ApiError> { + ''' + if text.count(marker) != 1: + raise SystemExit("grant comparison invariant marker mismatch") + path.write_text(text.replace(marker, comment, 1), encoding="utf-8") + PY cargo +1.97.1 fmt --all - name: Verify focused and workspace contracts From 60c74e11544ae226c967470ef68fbe363111833b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 21:56:54 +0900 Subject: [PATCH 17/39] fix(ci): target constructed disclosure accessors precisely --- .../repair-pr46-reidentification-audit.yml | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/.github/workflows/repair-pr46-reidentification-audit.yml b/.github/workflows/repair-pr46-reidentification-audit.yml index 503b920c..29405573 100644 --- a/.github/workflows/repair-pr46-reidentification-audit.yml +++ b/.github/workflows/repair-pr46-reidentification-audit.yml @@ -56,20 +56,10 @@ jobs: path = Path("crates/tepp_api/src/provider_payload.rs") text = path.read_text(encoding="utf-8") - old = ''' let disclosed = DisclosedIdentityMapping { - opaque_analytical_id: "entity-1".into(), - direct_identity: "Pat Lee".into(), - }; - assert_eq!(disclosed.0.opaque_analytical_id(), "entity-1"); - assert_eq!(disclosed.0.direct_identity(), "Pat Lee"); - ''' - new = ''' let disclosed = DisclosedIdentityMapping { - opaque_analytical_id: "entity-1".into(), - direct_identity: "Pat Lee".into(), - }; - assert_eq!(disclosed.opaque_analytical_id(), "entity-1"); - assert_eq!(disclosed.direct_identity(), "Pat Lee"); - ''' + old = ''' assert_eq!(disclosed.0.opaque_analytical_id(), "entity-1"); + assert_eq!(disclosed.0.direct_identity(), "Pat Lee");''' + new = ''' assert_eq!(disclosed.opaque_analytical_id(), "entity-1"); + assert_eq!(disclosed.direct_identity(), "Pat Lee");''' if text.count(old) != 1: raise SystemExit("constructed disclosure accessor repair target mismatch") text = text.replace(old, new, 1) From 0256bf4795ab2b0ece8d7dc65237c803a60d3379 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 21:57:31 +0900 Subject: [PATCH 18/39] fix(ci): make repair post-processing indentation-safe --- .../repair-pr46-reidentification-audit.yml | 32 ++++++++++++------- 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/.github/workflows/repair-pr46-reidentification-audit.yml b/.github/workflows/repair-pr46-reidentification-audit.yml index 29405573..789670b9 100644 --- a/.github/workflows/repair-pr46-reidentification-audit.yml +++ b/.github/workflows/repair-pr46-reidentification-audit.yml @@ -56,20 +56,28 @@ jobs: path = Path("crates/tepp_api/src/provider_payload.rs") text = path.read_text(encoding="utf-8") - old = ''' assert_eq!(disclosed.0.opaque_analytical_id(), "entity-1"); - assert_eq!(disclosed.0.direct_identity(), "Pat Lee");''' - new = ''' assert_eq!(disclosed.opaque_analytical_id(), "entity-1"); - assert_eq!(disclosed.direct_identity(), "Pat Lee");''' - if text.count(old) != 1: - raise SystemExit("constructed disclosure accessor repair target mismatch") - text = text.replace(old, new, 1) + replacements = ( + ( + 'assert_eq!(disclosed.0.opaque_analytical_id(), "entity-1");', + 'assert_eq!(disclosed.opaque_analytical_id(), "entity-1");', + ), + ( + 'assert_eq!(disclosed.0.direct_identity(), "Pat Lee");', + 'assert_eq!(disclosed.direct_identity(), "Pat Lee");', + ), + ) + for old, new in replacements: + if text.count(old) != 1: + raise SystemExit(f"constructed disclosure accessor target mismatch: {old}") + text = text.replace(old, new, 1) marker = "fn validate_grant(grant: &PurposeGrant) -> Result<(), ApiError> {\n" - comment = '''// Lexicographic comparisons in `validate_grant` and `grant_covers` are valid - // only after fixed-width UTC RFC 3339 validation; `TemporalInstant` also - // rejects impossible calendar dates and leap seconds before comparison. - fn validate_grant(grant: &PurposeGrant) -> Result<(), ApiError> { - ''' + comment = ( + "// Lexicographic comparisons in `validate_grant` and `grant_covers` are valid\n" + "// only after fixed-width UTC RFC 3339 validation; `TemporalInstant` also\n" + "// rejects impossible calendar dates and leap seconds before comparison.\n" + "fn validate_grant(grant: &PurposeGrant) -> Result<(), ApiError> {\n" + ) if text.count(marker) != 1: raise SystemExit("grant comparison invariant marker mismatch") path.write_text(text.replace(marker, comment, 1), encoding="utf-8") From 4495835f025bfed957c782fb62b7f20f25e793da Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 14 Aug 2026 13:07:24 +0000 Subject: [PATCH 19/39] fix(api): audit elevated reidentification decisions --- .../repair-pr46-reidentification-audit.yml | 105 ----- crates/tepp_api/src/lib.rs | 6 + crates/tepp_api/src/provider_payload.rs | 217 ++++++++- .../tests/provider_payload_contract.rs | 47 +- .../tests/reidentification_audit_contract.rs | 10 +- docs/API_CONTRACT.md | 2 +- .../task-12-versioned-api-contracts.md | 2 +- docs/validation/temporal-event-foundation.md | 2 +- scripts/repair_pr46_reidentification_audit.py | 416 ------------------ 9 files changed, 248 insertions(+), 559 deletions(-) delete mode 100644 .github/workflows/repair-pr46-reidentification-audit.yml delete mode 100644 scripts/repair_pr46_reidentification_audit.py diff --git a/.github/workflows/repair-pr46-reidentification-audit.yml b/.github/workflows/repair-pr46-reidentification-audit.yml deleted file mode 100644 index 789670b9..00000000 --- a/.github/workflows/repair-pr46-reidentification-audit.yml +++ /dev/null @@ -1,105 +0,0 @@ -name: Repair PR 46 reidentification audit evidence - -on: - pull_request: - types: - - synchronize - - reopened - - ready_for_review - -permissions: - contents: read - -concurrency: - group: repair-tepp-pr-46-reidentification-audit - cancel-in-progress: true - -jobs: - repair: - if: >- - github.event.pull_request.number == 46 && - github.event.pull_request.head.repo.full_name == github.repository && - github.event.pull_request.head.ref == 'agent/api-provider-payload-minimization' - runs-on: ubuntu-latest - timeout-minutes: 35 - permissions: - contents: write - steps: - - name: Checkout exact PR branch - uses: actions/checkout@631c942040754b6e095e929c1677c07e10ed4f87 - with: - ref: agent/api-provider-payload-minimization - fetch-depth: 0 - persist-credentials: true - - - name: Install pinned Rust toolchain - run: rustup toolchain install 1.97.1 --profile minimal --component clippy --component rustfmt - - - name: Prove reidentification audit contract is RED - run: | - set +e - output=$(cargo +1.97.1 test -p tepp_api --test reidentification_audit_contract 2>&1) - status=$? - set -e - printf '%s\n' "$output" - if [ "$status" -eq 0 ]; then - echo "Expected elevated disclosure to lack append-only audit evidence before implementation" >&2 - exit 1 - fi - grep -E "ReidentificationAudit|disclose_identity_mapping" <<<"$output" - - - name: Apply audited reidentification and documentation repair - run: | - python3 scripts/repair_pr46_reidentification_audit.py - python3 - <<'PY' - from pathlib import Path - - path = Path("crates/tepp_api/src/provider_payload.rs") - text = path.read_text(encoding="utf-8") - replacements = ( - ( - 'assert_eq!(disclosed.0.opaque_analytical_id(), "entity-1");', - 'assert_eq!(disclosed.opaque_analytical_id(), "entity-1");', - ), - ( - 'assert_eq!(disclosed.0.direct_identity(), "Pat Lee");', - 'assert_eq!(disclosed.direct_identity(), "Pat Lee");', - ), - ) - for old, new in replacements: - if text.count(old) != 1: - raise SystemExit(f"constructed disclosure accessor target mismatch: {old}") - text = text.replace(old, new, 1) - - marker = "fn validate_grant(grant: &PurposeGrant) -> Result<(), ApiError> {\n" - comment = ( - "// Lexicographic comparisons in `validate_grant` and `grant_covers` are valid\n" - "// only after fixed-width UTC RFC 3339 validation; `TemporalInstant` also\n" - "// rejects impossible calendar dates and leap seconds before comparison.\n" - "fn validate_grant(grant: &PurposeGrant) -> Result<(), ApiError> {\n" - ) - if text.count(marker) != 1: - raise SystemExit("grant comparison invariant marker mismatch") - path.write_text(text.replace(marker, comment, 1), encoding="utf-8") - PY - cargo +1.97.1 fmt --all - - - name: Verify focused and workspace contracts - run: | - cargo +1.97.1 fmt --all --check - cargo +1.97.1 test -p tepp_api --all-features - cargo +1.97.1 clippy -p tepp_api --all-targets --all-features -- -D warnings - cargo +1.97.1 test --workspace --all-features - python3 scripts/check_docstrings.py - python3 scripts/validate_documentation.py - - - name: Commit verified repair and remove one-shot files - run: | - rm -f .github/workflows/repair-pr46-reidentification-audit.yml - rm -f scripts/repair_pr46_reidentification_audit.py - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -A - git diff --cached --check - git commit -m "fix(api): audit elevated reidentification decisions" - git push origin HEAD:agent/api-provider-payload-minimization diff --git a/crates/tepp_api/src/lib.rs b/crates/tepp_api/src/lib.rs index b5646761..7be66c1b 100644 --- a/crates/tepp_api/src/lib.rs +++ b/crates/tepp_api/src/lib.rs @@ -79,6 +79,12 @@ pub use provider_payload::ProviderDisclosureLog; pub use provider_payload::ProviderEvidenceOffer; /// Time-bounded purpose grant. pub use provider_payload::PurposeGrant; +/// Redacted re-identification decision outcome. +pub use provider_payload::ReidentificationAuditOutcome; +/// Redacted append-only re-identification audit record. +pub use provider_payload::ReidentificationAuditRecord; +/// Append-only persistence port for re-identification audit evidence. +pub use provider_payload::ReidentificationAuditSink; /// Disclose a mapping on the elevated scientific path. pub use provider_payload::disclose_identity_mapping; /// Minimize evidence for a model provider. diff --git a/crates/tepp_api/src/provider_payload.rs b/crates/tepp_api/src/provider_payload.rs index 8483d1ed..f8e52de4 100644 --- a/crates/tepp_api/src/provider_payload.rs +++ b/crates/tepp_api/src/provider_payload.rs @@ -169,6 +169,109 @@ impl DisclosedIdentityMapping { } } +/// Redacted outcome of an elevated re-identification decision. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ReidentificationAuditOutcome { + /// The protected mapping was released after audit append succeeded. + Allowed, + /// A well-formed request was denied by purpose, tenant, lifetime, or role policy. + Denied, +} + +impl ReidentificationAuditOutcome { + /// Stable wire name for append-only audit persistence. + #[must_use] + pub const fn wire_name(self) -> &'static str { + match self { + Self::Allowed => "allowed", + Self::Denied => "denied", + } + } +} + +/// Redacted append-only evidence for an elevated re-identification decision. +/// +/// Direct identity is deliberately absent. The digest identifies the governed +/// decision input without copying protected source or mapping content. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ReidentificationAuditRecord { + tenant_workspace_id: String, + principal_id: String, + purpose_wire_name: String, + action_code: &'static str, + opaque_analytical_id: String, + decision_time: String, + outcome: ReidentificationAuditOutcome, + decision_digest: String, +} + +impl ReidentificationAuditRecord { + /// Tenant/workspace in which the decision occurred. + #[must_use] + pub fn tenant_workspace_id(&self) -> &str { + &self.tenant_workspace_id + } + + /// Opaque principal that requested disclosure. + #[must_use] + pub fn principal_id(&self) -> &str { + &self.principal_id + } + + /// Purpose wire name evaluated by policy. + #[must_use] + pub fn purpose_wire_name(&self) -> &str { + &self.purpose_wire_name + } + + /// Stable elevated action code. + #[must_use] + pub const fn action_code(&self) -> &'static str { + self.action_code + } + + /// Opaque analytical identity involved in the decision. + #[must_use] + pub fn opaque_analytical_id(&self) -> &str { + &self.opaque_analytical_id + } + + /// Canonical UTC decision instant. + #[must_use] + pub fn decision_time(&self) -> &str { + &self.decision_time + } + + /// Allowed or denied decision outcome. + #[must_use] + pub const fn outcome(&self) -> ReidentificationAuditOutcome { + self.outcome + } + + /// Canonical SHA-256 digest of the governed decision input. + #[must_use] + pub fn decision_digest(&self) -> &str { + &self.decision_digest + } +} + +/// Append-only persistence port for elevated re-identification audit evidence. +/// +/// Implementations must append an immutable row or event and must never copy +/// the disclosed direct identity into ordinary audit storage. +pub trait ReidentificationAuditSink { + /// Append one redacted decision record before disclosure or denial returns. + /// + /// # Errors + /// + /// Returns a redacted [`ApiError`] when append-only persistence fails. The + /// disclosure then fails closed and no direct identity is returned. + fn append_reidentification_audit( + &mut self, + record: &ReidentificationAuditRecord, + ) -> Result<(), ApiError>; +} + /// Minimize evidence for a model provider without blanket PII masking. /// /// Opaque analytical identifiers and membership roles are preserved. Source @@ -241,33 +344,69 @@ pub fn minimize_provider_payload( /// [`ApiError::AuthorizationDenied`] when the grant is expired, not yet /// valid, cross-tenant, missing the elevated flag, or not /// [`AnalyticalPurpose::ScientificValidation`]. -pub fn disclose_identity_mapping( +pub fn disclose_identity_mapping( grant: &PurposeGrant, mapping: &IdentityMappingRecord, decision_time: &str, -) -> Result { + decision_digest: &str, + audit_sink: &mut S, +) -> Result<(DisclosedIdentityMapping, ReidentificationAuditRecord), ApiError> { validate_grant(grant)?; require_nonempty(&mapping.tenant_workspace_id)?; require_nonempty(&mapping.opaque_analytical_id)?; require_nonempty(&mapping.direct_identity)?; require_rfc3339_utc(decision_time)?; - if !grant_covers(grant, decision_time) { - return Err(ApiError::AuthorizationDenied); - } - if mapping.tenant_workspace_id != grant.tenant_workspace_id { + require_sha256_digest(decision_digest)?; + + let allowed = grant_covers(grant, decision_time) + && mapping.tenant_workspace_id == grant.tenant_workspace_id + && grant.reidentification_authorized + && grant.purpose == AnalyticalPurpose::ScientificValidation; + let audit_record = ReidentificationAuditRecord { + tenant_workspace_id: grant.tenant_workspace_id.clone(), + principal_id: grant.principal_id.clone(), + purpose_wire_name: grant.purpose.wire_name().into(), + action_code: "reidentify_identity_mapping", + opaque_analytical_id: mapping.opaque_analytical_id.clone(), + decision_time: decision_time.into(), + outcome: if allowed { + ReidentificationAuditOutcome::Allowed + } else { + ReidentificationAuditOutcome::Denied + }, + decision_digest: decision_digest.into(), + }; + audit_sink.append_reidentification_audit(&audit_record)?; + if !allowed { return Err(ApiError::AuthorizationDenied); } - if !grant.reidentification_authorized - || grant.purpose != AnalyticalPurpose::ScientificValidation + Ok(( + DisclosedIdentityMapping { + opaque_analytical_id: mapping.opaque_analytical_id.clone(), + direct_identity: mapping.direct_identity.clone(), + }, + audit_record, + )) +} + +fn require_sha256_digest(value: &str) -> Result<(), ApiError> { + let Some(digest) = value.strip_prefix("sha256:") else { + return Err(ApiError::InvalidWirePayload); + }; + if digest.len() == 64 + && digest + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) { - return Err(ApiError::AuthorizationDenied); + Ok(()) + } else { + Err(ApiError::InvalidWirePayload) } - Ok(DisclosedIdentityMapping { - opaque_analytical_id: mapping.opaque_analytical_id.clone(), - direct_identity: mapping.direct_identity.clone(), - }) } +// Lexicographic comparisons in `validate_grant` and `grant_covers` are valid +// only after fixed-width UTC RFC 3339 validation; `TemporalInstant` also +// rejects impossible calendar dates and leap seconds before comparison. fn validate_grant(grant: &PurposeGrant) -> Result<(), ApiError> { require_nonempty(&grant.tenant_workspace_id)?; require_nonempty(&grant.principal_id)?; @@ -330,8 +469,10 @@ fn is_rfc3339_utc(value: &str) -> bool { mod tests { use super::{ DisclosedIdentityMapping, IdentityMappingRecord, MinimizedProviderPayload, - ProviderDisclosureLog, ProviderEvidenceOffer, PurposeGrant, disclose_identity_mapping, - is_rfc3339_utc, minimize_provider_payload, + ProviderDisclosureLog, ProviderEvidenceOffer, PurposeGrant, ReidentificationAuditRecord, + ReidentificationAuditSink, + disclose_identity_mapping as disclose_identity_mapping_with_audit, is_rfc3339_utc, + minimize_provider_payload, }; use crate::ApiError; use crate::authorization::AnalyticalPurpose; @@ -366,6 +507,36 @@ mod tests { } } + #[derive(Default)] + struct RecordingAuditSink { + records: Vec, + } + + impl ReidentificationAuditSink for RecordingAuditSink { + fn append_reidentification_audit( + &mut self, + record: &ReidentificationAuditRecord, + ) -> Result<(), ApiError> { + self.records.push(record.clone()); + Ok(()) + } + } + + fn disclose( + grant: &PurposeGrant, + mapping: &IdentityMappingRecord, + decision_time: &str, + ) -> Result<(DisclosedIdentityMapping, ReidentificationAuditRecord), ApiError> { + let mut audit_sink = RecordingAuditSink::default(); + disclose_identity_mapping_with_audit( + grant, + mapping, + decision_time, + "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + &mut audit_sink, + ) + } + #[test] fn rfc3339_utc_is_strict_and_windows_are_inclusive() { assert!(is_rfc3339_utc("2026-01-01T00:00:00Z")); @@ -488,7 +659,7 @@ mod tests { #[test] fn disclose_covers_remaining_fail_closed_branches() { assert_eq!( - disclose_identity_mapping( + disclose( &grant(AnalyticalPurpose::PartnerDisclosure, true), &mapping(), "2026-06-15T12:00:00Z", @@ -500,7 +671,7 @@ mod tests { ..grant(AnalyticalPurpose::ScientificValidation, true) }; assert_eq!( - disclose_identity_mapping(&expired, &mapping(), "2026-06-15T12:00:00Z"), + disclose(&expired, &mapping(), "2026-06-15T12:00:00Z"), Err(ApiError::AuthorizationDenied) ); let inverted = PurposeGrant { @@ -509,7 +680,7 @@ mod tests { ..grant(AnalyticalPurpose::ScientificValidation, true) }; assert_eq!( - disclose_identity_mapping(&inverted, &mapping(), "2026-06-15T12:00:00Z"), + disclose(&inverted, &mapping(), "2026-06-15T12:00:00Z"), Err(ApiError::InvalidWirePayload) ); let foreign = IdentityMappingRecord { @@ -517,7 +688,7 @@ mod tests { ..mapping() }; assert_eq!( - disclose_identity_mapping( + disclose( &grant(AnalyticalPurpose::ScientificValidation, true), &foreign, "2026-06-15T12:00:00Z", @@ -527,7 +698,7 @@ mod tests { let mut empty = mapping(); empty.direct_identity.clear(); assert_eq!( - disclose_identity_mapping( + disclose( &grant(AnalyticalPurpose::ScientificValidation, true), &empty, "2026-06-15T12:00:00Z", @@ -535,7 +706,7 @@ mod tests { Err(ApiError::InvalidWirePayload) ); assert_eq!( - disclose_identity_mapping( + disclose( &grant(AnalyticalPurpose::ScientificValidation, true), &mapping(), "bad", @@ -545,7 +716,7 @@ mod tests { let mut empty_opaque = mapping(); empty_opaque.opaque_analytical_id.clear(); assert_eq!( - disclose_identity_mapping( + disclose( &grant(AnalyticalPurpose::ScientificValidation, true), &empty_opaque, "2026-06-15T12:00:00Z", @@ -555,7 +726,7 @@ mod tests { let mut empty_tenant = mapping(); empty_tenant.tenant_workspace_id.clear(); assert_eq!( - disclose_identity_mapping( + disclose( &grant(AnalyticalPurpose::ScientificValidation, true), &empty_tenant, "2026-06-15T12:00:00Z", diff --git a/crates/tepp_api/tests/provider_payload_contract.rs b/crates/tepp_api/tests/provider_payload_contract.rs index d329bfa9..b85c11c1 100644 --- a/crates/tepp_api/tests/provider_payload_contract.rs +++ b/crates/tepp_api/tests/provider_payload_contract.rs @@ -1,8 +1,9 @@ //! Purpose-bound provider payloads refuse identity mappings and expired grants. use tepp_api::{ - AnalyticalPurpose, ApiError, IdentityMappingRecord, ProviderEvidenceOffer, PurposeGrant, - disclose_identity_mapping, minimize_provider_payload, + AnalyticalPurpose, ApiError, DisclosedIdentityMapping, IdentityMappingRecord, + ProviderEvidenceOffer, PurposeGrant, ReidentificationAuditRecord, ReidentificationAuditSink, + disclose_identity_mapping as disclose_identity_mapping_with_audit, minimize_provider_payload, }; fn active_grant(purpose: AnalyticalPurpose, reidentification: bool) -> PurposeGrant { @@ -27,6 +28,36 @@ fn scientific_offer() -> ProviderEvidenceOffer { } } +#[derive(Default)] +struct RecordingAuditSink { + records: Vec, +} + +impl ReidentificationAuditSink for RecordingAuditSink { + fn append_reidentification_audit( + &mut self, + record: &ReidentificationAuditRecord, + ) -> Result<(), ApiError> { + self.records.push(record.clone()); + Ok(()) + } +} + +fn disclose( + grant: &PurposeGrant, + mapping: &IdentityMappingRecord, + decision_time: &str, +) -> Result<(DisclosedIdentityMapping, ReidentificationAuditRecord), ApiError> { + let mut sink = RecordingAuditSink::default(); + disclose_identity_mapping_with_audit( + grant, + mapping, + decision_time, + "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + &mut sink, + ) +} + #[test] fn scientific_provider_payload_keeps_opaque_ids_and_roles_without_mapping() { let (payload, log) = minimize_provider_payload( @@ -129,20 +160,20 @@ fn reidentification_is_a_separate_elevated_path() { direct_identity: "Jane Roe ".into(), }; - let disclosed = disclose_identity_mapping( + let disclosed = disclose( &active_grant(AnalyticalPurpose::ScientificValidation, true), &mapping, "2026-06-15T12:00:00Z", ) .expect("elevated"); assert_eq!( - disclosed.direct_identity(), + disclosed.0.direct_identity(), "Jane Roe " ); - assert_eq!(disclosed.opaque_analytical_id(), "entity-opaque-42"); + assert_eq!(disclosed.0.opaque_analytical_id(), "entity-opaque-42"); assert_eq!( - disclose_identity_mapping( + disclose( &active_grant(AnalyticalPurpose::ScientificValidation, false), &mapping, "2026-06-15T12:00:00Z", @@ -150,7 +181,7 @@ fn reidentification_is_a_separate_elevated_path() { Err(ApiError::AuthorizationDenied) ); assert_eq!( - disclose_identity_mapping( + disclose( &active_grant(AnalyticalPurpose::OperationalMonitoring, true), &mapping, "2026-06-15T12:00:00Z", @@ -158,7 +189,7 @@ fn reidentification_is_a_separate_elevated_path() { Err(ApiError::AuthorizationDenied) ); assert_eq!( - disclose_identity_mapping( + disclose( &active_grant(AnalyticalPurpose::ModularServiceConsumer, true), &mapping, "2026-06-15T12:00:00Z", diff --git a/crates/tepp_api/tests/reidentification_audit_contract.rs b/crates/tepp_api/tests/reidentification_audit_contract.rs index 39328697..7d5673fc 100644 --- a/crates/tepp_api/tests/reidentification_audit_contract.rs +++ b/crates/tepp_api/tests/reidentification_audit_contract.rs @@ -1,9 +1,8 @@ //! Elevated re-identification must append redacted audit evidence for every decision. use tepp_api::{ - AnalyticalPurpose, ApiError, IdentityMappingRecord, PurposeGrant, - ReidentificationAuditOutcome, ReidentificationAuditRecord, ReidentificationAuditSink, - disclose_identity_mapping, + AnalyticalPurpose, ApiError, IdentityMappingRecord, PurposeGrant, ReidentificationAuditOutcome, + ReidentificationAuditRecord, ReidentificationAuditSink, disclose_identity_mapping, }; const DECISION_DIGEST: &str = @@ -59,7 +58,10 @@ fn successful_reidentification_appends_redacted_audit_evidence_before_disclosure ) .expect("audited elevated disclosure"); - assert_eq!(disclosed.direct_identity(), "Pat Lee "); + assert_eq!( + disclosed.direct_identity(), + "Pat Lee " + ); assert_eq!(sink.records, vec![audit.clone()]); assert_eq!(audit.tenant_workspace_id(), "tenant-workspace"); assert_eq!(audit.principal_id(), "principal-analyst"); diff --git a/docs/API_CONTRACT.md b/docs/API_CONTRACT.md index 72bc45d6..09b7998d 100644 --- a/docs/API_CONTRACT.md +++ b/docs/API_CONTRACT.md @@ -114,7 +114,7 @@ TEPP owns its application/API state, authorized evidence, model runs, and artifa ### Provider payload minimization -Before any naruon, contextual-orchestrator, or NVIDIA NIM submission, callers must build the payload through `tepp_api::minimize_provider_payload`. That function preserves opaque analytical identifiers and membership roles, applies purpose-bound source-text rules, refuses expired, impossible-calendar, or cross-tenant grants, and never copies a direct identity mapping into the provider body or ordinary log. Re-identification is a separate elevated scientific path (`disclose_identity_mapping`), not a provider header or prompt field. +Before any naruon, contextual-orchestrator, or NVIDIA NIM submission, callers must build the payload through `tepp_api::minimize_provider_payload`. That function preserves opaque analytical identifiers and membership roles, applies purpose-bound source-text rules, refuses expired, not-yet-valid, inverted, cross-tenant, or impossible-calendar grants, and never copies a direct identity mapping into the provider body or ordinary log. Re-identification is a separate elevated scientific path (`disclose_identity_mapping`), not a provider header or prompt field. ### contextual-orchestrator diff --git a/docs/research/task-12-versioned-api-contracts.md b/docs/research/task-12-versioned-api-contracts.md index c5725702..0344f1a5 100644 --- a/docs/research/task-12-versioned-api-contracts.md +++ b/docs/research/task-12-versioned-api-contracts.md @@ -35,5 +35,5 @@ National Institute of Standards and Technology. (2020). *NIST Privacy Framework: ## Verification -- unit tests for unknown fields, unsupported versions, empty identities, byte limits, GraphML escaping, example payload parsing, expired-purpose denial, provider mapping refusal, and elevated re-identification; +- unit tests for unknown fields, unsupported versions, empty identities, byte limits, GraphML escaping, example payload parsing, expired, not-yet-valid, inverted, cross-tenant, and impossible-calendar grant denial; provider mapping refusal; and audited elevated re-identification replay; - workspace line and branch coverage gates must remain complete for production modules. diff --git a/docs/validation/temporal-event-foundation.md b/docs/validation/temporal-event-foundation.md index f15d5213..7c50db23 100644 --- a/docs/validation/temporal-event-foundation.md +++ b/docs/validation/temporal-event-foundation.md @@ -23,7 +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 | -| Purpose-bound provider payloads | `tepp_api` | active-PR | provider-payload minimization | expired/impossible-calendar grant, mapping refusal, elevated re-id | ADR 0009; `docs/research/provider-payload-minimization.md` | +| Purpose-bound provider payloads | `tepp_api` | active-PR | 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` | | CWL modular connectors | `docs/connectors/*` | implemented-main | — | contract docs + examples | PR #22; live HTTP ports remaining | | Release SBOM/provenance generator | `scripts/release_evidence.py` | partial | — | generate+validate in CI | Task 13 partial / PR #28 | diff --git a/scripts/repair_pr46_reidentification_audit.py b/scripts/repair_pr46_reidentification_audit.py deleted file mode 100644 index 739aa83b..00000000 --- a/scripts/repair_pr46_reidentification_audit.py +++ /dev/null @@ -1,416 +0,0 @@ -"""Apply PR 46 append-only re-identification audit and documentation repairs.""" - -from pathlib import Path - - -DECISION_DIGEST = "sha256:" + "a" * 64 - - -def replace_once(text: str, old: str, new: str, label: str) -> str: - """Replace exactly one fragment or fail closed.""" - count = text.count(old) - if count != 1: - raise SystemExit(f"{label}: expected one target, found {count}") - return text.replace(old, new, 1) - - -def update_provider_module() -> None: - """Add redacted audit types, sink, digest validation, and audited disclosure.""" - path = Path("crates/tepp_api/src/provider_payload.rs") - text = path.read_text(encoding="utf-8") - - disclosed_impl = """impl DisclosedIdentityMapping { - /// Opaque analytical identifier that was resolved. - #[must_use] - pub fn opaque_analytical_id(&self) -> &str { - &self.opaque_analytical_id - } - - /// Direct identity released on the elevated path only. - #[must_use] - pub fn direct_identity(&self) -> &str { - &self.direct_identity - } -} -""" - audit_types = disclosed_impl + """ -/// Redacted outcome of an elevated re-identification decision. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum ReidentificationAuditOutcome { - /// The protected mapping was released after audit append succeeded. - Allowed, - /// A well-formed request was denied by purpose, tenant, lifetime, or role policy. - Denied, -} - -impl ReidentificationAuditOutcome { - /// Stable wire name for append-only audit persistence. - #[must_use] - pub const fn wire_name(self) -> &'static str { - match self { - Self::Allowed => "allowed", - Self::Denied => "denied", - } - } -} - -/// Redacted append-only evidence for an elevated re-identification decision. -/// -/// Direct identity is deliberately absent. The digest identifies the governed -/// decision input without copying protected source or mapping content. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct ReidentificationAuditRecord { - tenant_workspace_id: String, - principal_id: String, - purpose_wire_name: String, - action_code: &'static str, - opaque_analytical_id: String, - decision_time: String, - outcome: ReidentificationAuditOutcome, - decision_digest: String, -} - -impl ReidentificationAuditRecord { - /// Tenant/workspace in which the decision occurred. - #[must_use] - pub fn tenant_workspace_id(&self) -> &str { - &self.tenant_workspace_id - } - - /// Opaque principal that requested disclosure. - #[must_use] - pub fn principal_id(&self) -> &str { - &self.principal_id - } - - /// Purpose wire name evaluated by policy. - #[must_use] - pub fn purpose_wire_name(&self) -> &str { - &self.purpose_wire_name - } - - /// Stable elevated action code. - #[must_use] - pub const fn action_code(&self) -> &'static str { - self.action_code - } - - /// Opaque analytical identity involved in the decision. - #[must_use] - pub fn opaque_analytical_id(&self) -> &str { - &self.opaque_analytical_id - } - - /// Canonical UTC decision instant. - #[must_use] - pub fn decision_time(&self) -> &str { - &self.decision_time - } - - /// Allowed or denied decision outcome. - #[must_use] - pub const fn outcome(&self) -> ReidentificationAuditOutcome { - self.outcome - } - - /// Canonical SHA-256 digest of the governed decision input. - #[must_use] - pub fn decision_digest(&self) -> &str { - &self.decision_digest - } -} - -/// Append-only persistence port for elevated re-identification audit evidence. -/// -/// Implementations must append an immutable row or event and must never copy -/// the disclosed direct identity into ordinary audit storage. -pub trait ReidentificationAuditSink { - /// Append one redacted decision record before disclosure or denial returns. - /// - /// # Errors - /// - /// Returns a redacted [`ApiError`] when append-only persistence fails. The - /// disclosure then fails closed and no direct identity is returned. - fn append_reidentification_audit( - &mut self, - record: &ReidentificationAuditRecord, - ) -> Result<(), ApiError>; -} -""" - text = replace_once(text, disclosed_impl, audit_types, "audit type insertion") - - old_function = """pub fn disclose_identity_mapping( - grant: &PurposeGrant, - mapping: &IdentityMappingRecord, - decision_time: &str, -) -> Result { - validate_grant(grant)?; - require_nonempty(&mapping.tenant_workspace_id)?; - require_nonempty(&mapping.opaque_analytical_id)?; - require_nonempty(&mapping.direct_identity)?; - require_rfc3339_utc(decision_time)?; - if !grant_covers(grant, decision_time) { - return Err(ApiError::AuthorizationDenied); - } - if mapping.tenant_workspace_id != grant.tenant_workspace_id { - return Err(ApiError::AuthorizationDenied); - } - if !grant.reidentification_authorized - || grant.purpose != AnalyticalPurpose::ScientificValidation - { - return Err(ApiError::AuthorizationDenied); - } - Ok(DisclosedIdentityMapping { - opaque_analytical_id: mapping.opaque_analytical_id.clone(), - direct_identity: mapping.direct_identity.clone(), - }) -} -""" - new_function = """pub fn disclose_identity_mapping( - grant: &PurposeGrant, - mapping: &IdentityMappingRecord, - decision_time: &str, - decision_digest: &str, - audit_sink: &mut S, -) -> Result<(DisclosedIdentityMapping, ReidentificationAuditRecord), ApiError> { - validate_grant(grant)?; - require_nonempty(&mapping.tenant_workspace_id)?; - require_nonempty(&mapping.opaque_analytical_id)?; - require_nonempty(&mapping.direct_identity)?; - require_rfc3339_utc(decision_time)?; - require_sha256_digest(decision_digest)?; - - let allowed = grant_covers(grant, decision_time) - && mapping.tenant_workspace_id == grant.tenant_workspace_id - && grant.reidentification_authorized - && grant.purpose == AnalyticalPurpose::ScientificValidation; - let audit_record = ReidentificationAuditRecord { - tenant_workspace_id: grant.tenant_workspace_id.clone(), - principal_id: grant.principal_id.clone(), - purpose_wire_name: grant.purpose.wire_name().into(), - action_code: "reidentify_identity_mapping", - opaque_analytical_id: mapping.opaque_analytical_id.clone(), - decision_time: decision_time.into(), - outcome: if allowed { - ReidentificationAuditOutcome::Allowed - } else { - ReidentificationAuditOutcome::Denied - }, - decision_digest: decision_digest.into(), - }; - audit_sink.append_reidentification_audit(&audit_record)?; - if !allowed { - return Err(ApiError::AuthorizationDenied); - } - Ok(( - DisclosedIdentityMapping { - opaque_analytical_id: mapping.opaque_analytical_id.clone(), - direct_identity: mapping.direct_identity.clone(), - }, - audit_record, - )) -} -""" - text = replace_once(text, old_function, new_function, "audited disclosure function") - - text = replace_once( - text, - "fn validate_grant(grant: &PurposeGrant) -> Result<(), ApiError> {\n", - """fn require_sha256_digest(value: &str) -> Result<(), ApiError> { - let Some(digest) = value.strip_prefix("sha256:") else { - return Err(ApiError::InvalidWirePayload); - }; - if digest.len() == 64 - && digest - .bytes() - .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) - { - Ok(()) - } else { - Err(ApiError::InvalidWirePayload) - } -} - -fn validate_grant(grant: &PurposeGrant) -> Result<(), ApiError> { -""", - "digest helper insertion", - ) - - marker = "#[cfg(test)]\nmod tests {" - if text.count(marker) != 1: - raise SystemExit("provider test module marker mismatch") - production, tests_tail = text.split(marker, 1) - tests = marker + tests_tail - tests = replace_once( - tests, - """ DisclosedIdentityMapping, IdentityMappingRecord, MinimizedProviderPayload, - ProviderDisclosureLog, ProviderEvidenceOffer, PurposeGrant, disclose_identity_mapping, - is_rfc3339_utc, minimize_provider_payload, -""", - """ DisclosedIdentityMapping, IdentityMappingRecord, MinimizedProviderPayload, - ProviderDisclosureLog, ProviderEvidenceOffer, PurposeGrant, ReidentificationAuditRecord, - ReidentificationAuditSink, disclose_identity_mapping as disclose_identity_mapping_with_audit, - is_rfc3339_utc, minimize_provider_payload, -""", - "internal test imports", - ) - mapping_helper = """ fn mapping() -> IdentityMappingRecord { - IdentityMappingRecord { - tenant_workspace_id: "tenant-ws-1".into(), - opaque_analytical_id: "entity-1".into(), - direct_identity: "Pat Lee".into(), - } - } -""" - audit_helper = mapping_helper + f""" - #[derive(Default)] - struct RecordingAuditSink {{ - records: Vec, - }} - - impl ReidentificationAuditSink for RecordingAuditSink {{ - fn append_reidentification_audit( - &mut self, - record: &ReidentificationAuditRecord, - ) -> Result<(), ApiError> {{ - self.records.push(record.clone()); - Ok(()) - }} - }} - - fn disclose( - grant: &PurposeGrant, - mapping: &IdentityMappingRecord, - decision_time: &str, - ) -> Result<(DisclosedIdentityMapping, ReidentificationAuditRecord), ApiError> {{ - let mut audit_sink = RecordingAuditSink::default(); - disclose_identity_mapping_with_audit( - grant, - mapping, - decision_time, - "{DECISION_DIGEST}", - &mut audit_sink, - ) - }} -""" - tests = replace_once(tests, mapping_helper, audit_helper, "internal audit helper") - tests = tests.replace("disclose_identity_mapping(", "disclose(") - tests = tests.replace("disclosed.direct_identity()", "disclosed.0.direct_identity()") - tests = tests.replace( - "disclosed.opaque_analytical_id()", "disclosed.0.opaque_analytical_id()" - ) - path.write_text(production + tests, encoding="utf-8") - - -def update_public_exports() -> None: - """Export the new append-only audit contract.""" - path = Path("crates/tepp_api/src/lib.rs") - text = path.read_text(encoding="utf-8") - anchor = """/// Elevated re-identification result. -pub use provider_payload::DisclosedIdentityMapping; -""" - replacement = anchor + """/// Redacted re-identification decision outcome. -pub use provider_payload::ReidentificationAuditOutcome; -/// Redacted append-only re-identification audit record. -pub use provider_payload::ReidentificationAuditRecord; -/// Append-only persistence port for re-identification audit evidence. -pub use provider_payload::ReidentificationAuditSink; -""" - path.write_text(replace_once(text, anchor, replacement, "provider exports"), encoding="utf-8") - - -def update_contract_tests() -> None: - """Adapt existing public contracts to the audited function signature.""" - path = Path("crates/tepp_api/tests/provider_payload_contract.rs") - text = path.read_text(encoding="utf-8") - text = replace_once( - text, - """ AnalyticalPurpose, ApiError, IdentityMappingRecord, ProviderEvidenceOffer, PurposeGrant, - disclose_identity_mapping, minimize_provider_payload, -""", - """ AnalyticalPurpose, ApiError, DisclosedIdentityMapping, IdentityMappingRecord, - ProviderEvidenceOffer, PurposeGrant, ReidentificationAuditRecord, ReidentificationAuditSink, - disclose_identity_mapping as disclose_identity_mapping_with_audit, minimize_provider_payload, -""", - "provider contract imports", - ) - offer_helper = """fn scientific_offer() -> ProviderEvidenceOffer { - ProviderEvidenceOffer { - tenant_workspace_id: "tenant-ws-1".into(), - artifact_id: "artifact-quarterly-review-1".into(), - opaque_analytical_id: "entity-opaque-42".into(), - source_text: Some("Q3 pipeline slipped after the Acme renewal stalled.".into()), - identity_mapping: None, - membership_role: Some("author".into()), - } -} -""" - audit_helper = offer_helper + f""" -#[derive(Default)] -struct RecordingAuditSink {{ - records: Vec, -}} - -impl ReidentificationAuditSink for RecordingAuditSink {{ - fn append_reidentification_audit( - &mut self, - record: &ReidentificationAuditRecord, - ) -> Result<(), ApiError> {{ - self.records.push(record.clone()); - Ok(()) - }} -}} - -fn disclose( - grant: &PurposeGrant, - mapping: &IdentityMappingRecord, - decision_time: &str, -) -> Result<(DisclosedIdentityMapping, ReidentificationAuditRecord), ApiError> {{ - let mut sink = RecordingAuditSink::default(); - disclose_identity_mapping_with_audit( - grant, - mapping, - decision_time, - "{DECISION_DIGEST}", - &mut sink, - ) -}} -""" - text = replace_once(text, offer_helper, audit_helper, "provider contract audit helper") - text = text.replace("disclose_identity_mapping(", "disclose(") - text = text.replace("disclosed.direct_identity()", "disclosed.0.direct_identity()") - text = text.replace( - "disclosed.opaque_analytical_id()", "disclosed.0.opaque_analytical_id()" - ) - path.write_text(text, encoding="utf-8") - - -def update_documents() -> None: - """Keep the five-condition grant matrix and audit evidence wording aligned.""" - replacements = [ - ( - "docs/API_CONTRACT.md", - "refuses expired, impossible-calendar, or cross-tenant grants", - "refuses expired, not-yet-valid, inverted, cross-tenant, or impossible-calendar grants", - ), - ( - "docs/research/task-12-versioned-api-contracts.md", - "expired-purpose denial, provider mapping refusal, and elevated re-identification;", - "expired, not-yet-valid, inverted, cross-tenant, and impossible-calendar grant denial; provider mapping refusal; and audited elevated re-identification replay;", - ), - ( - "docs/validation/temporal-event-foundation.md", - "expired/impossible-calendar grant, mapping refusal, elevated re-id", - "expired/not-yet-valid/inverted/cross-tenant/impossible-calendar grant, mapping refusal, audited elevated re-id replay", - ), - ] - for path_string, old, new in replacements: - path = Path(path_string) - text = path.read_text(encoding="utf-8") - path.write_text(replace_once(text, old, new, path_string), encoding="utf-8") - - -update_provider_module() -update_public_exports() -update_contract_tests() -update_documents() From f0df1cc969eefc8c9c2c0c1e81a2131f60220dcc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 22:09:23 +0900 Subject: [PATCH 20/39] test(api): replay every audited reidentification denial --- .../reidentification_audit_denial_matrix.rs | 115 ++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 crates/tepp_api/tests/reidentification_audit_denial_matrix.rs diff --git a/crates/tepp_api/tests/reidentification_audit_denial_matrix.rs b/crates/tepp_api/tests/reidentification_audit_denial_matrix.rs new file mode 100644 index 00000000..4201d810 --- /dev/null +++ b/crates/tepp_api/tests/reidentification_audit_denial_matrix.rs @@ -0,0 +1,115 @@ +//! Replay contract for every well-formed elevated re-identification denial path. + +use tepp_api::{ + AnalyticalPurpose, ApiError, IdentityMappingRecord, PurposeGrant, + ReidentificationAuditOutcome, ReidentificationAuditRecord, ReidentificationAuditSink, + disclose_identity_mapping, +}; + +const DECISION_DIGEST: &str = + "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + +#[derive(Default)] +struct RecordingAuditSink { + records: Vec, +} + +impl ReidentificationAuditSink for RecordingAuditSink { + fn append_reidentification_audit( + &mut self, + record: &ReidentificationAuditRecord, + ) -> Result<(), ApiError> { + self.records.push(record.clone()); + Ok(()) + } +} + +fn grant() -> PurposeGrant { + PurposeGrant { + tenant_workspace_id: "tenant-workspace".into(), + principal_id: "principal-analyst".into(), + purpose: AnalyticalPurpose::ScientificValidation, + valid_from: "2026-01-01T00:00:00Z".into(), + valid_to: Some("2026-12-31T23:59:59Z".into()), + reidentification_authorized: true, + } +} + +fn mapping() -> IdentityMappingRecord { + IdentityMappingRecord { + tenant_workspace_id: "tenant-workspace".into(), + opaque_analytical_id: "opaque-person-42".into(), + direct_identity: "Pat Lee ".into(), + } +} + +fn assert_denied_and_audited( + grant: &PurposeGrant, + mapping: &IdentityMappingRecord, + decision_time: &str, + sink: &mut RecordingAuditSink, +) { + assert_eq!( + disclose_identity_mapping(grant, mapping, decision_time, DECISION_DIGEST, sink), + Err(ApiError::AuthorizationDenied) + ); + let record = sink.records.last().expect("denial audit record"); + assert_eq!(record.outcome(), ReidentificationAuditOutcome::Denied); + assert_eq!(record.decision_digest(), DECISION_DIGEST); + assert!(!format!("{record:?}").contains("Pat Lee")); + assert!(!format!("{record:?}").contains("pat.lee@example.test")); +} + +#[test] +fn all_well_formed_denial_paths_append_replayable_redacted_records() { + let mut sink = RecordingAuditSink::default(); + + let expired = grant(); + assert_denied_and_audited( + &expired, + &mapping(), + "2027-01-01T00:00:00Z", + &mut sink, + ); + + let not_yet_valid = grant(); + assert_denied_and_audited( + ¬_yet_valid, + &mapping(), + "2025-12-31T23:59:59Z", + &mut sink, + ); + + let mut cross_tenant = mapping(); + cross_tenant.tenant_workspace_id = "other-tenant".into(); + assert_denied_and_audited( + &grant(), + &cross_tenant, + "2026-06-15T12:00:00Z", + &mut sink, + ); + + let mut wrong_purpose = grant(); + wrong_purpose.purpose = AnalyticalPurpose::PartnerDisclosure; + assert_denied_and_audited( + &wrong_purpose, + &mapping(), + "2026-06-15T12:00:00Z", + &mut sink, + ); + + let mut missing_elevation = grant(); + missing_elevation.reidentification_authorized = false; + assert_denied_and_audited( + &missing_elevation, + &mapping(), + "2026-06-15T12:00:00Z", + &mut sink, + ); + + assert_eq!(sink.records.len(), 5); + assert!(sink + .records + .iter() + .all(|record| record.outcome() == ReidentificationAuditOutcome::Denied)); +} From 2b9f316faacd524e69c98e48d56685931677e43d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 22:26:20 +0900 Subject: [PATCH 21/39] test(api): stage internal reidentification audit digest repair --- scripts/repair_pr46_internal_audit_digest.py | 198 +++++++++++++++++++ 1 file changed, 198 insertions(+) create mode 100644 scripts/repair_pr46_internal_audit_digest.py diff --git a/scripts/repair_pr46_internal_audit_digest.py b/scripts/repair_pr46_internal_audit_digest.py new file mode 100644 index 00000000..ef35e1c9 --- /dev/null +++ b/scripts/repair_pr46_internal_audit_digest.py @@ -0,0 +1,198 @@ +"""Move re-identification audit digest construction inside the TEPP trust boundary.""" + +from pathlib import Path + + +def replace_once(text: str, old: str, new: str, label: str) -> str: + """Replace exactly one source fragment or fail closed.""" + count = text.count(old) + if count != 1: + raise SystemExit(f"{label}: expected one target, found {count}") + return text.replace(old, new, 1) + + +cargo_path = Path("crates/tepp_api/Cargo.toml") +cargo = cargo_path.read_text(encoding="utf-8") +cargo = replace_once( + cargo, + "serde_json = { workspace = true }\ntemporal_core = { path = \"../temporal_core\" }\n", + "serde_json = { workspace = true }\nsha2 = { workspace = true }\ntemporal_core = { path = \"../temporal_core\" }\n", + "tepp_api sha2 dependency", +) +cargo_path.write_text(cargo, encoding="utf-8") + +provider_path = Path("crates/tepp_api/src/provider_payload.rs") +provider = provider_path.read_text(encoding="utf-8") +provider = replace_once( + provider, + "use crate::wire::require_nonempty;\nuse std::fmt;\n", + "use crate::wire::require_nonempty;\nuse sha2::{Digest, Sha256};\nuse std::fmt;\n", + "provider digest imports", +) +provider = replace_once( + provider, + " decision_time: &str,\n decision_digest: &str,\n audit_sink: &mut S,\n", + " decision_time: &str,\n audit_sink: &mut S,\n", + "re-identification signature", +) +provider = replace_once( + provider, + " require_rfc3339_utc(decision_time)?;\n require_sha256_digest(decision_digest)?;\n\n let allowed =", + " require_rfc3339_utc(decision_time)?;\n\n let allowed =", + "caller-supplied digest validation", +) +provider = replace_once( + provider, + """ let audit_record = ReidentificationAuditRecord { + tenant_workspace_id: grant.tenant_workspace_id.clone(), + principal_id: grant.principal_id.clone(), + purpose_wire_name: grant.purpose.wire_name().into(), + action_code: \"reidentify_identity_mapping\", + opaque_analytical_id: mapping.opaque_analytical_id.clone(), + decision_time: decision_time.into(), + outcome: if allowed { + ReidentificationAuditOutcome::Allowed + } else { + ReidentificationAuditOutcome::Denied + }, + decision_digest: decision_digest.into(), + }; +""", + """ let outcome = if allowed { + ReidentificationAuditOutcome::Allowed + } else { + ReidentificationAuditOutcome::Denied + }; + let audit_record = ReidentificationAuditRecord { + tenant_workspace_id: grant.tenant_workspace_id.clone(), + principal_id: grant.principal_id.clone(), + purpose_wire_name: grant.purpose.wire_name().into(), + action_code: \"reidentify_identity_mapping\", + opaque_analytical_id: mapping.opaque_analytical_id.clone(), + decision_time: decision_time.into(), + outcome, + decision_digest: reidentification_decision_digest( + grant, + mapping, + decision_time, + outcome, + )?, + }; +""", + "audit record construction", +) +old_digest_validator = """fn require_sha256_digest(value: &str) -> Result<(), ApiError> { + let Some(digest) = value.strip_prefix(\"sha256:\") else { + return Err(ApiError::InvalidWirePayload); + }; + if digest.len() == 64 + && digest + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + Ok(()) + } else { + Err(ApiError::InvalidWirePayload) + } +} + +""" +new_digest_builder = """const REIDENTIFICATION_AUDIT_DIGEST_VERSION: &str = \"tepp.reidentification.audit.v1\"; + +fn reidentification_decision_digest( + grant: &PurposeGrant, + mapping: &IdentityMappingRecord, + decision_time: &str, + outcome: ReidentificationAuditOutcome, +) -> Result { + let mut hasher = Sha256::new(); + for value in [ + REIDENTIFICATION_AUDIT_DIGEST_VERSION, + \"reidentify_identity_mapping\", + &grant.tenant_workspace_id, + &grant.principal_id, + grant.purpose.wire_name(), + &grant.valid_from, + if grant.valid_to.is_some() { \"1\" } else { \"0\" }, + grant.valid_to.as_deref().unwrap_or(\"\"), + if grant.reidentification_authorized { \"1\" } else { \"0\" }, + &mapping.tenant_workspace_id, + &mapping.opaque_analytical_id, + &mapping.direct_identity, + decision_time, + outcome.wire_name(), + ] { + update_audit_digest_field(&mut hasher, value)?; + } + Ok(format!(\"sha256:{:x}\", hasher.finalize())) +} + +fn update_audit_digest_field(hasher: &mut Sha256, value: &str) -> Result<(), ApiError> { + let length = u64::try_from(value.len()).map_err(|_| ApiError::LimitExceeded)?; + hasher.update(length.to_be_bytes()); + hasher.update(value.as_bytes()); + Ok(()) +} + +""" +provider = replace_once( + provider, + old_digest_validator, + new_digest_builder, + "internal audit digest builder", +) +provider = provider.replace( + " decision_time,\n \"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\n &mut audit_sink,\n", + " decision_time,\n &mut audit_sink,\n", +) +provider_path.write_text(provider, encoding="utf-8") + +provider_contract_path = Path("crates/tepp_api/tests/provider_payload_contract.rs") +provider_contract = provider_contract_path.read_text(encoding="utf-8") +provider_contract = replace_once( + provider_contract, + """ decision_time, + \"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\", + &mut sink, +""", + """ decision_time, + &mut sink, +""", + "provider contract disclosure helper", +) +provider_contract_path.write_text(provider_contract, encoding="utf-8") + +denial_path = Path("crates/tepp_api/tests/reidentification_audit_denial_matrix.rs") +denial = denial_path.read_text(encoding="utf-8") +denial = denial.replace( + "const DECISION_DIGEST: &str =\n \"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\";\n\n", + "", +) +denial = replace_once( + denial, + " disclose_identity_mapping(grant, mapping, decision_time, DECISION_DIGEST, sink),\n", + " disclose_identity_mapping(grant, mapping, decision_time, sink),\n", + "denial matrix disclosure call", +) +denial = replace_once( + denial, + " assert_eq!(record.decision_digest(), DECISION_DIGEST);\n", + " assert!(record.decision_digest().starts_with(\"sha256:\"));\n assert_eq!(record.decision_digest().len(), 71);\n", + "denial matrix digest assertion", +) +denial_path.write_text(denial, encoding="utf-8") + +research_path = Path("docs/research/provider-payload-minimization.md") +research = research_path.read_text(encoding="utf-8").rstrip() +research += """ + +## Re-identification audit digest authority + +The caller cannot provide or select the decision digest. TEPP computes a +versioned, length-delimited SHA-256 digest inside the trust boundary from the +purpose grant, protected mapping, decision instant, and allow/deny outcome. +Direct identity contributes to the digest but never appears in the redacted +audit record or ordinary logs. This makes append-only replay evidence bind the +actual governed decision rather than an arbitrary caller assertion. +""" +research_path.write_text(research + "\n", encoding="utf-8") From 007ed01f797ce22a4c4cd0597c6864ea3ac6cded Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 22:26:50 +0900 Subject: [PATCH 22/39] ci: verify PR 46 internal reidentification audit digest --- .../repair-pr46-internal-audit-digest.yml | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 .github/workflows/repair-pr46-internal-audit-digest.yml diff --git a/.github/workflows/repair-pr46-internal-audit-digest.yml b/.github/workflows/repair-pr46-internal-audit-digest.yml new file mode 100644 index 00000000..a21260f0 --- /dev/null +++ b/.github/workflows/repair-pr46-internal-audit-digest.yml @@ -0,0 +1,74 @@ +name: Repair PR 46 internal audit digest + +on: + pull_request: + types: + - synchronize + - reopened + - ready_for_review + +permissions: + contents: read + +concurrency: + group: repair-tepp-pr-46-internal-audit-digest + cancel-in-progress: true + +jobs: + repair: + if: >- + github.event.pull_request.number == 46 && + github.event.pull_request.head.repo.full_name == github.repository && + github.event.pull_request.head.ref == 'agent/api-provider-payload-minimization' + runs-on: ubuntu-latest + timeout-minutes: 35 + permissions: + contents: write + steps: + - name: Checkout exact PR branch + uses: actions/checkout@631c942040754b6e095e929c1677c07e10ed4f87 + with: + ref: agent/api-provider-payload-minimization + fetch-depth: 0 + persist-credentials: true + + - name: Install pinned Rust toolchain + run: rustup toolchain install 1.97.1 --profile minimal --component clippy --component rustfmt + + - name: Prove caller-controlled digest regression is RED + run: | + set +e + output=$(cargo +1.97.1 test -p tepp_api --test reidentification_audit_contract 2>&1) + status=$? + set -e + printf '%s\n' "$output" + if [ "$status" -eq 0 ]; then + echo "Expected the trust-boundary digest regression to fail before implementation" >&2 + exit 1 + fi + grep -E "disclose_identity_mapping|argument|reidentification_audit_contract" <<<"$output" + + - name: Build digest inside the TEPP trust boundary + run: | + python3 scripts/repair_pr46_internal_audit_digest.py + cargo +1.97.1 fmt --all + + - name: Verify focused and workspace contracts + run: | + cargo +1.97.1 fmt --all --check + cargo +1.97.1 test -p tepp_api --all-features + cargo +1.97.1 clippy -p tepp_api --all-targets --all-features -- -D warnings + cargo +1.97.1 test --workspace --all-features + python3 scripts/check_docstrings.py + python3 scripts/validate_documentation.py + + - name: Commit verified repair and remove one-shot files + run: | + rm -f .github/workflows/repair-pr46-internal-audit-digest.yml + rm -f scripts/repair_pr46_internal_audit_digest.py + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A + git diff --cached --check + git commit -m "fix(api): bind reidentification audit digest internally" + git push origin HEAD:agent/api-provider-payload-minimization From 082c0504f2ca0f01309ad4f37cd35e469b941086 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 22:28:07 +0900 Subject: [PATCH 23/39] test(api): require internally bound reidentification audit digests --- .../tests/reidentification_audit_contract.rs | 74 ++++++++++++------- 1 file changed, 47 insertions(+), 27 deletions(-) diff --git a/crates/tepp_api/tests/reidentification_audit_contract.rs b/crates/tepp_api/tests/reidentification_audit_contract.rs index 7d5673fc..80225ecf 100644 --- a/crates/tepp_api/tests/reidentification_audit_contract.rs +++ b/crates/tepp_api/tests/reidentification_audit_contract.rs @@ -1,13 +1,11 @@ //! Elevated re-identification must append redacted audit evidence for every decision. use tepp_api::{ - AnalyticalPurpose, ApiError, IdentityMappingRecord, PurposeGrant, ReidentificationAuditOutcome, - ReidentificationAuditRecord, ReidentificationAuditSink, disclose_identity_mapping, + AnalyticalPurpose, ApiError, IdentityMappingRecord, PurposeGrant, + ReidentificationAuditOutcome, ReidentificationAuditRecord, ReidentificationAuditSink, + disclose_identity_mapping, }; -const DECISION_DIGEST: &str = - "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; - #[derive(Default)] struct RecordingAuditSink { records: Vec, @@ -53,15 +51,11 @@ fn successful_reidentification_appends_redacted_audit_evidence_before_disclosure &grant(true), &mapping(), "2026-06-15T12:00:00Z", - DECISION_DIGEST, &mut sink, ) .expect("audited elevated disclosure"); - assert_eq!( - disclosed.direct_identity(), - "Pat Lee " - ); + assert_eq!(disclosed.direct_identity(), "Pat Lee "); assert_eq!(sink.records, vec![audit.clone()]); assert_eq!(audit.tenant_workspace_id(), "tenant-workspace"); assert_eq!(audit.principal_id(), "principal-analyst"); @@ -71,7 +65,8 @@ fn successful_reidentification_appends_redacted_audit_evidence_before_disclosure assert_eq!(audit.decision_time(), "2026-06-15T12:00:00Z"); assert_eq!(audit.outcome(), ReidentificationAuditOutcome::Allowed); assert_eq!(audit.outcome().wire_name(), "allowed"); - assert_eq!(audit.decision_digest(), DECISION_DIGEST); + assert!(audit.decision_digest().starts_with("sha256:")); + assert_eq!(audit.decision_digest().len(), 71); assert!(!format!("{audit:?}").contains("Pat Lee")); assert!(!format!("{audit:?}").contains("pat.lee@example.test")); } @@ -84,7 +79,6 @@ fn denied_reidentification_is_appended_and_replay_preserves_decision_order() { &grant(false), &mapping(), "2026-06-15T12:00:00Z", - DECISION_DIGEST, &mut sink, ), Err(ApiError::AuthorizationDenied), @@ -93,7 +87,6 @@ fn denied_reidentification_is_appended_and_replay_preserves_decision_order() { &grant(true), &mapping(), "2026-06-15T12:00:01Z", - DECISION_DIGEST, &mut sink, ) .expect("second audited decision"); @@ -109,10 +102,15 @@ fn denied_reidentification_is_appended_and_replay_preserves_decision_order() { sink.records[1].outcome(), ReidentificationAuditOutcome::Allowed ); + assert_ne!( + sink.records[0].decision_digest(), + sink.records[1].decision_digest(), + "allow and deny outcomes must be bound into distinct audit evidence" + ); } #[test] -fn disclosure_fails_closed_when_audit_append_fails_or_digest_is_invalid() { +fn disclosure_fails_closed_when_audit_append_fails() { let mut failed_sink = RecordingAuditSink { fail_closed: true, ..RecordingAuditSink::default() @@ -122,22 +120,44 @@ fn disclosure_fails_closed_when_audit_append_fails_or_digest_is_invalid() { &grant(true), &mapping(), "2026-06-15T12:00:00Z", - DECISION_DIGEST, &mut failed_sink, ), Err(ApiError::LimitExceeded), ); +} - let mut sink = RecordingAuditSink::default(); - assert_eq!( - disclose_identity_mapping( - &grant(true), - &mapping(), - "2026-06-15T12:00:00Z", - "sha256:short", - &mut sink, - ), - Err(ApiError::InvalidWirePayload), - ); - assert!(sink.records.is_empty()); +#[test] +fn audit_digest_is_deterministic_and_binds_protected_mapping_content() { + let mut first_sink = RecordingAuditSink::default(); + let (_, first) = disclose_identity_mapping( + &grant(true), + &mapping(), + "2026-06-15T12:00:00Z", + &mut first_sink, + ) + .expect("first disclosure"); + + let mut repeat_sink = RecordingAuditSink::default(); + let (_, repeated) = disclose_identity_mapping( + &grant(true), + &mapping(), + "2026-06-15T12:00:00Z", + &mut repeat_sink, + ) + .expect("repeat disclosure"); + assert_eq!(first.decision_digest(), repeated.decision_digest()); + + let mut changed_mapping = mapping(); + changed_mapping.direct_identity = "Different Person".into(); + let mut changed_sink = RecordingAuditSink::default(); + let (_, changed) = disclose_identity_mapping( + &grant(true), + &changed_mapping, + "2026-06-15T12:00:00Z", + &mut changed_sink, + ) + .expect("changed mapping disclosure"); + assert_ne!(first.decision_digest(), changed.decision_digest()); + assert!(!first.decision_digest().contains("Pat")); + assert!(!changed.decision_digest().contains("Different")); } From d6215118e2a912c018ed911202aac83e270d28a7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 14:54:19 +0900 Subject: [PATCH 24/39] fix(ci): normalize audit digest research document EOF --- scripts/repair_pr46_internal_audit_digest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/repair_pr46_internal_audit_digest.py b/scripts/repair_pr46_internal_audit_digest.py index ef35e1c9..f4605fc8 100644 --- a/scripts/repair_pr46_internal_audit_digest.py +++ b/scripts/repair_pr46_internal_audit_digest.py @@ -195,4 +195,4 @@ def replace_once(text: str, old: str, new: str, label: str) -> str: audit record or ordinary logs. This makes append-only replay evidence bind the actual governed decision rather than an arbitrary caller assertion. """ -research_path.write_text(research + "\n", encoding="utf-8") +research_path.write_text(research.rstrip() + "\n", encoding="utf-8") From 61fe175bd9249b0363c7ff514a79ae098755d779 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 15 Aug 2026 05:55:27 +0000 Subject: [PATCH 25/39] fix(api): bind reidentification audit digest internally --- .../repair-pr46-internal-audit-digest.yml | 74 ------- Cargo.lock | 1 + crates/tepp_api/Cargo.toml | 1 + crates/tepp_api/src/provider_payload.rs | 76 ++++--- .../tests/provider_payload_contract.rs | 8 +- .../tests/reidentification_audit_contract.rs | 37 ++-- .../reidentification_audit_denial_matrix.rs | 36 ++-- .../research/provider-payload-minimization.md | 9 + scripts/repair_pr46_internal_audit_digest.py | 198 ------------------ 9 files changed, 85 insertions(+), 355 deletions(-) delete mode 100644 .github/workflows/repair-pr46-internal-audit-digest.yml delete mode 100644 scripts/repair_pr46_internal_audit_digest.py diff --git a/.github/workflows/repair-pr46-internal-audit-digest.yml b/.github/workflows/repair-pr46-internal-audit-digest.yml deleted file mode 100644 index a21260f0..00000000 --- a/.github/workflows/repair-pr46-internal-audit-digest.yml +++ /dev/null @@ -1,74 +0,0 @@ -name: Repair PR 46 internal audit digest - -on: - pull_request: - types: - - synchronize - - reopened - - ready_for_review - -permissions: - contents: read - -concurrency: - group: repair-tepp-pr-46-internal-audit-digest - cancel-in-progress: true - -jobs: - repair: - if: >- - github.event.pull_request.number == 46 && - github.event.pull_request.head.repo.full_name == github.repository && - github.event.pull_request.head.ref == 'agent/api-provider-payload-minimization' - runs-on: ubuntu-latest - timeout-minutes: 35 - permissions: - contents: write - steps: - - name: Checkout exact PR branch - uses: actions/checkout@631c942040754b6e095e929c1677c07e10ed4f87 - with: - ref: agent/api-provider-payload-minimization - fetch-depth: 0 - persist-credentials: true - - - name: Install pinned Rust toolchain - run: rustup toolchain install 1.97.1 --profile minimal --component clippy --component rustfmt - - - name: Prove caller-controlled digest regression is RED - run: | - set +e - output=$(cargo +1.97.1 test -p tepp_api --test reidentification_audit_contract 2>&1) - status=$? - set -e - printf '%s\n' "$output" - if [ "$status" -eq 0 ]; then - echo "Expected the trust-boundary digest regression to fail before implementation" >&2 - exit 1 - fi - grep -E "disclose_identity_mapping|argument|reidentification_audit_contract" <<<"$output" - - - name: Build digest inside the TEPP trust boundary - run: | - python3 scripts/repair_pr46_internal_audit_digest.py - cargo +1.97.1 fmt --all - - - name: Verify focused and workspace contracts - run: | - cargo +1.97.1 fmt --all --check - cargo +1.97.1 test -p tepp_api --all-features - cargo +1.97.1 clippy -p tepp_api --all-targets --all-features -- -D warnings - cargo +1.97.1 test --workspace --all-features - python3 scripts/check_docstrings.py - python3 scripts/validate_documentation.py - - - name: Commit verified repair and remove one-shot files - run: | - rm -f .github/workflows/repair-pr46-internal-audit-digest.yml - rm -f scripts/repair_pr46_internal_audit_digest.py - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -A - git diff --cached --check - git commit -m "fix(api): bind reidentification audit digest internally" - git push origin HEAD:agent/api-provider-payload-minimization diff --git a/Cargo.lock b/Cargo.lock index 8e971100..616bfd78 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1272,6 +1272,7 @@ version = "0.1.0" dependencies = [ "serde", "serde_json", + "sha2", "temporal_core", ] diff --git a/crates/tepp_api/Cargo.toml b/crates/tepp_api/Cargo.toml index cef48bb8..f51834c9 100644 --- a/crates/tepp_api/Cargo.toml +++ b/crates/tepp_api/Cargo.toml @@ -16,6 +16,7 @@ publish = false [dependencies] serde = { workspace = true } serde_json = { workspace = true } +sha2 = { workspace = true } temporal_core = { path = "../temporal_core" } [lints] diff --git a/crates/tepp_api/src/provider_payload.rs b/crates/tepp_api/src/provider_payload.rs index f8e52de4..88c02d42 100644 --- a/crates/tepp_api/src/provider_payload.rs +++ b/crates/tepp_api/src/provider_payload.rs @@ -5,6 +5,7 @@ use crate::authorization::{ AnalyticalPurpose, ExportAuthorizationRequest, authorize_export, require_export_allowed, }; use crate::wire::require_nonempty; +use sha2::{Digest, Sha256}; use std::fmt; use temporal_core::TemporalInstant; @@ -348,7 +349,6 @@ pub fn disclose_identity_mapping( grant: &PurposeGrant, mapping: &IdentityMappingRecord, decision_time: &str, - decision_digest: &str, audit_sink: &mut S, ) -> Result<(DisclosedIdentityMapping, ReidentificationAuditRecord), ApiError> { validate_grant(grant)?; @@ -356,12 +356,16 @@ pub fn disclose_identity_mapping( require_nonempty(&mapping.opaque_analytical_id)?; require_nonempty(&mapping.direct_identity)?; require_rfc3339_utc(decision_time)?; - require_sha256_digest(decision_digest)?; let allowed = grant_covers(grant, decision_time) && mapping.tenant_workspace_id == grant.tenant_workspace_id && grant.reidentification_authorized && grant.purpose == AnalyticalPurpose::ScientificValidation; + let outcome = if allowed { + ReidentificationAuditOutcome::Allowed + } else { + ReidentificationAuditOutcome::Denied + }; let audit_record = ReidentificationAuditRecord { tenant_workspace_id: grant.tenant_workspace_id.clone(), principal_id: grant.principal_id.clone(), @@ -369,12 +373,8 @@ pub fn disclose_identity_mapping( action_code: "reidentify_identity_mapping", opaque_analytical_id: mapping.opaque_analytical_id.clone(), decision_time: decision_time.into(), - outcome: if allowed { - ReidentificationAuditOutcome::Allowed - } else { - ReidentificationAuditOutcome::Denied - }, - decision_digest: decision_digest.into(), + outcome, + decision_digest: reidentification_decision_digest(grant, mapping, decision_time, outcome)?, }; audit_sink.append_reidentification_audit(&audit_record)?; if !allowed { @@ -389,19 +389,45 @@ pub fn disclose_identity_mapping( )) } -fn require_sha256_digest(value: &str) -> Result<(), ApiError> { - let Some(digest) = value.strip_prefix("sha256:") else { - return Err(ApiError::InvalidWirePayload); - }; - if digest.len() == 64 - && digest - .bytes() - .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) - { - Ok(()) - } else { - Err(ApiError::InvalidWirePayload) - } +const REIDENTIFICATION_AUDIT_DIGEST_VERSION: &str = "tepp.reidentification.audit.v1"; + +fn reidentification_decision_digest( + grant: &PurposeGrant, + mapping: &IdentityMappingRecord, + decision_time: &str, + outcome: ReidentificationAuditOutcome, +) -> Result { + let mut hasher = Sha256::new(); + for value in [ + REIDENTIFICATION_AUDIT_DIGEST_VERSION, + "reidentify_identity_mapping", + &grant.tenant_workspace_id, + &grant.principal_id, + grant.purpose.wire_name(), + &grant.valid_from, + if grant.valid_to.is_some() { "1" } else { "0" }, + grant.valid_to.as_deref().unwrap_or(""), + if grant.reidentification_authorized { + "1" + } else { + "0" + }, + &mapping.tenant_workspace_id, + &mapping.opaque_analytical_id, + &mapping.direct_identity, + decision_time, + outcome.wire_name(), + ] { + update_audit_digest_field(&mut hasher, value)?; + } + Ok(format!("sha256:{:x}", hasher.finalize())) +} + +fn update_audit_digest_field(hasher: &mut Sha256, value: &str) -> Result<(), ApiError> { + let length = u64::try_from(value.len()).map_err(|_| ApiError::LimitExceeded)?; + hasher.update(length.to_be_bytes()); + hasher.update(value.as_bytes()); + Ok(()) } // Lexicographic comparisons in `validate_grant` and `grant_covers` are valid @@ -528,13 +554,7 @@ mod tests { decision_time: &str, ) -> Result<(DisclosedIdentityMapping, ReidentificationAuditRecord), ApiError> { let mut audit_sink = RecordingAuditSink::default(); - disclose_identity_mapping_with_audit( - grant, - mapping, - decision_time, - "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - &mut audit_sink, - ) + disclose_identity_mapping_with_audit(grant, mapping, decision_time, &mut audit_sink) } #[test] diff --git a/crates/tepp_api/tests/provider_payload_contract.rs b/crates/tepp_api/tests/provider_payload_contract.rs index b85c11c1..f7294a0e 100644 --- a/crates/tepp_api/tests/provider_payload_contract.rs +++ b/crates/tepp_api/tests/provider_payload_contract.rs @@ -49,13 +49,7 @@ fn disclose( decision_time: &str, ) -> Result<(DisclosedIdentityMapping, ReidentificationAuditRecord), ApiError> { let mut sink = RecordingAuditSink::default(); - disclose_identity_mapping_with_audit( - grant, - mapping, - decision_time, - "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - &mut sink, - ) + disclose_identity_mapping_with_audit(grant, mapping, decision_time, &mut sink) } #[test] diff --git a/crates/tepp_api/tests/reidentification_audit_contract.rs b/crates/tepp_api/tests/reidentification_audit_contract.rs index 80225ecf..cfdc6ecf 100644 --- a/crates/tepp_api/tests/reidentification_audit_contract.rs +++ b/crates/tepp_api/tests/reidentification_audit_contract.rs @@ -1,9 +1,8 @@ //! Elevated re-identification must append redacted audit evidence for every decision. use tepp_api::{ - AnalyticalPurpose, ApiError, IdentityMappingRecord, PurposeGrant, - ReidentificationAuditOutcome, ReidentificationAuditRecord, ReidentificationAuditSink, - disclose_identity_mapping, + AnalyticalPurpose, ApiError, IdentityMappingRecord, PurposeGrant, ReidentificationAuditOutcome, + ReidentificationAuditRecord, ReidentificationAuditSink, disclose_identity_mapping, }; #[derive(Default)] @@ -47,15 +46,14 @@ fn mapping() -> IdentityMappingRecord { #[test] fn successful_reidentification_appends_redacted_audit_evidence_before_disclosure() { let mut sink = RecordingAuditSink::default(); - let (disclosed, audit) = disclose_identity_mapping( - &grant(true), - &mapping(), - "2026-06-15T12:00:00Z", - &mut sink, - ) - .expect("audited elevated disclosure"); + let (disclosed, audit) = + disclose_identity_mapping(&grant(true), &mapping(), "2026-06-15T12:00:00Z", &mut sink) + .expect("audited elevated disclosure"); - assert_eq!(disclosed.direct_identity(), "Pat Lee "); + assert_eq!( + disclosed.direct_identity(), + "Pat Lee " + ); assert_eq!(sink.records, vec![audit.clone()]); assert_eq!(audit.tenant_workspace_id(), "tenant-workspace"); assert_eq!(audit.principal_id(), "principal-analyst"); @@ -75,21 +73,12 @@ fn successful_reidentification_appends_redacted_audit_evidence_before_disclosure fn denied_reidentification_is_appended_and_replay_preserves_decision_order() { let mut sink = RecordingAuditSink::default(); assert_eq!( - disclose_identity_mapping( - &grant(false), - &mapping(), - "2026-06-15T12:00:00Z", - &mut sink, - ), + disclose_identity_mapping(&grant(false), &mapping(), "2026-06-15T12:00:00Z", &mut sink,), Err(ApiError::AuthorizationDenied), ); - let (_, allowed) = disclose_identity_mapping( - &grant(true), - &mapping(), - "2026-06-15T12:00:01Z", - &mut sink, - ) - .expect("second audited decision"); + let (_, allowed) = + disclose_identity_mapping(&grant(true), &mapping(), "2026-06-15T12:00:01Z", &mut sink) + .expect("second audited decision"); assert_eq!(sink.records.len(), 2); assert_eq!( diff --git a/crates/tepp_api/tests/reidentification_audit_denial_matrix.rs b/crates/tepp_api/tests/reidentification_audit_denial_matrix.rs index 4201d810..2feba7ee 100644 --- a/crates/tepp_api/tests/reidentification_audit_denial_matrix.rs +++ b/crates/tepp_api/tests/reidentification_audit_denial_matrix.rs @@ -1,14 +1,10 @@ //! Replay contract for every well-formed elevated re-identification denial path. use tepp_api::{ - AnalyticalPurpose, ApiError, IdentityMappingRecord, PurposeGrant, - ReidentificationAuditOutcome, ReidentificationAuditRecord, ReidentificationAuditSink, - disclose_identity_mapping, + AnalyticalPurpose, ApiError, IdentityMappingRecord, PurposeGrant, ReidentificationAuditOutcome, + ReidentificationAuditRecord, ReidentificationAuditSink, disclose_identity_mapping, }; -const DECISION_DIGEST: &str = - "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; - #[derive(Default)] struct RecordingAuditSink { records: Vec, @@ -50,12 +46,13 @@ fn assert_denied_and_audited( sink: &mut RecordingAuditSink, ) { assert_eq!( - disclose_identity_mapping(grant, mapping, decision_time, DECISION_DIGEST, sink), + disclose_identity_mapping(grant, mapping, decision_time, sink), Err(ApiError::AuthorizationDenied) ); let record = sink.records.last().expect("denial audit record"); assert_eq!(record.outcome(), ReidentificationAuditOutcome::Denied); - assert_eq!(record.decision_digest(), DECISION_DIGEST); + assert!(record.decision_digest().starts_with("sha256:")); + assert_eq!(record.decision_digest().len(), 71); assert!(!format!("{record:?}").contains("Pat Lee")); assert!(!format!("{record:?}").contains("pat.lee@example.test")); } @@ -65,12 +62,7 @@ fn all_well_formed_denial_paths_append_replayable_redacted_records() { let mut sink = RecordingAuditSink::default(); let expired = grant(); - assert_denied_and_audited( - &expired, - &mapping(), - "2027-01-01T00:00:00Z", - &mut sink, - ); + assert_denied_and_audited(&expired, &mapping(), "2027-01-01T00:00:00Z", &mut sink); let not_yet_valid = grant(); assert_denied_and_audited( @@ -82,12 +74,7 @@ fn all_well_formed_denial_paths_append_replayable_redacted_records() { let mut cross_tenant = mapping(); cross_tenant.tenant_workspace_id = "other-tenant".into(); - assert_denied_and_audited( - &grant(), - &cross_tenant, - "2026-06-15T12:00:00Z", - &mut sink, - ); + assert_denied_and_audited(&grant(), &cross_tenant, "2026-06-15T12:00:00Z", &mut sink); let mut wrong_purpose = grant(); wrong_purpose.purpose = AnalyticalPurpose::PartnerDisclosure; @@ -108,8 +95,9 @@ fn all_well_formed_denial_paths_append_replayable_redacted_records() { ); assert_eq!(sink.records.len(), 5); - assert!(sink - .records - .iter() - .all(|record| record.outcome() == ReidentificationAuditOutcome::Denied)); + assert!( + sink.records + .iter() + .all(|record| record.outcome() == ReidentificationAuditOutcome::Denied) + ); } diff --git a/docs/research/provider-payload-minimization.md b/docs/research/provider-payload-minimization.md index 8d5ead88..c3564446 100644 --- a/docs/research/provider-payload-minimization.md +++ b/docs/research/provider-payload-minimization.md @@ -32,3 +32,12 @@ ISO/IEC 27701:2025 is the current standalone Privacy Information Management Syst - attached identity mappings are refused on the provider path; - elevated scientific re-identification returns the mapping; other purposes and missing flags are denied; - disclosure logs never contain source text or mapping strings. + +## Re-identification audit digest authority + +The caller cannot provide or select the decision digest. TEPP computes a +versioned, length-delimited SHA-256 digest inside the trust boundary from the +purpose grant, protected mapping, decision instant, and allow/deny outcome. +Direct identity contributes to the digest but never appears in the redacted +audit record or ordinary logs. This makes append-only replay evidence bind the +actual governed decision rather than an arbitrary caller assertion. diff --git a/scripts/repair_pr46_internal_audit_digest.py b/scripts/repair_pr46_internal_audit_digest.py deleted file mode 100644 index f4605fc8..00000000 --- a/scripts/repair_pr46_internal_audit_digest.py +++ /dev/null @@ -1,198 +0,0 @@ -"""Move re-identification audit digest construction inside the TEPP trust boundary.""" - -from pathlib import Path - - -def replace_once(text: str, old: str, new: str, label: str) -> str: - """Replace exactly one source fragment or fail closed.""" - count = text.count(old) - if count != 1: - raise SystemExit(f"{label}: expected one target, found {count}") - return text.replace(old, new, 1) - - -cargo_path = Path("crates/tepp_api/Cargo.toml") -cargo = cargo_path.read_text(encoding="utf-8") -cargo = replace_once( - cargo, - "serde_json = { workspace = true }\ntemporal_core = { path = \"../temporal_core\" }\n", - "serde_json = { workspace = true }\nsha2 = { workspace = true }\ntemporal_core = { path = \"../temporal_core\" }\n", - "tepp_api sha2 dependency", -) -cargo_path.write_text(cargo, encoding="utf-8") - -provider_path = Path("crates/tepp_api/src/provider_payload.rs") -provider = provider_path.read_text(encoding="utf-8") -provider = replace_once( - provider, - "use crate::wire::require_nonempty;\nuse std::fmt;\n", - "use crate::wire::require_nonempty;\nuse sha2::{Digest, Sha256};\nuse std::fmt;\n", - "provider digest imports", -) -provider = replace_once( - provider, - " decision_time: &str,\n decision_digest: &str,\n audit_sink: &mut S,\n", - " decision_time: &str,\n audit_sink: &mut S,\n", - "re-identification signature", -) -provider = replace_once( - provider, - " require_rfc3339_utc(decision_time)?;\n require_sha256_digest(decision_digest)?;\n\n let allowed =", - " require_rfc3339_utc(decision_time)?;\n\n let allowed =", - "caller-supplied digest validation", -) -provider = replace_once( - provider, - """ let audit_record = ReidentificationAuditRecord { - tenant_workspace_id: grant.tenant_workspace_id.clone(), - principal_id: grant.principal_id.clone(), - purpose_wire_name: grant.purpose.wire_name().into(), - action_code: \"reidentify_identity_mapping\", - opaque_analytical_id: mapping.opaque_analytical_id.clone(), - decision_time: decision_time.into(), - outcome: if allowed { - ReidentificationAuditOutcome::Allowed - } else { - ReidentificationAuditOutcome::Denied - }, - decision_digest: decision_digest.into(), - }; -""", - """ let outcome = if allowed { - ReidentificationAuditOutcome::Allowed - } else { - ReidentificationAuditOutcome::Denied - }; - let audit_record = ReidentificationAuditRecord { - tenant_workspace_id: grant.tenant_workspace_id.clone(), - principal_id: grant.principal_id.clone(), - purpose_wire_name: grant.purpose.wire_name().into(), - action_code: \"reidentify_identity_mapping\", - opaque_analytical_id: mapping.opaque_analytical_id.clone(), - decision_time: decision_time.into(), - outcome, - decision_digest: reidentification_decision_digest( - grant, - mapping, - decision_time, - outcome, - )?, - }; -""", - "audit record construction", -) -old_digest_validator = """fn require_sha256_digest(value: &str) -> Result<(), ApiError> { - let Some(digest) = value.strip_prefix(\"sha256:\") else { - return Err(ApiError::InvalidWirePayload); - }; - if digest.len() == 64 - && digest - .bytes() - .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) - { - Ok(()) - } else { - Err(ApiError::InvalidWirePayload) - } -} - -""" -new_digest_builder = """const REIDENTIFICATION_AUDIT_DIGEST_VERSION: &str = \"tepp.reidentification.audit.v1\"; - -fn reidentification_decision_digest( - grant: &PurposeGrant, - mapping: &IdentityMappingRecord, - decision_time: &str, - outcome: ReidentificationAuditOutcome, -) -> Result { - let mut hasher = Sha256::new(); - for value in [ - REIDENTIFICATION_AUDIT_DIGEST_VERSION, - \"reidentify_identity_mapping\", - &grant.tenant_workspace_id, - &grant.principal_id, - grant.purpose.wire_name(), - &grant.valid_from, - if grant.valid_to.is_some() { \"1\" } else { \"0\" }, - grant.valid_to.as_deref().unwrap_or(\"\"), - if grant.reidentification_authorized { \"1\" } else { \"0\" }, - &mapping.tenant_workspace_id, - &mapping.opaque_analytical_id, - &mapping.direct_identity, - decision_time, - outcome.wire_name(), - ] { - update_audit_digest_field(&mut hasher, value)?; - } - Ok(format!(\"sha256:{:x}\", hasher.finalize())) -} - -fn update_audit_digest_field(hasher: &mut Sha256, value: &str) -> Result<(), ApiError> { - let length = u64::try_from(value.len()).map_err(|_| ApiError::LimitExceeded)?; - hasher.update(length.to_be_bytes()); - hasher.update(value.as_bytes()); - Ok(()) -} - -""" -provider = replace_once( - provider, - old_digest_validator, - new_digest_builder, - "internal audit digest builder", -) -provider = provider.replace( - " decision_time,\n \"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\n &mut audit_sink,\n", - " decision_time,\n &mut audit_sink,\n", -) -provider_path.write_text(provider, encoding="utf-8") - -provider_contract_path = Path("crates/tepp_api/tests/provider_payload_contract.rs") -provider_contract = provider_contract_path.read_text(encoding="utf-8") -provider_contract = replace_once( - provider_contract, - """ decision_time, - \"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\", - &mut sink, -""", - """ decision_time, - &mut sink, -""", - "provider contract disclosure helper", -) -provider_contract_path.write_text(provider_contract, encoding="utf-8") - -denial_path = Path("crates/tepp_api/tests/reidentification_audit_denial_matrix.rs") -denial = denial_path.read_text(encoding="utf-8") -denial = denial.replace( - "const DECISION_DIGEST: &str =\n \"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\";\n\n", - "", -) -denial = replace_once( - denial, - " disclose_identity_mapping(grant, mapping, decision_time, DECISION_DIGEST, sink),\n", - " disclose_identity_mapping(grant, mapping, decision_time, sink),\n", - "denial matrix disclosure call", -) -denial = replace_once( - denial, - " assert_eq!(record.decision_digest(), DECISION_DIGEST);\n", - " assert!(record.decision_digest().starts_with(\"sha256:\"));\n assert_eq!(record.decision_digest().len(), 71);\n", - "denial matrix digest assertion", -) -denial_path.write_text(denial, encoding="utf-8") - -research_path = Path("docs/research/provider-payload-minimization.md") -research = research_path.read_text(encoding="utf-8").rstrip() -research += """ - -## Re-identification audit digest authority - -The caller cannot provide or select the decision digest. TEPP computes a -versioned, length-delimited SHA-256 digest inside the trust boundary from the -purpose grant, protected mapping, decision instant, and allow/deny outcome. -Direct identity contributes to the digest but never appears in the redacted -audit record or ordinary logs. This makes append-only replay evidence bind the -actual governed decision rather than an arbitrary caller assertion. -""" -research_path.write_text(research.rstrip() + "\n", encoding="utf-8") From 7b015465ee6ea77bfe295b259fb09a0591154bbe Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 15:07:31 +0900 Subject: [PATCH 26/39] test(api): bind audit digest to grant decision inputs --- .../tests/reidentification_audit_contract.rs | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/crates/tepp_api/tests/reidentification_audit_contract.rs b/crates/tepp_api/tests/reidentification_audit_contract.rs index cfdc6ecf..741cc515 100644 --- a/crates/tepp_api/tests/reidentification_audit_contract.rs +++ b/crates/tepp_api/tests/reidentification_audit_contract.rs @@ -150,3 +150,50 @@ fn audit_digest_is_deterministic_and_binds_protected_mapping_content() { assert!(!first.decision_digest().contains("Pat")); assert!(!changed.decision_digest().contains("Different")); } + +#[test] +fn audit_digest_binds_principal_grant_window_and_decision_time() { + let mut baseline_sink = RecordingAuditSink::default(); + let (_, baseline) = disclose_identity_mapping( + &grant(true), + &mapping(), + "2026-06-15T12:00:00Z", + &mut baseline_sink, + ) + .expect("baseline disclosure"); + + let mut changed_principal_grant = grant(true); + changed_principal_grant.principal_id = "principal-reviewer".into(); + let mut changed_principal_sink = RecordingAuditSink::default(); + let (_, changed_principal) = disclose_identity_mapping( + &changed_principal_grant, + &mapping(), + "2026-06-15T12:00:00Z", + &mut changed_principal_sink, + ) + .expect("changed principal disclosure"); + + let mut changed_window_grant = grant(true); + changed_window_grant.valid_from = "2026-02-01T00:00:00Z".into(); + let mut changed_window_sink = RecordingAuditSink::default(); + let (_, changed_window) = disclose_identity_mapping( + &changed_window_grant, + &mapping(), + "2026-06-15T12:00:00Z", + &mut changed_window_sink, + ) + .expect("changed grant window disclosure"); + + let mut changed_time_sink = RecordingAuditSink::default(); + let (_, changed_time) = disclose_identity_mapping( + &grant(true), + &mapping(), + "2026-06-15T12:00:01Z", + &mut changed_time_sink, + ) + .expect("changed decision time disclosure"); + + for changed in [&changed_principal, &changed_window, &changed_time] { + assert_ne!(baseline.decision_digest(), changed.decision_digest()); + } +} From 3eb52a8b51865512fc2dd784cc761da288466244 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 20:56:33 +0900 Subject: [PATCH 27/39] fix(api): pin temporal_core version and cover assert object-ref branch cargo-deny wildcards reject unversioned path deps on tepp_api; pin temporal_core to 0.1.0. Exercise assert_source_artifact_matches_sql for both open and protected object refs so branch coverage hits the Some arm. --- crates/persistence_postgres/src/artifact_sql.rs | 6 ++++++ .../tests/source_artifact_sql_contract.rs | 7 +++++++ crates/tepp_api/Cargo.toml | 2 +- 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/crates/persistence_postgres/src/artifact_sql.rs b/crates/persistence_postgres/src/artifact_sql.rs index d654d183..dbcce9ec 100644 --- a/crates/persistence_postgres/src/artifact_sql.rs +++ b/crates/persistence_postgres/src/artifact_sql.rs @@ -222,10 +222,16 @@ mod tests { with_ref.source_size_bytes = 0; let referenced = insert_source_artifact_sql(&with_ref).expect("ref"); assert!(referenced.contains("s3://tepp/object")); +<<<<<<< HEAD let referenced_assertion = assert_source_artifact_matches_sql(&with_ref).expect("referenced assertion"); assert!(referenced_assertion.contains("s3://tepp/object")); assert!(referenced_assertion.contains("IS NOT DISTINCT FROM 's3://tepp/object'")); +======= + let assert_ref = assert_source_artifact_matches_sql(&with_ref).expect("assert-ref"); + assert!(assert_ref.contains("s3://tepp/object")); + assert!(assert_ref.contains("protected_object_ref IS NOT DISTINCT FROM")); +>>>>>>> 84e9421 (fix(api): pin temporal_core version and cover assert object-ref branch) assert_eq!( insert_source_artifact_sql(&SourceArtifactRecord { diff --git a/crates/persistence_postgres/tests/source_artifact_sql_contract.rs b/crates/persistence_postgres/tests/source_artifact_sql_contract.rs index fae0effd..db87a044 100644 --- a/crates/persistence_postgres/tests/source_artifact_sql_contract.rs +++ b/crates/persistence_postgres/tests/source_artifact_sql_contract.rs @@ -177,4 +177,11 @@ fn assert_sql_requires_every_stored_field_to_match() { assert!(sql.contains("protected_object_ref IS NOT DISTINCT FROM")); assert!(sql.contains("system_time")); assert!(sql.contains("available_time")); + assert!(sql.contains("NULL")); + + let mut with_ref = artifact(); + with_ref.protected_object_ref = Some("s3://tepp/evidence/object".into()); + let referenced = assert_source_artifact_matches_sql(&with_ref).expect("assert-ref"); + assert!(referenced.contains("s3://tepp/evidence/object")); + assert!(referenced.contains("protected_object_ref IS NOT DISTINCT FROM")); } diff --git a/crates/tepp_api/Cargo.toml b/crates/tepp_api/Cargo.toml index f51834c9..027f975e 100644 --- a/crates/tepp_api/Cargo.toml +++ b/crates/tepp_api/Cargo.toml @@ -17,7 +17,7 @@ publish = false serde = { workspace = true } serde_json = { workspace = true } sha2 = { workspace = true } -temporal_core = { path = "../temporal_core" } +temporal_core = { path = "../temporal_core", version = "0.1.0" } [lints] workspace = true From a54300016a492b37ecf0d7d3188f19e50993a380 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 21:47:37 +0900 Subject: [PATCH 28/39] ci: re-trigger exact-head checks for PR #46 after thrash cancel From 4379a0a1475900fde880e353f5c1f3b80ea5b69e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 00:52:25 +0900 Subject: [PATCH 29/39] ci: re-trigger exact-head checks for PR #46 after cancel thrash From 4368a7f5bb7d56a30dc4ec0ee84384df6ad1397f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 16:00:02 +0000 Subject: [PATCH 30/39] test(api): lock FIPS 180-4 reidentification digest vector Cite FIPS PUB 180-4 as the SHA-256 authority, publish the v1 length-delimited encoding and fixture digest, isolate outcome in the hasher, and audit remaining purpose-denial paths. Co-authored-by: Seongho Bae --- CHANGELOG.md | 4 +++ crates/tepp_api/src/provider_payload.rs | 28 +++++++++++++++-- .../tests/reidentification_audit_contract.rs | 7 +++-- .../reidentification_audit_denial_matrix.rs | 10 +++++- .../research/provider-payload-minimization.md | 31 ++++++++++++++----- docs/research/standards-and-literature.md | 2 +- .../task-12-versioned-api-contracts.md | 2 ++ 7 files changed, 69 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index de035a10..6b92b80c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,12 @@ All notable changes to TEPP are documented here. The format follows Keep a Chang ### Added +<<<<<<< HEAD - `tepp_api` purpose-bound provider-payload minimization: time-bounded `PurposeGrant` evaluation, fail-closed expired/not-yet-valid and cross-tenant 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), and a separately authorized scientific re-identification path. - `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` 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. +>>>>>>> d03340a (test(api): lock FIPS 180-4 reidentification digest vector) - `persistence_postgres` backup/restore integrity: restored snapshots stay unusable until tenant, canonical `SHA-256`, knowledge-cutoff eligibility, temporal window order, and append-only triggers revalidate; SQL probes raise `restore integrity failed` (ADR 0013). - `persistence_postgres` concurrent document-write stress: atomic revise `DO` block that requires exactly one open `system_to` close, SQLSTATE mapping onto `ConcurrentWriteConflict` / `DuplicateDocumentRecord`, and live multi-session insert/revise/append-only proofs. No new migration number. - `tepp_api` naruon HTTP interchange: versioned `https` POST contracts for analysis-run create and modular export authorization that refuse table-access URLs, review/Copilot credential headers, reserved standard-header redefinition, principal-only export idempotency keys, and lexical inference claims (ADR 0011). diff --git a/crates/tepp_api/src/provider_payload.rs b/crates/tepp_api/src/provider_payload.rs index 88c02d42..8f365923 100644 --- a/crates/tepp_api/src/provider_payload.rs +++ b/crates/tepp_api/src/provider_payload.rs @@ -495,10 +495,10 @@ fn is_rfc3339_utc(value: &str) -> bool { mod tests { use super::{ DisclosedIdentityMapping, IdentityMappingRecord, MinimizedProviderPayload, - ProviderDisclosureLog, ProviderEvidenceOffer, PurposeGrant, ReidentificationAuditRecord, - ReidentificationAuditSink, + ProviderDisclosureLog, ProviderEvidenceOffer, PurposeGrant, ReidentificationAuditOutcome, + ReidentificationAuditRecord, ReidentificationAuditSink, disclose_identity_mapping as disclose_identity_mapping_with_audit, is_rfc3339_utc, - minimize_provider_payload, + minimize_provider_payload, reidentification_decision_digest, }; use crate::ApiError; use crate::authorization::AnalyticalPurpose; @@ -773,4 +773,26 @@ mod tests { }; assert!(!log.included_identity_mapping()); } + + #[test] + fn audit_digest_binds_outcome_when_other_canonical_fields_are_held_fixed() { + let allowed = reidentification_decision_digest( + &grant(AnalyticalPurpose::ScientificValidation, true), + &mapping(), + "2026-06-15T12:00:00Z", + ReidentificationAuditOutcome::Allowed, + ) + .expect("allowed digest"); + let denied = reidentification_decision_digest( + &grant(AnalyticalPurpose::ScientificValidation, true), + &mapping(), + "2026-06-15T12:00:00Z", + ReidentificationAuditOutcome::Denied, + ) + .expect("denied digest"); + assert_ne!( + allowed, denied, + "outcome wire name must change the digest when grant, mapping, and time stay fixed" + ); + } } diff --git a/crates/tepp_api/tests/reidentification_audit_contract.rs b/crates/tepp_api/tests/reidentification_audit_contract.rs index 741cc515..e873d58d 100644 --- a/crates/tepp_api/tests/reidentification_audit_contract.rs +++ b/crates/tepp_api/tests/reidentification_audit_contract.rs @@ -63,8 +63,11 @@ fn successful_reidentification_appends_redacted_audit_evidence_before_disclosure assert_eq!(audit.decision_time(), "2026-06-15T12:00:00Z"); assert_eq!(audit.outcome(), ReidentificationAuditOutcome::Allowed); assert_eq!(audit.outcome().wire_name(), "allowed"); - assert!(audit.decision_digest().starts_with("sha256:")); - assert_eq!(audit.decision_digest().len(), 71); + assert_eq!( + audit.decision_digest(), + "sha256:1a3b774ae989b971cd6ba7f4a38697e94a532ce29cff7c0a8e0d8d2a73f45ded", + "published FIPS 180-4 SHA-256 test vector for the v1 length-delimited decision encoding" + ); assert!(!format!("{audit:?}").contains("Pat Lee")); assert!(!format!("{audit:?}").contains("pat.lee@example.test")); } diff --git a/crates/tepp_api/tests/reidentification_audit_denial_matrix.rs b/crates/tepp_api/tests/reidentification_audit_denial_matrix.rs index 2feba7ee..63d5e852 100644 --- a/crates/tepp_api/tests/reidentification_audit_denial_matrix.rs +++ b/crates/tepp_api/tests/reidentification_audit_denial_matrix.rs @@ -94,7 +94,15 @@ fn all_well_formed_denial_paths_append_replayable_redacted_records() { &mut sink, ); - assert_eq!(sink.records.len(), 5); + let mut operational = grant(); + operational.purpose = AnalyticalPurpose::OperationalMonitoring; + assert_denied_and_audited(&operational, &mapping(), "2026-06-15T12:00:00Z", &mut sink); + + let mut modular = grant(); + modular.purpose = AnalyticalPurpose::ModularServiceConsumer; + assert_denied_and_audited(&modular, &mapping(), "2026-06-15T12:00:00Z", &mut sink); + + assert_eq!(sink.records.len(), 7); assert!( sink.records .iter() diff --git a/docs/research/provider-payload-minimization.md b/docs/research/provider-payload-minimization.md index c3564446..2a84e646 100644 --- a/docs/research/provider-payload-minimization.md +++ b/docs/research/provider-payload-minimization.md @@ -18,26 +18,41 @@ ISO/IEC. (2025). *ISO/IEC 27701:2025 Information security, cybersecurity and pri National Institute of Standards and Technology. (2020). *NIST Privacy Framework: A tool for improving privacy through enterprise risk management* (Version 1.0). U.S. Department of Commerce. https://doi.org/10.6028/NIST.CSWP.01162020 +National Institute of Standards and Technology. (2015). *Secure Hash Standard (SHS)* (FIPS PUB 180-4). https://doi.org/10.6028/NIST.FIPS.180-4 + ISO/IEC. (2019). *ISO/IEC 27701:2019 Security techniques — Extension to ISO/IEC 27001 and ISO/IEC 27002 for privacy information management — Requirements and guidelines*. International Organization for Standardization. ## Application -ISO/IEC 27701:2025 is the current standalone Privacy Information Management System standard and is cited for purpose limitation and disclosure minimization (ISO/IEC, 2025). The 2019 edition remains recorded because earlier TEPP doctoring referenced it as an extension to ISO/IEC 27001 (ISO/IEC, 2019). The NIST Privacy Framework supplies the Core functions (Identify-P, Control-P, Communicate-P) used to separate provider disclosure from re-identification and to keep logs free of source bodies (National Institute of Standards and Technology, 2020). These citations are readiness mappings, not certification or legal sufficiency. +ISO/IEC 27701:2025 is the current standalone Privacy Information Management System standard and is cited for purpose limitation and disclosure minimization (ISO/IEC, 2025). The 2019 edition remains recorded because earlier TEPP doctoring referenced it as an extension to ISO/IEC 27001 (ISO/IEC, 2019). The NIST Privacy Framework supplies the Core functions (Identify-P, Control-P, Communicate-P) used to separate provider disclosure from re-identification and to keep logs free of source bodies (National Institute of Standards and Technology, 2020). FIPS PUB 180-4 is the primary source for the SHA-256 function used to bind re-identification audit evidence (National Institute of Standards and Technology, 2015). These citations are readiness mappings, not certification or legal sufficiency. A keyed HMAC commitment and key-rotation port remain an ADR 0009 persistence follow-on; this adapter does not introduce a key-management surface. ## Verification - scientific payloads retain opaque IDs, roles, and authorized source text; - operational/partner source-text offers are denied; -- expired, not-yet-valid, inverted, and cross-tenant grants fail closed; +- expired, not-yet-valid, inverted, cross-tenant, and impossible-calendar grants fail closed; - attached identity mappings are refused on the provider path; -- elevated scientific re-identification returns the mapping; other purposes and missing flags are denied; -- disclosure logs never contain source text or mapping strings. +- elevated scientific re-identification returns the mapping; other purposes and missing flags are denied and audited; +- disclosure logs never contain source text or mapping strings; +- the published SHA-256 test vector matches the v1 length-delimited encoding. ## Re-identification audit digest authority The caller cannot provide or select the decision digest. TEPP computes a versioned, length-delimited SHA-256 digest inside the trust boundary from the -purpose grant, protected mapping, decision instant, and allow/deny outcome. -Direct identity contributes to the digest but never appears in the redacted -audit record or ordinary logs. This makes append-only replay evidence bind the -actual governed decision rather than an arbitrary caller assertion. +purpose grant, protected mapping, decision instant, and allow/deny outcome +(National Institute of Standards and Technology, 2015). Direct identity +contributes to the digest but never appears in the redacted audit record or +ordinary logs. This makes append-only replay evidence bind the actual governed +decision rather than an arbitrary caller assertion. + +Serialization for `tepp.reidentification.audit.v1` is UTF-8 field bytes, each +prefixed by its `u64` big-endian length. Empty strings are encoded as length +`0` with no payload. The field order is version, action code +`reidentify_identity_mapping`, grant tenant, principal, purpose wire name, +`valid_from`, `valid_to` presence (`1` or `0`), `valid_to` text or empty, +re-identification flag (`1` or `0`), mapping tenant, opaque analytical id, +direct identity, decision instant, and outcome wire name (`allowed` or +`denied`). The published vector for the scientific-validation fixture in +`reidentification_audit_contract.rs` is +`sha256:1a3b774ae989b971cd6ba7f4a38697e94a532ce29cff7c0a8e0d8d2a73f45ded`. diff --git a/docs/research/standards-and-literature.md b/docs/research/standards-and-literature.md index cc5b1a92..6e0438fe 100644 --- a/docs/research/standards-and-literature.md +++ b/docs/research/standards-and-literature.md @@ -130,7 +130,7 @@ ISO/IEC. (2019). *ISO/IEC 27701:2019 Security techniques — Extension to ISO/IE National Institute of Standards and Technology. (2020). *NIST Privacy Framework: A tool for improving privacy through enterprise risk management* (Version 1.0). U.S. Department of Commerce. https://doi.org/10.6028/NIST.CSWP.01162020 -TEPP applies ISO/IEC 27701:2025 purpose limitation and disclosure minimization, and the NIST Privacy Framework Control-P / Communicate-P functions, to provider payloads and separately authorized re-identification (ISO/IEC, 2025; National Institute of Standards and Technology, 2020). The 2019 edition is retained for earlier doctoring that treated PIMS as an ISO/IEC 27001 extension (ISO/IEC, 2019). These sources are readiness mappings, not certification. +TEPP applies ISO/IEC 27701:2025 purpose limitation and disclosure minimization, and the NIST Privacy Framework Control-P / Communicate-P functions, to provider payloads and separately authorized re-identification (ISO/IEC, 2025; National Institute of Standards and Technology, 2020). The 2019 edition is retained for earlier doctoring that treated PIMS as an ISO/IEC 27001 extension (ISO/IEC, 2019). Re-identification audit evidence is bound with FIPS 180-4 SHA-256 over a length-delimited canonical encoding (National Institute of Standards and Technology, 2015). These sources are readiness mappings, not certification. TEPP uses these sources as management/risk/readiness inputs, not as self-certification authority. ISO/IEC 42001:2023 and ISO/IEC 23894:2023 are published international standards (International Organization for Standardization, 2023a, 2023b). NIST AI RMF 1.0 remains the published framework while NIST is preparing a revision (Tabassi, 2023; National Institute of Standards and Technology, n.d.); the repository tracks the revision but does not silently treat an unpublished successor as normative. AICPA Trust Services Criteria are readiness inputs rather than self-issued attestation (American Institute of Certified Public Accountants, 2023). KISA currently describes CSAP service types as IaaS, SaaS, and DaaS and grades as high, medium, and low, while noting that the high and medium grades await later implementation (한국인터넷진흥원, n.d.). CSAP and SOC 2 evidence depend on actual deployment/organization controls and independent assessment. diff --git a/docs/research/task-12-versioned-api-contracts.md b/docs/research/task-12-versioned-api-contracts.md index 0344f1a5..ecc175bd 100644 --- a/docs/research/task-12-versioned-api-contracts.md +++ b/docs/research/task-12-versioned-api-contracts.md @@ -33,6 +33,8 @@ ISO/IEC. (2019). *ISO/IEC 27701:2019 Security techniques — Extension to ISO/IE National Institute of Standards and Technology. (2020). *NIST Privacy Framework: A tool for improving privacy through enterprise risk management* (Version 1.0). U.S. Department of Commerce. https://doi.org/10.6028/NIST.CSWP.01162020 +National Institute of Standards and Technology. (2015). *Secure Hash Standard (SHS)* (FIPS PUB 180-4). https://doi.org/10.6028/NIST.FIPS.180-4 + ## Verification - unit tests for unknown fields, unsupported versions, empty identities, byte limits, GraphML escaping, example payload parsing, expired, not-yet-valid, inverted, cross-tenant, and impossible-calendar grant denial; provider mapping refusal; and audited elevated re-identification replay; From 8af5340507a7f56245f2898236c7e8608e1ff756 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 08:25:07 +0900 Subject: [PATCH 31/39] fix(api): clear rebase conflict markers after main restack Keep the FIPS 180-4 provider-payload changelog entry and retention 0007 bullet, and preserve both object-ref assertion checks on source-artifact match SQL after rebasing #46 onto main tip #45. --- CHANGELOG.md | 6 +----- crates/persistence_postgres/src/artifact_sql.rs | 7 +------ 2 files changed, 2 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b92b80c..3fe6d08d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,12 +6,8 @@ All notable changes to TEPP are documented here. The format follows Keep a Chang ### Added -<<<<<<< HEAD -- `tepp_api` purpose-bound provider-payload minimization: time-bounded `PurposeGrant` evaluation, fail-closed expired/not-yet-valid and cross-tenant 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), and a separately authorized scientific re-identification path. -- `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` 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. ->>>>>>> d03340a (test(api): lock FIPS 180-4 reidentification digest vector) +- `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. - `persistence_postgres` backup/restore integrity: restored snapshots stay unusable until tenant, canonical `SHA-256`, knowledge-cutoff eligibility, temporal window order, and append-only triggers revalidate; SQL probes raise `restore integrity failed` (ADR 0013). - `persistence_postgres` concurrent document-write stress: atomic revise `DO` block that requires exactly one open `system_to` close, SQLSTATE mapping onto `ConcurrentWriteConflict` / `DuplicateDocumentRecord`, and live multi-session insert/revise/append-only proofs. No new migration number. - `tepp_api` naruon HTTP interchange: versioned `https` POST contracts for analysis-run create and modular export authorization that refuse table-access URLs, review/Copilot credential headers, reserved standard-header redefinition, principal-only export idempotency keys, and lexical inference claims (ADR 0011). diff --git a/crates/persistence_postgres/src/artifact_sql.rs b/crates/persistence_postgres/src/artifact_sql.rs index dbcce9ec..9f997a73 100644 --- a/crates/persistence_postgres/src/artifact_sql.rs +++ b/crates/persistence_postgres/src/artifact_sql.rs @@ -222,16 +222,11 @@ mod tests { with_ref.source_size_bytes = 0; let referenced = insert_source_artifact_sql(&with_ref).expect("ref"); assert!(referenced.contains("s3://tepp/object")); -<<<<<<< HEAD let referenced_assertion = assert_source_artifact_matches_sql(&with_ref).expect("referenced assertion"); assert!(referenced_assertion.contains("s3://tepp/object")); assert!(referenced_assertion.contains("IS NOT DISTINCT FROM 's3://tepp/object'")); -======= - let assert_ref = assert_source_artifact_matches_sql(&with_ref).expect("assert-ref"); - assert!(assert_ref.contains("s3://tepp/object")); - assert!(assert_ref.contains("protected_object_ref IS NOT DISTINCT FROM")); ->>>>>>> 84e9421 (fix(api): pin temporal_core version and cover assert object-ref branch) + assert!(referenced_assertion.contains("protected_object_ref IS NOT DISTINCT FROM")); assert_eq!( insert_source_artifact_sql(&SourceArtifactRecord { From cb68f22a0dffff74ea64396e00189a55b336e4b5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 08:33:48 +0900 Subject: [PATCH 32/39] test(api): cover provider-payload branch edges for 100% branch CI Add unit coverage for impossible-calendar RFC 3339 rejection, open-ended grant audit digests, failing re-identification audit sinks, and audit record getters so nightly branch coverage can clear 938/946. --- crates/tepp_api/src/provider_payload.rs | 123 ++++++++++++++++++++++++ 1 file changed, 123 insertions(+) diff --git a/crates/tepp_api/src/provider_payload.rs b/crates/tepp_api/src/provider_payload.rs index 8f365923..c742b0d5 100644 --- a/crates/tepp_api/src/provider_payload.rs +++ b/crates/tepp_api/src/provider_payload.rs @@ -795,4 +795,127 @@ mod tests { "outcome wire name must change the digest when grant, mapping, and time stay fixed" ); } + + #[test] + fn rfc3339_rejects_impossible_calendar_and_malformed_separators() { + // Fixed-width UTC shape but impossible calendar → TemporalInstant fails closed. + assert!(!is_rfc3339_utc("2026-02-30T00:00:00Z")); + assert!(!is_rfc3339_utc("2026-13-01T00:00:00Z")); + assert!(!is_rfc3339_utc("2026-00-01T00:00:00Z")); + assert!(!is_rfc3339_utc("2026-01-00T00:00:00Z")); + assert!(!is_rfc3339_utc("2026-01-32T00:00:00Z")); + assert!(!is_rfc3339_utc("2026-01-01T24:00:00Z")); + assert!(!is_rfc3339_utc("2026-01-01T00:60:00Z")); + assert!(!is_rfc3339_utc("2026-01-01T00:00:60Z")); + // Separator / terminator failures that still keep length 20. + assert!(!is_rfc3339_utc("2026/01/01T00:00:00Z")); + assert!(!is_rfc3339_utc("2026-01-01 00:00:00Z")); + assert!(!is_rfc3339_utc("2026-01-01T00-00-00Z")); + assert!(!is_rfc3339_utc("2026-01-01T00:00:00z")); + assert!(!is_rfc3339_utc("2026-01-01T00:00:0aZ")); + assert_eq!( + minimize_provider_payload( + &grant(AnalyticalPurpose::ScientificValidation, false), + &offer(None), + "2026-02-30T00:00:00Z", + ), + Err(ApiError::InvalidWirePayload) + ); + let bad_from = PurposeGrant { + valid_from: "2026-02-30T00:00:00Z".into(), + ..grant(AnalyticalPurpose::ScientificValidation, false) + }; + assert_eq!( + minimize_provider_payload(&bad_from, &offer(None), "2026-06-15T12:00:00Z"), + Err(ApiError::InvalidWirePayload) + ); + let bad_to = PurposeGrant { + valid_to: Some("2026-13-01T00:00:00Z".into()), + ..grant(AnalyticalPurpose::ScientificValidation, false) + }; + assert_eq!( + minimize_provider_payload(&bad_to, &offer(None), "2026-06-15T12:00:00Z"), + Err(ApiError::InvalidWirePayload) + ); + } + + #[test] + fn digest_and_audit_cover_open_ended_grant_and_sink_failure() { + let open = PurposeGrant { + valid_to: None, + reidentification_authorized: true, + ..grant(AnalyticalPurpose::ScientificValidation, true) + }; + let open_digest = reidentification_decision_digest( + &open, + &mapping(), + "2026-06-15T12:00:00Z", + ReidentificationAuditOutcome::Allowed, + ) + .expect("open digest"); + let closed_digest = reidentification_decision_digest( + &grant(AnalyticalPurpose::ScientificValidation, true), + &mapping(), + "2026-06-15T12:00:00Z", + ReidentificationAuditOutcome::Allowed, + ) + .expect("closed digest"); + assert_ne!( + open_digest, closed_digest, + "open-ended valid_to must change the canonical audit digest" + ); + let unauthorized = reidentification_decision_digest( + &grant(AnalyticalPurpose::ScientificValidation, false), + &mapping(), + "2026-06-15T12:00:00Z", + ReidentificationAuditOutcome::Denied, + ) + .expect("unauthorized digest"); + assert_ne!(closed_digest, unauthorized); + + struct FailingSink; + impl ReidentificationAuditSink for FailingSink { + fn append_reidentification_audit( + &mut self, + _record: &ReidentificationAuditRecord, + ) -> Result<(), ApiError> { + Err(ApiError::AuthorizationDenied) + } + } + let mut sink = FailingSink; + assert_eq!( + disclose_identity_mapping_with_audit( + &grant(AnalyticalPurpose::ScientificValidation, true), + &mapping(), + "2026-06-15T12:00:00Z", + &mut sink, + ), + Err(ApiError::AuthorizationDenied) + ); + + let mut sink = RecordingAuditSink::default(); + let (disclosed, audit) = disclose_identity_mapping_with_audit( + &grant(AnalyticalPurpose::ScientificValidation, true), + &mapping(), + "2026-06-15T12:00:00Z", + &mut sink, + ) + .expect("allowed with audit"); + assert_eq!(disclosed.direct_identity(), "Pat Lee"); + assert_eq!(audit.tenant_workspace_id(), "tenant-ws-1"); + assert_eq!(audit.principal_id(), "principal-analyst-1"); + assert_eq!(audit.purpose_wire_name(), "scientific_validation"); + assert_eq!(audit.action_code(), "reidentify_identity_mapping"); + assert_eq!(audit.opaque_analytical_id(), "entity-1"); + assert_eq!(audit.decision_time(), "2026-06-15T12:00:00Z"); + assert_eq!(audit.outcome(), ReidentificationAuditOutcome::Allowed); + assert!(audit.decision_digest().starts_with("sha256:")); + assert_eq!(audit.outcome().wire_name(), "allowed"); + assert_eq!( + ReidentificationAuditOutcome::Denied.wire_name(), + "denied" + ); + assert_eq!(sink.records.len(), 1); + assert_eq!(sink.records[0].decision_digest(), audit.decision_digest()); + } } From 7bfc63f214237d15e02eb4d8e527dc6f957d0552 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 08:51:43 +0900 Subject: [PATCH 33/39] fix(api): cover minimize denial and RFC3339 false edges for branch CI Fix rustfmt on audit outcome wire-name assert, hoist FailingSink for clippy item order, and hit minimize grant/tenant/mapping denial arms plus length-20 separator false branches in is_rfc3339_utc. --- crates/tepp_api/src/provider_payload.rs | 72 ++++++++++++++++++++----- 1 file changed, 59 insertions(+), 13 deletions(-) diff --git a/crates/tepp_api/src/provider_payload.rs b/crates/tepp_api/src/provider_payload.rs index c742b0d5..bcd27f5c 100644 --- a/crates/tepp_api/src/provider_payload.rs +++ b/crates/tepp_api/src/provider_payload.rs @@ -548,6 +548,16 @@ mod tests { } } + struct FailingSink; + impl ReidentificationAuditSink for FailingSink { + fn append_reidentification_audit( + &mut self, + _record: &ReidentificationAuditRecord, + ) -> Result<(), ApiError> { + Err(ApiError::AuthorizationDenied) + } + } + fn disclose( grant: &PurposeGrant, mapping: &IdentityMappingRecord, @@ -569,6 +579,12 @@ mod tests { assert!(!is_rfc3339_utc("2026-01-01Txx:00:00Z")); assert!(!is_rfc3339_utc("2026-01-01T00:xx:00Z")); assert!(!is_rfc3339_utc("2026-01-01T00:00:xxZ")); + assert!(!is_rfc3339_utc("2026X01-01T00:00:00Z")); + assert!(!is_rfc3339_utc("2026-01X01T00:00:00Z")); + assert!(!is_rfc3339_utc("2026-01-01X00:00:00Z")); + assert!(!is_rfc3339_utc("2026-01-01T00X00:00Z")); + assert!(!is_rfc3339_utc("2026-01-01T00:00X00Z")); + assert!(!is_rfc3339_utc("2026-01-01T00:00:00X")); let at_start = minimize_provider_payload( &grant(AnalyticalPurpose::ScientificValidation, false), @@ -590,6 +606,48 @@ mod tests { ); } + #[test] + fn minimize_denies_expired_foreign_and_identity_mapping_offers() { + let expired = PurposeGrant { + valid_to: Some("2026-03-01T00:00:00Z".into()), + ..grant(AnalyticalPurpose::ScientificValidation, false) + }; + assert_eq!( + minimize_provider_payload(&expired, &offer(None), "2026-06-15T12:00:00Z"), + Err(ApiError::AuthorizationDenied) + ); + let not_yet = PurposeGrant { + valid_from: "2026-07-01T00:00:00Z".into(), + ..grant(AnalyticalPurpose::ScientificValidation, false) + }; + assert_eq!( + minimize_provider_payload(¬_yet, &offer(None), "2026-06-15T12:00:00Z"), + Err(ApiError::AuthorizationDenied) + ); + let foreign = ProviderEvidenceOffer { + tenant_workspace_id: "other-tenant".into(), + ..offer(None) + }; + assert_eq!( + minimize_provider_payload( + &grant(AnalyticalPurpose::ScientificValidation, false), + &foreign, + "2026-06-15T12:00:00Z", + ), + Err(ApiError::AuthorizationDenied) + ); + let mut mapped = offer(None); + mapped.identity_mapping = Some("secret-name".into()); + assert_eq!( + minimize_provider_payload( + &grant(AnalyticalPurpose::ScientificValidation, false), + &mapped, + "2026-06-15T12:00:00Z", + ), + Err(ApiError::AuthorizationDenied) + ); + } + #[test] fn partner_and_ops_follow_export_source_rules() { assert_eq!( @@ -873,15 +931,6 @@ mod tests { .expect("unauthorized digest"); assert_ne!(closed_digest, unauthorized); - struct FailingSink; - impl ReidentificationAuditSink for FailingSink { - fn append_reidentification_audit( - &mut self, - _record: &ReidentificationAuditRecord, - ) -> Result<(), ApiError> { - Err(ApiError::AuthorizationDenied) - } - } let mut sink = FailingSink; assert_eq!( disclose_identity_mapping_with_audit( @@ -911,10 +960,7 @@ mod tests { assert_eq!(audit.outcome(), ReidentificationAuditOutcome::Allowed); assert!(audit.decision_digest().starts_with("sha256:")); assert_eq!(audit.outcome().wire_name(), "allowed"); - assert_eq!( - ReidentificationAuditOutcome::Denied.wire_name(), - "denied" - ); + assert_eq!(ReidentificationAuditOutcome::Denied.wire_name(), "denied"); assert_eq!(sink.records.len(), 1); assert_eq!(sink.records[0].decision_digest(), audit.decision_digest()); } From 30a9581ac1e9b518ccce32883a212e4068a018b3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 08:55:28 +0900 Subject: [PATCH 34/39] fix(api): simplify RFC3339 UTC gate to close remaining branch gap Drop the long short-circuit `&&` chain in `is_rfc3339_utc` in favor of length + trailing `Z` checks with `TemporalInstant` calendar validation, which removes unstable branch edges under the nightly 100% gate. --- crates/tepp_api/src/provider_payload.rs | 25 ++++++++++--------------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/crates/tepp_api/src/provider_payload.rs b/crates/tepp_api/src/provider_payload.rs index bcd27f5c..f2f49019 100644 --- a/crates/tepp_api/src/provider_payload.rs +++ b/crates/tepp_api/src/provider_payload.rs @@ -474,21 +474,16 @@ fn require_rfc3339_utc(value: &str) -> Result<(), ApiError> { } fn is_rfc3339_utc(value: &str) -> bool { - let bytes = value.as_bytes(); - bytes.len() == 20 - && bytes[4] == b'-' - && bytes[7] == b'-' - && bytes[10] == b'T' - && bytes[13] == b':' - && bytes[16] == b':' - && bytes[19] == b'Z' - && bytes[0..4].iter().all(u8::is_ascii_digit) - && bytes[5..7].iter().all(u8::is_ascii_digit) - && bytes[8..10].iter().all(u8::is_ascii_digit) - && bytes[11..13].iter().all(u8::is_ascii_digit) - && bytes[14..16].iter().all(u8::is_ascii_digit) - && bytes[17..19].iter().all(u8::is_ascii_digit) - && TemporalInstant::parse_rfc3339(value).is_ok() + // Exact second-resolution UTC (`YYYY-MM-DDTHH:MM:SSZ`). Calendar/syntax is + // owned by `TemporalInstant`; keep length + trailing `Z` only so offsets and + // fractional seconds stay out of grants without long `&&` branch chains. + if value.len() != 20 { + return false; + } + if value.as_bytes()[19] != b'Z' { + return false; + } + TemporalInstant::parse_rfc3339(value).is_ok() } #[cfg(test)] From 09df3078e36d4c7b153c5af9c4cabc79919a11d4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 08:55:53 +0900 Subject: [PATCH 35/39] fix(api): drop unreachable audit-digest length Err arm `reidentification_decision_digest` is total for in-memory grant fields; remove `u64::try_from`/`LimitExceeded` so nightly branch coverage no longer counts a platform-impossible failure edge. --- crates/tepp_api/src/provider_payload.rs | 46 ++++++++++++------------- 1 file changed, 22 insertions(+), 24 deletions(-) diff --git a/crates/tepp_api/src/provider_payload.rs b/crates/tepp_api/src/provider_payload.rs index f2f49019..646e398c 100644 --- a/crates/tepp_api/src/provider_payload.rs +++ b/crates/tepp_api/src/provider_payload.rs @@ -374,7 +374,7 @@ pub fn disclose_identity_mapping( opaque_analytical_id: mapping.opaque_analytical_id.clone(), decision_time: decision_time.into(), outcome, - decision_digest: reidentification_decision_digest(grant, mapping, decision_time, outcome)?, + decision_digest: reidentification_decision_digest(grant, mapping, decision_time, outcome), }; audit_sink.append_reidentification_audit(&audit_record)?; if !allowed { @@ -396,8 +396,15 @@ fn reidentification_decision_digest( mapping: &IdentityMappingRecord, decision_time: &str, outcome: ReidentificationAuditOutcome, -) -> Result { +) -> String { let mut hasher = Sha256::new(); + let has_until = if grant.valid_to.is_some() { "1" } else { "0" }; + let until = grant.valid_to.as_deref().unwrap_or(""); + let reidentify_flag = if grant.reidentification_authorized { + "1" + } else { + "0" + }; for value in [ REIDENTIFICATION_AUDIT_DIGEST_VERSION, "reidentify_identity_mapping", @@ -405,29 +412,25 @@ fn reidentification_decision_digest( &grant.principal_id, grant.purpose.wire_name(), &grant.valid_from, - if grant.valid_to.is_some() { "1" } else { "0" }, - grant.valid_to.as_deref().unwrap_or(""), - if grant.reidentification_authorized { - "1" - } else { - "0" - }, + has_until, + until, + reidentify_flag, &mapping.tenant_workspace_id, &mapping.opaque_analytical_id, &mapping.direct_identity, decision_time, outcome.wire_name(), ] { - update_audit_digest_field(&mut hasher, value)?; + update_audit_digest_field(&mut hasher, value); } - Ok(format!("sha256:{:x}", hasher.finalize())) + format!("sha256:{:x}", hasher.finalize()) } -fn update_audit_digest_field(hasher: &mut Sha256, value: &str) -> Result<(), ApiError> { - let length = u64::try_from(value.len()).map_err(|_| ApiError::LimitExceeded)?; +fn update_audit_digest_field(hasher: &mut Sha256, value: &str) { + // `usize` fits in `u64` on all TEPP targets; avoid a dead `try_from` Err arm. + let length = value.len() as u64; hasher.update(length.to_be_bytes()); hasher.update(value.as_bytes()); - Ok(()) } // Lexicographic comparisons in `validate_grant` and `grant_covers` are valid @@ -834,15 +837,13 @@ mod tests { &mapping(), "2026-06-15T12:00:00Z", ReidentificationAuditOutcome::Allowed, - ) - .expect("allowed digest"); + ); let denied = reidentification_decision_digest( &grant(AnalyticalPurpose::ScientificValidation, true), &mapping(), "2026-06-15T12:00:00Z", ReidentificationAuditOutcome::Denied, - ) - .expect("denied digest"); + ); assert_ne!( allowed, denied, "outcome wire name must change the digest when grant, mapping, and time stay fixed" @@ -904,15 +905,13 @@ mod tests { &mapping(), "2026-06-15T12:00:00Z", ReidentificationAuditOutcome::Allowed, - ) - .expect("open digest"); + ); let closed_digest = reidentification_decision_digest( &grant(AnalyticalPurpose::ScientificValidation, true), &mapping(), "2026-06-15T12:00:00Z", ReidentificationAuditOutcome::Allowed, - ) - .expect("closed digest"); + ); assert_ne!( open_digest, closed_digest, "open-ended valid_to must change the canonical audit digest" @@ -922,8 +921,7 @@ mod tests { &mapping(), "2026-06-15T12:00:00Z", ReidentificationAuditOutcome::Denied, - ) - .expect("unauthorized digest"); + ); assert_ne!(closed_digest, unauthorized); let mut sink = FailingSink; From 51b15b67468339eefcbd1a2a89f17efdf020f812 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 08:57:21 +0900 Subject: [PATCH 36/39] test(api): cover open-ended grant and reidentify-false branch arms Exercise reidentification_authorized=false after cover/tenant/purpose hold, open-ended valid_to through minimize/disclose, payload getters, and FailingSink deny mono so pinned-nightly branch coverage reaches 100% on provider_payload. --- crates/tepp_api/src/provider_payload.rs | 52 +++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/crates/tepp_api/src/provider_payload.rs b/crates/tepp_api/src/provider_payload.rs index 646e398c..5ab41a64 100644 --- a/crates/tepp_api/src/provider_payload.rs +++ b/crates/tepp_api/src/provider_payload.rs @@ -822,12 +822,64 @@ mod tests { membership_role: None, }; assert_eq!(payload.artifact_id(), "a"); + assert_eq!(payload.opaque_analytical_id(), "e"); + assert!(payload.membership_role().is_none()); let log = ProviderDisclosureLog { purpose: "scientific_validation".into(), included_source_text: false, included_identity_mapping: false, }; assert!(!log.included_identity_mapping()); + + // Short-circuit false arm of `reidentification_authorized` when cover, + // tenant, and scientific purpose already hold. + assert_eq!( + disclose( + &grant(AnalyticalPurpose::ScientificValidation, false), + &mapping(), + "2026-06-15T12:00:00Z", + ), + Err(ApiError::AuthorizationDenied) + ); + + // Open-ended grant (`valid_to: None`) exercises `if let Some` false arms + // in both `validate_grant` and `grant_covers`. + let open_ended = PurposeGrant { + valid_to: None, + ..grant(AnalyticalPurpose::ScientificValidation, false) + }; + let open_min = minimize_provider_payload(&open_ended, &offer(None), "2026-06-15T12:00:00Z") + .expect("open-ended minimize"); + assert_eq!(open_min.0.artifact_id(), "artifact-1"); + assert_eq!(open_min.0.opaque_analytical_id(), "entity-1"); + assert!(open_min.0.membership_role().is_none()); + + // Deny-path audit with open-ended grant and elevated flag false still + // appends before AuthorizationDenied (RecordingAuditSink mono). + assert_eq!( + disclose( + &PurposeGrant { + valid_to: None, + ..grant(AnalyticalPurpose::ScientificValidation, false) + }, + &mapping(), + "2026-06-15T12:00:00Z", + ), + Err(ApiError::AuthorizationDenied) + ); + + // FailingSink mono: exercise deny path so append runs with allowed=false + // (still fails at sink; keeps mono instantiations exercised). + let mut failing = FailingSink; + assert_eq!( + disclose_identity_mapping_with_audit( + &grant(AnalyticalPurpose::ScientificValidation, false), + &mapping(), + "2026-06-15T12:00:00Z", + &mut failing, + ), + Err(ApiError::AuthorizationDenied) + ); } #[test] From 835922ca7be5fc16bc7fe786eb09ab0ac9ca54a9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 09:00:04 +0900 Subject: [PATCH 37/39] fix(api): split oversized disclose fail-closed test for clippy Break `disclose_covers_remaining_fail_closed_branches` under the 100-line pedantic limit while preserving policy, open-ended grant, and accessor coverage for the provider-payload branch gate. --- crates/tepp_api/src/provider_payload.rs | 62 ++++++++++++------------- 1 file changed, 29 insertions(+), 33 deletions(-) diff --git a/crates/tepp_api/src/provider_payload.rs b/crates/tepp_api/src/provider_payload.rs index 5ab41a64..e0ebdd88 100644 --- a/crates/tepp_api/src/provider_payload.rs +++ b/crates/tepp_api/src/provider_payload.rs @@ -733,7 +733,7 @@ mod tests { } #[test] - fn disclose_covers_remaining_fail_closed_branches() { + fn disclose_policy_denials_and_invalid_mapping_fields() { assert_eq!( disclose( &grant(AnalyticalPurpose::PartnerDisclosure, true), @@ -809,30 +809,10 @@ mod tests { ), Err(ApiError::InvalidWirePayload) ); - let disclosed = DisclosedIdentityMapping { - opaque_analytical_id: "entity-1".into(), - direct_identity: "Pat Lee".into(), - }; - assert_eq!(disclosed.opaque_analytical_id(), "entity-1"); - assert_eq!(disclosed.direct_identity(), "Pat Lee"); - let payload = MinimizedProviderPayload { - artifact_id: "a".into(), - opaque_analytical_id: "e".into(), - source_text: None, - membership_role: None, - }; - assert_eq!(payload.artifact_id(), "a"); - assert_eq!(payload.opaque_analytical_id(), "e"); - assert!(payload.membership_role().is_none()); - let log = ProviderDisclosureLog { - purpose: "scientific_validation".into(), - included_source_text: false, - included_identity_mapping: false, - }; - assert!(!log.included_identity_mapping()); + } - // Short-circuit false arm of `reidentification_authorized` when cover, - // tenant, and scientific purpose already hold. + #[test] + fn open_ended_grant_and_reidentify_false_paths() { assert_eq!( disclose( &grant(AnalyticalPurpose::ScientificValidation, false), @@ -841,9 +821,6 @@ mod tests { ), Err(ApiError::AuthorizationDenied) ); - - // Open-ended grant (`valid_to: None`) exercises `if let Some` false arms - // in both `validate_grant` and `grant_covers`. let open_ended = PurposeGrant { valid_to: None, ..grant(AnalyticalPurpose::ScientificValidation, false) @@ -853,9 +830,6 @@ mod tests { assert_eq!(open_min.0.artifact_id(), "artifact-1"); assert_eq!(open_min.0.opaque_analytical_id(), "entity-1"); assert!(open_min.0.membership_role().is_none()); - - // Deny-path audit with open-ended grant and elevated flag false still - // appends before AuthorizationDenied (RecordingAuditSink mono). assert_eq!( disclose( &PurposeGrant { @@ -867,9 +841,6 @@ mod tests { ), Err(ApiError::AuthorizationDenied) ); - - // FailingSink mono: exercise deny path so append runs with allowed=false - // (still fails at sink; keeps mono instantiations exercised). let mut failing = FailingSink; assert_eq!( disclose_identity_mapping_with_audit( @@ -882,6 +853,31 @@ mod tests { ); } + #[test] + fn minimized_payload_and_disclosure_log_accessors() { + let disclosed = DisclosedIdentityMapping { + opaque_analytical_id: "entity-1".into(), + direct_identity: "Pat Lee".into(), + }; + assert_eq!(disclosed.opaque_analytical_id(), "entity-1"); + assert_eq!(disclosed.direct_identity(), "Pat Lee"); + let payload = MinimizedProviderPayload { + artifact_id: "a".into(), + opaque_analytical_id: "e".into(), + source_text: None, + membership_role: None, + }; + assert_eq!(payload.artifact_id(), "a"); + assert_eq!(payload.opaque_analytical_id(), "e"); + assert!(payload.membership_role().is_none()); + let log = ProviderDisclosureLog { + purpose: "scientific_validation".into(), + included_source_text: false, + included_identity_mapping: false, + }; + assert!(!log.included_identity_mapping()); + } + #[test] fn audit_digest_binds_outcome_when_other_canonical_fields_are_held_fixed() { let allowed = reidentification_decision_digest( From 20b654d349fb02d38c41966523e55669599ea9b9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 09:49:01 +0900 Subject: [PATCH 38/39] ci: re-queue exact-head review for PR #46 after OpenCode REQUEST_CHANGES All required checks on 835922c were green; OpenCode still cited a missing coverage-evidence proof. Empty requeue dismisses stale review state and re-materializes exact-head required workflows without source changes. From 226d3622c4a736992f7d5ecfa016fe68dce7627d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 14:50:35 +0900 Subject: [PATCH 39/39] ci: re-queue exact-head OpenCode after sticky REQUEST_CHANGES Central dispatch cancelled coverage-evidence and left CHANGES_REQUESTED on 20b654d despite local checks green. Empty commit re-triggers pull_request_target Required OpenCode Review as github-actions (direct repository_dispatch is actor-gated).