diff --git a/CHANGELOG.d/interpretation-run-cancel-http.md b/CHANGELOG.d/interpretation-run-cancel-http.md new file mode 100644 index 000000000..d3ef1288c --- /dev/null +++ b/CHANGELOG.d/interpretation-run-cancel-http.md @@ -0,0 +1 @@ +- `orchestrator_live` loopback `POST /v1/interpretation-runs/{idempotency_key}/cancel` removes one accepted hypothetical interpretation-run identity on `tepp-orchestrator-loopback` (ADR 0073). Metric-free receipt 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 analysis-run cancel, not retrieval CLI, not persistence. diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index e82530848..ad37fd34f 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 | Interpretation-run CLI doctoring | [`docs/research/interpretation-run-cli.md`](docs/research/interpretation-run-cli.md) | | 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) | | 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/src/interpretation_run_cancel_http.rs b/crates/orchestrator_live/src/interpretation_run_cancel_http.rs new file mode 100644 index 000000000..5a14888a1 --- /dev/null +++ b/crates/orchestrator_live/src/interpretation_run_cancel_http.rs @@ -0,0 +1,400 @@ +//! Provider-owned interpretation-run cancel HTTP contracts. +//! +//! GAP-003A unique slice: `POST /v1/interpretation-runs/{idempotency_key}/cancel` +//! removes one accepted hypothetical identity from the in-memory +//! `OrchestratorLiveService` / `tepp-orchestrator-loopback` registry. The +//! response stays metric-free with `claim_status=hypothetical`, +//! `scientific_authority=false`, and `cancelled=true`. +//! `tepp.scientific_acceptance.v1` never appears. Cancel does not infer +//! causality or call a model provider. This module does not duplicate +//! interpretation-run CLI (#425), collection GET (#433), collection CLI +//! (#436), GET-by-id HTTP (#438), retrieval CLI (#439), analysis-run cancel +//! HTTP (#361), Leiden, or GAP-010 Figma/export. Persistence remains +//! GAP-003B. Naruon and `LineageWeave` are refused. `NaruonLiveService` +//! stays POST-only for analysis-run and export. + +use serde::{Deserialize, Serialize}; + +use crate::error::OrchestratorLiveError; +use crate::interpretation_run_cli::CONTEXTUAL_ORCHESTRATOR_CONSUMER_CODE; +use crate::interpretation_run_collection_http::INTERPRETATION_RUN_COLLECTION_CURSOR_MAX_LEN; +use crate::mode::OrchestrationMode; +use crate::request::{ + host_implies_table_access, require_nonempty, to_json, HYPOTHETICAL_CLAIM_STATUS, + INTERPRETATION_RUN_PATH, +}; + +/// Maximum opaque idempotency-key length on the cancel path. +pub const INTERPRETATION_RUN_CANCEL_ID_MAX_LEN: usize = + INTERPRETATION_RUN_COLLECTION_CURSOR_MAX_LEN; + +const FORBIDDEN_CANCEL_KEYS: [&str; 14] = [ + "rmse", + "rmse_standard_error", + "mean_bias", + "bias_standard_error", + "interval_coverage", + "coverage_wilson_lower", + "coverage_wilson_upper", + "temporal_order_accuracy", + "se_gate_accepted", + "scientific_acceptance", + "causal_score", + "causality", + "evidence_span_ids", + "findings", +]; + +/// Typed POST exchange for interpretation-run cancel. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct InterpretationRunCancelHttpExchange { + /// HTTP method, always `POST`. + pub method: &'static str, + /// Absolute HTTPS target ending in `/v1/interpretation-runs/{key}/cancel`. + pub target_url: String, + /// Exact version, consumer, and content headers. No credentials. + pub headers: Vec<(String, String)>, + /// POST body, always empty. + pub body: String, +} + +/// Metric-free cancelled interpretation-run identity. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct InterpretationRunCancelled { + /// Server-assigned opaque interpretation-run identity. + pub interpretation_run_id: String, + /// Exact request idempotency key that minted the stored run. + pub idempotency_key: String, + /// Selected orchestration mode. + pub orchestration_mode: OrchestrationMode, + /// Fixed claim boundary: accepted output is hypothetical. + pub claim_status: String, + /// Always `false`; LLM output is never scientific authority. + pub scientific_authority: bool, + /// Always `true` on a successful cancel receipt. + pub cancelled: bool, +} + +impl InterpretationRunCancelled { + /// Construct a validated cancelled identity. + /// + /// # Errors + /// + /// Returns a fail-closed error for empty identities, a non-hypothetical + /// claim, scientific authority, or `cancelled=false`. + pub fn new( + interpretation_run_id: impl Into, + idempotency_key: impl Into, + orchestration_mode: OrchestrationMode, + claim_status: impl Into, + scientific_authority: bool, + ) -> Result { + let item = Self { + interpretation_run_id: interpretation_run_id.into(), + idempotency_key: idempotency_key.into(), + orchestration_mode, + claim_status: claim_status.into(), + scientific_authority, + cancelled: true, + }; + item.validate()?; + Ok(item) + } + + fn validate(&self) -> Result<(), OrchestratorLiveError> { + require_nonempty(&self.interpretation_run_id)?; + require_nonempty(&self.idempotency_key)?; + if self.idempotency_key.len() > INTERPRETATION_RUN_CANCEL_ID_MAX_LEN { + return Err(OrchestratorLiveError::LimitExceeded); + } + if self.claim_status != HYPOTHETICAL_CLAIM_STATUS || self.scientific_authority { + return Err(OrchestratorLiveError::ScientificAuthorityRefused); + } + if !self.cancelled { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + Ok(()) + } + + /// Serialize this cancelled identity after metric refusal. + /// + /// # Errors + /// + /// Returns a validation or metric-key error. + pub fn to_json(&self) -> Result { + self.validate()?; + let payload = to_json(self)?; + refuse_metrics_on_interpretation_run_cancel_payload(&payload)?; + Ok(payload) + } +} + +/// Extract the opaque idempotency key from +/// `POST /v1/interpretation-runs/{key}/cancel`. +/// +/// # Errors +/// +/// Returns [`OrchestratorLiveError::InvalidWirePayload`] for the collection +/// path, GET-by-id path, extra segments, a hostile encoding, empty identity, +/// slash, or NUL, and [`OrchestratorLiveError::LimitExceeded`] when oversized. +pub fn interpretation_run_cancel_path_id(path: &str) -> Result { + let remainder = path + .strip_prefix(INTERPRETATION_RUN_PATH) + .ok_or(OrchestratorLiveError::InvalidWirePayload)?; + let encoded = remainder + .strip_prefix('/') + .ok_or(OrchestratorLiveError::InvalidWirePayload)?; + let encoded = encoded + .strip_suffix("/cancel") + .ok_or(OrchestratorLiveError::InvalidWirePayload)?; + if encoded.is_empty() || encoded.contains('/') { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + let idempotency_key = decode_path_segment(encoded)?; + require_nonempty(&idempotency_key)?; + if idempotency_key.contains('/') || idempotency_key.contains('\0') { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + if idempotency_key.len() > INTERPRETATION_RUN_CANCEL_ID_MAX_LEN { + return Err(OrchestratorLiveError::LimitExceeded); + } + Ok(idempotency_key) +} + +/// Refuse cancel JSON that already carries scientific-metric or evidence keys. +/// +/// Empty payloads are admitted for the POST request body. +/// +/// # Errors +/// +/// Returns [`OrchestratorLiveError::InvalidWirePayload`] when a forbidden key +/// is present, the scientific-acceptance schema is claimed, or nonempty JSON +/// is not an object. +pub fn refuse_metrics_on_interpretation_run_cancel_payload( + payload: &str, +) -> Result<(), OrchestratorLiveError> { + if payload.trim().is_empty() { + return Ok(()); + } + let value: serde_json::Value = + serde_json::from_str(payload).map_err(|_| OrchestratorLiveError::InvalidWirePayload)?; + let Some(object) = value.as_object() else { + return Err(OrchestratorLiveError::InvalidWirePayload); + }; + if object + .get("schema_version") + .and_then(serde_json::Value::as_str) + == Some("tepp.scientific_acceptance.v1") + { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + if FORBIDDEN_CANCEL_KEYS + .iter() + .any(|key| object.contains_key(*key)) + { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + Ok(()) +} + +/// Build a credential-free contextual-orchestrator cancel exchange. +/// +/// # Errors +/// +/// Returns a fail-closed origin or identity error. +pub fn contextual_orchestrator_interpretation_run_cancel_exchange( + origin: &str, + idempotency_key: &str, +) -> Result { + require_nonempty(origin)?; + if !origin.starts_with("https://") || origin.ends_with('/') { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + let rest = origin + .strip_prefix("https://") + .ok_or(OrchestratorLiveError::InvalidWirePayload)?; + if rest.contains('@') || rest.contains('?') || rest.contains('#') || rest.contains('\\') { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + if host_implies_table_access(rest) { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + require_nonempty(idempotency_key)?; + if idempotency_key.contains('/') || idempotency_key.contains('\0') { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + if idempotency_key.len() > INTERPRETATION_RUN_CANCEL_ID_MAX_LEN { + return Err(OrchestratorLiveError::LimitExceeded); + } + let encoded_id = encode_path_segment(idempotency_key); + Ok(InterpretationRunCancelHttpExchange { + method: "POST", + target_url: format!("{origin}{INTERPRETATION_RUN_PATH}/{encoded_id}/cancel"), + headers: vec![ + ("content-type".into(), "application/json".into()), + ( + "tepp-consumer".into(), + CONTEXTUAL_ORCHESTRATOR_CONSUMER_CODE.into(), + ), + ("tepp-contract-version".into(), "1".into()), + ], + body: String::new(), + }) +} + +fn encode_path_segment(value: &str) -> String { + let mut out = String::with_capacity(value.len()); + for byte in value.bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => { + out.push(byte as char); + } + _ => { + let hex = b"0123456789ABCDEF"; + out.push('%'); + out.push(hex[usize::from(byte >> 4)] as char); + out.push(hex[usize::from(byte & 0x0F)] as char); + } + } + } + out +} + +fn decode_path_segment(value: &str) -> Result { + let mut out = Vec::with_capacity(value.len()); + let bytes = value.as_bytes(); + let mut index = 0; + while index < bytes.len() { + match bytes[index] { + b'%' => { + if index + 2 >= bytes.len() { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + let hi = from_hex(bytes[index + 1])?; + let lo = from_hex(bytes[index + 2])?; + out.push((hi << 4) | lo); + index += 3; + } + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => { + out.push(bytes[index]); + index += 1; + } + _ => return Err(OrchestratorLiveError::InvalidWirePayload), + } + } + let decoded = String::from_utf8(out).map_err(|_| OrchestratorLiveError::InvalidWirePayload)?; + if decoded.chars().any(char::is_control) { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + Ok(decoded) +} + +fn from_hex(byte: u8) -> Result { + match byte { + b'0'..=b'9' => Ok(byte - b'0'), + b'A'..=b'F' => Ok(byte - b'A' + 10), + b'a'..=b'f' => Ok(byte - b'a' + 10), + _ => Err(OrchestratorLiveError::InvalidWirePayload), + } +} + +#[cfg(test)] +mod tests { + use super::{ + contextual_orchestrator_interpretation_run_cancel_exchange, + interpretation_run_cancel_path_id, refuse_metrics_on_interpretation_run_cancel_payload, + InterpretationRunCancelled, INTERPRETATION_RUN_CANCEL_ID_MAX_LEN, + }; + use crate::error::OrchestratorLiveError; + use crate::mode::OrchestrationMode; + + #[test] + fn cancel_exchange_is_metric_free_post_without_credentials() { + let exchange = contextual_orchestrator_interpretation_run_cancel_exchange( + "https://tepp.example.test", + "idem-a", + ) + .expect("exchange"); + assert_eq!(exchange.method, "POST"); + assert!(exchange + .target_url + .ends_with("/v1/interpretation-runs/idem-a/cancel")); + assert!(exchange.body.is_empty()); + assert!(!exchange + .headers + .iter() + .any(|(name, _)| name.eq_ignore_ascii_case("authorization") + || name.eq_ignore_ascii_case("idempotency-key"))); + assert_eq!( + interpretation_run_cancel_path_id("/v1/interpretation-runs/idem-a/cancel").expect("id"), + "idem-a" + ); + let item = InterpretationRunCancelled::new( + "orch-run-1", + "idem-a", + OrchestrationMode::Direct, + "hypothetical", + false, + ) + .expect("item"); + let json = item.to_json().expect("json"); + assert!(json.contains("\"cancelled\":true")); + assert!(!json.contains("rmse")); + assert!(!json.contains("evidence_span_ids")); + assert!(!json.contains("tepp.scientific_acceptance.v1")); + } + + #[test] + fn cancel_path_and_payloads_fail_closed() { + assert_eq!( + interpretation_run_cancel_path_id("/v1/interpretation-runs"), + Err(OrchestratorLiveError::InvalidWirePayload) + ); + assert_eq!( + interpretation_run_cancel_path_id("/v1/interpretation-runs/idem-a"), + Err(OrchestratorLiveError::InvalidWirePayload) + ); + assert_eq!( + interpretation_run_cancel_path_id("/v1/interpretation-runs/idem-a/extra/cancel"), + Err(OrchestratorLiveError::InvalidWirePayload) + ); + assert_eq!( + interpretation_run_cancel_path_id("/v1/analysis-runs/idem-a/cancel"), + Err(OrchestratorLiveError::InvalidWirePayload) + ); + assert_eq!( + interpretation_run_cancel_path_id(&format!( + "/v1/interpretation-runs/{}/cancel", + "a".repeat(INTERPRETATION_RUN_CANCEL_ID_MAX_LEN + 1) + )), + Err(OrchestratorLiveError::LimitExceeded) + ); + assert_eq!( + contextual_orchestrator_interpretation_run_cancel_exchange( + "http://insecure.example", + "idem-a", + ), + Err(OrchestratorLiveError::InvalidWirePayload) + ); + assert_eq!( + contextual_orchestrator_interpretation_run_cancel_exchange( + "https://tepp.example.test", + "idem/slash", + ), + Err(OrchestratorLiveError::InvalidWirePayload) + ); + assert_eq!( + refuse_metrics_on_interpretation_run_cancel_payload(""), + Ok(()) + ); + assert_eq!( + refuse_metrics_on_interpretation_run_cancel_payload(r#"{"rmse":1.0}"#), + Err(OrchestratorLiveError::InvalidWirePayload) + ); + assert_eq!( + refuse_metrics_on_interpretation_run_cancel_payload("[]"), + Err(OrchestratorLiveError::InvalidWirePayload) + ); + } +} diff --git a/crates/orchestrator_live/src/lib.rs b/crates/orchestrator_live/src/lib.rs index 260f3c2db..706fd8227 100644 --- a/crates/orchestrator_live/src/lib.rs +++ b/crates/orchestrator_live/src/lib.rs @@ -7,6 +7,8 @@ //! hypothetical and never scientific authority. Collection GET enumerates //! metric-free identities so operators do not guess idempotency keys. //! 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. //! 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 @@ -16,6 +18,7 @@ mod error; mod http; +mod interpretation_run_cancel_http; mod interpretation_run_cli; mod interpretation_run_collection_http; mod interpretation_run_retrieval_http; @@ -31,6 +34,18 @@ pub use http::OrchestratorLiveResponse; pub use http::LIVE_HEADER_BYTE_LIMIT; /// Maximum live HTTP header count. pub use http::LIVE_HEADER_COUNT_LIMIT; +/// 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. +pub use interpretation_run_cancel_http::interpretation_run_cancel_path_id; +/// Refuse metric, evidence, and causal-score keys on cancel JSON. +pub use interpretation_run_cancel_http::refuse_metrics_on_interpretation_run_cancel_payload; +/// Typed POST exchange for interpretation-run cancel. +pub use interpretation_run_cancel_http::InterpretationRunCancelHttpExchange; +/// Metric-free cancelled interpretation-run identity. +pub use interpretation_run_cancel_http::InterpretationRunCancelled; +/// Maximum opaque idempotency-key length on interpretation-run cancel. +pub use interpretation_run_cancel_http::INTERPRETATION_RUN_CANCEL_ID_MAX_LEN; /// Compose HTTP/1.1 interpretation-run POST from a CLI invocation. pub use interpretation_run_cli::compose_interpretation_run_cli_http; /// Build a credential-free contextual-orchestrator interpretation-run exchange. diff --git a/crates/orchestrator_live/src/service.rs b/crates/orchestrator_live/src/service.rs index 969fa0349..2ccd6318a 100644 --- a/crates/orchestrator_live/src/service.rs +++ b/crates/orchestrator_live/src/service.rs @@ -9,6 +9,10 @@ use crate::http::{ refuse_collection_get_headers, refuse_live_headers, refuse_retrieval_get_headers, split_request, status_for, write_response, OrchestratorLiveResponse, }; +use crate::interpretation_run_cancel_http::{ + interpretation_run_cancel_path_id, refuse_metrics_on_interpretation_run_cancel_payload, + InterpretationRunCancelled, +}; use crate::interpretation_run_collection_http::{ is_interpretation_run_collection_path, page_interpretation_run_collection_items, parse_interpretation_run_collection_page_cursor, @@ -30,6 +34,8 @@ use crate::request::{ /// `GET /v1/interpretation-runs` enumerates accepted hypothetical runs as /// metric-free identities. `GET /v1/interpretation-runs/{idempotency_key}` /// returns one of those identities without POST replay. +/// `POST /v1/interpretation-runs/{idempotency_key}/cancel` removes one +/// accepted identity from the in-memory registry. #[derive(Debug)] pub struct OrchestratorLiveService { listener: Option, @@ -182,11 +188,17 @@ impl OrchestratorLiveService { } return self.get_interpretation_run(path, &headers, body); } - if method != "POST" || path != INTERPRETATION_RUN_PATH { - return Err(OrchestratorLiveError::InvalidWirePayload); + if method == "POST" { + if interpretation_run_cancel_path_id(path).is_ok() { + return self.cancel_interpretation_run(path, &headers, body); + } + if path != INTERPRETATION_RUN_PATH { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + refuse_live_headers(&headers)?; + return self.accept_interpretation_run(&headers, body); } - refuse_live_headers(&headers)?; - self.accept_interpretation_run(&headers, body) + Err(OrchestratorLiveError::InvalidWirePayload) } fn list_interpretation_runs( @@ -261,6 +273,36 @@ impl OrchestratorLiveService { )) } + fn cancel_interpretation_run( + &mut self, + path: &str, + headers: &HashMap, + body: &str, + ) -> Result { + let idempotency_key = interpretation_run_cancel_path_id(path)?; + if !body.is_empty() { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + refuse_metrics_on_interpretation_run_cancel_payload(body)?; + refuse_retrieval_get_headers(headers)?; + let (_, accepted) = self + .accepted_runs + .remove(&idempotency_key) + .ok_or(OrchestratorLiveError::InvalidWirePayload)?; + let cancelled = InterpretationRunCancelled::new( + accepted.interpretation_run_id(), + accepted.idempotency_key(), + accepted.orchestration_mode(), + accepted.claim_status(), + accepted.scientific_authority(), + )?; + Ok(OrchestratorLiveResponse::json( + 200, + "OK", + cancelled.to_json()?, + )) + } + fn accept_interpretation_run( &mut self, headers: &HashMap, diff --git a/crates/orchestrator_live/tests/interpretation_run_cancel_http_contract.rs b/crates/orchestrator_live/tests/interpretation_run_cancel_http_contract.rs new file mode 100644 index 000000000..8688ab6dd --- /dev/null +++ b/crates/orchestrator_live/tests/interpretation_run_cancel_http_contract.rs @@ -0,0 +1,54 @@ +//! Contract tests for contextual-orchestrator interpretation-run cancel. + +use orchestrator_live::{ + contextual_orchestrator_interpretation_run_cancel_exchange, interpretation_run_cancel_path_id, + OrchestratorLiveError, CONTEXTUAL_ORCHESTRATOR_CONSUMER_CODE, +}; + +#[test] +fn interpretation_run_cancel_is_metric_free_post_without_credentials() { + let exchange = contextual_orchestrator_interpretation_run_cancel_exchange( + "https://tepp.example.test", + "orch-live-idem-001", + ) + .expect("exchange"); + assert_eq!(exchange.method, "POST"); + assert!(exchange + .target_url + .ends_with("/v1/interpretation-runs/orch-live-idem-001/cancel")); + assert!(exchange.body.is_empty()); + assert!(exchange + .headers + .iter() + .any(|(name, value)| name == "tepp-consumer" + && value == CONTEXTUAL_ORCHESTRATOR_CONSUMER_CODE)); + assert!(!exchange + .headers + .iter() + .any(|(name, _)| name.eq_ignore_ascii_case("authorization") + || name.eq_ignore_ascii_case("idempotency-key"))); + assert_eq!( + interpretation_run_cancel_path_id("/v1/interpretation-runs/orch-live-idem-001/cancel") + .expect("id"), + "orch-live-idem-001" + ); +} + +#[test] +fn interpretation_run_cancel_refuses_collection_get_by_id_and_insecure_origins() { + assert_eq!( + interpretation_run_cancel_path_id("/v1/interpretation-runs"), + Err(OrchestratorLiveError::InvalidWirePayload) + ); + assert_eq!( + interpretation_run_cancel_path_id("/v1/interpretation-runs/idem-a"), + Err(OrchestratorLiveError::InvalidWirePayload) + ); + assert_eq!( + contextual_orchestrator_interpretation_run_cancel_exchange( + "http://tepp.example.test", + "idem-a" + ), + Err(OrchestratorLiveError::InvalidWirePayload) + ); +} diff --git a/crates/orchestrator_live/tests/live_http_contract.rs b/crates/orchestrator_live/tests/live_http_contract.rs index ed4cde62e..b570e8c6e 100644 --- a/crates/orchestrator_live/tests/live_http_contract.rs +++ b/crates/orchestrator_live/tests/live_http_contract.rs @@ -459,6 +459,77 @@ fn handle_http_retrieves_one_interpretation_run_on_get_by_id() { ); } +#[test] +fn handle_http_cancels_one_interpretation_run_and_drops_the_identity() { + let mut service = OrchestratorLiveService::new(); + let first = sample_request(); + assert_eq!( + service + .handle_http_request(&interpretation_http(&first)) + .status_code, + 202 + ); + let mut naruon = collection_headers(); + naruon[2] = ("tepp-consumer".into(), "naruon".into()); + assert_eq!( + service + .handle_http_request(&http_request( + "POST", + "/v1/interpretation-runs/orch-live-idem-001/cancel", + &naruon, + "", + )) + .status_code, + 400 + ); + let cancelled = service.handle_http_request(&http_request( + "POST", + "/v1/interpretation-runs/orch-live-idem-001/cancel", + &collection_headers(), + "", + )); + assert_eq!(cancelled.status_code, 200, "{}", cancelled.body); + assert!(cancelled.body.contains("\"cancelled\":true")); + assert!(cancelled.body.contains("\"claim_status\":\"hypothetical\"")); + assert!(cancelled.body.contains("\"scientific_authority\":false")); + assert!(!cancelled.body.contains("rmse")); + assert!(!cancelled.body.contains("evidence_span_ids")); + assert!(!cancelled.body.contains("tepp.scientific_acceptance.v1")); + assert_eq!( + service + .handle_http_request(&http_request( + "GET", + "/v1/interpretation-runs/orch-live-idem-001", + &collection_headers(), + "", + )) + .status_code, + 400 + ); + assert_eq!( + service + .handle_http_request(&http_request( + "POST", + "/v1/interpretation-runs/orch-live-idem-001/cancel", + &collection_headers(), + "", + )) + .status_code, + 400 + ); + assert_eq!( + service + .handle_http_request(&http_request( + "POST", + "/v1/interpretation-runs/orch-live-idem-001/cancel", + &collection_headers(), + "{}", + )) + .status_code, + 400 + ); +} + #[test] fn handle_http_collection_get_refuses_foreign_consumers_and_hostile_headers() { let mut service = OrchestratorLiveService::new(); diff --git a/docs/API_CONTRACT.md b/docs/API_CONTRACT.md index 3698a7377..930c91994 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); 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); naruon and LineageWeave stay refused. ## 2. Contract families @@ -65,6 +65,7 @@ GET /v1/evidence-imports/{import_id} POST /v1/interpretation-runs GET /v1/interpretation-runs GET /v1/interpretation-runs/{idempotency_key} +POST /v1/interpretation-runs/{idempotency_key}/cancel POST /v1/analysis-runs POST /v1/temporal-context GET /v1/analysis-runs/{run_id} diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 4f128ab0f..78ff8183e 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -109,6 +109,7 @@ The full APA 7th standards/literature register remains `docs/research/standards- | loopback contextual-orchestrator interpretation-run CLI | ADR 0064; ADR 0010/0011; API contract; RFC 9110 | `orchestrator_live` `tepp-interpretation-runs create` CLI against `tepp-orchestrator-loopback` (`POST /v1/interpretation-runs`); metric-free hypothetical JSON; `tepp.scientific_acceptance.v1` never appears; does not infer causality; naruon and LineageWeave refused | active-PR | | 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 | | 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/0073-interpretation-run-cancel-http.md b/docs/adr/0073-interpretation-run-cancel-http.md new file mode 100644 index 000000000..e257e634e --- /dev/null +++ b/docs/adr/0073-interpretation-run-cancel-http.md @@ -0,0 +1,110 @@ +# ADR 0073 — Contextual-orchestrator interpretation-run cancel HTTP + +**Decision status:** Accepted +**Implementation maturity:** active-PR +**Date:** 2026-09-01 +**Supersedes:** None; complements ADR 0071 for removing one accepted identity. 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–0072. + +## Context + +ADR 0071 retrieves one accepted hypothetical interpretation-run identity, and +#439 publishes a retrieval CLI. Operators who held an `idempotency_key` still +had no loopback path to drop that in-memory identity without restarting the +listener. Duplicating interpretation-run CLI (#425), collection GET (#433), +collection CLI (#436), GET-by-id HTTP (#438), retrieval CLI (#439), +analysis-run cancel HTTP (#361), 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 loopback-only +`POST /v1/interpretation-runs/{idempotency_key}/cancel` on +`tepp-orchestrator-loopback`: + +- Consumer is `contextual-orchestrator` only. Empty body. Identity travels in + the path. `idempotency-key` and collection pagination headers are refused. +- Extra extra-segments, slash, NUL, and oversized identities fail closed. +- A successful cancel removes the identity from the in-memory registry and + returns a metric-free receipt: `interpretation_run_id`, `idempotency_key`, + `orchestration_mode`, `claim_status=hypothetical`, + `scientific_authority=false`, `cancelled=true`. +- Subsequent GET-by-id and collection rows for that key fail closed as + missing. A second cancel of the same key fails closed. +- `tepp.scientific_acceptance.v1`, RMSE, bias, coverage, SE-gate, + `evidence_span_ids`, `findings`, and `causal_score` never appear. +- Cancel does not infer causality, persist, or return a completed + psychometric result. +- This slice does not implement a cancel CLI. + +## Alternatives considered + +1. **Restart the listener to drop identities** — rejected; operators still + cannot target one key after ADR 0071. +2. **Reuse analysis-run cancel HTTP (#361)** — rejected; that is a different + live resource and a naruon consumer. +3. **Keep identities until process exit** — rejected; the in-memory registry + would retain hypothetic identities with no operator-visible drop path. +4. **Loopback `POST /v1/interpretation-runs/{idempotency_key}/cancel`** — + accepted. + +## Consequences + +- Operators can drop one accepted hypothetical identity without restarting + `tepp-orchestrator-loopback`. +- Cancel JSON 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 POST bodies, present +`idempotency-key`, pagination headers, extra path segments, slash/NUL keys, +credential flags, missing identities, 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 receipt. +- HTTP 200 on cancel is not measurement evidence and is not a causal claim. + +## Compatibility and migration + +Collection GET, GET-by-id, POST `/v1/interpretation-runs`, and +`tepp-interpretation-runs create` remain unchanged. A cancel CLI remains a +later slice. Persistence remains GAP-003B. + +## Verification + +Falsifiable evidence: + +- POST 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; +- subsequent GET-by-id, a second cancel, naruon or LineageWeave, nonempty + body, and unknown keys fail closed; +- Clippy `-D warnings`, `orchestrator_live` tests, rustdoc, and exact-head + review remain required. + +## Rollback and supersession + +Rollback removes cancel HTTP; collection GET, GET-by-id, and POST remain +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 0071 owns loopback interpretation-run GET-by-id. +- ADR 0072 owns the retrieval CLI (live #439). +- ADR 0069 owns loopback interpretation-run collection GET. +- ADR 0064 owns the interpretation-run POST CLI (live #425). +- ADR 0029 owns analysis-run cancel HTTP (live #361) as a different resource. +- 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 4002ba826..8bbb75bc5 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 | [0064](0064-interpretation-run-cli.md) | Loopback `tepp-interpretation-runs create` is contextual-orchestrator POST /v1/interpretation-runs client | Accepted | active-PR | Complements ADR 0010/0011; does not supersede ADR 0014. Unique on protected main. Does not infer causality. | | [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. | | [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 d70a725bc..57e593dcf 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. Non-loopback binds, table-access hosts, and +without POST replay. Cancel drops one in-memory identity. 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-http.md b/docs/research/interpretation-run-cancel-http.md new file mode 100644 index 000000000..165feae8d --- /dev/null +++ b/docs/research/interpretation-run-cancel-http.md @@ -0,0 +1,64 @@ +# Interpretation-run cancel HTTP (doctoring) + +## Scope + +`POST /v1/interpretation-runs/{idempotency_key}/cancel` is the +operator-visible drop of one accepted hypothetical interpretation-run +identity on `OrchestratorLiveService` / `tepp-orchestrator-loopback`. HTTP +method, path, and header semantics follow current HTTP semantics (Fielding, +Nottingham, & Reschke, 2022). Fail-closed refusal of unpublished consumers, +nonempty POST bodies, present `idempotency-key`, extra extra-segments, +pagination headers, review/Copilot/GitHub credential flags, and +scientific-authority promotion is repository contract authority (ADR 0073; +ADR 0071; ADR 0010; ADR 0011; ADR 0014), not an RFC inference rule. + +Cancel JSON is metric-free. `claim_status` remains `hypothetical`. +`scientific_authority` remains false. `cancelled` is `true`. +`tepp.scientific_acceptance.v1` never appears. HTTP 200 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/0073-interpretation-run-cancel-http.md` — this cancel +- `docs/adr/0071-interpretation-run-retrieval-get.md` — GET-by-id +- `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` — HTTP + 200 is not a scientific claim +- `crates/orchestrator_live/tests/interpretation_run_cancel_http_contract.rs` + — fail-closed cancel proofs +- `crates/orchestrator_live/tests/live_http_contract.rs` — loopback cancel + proofs + +## Verification + +- `POST /v1/interpretation-runs/{idempotency_key}/cancel` of an accepted + contextual-orchestrator run 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`; +- subsequent GET-by-id, a second cancel, naruon or LineageWeave, nonempty + body, present `idempotency-key`, pagination headers, slash/NUL keys fail + closed. + +## Non-claims + +This slice does not implement a cancel CLI, analysis-run cancel, export GET, +project-history GET-by-id, persistence, production TLS, Leiden consensus, +GAP-010 Figma/export, provider execution, causal inference, or an ADR 0014 +scientific claim-promotion package.