diff --git a/CHANGELOG.d/interpretation-run-cancel-cli.md b/CHANGELOG.d/interpretation-run-cancel-cli.md new file mode 100644 index 000000000..5895cf9f4 --- /dev/null +++ b/CHANGELOG.d/interpretation-run-cancel-cli.md @@ -0,0 +1 @@ +- `orchestrator_live` `tepp-interpretation-run-cancel cancel` mints a typed contextual-orchestrator `POST /v1/interpretation-runs/{idempotency_key}/cancel` onto spawned `tepp-orchestrator-loopback` TCP (ADR 0074). Metric-free cancelled identities only (`claim_status=hypothetical`, `scientific_authority=false`, `cancelled=true`). `tepp.scientific_acceptance.v1` never appears. Does not infer causality. Naruon and LineageWeave are refused. Not cancel HTTP-only, not analysis-run cancel CLI, not persistence. diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index ad37fd34f..7caba65c6 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -17,6 +17,7 @@ TEPP's approved PRD v0.4 and implementation plan are the primary product baselin | Interpretation-run collection GET doctoring | [`docs/research/interpretation-run-collection-http.md`](docs/research/interpretation-run-collection-http.md) | | Interpretation-run GET-by-id doctoring | [`docs/research/interpretation-run-retrieval-http.md`](docs/research/interpretation-run-retrieval-http.md) | | Interpretation-run cancel HTTP doctoring | [`docs/research/interpretation-run-cancel-http.md`](docs/research/interpretation-run-cancel-http.md) | +| Interpretation-run cancel CLI doctoring | [`docs/research/interpretation-run-cancel-cli.md`](docs/research/interpretation-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/orchestrator_live/Cargo.toml b/crates/orchestrator_live/Cargo.toml index 38df1a3bc..2ebdfea37 100644 --- a/crates/orchestrator_live/Cargo.toml +++ b/crates/orchestrator_live/Cargo.toml @@ -29,5 +29,11 @@ path = "src/bin/tepp_interpretation_runs.rs" test = false bench = false +[[bin]] +name = "tepp-interpretation-run-cancel" +path = "src/bin/tepp_interpretation_run_cancel.rs" +test = false +bench = false + [lints] workspace = true diff --git a/crates/orchestrator_live/src/bin/tepp_interpretation_run_cancel.rs b/crates/orchestrator_live/src/bin/tepp_interpretation_run_cancel.rs new file mode 100644 index 000000000..c7b39f2a4 --- /dev/null +++ b/crates/orchestrator_live/src/bin/tepp_interpretation_run_cancel.rs @@ -0,0 +1,34 @@ +//! Operator CLI for loopback contextual-orchestrator interpretation-run cancel. + +use std::io::{self, IsTerminal}; +use std::process::ExitCode; + +use orchestrator_live::{ + execute_interpretation_run_cancel_cli, read_interpretation_run_cancel_cli_stdin, + render_interpretation_run_cancel_cli_stdout, InterpretationRunCancelCliInvocation, + OrchestratorLiveError, +}; + +fn main() -> ExitCode { + match run() { + Ok(()) => ExitCode::SUCCESS, + Err(_) => ExitCode::FAILURE, + } +} + +fn run() -> Result<(), OrchestratorLiveError> { + let args: Vec = std::env::args().skip(1).collect(); + match args.first().map(String::as_str) { + Some("cancel") => run_cancel(&args), + _ => Err(OrchestratorLiveError::InvalidWirePayload), + } +} + +fn run_cancel(args: &[String]) -> Result<(), OrchestratorLiveError> { + let body = read_interpretation_run_cancel_cli_stdin(io::stdin().is_terminal(), io::stdin())?; + let invocation = InterpretationRunCancelCliInvocation::from_args(args, body)?; + let response = execute_interpretation_run_cancel_cli(&invocation)?; + let stdout = render_interpretation_run_cancel_cli_stdout(&invocation, &response)?; + println!("{stdout}"); + Ok(()) +} diff --git a/crates/orchestrator_live/src/interpretation_run_cancel_cli.rs b/crates/orchestrator_live/src/interpretation_run_cancel_cli.rs new file mode 100644 index 000000000..fefbc8148 --- /dev/null +++ b/crates/orchestrator_live/src/interpretation_run_cancel_cli.rs @@ -0,0 +1,667 @@ +//! Operator loopback CLI for contextual-orchestrator interpretation-run cancel. +//! +//! GAP-003A unique slice: operators run `tepp-interpretation-run-cancel cancel` +//! to mint `contextual_orchestrator_interpretation_run_cancel_exchange` onto +//! spawned `tepp-orchestrator-loopback` TCP. Stdout is one metric-free +//! cancelled identity with `claim_status=hypothetical`, +//! `scientific_authority=false`, and `cancelled=true`. +//! `tepp.scientific_acceptance.v1` never appears. The CLI does not infer +//! causality or call a model provider. Naruon and `LineageWeave` are refused. +//! `NaruonLiveService` stays POST-only. This module does not duplicate +//! interpretation-run CLI (#425), collection GET (#433), collection CLI +//! (#436), GET-by-id HTTP (#438), retrieval CLI (#439), cancel HTTP (#440), +//! analysis-run cancel CLI (#378), Leiden, or GAP-010 Figma/export. +//! Persistence remains GAP-003B. + +use std::fmt::Write as _; +use std::io::{Read, Write}; +use std::net::{SocketAddr, TcpStream}; +use std::time::Duration; + +use crate::http::{header_is_credential, map_io_error}; +use crate::interpretation_run_cancel_http::{ + contextual_orchestrator_interpretation_run_cancel_exchange, interpretation_run_cancel_path_id, + refuse_metrics_on_interpretation_run_cancel_payload, InterpretationRunCancelHttpExchange, + InterpretationRunCancelled, +}; +use crate::interpretation_run_cli::CONTEXTUAL_ORCHESTRATOR_CONSUMER_CODE; +use crate::request::{ + require_nonempty, DEFAULT_INTERPRETATION_BYTE_LIMIT, HYPOTHETICAL_CLAIM_STATUS, +}; +use crate::{ + OrchestratorLiveError, OrchestratorLiveResponse, OrchestratorLiveService, + LIVE_HEADER_BYTE_LIMIT, LIVE_HEADER_COUNT_LIMIT, +}; + +const SCIENTIFIC_ACCEPTANCE_SCHEMA: &str = "tepp.scientific_acceptance.v1"; +const CLI_IO_TIMEOUT: Duration = Duration::from_secs(2); +const MAXIMUM_HTTP_RESPONSE_BYTES: usize = + LIVE_HEADER_BYTE_LIMIT + 4 + DEFAULT_INTERPRETATION_BYTE_LIMIT; + +/// Supported operator verbs for the loopback interpretation-run cancel CLI. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum InterpretationRunCancelCliVerb { + /// `POST /v1/interpretation-runs/{idempotency_key}/cancel`. + Cancel, +} + +impl InterpretationRunCancelCliVerb { + /// Parse one exact lowercase verb token. + /// + /// # Errors + /// + /// Returns [`OrchestratorLiveError::InvalidWirePayload`] for an unknown token. + pub fn parse(token: &str) -> Result { + match token { + "cancel" => Ok(Self::Cancel), + _ => Err(OrchestratorLiveError::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 listener. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct InterpretationRunCancelCliInvocation { + /// CLI verb to execute. + pub verb: InterpretationRunCancelCliVerb, + /// Loopback `host:port` of `tepp-orchestrator-loopback`. + pub host: String, + /// Published HTTPS origin used to mint the typed cancel exchange. + pub origin: String, + /// Published modular consumer. Cancel admits `contextual-orchestrator` only. + pub consumer: String, + /// Opaque idempotency key that minted the stored identity. + pub idempotency_key: String, + /// JSON body. Cancel POST requires empty. + pub body: String, +} + +impl InterpretationRunCancelCliInvocation { + /// Parse argv plus stdin body into a validated loopback cancel invocation. + /// + /// Empty stdin is admitted. Nonempty leftover stdin fails closed. + /// + /// # Errors + /// + /// Returns a fail-closed error for unknown verbs, missing required flags, a + /// non-loopback host, a non-`https` origin, an unpublished consumer, + /// credential-shaped flags, a hostile identity, or a nonempty body. + 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(OrchestratorLiveError::InvalidWirePayload)?; + let verb = InterpretationRunCancelCliVerb::parse(verb_token)?; + let flags = parse_flags(rest)?; + assemble_invocation(verb, flags, body.into()) + } + + /// Reject a non-loopback host, unpublished consumer, or hostile POST body. + /// + /// # Errors + /// + /// Returns [`OrchestratorLiveError::AuthorizationDenied`] for a non-loopback + /// host and [`OrchestratorLiveError::InvalidWirePayload`] or + /// [`OrchestratorLiveError::LimitExceeded`] for empty, unpublished, + /// nonempty-body, or oversized fields. + pub fn validate(&self) -> Result<(), OrchestratorLiveError> { + require_loopback_host(&self.host)?; + require_nonempty(&self.origin)?; + if !self.origin.starts_with("https://") { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + require_nonempty(&self.consumer)?; + if self.consumer != CONTEXTUAL_ORCHESTRATOR_CONSUMER_CODE { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + require_nonempty(&self.idempotency_key)?; + if self.idempotency_key.contains('/') || self.idempotency_key.contains('\0') { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + if !self.body.is_empty() { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + refuse_scientific_acceptance(&self.body)?; + refuse_metrics_on_interpretation_run_cancel_payload(&self.body)?; + Ok(()) + } +} + +struct ParsedFlags { + host: Option, + origin: Option, + consumer: Option, + idempotency_key: Option, +} + +fn parse_flags(rest: &[String]) -> Result { + let mut flags = ParsedFlags { + host: None, + origin: None, + consumer: None, + idempotency_key: None, + }; + let mut index = 0; + while index < rest.len() { + let flag = rest[index].as_str(); + if !flag.starts_with("--") { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + let name = &flag[2..]; + if header_is_credential(name) { + return Err(OrchestratorLiveError::AuthorizationDenied); + } + let slot = match name { + "host" => &mut flags.host, + "origin" => &mut flags.origin, + "consumer" => &mut flags.consumer, + "idempotency-key" => &mut flags.idempotency_key, + _ => return Err(OrchestratorLiveError::InvalidWirePayload), + }; + if slot.is_some() || index + 1 >= rest.len() { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + let value = rest[index + 1].as_str(); + require_nonempty(value)?; + *slot = Some(value.to_owned()); + index += 2; + } + Ok(flags) +} + +fn assemble_invocation( + verb: InterpretationRunCancelCliVerb, + flags: ParsedFlags, + body: String, +) -> Result { + let invocation = InterpretationRunCancelCliInvocation { + verb, + host: flags + .host + .ok_or(OrchestratorLiveError::InvalidWirePayload)?, + origin: flags + .origin + .ok_or(OrchestratorLiveError::InvalidWirePayload)?, + consumer: flags + .consumer + .unwrap_or_else(|| CONTEXTUAL_ORCHESTRATOR_CONSUMER_CODE.to_owned()), + idempotency_key: flags + .idempotency_key + .ok_or(OrchestratorLiveError::InvalidWirePayload)?, + body, + }; + invocation.validate()?; + Ok(invocation) +} + +fn require_loopback_host(host: &str) -> Result { + let addr: SocketAddr = host + .parse() + .map_err(|_| OrchestratorLiveError::InvalidWirePayload)?; + if addr.ip().is_loopback() { + Ok(addr) + } else { + Err(OrchestratorLiveError::AuthorizationDenied) + } +} + +/// Render a typed cancel POST exchange as HTTP/1.1 for a loopback listener. +/// +/// # Errors +/// +/// Returns [`OrchestratorLiveError::AuthorizationDenied`] for a non-loopback +/// host or a credential-bearing header, and +/// [`OrchestratorLiveError::InvalidWirePayload`] when the exchange is not a +/// POST `/v1/interpretation-runs/{idempotency_key}/cancel` with an empty body. +pub fn loopback_http1_from_interpretation_run_cancel_exchange( + exchange: &InterpretationRunCancelHttpExchange, + loopback_host: &str, +) -> Result { + let _addr = require_loopback_host(loopback_host)?; + let host = loopback_host.trim(); + if exchange.method != "POST" { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + if !exchange.body.is_empty() { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + let rest = exchange + .target_url + .strip_prefix("https://") + .ok_or(OrchestratorLiveError::InvalidWirePayload)?; + let path = rest + .find('/') + .map(|index| &rest[index..]) + .ok_or(OrchestratorLiveError::InvalidWirePayload)?; + let _idempotency_key = interpretation_run_cancel_path_id(path)?; + for (name, _) in &exchange.headers { + if header_is_credential(name) { + return Err(OrchestratorLiveError::AuthorizationDenied); + } + if name.eq_ignore_ascii_case("idempotency-key") + || name.eq_ignore_ascii_case("tepp-page-limit") + || name.eq_ignore_ascii_case("tepp-page-cursor") + { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + } + let mut request = String::new(); + write!( + request, + "{} {path} HTTP/1.1\r\nHost: {host}\r\n", + exchange.method + ) + .map_err(|_| OrchestratorLiveError::InvalidWirePayload)?; + for (name, value) in &exchange.headers { + if name.eq_ignore_ascii_case("host") || name.eq_ignore_ascii_case("content-length") { + continue; + } + write!(request, "{name}: {value}\r\n") + .map_err(|_| OrchestratorLiveError::InvalidWirePayload)?; + } + write!(request, "content-length: 0\r\n\r\n") + .map_err(|_| OrchestratorLiveError::InvalidWirePayload)?; + Ok(request) +} + +/// Compose one HTTP/1.1 cancel POST from the typed consumer exchange. +/// +/// # Errors +/// +/// Returns the same fail-closed errors as +/// [`InterpretationRunCancelCliInvocation::validate`]. +pub fn compose_interpretation_run_cancel_cli_http( + invocation: &InterpretationRunCancelCliInvocation, +) -> Result { + invocation.validate()?; + let exchange = contextual_orchestrator_interpretation_run_cancel_exchange( + &invocation.origin, + &invocation.idempotency_key, + )?; + loopback_http1_from_interpretation_run_cancel_exchange(&exchange, &invocation.host) +} + +/// Dispatch one cancel CLI invocation against an in-process listener. +/// +/// # Errors +/// +/// Returns fail-closed validation errors before the HTTP handler runs. +pub fn dispatch_interpretation_run_cancel_cli( + service: &mut OrchestratorLiveService, + invocation: &InterpretationRunCancelCliInvocation, +) -> Result { + let request = compose_interpretation_run_cancel_cli_http(invocation)?; + Ok(service.handle_http_request(&request)) +} + +/// Execute one cancel CLI invocation over loopback TCP. +/// +/// # Errors +/// +/// Returns fail-closed validation, transport, or response-framing errors. +pub fn execute_interpretation_run_cancel_cli( + invocation: &InterpretationRunCancelCliInvocation, +) -> Result { + let addr = require_loopback_host(&invocation.host)?; + let request = compose_interpretation_run_cancel_cli_http(invocation)?; + let mut stream = TcpStream::connect(addr).map_err(|error| map_io_error(&error))?; + stream + .set_read_timeout(Some(CLI_IO_TIMEOUT)) + .map_err(|error| map_io_error(&error))?; + stream + .set_write_timeout(Some(CLI_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 bytes = read_bounded(&mut stream, MAXIMUM_HTTP_RESPONSE_BYTES)?; + parse_http_response(&bytes) +} + +/// Filter CLI stdout so cancel never prints scientific acceptance. +/// +/// # Errors +/// +/// Returns [`OrchestratorLiveError::InvalidWirePayload`] when a receipt carries +/// metric keys, evidence, causal scores, or +/// `tepp.scientific_acceptance.v1`, or when the identity does not match. +pub fn render_interpretation_run_cancel_cli_stdout( + invocation: &InterpretationRunCancelCliInvocation, + response: &OrchestratorLiveResponse, +) -> Result { + invocation.validate()?; + if response.body.is_empty() { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + refuse_scientific_acceptance(&response.body)?; + refuse_metrics_on_interpretation_run_cancel_payload(&response.body)?; + if response.status_code != 200 { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + let parsed: InterpretationRunCancelled = serde_json::from_str(&response.body) + .map_err(|_| OrchestratorLiveError::InvalidWirePayload)?; + if !parsed.cancelled { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + let item = InterpretationRunCancelled::new( + parsed.interpretation_run_id, + parsed.idempotency_key, + parsed.orchestration_mode, + parsed.claim_status, + parsed.scientific_authority, + )?; + if item.idempotency_key != invocation.idempotency_key + || item.claim_status != HYPOTHETICAL_CLAIM_STATUS + || item.scientific_authority + || !item.cancelled + { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + item.to_json() +} + +fn refuse_scientific_acceptance(body: &str) -> Result<(), OrchestratorLiveError> { + if body.contains(SCIENTIFIC_ACCEPTANCE_SCHEMA) { + Err(OrchestratorLiveError::InvalidWirePayload) + } else { + Ok(()) + } +} + +fn parse_http_response(bytes: &[u8]) -> Result { + let text = std::str::from_utf8(bytes).map_err(|_| OrchestratorLiveError::InvalidWirePayload)?; + let (header_block, body) = text + .split_once("\r\n\r\n") + .ok_or(OrchestratorLiveError::InvalidWirePayload)?; + if header_block.len() > LIVE_HEADER_BYTE_LIMIT { + return Err(OrchestratorLiveError::LimitExceeded); + } + let mut lines = header_block.split("\r\n"); + let status_line = lines + .next() + .ok_or(OrchestratorLiveError::InvalidWirePayload)?; + let mut parts = status_line.split(' '); + if parts.next() != Some("HTTP/1.1") { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + let code = parts + .next() + .ok_or(OrchestratorLiveError::InvalidWirePayload)? + .parse::() + .map_err(|_| OrchestratorLiveError::InvalidWirePayload)?; + let reason_phrase = static_reason(code)?; + let mut content_length = None; + for (index, line) in lines.enumerate() { + if index >= LIVE_HEADER_COUNT_LIMIT { + return Err(OrchestratorLiveError::LimitExceeded); + } + let (name, value) = line + .split_once(':') + .ok_or(OrchestratorLiveError::InvalidWirePayload)?; + if name.eq_ignore_ascii_case("content-length") { + if content_length.is_some() { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + content_length = Some( + value + .trim() + .parse::() + .map_err(|_| OrchestratorLiveError::InvalidWirePayload)?, + ); + } + } + let declared = content_length.ok_or(OrchestratorLiveError::InvalidWirePayload)?; + if declared > DEFAULT_INTERPRETATION_BYTE_LIMIT { + return Err(OrchestratorLiveError::LimitExceeded); + } + if declared != body.len() { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + Ok(OrchestratorLiveResponse { + status_code: code, + reason_phrase, + body: body.to_owned(), + }) +} + +fn static_reason(code: u16) -> Result<&'static str, OrchestratorLiveError> { + 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(OrchestratorLiveError::InvalidWirePayload), + } +} + +/// Read stdin leftover bytes on a non-terminal; cancel POST admits empty. +/// +/// # Errors +/// +/// Returns [`OrchestratorLiveError::InvalidWirePayload`] when stdin cannot be +/// read and [`OrchestratorLiveError::LimitExceeded`] when leftover stdin +/// exceeds the interpretation-run wire limit. +pub fn read_interpretation_run_cancel_cli_stdin( + stdin_is_terminal: bool, + mut stdin: impl Read, +) -> Result { + if stdin_is_terminal { + Ok(String::new()) + } else { + let bytes = read_bounded(&mut stdin, DEFAULT_INTERPRETATION_BYTE_LIMIT)?; + String::from_utf8(bytes).map_err(|_| OrchestratorLiveError::InvalidWirePayload) + } +} + +fn read_bounded( + reader: &mut impl Read, + maximum_bytes: usize, +) -> Result, OrchestratorLiveError> { + let mut bytes = Vec::new(); + reader + .take((maximum_bytes + 1) as u64) + .read_to_end(&mut bytes) + .map_err(|error| map_io_error(&error))?; + if bytes.len() > maximum_bytes { + return Err(OrchestratorLiveError::LimitExceeded); + } + Ok(bytes) +} + +#[cfg(test)] +mod tests { + use super::{ + compose_interpretation_run_cancel_cli_http, + loopback_http1_from_interpretation_run_cancel_exchange, + read_interpretation_run_cancel_cli_stdin, InterpretationRunCancelCliInvocation, + InterpretationRunCancelCliVerb, + }; + use crate::interpretation_run_cli::CONTEXTUAL_ORCHESTRATOR_CONSUMER_CODE; + use crate::{ + contextual_orchestrator_interpretation_run_cancel_exchange, OrchestratorLiveError, + }; + + const ORIGIN: &str = "https://tepp.example.test"; + + fn cancel_args() -> [&'static str; 9] { + [ + "cancel", + "--host", + "127.0.0.1:18082", + "--origin", + ORIGIN, + "--consumer", + CONTEXTUAL_ORCHESTRATOR_CONSUMER_CODE, + "--idempotency-key", + "idem-a", + ] + } + + #[test] + fn from_args_mints_cancel_and_refuses_fail_closed_inputs() { + assert_eq!( + InterpretationRunCancelCliVerb::parse("cancel").expect("cancel"), + InterpretationRunCancelCliVerb::Cancel + ); + assert_eq!(InterpretationRunCancelCliVerb::Cancel.as_str(), "cancel"); + assert_eq!( + InterpretationRunCancelCliVerb::parse("get"), + Err(OrchestratorLiveError::InvalidWirePayload) + ); + let cancel = + InterpretationRunCancelCliInvocation::from_args(cancel_args(), "").expect("cancel"); + assert_eq!(cancel.verb, InterpretationRunCancelCliVerb::Cancel); + let http = compose_interpretation_run_cancel_cli_http(&cancel).expect("http"); + assert!(http.starts_with("POST /v1/interpretation-runs/idem-a/cancel HTTP/1.1")); + assert!(http.contains("tepp-consumer: contextual-orchestrator")); + assert!(http.contains("content-length: 0")); + assert!(!http.contains("idempotency-key:")); + assert!(!http.contains("authorization")); + assert_eq!( + InterpretationRunCancelCliInvocation::from_args( + [ + "cancel", + "--host", + "8.8.8.8:80", + "--origin", + ORIGIN, + "--idempotency-key", + "idem-a" + ], + "" + ) + .unwrap_err(), + OrchestratorLiveError::AuthorizationDenied + ); + assert_eq!( + InterpretationRunCancelCliInvocation::from_args( + [ + "cancel", + "--host", + "localhost:18082", + "--origin", + ORIGIN, + "--idempotency-key", + "idem-a" + ], + "" + ) + .unwrap_err(), + OrchestratorLiveError::InvalidWirePayload + ); + assert_eq!( + InterpretationRunCancelCliInvocation::from_args( + [ + "cancel", + "--host", + "127.0.0.1:18082", + "--origin", + "http://tepp.example.test", + "--idempotency-key", + "idem-a" + ], + "" + ) + .unwrap_err(), + OrchestratorLiveError::InvalidWirePayload + ); + assert_eq!( + InterpretationRunCancelCliInvocation::from_args( + [ + "cancel", + "--host", + "127.0.0.1:18082", + "--origin", + ORIGIN, + "--idempotency-key", + "idem-a", + "--authorization", + "secret" + ], + "" + ) + .unwrap_err(), + OrchestratorLiveError::AuthorizationDenied + ); + } + + #[test] + fn from_args_refuses_unpublished_body_pagination_and_non_post() { + assert_eq!( + InterpretationRunCancelCliInvocation::from_args( + [ + "cancel", + "--host", + "127.0.0.1:18082", + "--origin", + ORIGIN, + "--consumer", + "naruon", + "--idempotency-key", + "idem-a" + ], + "" + ) + .unwrap_err(), + OrchestratorLiveError::InvalidWirePayload + ); + assert_eq!( + InterpretationRunCancelCliInvocation::from_args(cancel_args(), "{}").unwrap_err(), + OrchestratorLiveError::InvalidWirePayload + ); + assert_eq!( + InterpretationRunCancelCliInvocation::from_args( + [ + "cancel", + "--host", + "127.0.0.1:18082", + "--origin", + ORIGIN, + "--idempotency-key", + "idem-a", + "--page-limit", + "1" + ], + "" + ) + .unwrap_err(), + OrchestratorLiveError::InvalidWirePayload + ); + let leftover = + read_interpretation_run_cancel_cli_stdin(false, std::io::Cursor::new(b"leftover")) + .expect("leftover"); + assert_eq!(leftover, "leftover"); + assert!( + read_interpretation_run_cancel_cli_stdin(true, std::io::empty()) + .expect("tty") + .is_empty() + ); + let exchange = contextual_orchestrator_interpretation_run_cancel_exchange(ORIGIN, "idem-a") + .expect("exchange"); + let mut gotten = exchange.clone(); + gotten.method = "GET"; + assert_eq!( + loopback_http1_from_interpretation_run_cancel_exchange(&gotten, "127.0.0.1:18082") + .unwrap_err(), + OrchestratorLiveError::InvalidWirePayload + ); + } +} diff --git a/crates/orchestrator_live/src/lib.rs b/crates/orchestrator_live/src/lib.rs index 706fd8227..f8e3cc349 100644 --- a/crates/orchestrator_live/src/lib.rs +++ b/crates/orchestrator_live/src/lib.rs @@ -9,6 +9,7 @@ //! GET-by-id returns one of those identities without POST replay. //! `POST /v1/interpretation-runs/{idempotency_key}/cancel` removes one //! accepted identity from the in-memory registry. +//! `tepp-interpretation-run-cancel` mints that POST onto spawned loopback TCP. //! Table-access hosts, review/Copilot/GitHub credentials, and //! `COPILOT_GITHUB_TOKEN` fail closed. This crate does not implement TLS //! termination or call a model provider (ADR 0010; ADR 0011). The published @@ -18,6 +19,7 @@ mod error; mod http; +mod interpretation_run_cancel_cli; mod interpretation_run_cancel_http; mod interpretation_run_cli; mod interpretation_run_collection_http; @@ -34,6 +36,22 @@ pub use http::OrchestratorLiveResponse; pub use http::LIVE_HEADER_BYTE_LIMIT; /// Maximum live HTTP header count. pub use http::LIVE_HEADER_COUNT_LIMIT; +/// Compose one HTTP/1.1 cancel POST from the typed consumer exchange. +pub use interpretation_run_cancel_cli::compose_interpretation_run_cancel_cli_http; +/// Dispatch one cancel CLI invocation against an in-process listener. +pub use interpretation_run_cancel_cli::dispatch_interpretation_run_cancel_cli; +/// Execute one cancel CLI invocation over loopback TCP. +pub use interpretation_run_cancel_cli::execute_interpretation_run_cancel_cli; +/// Render a typed cancel POST exchange as HTTP/1.1 for a loopback listener. +pub use interpretation_run_cancel_cli::loopback_http1_from_interpretation_run_cancel_exchange; +/// Read stdin leftover bytes; cancel POST admits empty. +pub use interpretation_run_cancel_cli::read_interpretation_run_cancel_cli_stdin; +/// Filter CLI stdout so cancel never prints scientific acceptance. +pub use interpretation_run_cancel_cli::render_interpretation_run_cancel_cli_stdout; +/// One operator CLI invocation against a loopback cancel listener. +pub use interpretation_run_cancel_cli::InterpretationRunCancelCliInvocation; +/// Supported operator verbs for the loopback interpretation-run cancel CLI. +pub use interpretation_run_cancel_cli::InterpretationRunCancelCliVerb; /// Build a credential-free contextual-orchestrator cancel exchange. pub use interpretation_run_cancel_http::contextual_orchestrator_interpretation_run_cancel_exchange; /// Extract the opaque idempotency key from a cancel path. diff --git a/crates/orchestrator_live/tests/interpretation_run_cancel_cli_contract.rs b/crates/orchestrator_live/tests/interpretation_run_cancel_cli_contract.rs new file mode 100644 index 000000000..3e37cdb94 --- /dev/null +++ b/crates/orchestrator_live/tests/interpretation_run_cancel_cli_contract.rs @@ -0,0 +1,151 @@ +//! Contract tests for `tepp-interpretation-run-cancel cancel`. + +use orchestrator_live::{ + compose_interpretation_run_cli_http, dispatch_interpretation_run_cancel_cli, + execute_interpretation_run_cancel_cli, render_interpretation_run_cancel_cli_stdout, + InterpretationRunCancelCliInvocation, InterpretationRunCancelled, + InterpretationRunCliInvocation, InterpretationRunRequest, OrchestrationMode, + OrchestratorLiveError, OrchestratorLiveResponse, OrchestratorLiveService, + CONTEXTUAL_ORCHESTRATOR_CONSUMER_CODE, HYPOTHETICAL_CLAIM_STATUS, + INTERPRETATION_RUN_CONTRACT_VERSION, +}; + +const ORIGIN: &str = "https://tepp.example.test"; + +fn query_body(idem: &str) -> String { + InterpretationRunRequest::new( + INTERPRETATION_RUN_CONTRACT_VERSION, + idem, + "orch-tenant-demo", + "tepp-snapshot-demo-001", + "2026-08-01T00:00:00Z", + OrchestrationMode::Direct, + 2048, + vec!["span-001".into()], + false, + ) + .expect("request") + .to_json() + .expect("json") +} + +fn create_http(idem: &str) -> String { + let invocation = InterpretationRunCliInvocation::from_args( + [ + "create", + "--host", + "127.0.0.1:18082", + "--origin", + ORIGIN, + "--consumer", + CONTEXTUAL_ORCHESTRATOR_CONSUMER_CODE, + ], + query_body(idem), + ) + .expect("create"); + compose_interpretation_run_cli_http(&invocation).expect("post") +} + +fn cancel_invocation(idem: &str) -> InterpretationRunCancelCliInvocation { + InterpretationRunCancelCliInvocation::from_args( + [ + "cancel", + "--host", + "127.0.0.1:18082", + "--origin", + ORIGIN, + "--idempotency-key", + idem, + ], + "", + ) + .expect("cancel") +} + +#[test] +fn dispatch_cancels_one_hypothetical_identity_without_metrics() { + let mut service = OrchestratorLiveService::new(); + assert_eq!( + service + .handle_http_request(&create_http("idem-a")) + .status_code, + 202 + ); + let cancelled = + dispatch_interpretation_run_cancel_cli(&mut service, &cancel_invocation("idem-a")) + .expect("cancel"); + assert_eq!(cancelled.status_code, 200, "{}", cancelled.body); + let stdout = + render_interpretation_run_cancel_cli_stdout(&cancel_invocation("idem-a"), &cancelled) + .expect("out"); + assert!(!stdout.contains("tepp.scientific_acceptance.v1")); + assert!(!stdout.contains("rmse")); + assert!(!stdout.contains("evidence_span_ids")); + assert!(!stdout.contains("causal_score")); + let item: InterpretationRunCancelled = serde_json::from_str(&stdout).expect("item"); + assert_eq!(item.idempotency_key, "idem-a"); + assert_eq!(item.claim_status, HYPOTHETICAL_CLAIM_STATUS); + assert!(!item.scientific_authority); + assert!(item.cancelled); + assert_eq!( + dispatch_interpretation_run_cancel_cli(&mut service, &cancel_invocation("idem-a")) + .expect("second") + .status_code, + 400 + ); +} + +#[test] +fn render_refuses_metrics_schema_and_empty_bodies() { + let cancel = cancel_invocation("idem-a"); + assert_eq!( + render_interpretation_run_cancel_cli_stdout( + &cancel, + &OrchestratorLiveResponse { + status_code: 200, + reason_phrase: "OK", + body: String::new(), + } + ) + .unwrap_err(), + OrchestratorLiveError::InvalidWirePayload + ); + assert_eq!( + render_interpretation_run_cancel_cli_stdout( + &cancel, + &OrchestratorLiveResponse { + status_code: 200, + reason_phrase: "OK", + body: r#"{"interpretation_run_id":"r","idempotency_key":"idem-a","orchestration_mode":"direct","claim_status":"hypothetical","scientific_authority":false,"cancelled":true,"rmse":1.0}"#.into(), + } + ) + .unwrap_err(), + OrchestratorLiveError::InvalidWirePayload + ); + assert_eq!( + render_interpretation_run_cancel_cli_stdout( + &cancel, + &OrchestratorLiveResponse { + status_code: 400, + reason_phrase: "Bad Request", + body: r#"{"error_code":"invalid_wire_payload"}"#.into(), + } + ) + .unwrap_err(), + OrchestratorLiveError::InvalidWirePayload + ); +} + +#[test] +fn execute_over_tcp_returns_missing_identity_as_invalid_wire() { + let mut service = OrchestratorLiveService::bind_loopback().expect("bind"); + let addr = service.local_addr().expect("addr"); + let handle = std::thread::spawn(move || { + drop(service.serve_one()); + }); + let mut invocation = cancel_invocation("idem-a"); + invocation.host = addr.to_string(); + let response = execute_interpretation_run_cancel_cli(&invocation).expect("tcp"); + assert_eq!(response.status_code, 400, "{}", response.body); + handle.join().expect("join"); +} diff --git a/docs/API_CONTRACT.md b/docs/API_CONTRACT.md index 930c91994..5bc8658e1 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. `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. Loopback `tepp-interpretation-runs create` is the operator-visible client for `POST /v1/interpretation-runs` on `tepp-orchestrator-loopback` (ADR 0064); stdout stays metric-free with `claim_status` `hypothetical` and `scientific_authority` false. Loopback `GET /v1/interpretation-runs` enumerates those accepted hypothetical runs as metric-free identities (ADR 0069); `GET /v1/interpretation-runs/{idempotency_key}` returns one identity without POST replay (ADR 0071); `POST /v1/interpretation-runs/{idempotency_key}/cancel` drops one in-memory identity (ADR 0073); naruon and LineageWeave stay refused. +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. `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. Loopback `tepp-interpretation-runs create` is the operator-visible client for `POST /v1/interpretation-runs` on `tepp-orchestrator-loopback` (ADR 0064); stdout stays metric-free with `claim_status` `hypothetical` and `scientific_authority` false. Loopback `GET /v1/interpretation-runs` enumerates those accepted hypothetical runs as metric-free identities (ADR 0069); `GET /v1/interpretation-runs/{idempotency_key}` returns one identity without POST replay (ADR 0071); `POST /v1/interpretation-runs/{idempotency_key}/cancel` drops one in-memory identity (ADR 0073); `tepp-interpretation-run-cancel cancel` mints that POST onto spawned `tepp-orchestrator-loopback` TCP (ADR 0074); naruon and LineageWeave stay refused. ## 2. Contract families diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 78ff8183e..1fbdad4c5 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -110,6 +110,7 @@ The full APA 7th standards/literature register remains `docs/research/standards- | loopback contextual-orchestrator interpretation-run collection GET | ADR 0069; ADR 0010/0011; API contract; RFC 9110 | `orchestrator_live` `GET /v1/interpretation-runs` on `tepp-orchestrator-loopback`; metric-free hypothetical identities; empty body; no `idempotency-key`; `tepp.scientific_acceptance.v1` never appears; does not infer causality; naruon and LineageWeave refused | active-PR | | loopback contextual-orchestrator interpretation-run GET-by-id | ADR 0071; ADR 0069; ADR 0010/0011; API contract; RFC 9110 | `orchestrator_live` `GET /v1/interpretation-runs/{idempotency_key}` on `tepp-orchestrator-loopback`; metric-free hypothetical identity without POST replay; empty body; no pagination; `tepp.scientific_acceptance.v1` never appears; does not infer causality; naruon and LineageWeave refused | active-PR | | loopback contextual-orchestrator interpretation-run cancel HTTP | ADR 0073; ADR 0071; ADR 0010/0011; API contract; RFC 9110 | `orchestrator_live` `POST /v1/interpretation-runs/{idempotency_key}/cancel` on `tepp-orchestrator-loopback`; metric-free cancelled hypothetical identity; empty body; no pagination; `tepp.scientific_acceptance.v1` never appears; does not infer causality; naruon and LineageWeave refused | active-PR | +| loopback contextual-orchestrator interpretation-run cancel CLI | ADR 0074; ADR 0073; ADR 0010/0011; API contract; RFC 9110 | `orchestrator_live` `tepp-interpretation-run-cancel cancel` CLI against `tepp-orchestrator-loopback` (`POST /v1/interpretation-runs/{idempotency_key}/cancel`); metric-free cancelled hypothetical identity; empty stdin admitted; leftover nonempty stdin fail closed; `tepp.scientific_acceptance.v1` never appears; does not infer causality; naruon and LineageWeave refused | active-PR | | foundation validation / release-readiness ledger | ADR 0014; Test Strategy | PR #24 `docs/validation/temporal-event-foundation.md` on protected main | implemented-main | | scientific claim promotion separated from design/implementation/release | ADR 0014; ADR policy | `validation_core` exact-head promotion gates on this PR; documentation/CI/domain validation remain; full package/image release bundle remaining | partial | | CSAP/SOC 2/ISO/NIST assurance readiness | `docs/COMPLIANCE_READINESS.md`; research register | repository controls + future deployment evidence | accepted-target / deployment-owned | diff --git a/docs/adr/0074-interpretation-run-cancel-cli.md b/docs/adr/0074-interpretation-run-cancel-cli.md new file mode 100644 index 000000000..b000fa6cd --- /dev/null +++ b/docs/adr/0074-interpretation-run-cancel-cli.md @@ -0,0 +1,111 @@ +# ADR 0074 — Contextual-orchestrator interpretation-run cancel CLI + +**Decision status:** Accepted +**Implementation maturity:** active-PR +**Date:** 2026-09-01 +**Supersedes:** None; complements ADR 0073 for operator-visible cancel. Does not supersede ADR 0014 claim-promotion authority. This ADR number is unique on this interpretation stack versus protected main; live vs-main and sibling GAP-003A PRs already occupy 0026–0073. + +## Context + +ADR 0073 publishes loopback +`POST /v1/interpretation-runs/{idempotency_key}/cancel`. Operators still had +no published binary that mints that typed exchange onto spawned +`tepp-orchestrator-loopback` TCP. Duplicating interpretation-run CLI (#425), +collection GET (#433), collection CLI (#436), GET-by-id HTTP (#438), +retrieval CLI (#439), cancel HTTP (#440), analysis-run cancel CLI (#378), +Leiden, Driver p.16, or GAP-010 Figma/export would collide with live PRs. +Naruon and `LineageWeave` are refused on this orchestrator-owned adapter; +`NaruonLiveService` stays POST-only for analysis-run and export. + +## Decision + +`orchestrator_live` publishes `tepp-interpretation-run-cancel cancel`: + +- `from_args` plus typed + `contextual_orchestrator_interpretation_run_cancel_exchange`, + `loopback_http1_from_interpretation_run_cancel_exchange`, + `dispatch`/`execute`/`render`, and a published `[[bin]]`. +- Consumer is `contextual-orchestrator` only. Empty stdin is admitted; + leftover nonempty stdin fails closed. +- Public bind, `localhost` as a hostname, `http` origins, unpublished + consumers, pagination flags, and credential-shaped flags fail closed. +- Stdout is one metric-free cancelled identity: `claim_status=hypothetical`, + `scientific_authority=false`, `cancelled=true`. +- `tepp.scientific_acceptance.v1` never appears. +- The CLI does not infer causality, persist, or return a completed + psychometric result. +- A dedicated binary avoids colliding with collection CLI `list` on #436 and + retrieval CLI `get` on #439. + +## Alternatives considered + +1. **Keep HTTP cancel without a CLI** — rejected; operators still write raw + HTTP after ADR 0073. +2. **Add `cancel` onto `tepp-interpretation-runs`** — rejected for this + slice; collection CLI (`list`) lives on a parallel stack (#436). +3. **Reuse analysis-run cancel CLI (#378)** — rejected; that is a different + live resource. +4. **Published `tepp-interpretation-run-cancel cancel`** — accepted. + +## Consequences + +- Operators can drop one accepted hypothetical identity from a collection + key without writing HTTP by hand. +- Cancel stdout cannot be mistaken for a succeeded scientific-acceptance + result or a causal score. +- Cancel success is not release evidence and is not an ADR 0014 claim. + +## Failure and recovery + +Non-`contextual-orchestrator` consumers, nonempty leftover stdin, present +pagination flags, public bind, `localhost`, `http` origins, credential flags, +slash/NUL keys, missing identities, a second cancel, and metric keys fail +closed. The in-memory listener is not durable. + +## Security, privacy, scientific-integrity, and governance impact + +- No credential headers cross the consumer boundary. +- Evidence spans, tenant, and budget stay off the cancel stdout. +- Process 0 / HTTP 200 on cancel is not measurement evidence and is not a + causal claim. + +## Compatibility and migration + +Cancel HTTP, GET-by-id, collection GET, POST `/v1/interpretation-runs`, and +`tepp-interpretation-runs create` remain unchanged. Persistence remains +GAP-003B. + +## Verification + +Falsifiable evidence: + +- `tepp-interpretation-run-cancel cancel` of an accepted identity returns + `hypothetical` / `scientific_authority=false` / `cancelled=true` without + RMSE/bias/coverage/SE-gate/evidence/`causal_score`/`tepp.scientific_acceptance.v1` + keys; +- leftover stdin, naruon or LineageWeave, public bind, `localhost`, `http` + origin, pagination flags, credential flags, and a second cancel fail + closed; +- Clippy `-D warnings`, `orchestrator_live` tests, rustdoc, and exact-head + review remain required. + +## Rollback and supersession + +Rollback removes `tepp-interpretation-run-cancel`; cancel HTTP remains +valid. A superseding ADR is required to persist cancellations, bind a public +address, emit scientific-acceptance on cancel, open naruon or LineageWeave, +or treat cancel success as an ADR 0014 claim. + +## Related authority + +- ADR 0073 owns loopback interpretation-run cancel HTTP. +- ADR 0072 owns the retrieval CLI (live #439). +- ADR 0071 owns loopback interpretation-run GET-by-id. +- ADR 0069 owns loopback interpretation-run collection GET. +- ADR 0064 owns the interpretation-run POST CLI (live #425). +- ADR 0010 owns orchestration mode vocabulary and scientific-authority + separation. +- ADR 0011 owns standalone/modular HTTP boundaries. +- ADR 0014 owns scientific claim promotion. +- 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 8bbb75bc5..14f98664a 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -34,6 +34,7 @@ Read [`ADR_POLICY.md`](ADR_POLICY.md) first. **Decision status and implementatio | [0069](0069-interpretation-run-collection-get.md) | Loopback `GET /v1/interpretation-runs` enumerates accepted hypothetical interpretation runs | Accepted | active-PR | Complements ADR 0010/0011/0064; does not supersede ADR 0014. Unique on this stack versus protected main (0026–0068 occupied). Does not infer causality. | | [0071](0071-interpretation-run-retrieval-get.md) | Loopback `GET /v1/interpretation-runs/{idempotency_key}` returns one accepted hypothetical identity | Accepted | active-PR | Complements ADR 0069; does not supersede ADR 0014. Unique on this interpretation stack versus protected main (0026–0070 occupied). Does not infer causality. | | [0073](0073-interpretation-run-cancel-http.md) | Loopback `POST /v1/interpretation-runs/{idempotency_key}/cancel` drops one accepted hypothetical identity | Accepted | active-PR | Complements ADR 0071; does not supersede ADR 0014. Unique on this interpretation stack versus protected main (0026–0072 occupied). Does not infer causality. | +| [0074](0074-interpretation-run-cancel-cli.md) | Loopback `tepp-interpretation-run-cancel cancel` is contextual-orchestrator cancel client | Accepted | active-PR | Complements ADR 0073; does not supersede ADR 0014. Unique on this interpretation stack versus protected main (0026–0073 occupied). Does not infer causality. | | [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. | diff --git a/docs/connectors/contextual-orchestrator-interpretation-port.md b/docs/connectors/contextual-orchestrator-interpretation-port.md index 57e593dcf..086b198c1 100644 --- a/docs/connectors/contextual-orchestrator-interpretation-port.md +++ b/docs/connectors/contextual-orchestrator-interpretation-port.md @@ -18,7 +18,7 @@ LLM/provider settings are execution policy only. Deterministic scientific gates `POST /v1/interpretation-runs` plus `GET /v1/interpretation-runs`. Accepted output is always hypothetical and never scientific authority. Collection GET returns metric-free identities only. GET-by-id returns one of those identities -without POST replay. Cancel drops one in-memory identity. Non-loopback binds, table-access hosts, and +without POST replay. Cancel drops one in-memory identity. `tepp-interpretation-run-cancel cancel` mints that POST onto spawned loopback TCP. Non-loopback binds, table-access hosts, and review/Copilot/GitHub credential headers fail closed. The listener does not call a model provider. diff --git a/docs/research/interpretation-run-cancel-cli.md b/docs/research/interpretation-run-cancel-cli.md new file mode 100644 index 000000000..241cc39ef --- /dev/null +++ b/docs/research/interpretation-run-cancel-cli.md @@ -0,0 +1,60 @@ +# Interpretation-run cancel CLI (doctoring) + +## Scope + +`tepp-interpretation-run-cancel cancel` is the operator-visible client of +loopback `POST /v1/interpretation-runs/{idempotency_key}/cancel` on +`tepp-orchestrator-loopback`. HTTP method, path, and header semantics follow +current HTTP semantics (Fielding, Nottingham, & Reschke, 2022). Fail-closed +refusal of unpublished consumers, leftover nonempty stdin, public bind, +`localhost`, `http` origins, pagination flags, review/Copilot/GitHub +credential flags, and scientific-authority promotion is repository contract +authority (ADR 0074; ADR 0073; ADR 0010; ADR 0011; ADR 0014), not an RFC +inference rule. + +Stdout is metric-free. `claim_status` remains `hypothetical`. +`scientific_authority` remains false. `cancelled` is `true`. +`tepp.scientific_acceptance.v1` never appears. Process 0 is not a completed +psychometric result, calibrated score, theta estimate, uncertainty statement, +causal inference, or scientific claim. + +## 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 representation +enclosed in the request. TEPP maps that processing onto a bounded, in-memory +drop of one hypothetical interpretation-run identity. The RFC does not define +psychometric acceptance, RMSE, causality, or claim promotion. + +### Internal contract evidence + +- `docs/adr/0074-interpretation-run-cancel-cli.md` — this CLI +- `docs/adr/0073-interpretation-run-cancel-http.md` — cancel HTTP +- `docs/adr/0064-interpretation-run-cli.md` — create CLI +- `docs/adr/0010-adaptive-llm-orchestration.md` — mode vocabulary and + scientific-authority separation +- `docs/adr/0011-standalone-modular-msa-boundary.md` — modular HTTP boundary +- `docs/adr/0014-scientific-claim-promotion-and-release-evidence.md` — process + 0 is not a scientific claim +- `crates/orchestrator_live/tests/interpretation_run_cancel_cli_contract.rs` + — fail-closed cancel CLI proofs + +## Verification + +- `tepp-interpretation-run-cancel cancel` of an accepted contextual-orchestrator + identity returns `hypothetical` with `scientific_authority` false, + `cancelled` true, and without RMSE/bias/coverage/SE-gate keys, + `evidence_span_ids`, `causal_score`, or `tepp.scientific_acceptance.v1`; +- leftover nonempty stdin, naruon or LineageWeave, public bind, `localhost`, + `http` origin, pagination flags, and credential flags fail closed. + +## Non-claims + +This slice does not implement collection CLI, analysis-run cancel CLI, export +GET, project-history retrieval CLI, persistence, production TLS, Leiden +consensus, GAP-010 Figma/export, provider execution, causal inference, or an +ADR 0014 scientific claim-promotion package.