From 515fd3b8c37e4937fced7e53b3cbdefea1dc2aba Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 14:30:13 +0000 Subject: [PATCH 1/4] feat(api): resolve export identity by idempotency key on loopback GAP-003A unique slice: AnalysisRunLiveService serves naruon-only GET /v1/exports/by-idempotency/{idempotency_key} as a metric-free export_id lookup. NaruonLiveService stays POST-only. ADR 0093. --- CHANGELOG.d/export-idempotency-lookup-http.md | 1 + DOCUMENTATION.md | 1 + crates/tepp_api/src/analysis_run_live.rs | 140 ++++- crates/tepp_api/src/export_http.rs | 7 + .../src/export_idempotency_lookup_http.rs | 513 ++++++++++++++++++ crates/tepp_api/src/lib.rs | 13 + ...export_idempotency_lookup_http_contract.rs | 93 ++++ docs/API_CONTRACT.md | 3 +- docs/TRACEABILITY.md | 1 + .../adr/0093-export-idempotency-lookup-get.md | 127 +++++ docs/adr/README.md | 2 + .../export-idempotency-lookup-http.md | 34 ++ 12 files changed, 933 insertions(+), 2 deletions(-) create mode 100644 CHANGELOG.d/export-idempotency-lookup-http.md create mode 100644 crates/tepp_api/src/export_idempotency_lookup_http.rs create mode 100644 crates/tepp_api/tests/export_idempotency_lookup_http_contract.rs create mode 100644 docs/adr/0093-export-idempotency-lookup-get.md create mode 100644 docs/research/export-idempotency-lookup-http.md diff --git a/CHANGELOG.d/export-idempotency-lookup-http.md b/CHANGELOG.d/export-idempotency-lookup-http.md new file mode 100644 index 000000000..30743f865 --- /dev/null +++ b/CHANGELOG.d/export-idempotency-lookup-http.md @@ -0,0 +1 @@ +- `tepp_api` loopback `GET /v1/exports/by-idempotency/{idempotency_key}` returns the metric-free identity of the unique naruon export that used that key on `AnalysisRunLiveService`, so operators can jump from a 200 authorization receipt to `export_id` without scanning identities (ADR 0093). `NaruonLiveService` stays POST-only. LineageWeave is refused. Not GET-by-id, not collection GET, not stored-request GET, not analysis-run lookup, not cancel, not GAP-010 Figma/export, not persistence. diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 6fa4b9683..2747e9cd7 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -73,6 +73,7 @@ TEPP's approved PRD v0.4 and implementation plan are the primary product baselin | Analysis engine gap-closure doctoring | [`docs/doctoring/analysis-engine-gap-closure.md`](docs/doctoring/analysis-engine-gap-closure.md) | | Corpus-split leakage-audit wire doctoring | [`docs/research/corpus-split-manifest-wire.md`](docs/research/corpus-split-manifest-wire.md) | | 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) | | 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 a5f1f9f93..9fed9a073 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/by-idempotency/{idempotency_key}` for key lookup. //! It accepts transport acknowledgements, temporal evidence context, and //! export identities only; completed psychometric results remain outside this //! crate. @@ -13,6 +14,10 @@ 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_idempotency_lookup_http::{ + ExportIdempotencyLookup, export_idempotency_lookup_path_key, + refuse_metrics_on_export_idempotency_lookup_payload, +}; use crate::lineageweave_http::{ LINEAGEWEAVE_CONSUMER_CODE, NARUON_CONSUMER_CODE, consumer_is_supported, }; @@ -162,6 +167,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_path_key(path), + Ok(_) | Err(ApiError::LimitExceeded) + ) { + return self.lookup_export_by_idempotency(path, &headers, body); + } if matches!( export_retrieval_path_id(path), Ok(_) | Err(ApiError::LimitExceeded) @@ -342,6 +353,45 @@ impl AnalysisRunLiveService { Ok(json_response(200, "OK", response_body)) } + fn lookup_export_by_idempotency( + &self, + path: &str, + headers: &HashMap, + body: &str, + ) -> Result { + let idempotency_key = export_idempotency_lookup_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_idempotency_lookup_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 payload = ExportIdempotencyLookup::new( + stored.retrieval.export_id.clone(), + stored.retrieval.decision_code.clone(), + stored.retrieval.idempotency_key.clone(), + )?; + let response_body = payload.to_json()?; + refuse_metrics_on_export_idempotency_lookup_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; @@ -1202,6 +1252,77 @@ mod tests { 400 ); + let looked_up = service.handle_http_request(&export_lookup_http( + "export-idem-1", + NARUON_CONSUMER_CODE, + )); + assert_eq!(looked_up.status_code, 200); + let lookup = crate::ExportIdempotencyLookup::from_json(&looked_up.body).expect("lookup"); + assert_eq!(lookup.export_id, retrieval.export_id); + assert_eq!(lookup.idempotency_key, "export-idem-1"); + assert_eq!(lookup.decision_code, "purpose_bound_export_allowed"); + assert!(!looked_up.body.contains("tenant_workspace_id")); + assert!(!looked_up.body.contains("principal_id")); + assert!(!looked_up.body.contains("includes_source_text")); + assert!(!looked_up.body.contains("scientific_acceptance")); + assert!(!looked_up.body.contains("rmse")); + assert_eq!( + service + .handle_http_request(&export_lookup_http( + "export-idem-1", + LINEAGEWEAVE_CONSUMER_CODE + )) + .status_code, + 400 + ); + assert_eq!( + service + .handle_http_request(&export_lookup_http("missing-key", NARUON_CONSUMER_CODE)) + .status_code, + 400 + ); + assert_eq!( + service + .handle_http_request(&export_lookup_body_http( + "export-idem-1", + NARUON_CONSUMER_CODE, + "{}", + )) + .status_code, + 400 + ); + assert_eq!( + service + .handle_http_request(&export_lookup_post_http( + "export-idem-1", + NARUON_CONSUMER_CODE, + )) + .status_code, + 400 + ); + assert_eq!( + service + .handle_http_request(&export_get_http("by-idempotency", NARUON_CONSUMER_CODE)) + .status_code, + 400 + ); + + let mut other_tenant = request.clone(); + other_tenant.tenant_workspace_id = "export-live-tenant-b".into(); + let other_body = crate::wire::to_json(&other_tenant).expect("other json"); + let other_posted = service.handle_http_request(&export_post_http( + &other_body, + NARUON_CONSUMER_CODE, + "export-idem-1", + )); + assert_eq!(other_posted.status_code, 200); + assert_eq!( + service + .handle_http_request(&export_lookup_http("export-idem-1", NARUON_CONSUMER_CODE)) + .status_code, + 400 + ); + let principal_as_key = service.handle_http_request(&export_post_http( &body, NARUON_CONSUMER_CODE, @@ -1233,6 +1354,23 @@ mod tests { ) } + fn export_lookup_http(idempotency_key: &str, consumer: &str) -> String { + export_lookup_body_http(idempotency_key, consumer, "") + } + + fn export_lookup_body_http(idempotency_key: &str, consumer: &str, body: &str) -> String { + format!( + "GET {NARUON_EXPORT_PATH}/by-idempotency/{idempotency_key} HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: {consumer}\r\ntepp-contract-version: 1\r\ncontent-length: {}\r\n\r\n{body}", + body.len() + ) + } + + fn export_lookup_post_http(idempotency_key: &str, consumer: &str) -> String { + format!( + "POST {NARUON_EXPORT_PATH}/by-idempotency/{idempotency_key} 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: 0\r\n\r\n" + ) + } + struct ScriptedRead { reader: Cursor>, first_error: Option, diff --git a/crates/tepp_api/src/export_http.rs b/crates/tepp_api/src/export_http.rs index 36e986072..561c937b1 100644 --- a/crates/tepp_api/src/export_http.rs +++ b/crates/tepp_api/src/export_http.rs @@ -216,6 +216,9 @@ pub(crate) fn export_retrieval_path_id(path: &str) -> Result { return Err(ApiError::InvalidWirePayload); } let export_id = decode_path_segment(encoded)?; + if export_id == "by-idempotency" { + return Err(ApiError::InvalidWirePayload); + } if export_id.len() > EXPORT_RETRIEVAL_ID_MAX_LEN { return Err(ApiError::LimitExceeded); } @@ -456,6 +459,10 @@ mod tests { export_retrieval_path_id("/v1/exports/a/b"), Err(ApiError::InvalidWirePayload) ); + assert_eq!( + export_retrieval_path_id("/v1/exports/by-idempotency"), + Err(ApiError::InvalidWirePayload) + ); assert_eq!( export_retrieval_path_id("/v1/exports/%"), Err(ApiError::InvalidWirePayload) diff --git a/crates/tepp_api/src/export_idempotency_lookup_http.rs b/crates/tepp_api/src/export_idempotency_lookup_http.rs new file mode 100644 index 000000000..f5a670bb6 --- /dev/null +++ b/crates/tepp_api/src/export_idempotency_lookup_http.rs @@ -0,0 +1,513 @@ +//! Provider-owned export idempotency-key lookup GET contracts. +//! +//! GAP-003A unique slice: `GET /v1/exports/by-idempotency/{idempotency_key}` +//! returns the metric-free identity of the unique naruon export that used that +//! idempotency key on `AnalysisRunLiveService` / `tepp-loopback`. Retrieval GET +//! requires an `export_id`. Collection GET is a different stack. Operators who +//! hold a 200 authorization receipt or log key cannot jump to that export +//! without scanning identities. `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), stored-request GET/CLI (#457/#459), export-authorize CLI +//! (#410), analysis-run lookup GET (#380), or cancel lineages (closed). +//! Persistence remains GAP-003B. GAP-010 Figma/export remains later work. + +use crate::export_http::{EXPORT_RETRIEVAL_ALLOWED_DECISION_CODE, EXPORT_RETRIEVAL_ID_MAX_LEN}; +use crate::naruon_http::{NARUON_EXPORT_PATH, NaruonHttpExchange, compose_https_target}; +use crate::wire::{ + from_json, require_byte_limit, require_contract_version, require_nonempty, to_json, +}; +use crate::{ApiError, DEFAULT_ANALYSIS_RUN_BYTE_LIMIT}; +use serde::{Deserialize, Serialize}; + +/// Maximum length accepted for an opaque idempotency key in the lookup path. +pub const EXPORT_IDEMPOTENCY_LOOKUP_KEY_MAX_LEN: usize = EXPORT_RETRIEVAL_ID_MAX_LEN; + +/// Supported export idempotency-lookup contract version. +pub const EXPORT_IDEMPOTENCY_LOOKUP_CONTRACT_VERSION: u16 = 1; + +/// Reserved collection-relative prefix that names the lookup resource. +pub const EXPORT_IDEMPOTENCY_LOOKUP_PREFIX: &str = "by-idempotency"; + +const FORBIDDEN_EXPORT_LOOKUP_KEYS: [&str; 16] = [ + "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", + "tenant_workspace_id", + "principal_id", + "includes_source_text", +]; + +/// Metric-free identity of one authorized export found by idempotency key. +/// +/// Operators jump from a 200 authorization receipt or log key to the durable +/// `export_id` without scanning a collection. The payload never carries a +/// terminal result, source body, or scientific-acceptance artifact. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ExportIdempotencyLookup { + /// Semantic contract version for this payload family. + pub contract_version: u16, + /// Opaque server-assigned export identity. + pub export_id: String, + /// Stable machine-readable authorization decision code. + pub decision_code: String, + /// Exact per-export idempotency key that selected this identity. + pub idempotency_key: String, +} + +impl ExportIdempotencyLookup { + /// Construct a validated metric-free export idempotency-lookup payload. + /// + /// # Errors + /// + /// Returns a fail-closed error for empty identities, an oversized + /// identity, an unsupported contract version, or a decision other than + /// purpose-bound export allowed. + pub fn new( + export_id: impl Into, + decision_code: impl Into, + idempotency_key: impl Into, + ) -> Result { + let lookup = Self { + contract_version: EXPORT_IDEMPOTENCY_LOOKUP_CONTRACT_VERSION, + export_id: export_id.into(), + decision_code: decision_code.into(), + idempotency_key: idempotency_key.into(), + }; + lookup.validate()?; + Ok(lookup) + } + + /// Parse and validate an export lookup payload with the default byte limit. + /// + /// # Errors + /// + /// Returns wire, version, limit, metric-key, or field-validation errors. + pub fn from_json(payload: &str) -> Result { + Self::from_json_with_limit(payload, DEFAULT_ANALYSIS_RUN_BYTE_LIMIT) + } + + /// Parse and validate an export lookup payload with a caller-supplied limit. + /// + /// # Errors + /// + /// Returns wire, version, limit, metric-key, or field-validation errors. + pub fn from_json_with_limit(payload: &str, maximum_bytes: usize) -> Result { + require_byte_limit(payload, maximum_bytes)?; + refuse_metrics_on_export_idempotency_lookup_payload(payload)?; + let lookup: Self = from_json(payload)?; + lookup.validate()?; + Ok(lookup) + } + + /// Serialize this lookup payload after complete validation. + /// + /// # Errors + /// + /// Returns validation or serialization errors. + pub fn to_json(&self) -> Result { + self.validate()?; + let payload = to_json(self)?; + require_byte_limit(&payload, DEFAULT_ANALYSIS_RUN_BYTE_LIMIT)?; + refuse_metrics_on_export_idempotency_lookup_payload(&payload)?; + Ok(payload) + } + + fn validate(&self) -> Result<(), ApiError> { + require_contract_version( + self.contract_version, + EXPORT_IDEMPOTENCY_LOOKUP_CONTRACT_VERSION, + )?; + require_nonempty(&self.export_id)?; + require_nonempty(&self.decision_code)?; + require_nonempty(&self.idempotency_key)?; + if self.decision_code != EXPORT_RETRIEVAL_ALLOWED_DECISION_CODE { + return Err(ApiError::AuthorizationDenied); + } + if self.export_id.contains('/') + || self.export_id.contains('\0') + || self.idempotency_key.contains('/') + || self.idempotency_key.contains('\0') + { + return Err(ApiError::InvalidWirePayload); + } + if self.export_id == EXPORT_IDEMPOTENCY_LOOKUP_PREFIX + || self.idempotency_key == EXPORT_IDEMPOTENCY_LOOKUP_PREFIX + { + return Err(ApiError::InvalidWirePayload); + } + if self.export_id.len() > EXPORT_IDEMPOTENCY_LOOKUP_KEY_MAX_LEN + || self.idempotency_key.len() > EXPORT_IDEMPOTENCY_LOOKUP_KEY_MAX_LEN + { + return Err(ApiError::LimitExceeded); + } + Ok(()) + } +} + +/// Refuse export-lookup JSON that already carries scientific-metric keys. +/// +/// Empty payloads are admitted for the GET request body. Non-object JSON +/// fails closed as invalid wire. +/// +/// # Errors +/// +/// Returns [`ApiError::InvalidWirePayload`] when a forbidden metric key is +/// present or the payload is a non-empty non-object. +pub fn refuse_metrics_on_export_idempotency_lookup_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)?; + let Some(object) = value.as_object() else { + return Err(ApiError::InvalidWirePayload); + }; + if FORBIDDEN_EXPORT_LOOKUP_KEYS + .iter() + .any(|key| object.contains_key(*key)) + { + return Err(ApiError::InvalidWirePayload); + } + Ok(()) +} + +/// Extract the opaque idempotency key from +/// `GET /v1/exports/by-idempotency/{key}`. +/// +/// # Errors +/// +/// Returns [`ApiError::InvalidWirePayload`] for a collection path, GET-by-id, +/// extra segments, a missing `by-idempotency` prefix, stored-request `/request` +/// suffix, a reserved prefix used as the key, or a hostile encoding, and +/// [`ApiError::LimitExceeded`] when the decoded key exceeds +/// [`EXPORT_IDEMPOTENCY_LOOKUP_KEY_MAX_LEN`]. +pub(crate) fn export_idempotency_lookup_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)?; + if encoded.is_empty() || encoded.contains('/') { + return Err(ApiError::InvalidWirePayload); + } + let key = decode_path_segment(encoded)?; + require_nonempty(&key)?; + if key == EXPORT_IDEMPOTENCY_LOOKUP_PREFIX { + return Err(ApiError::InvalidWirePayload); + } + if key.len() > EXPORT_IDEMPOTENCY_LOOKUP_KEY_MAX_LEN { + return Err(ApiError::LimitExceeded); + } + Ok(key) +} + +/// Build a provider-owned `GET` export idempotency-lookup exchange. +/// +/// The builder refuses non-`https` origins and empty or oversized keys. It +/// does not inject credentials. The GET body is empty. The key travels in +/// the path; the builder does not send an `idempotency-key` header. +/// +/// # Errors +/// +/// Returns [`ApiError::InvalidWirePayload`] for a non-`https` origin or empty +/// key, and [`ApiError::LimitExceeded`] when the key exceeds +/// [`EXPORT_IDEMPOTENCY_LOOKUP_KEY_MAX_LEN`] bytes. +pub fn naruon_export_idempotency_lookup_exchange( + origin: &str, + idempotency_key: &str, +) -> Result { + require_nonempty(idempotency_key)?; + if idempotency_key == EXPORT_IDEMPOTENCY_LOOKUP_PREFIX { + 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}"); + 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('/') || 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::*; + + fn sample_lookup() -> ExportIdempotencyLookup { + ExportIdempotencyLookup::new("export-1", EXPORT_RETRIEVAL_ALLOWED_DECISION_CODE, "idem-1") + .expect("lookup") + } + + #[test] + fn export_idempotency_lookup_round_trips_and_refuses_hostile_shapes() { + let lookup = sample_lookup(); + let json = lookup.to_json().expect("json"); + assert_eq!( + ExportIdempotencyLookup::from_json(&json).expect("decode"), + lookup + ); + assert!(!json.contains("rmse")); + assert!(!json.contains("scientific_acceptance")); + assert!(!json.contains("terminal_result")); + assert!(!json.contains("tenant_workspace_id")); + assert!(!json.contains("principal_id")); + assert!(!json.contains("includes_source_text")); + assert!(!json.contains("artifact_id")); + + assert_eq!( + ExportIdempotencyLookup::new("", EXPORT_RETRIEVAL_ALLOWED_DECISION_CODE, "idem-1"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + ExportIdempotencyLookup::new("export-1", EXPORT_RETRIEVAL_ALLOWED_DECISION_CODE, ""), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + ExportIdempotencyLookup::new("export-1", "denied", "idem-1"), + Err(ApiError::AuthorizationDenied) + ); + assert_eq!( + ExportIdempotencyLookup::new( + "a".repeat(EXPORT_IDEMPOTENCY_LOOKUP_KEY_MAX_LEN + 1), + EXPORT_RETRIEVAL_ALLOWED_DECISION_CODE, + "idem-1", + ), + Err(ApiError::LimitExceeded) + ); + assert_eq!( + ExportIdempotencyLookup::new( + "export-1", + EXPORT_RETRIEVAL_ALLOWED_DECISION_CODE, + "a".repeat(EXPORT_IDEMPOTENCY_LOOKUP_KEY_MAX_LEN + 1), + ), + Err(ApiError::LimitExceeded) + ); + assert_eq!( + ExportIdempotencyLookup::new( + EXPORT_IDEMPOTENCY_LOOKUP_PREFIX, + EXPORT_RETRIEVAL_ALLOWED_DECISION_CODE, + "idem-1", + ), + Err(ApiError::InvalidWirePayload) + ); + + let mut unsupported = lookup.clone(); + unsupported.contract_version = 9; + assert_eq!( + unsupported.to_json(), + Err(ApiError::UnsupportedContractVersion) + ); + assert_eq!( + ExportIdempotencyLookup::from_json( + r#"{"contract_version":9,"export_id":"export-1","decision_code":"purpose_bound_export_allowed","idempotency_key":"idem-1"}"# + ), + Err(ApiError::UnsupportedContractVersion) + ); + assert_eq!( + ExportIdempotencyLookup::from_json( + r#"{"contract_version":1,"export_id":"export-1","decision_code":"purpose_bound_export_allowed","idempotency_key":"idem-1","extra":true}"# + ), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + ExportIdempotencyLookup::from_json_with_limit(&json, 8), + Err(ApiError::LimitExceeded) + ); + assert_eq!( + ExportIdempotencyLookup::from_json("[1,2,3]"), + Err(ApiError::InvalidWirePayload) + ); + } + + #[test] + fn export_idempotency_lookup_payloads_refuse_scientific_metric_keys() { + assert_eq!( + refuse_metrics_on_export_idempotency_lookup_payload(""), + Ok(()) + ); + assert_eq!( + refuse_metrics_on_export_idempotency_lookup_payload(" "), + Ok(()) + ); + assert_eq!( + refuse_metrics_on_export_idempotency_lookup_payload(r#"{"export_id":"e"}"#), + Ok(()) + ); + for key in FORBIDDEN_EXPORT_LOOKUP_KEYS { + let payload = format!(r#"{{"{key}":1,"export_id":"e"}}"#); + assert_eq!( + refuse_metrics_on_export_idempotency_lookup_payload(&payload), + Err(ApiError::InvalidWirePayload), + "key={key}" + ); + } + assert_eq!( + refuse_metrics_on_export_idempotency_lookup_payload("[true]"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + refuse_metrics_on_export_idempotency_lookup_payload("null"), + Err(ApiError::InvalidWirePayload) + ); + } + + #[test] + fn export_idempotency_lookup_path_decodes_keys_and_refuses_hostile_segments() { + assert_eq!( + export_idempotency_lookup_path_key("/v1/exports/by-idempotency/idem-1").expect("plain"), + "idem-1" + ); + assert_eq!( + export_idempotency_lookup_path_key("/v1/exports/by-idempotency/key%2dabc") + .expect("lower"), + "key-abc" + ); + assert_eq!( + export_idempotency_lookup_path_key("/v1/exports/by-idempotency/key%2Dabc") + .expect("upper"), + "key-abc" + ); + assert_eq!( + export_idempotency_lookup_path_key("/v1/exports"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + export_idempotency_lookup_path_key("/v1/exports/export-1"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + export_idempotency_lookup_path_key("/v1/exports/by-idempotency"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + export_idempotency_lookup_path_key("/v1/exports/by-idempotency/"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + export_idempotency_lookup_path_key("/v1/exports/export-1/request"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + export_idempotency_lookup_path_key("/v1/exports/export-1/cancel"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + export_idempotency_lookup_path_key("/v1/analysis-runs/by-idempotency/idem-1"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + export_idempotency_lookup_path_key("/v1/exports/by-idempotency/a/b"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + export_idempotency_lookup_path_key("/v1/exports/by-idempotency/%2F"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + export_idempotency_lookup_path_key("/v1/exports/by-idempotency/%00"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + export_idempotency_lookup_path_key("/v1/exports/by-idempotency/by-idempotency"), + Err(ApiError::InvalidWirePayload) + ); + let oversized = format!( + "/v1/exports/by-idempotency/{}", + "a".repeat(EXPORT_IDEMPOTENCY_LOOKUP_KEY_MAX_LEN + 1) + ); + assert_eq!( + export_idempotency_lookup_path_key(&oversized), + Err(ApiError::LimitExceeded) + ); + assert_eq!(decode_path_segment(""), Err(ApiError::InvalidWirePayload)); + assert_eq!(from_hex(b'0'), Ok(0)); + assert_eq!(from_hex(b'a'), Ok(10)); + assert_eq!(from_hex(b'F'), Ok(15)); + } +} diff --git a/crates/tepp_api/src/lib.rs b/crates/tepp_api/src/lib.rs index bd8a933e0..fd25065e0 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_idempotency_lookup_http; mod lineage_criterion_anchor; mod lineage_pair_criterion; mod lineageweave_http; @@ -106,6 +107,18 @@ 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; +/// Export idempotency-lookup contract version constant. +pub use export_idempotency_lookup_http::EXPORT_IDEMPOTENCY_LOOKUP_CONTRACT_VERSION; +/// Maximum export idempotency-key length on the lookup path. +pub use export_idempotency_lookup_http::EXPORT_IDEMPOTENCY_LOOKUP_KEY_MAX_LEN; +/// Reserved lookup path prefix. +pub use export_idempotency_lookup_http::EXPORT_IDEMPOTENCY_LOOKUP_PREFIX; +/// Metric-free export identity found by idempotency key. +pub use export_idempotency_lookup_http::ExportIdempotencyLookup; +/// Build a naruon export idempotency-lookup GET exchange. +pub use export_idempotency_lookup_http::naruon_export_idempotency_lookup_exchange; +/// Refuse scientific-metric keys on export lookup JSON. +pub use export_idempotency_lookup_http::refuse_metrics_on_export_idempotency_lookup_payload; /// Analytical export purpose. pub use authorization::AnalyticalPurpose; diff --git a/crates/tepp_api/tests/export_idempotency_lookup_http_contract.rs b/crates/tepp_api/tests/export_idempotency_lookup_http_contract.rs new file mode 100644 index 000000000..f19efefc5 --- /dev/null +++ b/crates/tepp_api/tests/export_idempotency_lookup_http_contract.rs @@ -0,0 +1,93 @@ +//! Contract tests for the export idempotency-key lookup GET exchange. + +use tepp_api::{ + ApiError, EXPORT_IDEMPOTENCY_LOOKUP_CONTRACT_VERSION, EXPORT_IDEMPOTENCY_LOOKUP_KEY_MAX_LEN, + ExportIdempotencyLookup, NaruonLiveService, naruon_export_idempotency_lookup_exchange, + refuse_metrics_on_export_idempotency_lookup_payload, +}; + +#[test] +fn export_idempotency_lookup_exchange_is_https_get_without_credentials_or_metrics() { + let exchange = naruon_export_idempotency_lookup_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" + ); + assert!(exchange.body.is_empty()); + assert!( + exchange + .headers + .iter() + .any(|(name, value)| name == "tepp-consumer" && value == "naruon") + ); + assert!( + !exchange + .headers + .iter() + .any(|(name, _)| name.contains("authorization") + || name.contains("token") + || name.contains("copilot") + || name.contains("idempotency")) + ); + let lookup = ExportIdempotencyLookup::new("export-9", "purpose_bound_export_allowed", "idem-9") + .expect("lookup"); + assert_eq!( + lookup.contract_version, + EXPORT_IDEMPOTENCY_LOOKUP_CONTRACT_VERSION + ); + let json = lookup.to_json().expect("json"); + assert_eq!( + refuse_metrics_on_export_idempotency_lookup_payload(&json), + Ok(()) + ); + assert!(!json.contains("scientific_acceptance")); + assert!(!json.contains("tenant_workspace_id")); + assert!(!json.contains("principal_id")); + assert!(!json.contains("includes_source_text")); + assert!(!json.contains("terminal_result")); +} + +#[test] +fn export_idempotency_lookup_contract_refuses_table_access_and_metric_keys() { + for origin in [ + "http://tepp.example.test", + "https://db.postgres.example", + "https://jdbc.example", + ] { + assert_eq!( + naruon_export_idempotency_lookup_exchange(origin, "idem-9"), + Err(ApiError::InvalidWirePayload), + "origin={origin}" + ); + } + assert_eq!( + naruon_export_idempotency_lookup_exchange( + "https://tepp.example.test", + &"a".repeat(EXPORT_IDEMPOTENCY_LOOKUP_KEY_MAX_LEN + 1) + ), + Err(ApiError::LimitExceeded) + ); + assert_eq!( + naruon_export_idempotency_lookup_exchange("https://tepp.example.test", "by-idempotency"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + refuse_metrics_on_export_idempotency_lookup_payload(r#"{"rmse":1.0}"#), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + refuse_metrics_on_export_idempotency_lookup_payload(r#"{"scientific_acceptance":{}}"#), + Err(ApiError::InvalidWirePayload) + ); +} + +#[test] +fn naruon_live_service_stays_post_only_for_export_lookup() { + let mut service = NaruonLiveService::new(); + let response = service.handle_http_request( + "GET /v1/exports/by-idempotency/idem-a 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); +} diff --git a/docs/API_CONTRACT.md b/docs/API_CONTRACT.md index 1142e99fe..4082b5ba9 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/by-idempotency/{idempotency_key}` is the executable export lookup route (ADR 0093); `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/by-idempotency/{idempotency_key} ``` 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..04fa95118 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -53,6 +53,7 @@ The full APA 7th standards/literature register remains `docs/research/standards- | 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 | +| 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 | | 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/0093-export-idempotency-lookup-get.md b/docs/adr/0093-export-idempotency-lookup-get.md new file mode 100644 index 000000000..08dda8973 --- /dev/null +++ b/docs/adr/0093-export-idempotency-lookup-get.md @@ -0,0 +1,127 @@ +# ADR 0093 — Loopback export idempotency-key lookup GET + +**Decision status:** Accepted +**Implementation maturity:** active-PR +**Date:** 2026-09-01 +**Supersedes:** None; complements ADR 0054 and ADR 0018 for the operator-visible +jump from an export idempotency key to a durable export identity. Does not +supersede ADR 0014. Unique versus protected main; 0026–0092 occupied including +#464=0092, #463=0091, #459=0090, #457=0089, #411=0054. +**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 0054 publishes `GET /v1/exports/{export_id}`. Collection GET is a different +stack. Stored-request GET requires an `export_id`. Operators who hold a 200 +authorization receipt or a log key therefore cannot jump to that export without +scanning identities. Returning RMSE, bias, coverage, SE-gate, source text, or +`tepp.scientific_acceptance.v1` on the lookup body would treat key resolution as +measurement evidence. Analysis-run lookup GET (#380) is a different adapter. +Reuse of GET-by-id with the key as `{export_id}` would collide with +server-assigned UUID v7 capabilities. + +## Decision + +`AnalysisRunLiveService` serves `GET /v1/exports/by-idempotency/{idempotency_key}` +on loopback: + +- The payload is metric-free: `export_id`, `decision_code`, `idempotency_key`. +- `tepp.scientific_acceptance.v1`, RMSE, bias, coverage, SE-gate, report, + `terminal_result`, `tenant_workspace_id`, `principal_id`, and + `includes_source_text` never appear. +- Lookup 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, GET-by-id, POST `/by-idempotency`, + GET `/request`, collection GET `/v1/exports`, reserved `by-idempotency` as a + key, and nonempty bodies fail closed. +- The key travels in the path. The NARUON exchange does not send an + `idempotency-key` header or credentials. +- `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 GET `/v1/exports/{export_id}` (#411), retrieval CLI (#417), + collection GET/CLI (#443/#444), stored-request GET/CLI (#457/#459), + export-authorize CLI (#410), analysis-run lookup GET (#380), or cancel + lineages (closed). +- Adding GET to `NaruonLiveService`. + +## Alternatives considered + +1. **Ask operators to scan collection pages or re-POST authorization** — + rejected because collection GET is a different stack and a 200 decision is + not an addressable identity. +2. **Return `tepp.scientific_acceptance.v1` on succeeded lookup** — rejected + because lookup bodies must stay metric-free. +3. **Reuse GET-by-id with the key as `{export_id}`** — rejected because + GET-by-id (#411) owns UUID v7 capabilities. +4. **Metric-free export idempotency-key lookup GET on loopback** — accepted. + +## Consequences + +- Operators can resolve a 200 export authorization receipt or log key to a + durable `export_id` without scanning identities. +- Lookup pages cannot be mistaken for a succeeded scientific-acceptance result. +- GET-by-id remains the capability-bearing retrieval route. + +## Failure and recovery + +Unknown keys, extra path segments, GET-by-id, query strings, nonempty bodies, +POST `/by-idempotency`, metric keys, LineageWeave, unpublished consumers, +consumer mismatch, ambiguous multi-tenant matches, reserved prefix-as-key, and +non-loopback hosts return a redacted `400` envelope. Oversized keys return +`413`. Credential headers remain `403`. The in-memory registry is not durable; +a restart requires re-POSTing the original metric-free authorization. Callers +must not fabricate a succeeded scientific-acceptance artifact from a lookup +payload. + +## Security, privacy, scientific-integrity, and governance impact + +- No credential headers cross the consumer boundary. +- Idempotency-key lookup remains loopback-only, size-bounded, consumer-scoped, + and content-redacting. +- HTTP `200` on a lookup payload is not measurement evidence and is not + release evidence. +- Ambiguous matches fail closed so lookup cannot become a tenant-count oracle. + +## Compatibility and migration + +Create POST, retrieval GET, temporal-context, and project-history paths are +unchanged. GET-by-id remains the capability route. Production adapters may +replace loopback while preserving metric-free lookup fields and the artifact +refusal. + +## Verification + +Falsifiable evidence: + +- GET lookup JSON has no RMSE/bias/coverage/SE-gate/scientific-acceptance/ + `terminal_result`/`tenant_workspace_id`/`principal_id`/`includes_source_text` + keys; +- GET of a create key returns the matching `export_id`; +- GET does not leak another consumer's export; +- GET-by-id, query strings, nonempty bodies, POST `/by-idempotency`, unknown + keys, LineageWeave, `NaruonLiveService` GET, and reserved `by-idempotency` as + a key fail closed; +- Clippy `-D warnings`, `tepp_api` tests, rustdoc, and exact-head review remain + required. + +## Rollback and supersession + +Rollback removes idempotency-lookup GET dispatch; POST authorize receipts and +retrieval GET remain valid. A superseding ADR is required to persist the +registry, bind a public address, emit scientific-acceptance on lookup, open +LineageWeave on this naruon-owned adapter, add GET to `NaruonLiveService`, or +treat HTTP success as an ADR 0014 claim. + +## Related authority + +ADR 0054, ADR 0018, 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..122217ab4 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. | +| [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. | | [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 idempotency-key lookup:** ADR 0093. ## Change and supersession rule diff --git a/docs/research/export-idempotency-lookup-http.md b/docs/research/export-idempotency-lookup-http.md new file mode 100644 index 000000000..24155bd57 --- /dev/null +++ b/docs/research/export-idempotency-lookup-http.md @@ -0,0 +1,34 @@ +# Export idempotency-key lookup HTTP (doctoring) + +## Scope + +Operators who receive a 200 purpose-bound export authorization still cannot +jump from the request idempotency key to that export. `GET /v1/exports/{export_id}` +requires the server-assigned capability. `GET /v1/exports/by-idempotency/{key}` +on `AnalysisRunLiveService` is the first executable lookup route. HTTP method, +path, `Host`, and `Transfer-Encoding` semantics follow current HTTP semantics +(Fielding, Nottingham, & Reschke, 2022). Fail-closed refusal of table-access +URLs, review/Copilot/NIM/proxy credential headers, metric keys, LineageWeave +on this naruon-owned adapter, ambiguous multi-tenant matches, and non-loopback +binds is repository contract authority, not an RFC inference rule. + +The live listener is loopback HTTP/1.1 with an installed read/write deadline. +It is not a production TLS/`$PORT` service. Persistence remains GAP-003B. +JSON-LD/GraphML envelopes, Figma views, and GAP-010 visual export workflows +remain later work. `NaruonLiveService` stays POST-only. + +## Internal contract evidence + +- ADR 0093 owns this lookup GET. +- ADR 0054 owns retrieval GET-by-id. +- ADR 0009 owns purpose-bound disclosure without blanket masking. +- ADR 0011 owns the standalone/CWL MSA boundary. +- `docs/API_CONTRACT.md` names `GET /v1/exports/by-idempotency/{idempotency_key}` + as the target lookup shape. + +## Non-goals + +GET-by-id (#411), retrieval CLI (#417), collection GET/CLI (#443/#444), +stored-request GET/CLI (#457/#459), export-authorize CLI (#410), analysis-run +lookup GET (#380), cancel lineages (closed), Leiden, Driver p.16 std-family +restoration, Figma/export (GAP-010), and Compose persistence (GAP-003B). From 79cb5d6cdbf8f18c82bb990a63d9282a278293e1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 23:41:52 +0900 Subject: [PATCH 2/4] test(api): reproduce export lookup review defects --- ...t_idempotency_lookup_review_regressions.rs | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 crates/tepp_api/tests/export_idempotency_lookup_review_regressions.rs diff --git a/crates/tepp_api/tests/export_idempotency_lookup_review_regressions.rs b/crates/tepp_api/tests/export_idempotency_lookup_review_regressions.rs new file mode 100644 index 000000000..a062d3929 --- /dev/null +++ b/crates/tepp_api/tests/export_idempotency_lookup_review_regressions.rs @@ -0,0 +1,64 @@ +//! Regression tests for export idempotency-lookup review findings. + +use tepp_api::{ + AnalysisRunLiveService, ApiError, ExportIdempotencyLookup, naruon_export_retrieval_exchange, + refuse_metrics_on_export_idempotency_lookup_payload, +}; + +const EXPORT_REQUEST_JSON: &str = r#"{"tenant_workspace_id":"tenant-a","principal_id":"principal-a","purpose":"modular_service_consumer","artifact_id":"artifact-a","includes_source_text":false}"#; + +fn export_post_http(idempotency_key: &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: {idempotency_key}\r\ncontent-length: {}\r\n\r\n{EXPORT_REQUEST_JSON}", + EXPORT_REQUEST_JSON.len() + ) +} + +fn export_lookup_http(encoded_idempotency_key: &str) -> String { + format!( + "GET /v1/exports/by-idempotency/{encoded_idempotency_key} 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" + ) +} + +#[test] +fn slash_containing_idempotency_key_round_trips_through_post_then_lookup() { + let mut service = AnalysisRunLiveService::new(); + let posted = service.handle_http_request(&export_post_http("scope/key")); + assert_eq!(posted.status_code, 200, "POST must preserve an already-valid opaque key"); + + let looked_up = service.handle_http_request(&export_lookup_http("scope%2Fkey")); + assert_eq!( + looked_up.status_code, 200, + "one percent-encoded path segment must recover the opaque slash-containing key" + ); + let lookup = ExportIdempotencyLookup::from_json(&looked_up.body).expect("lookup payload"); + assert_eq!(lookup.idempotency_key, "scope/key"); +} + +#[test] +fn reserved_lookup_prefix_cannot_build_an_unroutable_retrieval_exchange() { + assert_eq!( + naruon_export_retrieval_exchange("https://tepp.example.test", "by-idempotency"), + Err(ApiError::InvalidWirePayload) + ); +} + +#[test] +fn lookup_metric_refusal_walks_nested_objects_and_arrays() { + assert_eq!( + refuse_metrics_on_export_idempotency_lookup_payload( + r#"{"safe":{"nested":{"rmse":1.0}}}"# + ), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + refuse_metrics_on_export_idempotency_lookup_payload( + r#"{"safe":[{"deeper":{"scientific_acceptance":{}}}]}"# + ), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + refuse_metrics_on_export_idempotency_lookup_payload(r#"{"safe":[{"value":1}]}"#), + Ok(()) + ); +} From e40b4078762b37e05ca85fd066009bb30bd663c4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 23:43:49 +0900 Subject: [PATCH 3/4] fix(api): make export idempotency lookup path-safe and metric-recursive --- .../src/export_idempotency_lookup_http.rs | 81 ++++++++++++++----- 1 file changed, 61 insertions(+), 20 deletions(-) diff --git a/crates/tepp_api/src/export_idempotency_lookup_http.rs b/crates/tepp_api/src/export_idempotency_lookup_http.rs index f5a670bb6..fd55c98ea 100644 --- a/crates/tepp_api/src/export_idempotency_lookup_http.rs +++ b/crates/tepp_api/src/export_idempotency_lookup_http.rs @@ -138,7 +138,6 @@ impl ExportIdempotencyLookup { } if self.export_id.contains('/') || self.export_id.contains('\0') - || self.idempotency_key.contains('/') || self.idempotency_key.contains('\0') { return Err(ApiError::InvalidWirePayload); @@ -165,35 +164,46 @@ impl ExportIdempotencyLookup { /// # Errors /// /// Returns [`ApiError::InvalidWirePayload`] when a forbidden metric key is -/// present or the payload is a non-empty non-object. +/// present at any nesting depth or the payload is a non-empty non-object. pub fn refuse_metrics_on_export_idempotency_lookup_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)?; - let Some(object) = value.as_object() else { + if !value.is_object() { return Err(ApiError::InvalidWirePayload); - }; - if FORBIDDEN_EXPORT_LOOKUP_KEYS - .iter() - .any(|key| object.contains_key(*key)) - { + } + if contains_forbidden_export_lookup_key(&value) { return Err(ApiError::InvalidWirePayload); } Ok(()) } +fn contains_forbidden_export_lookup_key(value: &serde_json::Value) -> bool { + match value { + serde_json::Value::Object(object) => object.iter().any(|(key, value)| { + FORBIDDEN_EXPORT_LOOKUP_KEYS.contains(&key.as_str()) + || contains_forbidden_export_lookup_key(value) + }), + serde_json::Value::Array(values) => values.iter().any(contains_forbidden_export_lookup_key), + _ => false, + } +} + /// Extract the opaque idempotency key from /// `GET /v1/exports/by-idempotency/{key}`. /// +/// 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 a collection path, GET-by-id, -/// extra segments, a missing `by-idempotency` prefix, stored-request `/request` -/// suffix, a reserved prefix used as the key, or a hostile encoding, and -/// [`ApiError::LimitExceeded`] when the decoded key exceeds -/// [`EXPORT_IDEMPOTENCY_LOOKUP_KEY_MAX_LEN`]. +/// extra raw segments, a missing `by-idempotency` prefix, stored-request +/// `/request` suffix, a reserved prefix used as the key, a NUL byte, or a +/// hostile encoding, and [`ApiError::LimitExceeded`] when the decoded key +/// exceeds [`EXPORT_IDEMPOTENCY_LOOKUP_KEY_MAX_LEN`]. pub(crate) fn export_idempotency_lookup_path_key(path: &str) -> Result { let remainder = path .strip_prefix(NARUON_EXPORT_PATH) @@ -224,20 +234,22 @@ pub(crate) fn export_idempotency_lookup_path_key(path: &str) -> Result Result { require_nonempty(idempotency_key)?; - if idempotency_key == EXPORT_IDEMPOTENCY_LOOKUP_PREFIX { + 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 { @@ -300,7 +312,7 @@ fn decode_path_segment(value: &str) -> Result { } } let decoded = String::from_utf8(out).map_err(|_| ApiError::InvalidWirePayload)?; - if decoded.is_empty() || decoded.contains('/') || decoded.contains('\0') { + if decoded.is_empty() || decoded.contains('\0') { return Err(ApiError::InvalidWirePayload); } Ok(decoded) @@ -352,6 +364,14 @@ mod tests { ExportIdempotencyLookup::new("export-1", "denied", "idem-1"), Err(ApiError::AuthorizationDenied) ); + assert!( + ExportIdempotencyLookup::new( + "export-1", + EXPORT_RETRIEVAL_ALLOWED_DECISION_CODE, + "scope/key" + ) + .is_ok() + ); assert_eq!( ExportIdempotencyLookup::new( "a".repeat(EXPORT_IDEMPOTENCY_LOOKUP_KEY_MAX_LEN + 1), @@ -427,6 +447,22 @@ mod tests { "key={key}" ); } + assert_eq!( + refuse_metrics_on_export_idempotency_lookup_payload( + r#"{"safe":{"nested":{"rmse":1.0}}}"# + ), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + refuse_metrics_on_export_idempotency_lookup_payload( + r#"{"safe":[{"nested":{"scientific_acceptance":{}}}]}"# + ), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + refuse_metrics_on_export_idempotency_lookup_payload(r#"{"safe":[{"value":1}]}"#), + Ok(()) + ); assert_eq!( refuse_metrics_on_export_idempotency_lookup_payload("[true]"), Err(ApiError::InvalidWirePayload) @@ -453,6 +489,11 @@ mod tests { .expect("upper"), "key-abc" ); + assert_eq!( + export_idempotency_lookup_path_key("/v1/exports/by-idempotency/scope%2Fkey") + .expect("encoded slash remains opaque key data"), + "scope/key" + ); assert_eq!( export_idempotency_lookup_path_key("/v1/exports"), Err(ApiError::InvalidWirePayload) @@ -486,8 +527,8 @@ mod tests { Err(ApiError::InvalidWirePayload) ); assert_eq!( - export_idempotency_lookup_path_key("/v1/exports/by-idempotency/%2F"), - Err(ApiError::InvalidWirePayload) + export_idempotency_lookup_path_key("/v1/exports/by-idempotency/%2F").expect("slash"), + "/" ); assert_eq!( export_idempotency_lookup_path_key("/v1/exports/by-idempotency/%00"), From 0fd64f72cb0978d5603a5bf78954bb8e8f35d45a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 23:45:13 +0900 Subject: [PATCH 4/4] fix(api): reject reserved export retrieval identities at construction --- crates/tepp_api/src/export_http.rs | 31 ++++++++++++++++++++---------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/crates/tepp_api/src/export_http.rs b/crates/tepp_api/src/export_http.rs index 561c937b1..056dad9fd 100644 --- a/crates/tepp_api/src/export_http.rs +++ b/crates/tepp_api/src/export_http.rs @@ -197,13 +197,17 @@ fn contains_forbidden_export_key(value: &serde_json::Value) -> bool { } } +fn export_retrieval_id_is_reserved(export_id: &str) -> bool { + export_id == "by-idempotency" +} + /// Extract the opaque export identity from `GET /v1/exports/{export_id}`. /// /// # Errors /// /// Returns [`ApiError::InvalidWirePayload`] for the collection path, extra -/// segments, a hostile encoding, or an empty identity, and -/// [`ApiError::LimitExceeded`] when the decoded identity exceeds +/// segments, a reserved route identity, a hostile encoding, or an empty +/// identity, and [`ApiError::LimitExceeded`] when the decoded identity exceeds /// [`EXPORT_RETRIEVAL_ID_MAX_LEN`]. pub(crate) fn export_retrieval_path_id(path: &str) -> Result { let remainder = path @@ -216,7 +220,7 @@ pub(crate) fn export_retrieval_path_id(path: &str) -> Result { return Err(ApiError::InvalidWirePayload); } let export_id = decode_path_segment(encoded)?; - if export_id == "by-idempotency" { + if export_retrieval_id_is_reserved(&export_id) { return Err(ApiError::InvalidWirePayload); } if export_id.len() > EXPORT_RETRIEVAL_ID_MAX_LEN { @@ -227,21 +231,24 @@ pub(crate) fn export_retrieval_path_id(path: &str) -> Result { /// Build a provider-owned `GET` export-retrieval exchange. /// -/// The builder refuses non-`https` origins and empty or oversized identities. -/// It does not inject credentials. The GET body is empty. The identity -/// travels in the path; the builder does not send an `idempotency-key` -/// header. +/// The builder refuses non-`https` origins, empty or oversized identities, and +/// identities reserved for collection sub-routes. It does not inject +/// credentials. The GET body is empty. 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 the identity exceeds -/// [`EXPORT_RETRIEVAL_ID_MAX_LEN`] bytes. +/// Returns [`ApiError::InvalidWirePayload`] for a non-`https` origin, empty +/// identity, or reserved route identity, and [`ApiError::LimitExceeded`] when +/// the identity exceeds [`EXPORT_RETRIEVAL_ID_MAX_LEN`] bytes. pub fn naruon_export_retrieval_exchange( origin: &str, export_id: &str, ) -> Result { require_nonempty(export_id)?; + if export_retrieval_id_is_reserved(export_id) { + return Err(ApiError::InvalidWirePayload); + } if export_id.len() > EXPORT_RETRIEVAL_ID_MAX_LEN { return Err(ApiError::LimitExceeded); } @@ -522,6 +529,10 @@ mod tests { naruon_export_retrieval_exchange("https://tepp.example.test", ""), Err(ApiError::InvalidWirePayload) ); + assert_eq!( + naruon_export_retrieval_exchange("https://tepp.example.test", "by-idempotency"), + Err(ApiError::InvalidWirePayload) + ); assert_eq!( naruon_export_retrieval_exchange( "https://tepp.example.test",