diff --git a/CHANGELOG.d/analysis-run-retry-parent-http.md b/CHANGELOG.d/analysis-run-retry-parent-http.md new file mode 100644 index 000000000..906fccaa1 --- /dev/null +++ b/CHANGELOG.d/analysis-run-retry-parent-http.md @@ -0,0 +1 @@ +- `tepp_api` loopback `GET /v1/analysis-runs/{run_id}/parent` returns the metric-free parent identity of a listed run so operators can inspect which parent a retry child was cloned from (ADR 0038). Original runs return `"parent": null`. GET-by-id remains refused. Not lifecycle POST, not cancel, not collection GET, not retry POST, not stored-request GET, not retry-lineage GET, not idempotency-key lookup GET, not persistence. diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index ac5014a51..7db17fefd 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -19,6 +19,7 @@ TEPP's approved PRD v0.4 and implementation plan are the primary product baselin | Analysis-run stored-request HTTP doctoring | [`docs/research/analysis-run-stored-request-http.md`](docs/research/analysis-run-stored-request-http.md) | | Analysis-run retry-lineage HTTP doctoring | [`docs/research/analysis-run-retry-lineage-http.md`](docs/research/analysis-run-retry-lineage-http.md) | | Analysis-run idempotency-key lookup HTTP doctoring | [`docs/research/analysis-run-idempotency-lookup-http.md`](docs/research/analysis-run-idempotency-lookup-http.md) | +| Analysis-run retry-parent HTTP doctoring | [`docs/research/analysis-run-retry-parent-http.md`](docs/research/analysis-run-retry-parent-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_idempotency_lookup_http.rs b/crates/tepp_api/src/analysis_run_idempotency_lookup_http.rs index 6ee79d802..bedcfe3c6 100644 --- a/crates/tepp_api/src/analysis_run_idempotency_lookup_http.rs +++ b/crates/tepp_api/src/analysis_run_idempotency_lookup_http.rs @@ -443,6 +443,10 @@ mod tests { analysis_run_idempotency_lookup_path_key("/v1/analysis-runs/tepp-run-1/request"), Err(ApiError::InvalidWirePayload) ); + assert_eq!( + analysis_run_idempotency_lookup_path_key("/v1/analysis-runs/tepp-run-1/parent"), + Err(ApiError::InvalidWirePayload) + ); assert_eq!( analysis_run_idempotency_lookup_path_key("/v1/analysis-runs/tepp-run-1/running"), Err(ApiError::InvalidWirePayload) diff --git a/crates/tepp_api/src/analysis_run_live.rs b/crates/tepp_api/src/analysis_run_live.rs index b7fc005c7..ea612889e 100644 --- a/crates/tepp_api/src/analysis_run_live.rs +++ b/crates/tepp_api/src/analysis_run_live.rs @@ -14,7 +14,9 @@ //! retry. `GET /v1/analysis-runs/{run_id}/retries` returns metric-free direct //! retry children of a listed parent. `GET /v1/analysis-runs/by-idempotency/{key}` //! returns the metric-free identity of the unique run that used that key. -//! GET-by-id and running/terminal POST transitions remain later slices. +//! `GET /v1/analysis-runs/{run_id}/parent` returns the metric-free parent of a +//! listed run (`null` when the run was never retried). GET-by-id and +//! running/terminal POST transitions remain later slices. use std::collections::HashMap; use std::io::Write; @@ -39,6 +41,10 @@ use crate::analysis_run_retry_lineage_http::{ AnalysisRunRetryLineage, AnalysisRunRetryLineageItem, analysis_run_retry_lineage_path_run_id, refuse_metrics_on_retry_lineage_payload, }; +use crate::analysis_run_retry_parent_http::{ + AnalysisRunRetryParent, AnalysisRunRetryParentItem, analysis_run_retry_parent_path_run_id, + refuse_metrics_on_retry_parent_payload, +}; use crate::analysis_run_stored_request_http::{ AnalysisRunStoredRequest, analysis_run_stored_request_path_run_id, refuse_metrics_on_stored_request_payload, @@ -203,6 +209,12 @@ impl AnalysisRunLiveService { ) { return self.read_analysis_run_stored_request(path, &headers, body); } + if matches!( + analysis_run_retry_parent_path_run_id(path), + Ok(_) | Err(ApiError::LimitExceeded) + ) { + return self.read_analysis_run_retry_parent(path, &headers, body); + } if matches!( analysis_run_idempotency_lookup_path_key(path), Ok(_) | Err(ApiError::LimitExceeded) @@ -544,6 +556,67 @@ impl AnalysisRunLiveService { Ok(json_response(200, "OK", response_body)) } + fn read_analysis_run_retry_parent( + &self, + path: &str, + headers: &HashMap, + body: &str, + ) -> Result { + let run_id = analysis_run_retry_parent_path_run_id(path)?; + if !body.trim().is_empty() { + return Err(ApiError::InvalidWirePayload); + } + let consumer = require_headers(headers, self.bound_addr, false)?; + refuse_metrics_on_retry_parent_payload(body)?; + let replay_key = self + .runs_by_id + .get(&run_id) + .cloned() + .ok_or(ApiError::InvalidWirePayload)?; + let (child_id, child_state, child_key, child_consumer, parent_run_id) = { + let stored = self + .accepted_runs + .get(&replay_key) + .ok_or(ApiError::InvalidWirePayload)?; + ( + stored.accepted.run_id.clone(), + stored.run_state, + stored.accepted.idempotency_key.clone(), + stored.consumer.clone(), + stored.retried_from_run_id.clone(), + ) + }; + if child_consumer != consumer { + return Err(ApiError::InvalidWirePayload); + } + let parent_item = match parent_run_id { + None => None, + Some(parent_id) => { + let parent_replay_key = self + .runs_by_id + .get(&parent_id) + .cloned() + .ok_or(ApiError::InvalidWirePayload)?; + let parent = self + .accepted_runs + .get(&parent_replay_key) + .ok_or(ApiError::InvalidWirePayload)?; + if parent.consumer != consumer { + return Err(ApiError::InvalidWirePayload); + } + Some(AnalysisRunRetryParentItem::new( + parent.accepted.run_id.clone(), + parent.run_state, + parent.accepted.idempotency_key.clone(), + )?) + } + }; + let payload = AnalysisRunRetryParent::new(child_id, child_state, child_key, parent_item)?; + let response_body = payload.to_json()?; + refuse_metrics_on_retry_parent_payload(&response_body)?; + Ok(json_response(200, "OK", response_body)) + } + fn list_analysis_runs( &self, path: &str, @@ -606,9 +679,9 @@ impl AnalysisRunLiveService { /// Test-only seam that records a non-accepted loopback state. /// /// Used to prove cancel, collection, retry, stored-request inspect, - /// retry-lineage inspect, and idempotency-key lookup of running, - /// succeeded, failed, and cancelled runs without duplicating the live - /// POST running/terminal lifecycle slice. + /// retry-lineage inspect, idempotency-key lookup, and retry-parent + /// inspect of running, succeeded, failed, and cancelled runs without + /// duplicating the live POST running/terminal lifecycle slice. #[cfg(test)] fn force_loopback_run_state( &mut self, @@ -2293,6 +2366,164 @@ mod tests { assert_eq!(lineage.status_code, 200); } + fn retry_parent_http(run_id: &str, consumer: &str, extra: &[(&str, &str)]) -> String { + let mut request = format!("GET {NARUON_ANALYSIS_RUN_PATH}/{run_id}/parent 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\n" + ) + .expect("retry-parent headers"); + for (name, value) in extra { + write!(request, "{name}: {value}\r\n").expect("extra header"); + } + request.push_str("content-length: 0\r\n\r\n"); + request + } + + #[test] + #[allow(clippy::too_many_lines)] + fn handler_covers_metric_free_retry_parent_get() { + use crate::{AnalysisRunRetryParent, AnalysisRunRetryRequest, 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 parent_id = serde_json::from_str::(&accepted.body) + .expect("accepted json")["run_id"] + .as_str() + .expect("run_id") + .to_owned(); + + let original = + service.handle_http_request(&retry_parent_http(&parent_id, NARUON_CONSUMER_CODE, &[])); + assert_eq!(original.status_code, 200); + let original_payload = + AnalysisRunRetryParent::from_json(&original.body).expect("original parent"); + assert_eq!(original_payload.run_id, parent_id); + assert_eq!(original_payload.run_state, AnalysisRunStatusState::Accepted); + assert_eq!(original_payload.idempotency_key, run.idempotency_key); + assert_eq!(original_payload.parent, None); + assert!(original.body.contains("\"parent\":null")); + assert!(!original.body.contains("rmse")); + assert!(!original.body.contains("scientific_acceptance")); + assert!(!original.body.contains("snapshot_id")); + assert!(!original.body.contains("tenant_workspace_id")); + assert!(!original.body.contains("retried_from")); + + service + .force_loopback_run_state(&parent_id, AnalysisRunStatusState::Failed) + .expect("force failed"); + let retry_key = "analysis-live-retry-parent-retry-001"; + let retry_body = AnalysisRunRetryRequest::new(&parent_id, retry_key) + .expect("retry dto") + .to_json() + .expect("retry json"); + let retried = service.handle_http_request(&retry_http( + &parent_id, + &retry_body, + NARUON_CONSUMER_CODE, + retry_key, + )); + assert_eq!(retried.status_code, 202); + let child_id = serde_json::from_str::(&retried.body) + .expect("child json")["run_id"] + .as_str() + .expect("child id") + .to_owned(); + + let child = + service.handle_http_request(&retry_parent_http(&child_id, NARUON_CONSUMER_CODE, &[])); + assert_eq!(child.status_code, 200); + let child_payload = AnalysisRunRetryParent::from_json(&child.body).expect("child parent"); + assert_eq!(child_payload.run_id, child_id); + assert_eq!(child_payload.run_state, AnalysisRunStatusState::Accepted); + assert_eq!(child_payload.idempotency_key, retry_key); + let parent_item = child_payload.parent.expect("parent present"); + assert_eq!(parent_item.run_id, parent_id); + assert_eq!(parent_item.run_state, AnalysisRunStatusState::Failed); + assert_eq!(parent_item.idempotency_key, run.idempotency_key); + assert!(!child.body.contains("retried_from")); + assert!(!child.body.contains("snapshot_id")); + + let still_original = + service.handle_http_request(&retry_parent_http(&parent_id, NARUON_CONSUMER_CODE, &[])); + assert_eq!(still_original.status_code, 200); + assert_eq!( + AnalysisRunRetryParent::from_json(&still_original.body) + .expect("parent still original") + .parent, + None + ); + + assert_eq!( + service + .handle_http_request(&retry_parent_http( + &child_id, + LINEAGEWEAVE_CONSUMER_CODE, + &[], + )) + .status_code, + 400 + ); + assert_eq!( + service + .handle_http_request(&retry_parent_http("missing-run", NARUON_CONSUMER_CODE, &[],)) + .status_code, + 400 + ); + assert_eq!( + service + .handle_http_request(&format!( + "GET {NARUON_ANALYSIS_RUN_PATH}/{parent_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\ncontent-length: 0\r\n\r\n" + )) + .status_code, + 400 + ); + assert_eq!( + service + .handle_http_request(&format!( + "POST {NARUON_ANALYSIS_RUN_PATH}/{parent_id}/parent HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: naruon\r\ntepp-contract-version: 1\r\ncontent-length: 0\r\n\r\n" + )) + .status_code, + 400 + ); + assert_eq!( + service + .handle_http_request(&format!( + "GET {NARUON_ANALYSIS_RUN_PATH}/{parent_id}/parent HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: naruon\r\ntepp-contract-version: 1\r\ncontent-length: 2\r\n\r\n{{}}" + )) + .status_code, + 400 + ); + let oversized = "a".repeat(129); + assert_eq!( + service + .handle_http_request(&retry_parent_http(&oversized, NARUON_CONSUMER_CODE, &[])) + .status_code, + 413 + ); + let listed = service.handle_http_request(&collection_http(NARUON_CONSUMER_CODE, &[])); + assert_eq!(listed.status_code, 200); + assert!(!listed.body.contains("retried_from")); + let stored = service.handle_http_request(&stored_request_http( + &parent_id, + NARUON_CONSUMER_CODE, + &[], + )); + assert_eq!(stored.status_code, 200); + let lineage = + service.handle_http_request(&retry_lineage_http(&parent_id, NARUON_CONSUMER_CODE, &[])); + assert_eq!(lineage.status_code, 200); + let lookup = service.handle_http_request(&idempotency_lookup_http( + retry_key, + NARUON_CONSUMER_CODE, + &[], + )); + assert_eq!(lookup.status_code, 200); + } + #[test] fn temporal_read_headers_and_defensive_write_edges_are_covered() { let run = sample_run(); diff --git a/crates/tepp_api/src/analysis_run_retry_lineage_http.rs b/crates/tepp_api/src/analysis_run_retry_lineage_http.rs index eee2c1da0..56813cdca 100644 --- a/crates/tepp_api/src/analysis_run_retry_lineage_http.rs +++ b/crates/tepp_api/src/analysis_run_retry_lineage_http.rs @@ -517,6 +517,10 @@ mod tests { analysis_run_retry_lineage_path_run_id("/v1/analysis-runs/tepp-run-1/request"), Err(ApiError::InvalidWirePayload) ); + assert_eq!( + analysis_run_retry_lineage_path_run_id("/v1/analysis-runs/tepp-run-1/parent"), + Err(ApiError::InvalidWirePayload) + ); assert_eq!( analysis_run_retry_lineage_path_run_id("/v1/analysis-runs/by-idempotency/idem-1"), Err(ApiError::InvalidWirePayload) diff --git a/crates/tepp_api/src/analysis_run_retry_parent_http.rs b/crates/tepp_api/src/analysis_run_retry_parent_http.rs new file mode 100644 index 000000000..5b355d370 --- /dev/null +++ b/crates/tepp_api/src/analysis_run_retry_parent_http.rs @@ -0,0 +1,599 @@ +//! Provider-owned analysis-run retry-parent GET contracts. +//! +//! GAP-003A twelfth slice: `GET /v1/analysis-runs/{run_id}/parent` returns the +//! metric-free parent identity of a listed run. Retry-lineage GET lists +//! children of a parent. Collection GET lists parent and child independently +//! without `retried_from`. Idempotency-key lookup resolves a key to a +//! `run_id` without linkage. Operators looking at a retry child therefore +//! cannot see which parent it came from. This module does not serve GET-by-id +//! (#359), lifecycle POST (#360), cancel HTTP (#361), loopback CLI (#362), +//! collection GET (#368), retry POST (#369), stored-request GET (#377), +//! retry-lineage GET (#379), or idempotency-key lookup GET (#380). +//! Persistence remains GAP-003B. + +use crate::naruon_http::{NaruonHttpExchange, compose_https_target}; +use crate::wire::{ + from_json, require_byte_limit, require_contract_version, require_nonempty, to_json, +}; +use crate::{ + ANALYSIS_RUN_STATUS_PATH, AnalysisRunStatusState, ApiError, DEFAULT_ANALYSIS_RUN_BYTE_LIMIT, +}; +use serde::{Deserialize, Serialize}; + +/// Maximum length accepted for an opaque run identity in the retry-parent path. +pub const ANALYSIS_RUN_RETRY_PARENT_ID_MAX_LEN: usize = 128; + +/// Supported analysis-run retry-parent contract version. +pub const ANALYSIS_RUN_RETRY_PARENT_CONTRACT_VERSION: u16 = 1; + +const FORBIDDEN_RETRY_PARENT_KEYS: [&str; 14] = [ + "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", + "terminal_result", + "tenant_workspace_id", +]; + +/// One metric-free parent identity of a retry child. +/// +/// The row names the original attempt. It never carries a terminal result, +/// snapshot, or scientific-acceptance artifact. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct AnalysisRunRetryParentItem { + /// Opaque server-assigned parent run identity. + pub run_id: String, + /// Current lifecycle state of the parent. + pub run_state: AnalysisRunStatusState, + /// Exact request idempotency key of the parent. + pub idempotency_key: String, +} + +impl AnalysisRunRetryParentItem { + /// Construct a validated metric-free retry-parent identity row. + /// + /// # Errors + /// + /// Returns a fail-closed error for empty identities or an oversized run + /// identity. + pub fn new( + run_id: impl Into, + run_state: AnalysisRunStatusState, + idempotency_key: impl Into, + ) -> Result { + let item = Self { + run_id: run_id.into(), + run_state, + idempotency_key: idempotency_key.into(), + }; + item.validate()?; + Ok(item) + } + + fn validate(&self) -> Result<(), ApiError> { + require_nonempty(&self.run_id)?; + require_nonempty(&self.idempotency_key)?; + if self.run_id.len() > ANALYSIS_RUN_RETRY_PARENT_ID_MAX_LEN { + return Err(ApiError::LimitExceeded); + } + Ok(()) + } +} + +/// Metric-free retry parent for one analysis run. +/// +/// Operators inspect which listed run a retry child was cloned from. +/// `parent` is JSON `null` when the run was never retried from another run. +/// The payload never carries a terminal result or scientific-acceptance +/// artifact. The `parent` key is always present. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct AnalysisRunRetryParent { + /// Semantic contract version for this payload family. + pub contract_version: u16, + /// Opaque server-assigned child (or original) run identity. + pub run_id: String, + /// Current lifecycle state of the inspected run. + pub run_state: AnalysisRunStatusState, + /// Exact request idempotency key of the inspected run. + pub idempotency_key: String, + /// Direct parent identity, or `null` when this run was never retried. + pub parent: Option, +} + +impl AnalysisRunRetryParent { + /// Construct a validated metric-free retry-parent payload. + /// + /// # Errors + /// + /// Returns a fail-closed error for empty identities, an oversized run + /// identity, or an unsupported contract version. + pub fn new( + run_id: impl Into, + run_state: AnalysisRunStatusState, + idempotency_key: impl Into, + parent: Option, + ) -> Result { + let payload = Self { + contract_version: ANALYSIS_RUN_RETRY_PARENT_CONTRACT_VERSION, + run_id: run_id.into(), + run_state, + idempotency_key: idempotency_key.into(), + parent, + }; + payload.validate()?; + Ok(payload) + } + + /// Parse and validate a retry-parent payload 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 retry-parent payload 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_retry_parent_payload(payload)?; + let decoded: Self = from_json(payload)?; + decoded.validate()?; + Ok(decoded) + } + + /// Serialize this retry-parent payload 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_retry_parent_payload(&payload)?; + Ok(payload) + } + + fn validate(&self) -> Result<(), ApiError> { + require_contract_version( + self.contract_version, + ANALYSIS_RUN_RETRY_PARENT_CONTRACT_VERSION, + )?; + require_nonempty(&self.run_id)?; + require_nonempty(&self.idempotency_key)?; + if self.run_id.len() > ANALYSIS_RUN_RETRY_PARENT_ID_MAX_LEN { + return Err(ApiError::LimitExceeded); + } + if let Some(parent) = &self.parent { + parent.validate()?; + } + Ok(()) + } +} + +/// Refuse retry-parent JSON that already carries scientific-metric keys. +/// +/// Empty payloads are admitted for the GET request body. 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_retry_parent_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_RETRY_PARENT_KEYS + .iter() + .any(|key| object.contains_key(*key)) + { + return Err(ApiError::InvalidWirePayload); + } + Ok(()) +} + +/// Extract the opaque run identity from `GET /v1/analysis-runs/{run_id}/parent`. +/// +/// # Errors +/// +/// Returns [`ApiError::InvalidWirePayload`] for a collection path, extra +/// segments, a missing `/parent` suffix, cancel/retry/retries/request/ +/// running/terminal suffixes, or a hostile encoding, and +/// [`ApiError::LimitExceeded`] when the decoded identity exceeds +/// [`ANALYSIS_RUN_RETRY_PARENT_ID_MAX_LEN`]. +pub(crate) fn analysis_run_retry_parent_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("/parent") + .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_RETRY_PARENT_ID_MAX_LEN { + return Err(ApiError::LimitExceeded); + } + Ok(run_id) +} + +/// Build a provider-owned `GET` analysis-run retry-parent exchange. +/// +/// The builder refuses non-`https` origins and empty or oversized run +/// identifiers. It does not inject credentials. The GET body is empty. +/// +/// # Errors +/// +/// Returns [`ApiError::InvalidWirePayload`] for a non-`https` origin or empty +/// identity, and [`ApiError::LimitExceeded`] when the run identity exceeds +/// [`ANALYSIS_RUN_RETRY_PARENT_ID_MAX_LEN`] bytes. +pub fn naruon_analysis_run_retry_parent_exchange( + origin: &str, + run_id: &str, +) -> Result { + require_nonempty(run_id)?; + if run_id.len() > ANALYSIS_RUN_RETRY_PARENT_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}/parent"); + let target_url = compose_https_target(origin, &target_path)?; + Ok(NaruonHttpExchange { + method: "GET", + target_url, + headers: vec![ + ("content-type".into(), "application/json".into()), + ("tepp-consumer".into(), "naruon".into()), + ("tepp-contract-version".into(), "1".into()), + ], + body: String::new(), + }) +} + +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::*; + + fn sample_parent() -> AnalysisRunRetryParent { + AnalysisRunRetryParent::new( + "tepp-run-2", + AnalysisRunStatusState::Accepted, + "idem-retry-1", + Some( + AnalysisRunRetryParentItem::new( + "tepp-run-1", + AnalysisRunStatusState::Failed, + "idem-1", + ) + .expect("parent"), + ), + ) + .expect("payload") + } + + #[test] + fn retry_parent_round_trips_and_refuses_hostile_shapes() { + let payload = sample_parent(); + let json = payload.to_json().expect("json"); + assert_eq!( + AnalysisRunRetryParent::from_json(&json).expect("decode"), + payload + ); + assert!(json.contains("\"parent\":{") || json.contains("\"parent\": {")); + assert!(!json.contains("rmse")); + assert!(!json.contains("scientific_acceptance")); + assert!(!json.contains("terminal_result")); + assert!(!json.contains("tenant_workspace_id")); + assert!(!json.contains("snapshot_id")); + assert!(!json.contains("retried_from")); + + let original = AnalysisRunRetryParent::new( + "tepp-run-1", + AnalysisRunStatusState::Failed, + "idem-1", + None, + ) + .expect("original"); + let original_json = original.to_json().expect("original json"); + assert!(original_json.contains("\"parent\":null")); + assert_eq!( + AnalysisRunRetryParent::from_json(&original_json).expect("null parent"), + original + ); + + assert_eq!( + AnalysisRunRetryParent::new("", AnalysisRunStatusState::Failed, "idem-1", None,), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + AnalysisRunRetryParent::new("tepp-run-1", AnalysisRunStatusState::Failed, "", None,), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + AnalysisRunRetryParent::new( + "a".repeat(ANALYSIS_RUN_RETRY_PARENT_ID_MAX_LEN + 1), + AnalysisRunStatusState::Failed, + "idem-1", + None, + ), + Err(ApiError::LimitExceeded) + ); + assert_eq!( + AnalysisRunRetryParentItem::new( + "a".repeat(ANALYSIS_RUN_RETRY_PARENT_ID_MAX_LEN + 1), + AnalysisRunStatusState::Failed, + "idem-1", + ), + Err(ApiError::LimitExceeded) + ); + + let mut unsupported = payload.clone(); + unsupported.contract_version = 9; + assert_eq!( + unsupported.to_json(), + Err(ApiError::UnsupportedContractVersion) + ); + assert_eq!( + AnalysisRunRetryParent::from_json( + r#"{"contract_version":9,"run_id":"tepp-run-1","run_state":"failed","idempotency_key":"idem-1","parent":null}"# + ), + Err(ApiError::UnsupportedContractVersion) + ); + assert_eq!( + AnalysisRunRetryParent::from_json( + r#"{"contract_version":1,"run_id":"tepp-run-1","run_state":"failed","idempotency_key":"idem-1","parent":null,"extra":true}"# + ), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + AnalysisRunRetryParent::from_json_with_limit(&json, 8), + Err(ApiError::LimitExceeded) + ); + assert_eq!( + AnalysisRunRetryParent::from_json("[1,2,3]"), + Err(ApiError::InvalidWirePayload) + ); + } + + #[test] + fn retry_parent_payloads_refuse_scientific_metric_keys() { + assert_eq!(refuse_metrics_on_retry_parent_payload(""), Ok(())); + assert_eq!(refuse_metrics_on_retry_parent_payload(" "), Ok(())); + assert_eq!( + refuse_metrics_on_retry_parent_payload(r#"{"run_id":"r"}"#), + Ok(()) + ); + for key in FORBIDDEN_RETRY_PARENT_KEYS { + let payload = format!(r#"{{"{key}":1,"run_id":"r"}}"#); + assert_eq!( + refuse_metrics_on_retry_parent_payload(&payload), + Err(ApiError::InvalidWirePayload), + "key={key}" + ); + } + assert_eq!( + refuse_metrics_on_retry_parent_payload("[true]"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + refuse_metrics_on_retry_parent_payload("null"), + Err(ApiError::InvalidWirePayload) + ); + } + + #[test] + fn retry_parent_path_decodes_identities_and_refuses_hostile_segments() { + assert_eq!( + analysis_run_retry_parent_path_run_id("/v1/analysis-runs/tepp-run-1/parent") + .expect("plain"), + "tepp-run-1" + ); + assert_eq!( + analysis_run_retry_parent_path_run_id("/v1/analysis-runs/run%2dabc/parent") + .expect("lower"), + "run-abc" + ); + assert_eq!( + analysis_run_retry_parent_path_run_id("/v1/analysis-runs/run%2Dabc/parent") + .expect("upper"), + "run-abc" + ); + assert_eq!( + analysis_run_retry_parent_path_run_id("/v1/analysis-runs"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + analysis_run_retry_parent_path_run_id("/v1/analysis-runs/tepp-run-1"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + analysis_run_retry_parent_path_run_id("/v1/analysis-runs/tepp-run-1/cancel"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + analysis_run_retry_parent_path_run_id("/v1/analysis-runs/tepp-run-1/retry"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + analysis_run_retry_parent_path_run_id("/v1/analysis-runs/tepp-run-1/retries"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + analysis_run_retry_parent_path_run_id("/v1/analysis-runs/tepp-run-1/request"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + analysis_run_retry_parent_path_run_id("/v1/analysis-runs/by-idempotency/idem-1"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + analysis_run_retry_parent_path_run_id("/v1/analysis-runs/tepp-run-1/running"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + analysis_run_retry_parent_path_run_id("/v1/analysis-runs/tepp-run-1/terminal"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + analysis_run_retry_parent_path_run_id("/v1/other/tepp-run-1/parent"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + analysis_run_retry_parent_path_run_id("/v1/analysis-runs//parent"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + analysis_run_retry_parent_path_run_id("/v1/analysis-runs/a/b/parent"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + analysis_run_retry_parent_path_run_id("/v1/analysis-runs/%2F/parent"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + analysis_run_retry_parent_path_run_id("/v1/analysis-runs/%00/parent"), + Err(ApiError::InvalidWirePayload) + ); + let oversized = format!( + "/v1/analysis-runs/{}/parent", + "a".repeat(ANALYSIS_RUN_RETRY_PARENT_ID_MAX_LEN + 1) + ); + assert_eq!( + analysis_run_retry_parent_path_run_id(&oversized), + Err(ApiError::LimitExceeded) + ); + assert_eq!(decode_path_segment(""), 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 retry_parent_exchange_gets_https_path_without_credentials() { + let exchange = + naruon_analysis_run_retry_parent_exchange("https://tepp.example.com", "tepp-run-1") + .expect("exchange"); + assert_eq!(exchange.method, "GET"); + assert_eq!( + exchange.target_url, + "https://tepp.example.com/v1/analysis-runs/tepp-run-1/parent" + ); + assert!(exchange.body.is_empty()); + assert!( + exchange + .headers + .iter() + .any(|(name, value)| name == "tepp-consumer" && value == "naruon") + ); + assert!( + !exchange + .headers + .iter() + .any(|(name, _)| name.contains("authorization") + || name.contains("copilot") + || name.contains("idempotency")) + ); + + let encoded = + naruon_analysis_run_retry_parent_exchange("https://tepp.example.com", "run/../../etc") + .expect("encoded"); + assert!(encoded.target_url.contains("run%2F..%2F..%2Fetc/parent")); + + assert_eq!( + naruon_analysis_run_retry_parent_exchange("http://tepp.example.com", "tepp-run-1"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + naruon_analysis_run_retry_parent_exchange("https://tepp.example.com", ""), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + naruon_analysis_run_retry_parent_exchange( + "https://tepp.example.com", + &"a".repeat(ANALYSIS_RUN_RETRY_PARENT_ID_MAX_LEN + 1) + ), + Err(ApiError::LimitExceeded) + ); + } +} diff --git a/crates/tepp_api/src/analysis_run_stored_request_http.rs b/crates/tepp_api/src/analysis_run_stored_request_http.rs index 998b57307..3843f78d9 100644 --- a/crates/tepp_api/src/analysis_run_stored_request_http.rs +++ b/crates/tepp_api/src/analysis_run_stored_request_http.rs @@ -450,6 +450,10 @@ mod tests { analysis_run_stored_request_path_run_id("/v1/analysis-runs/tepp-run-1/retries"), Err(ApiError::InvalidWirePayload) ); + assert_eq!( + analysis_run_stored_request_path_run_id("/v1/analysis-runs/tepp-run-1/parent"), + Err(ApiError::InvalidWirePayload) + ); assert_eq!( analysis_run_stored_request_path_run_id("/v1/analysis-runs/by-idempotency/idem-1"), Err(ApiError::InvalidWirePayload) diff --git a/crates/tepp_api/src/lib.rs b/crates/tepp_api/src/lib.rs index 5a61ab94a..aa0d40d99 100644 --- a/crates/tepp_api/src/lib.rs +++ b/crates/tepp_api/src/lib.rs @@ -19,6 +19,7 @@ mod analysis_run_idempotency_lookup_http; mod analysis_run_live; mod analysis_run_retry_http; mod analysis_run_retry_lineage_http; +mod analysis_run_retry_parent_http; mod analysis_run_status_http; mod analysis_run_stored_request_http; mod authorization; @@ -145,6 +146,18 @@ pub use analysis_run_retry_lineage_http::AnalysisRunRetryLineageItem; pub use analysis_run_retry_lineage_http::naruon_analysis_run_retry_lineage_exchange; /// Refuse scientific-metric keys on a retry-lineage payload. pub use analysis_run_retry_lineage_http::refuse_metrics_on_retry_lineage_payload; +/// Analysis-run retry-parent contract version constant. +pub use analysis_run_retry_parent_http::ANALYSIS_RUN_RETRY_PARENT_CONTRACT_VERSION; +/// Maximum opaque run identity length on the retry-parent path. +pub use analysis_run_retry_parent_http::ANALYSIS_RUN_RETRY_PARENT_ID_MAX_LEN; +/// Versioned metric-free retry parent of one analysis run. +pub use analysis_run_retry_parent_http::AnalysisRunRetryParent; +/// One metric-free retry-parent identity row. +pub use analysis_run_retry_parent_http::AnalysisRunRetryParentItem; +/// Build a Naruon analysis-run retry-parent GET exchange. +pub use analysis_run_retry_parent_http::naruon_analysis_run_retry_parent_exchange; +/// Refuse scientific-metric keys on a retry-parent payload. +pub use analysis_run_retry_parent_http::refuse_metrics_on_retry_parent_payload; /// Analysis-run status HTTP exchange re-exports. pub use analysis_run_status_http::{ANALYSIS_RUN_ID_MAX_LEN, naruon_analysis_run_status_exchange}; /// Analysis-run stored-request contract version constant. diff --git a/crates/tepp_api/tests/analysis_run_retry_parent_http_contract.rs b/crates/tepp_api/tests/analysis_run_retry_parent_http_contract.rs new file mode 100644 index 000000000..463428588 --- /dev/null +++ b/crates/tepp_api/tests/analysis_run_retry_parent_http_contract.rs @@ -0,0 +1,94 @@ +//! Contract tests for the analysis-run retry-parent GET exchange. + +use tepp_api::{ + ANALYSIS_RUN_RETRY_PARENT_CONTRACT_VERSION, ANALYSIS_RUN_RETRY_PARENT_ID_MAX_LEN, + AnalysisRunRetryParent, AnalysisRunRetryParentItem, AnalysisRunStatusState, ApiError, + naruon_analysis_run_retry_parent_exchange, refuse_metrics_on_retry_parent_payload, +}; + +#[test] +fn retry_parent_exchange_is_https_get_without_credentials_or_metrics() { + let exchange = + naruon_analysis_run_retry_parent_exchange("https://tepp.example.test", "tepp-run-9") + .expect("exchange"); + assert_eq!(exchange.method, "GET"); + assert_eq!( + exchange.target_url, + "https://tepp.example.test/v1/analysis-runs/tepp-run-9/parent" + ); + assert!(exchange.body.is_empty()); + assert!( + exchange + .headers + .iter() + .any(|(name, value)| name == "tepp-consumer" && value == "naruon") + ); + assert!( + !exchange + .headers + .iter() + .any(|(name, _)| name.contains("authorization") + || name.contains("token") + || name.contains("copilot") + || name.contains("idempotency")) + ); + let payload = AnalysisRunRetryParent::new( + "tepp-run-10", + AnalysisRunStatusState::Accepted, + "idem-retry-9", + Some( + AnalysisRunRetryParentItem::new("tepp-run-9", AnalysisRunStatusState::Failed, "idem-9") + .expect("parent"), + ), + ) + .expect("payload"); + assert_eq!( + payload.contract_version, + ANALYSIS_RUN_RETRY_PARENT_CONTRACT_VERSION + ); + let json = payload.to_json().expect("json"); + assert_eq!(refuse_metrics_on_retry_parent_payload(&json), Ok(())); + assert!(!json.contains("scientific_acceptance")); + assert!(!json.contains("tenant_workspace_id")); + assert!(!json.contains("snapshot_id")); + assert!(!json.contains("retried_from")); + let original = + AnalysisRunRetryParent::new("tepp-run-9", AnalysisRunStatusState::Failed, "idem-9", None) + .expect("original"); + assert!( + original + .to_json() + .expect("null") + .contains("\"parent\":null") + ); +} + +#[test] +fn retry_parent_contract_refuses_table_access_and_metric_keys() { + for origin in [ + "http://tepp.example.test", + "https://db.postgres.example", + "https://jdbc.example", + ] { + assert_eq!( + naruon_analysis_run_retry_parent_exchange(origin, "tepp-run-9"), + Err(ApiError::InvalidWirePayload), + "origin={origin}" + ); + } + assert_eq!( + naruon_analysis_run_retry_parent_exchange( + "https://tepp.example.test", + &"a".repeat(ANALYSIS_RUN_RETRY_PARENT_ID_MAX_LEN + 1) + ), + Err(ApiError::LimitExceeded) + ); + assert_eq!( + refuse_metrics_on_retry_parent_payload(r#"{"rmse":1.0}"#), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + refuse_metrics_on_retry_parent_payload(r#"{"scientific_acceptance":{}}"#), + Err(ApiError::InvalidWirePayload) + ); +} diff --git a/docs/API_CONTRACT.md b/docs/API_CONTRACT.md index dc8620c74..4aef8e141 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, `POST /v1/analysis-runs/{run_id}/cancel` for metric-free cancellation of accepted or running runs, `GET /v1/analysis-runs` for metric-free enumeration of accepted, running, cancelled, and terminal runs, and `POST /v1/analysis-runs/{run_id}/retry` for cloning a failed or cancelled run into a new metric-free `202 Accepted`, and `GET /v1/analysis-runs/{run_id}/request` for metric-free inspect of stored create fields, and `GET /v1/analysis-runs/{run_id}/retries` for metric-free inspect of direct retry children, and `GET /v1/analysis-runs/by-idempotency/{idempotency_key}` for metric-free resolve of a 202 receipt or retry child key to a durable run identity. `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, `POST /v1/analysis-runs/{run_id}/cancel` for metric-free cancellation of accepted or running runs, `GET /v1/analysis-runs` for metric-free enumeration of accepted, running, cancelled, and terminal runs, and `POST /v1/analysis-runs/{run_id}/retry` for cloning a failed or cancelled run into a new metric-free `202 Accepted`, and `GET /v1/analysis-runs/{run_id}/request` for metric-free inspect of stored create fields, and `GET /v1/analysis-runs/{run_id}/retries` for metric-free inspect of direct retry children, and `GET /v1/analysis-runs/by-idempotency/{idempotency_key}` for metric-free resolve of a 202 receipt or retry child key to a durable run identity, and `GET /v1/analysis-runs/{run_id}/parent` for metric-free inspect of a retry child's parent (`null` when the run was never retried). `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 @@ -71,6 +71,7 @@ POST /v1/analysis-runs/{run_id}/cancel POST /v1/analysis-runs/{run_id}/retry GET /v1/analysis-runs/{run_id}/request GET /v1/analysis-runs/{run_id}/retries +GET /v1/analysis-runs/{run_id}/parent GET /v1/analysis-runs/by-idempotency/{idempotency_key} GET /v1/model-artifacts/{artifact_id} GET /v1/exports/{export_id} @@ -100,8 +101,11 @@ can inspect lineage after retry. An empty `retries` array is `200` when the parent was never retried. `GET /v1/analysis-runs/by-idempotency/{idempotency_key}` on the loopback listener returns the metric-free identity of the unique run that used that key so operators can jump from a 202 receipt or retry child -key without scanning collection pages. GET-by-id remains a later slice on this -protected-main lineage. +key without scanning collection pages. `GET /v1/analysis-runs/{run_id}/parent` +on the loopback listener returns the metric-free parent of that run so +operators can inspect which listed run a retry child was cloned from. Original +(never-retried) runs return `"parent": null`. GET-by-id 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 ec5ddfc26..51bdad9a2 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -59,6 +59,7 @@ The full APA 7th standards/literature register remains `docs/research/standards- | loopback analysis-run stored-request GET | ADR 0034; API contract; RFC 9110 | `tepp_api` `GET /v1/analysis-runs/{run_id}/request` on `AnalysisRunLiveService`: metric-free inspect of snapshot/cutoff/model/profile; collection GET lists identity only; GET-by-id remains a later slice | active-PR | | loopback analysis-run retry-lineage GET | ADR 0035; API contract; RFC 9110 | `tepp_api` `GET /v1/analysis-runs/{run_id}/retries` on `AnalysisRunLiveService`: metric-free direct retry children of a listed parent; empty `retries` when never retried; GET-by-id remains a later slice | active-PR | | loopback analysis-run idempotency-key lookup GET | ADR 0037; API contract; RFC 9110 | `tepp_api` `GET /v1/analysis-runs/by-idempotency/{idempotency_key}` on `AnalysisRunLiveService`: metric-free resolve of a 202 receipt or retry child key to a durable `run_id`; GET-by-id remains a later slice | active-PR | +| loopback analysis-run retry-parent GET | ADR 0038; API contract; RFC 9110 | `tepp_api` `GET /v1/analysis-runs/{run_id}/parent` on `AnalysisRunLiveService`: metric-free parent of a listed run; `"parent": null` when never retried; GET-by-id 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/0038-analysis-run-retry-parent-get.md b/docs/adr/0038-analysis-run-retry-parent-get.md new file mode 100644 index 000000000..1c4baf288 --- /dev/null +++ b/docs/adr/0038-analysis-run-retry-parent-get.md @@ -0,0 +1,85 @@ +# ADR 0038 — Analysis-run retry-parent GET path + +**Decision status:** Accepted +**Implementation maturity:** active-PR +**Date:** 2026-08-31 +**Supersedes:** None; complements ADR 0018, ADR 0031, ADR 0032, ADR 0034, ADR 0035, and ADR 0037 for the operator-visible jump from a retry child to its parent. Does not supersede ADR 0014 claim-promotion authority. ADR 0026–0037 remain on live GAP-003A engine-library, terminal-wire DTO, GET-by-id, lifecycle-POST, cancel, loopback-CLI, collection-GET, retry, collection-CLI, engine-execute, loopback-binary, CWC, cancel-consumer-parity, Rubin, stored-request, retry-lineage, ESEM/DSEM, cancel-CLI, execute-exchange, execute-TCP, and idempotency-lookup slices. + +## Context + +Retry-lineage GET lists direct children of a parent. Collection GET lists parent and child as independent rows and does not leak `retried_from`. Stored-request GET returns snapshot/cutoff/model/profile of one run. Idempotency-key lookup resolves a 202 receipt or retry child key to a `run_id` without linkage. Operators who land on a retry child (from collection or lookup) therefore cannot see which parent it was cloned from. Returning RMSE, bias, coverage, SE-gate, or `tepp.scientific_acceptance.v1` on the parent body would treat parent inspect as measurement evidence. GET-by-id (#359) is status/terminal by `run_id` on another stack and remains 400 here. + +## Decision + +`AnalysisRunLiveService` serves `GET /v1/analysis-runs/{run_id}/parent` on loopback: + +- The payload is metric-free: child `run_id`, `run_state`, `idempotency_key`, and a `parent` object (`run_id`, `run_state`, `idempotency_key`) or JSON `null`. +- The `parent` key is always present. Original (never-retried) runs return `"parent": null`. +- `tepp.scientific_acceptance.v1`, RMSE, bias, coverage, SE-gate, report, `terminal_result`, `tenant_workspace_id`, `snapshot_id`, and `retried_from` never appear. +- Empty GET bodies only. Query strings, GET-by-id, POST `/parent`, GET `/retries`, GET `/request`, GET `/by-idempotency/{key}`, and nonempty bodies fail closed. +- Consumer isolation: another consumer cannot read the first consumer's parent. Missing parent identity fails closed. +- Unknown identities fail closed. Persistence remains GAP-003B. + +## Non-goals + +- Production TLS, public bind, or durable request 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}`, POST running/terminal, POST cancel, GET collection, POST retry, GET stored-request, GET retry-lineage, GET idempotency-lookup, loopback CLI, or cancel CLI. +- LineageWeave/Naruon stored-request/retry-lineage/idempotency/parent consumer-parity (mirrors #373; remains a later unique slice). + +## Alternatives considered + +1. **Add `retried_from` to collection GET rows** — rejected because collection GET (#368) already owns identity-only enumeration and a parallel field would duplicate that head. +2. **Ask operators to scan retry-lineage of every listed run** — rejected because retry-lineage GET (#379) is parent→children and operators often hold only the child `run_id`. +3. **Return `tepp.scientific_acceptance.v1` on a succeeded parent** — rejected because parent bodies must stay metric-free. +4. **Metric-free retry-parent GET on loopback** — accepted. + +## Consequences + +- Operators can inspect the parent of a listed retry child after retry or idempotency-key lookup. +- Parent pages cannot be mistaken for a succeeded scientific-acceptance result. +- GET-by-id may later return a digest-bound artifact without changing these parent gates. + +## Failure and recovery + +Unknown identities, extra path segments, GET-by-id, query strings, nonempty bodies, metric keys, unpublished consumers, consumer mismatch, missing parent identity, and non-loopback hosts 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 and retry requests. Callers must not fabricate a succeeded scientific-acceptance artifact from a retry-parent payload. + +## Security, privacy, scientific-integrity, and governance impact + +- No credential headers cross the consumer boundary. +- Retry-parent GET remains loopback-only, size-bounded, consumer-scoped, and content-redacting. +- HTTP `200` on a retry-parent payload is not measurement evidence and is not release evidence. + +## Compatibility and migration + +Create POST, cancel POST, retry POST, collection GET, stored-request GET, retry-lineage GET, idempotency-key lookup GET, temporal-context, and project-history paths are unchanged. GET-by-id remains refused on this slice. Production adapters may replace loopback while preserving metric-free parent fields and the artifact refusal. + +## Verification + +Falsifiable evidence: + +- GET retry-parent JSON has no RMSE/bias/coverage/SE-gate/scientific-acceptance/`terminal_result`/`tenant_workspace_id`/`snapshot_id`/`retried_from` keys; +- GET of an original run returns `"parent": null`; +- GET of a retry child returns the parent identity; +- GET of the parent after retry still returns `"parent": null`; +- GET does not leak another consumer's parent; +- GET-by-id, query strings, nonempty bodies, POST `/parent`, and unknown identities fail closed; +- Clippy `-D warnings`, `tepp_api` tests, rustdoc, and exact-head review remain required. + +## Rollback and supersession + +Rollback removes retry-parent GET dispatch; POST create receipts, cancel, collection GET, retry, stored-request GET, retry-lineage GET, and idempotency-key lookup GET remain valid. A superseding ADR is required to persist the registry, bind a public address, emit scientific-acceptance on parent inspect, or treat HTTP success as an ADR 0014 claim. + +## Related authority + +- ADR 0018 owns consumer-scoped ingress and metric-free `202 Accepted`. +- ADR 0031 owns loopback collection GET. +- ADR 0032 owns loopback retry HTTP on this stack. +- ADR 0034 owns loopback stored-request GET on this stack. +- ADR 0035 owns loopback retry-lineage GET on this stack. +- ADR 0037 owns loopback idempotency-key lookup GET on this stack. +- ADR 0027 owns GET-by-id status (live on another PR). +- ADR 0014 owns scientific claim promotion. +- ADR 0011 owns standalone/modular HTTP boundaries. +- RFC 9110 owns GET semantics (Fielding, Nottingham, & Reschke, 2022). It does not authorize scientific claims. diff --git a/docs/adr/README.md b/docs/adr/README.md index a206d0df7..414e43f7e 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -36,6 +36,7 @@ Read [`ADR_POLICY.md`](ADR_POLICY.md) first. **Decision status and implementatio | [0034](0034-analysis-run-stored-request-get.md) | Loopback GET analysis-run stored-request is metric-free inspect | Accepted | active-PR | Complements ADR 0018/0031/0032; does not supersede ADR 0014. ADR 0026–0033 live on other GAP-003A PRs. | | [0035](0035-analysis-run-retry-lineage-get.md) | Loopback GET analysis-run retry-lineage is metric-free parent/child inspect | Accepted | active-PR | Complements ADR 0018/0031/0032/0034; does not supersede ADR 0014. ADR 0026–0034 live on other GAP-003A PRs. | | [0037](0037-analysis-run-idempotency-lookup-get.md) | Loopback GET analysis-run idempotency-key lookup is metric-free identity resolve | Accepted | active-PR | Complements ADR 0018/0031/0032/0034/0035; does not supersede ADR 0014. ADR 0026–0036 live on other GAP-003A PRs. | +| [0038](0038-analysis-run-retry-parent-get.md) | Loopback GET analysis-run retry-parent is metric-free child→parent inspect | Accepted | active-PR | Complements ADR 0018/0031/0032/0034/0035/0037; does not supersede ADR 0014. ADR 0026–0037 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. | @@ -152,6 +153,7 @@ Use the narrowest owning ADR when decisions overlap: - **analysis-run stored-request GET:** ADR 0034. - **analysis-run retry-lineage GET:** ADR 0035. - **analysis-run idempotency-key lookup GET:** ADR 0037. +- **analysis-run retry-parent GET:** ADR 0038. ## Change and supersession rule diff --git a/docs/research/analysis-run-retry-parent-http.md b/docs/research/analysis-run-retry-parent-http.md new file mode 100644 index 000000000..8cb549bd8 --- /dev/null +++ b/docs/research/analysis-run-retry-parent-http.md @@ -0,0 +1,61 @@ +# Analysis-run retry-parent HTTP (doctoring) + +## Scope + +`AnalysisRunLiveService` serves +`GET /v1/analysis-runs/{run_id}/parent` 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 0038), not an RFC inference rule. + +Parent responses are metric-free `AnalysisRunRetryParent` JSON. Each payload +carries the inspected run's `run_id`, `run_state`, and `idempotency_key`, +plus a `parent` object or JSON `null`. HTTP `200` is not a completed temporal +model, calibrated score, theta estimate, uncertainty statement, or scientific +claim. `tepp.scientific_acceptance.v1` never appears. + +## 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.1 describes GET as a method for retrieving the target resource's +current state. TEPP maps that retrieval onto a bounded, consumer-scoped +inspect of a retry child's parent identity. The RFC does not define +psychometric acceptance, RMSE, or claim promotion. + +### Internal contract evidence + +- `docs/adr/0038-analysis-run-retry-parent-get.md` — parent inspect + authority and metric-free identity fields +- `docs/adr/0037-analysis-run-idempotency-lookup-get.md` — key-to-identity + resolve without linkage +- `docs/adr/0035-analysis-run-retry-lineage-get.md` — parent→children inspect +- `docs/adr/0034-analysis-run-stored-request-get.md` — stored-request inspect +- `docs/adr/0032-analysis-run-retry-http.md` — retry clones without exposing + parent/child linkage +- `docs/adr/0031-analysis-run-collection-get.md` — collection is + identity-only and does not leak `retried_from` +- `docs/adr/0018-consumer-scoped-analysis-run-ingress.md` — closed consumer + registry and metric-free `202 Accepted` +- `docs/API_CONTRACT.md` — documented retry-parent resource +- `crates/tepp_api/tests/analysis_run_retry_parent_http_contract.rs` — + fail-closed parent exchange proofs + +## Operator-visible behaviour + +- loopback `GET /v1/analysis-runs/{run_id}/parent` of an original run + returns `"parent": null` +- the same path of a retry child returns the parent's metric-free identity +- GET of the parent after retry still returns `"parent": null` +- collection GET still lists identity rows and does not leak `retried_from` +- stored-request GET still inspects snapshot/cutoff/model/profile +- retry-lineage GET still lists direct children of a parent `run_id` +- idempotency-key lookup still resolves a key to a durable `run_id` +- GET-by-id remains refused on this stack +- consumer mismatch, unknown identities, nonempty bodies, and metric keys + fail closed diff --git a/schemas/analysis_run_retry_parent_v1.json b/schemas/analysis_run_retry_parent_v1.json new file mode 100644 index 000000000..ad6eb25c2 --- /dev/null +++ b/schemas/analysis_run_retry_parent_v1.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://tepp.local/schemas/analysis_run_retry_parent_v1.json", + "title": "AnalysisRunRetryParentV1", + "type": "object", + "additionalProperties": false, + "required": [ + "contract_version", + "run_id", + "run_state", + "idempotency_key", + "parent" + ], + "properties": { + "contract_version": { "type": "integer", "const": 1 }, + "run_id": { "type": "string", "minLength": 1, "maxLength": 128, "pattern": ".*\\S.*" }, + "run_state": { + "type": "string", + "enum": ["accepted", "running", "succeeded", "failed", "cancelled"] + }, + "idempotency_key": { "type": "string", "minLength": 1, "maxLength": 128, "pattern": ".*\\S.*" }, + "parent": { + "anyOf": [ + { "type": "null" }, + { + "type": "object", + "additionalProperties": false, + "required": ["run_id", "run_state", "idempotency_key"], + "properties": { + "run_id": { "type": "string", "minLength": 1, "maxLength": 128, "pattern": ".*\\S.*" }, + "run_state": { + "type": "string", + "enum": ["accepted", "running", "succeeded", "failed", "cancelled"] + }, + "idempotency_key": { "type": "string", "minLength": 1, "maxLength": 128, "pattern": ".*\\S.*" } + } + } + ] + } + } +}