diff --git a/CHANGELOG.md b/CHANGELOG.md index 062a69412..7993299cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,8 @@ All notable changes to TEPP are documented here. The format follows Keep a Chang ## [Unreleased] +- `tepp_api` loopback `AnalysisRunLiveService` now serves `GET /v1/analysis-runs/{run_id}` so accepted/running statuses stay metric-free and only a succeeded status with profile `scientific_acceptance_v1` may return `tepp.scientific_acceptance.v1`. Receipt RMSE/bias/coverage/SE-gate keys, a GET body, failed-plus-artifact emission, an all-zero digest, and digest mismatch fail closed. This is the GAP-003A HTTP status slice for issue #166; it does not duplicate the `analysis_engine` library bind (#356) or the terminal-result DTO wire (#358); persistence remains GAP-003B. + - `event_core` adds bounded Allen interval-consistency classification, atomic path-consistency closure, contradiction/resource refusals, and an explicit dependency-error fallback without claiming unrestricted global satisfiability. - `psychometric_core` recovers the Driver, Oud, and Voelkle (2017, Table 2, p. 12 `MANIFESTTRAITVAR`; §7.1, p. 19; p. 16 `MANIFESTTRAITVARstd`; footnote 4; 2017-era ctsem `summary.ctsemFit.R`; JSS PDF re-opened 2026-08-27T14:20Z from https://www.jstatsoft.org/index.php/jss/article/download/v077i05/1104) scalar standardised manifest-trait variance on current main after `0ce16e8` dropped the pre-consolidation code while research notes already named the map (register items 83–84). Table 2 names `MANIFESTTRAITVAR` `Ψ_τ` the additional time-invariant variance-covariance on the measurement level and sets it `NULL` when there is no manifest trait. Equation 5 writes `Γ ~ N(τ, Ψ)` and names that covariance the manifest traits. Section 7.1 names manifest traits stable individual differences in indicator levels, distinct from process-level `TRAITVAR` `φ_ξ`. Page 16 prints standardised matrices with the suffix `std` when appropriate. The printed example on p. 16 is `discreteDRIFTstd`, not `MANIFESTTRAITVARstd`. Footnote 4 standardises using only the relevant variance, not the total. The relevant variance for that named indicator-level correlation is `MANIFESTTRAITVAR`, not process-level `TRAITVAR` and not residual `MANIFESTVAR` `θ`. The 2017-era source forms `MANIFESTTRAITVARstd` only when `MANIFESTTRAITVAR != 0`, as `solve(sqrt(diag(MANIFESTTRAITVAR) + ridging)) %&% MANIFESTTRAITVAR` when `verbose = TRUE`. OpenMx `%&%` is `t(A) %*% B %*% A`. Unlike `TRAITVARstd`, that formation adds `diag(c(ridging), n.manifest)`. The default `ridging = FALSE` adds 0, not `0.0001`; that ridge is a numerical hack and is not this exact map. The scalar correlation is `ψ / ψ = 1` after strictly positive `MANIFESTTRAITVAR`. Form strictly positive `ψ` first, then `1 / √ψ`, then `(1 / √ψ) ψ (1 / √ψ)`. Unstandardised `MANIFESTTRAITVAR` is defined for a zero trait; standardised `MANIFESTTRAITVAR` is not. Zero `MANIFESTTRAITVAR` skips forming `MANIFESTTRAITVARstd` in the 2017-era source and fails closed here. Indicator-level trait variance is an event-time structural quantity, so a non-event clock fails closed. `MANIFESTTRAITVAR` does not require stable `a < 0`. Distinct positive `ψ` recover the same 1. `trait / trait = 1` is `TRAITVARstd` and recovers the same number and remains a distinct named quantity. `θ` is `MANIFESTVAR` and is measurement error, not this correlation. Meredith (1993) remains unread (web search 2026-08-27T14:20Z: Springer/Cambridge Core paywalled; Unpaywall historically `is_oa: false`; Springer `content/pdf` is an HTML stub). Mislevy (1991, *Psychometrika, 56*, 177–196) remains unread on the same terms (DOI `10.1007/bf02294457`). Still not a Kalman filter, not a matrix `expm`, not ESEM estimation, not DSEM, and not ctsem estimation. diff --git a/crates/psychometric_core/src/error.rs b/crates/psychometric_core/src/error.rs index 4ab2695e0..9c06bbe83 100644 --- a/crates/psychometric_core/src/error.rs +++ b/crates/psychometric_core/src/error.rs @@ -1384,6 +1384,25 @@ mod tests { PsychometricError::InitialObservedMeanIsNotEvolvedObservedMean.to_string(), "first-occasion observed mean is not the evolved observed mean" ); + assert_eq!( + PsychometricError::StandardisedManifestVarianceRequiresPositiveManifestVariance + .to_string(), + "standardised measurement-error variance requires strictly positive measurement-error variance" + ); + assert_eq!( + PsychometricError::UnstandardisedManifestVarianceIsNotStandardisedManifestVariance + .to_string(), + "unstandardised measurement-error variance is not standardised measurement-error variance" + ); + assert_eq!( + PsychometricError::StandardisedManifestTraitVarianceIsNotStandardisedManifestVariance + .to_string(), + "standardised manifest-trait variance is not standardised measurement-error variance" + ); + assert_eq!( + PsychometricError::ObservedVarianceIsNotStandardisedManifestVariance.to_string(), + "observed-indicator variance is not standardised measurement-error variance" + ); } #[test] diff --git a/crates/tepp_api/src/analysis_run_live.rs b/crates/tepp_api/src/analysis_run_live.rs index 6768c6ef1..01800f959 100644 --- a/crates/tepp_api/src/analysis_run_live.rs +++ b/crates/tepp_api/src/analysis_run_live.rs @@ -2,32 +2,51 @@ //! //! This module keeps the Naruon compatibility listener intact while providing //! the shared `/v1/analysis-runs` and cutoff-safe `/v1/temporal-context` -//! boundaries needed by Naruon and `LineageWeave`. It accepts transport -//! acknowledgements and temporal evidence context only; completed psychometric -//! results remain outside this crate. +//! boundaries needed by Naruon and `LineageWeave`. `POST /v1/analysis-runs` +//! accepts transport acknowledgements only. `GET /v1/analysis-runs/{run_id}` +//! returns metric-free accepted/running status, and may return +//! `tepp.scientific_acceptance.v1` only on a succeeded status whose request +//! profile is `scientific_acceptance_v1`. Completed psychometric estimation +//! remains outside this crate. use std::collections::HashMap; use std::io::Write; use std::net::{SocketAddr, TcpListener}; +use crate::analysis_run_status_http::analysis_run_status_path_run_id; use crate::lineageweave_http::{LINEAGEWEAVE_CONSUMER_CODE, consumer_is_supported}; use crate::live_http::{ header_value, map_io_error, parse_headers, parse_request_line, read_http_request_with_limit, split_request_with_limit, validate_common_headers, }; use crate::naruon_http::NARUON_ANALYSIS_RUN_PATH; +use crate::scientific_acceptance_http::{refuse_metrics_on_receipt, status_http_json}; use crate::{ - AnalysisRunAccepted, AnalysisRunRequest, ApiError, DEFAULT_PROJECT_HISTORY_BYTE_LIMIT, - ErrorEnvelope, NARUON_LIVE_IO_TIMEOUT, NaruonLiveResponse, PROJECT_HISTORY_PATH, - ProjectHistoryProjection, ProjectHistoryRequest, TEMPORAL_CONTEXT_PATH, TemporalContextRequest, - build_temporal_context, project_history_projection, requests_are_idempotent_matches, + AnalysisRunAccepted, AnalysisRunRequest, AnalysisRunStatus, ApiError, + DEFAULT_PROJECT_HISTORY_BYTE_LIMIT, ErrorEnvelope, NARUON_LIVE_IO_TIMEOUT, NaruonLiveResponse, + PROJECT_HISTORY_PATH, ProjectHistoryProjection, ProjectHistoryRequest, TEMPORAL_CONTEXT_PATH, + TemporalContextRequest, build_temporal_context, project_history_projection, + requests_are_idempotent_matches, }; +#[cfg(test)] +use crate::require_status_binding; + const MAX_LIVE_REQUEST_BODY_BYTES: usize = DEFAULT_PROJECT_HISTORY_BYTE_LIMIT; #[cfg(test)] use crate::live_http::{declared_content_length, host_implies_table_access, split_header_line}; +/// One accepted loopback analysis run and its current status/read body. +#[derive(Clone, Debug, Eq, PartialEq)] +struct LiveAnalysisRun { + consumer: String, + request: AnalysisRunRequest, + accepted: AnalysisRunAccepted, + status: AnalysisRunStatus, + scientific_acceptance_json: Option, +} + /// Loopback HTTP/1.1 analysis-run service shared by published CWL consumers. /// /// The service accepts only Naruon and `LineageWeave` consumer identities. Its @@ -39,7 +58,8 @@ pub struct AnalysisRunLiveService { bound_addr: Option, next_run_serial: u64, next_request_serial: u64, - accepted_runs: HashMap, + accepted_runs: HashMap, + runs_by_id: HashMap, accepted_project_histories: HashMap, } @@ -59,6 +79,7 @@ impl AnalysisRunLiveService { next_run_serial: 1, next_request_serial: 1, accepted_runs: HashMap::new(), + runs_by_id: HashMap::new(), accepted_project_histories: HashMap::new(), } } @@ -143,6 +164,10 @@ impl AnalysisRunLiveService { let (header_block, body) = split_request_with_limit(request, MAX_LIVE_REQUEST_BODY_BYTES)?; let mut lines = header_block.split("\r\n"); let (method, path) = parse_request_line(lines.next().unwrap_or(""))?; + let headers = parse_headers(&mut lines)?; + if method == "GET" { + return self.read_analysis_run_status(path, &headers, body); + } if method != "POST" || (path != NARUON_ANALYSIS_RUN_PATH && path != TEMPORAL_CONTEXT_PATH @@ -150,7 +175,6 @@ impl AnalysisRunLiveService { { return Err(ApiError::InvalidWirePayload); } - let headers = parse_headers(&mut lines)?; let consumer = require_headers( &headers, self.bound_addr, @@ -176,6 +200,7 @@ impl AnalysisRunLiveService { headers: &HashMap, body: &str, ) -> Result { + refuse_metrics_on_receipt(body)?; let request = AnalysisRunRequest::from_json(body)?; let idempotency_key = header_value(headers, "idempotency-key")?; if idempotency_key != request.idempotency_key { @@ -186,19 +211,102 @@ impl AnalysisRunLiveService { &request.tenant_workspace_id, idempotency_key, ); - if let Some((stored_request, stored_accepted)) = self.accepted_runs.get(&replay_key) { - if requests_are_idempotent_matches(stored_request, &request) { - return Ok(json_response(202, "Accepted", stored_accepted.to_json()?)); + if let Some(stored) = self.accepted_runs.get(&replay_key) { + if requests_are_idempotent_matches(&stored.request, &request) { + let accepted_json = stored.accepted.to_json()?; + refuse_metrics_on_receipt(&accepted_json)?; + return Ok(json_response(202, "Accepted", accepted_json)); } return Err(ApiError::InvalidWirePayload); } let run_id = format!("tepp-run-{}", self.next_run_serial); self.next_run_serial += 1; + if self.runs_by_id.contains_key(&run_id) { + return Err(ApiError::InvalidWirePayload); + } let accepted = - AnalysisRunAccepted::new(run_id, "accepted", request.idempotency_key.clone())?; - let response_body = accepted.to_json()?; - self.accepted_runs.insert(replay_key, (request, accepted)); - Ok(json_response(202, "Accepted", response_body)) + AnalysisRunAccepted::new(run_id.clone(), "accepted", request.idempotency_key.clone())?; + let accepted_json = accepted.to_json()?; + refuse_metrics_on_receipt(&accepted_json)?; + let status = AnalysisRunStatus::accepted(&accepted)?; + self.runs_by_id.insert(run_id, replay_key.clone()); + self.accepted_runs.insert( + replay_key, + LiveAnalysisRun { + consumer: consumer.to_owned(), + request, + accepted, + status, + scientific_acceptance_json: None, + }, + ); + Ok(json_response(202, "Accepted", accepted_json)) + } + + fn read_analysis_run_status( + &mut self, + path: &str, + headers: &HashMap, + body: &str, + ) -> Result { + if !body.is_empty() { + return Err(ApiError::InvalidWirePayload); + } + let run_id = analysis_run_status_path_run_id(path)?; + let consumer = require_headers(headers, self.bound_addr, true)?; + let idempotency_key = header_value(headers, "idempotency-key")?; + let replay_key = self + .runs_by_id + .get(&run_id) + .ok_or(ApiError::InvalidWirePayload)?; + let stored = self + .accepted_runs + .get(replay_key) + .ok_or(ApiError::InvalidWirePayload)?; + if stored.consumer != consumer || stored.accepted.idempotency_key != idempotency_key { + return Err(ApiError::InvalidWirePayload); + } + let response_body = status_http_json( + &stored.status, + &stored.request, + stored.scientific_acceptance_json.as_deref(), + )?; + Ok(json_response(200, "OK", response_body)) + } + + /// Record a loopback lifecycle transition for an already accepted run. + /// + /// Tests and the in-memory listener use this helper because completed + /// psychometric execution remains outside this crate. HTTP GET is the only + /// operator-visible status path. + #[cfg(test)] + pub(crate) fn record_loopback_status( + &mut self, + run_id: &str, + status: AnalysisRunStatus, + scientific_acceptance_json: Option, + ) -> Result<(), ApiError> { + let replay_key = self + .runs_by_id + .get(run_id) + .ok_or(ApiError::InvalidWirePayload)? + .clone(); + let stored = self + .accepted_runs + .get_mut(&replay_key) + .ok_or(ApiError::InvalidWirePayload)?; + if stored.accepted.run_id != run_id { + return Err(ApiError::InvalidWirePayload); + } + require_status_binding(&stored.request, &stored.accepted, &status)?; + let _ = status_http_json( + &status, + &stored.request, + scientific_acceptance_json.as_deref(), + )?; + stored.status = status; + stored.scientific_acceptance_json = scientific_acceptance_json; + Ok(()) } fn accept_project_history( @@ -316,11 +424,14 @@ mod tests { }; use crate::live_http::{host_is_loopback, read_http_request, split_request}; use crate::{ - ANALYSIS_RUN_CONTRACT_VERSION, AnalysisRunRequest, ApiError, + ANALYSIS_RUN_CONTRACT_VERSION, AnalysisResultSummary, AnalysisRunAccepted, + AnalysisRunRequest, AnalysisRunStatus, AnalysisRunTerminalResult, ApiError, DEFAULT_ANALYSIS_RUN_BYTE_LIMIT, ErrorEnvelope, LINEAGEWEAVE_CONSUMER_CODE, NARUON_ANALYSIS_RUN_PATH, NARUON_CONSUMER_CODE, NARUON_LIVE_HEADER_BYTE_LIMIT, - NARUON_LIVE_HEADER_COUNT_LIMIT, NARUON_LIVE_IO_TIMEOUT, TEMPORAL_CONTEXT_PATH, + NARUON_LIVE_HEADER_COUNT_LIMIT, NARUON_LIVE_IO_TIMEOUT, SCIENTIFIC_ACCEPTANCE_HTTP_PROFILE, + SCIENTIFIC_ACCEPTANCE_HTTP_SCHEMA, TEMPORAL_CONTEXT_PATH, }; + use sha2::{Digest, Sha256}; fn sample_run() -> AnalysisRunRequest { AnalysisRunRequest { @@ -343,6 +454,39 @@ mod tests { request } + fn http_get(path: &str, headers: &[(&str, &str)]) -> String { + let mut request = format!("GET {path} HTTP/1.1\r\n"); + for (name, value) in headers { + write!(request, "{name}: {value}\r\n").expect("header"); + } + request.push_str("content-length: 0\r\n\r\n"); + request + } + + fn status_get(run_id: &str, consumer: &str, idempotency_key: &str) -> String { + http_get( + &format!("{NARUON_ANALYSIS_RUN_PATH}/{run_id}"), + &[ + ("Host", "127.0.0.1"), + ("content-type", "application/json"), + ("tepp-consumer", consumer), + ("tepp-contract-version", "1"), + ("idempotency-key", idempotency_key), + ], + ) + } + + fn sha256_hex(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let digest = Sha256::digest(bytes); + let mut encoded = String::with_capacity(64); + for byte in digest { + encoded.push(char::from(HEX[usize::from(byte >> 4)])); + encoded.push(char::from(HEX[usize::from(byte & 0x0f)])); + } + encoded + } + fn valid_request(run: &AnalysisRunRequest, consumer: &str, host: &str) -> String { let body = run.to_json().expect("run json"); http_request( @@ -938,6 +1082,280 @@ mod tests { ); } + #[test] + #[allow(clippy::too_many_lines)] + fn get_status_keeps_receipts_metric_free_and_gates_scientific_acceptance() { + let mut run = sample_run(); + run.output_profile = SCIENTIFIC_ACCEPTANCE_HTTP_PROFILE.into(); + let mut service = AnalysisRunLiveService::new(); + let accepted = + service.handle_http_request(&valid_request(&run, NARUON_CONSUMER_CODE, "127.0.0.1")); + assert_eq!(accepted.status_code, 202); + assert!(!accepted.body.contains("scientific_acceptance")); + assert!(!accepted.body.contains("rmse")); + let accepted_dto = AnalysisRunAccepted::from_json(&accepted.body).expect("accepted"); + assert_eq!( + service + .handle_http_request( + &valid_request(&run, NARUON_CONSUMER_CODE, "127.0.0.1",) + .replacen("POST", "PUT", 1) + ) + .status_code, + 400 + ); + let get_accepted = service.handle_http_request(&status_get( + &accepted_dto.run_id, + NARUON_CONSUMER_CODE, + run.idempotency_key.as_str(), + )); + assert_eq!(get_accepted.status_code, 200); + assert!(get_accepted.body.contains("\"accepted\"")); + assert!(!get_accepted.body.contains("scientific_acceptance")); + assert!(!get_accepted.body.contains("rmse")); + service + .accepted_runs + .values_mut() + .next() + .expect("stored run") + .scientific_acceptance_json = Some("{}".into()); + assert_eq!( + service + .handle_http_request(&status_get( + &accepted_dto.run_id, + NARUON_CONSUMER_CODE, + run.idempotency_key.as_str(), + )) + .status_code, + 400 + ); + service + .accepted_runs + .values_mut() + .next() + .expect("stored run") + .scientific_acceptance_json = None; + + let encoded = service.handle_http_request(&http_get( + &format!("{NARUON_ANALYSIS_RUN_PATH}/tepp-run-%31"), + &[ + ("Host", "127.0.0.1"), + ("content-type", "application/json"), + ("tepp-consumer", NARUON_CONSUMER_CODE), + ("tepp-contract-version", "1"), + ("idempotency-key", run.idempotency_key.as_str()), + ], + )); + assert_eq!(encoded.status_code, 200); + + let running = AnalysisRunStatus::running(&accepted_dto).expect("running"); + service + .record_loopback_status(&accepted_dto.run_id, running, None) + .expect("running recorded"); + let get_running = service.handle_http_request(&status_get( + &accepted_dto.run_id, + NARUON_CONSUMER_CODE, + run.idempotency_key.as_str(), + )); + assert_eq!(get_running.status_code, 200); + assert!(get_running.body.contains("\"running\"")); + assert!(!get_running.body.contains("scientific_acceptance")); + + let artifact = format!( + r#"{{"schema_version":"{SCIENTIFIC_ACCEPTANCE_HTTP_SCHEMA}","output_profile":"{SCIENTIFIC_ACCEPTANCE_HTTP_PROFILE}","binding_sha256":"{}","run_id":"{}"}}"#, + "ab".repeat(32), + accepted_dto.run_id + ); + let digest = sha256_hex(artifact.as_bytes()); + let terminal = AnalysisRunTerminalResult::succeeded( + &run, + &accepted_dto, + "artifact-live-1", + digest, + SCIENTIFIC_ACCEPTANCE_HTTP_SCHEMA, + "2026-08-02T03:04:05Z", + AnalysisResultSummary::new("scientific_acceptance", 4, 8, "validated") + .expect("summary"), + ) + .expect("terminal"); + let succeeded = + AnalysisRunStatus::terminal(&run, &accepted_dto, terminal).expect("succeeded"); + service + .record_loopback_status(&accepted_dto.run_id, succeeded, Some(artifact.clone())) + .expect("succeeded recorded"); + let get_succeeded = service.handle_http_request(&status_get( + &accepted_dto.run_id, + NARUON_CONSUMER_CODE, + run.idempotency_key.as_str(), + )); + assert_eq!(get_succeeded.status_code, 200); + assert!( + get_succeeded + .body + .contains(SCIENTIFIC_ACCEPTANCE_HTTP_SCHEMA) + ); + assert!(get_succeeded.body.contains("scientific_acceptance")); + + let failed = AnalysisRunTerminalResult::failed( + &run, + &accepted_dto, + "2026-08-02T03:04:05Z", + "estimation_failed", + ) + .expect("failed"); + let failed_status = + AnalysisRunStatus::terminal(&run, &accepted_dto, failed).expect("failed status"); + assert_eq!( + service.record_loopback_status(&accepted_dto.run_id, failed_status, Some(artifact),), + Err(ApiError::InvalidWirePayload) + ); + + assert_eq!( + service + .handle_http_request(&status_get( + &accepted_dto.run_id, + LINEAGEWEAVE_CONSUMER_CODE, + run.idempotency_key.as_str(), + )) + .status_code, + 400 + ); + assert_eq!( + service + .handle_http_request(&status_get( + &accepted_dto.run_id, + NARUON_CONSUMER_CODE, + "wrong-key", + )) + .status_code, + 400 + ); + assert_eq!( + service + .handle_http_request(&status_get( + "missing-run", + NARUON_CONSUMER_CODE, + run.idempotency_key.as_str(), + )) + .status_code, + 400 + ); + let mut with_body = status_get( + &accepted_dto.run_id, + NARUON_CONSUMER_CODE, + run.idempotency_key.as_str(), + ); + with_body = with_body.replace("content-length: 0", "content-length: 1"); + with_body.push('x'); + assert_eq!(service.handle_http_request(&with_body).status_code, 400); + assert_eq!( + service + .handle_http_request(&http_get( + NARUON_ANALYSIS_RUN_PATH, + &[ + ("Host", "127.0.0.1"), + ("content-type", "application/json"), + ("tepp-consumer", NARUON_CONSUMER_CODE), + ("tepp-contract-version", "1"), + ("idempotency-key", run.idempotency_key.as_str()), + ], + )) + .status_code, + 400 + ); + + let metric_body = run + .to_json() + .expect("json") + .replacen('{', r#"{"rmse":0.1,"#, 1); + assert_eq!( + service + .handle_http_request(&http_request( + &metric_body, + &[ + ("Host", "127.0.0.1"), + ("content-type", "application/json"), + ("tepp-consumer", NARUON_CONSUMER_CODE), + ("tepp-contract-version", "1"), + ("idempotency-key", run.idempotency_key.as_str()), + ], + )) + .status_code, + 400 + ); + + assert_eq!( + service.record_loopback_status("missing", failed_status_placeholder(), None), + Err(ApiError::InvalidWirePayload) + ); + service + .runs_by_id + .insert("ghost".into(), "missing-replay".into()); + assert_eq!( + service + .handle_http_request(&status_get( + "ghost", + NARUON_CONSUMER_CODE, + run.idempotency_key.as_str(), + )) + .status_code, + 400 + ); + assert_eq!( + service.record_loopback_status("ghost", failed_status_placeholder(), None), + Err(ApiError::InvalidWirePayload) + ); + let replay = service + .runs_by_id + .get(&accepted_dto.run_id) + .expect("replay") + .clone(); + service.runs_by_id.insert("tepp-run-other".into(), replay); + assert_eq!( + service.record_loopback_status( + "tepp-run-other", + AnalysisRunStatus::accepted(&accepted_dto).expect("status"), + None, + ), + Err(ApiError::InvalidWirePayload) + ); + + let mut collision = AnalysisRunLiveService::new(); + collision + .runs_by_id + .insert("tepp-run-1".into(), "preloaded".into()); + collision.next_run_serial = 1; + assert_eq!( + collision + .handle_http_request(&valid_request(&run, NARUON_CONSUMER_CODE, "127.0.0.1")) + .status_code, + 400 + ); + assert_eq!( + service + .handle_http_request(&http_get( + &format!("{NARUON_ANALYSIS_RUN_PATH}/{}", accepted_dto.run_id), + &[ + ("Host", "127.0.0.1"), + ("content-type", "application/json"), + ("tepp-consumer", NARUON_CONSUMER_CODE), + ("tepp-contract-version", "1"), + ("idempotency-key", run.idempotency_key.as_str()), + ("authorization", "Bearer secret"), + ], + )) + .status_code, + 403 + ); + } + + fn failed_status_placeholder() -> AnalysisRunStatus { + let request = sample_run(); + let accepted = + AnalysisRunAccepted::new("missing", "accepted", request.idempotency_key.clone()) + .expect("accepted"); + AnalysisRunStatus::accepted(&accepted).expect("status") + } + struct ScriptedRead { reader: Cursor>, first_error: Option, diff --git a/crates/tepp_api/src/analysis_run_status_http.rs b/crates/tepp_api/src/analysis_run_status_http.rs index 48a1033b4..3286283e8 100644 --- a/crates/tepp_api/src/analysis_run_status_http.rs +++ b/crates/tepp_api/src/analysis_run_status_http.rs @@ -65,6 +65,74 @@ fn encode_path_segment(value: &str) -> String { out } +/// Decode one status-path segment and refuse empty, slash, or hostile values. +/// +/// # Errors +/// +/// Returns [`ApiError::InvalidWirePayload`] for truncated encodings, non-UTF-8 +/// octets, empty results, or a decoded slash/NUL. +pub(crate) fn decode_path_segment(value: &str) -> Result { + let mut out = Vec::with_capacity(value.len()); + let bytes = value.as_bytes(); + let mut index = 0; + while index < bytes.len() { + match bytes[index] { + b'%' => { + if index + 2 >= bytes.len() { + return Err(ApiError::InvalidWirePayload); + } + let hi = from_hex(bytes[index + 1])?; + let lo = from_hex(bytes[index + 2])?; + out.push((hi << 4) | lo); + index += 3; + } + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => { + out.push(bytes[index]); + index += 1; + } + _ => return Err(ApiError::InvalidWirePayload), + } + } + let decoded = String::from_utf8(out).map_err(|_| ApiError::InvalidWirePayload)?; + if decoded.is_empty() || decoded.contains('/') || decoded.contains('\0') { + return Err(ApiError::InvalidWirePayload); + } + Ok(decoded) +} + +fn from_hex(byte: u8) -> Result { + match byte { + b'0'..=b'9' => Ok(byte - b'0'), + b'a'..=b'f' => Ok(byte - b'a' + 10), + b'A'..=b'F' => Ok(byte - b'A' + 10), + _ => Err(ApiError::InvalidWirePayload), + } +} + +/// Extract the opaque run identity from `GET /v1/analysis-runs/{run_id}`. +/// +/// # Errors +/// +/// Returns [`ApiError::InvalidWirePayload`] for a collection path, extra +/// segments, or a hostile encoding, and [`ApiError::LimitExceeded`] when the +/// decoded identity exceeds [`ANALYSIS_RUN_ID_MAX_LEN`]. +pub(crate) fn analysis_run_status_path_run_id(path: &str) -> Result { + let remainder = path + .strip_prefix(ANALYSIS_RUN_STATUS_PATH) + .ok_or(ApiError::InvalidWirePayload)?; + let encoded = remainder + .strip_prefix('/') + .ok_or(ApiError::InvalidWirePayload)?; + if encoded.is_empty() || encoded.contains('/') { + return Err(ApiError::InvalidWirePayload); + } + let run_id = decode_path_segment(encoded)?; + if run_id.len() > ANALYSIS_RUN_ID_MAX_LEN { + return Err(ApiError::LimitExceeded); + } + Ok(run_id) +} + #[cfg(test)] mod tests { use super::*; @@ -112,4 +180,65 @@ mod tests { let result = naruon_analysis_run_status_exchange("https://t.example.com", &big, "k"); assert_eq!(result.unwrap_err(), ApiError::LimitExceeded); } + + #[test] + fn decodes_status_path_identities_and_refuses_hostile_segments() { + assert_eq!( + analysis_run_status_path_run_id("/v1/analysis-runs/tepp-run-1").expect("plain"), + "tepp-run-1" + ); + assert_eq!( + decode_path_segment("run%2dabc").expect("lower hex"), + "run-abc" + ); + assert_eq!( + decode_path_segment("run%2Dabc").expect("upper hex"), + "run-abc" + ); + assert_eq!( + analysis_run_status_path_run_id("/v1/analysis-runs"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + analysis_run_status_path_run_id("/v1/other/tepp-run-1"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + analysis_run_status_path_run_id("/v1/analysis-runs/"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + analysis_run_status_path_run_id("/v1/analysis-runs/a/b"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + analysis_run_status_path_run_id("/v1/analysis-runs/%2F"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!(decode_path_segment("%"), Err(ApiError::InvalidWirePayload)); + assert_eq!(decode_path_segment("%2"), Err(ApiError::InvalidWirePayload)); + assert_eq!(decode_path_segment(""), Err(ApiError::InvalidWirePayload)); + assert_eq!( + decode_path_segment("%ZZ"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + decode_path_segment("run id"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + decode_path_segment("%00"), + Err(ApiError::InvalidWirePayload) + ); + let oversized = "a".repeat(ANALYSIS_RUN_ID_MAX_LEN + 1); + assert_eq!( + analysis_run_status_path_run_id(&format!("/v1/analysis-runs/{oversized}")), + Err(ApiError::LimitExceeded) + ); + let invalid_utf8 = "%FF"; + assert_eq!( + decode_path_segment(invalid_utf8), + Err(ApiError::InvalidWirePayload) + ); + } } diff --git a/crates/tepp_api/src/lib.rs b/crates/tepp_api/src/lib.rs index 876703ebc..0f03ebe59 100644 --- a/crates/tepp_api/src/lib.rs +++ b/crates/tepp_api/src/lib.rs @@ -9,7 +9,10 @@ //! may also request a cutoff-safe project-history projection from explicit //! source evidence. Naruon owns the current purpose-bound export adapter. //! Loopback listeners prove the HTTP boundary without claiming production TLS, -//! causality, or completed psychometric model results. +//! causality, or completed psychometric model results. `GET /v1/analysis-runs/{run_id}` +//! on the loopback listener keeps accepted and running statuses metric-free; +//! only a succeeded status with profile `scientific_acceptance_v1` may return +//! `tepp.scientific_acceptance.v1`. mod analysis_result; mod analysis_run; @@ -30,6 +33,7 @@ mod orchestration; mod project_history; mod project_journey; mod provider_payload; +mod scientific_acceptance_http; mod temporal_context; mod wire; @@ -95,6 +99,14 @@ pub use export::GraphMlExport; pub use export::JsonLdExport; /// Reproducibility manifest. pub use export::ReproducibilityManifest; +/// Output profile that authorizes scientific-acceptance on a loopback GET. +pub use scientific_acceptance_http::SCIENTIFIC_ACCEPTANCE_HTTP_PROFILE; +/// Schema identity returned on a succeeded scientific-acceptance GET. +pub use scientific_acceptance_http::SCIENTIFIC_ACCEPTANCE_HTTP_SCHEMA; +/// Detect scientific-metric keys on a receipt JSON object. +pub use scientific_acceptance_http::receipt_json_carries_scientific_metrics; +/// Refuse scientific-metric keys on request, accepted, or non-terminal status JSON. +pub use scientific_acceptance_http::refuse_metrics_on_receipt; /// Analytical export purpose. pub use authorization::AnalyticalPurpose; diff --git a/crates/tepp_api/src/scientific_acceptance_http.rs b/crates/tepp_api/src/scientific_acceptance_http.rs new file mode 100644 index 000000000..7803657c2 --- /dev/null +++ b/crates/tepp_api/src/scientific_acceptance_http.rs @@ -0,0 +1,555 @@ +//! Loopback HTTP gates for `tepp.scientific_acceptance.v1` status reads. +//! +//! GAP-003A third slice: `POST /v1/analysis-runs` stays a metric-free receipt. +//! `GET /v1/analysis-runs/{run_id}` returns an accepted or running status with +//! no scientific-acceptance object. Only a succeeded status whose request +//! profile is `scientific_acceptance_v1` may carry `tepp.scientific_acceptance.v1`. +//! This module does not define the terminal-result DTO; that wire belongs to +//! the separate GAP-003A API slice. Persistence remains GAP-003B. + +use crate::wire::require_nonempty; +use crate::{ + AnalysisRunRequest, AnalysisRunStatus, AnalysisRunStatusState, AnalysisRunTerminalState, + ApiError, +}; +use sha2::{Digest, Sha256}; + +/// Schema identity returned on a succeeded scientific-acceptance GET. +pub const SCIENTIFIC_ACCEPTANCE_HTTP_SCHEMA: &str = "tepp.scientific_acceptance.v1"; +/// Output profile that authorizes the scientific-acceptance HTTP attachment. +pub const SCIENTIFIC_ACCEPTANCE_HTTP_PROFILE: &str = "scientific_acceptance_v1"; + +const FORBIDDEN_RECEIPT_KEYS: [&str; 12] = [ + "rmse", + "rmse_standard_error", + "mean_bias", + "bias_standard_error", + "interval_coverage", + "coverage_wilson_lower", + "coverage_wilson_upper", + "temporal_order_accuracy", + "se_gate_accepted", + "se_gate_k", + "scientific_acceptance", + "report", +]; + +/// Return whether a receipt JSON object carries scientific-metric keys. +/// +/// Request and accepted receipts, and accepted/running status bodies, must +/// remain metric-free. Unknown-field denial is the DTO gate; this helper names +/// the forbidden keys for the HTTP boundary. +#[must_use] +pub fn receipt_json_carries_scientific_metrics(payload: &str) -> bool { + let Ok(value) = serde_json::from_str::(payload) else { + return false; + }; + let Some(object) = value.as_object() else { + return false; + }; + FORBIDDEN_RECEIPT_KEYS + .iter() + .any(|key| object.contains_key(*key)) +} + +/// Refuse a request, accepted receipt, or non-terminal status that already +/// carries scientific-metric keys. +/// +/// # Errors +/// +/// Returns [`ApiError::InvalidWirePayload`] when a forbidden metric key is +/// present on a receipt object. +pub fn refuse_metrics_on_receipt(payload: &str) -> Result<(), ApiError> { + if receipt_json_carries_scientific_metrics(payload) { + Err(ApiError::InvalidWirePayload) + } else { + Ok(()) + } +} + +/// Serialize one loopback status GET body with HTTP-layer scientific-acceptance +/// gates. +/// +/// Accepted and running statuses stay metric-free. A failed status cannot carry +/// the artifact. A succeeded status may carry `tepp.scientific_acceptance.v1` +/// only when the request profile is `scientific_acceptance_v1`, the binding +/// digest is a non-zero canonical SHA-256, and the artifact bytes match +/// `result_sha256`. +/// +/// # Errors +/// +/// Returns [`ApiError::InvalidWirePayload`] for profile, state, digest, or +/// metric-key violations, and [`ApiError::LimitExceeded`] is not used here. +pub(crate) fn status_http_json( + status: &AnalysisRunStatus, + request: &AnalysisRunRequest, + artifact_json: Option<&str>, +) -> Result { + request.validate()?; + if status.idempotency_key != request.idempotency_key { + return Err(ApiError::InvalidWirePayload); + } + match status.run_state { + AnalysisRunStatusState::Accepted + | AnalysisRunStatusState::Running + | AnalysisRunStatusState::Failed => { + if artifact_json.is_some() { + return Err(ApiError::InvalidWirePayload); + } + let status_json = status.to_json()?; + refuse_metrics_on_receipt(&status_json)?; + Ok(status_json) + } + AnalysisRunStatusState::Succeeded => match ( + request.output_profile.as_str() == SCIENTIFIC_ACCEPTANCE_HTTP_PROFILE, + artifact_json, + status.terminal_result.as_ref(), + ) { + (false, None, Some(_)) => { + let status_json = status.to_json()?; + refuse_metrics_on_receipt(&status_json)?; + Ok(status_json) + } + (true, Some(artifact), Some(terminal)) => { + validate_scientific_acceptance_terminal(terminal, artifact)?; + let status_json = status.to_json()?; + refuse_metrics_on_receipt(&status_json)?; + inject_scientific_acceptance_http(&status_json, artifact) + } + _ => Err(ApiError::InvalidWirePayload), + }, + } +} + +fn validate_scientific_acceptance_terminal( + terminal: &crate::AnalysisRunTerminalResult, + artifact_json: &str, +) -> Result<(), ApiError> { + if terminal.run_state != AnalysisRunTerminalState::Succeeded { + return Err(ApiError::InvalidWirePayload); + } + let schema = terminal + .result_schema_version + .as_deref() + .ok_or(ApiError::InvalidWirePayload)?; + let digest = terminal + .result_sha256 + .as_deref() + .ok_or(ApiError::InvalidWirePayload)?; + if schema != SCIENTIFIC_ACCEPTANCE_HTTP_SCHEMA + || terminal.output_profile != SCIENTIFIC_ACCEPTANCE_HTTP_PROFILE + { + return Err(ApiError::InvalidWirePayload); + } + require_nonzero_canonical_sha256(digest)?; + let _artifact = parse_scientific_acceptance_http_artifact(artifact_json)?; + if sha256_hex(artifact_json.as_bytes()) != digest { + return Err(ApiError::InvalidWirePayload); + } + Ok(()) +} + +pub(crate) fn inject_scientific_acceptance_http( + status_json: &str, + artifact_json: &str, +) -> Result { + let artifact = parse_scientific_acceptance_http_artifact(artifact_json)?; + let mut status_value: serde_json::Value = + serde_json::from_str(status_json).map_err(|_| ApiError::InvalidWirePayload)?; + let terminal_value = status_value + .get_mut("terminal_result") + .ok_or(ApiError::InvalidWirePayload)?; + let terminal_object = terminal_value + .as_object_mut() + .ok_or(ApiError::InvalidWirePayload)?; + terminal_object.insert("scientific_acceptance".to_owned(), artifact); + serde_json::to_string(&status_value).map_err(|_| ApiError::InvalidWirePayload) +} + +fn parse_scientific_acceptance_http_artifact( + artifact_json: &str, +) -> Result { + require_nonempty(artifact_json)?; + let value: serde_json::Value = + serde_json::from_str(artifact_json).map_err(|_| ApiError::InvalidWirePayload)?; + let object = value.as_object().ok_or(ApiError::InvalidWirePayload)?; + let schema = object + .get("schema_version") + .and_then(serde_json::Value::as_str) + .ok_or(ApiError::InvalidWirePayload)?; + let profile = object + .get("output_profile") + .and_then(serde_json::Value::as_str) + .ok_or(ApiError::InvalidWirePayload)?; + let binding = object + .get("binding_sha256") + .and_then(serde_json::Value::as_str) + .ok_or(ApiError::InvalidWirePayload)?; + if schema != SCIENTIFIC_ACCEPTANCE_HTTP_SCHEMA || profile != SCIENTIFIC_ACCEPTANCE_HTTP_PROFILE + { + return Err(ApiError::InvalidWirePayload); + } + require_nonzero_canonical_sha256(binding)?; + Ok(value) +} + +fn require_nonzero_canonical_sha256(value: &str) -> Result<(), ApiError> { + let valid = value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)); + if !valid || value.bytes().all(|byte| byte == b'0') { + return Err(ApiError::InvalidWirePayload); + } + Ok(()) +} + +fn sha256_hex(bytes: &[u8]) -> String { + encode_hex(&Sha256::digest(bytes)) +} + +fn encode_hex(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut encoded = String::with_capacity(bytes.len() * 2); + for byte in bytes { + encoded.push(char::from(HEX[usize::from(byte >> 4)])); + encoded.push(char::from(HEX[usize::from(byte & 0x0f)])); + } + encoded +} + +#[cfg(test)] +mod tests { + use super::{ + SCIENTIFIC_ACCEPTANCE_HTTP_PROFILE, SCIENTIFIC_ACCEPTANCE_HTTP_SCHEMA, encode_hex, + inject_scientific_acceptance_http, receipt_json_carries_scientific_metrics, + refuse_metrics_on_receipt, require_nonzero_canonical_sha256, sha256_hex, status_http_json, + }; + use crate::{ + ANALYSIS_RUN_CONTRACT_VERSION, AnalysisResultSummary, AnalysisRunAccepted, + AnalysisRunRequest, AnalysisRunStatus, AnalysisRunStatusState, AnalysisRunTerminalResult, + AnalysisRunTerminalState, ApiError, + }; + + fn request(profile: &str) -> AnalysisRunRequest { + AnalysisRunRequest { + contract_version: ANALYSIS_RUN_CONTRACT_VERSION, + idempotency_key: "idem-http-1".into(), + tenant_workspace_id: "tenant-http-1".into(), + snapshot_id: "snapshot-http-1".into(), + knowledge_cutoff: "2026-08-01T00:00:00Z".into(), + model_contract_version: "validation_cpu_f64_v1".into(), + output_profile: profile.into(), + } + } + + fn accepted() -> AnalysisRunAccepted { + AnalysisRunAccepted::new("tepp-run-1", "accepted", "idem-http-1").expect("accepted") + } + + fn summary() -> AnalysisResultSummary { + AnalysisResultSummary::new("scientific_acceptance", 4, 8, "validated").expect("summary") + } + + fn artifact_json(binding: &str) -> String { + format!( + r#"{{"schema_version":"{SCIENTIFIC_ACCEPTANCE_HTTP_SCHEMA}","output_profile":"{SCIENTIFIC_ACCEPTANCE_HTTP_PROFILE}","binding_sha256":"{binding}","run_id":"tepp-run-1"}}"# + ) + } + + fn succeeded(profile: &str, digest: &str, schema: &str) -> AnalysisRunStatus { + let request = request(profile); + let accepted = accepted(); + let terminal = AnalysisRunTerminalResult::succeeded( + &request, + &accepted, + "artifact-http-1", + digest, + schema, + "2026-08-02T03:04:05Z", + summary(), + ) + .expect("succeeded"); + AnalysisRunStatus::terminal(&request, &accepted, terminal).expect("status") + } + + #[test] + fn receipt_metric_keys_and_nonzero_digests_fail_closed() { + assert!(!receipt_json_carries_scientific_metrics("{")); + assert!(!receipt_json_carries_scientific_metrics("[]")); + assert!(!receipt_json_carries_scientific_metrics("{}")); + assert!(refuse_metrics_on_receipt(r#"{"run_id":"tepp-run-1"}"#).is_ok()); + for key in [ + "rmse", + "rmse_standard_error", + "mean_bias", + "bias_standard_error", + "interval_coverage", + "coverage_wilson_lower", + "coverage_wilson_upper", + "temporal_order_accuracy", + "se_gate_accepted", + "se_gate_k", + "scientific_acceptance", + "report", + ] { + let payload = format!(r#"{{"{key}":1}}"#); + assert!(receipt_json_carries_scientific_metrics(&payload), "{key}"); + assert_eq!( + refuse_metrics_on_receipt(&payload), + Err(ApiError::InvalidWirePayload), + "{key}" + ); + } + assert_eq!( + require_nonzero_canonical_sha256(&"0".repeat(64)), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + require_nonzero_canonical_sha256("not-a-digest"), + Err(ApiError::InvalidWirePayload) + ); + assert!(require_nonzero_canonical_sha256(&"ab".repeat(32)).is_ok()); + assert_eq!(encode_hex(&[0x0f, 0xa0]), "0fa0"); + assert_eq!(sha256_hex(b"").len(), 64); + } + + #[test] + fn accepted_running_and_failed_status_bodies_stay_metric_free() { + let request = request("calibrated_event_measurement"); + let accepted = accepted(); + let accepted_status = AnalysisRunStatus::accepted(&accepted).expect("accepted"); + let running_status = AnalysisRunStatus::running(&accepted).expect("running"); + let accepted_json = + status_http_json(&accepted_status, &request, None).expect("accepted json"); + let running_json = status_http_json(&running_status, &request, None).expect("running json"); + assert!(!accepted_json.contains("scientific_acceptance")); + assert!(!running_json.contains("rmse")); + assert_eq!( + status_http_json(&accepted_status, &request, Some("{}")), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + status_http_json(&running_status, &request, Some("{}")), + Err(ApiError::InvalidWirePayload) + ); + + let failed = AnalysisRunTerminalResult::failed( + &request, + &accepted, + "2026-08-02T03:04:05Z", + "estimation_failed", + ) + .expect("failed"); + let failed_status = + AnalysisRunStatus::terminal(&request, &accepted, failed).expect("failed status"); + assert!( + status_http_json(&failed_status, &request, None) + .expect("failed json") + .contains("\"failed\"") + ); + assert_eq!( + status_http_json( + &failed_status, + &request, + Some(&artifact_json(&"ab".repeat(32))) + ), + Err(ApiError::InvalidWirePayload) + ); + } + + #[test] + fn only_succeeded_scientific_acceptance_profile_may_return_schema() { + let binding = "ab".repeat(32); + let artifact = artifact_json(&binding); + let digest = sha256_hex(artifact.as_bytes()); + let other = succeeded("calibrated_event_measurement", &digest, "tepp-result-v1"); + let other_request = request("calibrated_event_measurement"); + let other_json = status_http_json(&other, &other_request, None).expect("other"); + assert!(!other_json.contains(SCIENTIFIC_ACCEPTANCE_HTTP_SCHEMA)); + assert_eq!( + status_http_json(&other, &other_request, Some(&artifact)), + Err(ApiError::InvalidWirePayload) + ); + + let profile_request = request(SCIENTIFIC_ACCEPTANCE_HTTP_PROFILE); + let missing = succeeded( + SCIENTIFIC_ACCEPTANCE_HTTP_PROFILE, + &digest, + SCIENTIFIC_ACCEPTANCE_HTTP_SCHEMA, + ); + assert_eq!( + status_http_json(&missing, &profile_request, None), + Err(ApiError::InvalidWirePayload) + ); + + let body = status_http_json(&missing, &profile_request, Some(&artifact)).expect("attached"); + assert!(body.contains(SCIENTIFIC_ACCEPTANCE_HTTP_SCHEMA)); + assert!(body.contains("scientific_acceptance")); + assert_eq!( + status_http_json( + &missing, + &request("calibrated_event_measurement"), + Some(&artifact) + ), + Err(ApiError::InvalidWirePayload) + ); + } + + #[test] + #[allow(clippy::too_many_lines)] + fn zero_digest_mismatch_and_hostile_artifacts_fail_closed() { + let binding = "cd".repeat(32); + let artifact = artifact_json(&binding); + let digest = sha256_hex(artifact.as_bytes()); + let profile_request = request(SCIENTIFIC_ACCEPTANCE_HTTP_PROFILE); + let status = succeeded( + SCIENTIFIC_ACCEPTANCE_HTTP_PROFILE, + &digest, + SCIENTIFIC_ACCEPTANCE_HTTP_SCHEMA, + ); + let zero_binding = artifact_json(&"0".repeat(64)); + assert_eq!( + status_http_json(&status, &profile_request, Some(&zero_binding)), + Err(ApiError::InvalidWirePayload) + ); + let mismatched = artifact.replace(&binding, &"ef".repeat(32)); + assert_eq!( + status_http_json(&status, &profile_request, Some(&mismatched)), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + status_http_json(&status, &profile_request, Some("[]")), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + status_http_json(&status, &profile_request, Some("")), + Err(ApiError::InvalidWirePayload) + ); + let wrong_schema = artifact.replace( + SCIENTIFIC_ACCEPTANCE_HTTP_SCHEMA, + "tepp.scientific_acceptance.v0", + ); + assert_eq!( + status_http_json(&status, &profile_request, Some(&wrong_schema)), + Err(ApiError::InvalidWirePayload) + ); + let wrong_profile = artifact.replace(SCIENTIFIC_ACCEPTANCE_HTTP_PROFILE, "other_profile"); + assert_eq!( + status_http_json(&status, &profile_request, Some(&wrong_profile)), + Err(ApiError::InvalidWirePayload) + ); + let missing_binding = r#"{"schema_version":"tepp.scientific_acceptance.v1","output_profile":"scientific_acceptance_v1"}"#; + assert_eq!( + status_http_json(&status, &profile_request, Some(missing_binding)), + Err(ApiError::InvalidWirePayload) + ); + let numeric_schema = r#"{"schema_version":1,"output_profile":"scientific_acceptance_v1","binding_sha256":"abababababababababababababababababababababababababababababababab"}"#; + assert_eq!( + status_http_json(&status, &profile_request, Some(numeric_schema)), + Err(ApiError::InvalidWirePayload) + ); + let zero_digest_status = succeeded( + SCIENTIFIC_ACCEPTANCE_HTTP_PROFILE, + &"0".repeat(64), + SCIENTIFIC_ACCEPTANCE_HTTP_SCHEMA, + ); + assert_eq!( + status_http_json(&zero_digest_status, &profile_request, Some(&artifact)), + Err(ApiError::InvalidWirePayload) + ); + let wrong_schema_status = succeeded( + SCIENTIFIC_ACCEPTANCE_HTTP_PROFILE, + &digest, + "tepp-result-v1", + ); + assert_eq!( + status_http_json(&wrong_schema_status, &profile_request, Some(&artifact)), + Err(ApiError::InvalidWirePayload) + ); + let mut mismatched_request = profile_request.clone(); + mismatched_request.idempotency_key = "other-key".into(); + assert_eq!( + status_http_json(&status, &mismatched_request, Some(&artifact)), + Err(ApiError::InvalidWirePayload) + ); + + let mut missing_terminal = status.clone(); + missing_terminal.terminal_result = None; + assert_eq!( + status_http_json(&missing_terminal, &profile_request, Some(&artifact)), + Err(ApiError::InvalidWirePayload) + ); + let mut failed_terminal = status.clone(); + failed_terminal + .terminal_result + .as_mut() + .expect("terminal") + .run_state = AnalysisRunTerminalState::Failed; + assert_eq!( + status_http_json(&failed_terminal, &profile_request, Some(&artifact)), + Err(ApiError::InvalidWirePayload) + ); + let mut missing_digest = status.clone(); + missing_digest + .terminal_result + .as_mut() + .expect("terminal") + .result_sha256 = None; + assert_eq!( + status_http_json(&missing_digest, &profile_request, Some(&artifact)), + Err(ApiError::InvalidWirePayload) + ); + let mut missing_schema = status.clone(); + missing_schema + .terminal_result + .as_mut() + .expect("terminal") + .result_schema_version = None; + assert_eq!( + status_http_json(&missing_schema, &profile_request, Some(&artifact)), + Err(ApiError::InvalidWirePayload) + ); + let mut other_profile_terminal = status.clone(); + other_profile_terminal + .terminal_result + .as_mut() + .expect("terminal") + .output_profile = "calibrated_event_measurement".into(); + assert_eq!( + status_http_json(&other_profile_terminal, &profile_request, Some(&artifact)), + Err(ApiError::InvalidWirePayload) + ); + let mut succeeded_without_terminal = + AnalysisRunStatus::accepted(&accepted()).expect("base"); + succeeded_without_terminal.run_state = AnalysisRunStatusState::Succeeded; + assert_eq!( + status_http_json( + &succeeded_without_terminal, + &request("calibrated_event_measurement"), + None, + ), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + inject_scientific_acceptance_http("not-json", &artifact), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + inject_scientific_acceptance_http("{}", &artifact), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + inject_scientific_acceptance_http(r#"{"terminal_result":null}"#, &artifact), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + inject_scientific_acceptance_http(r#"{"terminal_result":[]}"#, &artifact), + Err(ApiError::InvalidWirePayload) + ); + let injected = + inject_scientific_acceptance_http(&status.to_json().expect("status json"), &artifact) + .expect("inject"); + assert!(injected.contains("scientific_acceptance")); + } +} diff --git a/crates/tepp_api/tests/scientific_acceptance_http_contract.rs b/crates/tepp_api/tests/scientific_acceptance_http_contract.rs new file mode 100644 index 000000000..84824e34a --- /dev/null +++ b/crates/tepp_api/tests/scientific_acceptance_http_contract.rs @@ -0,0 +1,76 @@ +//! Operator-visible loopback GET contract for GAP-003A scientific acceptance. + +use tepp_api::{ + ANALYSIS_RUN_CONTRACT_VERSION, AnalysisRunAccepted, AnalysisRunLiveService, AnalysisRunRequest, + ApiError, NARUON_ANALYSIS_RUN_PATH, NARUON_CONSUMER_CODE, SCIENTIFIC_ACCEPTANCE_HTTP_PROFILE, + receipt_json_carries_scientific_metrics, refuse_metrics_on_receipt, +}; + +fn request() -> AnalysisRunRequest { + AnalysisRunRequest { + contract_version: ANALYSIS_RUN_CONTRACT_VERSION, + idempotency_key: "http-contract-idem-1".into(), + tenant_workspace_id: "http-contract-tenant".into(), + snapshot_id: "http-contract-snapshot".into(), + knowledge_cutoff: "2026-08-01T00:00:00Z".into(), + model_contract_version: "validation_cpu_f64_v1".into(), + output_profile: SCIENTIFIC_ACCEPTANCE_HTTP_PROFILE.into(), + } +} + +fn post_request(run: &AnalysisRunRequest) -> String { + let body = run.to_json().expect("body"); + format!( + "POST {NARUON_ANALYSIS_RUN_PATH} HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: {NARUON_CONSUMER_CODE}\r\ntepp-contract-version: 1\r\nidempotency-key: {}\r\ncontent-length: {}\r\n\r\n{body}", + run.idempotency_key, + body.len() + ) +} + +fn get_request(run_id: &str, idempotency_key: &str) -> String { + format!( + "GET {NARUON_ANALYSIS_RUN_PATH}/{run_id} HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: {NARUON_CONSUMER_CODE}\r\ntepp-contract-version: 1\r\nidempotency-key: {idempotency_key}\r\ncontent-length: 0\r\n\r\n" + ) +} + +#[test] +fn post_receipt_and_get_accepted_status_stay_metric_free() { + let run = request(); + let mut service = AnalysisRunLiveService::new(); + let accepted = service.handle_http_request(&post_request(&run)); + assert_eq!(accepted.status_code, 202); + assert!(!receipt_json_carries_scientific_metrics(&accepted.body)); + assert_eq!(refuse_metrics_on_receipt(&accepted.body), Ok(())); + let accepted_dto = AnalysisRunAccepted::from_json(&accepted.body).expect("accepted"); + let status = service.handle_http_request(&get_request( + &accepted_dto.run_id, + run.idempotency_key.as_str(), + )); + assert_eq!(status.status_code, 200); + assert!(status.body.contains("\"accepted\"")); + assert!(!status.body.contains("scientific_acceptance")); + assert!(!status.body.contains("rmse")); + assert_eq!(refuse_metrics_on_receipt(&status.body), Ok(())); + let replay = service.handle_http_request(&post_request(&run)); + assert_eq!(replay.body, accepted.body); +} + +#[test] +fn metric_keys_on_the_create_receipt_fail_closed() { + let run = request(); + let body = run + .to_json() + .expect("json") + .replacen('{', r#"{"rmse":0.02,"#, 1); + let request = format!( + "POST {NARUON_ANALYSIS_RUN_PATH} HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: {NARUON_CONSUMER_CODE}\r\ntepp-contract-version: 1\r\nidempotency-key: {}\r\ncontent-length: {}\r\n\r\n{body}", + run.idempotency_key, + body.len() + ); + let mut service = AnalysisRunLiveService::new(); + assert_eq!(service.handle_http_request(&request).status_code, 400); + assert_eq!( + refuse_metrics_on_receipt(&body), + Err(ApiError::InvalidWirePayload) + ); +} diff --git a/docs/API_CONTRACT.md b/docs/API_CONTRACT.md index b76b688e1..70d557174 100644 --- a/docs/API_CONTRACT.md +++ b/docs/API_CONTRACT.md @@ -96,8 +96,11 @@ The typed status/read contract returns `accepted`, `running`, `succeeded`, or 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. +run as measurement evidence. The loopback `AnalysisRunLiveService` now serves +`GET /v1/analysis-runs/{run_id}` for those statuses: accepted and running GET +bodies stay metric-free, and only a succeeded status with profile +`scientific_acceptance_v1` may return `tepp.scientific_acceptance.v1`. Production +TLS remains a later adapter. The stacked `analysis_engine` slice provides the first executable service-side path behind these DTOs. It consumes a bounded identity-free snapshot, excludes diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 2b783c2ab..1a8294910 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -53,6 +53,7 @@ The full APA 7th standards/literature register remains `docs/research/standards- | 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 session-affine `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 (#44 implemented-main), `revision_order` later-revision system-time ordering implemented-main, entity/project target SQL on PR #131; 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; the `orchestrator_live` loopback interpretation listener is on this PR | partial | +| loopback analysis-run scientific-acceptance GET | ADR 0027; API contract; RFC 9110; FIPS 180-4 | `tepp_api` `GET /v1/analysis-runs/{run_id}` on `AnalysisRunLiveService` (this PR): accepted/running stay metric-free; `tepp.scientific_acceptance.v1` only on succeeded `scientific_acceptance_v1`; not implemented-main | 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 | | delayed-reporting cutoff eligibility in truth corpora | ADR 0002; research | `tepp_simulation` eligible-at-cutoff filter on the active PR | active-PR | | 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 | diff --git a/docs/adr/0027-scientific-acceptance-http-status.md b/docs/adr/0027-scientific-acceptance-http-status.md new file mode 100644 index 000000000..2271705e3 --- /dev/null +++ b/docs/adr/0027-scientific-acceptance-http-status.md @@ -0,0 +1,77 @@ +# ADR 0027 — Scientific-acceptance loopback HTTP status path + +**Decision status:** Accepted +**Implementation maturity:** active-PR +**Date:** 2026-08-31 +**Supersedes:** None; complements ADR 0018 and ADR 0022 for the operator-visible status read. Does not supersede ADR 0014 claim-promotion authority and does not reuse ADR 0026. + +## Context + +Modular consumers can POST an analysis-run receipt on the loopback listener and can build a `GET /v1/analysis-runs/{run_id}` exchange, but the listener refused GET. Scientific-acceptance metrics therefore could not appear on an operator-visible status/terminal HTTP path. Putting RMSE, bias, coverage, or SE-gate keys on the create receipt would treat acknowledgement as measurement evidence. Duplicating the terminal-result DTO on this slice would collide with the live API wire PR. + +## Decision + +`AnalysisRunLiveService` serves `GET /v1/analysis-runs/{run_id}` on loopback: + +- `POST /v1/analysis-runs` remains a metric-free `202 Accepted` receipt. +- Accepted and running GET bodies are metric-free `AnalysisRunStatus` JSON. +- Only a succeeded status whose request profile is `scientific_acceptance_v1` may return `tepp.scientific_acceptance.v1`. +- A failed status cannot carry the scientific-acceptance object. +- An all-zero binding or result digest, a digest mismatch, a profile mismatch, a GET body, an unknown run, or a consumer/idempotency mismatch fails closed. +- This slice does not introduce a `ScientificAcceptanceArtifact` DTO. Persistence, Compose recovery, and worker execution remain GAP-003B. + +## Non-goals + +- Production TLS, public bind, or durable status storage. +- Leiden community detection, Driver p.16 std-family restoration, or Figma/export work. +- Promoting an ADR 0014 scientific claim from HTTP success. + +## Alternatives considered + +1. **Stack GET onto the live terminal-result DTO PR** — rejected because that head is moving and the HTTP gap is independently operator-visible. +2. **Copy the terminal-result scientific-acceptance DTO into this crate module** — rejected as a duplicate API wire slice. +3. **Return metrics on the accepted receipt** — rejected because acknowledgement is not measurement evidence. +4. **Loopback GET with HTTP-layer schema, profile, and digest gates** — accepted. + +## Consequences + +- Operators can poll an accepted run on the same loopback listener that created it. +- Scientific-acceptance bytes appear only after a succeeded, profile-matched, digest-bound terminal status. +- The typed terminal-result DTO may later nest the same object without changing these HTTP gates. + +## Failure and recovery + +Unknown run identities, extra path segments, truncated percent-encoding, non-empty GET bodies, metric keys on receipts, failed-plus-artifact emission, all-zero digests, and digest mismatch return a redacted `400` envelope. Credential headers remain `403`. The in-memory registry is not durable; a restart requires re-POSTing the original metric-free request. Callers must not fabricate a succeeded GET. + +## Security, privacy, scientific-integrity, and governance impact + +- No credential headers cross the consumer boundary. +- GET remains loopback-only, size-bounded, and content-redacting. +- SHA-256 digest agreement is a byte-identity check, not a validity claim. +- HTTP `200` on a succeeded scientific-acceptance GET is not release evidence. + +## Compatibility and migration + +The existing POST analysis-run, temporal-context, and project-history paths are unchanged. The client GET builder already targets `/v1/analysis-runs/{run_id}`. Production adapters may replace loopback while preserving metric-free receipts and the succeeded-only scientific-acceptance rule. + +## Verification + +Falsifiable evidence: + +- POST accepted JSON has no RMSE/bias/coverage/SE-gate/scientific-acceptance keys; +- GET accepted and GET running stay metric-free; +- GET succeeded with profile `scientific_acceptance_v1` returns `tepp.scientific_acceptance.v1` only when the artifact digest matches; +- GET failed with an artifact, all-zero digest, digest mismatch, GET body, unknown run, and consumer mismatch fail closed; +- Clippy `-D warnings`, `tepp_api` tests, rustdoc, and exact-head review remain required. + +## Rollback and supersession + +Rollback removes GET dispatch and the in-memory status index; POST receipts remain valid. A superseding ADR is required to persist status, bind a public address, or treat HTTP success as an ADR 0014 claim. + +## Related authority + +- ADR 0018 owns consumer-scoped ingress and metric-free `202 Accepted`. +- ADR 0022 owns deterministic execution to a digest-bound terminal result. +- ADR 0014 owns scientific claim promotion. +- ADR 0008 owns SHA-256 identity. +- ADR 0011 owns standalone/modular HTTP boundaries. diff --git a/docs/adr/README.md b/docs/adr/README.md index 1254c8079..2246e011e 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -30,6 +30,7 @@ Read [`ADR_POLICY.md`](ADR_POLICY.md) first. **Decision status and implementatio | [0022](0022-deterministic-analysis-run-execution.md) | Deterministic cutoff-safe analysis-run execution | Accepted | active-PR | Closes the first executable product path from accepted run to digest-bound terminal result without claiming estimator authority. | | [0024](0024-lineage-pair-criterion-and-project-journey-posterior.md) | Independent Event Lineage pair criterion and posterior Project Journey | Proposed | active-PR | Strict artifacts preserve criterion/event-time draws, branches, ties, and CPU/GPU receipts without claiming the scientific estimator is complete. | | [0025](0025-macos-native-rust-mlx-metal-boundary.md) | macOS-native Rust-owned MLX Metal execution | Accepted | accepted-target | Compose authenticates to a native host service; Linux never claims Metal, and actual backend/parity receipts fail closed. | +| [0027](0027-scientific-acceptance-http-status.md) | Scientific-acceptance loopback HTTP status path | Accepted | active-PR | GET `/v1/analysis-runs/{run_id}` stays metric-free on accepted/running; `tepp.scientific_acceptance.v1` only on succeeded `scientific_acceptance_v1`. | | [0023](0023-lineage-criterion-anchor-contract.md) | TEPP-owned Event Lineage criterion anchor | Accepted | active-PR | PR #237 publishes the strict accepted/rejected artifact and identities; estimator execution remains fail-closed future work. | | [0024](0024-independent-topic-importance-anchor.md) | Posterior topic-context producer contract | Accepted | contract-only active-PR | Strict DTO/schema only; the current estimator does not emit it. fast-mlsirm owns case-deletion influence. | | [0001](0001-rust-first-modular-msa.md) | Rust-first numerical core and CPU `f64` reference | Accepted | partial | ADR 0011 owns cross-service/MSA authority; 0001 retains numerical/backend authority. | diff --git a/docs/research/scientific-acceptance-http-status.md b/docs/research/scientific-acceptance-http-status.md new file mode 100644 index 000000000..e42e03926 --- /dev/null +++ b/docs/research/scientific-acceptance-http-status.md @@ -0,0 +1,57 @@ +# Scientific-acceptance loopback HTTP status (GAP-003A) + +## Scope + +This note doctors the third GAP-003A executable slice in `tepp_api` +(issue #166): + +1. `POST /v1/analysis-runs` remains a metric-free receipt; +2. `GET /v1/analysis-runs/{run_id}` returns accepted/running status without + scientific-acceptance metrics; +3. only a succeeded status with output profile `scientific_acceptance_v1` may + return `tepp.scientific_acceptance.v1`; +4. failed-plus-artifact emission, all-zero binding or result digests, digest + mismatch, receipt RMSE/bias/coverage/SE-gate keys, a GET body, and unknown + run identities fail closed. + +This slice does not copy the terminal-result DTO. Library binding remains on +live PR #356. The API wire DTO remains on live PR #358. PostgreSQL persistence +and Compose recovery remain GAP-003B. HTTP success does not promote an ADR 0014 +claim. + +## Authoritative sources + +Fielding, R., Nottingham, M., & Reschke, J. (Eds.). (2022). *HTTP semantics* +(RFC 9110). Internet Engineering Task Force. https://doi.org/10.17487/RFC9110 + +National Institute of Standards and Technology. (2015). *Secure Hash Standard +(SHS)* (FIPS PUB 180-4). https://doi.org/10.6028/NIST.FIPS.180-4 + +Peng, R. D. (2011). Reproducible research in computational science. +*Science, 334*(6060), 1226–1227. https://doi.org/10.1126/science.1213847 + +National Academies of Sciences, Engineering, and Medicine. (2019). +*Reproducibility and replicability in science*. The National Academies Press. +https://doi.org/10.17226/25303 + +## Application + +RFC 9110 §9.3.1 defines GET as a safe read that does not create a new +resource; TEPP therefore refuses a GET body and serves only the stored status +for the opaque run identity (Fielding, Nottingham, & Reschke, 2022). Peng +(2011) and the National Academies (2019) require computational reproducibility +to bind identities without treating a receipt as a scientific claim, so RMSE, +bias, coverage, and SE-gate keys stay off POST receipts and accepted/running +GET bodies. FIPS 180-4 SHA-256 detects whether the HTTP artifact bytes agree +with `result_sha256` and refuses an all-zero digest (National Institute of +Standards and Technology, 2015). + +## Verification + +- POST with `rmse` (or other named metric keys) fails closed; +- GET accepted and GET running contain neither `scientific_acceptance` nor + `rmse`; +- GET succeeded with profile `scientific_acceptance_v1` includes + `tepp.scientific_acceptance.v1` only when the digest matches; +- failed-plus-artifact, all-zero digest, digest mismatch, GET body, unknown + run, and consumer/idempotency mismatch fail closed. diff --git a/docs/research/standards-and-literature.md b/docs/research/standards-and-literature.md index 8ce0ecc77..5ba4bbe72 100644 --- a/docs/research/standards-and-literature.md +++ b/docs/research/standards-and-literature.md @@ -412,7 +412,7 @@ Fielding, R., Nottingham, M., & Reschke, J. (Eds.). (2022). *HTTP semantics* (RF Klyne, G., & Newman, C. (2002). *Date and time on the Internet: Timestamps* (RFC 3339). IETF. https://doi.org/10.17487/RFC3339 -TEPP uses RFC 9110 for live `Host` and `Transfer-Encoding` refusal on the naruon loopback listener, and RFC 3339 via `temporal_core::KnowledgeCutoff` so a buyer cannot submit `"k"` or a future-dated cutoff as an analysis-run clock. +TEPP uses RFC 9110 for live `Host` and `Transfer-Encoding` refusal on the naruon loopback listener, RFC 9110 §9.3.1 GET as a safe analysis-run status read that refuses a request body, and RFC 3339 via `temporal_core::KnowledgeCutoff` so a future-dated cutoff cannot be submitted as an analysis-run clock. ## Security, accessibility, and software supply chain