From e38a1c1d0bd91a97c1b6427ad2617d1837b36360 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 10:22:33 +0000 Subject: [PATCH] feat(api): cancel authorized exports on loopback HTTP POST /v1/exports/{export_id}/cancel removes one metric-free naruon identity from AnalysisRunLiveService. Receipts stay cancelled=true. LineageWeave is refused. NaruonLiveService stays POST-only. ADR 0077. --- CHANGELOG.d/export-cancel-http.md | 1 + DOCUMENTATION.md | 1 + crates/tepp_api/src/analysis_run_live.rs | 130 +++++++ crates/tepp_api/src/export_cancel_http.rs | 333 ++++++++++++++++++ crates/tepp_api/src/lib.rs | 9 + .../tests/export_cancel_http_contract.rs | 58 +++ docs/API_CONTRACT.md | 2 +- docs/TRACEABILITY.md | 2 +- docs/adr/0077-export-cancel-http.md | 97 +++++ docs/adr/README.md | 1 + docs/connectors/naruon-artifact-consumer.md | 1 + docs/research/export-cancel-http.md | 55 +++ 12 files changed, 688 insertions(+), 2 deletions(-) create mode 100644 CHANGELOG.d/export-cancel-http.md create mode 100644 crates/tepp_api/src/export_cancel_http.rs create mode 100644 crates/tepp_api/tests/export_cancel_http_contract.rs create mode 100644 docs/adr/0077-export-cancel-http.md create mode 100644 docs/research/export-cancel-http.md diff --git a/CHANGELOG.d/export-cancel-http.md b/CHANGELOG.d/export-cancel-http.md new file mode 100644 index 000000000..77ef151e0 --- /dev/null +++ b/CHANGELOG.d/export-cancel-http.md @@ -0,0 +1 @@ +- `POST /v1/exports/{export_id}/cancel` on `AnalysisRunLiveService` / `tepp-loopback` removes one authorized naruon export identity (ADR 0077). Metric-free `cancelled=true` receipts only. `tepp.scientific_acceptance.v1` never appears. Does not infer causality. LineageWeave refused. `NaruonLiveService` stays POST-only. Not analysis-run cancel, not interpretation-run cancel, not GAP-010 Figma/export, not persistence. diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 9eb2aa102..0f1e1e03c 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -14,6 +14,7 @@ TEPP's approved PRD v0.4 and implementation plan are the primary product baselin | contextual-orchestrator interpretation port | [`docs/connectors/contextual-orchestrator-interpretation-port.md`](docs/connectors/contextual-orchestrator-interpretation-port.md) | | Orchestrator live HTTP doctoring | [`docs/research/orchestrator-live-http.md`](docs/research/orchestrator-live-http.md) | | Export collection GET doctoring | [`docs/research/export-collection-http.md`](docs/research/export-collection-http.md) | +| Export cancel HTTP doctoring | [`docs/research/export-cancel-http.md`](docs/research/export-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/tepp_api/src/analysis_run_live.rs b/crates/tepp_api/src/analysis_run_live.rs index bb3ce1c82..cc02d6eac 100644 --- a/crates/tepp_api/src/analysis_run_live.rs +++ b/crates/tepp_api/src/analysis_run_live.rs @@ -13,6 +13,7 @@ use std::collections::HashMap; use std::io::Write; use std::net::{SocketAddr, TcpListener}; +use crate::export_cancel_http::{export_cancel_path_id, ExportCancelled}; use crate::export_collection_http::{ is_export_collection_path, page_export_collection_items, parse_export_collection_page_cursor, parse_export_collection_page_limit, ExportCollection, @@ -184,6 +185,12 @@ impl AnalysisRunLiveService { if path == NARUON_EXPORT_PATH { return self.accept_export(&headers, body); } + if matches!( + export_cancel_path_id(path), + Ok(_) | Err(ApiError::LimitExceeded) + ) { + return self.cancel_export(path, &headers, body); + } if path != NARUON_ANALYSIS_RUN_PATH && path != TEMPORAL_CONTEXT_PATH && path != PROJECT_HISTORY_PATH @@ -381,6 +388,38 @@ impl AnalysisRunLiveService { Ok(json_response(200, "OK", collection.to_json()?)) } + fn cancel_export( + &mut self, + path: &str, + headers: &HashMap, + body: &str, + ) -> Result { + let export_id = export_cancel_path_id(path)?; + if !body.trim().is_empty() { + return Err(ApiError::InvalidWirePayload); + } + refuse_metrics_on_export_retrieval_payload(body)?; + let consumer = require_headers(headers, self.bound_addr, false)?; + if consumer != NARUON_CONSUMER_CODE { + return Err(ApiError::InvalidWirePayload); + } + if headers.contains_key("idempotency-key") { + return Err(ApiError::InvalidWirePayload); + } + let replay_key = self + .exports_by_id + .remove(&export_id) + .ok_or(ApiError::InvalidWirePayload)?; + let stored = self + .authorized_exports + .remove(&replay_key) + .ok_or(ApiError::InvalidWirePayload)?; + let cancelled = ExportCancelled::from_retrieval(stored.retrieval)?; + let response_body = cancelled.to_json()?; + refuse_metrics_on_export_retrieval_payload(&response_body)?; + Ok(json_response(200, "OK", response_body)) + } + fn response_from_error(&mut self, error: ApiError) -> NaruonLiveResponse { let request_id = format!("analysis-run-live-{}", self.next_request_serial); self.next_request_serial += 1; @@ -1258,6 +1297,91 @@ mod tests { ); } + #[test] + fn handler_covers_metric_free_export_cancel() { + use crate::{ + AnalyticalPurpose, ExportAuthorizationRequest, ExportCancelled, ExportCollection, + ExportRetrieval, + }; + + let request = ExportAuthorizationRequest { + tenant_workspace_id: "export-cancel-tenant".into(), + principal_id: "principal-analyst-1".into(), + purpose: AnalyticalPurpose::ModularServiceConsumer, + artifact_id: "artifact-cancel-1".into(), + includes_source_text: false, + }; + let body = crate::wire::to_json(&request).expect("export json"); + let mut service = AnalysisRunLiveService::new(); + let posted = service.handle_http_request(&export_post_http( + &body, + NARUON_CONSUMER_CODE, + "export-cancel-idem-1", + )); + assert_eq!(posted.status_code, 200); + let retrieval = ExportRetrieval::from_json(&posted.body).expect("posted retrieval"); + let cancelled = service.handle_http_request(&export_cancel_http( + &retrieval.export_id, + NARUON_CONSUMER_CODE, + )); + assert_eq!(cancelled.status_code, 200, "{}", cancelled.body); + let parsed = ExportCancelled::from_json(&cancelled.body).expect("cancelled"); + assert_eq!(parsed.export_id, retrieval.export_id); + assert_eq!(parsed.artifact_id, "artifact-cancel-1"); + assert!(parsed.cancelled); + assert!(!cancelled.body.contains("tenant_workspace_id")); + assert!(!cancelled.body.contains("rmse")); + assert!(!cancelled.body.contains("scientific_acceptance")); + assert_eq!( + service + .handle_http_request(&export_get_http(&retrieval.export_id, NARUON_CONSUMER_CODE)) + .status_code, + 400 + ); + let listed = service.handle_http_request(&format!( + "GET {NARUON_EXPORT_PATH} HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: naruon\r\ntepp-contract-version: 1\r\ncontent-length: 0\r\n\r\n" + )); + assert_eq!(listed.status_code, 200); + let page: ExportCollection = serde_json::from_str(&listed.body).expect("page"); + assert!(page.items.is_empty()); + assert_eq!( + service + .handle_http_request(&export_cancel_http( + &retrieval.export_id, + NARUON_CONSUMER_CODE + )) + .status_code, + 400 + ); + assert_eq!( + service + .handle_http_request(&export_cancel_http( + &retrieval.export_id, + LINEAGEWEAVE_CONSUMER_CODE + )) + .status_code, + 400 + ); + assert_eq!( + service + .handle_http_request(&format!( + "POST {NARUON_EXPORT_PATH}/{}/cancel HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: naruon\r\ntepp-contract-version: 1\r\nidempotency-key: export-cancel-idem-1\r\ncontent-length: 0\r\n\r\n", + retrieval.export_id + )) + .status_code, + 400 + ); + assert_eq!( + service + .handle_http_request(&format!( + "POST {NARUON_EXPORT_PATH}/{}/cancel HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: naruon\r\ntepp-contract-version: 1\r\ncontent-length: 2\r\n\r\n{{}}", + retrieval.export_id + )) + .status_code, + 400 + ); + } + fn export_post_http(body: &str, consumer: &str, idempotency_key: &str) -> String { format!( "POST {NARUON_EXPORT_PATH} HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: {consumer}\r\ntepp-contract-version: 1\r\nidempotency-key: {idempotency_key}\r\ncontent-length: {}\r\n\r\n{body}", @@ -1271,6 +1395,12 @@ mod tests { ) } + fn export_cancel_http(export_id: &str, consumer: &str) -> String { + format!( + "POST {NARUON_EXPORT_PATH}/{export_id}/cancel HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: {consumer}\r\ntepp-contract-version: 1\r\ncontent-length: 0\r\n\r\n" + ) + } + struct ScriptedRead { reader: Cursor>, first_error: Option, diff --git a/crates/tepp_api/src/export_cancel_http.rs b/crates/tepp_api/src/export_cancel_http.rs new file mode 100644 index 000000000..4222c32f2 --- /dev/null +++ b/crates/tepp_api/src/export_cancel_http.rs @@ -0,0 +1,333 @@ +//! Provider-owned export cancel HTTP contracts. +//! +//! GAP-003A unique slice: `POST /v1/exports/{export_id}/cancel` removes one +//! authorized metric-free identity from `AnalysisRunLiveService` / +//! `tepp-loopback`. The receipt stays metric-free with `cancelled=true`. +//! `tepp.scientific_acceptance.v1` never appears. Cancel does not infer +//! causality. This module does not duplicate analysis-run cancel (#361), +//! interpretation-run cancel HTTP (#440), interpretation-run cancel CLI +//! (#442), export collection GET (#443), export collection CLI (#444), +//! export-retrieval CLI (#417), export retrieval GET (#411), export-authorize +//! CLI (#410), Leiden, or GAP-010 Figma/export. Persistence remains GAP-003B. +//! `LineageWeave` is refused. `NaruonLiveService` stays POST-only. + +use crate::naruon_http::{compose_https_target, NaruonHttpExchange, NARUON_EXPORT_PATH}; +use crate::wire::{require_byte_limit, require_nonempty, to_json}; +use crate::{ + refuse_metrics_on_export_retrieval_payload, ApiError, ExportRetrieval, + DEFAULT_ANALYSIS_RUN_BYTE_LIMIT, EXPORT_RETRIEVAL_ID_MAX_LEN, +}; +use serde::{Deserialize, Serialize}; + +/// Maximum opaque export identity length on the cancel path. +pub const EXPORT_CANCEL_ID_MAX_LEN: usize = EXPORT_RETRIEVAL_ID_MAX_LEN; + +/// Metric-free cancelled export identity. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ExportCancelled { + /// Opaque server-assigned export identity. + pub export_id: String, + /// Opaque artifact identity that was authorized. + pub artifact_id: String, + /// Stable machine-readable authorization decision code. + pub decision_code: String, + /// Declared analytical purpose as a wire name. + pub purpose: String, + /// Exact per-export idempotency key that minted this identity. + pub idempotency_key: String, + /// Always `true` on a successful cancel receipt. + pub cancelled: bool, +} + +impl ExportCancelled { + /// Construct a validated cancelled identity from a stored retrieval. + /// + /// # Errors + /// + /// Returns a fail-closed error when the retrieval is invalid or + /// `cancelled` would not be true. + pub fn from_retrieval(retrieval: ExportRetrieval) -> Result { + let cancelled = Self { + export_id: retrieval.export_id, + artifact_id: retrieval.artifact_id, + decision_code: retrieval.decision_code, + purpose: retrieval.purpose, + idempotency_key: retrieval.idempotency_key, + cancelled: true, + }; + cancelled.validate()?; + Ok(cancelled) + } + + /// Parse and validate a cancelled identity with the default byte limit. + /// + /// # Errors + /// + /// Returns wire, limit, metric-key, or field-validation errors. + pub fn from_json(payload: &str) -> Result { + require_byte_limit(payload, DEFAULT_ANALYSIS_RUN_BYTE_LIMIT)?; + refuse_metrics_on_export_retrieval_payload(payload)?; + let cancelled: Self = crate::wire::from_json(payload)?; + cancelled.validate()?; + Ok(cancelled) + } + + /// 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)?; + require_byte_limit(&payload, DEFAULT_ANALYSIS_RUN_BYTE_LIMIT)?; + refuse_metrics_on_export_retrieval_payload(&payload)?; + Ok(payload) + } + + fn validate(&self) -> Result<(), ApiError> { + require_nonempty(&self.export_id)?; + require_nonempty(&self.artifact_id)?; + require_nonempty(&self.decision_code)?; + require_nonempty(&self.purpose)?; + require_nonempty(&self.idempotency_key)?; + if self.export_id.len() > EXPORT_CANCEL_ID_MAX_LEN + || self.artifact_id.len() > EXPORT_CANCEL_ID_MAX_LEN + || self.idempotency_key.len() > EXPORT_CANCEL_ID_MAX_LEN + { + return Err(ApiError::LimitExceeded); + } + if self.export_id.contains('/') + || self.export_id.contains('\0') + || self.idempotency_key.contains('/') + || self.idempotency_key.contains('\0') + { + return Err(ApiError::InvalidWirePayload); + } + if !self.cancelled { + return Err(ApiError::InvalidWirePayload); + } + Ok(()) + } +} + +/// Extract the opaque export identity from `POST /v1/exports/{export_id}/cancel`. +/// +/// # Errors +/// +/// Returns [`ApiError::InvalidWirePayload`] for the collection path, GET-by-id +/// path, extra segments, a hostile encoding, empty identity, slash, or NUL, +/// and [`ApiError::LimitExceeded`] when oversized. +pub fn export_cancel_path_id(path: &str) -> Result { + let remainder = path + .strip_prefix(NARUON_EXPORT_PATH) + .ok_or(ApiError::InvalidWirePayload)?; + let encoded = remainder + .strip_prefix('/') + .ok_or(ApiError::InvalidWirePayload)?; + let encoded = encoded + .strip_suffix("/cancel") + .ok_or(ApiError::InvalidWirePayload)?; + if encoded.is_empty() || encoded.contains('/') { + return Err(ApiError::InvalidWirePayload); + } + let export_id = decode_path_segment(encoded)?; + require_nonempty(&export_id)?; + if export_id.contains('/') || export_id.contains('\0') { + return Err(ApiError::InvalidWirePayload); + } + if export_id.len() > EXPORT_CANCEL_ID_MAX_LEN { + return Err(ApiError::LimitExceeded); + } + Ok(export_id) +} + +/// Build a credential-free naruon cancel POST exchange. +/// +/// Empty body is admitted. The identity travels in the path; the builder does +/// not send an `idempotency-key` header. +/// +/// # Errors +/// +/// Returns [`ApiError::InvalidWirePayload`] for a non-`https` origin or empty +/// identity, and [`ApiError::LimitExceeded`] when oversized. +pub fn naruon_export_cancel_exchange( + origin: &str, + export_id: &str, +) -> Result { + require_nonempty(export_id)?; + if export_id.contains('/') || export_id.contains('\0') { + return Err(ApiError::InvalidWirePayload); + } + if export_id.len() > EXPORT_CANCEL_ID_MAX_LEN { + return Err(ApiError::LimitExceeded); + } + let encoded_id = encode_path_segment(export_id); + let target_path = format!("{NARUON_EXPORT_PATH}/{encoded_id}/cancel"); + let target_url = compose_https_target(origin, &target_path)?; + Ok(NaruonHttpExchange { + method: "POST", + target_url, + headers: vec![ + ("content-type".into(), "application/json".into()), + ("tepp-consumer".into(), "naruon".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() + value.len() / 2); + let hex = b"0123456789ABCDEF"; + 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); + } + _ => { + 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(ApiError::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(ApiError::InvalidWirePayload), + } + } + let decoded = String::from_utf8(out).map_err(|_| ApiError::InvalidWirePayload)?; + if decoded.contains('/') || decoded.contains('\0') { + return Err(ApiError::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(ApiError::InvalidWirePayload), + } +} + +#[cfg(test)] +mod tests { + use super::{ + export_cancel_path_id, naruon_export_cancel_exchange, ExportCancelled, EXPORT_CANCEL_ID_MAX_LEN, + }; + use crate::export_http::EXPORT_RETRIEVAL_ALLOWED_DECISION_CODE; + use crate::naruon_http::NARUON_EXPORT_PATH; + use crate::{ + refuse_metrics_on_export_retrieval_payload, ApiError, ExportRetrieval, + }; + + #[test] + fn cancel_exchange_is_metric_free_post_without_credentials() { + let exchange = + naruon_export_cancel_exchange("https://tepp.example.test", "export-1").expect("exchange"); + assert_eq!(exchange.method, "POST"); + assert!(exchange + .target_url + .ends_with("/v1/exports/export-1/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!( + export_cancel_path_id("/v1/exports/export-1/cancel").expect("id"), + "export-1" + ); + assert!(!is_collection_or_get_by_id("/v1/exports/export-1/cancel")); + let retrieval = ExportRetrieval::new( + "export-1", + "artifact-1", + EXPORT_RETRIEVAL_ALLOWED_DECISION_CODE, + "modular_service_consumer", + "export-idem-1", + ) + .expect("retrieval"); + let json = ExportCancelled::from_retrieval(retrieval) + .expect("cancelled") + .to_json() + .expect("json"); + assert!(json.contains("\"cancelled\":true")); + assert!(!json.contains("tenant_workspace_id")); + assert!(!json.contains("rmse")); + assert_eq!(refuse_metrics_on_export_retrieval_payload(&json), Ok(())); + assert_eq!(NARUON_EXPORT_PATH, "/v1/exports"); + } + + #[test] + fn cancel_path_and_payloads_fail_closed() { + assert_eq!( + export_cancel_path_id("/v1/exports"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + export_cancel_path_id("/v1/exports/export-1"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + export_cancel_path_id("/v1/exports/export-1/extra/cancel"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + export_cancel_path_id("/v1/analysis-runs/export-1/cancel"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + export_cancel_path_id(&format!( + "/v1/exports/{}/cancel", + "e".repeat(EXPORT_CANCEL_ID_MAX_LEN + 1) + )), + Err(ApiError::LimitExceeded) + ); + assert_eq!( + naruon_export_cancel_exchange("http://tepp.example.test", "export-1"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + naruon_export_cancel_exchange("https://tepp.example.test", "a/b"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + naruon_export_cancel_exchange("https://tepp.example.test", "a\0b"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!(refuse_metrics_on_export_retrieval_payload(""), Ok(())); + assert_eq!( + refuse_metrics_on_export_retrieval_payload(r#"{"rmse":1.0}"#), + Err(ApiError::InvalidWirePayload) + ); + } + + fn is_collection_or_get_by_id(path: &str) -> bool { + path == NARUON_EXPORT_PATH || !path.ends_with("/cancel") + } +} diff --git a/crates/tepp_api/src/lib.rs b/crates/tepp_api/src/lib.rs index da946cef1..b3459f3fa 100644 --- a/crates/tepp_api/src/lib.rs +++ b/crates/tepp_api/src/lib.rs @@ -20,6 +20,7 @@ mod corpus_split_manifest; mod envelope; mod error; mod export; +mod export_cancel_http; mod export_collection_http; mod export_http; mod lineage_criterion_anchor; @@ -123,6 +124,14 @@ pub use export_collection_http::EXPORT_COLLECTION_CURSOR_MAX_LEN; pub use export_collection_http::EXPORT_COLLECTION_DEFAULT_LIMIT; /// Maximum page size for export collection GET. pub use export_collection_http::EXPORT_COLLECTION_MAX_LIMIT; +/// Metric-free cancelled export identity. +pub use export_cancel_http::ExportCancelled; +/// Maximum opaque export identity length on the cancel path. +pub use export_cancel_http::EXPORT_CANCEL_ID_MAX_LEN; +/// Extract the opaque export identity from `POST /v1/exports/{export_id}/cancel`. +pub use export_cancel_http::export_cancel_path_id; +/// Build a credential-free naruon cancel POST exchange. +pub use export_cancel_http::naruon_export_cancel_exchange; /// Refuse scientific-metric keys on export-retrieval JSON. pub use export_http::refuse_metrics_on_export_retrieval_payload; diff --git a/crates/tepp_api/tests/export_cancel_http_contract.rs b/crates/tepp_api/tests/export_cancel_http_contract.rs new file mode 100644 index 000000000..440841d1b --- /dev/null +++ b/crates/tepp_api/tests/export_cancel_http_contract.rs @@ -0,0 +1,58 @@ +//! Contract tests for loopback `POST /v1/exports/{export_id}/cancel`. + +use tepp_api::{ + naruon_export_cancel_exchange, AnalysisRunLiveService, AnalyticalPurpose, ApiError, + ExportAuthorizationRequest, ExportCancelled, ExportCollection, NARUON_CONSUMER_CODE, + NARUON_EXPORT_PATH, NaruonLiveService, +}; + +fn authorize_body() -> String { + let request = ExportAuthorizationRequest { + tenant_workspace_id: "export-cancel-contract-tenant".into(), + principal_id: "principal-analyst-1".into(), + purpose: AnalyticalPurpose::ModularServiceConsumer, + artifact_id: "artifact-cancel-contract-1".into(), + includes_source_text: false, + }; + serde_json::to_string(&request).expect("json") +} + +fn authorize_http(idem: &str) -> String { + let body = authorize_body(); + format!( + "POST {NARUON_EXPORT_PATH} HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: {NARUON_CONSUMER_CODE}\r\ntepp-contract-version: 1\r\nidempotency-key: {idem}\r\ncontent-length: {}\r\n\r\n{body}", + body.len() + ) +} + +#[test] +fn live_service_cancels_and_naruon_live_stays_post_only() { + let mut service = AnalysisRunLiveService::new(); + let posted = service.handle_http_request(&authorize_http("export-cancel-contract-1")); + assert_eq!(posted.status_code, 200, "{}", posted.body); + let retrieval: serde_json::Value = serde_json::from_str(&posted.body).expect("posted"); + let export_id = retrieval["export_id"].as_str().expect("id"); + let cancel = format!( + "POST {NARUON_EXPORT_PATH}/{export_id}/cancel HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: {NARUON_CONSUMER_CODE}\r\ntepp-contract-version: 1\r\ncontent-length: 0\r\n\r\n" + ); + let cancelled = service.handle_http_request(&cancel); + assert_eq!(cancelled.status_code, 200, "{}", cancelled.body); + let parsed = ExportCancelled::from_json(&cancelled.body).expect("cancelled"); + assert!(parsed.cancelled); + assert_eq!(parsed.export_id, export_id); + let listed = service.handle_http_request(&format!( + "GET {NARUON_EXPORT_PATH} HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: {NARUON_CONSUMER_CODE}\r\ntepp-contract-version: 1\r\ncontent-length: 0\r\n\r\n" + )); + let page: ExportCollection = serde_json::from_str(&listed.body).expect("page"); + assert!(page.items.is_empty()); + let mut naruon = NaruonLiveService::new(); + assert_ne!(naruon.handle_http_request(&cancel).status_code, 200); +} + +#[test] +fn cancel_exchange_refuses_http_origin() { + assert_eq!( + naruon_export_cancel_exchange("http://tepp.example.test", "export-1"), + Err(ApiError::InvalidWirePayload) + ); +} diff --git a/docs/API_CONTRACT.md b/docs/API_CONTRACT.md index 04c0bbb69..a61c96986 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. Loopback `GET /v1/exports/{export_id}` on `AnalysisRunLiveService` is the executable export-retrieval route (ADR 0054); `GET /v1/exports` enumerates those identities (ADR 0075); `NaruonLiveService` stays POST-only. +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. Loopback `GET /v1/exports/{export_id}` on `AnalysisRunLiveService` is the executable export-retrieval route (ADR 0054); `GET /v1/exports` enumerates those identities (ADR 0075); `POST /v1/exports/{export_id}/cancel` removes one identity (ADR 0077); `NaruonLiveService` stays POST-only. ## 2. Contract families diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 2bef6941c..fc8c06c6c 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -52,7 +52,7 @@ The full APA 7th standards/literature register remains `docs/research/standards- | recovery metrics (RMSE, bias, coverage, graph, temporal order, Monte Carlo SE gates) | PRD; Test Strategy; ADR 0007/0014 | `validation_core` on protected main (PR #19); SE-aware Monte Carlo gates included | implemented-main | | PostgreSQL bitemporal/lineage persistence | ADR 0013; Architecture/ERD | `persistence_postgres` migration contracts, in-memory adapters, live SQL session/document SQL port, tenant RLS (`0002` + session GUC/role helpers), `DATABASE_URL` SQLx gate, optional session-affine `live-sqlx` `PgPool` driver, exact-head live PostgreSQL CI with isolation proof, append-only immutability triggers (`0004`), temporal interval ordering CHECKs (`0005`), typed membership assignment (`0006` implemented-main), event-relation/mention/instance SQL (#37–#39 implemented-main), source-artifact SQL (#40 implemented-main), audit-event SQL (#41 implemented-main), concurrent document-write stress (#43 implemented-main), backup/restore integrity revalidation (#44 implemented-main), `revision_order` later-revision system-time ordering implemented-main, entity/project target SQL on PR #131; remaining physical ERD constraints | partial | | known-truth temporal/event simulation manifests | PRD; TRD; Test Strategy | `tepp_simulation` on protected main; recovery metrics in `validation_core` | implemented-main | -| versioned service/API contracts and exports | PRD; API contract; ADR 0011/0013/0054/0075 | `tepp_api` analysis-run/export/JSON-LD/GraphML contracts on protected main (PR #21); loopback `GET /v1/exports/{export_id}` is the executable retrieval route; loopback `GET /v1/exports` enumerates authorized identities on this PR; request-bound terminal result active in PR #157; production TLS remaining | partial | +| versioned service/API contracts and exports | PRD; API contract; ADR 0011/0013/0054/0075/0077 | `tepp_api` analysis-run/export/JSON-LD/GraphML contracts on protected main (PR #21); loopback `GET /v1/exports/{export_id}` is the executable retrieval route; loopback `GET /v1/exports` enumerates authorized identities; `POST /v1/exports/{export_id}/cancel` removes one identity on this PR; request-bound terminal result active in PR #157; production TLS remaining | partial | | 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/0077-export-cancel-http.md b/docs/adr/0077-export-cancel-http.md new file mode 100644 index 000000000..3dda73e5a --- /dev/null +++ b/docs/adr/0077-export-cancel-http.md @@ -0,0 +1,97 @@ +# ADR 0077 — Loopback export cancel HTTP + +**Decision status:** Accepted +**Implementation maturity:** active-PR +**Date:** 2026-09-01 +**Supersedes:** None; complements ADR 0075/0054 for operator-visible cancel. +Does not supersede ADR 0014 claim-promotion authority. This ADR number is +unique versus protected main; live vs-main and sibling GAP-003A PRs already +occupy 0026–0076. + +## Context + +ADR 0054 and ADR 0075 mint and enumerate authorized export identities on +`AnalysisRunLiveService`. Operators had no loopback POST that removes one +identity without guessing a second collection GET. Duplicating analysis-run +cancel (#361), interpretation-run cancel HTTP (#440), interpretation-run +cancel CLI (#442), export collection GET (#443), export collection CLI +(#444), export-retrieval CLI (#417), export retrieval GET (#411), +export-authorize CLI (#410), Leiden, Driver p.16, or GAP-010 Figma/export +would collide with live PRs. LineageWeave is refused on this naruon-owned +adapter; `NaruonLiveService` stays POST-only. + +## Decision + +Publish `POST /v1/exports/{export_id}/cancel` on `AnalysisRunLiveService` / +`tepp-loopback`: + +- Empty body is admitted. Nonempty leftover body fails closed. +- Public bind, unpublished consumer, present `idempotency-key`, extra path + segments, slash/NUL identities, and credential headers fail closed. +- Receipt is metric-free with `cancelled=true`. Tenant, principal, source + text, RMSE, bias, coverage, SE-gate, and `tepp.scientific_acceptance.v1` + never appear. +- Cancelled identities drop from collection GET and GET-by-id. + +## Alternatives considered + +1. **Reuse analysis-run cancel (#361)** — rejected; different resource. +2. **Reuse interpretation-run cancel (#440)** — rejected; orchestrator-owned. +3. **Add cancel to `NaruonLiveService`** — rejected; POST-only for authorize. +4. **Published loopback cancel POST** — accepted. + +## Consequences + +- Operators can retract an authorized export identity without persistence. +- 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 + +Missing identity, second cancel, LineageWeave, nonempty body, present +`idempotency-key`, extra segments, public bind, and metric keys fail closed. + +## Security, privacy, scientific-integrity, and governance impact + +- No credential headers cross the consumer boundary. +- Tenant, principal, and source text stay off the cancel receipt. +- HTTP 200 on cancel is not measurement evidence and is not a causal claim. + +## Compatibility and migration + +GET-by-id, collection GET, POST `/v1/exports`, and `NaruonLiveService` +POST-only remain unchanged. Persistence remains GAP-003B. + +## Verification + +Falsifiable evidence: + +- cancel of an authorized export returns metric-free `cancelled=true` + without RMSE/bias/coverage/SE-gate/tenant/principal/source-text/ + `tepp.scientific_acceptance.v1`; +- subsequent GET-by-id and collection GET omit the identity; +- LineageWeave, nonempty body, present `idempotency-key`, extra segments, + public bind, and unknown keys fail closed; +- `NaruonLiveService` still refuses GET and refuses this cancel path as a + 200; +- Clippy `-D warnings`, `tepp_api` tests, rustdoc, and exact-head review + remain required. + +## Rollback and supersession + +Rollback removes the cancel route; collection and retrieval remain valid. A +superseding ADR is required to persist cancel, bind a public address, emit +scientific-acceptance on cancel, open LineageWeave, add GET to +`NaruonLiveService`, or treat cancel success as an ADR 0014 claim. + +## Related authority + +- ADR 0075 owns loopback export collection GET. +- ADR 0076 owns the export collection CLI (live #444). +- ADR 0054 owns loopback export retrieval GET. +- ADR 0029 owns analysis-run cancel HTTP (live #361). +- ADR 0073 owns interpretation-run cancel HTTP (live #440). +- 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 9153090dc..2e6a6dbf8 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -32,6 +32,7 @@ Read [`ADR_POLICY.md`](ADR_POLICY.md) first. **Decision status and implementatio | [0025](0025-macos-native-rust-mlx-metal-boundary.md) | macOS-native Rust-owned MLX Metal execution | Accepted | accepted-target | Compose authenticates to a native host service; Linux never claims Metal, and actual backend/parity receipts fail closed. | | [0054](0054-export-retrieval-get.md) | Loopback export retrieval GET | Accepted | active-PR | `AnalysisRunLiveService` mints a metric-free `export_id` on naruon `POST /v1/exports` and serves `GET /v1/exports/{export_id}`; `NaruonLiveService` stays POST-only. | | [0075](0075-export-collection-get.md) | Loopback export collection GET | Accepted | active-PR | Complements ADR 0054; `GET /v1/exports` enumerates metric-free authorized identities. Unique versus protected main (0026–0074 occupied). `NaruonLiveService` stays POST-only. | +| [0077](0077-export-cancel-http.md) | Loopback export cancel HTTP | Accepted | active-PR | Complements ADR 0075/0054; `POST /v1/exports/{export_id}/cancel` removes one metric-free identity. Unique versus protected main (0026–0076 occupied). `NaruonLiveService` stays POST-only. | | [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/naruon-artifact-consumer.md b/docs/connectors/naruon-artifact-consumer.md index 90bea97d9..5665eb86f 100644 --- a/docs/connectors/naruon-artifact-consumer.md +++ b/docs/connectors/naruon-artifact-consumer.md @@ -30,6 +30,7 @@ TEPP remains the scientific authority for estimation, recovery metrics, temporal | Live loopback POST | `tepp_api` `NaruonLiveService` → `POST /v1/analysis-runs` and `/v1/exports` | naruon → TEPP | | Live loopback export retrieval | `tepp_api` `AnalysisRunLiveService` → `POST /v1/exports` then `GET /v1/exports/{export_id}` | naruon → TEPP | | Live loopback export collection | `tepp_api` `AnalysisRunLiveService` → `GET /v1/exports` | naruon → TEPP | +| Live loopback export cancel | `tepp_api` `AnalysisRunLiveService` → `POST /v1/exports/{export_id}/cancel` | naruon → TEPP | Committed examples live under `examples/`. Schemas for analysis-run requests and corpus-split manifests live under `schemas/`. diff --git a/docs/research/export-cancel-http.md b/docs/research/export-cancel-http.md new file mode 100644 index 000000000..3afc389cc --- /dev/null +++ b/docs/research/export-cancel-http.md @@ -0,0 +1,55 @@ +# Export cancel HTTP (doctoring) + +## Scope + +`POST /v1/exports/{export_id}/cancel` on `AnalysisRunLiveService` / +`tepp-loopback` removes one authorized naruon export identity. HTTP method, +path, and header semantics follow current HTTP semantics (Fielding, +Nottingham, & Reschke, 2022). Fail-closed refusal of unpublished consumers, +nonempty leftover body, present `idempotency-key`, extra path segments, +slash/NUL identities, credential headers, public bind, and +scientific-authority promotion is repository contract authority (ADR 0077; +ADR 0075; ADR 0014), not an RFC inference rule. + +The receipt is metric-free with `cancelled=true`. Tenant, principal, source +text, and `tepp.scientific_acceptance.v1` never appear. 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 according to the +resource's own semantics. TEPP maps that processing onto in-memory removal of +one metric-free export identity. The RFC does not define psychometric +acceptance, RMSE, causality, or claim promotion. + +### Internal contract evidence + +- `docs/adr/0077-export-cancel-http.md` — this cancel route +- `docs/adr/0075-export-collection-get.md` — collection GET +- `docs/adr/0014-scientific-claim-promotion-and-release-evidence.md` — HTTP + 200 is not a scientific claim +- `crates/tepp_api/tests/export_cancel_http_contract.rs` — fail-closed proofs + +## Verification + +- cancel of an authorized naruon export returns metric-free `cancelled=true` + without RMSE/bias/coverage/SE-gate keys, tenant, principal, source text, or + `tepp.scientific_acceptance.v1`; +- subsequent GET-by-id and collection GET omit the identity; +- LineageWeave, nonempty leftover body, present `idempotency-key`, extra path + segments, slash/NUL identities, and `http` origin fail closed; +- `NaruonLiveService` still refuses GET and does not serve this cancel path + as a 200. + +## Non-claims + +This slice does not implement GAP-010 Figma/export, analysis-run cancel, +interpretation-run cancel, persistence, production TLS, Leiden consensus, +provider execution, causal inference, or an ADR 0014 scientific +claim-promotion package.