From 515fd3b8c37e4937fced7e53b3cbdefea1dc2aba Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 14:30:13 +0000 Subject: [PATCH 01/23] 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 77de1af4ef7fb6038cbc688be982aca1be927b51 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 14:40:36 +0000 Subject: [PATCH 02/23] feat(api): mint export idempotency-lookup GET from a dedicated CLI Publish tepp-export-lookup lookup so operators can resolve a 200 naruon export authorization receipt to export_id on spawned tepp-loopback TCP without writing raw HTTP. Metric-free identity stdout. Empty stdin admitted. LineageWeave refused. NaruonLiveService stays POST-only. --- CHANGELOG.d/export-idempotency-lookup-cli.md | 1 + DOCUMENTATION.md | 1 + crates/tepp_api/Cargo.toml | 6 + crates/tepp_api/src/bin/tepp_export_lookup.rs | 33 + .../src/export_idempotency_lookup_cli.rs | 676 ++++++++++++++++++ crates/tepp_api/src/lib.rs | 17 + .../export_idempotency_lookup_cli_contract.rs | 404 +++++++++++ docs/API_CONTRACT.md | 2 +- docs/TRACEABILITY.md | 1 + .../adr/0094-export-idempotency-lookup-cli.md | 71 ++ docs/adr/README.md | 2 + .../research/export-idempotency-lookup-cli.md | 37 + 12 files changed, 1250 insertions(+), 1 deletion(-) create mode 100644 CHANGELOG.d/export-idempotency-lookup-cli.md create mode 100644 crates/tepp_api/src/bin/tepp_export_lookup.rs create mode 100644 crates/tepp_api/src/export_idempotency_lookup_cli.rs create mode 100644 crates/tepp_api/tests/export_idempotency_lookup_cli_contract.rs create mode 100644 docs/adr/0094-export-idempotency-lookup-cli.md create mode 100644 docs/research/export-idempotency-lookup-cli.md diff --git a/CHANGELOG.d/export-idempotency-lookup-cli.md b/CHANGELOG.d/export-idempotency-lookup-cli.md new file mode 100644 index 000000000..42d98cfff --- /dev/null +++ b/CHANGELOG.d/export-idempotency-lookup-cli.md @@ -0,0 +1 @@ +- `tepp_api` published `tepp-export-lookup lookup` mints `naruon_export_idempotency_lookup_exchange` onto spawned `tepp-loopback` TCP so operators can resolve a 200 export authorization receipt to `export_id` without writing raw HTTP (ADR 0094). Empty stdin is admitted. `NaruonLiveService` stays POST-only. LineageWeave is refused. Not lookup GET, not GET-by-id, not collection, not stored-request, not analysis-run lookup CLI, not cancel, not GAP-010 Figma/export, not persistence. diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 2747e9cd7..8cd946a68 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -74,6 +74,7 @@ TEPP's approved PRD v0.4 and implementation plan are the primary product baselin | 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) | +| Export idempotency-key lookup CLI doctoring | [`docs/research/export-idempotency-lookup-cli.md`](docs/research/export-idempotency-lookup-cli.md) | | Change history | [`CHANGELOG.md`](CHANGELOG.md) | ## Maturity vocabulary diff --git a/crates/tepp_api/Cargo.toml b/crates/tepp_api/Cargo.toml index 47ad7c433..eeca298df 100644 --- a/crates/tepp_api/Cargo.toml +++ b/crates/tepp_api/Cargo.toml @@ -29,5 +29,11 @@ path = "src/bin/tepp_loopback.rs" test = false bench = false +[[bin]] +name = "tepp-export-lookup" +path = "src/bin/tepp_export_lookup.rs" +test = false +bench = false + [lints] workspace = true diff --git a/crates/tepp_api/src/bin/tepp_export_lookup.rs b/crates/tepp_api/src/bin/tepp_export_lookup.rs new file mode 100644 index 000000000..b3c8727d0 --- /dev/null +++ b/crates/tepp_api/src/bin/tepp_export_lookup.rs @@ -0,0 +1,33 @@ +//! Operator CLI for loopback naruon export idempotency-key lookup GET. + +use std::io::{self, IsTerminal}; +use std::process::ExitCode; + +use tepp_api::{ + execute_export_idempotency_lookup_cli, read_export_idempotency_lookup_cli_stdin, + render_export_idempotency_lookup_cli_stdout, ApiError, ExportIdempotencyLookupCliInvocation, +}; + +fn main() -> ExitCode { + match run() { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("tepp-export-lookup: {error}"); + ExitCode::FAILURE + } + } +} + +fn run() -> Result<(), ApiError> { + let args: Vec = std::env::args().skip(1).collect(); + let body = read_export_idempotency_lookup_cli_stdin(io::stdin().is_terminal(), io::stdin())?; + let invocation = ExportIdempotencyLookupCliInvocation::from_args(&args, body)?; + let response = execute_export_idempotency_lookup_cli(&invocation)?; + let stdout = render_export_idempotency_lookup_cli_stdout(&invocation, &response)?; + println!("{stdout}"); + if (200..300).contains(&response.status_code) { + Ok(()) + } else { + Err(ApiError::InvalidWirePayload) + } +} diff --git a/crates/tepp_api/src/export_idempotency_lookup_cli.rs b/crates/tepp_api/src/export_idempotency_lookup_cli.rs new file mode 100644 index 000000000..9d1b0654b --- /dev/null +++ b/crates/tepp_api/src/export_idempotency_lookup_cli.rs @@ -0,0 +1,676 @@ +//! Operator loopback CLI for naruon export idempotency-key lookup GET. +//! +//! GAP-003A unique slice: operators run `tepp-export-lookup lookup` to mint +//! `naruon_export_idempotency_lookup_exchange` onto spawned `tepp-loopback` +//! TCP. Stdout is the metric-free `ExportIdempotencyLookup`. +//! `tepp.scientific_acceptance.v1` never appears. The CLI does not infer +//! causality. `LineageWeave` is refused on this naruon-owned adapter. +//! `NaruonLiveService` stays POST-only. This module does not duplicate +//! lookup GET (#465), GET-by-id HTTP (#411), retrieval CLI (#417), +//! collection GET/CLI (#443/#444), stored-request GET/CLI (#457/#459), +//! export-authorize CLI (#410), analysis-run lookup CLI (#401), cancel +//! lineages (closed), Leiden, or GAP-010 Figma/export. Persistence remains +//! GAP-003B. + +use std::collections::HashSet; +use std::fmt::Write as _; +use std::io::{Read, Write}; +use std::net::{SocketAddr, TcpStream}; + +use crate::export_idempotency_lookup_http::export_idempotency_lookup_path_key; +use crate::live_http::map_io_error; +use crate::naruon_http::header_is_credential; +use crate::wire::require_nonempty; +use crate::{ + naruon_export_idempotency_lookup_exchange, refuse_metrics_on_export_idempotency_lookup_payload, + AnalysisRunLiveService, ApiError, ErrorEnvelope, ExportIdempotencyLookup, NaruonHttpExchange, + NaruonLiveResponse, DEFAULT_PROJECT_HISTORY_BYTE_LIMIT, EXPORT_IDEMPOTENCY_LOOKUP_KEY_MAX_LEN, + EXPORT_IDEMPOTENCY_LOOKUP_PREFIX, NARUON_CONSUMER_CODE, NARUON_LIVE_HEADER_BYTE_LIMIT, + NARUON_LIVE_HEADER_COUNT_LIMIT, NARUON_LIVE_IO_TIMEOUT, +}; + +const MAXIMUM_HTTP_RESPONSE_BYTES: usize = + NARUON_LIVE_HEADER_BYTE_LIMIT + 4 + DEFAULT_PROJECT_HISTORY_BYTE_LIMIT; + +/// Supported operator verbs for the loopback export idempotency-lookup CLI. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ExportIdempotencyLookupCliVerb { + /// `GET /v1/exports/by-idempotency/{idempotency_key}`. + Lookup, +} + +impl ExportIdempotencyLookupCliVerb { + /// Parse one exact lowercase verb token. + /// + /// # Errors + /// + /// Returns [`ApiError::InvalidWirePayload`] for an unknown token. + pub fn parse(token: &str) -> Result { + match token { + "lookup" => Ok(Self::Lookup), + _ => Err(ApiError::InvalidWirePayload), + } + } + + /// Return the canonical lowercase verb token. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Lookup => "lookup", + } + } +} + +/// One operator CLI invocation against a loopback export lookup listener. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ExportIdempotencyLookupCliInvocation { + /// CLI verb to execute. + pub verb: ExportIdempotencyLookupCliVerb, + /// Loopback `host:port` of `tepp-loopback`. + pub host: String, + /// Published HTTPS origin used to mint the typed lookup exchange. + pub origin: String, + /// Published modular consumer. Lookup GET admits `naruon` only. + pub consumer: String, + /// Exact request idempotency key to resolve. + pub idempotency_key: String, + /// JSON body. Lookup GET requires empty. + pub body: String, +} + +impl ExportIdempotencyLookupCliInvocation { + /// Parse argv plus stdin body into a validated loopback lookup invocation. + /// + /// # Errors + /// + /// Returns a fail-closed error for unknown verbs, missing required flags, a + /// non-loopback host, a non-`https` origin, an unpublished or `LineageWeave` + /// consumer, credential-shaped flags, a hostile key, or a nonempty body. + pub fn from_args(args: I, body: impl Into) -> Result + where + I: IntoIterator, + S: AsRef, + { + let tokens: Vec = args + .into_iter() + .map(|token| token.as_ref().to_owned()) + .collect(); + let (verb_token, rest) = tokens.split_first().ok_or(ApiError::InvalidWirePayload)?; + let verb = ExportIdempotencyLookupCliVerb::parse(verb_token)?; + let flags = parse_flags(rest)?; + assemble_invocation(verb, flags, body.into()) + } + + /// Reject a non-loopback host, unpublished consumer, or hostile GET body. + /// + /// # Errors + /// + /// Returns [`ApiError::AuthorizationDenied`] for a non-loopback host and + /// [`ApiError::InvalidWirePayload`] or [`ApiError::LimitExceeded`] for + /// empty, unpublished, `LineageWeave`, nonempty-body, or oversized fields. + pub fn validate(&self) -> Result<(), ApiError> { + require_loopback_host(&self.host)?; + require_nonempty(&self.origin)?; + if !self.origin.starts_with("https://") { + return Err(ApiError::InvalidWirePayload); + } + require_nonempty(&self.consumer)?; + if self.consumer != NARUON_CONSUMER_CODE { + return Err(ApiError::InvalidWirePayload); + } + require_nonempty(&self.idempotency_key)?; + if self.idempotency_key.contains('/') + || self.idempotency_key.contains('\0') + || self.idempotency_key == EXPORT_IDEMPOTENCY_LOOKUP_PREFIX + { + return Err(ApiError::InvalidWirePayload); + } + if self.idempotency_key.len() > EXPORT_IDEMPOTENCY_LOOKUP_KEY_MAX_LEN { + return Err(ApiError::LimitExceeded); + } + if !self.body.is_empty() { + return Err(ApiError::InvalidWirePayload); + } + refuse_metrics_on_export_idempotency_lookup_payload(&self.body)?; + Ok(()) + } +} + +struct ParsedFlags { + host: Option, + origin: Option, + consumer: Option, + idempotency_key: Option, +} + +fn parse_flags(rest: &[String]) -> Result { + let mut flags = ParsedFlags { + host: None, + origin: None, + consumer: None, + idempotency_key: None, + }; + let mut index = 0; + while index < rest.len() { + let flag = rest[index].as_str(); + if !flag.starts_with("--") { + return Err(ApiError::InvalidWirePayload); + } + let name = &flag[2..]; + if header_is_credential(name) { + return Err(ApiError::AuthorizationDenied); + } + let slot = match name { + "host" => &mut flags.host, + "origin" => &mut flags.origin, + "consumer" => &mut flags.consumer, + "idempotency-key" => &mut flags.idempotency_key, + _ => return Err(ApiError::InvalidWirePayload), + }; + if slot.is_some() || index + 1 >= rest.len() { + return Err(ApiError::InvalidWirePayload); + } + let value = rest[index + 1].as_str(); + require_nonempty(value)?; + *slot = Some(value.to_owned()); + index += 2; + } + Ok(flags) +} + +fn assemble_invocation( + verb: ExportIdempotencyLookupCliVerb, + flags: ParsedFlags, + body: String, +) -> Result { + let invocation = ExportIdempotencyLookupCliInvocation { + verb, + host: flags.host.ok_or(ApiError::InvalidWirePayload)?, + origin: flags.origin.ok_or(ApiError::InvalidWirePayload)?, + consumer: flags + .consumer + .unwrap_or_else(|| NARUON_CONSUMER_CODE.to_owned()), + idempotency_key: flags.idempotency_key.ok_or(ApiError::InvalidWirePayload)?, + body, + }; + invocation.validate()?; + Ok(invocation) +} + +fn require_loopback_host(host: &str) -> Result { + let addr: SocketAddr = host.parse().map_err(|_| ApiError::InvalidWirePayload)?; + if addr.ip().is_loopback() { + Ok(addr) + } else { + Err(ApiError::AuthorizationDenied) + } +} + +/// Render a typed lookup GET exchange as HTTP/1.1 for a loopback listener. +/// +/// The exchange keeps its HTTPS origin contract. Only the HTTP/1.1 `Host` is +/// the loopback bind address. Public bind hosts fail closed. GET-by-id, +/// collection, stored-request extra-segments, and pagination headers fail +/// closed. +/// +/// # Errors +/// +/// Returns [`ApiError::AuthorizationDenied`] for a non-loopback host or a +/// credential-bearing header, and [`ApiError::InvalidWirePayload`] when the +/// exchange is not a GET `/v1/exports/by-idempotency/{key}` with an empty body. +pub fn loopback_http1_from_export_idempotency_lookup_exchange( + exchange: &NaruonHttpExchange, + loopback_host: &str, +) -> Result { + let _addr = require_loopback_host(loopback_host)?; + let host = loopback_host.trim(); + if exchange.method != "GET" { + return Err(ApiError::InvalidWirePayload); + } + if !exchange.body.is_empty() { + return Err(ApiError::InvalidWirePayload); + } + let rest = exchange + .target_url + .strip_prefix("https://") + .ok_or(ApiError::InvalidWirePayload)?; + let path = rest + .find('/') + .map(|index| &rest[index..]) + .ok_or(ApiError::InvalidWirePayload)?; + let _key = export_idempotency_lookup_path_key(path)?; + let mut seen = HashSet::with_capacity(exchange.headers.len()); + let mut has_content_type = false; + let mut has_consumer = false; + let mut has_contract = false; + for (name, value) in &exchange.headers { + if header_is_credential(name) { + return Err(ApiError::AuthorizationDenied); + } + if !valid_http_field_name(name) + || value.chars().any(char::is_control) + || !seen.insert(name.to_ascii_lowercase()) + { + return Err(ApiError::InvalidWirePayload); + } + let valid = match name.to_ascii_lowercase().as_str() { + "content-type" => { + has_content_type = true; + value == "application/json" + } + "tepp-consumer" => { + has_consumer = true; + value == NARUON_CONSUMER_CODE + } + "tepp-contract-version" => { + has_contract = true; + value == "1" + } + _ => false, + }; + if !valid { + return Err(ApiError::InvalidWirePayload); + } + } + if !has_content_type || !has_consumer || !has_contract { + return Err(ApiError::InvalidWirePayload); + } + let mut request = String::new(); + write!( + request, + "{} {path} HTTP/1.1\r\nHost: {host}\r\n", + exchange.method + ) + .map_err(|_| ApiError::InvalidWirePayload)?; + for (name, value) in &exchange.headers { + write!(request, "{name}: {value}\r\n").map_err(|_| ApiError::InvalidWirePayload)?; + } + write!(request, "content-length: 0\r\n\r\n").map_err(|_| ApiError::InvalidWirePayload)?; + Ok(request) +} + +/// Compose one HTTP/1.1 lookup GET from the typed naruon exchange. +/// +/// # Errors +/// +/// Returns the same fail-closed errors as +/// [`ExportIdempotencyLookupCliInvocation::validate`]. +pub fn compose_export_idempotency_lookup_cli_http( + invocation: &ExportIdempotencyLookupCliInvocation, +) -> Result { + invocation.validate()?; + let exchange = + naruon_export_idempotency_lookup_exchange(&invocation.origin, &invocation.idempotency_key)?; + loopback_http1_from_export_idempotency_lookup_exchange(&exchange, &invocation.host) +} + +/// Dispatch one lookup CLI invocation against an in-process listener. +/// +/// # Errors +/// +/// Returns fail-closed validation errors before the HTTP handler runs. +pub fn dispatch_export_idempotency_lookup_cli( + service: &mut AnalysisRunLiveService, + invocation: &ExportIdempotencyLookupCliInvocation, +) -> Result { + let request = compose_export_idempotency_lookup_cli_http(invocation)?; + Ok(service.handle_http_request(&request)) +} + +/// Execute one lookup CLI invocation over loopback TCP against `tepp-loopback`. +/// +/// # Errors +/// +/// Returns fail-closed validation, transport, or response-framing errors. +pub fn execute_export_idempotency_lookup_cli( + invocation: &ExportIdempotencyLookupCliInvocation, +) -> Result { + let addr = require_loopback_host(&invocation.host)?; + let request = compose_export_idempotency_lookup_cli_http(invocation)?; + let mut stream = TcpStream::connect(addr).map_err(|error| map_io_error(&error))?; + stream + .set_read_timeout(Some(NARUON_LIVE_IO_TIMEOUT)) + .map_err(|error| map_io_error(&error))?; + stream + .set_write_timeout(Some(NARUON_LIVE_IO_TIMEOUT)) + .map_err(|error| map_io_error(&error))?; + stream + .write_all(request.as_bytes()) + .map_err(|error| map_io_error(&error))?; + stream.flush().map_err(|error| map_io_error(&error))?; + let bytes = read_bounded(&mut stream, MAXIMUM_HTTP_RESPONSE_BYTES)?; + parse_http_response(&bytes) +} + +/// Filter CLI stdout so lookup GET never prints scientific acceptance. +/// +/// RMSE, bias, coverage, SE-gate, tenant, principal, source-text, and +/// causal-score keys fail closed. Success stdout is only the metric-free +/// identity projection. +/// +/// # Errors +/// +/// Returns [`ApiError::InvalidWirePayload`] when a body carries metric keys, +/// `tepp.scientific_acceptance.v1`, or a success body that is not an +/// `ExportIdempotencyLookup`. +pub fn render_export_idempotency_lookup_cli_stdout( + invocation: &ExportIdempotencyLookupCliInvocation, + response: &NaruonLiveResponse, +) -> Result { + invocation.validate()?; + if response.body.is_empty() { + return Err(ApiError::InvalidWirePayload); + } + refuse_metrics_on_export_idempotency_lookup_payload(&response.body)?; + if !(200..300).contains(&response.status_code) { + let expected_code = match response.status_code { + 400 => "invalid_wire_payload", + 403 => "authorization_denied", + 413 => "limit_exceeded", + 422 => "unsupported_contract_version", + _ => return Err(ApiError::InvalidWirePayload), + }; + let envelope: ErrorEnvelope = + serde_json::from_str(&response.body).map_err(|_| ApiError::InvalidWirePayload)?; + if envelope.error_code() != expected_code { + return Err(ApiError::InvalidWirePayload); + } + return envelope.to_json(); + } + if response.status_code != 200 { + return Err(ApiError::InvalidWirePayload); + } + let lookup = ExportIdempotencyLookup::from_json(&response.body)?; + lookup.to_json() +} + +fn parse_http_response(bytes: &[u8]) -> Result { + let text = std::str::from_utf8(bytes).map_err(|_| ApiError::InvalidWirePayload)?; + let (header_block, body) = text + .split_once("\r\n\r\n") + .ok_or(ApiError::InvalidWirePayload)?; + if header_block.len() > NARUON_LIVE_HEADER_BYTE_LIMIT { + return Err(ApiError::LimitExceeded); + } + let mut lines = header_block.split("\r\n"); + let status_line = lines.next().ok_or(ApiError::InvalidWirePayload)?; + let (version, status) = status_line + .split_once(' ') + .ok_or(ApiError::InvalidWirePayload)?; + if version != "HTTP/1.1" { + return Err(ApiError::InvalidWirePayload); + } + let (code, reason) = status.split_once(' ').ok_or(ApiError::InvalidWirePayload)?; + let code = code + .parse::() + .map_err(|_| ApiError::InvalidWirePayload)?; + let reason_phrase = static_reason(code)?; + if reason != reason_phrase { + return Err(ApiError::InvalidWirePayload); + } + let mut content_length = None; + let mut seen = HashSet::new(); + for (index, line) in lines.enumerate() { + if index >= NARUON_LIVE_HEADER_COUNT_LIMIT { + return Err(ApiError::LimitExceeded); + } + let (name, value) = line.split_once(':').ok_or(ApiError::InvalidWirePayload)?; + if !valid_http_field_name(name) + || value + .chars() + .any(|character| character.is_control() && character != '\t') + || !seen.insert(name.to_ascii_lowercase()) + || name.eq_ignore_ascii_case("transfer-encoding") + { + return Err(ApiError::InvalidWirePayload); + } + if name.eq_ignore_ascii_case("content-length") { + content_length = Some( + value + .trim() + .parse::() + .map_err(|_| ApiError::InvalidWirePayload)?, + ); + } + } + let declared = content_length.ok_or(ApiError::InvalidWirePayload)?; + if declared > DEFAULT_PROJECT_HISTORY_BYTE_LIMIT { + return Err(ApiError::LimitExceeded); + } + if declared != body.len() { + return Err(ApiError::InvalidWirePayload); + } + Ok(NaruonLiveResponse { + status_code: code, + reason_phrase, + body: body.to_owned(), + }) +} + +fn static_reason(code: u16) -> Result<&'static str, ApiError> { + match code { + 200 => Ok("OK"), + 202 => Ok("Accepted"), + 400 => Ok("Bad Request"), + 403 => Ok("Forbidden"), + 413 => Ok("Payload Too Large"), + 422 => Ok("Unprocessable Entity"), + _ => Err(ApiError::InvalidWirePayload), + } +} + +/// Read stdin leftover bytes on a non-terminal; lookup GET admits empty. +/// +/// # Errors +/// +/// Returns [`ApiError::InvalidWirePayload`] when stdin cannot be read and +/// [`ApiError::LimitExceeded`] when leftover stdin exceeds the live wire +/// limit. +pub fn read_export_idempotency_lookup_cli_stdin( + stdin_is_terminal: bool, + mut stdin: impl Read, +) -> Result { + if stdin_is_terminal { + Ok(String::new()) + } else { + let bytes = read_bounded(&mut stdin, DEFAULT_PROJECT_HISTORY_BYTE_LIMIT)?; + String::from_utf8(bytes).map_err(|_| ApiError::InvalidWirePayload) + } +} + +fn read_bounded(reader: &mut impl Read, maximum_bytes: usize) -> Result, ApiError> { + let mut bytes = Vec::new(); + reader + .take((maximum_bytes + 1) as u64) + .read_to_end(&mut bytes) + .map_err(|error| map_io_error(&error))?; + if bytes.len() > maximum_bytes { + return Err(ApiError::LimitExceeded); + } + Ok(bytes) +} + +fn valid_http_field_name(name: &str) -> bool { + !name.is_empty() + && name.bytes().all(|byte| { + byte.is_ascii_alphanumeric() + || matches!( + byte, + b'!' | b'#' + | b'$' + | b'%' + | b'&' + | b'\'' + | b'*' + | b'+' + | b'-' + | b'.' + | b'^' + | b'_' + | b'`' + | b'|' + | b'~' + ) + }) +} + +#[cfg(test)] +mod branch_coverage_tests { + use std::io::{self, Cursor, Read}; + + use super::{ + loopback_http1_from_export_idempotency_lookup_exchange, parse_http_response, + read_export_idempotency_lookup_cli_stdin, valid_http_field_name, + ExportIdempotencyLookupCliInvocation, ExportIdempotencyLookupCliVerb, + }; + use crate::{ + naruon_export_idempotency_lookup_exchange, ApiError, DEFAULT_PROJECT_HISTORY_BYTE_LIMIT, + NARUON_CONSUMER_CODE, + }; + + fn invocation() -> ExportIdempotencyLookupCliInvocation { + ExportIdempotencyLookupCliInvocation { + verb: ExportIdempotencyLookupCliVerb::Lookup, + host: "127.0.0.1:18081".into(), + origin: "https://tepp.example.test".into(), + consumer: NARUON_CONSUMER_CODE.into(), + idempotency_key: "idem-1".into(), + body: String::new(), + } + } + + #[test] + fn invocation_and_flag_error_arms_are_covered() { + let mut value = invocation(); + value.origin = "http://tepp.example.test".into(); + assert_eq!(value.validate(), Err(ApiError::InvalidWirePayload)); + value = invocation(); + value.consumer = "lineageweave".into(); + assert_eq!(value.validate(), Err(ApiError::InvalidWirePayload)); + value = invocation(); + value.body = "{}".into(); + assert_eq!(value.validate(), Err(ApiError::InvalidWirePayload)); + value = invocation(); + value.idempotency_key = "idem\nother".into(); + assert_eq!(value.validate(), Err(ApiError::InvalidWirePayload)); + value = invocation(); + value.origin = "https://bad/path".into(); + assert!(super::compose_export_idempotency_lookup_cli_http(&value).is_err()); + + for args in [ + vec!["lookup", "host"], + vec!["lookup", "--host"], + vec!["lookup", "--host", "a", "--host", "b"], + vec!["lookup", "--host", ""], + ] { + assert!(ExportIdempotencyLookupCliInvocation::from_args(args, "").is_err()); + } + } + + #[test] + fn exchange_header_and_target_error_arms_are_covered() { + let origin = "https://tepp.example.test"; + let base = naruon_export_idempotency_lookup_exchange(origin, "idem-1").expect("exchange"); + let mut cases = Vec::new(); + let mut value = base.clone(); + value.body = "{}".into(); + cases.push(value); + let mut value = base.clone(); + value.target_url = "http://tepp.example.test/v1/exports/by-idempotency/idem-1".into(); + cases.push(value); + let mut value = base.clone(); + value.target_url = "https://tepp.example.test".into(); + cases.push(value); + for (name, header_value) in [("bad name", "x"), ("x-good", "bad\nvalue")] { + let mut value = base.clone(); + value.headers.push((name.into(), header_value.into())); + cases.push(value); + } + let mut value = base.clone(); + value + .headers + .push(("content-type".into(), "application/json".into())); + cases.push(value); + for index in 0..base.headers.len() { + let mut value = base.clone(); + value.headers.remove(index); + cases.push(value); + } + for value in cases { + assert!(loopback_http1_from_export_idempotency_lookup_exchange( + &value, + "127.0.0.1:18081" + ) + .is_err()); + } + } + + #[test] + fn response_parser_and_reader_error_arms_are_covered() { + use std::fmt::Write as _; + + let oversized_header = "x".repeat(crate::NARUON_LIVE_HEADER_BYTE_LIMIT + 1); + let mut many_headers = String::new(); + for index in 0..=crate::NARUON_LIVE_HEADER_COUNT_LIMIT { + write!(many_headers, "x-{index}: b\r\n").expect("string write"); + } + let cases = [ + vec![0xff], + b"HTTP/1.1 200 OK".to_vec(), + format!("{oversized_header}\r\n\r\n").into_bytes(), + b"HTTP/1.0 200 OK\r\ncontent-length: 0\r\n\r\n".to_vec(), + b"HTTP/1.1 nope\r\ncontent-length: 0\r\n\r\n".to_vec(), + b"HTTP/1.1 999 Unknown\r\ncontent-length: 0\r\n\r\n".to_vec(), + b"HTTP/1.1 200 Bad\r\ncontent-length: 0\r\n\r\n".to_vec(), + b"HTTP/1.1 200 OK\r\nbad\r\n\r\n".to_vec(), + b"HTTP/1.1 200 OK\r\nbad name: x\r\ncontent-length: 0\r\n\r\n".to_vec(), + b"HTTP/1.1 200 OK\r\nx-good: bad\x01value\r\ncontent-length: 0\r\n\r\n".to_vec(), + b"HTTP/1.1 200 OK\r\nx-good: a\r\nx-good: b\r\ncontent-length: 0\r\n\r\n".to_vec(), + b"HTTP/1.1 200 OK\r\ntransfer-encoding: chunked\r\n\r\n".to_vec(), + b"HTTP/1.1 200 OK\r\ncontent-length: x\r\n\r\n".to_vec(), + b"HTTP/1.1 200 OK\r\n\r\n".to_vec(), + b"HTTP/1.1 200 OK\r\ncontent-length: 1\r\n\r\n".to_vec(), + format!( + "HTTP/1.1 200 OK\r\ncontent-length: {}\r\n\r\n", + DEFAULT_PROJECT_HISTORY_BYTE_LIMIT + 1 + ) + .into_bytes(), + format!("HTTP/1.1 200 OK\r\n{many_headers}content-length: 0\r\n\r\n").into_bytes(), + ]; + for bytes in cases { + assert!(parse_http_response(&bytes).is_err()); + } + for (code, reason) in [ + (202, "Accepted"), + (400, "Bad Request"), + (403, "Forbidden"), + (413, "Payload Too Large"), + (422, "Unprocessable Entity"), + ] { + let response = format!("HTTP/1.1 {code} {reason}\r\ncontent-length: 0\r\n\r\n"); + assert_eq!( + parse_http_response(response.as_bytes()) + .expect("response") + .status_code, + code + ); + } + assert!(read_export_idempotency_lookup_cli_stdin(false, Cursor::new([0xff])).is_err()); + assert!(read_export_idempotency_lookup_cli_stdin( + false, + Cursor::new(vec![b'a'; DEFAULT_PROJECT_HISTORY_BYTE_LIMIT + 1]), + ) + .is_err()); + assert!(read_export_idempotency_lookup_cli_stdin(false, FailingReader).is_err()); + assert!(!valid_http_field_name("")); + assert!(!valid_http_field_name("bad name")); + } + + struct FailingReader; + + impl Read for FailingReader { + fn read(&mut self, _buffer: &mut [u8]) -> io::Result { + Err(io::Error::other("redacted")) + } + } +} diff --git a/crates/tepp_api/src/lib.rs b/crates/tepp_api/src/lib.rs index fd25065e0..e169af816 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_cli; mod export_idempotency_lookup_http; mod lineage_criterion_anchor; mod lineage_pair_criterion; @@ -119,6 +120,22 @@ pub use export_idempotency_lookup_http::ExportIdempotencyLookup; 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; +/// One operator CLI invocation against export idempotency-lookup GET. +pub use export_idempotency_lookup_cli::ExportIdempotencyLookupCliInvocation; +/// Supported export idempotency-lookup CLI verbs. +pub use export_idempotency_lookup_cli::ExportIdempotencyLookupCliVerb; +/// Compose HTTP/1.1 lookup GET from a typed CLI invocation. +pub use export_idempotency_lookup_cli::compose_export_idempotency_lookup_cli_http; +/// Dispatch lookup CLI against an in-process listener. +pub use export_idempotency_lookup_cli::dispatch_export_idempotency_lookup_cli; +/// Execute lookup CLI over loopback TCP. +pub use export_idempotency_lookup_cli::execute_export_idempotency_lookup_cli; +/// Render a typed lookup GET as HTTP/1.1 for a loopback host. +pub use export_idempotency_lookup_cli::loopback_http1_from_export_idempotency_lookup_exchange; +/// Read leftover stdin for lookup GET (empty admitted). +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; /// Analytical export purpose. pub use authorization::AnalyticalPurpose; diff --git a/crates/tepp_api/tests/export_idempotency_lookup_cli_contract.rs b/crates/tepp_api/tests/export_idempotency_lookup_cli_contract.rs new file mode 100644 index 000000000..d00b5140e --- /dev/null +++ b/crates/tepp_api/tests/export_idempotency_lookup_cli_contract.rs @@ -0,0 +1,404 @@ +//! Contract tests for the naruon export idempotency-lookup loopback CLI. + +use tepp_api::{ + compose_export_idempotency_lookup_cli_http, dispatch_export_idempotency_lookup_cli, + execute_export_idempotency_lookup_cli, loopback_http1_from_export_idempotency_lookup_exchange, + naruon_export_idempotency_lookup_exchange, read_export_idempotency_lookup_cli_stdin, + render_export_idempotency_lookup_cli_stdout, AnalysisRunLiveService, AnalyticalPurpose, + ApiError, ExportAuthorizationRequest, ExportIdempotencyLookup, + ExportIdempotencyLookupCliInvocation, ExportIdempotencyLookupCliVerb, NaruonHttpExchange, + NaruonLiveResponse, NaruonLiveService, EXPORT_IDEMPOTENCY_LOOKUP_KEY_MAX_LEN, + LINEAGEWEAVE_CONSUMER_CODE, NARUON_CONSUMER_CODE, NARUON_EXPORT_PATH, +}; + +const ORIGIN: &str = "https://tepp.example.test"; +const SCHEMA: &str = "tepp.scientific_acceptance.v1"; + +fn sample_request() -> ExportAuthorizationRequest { + ExportAuthorizationRequest { + tenant_workspace_id: "export-lookup-cli-tenant".into(), + principal_id: "principal-analyst-1".into(), + purpose: AnalyticalPurpose::ModularServiceConsumer, + artifact_id: "artifact-lookup-cli-1".into(), + includes_source_text: false, + } +} + +fn export_post(request: &ExportAuthorizationRequest, idempotency_key: &str) -> String { + let body = serde_json::to_string(request).expect("request json"); + format!( + "POST {NARUON_EXPORT_PATH} HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: {NARUON_CONSUMER_CODE}\r\ntepp-contract-version: 1\r\nidempotency-key: {idempotency_key}\r\ncontent-length: {}\r\n\r\n{body}", + body.len() + ) +} + +fn lookup_args<'a>(host: &'a str, key: &'a str, consumer: &'a str) -> [&'a str; 9] { + [ + "lookup", + "--host", + host, + "--origin", + ORIGIN, + "--consumer", + consumer, + "--idempotency-key", + key, + ] +} + +#[test] +fn verbs_and_from_args_fail_closed() { + assert_eq!( + ExportIdempotencyLookupCliVerb::parse("lookup").expect("lookup"), + ExportIdempotencyLookupCliVerb::Lookup + ); + assert_eq!(ExportIdempotencyLookupCliVerb::Lookup.as_str(), "lookup"); + assert_eq!( + ExportIdempotencyLookupCliVerb::parse("get"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + ExportIdempotencyLookupCliInvocation::from_args( + lookup_args("8.8.8.8:80", "idem-1", NARUON_CONSUMER_CODE), + "" + ) + .unwrap_err(), + ApiError::AuthorizationDenied + ); + assert_eq!( + ExportIdempotencyLookupCliInvocation::from_args( + [ + "lookup", + "--host", + "localhost:18081", + "--origin", + ORIGIN, + "--idempotency-key", + "idem-1" + ], + "" + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + ExportIdempotencyLookupCliInvocation::from_args( + [ + "lookup", + "--host", + "127.0.0.1:18081", + "--origin", + "http://tepp.example.test", + "--idempotency-key", + "idem-1" + ], + "" + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + ExportIdempotencyLookupCliInvocation::from_args( + [ + "lookup", + "--host", + "127.0.0.1:18081", + "--origin", + ORIGIN, + "--authorization", + "secret", + "--idempotency-key", + "idem-1" + ], + "" + ) + .unwrap_err(), + ApiError::AuthorizationDenied + ); +} + +#[test] +fn from_args_refuses_lineageweave_slash_body_size_and_pagination() { + assert_eq!( + ExportIdempotencyLookupCliInvocation::from_args( + lookup_args("127.0.0.1:18081", "idem-1", LINEAGEWEAVE_CONSUMER_CODE), + "" + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + ExportIdempotencyLookupCliInvocation::from_args( + lookup_args("127.0.0.1:18081", "idem-1", "unpublished"), + "" + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + ExportIdempotencyLookupCliInvocation::from_args( + lookup_args("127.0.0.1:18081", "idem-1", NARUON_CONSUMER_CODE), + "{}" + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + ExportIdempotencyLookupCliInvocation::from_args( + lookup_args("127.0.0.1:18081", "idem/slash", NARUON_CONSUMER_CODE), + "" + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + ExportIdempotencyLookupCliInvocation::from_args( + lookup_args("127.0.0.1:18081", "by-idempotency", NARUON_CONSUMER_CODE), + "" + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + ExportIdempotencyLookupCliInvocation::from_args( + lookup_args( + "127.0.0.1:18081", + &"a".repeat(EXPORT_IDEMPOTENCY_LOOKUP_KEY_MAX_LEN + 1), + NARUON_CONSUMER_CODE + ), + "" + ) + .unwrap_err(), + ApiError::LimitExceeded + ); + assert_eq!( + ExportIdempotencyLookupCliInvocation::from_args( + [ + "lookup", + "--host", + "127.0.0.1:18081", + "--origin", + ORIGIN, + "--idempotency-key", + "idem-1", + "--page-limit", + "1" + ], + "" + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); +} + +#[test] +fn compose_is_typed_https_get_without_credentials() { + let invocation = ExportIdempotencyLookupCliInvocation::from_args( + lookup_args("127.0.0.1:18081", "idem-1", NARUON_CONSUMER_CODE), + "", + ) + .expect("invocation"); + let http = compose_export_idempotency_lookup_cli_http(&invocation).expect("http"); + assert!(http.starts_with("GET /v1/exports/by-idempotency/idem-1 HTTP/1.1")); + assert!(http.contains("tepp-consumer: naruon")); + assert!(!http.to_ascii_lowercase().contains("authorization")); + assert!(!http.to_ascii_lowercase().contains("idempotency-key:")); + assert!(!http.contains("rmse")); + assert!(!http.contains(SCHEMA)); +} + +#[test] +fn naruon_cli_resolves_export_identity_and_naruon_live_stays_post_only() { + let mut service = AnalysisRunLiveService::new(); + let request = sample_request(); + let posted = service.handle_http_request(&export_post(&request, "export-lookup-cli-1")); + assert_eq!(posted.status_code, 200, "{}", posted.body); + let invocation = ExportIdempotencyLookupCliInvocation::from_args( + lookup_args( + "127.0.0.1:18081", + "export-lookup-cli-1", + NARUON_CONSUMER_CODE, + ), + "", + ) + .expect("invocation"); + let got = dispatch_export_idempotency_lookup_cli(&mut service, &invocation).expect("get"); + assert_eq!(got.status_code, 200, "{}", got.body); + let stdout = render_export_idempotency_lookup_cli_stdout(&invocation, &got).expect("out"); + let lookup = ExportIdempotencyLookup::from_json(&stdout).expect("lookup"); + assert_eq!(lookup.idempotency_key, "export-lookup-cli-1"); + assert_eq!(lookup.decision_code, "purpose_bound_export_allowed"); + assert!(!stdout.contains("rmse")); + assert!(!stdout.contains(SCHEMA)); + assert!(!stdout.contains("tenant_workspace_id")); + assert!(!stdout.contains("principal_id")); + let missing = ExportIdempotencyLookupCliInvocation::from_args( + lookup_args("127.0.0.1:18081", "missing-key", NARUON_CONSUMER_CODE), + "", + ) + .expect("missing"); + let denied = dispatch_export_idempotency_lookup_cli(&mut service, &missing).expect("denied"); + assert_eq!(denied.status_code, 400); + assert!( + render_export_idempotency_lookup_cli_stdout(&missing, &denied) + .expect("err") + .contains("invalid_wire_payload") + ); + let mut naruon = NaruonLiveService::new(); + assert_eq!( + naruon + .handle_http_request( + &compose_export_idempotency_lookup_cli_http(&invocation).expect("composed") + ) + .status_code, + 400 + ); +} + +#[test] +fn render_refuses_metrics_schema_and_empty_success() { + let invocation = ExportIdempotencyLookupCliInvocation::from_args( + lookup_args("127.0.0.1:18081", "idem-1", NARUON_CONSUMER_CODE), + "", + ) + .expect("invocation"); + assert_eq!( + render_export_idempotency_lookup_cli_stdout( + &invocation, + &NaruonLiveResponse { + status_code: 200, + reason_phrase: "OK", + body: String::new() + } + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + render_export_idempotency_lookup_cli_stdout( + &invocation, + &NaruonLiveResponse { + status_code: 200, + reason_phrase: "OK", + body: r#"{"export_id":"e","rmse":1.0}"#.into() + } + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + render_export_idempotency_lookup_cli_stdout( + &invocation, + &NaruonLiveResponse { + status_code: 200, + reason_phrase: "OK", + body: format!(r#"{{"schema_version":"{SCHEMA}"}}"#) + } + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); +} + +#[test] +fn loopback_http1_refuses_non_get_collection_get_by_id_request_and_credentials() { + let host = "127.0.0.1:18081"; + let exchange = naruon_export_idempotency_lookup_exchange(ORIGIN, "idem-1").expect("ex"); + let ok = loopback_http1_from_export_idempotency_lookup_exchange(&exchange, host).expect("ok"); + assert!(ok.starts_with("GET /v1/exports/by-idempotency/idem-1 HTTP/1.1")); + let mut posted = exchange.clone(); + posted.method = "POST"; + assert_eq!( + loopback_http1_from_export_idempotency_lookup_exchange(&posted, host).unwrap_err(), + ApiError::InvalidWirePayload + ); + let mut by_id = exchange.clone(); + by_id.target_url = format!("{ORIGIN}{NARUON_EXPORT_PATH}/export-1"); + assert_eq!( + loopback_http1_from_export_idempotency_lookup_exchange(&by_id, host).unwrap_err(), + ApiError::InvalidWirePayload + ); + let mut request_path = exchange.clone(); + request_path.target_url = format!("{ORIGIN}{NARUON_EXPORT_PATH}/export-1/request"); + assert_eq!( + loopback_http1_from_export_idempotency_lookup_exchange(&request_path, host).unwrap_err(), + ApiError::InvalidWirePayload + ); + let credentialed = NaruonHttpExchange { + method: "GET", + target_url: format!("{ORIGIN}{NARUON_EXPORT_PATH}/by-idempotency/idem-1"), + headers: vec![("authorization".into(), "secret".into())], + body: String::new(), + }; + assert_eq!( + loopback_http1_from_export_idempotency_lookup_exchange(&credentialed, host).unwrap_err(), + ApiError::AuthorizationDenied + ); +} + +#[test] +fn execute_over_tcp_and_stdin_reader() { + let mut service = AnalysisRunLiveService::bind_loopback().expect("bind"); + let addr = service.local_addr().expect("addr").to_string(); + let request = sample_request(); + let posted = service.handle_http_request(&export_post(&request, "export-lookup-tcp")); + assert_eq!(posted.status_code, 200, "{}", posted.body); + let handle = std::thread::spawn(move || { + drop(service.serve_one()); + }); + let invocation = ExportIdempotencyLookupCliInvocation::from_args( + lookup_args(addr.as_str(), "export-lookup-tcp", NARUON_CONSUMER_CODE), + "", + ) + .expect("tcp"); + let response = execute_export_idempotency_lookup_cli(&invocation).expect("execute"); + assert_eq!(response.status_code, 200, "{}", response.body); + let lookup = ExportIdempotencyLookup::from_json( + &render_export_idempotency_lookup_cli_stdout(&invocation, &response).expect("stdout"), + ) + .expect("parsed"); + assert_eq!(lookup.idempotency_key, "export-lookup-tcp"); + handle.join().expect("join"); + assert!( + read_export_idempotency_lookup_cli_stdin(true, std::io::empty()) + .expect("tty") + .is_empty() + ); + assert!( + read_export_idempotency_lookup_cli_stdin(false, std::io::Cursor::new(b"")) + .expect("pipe") + .is_empty() + ); +} + +#[test] +fn binary_reports_redacted_success_and_failure_statuses() { + let mut service = AnalysisRunLiveService::bind_loopback().expect("bind"); + let addr = service.local_addr().expect("addr").to_string(); + let request = sample_request(); + let posted = service.handle_http_request(&export_post(&request, "export-lookup-bin")); + assert_eq!(posted.status_code, 200, "{}", posted.body); + let handle = std::thread::spawn(move || { + service.serve_one().expect("success request"); + service.serve_one().expect("missing request"); + }); + let binary = env!("CARGO_BIN_EXE_tepp-export-lookup"); + let run = |key: &str| { + std::process::Command::new(binary) + .args(lookup_args(&addr, key, NARUON_CONSUMER_CODE)) + .output() + .expect("binary") + }; + let success = run("export-lookup-bin"); + assert!( + success.status.success(), + "{}", + String::from_utf8_lossy(&success.stderr) + ); + assert!(String::from_utf8_lossy(&success.stdout).contains("export-lookup-bin")); + assert!(!String::from_utf8_lossy(&success.stdout).contains(SCHEMA)); + let failure = run("missing-key"); + assert!(!failure.status.success()); + assert!(String::from_utf8_lossy(&failure.stderr).contains("invalid API wire payload")); + handle.join().expect("server"); +} diff --git a/docs/API_CONTRACT.md b/docs/API_CONTRACT.md index 4082b5ba9..f759e6188 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. +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). ## 2. Contract families diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 04fa95118..b0d6fc8e1 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -54,6 +54,7 @@ The full APA 7th standards/literature register remains `docs/research/standards- | 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 | +| 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 | | 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/0094-export-idempotency-lookup-cli.md b/docs/adr/0094-export-idempotency-lookup-cli.md new file mode 100644 index 000000000..f3074e7f2 --- /dev/null +++ b/docs/adr/0094-export-idempotency-lookup-cli.md @@ -0,0 +1,71 @@ +# ADR 0094 — Loopback export idempotency-key lookup CLI + +**Decision status:** Accepted +**Implementation maturity:** active-PR +**Date:** 2026-09-01 +**Supersedes:** None; complements ADR 0093. Does not re-open cancel lineages. +Does not supersede ADR 0014. Unique versus protected main; 0026–0093 occupied +including #465=0093, #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 0093 publishes `GET /v1/exports/by-idempotency/{idempotency_key}`. Operators +still had no published binary that mints that GET onto spawned `tepp-loopback` +TCP. Duplicating lookup GET (#465), 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 CLI (#401), Leiden, or GAP-010 +would collide with live PRs. Cancel lineages stay closed. LineageWeave is +refused on this naruon-owned adapter. `NaruonLiveService` stays POST-only. + +## Decision + +Publish `tepp-export-lookup lookup` which mints +`naruon_export_idempotency_lookup_exchange` onto spawned `tepp-loopback` TCP. +Empty stdin is admitted. Nonempty leftover stdin, public bind, `localhost`, +`http` origin, unpublished consumer, LineageWeave, reserved prefix-as-key, and +credential flags fail closed. Dedicated binary so it does not collide with +`tepp-export-list` (#444), `tepp-export-get` (#417), `tepp-export-request` +(#459), or export-authorize (#410). Response is the metric-free +`ExportIdempotencyLookup`. `tepp.scientific_acceptance.v1` never appears. + +## Alternatives considered + +1. Re-open cancel CLI — rejected. +2. Reuse `tepp-export-get` — rejected; that is ADR 0055. +3. Reuse `tepp-export-request` — rejected; that is stored-request GET. +4. Dedicated lookup binary — accepted. + +## Consequences + +CLI success is not measurement evidence and is not an ADR 0014 claim. +Sequence remains association, not causation. + +## Failure and recovery + +LineageWeave, nonempty leftover stdin, extra segments, slash/NUL, missing +keys, public bind, `localhost`, reserved prefix-as-key, and metric keys fail +closed. + +## Verification + +- `tepp-export-lookup lookup` of an authorized export prints + `export_id`/`decision_code`/`idempotency_key` without RMSE or + `tepp.scientific_acceptance.v1`; +- LineageWeave, public bind, `localhost`, `http` origin, leftover stdin, + slash/NUL, reserved prefix, and missing keys fail closed; +- Clippy `-D warnings`, `tepp_api` tests, rustdoc, and exact-head review remain + required. + +## Rollback and supersession + +Rollback removes the published binary; lookup GET remains valid. A superseding +ADR is required to persist the registry, bind a public address, re-open cancel, +emit scientific-acceptance, open LineageWeave on this adapter, add GET to +`NaruonLiveService`, or treat CLI success as an ADR 0014 claim. + +## Related authority + +ADR 0093, ADR 0054, ADR 0009, ADR 0011, ADR 0014, RFC 9110 (Fielding, Nottingham, & Reschke, 2022). diff --git a/docs/adr/README.md b/docs/adr/README.md index 122217ab4..7768eb4a1 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -32,6 +32,7 @@ Read [`ADR_POLICY.md`](ADR_POLICY.md) first. **Decision status and implementatio | [0025](0025-macos-native-rust-mlx-metal-boundary.md) | macOS-native Rust-owned MLX Metal execution | Accepted | accepted-target | Compose authenticates to a native host service; Linux never claims Metal, and actual backend/parity receipts fail closed. | | [0054](0054-export-retrieval-get.md) | Loopback export retrieval GET | Accepted | active-PR | `AnalysisRunLiveService` mints a metric-free `export_id` on naruon `POST /v1/exports` and serves `GET /v1/exports/{export_id}`; `NaruonLiveService` stays POST-only. | | [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. | | [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. | @@ -144,6 +145,7 @@ Use the narrowest owning ADR when decisions overlap: - **macOS-native Rust-owned MLX Metal execution:** ADR 0024. - **loopback export retrieval identity:** ADR 0054. - **loopback export idempotency-key lookup:** ADR 0093. +- **loopback export idempotency-key lookup CLI:** ADR 0094. ## Change and supersession rule diff --git a/docs/research/export-idempotency-lookup-cli.md b/docs/research/export-idempotency-lookup-cli.md new file mode 100644 index 000000000..2bd7f9cf1 --- /dev/null +++ b/docs/research/export-idempotency-lookup-cli.md @@ -0,0 +1,37 @@ +# Export idempotency-key lookup CLI (doctoring) + +## Scope + +Operators who receive a 200 purpose-bound export authorization still cannot +mint `GET /v1/exports/by-idempotency/{idempotency_key}` without writing raw +HTTP. `tepp-export-lookup lookup` is the first published binary that mints +that typed naruon exchange onto spawned `tepp-loopback` TCP. 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, reserved prefix-as-key, leftover stdin, 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 0094 owns this lookup CLI. +- ADR 0093 owns 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 + +lookup GET (#465), 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 CLI (#401), 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 03/23] 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 04/23] 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 05/23] 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", From 29e87b805d1be0aade7dea21669414dd1250643e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 18:38:53 +0000 Subject: [PATCH 06/23] feat(api): retrieve stored export authorization by idempotency key Publish GET /v1/exports/by-idempotency/{idempotency_key}/request so operators who hold a 200 authorization receipt can recover the stored create without a second hop through export_id stored-request. Metric-free. Zero and ambiguous matches fail closed. LineageWeave refused. NaruonLiveService stays POST-only. --- ...t-idempotency-lookup-stored-request-get.md | 1 + DOCUMENTATION.md | 1 + crates/tepp_api/src/analysis_run_live.rs | 46 +++ ..._idempotency_lookup_stored_request_http.rs | 377 ++++++++++++++++++ crates/tepp_api/src/lib.rs | 11 + ...ncy_lookup_stored_request_http_contract.rs | 103 +++++ docs/API_CONTRACT.md | 2 +- docs/TRACEABILITY.md | 1 + ...t-idempotency-lookup-stored-request-get.md | 96 +++++ docs/adr/README.md | 1 + ...-idempotency-lookup-stored-request-http.md | 16 + 11 files changed, 654 insertions(+), 1 deletion(-) create mode 100644 CHANGELOG.d/export-idempotency-lookup-stored-request-get.md create mode 100644 crates/tepp_api/src/export_idempotency_lookup_stored_request_http.rs create mode 100644 crates/tepp_api/tests/export_idempotency_lookup_stored_request_http_contract.rs create mode 100644 docs/adr/0099-export-idempotency-lookup-stored-request-get.md create mode 100644 docs/research/export-idempotency-lookup-stored-request-http.md 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). From 8f38c2708771ead5ca197b65b3c8973ac7aebfea Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 04:00:16 +0900 Subject: [PATCH 07/23] test(security): require export stored-request lookup isolation --- ...ncy_lookup_stored_request_http_contract.rs | 51 +++++++++---------- 1 file changed, 23 insertions(+), 28 deletions(-) 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 index 7706af542..22a3c4863 100644 --- 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 @@ -1,8 +1,9 @@ -//! Contract tests for export lookup stored-request GET. +//! Contract tests for the quarantined export lookup stored-request GET. use tepp_api::{ AnalysisRunLiveService, AnalyticalPurpose, ApiError, ExportAuthorizationRequest, - NaruonLiveService, naruon_export_idempotency_lookup_stored_request_exchange, + NaruonLiveService, export_idempotency_lookup_stored_request_path_key, + naruon_export_idempotency_lookup_stored_request_exchange, refuse_metrics_on_export_lookup_stored_request_payload, }; @@ -24,48 +25,42 @@ fn export_post_http(body: &str) -> String { } #[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"); +fn lookup_stored_request_exchange_is_quarantined_without_tenant_principal_binding() { assert_eq!( - exchange.target_url, - "https://tepp.example.test/v1/exports/by-idempotency/export-idem-1/request" + naruon_export_idempotency_lookup_stored_request_exchange( + "https://tepp.example.test", + "export-idem-1", + ), + Err(ApiError::AuthorizationDenied) ); - assert!(exchange.body.is_empty()); - assert!( - exchange - .headers - .iter() - .any(|(name, value)| name == "tepp-consumer" && value == "naruon") + assert_eq!( + export_idempotency_lookup_stored_request_path_key( + "/v1/exports/by-idempotency/export%2Fidem/request" + ), + Err(ApiError::InvalidWirePayload) ); } #[test] -fn live_get_returns_stored_authorization_request() { +fn live_get_without_tenant_principal_scope_fails_closed() { 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!(got.status_code, 400, "{}", got.body); + assert!(!got.body.contains("export-live-tenant")); + assert!(!got.body.contains("principal-analyst-1")); + assert!(!got.body.contains("artifact-live-1")); 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\"") + refuse_metrics_on_export_lookup_stored_request_payload(&body), + Err(ApiError::InvalidWirePayload) ); + assert_eq!( service .handle_http_request( From 45754fdb46d4a2c84db9e440f5eb0001c2f26d10 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 04:01:08 +0900 Subject: [PATCH 08/23] fix(security): quarantine unscoped export stored-request lookup --- ..._idempotency_lookup_stored_request_http.rs | 135 +++++++++--------- 1 file changed, 68 insertions(+), 67 deletions(-) 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 index 0e71fc5e5..bfc8cc2d3 100644 --- a/crates/tepp_api/src/export_idempotency_lookup_stored_request_http.rs +++ b/crates/tepp_api/src/export_idempotency_lookup_stored_request_http.rs @@ -1,18 +1,15 @@ -//! Provider-owned export lookup stored-request GET contracts. +//! Export idempotency-key stored-request lookup 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. +//! `GET /v1/exports/by-idempotency/{idempotency_key}/request` was introduced as +//! a convenience lookup for an accepted Naruon export authorization. Review of +//! the first implementation showed that consumer-only lookup could search all +//! Naruon tenant namespaces and return the original request, including tenant +//! and principal identity. The route is therefore fail-closed until the +//! Analysis Run boundary has an explicit tenant-and-principal authorization +//! binding. The parser remains available so the live dispatcher can recognize +//! and reject the reserved resource deterministically; the client exchange +//! builder also refuses activation. `LineageWeave` remains outside this +//! Naruon-owned adapter and `tepp.scientific_acceptance.v1` is never admitted. use crate::ApiError; use crate::export_idempotency_lookup_http::{ @@ -24,7 +21,9 @@ 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] = [ +const FORBIDDEN_STORED_REQUEST_KEYS: [&str; 15] = [ + "tenant_workspace_id", + "principal_id", "rmse", "rmse_standard_error", "mean_bias", @@ -40,18 +39,18 @@ const FORBIDDEN_STORED_REQUEST_KEYS: [&str; 13] = [ "terminal_result", ]; -/// Extract the opaque idempotency key from -/// `GET /v1/exports/by-idempotency/{idempotency_key}/request`. +/// Extract the opaque idempotency key from the reserved stored-request route. /// -/// The route is segmented before percent decoding, so an encoded `/` remains -/// data inside one opaque key rather than becoming an extra path segment. +/// Raw and percent-decoded slashes are rejected. Keeping the key in one route +/// segment avoids ambiguous normalization between proxies and the loopback +/// dispatcher. /// /// # 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. +/// `by-idempotency` prefix, reserved prefix used as the key, slash, NUL, empty +/// key, or 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) @@ -73,10 +72,7 @@ pub fn export_idempotency_lookup_stored_request_path_key(path: &str) -> Result EXPORT_IDEMPOTENCY_LOOKUP_KEY_MAX_LEN { @@ -91,16 +87,18 @@ 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. +/// Refuse stored-request JSON that carries sensitive identity or scientific 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. +/// Empty payloads are admitted for the GET request body. A serialized +/// [`crate::ExportAuthorizationRequest`] contains tenant and principal identity, +/// so it is intentionally rejected while this route lacks caller scope binding. +/// This gives the existing live dispatcher a fail-closed quarantine without +/// weakening the separate metric-free export identity lookup. /// /// # Errors /// -/// Returns [`ApiError::InvalidWirePayload`] when a forbidden metric key is -/// present. +/// Returns [`ApiError::InvalidWirePayload`] when a forbidden identity, +/// scientific metric, report, or terminal-result key is present. pub fn refuse_metrics_on_export_lookup_stored_request_payload( payload: &str, ) -> Result<(), ApiError> { @@ -146,22 +144,27 @@ fn refuse_metrics_on_json(value: &serde_json::Value) -> Result<(), ApiError> { } } -/// Build a credential-free naruon lookup stored-request GET exchange. +/// Validate a would-be Naruon lookup stored-request GET and fail closed. /// -/// 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`. +/// No exchange is emitted until the service can bind the lookup to both the +/// authorized tenant/workspace and principal. Valid origin/key syntax is still +/// checked so malformed callers receive the existing deterministic validation +/// errors instead of using quarantine as an input-validation bypass. /// /// # Errors /// -/// Returns a fail-closed origin or identity error. +/// Returns a fail-closed origin/identity error for invalid inputs and +/// [`ApiError::AuthorizationDenied`] for otherwise valid requests while the +/// route is quarantined. 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') { + if idempotency_key == EXPORT_IDEMPOTENCY_LOOKUP_PREFIX + || idempotency_key.contains('/') + || idempotency_key.contains('\0') + { return Err(ApiError::InvalidWirePayload); } if idempotency_key.len() > EXPORT_IDEMPOTENCY_LOOKUP_KEY_MAX_LEN { @@ -171,17 +174,8 @@ pub fn naruon_export_idempotency_lookup_stored_request_exchange( 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(), - }) + let _validated_target = compose_https_target(origin, &target_path)?; + Err(ApiError::AuthorizationDenied) } fn encode_path_segment(value: &str) -> String { @@ -257,25 +251,13 @@ mod tests { }; #[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"); + fn lookup_stored_request_route_is_recognized_but_client_activation_is_quarantined() { 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")) + naruon_export_idempotency_lookup_stored_request_exchange( + "https://tepp.example.test", + "idem-9", + ), + Err(ApiError::AuthorizationDenied) ); assert!(is_export_idempotency_lookup_stored_request_path( "/v1/exports/by-idempotency/idem-9/request" @@ -301,6 +283,12 @@ mod tests { refuse_metrics_on_export_lookup_stored_request_payload(""), Ok(()) ); + assert_eq!( + refuse_metrics_on_export_lookup_stored_request_payload( + r#"{"tenant_workspace_id":"tenant-a","principal_id":"principal-a","artifact_id":"artifact-a"}"# + ), + Err(ApiError::InvalidWirePayload) + ); } #[test] @@ -341,6 +329,12 @@ mod tests { ), Err(ApiError::InvalidWirePayload) ); + assert_eq!( + export_idempotency_lookup_stored_request_path_key( + "/v1/exports/by-idempotency/idem%2F9/request" + ), + Err(ApiError::InvalidWirePayload) + ); assert_eq!( export_idempotency_lookup_stored_request_path_key(&format!( "/v1/exports/by-idempotency/{}/request", @@ -369,6 +363,13 @@ mod tests { ), Err(ApiError::InvalidWirePayload) ); + assert_eq!( + naruon_export_idempotency_lookup_stored_request_exchange( + "https://tepp.example.test", + "idem/9", + ), + Err(ApiError::InvalidWirePayload) + ); assert_eq!( refuse_metrics_on_export_lookup_stored_request_payload(r#"{"rmse":1.0}"#), Err(ApiError::InvalidWirePayload) From 14ef78c26a54a9a9e2e431d50731f830f65097e6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 04:01:49 +0900 Subject: [PATCH 09/23] docs(security): quarantine unscoped export request lookup --- ...t-idempotency-lookup-stored-request-get.md | 162 +++++++++++------- 1 file changed, 97 insertions(+), 65 deletions(-) diff --git a/docs/adr/0099-export-idempotency-lookup-stored-request-get.md b/docs/adr/0099-export-idempotency-lookup-stored-request-get.md index 0c903489a..4fbb75c91 100644 --- a/docs/adr/0099-export-idempotency-lookup-stored-request-get.md +++ b/docs/adr/0099-export-idempotency-lookup-stored-request-get.md @@ -1,94 +1,126 @@ -# 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. +# ADR 0099 — Quarantine unscoped export idempotency-key stored-request lookup + +**Decision status:** Accepted +**Implementation maturity:** active-PR security quarantine +**Date:** 2026-09-01 +**Supersedes:** the initial active-route interpretation of this same ADR; complements ADR 0093 and ADR 0089. Does not supersede ADR 0014. +**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. +ADR 0093 publishes `GET /v1/exports/by-idempotency/{idempotency_key}` as a +metric-free identity lookup. A follow-on implementation added +`GET /v1/exports/by-idempotency/{idempotency_key}/request` to return the stored +export-authorization request directly. + +Exact-head review found that the first implementation scoped lookup only by +`tepp-consumer: naruon`. The underlying export registry is keyed by consumer, +tenant/workspace, and idempotency key, but the GET searched the whole Naruon +consumer namespace. A caller that knew another tenant's otherwise unique +idempotency key could therefore receive that tenant's original authorization +request, including `tenant_workspace_id` and `principal_id`. The route had no +request field or trusted header that could prove the caller's tenant and +principal scope. + +This is an authorization-boundary defect, not a documentation-only issue. An +idempotency key is replay identity; it is not authorization to disclose the +stored create request. ## 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. +The stored-request-by-idempotency route is quarantined fail closed until the +Analysis Run API has an explicit tenant-and-principal authorization binding. + +- The live dispatcher may still recognize the reserved route so it cannot fall + through to a different GET interpretation, but serialization of a stored + authorization request is rejected because tenant/principal identity is + forbidden on this unscoped response path. +- The public Naruon exchange builder validates origin and key syntax, then + returns `authorization_denied` rather than minting a request that the service + cannot authorize correctly. +- Raw and percent-decoded `/` in the idempotency key are rejected. Proxy/path + normalization must not change the identity interpreted by the loopback + dispatcher. +- `tenant_workspace_id` and `principal_id` are now explicit forbidden response + keys for this quarantined lookup, in addition to scientific metric and + terminal-result keys. +- Lookup GET from ADR 0093 remains metric-free and separate. Stored-request GET + by server-issued `export_id` remains a different adapter contract and is not + authorized by this ADR. +- `NaruonLiveService` stays POST-only. LineageWeave remains refused on this + Naruon-owned adapter. HTTP failure/success is never ADR 0014 scientific + evidence. + +Reactivation requires a versioned contract that binds the request to the +already-authorized tenant/workspace and principal (or an equivalent stronger +authorization context), proves cross-tenant and cross-principal denial, and +passes exact-head security/coverage/review gates. A consumer-only check or +knowledge of an idempotency key is insufficient. ## Non-goals +- Inventing a new authentication scheme inside this repair. +- Treating an idempotency key as a bearer credential. +- Weakening the metric-free export identity lookup from ADR 0093. - 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. +- Promoting an ADR 0014 scientific claim from transport state. +- Re-opening cancel lineages, persistence, Leiden, or GAP-010 UI work. ## 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. +1. Keep the route because idempotency keys are expected to be opaque — rejected; + opacity is not an authorization boundary. +2. Return the original request after checking only `tepp-consumer: naruon` — + rejected because all Naruon tenants share that consumer code. +3. Add ad-hoc tenant/principal headers in this repair — rejected until those + values have a defined authenticated authority and versioned admission + contract; trusting caller-supplied scope would only move the defect. +4. Quarantine the route while preserving deterministic parsing and evidence — + accepted. ## Consequences -Operators can recover the stored create from an authorization key without a -second hop. HTTP 200 is not measurement evidence. +The convenience one-hop stored-request lookup is temporarily unavailable, but +no cross-tenant request identity can be disclosed through this path. Operators +can continue to use the metric-free idempotency lookup and other independently +authorized export surfaces. The feature can return only after its authorization +context is explicit and testable. ## 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`. +A syntactically valid stored-request-by-idempotency client request fails closed +with authorization denial. Direct loopback attempts cannot emit the stored +request because the response guard rejects tenant/principal identity. Unknown +keys, extra path segments, raw or percent-decoded slash, NUL, reserved prefix, +nonempty body, POST, LineageWeave, unpublished consumers, credential headers, +and non-loopback hosts remain fail closed. + +Recovery requires RED tests proving that same idempotency keys across different +tenants and principals cannot cross-read, followed by a versioned authorization +binding and exact-head GREEN security/coverage evidence. ## 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. +- a valid-looking client exchange is denied while scope binding is absent; +- a posted export followed by unscoped stored-request-by-idempotency GET returns + a redacted error and does not echo tenant, principal, or artifact identity; +- serialized authorization requests carrying `tenant_workspace_id` or + `principal_id` are rejected on this response boundary; +- `%2F` and raw slash in an idempotency key are rejected consistently; +- LineageWeave, unknown keys, cancel extra-segments, metric payloads and + malformed origins remain fail closed; +- exact-head branch/line coverage, clippy, rustdoc, security workflows and + independent review remain required before any surviving landing vehicle may + advance. ## 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. +Do not roll back to the unscoped active route. A future superseding decision may +reactivate this resource only with an authenticated tenant/principal (or +stronger equivalent) scope contract and regression evidence. Repository-wide +ADR identity normalization remains tracked separately; this file preserves the +existing 0099 lineage rather than minting another operation-specific ADR. ## Related authority From 9d61148867bcebe4c793d4cb1ee5bf26b753d7f4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 04:02:02 +0900 Subject: [PATCH 10/23] docs(security): doctor export lookup quarantine --- ...-idempotency-lookup-stored-request-http.md | 37 ++++++++++++------- 1 file changed, 24 insertions(+), 13 deletions(-) diff --git a/docs/research/export-idempotency-lookup-stored-request-http.md b/docs/research/export-idempotency-lookup-stored-request-http.md index 65cc143ba..2e564ce82 100644 --- a/docs/research/export-idempotency-lookup-stored-request-http.md +++ b/docs/research/export-idempotency-lookup-stored-request-http.md @@ -1,16 +1,27 @@ -# Export idempotency-key lookup stored-request GET (doctoring) +# Export idempotency-key stored-request lookup security 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). +The first `GET /v1/exports/by-idempotency/{idempotency_key}/request` +implementation searched the whole Naruon consumer namespace and returned the +stored export-authorization request. The registry itself is tenant-aware, but +the GET carried no authenticated tenant/workspace or principal scope. Knowledge +of a unique idempotency key could therefore disclose another tenant's +`tenant_workspace_id`, `principal_id`, and artifact request metadata. -`tepp.scientific_acceptance.v1` never appears. HTTP 200 is not a scientific -claim. `NaruonLiveService` stays POST-only. LineageWeave is refused. +ADR 0099 now quarantines the route. A syntactically valid client exchange is +denied until a versioned tenant-and-principal authorization context exists, and +the live response guard rejects serialized tenant/principal identity. Raw and +percent-decoded slash are both refused so intermediaries cannot normalize one +opaque key into a different path interpretation. The metric-free identity lookup +from ADR 0093 remains separate and does not gain stored-request authority. -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). +The security rule is deliberately stronger than key opacity: an idempotency key +identifies a replay domain; it is not a bearer authorization credential. A +future reactivation must prove cross-tenant and cross-principal isolation with +same-key regression cases and exact-head coverage/security evidence. Merely +adding caller-controlled scope headers without an authenticated authority would +not repair the boundary. + +HTTP semantics remain aligned with RFC 9110 (Fielding, Nottingham, & Reschke, +2022). `tepp.scientific_acceptance.v1` is unrelated to this transport repair and +never appears. `NaruonLiveService` remains POST-only and LineageWeave remains +outside this Naruon-owned adapter. From befd69a2e1c97c8be7714643cfce905659f0d912 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 04:02:14 +0900 Subject: [PATCH 11/23] chore(changelog): record stored-request lookup quarantine --- CHANGELOG.d/export-idempotency-lookup-stored-request-get.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.d/export-idempotency-lookup-stored-request-get.md b/CHANGELOG.d/export-idempotency-lookup-stored-request-get.md index cb8d269af..d153a963d 100644 --- a/CHANGELOG.d/export-idempotency-lookup-stored-request-get.md +++ b/CHANGELOG.d/export-idempotency-lookup-stored-request-get.md @@ -1 +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. +- Security quarantine for `GET /v1/exports/by-idempotency/{idempotency_key}/request`: exact-head review found that consumer-only lookup could disclose a stored authorization request across Naruon tenant namespaces. The client builder now fails closed until an authenticated tenant/principal binding exists; the live response guard refuses tenant/principal identity, and raw or percent-decoded slash keys are rejected. ADR 0099 records the repair. The metric-free idempotency lookup remains separate. From da8088bdf9f90ba34147401c016ea731c7308fb5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 04:06:38 +0900 Subject: [PATCH 12/23] test(api): preserve opaque export lookup keys --- ...rt_idempotency_lookup_key_compatibility.rs | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 crates/tepp_api/tests/export_idempotency_lookup_key_compatibility.rs diff --git a/crates/tepp_api/tests/export_idempotency_lookup_key_compatibility.rs b/crates/tepp_api/tests/export_idempotency_lookup_key_compatibility.rs new file mode 100644 index 000000000..6c3a603b3 --- /dev/null +++ b/crates/tepp_api/tests/export_idempotency_lookup_key_compatibility.rs @@ -0,0 +1,86 @@ +//! Regression tests for opaque export idempotency-key lookup compatibility. + +use tepp_api::{ + AnalysisRunLiveService, AnalyticalPurpose, ExportAuthorizationRequest, + ExportIdempotencyLookupCliInvocation, NARUON_CONSUMER_CODE, + dispatch_export_idempotency_lookup_cli, naruon_export_idempotency_lookup_exchange, +}; + +const ORIGIN: &str = "https://tepp.example.test"; + +fn request() -> ExportAuthorizationRequest { + ExportAuthorizationRequest { + tenant_workspace_id: "lookup-key-compat-tenant".into(), + principal_id: "lookup-key-compat-principal".into(), + purpose: AnalyticalPurpose::ModularServiceConsumer, + artifact_id: "lookup-key-compat-artifact".into(), + includes_source_text: false, + } +} + +fn post(service: &mut AnalysisRunLiveService, key: &str) { + let body = serde_json::to_string(&request()).expect("request json"); + let raw = 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: {key}\r\ncontent-length: {}\r\n\r\n{body}", + body.len() + ); + let response = service.handle_http_request(&raw); + assert_eq!(response.status_code, 200, "{}", response.body); +} + +fn invocation(key: &str) -> ExportIdempotencyLookupCliInvocation { + ExportIdempotencyLookupCliInvocation::from_args( + [ + "lookup", + "--host", + "127.0.0.1:18081", + "--origin", + ORIGIN, + "--consumer", + NARUON_CONSUMER_CODE, + "--idempotency-key", + key, + ], + "", + ) + .expect("opaque accepted export key must remain lookup-addressable") +} + +#[test] +fn slash_key_remains_addressable_through_encoded_lookup_and_cli() { + let exchange = naruon_export_idempotency_lookup_exchange(ORIGIN, "scope/key") + .expect("encoded slash exchange"); + assert!(exchange.target_url.ends_with("/by-idempotency/scope%2Fkey")); + + let mut service = AnalysisRunLiveService::new(); + post(&mut service, "scope/key"); + let response = dispatch_export_idempotency_lookup_cli(&mut service, &invocation("scope/key")) + .expect("dispatch"); + assert_eq!(response.status_code, 200, "{}", response.body); + assert!(response.body.contains("\"idempotency_key\":\"scope/key\"")); +} + +#[test] +fn route_prefix_key_remains_addressable_as_nested_opaque_value() { + let exchange = naruon_export_idempotency_lookup_exchange(ORIGIN, "by-idempotency") + .expect("reserved-looking value is data after the route prefix"); + assert!( + exchange + .target_url + .ends_with("/by-idempotency/by-idempotency") + ); + + let mut service = AnalysisRunLiveService::new(); + post(&mut service, "by-idempotency"); + let response = dispatch_export_idempotency_lookup_cli( + &mut service, + &invocation("by-idempotency"), + ) + .expect("dispatch"); + assert_eq!(response.status_code, 200, "{}", response.body); + assert!( + response + .body + .contains("\"idempotency_key\":\"by-idempotency\"") + ); +} From 44deacb69afbeb3e59f72ddb99b9aac52f43300f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 04:08:14 +0900 Subject: [PATCH 13/23] fix(api): keep accepted opaque lookup keys addressable --- .../src/export_idempotency_lookup_http.rs | 63 ++++++++++--------- 1 file changed, 34 insertions(+), 29 deletions(-) diff --git a/crates/tepp_api/src/export_idempotency_lookup_http.rs b/crates/tepp_api/src/export_idempotency_lookup_http.rs index fd55c98ea..1a4bc7123 100644 --- a/crates/tepp_api/src/export_idempotency_lookup_http.rs +++ b/crates/tepp_api/src/export_idempotency_lookup_http.rs @@ -1,17 +1,13 @@ //! 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. +//! GAP-003A: `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`. Idempotency keys are opaque accepted request data: values that +//! contain `/` are encoded into one path segment and the literal value +//! `by-idempotency` remains addressable after the route prefix. +//! `NaruonLiveService` stays POST-only. `LineageWeave` is refused on this +//! naruon-owned adapter. `tepp.scientific_acceptance.v1` never appears. 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}; @@ -27,7 +23,7 @@ pub const EXPORT_IDEMPOTENCY_LOOKUP_KEY_MAX_LEN: usize = EXPORT_RETRIEVAL_ID_MAX /// Supported export idempotency-lookup contract version. pub const EXPORT_IDEMPOTENCY_LOOKUP_CONTRACT_VERSION: u16 = 1; -/// Reserved collection-relative prefix that names the lookup resource. +/// Collection-relative prefix that names the lookup resource. pub const EXPORT_IDEMPOTENCY_LOOKUP_PREFIX: &str = "by-idempotency"; const FORBIDDEN_EXPORT_LOOKUP_KEYS: [&str; 16] = [ @@ -142,9 +138,9 @@ impl ExportIdempotencyLookup { { return Err(ApiError::InvalidWirePayload); } - if self.export_id == EXPORT_IDEMPOTENCY_LOOKUP_PREFIX - || self.idempotency_key == EXPORT_IDEMPOTENCY_LOOKUP_PREFIX - { + // The route prefix is reserved only in the server-assigned export-id + // position. Client idempotency keys are opaque data and may equal it. + if self.export_id == EXPORT_IDEMPOTENCY_LOOKUP_PREFIX { return Err(ApiError::InvalidWirePayload); } if self.export_id.len() > EXPORT_IDEMPOTENCY_LOOKUP_KEY_MAX_LEN @@ -195,15 +191,17 @@ fn contains_forbidden_export_lookup_key(value: &serde_json::Value) -> bool { /// `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. +/// data inside one opaque key rather than becoming an extra path segment. The +/// key value may itself equal `by-idempotency`; after the route prefix that +/// token is data, not another control segment. /// /// # Errors /// /// Returns [`ApiError::InvalidWirePayload`] for a collection path, GET-by-id, /// 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`]. +/// `/request` suffix, a NUL byte, or 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) @@ -222,9 +220,6 @@ pub(crate) fn export_idempotency_lookup_path_key(path: &str) -> Result EXPORT_IDEMPOTENCY_LOOKUP_KEY_MAX_LEN { return Err(ApiError::LimitExceeded); } @@ -241,15 +236,14 @@ pub(crate) fn export_idempotency_lookup_path_key(path: &str) -> Result Result { require_nonempty(idempotency_key)?; - if idempotency_key == EXPORT_IDEMPOTENCY_LOOKUP_PREFIX || idempotency_key.contains('\0') { + if idempotency_key.contains('\0') { return Err(ApiError::InvalidWirePayload); } if idempotency_key.len() > EXPORT_IDEMPOTENCY_LOOKUP_KEY_MAX_LEN { @@ -372,6 +366,14 @@ mod tests { ) .is_ok() ); + assert!( + ExportIdempotencyLookup::new( + "export-1", + EXPORT_RETRIEVAL_ALLOWED_DECISION_CODE, + EXPORT_IDEMPOTENCY_LOOKUP_PREFIX + ) + .is_ok() + ); assert_eq!( ExportIdempotencyLookup::new( "a".repeat(EXPORT_IDEMPOTENCY_LOOKUP_KEY_MAX_LEN + 1), @@ -535,8 +537,11 @@ mod tests { Err(ApiError::InvalidWirePayload) ); assert_eq!( - export_idempotency_lookup_path_key("/v1/exports/by-idempotency/by-idempotency"), - Err(ApiError::InvalidWirePayload) + export_idempotency_lookup_path_key( + "/v1/exports/by-idempotency/by-idempotency" + ) + .expect("route prefix is opaque data after the route segment"), + "by-idempotency" ); let oversized = format!( "/v1/exports/by-idempotency/{}", From 0458c8508a854268d089b47fb2fa8a406c268ac0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 04:09:38 +0900 Subject: [PATCH 14/23] fix(cli): preserve opaque export idempotency keys --- .../src/export_idempotency_lookup_cli.rs | 30 ++++++++----------- 1 file changed, 12 insertions(+), 18 deletions(-) diff --git a/crates/tepp_api/src/export_idempotency_lookup_cli.rs b/crates/tepp_api/src/export_idempotency_lookup_cli.rs index 9d1b0654b..0fa6d0561 100644 --- a/crates/tepp_api/src/export_idempotency_lookup_cli.rs +++ b/crates/tepp_api/src/export_idempotency_lookup_cli.rs @@ -1,16 +1,12 @@ //! Operator loopback CLI for naruon export idempotency-key lookup GET. //! -//! GAP-003A unique slice: operators run `tepp-export-lookup lookup` to mint +//! Operators run `tepp-export-lookup lookup` to mint //! `naruon_export_idempotency_lookup_exchange` onto spawned `tepp-loopback` -//! TCP. Stdout is the metric-free `ExportIdempotencyLookup`. -//! `tepp.scientific_acceptance.v1` never appears. The CLI does not infer -//! causality. `LineageWeave` is refused on this naruon-owned adapter. -//! `NaruonLiveService` stays POST-only. This module does not duplicate -//! lookup GET (#465), GET-by-id HTTP (#411), retrieval CLI (#417), -//! collection GET/CLI (#443/#444), stored-request GET/CLI (#457/#459), -//! export-authorize CLI (#410), analysis-run lookup CLI (#401), cancel -//! lineages (closed), Leiden, or GAP-010 Figma/export. Persistence remains -//! GAP-003B. +//! TCP. Stdout is the metric-free `ExportIdempotencyLookup`. Accepted +//! idempotency keys remain opaque data: slash-containing keys are percent- +//! encoded by the HTTP contract and the literal `by-idempotency` value remains +//! addressable after the route prefix. `tepp.scientific_acceptance.v1` never +//! appears. `LineageWeave` is refused and `NaruonLiveService` stays POST-only. use std::collections::HashSet; use std::fmt::Write as _; @@ -25,8 +21,8 @@ use crate::{ naruon_export_idempotency_lookup_exchange, refuse_metrics_on_export_idempotency_lookup_payload, AnalysisRunLiveService, ApiError, ErrorEnvelope, ExportIdempotencyLookup, NaruonHttpExchange, NaruonLiveResponse, DEFAULT_PROJECT_HISTORY_BYTE_LIMIT, EXPORT_IDEMPOTENCY_LOOKUP_KEY_MAX_LEN, - EXPORT_IDEMPOTENCY_LOOKUP_PREFIX, NARUON_CONSUMER_CODE, NARUON_LIVE_HEADER_BYTE_LIMIT, - NARUON_LIVE_HEADER_COUNT_LIMIT, NARUON_LIVE_IO_TIMEOUT, + NARUON_CONSUMER_CODE, NARUON_LIVE_HEADER_BYTE_LIMIT, NARUON_LIVE_HEADER_COUNT_LIMIT, + NARUON_LIVE_IO_TIMEOUT, }; const MAXIMUM_HTTP_RESPONSE_BYTES: usize = @@ -85,7 +81,7 @@ impl ExportIdempotencyLookupCliInvocation { /// /// Returns a fail-closed error for unknown verbs, missing required flags, a /// non-loopback host, a non-`https` origin, an unpublished or `LineageWeave` - /// consumer, credential-shaped flags, a hostile key, or a nonempty body. + /// consumer, credential-shaped flags, an invalid key, or a nonempty body. pub fn from_args(args: I, body: impl Into) -> Result where I: IntoIterator, @@ -107,7 +103,8 @@ impl ExportIdempotencyLookupCliInvocation { /// /// Returns [`ApiError::AuthorizationDenied`] for a non-loopback host and /// [`ApiError::InvalidWirePayload`] or [`ApiError::LimitExceeded`] for - /// empty, unpublished, `LineageWeave`, nonempty-body, or oversized fields. + /// empty, unpublished, `LineageWeave`, nonempty-body, NUL-containing, or + /// oversized fields. pub fn validate(&self) -> Result<(), ApiError> { require_loopback_host(&self.host)?; require_nonempty(&self.origin)?; @@ -119,10 +116,7 @@ impl ExportIdempotencyLookupCliInvocation { return Err(ApiError::InvalidWirePayload); } require_nonempty(&self.idempotency_key)?; - if self.idempotency_key.contains('/') - || self.idempotency_key.contains('\0') - || self.idempotency_key == EXPORT_IDEMPOTENCY_LOOKUP_PREFIX - { + if self.idempotency_key.contains('\0') { return Err(ApiError::InvalidWirePayload); } if self.idempotency_key.len() > EXPORT_IDEMPOTENCY_LOOKUP_KEY_MAX_LEN { From aef2cc822aae3684216b248ca8bac68e983e438a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 04:11:45 +0900 Subject: [PATCH 15/23] test(cli): align opaque export lookup key contract --- .../tests/export_idempotency_lookup_cli_contract.rs | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/crates/tepp_api/tests/export_idempotency_lookup_cli_contract.rs b/crates/tepp_api/tests/export_idempotency_lookup_cli_contract.rs index d00b5140e..849167923 100644 --- a/crates/tepp_api/tests/export_idempotency_lookup_cli_contract.rs +++ b/crates/tepp_api/tests/export_idempotency_lookup_cli_contract.rs @@ -118,7 +118,7 @@ fn verbs_and_from_args_fail_closed() { } #[test] -fn from_args_refuses_lineageweave_slash_body_size_and_pagination() { +fn from_args_refuses_lineageweave_body_size_and_pagination_but_keeps_opaque_keys() { assert_eq!( ExportIdempotencyLookupCliInvocation::from_args( lookup_args("127.0.0.1:18081", "idem-1", LINEAGEWEAVE_CONSUMER_CODE), @@ -143,21 +143,19 @@ fn from_args_refuses_lineageweave_slash_body_size_and_pagination() { .unwrap_err(), ApiError::InvalidWirePayload ); - assert_eq!( + assert!( ExportIdempotencyLookupCliInvocation::from_args( lookup_args("127.0.0.1:18081", "idem/slash", NARUON_CONSUMER_CODE), "" ) - .unwrap_err(), - ApiError::InvalidWirePayload + .is_ok() ); - assert_eq!( + assert!( ExportIdempotencyLookupCliInvocation::from_args( lookup_args("127.0.0.1:18081", "by-idempotency", NARUON_CONSUMER_CODE), "" ) - .unwrap_err(), - ApiError::InvalidWirePayload + .is_ok() ); assert_eq!( ExportIdempotencyLookupCliInvocation::from_args( From 1950f261ec3937d777f4522767a274dd0402c03d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 04:12:03 +0900 Subject: [PATCH 16/23] test(api): keep reserved-looking lookup keys addressable --- .../tests/export_idempotency_lookup_http_contract.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/tepp_api/tests/export_idempotency_lookup_http_contract.rs b/crates/tepp_api/tests/export_idempotency_lookup_http_contract.rs index f19efefc5..be30ed21b 100644 --- a/crates/tepp_api/tests/export_idempotency_lookup_http_contract.rs +++ b/crates/tepp_api/tests/export_idempotency_lookup_http_contract.rs @@ -69,9 +69,12 @@ fn export_idempotency_lookup_contract_refuses_table_access_and_metric_keys() { ), Err(ApiError::LimitExceeded) ); + let reserved_looking = + naruon_export_idempotency_lookup_exchange("https://tepp.example.test", "by-idempotency") + .expect("route prefix remains opaque client-key data after the prefix segment"); assert_eq!( - naruon_export_idempotency_lookup_exchange("https://tepp.example.test", "by-idempotency"), - Err(ApiError::InvalidWirePayload) + reserved_looking.target_url, + "https://tepp.example.test/v1/exports/by-idempotency/by-idempotency" ); assert_eq!( refuse_metrics_on_export_idempotency_lookup_payload(r#"{"rmse":1.0}"#), From af0dfc20ee7cb9e6d3207da32f364f3059372a1e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 04:12:47 +0900 Subject: [PATCH 17/23] docs(api): preserve opaque export lookup identity --- .../adr/0093-export-idempotency-lookup-get.md | 127 ++++++------------ 1 file changed, 42 insertions(+), 85 deletions(-) diff --git a/docs/adr/0093-export-idempotency-lookup-get.md b/docs/adr/0093-export-idempotency-lookup-get.md index 08dda8973..6f26a09d2 100644 --- a/docs/adr/0093-export-idempotency-lookup-get.md +++ b/docs/adr/0093-export-idempotency-lookup-get.md @@ -1,127 +1,84 @@ # 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. +**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. +**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. +ADR 0054 publishes `GET /v1/exports/{export_id}`. Operators who hold a 200 authorization receipt or log key still need a metric-free way to resolve the server-assigned export identity without scanning a collection. Reusing GET-by-id with the key as `{export_id}` would collide with server-assigned UUID v7 capability identity. + +Export authorization already accepts opaque idempotency keys. Review found that the first lookup adapter imposed narrower client/path rules: the CLI rejected slash-containing keys and the HTTP/DTO/CLI rejected the literal key `by-idempotency`. Those restrictions made valid authorization receipts unresolvable. The lookup contract therefore has to preserve accepted opaque key identity rather than retrospectively inventing a smaller key domain. ## Decision -`AnalysisRunLiveService` serves `GET /v1/exports/by-idempotency/{idempotency_key}` -on loopback: +`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. +- `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 without disclosing tenant counts. LineageWeave is refused. +- Empty GET bodies only. Query strings, GET-by-id, POST `/by-idempotency`, stored-request suffixes, collection GET, and nonempty bodies fail closed. +- Client idempotency keys are opaque accepted request data. A `/` inside a key is percent-encoded into one path segment and decoded after route segmentation. The literal value `by-idempotency` remains addressable at `/v1/exports/by-idempotency/by-idempotency`; the first occurrence is routing syntax and the second is data. +- Raw extra path segments are never treated as part of a key. NUL and oversized keys fail closed. +- The Naruon exchange does not send an `idempotency-key` header or credentials. +- `NaruonLiveService` stays POST-only. Persistence remains GAP-003B. + +The separate stored-request-by-idempotency convenience route is governed by ADR 0099 and is currently quarantined; success of the metric-free identity lookup does not authorize disclosure of the original request. ## 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). +- Treating an idempotency key as authorization to retrieve a stored create request. +- Leiden, longitudinal-model repair, or GAP-010 UI/export work. - 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. +1. Ask operators to scan collection pages or re-POST authorization — rejected because a valid receipt should remain addressable without changing request identity. +2. Restrict new lookup clients to a narrower key grammar than authorization — rejected because it strands already-valid receipts. +3. Reuse GET-by-id with the client key as `{export_id}` — rejected because GET-by-id owns server-assigned export capabilities. +4. Preserve opaque accepted key identity with one-segment percent encoding — 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. +- A valid authorization key remains lookup-addressable even when it contains `/` or equals `by-idempotency`. +- Route parsing remains unambiguous because segmentation occurs before percent decoding and raw additional `/` segments are rejected. +- Lookup payloads cannot be mistaken for scientific results or stored authorization requests. ## 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. +Unknown keys, extra raw path segments, GET-by-id, query strings, nonempty bodies, POST `/by-idempotency`, metric keys, LineageWeave, unpublished consumers, consumer mismatch, ambiguous multi-tenant matches, NUL, and non-loopback hosts return a redacted failure. Oversized keys return the bounded limit failure. Credential headers remain forbidden. The in-memory registry is not durable; a restart requires reconstruction through the authorized create path. ## 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. +- No credential headers cross this consumer boundary. +- The response exposes no tenant/principal/source-text or scientific fields. +- Ambiguous matches fail closed so the lookup is not a tenant-count oracle. +- An idempotency key identifies a replay domain; it is not a bearer credential for the ADR 0099 stored-request resource. ## 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. +Create POST, retrieval GET, temporal-context, and project-history paths are unchanged. Existing accepted slash-containing or prefix-looking keys need no migration: lookup preserves their exact decoded identity. Production adapters may replace loopback only while retaining this opaque-key and metric-free contract. ## Verification -Falsifiable evidence: +Falsifiable evidence includes: -- 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. +- GET lookup JSON has no scientific, tenant, principal, source-text, report, or terminal-result fields; +- POST with `scope/key` followed by lookup through `scope%2Fkey` returns the same opaque key and matching `export_id`; +- POST with the literal key `by-idempotency` remains resolvable through the nested lookup path; +- CLI and HTTP builders admit the same accepted key domain; +- raw extra segments, NUL, oversized keys, unknown keys, LineageWeave and forbidden credentials fail closed; +- exact-head Clippy, `tepp_api` tests, rustdoc, line/branch coverage, security workflows and qualifying 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. +Rollback removes idempotency-lookup GET dispatch; POST authorization receipts and retrieval GET remain valid. A superseding ADR is required to change accepted idempotency-key identity, persist the registry, expose a public address, open LineageWeave, add GET to `NaruonLiveService`, or promote HTTP success to scientific authority. ## Related authority -ADR 0054, ADR 0018, ADR 0009, ADR 0011, ADR 0014, RFC 9110 (Fielding, -Nottingham, & Reschke, 2022). +ADR 0054, ADR 0018, ADR 0009, ADR 0011, ADR 0014, ADR 0099, RFC 9110 (Fielding, Nottingham, & Reschke, 2022). From 66289fb0e164f4686d3e2e19802aba2c74aa835f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 04:13:09 +0900 Subject: [PATCH 18/23] docs(cli): align opaque export lookup keys --- .../adr/0094-export-idempotency-lookup-cli.md | 72 ++++++++----------- 1 file changed, 28 insertions(+), 44 deletions(-) diff --git a/docs/adr/0094-export-idempotency-lookup-cli.md b/docs/adr/0094-export-idempotency-lookup-cli.md index f3074e7f2..a2d5dadab 100644 --- a/docs/adr/0094-export-idempotency-lookup-cli.md +++ b/docs/adr/0094-export-idempotency-lookup-cli.md @@ -1,71 +1,55 @@ # ADR 0094 — Loopback export idempotency-key lookup CLI -**Decision status:** Accepted -**Implementation maturity:** active-PR -**Date:** 2026-09-01 -**Supersedes:** None; complements ADR 0093. Does not re-open cancel lineages. -Does not supersede ADR 0014. Unique versus protected main; 0026–0093 occupied -including #465=0093, #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. +**Decision status:** Accepted +**Implementation maturity:** active-PR +**Date:** 2026-09-01 +**Supersedes:** None; complements ADR 0093. Does not re-open cancel lineages or supersede ADR 0014. +**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}`. Operators -still had no published binary that mints that GET onto spawned `tepp-loopback` -TCP. Duplicating lookup GET (#465), 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 CLI (#401), Leiden, or GAP-010 -would collide with live PRs. Cancel lineages stay closed. LineageWeave is -refused on this naruon-owned adapter. `NaruonLiveService` stays POST-only. +ADR 0093 publishes `GET /v1/exports/by-idempotency/{idempotency_key}`. Operators need a published binary that mints that GET onto spawned `tepp-loopback` TCP without hand-writing HTTP. The CLI must accept the same opaque idempotency-key domain as export authorization and ADR 0093. Review found the first CLI narrowed that domain by rejecting slash-containing keys and the literal value `by-idempotency`, even though those values could already have been accepted by export authorization. ## Decision -Publish `tepp-export-lookup lookup` which mints -`naruon_export_idempotency_lookup_exchange` onto spawned `tepp-loopback` TCP. -Empty stdin is admitted. Nonempty leftover stdin, public bind, `localhost`, -`http` origin, unpublished consumer, LineageWeave, reserved prefix-as-key, and -credential flags fail closed. Dedicated binary so it does not collide with -`tepp-export-list` (#444), `tepp-export-get` (#417), `tepp-export-request` -(#459), or export-authorize (#410). Response is the metric-free -`ExportIdempotencyLookup`. `tepp.scientific_acceptance.v1` never appears. +Publish `tepp-export-lookup lookup`, backed by `naruon_export_idempotency_lookup_exchange`: + +- Empty stdin is admitted; nonempty leftover stdin fails closed. +- Public bind, `localhost`, non-HTTPS origin, unpublished consumers, LineageWeave and credential-shaped flags fail closed. +- Idempotency keys are opaque data. Slash-containing keys are accepted by the CLI and percent-encoded by the typed HTTP exchange into one route segment. The literal key `by-idempotency` is accepted as data after the route prefix. +- NUL and oversized keys fail closed. Raw additional URL segments are never accepted by the HTTP parser as part of a key. +- Response stdout is only the metric-free `ExportIdempotencyLookup`. `tepp.scientific_acceptance.v1` and tenant/principal/source-text data never appear. +- The CLI does not authorize ADR 0099 stored-request disclosure; that separate convenience route remains quarantined until authenticated tenant/principal scope exists. +- `NaruonLiveService` stays POST-only. ## Alternatives considered -1. Re-open cancel CLI — rejected. -2. Reuse `tepp-export-get` — rejected; that is ADR 0055. -3. Reuse `tepp-export-request` — rejected; that is stored-request GET. -4. Dedicated lookup binary — accepted. +1. Re-open a cancel CLI — rejected; unrelated lifecycle responsibility. +2. Reuse `tepp-export-get` — rejected because that command resolves server-assigned `export_id` capabilities. +3. Keep a stricter CLI key grammar than the create contract — rejected because accepted receipts would become operationally unreachable. +4. Preserve the exact opaque accepted key domain through the typed exchange — accepted. ## Consequences -CLI success is not measurement evidence and is not an ADR 0014 claim. -Sequence remains association, not causation. +The CLI is compatible with the create contract for key identity instead of imposing a second, narrower schema. URL routing remains safe because encoding happens inside a single path segment and parsing segments precedes percent decoding. ## Failure and recovery -LineageWeave, nonempty leftover stdin, extra segments, slash/NUL, missing -keys, public bind, `localhost`, reserved prefix-as-key, and metric keys fail -closed. +LineageWeave, nonempty stdin, extra raw URL segments, NUL, oversized keys, missing keys, public bind, `localhost`, invalid origin, credentials and metric-bearing responses fail closed. A key containing `/` or equal to `by-idempotency` is not itself an error; it must resolve exactly as the create contract stored it. ## Verification -- `tepp-export-lookup lookup` of an authorized export prints - `export_id`/`decision_code`/`idempotency_key` without RMSE or - `tepp.scientific_acceptance.v1`; -- LineageWeave, public bind, `localhost`, `http` origin, leftover stdin, - slash/NUL, reserved prefix, and missing keys fail closed; -- Clippy `-D warnings`, `tepp_api` tests, rustdoc, and exact-head review remain - required. +- lookup of an authorized export prints `export_id`/`decision_code`/`idempotency_key` without RMSE or scientific-acceptance data; +- POST then CLI lookup round-trips `scope/key` through `%2F` path encoding; +- POST then CLI lookup round-trips the literal key `by-idempotency` through `/by-idempotency/by-idempotency`; +- LineageWeave, public bind, `localhost`, non-HTTPS origin, leftover stdin, NUL, oversized keys, missing keys and credentials fail closed; +- exact-head Clippy, `tepp_api` tests, rustdoc, line/branch coverage, security workflows and qualifying review remain required. ## Rollback and supersession -Rollback removes the published binary; lookup GET remains valid. A superseding -ADR is required to persist the registry, bind a public address, re-open cancel, -emit scientific-acceptance, open LineageWeave on this adapter, add GET to -`NaruonLiveService`, or treat CLI success as an ADR 0014 claim. +Rollback removes the published binary; ADR 0093 lookup GET remains valid. A superseding ADR is required to change key identity semantics, persist the registry, bind a public address, open LineageWeave, add GET to `NaruonLiveService`, or treat CLI success as an ADR 0014 claim. ## Related authority -ADR 0093, ADR 0054, ADR 0009, ADR 0011, ADR 0014, RFC 9110 (Fielding, Nottingham, & Reschke, 2022). +ADR 0093, ADR 0099, ADR 0054, ADR 0009, ADR 0011, ADR 0014, RFC 9110 (Fielding, Nottingham, & Reschke, 2022). From 0f3bea7e32ffe3dffed78db07f76f57a81f45823 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 04:13:23 +0900 Subject: [PATCH 19/23] docs(api): doctor opaque export lookup compatibility --- .../export-idempotency-lookup-http.md | 35 ++++--------------- 1 file changed, 6 insertions(+), 29 deletions(-) diff --git a/docs/research/export-idempotency-lookup-http.md b/docs/research/export-idempotency-lookup-http.md index 24155bd57..1d0711a9b 100644 --- a/docs/research/export-idempotency-lookup-http.md +++ b/docs/research/export-idempotency-lookup-http.md @@ -1,34 +1,11 @@ -# Export idempotency-key lookup HTTP (doctoring) +# Export idempotency-key lookup HTTP doctoring -## Scope +`GET /v1/exports/by-idempotency/{idempotency_key}` resolves a purpose-bound export authorization key to the metric-free server-assigned export identity on `AnalysisRunLiveService`. HTTP method/path/framing semantics follow RFC 9110 (Fielding, Nottingham, & Reschke, 2022); the product-specific authorization, privacy and scientific-authority rules are TEPP contracts. -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 important compatibility invariant is that lookup does not narrow the idempotency-key domain already admitted by export authorization. Keys are opaque request identity. A key containing `/` is percent-encoded into one route segment, with segmentation performed before percent decoding. A key whose literal value is `by-idempotency` remains data at `/v1/exports/by-idempotency/by-idempotency`. Raw extra path segments, NUL and oversized values still fail closed. This avoids accepting an export and later making its receipt impossible to resolve. -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. +The returned `ExportIdempotencyLookup` contains only `export_id`, `decision_code` and the exact decoded `idempotency_key`. Tenant/workspace, principal, source-text and scientific metric/acceptance fields are refused recursively. Zero and ambiguous matches fail closed rather than becoming a tenant-count oracle. LineageWeave is outside this Naruon-owned adapter and `NaruonLiveService` stays POST-only. -## Internal contract evidence +The related stored-request-by-idempotency route is a different disclosure boundary. ADR 0099 quarantines that convenience path because the first version had no authenticated tenant/principal binding. Successful metric-free identity lookup is therefore not authorization to retrieve the original request. -- 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). +The listener remains loopback HTTP/1.1 with bounded framing and deadlines; it is not a production public TLS service. Persistence remains GAP-003B. Exact-head tests cover slash and route-prefix-looking keys through POST→lookup round trips as well as fail-closed privacy and framing cases. From 777b314a0016d5289f6bb008ae543ec5d74e2ccf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 04:13:37 +0900 Subject: [PATCH 20/23] docs(cli): doctor opaque export lookup compatibility --- .../research/export-idempotency-lookup-cli.md | 38 +++---------------- 1 file changed, 6 insertions(+), 32 deletions(-) diff --git a/docs/research/export-idempotency-lookup-cli.md b/docs/research/export-idempotency-lookup-cli.md index 2bd7f9cf1..a725e4186 100644 --- a/docs/research/export-idempotency-lookup-cli.md +++ b/docs/research/export-idempotency-lookup-cli.md @@ -1,37 +1,11 @@ -# Export idempotency-key lookup CLI (doctoring) +# Export idempotency-key lookup CLI doctoring -## Scope +`tepp-export-lookup lookup` mints ADR 0093's typed Naruon GET onto spawned `tepp-loopback` TCP. The CLI exists so an operator can resolve a purpose-bound export receipt without writing raw HTTP. HTTP framing follows RFC 9110 (Fielding, Nottingham, & Reschke, 2022); the consumer, privacy and scientific-authority boundaries are TEPP contracts. -Operators who receive a 200 purpose-bound export authorization still cannot -mint `GET /v1/exports/by-idempotency/{idempotency_key}` without writing raw -HTTP. `tepp-export-lookup lookup` is the first published binary that mints -that typed naruon exchange onto spawned `tepp-loopback` TCP. 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, reserved prefix-as-key, leftover stdin, and -non-loopback binds is repository contract authority, not an RFC inference -rule. +The CLI accepts the same opaque idempotency-key domain as the create and HTTP contracts. A slash-containing key is not parsed as CLI routing syntax: it is passed to the typed exchange and percent-encoded into a single HTTP path segment. The literal value `by-idempotency` also remains valid key data after the route prefix. NUL and oversized values remain invalid. This prevents a valid create receipt from becoming operationally unresolvable because a later adapter invented a narrower key grammar. -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. +Public binds, `localhost`, non-HTTPS origins, unpublished consumers, LineageWeave, credential-shaped flags, nonempty stdin, malformed framing and metric-bearing responses fail closed. Success stdout is the metric-free `ExportIdempotencyLookup`; tenant/principal/source-text and `tepp.scientific_acceptance.v1` are not emitted. `NaruonLiveService` remains POST-only. -## Internal contract evidence +ADR 0099's stored-request-by-idempotency convenience route remains quarantined and is not activated by this CLI. Resolving an export identity does not authorize disclosure of its original authorization request. -- ADR 0094 owns this lookup CLI. -- ADR 0093 owns 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 - -lookup GET (#465), 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 CLI (#401), cancel lineages (closed), Leiden, Driver p.16 -std-family restoration, Figma/export (GAP-010), and Compose persistence -(GAP-003B). +Exact-head regressions exercise POST→CLI lookup for ordinary, slash-containing and route-prefix-looking keys, plus loopback, credential, body, response-framing and scientific-field refusals. Persistence and public-service deployment remain outside this adapter slice. From 12e8337d68d3263feab21c63cf66a8578e997d07 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 04:15:11 +0900 Subject: [PATCH 21/23] docs(api): quarantine unscoped stored-request disclosure --- docs/API_CONTRACT.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/API_CONTRACT.md b/docs/API_CONTRACT.md index 7984f48f7..fed1bf3d4 100644 --- a/docs/API_CONTRACT.md +++ b/docs/API_CONTRACT.md @@ -1,14 +1,13 @@ # TEPP API and Modular Integration Contract **Status:** Accepted target contract; exact endpoints are introduced only with executable services. -**Last reviewed:** 2026-08-31 -**Last reviewed:** 2026-08-21 +**Last reviewed:** 2026-09-02 ## 1. Authority boundary 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). Loopback `GET /v1/exports/by-idempotency/{idempotency_key}/request` is the executable lookup stored-request route (ADR 0099). +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 metric-free export identity lookup (ADR 0093); accepted idempotency keys remain opaque and route-safe through one-segment percent encoding. `NaruonLiveService` stays POST-only. Published `tepp-export-lookup lookup` mints that GET onto spawned `tepp-loopback` TCP (ADR 0094). The reserved `GET /v1/exports/by-idempotency/{idempotency_key}/request` path is recognized but quarantined by ADR 0099: it must fail closed until an authenticated tenant/workspace plus principal authorization binding exists, and it is not an executable disclosure contract merely because the metric-free lookup succeeds. ## 2. Contract families From b600198587b013241b095a1fb5d69518aacede31 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 19:34:18 +0000 Subject: [PATCH 22/23] feat(api): publish quarantine-parity export lookup stored-request CLI Publish tepp-export-lookup-request get as ADR 0099 parity. Valid origin and key still fail closed with authorization_denied. The CLI never prints a stored export-authorization request or tenant/principal fields. LineageWeave refused. NaruonLiveService stays POST-only. --- ...t-idempotency-lookup-stored-request-cli.md | 1 + DOCUMENTATION.md | 1 + crates/tepp_api/Cargo.toml | 6 + .../src/bin/tepp_export_lookup_request.rs | 39 ++ ...t_idempotency_lookup_stored_request_cli.rs | 662 ++++++++++++++++++ crates/tepp_api/src/lib.rs | 17 + ...ency_lookup_stored_request_cli_contract.rs | 129 ++++ docs/API_CONTRACT.md | 2 +- docs/TRACEABILITY.md | 1 + ...t-idempotency-lookup-stored-request-cli.md | 85 +++ docs/adr/README.md | 1 + ...t-idempotency-lookup-stored-request-cli.md | 17 + 12 files changed, 960 insertions(+), 1 deletion(-) create mode 100644 CHANGELOG.d/export-idempotency-lookup-stored-request-cli.md create mode 100644 crates/tepp_api/src/bin/tepp_export_lookup_request.rs create mode 100644 crates/tepp_api/src/export_idempotency_lookup_stored_request_cli.rs create mode 100644 crates/tepp_api/tests/export_idempotency_lookup_stored_request_cli_contract.rs create mode 100644 docs/adr/0100-export-idempotency-lookup-stored-request-cli.md create mode 100644 docs/research/export-idempotency-lookup-stored-request-cli.md diff --git a/CHANGELOG.d/export-idempotency-lookup-stored-request-cli.md b/CHANGELOG.d/export-idempotency-lookup-stored-request-cli.md new file mode 100644 index 000000000..71be74c22 --- /dev/null +++ b/CHANGELOG.d/export-idempotency-lookup-stored-request-cli.md @@ -0,0 +1 @@ +- Published `tepp-export-lookup-request get` is quarantine-parity of ADR 0099: the typed exchange returns `authorization_denied` after origin/key validation and never prints a stored export-authorization request (ADR 0100). Empty stdin admitted. Public bind/`localhost`/`http` origin/unpublished consumer/LineageWeave/credential flags fail closed. Does not weaken ADR 0099. `NaruonLiveService` stays POST-only. Does not re-open cancel lineages. diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index b10e53daf..0e70ef83a 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -76,6 +76,7 @@ TEPP's approved PRD v0.4 and implementation plan are the primary product baselin | 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) | +| Export idempotency-key lookup stored-request CLI doctoring | [`docs/research/export-idempotency-lookup-stored-request-cli.md`](docs/research/export-idempotency-lookup-stored-request-cli.md) | | Change history | [`CHANGELOG.md`](CHANGELOG.md) | ## Maturity vocabulary diff --git a/crates/tepp_api/Cargo.toml b/crates/tepp_api/Cargo.toml index eeca298df..46266dac9 100644 --- a/crates/tepp_api/Cargo.toml +++ b/crates/tepp_api/Cargo.toml @@ -35,5 +35,11 @@ path = "src/bin/tepp_export_lookup.rs" test = false bench = false +[[bin]] +name = "tepp-export-lookup-request" +path = "src/bin/tepp_export_lookup_request.rs" +test = false +bench = false + [lints] workspace = true diff --git a/crates/tepp_api/src/bin/tepp_export_lookup_request.rs b/crates/tepp_api/src/bin/tepp_export_lookup_request.rs new file mode 100644 index 000000000..ef705d534 --- /dev/null +++ b/crates/tepp_api/src/bin/tepp_export_lookup_request.rs @@ -0,0 +1,39 @@ +//! Operator CLI for loopback naruon export lookup stored-request GET. + +use std::io::{self, IsTerminal}; +use std::process::ExitCode; + +use tepp_api::{ + ApiError, ExportIdempotencyLookupStoredRequestCliInvocation, + execute_export_idempotency_lookup_stored_request_cli, + read_export_idempotency_lookup_stored_request_cli_stdin, + render_export_idempotency_lookup_stored_request_cli_stdout, +}; + +fn main() -> ExitCode { + match run() { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("tepp-export-lookup-request: {error}"); + ExitCode::FAILURE + } + } +} + +fn run() -> Result<(), ApiError> { + let args: Vec = std::env::args().skip(1).collect(); + let body = read_export_idempotency_lookup_stored_request_cli_stdin( + io::stdin().is_terminal(), + io::stdin(), + )?; + let invocation = ExportIdempotencyLookupStoredRequestCliInvocation::from_args(&args, body)?; + let response = execute_export_idempotency_lookup_stored_request_cli(&invocation)?; + let stdout = + render_export_idempotency_lookup_stored_request_cli_stdout(&invocation, &response)?; + println!("{stdout}"); + if (200..300).contains(&response.status_code) { + Ok(()) + } else { + Err(ApiError::InvalidWirePayload) + } +} diff --git a/crates/tepp_api/src/export_idempotency_lookup_stored_request_cli.rs b/crates/tepp_api/src/export_idempotency_lookup_stored_request_cli.rs new file mode 100644 index 000000000..ec3b0cc26 --- /dev/null +++ b/crates/tepp_api/src/export_idempotency_lookup_stored_request_cli.rs @@ -0,0 +1,662 @@ +//! Operator loopback CLI for naruon export lookup stored-request GET. +//! +//! Operators run `tepp-export-lookup-request get` to mint +//! `naruon_export_idempotency_lookup_stored_request_exchange` onto spawned +//! `tepp-loopback` TCP. Stdout is the stored export-authorization request. +//! Reserved `by-idempotency` as a key, slash, and NUL fail closed to match +//! lookup stored-request GET. `tepp.scientific_acceptance.v1` never +//! appears. `LineageWeave` is refused and `NaruonLiveService` stays POST-only. + +use std::collections::HashSet; +use std::fmt::Write as _; +use std::io::{Read, Write}; +use std::net::{SocketAddr, TcpStream}; + +use crate::export_idempotency_lookup_stored_request_http::export_idempotency_lookup_stored_request_path_key; +use crate::live_http::map_io_error; +use crate::naruon_http::header_is_credential; +use crate::wire::require_nonempty; +use crate::{ + AnalysisRunLiveService, ApiError, DEFAULT_PROJECT_HISTORY_BYTE_LIMIT, + EXPORT_IDEMPOTENCY_LOOKUP_KEY_MAX_LEN, ErrorEnvelope, ExportAuthorizationRequest, + NARUON_CONSUMER_CODE, NARUON_LIVE_HEADER_BYTE_LIMIT, NARUON_LIVE_HEADER_COUNT_LIMIT, + NARUON_LIVE_IO_TIMEOUT, NaruonHttpExchange, NaruonLiveResponse, + naruon_export_idempotency_lookup_stored_request_exchange, + refuse_metrics_on_export_lookup_stored_request_payload, +}; + +const MAXIMUM_HTTP_RESPONSE_BYTES: usize = + NARUON_LIVE_HEADER_BYTE_LIMIT + 4 + DEFAULT_PROJECT_HISTORY_BYTE_LIMIT; + +/// Supported operator verbs for the loopback export idempotency-lookup CLI. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ExportIdempotencyLookupStoredRequestCliVerb { + /// `GET /v1/exports/by-idempotency/{idempotency_key}/request`. + Get, +} + +impl ExportIdempotencyLookupStoredRequestCliVerb { + /// Parse one exact lowercase verb token. + /// + /// # Errors + /// + /// Returns [`ApiError::InvalidWirePayload`] for an unknown token. + pub fn parse(token: &str) -> Result { + match token { + "get" => Ok(Self::Get), + _ => Err(ApiError::InvalidWirePayload), + } + } + + /// Return the canonical lowercase verb token. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Get => "get", + } + } +} + +/// One operator CLI invocation against a loopback export lookup stored-request listener. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ExportIdempotencyLookupStoredRequestCliInvocation { + /// CLI verb to execute. + pub verb: ExportIdempotencyLookupStoredRequestCliVerb, + /// Loopback `host:port` of `tepp-loopback`. + pub host: String, + /// Published HTTPS origin used to mint the typed lookup exchange. + pub origin: String, + /// Published modular consumer. Lookup GET admits `naruon` only. + pub consumer: String, + /// Exact request idempotency key to resolve. + pub idempotency_key: String, + /// JSON body. Lookup GET requires empty. + pub body: String, +} + +impl ExportIdempotencyLookupStoredRequestCliInvocation { + /// Parse argv plus stdin body into a validated loopback lookup invocation. + /// + /// # Errors + /// + /// Returns a fail-closed error for unknown verbs, missing required flags, a + /// non-loopback host, a non-`https` origin, an unpublished or `LineageWeave` + /// consumer, credential-shaped flags, an invalid key, or a nonempty body. + pub fn from_args(args: I, body: impl Into) -> Result + where + I: IntoIterator, + S: AsRef, + { + let tokens: Vec = args + .into_iter() + .map(|token| token.as_ref().to_owned()) + .collect(); + let (verb_token, rest) = tokens.split_first().ok_or(ApiError::InvalidWirePayload)?; + let verb = ExportIdempotencyLookupStoredRequestCliVerb::parse(verb_token)?; + let flags = parse_flags(rest)?; + assemble_invocation(verb, flags, body.into()) + } + + /// Reject a non-loopback host, unpublished consumer, or hostile GET body. + /// + /// # Errors + /// + /// Returns [`ApiError::AuthorizationDenied`] for a non-loopback host and + /// [`ApiError::InvalidWirePayload`] or [`ApiError::LimitExceeded`] for + /// empty, unpublished, `LineageWeave`, nonempty-body, NUL-containing, or + /// oversized fields. + pub fn validate(&self) -> Result<(), ApiError> { + require_loopback_host(&self.host)?; + require_nonempty(&self.origin)?; + if !self.origin.starts_with("https://") { + return Err(ApiError::InvalidWirePayload); + } + require_nonempty(&self.consumer)?; + if self.consumer != NARUON_CONSUMER_CODE { + return Err(ApiError::InvalidWirePayload); + } + require_nonempty(&self.idempotency_key)?; + if self.idempotency_key == crate::EXPORT_IDEMPOTENCY_LOOKUP_PREFIX + || self.idempotency_key.contains('/') + || self.idempotency_key.contains('\0') + { + return Err(ApiError::InvalidWirePayload); + } + if self.idempotency_key.len() > EXPORT_IDEMPOTENCY_LOOKUP_KEY_MAX_LEN { + return Err(ApiError::LimitExceeded); + } + if !self.body.is_empty() { + return Err(ApiError::InvalidWirePayload); + } + refuse_metrics_on_export_lookup_stored_request_payload(&self.body)?; + Ok(()) + } +} + +struct ParsedFlags { + host: Option, + origin: Option, + consumer: Option, + idempotency_key: Option, +} + +fn parse_flags(rest: &[String]) -> Result { + let mut flags = ParsedFlags { + host: None, + origin: None, + consumer: None, + idempotency_key: None, + }; + let mut index = 0; + while index < rest.len() { + let flag = rest[index].as_str(); + if !flag.starts_with("--") { + return Err(ApiError::InvalidWirePayload); + } + let name = &flag[2..]; + if header_is_credential(name) { + return Err(ApiError::AuthorizationDenied); + } + let slot = match name { + "host" => &mut flags.host, + "origin" => &mut flags.origin, + "consumer" => &mut flags.consumer, + "idempotency-key" => &mut flags.idempotency_key, + _ => return Err(ApiError::InvalidWirePayload), + }; + if slot.is_some() || index + 1 >= rest.len() { + return Err(ApiError::InvalidWirePayload); + } + let value = rest[index + 1].as_str(); + require_nonempty(value)?; + *slot = Some(value.to_owned()); + index += 2; + } + Ok(flags) +} + +fn assemble_invocation( + verb: ExportIdempotencyLookupStoredRequestCliVerb, + flags: ParsedFlags, + body: String, +) -> Result { + let invocation = ExportIdempotencyLookupStoredRequestCliInvocation { + verb, + host: flags.host.ok_or(ApiError::InvalidWirePayload)?, + origin: flags.origin.ok_or(ApiError::InvalidWirePayload)?, + consumer: flags + .consumer + .unwrap_or_else(|| NARUON_CONSUMER_CODE.to_owned()), + idempotency_key: flags.idempotency_key.ok_or(ApiError::InvalidWirePayload)?, + body, + }; + invocation.validate()?; + Ok(invocation) +} + +fn require_loopback_host(host: &str) -> Result { + let addr: SocketAddr = host.parse().map_err(|_| ApiError::InvalidWirePayload)?; + if addr.ip().is_loopback() { + Ok(addr) + } else { + Err(ApiError::AuthorizationDenied) + } +} + +/// Render a typed lookup GET exchange as HTTP/1.1 for a loopback listener. +/// +/// The exchange keeps its HTTPS origin contract. Only the HTTP/1.1 `Host` is +/// the loopback bind address. Public bind hosts fail closed. GET-by-id, +/// collection, stored-request extra-segments, and pagination headers fail +/// closed. +/// +/// # Errors +/// +/// Returns [`ApiError::AuthorizationDenied`] for a non-loopback host or a +/// credential-bearing header, and [`ApiError::InvalidWirePayload`] when the +/// exchange is not a GET `/v1/exports/by-idempotency/{key}` with an empty body. +pub fn loopback_http1_from_export_idempotency_lookup_stored_request_exchange( + exchange: &NaruonHttpExchange, + loopback_host: &str, +) -> Result { + let _addr = require_loopback_host(loopback_host)?; + let host = loopback_host.trim(); + if exchange.method != "GET" { + return Err(ApiError::InvalidWirePayload); + } + if !exchange.body.is_empty() { + return Err(ApiError::InvalidWirePayload); + } + let rest = exchange + .target_url + .strip_prefix("https://") + .ok_or(ApiError::InvalidWirePayload)?; + let path = rest + .find('/') + .map(|index| &rest[index..]) + .ok_or(ApiError::InvalidWirePayload)?; + let _key = export_idempotency_lookup_stored_request_path_key(path)?; + let mut seen = HashSet::with_capacity(exchange.headers.len()); + let mut has_content_type = false; + let mut has_consumer = false; + let mut has_contract = false; + for (name, value) in &exchange.headers { + if header_is_credential(name) { + return Err(ApiError::AuthorizationDenied); + } + if !valid_http_field_name(name) + || value.chars().any(char::is_control) + || !seen.insert(name.to_ascii_lowercase()) + { + return Err(ApiError::InvalidWirePayload); + } + let valid = match name.to_ascii_lowercase().as_str() { + "content-type" => { + has_content_type = true; + value == "application/json" + } + "tepp-consumer" => { + has_consumer = true; + value == NARUON_CONSUMER_CODE + } + "tepp-contract-version" => { + has_contract = true; + value == "1" + } + _ => false, + }; + if !valid { + return Err(ApiError::InvalidWirePayload); + } + } + if !has_content_type || !has_consumer || !has_contract { + return Err(ApiError::InvalidWirePayload); + } + let mut request = String::new(); + write!( + request, + "{} {path} HTTP/1.1\r\nHost: {host}\r\n", + exchange.method + ) + .map_err(|_| ApiError::InvalidWirePayload)?; + for (name, value) in &exchange.headers { + write!(request, "{name}: {value}\r\n").map_err(|_| ApiError::InvalidWirePayload)?; + } + write!(request, "content-length: 0\r\n\r\n").map_err(|_| ApiError::InvalidWirePayload)?; + Ok(request) +} + +/// Compose one HTTP/1.1 lookup GET from the typed naruon exchange. +/// +/// # Errors +/// +/// Returns the same fail-closed errors as +/// [`ExportIdempotencyLookupStoredRequestCliInvocation::validate`]. +pub fn compose_export_idempotency_lookup_stored_request_cli_http( + invocation: &ExportIdempotencyLookupStoredRequestCliInvocation, +) -> Result { + invocation.validate()?; + let exchange = naruon_export_idempotency_lookup_stored_request_exchange( + &invocation.origin, + &invocation.idempotency_key, + )?; + loopback_http1_from_export_idempotency_lookup_stored_request_exchange( + &exchange, + &invocation.host, + ) +} + +/// Dispatch one lookup CLI invocation against an in-process listener. +/// +/// # Errors +/// +/// Returns fail-closed validation errors before the HTTP handler runs. +pub fn dispatch_export_idempotency_lookup_stored_request_cli( + service: &mut AnalysisRunLiveService, + invocation: &ExportIdempotencyLookupStoredRequestCliInvocation, +) -> Result { + let request = compose_export_idempotency_lookup_stored_request_cli_http(invocation)?; + Ok(service.handle_http_request(&request)) +} + +/// Execute one lookup CLI invocation over loopback TCP against `tepp-loopback`. +/// +/// # Errors +/// +/// Returns fail-closed validation, transport, or response-framing errors. +pub fn execute_export_idempotency_lookup_stored_request_cli( + invocation: &ExportIdempotencyLookupStoredRequestCliInvocation, +) -> Result { + let addr = require_loopback_host(&invocation.host)?; + let request = compose_export_idempotency_lookup_stored_request_cli_http(invocation)?; + let mut stream = TcpStream::connect(addr).map_err(|error| map_io_error(&error))?; + stream + .set_read_timeout(Some(NARUON_LIVE_IO_TIMEOUT)) + .map_err(|error| map_io_error(&error))?; + stream + .set_write_timeout(Some(NARUON_LIVE_IO_TIMEOUT)) + .map_err(|error| map_io_error(&error))?; + stream + .write_all(request.as_bytes()) + .map_err(|error| map_io_error(&error))?; + stream.flush().map_err(|error| map_io_error(&error))?; + let bytes = read_bounded(&mut stream, MAXIMUM_HTTP_RESPONSE_BYTES)?; + parse_http_response(&bytes) +} + +/// Filter CLI stdout so lookup GET never prints scientific acceptance. +/// +/// RMSE, bias, coverage, SE-gate, tenant, principal, source-text, and +/// causal-score keys fail closed. Success stdout is only the metric-free +/// identity projection. +/// +/// # Errors +/// +/// Returns [`ApiError::InvalidWirePayload`] when a body carries metric keys, +/// `tepp.scientific_acceptance.v1`, or a success body that is not an +/// `ExportIdempotencyLookup`. +pub fn render_export_idempotency_lookup_stored_request_cli_stdout( + invocation: &ExportIdempotencyLookupStoredRequestCliInvocation, + response: &NaruonLiveResponse, +) -> Result { + invocation.validate()?; + if response.body.is_empty() { + return Err(ApiError::InvalidWirePayload); + } + refuse_metrics_on_export_lookup_stored_request_payload(&response.body)?; + if !(200..300).contains(&response.status_code) { + let expected_code = match response.status_code { + 400 => "invalid_wire_payload", + 403 => "authorization_denied", + 413 => "limit_exceeded", + 422 => "unsupported_contract_version", + _ => return Err(ApiError::InvalidWirePayload), + }; + let envelope: ErrorEnvelope = + serde_json::from_str(&response.body).map_err(|_| ApiError::InvalidWirePayload)?; + if envelope.error_code() != expected_code { + return Err(ApiError::InvalidWirePayload); + } + return envelope.to_json(); + } + if response.status_code != 200 { + return Err(ApiError::InvalidWirePayload); + } + let stored = serde_json::from_str::(&response.body) + .map_err(|_| ApiError::InvalidWirePayload)?; + crate::wire::to_json(&stored) +} + +fn parse_http_response(bytes: &[u8]) -> Result { + let text = std::str::from_utf8(bytes).map_err(|_| ApiError::InvalidWirePayload)?; + let (header_block, body) = text + .split_once("\r\n\r\n") + .ok_or(ApiError::InvalidWirePayload)?; + if header_block.len() > NARUON_LIVE_HEADER_BYTE_LIMIT { + return Err(ApiError::LimitExceeded); + } + let mut lines = header_block.split("\r\n"); + let status_line = lines.next().ok_or(ApiError::InvalidWirePayload)?; + let (version, status) = status_line + .split_once(' ') + .ok_or(ApiError::InvalidWirePayload)?; + if version != "HTTP/1.1" { + return Err(ApiError::InvalidWirePayload); + } + let (code, reason) = status.split_once(' ').ok_or(ApiError::InvalidWirePayload)?; + let code = code + .parse::() + .map_err(|_| ApiError::InvalidWirePayload)?; + let reason_phrase = static_reason(code)?; + if reason != reason_phrase { + return Err(ApiError::InvalidWirePayload); + } + let mut content_length = None; + let mut seen = HashSet::new(); + for (index, line) in lines.enumerate() { + if index >= NARUON_LIVE_HEADER_COUNT_LIMIT { + return Err(ApiError::LimitExceeded); + } + let (name, value) = line.split_once(':').ok_or(ApiError::InvalidWirePayload)?; + if !valid_http_field_name(name) + || value + .chars() + .any(|character| character.is_control() && character != '\t') + || !seen.insert(name.to_ascii_lowercase()) + || name.eq_ignore_ascii_case("transfer-encoding") + { + return Err(ApiError::InvalidWirePayload); + } + if name.eq_ignore_ascii_case("content-length") { + content_length = Some( + value + .trim() + .parse::() + .map_err(|_| ApiError::InvalidWirePayload)?, + ); + } + } + let declared = content_length.ok_or(ApiError::InvalidWirePayload)?; + if declared > DEFAULT_PROJECT_HISTORY_BYTE_LIMIT { + return Err(ApiError::LimitExceeded); + } + if declared != body.len() { + return Err(ApiError::InvalidWirePayload); + } + Ok(NaruonLiveResponse { + status_code: code, + reason_phrase, + body: body.to_owned(), + }) +} + +fn static_reason(code: u16) -> Result<&'static str, ApiError> { + match code { + 200 => Ok("OK"), + 202 => Ok("Accepted"), + 400 => Ok("Bad Request"), + 403 => Ok("Forbidden"), + 413 => Ok("Payload Too Large"), + 422 => Ok("Unprocessable Entity"), + _ => Err(ApiError::InvalidWirePayload), + } +} + +/// Read stdin leftover bytes on a non-terminal; lookup GET admits empty. +/// +/// # Errors +/// +/// Returns [`ApiError::InvalidWirePayload`] when stdin cannot be read and +/// [`ApiError::LimitExceeded`] when leftover stdin exceeds the live wire +/// limit. +pub fn read_export_idempotency_lookup_stored_request_cli_stdin( + stdin_is_terminal: bool, + mut stdin: impl Read, +) -> Result { + if stdin_is_terminal { + Ok(String::new()) + } else { + let bytes = read_bounded(&mut stdin, DEFAULT_PROJECT_HISTORY_BYTE_LIMIT)?; + String::from_utf8(bytes).map_err(|_| ApiError::InvalidWirePayload) + } +} + +fn read_bounded(reader: &mut impl Read, maximum_bytes: usize) -> Result, ApiError> { + let mut bytes = Vec::new(); + reader + .take((maximum_bytes + 1) as u64) + .read_to_end(&mut bytes) + .map_err(|error| map_io_error(&error))?; + if bytes.len() > maximum_bytes { + return Err(ApiError::LimitExceeded); + } + Ok(bytes) +} + +fn valid_http_field_name(name: &str) -> bool { + !name.is_empty() + && name.bytes().all(|byte| { + byte.is_ascii_alphanumeric() + || matches!( + byte, + b'!' | b'#' + | b'$' + | b'%' + | b'&' + | b'\'' + | b'*' + | b'+' + | b'-' + | b'.' + | b'^' + | b'_' + | b'`' + | b'|' + | b'~' + ) + }) +} + +#[cfg(test)] +mod branch_coverage_tests { + use std::io::{self, Cursor, Read}; + + use super::{ + ExportIdempotencyLookupStoredRequestCliInvocation, + ExportIdempotencyLookupStoredRequestCliVerb, parse_http_response, + read_export_idempotency_lookup_stored_request_cli_stdin, valid_http_field_name, + }; + use crate::{ + ApiError, DEFAULT_PROJECT_HISTORY_BYTE_LIMIT, NARUON_CONSUMER_CODE, + naruon_export_idempotency_lookup_stored_request_exchange, + }; + + fn invocation() -> ExportIdempotencyLookupStoredRequestCliInvocation { + ExportIdempotencyLookupStoredRequestCliInvocation { + verb: ExportIdempotencyLookupStoredRequestCliVerb::Get, + host: "127.0.0.1:18081".into(), + origin: "https://tepp.example.test".into(), + consumer: NARUON_CONSUMER_CODE.into(), + idempotency_key: "idem-1".into(), + body: String::new(), + } + } + + #[test] + fn invocation_and_flag_error_arms_are_covered() { + let mut value = invocation(); + value.origin = "http://tepp.example.test".into(); + assert_eq!(value.validate(), Err(ApiError::InvalidWirePayload)); + value = invocation(); + value.consumer = "lineageweave".into(); + assert_eq!(value.validate(), Err(ApiError::InvalidWirePayload)); + value = invocation(); + value.body = "{}".into(); + assert_eq!(value.validate(), Err(ApiError::InvalidWirePayload)); + value = invocation(); + value.idempotency_key = "idem\nother".into(); + assert_eq!(value.validate(), Err(ApiError::InvalidWirePayload)); + value = invocation(); + value.origin = "https://bad/path".into(); + assert!(super::compose_export_idempotency_lookup_stored_request_cli_http(&value).is_err()); + + for args in [ + vec!["get", "host"], + vec!["get", "--host"], + vec!["get", "--host", "a", "--host", "b"], + vec!["get", "--host", ""], + ] { + assert!( + ExportIdempotencyLookupStoredRequestCliInvocation::from_args(args, "").is_err() + ); + } + let valid = invocation(); + assert_eq!( + super::compose_export_idempotency_lookup_stored_request_cli_http(&valid), + Err(ApiError::AuthorizationDenied) + ); + assert_eq!( + naruon_export_idempotency_lookup_stored_request_exchange( + "https://tepp.example.test", + "idem-1" + ), + Err(ApiError::AuthorizationDenied) + ); + } + + #[test] + fn response_parser_and_reader_error_arms_are_covered() { + use std::fmt::Write as _; + + let oversized_header = "x".repeat(crate::NARUON_LIVE_HEADER_BYTE_LIMIT + 1); + let mut many_headers = String::new(); + for index in 0..=crate::NARUON_LIVE_HEADER_COUNT_LIMIT { + write!(many_headers, "x-{index}: b\r\n").expect("string write"); + } + let cases = [ + vec![0xff], + b"HTTP/1.1 200 OK".to_vec(), + format!("{oversized_header}\r\n\r\n").into_bytes(), + b"HTTP/1.0 200 OK\r\ncontent-length: 0\r\n\r\n".to_vec(), + b"HTTP/1.1 nope\r\ncontent-length: 0\r\n\r\n".to_vec(), + b"HTTP/1.1 999 Unknown\r\ncontent-length: 0\r\n\r\n".to_vec(), + b"HTTP/1.1 200 Bad\r\ncontent-length: 0\r\n\r\n".to_vec(), + b"HTTP/1.1 200 OK\r\nbad\r\n\r\n".to_vec(), + b"HTTP/1.1 200 OK\r\nbad name: x\r\ncontent-length: 0\r\n\r\n".to_vec(), + b"HTTP/1.1 200 OK\r\nx-good: bad\x01value\r\ncontent-length: 0\r\n\r\n".to_vec(), + b"HTTP/1.1 200 OK\r\nx-good: a\r\nx-good: b\r\ncontent-length: 0\r\n\r\n".to_vec(), + b"HTTP/1.1 200 OK\r\ntransfer-encoding: chunked\r\n\r\n".to_vec(), + b"HTTP/1.1 200 OK\r\ncontent-length: x\r\n\r\n".to_vec(), + b"HTTP/1.1 200 OK\r\n\r\n".to_vec(), + b"HTTP/1.1 200 OK\r\ncontent-length: 1\r\n\r\n".to_vec(), + format!( + "HTTP/1.1 200 OK\r\ncontent-length: {}\r\n\r\n", + DEFAULT_PROJECT_HISTORY_BYTE_LIMIT + 1 + ) + .into_bytes(), + format!("HTTP/1.1 200 OK\r\n{many_headers}content-length: 0\r\n\r\n").into_bytes(), + ]; + for bytes in cases { + assert!(parse_http_response(&bytes).is_err()); + } + for (code, reason) in [ + (202, "Accepted"), + (400, "Bad Request"), + (403, "Forbidden"), + (413, "Payload Too Large"), + (422, "Unprocessable Entity"), + ] { + let response = format!("HTTP/1.1 {code} {reason}\r\ncontent-length: 0\r\n\r\n"); + assert_eq!( + parse_http_response(response.as_bytes()) + .expect("response") + .status_code, + code + ); + } + assert!( + read_export_idempotency_lookup_stored_request_cli_stdin(false, Cursor::new([0xff])) + .is_err() + ); + assert!( + read_export_idempotency_lookup_stored_request_cli_stdin( + false, + Cursor::new(vec![b'a'; DEFAULT_PROJECT_HISTORY_BYTE_LIMIT + 1]), + ) + .is_err() + ); + assert!( + read_export_idempotency_lookup_stored_request_cli_stdin(false, FailingReader).is_err() + ); + assert!(!valid_http_field_name("")); + assert!(!valid_http_field_name("bad name")); + } + + struct FailingReader; + + impl Read for FailingReader { + fn read(&mut self, _buffer: &mut [u8]) -> io::Result { + Err(io::Error::other("redacted")) + } + } +} diff --git a/crates/tepp_api/src/lib.rs b/crates/tepp_api/src/lib.rs index 27f6a93b5..a402a2505 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_cli; mod export_idempotency_lookup_stored_request_http; mod lineage_criterion_anchor; mod lineage_pair_criterion; @@ -147,6 +148,22 @@ pub use export_idempotency_lookup_stored_request_http::is_export_idempotency_loo 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; +/// One operator CLI invocation against lookup stored-request GET. +pub use export_idempotency_lookup_stored_request_cli::ExportIdempotencyLookupStoredRequestCliInvocation; +/// Supported lookup stored-request CLI verbs. +pub use export_idempotency_lookup_stored_request_cli::ExportIdempotencyLookupStoredRequestCliVerb; +/// Compose HTTP/1.1 lookup stored-request GET from a typed CLI invocation. +pub use export_idempotency_lookup_stored_request_cli::compose_export_idempotency_lookup_stored_request_cli_http; +/// Dispatch lookup stored-request CLI against an in-process listener. +pub use export_idempotency_lookup_stored_request_cli::dispatch_export_idempotency_lookup_stored_request_cli; +/// Execute lookup stored-request CLI over loopback TCP. +pub use export_idempotency_lookup_stored_request_cli::execute_export_idempotency_lookup_stored_request_cli; +/// Render a typed lookup stored-request GET as HTTP/1.1 for a loopback host. +pub use export_idempotency_lookup_stored_request_cli::loopback_http1_from_export_idempotency_lookup_stored_request_exchange; +/// Read leftover stdin for lookup stored-request GET (empty admitted). +pub use export_idempotency_lookup_stored_request_cli::read_export_idempotency_lookup_stored_request_cli_stdin; +/// Filter lookup stored-request CLI stdout so scientific acceptance never appears. +pub use export_idempotency_lookup_stored_request_cli::render_export_idempotency_lookup_stored_request_cli_stdout; /// Analytical export purpose. pub use authorization::AnalyticalPurpose; diff --git a/crates/tepp_api/tests/export_idempotency_lookup_stored_request_cli_contract.rs b/crates/tepp_api/tests/export_idempotency_lookup_stored_request_cli_contract.rs new file mode 100644 index 000000000..706f39a97 --- /dev/null +++ b/crates/tepp_api/tests/export_idempotency_lookup_stored_request_cli_contract.rs @@ -0,0 +1,129 @@ +//! Contract tests for quarantined `tepp-export-lookup-request get`. + +use tepp_api::{ + AnalysisRunLiveService, AnalyticalPurpose, ApiError, ExportAuthorizationRequest, + ExportIdempotencyLookupStoredRequestCliInvocation, ExportIdempotencyLookupStoredRequestCliVerb, + LINEAGEWEAVE_CONSUMER_CODE, NARUON_CONSUMER_CODE, NARUON_EXPORT_PATH, + compose_export_idempotency_lookup_stored_request_cli_http, + dispatch_export_idempotency_lookup_stored_request_cli, +}; + +const ORIGIN: &str = "https://tepp.example.test"; + +fn sample_request() -> ExportAuthorizationRequest { + ExportAuthorizationRequest { + tenant_workspace_id: "export-lookup-sr-cli-tenant".into(), + principal_id: "principal-analyst-1".into(), + purpose: AnalyticalPurpose::ModularServiceConsumer, + artifact_id: "artifact-lookup-sr-cli-1".into(), + includes_source_text: false, + } +} + +fn export_post(request: &ExportAuthorizationRequest, idempotency_key: &str) -> String { + let body = serde_json::to_string(request).expect("request json"); + format!( + "POST {NARUON_EXPORT_PATH} HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: {NARUON_CONSUMER_CODE}\r\ntepp-contract-version: 1\r\nidempotency-key: {idempotency_key}\r\ncontent-length: {}\r\n\r\n{body}", + body.len() + ) +} + +fn get_args<'a>(host: &'a str, key: &'a str, consumer: &'a str) -> [&'a str; 9] { + [ + "get", + "--host", + host, + "--origin", + ORIGIN, + "--consumer", + consumer, + "--idempotency-key", + key, + ] +} + +#[test] +fn verbs_and_from_args_fail_closed() { + assert_eq!( + ExportIdempotencyLookupStoredRequestCliVerb::parse("get").expect("get"), + ExportIdempotencyLookupStoredRequestCliVerb::Get + ); + assert_eq!( + ExportIdempotencyLookupStoredRequestCliVerb::Get.as_str(), + "get" + ); + assert_eq!( + ExportIdempotencyLookupStoredRequestCliVerb::parse("lookup"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + ExportIdempotencyLookupStoredRequestCliInvocation::from_args( + get_args("8.8.8.8:80", "idem-1", NARUON_CONSUMER_CODE), + "" + ) + .unwrap_err(), + ApiError::AuthorizationDenied + ); + assert_eq!( + ExportIdempotencyLookupStoredRequestCliInvocation::from_args( + get_args("localhost:18081", "idem-1", NARUON_CONSUMER_CODE), + "" + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + ExportIdempotencyLookupStoredRequestCliInvocation::from_args( + get_args("127.0.0.1:18081", "idem-1", LINEAGEWEAVE_CONSUMER_CODE), + "" + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + ExportIdempotencyLookupStoredRequestCliInvocation::from_args( + get_args("127.0.0.1:18081", "by-idempotency", NARUON_CONSUMER_CODE), + "" + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + ExportIdempotencyLookupStoredRequestCliInvocation::from_args( + get_args("127.0.0.1:18081", "idem/slash", NARUON_CONSUMER_CODE), + "" + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); +} + +#[test] +fn compose_stays_quarantined_and_never_discloses_stored_create() { + let invocation = ExportIdempotencyLookupStoredRequestCliInvocation::from_args( + get_args("127.0.0.1:18081", "idem-1", NARUON_CONSUMER_CODE), + "", + ) + .expect("invocation"); + assert_eq!( + compose_export_idempotency_lookup_stored_request_cli_http(&invocation), + Err(ApiError::AuthorizationDenied) + ); + let mut service = AnalysisRunLiveService::new(); + let request = sample_request(); + let posted = service.handle_http_request(&export_post(&request, "export-lookup-sr-1")); + assert_eq!(posted.status_code, 200, "{}", posted.body); + let scoped = ExportIdempotencyLookupStoredRequestCliInvocation::from_args( + get_args( + "127.0.0.1:18081", + "export-lookup-sr-1", + NARUON_CONSUMER_CODE, + ), + "", + ) + .expect("scoped"); + assert_eq!( + dispatch_export_idempotency_lookup_stored_request_cli(&mut service, &scoped), + Err(ApiError::AuthorizationDenied) + ); +} diff --git a/docs/API_CONTRACT.md b/docs/API_CONTRACT.md index fed1bf3d4..73ab1d14a 100644 --- a/docs/API_CONTRACT.md +++ b/docs/API_CONTRACT.md @@ -7,7 +7,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 metric-free export identity lookup (ADR 0093); accepted idempotency keys remain opaque and route-safe through one-segment percent encoding. `NaruonLiveService` stays POST-only. Published `tepp-export-lookup lookup` mints that GET onto spawned `tepp-loopback` TCP (ADR 0094). The reserved `GET /v1/exports/by-idempotency/{idempotency_key}/request` path is recognized but quarantined by ADR 0099: it must fail closed until an authenticated tenant/workspace plus principal authorization binding exists, and it is not an executable disclosure contract merely because the metric-free lookup succeeds. +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 metric-free export identity lookup (ADR 0093); accepted idempotency keys remain opaque and route-safe through one-segment percent encoding. `NaruonLiveService` stays POST-only. Published `tepp-export-lookup lookup` mints that GET onto spawned `tepp-loopback` TCP (ADR 0094). The reserved `GET /v1/exports/by-idempotency/{idempotency_key}/request` path is recognized but quarantined by ADR 0099: it must fail closed until an authenticated tenant/workspace plus principal authorization binding exists, and it is not an executable disclosure contract merely because the metric-free lookup succeeds. Published `tepp-export-lookup-request get` is quarantine-parity of that reserved path (ADR 0100) and returns `authorization_denied` without printing a stored create. ## 2. Contract families diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 0102a2529..a9ee76373 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -56,6 +56,7 @@ The full APA 7th standards/literature register remains `docs/research/standards- | 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 | +| loopback naruon export idempotency-key lookup stored-request CLI | ADR 0100; ADR 0099; API contract; RFC 9110; ADR 0009/0011/0014 | `tepp_api` published `tepp-export-lookup-request get` is quarantine-parity of ADR 0099; typed exchange returns `authorization_denied`; never prints stored create/`tenant_workspace_id`/`principal_id`; empty stdin admitted; 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/0100-export-idempotency-lookup-stored-request-cli.md b/docs/adr/0100-export-idempotency-lookup-stored-request-cli.md new file mode 100644 index 000000000..6516a0502 --- /dev/null +++ b/docs/adr/0100-export-idempotency-lookup-stored-request-cli.md @@ -0,0 +1,85 @@ +# ADR 0100 — Quarantine-parity export lookup stored-request CLI + +**Decision status:** Accepted +**Implementation maturity:** active-PR security quarantine +**Date:** 2026-09-01 +**Supersedes:** None; complements ADR 0099. Does not re-open cancel lineages. +Does not supersede ADR 0014. Unique versus protected main; 0026–0099 occupied +including #466=0093+0094+0099. +**Figma File ID:** N/A — this increment changes a Rust CLI binary and has no +user-interface surface. +**Storybook inventory:** N/A — no reusable web object or interaction changed. + +## Context + +ADR 0099 quarantines +`GET /v1/exports/by-idempotency/{idempotency_key}/request` because a +consumer-only lookup can disclose another tenant's stored authorization +request (`tenant_workspace_id`, `principal_id`) when the idempotency key is +unique in the naruon namespace. Operators still had no published binary that +mints that reserved route onto spawned `tepp-loopback` TCP. Reusing +`tepp-export-lookup` would collide with identity lookup. A disclosure CLI +would weaken the ADR 0099 fail-closed quarantine. + +## Decision + +Publish `tepp-export-lookup-request get` as quarantine-parity of ADR 0099: + +- `from_args` admits loopback host, `https` origin, naruon consumer, and a + syntactically valid key. Empty stdin is admitted. +- Compose calls `naruon_export_idempotency_lookup_stored_request_exchange`, + which returns `authorization_denied` after origin/key validation. The CLI + never serializes a stored authorization request and never prints + `tenant_workspace_id` or `principal_id`. +- Public bind, `localhost`, `http` origin, unpublished consumer, + LineageWeave, credential flags, reserved `by-idempotency` as a key, + slash/NUL, and leftover stdin fail closed before the quarantine result. +- `NaruonLiveService` stays POST-only. CLI failure is not an ADR 0014 claim. + +Reactivation of a disclosure CLI requires the same versioned +tenant-and-principal binding as ADR 0099. + +## Non-goals + +- Weakening ADR 0099 or treating an idempotency key as a bearer credential. +- Production TLS, public bind, or durable export storage. +- Project-history by-idempotency lookup (duplicates GET-by-id). +- Temporal-context stored-request GET (already #464). +- Re-opening cancel lineages, Leiden, persistence, or GAP-010. + +## Alternatives considered + +1. Disclosure CLI that prints the stored create — rejected; weakens ADR 0099. +2. Reuse `tepp-export-lookup` — rejected; ADR 0094. +3. Project-history by-idempotency lookup — rejected; GET-by-id already keys + by `idempotency_key`. +4. Quarantine-parity dedicated binary — accepted. + +## Consequences + +Operators who try the reserved extra-segment from a published binary receive +the same authorization denial as the typed exchange. No stored create is +disclosed. + +## Failure and recovery + +Invalid hosts, origins, consumers, keys, leftover stdin, and the quarantined +valid path fail closed. Credential headers remain `authorization_denied`. + +## Verification + +- Valid `tepp-export-lookup-request get` returns `authorization_denied` and + never prints tenant/principal/artifact identities from a stored create; +- LineageWeave, public bind, `localhost`, `http` origin, leftover stdin, + reserved prefix, and slash fail closed before disclosure; +- Clippy `-D warnings`, `tepp_api` tests, rustdoc, and exact-head review + remain required. + +## Rollback and supersession + +Rollback removes the published binary; ADR 0099 quarantine remains. A +superseding ADR is required to disclose stored creates from a client key. + +## Related authority + +ADR 0099, ADR 0094, ADR 0014, RFC 9110 (Fielding, Nottingham, & Reschke, 2022). diff --git a/docs/adr/README.md b/docs/adr/README.md index ed902c49f..9fd29ad4e 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -34,6 +34,7 @@ Read [`ADR_POLICY.md`](ADR_POLICY.md) first. **Decision status and implementatio | [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. | +| [0100](0100-export-idempotency-lookup-stored-request-cli.md) | Quarantine-parity export lookup stored-request CLI | Accepted | active-PR | Complements ADR 0099; published `tepp-export-lookup-request get` returns `authorization_denied` and never discloses stored creates. Unique versus protected main (0026–0099 occupied including #466=0099). Does not weaken fail-closed. | | [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-cli.md b/docs/research/export-idempotency-lookup-stored-request-cli.md new file mode 100644 index 000000000..a01a7ee04 --- /dev/null +++ b/docs/research/export-idempotency-lookup-stored-request-cli.md @@ -0,0 +1,17 @@ +# Export lookup stored-request CLI quarantine (doctoring) + +`tepp-export-lookup-request get` is quarantine-parity of ADR 0099. It mints +no executable `GET /v1/exports/by-idempotency/{idempotency_key}/request` +disclosure onto spawned `tepp-loopback` TCP. The typed exchange builder +returns `authorization_denied` after origin/key validation. HTTP semantics +follow RFC 9110 (Fielding, Nottingham, & Reschke, 2022). + +An idempotency key is replay identity, not authorization to disclose another +tenant's stored create. `tenant_workspace_id` and `principal_id` never appear +on CLI stdout. `tepp.scientific_acceptance.v1` never appears. CLI failure 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. Does not weaken ADR 0099. Does not +duplicate `tepp-export-lookup` (#466) or `{export_id}/request` CLI (#459). From 71f34b890bbd096eee152947c5e22d9778d323e8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 19:37:55 +0000 Subject: [PATCH 23/23] docs(api): align lookup stored-request CLI rustdoc with ADR 0099 Module and compose docs no longer promise stored-create stdout. The typed exchange remains authorization_denied after origin/key validation. --- .../src/bin/tepp_export_lookup_request.rs | 5 ++- ...t_idempotency_lookup_stored_request_cli.rs | 38 +++++++++++-------- 2 files changed, 27 insertions(+), 16 deletions(-) diff --git a/crates/tepp_api/src/bin/tepp_export_lookup_request.rs b/crates/tepp_api/src/bin/tepp_export_lookup_request.rs index ef705d534..c27d1482f 100644 --- a/crates/tepp_api/src/bin/tepp_export_lookup_request.rs +++ b/crates/tepp_api/src/bin/tepp_export_lookup_request.rs @@ -1,4 +1,7 @@ -//! Operator CLI for loopback naruon export lookup stored-request GET. +//! Operator CLI for quarantined naruon export lookup stored-request GET. +//! +//! Compose returns `authorization_denied` after origin/key validation (ADR +//! 0099). This binary never prints a stored export-authorization request. use std::io::{self, IsTerminal}; use std::process::ExitCode; diff --git a/crates/tepp_api/src/export_idempotency_lookup_stored_request_cli.rs b/crates/tepp_api/src/export_idempotency_lookup_stored_request_cli.rs index ec3b0cc26..f33d2e1ef 100644 --- a/crates/tepp_api/src/export_idempotency_lookup_stored_request_cli.rs +++ b/crates/tepp_api/src/export_idempotency_lookup_stored_request_cli.rs @@ -1,11 +1,13 @@ //! Operator loopback CLI for naruon export lookup stored-request GET. //! -//! Operators run `tepp-export-lookup-request get` to mint -//! `naruon_export_idempotency_lookup_stored_request_exchange` onto spawned -//! `tepp-loopback` TCP. Stdout is the stored export-authorization request. -//! Reserved `by-idempotency` as a key, slash, and NUL fail closed to match -//! lookup stored-request GET. `tepp.scientific_acceptance.v1` never -//! appears. `LineageWeave` is refused and `NaruonLiveService` stays POST-only. +//! Operators run `tepp-export-lookup-request get` as quarantine-parity of +//! ADR 0099. `naruon_export_idempotency_lookup_stored_request_exchange` +//! returns [`ApiError::AuthorizationDenied`] after origin/key validation. +//! Compose never mints HTTP onto `tepp-loopback`. Stdout never contains a +//! stored export-authorization request, `tenant_workspace_id`, or +//! `principal_id`. Reserved `by-idempotency` as a key, slash, and NUL fail +//! closed. `tepp.scientific_acceptance.v1` never appears. `LineageWeave` is +//! refused and `NaruonLiveService` stays POST-only. use std::collections::HashSet; use std::fmt::Write as _; @@ -286,12 +288,18 @@ pub fn loopback_http1_from_export_idempotency_lookup_stored_request_exchange( Ok(request) } -/// Compose one HTTP/1.1 lookup GET from the typed naruon exchange. +/// Compose one HTTP/1.1 lookup stored-request GET from the typed exchange. +/// +/// While ADR 0099 is in force the typed exchange returns +/// [`ApiError::AuthorizationDenied`] after origin/key validation, so this +/// function never emits a wire request and never discloses a stored create. /// /// # Errors /// -/// Returns the same fail-closed errors as -/// [`ExportIdempotencyLookupStoredRequestCliInvocation::validate`]. +/// Returns fail-closed validation errors from +/// [`ExportIdempotencyLookupStoredRequestCliInvocation::validate`], then +/// [`ApiError::AuthorizationDenied`] for an otherwise valid quarantined +/// invocation. pub fn compose_export_idempotency_lookup_stored_request_cli_http( invocation: &ExportIdempotencyLookupStoredRequestCliInvocation, ) -> Result { @@ -344,17 +352,17 @@ pub fn execute_export_idempotency_lookup_stored_request_cli( parse_http_response(&bytes) } -/// Filter CLI stdout so lookup GET never prints scientific acceptance. +/// Filter CLI stdout so the quarantined lookup never prints a stored create. /// -/// RMSE, bias, coverage, SE-gate, tenant, principal, source-text, and -/// causal-score keys fail closed. Success stdout is only the metric-free -/// identity projection. +/// Tenant, principal, RMSE, bias, coverage, SE-gate, source-text, and +/// causal-score keys fail closed. A 200 stored-create body is unreachable +/// while ADR 0099 remains in force; compose fails first. /// /// # Errors /// /// Returns [`ApiError::InvalidWirePayload`] when a body carries metric keys, -/// `tepp.scientific_acceptance.v1`, or a success body that is not an -/// `ExportIdempotencyLookup`. +/// `tepp.scientific_acceptance.v1`, tenant/principal fields, or a success +/// body that is not a metric-free stored create. pub fn render_export_idempotency_lookup_stored_request_cli_stdout( invocation: &ExportIdempotencyLookupStoredRequestCliInvocation, response: &NaruonLiveResponse,