diff --git a/CHANGELOG.d/export-stored-request-get.md b/CHANGELOG.d/export-stored-request-get.md new file mode 100644 index 000000000..a9d818b53 --- /dev/null +++ b/CHANGELOG.d/export-stored-request-get.md @@ -0,0 +1 @@ +- `GET /v1/exports/{export_id}/request` returns the accepted naruon export-authorization request on `tepp-loopback` (ADR 0089). Metric-free. LineageWeave refused. `NaruonLiveService` stays POST-only. Does not re-open cancel lineages. Not GAP-010 Figma/export, not persistence. diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 6fa4b9683..0044e66e7 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -11,6 +11,7 @@ TEPP's approved PRD v0.4 and implementation plan are the primary product baselin | Architecture | [`ARCHITECTURE.md`](ARCHITECTURE.md) | | Modular/API integration contract | [`docs/API_CONTRACT.md`](docs/API_CONTRACT.md) | | naruon modular consumer contract | [`docs/connectors/naruon-artifact-consumer.md`](docs/connectors/naruon-artifact-consumer.md) | +| Export stored-request GET doctoring | [`docs/research/export-stored-request-get.md`](docs/research/export-stored-request-get.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) | @@ -109,6 +110,7 @@ TEPP's approved PRD v0.4 and implementation plan are the primary product baselin | Architecture | [`ARCHITECTURE.md`](ARCHITECTURE.md) | | Modular/API integration contract | [`docs/API_CONTRACT.md`](docs/API_CONTRACT.md) | | naruon modular consumer contract | [`docs/connectors/naruon-artifact-consumer.md`](docs/connectors/naruon-artifact-consumer.md) | +| Export stored-request GET doctoring | [`docs/research/export-stored-request-get.md`](docs/research/export-stored-request-get.md) | | contextual-orchestrator interpretation port | [`docs/connectors/contextual-orchestrator-interpretation-port.md`](docs/connectors/contextual-orchestrator-interpretation-port.md) | | UML/runtime/scientific flows | [`docs/UML.md`](docs/UML.md) | | Logical/physical ERD | [`docs/ERD.md`](docs/ERD.md) | diff --git a/crates/tepp_api/src/analysis_run_live.rs b/crates/tepp_api/src/analysis_run_live.rs index a5f1f9f93..17e5972cc 100644 --- a/crates/tepp_api/src/analysis_run_live.rs +++ b/crates/tepp_api/src/analysis_run_live.rs @@ -3,7 +3,8 @@ //! This module keeps the Naruon compatibility listener intact while providing //! the shared `/v1/analysis-runs` and cutoff-safe `/v1/temporal-context` //! boundaries needed by Naruon and `LineageWeave`. Naruon may also POST and -//! GET `/v1/exports/{export_id}` for metric-free purpose-bound retrieval. +//! GET `/v1/exports/{export_id}` for metric-free purpose-bound retrieval, and +//! GET `/v1/exports/{export_id}/request` for the stored authorization request. //! It accepts transport acknowledgements, temporal evidence context, and //! export identities only; completed psychometric results remain outside this //! crate. @@ -13,8 +14,11 @@ use std::io::Write; use std::net::{SocketAddr, TcpListener}; use crate::export_http::{export_retrieval_path_id, refuse_metrics_on_export_retrieval_payload}; +use crate::export_stored_request_http::{ + export_stored_request_path_id, refuse_metrics_on_export_stored_request_payload, +}; use crate::lineageweave_http::{ - LINEAGEWEAVE_CONSUMER_CODE, NARUON_CONSUMER_CODE, consumer_is_supported, + consumer_is_supported, LINEAGEWEAVE_CONSUMER_CODE, NARUON_CONSUMER_CODE, }; use crate::live_http::{ header_value, map_io_error, parse_headers, parse_request_line, read_http_request_with_limit, @@ -22,12 +26,12 @@ use crate::live_http::{ }; use crate::naruon_http::{NARUON_ANALYSIS_RUN_PATH, NARUON_EXPORT_PATH}; use crate::{ - AnalysisRunAccepted, AnalysisRunRequest, AnalyticalPurpose, ApiError, - DEFAULT_PROJECT_HISTORY_BYTE_LIMIT, ErrorEnvelope, ExportAuthorizationRequest, ExportRetrieval, - NARUON_LIVE_IO_TIMEOUT, NaruonLiveResponse, PROJECT_HISTORY_PATH, ProjectHistoryProjection, - ProjectHistoryRequest, TEMPORAL_CONTEXT_PATH, TemporalContextRequest, authorize_export, - build_temporal_context, project_history_projection, requests_are_idempotent_matches, - require_export_allowed, + authorize_export, build_temporal_context, project_history_projection, + requests_are_idempotent_matches, require_export_allowed, AnalysisRunAccepted, + AnalysisRunRequest, AnalyticalPurpose, ApiError, ErrorEnvelope, ExportAuthorizationRequest, + ExportRetrieval, NaruonLiveResponse, ProjectHistoryProjection, ProjectHistoryRequest, + TemporalContextRequest, DEFAULT_PROJECT_HISTORY_BYTE_LIMIT, NARUON_LIVE_IO_TIMEOUT, + PROJECT_HISTORY_PATH, TEMPORAL_CONTEXT_PATH, }; const MAX_LIVE_REQUEST_BODY_BYTES: usize = DEFAULT_PROJECT_HISTORY_BYTE_LIMIT; @@ -162,6 +166,12 @@ impl AnalysisRunLiveService { let (method, path) = parse_request_line(lines.next().unwrap_or(""))?; let headers = parse_headers(&mut lines)?; if method == "GET" { + if matches!( + export_stored_request_path_id(path), + Ok(_) | Err(ApiError::LimitExceeded) + ) { + return self.get_export_stored_request(path, &headers, body); + } if matches!( export_retrieval_path_id(path), Ok(_) | Err(ApiError::LimitExceeded) @@ -342,6 +352,35 @@ impl AnalysisRunLiveService { Ok(json_response(200, "OK", response_body)) } + fn get_export_stored_request( + &self, + path: &str, + headers: &HashMap, + body: &str, + ) -> Result { + if !body.trim().is_empty() { + return Err(ApiError::InvalidWirePayload); + } + refuse_metrics_on_export_stored_request_payload(body)?; + let consumer = require_headers(headers, self.bound_addr, false)?; + if consumer != NARUON_CONSUMER_CODE { + return Err(ApiError::InvalidWirePayload); + } + let export_id = export_stored_request_path_id(path)?; + let replay_key = self + .exports_by_id + .get(&export_id) + .cloned() + .ok_or(ApiError::InvalidWirePayload)?; + let stored = self + .authorized_exports + .get(&replay_key) + .ok_or(ApiError::InvalidWirePayload)?; + let response_body = crate::wire::to_json(&stored.request)?; + refuse_metrics_on_export_stored_request_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; @@ -417,17 +456,16 @@ mod tests { use std::time::Duration; use super::{ - AnalysisRunLiveService, consumer_tenant_idempotency_key, declared_content_length, - error_envelope_json, host_implies_table_access, map_io_error, parse_headers, - require_headers, split_header_line, status_for, + consumer_tenant_idempotency_key, declared_content_length, error_envelope_json, + host_implies_table_access, map_io_error, parse_headers, require_headers, split_header_line, + status_for, AnalysisRunLiveService, }; use crate::live_http::{host_is_loopback, read_http_request, split_request}; use crate::{ - ANALYSIS_RUN_CONTRACT_VERSION, AnalysisRunRequest, ApiError, - DEFAULT_ANALYSIS_RUN_BYTE_LIMIT, ErrorEnvelope, LINEAGEWEAVE_CONSUMER_CODE, - NARUON_ANALYSIS_RUN_PATH, NARUON_CONSUMER_CODE, NARUON_EXPORT_PATH, - NARUON_LIVE_HEADER_BYTE_LIMIT, NARUON_LIVE_HEADER_COUNT_LIMIT, NARUON_LIVE_IO_TIMEOUT, - TEMPORAL_CONTEXT_PATH, + AnalysisRunRequest, ApiError, ErrorEnvelope, ANALYSIS_RUN_CONTRACT_VERSION, + DEFAULT_ANALYSIS_RUN_BYTE_LIMIT, LINEAGEWEAVE_CONSUMER_CODE, NARUON_ANALYSIS_RUN_PATH, + NARUON_CONSUMER_CODE, NARUON_EXPORT_PATH, NARUON_LIVE_HEADER_BYTE_LIMIT, + NARUON_LIVE_HEADER_COUNT_LIMIT, NARUON_LIVE_IO_TIMEOUT, TEMPORAL_CONTEXT_PATH, }; fn sample_run() -> AnalysisRunRequest { @@ -1220,6 +1258,73 @@ mod tests { ); } + #[test] + fn handler_returns_stored_export_authorization_request_and_fails_closed() { + use crate::ExportAuthorizationRequest; + + let request = ExportAuthorizationRequest { + tenant_workspace_id: "export-live-tenant".into(), + principal_id: "principal-analyst-1".into(), + purpose: crate::AnalyticalPurpose::ModularServiceConsumer, + artifact_id: "artifact-live-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-idem-1", + )); + assert_eq!(posted.status_code, 200); + let retrieval = crate::ExportRetrieval::from_json(&posted.body).expect("posted retrieval"); + let got = service.handle_http_request(&format!( + "GET {NARUON_EXPORT_PATH}/{}/request 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", + retrieval.export_id + )); + assert_eq!(got.status_code, 200, "{}", got.body); + let stored: ExportAuthorizationRequest = crate::wire::from_json(&got.body).expect("stored"); + assert_eq!(stored, request); + assert!(!got.body.contains("rmse")); + assert!(!got.body.contains("tepp.scientific_acceptance.v1")); + assert!(!got.body.contains("scientific_acceptance")); + assert_eq!( + service + .handle_http_request(&format!( + "GET {NARUON_EXPORT_PATH}/{}/request 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\ncontent-length: 0\r\n\r\n", + retrieval.export_id + )) + .status_code, + 400 + ); + assert_eq!( + service + .handle_http_request(&format!( + "GET {NARUON_EXPORT_PATH}/{}/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", + retrieval.export_id + )) + .status_code, + 400 + ); + assert_eq!( + service + .handle_http_request(&format!( + "GET {NARUON_EXPORT_PATH}/missing/request 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" + )) + .status_code, + 400 + ); + assert_eq!( + service + .handle_http_request(&format!( + "GET {NARUON_EXPORT_PATH}/{}/request 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: 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}", diff --git a/crates/tepp_api/src/export_stored_request_http.rs b/crates/tepp_api/src/export_stored_request_http.rs new file mode 100644 index 000000000..c9a42989a --- /dev/null +++ b/crates/tepp_api/src/export_stored_request_http.rs @@ -0,0 +1,262 @@ +//! Provider-owned export stored-request GET contracts. +//! +//! GAP-003A unique slice: `GET /v1/exports/{export_id}/request` returns the +//! accepted naruon export-authorization request on `AnalysisRunLiveService` +//! / `tepp-loopback` so operators who hold a retrieval identity do not replay +//! POST. `NaruonLiveService` stays POST-only. `LineageWeave` is refused on this +//! naruon-owned adapter. `tepp.scientific_acceptance.v1` never appears. This +//! module does not duplicate GET-by-id (#411), retrieval CLI (#417), +//! collection GET/CLI (#443/#444), export-authorize CLI (#410), analysis-run +//! stored-request GET (#377), project-history stored-request GET (#455), +//! interpretation-run stored-request GET (#453), or cancel lineages (closed). +//! Persistence remains GAP-003B. GAP-010 Figma/export remains later work. + +use crate::export_http::EXPORT_RETRIEVAL_ID_MAX_LEN; +use crate::naruon_http::{compose_https_target, NaruonHttpExchange}; +use crate::wire::require_nonempty; +use crate::{ApiError, NARUON_EXPORT_PATH}; + +const FORBIDDEN_STORED_REQUEST_KEYS: [&str; 13] = [ + "rmse", + "rmse_standard_error", + "mean_bias", + "bias_standard_error", + "interval_coverage", + "coverage_wilson_lower", + "coverage_wilson_upper", + "temporal_order_accuracy", + "se_gate_accepted", + "se_gate_k", + "scientific_acceptance", + "report", + "terminal_result", +]; + +/// Extract the opaque export identity from +/// `GET /v1/exports/{export_id}/request`. +/// +/// # Errors +/// +/// Returns [`ApiError::InvalidWirePayload`] for collection, GET-by-id, extra +/// segments, a hostile encoding, or an empty identity, and +/// [`ApiError::LimitExceeded`] when oversized. +pub fn export_stored_request_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_id, rest) = encoded + .split_once('/') + .ok_or(ApiError::InvalidWirePayload)?; + if rest != "request" || encoded_id.is_empty() { + return Err(ApiError::InvalidWirePayload); + } + let export_id = decode_path_segment(encoded_id)?; + require_nonempty(&export_id)?; + if export_id.contains('/') || export_id.contains('\0') { + return Err(ApiError::InvalidWirePayload); + } + if export_id.len() > EXPORT_RETRIEVAL_ID_MAX_LEN { + return Err(ApiError::LimitExceeded); + } + Ok(export_id) +} + +/// Whether `path` is the stored-request extra-segment resource. +#[must_use] +pub fn is_export_stored_request_path(path: &str) -> bool { + export_stored_request_path_id(path).is_ok() +} + +/// Refuse stored-request JSON that already carries scientific-metric keys. +/// +/// Empty payloads are admitted for the GET request body. The original +/// authorization request may carry `tenant_workspace_id`, `principal_id`, and +/// `includes_source_text`; those keys are not scientific metrics. +/// +/// # Errors +/// +/// Returns [`ApiError::InvalidWirePayload`] when a forbidden metric key is +/// present. +pub fn refuse_metrics_on_export_stored_request_payload(payload: &str) -> Result<(), ApiError> { + if payload.trim().is_empty() { + return Ok(()); + } + let value: serde_json::Value = + serde_json::from_str(payload).map_err(|_| ApiError::InvalidWirePayload)?; + if !value.is_object() { + return Err(ApiError::InvalidWirePayload); + } + refuse_metrics_on_json(&value) +} + +fn refuse_metrics_on_json(value: &serde_json::Value) -> Result<(), ApiError> { + match value { + serde_json::Value::Object(object) => { + if object + .get("schema_version") + .and_then(serde_json::Value::as_str) + == Some("tepp.scientific_acceptance.v1") + { + return Err(ApiError::InvalidWirePayload); + } + if FORBIDDEN_STORED_REQUEST_KEYS + .iter() + .any(|key| object.contains_key(*key)) + { + return Err(ApiError::InvalidWirePayload); + } + for nested in object.values() { + refuse_metrics_on_json(nested)?; + } + Ok(()) + } + serde_json::Value::Array(items) => { + for nested in items { + refuse_metrics_on_json(nested)?; + } + Ok(()) + } + _ => Ok(()), + } +} + +/// Build a credential-free naruon stored-request GET exchange. +/// +/// # Errors +/// +/// Returns a fail-closed origin or identity error. +pub fn naruon_export_stored_request_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_RETRIEVAL_ID_MAX_LEN { + return Err(ApiError::LimitExceeded); + } + let encoded_id = encode_path_segment(export_id); + let target_path = format!("{NARUON_EXPORT_PATH}/{encoded_id}/request"); + let target_url = compose_https_target(origin, &target_path)?; + Ok(NaruonHttpExchange { + method: "GET", + 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.chars().any(char::is_control) { + 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_stored_request_path_id, is_export_stored_request_path, + naruon_export_stored_request_exchange, refuse_metrics_on_export_stored_request_payload, + }; + use crate::ApiError; + + #[test] + fn stored_request_exchange_is_naruon_get_without_credentials() { + let exchange = + naruon_export_stored_request_exchange("https://tepp.example.test", "export-1") + .expect("exchange"); + assert_eq!(exchange.method, "GET"); + assert!(exchange + .target_url + .ends_with("/v1/exports/export-1/request")); + 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!(is_export_stored_request_path( + "/v1/exports/export-1/request" + )); + assert!(!is_export_stored_request_path("/v1/exports/export-1")); + assert_eq!( + export_stored_request_path_id("/v1/exports/export-1/request").expect("id"), + "export-1" + ); + assert_eq!( + export_stored_request_path_id("/v1/exports/export-1"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + export_stored_request_path_id("/v1/exports/export-1/cancel"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + naruon_export_stored_request_exchange("http://tepp.example.test", "export-1"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!(refuse_metrics_on_export_stored_request_payload(""), Ok(())); + assert_eq!( + refuse_metrics_on_export_stored_request_payload(r#"{"rmse":1.0}"#), + Err(ApiError::InvalidWirePayload) + ); + } +} diff --git a/crates/tepp_api/src/lib.rs b/crates/tepp_api/src/lib.rs index bd8a933e0..05f8540ac 100644 --- a/crates/tepp_api/src/lib.rs +++ b/crates/tepp_api/src/lib.rs @@ -21,6 +21,7 @@ mod envelope; mod error; mod export; mod export_http; +mod export_stored_request_http; mod lineage_criterion_anchor; mod lineage_pair_criterion; mod lineageweave_http; @@ -106,6 +107,14 @@ pub use export_http::ExportRetrieval; pub use export_http::naruon_export_retrieval_exchange; /// Refuse scientific-metric keys on export-retrieval JSON. pub use export_http::refuse_metrics_on_export_retrieval_payload; +/// Extract the opaque export identity from a stored-request path. +pub use export_stored_request_http::export_stored_request_path_id; +/// Whether a path is the export stored-request extra-segment resource. +pub use export_stored_request_http::is_export_stored_request_path; +/// Build a naruon export stored-request GET exchange. +pub use export_stored_request_http::naruon_export_stored_request_exchange; +/// Refuse scientific-metric keys on export stored-request JSON. +pub use export_stored_request_http::refuse_metrics_on_export_stored_request_payload; /// Analytical export purpose. pub use authorization::AnalyticalPurpose; diff --git a/crates/tepp_api/tests/export_stored_request_http_contract.rs b/crates/tepp_api/tests/export_stored_request_http_contract.rs new file mode 100644 index 000000000..fb06fd15f --- /dev/null +++ b/crates/tepp_api/tests/export_stored_request_http_contract.rs @@ -0,0 +1,43 @@ +//! Contract tests for naruon export stored-request GET. + +use tepp_api::{ + export_stored_request_path_id, naruon_export_stored_request_exchange, + refuse_metrics_on_export_stored_request_payload, ApiError, NARUON_CONSUMER_CODE, +}; + +#[test] +fn stored_request_exchange_is_metric_free_get_without_credentials() { + let exchange = naruon_export_stored_request_exchange("https://tepp.example.test", "export-1") + .expect("exchange"); + assert_eq!(exchange.method, "GET"); + assert!(exchange + .target_url + .ends_with("/v1/exports/export-1/request")); + assert!(exchange.body.is_empty()); + assert!(exchange + .headers + .iter() + .any(|(name, value)| name == "tepp-consumer" && value == NARUON_CONSUMER_CODE)); + assert!(!exchange + .headers + .iter() + .any(|(name, _)| name.eq_ignore_ascii_case("authorization") + || name.eq_ignore_ascii_case("idempotency-key"))); + assert_eq!( + export_stored_request_path_id("/v1/exports/export-1/request").expect("id"), + "export-1" + ); + assert_eq!( + export_stored_request_path_id("/v1/exports/export-1"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + export_stored_request_path_id("/v1/exports/export-1/cancel"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + refuse_metrics_on_export_stored_request_payload(r#"{"rmse":1.0}"#), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!(refuse_metrics_on_export_stored_request_payload(""), Ok(())); +} diff --git a/docs/API_CONTRACT.md b/docs/API_CONTRACT.md index 1142e99fe..271c4d612 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); `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); loopback `GET /v1/exports/{export_id}/request` returns the stored authorization request (ADR 0089); `NaruonLiveService` stays POST-only. ## 2. Contract families @@ -69,6 +69,7 @@ GET /v1/analysis-runs/{run_id} POST /v1/analysis-runs/{run_id}/cancel GET /v1/model-artifacts/{artifact_id} GET /v1/exports/{export_id} +GET /v1/exports/{export_id}/request ``` Long-running analysis is durable asynchronous work. `POST /v1/analysis-runs` accepts an idempotency key, immutable input snapshot identity, knowledge cutoff, versioned model contract/configuration, and requested output profile. A retry with the same principal/idempotency key and semantically identical request returns the same run identity; a conflicting body fails closed. diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 20d4b7f01..2c8d53cbb 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 | `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 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/0089 | `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 and `GET /v1/exports/{export_id}/request` returns the stored authorization request 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/0089-export-stored-request-get.md b/docs/adr/0089-export-stored-request-get.md new file mode 100644 index 000000000..9a2b4e0dd --- /dev/null +++ b/docs/adr/0089-export-stored-request-get.md @@ -0,0 +1,62 @@ +# ADR 0089 — Loopback export stored-request GET + +**Decision status:** Accepted +**Implementation maturity:** active-PR +**Date:** 2026-09-01 +**Supersedes:** None; complements ADR 0054. Does not re-open cancel lineages. +Does not supersede ADR 0014. Unique versus protected main; 0026–0088 occupied. + +## Context + +ADR 0054 retrieves one authorized export identity. Operators still had no +extra-segment GET for the stored naruon authorization request. +Project-history stored-request GET (#455) is LineageWeave-owned. +Interpretation-run stored-request GET (#453) is orchestrator-owned. +Duplicating GET-by-id (#411), retrieval CLI (#417), collection GET/CLI +(#443/#444), export-authorize CLI (#410), Leiden, or GAP-010 would collide +with live PRs. Cancel lineages stay closed. LineageWeave is refused on this +naruon-owned adapter. `NaruonLiveService` stays POST-only. + +## Decision + +Publish `GET /v1/exports/{export_id}/request` on `AnalysisRunLiveService`. +Extra-segment parse before GET-by-id. Slash/NUL fail closed. Empty body. +Naruon-only. Response is the stored authorization request. Scientific-metric +keys and `tepp.scientific_acceptance.v1` never appear. Cancel extra-segment +stays refused. `NaruonLiveService` stays POST-only. + +## Alternatives considered + +1. Re-open cancel HTTP — rejected. +2. Return GET-by-id retrieval identity — rejected (ADR 0054). +3. Loopback stored-request GET — accepted. + +## Consequences + +HTTP 200 is not measurement evidence and is not an ADR 0014 claim. Sequence +remains association, not causation. + +## Failure and recovery + +LineageWeave, nonempty bodies, extra segments, slash/NUL, missing keys, http +origins, unpublished consumers, credential flags, and metric keys fail closed. + +## Verification + +- `GET /v1/exports/{export_id}/request` of an authorized export returns the + stored authorization request without RMSE/`tepp.scientific_acceptance.v1`; +- LineageWeave, GET-by-id path, extra segments, slash/NUL, nonempty body, and + missing keys fail closed; +- Clippy `-D warnings`, `tepp_api` tests, rustdoc, and exact-head review remain + required. + +## Rollback and supersession + +Rollback removes the extra-segment GET; POST and GET-by-id remain valid. A +superseding ADR is required to persist the registry, bind a public address, +re-open cancel, emit scientific-acceptance, open LineageWeave on this adapter, +add GET to `NaruonLiveService`, or treat retrieval success as an ADR 0014 claim. + +## Related authority + +ADR 0054, ADR 0009, ADR 0011, ADR 0014, RFC 9110 (Fielding, Nottingham, & Reschke, 2022). diff --git a/docs/adr/README.md b/docs/adr/README.md index 5e43e54fb..74f8747c0 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -31,6 +31,7 @@ Read [`ADR_POLICY.md`](ADR_POLICY.md) first. **Decision status and implementatio | [0024](0024-lineage-pair-criterion-and-project-journey-posterior.md) | Independent Event Lineage pair criterion and posterior Project Journey | Proposed | active-PR | Strict artifacts preserve criterion/event-time draws, branches, ties, and CPU/GPU receipts without claiming the scientific estimator is complete. | | [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. | +| [0089](0089-export-stored-request-get.md) | Loopback export stored-request GET | Accepted | active-PR | `AnalysisRunLiveService` serves `GET /v1/exports/{export_id}/request` for the stored naruon authorization request; `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. | @@ -142,6 +143,7 @@ Use the narrowest owning ADR when decisions overlap: - **independent lineage criterion and posterior Project Journey:** ADR 0023. - **macOS-native Rust-owned MLX Metal execution:** ADR 0024. - **loopback export retrieval identity:** ADR 0054. +- **loopback export stored-request GET:** ADR 0089. ## Change and supersession rule diff --git a/docs/research/export-stored-request-get.md b/docs/research/export-stored-request-get.md new file mode 100644 index 000000000..04ecd0ab8 --- /dev/null +++ b/docs/research/export-stored-request-get.md @@ -0,0 +1,13 @@ +# Export stored-request GET (doctoring) + +`GET /v1/exports/{export_id}/request` returns one accepted naruon +export-authorization request on `tepp-loopback`. HTTP semantics follow RFC 9110 +(Fielding, Nottingham, & Reschke, 2022). Fail-closed LineageWeave, extra +segments, slash/NUL, leftover bodies, credential flags, and scientific-authority +promotion are repository contract (ADR 0089; ADR 0014). + +`tepp.scientific_acceptance.v1` never appears. HTTP 200 is not a scientific +claim. `NaruonLiveService` stays POST-only. + +Does not re-open cancel lineages, GAP-010 Figma/export, persistence, Leiden, +or an ADR 0014 claim-promotion package.