diff --git a/CHANGELOG.d/analysis-run-cancel-http.md b/CHANGELOG.d/analysis-run-cancel-http.md new file mode 100644 index 000000000..cbe7f697e --- /dev/null +++ b/CHANGELOG.d/analysis-run-cancel-http.md @@ -0,0 +1 @@ +- `tepp_api` loopback `POST /v1/analysis-runs/{run_id}/cancel` returns metric-free cancelled status for accepted and running runs (ADR 0029). Succeeded/failed/unknown cancel fails closed. Not GET status, not lifecycle POST, not persistence. diff --git a/CHANGELOG.md b/CHANGELOG.md index 062a69412..29a24a159 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` serves `POST /v1/analysis-runs/{run_id}/cancel` on the shared loopback listener (ADR 0029). Accepted and running runs become metric-free `cancelled` status. Succeeded, failed, and unknown runs cannot be cancelled. Cancel bodies refuse RMSE/bias/coverage/SE-gate/scientific-acceptance keys. GET status and running/terminal POST remain later GAP-003A slices; this is not an ADR 0014 claim. + - `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/DOCUMENTATION.md b/DOCUMENTATION.md index 6fa4b9683..5ada27f8e 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -13,6 +13,7 @@ TEPP's approved PRD v0.4 and implementation plan are the primary product baselin | naruon modular consumer contract | [`docs/connectors/naruon-artifact-consumer.md`](docs/connectors/naruon-artifact-consumer.md) | | contextual-orchestrator interpretation port | [`docs/connectors/contextual-orchestrator-interpretation-port.md`](docs/connectors/contextual-orchestrator-interpretation-port.md) | | Orchestrator live HTTP doctoring | [`docs/research/orchestrator-live-http.md`](docs/research/orchestrator-live-http.md) | +| Analysis-run cancel HTTP doctoring | [`docs/research/analysis-run-cancel-http.md`](docs/research/analysis-run-cancel-http.md) | | UML/runtime/scientific flows | [`docs/UML.md`](docs/UML.md) | | Logical/physical ERD | [`docs/ERD.md`](docs/ERD.md) | | Security policy | [`SECURITY.md`](SECURITY.md) | diff --git a/crates/tepp_api/src/analysis_run.rs b/crates/tepp_api/src/analysis_run.rs index 97af85784..3e401029c 100644 --- a/crates/tepp_api/src/analysis_run.rs +++ b/crates/tepp_api/src/analysis_run.rs @@ -67,6 +67,8 @@ pub enum AnalysisRunStatusState { Succeeded, /// The run completed without a measurement artifact. Failed, + /// The run was cancelled before a measurement artifact existed. + Cancelled, } /// Typed status/read response for an accepted analysis run. @@ -231,6 +233,19 @@ impl AnalysisRunStatus { Self::new(accepted, AnalysisRunStatusState::Running, None) } + /// Construct a cancelled status from a durable receipt. + /// + /// Cancelled is a metric-free terminal-of-work state. It is not a + /// succeeded or failed measurement result and must not carry + /// `terminal_result`. + /// + /// # Errors + /// + /// Returns a fail-closed error when the receipt is invalid. + pub fn cancelled(accepted: &AnalysisRunAccepted) -> Result { + Self::new(accepted, AnalysisRunStatusState::Cancelled, None) + } + /// Construct a terminal status bound to the submitted request and receipt. /// /// # Errors @@ -310,7 +325,9 @@ impl AnalysisRunStatus { require_nonempty(&self.run_id)?; require_nonempty(&self.idempotency_key)?; match self.run_state { - AnalysisRunStatusState::Accepted | AnalysisRunStatusState::Running => { + AnalysisRunStatusState::Accepted + | AnalysisRunStatusState::Running + | AnalysisRunStatusState::Cancelled => { if self.terminal_result.is_some() { return Err(ApiError::InvalidWirePayload); } diff --git a/crates/tepp_api/src/analysis_run_cancel_http.rs b/crates/tepp_api/src/analysis_run_cancel_http.rs new file mode 100644 index 000000000..5640e2a65 --- /dev/null +++ b/crates/tepp_api/src/analysis_run_cancel_http.rs @@ -0,0 +1,495 @@ +//! Provider-owned analysis-run cancel HTTP contracts. +//! +//! GAP-003A fifth slice: `POST /v1/analysis-runs/{run_id}/cancel` is the +//! operator-visible cancel path on the shared loopback listener. Accepted and +//! running runs become metric-free `cancelled` status. Succeeded and failed +//! runs cannot be cancelled. Cancel bodies and cancelled status JSON refuse +//! RMSE, bias, coverage, SE-gate, scientific-acceptance, and report keys. +//! This module does not serve GET status (#359) and does not record running +//! or terminal POST transitions (#360). Persistence remains GAP-003B. + +use crate::naruon_http::{NaruonHttpExchange, compose_https_target, standard_headers}; +use crate::wire::{ + from_json, require_byte_limit, require_contract_version, require_nonempty, to_json, +}; +use crate::{ANALYSIS_RUN_STATUS_PATH, ApiError, DEFAULT_ANALYSIS_RUN_BYTE_LIMIT}; +use serde::{Deserialize, Serialize}; + +/// Maximum length accepted for an opaque run identity in the cancel path. +pub const ANALYSIS_RUN_CANCEL_ID_MAX_LEN: usize = 128; + +/// Supported analysis-run cancel contract version. +pub const ANALYSIS_RUN_CANCEL_CONTRACT_VERSION: u16 = 1; + +const FORBIDDEN_CANCEL_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", +]; + +/// Versioned cancel request for one accepted or running analysis run. +/// +/// Path `run_id` and header `idempotency-key` must match these fields when a +/// body is present. An empty POST body is also admitted on the loopback +/// listener and uses the path identity plus the idempotency header. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct AnalysisRunCancelRequest { + /// Semantic contract version for this payload family. + pub contract_version: u16, + /// Opaque server-assigned run identity. + pub run_id: String, + /// Exact request idempotency key of the accepted run. + pub idempotency_key: String, +} + +impl AnalysisRunCancelRequest { + /// Construct a validated cancel request. + /// + /// # Errors + /// + /// Returns a fail-closed error for empty identities or an unsupported + /// contract version. + pub fn new( + run_id: impl Into, + idempotency_key: impl Into, + ) -> Result { + let request = Self { + contract_version: ANALYSIS_RUN_CANCEL_CONTRACT_VERSION, + run_id: run_id.into(), + idempotency_key: idempotency_key.into(), + }; + request.validate()?; + Ok(request) + } + + /// Parse and validate a cancel request 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 cancel request 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_cancel_payload(payload)?; + let request: Self = from_json(payload)?; + request.validate()?; + Ok(request) + } + + /// Serialize this cancel request 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)?; + refuse_metrics_on_cancel_payload(&payload)?; + Ok(payload) + } + + pub(crate) fn validate(&self) -> Result<(), ApiError> { + require_contract_version(self.contract_version, ANALYSIS_RUN_CANCEL_CONTRACT_VERSION)?; + require_nonempty(&self.run_id)?; + require_nonempty(&self.idempotency_key)?; + if self.run_id.len() > ANALYSIS_RUN_CANCEL_ID_MAX_LEN { + return Err(ApiError::LimitExceeded); + } + Ok(()) + } +} + +/// Refuse cancel JSON that already carries scientific-metric keys. +/// +/// Empty bodies are admitted (the loopback listener treats them as +/// header-and-path cancel). Non-object JSON fails closed as invalid wire. +/// +/// # Errors +/// +/// Returns [`ApiError::InvalidWirePayload`] when a forbidden metric key is +/// present or the payload is a non-empty non-object. +pub fn refuse_metrics_on_cancel_payload(payload: &str) -> Result<(), ApiError> { + if payload.trim().is_empty() { + return Ok(()); + } + let value: serde_json::Value = + serde_json::from_str(payload).map_err(|_| ApiError::InvalidWirePayload)?; + let Some(object) = value.as_object() else { + return Err(ApiError::InvalidWirePayload); + }; + if FORBIDDEN_CANCEL_KEYS + .iter() + .any(|key| object.contains_key(*key)) + { + return Err(ApiError::InvalidWirePayload); + } + Ok(()) +} + +/// Build a provider-owned `POST` analysis-run cancel exchange. +/// +/// The builder refuses non-`https` origins and empty or oversized run +/// identifiers. It does not inject credentials. +/// +/// # Errors +/// +/// Returns [`ApiError::InvalidWirePayload`] for a non-`https` origin or empty +/// identity, and [`ApiError::LimitExceeded`] when the run identity exceeds +/// [`ANALYSIS_RUN_CANCEL_ID_MAX_LEN`] bytes. +pub fn naruon_analysis_run_cancel_exchange( + origin: &str, + request: &AnalysisRunCancelRequest, +) -> Result { + request.validate()?; + let encoded_run_id = encode_path_segment(&request.run_id); + let target_path = format!("{ANALYSIS_RUN_STATUS_PATH}/{encoded_run_id}/cancel"); + let target_url = compose_https_target(origin, &target_path)?; + Ok(NaruonHttpExchange { + method: "POST", + target_url, + headers: standard_headers(&request.idempotency_key), + body: request.to_json()?, + }) +} + +/// Extract the opaque run identity from `POST /v1/analysis-runs/{run_id}/cancel`. +/// +/// # Errors +/// +/// Returns [`ApiError::InvalidWirePayload`] for a collection path, extra +/// segments, a missing `/cancel` suffix, or a hostile encoding, and +/// [`ApiError::LimitExceeded`] when the decoded identity exceeds +/// [`ANALYSIS_RUN_CANCEL_ID_MAX_LEN`]. +pub(crate) fn analysis_run_cancel_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)?; + let encoded = encoded + .strip_suffix("/cancel") + .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_CANCEL_ID_MAX_LEN { + return Err(ApiError::LimitExceeded); + } + Ok(run_id) +} + +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() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => { + out.push(byte as char); + } + _ => { + out.push('%'); + out.push(hex[usize::from(byte >> 4)] as char); + out.push(hex[usize::from(byte & 0x0F)] as char); + } + } + } + out +} + +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), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::DEFAULT_ANALYSIS_RUN_BYTE_LIMIT; + + fn sample_request() -> AnalysisRunCancelRequest { + AnalysisRunCancelRequest::new("tepp-run-1", "idem-1").expect("request") + } + + #[test] + fn cancel_request_round_trips_and_refuses_hostile_shapes() { + let request = sample_request(); + let json = request.to_json().expect("json"); + assert_eq!( + AnalysisRunCancelRequest::from_json(&json).expect("decode"), + request + ); + assert!(!json.contains("rmse")); + assert!(!json.contains("scientific_acceptance")); + + assert_eq!( + AnalysisRunCancelRequest::new("", "idem-1"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + AnalysisRunCancelRequest::new("tepp-run-1", ""), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + AnalysisRunCancelRequest::new("a".repeat(ANALYSIS_RUN_CANCEL_ID_MAX_LEN + 1), "idem-1"), + Err(ApiError::LimitExceeded) + ); + + let mut unsupported = request.clone(); + unsupported.contract_version = 9; + assert_eq!( + unsupported.to_json(), + Err(ApiError::UnsupportedContractVersion) + ); + assert_eq!( + AnalysisRunCancelRequest::from_json( + r#"{"contract_version":9,"run_id":"tepp-run-1","idempotency_key":"idem-1"}"# + ), + Err(ApiError::UnsupportedContractVersion) + ); + assert_eq!( + AnalysisRunCancelRequest::from_json( + r#"{"contract_version":1,"run_id":"tepp-run-1","idempotency_key":"idem-1","extra":true}"# + ), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + AnalysisRunCancelRequest::from_json_with_limit(&json, 8), + Err(ApiError::LimitExceeded) + ); + let mut oversized_json = request.clone(); + oversized_json.idempotency_key = "x".repeat(DEFAULT_ANALYSIS_RUN_BYTE_LIMIT); + assert_eq!(oversized_json.to_json(), Err(ApiError::LimitExceeded)); + assert_eq!( + AnalysisRunCancelRequest::from_json("[1,2,3]"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + AnalysisRunCancelRequest::from_json("not-json"), + Err(ApiError::InvalidWirePayload) + ); + } + + #[test] + fn cancel_payloads_refuse_scientific_metric_keys() { + assert_eq!(refuse_metrics_on_cancel_payload(""), Ok(())); + assert_eq!(refuse_metrics_on_cancel_payload(" "), Ok(())); + assert_eq!( + refuse_metrics_on_cancel_payload(r#"{"run_id":"r"}"#), + Ok(()) + ); + for key in FORBIDDEN_CANCEL_KEYS { + let payload = format!(r#"{{"{key}":1,"run_id":"r"}}"#); + assert_eq!( + refuse_metrics_on_cancel_payload(&payload), + Err(ApiError::InvalidWirePayload), + "key={key}" + ); + let with_contract = format!( + r#"{{"contract_version":1,"run_id":"tepp-run-1","idempotency_key":"idem-1","{key}":0}}"# + ); + assert_eq!( + AnalysisRunCancelRequest::from_json(&with_contract), + Err(ApiError::InvalidWirePayload), + "dto key={key}" + ); + } + assert_eq!( + refuse_metrics_on_cancel_payload("[true]"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + refuse_metrics_on_cancel_payload("null"), + Err(ApiError::InvalidWirePayload) + ); + } + + #[test] + fn cancel_path_decodes_identities_and_refuses_hostile_segments() { + assert_eq!( + analysis_run_cancel_path_run_id("/v1/analysis-runs/tepp-run-1/cancel").expect("plain"), + "tepp-run-1" + ); + assert_eq!( + analysis_run_cancel_path_run_id("/v1/analysis-runs/run%2dabc/cancel").expect("lower"), + "run-abc" + ); + assert_eq!( + analysis_run_cancel_path_run_id("/v1/analysis-runs/run%2Dabc/cancel").expect("upper"), + "run-abc" + ); + assert_eq!( + analysis_run_cancel_path_run_id("/v1/analysis-runs"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + analysis_run_cancel_path_run_id("/v1/analysis-runs/tepp-run-1"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + analysis_run_cancel_path_run_id("/v1/analysis-runs/tepp-run-1/running"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + analysis_run_cancel_path_run_id("/v1/analysis-runs/tepp-run-1/terminal"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + analysis_run_cancel_path_run_id("/v1/other/tepp-run-1/cancel"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + analysis_run_cancel_path_run_id("/v1/analysis-runs//cancel"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + analysis_run_cancel_path_run_id("/v1/analysis-runs/a/b/cancel"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + analysis_run_cancel_path_run_id("/v1/analysis-runs/%2F/cancel"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + analysis_run_cancel_path_run_id("/v1/analysis-runs/%00/cancel"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + analysis_run_cancel_path_run_id("/v1/analysis-runs/%/cancel"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + analysis_run_cancel_path_run_id("/v1/analysis-runs/%2/cancel"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + analysis_run_cancel_path_run_id("/v1/analysis-runs/%2G/cancel"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + analysis_run_cancel_path_run_id("/v1/analysis-runs/run space/cancel"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + analysis_run_cancel_path_run_id("/v1/analysis-runs/%80/cancel"), + Err(ApiError::InvalidWirePayload) + ); + let oversized = format!( + "/v1/analysis-runs/{}/cancel", + "a".repeat(ANALYSIS_RUN_CANCEL_ID_MAX_LEN + 1) + ); + assert_eq!( + analysis_run_cancel_path_run_id(&oversized), + Err(ApiError::LimitExceeded) + ); + assert_eq!(decode_path_segment(""), 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("%2g"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!(from_hex(b'g'), Err(ApiError::InvalidWirePayload)); + assert_eq!(from_hex(b'0'), Ok(0)); + assert_eq!(from_hex(b'a'), Ok(10)); + assert_eq!(from_hex(b'F'), Ok(15)); + } + + #[test] + fn cancel_exchange_posts_https_path_without_credentials() { + let request = sample_request(); + let exchange = naruon_analysis_run_cancel_exchange("https://tepp.example.com", &request) + .expect("exchange"); + assert_eq!(exchange.method, "POST"); + assert_eq!( + exchange.target_url, + "https://tepp.example.com/v1/analysis-runs/tepp-run-1/cancel" + ); + assert!(!exchange.body.is_empty()); + assert_eq!( + exchange.headers, + [ + ("content-type", "application/json"), + ("tepp-consumer", "naruon"), + ("tepp-contract-version", "1"), + ("idempotency-key", "idem-1"), + ] + .map(|(name, value)| (name.into(), value.into())) + ); + + let encoded = AnalysisRunCancelRequest::new("run/../../etc", "key").expect("encoded"); + let exchange = naruon_analysis_run_cancel_exchange("https://tepp.example.com", &encoded) + .expect("encoded exchange"); + assert!(exchange.target_url.contains("run%2F..%2F..%2Fetc/cancel")); + + assert_eq!( + naruon_analysis_run_cancel_exchange("http://tepp.example.com", &request), + Err(ApiError::InvalidWirePayload) + ); + let mut empty_id = request.clone(); + empty_id.run_id.clear(); + assert_eq!( + naruon_analysis_run_cancel_exchange("https://tepp.example.com", &empty_id), + Err(ApiError::InvalidWirePayload) + ); + let mut oversized = request; + oversized.run_id = "a".repeat(ANALYSIS_RUN_CANCEL_ID_MAX_LEN + 1); + assert_eq!( + naruon_analysis_run_cancel_exchange("https://tepp.example.com", &oversized), + Err(ApiError::LimitExceeded) + ); + } +} diff --git a/crates/tepp_api/src/analysis_run_live.rs b/crates/tepp_api/src/analysis_run_live.rs index 6768c6ef1..d4a7b4cc0 100644 --- a/crates/tepp_api/src/analysis_run_live.rs +++ b/crates/tepp_api/src/analysis_run_live.rs @@ -4,12 +4,18 @@ //! 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. +//! results remain outside this crate. `POST /v1/analysis-runs/{run_id}/cancel` +//! is the operator-visible cancel path: accepted and running runs become +//! metric-free `cancelled` status. GET status and running/terminal POST +//! transitions remain later slices. use std::collections::HashMap; use std::io::Write; use std::net::{SocketAddr, TcpListener}; +use crate::analysis_run_cancel_http::{ + AnalysisRunCancelRequest, analysis_run_cancel_path_run_id, refuse_metrics_on_cancel_payload, +}; 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, @@ -17,10 +23,11 @@ use crate::live_http::{ }; use crate::naruon_http::NARUON_ANALYSIS_RUN_PATH; 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, 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, }; const MAX_LIVE_REQUEST_BODY_BYTES: usize = DEFAULT_PROJECT_HISTORY_BYTE_LIMIT; @@ -28,6 +35,15 @@ 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 lifecycle state. +#[derive(Clone, Debug, Eq, PartialEq)] +struct LiveAnalysisRun { + consumer: String, + request: AnalysisRunRequest, + accepted: AnalysisRunAccepted, + run_state: AnalysisRunStatusState, +} + /// Loopback HTTP/1.1 analysis-run service shared by published CWL consumers. /// /// The service accepts only Naruon and `LineageWeave` consumer identities. Its @@ -39,7 +55,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 +76,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,14 +161,22 @@ 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(""))?; - if method != "POST" - || (path != NARUON_ANALYSIS_RUN_PATH - && path != TEMPORAL_CONTEXT_PATH - && path != PROJECT_HISTORY_PATH) - { + if method != "POST" { return Err(ApiError::InvalidWirePayload); } let headers = parse_headers(&mut lines)?; + if matches!( + analysis_run_cancel_path_run_id(path), + Ok(_) | Err(ApiError::LimitExceeded) + ) { + return self.cancel_analysis_run(path, &headers, body); + } + if path != NARUON_ANALYSIS_RUN_PATH + && path != TEMPORAL_CONTEXT_PATH + && path != PROJECT_HISTORY_PATH + { + return Err(ApiError::InvalidWirePayload); + } let consumer = require_headers( &headers, self.bound_addr, @@ -186,21 +212,97 @@ 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) { + return Ok(json_response(202, "Accepted", stored.accepted.to_json()?)); } return Err(ApiError::InvalidWirePayload); } let run_id = format!("tepp-run-{}", self.next_run_serial); self.next_run_serial += 1; let accepted = - AnalysisRunAccepted::new(run_id, "accepted", request.idempotency_key.clone())?; + AnalysisRunAccepted::new(run_id.clone(), "accepted", request.idempotency_key.clone())?; let response_body = accepted.to_json()?; - self.accepted_runs.insert(replay_key, (request, accepted)); + refuse_metrics_on_cancel_payload(&response_body)?; + self.runs_by_id.insert(run_id, replay_key.clone()); + self.accepted_runs.insert( + replay_key, + LiveAnalysisRun { + consumer: consumer.to_owned(), + request, + accepted, + run_state: AnalysisRunStatusState::Accepted, + }, + ); Ok(json_response(202, "Accepted", response_body)) } + fn cancel_analysis_run( + &mut self, + path: &str, + headers: &HashMap, + body: &str, + ) -> Result { + let run_id = analysis_run_cancel_path_run_id(path)?; + let consumer = require_headers(headers, self.bound_addr, true)?; + refuse_metrics_on_cancel_payload(body)?; + let idempotency_key = header_value(headers, "idempotency-key")?; + if !body.trim().is_empty() { + let cancel = AnalysisRunCancelRequest::from_json(body)?; + if cancel.run_id != run_id || cancel.idempotency_key != idempotency_key { + return Err(ApiError::InvalidWirePayload); + } + } + let replay_key = self + .runs_by_id + .get(&run_id) + .cloned() + .ok_or(ApiError::InvalidWirePayload)?; + let stored = self + .accepted_runs + .get_mut(&replay_key) + .ok_or(ApiError::InvalidWirePayload)?; + if stored.consumer != consumer || stored.accepted.idempotency_key != idempotency_key { + return Err(ApiError::InvalidWirePayload); + } + match stored.run_state { + AnalysisRunStatusState::Accepted | AnalysisRunStatusState::Running => { + stored.run_state = AnalysisRunStatusState::Cancelled; + } + AnalysisRunStatusState::Cancelled => {} + AnalysisRunStatusState::Succeeded | AnalysisRunStatusState::Failed => { + return Err(ApiError::InvalidWirePayload); + } + } + let status = AnalysisRunStatus::cancelled(&stored.accepted)?; + let response_body = status.to_json()?; + refuse_metrics_on_cancel_payload(&response_body)?; + Ok(json_response(200, "OK", response_body)) + } + + /// Test-only seam that records a non-accepted loopback state. + /// + /// Used to prove cancel of running, succeeded, and failed runs without + /// duplicating the live POST running/terminal lifecycle slice. + #[cfg(test)] + fn force_loopback_run_state( + &mut self, + run_id: &str, + run_state: AnalysisRunStatusState, + ) -> Result<(), ApiError> { + let replay_key = self + .runs_by_id + .get(run_id) + .cloned() + .ok_or(ApiError::InvalidWirePayload)?; + let stored = self + .accepted_runs + .get_mut(&replay_key) + .ok_or(ApiError::InvalidWirePayload)?; + stored.run_state = run_state; + Ok(()) + } + fn accept_project_history( &mut self, consumer: &str, @@ -661,6 +763,268 @@ mod tests { assert_eq!(service.handle_http_request(&transfer).status_code, 400); } + fn cancel_http(run_id: &str, body: &str, consumer: &str, idempotency_key: &str) -> String { + let mut request = format!("POST {NARUON_ANALYSIS_RUN_PATH}/{run_id}/cancel HTTP/1.1\r\n"); + write!( + request, + "Host: 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() + ) + .expect("cancel request"); + request + } + + #[test] + #[allow(clippy::too_many_lines)] + fn handler_covers_metric_free_cancel_and_terminal_refusal() { + use crate::{AnalysisRunCancelRequest, AnalysisRunStatus, AnalysisRunStatusState}; + + let run = sample_run(); + 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 receipt: serde_json::Value = + serde_json::from_str(&accepted.body).expect("accepted json"); + let run_id = receipt["run_id"].as_str().expect("run_id"); + assert_eq!(run_id, "tepp-run-1"); + + let cancel_body = AnalysisRunCancelRequest::new(run_id, run.idempotency_key.as_str()) + .expect("cancel dto") + .to_json() + .expect("cancel json"); + let cancelled = service.handle_http_request(&cancel_http( + run_id, + &cancel_body, + NARUON_CONSUMER_CODE, + run.idempotency_key.as_str(), + )); + assert_eq!(cancelled.status_code, 200); + let status = AnalysisRunStatus::from_json(&cancelled.body).expect("cancelled status"); + assert_eq!(status.run_state, AnalysisRunStatusState::Cancelled); + assert_eq!(status.terminal_result, None); + assert!(!cancelled.body.contains("rmse")); + assert!(!cancelled.body.contains("scientific_acceptance")); + + let replay = service.handle_http_request(&cancel_http( + run_id, + &cancel_body, + NARUON_CONSUMER_CODE, + run.idempotency_key.as_str(), + )); + assert_eq!(replay.status_code, 200); + assert_eq!(replay.body, cancelled.body); + + let empty_body = service.handle_http_request(&cancel_http( + run_id, + "", + NARUON_CONSUMER_CODE, + run.idempotency_key.as_str(), + )); + assert_eq!(empty_body.status_code, 200); + + let create_replay = + service.handle_http_request(&valid_request(&run, NARUON_CONSUMER_CODE, "127.0.0.1")); + assert_eq!(create_replay.status_code, 202); + assert_eq!(create_replay.body, accepted.body); + + let mut second = run.clone(); + second.idempotency_key = "analysis-live-idem-002".into(); + let running_accepted = + service.handle_http_request(&valid_request(&second, NARUON_CONSUMER_CODE, "127.0.0.1")); + let running_id = serde_json::from_str::(&running_accepted.body) + .expect("running accepted")["run_id"] + .as_str() + .expect("id") + .to_owned(); + service + .force_loopback_run_state(&running_id, AnalysisRunStatusState::Running) + .expect("force running"); + let running_cancel = service.handle_http_request(&cancel_http( + &running_id, + "", + NARUON_CONSUMER_CODE, + second.idempotency_key.as_str(), + )); + assert_eq!(running_cancel.status_code, 200); + assert_eq!( + AnalysisRunStatus::from_json(&running_cancel.body) + .expect("running cancelled") + .run_state, + AnalysisRunStatusState::Cancelled + ); + + for (state, key_suffix) in [ + (AnalysisRunStatusState::Succeeded, "003"), + (AnalysisRunStatusState::Failed, "004"), + ] { + let mut terminal = run.clone(); + terminal.idempotency_key = format!("analysis-live-idem-{key_suffix}"); + let terminal_accepted = service.handle_http_request(&valid_request( + &terminal, + NARUON_CONSUMER_CODE, + "127.0.0.1", + )); + let terminal_id = serde_json::from_str::(&terminal_accepted.body) + .expect("terminal accepted")["run_id"] + .as_str() + .expect("id") + .to_owned(); + service + .force_loopback_run_state(&terminal_id, state) + .expect("force terminal"); + assert_eq!( + service + .handle_http_request(&cancel_http( + &terminal_id, + "", + NARUON_CONSUMER_CODE, + terminal.idempotency_key.as_str(), + )) + .status_code, + 400, + "state={state:?}" + ); + } + + assert_eq!( + service + .handle_http_request(&cancel_http( + "missing-run", + "", + NARUON_CONSUMER_CODE, + run.idempotency_key.as_str(), + )) + .status_code, + 400 + ); + assert_eq!( + service + .handle_http_request(&cancel_http( + run_id, + &cancel_body, + LINEAGEWEAVE_CONSUMER_CODE, + run.idempotency_key.as_str(), + )) + .status_code, + 400 + ); + assert_eq!( + service + .handle_http_request(&cancel_http( + run_id, + &cancel_body, + NARUON_CONSUMER_CODE, + "wrong-key", + )) + .status_code, + 400 + ); + assert_eq!( + service + .handle_http_request(&cancel_http(run_id, "", NARUON_CONSUMER_CODE, "wrong-key",)) + .status_code, + 400 + ); + let mismatched = AnalysisRunCancelRequest::new("other-run", run.idempotency_key.as_str()) + .expect("mismatch") + .to_json() + .expect("mismatch json"); + assert_eq!( + service + .handle_http_request(&cancel_http( + run_id, + &mismatched, + NARUON_CONSUMER_CODE, + run.idempotency_key.as_str(), + )) + .status_code, + 400 + ); + let key_mismatch = AnalysisRunCancelRequest::new(run_id, "wrong-key") + .expect("key mismatch") + .to_json() + .expect("key json"); + assert_eq!( + service + .handle_http_request(&cancel_http( + run_id, + &key_mismatch, + NARUON_CONSUMER_CODE, + run.idempotency_key.as_str(), + )) + .status_code, + 400 + ); + let metric_body = r#"{"contract_version":1,"run_id":"tepp-run-1","idempotency_key":"analysis-live-idem-001","rmse":0.1}"#; + assert_eq!( + service + .handle_http_request(&cancel_http( + run_id, + metric_body, + NARUON_CONSUMER_CODE, + run.idempotency_key.as_str(), + )) + .status_code, + 400 + ); + assert_eq!( + service + .handle_http_request(&format!( + "GET {NARUON_ANALYSIS_RUN_PATH}/{run_id}/cancel HTTP/1.1\r\ncontent-length: 0\r\n\r\n" + )) + .status_code, + 400 + ); + assert_eq!( + service + .handle_http_request(&format!( + "POST {NARUON_ANALYSIS_RUN_PATH}/{run_id} HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: naruon\r\ntepp-contract-version: 1\r\nidempotency-key: {}\r\ncontent-length: 0\r\n\r\n", + run.idempotency_key + )) + .status_code, + 400 + ); + let oversized = "a".repeat(129); + assert_eq!( + service + .handle_http_request(&cancel_http( + &oversized, + "", + NARUON_CONSUMER_CODE, + run.idempotency_key.as_str(), + )) + .status_code, + 413 + ); + assert_eq!( + service + .force_loopback_run_state("missing", AnalysisRunStatusState::Running) + .expect_err("unknown force"), + ApiError::InvalidWirePayload + ); + service + .runs_by_id + .insert("dangling".into(), "missing-replay".into()); + assert_eq!( + service + .force_loopback_run_state("dangling", AnalysisRunStatusState::Running) + .expect_err("dangling force"), + ApiError::InvalidWirePayload + ); + assert_eq!( + service + .handle_http_request(&cancel_http( + "dangling", + "", + NARUON_CONSUMER_CODE, + run.idempotency_key.as_str(), + )) + .status_code, + 400 + ); + } + #[test] fn temporal_read_headers_and_defensive_write_edges_are_covered() { let run = sample_run(); diff --git a/crates/tepp_api/src/lib.rs b/crates/tepp_api/src/lib.rs index 876703ebc..cdc7f7cf9 100644 --- a/crates/tepp_api/src/lib.rs +++ b/crates/tepp_api/src/lib.rs @@ -13,6 +13,7 @@ mod analysis_result; mod analysis_run; +mod analysis_run_cancel_http; mod analysis_run_live; mod analysis_run_status_http; mod authorization; @@ -69,6 +70,16 @@ 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; +/// Analysis-run cancel contract version constant. +pub use analysis_run_cancel_http::ANALYSIS_RUN_CANCEL_CONTRACT_VERSION; +/// Maximum opaque run identity length on the cancel path. +pub use analysis_run_cancel_http::ANALYSIS_RUN_CANCEL_ID_MAX_LEN; +/// Versioned analysis-run cancel request. +pub use analysis_run_cancel_http::AnalysisRunCancelRequest; +/// Build a Naruon analysis-run cancel exchange. +pub use analysis_run_cancel_http::naruon_analysis_run_cancel_exchange; +/// Refuse scientific-metric keys on a cancel payload. +pub use analysis_run_cancel_http::refuse_metrics_on_cancel_payload; /// 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/analysis_result_contract.rs b/crates/tepp_api/tests/analysis_result_contract.rs index c18e536e3..f89cf8ee7 100644 --- a/crates/tepp_api/tests/analysis_result_contract.rs +++ b/crates/tepp_api/tests/analysis_result_contract.rs @@ -164,6 +164,10 @@ fn serialization_enforces_default_result_and_status_limits() { AnalysisRunStatus::accepted(&oversized_accepted), Err(ApiError::LimitExceeded) ); + assert_eq!( + AnalysisRunStatus::cancelled(&oversized_accepted), + Err(ApiError::LimitExceeded) + ); let mut near_limit_result = succeeded(); let initial_size = near_limit_result.to_json().expect("initial result").len(); @@ -443,6 +447,30 @@ fn status_read_contract_round_trips_lifecycle_and_terminal_results() { Ok(()) ); + let cancelled_status = AnalysisRunStatus::cancelled(&accepted()).expect("cancelled status"); + assert_eq!( + cancelled_status.run_state, + AnalysisRunStatusState::Cancelled + ); + assert_eq!(cancelled_status.terminal_result, None); + assert_eq!( + require_status_binding(&request(), &accepted(), &cancelled_status), + Ok(()) + ); + let cancelled_json = cancelled_status.to_json().expect("cancelled json"); + assert!(!cancelled_json.contains("rmse")); + assert!(!cancelled_json.contains("scientific_acceptance")); + assert_eq!( + AnalysisRunStatus::from_json(&cancelled_json).expect("cancelled decode"), + cancelled_status + ); + let mut invalid_cancelled = cancelled_status; + invalid_cancelled.terminal_result = Some(succeeded()); + assert_eq!( + invalid_cancelled.to_json(), + Err(ApiError::InvalidWirePayload) + ); + for (result, expected_state) in [ (succeeded(), AnalysisRunStatusState::Succeeded), (failed(), AnalysisRunStatusState::Failed), diff --git a/crates/tepp_api/tests/analysis_run_cancel_http_contract.rs b/crates/tepp_api/tests/analysis_run_cancel_http_contract.rs new file mode 100644 index 000000000..637b43a1d --- /dev/null +++ b/crates/tepp_api/tests/analysis_run_cancel_http_contract.rs @@ -0,0 +1,63 @@ +//! Contract tests for the analysis-run cancel HTTP exchange. + +use tepp_api::{ + ANALYSIS_RUN_CANCEL_CONTRACT_VERSION, ANALYSIS_RUN_CANCEL_ID_MAX_LEN, AnalysisRunCancelRequest, + ApiError, naruon_analysis_run_cancel_exchange, refuse_metrics_on_cancel_payload, +}; + +#[test] +fn cancel_exchange_is_https_post_without_credentials_or_metrics() { + let request = AnalysisRunCancelRequest::new("tepp-run-9", "idem-9").expect("request"); + assert_eq!( + request.contract_version, + ANALYSIS_RUN_CANCEL_CONTRACT_VERSION + ); + let exchange = naruon_analysis_run_cancel_exchange("https://tepp.example.test", &request) + .expect("exchange"); + assert_eq!(exchange.method, "POST"); + assert_eq!( + exchange.target_url, + "https://tepp.example.test/v1/analysis-runs/tepp-run-9/cancel" + ); + assert!( + exchange + .headers + .iter() + .any(|(name, value)| name == "idempotency-key" && value == "idem-9") + ); + assert!( + !exchange + .headers + .iter() + .any(|(name, _)| name.contains("authorization") + || name.contains("token") + || name.contains("copilot")) + ); + let decoded = AnalysisRunCancelRequest::from_json(&exchange.body).expect("body"); + assert_eq!(decoded, request); + assert_eq!(refuse_metrics_on_cancel_payload(&exchange.body), Ok(())); +} + +#[test] +fn cancel_contract_refuses_table_access_and_metric_keys() { + let request = AnalysisRunCancelRequest::new("tepp-run-9", "idem-9").expect("request"); + for origin in [ + "http://tepp.example.test", + "https://db.postgres.example", + "https://jdbc.example", + ] { + assert_eq!( + naruon_analysis_run_cancel_exchange(origin, &request), + Err(ApiError::InvalidWirePayload), + "origin={origin}" + ); + } + assert_eq!( + AnalysisRunCancelRequest::new("a".repeat(ANALYSIS_RUN_CANCEL_ID_MAX_LEN + 1), "idem-9"), + Err(ApiError::LimitExceeded) + ); + assert_eq!( + refuse_metrics_on_cancel_payload(r#"{"rmse":1.0}"#), + Err(ApiError::InvalidWirePayload) + ); +} diff --git a/docs/API_CONTRACT.md b/docs/API_CONTRACT.md index b76b688e1..8dc69e27d 100644 --- a/docs/API_CONTRACT.md +++ b/docs/API_CONTRACT.md @@ -8,7 +8,7 @@ TEPP must work both as a standalone product and as a modular CWL component. Integrations with `naruon`, `contextual-orchestrator`, `.github`, or other repositories use explicit versioned API/artifact contracts. Cross-service direct table access is prohibited. -Current protected main exposes Rust library/domain contracts. The active stack adds a loopback HTTP/1.1 listener for naruon analysis-run, LineageWeave temporal-context, and export POSTs, including `POST /v1/project-histories` on the `AnalysisRunLiveService` contract boundary. `tepp-loopback` runs the shared consumer listener on `127.0.0.1:18081` by default; a caller may pass another loopback socket address and an optional maximum request count as its two arguments. The container is intended for a trusted same-host or shared-network-namespace sidecar, checks readiness through a synthetic bounded temporal-context request, and deliberately cannot bind a public or bridge address. It is not a production TLS/`$PORT` service. Endpoint examples below that are not covered by `NaruonLiveService` or `AnalysisRunLiveService` remain target interface shapes; export retrieval stays a target shape until an executable export route ships. +Current protected main exposes Rust library/domain contracts. The active stack adds a loopback HTTP/1.1 listener for naruon analysis-run, LineageWeave temporal-context, and export POSTs, including `POST /v1/project-histories` on the `AnalysisRunLiveService` contract boundary and `POST /v1/analysis-runs/{run_id}/cancel` for metric-free cancellation of accepted or running runs. `tepp-loopback` runs the shared consumer listener on `127.0.0.1:18081` by default; a caller may pass another loopback socket address and an optional maximum request count as its two arguments. The container is intended for a trusted same-host or shared-network-namespace sidecar, checks readiness through a synthetic bounded temporal-context request, and deliberately cannot bind a public or bridge address. It is not a production TLS/`$PORT` service. Endpoint examples below that are not covered by `NaruonLiveService` or `AnalysisRunLiveService` remain target interface shapes; export retrieval stays a target shape until an executable export route ships. ## 2. Contract families @@ -73,11 +73,15 @@ GET /v1/exports/{export_id} Long-running analysis is durable asynchronous work. `POST /v1/analysis-runs` accepts an idempotency key, immutable input snapshot identity, knowledge cutoff, versioned model contract/configuration, and requested output profile. A retry with the same principal/idempotency key and semantically identical request returns the same run identity; a conflicting body fails closed. -The typed status/read contract returns `accepted`, `running`, `succeeded`, or -`failed`. Accepted and running statuses contain no measurement result. A -terminal status contains exactly one request-bound `AnalysisRunTerminalResult`; -consumers validate its request, receipt, snapshot, cutoff, model, profile, and -idempotency bindings before treating it as measurement evidence. +The typed status/read contract returns `accepted`, `running`, `succeeded`, +`failed`, or `cancelled`. Accepted, running, and cancelled statuses contain no +measurement result. A succeeded or failed terminal status contains exactly one +request-bound `AnalysisRunTerminalResult`; consumers validate its request, +receipt, snapshot, cutoff, model, profile, and idempotency bindings before +treating it as measurement evidence. `POST /v1/analysis-runs/{run_id}/cancel` +on the loopback listener transitions accepted or running runs to cancelled; +succeeded, failed, and unknown runs fail closed. GET status remains a later +slice on this protected-main lineage. 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..d0242669e 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 cancel HTTP | ADR 0029; API contract; RFC 9110 | `tepp_api` `POST /v1/analysis-runs/{run_id}/cancel` on `AnalysisRunLiveService`: metric-free cancelled status for accepted/running runs; succeeded/failed/unknown refuse; GET status remains a later slice | 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/0029-analysis-run-cancel-http.md b/docs/adr/0029-analysis-run-cancel-http.md new file mode 100644 index 000000000..4c2877741 --- /dev/null +++ b/docs/adr/0029-analysis-run-cancel-http.md @@ -0,0 +1,80 @@ +# ADR 0029 — Analysis-run cancel HTTP path + +**Decision status:** Accepted +**Implementation maturity:** active-PR +**Date:** 2026-08-31 +**Supersedes:** None; complements ADR 0018 for the operator-visible cancel path. Does not supersede ADR 0014 claim-promotion authority. ADR 0026–0028 remain on live GAP-003A engine-library, GET-status, and lifecycle-POST slices. + +## Context + +`docs/API_CONTRACT.md` documents `POST /v1/analysis-runs/{run_id}/cancel` and the lifecycle `accepted/running -> cancelling -> cancelled`. Protected main accepts analysis runs on loopback but refuses every non-create analysis-run path. Operators therefore cannot withdraw an accepted or running run. Returning RMSE, bias, coverage, SE-gate, or `tepp.scientific_acceptance.v1` on a cancel body would treat cancellation as measurement evidence. Stacking this slice onto the live GET-status or lifecycle-POST PRs would duplicate those heads. + +## Decision + +`AnalysisRunLiveService` serves `POST /v1/analysis-runs/{run_id}/cancel` on loopback: + +- Accepted and running runs transition atomically to metric-free `cancelled` status. +- Already-cancelled runs are idempotent: the same `200` cancelled status is returned. +- Succeeded, failed, and unknown runs cannot be cancelled. +- Empty POST bodies are admitted and bind path `run_id` plus the `idempotency-key` header. A typed `AnalysisRunCancelRequest` body must match path identity and header key. +- Cancel bodies and cancelled status JSON refuse RMSE, bias, coverage, SE-gate, scientific-acceptance, and report keys. +- `Cancelling` is not an HTTP response state: the loopback proof has no worker, so cancel is atomic. +- GET status and running/terminal POST transitions remain later slices. Persistence remains GAP-003B. + +## Non-goals + +- Production TLS, public bind, or durable cancel storage. +- Leiden community detection, Driver p.16 std-family restoration, or Figma/export work. +- Promoting an ADR 0014 scientific claim from HTTP success. +- Duplicating GET `/v1/analysis-runs/{run_id}` or POST running/terminal. + +## Alternatives considered + +1. **Stack cancel onto the live GET-status PR** — rejected because that head is moving and cancel is independently operator-visible. +2. **Return `cancelling` then `cancelled`** — rejected because the loopback proof has no worker and a two-step HTTP state would be fiction. +3. **Carry scientific-acceptance metrics on the cancel receipt** — rejected because cancellation is not measurement evidence. +4. **Atomic accepted/running → cancelled with metric-free `200`** — accepted. + +## Consequences + +- Operators can withdraw an accepted or running run on the same loopback listener that created it. +- Cancelled status cannot be mistaken for a succeeded scientific-acceptance result. +- GET status may later report `cancelled` without changing these cancel gates. + +## Failure and recovery + +Unknown run identities, extra path segments, truncated percent-encoding, metric keys on cancel bodies, succeeded/failed cancel, consumer mismatch, and path/header/body identity mismatch return a redacted `400` envelope. Oversized run identities return `413`. Credential headers remain `403`. The in-memory registry is not durable; a restart requires re-POSTing the original metric-free create request. Callers must not fabricate a succeeded run from a cancelled status. + +## Security, privacy, scientific-integrity, and governance impact + +- No credential headers cross the consumer boundary. +- Cancel remains loopback-only, size-bounded, and content-redacting. +- HTTP `200` on a cancelled run is not measurement evidence and is not release evidence. + +## Compatibility and migration + +The existing POST analysis-run, temporal-context, and project-history paths are unchanged. GET remains refused on this slice. Production adapters may replace loopback while preserving metric-free cancelled status and the succeeded/failed cancel refusal. + +## Verification + +Falsifiable evidence: + +- POST accepted JSON has no RMSE/bias/coverage/SE-gate/scientific-acceptance keys; +- POST cancel of accepted and running returns metric-free `cancelled`; +- POST cancel of already-cancelled is idempotent; +- POST cancel of succeeded, failed, and unknown runs fails closed; +- metric keys, consumer mismatch, and identity mismatch fail closed; +- GET `/v1/analysis-runs` remains `400` on this slice; +- Clippy `-D warnings`, `tepp_api` tests, rustdoc, and exact-head review remain required. + +## Rollback and supersession + +Rollback removes cancel dispatch and the in-memory run-id index; POST create receipts remain valid. A superseding ADR is required to persist cancel, bind a public address, emit `cancelling` as an HTTP state, 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 0011 owns standalone/modular HTTP boundaries. +- RFC 9110 owns POST semantics (Fielding, Nottingham, & Reschke, 2022). It does not authorize scientific claims. diff --git a/docs/adr/README.md b/docs/adr/README.md index 1254c8079..e54dce807 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. | +| [0029](0029-analysis-run-cancel-http.md) | Loopback POST analysis-run cancel is metric-free cancelled status | Accepted | active-PR | Complements ADR 0018; does not supersede ADR 0014. ADR 0026–0028 live on other GAP-003A PRs. | | [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. | @@ -140,6 +141,7 @@ Use the narrowest owning ADR when decisions overlap: - **accepted-run execution and terminal artifact production:** ADR 0022. - **independent lineage criterion and posterior Project Journey:** ADR 0023. - **macOS-native Rust-owned MLX Metal execution:** ADR 0024. +- **analysis-run cancel HTTP:** ADR 0029. ## Change and supersession rule diff --git a/docs/connectors/naruon-artifact-consumer.md b/docs/connectors/naruon-artifact-consumer.md index 9c6f6d185..db635810d 100644 --- a/docs/connectors/naruon-artifact-consumer.md +++ b/docs/connectors/naruon-artifact-consumer.md @@ -26,6 +26,7 @@ TEPP remains the scientific authority for estimation, recovery metrics, temporal | GraphML relation export | `tepp_api` `GraphMlExport` | TEPP → naruon | | purpose-bound export auth | `tepp_api` `authorize_export` with `ModularServiceConsumer` | TEPP gate | | HTTP analysis-run create | `tepp_api` `naruon_analysis_run_exchange` → `POST /v1/analysis-runs` | naruon → TEPP | +| HTTP analysis-run cancel | `tepp_api` `naruon_analysis_run_cancel_exchange` → `POST /v1/analysis-runs/{run_id}/cancel` | naruon → TEPP | | HTTP export authorize | `tepp_api` `naruon_export_exchange` → `POST /v1/exports` | naruon → TEPP | | Live loopback POST | `tepp_api` `NaruonLiveService` → `POST /v1/analysis-runs` and `/v1/exports` | naruon → TEPP | @@ -48,7 +49,9 @@ When naruon requests an export, TEPP evaluates `AnalyticalPurpose::ModularServic - redefinition of reserved headers (`content-type`, `tepp-consumer`, `tepp-contract-version`, `idempotency-key`) via extra headers → reject; - export interchange without a nonempty per-export idempotency key → reject; -- lexical method codes (`tfidf`, `bm25`, `keyword`) claiming TEPP inference → reject. +- lexical method codes (`tfidf`, `bm25`, `keyword`) claiming TEPP inference → reject; +- scientific-metric keys (`rmse`, `bias`, `coverage`, `se_gate`, `scientific_acceptance`, `report`) on a cancel body → reject; +- cancel of a succeeded, failed, or unknown analysis run → reject. ## Authority sources diff --git a/docs/research/analysis-run-cancel-http.md b/docs/research/analysis-run-cancel-http.md new file mode 100644 index 000000000..370601770 --- /dev/null +++ b/docs/research/analysis-run-cancel-http.md @@ -0,0 +1,56 @@ +# Analysis-run cancel HTTP (doctoring) + +## Scope + +`AnalysisRunLiveService` serves `POST /v1/analysis-runs/{run_id}/cancel` on a +loopback-only HTTP/1.1 listener. HTTP method, path, and header semantics +follow current HTTP semantics (Fielding, Nottingham, & Reschke, 2022). +Fail-closed refusal of non-loopback binds, table-access hosts, +review/Copilot/GitHub credential headers, and scientific-authority promotion +is repository contract authority (ADR 0018; ADR 0011; ADR 0029), not an RFC +inference rule. + +Cancelled responses are metric-free `AnalysisRunStatus` JSON with +`run_state = cancelled` and no `terminal_result`. HTTP `200` is not a +completed temporal model, calibrated score, theta estimate, uncertainty +statement, or scientific claim. + +## Authority + +### External standards (HTTP only) + +Fielding, R., Nottingham, M., & Reschke, J. (Eds.). (2022). *HTTP semantics* +(RFC 9110). IETF. https://doi.org/10.17487/RFC9110 + +RFC 9110 §9.3.3 describes POST as a method for processing the request +according to the resource's own semantics. TEPP maps that processing onto an +atomic accepted/running → cancelled transition. The RFC does not define +psychometric acceptance, RMSE, or claim promotion. + +### Internal contract evidence + +- `docs/adr/0029-analysis-run-cancel-http.md` — cancel authority and + metric-free cancelled status +- `docs/adr/0018-consumer-scoped-analysis-run-ingress.md` — closed consumer + registry and metric-free `202 Accepted` +- `docs/adr/0014-scientific-claim-promotion-and-release-evidence.md` — HTTP + success is not a scientific claim +- `docs/API_CONTRACT.md` — documented cancel resource +- `crates/tepp_api/tests/analysis_run_cancel_http_contract.rs` — fail-closed + cancel exchange proofs + +## Verification + +- loopback `POST /v1/analysis-runs/{run_id}/cancel` of an accepted run + returns `200` cancelled status without RMSE/bias/coverage/SE-gate keys; +- running runs cancel to the same metric-free status; +- already-cancelled runs replay the same body; +- succeeded, failed, and unknown runs fail closed; +- GET `/v1/analysis-runs` remains `400` on this slice; +- review, Copilot, GitHub, and bearer headers remain `AuthorizationDenied`. + +## Non-claims + +This slice does not implement GET status, running/terminal POST, persistence, +production TLS, Leiden consensus, or an ADR 0014 scientific claim-promotion +package. diff --git a/schemas/analysis_run_cancel_request_v1.json b/schemas/analysis_run_cancel_request_v1.json new file mode 100644 index 000000000..bb773534d --- /dev/null +++ b/schemas/analysis_run_cancel_request_v1.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://tepp.local/schemas/analysis_run_cancel_request_v1.json", + "title": "AnalysisRunCancelRequestV1", + "type": "object", + "additionalProperties": false, + "required": [ + "contract_version", + "run_id", + "idempotency_key" + ], + "properties": { + "contract_version": { "type": "integer", "const": 1 }, + "run_id": { "type": "string", "minLength": 1, "maxLength": 128, "pattern": ".*\\S.*" }, + "idempotency_key": { "type": "string", "minLength": 1, "pattern": ".*\\S.*" } + } +}