diff --git a/CHANGELOG.d/interpretation-run-lookup-cli.md b/CHANGELOG.d/interpretation-run-lookup-cli.md new file mode 100644 index 000000000..ef354a66a --- /dev/null +++ b/CHANGELOG.d/interpretation-run-lookup-cli.md @@ -0,0 +1 @@ +- `tepp-interpretation-run-lookup lookup` mints contextual-orchestrator `GET /v1/interpretation-runs/by-run-id/{interpretation_run_id}` onto spawned `tepp-orchestrator-loopback` TCP (ADR 0096). Metric-free identity; `claim_status` remains hypothetical; `scientific_authority` remains false. Empty stdin admitted. Does not infer causality. Naruon and LineageWeave refused. `NaruonLiveService` stays POST-only. Does not re-open cancel lineages. Not GAP-010 Figma/export, not persistence. diff --git a/CHANGELOG.d/interpretation-run-lookup-http.md b/CHANGELOG.d/interpretation-run-lookup-http.md new file mode 100644 index 000000000..1eba53fdb --- /dev/null +++ b/CHANGELOG.d/interpretation-run-lookup-http.md @@ -0,0 +1 @@ +- `GET /v1/interpretation-runs/by-run-id/{interpretation_run_id}` returns the accepted contextual-orchestrator metric-free identity on `tepp-orchestrator-loopback` (ADR 0095). Dual identity of GET-by-id (`idempotency_key`). Zero and ambiguous matches fail closed. `claim_status` remains hypothetical; `scientific_authority` remains false. Does not infer causality. Naruon and LineageWeave refused. `NaruonLiveService` stays POST-only. Does not re-open cancel lineages. Not GAP-010 Figma/export, not persistence. diff --git a/CHANGELOG.d/interpretation-run-lookup-stored-request-cli.md b/CHANGELOG.d/interpretation-run-lookup-stored-request-cli.md new file mode 100644 index 000000000..f1a8fe094 --- /dev/null +++ b/CHANGELOG.d/interpretation-run-lookup-stored-request-cli.md @@ -0,0 +1 @@ +- Published `tepp-interpretation-run-lookup-request get` mints `GET /v1/interpretation-runs/by-run-id/{interpretation_run_id}/request` onto spawned `tepp-orchestrator-loopback` TCP (ADR 0098). Dual identity of stored-request CLI (`idempotency_key`). Empty stdin admitted. Public bind/`localhost`/`http` origin/unpublished consumer/credential flags fail closed. `scientific_authority` remains false. Does not infer causality. Naruon and LineageWeave refused. `NaruonLiveService` stays POST-only. Does not re-open cancel lineages. Not GAP-010 Figma/export, not persistence. diff --git a/CHANGELOG.d/interpretation-run-lookup-stored-request-get.md b/CHANGELOG.d/interpretation-run-lookup-stored-request-get.md new file mode 100644 index 000000000..2298bb885 --- /dev/null +++ b/CHANGELOG.d/interpretation-run-lookup-stored-request-get.md @@ -0,0 +1 @@ +- `GET /v1/interpretation-runs/by-run-id/{interpretation_run_id}/request` returns the accepted contextual-orchestrator stored create request on `tepp-orchestrator-loopback` (ADR 0097). Dual identity of stored-request GET (`idempotency_key`). Zero and ambiguous matches fail closed. `scientific_authority` remains false. Does not infer causality. Naruon and LineageWeave refused. `NaruonLiveService` stays POST-only. Does not re-open cancel lineages. Not GAP-010 Figma/export, not persistence. diff --git a/CHANGELOG.d/interpretation-run-stored-request-cli.md b/CHANGELOG.d/interpretation-run-stored-request-cli.md new file mode 100644 index 000000000..0ea9a4d12 --- /dev/null +++ b/CHANGELOG.d/interpretation-run-stored-request-cli.md @@ -0,0 +1 @@ +- `tepp-interpretation-run-request get` mints contextual-orchestrator `GET /v1/interpretation-runs/{idempotency_key}/request` onto spawned `tepp-orchestrator-loopback` TCP (ADR 0086). Metric-free; `scientific_authority` remains false. Empty stdin admitted. Does not infer causality. Naruon and LineageWeave refused. `NaruonLiveService` stays POST-only. Does not re-open cancel lineages. Not GAP-010 Figma/export, not persistence. diff --git a/CHANGELOG.d/interpretation-run-stored-request-get.md b/CHANGELOG.d/interpretation-run-stored-request-get.md new file mode 100644 index 000000000..2b0c143a0 --- /dev/null +++ b/CHANGELOG.d/interpretation-run-stored-request-get.md @@ -0,0 +1 @@ +- `GET /v1/interpretation-runs/{idempotency_key}/request` returns the accepted contextual-orchestrator create request on `tepp-orchestrator-loopback` (ADR 0085). Metric-free; `scientific_authority` remains false. Does not infer causality. Naruon and 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 e82530848..6bc8b162c 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -16,6 +16,12 @@ TEPP's approved PRD v0.4 and implementation plan are the primary product baselin | Interpretation-run CLI doctoring | [`docs/research/interpretation-run-cli.md`](docs/research/interpretation-run-cli.md) | | Interpretation-run collection GET doctoring | [`docs/research/interpretation-run-collection-http.md`](docs/research/interpretation-run-collection-http.md) | | Interpretation-run GET-by-id doctoring | [`docs/research/interpretation-run-retrieval-http.md`](docs/research/interpretation-run-retrieval-http.md) | +| Interpretation-run stored-request GET doctoring | [`docs/research/interpretation-run-stored-request-get.md`](docs/research/interpretation-run-stored-request-get.md) | +| Interpretation-run stored-request CLI doctoring | [`docs/research/interpretation-run-stored-request-cli.md`](docs/research/interpretation-run-stored-request-cli.md) | +| Interpretation-run lookup GET doctoring | [`docs/research/interpretation-run-lookup-http.md`](docs/research/interpretation-run-lookup-http.md) | +| Interpretation-run lookup CLI doctoring | [`docs/research/interpretation-run-lookup-cli.md`](docs/research/interpretation-run-lookup-cli.md) | +| Interpretation-run lookup stored-request GET doctoring | [`docs/research/interpretation-run-lookup-stored-request-http.md`](docs/research/interpretation-run-lookup-stored-request-http.md) | +| Interpretation-run lookup stored-request CLI doctoring | [`docs/research/interpretation-run-lookup-stored-request-cli.md`](docs/research/interpretation-run-lookup-stored-request-cli.md) | | UML/runtime/scientific flows | [`docs/UML.md`](docs/UML.md) | | Logical/physical ERD | [`docs/ERD.md`](docs/ERD.md) | | Security policy | [`SECURITY.md`](SECURITY.md) | diff --git a/crates/orchestrator_live/Cargo.toml b/crates/orchestrator_live/Cargo.toml index 38df1a3bc..f89c6b0fa 100644 --- a/crates/orchestrator_live/Cargo.toml +++ b/crates/orchestrator_live/Cargo.toml @@ -29,5 +29,23 @@ path = "src/bin/tepp_interpretation_runs.rs" test = false bench = false +[[bin]] +name = "tepp-interpretation-run-request" +path = "src/bin/tepp_interpretation_run_request.rs" +test = false +bench = false + +[[bin]] +name = "tepp-interpretation-run-lookup" +path = "src/bin/tepp_interpretation_run_lookup.rs" +test = false +bench = false + +[[bin]] +name = "tepp-interpretation-run-lookup-request" +path = "src/bin/tepp_interpretation_run_lookup_request.rs" +test = false +bench = false + [lints] workspace = true diff --git a/crates/orchestrator_live/src/bin/tepp_interpretation_run_lookup.rs b/crates/orchestrator_live/src/bin/tepp_interpretation_run_lookup.rs new file mode 100644 index 000000000..38d2e6736 --- /dev/null +++ b/crates/orchestrator_live/src/bin/tepp_interpretation_run_lookup.rs @@ -0,0 +1,34 @@ +//! Operator CLI for loopback contextual-orchestrator lookup GET. + +use std::io::{self, IsTerminal}; +use std::process::ExitCode; + +use orchestrator_live::{ + InterpretationRunLookupCliInvocation, OrchestratorLiveError, + execute_interpretation_run_lookup_cli, read_interpretation_run_lookup_cli_stdin, + render_interpretation_run_lookup_cli_stdout, +}; + +fn main() -> ExitCode { + match run() { + Ok(()) => ExitCode::SUCCESS, + Err(_) => ExitCode::FAILURE, + } +} + +fn run() -> Result<(), OrchestratorLiveError> { + let args: Vec = std::env::args().skip(1).collect(); + match args.first().map(String::as_str) { + Some("lookup") => run_lookup(&args), + _ => Err(OrchestratorLiveError::InvalidWirePayload), + } +} + +fn run_lookup(args: &[String]) -> Result<(), OrchestratorLiveError> { + let body = read_interpretation_run_lookup_cli_stdin(io::stdin().is_terminal(), io::stdin())?; + let invocation = InterpretationRunLookupCliInvocation::from_args(args, body)?; + let response = execute_interpretation_run_lookup_cli(&invocation)?; + let stdout = render_interpretation_run_lookup_cli_stdout(&invocation, &response)?; + println!("{stdout}"); + Ok(()) +} diff --git a/crates/orchestrator_live/src/bin/tepp_interpretation_run_lookup_request.rs b/crates/orchestrator_live/src/bin/tepp_interpretation_run_lookup_request.rs new file mode 100644 index 000000000..6db7801c6 --- /dev/null +++ b/crates/orchestrator_live/src/bin/tepp_interpretation_run_lookup_request.rs @@ -0,0 +1,39 @@ +//! Operator CLI for loopback contextual-orchestrator lookup stored-request GET. + +use std::io::{self, IsTerminal}; +use std::process::ExitCode; + +use orchestrator_live::{ + InterpretationRunLookupStoredRequestCliInvocation, OrchestratorLiveError, + execute_interpretation_run_lookup_stored_request_cli, + read_interpretation_run_lookup_stored_request_cli_stdin, + render_interpretation_run_lookup_stored_request_cli_stdout, +}; + +fn main() -> ExitCode { + match run() { + Ok(()) => ExitCode::SUCCESS, + Err(_) => ExitCode::FAILURE, + } +} + +fn run() -> Result<(), OrchestratorLiveError> { + let args: Vec = std::env::args().skip(1).collect(); + match args.first().map(String::as_str) { + Some("get") => run_get(&args), + _ => Err(OrchestratorLiveError::InvalidWirePayload), + } +} + +fn run_get(args: &[String]) -> Result<(), OrchestratorLiveError> { + let body = read_interpretation_run_lookup_stored_request_cli_stdin( + io::stdin().is_terminal(), + io::stdin(), + )?; + let invocation = InterpretationRunLookupStoredRequestCliInvocation::from_args(args, body)?; + let response = execute_interpretation_run_lookup_stored_request_cli(&invocation)?; + let stdout = + render_interpretation_run_lookup_stored_request_cli_stdout(&invocation, &response)?; + println!("{stdout}"); + Ok(()) +} diff --git a/crates/orchestrator_live/src/bin/tepp_interpretation_run_request.rs b/crates/orchestrator_live/src/bin/tepp_interpretation_run_request.rs new file mode 100644 index 000000000..1b6f4ad27 --- /dev/null +++ b/crates/orchestrator_live/src/bin/tepp_interpretation_run_request.rs @@ -0,0 +1,35 @@ +//! Operator CLI for loopback contextual-orchestrator stored-request GET. + +use std::io::{self, IsTerminal}; +use std::process::ExitCode; + +use orchestrator_live::{ + execute_interpretation_run_stored_request_cli, read_interpretation_run_stored_request_cli_stdin, + render_interpretation_run_stored_request_cli_stdout, + InterpretationRunStoredRequestCliInvocation, OrchestratorLiveError, +}; + +fn main() -> ExitCode { + match run() { + Ok(()) => ExitCode::SUCCESS, + Err(_) => ExitCode::FAILURE, + } +} + +fn run() -> Result<(), OrchestratorLiveError> { + let args: Vec = std::env::args().skip(1).collect(); + match args.first().map(String::as_str) { + Some("get") => run_get(&args), + _ => Err(OrchestratorLiveError::InvalidWirePayload), + } +} + +fn run_get(args: &[String]) -> Result<(), OrchestratorLiveError> { + let body = + read_interpretation_run_stored_request_cli_stdin(io::stdin().is_terminal(), io::stdin())?; + let invocation = InterpretationRunStoredRequestCliInvocation::from_args(args, body)?; + let response = execute_interpretation_run_stored_request_cli(&invocation)?; + let stdout = render_interpretation_run_stored_request_cli_stdout(&invocation, &response)?; + println!("{stdout}"); + Ok(()) +} diff --git a/crates/orchestrator_live/src/interpretation_run_lookup_cli.rs b/crates/orchestrator_live/src/interpretation_run_lookup_cli.rs new file mode 100644 index 000000000..d183638fe --- /dev/null +++ b/crates/orchestrator_live/src/interpretation_run_lookup_cli.rs @@ -0,0 +1,700 @@ +//! Operator loopback CLI for contextual-orchestrator lookup GET. +//! +//! GAP-003A unique slice: operators run `tepp-interpretation-run-lookup lookup` +//! to mint `contextual_orchestrator_interpretation_run_lookup_exchange` onto +//! spawned `tepp-orchestrator-loopback` TCP. Stdout is the metric-free identity +//! with `claim_status=hypothetical` and `scientific_authority=false`. +//! `tepp.scientific_acceptance.v1` never appears. The CLI does not infer +//! causality or call a model provider. Naruon and `LineageWeave` are refused. +//! `NaruonLiveService` stays POST-only. This module does not duplicate lookup +//! GET (#467), GET-by-id (#438), retrieval CLI (#439), collection GET/CLI +//! (#433/#436), stored-request GET/CLI (#453/#454), create CLI (#425), export +//! lookup (#466), analysis-run lookup GET/CLI (#380/#401), or cancel lineages +//! (closed). Persistence remains GAP-003B. + +use std::fmt::Write as _; +use std::io::{Read, Write}; +use std::net::{SocketAddr, TcpStream}; +use std::time::Duration; + +use crate::http::{header_is_credential, map_io_error}; +use crate::interpretation_run_cli::CONTEXTUAL_ORCHESTRATOR_CONSUMER_CODE; +use crate::interpretation_run_collection_http::{ + InterpretationRunCollectionItem, refuse_metrics_on_interpretation_run_collection_payload, +}; +use crate::interpretation_run_lookup_http::{ + INTERPRETATION_RUN_LOOKUP_PREFIX, InterpretationRunLookupHttpExchange, + contextual_orchestrator_interpretation_run_lookup_exchange, interpretation_run_lookup_path_id, +}; +use crate::interpretation_run_retrieval_http::interpretation_run_retrieval_item_json; +use crate::request::{DEFAULT_INTERPRETATION_BYTE_LIMIT, require_nonempty}; +use crate::{ + LIVE_HEADER_BYTE_LIMIT, LIVE_HEADER_COUNT_LIMIT, OrchestratorLiveError, + OrchestratorLiveResponse, OrchestratorLiveService, +}; + +const SCIENTIFIC_ACCEPTANCE_SCHEMA: &str = "tepp.scientific_acceptance.v1"; +const CLI_IO_TIMEOUT: Duration = Duration::from_secs(2); +const MAXIMUM_HTTP_RESPONSE_BYTES: usize = + LIVE_HEADER_BYTE_LIMIT + 4 + DEFAULT_INTERPRETATION_BYTE_LIMIT; + +/// Supported operator verbs for the loopback interpretation-run lookup CLI. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum InterpretationRunLookupCliVerb { + /// `GET /v1/interpretation-runs/by-run-id/{interpretation_run_id}`. + Lookup, +} + +impl InterpretationRunLookupCliVerb { + /// Parse one exact lowercase verb token. + /// + /// # Errors + /// + /// Returns [`OrchestratorLiveError::InvalidWirePayload`] for an unknown token. + pub fn parse(token: &str) -> Result { + match token { + "lookup" => Ok(Self::Lookup), + _ => Err(OrchestratorLiveError::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 lookup listener. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct InterpretationRunLookupCliInvocation { + /// CLI verb to execute. + pub verb: InterpretationRunLookupCliVerb, + /// Loopback `host:port` of `tepp-orchestrator-loopback`. + pub host: String, + /// Published HTTPS origin used to mint the typed lookup exchange. + pub origin: String, + /// Published modular consumer. Lookup GET admits + /// `contextual-orchestrator` only. + pub consumer: String, + /// Server-assigned opaque interpretation-run identity. + pub interpretation_run_id: String, + /// JSON body. Lookup GET requires empty. + pub body: String, +} + +impl InterpretationRunLookupCliInvocation { + /// Parse argv plus stdin body into a validated loopback lookup invocation. + /// + /// Empty stdin is admitted. Nonempty leftover stdin fails closed. + /// + /// # Errors + /// + /// Returns a fail-closed error for unknown verbs, missing required flags, a + /// non-loopback host, a non-`https` origin, an unpublished consumer, + /// credential-shaped flags, a hostile identity, 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(OrchestratorLiveError::InvalidWirePayload)?; + let verb = InterpretationRunLookupCliVerb::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 [`OrchestratorLiveError::AuthorizationDenied`] for a non-loopback + /// host and [`OrchestratorLiveError::InvalidWirePayload`] or + /// [`OrchestratorLiveError::LimitExceeded`] for empty, unpublished, + /// nonempty-body, reserved-prefix, or oversized fields. + pub fn validate(&self) -> Result<(), OrchestratorLiveError> { + require_loopback_host(&self.host)?; + require_nonempty(&self.origin)?; + if !self.origin.starts_with("https://") { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + require_nonempty(&self.consumer)?; + if self.consumer != CONTEXTUAL_ORCHESTRATOR_CONSUMER_CODE { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + require_nonempty(&self.interpretation_run_id)?; + if self.interpretation_run_id == INTERPRETATION_RUN_LOOKUP_PREFIX { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + if self.interpretation_run_id.contains('/') || self.interpretation_run_id.contains('\0') { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + if !self.body.is_empty() { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + refuse_scientific_acceptance(&self.body)?; + refuse_metrics_on_interpretation_run_collection_payload(&self.body)?; + Ok(()) + } +} + +struct ParsedFlags { + host: Option, + origin: Option, + consumer: Option, + interpretation_run_id: Option, +} + +fn parse_flags(rest: &[String]) -> Result { + let mut flags = ParsedFlags { + host: None, + origin: None, + consumer: None, + interpretation_run_id: None, + }; + let mut index = 0; + while index < rest.len() { + let flag = rest[index].as_str(); + if !flag.starts_with("--") { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + let name = &flag[2..]; + if header_is_credential(name) { + return Err(OrchestratorLiveError::AuthorizationDenied); + } + let slot = match name { + "host" => &mut flags.host, + "origin" => &mut flags.origin, + "consumer" => &mut flags.consumer, + "interpretation-run-id" => &mut flags.interpretation_run_id, + _ => return Err(OrchestratorLiveError::InvalidWirePayload), + }; + if slot.is_some() || index + 1 >= rest.len() { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + let value = rest[index + 1].as_str(); + require_nonempty(value)?; + *slot = Some(value.to_owned()); + index += 2; + } + Ok(flags) +} + +fn assemble_invocation( + verb: InterpretationRunLookupCliVerb, + flags: ParsedFlags, + body: String, +) -> Result { + let invocation = InterpretationRunLookupCliInvocation { + verb, + host: flags + .host + .ok_or(OrchestratorLiveError::InvalidWirePayload)?, + origin: flags + .origin + .ok_or(OrchestratorLiveError::InvalidWirePayload)?, + consumer: flags + .consumer + .unwrap_or_else(|| CONTEXTUAL_ORCHESTRATOR_CONSUMER_CODE.to_owned()), + interpretation_run_id: flags + .interpretation_run_id + .ok_or(OrchestratorLiveError::InvalidWirePayload)?, + body, + }; + invocation.validate()?; + Ok(invocation) +} + +fn require_loopback_host(host: &str) -> Result { + let addr: SocketAddr = host + .parse() + .map_err(|_| OrchestratorLiveError::InvalidWirePayload)?; + if addr.ip().is_loopback() { + Ok(addr) + } else { + Err(OrchestratorLiveError::AuthorizationDenied) + } +} + +/// Render a typed lookup GET exchange as HTTP/1.1 for a loopback listener. +/// +/// # Errors +/// +/// Returns [`OrchestratorLiveError::AuthorizationDenied`] for a non-loopback +/// host or a credential-bearing header, and +/// [`OrchestratorLiveError::InvalidWirePayload`] when the exchange is not a +/// GET `/v1/interpretation-runs/by-run-id/{interpretation_run_id}` with an +/// empty body. +pub fn loopback_http1_from_interpretation_run_lookup_exchange( + exchange: &InterpretationRunLookupHttpExchange, + loopback_host: &str, +) -> Result { + let _addr = require_loopback_host(loopback_host)?; + let host = loopback_host.trim(); + if exchange.method != "GET" { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + if !exchange.body.is_empty() { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + let rest = exchange + .target_url + .strip_prefix("https://") + .ok_or(OrchestratorLiveError::InvalidWirePayload)?; + let path = rest + .find('/') + .map(|index| &rest[index..]) + .ok_or(OrchestratorLiveError::InvalidWirePayload)?; + let _interpretation_run_id = interpretation_run_lookup_path_id(path)?; + for (name, _) in &exchange.headers { + if header_is_credential(name) { + return Err(OrchestratorLiveError::AuthorizationDenied); + } + if name.eq_ignore_ascii_case("idempotency-key") + || name.eq_ignore_ascii_case("tepp-page-limit") + || name.eq_ignore_ascii_case("tepp-page-cursor") + { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + } + let mut request = String::new(); + write!( + request, + "{} {path} HTTP/1.1\r\nHost: {host}\r\n", + exchange.method + ) + .map_err(|_| OrchestratorLiveError::InvalidWirePayload)?; + for (name, value) in &exchange.headers { + if name.eq_ignore_ascii_case("host") || name.eq_ignore_ascii_case("content-length") { + continue; + } + write!(request, "{name}: {value}\r\n") + .map_err(|_| OrchestratorLiveError::InvalidWirePayload)?; + } + write!(request, "content-length: 0\r\n\r\n") + .map_err(|_| OrchestratorLiveError::InvalidWirePayload)?; + Ok(request) +} + +/// Compose one HTTP/1.1 lookup GET from the typed consumer exchange. +/// +/// # Errors +/// +/// Returns the same fail-closed errors as +/// [`InterpretationRunLookupCliInvocation::validate`]. +pub fn compose_interpretation_run_lookup_cli_http( + invocation: &InterpretationRunLookupCliInvocation, +) -> Result { + invocation.validate()?; + let exchange = contextual_orchestrator_interpretation_run_lookup_exchange( + &invocation.origin, + &invocation.interpretation_run_id, + )?; + loopback_http1_from_interpretation_run_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_interpretation_run_lookup_cli( + service: &mut OrchestratorLiveService, + invocation: &InterpretationRunLookupCliInvocation, +) -> Result { + let request = compose_interpretation_run_lookup_cli_http(invocation)?; + Ok(service.handle_http_request(&request)) +} + +/// Execute one lookup CLI invocation over loopback TCP. +/// +/// # Errors +/// +/// Returns fail-closed validation, transport, or response-framing errors. +pub fn execute_interpretation_run_lookup_cli( + invocation: &InterpretationRunLookupCliInvocation, +) -> Result { + let addr = require_loopback_host(&invocation.host)?; + let request = compose_interpretation_run_lookup_cli_http(invocation)?; + let mut stream = TcpStream::connect(addr).map_err(|error| map_io_error(&error))?; + stream + .set_read_timeout(Some(CLI_IO_TIMEOUT)) + .map_err(|error| map_io_error(&error))?; + stream + .set_write_timeout(Some(CLI_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. +/// +/// # Errors +/// +/// Returns [`OrchestratorLiveError::InvalidWirePayload`] when a receipt carries +/// metric keys, evidence, causal scores, or +/// `tepp.scientific_acceptance.v1`, or when the identity does not match. +pub fn render_interpretation_run_lookup_cli_stdout( + invocation: &InterpretationRunLookupCliInvocation, + response: &OrchestratorLiveResponse, +) -> Result { + invocation.validate()?; + if response.body.is_empty() { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + refuse_scientific_acceptance(&response.body)?; + refuse_metrics_on_interpretation_run_collection_payload(&response.body)?; + if response.status_code != 200 { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + let parsed: InterpretationRunCollectionItem = serde_json::from_str(&response.body) + .map_err(|_| OrchestratorLiveError::InvalidWirePayload)?; + let item = InterpretationRunCollectionItem::new( + parsed.interpretation_run_id, + parsed.idempotency_key, + parsed.orchestration_mode, + parsed.claim_status, + parsed.scientific_authority, + )?; + if item.interpretation_run_id != invocation.interpretation_run_id { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + interpretation_run_retrieval_item_json(&item) +} + +fn refuse_scientific_acceptance(body: &str) -> Result<(), OrchestratorLiveError> { + if body.contains(SCIENTIFIC_ACCEPTANCE_SCHEMA) { + Err(OrchestratorLiveError::InvalidWirePayload) + } else { + Ok(()) + } +} + +fn parse_http_response(bytes: &[u8]) -> Result { + let text = std::str::from_utf8(bytes).map_err(|_| OrchestratorLiveError::InvalidWirePayload)?; + let (header_block, body) = text + .split_once("\r\n\r\n") + .ok_or(OrchestratorLiveError::InvalidWirePayload)?; + if header_block.len() > LIVE_HEADER_BYTE_LIMIT { + return Err(OrchestratorLiveError::LimitExceeded); + } + let mut lines = header_block.split("\r\n"); + let status_line = lines + .next() + .ok_or(OrchestratorLiveError::InvalidWirePayload)?; + let mut parts = status_line.split(' '); + if parts.next() != Some("HTTP/1.1") { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + let code = parts + .next() + .ok_or(OrchestratorLiveError::InvalidWirePayload)? + .parse::() + .map_err(|_| OrchestratorLiveError::InvalidWirePayload)?; + let reason_phrase = static_reason(code)?; + let mut content_length = None; + for (index, line) in lines.enumerate() { + if index >= LIVE_HEADER_COUNT_LIMIT { + return Err(OrchestratorLiveError::LimitExceeded); + } + let (name, value) = line + .split_once(':') + .ok_or(OrchestratorLiveError::InvalidWirePayload)?; + if name.eq_ignore_ascii_case("content-length") { + if content_length.is_some() { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + content_length = Some( + value + .trim() + .parse::() + .map_err(|_| OrchestratorLiveError::InvalidWirePayload)?, + ); + } + } + let declared = content_length.ok_or(OrchestratorLiveError::InvalidWirePayload)?; + if declared > DEFAULT_INTERPRETATION_BYTE_LIMIT { + return Err(OrchestratorLiveError::LimitExceeded); + } + if declared != body.len() { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + Ok(OrchestratorLiveResponse { + status_code: code, + reason_phrase, + body: body.to_owned(), + }) +} + +fn static_reason(code: u16) -> Result<&'static str, OrchestratorLiveError> { + 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(OrchestratorLiveError::InvalidWirePayload), + } +} + +/// Read stdin leftover bytes on a non-terminal; lookup GET admits empty. +/// +/// # Errors +/// +/// Returns [`OrchestratorLiveError::InvalidWirePayload`] when stdin cannot be +/// read and [`OrchestratorLiveError::LimitExceeded`] when leftover stdin +/// exceeds the interpretation-run wire limit. +pub fn read_interpretation_run_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_INTERPRETATION_BYTE_LIMIT)?; + String::from_utf8(bytes).map_err(|_| OrchestratorLiveError::InvalidWirePayload) + } +} + +fn read_bounded( + reader: &mut impl Read, + maximum_bytes: usize, +) -> Result, OrchestratorLiveError> { + 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(OrchestratorLiveError::LimitExceeded); + } + Ok(bytes) +} + +#[cfg(test)] +mod tests { + use super::{ + InterpretationRunLookupCliInvocation, InterpretationRunLookupCliVerb, + compose_interpretation_run_lookup_cli_http, + loopback_http1_from_interpretation_run_lookup_exchange, + read_interpretation_run_lookup_cli_stdin, + }; + use crate::interpretation_run_cli::CONTEXTUAL_ORCHESTRATOR_CONSUMER_CODE; + use crate::{ + OrchestratorLiveError, contextual_orchestrator_interpretation_run_lookup_exchange, + }; + + const ORIGIN: &str = "https://tepp.example.test"; + + fn lookup_args() -> [&'static str; 9] { + [ + "lookup", + "--host", + "127.0.0.1:18082", + "--origin", + ORIGIN, + "--consumer", + CONTEXTUAL_ORCHESTRATOR_CONSUMER_CODE, + "--interpretation-run-id", + "orch-run-1", + ] + } + + #[test] + fn from_args_mints_lookup_and_refuses_fail_closed_inputs() { + assert_eq!( + InterpretationRunLookupCliVerb::parse("lookup").expect("lookup"), + InterpretationRunLookupCliVerb::Lookup + ); + assert_eq!(InterpretationRunLookupCliVerb::Lookup.as_str(), "lookup"); + assert_eq!( + InterpretationRunLookupCliVerb::parse("get"), + Err(OrchestratorLiveError::InvalidWirePayload) + ); + let lookup = + InterpretationRunLookupCliInvocation::from_args(lookup_args(), "").expect("lookup"); + assert_eq!(lookup.verb, InterpretationRunLookupCliVerb::Lookup); + let http = compose_interpretation_run_lookup_cli_http(&lookup).expect("http"); + assert!(http.starts_with("GET /v1/interpretation-runs/by-run-id/orch-run-1 HTTP/1.1")); + assert!(http.contains("tepp-consumer: contextual-orchestrator")); + assert!(http.contains("content-length: 0")); + assert!(!http.contains("idempotency-key:")); + assert!(!http.contains("authorization")); + assert_eq!( + InterpretationRunLookupCliInvocation::from_args( + [ + "lookup", + "--host", + "8.8.8.8:80", + "--origin", + ORIGIN, + "--interpretation-run-id", + "orch-run-1" + ], + "" + ) + .unwrap_err(), + OrchestratorLiveError::AuthorizationDenied + ); + assert_eq!( + InterpretationRunLookupCliInvocation::from_args( + [ + "lookup", + "--host", + "localhost:18082", + "--origin", + ORIGIN, + "--interpretation-run-id", + "orch-run-1" + ], + "" + ) + .unwrap_err(), + OrchestratorLiveError::InvalidWirePayload + ); + assert_eq!( + InterpretationRunLookupCliInvocation::from_args( + [ + "lookup", + "--host", + "127.0.0.1:18082", + "--origin", + "http://tepp.example.test", + "--interpretation-run-id", + "orch-run-1" + ], + "" + ) + .unwrap_err(), + OrchestratorLiveError::InvalidWirePayload + ); + assert_eq!( + InterpretationRunLookupCliInvocation::from_args( + [ + "lookup", + "--host", + "127.0.0.1:18082", + "--origin", + ORIGIN, + "--interpretation-run-id", + "orch-run-1", + "--authorization", + "secret" + ], + "" + ) + .unwrap_err(), + OrchestratorLiveError::AuthorizationDenied + ); + } + + #[test] + fn from_args_refuses_unpublished_body_pagination_and_non_get() { + assert_eq!( + InterpretationRunLookupCliInvocation::from_args( + [ + "lookup", + "--host", + "127.0.0.1:18082", + "--origin", + ORIGIN, + "--consumer", + "naruon", + "--interpretation-run-id", + "orch-run-1" + ], + "" + ) + .unwrap_err(), + OrchestratorLiveError::InvalidWirePayload + ); + assert_eq!( + InterpretationRunLookupCliInvocation::from_args( + [ + "lookup", + "--host", + "127.0.0.1:18082", + "--origin", + ORIGIN, + "--consumer", + "lineageweave", + "--interpretation-run-id", + "orch-run-1" + ], + "" + ) + .unwrap_err(), + OrchestratorLiveError::InvalidWirePayload + ); + assert_eq!( + InterpretationRunLookupCliInvocation::from_args(lookup_args(), "{}").unwrap_err(), + OrchestratorLiveError::InvalidWirePayload + ); + assert_eq!( + InterpretationRunLookupCliInvocation::from_args( + [ + "lookup", + "--host", + "127.0.0.1:18082", + "--origin", + ORIGIN, + "--interpretation-run-id", + "by-run-id" + ], + "" + ) + .unwrap_err(), + OrchestratorLiveError::InvalidWirePayload + ); + assert_eq!( + InterpretationRunLookupCliInvocation::from_args( + [ + "lookup", + "--host", + "127.0.0.1:18082", + "--origin", + ORIGIN, + "--interpretation-run-id", + "orch-run-1", + "--page-limit", + "1" + ], + "" + ) + .unwrap_err(), + OrchestratorLiveError::InvalidWirePayload + ); + let leftover = + read_interpretation_run_lookup_cli_stdin(false, std::io::Cursor::new(b"leftover")) + .expect("leftover"); + assert_eq!(leftover, "leftover"); + assert!( + read_interpretation_run_lookup_cli_stdin(true, std::io::empty()) + .expect("tty") + .is_empty() + ); + let exchange = + contextual_orchestrator_interpretation_run_lookup_exchange(ORIGIN, "orch-run-1") + .expect("exchange"); + let mut posted = exchange.clone(); + posted.method = "POST"; + assert_eq!( + loopback_http1_from_interpretation_run_lookup_exchange(&posted, "127.0.0.1:18082") + .unwrap_err(), + OrchestratorLiveError::InvalidWirePayload + ); + } +} diff --git a/crates/orchestrator_live/src/interpretation_run_lookup_http.rs b/crates/orchestrator_live/src/interpretation_run_lookup_http.rs new file mode 100644 index 000000000..9c59015a3 --- /dev/null +++ b/crates/orchestrator_live/src/interpretation_run_lookup_http.rs @@ -0,0 +1,332 @@ +//! Provider-owned interpretation-run lookup GET contracts. +//! +//! GAP-003A unique slice: `GET /v1/interpretation-runs/by-run-id/{interpretation_run_id}` +//! returns the metric-free identity of the unique accepted hypothetical run +//! that used that server-assigned `interpretation_run_id` on +//! `OrchestratorLiveService` / `tepp-orchestrator-loopback`. GET-by-id +//! requires the client `idempotency_key`. Collection GET is a different +//! stack. Operators who hold a 202 acceptance receipt or log `orch-run-N` +//! cannot jump to that identity without scanning pages. Rows stay +//! `claim_status=hypothetical` and `scientific_authority=false`. +//! `tepp.scientific_acceptance.v1` never appears. The lookup does not infer +//! causality or call a model provider. This module does not duplicate +//! GET-by-id (#438), retrieval CLI (#439), collection GET/CLI (#433/#436), +//! stored-request GET/CLI (#453/#454), create CLI (#425), export lookup +//! (#466), analysis-run lookup GET (#380), or cancel lineages (closed). +//! Persistence remains GAP-003B. Naruon and `LineageWeave` are refused. +//! `NaruonLiveService` stays POST-only. + +use crate::error::OrchestratorLiveError; +use crate::interpretation_run_cli::CONTEXTUAL_ORCHESTRATOR_CONSUMER_CODE; +use crate::interpretation_run_retrieval_http::INTERPRETATION_RUN_RETRIEVAL_ID_MAX_LEN; +use crate::request::{INTERPRETATION_RUN_PATH, host_implies_table_access, require_nonempty}; + +/// Reserved collection-relative prefix that names the lookup resource. +pub const INTERPRETATION_RUN_LOOKUP_PREFIX: &str = "by-run-id"; + +/// Maximum opaque `interpretation_run_id` length on the lookup path. +pub const INTERPRETATION_RUN_LOOKUP_ID_MAX_LEN: usize = INTERPRETATION_RUN_RETRIEVAL_ID_MAX_LEN; + +/// Typed GET exchange for interpretation-run lookup by server-assigned id. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct InterpretationRunLookupHttpExchange { + /// HTTP method, always `GET`. + pub method: &'static str, + /// Absolute HTTPS target ending in `/v1/interpretation-runs/by-run-id/{id}`. + pub target_url: String, + /// Exact version, consumer, and content headers. No credentials. + pub headers: Vec<(String, String)>, + /// GET body, always empty. + pub body: String, +} + +/// Extract the opaque `interpretation_run_id` from +/// `GET /v1/interpretation-runs/by-run-id/{interpretation_run_id}`. +/// +/// The route is segmented before percent decoding, so an encoded `/` remains +/// data inside one opaque identity rather than becoming an extra path segment. +/// +/// # Errors +/// +/// Returns [`OrchestratorLiveError::InvalidWirePayload`] for collection, +/// GET-by-id, extra raw segments, a missing `by-run-id` prefix, stored-request +/// `/request` suffix, reserved prefix used as the id, slash, NUL, empty +/// identity, or a hostile encoding, and +/// [`OrchestratorLiveError::LimitExceeded`] when oversized. +pub fn interpretation_run_lookup_path_id(path: &str) -> Result { + let remainder = path + .strip_prefix(INTERPRETATION_RUN_PATH) + .ok_or(OrchestratorLiveError::InvalidWirePayload)?; + let encoded = remainder + .strip_prefix('/') + .ok_or(OrchestratorLiveError::InvalidWirePayload)?; + let encoded = encoded + .strip_prefix(INTERPRETATION_RUN_LOOKUP_PREFIX) + .ok_or(OrchestratorLiveError::InvalidWirePayload)?; + let encoded = encoded + .strip_prefix('/') + .ok_or(OrchestratorLiveError::InvalidWirePayload)?; + if encoded.is_empty() || encoded.contains('/') { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + let interpretation_run_id = decode_path_segment(encoded)?; + require_nonempty(&interpretation_run_id)?; + if interpretation_run_id == INTERPRETATION_RUN_LOOKUP_PREFIX { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + if interpretation_run_id.contains('/') || interpretation_run_id.contains('\0') { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + if interpretation_run_id.len() > INTERPRETATION_RUN_LOOKUP_ID_MAX_LEN { + return Err(OrchestratorLiveError::LimitExceeded); + } + Ok(interpretation_run_id) +} + +/// Whether `path` is the lookup-by-run-id resource. +#[must_use] +pub fn is_interpretation_run_lookup_path(path: &str) -> bool { + interpretation_run_lookup_path_id(path).is_ok() +} + +/// Build a credential-free contextual-orchestrator lookup GET exchange. +/// +/// The builder refuses non-`https` origins and empty or oversized identities. +/// It does not inject credentials. The GET body is empty. The opaque id is +/// percent-encoded into exactly one path segment after `by-run-id`. +/// +/// # Errors +/// +/// Returns a fail-closed origin or identity error. +pub fn contextual_orchestrator_interpretation_run_lookup_exchange( + origin: &str, + interpretation_run_id: &str, +) -> Result { + require_nonempty(origin)?; + if !origin.starts_with("https://") || origin.ends_with('/') { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + let rest = origin + .strip_prefix("https://") + .ok_or(OrchestratorLiveError::InvalidWirePayload)?; + if rest.contains('@') || rest.contains('?') || rest.contains('#') || rest.contains('\\') { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + if host_implies_table_access(rest) { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + require_nonempty(interpretation_run_id)?; + if interpretation_run_id == INTERPRETATION_RUN_LOOKUP_PREFIX { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + if interpretation_run_id.contains('/') || interpretation_run_id.contains('\0') { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + if interpretation_run_id.len() > INTERPRETATION_RUN_LOOKUP_ID_MAX_LEN { + return Err(OrchestratorLiveError::LimitExceeded); + } + let encoded_id = encode_path_segment(interpretation_run_id); + Ok(InterpretationRunLookupHttpExchange { + method: "GET", + target_url: format!( + "{origin}{INTERPRETATION_RUN_PATH}/{INTERPRETATION_RUN_LOOKUP_PREFIX}/{encoded_id}" + ), + headers: vec![ + ("content-type".into(), "application/json".into()), + ( + "tepp-consumer".into(), + CONTEXTUAL_ORCHESTRATOR_CONSUMER_CODE.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()); + 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); + } + _ => { + let hex = b"0123456789ABCDEF"; + 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(OrchestratorLiveError::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(OrchestratorLiveError::InvalidWirePayload), + } + } + let decoded = String::from_utf8(out).map_err(|_| OrchestratorLiveError::InvalidWirePayload)?; + if decoded.chars().any(char::is_control) { + return Err(OrchestratorLiveError::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(OrchestratorLiveError::InvalidWirePayload), + } +} + +#[cfg(test)] +mod tests { + use super::{ + INTERPRETATION_RUN_LOOKUP_ID_MAX_LEN, INTERPRETATION_RUN_LOOKUP_PREFIX, + contextual_orchestrator_interpretation_run_lookup_exchange, + interpretation_run_lookup_path_id, is_interpretation_run_lookup_path, + }; + use crate::error::OrchestratorLiveError; + + #[test] + fn lookup_exchange_is_metric_free_get_without_credentials() { + let exchange = contextual_orchestrator_interpretation_run_lookup_exchange( + "https://tepp.example.test", + "orch-run-1", + ) + .expect("exchange"); + assert_eq!(exchange.method, "GET"); + assert!( + exchange + .target_url + .ends_with("/v1/interpretation-runs/by-run-id/orch-run-1") + ); + assert!(exchange.body.is_empty()); + assert!( + !exchange + .headers + .iter() + .any(|(name, _)| name.eq_ignore_ascii_case("authorization") + || name.eq_ignore_ascii_case("idempotency-key")) + ); + assert!(is_interpretation_run_lookup_path( + "/v1/interpretation-runs/by-run-id/orch-run-1" + )); + assert!(!is_interpretation_run_lookup_path( + "/v1/interpretation-runs/orch-run-1" + )); + assert!(!is_interpretation_run_lookup_path( + "/v1/interpretation-runs/by-run-id/orch-run-1/request" + )); + assert_eq!( + interpretation_run_lookup_path_id("/v1/interpretation-runs/by-run-id/orch-run-1") + .expect("id"), + "orch-run-1" + ); + assert_eq!(INTERPRETATION_RUN_LOOKUP_PREFIX, "by-run-id"); + } + + #[test] + fn lookup_path_and_origins_fail_closed() { + assert_eq!( + interpretation_run_lookup_path_id("/v1/interpretation-runs"), + Err(OrchestratorLiveError::InvalidWirePayload) + ); + assert_eq!( + interpretation_run_lookup_path_id("/v1/interpretation-runs/by-run-id"), + Err(OrchestratorLiveError::InvalidWirePayload) + ); + assert_eq!( + interpretation_run_lookup_path_id("/v1/interpretation-runs/by-run-id/"), + Err(OrchestratorLiveError::InvalidWirePayload) + ); + assert_eq!( + interpretation_run_lookup_path_id("/v1/interpretation-runs/orch-run-1"), + Err(OrchestratorLiveError::InvalidWirePayload) + ); + assert_eq!( + interpretation_run_lookup_path_id("/v1/interpretation-runs/by-run-id/orch-run-1/extra"), + Err(OrchestratorLiveError::InvalidWirePayload) + ); + assert_eq!( + interpretation_run_lookup_path_id( + "/v1/interpretation-runs/by-run-id/orch-run-1/request" + ), + Err(OrchestratorLiveError::InvalidWirePayload) + ); + assert_eq!( + interpretation_run_lookup_path_id("/v1/interpretation-runs/by-run-id/by-run-id"), + Err(OrchestratorLiveError::InvalidWirePayload) + ); + assert_eq!( + interpretation_run_lookup_path_id("/v1/interpretation-runs/by-run-id/orch%2Fslash"), + Err(OrchestratorLiveError::InvalidWirePayload) + ); + assert_eq!( + interpretation_run_lookup_path_id("/v1/interpretation-runs/by-run-id/%00"), + Err(OrchestratorLiveError::InvalidWirePayload) + ); + assert_eq!( + interpretation_run_lookup_path_id(&format!( + "/v1/interpretation-runs/by-run-id/{}", + "a".repeat(INTERPRETATION_RUN_LOOKUP_ID_MAX_LEN + 1) + )), + Err(OrchestratorLiveError::LimitExceeded) + ); + assert_eq!( + contextual_orchestrator_interpretation_run_lookup_exchange( + "http://insecure.example", + "orch-run-1", + ), + Err(OrchestratorLiveError::InvalidWirePayload) + ); + assert_eq!( + contextual_orchestrator_interpretation_run_lookup_exchange( + "https://postgres.example.test", + "orch-run-1", + ), + Err(OrchestratorLiveError::InvalidWirePayload) + ); + assert_eq!( + contextual_orchestrator_interpretation_run_lookup_exchange( + "https://tepp.example.test", + "by-run-id", + ), + Err(OrchestratorLiveError::InvalidWirePayload) + ); + assert_eq!( + contextual_orchestrator_interpretation_run_lookup_exchange( + "https://tepp.example.test", + "orch/slash", + ), + Err(OrchestratorLiveError::InvalidWirePayload) + ); + assert_eq!( + contextual_orchestrator_interpretation_run_lookup_exchange( + "https://tepp.example.test", + "", + ), + Err(OrchestratorLiveError::InvalidWirePayload) + ); + } +} diff --git a/crates/orchestrator_live/src/interpretation_run_lookup_stored_request_cli.rs b/crates/orchestrator_live/src/interpretation_run_lookup_stored_request_cli.rs new file mode 100644 index 000000000..8c01a6049 --- /dev/null +++ b/crates/orchestrator_live/src/interpretation_run_lookup_stored_request_cli.rs @@ -0,0 +1,695 @@ +//! Operator loopback CLI for contextual-orchestrator lookup stored-request GET. +//! +//! GAP-003A unique slice: operators run +//! `tepp-interpretation-run-lookup-request get` to mint +//! `contextual_orchestrator_interpretation_run_lookup_stored_request_exchange` +//! onto spawned `tepp-orchestrator-loopback` TCP. Stdout is the stored create +//! request with `scientific_authority=false`. `tepp.scientific_acceptance.v1` +//! never appears. The CLI does not infer causality or call a model provider. +//! Naruon and `LineageWeave` are refused. `NaruonLiveService` stays POST-only. +//! This module does not duplicate lookup stored-request GET (#469), lookup +//! GET/CLI (#467/#468), stored-request GET/CLI (#453/#454), GET-by-id (#438), +//! retrieval CLI (#439), collection GET/CLI (#433/#436), create CLI (#425), +//! export lookup (#466), analysis-run lookup (#380/#401), or cancel lineages +//! (closed). Persistence remains GAP-003B. + +use std::fmt::Write as _; +use std::io::{Read, Write}; +use std::net::{SocketAddr, TcpStream}; +use std::time::Duration; + +use crate::http::{header_is_credential, map_io_error}; +use crate::interpretation_run_cli::CONTEXTUAL_ORCHESTRATOR_CONSUMER_CODE; +use crate::interpretation_run_lookup_http::INTERPRETATION_RUN_LOOKUP_PREFIX; +use crate::interpretation_run_lookup_stored_request_http::{ + INTERPRETATION_RUN_LOOKUP_STORED_REQUEST_RESPONSE_BYTE_LIMIT, + InterpretationRunLookupStoredRequestHttpExchange, InterpretationRunLookupStoredRequestPayload, + contextual_orchestrator_interpretation_run_lookup_stored_request_exchange, + interpretation_run_lookup_stored_request_path_id, +}; +use crate::interpretation_run_stored_request_http::refuse_metrics_on_interpretation_run_stored_request_payload; +use crate::request::{DEFAULT_INTERPRETATION_BYTE_LIMIT, require_nonempty}; +use crate::{ + LIVE_HEADER_BYTE_LIMIT, LIVE_HEADER_COUNT_LIMIT, OrchestratorLiveError, + OrchestratorLiveResponse, OrchestratorLiveService, +}; + +const SCIENTIFIC_ACCEPTANCE_SCHEMA: &str = "tepp.scientific_acceptance.v1"; +const CLI_IO_TIMEOUT: Duration = Duration::from_secs(2); +const MAXIMUM_HTTP_RESPONSE_BYTES: usize = + LIVE_HEADER_BYTE_LIMIT + 4 + INTERPRETATION_RUN_LOOKUP_STORED_REQUEST_RESPONSE_BYTE_LIMIT; + +/// Supported operator verbs for lookup stored-request GET. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum InterpretationRunLookupStoredRequestCliVerb { + /// `GET /v1/interpretation-runs/by-run-id/{interpretation_run_id}/request`. + Get, +} + +impl InterpretationRunLookupStoredRequestCliVerb { + /// Parse one exact lowercase verb token. + /// + /// # Errors + /// + /// Returns [`OrchestratorLiveError::InvalidWirePayload`] for an unknown token. + pub fn parse(token: &str) -> Result { + match token { + "get" => Ok(Self::Get), + _ => Err(OrchestratorLiveError::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 lookup stored-request listener. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct InterpretationRunLookupStoredRequestCliInvocation { + /// CLI verb to execute. + pub verb: InterpretationRunLookupStoredRequestCliVerb, + /// Loopback `host:port` of `tepp-orchestrator-loopback`. + pub host: String, + /// Published HTTPS origin used to mint the typed exchange. + pub origin: String, + /// Published modular consumer. Lookup stored-request GET admits + /// `contextual-orchestrator` only. + pub consumer: String, + /// Server-assigned opaque interpretation-run identity. + pub interpretation_run_id: String, + /// JSON body. GET requires empty. + pub body: String, +} + +impl InterpretationRunLookupStoredRequestCliInvocation { + /// Parse argv plus stdin body into a validated loopback invocation. + /// + /// Empty stdin is admitted. Nonempty leftover stdin fails closed. + /// + /// # Errors + /// + /// Returns a fail-closed error for unknown verbs, missing required flags, a + /// non-loopback host, a non-`https` origin, an unpublished consumer, + /// credential-shaped flags, a hostile identity, 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(OrchestratorLiveError::InvalidWirePayload)?; + let verb = InterpretationRunLookupStoredRequestCliVerb::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 [`OrchestratorLiveError::AuthorizationDenied`] for a non-loopback + /// host and [`OrchestratorLiveError::InvalidWirePayload`] or + /// [`OrchestratorLiveError::LimitExceeded`] for empty, unpublished, + /// nonempty-body, reserved-prefix, or oversized fields. + pub fn validate(&self) -> Result<(), OrchestratorLiveError> { + require_loopback_host(&self.host)?; + require_nonempty(&self.origin)?; + if !self.origin.starts_with("https://") { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + require_nonempty(&self.consumer)?; + if self.consumer != CONTEXTUAL_ORCHESTRATOR_CONSUMER_CODE { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + require_nonempty(&self.interpretation_run_id)?; + if self.interpretation_run_id == INTERPRETATION_RUN_LOOKUP_PREFIX { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + if self.interpretation_run_id.contains('/') || self.interpretation_run_id.contains('\0') { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + if !self.body.is_empty() { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + refuse_scientific_acceptance(&self.body)?; + refuse_metrics_on_interpretation_run_stored_request_payload(&self.body)?; + Ok(()) + } +} + +struct ParsedFlags { + host: Option, + origin: Option, + consumer: Option, + interpretation_run_id: Option, +} + +fn parse_flags(rest: &[String]) -> Result { + let mut flags = ParsedFlags { + host: None, + origin: None, + consumer: None, + interpretation_run_id: None, + }; + let mut index = 0; + while index < rest.len() { + let flag = rest[index].as_str(); + if !flag.starts_with("--") { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + let name = &flag[2..]; + if header_is_credential(name) { + return Err(OrchestratorLiveError::AuthorizationDenied); + } + let slot = match name { + "host" => &mut flags.host, + "origin" => &mut flags.origin, + "consumer" => &mut flags.consumer, + "interpretation-run-id" => &mut flags.interpretation_run_id, + _ => return Err(OrchestratorLiveError::InvalidWirePayload), + }; + if slot.is_some() || index + 1 >= rest.len() { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + let value = rest[index + 1].as_str(); + require_nonempty(value)?; + *slot = Some(value.to_owned()); + index += 2; + } + Ok(flags) +} + +fn assemble_invocation( + verb: InterpretationRunLookupStoredRequestCliVerb, + flags: ParsedFlags, + body: String, +) -> Result { + let invocation = InterpretationRunLookupStoredRequestCliInvocation { + verb, + host: flags + .host + .ok_or(OrchestratorLiveError::InvalidWirePayload)?, + origin: flags + .origin + .ok_or(OrchestratorLiveError::InvalidWirePayload)?, + consumer: flags + .consumer + .unwrap_or_else(|| CONTEXTUAL_ORCHESTRATOR_CONSUMER_CODE.to_owned()), + interpretation_run_id: flags + .interpretation_run_id + .ok_or(OrchestratorLiveError::InvalidWirePayload)?, + body, + }; + invocation.validate()?; + Ok(invocation) +} + +fn require_loopback_host(host: &str) -> Result { + let addr: SocketAddr = host + .parse() + .map_err(|_| OrchestratorLiveError::InvalidWirePayload)?; + if addr.ip().is_loopback() { + Ok(addr) + } else { + Err(OrchestratorLiveError::AuthorizationDenied) + } +} + +/// Render a typed lookup stored-request GET as HTTP/1.1 for a loopback listener. +/// +/// # Errors +/// +/// Returns [`OrchestratorLiveError::AuthorizationDenied`] for a non-loopback +/// host or a credential-bearing header, and +/// [`OrchestratorLiveError::InvalidWirePayload`] when the exchange is not a +/// GET `/v1/interpretation-runs/by-run-id/{id}/request` with an empty body. +pub fn loopback_http1_from_interpretation_run_lookup_stored_request_exchange( + exchange: &InterpretationRunLookupStoredRequestHttpExchange, + loopback_host: &str, +) -> Result { + let _addr = require_loopback_host(loopback_host)?; + let host = loopback_host.trim(); + if exchange.method != "GET" { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + if !exchange.body.is_empty() { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + let rest = exchange + .target_url + .strip_prefix("https://") + .ok_or(OrchestratorLiveError::InvalidWirePayload)?; + let path = rest + .find('/') + .map(|index| &rest[index..]) + .ok_or(OrchestratorLiveError::InvalidWirePayload)?; + let _interpretation_run_id = interpretation_run_lookup_stored_request_path_id(path)?; + for (name, _) in &exchange.headers { + if header_is_credential(name) { + return Err(OrchestratorLiveError::AuthorizationDenied); + } + if name.eq_ignore_ascii_case("idempotency-key") + || name.eq_ignore_ascii_case("tepp-page-limit") + || name.eq_ignore_ascii_case("tepp-page-cursor") + { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + } + let mut request = String::new(); + write!( + request, + "{} {path} HTTP/1.1\r\nHost: {host}\r\n", + exchange.method + ) + .map_err(|_| OrchestratorLiveError::InvalidWirePayload)?; + for (name, value) in &exchange.headers { + if name.eq_ignore_ascii_case("host") || name.eq_ignore_ascii_case("content-length") { + continue; + } + write!(request, "{name}: {value}\r\n") + .map_err(|_| OrchestratorLiveError::InvalidWirePayload)?; + } + write!(request, "content-length: 0\r\n\r\n") + .map_err(|_| OrchestratorLiveError::InvalidWirePayload)?; + Ok(request) +} + +/// Compose one HTTP/1.1 lookup stored-request GET from the typed exchange. +/// +/// # Errors +/// +/// Returns the same fail-closed errors as +/// [`InterpretationRunLookupStoredRequestCliInvocation::validate`]. +pub fn compose_interpretation_run_lookup_stored_request_cli_http( + invocation: &InterpretationRunLookupStoredRequestCliInvocation, +) -> Result { + invocation.validate()?; + let exchange = contextual_orchestrator_interpretation_run_lookup_stored_request_exchange( + &invocation.origin, + &invocation.interpretation_run_id, + )?; + loopback_http1_from_interpretation_run_lookup_stored_request_exchange( + &exchange, + &invocation.host, + ) +} + +/// Dispatch one lookup stored-request CLI invocation against an in-process listener. +/// +/// # Errors +/// +/// Returns fail-closed validation errors before the HTTP handler runs. +pub fn dispatch_interpretation_run_lookup_stored_request_cli( + service: &mut OrchestratorLiveService, + invocation: &InterpretationRunLookupStoredRequestCliInvocation, +) -> Result { + let request = compose_interpretation_run_lookup_stored_request_cli_http(invocation)?; + Ok(service.handle_http_request(&request)) +} + +/// Execute one lookup stored-request CLI invocation over loopback TCP. +/// +/// # Errors +/// +/// Returns fail-closed validation, transport, or response-framing errors. +pub fn execute_interpretation_run_lookup_stored_request_cli( + invocation: &InterpretationRunLookupStoredRequestCliInvocation, +) -> Result { + let addr = require_loopback_host(&invocation.host)?; + let request = compose_interpretation_run_lookup_stored_request_cli_http(invocation)?; + let mut stream = TcpStream::connect(addr).map_err(|error| map_io_error(&error))?; + stream + .set_read_timeout(Some(CLI_IO_TIMEOUT)) + .map_err(|error| map_io_error(&error))?; + stream + .set_write_timeout(Some(CLI_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 stored-request GET never prints scientific acceptance. +/// +/// The run-bound response envelope must identify exactly the run requested by +/// the invocation. Stdout strips that transport binding and prints only the +/// validated stored create request. +/// +/// # Errors +/// +/// Returns [`OrchestratorLiveError::InvalidWirePayload`] when a response is +/// bound to a different run, carries metric/evidence/causal keys, or includes +/// `tepp.scientific_acceptance.v1`. +pub fn render_interpretation_run_lookup_stored_request_cli_stdout( + invocation: &InterpretationRunLookupStoredRequestCliInvocation, + response: &OrchestratorLiveResponse, +) -> Result { + invocation.validate()?; + if response.body.is_empty() { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + refuse_scientific_acceptance(&response.body)?; + refuse_metrics_on_interpretation_run_stored_request_payload(&response.body)?; + if response.status_code != 200 { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + let payload = InterpretationRunLookupStoredRequestPayload::from_json(&response.body)?; + if payload.interpretation_run_id() != invocation.interpretation_run_id { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + payload.request().to_json() +} + +fn refuse_scientific_acceptance(body: &str) -> Result<(), OrchestratorLiveError> { + if body.contains(SCIENTIFIC_ACCEPTANCE_SCHEMA) { + Err(OrchestratorLiveError::InvalidWirePayload) + } else { + Ok(()) + } +} + +fn parse_http_response(bytes: &[u8]) -> Result { + let text = std::str::from_utf8(bytes).map_err(|_| OrchestratorLiveError::InvalidWirePayload)?; + let (header_block, body) = text + .split_once("\r\n\r\n") + .ok_or(OrchestratorLiveError::InvalidWirePayload)?; + if header_block.len() > LIVE_HEADER_BYTE_LIMIT { + return Err(OrchestratorLiveError::LimitExceeded); + } + let mut lines = header_block.split("\r\n"); + let status_line = lines + .next() + .ok_or(OrchestratorLiveError::InvalidWirePayload)?; + let mut parts = status_line.split(' '); + if parts.next() != Some("HTTP/1.1") { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + let code = parts + .next() + .ok_or(OrchestratorLiveError::InvalidWirePayload)? + .parse::() + .map_err(|_| OrchestratorLiveError::InvalidWirePayload)?; + let reason_phrase = static_reason(code)?; + let mut content_length = None; + for (index, line) in lines.enumerate() { + if index >= LIVE_HEADER_COUNT_LIMIT { + return Err(OrchestratorLiveError::LimitExceeded); + } + let (name, value) = line + .split_once(':') + .ok_or(OrchestratorLiveError::InvalidWirePayload)?; + if name.eq_ignore_ascii_case("content-length") { + if content_length.is_some() { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + content_length = Some( + value + .trim() + .parse::() + .map_err(|_| OrchestratorLiveError::InvalidWirePayload)?, + ); + } + } + let declared = content_length.ok_or(OrchestratorLiveError::InvalidWirePayload)?; + if declared > INTERPRETATION_RUN_LOOKUP_STORED_REQUEST_RESPONSE_BYTE_LIMIT { + return Err(OrchestratorLiveError::LimitExceeded); + } + if declared != body.len() { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + Ok(OrchestratorLiveResponse { + status_code: code, + reason_phrase, + body: body.to_owned(), + }) +} + +fn static_reason(code: u16) -> Result<&'static str, OrchestratorLiveError> { + 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(OrchestratorLiveError::InvalidWirePayload), + } +} + +/// Read stdin leftover bytes on a non-terminal; GET admits empty. +/// +/// # Errors +/// +/// Returns [`OrchestratorLiveError::InvalidWirePayload`] when stdin cannot be +/// read and [`OrchestratorLiveError::LimitExceeded`] when leftover stdin +/// exceeds the interpretation-run wire limit. +pub fn read_interpretation_run_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_INTERPRETATION_BYTE_LIMIT)?; + String::from_utf8(bytes).map_err(|_| OrchestratorLiveError::InvalidWirePayload) + } +} + +fn read_bounded( + reader: &mut impl Read, + maximum_bytes: usize, +) -> Result, OrchestratorLiveError> { + 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(OrchestratorLiveError::LimitExceeded); + } + Ok(bytes) +} + +#[cfg(test)] +mod tests { + use super::{ + InterpretationRunLookupStoredRequestCliInvocation, + InterpretationRunLookupStoredRequestCliVerb, + compose_interpretation_run_lookup_stored_request_cli_http, + loopback_http1_from_interpretation_run_lookup_stored_request_exchange, + read_interpretation_run_lookup_stored_request_cli_stdin, + }; + use crate::interpretation_run_cli::CONTEXTUAL_ORCHESTRATOR_CONSUMER_CODE; + use crate::{ + OrchestratorLiveError, + contextual_orchestrator_interpretation_run_lookup_stored_request_exchange, + }; + + const ORIGIN: &str = "https://tepp.example.test"; + + fn get_args() -> [&'static str; 9] { + [ + "get", + "--host", + "127.0.0.1:18082", + "--origin", + ORIGIN, + "--consumer", + CONTEXTUAL_ORCHESTRATOR_CONSUMER_CODE, + "--interpretation-run-id", + "orch-run-1", + ] + } + + #[test] + fn from_args_mints_get_and_refuses_fail_closed_inputs() { + assert_eq!( + InterpretationRunLookupStoredRequestCliVerb::parse("get").expect("get"), + InterpretationRunLookupStoredRequestCliVerb::Get + ); + assert_eq!( + InterpretationRunLookupStoredRequestCliVerb::Get.as_str(), + "get" + ); + assert_eq!( + InterpretationRunLookupStoredRequestCliVerb::parse("lookup"), + Err(OrchestratorLiveError::InvalidWirePayload) + ); + let get = InterpretationRunLookupStoredRequestCliInvocation::from_args(get_args(), "") + .expect("get"); + assert_eq!(get.verb, InterpretationRunLookupStoredRequestCliVerb::Get); + let http = compose_interpretation_run_lookup_stored_request_cli_http(&get).expect("http"); + assert!( + http.starts_with("GET /v1/interpretation-runs/by-run-id/orch-run-1/request HTTP/1.1") + ); + assert!(http.contains("tepp-consumer: contextual-orchestrator")); + assert!(http.contains("content-length: 0")); + assert!(!http.contains("idempotency-key:")); + assert!(!http.contains("authorization")); + assert_eq!( + InterpretationRunLookupStoredRequestCliInvocation::from_args( + [ + "get", + "--host", + "8.8.8.8:80", + "--origin", + ORIGIN, + "--interpretation-run-id", + "orch-run-1" + ], + "" + ) + .unwrap_err(), + OrchestratorLiveError::AuthorizationDenied + ); + assert_eq!( + InterpretationRunLookupStoredRequestCliInvocation::from_args( + [ + "get", + "--host", + "localhost:18082", + "--origin", + ORIGIN, + "--interpretation-run-id", + "orch-run-1" + ], + "" + ) + .unwrap_err(), + OrchestratorLiveError::InvalidWirePayload + ); + assert_eq!( + InterpretationRunLookupStoredRequestCliInvocation::from_args( + [ + "get", + "--host", + "127.0.0.1:18082", + "--origin", + "http://tepp.example.test", + "--interpretation-run-id", + "orch-run-1" + ], + "" + ) + .unwrap_err(), + OrchestratorLiveError::InvalidWirePayload + ); + assert_eq!( + InterpretationRunLookupStoredRequestCliInvocation::from_args( + [ + "get", + "--host", + "127.0.0.1:18082", + "--origin", + ORIGIN, + "--interpretation-run-id", + "orch-run-1", + "--authorization", + "secret" + ], + "" + ) + .unwrap_err(), + OrchestratorLiveError::AuthorizationDenied + ); + } + + #[test] + fn from_args_refuses_unpublished_body_and_reserved_prefix() { + assert_eq!( + InterpretationRunLookupStoredRequestCliInvocation::from_args( + [ + "get", + "--host", + "127.0.0.1:18082", + "--origin", + ORIGIN, + "--consumer", + "naruon", + "--interpretation-run-id", + "orch-run-1" + ], + "" + ) + .unwrap_err(), + OrchestratorLiveError::InvalidWirePayload + ); + assert_eq!( + InterpretationRunLookupStoredRequestCliInvocation::from_args( + [ + "get", + "--host", + "127.0.0.1:18082", + "--origin", + ORIGIN, + "--consumer", + "lineageweave", + "--interpretation-run-id", + "orch-run-1" + ], + "" + ) + .unwrap_err(), + OrchestratorLiveError::InvalidWirePayload + ); + assert_eq!( + InterpretationRunLookupStoredRequestCliInvocation::from_args(get_args(), "{}") + .unwrap_err(), + OrchestratorLiveError::InvalidWirePayload + ); + assert_eq!( + InterpretationRunLookupStoredRequestCliInvocation::from_args( + [ + "get", + "--host", + "127.0.0.1:18082", + "--origin", + ORIGIN, + "--interpretation-run-id", + "by-run-id" + ], + "" + ) + .unwrap_err(), + OrchestratorLiveError::InvalidWirePayload + ); + let leftover = read_interpretation_run_lookup_stored_request_cli_stdin( + false, + std::io::Cursor::new(b"leftover"), + ) + .expect("leftover"); + assert_eq!(leftover, "leftover"); + assert!( + read_interpretation_run_lookup_stored_request_cli_stdin(true, std::io::empty()) + .expect("tty") + .is_empty() + ); + let exchange = contextual_orchestrator_interpretation_run_lookup_stored_request_exchange( + ORIGIN, + "orch-run-1", + ) + .expect("exchange"); + let mut posted = exchange.clone(); + posted.method = "POST"; + assert_eq!( + loopback_http1_from_interpretation_run_lookup_stored_request_exchange( + &posted, + "127.0.0.1:18082" + ), + Err(OrchestratorLiveError::InvalidWirePayload) + ); + } +} diff --git a/crates/orchestrator_live/src/interpretation_run_lookup_stored_request_http.rs b/crates/orchestrator_live/src/interpretation_run_lookup_stored_request_http.rs new file mode 100644 index 000000000..12c2e1818 --- /dev/null +++ b/crates/orchestrator_live/src/interpretation_run_lookup_stored_request_http.rs @@ -0,0 +1,462 @@ +//! Provider-owned interpretation-run lookup stored-request GET contracts. +//! +//! GAP-003A unique slice: `GET /v1/interpretation-runs/by-run-id/{interpretation_run_id}/request` +//! returns the stored create request of the unique accepted hypothetical run +//! that used that server-assigned `interpretation_run_id` on +//! `OrchestratorLiveService` / `tepp-orchestrator-loopback`. Stored-request +//! GET requires the client `idempotency_key`. Lookup GET returns identity +//! only. Operators who hold a 202 receipt or log `orch-run-N` cannot fetch +//! the stored create without a second hop through lookup then +//! `{idempotency_key}/request`. Rows stay `scientific_authority=false`. +//! `tepp.scientific_acceptance.v1` never appears. The lookup does not infer +//! causality or call a model provider. This module does not duplicate lookup +//! GET/CLI (#467/#468), stored-request GET/CLI (#453/#454), GET-by-id (#438), +//! retrieval CLI (#439), collection GET/CLI (#433/#436), create CLI (#425), +//! export lookup (#466), analysis-run lookup (#380/#401), or cancel lineages +//! (closed). Persistence remains GAP-003B. Naruon and `LineageWeave` are +//! refused. `NaruonLiveService` stays POST-only. + +use serde::{Deserialize, Serialize}; + +use crate::error::OrchestratorLiveError; +use crate::interpretation_run_cli::CONTEXTUAL_ORCHESTRATOR_CONSUMER_CODE; +use crate::interpretation_run_lookup_http::{ + INTERPRETATION_RUN_LOOKUP_ID_MAX_LEN, INTERPRETATION_RUN_LOOKUP_PREFIX, +}; +use crate::request::{ + DEFAULT_INTERPRETATION_BYTE_LIMIT, INTERPRETATION_RUN_PATH, InterpretationRunRequest, + from_json, host_implies_table_access, require_byte_limit, require_nonempty, to_json, +}; + +/// Extra-segment that names the stored create request. +pub const INTERPRETATION_RUN_LOOKUP_STORED_REQUEST_SEGMENT: &str = "request"; + +/// Maximum JSON bytes for the run-bound stored-request response envelope. +pub const INTERPRETATION_RUN_LOOKUP_STORED_REQUEST_RESPONSE_BYTE_LIMIT: usize = + DEFAULT_INTERPRETATION_BYTE_LIMIT + INTERPRETATION_RUN_LOOKUP_ID_MAX_LEN + 128; + +/// Typed GET exchange for stored-request lookup by server-assigned id. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct InterpretationRunLookupStoredRequestHttpExchange { + /// HTTP method, always `GET`. + pub method: &'static str, + /// Absolute HTTPS target ending in + /// `/v1/interpretation-runs/by-run-id/{id}/request`. + pub target_url: String, + /// Exact version, consumer, and content headers. No credentials. + pub headers: Vec<(String, String)>, + /// GET body, always empty. + pub body: String, +} + +/// Run-bound response envelope for stored-request lookup by server-assigned id. +/// +/// Carrying the requested run identity in the response prevents a valid stored +/// request for a different run from being accepted as the result of this lookup. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct InterpretationRunLookupStoredRequestPayload { + interpretation_run_id: String, + request: InterpretationRunRequest, +} + +impl InterpretationRunLookupStoredRequestPayload { + /// Bind one validated stored request to its server-assigned run identity. + /// + /// # Errors + /// + /// Returns a fail-closed identity or request validation error. + pub fn new( + interpretation_run_id: impl Into, + request: InterpretationRunRequest, + ) -> Result { + let payload = Self { + interpretation_run_id: interpretation_run_id.into(), + request, + }; + payload.validate()?; + Ok(payload) + } + + /// Parse and validate a run-bound stored-request response. + /// + /// # Errors + /// + /// Returns a limit, wire, identity, or nested-request validation error. + pub fn from_json(payload: &str) -> Result { + require_byte_limit( + payload, + INTERPRETATION_RUN_LOOKUP_STORED_REQUEST_RESPONSE_BYTE_LIMIT, + )?; + let payload: Self = from_json(payload)?; + payload.validate()?; + Ok(payload) + } + + /// Serialize the validated response envelope. + /// + /// # Errors + /// + /// Returns a validation or serialization error. + pub fn to_json(&self) -> Result { + self.validate()?; + to_json(self) + } + + /// Server-assigned run identity that owns the returned stored request. + #[must_use] + pub fn interpretation_run_id(&self) -> &str { + &self.interpretation_run_id + } + + /// Stored create request bound to [`Self::interpretation_run_id`]. + #[must_use] + pub const fn request(&self) -> &InterpretationRunRequest { + &self.request + } + + fn validate(&self) -> Result<(), OrchestratorLiveError> { + validate_lookup_identity(&self.interpretation_run_id)?; + self.request.to_json()?; + Ok(()) + } +} + +/// Extract the opaque `interpretation_run_id` from +/// `GET /v1/interpretation-runs/by-run-id/{interpretation_run_id}/request`. +/// +/// The route is segmented before percent decoding, so an encoded `/` remains +/// data inside one opaque identity rather than becoming an extra path segment. +/// +/// # Errors +/// +/// Returns [`OrchestratorLiveError::InvalidWirePayload`] for collection, +/// GET-by-id, lookup without `/request`, stored-request `{key}/request`, extra +/// raw segments, a missing `by-run-id` prefix, reserved prefix used as the +/// id, slash, NUL, empty identity, or a hostile encoding, and +/// [`OrchestratorLiveError::LimitExceeded`] when oversized. +pub fn interpretation_run_lookup_stored_request_path_id( + path: &str, +) -> Result { + let remainder = path + .strip_prefix(INTERPRETATION_RUN_PATH) + .ok_or(OrchestratorLiveError::InvalidWirePayload)?; + let encoded = remainder + .strip_prefix('/') + .ok_or(OrchestratorLiveError::InvalidWirePayload)?; + let encoded = encoded + .strip_prefix(INTERPRETATION_RUN_LOOKUP_PREFIX) + .ok_or(OrchestratorLiveError::InvalidWirePayload)?; + let encoded = encoded + .strip_prefix('/') + .ok_or(OrchestratorLiveError::InvalidWirePayload)?; + let (encoded_id, rest) = encoded + .split_once('/') + .ok_or(OrchestratorLiveError::InvalidWirePayload)?; + if rest != INTERPRETATION_RUN_LOOKUP_STORED_REQUEST_SEGMENT || encoded_id.is_empty() { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + let interpretation_run_id = decode_path_segment(encoded_id)?; + validate_lookup_identity(&interpretation_run_id)?; + Ok(interpretation_run_id) +} + +/// Whether `path` structurally targets the lookup stored-request resource. +/// +/// Oversized identities still belong to this route so the handler can preserve +/// [`OrchestratorLiveError::LimitExceeded`] and map it to HTTP 413 instead of +/// falling through to an unrelated 400 route. Other invalid shapes remain +/// non-matches. +#[must_use] +pub fn is_interpretation_run_lookup_stored_request_path(path: &str) -> bool { + matches!( + interpretation_run_lookup_stored_request_path_id(path), + Ok(_) | Err(OrchestratorLiveError::LimitExceeded) + ) +} + +/// Build a credential-free contextual-orchestrator lookup stored-request GET. +/// +/// The builder refuses non-`https` origins and empty or oversized identities. +/// It does not inject credentials. The GET body is empty. The opaque id is +/// percent-encoded into exactly one path segment after `by-run-id` and before +/// `/request`. +/// +/// # Errors +/// +/// Returns a fail-closed origin or identity error. +pub fn contextual_orchestrator_interpretation_run_lookup_stored_request_exchange( + origin: &str, + interpretation_run_id: &str, +) -> Result { + require_nonempty(origin)?; + if !origin.starts_with("https://") || origin.ends_with('/') { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + let rest = origin + .strip_prefix("https://") + .ok_or(OrchestratorLiveError::InvalidWirePayload)?; + if rest.contains('@') || rest.contains('?') || rest.contains('#') || rest.contains('\\') { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + if host_implies_table_access(rest) { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + validate_lookup_identity(interpretation_run_id)?; + let encoded_id = encode_path_segment(interpretation_run_id); + Ok(InterpretationRunLookupStoredRequestHttpExchange { + method: "GET", + target_url: format!( + "{origin}{INTERPRETATION_RUN_PATH}/{INTERPRETATION_RUN_LOOKUP_PREFIX}/{encoded_id}/{INTERPRETATION_RUN_LOOKUP_STORED_REQUEST_SEGMENT}" + ), + headers: vec![ + ("content-type".into(), "application/json".into()), + ( + "tepp-consumer".into(), + CONTEXTUAL_ORCHESTRATOR_CONSUMER_CODE.into(), + ), + ("tepp-contract-version".into(), "1".into()), + ], + body: String::new(), + }) +} + +fn validate_lookup_identity(interpretation_run_id: &str) -> Result<(), OrchestratorLiveError> { + require_nonempty(interpretation_run_id)?; + if interpretation_run_id == INTERPRETATION_RUN_LOOKUP_PREFIX { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + if interpretation_run_id.contains('/') || interpretation_run_id.contains('\0') { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + if interpretation_run_id.len() > INTERPRETATION_RUN_LOOKUP_ID_MAX_LEN { + return Err(OrchestratorLiveError::LimitExceeded); + } + Ok(()) +} + +fn encode_path_segment(value: &str) -> String { + let mut out = String::with_capacity(value.len()); + 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); + } + _ => { + let hex = b"0123456789ABCDEF"; + 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(OrchestratorLiveError::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(OrchestratorLiveError::InvalidWirePayload), + } + } + let decoded = String::from_utf8(out).map_err(|_| OrchestratorLiveError::InvalidWirePayload)?; + if decoded.chars().any(char::is_control) { + return Err(OrchestratorLiveError::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(OrchestratorLiveError::InvalidWirePayload), + } +} + +#[cfg(test)] +mod tests { + use super::{ + INTERPRETATION_RUN_LOOKUP_STORED_REQUEST_SEGMENT, + InterpretationRunLookupStoredRequestPayload, + contextual_orchestrator_interpretation_run_lookup_stored_request_exchange, + interpretation_run_lookup_stored_request_path_id, + is_interpretation_run_lookup_stored_request_path, + }; + use crate::error::OrchestratorLiveError; + use crate::interpretation_run_lookup_http::{ + INTERPRETATION_RUN_LOOKUP_ID_MAX_LEN, INTERPRETATION_RUN_LOOKUP_PREFIX, + is_interpretation_run_lookup_path, + }; + use crate::interpretation_run_stored_request_http::is_interpretation_run_stored_request_path; + use crate::mode::OrchestrationMode; + use crate::request::{INTERPRETATION_RUN_CONTRACT_VERSION, InterpretationRunRequest}; + + fn sample_request() -> InterpretationRunRequest { + InterpretationRunRequest::new( + INTERPRETATION_RUN_CONTRACT_VERSION, + "idem-a", + "tenant-a", + "snapshot-a", + "2026-09-01T00:00:00Z", + OrchestrationMode::Direct, + 128, + vec!["span-a".into()], + false, + ) + .expect("request") + } + + #[test] + fn lookup_stored_request_exchange_is_metric_free_get_without_credentials() { + let exchange = contextual_orchestrator_interpretation_run_lookup_stored_request_exchange( + "https://tepp.example.test", + "orch-run-1", + ) + .expect("exchange"); + assert_eq!(exchange.method, "GET"); + assert!( + exchange + .target_url + .ends_with("/v1/interpretation-runs/by-run-id/orch-run-1/request") + ); + assert!(exchange.body.is_empty()); + assert!( + !exchange + .headers + .iter() + .any(|(name, _)| name.eq_ignore_ascii_case("authorization") + || name.eq_ignore_ascii_case("idempotency-key")) + ); + assert!(is_interpretation_run_lookup_stored_request_path( + "/v1/interpretation-runs/by-run-id/orch-run-1/request" + )); + assert!(!is_interpretation_run_lookup_path( + "/v1/interpretation-runs/by-run-id/orch-run-1/request" + )); + assert!(!is_interpretation_run_stored_request_path( + "/v1/interpretation-runs/by-run-id/orch-run-1/request" + )); + assert_eq!( + interpretation_run_lookup_stored_request_path_id( + "/v1/interpretation-runs/by-run-id/orch-run-1/request" + ) + .expect("id"), + "orch-run-1" + ); + assert_eq!(INTERPRETATION_RUN_LOOKUP_STORED_REQUEST_SEGMENT, "request"); + assert_eq!(INTERPRETATION_RUN_LOOKUP_PREFIX, "by-run-id"); + } + + #[test] + fn lookup_stored_request_payload_binds_run_to_request() { + let payload = + InterpretationRunLookupStoredRequestPayload::new("orch-run-1", sample_request()) + .expect("payload"); + let json = payload.to_json().expect("json"); + let parsed = InterpretationRunLookupStoredRequestPayload::from_json(&json).expect("parse"); + assert_eq!(parsed.interpretation_run_id(), "orch-run-1"); + assert_eq!(parsed.request().idempotency_key(), "idem-a"); + } + + #[test] + fn lookup_stored_request_path_and_origins_fail_closed() { + assert_eq!( + interpretation_run_lookup_stored_request_path_id( + "/v1/interpretation-runs/by-run-id/orch-run-1" + ), + Err(OrchestratorLiveError::InvalidWirePayload) + ); + assert_eq!( + interpretation_run_lookup_stored_request_path_id( + "/v1/interpretation-runs/orch-run-1/request" + ), + Err(OrchestratorLiveError::InvalidWirePayload) + ); + assert_eq!( + interpretation_run_lookup_stored_request_path_id( + "/v1/interpretation-runs/by-run-id/request" + ), + Err(OrchestratorLiveError::InvalidWirePayload) + ); + assert_eq!( + interpretation_run_lookup_stored_request_path_id( + "/v1/interpretation-runs/by-run-id/orch-run-1/request/extra" + ), + Err(OrchestratorLiveError::InvalidWirePayload) + ); + assert_eq!( + interpretation_run_lookup_stored_request_path_id( + "/v1/interpretation-runs/by-run-id/orch-run-1/cancel" + ), + Err(OrchestratorLiveError::InvalidWirePayload) + ); + assert_eq!( + interpretation_run_lookup_stored_request_path_id( + "/v1/interpretation-runs/by-run-id/by-run-id/request" + ), + Err(OrchestratorLiveError::InvalidWirePayload) + ); + assert_eq!( + interpretation_run_lookup_stored_request_path_id( + "/v1/interpretation-runs/by-run-id/orch%2Fslash/request" + ), + Err(OrchestratorLiveError::InvalidWirePayload) + ); + assert_eq!( + interpretation_run_lookup_stored_request_path_id( + "/v1/interpretation-runs/by-run-id/%00/request" + ), + Err(OrchestratorLiveError::InvalidWirePayload) + ); + let oversized_path = format!( + "/v1/interpretation-runs/by-run-id/{}/request", + "a".repeat(INTERPRETATION_RUN_LOOKUP_ID_MAX_LEN + 1) + ); + assert_eq!( + interpretation_run_lookup_stored_request_path_id(&oversized_path), + Err(OrchestratorLiveError::LimitExceeded) + ); + assert!(is_interpretation_run_lookup_stored_request_path( + &oversized_path + )); + assert_eq!( + contextual_orchestrator_interpretation_run_lookup_stored_request_exchange( + "http://insecure.example", + "orch-run-1", + ), + Err(OrchestratorLiveError::InvalidWirePayload) + ); + assert_eq!( + contextual_orchestrator_interpretation_run_lookup_stored_request_exchange( + "https://postgres.example.test", + "orch-run-1", + ), + Err(OrchestratorLiveError::InvalidWirePayload) + ); + assert_eq!( + contextual_orchestrator_interpretation_run_lookup_stored_request_exchange( + "https://tepp.example.test", + "by-run-id", + ), + Err(OrchestratorLiveError::InvalidWirePayload) + ); + } +} diff --git a/crates/orchestrator_live/src/interpretation_run_retrieval_http.rs b/crates/orchestrator_live/src/interpretation_run_retrieval_http.rs index ff445cae4..192458bf6 100644 --- a/crates/orchestrator_live/src/interpretation_run_retrieval_http.rs +++ b/crates/orchestrator_live/src/interpretation_run_retrieval_http.rs @@ -16,11 +16,11 @@ use crate::error::OrchestratorLiveError; use crate::interpretation_run_cli::CONTEXTUAL_ORCHESTRATOR_CONSUMER_CODE; use crate::interpretation_run_collection_http::{ - refuse_metrics_on_interpretation_run_collection_payload, InterpretationRunCollectionItem, - INTERPRETATION_RUN_COLLECTION_CURSOR_MAX_LEN, + INTERPRETATION_RUN_COLLECTION_CURSOR_MAX_LEN, InterpretationRunCollectionItem, + refuse_metrics_on_interpretation_run_collection_payload, }; use crate::request::{ - host_implies_table_access, require_nonempty, to_json, INTERPRETATION_RUN_PATH, + INTERPRETATION_RUN_PATH, host_implies_table_access, require_nonempty, to_json, }; /// Maximum opaque idempotency-key length on the retrieval path. @@ -59,6 +59,9 @@ pub fn interpretation_run_retrieval_path_id(path: &str) -> Result Result { #[cfg(test)] mod tests { use super::{ + INTERPRETATION_RUN_RETRIEVAL_ID_MAX_LEN, contextual_orchestrator_interpretation_run_retrieval_exchange, interpretation_run_retrieval_item_json, interpretation_run_retrieval_path_id, - INTERPRETATION_RUN_RETRIEVAL_ID_MAX_LEN, }; use crate::error::OrchestratorLiveError; use crate::interpretation_run_collection_http::InterpretationRunCollectionItem; @@ -202,15 +205,19 @@ mod tests { ) .expect("exchange"); assert_eq!(exchange.method, "GET"); - assert!(exchange - .target_url - .ends_with("/v1/interpretation-runs/idem-a")); + assert!( + exchange + .target_url + .ends_with("/v1/interpretation-runs/idem-a") + ); assert!(exchange.body.is_empty()); - assert!(!exchange - .headers - .iter() - .any(|(name, _)| name.eq_ignore_ascii_case("authorization") - || name.eq_ignore_ascii_case("idempotency-key"))); + assert!( + !exchange + .headers + .iter() + .any(|(name, _)| name.eq_ignore_ascii_case("authorization") + || name.eq_ignore_ascii_case("idempotency-key")) + ); assert_eq!( interpretation_run_retrieval_path_id("/v1/interpretation-runs/idem-a").expect("id"), "idem-a" @@ -249,6 +256,10 @@ mod tests { interpretation_run_retrieval_path_id("/v1/analysis-runs/idem-a"), Err(OrchestratorLiveError::InvalidWirePayload) ); + assert_eq!( + interpretation_run_retrieval_path_id("/v1/interpretation-runs/by-run-id"), + Err(OrchestratorLiveError::InvalidWirePayload) + ); assert_eq!( interpretation_run_retrieval_path_id("/v1/interpretation-runs/idem%2Fslash"), Err(OrchestratorLiveError::InvalidWirePayload) diff --git a/crates/orchestrator_live/src/interpretation_run_stored_request_cli.rs b/crates/orchestrator_live/src/interpretation_run_stored_request_cli.rs new file mode 100644 index 000000000..5a02f54b9 --- /dev/null +++ b/crates/orchestrator_live/src/interpretation_run_stored_request_cli.rs @@ -0,0 +1,652 @@ +//! Operator loopback CLI for contextual-orchestrator stored-request GET. +//! +//! GAP-003A unique slice: operators run `tepp-interpretation-run-request get` +//! to mint `contextual_orchestrator_interpretation_run_stored_request_exchange` +//! onto spawned `tepp-orchestrator-loopback` TCP. Stdout is the stored create +//! request with `scientific_authority=false`. `tepp.scientific_acceptance.v1` +//! never appears. The CLI does not infer causality or call a model provider. +//! Naruon and `LineageWeave` are refused. `NaruonLiveService` stays POST-only. +//! This module does not duplicate stored-request GET (#453), GET-by-id (#438), +//! retrieval CLI (#439), collection GET/CLI (#433/#436), create CLI (#425), +//! analysis-run stored-request CLI (#395), cancel lineages (closed), Leiden, +//! or GAP-010 Figma/export. Persistence remains GAP-003B. + +use std::fmt::Write as _; +use std::io::{Read, Write}; +use std::net::{SocketAddr, TcpStream}; +use std::time::Duration; + +use crate::http::{header_is_credential, map_io_error}; +use crate::interpretation_run_cli::CONTEXTUAL_ORCHESTRATOR_CONSUMER_CODE; +use crate::interpretation_run_stored_request_http::{ + contextual_orchestrator_interpretation_run_stored_request_exchange, + interpretation_run_stored_request_path_id, + refuse_metrics_on_interpretation_run_stored_request_payload, + InterpretationRunStoredRequestHttpExchange, +}; +use crate::request::{ + require_nonempty, InterpretationRunRequest, DEFAULT_INTERPRETATION_BYTE_LIMIT, +}; +use crate::{ + OrchestratorLiveError, OrchestratorLiveResponse, OrchestratorLiveService, + LIVE_HEADER_BYTE_LIMIT, LIVE_HEADER_COUNT_LIMIT, +}; + +const SCIENTIFIC_ACCEPTANCE_SCHEMA: &str = "tepp.scientific_acceptance.v1"; +const CLI_IO_TIMEOUT: Duration = Duration::from_secs(2); +const MAXIMUM_HTTP_RESPONSE_BYTES: usize = + LIVE_HEADER_BYTE_LIMIT + 4 + DEFAULT_INTERPRETATION_BYTE_LIMIT; + +/// Supported operator verbs for the loopback interpretation-run retrieval CLI. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum InterpretationRunStoredRequestCliVerb { + /// `GET /v1/interpretation-runs/{idempotency_key}/request`. + Get, +} + +impl InterpretationRunStoredRequestCliVerb { + /// Parse one exact lowercase verb token. + /// + /// # Errors + /// + /// Returns [`OrchestratorLiveError::InvalidWirePayload`] for an unknown token. + pub fn parse(token: &str) -> Result { + match token { + "get" => Ok(Self::Get), + _ => Err(OrchestratorLiveError::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 stored-request listener. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct InterpretationRunStoredRequestCliInvocation { + /// CLI verb to execute. + pub verb: InterpretationRunStoredRequestCliVerb, + /// Loopback `host:port` of `tepp-orchestrator-loopback`. + pub host: String, + /// Published HTTPS origin used to mint the typed retrieval exchange. + pub origin: String, + /// Published modular consumer. Retrieval GET admits + /// `contextual-orchestrator` only. + pub consumer: String, + /// Opaque idempotency key that minted the stored identity. + pub idempotency_key: String, + /// JSON body. Retrieval GET requires empty. + pub body: String, +} + +impl InterpretationRunStoredRequestCliInvocation { + /// Parse argv plus stdin body into a validated loopback retrieval invocation. + /// + /// Empty stdin is admitted. Nonempty leftover stdin fails closed. + /// + /// # Errors + /// + /// Returns a fail-closed error for unknown verbs, missing required flags, a + /// non-loopback host, a non-`https` origin, an unpublished consumer, + /// credential-shaped flags, a hostile identity, 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(OrchestratorLiveError::InvalidWirePayload)?; + let verb = InterpretationRunStoredRequestCliVerb::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 [`OrchestratorLiveError::AuthorizationDenied`] for a non-loopback + /// host and [`OrchestratorLiveError::InvalidWirePayload`] or + /// [`OrchestratorLiveError::LimitExceeded`] for empty, unpublished, + /// nonempty-body, or oversized fields. + pub fn validate(&self) -> Result<(), OrchestratorLiveError> { + require_loopback_host(&self.host)?; + require_nonempty(&self.origin)?; + if !self.origin.starts_with("https://") { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + require_nonempty(&self.consumer)?; + if self.consumer != CONTEXTUAL_ORCHESTRATOR_CONSUMER_CODE { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + require_nonempty(&self.idempotency_key)?; + if self.idempotency_key.contains('/') || self.idempotency_key.contains('\0') { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + if !self.body.is_empty() { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + refuse_scientific_acceptance(&self.body)?; + refuse_metrics_on_interpretation_run_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(OrchestratorLiveError::InvalidWirePayload); + } + let name = &flag[2..]; + if header_is_credential(name) { + return Err(OrchestratorLiveError::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(OrchestratorLiveError::InvalidWirePayload), + }; + if slot.is_some() || index + 1 >= rest.len() { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + let value = rest[index + 1].as_str(); + require_nonempty(value)?; + *slot = Some(value.to_owned()); + index += 2; + } + Ok(flags) +} + +fn assemble_invocation( + verb: InterpretationRunStoredRequestCliVerb, + flags: ParsedFlags, + body: String, +) -> Result { + let invocation = InterpretationRunStoredRequestCliInvocation { + verb, + host: flags + .host + .ok_or(OrchestratorLiveError::InvalidWirePayload)?, + origin: flags + .origin + .ok_or(OrchestratorLiveError::InvalidWirePayload)?, + consumer: flags + .consumer + .unwrap_or_else(|| CONTEXTUAL_ORCHESTRATOR_CONSUMER_CODE.to_owned()), + idempotency_key: flags + .idempotency_key + .ok_or(OrchestratorLiveError::InvalidWirePayload)?, + body, + }; + invocation.validate()?; + Ok(invocation) +} + +fn require_loopback_host(host: &str) -> Result { + let addr: SocketAddr = host + .parse() + .map_err(|_| OrchestratorLiveError::InvalidWirePayload)?; + if addr.ip().is_loopback() { + Ok(addr) + } else { + Err(OrchestratorLiveError::AuthorizationDenied) + } +} + +/// Render a typed retrieval GET exchange as HTTP/1.1 for a loopback listener. +/// +/// # Errors +/// +/// Returns [`OrchestratorLiveError::AuthorizationDenied`] for a non-loopback +/// host or a credential-bearing header, and +/// [`OrchestratorLiveError::InvalidWirePayload`] when the exchange is not a +/// GET `/v1/interpretation-runs/{idempotency_key}/request` with an empty body. +pub fn loopback_http1_from_interpretation_run_stored_request_exchange( + exchange: &InterpretationRunStoredRequestHttpExchange, + loopback_host: &str, +) -> Result { + let _addr = require_loopback_host(loopback_host)?; + let host = loopback_host.trim(); + if exchange.method != "GET" { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + if !exchange.body.is_empty() { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + let rest = exchange + .target_url + .strip_prefix("https://") + .ok_or(OrchestratorLiveError::InvalidWirePayload)?; + let path = rest + .find('/') + .map(|index| &rest[index..]) + .ok_or(OrchestratorLiveError::InvalidWirePayload)?; + let _idempotency_key = interpretation_run_stored_request_path_id(path)?; + for (name, _) in &exchange.headers { + if header_is_credential(name) { + return Err(OrchestratorLiveError::AuthorizationDenied); + } + if name.eq_ignore_ascii_case("idempotency-key") + || name.eq_ignore_ascii_case("tepp-page-limit") + || name.eq_ignore_ascii_case("tepp-page-cursor") + { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + } + let mut request = String::new(); + write!( + request, + "{} {path} HTTP/1.1\r\nHost: {host}\r\n", + exchange.method + ) + .map_err(|_| OrchestratorLiveError::InvalidWirePayload)?; + for (name, value) in &exchange.headers { + if name.eq_ignore_ascii_case("host") || name.eq_ignore_ascii_case("content-length") { + continue; + } + write!(request, "{name}: {value}\r\n") + .map_err(|_| OrchestratorLiveError::InvalidWirePayload)?; + } + write!(request, "content-length: 0\r\n\r\n") + .map_err(|_| OrchestratorLiveError::InvalidWirePayload)?; + Ok(request) +} + +/// Compose one HTTP/1.1 stored-request GET from the typed consumer exchange. +/// +/// # Errors +/// +/// Returns the same fail-closed errors as +/// [`InterpretationRunStoredRequestCliInvocation::validate`]. +pub fn compose_interpretation_run_stored_request_cli_http( + invocation: &InterpretationRunStoredRequestCliInvocation, +) -> Result { + invocation.validate()?; + let exchange = contextual_orchestrator_interpretation_run_stored_request_exchange( + &invocation.origin, + &invocation.idempotency_key, + )?; + loopback_http1_from_interpretation_run_stored_request_exchange(&exchange, &invocation.host) +} + +/// Dispatch one stored-request CLI invocation against an in-process listener. +/// +/// # Errors +/// +/// Returns fail-closed validation errors before the HTTP handler runs. +pub fn dispatch_interpretation_run_stored_request_cli( + service: &mut OrchestratorLiveService, + invocation: &InterpretationRunStoredRequestCliInvocation, +) -> Result { + let request = compose_interpretation_run_stored_request_cli_http(invocation)?; + Ok(service.handle_http_request(&request)) +} + +/// Execute one stored-request CLI invocation over loopback TCP. +/// +/// # Errors +/// +/// Returns fail-closed validation, transport, or response-framing errors. +pub fn execute_interpretation_run_stored_request_cli( + invocation: &InterpretationRunStoredRequestCliInvocation, +) -> Result { + let addr = require_loopback_host(&invocation.host)?; + let request = compose_interpretation_run_stored_request_cli_http(invocation)?; + let mut stream = TcpStream::connect(addr).map_err(|error| map_io_error(&error))?; + stream + .set_read_timeout(Some(CLI_IO_TIMEOUT)) + .map_err(|error| map_io_error(&error))?; + stream + .set_write_timeout(Some(CLI_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 stored-request GET never prints scientific acceptance. +/// +/// # Errors +/// +/// Returns [`OrchestratorLiveError::InvalidWirePayload`] when a receipt carries +/// metric keys, evidence, causal scores, or +/// `tepp.scientific_acceptance.v1`, or when the identity does not match. +pub fn render_interpretation_run_stored_request_cli_stdout( + invocation: &InterpretationRunStoredRequestCliInvocation, + response: &OrchestratorLiveResponse, +) -> Result { + invocation.validate()?; + if response.body.is_empty() { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + refuse_scientific_acceptance(&response.body)?; + refuse_metrics_on_interpretation_run_stored_request_payload(&response.body)?; + if response.status_code != 200 { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + let stored = InterpretationRunRequest::from_json(&response.body)?; + if stored.idempotency_key() != invocation.idempotency_key { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + stored.to_json() +} + +fn refuse_scientific_acceptance(body: &str) -> Result<(), OrchestratorLiveError> { + if body.contains(SCIENTIFIC_ACCEPTANCE_SCHEMA) { + Err(OrchestratorLiveError::InvalidWirePayload) + } else { + Ok(()) + } +} + +fn parse_http_response(bytes: &[u8]) -> Result { + let text = std::str::from_utf8(bytes).map_err(|_| OrchestratorLiveError::InvalidWirePayload)?; + let (header_block, body) = text + .split_once("\r\n\r\n") + .ok_or(OrchestratorLiveError::InvalidWirePayload)?; + if header_block.len() > LIVE_HEADER_BYTE_LIMIT { + return Err(OrchestratorLiveError::LimitExceeded); + } + let mut lines = header_block.split("\r\n"); + let status_line = lines + .next() + .ok_or(OrchestratorLiveError::InvalidWirePayload)?; + let mut parts = status_line.split(' '); + if parts.next() != Some("HTTP/1.1") { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + let code = parts + .next() + .ok_or(OrchestratorLiveError::InvalidWirePayload)? + .parse::() + .map_err(|_| OrchestratorLiveError::InvalidWirePayload)?; + let reason_phrase = static_reason(code)?; + let mut content_length = None; + for (index, line) in lines.enumerate() { + if index >= LIVE_HEADER_COUNT_LIMIT { + return Err(OrchestratorLiveError::LimitExceeded); + } + let (name, value) = line + .split_once(':') + .ok_or(OrchestratorLiveError::InvalidWirePayload)?; + if name.eq_ignore_ascii_case("content-length") { + if content_length.is_some() { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + content_length = Some( + value + .trim() + .parse::() + .map_err(|_| OrchestratorLiveError::InvalidWirePayload)?, + ); + } + } + let declared = content_length.ok_or(OrchestratorLiveError::InvalidWirePayload)?; + if declared > DEFAULT_INTERPRETATION_BYTE_LIMIT { + return Err(OrchestratorLiveError::LimitExceeded); + } + if declared != body.len() { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + Ok(OrchestratorLiveResponse { + status_code: code, + reason_phrase, + body: body.to_owned(), + }) +} + +fn static_reason(code: u16) -> Result<&'static str, OrchestratorLiveError> { + 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(OrchestratorLiveError::InvalidWirePayload), + } +} + +/// Read stdin leftover bytes on a non-terminal; retrieval GET admits empty. +/// +/// # Errors +/// +/// Returns [`OrchestratorLiveError::InvalidWirePayload`] when stdin cannot be +/// read and [`OrchestratorLiveError::LimitExceeded`] when leftover stdin +/// exceeds the interpretation-run wire limit. +pub fn read_interpretation_run_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_INTERPRETATION_BYTE_LIMIT)?; + String::from_utf8(bytes).map_err(|_| OrchestratorLiveError::InvalidWirePayload) + } +} + +fn read_bounded( + reader: &mut impl Read, + maximum_bytes: usize, +) -> Result, OrchestratorLiveError> { + 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(OrchestratorLiveError::LimitExceeded); + } + Ok(bytes) +} + +#[cfg(test)] +mod tests { + use super::{ + compose_interpretation_run_stored_request_cli_http, + loopback_http1_from_interpretation_run_stored_request_exchange, + read_interpretation_run_stored_request_cli_stdin, InterpretationRunStoredRequestCliInvocation, + InterpretationRunStoredRequestCliVerb, + }; + use crate::interpretation_run_cli::CONTEXTUAL_ORCHESTRATOR_CONSUMER_CODE; + use crate::{ + contextual_orchestrator_interpretation_run_stored_request_exchange, OrchestratorLiveError, + }; + + const ORIGIN: &str = "https://tepp.example.test"; + + fn get_args() -> [&'static str; 9] { + [ + "get", + "--host", + "127.0.0.1:18082", + "--origin", + ORIGIN, + "--consumer", + CONTEXTUAL_ORCHESTRATOR_CONSUMER_CODE, + "--idempotency-key", + "idem-a", + ] + } + + #[test] + fn from_args_mints_get_and_refuses_fail_closed_inputs() { + assert_eq!( + InterpretationRunStoredRequestCliVerb::parse("get").expect("get"), + InterpretationRunStoredRequestCliVerb::Get + ); + assert_eq!(InterpretationRunStoredRequestCliVerb::Get.as_str(), "get"); + assert_eq!( + InterpretationRunStoredRequestCliVerb::parse("list"), + Err(OrchestratorLiveError::InvalidWirePayload) + ); + let get = InterpretationRunStoredRequestCliInvocation::from_args(get_args(), "").expect("get"); + assert_eq!(get.verb, InterpretationRunStoredRequestCliVerb::Get); + let http = compose_interpretation_run_stored_request_cli_http(&get).expect("http"); + assert!(http.starts_with("GET /v1/interpretation-runs/idem-a/request HTTP/1.1")); + assert!(http.contains("tepp-consumer: contextual-orchestrator")); + assert!(http.contains("content-length: 0")); + assert!(!http.contains("idempotency-key:")); + assert!(!http.contains("authorization")); + assert_eq!( + InterpretationRunStoredRequestCliInvocation::from_args( + [ + "get", + "--host", + "8.8.8.8:80", + "--origin", + ORIGIN, + "--idempotency-key", + "idem-a" + ], + "" + ) + .unwrap_err(), + OrchestratorLiveError::AuthorizationDenied + ); + assert_eq!( + InterpretationRunStoredRequestCliInvocation::from_args( + [ + "get", + "--host", + "localhost:18082", + "--origin", + ORIGIN, + "--idempotency-key", + "idem-a" + ], + "" + ) + .unwrap_err(), + OrchestratorLiveError::InvalidWirePayload + ); + assert_eq!( + InterpretationRunStoredRequestCliInvocation::from_args( + [ + "get", + "--host", + "127.0.0.1:18082", + "--origin", + "http://tepp.example.test", + "--idempotency-key", + "idem-a" + ], + "" + ) + .unwrap_err(), + OrchestratorLiveError::InvalidWirePayload + ); + assert_eq!( + InterpretationRunStoredRequestCliInvocation::from_args( + [ + "get", + "--host", + "127.0.0.1:18082", + "--origin", + ORIGIN, + "--idempotency-key", + "idem-a", + "--authorization", + "secret" + ], + "" + ) + .unwrap_err(), + OrchestratorLiveError::AuthorizationDenied + ); + } + + #[test] + fn from_args_refuses_unpublished_body_pagination_and_non_get() { + assert_eq!( + InterpretationRunStoredRequestCliInvocation::from_args( + [ + "get", + "--host", + "127.0.0.1:18082", + "--origin", + ORIGIN, + "--consumer", + "naruon", + "--idempotency-key", + "idem-a" + ], + "" + ) + .unwrap_err(), + OrchestratorLiveError::InvalidWirePayload + ); + assert_eq!( + InterpretationRunStoredRequestCliInvocation::from_args(get_args(), "{}").unwrap_err(), + OrchestratorLiveError::InvalidWirePayload + ); + assert_eq!( + InterpretationRunStoredRequestCliInvocation::from_args( + [ + "get", + "--host", + "127.0.0.1:18082", + "--origin", + ORIGIN, + "--idempotency-key", + "idem-a", + "--page-limit", + "1" + ], + "" + ) + .unwrap_err(), + OrchestratorLiveError::InvalidWirePayload + ); + let leftover = + read_interpretation_run_stored_request_cli_stdin(false, std::io::Cursor::new(b"leftover")) + .expect("leftover"); + assert_eq!(leftover, "leftover"); + assert!( + read_interpretation_run_stored_request_cli_stdin(true, std::io::empty()) + .expect("tty") + .is_empty() + ); + let exchange = + contextual_orchestrator_interpretation_run_stored_request_exchange(ORIGIN, "idem-a") + .expect("exchange"); + let mut posted = exchange.clone(); + posted.method = "POST"; + assert_eq!( + loopback_http1_from_interpretation_run_stored_request_exchange(&posted, "127.0.0.1:18082") + .unwrap_err(), + OrchestratorLiveError::InvalidWirePayload + ); + } +} diff --git a/crates/orchestrator_live/src/interpretation_run_stored_request_http.rs b/crates/orchestrator_live/src/interpretation_run_stored_request_http.rs new file mode 100644 index 000000000..fc2b36790 --- /dev/null +++ b/crates/orchestrator_live/src/interpretation_run_stored_request_http.rs @@ -0,0 +1,301 @@ +//! Provider-owned interpretation-run stored-request GET contracts. +//! +//! GAP-003A unique slice: `GET /v1/interpretation-runs/{idempotency_key}/request` +//! returns the accepted metric-free `InterpretationRunRequest` on +//! `OrchestratorLiveService` / `tepp-orchestrator-loopback` so operators who +//! hold a retrieval identity do not replay POST. The stored request stays +//! `scientific_authority=false`. `tepp.scientific_acceptance.v1` never +//! appears. This module does not duplicate GET-by-id (#438), retrieval CLI +//! (#439), collection GET (#433), collection CLI (#436), create CLI (#425), +//! analysis-run stored-request GET (#377), cancel lineages (closed), Leiden, +//! or GAP-010 Figma/export. Persistence remains GAP-003B. Naruon and +//! `LineageWeave` are refused. `NaruonLiveService` stays POST-only. + +use crate::error::OrchestratorLiveError; +use crate::interpretation_run_cli::CONTEXTUAL_ORCHESTRATOR_CONSUMER_CODE; +use crate::interpretation_run_lookup_http::INTERPRETATION_RUN_LOOKUP_PREFIX; +use crate::interpretation_run_retrieval_http::INTERPRETATION_RUN_RETRIEVAL_ID_MAX_LEN; +use crate::request::{host_implies_table_access, require_nonempty, INTERPRETATION_RUN_PATH}; + +const FORBIDDEN_STORED_REQUEST_KEYS: [&str; 12] = [ + "rmse", + "rmse_standard_error", + "mean_bias", + "bias_standard_error", + "interval_coverage", + "se_gate_accepted", + "scientific_acceptance", + "causal_score", + "findings", + "evidence_text", + "report", + "event_label", +]; + +/// Typed GET exchange for interpretation-run stored-request retrieval. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct InterpretationRunStoredRequestHttpExchange { + /// HTTP method, always `GET`. + pub method: &'static str, + /// Absolute HTTPS target ending in `/v1/interpretation-runs/{key}/request`. + pub target_url: String, + /// Exact version, consumer, and content headers. No credentials. + pub headers: Vec<(String, String)>, + /// GET body, always empty. + pub body: String, +} + +/// Extract the opaque idempotency key from +/// `GET /v1/interpretation-runs/{key}/request`. +/// +/// # Errors +/// +/// Returns [`OrchestratorLiveError::InvalidWirePayload`] for collection, +/// GET-by-id, extra segments, a hostile encoding, empty identity, slash, or +/// NUL, and [`OrchestratorLiveError::LimitExceeded`] when oversized. +pub fn interpretation_run_stored_request_path_id( + path: &str, +) -> Result { + let remainder = path + .strip_prefix(INTERPRETATION_RUN_PATH) + .ok_or(OrchestratorLiveError::InvalidWirePayload)?; + let encoded = remainder + .strip_prefix('/') + .ok_or(OrchestratorLiveError::InvalidWirePayload)?; + let (encoded_id, rest) = encoded + .split_once('/') + .ok_or(OrchestratorLiveError::InvalidWirePayload)?; + if rest != "request" || encoded_id.is_empty() { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + let idempotency_key = decode_path_segment(encoded_id)?; + require_nonempty(&idempotency_key)?; + if idempotency_key == INTERPRETATION_RUN_LOOKUP_PREFIX { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + if idempotency_key.contains('/') || idempotency_key.contains('\0') { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + if idempotency_key.len() > INTERPRETATION_RUN_RETRIEVAL_ID_MAX_LEN { + return Err(OrchestratorLiveError::LimitExceeded); + } + Ok(idempotency_key) +} + +/// Whether `path` is the stored-request extra-segment resource. +#[must_use] +pub fn is_interpretation_run_stored_request_path(path: &str) -> bool { + interpretation_run_stored_request_path_id(path).is_ok() +} + +/// Build a credential-free contextual-orchestrator stored-request GET exchange. +/// +/// # Errors +/// +/// Returns a fail-closed origin or identity error. +pub fn contextual_orchestrator_interpretation_run_stored_request_exchange( + origin: &str, + idempotency_key: &str, +) -> Result { + require_nonempty(origin)?; + if !origin.starts_with("https://") || origin.ends_with('/') { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + let rest = origin + .strip_prefix("https://") + .ok_or(OrchestratorLiveError::InvalidWirePayload)?; + if rest.contains('@') || rest.contains('?') || rest.contains('#') || rest.contains('\\') { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + if host_implies_table_access(rest) { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + require_nonempty(idempotency_key)?; + if idempotency_key == INTERPRETATION_RUN_LOOKUP_PREFIX { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + if idempotency_key.contains('/') || idempotency_key.contains('\0') { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + if idempotency_key.len() > INTERPRETATION_RUN_RETRIEVAL_ID_MAX_LEN { + return Err(OrchestratorLiveError::LimitExceeded); + } + let encoded_id = encode_path_segment(idempotency_key); + Ok(InterpretationRunStoredRequestHttpExchange { + method: "GET", + target_url: format!("{origin}{INTERPRETATION_RUN_PATH}/{encoded_id}/request"), + headers: vec![ + ("content-type".into(), "application/json".into()), + ( + "tepp-consumer".into(), + CONTEXTUAL_ORCHESTRATOR_CONSUMER_CODE.into(), + ), + ("tepp-contract-version".into(), "1".into()), + ], + body: String::new(), + }) +} + +/// Refuse stored-request JSON that already carries scientific-metric keys. +/// +/// Empty payloads are admitted for the GET request body. +/// +/// # Errors +/// +/// Returns [`OrchestratorLiveError::InvalidWirePayload`] when a forbidden +/// metric, evidence, or causal-score key is present. +pub fn refuse_metrics_on_interpretation_run_stored_request_payload( + payload: &str, +) -> Result<(), OrchestratorLiveError> { + if payload.trim().is_empty() { + return Ok(()); + } + if payload.contains("tepp.scientific_acceptance.v1") { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + let value: serde_json::Value = + serde_json::from_str(payload).map_err(|_| OrchestratorLiveError::InvalidWirePayload)?; + refuse_metrics_on_json(&value) +} + +fn refuse_metrics_on_json(value: &serde_json::Value) -> Result<(), OrchestratorLiveError> { + match value { + serde_json::Value::Object(object) => { + if FORBIDDEN_STORED_REQUEST_KEYS + .iter() + .any(|key| object.contains_key(*key)) + { + return Err(OrchestratorLiveError::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(()), + } +} + +fn encode_path_segment(value: &str) -> String { + let mut out = String::with_capacity(value.len()); + 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); + } + _ => { + let hex = b"0123456789ABCDEF"; + 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(OrchestratorLiveError::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(OrchestratorLiveError::InvalidWirePayload), + } + } + let decoded = String::from_utf8(out).map_err(|_| OrchestratorLiveError::InvalidWirePayload)?; + if decoded.chars().any(char::is_control) { + return Err(OrchestratorLiveError::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(OrchestratorLiveError::InvalidWirePayload), + } +} + +#[cfg(test)] +mod tests { + use super::{ + contextual_orchestrator_interpretation_run_stored_request_exchange, + interpretation_run_stored_request_path_id, is_interpretation_run_stored_request_path, + }; + use crate::error::OrchestratorLiveError; + + #[test] + fn stored_request_exchange_is_metric_free_get_without_credentials() { + let exchange = contextual_orchestrator_interpretation_run_stored_request_exchange( + "https://tepp.example.test", + "idem-a", + ) + .expect("exchange"); + assert_eq!(exchange.method, "GET"); + assert!(exchange + .target_url + .ends_with("/v1/interpretation-runs/idem-a/request")); + assert!(exchange.body.is_empty()); + assert!(!exchange.headers.iter().any(|(name, _)| name + .eq_ignore_ascii_case("authorization") + || name.eq_ignore_ascii_case("idempotency-key"))); + assert!(is_interpretation_run_stored_request_path( + "/v1/interpretation-runs/idem-a/request" + )); + assert!(!is_interpretation_run_stored_request_path( + "/v1/interpretation-runs/idem-a" + )); + assert_eq!( + interpretation_run_stored_request_path_id("/v1/interpretation-runs/idem-a/request") + .expect("id"), + "idem-a" + ); + assert_eq!( + interpretation_run_stored_request_path_id("/v1/interpretation-runs/idem-a"), + Err(OrchestratorLiveError::InvalidWirePayload) + ); + assert_eq!( + interpretation_run_stored_request_path_id("/v1/interpretation-runs/idem-a/cancel"), + Err(OrchestratorLiveError::InvalidWirePayload) + ); + assert_eq!( + interpretation_run_stored_request_path_id("/v1/interpretation-runs/by-run-id/request"), + Err(OrchestratorLiveError::InvalidWirePayload) + ); + assert_eq!( + interpretation_run_stored_request_path_id( + "/v1/interpretation-runs/by-run-id/orch-run-1/request" + ), + Err(OrchestratorLiveError::InvalidWirePayload) + ); + assert_eq!( + contextual_orchestrator_interpretation_run_stored_request_exchange( + "http://tepp.example.test", + "idem-a" + ), + Err(OrchestratorLiveError::InvalidWirePayload) + ); + } +} diff --git a/crates/orchestrator_live/src/lib.rs b/crates/orchestrator_live/src/lib.rs index 260f3c2db..daa388b62 100644 --- a/crates/orchestrator_live/src/lib.rs +++ b/crates/orchestrator_live/src/lib.rs @@ -7,6 +7,17 @@ //! hypothetical and never scientific authority. Collection GET enumerates //! metric-free identities so operators do not guess idempotency keys. //! GET-by-id returns one of those identities without POST replay. +//! `GET /v1/interpretation-runs/{idempotency_key}/request` returns the stored +//! create request without POST replay. Published `tepp-interpretation-run-request` +//! mints that GET onto spawned `tepp-orchestrator-loopback` TCP. +//! `GET /v1/interpretation-runs/by-run-id/{interpretation_run_id}` returns the +//! metric-free identity of the unique accepted run without POST replay. +//! Published `tepp-interpretation-run-lookup` mints that GET onto spawned +//! `tepp-orchestrator-loopback` TCP. +//! `GET /v1/interpretation-runs/by-run-id/{interpretation_run_id}/request` +//! returns the stored create request of that unique accepted run. +//! Published `tepp-interpretation-run-lookup-request` mints that GET onto +//! spawned `tepp-orchestrator-loopback` TCP. //! Table-access hosts, review/Copilot/GitHub credentials, and //! `COPILOT_GITHUB_TOKEN` fail closed. This crate does not implement TLS //! termination or call a model provider (ADR 0010; ADR 0011). The published @@ -18,19 +29,33 @@ mod error; mod http; mod interpretation_run_cli; mod interpretation_run_collection_http; +mod interpretation_run_lookup_cli; +mod interpretation_run_lookup_http; +mod interpretation_run_lookup_stored_request_cli; +mod interpretation_run_lookup_stored_request_http; mod interpretation_run_retrieval_http; +mod interpretation_run_stored_request_cli; +mod interpretation_run_stored_request_http; mod mode; mod request; mod service; /// Fail-closed orchestrator live-listener errors. pub use error::OrchestratorLiveError; -/// Loopback live HTTP/1.1 response. -pub use http::OrchestratorLiveResponse; /// Maximum live HTTP header-block size in bytes. pub use http::LIVE_HEADER_BYTE_LIMIT; /// Maximum live HTTP header count. pub use http::LIVE_HEADER_COUNT_LIMIT; +/// Loopback live HTTP/1.1 response. +pub use http::OrchestratorLiveResponse; +/// Published modular consumer for interpretation-run POST. +pub use interpretation_run_cli::CONTEXTUAL_ORCHESTRATOR_CONSUMER_CODE; +/// Loopback interpretation-run CLI invocation. +pub use interpretation_run_cli::InterpretationRunCliInvocation; +/// Loopback interpretation-run CLI verb. +pub use interpretation_run_cli::InterpretationRunCliVerb; +/// Typed HTTPS interpretation-run exchange. +pub use interpretation_run_cli::InterpretationRunHttpExchange; /// Compose HTTP/1.1 interpretation-run POST from a CLI invocation. pub use interpretation_run_cli::compose_interpretation_run_cli_http; /// Build a credential-free contextual-orchestrator interpretation-run exchange. @@ -47,14 +72,18 @@ pub use interpretation_run_cli::read_interpretation_run_cli_stdin; pub use interpretation_run_cli::refuse_metrics_on_interpretation_run_cli_payload; /// Filter interpretation-run CLI stdout so the accepted run stays hypothetical. pub use interpretation_run_cli::render_interpretation_run_cli_stdout; -/// Loopback interpretation-run CLI invocation. -pub use interpretation_run_cli::InterpretationRunCliInvocation; -/// Loopback interpretation-run CLI verb. -pub use interpretation_run_cli::InterpretationRunCliVerb; -/// Typed HTTPS interpretation-run exchange. -pub use interpretation_run_cli::InterpretationRunHttpExchange; -/// Published modular consumer for interpretation-run POST. -pub use interpretation_run_cli::CONTEXTUAL_ORCHESTRATOR_CONSUMER_CODE; +/// Maximum opaque cursor length on interpretation-run collection GET. +pub use interpretation_run_collection_http::INTERPRETATION_RUN_COLLECTION_CURSOR_MAX_LEN; +/// Default page size for interpretation-run collection GET. +pub use interpretation_run_collection_http::INTERPRETATION_RUN_COLLECTION_DEFAULT_LIMIT; +/// Maximum page size for interpretation-run collection GET. +pub use interpretation_run_collection_http::INTERPRETATION_RUN_COLLECTION_MAX_LIMIT; +/// Metric-free interpretation-run collection page. +pub use interpretation_run_collection_http::InterpretationRunCollection; +/// Typed GET exchange for interpretation-run collection. +pub use interpretation_run_collection_http::InterpretationRunCollectionHttpExchange; +/// One metric-free interpretation-run collection row. +pub use interpretation_run_collection_http::InterpretationRunCollectionItem; /// Build a credential-free contextual-orchestrator collection GET exchange. pub use interpretation_run_collection_http::contextual_orchestrator_interpretation_run_collection_exchange; /// Whether a path is the interpretation-run collection resource. @@ -67,34 +96,98 @@ pub use interpretation_run_collection_http::parse_interpretation_run_collection_ pub use interpretation_run_collection_http::parse_interpretation_run_collection_page_limit; /// Refuse metric, evidence, and causal-score keys on collection JSON. pub use interpretation_run_collection_http::refuse_metrics_on_interpretation_run_collection_payload; -/// Metric-free interpretation-run collection page. -pub use interpretation_run_collection_http::InterpretationRunCollection; -/// Typed GET exchange for interpretation-run collection. -pub use interpretation_run_collection_http::InterpretationRunCollectionHttpExchange; -/// One metric-free interpretation-run collection row. -pub use interpretation_run_collection_http::InterpretationRunCollectionItem; -/// Maximum opaque cursor length on interpretation-run collection GET. -pub use interpretation_run_collection_http::INTERPRETATION_RUN_COLLECTION_CURSOR_MAX_LEN; -/// Default page size for interpretation-run collection GET. -pub use interpretation_run_collection_http::INTERPRETATION_RUN_COLLECTION_DEFAULT_LIMIT; -/// Maximum page size for interpretation-run collection GET. -pub use interpretation_run_collection_http::INTERPRETATION_RUN_COLLECTION_MAX_LIMIT; +/// Maximum opaque `interpretation_run_id` length on interpretation-run lookup GET. +pub use interpretation_run_lookup_http::INTERPRETATION_RUN_LOOKUP_ID_MAX_LEN; +/// Reserved collection-relative prefix that names the lookup resource. +pub use interpretation_run_lookup_http::INTERPRETATION_RUN_LOOKUP_PREFIX; +/// Typed GET exchange for interpretation-run lookup by server-assigned id. +pub use interpretation_run_lookup_http::InterpretationRunLookupHttpExchange; +/// Build a credential-free contextual-orchestrator lookup GET exchange. +pub use interpretation_run_lookup_http::contextual_orchestrator_interpretation_run_lookup_exchange; +/// Extract the opaque `interpretation_run_id` from a lookup GET path. +pub use interpretation_run_lookup_http::interpretation_run_lookup_path_id; +/// Whether a path is the lookup-by-run-id resource. +pub use interpretation_run_lookup_http::is_interpretation_run_lookup_path; +/// Extra-segment that names the stored create on lookup stored-request GET. +pub use interpretation_run_lookup_stored_request_http::INTERPRETATION_RUN_LOOKUP_STORED_REQUEST_SEGMENT; +/// Typed GET exchange for stored-request lookup by server-assigned id. +pub use interpretation_run_lookup_stored_request_http::InterpretationRunLookupStoredRequestHttpExchange; +/// Build a credential-free contextual-orchestrator lookup stored-request GET. +pub use interpretation_run_lookup_stored_request_http::contextual_orchestrator_interpretation_run_lookup_stored_request_exchange; +/// Extract the opaque `interpretation_run_id` from a lookup stored-request path. +pub use interpretation_run_lookup_stored_request_http::interpretation_run_lookup_stored_request_path_id; +/// Whether a path is the lookup stored-request extra-segment resource. +pub use interpretation_run_lookup_stored_request_http::is_interpretation_run_lookup_stored_request_path; +/// Loopback lookup stored-request CLI invocation. +pub use interpretation_run_lookup_stored_request_cli::InterpretationRunLookupStoredRequestCliInvocation; +/// Loopback lookup stored-request CLI verb. +pub use interpretation_run_lookup_stored_request_cli::InterpretationRunLookupStoredRequestCliVerb; +/// Compose HTTP/1.1 lookup stored-request GET from a CLI invocation. +pub use interpretation_run_lookup_stored_request_cli::compose_interpretation_run_lookup_stored_request_cli_http; +/// Dispatch a lookup stored-request CLI invocation against an in-process listener. +pub use interpretation_run_lookup_stored_request_cli::dispatch_interpretation_run_lookup_stored_request_cli; +/// Execute a lookup stored-request CLI invocation over loopback TCP. +pub use interpretation_run_lookup_stored_request_cli::execute_interpretation_run_lookup_stored_request_cli; +/// Render a typed lookup stored-request exchange onto a loopback HTTP/1.1 request. +pub use interpretation_run_lookup_stored_request_cli::loopback_http1_from_interpretation_run_lookup_stored_request_exchange; +/// Read leftover stdin for the lookup stored-request CLI. +pub use interpretation_run_lookup_stored_request_cli::read_interpretation_run_lookup_stored_request_cli_stdin; +/// Filter lookup stored-request CLI stdout so the stored create stays hypothetical. +pub use interpretation_run_lookup_stored_request_cli::render_interpretation_run_lookup_stored_request_cli_stdout; +/// Loopback lookup CLI invocation. +pub use interpretation_run_lookup_cli::InterpretationRunLookupCliInvocation; +/// Loopback lookup CLI verb. +pub use interpretation_run_lookup_cli::InterpretationRunLookupCliVerb; +/// Compose HTTP/1.1 lookup GET from a CLI invocation. +pub use interpretation_run_lookup_cli::compose_interpretation_run_lookup_cli_http; +/// Dispatch a lookup CLI invocation against an in-process listener. +pub use interpretation_run_lookup_cli::dispatch_interpretation_run_lookup_cli; +/// Execute a lookup CLI invocation over loopback TCP. +pub use interpretation_run_lookup_cli::execute_interpretation_run_lookup_cli; +/// Render a typed lookup exchange onto a loopback HTTP/1.1 request. +pub use interpretation_run_lookup_cli::loopback_http1_from_interpretation_run_lookup_exchange; +/// Read leftover stdin for the lookup CLI. +pub use interpretation_run_lookup_cli::read_interpretation_run_lookup_cli_stdin; +/// Filter lookup CLI stdout so the identity stays hypothetical. +pub use interpretation_run_lookup_cli::render_interpretation_run_lookup_cli_stdout; +/// Maximum opaque idempotency-key length on interpretation-run GET-by-id. +pub use interpretation_run_retrieval_http::INTERPRETATION_RUN_RETRIEVAL_ID_MAX_LEN; +/// Typed GET exchange for interpretation-run GET-by-id. +pub use interpretation_run_retrieval_http::InterpretationRunRetrievalHttpExchange; /// Build a credential-free contextual-orchestrator GET-by-id exchange. pub use interpretation_run_retrieval_http::contextual_orchestrator_interpretation_run_retrieval_exchange; /// Serialize one metric-free GET-by-id identity. pub use interpretation_run_retrieval_http::interpretation_run_retrieval_item_json; /// Extract the opaque idempotency key from a GET-by-id path. pub use interpretation_run_retrieval_http::interpretation_run_retrieval_path_id; -/// Typed GET exchange for interpretation-run GET-by-id. -pub use interpretation_run_retrieval_http::InterpretationRunRetrievalHttpExchange; -/// Maximum opaque idempotency-key length on interpretation-run GET-by-id. -pub use interpretation_run_retrieval_http::INTERPRETATION_RUN_RETRIEVAL_ID_MAX_LEN; +/// Loopback stored-request CLI invocation. +pub use interpretation_run_stored_request_cli::InterpretationRunStoredRequestCliInvocation; +/// Loopback stored-request CLI verb. +pub use interpretation_run_stored_request_cli::InterpretationRunStoredRequestCliVerb; +/// Compose HTTP/1.1 stored-request GET from a CLI invocation. +pub use interpretation_run_stored_request_cli::compose_interpretation_run_stored_request_cli_http; +/// Dispatch a stored-request CLI invocation against an in-process listener. +pub use interpretation_run_stored_request_cli::dispatch_interpretation_run_stored_request_cli; +/// Execute a stored-request CLI invocation over loopback TCP. +pub use interpretation_run_stored_request_cli::execute_interpretation_run_stored_request_cli; +/// Render a typed stored-request exchange onto a loopback HTTP/1.1 request. +pub use interpretation_run_stored_request_cli::loopback_http1_from_interpretation_run_stored_request_exchange; +/// Read leftover stdin for the stored-request CLI. +pub use interpretation_run_stored_request_cli::read_interpretation_run_stored_request_cli_stdin; +/// Filter stored-request CLI stdout so `scientific_authority` stays false. +pub use interpretation_run_stored_request_cli::render_interpretation_run_stored_request_cli_stdout; +/// Typed GET exchange for interpretation-run stored-request retrieval. +pub use interpretation_run_stored_request_http::InterpretationRunStoredRequestHttpExchange; +/// Build a credential-free contextual-orchestrator stored-request GET exchange. +pub use interpretation_run_stored_request_http::contextual_orchestrator_interpretation_run_stored_request_exchange; +/// Extract the opaque idempotency key from a stored-request GET path. +pub use interpretation_run_stored_request_http::interpretation_run_stored_request_path_id; +/// Whether a path is the stored-request extra-segment resource. +pub use interpretation_run_stored_request_http::is_interpretation_run_stored_request_path; +/// Refuse metric keys on stored-request JSON. +pub use interpretation_run_stored_request_http::refuse_metrics_on_interpretation_run_stored_request_payload; /// Closed ADR 0010 orchestration-mode vocabulary. pub use mode::OrchestrationMode; -/// Accepted hypothetical interpretation-run response. -pub use request::InterpretationRunAccepted; -/// Interpretation-run create request. -pub use request::InterpretationRunRequest; /// Default maximum interpretation-run JSON payload size in bytes. pub use request::DEFAULT_INTERPRETATION_BYTE_LIMIT; /// Canonical hypothetical claim-status label. @@ -103,5 +196,9 @@ pub use request::HYPOTHETICAL_CLAIM_STATUS; pub use request::INTERPRETATION_RUN_CONTRACT_VERSION; /// Versioned path contextual-orchestrator may POST or GET. pub use request::INTERPRETATION_RUN_PATH; +/// Accepted hypothetical interpretation-run response. +pub use request::InterpretationRunAccepted; +/// Interpretation-run create request. +pub use request::InterpretationRunRequest; /// Loopback live HTTP/1.1 orchestrator listener. pub use service::OrchestratorLiveService; diff --git a/crates/orchestrator_live/src/service.rs b/crates/orchestrator_live/src/service.rs index 969fa0349..601b2d8b5 100644 --- a/crates/orchestrator_live/src/service.rs +++ b/crates/orchestrator_live/src/service.rs @@ -1,25 +1,40 @@ //! Loopback-only live HTTP/1.1 listener for interpretation POSTs (ADR 0010/0011). +//! `GET /v1/interpretation-runs/{idempotency_key}/request` returns the stored +//! create request without POST replay. `GET /v1/interpretation-runs/by-run-id/{interpretation_run_id}` +//! returns the metric-free identity of the unique accepted run. use std::collections::HashMap; use std::net::{SocketAddr, TcpListener, TcpStream}; use crate::error::OrchestratorLiveError; use crate::http::{ - header_value, map_io_error, parse_headers, parse_request_line, read_http_request, - refuse_collection_get_headers, refuse_live_headers, refuse_retrieval_get_headers, - split_request, status_for, write_response, OrchestratorLiveResponse, + OrchestratorLiveResponse, header_value, map_io_error, parse_headers, parse_request_line, + read_http_request, refuse_collection_get_headers, refuse_live_headers, + refuse_retrieval_get_headers, split_request, status_for, write_response, }; use crate::interpretation_run_collection_http::{ + InterpretationRunCollection, InterpretationRunCollectionItem, is_interpretation_run_collection_path, page_interpretation_run_collection_items, parse_interpretation_run_collection_page_cursor, - parse_interpretation_run_collection_page_limit, InterpretationRunCollection, - InterpretationRunCollectionItem, + parse_interpretation_run_collection_page_limit, +}; +use crate::interpretation_run_lookup_http::{ + INTERPRETATION_RUN_LOOKUP_PREFIX, interpretation_run_lookup_path_id, + is_interpretation_run_lookup_path, +}; +use crate::interpretation_run_lookup_stored_request_http::{ + InterpretationRunLookupStoredRequestPayload, interpretation_run_lookup_stored_request_path_id, + is_interpretation_run_lookup_stored_request_path, }; use crate::interpretation_run_retrieval_http::{ interpretation_run_retrieval_item_json, interpretation_run_retrieval_path_id, }; +use crate::interpretation_run_stored_request_http::{ + interpretation_run_stored_request_path_id, is_interpretation_run_stored_request_path, + refuse_metrics_on_interpretation_run_stored_request_payload, +}; use crate::request::{ - to_json, InterpretationRunAccepted, InterpretationRunRequest, INTERPRETATION_RUN_PATH, + INTERPRETATION_RUN_PATH, InterpretationRunAccepted, InterpretationRunRequest, to_json, }; /// Loopback live HTTP/1.1 service for contextual-orchestrator interpretation POSTs. @@ -30,6 +45,10 @@ use crate::request::{ /// `GET /v1/interpretation-runs` enumerates accepted hypothetical runs as /// metric-free identities. `GET /v1/interpretation-runs/{idempotency_key}` /// returns one of those identities without POST replay. +/// `GET /v1/interpretation-runs/{idempotency_key}/request` returns the stored +/// create request without POST replay. +/// `GET /v1/interpretation-runs/by-run-id/{interpretation_run_id}` returns one +/// of those identities from the server-assigned run id without POST replay. #[derive(Debug)] pub struct OrchestratorLiveService { listener: Option, @@ -180,6 +199,15 @@ impl OrchestratorLiveService { if is_interpretation_run_collection_path(path) { return self.list_interpretation_runs(path, &headers, body); } + if is_interpretation_run_stored_request_path(path) { + return self.get_interpretation_run_stored_request(path, &headers, body); + } + if is_interpretation_run_lookup_stored_request_path(path) { + return self.get_interpretation_run_lookup_stored_request(path, &headers, body); + } + if is_interpretation_run_lookup_path(path) { + return self.lookup_interpretation_run_by_run_id(path, &headers, body); + } return self.get_interpretation_run(path, &headers, body); } if method != "POST" || path != INTERPRETATION_RUN_PATH { @@ -261,12 +289,100 @@ impl OrchestratorLiveService { )) } + fn get_interpretation_run_stored_request( + &self, + path: &str, + headers: &HashMap, + body: &str, + ) -> Result { + let idempotency_key = interpretation_run_stored_request_path_id(path)?; + if !body.is_empty() { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + refuse_retrieval_get_headers(headers)?; + let stored = self + .accepted_runs + .get(&idempotency_key) + .map(|(request, _)| request) + .ok_or(OrchestratorLiveError::InvalidWirePayload)?; + let payload = stored.to_json()?; + refuse_metrics_on_interpretation_run_stored_request_payload(&payload)?; + Ok(OrchestratorLiveResponse::json(200, "OK", payload)) + } + + fn get_interpretation_run_lookup_stored_request( + &self, + path: &str, + headers: &HashMap, + body: &str, + ) -> Result { + let interpretation_run_id = interpretation_run_lookup_stored_request_path_id(path)?; + if !body.is_empty() { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + refuse_retrieval_get_headers(headers)?; + let mut matches = self.accepted_runs.values().filter_map(|(request, accepted)| { + (accepted.interpretation_run_id() == interpretation_run_id) + .then_some((request, accepted)) + }); + let (stored, accepted) = matches + .next() + .ok_or(OrchestratorLiveError::InvalidWirePayload)?; + if matches.next().is_some() { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + let payload = InterpretationRunLookupStoredRequestPayload::new( + accepted.interpretation_run_id(), + stored.clone(), + )? + .to_json()?; + refuse_metrics_on_interpretation_run_stored_request_payload(&payload)?; + Ok(OrchestratorLiveResponse::json(200, "OK", payload)) + } + + fn lookup_interpretation_run_by_run_id( + &self, + path: &str, + headers: &HashMap, + body: &str, + ) -> Result { + let interpretation_run_id = interpretation_run_lookup_path_id(path)?; + if !body.is_empty() { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + refuse_retrieval_get_headers(headers)?; + let mut matches = self.accepted_runs.values().filter_map(|(_, accepted)| { + (accepted.interpretation_run_id() == interpretation_run_id).then_some(accepted) + }); + let accepted = matches + .next() + .ok_or(OrchestratorLiveError::InvalidWirePayload)?; + if matches.next().is_some() { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + let item = InterpretationRunCollectionItem::new( + accepted.interpretation_run_id(), + accepted.idempotency_key(), + accepted.orchestration_mode(), + accepted.claim_status(), + accepted.scientific_authority(), + )?; + Ok(OrchestratorLiveResponse::json( + 200, + "OK", + interpretation_run_retrieval_item_json(&item)?, + )) + } + fn accept_interpretation_run( &mut self, headers: &HashMap, body: &str, ) -> Result { let request = InterpretationRunRequest::from_json(body)?; + if request.idempotency_key() == INTERPRETATION_RUN_LOOKUP_PREFIX { + return Err(OrchestratorLiveError::InvalidWirePayload); + } let idempotency_key = header_value(headers, "idempotency-key")?; if idempotency_key != request.idempotency_key() { return Err(OrchestratorLiveError::InvalidWirePayload); @@ -332,7 +448,7 @@ struct ErrorWire { #[cfg(test)] mod tests { - use super::{envelope_json, fallback_envelope_json, OrchestratorLiveService}; + use super::{OrchestratorLiveService, envelope_json, fallback_envelope_json}; use crate::error::OrchestratorLiveError; #[test] @@ -346,11 +462,13 @@ mod tests { envelope_json(OrchestratorLiveError::LimitExceeded, "req-1".into()) .contains("limit_exceeded") ); - assert!(envelope_json( - OrchestratorLiveError::ScientificAuthorityRefused, - "req-2".into() - ) - .contains("scientific_authority_refused")); + assert!( + envelope_json( + OrchestratorLiveError::ScientificAuthorityRefused, + "req-2".into() + ) + .contains("scientific_authority_refused") + ); assert_eq!( OrchestratorLiveService::new() .serve_accepted(Err(std::io::Error::other("accept"))) diff --git a/crates/orchestrator_live/tests/interpretation_run_lookup_cli_contract.rs b/crates/orchestrator_live/tests/interpretation_run_lookup_cli_contract.rs new file mode 100644 index 000000000..0b17f8eca --- /dev/null +++ b/crates/orchestrator_live/tests/interpretation_run_lookup_cli_contract.rs @@ -0,0 +1,145 @@ +//! Contract tests for `tepp-interpretation-run-lookup lookup`. + +use orchestrator_live::{ + CONTEXTUAL_ORCHESTRATOR_CONSUMER_CODE, INTERPRETATION_RUN_CONTRACT_VERSION, + InterpretationRunAccepted, InterpretationRunCliInvocation, + InterpretationRunLookupCliInvocation, InterpretationRunRequest, OrchestrationMode, + OrchestratorLiveError, OrchestratorLiveResponse, OrchestratorLiveService, + compose_interpretation_run_cli_http, dispatch_interpretation_run_lookup_cli, + execute_interpretation_run_lookup_cli, render_interpretation_run_lookup_cli_stdout, +}; + +const ORIGIN: &str = "https://tepp.example.test"; + +fn query_body(idem: &str) -> String { + InterpretationRunRequest::new( + INTERPRETATION_RUN_CONTRACT_VERSION, + idem, + "orch-tenant-demo", + "tepp-snapshot-demo-001", + "2026-08-01T00:00:00Z", + OrchestrationMode::Direct, + 2048, + vec!["span-001".into()], + false, + ) + .expect("request") + .to_json() + .expect("json") +} + +fn create_http(idem: &str) -> String { + let invocation = InterpretationRunCliInvocation::from_args( + [ + "create", + "--host", + "127.0.0.1:18082", + "--origin", + ORIGIN, + "--consumer", + CONTEXTUAL_ORCHESTRATOR_CONSUMER_CODE, + ], + query_body(idem), + ) + .expect("create"); + compose_interpretation_run_cli_http(&invocation).expect("post") +} + +fn lookup_invocation(run_id: &str) -> InterpretationRunLookupCliInvocation { + InterpretationRunLookupCliInvocation::from_args( + [ + "lookup", + "--host", + "127.0.0.1:18082", + "--origin", + ORIGIN, + "--interpretation-run-id", + run_id, + ], + "", + ) + .expect("lookup") +} + +#[test] +fn dispatch_retrieves_metric_free_identity_without_scientific_authority() { + let mut service = OrchestratorLiveService::new(); + let created = service.handle_http_request(&create_http("idem-a")); + assert_eq!(created.status_code, 202, "{}", created.body); + let accepted = InterpretationRunAccepted::from_json(&created.body).expect("accepted"); + let run_id = accepted.interpretation_run_id().to_owned(); + let got = dispatch_interpretation_run_lookup_cli(&mut service, &lookup_invocation(&run_id)) + .expect("lookup"); + assert_eq!(got.status_code, 200, "{}", got.body); + let stdout = render_interpretation_run_lookup_cli_stdout(&lookup_invocation(&run_id), &got) + .expect("out"); + assert!(!stdout.contains("tepp.scientific_acceptance.v1")); + assert!(!stdout.contains("rmse")); + assert!(!stdout.contains("causal_score")); + assert!(!stdout.contains("evidence_span_ids")); + assert!(stdout.contains(&format!("\"interpretation_run_id\":\"{run_id}\""))); + assert!(stdout.contains("\"idempotency_key\":\"idem-a\"")); + assert!(stdout.contains("\"claim_status\":\"hypothetical\"")); + assert!(stdout.contains("\"scientific_authority\":false")); + assert_eq!( + dispatch_interpretation_run_lookup_cli(&mut service, &lookup_invocation("missing")) + .expect("missing") + .status_code, + 400 + ); +} + +#[test] +fn render_refuses_metrics_schema_and_empty_bodies() { + let lookup = lookup_invocation("orch-run-1"); + assert_eq!( + render_interpretation_run_lookup_cli_stdout( + &lookup, + &OrchestratorLiveResponse { + status_code: 200, + reason_phrase: "OK", + body: String::new(), + } + ) + .unwrap_err(), + OrchestratorLiveError::InvalidWirePayload + ); + assert_eq!( + render_interpretation_run_lookup_cli_stdout( + &lookup, + &OrchestratorLiveResponse { + status_code: 200, + reason_phrase: "OK", + body: r#"{"interpretation_run_id":"orch-run-1","idempotency_key":"idem-a","orchestration_mode":"direct","claim_status":"hypothetical","scientific_authority":false,"rmse":1.0}"#.into(), + } + ) + .unwrap_err(), + OrchestratorLiveError::InvalidWirePayload + ); + assert_eq!( + render_interpretation_run_lookup_cli_stdout( + &lookup, + &OrchestratorLiveResponse { + status_code: 400, + reason_phrase: "Bad Request", + body: r#"{"error_code":"invalid_wire_payload"}"#.into(), + } + ) + .unwrap_err(), + OrchestratorLiveError::InvalidWirePayload + ); +} + +#[test] +fn execute_over_tcp_returns_missing_identity_as_invalid_wire() { + let mut service = OrchestratorLiveService::bind_loopback().expect("bind"); + let addr = service.local_addr().expect("addr"); + let handle = std::thread::spawn(move || { + drop(service.serve_one()); + }); + let mut invocation = lookup_invocation("orch-run-1"); + invocation.host = addr.to_string(); + let response = execute_interpretation_run_lookup_cli(&invocation).expect("tcp"); + assert_eq!(response.status_code, 400, "{}", response.body); + handle.join().expect("join"); +} diff --git a/crates/orchestrator_live/tests/interpretation_run_lookup_http_contract.rs b/crates/orchestrator_live/tests/interpretation_run_lookup_http_contract.rs new file mode 100644 index 000000000..33a0fdb54 --- /dev/null +++ b/crates/orchestrator_live/tests/interpretation_run_lookup_http_contract.rs @@ -0,0 +1,146 @@ +//! Contract tests for contextual-orchestrator interpretation-run lookup GET. + +use std::io::{Read, Write}; + +use orchestrator_live::{ + CONTEXTUAL_ORCHESTRATOR_CONSUMER_CODE, INTERPRETATION_RUN_CONTRACT_VERSION, + INTERPRETATION_RUN_LOOKUP_PREFIX, INTERPRETATION_RUN_PATH, InterpretationRunRequest, + OrchestrationMode, OrchestratorLiveError, OrchestratorLiveService, + contextual_orchestrator_interpretation_run_lookup_exchange, interpretation_run_lookup_path_id, + interpretation_run_retrieval_path_id, is_interpretation_run_lookup_path, +}; + +fn sample_request() -> InterpretationRunRequest { + InterpretationRunRequest::new( + INTERPRETATION_RUN_CONTRACT_VERSION, + "orch-live-idem-001", + "orch-tenant-workspace-demo", + "tepp-snapshot-demo-001", + "2026-08-01T00:00:00Z", + OrchestrationMode::Direct, + 2048, + vec!["span-001".into()], + false, + ) + .expect("sample") +} + +fn post_http(request: &InterpretationRunRequest) -> String { + let body = request.to_json().expect("json"); + format!( + "POST {INTERPRETATION_RUN_PATH} HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: {CONTEXTUAL_ORCHESTRATOR_CONSUMER_CODE}\r\ntepp-contract-version: 1\r\nidempotency-key: {}\r\ncontent-length: {}\r\n\r\n{body}", + request.idempotency_key(), + body.len() + ) +} + +#[test] +fn lookup_exchange_is_metric_free_get_without_credentials() { + let exchange = contextual_orchestrator_interpretation_run_lookup_exchange( + "https://tepp.example.test", + "orch-run-1", + ) + .expect("exchange"); + assert_eq!(exchange.method, "GET"); + assert!( + exchange + .target_url + .ends_with("/v1/interpretation-runs/by-run-id/orch-run-1") + ); + assert!(exchange.body.is_empty()); + assert_eq!( + interpretation_run_lookup_path_id("/v1/interpretation-runs/by-run-id/orch-run-1") + .expect("id"), + "orch-run-1" + ); + assert!(!is_interpretation_run_lookup_path( + "/v1/interpretation-runs/orch-run-1" + )); + assert_eq!(INTERPRETATION_RUN_LOOKUP_PREFIX, "by-run-id"); +} + +#[test] +fn live_get_returns_metric_free_identity_for_unique_run_id() { + let request = sample_request(); + let mut service = OrchestratorLiveService::new(); + let accepted = service.handle_http_request(&post_http(&request)); + assert_eq!(accepted.status_code, 202, "{}", accepted.body); + assert!( + accepted + .body + .contains("\"interpretation_run_id\":\"orch-run-1\"") + ); + let got = service.handle_http_request( + "GET /v1/interpretation-runs/by-run-id/orch-run-1 HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: contextual-orchestrator\r\ntepp-contract-version: 1\r\ncontent-length: 0\r\n\r\n", + ); + assert_eq!(got.status_code, 200, "{}", got.body); + assert!( + got.body + .contains("\"interpretation_run_id\":\"orch-run-1\"") + ); + assert!( + got.body + .contains("\"idempotency_key\":\"orch-live-idem-001\"") + ); + assert!(got.body.contains("\"claim_status\":\"hypothetical\"")); + assert!(got.body.contains("\"scientific_authority\":false")); + assert!(!got.body.contains("tepp.scientific_acceptance.v1")); + assert!(!got.body.contains("rmse")); + assert!(!got.body.contains("evidence_span_ids")); + assert_eq!( + service + .handle_http_request( + "GET /v1/interpretation-runs/by-run-id/orch-run-missing HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: contextual-orchestrator\r\ntepp-contract-version: 1\r\ncontent-length: 0\r\n\r\n" + ) + .status_code, + 400 + ); + assert_eq!( + service + .handle_http_request( + "GET /v1/interpretation-runs/by-run-id/orch-run-1 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/interpretation-runs/by-run-id/orch-run-1 HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: contextual-orchestrator\r\ntepp-contract-version: 1\r\ncontent-length: 1\r\n\r\nx" + ) + .status_code, + 400 + ); + assert_eq!( + interpretation_run_retrieval_path_id("/v1/interpretation-runs/by-run-id"), + Err(OrchestratorLiveError::InvalidWirePayload) + ); +} + +#[test] +fn lookup_serves_over_tcp() { + let request = sample_request(); + let mut service = OrchestratorLiveService::bind_loopback().expect("bind"); + assert_eq!( + service + .handle_http_request(&post_http(&request)) + .status_code, + 202 + ); + let addr = service.local_addr().expect("addr"); + let handle = std::thread::spawn(move || { + drop(service.serve_one()); + }); + let http = "GET /v1/interpretation-runs/by-run-id/orch-run-1 HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: contextual-orchestrator\r\ntepp-contract-version: 1\r\ncontent-length: 0\r\n\r\n"; + let mut stream = std::net::TcpStream::connect(addr).expect("connect"); + stream.write_all(http.as_bytes()).expect("write"); + stream.flush().expect("flush"); + let mut bytes = Vec::new(); + stream.read_to_end(&mut bytes).expect("read"); + let text = String::from_utf8(bytes).expect("utf8"); + assert!(text.contains("HTTP/1.1 200"), "{text}"); + assert!(text.contains("orch-run-1"), "{text}"); + assert!(text.contains("\"scientific_authority\":false"), "{text}"); + assert!(text.contains("\"claim_status\":\"hypothetical\""), "{text}"); + handle.join().expect("join"); +} diff --git a/crates/orchestrator_live/tests/interpretation_run_lookup_stored_request_cli_contract.rs b/crates/orchestrator_live/tests/interpretation_run_lookup_stored_request_cli_contract.rs new file mode 100644 index 000000000..dca419e5d --- /dev/null +++ b/crates/orchestrator_live/tests/interpretation_run_lookup_stored_request_cli_contract.rs @@ -0,0 +1,152 @@ +//! Contract tests for `tepp-interpretation-run-lookup-request get`. + +use orchestrator_live::{ + CONTEXTUAL_ORCHESTRATOR_CONSUMER_CODE, INTERPRETATION_RUN_CONTRACT_VERSION, + InterpretationRunAccepted, InterpretationRunCliInvocation, + InterpretationRunLookupStoredRequestCliInvocation, InterpretationRunRequest, OrchestrationMode, + OrchestratorLiveError, OrchestratorLiveResponse, OrchestratorLiveService, + compose_interpretation_run_cli_http, dispatch_interpretation_run_lookup_stored_request_cli, + execute_interpretation_run_lookup_stored_request_cli, + render_interpretation_run_lookup_stored_request_cli_stdout, +}; + +const ORIGIN: &str = "https://tepp.example.test"; + +fn query_body(idem: &str) -> String { + InterpretationRunRequest::new( + INTERPRETATION_RUN_CONTRACT_VERSION, + idem, + "orch-tenant-demo", + "tepp-snapshot-demo-001", + "2026-08-01T00:00:00Z", + OrchestrationMode::Direct, + 2048, + vec!["span-001".into()], + false, + ) + .expect("request") + .to_json() + .expect("json") +} + +fn create_http(idem: &str) -> String { + let invocation = InterpretationRunCliInvocation::from_args( + [ + "create", + "--host", + "127.0.0.1:18082", + "--origin", + ORIGIN, + "--consumer", + CONTEXTUAL_ORCHESTRATOR_CONSUMER_CODE, + ], + query_body(idem), + ) + .expect("create"); + compose_interpretation_run_cli_http(&invocation).expect("post") +} + +fn get_invocation(run_id: &str) -> InterpretationRunLookupStoredRequestCliInvocation { + InterpretationRunLookupStoredRequestCliInvocation::from_args( + [ + "get", + "--host", + "127.0.0.1:18082", + "--origin", + ORIGIN, + "--interpretation-run-id", + run_id, + ], + "", + ) + .expect("get") +} + +#[test] +fn dispatch_retrieves_stored_request_without_scientific_authority() { + let mut service = OrchestratorLiveService::new(); + let created = service.handle_http_request(&create_http("idem-a")); + assert_eq!(created.status_code, 202, "{}", created.body); + let accepted = InterpretationRunAccepted::from_json(&created.body).expect("accepted"); + let run_id = accepted.interpretation_run_id().to_owned(); + let got = dispatch_interpretation_run_lookup_stored_request_cli( + &mut service, + &get_invocation(&run_id), + ) + .expect("get"); + assert_eq!(got.status_code, 200, "{}", got.body); + let stdout = + render_interpretation_run_lookup_stored_request_cli_stdout(&get_invocation(&run_id), &got) + .expect("out"); + assert!(!stdout.contains("tepp.scientific_acceptance.v1")); + assert!(!stdout.contains("rmse")); + assert!(!stdout.contains("causal_score")); + assert!(stdout.contains("\"idempotency_key\":\"idem-a\"")); + assert!(stdout.contains("\"scientific_authority\":false")); + let stored = InterpretationRunRequest::from_json(&stdout).expect("stored"); + assert_eq!(stored.idempotency_key(), "idem-a"); + assert_eq!( + dispatch_interpretation_run_lookup_stored_request_cli( + &mut service, + &get_invocation("missing") + ) + .expect("missing") + .status_code, + 400 + ); +} + +#[test] +fn render_refuses_metrics_schema_and_empty_bodies() { + let get = get_invocation("orch-run-1"); + assert_eq!( + render_interpretation_run_lookup_stored_request_cli_stdout( + &get, + &OrchestratorLiveResponse { + status_code: 200, + reason_phrase: "OK", + body: String::new(), + } + ) + .unwrap_err(), + OrchestratorLiveError::InvalidWirePayload + ); + assert_eq!( + render_interpretation_run_lookup_stored_request_cli_stdout( + &get, + &OrchestratorLiveResponse { + status_code: 200, + reason_phrase: "OK", + body: r#"{"contract_version":1,"idempotency_key":"idem-a","tenant_workspace_id":"orch-tenant-demo","snapshot_id":"tepp-snapshot-demo-001","knowledge_cutoff":"2026-08-01T00:00:00Z","orchestration_mode":"direct","compute_budget_tokens":2048,"evidence_span_ids":["span-001"],"scientific_authority":false,"rmse":1.0}"#.into(), + } + ) + .unwrap_err(), + OrchestratorLiveError::InvalidWirePayload + ); + assert_eq!( + render_interpretation_run_lookup_stored_request_cli_stdout( + &get, + &OrchestratorLiveResponse { + status_code: 400, + reason_phrase: "Bad Request", + body: r#"{"error_code":"invalid_wire_payload"}"#.into(), + } + ) + .unwrap_err(), + OrchestratorLiveError::InvalidWirePayload + ); +} + +#[test] +fn execute_over_tcp_returns_missing_identity_as_invalid_wire() { + let mut service = OrchestratorLiveService::bind_loopback().expect("bind"); + let addr = service.local_addr().expect("addr"); + let handle = std::thread::spawn(move || { + drop(service.serve_one()); + }); + let mut invocation = get_invocation("orch-run-1"); + invocation.host = addr.to_string(); + let response = execute_interpretation_run_lookup_stored_request_cli(&invocation).expect("tcp"); + assert_eq!(response.status_code, 400, "{}", response.body); + handle.join().expect("join"); +} diff --git a/crates/orchestrator_live/tests/interpretation_run_lookup_stored_request_http_contract.rs b/crates/orchestrator_live/tests/interpretation_run_lookup_stored_request_http_contract.rs new file mode 100644 index 000000000..591233b2a --- /dev/null +++ b/crates/orchestrator_live/tests/interpretation_run_lookup_stored_request_http_contract.rs @@ -0,0 +1,143 @@ +//! Contract tests for contextual-orchestrator lookup stored-request GET. + +use std::io::{Read, Write}; + +use orchestrator_live::{ + CONTEXTUAL_ORCHESTRATOR_CONSUMER_CODE, INTERPRETATION_RUN_CONTRACT_VERSION, + INTERPRETATION_RUN_PATH, InterpretationRunLookupStoredRequestPayload, InterpretationRunRequest, + OrchestrationMode, OrchestratorLiveService, + contextual_orchestrator_interpretation_run_lookup_stored_request_exchange, + interpretation_run_lookup_stored_request_path_id, is_interpretation_run_lookup_path, + is_interpretation_run_lookup_stored_request_path, is_interpretation_run_stored_request_path, +}; + +fn sample_request() -> InterpretationRunRequest { + InterpretationRunRequest::new( + INTERPRETATION_RUN_CONTRACT_VERSION, + "orch-live-idem-001", + "orch-tenant-workspace-demo", + "tepp-snapshot-demo-001", + "2026-08-01T00:00:00Z", + OrchestrationMode::Direct, + 2048, + vec!["span-001".into()], + false, + ) + .expect("sample") +} + +fn post_http(request: &InterpretationRunRequest) -> String { + let body = request.to_json().expect("json"); + format!( + "POST {INTERPRETATION_RUN_PATH} HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: {CONTEXTUAL_ORCHESTRATOR_CONSUMER_CODE}\r\ntepp-contract-version: 1\r\nidempotency-key: {}\r\ncontent-length: {}\r\n\r\n{body}", + request.idempotency_key(), + body.len() + ) +} + +#[test] +fn lookup_stored_request_exchange_is_metric_free_get_without_credentials() { + let exchange = contextual_orchestrator_interpretation_run_lookup_stored_request_exchange( + "https://tepp.example.test", + "orch-run-1", + ) + .expect("exchange"); + assert_eq!(exchange.method, "GET"); + assert!( + exchange + .target_url + .ends_with("/v1/interpretation-runs/by-run-id/orch-run-1/request") + ); + assert!(exchange.body.is_empty()); + assert_eq!( + interpretation_run_lookup_stored_request_path_id( + "/v1/interpretation-runs/by-run-id/orch-run-1/request" + ) + .expect("id"), + "orch-run-1" + ); + assert!(is_interpretation_run_lookup_stored_request_path( + "/v1/interpretation-runs/by-run-id/orch-run-1/request" + )); + assert!(!is_interpretation_run_lookup_path( + "/v1/interpretation-runs/by-run-id/orch-run-1/request" + )); + assert!(!is_interpretation_run_stored_request_path( + "/v1/interpretation-runs/by-run-id/orch-run-1/request" + )); +} + +#[test] +fn live_get_returns_stored_request_without_scientific_authority() { + let request = sample_request(); + let mut service = OrchestratorLiveService::new(); + let accepted = service.handle_http_request(&post_http(&request)); + assert_eq!(accepted.status_code, 202, "{}", accepted.body); + assert!( + accepted + .body + .contains("\"interpretation_run_id\":\"orch-run-1\"") + ); + let got = service.handle_http_request( + "GET /v1/interpretation-runs/by-run-id/orch-run-1/request HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: contextual-orchestrator\r\ntepp-contract-version: 1\r\ncontent-length: 0\r\n\r\n", + ); + assert_eq!(got.status_code, 200, "{}", got.body); + let stored_payload = + InterpretationRunLookupStoredRequestPayload::from_json(&got.body).expect("stored"); + assert_eq!(stored_payload.interpretation_run_id(), "orch-run-1"); + assert_eq!(stored_payload.request(), &request); + assert!(!got.body.contains("tepp.scientific_acceptance.v1")); + assert!(!got.body.contains("rmse")); + assert!(got.body.contains("\"scientific_authority\":false")); + assert_eq!( + service + .handle_http_request( + "GET /v1/interpretation-runs/by-run-id/orch-run-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" + ) + .status_code, + 400 + ); + assert_eq!( + service + .handle_http_request( + "GET /v1/interpretation-runs/by-run-id/missing/request HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: contextual-orchestrator\r\ntepp-contract-version: 1\r\ncontent-length: 0\r\n\r\n" + ) + .status_code, + 400 + ); + assert_eq!( + service + .handle_http_request( + "GET /v1/interpretation-runs/by-run-id/orch-run-1/cancel HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: contextual-orchestrator\r\ntepp-contract-version: 1\r\ncontent-length: 0\r\n\r\n" + ) + .status_code, + 400 + ); +} + +#[test] +fn lookup_stored_request_serves_over_tcp() { + let request = sample_request(); + let mut service = OrchestratorLiveService::bind_loopback().expect("bind"); + assert_eq!( + service + .handle_http_request(&post_http(&request)) + .status_code, + 202 + ); + let addr = service.local_addr().expect("addr"); + let handle = std::thread::spawn(move || { + drop(service.serve_one()); + }); + let http = "GET /v1/interpretation-runs/by-run-id/orch-run-1/request HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: contextual-orchestrator\r\ntepp-contract-version: 1\r\ncontent-length: 0\r\n\r\n"; + let mut stream = std::net::TcpStream::connect(addr).expect("connect"); + stream.write_all(http.as_bytes()).expect("write"); + stream.flush().expect("flush"); + let mut bytes = Vec::new(); + stream.read_to_end(&mut bytes).expect("read"); + let text = String::from_utf8(bytes).expect("utf8"); + assert!(text.contains("HTTP/1.1 200"), "{text}"); + assert!(text.contains("orch-live-idem-001"), "{text}"); + assert!(text.contains("\"scientific_authority\":false"), "{text}"); + handle.join().expect("join"); +} diff --git a/crates/orchestrator_live/tests/interpretation_run_route_integrity_contract.rs b/crates/orchestrator_live/tests/interpretation_run_route_integrity_contract.rs new file mode 100644 index 000000000..e75e05255 --- /dev/null +++ b/crates/orchestrator_live/tests/interpretation_run_route_integrity_contract.rs @@ -0,0 +1,104 @@ +//! Interpretation-run route identities fail closed before they become unreachable. + +use orchestrator_live::{ + INTERPRETATION_RUN_CONTRACT_VERSION, INTERPRETATION_RUN_LOOKUP_ID_MAX_LEN, + INTERPRETATION_RUN_PATH, InterpretationRunAccepted, InterpretationRunLookupStoredRequestCliInvocation, + InterpretationRunRequest, OrchestrationMode, OrchestratorLiveError, OrchestratorLiveService, + dispatch_interpretation_run_lookup_stored_request_cli, + render_interpretation_run_lookup_stored_request_cli_stdout, +}; + +fn interpretation_request(idempotency_key: &str, snapshot_id: &str) -> InterpretationRunRequest { + InterpretationRunRequest::new( + INTERPRETATION_RUN_CONTRACT_VERSION, + idempotency_key, + "tenant-a", + snapshot_id, + "2026-09-01T00:00:00Z", + OrchestrationMode::Direct, + 128, + vec!["span-a".into()], + false, + ) + .expect("valid request") +} + +fn post_request(request: &InterpretationRunRequest) -> String { + let body = request.to_json().expect("request JSON"); + format!( + "POST {INTERPRETATION_RUN_PATH} HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: contextual-orchestrator\r\ntepp-contract-version: 1\r\nidempotency-key: {}\r\ncontent-length: {}\r\n\r\n{body}", + request.idempotency_key(), + body.len() + ) +} + +fn accept( + service: &mut OrchestratorLiveService, + request: &InterpretationRunRequest, +) -> InterpretationRunAccepted { + let response = service.handle_http_request(&post_request(request)); + assert_eq!(response.status_code, 202); + InterpretationRunAccepted::from_json(&response.body).expect("accepted") +} + +fn lookup_request_invocation(run_id: &str) -> InterpretationRunLookupStoredRequestCliInvocation { + InterpretationRunLookupStoredRequestCliInvocation::from_args( + [ + "get", + "--host", + "127.0.0.1:41414", + "--origin", + "https://tepp.example.test", + "--consumer", + "contextual-orchestrator", + "--interpretation-run-id", + run_id, + ], + "", + ) + .expect("lookup invocation") +} + +#[test] +fn service_refuses_reserved_lookup_segment_before_acceptance() { + let request = interpretation_request("by-run-id", "snapshot-reserved"); + let response = OrchestratorLiveService::new().handle_http_request(&post_request(&request)); + assert_eq!(response.status_code, 400); + assert!(response.body.contains("invalid_wire_payload")); +} + +#[test] +fn oversized_lookup_stored_request_identity_preserves_limit_status() { + let oversized = "a".repeat(INTERPRETATION_RUN_LOOKUP_ID_MAX_LEN + 1); + let request = format!( + "GET /v1/interpretation-runs/by-run-id/{oversized}/request HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: contextual-orchestrator\r\ntepp-contract-version: 1\r\ncontent-length: 0\r\n\r\n" + ); + + let response = OrchestratorLiveService::new().handle_http_request(&request); + assert_eq!(response.status_code, 413); + assert!(response.body.contains("limit_exceeded")); +} + +#[test] +fn lookup_request_cli_rejects_response_bound_to_another_run() { + let mut service = OrchestratorLiveService::new(); + let first_request = interpretation_request("idem-first", "snapshot-first"); + let second_request = interpretation_request("idem-second", "snapshot-second"); + let first = accept(&mut service, &first_request); + let second = accept(&mut service, &second_request); + + let first_invocation = lookup_request_invocation(first.interpretation_run_id()); + let second_invocation = lookup_request_invocation(second.interpretation_run_id()); + let second_response = + dispatch_interpretation_run_lookup_stored_request_cli(&mut service, &second_invocation) + .expect("dispatch"); + assert_eq!(second_response.status_code, 200); + + assert_eq!( + render_interpretation_run_lookup_stored_request_cli_stdout( + &first_invocation, + &second_response, + ), + Err(OrchestratorLiveError::InvalidWirePayload) + ); +} diff --git a/crates/orchestrator_live/tests/interpretation_run_stored_request_cli_contract.rs b/crates/orchestrator_live/tests/interpretation_run_stored_request_cli_contract.rs new file mode 100644 index 000000000..f65ee0103 --- /dev/null +++ b/crates/orchestrator_live/tests/interpretation_run_stored_request_cli_contract.rs @@ -0,0 +1,146 @@ +//! Contract tests for `tepp-interpretation-run-request get`. + +use orchestrator_live::{ + compose_interpretation_run_cli_http, dispatch_interpretation_run_stored_request_cli, + execute_interpretation_run_stored_request_cli, render_interpretation_run_stored_request_cli_stdout, + InterpretationRunCliInvocation, InterpretationRunRequest, + InterpretationRunStoredRequestCliInvocation, OrchestrationMode, OrchestratorLiveError, + OrchestratorLiveResponse, OrchestratorLiveService, CONTEXTUAL_ORCHESTRATOR_CONSUMER_CODE, + INTERPRETATION_RUN_CONTRACT_VERSION, +}; + +const ORIGIN: &str = "https://tepp.example.test"; + +fn query_body(idem: &str) -> String { + InterpretationRunRequest::new( + INTERPRETATION_RUN_CONTRACT_VERSION, + idem, + "orch-tenant-demo", + "tepp-snapshot-demo-001", + "2026-08-01T00:00:00Z", + OrchestrationMode::Direct, + 2048, + vec!["span-001".into()], + false, + ) + .expect("request") + .to_json() + .expect("json") +} + +fn create_http(idem: &str) -> String { + let invocation = InterpretationRunCliInvocation::from_args( + [ + "create", + "--host", + "127.0.0.1:18082", + "--origin", + ORIGIN, + "--consumer", + CONTEXTUAL_ORCHESTRATOR_CONSUMER_CODE, + ], + query_body(idem), + ) + .expect("create"); + compose_interpretation_run_cli_http(&invocation).expect("post") +} + +fn get_invocation(idem: &str) -> InterpretationRunStoredRequestCliInvocation { + InterpretationRunStoredRequestCliInvocation::from_args( + [ + "get", + "--host", + "127.0.0.1:18082", + "--origin", + ORIGIN, + "--idempotency-key", + idem, + ], + "", + ) + .expect("get") +} + +#[test] +fn dispatch_retrieves_stored_request_without_scientific_authority() { + let mut service = OrchestratorLiveService::new(); + assert_eq!( + service + .handle_http_request(&create_http("idem-a")) + .status_code, + 202 + ); + let got = dispatch_interpretation_run_stored_request_cli(&mut service, &get_invocation("idem-a")) + .expect("get"); + assert_eq!(got.status_code, 200, "{}", got.body); + let stdout = + render_interpretation_run_stored_request_cli_stdout(&get_invocation("idem-a"), &got) + .expect("out"); + assert!(!stdout.contains("tepp.scientific_acceptance.v1")); + assert!(!stdout.contains("rmse")); + assert!(!stdout.contains("causal_score")); + let stored = InterpretationRunRequest::from_json(&stdout).expect("stored"); + assert_eq!(stored.idempotency_key(), "idem-a"); + assert!(stdout.contains("\"scientific_authority\":false")); + assert_eq!( + dispatch_interpretation_run_stored_request_cli(&mut service, &get_invocation("missing")) + .expect("missing") + .status_code, + 400 + ); +} + +#[test] +fn render_refuses_metrics_schema_and_empty_bodies() { + let get = get_invocation("idem-a"); + assert_eq!( + render_interpretation_run_stored_request_cli_stdout( + &get, + &OrchestratorLiveResponse { + status_code: 200, + reason_phrase: "OK", + body: String::new(), + } + ) + .unwrap_err(), + OrchestratorLiveError::InvalidWirePayload + ); + assert_eq!( + render_interpretation_run_stored_request_cli_stdout( + &get, + &OrchestratorLiveResponse { + status_code: 200, + reason_phrase: "OK", + body: r#"{"contract_version":1,"idempotency_key":"idem-a","tenant_workspace_id":"t","snapshot_id":"s","knowledge_cutoff":"k","orchestration_mode":"direct","compute_budget_tokens":8,"evidence_span_ids":["span-001"],"scientific_authority":false,"rmse":1.0}"#.into(), + } + ) + .unwrap_err(), + OrchestratorLiveError::InvalidWirePayload + ); + assert_eq!( + render_interpretation_run_stored_request_cli_stdout( + &get, + &OrchestratorLiveResponse { + status_code: 400, + reason_phrase: "Bad Request", + body: r#"{"error_code":"invalid_wire_payload"}"#.into(), + } + ) + .unwrap_err(), + OrchestratorLiveError::InvalidWirePayload + ); +} + +#[test] +fn execute_over_tcp_returns_missing_identity_as_invalid_wire() { + let mut service = OrchestratorLiveService::bind_loopback().expect("bind"); + let addr = service.local_addr().expect("addr"); + let handle = std::thread::spawn(move || { + drop(service.serve_one()); + }); + let mut invocation = get_invocation("idem-a"); + invocation.host = addr.to_string(); + let response = execute_interpretation_run_stored_request_cli(&invocation).expect("tcp"); + assert_eq!(response.status_code, 400, "{}", response.body); + handle.join().expect("join"); +} diff --git a/crates/orchestrator_live/tests/interpretation_run_stored_request_http_contract.rs b/crates/orchestrator_live/tests/interpretation_run_stored_request_http_contract.rs new file mode 100644 index 000000000..cf87006e2 --- /dev/null +++ b/crates/orchestrator_live/tests/interpretation_run_stored_request_http_contract.rs @@ -0,0 +1,114 @@ +//! Contract tests for contextual-orchestrator interpretation-run stored-request GET. + +use std::io::{Read, Write}; + +use orchestrator_live::{ + contextual_orchestrator_interpretation_run_stored_request_exchange, + interpretation_run_stored_request_path_id, is_interpretation_run_stored_request_path, + InterpretationRunRequest, OrchestrationMode, OrchestratorLiveError, OrchestratorLiveService, + CONTEXTUAL_ORCHESTRATOR_CONSUMER_CODE, INTERPRETATION_RUN_CONTRACT_VERSION, + INTERPRETATION_RUN_PATH, +}; + +fn sample_request() -> InterpretationRunRequest { + InterpretationRunRequest::new( + INTERPRETATION_RUN_CONTRACT_VERSION, + "orch-live-idem-001", + "orch-tenant-workspace-demo", + "tepp-snapshot-demo-001", + "2026-08-01T00:00:00Z", + OrchestrationMode::Direct, + 2048, + vec!["span-001".into()], + false, + ) + .expect("sample") +} + +fn post_http(request: &InterpretationRunRequest) -> String { + let body = request.to_json().expect("json"); + format!( + "POST {INTERPRETATION_RUN_PATH} HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: {CONTEXTUAL_ORCHESTRATOR_CONSUMER_CODE}\r\ntepp-contract-version: 1\r\nidempotency-key: {}\r\ncontent-length: {}\r\n\r\n{body}", + request.idempotency_key(), + body.len() + ) +} + +#[test] +fn stored_request_exchange_is_metric_free_get_without_credentials() { + let exchange = contextual_orchestrator_interpretation_run_stored_request_exchange( + "https://tepp.example.test", + "orch-live-idem-001", + ) + .expect("exchange"); + assert_eq!(exchange.method, "GET"); + assert!(exchange + .target_url + .ends_with("/v1/interpretation-runs/orch-live-idem-001/request")); + assert!(exchange.body.is_empty()); + assert_eq!( + interpretation_run_stored_request_path_id( + "/v1/interpretation-runs/orch-live-idem-001/request" + ) + .expect("id"), + "orch-live-idem-001" + ); + assert!(!is_interpretation_run_stored_request_path( + "/v1/interpretation-runs/orch-live-idem-001" + )); +} + +#[test] +fn live_get_returns_stored_request_without_scientific_authority() { + let request = sample_request(); + let mut service = OrchestratorLiveService::new(); + assert_eq!( + service.handle_http_request(&post_http(&request)).status_code, + 202 + ); + let got = service.handle_http_request( + "GET /v1/interpretation-runs/orch-live-idem-001/request HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: contextual-orchestrator\r\ntepp-contract-version: 1\r\ncontent-length: 0\r\n\r\n", + ); + assert_eq!(got.status_code, 200, "{}", got.body); + let stored = InterpretationRunRequest::from_json(&got.body).expect("stored"); + assert_eq!(stored, request); + assert!(!got.body.contains("tepp.scientific_acceptance.v1")); + assert!(!got.body.contains("rmse")); + assert_eq!( + service + .handle_http_request( + "GET /v1/interpretation-runs/orch-live-idem-001/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!( + interpretation_run_stored_request_path_id("/v1/interpretation-runs/idem-a/cancel"), + Err(OrchestratorLiveError::InvalidWirePayload) + ); +} + +#[test] +fn stored_request_serves_over_tcp() { + let request = sample_request(); + let mut service = OrchestratorLiveService::bind_loopback().expect("bind"); + assert_eq!( + service.handle_http_request(&post_http(&request)).status_code, + 202 + ); + let addr = service.local_addr().expect("addr"); + let handle = std::thread::spawn(move || { + drop(service.serve_one()); + }); + let http = "GET /v1/interpretation-runs/orch-live-idem-001/request HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: contextual-orchestrator\r\ntepp-contract-version: 1\r\ncontent-length: 0\r\n\r\n"; + let mut stream = std::net::TcpStream::connect(addr).expect("connect"); + stream.write_all(http.as_bytes()).expect("write"); + stream.flush().expect("flush"); + let mut bytes = Vec::new(); + stream.read_to_end(&mut bytes).expect("read"); + let text = String::from_utf8(bytes).expect("utf8"); + assert!(text.contains("HTTP/1.1 200"), "{text}"); + assert!(text.contains("orch-live-idem-001"), "{text}"); + assert!(text.contains("\"scientific_authority\":false"), "{text}"); + handle.join().expect("join"); +} diff --git a/docs/API_CONTRACT.md b/docs/API_CONTRACT.md index 3698a7377..77bcbc0cb 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; export retrieval stays a target shape until an executable export route ships. Loopback `tepp-interpretation-runs create` is the operator-visible client for `POST /v1/interpretation-runs` on `tepp-orchestrator-loopback` (ADR 0064); stdout stays metric-free with `claim_status` `hypothetical` and `scientific_authority` false. Loopback `GET /v1/interpretation-runs` enumerates those accepted hypothetical runs as metric-free identities (ADR 0069); `GET /v1/interpretation-runs/{idempotency_key}` returns one identity without POST replay (ADR 0071); naruon and LineageWeave stay refused. +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; export retrieval stays a target shape until an executable export route ships. Loopback `tepp-interpretation-runs create` is the operator-visible client for `POST /v1/interpretation-runs` on `tepp-orchestrator-loopback` (ADR 0064); stdout stays metric-free with `claim_status` `hypothetical` and `scientific_authority` false. Loopback `GET /v1/interpretation-runs` enumerates those accepted hypothetical runs as metric-free identities (ADR 0069); `GET /v1/interpretation-runs/{idempotency_key}` returns one identity without POST replay (ADR 0071); `GET /v1/interpretation-runs/by-run-id/{interpretation_run_id}` returns one identity from the server-assigned run id (ADR 0095); naruon and LineageWeave stay refused. ## 2. Contract families @@ -65,6 +65,12 @@ GET /v1/evidence-imports/{import_id} POST /v1/interpretation-runs GET /v1/interpretation-runs GET /v1/interpretation-runs/{idempotency_key} +GET /v1/interpretation-runs/{idempotency_key}/request +GET /v1/interpretation-runs/by-run-id/{interpretation_run_id} +GET /v1/interpretation-runs/by-run-id/{interpretation_run_id}/request +Loopback `tepp-interpretation-run-request get` mints that stored-request GET onto spawned `tepp-orchestrator-loopback` TCP. +Loopback `tepp-interpretation-run-lookup lookup` mints that lookup GET onto spawned `tepp-orchestrator-loopback` TCP. +Loopback `tepp-interpretation-run-lookup-request get` mints that lookup stored-request GET onto spawned `tepp-orchestrator-loopback` TCP. POST /v1/analysis-runs POST /v1/temporal-context GET /v1/analysis-runs/{run_id} diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 4f128ab0f..63cd95065 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -109,6 +109,12 @@ The full APA 7th standards/literature register remains `docs/research/standards- | loopback contextual-orchestrator interpretation-run CLI | ADR 0064; ADR 0010/0011; API contract; RFC 9110 | `orchestrator_live` `tepp-interpretation-runs create` CLI against `tepp-orchestrator-loopback` (`POST /v1/interpretation-runs`); metric-free hypothetical JSON; `tepp.scientific_acceptance.v1` never appears; does not infer causality; naruon and LineageWeave refused | active-PR | | loopback contextual-orchestrator interpretation-run collection GET | ADR 0069; ADR 0010/0011; API contract; RFC 9110 | `orchestrator_live` `GET /v1/interpretation-runs` on `tepp-orchestrator-loopback`; metric-free hypothetical identities; empty body; no `idempotency-key`; `tepp.scientific_acceptance.v1` never appears; does not infer causality; naruon and LineageWeave refused | active-PR | | loopback contextual-orchestrator interpretation-run GET-by-id | ADR 0071; ADR 0069; ADR 0010/0011; API contract; RFC 9110 | `orchestrator_live` `GET /v1/interpretation-runs/{idempotency_key}` on `tepp-orchestrator-loopback`; metric-free hypothetical identity without POST replay; empty body; no pagination; `tepp.scientific_acceptance.v1` never appears; does not infer causality; naruon and LineageWeave refused | active-PR | +| loopback contextual-orchestrator interpretation-run stored-request GET | ADR 0085; ADR 0071; ADR 0010/0011; API contract; RFC 9110 | `orchestrator_live` `GET /v1/interpretation-runs/{idempotency_key}/request` on `tepp-orchestrator-loopback`; returns stored create request; `scientific_authority` remains false; `tepp.scientific_acceptance.v1` never appears; does not infer causality; naruon and LineageWeave refused | active-PR | +| loopback contextual-orchestrator interpretation-run stored-request CLI | ADR 0086; ADR 0085; ADR 0010/0011; API contract; RFC 9110 | published `tepp-interpretation-run-request get` mints stored-request GET onto spawned `tepp-orchestrator-loopback` TCP; `scientific_authority` remains false; empty stdin admitted; naruon and LineageWeave refused | active-PR | +| loopback contextual-orchestrator interpretation-run lookup GET | ADR 0095; ADR 0071; ADR 0010/0011; API contract; RFC 9110 | `orchestrator_live` `GET /v1/interpretation-runs/by-run-id/{interpretation_run_id}` on `tepp-orchestrator-loopback`; metric-free hypothetical identity from server-assigned run id; empty body; 0 and >1 matches fail closed; `tepp.scientific_acceptance.v1` never appears; does not infer causality; naruon and LineageWeave refused | active-PR | +| loopback contextual-orchestrator interpretation-run lookup CLI | ADR 0096; ADR 0095; ADR 0010/0011; API contract; RFC 9110 | published `tepp-interpretation-run-lookup lookup` mints lookup GET onto spawned `tepp-orchestrator-loopback` TCP; metric-free hypothetical identity; empty stdin admitted; naruon and LineageWeave refused | active-PR | +| loopback contextual-orchestrator interpretation-run lookup stored-request GET | ADR 0097; ADR 0095; ADR 0085; ADR 0010/0011; API contract; RFC 9110 | `orchestrator_live` `GET /v1/interpretation-runs/by-run-id/{interpretation_run_id}/request` on `tepp-orchestrator-loopback`; stored create request from server-assigned run id; empty body; 0 and >1 matches fail closed; `tepp.scientific_acceptance.v1` never appears; does not infer causality; naruon and LineageWeave refused | active-PR | +| loopback contextual-orchestrator interpretation-run lookup stored-request CLI | ADR 0098; ADR 0097; ADR 0010/0011; API contract; RFC 9110 | published `tepp-interpretation-run-lookup-request get` mints lookup stored-request GET onto spawned `tepp-orchestrator-loopback` TCP; metric-free stored create; empty stdin admitted; naruon and LineageWeave refused | active-PR | | foundation validation / release-readiness ledger | ADR 0014; Test Strategy | PR #24 `docs/validation/temporal-event-foundation.md` on protected main | implemented-main | | scientific claim promotion separated from design/implementation/release | ADR 0014; ADR policy | `validation_core` exact-head promotion gates on this PR; documentation/CI/domain validation remain; full package/image release bundle remaining | partial | | CSAP/SOC 2/ISO/NIST assurance readiness | `docs/COMPLIANCE_READINESS.md`; research register | repository controls + future deployment evidence | accepted-target / deployment-owned | diff --git a/docs/adr/0085-interpretation-run-stored-request-get.md b/docs/adr/0085-interpretation-run-stored-request-get.md new file mode 100644 index 000000000..2cb36e82d --- /dev/null +++ b/docs/adr/0085-interpretation-run-stored-request-get.md @@ -0,0 +1,57 @@ +# ADR 0085 — Loopback interpretation-run stored-request GET + +**Decision status:** Accepted +**Implementation maturity:** active-PR +**Date:** 2026-09-01 +**Supersedes:** None; complements ADR 0071. Does not re-open cancel lineages. +Does not supersede ADR 0014. Unique versus protected main; 0026–0084 occupied. + +## Context + +ADR 0071 retrieves one accepted interpretation-run identity. Operators still +had no extra-segment GET for the stored create request. Analysis-run +stored-request GET (#377) is naruon-owned. Duplicating GET-by-id (#438), +retrieval CLI (#439), collection GET/CLI, create CLI, Leiden, or GAP-010 +would collide with live PRs. + +## Decision + +Publish `GET /v1/interpretation-runs/{idempotency_key}/request` on +`OrchestratorLiveService`. Extra-segment parse. Slash/NUL fail closed. Empty +body. `scientific_authority` remains false. `tepp.scientific_acceptance.v1` +never appears. Cancel extra-segment stays refused. + +## Alternatives considered + +1. Re-open cancel HTTP — rejected. +2. Return GET-by-id identity — rejected (ADR 0071). +3. Loopback stored-request GET — accepted. + +## Consequences + +HTTP 200 is not measurement evidence and is not an ADR 0014 claim. + +## Failure and recovery + +Non-orchestrator consumers, nonempty bodies, extra segments, slash/NUL, +missing keys, and metric keys fail closed. + +## Verification + +- `GET /v1/interpretation-runs/{idempotency_key}/request` of an accepted run + returns the stored create request without RMSE/`tepp.scientific_acceptance.v1`; +- naruon, LineageWeave, GET-by-id path, extra segments, slash/NUL, nonempty + body, and missing keys fail closed; +- Clippy `-D warnings`, `orchestrator_live` tests, rustdoc, and exact-head + review remain required. + +## Rollback and supersession + +Rollback removes the extra-segment GET; POST and GET-by-id remain valid. A +superseding ADR is required to persist the registry, bind a public address, +re-open cancel, emit scientific-acceptance, open naruon or LineageWeave, add +GET to `NaruonLiveService`, or treat retrieval success as an ADR 0014 claim. + +## Related authority + +ADR 0071, ADR 0069, ADR 0014, RFC 9110 (Fielding, Nottingham, & Reschke, 2022). diff --git a/docs/adr/0086-interpretation-run-stored-request-cli.md b/docs/adr/0086-interpretation-run-stored-request-cli.md new file mode 100644 index 000000000..9db718b4d --- /dev/null +++ b/docs/adr/0086-interpretation-run-stored-request-cli.md @@ -0,0 +1,60 @@ +# ADR 0086 — Loopback interpretation-run stored-request CLI + +**Decision status:** Accepted +**Implementation maturity:** active-PR +**Date:** 2026-09-01 +**Supersedes:** None; complements ADR 0085. Does not re-open cancel lineages. +Does not supersede ADR 0014. Unique versus protected main; 0026–0085 occupied. + +## Context + +ADR 0085 publishes `GET /v1/interpretation-runs/{idempotency_key}/request`. +Operators still had no published binary that mints that GET onto spawned +`tepp-orchestrator-loopback` TCP. Duplicating stored-request GET (#453), +GET-by-id (#438), retrieval CLI (#439), collection GET/CLI, create CLI, +analysis-run stored-request CLI (#395), Leiden, or GAP-010 would collide with +live PRs. Cancel lineages stay closed. + +## Decision + +Publish `tepp-interpretation-run-request get` which mints +`contextual_orchestrator_interpretation_run_stored_request_exchange` onto +spawned loopback TCP. Empty stdin is admitted. Nonempty leftover stdin, public +bind, `localhost`, `http` origin, unpublished consumer, and credential flags +fail closed. `scientific_authority` remains false. +`tepp.scientific_acceptance.v1` never appears. + +## Alternatives considered + +1. Re-open cancel CLI — rejected. +2. Reuse `tepp-interpretation-run-get` — rejected; that is ADR 0072. +3. Dedicated stored-request binary — accepted. + +## Consequences + +CLI success is not measurement evidence and is not an ADR 0014 claim. + +## Failure and recovery + +Non-orchestrator consumers, nonempty leftover stdin, extra segments, slash/NUL, +missing keys, and metric keys fail closed. + +## Verification + +- `tepp-interpretation-run-request get` of an accepted run prints the stored + create request without RMSE/`tepp.scientific_acceptance.v1`; +- naruon, LineageWeave, public bind, `localhost`, `http` origin, leftover + stdin, and missing keys fail closed; +- Clippy `-D warnings`, `orchestrator_live` tests, rustdoc, and exact-head + review remain required. + +## Rollback and supersession + +Rollback removes the published binary; stored-request GET remains valid. A +superseding ADR is required to persist the registry, bind a public address, +re-open cancel, emit scientific-acceptance, open naruon or LineageWeave, add +GET to `NaruonLiveService`, or treat CLI success as an ADR 0014 claim. + +## Related authority + +ADR 0085, ADR 0071, ADR 0014, RFC 9110 (Fielding, Nottingham, & Reschke, 2022). diff --git a/docs/adr/0095-interpretation-run-lookup-get.md b/docs/adr/0095-interpretation-run-lookup-get.md new file mode 100644 index 000000000..068a615c7 --- /dev/null +++ b/docs/adr/0095-interpretation-run-lookup-get.md @@ -0,0 +1,130 @@ +# ADR 0095 — Loopback interpretation-run lookup GET by server-assigned id + +**Decision status:** Accepted +**Implementation maturity:** active-PR +**Date:** 2026-09-01 +**Supersedes:** None; complements ADR 0071 and ADR 0018 for the operator-visible +jump from a server-assigned `interpretation_run_id` to the metric-free identity. +Does not supersede ADR 0014. Unique versus protected main; 0026–0094 occupied +including #466=0093+0094, #464=0092, #454=0086, #453=0085. +**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 0071 publishes `GET /v1/interpretation-runs/{idempotency_key}`. Collection +GET is a different stack. Stored-request GET requires the client key. Operators +who hold a 202 acceptance receipt or a log `orch-run-N` therefore cannot jump +to that identity without scanning pages. Returning RMSE, evidence spans, or +`tepp.scientific_acceptance.v1` on the lookup body would treat identity +resolution as measurement evidence. Export lookup GET (#466) is naruon-owned +and resolves the inverse dual identity (key → `export_id`). Reuse of GET-by-id +with the run id as `{idempotency_key}` would collide with client-key retrieval. + +## Decision + +`OrchestratorLiveService` serves +`GET /v1/interpretation-runs/by-run-id/{interpretation_run_id}` on loopback: + +- The payload is the metric-free collection identity: `interpretation_run_id`, + `idempotency_key`, `orchestration_mode`, `claim_status=hypothetical`, + `scientific_authority=false`. +- `tepp.scientific_acceptance.v1`, RMSE, bias, coverage, SE-gate, evidence + spans, and causal-score keys never appear. +- Lookup is consumer-scoped to contextual-orchestrator. Zero matches and more + than one match fail closed (no tenant oracle). Naruon and LineageWeave are + refused. +- Empty GET bodies only. Query strings, GET-by-id, POST `/by-run-id`, GET + `/request`, collection GET `/v1/interpretation-runs`, reserved `by-run-id` + as an identity, slash/NUL, and nonempty bodies fail closed. +- Dispatch order: collection → stored-request extra-segment → lookup by-run-id + → GET-by-id. GET-by-id refuses the reserved prefix as an idempotency key. +- `NaruonLiveService` stays POST-only. Unknown ids fail closed. Persistence + remains GAP-003B. + +## Non-goals + +- Production TLS, public bind, or durable interpretation-run 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/interpretation-runs/{idempotency_key}` (#438), retrieval + CLI (#439), collection GET/CLI (#433/#436), stored-request GET/CLI + (#453/#454), create CLI (#425), export lookup (#466), analysis-run lookup + GET (#380), or cancel lineages (closed). +- Adding GET to `NaruonLiveService`. Opening naruon or LineageWeave on this + orchestrator-owned adapter. + +## Alternatives considered + +1. **Ask operators to scan collection pages or re-POST create** — rejected + because collection GET is a different stack and a 202 receipt is not an + addressable GET-by-id 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 run id as `{idempotency_key}`** — rejected + because GET-by-id (#438) owns client-key retrieval. +4. **Metric-free interpretation-run lookup GET on loopback** — accepted. + +## Consequences + +- Operators can resolve a 202 acceptance receipt or log `orch-run-N` to the + metric-free identity without scanning pages. +- Lookup pages cannot be mistaken for a succeeded scientific-acceptance result. +- GET-by-id remains the client-key retrieval route. + +## Failure and recovery + +Unknown ids, extra path segments, GET-by-id, query strings, nonempty bodies, +POST `/by-run-id`, metric keys, naruon, LineageWeave, unpublished consumers, +consumer mismatch, ambiguous multi-match, reserved prefix-as-id, slash/NUL, +and non-loopback hosts return a redacted `400` envelope. Oversized ids return +`413`. Credential headers remain `403`. The in-memory registry is not durable; +a restart requires re-POSTing the original metric-free create. 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. +- Run-id 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, collection GET, GET-by-id, and stored-request GET paths are +unchanged. GET-by-id remains the client-key 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/ + evidence-span keys and keeps `claim_status=hypothetical` with + `scientific_authority=false`; +- GET of an accepted `orch-run-N` returns the matching `idempotency_key`; +- GET does not leak another consumer's run; +- GET-by-id, query strings, nonempty bodies, POST `/by-run-id`, unknown ids, + naruon, `NaruonLiveService` GET, reserved `by-run-id` as an identity, and + slash/NUL fail closed; +- Clippy `-D warnings`, `orchestrator_live` tests, rustdoc, and exact-head + review remain required. + +## Rollback and supersession + +Rollback removes lookup GET dispatch; POST, collection GET, GET-by-id, and +stored-request GET remain valid. A superseding ADR is required to persist the +registry, bind a public address, emit scientific-acceptance on lookup, open +naruon or LineageWeave on this orchestrator-owned adapter, add GET to +`NaruonLiveService`, re-open cancel lineages, or treat HTTP success as an +ADR 0014 claim. + +## Related authority + +ADR 0071, ADR 0069, ADR 0018, ADR 0010, ADR 0011, ADR 0014, RFC 9110 (Fielding, +Nottingham, & Reschke, 2022). diff --git a/docs/adr/0096-interpretation-run-lookup-cli.md b/docs/adr/0096-interpretation-run-lookup-cli.md new file mode 100644 index 000000000..d63632096 --- /dev/null +++ b/docs/adr/0096-interpretation-run-lookup-cli.md @@ -0,0 +1,87 @@ +# ADR 0096 — Loopback interpretation-run lookup CLI + +**Decision status:** Accepted +**Implementation maturity:** active-PR +**Date:** 2026-09-01 +**Supersedes:** None; complements ADR 0095. Does not re-open cancel lineages. +Does not supersede ADR 0014. Unique versus protected main; 0026–0095 occupied +including #467=0095, #466=0093+0094. +**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 0095 publishes `GET /v1/interpretation-runs/by-run-id/{interpretation_run_id}`. +Operators still had no published binary that mints that GET onto spawned +`tepp-orchestrator-loopback` TCP. Reusing `tepp-interpretation-run-request` +(#454) or `tepp-interpretation-runs` (#425) would collide with stored-request +GET and create. Export lookup CLI (#466) is naruon-owned. Analysis-run lookup +CLI (#401) is a different stack. Project-history by-idempotency lookup would +duplicate GET-by-id (already keyed by `idempotency_key`). Cancel lineages stay +closed. + +## Decision + +Publish `tepp-interpretation-run-lookup lookup` which mints +`contextual_orchestrator_interpretation_run_lookup_exchange` onto spawned +loopback TCP. Empty stdin is admitted. Nonempty leftover stdin, public bind, +`localhost`, `http` origin, unpublished consumer, credential flags, reserved +`by-run-id` as an identity, slash/NUL, and metric keys fail closed. Stdout is +the metric-free identity. `claim_status` remains hypothetical. +`scientific_authority` remains false. `tepp.scientific_acceptance.v1` never +appears. Naruon and LineageWeave are refused. `NaruonLiveService` stays +POST-only. + +## Non-goals + +- Production TLS, public bind, or durable interpretation-run storage. +- Leiden community detection, Driver p.16 std-family restoration, or + Figma/export work (GAP-010). +- Promoting an ADR 0014 scientific claim from CLI success. +- Duplicating lookup GET (#467), GET-by-id (#438), retrieval CLI (#439), + collection GET/CLI (#433/#436), stored-request GET/CLI (#453/#454), create + CLI (#425), export lookup (#466), analysis-run lookup GET/CLI (#380/#401), + or cancel lineages (closed). +- Adding GET to `NaruonLiveService`. Opening naruon or LineageWeave on this + orchestrator-owned adapter. + +## Alternatives considered + +1. Re-open cancel CLI — rejected. +2. Reuse `tepp-interpretation-run-request` or `tepp-interpretation-runs` — + rejected; those are ADR 0086 and ADR 0064. +3. Project-history by-idempotency lookup — rejected; GET-by-id already keys + by `idempotency_key`. +4. Dedicated lookup binary — accepted. + +## Consequences + +CLI success is not measurement evidence and is not an ADR 0014 claim. +Operators who hold a 202 receipt or log `orch-run-N` can resolve identity +without scanning collection pages. + +## Failure and recovery + +Non-orchestrator consumers, nonempty leftover stdin, extra segments, slash/NUL, +reserved prefix, missing ids, 0 or >1 match, and metric keys fail closed. + +## Verification + +- `tepp-interpretation-run-lookup lookup` of an accepted `orch-run-N` prints + the matching `idempotency_key` without RMSE/`tepp.scientific_acceptance.v1`; +- naruon, LineageWeave, public bind, `localhost`, `http` origin, leftover + stdin, reserved prefix, and missing ids fail closed; +- Clippy `-D warnings`, `orchestrator_live` 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 naruon or LineageWeave, add GET to +`NaruonLiveService`, or treat CLI success as an ADR 0014 claim. + +## Related authority + +ADR 0095, ADR 0071, ADR 0014, RFC 9110 (Fielding, Nottingham, & Reschke, 2022). diff --git a/docs/adr/0097-interpretation-run-lookup-stored-request-get.md b/docs/adr/0097-interpretation-run-lookup-stored-request-get.md new file mode 100644 index 000000000..6135cbfa8 --- /dev/null +++ b/docs/adr/0097-interpretation-run-lookup-stored-request-get.md @@ -0,0 +1,103 @@ +# ADR 0097 — Loopback interpretation-run lookup stored-request GET + +**Decision status:** Accepted +**Implementation maturity:** active-PR +**Date:** 2026-09-01 +**Supersedes:** None; complements ADR 0095 and ADR 0085. Does not re-open +cancel lineages. Does not supersede ADR 0014. Unique versus protected main; +0026–0096 occupied including #468=0096, #467=0095. +**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 0095 publishes `GET /v1/interpretation-runs/by-run-id/{interpretation_run_id}` +as the metric-free identity. ADR 0085 publishes +`GET /v1/interpretation-runs/{idempotency_key}/request` as the stored create. +Operators who hold a 202 receipt or log `orch-run-N` still need two hops +(lookup identity, then stored-request by client key) to recover the create. +Reuse of `{idempotency_key}/request` with the run id as the key would collide +with client-key stored-request. Cancel extra-segment stays refused. + +## Decision + +`OrchestratorLiveService` serves +`GET /v1/interpretation-runs/by-run-id/{interpretation_run_id}/request` on +loopback: + +- The payload is the stored metric-free create request. + `scientific_authority` remains false. `tepp.scientific_acceptance.v1` never + appears. +- Lookup stored-request is consumer-scoped to contextual-orchestrator. Zero + matches and more than one match fail closed (no tenant oracle). Naruon and + LineageWeave are refused. +- Empty GET bodies only. Query strings, lookup without `/request`, + `{key}/request`, GET-by-id, POST `/by-run-id/.../request`, collection GET, + reserved `by-run-id` as an identity, slash/NUL, cancel extra-segment, and + nonempty bodies fail closed. +- Dispatch order: collection → stored-request `{key}/request` → lookup + stored-request `by-run-id/{id}/request` → lookup by-run-id → GET-by-id. + Stored-request `{key}/request` refuses reserved prefix `by-run-id` as a key. +- `NaruonLiveService` stays POST-only. Unknown ids fail closed. Persistence + remains GAP-003B. + +## Non-goals + +- Production TLS, public bind, or durable interpretation-run 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 (#467/#468), stored-request GET/CLI (#453/#454), + GET-by-id (#438), retrieval CLI (#439), collection GET/CLI (#433/#436), + create CLI (#425), export lookup (#466), analysis-run lookup (#380/#401), + or cancel lineages (closed). +- Adding GET to `NaruonLiveService`. Opening naruon or LineageWeave on this + orchestrator-owned adapter. + +## Alternatives considered + +1. Ask operators to hop lookup then `{key}/request` — rejected because a 202 + receipt is already an addressable server-assigned identity. +2. Reuse `{idempotency_key}/request` with the run id as the key — rejected + because ADR 0085 owns client-key stored-request. +3. Return identity JSON on `/request` — rejected because that is ADR 0095. +4. Metric-free lookup stored-request GET on loopback — accepted. + +## Consequences + +Operators can recover the stored create from `orch-run-N` without a second hop +and without scanning collection pages. HTTP 200 is not measurement evidence. + +## Failure and recovery + +Unknown ids, extra path segments, lookup without `/request`, `{key}/request`, +query strings, nonempty bodies, POST, metric keys, naruon, LineageWeave, +unpublished consumers, consumer mismatch, ambiguous multi-match, reserved +prefix-as-id, slash/NUL, cancel extra-segment, and non-loopback hosts return a +redacted `400` envelope. Oversized ids return `413`. Credential headers remain +`403`. + +## Verification + +- GET lookup stored-request JSON has no RMSE/scientific-acceptance keys and + keeps `scientific_authority=false`; +- GET of an accepted `orch-run-N` returns the matching stored create + `idempotency_key`; +- `{key}/request`, lookup without `/request`, naruon, cancel extra-segment, + reserved prefix, slash/NUL, and unknown ids fail closed; +- Clippy `-D warnings`, `orchestrator_live` tests, rustdoc, and exact-head + review remain required. + +## Rollback and supersession + +Rollback removes lookup stored-request dispatch; lookup GET, `{key}/request`, +GET-by-id, collection GET, and POST remain valid. A superseding ADR is required +to persist the registry, bind a public address, emit scientific-acceptance, +open naruon or LineageWeave, add GET to `NaruonLiveService`, re-open cancel +lineages, or treat HTTP success as an ADR 0014 claim. + +## Related authority + +ADR 0095, ADR 0085, ADR 0071, ADR 0014, RFC 9110 (Fielding, Nottingham, & +Reschke, 2022). diff --git a/docs/adr/0098-interpretation-run-lookup-stored-request-cli.md b/docs/adr/0098-interpretation-run-lookup-stored-request-cli.md new file mode 100644 index 000000000..4b895b67c --- /dev/null +++ b/docs/adr/0098-interpretation-run-lookup-stored-request-cli.md @@ -0,0 +1,87 @@ +# ADR 0098 — Loopback interpretation-run lookup stored-request CLI + +**Decision status:** Accepted +**Implementation maturity:** active-PR +**Date:** 2026-09-01 +**Supersedes:** None; complements ADR 0097. Does not re-open cancel lineages. +Does not supersede ADR 0014. Unique versus protected main; 0026–0097 occupied +including #469=0097, #468=0096. +**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 0097 publishes +`GET /v1/interpretation-runs/by-run-id/{interpretation_run_id}/request`. +Operators still had no published binary that mints that GET onto spawned +`tepp-orchestrator-loopback` TCP. Reusing `tepp-interpretation-run-lookup` +(#468) or `tepp-interpretation-run-request` (#454) would collide with identity +lookup and client-key stored-request. Temporal-context stored-request is +already #464. Cancel lineages stay closed. + +## Decision + +Publish `tepp-interpretation-run-lookup-request get` which mints +`contextual_orchestrator_interpretation_run_lookup_stored_request_exchange` +onto spawned loopback TCP. Empty stdin is admitted. Nonempty leftover stdin, +public bind, `localhost`, `http` origin, unpublished consumer, credential +flags, reserved `by-run-id` as an identity, slash/NUL, and metric keys fail +closed. Stdout is the stored metric-free create. +`scientific_authority` remains false. `tepp.scientific_acceptance.v1` never +appears. Naruon and LineageWeave are refused. `NaruonLiveService` stays +POST-only. + +## Non-goals + +- Production TLS, public bind, or durable interpretation-run storage. +- Leiden community detection, Driver p.16 std-family restoration, or + Figma/export work (GAP-010). +- Promoting an ADR 0014 scientific claim from CLI success. +- Duplicating lookup stored-request GET (#469), lookup GET/CLI (#467/#468), + stored-request GET/CLI (#453/#454), GET-by-id (#438), retrieval CLI (#439), + collection GET/CLI (#433/#436), create CLI (#425), export lookup (#466), + analysis-run lookup (#380/#401), or cancel lineages (closed). +- Adding GET to `NaruonLiveService`. Opening naruon or LineageWeave on this + orchestrator-owned adapter. + +## Alternatives considered + +1. Re-open cancel CLI — rejected. +2. Reuse `tepp-interpretation-run-lookup` or `tepp-interpretation-run-request` + — rejected; those are ADR 0096 and ADR 0086. +3. Temporal-context stored-request GET — rejected; already #464. +4. Dedicated lookup stored-request binary — accepted. + +## Consequences + +CLI success is not measurement evidence and is not an ADR 0014 claim. +Operators who hold a 202 receipt or log `orch-run-N` can recover the stored +create without a second hop. + +## Failure and recovery + +Non-orchestrator consumers, nonempty leftover stdin, extra segments, slash/NUL, +reserved prefix, missing ids, 0 or >1 match, and metric keys fail closed. + +## Verification + +- `tepp-interpretation-run-lookup-request get` of an accepted `orch-run-N` + prints the matching stored create without RMSE/`tepp.scientific_acceptance.v1`; +- naruon, LineageWeave, public bind, `localhost`, `http` origin, leftover + stdin, reserved prefix, and missing ids fail closed; +- Clippy `-D warnings`, `orchestrator_live` tests, rustdoc, and exact-head + review remain required. + +## Rollback and supersession + +Rollback removes the published binary; lookup stored-request GET remains valid. +A superseding ADR is required to persist the registry, bind a public address, +emit scientific-acceptance, open naruon or LineageWeave, add GET to +`NaruonLiveService`, re-open cancel lineages, or treat CLI success as an +ADR 0014 claim. + +## Related authority + +ADR 0097, ADR 0096, ADR 0085, ADR 0014, RFC 9110 (Fielding, Nottingham, & +Reschke, 2022). diff --git a/docs/adr/README.md b/docs/adr/README.md index 4002ba826..099369bd1 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -33,6 +33,12 @@ Read [`ADR_POLICY.md`](ADR_POLICY.md) first. **Decision status and implementatio | [0064](0064-interpretation-run-cli.md) | Loopback `tepp-interpretation-runs create` is contextual-orchestrator POST /v1/interpretation-runs client | Accepted | active-PR | Complements ADR 0010/0011; does not supersede ADR 0014. Unique on protected main. Does not infer causality. | | [0069](0069-interpretation-run-collection-get.md) | Loopback `GET /v1/interpretation-runs` enumerates accepted hypothetical interpretation runs | Accepted | active-PR | Complements ADR 0010/0011/0064; does not supersede ADR 0014. Unique on this stack versus protected main (0026–0068 occupied). Does not infer causality. | | [0071](0071-interpretation-run-retrieval-get.md) | Loopback `GET /v1/interpretation-runs/{idempotency_key}` returns one accepted hypothetical identity | Accepted | active-PR | Complements ADR 0069; does not supersede ADR 0014. Unique on this interpretation stack versus protected main (0026–0070 occupied). Does not infer causality. | +| [0085](0085-interpretation-run-stored-request-get.md) | Loopback interpretation-run stored-request GET | Accepted | active-PR | Complements ADR 0071; `GET /v1/interpretation-runs/{idempotency_key}/request` returns the stored create request. Unique versus protected main (0026–0084 occupied). Does not re-open cancel lineages. | +| [0086](0086-interpretation-run-stored-request-cli.md) | Loopback interpretation-run stored-request CLI | Accepted | active-PR | Complements ADR 0085; published `tepp-interpretation-run-request get`. Unique versus protected main (0026–0085 occupied). Does not re-open cancel lineages. | +| [0095](0095-interpretation-run-lookup-get.md) | Loopback interpretation-run lookup GET by server-assigned id | Accepted | active-PR | Complements ADR 0071; `GET /v1/interpretation-runs/by-run-id/{interpretation_run_id}` returns the metric-free identity. Unique versus protected main (0026–0094 occupied including #466=0093+0094). Dual identity of GET-by-id. Does not re-open cancel lineages. | +| [0096](0096-interpretation-run-lookup-cli.md) | Loopback interpretation-run lookup CLI | Accepted | active-PR | Complements ADR 0095; published `tepp-interpretation-run-lookup lookup`. Unique versus protected main (0026–0095 occupied including #467=0095). Does not re-open cancel lineages. | +| [0097](0097-interpretation-run-lookup-stored-request-get.md) | Loopback interpretation-run lookup stored-request GET | Accepted | active-PR | Complements ADR 0095 and ADR 0085; `GET /v1/interpretation-runs/by-run-id/{interpretation_run_id}/request` returns the stored create. Unique versus protected main (0026–0096 occupied including #468=0096). Does not re-open cancel lineages. | +| [0098](0098-interpretation-run-lookup-stored-request-cli.md) | Loopback interpretation-run lookup stored-request CLI | Accepted | active-PR | Complements ADR 0097; published `tepp-interpretation-run-lookup-request get`. Unique versus protected main (0026–0097 occupied including #469=0097). 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/interpretation-run-lookup-cli.md b/docs/research/interpretation-run-lookup-cli.md new file mode 100644 index 000000000..cb2b03b24 --- /dev/null +++ b/docs/research/interpretation-run-lookup-cli.md @@ -0,0 +1,19 @@ +# Interpretation-run lookup CLI (doctoring) + +`tepp-interpretation-run-lookup lookup` mints +`GET /v1/interpretation-runs/by-run-id/{interpretation_run_id}` onto spawned +`tepp-orchestrator-loopback` TCP. HTTP semantics follow RFC 9110 (Fielding, +Nottingham, & Reschke, 2022). Fail-closed unpublished consumers, leftover +stdin, public bind, `localhost`, `http` origin, credential flags, reserved +prefix, and scientific-authority promotion are repository contract (ADR 0096; +ADR 0014). + +`claim_status` remains `hypothetical`. `scientific_authority` remains false. +`tepp.scientific_acceptance.v1` never appears. CLI success is not a scientific +claim. + +Does not re-open cancel lineages, GAP-010 Figma/export, persistence, Leiden, +or an ADR 0014 claim-promotion package. Dual identity of GET-by-id +(`idempotency_key`) versus this lookup (`interpretation_run_id`). Analog of +export idempotency-key lookup CLI; not a duplicate of GET-by-id or of lookup +GET (#467). diff --git a/docs/research/interpretation-run-lookup-http.md b/docs/research/interpretation-run-lookup-http.md new file mode 100644 index 000000000..3ed360d7d --- /dev/null +++ b/docs/research/interpretation-run-lookup-http.md @@ -0,0 +1,18 @@ +# Interpretation-run lookup GET (doctoring) + +`GET /v1/interpretation-runs/by-run-id/{interpretation_run_id}` returns one +accepted contextual-orchestrator metric-free identity on +`tepp-orchestrator-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, and scientific-authority promotion are repository contract (ADR 0095; +ADR 0014). + +`claim_status` remains `hypothetical`. `scientific_authority` remains false. +`tepp.scientific_acceptance.v1` never appears. HTTP 200 is not a scientific +claim. + +Does not re-open cancel lineages, GAP-010 Figma/export, persistence, Leiden, +or an ADR 0014 claim-promotion package. Dual identity of GET-by-id +(`idempotency_key`) versus this lookup (`interpretation_run_id`). Analog of +export idempotency-key lookup; not a duplicate of GET-by-id. diff --git a/docs/research/interpretation-run-lookup-stored-request-cli.md b/docs/research/interpretation-run-lookup-stored-request-cli.md new file mode 100644 index 000000000..a9b01c0f2 --- /dev/null +++ b/docs/research/interpretation-run-lookup-stored-request-cli.md @@ -0,0 +1,17 @@ +# Interpretation-run lookup stored-request CLI (doctoring) + +`tepp-interpretation-run-lookup-request get` mints +`GET /v1/interpretation-runs/by-run-id/{interpretation_run_id}/request` onto +spawned `tepp-orchestrator-loopback` TCP. HTTP semantics follow RFC 9110 +(Fielding, Nottingham, & Reschke, 2022). Fail-closed unpublished consumers, +leftover stdin, public bind, `localhost`, `http` origin, reserved prefix, +slash/NUL, credential flags, cancel extra-segment, and scientific-authority +promotion are repository contract (ADR 0098; ADR 0014). + +`scientific_authority` remains false. `tepp.scientific_acceptance.v1` never +appears. CLI success is not a scientific claim. + +Does not re-open cancel lineages, GAP-010 Figma/export, persistence, Leiden, +or an ADR 0014 claim-promotion package. Dual identity of stored-request CLI +(`idempotency_key`) versus this lookup (`interpretation_run_id`). Not a +duplicate of lookup stored-request GET (#469) or of `{key}/request` CLI (#454). diff --git a/docs/research/interpretation-run-lookup-stored-request-http.md b/docs/research/interpretation-run-lookup-stored-request-http.md new file mode 100644 index 000000000..d25f024b6 --- /dev/null +++ b/docs/research/interpretation-run-lookup-stored-request-http.md @@ -0,0 +1,17 @@ +# Interpretation-run lookup stored-request GET (doctoring) + +`GET /v1/interpretation-runs/by-run-id/{interpretation_run_id}/request` returns +the stored contextual-orchestrator create request on +`tepp-orchestrator-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 0097; ADR 0014). + +`scientific_authority` remains false. `tepp.scientific_acceptance.v1` never +appears. HTTP 200 is not a scientific claim. + +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 +(`idempotency_key`) versus this lookup (`interpretation_run_id`). Not a +duplicate of lookup GET (#467) or of `{key}/request` (#453). diff --git a/docs/research/interpretation-run-stored-request-cli.md b/docs/research/interpretation-run-stored-request-cli.md new file mode 100644 index 000000000..fe096740f --- /dev/null +++ b/docs/research/interpretation-run-stored-request-cli.md @@ -0,0 +1,14 @@ +# Interpretation-run stored-request CLI (doctoring) + +`tepp-interpretation-run-request get` mints +`GET /v1/interpretation-runs/{idempotency_key}/request` onto spawned +`tepp-orchestrator-loopback` TCP. HTTP semantics follow RFC 9110 (Fielding, +Nottingham, & Reschke, 2022). Fail-closed unpublished consumers, leftover +stdin, public bind, `localhost`, `http` origin, credential flags, and +scientific-authority promotion are repository contract (ADR 0086; ADR 0014). + +`scientific_authority` remains false. `tepp.scientific_acceptance.v1` never +appears. CLI success is not a scientific claim. + +Does not re-open cancel lineages, GAP-010 Figma/export, persistence, Leiden, +or an ADR 0014 claim-promotion package. diff --git a/docs/research/interpretation-run-stored-request-get.md b/docs/research/interpretation-run-stored-request-get.md new file mode 100644 index 000000000..60e43a30a --- /dev/null +++ b/docs/research/interpretation-run-stored-request-get.md @@ -0,0 +1,13 @@ +# Interpretation-run stored-request GET (doctoring) + +`GET /v1/interpretation-runs/{idempotency_key}/request` returns one accepted +contextual-orchestrator create request on `tepp-orchestrator-loopback`. HTTP +semantics follow RFC 9110 (Fielding, Nottingham, & Reschke, 2022). Fail-closed +unpublished consumers, extra segments, slash/NUL, credential flags, and +scientific-authority promotion are repository contract (ADR 0085; ADR 0014). + +`scientific_authority` remains false. `tepp.scientific_acceptance.v1` never +appears. HTTP 200 is not a scientific claim. + +Does not re-open cancel lineages, GAP-010 Figma/export, persistence, Leiden, +or an ADR 0014 claim-promotion package.