From 0542042ca05ce1f5976d07ae568c62579a95ece7 Mon Sep 17 00:00:00 2001 From: seonghobae Date: Mon, 31 Aug 2026 15:53:31 +0000 Subject: [PATCH] feat(api): inspect analysis-run retry children from LineageWeave and Naruon LineageWeave can GET /v1/analysis-runs/{run_id}/retries without a Naruon-labelled exchange. NaruonLiveService serves the same metric-free inspect for Naruon only and returns an empty retries array on accepted creates. tepp-loopback proves a non-empty child list over TCP after create, cancel, and retry (ADR 0045). --- ...lysis-run-retry-lineage-consumer-parity.md | 1 + CHANGELOG.md | 2 + DOCUMENTATION.md | 1 + crates/tepp_api/src/lib.rs | 2 + crates/tepp_api/src/lineageweave_http.rs | 67 +++++++- crates/tepp_api/src/naruon_live.rs | 156 +++++++++++++++++- .../tests/lineageweave_http_contract.rs | 98 ++++++++++- .../tests/loopback_binary_contract.rs | 77 +++++++++ .../tests/naruon_live_http_contract.rs | 49 +++++- docs/API_CONTRACT.md | 5 +- docs/TRACEABILITY.md | 1 + ...lysis-run-retry-lineage-consumer-parity.md | 72 ++++++++ docs/adr/README.md | 2 + docs/connectors/naruon-artifact-consumer.md | 3 + ...lysis-run-retry-lineage-consumer-parity.md | 43 +++++ 15 files changed, 565 insertions(+), 14 deletions(-) create mode 100644 CHANGELOG.d/analysis-run-retry-lineage-consumer-parity.md create mode 100644 docs/adr/0045-analysis-run-retry-lineage-consumer-parity.md create mode 100644 docs/research/analysis-run-retry-lineage-consumer-parity.md diff --git a/CHANGELOG.d/analysis-run-retry-lineage-consumer-parity.md b/CHANGELOG.d/analysis-run-retry-lineage-consumer-parity.md new file mode 100644 index 000000000..9109acf2a --- /dev/null +++ b/CHANGELOG.d/analysis-run-retry-lineage-consumer-parity.md @@ -0,0 +1 @@ +- `tepp_api` adds `lineageweave_analysis_run_retry_lineage_exchange`, Naruon compatibility-listener retry-lineage GET (empty `retries` on accepted creates), and a `tepp-loopback` TCP create-cancel-retry-inspect proof (ADR 0045). Metric-free retry-lineage fields are unchanged from ADR 0035. Not GET status, not lifecycle POST, not an ADR 0014 claim. diff --git a/CHANGELOG.md b/CHANGELOG.md index b55a4f9c4..3b15763e4 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-lineage consumer parity: LineageWeave GET-retries exchange, Naruon compatibility-listener inspect (empty `retries` on accepted creates), and `tepp-loopback` TCP create-cancel-retry-inspect proof (ADR 0045). + - `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 5a3a59cc0..50585bac6 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -18,6 +18,7 @@ TEPP's approved PRD v0.4 and implementation plan are the primary product baselin | Analysis-run retry HTTP doctoring | [`docs/research/analysis-run-retry-http.md`](docs/research/analysis-run-retry-http.md) | | Analysis-run stored-request HTTP doctoring | [`docs/research/analysis-run-stored-request-http.md`](docs/research/analysis-run-stored-request-http.md) | | Analysis-run retry-lineage HTTP doctoring | [`docs/research/analysis-run-retry-lineage-http.md`](docs/research/analysis-run-retry-lineage-http.md) | +| Analysis-run retry-lineage consumer-parity doctoring | [`docs/research/analysis-run-retry-lineage-consumer-parity.md`](docs/research/analysis-run-retry-lineage-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 8dcc16038..f905a280f 100644 --- a/crates/tepp_api/src/lib.rs +++ b/crates/tepp_api/src/lib.rs @@ -215,6 +215,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-lineage GET without provider credentials. +pub use lineageweave_http::lineageweave_analysis_run_retry_lineage_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..2d814cb32 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_lineage_http::naruon_analysis_run_retry_lineage_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-lineage 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 direct retry children, not a measurement result. +/// +/// # Errors +/// +/// Returns the same fail-closed errors as [`naruon_analysis_run_retry_lineage_exchange`]. +pub fn lineageweave_analysis_run_retry_lineage_exchange( + origin: &str, + run_id: &str, +) -> Result { + let mut exchange = naruon_analysis_run_retry_lineage_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_lineage_exchange, }; use crate::{ANALYSIS_RUN_CONTRACT_VERSION, AnalysisRunRequest, ApiError}; @@ -132,4 +156,45 @@ mod tests { Err(ApiError::InvalidWirePayload) ); } + #[test] + fn lineageweave_retry_lineage_exchange_swaps_only_the_consumer_header() { + let exchange = lineageweave_analysis_run_retry_lineage_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/retries" + ); + 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_lineage_exchange( + "http://tepp.example.test", + "tepp-run-1" + ), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + lineageweave_analysis_run_retry_lineage_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..726605177 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_lineage_http::{ + AnalysisRunRetryLineage, analysis_run_retry_lineage_path_run_id, + refuse_metrics_on_retry_lineage_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-lineage 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_lineage_path_run_id(path), + Ok(_) | Err(ApiError::LimitExceeded) + ) { + refuse_live_headers(&headers, self.bound_addr, false)?; + return self.list_analysis_run_retries(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 list_analysis_run_retries( + &self, + path: &str, + _headers: &HashMap, + body: &str, + ) -> Result { + let run_id = analysis_run_retry_lineage_path_run_id(path)?; + if !body.trim().is_empty() { + return Err(ApiError::InvalidWirePayload); + } + refuse_metrics_on_retry_lineage_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 = AnalysisRunRetryLineage::new( + stored_accepted.run_id.clone(), + AnalysisRunStatusState::Accepted, + stored_accepted.idempotency_key.clone(), + Vec::new(), + )?; + let response_body = payload.to_json()?; + refuse_metrics_on_retry_lineage_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,96 @@ mod tests { ApiError::InvalidWirePayload ); } + + #[test] + #[allow(clippy::too_many_lines)] + fn naruon_compatibility_listener_lists_empty_retry_lineage() { + use crate::{ANALYSIS_RUN_CONTRACT_VERSION, AnalysisRunRequest, AnalysisRunRetryLineage}; + + let run = AnalysisRunRequest { + contract_version: ANALYSIS_RUN_CONTRACT_VERSION, + idempotency_key: "naruon-retries-idem".into(), + tenant_workspace_id: "naruon-retries-tenant".into(), + snapshot_id: "naruon-retries-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-retries-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}/retries 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 lineage = AnalysisRunRetryLineage::from_json(&inspected.body).expect("lineage"); + assert_eq!(lineage.run_id, run_id); + assert_eq!(lineage.run_state, crate::AnalysisRunStatusState::Accepted); + assert_eq!(lineage.idempotency_key, run.idempotency_key); + assert!(lineage.retries.is_empty()); + assert!( + inspected.body.contains("\"retries\":[]") || inspected.body.contains("\"retries\": []") + ); + assert!(!inspected.body.contains("rmse")); + assert!(!inspected.body.contains("scientific_acceptance")); + assert!(!inspected.body.contains("tenant_workspace_id")); + assert!(!inspected.body.contains("snapshot_id")); + + let replay = service.handle_http_request(&inspect); + assert_eq!(replay.body, inspected.body); + + let lineageweave = format!( + "GET /v1/analysis-runs/{run_id}/retries 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/retries 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}/retries 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}/retries 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-retries-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}/retries 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}/retries 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..85b2d9c25 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, AnalysisRunRetryLineage, 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_lineage_exchange, }; fn sample_run() -> AnalysisRunRequest { @@ -153,3 +155,97 @@ fn live_listener_serves_lineageweave_over_loopback() { 202 ); } + +#[test] +fn lineageweave_retry_lineage_exchange_uses_the_published_consumer_header_without_credentials() { + let exchange = lineageweave_analysis_run_retry_lineage_exchange( + "https://tepp.example.test", + "tepp-run-lineage", + ) + .expect("lineageweave retry-lineage exchange"); + assert_eq!(exchange.method, "GET"); + assert_eq!( + exchange.target_url, + "https://tepp.example.test/v1/analysis-runs/tepp-run-lineage/retries" + ); + 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_lineage_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}/{}/retries 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 lineage = AnalysisRunRetryLineage::from_json(&inspected.body).expect("lineage"); + assert_eq!(lineage.run_id, accepted.run_id); + assert!(lineage.retries.is_empty()); + assert!(!inspected.body.contains("rmse")); + assert!(!inspected.body.contains("scientific_acceptance")); + let naruon_inspect = format!( + "GET {NARUON_ANALYSIS_RUN_PATH}/{}/retries 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-lineage-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 parent_inspect = service.handle_http_request(&inspect); + assert_eq!(parent_inspect.status_code, 200); + let payload = AnalysisRunRetryLineage::from_json(&parent_inspect.body).expect("children"); + assert_eq!(payload.run_id, accepted.run_id); + assert_eq!(payload.retries.len(), 1); + assert_eq!(payload.retries[0].run_id, child.run_id); + assert!(!parent_inspect.body.contains("rmse")); + assert!(!parent_inspect.body.contains("scientific_acceptance")); + assert!(!parent_inspect.body.contains("tenant_workspace_id")); + assert!(!parent_inspect.body.contains("snapshot_id")); +} diff --git a/crates/tepp_api/tests/loopback_binary_contract.rs b/crates/tepp_api/tests/loopback_binary_contract.rs index 20e475647..8a50bc892 100644 --- a/crates/tepp_api/tests/loopback_binary_contract.rs +++ b/crates/tepp_api/tests/loopback_binary_contract.rs @@ -29,3 +29,80 @@ fn binary_serves_one_bounded_temporal_context_request() { assert!(response.contains("association_not_causal")); assert!(child.wait().expect("wait").success()); } + +#[test] +fn binary_lists_retry_children_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-retries-idem","tenant_workspace_id":"loopback-retries-tenant","snapshot_id":"loopback-retries-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-retries-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-retries-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-retries-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-retries-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-retries-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/{run_id}/retries 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 retries"); + 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\":\"{run_id}\""))); + assert!(inspected.contains(&format!("\"run_id\":\"{child_id}\""))); + assert!(inspected.contains("\"retries\":[") || inspected.contains("\"retries\": [")); + assert!(!inspected.contains("rmse")); + assert!(!inspected.contains("scientific_acceptance")); + assert!(!inspected.contains("tenant_workspace_id")); + assert!(!inspected.contains("snapshot_id")); + 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..729ccfd1a 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, + AnalysisRunRetryLineage, 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,45 @@ fn serve_one_maps_partial_request_timeout_to_limit_exceeded() { assert_eq!(envelope(&served.body).error_code(), "limit_exceeded"); } +#[test] +fn handle_http_lists_naruon_retry_lineage_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}/{}/retries", 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 lineage = AnalysisRunRetryLineage::from_json(&inspected.body).expect("lineage"); + assert_eq!(lineage.run_id, accepted.run_id); + assert!(lineage.retries.is_empty()); + assert!(!inspected.body.contains("rmse")); + assert!(!inspected.body.contains("scientific_acceptance")); + let lineageweave = http_request( + "GET", + &format!("{NARUON_ANALYSIS_RUN_PATH}/{}/retries", 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 7a40b9d1b..94b229614 100644 --- a/docs/API_CONTRACT.md +++ b/docs/API_CONTRACT.md @@ -96,7 +96,10 @@ returns metric-free stored create fields (`snapshot_id`, `knowledge_cutoff`, run before retry. `GET /v1/analysis-runs/{run_id}/retries` on the loopback listener returns metric-free direct retry children of that parent so operators can inspect lineage after retry. An empty `retries` array is `200` when the -parent was never retried. GET-by-id remains a later slice on this protected-main +parent was never retried. `lineageweave_analysis_run_retry_lineage_exchange` +builds the same GET for LineageWeave. `NaruonLiveService` serves retry-lineage GET +for Naruon only and returns an empty `retries` array 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 1ba90ed29..26f005711 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -58,6 +58,7 @@ The full APA 7th standards/literature register remains `docs/research/standards- | 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 stored-request GET | ADR 0034; API contract; RFC 9110 | `tepp_api` `GET /v1/analysis-runs/{run_id}/request` on `AnalysisRunLiveService`: metric-free inspect of snapshot/cutoff/model/profile; collection GET lists identity only; GET-by-id remains a later slice | active-PR | | loopback analysis-run retry-lineage GET | ADR 0035; API contract; RFC 9110 | `tepp_api` `GET /v1/analysis-runs/{run_id}/retries` on `AnalysisRunLiveService`: metric-free direct retry children of a listed parent; empty `retries` when never retried; GET-by-id remains a later slice | active-PR | +| loopback analysis-run retry-lineage consumer parity | ADR 0045; API contract; RFC 9110 | `tepp_api` LineageWeave retry-lineage exchange, Naruon compatibility-listener inspect (empty `retries` 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/0045-analysis-run-retry-lineage-consumer-parity.md b/docs/adr/0045-analysis-run-retry-lineage-consumer-parity.md new file mode 100644 index 000000000..ca20b2248 --- /dev/null +++ b/docs/adr/0045-analysis-run-retry-lineage-consumer-parity.md @@ -0,0 +1,72 @@ +# ADR 0045 — Analysis-run retry-lineage consumer parity + +**Decision status:** Accepted +**Implementation maturity:** active-PR +**Date:** 2026-08-31 +**Supersedes:** None; complements ADR 0035 and ADR 0018. Does not supersede ADR 0014. ADR 0026–0044 remain on other live PRs (0044 is retry-parent consumer parity). This ADR number is unique on the retry-lineage GET lineage. + +## Context + +ADR 0035 added `GET /v1/analysis-runs/{run_id}/retries` on `AnalysisRunLiveService` and a Naruon retry-lineage exchange builder. The Naruon compatibility listener (`NaruonLiveService`) still refused every GET path. `LineageWeave` had a create-exchange builder but no retry-lineage exchange, so a published consumer would have to mint a Naruon-labelled inspect. The packaged `tepp-loopback` binary had no TCP proof that retry-lineage GET works on the shared listener after cancel and retry. + +Duplicating the retry-lineage DTO, GET status, lifecycle POST, collection GET, retry POST, stored-request, retry-parent, 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_lineage_exchange` reuses the Naruon retry-lineage builder and replaces only `tepp-consumer`. +- `NaruonLiveService` serves the same metric-free retry-lineage GET for the Naruon-only compatibility listener. Accepted creates return an empty `retries` array 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-empty child list is observable. +- Retry-lineage 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-parent, persistence, or production TLS. +- Opening `NaruonLiveService` to LineageWeave. +- Adding retry to `NaruonLiveService`. +- An ADR 0014 scientific claim. + +## Alternatives considered + +1. **Leave retry-lineage 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-lineage DTO** — rejected as a duplicate of ADR 0035. +4. **Add retry to `NaruonLiveService` so children can be non-empty there** — rejected as a duplicate of ADR 0032. +5. **Consumer-parity retry-lineage on the existing typed inspect** — accepted. + +## Consequences + +- Both published consumers can build a credential-free retry-lineage GET. +- Naruon local proofs can inspect an empty `retries` array on accepted creates on either listener. +- Operators can observe a non-empty child list 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-lineage remains loopback-only and metric-free. +- HTTP `200` inspect is not measurement or release evidence. + +## Compatibility and migration + +ADR 0035 create/inspect semantics are unchanged. Production adapters may replace loopback while preserving consumer identity, metric-free retry-lineage fields, and Naruon-only compatibility-listener admission. + +## Verification + +- LineageWeave retry-lineage exchange carries `tepp-consumer: lineageweave` and no credentials; +- NaruonLiveService inspects accepted Naruon runs as an empty `retries` array and refuses LineageWeave, metrics, nonempty bodies, and unknown runs; +- `tepp-loopback` create-cancel-retry-inspect over TCP returns a metric-free non-empty child list; +- 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 0035 shared-listener retry-lineage 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 0035 owns the shared-listener retry-lineage 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 c0a9d4eab..cfe86cacc 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -35,6 +35,7 @@ Read [`ADR_POLICY.md`](ADR_POLICY.md) first. **Decision status and implementatio | [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. | | [0034](0034-analysis-run-stored-request-get.md) | Loopback GET analysis-run stored-request is metric-free inspect | Accepted | active-PR | Complements ADR 0018/0031/0032; does not supersede ADR 0014. ADR 0026–0033 live on other GAP-003A PRs. | | [0035](0035-analysis-run-retry-lineage-get.md) | Loopback GET analysis-run retry-lineage is metric-free parent/child inspect | Accepted | active-PR | Complements ADR 0018/0031/0032/0034; does not supersede ADR 0014. ADR 0026–0034 live on other GAP-003A PRs. | +| [0045](0045-analysis-run-retry-lineage-consumer-parity.md) | LineageWeave and Naruon compatibility-listener retry-lineage GET | Accepted | active-PR | Complements ADR 0035/0018; does not supersede ADR 0014. ADR 0026–0044 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. | @@ -150,6 +151,7 @@ Use the narrowest owning ADR when decisions overlap: - **analysis-run retry HTTP:** ADR 0032. - **analysis-run stored-request GET:** ADR 0034. - **analysis-run retry-lineage GET:** ADR 0035. +- **analysis-run retry-lineage consumer parity:** ADR 0045. ## Change and supersession rule diff --git a/docs/connectors/naruon-artifact-consumer.md b/docs/connectors/naruon-artifact-consumer.md index 5bdc328a1..1346aa4c1 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-lineage | `tepp_api` `naruon_analysis_run_retry_lineage_exchange` → `GET /v1/analysis-runs/{run_id}/retries` | 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-lineage GET | `tepp_api` `NaruonLiveService` → `GET /v1/analysis-runs/{run_id}/retries` (Naruon only; empty `retries` 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`) on a retry-lineage body → reject; - cancel of a succeeded, failed, or unknown analysis run → reject. ## Authority sources diff --git a/docs/research/analysis-run-retry-lineage-consumer-parity.md b/docs/research/analysis-run-retry-lineage-consumer-parity.md new file mode 100644 index 000000000..b966d5f53 --- /dev/null +++ b/docs/research/analysis-run-retry-lineage-consumer-parity.md @@ -0,0 +1,43 @@ +# Analysis-run retry-lineage consumer parity (doctoring) + +## Scope + +`LineageWeave` and the Naruon compatibility listener must be able to inspect +the metric-free direct retry children 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 0035; ADR 0045), not an RFC inference rule. + +This slice does not serve GET status, running/terminal POST, collection GET, +retry POST, retry-parent, or persistence. `NaruonLiveService` does not retry; +accepted creates return an empty `retries` array. + +## 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/0035-analysis-run-retry-lineage-get.md` — shared-listener inspect +- `docs/adr/0045-analysis-run-retry-lineage-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-lineage exchange sets only the published consumer header; +- NaruonLiveService inspects accepted Naruon runs as an empty `retries` array and refuses LineageWeave; +- retry-lineage JSON has no RMSE/bias/coverage/SE-gate/scientific-acceptance keys; +- `tepp-loopback` create-cancel-retry-inspect over TCP returns `200` with a non-empty child list. + +## Non-claims + +This slice does not implement GET status, lifecycle POST, persistence, +production TLS, Naruon-listener retry, or an ADR 0014 scientific claim.