diff --git a/CHANGELOG.d/analysis-run-stored-request-consumer-parity.md b/CHANGELOG.d/analysis-run-stored-request-consumer-parity.md new file mode 100644 index 000000000..f39526a6a --- /dev/null +++ b/CHANGELOG.d/analysis-run-stored-request-consumer-parity.md @@ -0,0 +1 @@ +- `tepp_api` adds `lineageweave_analysis_run_stored_request_exchange`, Naruon compatibility-listener stored-request GET, and a `tepp-loopback` TCP inspect proof (ADR 0040). Metric-free stored-request fields are unchanged from ADR 0034. Not GET status, not lifecycle POST, not an ADR 0014 claim. diff --git a/CHANGELOG.md b/CHANGELOG.md index b55a4f9c4..4af42e43b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,10 @@ All notable changes to TEPP are documented here. The format follows Keep a Chang ## [Unreleased] +### Added + +- Loopback stored-request consumer parity: LineageWeave GET-request exchange, Naruon compatibility-listener inspect, and `tepp-loopback` TCP proof (ADR 0040). + - `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 cbf933eef..d726599ac 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -17,6 +17,7 @@ TEPP's approved PRD v0.4 and implementation plan are the primary product baselin | Analysis-run collection HTTP doctoring | [`docs/research/analysis-run-collection-http.md`](docs/research/analysis-run-collection-http.md) | | Analysis-run retry HTTP doctoring | [`docs/research/analysis-run-retry-http.md`](docs/research/analysis-run-retry-http.md) | | Analysis-run stored-request HTTP doctoring | [`docs/research/analysis-run-stored-request-http.md`](docs/research/analysis-run-stored-request-http.md) | +| Analysis-run stored-request consumer-parity doctoring | [`docs/research/analysis-run-stored-request-consumer-parity.md`](docs/research/analysis-run-stored-request-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 ac10fc472..2be878783 100644 --- a/crates/tepp_api/src/lib.rs +++ b/crates/tepp_api/src/lib.rs @@ -200,6 +200,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` stored-request GET without provider credentials. +pub use lineageweave_http::lineageweave_analysis_run_stored_request_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..711f1a1db 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_stored_request_http::naruon_analysis_run_stored_request_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 stored-request 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 stored create fields, not a measurement result. +/// +/// # Errors +/// +/// Returns the same fail-closed errors as [`naruon_analysis_run_stored_request_exchange`]. +pub fn lineageweave_analysis_run_stored_request_exchange( + origin: &str, + run_id: &str, +) -> Result { + let mut exchange = naruon_analysis_run_stored_request_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_stored_request_exchange, }; use crate::{ANALYSIS_RUN_CONTRACT_VERSION, AnalysisRunRequest, ApiError}; @@ -132,4 +156,46 @@ mod tests { Err(ApiError::InvalidWirePayload) ); } + + #[test] + fn lineageweave_stored_request_exchange_swaps_only_the_consumer_header() { + let exchange = lineageweave_analysis_run_stored_request_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/request" + ); + 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_stored_request_exchange( + "http://tepp.example.test", + "tepp-run-1" + ), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + lineageweave_analysis_run_stored_request_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..16bb604f4 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_stored_request_http::{ + AnalysisRunStoredRequest, analysis_run_stored_request_path_run_id, + refuse_metrics_on_stored_request_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 stored-request 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_stored_request_path_run_id(path), + Ok(_) | Err(ApiError::LimitExceeded) + ) { + refuse_live_headers(&headers, self.bound_addr, false)?; + return self.read_analysis_run_stored_request(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,47 @@ 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_stored_request( + &self, + path: &str, + _headers: &HashMap, + body: &str, + ) -> Result { + let run_id = analysis_run_stored_request_path_run_id(path)?; + if !body.trim().is_empty() { + return Err(ApiError::InvalidWirePayload); + } + refuse_metrics_on_stored_request_payload(body)?; + let replay_key = self + .runs_by_id + .get(&run_id) + .cloned() + .ok_or(ApiError::InvalidWirePayload)?; + let (stored_request, stored_accepted) = self + .accepted_runs + .get(&replay_key) + .ok_or(ApiError::InvalidWirePayload)?; + let payload = AnalysisRunStoredRequest::new( + stored_accepted.run_id.clone(), + AnalysisRunStatusState::Accepted, + stored_accepted.idempotency_key.clone(), + stored_request.snapshot_id.clone(), + stored_request.knowledge_cutoff.clone(), + stored_request.model_contract_version.clone(), + stored_request.output_profile.clone(), + )?; + let response_body = payload.to_json()?; + refuse_metrics_on_stored_request_payload(&response_body)?; + Ok(NaruonLiveResponse::json(200, "OK", response_body)) + } + fn authorize_export( headers: &HashMap, body: &str, @@ -326,6 +377,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 +386,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 +585,95 @@ mod tests { ApiError::InvalidWirePayload ); } + + #[test] + #[allow(clippy::too_many_lines)] + fn naruon_compatibility_listener_inspects_stored_request() { + use crate::{ANALYSIS_RUN_CONTRACT_VERSION, AnalysisRunRequest, AnalysisRunStoredRequest}; + + let run = AnalysisRunRequest { + contract_version: ANALYSIS_RUN_CONTRACT_VERSION, + idempotency_key: "naruon-stored-idem".into(), + tenant_workspace_id: "naruon-stored-tenant".into(), + snapshot_id: "naruon-stored-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-stored-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}/request 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 stored = AnalysisRunStoredRequest::from_json(&inspected.body).expect("stored"); + assert_eq!(stored.run_id, run_id); + assert_eq!(stored.run_state, crate::AnalysisRunStatusState::Accepted); + assert_eq!(stored.idempotency_key, run.idempotency_key); + assert_eq!(stored.snapshot_id, run.snapshot_id); + assert_eq!(stored.knowledge_cutoff, run.knowledge_cutoff); + assert_eq!(stored.model_contract_version, run.model_contract_version); + assert_eq!(stored.output_profile, run.output_profile); + assert!(!inspected.body.contains("rmse")); + assert!(!inspected.body.contains("scientific_acceptance")); + assert!(!inspected.body.contains("tenant_workspace_id")); + + let replay = service.handle_http_request(&inspect); + assert_eq!(replay.body, inspected.body); + + let lineageweave = format!( + "GET /v1/analysis-runs/{run_id}/request 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/request 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}/request 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}/request 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-stored-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}/request 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}/request 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..d07f2678a 100644 --- a/crates/tepp_api/tests/lineageweave_http_contract.rs +++ b/crates/tepp_api/tests/lineageweave_http_contract.rs @@ -8,8 +8,9 @@ use std::time::Duration; use tepp_api::{ ANALYSIS_RUN_CONTRACT_VERSION, AnalysisRunAccepted, AnalysisRunLiveService, AnalysisRunRequest, - ApiError, LINEAGEWEAVE_CONSUMER_CODE, NARUON_ANALYSIS_RUN_PATH, NARUON_CONSUMER_CODE, - NARUON_LIVE_HEADER_BYTE_LIMIT, lineageweave_analysis_run_exchange, + AnalysisRunStoredRequest, ApiError, LINEAGEWEAVE_CONSUMER_CODE, NARUON_ANALYSIS_RUN_PATH, + NARUON_CONSUMER_CODE, NARUON_LIVE_HEADER_BYTE_LIMIT, lineageweave_analysis_run_exchange, + lineageweave_analysis_run_stored_request_exchange, }; fn sample_run() -> AnalysisRunRequest { @@ -153,3 +154,63 @@ fn live_listener_serves_lineageweave_over_loopback() { 202 ); } + +#[test] +fn lineageweave_stored_request_exchange_uses_the_published_consumer_header_without_credentials() { + let exchange = lineageweave_analysis_run_stored_request_exchange( + "https://tepp.example.test", + "tepp-run-lineage", + ) + .expect("lineageweave stored-request exchange"); + assert_eq!(exchange.method, "GET"); + assert_eq!( + exchange.target_url, + "https://tepp.example.test/v1/analysis-runs/tepp-run-lineage/request" + ); + 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_stored_request_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}/{}/request 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 stored = AnalysisRunStoredRequest::from_json(&inspected.body).expect("stored"); + assert_eq!(stored.run_id, accepted.run_id); + assert_eq!(stored.snapshot_id, run.snapshot_id); + assert_eq!(stored.output_profile, run.output_profile); + assert!(!inspected.body.contains("rmse")); + assert!(!inspected.body.contains("scientific_acceptance")); + let naruon_inspect = format!( + "GET {NARUON_ANALYSIS_RUN_PATH}/{}/request 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 + ); +} diff --git a/crates/tepp_api/tests/loopback_binary_contract.rs b/crates/tepp_api/tests/loopback_binary_contract.rs index 20e475647..312957d6a 100644 --- a/crates/tepp_api/tests/loopback_binary_contract.rs +++ b/crates/tepp_api/tests/loopback_binary_contract.rs @@ -29,3 +29,46 @@ fn binary_serves_one_bounded_temporal_context_request() { assert!(response.contains("association_not_causal")); assert!(child.wait().expect("wait").success()); } + +#[test] +fn binary_inspects_an_accepted_analysis_run_stored_request_over_tcp() { + 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-stored-idem","tenant_workspace_id":"loopback-stored-tenant","snapshot_id":"loopback-stored-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-stored-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/{run_id}/request 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 stored-request"); + 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("\"snapshot_id\":\"loopback-stored-snapshot\"")); + assert!(inspected.contains("\"output_profile\":\"calibrated_event_measurement\"")); + assert!(!inspected.contains("rmse")); + assert!(!inspected.contains("scientific_acceptance")); + assert!(!inspected.contains("tenant_workspace_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..1b1020b42 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, + AnalysisRunStoredRequest, 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_stored_request_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}/{}/request", 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 stored = AnalysisRunStoredRequest::from_json(&inspected.body).expect("stored"); + assert_eq!(stored.run_id, accepted.run_id); + assert_eq!(stored.snapshot_id, run.snapshot_id); + assert_eq!(stored.output_profile, run.output_profile); + assert!(!inspected.body.contains("rmse")); + assert!(!inspected.body.contains("scientific_acceptance")); + let lineageweave = http_request( + "GET", + &format!("{NARUON_ANALYSIS_RUN_PATH}/{}/request", 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 41e52cb68..fd5231a7e 100644 --- a/docs/API_CONTRACT.md +++ b/docs/API_CONTRACT.md @@ -93,7 +93,9 @@ closed. `GET /v1/analysis-runs/{run_id}/request` on the loopback listener returns metric-free stored create fields (`snapshot_id`, `knowledge_cutoff`, `model_contract_version`, `output_profile`) so operators can inspect a listed run before retry. GET-by-id remains a later slice on this protected-main -lineage. +lineage. `lineageweave_analysis_run_stored_request_exchange` builds the same +GET for LineageWeave. `NaruonLiveService` serves stored-request GET for Naruon +only; LineageWeave remains refused on that compatibility listener. The stacked `analysis_engine` slice provides the first executable service-side path behind these DTOs. It consumes a bounded identity-free snapshot, excludes diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 3dd7d658a..da25734e9 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -57,6 +57,7 @@ The full APA 7th standards/literature register remains `docs/research/standards- | loopback analysis-run collection GET | ADR 0031; API contract; RFC 9110 | `tepp_api` `GET /v1/analysis-runs` on `AnalysisRunLiveService`: metric-free enumeration of accepted/running/cancelled/terminal runs; collection bodies refuse scientific-acceptance and RMSE keys; GET-by-id remains a later slice | active-PR | | loopback analysis-run retry HTTP | ADR 0032; API contract; RFC 9110 | `tepp_api` `POST /v1/analysis-runs/{run_id}/retry` on `AnalysisRunLiveService`: clones failed/cancelled into a new metric-free `202 Accepted` with a new idempotency key; accepted/running/succeeded/unknown refuse; GET-by-id remains a later slice | active-PR | | loopback analysis-run 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 stored-request consumer parity | ADR 0040; API contract; RFC 9110 | `tepp_api` LineageWeave stored-request exchange, Naruon compatibility-listener inspect, and `tepp-loopback` TCP create-then-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/0040-analysis-run-stored-request-consumer-parity.md b/docs/adr/0040-analysis-run-stored-request-consumer-parity.md new file mode 100644 index 000000000..98a0ca6f2 --- /dev/null +++ b/docs/adr/0040-analysis-run-stored-request-consumer-parity.md @@ -0,0 +1,70 @@ +# ADR 0040 — Analysis-run stored-request consumer parity + +**Decision status:** Accepted +**Implementation maturity:** active-PR +**Date:** 2026-08-31 +**Supersedes:** None; complements ADR 0034 and ADR 0018. Does not supersede ADR 0014. ADR 0026–0039 remain on other live PRs (0039 is the two-group OLS profile). This ADR number is unique on the stored-request GET lineage. + +## Context + +ADR 0034 added `GET /v1/analysis-runs/{run_id}/request` on `AnalysisRunLiveService` and a Naruon stored-request exchange builder. The Naruon compatibility listener (`NaruonLiveService`) still refused every GET path. `LineageWeave` had a create-exchange builder but no stored-request exchange, so a published consumer would have to mint a Naruon-labelled inspect. The packaged `tepp-loopback` binary had no TCP proof that stored-request GET works on the shared listener. + +Duplicating the stored-request DTO, GET status, lifecycle POST, collection GET, retry, retry-lineage, or engine-library slices would not close this consumer-parity gap. + +## Decision + +- `lineageweave_analysis_run_stored_request_exchange` reuses the Naruon stored-request builder and replaces only `tepp-consumer`. +- `NaruonLiveService` serves the same metric-free stored-request GET for the Naruon-only compatibility listener. LineageWeave consumers remain refused there; they use `AnalysisRunLiveService`. +- `tepp-loopback` proves create-then-inspect over loopback TCP. +- Stored-request 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, retry-lineage, persistence, or production TLS. +- Opening `NaruonLiveService` to LineageWeave. +- An ADR 0014 scientific claim. + +## Alternatives considered + +1. **Leave stored-request 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 stored-request DTO** — rejected as a duplicate of ADR 0034. +4. **Consumer-parity stored-request on the existing typed inspect** — accepted. + +## Consequences + +- Both published consumers can build a credential-free stored-request GET. +- Naruon local proofs can inspect stored create fields on either listener. +- Operators can observe stored-request inspect through `tepp-loopback` 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. +- Stored-request remains loopback-only and metric-free. +- HTTP `200` inspect is not measurement or release evidence. + +## Compatibility and migration + +ADR 0034 create/inspect semantics are unchanged. Production adapters may replace loopback while preserving consumer identity, metric-free stored-request fields, and Naruon-only compatibility-listener admission. + +## Verification + +- LineageWeave stored-request exchange carries `tepp-consumer: lineageweave` and no credentials; +- NaruonLiveService inspects accepted Naruon runs and refuses LineageWeave, metrics, nonempty bodies, and unknown runs; +- `tepp-loopback` create-then-inspect over TCP returns metric-free stored create fields; +- 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 0034 shared-listener stored-request 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 0034 owns the shared-listener stored-request 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 4a23e5b42..6eca379f4 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -34,6 +34,7 @@ Read [`ADR_POLICY.md`](ADR_POLICY.md) first. **Decision status and implementatio | [0031](0031-analysis-run-collection-get.md) | Loopback GET analysis-run collection is metric-free enumeration | Accepted | active-PR | Complements ADR 0018/0029; does not supersede ADR 0014. ADR 0026–0030 live on other GAP-003A PRs. | | [0032](0032-analysis-run-retry-http.md) | Loopback POST analysis-run retry clones failed/cancelled into a new metric-free 202 | Accepted | active-PR | Complements ADR 0018/0029/0031; does not supersede ADR 0014. ADR 0026–0031 live on other GAP-003A PRs. | | [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. | +| [0040](0040-analysis-run-stored-request-consumer-parity.md) | LineageWeave and Naruon compatibility-listener stored-request GET | Accepted | active-PR | Complements ADR 0034/0018; does not supersede ADR 0014. ADR 0026–0039 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. | @@ -148,6 +149,7 @@ Use the narrowest owning ADR when decisions overlap: - **analysis-run collection GET:** ADR 0031. - **analysis-run retry HTTP:** ADR 0032. - **analysis-run stored-request GET:** ADR 0034. +- **analysis-run stored-request consumer parity:** ADR 0040. ## Change and supersession rule diff --git a/docs/connectors/naruon-artifact-consumer.md b/docs/connectors/naruon-artifact-consumer.md index 5bdc328a1..8ab376472 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 stored-request | `tepp_api` `naruon_analysis_run_stored_request_exchange` → `GET /v1/analysis-runs/{run_id}/request` | 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 stored-request GET | `tepp_api` `NaruonLiveService` → `GET /v1/analysis-runs/{run_id}/request` (Naruon only) | 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`) on a stored-request body → reject; - cancel of a succeeded, failed, or unknown analysis run → reject. ## Authority sources diff --git a/docs/research/analysis-run-stored-request-consumer-parity.md b/docs/research/analysis-run-stored-request-consumer-parity.md new file mode 100644 index 000000000..170cb3692 --- /dev/null +++ b/docs/research/analysis-run-stored-request-consumer-parity.md @@ -0,0 +1,42 @@ +# Analysis-run stored-request consumer parity (doctoring) + +## Scope + +`LineageWeave` and the Naruon compatibility listener must be able to inspect +stored analysis-run create fields 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 0034; +ADR 0040), not an RFC inference rule. + +This slice does not serve GET status, running/terminal POST, collection GET, +retry, retry-lineage, or persistence. + +## 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/0034-analysis-run-stored-request-get.md` — shared-listener inspect +- `docs/adr/0040-analysis-run-stored-request-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 stored-request exchange sets only the published consumer header; +- NaruonLiveService inspects accepted Naruon runs and refuses LineageWeave; +- stored-request JSON has no RMSE/bias/coverage/SE-gate/scientific-acceptance keys; +- `tepp-loopback` create-then-inspect over TCP returns `200` with snapshot and profile. + +## Non-claims + +This slice does not implement GET status, lifecycle POST, persistence, +production TLS, or an ADR 0014 scientific claim.