diff --git a/CHANGELOG.d/analysis-run-cancel-consumer-parity.md b/CHANGELOG.d/analysis-run-cancel-consumer-parity.md new file mode 100644 index 000000000..2a5b4adec --- /dev/null +++ b/CHANGELOG.d/analysis-run-cancel-consumer-parity.md @@ -0,0 +1 @@ +- `tepp_api` adds `lineageweave_analysis_run_cancel_exchange`, Naruon compatibility-listener cancel, and a `tepp-loopback` TCP cancel proof (ADR 0030). Metric-free cancelled status is unchanged from ADR 0029. Not GET status, not lifecycle POST, not an ADR 0014 claim. diff --git a/CHANGELOG.md b/CHANGELOG.md index 29a24a159..983e9c22e 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] +- `tepp_api` adds `lineageweave_analysis_run_cancel_exchange`, Naruon compatibility-listener cancel, and a `tepp-loopback` TCP cancel proof (ADR 0030). Metric-free cancelled status is unchanged from ADR 0029. Not GET status, not lifecycle POST, 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. - `event_core` adds bounded Allen interval-consistency classification, atomic path-consistency closure, contradiction/resource refusals, and an explicit dependency-error fallback without claiming unrestricted global satisfiability. diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 5ada27f8e..83ac2a7a8 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -14,6 +14,7 @@ TEPP's approved PRD v0.4 and implementation plan are the primary product baselin | contextual-orchestrator interpretation port | [`docs/connectors/contextual-orchestrator-interpretation-port.md`](docs/connectors/contextual-orchestrator-interpretation-port.md) | | 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 cancel consumer-parity doctoring | [`docs/research/analysis-run-cancel-consumer-parity.md`](docs/research/analysis-run-cancel-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 cdc7f7cf9..31d198536 100644 --- a/crates/tepp_api/src/lib.rs +++ b/crates/tepp_api/src/lib.rs @@ -153,6 +153,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` analysis-run cancel exchange without credentials. +pub use lineageweave_http::lineageweave_analysis_run_cancel_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..5acfe0aca 100644 --- a/crates/tepp_api/src/lineageweave_http.rs +++ b/crates/tepp_api/src/lineageweave_http.rs @@ -3,9 +3,10 @@ use crate::naruon_http::compose_https_target; use crate::project_history::build_project_history_exchange; use crate::{ - AnalysisRunRequest, ApiError, NaruonHttpExchange, ProjectHistoryHttpExchange, - ProjectHistoryRequest, TEMPORAL_CONTEXT_CONTRACT_VERSION, TEMPORAL_CONTEXT_PATH, - TemporalContextRequest, naruon_analysis_run_exchange, + AnalysisRunCancelRequest, AnalysisRunRequest, ApiError, NaruonHttpExchange, + ProjectHistoryHttpExchange, ProjectHistoryRequest, TEMPORAL_CONTEXT_CONTRACT_VERSION, + TEMPORAL_CONTEXT_PATH, TemporalContextRequest, naruon_analysis_run_cancel_exchange, + naruon_analysis_run_exchange, }; /// Stable consumer identity used by the Naruon adapter. @@ -38,6 +39,29 @@ pub fn lineageweave_analysis_run_exchange( Ok(exchange) } +/// Build a `LineageWeave` → TEPP analysis-run cancel exchange without credentials. +/// +/// The function reuses TEPP's existing origin, body, and header validation, +/// then replaces only the published modular-consumer identity. Cancellation +/// remains a metric-free lifecycle command, not a measurement result. +/// +/// # Errors +/// +/// Returns the same fail-closed errors as [`naruon_analysis_run_cancel_exchange`]. +pub fn lineageweave_analysis_run_cancel_exchange( + origin: &str, + request: &AnalysisRunCancelRequest, +) -> Result { + let mut exchange = naruon_analysis_run_cancel_exchange(origin, request)?; + 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,9 +118,11 @@ 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_cancel_exchange, lineageweave_analysis_run_exchange, + }; + use crate::{ + ANALYSIS_RUN_CONTRACT_VERSION, AnalysisRunCancelRequest, AnalysisRunRequest, ApiError, }; - use crate::{ANALYSIS_RUN_CONTRACT_VERSION, AnalysisRunRequest, ApiError}; fn sample_run() -> AnalysisRunRequest { AnalysisRunRequest { @@ -132,4 +158,31 @@ mod tests { Err(ApiError::InvalidWirePayload) ); } + + #[test] + fn lineageweave_cancel_exchange_swaps_only_the_consumer_header() { + let request = AnalysisRunCancelRequest::new("tepp-run-1", "idem-1").expect("cancel"); + let exchange = + lineageweave_analysis_run_cancel_exchange("https://tepp.example.test", &request) + .expect("exchange"); + assert_eq!(exchange.method, "POST"); + assert_eq!( + exchange.target_url, + "https://tepp.example.test/v1/analysis-runs/tepp-run-1/cancel" + ); + assert!( + exchange + .headers + .contains(&("tepp-consumer".into(), LINEAGEWEAVE_CONSUMER_CODE.into())) + ); + assert!( + !exchange + .headers + .contains(&("tepp-consumer".into(), NARUON_CONSUMER_CODE.into())) + ); + assert_eq!( + lineageweave_analysis_run_cancel_exchange("http://tepp.example.test", &request), + Err(ApiError::InvalidWirePayload) + ); + } } diff --git a/crates/tepp_api/src/naruon_live.rs b/crates/tepp_api/src/naruon_live.rs index f9b4ca327..590f3658d 100644 --- a/crates/tepp_api/src/naruon_live.rs +++ b/crates/tepp_api/src/naruon_live.rs @@ -5,6 +5,9 @@ use std::io::{Read, Write}; use std::net::{SocketAddr, TcpListener, TcpStream}; use std::time::Duration; +use crate::analysis_run_cancel_http::{ + AnalysisRunCancelRequest, analysis_run_cancel_path_run_id, refuse_metrics_on_cancel_payload, +}; use crate::authorization::{ AnalyticalPurpose, ExportAuthorizationRequest, authorize_export, require_export_allowed, }; @@ -16,8 +19,8 @@ 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, - requests_are_idempotent_matches, + AnalysisRunAccepted, AnalysisRunRequest, AnalysisRunStatus, AnalysisRunStatusState, ApiError, + ErrorEnvelope, requests_are_idempotent_matches, }; #[cfg(test)] @@ -51,14 +54,24 @@ 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 only accepts versioned naruon POSTs, including +/// `POST /v1/analysis-runs/{run_id}/cancel` for metric-free cancellation. #[derive(Debug)] pub struct NaruonLiveService { listener: Option, bound_addr: Option, next_run_serial: u64, next_request_serial: u64, - accepted_runs: HashMap, + accepted_runs: HashMap, + runs_by_id: HashMap, +} + +/// One naruon-only accepted run and its current lifecycle state. +#[derive(Clone, Debug, Eq, PartialEq)] +struct NaruonLiveRun { + request: AnalysisRunRequest, + accepted: AnalysisRunAccepted, + run_state: AnalysisRunStatusState, } impl Default for NaruonLiveService { @@ -77,6 +90,7 @@ impl NaruonLiveService { next_run_serial: 1, next_request_serial: 1, accepted_runs: HashMap::new(), + runs_by_id: HashMap::new(), } } @@ -201,11 +215,17 @@ impl NaruonLiveService { if method != "POST" { return Err(ApiError::InvalidWirePayload); } + let headers = parse_headers(lines)?; + refuse_live_headers(&headers, self.bound_addr)?; + if matches!( + analysis_run_cancel_path_run_id(path), + Ok(_) | Err(ApiError::LimitExceeded) + ) { + return self.cancel_analysis_run(path, &headers, body); + } 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)?; self.dispatch_path(path, &headers, body) } @@ -233,12 +253,12 @@ impl NaruonLiveService { return Err(ApiError::InvalidWirePayload); } let replay_key = tenant_idempotency_key(&request.tenant_workspace_id, idempotency_key); - if let Some((stored_request, stored_accepted)) = self.accepted_runs.get(&replay_key) { - if requests_are_idempotent_matches(stored_request, &request) { + if let Some(stored) = self.accepted_runs.get(&replay_key) { + if requests_are_idempotent_matches(&stored.request, &request) { return Ok(NaruonLiveResponse::json( 202, "Accepted", - stored_accepted.to_json()?, + stored.accepted.to_json()?, )); } return Err(ApiError::InvalidWirePayload); @@ -248,10 +268,80 @@ impl NaruonLiveService { let accepted = AnalysisRunAccepted::new(run_id, "accepted", request.idempotency_key.clone())?; let body = accepted.to_json()?; - self.accepted_runs.insert(replay_key, (request, accepted)); + self.runs_by_id + .insert(accepted.run_id.clone(), replay_key.clone()); + self.accepted_runs.insert( + replay_key, + NaruonLiveRun { + request, + accepted, + run_state: AnalysisRunStatusState::Accepted, + }, + ); Ok(NaruonLiveResponse::json(202, "Accepted", body)) } + fn cancel_analysis_run( + &mut self, + path: &str, + headers: &HashMap, + body: &str, + ) -> Result { + let run_id = analysis_run_cancel_path_run_id(path)?; + refuse_metrics_on_cancel_payload(body)?; + let idempotency_key = header_value(headers, "idempotency-key")?; + if !body.trim().is_empty() { + let request = AnalysisRunCancelRequest::from_json(body)?; + if request.run_id != run_id || request.idempotency_key != idempotency_key { + return Err(ApiError::InvalidWirePayload); + } + } + let replay_key = self + .runs_by_id + .get(&run_id) + .cloned() + .ok_or(ApiError::InvalidWirePayload)?; + let stored = self + .accepted_runs + .get_mut(&replay_key) + .ok_or(ApiError::InvalidWirePayload)?; + if stored.accepted.idempotency_key != idempotency_key { + return Err(ApiError::InvalidWirePayload); + } + match stored.run_state { + AnalysisRunStatusState::Accepted | AnalysisRunStatusState::Running => { + stored.run_state = AnalysisRunStatusState::Cancelled; + } + AnalysisRunStatusState::Cancelled => {} + AnalysisRunStatusState::Succeeded | AnalysisRunStatusState::Failed => { + return Err(ApiError::InvalidWirePayload); + } + } + let status = AnalysisRunStatus::cancelled(&stored.accepted)?; + let status_json = status.to_json()?; + refuse_metrics_on_cancel_payload(&status_json)?; + Ok(NaruonLiveResponse::json(200, "OK", status_json)) + } + + #[cfg(test)] + fn force_naruon_run_state( + &mut self, + run_id: &str, + run_state: AnalysisRunStatusState, + ) -> Result<(), ApiError> { + let replay_key = self + .runs_by_id + .get(run_id) + .cloned() + .ok_or(ApiError::InvalidWirePayload)?; + let stored = self + .accepted_runs + .get_mut(&replay_key) + .ok_or(ApiError::InvalidWirePayload)?; + stored.run_state = run_state; + Ok(()) + } + fn authorize_export( headers: &HashMap, body: &str, @@ -531,4 +621,130 @@ mod tests { ApiError::InvalidWirePayload ); } + + #[test] + #[allow(clippy::too_many_lines)] + fn naruon_compatibility_listener_cancels_accepted_and_running_runs() { + use crate::{ANALYSIS_RUN_CONTRACT_VERSION, AnalysisRunRequest, AnalysisRunStatusState}; + let run = AnalysisRunRequest { + contract_version: ANALYSIS_RUN_CONTRACT_VERSION, + idempotency_key: "naruon-cancel-idem".into(), + tenant_workspace_id: "naruon-cancel-tenant".into(), + snapshot_id: "naruon-cancel-snapshot".into(), + knowledge_cutoff: "2026-08-01T00:00:00Z".into(), + model_contract_version: "topic-measurement-v1".into(), + output_profile: "naruon-consumer-validation-report".into(), + }; + let body = run.to_json().expect("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: {}\r\ncontent-length: {}\r\n\r\n{body}", + run.idempotency_key, + body.len() + ); + let mut service = NaruonLiveService::new(); + let accepted = service.handle_http_request(&create); + assert_eq!(accepted.status_code, 202); + let run_id = crate::AnalysisRunAccepted::from_json(&accepted.body) + .expect("accepted") + .run_id; + let first_run_id = run_id.clone(); + let cancel = format!( + "POST /v1/analysis-runs/{run_id}/cancel 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: {}\r\ncontent-length: 0\r\n\r\n", + run.idempotency_key + ); + let cancelled = service.handle_http_request(&cancel); + assert_eq!(cancelled.status_code, 200); + assert!(cancelled.body.contains("\"run_state\":\"cancelled\"")); + assert!(!cancelled.body.contains("rmse")); + let replay = service.handle_http_request(&cancel); + assert_eq!(replay.body, cancelled.body); + + let mut running = NaruonLiveService::new(); + let accepted = running.handle_http_request(&create); + let run_id = crate::AnalysisRunAccepted::from_json(&accepted.body) + .expect("accepted") + .run_id; + running + .force_naruon_run_state(&run_id, AnalysisRunStatusState::Running) + .expect("running"); + let cancel = format!( + "POST /v1/analysis-runs/{run_id}/cancel 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: {}\r\ncontent-length: 0\r\n\r\n", + run.idempotency_key + ); + assert_eq!(running.handle_http_request(&cancel).status_code, 200); + + for terminal in [ + AnalysisRunStatusState::Succeeded, + AnalysisRunStatusState::Failed, + ] { + let mut terminal_service = NaruonLiveService::new(); + let accepted = terminal_service.handle_http_request(&create); + let run_id = crate::AnalysisRunAccepted::from_json(&accepted.body) + .expect("accepted") + .run_id; + terminal_service + .force_naruon_run_state(&run_id, terminal) + .expect("terminal"); + let cancel = format!( + "POST /v1/analysis-runs/{run_id}/cancel 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: {}\r\ncontent-length: 0\r\n\r\n", + run.idempotency_key + ); + assert_eq!( + terminal_service.handle_http_request(&cancel).status_code, + 400 + ); + } + + assert_eq!( + service.handle_http_request( + "POST /v1/analysis-runs/missing/cancel 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-cancel-idem\r\ncontent-length: 0\r\n\r\n" + ).status_code, + 400 + ); + let lineageweave = format!( + "POST /v1/analysis-runs/{first_run_id}/cancel HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: lineageweave\r\ntepp-contract-version: 1\r\nidempotency-key: {}\r\ncontent-length: 0\r\n\r\n", + run.idempotency_key + ); + assert_eq!(service.handle_http_request(&lineageweave).status_code, 400); + let metrics = format!( + "POST /v1/analysis-runs/{first_run_id}/cancel 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: {}\r\ncontent-length: 12\r\n\r\n{{\"rmse\":0.1}}", + run.idempotency_key + ); + assert_eq!(service.handle_http_request(&metrics).status_code, 400); + assert_eq!( + NaruonLiveService::new() + .force_naruon_run_state("missing", AnalysisRunStatusState::Running) + .expect_err("unknown"), + ApiError::InvalidWirePayload + ); + let mut dangling = NaruonLiveService::new(); + dangling + .runs_by_id + .insert("ghost".into(), "missing-replay".into()); + assert_eq!( + dangling + .force_naruon_run_state("ghost", AnalysisRunStatusState::Running) + .expect_err("dangling"), + ApiError::InvalidWirePayload + ); + let cancel_dangling = "POST /v1/analysis-runs/ghost/cancel 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-cancel-idem\r\ncontent-length: 0\r\n\r\n"; + assert_eq!( + dangling.handle_http_request(cancel_dangling).status_code, + 400 + ); + let oversized = format!( + "POST /v1/analysis-runs/{}/cancel 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: k\r\ncontent-length: 0\r\n\r\n", + "a".repeat(129) + ); + assert_eq!(service.handle_http_request(&oversized).status_code, 413); + let mismatch_body = format!( + "{{\"contract_version\":1,\"run_id\":\"{first_run_id}\",\"idempotency_key\":\"other\"}}" + ); + let mismatch = format!( + "POST /v1/analysis-runs/{first_run_id}/cancel 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: {}\r\ncontent-length: {}\r\n\r\n{mismatch_body}", + run.idempotency_key, + mismatch_body.len() + ); + assert_eq!(service.handle_http_request(&mismatch).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..d6851c3ee 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, AnalysisRunCancelRequest, + AnalysisRunLiveService, AnalysisRunRequest, ApiError, LINEAGEWEAVE_CONSUMER_CODE, + NARUON_ANALYSIS_RUN_PATH, NARUON_CONSUMER_CODE, NARUON_LIVE_HEADER_BYTE_LIMIT, + lineageweave_analysis_run_cancel_exchange, lineageweave_analysis_run_exchange, }; fn sample_run() -> AnalysisRunRequest { @@ -68,6 +69,34 @@ fn lineageweave_exchange_uses_the_published_consumer_header_without_credentials( })); } +#[test] +fn lineageweave_cancel_exchange_posts_the_published_consumer_without_credentials() { + let request = AnalysisRunCancelRequest::new("tepp-run-9", "shared-idempotency-key") + .expect("cancel request"); + let exchange = lineageweave_analysis_run_cancel_exchange("https://tepp.example.test", &request) + .expect("lineageweave cancel"); + assert_eq!(exchange.method, "POST"); + assert_eq!( + exchange.target_url, + "https://tepp.example.test/v1/analysis-runs/tepp-run-9/cancel" + ); + assert!( + exchange + .headers + .contains(&("tepp-consumer".into(), LINEAGEWEAVE_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_cancel_exchange("http://tepp.example.test", &request), + Err(ApiError::InvalidWirePayload) + ); +} + #[test] fn live_listener_accepts_lineageweave_and_isolates_consumer_idempotency() { let loopback = AnalysisRunLiveService::bind_loopback().expect("loopback bind"); diff --git a/crates/tepp_api/tests/loopback_binary_contract.rs b/crates/tepp_api/tests/loopback_binary_contract.rs index 20e475647..eddc81a63 100644 --- a/crates/tepp_api/tests/loopback_binary_contract.rs +++ b/crates/tepp_api/tests/loopback_binary_contract.rs @@ -29,3 +29,44 @@ fn binary_serves_one_bounded_temporal_context_request() { assert!(response.contains("association_not_causal")); assert!(child.wait().expect("wait").success()); } + +#[test] +fn binary_cancels_an_accepted_analysis_run_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-cancel-idem","tenant_workspace_id":"loopback-cancel-tenant","snapshot_id":"loopback-cancel-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: naruon\r\ntepp-contract-version: 1\r\nidempotency-key: loopback-cancel-idem\r\ncontent-length: {}\r\n\r\n{body}", + body.len() + ); + let mut stream = TcpStream::connect(host).expect("connect create"); + stream.write_all(create.as_bytes()).expect("create"); + let mut created = String::new(); + stream.read_to_string(&mut created).expect("created"); + assert!(created.starts_with("HTTP/1.1 202 Accepted")); + let json_start = created.find("{\"contract_version\"").expect("json"); + let accepted: serde_json::Value = + serde_json::from_str(&created[json_start..]).expect("accepted json"); + let run_id = accepted["run_id"].as_str().expect("run_id"); + assert!(!created[json_start..].contains("rmse")); + let cancel = format!( + "POST /v1/analysis-runs/{run_id}/cancel HTTP/1.1\r\nHost: {host}\r\ncontent-type: application/json\r\ntepp-consumer: naruon\r\ntepp-contract-version: 1\r\nidempotency-key: loopback-cancel-idem\r\ncontent-length: 0\r\n\r\n" + ); + let mut stream = TcpStream::connect(host).expect("connect cancel"); + stream.write_all(cancel.as_bytes()).expect("cancel"); + let mut cancelled = String::new(); + stream.read_to_string(&mut cancelled).expect("cancelled"); + assert!(cancelled.starts_with("HTTP/1.1 200 OK")); + assert!(cancelled.contains("\"run_state\":\"cancelled\"")); + assert!(!cancelled.contains("rmse")); + assert!(!cancelled.contains("scientific_acceptance")); + 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..04e295ccd 100644 --- a/crates/tepp_api/tests/naruon_live_http_contract.rs +++ b/crates/tepp_api/tests/naruon_live_http_contract.rs @@ -143,6 +143,38 @@ fn handle_http_accepts_analysis_run_and_replays_idempotent_retries() { ); } +#[test] +fn handle_http_cancels_accepted_naruon_runs_without_metrics() { + let mut service = NaruonLiveService::new(); + let run = sample_run(); + let created = service.handle_http_request(&analysis_http(&run)); + let accepted = AnalysisRunAccepted::from_json(&created.body).expect("accepted"); + let cancel_body = format!( + "{{\"contract_version\":1,\"run_id\":\"{}\",\"idempotency_key\":\"{}\"}}", + accepted.run_id, run.idempotency_key + ); + let cancel = http_request( + "POST", + &format!("/v1/analysis-runs/{}/cancel", accepted.run_id), + &naruon_headers(&run.idempotency_key), + &cancel_body, + ); + let cancelled = service.handle_http_request(&cancel); + assert_eq!(cancelled.status_code, 200); + assert!(cancelled.body.contains("\"run_state\":\"cancelled\"")); + assert!(!cancelled.body.contains("rmse")); + assert!(!cancelled.body.contains("scientific_acceptance")); + let replay = service.handle_http_request(&cancel); + assert_eq!(replay.body, cancelled.body); + let wrong_key = http_request( + "POST", + &format!("/v1/analysis-runs/{}/cancel", accepted.run_id), + &naruon_headers("wrong-key"), + "", + ); + assert_eq!(service.handle_http_request(&wrong_key).status_code, 400); +} + #[test] fn handle_http_keys_idempotency_replay_by_tenant_and_key() { let mut service = NaruonLiveService::new(); diff --git a/docs/API_CONTRACT.md b/docs/API_CONTRACT.md index 8dc69e27d..1583c6f38 100644 --- a/docs/API_CONTRACT.md +++ b/docs/API_CONTRACT.md @@ -8,7 +8,7 @@ TEPP must work both as a standalone product and as a modular CWL component. Integrations with `naruon`, `contextual-orchestrator`, `.github`, or other repositories use explicit versioned API/artifact contracts. Cross-service direct table access is prohibited. -Current protected main exposes Rust library/domain contracts. The active stack adds a loopback HTTP/1.1 listener for naruon analysis-run, LineageWeave temporal-context, and export POSTs, including `POST /v1/project-histories` on the `AnalysisRunLiveService` contract boundary and `POST /v1/analysis-runs/{run_id}/cancel` for metric-free cancellation of accepted or running runs. `tepp-loopback` runs the shared consumer listener on `127.0.0.1:18081` by default; a caller may pass another loopback socket address and an optional maximum request count as its two arguments. The container is intended for a trusted same-host or shared-network-namespace sidecar, checks readiness through a synthetic bounded temporal-context request, and deliberately cannot bind a public or bridge address. It is not a production TLS/`$PORT` service. Endpoint examples below that are not covered by `NaruonLiveService` or `AnalysisRunLiveService` remain target interface shapes; export retrieval stays a target shape until an executable export route ships. +Current protected main exposes Rust library/domain contracts. The active stack adds a loopback HTTP/1.1 listener for naruon analysis-run, LineageWeave temporal-context, and export POSTs, including `POST /v1/project-histories` on the `AnalysisRunLiveService` contract boundary and `POST /v1/analysis-runs/{run_id}/cancel` for metric-free cancellation of accepted or running runs. The Naruon compatibility listener serves the same cancel path for Naruon only. `lineageweave_analysis_run_cancel_exchange` is the published LineageWeave cancel builder. `tepp-loopback` runs the shared consumer listener on `127.0.0.1:18081` by default; a caller may pass another loopback socket address and an optional maximum request count as its two arguments. The container is intended for a trusted same-host or shared-network-namespace sidecar, checks readiness through a synthetic bounded temporal-context request, and deliberately cannot bind a public or bridge address. It is not a production TLS/`$PORT` service. Endpoint examples below that are not covered by `NaruonLiveService` or `AnalysisRunLiveService` remain target interface shapes; export retrieval stays a target shape until an executable export route ships. ## 2. Contract families diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index d0242669e..d5c27f0e0 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -54,6 +54,7 @@ The full APA 7th standards/literature register remains `docs/research/standards- | known-truth temporal/event simulation manifests | PRD; TRD; Test Strategy | `tepp_simulation` on protected main; recovery metrics in `validation_core` | implemented-main | | 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 | +| analysis-run cancel consumer parity | ADR 0030; ADR 0029; RFC 9110 | `lineageweave_analysis_run_cancel_exchange`, `NaruonLiveService` cancel, and `tepp-loopback` TCP proof; LineageWeave remains refused on the Naruon-only listener | 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/0030-analysis-run-cancel-consumer-parity.md b/docs/adr/0030-analysis-run-cancel-consumer-parity.md new file mode 100644 index 000000000..365ab0087 --- /dev/null +++ b/docs/adr/0030-analysis-run-cancel-consumer-parity.md @@ -0,0 +1,70 @@ +# ADR 0030 — Analysis-run cancel consumer parity + +**Decision status:** Accepted +**Implementation maturity:** active-PR +**Date:** 2026-08-31 +**Supersedes:** None; complements ADR 0029 and ADR 0018. Does not supersede ADR 0014. ADR 0026–0028 remain on other GAP-003A slices. This ADR number is unique on the cancel-HTTP lineage; other live PRs may reuse 0030 on unrelated stacks. + +## Context + +ADR 0029 added `POST /v1/analysis-runs/{run_id}/cancel` on `AnalysisRunLiveService` and a Naruon cancel-exchange builder. The Naruon compatibility listener (`NaruonLiveService`) still refused every cancel path. `LineageWeave` had a create-exchange builder but no cancel-exchange, so a published consumer would have to mint a Naruon-labelled cancel. The packaged `tepp-loopback` binary had no TCP proof that cancel works on the shared listener. + +Duplicating the cancel DTO, GET status, lifecycle POST, collection GET, retry, or engine-library slices would not close this consumer-parity gap. + +## Decision + +- `lineageweave_analysis_run_cancel_exchange` reuses the Naruon cancel builder and replaces only `tepp-consumer`. +- `NaruonLiveService` serves the same metric-free cancel path for the Naruon-only compatibility listener. LineageWeave consumers remain refused there; they use `AnalysisRunLiveService`. +- `tepp-loopback` proves create-then-cancel over loopback TCP. +- Cancelled status stays metric-free. Succeeded, failed, and unknown runs still fail closed. + +## Non-goals + +- GET status, running/terminal POST, collection GET, retry, persistence, or production TLS. +- Opening `NaruonLiveService` to LineageWeave. +- An ADR 0014 scientific claim. + +## Alternatives considered + +1. **Leave cancel 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 cancel DTO** — rejected as a duplicate of ADR 0029. +4. **Consumer-parity cancel on the existing typed request** — accepted. + +## Consequences + +- Both published consumers can build a credential-free cancel exchange. +- Naruon local proofs can cancel on either listener. +- Operators can observe cancel through `tepp-loopback` without a second HTTP stack. + +## Failure and recovery + +Unknown runs, consumer mismatch, idempotency mismatch, metric keys, succeeded/failed runs, 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. +- Cancel remains loopback-only and metric-free. +- HTTP `200` cancelled is not measurement or release evidence. + +## Compatibility and migration + +ADR 0029 create/cancel semantics are unchanged. Production adapters may replace loopback while preserving consumer identity, metric-free cancelled status, and Naruon-only compatibility-listener admission. + +## Verification + +- LineageWeave cancel exchange carries `tepp-consumer: lineageweave` and no credentials; +- NaruonLiveService cancels accepted/running Naruon runs and refuses LineageWeave, metrics, and terminal runs; +- `tepp-loopback` create-then-cancel over TCP returns metric-free `cancelled`; +- Clippy `-D warnings`, `tepp_api` tests, rustdoc, and exact-head review remain required. + +## Rollback and supersession + +Rollback removes the LineageWeave builder, compatibility-listener cancel, and binary TCP proof; ADR 0029 shared-listener cancel remains. A superseding ADR is required to persist cancel, bind a public address, or treat HTTP success as an ADR 0014 claim. + +## Related authority + +- ADR 0029 owns the shared-listener cancel path and cancelled status. +- 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 e54dce807..697fe4a0d 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -31,6 +31,7 @@ Read [`ADR_POLICY.md`](ADR_POLICY.md) first. **Decision status and implementatio | [0024](0024-lineage-pair-criterion-and-project-journey-posterior.md) | Independent Event Lineage pair criterion and posterior Project Journey | Proposed | active-PR | Strict artifacts preserve criterion/event-time draws, branches, ties, and CPU/GPU receipts without claiming the scientific estimator is complete. | | [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. | +| [0030](0030-analysis-run-cancel-consumer-parity.md) | LineageWeave cancel exchange and Naruon compatibility-listener cancel | Accepted | active-PR | Complements ADR 0029; does not open NaruonLiveService to LineageWeave. | | [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. | @@ -142,6 +143,7 @@ Use the narrowest owning ADR when decisions overlap: - **independent lineage criterion and posterior Project Journey:** ADR 0023. - **macOS-native Rust-owned MLX Metal execution:** ADR 0024. - **analysis-run cancel HTTP:** ADR 0029. +- **analysis-run cancel consumer parity:** ADR 0030. ## Change and supersession rule diff --git a/docs/connectors/naruon-artifact-consumer.md b/docs/connectors/naruon-artifact-consumer.md index db635810d..5f847b7b4 100644 --- a/docs/connectors/naruon-artifact-consumer.md +++ b/docs/connectors/naruon-artifact-consumer.md @@ -27,8 +27,9 @@ 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 cancel | `tepp_api` `naruon_analysis_run_cancel_exchange` → `POST /v1/analysis-runs/{run_id}/cancel` | naruon → TEPP | +| HTTP analysis-run cancel (LineageWeave) | `tepp_api` `lineageweave_analysis_run_cancel_exchange` → `POST /v1/analysis-runs/{run_id}/cancel` | lineageweave → 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 POST | `tepp_api` `NaruonLiveService` → `POST /v1/analysis-runs`, `/v1/analysis-runs/{run_id}/cancel`, and `/v1/exports` | 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-cancel-consumer-parity.md b/docs/research/analysis-run-cancel-consumer-parity.md new file mode 100644 index 000000000..705114582 --- /dev/null +++ b/docs/research/analysis-run-cancel-consumer-parity.md @@ -0,0 +1,42 @@ +# Analysis-run cancel consumer parity (doctoring) + +## Scope + +`LineageWeave` and the Naruon compatibility listener must be able to cancel an +accepted or running analysis run without inventing a second DTO. HTTP method, +path, and `Host` semantics follow current HTTP semantics (Fielding, Nottingham, +& Reschke, 2022). Fail-closed refusal of non-loopback binds, table-access +hosts, review/Copilot/GitHub credential headers, and scientific-authority +promotion is repository contract authority (ADR 0011; ADR 0018; ADR 0029; +ADR 0030), not an RFC inference rule. + +This slice does not serve GET status, running/terminal POST, collection GET, +retry, 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/0029-analysis-run-cancel-http.md` — shared-listener cancel +- `docs/adr/0030-analysis-run-cancel-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 cancel exchange sets only the published consumer header; +- NaruonLiveService cancels accepted Naruon runs and refuses LineageWeave; +- cancelled JSON has no RMSE/bias/coverage/SE-gate/scientific-acceptance keys; +- `tepp-loopback` create-then-cancel over TCP returns `200` cancelled. + +## Non-claims + +This slice does not implement GET status, lifecycle POST, persistence, +production TLS, or an ADR 0014 scientific claim.