From 02eee9f07f58dd6dee5396c36cfcd0a0b228d199 Mon Sep 17 00:00:00 2001 From: seonghobae Date: Mon, 31 Aug 2026 15:44:35 +0000 Subject: [PATCH] feat(api): inspect analysis-run retry parent from LineageWeave and Naruon LineageWeave can GET /v1/analysis-runs/{run_id}/parent without a Naruon-labelled exchange. NaruonLiveService serves the same metric-free inspect for Naruon only and returns parent null on accepted creates. tepp-loopback proves a non-null parent over TCP after create, cancel, and retry (ADR 0044). --- ...alysis-run-retry-parent-consumer-parity.md | 1 + CHANGELOG.md | 2 + DOCUMENTATION.md | 1 + crates/tepp_api/src/lib.rs | 2 + crates/tepp_api/src/lineageweave_http.rs | 68 +++++++- crates/tepp_api/src/naruon_live.rs | 155 +++++++++++++++++- .../tests/lineageweave_http_contract.rs | 104 +++++++++++- .../tests/loopback_binary_contract.rs | 79 +++++++++ .../tests/naruon_live_http_contract.rs | 50 +++++- docs/API_CONTRACT.md | 5 +- docs/TRACEABILITY.md | 1 + ...alysis-run-retry-parent-consumer-parity.md | 72 ++++++++ docs/adr/README.md | 2 + docs/connectors/naruon-artifact-consumer.md | 3 + ...alysis-run-retry-parent-consumer-parity.md | 43 +++++ 15 files changed, 574 insertions(+), 14 deletions(-) create mode 100644 CHANGELOG.d/analysis-run-retry-parent-consumer-parity.md create mode 100644 docs/adr/0044-analysis-run-retry-parent-consumer-parity.md create mode 100644 docs/research/analysis-run-retry-parent-consumer-parity.md diff --git a/CHANGELOG.d/analysis-run-retry-parent-consumer-parity.md b/CHANGELOG.d/analysis-run-retry-parent-consumer-parity.md new file mode 100644 index 000000000..27fdbf5b5 --- /dev/null +++ b/CHANGELOG.d/analysis-run-retry-parent-consumer-parity.md @@ -0,0 +1 @@ +- `tepp_api` adds `lineageweave_analysis_run_retry_parent_exchange`, Naruon compatibility-listener retry-parent GET (`"parent": null` on accepted creates), and a `tepp-loopback` TCP create-cancel-retry-inspect proof (ADR 0044). Metric-free retry-parent fields are unchanged from ADR 0038. Not GET status, not lifecycle POST, not an ADR 0014 claim. diff --git a/CHANGELOG.md b/CHANGELOG.md index b55a4f9c4..7caf70f15 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] +- Loopback retry-parent consumer parity: LineageWeave GET-parent exchange, Naruon compatibility-listener inspect (`"parent": null` on accepted creates), and `tepp-loopback` TCP create-cancel-retry-inspect proof (ADR 0044). + - `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 7db17fefd..822a8f563 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -20,6 +20,7 @@ TEPP's approved PRD v0.4 and implementation plan are the primary product baselin | 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) | +| Analysis-run retry-parent consumer-parity doctoring | [`docs/research/analysis-run-retry-parent-consumer-parity.md`](docs/research/analysis-run-retry-parent-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 aa0d40d99..5266b76a1 100644 --- a/crates/tepp_api/src/lib.rs +++ b/crates/tepp_api/src/lib.rs @@ -241,6 +241,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` retry-parent GET without provider credentials. +pub use lineageweave_http::lineageweave_analysis_run_retry_parent_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..91d894d6b 100644 --- a/crates/tepp_api/src/lineageweave_http.rs +++ b/crates/tepp_api/src/lineageweave_http.rs @@ -1,5 +1,6 @@ //! Published modular-consumer identity and `LineageWeave` TEPP exchanges. +use crate::analysis_run_retry_parent_http::naruon_analysis_run_retry_parent_exchange; use crate::naruon_http::compose_https_target; use crate::project_history::build_project_history_exchange; use crate::{ @@ -38,6 +39,29 @@ pub fn lineageweave_analysis_run_exchange( Ok(exchange) } +/// Build a `LineageWeave` → TEPP retry-parent GET without credentials. +/// +/// The function reuses TEPP's existing origin and identity validation, then +/// replaces only the published modular-consumer identity. The response remains +/// a metric-free inspect of the parent identity, not a measurement result. +/// +/// # Errors +/// +/// Returns the same fail-closed errors as [`naruon_analysis_run_retry_parent_exchange`]. +pub fn lineageweave_analysis_run_retry_parent_exchange( + origin: &str, + run_id: &str, +) -> Result { + let mut exchange = naruon_analysis_run_retry_parent_exchange(origin, run_id)?; + 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(exchange) +} + /// Build a credential-free `LineageWeave` temporal-context exchange. /// /// # Errors @@ -94,7 +118,7 @@ pub(crate) fn consumer_is_supported(consumer_code: &str) -> bool { 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_parent_exchange, }; use crate::{ANALYSIS_RUN_CONTRACT_VERSION, AnalysisRunRequest, ApiError}; @@ -132,4 +156,46 @@ mod tests { Err(ApiError::InvalidWirePayload) ); } + + #[test] + fn lineageweave_retry_parent_exchange_swaps_only_the_consumer_header() { + let exchange = lineageweave_analysis_run_retry_parent_exchange( + "https://tepp.example.test", + "tepp-run-1", + ) + .expect("exchange"); + assert_eq!(exchange.method, "GET"); + assert_eq!( + exchange.target_url, + "https://tepp.example.test/v1/analysis-runs/tepp-run-1/parent" + ); + assert!(exchange.body.is_empty()); + assert!( + exchange + .headers + .contains(&("tepp-consumer".into(), LINEAGEWEAVE_CONSUMER_CODE.into())) + ); + assert!( + !exchange + .headers + .contains(&("tepp-consumer".into(), NARUON_CONSUMER_CODE.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_parent_exchange( + "http://tepp.example.test", + "tepp-run-1" + ), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + lineageweave_analysis_run_retry_parent_exchange("https://tepp.example.test", ""), + Err(ApiError::InvalidWirePayload) + ); + } } diff --git a/crates/tepp_api/src/naruon_live.rs b/crates/tepp_api/src/naruon_live.rs index f9b4ca327..95bc85b6f 100644 --- a/crates/tepp_api/src/naruon_live.rs +++ b/crates/tepp_api/src/naruon_live.rs @@ -5,6 +5,10 @@ use std::io::{Read, Write}; use std::net::{SocketAddr, TcpListener, TcpStream}; use std::time::Duration; +use crate::analysis_run_retry_parent_http::{ + AnalysisRunRetryParent, analysis_run_retry_parent_path_run_id, + refuse_metrics_on_retry_parent_payload, +}; use crate::authorization::{ AnalyticalPurpose, ExportAuthorizationRequest, authorize_export, require_export_allowed, }; @@ -16,7 +20,7 @@ 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, + AnalysisRunAccepted, AnalysisRunRequest, AnalysisRunStatusState, ApiError, ErrorEnvelope, requests_are_idempotent_matches, }; @@ -51,7 +55,7 @@ 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 accepts versioned naruon POSTs and Naruon-only retry-parent GET. #[derive(Debug)] pub struct NaruonLiveService { listener: Option, @@ -59,6 +63,7 @@ pub struct NaruonLiveService { next_run_serial: u64, next_request_serial: u64, accepted_runs: HashMap, + runs_by_id: HashMap, } impl Default for NaruonLiveService { @@ -77,6 +82,7 @@ impl NaruonLiveService { next_run_serial: 1, next_request_serial: 1, accepted_runs: HashMap::new(), + runs_by_id: HashMap::new(), } } @@ -198,14 +204,24 @@ impl NaruonLiveService { let mut lines = header_block.split("\r\n"); let request_line = lines.next().unwrap_or(""); let (method, path) = parse_request_line(request_line)?; + let headers = parse_headers(lines)?; + if method == "GET" { + if matches!( + analysis_run_retry_parent_path_run_id(path), + Ok(_) | Err(ApiError::LimitExceeded) + ) { + refuse_live_headers(&headers, self.bound_addr, false)?; + return self.read_analysis_run_retry_parent(path, &headers, body); + } + return Err(ApiError::InvalidWirePayload); + } if method != "POST" { return Err(ApiError::InvalidWirePayload); } 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)?; + refuse_live_headers(&headers, self.bound_addr, true)?; self.dispatch_path(path, &headers, body) } @@ -246,12 +262,45 @@ impl NaruonLiveService { let run_id = format!("naruon-run-{}", self.next_run_serial); self.next_run_serial += 1; let accepted = - AnalysisRunAccepted::new(run_id, "accepted", request.idempotency_key.clone())?; + AnalysisRunAccepted::new(run_id.clone(), "accepted", request.idempotency_key.clone())?; let body = accepted.to_json()?; + self.runs_by_id.insert(run_id, replay_key.clone()); self.accepted_runs.insert(replay_key, (request, accepted)); Ok(NaruonLiveResponse::json(202, "Accepted", 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); + } + refuse_metrics_on_retry_parent_payload(body)?; + let replay_key = self + .runs_by_id + .get(&run_id) + .cloned() + .ok_or(ApiError::InvalidWirePayload)?; + let stored_accepted = &self + .accepted_runs + .get(&replay_key) + .ok_or(ApiError::InvalidWirePayload)? + .1; + let payload = AnalysisRunRetryParent::new( + stored_accepted.run_id.clone(), + AnalysisRunStatusState::Accepted, + stored_accepted.idempotency_key.clone(), + None, + )?; + let response_body = payload.to_json()?; + refuse_metrics_on_retry_parent_payload(&response_body)?; + Ok(NaruonLiveResponse::json(200, "OK", response_body)) + } + fn authorize_export( headers: &HashMap, body: &str, @@ -326,6 +375,7 @@ fn status_for(error: ApiError) -> (u16, &'static str) { fn refuse_live_headers( headers: &HashMap, bound_addr: Option, + require_idempotency: bool, ) -> Result<(), ApiError> { validate_common_headers(headers, bound_addr)?; if header_value(headers, "tepp-consumer")? != NARUON_CONSUMER_CODE { @@ -334,7 +384,9 @@ fn refuse_live_headers( if header_value(headers, "tepp-contract-version")? != "1" { return Err(ApiError::InvalidWirePayload); } - let _idempotency_key = header_value(headers, "idempotency-key")?; + if require_idempotency { + let _idempotency_key = header_value(headers, "idempotency-key")?; + } Ok(()) } @@ -531,4 +583,95 @@ mod tests { ApiError::InvalidWirePayload ); } + + #[test] + #[allow(clippy::too_many_lines)] + fn naruon_compatibility_listener_inspects_retry_parent_as_null() { + use crate::{ANALYSIS_RUN_CONTRACT_VERSION, AnalysisRunRequest, AnalysisRunRetryParent}; + + let run = AnalysisRunRequest { + contract_version: ANALYSIS_RUN_CONTRACT_VERSION, + idempotency_key: "naruon-parent-idem".into(), + tenant_workspace_id: "naruon-parent-tenant".into(), + snapshot_id: "naruon-parent-snapshot".into(), + knowledge_cutoff: "2026-08-01T00:00:00Z".into(), + model_contract_version: "tepp-analysis-run-v1".into(), + output_profile: "calibrated_event_measurement".into(), + }; + let body = run.to_json().expect("run json"); + let create = 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-parent-idem\r\ncontent-length: {}\r\n\r\n{body}", + body.len() + ); + let mut service = NaruonLiveService::new(); + let accepted = service.handle_http_request(&create); + assert_eq!(accepted.status_code, 202); + let run_id = serde_json::from_str::(&accepted.body) + .expect("accepted json")["run_id"] + .as_str() + .expect("run_id") + .to_owned(); + + let inspect = format!( + "GET /v1/analysis-runs/{run_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" + ); + let inspected = service.handle_http_request(&inspect); + assert_eq!(inspected.status_code, 200); + let parent = AnalysisRunRetryParent::from_json(&inspected.body).expect("parent"); + assert_eq!(parent.run_id, run_id); + assert_eq!(parent.run_state, crate::AnalysisRunStatusState::Accepted); + assert_eq!(parent.idempotency_key, run.idempotency_key); + assert_eq!(parent.parent, None); + assert!(inspected.body.contains("\"parent\":null")); + assert!(!inspected.body.contains("rmse")); + assert!(!inspected.body.contains("scientific_acceptance")); + assert!(!inspected.body.contains("tenant_workspace_id")); + assert!(!inspected.body.contains("snapshot_id")); + assert!(!inspected.body.contains("retried_from")); + + let replay = service.handle_http_request(&inspect); + assert_eq!(replay.body, inspected.body); + + let lineageweave = format!( + "GET /v1/analysis-runs/{run_id}/parent HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: lineageweave\r\ntepp-contract-version: 1\r\ncontent-length: 0\r\n\r\n" + ); + assert_eq!(service.handle_http_request(&lineageweave).status_code, 400); + assert_eq!( + service + .handle_http_request( + "GET /v1/analysis-runs/missing/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 /v1/analysis-runs/{run_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 + ); + assert_eq!( + service + .handle_http_request(&format!( + "POST /v1/analysis-runs/{run_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\nidempotency-key: naruon-parent-idem\r\ncontent-length: 0\r\n\r\n" + )) + .status_code, + 400 + ); + let oversized = "a".repeat(129); + assert_eq!( + service + .handle_http_request(&format!( + "GET /v1/analysis-runs/{oversized}/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, + 413 + ); + let metrics = format!( + "GET /v1/analysis-runs/{run_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: 16\r\n\r\n{{\"rmse\":0.1}}" + ); + assert_eq!(service.handle_http_request(&metrics).status_code, 400); + } } diff --git a/crates/tepp_api/tests/lineageweave_http_contract.rs b/crates/tepp_api/tests/lineageweave_http_contract.rs index 19b3e352e..910e3067e 100644 --- a/crates/tepp_api/tests/lineageweave_http_contract.rs +++ b/crates/tepp_api/tests/lineageweave_http_contract.rs @@ -7,9 +7,11 @@ use std::thread; use std::time::Duration; use tepp_api::{ - ANALYSIS_RUN_CONTRACT_VERSION, AnalysisRunAccepted, AnalysisRunLiveService, AnalysisRunRequest, + ANALYSIS_RUN_CONTRACT_VERSION, AnalysisRunAccepted, AnalysisRunCancelRequest, + AnalysisRunLiveService, AnalysisRunRequest, AnalysisRunRetryParent, 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_parent_exchange, }; fn sample_run() -> AnalysisRunRequest { @@ -153,3 +155,103 @@ fn live_listener_serves_lineageweave_over_loopback() { 202 ); } + +#[test] +fn lineageweave_retry_parent_exchange_uses_the_published_consumer_header_without_credentials() { + let exchange = lineageweave_analysis_run_retry_parent_exchange( + "https://tepp.example.test", + "tepp-run-lineage", + ) + .expect("lineageweave retry-parent exchange"); + assert_eq!(exchange.method, "GET"); + assert_eq!( + exchange.target_url, + "https://tepp.example.test/v1/analysis-runs/tepp-run-lineage/parent" + ); + assert!(exchange.body.is_empty()); + assert!( + exchange + .headers + .contains(&("tepp-consumer".into(), LINEAGEWEAVE_CONSUMER_CODE.into())) + ); + assert!( + !exchange + .headers + .contains(&("tepp-consumer".into(), NARUON_CONSUMER_CODE.into())) + ); + assert!(exchange.headers.iter().all(|(name, _)| { + !matches!( + name.to_ascii_lowercase().as_str(), + "authorization" | "proxy-authorization" | "cookie" | "x-api-key" | "idempotency-key" + ) + })); +} + +#[test] +fn live_listener_inspects_lineageweave_retry_parent_and_isolates_consumers() { + let run = sample_run(); + let mut service = AnalysisRunLiveService::new(); + let lineageweave = service.handle_http_request(&http_request(LINEAGEWEAVE_CONSUMER_CODE, &run)); + assert_eq!(lineageweave.status_code, 202); + let accepted = AnalysisRunAccepted::from_json(&lineageweave.body).expect("accepted"); + let inspect = format!( + "GET {NARUON_ANALYSIS_RUN_PATH}/{}/parent HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: {LINEAGEWEAVE_CONSUMER_CODE}\r\ntepp-contract-version: 1\r\ncontent-length: 0\r\n\r\n", + accepted.run_id + ); + let inspected = service.handle_http_request(&inspect); + assert_eq!(inspected.status_code, 200); + let parent = AnalysisRunRetryParent::from_json(&inspected.body).expect("parent"); + assert_eq!(parent.run_id, accepted.run_id); + assert_eq!(parent.parent, None); + assert!(inspected.body.contains("\"parent\":null")); + assert!(!inspected.body.contains("rmse")); + assert!(!inspected.body.contains("scientific_acceptance")); + let naruon_inspect = format!( + "GET {NARUON_ANALYSIS_RUN_PATH}/{}/parent HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: {NARUON_CONSUMER_CODE}\r\ntepp-contract-version: 1\r\ncontent-length: 0\r\n\r\n", + accepted.run_id + ); + assert_eq!( + service.handle_http_request(&naruon_inspect).status_code, + 400 + ); + + let cancel_body = AnalysisRunCancelRequest::new(&accepted.run_id, run.idempotency_key.as_str()) + .expect("cancel dto") + .to_json() + .expect("cancel json"); + let cancel = format!( + "POST {NARUON_ANALYSIS_RUN_PATH}/{}/cancel HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: {LINEAGEWEAVE_CONSUMER_CODE}\r\ntepp-contract-version: 1\r\nidempotency-key: {}\r\ncontent-length: {}\r\n\r\n{cancel_body}", + accepted.run_id, + run.idempotency_key, + cancel_body.len() + ); + assert_eq!(service.handle_http_request(&cancel).status_code, 200); + let retry_key = "lineageweave-retry-parent-idem"; + let retry_body = AnalysisRunRetryRequest::new(&accepted.run_id, retry_key) + .expect("retry dto") + .to_json() + .expect("retry json"); + let 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: {LINEAGEWEAVE_CONSUMER_CODE}\r\ntepp-contract-version: 1\r\nidempotency-key: {retry_key}\r\ncontent-length: {}\r\n\r\n{retry_body}", + accepted.run_id, + retry_body.len() + ); + let retried = service.handle_http_request(&retry); + assert_eq!(retried.status_code, 202); + let child = AnalysisRunAccepted::from_json(&retried.body).expect("child"); + let child_inspect = format!( + "GET {NARUON_ANALYSIS_RUN_PATH}/{}/parent HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: {LINEAGEWEAVE_CONSUMER_CODE}\r\ntepp-contract-version: 1\r\ncontent-length: 0\r\n\r\n", + child.run_id + ); + let child_parent = service.handle_http_request(&child_inspect); + assert_eq!(child_parent.status_code, 200); + let payload = AnalysisRunRetryParent::from_json(&child_parent.body).expect("child parent"); + assert_eq!(payload.run_id, child.run_id); + let parent_item = payload.parent.expect("non-null parent"); + assert_eq!(parent_item.run_id, accepted.run_id); + assert!(!child_parent.body.contains("rmse")); + assert!(!child_parent.body.contains("scientific_acceptance")); + assert!(!child_parent.body.contains("tenant_workspace_id")); + assert!(!child_parent.body.contains("snapshot_id")); + assert!(!child_parent.body.contains("retried_from")); +} diff --git a/crates/tepp_api/tests/loopback_binary_contract.rs b/crates/tepp_api/tests/loopback_binary_contract.rs index 20e475647..2427089f5 100644 --- a/crates/tepp_api/tests/loopback_binary_contract.rs +++ b/crates/tepp_api/tests/loopback_binary_contract.rs @@ -29,3 +29,82 @@ fn binary_serves_one_bounded_temporal_context_request() { assert!(response.contains("association_not_causal")); assert!(child.wait().expect("wait").success()); } + +#[test] +fn binary_inspects_a_retry_parent_over_tcp_after_cancel_and_retry() { + let mut child = Command::new(env!("CARGO_BIN_EXE_tepp-loopback")) + .args(["127.0.0.1:0", "4"]) + .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-parent-idem","tenant_workspace_id":"loopback-parent-tenant","snapshot_id":"loopback-parent-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-parent-idem\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 created = String::new(); + stream.read_to_string(&mut created).expect("created"); + assert!(created.starts_with("HTTP/1.1 202 Accepted")); + let json_start = created.find("{\"contract_version\"").expect("json"); + let accepted: serde_json::Value = + serde_json::from_str(&created[json_start..]).expect("accepted json"); + let run_id = accepted["run_id"].as_str().expect("run_id"); + assert!(!created[json_start..].contains("rmse")); + + let cancel_body = format!( + r#"{{"contract_version":1,"run_id":"{run_id}","idempotency_key":"loopback-parent-idem"}}"# + ); + 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-parent-idem\r\ncontent-length: {}\r\n\r\n{cancel_body}", + cancel_body.len() + ); + 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")); + + let retry_body = format!( + r#"{{"contract_version":1,"run_id":"{run_id}","idempotency_key":"loopback-parent-retry-idem"}}"# + ); + 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-parent-retry-idem\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")); + let retry_json = retried.find("{\"contract_version\"").expect("retry json"); + let child_accepted: serde_json::Value = + serde_json::from_str(&retried[retry_json..]).expect("child json"); + let child_id = child_accepted["run_id"].as_str().expect("child run_id"); + assert_ne!(child_id, run_id); + + let inspect = format!( + "GET /v1/analysis-runs/{child_id}/parent HTTP/1.1\r\nHost: {host}\r\ncontent-type: application/json\r\ntepp-consumer: lineageweave\r\ntepp-contract-version: 1\r\ncontent-length: 0\r\n\r\n" + ); + let mut stream = TcpStream::connect(host).expect("connect parent"); + stream.write_all(inspect.as_bytes()).expect("inspect"); + let mut inspected = String::new(); + stream.read_to_string(&mut inspected).expect("inspected"); + assert!(inspected.starts_with("HTTP/1.1 200 OK")); + assert!(inspected.contains(&format!("\"run_id\":\"{child_id}\""))); + assert!(inspected.contains(&format!("\"run_id\":\"{run_id}\""))); + assert!(inspected.contains("\"parent\":{") || inspected.contains("\"parent\": {")); + assert!(!inspected.contains("\"parent\":null")); + assert!(!inspected.contains("rmse")); + assert!(!inspected.contains("scientific_acceptance")); + assert!(!inspected.contains("tenant_workspace_id")); + assert!(!inspected.contains("snapshot_id")); + assert!(!inspected.contains("retried_from")); + 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..34b9f382f 100644 --- a/crates/tepp_api/tests/naruon_live_http_contract.rs +++ b/crates/tepp_api/tests/naruon_live_http_contract.rs @@ -7,11 +7,11 @@ use std::thread; use std::time::{Duration, Instant}; use tepp_api::{ - ANALYSIS_RUN_CONTRACT_VERSION, AnalysisRunAccepted, AnalysisRunRequest, AnalyticalPurpose, - ApiError, DEFAULT_ANALYSIS_RUN_BYTE_LIMIT, ErrorEnvelope, ExportAuthorizationRequest, - NARUON_ANALYSIS_RUN_PATH, NARUON_EXPORT_PATH, NARUON_LIVE_HEADER_BYTE_LIMIT, - NARUON_LIVE_HEADER_COUNT_LIMIT, NARUON_LIVE_IO_TIMEOUT, NaruonLiveService, - naruon_analysis_run_exchange, naruon_export_exchange, + ANALYSIS_RUN_CONTRACT_VERSION, AnalysisRunAccepted, AnalysisRunRequest, AnalysisRunRetryParent, + AnalyticalPurpose, ApiError, DEFAULT_ANALYSIS_RUN_BYTE_LIMIT, ErrorEnvelope, + ExportAuthorizationRequest, NARUON_ANALYSIS_RUN_PATH, NARUON_EXPORT_PATH, + NARUON_LIVE_HEADER_BYTE_LIMIT, NARUON_LIVE_HEADER_COUNT_LIMIT, NARUON_LIVE_IO_TIMEOUT, + NaruonLiveService, naruon_analysis_run_exchange, naruon_export_exchange, }; fn sample_run() -> AnalysisRunRequest { @@ -719,6 +719,46 @@ fn serve_one_maps_partial_request_timeout_to_limit_exceeded() { assert_eq!(envelope(&served.body).error_code(), "limit_exceeded"); } +#[test] +fn handle_http_inspects_naruon_retry_parent_and_refuses_lineageweave() { + 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 accepted = AnalysisRunAccepted::from_json(&created.body).expect("accepted"); + let inspect = http_request( + "GET", + &format!("{NARUON_ANALYSIS_RUN_PATH}/{}/parent", accepted.run_id), + &[ + ("Host".into(), "127.0.0.1".into()), + ("content-type".into(), "application/json".into()), + ("tepp-consumer".into(), "naruon".into()), + ("tepp-contract-version".into(), "1".into()), + ], + "", + ); + let inspected = service.handle_http_request(&inspect); + assert_eq!(inspected.status_code, 200); + let parent = AnalysisRunRetryParent::from_json(&inspected.body).expect("parent"); + assert_eq!(parent.run_id, accepted.run_id); + assert_eq!(parent.parent, None); + assert!(inspected.body.contains("\"parent\":null")); + assert!(!inspected.body.contains("rmse")); + assert!(!inspected.body.contains("scientific_acceptance")); + let lineageweave = http_request( + "GET", + &format!("{NARUON_ANALYSIS_RUN_PATH}/{}/parent", accepted.run_id), + &[ + ("Host".into(), "127.0.0.1".into()), + ("content-type".into(), "application/json".into()), + ("tepp-consumer".into(), "lineageweave".into()), + ("tepp-contract-version".into(), "1".into()), + ], + "", + ); + assert_eq!(service.handle_http_request(&lineageweave).status_code, 400); +} + struct TimeoutRead; impl Read for TimeoutRead { diff --git a/docs/API_CONTRACT.md b/docs/API_CONTRACT.md index 4aef8e141..ec8b4934d 100644 --- a/docs/API_CONTRACT.md +++ b/docs/API_CONTRACT.md @@ -104,7 +104,10 @@ that used that key so operators can jump from a 202 receipt or retry child 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 +(never-retried) runs return `"parent": null`. `lineageweave_analysis_run_retry_parent_exchange` +builds the same GET for LineageWeave. `NaruonLiveService` serves retry-parent GET +for Naruon only and returns `"parent": null` on accepted creates; LineageWeave +remains refused on that compatibility listener. GET-by-id remains a later slice on this protected-main lineage. The stacked `analysis_engine` slice provides the first executable service-side diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 51bdad9a2..00b3f3e4f 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -60,6 +60,7 @@ The full APA 7th standards/literature register remains `docs/research/standards- | 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 | +| loopback analysis-run retry-parent consumer parity | ADR 0044; API contract; RFC 9110 | `tepp_api` LineageWeave retry-parent exchange, Naruon compatibility-listener inspect (`"parent": null` on accepted creates), and `tepp-loopback` TCP create-cancel-retry-inspect; LineageWeave remains refused on `NaruonLiveService` | 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/0044-analysis-run-retry-parent-consumer-parity.md b/docs/adr/0044-analysis-run-retry-parent-consumer-parity.md new file mode 100644 index 000000000..693b68f93 --- /dev/null +++ b/docs/adr/0044-analysis-run-retry-parent-consumer-parity.md @@ -0,0 +1,72 @@ +# ADR 0044 — Analysis-run retry-parent consumer parity + +**Decision status:** Accepted +**Implementation maturity:** active-PR +**Date:** 2026-08-31 +**Supersedes:** None; complements ADR 0038 and ADR 0018. Does not supersede ADR 0014. ADR 0026–0043 remain on other live PRs (0043 is the retry CLI). This ADR number is unique on the retry-parent GET lineage. + +## Context + +ADR 0038 added `GET /v1/analysis-runs/{run_id}/parent` on `AnalysisRunLiveService` and a Naruon retry-parent exchange builder. The Naruon compatibility listener (`NaruonLiveService`) still refused every GET path. `LineageWeave` had a create-exchange builder but no retry-parent exchange, so a published consumer would have to mint a Naruon-labelled inspect. The packaged `tepp-loopback` binary had no TCP proof that retry-parent GET works on the shared listener after cancel and retry. + +Duplicating the retry-parent DTO, GET status, lifecycle POST, collection GET, retry POST, retry-lineage, stored-request, or engine-library slices would not close this consumer-parity gap. Adding retry to `NaruonLiveService` would duplicate ADR 0032 / #369. + +## Decision + +- `lineageweave_analysis_run_retry_parent_exchange` reuses the Naruon retry-parent builder and replaces only `tepp-consumer`. +- `NaruonLiveService` serves the same metric-free retry-parent GET for the Naruon-only compatibility listener. Accepted creates return `"parent": null` because that listener does not retry. LineageWeave consumers remain refused there; they use `AnalysisRunLiveService`. +- `tepp-loopback` proves create-then-cancel-then-retry-then-inspect over loopback TCP so a non-null parent is observable. +- Retry-parent payloads stay metric-free. Unknown runs, consumer mismatch, nonempty bodies, and metric keys still fail closed. + +## Non-goals + +- GET status, running/terminal POST, collection GET, retry POST, retry-lineage, persistence, or production TLS. +- Opening `NaruonLiveService` to LineageWeave. +- Adding retry to `NaruonLiveService`. +- An ADR 0014 scientific claim. + +## Alternatives considered + +1. **Leave retry-parent 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-parent DTO** — rejected as a duplicate of ADR 0038. +4. **Add retry to `NaruonLiveService` so parent can be non-null there** — rejected as a duplicate of ADR 0032. +5. **Consumer-parity retry-parent on the existing typed inspect** — accepted. + +## Consequences + +- Both published consumers can build a credential-free retry-parent GET. +- Naruon local proofs can inspect `"parent": null` on accepted creates on either listener. +- Operators can observe a non-null parent through `tepp-loopback` after cancel and retry without a second HTTP stack. + +## Failure and recovery + +Unknown runs, consumer mismatch, nonempty bodies, metric keys, 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-parent remains loopback-only and metric-free. +- HTTP `200` inspect is not measurement or release evidence. + +## Compatibility and migration + +ADR 0038 create/inspect semantics are unchanged. Production adapters may replace loopback while preserving consumer identity, metric-free retry-parent fields, and Naruon-only compatibility-listener admission. + +## Verification + +- LineageWeave retry-parent exchange carries `tepp-consumer: lineageweave` and no credentials; +- NaruonLiveService inspects accepted Naruon runs as `"parent": null` and refuses LineageWeave, metrics, nonempty bodies, and unknown runs; +- `tepp-loopback` create-cancel-retry-inspect over TCP returns a metric-free non-null parent; +- Clippy `-D warnings`, `tepp_api` tests, rustdoc, and exact-head review remain required. + +## Rollback and supersession + +Rollback removes the LineageWeave builder, compatibility-listener inspect, and binary TCP proof; ADR 0038 shared-listener retry-parent GET remains. A superseding ADR is required to persist inspect, bind a public address, add retry to `NaruonLiveService`, or treat HTTP success as an ADR 0014 claim. + +## Related authority + +- ADR 0038 owns the shared-listener retry-parent GET path and metric-free inspect fields. +- ADR 0018 owns consumer-scoped ingress. +- ADR 0011 owns standalone/modular HTTP boundaries. +- ADR 0014 owns scientific claim promotion. diff --git a/docs/adr/README.md b/docs/adr/README.md index 414e43f7e..0e7c7df2a 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -37,6 +37,7 @@ Read [`ADR_POLICY.md`](ADR_POLICY.md) first. **Decision status and implementatio | [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. | +| [0044](0044-analysis-run-retry-parent-consumer-parity.md) | LineageWeave and Naruon compatibility-listener retry-parent GET | Accepted | active-PR | Complements ADR 0038/0018; does not supersede ADR 0014. ADR 0026–0043 live on other 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. | @@ -154,6 +155,7 @@ Use the narrowest owning ADR when decisions overlap: - **analysis-run retry-lineage GET:** ADR 0035. - **analysis-run idempotency-key lookup GET:** ADR 0037. - **analysis-run retry-parent GET:** ADR 0038. +- **analysis-run retry-parent consumer parity:** ADR 0044. ## Change and supersession rule diff --git a/docs/connectors/naruon-artifact-consumer.md b/docs/connectors/naruon-artifact-consumer.md index 5bdc328a1..b3c361c04 100644 --- a/docs/connectors/naruon-artifact-consumer.md +++ b/docs/connectors/naruon-artifact-consumer.md @@ -28,8 +28,10 @@ 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-parent | `tepp_api` `naruon_analysis_run_retry_parent_exchange` → `GET /v1/analysis-runs/{run_id}/parent` | 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 retry-parent GET | `tepp_api` `NaruonLiveService` → `GET /v1/analysis-runs/{run_id}/parent` (Naruon only; `"parent": null` on accepted creates) | naruon → TEPP | Committed examples live under `examples/`. Schemas for analysis-run requests and corpus-split manifests live under `schemas/`. @@ -53,6 +55,7 @@ When naruon requests an export, TEPP evaluates `AnalyticalPurpose::ModularServic - lexical method codes (`tfidf`, `bm25`, `keyword`) claiming TEPP inference → reject; - scientific-metric keys (`rmse`, `bias`, `coverage`, `se_gate`, `scientific_acceptance`, `report`) on a cancel body → reject; - scientific-metric keys (`rmse`, `bias`, `coverage`, `se_gate`, `scientific_acceptance`, `report`, `terminal_result`) on a collection body → reject; +- scientific-metric keys (`rmse`, `bias`, `coverage`, `se_gate`, `scientific_acceptance`, `report`, `terminal_result`, `tenant_workspace_id`, `snapshot_id`, `retried_from`) on a retry-parent body → reject; - cancel of a succeeded, failed, or unknown analysis run → reject. ## Authority sources diff --git a/docs/research/analysis-run-retry-parent-consumer-parity.md b/docs/research/analysis-run-retry-parent-consumer-parity.md new file mode 100644 index 000000000..42ef30177 --- /dev/null +++ b/docs/research/analysis-run-retry-parent-consumer-parity.md @@ -0,0 +1,43 @@ +# Analysis-run retry-parent consumer parity (doctoring) + +## Scope + +`LineageWeave` and the Naruon compatibility listener must be able to inspect +the metric-free parent of a listed 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 0038; ADR 0044), not an RFC inference rule. + +This slice does not serve GET status, running/terminal POST, collection GET, +retry POST, retry-lineage, or persistence. `NaruonLiveService` does not retry; +accepted creates return `"parent": null`. + +## 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/0038-analysis-run-retry-parent-get.md` — shared-listener inspect +- `docs/adr/0044-analysis-run-retry-parent-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-parent exchange sets only the published consumer header; +- NaruonLiveService inspects accepted Naruon runs as `"parent": null` and refuses LineageWeave; +- retry-parent JSON has no RMSE/bias/coverage/SE-gate/scientific-acceptance keys; +- `tepp-loopback` create-cancel-retry-inspect over TCP returns `200` with a non-null parent. + +## Non-claims + +This slice does not implement GET status, lifecycle POST, persistence, +production TLS, Naruon-listener retry, or an ADR 0014 scientific claim.