From 520f1a4e9fc73c274201d89a9857b8d0b5d8353d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 31 Aug 2026 14:45:39 +0000 Subject: [PATCH] feat(api): retry analysis runs from LineageWeave and Naruon compatibility Add lineageweave_analysis_run_retry_exchange, NaruonLiveService POST retry for Naruon only, and a tepp-loopback TCP create-cancel-retry proof (ADR 0033). Metric-free child 202 receipts stay fail-closed. GET remains refused on the compatibility listener. Stacked on the retry HTTP slice; not a second retry DTO. --- .../analysis-run-retry-consumer-parity.md | 1 + CHANGELOG.md | 2 + DOCUMENTATION.md | 1 + crates/tepp_api/src/lib.rs | 2 + crates/tepp_api/src/lineageweave_http.rs | 81 +++- crates/tepp_api/src/naruon_live.rs | 347 +++++++++++++++++- .../tests/lineageweave_http_contract.rs | 96 ++++- .../tests/loopback_binary_contract.rs | 60 +++ .../tests/naruon_live_http_contract.rs | 30 ++ docs/API_CONTRACT.md | 2 +- docs/TRACEABILITY.md | 1 + ...0033-analysis-run-retry-consumer-parity.md | 74 ++++ docs/adr/README.md | 2 + docs/connectors/naruon-artifact-consumer.md | 3 +- .../analysis-run-retry-consumer-parity.md | 42 +++ 15 files changed, 719 insertions(+), 25 deletions(-) create mode 100644 CHANGELOG.d/analysis-run-retry-consumer-parity.md create mode 100644 docs/adr/0033-analysis-run-retry-consumer-parity.md create mode 100644 docs/research/analysis-run-retry-consumer-parity.md diff --git a/CHANGELOG.d/analysis-run-retry-consumer-parity.md b/CHANGELOG.d/analysis-run-retry-consumer-parity.md new file mode 100644 index 000000000..4bfc6ee7b --- /dev/null +++ b/CHANGELOG.d/analysis-run-retry-consumer-parity.md @@ -0,0 +1 @@ +- `tepp_api` adds `lineageweave_analysis_run_retry_exchange`, Naruon compatibility-listener retry, and a `tepp-loopback` TCP retry proof (ADR 0033). Metric-free child `202 Accepted` is unchanged from ADR 0032. Not GET status, not lifecycle POST, not an ADR 0014 claim. diff --git a/CHANGELOG.md b/CHANGELOG.md index b55a4f9c4..438b4aa31 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` adds `lineageweave_analysis_run_retry_exchange`, Naruon compatibility-listener `POST /v1/analysis-runs/{run_id}/retry`, and a `tepp-loopback` TCP retry proof (ADR 0033). Failed and cancelled Naruon runs clone into a metric-free child `202 Accepted`. LineageWeave remains refused on `NaruonLiveService`. GET remains refused there. Not GET-by-id, not lifecycle POST, not an ADR 0014 claim. + - `tepp_api` serves `GET /v1/analysis-runs` on the shared loopback listener (ADR 0031). Operators enumerate accepted, running, cancelled, and terminal runs as metric-free collection rows. Collection bodies refuse RMSE/bias/coverage/SE-gate/scientific-acceptance/`terminal_result` keys. GET-by-id and running/terminal POST remain later GAP-003A slices; this is not an ADR 0014 claim. - `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. diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 7bfb61f00..3283a37d3 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -16,6 +16,7 @@ TEPP's approved PRD v0.4 and implementation plan are the primary product baselin | Analysis-run cancel HTTP doctoring | [`docs/research/analysis-run-cancel-http.md`](docs/research/analysis-run-cancel-http.md) | | Analysis-run collection HTTP doctoring | [`docs/research/analysis-run-collection-http.md`](docs/research/analysis-run-collection-http.md) | | Analysis-run retry HTTP doctoring | [`docs/research/analysis-run-retry-http.md`](docs/research/analysis-run-retry-http.md) | +| Analysis-run retry consumer-parity doctoring | [`docs/research/analysis-run-retry-consumer-parity.md`](docs/research/analysis-run-retry-consumer-parity.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/lib.rs b/crates/tepp_api/src/lib.rs index 227dbd4ec..67c5c4aa9 100644 --- a/crates/tepp_api/src/lib.rs +++ b/crates/tepp_api/src/lib.rs @@ -189,6 +189,8 @@ pub use lineageweave_http::LINEAGEWEAVE_CONSUMER_CODE; pub use lineageweave_http::NARUON_CONSUMER_CODE; /// Build a `LineageWeave` analysis-run exchange without provider credentials. pub use lineageweave_http::lineageweave_analysis_run_exchange; +/// Build a `LineageWeave` analysis-run retry exchange without provider credentials. +pub use lineageweave_http::lineageweave_analysis_run_retry_exchange; /// Build a `LineageWeave` project-history exchange without provider credentials. pub use lineageweave_http::lineageweave_project_history_exchange; /// Build a credential-free `LineageWeave` temporal-context exchange. diff --git a/crates/tepp_api/src/lineageweave_http.rs b/crates/tepp_api/src/lineageweave_http.rs index 6094760ed..1ee9fc34e 100644 --- a/crates/tepp_api/src/lineageweave_http.rs +++ b/crates/tepp_api/src/lineageweave_http.rs @@ -3,9 +3,10 @@ use crate::naruon_http::compose_https_target; use crate::project_history::build_project_history_exchange; use crate::{ - AnalysisRunRequest, ApiError, NaruonHttpExchange, ProjectHistoryHttpExchange, - ProjectHistoryRequest, TEMPORAL_CONTEXT_CONTRACT_VERSION, TEMPORAL_CONTEXT_PATH, - TemporalContextRequest, naruon_analysis_run_exchange, + AnalysisRunRequest, AnalysisRunRetryRequest, ApiError, NaruonHttpExchange, + ProjectHistoryHttpExchange, ProjectHistoryRequest, TEMPORAL_CONTEXT_CONTRACT_VERSION, + TEMPORAL_CONTEXT_PATH, TemporalContextRequest, naruon_analysis_run_exchange, + naruon_analysis_run_retry_exchange, }; /// Stable consumer identity used by the Naruon adapter. @@ -29,12 +30,25 @@ pub fn lineageweave_analysis_run_exchange( request: &AnalysisRunRequest, ) -> Result { let mut exchange = naruon_analysis_run_exchange(origin, request)?; - let consumer_header = exchange - .headers - .iter_mut() - .find(|(name, _)| name.eq_ignore_ascii_case("tepp-consumer")) - .ok_or(ApiError::InvalidWirePayload)?; - LINEAGEWEAVE_CONSUMER_CODE.clone_into(&mut consumer_header.1); + swap_consumer_header(&mut exchange)?; + Ok(exchange) +} + +/// Build a `LineageWeave` → TEPP analysis-run retry exchange without credentials. +/// +/// The function reuses TEPP's existing origin, body, and header validation, +/// then replaces only the published modular-consumer identity. Retry remains a +/// metric-free clone of a failed or cancelled run, not a measurement result. +/// +/// # Errors +/// +/// Returns the same fail-closed errors as [`naruon_analysis_run_retry_exchange`]. +pub fn lineageweave_analysis_run_retry_exchange( + origin: &str, + request: &AnalysisRunRetryRequest, +) -> Result { + let mut exchange = naruon_analysis_run_retry_exchange(origin, request)?; + swap_consumer_header(&mut exchange)?; Ok(exchange) } @@ -90,13 +104,25 @@ pub(crate) fn consumer_is_supported(consumer_code: &str) -> bool { ) } +fn swap_consumer_header(exchange: &mut NaruonHttpExchange) -> Result<(), ApiError> { + let consumer_header = exchange + .headers + .iter_mut() + .find(|(name, _)| name.eq_ignore_ascii_case("tepp-consumer")) + .ok_or(ApiError::InvalidWirePayload)?; + LINEAGEWEAVE_CONSUMER_CODE.clone_into(&mut consumer_header.1); + Ok(()) +} + #[cfg(test)] mod tests { use super::{ LINEAGEWEAVE_CONSUMER_CODE, NARUON_CONSUMER_CODE, consumer_is_supported, - lineageweave_analysis_run_exchange, + lineageweave_analysis_run_exchange, lineageweave_analysis_run_retry_exchange, + }; + use crate::{ + ANALYSIS_RUN_CONTRACT_VERSION, AnalysisRunRequest, AnalysisRunRetryRequest, ApiError, }; - use crate::{ANALYSIS_RUN_CONTRACT_VERSION, AnalysisRunRequest, ApiError}; fn sample_run() -> AnalysisRunRequest { AnalysisRunRequest { @@ -132,4 +158,37 @@ mod tests { Err(ApiError::InvalidWirePayload) ); } + + #[test] + fn lineageweave_retry_exchange_swaps_only_the_consumer_header() { + let request = AnalysisRunRetryRequest::new("tepp-run-1", "idem-retry-1").expect("retry"); + let exchange = + lineageweave_analysis_run_retry_exchange("https://tepp.example.test", &request) + .expect("retry exchange"); + assert_eq!(exchange.method, "POST"); + assert_eq!( + exchange.target_url, + "https://tepp.example.test/v1/analysis-runs/tepp-run-1/retry" + ); + assert!( + exchange + .headers + .contains(&("tepp-consumer".into(), LINEAGEWEAVE_CONSUMER_CODE.into())) + ); + assert!( + exchange + .headers + .contains(&("idempotency-key".into(), "idem-retry-1".into())) + ); + assert!(exchange.headers.iter().all(|(name, _)| { + !matches!( + name.to_ascii_lowercase().as_str(), + "authorization" | "proxy-authorization" | "cookie" | "x-api-key" + ) + })); + assert_eq!( + lineageweave_analysis_run_retry_exchange("http://tepp.example.test", &request), + Err(ApiError::InvalidWirePayload) + ); + } } diff --git a/crates/tepp_api/src/naruon_live.rs b/crates/tepp_api/src/naruon_live.rs index f9b4ca327..8b01824be 100644 --- a/crates/tepp_api/src/naruon_live.rs +++ b/crates/tepp_api/src/naruon_live.rs @@ -5,6 +5,7 @@ use std::io::{Read, Write}; use std::net::{SocketAddr, TcpListener, TcpStream}; use std::time::Duration; +use crate::analysis_run_retry_http::analysis_run_retry_path_run_id; use crate::authorization::{ AnalyticalPurpose, ExportAuthorizationRequest, authorize_export, require_export_allowed, }; @@ -16,8 +17,8 @@ use crate::live_http::{ use crate::naruon_http::{NARUON_ANALYSIS_RUN_PATH, NARUON_EXPORT_PATH}; use crate::wire::{from_json, to_json}; use crate::{ - AnalysisRunAccepted, AnalysisRunRequest, ApiError, ErrorEnvelope, - requests_are_idempotent_matches, + AnalysisRunAccepted, AnalysisRunRequest, AnalysisRunRetryRequest, AnalysisRunStatusState, + ApiError, ErrorEnvelope, refuse_metrics_on_retry_payload, requests_are_idempotent_matches, }; #[cfg(test)] @@ -51,14 +52,26 @@ pub struct NaruonLiveResponse { /// Production interchange origins remain `https` only. This listener binds /// loopback TCP so tests and local standalone operation can prove request /// handling without claiming TLS termination or cross-service table access. -/// This port only accepts versioned naruon POSTs. +/// This port only accepts versioned naruon POSTs, including +/// `POST /v1/analysis-runs/{run_id}/retry` for metric-free retry of failed or +/// cancelled runs. `LineageWeave` remains refused here and uses +/// `AnalysisRunLiveService`. GET remains refused. #[derive(Debug)] pub struct NaruonLiveService { listener: Option, bound_addr: Option, next_run_serial: u64, next_request_serial: u64, - accepted_runs: HashMap, + accepted_runs: HashMap, + runs_by_id: HashMap, +} + +/// One naruon-only accepted run and its current lifecycle state. +#[derive(Clone, Debug, Eq, PartialEq)] +struct NaruonLiveRun { + request: AnalysisRunRequest, + accepted: AnalysisRunAccepted, + run_state: AnalysisRunStatusState, } impl Default for NaruonLiveService { @@ -77,6 +90,7 @@ impl NaruonLiveService { next_run_serial: 1, next_request_serial: 1, accepted_runs: HashMap::new(), + runs_by_id: HashMap::new(), } } @@ -201,11 +215,17 @@ impl NaruonLiveService { if method != "POST" { return Err(ApiError::InvalidWirePayload); } + let headers = parse_headers(lines)?; + refuse_live_headers(&headers, self.bound_addr)?; + if matches!( + analysis_run_retry_path_run_id(path), + Ok(_) | Err(ApiError::LimitExceeded) + ) { + return self.retry_analysis_run(path, &headers, body); + } if path != NARUON_ANALYSIS_RUN_PATH && path != NARUON_EXPORT_PATH { return Err(ApiError::InvalidWirePayload); } - let headers = parse_headers(lines)?; - refuse_live_headers(&headers, self.bound_addr)?; self.dispatch_path(path, &headers, body) } @@ -233,12 +253,12 @@ impl NaruonLiveService { return Err(ApiError::InvalidWirePayload); } let replay_key = tenant_idempotency_key(&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) { + if let Some(stored) = self.accepted_runs.get(&replay_key) { + if requests_are_idempotent_matches(&stored.request, &request) { return Ok(NaruonLiveResponse::json( 202, "Accepted", - stored_accepted.to_json()?, + stored.accepted.to_json()?, )); } return Err(ApiError::InvalidWirePayload); @@ -248,10 +268,117 @@ impl NaruonLiveService { let accepted = AnalysisRunAccepted::new(run_id, "accepted", request.idempotency_key.clone())?; let body = accepted.to_json()?; - self.accepted_runs.insert(replay_key, (request, accepted)); + self.runs_by_id + .insert(accepted.run_id.clone(), replay_key.clone()); + self.accepted_runs.insert( + replay_key, + NaruonLiveRun { + request, + accepted, + run_state: AnalysisRunStatusState::Accepted, + }, + ); Ok(NaruonLiveResponse::json(202, "Accepted", body)) } + fn retry_analysis_run( + &mut self, + path: &str, + headers: &HashMap, + body: &str, + ) -> Result { + let run_id = analysis_run_retry_path_run_id(path)?; + refuse_metrics_on_retry_payload(body)?; + let new_idempotency_key = header_value(headers, "idempotency-key")?; + if !body.trim().is_empty() { + let retry = AnalysisRunRetryRequest::from_json(body)?; + if retry.run_id != run_id || retry.idempotency_key != new_idempotency_key { + return Err(ApiError::InvalidWirePayload); + } + } + let parent_replay_key = self + .runs_by_id + .get(&run_id) + .cloned() + .ok_or(ApiError::InvalidWirePayload)?; + let (mut cloned_request, parent_idempotency_key, parent_state) = { + let stored = self + .accepted_runs + .get(&parent_replay_key) + .ok_or(ApiError::InvalidWirePayload)?; + ( + stored.request.clone(), + stored.accepted.idempotency_key.clone(), + stored.run_state, + ) + }; + if new_idempotency_key == parent_idempotency_key { + return Err(ApiError::InvalidWirePayload); + } + match parent_state { + AnalysisRunStatusState::Failed | AnalysisRunStatusState::Cancelled => {} + AnalysisRunStatusState::Accepted + | AnalysisRunStatusState::Running + | AnalysisRunStatusState::Succeeded => { + return Err(ApiError::InvalidWirePayload); + } + } + new_idempotency_key.clone_into(&mut cloned_request.idempotency_key); + cloned_request.validate()?; + let replay_key = + tenant_idempotency_key(&cloned_request.tenant_workspace_id, new_idempotency_key); + if let Some(stored) = self.accepted_runs.get(&replay_key) { + if requests_are_idempotent_matches(&stored.request, &cloned_request) { + let response_body = stored.accepted.to_json()?; + refuse_metrics_on_retry_payload(&response_body)?; + return Ok(NaruonLiveResponse::json(202, "Accepted", response_body)); + } + return Err(ApiError::InvalidWirePayload); + } + let child_run_id = format!("naruon-run-{}", self.next_run_serial); + self.next_run_serial += 1; + let accepted = AnalysisRunAccepted::new( + child_run_id.clone(), + "accepted", + cloned_request.idempotency_key.clone(), + )?; + let response_body = accepted.to_json()?; + refuse_metrics_on_retry_payload(&response_body)?; + self.runs_by_id.insert(child_run_id, replay_key.clone()); + self.accepted_runs.insert( + replay_key, + NaruonLiveRun { + request: cloned_request, + accepted, + run_state: AnalysisRunStatusState::Accepted, + }, + ); + Ok(NaruonLiveResponse::json(202, "Accepted", response_body)) + } + + /// Test-only seam that records a non-accepted loopback state. + /// + /// Used to prove retry of failed and cancelled Naruon runs without + /// duplicating the live cancel HTTP slice. + #[cfg(test)] + fn force_naruon_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 authorize_export( headers: &HashMap, body: &str, @@ -531,4 +658,204 @@ mod tests { ApiError::InvalidWirePayload ); } + + fn sample_run_json() -> String { + r#"{"contract_version":1,"idempotency_key":"naruon-retry-parent","tenant_workspace_id":"naruon-retry-tenant","snapshot_id":"naruon-retry-snapshot","knowledge_cutoff":"2026-08-01T00:00:00Z","model_contract_version":"tepp-analysis-run-v1","output_profile":"calibrated_event_measurement"}"#.to_owned() + } + + fn create_http(body: &str) -> String { + format!( + "POST /v1/analysis-runs 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: naruon-retry-parent\r\ncontent-length: {}\r\n\r\n{body}", + body.len() + ) + } + + fn retry_http(run_id: &str, body: &str, consumer: &str, idempotency_key: &str) -> String { + format!( + "POST /v1/analysis-runs/{run_id}/retry HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: {consumer}\r\ntepp-contract-version: 1\r\nidempotency-key: {idempotency_key}\r\ncontent-length: {}\r\n\r\n{body}", + body.len() + ) + } + + #[test] + #[allow(clippy::too_many_lines)] + fn handler_covers_metric_free_retry_of_failed_and_cancelled() { + use crate::{AnalysisRunAccepted, AnalysisRunRetryRequest, AnalysisRunStatusState}; + + let body = sample_run_json(); + let mut service = NaruonLiveService::new(); + let accepted = service.handle_http_request(&create_http(&body)); + assert_eq!(accepted.status_code, 202); + let parent = AnalysisRunAccepted::from_json(&accepted.body).expect("parent"); + assert_eq!(parent.run_id, "naruon-run-1"); + service + .force_naruon_run_state(&parent.run_id, AnalysisRunStatusState::Failed) + .expect("force failed"); + + let retry_key = "naruon-retry-child"; + let retry_body = AnalysisRunRetryRequest::new(&parent.run_id, retry_key) + .expect("retry dto") + .to_json() + .expect("retry json"); + let retried = service.handle_http_request(&retry_http( + &parent.run_id, + &retry_body, + "naruon", + retry_key, + )); + assert_eq!(retried.status_code, 202); + let child = AnalysisRunAccepted::from_json(&retried.body).expect("child"); + assert_eq!(child.run_id, "naruon-run-2"); + assert_eq!(child.idempotency_key, retry_key); + assert_eq!(child.run_state, "accepted"); + assert!(!retried.body.contains("rmse")); + assert!(!retried.body.contains("scientific_acceptance")); + + let replay = service.handle_http_request(&retry_http( + &parent.run_id, + &retry_body, + "naruon", + retry_key, + )); + assert_eq!(replay.status_code, 202); + assert_eq!(replay.body, retried.body); + + let empty_body = + service.handle_http_request(&retry_http(&parent.run_id, "", "naruon", retry_key)); + assert_eq!(empty_body.status_code, 202); + assert_eq!(empty_body.body, retried.body); + + let mut cancelled_service = NaruonLiveService::new(); + let cancelled_parent = cancelled_service.handle_http_request(&create_http(&body)); + let cancelled_id = AnalysisRunAccepted::from_json(&cancelled_parent.body) + .expect("cancelled parent") + .run_id; + cancelled_service + .force_naruon_run_state(&cancelled_id, AnalysisRunStatusState::Cancelled) + .expect("force cancelled"); + let cancelled_retry = cancelled_service.handle_http_request(&retry_http( + &cancelled_id, + "", + "naruon", + "naruon-retry-cancelled-child", + )); + assert_eq!(cancelled_retry.status_code, 202); + assert!(!cancelled_retry.body.contains("rmse")); + + let mut accepted_only = NaruonLiveService::new(); + let still_accepted = accepted_only.handle_http_request(&create_http(&body)); + let accepted_id = AnalysisRunAccepted::from_json(&still_accepted.body) + .expect("accepted") + .run_id; + assert_eq!( + accepted_only + .handle_http_request(&retry_http( + &accepted_id, + "", + "naruon", + "naruon-retry-too-early" + )) + .status_code, + 400 + ); + + for state in [ + AnalysisRunStatusState::Running, + AnalysisRunStatusState::Succeeded, + ] { + let mut blocked = NaruonLiveService::new(); + let created = blocked.handle_http_request(&create_http(&body)); + let blocked_id = AnalysisRunAccepted::from_json(&created.body) + .expect("blocked") + .run_id; + blocked + .force_naruon_run_state(&blocked_id, state) + .expect("force"); + assert_eq!( + blocked + .handle_http_request(&retry_http( + &blocked_id, + "", + "naruon", + "naruon-retry-blocked" + )) + .status_code, + 400 + ); + } + + assert_eq!( + service + .handle_http_request(&retry_http( + &parent.run_id, + "", + "naruon", + "naruon-retry-parent" + )) + .status_code, + 400 + ); + assert_eq!( + service + .handle_http_request(&retry_http("missing", "", "naruon", retry_key)) + .status_code, + 400 + ); + assert_eq!( + service + .handle_http_request(&retry_http( + &parent.run_id, + "", + "lineageweave", + "naruon-retry-foreign" + )) + .status_code, + 400 + ); + assert_eq!( + service + .handle_http_request(&format!( + "GET /v1/analysis-runs/{}/retry 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: {retry_key}\r\ncontent-length: 0\r\n\r\n", + parent.run_id + )) + .status_code, + 400 + ); + let mismatched = AnalysisRunRetryRequest::new("other-run", retry_key) + .expect("mismatch") + .to_json() + .expect("json"); + assert_eq!( + service + .handle_http_request(&retry_http( + &parent.run_id, + &mismatched, + "naruon", + retry_key + )) + .status_code, + 400 + ); + let metric_body = format!( + r#"{{"contract_version":1,"run_id":"{}","idempotency_key":"{retry_key}","rmse":0.1}}"#, + parent.run_id + ); + assert_eq!( + service + .handle_http_request(&retry_http( + &parent.run_id, + &metric_body, + "naruon", + retry_key + )) + .status_code, + 400 + ); + assert_eq!( + service + .force_naruon_run_state("ghost", AnalysisRunStatusState::Failed) + .expect_err("unknown"), + ApiError::InvalidWirePayload + ); + } } diff --git a/crates/tepp_api/tests/lineageweave_http_contract.rs b/crates/tepp_api/tests/lineageweave_http_contract.rs index 19b3e352e..e1b900cc8 100644 --- a/crates/tepp_api/tests/lineageweave_http_contract.rs +++ b/crates/tepp_api/tests/lineageweave_http_contract.rs @@ -8,8 +8,9 @@ use std::time::Duration; use tepp_api::{ ANALYSIS_RUN_CONTRACT_VERSION, AnalysisRunAccepted, AnalysisRunLiveService, AnalysisRunRequest, - ApiError, LINEAGEWEAVE_CONSUMER_CODE, NARUON_ANALYSIS_RUN_PATH, NARUON_CONSUMER_CODE, - NARUON_LIVE_HEADER_BYTE_LIMIT, lineageweave_analysis_run_exchange, + AnalysisRunRetryRequest, ApiError, LINEAGEWEAVE_CONSUMER_CODE, NARUON_ANALYSIS_RUN_PATH, + NARUON_CONSUMER_CODE, NARUON_LIVE_HEADER_BYTE_LIMIT, lineageweave_analysis_run_exchange, + lineageweave_analysis_run_retry_exchange, }; fn sample_run() -> AnalysisRunRequest { @@ -68,6 +69,39 @@ fn lineageweave_exchange_uses_the_published_consumer_header_without_credentials( })); } +#[test] +fn lineageweave_retry_exchange_posts_the_published_consumer_without_credentials() { + let request = + AnalysisRunRetryRequest::new("tepp-run-9", "idem-retry-9").expect("retry request"); + let exchange = lineageweave_analysis_run_retry_exchange("https://tepp.example.test", &request) + .expect("lineageweave retry"); + assert_eq!(exchange.method, "POST"); + assert_eq!( + exchange.target_url, + "https://tepp.example.test/v1/analysis-runs/tepp-run-9/retry" + ); + assert!( + exchange + .headers + .contains(&("tepp-consumer".into(), LINEAGEWEAVE_CONSUMER_CODE.into())) + ); + assert!( + exchange + .headers + .contains(&("idempotency-key".into(), "idem-retry-9".into())) + ); + assert!(exchange.headers.iter().all(|(name, _)| { + !matches!( + name.to_ascii_lowercase().as_str(), + "authorization" | "proxy-authorization" | "cookie" | "x-api-key" + ) + })); + assert_eq!( + lineageweave_analysis_run_retry_exchange("http://tepp.example.test", &request), + Err(ApiError::InvalidWirePayload) + ); +} + #[test] fn live_listener_accepts_lineageweave_and_isolates_consumer_idempotency() { let loopback = AnalysisRunLiveService::bind_loopback().expect("loopback bind"); @@ -153,3 +187,61 @@ fn live_listener_serves_lineageweave_over_loopback() { 202 ); } + +fn cancel_http(run_id: &str, consumer: &str, idempotency_key: &str) -> String { + format!( + "POST {NARUON_ANALYSIS_RUN_PATH}/{run_id}/cancel HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: {consumer}\r\ntepp-contract-version: 1\r\nidempotency-key: {idempotency_key}\r\ncontent-length: 0\r\n\r\n" + ) +} + +fn retry_http(run_id: &str, consumer: &str, idempotency_key: &str, body: &str) -> String { + format!( + "POST {NARUON_ANALYSIS_RUN_PATH}/{run_id}/retry HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: {consumer}\r\ntepp-contract-version: 1\r\nidempotency-key: {idempotency_key}\r\ncontent-length: {}\r\n\r\n{body}", + body.len() + ) +} + +#[test] +fn live_listener_retries_a_cancelled_lineageweave_run_and_refuses_naruon_mismatch() { + let run = sample_run(); + let mut service = AnalysisRunLiveService::new(); + let created = service.handle_http_request(&http_request(LINEAGEWEAVE_CONSUMER_CODE, &run)); + assert_eq!(created.status_code, 202); + let parent = AnalysisRunAccepted::from_json(&created.body).expect("parent"); + + let cancelled = service.handle_http_request(&cancel_http( + &parent.run_id, + LINEAGEWEAVE_CONSUMER_CODE, + &run.idempotency_key, + )); + assert_eq!(cancelled.status_code, 200); + assert!(cancelled.body.contains("\"run_state\":\"cancelled\"")); + assert!(!cancelled.body.contains("rmse")); + + let retry_key = "lineageweave-retry-child"; + let retry_body = AnalysisRunRetryRequest::new(&parent.run_id, retry_key) + .expect("retry dto") + .to_json() + .expect("retry json"); + let retried = service.handle_http_request(&retry_http( + &parent.run_id, + LINEAGEWEAVE_CONSUMER_CODE, + retry_key, + &retry_body, + )); + assert_eq!(retried.status_code, 202); + let child = AnalysisRunAccepted::from_json(&retried.body).expect("child"); + assert_ne!(child.run_id, parent.run_id); + assert_eq!(child.idempotency_key, retry_key); + assert_eq!(child.run_state, "accepted"); + assert!(!retried.body.contains("rmse")); + assert!(!retried.body.contains("scientific_acceptance")); + + let naruon_mismatch = service.handle_http_request(&retry_http( + &parent.run_id, + NARUON_CONSUMER_CODE, + "naruon-retry-mismatch", + "", + )); + assert_eq!(naruon_mismatch.status_code, 400); +} diff --git a/crates/tepp_api/tests/loopback_binary_contract.rs b/crates/tepp_api/tests/loopback_binary_contract.rs index 20e475647..8fba3f71c 100644 --- a/crates/tepp_api/tests/loopback_binary_contract.rs +++ b/crates/tepp_api/tests/loopback_binary_contract.rs @@ -29,3 +29,63 @@ fn binary_serves_one_bounded_temporal_context_request() { assert!(response.contains("association_not_causal")); assert!(child.wait().expect("wait").success()); } + +#[test] +fn binary_retries_a_cancelled_lineageweave_analysis_run_over_tcp() { + let mut child = Command::new(env!("CARGO_BIN_EXE_tepp-loopback")) + .args(["127.0.0.1:0", "3"]) + .stdout(Stdio::piped()) + .spawn() + .expect("spawn loopback service"); + let mut address = String::new(); + BufReader::new(child.stdout.take().expect("stdout")) + .read_line(&mut address) + .expect("bound address"); + let host = address.trim(); + let body = r#"{"contract_version":1,"idempotency_key":"loopback-retry-parent","tenant_workspace_id":"loopback-retry-tenant","snapshot_id":"loopback-retry-snapshot","knowledge_cutoff":"2026-08-01T00:00:00Z","model_contract_version":"tepp-analysis-run-v1","output_profile":"calibrated_event_measurement"}"#; + let create = format!( + "POST /v1/analysis-runs HTTP/1.1\r\nHost: {host}\r\ncontent-type: application/json\r\ntepp-consumer: lineageweave\r\ntepp-contract-version: 1\r\nidempotency-key: loopback-retry-parent\r\ncontent-length: {}\r\n\r\n{body}", + body.len() + ); + let mut stream = TcpStream::connect(host).expect("connect create"); + stream.write_all(create.as_bytes()).expect("create"); + let mut accepted = String::new(); + stream.read_to_string(&mut accepted).expect("accepted"); + assert!(accepted.starts_with("HTTP/1.1 202 Accepted")); + let run_id = accepted + .split("\"run_id\":\"") + .nth(1) + .expect("run_id") + .split('"') + .next() + .expect("id"); + + let cancel = format!( + "POST /v1/analysis-runs/{run_id}/cancel HTTP/1.1\r\nHost: {host}\r\ncontent-type: application/json\r\ntepp-consumer: lineageweave\r\ntepp-contract-version: 1\r\nidempotency-key: loopback-retry-parent\r\ncontent-length: 0\r\n\r\n" + ); + let mut stream = TcpStream::connect(host).expect("connect cancel"); + stream.write_all(cancel.as_bytes()).expect("cancel"); + let mut cancelled = String::new(); + stream.read_to_string(&mut cancelled).expect("cancelled"); + assert!(cancelled.starts_with("HTTP/1.1 200 OK")); + assert!(cancelled.contains("\"run_state\":\"cancelled\"")); + assert!(!cancelled.contains("rmse")); + + let retry_body = format!( + r#"{{"contract_version":1,"run_id":"{run_id}","idempotency_key":"loopback-retry-child"}}"# + ); + let retry = format!( + "POST /v1/analysis-runs/{run_id}/retry HTTP/1.1\r\nHost: {host}\r\ncontent-type: application/json\r\ntepp-consumer: lineageweave\r\ntepp-contract-version: 1\r\nidempotency-key: loopback-retry-child\r\ncontent-length: {}\r\n\r\n{retry_body}", + retry_body.len() + ); + let mut stream = TcpStream::connect(host).expect("connect retry"); + stream.write_all(retry.as_bytes()).expect("retry"); + let mut retried = String::new(); + stream.read_to_string(&mut retried).expect("retried"); + assert!(retried.starts_with("HTTP/1.1 202 Accepted")); + assert!(retried.contains("\"run_state\":\"accepted\"")); + assert!(retried.contains("loopback-retry-child")); + assert!(!retried.contains("rmse")); + assert!(!retried.contains("scientific_acceptance")); + assert!(child.wait().expect("wait").success()); +} diff --git a/crates/tepp_api/tests/naruon_live_http_contract.rs b/crates/tepp_api/tests/naruon_live_http_contract.rs index dfb73c56f..9a86b3537 100644 --- a/crates/tepp_api/tests/naruon_live_http_contract.rs +++ b/crates/tepp_api/tests/naruon_live_http_contract.rs @@ -719,6 +719,36 @@ fn serve_one_maps_partial_request_timeout_to_limit_exceeded() { assert_eq!(envelope(&served.body).error_code(), "limit_exceeded"); } +#[test] +fn handle_http_refuses_lineageweave_get_and_accepted_retry() { + let mut service = NaruonLiveService::new(); + let run = sample_run(); + let created = service.handle_http_request(&analysis_http(&run)); + assert_eq!(created.status_code, 202); + let parent = AnalysisRunAccepted::from_json(&created.body).expect("parent"); + + let accepted_retry = format!( + "POST {NARUON_ANALYSIS_RUN_PATH}/{}/retry 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: naruon-retry-too-early\r\ncontent-length: 0\r\n\r\n", + parent.run_id + ); + assert_eq!( + service.handle_http_request(&accepted_retry).status_code, + 400 + ); + + let lineageweave = format!( + "POST {NARUON_ANALYSIS_RUN_PATH}/{}/retry HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: lineageweave\r\ntepp-contract-version: 1\r\nidempotency-key: naruon-retry-foreign\r\ncontent-length: 0\r\n\r\n", + parent.run_id + ); + assert_eq!(service.handle_http_request(&lineageweave).status_code, 400); + + let get = format!( + "GET {NARUON_ANALYSIS_RUN_PATH}/{}/retry 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: naruon-retry-get\r\ncontent-length: 0\r\n\r\n", + parent.run_id + ); + assert_eq!(service.handle_http_request(&get).status_code, 400); +} + struct TimeoutRead; impl Read for TimeoutRead { diff --git a/docs/API_CONTRACT.md b/docs/API_CONTRACT.md index 4cc2e63e4..9da7f697f 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`. `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`. `lineageweave_analysis_run_retry_exchange` mints the same retry path for LineageWeave. `NaruonLiveService` serves Naruon-only retry POST on that path and still refuses GET and LineageWeave. `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 diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 3937c14b5..a4a6b33c3 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -56,6 +56,7 @@ The full APA 7th standards/literature register remains `docs/research/standards- | 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 | | loopback analysis-run collection GET | ADR 0031; API contract; RFC 9110 | `tepp_api` `GET /v1/analysis-runs` on `AnalysisRunLiveService`: metric-free enumeration of accepted/running/cancelled/terminal runs; collection bodies refuse scientific-acceptance and RMSE keys; GET-by-id remains a later slice | active-PR | | loopback analysis-run retry HTTP | ADR 0032; API contract; RFC 9110 | `tepp_api` `POST /v1/analysis-runs/{run_id}/retry` on `AnalysisRunLiveService`: clones failed/cancelled into a new metric-free `202 Accepted` with a new idempotency key; accepted/running/succeeded/unknown refuse; GET-by-id remains a later slice | active-PR | +| loopback analysis-run retry consumer parity | ADR 0033; API contract; RFC 9110 | `tepp_api` `lineageweave_analysis_run_retry_exchange` plus Naruon compatibility-listener retry; LineageWeave remains refused on `NaruonLiveService`; `tepp-loopback` TCP create-cancel-retry proof; GET remains refused on the compatibility listener | 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/0033-analysis-run-retry-consumer-parity.md b/docs/adr/0033-analysis-run-retry-consumer-parity.md new file mode 100644 index 000000000..e738067d2 --- /dev/null +++ b/docs/adr/0033-analysis-run-retry-consumer-parity.md @@ -0,0 +1,74 @@ +# ADR 0033 — Analysis-run retry consumer parity + +**Decision status:** Accepted +**Implementation maturity:** active-PR +**Date:** 2026-08-31 +**Supersedes:** None; complements ADR 0032 and ADR 0018. Does not supersede ADR 0014. ADR 0026–0032 remain on other GAP-003A slices. This ADR number is unique on the retry-HTTP lineage; other live PRs may reuse 0033 on unrelated stacks. + +## Context + +ADR 0032 added `POST /v1/analysis-runs/{run_id}/retry` on `AnalysisRunLiveService` and a Naruon retry-exchange builder. The Naruon compatibility listener (`NaruonLiveService`) still refused every retry path. `LineageWeave` had a create-exchange builder but no retry-exchange, so a published consumer would have to mint a Naruon-labelled retry. The packaged `tepp-loopback` binary had no TCP proof that retry works on the shared listener. + +Duplicating the retry DTO, GET status, lifecycle POST, cancel, collection GET, or engine-library slices would not close this consumer-parity gap. + +## Decision + +- `lineageweave_analysis_run_retry_exchange` reuses the Naruon retry builder and replaces only `tepp-consumer`. +- `NaruonLiveService` serves the same metric-free retry path for the Naruon-only compatibility listener. LineageWeave consumers remain refused there; they use `AnalysisRunLiveService`. +- `NaruonLiveService` stays POST-only. GET remains refused. +- `tepp-loopback` proves create-then-cancel-then-retry over loopback TCP for LineageWeave. +- Retry receipts stay metric-free `202 Accepted` with a new `run_id` and a new idempotency key. Accepted, running, succeeded, and unknown runs still fail closed. + +## Non-goals + +- GET status, running/terminal POST, collection GET, cancel DTO changes, persistence, or production TLS. +- Opening `NaruonLiveService` to LineageWeave. +- Adding GET to `NaruonLiveService`. +- An ADR 0014 scientific claim. + +## Alternatives considered + +1. **Leave retry only on `AnalysisRunLiveService`** — rejected because the compatibility listener would silently refuse a documented path. +2. **Admit LineageWeave on `NaruonLiveService`** — rejected because that listener is Naruon-only (ADR 0011/0018). +3. **Mint a second retry DTO** — rejected as a duplicate of ADR 0032. +4. **Consumer-parity retry on the existing typed request** — accepted. + +## Consequences + +- Both published consumers can build a credential-free retry exchange. +- Naruon local proofs can retry failed or cancelled runs on either listener. +- Operators can observe LineageWeave retry through `tepp-loopback` without a second HTTP stack. + +## Failure and recovery + +Unknown runs, consumer mismatch, parent-key reuse, metric keys, accepted/running/succeeded retry, identity mismatch, and oversized identities fail closed with a redacted envelope. The in-memory registry is not durable. + +## Security, privacy, scientific-integrity, and governance impact + +- No credential headers cross the consumer boundary. +- Retry remains loopback-only and metric-free. +- HTTP `202` on a retried run is not measurement or release evidence. + +## Compatibility and migration + +ADR 0032 create/retry semantics are unchanged. Production adapters may replace loopback while preserving consumer identity, metric-free retry receipts, and Naruon-only compatibility-listener admission. + +## Verification + +- LineageWeave retry exchange carries `tepp-consumer: lineageweave` and no credentials; +- NaruonLiveService retries failed/cancelled Naruon runs and refuses LineageWeave, GET, metrics, and accepted/running/succeeded runs; +- `tepp-loopback` create-then-cancel-then-retry over TCP returns metric-free `202 Accepted`; +- Clippy `-D warnings`, `tepp_api` tests, rustdoc, and exact-head review remain required. + +## Rollback and supersession + +Rollback removes the LineageWeave builder, compatibility-listener retry, and binary TCP proof; ADR 0032 shared-listener retry remains. A superseding ADR is required to persist retry, bind a public address, or treat HTTP success as an ADR 0014 claim. + +## Related authority + +- ADR 0032 owns the shared-listener retry path and metric-free child `202`. +- ADR 0029 owns loopback cancel used by the TCP proof. +- ADR 0018 owns consumer-scoped ingress. +- ADR 0011 owns standalone/modular HTTP boundaries. +- ADR 0014 owns scientific claim promotion. +- 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 12683d65a..017023cf4 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -33,6 +33,7 @@ Read [`ADR_POLICY.md`](ADR_POLICY.md) first. **Decision status and implementatio | [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. | | [0031](0031-analysis-run-collection-get.md) | Loopback GET analysis-run collection is metric-free enumeration | Accepted | active-PR | Complements ADR 0018/0029; does not supersede ADR 0014. ADR 0026–0030 live on other GAP-003A PRs. | | [0032](0032-analysis-run-retry-http.md) | Loopback POST analysis-run retry clones failed/cancelled into a new metric-free 202 | Accepted | active-PR | Complements ADR 0018/0029/0031; does not supersede ADR 0014. ADR 0026–0031 live on other GAP-003A PRs. | +| [0033](0033-analysis-run-retry-consumer-parity.md) | LineageWeave retry-exchange and Naruon compatibility-listener retry | Accepted | active-PR | Complements ADR 0032/0018; does not supersede ADR 0014. Unique on the retry-HTTP lineage. | | [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. | @@ -146,6 +147,7 @@ Use the narrowest owning ADR when decisions overlap: - **analysis-run cancel HTTP:** ADR 0029. - **analysis-run collection GET:** ADR 0031. - **analysis-run retry HTTP:** ADR 0032. +- **analysis-run retry consumer parity:** ADR 0033. ## Change and supersession rule diff --git a/docs/connectors/naruon-artifact-consumer.md b/docs/connectors/naruon-artifact-consumer.md index 5bdc328a1..120de3f4e 100644 --- a/docs/connectors/naruon-artifact-consumer.md +++ b/docs/connectors/naruon-artifact-consumer.md @@ -28,8 +28,9 @@ TEPP remains the scientific authority for estimation, recovery metrics, temporal | HTTP analysis-run create | `tepp_api` `naruon_analysis_run_exchange` → `POST /v1/analysis-runs` | naruon → TEPP | | HTTP analysis-run collection | `tepp_api` `naruon_analysis_run_collection_exchange` → `GET /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 analysis-run retry | `tepp_api` `naruon_analysis_run_retry_exchange` → `POST /v1/analysis-runs/{run_id}/retry` | 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 | +| Live loopback POST | `tepp_api` `NaruonLiveService` → `POST /v1/analysis-runs`, `/v1/analysis-runs/{run_id}/retry`, and `/v1/exports` | naruon → TEPP | Committed examples live under `examples/`. Schemas for analysis-run requests and corpus-split manifests live under `schemas/`. diff --git a/docs/research/analysis-run-retry-consumer-parity.md b/docs/research/analysis-run-retry-consumer-parity.md new file mode 100644 index 000000000..c4feccd0c --- /dev/null +++ b/docs/research/analysis-run-retry-consumer-parity.md @@ -0,0 +1,42 @@ +# Analysis-run retry consumer parity (doctoring) + +## Scope + +`LineageWeave` and the Naruon compatibility listener must be able to retry a +failed or cancelled analysis run without inventing a second DTO. HTTP method, +path, and `Host` 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 0011; ADR 0018; ADR 0032; +ADR 0033), not an RFC inference rule. + +This slice does not serve GET status, running/terminal POST, collection GET, +cancel DTO changes, or persistence. `NaruonLiveService` stays POST-only. + +## Authority + +### External standards (HTTP only) + +Fielding, R., Nottingham, M., & Reschke, J. (Eds.). (2022). *HTTP semantics* +(RFC 9110). IETF. https://doi.org/10.17487/RFC9110 + +### Internal contract evidence + +- `docs/adr/0032-analysis-run-retry-http.md` — shared-listener retry +- `docs/adr/0033-analysis-run-retry-consumer-parity.md` — consumer parity +- `docs/adr/0018-consumer-scoped-analysis-run-ingress.md` — closed consumers +- `crates/tepp_api/tests/lineageweave_http_contract.rs` — LineageWeave builder +- `crates/tepp_api/tests/naruon_live_http_contract.rs` — compatibility listener +- `crates/tepp_api/tests/loopback_binary_contract.rs` — `tepp-loopback` TCP + +## Verification + +- LineageWeave retry exchange sets only the published consumer header; +- NaruonLiveService retries failed/cancelled Naruon runs and refuses LineageWeave; +- retry JSON has no RMSE/bias/coverage/SE-gate/scientific-acceptance keys; +- `tepp-loopback` create-then-cancel-then-retry over TCP returns `202` accepted. + +## Non-claims + +This slice does not implement GET status, lifecycle POST, persistence, +production TLS, or an ADR 0014 scientific claim.