diff --git a/CHANGELOG.md b/CHANGELOG.md index 7993299cb..dcbe8b94a 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 production `POST /v1/analysis-runs/{run_id}/running` and `POST /v1/analysis-runs/{run_id}/terminal` so accepted/running stay metric-free and only a succeeded status with profile `scientific_acceptance_v1` may return `tepp.scientific_acceptance.v1` after a lifecycle POST. Canonical artifact bytes travel as `scientific_acceptance_json`. Reverse transitions, mutating a terminal run, failed-plus-artifact emission, receipt RMSE/bias/coverage/SE-gate keys, an unknown run, and consumer mismatch fail closed. This is the GAP-003A HTTP lifecycle slice for issue #166; it does not duplicate the `analysis_engine` library bind (#356), the terminal-result DTO wire (#358), or the GET status slice (#359); persistence remains GAP-003B. + - `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. diff --git a/crates/tepp_api/Cargo.toml b/crates/tepp_api/Cargo.toml index 47ad7c433..053199f39 100644 --- a/crates/tepp_api/Cargo.toml +++ b/crates/tepp_api/Cargo.toml @@ -31,3 +31,7 @@ bench = false [lints] workspace = true + +[dev-dependencies] +sha2 = { workspace = true } + diff --git a/crates/tepp_api/src/analysis_run_lifecycle_http.rs b/crates/tepp_api/src/analysis_run_lifecycle_http.rs new file mode 100644 index 000000000..714b1496c --- /dev/null +++ b/crates/tepp_api/src/analysis_run_lifecycle_http.rs @@ -0,0 +1,486 @@ +//! Production loopback lifecycle POST contracts for analysis-run status. +//! +//! GAP-003A fourth slice: `POST /v1/analysis-runs/{run_id}/running` and +//! `POST /v1/analysis-runs/{run_id}/terminal` are the operator-visible +//! status-update path. Accepted and running bodies stay metric-free. Only a +//! succeeded terminal whose request profile is `scientific_acceptance_v1` may +//! attach canonical `tepp.scientific_acceptance.v1` bytes. This module does +//! not copy the terminal-result DTO, does not persist, and does not execute +//! psychometric estimation. + +use crate::analysis_run_status_http::{ANALYSIS_RUN_ID_MAX_LEN, encode_path_segment}; +use crate::naruon_http::{NaruonHttpExchange, compose_https_target, standard_headers}; +use crate::scientific_acceptance_http::refuse_metrics_on_receipt; +use crate::wire::{ + from_json, require_byte_limit, require_contract_version, require_nonempty, to_json, +}; +use crate::{ + ANALYSIS_RUN_STATUS_PATH, AnalysisRunStatusState, AnalysisRunTerminalResult, + AnalysisRunTerminalState, ApiError, DEFAULT_ANALYSIS_RUN_BYTE_LIMIT, +}; +use serde::{Deserialize, Serialize}; + +/// Supported lifecycle-transition contract version. +pub const ANALYSIS_RUN_LIFECYCLE_CONTRACT_VERSION: u16 = 1; + +/// Production HTTP transition that records running or terminal status. +/// +/// `scientific_acceptance_json` stores canonical artifact bytes as a JSON +/// string so `result_sha256` hashes those exact bytes. The field is absent on +/// running and failed transitions and on succeeded profiles that do not bind +/// scientific acceptance. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct AnalysisRunLifecycleTransition { + /// Semantic contract version for this payload family. + pub contract_version: u16, + /// Opaque server-assigned run identity. + pub run_id: String, + /// Requested lifecycle state. `accepted` is refused. + pub run_state: AnalysisRunStatusState, + /// Exact request idempotency key. + pub idempotency_key: String, + /// Request-bound terminal result, required for terminal states. + #[serde(skip_serializing_if = "Option::is_none")] + pub terminal_result: Option, + /// Canonical scientific-acceptance JSON bytes, when authorized. + #[serde(skip_serializing_if = "Option::is_none")] + pub scientific_acceptance_json: Option, +} + +impl AnalysisRunLifecycleTransition { + /// Construct a metric-free running transition. + /// + /// # Errors + /// + /// Returns a fail-closed error for empty identities. + pub fn running( + run_id: impl Into, + idempotency_key: impl Into, + ) -> Result { + let transition = Self { + contract_version: ANALYSIS_RUN_LIFECYCLE_CONTRACT_VERSION, + run_id: run_id.into(), + run_state: AnalysisRunStatusState::Running, + idempotency_key: idempotency_key.into(), + terminal_result: None, + scientific_acceptance_json: None, + }; + transition.validate()?; + Ok(transition) + } + + /// Construct a terminal transition bound to a request-bound result. + /// + /// # Errors + /// + /// Returns a fail-closed error when the result, identities, or optional + /// canonical artifact bytes are invalid. + pub fn terminal( + run_id: impl Into, + idempotency_key: impl Into, + terminal_result: AnalysisRunTerminalResult, + scientific_acceptance_json: Option, + ) -> Result { + let run_state = match terminal_result.run_state { + AnalysisRunTerminalState::Succeeded => AnalysisRunStatusState::Succeeded, + AnalysisRunTerminalState::Failed => AnalysisRunStatusState::Failed, + }; + let transition = Self { + contract_version: ANALYSIS_RUN_LIFECYCLE_CONTRACT_VERSION, + run_id: run_id.into(), + run_state, + idempotency_key: idempotency_key.into(), + terminal_result: Some(terminal_result), + scientific_acceptance_json, + }; + transition.validate()?; + Ok(transition) + } + + /// Parse and validate a lifecycle transition with the default byte limit. + /// + /// # Errors + /// + /// Returns wire, version, limit, metric-key, 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 lifecycle transition with a caller-supplied limit. + /// + /// # Errors + /// + /// Returns wire, version, limit, metric-key, or field-validation errors. + pub fn from_json_with_limit(payload: &str, maximum_bytes: usize) -> Result { + require_byte_limit(payload, maximum_bytes)?; + refuse_metrics_on_receipt(payload)?; + let transition: Self = from_json(payload)?; + transition.validate()?; + Ok(transition) + } + + /// Serialize this transition after complete validation. + /// + /// # Errors + /// + /// Returns validation or serialization errors. + pub fn to_json(&self) -> Result { + self.validate()?; + let payload = to_json(self)?; + require_byte_limit(&payload, DEFAULT_ANALYSIS_RUN_BYTE_LIMIT)?; + Ok(payload) + } + + pub(crate) fn validate(&self) -> Result<(), ApiError> { + require_contract_version( + self.contract_version, + ANALYSIS_RUN_LIFECYCLE_CONTRACT_VERSION, + )?; + require_nonempty(&self.run_id)?; + require_nonempty(&self.idempotency_key)?; + match self.run_state { + AnalysisRunStatusState::Accepted => Err(ApiError::InvalidWirePayload), + AnalysisRunStatusState::Running => { + if self.terminal_result.is_some() || self.scientific_acceptance_json.is_some() { + return Err(ApiError::InvalidWirePayload); + } + Ok(()) + } + AnalysisRunStatusState::Succeeded | AnalysisRunStatusState::Failed => { + let result = self + .terminal_result + .as_ref() + .ok_or(ApiError::InvalidWirePayload)?; + result.validate()?; + let expected = match result.run_state { + AnalysisRunTerminalState::Succeeded => AnalysisRunStatusState::Succeeded, + AnalysisRunTerminalState::Failed => AnalysisRunStatusState::Failed, + }; + if expected != self.run_state + || result.run_id != self.run_id + || result.idempotency_key != self.idempotency_key + { + return Err(ApiError::InvalidWirePayload); + } + if self.run_state == AnalysisRunStatusState::Failed + && self.scientific_acceptance_json.is_some() + { + return Err(ApiError::InvalidWirePayload); + } + if let Some(artifact) = self.scientific_acceptance_json.as_deref() { + require_nonempty(artifact)?; + } + Ok(()) + } + } + } +} + +/// Build a provider-owned `POST` running-status exchange. +/// +/// # Errors +/// +/// Returns [`ApiError::InvalidWirePayload`] for a non-running transition or a +/// non-`https` origin, and [`ApiError::LimitExceeded`] when the run identity +/// exceeds [`ANALYSIS_RUN_ID_MAX_LEN`]. +pub fn naruon_analysis_run_running_exchange( + origin: &str, + transition: &AnalysisRunLifecycleTransition, +) -> Result { + if transition.run_state != AnalysisRunStatusState::Running { + return Err(ApiError::InvalidWirePayload); + } + build_lifecycle_exchange(origin, transition, "running") +} + +/// Build a provider-owned `POST` terminal-status exchange. +/// +/// # Errors +/// +/// Returns [`ApiError::InvalidWirePayload`] for a non-terminal transition or a +/// non-`https` origin, and [`ApiError::LimitExceeded`] when the run identity +/// exceeds [`ANALYSIS_RUN_ID_MAX_LEN`]. +pub fn naruon_analysis_run_terminal_exchange( + origin: &str, + transition: &AnalysisRunLifecycleTransition, +) -> Result { + if !matches!( + transition.run_state, + AnalysisRunStatusState::Succeeded | AnalysisRunStatusState::Failed + ) { + return Err(ApiError::InvalidWirePayload); + } + build_lifecycle_exchange(origin, transition, "terminal") +} + +fn build_lifecycle_exchange( + origin: &str, + transition: &AnalysisRunLifecycleTransition, + suffix: &str, +) -> Result { + transition.validate()?; + if transition.run_id.len() > ANALYSIS_RUN_ID_MAX_LEN { + return Err(ApiError::LimitExceeded); + } + let encoded_run_id = encode_path_segment(&transition.run_id); + let target_path = format!("{ANALYSIS_RUN_STATUS_PATH}/{encoded_run_id}/{suffix}"); + let target_url = compose_https_target(origin, &target_path)?; + Ok(NaruonHttpExchange { + method: "POST", + target_url, + headers: standard_headers(&transition.idempotency_key), + body: transition.to_json()?, + }) +} + +#[cfg(test)] +mod tests { + use super::{ + ANALYSIS_RUN_LIFECYCLE_CONTRACT_VERSION, AnalysisRunLifecycleTransition, + naruon_analysis_run_running_exchange, naruon_analysis_run_terminal_exchange, + }; + use crate::analysis_run_status_http::ANALYSIS_RUN_ID_MAX_LEN; + use crate::{ + ANALYSIS_RUN_CONTRACT_VERSION, AnalysisResultSummary, AnalysisRunAccepted, + AnalysisRunRequest, AnalysisRunStatusState, AnalysisRunTerminalResult, ApiError, + DEFAULT_ANALYSIS_RUN_BYTE_LIMIT, SCIENTIFIC_ACCEPTANCE_HTTP_PROFILE, + SCIENTIFIC_ACCEPTANCE_HTTP_SCHEMA, + }; + + fn request(profile: &str) -> AnalysisRunRequest { + AnalysisRunRequest { + contract_version: ANALYSIS_RUN_CONTRACT_VERSION, + idempotency_key: "lifecycle-idem-1".into(), + tenant_workspace_id: "lifecycle-tenant-1".into(), + snapshot_id: "lifecycle-snapshot-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", "lifecycle-idem-1").expect("accepted") + } + + fn succeeded_result() -> AnalysisRunTerminalResult { + let request = request(SCIENTIFIC_ACCEPTANCE_HTTP_PROFILE); + let accepted = accepted(); + AnalysisRunTerminalResult::succeeded( + &request, + &accepted, + "artifact-lifecycle-1", + "ab".repeat(32), + SCIENTIFIC_ACCEPTANCE_HTTP_SCHEMA, + "2026-08-02T03:04:05Z", + AnalysisResultSummary::new("scientific_acceptance", 4, 8, "validated") + .expect("summary"), + ) + .expect("succeeded") + } + + fn failed_result() -> AnalysisRunTerminalResult { + AnalysisRunTerminalResult::failed( + &request("calibrated_event_measurement"), + &accepted(), + "2026-08-02T03:04:05Z", + "estimation_failed", + ) + .expect("failed") + } + + #[test] + #[allow(clippy::too_many_lines)] + fn running_and_terminal_transitions_round_trip_and_refuse_hostile_shapes() { + let running = AnalysisRunLifecycleTransition::running("tepp-run-1", "lifecycle-idem-1") + .expect("running"); + let running_json = running.to_json().expect("running json"); + assert_eq!( + AnalysisRunLifecycleTransition::from_json(&running_json).expect("decode"), + running + ); + assert!(!running_json.contains("rmse")); + assert!(!running_json.contains("scientific_acceptance_json")); + + let artifact = r#"{"schema_version":"tepp.scientific_acceptance.v1"}"#; + let terminal = AnalysisRunLifecycleTransition::terminal( + "tepp-run-1", + "lifecycle-idem-1", + succeeded_result(), + Some(artifact.into()), + ) + .expect("terminal"); + let terminal_json = terminal.to_json().expect("terminal json"); + assert_eq!( + AnalysisRunLifecycleTransition::from_json(&terminal_json).expect("decode terminal"), + terminal + ); + assert!(terminal_json.contains("scientific_acceptance_json")); + + let failed = AnalysisRunLifecycleTransition::terminal( + "tepp-run-1", + "lifecycle-idem-1", + failed_result(), + None, + ) + .expect("failed"); + assert_eq!(failed.run_state, AnalysisRunStatusState::Failed); + + assert_eq!( + AnalysisRunLifecycleTransition::running("", "lifecycle-idem-1"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + AnalysisRunLifecycleTransition::running("tepp-run-1", ""), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + AnalysisRunLifecycleTransition::terminal( + "tepp-run-1", + "lifecycle-idem-1", + failed_result(), + Some(artifact.into()), + ), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + AnalysisRunLifecycleTransition::terminal( + "tepp-run-other", + "lifecycle-idem-1", + succeeded_result(), + None, + ), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + AnalysisRunLifecycleTransition::from_json(&running_json.replacen( + '{', + r#"{"rmse":0.1,"#, + 1 + )), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + AnalysisRunLifecycleTransition::from_json_with_limit(&running_json, 8), + Err(ApiError::LimitExceeded) + ); + assert!(DEFAULT_ANALYSIS_RUN_BYTE_LIMIT >= running_json.len()); + + let mut accepted_state = running.clone(); + accepted_state.run_state = AnalysisRunStatusState::Accepted; + assert_eq!(accepted_state.to_json(), Err(ApiError::InvalidWirePayload)); + + let mut running_with_result = running.clone(); + running_with_result.terminal_result = Some(failed_result()); + assert_eq!( + running_with_result.to_json(), + Err(ApiError::InvalidWirePayload) + ); + + let mut running_with_artifact = running.clone(); + running_with_artifact.scientific_acceptance_json = Some(artifact.into()); + assert_eq!( + running_with_artifact.to_json(), + Err(ApiError::InvalidWirePayload) + ); + + let mut missing_result = terminal.clone(); + missing_result.terminal_result = None; + assert_eq!(missing_result.to_json(), Err(ApiError::InvalidWirePayload)); + + let mut mismatched_state = terminal.clone(); + mismatched_state.run_state = AnalysisRunStatusState::Failed; + assert_eq!( + mismatched_state.to_json(), + Err(ApiError::InvalidWirePayload) + ); + + let mut mismatched_idem = terminal.clone(); + mismatched_idem.idempotency_key = "other-key".into(); + assert_eq!(mismatched_idem.to_json(), Err(ApiError::InvalidWirePayload)); + + let mut empty_artifact = terminal.clone(); + empty_artifact.scientific_acceptance_json = Some(String::new()); + assert_eq!(empty_artifact.to_json(), Err(ApiError::InvalidWirePayload)); + + let extra = running_json.replacen('{', r#"{"extra":true,"#, 1); + assert_eq!( + AnalysisRunLifecycleTransition::from_json(&extra), + Err(ApiError::InvalidWirePayload) + ); + let wrong_version = running_json.replace( + &format!("\"contract_version\":{ANALYSIS_RUN_LIFECYCLE_CONTRACT_VERSION}"), + "\"contract_version\":9", + ); + assert_eq!( + AnalysisRunLifecycleTransition::from_json(&wrong_version), + Err(ApiError::UnsupportedContractVersion) + ); + } + + #[test] + fn running_and_terminal_exchanges_encode_paths_and_refuse_hostile_origins() { + let running = AnalysisRunLifecycleTransition::running("tepp-run-1", "lifecycle-idem-1") + .expect("running"); + let running_exchange = + naruon_analysis_run_running_exchange("https://tepp.example.com", &running) + .expect("running exchange"); + assert_eq!(running_exchange.method, "POST"); + assert_eq!( + running_exchange.target_url, + "https://tepp.example.com/v1/analysis-runs/tepp-run-1/running" + ); + assert!(!running_exchange.body.contains("rmse")); + + let unsafe_id = + AnalysisRunLifecycleTransition::running("run/../../etc", "lifecycle-idem-1") + .expect("unsafe"); + let encoded = naruon_analysis_run_running_exchange("https://tepp.example.com", &unsafe_id) + .expect("encoded"); + assert!(encoded.target_url.contains("run%2F..%2F..%2Fetc/running")); + + let terminal = AnalysisRunLifecycleTransition::terminal( + "tepp-run-1", + "lifecycle-idem-1", + failed_result(), + None, + ) + .expect("terminal"); + let terminal_exchange = + naruon_analysis_run_terminal_exchange("https://tepp.example.com", &terminal) + .expect("terminal exchange"); + assert!( + terminal_exchange + .target_url + .ends_with("/v1/analysis-runs/tepp-run-1/terminal") + ); + + assert_eq!( + naruon_analysis_run_running_exchange("http://tepp.example.com", &running) + .expect_err("http"), + ApiError::InvalidWirePayload + ); + assert_eq!( + naruon_analysis_run_running_exchange("https://tepp.example.com", &terminal) + .expect_err("terminal on running"), + ApiError::InvalidWirePayload + ); + assert_eq!( + naruon_analysis_run_terminal_exchange("https://tepp.example.com", &running) + .expect_err("running on terminal"), + ApiError::InvalidWirePayload + ); + + let oversized_id = "a".repeat(ANALYSIS_RUN_ID_MAX_LEN + 1); + let mut oversized = running.clone(); + oversized.run_id = oversized_id; + // bypass validate by calling builder after mutating + oversized.contract_version = ANALYSIS_RUN_LIFECYCLE_CONTRACT_VERSION; + assert_eq!( + naruon_analysis_run_running_exchange("https://tepp.example.com", &oversized) + .expect_err("limit"), + ApiError::LimitExceeded + ); + } +} diff --git a/crates/tepp_api/src/analysis_run_live.rs b/crates/tepp_api/src/analysis_run_live.rs index fba9bf5e6..624d5ef46 100644 --- a/crates/tepp_api/src/analysis_run_live.rs +++ b/crates/tepp_api/src/analysis_run_live.rs @@ -6,14 +6,19 @@ //! 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. +//! profile is `scientific_acceptance_v1`. `POST /v1/analysis-runs/{run_id}/running` +//! and `POST /v1/analysis-runs/{run_id}/terminal` are the production +//! status-update path. 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::analysis_run_lifecycle_http::AnalysisRunLifecycleTransition; +use crate::analysis_run_status_http::{ + AnalysisRunLiveRoute, analysis_run_status_path_run_id, parse_analysis_run_live_route, +}; 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, @@ -22,16 +27,13 @@ use crate::live_http::{ use crate::naruon_http::NARUON_ANALYSIS_RUN_PATH; use crate::scientific_acceptance_http::{refuse_metrics_on_receipt, status_http_json}; use crate::{ - AnalysisRunAccepted, AnalysisRunRequest, AnalysisRunStatus, ApiError, + AnalysisRunAccepted, AnalysisRunRequest, AnalysisRunStatus, AnalysisRunStatusState, 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, + requests_are_idempotent_matches, require_status_binding, }; -#[cfg(test)] -use crate::require_status_binding; - const MAX_LIVE_REQUEST_BODY_BYTES: usize = DEFAULT_PROJECT_HISTORY_BYTE_LIMIT; #[cfg(test)] @@ -168,19 +170,15 @@ impl AnalysisRunLiveService { if method == "GET" { return self.read_analysis_run_status(path, &headers, body); } - if method != "POST" - || (path != NARUON_ANALYSIS_RUN_PATH - && path != TEMPORAL_CONTEXT_PATH - && path != PROJECT_HISTORY_PATH) - { + if method != "POST" { return Err(ApiError::InvalidWirePayload); } - let consumer = require_headers( - &headers, - self.bound_addr, - path == NARUON_ANALYSIS_RUN_PATH || path == PROJECT_HISTORY_PATH, - )?; + if path == NARUON_ANALYSIS_RUN_PATH { + let consumer = require_headers(&headers, self.bound_addr, true)?; + return self.accept_analysis_run(consumer, &headers, body); + } if path == TEMPORAL_CONTEXT_PATH { + let consumer = require_headers(&headers, self.bound_addr, false)?; if consumer != LINEAGEWEAVE_CONSUMER_CODE { return Err(ApiError::InvalidWirePayload); } @@ -189,9 +187,18 @@ impl AnalysisRunLiveService { return Ok(json_response(200, "OK", response.to_json()?)); } if path == PROJECT_HISTORY_PATH { + let consumer = require_headers(&headers, self.bound_addr, true)?; return self.accept_project_history(consumer, &headers, body); } - self.accept_analysis_run(consumer, &headers, body) + match parse_analysis_run_live_route(path)? { + AnalysisRunLiveRoute::Running { run_id } => { + self.post_running_status(&run_id, &headers, body) + } + AnalysisRunLiveRoute::Terminal { run_id } => { + self.post_terminal_status(&run_id, &headers, body) + } + AnalysisRunLiveRoute::Status { .. } => Err(ApiError::InvalidWirePayload), + } } fn accept_analysis_run( @@ -276,10 +283,10 @@ impl AnalysisRunLiveService { /// 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)] + /// HTTP `POST /v1/analysis-runs/{run_id}/running` and + /// `POST /v1/analysis-runs/{run_id}/terminal` call this after the request + /// is authenticated and parsed. Psychometric execution remains outside this + /// crate; this records the supplied status only. pub(crate) fn record_loopback_status( &mut self, run_id: &str, @@ -309,6 +316,110 @@ impl AnalysisRunLiveService { Ok(()) } + fn post_running_status( + &mut self, + run_id: &str, + headers: &HashMap, + body: &str, + ) -> Result { + refuse_metrics_on_receipt(body)?; + let transition = AnalysisRunLifecycleTransition::from_json(body)?; + if transition.run_state != AnalysisRunStatusState::Running { + return Err(ApiError::InvalidWirePayload); + } + self.commit_lifecycle_transition(run_id, headers, &transition) + } + + fn post_terminal_status( + &mut self, + run_id: &str, + headers: &HashMap, + body: &str, + ) -> Result { + let transition = AnalysisRunLifecycleTransition::from_json(body)?; + if !matches!( + transition.run_state, + AnalysisRunStatusState::Succeeded | AnalysisRunStatusState::Failed + ) { + return Err(ApiError::InvalidWirePayload); + } + self.commit_lifecycle_transition(run_id, headers, &transition) + } + + fn commit_lifecycle_transition( + &mut self, + path_run_id: &str, + headers: &HashMap, + transition: &AnalysisRunLifecycleTransition, + ) -> Result { + if transition.run_id != path_run_id { + return Err(ApiError::InvalidWirePayload); + } + let consumer = require_headers(headers, self.bound_addr, true)?; + let idempotency_key = header_value(headers, "idempotency-key")?; + if idempotency_key != transition.idempotency_key { + return Err(ApiError::InvalidWirePayload); + } + let replay_key = self + .runs_by_id + .get(path_run_id) + .ok_or(ApiError::InvalidWirePayload)? + .clone(); + 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 status = match transition.run_state { + AnalysisRunStatusState::Running => AnalysisRunStatus::running(&stored.accepted)?, + AnalysisRunStatusState::Succeeded | AnalysisRunStatusState::Failed => { + let result = transition + .terminal_result + .clone() + .ok_or(ApiError::InvalidWirePayload)?; + AnalysisRunStatus::terminal(&stored.request, &stored.accepted, result)? + } + AnalysisRunStatusState::Accepted => return Err(ApiError::InvalidWirePayload), + }; + if stored.status == status + && stored.scientific_acceptance_json == transition.scientific_acceptance_json + { + let response_body = status_http_json( + &stored.status, + &stored.request, + stored.scientific_acceptance_json.as_deref(), + )?; + return Ok(json_response(200, "OK", response_body)); + } + match stored.status.run_state { + AnalysisRunStatusState::Accepted => {} + AnalysisRunStatusState::Running + if matches!( + transition.run_state, + AnalysisRunStatusState::Succeeded | AnalysisRunStatusState::Failed + ) => {} + AnalysisRunStatusState::Running + | AnalysisRunStatusState::Succeeded + | AnalysisRunStatusState::Failed => { + return Err(ApiError::InvalidWirePayload); + } + } + let artifact = transition.scientific_acceptance_json.clone(); + self.record_loopback_status(path_run_id, status, artifact)?; + let stored = self + .accepted_runs + .get(&replay_key) + .ok_or(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)) + } + fn accept_project_history( &mut self, consumer: &str, @@ -418,9 +529,9 @@ mod tests { use std::time::Duration; use super::{ - AnalysisRunLiveService, consumer_tenant_idempotency_key, declared_content_length, - error_envelope_json, host_implies_table_access, map_io_error, parse_headers, - require_headers, split_header_line, status_for, + AnalysisRunLifecycleTransition, AnalysisRunLiveService, consumer_tenant_idempotency_key, + declared_content_length, error_envelope_json, host_implies_table_access, map_io_error, + parse_headers, require_headers, split_header_line, status_for, }; use crate::live_http::{host_is_loopback, read_http_request, split_request}; use crate::{ @@ -476,6 +587,13 @@ mod tests { ) } + fn lifecycle_post(path: &str, body: &str, consumer: &str, idempotency_key: &str) -> String { + format!( + "POST {path} HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: {consumer}\r\ntepp-contract-version: 1\r\nidempotency-key: {idempotency_key}\r\ncontent-length: {}\r\n\r\n{body}", + body.len() + ) + } + fn sha256_hex(bytes: &[u8]) -> String { const HEX: &[u8; 16] = b"0123456789abcdef"; let digest = Sha256::digest(bytes); @@ -1315,6 +1433,385 @@ mod tests { .status_code, 403 ); + assert_eq!( + service + .handle_http_request(&http_get( + &format!("{NARUON_ANALYSIS_RUN_PATH}/{}/running", 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()), + ], + )) + .status_code, + 400 + ); + assert_eq!( + service + .handle_http_request(&http_get( + &format!( + "{NARUON_ANALYSIS_RUN_PATH}/{}/terminal", + 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()), + ], + )) + .status_code, + 400 + ); + } + + #[test] + #[allow(clippy::too_many_lines)] + fn production_http_lifecycle_posts_record_running_and_terminal_status() { + 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); + let accepted_dto = AnalysisRunAccepted::from_json(&accepted.body).expect("accepted"); + + let put = format!( + "PUT {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: 2\r\n\r\n{{}}", + accepted_dto.run_id, run.idempotency_key + ); + assert_eq!(service.handle_http_request(&put).status_code, 400); + assert_eq!( + service + .handle_http_request(&lifecycle_post( + &format!("{NARUON_ANALYSIS_RUN_PATH}/{}", accepted_dto.run_id), + "{}", + NARUON_CONSUMER_CODE, + run.idempotency_key.as_str(), + )) + .status_code, + 400 + ); + + let running = AnalysisRunLifecycleTransition::running( + accepted_dto.run_id.clone(), + run.idempotency_key.clone(), + ) + .expect("running"); + let running_json = running.to_json().expect("running json"); + let post_running = service.handle_http_request(&lifecycle_post( + &format!("{NARUON_ANALYSIS_RUN_PATH}/{}/running", accepted_dto.run_id), + &running_json, + NARUON_CONSUMER_CODE, + run.idempotency_key.as_str(), + )); + assert_eq!(post_running.status_code, 200); + assert!(post_running.body.contains("\"running\"")); + assert!(!post_running.body.contains("rmse")); + assert!(!post_running.body.contains("scientific_acceptance")); + let replay_running = service.handle_http_request(&lifecycle_post( + &format!("{NARUON_ANALYSIS_RUN_PATH}/{}/running", accepted_dto.run_id), + &running_json, + NARUON_CONSUMER_CODE, + run.idempotency_key.as_str(), + )); + assert_eq!(replay_running.body, post_running.body); + + let mismatched_id = + AnalysisRunLifecycleTransition::running("tepp-run-other", run.idempotency_key.clone()) + .expect("other id"); + assert_eq!( + service + .handle_http_request(&lifecycle_post( + &format!("{NARUON_ANALYSIS_RUN_PATH}/{}/running", accepted_dto.run_id), + &mismatched_id.to_json().expect("json"), + NARUON_CONSUMER_CODE, + run.idempotency_key.as_str(), + )) + .status_code, + 400 + ); + assert_eq!( + service + .handle_http_request(&lifecycle_post( + &format!("{NARUON_ANALYSIS_RUN_PATH}/{}/running", accepted_dto.run_id), + &running_json.replacen('{', r#"{"rmse":0.1,"#, 1), + NARUON_CONSUMER_CODE, + run.idempotency_key.as_str(), + )) + .status_code, + 400 + ); + assert_eq!( + service + .handle_http_request(&lifecycle_post( + &format!("{NARUON_ANALYSIS_RUN_PATH}/{}/running", accepted_dto.run_id), + &running_json, + LINEAGEWEAVE_CONSUMER_CODE, + run.idempotency_key.as_str(), + )) + .status_code, + 400 + ); + assert_eq!( + service + .handle_http_request(&lifecycle_post( + &format!("{NARUON_ANALYSIS_RUN_PATH}/{}/running", accepted_dto.run_id), + &running_json, + NARUON_CONSUMER_CODE, + "wrong-key", + )) + .status_code, + 400 + ); + assert_eq!( + service + .handle_http_request(&lifecycle_post( + &format!("{NARUON_ANALYSIS_RUN_PATH}/missing/running"), + &AnalysisRunLifecycleTransition::running( + "missing", + run.idempotency_key.clone() + ) + .expect("missing") + .to_json() + .expect("json"), + NARUON_CONSUMER_CODE, + run.idempotency_key.as_str(), + )) + .status_code, + 400 + ); + + 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 transition = AnalysisRunLifecycleTransition::terminal( + accepted_dto.run_id.clone(), + run.idempotency_key.clone(), + terminal.clone(), + Some(artifact.clone()), + ) + .expect("transition"); + let terminal_json = transition.to_json().expect("terminal json"); + assert_eq!( + service + .handle_http_request(&lifecycle_post( + &format!("{NARUON_ANALYSIS_RUN_PATH}/{}/running", accepted_dto.run_id), + &terminal_json, + NARUON_CONSUMER_CODE, + run.idempotency_key.as_str(), + )) + .status_code, + 400 + ); + let post_terminal = service.handle_http_request(&lifecycle_post( + &format!( + "{NARUON_ANALYSIS_RUN_PATH}/{}/terminal", + accepted_dto.run_id + ), + &terminal_json, + NARUON_CONSUMER_CODE, + run.idempotency_key.as_str(), + )); + assert_eq!(post_terminal.status_code, 200); + assert!( + post_terminal + .body + .contains(SCIENTIFIC_ACCEPTANCE_HTTP_SCHEMA) + ); + assert!(post_terminal.body.contains("scientific_acceptance")); + let replay_terminal = service.handle_http_request(&lifecycle_post( + &format!( + "{NARUON_ANALYSIS_RUN_PATH}/{}/terminal", + accepted_dto.run_id + ), + &terminal_json, + NARUON_CONSUMER_CODE, + run.idempotency_key.as_str(), + )); + assert_eq!(replay_terminal.body, post_terminal.body); + 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.body, post_terminal.body); + + assert_eq!( + service + .handle_http_request(&lifecycle_post( + &format!("{NARUON_ANALYSIS_RUN_PATH}/{}/running", accepted_dto.run_id), + &running_json, + NARUON_CONSUMER_CODE, + run.idempotency_key.as_str(), + )) + .status_code, + 400 + ); + let failed = AnalysisRunTerminalResult::failed( + &run, + &accepted_dto, + "2026-08-02T03:04:05Z", + "estimation_failed", + ) + .expect("failed"); + let failed_transition = AnalysisRunLifecycleTransition::terminal( + accepted_dto.run_id.clone(), + run.idempotency_key.clone(), + failed, + None, + ) + .expect("failed transition"); + assert_eq!( + service + .handle_http_request(&lifecycle_post( + &format!( + "{NARUON_ANALYSIS_RUN_PATH}/{}/terminal", + accepted_dto.run_id + ), + &failed_transition.to_json().expect("json"), + NARUON_CONSUMER_CODE, + run.idempotency_key.as_str(), + )) + .status_code, + 400 + ); + assert_eq!( + service + .handle_http_request(&lifecycle_post( + &format!( + "{NARUON_ANALYSIS_RUN_PATH}/{}/terminal", + accepted_dto.run_id + ), + &running_json, + NARUON_CONSUMER_CODE, + run.idempotency_key.as_str(), + )) + .status_code, + 400 + ); + + let mut ghost = AnalysisRunLiveService::new(); + ghost + .runs_by_id + .insert("ghost".into(), "missing-replay".into()); + assert_eq!( + ghost + .handle_http_request(&lifecycle_post( + &format!("{NARUON_ANALYSIS_RUN_PATH}/ghost/running"), + &AnalysisRunLifecycleTransition::running("ghost", run.idempotency_key.clone()) + .expect("ghost") + .to_json() + .expect("json"), + NARUON_CONSUMER_CODE, + run.idempotency_key.as_str(), + )) + .status_code, + 400 + ); + + let ordinary = sample_run(); + let mut ordinary_service = AnalysisRunLiveService::new(); + let ordinary_accepted = ordinary_service.handle_http_request(&valid_request( + &ordinary, + NARUON_CONSUMER_CODE, + "127.0.0.1", + )); + let ordinary_dto = + AnalysisRunAccepted::from_json(&ordinary_accepted.body).expect("ordinary accepted"); + let ordinary_terminal = AnalysisRunTerminalResult::succeeded( + &ordinary, + &ordinary_dto, + "artifact-ordinary-1", + "cd".repeat(32), + "tepp-result-v1", + "2026-08-02T03:04:05Z", + AnalysisResultSummary::new("calibrated_event", 2, 2, "validated").expect("summary"), + ) + .expect("ordinary terminal"); + let ordinary_transition = AnalysisRunLifecycleTransition::terminal( + ordinary_dto.run_id.clone(), + ordinary.idempotency_key.clone(), + ordinary_terminal, + None, + ) + .expect("ordinary transition"); + let ordinary_post = ordinary_service.handle_http_request(&lifecycle_post( + &format!( + "{NARUON_ANALYSIS_RUN_PATH}/{}/terminal", + ordinary_dto.run_id + ), + &ordinary_transition.to_json().expect("json"), + NARUON_CONSUMER_CODE, + ordinary.idempotency_key.as_str(), + )); + assert_eq!(ordinary_post.status_code, 200); + assert!(!ordinary_post.body.contains("scientific_acceptance")); + + let mut conflicted = sample_run(); + conflicted.idempotency_key = "analysis-live-idem-conflict".into(); + let mut conflict_service = AnalysisRunLiveService::new(); + let conflict_accepted = conflict_service.handle_http_request(&valid_request( + &conflicted, + NARUON_CONSUMER_CODE, + "127.0.0.1", + )); + let conflict_dto = + AnalysisRunAccepted::from_json(&conflict_accepted.body).expect("conflict accepted"); + let conflict_running = AnalysisRunLifecycleTransition::running( + conflict_dto.run_id.clone(), + conflicted.idempotency_key.clone(), + ) + .expect("conflict running"); + assert_eq!( + conflict_service + .handle_http_request(&lifecycle_post( + &format!("{NARUON_ANALYSIS_RUN_PATH}/{}/running", conflict_dto.run_id), + &conflict_running.to_json().expect("json"), + NARUON_CONSUMER_CODE, + conflicted.idempotency_key.as_str(), + )) + .status_code, + 200 + ); + conflict_service + .accepted_runs + .get_mut( + conflict_service + .runs_by_id + .get(&conflict_dto.run_id) + .expect("replay"), + ) + .expect("stored") + .scientific_acceptance_json = Some("{}".into()); + assert_eq!( + conflict_service + .handle_http_request(&lifecycle_post( + &format!("{NARUON_ANALYSIS_RUN_PATH}/{}/running", conflict_dto.run_id), + &conflict_running.to_json().expect("json"), + NARUON_CONSUMER_CODE, + conflicted.idempotency_key.as_str(), + )) + .status_code, + 400 + ); } fn failed_status_placeholder() -> AnalysisRunStatus { diff --git a/crates/tepp_api/src/analysis_run_status_http.rs b/crates/tepp_api/src/analysis_run_status_http.rs index 367ffb01e..08ea5d114 100644 --- a/crates/tepp_api/src/analysis_run_status_http.rs +++ b/crates/tepp_api/src/analysis_run_status_http.rs @@ -13,6 +13,17 @@ use crate::{ANALYSIS_RUN_STATUS_PATH, ApiError}; /// Maximum length accepted for an opaque run identity in the status path. pub const ANALYSIS_RUN_ID_MAX_LEN: usize = 128; +/// Parsed loopback analysis-run route after percent-decoding. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum AnalysisRunLiveRoute { + /// `GET /v1/analysis-runs/{run_id}` + Status { run_id: String }, + /// `POST /v1/analysis-runs/{run_id}/running` + Running { run_id: String }, + /// `POST /v1/analysis-runs/{run_id}/terminal` + Terminal { run_id: String }, +} + /// Build a provider-owned `GET` analysis-run status exchange. /// /// The caller supplies the TEPP origin and the opaque server-assigned run @@ -31,12 +42,7 @@ pub fn naruon_analysis_run_status_exchange( run_id: &str, idempotency_key: &str, ) -> Result { - require_nonempty(run_id)?; - if run_id.len() > ANALYSIS_RUN_ID_MAX_LEN { - return Err(ApiError::LimitExceeded); - } - let encoded_run_id = encode_path_segment(run_id); - let target_path = format!("{ANALYSIS_RUN_STATUS_PATH}/{encoded_run_id}"); + let target_path = encoded_run_path(run_id, None)?; let target_url = compose_https_target(origin, &target_path)?; Ok(NaruonHttpExchange { method: "GET", @@ -46,8 +52,22 @@ pub fn naruon_analysis_run_status_exchange( }) } +fn encoded_run_path(run_id: &str, suffix: Option<&str>) -> Result { + require_nonempty(run_id)?; + if run_id.len() > ANALYSIS_RUN_ID_MAX_LEN { + return Err(ApiError::LimitExceeded); + } + let encoded_run_id = encode_path_segment(run_id); + match suffix { + None => Ok(format!("{ANALYSIS_RUN_STATUS_PATH}/{encoded_run_id}")), + Some(suffix) => Ok(format!( + "{ANALYSIS_RUN_STATUS_PATH}/{encoded_run_id}/{suffix}" + )), + } +} + /// Percent-encode one `URI` path segment without double-encoding safe chars. -fn encode_path_segment(value: &str) -> String { +pub(crate) fn encode_path_segment(value: &str) -> String { let mut out = String::with_capacity(value.len() + value.len() / 2); let hex = b"0123456789ABCDEF"; for byte in value.bytes() { @@ -109,28 +129,66 @@ fn from_hex(byte: u8) -> Result { } } -/// Extract the opaque run identity from `GET /v1/analysis-runs/{run_id}`. +fn require_run_id_length(run_id: &str) -> Result<(), ApiError> { + if run_id.len() > ANALYSIS_RUN_ID_MAX_LEN { + Err(ApiError::LimitExceeded) + } else { + Ok(()) + } +} + +/// Parse `GET` status and `POST` running/terminal loopback routes. /// /// # 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 { +/// segments, an unknown suffix, or a hostile encoding, and +/// [`ApiError::LimitExceeded`] when the decoded identity exceeds +/// [`ANALYSIS_RUN_ID_MAX_LEN`]. +pub(crate) fn parse_analysis_run_live_route(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('/') { + if encoded.is_empty() { return Err(ApiError::InvalidWirePayload); } - let run_id = decode_path_segment(encoded)?; - if run_id.len() > ANALYSIS_RUN_ID_MAX_LEN { - return Err(ApiError::LimitExceeded); + match encoded.split_once('/') { + None => { + let run_id = decode_path_segment(encoded)?; + require_run_id_length(&run_id)?; + Ok(AnalysisRunLiveRoute::Status { run_id }) + } + Some((encoded_id, "running")) => { + let run_id = decode_path_segment(encoded_id)?; + require_run_id_length(&run_id)?; + Ok(AnalysisRunLiveRoute::Running { run_id }) + } + Some((encoded_id, "terminal")) => { + let run_id = decode_path_segment(encoded_id)?; + require_run_id_length(&run_id)?; + Ok(AnalysisRunLiveRoute::Terminal { run_id }) + } + Some(_) => 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 { + match parse_analysis_run_live_route(path)? { + AnalysisRunLiveRoute::Status { run_id } => Ok(run_id), + AnalysisRunLiveRoute::Running { .. } | AnalysisRunLiveRoute::Terminal { .. } => { + Err(ApiError::InvalidWirePayload) + } } - Ok(run_id) } #[cfg(test)] @@ -239,5 +297,45 @@ mod tests { decode_path_segment(invalid_utf8), Err(ApiError::InvalidWirePayload) ); + assert_eq!( + analysis_run_status_path_run_id("/v1/analysis-runs/tepp-run-1/running"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + parse_analysis_run_live_route("/v1/analysis-runs/tepp-run-1/running").expect("running"), + AnalysisRunLiveRoute::Running { + run_id: "tepp-run-1".into() + } + ); + assert_eq!( + parse_analysis_run_live_route("/v1/analysis-runs/tepp-run-1/terminal") + .expect("terminal"), + AnalysisRunLiveRoute::Terminal { + run_id: "tepp-run-1".into() + } + ); + assert_eq!( + parse_analysis_run_live_route("/v1/analysis-runs/tepp-run-1/running/extra"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + parse_analysis_run_live_route(&format!( + "/v1/analysis-runs/{}/running", + "a".repeat(ANALYSIS_RUN_ID_MAX_LEN + 1) + )), + Err(ApiError::LimitExceeded) + ); + assert_eq!( + parse_analysis_run_live_route(&format!( + "/v1/analysis-runs/{}/terminal", + "a".repeat(ANALYSIS_RUN_ID_MAX_LEN + 1) + )), + Err(ApiError::LimitExceeded) + ); + assert_eq!( + parse_analysis_run_live_route("/v1/analysis-runs/%2F/running"), + Err(ApiError::InvalidWirePayload) + ); + let _ = encoded_run_path("tepp-run-1", Some("running")).expect("suffix path"); } } diff --git a/crates/tepp_api/src/lib.rs b/crates/tepp_api/src/lib.rs index 0f03ebe59..facecf471 100644 --- a/crates/tepp_api/src/lib.rs +++ b/crates/tepp_api/src/lib.rs @@ -12,10 +12,13 @@ //! 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`. +//! `tepp.scientific_acceptance.v1`. `POST /v1/analysis-runs/{run_id}/running` +//! and `POST /v1/analysis-runs/{run_id}/terminal` record those statuses on the +//! same loopback listener. mod analysis_result; mod analysis_run; +mod analysis_run_lifecycle_http; mod analysis_run_live; mod analysis_run_status_http; mod authorization; @@ -73,6 +76,14 @@ pub use analysis_run::DEFAULT_ANALYSIS_RUN_BYTE_LIMIT; 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; +/// Lifecycle-transition contract version constant. +pub use analysis_run_lifecycle_http::ANALYSIS_RUN_LIFECYCLE_CONTRACT_VERSION; +/// Production HTTP running/terminal transition body. +pub use analysis_run_lifecycle_http::AnalysisRunLifecycleTransition; +/// Provider-owned running-status HTTP exchange builder. +pub use analysis_run_lifecycle_http::naruon_analysis_run_running_exchange; +/// Provider-owned terminal-status HTTP exchange builder. +pub use analysis_run_lifecycle_http::naruon_analysis_run_terminal_exchange; /// Consumer-neutral loopback analysis-run service. pub use analysis_run_live::AnalysisRunLiveService; /// Analysis-run status HTTP exchange re-exports. diff --git a/crates/tepp_api/tests/scientific_acceptance_lifecycle_http_contract.rs b/crates/tepp_api/tests/scientific_acceptance_lifecycle_http_contract.rs new file mode 100644 index 000000000..ed78a1fe5 --- /dev/null +++ b/crates/tepp_api/tests/scientific_acceptance_lifecycle_http_contract.rs @@ -0,0 +1,172 @@ +//! Operator-visible loopback running/terminal POST contract for GAP-003A. + +use sha2::{Digest, Sha256}; +use tepp_api::{ + ANALYSIS_RUN_CONTRACT_VERSION, AnalysisResultSummary, AnalysisRunAccepted, + AnalysisRunLifecycleTransition, AnalysisRunLiveService, AnalysisRunRequest, + AnalysisRunTerminalResult, NARUON_ANALYSIS_RUN_PATH, NARUON_CONSUMER_CODE, + SCIENTIFIC_ACCEPTANCE_HTTP_PROFILE, SCIENTIFIC_ACCEPTANCE_HTTP_SCHEMA, + naruon_analysis_run_running_exchange, naruon_analysis_run_terminal_exchange, + receipt_json_carries_scientific_metrics, refuse_metrics_on_receipt, +}; + +fn request() -> AnalysisRunRequest { + AnalysisRunRequest { + contract_version: ANALYSIS_RUN_CONTRACT_VERSION, + idempotency_key: "lifecycle-contract-idem-1".into(), + tenant_workspace_id: "lifecycle-contract-tenant".into(), + snapshot_id: "lifecycle-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_create(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 post_lifecycle(path: &str, body: &str, idempotency_key: &str) -> String { + format!( + "POST {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: {idempotency_key}\r\ncontent-length: {}\r\n\r\n{body}", + body.len() + ) +} + +fn get_status(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" + ) +} + +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 +} + +#[test] +fn production_running_and_terminal_posts_then_get_return_scientific_acceptance() { + let run = request(); + let mut service = AnalysisRunLiveService::new(); + let accepted = service.handle_http_request(&post_create(&run)); + assert_eq!(accepted.status_code, 202); + assert!(!receipt_json_carries_scientific_metrics(&accepted.body)); + let accepted_dto = AnalysisRunAccepted::from_json(&accepted.body).expect("accepted"); + + let running = AnalysisRunLifecycleTransition::running( + accepted_dto.run_id.clone(), + run.idempotency_key.clone(), + ) + .expect("running"); + let running_exchange = + naruon_analysis_run_running_exchange("https://tepp.example.com", &running) + .expect("running exchange"); + assert_eq!(running_exchange.method, "POST"); + assert!(running_exchange.target_url.ends_with("/running")); + let running_response = service.handle_http_request(&post_lifecycle( + &format!("{NARUON_ANALYSIS_RUN_PATH}/{}/running", accepted_dto.run_id), + &running.to_json().expect("running json"), + run.idempotency_key.as_str(), + )); + assert_eq!(running_response.status_code, 200); + assert!(running_response.body.contains("\"running\"")); + assert_eq!(refuse_metrics_on_receipt(&running_response.body), Ok(())); + let get_running = service.handle_http_request(&get_status( + &accepted_dto.run_id, + run.idempotency_key.as_str(), + )); + assert_eq!(get_running.body, running_response.body); + + 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-contract-1", + digest, + SCIENTIFIC_ACCEPTANCE_HTTP_SCHEMA, + "2026-08-02T03:04:05Z", + AnalysisResultSummary::new("scientific_acceptance", 4, 8, "validated").expect("summary"), + ) + .expect("terminal"); + let transition = AnalysisRunLifecycleTransition::terminal( + accepted_dto.run_id.clone(), + run.idempotency_key.clone(), + terminal, + Some(artifact), + ) + .expect("transition"); + let terminal_exchange = + naruon_analysis_run_terminal_exchange("https://tepp.example.com", &transition) + .expect("terminal exchange"); + assert!(terminal_exchange.target_url.ends_with("/terminal")); + let terminal_response = service.handle_http_request(&post_lifecycle( + &format!( + "{NARUON_ANALYSIS_RUN_PATH}/{}/terminal", + accepted_dto.run_id + ), + &transition.to_json().expect("terminal json"), + run.idempotency_key.as_str(), + )); + assert_eq!(terminal_response.status_code, 200); + assert!( + terminal_response + .body + .contains(SCIENTIFIC_ACCEPTANCE_HTTP_SCHEMA) + ); + let get_succeeded = service.handle_http_request(&get_status( + &accepted_dto.run_id, + run.idempotency_key.as_str(), + )); + assert_eq!(get_succeeded.status_code, 200); + assert_eq!(get_succeeded.body, terminal_response.body); +} + +#[test] +fn failed_terminal_post_cannot_carry_scientific_acceptance() { + let run = request(); + let mut service = AnalysisRunLiveService::new(); + let accepted = service.handle_http_request(&post_create(&run)); + let accepted_dto = AnalysisRunAccepted::from_json(&accepted.body).expect("accepted"); + let failed = AnalysisRunTerminalResult::failed( + &run, + &accepted_dto, + "2026-08-02T03:04:05Z", + "estimation_failed", + ) + .expect("failed"); + let transition = AnalysisRunLifecycleTransition::terminal( + accepted_dto.run_id.clone(), + run.idempotency_key.clone(), + failed, + None, + ) + .expect("transition"); + let response = service.handle_http_request(&post_lifecycle( + &format!( + "{NARUON_ANALYSIS_RUN_PATH}/{}/terminal", + accepted_dto.run_id + ), + &transition.to_json().expect("json"), + run.idempotency_key.as_str(), + )); + assert_eq!(response.status_code, 200); + assert!(response.body.contains("\"failed\"")); + assert!(!response.body.contains(SCIENTIFIC_ACCEPTANCE_HTTP_SCHEMA)); + assert!(!response.body.contains("\"scientific_acceptance\":")); +} diff --git a/docs/API_CONTRACT.md b/docs/API_CONTRACT.md index 70d557174..87a7a4a87 100644 --- a/docs/API_CONTRACT.md +++ b/docs/API_CONTRACT.md @@ -66,6 +66,8 @@ POST /v1/interpretation-runs POST /v1/analysis-runs POST /v1/temporal-context GET /v1/analysis-runs/{run_id} +POST /v1/analysis-runs/{run_id}/running +POST /v1/analysis-runs/{run_id}/terminal POST /v1/analysis-runs/{run_id}/cancel GET /v1/model-artifacts/{artifact_id} GET /v1/exports/{export_id} @@ -99,8 +101,11 @@ snapshot, cutoff, model, profile, and idempotency bindings before treating the 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. +`scientific_acceptance_v1` may return `tepp.scientific_acceptance.v1`. +`POST /v1/analysis-runs/{run_id}/running` and +`POST /v1/analysis-runs/{run_id}/terminal` are the production loopback +status-update path that records those statuses; they do not persist and do not +execute psychometric estimation. 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 1a8294910..b90843a85 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -53,7 +53,8 @@ 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 | +| 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` (#359): accepted/running stay metric-free; `tepp.scientific_acceptance.v1` only on succeeded `scientific_acceptance_v1`; not implemented-main | active-PR | +| loopback analysis-run scientific-acceptance lifecycle POST | ADR 0028; API contract; RFC 9110; FIPS 180-4 | `tepp_api` `POST /v1/analysis-runs/{run_id}/running` and `/terminal` on `AnalysisRunLiveService` (this PR): production status-update path; accepted/running stay metric-free; `tepp.scientific_acceptance.v1` only after 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/0028-scientific-acceptance-http-lifecycle.md b/docs/adr/0028-scientific-acceptance-http-lifecycle.md new file mode 100644 index 000000000..96871b837 --- /dev/null +++ b/docs/adr/0028-scientific-acceptance-http-lifecycle.md @@ -0,0 +1,77 @@ +# ADR 0028 — Scientific-acceptance loopback HTTP lifecycle POST + +**Decision status:** Accepted +**Implementation maturity:** active-PR +**Date:** 2026-08-31 +**Supersedes:** None; complements ADR 0027 for the operator-visible status update. Does not supersede ADR 0014 claim-promotion authority and does not reuse ADR 0026 or ADR 0027. + +## Context + +ADR 0027 serves `GET /v1/analysis-runs/{run_id}` on the loopback listener, but the only way to record running or terminal status was a test-only helper. Production runs therefore stayed accepted forever, and a succeeded scientific-acceptance GET could not be produced through the HTTP boundary. Duplicating the GET slice, the terminal-result DTO, or Compose persistence would collide with live PRs. + +## Decision + +`AnalysisRunLiveService` serves production lifecycle updates on loopback: + +- `POST /v1/analysis-runs/{run_id}/running` records a metric-free running status. +- `POST /v1/analysis-runs/{run_id}/terminal` records a request-bound terminal status. +- Canonical scientific-acceptance bytes travel as `scientific_acceptance_json` on the transition body so `result_sha256` hashes those exact bytes. +- Accepted and running responses stay metric-free. Only a succeeded status whose request profile is `scientific_acceptance_v1` may return `tepp.scientific_acceptance.v1` on the subsequent GET. +- Reverse transitions, mutating a terminal run, a failed-plus-artifact emission, an unknown run, a consumer/idempotency mismatch, and receipt metric keys fail closed. +- Persistence, Compose recovery, and psychometric 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. **Keep the test-only recorder** — rejected because operators cannot reach terminal status over HTTP. +2. **Stack the write path onto the live GET PR** — rejected because that head is already under review as a GET-only slice. +3. **Persist running/terminal rows in PostgreSQL** — rejected as GAP-003B / live draft #287. +4. **Loopback POST running/terminal with HTTP-layer schema, profile, and digest gates** — accepted. + +## Consequences + +- A worker or operator can move an accepted loopback run to running and then to terminal without a test helper. +- GET remains the safe read (ADR 0027). POST remains the state change (RFC 9110 §9.3.3). +- Canonical artifact bytes are preserved for SHA-256 identity on the write path. + +## Failure and recovery + +Unknown run identities, extra path segments, metric keys on running bodies, failed-plus-artifact emission, reverse transitions, and consumer mismatch return a redacted `400` envelope. Credential headers remain `403`. The in-memory registry is not durable. + +## Security, privacy, scientific-integrity, and governance impact + +- No credential headers cross the consumer boundary. +- POST 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 + +GET status, POST create, temporal-context, and project-history paths are unchanged. Production adapters may replace loopback while preserving metric-free receipts and the succeeded-only scientific-acceptance rule. + +## Verification + +Falsifiable evidence: + +- POST create and POST running JSON have no RMSE/bias/coverage/SE-gate/scientific-acceptance keys; +- POST terminal succeeded with profile `scientific_acceptance_v1` then GET returns `tepp.scientific_acceptance.v1` only when the artifact digest matches; +- POST failed with an artifact, reverse transitions, 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 the running/terminal POST dispatch; GET status and 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 0027 owns the GET status read. +- 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 2246e011e..421caeadd 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -31,6 +31,7 @@ Read [`ADR_POLICY.md`](ADR_POLICY.md) first. **Decision status and implementatio | [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`. | +| [0028](0028-scientific-acceptance-http-lifecycle.md) | Scientific-acceptance loopback HTTP lifecycle POST | Accepted | active-PR | POST `/running` and `/terminal` are the production status-update path; GET remains ADR 0027. Persistence remains GAP-003B. | | [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-lifecycle.md b/docs/research/scientific-acceptance-http-lifecycle.md new file mode 100644 index 000000000..6c979debc --- /dev/null +++ b/docs/research/scientific-acceptance-http-lifecycle.md @@ -0,0 +1,55 @@ +# Scientific-acceptance loopback HTTP lifecycle POST (GAP-003A) + +## Scope + +This note doctors the fourth GAP-003A executable slice in `tepp_api` +(issue #166): + +1. `POST /v1/analysis-runs` remains a metric-free receipt; +2. `POST /v1/analysis-runs/{run_id}/running` records metric-free running status; +3. `POST /v1/analysis-runs/{run_id}/terminal` records a request-bound terminal + status, with canonical `tepp.scientific_acceptance.v1` bytes only when the + request profile is `scientific_acceptance_v1` and the run succeeded; +4. reverse transitions, mutating a terminal run, failed-plus-artifact emission, + receipt RMSE/bias/coverage/SE-gate keys, an unknown run, and consumer + mismatch 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. The GET status path +remains on live PR #359 / ADR 0027. 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.3 defines POST as the method that processes a representation +according to the resource's own semantics, which is the correct verb for a +lifecycle transition; GET remains a safe read (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 running bodies. FIPS 180-4 SHA-256 hashes the canonical artifact +bytes carried as `scientific_acceptance_json` and refuses an all-zero digest +(National Institute of Standards and Technology, 2015). + +## Verification + +- POST running contains neither `scientific_acceptance` nor `rmse`; +- POST terminal succeeded with profile `scientific_acceptance_v1` then GET + includes `tepp.scientific_acceptance.v1` only when the digest matches; +- failed-plus-artifact, reverse transitions, 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 5ba4bbe72..87320db7e 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, 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. +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, RFC 9110 §9.3.3 POST as the analysis-run running/terminal lifecycle update, 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