From 7034670efb5104479183fab92d913becec1db393 Mon Sep 17 00:00:00 2001 From: seonghobae Date: Mon, 31 Aug 2026 16:44:07 +0000 Subject: [PATCH] feat(api): resolve analysis-run identity from LineageWeave and Naruon LineageWeave can GET /v1/analysis-runs/by-idempotency/{key} without a Naruon-labelled exchange. NaruonLiveService serves the same metric-free inspect for Naruon only and returns a real run_id on accepted creates. tepp-loopback proves create-then-GET over TCP (ADR 0047). --- ...-run-idempotency-lookup-consumer-parity.md | 1 + CHANGELOG.md | 2 + DOCUMENTATION.md | 1 + crates/tepp_api/src/lib.rs | 2 + crates/tepp_api/src/lineageweave_http.rs | 72 ++++++++- crates/tepp_api/src/naruon_live.rs | 143 +++++++++++++++++- .../tests/lineageweave_http_contract.rs | 71 ++++++++- .../tests/loopback_binary_contract.rs | 45 ++++++ .../tests/naruon_live_http_contract.rs | 37 ++++- docs/API_CONTRACT.md | 2 +- docs/TRACEABILITY.md | 1 + ...-run-idempotency-lookup-consumer-parity.md | 70 +++++++++ docs/adr/README.md | 2 + docs/connectors/naruon-artifact-consumer.md | 4 + ...-run-idempotency-lookup-consumer-parity.md | 40 +++++ 15 files changed, 478 insertions(+), 15 deletions(-) create mode 100644 CHANGELOG.d/analysis-run-idempotency-lookup-consumer-parity.md create mode 100644 docs/adr/0047-analysis-run-idempotency-lookup-consumer-parity.md create mode 100644 docs/research/analysis-run-idempotency-lookup-consumer-parity.md diff --git a/CHANGELOG.d/analysis-run-idempotency-lookup-consumer-parity.md b/CHANGELOG.d/analysis-run-idempotency-lookup-consumer-parity.md new file mode 100644 index 000000000..e098ff023 --- /dev/null +++ b/CHANGELOG.d/analysis-run-idempotency-lookup-consumer-parity.md @@ -0,0 +1 @@ +- `tepp_api` adds `lineageweave_analysis_run_idempotency_lookup_exchange`, Naruon compatibility-listener idempotency-lookup GET (accepted creates return a real `run_id`), and a `tepp-loopback` TCP create-then-GET proof (ADR 0047). Metric-free lookup fields are unchanged from ADR 0037. Not GET status, not lifecycle POST, not an ADR 0014 claim. diff --git a/CHANGELOG.md b/CHANGELOG.md index b55a4f9c4..3e63b205c 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 idempotency-lookup consumer parity: LineageWeave GET-by-idempotency exchange, Naruon compatibility-listener inspect (accepted creates return a real `run_id`), and `tepp-loopback` TCP create-then-GET proof (ADR 0047). + - `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 ac5014a51..e3dcbd802 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -19,6 +19,7 @@ TEPP's approved PRD v0.4 and implementation plan are the primary product baselin | Analysis-run stored-request HTTP doctoring | [`docs/research/analysis-run-stored-request-http.md`](docs/research/analysis-run-stored-request-http.md) | | Analysis-run retry-lineage HTTP doctoring | [`docs/research/analysis-run-retry-lineage-http.md`](docs/research/analysis-run-retry-lineage-http.md) | | Analysis-run idempotency-key lookup HTTP doctoring | [`docs/research/analysis-run-idempotency-lookup-http.md`](docs/research/analysis-run-idempotency-lookup-http.md) | +| Analysis-run idempotency-lookup consumer-parity doctoring | [`docs/research/analysis-run-idempotency-lookup-consumer-parity.md`](docs/research/analysis-run-idempotency-lookup-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 5a61ab94a..6b08fe9bc 100644 --- a/crates/tepp_api/src/lib.rs +++ b/crates/tepp_api/src/lib.rs @@ -228,6 +228,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` idempotency-lookup GET without provider credentials. +pub use lineageweave_http::lineageweave_analysis_run_idempotency_lookup_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..e945f04f0 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_idempotency_lookup_http::naruon_analysis_run_idempotency_lookup_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 idempotency-lookup 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 resolve of a 202 receipt key to a durable `run_id`. +/// +/// # Errors +/// +/// Returns the same fail-closed errors as [`naruon_analysis_run_idempotency_lookup_exchange`]. +pub fn lineageweave_analysis_run_idempotency_lookup_exchange( + origin: &str, + idempotency_key: &str, +) -> Result { + let mut exchange = naruon_analysis_run_idempotency_lookup_exchange(origin, idempotency_key)?; + 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_idempotency_lookup_exchange, }; use crate::{ANALYSIS_RUN_CONTRACT_VERSION, AnalysisRunRequest, ApiError}; @@ -132,4 +156,50 @@ mod tests { Err(ApiError::InvalidWirePayload) ); } + + #[test] + fn lineageweave_idempotency_lookup_exchange_swaps_only_the_consumer_header() { + let exchange = lineageweave_analysis_run_idempotency_lookup_exchange( + "https://tepp.example.test", + "idem-1", + ) + .expect("exchange"); + assert_eq!(exchange.method, "GET"); + assert_eq!( + exchange.target_url, + "https://tepp.example.test/v1/analysis-runs/by-idempotency/idem-1" + ); + 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" + ) + })); + assert_eq!( + lineageweave_analysis_run_idempotency_lookup_exchange( + "http://tepp.example.test", + "idem-1" + ), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + lineageweave_analysis_run_idempotency_lookup_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..794ee3fef 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_idempotency_lookup_http::{ + AnalysisRunIdempotencyLookup, analysis_run_idempotency_lookup_path_key, + refuse_metrics_on_idempotency_lookup_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 idempotency-lookup GET. #[derive(Debug)] pub struct NaruonLiveService { listener: Option, @@ -198,14 +202,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_idempotency_lookup_path_key(path), + Ok(_) | Err(ApiError::LimitExceeded) + ) { + refuse_live_headers(&headers, self.bound_addr, false)?; + return self.lookup_analysis_run_by_idempotency(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) } @@ -252,6 +266,37 @@ impl NaruonLiveService { Ok(NaruonLiveResponse::json(202, "Accepted", body)) } + fn lookup_analysis_run_by_idempotency( + &self, + path: &str, + _headers: &HashMap, + body: &str, + ) -> Result { + let idempotency_key = analysis_run_idempotency_lookup_path_key(path)?; + if !body.trim().is_empty() { + return Err(ApiError::InvalidWirePayload); + } + refuse_metrics_on_idempotency_lookup_payload(body)?; + let mut matches: Vec<&AnalysisRunAccepted> = self + .accepted_runs + .values() + .filter(|(_, accepted)| accepted.idempotency_key == idempotency_key) + .map(|(_, accepted)| accepted) + .collect(); + if matches.len() != 1 { + return Err(ApiError::InvalidWirePayload); + } + let stored = matches.remove(0); + let payload = AnalysisRunIdempotencyLookup::new( + stored.run_id.clone(), + AnalysisRunStatusState::Accepted, + stored.idempotency_key.clone(), + )?; + let response_body = payload.to_json()?; + refuse_metrics_on_idempotency_lookup_payload(&response_body)?; + Ok(NaruonLiveResponse::json(200, "OK", response_body)) + } + fn authorize_export( headers: &HashMap, body: &str, @@ -326,6 +371,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 +380,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 +579,89 @@ mod tests { ApiError::InvalidWirePayload ); } + + #[test] + #[allow(clippy::too_many_lines)] + fn naruon_compatibility_listener_looks_up_accepted_idempotency_key() { + use crate::{ + ANALYSIS_RUN_CONTRACT_VERSION, AnalysisRunIdempotencyLookup, AnalysisRunRequest, + }; + + let run = AnalysisRunRequest { + contract_version: ANALYSIS_RUN_CONTRACT_VERSION, + idempotency_key: "naruon-lookup-idem".into(), + tenant_workspace_id: "naruon-lookup-tenant".into(), + snapshot_id: "naruon-lookup-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-lookup-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 = + "GET /v1/analysis-runs/by-idempotency/naruon-lookup-idem 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 lookup = AnalysisRunIdempotencyLookup::from_json(&inspected.body).expect("lookup"); + assert_eq!(lookup.run_id, run_id); + assert_eq!(lookup.run_state, crate::AnalysisRunStatusState::Accepted); + assert_eq!(lookup.idempotency_key, run.idempotency_key); + 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 = "GET /v1/analysis-runs/by-idempotency/naruon-lookup-idem 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/by-idempotency/missing-key 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( + "GET /v1/analysis-runs/by-idempotency/naruon-lookup-idem 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( + "POST /v1/analysis-runs/by-idempotency/naruon-lookup-idem 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-lookup-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/by-idempotency/{oversized} 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 = "GET /v1/analysis-runs/by-idempotency/naruon-lookup-idem 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..4e9c430bf 100644 --- a/crates/tepp_api/tests/lineageweave_http_contract.rs +++ b/crates/tepp_api/tests/lineageweave_http_contract.rs @@ -7,9 +7,10 @@ use std::thread; 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, + ANALYSIS_RUN_CONTRACT_VERSION, AnalysisRunAccepted, AnalysisRunIdempotencyLookup, + AnalysisRunLiveService, AnalysisRunRequest, ApiError, LINEAGEWEAVE_CONSUMER_CODE, + NARUON_ANALYSIS_RUN_PATH, NARUON_CONSUMER_CODE, NARUON_LIVE_HEADER_BYTE_LIMIT, + lineageweave_analysis_run_exchange, lineageweave_analysis_run_idempotency_lookup_exchange, }; fn sample_run() -> AnalysisRunRequest { @@ -153,3 +154,67 @@ fn live_listener_serves_lineageweave_over_loopback() { 202 ); } + +#[test] +fn lineageweave_idempotency_lookup_exchange_uses_the_published_consumer_header_without_credentials() +{ + let exchange = lineageweave_analysis_run_idempotency_lookup_exchange( + "https://tepp.example.test", + "lineageweave-lookup-idem", + ) + .expect("lineageweave idempotency-lookup exchange"); + assert_eq!(exchange.method, "GET"); + assert_eq!( + exchange.target_url, + "https://tepp.example.test/v1/analysis-runs/by-idempotency/lineageweave-lookup-idem" + ); + 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_idempotency_lookup_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}/by-idempotency/{} 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", + run.idempotency_key + ); + let inspected = service.handle_http_request(&inspect); + assert_eq!(inspected.status_code, 200); + let lookup = AnalysisRunIdempotencyLookup::from_json(&inspected.body).expect("lookup"); + assert_eq!(lookup.run_id, accepted.run_id); + assert_eq!(lookup.idempotency_key, run.idempotency_key); + 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 naruon_inspect = format!( + "GET {NARUON_ANALYSIS_RUN_PATH}/by-idempotency/{} 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", + run.idempotency_key + ); + assert_eq!( + service.handle_http_request(&naruon_inspect).status_code, + 400 + ); + let replay = service.handle_http_request(&inspect); + assert_eq!(replay.body, inspected.body); +} diff --git a/crates/tepp_api/tests/loopback_binary_contract.rs b/crates/tepp_api/tests/loopback_binary_contract.rs index 20e475647..be3534093 100644 --- a/crates/tepp_api/tests/loopback_binary_contract.rs +++ b/crates/tepp_api/tests/loopback_binary_contract.rs @@ -29,3 +29,48 @@ fn binary_serves_one_bounded_temporal_context_request() { assert!(response.contains("association_not_causal")); assert!(child.wait().expect("wait").success()); } + +#[test] +fn binary_resolves_idempotency_key_over_tcp_after_create() { + let mut child = Command::new(env!("CARGO_BIN_EXE_tepp-loopback")) + .args(["127.0.0.1:0", "2"]) + .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-lookup-idem","tenant_workspace_id":"loopback-lookup-tenant","snapshot_id":"loopback-lookup-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-lookup-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 inspect = format!( + "GET /v1/analysis-runs/by-idempotency/loopback-lookup-idem 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 lookup"); + 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("\"idempotency_key\":\"loopback-lookup-idem\"")); + 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..dafde444a 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, AnalysisRunIdempotencyLookup, + 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, }; fn sample_run() -> AnalysisRunRequest { @@ -719,6 +719,33 @@ 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_idempotency_lookup_and_refuses_lineageweave() { + let run = sample_run(); + let mut service = NaruonLiveService::new(); + let accepted = service.handle_http_request(&analysis_http(&run)); + assert_eq!(accepted.status_code, 202); + let run_id = AnalysisRunAccepted::from_json(&accepted.body) + .expect("accepted") + .run_id; + let inspect = format!( + "GET {NARUON_ANALYSIS_RUN_PATH}/by-idempotency/{} 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", + run.idempotency_key + ); + let inspected = service.handle_http_request(&inspect); + assert_eq!(inspected.status_code, 200); + let lookup = AnalysisRunIdempotencyLookup::from_json(&inspected.body).expect("lookup"); + assert_eq!(lookup.run_id, run_id); + assert_eq!(lookup.idempotency_key, run.idempotency_key); + assert!(!inspected.body.contains("rmse")); + assert!(!inspected.body.contains("scientific_acceptance")); + let lineageweave = format!( + "GET {NARUON_ANALYSIS_RUN_PATH}/by-idempotency/{} 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", + run.idempotency_key + ); + 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 dc8620c74..9ca6c1697 100644 --- a/docs/API_CONTRACT.md +++ b/docs/API_CONTRACT.md @@ -100,7 +100,7 @@ can inspect lineage after retry. An empty `retries` array is `200` when the parent was never retried. `GET /v1/analysis-runs/by-idempotency/{idempotency_key}` on the loopback listener returns the metric-free identity of the unique run that used that key so operators can jump from a 202 receipt or retry child -key without scanning collection pages. GET-by-id remains a later slice on this +key without scanning collection pages. `lineageweave_analysis_run_idempotency_lookup_exchange` and the Naruon compatibility listener serve the same metric-free GET for published consumers (ADR 0047); LineageWeave remains refused on `NaruonLiveService`. 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 ec5ddfc26..33974109c 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -59,6 +59,7 @@ The full APA 7th standards/literature register remains `docs/research/standards- | loopback analysis-run stored-request GET | ADR 0034; API contract; RFC 9110 | `tepp_api` `GET /v1/analysis-runs/{run_id}/request` on `AnalysisRunLiveService`: metric-free inspect of snapshot/cutoff/model/profile; collection GET lists identity only; GET-by-id remains a later slice | active-PR | | loopback analysis-run retry-lineage GET | ADR 0035; API contract; RFC 9110 | `tepp_api` `GET /v1/analysis-runs/{run_id}/retries` on `AnalysisRunLiveService`: metric-free direct retry children of a listed parent; empty `retries` when never retried; GET-by-id remains a later slice | active-PR | | loopback analysis-run idempotency-key lookup GET | ADR 0037; API contract; RFC 9110 | `tepp_api` `GET /v1/analysis-runs/by-idempotency/{idempotency_key}` on `AnalysisRunLiveService`: metric-free resolve of a 202 receipt or retry child key to a durable `run_id`; GET-by-id remains a later slice | active-PR | +| loopback analysis-run idempotency-lookup consumer parity | ADR 0047; API contract; RFC 9110 | `tepp_api` LineageWeave idempotency-lookup exchange, Naruon compatibility-listener inspect (accepted creates return a real `run_id`), and `tepp-loopback` TCP create-then-GET; 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/0047-analysis-run-idempotency-lookup-consumer-parity.md b/docs/adr/0047-analysis-run-idempotency-lookup-consumer-parity.md new file mode 100644 index 000000000..0552062a0 --- /dev/null +++ b/docs/adr/0047-analysis-run-idempotency-lookup-consumer-parity.md @@ -0,0 +1,70 @@ +# ADR 0047 — Analysis-run idempotency-lookup consumer parity + +**Decision status:** Accepted +**Implementation maturity:** active-PR +**Date:** 2026-08-31 +**Supersedes:** None; complements ADR 0037 and ADR 0018. Does not supersede ADR 0014. ADR 0026–0046 remain on other live PRs (0046 is retry-parent CLI; 0045 is retry-lineage consumer parity and nested ICC). This ADR number is unique on the idempotency-lookup GET lineage. + +## Context + +ADR 0037 added `GET /v1/analysis-runs/by-idempotency/{key}` on `AnalysisRunLiveService` and a Naruon lookup exchange builder. The Naruon compatibility listener (`NaruonLiveService`) still refused every GET path. `LineageWeave` had a create-exchange builder but no idempotency-lookup exchange, so a published consumer would have to mint a Naruon-labelled inspect. The packaged `tepp-loopback` binary had no TCP proof that lookup GET works on the shared listener after create. + +Duplicating the lookup DTO, GET status, lifecycle POST, collection GET, retry POST, stored-request, retry-lineage, retry-parent, or engine-library slices would not close this consumer-parity gap. + +## Decision + +- `lineageweave_analysis_run_idempotency_lookup_exchange` reuses the Naruon lookup builder and replaces only `tepp-consumer`. +- `NaruonLiveService` serves the same metric-free lookup GET for the Naruon-only compatibility listener. Accepted creates return a real `run_id` because that listener already keys idempotency replay. LineageWeave consumers remain refused there; they use `AnalysisRunLiveService`. +- `tepp-loopback` proves create-then-inspect over loopback TCP so a durable `run_id` is observable from the 202 receipt key. +- Lookup payloads stay metric-free. Unknown keys, 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. +- An ADR 0014 scientific claim. + +## Alternatives considered + +1. **Leave lookup 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 lookup DTO** — rejected as a duplicate of ADR 0037. +4. **Consumer-parity lookup on the existing typed inspect** — accepted. + +## Consequences + +- Both published consumers can build a credential-free idempotency-lookup GET. +- Naruon local proofs can resolve an accepted create to a durable `run_id` on either listener. +- Operators can observe the same resolve through `tepp-loopback` after create without a second HTTP stack. + +## Failure and recovery + +Unknown keys, 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. +- Lookup remains loopback-only and metric-free. +- HTTP `200` inspect is not measurement or release evidence. + +## Compatibility and migration + +ADR 0037 create/inspect semantics are unchanged. Production adapters may replace loopback while preserving consumer identity, metric-free lookup fields, and Naruon-only compatibility-listener admission. + +## Verification + +- LineageWeave lookup exchange carries `tepp-consumer: lineageweave` and no credentials; +- NaruonLiveService resolves accepted Naruon runs to a real `run_id` and refuses LineageWeave, metrics, nonempty bodies, and unknown keys; +- `tepp-loopback` create-then-GET over TCP returns a metric-free identity; +- 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 0037 shared-listener lookup GET remains. A superseding ADR is required to persist inspect, bind a public address, or treat HTTP success as an ADR 0014 claim. + +## Related authority + +- ADR 0037 owns the shared-listener idempotency-lookup 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 a206d0df7..a1e558f5d 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -36,6 +36,7 @@ Read [`ADR_POLICY.md`](ADR_POLICY.md) first. **Decision status and implementatio | [0034](0034-analysis-run-stored-request-get.md) | Loopback GET analysis-run stored-request is metric-free inspect | Accepted | active-PR | Complements ADR 0018/0031/0032; does not supersede ADR 0014. ADR 0026–0033 live on other GAP-003A PRs. | | [0035](0035-analysis-run-retry-lineage-get.md) | Loopback GET analysis-run retry-lineage is metric-free parent/child inspect | Accepted | active-PR | Complements ADR 0018/0031/0032/0034; does not supersede ADR 0014. ADR 0026–0034 live on other GAP-003A PRs. | | [0037](0037-analysis-run-idempotency-lookup-get.md) | Loopback GET analysis-run idempotency-key lookup is metric-free identity resolve | Accepted | active-PR | Complements ADR 0018/0031/0032/0034/0035; does not supersede ADR 0014. ADR 0026–0036 live on other GAP-003A PRs. | +| [0047](0047-analysis-run-idempotency-lookup-consumer-parity.md) | LineageWeave and Naruon compatibility-listener idempotency-lookup GET | Accepted | active-PR | Complements ADR 0037/0018; does not supersede ADR 0014. ADR 0026–0046 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. | @@ -152,6 +153,7 @@ Use the narrowest owning ADR when decisions overlap: - **analysis-run stored-request GET:** ADR 0034. - **analysis-run retry-lineage GET:** ADR 0035. - **analysis-run idempotency-key lookup GET:** ADR 0037. +- **analysis-run idempotency-lookup consumer parity:** ADR 0047. ## Change and supersession rule diff --git a/docs/connectors/naruon-artifact-consumer.md b/docs/connectors/naruon-artifact-consumer.md index 5bdc328a1..00f796764 100644 --- a/docs/connectors/naruon-artifact-consumer.md +++ b/docs/connectors/naruon-artifact-consumer.md @@ -27,6 +27,8 @@ TEPP remains the scientific authority for estimation, recovery metrics, temporal | purpose-bound export auth | `tepp_api` `authorize_export` with `ModularServiceConsumer` | TEPP gate | | HTTP analysis-run create | `tepp_api` `naruon_analysis_run_exchange` → `POST /v1/analysis-runs` | naruon → TEPP | | HTTP analysis-run collection | `tepp_api` `naruon_analysis_run_collection_exchange` → `GET /v1/analysis-runs` | naruon → TEPP | +| HTTP analysis-run idempotency lookup | `tepp_api` `naruon_analysis_run_idempotency_lookup_exchange` → `GET /v1/analysis-runs/by-idempotency/{key}` | naruon → TEPP | +| Live loopback idempotency-lookup GET | `tepp_api` `NaruonLiveService` Naruon-only `GET /v1/analysis-runs/by-idempotency/{key}` | naruon → TEPP | | HTTP analysis-run cancel | `tepp_api` `naruon_analysis_run_cancel_exchange` → `POST /v1/analysis-runs/{run_id}/cancel` | naruon → TEPP | | HTTP export authorize | `tepp_api` `naruon_export_exchange` → `POST /v1/exports` | naruon → TEPP | | Live loopback POST | `tepp_api` `NaruonLiveService` → `POST /v1/analysis-runs` and `/v1/exports` | naruon → TEPP | @@ -54,6 +56,8 @@ When naruon requests an export, TEPP evaluates `AnalyticalPurpose::ModularServic - 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; - cancel of a succeeded, failed, or unknown analysis run → reject. +- LineageWeave consumer on `NaruonLiveService` idempotency-lookup GET → reject; +- unknown, nonempty, or metric-bearing idempotency-lookup GET → reject. ## Authority sources diff --git a/docs/research/analysis-run-idempotency-lookup-consumer-parity.md b/docs/research/analysis-run-idempotency-lookup-consumer-parity.md new file mode 100644 index 000000000..a931212fb --- /dev/null +++ b/docs/research/analysis-run-idempotency-lookup-consumer-parity.md @@ -0,0 +1,40 @@ +# Analysis-run idempotency-lookup consumer parity (doctoring) + +## Scope + +`LineageWeave` and the Naruon compatibility listener must be able to resolve +a metric-free analysis-run identity from an idempotency key 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 0037; ADR 0047), not an RFC inference rule. + +This slice does not serve GET status, running/terminal POST, collection GET, +retry POST, retry-parent, retry-lineage, or persistence. `NaruonLiveService` +already keys idempotency replay, so accepted creates return a real `run_id`. + +## Authority + +### External standards (HTTP only) + +Fielding, R., Nottingham, M., & Reschke, J. (Eds.). (2022). *HTTP semantics* +(RFC 9110). IETF. https://doi.org/10.17487/RFC9110 + +RFC 9110 §9.3.1 describes GET as a method for retrieving the target resource's +current state. TEPP maps that retrieval onto a bounded, consumer-scoped +resolution of an idempotency key. The RFC does not define psychometric +acceptance, RMSE, or claim promotion. + +### Internal contract evidence + +- `docs/adr/0047-analysis-run-idempotency-lookup-consumer-parity.md` +- `docs/adr/0037-analysis-run-idempotency-lookup-get.md` +- `docs/adr/0018-consumer-scoped-analysis-run-ingress.md` +- `docs/adr/0011-standalone-modular-msa-boundary.md` + +## Claim boundary + +HTTP `200` lookup is not a completed temporal model, calibrated score, theta +estimate, uncertainty statement, or scientific claim. +`tepp.scientific_acceptance.v1` never appears.