From c891d46a57c03e71f1ca44150a00a9d71aa2f2a9 Mon Sep 17 00:00:00 2001 From: Seongho Bae <8172694+seonghobae@users.noreply.github.com> Date: Mon, 31 Aug 2026 11:48:37 +0000 Subject: [PATCH] feat(api): cancel analysis runs via loopback CLI GAP-003A / issue #166: operators can cancel accepted and running runs through `tepp-analysis-runs cancel` without writing raw HTTP. Stdout stays metric-free; tepp.scientific_acceptance.v1 never appears. Stacked on collection CLI (#371). Does not duplicate GET-by-id, lifecycle POST, cancel HTTP, scientific-acceptance CLI, or collection list. --- CHANGELOG.d/analysis-run-cancel-cli.md | 1 + CHANGELOG.md | 2 + DOCUMENTATION.md | 1 + .../tepp_api/src/analysis_run_cancel_cli.rs | 985 ++++++++++++++++++ .../tepp_api/src/analysis_run_cancel_http.rs | 2 +- crates/tepp_api/src/bin/tepp_analysis_runs.rs | 31 +- crates/tepp_api/src/lib.rs | 15 + .../tests/analysis_run_cancel_cli_contract.rs | 78 ++ docs/API_CONTRACT.md | 5 +- docs/TRACEABILITY.md | 1 + docs/adr/0033-analysis-run-cancel-cli.md | 73 ++ docs/adr/README.md | 2 + docs/connectors/naruon-artifact-consumer.md | 1 + docs/research/analysis-run-cancel-cli.md | 64 ++ 14 files changed, 1255 insertions(+), 6 deletions(-) create mode 100644 CHANGELOG.d/analysis-run-cancel-cli.md create mode 100644 crates/tepp_api/src/analysis_run_cancel_cli.rs create mode 100644 crates/tepp_api/tests/analysis_run_cancel_cli_contract.rs create mode 100644 docs/adr/0033-analysis-run-cancel-cli.md create mode 100644 docs/research/analysis-run-cancel-cli.md diff --git a/CHANGELOG.d/analysis-run-cancel-cli.md b/CHANGELOG.d/analysis-run-cancel-cli.md new file mode 100644 index 000000000..14e273ca4 --- /dev/null +++ b/CHANGELOG.d/analysis-run-cancel-cli.md @@ -0,0 +1 @@ +- `tepp_api` loopback `tepp-analysis-runs cancel` withdraws metric-free accepted and running runs (ADR 0033). Cancel CLI stdout refuses RMSE/bias/coverage/SE-gate/scientific-acceptance keys and a non-null `terminal_result`. Not GET-by-id, not scientific-acceptance CLI, not collection list, not persistence. diff --git a/CHANGELOG.md b/CHANGELOG.md index 3df3d7446..1a79c1030 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` publishes `tepp-analysis-runs cancel` as the loopback client of `POST /v1/analysis-runs/{run_id}/cancel` (ADR 0033). Operators withdraw accepted or running runs as metric-free cancelled status without writing raw HTTP. CLI stdout refuses RMSE/bias/coverage/SE-gate/scientific-acceptance keys and a non-null `terminal_result`. Not GET-by-id, not the scientific-acceptance CLI, not collection list, and not an ADR 0014 claim. + - `tepp_api` publishes `tepp-analysis-runs list` as the loopback client of `GET /v1/analysis-runs` (ADR 0032). Operators enumerate accepted, running, cancelled, and terminal runs as metric-free collection rows without writing raw HTTP. CLI stdout refuses RMSE/bias/coverage/SE-gate/scientific-acceptance/`terminal_result` keys. Not GET-by-id, not the scientific-acceptance CLI, and not an ADR 0014 claim. - `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. diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index ca980638c..1f61318fa 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -16,6 +16,7 @@ TEPP's approved PRD v0.4 and implementation plan are the primary product baselin | 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 CLI doctoring | [`docs/research/analysis-run-collection-cli.md`](docs/research/analysis-run-collection-cli.md) | +| Analysis-run cancel CLI doctoring | [`docs/research/analysis-run-cancel-cli.md`](docs/research/analysis-run-cancel-cli.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/analysis_run_cancel_cli.rs b/crates/tepp_api/src/analysis_run_cancel_cli.rs new file mode 100644 index 000000000..925577fb1 --- /dev/null +++ b/crates/tepp_api/src/analysis_run_cancel_cli.rs @@ -0,0 +1,985 @@ +//! Operator loopback CLI for analysis-run cancel POST. +//! +//! GAP-003A ninth slice: operators run `tepp-analysis-runs cancel` to withdraw +//! accepted or running runs without writing raw HTTP. Stdout stays metric-free. +//! `tepp.scientific_acceptance.v1` never appears. This module does not duplicate +//! GET-by-id (#359), lifecycle POST (#360), cancel HTTP (#361), +//! scientific-acceptance CLI (#362), collection GET (#368), collection CLI list +//! (#371), or consumer-parity cancel (#373). Persistence remains GAP-003B. + +use std::io::{Read, Write}; +use std::net::{SocketAddr, TcpStream}; + +use crate::analysis_run_cancel_http::{encode_path_segment, refuse_metrics_on_cancel_payload}; +use crate::lineageweave_http::consumer_is_supported; +use crate::live_http::map_io_error; +use crate::naruon_http::{NARUON_ANALYSIS_RUN_PATH, header_is_credential}; +use crate::wire::require_nonempty; +use crate::{ + ANALYSIS_RUN_CANCEL_ID_MAX_LEN, AnalysisRunCancelRequest, AnalysisRunLiveService, + AnalysisRunStatus, AnalysisRunStatusState, ApiError, NARUON_LIVE_IO_TIMEOUT, + NaruonLiveResponse, +}; + +const SCIENTIFIC_ACCEPTANCE_SCHEMA: &str = "tepp.scientific_acceptance.v1"; + +/// Supported operator verbs for the loopback cancel CLI. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum AnalysisRunCancelCliVerb { + /// `POST /v1/analysis-runs/{run_id}/cancel`. + Cancel, +} + +impl AnalysisRunCancelCliVerb { + /// Parse one exact lowercase verb token. + /// + /// # Errors + /// + /// Returns [`ApiError::InvalidWirePayload`] for an unknown token. + pub fn parse(token: &str) -> Result { + match token { + "cancel" => Ok(Self::Cancel), + _ => Err(ApiError::InvalidWirePayload), + } + } + + /// Return the canonical lowercase verb token. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Cancel => "cancel", + } + } +} + +/// One operator CLI invocation against a loopback cancel POST listener. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AnalysisRunCancelCliInvocation { + /// CLI verb to execute. + pub verb: AnalysisRunCancelCliVerb, + /// Loopback `host:port` of `tepp-loopback`. + pub host: String, + /// Published modular consumer (`naruon` or `lineageweave`). + pub consumer: String, + /// Opaque server-assigned run identity. + pub run_id: String, + /// Exact request idempotency key of the accepted run. + pub idempotency_key: String, + /// Optional typed cancel JSON. Empty POST is admitted. + pub body: String, +} + +impl AnalysisRunCancelCliInvocation { + /// Parse argv plus stdin body into a validated loopback cancel invocation. + /// + /// # Errors + /// + /// Returns a fail-closed error for unknown verbs, missing required flags, a + /// non-loopback host, an unpublished consumer, credential-shaped flags, + /// hostile identities, metric bodies, or a typed body that does not match + /// the path identity and idempotency key. + pub fn from_args(args: I, body: impl Into) -> Result + where + I: IntoIterator, + S: AsRef, + { + let tokens: Vec = args + .into_iter() + .map(|token| token.as_ref().to_owned()) + .collect(); + let (verb_token, rest) = tokens.split_first().ok_or(ApiError::InvalidWirePayload)?; + let verb = AnalysisRunCancelCliVerb::parse(verb_token)?; + let flags = parse_flags(rest)?; + assemble_invocation(verb, flags, body.into()) + } + + /// Reject a non-loopback host, unpublished consumer, or hostile cancel body. + /// + /// # Errors + /// + /// Returns [`ApiError::AuthorizationDenied`] for a non-loopback host and + /// [`ApiError::InvalidWirePayload`] or [`ApiError::LimitExceeded`] for + /// empty, unpublished, oversized, or metric-bearing fields. + pub fn validate(&self) -> Result<(), ApiError> { + require_loopback_host(&self.host)?; + require_nonempty(&self.consumer)?; + if !consumer_is_supported(&self.consumer) { + return Err(ApiError::InvalidWirePayload); + } + require_nonempty(&self.run_id)?; + require_nonempty(&self.idempotency_key)?; + if self.run_id.len() > ANALYSIS_RUN_CANCEL_ID_MAX_LEN { + return Err(ApiError::LimitExceeded); + } + refuse_scientific_acceptance_schema(&self.body)?; + refuse_metrics_on_cancel_payload(&self.body)?; + if !self.body.is_empty() { + let request = AnalysisRunCancelRequest::from_json(&self.body)?; + if request.run_id != self.run_id || request.idempotency_key != self.idempotency_key { + return Err(ApiError::InvalidWirePayload); + } + } + Ok(()) + } +} + +struct ParsedFlags { + host: Option, + consumer: Option, + run_id: Option, + idempotency_key: Option, +} + +fn parse_flags(rest: &[String]) -> Result { + let mut flags = ParsedFlags { + host: None, + consumer: None, + run_id: None, + idempotency_key: None, + }; + let mut index = 0; + while index < rest.len() { + let flag = rest[index].as_str(); + if !flag.starts_with("--") { + return Err(ApiError::InvalidWirePayload); + } + let name = &flag[2..]; + if header_is_credential(name) { + return Err(ApiError::AuthorizationDenied); + } + let slot = match name { + "host" => &mut flags.host, + "consumer" => &mut flags.consumer, + "run-id" => &mut flags.run_id, + "idempotency-key" => &mut flags.idempotency_key, + _ => return Err(ApiError::InvalidWirePayload), + }; + if slot.is_some() || index + 1 >= rest.len() { + return Err(ApiError::InvalidWirePayload); + } + let value = rest[index + 1].as_str(); + require_nonempty(value)?; + *slot = Some(value.to_owned()); + index += 2; + } + Ok(flags) +} + +fn assemble_invocation( + verb: AnalysisRunCancelCliVerb, + flags: ParsedFlags, + body: String, +) -> Result { + let invocation = AnalysisRunCancelCliInvocation { + verb, + host: flags.host.ok_or(ApiError::InvalidWirePayload)?, + consumer: flags + .consumer + .unwrap_or_else(|| crate::NARUON_CONSUMER_CODE.to_owned()), + run_id: flags.run_id.ok_or(ApiError::InvalidWirePayload)?, + idempotency_key: flags.idempotency_key.ok_or(ApiError::InvalidWirePayload)?, + body, + }; + invocation.validate()?; + Ok(invocation) +} + +fn require_loopback_host(host: &str) -> Result { + let addr: SocketAddr = host.parse().map_err(|_| ApiError::InvalidWirePayload)?; + if addr.ip().is_loopback() { + Ok(addr) + } else { + Err(ApiError::AuthorizationDenied) + } +} + +/// Compose one HTTP/1.1 cancel POST for a validated CLI invocation. +/// +/// # Errors +/// +/// Returns the same fail-closed errors as +/// [`AnalysisRunCancelCliInvocation::validate`]. +pub fn compose_analysis_run_cancel_cli_http( + invocation: &AnalysisRunCancelCliInvocation, +) -> Result { + invocation.validate()?; + let encoded_run_id = encode_path_segment(&invocation.run_id); + let path = format!("{NARUON_ANALYSIS_RUN_PATH}/{encoded_run_id}/cancel"); + Ok(format!( + "POST {path} HTTP/1.1\r\nHost: {}\r\ncontent-type: application/json\r\ntepp-consumer: {}\r\ntepp-contract-version: 1\r\nidempotency-key: {}\r\ncontent-length: {}\r\n\r\n{}", + invocation.host, + invocation.consumer, + invocation.idempotency_key, + invocation.body.len(), + invocation.body + )) +} + +/// Dispatch one cancel CLI invocation against an in-process loopback service. +/// +/// # Errors +/// +/// Returns fail-closed validation errors before the HTTP handler runs. +pub fn dispatch_analysis_run_cancel_cli( + service: &mut AnalysisRunLiveService, + invocation: &AnalysisRunCancelCliInvocation, +) -> Result { + let request = compose_analysis_run_cancel_cli_http(invocation)?; + Ok(service.handle_http_request(&request)) +} + +/// Execute one cancel CLI invocation over loopback TCP against `tepp-loopback`. +/// +/// # Errors +/// +/// Returns fail-closed validation, transport, or response-framing errors. +pub fn execute_analysis_run_cancel_cli( + invocation: &AnalysisRunCancelCliInvocation, +) -> Result { + let addr = require_loopback_host(&invocation.host)?; + let request = compose_analysis_run_cancel_cli_http(invocation)?; + let mut stream = TcpStream::connect(addr).map_err(|error| map_io_error(&error))?; + stream + .set_read_timeout(Some(NARUON_LIVE_IO_TIMEOUT)) + .map_err(|error| map_io_error(&error))?; + stream + .set_write_timeout(Some(NARUON_LIVE_IO_TIMEOUT)) + .map_err(|error| map_io_error(&error))?; + stream + .write_all(request.as_bytes()) + .map_err(|error| map_io_error(&error))?; + stream.flush().map_err(|error| map_io_error(&error))?; + let mut bytes = Vec::new(); + stream + .read_to_end(&mut bytes) + .map_err(|error| map_io_error(&error))?; + parse_http_response(&bytes) +} + +/// Filter CLI stdout so cancel receipts never print scientific acceptance. +/// +/// # Errors +/// +/// Returns [`ApiError::InvalidWirePayload`] when a receipt carries metric keys, +/// `tepp.scientific_acceptance.v1`, or a non-cancelled success body. +pub fn render_analysis_run_cancel_cli_stdout( + invocation: &AnalysisRunCancelCliInvocation, + response: &NaruonLiveResponse, +) -> Result { + invocation.validate()?; + if response.body.is_empty() { + return Err(ApiError::InvalidWirePayload); + } + refuse_scientific_acceptance_schema(&response.body)?; + refuse_metrics_on_cancel_payload(&response.body)?; + if !(200..300).contains(&response.status_code) { + return Ok(response.body.clone()); + } + let status = AnalysisRunStatus::from_json(&response.body)?; + if status.run_state != AnalysisRunStatusState::Cancelled + || status.run_id != invocation.run_id + || status.idempotency_key != invocation.idempotency_key + || status.terminal_result.is_some() + { + return Err(ApiError::InvalidWirePayload); + } + status.to_json() +} + +fn refuse_scientific_acceptance_schema(body: &str) -> Result<(), ApiError> { + if body.contains(SCIENTIFIC_ACCEPTANCE_SCHEMA) { + Err(ApiError::InvalidWirePayload) + } else { + Ok(()) + } +} + +fn parse_http_response(bytes: &[u8]) -> Result { + let text = std::str::from_utf8(bytes).map_err(|_| ApiError::InvalidWirePayload)?; + let (header_block, body) = text + .split_once("\r\n\r\n") + .ok_or(ApiError::InvalidWirePayload)?; + let mut lines = header_block.split("\r\n"); + let status_line = lines.next().ok_or(ApiError::InvalidWirePayload)?; + let mut parts = status_line.split(' '); + if parts.next() != Some("HTTP/1.1") { + return Err(ApiError::InvalidWirePayload); + } + let code = parts + .next() + .ok_or(ApiError::InvalidWirePayload)? + .parse::() + .map_err(|_| ApiError::InvalidWirePayload)?; + let reason_phrase = static_reason(code)?; + let mut content_length = None; + for line in lines { + let (name, value) = line.split_once(':').ok_or(ApiError::InvalidWirePayload)?; + if name.eq_ignore_ascii_case("content-length") { + if content_length.is_some() { + return Err(ApiError::InvalidWirePayload); + } + content_length = Some( + value + .trim() + .parse::() + .map_err(|_| ApiError::InvalidWirePayload)?, + ); + } + } + let declared = content_length.ok_or(ApiError::InvalidWirePayload)?; + if declared != body.len() { + return Err(ApiError::InvalidWirePayload); + } + Ok(NaruonLiveResponse { + status_code: code, + reason_phrase, + body: body.to_owned(), + }) +} + +fn static_reason(code: u16) -> Result<&'static str, ApiError> { + match code { + 200 => Ok("OK"), + 202 => Ok("Accepted"), + 400 => Ok("Bad Request"), + 403 => Ok("Forbidden"), + 413 => Ok("Payload Too Large"), + 422 => Ok("Unprocessable Entity"), + _ => Err(ApiError::InvalidWirePayload), + } +} + +/// Read stdin leftover bytes on a non-terminal; empty cancel POST is admitted. +/// +/// # Errors +/// +/// Returns [`ApiError::InvalidWirePayload`] when stdin cannot be read. +pub fn read_analysis_run_cancel_cli_stdin( + stdin_is_terminal: bool, + mut stdin: impl Read, +) -> Result { + if stdin_is_terminal { + Ok(String::new()) + } else { + let mut body = String::new(); + stdin + .read_to_string(&mut body) + .map_err(|_| ApiError::InvalidWirePayload)?; + Ok(body) + } +} + +#[cfg(test)] +#[allow(clippy::too_many_lines)] +mod tests { + use super::{ + AnalysisRunCancelCliInvocation, AnalysisRunCancelCliVerb, SCIENTIFIC_ACCEPTANCE_SCHEMA, + compose_analysis_run_cancel_cli_http, dispatch_analysis_run_cancel_cli, + execute_analysis_run_cancel_cli, parse_http_response, read_analysis_run_cancel_cli_stdin, + render_analysis_run_cancel_cli_stdout, static_reason, + }; + use crate::{ + ANALYSIS_RUN_CANCEL_CONTRACT_VERSION, ANALYSIS_RUN_CANCEL_ID_MAX_LEN, + ANALYSIS_RUN_CONTRACT_VERSION, AnalysisRunAccepted, AnalysisRunCancelRequest, + AnalysisRunLiveService, AnalysisRunRequest, AnalysisRunStatus, AnalysisRunStatusState, + ApiError, LINEAGEWEAVE_CONSUMER_CODE, NARUON_ANALYSIS_RUN_PATH, NARUON_CONSUMER_CODE, + NaruonLiveResponse, + }; + + fn request(idempotency_key: &str) -> AnalysisRunRequest { + AnalysisRunRequest { + contract_version: ANALYSIS_RUN_CONTRACT_VERSION, + idempotency_key: idempotency_key.into(), + tenant_workspace_id: "cli-cancel-tenant".into(), + snapshot_id: "cli-cancel-snapshot".into(), + knowledge_cutoff: "2026-08-01T00:00:00Z".into(), + model_contract_version: "tepp-analysis-run-v1".into(), + output_profile: "calibrated_event_measurement".into(), + } + } + + fn create_http(run: &AnalysisRunRequest, consumer: &str, host: &str) -> String { + let body = run.to_json().expect("json"); + format!( + "POST {NARUON_ANALYSIS_RUN_PATH} HTTP/1.1\r\nHost: {host}\r\ncontent-type: application/json\r\ntepp-consumer: {consumer}\r\ntepp-contract-version: 1\r\nidempotency-key: {}\r\ncontent-length: {}\r\n\r\n{body}", + run.idempotency_key, + body.len() + ) + } + + fn cancel_invocation(run_id: &str, idempotency_key: &str) -> AnalysisRunCancelCliInvocation { + AnalysisRunCancelCliInvocation::from_args( + [ + "cancel", + "--host", + "127.0.0.1:18081", + "--run-id", + run_id, + "--idempotency-key", + idempotency_key, + ], + "", + ) + .expect("cancel") + } + + #[test] + fn verbs_parse_and_reject_unknown_tokens() { + assert_eq!( + AnalysisRunCancelCliVerb::parse("cancel").expect("cancel"), + AnalysisRunCancelCliVerb::Cancel + ); + assert_eq!(AnalysisRunCancelCliVerb::Cancel.as_str(), "cancel"); + assert_eq!( + AnalysisRunCancelCliVerb::parse("CANCEL"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + AnalysisRunCancelCliVerb::parse("list"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + AnalysisRunCancelCliVerb::parse("status"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + AnalysisRunCancelCliVerb::parse("create"), + Err(ApiError::InvalidWirePayload) + ); + } + + #[test] + fn from_args_refuses_empty_unknown_host_and_credential_flags() { + assert_eq!( + AnalysisRunCancelCliInvocation::from_args(Vec::::new(), "").unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + AnalysisRunCancelCliInvocation::from_args(["nope"], "").unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + AnalysisRunCancelCliInvocation::from_args(["cancel"], "").unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + AnalysisRunCancelCliInvocation::from_args(["cancel", "--host", "127.0.0.1:18081"], "") + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + AnalysisRunCancelCliInvocation::from_args( + [ + "cancel", + "--host", + "8.8.8.8:80", + "--run-id", + "tepp-run-1", + "--idempotency-key", + "idem-1" + ], + "" + ) + .unwrap_err(), + ApiError::AuthorizationDenied + ); + assert_eq!( + AnalysisRunCancelCliInvocation::from_args( + [ + "cancel", + "--host", + "not-a-socket", + "--run-id", + "tepp-run-1", + "--idempotency-key", + "idem-1" + ], + "" + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + AnalysisRunCancelCliInvocation::from_args( + [ + "cancel", + "--host", + "127.0.0.1:18081", + "--run-id", + "tepp-run-1", + "--idempotency-key", + "idem-1", + "--authorization", + "secret" + ], + "" + ) + .unwrap_err(), + ApiError::AuthorizationDenied + ); + assert_eq!( + AnalysisRunCancelCliInvocation::from_args( + [ + "cancel", + "--host", + "127.0.0.1:18081", + "--run-id", + "tepp-run-1", + "--idempotency-key", + "idem-1", + "--pretty" + ], + "" + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + AnalysisRunCancelCliInvocation::from_args( + [ + "cancel", + "--host", + "127.0.0.1:18081", + "--run-id", + "tepp-run-1", + "--idempotency-key", + "idem-1", + "extra" + ], + "" + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + AnalysisRunCancelCliInvocation::from_args( + [ + "cancel", + "--host", + "127.0.0.1:18081", + "--run-id", + "tepp-run-1", + "--idempotency-key", + "idem-1", + "--page-limit", + "1" + ], + "" + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + AnalysisRunCancelCliInvocation::from_args( + [ + "cancel", + "--host", + "127.0.0.1:18081", + "--run-id", + "tepp-run-1", + "--idempotency-key", + "idem-1", + "--consumer", + "other" + ], + "" + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + AnalysisRunCancelCliInvocation::from_args( + [ + "cancel", + "--host", + "127.0.0.1:18081", + "--run-id", + "tepp-run-1", + "--idempotency-key", + "idem-1", + "--host", + "127.0.0.1:9" + ], + "" + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + AnalysisRunCancelCliInvocation::from_args( + [ + "cancel", + "--host", + "127.0.0.1:18081", + "--run-id", + &"a".repeat(ANALYSIS_RUN_CANCEL_ID_MAX_LEN + 1), + "--idempotency-key", + "idem-1" + ], + "" + ) + .unwrap_err(), + ApiError::LimitExceeded + ); + assert_eq!( + AnalysisRunCancelCliInvocation::from_args( + [ + "cancel", + "--host", + "127.0.0.1:18081", + "--run-id", + "tepp-run-1", + "--idempotency-key", + "idem-1" + ], + r#"{"rmse":1.0}"# + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + AnalysisRunCancelCliInvocation::from_args( + [ + "cancel", + "--host", + "127.0.0.1:18081", + "--run-id", + "tepp-run-1", + "--idempotency-key", + "idem-1" + ], + format!(r#"{{"schema_version":"{SCIENTIFIC_ACCEPTANCE_SCHEMA}"}}"#) + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + let mismatched = AnalysisRunCancelRequest::new("other-run", "idem-1") + .expect("request") + .to_json() + .expect("json"); + assert_eq!( + AnalysisRunCancelCliInvocation::from_args( + [ + "cancel", + "--host", + "127.0.0.1:18081", + "--run-id", + "tepp-run-1", + "--idempotency-key", + "idem-1" + ], + mismatched + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + } + + #[test] + fn cancel_assembles_default_consumer_and_optional_typed_body() { + let cancel = cancel_invocation("tepp-run-1", "idem-1"); + assert_eq!(cancel.verb, AnalysisRunCancelCliVerb::Cancel); + assert_eq!(cancel.consumer, NARUON_CONSUMER_CODE); + assert!(cancel.body.is_empty()); + let http = compose_analysis_run_cancel_cli_http(&cancel).expect("http"); + assert!(http.starts_with("POST /v1/analysis-runs/tepp-run-1/cancel HTTP/1.1")); + assert!(http.contains("tepp-consumer: naruon")); + assert!(http.contains("idempotency-key: idem-1")); + assert!(http.contains("content-length: 0")); + assert!(!http.contains("authorization")); + assert!(!http.contains("copilot")); + assert!(!http.contains("tepp-page-cursor")); + + let typed = AnalysisRunCancelRequest::new("tepp-run-1", "idem-1") + .expect("typed") + .to_json() + .expect("json"); + let with_body = AnalysisRunCancelCliInvocation::from_args( + [ + "cancel", + "--host", + "127.0.0.1:18081", + "--consumer", + LINEAGEWEAVE_CONSUMER_CODE, + "--run-id", + "tepp-run-1", + "--idempotency-key", + "idem-1", + ], + typed.clone(), + ) + .expect("typed body"); + assert_eq!(with_body.consumer, LINEAGEWEAVE_CONSUMER_CODE); + let typed_http = compose_analysis_run_cancel_cli_http(&with_body).expect("typed http"); + assert!(typed_http.contains("tepp-consumer: lineageweave")); + assert!(typed_http.contains(&format!("content-length: {}", typed.len()))); + assert!(typed_http.contains(&typed)); + assert_eq!( + ANALYSIS_RUN_CANCEL_CONTRACT_VERSION, + AnalysisRunCancelRequest::from_json(&typed) + .expect("decode") + .contract_version + ); + + let encoded = cancel_invocation("run/../../etc", "key"); + let encoded_http = compose_analysis_run_cancel_cli_http(&encoded).expect("encoded"); + assert!( + encoded_http.contains("POST /v1/analysis-runs/run%2F..%2F..%2Fetc/cancel HTTP/1.1") + ); + } + + #[test] + fn dispatch_cancels_accepted_runs_without_scientific_acceptance() { + let mut service = AnalysisRunLiveService::new(); + let first = request("cli-cancel-idem-1"); + let created = service.handle_http_request(&create_http( + &first, + NARUON_CONSUMER_CODE, + "127.0.0.1:18081", + )); + assert_eq!(created.status_code, 202); + let accepted = AnalysisRunAccepted::from_json(&created.body).expect("accepted"); + let invocation = cancel_invocation(&accepted.run_id, &first.idempotency_key); + let cancelled = + dispatch_analysis_run_cancel_cli(&mut service, &invocation).expect("cancel"); + assert_eq!(cancelled.status_code, 200); + let stdout = + render_analysis_run_cancel_cli_stdout(&invocation, &cancelled).expect("stdout"); + assert!(!stdout.contains(SCIENTIFIC_ACCEPTANCE_SCHEMA)); + assert!(!stdout.contains("rmse")); + assert!(!stdout.contains("\"terminal_result\":{")); + let status = AnalysisRunStatus::from_json(&stdout).expect("status"); + assert_eq!(status.run_id, accepted.run_id); + assert_eq!(status.run_state, AnalysisRunStatusState::Cancelled); + assert!(status.terminal_result.is_none()); + + let replay = dispatch_analysis_run_cancel_cli(&mut service, &invocation).expect("replay"); + assert_eq!(replay.status_code, 200); + let replay_stdout = + render_analysis_run_cancel_cli_stdout(&invocation, &replay).expect("replay stdout"); + let replay_status = AnalysisRunStatus::from_json(&replay_stdout).expect("replay status"); + assert_eq!(replay_status.run_state, AnalysisRunStatusState::Cancelled); + + let other = AnalysisRunCancelCliInvocation::from_args( + [ + "cancel", + "--host", + "127.0.0.1:18081", + "--consumer", + LINEAGEWEAVE_CONSUMER_CODE, + "--run-id", + accepted.run_id.as_str(), + "--idempotency-key", + first.idempotency_key.as_str(), + ], + "", + ) + .expect("other consumer"); + let isolated = dispatch_analysis_run_cancel_cli(&mut service, &other).expect("isolated"); + assert_eq!(isolated.status_code, 400); + let isolated_stdout = + render_analysis_run_cancel_cli_stdout(&other, &isolated).expect("isolated stdout"); + assert!(isolated_stdout.contains("invalid_wire_payload")); + assert!(!isolated_stdout.contains(SCIENTIFIC_ACCEPTANCE_SCHEMA)); + } + + #[test] + fn render_refuses_metrics_scientific_acceptance_and_empty_bodies() { + let cancel = cancel_invocation("tepp-run-1", "idem-1"); + assert_eq!( + render_analysis_run_cancel_cli_stdout( + &cancel, + &NaruonLiveResponse { + status_code: 200, + reason_phrase: "OK", + body: String::new(), + } + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + render_analysis_run_cancel_cli_stdout( + &cancel, + &NaruonLiveResponse { + status_code: 200, + reason_phrase: "OK", + body: r#"{"contract_version":1,"run_id":"tepp-run-1","run_state":"cancelled","idempotency_key":"idem-1","rmse":1.0}"#.into(), + } + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + render_analysis_run_cancel_cli_stdout( + &cancel, + &NaruonLiveResponse { + status_code: 200, + reason_phrase: "OK", + body: format!( + r#"{{"contract_version":1,"run_id":"tepp-run-1","run_state":"cancelled","idempotency_key":"idem-1","schema_version":"{SCIENTIFIC_ACCEPTANCE_SCHEMA}"}}"# + ), + } + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + render_analysis_run_cancel_cli_stdout( + &cancel, + &NaruonLiveResponse { + status_code: 200, + reason_phrase: "OK", + body: r#"{"contract_version":1,"run_id":"tepp-run-1","run_state":"accepted","idempotency_key":"idem-1","terminal_result":null}"#.into(), + } + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + let error_stdout = render_analysis_run_cancel_cli_stdout( + &cancel, + &NaruonLiveResponse { + status_code: 400, + reason_phrase: "Bad Request", + body: r#"{"error_code":"invalid_wire_payload"}"#.into(), + }, + ) + .expect("error"); + assert!(error_stdout.contains("invalid_wire_payload")); + assert_eq!( + render_analysis_run_cancel_cli_stdout( + &cancel, + &NaruonLiveResponse { + status_code: 400, + reason_phrase: "Bad Request", + body: format!(r#"{{"schema_version":"{SCIENTIFIC_ACCEPTANCE_SCHEMA}"}}"#), + } + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + render_analysis_run_cancel_cli_stdout( + &cancel, + &NaruonLiveResponse { + status_code: 400, + reason_phrase: "Bad Request", + body: r#"{"rmse":0.1}"#.into(), + } + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + let cancelled_ok = render_analysis_run_cancel_cli_stdout( + &cancel, + &NaruonLiveResponse { + status_code: 200, + reason_phrase: "OK", + body: r#"{"contract_version":1,"run_id":"tepp-run-1","run_state":"cancelled","idempotency_key":"idem-1","terminal_result":null}"#.into(), + }, + ) + .expect("cancelled"); + assert!(cancelled_ok.contains("\"run_state\":\"cancelled\"")); + assert!(!cancelled_ok.contains(SCIENTIFIC_ACCEPTANCE_SCHEMA)); + } + + #[test] + fn execute_over_tcp_and_parse_response_failures() { + let mut service = AnalysisRunLiveService::bind_loopback().expect("bind"); + let addr = service.local_addr().expect("addr"); + let created = service.handle_http_request(&create_http( + &request("cli-cancel-tcp"), + NARUON_CONSUMER_CODE, + &addr.to_string(), + )); + let accepted = AnalysisRunAccepted::from_json(&created.body).expect("accepted"); + let handle = std::thread::spawn(move || { + drop(service.serve_one()); + }); + let mut invocation = cancel_invocation(&accepted.run_id, "cli-cancel-tcp"); + invocation.host = addr.to_string(); + let response = execute_analysis_run_cancel_cli(&invocation).expect("tcp"); + assert_eq!(response.status_code, 200); + handle.join().expect("join"); + + invocation.host = "127.0.0.1:1".into(); + assert_eq!( + execute_analysis_run_cancel_cli(&invocation).unwrap_err(), + ApiError::InvalidWirePayload + ); + + let parsed = + parse_http_response(b"HTTP/1.1 200 OK\r\ncontent-length: 2\r\n\r\n{}").expect("parse"); + assert_eq!(parsed.status_code, 200); + assert_eq!( + parse_http_response(b"not-http").unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + parse_http_response(b"HTTP/1.0 200 OK\r\ncontent-length: 2\r\n\r\n{}").unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + parse_http_response(b"HTTP/1.1 299 Mystery\r\ncontent-length: 2\r\n\r\n{}") + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + parse_http_response( + b"HTTP/1.1 200 OK\r\ncontent-length: 2\r\ncontent-length: 2\r\n\r\n{}" + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + parse_http_response(b"HTTP/1.1 200 OK\r\ncontent-length: 9\r\n\r\n{}").unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + parse_http_response(b"HTTP/1.1 200 OK\r\nbad-header\r\n\r\n{}").unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + parse_http_response(&[0xff, 0xfe]).unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!(static_reason(200).expect("200"), "OK"); + assert_eq!(static_reason(202).expect("202"), "Accepted"); + assert_eq!(static_reason(400).expect("400"), "Bad Request"); + assert_eq!(static_reason(403).expect("403"), "Forbidden"); + assert_eq!(static_reason(413).expect("413"), "Payload Too Large"); + assert_eq!(static_reason(422).expect("422"), "Unprocessable Entity"); + assert_eq!( + static_reason(500).unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + parse_http_response(b"HTTP/1.1\r\ncontent-length: 0\r\n\r\n").unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + parse_http_response(b"HTTP/1.1 abc OK\r\ncontent-length: 0\r\n\r\n").unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + parse_http_response(b"HTTP/1.1 200 OK\r\ncontent-length: x\r\n\r\n").unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + parse_http_response(b"HTTP/1.1 200 OK\r\nhost: 127.0.0.1\r\n\r\n{}").unwrap_err(), + ApiError::InvalidWirePayload + ); + } + + #[test] + fn stdin_reader_skips_terminal_and_reads_otherwise() { + let empty = read_analysis_run_cancel_cli_stdin(true, std::io::empty()).expect("tty"); + assert!(empty.is_empty()); + let piped = read_analysis_run_cancel_cli_stdin(false, std::io::Cursor::new(b"leftover")) + .expect("piped"); + assert_eq!(piped, "leftover"); + let piped_empty = + read_analysis_run_cancel_cli_stdin(false, std::io::Cursor::new(b"")).expect("empty"); + assert!(piped_empty.is_empty()); + } +} diff --git a/crates/tepp_api/src/analysis_run_cancel_http.rs b/crates/tepp_api/src/analysis_run_cancel_http.rs index daea7b158..0cd55d335 100644 --- a/crates/tepp_api/src/analysis_run_cancel_http.rs +++ b/crates/tepp_api/src/analysis_run_cancel_http.rs @@ -199,7 +199,7 @@ pub(crate) fn analysis_run_cancel_path_run_id(path: &str) -> Result String { +pub(crate) fn encode_path_segment(value: &str) -> String { let mut out = String::with_capacity(value.len() + value.len() / 2); let hex = b"0123456789ABCDEF"; for byte in value.bytes() { diff --git a/crates/tepp_api/src/bin/tepp_analysis_runs.rs b/crates/tepp_api/src/bin/tepp_analysis_runs.rs index 87682715b..f792c7db6 100644 --- a/crates/tepp_api/src/bin/tepp_analysis_runs.rs +++ b/crates/tepp_api/src/bin/tepp_analysis_runs.rs @@ -1,11 +1,13 @@ -//! Operator CLI for loopback analysis-run collection GET. +//! Operator CLI for loopback analysis-run collection GET and cancel POST. use std::io::{self, IsTerminal}; use std::process::ExitCode; use tepp_api::{ - AnalysisRunCollectionCliInvocation, AnalysisRunCollectionCliVerb, ApiError, - execute_analysis_run_collection_cli, read_analysis_run_collection_cli_stdin, + AnalysisRunCancelCliInvocation, AnalysisRunCollectionCliInvocation, + AnalysisRunCollectionCliVerb, ApiError, execute_analysis_run_cancel_cli, + execute_analysis_run_collection_cli, read_analysis_run_cancel_cli_stdin, + read_analysis_run_collection_cli_stdin, render_analysis_run_cancel_cli_stdout, render_analysis_run_collection_cli_stdout, }; @@ -18,10 +20,18 @@ fn main() -> ExitCode { fn run() -> Result<(), ApiError> { let args: Vec = std::env::args().skip(1).collect(); + match args.first().map(String::as_str) { + Some("list") => run_list(&args), + Some("cancel") => run_cancel(&args), + _ => Err(ApiError::InvalidWirePayload), + } +} + +fn run_list(args: &[String]) -> Result<(), ApiError> { let verb = AnalysisRunCollectionCliVerb::parse(args.first().ok_or(ApiError::InvalidWirePayload)?)?; let body = read_analysis_run_collection_cli_stdin(io::stdin().is_terminal(), io::stdin())?; - let invocation = AnalysisRunCollectionCliInvocation::from_args(&args, body)?; + let invocation = AnalysisRunCollectionCliInvocation::from_args(args, body)?; if invocation.verb != verb { return Err(ApiError::InvalidWirePayload); } @@ -34,3 +44,16 @@ fn run() -> Result<(), ApiError> { Err(ApiError::InvalidWirePayload) } } + +fn run_cancel(args: &[String]) -> Result<(), ApiError> { + let body = read_analysis_run_cancel_cli_stdin(io::stdin().is_terminal(), io::stdin())?; + let invocation = AnalysisRunCancelCliInvocation::from_args(args, body)?; + let response = execute_analysis_run_cancel_cli(&invocation)?; + let stdout = render_analysis_run_cancel_cli_stdout(&invocation, &response)?; + println!("{stdout}"); + if (200..300).contains(&response.status_code) { + Ok(()) + } else { + Err(ApiError::InvalidWirePayload) + } +} diff --git a/crates/tepp_api/src/lib.rs b/crates/tepp_api/src/lib.rs index ed5df35e7..41d535c39 100644 --- a/crates/tepp_api/src/lib.rs +++ b/crates/tepp_api/src/lib.rs @@ -13,6 +13,7 @@ mod analysis_result; mod analysis_run; +mod analysis_run_cancel_cli; mod analysis_run_cancel_http; mod analysis_run_collection_cli; mod analysis_run_collection_http; @@ -72,6 +73,20 @@ pub use analysis_run::DEFAULT_ANALYSIS_RUN_BYTE_LIMIT; pub use analysis_run::requests_are_idempotent_matches; /// Require exact status binding to a request and accepted receipt. pub use analysis_run::require_status_binding; +/// One validated cancel CLI invocation. +pub use analysis_run_cancel_cli::AnalysisRunCancelCliInvocation; +/// Loopback cancel CLI verb. +pub use analysis_run_cancel_cli::AnalysisRunCancelCliVerb; +/// Compose loopback cancel POST bytes for a CLI invocation. +pub use analysis_run_cancel_cli::compose_analysis_run_cancel_cli_http; +/// Dispatch a cancel CLI invocation against an in-process listener. +pub use analysis_run_cancel_cli::dispatch_analysis_run_cancel_cli; +/// Execute a cancel CLI invocation over loopback TCP. +pub use analysis_run_cancel_cli::execute_analysis_run_cancel_cli; +/// Read leftover stdin for the cancel CLI. +pub use analysis_run_cancel_cli::read_analysis_run_cancel_cli_stdin; +/// Render metric-free cancel CLI stdout. +pub use analysis_run_cancel_cli::render_analysis_run_cancel_cli_stdout; /// Analysis-run cancel contract version constant. pub use analysis_run_cancel_http::ANALYSIS_RUN_CANCEL_CONTRACT_VERSION; /// Maximum opaque run identity length on the cancel path. diff --git a/crates/tepp_api/tests/analysis_run_cancel_cli_contract.rs b/crates/tepp_api/tests/analysis_run_cancel_cli_contract.rs new file mode 100644 index 000000000..94a36558e --- /dev/null +++ b/crates/tepp_api/tests/analysis_run_cancel_cli_contract.rs @@ -0,0 +1,78 @@ +//! Contract tests for the analysis-run cancel loopback CLI. + +use tepp_api::{ + ANALYSIS_RUN_CANCEL_CONTRACT_VERSION, AnalysisRunCancelCliInvocation, AnalysisRunCancelCliVerb, + AnalysisRunCancelRequest, ApiError, NARUON_CONSUMER_CODE, compose_analysis_run_cancel_cli_http, +}; + +#[test] +fn cancel_cli_is_metric_free_post_without_credentials() { + assert_eq!( + AnalysisRunCancelCliVerb::parse("cancel").expect("cancel"), + AnalysisRunCancelCliVerb::Cancel + ); + let invocation = AnalysisRunCancelCliInvocation::from_args( + [ + "cancel", + "--host", + "127.0.0.1:18081", + "--run-id", + "tepp-run-1", + "--idempotency-key", + "idem-1", + ], + "", + ) + .expect("invocation"); + assert_eq!(invocation.consumer, NARUON_CONSUMER_CODE); + let http = compose_analysis_run_cancel_cli_http(&invocation).expect("http"); + assert!(http.starts_with("POST /v1/analysis-runs/tepp-run-1/cancel HTTP/1.1")); + assert!(http.contains("idempotency-key: idem-1")); + assert!(!http.contains("authorization")); + assert!(!http.contains("copilot")); + assert!(!http.contains("tepp.scientific_acceptance.v1")); + assert_eq!( + ANALYSIS_RUN_CANCEL_CONTRACT_VERSION, + AnalysisRunCancelRequest::new("tepp-run-1", "idem-1") + .expect("request") + .contract_version + ); +} + +#[test] +fn cancel_cli_refuses_non_loopback_unknown_verbs_and_metric_bodies() { + assert_eq!( + AnalysisRunCancelCliInvocation::from_args( + [ + "cancel", + "--host", + "8.8.8.8:80", + "--run-id", + "tepp-run-1", + "--idempotency-key", + "idem-1" + ], + "" + ), + Err(ApiError::AuthorizationDenied) + ); + assert_eq!( + AnalysisRunCancelCliVerb::parse("list"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + AnalysisRunCancelCliInvocation::from_args( + [ + "cancel", + "--host", + "127.0.0.1:18081", + "--run-id", + "tepp-run-1", + "--idempotency-key", + "idem-1" + ], + r#"{"rmse":1.0}"# + ), + Err(ApiError::InvalidWirePayload) + ); +} diff --git a/docs/API_CONTRACT.md b/docs/API_CONTRACT.md index f9304407e..2adcfab25 100644 --- a/docs/API_CONTRACT.md +++ b/docs/API_CONTRACT.md @@ -87,7 +87,10 @@ 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. The loopback `tepp-analysis-runs list` CLI is the operator-visible client for that collection GET; it does not duplicate the -scientific-acceptance `tepp-analysis-run` CLI. +scientific-acceptance `tepp-analysis-run` CLI. The loopback +`tepp-analysis-runs cancel` CLI is the operator-visible client for +`POST /v1/analysis-runs/{run_id}/cancel`; cancel stdout stays metric-free and +never prints `tepp.scientific_acceptance.v1`. 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 5840d5fd6..4955ee6ab 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -56,6 +56,7 @@ The full APA 7th standards/literature register remains `docs/research/standards- | 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 CLI | ADR 0032; API contract; RFC 9110 | `tepp_api` `tepp-analysis-runs list` CLI: operator-visible metric-free collection client; `tepp.scientific_acceptance.v1` never prints; not GET-by-id and not the scientific-acceptance CLI | active-PR | +| loopback analysis-run cancel CLI | ADR 0033; API contract; RFC 9110 | `tepp_api` `tepp-analysis-runs cancel` CLI: operator-visible metric-free client of `POST /v1/analysis-runs/{run_id}/cancel`; `tepp.scientific_acceptance.v1` never prints; not GET-by-id, not collection list, and not the scientific-acceptance CLI | 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/0033-analysis-run-cancel-cli.md b/docs/adr/0033-analysis-run-cancel-cli.md new file mode 100644 index 000000000..6a36ec633 --- /dev/null +++ b/docs/adr/0033-analysis-run-cancel-cli.md @@ -0,0 +1,73 @@ +# ADR 0033 — Analysis-run cancel loopback CLI + +**Decision status:** Accepted +**Implementation maturity:** active-PR +**Date:** 2026-08-31 +**Supersedes:** None; complements ADR 0029 for the operator-visible cancel client. Does not supersede ADR 0014 claim-promotion authority. ADR 0026–0032 remain on live GAP-003A engine-library, terminal-wire DTO, GET-by-id, lifecycle-POST, cancel-HTTP, scientific-acceptance CLI, collection-GET, and collection-CLI slices. + +## Context + +ADR 0029 serves `POST /v1/analysis-runs/{run_id}/cancel` on the loopback listener, but operators still had to write raw HTTP/1.1 to withdraw an accepted or running run. Duplicating cancel HTTP, GET-by-id, lifecycle POST, the scientific-acceptance CLI (`tepp-analysis-run` on live #362), collection GET, collection CLI `list`, or consumer-parity cancel would collide with live PRs. + +## Decision + +`tepp_api` extends the loopback-only `tepp-analysis-runs` CLI: + +- `cancel` POSTs `/v1/analysis-runs/{run_id}/cancel` with `--run-id` and `--idempotency-key`. +- Empty stdin is admitted (header-and-path cancel). A typed `AnalysisRunCancelRequest` body must match those flags. +- Stdout is metric-free cancelled status: `run_id`, `run_state`, `idempotency_key`, and `terminal_result: null`. +- `tepp.scientific_acceptance.v1`, RMSE, bias, coverage, SE-gate, report, and a non-null `terminal_result` never appear. +- Non-loopback hosts, unpublished consumers, credential-shaped flags, collection pagination flags, unknown verbs, hostile identities, and metric bodies fail closed. +- Persistence, Compose recovery, and psychometric execution remain GAP-003B. + +## Alternatives considered + +1. **Keep raw HTTP as the only cancel path** — rejected because operators still guess framing after ADR 0029. +2. **Add `cancel` onto the live scientific-acceptance CLI (#362)** — rejected because that head already owns create/running/terminal/status and is stacked on GET-by-id, not cancel HTTP. +3. **Open a second `tepp-analysis-runs` binary beside collection CLI list (#371)** — rejected because the operator-visible command is the same binary. +4. **Persist cancelled rows in PostgreSQL** — rejected as GAP-003B / live draft #287. +5. **Loopback cancel CLI with the same metric-free gates as ADR 0029** — accepted. + +## Consequences + +- Operators can withdraw accepted or running runs on the same loopback listener that created them without writing HTTP. +- Cancelled status cannot be mistaken for a succeeded scientific-acceptance result. +- CLI success is not release evidence. + +## Failure and recovery + +Non-loopback hosts return authorization denied. Unknown verbs, metric keys, mismatched typed bodies, unpublished consumers, credential flags, oversized run identities, and collection pagination flags fail closed. Succeeded, failed, and unknown runs remain refused by ADR 0029. The in-memory registry is not durable. + +## Security, privacy, scientific-integrity, and governance impact + +- No credential headers cross the consumer boundary. +- The CLI remains loopback-only and size-bounded. +- Process exit 0 on a cancelled status is not measurement evidence and is not an ADR 0014 claim. + +## Compatibility and migration + +Cancel HTTP, collection GET, collection CLI `list`, create POST, temporal-context, and project-history paths are unchanged. The scientific-acceptance CLI binary name `tepp-analysis-run` remains owned by ADR 0030 / #362. Production adapters may replace loopback while preserving metric-free cancelled status. + +## Verification + +Falsifiable evidence: + +- CLI cancel JSON has no RMSE/bias/coverage/SE-gate/scientific-acceptance keys and no non-null `terminal_result`; +- CLI cancel of accepted is metric-free `cancelled` and replay is idempotent; +- another consumer cannot cancel the first consumer's run; +- non-loopback host, credential flags, collection flags, metric stdin, and unknown verbs fail closed; +- Clippy `-D warnings`, `tepp_api` tests, rustdoc, and exact-head review remain required. + +## Rollback and supersession + +Rollback removes the cancel verb from `tepp-analysis-runs`; collection `list` and cancel HTTP remain valid. A superseding ADR is required to persist the registry, bind a public address, emit scientific-acceptance on cancel, or treat CLI success as an ADR 0014 claim. + +## Related authority + +- ADR 0029 owns loopback cancel HTTP. +- ADR 0032 owns `tepp-analysis-runs list`. +- ADR 0030 owns the scientific-acceptance loopback CLI (live #362). +- ADR 0018 owns consumer-scoped ingress and metric-free `202 Accepted`. +- ADR 0014 owns scientific claim promotion. +- ADR 0011 owns standalone/modular HTTP boundaries. +- RFC 9110 owns POST semantics (Fielding, Nottingham, & Reschke, 2022). It does not authorize scientific claims. diff --git a/docs/adr/README.md b/docs/adr/README.md index 82318039c..592ab1107 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -33,6 +33,7 @@ Read [`ADR_POLICY.md`](ADR_POLICY.md) first. **Decision status and implementatio | [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. | | [0032](0032-analysis-run-collection-cli.md) | Loopback `tepp-analysis-runs list` is metric-free collection client | Accepted | active-PR | Complements ADR 0031; does not supersede ADR 0014. ADR 0026–0031 live on other GAP-003A PRs. | +| [0033](0033-analysis-run-cancel-cli.md) | Loopback `tepp-analysis-runs cancel` is metric-free cancel client | Accepted | active-PR | Complements ADR 0029/0032; does not supersede ADR 0014. ADR 0026–0032 live on other GAP-003A 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. | @@ -146,6 +147,7 @@ Use the narrowest owning ADR when decisions overlap: - **analysis-run cancel HTTP:** ADR 0029. - **analysis-run collection GET:** ADR 0031. - **analysis-run collection CLI:** ADR 0032. +- **analysis-run cancel CLI:** ADR 0033. ## Change and supersession rule diff --git a/docs/connectors/naruon-artifact-consumer.md b/docs/connectors/naruon-artifact-consumer.md index 0bdd58d9d..502f61d34 100644 --- a/docs/connectors/naruon-artifact-consumer.md +++ b/docs/connectors/naruon-artifact-consumer.md @@ -28,6 +28,7 @@ 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 | | CLI analysis-run collection | `tepp_api` `tepp-analysis-runs list` → loopback `GET /v1/analysis-runs` | naruon → TEPP | +| CLI analysis-run cancel | `tepp_api` `tepp-analysis-runs cancel` → loopback `POST /v1/analysis-runs/{run_id}/cancel` | naruon → TEPP | | HTTP analysis-run cancel | `tepp_api` `naruon_analysis_run_cancel_exchange` → `POST /v1/analysis-runs/{run_id}/cancel` | naruon → TEPP | | HTTP export authorize | `tepp_api` `naruon_export_exchange` → `POST /v1/exports` | naruon → TEPP | | Live loopback POST | `tepp_api` `NaruonLiveService` → `POST /v1/analysis-runs` and `/v1/exports` | naruon → TEPP | diff --git a/docs/research/analysis-run-cancel-cli.md b/docs/research/analysis-run-cancel-cli.md new file mode 100644 index 000000000..57d8fc329 --- /dev/null +++ b/docs/research/analysis-run-cancel-cli.md @@ -0,0 +1,64 @@ +# Analysis-run cancel CLI (doctoring) + +## Scope + +`tepp-analysis-runs cancel` is the operator-visible client of loopback +`POST /v1/analysis-runs/{run_id}/cancel`. HTTP method, path, and header +semantics follow current HTTP semantics (Fielding, Nottingham, & Reschke, +2022). Fail-closed refusal of non-loopback hosts, unpublished consumers, +review/Copilot/GitHub credential flags, and scientific-authority promotion is +repository contract authority (ADR 0033; ADR 0029; ADR 0018; ADR 0011), not an +RFC inference rule. + +CLI stdout is metric-free `AnalysisRunStatus` JSON with `run_state=cancelled`. +The row carries `run_id`, `run_state`, and `idempotency_key` only. +`terminal_result` stays null. Process exit 0 is not a completed temporal model, +calibrated score, theta estimate, uncertainty statement, or scientific claim. +`tepp.scientific_acceptance.v1` never appears. + +## Authority + +### External standards (HTTP only) + +Fielding, R., Nottingham, M., & Reschke, J. (Eds.). (2022). *HTTP semantics* +(RFC 9110). IETF. https://doi.org/10.17487/RFC9110 + +RFC 9110 §9.3.3 describes POST as a method for processing the enclosed +representation according to the resource's own semantics. TEPP maps that +processing onto a bounded, consumer-scoped cancel of an accepted or running +analysis run. The RFC does not define psychometric acceptance, RMSE, or claim +promotion. + +### Internal contract evidence + +- `docs/adr/0033-analysis-run-cancel-cli.md` — this client +- `docs/adr/0029-analysis-run-cancel-http.md` — cancel HTTP listener +- `docs/adr/0032-analysis-run-collection-cli.md` — distinct `list` verb on the + same `tepp-analysis-runs` binary +- `docs/adr/0030-scientific-acceptance-loopback-cli.md` — distinct + `tepp-analysis-run` scientific-acceptance CLI on live #362 +- `docs/adr/0018-consumer-scoped-analysis-run-ingress.md` — closed consumer + registry and metric-free `202 Accepted` +- `docs/adr/0014-scientific-claim-promotion-and-release-evidence.md` — CLI + success is not a scientific claim +- `docs/API_CONTRACT.md` — documented cancel resource +- `crates/tepp_api/tests/analysis_run_cancel_cli_contract.rs` — + fail-closed cancel CLI proofs + +## Verification + +- `tepp-analysis-runs cancel` of an accepted run returns metric-free cancelled + status without RMSE/bias/coverage/SE-gate keys or + `tepp.scientific_acceptance.v1`; +- replay of the same cancel is idempotent; +- another consumer cannot cancel the first consumer's run; +- non-loopback hosts, credential flags, collection pagination flags, metric + stdin, and unknown verbs fail closed; +- review, Copilot, GitHub, and bearer flags remain `AuthorizationDenied`. + +## Non-claims + +This slice does not implement GET-by-id, running/terminal POST, collection GET, +collection CLI list, scientific-acceptance CLI verbs, consumer-parity cancel, +persistence, production TLS, Leiden consensus, or an ADR 0014 scientific +claim-promotion package.