From a3c57a9b60a0f906ef3918e63f7e61b498141b51 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 17:29:57 -0700 Subject: [PATCH 01/31] feat(api): publish terminal analysis result contract --- crates/tepp_api/src/analysis_result.rs | 668 +++++++++++++++++++++++++ crates/tepp_api/src/lib.rs | 25 +- 2 files changed, 689 insertions(+), 4 deletions(-) create mode 100644 crates/tepp_api/src/analysis_result.rs diff --git a/crates/tepp_api/src/analysis_result.rs b/crates/tepp_api/src/analysis_result.rs new file mode 100644 index 000000000..4f1e938ca --- /dev/null +++ b/crates/tepp_api/src/analysis_result.rs @@ -0,0 +1,668 @@ +//! Versioned terminal analysis-run result contracts. +//! +//! Submission acceptance and scientific completion are separate facts. An +//! [`AnalysisRunAccepted`] value proves only that TEPP accepted a durable run. +//! This module publishes a distinct terminal contract that binds any result +//! artifact back to the immutable request, snapshot, cutoff, model contract, +//! output profile, and accepted remote run identity. + +use crate::wire::{ + from_json, require_byte_limit, require_contract_version, require_nonempty, to_json, +}; +use crate::{AnalysisRunAccepted, AnalysisRunRequest, ApiError}; +use serde::{Deserialize, Serialize}; +use temporal_core::{KnowledgeCutoff, SystemTime}; + +/// Supported terminal analysis-result contract version. +pub const ANALYSIS_RESULT_CONTRACT_VERSION: u16 = 1; + +/// Default maximum terminal analysis-result JSON payload size in bytes. +pub const DEFAULT_ANALYSIS_RESULT_BYTE_LIMIT: usize = 64 * 1024; + +const MAXIMUM_SUMMARY_COUNT: u64 = 1_000_000_000; +const MAXIMUM_FAILURE_CODE_BYTES: usize = 64; + +/// Canonical terminal lifecycle state for an analysis run. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum AnalysisRunTerminalState { + /// Computation completed and a digest-bound result artifact is available. + Succeeded, + /// Computation ended without a result artifact. + Failed, +} + +/// Bounded, identity-free summary of a completed measurement artifact. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct AnalysisResultSummary { + /// Versioned analysis family, such as `temporal_topic_measurement`. + pub analysis_family: String, + /// Number of evidence units represented by the result. + pub evidence_count: u64, + /// Number of reported statistics or parameters. + pub statistic_count: u64, + /// Provider-authored validation state, such as `validated`. + pub validation_status: String, +} + +impl AnalysisResultSummary { + /// Construct and validate an identity-free result summary. + /// + /// # Errors + /// + /// Returns [`ApiError::InvalidWirePayload`] for empty labels or unbounded + /// counts. + pub fn new( + analysis_family: impl Into, + evidence_count: u64, + statistic_count: u64, + validation_status: impl Into, + ) -> Result { + let summary = Self { + analysis_family: analysis_family.into(), + evidence_count, + statistic_count, + validation_status: validation_status.into(), + }; + summary.validate(); + Ok(summary) + } + + fn validate(&self) -> Result<(), ApiError> { + require_nonempty(&self.analysis_family)?; + require_nonempty(&self.validation_status)?; + if self.evidence_count > MAXIMUM_SUMMARY_COUNT + || self.statistic_count > MAXIMUM_SUMMARY_COUNT + { + return Err(ApiError::LimitExceeded); + } + Ok(()) + } +} + +/// A request-bound terminal analysis outcome. +/// +/// A succeeded value carries only artifact identity, canonical digest, schema, +/// and a bounded summary. It deliberately excludes source text, credentials, +/// direct identity, respondent records, item records, and unrestricted model +/// output. A failed value carries only a stable redacted failure code. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct AnalysisRunTerminalResult { + /// Semantic contract version for this payload family. + pub contract_version: u16, + /// Server-assigned opaque run identity from [`AnalysisRunAccepted`]. + pub run_id: String, + /// Canonical terminal lifecycle state. + pub run_state: AnalysisRunTerminalState, + /// Echo of the validated request idempotency key. + pub idempotency_key: String, + /// Authorized tenant or workspace opaque identity. + pub tenant_workspace_id: String, + /// Immutable corpus/evidence snapshot identity. + pub snapshot_id: String, + /// Exact request knowledge cutoff. + pub knowledge_cutoff: String, + /// Versioned model/backend contract identity. + pub model_contract_version: String, + /// Exact requested output profile. + pub output_profile: String, + /// Opaque immutable result artifact identity for a succeeded run. + pub result_artifact_id: Option, + /// Canonical lowercase SHA-256 digest for a succeded result artifact. + pub result_sha256: Option, + /// Versioned result schema identity for a succeeded run. + pub result_schema_version: Option, + /// Strict RFC 3339 system time at which the run became terminal. + pub completed_at: String, + /// Bounded identity-free summary for a succeeded run. + pub summary: Option, + /// Stable snake-case failure code for a failed run. + pub failure_code: Option, +} + +impl AnalysisRunTerminalResult { + /// Construct a validated succeeded result bound to request and acceptance. + /// + /// # Errors + /// + /// Returns a fail-closed contract error when request binding, acceptance + /// binding, timestamp, digest, or summary validation fails. + pub fn succeeded( + request: &AnalysisRunRequest, + accepted: &AnalysisRunAccepted, + result_artifact_id: impl Into, + result_sha256: impl Into, + result_schema_version: impl Into, + completed_at: impl Into, + summary: AnalysisResultSummary, + ) -> Result { + let result = Self { + contract_version: ANALYSIS_RESULT_CONTRACT_VERSION, + run_id: accepted.run_id.clone(), + run_state: AnalysisRunTerminalState::Succeeded, + idempotency_key: request.idempotency_key.clone(), + tenant_workspace_id: request.tenant_workspace_id.clone(), + snapshot_id: request.snapshot_id.clone(), + knowledge_cutoff: request.knowledge_cutoff.clone(), + model_contract_version: request.model_contract_version.clone(), + output_profile: request.output_profile.clone(), + result_artifact_id: Some(result_artifact_id.into()), + result_sha256: Some(result_sha256.into()), + result_schema_version: Some(result_schema_version.into()), + completed_at: completed_at.into(), + summary: Some(summary), + failure_code: None, + }; + result.validate()?; + require_terminal_binding(request, accepted, &result)?; + Ok(result) + } + + /// Construct a validated terminal failure bound to request and acceptance. + /// + /// # Errors + /// + /// Returns a fail-closed contract error when request binding, acceptance + /// binding, timestamp, or failure-code validation fails. + pub fn failed( + request: &AnalysisRunRequest, + accepted: &AnalysisRunAccepted, + completed_at: impl Into, + failure_code: impl Into, + ) -> Result { + let result = Self { + contract_version: ANALYSIS_RESULT_CONTRACT_VERSION, + run_id: accepted.run_id.clone(), + run_state: AnalysisRunTerminalState::Failed, + idempotency_key: request.idempotency_key.clone(), + tenant_workspace_id: request.tenant_workspace_id.clone(), + snapshot_id: request.snapshot_id.clone(), + knowledge_cutoff: request.knowledge_cutoff.clone(), + model_contract_version: request.model_contract_version.clone(), + output_profile: request.output_profile.clone(), + result_artifact_id: None, + result_sha256: None, + result_schema_version: None, + completed_at: completed_at.into(), + summary: None, + failure_code: Some(failure_code.into()), + }; + result.validate()?; + require_terminal_binding(request, accepted, &result)?; + Ok(result) + } + + /// Parse and validate a terminal result with the default byte limit. + /// + /// # Errors + /// + /// Returns wire, version, limit, timestamp, digest, state-shape, or field + /// validation errors. + pub fn from_json(payload: &str) -> Result { + Self::from_json_with_limit(payload, DEFAULT_ANALYSIS_RESULT_BYTE_LIMIT) + } + + /// Parse and validate a terminal result with a caller-supplied byte limit. + /// + /// # Errors + /// + /// Returns wire, version, limit, timestamp, digest, state-shape, or field + /// validation errors. + pub fn from_json_with_limit(payload: &str, maximum_bytes: usize) -> Result { + require_byte_limit(payload, maximum_bytes)?; + let result: Self = from_json(payload)?; + result.validate()?; + Ok(result) + } + + /// Serialize this terminal result after complete validation. + /// + /// # Errors + /// + /// Returns validation or serialization errors. + pub fn to_json(&self) -> Result { + self.validate()?; + to_json(self) + } + + fn validate(&self) -> Result<(), ApiError> { + require_contract_version(self.contract_version, ANALYSIS_RESULT_CONTRACT_VERSION)?; + require_nonempty(&self.run_id)?; + require_nonempty(&self.idempotency_key)?; + require_nonempty(&self.tenant_workspace_id)?; + require_nonempty(&self.snapshot_id)?; + require_nonempty(&self.knowledge_cutoff)?; + require_nonempty(&self.model_contract_version)?; + require_nonempty(&self.output_profile)?; + require_nonempty(&self.completed_at)?; + KnowledgeCutoff::parse_rfc3339(&self.knowledge_cutoff) + .map_err(|_| ApiError::InvalidWirePayload)?; + SystemTime::parse_rfc3339(&self.completed_at).map_err(|_| ApiError::InvalidWirePayload)?; + + match self.run_state { + AnalysisRunTerminalState::Succeeded => self.validate_succeeded_shape(), + AnalysisRunTerminalState::Failed => self.validate_failed_shape(), + } + } + + fn validate_suceeded_shape(&self) -> Result<(), ApiError> { + let artifact_id = self + .result_artifact_id + .as_deref() + .ok_or(ApiError::InvalidWirePayload)?; + let digest = self + .result_sha256 + .as_deref() + .ok_or(ApiError::InvalidWirePayload)?; + let schema_version = self + .result_schema_version + .as_deref() + .ok_or(ApiError::InvalidWirePayload)?; + let summary = self + .summary + .as_ref() + .ok_or(ApiError::InvalidWirePayload)?; + require_nonempty(artifact_id)?; + require_nonempty(schema_version)?; + require_canonical_sha256(digest)?; + summary.validate()?; + if self.failure_code.is_some() { + return Err(ApiError::InvalidWirePayload); + } + Ok(()) + } + + fn validate_failed_shape(&self) -> Result<(), ApiError> { + if self.result_artifact_id.is_some() + || self.result_sha256.is_some() + || self.result_schema_version.is_some() + || self.summary.is_some() + { + return Err(ApiError::InvalidWirePayload); + } + let failure_code = self + .failure_code + .as_deref() + .ok_or(ApiError::InvalidWirePayload)?; + require_failure_code(failure_code) + } +} + +/// Return whether a terminal outcome exactly binds to its submitted request. +#[must_use] +pub fn terminal_result_matches_request( + request: &AnalysisRunRequest, + result: &AnalysisRunTerminalResult, +) -> bool { + result.idempotency_key == request.idempotency_key + && result.tenant_workspace_id == request.tenant_workspace_id + && result.snapshot_id == request.snapshot_id + && result.knowledge_cutoff == request.knowledge_cutoff + && result.model_contract_version == request.model_contract_version + && result.output_profile == request.output_profile +} + +/// Return whether a terminal outcome exactly binds to an accepted receipt. +#[must_use] +pub fn terminal_result_matches_accepted( + accepted: &AnalysisRunAccepted, + result: &AnalysisRunTerminalResult, +) -> bool { + result.run_id == accepted.run_id && result.idempotency_key == accepted.idempotency_key +} + +/// Require exact request and accepted-receipt binding for a terminal outcome. +/// +/// # Errors +/// +/// Returns [`ApiError::InvalidWirePayload`] if either binding differs. +pub fn require_terminal_binding( + request: &AnalysisRunRequest, + accepted: &AnalysisRunAccepted, + result: &AnalysisRunTerminalResult, +) -> Result<(), ApiError> { + if terminal_result_matches_request(request, result) + && terminal_result_matches_accepted(accepted, result) + { + Ok(()) + } else { + Err(ApiError::InvalidWirePayload) + } +} + +fn require_canonical_sha256(value: &str) -> Result<(), ApiError> { + if value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + Ok(()) + } else { + Err(ApiError::InvalidWirePayload) + } +} + +fn require_failure_code(value: &str) -> Result<(), ApiError> { + let bytes = value.as_bytes(); + if bytes.is_empty() + || bytes.len() > MAXIMUM_FAILURE_CODE_BYTES + || !bytes[0].is_ascii_lowercase() + || !bytes + .iter() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || *byte == b'_') + { + return Err(ApiError::InvalidWirePayload); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::{ + ANALYSIS_RESULT_CONTRACT_VERSION, AnalysisResultSummary, AnalysisRunTerminalResult, + AnalysisRunTerminalState, DEFAULT_ANALYSIS_RESULT_BYTE_LIMIT, require_terminal_binding, + terminal_result_matches_accepted, terminal_result_matches_request, + }; + use crate::{ + ANALYSIS_RUN_CONTRACT_VERSION, AnalysisRunAccepted, AnalysisRunRequest, ApiError, + }; + + const DIGEST: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + + fn sample_request() -> AnalysisRunRequest { + AnalysisRunRequest { + contract_version: ANALYSIS_RUN_CONTRACT_VERSION, + idempotency_key: "idem-1".into(), + tenant_workspace_id: "tenant-ws-1".into(), + snapshot_id: "snapshot-1".into(), + knowledge_cutoff: "2026-08-01T00:00:00Z".into(), + model_contract_version: "temporal-model-v1".into(), + output_profile: "validation-report".into(), + } + } + + fn sample_accepted() -> AnalysisRunAccepted { + AnalysisRunAccepted::new("run-1", "accepted", "idem-1").expect("accepted") + } + + fn sample_summary() -> AnalysisResultSummary { + AnalysisResultSummary::new("temporal_topic_measurement", 120, 42, "validated") + .expect("summary") + } + + fn sample_succeeded() -> AnalysisRunTerminalResult { + AnalysisRunTerminalResult::succeeded( + &sample_request(), + &sample_accepted(), + "artifact-1", + DIGEST, + "tepp-result-v1", + "2026-08-02T03:04:05Z", + sample_summary(), + ) + .expect("succeeded") + } + + #[test] + fn succeeded_result_round_trips_and_binds_request_and_receipt() { + let request = sample_request(); + let accepted = sample_accepted(); + let result = AnalysisRunTerminalResult::succeeded( + &request, + &accepted, + "artifact-1", + DIGEST, + "tepp-result-v1", + "2026-08-02T03:04:05+00:00", + sample_summary(), + ) + .expect("succeed"); + assert_eq!(result.run_state, AnalysisRunTerminalState::Succeeded); + assert!(terminal_result_matches_request(&request, &result)); + assert!(terminal_result_matches_accepted(&accepted, &result)); + assert_eq!(require_terminal_binding(&request, &accepted, &result), Ok(())); + let json = result.to_json().expect("json"); + assert_eq!( + AnalysisRunTerminalResult::from_json(&ajson).expect("decoded"), + result + ); + assert!(DEFAULT_ANALYSIS_RESULT_BYTE_LIMIT >= json.len()); + } + + #[test] + fn failed_result_round_trips_without_measurement_artifact() { + let request = sample_request(); + let accepted = sample_accepted(); + let result = AnalysisRunTerminalResult::failed( + &request, + &accepted, + "2026-08-02T03:04:05Z", + "estimation_failed", + ) + .expect("failed"); + assert_eq!(result.run_state, AnalysisRunTerminalState::Failed); + assert_eq!(result.result_artifact_id, None); + assert_eq!(result.summary, None); + let json = result.to_json().expect("json"); + assert_eq!( + AnalysisRunTerminalResult::from_json(&ajson).expect("decoded"), + result + ); + } + + #[test] + fn accepted_receipt_and_extended_or_oversized_payloads_fail_closed() { + let accepted_json = sample_accepted().to_json().expect("accepted json"); + assert_eq!( + AnalysisRunTerminalResult::from_json(&accepted_json), + Err(ApiError::InvalidWirePayload) + ); + + let mut value: serde_json::Value = + serde_json::from_str(&sample_succeeded().to_json().expect( "json" )).expect("value"); + value["extra"] = serde_json::json!(true); + assert_eq!( + AnalysisRunTerminalResult::from_json(&value.to_string()), + Err(ApiError::InvalidWirePayload) + ); + + let json = sample_succeeded().to_json().expect("json"); + assert_eq!( + AnalysisRunTerminalResult::from_json_with_limit(&json, 8), + Err(ApiError::LimitExceeded) + ); + } + + #[test] + fn version_required_fields_and_timestamps_fail_closed() { + let mut result = sample_succeeded(); + result.contract_version = ANALYSIS_RESULT_CONTRACT_VERSION + 1; + assert_eq!(result.to_json(), Err(ApiError::UnsupportedContractVersion)); + + for clear in 0..8 { + let mut invalid = sample_succeeded(); + match clear { + 0 => invalid.run_id.clear(), + 1 => invalid.idempotency_key.clear(), + 2 => invalid.tenant_workspace_id.clear(), + 3 => invalid.snapshot_id.clear(), + 4 => invalid.knowledge_cutoff.clear(), + 5 => invalid.model_contract_version.clear(), + 6 => invalid.output_profile.clear(), + 7 => invalid.completed_at.clear(), + _ => unreachable!(), + } + assert_eq!(invalid.to_json(), Err(ApiError::InvalidWirePayload)); + } + + let mut invalid_cutoff = sample_succeeded(); + invalid_cutoff.knowledge_cutoff = "yesterday".into(); + assert_eq!(invalid_cutoff.to_json(), Err(ApiError::InvalidWirePayload)); + + let mut invalid_completion = sample_succeeded(); + invalid_completion.completed_at = "2026-99-99T25:00:00Z".into(); + assert_eq!(invalid_completion.to_json(), Err(ApiError::InvalidWirePayload)); + } + + #[test] + fn succeeded_shape_requires_complete_digest_bound_result_and_no_failure() { + let mut result = sample_succeeded(); + result.result_artifact_id = None; + assert_eq!(result.to_json(), Err(ApiError::InvalidWirePayload)); + + let mut result = sample_succeeded(); + result.result_artifact_id = Some(String::new()); + assert_eq!(result.to_json(), Err(ApiError::InvalidWirePayload)); + + let mut result = sample_succeeded(); + result.result_sha256 = None; + assert_eq!(result.to_json(), Err(ApiError::InvalidWirePayload)); + + for digest in [ + "abcd".to_string(), + DIGEST.to_uppercase(), + format!("{DIGEST}0"), + ] { + let mut result = sample_succeeded(); + result.result_sha256 = Some(digest); + assert_eq!(result.to_json(), Err(ApiError::InvalidWirePayload)); + } + + let mut result = sample_succeeded(); + result.result_schema_version = None; + assert_eq!(result.to_json(), Err(ApiError::InvalidWirePayload)); + + let mut result = sample_succeeded(); + result.result_schema_version = Some(String::new()); + assert_eq!(result.to_json(), Err(ApiError::InvalidWirePayload)); + + let mut result = sample_succeeded(); + result.summary = None; + assert_eq!(result.to_json(), Err(ApiError::InvalidWirePayload)); + + let mut result = sample_succeeded(); + result.failure_code = Some("unexpected_failure".into(); + assert_eq!(result.to_json(), Err(ApiError::InvalidWirePayload)); + } + + #[test] + fn summary_is_bounded_and_nonempty() { + assert_eq!( + AnalysisResultSummary::new("", 0, 0, "validated"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + AnalysisResultSummary::new("family", 0, 0, ""), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + AnalysisResultSummary::new("family", 1_000_000_001, 0, "validated"), + Err(ApiError::LimitExceeded) + ); + assert_eq!( + AnalysisResultSummary::new("family", 0, 1_000_000_001, "validated"), + Err(ApiError::LimitExceeded) + ); + + let mut result = sample_succeeded(); + result.summary = Some(AnalysisResultSummary { + analysis_family: String::new(), + evidence_count: 0, + statistic_count: 0, + validation_status: "validated".into(), + }); + assert_eq!(result.to_json(), Err(ApiError::InvalidWirePayload)); + } + + #[test] + fn failed_shape_refuses_result_fields_and_invalid_failure_codes() { + let request = sample_request(); + let accepted = sample_accepted(); + let base = AnalysisRunTerminalResult::failed( + &request, + &accepted, + "2026-08-02T03:04:05Z", + "provider_timeout", + ) + .expect("failed"); + + let mut with_artifact = base.clone(); + with_artifact.result_artifact_id = Some("artifact".into()); + assert_eq!(with_artifact.to_json(), Err(ApiError::InvalidWirePayload)); + + let mut with_digest = base.clone(); + with_digest.result_sha256 = Some(DIGEST.into(); + assert_eq!(with_digest.to_json(), Err(ApiError::InvalidWirePayload)); + + let mut with_schema = base.clone(); + with_schema.result_schema_version = Some("schema".into(); + assert_eq!(with_schema.to_json(), Err(ApiError:InvalidWirePayload)); + + let mut with_summary = base.clone(); + with_summary.summary = Some(sample_summary()); + assert_eq!(with_summary.to_json(), Err(ApiError::InvalidWirePayload)); + + for failure_code in [ + None, + Some(String::new()), + Some("UPPER_CASE".into()), + Some("_leading".into(), + Some("contains-hyphen".into()), + Some("x".repeat(65)), + ] { + let mut invalid = base.clone(); + invalid.failure_code = failure_code; + assert_eq!(invalid.to_json(), Err(ApiError:InvalidWirePayload)); + } + } + + #[test] + fn request_and_acceptance_mismatches_are_rejected() { + let request = sample_request(); + let accepted = sample_accepted(); + let result = sample_succeeded(); + + let mut mismatched_request = request.clone(); + mismatched_request.snapshot_id = "other-snapshot".into(); + assert!(!terminal_result_matches_request(&mismatched_request, &result)); + assert_eq!( + require_terminal_binding(&mismatched_request, &accepted, &result), + Err(ApiError:InvalidWirePayload) + ); + + let mismatched_accepted = + AnalysisRunAccepted::new("other-run", "accepted", "idem-1").expect("accepted"); + assert!(!terminal_result_matches_accepted(&mismatched_accepted, &result)); + assert_eq!( + require_terminal_binding(&request, &mismatched_accepted, &result), + Err(ApiError::InvalidWirePayload) + ); + + let mismatched_idempotency = + AnalysisRunAccepted::new(brun-1", "accepted", "other-idem").expect("accepted"); + assert!(!terminal_result_matches_accepted(&mismatched_idempotency, &result)); + assert_eq!( + AnalysisRunTerminalResult::succeeded( + &request, + &mismatched_idempotency, + "artifact-1", + DIGEST, + "tepp-result-v1", + "2026-08-02T03:04:05Z", + sample_summary(), + ), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + AnalysisRunTerminalResult::failed( + &request, + &mismatched_idempotency, + "2026-08-02T03:04:05Z", + "provider_timeout", + ), + Err(ApiError::InvalidWirePayload) + ); + } +} diff --git a/crates/tepp_api/src/lib.rs b/crates/tepp_api/src/lib.rs index 411da83f2..b5c021b89 100644 --- a/crates/tepp_api/src/lib.rs +++ b/crates/tepp_api/src/lib.rs @@ -1,6 +1,6 @@ #![forbid(unsafe_code)] #![deny(missing_docs)] -//! Versioned TEPP service DTOs, error envelopes, and export contracts. +//! Versioned TEPP service @TOs, error envelopes, and export contracts. //! //! These pure wire contracts let TEPP operate standalone and as a modular CWL //! component without sharing application tables. Domain estimation remains in @@ -9,6 +9,7 @@ //! export paths; table-access URLs, review/Copilot headers, and lexical //! inference claims fail closed (ADR 0011). +mod analysis_result; mod analysis_run; mod authorization; mod envelope; @@ -19,11 +20,27 @@ mod orchestration; mod provider_payload; mod wire; +/// Terminal analysis-result contract version constant. +pub use analysis_result::ANALYSIS_RESULT_CONTRACT_VERSION; +/// Bounded identity-free terminal result summary. +pub use analysis_result::AnalysisResultSummary; +/// Request-bound terminal analysis outcome. +pub use analysis_result::AnalysisRunTerminalResult; +/// Canonical terminal analysis-run state. +pub use analysis_result::AnalysisRunTerminalState; +/// Default terminal analysis-result payload byte limit. +pub use analysis_result::DEFAULT_ANALYSIS_RESULT_BYTE_LIMIT; +/// Require exact terminal result binding to request and accepted receipt. +pub use analysis_result::require_terminal_binding; +/// Compare a terminal result with an accepted receipt. +pub use analysis_result::terminal_result_matches_accepted; +/// Compare a terminal result with its submitted request. +pub use analysis_result::terminal_result_matches_request; /// Analysis-run contract version constant. pub use analysis_run::ANALYSIS_RUN_CONTRACT_VERSION; /// Accepted analysis-run response. pub use analysis_run::AnalysisRunAccepted; -/// Analysis-run create request. +/// Analysis-sun create request. pub use analysis_run::AnalysisRunRequest; /// Default analysis-run payload byte limit. pub use analysis_run::DEFAULT_ANALYSIS_RUN_BYTE_LIMIT; @@ -52,7 +69,7 @@ pub use authorization::ExportAuthorizationRequest; pub use authorization::authorize_export; /// Fail closed when an export decision is denied. pub use authorization::require_export_allowed; -/// Versioned analysis-run path naruon may call. +/// Versioned analysis-sun path naruon may call. pub use naruon_http::NARUON_ANALYSIS_RUN_PATH; /// Versioned export path naruon may call. pub use naruon_http::NARUON_EXPORT_PATH; @@ -62,7 +79,7 @@ pub use naruon_http::NARUON_TEPP_INFERENCE_METHOD; pub use naruon_http::NaruonHttpExchange; /// Build a naruon analysis-run create exchange. pub use naruon_http::naruon_analysis_run_exchange; -/// Build an analysis-run exchange and refuse credential headers. +/// Build an analysis-sun exchange and refuse credential headers. pub use naruon_http::naruon_analysis_run_exchange_with_headers; /// Build a naruon export-authorization exchange. pub use naruon_http::naruon_export_exchange; From 003dae3b397f1ec2d819822ad883ae5568bf6c11 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 17:31:20 -0700 Subject: [PATCH 02/31] fix(api): preserve existing contract documentation --- crates/tepp_api/src/lib.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/tepp_api/src/lib.rs b/crates/tepp_api/src/lib.rs index b5c021b89..d5e5c9f5b 100644 --- a/crates/tepp_api/src/lib.rs +++ b/crates/tepp_api/src/lib.rs @@ -1,6 +1,6 @@ #![forbid(unsafe_code)] #![deny(missing_docs)] -//! Versioned TEPP service @TOs, error envelopes, and export contracts. +//! Versioned TEPP service DTOs, error envelopes, and export contracts. //! //! These pure wire contracts let TEPP operate standalone and as a modular CWL //! component without sharing application tables. Domain estimation remains in @@ -40,7 +40,7 @@ pub use analysis_result::terminal_result_matches_request; pub use analysis_run::ANALYSIS_RUN_CONTRACT_VERSION; /// Accepted analysis-run response. pub use analysis_run::AnalysisRunAccepted; -/// Analysis-sun create request. +/// Analysis-run create request. pub use analysis_run::AnalysisRunRequest; /// Default analysis-run payload byte limit. pub use analysis_run::DEFAULT_ANALYSIS_RUN_BYTE_LIMIT; @@ -69,7 +69,7 @@ pub use authorization::ExportAuthorizationRequest; pub use authorization::authorize_export; /// Fail closed when an export decision is denied. pub use authorization::require_export_allowed; -/// Versioned analysis-sun path naruon may call. +/// Versioned analysis-run path naruon may call. pub use naruon_http::NARUON_ANALYSIS_RUN_PATH; /// Versioned export path naruon may call. pub use naruon_http::NARUON_EXPORT_PATH; @@ -79,7 +79,7 @@ pub use naruon_http::NARUON_TEPP_INFERENCE_METHOD; pub use naruon_http::NaruonHttpExchange; /// Build a naruon analysis-run create exchange. pub use naruon_http::naruon_analysis_run_exchange; -/// Build an analysis-sun exchange and refuse credential headers. +/// Build an analysis-run exchange and refuse credential headers. pub use naruon_http::naruon_analysis_run_exchange_with_headers; /// Build a naruon export-authorization exchange. pub use naruon_http::naruon_export_exchange; From 0cb693fef17c2823d156aa24722b472744339095 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 17:33:27 -0700 Subject: [PATCH 03/31] fix(api): restore strict terminal result implementation --- crates/tepp_api/src/analysis_result.rs | 484 +++++-------------------- 1 file changed, 88 insertions(+), 396 deletions(-) diff --git a/crates/tepp_api/src/analysis_result.rs b/crates/tepp_api/src/analysis_result.rs index 4f1e938ca..771087e38 100644 --- a/crates/tepp_api/src/analysis_result.rs +++ b/crates/tepp_api/src/analysis_result.rs @@ -1,10 +1,9 @@ //! Versioned terminal analysis-run result contracts. //! -//! Submission acceptance and scientific completion are separate facts. An -//! [`AnalysisRunAccepted`] value proves only that TEPP accepted a durable run. -//! This module publishes a distinct terminal contract that binds any result -//! artifact back to the immutable request, snapshot, cutoff, model contract, -//! output profile, and accepted remote run identity. +//! Submission acceptance and scientific completion are separate facts. +//! [`AnalysisRunAccepted`] is only a durable receipt. This module defines a +//! distinct, request-bound terminal result with a digest-bound artifact or a +//! redacted failure code. use crate::wire::{ from_json, require_byte_limit, require_contract_version, require_nonempty, to_json, @@ -26,32 +25,32 @@ const MAXIMUM_FAILURE_CODE_BYTES: usize = 64; #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "snake_case")] pub enum AnalysisRunTerminalState { - /// Computation completed and a digest-bound result artifact is available. + /// Computation completed with a digest-bound result artifact. Succeeded, /// Computation ended without a result artifact. Failed, } -/// Bounded, identity-free summary of a completed measurement artifact. +/// Bounded, identity-free summary of one completed measurement artifact. #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(deny_unknown_fields)] pub struct AnalysisResultSummary { - /// Versioned analysis family, such as `temporal_topic_measurement`. + /// Versioned analysis family. pub analysis_family: String, /// Number of evidence units represented by the result. pub evidence_count: u64, /// Number of reported statistics or parameters. pub statistic_count: u64, - /// Provider-authored validation state, such as `validated`. + /// Provider-authored validation status. pub validation_status: String, } impl AnalysisResultSummary { - /// Construct and validate an identity-free result summary. + /// Construct and validate a bounded, identity-free summary. /// /// # Errors /// - /// Returns [`ApiError::InvalidWirePayload`] for empty labels or unbounded + /// Returns a fail-closed contract error for empty labels or unbounded /// counts. pub fn new( analysis_family: impl Into, @@ -59,14 +58,14 @@ impl AnalysisResultSummary { statistic_count: u64, validation_status: impl Into, ) -> Result { - let summary = Self { + let value = Self { analysis_family: analysis_family.into(), evidence_count, statistic_count, validation_status: validation_status.into(), }; - summary.validate(); - Ok(summary) + value.validate()?; + Ok(value) } fn validate(&self) -> Result<(), ApiError> { @@ -76,59 +75,58 @@ impl AnalysisResultSummary { || self.statistic_count > MAXIMUM_SUMMARY_COUNT { return Err(ApiError::LimitExceeded); - } + } Ok(()) } } -/// A request-bound terminal analysis outcome. +/// Request-bound terminal outcome for one accepted analysis run. /// -/// A succeeded value carries only artifact identity, canonical digest, schema, -/// and a bounded summary. It deliberately excludes source text, credentials, -/// direct identity, respondent records, item records, and unrestricted model -/// output. A failed value carries only a stable redacted failure code. +/// The succeeded shape excludes source text, credentials, direct identity, +/// respondent/item records, and unrestricted model output. The failed shape +/// contains no measurement artifact. #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(deny_unknown_fields)] pub struct AnalysisRunTerminalResult { - /// Semantic contract version for this payload family. + /// Semantic contract version. pub contract_version: u16, - /// Server-assigned opaque run identity from [`AnalysisRunAccepted`]. + /// Opaque remote run identity from [`AnalysisRunAccepted`]. pub run_id: String, - /// Canonical terminal lifecycle state. + /// Terminal lifecycle state. pub run_state: AnalysisRunTerminalState, - /// Echo of the validated request idempotency key. + /// Exact request idempotency key. pub idempotency_key: String, - /// Authorized tenant or workspace opaque identity. + /// Authorized tenant/workspace opaque identity. pub tenant_workspace_id: String, /// Immutable corpus/evidence snapshot identity. pub snapshot_id: String, /// Exact request knowledge cutoff. pub knowledge_cutoff: String, - /// Versioned model/backend contract identity. + /// Exact model/backend contract identity. pub model_contract_version: String, /// Exact requested output profile. pub output_profile: String, - /// Opaque immutable result artifact identity for a succeeded run. + /// Opaque result artifact identity for a succeeded run. pub result_artifact_id: Option, - /// Canonical lowercase SHA-256 digest for a succeded result artifact. + /// Canonical lowercase SHA-256 result digest. pub result_sha256: Option, - /// Versioned result schema identity for a succeeded run. + /// Versioned result-schema identity. pub result_schema_version: Option, - /// Strict RFC 3339 system time at which the run became terminal. + /// Strict RFC 3339 system time at terminal completion. pub completed_at: String, - /// Bounded identity-free summary for a succeeded run. + /// Bounded summary for a succeeded run. pub summary: Option, - /// Stable snake-case failure code for a failed run. + /// Stable snake-case code for a failed run. pub failure_code: Option, } impl AnalysisRunTerminalResult { - /// Construct a validated succeeded result bound to request and acceptance. + /// Construct a succeeded terminal result bound to request and receipt. /// /// # Errors /// - /// Returns a fail-closed contract error when request binding, acceptance - /// binding, timestamp, digest, or summary validation fails. + /// Returns a fail-closed error for invalid shape, digest, time, summary, or + /// request/receipt binding. pub fn succeeded( request: &AnalysisRunRequest, accepted: &AnalysisRunAccepted, @@ -138,7 +136,7 @@ impl AnalysisRunTerminalResult { completed_at: impl Into, summary: AnalysisResultSummary, ) -> Result { - let result = Self { + let value = Self { contract_version: ANALYSIS_RESULT_CONTRACT_VERSION, run_id: accepted.run_id.clone(), run_state: AnalysisRunTerminalState::Succeeded, @@ -155,24 +153,24 @@ impl AnalysisRunTerminalResult { summary: Some(summary), failure_code: None, }; - result.validate()?; - require_terminal_binding(request, accepted, &result)?; - Ok(result) + value.validate()?; + require_terminal_binding(request, accepted, &value)?; + Ok(value) } - /// Construct a validated terminal failure bound to request and acceptance. + /// Construct a failed terminal result bound to request and receipt. /// /// # Errors /// - /// Returns a fail-closed contract error when request binding, acceptance - /// binding, timestamp, or failure-code validation fails. + /// Returns a fail-closed error for invalid time, failure code, or + /// request/receipt binding. pub fn failed( request: &AnalysisRunRequest, accepted: &AnalysisRunAccepted, completed_at: impl Into, failure_code: impl Into, ) -> Result { - let result = Self { + let value = Self { contract_version: ANALYSIS_RESULT_CONTRACT_VERSION, run_id: accepted.run_id.clone(), run_state: AnalysisRunTerminalState::Failed, @@ -189,17 +187,16 @@ impl AnalysisRunTerminalResult { summary: None, failure_code: Some(failure_code.into()), }; - result.validate()?; - require_terminal_binding(request, accepted, &result)?; - Ok(result) + value.validate()?; + require_terminal_binding(request, accepted, &value)?; + Ok(value) } /// Parse and validate a terminal result with the default byte limit. /// /// # Errors /// - /// Returns wire, version, limit, timestamp, digest, state-shape, or field - /// validation errors. + /// Returns wire, version, limit, time, digest, shape, or field errors. pub fn from_json(payload: &str) -> Result { Self::from_json_with_limit(payload, DEFAULT_ANALYSIS_RESULT_BYTE_LIMIT) } @@ -208,13 +205,12 @@ impl AnalysisRunTerminalResult { /// /// # Errors /// - /// Returns wire, version, limit, timestamp, digest, state-shape, or field - /// validation errors. + /// Returns wire, version, limit, time, digest, shape, or field errors. pub fn from_json_with_limit(payload: &str, maximum_bytes: usize) -> Result { require_byte_limit(payload, maximum_bytes)?; - let result: Self = from_json(payload)?; - result.validate()?; - Ok(result) + let value: Self = from_json(payload)?; + value.validate()?; + Ok(value) } /// Serialize this terminal result after complete validation. @@ -229,25 +225,29 @@ impl AnalysisRunTerminalResult { fn validate(&self) -> Result<(), ApiError> { require_contract_version(self.contract_version, ANALYSIS_RESULT_CONTRACT_VERSION)?; - require_nonempty(&self.run_id)?; - require_nonempty(&self.idempotency_key)?; - require_nonempty(&self.tenant_workspace_id)?; - require_nonempty(&self.snapshot_id)?; - require_nonempty(&self.knowledge_cutoff)?; - require_nonempty(&self.model_contract_version)?; - require_nonempty(&self.output_profile)?; - require_nonempty(&self.completed_at)?; + for value in [ + &self.run_id, + &self.idempotency_key, + &self.tenant_workspace_id, + &self.snapshot_id, + &self.knowledge_cutoff, + &self.model_contract_version, + &self.output_profile, + &self.completed_at, + ] { + require_nonempty(value)?; + } KnowledgeCutoff::parse_rfc3339(&self.knowledge_cutoff) .map_err(|_| ApiError::InvalidWirePayload)?; SystemTime::parse_rfc3339(&self.completed_at).map_err(|_| ApiError::InvalidWirePayload)?; match self.run_state { - AnalysisRunTerminalState::Succeeded => self.validate_succeeded_shape(), - AnalysisRunTerminalState::Failed => self.validate_failed_shape(), + AnalysisRunTerminalState::Succeeded => self.validate_succeeded(), + AnalysisRunTerminalState::Failed => self.validate_failed(), } } - fn validate_suceeded_shape(&self) -> Result<(), ApiError> { + fn validate_succeeded(&self) -> Result<(), ApiError> { let artifact_id = self .result_artifact_id .as_deref() @@ -256,7 +256,7 @@ impl AnalysisRunTerminalResult { .result_sha256 .as_deref() .ok_or(ApiError::InvalidWirePayload)?; - let schema_version = self + let schema = self .result_schema_version .as_deref() .ok_or(ApiError::InvalidWirePayload)?; @@ -265,7 +265,7 @@ impl AnalysisRunTerminalResult { .as_ref() .ok_or(ApiError::InvalidWirePayload)?; require_nonempty(artifact_id)?; - require_nonempty(schema_version)?; + require_nonempty(schema)?; require_canonical_sha256(digest)?; summary.validate()?; if self.failure_code.is_some() { @@ -274,7 +274,7 @@ impl AnalysisRunTerminalResult { Ok(()) } - fn validate_failed_shape(&self) -> Result<(), ApiError> { + fn validate_failed(&self) -> Result<(), ApiError> { if self.result_artifact_id.is_some() || self.result_sha256.is_some() || self.result_schema_version.is_some() @@ -282,15 +282,15 @@ impl AnalysisRunTerminalResult { { return Err(ApiError::InvalidWirePayload); } - let failure_code = self - .failure_code - .as_deref() - .ok_or(ApiError::InvalidWirePayload)?; - require_failure_code(failure_code) + require_failure_code( + self.failure_code + .as_deref() + .ok_or(ApiError::InvalidWirePayload)?, + ) } } -/// Return whether a terminal outcome exactly binds to its submitted request. +/// Return whether a terminal result exactly binds to its submitted request. #[must_use] pub fn terminal_result_matches_request( request: &AnalysisRunRequest, @@ -304,7 +304,7 @@ pub fn terminal_result_matches_request( && result.output_profile == request.output_profile } -/// Return whether a terminal outcome exactly binds to an accepted receipt. +/// Return whether a terminal result exactly binds to an accepted receipt. #[must_use] pub fn terminal_result_matches_accepted( accepted: &AnalysisRunAccepted, @@ -313,11 +313,11 @@ pub fn terminal_result_matches_accepted( result.run_id == accepted.run_id && result.idempotency_key == accepted.idempotency_key } -/// Require exact request and accepted-receipt binding for a terminal outcome. +/// Require exact request and accepted-receipt binding. /// /// # Errors /// -/// Returns [`ApiError::InvalidWirePayload`] if either binding differs. +/// Returns [`ApiError::InvalidWirePayload`] when either binding differs. pub fn require_terminal_binding( request: &AnalysisRunRequest, accepted: &AnalysisRunAccepted, @@ -333,11 +333,11 @@ pub fn require_terminal_binding( } fn require_canonical_sha256(value: &str) -> Result<(), ApiError> { - if value.len() == 64 + let valid = value.len() == 64 && value .bytes() - .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) - { + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)); + if valid { Ok(()) } else { Err(ApiError::InvalidWirePayload) @@ -346,323 +346,15 @@ fn require_canonical_sha256(value: &str) -> Result<(), ApiError> { fn require_failure_code(value: &str) -> Result<(), ApiError> { let bytes = value.as_bytes(); - if bytes.is_empty() - || bytes.len() > MAXIMUM_FAILURE_CODE_BYTES - || !bytes[0].is_ascii_lowercase() - || !bytes + let valid = !bytes.is_empty() + && bytes.len() <= MAXIMUM_FAILURE_CODE_BYTES + && bytes[0].is_ascii_lowercase() + && bytes .iter() - .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || *byte == b'_') - { - return Err(ApiError::InvalidWirePayload); - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::{ - ANALYSIS_RESULT_CONTRACT_VERSION, AnalysisResultSummary, AnalysisRunTerminalResult, - AnalysisRunTerminalState, DEFAULT_ANALYSIS_RESULT_BYTE_LIMIT, require_terminal_binding, - terminal_result_matches_accepted, terminal_result_matches_request, - }; - use crate::{ - ANALYSIS_RUN_CONTRACT_VERSION, AnalysisRunAccepted, AnalysisRunRequest, ApiError, - }; - - const DIGEST: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; - - fn sample_request() -> AnalysisRunRequest { - AnalysisRunRequest { - contract_version: ANALYSIS_RUN_CONTRACT_VERSION, - idempotency_key: "idem-1".into(), - tenant_workspace_id: "tenant-ws-1".into(), - snapshot_id: "snapshot-1".into(), - knowledge_cutoff: "2026-08-01T00:00:00Z".into(), - model_contract_version: "temporal-model-v1".into(), - output_profile: "validation-report".into(), - } - } - - fn sample_accepted() -> AnalysisRunAccepted { - AnalysisRunAccepted::new("run-1", "accepted", "idem-1").expect("accepted") - } - - fn sample_summary() -> AnalysisResultSummary { - AnalysisResultSummary::new("temporal_topic_measurement", 120, 42, "validated") - .expect("summary") - } - - fn sample_succeeded() -> AnalysisRunTerminalResult { - AnalysisRunTerminalResult::succeeded( - &sample_request(), - &sample_accepted(), - "artifact-1", - DIGEST, - "tepp-result-v1", - "2026-08-02T03:04:05Z", - sample_summary(), - ) - .expect("succeeded") - } - - #[test] - fn succeeded_result_round_trips_and_binds_request_and_receipt() { - let request = sample_request(); - let accepted = sample_accepted(); - let result = AnalysisRunTerminalResult::succeeded( - &request, - &accepted, - "artifact-1", - DIGEST, - "tepp-result-v1", - "2026-08-02T03:04:05+00:00", - sample_summary(), - ) - .expect("succeed"); - assert_eq!(result.run_state, AnalysisRunTerminalState::Succeeded); - assert!(terminal_result_matches_request(&request, &result)); - assert!(terminal_result_matches_accepted(&accepted, &result)); - assert_eq!(require_terminal_binding(&request, &accepted, &result), Ok(())); - let json = result.to_json().expect("json"); - assert_eq!( - AnalysisRunTerminalResult::from_json(&ajson).expect("decoded"), - result - ); - assert!(DEFAULT_ANALYSIS_RESULT_BYTE_LIMIT >= json.len()); - } - - #[test] - fn failed_result_round_trips_without_measurement_artifact() { - let request = sample_request(); - let accepted = sample_accepted(); - let result = AnalysisRunTerminalResult::failed( - &request, - &accepted, - "2026-08-02T03:04:05Z", - "estimation_failed", - ) - .expect("failed"); - assert_eq!(result.run_state, AnalysisRunTerminalState::Failed); - assert_eq!(result.result_artifact_id, None); - assert_eq!(result.summary, None); - let json = result.to_json().expect("json"); - assert_eq!( - AnalysisRunTerminalResult::from_json(&ajson).expect("decoded"), - result - ); - } - - #[test] - fn accepted_receipt_and_extended_or_oversized_payloads_fail_closed() { - let accepted_json = sample_accepted().to_json().expect("accepted json"); - assert_eq!( - AnalysisRunTerminalResult::from_json(&accepted_json), - Err(ApiError::InvalidWirePayload) - ); - - let mut value: serde_json::Value = - serde_json::from_str(&sample_succeeded().to_json().expect( "json" )).expect("value"); - value["extra"] = serde_json::json!(true); - assert_eq!( - AnalysisRunTerminalResult::from_json(&value.to_string()), - Err(ApiError::InvalidWirePayload) - ); - - let json = sample_succeeded().to_json().expect("json"); - assert_eq!( - AnalysisRunTerminalResult::from_json_with_limit(&json, 8), - Err(ApiError::LimitExceeded) - ); - } - - #[test] - fn version_required_fields_and_timestamps_fail_closed() { - let mut result = sample_succeeded(); - result.contract_version = ANALYSIS_RESULT_CONTRACT_VERSION + 1; - assert_eq!(result.to_json(), Err(ApiError::UnsupportedContractVersion)); - - for clear in 0..8 { - let mut invalid = sample_succeeded(); - match clear { - 0 => invalid.run_id.clear(), - 1 => invalid.idempotency_key.clear(), - 2 => invalid.tenant_workspace_id.clear(), - 3 => invalid.snapshot_id.clear(), - 4 => invalid.knowledge_cutoff.clear(), - 5 => invalid.model_contract_version.clear(), - 6 => invalid.output_profile.clear(), - 7 => invalid.completed_at.clear(), - _ => unreachable!(), - } - assert_eq!(invalid.to_json(), Err(ApiError::InvalidWirePayload)); - } - - let mut invalid_cutoff = sample_succeeded(); - invalid_cutoff.knowledge_cutoff = "yesterday".into(); - assert_eq!(invalid_cutoff.to_json(), Err(ApiError::InvalidWirePayload)); - - let mut invalid_completion = sample_succeeded(); - invalid_completion.completed_at = "2026-99-99T25:00:00Z".into(); - assert_eq!(invalid_completion.to_json(), Err(ApiError::InvalidWirePayload)); - } - - #[test] - fn succeeded_shape_requires_complete_digest_bound_result_and_no_failure() { - let mut result = sample_succeeded(); - result.result_artifact_id = None; - assert_eq!(result.to_json(), Err(ApiError::InvalidWirePayload)); - - let mut result = sample_succeeded(); - result.result_artifact_id = Some(String::new()); - assert_eq!(result.to_json(), Err(ApiError::InvalidWirePayload)); - - let mut result = sample_succeeded(); - result.result_sha256 = None; - assert_eq!(result.to_json(), Err(ApiError::InvalidWirePayload)); - - for digest in [ - "abcd".to_string(), - DIGEST.to_uppercase(), - format!("{DIGEST}0"), - ] { - let mut result = sample_succeeded(); - result.result_sha256 = Some(digest); - assert_eq!(result.to_json(), Err(ApiError::InvalidWirePayload)); - } - - let mut result = sample_succeeded(); - result.result_schema_version = None; - assert_eq!(result.to_json(), Err(ApiError::InvalidWirePayload)); - - let mut result = sample_succeeded(); - result.result_schema_version = Some(String::new()); - assert_eq!(result.to_json(), Err(ApiError::InvalidWirePayload)); - - let mut result = sample_succeeded(); - result.summary = None; - assert_eq!(result.to_json(), Err(ApiError::InvalidWirePayload)); - - let mut result = sample_succeeded(); - result.failure_code = Some("unexpected_failure".into(); - assert_eq!(result.to_json(), Err(ApiError::InvalidWirePayload)); - } - - #[test] - fn summary_is_bounded_and_nonempty() { - assert_eq!( - AnalysisResultSummary::new("", 0, 0, "validated"), - Err(ApiError::InvalidWirePayload) - ); - assert_eq!( - AnalysisResultSummary::new("family", 0, 0, ""), - Err(ApiError::InvalidWirePayload) - ); - assert_eq!( - AnalysisResultSummary::new("family", 1_000_000_001, 0, "validated"), - Err(ApiError::LimitExceeded) - ); - assert_eq!( - AnalysisResultSummary::new("family", 0, 1_000_000_001, "validated"), - Err(ApiError::LimitExceeded) - ); - - let mut result = sample_succeeded(); - result.summary = Some(AnalysisResultSummary { - analysis_family: String::new(), - evidence_count: 0, - statistic_count: 0, - validation_status: "validated".into(), - }); - assert_eq!(result.to_json(), Err(ApiError::InvalidWirePayload)); - } - - #[test] - fn failed_shape_refuses_result_fields_and_invalid_failure_codes() { - let request = sample_request(); - let accepted = sample_accepted(); - let base = AnalysisRunTerminalResult::failed( - &request, - &accepted, - "2026-08-02T03:04:05Z", - "provider_timeout", - ) - .expect("failed"); - - let mut with_artifact = base.clone(); - with_artifact.result_artifact_id = Some("artifact".into()); - assert_eq!(with_artifact.to_json(), Err(ApiError::InvalidWirePayload)); - - let mut with_digest = base.clone(); - with_digest.result_sha256 = Some(DIGEST.into(); - assert_eq!(with_digest.to_json(), Err(ApiError::InvalidWirePayload)); - - let mut with_schema = base.clone(); - with_schema.result_schema_version = Some("schema".into(); - assert_eq!(with_schema.to_json(), Err(ApiError:InvalidWirePayload)); - - let mut with_summary = base.clone(); - with_summary.summary = Some(sample_summary()); - assert_eq!(with_summary.to_json(), Err(ApiError::InvalidWirePayload)); - - for failure_code in [ - None, - Some(String::new()), - Some("UPPER_CASE".into()), - Some("_leading".into(), - Some("contains-hyphen".into()), - Some("x".repeat(65)), - ] { - let mut invalid = base.clone(); - invalid.failure_code = failure_code; - assert_eq!(invalid.to_json(), Err(ApiError:InvalidWirePayload)); - } - } - - #[test] - fn request_and_acceptance_mismatches_are_rejected() { - let request = sample_request(); - let accepted = sample_accepted(); - let result = sample_succeeded(); - - let mut mismatched_request = request.clone(); - mismatched_request.snapshot_id = "other-snapshot".into(); - assert!(!terminal_result_matches_request(&mismatched_request, &result)); - assert_eq!( - require_terminal_binding(&mismatched_request, &accepted, &result), - Err(ApiError:InvalidWirePayload) - ); - - let mismatched_accepted = - AnalysisRunAccepted::new("other-run", "accepted", "idem-1").expect("accepted"); - assert!(!terminal_result_matches_accepted(&mismatched_accepted, &result)); - assert_eq!( - require_terminal_binding(&request, &mismatched_accepted, &result), - Err(ApiError::InvalidWirePayload) - ); - - let mismatched_idempotency = - AnalysisRunAccepted::new(brun-1", "accepted", "other-idem").expect("accepted"); - assert!(!terminal_result_matches_accepted(&mismatched_idempotency, &result)); - assert_eq!( - AnalysisRunTerminalResult::succeeded( - &request, - &mismatched_idempotency, - "artifact-1", - DIGEST, - "tepp-result-v1", - "2026-08-02T03:04:05Z", - sample_summary(), - ), - Err(ApiError::InvalidWirePayload) - ); - assert_eq!( - AnalysisRunTerminalResult::failed( - &request, - &mismatched_idempotency, - "2026-08-02T03:04:05Z", - "provider_timeout", - ), - Err(ApiError::InvalidWirePayload) - ); + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || *byte == b'_'); + if valid { + Ok(()) + } else { + Err(ApiError::InvalidWirePayload) } } From cffbf4e70bed035b62ae0c92cf1f8a7cc7953bb6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 17:35:11 -0700 Subject: [PATCH 04/31] test(api): cover terminal analysis result contract --- .../tests/analysis_result_contract.rs | 329 ++++++++++++++++++ 1 file changed, 329 insertions(+) create mode 100644 crates/tepp_api/tests/analysis_result_contract.rs diff --git a/crates/tepp_api/tests/analysis_result_contract.rs b/crates/tepp_api/tests/analysis_result_contract.rs new file mode 100644 index 000000000..5c157909e --- /dev/null +++ b/crates/tepp_api/tests/analysis_result_contract.rs @@ -0,0 +1,329 @@ +use tepp_api::{ + ANALYSIS_RESULT_CONTRACT_VERSION, ANALYSIS_RUN_CONTRACT_VERSION, AnalysisResultSummary, + AnalysisRunAccepted, AnalysisRunRequest, AnalysisRunTerminalResult, AnalysisRunTerminalState, + ApiError, DEFAULT_ANALYSIS_RESULT_BYTE_LIMIT, require_terminal_binding, + terminal_result_matches_accepted, terminal_result_matches_request, +}; + +const DIGEST: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +fn request() -> AnalysisRunRequest { + AnalysisRunRequest { + contract_version: ANALYSIS_RUN_CONTRACT_VERSION, + idempotency_key: "idem-1".into(), + tenant_workspace_id: "tenant-ws-1".into(), + snapshot_id: "snapshot-1".into(), + knowledge_cutoff: "2026-08-01T00:00:00Z".into(), + model_contract_version: "temporal-model-v1".into(), + output_profile: "validation-report".into(), + } +} + +fn accepted() -> AnalysisRunAccepted { + AnalysisRunAccepted::new("run-1", "accepted", "idem-1").expect("accepted") +} + +fn summary() -> AnalysisResultSummary { + AnalysisResultSummary::new("temporal_topic_measurement", 120, 42, "validated") + .expect("summary") +} + +fn succeeded() -> AnalysisRunTerminalResult { + AnalysisRunTerminalResult::succeeded( + &request(), + &accepted(), + "artifact-1", + DIGEST, + "tepp-result-v1", + "2026-08-02T03:04:05Z", + summary(), + ) + .expect("succeeded") +} + +fn failed() -> AnalysisRunTerminalResult { + AnalysisRunTerminalResult::failed( + &request(), + &accepted(), + "2026-08-02T03:04:05Z", + "estimation_failed", + ) + .expect("failed") +} + +#[test] +fn terminal_success_and_failure_round_trip_without_receipt_confusion() { + let success = succeeded(); + assert_eq!(success.run_state, AnalysisRunTerminalState::Succeeded); + assert!(terminal_result_matches_request(&request(), &success)); + assert!(terminal_result_matches_accepted(&accepted(), &success)); + assert_eq!( + require_terminal_binding(&request(), &accepted(), &success), + Ok(()) + ); + let json = success.to_json().expect("json"); + assert!(json.len() <= DEFAULT_ANALYSIS_RESULT_BYTE_LIMIT); + assert_eq!( + AnalysisRunTerminalResult::from_json(&json).expect("decoded"), + success + ); + + let failure = failed(); + assert_eq!(failure.run_state, AnalysisRunTerminalState::Failed); + assert_eq!(failure.result_artifact_id, None); + assert_eq!(failure.summary, None); + let json = failure.to_json().expect("json"); + assert_eq!( + AnalysisRunTerminalResult::from_json(&json).expect("decoded"), + failure + ); + + let accepted_json = accepted().to_json().expect("accepted json"); + assert_eq!( + AnalysisRunTerminalResult::from_json(&accepted_json), + Err(ApiError::InvalidWirePayload) + ); +} + +#[test] +fn wire_version_limit_extension_and_time_validation_fail_closed() { + let mut value: serde_json::Value = + serde_json::from_str(&succeeded().to_json().expect("json")).expect("value"); + value["extra"] = serde_json::json!(true); + assert_eq!( + AnalysisRunTerminalResult::from_json(&value.to_string()), + Err(ApiError::InvalidWirePayload) + ); + + let json = succeeded().to_json().expect("json"); + assert_eq!( + AnalysisRunTerminalResult::from_json_with_limit(&json, 8), + Err(ApiError::LimitExceeded) + ); + + let mut value = succeeded(); + value.contract_version = ANALYSIS_RESULT_CONTRACT_VERSION + 1; + assert_eq!( + value.to_json(), + Err(ApiError::UnsupportedContractVersion) + ); + + let mut value = succeeded(); + value.knowledge_cutoff = "yesterday".into(); + assert_eq!(value.to_json(), Err(ApiError::InvalidWirePayload)); + + let mut value = succeeded(); + value.completed_at = "2026-99-99T25:00:00Z".into(); + assert_eq!(value.to_json(), Err(ApiError::InvalidWirePayload)); +} + +#[test] +fn every_required_binding_field_is_nonempty() { + for index in 0..8 { + let mut value = succeeded(); + match index { + 0 => value.run_id.clear(), + 1 => value.idempotency_key.clear(), + 2 => value.tenant_workspace_id.clear(), + 3 => value.snapshot_id.clear(), + 4 => value.knowledge_cutoff.clear(), + 5 => value.model_contract_version.clear(), + 6 => value.output_profile.clear(), + 7 => value.completed_at.clear(), + _ => unreachable!(), + } + assert_eq!(value.to_json(), Err(ApiError::InvalidWirePayload)); + } +} + +#[test] +fn succeeded_shape_requires_complete_digest_bound_result() { + let mut value = succeeded(); + value.result_artifact_id = None; + assert_eq!(value.to_json(), Err(ApiError::InvalidWirePayload)); + + let mut value = succeeded(); + value.result_artifact_id = Some(String::new()); + assert_eq!(value.to_json(), Err(ApiError::InvalidWirePayload)); + + let mut value = succeeded(); + value.result_sha256 = None; + assert_eq!(value.to_json(), Err(ApiError::InvalidWirePayload)); + + for digest in [ + String::new(), + "abcd".into(), + DIGEST.to_uppercase(), + format!("{DIGEST}0"), + format!("g{}", &DIGEST[1..]), + ] { + let mut value = succeeded(); + value.result_sha256 = Some(digest); + assert_eq!(value.to_json(), Err(ApiError::InvalidWirePayload)); + } + + let mut value = succeeded(); + value.result_schema_version = None; + assert_eq!(value.to_json(), Err(ApiError::InvalidWirePayload)); + + let mut value = succeeded(); + value.result_schema_version = Some(String::new()); + assert_eq!(value.to_json(), Err(ApiError::InvalidWirePayload)); + + let mut value = succeeded(); + value.summary = None; + assert_eq!(value.to_json(), Err(ApiError::InvalidWirePayload)); + + let mut value = succeeded(); + value.failure_code = Some("unexpected_failure".into()); + assert_eq!(value.to_json(), Err(ApiError::InvalidWirePayload)); +} + +#[test] +fn summary_is_nonempty_and_bounded_in_constructor_and_wire_shape() { + assert_eq!( + AnalysisResultSummary::new("", 0, 0, "validated"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + AnalysisResultSummary::new("family", 0, 0, ""), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + AnalysisResultSummary::new("family", 1_000_000_001, 0, "validated"), + Err(ApiError::LimitExceeded) + ); + assert_eq!( + AnalysisResultSummary::new("family", 0, 1_000_000_001, "validated"), + Err(ApiError::LimitExceeded) + ); + + let invalid_summaries = [ + AnalysisResultSummary { + analysis_family: String::new(), + evidence_count: 0, + statistic_count: 0, + validation_status: "validated".into(), + }, + AnalysisResultSummary { + analysis_family: "family".into(), + evidence_count: 0, + statistic_count: 0, + validation_status: String::new(), + }, + AnalysisResultSummary { + analysis_family: "family".into(), + evidence_count: 1_000_000_001, + statistic_count: 0, + validation_status: "validated".into(), + }, + AnalysisResultSummary { + analysis_family: "family".into(), + evidence_count: 0, + statistic_count: 1_000_000_001, + validation_status: "validated".into(), + }, + ]; + for invalid_summary in invalid_summaries { + let mut value = succeeded(); + value.summary = Some(invalid_summary); + assert!(matches!( + value.to_json(), + Err(ApiError::InvalidWirePayload | ApiError::LimitExceeded) + )); + } +} + +#[test] +fn failed_shape_refuses_measurement_fields_and_invalid_failure_codes() { + let base = failed(); + + let mut value = base.clone(); + value.result_artifact_id = Some("artifact".into()); + assert_eq!(value.to_json(), Err(ApiError::InvalidWirePayload)); + + let mut value = base.clone(); + value.result_sha256 = Some(DIGEST.into()); + assert_eq!(value.to_json(), Err(ApiError::InvalidWirePayload)); + + let mut value = base.clone(); + value.result_schema_version = Some("schema".into()); + assert_eq!(value.to_json(), Err(ApiError::InvalidWirePayload)); + + let mut value = base.clone(); + value.summary = Some(summary()); + assert_eq!(value.to_json(), Err(ApiError::InvalidWirePayload)); + + for code in [ + None, + Some(String::new()), + Some("UPPER_CASE".into()), + Some("_leading".into()), + Some("contains-hyphen".into()), + Some("x".repeat(65)), + ] { + let mut value = base.clone(); + value.failure_code = code; + assert_eq!(value.to_json(), Err(ApiError::InvalidWirePayload)); + } +} + +#[test] +fn every_request_binding_dimension_and_receipt_identity_is_checked() { + let result = succeeded(); + + for index in 0..6 { + let mut mismatched = request(); + match index { + 0 => mismatched.idempotency_key = "other".into(), + 1 => mismatched.tenant_workspace_id = "other".into(), + 2 => mismatched.snapshot_id = "other".into(), + 3 => mismatched.knowledge_cutoff = "2026-07-31T00:00:00Z".into(), + 4 => mismatched.model_contract_version = "other".into(), + 5 => mismatched.output_profile = "other".into(), + _ => unreachable!(), + } + assert!(!terminal_result_matches_request(&mismatched, &result)); + assert_eq!( + require_terminal_binding(&mismatched, &accepted(), &result), + Err(ApiError::InvalidWirePayload) + ); + } + + let other_run = + AnalysisRunAccepted::new("other-run", "accepted", "idem-1").expect("accepted"); + assert!(!terminal_result_matches_accepted(&other_run, &result)); + assert_eq!( + require_terminal_binding(&request(), &other_run, &result), + Err(ApiError::InvalidWirePayload) + ); + + let other_key = + AnalysisRunAccepted::new("run-1", "accepted", "other-key").expect("accepted"); + assert!(!terminal_result_matches_accepted(&other_key, &result)); + assert_eq!( + require_terminal_binding(&request(), &other_key, &result), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + AnalysisRunTerminalResult::succeeded( + &request(), + &other_key, + "artifact", + DIGEST, + "schema", + "2026-08-02T03:04:05Z", + summary(), + ), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + AnalysisRunTerminalResult::failed( + &request(), + &other_key, + "2026-08-02T03:04:05Z", + "provider_timeout", + ), + Err(ApiError::InvalidWirePayload) + ); +} From 24f00c0607a61aa6297450048f747aad7bc95914 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 11:19:30 +0900 Subject: [PATCH 05/31] feat(api): add analysis run status contract --- CHANGELOG.md | 1 + crates/tepp_api/src/analysis_result.rs | 7 +- crates/tepp_api/src/analysis_run.rs | 172 ++++++++++++++++++ crates/tepp_api/src/lib.rs | 8 + .../tests/analysis_result_contract.rs | 166 +++++++++++++++-- docs/API_CONTRACT.md | 12 +- docs/TRACEABILITY.md | 2 +- 7 files changed, 346 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 93891a271..12ec684dd 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` request-bound terminal analysis results and typed analysis-run status/read responses: accepted/running states cannot carry measurement evidence, terminal results bind exact request and receipt identities, and succeeded/failed payloads remain digest-bound or content-redacted. - `tepp_api` adaptive orchestration router (ADR 0010): versioned `direct`/`verify`/`committee`/`conductor`/`abstain` selection from CPU `f64` risk, ambiguity, evidence, and token-budget inputs; recorded stages, recursion, decomposition, access lists, and role-specific reasoning effort; fail-closed document-controlled policy/access/credentials; LLM plans remain proposals under deterministic statistical authority; comparable-budget ablation requires a direct baseline; credential-free contextual-orchestrator binding. Live NIM HTTP remains accepted-target. - `tepp_api` purpose-bound provider-payload minimization: time-bounded `PurposeGrant` evaluation, fail-closed expired/not-yet-valid/inverted/cross-tenant/impossible-calendar denial, semantic UTC calendar validation, refusal to copy identity mappings into model-provider payloads or ordinary logs, preservation of opaque analytical identifiers and membership roles (no blanket PII mask), a separately authorized scientific re-identification path, and an internally bound FIPS 180-4 SHA-256 audit digest appended through `ReidentificationAuditSink` before disclosure. - `persistence_postgres` backup/restore integrity: restored snapshots stay unusable until tenant, canonical `SHA-256`, knowledge-cutoff eligibility, temporal window order, and append-only triggers revalidate; SQL probes raise `restore integrity failed` (ADR 0013). diff --git a/crates/tepp_api/src/analysis_result.rs b/crates/tepp_api/src/analysis_result.rs index 771087e38..fe1cc3018 100644 --- a/crates/tepp_api/src/analysis_result.rs +++ b/crates/tepp_api/src/analysis_result.rs @@ -223,7 +223,7 @@ impl AnalysisRunTerminalResult { to_json(self) } - fn validate(&self) -> Result<(), ApiError> { + pub(crate) fn validate(&self) -> Result<(), ApiError> { require_contract_version(self.contract_version, ANALYSIS_RESULT_CONTRACT_VERSION)?; for value in [ &self.run_id, @@ -260,10 +260,7 @@ impl AnalysisRunTerminalResult { .result_schema_version .as_deref() .ok_or(ApiError::InvalidWirePayload)?; - let summary = self - .summary - .as_ref() - .ok_or(ApiError::InvalidWirePayload)?; + let summary = self.summary.as_ref().ok_or(ApiError::InvalidWirePayload)?; require_nonempty(artifact_id)?; require_nonempty(schema)?; require_canonical_sha256(digest)?; diff --git a/crates/tepp_api/src/analysis_run.rs b/crates/tepp_api/src/analysis_run.rs index 16ac6ba80..a054a0930 100644 --- a/crates/tepp_api/src/analysis_run.rs +++ b/crates/tepp_api/src/analysis_run.rs @@ -4,6 +4,7 @@ use crate::ApiError; use crate::wire::{ from_json, require_byte_limit, require_contract_version, require_nonempty, to_json, }; +use crate::{AnalysisRunTerminalResult, AnalysisRunTerminalState, require_terminal_binding}; use serde::{Deserialize, Serialize}; /// Supported analysis-run contract version. @@ -12,6 +13,9 @@ pub const ANALYSIS_RUN_CONTRACT_VERSION: u16 = 1; /// Default maximum analysis-run JSON payload size in bytes. pub const DEFAULT_ANALYSIS_RUN_BYTE_LIMIT: usize = 64 * 1024; +/// Supported analysis-run status/read contract version. +pub const ANALYSIS_RUN_STATUS_CONTRACT_VERSION: u16 = 1; + /// Request to create a durable analysis run. #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(deny_unknown_fields)] @@ -46,6 +50,36 @@ pub struct AnalysisRunAccepted { pub idempotency_key: String, } +/// Lifecycle state returned by the typed analysis-run status contract. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum AnalysisRunStatusState { + /// The server durably accepted the run. + Accepted, + /// The server is processing the accepted run. + Running, + /// The run completed with a measurement artifact. + Succeeded, + /// The run completed without a measurement artifact. + Failed, +} + +/// Typed status/read response for an accepted analysis run. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct AnalysisRunStatus { + /// Semantic contract version for this status payload family. + pub contract_version: u16, + /// Opaque server-assigned run identity. + pub run_id: String, + /// Current lifecycle state. + pub run_state: AnalysisRunStatusState, + /// Exact request idempotency key. + pub idempotency_key: String, + /// Validated terminal result, present only for terminal states. + pub terminal_result: Option, +} + impl AnalysisRunRequest { /// Parse and validate a JSON analysis-run request with default size limit. /// @@ -141,6 +175,123 @@ impl AnalysisRunAccepted { } } +impl AnalysisRunStatus { + /// Construct an accepted status from a durable receipt. + /// + /// # Errors + /// + /// Returns a fail-closed error when the receipt is invalid. + pub fn accepted(accepted: &AnalysisRunAccepted) -> Result { + Self::new(accepted, AnalysisRunStatusState::Accepted, None) + } + + /// Construct a running status from a durable receipt. + /// + /// # Errors + /// + /// Returns a fail-closed error when the receipt is invalid. + pub fn running(accepted: &AnalysisRunAccepted) -> Result { + Self::new(accepted, AnalysisRunStatusState::Running, None) + } + + /// Construct a terminal status bound to the submitted request and receipt. + /// + /// # Errors + /// + /// Returns a fail-closed error when the result or its binding is invalid. + pub fn terminal( + request: &AnalysisRunRequest, + accepted: &AnalysisRunAccepted, + result: AnalysisRunTerminalResult, + ) -> Result { + require_terminal_binding(request, accepted, &result)?; + let state = match result.run_state { + AnalysisRunTerminalState::Succeeded => AnalysisRunStatusState::Succeeded, + AnalysisRunTerminalState::Failed => AnalysisRunStatusState::Failed, + }; + Self::new(accepted, state, Some(result)) + } + + /// Parse and validate a status/read payload with the default byte limit. + /// + /// # Errors + /// + /// Returns wire, version, limit, shape, or field-validation errors. + pub fn from_json(payload: &str) -> Result { + Self::from_json_with_limit(payload, DEFAULT_ANALYSIS_RUN_BYTE_LIMIT) + } + + /// Parse and validate a status/read payload with a caller-supplied limit. + /// + /// # Errors + /// + /// Returns wire, version, limit, shape, or field-validation errors. + pub fn from_json_with_limit(payload: &str, maximum_bytes: usize) -> Result { + require_byte_limit(payload, maximum_bytes)?; + let status: Self = from_json(payload)?; + status.validate()?; + Ok(status) + } + + /// Serialize a status/read payload after complete validation. + /// + /// # Errors + /// + /// Returns validation or serialization errors. + pub fn to_json(&self) -> Result { + self.validate()?; + to_json(self) + } + + fn new( + accepted: &AnalysisRunAccepted, + run_state: AnalysisRunStatusState, + terminal_result: Option, + ) -> Result { + accepted.validate()?; + let status = Self { + contract_version: ANALYSIS_RUN_STATUS_CONTRACT_VERSION, + run_id: accepted.run_id.clone(), + run_state, + idempotency_key: accepted.idempotency_key.clone(), + terminal_result, + }; + status.validate()?; + Ok(status) + } + + fn validate(&self) -> Result<(), ApiError> { + require_contract_version(self.contract_version, ANALYSIS_RUN_STATUS_CONTRACT_VERSION)?; + require_nonempty(&self.run_id)?; + require_nonempty(&self.idempotency_key)?; + match self.run_state { + AnalysisRunStatusState::Accepted | AnalysisRunStatusState::Running => { + if self.terminal_result.is_some() { + return Err(ApiError::InvalidWirePayload); + } + } + AnalysisRunStatusState::Succeeded | AnalysisRunStatusState::Failed => { + let result = self + .terminal_result + .as_ref() + .ok_or(ApiError::InvalidWirePayload)?; + result.validate()?; + let expected_state = match result.run_state { + AnalysisRunTerminalState::Succeeded => AnalysisRunStatusState::Succeeded, + AnalysisRunTerminalState::Failed => AnalysisRunStatusState::Failed, + }; + if expected_state != self.run_state + || result.run_id != self.run_id + || result.idempotency_key != self.idempotency_key + { + return Err(ApiError::InvalidWirePayload); + } + } + } + Ok(()) + } +} + /// Compare two requests for idempotent-retry semantic equality. #[must_use] pub fn requests_are_idempotent_matches( @@ -150,6 +301,27 @@ pub fn requests_are_idempotent_matches( left == right } +/// Require exact status identity and, for terminal states, request binding. +/// +/// # Errors +/// +/// Returns [`ApiError::InvalidWirePayload`] when the status does not match the +/// receipt or its terminal result does not match the request. +pub fn require_status_binding( + request: &AnalysisRunRequest, + accepted: &AnalysisRunAccepted, + status: &AnalysisRunStatus, +) -> Result<(), ApiError> { + status.validate()?; + if status.run_id != accepted.run_id || status.idempotency_key != accepted.idempotency_key { + return Err(ApiError::InvalidWirePayload); + } + if let Some(result) = status.terminal_result.as_ref() { + require_terminal_binding(request, accepted, result)?; + } + Ok(()) +} + #[cfg(test)] mod tests { use super::{ diff --git a/crates/tepp_api/src/lib.rs b/crates/tepp_api/src/lib.rs index d5e5c9f5b..ee4f8a8ff 100644 --- a/crates/tepp_api/src/lib.rs +++ b/crates/tepp_api/src/lib.rs @@ -38,14 +38,22 @@ pub use analysis_result::terminal_result_matches_accepted; pub use analysis_result::terminal_result_matches_request; /// Analysis-run contract version constant. pub use analysis_run::ANALYSIS_RUN_CONTRACT_VERSION; +/// Analysis-run status/read contract version constant. +pub use analysis_run::ANALYSIS_RUN_STATUS_CONTRACT_VERSION; /// Accepted analysis-run response. pub use analysis_run::AnalysisRunAccepted; /// Analysis-run create request. pub use analysis_run::AnalysisRunRequest; +/// Typed analysis-run status/read response. +pub use analysis_run::AnalysisRunStatus; +/// Analysis-run status/read lifecycle state. +pub use analysis_run::AnalysisRunStatusState; /// Default analysis-run payload byte limit. pub use analysis_run::DEFAULT_ANALYSIS_RUN_BYTE_LIMIT; /// Idempotent request equality helper. pub use analysis_run::requests_are_idempotent_matches; +/// Require exact status binding to a request and accepted receipt. +pub use analysis_run::require_status_binding; /// Content-redacting error envelope. pub use envelope::ErrorEnvelope; /// Fail-closed API errors. diff --git a/crates/tepp_api/tests/analysis_result_contract.rs b/crates/tepp_api/tests/analysis_result_contract.rs index 5c157909e..f31c0ea95 100644 --- a/crates/tepp_api/tests/analysis_result_contract.rs +++ b/crates/tepp_api/tests/analysis_result_contract.rs @@ -1,8 +1,11 @@ +//! Contract tests for request-bound terminal analysis results. + use tepp_api::{ - ANALYSIS_RESULT_CONTRACT_VERSION, ANALYSIS_RUN_CONTRACT_VERSION, AnalysisResultSummary, - AnalysisRunAccepted, AnalysisRunRequest, AnalysisRunTerminalResult, AnalysisRunTerminalState, - ApiError, DEFAULT_ANALYSIS_RESULT_BYTE_LIMIT, require_terminal_binding, - terminal_result_matches_accepted, terminal_result_matches_request, + ANALYSIS_RESULT_CONTRACT_VERSION, ANALYSIS_RUN_CONTRACT_VERSION, + ANALYSIS_RUN_STATUS_CONTRACT_VERSION, AnalysisResultSummary, AnalysisRunAccepted, + AnalysisRunRequest, AnalysisRunStatus, AnalysisRunStatusState, AnalysisRunTerminalResult, + AnalysisRunTerminalState, ApiError, DEFAULT_ANALYSIS_RESULT_BYTE_LIMIT, require_status_binding, + require_terminal_binding, terminal_result_matches_accepted, terminal_result_matches_request, }; const DIGEST: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; @@ -24,8 +27,7 @@ fn accepted() -> AnalysisRunAccepted { } fn summary() -> AnalysisResultSummary { - AnalysisResultSummary::new("temporal_topic_measurement", 120, 42, "validated") - .expect("summary") + AnalysisResultSummary::new("temporal_topic_measurement", 120, 42, "validated").expect("summary") } fn succeeded() -> AnalysisRunTerminalResult { @@ -103,10 +105,7 @@ fn wire_version_limit_extension_and_time_validation_fail_closed() { let mut value = succeeded(); value.contract_version = ANALYSIS_RESULT_CONTRACT_VERSION + 1; - assert_eq!( - value.to_json(), - Err(ApiError::UnsupportedContractVersion) - ); + assert_eq!(value.to_json(), Err(ApiError::UnsupportedContractVersion)); let mut value = succeeded(); value.knowledge_cutoff = "yesterday".into(); @@ -237,6 +236,17 @@ fn summary_is_nonempty_and_bounded_in_constructor_and_wire_shape() { #[test] fn failed_shape_refuses_measurement_fields_and_invalid_failure_codes() { let base = failed(); + let digit_code = AnalysisRunTerminalResult::failed( + &request(), + &accepted(), + "2026-08-02T03:04:05Z", + "estimation_failed_2", + ) + .expect("digits are valid failure-code characters"); + assert_eq!( + digit_code.failure_code.as_deref(), + Some("estimation_failed_2") + ); let mut value = base.clone(); value.result_artifact_id = Some("artifact".into()); @@ -290,16 +300,14 @@ fn every_request_binding_dimension_and_receipt_identity_is_checked() { ); } - let other_run = - AnalysisRunAccepted::new("other-run", "accepted", "idem-1").expect("accepted"); + let other_run = AnalysisRunAccepted::new("other-run", "accepted", "idem-1").expect("accepted"); assert!(!terminal_result_matches_accepted(&other_run, &result)); assert_eq!( require_terminal_binding(&request(), &other_run, &result), Err(ApiError::InvalidWirePayload) ); - let other_key = - AnalysisRunAccepted::new("run-1", "accepted", "other-key").expect("accepted"); + let other_key = AnalysisRunAccepted::new("run-1", "accepted", "other-key").expect("accepted"); assert!(!terminal_result_matches_accepted(&other_key, &result)); assert_eq!( require_terminal_binding(&request(), &other_key, &result), @@ -327,3 +335,133 @@ fn every_request_binding_dimension_and_receipt_identity_is_checked() { Err(ApiError::InvalidWirePayload) ); } + +#[test] +fn status_read_contract_round_trips_lifecycle_and_terminal_results() { + let accepted_status = AnalysisRunStatus::accepted(&accepted()).expect("accepted status"); + assert_eq!(accepted_status.run_state, AnalysisRunStatusState::Accepted); + assert_eq!(accepted_status.terminal_result, None); + assert_eq!( + require_status_binding(&request(), &accepted(), &accepted_status), + Ok(()) + ); + let accepted_json = accepted_status.to_json().expect("accepted json"); + assert_eq!( + AnalysisRunStatus::from_json(&accepted_json).expect("accepted decode"), + accepted_status + ); + + let running_status = AnalysisRunStatus::running(&accepted()).expect("running status"); + assert_eq!(running_status.run_state, AnalysisRunStatusState::Running); + assert_eq!( + require_status_binding(&request(), &accepted(), &running_status), + Ok(()) + ); + + for (result, expected_state) in [ + (succeeded(), AnalysisRunStatusState::Succeeded), + (failed(), AnalysisRunStatusState::Failed), + ] { + let status = + AnalysisRunStatus::terminal(&request(), &accepted(), result).expect("terminal status"); + assert_eq!(status.run_state, expected_state); + assert!(status.terminal_result.is_some()); + assert_eq!( + require_status_binding(&request(), &accepted(), &status), + Ok(()) + ); + let json = status.to_json().expect("terminal json"); + assert_eq!( + AnalysisRunStatus::from_json(&json).expect("terminal decode"), + status + ); + } + + assert_eq!( + ANALYSIS_RUN_STATUS_CONTRACT_VERSION, + ANALYSIS_RUN_CONTRACT_VERSION + ); +} + +#[test] +fn status_read_contract_rejects_unknown_oversized_and_invalid_shapes() { + let accepted_status = AnalysisRunStatus::accepted(&accepted()).expect("status"); + let mut value: serde_json::Value = + serde_json::from_str(&accepted_status.to_json().expect("json")).expect("value"); + value["extra"] = serde_json::json!(true); + assert_eq!( + AnalysisRunStatus::from_json(&value.to_string()), + Err(ApiError::InvalidWirePayload) + ); + let json = accepted_status.to_json().expect("json"); + assert_eq!( + AnalysisRunStatus::from_json_with_limit(&json, 1), + Err(ApiError::LimitExceeded) + ); + + let mut invalid = accepted_status.clone(); + invalid.contract_version = ANALYSIS_RUN_STATUS_CONTRACT_VERSION + 1; + assert_eq!(invalid.to_json(), Err(ApiError::UnsupportedContractVersion)); + invalid = accepted_status.clone(); + invalid.run_id.clear(); + assert_eq!(invalid.to_json(), Err(ApiError::InvalidWirePayload)); + invalid = accepted_status.clone(); + invalid.idempotency_key.clear(); + assert_eq!(invalid.to_json(), Err(ApiError::InvalidWirePayload)); + + let mut invalid = accepted_status.clone(); + invalid.terminal_result = Some(succeeded()); + assert_eq!(invalid.to_json(), Err(ApiError::InvalidWirePayload)); + + let terminal = + AnalysisRunStatus::terminal(&request(), &accepted(), succeeded()).expect("terminal status"); + let mut invalid = terminal.clone(); + invalid.terminal_result = None; + assert_eq!(invalid.to_json(), Err(ApiError::InvalidWirePayload)); + invalid = terminal.clone(); + invalid.run_state = AnalysisRunStatusState::Failed; + assert_eq!(invalid.to_json(), Err(ApiError::InvalidWirePayload)); + + let mut invalid = terminal.clone(); + invalid.terminal_result.as_mut().expect("result").run_id = "other".into(); + assert_eq!(invalid.to_json(), Err(ApiError::InvalidWirePayload)); + let mut invalid = terminal; + invalid + .terminal_result + .as_mut() + .expect("result") + .idempotency_key = "other".into(); + assert_eq!(invalid.to_json(), Err(ApiError::InvalidWirePayload)); +} + +#[test] +fn status_read_binding_rejects_receipt_and_request_mismatches() { + let accepted_status = AnalysisRunStatus::accepted(&accepted()).expect("status"); + let other_run = AnalysisRunAccepted::new("other-run", "accepted", "idem-1").expect("run"); + assert_eq!( + require_status_binding(&request(), &other_run, &accepted_status), + Err(ApiError::InvalidWirePayload) + ); + let other_key = AnalysisRunAccepted::new("run-1", "accepted", "other-key").expect("key"); + assert_eq!( + require_status_binding(&request(), &other_key, &accepted_status), + Err(ApiError::InvalidWirePayload) + ); + + let terminal = + AnalysisRunStatus::terminal(&request(), &accepted(), succeeded()).expect("terminal status"); + let mut other_request = request(); + other_request.snapshot_id = "other-snapshot".into(); + assert_eq!( + require_status_binding(&other_request, &accepted(), &terminal), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + AnalysisRunStatus::terminal( + &other_request, + &accepted(), + terminal.terminal_result.unwrap() + ), + Err(ApiError::InvalidWirePayload) + ); +} diff --git a/docs/API_CONTRACT.md b/docs/API_CONTRACT.md index 95a1b64fe..3977acab9 100644 --- a/docs/API_CONTRACT.md +++ b/docs/API_CONTRACT.md @@ -20,7 +20,7 @@ Current protected main exposes Rust library/domain contracts, not a production H | semantic/topic measurement API | future TEPP measurement service | naruon, batch jobs, visual analytics | accepted-target | | LLM interpretation provider port | `tepp_api` orchestration router + future HTTP gateway | contextual-orchestrator | partial | | model/artifact/export API | `tepp_api` export envelopes + future HTTP service | standalone UI/CWL consumers | partial | -| analysis-run request/accepted contracts | `tepp_api` v1 wire DTOs | naruon, orchestrator, UI | active-PR | +| analysis-run request/accepted/status/terminal-result contracts | `tepp_api` v1 wire DTOs | naruon, orchestrator, UI | active-PR #157 | ## 3. Versioning @@ -50,6 +50,14 @@ GET /v1/exports/{export_id} Long-running analysis is durable asynchronous work. `POST /v1/analysis-runs` accepts an idempotency key, immutable input snapshot identity, knowledge cutoff, versioned model contract/configuration, and requested output profile. A retry with the same principal/idempotency key and semantically identical request returns the same run identity; a conflicting body fails closed. +The typed status/read contract returns `accepted`, `running`, `succeeded`, or +`failed`. Accepted and running statuses contain no measurement result. A +terminal status contains exactly one request-bound +`AnalysisRunTerminalResult`; consumers must validate its request, receipt, +snapshot, cutoff, model, profile, and idempotency bindings before treating the +run as measurement evidence. The Rust DTO is available before the future HTTP +service is deployed. + ## 5. Analysis request authority An analysis request cannot supply arbitrary facts that bypass validated domain state. The service resolves and validates: @@ -143,4 +151,4 @@ Cross-format exports (JSON-LD, GraphML, CSV, Arrow/Parquet, SVG/PDF) must be sem ## 11. Compatibility tests -Consumer/provider contract tests must cover version negotiation, unknown fields, size/depth limits, idempotency, stale/invalid snapshot identity, future evidence, tenant/purpose denial, cancellation, retry semantics, artifact digest mismatch, and graceful handling of unsupported model/language capabilities. \ No newline at end of file +Consumer/provider contract tests must cover version negotiation, unknown fields, size/depth limits, idempotency, stale/invalid snapshot identity, future evidence, tenant/purpose denial, cancellation, retry semantics, artifact digest mismatch, and graceful handling of unsupported model/language capabilities. diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index f67396413..60d42366b 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -19,7 +19,7 @@ The full APA 7th standards/literature register remains `docs/research/standards- | recovery metrics (RMSE, bias, coverage, graph, temporal order, Monte Carlo SE gates) | PRD; Test Strategy; ADR 0007/0014 | `validation_core` on protected main (PR #19); SE-aware Monte Carlo gates included | implemented-main | | PostgreSQL bitemporal/lineage persistence | ADR 0013; Architecture/ERD | `persistence_postgres` migration contracts, in-memory adapters, live SQL session/document SQL port, tenant RLS (`0002` + session GUC/role helpers), `DATABASE_URL` SQLx gate, optional `live-sqlx` `PgPool` driver, exact-head live PostgreSQL CI with isolation proof, append-only immutability triggers (`0004`), temporal interval ordering CHECKs (`0005`), typed membership assignment (`0006` implemented-main), event-relation/mention/instance SQL (#37–#39 implemented-main), source-artifact SQL (#40 implemented-main), audit-event SQL (#41 implemented-main), concurrent document-write stress (#43 implemented-main), backup/restore integrity revalidation (active PR); remaining physical ERD constraints | partial | | known-truth temporal/event simulation manifests | PRD; TRD; Test Strategy | `tepp_simulation` on protected main; recovery metrics in `validation_core` | implemented-main | -| versioned service/API contracts and exports | PRD; API contract; ADR 0011/0013 | `tepp_api` analysis-run/export/JSON-LD/GraphML contracts on protected main (PR #21); HTTP service remaining accepted-target | partial | +| versioned service/API contracts and exports | PRD; API contract; ADR 0011/0013 | `tepp_api` analysis-run/export/JSON-LD/GraphML contracts on protected main (PR #21); request-bound terminal result and typed status/read contract active in PR #157; HTTP service remaining accepted-target | partial | | immutable split/run/reproducibility manifests | ADR 0013; ERD | `tepp_api` reproducibility manifest contract on protected main; `persistence_postgres` append-only SQL insert/lookup for `reproducibility_manifest`, `corpus_split_manifest`, `model_run`, and `model_artifact` (migration `0003`); full physical ERD constraints remaining | partial | | multilingual shared latent semantic space | PRD; ADR 0004 | future semantic/concept/topic crates | accepted-target | | TRSL-TM temporal/relational topic posterior and backend compatibility | ADR 0012; ADR 0004 | future `topic_measurement` | accepted-target | From 30918e5398c50f4069d33927d9c3612441b60a44 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 11:59:00 +0900 Subject: [PATCH 06/31] fix(api): harden analysis result serialization bindings --- crates/tepp_api/src/analysis_result.rs | 6 +- crates/tepp_api/src/analysis_run.rs | 19 ++++-- .../tests/analysis_result_contract.rs | 63 ++++++++++++++++++- 3 files changed, 80 insertions(+), 8 deletions(-) diff --git a/crates/tepp_api/src/analysis_result.rs b/crates/tepp_api/src/analysis_result.rs index fe1cc3018..c8663b2c5 100644 --- a/crates/tepp_api/src/analysis_result.rs +++ b/crates/tepp_api/src/analysis_result.rs @@ -220,7 +220,9 @@ impl AnalysisRunTerminalResult { /// Returns validation or serialization errors. pub fn to_json(&self) -> Result { self.validate()?; - to_json(self) + let payload = to_json(self)?; + require_byte_limit(&payload, DEFAULT_ANALYSIS_RESULT_BYTE_LIMIT)?; + Ok(payload) } pub(crate) fn validate(&self) -> Result<(), ApiError> { @@ -320,6 +322,8 @@ pub fn require_terminal_binding( accepted: &AnalysisRunAccepted, result: &AnalysisRunTerminalResult, ) -> Result<(), ApiError> { + request.validate()?; + accepted.validate()?; if terminal_result_matches_request(request, result) && terminal_result_matches_accepted(accepted, result) { diff --git a/crates/tepp_api/src/analysis_run.rs b/crates/tepp_api/src/analysis_run.rs index a054a0930..dd81424ec 100644 --- a/crates/tepp_api/src/analysis_run.rs +++ b/crates/tepp_api/src/analysis_run.rs @@ -109,10 +109,12 @@ impl AnalysisRunRequest { /// Returns field-validation or serialization errors. pub fn to_json(&self) -> Result { self.validate()?; - to_json(self) + let payload = to_json(self)?; + require_byte_limit(&payload, DEFAULT_ANALYSIS_RUN_BYTE_LIMIT)?; + Ok(payload) } - fn validate(&self) -> Result<(), ApiError> { + pub(crate) fn validate(&self) -> Result<(), ApiError> { require_contract_version(self.contract_version, ANALYSIS_RUN_CONTRACT_VERSION)?; require_nonempty(&self.idempotency_key)?; require_nonempty(&self.tenant_workspace_id)?; @@ -166,7 +168,7 @@ impl AnalysisRunAccepted { to_json(self) } - fn validate(&self) -> Result<(), ApiError> { + pub(crate) fn validate(&self) -> Result<(), ApiError> { require_contract_version(self.contract_version, ANALYSIS_RUN_CONTRACT_VERSION)?; require_nonempty(&self.run_id)?; require_nonempty(&self.run_state)?; @@ -240,7 +242,9 @@ impl AnalysisRunStatus { /// Returns validation or serialization errors. pub fn to_json(&self) -> Result { self.validate()?; - to_json(self) + let payload = to_json(self)?; + require_byte_limit(&payload, DEFAULT_ANALYSIS_RUN_BYTE_LIMIT)?; + Ok(payload) } fn new( @@ -312,8 +316,13 @@ pub fn require_status_binding( accepted: &AnalysisRunAccepted, status: &AnalysisRunStatus, ) -> Result<(), ApiError> { + request.validate()?; + accepted.validate()?; status.validate()?; - if status.run_id != accepted.run_id || status.idempotency_key != accepted.idempotency_key { + if request.idempotency_key != accepted.idempotency_key + || status.run_id != accepted.run_id + || status.idempotency_key != accepted.idempotency_key + { return Err(ApiError::InvalidWirePayload); } if let Some(result) = status.terminal_result.as_ref() { diff --git a/crates/tepp_api/tests/analysis_result_contract.rs b/crates/tepp_api/tests/analysis_result_contract.rs index f31c0ea95..1560efc8b 100644 --- a/crates/tepp_api/tests/analysis_result_contract.rs +++ b/crates/tepp_api/tests/analysis_result_contract.rs @@ -4,8 +4,9 @@ use tepp_api::{ ANALYSIS_RESULT_CONTRACT_VERSION, ANALYSIS_RUN_CONTRACT_VERSION, ANALYSIS_RUN_STATUS_CONTRACT_VERSION, AnalysisResultSummary, AnalysisRunAccepted, AnalysisRunRequest, AnalysisRunStatus, AnalysisRunStatusState, AnalysisRunTerminalResult, - AnalysisRunTerminalState, ApiError, DEFAULT_ANALYSIS_RESULT_BYTE_LIMIT, require_status_binding, - require_terminal_binding, terminal_result_matches_accepted, terminal_result_matches_request, + AnalysisRunTerminalState, ApiError, DEFAULT_ANALYSIS_RESULT_BYTE_LIMIT, + DEFAULT_ANALYSIS_RUN_BYTE_LIMIT, require_status_binding, require_terminal_binding, + terminal_result_matches_accepted, terminal_result_matches_request, }; const DIGEST: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; @@ -114,6 +115,29 @@ fn wire_version_limit_extension_and_time_validation_fail_closed() { let mut value = succeeded(); value.completed_at = "2026-99-99T25:00:00Z".into(); assert_eq!(value.to_json(), Err(ApiError::InvalidWirePayload)); + + // System time is distinct from the knowledge cutoff; a pre-cutoff run may + // legitimately publish a result for a historical snapshot. + let mut value = succeeded(); + value.completed_at = "2026-07-31T23:59:59Z".into(); + assert!(value.to_json().is_ok()); +} + +#[test] +fn serialization_enforces_default_result_and_status_limits() { + let mut result = succeeded(); + result.summary.as_mut().expect("summary").analysis_family = + "x".repeat(DEFAULT_ANALYSIS_RESULT_BYTE_LIMIT); + assert_eq!(result.to_json(), Err(ApiError::LimitExceeded)); + + let oversized_accepted = AnalysisRunAccepted::new( + "x".repeat(DEFAULT_ANALYSIS_RUN_BYTE_LIMIT), + "accepted", + "idem-1", + ) + .expect("accepted"); + let status = AnalysisRunStatus::accepted(&oversized_accepted).expect("status"); + assert_eq!(status.to_json(), Err(ApiError::LimitExceeded)); } #[test] @@ -334,6 +358,19 @@ fn every_request_binding_dimension_and_receipt_identity_is_checked() { ), Err(ApiError::InvalidWirePayload) ); + + let mut invalid_request = request(); + invalid_request.contract_version += 1; + assert_eq!( + require_terminal_binding(&invalid_request, &accepted(), &result), + Err(ApiError::UnsupportedContractVersion) + ); + let mut invalid_accepted = accepted(); + invalid_accepted.contract_version += 1; + assert_eq!( + require_terminal_binding(&request(), &invalid_accepted, &result), + Err(ApiError::UnsupportedContractVersion) + ); } #[test] @@ -456,6 +493,28 @@ fn status_read_binding_rejects_receipt_and_request_mismatches() { require_status_binding(&other_request, &accepted(), &terminal), Err(ApiError::InvalidWirePayload) ); + + let accepted_status = AnalysisRunStatus::accepted(&accepted()).expect("status"); + let mut other_idempotency = request(); + other_idempotency.idempotency_key = "other-key".into(); + assert_eq!( + require_status_binding(&other_idempotency, &accepted(), &accepted_status), + Err(ApiError::InvalidWirePayload) + ); + + let mut invalid_status = accepted_status.clone(); + invalid_status.idempotency_key = "other-key".into(); + assert_eq!( + require_status_binding(&request(), &accepted(), &invalid_status), + Err(ApiError::InvalidWirePayload) + ); + + let mut invalid_request = request(); + invalid_request.contract_version += 1; + assert_eq!( + require_status_binding(&invalid_request, &accepted(), &accepted_status), + Err(ApiError::UnsupportedContractVersion) + ); assert_eq!( AnalysisRunStatus::terminal( &other_request, From 020c353022fc917790c97617bb862d491ad41f8d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 18:41:24 +0900 Subject: [PATCH 07/31] fix(api): bound accepted analysis run payloads --- crates/tepp_api/src/analysis_run.rs | 14 +++++++++++++- crates/tepp_api/src/wire.rs | 11 ++++++++--- crates/tepp_api/tests/analysis_result_contract.rs | 8 ++++++++ 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/crates/tepp_api/src/analysis_run.rs b/crates/tepp_api/src/analysis_run.rs index dd81424ec..25774902c 100644 --- a/crates/tepp_api/src/analysis_run.rs +++ b/crates/tepp_api/src/analysis_run.rs @@ -153,6 +153,16 @@ impl AnalysisRunAccepted { /// /// Returns wire, version, or field-validation errors. pub fn from_json(payload: &str) -> Result { + Self::from_json_with_limit(payload, DEFAULT_ANALYSIS_RUN_BYTE_LIMIT) + } + + /// Parse an accepted-run payload with a caller-supplied byte limit. + /// + /// # Errors + /// + /// Returns wire, version, limit, or field-validation errors. + pub fn from_json_with_limit(payload: &str, maximum_bytes: usize) -> Result { + require_byte_limit(payload, maximum_bytes)?; let accepted: Self = from_json(payload)?; accepted.validate()?; Ok(accepted) @@ -165,7 +175,9 @@ impl AnalysisRunAccepted { /// Returns validation or serialization errors. pub fn to_json(&self) -> Result { self.validate()?; - to_json(self) + let payload = to_json(self)?; + require_byte_limit(&payload, DEFAULT_ANALYSIS_RUN_BYTE_LIMIT)?; + Ok(payload) } pub(crate) fn validate(&self) -> Result<(), ApiError> { diff --git a/crates/tepp_api/src/wire.rs b/crates/tepp_api/src/wire.rs index 9ce0ef36f..7c1966c22 100644 --- a/crates/tepp_api/src/wire.rs +++ b/crates/tepp_api/src/wire.rs @@ -21,13 +21,14 @@ pub fn from_json<'de, T: Deserialize<'de>>(payload: &'de str) -> Result Result<(), ApiError> { - if value.trim().is_empty() { + if value.trim().is_empty() || value.chars().any(char::is_control) { return Err(ApiError::InvalidWirePayload); } Ok(()) @@ -91,6 +92,10 @@ mod tests { require_nonempty("tenant-a").expect("ok"); assert_eq!(require_nonempty(" "), Err(ApiError::InvalidWirePayload)); assert_eq!(require_nonempty(""), Err(ApiError::InvalidWirePayload)); + assert_eq!( + require_nonempty("tenant\u{1f}workspace"), + Err(ApiError::InvalidWirePayload) + ); require_byte_limit("abc", 3).expect("ok"); assert_eq!(require_byte_limit("abcd", 3), Err(ApiError::LimitExceeded)); require_contract_version(1, 1).expect("ok"); diff --git a/crates/tepp_api/tests/analysis_result_contract.rs b/crates/tepp_api/tests/analysis_result_contract.rs index 1560efc8b..6284b7302 100644 --- a/crates/tepp_api/tests/analysis_result_contract.rs +++ b/crates/tepp_api/tests/analysis_result_contract.rs @@ -136,6 +136,14 @@ fn serialization_enforces_default_result_and_status_limits() { "idem-1", ) .expect("accepted"); + assert_eq!(oversized_accepted.to_json(), Err(ApiError::LimitExceeded)); + assert_eq!( + AnalysisRunAccepted::from_json(&format!( + "{{\"contract_version\":1,\"run_id\":\"{}\",\"run_state\":\"accepted\",\"idempotency_key\":\"idem-1\"}}", + "x".repeat(DEFAULT_ANALYSIS_RUN_BYTE_LIMIT) + )), + Err(ApiError::LimitExceeded) + ); let status = AnalysisRunStatus::accepted(&oversized_accepted).expect("status"); assert_eq!(status.to_json(), Err(ApiError::LimitExceeded)); } From f1c94f75ee86a6526c54905b40715eb049408460 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 18:52:24 +0900 Subject: [PATCH 08/31] fix: validate terminal result bindings --- crates/tepp_api/src/analysis_result.rs | 1 + crates/tepp_api/tests/analysis_result_contract.rs | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/crates/tepp_api/src/analysis_result.rs b/crates/tepp_api/src/analysis_result.rs index c8663b2c5..b3bfca5eb 100644 --- a/crates/tepp_api/src/analysis_result.rs +++ b/crates/tepp_api/src/analysis_result.rs @@ -324,6 +324,7 @@ pub fn require_terminal_binding( ) -> Result<(), ApiError> { request.validate()?; accepted.validate()?; + result.validate()?; if terminal_result_matches_request(request, result) && terminal_result_matches_accepted(accepted, result) { diff --git a/crates/tepp_api/tests/analysis_result_contract.rs b/crates/tepp_api/tests/analysis_result_contract.rs index 6284b7302..818eb2a15 100644 --- a/crates/tepp_api/tests/analysis_result_contract.rs +++ b/crates/tepp_api/tests/analysis_result_contract.rs @@ -314,6 +314,13 @@ fn failed_shape_refuses_measurement_fields_and_invalid_failure_codes() { fn every_request_binding_dimension_and_receipt_identity_is_checked() { let result = succeeded(); + let mut tampered = result.clone(); + tampered.failure_code = Some("late_failure".into()); + assert_eq!( + require_terminal_binding(&request(), &accepted(), &tampered), + Err(ApiError::InvalidWirePayload) + ); + for index in 0..6 { let mut mismatched = request(); match index { From fb783f5d1fbc839304e9cd1f0265c277efc7258f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 20:18:14 +0900 Subject: [PATCH 09/31] test(api): cover empty https origin --- crates/tepp_api/tests/naruon_http_contract.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/tepp_api/tests/naruon_http_contract.rs b/crates/tepp_api/tests/naruon_http_contract.rs index 143277126..2c60597b5 100644 --- a/crates/tepp_api/tests/naruon_http_contract.rs +++ b/crates/tepp_api/tests/naruon_http_contract.rs @@ -57,6 +57,7 @@ fn table_access_and_non_https_origins_fail_closed() { let run = sample_run(); for origin in [ "", + "https://", "postgres://tepp.example.test/tepp", "postgresql://tepp.example.test/tepp", "jdbc:postgresql://tepp.example.test/tepp", From 0cb9ff6b32afb56849214bbe464ea39004c1e106 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 20:23:48 +0900 Subject: [PATCH 10/31] test(api): close unreachable HTTP branch --- crates/tepp_api/src/naruon_http.rs | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/crates/tepp_api/src/naruon_http.rs b/crates/tepp_api/src/naruon_http.rs index 2d2d0083c..d3f15abd0 100644 --- a/crates/tepp_api/src/naruon_http.rs +++ b/crates/tepp_api/src/naruon_http.rs @@ -128,9 +128,7 @@ fn compose_https_target(origin: &str, path: &str) -> Result { || host.contains('/') || host.contains('?') || host.contains('#') - || host - .chars() - .any(|ch| ch.is_control() || matches!(ch, '\'' | ';' | '\\' | ' ')) + || host.chars().any(|ch| matches!(ch, '\'' | ';' | '\\' | ' ')) { return Err(ApiError::InvalidWirePayload); } @@ -312,4 +310,28 @@ mod tests { Err(ApiError::InvalidWirePayload) ); } + + #[test] + fn naruon_export_exchange_covers_both_purpose_gate_arms() { + let allowed = crate::authorization::ExportAuthorizationRequest { + tenant_workspace_id: "naruon-tenant-workspace-demo".into(), + principal_id: "naruon-service".into(), + purpose: crate::authorization::AnalyticalPurpose::ModularServiceConsumer, + artifact_id: "tepp-export-demo-001".into(), + includes_source_text: false, + }; + assert!( + super::naruon_export_exchange("https://tepp.example.test", &allowed, "export-idem-001") + .is_ok() + ); + + let denied = crate::authorization::ExportAuthorizationRequest { + purpose: crate::authorization::AnalyticalPurpose::OperationalMonitoring, + ..allowed + }; + assert_eq!( + super::naruon_export_exchange("https://tepp.example.test", &denied, "export-idem-002"), + Err(ApiError::AuthorizationDenied) + ); + } } From 63a419e2b96cef3def7f26bfc0337fece88e83c2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 10:18:07 +0900 Subject: [PATCH 11/31] fix(docs): align naruon maturity with protected main --- crates/tepp_api/src/naruon_http.rs | 12 ++++++------ docs/adr/0011-standalone-modular-msa-boundary.md | 2 +- docs/connectors/naruon-artifact-consumer.md | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/crates/tepp_api/src/naruon_http.rs b/crates/tepp_api/src/naruon_http.rs index 090202ba0..51530ec19 100644 --- a/crates/tepp_api/src/naruon_http.rs +++ b/crates/tepp_api/src/naruon_http.rs @@ -356,24 +356,24 @@ mod tests { #[test] fn naruon_export_exchange_covers_both_purpose_gate_arms() { - let allowed = crate::authorization::ExportAuthorizationRequest { + let allowed = ExportAuthorizationRequest { tenant_workspace_id: "naruon-tenant-workspace-demo".into(), principal_id: "naruon-service".into(), - purpose: crate::authorization::AnalyticalPurpose::ModularServiceConsumer, + purpose: AnalyticalPurpose::ModularServiceConsumer, artifact_id: "tepp-export-demo-001".into(), includes_source_text: false, }; assert!( - super::naruon_export_exchange("https://tepp.example.test", &allowed, "export-idem-001") + naruon_export_exchange("https://tepp.example.test", &allowed, "export-idem-001") .is_ok() ); - let denied = crate::authorization::ExportAuthorizationRequest { - purpose: crate::authorization::AnalyticalPurpose::OperationalMonitoring, + let denied = ExportAuthorizationRequest { + purpose: AnalyticalPurpose::OperationalMonitoring, ..allowed }; assert_eq!( - super::naruon_export_exchange("https://tepp.example.test", &denied, "export-idem-002"), + naruon_export_exchange("https://tepp.example.test", &denied, "export-idem-002"), Err(ApiError::AuthorizationDenied) ); } diff --git a/docs/adr/0011-standalone-modular-msa-boundary.md b/docs/adr/0011-standalone-modular-msa-boundary.md index 04181fb38..365dc07d5 100644 --- a/docs/adr/0011-standalone-modular-msa-boundary.md +++ b/docs/adr/0011-standalone-modular-msa-boundary.md @@ -1,7 +1,7 @@ # ADR 0011 — Standalone operation and modular CWL MSA boundary **Decision status:** Accepted -**Implementation maturity:** partial — Rust crates are independently usable; naruon HTTP interchange and loopback live listener (`POST /v1/analysis-runs` and `/v1/exports`, fail-closed table-access, NIM/proxy headers, RFC 3339 cutoff, stream deadline) are on the active PR (not implemented-main); production TLS/`$PORT` and remaining persistence integrations remain accepted-target +**Implementation maturity:** partial — Rust crates are independently usable; naruon HTTP interchange and loopback live listener (`POST /v1/analysis-runs` and `/v1/exports`, fail-closed table-access, NIM/proxy headers, RFC 3339 cutoff, stream deadline) are implemented-main at protected head `c45be17a9dbce95ef81cee230e9d128abc7160ac`; production TLS/`$PORT` and remaining persistence integrations remain accepted-target **Date:** 2026-08-10 **Supersedes:** The broad cross-service ownership wording in ADR 0001. ADR 0001 remains authoritative for Rust-first numerical architecture. diff --git a/docs/connectors/naruon-artifact-consumer.md b/docs/connectors/naruon-artifact-consumer.md index 5fe0424c4..266457ea7 100644 --- a/docs/connectors/naruon-artifact-consumer.md +++ b/docs/connectors/naruon-artifact-consumer.md @@ -1,6 +1,6 @@ # naruon modular consumer contract for TEPP artifacts -**Status:** Partial — versioned DTO, HTTP interchange, and loopback live listener on the active PR; production TLS/`$PORT` remaining +**Status:** Partial — versioned DTO, HTTP interchange, and loopback live listener are implemented-main at protected head `c45be17a9dbce95ef81cee230e9d128abc7160ac`; production TLS/`$PORT` remaining **Last reviewed:** 2026-08-16 ## Boundary From 7625dc6fc5711f63b7dfdd1f6758a1e73678954c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 18:20:04 +0900 Subject: [PATCH 12/31] fix(api): harden accepted receipts and provider headers --- CHANGELOG.md | 2 +- crates/tepp_api/src/analysis_run.rs | 4 +- crates/tepp_api/src/naruon_http.rs | 60 ++++++++++++++++++- .../tests/analysis_result_contract.rs | 12 ++++ crates/tepp_api/tests/naruon_http_contract.rs | 36 +++++++++++ .../0011-standalone-modular-msa-boundary.md | 2 +- docs/connectors/naruon-artifact-consumer.md | 2 +- 7 files changed, 113 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 795a48908..1c034a546 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ All notable changes to TEPP are documented here. The format follows Keep a Chang - `tepp_api` purpose-bound provider-payload minimization: time-bounded `PurposeGrant` evaluation, fail-closed expired/not-yet-valid/inverted/cross-tenant/impossible-calendar denial, semantic UTC calendar validation, refusal to copy identity mappings into model-provider payloads or ordinary logs, preservation of opaque analytical identifiers and membership roles (no blanket PII mask), a separately authorized scientific re-identification path, and an internally bound FIPS 180-4 SHA-256 audit digest appended through `ReidentificationAuditSink` before disclosure. - `persistence_postgres` backup/restore integrity: restored snapshots stay unusable until tenant, canonical `SHA-256`, knowledge-cutoff eligibility, temporal window order, and append-only triggers revalidate; SQL probes raise `restore integrity failed` (ADR 0013). - `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). +- `tepp_api` naruon HTTP interchange: versioned `https` POST contracts for analysis-run create and modular export authorization that refuse table-access URLs, provider-specific API-key/secret and review/Copilot credential headers, malformed extra HTTP fields, reserved standard-header redefinition, principal-only export idempotency keys, and lexical inference claims (ADR 0011). - `persistence_postgres` audit-event SQL contracts: append-only insert that refuses empty, oversized, or hostile `action_code` values before SQL is rendered. - `persistence_postgres` event-instance SQL contracts: bitemporal insert and as-known-at lookup that refuse inverted valid/system windows and hostile type/lifecycle labels before SQL is rendered. - `persistence_postgres` event-mention SQL contracts: mention identity cannot equal the instance it supports; confidence must be finite and in `(0, 1]`. diff --git a/crates/tepp_api/src/analysis_run.rs b/crates/tepp_api/src/analysis_run.rs index 8ab226b1d..e061e0cb8 100644 --- a/crates/tepp_api/src/analysis_run.rs +++ b/crates/tepp_api/src/analysis_run.rs @@ -201,7 +201,9 @@ impl AnalysisRunAccepted { pub(crate) fn validate(&self) -> Result<(), ApiError> { require_contract_version(self.contract_version, ANALYSIS_RUN_CONTRACT_VERSION)?; require_nonempty(&self.run_id)?; - require_nonempty(&self.run_state)?; + if self.run_state != "accepted" { + return Err(ApiError::InvalidWirePayload); + } require_nonempty(&self.idempotency_key)?; Ok(()) } diff --git a/crates/tepp_api/src/naruon_http.rs b/crates/tepp_api/src/naruon_http.rs index 51530ec19..1acd5bedf 100644 --- a/crates/tepp_api/src/naruon_http.rs +++ b/crates/tepp_api/src/naruon_http.rs @@ -155,6 +155,14 @@ pub(crate) fn header_is_credential(name: &str) -> bool { || lowered == "proxy-authorization" || lowered == "cookie" || lowered == "x-api-key" + || lowered.contains("api-key") + || lowered.contains("api_key") + || lowered.contains("secret") + || lowered.contains("credential") + || lowered.contains("openai") + || lowered.contains("anthropic") + || lowered.contains("bytez") + || lowered.contains("openrouter") || lowered.contains("token") || lowered.contains("copilot") || lowered.contains("github") @@ -163,7 +171,10 @@ pub(crate) fn header_is_credential(name: &str) -> bool { } fn refuse_credential_headers(extra_headers: &[(&str, &str)]) -> Result<(), ApiError> { - for (name, _) in extra_headers { + for (name, value) in extra_headers { + if !is_http_field_name(name) || value.chars().any(char::is_control) { + return Err(ApiError::InvalidWirePayload); + } if header_is_reserved_standard(name) { return Err(ApiError::InvalidWirePayload); } @@ -174,6 +185,30 @@ fn refuse_credential_headers(extra_headers: &[(&str, &str)]) -> Result<(), ApiEr Ok(()) } +fn is_http_field_name(name: &str) -> bool { + !name.is_empty() + && name.bytes().all(|byte| { + byte.is_ascii_alphanumeric() + || matches!( + byte, + b'!' | b'#' + | b'$' + | b'%' + | b'&' + | b'\'' + | b'*' + | b'+' + | b'-' + | b'.' + | b'^' + | b'_' + | b'`' + | b'|' + | b'~' + ) + }) +} + fn standard_headers(idempotency_key: &str) -> Vec<(String, String)> { vec![ ("content-type".into(), "application/json".into()), @@ -316,6 +351,29 @@ mod tests { refuse_credential_headers(&[("x-nvidia-nim-key", "nvapi-x")]), Err(ApiError::AuthorizationDenied) ); + for name in [ + "x-openai-api-key", + "x-anthropic-key", + "x-bytez-api-key", + "x-openrouter-api-key", + ] { + assert_eq!( + refuse_credential_headers(&[(name, "provider-secret")]), + Err(ApiError::AuthorizationDenied), + "header={name}" + ); + } + for (name, value) in [ + ("", "value"), + ("bad name", "value"), + ("x-trace", "ok\r\nx-injected: 1"), + ] { + assert_eq!( + refuse_credential_headers(&[(name, value)]), + Err(ApiError::InvalidWirePayload), + "header={name:?}" + ); + } } #[test] diff --git a/crates/tepp_api/tests/analysis_result_contract.rs b/crates/tepp_api/tests/analysis_result_contract.rs index 818eb2a15..a3667affd 100644 --- a/crates/tepp_api/tests/analysis_result_contract.rs +++ b/crates/tepp_api/tests/analysis_result_contract.rs @@ -27,6 +27,18 @@ fn accepted() -> AnalysisRunAccepted { AnalysisRunAccepted::new("run-1", "accepted", "idem-1").expect("accepted") } +#[test] +fn accepted_receipt_rejects_non_accepted_lifecycle_states() { + assert_eq!( + AnalysisRunAccepted::new("run-1", "running", "idem-1"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + AnalysisRunAccepted::new("run-1", "failed", "idem-1"), + Err(ApiError::InvalidWirePayload) + ); +} + fn summary() -> AnalysisResultSummary { AnalysisResultSummary::new("temporal_topic_measurement", 120, 42, "validated").expect("summary") } diff --git a/crates/tepp_api/tests/naruon_http_contract.rs b/crates/tepp_api/tests/naruon_http_contract.rs index 1878285da..5b4628cab 100644 --- a/crates/tepp_api/tests/naruon_http_contract.rs +++ b/crates/tepp_api/tests/naruon_http_contract.rs @@ -120,6 +120,42 @@ fn review_and_copilot_headers_are_authorization_denied() { ), Err(ApiError::AuthorizationDenied) ); + for name in [ + "x-openai-api-key", + "x-anthropic-key", + "x-bytez-api-key", + "x-openrouter-api-key", + ] { + assert_eq!( + naruon_analysis_run_exchange_with_headers( + "https://tepp.example.test", + &run, + &[(name, "provider-secret")] + ), + Err(ApiError::AuthorizationDenied), + "header={name}" + ); + } +} + +#[test] +fn malformed_extra_headers_fail_closed_before_forwarding() { + let run = sample_run(); + for (name, value) in [ + ("", "value"), + ("bad name", "value"), + ("x-trace", "ok\r\nx-injected: 1"), + ] { + assert_eq!( + naruon_analysis_run_exchange_with_headers( + "https://tepp.example.test", + &run, + &[(name, value)] + ), + Err(ApiError::InvalidWirePayload), + "header={name:?}" + ); + } } #[test] diff --git a/docs/adr/0011-standalone-modular-msa-boundary.md b/docs/adr/0011-standalone-modular-msa-boundary.md index 365dc07d5..b1be5d37a 100644 --- a/docs/adr/0011-standalone-modular-msa-boundary.md +++ b/docs/adr/0011-standalone-modular-msa-boundary.md @@ -1,7 +1,7 @@ # ADR 0011 — Standalone operation and modular CWL MSA boundary **Decision status:** Accepted -**Implementation maturity:** partial — Rust crates are independently usable; naruon HTTP interchange and loopback live listener (`POST /v1/analysis-runs` and `/v1/exports`, fail-closed table-access, NIM/proxy headers, RFC 3339 cutoff, stream deadline) are implemented-main at protected head `c45be17a9dbce95ef81cee230e9d128abc7160ac`; production TLS/`$PORT` and remaining persistence integrations remain accepted-target +**Implementation maturity:** partial — Rust crates are independently usable; naruon HTTP interchange is implemented-main at protected head `c45be17a9dbce95ef81cee230e9d128abc7160ac`, while the loopback live listener (`POST /v1/analysis-runs` and `/v1/exports`, fail-closed table-access, NIM/proxy headers, RFC 3339 cutoff, stream deadline) is active-PR #157; production TLS/`$PORT` and remaining persistence integrations remain accepted-target **Date:** 2026-08-10 **Supersedes:** The broad cross-service ownership wording in ADR 0001. ADR 0001 remains authoritative for Rust-first numerical architecture. diff --git a/docs/connectors/naruon-artifact-consumer.md b/docs/connectors/naruon-artifact-consumer.md index 266457ea7..30a08ae63 100644 --- a/docs/connectors/naruon-artifact-consumer.md +++ b/docs/connectors/naruon-artifact-consumer.md @@ -1,6 +1,6 @@ # naruon modular consumer contract for TEPP artifacts -**Status:** Partial — versioned DTO, HTTP interchange, and loopback live listener are implemented-main at protected head `c45be17a9dbce95ef81cee230e9d128abc7160ac`; production TLS/`$PORT` remaining +**Status:** Partial — versioned DTO and HTTP interchange are implemented-main at protected head `c45be17a9dbce95ef81cee230e9d128abc7160ac`; the loopback live listener is active-PR #157; production TLS/`$PORT` remaining **Last reviewed:** 2026-08-16 ## Boundary From 6852e9db004f1c5dfbdcf5362d8d8cfb497f8f23 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 21:18:02 +0900 Subject: [PATCH 13/31] fix: harden analysis result contract boundaries --- CHANGELOG.md | 6 +++++ crates/tepp_api/src/analysis_result.rs | 6 ++--- crates/tepp_api/src/analysis_run.rs | 8 +++++- crates/tepp_api/src/naruon_http.rs | 24 ------------------ .../tests/analysis_result_contract.rs | 25 +++++++++++++++++-- scripts/check_coverage.py | 4 +++ tests/quality/test_check_coverage.py | 2 ++ 7 files changed, 45 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c034a546..2ba4b922a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ All notable changes to TEPP are documented here. The format follows Keep a Chang ### Added +- `tepp_api` fail-closed analysis-result boundaries: status constructors reject + terminal envelopes that cannot fit the default 64 KiB status limit, and + standalone terminal results reject knowledge cutoffs in the future. - `tepp_api` request-bound terminal analysis results and typed analysis-run status/read responses: accepted/running states cannot carry measurement evidence, terminal results bind exact request and receipt identities, and succeeded/failed payloads remain digest-bound or content-redacted. - `tepp_api` naruon live loopback HTTP/1.1 listener: `serve_one` installs a read/write deadline, requires a loopback `Host`, refuses `Transfer-Encoding` and NIM/proxy credential headers, parses `knowledge_cutoff` as RFC 3339 and refuses a future cutoff, keys analysis-run idempotency by tenant plus key, and proves both analysis-run and export POSTs over a real `TcpStream`. Not a production TLS/`$PORT` service (ADR 0011). - `tepp_api` adaptive orchestration router (ADR 0010): versioned `direct`/`verify`/`committee`/`conductor`/`abstain` selection from CPU `f64` risk, ambiguity, evidence, and token-budget inputs; recorded stages, recursion, decomposition, access lists, and role-specific reasoning effort; fail-closed document-controlled policy/access/credentials; LLM plans remain proposals under deterministic statistical authority; comparable-budget ablation requires a direct baseline; credential-free contextual-orchestrator binding. Live NIM HTTP remains accepted-target. @@ -76,6 +79,9 @@ All notable changes to TEPP are documented here. The format follows Keep a Chang ### Changed +- Rust LCOV quality gating now ignores visibility-qualified function signatures + and structural match-arm labels that LLVM reports as zero-hit non-executable + lines. - Clarified ADR 0001 so it owns Rust-first numerical/reference-backend authority while ADR 0011 owns cross-service MSA/service authority. - Clarified ADR 0006 so it owns GPU/VRAM and model-credential boundaries; ADR 0010 now owns LLM orchestration policy and ADR 0015 owns autonomous repository-write/review/merge authority. - Expanded ADR 0002–0005 and 0009–0011 with explicit implementation maturity, alternatives, failure/recovery, compatibility/migration, verification, and rollback/supersession boundaries where they were previously implicit. diff --git a/crates/tepp_api/src/analysis_result.rs b/crates/tepp_api/src/analysis_result.rs index b3bfca5eb..61bd13db3 100644 --- a/crates/tepp_api/src/analysis_result.rs +++ b/crates/tepp_api/src/analysis_result.rs @@ -5,12 +5,13 @@ //! distinct, request-bound terminal result with a digest-bound artifact or a //! redacted failure code. +use crate::analysis_run::require_rfc3339_knowledge_cutoff; use crate::wire::{ from_json, require_byte_limit, require_contract_version, require_nonempty, to_json, }; use crate::{AnalysisRunAccepted, AnalysisRunRequest, ApiError}; use serde::{Deserialize, Serialize}; -use temporal_core::{KnowledgeCutoff, SystemTime}; +use temporal_core::SystemTime; /// Supported terminal analysis-result contract version. pub const ANALYSIS_RESULT_CONTRACT_VERSION: u16 = 1; @@ -239,8 +240,7 @@ impl AnalysisRunTerminalResult { ] { require_nonempty(value)?; } - KnowledgeCutoff::parse_rfc3339(&self.knowledge_cutoff) - .map_err(|_| ApiError::InvalidWirePayload)?; + require_rfc3339_knowledge_cutoff(&self.knowledge_cutoff)?; SystemTime::parse_rfc3339(&self.completed_at).map_err(|_| ApiError::InvalidWirePayload)?; match self.run_state { diff --git a/crates/tepp_api/src/analysis_run.rs b/crates/tepp_api/src/analysis_run.rs index e061e0cb8..2dd80374b 100644 --- a/crates/tepp_api/src/analysis_run.rs +++ b/crates/tepp_api/src/analysis_run.rs @@ -132,7 +132,7 @@ impl AnalysisRunRequest { /// /// A buyer cannot claim analysis of evidence that is not yet available. The /// request receipt instant is treated as availability of the command itself. -fn require_rfc3339_knowledge_cutoff(knowledge_cutoff: &str) -> Result<(), ApiError> { +pub(crate) fn require_rfc3339_knowledge_cutoff(knowledge_cutoff: &str) -> Result<(), ApiError> { require_nonempty(knowledge_cutoff)?; let cutoff = KnowledgeCutoff::parse_rfc3339(knowledge_cutoff) .map_err(|_| ApiError::InvalidWirePayload)?; @@ -293,9 +293,15 @@ impl AnalysisRunStatus { terminal_result, }; status.validate()?; + status.require_serialized_size()?; Ok(status) } + fn require_serialized_size(&self) -> Result<(), ApiError> { + let payload = to_json(self)?; + require_byte_limit(&payload, DEFAULT_ANALYSIS_RUN_BYTE_LIMIT) + } + fn validate(&self) -> Result<(), ApiError> { require_contract_version(self.contract_version, ANALYSIS_RUN_STATUS_CONTRACT_VERSION)?; require_nonempty(&self.run_id)?; diff --git a/crates/tepp_api/src/naruon_http.rs b/crates/tepp_api/src/naruon_http.rs index 1acd5bedf..11d037565 100644 --- a/crates/tepp_api/src/naruon_http.rs +++ b/crates/tepp_api/src/naruon_http.rs @@ -411,28 +411,4 @@ mod tests { Err(ApiError::InvalidWirePayload) ); } - - #[test] - fn naruon_export_exchange_covers_both_purpose_gate_arms() { - let allowed = ExportAuthorizationRequest { - tenant_workspace_id: "naruon-tenant-workspace-demo".into(), - principal_id: "naruon-service".into(), - purpose: AnalyticalPurpose::ModularServiceConsumer, - artifact_id: "tepp-export-demo-001".into(), - includes_source_text: false, - }; - assert!( - naruon_export_exchange("https://tepp.example.test", &allowed, "export-idem-001") - .is_ok() - ); - - let denied = ExportAuthorizationRequest { - purpose: AnalyticalPurpose::OperationalMonitoring, - ..allowed - }; - assert_eq!( - naruon_export_exchange("https://tepp.example.test", &denied, "export-idem-002"), - Err(ApiError::AuthorizationDenied) - ); - } } diff --git a/crates/tepp_api/tests/analysis_result_contract.rs b/crates/tepp_api/tests/analysis_result_contract.rs index a3667affd..c18e536e3 100644 --- a/crates/tepp_api/tests/analysis_result_contract.rs +++ b/crates/tepp_api/tests/analysis_result_contract.rs @@ -124,6 +124,10 @@ fn wire_version_limit_extension_and_time_validation_fail_closed() { value.knowledge_cutoff = "yesterday".into(); assert_eq!(value.to_json(), Err(ApiError::InvalidWirePayload)); + let mut value = succeeded(); + value.knowledge_cutoff = "2099-01-01T00:00:00Z".into(); + assert_eq!(value.to_json(), Err(ApiError::InvalidWirePayload)); + let mut value = succeeded(); value.completed_at = "2026-99-99T25:00:00Z".into(); assert_eq!(value.to_json(), Err(ApiError::InvalidWirePayload)); @@ -156,8 +160,25 @@ fn serialization_enforces_default_result_and_status_limits() { )), Err(ApiError::LimitExceeded) ); - let status = AnalysisRunStatus::accepted(&oversized_accepted).expect("status"); - assert_eq!(status.to_json(), Err(ApiError::LimitExceeded)); + assert_eq!( + AnalysisRunStatus::accepted(&oversized_accepted), + Err(ApiError::LimitExceeded) + ); + + let mut near_limit_result = succeeded(); + let initial_size = near_limit_result.to_json().expect("initial result").len(); + near_limit_result + .summary + .as_mut() + .expect("summary") + .analysis_family + .push_str(&"x".repeat(DEFAULT_ANALYSIS_RESULT_BYTE_LIMIT - 1 - initial_size)); + let near_limit_json = near_limit_result.to_json().expect("near-limit result"); + assert!(near_limit_json.len() < DEFAULT_ANALYSIS_RESULT_BYTE_LIMIT); + assert_eq!( + AnalysisRunStatus::terminal(&request(), &accepted(), near_limit_result), + Err(ApiError::LimitExceeded) + ); } #[test] diff --git a/scripts/check_coverage.py b/scripts/check_coverage.py index 502346350..bb506995e 100644 --- a/scripts/check_coverage.py +++ b/scripts/check_coverage.py @@ -94,6 +94,10 @@ def is_executable_source_line( return False if text.startswith("pub fn ") or text.startswith("fn "): return False + if text.startswith("pub(crate) fn "): + return False + if text.endswith("=> {"): + return False if text.startswith("pub struct ") or text.startswith("struct "): return False if text.startswith("pub enum ") or text.startswith("enum "): diff --git a/tests/quality/test_check_coverage.py b/tests/quality/test_check_coverage.py index a43373973..0fb6bcaca 100644 --- a/tests/quality/test_check_coverage.py +++ b/tests/quality/test_check_coverage.py @@ -321,6 +321,8 @@ def test_executable_source_line_filters_noise_records(self) -> None: " }", # 55 "}", # 56 " executable_statement();", # 57 executable + "pub(crate) fn crate_visible() {", # 58 visibility-qualified fn + "State::Accepted => {", # 59 match-arm structure ] source.write_text("\n".join(source_lines) + "\n", encoding="utf-8") path = str(source) From 95add88911b6b952839d03e373ea65114b6fb810 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 23:33:55 +0900 Subject: [PATCH 14/31] test: close coverage and match guarded arms --- crates/tepp_api/tests/naruon_http_contract.rs | 6 ++++++ scripts/check_coverage.py | 4 +++- tests/quality/test_check_coverage.py | 3 ++- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/crates/tepp_api/tests/naruon_http_contract.rs b/crates/tepp_api/tests/naruon_http_contract.rs index 5b4628cab..ab29e96fb 100644 --- a/crates/tepp_api/tests/naruon_http_contract.rs +++ b/crates/tepp_api/tests/naruon_http_contract.rs @@ -125,6 +125,12 @@ fn review_and_copilot_headers_are_authorization_denied() { "x-anthropic-key", "x-bytez-api-key", "x-openrouter-api-key", + "x_api_key", + "x-secret", + "x-credential", + "x_openai", + "x_bytez", + "x_openrouter", ] { assert_eq!( naruon_analysis_run_exchange_with_headers( diff --git a/scripts/check_coverage.py b/scripts/check_coverage.py index bb506995e..cf61f4877 100644 --- a/scripts/check_coverage.py +++ b/scripts/check_coverage.py @@ -96,7 +96,9 @@ def is_executable_source_line( return False if text.startswith("pub(crate) fn "): return False - if text.endswith("=> {"): + # Keep guarded match arms in the authored-line denominator: the guard + # executes even though the arm label itself is structural. + if text.endswith("=> {") and " if " not in text: return False if text.startswith("pub struct ") or text.startswith("struct "): return False diff --git a/tests/quality/test_check_coverage.py b/tests/quality/test_check_coverage.py index 0fb6bcaca..76bd07664 100644 --- a/tests/quality/test_check_coverage.py +++ b/tests/quality/test_check_coverage.py @@ -323,6 +323,7 @@ def test_executable_source_line_filters_noise_records(self) -> None: " executable_statement();", # 57 executable "pub(crate) fn crate_visible() {", # 58 visibility-qualified fn "State::Accepted => {", # 59 match-arm structure + "State::Guarded(value) if valid(value) => {", # 60 guarded arm is executable ] source.write_text("\n".join(source_lines) + "\n", encoding="utf-8") path = str(source) @@ -337,7 +338,7 @@ def test_executable_source_line_filters_noise_records(self) -> None: coverage_contract.is_executable_source_line(path, len(source_lines) + 5) ) - expected_executable = {13, 40, 44, 57} + expected_executable = {13, 40, 44, 57, 60} for line_number in range(1, len(source_lines) + 1): is_exec = coverage_contract.is_executable_source_line(path, line_number) if line_number in expected_executable: From e3770a67150dc23c7faca2a720681bb5049a3acd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 23:36:08 +0900 Subject: [PATCH 15/31] test: cover provider credential header branches --- crates/tepp_api/src/naruon_http.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/tepp_api/src/naruon_http.rs b/crates/tepp_api/src/naruon_http.rs index 11d037565..c33198ef7 100644 --- a/crates/tepp_api/src/naruon_http.rs +++ b/crates/tepp_api/src/naruon_http.rs @@ -356,6 +356,12 @@ mod tests { "x-anthropic-key", "x-bytez-api-key", "x-openrouter-api-key", + "x-provider-api_key", + "x-provider-secret", + "x-provider-credential", + "x-provider-openai", + "x-provider-bytez", + "x-provider-openrouter", ] { assert_eq!( refuse_credential_headers(&[(name, "provider-secret")]), From 910a54e314de7688d8f2dd4d48e7306fc54866b9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 23:50:36 +0900 Subject: [PATCH 16/31] fix(api): reject delimiter-free credential headers --- crates/tepp_api/src/naruon_http.rs | 1 + crates/tepp_api/tests/naruon_http_contract.rs | 1 + 2 files changed, 2 insertions(+) diff --git a/crates/tepp_api/src/naruon_http.rs b/crates/tepp_api/src/naruon_http.rs index c33198ef7..b0dec3244 100644 --- a/crates/tepp_api/src/naruon_http.rs +++ b/crates/tepp_api/src/naruon_http.rs @@ -157,6 +157,7 @@ pub(crate) fn header_is_credential(name: &str) -> bool { || lowered == "x-api-key" || lowered.contains("api-key") || lowered.contains("api_key") + || lowered.contains("apikey") || lowered.contains("secret") || lowered.contains("credential") || lowered.contains("openai") diff --git a/crates/tepp_api/tests/naruon_http_contract.rs b/crates/tepp_api/tests/naruon_http_contract.rs index ab29e96fb..1623e5bdc 100644 --- a/crates/tepp_api/tests/naruon_http_contract.rs +++ b/crates/tepp_api/tests/naruon_http_contract.rs @@ -125,6 +125,7 @@ fn review_and_copilot_headers_are_authorization_denied() { "x-anthropic-key", "x-bytez-api-key", "x-openrouter-api-key", + "x-apikey", "x_api_key", "x-secret", "x-credential", From efd53861fe112aa772ec4921f625ee74b0a3c5ed Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 00:28:00 +0900 Subject: [PATCH 17/31] test: configure repository root for pytest --- pytest.ini | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 pytest.ini diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 000000000..a635c5c03 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +pythonpath = . From 48643e58dc41429f14e3f97507ab3e8ee187083c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 00:37:28 +0900 Subject: [PATCH 18/31] fix(coverage): preserve multiline match guards --- CHANGELOG.md | 1 + scripts/check_coverage.py | 14 +++++++++++++- tests/quality/test_check_coverage.py | 22 ++++++++++++++++++++++ 3 files changed, 36 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ba4b922a..e0ea8255a 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 +- Coverage classification preserves the final expression line of multiline Rust `match` guards, keeping the 100% authored-line gate conservative. - `tepp_api` fail-closed analysis-result boundaries: status constructors reject terminal envelopes that cannot fit the default 64 KiB status limit, and standalone terminal results reject knowledge cutoffs in the future. diff --git a/scripts/check_coverage.py b/scripts/check_coverage.py index cf61f4877..a5f4d5350 100644 --- a/scripts/check_coverage.py +++ b/scripts/check_coverage.py @@ -99,7 +99,7 @@ def is_executable_source_line( # Keep guarded match arms in the authored-line denominator: the guard # executes even though the arm label itself is structural. if text.endswith("=> {") and " if " not in text: - return False + return _is_multiline_match_guard(lines, line_number) if text.startswith("pub struct ") or text.startswith("struct "): return False if text.startswith("pub enum ") or text.startswith("enum "): @@ -111,6 +111,18 @@ def is_executable_source_line( return True +def _is_multiline_match_guard(lines: list[str], line_number: int) -> bool: + """Recognize a guard continued onto the lines immediately before an arm.""" + + for candidate in reversed(lines[max(0, line_number - 32) : line_number - 1]): + stripped = candidate.strip() + if "=>" in stripped: + return False + if stripped.startswith("if ") or stripped.startswith("if("): + return True + return False + + def _cfg_test_module_line_numbers(lines: list[str]) -> set[int]: """Return line numbers belonging to any ``#[cfg(test)] mod ... { ... }`` block.""" diff --git a/tests/quality/test_check_coverage.py b/tests/quality/test_check_coverage.py index 76bd07664..d82b60d1c 100644 --- a/tests/quality/test_check_coverage.py +++ b/tests/quality/test_check_coverage.py @@ -405,6 +405,28 @@ def test_lcov_rejects_source_paths_outside_repository(self) -> None: if outside.exists(): outside.unlink() + def test_multiline_guard_arm_is_executable(self) -> None: + """Retain the final expression of a multiline Rust match guard.""" + + with tempfile.TemporaryDirectory() as temporary: + source = Path(temporary) / "multiline_guard.rs" + source.write_text( + "match state {\n" + " State::Ready(value)\n" + " if value.is_valid()\n" + " && value.is_fresh() => {\n" + " consume(value);\n" + " }\n" + " _ => {\n" + " ignore(value);\n" + " }\n" + "}\n", + encoding="utf-8", + ) + + self.assertTrue(coverage_contract.is_executable_source_line(str(source), 4)) + self.assertFalse(coverage_contract.is_executable_source_line(str(source), 7)) + def test_cfg_test_and_not_feature_block_helpers(self) -> None: """cfg(test) modules and cfg(not(feature)) blocks are fully recognized.""" From a9a49d3ea3f7062b609553f3f0579acaee599931 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 00:46:33 +0900 Subject: [PATCH 19/31] fix(coverage): respect match arm boundaries --- CHANGELOG.md | 2 +- scripts/check_coverage.py | 2 ++ tests/quality/test_check_coverage.py | 50 ++++++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e0ea8255a..d4f0c7c05 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 -- Coverage classification preserves the final expression line of multiline Rust `match` guards, keeping the 100% authored-line gate conservative. +- Coverage classification preserves the final expression line of multiline Rust `match` guards while respecting preceding-arm boundaries, keeping the 100% authored-line gate conservative. - `tepp_api` fail-closed analysis-result boundaries: status constructors reject terminal envelopes that cannot fit the default 64 KiB status limit, and standalone terminal results reject knowledge cutoffs in the future. diff --git a/scripts/check_coverage.py b/scripts/check_coverage.py index a5f4d5350..ae5724043 100644 --- a/scripts/check_coverage.py +++ b/scripts/check_coverage.py @@ -116,6 +116,8 @@ def _is_multiline_match_guard(lines: list[str], line_number: int) -> bool: for candidate in reversed(lines[max(0, line_number - 32) : line_number - 1]): stripped = candidate.strip() + if stripped.startswith("}"): + return False if "=>" in stripped: return False if stripped.startswith("if ") or stripped.startswith("if("): diff --git a/tests/quality/test_check_coverage.py b/tests/quality/test_check_coverage.py index d82b60d1c..d1b96ea77 100644 --- a/tests/quality/test_check_coverage.py +++ b/tests/quality/test_check_coverage.py @@ -427,6 +427,56 @@ def test_multiline_guard_arm_is_executable(self) -> None: self.assertTrue(coverage_contract.is_executable_source_line(str(source), 4)) self.assertFalse(coverage_contract.is_executable_source_line(str(source), 7)) + def test_previous_arm_body_does_not_make_next_label_executable(self) -> None: + """Do not treat an ``if`` inside the preceding arm as a guard.""" + + with tempfile.TemporaryDirectory() as temporary: + source = Path(temporary) / "previous_arm.rs" + source.write_text( + "match state {\n" + " State::Previous => {\n" + " if value.is_valid() {\n" + " consume(value);\n" + " }\n" + " }\n" + " State::Current => {\n" + " consume(value);\n" + " }\n" + "}\n", + encoding="utf-8", + ) + + self.assertFalse(coverage_contract.is_executable_source_line(str(source), 7)) + + one_line_previous = Path(temporary) / "one_line_previous.rs" + one_line_previous.write_text( + "match state {\n" + " State::Previous => value,\n" + " State::Current => {\n" + " consume(value);\n" + " }\n" + "}\n", + encoding="utf-8", + ) + self.assertFalse( + coverage_contract.is_executable_source_line( + str(one_line_previous), 3 + ) + ) + + first_arm = Path(temporary) / "first_arm.rs" + first_arm.write_text( + "match state {\n" + " State::Current => {\n" + " consume(value);\n" + " }\n" + "}\n", + encoding="utf-8", + ) + self.assertFalse( + coverage_contract.is_executable_source_line(str(first_arm), 2) + ) + def test_cfg_test_and_not_feature_block_helpers(self) -> None: """cfg(test) modules and cfg(not(feature)) blocks are fully recognized.""" From ef457631fa28451a59e63724d95c59a9baf7ce29 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 00:51:19 +0900 Subject: [PATCH 20/31] fix(coverage): reject block-boundary false guards --- scripts/check_coverage.py | 2 +- tests/quality/test_check_coverage.py | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/scripts/check_coverage.py b/scripts/check_coverage.py index ae5724043..77de890e0 100644 --- a/scripts/check_coverage.py +++ b/scripts/check_coverage.py @@ -116,7 +116,7 @@ def _is_multiline_match_guard(lines: list[str], line_number: int) -> bool: for candidate in reversed(lines[max(0, line_number - 32) : line_number - 1]): stripped = candidate.strip() - if stripped.startswith("}"): + if stripped.startswith("}") or stripped.endswith(("}", "{", ";")): return False if "=>" in stripped: return False diff --git a/tests/quality/test_check_coverage.py b/tests/quality/test_check_coverage.py index d1b96ea77..4175f40f6 100644 --- a/tests/quality/test_check_coverage.py +++ b/tests/quality/test_check_coverage.py @@ -476,6 +476,18 @@ def test_previous_arm_body_does_not_make_next_label_executable(self) -> None: self.assertFalse( coverage_contract.is_executable_source_line(str(first_arm), 2) ) + self.assertFalse( + coverage_contract._is_multiline_match_guard( # noqa: SLF001 + [" if value.is_valid() { consume(value); }", "State::Current => {"], + 2, + ) + ) + self.assertFalse( + coverage_contract._is_multiline_match_guard( + ["State::Current", "State::Current => {"], + 2, + ) + ) def test_cfg_test_and_not_feature_block_helpers(self) -> None: """cfg(test) modules and cfg(not(feature)) blocks are fully recognized.""" From 3b070025d67287c788a0de657dc97e9338769bb3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 01:15:59 +0900 Subject: [PATCH 21/31] fix coverage guard after destructuring match arm --- scripts/check_coverage.py | 2 ++ tests/quality/test_check_coverage.py | 17 +++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/scripts/check_coverage.py b/scripts/check_coverage.py index 77de890e0..56bd4c388 100644 --- a/scripts/check_coverage.py +++ b/scripts/check_coverage.py @@ -99,6 +99,8 @@ def is_executable_source_line( # Keep guarded match arms in the authored-line denominator: the guard # executes even though the arm label itself is structural. if text.endswith("=> {") and " if " not in text: + if text.startswith("if ") or text.startswith("if("): + return True return _is_multiline_match_guard(lines, line_number) if text.startswith("pub struct ") or text.startswith("struct "): return False diff --git a/tests/quality/test_check_coverage.py b/tests/quality/test_check_coverage.py index 4175f40f6..131a9274b 100644 --- a/tests/quality/test_check_coverage.py +++ b/tests/quality/test_check_coverage.py @@ -427,6 +427,23 @@ def test_multiline_guard_arm_is_executable(self) -> None: self.assertTrue(coverage_contract.is_executable_source_line(str(source), 4)) self.assertFalse(coverage_contract.is_executable_source_line(str(source), 7)) + def test_guard_after_brace_closing_pattern_is_executable(self) -> None: + """Count a guard after a destructuring pattern that closes with a brace.""" + + with tempfile.TemporaryDirectory() as temporary: + source = Path(temporary) / "destructured_guard.rs" + source.write_text( + "match state {\n" + " State::Ready { value }\n" + " if value.is_valid() => {\n" + " consume(value);\n" + " }\n" + "}\n", + encoding="utf-8", + ) + + self.assertTrue(coverage_contract.is_executable_source_line(str(source), 3)) + def test_previous_arm_body_does_not_make_next_label_executable(self) -> None: """Do not treat an ``if`` inside the preceding arm as a guard.""" From 9238c3af386870a9f3e51b780814adb90a470024 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 01:26:05 +0900 Subject: [PATCH 22/31] cover nested and long match guards --- scripts/check_coverage.py | 25 +++++++++++++------ tests/quality/test_check_coverage.py | 36 ++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 7 deletions(-) diff --git a/scripts/check_coverage.py b/scripts/check_coverage.py index 56bd4c388..12146973f 100644 --- a/scripts/check_coverage.py +++ b/scripts/check_coverage.py @@ -116,15 +116,26 @@ def is_executable_source_line( def _is_multiline_match_guard(lines: list[str], line_number: int) -> bool: """Recognize a guard continued onto the lines immediately before an arm.""" - for candidate in reversed(lines[max(0, line_number - 32) : line_number - 1]): + target_prefix = lines[line_number - 1].strip().partition("=>")[0] + brace_depth = target_prefix.count("}") - target_prefix.count("{") + guard_found = False + boundary_candidate = False + for candidate in reversed(lines[: line_number - 1]): stripped = candidate.strip() - if stripped.startswith("}") or stripped.endswith(("}", "{", ";")): + if brace_depth == 0 and "=>" in stripped: return False - if "=>" in stripped: - return False - if stripped.startswith("if ") or stripped.startswith("if("): - return True - return False + if brace_depth == 1 and stripped.endswith("=> {"): + boundary_candidate = True + brace_depth += stripped.count("}") - stripped.count("{") + if ( + (stripped.startswith("if ") or stripped.startswith("if(")) + and not stripped.endswith(("}", ";")) + and brace_depth == 0 + ): + guard_found = True + if stripped.startswith("match "): + return guard_found and not boundary_candidate + return guard_found and not boundary_candidate def _cfg_test_module_line_numbers(lines: list[str]) -> set[int]: diff --git a/tests/quality/test_check_coverage.py b/tests/quality/test_check_coverage.py index 131a9274b..f0b53a80d 100644 --- a/tests/quality/test_check_coverage.py +++ b/tests/quality/test_check_coverage.py @@ -444,6 +444,42 @@ def test_guard_after_brace_closing_pattern_is_executable(self) -> None: self.assertTrue(coverage_contract.is_executable_source_line(str(source), 3)) + def test_long_and_nested_match_guards_are_executable(self) -> None: + """Track guard boundaries beyond the old scan window and nested arms.""" + + with tempfile.TemporaryDirectory() as temporary: + source = Path(temporary) / "complex_guard.rs" + long_guard = [ + "match state {", + " State::Ready(value)", + " if value.is_valid()", + *[f" && value.part_{index}()" for index in range(40)], + " && value.is_fresh() => {", + " consume(value);", + " }", + "}", + ] + source.write_text("\n".join(long_guard) + "\n", encoding="utf-8") + self.assertTrue( + coverage_contract.is_executable_source_line( + str(source), len(long_guard) - 3 + ) + ) + + source.write_text( + "match state {\n" + " State::Ready(value)\n" + " if match value {\n" + " 0 => true,\n" + " _ => false,\n" + " } && value.is_fresh() => {\n" + " consume(value);\n" + " }\n" + "}\n", + encoding="utf-8", + ) + self.assertTrue(coverage_contract.is_executable_source_line(str(source), 6)) + def test_previous_arm_body_does_not_make_next_label_executable(self) -> None: """Do not treat an ``if`` inside the preceding arm as a guard.""" From e06e5047fa86ec6313ede68b9a1d034059e1164d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 01:27:24 +0900 Subject: [PATCH 23/31] cover split nested match guard --- tests/quality/test_check_coverage.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/quality/test_check_coverage.py b/tests/quality/test_check_coverage.py index f0b53a80d..581b9ff77 100644 --- a/tests/quality/test_check_coverage.py +++ b/tests/quality/test_check_coverage.py @@ -480,6 +480,21 @@ def test_long_and_nested_match_guards_are_executable(self) -> None: ) self.assertTrue(coverage_contract.is_executable_source_line(str(source), 6)) + source.write_text( + "match state {\n" + " State::Ready(value)\n" + " if match value {\n" + " 0 => true,\n" + " _ => false,\n" + " }\n" + " && value.is_fresh() => {\n" + " consume(value);\n" + " }\n" + "}\n", + encoding="utf-8", + ) + self.assertTrue(coverage_contract.is_executable_source_line(str(source), 7)) + def test_previous_arm_body_does_not_make_next_label_executable(self) -> None: """Do not treat an ``if`` inside the preceding arm as a guard.""" From 12ada1365c2795ed32644354ef46c5c2f89f0697 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 01:34:46 +0900 Subject: [PATCH 24/31] retain guards after sibling match arms --- scripts/check_coverage.py | 2 +- tests/quality/test_check_coverage.py | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/scripts/check_coverage.py b/scripts/check_coverage.py index 12146973f..ce9af0735 100644 --- a/scripts/check_coverage.py +++ b/scripts/check_coverage.py @@ -123,7 +123,7 @@ def _is_multiline_match_guard(lines: list[str], line_number: int) -> bool: for candidate in reversed(lines[: line_number - 1]): stripped = candidate.strip() if brace_depth == 0 and "=>" in stripped: - return False + return guard_found and not boundary_candidate if brace_depth == 1 and stripped.endswith("=> {"): boundary_candidate = True brace_depth += stripped.count("}") - stripped.count("{") diff --git a/tests/quality/test_check_coverage.py b/tests/quality/test_check_coverage.py index 581b9ff77..6c2bce689 100644 --- a/tests/quality/test_check_coverage.py +++ b/tests/quality/test_check_coverage.py @@ -532,6 +532,22 @@ def test_previous_arm_body_does_not_make_next_label_executable(self) -> None: ) ) + second_guard = Path(temporary) / "second_guard.rs" + second_guard.write_text( + "match state {\n" + " State::First => value,\n" + " State::Ready(value)\n" + " if value.is_valid()\n" + " && value.is_fresh() => {\n" + " consume(value);\n" + " }\n" + "}\n", + encoding="utf-8", + ) + self.assertTrue( + coverage_contract.is_executable_source_line(str(second_guard), 5) + ) + first_arm = Path(temporary) / "first_arm.rs" first_arm.write_text( "match state {\n" From cce90a11ac920244253f0f6a1c77b82e75e1cd44 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 17:16:28 -0700 Subject: [PATCH 25/31] feat(engine): execute cutoff-safe analysis runs (#178) * feat(engine): execute cutoff-safe analysis runs * docs(api): record analysis execution boundary * fix(engine): propagate artifact serialization errors * docs(engine): separate local and hosted verification * docs(engine): avoid duplicate gap-register landing file * docs(engine): remove absent register reference * docs(engine): preserve canonical documentation map * fix(engine): bound opaque analysis identifiers * Align analysis execution with accepted receipt contract * Guard analysis execution receipt identity * docs: register analysis gap doctoring --- ARCHITECTURE.md | 8 +- CHANGELOG.md | 3 + Cargo.lock | 11 + Cargo.toml | 2 + DOCUMENTATION.md | 2 + README.md | 12 +- crates/analysis_engine/Cargo.toml | 24 + crates/analysis_engine/src/lib.rs | 695 ++++++++++++++++++ .../analysis_engine/tests/crate_contract.rs | 6 + .../tests/end_to_end_contract.rs | 107 +++ crates/tepp_api/src/naruon_http.rs | 6 + docs/API_CONTRACT.md | 10 +- docs/TRACEABILITY.md | 3 +- ...17-deterministic-analysis-run-execution.md | 79 ++ docs/adr/README.md | 2 + docs/doctoring/analysis-engine-gap-closure.md | 46 ++ docs/doctoring/analysis-engine-v1.md | 52 ++ scripts/check_coverage.py | 2 +- scripts/check_workspace_contract.py | 1 + tests/quality/test_check_coverage.py | 9 +- tests/quality/test_check_docstrings.py | 2 +- 21 files changed, 1069 insertions(+), 13 deletions(-) create mode 100644 crates/analysis_engine/Cargo.toml create mode 100644 crates/analysis_engine/src/lib.rs create mode 100644 crates/analysis_engine/tests/crate_contract.rs create mode 100644 crates/analysis_engine/tests/end_to_end_contract.rs create mode 100644 docs/adr/0017-deterministic-analysis-run-execution.md create mode 100644 docs/doctoring/analysis-engine-gap-closure.md create mode 100644 docs/doctoring/analysis-engine-v1.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ffe514db6..a745dc1e9 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -43,6 +43,11 @@ flowchart LR Every boundary must be independently usable and expose versioned contracts for integration with organization repositories, `naruon`, and `contextual-orchestrator`. +The `analysis_engine` vertical slice is intentionally separate from `tepp_api`: +the API owns wire contracts while the engine owns deterministic execution. It +does not replace the future topic or psychometric estimators and does not read +another service's application tables. + ## Implemented foundation topology Task 1 materializes the first storage-independent workspace boundaries. The @@ -60,7 +65,8 @@ boundaries above remain the target modular MSA architecture. | `corpus_split` | cutoff-safe, relation-aware partitioning | | `tepp_simulation` | known-truth temporal/event data generation | | `validation_core` | RMSE, bias, coverage, graph, and Monte Carlo metrics | -| `tepp_api` | versioned DTO, schema, and export contracts | +| `tepp_api` | versioned DTO, schema, terminal-result, and export contracts | +| `analysis_engine` | bounded cutoff-safe temporal evidence readiness execution and digest-bound terminal artifacts | No crate exposes placeholder production behavior in Task 1. This prevents an empty façade from becoming a de facto public API before its invariants and tests diff --git a/CHANGELOG.md b/CHANGELOG.md index d4f0c7c05..4f480291d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ All notable changes to TEPP are documented here. The format follows Keep a Chang ### Added +- Registered the analysis-engine gap-closure doctoring in the canonical documentation map so its product and scientific traceability record is discoverable. +- Authored Rust coverage classification now ignores standalone structural closing parentheses, preventing formatting-only LCOV rows from appearing as uncovered production behavior. +- Stacked `analysis_engine` vertical slice (ADR 0017): bounded Rust execution from an accepted analysis run to a cutoff-safe, multiple-membership-aware, SHA-256-digest-bound terminal artifact or redacted no-eligible-evidence result. This is active-PR evidence and does not claim psychometric estimator authority. - Coverage classification preserves the final expression line of multiline Rust `match` guards while respecting preceding-arm boundaries, keeping the 100% authored-line gate conservative. - `tepp_api` fail-closed analysis-result boundaries: status constructors reject terminal envelopes that cannot fit the default 64 KiB status limit, and diff --git a/Cargo.lock b/Cargo.lock index fb502b9cd..6643af564 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -23,6 +23,17 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "analysis_engine" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "sha2", + "temporal_core", + "tepp_api", +] + [[package]] name = "atoi" version = "2.0.0" diff --git a/Cargo.toml b/Cargo.toml index 925659406..51543a95d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ members = [ "crates/tepp_simulation", "crates/validation_core", "crates/tepp_api", + "crates/analysis_engine", ] default-members = [ "crates/evidence_core", @@ -23,6 +24,7 @@ default-members = [ "crates/tepp_simulation", "crates/validation_core", "crates/tepp_api", + "crates/analysis_engine", ] [workspace.package] diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 3f0949473..d5bb55030 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -37,6 +37,8 @@ TEPP's approved PRD v0.4 and implementation plan are the primary product baselin | Provider-payload minimization doctoring | [`docs/research/provider-payload-minimization.md`](docs/research/provider-payload-minimization.md) | | Adaptive orchestration router doctoring | [`docs/research/adaptive-orchestration-router.md`](docs/research/adaptive-orchestration-router.md) | | Hourly NIM OpenCode doctoring | [`docs/doctoring/hourly-nim-opencode-development.md`](docs/doctoring/hourly-nim-opencode-development.md) | +| Analysis engine v1 doctoring | [`docs/doctoring/analysis-engine-v1.md`](docs/doctoring/analysis-engine-v1.md) | +| Analysis engine gap-closure doctoring | [`docs/doctoring/analysis-engine-gap-closure.md`](docs/doctoring/analysis-engine-gap-closure.md) | | Change history | [`CHANGELOG.md`](CHANGELOG.md) | ## Maturity vocabulary diff --git a/README.md b/README.md index ae74015d3..f28ddad27 100644 --- a/README.md +++ b/README.md @@ -6,10 +6,9 @@ implemented in Rust. ## Current implementation state -This branch establishes the Task 1 Rust workspace and quality-gate foundation. -The ten bounded crates compile independently but intentionally expose no -placeholder production APIs. Domain behavior begins in Task 2 with immutable -evidence identifiers and source records. +This branch establishes the Rust workspace and quality-gate foundation. The +eleven bounded crates compile independently; domain behavior includes immutable +evidence records and the active stacked cutoff-safe analysis execution slice. ```text crates/evidence_core @@ -22,6 +21,7 @@ crates/corpus_split crates/tepp_simulation crates/validation_core crates/tepp_api +crates/analysis_engine ``` ## Local verification @@ -56,3 +56,7 @@ this skeleton-only slice; it must never conceal uncovered production behavior. No release, production-readiness, GPU, database, or statistical-recovery claim is made by this foundation slice. + +The active stacked analysis-engine slice adds a bounded executable readiness path +from an accepted run to a digest-bound terminal artifact. It is not yet +implemented-main and does not replace scientific estimator contracts. diff --git a/crates/analysis_engine/Cargo.toml b/crates/analysis_engine/Cargo.toml new file mode 100644 index 000000000..9cd55140d --- /dev/null +++ b/crates/analysis_engine/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "analysis_engine" +description = "Deterministic cutoff-safe temporal evidence readiness execution." +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +homepage.workspace = true +readme.workspace = true +keywords.workspace = true +categories.workspace = true +publish = false + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +sha2 = { workspace = true } +tepp_api = { path = "../tepp_api", version = "0.1.0" } +temporal_core = { path = "../temporal_core", version = "0.1.0" } + +[lints] +workspace = true diff --git a/crates/analysis_engine/src/lib.rs b/crates/analysis_engine/src/lib.rs new file mode 100644 index 000000000..22e574f85 --- /dev/null +++ b/crates/analysis_engine/src/lib.rs @@ -0,0 +1,695 @@ +#![forbid(unsafe_code)] +#![deny(missing_docs)] +//! Deterministic, cutoff-safe execution for the first TEPP analysis vertical slice. +//! +//! The engine consumes identity-free evidence metadata, excludes evidence that +//! was unavailable at the requested knowledge cutoff, counts multiple-membership +//! assignments without collapsing them, and emits a digest-bound terminal result +//! through [`tepp_api`]. It deliberately does not claim latent-variable or topic +//! estimation authority; those estimators remain separate scientific crates. + +use serde::Serialize; +use sha2::{Digest, Sha256}; +use std::collections::BTreeSet; +use std::fmt; +use std::fmt::Write as _; +use temporal_core::{AvailableTime, EventTime, KnowledgeCutoff}; +use tepp_api::{ + AnalysisResultSummary, AnalysisRunAccepted, AnalysisRunRequest, AnalysisRunTerminalResult, + ApiError, +}; + +/// Versioned artifact schema emitted by this engine. +pub const ANALYSIS_ARTIFACT_SCHEMA_VERSION: &str = "tepp.temporal_evidence_readiness.v1"; +/// Number of deterministic statistics represented in the artifact summary. +pub const ANALYSIS_STATISTIC_COUNT: u64 = 4; +/// Maximum number of evidence units accepted by one in-memory execution. +pub const MAX_EVIDENCE_UNITS: usize = 100_000; +/// Maximum UTF-8 byte length of one snapshot or opaque evidence identifier. +pub const MAX_ANALYSIS_IDENTIFIER_BYTES: usize = 256; + +/// A bounded identity-free evidence unit offered to one analysis run. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AnalysisEvidenceUnit { + evidence_id: String, + event_time: EventTime, + available_time: AvailableTime, + membership_count: u32, +} + +impl AnalysisEvidenceUnit { + /// Construct an evidence unit with explicit event, availability, and + /// multiple-membership metadata. + /// + /// # Errors + /// + /// Returns [`AnalysisEngineError::InvalidEvidence`] when the identity is + /// empty or no membership assignment is supplied. + pub fn new( + evidence_id: impl Into, + event_time: EventTime, + available_time: AvailableTime, + membership_count: u32, + ) -> Result { + let evidence_id = evidence_id.into(); + if !valid_identifier(&evidence_id) || membership_count == 0 { + return Err(AnalysisEngineError::InvalidEvidence); + } + Ok(Self { + evidence_id, + event_time, + available_time, + membership_count, + }) + } + + /// Return the opaque evidence identity. + #[must_use] + pub fn evidence_id(&self) -> &str { + &self.evidence_id + } + + /// Return the event-valid time. + #[must_use] + pub const fn event_time(&self) -> EventTime { + self.event_time + } + + /// Return the evidence availability time. + #[must_use] + pub const fn available_time(&self) -> AvailableTime { + self.available_time + } + + /// Return the number of simultaneous membership assignments. + #[must_use] + pub const fn membership_count(&self) -> u32 { + self.membership_count + } +} + +/// A bounded snapshot of evidence metadata for one analysis run. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AnalysisCorpus { + snapshot_id: String, + evidence_units: Vec, +} + +impl AnalysisCorpus { + /// Construct a snapshot-owned corpus. + /// + /// # Errors + /// + /// Returns [`AnalysisEngineError::InvalidEvidence`] for an empty snapshot + /// identity or [`AnalysisEngineError::LimitExceeded`] for an oversized + /// in-memory corpus. + pub fn new( + snapshot_id: impl Into, + evidence_units: Vec, + ) -> Result { + let snapshot_id = snapshot_id.into(); + if !valid_identifier(&snapshot_id) { + return Err(AnalysisEngineError::InvalidEvidence); + } + if evidence_units.len() > MAX_EVIDENCE_UNITS { + return Err(AnalysisEngineError::LimitExceeded); + } + Ok(Self { + snapshot_id, + evidence_units, + }) + } + + /// Return the immutable snapshot identity. + #[must_use] + pub fn snapshot_id(&self) -> &str { + &self.snapshot_id + } + + /// Return the evidence units in source order. + #[must_use] + pub fn evidence_units(&self) -> &[AnalysisEvidenceUnit] { + &self.evidence_units + } +} + +/// Digest-bound, identity-free output artifact for one successful execution. +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +pub struct AnalysisArtifact { + /// Versioned artifact schema. + pub schema_version: String, + /// Opaque accepted-run identity. + pub run_id: String, + /// Immutable source snapshot identity. + pub snapshot_id: String, + /// Historical cutoff applied to availability. + pub knowledge_cutoff: String, + /// Number of evidence units available by the cutoff. + pub eligible_evidence_count: u64, + /// Sum of preserved multiple-membership assignments. + pub eligible_membership_count: u64, + /// Earliest event-valid time among eligible evidence. + pub earliest_event_time: String, + /// Latest event-valid time among eligible evidence. + pub latest_event_time: String, +} + +impl AnalysisArtifact { + /// Serialize the canonical artifact bytes used for digesting. + /// + /// # Errors + /// + /// Returns [`AnalysisEngineError::SerializationFailure`] if serialization + /// unexpectedly fails. + pub fn to_json(&self) -> Result { + serde_json::to_string(self).map_err(|_| AnalysisEngineError::SerializationFailure) + } + + /// Return the lowercase SHA-256 digest of the canonical artifact JSON. + /// + /// # Errors + /// + /// Returns [`AnalysisEngineError::SerializationFailure`] if serialization + /// unexpectedly fails. + pub fn sha256(&self) -> Result { + self.to_json() + .map(|json| format_digest(Sha256::digest(json.into_bytes()))) + } +} + +/// One complete execution response, including the internal artifact and the +/// request-bound terminal wire result. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AnalysisExecution { + /// Digest-bound artifact, present only when the terminal result succeeded. + pub artifact: Option, + /// Request-bound terminal result returned to the service boundary. + pub terminal_result: AnalysisRunTerminalResult, +} + +/// Fail-closed errors from the deterministic analysis vertical slice. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum AnalysisEngineError { + /// Request or accepted receipt failed its API contract. + Api(ApiError), + /// Evidence metadata was empty or structurally invalid. + InvalidEvidence, + /// Two evidence units reused one opaque identity. + DuplicateEvidence, + /// Corpus snapshot identity differed from the request snapshot. + SnapshotMismatch, + /// A bounded integer aggregation overflowed. + ArithmeticOverflow, + /// A serialized artifact could not be produced. + SerializationFailure, + /// The in-memory corpus exceeded the execution bound. + LimitExceeded, +} + +impl fmt::Display for AnalysisEngineError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let message = match self { + Self::Api(error) => return error.fmt(formatter), + Self::InvalidEvidence => "invalid analysis evidence", + Self::DuplicateEvidence => "duplicate analysis evidence identity", + Self::SnapshotMismatch => "analysis snapshot identity mismatch", + Self::ArithmeticOverflow => "analysis evidence count overflow", + Self::SerializationFailure => "analysis artifact serialization failed", + Self::LimitExceeded => "analysis corpus exceeded its execution bound", + }; + formatter.write_str(message) + } +} + +impl std::error::Error for AnalysisEngineError {} + +impl From for AnalysisEngineError { + fn from(error: ApiError) -> Self { + Self::Api(error) + } +} + +/// Execute the cutoff-safe temporal evidence readiness analysis. +/// +/// Evidence whose `available_time` is later than the request cutoff is excluded +/// before aggregation. Event time remains a separate clock, and all membership +/// assignments are summed rather than collapsed to one group. Successful output +/// contains only bounded counts and temporal extrema; source text, credentials, +/// and direct identities never enter the artifact. +/// +/// # Errors +/// +/// Returns a fail-closed error for invalid contracts, snapshot mismatch, +/// duplicate evidence identities, or invalid arithmetic/serialization state. +pub fn execute_analysis_run( + request: &AnalysisRunRequest, + accepted: &AnalysisRunAccepted, + corpus: &AnalysisCorpus, + completed_at: impl Into, +) -> Result { + request.to_json()?; + accepted.to_json()?; + require_receipt_identity(request, accepted)?; + if request.snapshot_id != corpus.snapshot_id { + return Err(AnalysisEngineError::SnapshotMismatch); + } + let cutoff = KnowledgeCutoff::parse_rfc3339(&request.knowledge_cutoff) + .map_err(|_| AnalysisEngineError::Api(ApiError::InvalidWirePayload))?; + let mut identities = BTreeSet::new(); + let mut eligible = Vec::new(); + for unit in &corpus.evidence_units { + if !identities.insert(unit.evidence_id.clone()) { + return Err(AnalysisEngineError::DuplicateEvidence); + } + if unit.available_time.instant() <= cutoff.instant() { + eligible.push(unit); + } + } + + let completed_at = completed_at.into(); + if eligible.is_empty() { + let terminal_result = AnalysisRunTerminalResult::failed( + request, + accepted, + completed_at, + "no_eligible_evidence", + )?; + return Ok(AnalysisExecution { + artifact: None, + terminal_result, + }); + } + + // The corpus bound makes this conversion and sum strictly smaller than + // `u64::MAX`: 100,000 * u32::MAX is below the 64-bit range. + let eligible_evidence_count = eligible.len() as u64; + let eligible_membership_count = eligible + .iter() + .fold(0_u64, |sum, unit| sum + u64::from(unit.membership_count)); + let (earliest, latest) = eligible.iter().fold( + (eligible[0].event_time, eligible[0].event_time), + |(earliest, latest), unit| (earliest.min(unit.event_time), latest.max(unit.event_time)), + ); + let artifact = AnalysisArtifact { + schema_version: ANALYSIS_ARTIFACT_SCHEMA_VERSION.to_owned(), + run_id: accepted.run_id.clone(), + snapshot_id: request.snapshot_id.clone(), + knowledge_cutoff: cutoff.to_rfc3339(), + eligible_evidence_count, + eligible_membership_count, + earliest_event_time: earliest.to_rfc3339(), + latest_event_time: latest.to_rfc3339(), + }; + artifact.sha256().and_then(move |digest| { + let artifact_id = format!("analysis_artifact_{}", &digest[..16]); + let summary = AnalysisResultSummary { + analysis_family: "temporal_evidence_readiness".to_owned(), + evidence_count: eligible_evidence_count, + statistic_count: ANALYSIS_STATISTIC_COUNT, + validation_status: "validated".to_owned(), + }; + let terminal_result = AnalysisRunTerminalResult::succeeded( + request, + accepted, + artifact_id, + digest, + ANALYSIS_ARTIFACT_SCHEMA_VERSION, + completed_at, + summary, + ) + .map_err(AnalysisEngineError::from)?; + Ok(AnalysisExecution { + artifact: Some(artifact), + terminal_result, + }) + }) +} + +/// Require the accepted receipt to carry the request's idempotency identity. +fn require_receipt_identity( + request: &AnalysisRunRequest, + accepted: &AnalysisRunAccepted, +) -> Result<(), AnalysisEngineError> { + if request.idempotency_key != accepted.idempotency_key { + return Err(AnalysisEngineError::Api(ApiError::InvalidWirePayload)); + } + Ok(()) +} + +fn format_digest(digest: impl AsRef<[u8]>) -> String { + let mut output = String::with_capacity(digest.as_ref().len() * 2); + for byte in digest.as_ref() { + let _ = write!(output, "{byte:02x}"); + } + output +} + +fn valid_identifier(value: &str) -> bool { + !value.trim().is_empty() + && value.len() <= MAX_ANALYSIS_IDENTIFIER_BYTES + && !value.chars().any(char::is_control) +} + +#[cfg(test)] +mod tests { + use super::{ + ANALYSIS_ARTIFACT_SCHEMA_VERSION, ANALYSIS_STATISTIC_COUNT, AnalysisCorpus, + AnalysisEngineError, AnalysisEvidenceUnit, MAX_ANALYSIS_IDENTIFIER_BYTES, + MAX_EVIDENCE_UNITS, execute_analysis_run, + }; + use temporal_core::{AvailableTime, EventTime}; + use tepp_api::{AnalysisRunAccepted, AnalysisRunRequest, AnalysisRunTerminalState, ApiError}; + + fn request() -> AnalysisRunRequest { + AnalysisRunRequest { + contract_version: 1, + idempotency_key: "idem-analysis-1".into(), + tenant_workspace_id: "tenant-workspace-1".into(), + snapshot_id: "snapshot-1".into(), + knowledge_cutoff: "2026-08-01T00:00:00Z".into(), + model_contract_version: "temporal-evidence-v1".into(), + output_profile: "validation-report".into(), + } + } + + fn accepted() -> AnalysisRunAccepted { + AnalysisRunAccepted::new("run-1", "accepted", "idem-analysis-1").expect("accepted") + } + + fn unit(id: &str, event: &str, available: &str, memberships: u32) -> AnalysisEvidenceUnit { + AnalysisEvidenceUnit::new( + id, + EventTime::parse_rfc3339(event).expect("event"), + AvailableTime::parse_rfc3339(available).expect("available"), + memberships, + ) + .expect("unit") + } + + #[test] + fn successful_run_is_cutoff_safe_and_preserves_multiple_memberships() { + let corpus = AnalysisCorpus::new( + "snapshot-1", + vec![ + unit( + "evidence-1", + "2026-07-01T00:00:00Z", + "2026-07-15T00:00:00Z", + 2, + ), + unit( + "evidence-2", + "2026-07-20T00:00:00Z", + "2026-08-01T00:00:00Z", + 3, + ), + unit( + "late-evidence", + "2026-07-25T00:00:00Z", + "2026-08-02T00:00:00Z", + 9, + ), + ], + ) + .expect("corpus"); + let execution = + execute_analysis_run(&request(), &accepted(), &corpus, "2026-08-03T00:00:00Z") + .expect("execution"); + let artifact = execution.artifact.expect("artifact"); + assert_eq!(artifact.schema_version, ANALYSIS_ARTIFACT_SCHEMA_VERSION); + assert_eq!(artifact.eligible_evidence_count, 2); + assert_eq!(artifact.eligible_membership_count, 5); + assert_eq!(artifact.earliest_event_time, "2026-07-01T00:00:00Z"); + assert_eq!(artifact.latest_event_time, "2026-07-20T00:00:00Z"); + assert_eq!( + execution.terminal_result.run_state, + AnalysisRunTerminalState::Succeeded + ); + let summary = execution.terminal_result.summary.as_ref().expect("summary"); + assert_eq!(summary.evidence_count, 2); + assert_eq!(summary.statistic_count, ANALYSIS_STATISTIC_COUNT); + assert_eq!(summary.validation_status, "validated"); + assert!(execution.terminal_result.result_sha256.is_some()); + assert!(execution.terminal_result.to_json().is_ok()); + } + + #[test] + fn no_eligible_evidence_returns_a_redacted_failure_result() { + let corpus = AnalysisCorpus::new( + "snapshot-1", + vec![unit( + "late", + "2026-07-25T00:00:00Z", + "2026-08-02T00:00:00Z", + 1, + )], + ) + .expect("corpus"); + let execution = + execute_analysis_run(&request(), &accepted(), &corpus, "2026-08-03T00:00:00Z") + .expect("failure result"); + assert!(execution.artifact.is_none()); + assert_eq!( + execution.terminal_result.run_state, + AnalysisRunTerminalState::Failed + ); + assert_eq!( + execution.terminal_result.failure_code.as_deref(), + Some("no_eligible_evidence") + ); + assert!(execution.terminal_result.summary.is_none()); + } + + #[test] + fn trust_boundary_and_shape_errors_fail_closed() { + assert_eq!( + AnalysisEvidenceUnit::new( + "", + EventTime::parse_rfc3339("2026-07-01T00:00:00Z").expect("event"), + AvailableTime::parse_rfc3339("2026-07-01T00:00:00Z").expect("available"), + 1, + ), + Err(AnalysisEngineError::InvalidEvidence) + ); + assert_eq!( + AnalysisCorpus::new("", Vec::new()), + Err(AnalysisEngineError::InvalidEvidence) + ); + assert_eq!( + AnalysisCorpus::new("\n", Vec::new()), + Err(AnalysisEngineError::InvalidEvidence) + ); + assert_eq!( + AnalysisCorpus::new("s".repeat(MAX_ANALYSIS_IDENTIFIER_BYTES + 1), Vec::new()), + Err(AnalysisEngineError::InvalidEvidence) + ); + assert_eq!( + AnalysisEvidenceUnit::new( + "e", + EventTime::parse_rfc3339("2026-07-01T00:00:00Z").expect("event"), + AvailableTime::parse_rfc3339("2026-07-01T00:00:00Z").expect("available"), + 0, + ), + Err(AnalysisEngineError::InvalidEvidence) + ); + assert_eq!( + AnalysisEvidenceUnit::new( + "e".repeat(MAX_ANALYSIS_IDENTIFIER_BYTES + 1), + EventTime::parse_rfc3339("2026-07-01T00:00:00Z").expect("event"), + AvailableTime::parse_rfc3339("2026-07-01T00:00:00Z").expect("available"), + 1, + ), + Err(AnalysisEngineError::InvalidEvidence) + ); + let corpus = AnalysisCorpus::new( + "snapshot-2", + vec![unit( + "evidence-1", + "2026-07-01T00:00:00Z", + "2026-07-01T00:00:00Z", + 1, + )], + ) + .expect("corpus"); + assert_eq!( + execute_analysis_run(&request(), &accepted(), &corpus, "2026-08-03T00:00:00Z"), + Err(AnalysisEngineError::SnapshotMismatch) + ); + let mismatched_receipt = + AnalysisRunAccepted::new("run-1", "accepted", "other-idempotency").expect("receipt"); + let matching_corpus = AnalysisCorpus::new( + "snapshot-1", + vec![unit( + "evidence-1", + "2026-07-01T00:00:00Z", + "2026-07-01T00:00:00Z", + 1, + )], + ) + .expect("corpus"); + assert_eq!( + execute_analysis_run( + &request(), + &mismatched_receipt, + &matching_corpus, + "2026-08-03T00:00:00Z" + ), + Err(AnalysisEngineError::Api(ApiError::InvalidWirePayload)) + ); + let duplicate = AnalysisCorpus::new( + "snapshot-1", + vec![ + unit("same", "2026-07-01T00:00:00Z", "2026-07-01T00:00:00Z", 1), + unit("same", "2026-07-02T00:00:00Z", "2026-07-02T00:00:00Z", 1), + ], + ) + .expect("corpus"); + assert_eq!( + execute_analysis_run(&request(), &accepted(), &duplicate, "2026-08-03T00:00:00Z"), + Err(AnalysisEngineError::DuplicateEvidence) + ); + assert_eq!( + AnalysisEngineError::Api(ApiError::LimitExceeded).to_string(), + "API request exceeded configured limits" + ); + assert_eq!( + AnalysisEngineError::SerializationFailure.to_string(), + "analysis artifact serialization failed" + ); + } + + #[test] + fn public_accessors_limits_and_error_messages_are_executable() { + let evidence = unit( + "evidence-accessor", + "2026-07-01T00:00:00Z", + "2026-07-01T00:00:00Z", + 4, + ); + assert_eq!(evidence.evidence_id(), "evidence-accessor"); + assert_eq!( + evidence.event_time(), + EventTime::parse_rfc3339("2026-07-01T00:00:00Z").expect("event") + ); + assert_eq!( + evidence.available_time(), + AvailableTime::parse_rfc3339("2026-07-01T00:00:00Z").expect("available") + ); + assert_eq!(evidence.membership_count(), 4); + let corpus = + AnalysisCorpus::new("snapshot-accessor", vec![evidence.clone()]).expect("corpus"); + assert_eq!(corpus.snapshot_id(), "snapshot-accessor"); + assert_eq!(corpus.evidence_units(), &[evidence]); + + let oversized = AnalysisCorpus::new( + "snapshot-limit", + vec![ + unit("bounded", "2026-07-01T00:00:00Z", "2026-07-01T00:00:00Z", 1,); + MAX_EVIDENCE_UNITS + 1 + ], + ); + assert_eq!(oversized, Err(AnalysisEngineError::LimitExceeded)); + + let messages = [ + ( + AnalysisEngineError::InvalidEvidence, + "invalid analysis evidence", + ), + ( + AnalysisEngineError::DuplicateEvidence, + "duplicate analysis evidence identity", + ), + ( + AnalysisEngineError::SnapshotMismatch, + "analysis snapshot identity mismatch", + ), + ( + AnalysisEngineError::ArithmeticOverflow, + "analysis evidence count overflow", + ), + ( + AnalysisEngineError::SerializationFailure, + "analysis artifact serialization failed", + ), + ( + AnalysisEngineError::LimitExceeded, + "analysis corpus exceeded its execution bound", + ), + ]; + for (error, message) in messages { + assert_eq!(error.to_string(), message); + } + let converted: AnalysisEngineError = ApiError::InvalidWirePayload.into(); + assert_eq!(converted.to_string(), "invalid API wire payload"); + } + + #[test] + fn malformed_request_receipt_cutoff_and_completion_fail_closed() { + let corpus = AnalysisCorpus::new( + "snapshot-1", + vec![unit( + "evidence-1", + "2026-07-01T00:00:00Z", + "2026-07-01T00:00:00Z", + 1, + )], + ) + .expect("corpus"); + + let mut invalid_request = request(); + invalid_request.idempotency_key.clear(); + assert_eq!( + execute_analysis_run( + &invalid_request, + &accepted(), + &corpus, + "2026-08-03T00:00:00Z" + ), + Err(AnalysisEngineError::Api(ApiError::InvalidWirePayload)) + ); + + let mut invalid_accepted = accepted(); + invalid_accepted.run_id.clear(); + assert_eq!( + execute_analysis_run( + &request(), + &invalid_accepted, + &corpus, + "2026-08-03T00:00:00Z" + ), + Err(AnalysisEngineError::Api(ApiError::InvalidWirePayload)) + ); + + let mut invalid_cutoff = request(); + invalid_cutoff.knowledge_cutoff = "not-a-time".into(); + assert_eq!( + execute_analysis_run( + &invalid_cutoff, + &accepted(), + &corpus, + "2026-08-03T00:00:00Z" + ), + Err(AnalysisEngineError::Api(ApiError::InvalidWirePayload)) + ); + + assert_eq!( + execute_analysis_run(&request(), &accepted(), &corpus, "not-a-time"), + Err(AnalysisEngineError::Api(ApiError::InvalidWirePayload)) + ); + + let no_evidence = AnalysisCorpus::new( + "snapshot-1", + vec![unit( + "late", + "2026-07-01T00:00:00Z", + "2026-08-02T00:00:00Z", + 1, + )], + ) + .expect("corpus"); + assert_eq!( + execute_analysis_run(&request(), &accepted(), &no_evidence, "not-a-time"), + Err(AnalysisEngineError::Api(ApiError::InvalidWirePayload)) + ); + } +} diff --git a/crates/analysis_engine/tests/crate_contract.rs b/crates/analysis_engine/tests/crate_contract.rs new file mode 100644 index 000000000..c401c2877 --- /dev/null +++ b/crates/analysis_engine/tests/crate_contract.rs @@ -0,0 +1,6 @@ +//! Package identity contract for the analysis engine. + +#[test] +fn package_identity_is_stable() { + assert_eq!(env!("CARGO_PKG_NAME"), "analysis_engine"); +} diff --git a/crates/analysis_engine/tests/end_to_end_contract.rs b/crates/analysis_engine/tests/end_to_end_contract.rs new file mode 100644 index 000000000..829e56f5d --- /dev/null +++ b/crates/analysis_engine/tests/end_to_end_contract.rs @@ -0,0 +1,107 @@ +//! Realistic cutoff-safe end-to-end analysis execution. + +use analysis_engine::{ + AnalysisCorpus, AnalysisEngineError, AnalysisEvidenceUnit, execute_analysis_run, +}; +use temporal_core::{AvailableTime, EventTime}; +use tepp_api::{AnalysisRunAccepted, AnalysisRunRequest, AnalysisRunTerminalState}; + +fn evidence(id: &str, available: &str, memberships: u32) -> AnalysisEvidenceUnit { + AnalysisEvidenceUnit::new( + id, + EventTime::parse_rfc3339("2026-07-10T12:00:00Z").expect("event time"), + AvailableTime::parse_rfc3339(available).expect("available time"), + memberships, + ) + .expect("evidence") +} + +#[test] +fn production_shape_run_excludes_future_available_evidence() { + let request = AnalysisRunRequest { + contract_version: 1, + idempotency_key: "customer-run-2026-08-01".into(), + tenant_workspace_id: "workspace-opaque-1".into(), + snapshot_id: "snapshot-customer-2026-08-01".into(), + knowledge_cutoff: "2026-08-01T00:00:00Z".into(), + model_contract_version: "temporal-evidence-v1".into(), + output_profile: "validation-report".into(), + }; + let accepted = + AnalysisRunAccepted::new("run-customer-1", "accepted", "customer-run-2026-08-01") + .expect("accepted"); + let corpus = AnalysisCorpus::new( + "snapshot-customer-2026-08-01", + vec![ + evidence("invoice-renewal", "2026-07-31T23:59:59Z", 2), + evidence("later-correction", "2026-08-01T00:00:01Z", 4), + ], + ) + .expect("snapshot"); + let execution = execute_analysis_run(&request, &accepted, &corpus, "2026-08-01T00:01:00Z") + .expect("execute"); + assert_eq!( + execution.terminal_result.run_state, + AnalysisRunTerminalState::Succeeded + ); + assert_eq!( + execution + .artifact + .expect("artifact") + .eligible_evidence_count, + 1 + ); +} + +#[test] +fn snapshot_identity_is_not_inferred_from_customer_payload() { + let request = AnalysisRunRequest { + contract_version: 1, + idempotency_key: "run".into(), + tenant_workspace_id: "workspace".into(), + snapshot_id: "request-snapshot".into(), + knowledge_cutoff: "2026-08-01T00:00:00Z".into(), + model_contract_version: "model-v1".into(), + output_profile: "report".into(), + }; + let accepted = AnalysisRunAccepted::new("run", "accepted", "run").expect("accepted"); + let corpus = AnalysisCorpus::new( + "other-snapshot", + vec![evidence("evidence", "2026-07-01T00:00:00Z", 1)], + ) + .expect("snapshot"); + assert_eq!( + execute_analysis_run(&request, &accepted, &corpus, "2026-08-01T00:01:00Z"), + Err(AnalysisEngineError::SnapshotMismatch) + ); +} + +#[test] +fn mismatched_receipt_identity_is_rejected_before_corpus_scan() { + let request = AnalysisRunRequest { + contract_version: 1, + idempotency_key: "request-idempotency".into(), + tenant_workspace_id: "workspace".into(), + snapshot_id: "snapshot".into(), + knowledge_cutoff: "2026-08-01T00:00:00Z".into(), + model_contract_version: "model-v1".into(), + output_profile: "report".into(), + }; + let accepted = + AnalysisRunAccepted::new("run", "accepted", "receipt-idempotency").expect("accepted"); + let corpus = AnalysisCorpus::new( + "snapshot", + vec![ + evidence("duplicate-evidence", "2026-07-01T00:00:00Z", 1), + evidence("duplicate-evidence", "2026-07-02T00:00:00Z", 1), + ], + ) + .expect("snapshot"); + + assert_eq!( + execute_analysis_run(&request, &accepted, &corpus, "2026-08-01T00:01:00Z"), + Err(AnalysisEngineError::Api( + tepp_api::ApiError::InvalidWirePayload + )) + ); +} diff --git a/crates/tepp_api/src/naruon_http.rs b/crates/tepp_api/src/naruon_http.rs index b0dec3244..a066d532a 100644 --- a/crates/tepp_api/src/naruon_http.rs +++ b/crates/tepp_api/src/naruon_http.rs @@ -357,6 +357,12 @@ mod tests { "x-anthropic-key", "x-bytez-api-key", "x-openrouter-api-key", + "x-api_key", + "x-secret", + "x-credential", + "x-openai", + "x-bytez", + "x-openrouter", "x-provider-api_key", "x-provider-secret", "x-provider-credential", diff --git a/docs/API_CONTRACT.md b/docs/API_CONTRACT.md index 0fdc07543..074da319e 100644 --- a/docs/API_CONTRACT.md +++ b/docs/API_CONTRACT.md @@ -1,7 +1,7 @@ # TEPP API and Modular Integration Contract **Status:** Accepted target contract; exact endpoints are introduced only with executable services. -**Last reviewed:** 2026-08-16 +**Last reviewed:** 2026-08-21 ## 1. Authority boundary @@ -21,6 +21,7 @@ Current protected main exposes Rust library/domain contracts. The active PR adds | LLM interpretation provider port | `tepp_api` orchestration router + future HTTP gateway | contextual-orchestrator | partial | | model/artifact/export API | `tepp_api` export envelopes + future HTTP service | standalone UI/CWL consumers | partial | | analysis-run request/accepted/status/terminal-result contracts | `tepp_api` v1 wire DTOs | naruon, orchestrator, UI | active-PR #157 | +| cutoff-safe analysis-run readiness execution | `analysis_engine` bounded Rust crate | `tepp_api`, future HTTP/service adapters | active-PR #178 stacked on #157 | ## 3. Versioning @@ -58,6 +59,13 @@ snapshot, cutoff, model, profile, and idempotency bindings before treating the run as measurement evidence. The Rust DTO is available before the future HTTP service is deployed. +The stacked `analysis_engine` slice provides the first executable service-side +path behind these DTOs. It consumes a bounded identity-free snapshot, excludes +evidence unavailable at the historical cutoff, preserves multiple-membership +counts, and emits a digest-bound terminal result or a redacted failure. It is +not a substitute for the approved topic or psychometric estimators and remains +active-PR evidence until its exact-head checks and protected merge pass. + ## 5. Analysis request authority An analysis request cannot supply arbitrary facts that bypass validated domain state. The service resolves and validates: diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 8101fa64c..f73788b20 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -19,7 +19,8 @@ The full APA 7th standards/literature register remains `docs/research/standards- | recovery metrics (RMSE, bias, coverage, graph, temporal order, Monte Carlo SE gates) | PRD; Test Strategy; ADR 0007/0014 | `validation_core` on protected main (PR #19); SE-aware Monte Carlo gates included | implemented-main | | PostgreSQL bitemporal/lineage persistence | ADR 0013; Architecture/ERD | `persistence_postgres` migration contracts, in-memory adapters, live SQL session/document SQL port, tenant RLS (`0002` + session GUC/role helpers), `DATABASE_URL` SQLx gate, optional `live-sqlx` `PgPool` driver, exact-head live PostgreSQL CI with isolation proof, append-only immutability triggers (`0004`), temporal interval ordering CHECKs (`0005`), typed membership assignment (`0006` implemented-main), event-relation/mention/instance SQL (#37–#39 implemented-main), source-artifact SQL (#40 implemented-main), audit-event SQL (#41 implemented-main), concurrent document-write stress (#43 implemented-main), backup/restore integrity revalidation (active PR); remaining physical ERD constraints | partial | | known-truth temporal/event simulation manifests | PRD; TRD; Test Strategy | `tepp_simulation` on protected main; recovery metrics in `validation_core` | implemented-main | -| versioned service/API contracts and exports | PRD; API contract; ADR 0011/0013 | `tepp_api` analysis-run/export/JSON-LD/GraphML contracts on protected main (PR #21); request-bound terminal result and typed status/read contract active in PR #157; HTTP service remaining accepted-target | partial | +| versioned service/API contracts and exports | PRD; API contract; ADR 0011/0013 | `tepp_api` analysis-run/export/JSON-LD/GraphML contracts on protected main (PR #21); request-bound terminal result active in PR #157; HTTP service remains accepted-target | partial | +| executable cutoff-safe analysis-run readiness | ADR 0017; temporal research; API terminal-result contract | stacked `analysis_engine` PR on #157: availability cutoff, snapshot binding, multiple-membership aggregation, digest-bound artifact, realistic end-to-end tests | active-PR | | immutable split/run/reproducibility manifests | ADR 0013; ERD | `tepp_api` reproducibility manifest contract on protected main; `persistence_postgres` append-only SQL insert/lookup for `reproducibility_manifest`, `corpus_split_manifest`, `model_run`, and `model_artifact` (migration `0003`); full physical ERD constraints remaining | partial | | multilingual shared latent semantic space | PRD; ADR 0004 | future semantic/concept/topic crates | accepted-target | | TRSL-TM temporal/relational topic posterior and backend compatibility | ADR 0012; ADR 0004 | future `topic_measurement` | accepted-target | diff --git a/docs/adr/0017-deterministic-analysis-run-execution.md b/docs/adr/0017-deterministic-analysis-run-execution.md new file mode 100644 index 000000000..567395e8d --- /dev/null +++ b/docs/adr/0017-deterministic-analysis-run-execution.md @@ -0,0 +1,79 @@ +# ADR 0017 — Deterministic cutoff-safe analysis-run execution + +**Decision status:** Accepted +**Implementation maturity:** active-PR — stacked on PR #157; not implemented-main +**Date:** 2026-08-21 +**Supersedes:** None; complements ADR 0002, ADR 0003, ADR 0011, ADR 0013, and the terminal-result contract introduced by PR #157. +**Figma File ID:** N/A — this increment changes a Rust service crate and has no user-interface surface. +**Storybook inventory:** N/A — no reusable web object or interaction changed. + +## Context + +TEPP already accepts an analysis request and can describe a completed result, +but a buyer needs a demonstrable path between those contracts. Without one +bounded execution slice, an accepted run is only a receipt and consumers cannot +verify cutoff safety, multiple-membership preservation, or artifact identity. + +## Decision + +Add the standalone `analysis_engine` Rust crate as the first executable vertical +slice. It consumes a request, an accepted receipt, and a bounded identity-free +evidence snapshot. It: + +- excludes evidence whose `available_time` is later than the request's + `knowledge_cutoff`; +- preserves multiple-membership assignments by summing their counts rather than + reducing an evidence unit to one group; +- binds the result to the accepted run and source snapshot; +- verifies request/receipt idempotency identity before scanning the corpus; +- emits a canonical SHA-256-digested `AnalysisArtifact` and the versioned + `AnalysisRunTerminalResult` from `tepp_api`; +- returns a content-redacted failed terminal result when no evidence is + eligible; and +- remains a readiness/counting slice, not latent-variable, topic, or + psychometric estimator authority. + +The engine is deterministic, synchronous, bounded to `100_000` evidence units, +and CPU-only. Scientific estimators and their Rust CPU `f64`/GPU parity +contracts remain separate boundaries under ADR 0001 and ADR 0006. + +## Alternatives considered + +1. Keep the API as contracts only — rejected because an accepted run would not + produce a buyer-verifiable terminal outcome. +2. Put execution into `tepp_api` — rejected because transport contracts and + scientific execution would become one service boundary. +3. Add a bounded standalone engine behind the existing contracts — accepted + because it is independently testable and composable without shared tables. + +## Consequences + +Consumers can run a reproducible readiness check while seeing only opaque +identifiers, bounded counts, temporal extrema, and a digest. The engine does +not expose source text or identity mappings and does not claim a psychometric +measurement. The initial linear scan is intentionally simple; a production +large-corpus adapter must stream snapshots and preserve the same artifact +semantics before raising the bound. + +## Verification + +The stacked PR includes Rust unit and integration tests for cutoff exclusion, +multiple-membership summation, snapshot binding, duplicate identities, empty +eligibility, receipt validation, and package identity. Run: + +```text +cargo fmt --all -- --check +cargo test -p analysis_engine +cargo clippy -p analysis_engine --all-targets -- -D warnings +``` + +The supporting research and APA 7th citations are recorded in +`docs/doctoring/analysis-engine-v1.md` and the standards register. + +## Rollback and supersession + +Rollback removes the `analysis_engine` workspace member and stops publishing +the readiness artifact while preserving the request and terminal-result DTOs. +No persisted schema migration is introduced. Supersession requires a new ADR +if execution changes cutoff semantics, artifact authority, privacy fields, or +scientific estimands. diff --git a/docs/adr/README.md b/docs/adr/README.md index 258eb7f31..c1e54fddc 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -22,6 +22,7 @@ Read [`ADR_POLICY.md`](ADR_POLICY.md) first. **Decision status and implementatio | [0014](0014-scientific-claim-promotion-and-release-evidence.md) | Scientific claim promotion and release evidence authority | Accepted | partial | Separates design, implementation, scientific/product claim, and release authority; repository SBOM/provenance generator implemented, full release bundle remaining. | | [0015](0015-autonomous-development-review-and-merge-authority.md) | Autonomous development, review, and merge authority separation | Accepted | active-PR | Separates model proposal, deterministic verification, publication, independent review, and merge/release authority. | | [0016](0016-tdt-chronos-event-intelligence-boundary.md) | TDT, CHRONOS, and Event Ontology intelligence boundary | Accepted | accepted-target | Separates observed evidence, detection/tracking, prediction/schema inference, temporal consistency, and promoted transition authority. | +| [0017](0017-deterministic-analysis-run-execution.md) | Deterministic cutoff-safe analysis-run execution | Accepted | active-PR | Stacked on PR #157; closes the first executable buyer path from accepted run to digest-bound terminal result without claiming estimator authority. | ## Decision ownership summary @@ -43,6 +44,7 @@ Use the narrowest owning ADR when decisions overlap: - **claim maturity / release evidence:** ADR 0014; - **autonomous development/review/merge authority:** ADR 0015; - **TDT/CHRONOS event intelligence:** ADR 0016. +- **accepted-run execution and terminal artifact production:** ADR 0017. ## Change and supersession rule diff --git a/docs/doctoring/analysis-engine-gap-closure.md b/docs/doctoring/analysis-engine-gap-closure.md new file mode 100644 index 000000000..c9fef79dc --- /dev/null +++ b/docs/doctoring/analysis-engine-gap-closure.md @@ -0,0 +1,46 @@ +# Analysis Engine v1 — Buyer Gap Closure + +**Review date:** 2026-08-21 +**Active slice:** PR #157 terminal-result contract → stacked analysis execution +engine for issue #166 + +The organization-wide buyer-gap register is maintained by a separate landing +vehicle. This document records only the analysis-engine slice so it can land +without competing with that register or requiring another PR to be present. + +## Buyer-visible gap + +An accepted analysis run previously had a durable receipt and a terminal-result +DTO, but no executable path that applied a historical availability cutoff and +returned a verifiable artifact. A buyer could submit work but could not yet +demonstrate that the result was complete, cutoff-safe, multiple-membership aware, +and unchanged after transport. + +## Bounded closure + +The stacked `analysis_engine` crate closes the first vertical slice with a +standalone Rust API. It accepts a bounded identity-free evidence snapshot, +rejects snapshot mismatches and duplicate opaque IDs, excludes future-available +evidence, preserves membership counts, and emits a digest-bound terminal result +or a redacted no-eligible-evidence failure. + +This is readiness evidence, not a psychometric estimate. Latent-variable, +multilingual, GPU, and HTTP service gaps remain separately governed by their +own ADRs and must not be implied by this slice. + +## Next leverage-ranked gaps + +1. Add a streaming snapshot adapter with the same digest and cutoff semantics. +2. Bind the engine to a versioned standalone HTTP port without cross-service + table access. +3. Add known-truth estimator execution with RMSE, bias, interval coverage, + multilevel/multiple-membership recovery, and CPU/GPU parity. +4. Add buyer-facing visual analytics only after the interaction contract is + stable; then create a Figma file and Storybook inventory and record its real + File ID in a new UI ADR. + +## Evidence boundary + +The current implementation is active-PR evidence only. Exact-head checks, +independent review, protected merge, release evidence, and deployment controls +are required before a capability is promoted to implemented-main. diff --git a/docs/doctoring/analysis-engine-v1.md b/docs/doctoring/analysis-engine-v1.md new file mode 100644 index 000000000..f3a9f26a9 --- /dev/null +++ b/docs/doctoring/analysis-engine-v1.md @@ -0,0 +1,52 @@ +# Analysis Engine v1 — Evidence Doctoring + +## Claim boundary + +The stacked PR proves one deterministic temporal-evidence-readiness execution +slice. It does not prove production psychometric estimation, topic validity, +GPU performance, HTTP deployment, certification, or customer-wide scale. + +## Decision-to-evidence mapping + +| Contract | Implementation evidence | Customer action enabled | +|---|---|---| +| Historical cutoff safety | `available_time <= knowledge_cutoff` filter in `analysis_engine` | Re-run a historical snapshot without future-availability leakage | +| Multiple membership | `membership_count` is summed for every eligible unit | Inspect inclusive counts without atomistic single-group collapse | +| Terminal completion | `AnalysisRunTerminalResult` is built from the accepted request and receipt | Poll one stable terminal contract instead of treating acceptance as completion | +| Artifact integrity | Canonical JSON and SHA-256 digest | Verify that a downloaded result matches the published artifact identity | +| Privacy boundary | Artifact contains opaque IDs, counts, and times only | Keep identity mapping in the authorized source boundary | + +## Scientific and standards basis + +The implementation preserves TEPP's distinct event and availability clocks and +does not infer an event time from availability time. The API payload is explicit +JSON, and the artifact digest is an integrity check rather than proof of origin +or scientific truth. These interpretations follow the existing temporal, +interchange, and hashing register entries (Bray, 2017; International +Organization for Standardization, 2012; National Institute of Standards and +Technology, 2015). + +## Verification record + +The local preflight for this slice passed with Rust 1.97.1: + +- `cargo fmt --all -- --check`; +- `cargo test -p analysis_engine` — 5 unit tests, 1 crate-contract test, 2 + end-to-end tests, and doctest collection; +- `cargo clippy -p analysis_engine --all-targets -- -D warnings`. + +The protected-hosted exact-head checks and qualifying independent reviews are +still pending. This document must not be used as implemented-main or release +evidence before that merge. + +## APA 7th references + +Bray, T. (Ed.). (2017). *The JavaScript Object Notation (JSON) data interchange +format* (RFC 8259). RFC Editor. https://doi.org/10.17487/RFC8259 + +International Organization for Standardization. (2012). *Language resource +management—Semantic annotation framework (SemAF)—Part 1: Time and events +(SemAF-Time, ISO-TimeML)* (ISO Standard No. 24617-1:2012). + +National Institute of Standards and Technology. (2015). *Secure Hash Standard +(SHS)* (FIPS PUB 180-4). https://doi.org/10.6028/NIST.FIPS.180-4 diff --git a/scripts/check_coverage.py b/scripts/check_coverage.py index ce9af0735..475ac53b9 100644 --- a/scripts/check_coverage.py +++ b/scripts/check_coverage.py @@ -82,7 +82,7 @@ def is_executable_source_line( return False if text.startswith("#[") or text.startswith("#!["): return False - if text in {"{", "}", "},", ");", "];", "();", "};"}: + if text in {"{", "}", "},", ")", ");", "];", "();", "};"}: return False if text.startswith("use ") or text.startswith("pub use "): return False diff --git a/scripts/check_workspace_contract.py b/scripts/check_workspace_contract.py index c7b1ecf58..425233491 100644 --- a/scripts/check_workspace_contract.py +++ b/scripts/check_workspace_contract.py @@ -23,6 +23,7 @@ "tepp_simulation", "validation_core", "tepp_api", + "analysis_engine", ) REQUIRED_CI_SNIPPETS: tuple[str, ...] = ( diff --git a/tests/quality/test_check_coverage.py b/tests/quality/test_check_coverage.py index 6c2bce689..9f5b11a09 100644 --- a/tests/quality/test_check_coverage.py +++ b/tests/quality/test_check_coverage.py @@ -321,9 +321,10 @@ def test_executable_source_line_filters_noise_records(self) -> None: " }", # 55 "}", # 56 " executable_statement();", # 57 executable - "pub(crate) fn crate_visible() {", # 58 visibility-qualified fn - "State::Accepted => {", # 59 match-arm structure - "State::Guarded(value) if valid(value) => {", # 60 guarded arm is executable + ")", # 58 standalone structural close + "pub(crate) fn crate_visible() {", # 59 visibility-qualified fn + "State::Accepted => {", # 60 match-arm structure + "State::Guarded(value) if valid(value) => {", # 61 guarded arm is executable ] source.write_text("\n".join(source_lines) + "\n", encoding="utf-8") path = str(source) @@ -338,7 +339,7 @@ def test_executable_source_line_filters_noise_records(self) -> None: coverage_contract.is_executable_source_line(path, len(source_lines) + 5) ) - expected_executable = {13, 40, 44, 57, 60} + expected_executable = {13, 40, 44, 57, 61} for line_number in range(1, len(source_lines) + 1): is_exec = coverage_contract.is_executable_source_line(path, line_number) if line_number in expected_executable: diff --git a/tests/quality/test_check_docstrings.py b/tests/quality/test_check_docstrings.py index 2c11f7a53..b99537c52 100644 --- a/tests/quality/test_check_docstrings.py +++ b/tests/quality/test_check_docstrings.py @@ -24,7 +24,7 @@ def test_live_repository_is_documented(self) -> None: sources = docstrings.rust_sources(REPOSITORY_ROOT) crate_roots = sorted(REPOSITORY_ROOT.glob("crates/*/src/lib.rs")) - self.assertEqual(len(crate_roots), 10) + self.assertEqual(len(crate_roots), 11) self.assertTrue(set(crate_roots).issubset(sources)) self.assertGreaterEqual(len(sources), len(crate_roots)) self.assertEqual(docstrings.validate_repository(REPOSITORY_ROOT), []) From 7402745cfb0c27964f286c38a2efd7042c2320ff Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 22:24:15 +0900 Subject: [PATCH 26/31] test(engine): include evidence available exactly at cutoff Drive execute_analysis_run with a unit whose available_time equals the knowledge cutoff so a strict-before comparison cannot slip through. Multiple-membership count on that unit is preserved in the artifact. --- .../tests/end_to_end_contract.rs | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/crates/analysis_engine/tests/end_to_end_contract.rs b/crates/analysis_engine/tests/end_to_end_contract.rs index 829e56f5d..e1a01258c 100644 --- a/crates/analysis_engine/tests/end_to_end_contract.rs +++ b/crates/analysis_engine/tests/end_to_end_contract.rs @@ -53,6 +53,39 @@ fn production_shape_run_excludes_future_available_evidence() { ); } +#[test] +fn evidence_available_exactly_at_cutoff_is_eligible_and_keeps_membership() { + let request = AnalysisRunRequest { + contract_version: 1, + idempotency_key: "boundary-run-2026-08-01".into(), + tenant_workspace_id: "workspace-opaque-1".into(), + snapshot_id: "snapshot-boundary-2026-08-01".into(), + knowledge_cutoff: "2026-08-01T00:00:00Z".into(), + model_contract_version: "temporal-evidence-v1".into(), + output_profile: "validation-report".into(), + }; + let accepted = + AnalysisRunAccepted::new("run-boundary-1", "accepted", "boundary-run-2026-08-01") + .expect("accepted"); + let corpus = AnalysisCorpus::new( + "snapshot-boundary-2026-08-01", + vec![ + evidence("invoice-on-cutoff", "2026-08-01T00:00:00Z", 3), + evidence("later-correction", "2026-08-01T00:00:01Z", 4), + ], + ) + .expect("snapshot"); + let execution = execute_analysis_run(&request, &accepted, &corpus, "2026-08-01T00:01:00Z") + .expect("execute"); + assert_eq!( + execution.terminal_result.run_state, + AnalysisRunTerminalState::Succeeded + ); + let artifact = execution.artifact.expect("artifact"); + assert_eq!(artifact.eligible_evidence_count, 1); + assert_eq!(artifact.eligible_membership_count, 3); +} + #[test] fn snapshot_identity_is_not_inferred_from_customer_payload() { let request = AnalysisRunRequest { From 340087494b0a9653aede4eeb4bd27049e051222d Mon Sep 17 00:00:00 2001 From: opencode-agent Date: Mon, 24 Aug 2026 12:53:36 +0900 Subject: [PATCH 27/31] fix(quality): keep sibling-block guards and lone closes in coverage gate - Count a multiline match guard whose preceding sibling arm uses a block body: the backward scan now distinguishes the sibling opener from a nested guard match through arrow evidence, so uncovered guards can no longer leave the authored-line denominator (review bug finding). - Keep a lone closing paren in the denominator; it can complete a covered expression statement and excluding it loosened the gate. - Fail closed on checked membership totals instead of relying only on the corpus bound. --- crates/analysis_engine/src/lib.rs | 12 ++++++---- scripts/check_coverage.py | 34 +++++++++++++++++++++------- tests/quality/test_check_coverage.py | 27 ++++++++++++++++++++-- 3 files changed, 58 insertions(+), 15 deletions(-) diff --git a/crates/analysis_engine/src/lib.rs b/crates/analysis_engine/src/lib.rs index 22e574f85..2e28feb8c 100644 --- a/crates/analysis_engine/src/lib.rs +++ b/crates/analysis_engine/src/lib.rs @@ -280,12 +280,14 @@ pub fn execute_analysis_run( }); } - // The corpus bound makes this conversion and sum strictly smaller than - // `u64::MAX`: 100,000 * u32::MAX is below the 64-bit range. + // The corpus bound makes this conversion strictly smaller than + // `u64::MAX`; the fold still fails closed through checked arithmetic so a + // future bound change cannot wrap membership totals silently. let eligible_evidence_count = eligible.len() as u64; - let eligible_membership_count = eligible - .iter() - .fold(0_u64, |sum, unit| sum + u64::from(unit.membership_count)); + let eligible_membership_count = eligible.iter().try_fold(0_u64, |sum, unit| { + sum.checked_add(u64::from(unit.membership_count)) + .ok_or(AnalysisEngineError::ArithmeticOverflow) + })?; let (earliest, latest) = eligible.iter().fold( (eligible[0].event_time, eligible[0].event_time), |(earliest, latest), unit| (earliest.min(unit.event_time), latest.max(unit.event_time)), diff --git a/scripts/check_coverage.py b/scripts/check_coverage.py index 475ac53b9..0ae91b6ed 100644 --- a/scripts/check_coverage.py +++ b/scripts/check_coverage.py @@ -82,7 +82,7 @@ def is_executable_source_line( return False if text.startswith("#[") or text.startswith("#!["): return False - if text in {"{", "}", "},", ")", ");", "];", "();", "};"}: + if text in {"{", "}", "},", ");", "];", "();", "};"}: return False if text.startswith("use ") or text.startswith("pub use "): return False @@ -119,14 +119,32 @@ def _is_multiline_match_guard(lines: list[str], line_number: int) -> bool: target_prefix = lines[line_number - 1].strip().partition("=>")[0] brace_depth = target_prefix.count("}") - target_prefix.count("{") guard_found = False - boundary_candidate = False + inside_block = False + nested_arrow_seen = False for candidate in reversed(lines[: line_number - 1]): stripped = candidate.strip() if brace_depth == 0 and "=>" in stripped: - return guard_found and not boundary_candidate - if brace_depth == 1 and stripped.endswith("=> {"): - boundary_candidate = True - brace_depth += stripped.count("}") - stripped.count("{") + return guard_found + if ( + inside_block + and brace_depth >= 1 + and stripped.endswith("=> {") + and not nested_arrow_seen + ): + # The opener of the preceding sibling arm sits directly above its + # body with no nested match between, so every guard token found so + # far belongs to that sibling rather than to this arm. + return guard_found + if "=>" in stripped and brace_depth >= 1: + nested_arrow_seen = True + next_depth = brace_depth + stripped.count("}") - stripped.count("{") + if brace_depth == 0 < next_depth: + inside_block = True + nested_arrow_seen = False + elif next_depth <= 0 < brace_depth: + inside_block = False + nested_arrow_seen = False + brace_depth = next_depth if ( (stripped.startswith("if ") or stripped.startswith("if(")) and not stripped.endswith(("}", ";")) @@ -134,8 +152,8 @@ def _is_multiline_match_guard(lines: list[str], line_number: int) -> bool: ): guard_found = True if stripped.startswith("match "): - return guard_found and not boundary_candidate - return guard_found and not boundary_candidate + return guard_found + return guard_found def _cfg_test_module_line_numbers(lines: list[str]) -> set[int]: diff --git a/tests/quality/test_check_coverage.py b/tests/quality/test_check_coverage.py index 9f5b11a09..e881dc2f5 100644 --- a/tests/quality/test_check_coverage.py +++ b/tests/quality/test_check_coverage.py @@ -321,7 +321,7 @@ def test_executable_source_line_filters_noise_records(self) -> None: " }", # 55 "}", # 56 " executable_statement();", # 57 executable - ")", # 58 standalone structural close + ")", # 58 lone close completes a covered expression statement "pub(crate) fn crate_visible() {", # 59 visibility-qualified fn "State::Accepted => {", # 60 match-arm structure "State::Guarded(value) if valid(value) => {", # 61 guarded arm is executable @@ -339,7 +339,7 @@ def test_executable_source_line_filters_noise_records(self) -> None: coverage_contract.is_executable_source_line(path, len(source_lines) + 5) ) - expected_executable = {13, 40, 44, 57, 61} + expected_executable = {13, 40, 44, 57, 58, 61} for line_number in range(1, len(source_lines) + 1): is_exec = coverage_contract.is_executable_source_line(path, line_number) if line_number in expected_executable: @@ -574,6 +574,29 @@ def test_previous_arm_body_does_not_make_next_label_executable(self) -> None: ) ) + guarded_after_block = Path(temporary) / "guarded_after_block.rs" + guarded_after_block.write_text( + "match state {\n" + " State::Previous => {\n" + " consume(value);\n" + " }\n" + " State::Ready(value)\n" + " if value.is_valid()\n" + " && value.is_fresh() => {\n" + " consume(value);\n" + " }\n" + "}\n", + encoding="utf-8", + ) + self.assertTrue( + coverage_contract.is_executable_source_line( + str(guarded_after_block), 7 + ) + ) + self.assertFalse( + coverage_contract.is_executable_source_line(str(guarded_after_block), 2) + ) + def test_cfg_test_and_not_feature_block_helpers(self) -> None: """cfg(test) modules and cfg(not(feature)) blocks are fully recognized.""" From 88f10c42732f822942f944c9e7c0a1dd64f3e975 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 22:01:31 +0900 Subject: [PATCH 28/31] docs: assign analysis engine ADR a unique number --- CHANGELOG.md | 2 +- docs/TRACEABILITY.md | 2 +- ...cution.md => 0021-deterministic-analysis-run-execution.md} | 2 +- docs/adr/README.md | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) rename docs/adr/{0017-deterministic-analysis-run-execution.md => 0021-deterministic-analysis-run-execution.md} (98%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0882e7480..6226c1289 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 -- Stacked `analysis_engine` vertical slice (ADR 0017): bounded Rust execution +- Stacked `analysis_engine` vertical slice (ADR 0021): bounded Rust execution from an accepted analysis run to a cutoff-safe, multiple-membership-aware, SHA-256-digest-bound terminal artifact or redacted no-eligible-evidence result. This remains active-PR evidence and does not claim estimator diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 8fe953367..000f9eb48 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -20,7 +20,7 @@ The full APA 7th standards/literature register remains `docs/research/standards- | PostgreSQL bitemporal/lineage persistence | ADR 0013; Architecture/ERD | `persistence_postgres` on protected main as before; `revision_order` later-revision system-time gate on the active PR; remaining physical ERD constraints | partial | | known-truth temporal/event simulation manifests | PRD; TRD; Test Strategy | `tepp_simulation` on protected main; recovery metrics in `validation_core` | implemented-main | | versioned service/API contracts and exports | PRD; API contract; ADR 0011/0013 | `tepp_api` analysis-run/export/JSON-LD/GraphML contracts on protected main (PR #21); request-bound terminal result active in PR #157; HTTP service remains accepted-target | partial | -| executable cutoff-safe analysis-run readiness | ADR 0017; temporal research; API terminal-result contract | stacked `analysis_engine` PR on #157: availability cutoff, snapshot binding, multiple-membership aggregation, digest-bound artifact, realistic end-to-end tests | active-PR | +| executable cutoff-safe analysis-run readiness | ADR 0021; temporal research; API terminal-result contract | stacked `analysis_engine` PR on #157: availability cutoff, snapshot binding, multiple-membership aggregation, digest-bound artifact, realistic end-to-end tests | active-PR | | immutable split/run/reproducibility manifests | ADR 0013; ERD | `tepp_api` reproducibility manifest contract on protected main; `persistence_postgres` append-only SQL insert/lookup for `reproducibility_manifest`, `corpus_split_manifest`, `model_run`, and `model_artifact` (migration `0003`); full physical ERD constraints remaining | partial | | multilingual shared latent semantic space | PRD; ADR 0004; ADR 0020 | `semantic_core` span-grounded units (active-PR); concept dictionary and shared latent estimator remaining | active-PR | | TRSL-TM temporal/relational topic posterior and backend compatibility | ADR 0012; ADR 0004 | future `topic_measurement` | accepted-target | diff --git a/docs/adr/0017-deterministic-analysis-run-execution.md b/docs/adr/0021-deterministic-analysis-run-execution.md similarity index 98% rename from docs/adr/0017-deterministic-analysis-run-execution.md rename to docs/adr/0021-deterministic-analysis-run-execution.md index 567395e8d..cf2dfcd0b 100644 --- a/docs/adr/0017-deterministic-analysis-run-execution.md +++ b/docs/adr/0021-deterministic-analysis-run-execution.md @@ -1,4 +1,4 @@ -# ADR 0017 — Deterministic cutoff-safe analysis-run execution +# ADR 0021 — Deterministic cutoff-safe analysis-run execution **Decision status:** Accepted **Implementation maturity:** active-PR — stacked on PR #157; not implemented-main diff --git a/docs/adr/README.md b/docs/adr/README.md index cae6e39fc..120859f02 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -79,7 +79,7 @@ Read [`ADR_POLICY.md`](ADR_POLICY.md) first. **Decision status and implementatio | [0015](0015-autonomous-development-review-and-merge-authority.md) | Autonomous development, review, and merge authority separation | Accepted | active-PR | Separates model proposal, deterministic verification, publication, independent review, and merge/release authority. | | [0016](0016-tdt-chronos-event-intelligence-boundary.md) | TDT, CHRONOS, and Event Ontology intelligence boundary | Accepted | active-PR | Bounded predicted-vs-observed Allen promotion gate: `refuse_promotion` requires observed coverage; remaining TDT/CHRONOS tasks stay accepted-target. | | [0016](0016-tdt-chronos-event-intelligence-boundary.md) | TDT, CHRONOS, and Event Ontology intelligence boundary | Accepted | accepted-target | Separates observed evidence, detection/tracking, prediction/schema inference, temporal consistency, and promoted transition authority. | -| [0017](0017-deterministic-analysis-run-execution.md) | Deterministic cutoff-safe analysis-run execution | Accepted | active-PR | Stacked on PR #157; closes the first executable buyer path from accepted run to digest-bound terminal result without claiming estimator authority. | +| [0021](0021-deterministic-analysis-run-execution.md) | Deterministic cutoff-safe analysis-run execution | Accepted | active-PR | Stacked on PR #157; closes the first executable buyer path from accepted run to digest-bound terminal result without claiming estimator authority. | | [0020](0020-span-grounded-semantic-units.md) | Span-grounded semantic units; language tags are not identity | Accepted | active-PR | First ADR 0004 production slice. Does not claim concept alignment, invariance, or a topic estimator. | | [0017](0017-hourly-contextual-orchestrator-gateway.md) | Hourly contextual-orchestrator gateway and all-provider model discovery | Accepted | active-PR | Keeps proposal-model execution behind a pinned loopback gateway while preserving independent verifier, publisher, reviewer, and merge authority. | | [0018](0018-consumer-scoped-analysis-run-ingress.md) | Consumer-scoped modular analysis-run ingress | Accepted | active-PR | Narrows ADR 0011 for the closed consumer registry, credential-free exchange, and consumer-qualified idempotency namespace; production TLS remains separate. | @@ -106,7 +106,7 @@ Use the narrowest owning ADR when decisions overlap: - **claim maturity / release evidence:** ADR 0014; - **autonomous development/review/merge authority:** ADR 0015; - **TDT/CHRONOS event intelligence:** ADR 0016; -- **accepted-run execution and terminal artifact production:** ADR 0017. +- **accepted-run execution and terminal artifact production:** ADR 0021. - **hourly proposal gateway and provider discovery:** ADR 0017. - **modular consumer admission / replay identity:** ADR 0018. - **project-history wire-size symmetry:** ADR 0019. From e22dcb11cff81126aa3f3ea5af07b311ff59bcb6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 22:13:32 +0900 Subject: [PATCH 29/31] fix: retain compact guarded match coverage --- scripts/check_coverage.py | 7 ++++++- tests/quality/test_check_coverage.py | 10 ++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/scripts/check_coverage.py b/scripts/check_coverage.py index 8061a2825..684787275 100644 --- a/scripts/check_coverage.py +++ b/scripts/check_coverage.py @@ -167,7 +167,12 @@ def is_executable_source_line( # Keep guarded match arms in the authored-line denominator: the guard # executes even though the arm label itself is structural. if text.endswith("=> {") and " if " not in text: - if text.startswith("if ") or text.startswith("if("): + if ( + text.startswith("if ") + or text.startswith("if(") + or " if(" in text + or " if (" in text + ): return True return _is_multiline_match_guard(lines, line_number) if text.startswith("pub struct ") or text.startswith("struct "): diff --git a/tests/quality/test_check_coverage.py b/tests/quality/test_check_coverage.py index 8409ddd84..01e4bb9eb 100644 --- a/tests/quality/test_check_coverage.py +++ b/tests/quality/test_check_coverage.py @@ -502,6 +502,16 @@ def test_multiline_guard_arm_is_executable(self) -> None: self.assertTrue(coverage_contract.is_executable_source_line(str(source), 4)) self.assertFalse(coverage_contract.is_executable_source_line(str(source), 7)) + source.write_text( + "match state {\n" + " State::Ready(value) if(value.is_valid()) => {\n" + " consume(value);\n" + " }\n" + "}\n", + encoding="utf-8", + ) + self.assertTrue(coverage_contract.is_executable_source_line(str(source), 2)) + def test_guard_after_brace_closing_pattern_is_executable(self) -> None: """Count a guard after a destructuring pattern that closes with a brace.""" From 41a72188d674d693bad7d704b2a760f69261f237 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 22:29:02 +0900 Subject: [PATCH 30/31] remove dead coverage guard branch --- scripts/check_coverage.py | 1 - 1 file changed, 1 deletion(-) diff --git a/scripts/check_coverage.py b/scripts/check_coverage.py index 684787275..85e468426 100644 --- a/scripts/check_coverage.py +++ b/scripts/check_coverage.py @@ -171,7 +171,6 @@ def is_executable_source_line( text.startswith("if ") or text.startswith("if(") or " if(" in text - or " if (" in text ): return True return _is_multiline_match_guard(lines, line_number) From d245a9dd9381a82afdc492272dc3f1be2885d403 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 22:52:55 +0900 Subject: [PATCH 31/31] fix(coverage): stop nested match guard scans at let bindings --- scripts/check_coverage.py | 4 +++- tests/quality/test_check_coverage.py | 12 ++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/scripts/check_coverage.py b/scripts/check_coverage.py index 85e468426..dba4aae65 100644 --- a/scripts/check_coverage.py +++ b/scripts/check_coverage.py @@ -223,7 +223,9 @@ def _is_multiline_match_guard(lines: list[str], line_number: int) -> bool: and brace_depth == 0 ): guard_found = True - if stripped.startswith("match "): + if stripped.startswith("match ") or ( + stripped.startswith("let ") and "= match " in stripped + ): return guard_found return guard_found diff --git a/tests/quality/test_check_coverage.py b/tests/quality/test_check_coverage.py index 01e4bb9eb..b3e988120 100644 --- a/tests/quality/test_check_coverage.py +++ b/tests/quality/test_check_coverage.py @@ -657,6 +657,18 @@ def test_previous_arm_body_does_not_make_next_label_executable(self) -> None: 2, ) ) + self.assertFalse( + coverage_contract._is_multiline_match_guard( + [ + "match state {", + " if previous_guard", + " }", + " let nested = match input {", + " 0 => {", + ], + 5, + ) + ) guarded_after_block = Path(temporary) / "guarded_after_block.rs" guarded_after_block.write_text(