From 325590cc1664f89bf6de5f43a1b8092560dac6bc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 10:46:37 +0000 Subject: [PATCH] feat(api): cancel accepted project-histories on loopback HTTP POST /v1/project-histories/{idempotency_key}/cancel removes one metric-free LineageWeave identity from AnalysisRunLiveService. Receipts stay cancelled=true with temporal_association_only. Naruon is refused. NaruonLiveService stays POST-only. ADR 0079. --- CHANGELOG.d/project-history-cancel-http.md | 1 + DOCUMENTATION.md | 1 + crates/tepp_api/src/analysis_run_live.rs | 101 +++++- crates/tepp_api/src/lib.rs | 9 + .../src/project_history_cancel_http.rs | 337 ++++++++++++++++++ .../project_history_cancel_http_contract.rs | 74 ++++ docs/API_CONTRACT.md | 5 + docs/TRACEABILITY.md | 1 + docs/adr/0079-project-history-cancel-http.md | 100 ++++++ docs/adr/README.md | 1 + docs/research/project-history-cancel-http.md | 58 +++ 11 files changed, 683 insertions(+), 5 deletions(-) create mode 100644 CHANGELOG.d/project-history-cancel-http.md create mode 100644 crates/tepp_api/src/project_history_cancel_http.rs create mode 100644 crates/tepp_api/tests/project_history_cancel_http_contract.rs create mode 100644 docs/adr/0079-project-history-cancel-http.md create mode 100644 docs/research/project-history-cancel-http.md diff --git a/CHANGELOG.d/project-history-cancel-http.md b/CHANGELOG.d/project-history-cancel-http.md new file mode 100644 index 000000000..70da39d92 --- /dev/null +++ b/CHANGELOG.d/project-history-cancel-http.md @@ -0,0 +1 @@ +- `POST /v1/project-histories/{idempotency_key}/cancel` on `AnalysisRunLiveService` / `tepp-loopback` removes one accepted LineageWeave project-history identity (ADR 0079). Metric-free `cancelled=true` receipts with `inference_status=temporal_association_only`. `tepp.scientific_acceptance.v1` never appears. Does not infer causality. Naruon refused. `NaruonLiveService` stays POST-only. Not export cancel, not interpretation-run cancel, not GAP-010 Figma/export, not persistence. diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index eb183395e..4e6c1e514 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -13,6 +13,7 @@ TEPP's approved PRD v0.4 and implementation plan are the primary product baselin | naruon modular consumer contract | [`docs/connectors/naruon-artifact-consumer.md`](docs/connectors/naruon-artifact-consumer.md) | | Project-history collection GET doctoring | [`docs/research/project-history-collection-http.md`](docs/research/project-history-collection-http.md) | | Project-history GET-by-id doctoring | [`docs/research/project-history-retrieval-http.md`](docs/research/project-history-retrieval-http.md) | +| Project-history cancel HTTP doctoring | [`docs/research/project-history-cancel-http.md`](docs/research/project-history-cancel-http.md) | | 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) | | UML/runtime/scientific flows | [`docs/UML.md`](docs/UML.md) | diff --git a/crates/tepp_api/src/analysis_run_live.rs b/crates/tepp_api/src/analysis_run_live.rs index 8807ed1d6..b19d0b359 100644 --- a/crates/tepp_api/src/analysis_run_live.rs +++ b/crates/tepp_api/src/analysis_run_live.rs @@ -24,8 +24,10 @@ use crate::{ TEMPORAL_CONTEXT_PATH, TemporalContextRequest, build_temporal_context, is_project_history_collection_path, page_project_history_collection_items, parse_project_history_collection_page_cursor, parse_project_history_collection_page_limit, - project_history_projection, project_history_retrieval_path_id, + project_history_cancel_path_id, project_history_projection, project_history_retrieval_path_id, + refuse_metrics_on_project_history_collection_payload, refuse_metrics_on_project_history_retrieval_payload, requests_are_idempotent_matches, + ProjectHistoryCancelled, }; const MAX_LIVE_REQUEST_BODY_BYTES: usize = DEFAULT_PROJECT_HISTORY_BYTE_LIMIT; @@ -161,10 +163,18 @@ impl AnalysisRunLiveService { } return Err(ApiError::InvalidWirePayload); } - if method != "POST" - || (path != NARUON_ANALYSIS_RUN_PATH - && path != TEMPORAL_CONTEXT_PATH - && path != PROJECT_HISTORY_PATH) + if method != "POST" { + return Err(ApiError::InvalidWirePayload); + } + if matches!( + project_history_cancel_path_id(path), + Ok(_) | Err(ApiError::LimitExceeded) + ) { + return self.cancel_project_history(path, &headers, body); + } + if path != NARUON_ANALYSIS_RUN_PATH + && path != TEMPORAL_CONTEXT_PATH + && path != PROJECT_HISTORY_PATH { return Err(ApiError::InvalidWirePayload); } @@ -321,6 +331,40 @@ impl AnalysisRunLiveService { Ok(json_response(200, "OK", response_body)) } + fn cancel_project_history( + &mut self, + path: &str, + headers: &HashMap, + body: &str, + ) -> Result { + if !body.trim().is_empty() { + return Err(ApiError::InvalidWirePayload); + } + refuse_metrics_on_project_history_collection_payload(body)?; + let consumer = require_headers(headers, self.bound_addr, false)?; + if consumer != LINEAGEWEAVE_CONSUMER_CODE { + return Err(ApiError::InvalidWirePayload); + } + if headers.contains_key("idempotency-key") + || headers.contains_key("tepp-page-limit") + || headers.contains_key("tepp-page-cursor") + { + return Err(ApiError::InvalidWirePayload); + } + let tenant_workspace_id = header_value(headers, PROJECT_HISTORY_RETRIEVAL_TENANT_HEADER)?; + crate::project_history::validate_project_history_registry_identity(tenant_workspace_id)?; + let idempotency_key = project_history_cancel_path_id(path)?; + let replay_key = + consumer_tenant_idempotency_key(consumer, tenant_workspace_id, &idempotency_key); + let (request, projection) = self + .accepted_project_histories + .remove(&replay_key) + .ok_or(ApiError::InvalidWirePayload)?; + let cancelled = ProjectHistoryCancelled::from_stored(&request, &projection)?; + let response_body = cancelled.to_json()?; + 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; @@ -1237,6 +1281,53 @@ mod tests { assert!(!collection.body.contains("evidence_text")); } + #[test] + fn project_history_cancel_removes_identity_and_fails_closed() { + let mut service = AnalysisRunLiveService::new(); + let first = sample_project_history("idem-a", "project-a"); + let posted = service.handle_http_request(&project_history_post(&first)); + assert_eq!(posted.status_code, 200); + let cancelled = service.handle_http_request(&format!( + "POST {PROJECT_HISTORY_PATH}/idem-a/cancel HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: {LINEAGEWEAVE_CONSUMER_CODE}\r\ntepp-contract-version: 1\r\ntepp-tenant-workspace-id: history-tenant\r\ncontent-length: 0\r\n\r\n" + )); + assert_eq!(cancelled.status_code, 200, "{}", cancelled.body); + let parsed = + crate::ProjectHistoryCancelled::from_json(&cancelled.body).expect("cancelled"); + assert!(parsed.cancelled); + assert_eq!(parsed.project_key, "project-a"); + assert_eq!(parsed.idempotency_key, "idem-a"); + assert_eq!(parsed.inference_status, "temporal_association_only"); + assert!(!cancelled.body.contains("evidence_text")); + assert!(!cancelled.body.contains("findings")); + assert!(!cancelled.body.contains("rmse")); + assert!(!cancelled.body.contains("tepp.scientific_acceptance.v1")); + let missing = service.handle_http_request(&format!( + "GET {PROJECT_HISTORY_PATH}/idem-a HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: {LINEAGEWEAVE_CONSUMER_CODE}\r\ntepp-contract-version: 1\r\ntepp-tenant-workspace-id: history-tenant\r\ncontent-length: 0\r\n\r\n" + )); + assert_eq!(missing.status_code, 400); + let listed = service.handle_http_request(&format!( + "GET {PROJECT_HISTORY_PATH} HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: {LINEAGEWEAVE_CONSUMER_CODE}\r\ntepp-contract-version: 1\r\ntepp-tenant-workspace-id: history-tenant\r\ncontent-length: 0\r\n\r\n" + )); + assert_eq!(listed.status_code, 200); + assert!(!listed.body.contains("idem-a")); + let replay = service.handle_http_request(&format!( + "POST {PROJECT_HISTORY_PATH}/idem-a/cancel HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: {LINEAGEWEAVE_CONSUMER_CODE}\r\ntepp-contract-version: 1\r\ntepp-tenant-workspace-id: history-tenant\r\ncontent-length: 0\r\n\r\n" + )); + assert_eq!(replay.status_code, 400); + let naruon = service.handle_http_request(&format!( + "POST {PROJECT_HISTORY_PATH}/idem-a/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\ntepp-tenant-workspace-id: history-tenant\r\ncontent-length: 0\r\n\r\n" + )); + assert_eq!(naruon.status_code, 400); + let with_key = service.handle_http_request(&format!( + "POST {PROJECT_HISTORY_PATH}/idem-a/cancel HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: {LINEAGEWEAVE_CONSUMER_CODE}\r\ntepp-contract-version: 1\r\nidempotency-key: idem-a\r\ntepp-tenant-workspace-id: history-tenant\r\ncontent-length: 0\r\n\r\n" + )); + assert_eq!(with_key.status_code, 400); + let nonempty = service.handle_http_request(&format!( + "POST {PROJECT_HISTORY_PATH}/idem-a/cancel HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: {LINEAGEWEAVE_CONSUMER_CODE}\r\ntepp-contract-version: 1\r\ntepp-tenant-workspace-id: history-tenant\r\ncontent-length: 2\r\n\r\n{{}}" + )); + assert_eq!(nonempty.status_code, 400); + } + struct ScriptedRead { reader: Cursor>, first_error: Option, diff --git a/crates/tepp_api/src/lib.rs b/crates/tepp_api/src/lib.rs index 68d4a85ac..242995a65 100644 --- a/crates/tepp_api/src/lib.rs +++ b/crates/tepp_api/src/lib.rs @@ -28,6 +28,7 @@ mod naruon_http; mod naruon_live; mod orchestration; mod project_history; +mod project_history_cancel_http; mod project_history_collection_http; mod project_history_retrieval_http; mod project_journey; @@ -266,6 +267,14 @@ pub use project_history_retrieval_http::lineageweave_project_history_retrieval_e pub use project_history_retrieval_http::project_history_retrieval_path_id; /// Refuse scientific-metric and causal-score keys on retrieval JSON. pub use project_history_retrieval_http::refuse_metrics_on_project_history_retrieval_payload; +/// Metric-free cancelled project-history identity. +pub use project_history_cancel_http::ProjectHistoryCancelled; +/// Maximum opaque idempotency-key length on the cancel path. +pub use project_history_cancel_http::PROJECT_HISTORY_CANCEL_ID_MAX_LEN; +/// Extract the opaque idempotency key from `POST /v1/project-histories/{key}/cancel`. +pub use project_history_cancel_http::project_history_cancel_path_id; +/// Build a credential-free `LineageWeave` cancel POST exchange. +pub use project_history_cancel_http::lineageweave_project_history_cancel_exchange; /// Maximum posterior Project Journey artifact size. pub use project_journey::DEFAULT_PROJECT_JOURNEY_BYTE_LIMIT; /// Exact posterior Project Journey schema identity. diff --git a/crates/tepp_api/src/project_history_cancel_http.rs b/crates/tepp_api/src/project_history_cancel_http.rs new file mode 100644 index 000000000..4cf58e631 --- /dev/null +++ b/crates/tepp_api/src/project_history_cancel_http.rs @@ -0,0 +1,337 @@ +//! Provider-owned project-history cancel HTTP contracts. +//! +//! GAP-003A unique slice: `POST /v1/project-histories/{idempotency_key}/cancel` +//! removes one accepted cutoff-safe identity from `AnalysisRunLiveService` / +//! `tepp-loopback`. The receipt stays metric-free with +//! `inference_status=temporal_association_only` and `cancelled=true`. +//! `tepp.scientific_acceptance.v1` never appears. Cancel does not infer +//! causality. This module does not duplicate project-history POST CLI (#420), +//! collection GET (#424), collection CLI (#428), GET-by-id (#429), retrieval +//! CLI (#431), export cancel HTTP (#445), interpretation-run cancel HTTP +//! (#440), analysis-run cancel (#361), Leiden, or GAP-010 Figma/export. +//! Persistence remains GAP-003B. Naruon is refused. `NaruonLiveService` stays +//! POST-only. + +use crate::naruon_http::{compose_https_target, NaruonHttpExchange}; +use crate::project_history::validate_project_history_registry_identity; +use crate::project_history_collection_http::{ + refuse_metrics_on_project_history_collection_payload, + PROJECT_HISTORY_COLLECTION_INFERENCE_STATUS, +}; +use crate::project_history_retrieval_http::PROJECT_HISTORY_RETRIEVAL_TENANT_HEADER; +use crate::wire::{require_byte_limit, require_nonempty, to_json}; +use crate::{ + ApiError, ProjectHistoryProjection, ProjectHistoryRequest, DEFAULT_PROJECT_HISTORY_BYTE_LIMIT, + PROJECT_HISTORY_PATH, +}; +use serde::{Deserialize, Serialize}; + +/// Maximum opaque idempotency-key length on the cancel path. +pub const PROJECT_HISTORY_CANCEL_ID_MAX_LEN: usize = 256; + +/// Metric-free cancelled project-history identity. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ProjectHistoryCancelled { + /// Consumer-owned stable project key. + pub project_key: String, + /// Exact request idempotency key that minted the stored projection. + pub idempotency_key: String, + /// Knowledge cutoff applied to the stored projection. + pub knowledge_cutoff: String, + /// Fixed claim boundary: sequence is association, not causation. + pub inference_status: String, + /// Always `true` on a successful cancel receipt. + pub cancelled: bool, +} + +impl ProjectHistoryCancelled { + /// Construct a validated cancelled identity from a stored projection. + /// + /// # Errors + /// + /// Returns a fail-closed error for empty identities, a causal inference + /// status, or `cancelled` that would not be true. + pub fn from_stored( + request: &ProjectHistoryRequest, + projection: &ProjectHistoryProjection, + ) -> Result { + let cancelled = Self { + project_key: request.project_key.clone(), + idempotency_key: request.idempotency_key.clone(), + knowledge_cutoff: projection.knowledge_cutoff.clone(), + inference_status: projection.inference_status.clone(), + 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_PROJECT_HISTORY_BYTE_LIMIT)?; + refuse_metrics_on_project_history_collection_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_PROJECT_HISTORY_BYTE_LIMIT)?; + refuse_metrics_on_project_history_collection_payload(&payload)?; + Ok(payload) + } + + fn validate(&self) -> Result<(), ApiError> { + require_nonempty(&self.project_key)?; + require_nonempty(&self.idempotency_key)?; + require_nonempty(&self.knowledge_cutoff)?; + if self.idempotency_key.contains('/') || self.idempotency_key.contains('\0') { + return Err(ApiError::InvalidWirePayload); + } + if self.idempotency_key.len() > PROJECT_HISTORY_CANCEL_ID_MAX_LEN { + return Err(ApiError::LimitExceeded); + } + if self.inference_status != PROJECT_HISTORY_COLLECTION_INFERENCE_STATUS { + return Err(ApiError::InvalidWirePayload); + } + if !self.cancelled { + return Err(ApiError::InvalidWirePayload); + } + Ok(()) + } +} + +/// Extract the opaque idempotency key from +/// `POST /v1/project-histories/{key}/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 project_history_cancel_path_id(path: &str) -> Result { + let remainder = path + .strip_prefix(PROJECT_HISTORY_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 idempotency_key = decode_path_segment(encoded)?; + require_nonempty(&idempotency_key)?; + if idempotency_key.contains('/') || idempotency_key.contains('\0') { + return Err(ApiError::InvalidWirePayload); + } + if idempotency_key.len() > PROJECT_HISTORY_CANCEL_ID_MAX_LEN { + return Err(ApiError::LimitExceeded); + } + Ok(idempotency_key) +} + +/// Build a credential-free `LineageWeave` 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 lineageweave_project_history_cancel_exchange( + origin: &str, + tenant_workspace_id: &str, + idempotency_key: &str, +) -> Result { + validate_project_history_registry_identity(tenant_workspace_id)?; + validate_project_history_registry_identity(idempotency_key)?; + if idempotency_key.contains('/') || idempotency_key.contains('\0') { + return Err(ApiError::InvalidWirePayload); + } + if idempotency_key.len() > PROJECT_HISTORY_CANCEL_ID_MAX_LEN { + return Err(ApiError::LimitExceeded); + } + let encoded_id = encode_path_segment(idempotency_key); + let target_path = format!("{PROJECT_HISTORY_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(), "lineageweave".into()), + ("tepp-contract-version".into(), "1".into()), + ( + PROJECT_HISTORY_RETRIEVAL_TENANT_HEADER.into(), + tenant_workspace_id.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::{ + lineageweave_project_history_cancel_exchange, project_history_cancel_path_id, + PROJECT_HISTORY_CANCEL_ID_MAX_LEN, + }; + use crate::project_history_collection_http::refuse_metrics_on_project_history_collection_payload; + use crate::{ApiError, PROJECT_HISTORY_PATH}; + + #[test] + fn cancel_exchange_is_metric_free_post_without_credentials() { + let exchange = lineageweave_project_history_cancel_exchange( + "https://tepp.example.test", + "history-tenant", + "idem-a", + ) + .expect("exchange"); + assert_eq!(exchange.method, "POST"); + assert!(exchange + .target_url + .ends_with("/v1/project-histories/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!(exchange + .headers + .iter() + .any(|(name, value)| name == "tepp-consumer" && value == "lineageweave")); + assert_eq!( + project_history_cancel_path_id("/v1/project-histories/idem-a/cancel").expect("id"), + "idem-a" + ); + assert_eq!(PROJECT_HISTORY_PATH, "/v1/project-histories"); + assert_eq!( + refuse_metrics_on_project_history_collection_payload(""), + Ok(()) + ); + } + + #[test] + fn cancel_path_and_payloads_fail_closed() { + assert_eq!( + project_history_cancel_path_id("/v1/project-histories"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + project_history_cancel_path_id("/v1/project-histories/idem-a"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + project_history_cancel_path_id("/v1/project-histories/idem-a/extra/cancel"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + project_history_cancel_path_id("/v1/exports/idem-a/cancel"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + project_history_cancel_path_id(&format!( + "/v1/project-histories/{}/cancel", + "e".repeat(PROJECT_HISTORY_CANCEL_ID_MAX_LEN + 1) + )), + Err(ApiError::LimitExceeded) + ); + assert_eq!( + lineageweave_project_history_cancel_exchange( + "http://tepp.example.test", + "history-tenant", + "idem-a" + ), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + lineageweave_project_history_cancel_exchange( + "https://tepp.example.test", + "history-tenant", + "a/b" + ), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + refuse_metrics_on_project_history_collection_payload(r#"{"rmse":1.0}"#), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + refuse_metrics_on_project_history_collection_payload(r#"{"findings":[]}"#), + Err(ApiError::InvalidWirePayload) + ); + } +} diff --git a/crates/tepp_api/tests/project_history_cancel_http_contract.rs b/crates/tepp_api/tests/project_history_cancel_http_contract.rs new file mode 100644 index 000000000..80fc9f96a --- /dev/null +++ b/crates/tepp_api/tests/project_history_cancel_http_contract.rs @@ -0,0 +1,74 @@ +//! Contract tests for loopback `POST /v1/project-histories/{key}/cancel`. + +use tepp_api::{ + lineageweave_project_history_cancel_exchange, AnalysisRunLiveService, ApiError, + LINEAGEWEAVE_CONSUMER_CODE, NARUON_CONSUMER_CODE, NaruonLiveService, PROJECT_HISTORY_PATH, + ProjectHistoryCancelled, ProjectHistoryEvent, ProjectHistoryRequest, +}; + +fn sample_request() -> ProjectHistoryRequest { + ProjectHistoryRequest { + contract_version: 1, + idempotency_key: "idem-cancel-contract".into(), + tenant_workspace_id: "history-tenant".into(), + project_key: "project-cancel".into(), + project_name: "Project".into(), + knowledge_cutoff: "2026-08-19T23:59:59Z".into(), + focus_event_id: "focus".into(), + events: vec![ProjectHistoryEvent { + event_id: "focus".into(), + event_type_code: "voc_received".into(), + event_title: "VOC".into(), + occurred_at: "2026-08-19T09:00:00Z".into(), + available_at: "2026-08-19T10:00:00Z".into(), + source_post_id: "post".into(), + evidence_text: "explicit evidence".into(), + actor_ids: Vec::new(), + }], + } +} + +fn post_http(request: &ProjectHistoryRequest) -> String { + let body = request.to_json().expect("json"); + format!( + "POST {PROJECT_HISTORY_PATH} HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: {LINEAGEWEAVE_CONSUMER_CODE}\r\ntepp-contract-version: 1\r\nidempotency-key: {}\r\ncontent-length: {}\r\n\r\n{body}", + request.idempotency_key, + body.len() + ) +} + +#[test] +fn live_service_cancels_and_naruon_live_stays_post_only() { + let mut service = AnalysisRunLiveService::new(); + let posted = service.handle_http_request(&post_http(&sample_request())); + assert_eq!(posted.status_code, 200, "{}", posted.body); + let cancel = format!( + "POST {PROJECT_HISTORY_PATH}/idem-cancel-contract/cancel HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: {LINEAGEWEAVE_CONSUMER_CODE}\r\ntepp-contract-version: 1\r\ntepp-tenant-workspace-id: history-tenant\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 = ProjectHistoryCancelled::from_json(&cancelled.body).expect("cancelled"); + assert!(parsed.cancelled); + assert_eq!(parsed.inference_status, "temporal_association_only"); + let mut naruon = NaruonLiveService::new(); + assert_ne!(naruon.handle_http_request(&cancel).status_code, 200); + let naruon_consumer = format!( + "POST {PROJECT_HISTORY_PATH}/idem-cancel-contract/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\ntepp-tenant-workspace-id: history-tenant\r\ncontent-length: 0\r\n\r\n" + ); + assert_eq!( + service.handle_http_request(&naruon_consumer).status_code, + 400 + ); +} + +#[test] +fn cancel_exchange_refuses_http_origin() { + assert_eq!( + lineageweave_project_history_cancel_exchange( + "http://tepp.example.test", + "history-tenant", + "idem-a" + ), + Err(ApiError::InvalidWirePayload) + ); +} diff --git a/docs/API_CONTRACT.md b/docs/API_CONTRACT.md index 819f70fa3..a43d4d408 100644 --- a/docs/API_CONTRACT.md +++ b/docs/API_CONTRACT.md @@ -104,6 +104,11 @@ cutoff-safe `ProjectHistoryProjection` on `tepp-loopback`. Consumer is `tepp.scientific_acceptance.v1` and causal scores never appear. The retrieval does not infer causality. +`POST /v1/project-histories/{idempotency_key}/cancel` removes one accepted +identity (ADR 0079). Receipts stay metric-free with `cancelled=true` and +`inference_status=temporal_association_only`. Naruon is refused. +`NaruonLiveService` stays POST-only. + The typed status/read contract returns `accepted`, `running`, `succeeded`, or `failed`. Accepted and running statuses contain no measurement result. A terminal status contains exactly one request-bound diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index dbc6936c6..42cffb2ee 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -55,6 +55,7 @@ The full APA 7th standards/literature register remains `docs/research/standards- | versioned service/API contracts and exports | PRD; API contract; ADR 0011/0013 | `tepp_api` analysis-run/export/JSON-LD/GraphML contracts on protected main (PR #21); request-bound terminal result active in PR #157; HTTP service remains accepted-target; the `orchestrator_live` loopback interpretation listener is on this PR | partial | | loopback LineageWeave project-history collection GET | ADR 0028; API contract; RFC 9110; ADR 0021/0011 | `tepp_api` `GET /v1/project-histories` on `tepp-loopback`; metric-free `temporal_association_only` identities; `tepp.scientific_acceptance.v1` never appears; does not infer causality | active-PR | | loopback LineageWeave project-history GET-by-id | ADR 0066; API contract; RFC 9110; ADR 0028/0021/0011 | `tepp_api` `GET /v1/project-histories/{idempotency_key}` on `tepp-loopback`; stored `temporal_association_only` projection; `tepp.scientific_acceptance.v1` never appears; does not infer causality | active-PR | +| loopback LineageWeave project-history cancel HTTP | ADR 0079; API contract; RFC 9110; ADR 0066/0028/0014 | `tepp_api` `POST /v1/project-histories/{idempotency_key}/cancel` on `tepp-loopback`; metric-free `cancelled=true` identity; `tepp.scientific_acceptance.v1` never appears; does not infer causality | active-PR | | executable cutoff-safe analysis-run readiness | ADR 0021; temporal research; API terminal-result contract | stacked `analysis_engine` PR on #157: availability cutoff, snapshot binding, multiple-membership aggregation, digest-bound artifact, realistic end-to-end tests | active-PR | | delayed-reporting cutoff eligibility in truth corpora | ADR 0002; research | `tepp_simulation` eligible-at-cutoff filter on the active PR | active-PR | | versioned service/API contracts and exports | PRD; API contract; ADR 0011/0013 | `tepp_api` analysis-run/export/JSON-LD/GraphML contracts on protected main (PR #21); HTTP service remaining accepted-target | partial | diff --git a/docs/adr/0079-project-history-cancel-http.md b/docs/adr/0079-project-history-cancel-http.md new file mode 100644 index 000000000..3e7482021 --- /dev/null +++ b/docs/adr/0079-project-history-cancel-http.md @@ -0,0 +1,100 @@ +# ADR 0079 — Loopback project-history cancel HTTP + +**Decision status:** Accepted +**Implementation maturity:** active-PR +**Date:** 2026-09-01 +**Supersedes:** None; complements ADR 0066/0028 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–0078. + +## Context + +ADR 0028 and ADR 0066 mint and retrieve accepted project-history identities +on `AnalysisRunLiveService`. Operators had no loopback POST that removes one +identity without guessing a second collection GET. Duplicating +project-history POST CLI (#420), collection GET (#424), collection CLI +(#428), GET-by-id (#429), retrieval CLI (#431), export cancel HTTP (#445), +interpretation-run cancel HTTP (#440), analysis-run cancel (#361), Leiden, +Driver p.16, or GAP-010 Figma/export would collide with live PRs. Naruon is +refused on this LineageWeave-owned adapter; `NaruonLiveService` stays +POST-only. + +## Decision + +Publish `POST /v1/project-histories/{idempotency_key}/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 `inference_status=temporal_association_only` + and `cancelled=true`. Evidence text, findings, RMSE, bias, coverage, + SE-gate, causal scores, 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 export cancel (#445)** — rejected; naruon-owned export adapter. +3. **Reuse interpretation-run cancel (#440)** — rejected; orchestrator-owned. +4. **Add GET to `NaruonLiveService`** — rejected; POST-only. +5. **Published loopback cancel POST** — accepted. + +## Consequences + +- Operators can retract an accepted project-history 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, naruon, 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. +- Evidence text, findings, and actor lists 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/project-histories`, and +`NaruonLiveService` POST-only remain unchanged. Persistence remains +GAP-003B. + +## Verification + +Falsifiable evidence: + +- cancel of an accepted project-history returns metric-free `cancelled=true` + without RMSE/bias/coverage/SE-gate/evidence/findings/causal-score/ + `tepp.scientific_acceptance.v1`; +- subsequent GET-by-id and collection GET omit the identity; +- naruon, 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 naruon on this adapter, add GET to +`NaruonLiveService`, or treat cancel success as an ADR 0014 claim. + +## Related authority + +- ADR 0066 owns loopback project-history GET-by-id. +- ADR 0028 owns loopback project-history collection GET. +- ADR 0077 owns loopback export cancel HTTP (live #445). +- ADR 0073 owns interpretation-run cancel HTTP (live #440). +- ADR 0029 owns analysis-run cancel HTTP (live #361). +- 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 c865f298e..9e659988b 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. | | [0028](0028-project-history-collection-get.md) | Loopback `GET /v1/project-histories` enumerates accepted LineageWeave projections | Accepted | active-PR | Complements ADR 0021/0011; does not supersede ADR 0014. Unique on protected main. Does not infer causality. | | [0066](0066-project-history-retrieval-get.md) | Loopback `GET /v1/project-histories/{idempotency_key}` retrieves one accepted LineageWeave projection | Accepted | active-PR | Complements ADR 0028; unique vs protected main. Does not infer causality. | +| [0079](0079-project-history-cancel-http.md) | Loopback project-history cancel HTTP | Accepted | active-PR | Complements ADR 0066/0028; `POST /v1/project-histories/{idempotency_key}/cancel` removes one metric-free identity. Unique versus protected main (0026–0078 occupied). Naruon refused. `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/research/project-history-cancel-http.md b/docs/research/project-history-cancel-http.md new file mode 100644 index 000000000..c95e906bb --- /dev/null +++ b/docs/research/project-history-cancel-http.md @@ -0,0 +1,58 @@ +# Project-history cancel HTTP (doctoring) + +## Scope + +`POST /v1/project-histories/{idempotency_key}/cancel` on +`AnalysisRunLiveService` / `tepp-loopback` removes one accepted LineageWeave +project-history 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 0079; ADR 0066; ADR 0014), not an RFC inference +rule. + +The receipt is metric-free with `inference_status=temporal_association_only` +and `cancelled=true`. Evidence text, findings, actor lists, 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 project-history identity. The RFC does not define +psychometric acceptance, RMSE, causality, or claim promotion. + +### Internal contract evidence + +- `docs/adr/0079-project-history-cancel-http.md` — this cancel route +- `docs/adr/0066-project-history-retrieval-get.md` — GET-by-id +- `docs/adr/0014-scientific-claim-promotion-and-release-evidence.md` — HTTP + 200 is not a scientific claim +- `crates/tepp_api/tests/project_history_cancel_http_contract.rs` — + fail-closed proofs + +## Verification + +- cancel of an accepted LineageWeave project-history returns metric-free + `cancelled=true` without RMSE/bias/coverage/SE-gate keys, evidence text, + findings, causal scores, or `tepp.scientific_acceptance.v1`; +- subsequent GET-by-id and collection GET omit the identity; +- naruon, 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, export cancel, persistence, production TLS, +Leiden consensus, provider execution, causal inference, or an ADR 0014 +scientific claim-promotion package.