diff --git a/CHANGELOG.d/export-idempotency-lookup-stored-request-get.md b/CHANGELOG.d/export-idempotency-lookup-stored-request-get.md new file mode 100644 index 000000000..cb8d269af --- /dev/null +++ b/CHANGELOG.d/export-idempotency-lookup-stored-request-get.md @@ -0,0 +1 @@ +- `GET /v1/exports/by-idempotency/{idempotency_key}/request` returns the stored naruon export-authorization request on `tepp-loopback` (ADR 0099). Dual identity of stored-request GET (`export_id`). Zero and ambiguous matches fail closed. `tepp.scientific_acceptance.v1` never appears. 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 8cd946a68..b10e53daf 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -75,6 +75,7 @@ TEPP's approved PRD v0.4 and implementation plan are the primary product baselin | Unicode canonical-identity doctoring | [`docs/research/unicode-canonical-identity.md`](docs/research/unicode-canonical-identity.md) | | Export idempotency-key lookup HTTP doctoring | [`docs/research/export-idempotency-lookup-http.md`](docs/research/export-idempotency-lookup-http.md) | | Export idempotency-key lookup CLI doctoring | [`docs/research/export-idempotency-lookup-cli.md`](docs/research/export-idempotency-lookup-cli.md) | +| Export idempotency-key lookup stored-request GET doctoring | [`docs/research/export-idempotency-lookup-stored-request-http.md`](docs/research/export-idempotency-lookup-stored-request-http.md) | | Change history | [`CHANGELOG.md`](CHANGELOG.md) | ## Maturity vocabulary diff --git a/crates/tepp_api/src/analysis_run_live.rs b/crates/tepp_api/src/analysis_run_live.rs index 9fed9a073..05897734d 100644 --- a/crates/tepp_api/src/analysis_run_live.rs +++ b/crates/tepp_api/src/analysis_run_live.rs @@ -5,6 +5,8 @@ //! boundaries needed by Naruon and `LineageWeave`. Naruon may also POST and //! GET `/v1/exports/{export_id}` for metric-free purpose-bound retrieval //! and `GET /v1/exports/by-idempotency/{idempotency_key}` for key lookup. +//! `GET /v1/exports/by-idempotency/{idempotency_key}/request` returns the stored +//! export-authorization request of that unique accepted export. //! It accepts transport acknowledgements, temporal evidence context, and //! export identities only; completed psychometric results remain outside this //! crate. @@ -18,6 +20,10 @@ use crate::export_idempotency_lookup_http::{ ExportIdempotencyLookup, export_idempotency_lookup_path_key, refuse_metrics_on_export_idempotency_lookup_payload, }; +use crate::export_idempotency_lookup_stored_request_http::{ + export_idempotency_lookup_stored_request_path_key, + refuse_metrics_on_export_lookup_stored_request_payload, +}; use crate::lineageweave_http::{ LINEAGEWEAVE_CONSUMER_CODE, NARUON_CONSUMER_CODE, consumer_is_supported, }; @@ -167,6 +173,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_idempotency_lookup_stored_request_path_key(path), + Ok(_) | Err(ApiError::LimitExceeded) + ) { + return self.lookup_export_stored_request_by_idempotency(path, &headers, body); + } if matches!( export_idempotency_lookup_path_key(path), Ok(_) | Err(ApiError::LimitExceeded) @@ -392,6 +404,40 @@ impl AnalysisRunLiveService { Ok(json_response(200, "OK", response_body)) } + fn lookup_export_stored_request_by_idempotency( + &self, + path: &str, + headers: &HashMap, + body: &str, + ) -> Result { + let idempotency_key = export_idempotency_lookup_stored_request_path_key(path)?; + if !body.trim().is_empty() { + return Err(ApiError::InvalidWirePayload); + } + let consumer = require_headers(headers, self.bound_addr, false)?; + if consumer != NARUON_CONSUMER_CODE { + return Err(ApiError::InvalidWirePayload); + } + refuse_metrics_on_export_lookup_stored_request_payload(body)?; + let prefix = format!("{consumer}\u{1f}"); + let mut matches: Vec<&StoredExport> = self + .authorized_exports + .iter() + .filter(|(replay_key, stored)| { + replay_key.starts_with(&prefix) + && stored.retrieval.idempotency_key == idempotency_key + }) + .map(|(_, stored)| stored) + .collect(); + if matches.len() != 1 { + return Err(ApiError::InvalidWirePayload); + } + let stored = matches.remove(0); + let response_body = crate::wire::to_json(&stored.request)?; + refuse_metrics_on_export_lookup_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; diff --git a/crates/tepp_api/src/export_idempotency_lookup_stored_request_http.rs b/crates/tepp_api/src/export_idempotency_lookup_stored_request_http.rs new file mode 100644 index 000000000..0e71fc5e5 --- /dev/null +++ b/crates/tepp_api/src/export_idempotency_lookup_stored_request_http.rs @@ -0,0 +1,377 @@ +//! Provider-owned export lookup stored-request GET contracts. +//! +//! GAP-003A unique slice: `GET /v1/exports/by-idempotency/{idempotency_key}/request` +//! returns the stored naruon export-authorization request of the unique +//! accepted export that used that client key on `AnalysisRunLiveService` / +//! `tepp-loopback`. Lookup GET returns identity only. Stored-request GET +//! requires `export_id`. Operators who hold a 200 authorization receipt or +//! log key still need two hops. `NaruonLiveService` stays POST-only. +//! `LineageWeave` is refused on this naruon-owned adapter. +//! `tepp.scientific_acceptance.v1` never appears. This module does not +//! duplicate lookup GET/CLI (#465/#466), stored-request GET/CLI (#457/#459), +//! GET-by-id (#411), retrieval CLI (#417), collection GET/CLI (#443/#444), +//! export-authorize CLI (#410), analysis-run lookup (#380), or cancel +//! lineages (closed). Persistence remains GAP-003B. GAP-010 Figma/export +//! remains later work. + +use crate::ApiError; +use crate::export_idempotency_lookup_http::{ + EXPORT_IDEMPOTENCY_LOOKUP_KEY_MAX_LEN, EXPORT_IDEMPOTENCY_LOOKUP_PREFIX, +}; +use crate::naruon_http::{NARUON_EXPORT_PATH, NaruonHttpExchange, compose_https_target}; +use crate::wire::require_nonempty; + +/// Extra-segment that names the stored export-authorization request. +pub const EXPORT_IDEMPOTENCY_LOOKUP_STORED_REQUEST_SEGMENT: &str = "request"; + +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 idempotency key from +/// `GET /v1/exports/by-idempotency/{idempotency_key}/request`. +/// +/// The route is segmented before percent decoding, so an encoded `/` remains +/// data inside one opaque key rather than becoming an extra path segment. +/// +/// # Errors +/// +/// Returns [`ApiError::InvalidWirePayload`] for collection, GET-by-id, lookup +/// without `/request`, `{export_id}/request`, extra raw segments, a missing +/// `by-idempotency` prefix, reserved prefix used as the key, NUL, empty key, +/// or a hostile encoding, and [`ApiError::LimitExceeded`] when oversized. +pub fn export_idempotency_lookup_stored_request_path_key(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_prefix(EXPORT_IDEMPOTENCY_LOOKUP_PREFIX) + .ok_or(ApiError::InvalidWirePayload)?; + let encoded = encoded + .strip_prefix('/') + .ok_or(ApiError::InvalidWirePayload)?; + let (encoded_key, rest) = encoded + .split_once('/') + .ok_or(ApiError::InvalidWirePayload)?; + if rest != EXPORT_IDEMPOTENCY_LOOKUP_STORED_REQUEST_SEGMENT || encoded_key.is_empty() { + return Err(ApiError::InvalidWirePayload); + } + let key = decode_path_segment(encoded_key)?; + require_nonempty(&key)?; + if key == EXPORT_IDEMPOTENCY_LOOKUP_PREFIX { + return Err(ApiError::InvalidWirePayload); + } + if key.contains('\0') { + return Err(ApiError::InvalidWirePayload); + } + if key.len() > EXPORT_IDEMPOTENCY_LOOKUP_KEY_MAX_LEN { + return Err(ApiError::LimitExceeded); + } + Ok(key) +} + +/// Whether `path` is the lookup stored-request extra-segment resource. +#[must_use] +pub fn is_export_idempotency_lookup_stored_request_path(path: &str) -> bool { + export_idempotency_lookup_stored_request_path_key(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_lookup_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 lookup stored-request GET exchange. +/// +/// The builder refuses non-`https` origins and empty or oversized keys. It +/// does not inject credentials. The GET body is empty. The opaque key is +/// percent-encoded into exactly one path segment after `by-idempotency` and +/// before `/request`. +/// +/// # Errors +/// +/// Returns a fail-closed origin or identity error. +pub fn naruon_export_idempotency_lookup_stored_request_exchange( + origin: &str, + idempotency_key: &str, +) -> Result { + require_nonempty(idempotency_key)?; + if idempotency_key == EXPORT_IDEMPOTENCY_LOOKUP_PREFIX || idempotency_key.contains('\0') { + return Err(ApiError::InvalidWirePayload); + } + if idempotency_key.len() > EXPORT_IDEMPOTENCY_LOOKUP_KEY_MAX_LEN { + return Err(ApiError::LimitExceeded); + } + let encoded_key = encode_path_segment(idempotency_key); + let target_path = format!( + "{NARUON_EXPORT_PATH}/{EXPORT_IDEMPOTENCY_LOOKUP_PREFIX}/{encoded_key}/{EXPORT_IDEMPOTENCY_LOOKUP_STORED_REQUEST_SEGMENT}" + ); + 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.is_empty() || 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_IDEMPOTENCY_LOOKUP_STORED_REQUEST_SEGMENT, + export_idempotency_lookup_stored_request_path_key, + is_export_idempotency_lookup_stored_request_path, + naruon_export_idempotency_lookup_stored_request_exchange, + refuse_metrics_on_export_lookup_stored_request_payload, + }; + use crate::ApiError; + use crate::export_http::export_retrieval_path_id; + use crate::export_idempotency_lookup_http::{ + EXPORT_IDEMPOTENCY_LOOKUP_KEY_MAX_LEN, EXPORT_IDEMPOTENCY_LOOKUP_PREFIX, + export_idempotency_lookup_path_key, + }; + + #[test] + fn lookup_stored_request_exchange_is_metric_free_get_without_credentials() { + let exchange = naruon_export_idempotency_lookup_stored_request_exchange( + "https://tepp.example.test", + "idem-9", + ) + .expect("exchange"); + assert_eq!(exchange.method, "GET"); + assert_eq!( + exchange.target_url, + "https://tepp.example.test/v1/exports/by-idempotency/idem-9/request" + ); + assert!(exchange.body.is_empty()); + assert!( + !exchange + .headers + .iter() + .any(|(name, _)| name.contains("authorization") + || name.contains("token") + || name.contains("idempotency")) + ); + assert!(is_export_idempotency_lookup_stored_request_path( + "/v1/exports/by-idempotency/idem-9/request" + )); + assert_eq!( + export_idempotency_lookup_path_key("/v1/exports/by-idempotency/idem-9/request"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + export_retrieval_path_id("/v1/exports/by-idempotency/idem-9/request"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + export_idempotency_lookup_stored_request_path_key( + "/v1/exports/by-idempotency/idem-9/request" + ) + .expect("key"), + "idem-9" + ); + assert_eq!(EXPORT_IDEMPOTENCY_LOOKUP_STORED_REQUEST_SEGMENT, "request"); + assert_eq!(EXPORT_IDEMPOTENCY_LOOKUP_PREFIX, "by-idempotency"); + assert_eq!( + refuse_metrics_on_export_lookup_stored_request_payload(""), + Ok(()) + ); + } + + #[test] + fn lookup_stored_request_path_and_origins_fail_closed() { + assert_eq!( + export_idempotency_lookup_stored_request_path_key("/v1/exports/by-idempotency/idem-9"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + export_idempotency_lookup_stored_request_path_key("/v1/exports/idem-9/request"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + export_idempotency_lookup_stored_request_path_key("/v1/exports/by-idempotency/request"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + export_idempotency_lookup_stored_request_path_key( + "/v1/exports/by-idempotency/idem-9/request/extra" + ), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + export_idempotency_lookup_stored_request_path_key( + "/v1/exports/by-idempotency/idem-9/cancel" + ), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + export_idempotency_lookup_stored_request_path_key( + "/v1/exports/by-idempotency/by-idempotency/request" + ), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + export_idempotency_lookup_stored_request_path_key( + "/v1/exports/by-idempotency/%00/request" + ), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + export_idempotency_lookup_stored_request_path_key(&format!( + "/v1/exports/by-idempotency/{}/request", + "a".repeat(EXPORT_IDEMPOTENCY_LOOKUP_KEY_MAX_LEN + 1) + )), + Err(ApiError::LimitExceeded) + ); + assert_eq!( + naruon_export_idempotency_lookup_stored_request_exchange( + "http://tepp.example.test", + "idem-9", + ), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + naruon_export_idempotency_lookup_stored_request_exchange( + "https://db.postgres.example", + "idem-9", + ), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + naruon_export_idempotency_lookup_stored_request_exchange( + "https://tepp.example.test", + "by-idempotency", + ), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + refuse_metrics_on_export_lookup_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 e169af816..27f6a93b5 100644 --- a/crates/tepp_api/src/lib.rs +++ b/crates/tepp_api/src/lib.rs @@ -23,6 +23,7 @@ mod export; mod export_http; mod export_idempotency_lookup_cli; mod export_idempotency_lookup_http; +mod export_idempotency_lookup_stored_request_http; mod lineage_criterion_anchor; mod lineage_pair_criterion; mod lineageweave_http; @@ -136,6 +137,16 @@ pub use export_idempotency_lookup_cli::loopback_http1_from_export_idempotency_lo pub use export_idempotency_lookup_cli::read_export_idempotency_lookup_cli_stdin; /// Filter lookup CLI stdout so scientific acceptance never appears. pub use export_idempotency_lookup_cli::render_export_idempotency_lookup_cli_stdout; +/// Extra-segment that names the stored create on lookup stored-request GET. +pub use export_idempotency_lookup_stored_request_http::EXPORT_IDEMPOTENCY_LOOKUP_STORED_REQUEST_SEGMENT; +/// Extract the opaque idempotency key from a lookup stored-request path. +pub use export_idempotency_lookup_stored_request_http::export_idempotency_lookup_stored_request_path_key; +/// Whether a path is the lookup stored-request extra-segment resource. +pub use export_idempotency_lookup_stored_request_http::is_export_idempotency_lookup_stored_request_path; +/// Build a naruon lookup stored-request GET exchange. +pub use export_idempotency_lookup_stored_request_http::naruon_export_idempotency_lookup_stored_request_exchange; +/// Refuse scientific-metric keys on lookup stored-request JSON. +pub use export_idempotency_lookup_stored_request_http::refuse_metrics_on_export_lookup_stored_request_payload; /// Analytical export purpose. pub use authorization::AnalyticalPurpose; diff --git a/crates/tepp_api/tests/export_idempotency_lookup_stored_request_http_contract.rs b/crates/tepp_api/tests/export_idempotency_lookup_stored_request_http_contract.rs new file mode 100644 index 000000000..7706af542 --- /dev/null +++ b/crates/tepp_api/tests/export_idempotency_lookup_stored_request_http_contract.rs @@ -0,0 +1,103 @@ +//! Contract tests for export lookup stored-request GET. + +use tepp_api::{ + AnalysisRunLiveService, AnalyticalPurpose, ApiError, ExportAuthorizationRequest, + NaruonLiveService, naruon_export_idempotency_lookup_stored_request_exchange, + refuse_metrics_on_export_lookup_stored_request_payload, +}; + +fn sample_request() -> ExportAuthorizationRequest { + ExportAuthorizationRequest { + tenant_workspace_id: "export-live-tenant".into(), + principal_id: "principal-analyst-1".into(), + purpose: AnalyticalPurpose::ModularServiceConsumer, + artifact_id: "artifact-live-1".into(), + includes_source_text: false, + } +} + +fn export_post_http(body: &str) -> String { + format!( + "POST /v1/exports 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-idem-1\r\ncontent-length: {}\r\n\r\n{body}", + body.len() + ) +} + +#[test] +fn lookup_stored_request_exchange_is_https_get_without_credentials() { + let exchange = naruon_export_idempotency_lookup_stored_request_exchange( + "https://tepp.example.test", + "export-idem-1", + ) + .expect("exchange"); + assert_eq!(exchange.method, "GET"); + assert_eq!( + exchange.target_url, + "https://tepp.example.test/v1/exports/by-idempotency/export-idem-1/request" + ); + assert!(exchange.body.is_empty()); + assert!( + exchange + .headers + .iter() + .any(|(name, value)| name == "tepp-consumer" && value == "naruon") + ); +} + +#[test] +fn live_get_returns_stored_authorization_request() { + let request = sample_request(); + let body = serde_json::to_string(&request).expect("json"); + let mut service = AnalysisRunLiveService::new(); + let posted = service.handle_http_request(&export_post_http(&body)); + assert_eq!(posted.status_code, 200, "{}", posted.body); + let got = service.handle_http_request( + "GET /v1/exports/by-idempotency/export-idem-1/request 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!(got.status_code, 200, "{}", got.body); + assert_eq!( + refuse_metrics_on_export_lookup_stored_request_payload(&got.body), + Ok(()) + ); + assert!(!got.body.contains("tepp.scientific_acceptance.v1")); + assert!(!got.body.contains("rmse")); + assert!(got.body.contains("\"artifact_id\":\"artifact-live-1\"")); + assert!( + got.body + .contains("\"tenant_workspace_id\":\"export-live-tenant\"") + ); + assert_eq!( + service + .handle_http_request( + "GET /v1/exports/by-idempotency/export-idem-1/request HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: lineageweave\r\ntepp-contract-version: 1\r\ncontent-length: 0\r\n\r\n" + ) + .status_code, + 400 + ); + assert_eq!( + service + .handle_http_request( + "GET /v1/exports/by-idempotency/missing/request 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" + ) + .status_code, + 400 + ); + assert_eq!( + service + .handle_http_request( + "GET /v1/exports/by-idempotency/export-idem-1/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: 0\r\n\r\n" + ) + .status_code, + 400 + ); +} + +#[test] +fn naruon_live_service_stays_post_only_for_lookup_stored_request() { + let mut service = NaruonLiveService::new(); + let response = service.handle_http_request( + "GET /v1/exports/by-idempotency/export-idem-1/request 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!(response.status_code, 400); + let _ = ApiError::InvalidWirePayload; +} diff --git a/docs/API_CONTRACT.md b/docs/API_CONTRACT.md index f759e6188..7984f48f7 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). Loopback `GET /v1/exports/by-idempotency/{idempotency_key}` is the executable export lookup route (ADR 0093); `NaruonLiveService` stays POST-only. Published `tepp-export-lookup lookup` mints that GET onto spawned `tepp-loopback` TCP (ADR 0094). +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/by-idempotency/{idempotency_key}` is the executable export lookup route (ADR 0093); `NaruonLiveService` stays POST-only. Published `tepp-export-lookup lookup` mints that GET onto spawned `tepp-loopback` TCP (ADR 0094). Loopback `GET /v1/exports/by-idempotency/{idempotency_key}/request` is the executable lookup stored-request route (ADR 0099). ## 2. Contract families diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index b0d6fc8e1..0102a2529 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/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 | | loopback naruon export idempotency-key lookup GET | ADR 0093; API contract; RFC 9110; ADR 0009/0011/0014/0054 | `tepp_api` `GET /v1/exports/by-idempotency/{idempotency_key}` on `tepp-loopback`; metric-free `export_id` identity; `tepp.scientific_acceptance.v1` never appears; LineageWeave refused; `NaruonLiveService` stays POST-only; does not duplicate GET-by-id, collection, stored-request, or analysis-run lookup | active-PR | | loopback naruon export idempotency-key lookup CLI | ADR 0094; API contract; RFC 9110; ADR 0009/0011/0014/0093 | `tepp_api` published `tepp-export-lookup lookup` mints typed naruon lookup GET onto spawned `tepp-loopback` TCP; metric-free identity stdout; empty stdin admitted; LineageWeave refused; `NaruonLiveService` stays POST-only; does not duplicate lookup GET, GET-by-id, collection, stored-request, or analysis-run lookup CLI | active-PR | +| loopback naruon export idempotency-key lookup stored-request GET | ADR 0099; API contract; RFC 9110; ADR 0009/0011/0014/0093 | `tepp_api` `GET /v1/exports/by-idempotency/{idempotency_key}/request` on `tepp-loopback`; stored export-authorization request from client key; empty body; 0 and >1 matches fail closed; `tepp.scientific_acceptance.v1` never appears; LineageWeave refused; `NaruonLiveService` stays POST-only | 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/0099-export-idempotency-lookup-stored-request-get.md b/docs/adr/0099-export-idempotency-lookup-stored-request-get.md new file mode 100644 index 000000000..0c903489a --- /dev/null +++ b/docs/adr/0099-export-idempotency-lookup-stored-request-get.md @@ -0,0 +1,96 @@ +# ADR 0099 — Loopback export idempotency-key lookup stored-request GET + +**Decision status:** Accepted +**Implementation maturity:** active-PR +**Date:** 2026-09-01 +**Supersedes:** None; complements ADR 0093 and ADR 0089. Does not re-open +cancel lineages. Does not supersede ADR 0014. Unique versus protected main; +0026–0098 occupied including #470=0098, #469=0097, #466=0093+0094. +**Figma File ID:** N/A — this increment changes a Rust service crate and has no +user-interface surface. +**Storybook inventory:** N/A — no reusable web object or interaction changed. + +## Context + +ADR 0093 publishes `GET /v1/exports/by-idempotency/{idempotency_key}` as the +metric-free identity. Stored-request GET (`GET /v1/exports/{export_id}/request`) +is the client-id extra-segment on a parallel stack. Operators who hold a 200 +authorization receipt or log key still need two hops (lookup identity, then +stored-request by `export_id`) to recover the create. Reuse of +`{export_id}/request` with the idempotency key as the id would collide with +server-id stored-request. Cancel extra-segment stays refused. + +## Decision + +`AnalysisRunLiveService` serves +`GET /v1/exports/by-idempotency/{idempotency_key}/request` on loopback: + +- The payload is the stored naruon export-authorization request. + `tepp.scientific_acceptance.v1` never appears. +- Lookup stored-request is consumer-scoped to naruon. Zero matches and more + than one match fail closed (no tenant oracle). LineageWeave is refused. +- Empty GET bodies only. Query strings, lookup without `/request`, + `{export_id}/request`, GET-by-id, POST `/by-idempotency/.../request`, + collection GET, reserved `by-idempotency` as a key, slash/NUL, cancel + extra-segment, and nonempty bodies fail closed. +- Dispatch order: lookup stored-request `by-idempotency/{key}/request` → + lookup by-idempotency → GET-by-id. +- `NaruonLiveService` stays POST-only. Unknown keys fail closed. Persistence + remains GAP-003B. + +## Non-goals + +- Production TLS, public bind, or durable export storage. +- Leiden community detection, Driver p.16 std-family restoration, or + Figma/export work (GAP-010). +- Promoting an ADR 0014 scientific claim from HTTP success. +- Duplicating lookup GET/CLI (#465/#466), stored-request GET/CLI (#457/#459), + GET-by-id (#411), retrieval CLI (#417), collection GET/CLI (#443/#444), + export-authorize CLI (#410), analysis-run lookup (#380), or cancel lineages + (closed). +- Adding GET to `NaruonLiveService`. Opening LineageWeave on this naruon-owned + adapter. + +## Alternatives considered + +1. Ask operators to hop lookup then `{export_id}/request` — rejected because a + 200 receipt already carries the client key. +2. Reuse `{export_id}/request` with the key as the id — rejected because ADR + 0089 owns server-id stored-request. +3. Return identity JSON on `/request` — rejected because that is ADR 0093. +4. Metric-free lookup stored-request GET on loopback — accepted. + +## Consequences + +Operators can recover the stored create from an authorization key without a +second hop. HTTP 200 is not measurement evidence. + +## Failure and recovery + +Unknown keys, extra path segments, lookup without `/request`, `{export_id}/request`, +query strings, nonempty bodies, POST, metric keys, LineageWeave, unpublished +consumers, consumer mismatch, ambiguous multi-match, reserved prefix-as-key, +slash/NUL, cancel extra-segment, and non-loopback hosts return a redacted `400` +envelope. Oversized keys return `413`. Credential headers remain `403`. + +## Verification + +- GET lookup stored-request JSON has no RMSE/scientific-acceptance keys; +- GET of an authorized key returns the matching stored create `artifact_id`; +- `{export_id}/request`, lookup without `/request`, LineageWeave, cancel + extra-segment, reserved prefix, and unknown keys fail closed; +- Clippy `-D warnings`, `tepp_api` tests, rustdoc, and exact-head review remain + required. + +## Rollback and supersession + +Rollback removes lookup stored-request dispatch; lookup GET, GET-by-id, and +POST remain valid. A superseding ADR is required to persist the registry, bind +a public address, emit scientific-acceptance, open LineageWeave, add GET to +`NaruonLiveService`, re-open cancel lineages, or treat HTTP success as an +ADR 0014 claim. + +## Related authority + +ADR 0093, ADR 0089, ADR 0054, ADR 0014, RFC 9110 (Fielding, Nottingham, & +Reschke, 2022). diff --git a/docs/adr/README.md b/docs/adr/README.md index 7768eb4a1..ed902c49f 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -33,6 +33,7 @@ Read [`ADR_POLICY.md`](ADR_POLICY.md) first. **Decision status and implementatio | [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. | | [0093](0093-export-idempotency-lookup-get.md) | Loopback export idempotency-key lookup GET | Accepted | active-PR | `AnalysisRunLiveService` serves naruon-only `GET /v1/exports/by-idempotency/{idempotency_key}`; `NaruonLiveService` stays POST-only. | | [0094](0094-export-idempotency-lookup-cli.md) | Loopback export idempotency-key lookup CLI | Accepted | active-PR | Published `tepp-export-lookup lookup` mints naruon lookup GET onto spawned `tepp-loopback` TCP; `NaruonLiveService` stays POST-only. | +| [0099](0099-export-idempotency-lookup-stored-request-get.md) | Loopback export idempotency-key lookup stored-request GET | Accepted | active-PR | Complements ADR 0093 and ADR 0089; `GET /v1/exports/by-idempotency/{idempotency_key}/request` returns the stored create. Unique versus protected main (0026–0098 occupied including #470=0098). Does not re-open cancel lineages. | | [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/export-idempotency-lookup-stored-request-http.md b/docs/research/export-idempotency-lookup-stored-request-http.md new file mode 100644 index 000000000..65cc143ba --- /dev/null +++ b/docs/research/export-idempotency-lookup-stored-request-http.md @@ -0,0 +1,16 @@ +# Export idempotency-key lookup stored-request GET (doctoring) + +`GET /v1/exports/by-idempotency/{idempotency_key}/request` returns the stored +naruon export-authorization request on `tepp-loopback`. HTTP semantics follow +RFC 9110 (Fielding, Nottingham, & Reschke, 2022). Fail-closed unpublished +consumers, extra segments, slash/NUL, reserved prefix, zero or ambiguous +matches, credential flags, cancel extra-segment, and scientific-authority +promotion are repository contract (ADR 0099; ADR 0014). + +`tepp.scientific_acceptance.v1` never appears. HTTP 200 is not a scientific +claim. `NaruonLiveService` stays POST-only. LineageWeave is refused. + +Does not re-open cancel lineages, GAP-010 Figma/export, persistence, Leiden, +or an ADR 0014 claim-promotion package. Dual identity of stored-request GET +(`export_id`) versus this lookup (`idempotency_key`). Not a duplicate of +lookup GET (#466) or of `{export_id}/request` (#459).