From b600198587b013241b095a1fb5d69518aacede31 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 19:34:18 +0000 Subject: [PATCH 1/2] 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 2/2] 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,