From 22d89a36bc104a03d89c034585770856d6d96cc6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 31 Aug 2026 14:26:26 +0000 Subject: [PATCH] feat(api): enumerate analysis runs from LineageWeave and Naruon GAP-003A / issue #166: LineageWeave mints the metric-free collection GET and the Naruon compatibility listener lists accepted runs. tepp-loopback proves create-then-list over TCP. Not GET status, not an ADR 0014 claim. --- ...analysis-run-collection-consumer-parity.md | 1 + CHANGELOG.md | 4 + DOCUMENTATION.md | 1 + crates/tepp_api/src/lib.rs | 2 + crates/tepp_api/src/lineageweave_http.rs | 97 ++++++++- crates/tepp_api/src/naruon_live.rs | 191 +++++++++++++++++- .../tests/lineageweave_http_contract.rs | 62 +++++- .../tests/loopback_binary_contract.rs | 45 +++++ .../tests/naruon_live_http_contract.rs | 50 ++++- docs/API_CONTRACT.md | 6 +- docs/TRACEABILITY.md | 1 + ...analysis-run-collection-consumer-parity.md | 70 +++++++ docs/adr/README.md | 2 + docs/connectors/naruon-artifact-consumer.md | 1 + ...analysis-run-collection-consumer-parity.md | 42 ++++ 15 files changed, 559 insertions(+), 16 deletions(-) create mode 100644 CHANGELOG.d/analysis-run-collection-consumer-parity.md create mode 100644 docs/adr/0042-analysis-run-collection-consumer-parity.md create mode 100644 docs/research/analysis-run-collection-consumer-parity.md diff --git a/CHANGELOG.d/analysis-run-collection-consumer-parity.md b/CHANGELOG.d/analysis-run-collection-consumer-parity.md new file mode 100644 index 000000000..93f4a125d --- /dev/null +++ b/CHANGELOG.d/analysis-run-collection-consumer-parity.md @@ -0,0 +1 @@ +- `tepp_api` adds `lineageweave_analysis_run_collection_exchange`, Naruon compatibility-listener collection GET, and a `tepp-loopback` TCP list proof (ADR 0042). Metric-free collection rows are unchanged from ADR 0031. Not GET status, not lifecycle POST, not an ADR 0014 claim. diff --git a/CHANGELOG.md b/CHANGELOG.md index b55a4f9c4..79c6967df 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 collection GET consumer parity: LineageWeave collection exchange, Naruon compatibility-listener list, and `tepp-loopback` TCP proof (ADR 0042). + - `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 b29d53702..cb4508d2e 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -15,6 +15,7 @@ TEPP's approved PRD v0.4 and implementation plan are the primary product baselin | Orchestrator live HTTP doctoring | [`docs/research/orchestrator-live-http.md`](docs/research/orchestrator-live-http.md) | | Analysis-run cancel HTTP doctoring | [`docs/research/analysis-run-cancel-http.md`](docs/research/analysis-run-cancel-http.md) | | Analysis-run collection HTTP doctoring | [`docs/research/analysis-run-collection-http.md`](docs/research/analysis-run-collection-http.md) | +| Analysis-run collection consumer-parity doctoring | [`docs/research/analysis-run-collection-consumer-parity.md`](docs/research/analysis-run-collection-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 2acf87810..7a080cf60 100644 --- a/crates/tepp_api/src/lib.rs +++ b/crates/tepp_api/src/lib.rs @@ -176,6 +176,8 @@ pub use lineage_pair_criterion::LineageTemporalProvenance; pub use lineageweave_http::LINEAGEWEAVE_CONSUMER_CODE; /// Published Naruon modular-consumer identity. pub use lineageweave_http::NARUON_CONSUMER_CODE; +/// Build a `LineageWeave` collection GET without provider credentials. +pub use lineageweave_http::lineageweave_analysis_run_collection_exchange; /// Build a `LineageWeave` analysis-run exchange without provider credentials. pub use lineageweave_http::lineageweave_analysis_run_exchange; /// Build a `LineageWeave` project-history exchange without provider credentials. diff --git a/crates/tepp_api/src/lineageweave_http.rs b/crates/tepp_api/src/lineageweave_http.rs index 6094760ed..b4e2771cb 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_collection_http::naruon_analysis_run_collection_exchange; use crate::naruon_http::compose_https_target; use crate::project_history::build_project_history_exchange; use crate::{ @@ -38,6 +39,30 @@ pub fn lineageweave_analysis_run_exchange( Ok(exchange) } +/// Build a `LineageWeave` → TEPP collection GET without credentials. +/// +/// The function reuses TEPP's existing origin and pagination validation, then +/// replaces only the published modular-consumer identity. The response remains +/// a metric-free enumeration of run rows, not a measurement result. +/// +/// # Errors +/// +/// Returns the same fail-closed errors as [`naruon_analysis_run_collection_exchange`]. +pub fn lineageweave_analysis_run_collection_exchange( + origin: &str, + cursor: Option<&str>, + limit: Option<&str>, +) -> Result { + let mut exchange = naruon_analysis_run_collection_exchange(origin, cursor, limit)?; + 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 +119,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_collection_exchange, lineageweave_analysis_run_exchange, }; use crate::{ANALYSIS_RUN_CONTRACT_VERSION, AnalysisRunRequest, ApiError}; @@ -132,4 +157,74 @@ mod tests { Err(ApiError::InvalidWirePayload) ); } + + #[test] + fn lineageweave_collection_exchange_swaps_only_the_consumer_header() { + let exchange = + lineageweave_analysis_run_collection_exchange("https://tepp.example.test", None, None) + .expect("exchange"); + assert_eq!(exchange.method, "GET"); + assert_eq!( + exchange.target_url, + "https://tepp.example.test/v1/analysis-runs" + ); + 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" + ) + })); + + let paged = lineageweave_analysis_run_collection_exchange( + "https://tepp.example.test", + Some("tepp-run-1"), + Some("8"), + ) + .expect("paged"); + assert!( + paged + .headers + .contains(&("tepp-page-cursor".into(), "tepp-run-1".into())) + ); + assert!( + paged + .headers + .contains(&("tepp-page-limit".into(), "8".into())) + ); + assert_eq!( + lineageweave_analysis_run_collection_exchange("http://tepp.example.test", None, None), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + lineageweave_analysis_run_collection_exchange( + "https://tepp.example.test", + Some(""), + None + ), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + lineageweave_analysis_run_collection_exchange( + "https://tepp.example.test", + None, + Some("99") + ), + Err(ApiError::LimitExceeded) + ); + } } diff --git a/crates/tepp_api/src/naruon_live.rs b/crates/tepp_api/src/naruon_live.rs index f9b4ca327..7ac28a688 100644 --- a/crates/tepp_api/src/naruon_live.rs +++ b/crates/tepp_api/src/naruon_live.rs @@ -5,6 +5,11 @@ use std::io::{Read, Write}; use std::net::{SocketAddr, TcpListener, TcpStream}; use std::time::Duration; +use crate::analysis_run_collection_http::{ + AnalysisRunCollection, AnalysisRunCollectionItem, is_analysis_run_collection_path, + parse_collection_page_cursor, parse_collection_page_limit, + refuse_metrics_on_collection_payload, +}; use crate::authorization::{ AnalyticalPurpose, ExportAuthorizationRequest, authorize_export, require_export_allowed, }; @@ -16,7 +21,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 +56,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 collection GET. #[derive(Debug)] pub struct NaruonLiveService { listener: Option, @@ -198,14 +203,21 @@ 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" && is_analysis_run_collection_path(path) { + refuse_live_headers(&headers, self.bound_addr, false)?; + return self.list_analysis_runs(path, &headers, body); + } + if method == "GET" { + 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 +264,64 @@ impl NaruonLiveService { Ok(NaruonLiveResponse::json(202, "Accepted", body)) } + fn list_analysis_runs( + &self, + path: &str, + headers: &HashMap, + body: &str, + ) -> Result { + if !is_analysis_run_collection_path(path) { + return Err(ApiError::InvalidWirePayload); + } + if !body.trim().is_empty() { + return Err(ApiError::InvalidWirePayload); + } + refuse_metrics_on_collection_payload(body)?; + let limit = + parse_collection_page_limit(headers.get("tepp-page-limit").map(String::as_str))?; + let cursor = + parse_collection_page_cursor(headers.get("tepp-page-cursor").map(String::as_str))?; + let mut rows: Vec<&AnalysisRunAccepted> = self + .accepted_runs + .values() + .map(|(_, accepted)| accepted) + .collect(); + rows.sort_by(|left, right| left.run_id.cmp(&right.run_id)); + let start = match cursor { + Some(cursor) => { + let position = rows + .iter() + .position(|accepted| accepted.run_id == cursor) + .ok_or(ApiError::InvalidWirePayload)?; + position + 1 + } + None => 0, + }; + let page = rows.get(start..).unwrap_or(&[]); + let (visible, remainder) = if page.len() > limit { + page.split_at(limit) + } else { + (page, &[] as &[&AnalysisRunAccepted]) + }; + let mut items = Vec::with_capacity(visible.len()); + for accepted in visible { + items.push(AnalysisRunCollectionItem::new( + accepted.run_id.clone(), + AnalysisRunStatusState::Accepted, + accepted.idempotency_key.clone(), + )?); + } + let next_cursor = if remainder.is_empty() { + None + } else { + visible.last().map(|accepted| accepted.run_id.clone()) + }; + let collection = AnalysisRunCollection::new(items, next_cursor)?; + let response_body = collection.to_json()?; + refuse_metrics_on_collection_payload(&response_body)?; + Ok(NaruonLiveResponse::json(200, "OK", response_body)) + } + fn authorize_export( headers: &HashMap, body: &str, @@ -326,6 +396,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 +405,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 +604,112 @@ mod tests { ApiError::InvalidWirePayload ); } + + #[test] + #[allow(clippy::too_many_lines)] + fn naruon_compatibility_listener_lists_accepted_runs() { + use crate::{ANALYSIS_RUN_CONTRACT_VERSION, AnalysisRunCollection, AnalysisRunRequest}; + + let run = AnalysisRunRequest { + contract_version: ANALYSIS_RUN_CONTRACT_VERSION, + idempotency_key: "naruon-collection-idem".into(), + tenant_workspace_id: "naruon-collection-tenant".into(), + snapshot_id: "naruon-collection-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-collection-idem\r\ncontent-length: {}\r\n\r\n{body}", + body.len() + ); + let mut service = NaruonLiveService::new(); + let empty = service.handle_http_request( + "GET /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\ncontent-length: 0\r\n\r\n", + ); + assert_eq!(empty.status_code, 200); + let empty_page = AnalysisRunCollection::from_json(&empty.body).expect("empty"); + assert!(empty_page.runs.is_empty()); + assert!(empty_page.next_cursor.is_none()); + + 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 listed = service.handle_http_request( + "GET /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\ncontent-length: 0\r\n\r\n", + ); + assert_eq!(listed.status_code, 200); + let page = AnalysisRunCollection::from_json(&listed.body).expect("page"); + assert_eq!(page.runs.len(), 1); + assert_eq!(page.runs[0].run_id, run_id); + assert_eq!( + page.runs[0].run_state, + crate::AnalysisRunStatusState::Accepted + ); + assert_eq!(page.runs[0].idempotency_key, run.idempotency_key); + assert!(!listed.body.contains("rmse")); + assert!(!listed.body.contains("scientific_acceptance")); + assert!(!listed.body.contains("terminal_result")); + assert!(!listed.body.contains("tenant_workspace_id")); + + let replay = service.handle_http_request( + "GET /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\ncontent-length: 0\r\n\r\n", + ); + assert_eq!(replay.body, listed.body); + + let lineageweave = service.handle_http_request( + "GET /v1/analysis-runs 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!(lineageweave.status_code, 400); + assert_eq!( + service + .handle_http_request( + "GET /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\ncontent-length: 2\r\n\r\n{}" + ) + .status_code, + 400 + ); + assert_eq!( + service + .handle_http_request( + "GET /v1/analysis-runs/missing 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 HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: naruon\r\ntepp-contract-version: 1\r\ntepp-page-cursor: missing\r\ncontent-length: 0\r\n\r\n" + ) + .status_code, + 400 + ); + assert_eq!( + service + .handle_http_request( + "GET /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\ntepp-page-limit: 0\r\ncontent-length: 0\r\n\r\n" + ) + .status_code, + 400 + ); + assert_eq!( + service + .handle_http_request( + "GET /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\ntepp-page-limit: 99\r\ncontent-length: 0\r\n\r\n" + ) + .status_code, + 413 + ); + let metrics = service.handle_http_request( + "GET /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\ncontent-length: 16\r\n\r\n{\"rmse\":0.1}", + ); + assert_eq!(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..6b3328f27 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, AnalysisRunCollection, + AnalysisRunLiveService, AnalysisRunRequest, ApiError, LINEAGEWEAVE_CONSUMER_CODE, + NARUON_ANALYSIS_RUN_PATH, NARUON_CONSUMER_CODE, NARUON_LIVE_HEADER_BYTE_LIMIT, + lineageweave_analysis_run_collection_exchange, lineageweave_analysis_run_exchange, }; fn sample_run() -> AnalysisRunRequest { @@ -153,3 +154,58 @@ fn live_listener_serves_lineageweave_over_loopback() { 202 ); } + +#[test] +fn lineageweave_collection_exchange_uses_the_published_consumer_header_without_credentials() { + let exchange = + lineageweave_analysis_run_collection_exchange("https://tepp.example.test", None, None) + .expect("lineageweave collection exchange"); + assert_eq!(exchange.method, "GET"); + assert_eq!( + exchange.target_url, + "https://tepp.example.test/v1/analysis-runs" + ); + 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_lists_lineageweave_runs_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 list = format!( + "GET {NARUON_ANALYSIS_RUN_PATH} 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" + ); + let listed = service.handle_http_request(&list); + assert_eq!(listed.status_code, 200); + let page = AnalysisRunCollection::from_json(&listed.body).expect("page"); + assert_eq!(page.runs.len(), 1); + assert_eq!(page.runs[0].run_id, accepted.run_id); + assert!(!listed.body.contains("rmse")); + assert!(!listed.body.contains("scientific_acceptance")); + let naruon_list = format!( + "GET {NARUON_ANALYSIS_RUN_PATH} 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" + ); + let naruon_page = + AnalysisRunCollection::from_json(&service.handle_http_request(&naruon_list).body) + .expect("naruon empty"); + assert!(naruon_page.runs.is_empty()); +} diff --git a/crates/tepp_api/tests/loopback_binary_contract.rs b/crates/tepp_api/tests/loopback_binary_contract.rs index 20e475647..b16c76a51 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_lists_an_accepted_analysis_run_collection_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-collection-idem","tenant_workspace_id":"loopback-collection-tenant","snapshot_id":"loopback-collection-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-collection-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 list = format!( + "GET /v1/analysis-runs 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 collection"); + stream.write_all(list.as_bytes()).expect("list"); + let mut listed = String::new(); + stream.read_to_string(&mut listed).expect("listed"); + assert!(listed.starts_with("HTTP/1.1 200 OK")); + assert!(listed.contains(&format!("\"run_id\":\"{run_id}\""))); + assert!(listed.contains("\"run_state\":\"accepted\"")); + assert!(listed.contains("\"idempotency_key\":\"loopback-collection-idem\"")); + assert!(!listed.contains("rmse")); + assert!(!listed.contains("scientific_acceptance")); + assert!(!listed.contains("terminal_result")); + assert!(!listed.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..acf35f700 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, AnalysisRunCollection, 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,46 @@ 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_collection_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 list = http_request( + "GET", + NARUON_ANALYSIS_RUN_PATH, + &[ + ("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 listed = service.handle_http_request(&list); + assert_eq!(listed.status_code, 200); + let page = AnalysisRunCollection::from_json(&listed.body).expect("page"); + assert_eq!(page.runs.len(), 1); + assert_eq!(page.runs[0].run_id, accepted.run_id); + assert_eq!(page.runs[0].idempotency_key, run.idempotency_key); + assert!(!listed.body.contains("rmse")); + assert!(!listed.body.contains("scientific_acceptance")); + let lineageweave = http_request( + "GET", + NARUON_ANALYSIS_RUN_PATH, + &[ + ("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 88782097a..406d1e840 100644 --- a/docs/API_CONTRACT.md +++ b/docs/API_CONTRACT.md @@ -84,8 +84,10 @@ on the loopback listener transitions accepted or running runs to cancelled; succeeded, failed, and unknown runs fail closed. `GET /v1/analysis-runs` on the loopback listener returns a metric-free collection of those states so operators do not guess run identities. Collection bodies never carry -`tepp.scientific_acceptance.v1`. GET-by-id remains a later slice on this -protected-main lineage. +`tepp.scientific_acceptance.v1`. `lineageweave_analysis_run_collection_exchange` +builds the same GET for LineageWeave. `NaruonLiveService` serves collection GET +for Naruon only; 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 path behind these DTOs. It consumes a bounded identity-free snapshot, excludes diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index b4509e6fd..ddac43b6c 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -55,6 +55,7 @@ The full APA 7th standards/literature register remains `docs/research/standards- | 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); request-bound terminal result active in PR #157; HTTP service remains accepted-target; the `orchestrator_live` loopback interpretation listener is on this PR | partial | | loopback analysis-run cancel HTTP | ADR 0029; API contract; RFC 9110 | `tepp_api` `POST /v1/analysis-runs/{run_id}/cancel` on `AnalysisRunLiveService`: metric-free cancelled status for accepted/running runs; succeeded/failed/unknown refuse; GET status remains a later slice | active-PR | | loopback analysis-run collection GET | ADR 0031; API contract; RFC 9110 | `tepp_api` `GET /v1/analysis-runs` on `AnalysisRunLiveService`: metric-free enumeration of accepted/running/cancelled/terminal runs; collection bodies refuse scientific-acceptance and RMSE keys; GET-by-id remains a later slice | active-PR | +| loopback analysis-run collection consumer parity | ADR 0042; API contract; RFC 9110 | `tepp_api` LineageWeave collection exchange, Naruon compatibility-listener list, and `tepp-loopback` TCP create-then-list; 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/0042-analysis-run-collection-consumer-parity.md b/docs/adr/0042-analysis-run-collection-consumer-parity.md new file mode 100644 index 000000000..5f843ed2a --- /dev/null +++ b/docs/adr/0042-analysis-run-collection-consumer-parity.md @@ -0,0 +1,70 @@ +# ADR 0042 — Analysis-run collection GET consumer parity + +**Decision status:** Accepted +**Implementation maturity:** active-PR +**Date:** 2026-08-31 +**Supersedes:** None; complements ADR 0031 and ADR 0018. Does not supersede ADR 0014. ADR 0026–0041 remain on other live PRs (0041 is the scientific-acceptance execute CLI). This ADR number is unique on the collection GET lineage. + +## Context + +ADR 0031 added `GET /v1/analysis-runs` on `AnalysisRunLiveService` and a Naruon collection exchange builder. The Naruon compatibility listener (`NaruonLiveService`) still refused every GET path. `LineageWeave` had a create-exchange builder but no collection exchange, so a published consumer would have to mint a Naruon-labelled list. The packaged `tepp-loopback` binary had no TCP proof that collection GET works on the shared listener. + +Duplicating the collection DTO, GET status, lifecycle POST, stored-request GET, cancel consumer-parity, retry, or engine-library slices would not close this consumer-parity gap. + +## Decision + +- `lineageweave_analysis_run_collection_exchange` reuses the Naruon collection builder and replaces only `tepp-consumer`. +- `NaruonLiveService` serves the same metric-free collection GET for the Naruon-only compatibility listener. LineageWeave consumers remain refused there; they use `AnalysisRunLiveService`. +- `tepp-loopback` proves create-then-list over loopback TCP. +- Collection payloads stay metric-free. Unknown cursors, consumer mismatch, nonempty bodies, GET-by-id, and metric keys still fail closed. + +## Non-goals + +- GET status, running/terminal POST, stored-request GET, retry, cancel consumer-parity, persistence, or production TLS. +- Opening `NaruonLiveService` to LineageWeave. +- An ADR 0014 scientific claim. + +## Alternatives considered + +1. **Leave collection GET 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 collection DTO** — rejected as a duplicate of ADR 0031. +4. **Consumer-parity collection GET on the existing typed list** — accepted. + +## Consequences + +- Both published consumers can build a credential-free collection GET. +- Naruon local proofs can enumerate accepted runs on either listener. +- Operators can observe collection GET through `tepp-loopback` without a second HTTP stack. + +## Failure and recovery + +Unknown cursors, consumer mismatch, nonempty bodies, metric keys, GET-by-id, and oversized page limits 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. +- Collection remains loopback-only and metric-free. +- HTTP `200` list is not measurement or release evidence. + +## Compatibility and migration + +ADR 0031 create/list semantics are unchanged. Production adapters may replace loopback while preserving consumer identity, metric-free collection rows, and Naruon-only compatibility-listener admission. + +## Verification + +- LineageWeave collection exchange carries `tepp-consumer: lineageweave` and no credentials; +- NaruonLiveService lists accepted Naruon runs and refuses LineageWeave, metrics, nonempty bodies, GET-by-id, and unknown cursors; +- `tepp-loopback` create-then-list over TCP returns metric-free collection rows; +- Clippy `-D warnings`, `tepp_api` tests, rustdoc, and exact-head review remain required. + +## Rollback and supersession + +Rollback removes the LineageWeave builder, compatibility-listener list, and binary TCP proof; ADR 0031 shared-listener collection GET remains. A superseding ADR is required to persist the registry, bind a public address, or treat HTTP success as an ADR 0014 claim. + +## Related authority + +- ADR 0031 owns the shared-listener collection GET path and metric-free list rows. +- 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 b4111e69c..8ecdcbf6a 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -32,6 +32,7 @@ Read [`ADR_POLICY.md`](ADR_POLICY.md) first. **Decision status and implementatio | [0025](0025-macos-native-rust-mlx-metal-boundary.md) | macOS-native Rust-owned MLX Metal execution | Accepted | accepted-target | Compose authenticates to a native host service; Linux never claims Metal, and actual backend/parity receipts fail closed. | | [0029](0029-analysis-run-cancel-http.md) | Loopback POST analysis-run cancel is metric-free cancelled status | Accepted | active-PR | Complements ADR 0018; does not supersede ADR 0014. ADR 0026–0028 live on other GAP-003A PRs. | | [0031](0031-analysis-run-collection-get.md) | Loopback GET analysis-run collection is metric-free enumeration | Accepted | active-PR | Complements ADR 0018/0029; does not supersede ADR 0014. ADR 0026–0030 live on other GAP-003A PRs. | +| [0042](0042-analysis-run-collection-consumer-parity.md) | LineageWeave and Naruon compatibility-listener collection GET | Accepted | active-PR | Complements ADR 0031/0018; does not supersede ADR 0014. ADR 0026–0041 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. | @@ -144,6 +145,7 @@ Use the narrowest owning ADR when decisions overlap: - **macOS-native Rust-owned MLX Metal execution:** ADR 0024. - **analysis-run cancel HTTP:** ADR 0029. - **analysis-run collection GET:** ADR 0031. +- **analysis-run collection consumer parity:** ADR 0042. ## Change and supersession rule diff --git a/docs/connectors/naruon-artifact-consumer.md b/docs/connectors/naruon-artifact-consumer.md index 5bdc328a1..acd6029ad 100644 --- a/docs/connectors/naruon-artifact-consumer.md +++ b/docs/connectors/naruon-artifact-consumer.md @@ -30,6 +30,7 @@ TEPP remains the scientific authority for estimation, recovery metrics, temporal | 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 | +| Live loopback collection GET | `tepp_api` `NaruonLiveService` → `GET /v1/analysis-runs` (Naruon only) | naruon → TEPP | Committed examples live under `examples/`. Schemas for analysis-run requests and corpus-split manifests live under `schemas/`. diff --git a/docs/research/analysis-run-collection-consumer-parity.md b/docs/research/analysis-run-collection-consumer-parity.md new file mode 100644 index 000000000..6b6fa3168 --- /dev/null +++ b/docs/research/analysis-run-collection-consumer-parity.md @@ -0,0 +1,42 @@ +# Analysis-run collection consumer parity (doctoring) + +## Scope + +`LineageWeave` and the Naruon compatibility listener must be able to enumerate +accepted analysis runs 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 0031; ADR 0042), not an +RFC inference rule. + +This slice does not serve GET status, running/terminal POST, stored-request +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/0031-analysis-run-collection-get.md` — shared-listener collection +- `docs/adr/0042-analysis-run-collection-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 collection exchange sets only the published consumer header; +- NaruonLiveService lists accepted Naruon runs and refuses LineageWeave; +- collection JSON has no RMSE/bias/coverage/SE-gate/scientific-acceptance keys; +- `tepp-loopback` create-then-list over TCP returns `200` with metric-free rows. + +## Non-claims + +This slice does not implement GET status, lifecycle POST, persistence, +production TLS, or an ADR 0014 scientific claim.