diff --git a/CHANGELOG.d/interpretation-run-cli.md b/CHANGELOG.d/interpretation-run-cli.md new file mode 100644 index 00000000..781e541d --- /dev/null +++ b/CHANGELOG.d/interpretation-run-cli.md @@ -0,0 +1 @@ +- `orchestrator_live` `tepp-interpretation-runs create` mints a typed contextual-orchestrator `POST /v1/interpretation-runs` onto spawned `tepp-orchestrator-loopback` TCP (ADR 0064). Metric-free hypothetical JSON only. `tepp.scientific_acceptance.v1` never appears. Does not infer causality. Naruon and LineageWeave are refused. Not analysis-run CLI, not export CLI, not persistence. diff --git a/CHANGELOG.d/interpretation-run-collection-cli.md b/CHANGELOG.d/interpretation-run-collection-cli.md new file mode 100644 index 00000000..bf22373b --- /dev/null +++ b/CHANGELOG.d/interpretation-run-collection-cli.md @@ -0,0 +1 @@ +- `orchestrator_live` publishes `tepp-interpretation-runs list` minting typed contextual-orchestrator `GET /v1/interpretation-runs` onto spawned `tepp-orchestrator-loopback` TCP (ADR 0070). Metric-free identities only (`claim_status=hypothetical`, `scientific_authority=false`). Empty stdin admitted. `tepp.scientific_acceptance.v1` never appears. Does not infer causality. Naruon and LineageWeave are refused. Not interpretation-run create CLI, not collection GET, not project-history collection CLI, not persistence. diff --git a/CHANGELOG.d/interpretation-run-collection-http.md b/CHANGELOG.d/interpretation-run-collection-http.md new file mode 100644 index 00000000..2077751b --- /dev/null +++ b/CHANGELOG.d/interpretation-run-collection-http.md @@ -0,0 +1 @@ +- `orchestrator_live` loopback `GET /v1/interpretation-runs` enumerates accepted hypothetical interpretation runs on `tepp-orchestrator-loopback` (ADR 0069). Metric-free identities only (`claim_status=hypothetical`, `scientific_authority=false`). `tepp.scientific_acceptance.v1` never appears. Does not infer causality. Naruon and LineageWeave are refused. Not interpretation-run CLI, not project-history collection GET, not persistence. diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 6fa4b968..2fec1b2f 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -13,6 +13,9 @@ TEPP's approved PRD v0.4 and implementation plan are the primary product baselin | naruon modular consumer contract | [`docs/connectors/naruon-artifact-consumer.md`](docs/connectors/naruon-artifact-consumer.md) | | contextual-orchestrator interpretation port | [`docs/connectors/contextual-orchestrator-interpretation-port.md`](docs/connectors/contextual-orchestrator-interpretation-port.md) | | Orchestrator live HTTP doctoring | [`docs/research/orchestrator-live-http.md`](docs/research/orchestrator-live-http.md) | +| 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 collection CLI doctoring | [`docs/research/interpretation-run-collection-cli.md`](docs/research/interpretation-run-collection-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 ea2db337..38df1a3b 100644 --- a/crates/orchestrator_live/Cargo.toml +++ b/crates/orchestrator_live/Cargo.toml @@ -17,5 +17,17 @@ publish = false serde = { workspace = true } serde_json = { workspace = true } +[[bin]] +name = "tepp-orchestrator-loopback" +path = "src/bin/tepp_orchestrator_loopback.rs" +test = false +bench = false + +[[bin]] +name = "tepp-interpretation-runs" +path = "src/bin/tepp_interpretation_runs.rs" +test = false +bench = false + [lints] workspace = true diff --git a/crates/orchestrator_live/src/bin/tepp_interpretation_runs.rs b/crates/orchestrator_live/src/bin/tepp_interpretation_runs.rs new file mode 100644 index 00000000..7e21ecfd --- /dev/null +++ b/crates/orchestrator_live/src/bin/tepp_interpretation_runs.rs @@ -0,0 +1,51 @@ +//! Operator CLI for loopback contextual-orchestrator interpretation-run POST and collection GET. + +use std::io::{self, IsTerminal}; +use std::process::ExitCode; + +use orchestrator_live::{ + execute_interpretation_run_cli, execute_interpretation_run_collection_cli, + read_interpretation_run_cli_stdin, read_interpretation_run_collection_cli_stdin, + render_interpretation_run_cli_stdout, render_interpretation_run_collection_cli_stdout, + InterpretationRunCliInvocation, InterpretationRunCollectionCliInvocation, + 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("create") => run_create(&args), + Some("list") => run_list(&args), + _ => Err(OrchestratorLiveError::InvalidWirePayload), + } +} + +fn run_create(args: &[String]) -> Result<(), OrchestratorLiveError> { + let body = read_interpretation_run_cli_stdin(io::stdin().is_terminal(), io::stdin())?; + let invocation = InterpretationRunCliInvocation::from_args(args, body)?; + let response = execute_interpretation_run_cli(&invocation)?; + let stdout = render_interpretation_run_cli_stdout(&invocation, &response)?; + println!("{stdout}"); + if (200..300).contains(&response.status_code) { + Ok(()) + } else { + Err(OrchestratorLiveError::InvalidWirePayload) + } +} + +fn run_list(args: &[String]) -> Result<(), OrchestratorLiveError> { + let body = + read_interpretation_run_collection_cli_stdin(io::stdin().is_terminal(), io::stdin())?; + let invocation = InterpretationRunCollectionCliInvocation::from_args(args, body)?; + let response = execute_interpretation_run_collection_cli(&invocation)?; + let stdout = render_interpretation_run_collection_cli_stdout(&invocation, &response)?; + println!("{stdout}"); + Ok(()) +} diff --git a/crates/orchestrator_live/src/bin/tepp_orchestrator_loopback.rs b/crates/orchestrator_live/src/bin/tepp_orchestrator_loopback.rs new file mode 100644 index 00000000..1bf938d0 --- /dev/null +++ b/crates/orchestrator_live/src/bin/tepp_orchestrator_loopback.rs @@ -0,0 +1,24 @@ +//! Runnable loopback ingress for trusted same-host contextual-orchestrator. + +use std::net::SocketAddr; + +use orchestrator_live::OrchestratorLiveService; + +const DEFAULT_BIND_ADDR: &str = "127.0.0.1:18082"; + +fn main() -> Result<(), Box> { + let mut arguments = std::env::args().skip(1); + let bind_addr = arguments + .next() + .unwrap_or(DEFAULT_BIND_ADDR.to_owned()) + .parse::()?; + let request_limit = arguments + .next() + .map(|value| value.parse::()) + .transpose()? + .unwrap_or(usize::MAX); + let mut service = OrchestratorLiveService::bind(bind_addr)?; + println!("{}", service.local_addr()?); + (0..request_limit).for_each(|_| drop(service.serve_one())); + Ok(()) +} diff --git a/crates/orchestrator_live/src/http.rs b/crates/orchestrator_live/src/http.rs index cba27296..f95844d7 100644 --- a/crates/orchestrator_live/src/http.rs +++ b/crates/orchestrator_live/src/http.rs @@ -194,6 +194,25 @@ pub(crate) fn split_header_line(line: &str) -> Result<(&str, &str), Orchestrator pub(crate) fn refuse_live_headers( headers: &HashMap, +) -> Result<(), OrchestratorLiveError> { + refuse_common_live_headers(headers)?; + let _idempotency_key = header_value(headers, "idempotency-key")?; + Ok(()) +} + +/// Collection GET admits empty bodies and refuses `idempotency-key`. +pub(crate) fn refuse_collection_get_headers( + headers: &HashMap, +) -> Result<(), OrchestratorLiveError> { + refuse_common_live_headers(headers)?; + if headers.contains_key("idempotency-key") { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + Ok(()) +} + +fn refuse_common_live_headers( + headers: &HashMap, ) -> Result<(), OrchestratorLiveError> { for (name, value) in headers { if header_is_credential(name) || header_is_credential(value) { @@ -213,7 +232,6 @@ pub(crate) fn refuse_live_headers( if header_value(headers, "tepp-contract-version")? != "1" { return Err(OrchestratorLiveError::InvalidWirePayload); } - let _idempotency_key = header_value(headers, "idempotency-key")?; Ok(()) } @@ -262,7 +280,8 @@ pub(crate) fn status_for(error: OrchestratorLiveError) -> (u16, &'static str) { mod tests { use super::{ declared_content_length, header_is_credential, map_io_error, parse_headers, - parse_request_line, refuse_live_headers, split_header_line, split_request, status_for, + parse_request_line, refuse_collection_get_headers, refuse_live_headers, split_header_line, + split_request, status_for, }; use crate::error::OrchestratorLiveError; use std::collections::HashMap; @@ -423,4 +442,36 @@ mod tests { assert!(header_is_credential("x-nvidia_nim_api_key")); assert!(!header_is_credential("x-safe-header")); } + + #[test] + fn collection_get_headers_refuse_idempotency_key_and_foreign_consumers() { + let mut headers = HashMap::new(); + headers.insert("host".into(), "127.0.0.1".into()); + headers.insert("content-type".into(), "application/json".into()); + headers.insert("tepp-consumer".into(), "contextual-orchestrator".into()); + headers.insert("tepp-contract-version".into(), "1".into()); + headers.insert("idempotency-key".into(), "idem".into()); + assert_eq!( + refuse_collection_get_headers(&headers), + Err(OrchestratorLiveError::InvalidWirePayload) + ); + headers.remove("idempotency-key"); + refuse_collection_get_headers(&headers).expect("collection headers"); + headers.insert("tepp-consumer".into(), "naruon".into()); + assert_eq!( + refuse_collection_get_headers(&headers), + Err(OrchestratorLiveError::InvalidWirePayload) + ); + headers.insert("tepp-consumer".into(), "lineageweave".into()); + assert_eq!( + refuse_collection_get_headers(&headers), + Err(OrchestratorLiveError::InvalidWirePayload) + ); + headers.insert("tepp-consumer".into(), "contextual-orchestrator".into()); + headers.insert("authorization".into(), "Bearer x".into()); + assert_eq!( + refuse_collection_get_headers(&headers), + Err(OrchestratorLiveError::AuthorizationDenied) + ); + } } diff --git a/crates/orchestrator_live/src/interpretation_run_cli.rs b/crates/orchestrator_live/src/interpretation_run_cli.rs new file mode 100644 index 00000000..94d6d893 --- /dev/null +++ b/crates/orchestrator_live/src/interpretation_run_cli.rs @@ -0,0 +1,859 @@ +//! Operator loopback CLI for contextual-orchestrator interpretation-run POST. +//! +//! Operators run `tepp-interpretation-runs create` to mint a typed +//! `contextual_orchestrator_interpretation_run_exchange` onto spawned +//! `tepp-orchestrator-loopback` TCP. Stdout is a metric-free `202 Accepted` +//! body with `claim_status` `hypothetical` and `scientific_authority` false. +//! `tepp.scientific_acceptance.v1` never appears. The CLI does not call a +//! model provider, infer causality, or promote scientific authority. +//! Naruon and `LineageWeave` are refused on this orchestrator-owned adapter. +//! `NaruonLiveService` stays POST-only for analysis-run and export. + +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::request::{host_implies_table_access, require_nonempty}; +use crate::{ + HYPOTHETICAL_CLAIM_STATUS, INTERPRETATION_RUN_PATH, InterpretationRunAccepted, + InterpretationRunRequest, OrchestratorLiveError, OrchestratorLiveResponse, + OrchestratorLiveService, +}; + +/// Published modular consumer for the interpretation-run adapter. +pub const CONTEXTUAL_ORCHESTRATOR_CONSUMER_CODE: &str = "contextual-orchestrator"; + +const SCIENTIFIC_ACCEPTANCE_SCHEMA: &str = "tepp.scientific_acceptance.v1"; +const CLI_IO_TIMEOUT: Duration = Duration::from_secs(2); + +/// Supported operator verbs for the loopback interpretation-run CLI. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum InterpretationRunCliVerb { + /// `POST /v1/interpretation-runs`. + Create, +} + +impl InterpretationRunCliVerb { + /// Parse one exact lowercase verb token. + /// + /// # Errors + /// + /// Returns [`OrchestratorLiveError::InvalidWirePayload`] for an unknown token. + pub fn parse(token: &str) -> Result { + match token { + "create" => Ok(Self::Create), + _ => Err(OrchestratorLiveError::InvalidWirePayload), + } + } + + /// Return the canonical lowercase verb token. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Create => "create", + } + } +} + +/// Typed HTTPS interpretation-run exchange before loopback Host rewrite. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct InterpretationRunHttpExchange { + /// HTTP method, always `POST` for a valid exchange. + pub method: &'static str, + /// Absolute HTTPS target ending in [`INTERPRETATION_RUN_PATH`]. + pub target_url: String, + /// Credential-free consumer, version, content, and idempotency headers. + pub headers: Vec<(String, String)>, + /// Validated JSON request body. + pub body: String, +} + +/// One operator CLI invocation against a loopback interpretation-run listener. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct InterpretationRunCliInvocation { + /// CLI verb to execute. + pub verb: InterpretationRunCliVerb, + /// 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. Interpretation-run admits + /// `contextual-orchestrator` only. + pub consumer: String, + /// Validated hypothetical interpretation-run request. + pub request: InterpretationRunRequest, +} + +impl InterpretationRunCliInvocation { + /// Parse argv plus stdin JSON into a validated loopback create invocation. + /// + /// # Errors + /// + /// Returns a fail-closed error for unknown verbs, missing flags, a + /// non-loopback host, a non-`https` origin, an unpublished consumer, + /// credential-shaped flags, metric keys, or an invalid 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 = InterpretationRunCliVerb::parse(verb_token)?; + let flags = parse_flags(rest)?; + let body = body.into(); + refuse_scientific_acceptance(&body)?; + refuse_metrics_on_interpretation_run_cli_payload(&body)?; + let request = InterpretationRunRequest::from_json(&body)?; + let invocation = Self { + 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()), + request, + }; + invocation.validate()?; + Ok(invocation) + } + + /// Reject a non-loopback host, unpublished consumer, or hostile origin. + /// + /// # Errors + /// + /// Returns [`OrchestratorLiveError::AuthorizationDenied`] for a non-loopback + /// host and [`OrchestratorLiveError::InvalidWirePayload`] when the origin is + /// not `https` or the consumer is not `contextual-orchestrator`. + 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); + } + Ok(()) + } +} + +struct ParsedFlags { + host: Option, + origin: Option, + consumer: Option, +} + +fn parse_flags(rest: &[String]) -> Result { + let mut flags = ParsedFlags { + host: None, + origin: None, + consumer: 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, + _ => 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 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) + } +} + +fn compose_https_target(origin: &str, path: &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); + } + Ok(format!("{origin}{path}")) +} + +/// Build a credential-free contextual-orchestrator interpretation-run exchange. +/// +/// # Errors +/// +/// Returns a fail-closed origin, request, or scientific-authority error. +pub fn contextual_orchestrator_interpretation_run_exchange( + origin: &str, + request: &InterpretationRunRequest, +) -> Result { + let target_url = compose_https_target(origin, INTERPRETATION_RUN_PATH)?; + let body = request.to_json()?; + Ok(InterpretationRunHttpExchange { + method: "POST", + target_url, + headers: vec![ + ("content-type".into(), "application/json".into()), + ( + "tepp-consumer".into(), + CONTEXTUAL_ORCHESTRATOR_CONSUMER_CODE.into(), + ), + ("tepp-contract-version".into(), "1".into()), + ( + "idempotency-key".into(), + request.idempotency_key().to_owned(), + ), + ], + body, + }) +} + +/// Render a typed interpretation-run exchange as HTTP/1.1 for a loopback listener. +/// +/// The exchange keeps its HTTPS origin contract. Only the HTTP/1.1 `Host` is +/// the loopback bind address. Public bind hosts fail closed. +/// +/// # Errors +/// +/// Returns [`OrchestratorLiveError::AuthorizationDenied`] for a non-loopback +/// host or a credential-bearing header, and +/// [`OrchestratorLiveError::InvalidWirePayload`] when the exchange is not a +/// POST `/v1/interpretation-runs`. +pub fn loopback_http1_from_interpretation_run_exchange( + exchange: &InterpretationRunHttpExchange, + loopback_host: &str, +) -> Result { + let _addr = require_loopback_host(loopback_host)?; + let host = loopback_host.trim(); + if exchange.method != "POST" { + 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)?; + if path != INTERPRETATION_RUN_PATH { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + for (name, _) in &exchange.headers { + if header_is_credential(name) { + return Err(OrchestratorLiveError::AuthorizationDenied); + } + } + 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: {}\r\n\r\n{}", + exchange.body.len(), + exchange.body + ) + .map_err(|_| OrchestratorLiveError::InvalidWirePayload)?; + Ok(request) +} + +/// Compose one HTTP/1.1 interpretation-run POST from the typed consumer exchange. +/// +/// # Errors +/// +/// Returns the same fail-closed errors as +/// [`InterpretationRunCliInvocation::validate`]. +pub fn compose_interpretation_run_cli_http( + invocation: &InterpretationRunCliInvocation, +) -> Result { + invocation.validate()?; + let exchange = contextual_orchestrator_interpretation_run_exchange( + &invocation.origin, + &invocation.request, + )?; + loopback_http1_from_interpretation_run_exchange(&exchange, &invocation.host) +} + +/// Dispatch one interpretation-run CLI invocation against an in-process listener. +/// +/// # Errors +/// +/// Returns fail-closed validation errors before the HTTP handler runs. +pub fn dispatch_interpretation_run_cli( + service: &mut OrchestratorLiveService, + invocation: &InterpretationRunCliInvocation, +) -> Result { + let request = compose_interpretation_run_cli_http(invocation)?; + Ok(service.handle_http_request(&request)) +} + +/// Execute one interpretation-run CLI invocation over loopback TCP. +/// +/// # Errors +/// +/// Returns fail-closed validation, transport, or response-framing errors. +pub fn execute_interpretation_run_cli( + invocation: &InterpretationRunCliInvocation, +) -> Result { + let addr = require_loopback_host(&invocation.host)?; + let request = compose_interpretation_run_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 mut bytes = Vec::new(); + stream + .read_to_end(&mut bytes) + .map_err(|error| map_io_error(&error))?; + parse_http_response(&bytes) +} + +/// Filter CLI stdout so interpretation-run never prints scientific acceptance. +/// +/// # Errors +/// +/// Returns [`OrchestratorLiveError::InvalidWirePayload`] when the body is empty, +/// carries metric keys, or a success body is not a hypothetical accepted run +/// for the requested idempotency key. +pub fn render_interpretation_run_cli_stdout( + invocation: &InterpretationRunCliInvocation, + response: &OrchestratorLiveResponse, +) -> Result { + invocation.validate()?; + if response.body.is_empty() { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + refuse_scientific_acceptance(&response.body)?; + refuse_metrics_on_interpretation_run_cli_payload(&response.body)?; + if !(200..300).contains(&response.status_code) { + return Ok(response.body.clone()); + } + if response.status_code != 202 { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + let accepted = InterpretationRunAccepted::from_json(&response.body)?; + if accepted.idempotency_key() != invocation.request.idempotency_key() + || accepted.claim_status() != HYPOTHETICAL_CLAIM_STATUS + || accepted.scientific_authority() + { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + accepted.to_json() +} + +fn refuse_scientific_acceptance(body: &str) -> Result<(), OrchestratorLiveError> { + if body.contains(SCIENTIFIC_ACCEPTANCE_SCHEMA) { + Err(OrchestratorLiveError::InvalidWirePayload) + } else { + Ok(()) + } +} + +/// Refuse interpretation-run JSON that already carries scientific-metric keys. +/// +/// Empty payloads are admitted so missing stdin can fail later as invalid +/// wire. Non-object JSON fails closed. +/// +/// # Errors +/// +/// Returns [`OrchestratorLiveError::InvalidWirePayload`] when a forbidden +/// metric or causal key is present or the payload is a non-empty non-object. +pub fn refuse_metrics_on_interpretation_run_cli_payload( + payload: &str, +) -> Result<(), OrchestratorLiveError> { + const FORBIDDEN: [&str; 13] = [ + "rmse", + "rmse_standard_error", + "mean_bias", + "bias_standard_error", + "interval_coverage", + "coverage_wilson_lower", + "coverage_wilson_upper", + "temporal_order_accuracy", + "se_gate_accepted", + "scientific_acceptance", + "causal_score", + "causality", + "terminal_result", + ]; + if payload.trim().is_empty() { + return Ok(()); + } + let value: serde_json::Value = + serde_json::from_str(payload).map_err(|_| OrchestratorLiveError::InvalidWirePayload)?; + if !value.is_object() { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + if contains_forbidden(&value, &FORBIDDEN) { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + Ok(()) +} + +fn contains_forbidden(value: &serde_json::Value, forbidden: &[&str]) -> bool { + match value { + serde_json::Value::Object(object) => object.iter().any(|(key, nested)| { + forbidden.contains(&key.as_str()) || contains_forbidden(nested, forbidden) + }), + serde_json::Value::Array(values) => values + .iter() + .any(|nested| contains_forbidden(nested, forbidden)), + _ => false, + } +} + +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)?; + 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 line in lines { + 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 != 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; create requires JSON. +/// +/// # Errors +/// +/// Returns [`OrchestratorLiveError::InvalidWirePayload`] when stdin cannot be read. +pub fn read_interpretation_run_cli_stdin( + stdin_is_terminal: bool, + mut stdin: impl Read, +) -> Result { + if stdin_is_terminal { + Ok(String::new()) + } else { + let mut body = String::new(); + stdin + .read_to_string(&mut body) + .map_err(|_| OrchestratorLiveError::InvalidWirePayload)?; + Ok(body) + } +} + +#[cfg(test)] +#[allow(clippy::too_many_lines)] +mod tests { + use super::{ + CONTEXTUAL_ORCHESTRATOR_CONSUMER_CODE, InterpretationRunCliInvocation, + InterpretationRunCliVerb, InterpretationRunHttpExchange, SCIENTIFIC_ACCEPTANCE_SCHEMA, + compose_interpretation_run_cli_http, contextual_orchestrator_interpretation_run_exchange, + dispatch_interpretation_run_cli, execute_interpretation_run_cli, + loopback_http1_from_interpretation_run_exchange, parse_http_response, + read_interpretation_run_cli_stdin, render_interpretation_run_cli_stdout, static_reason, + }; + use crate::{ + HYPOTHETICAL_CLAIM_STATUS, INTERPRETATION_RUN_CONTRACT_VERSION, InterpretationRunAccepted, + InterpretationRunRequest, OrchestrationMode, OrchestratorLiveError, + OrchestratorLiveResponse, OrchestratorLiveService, + }; + + const ORIGIN: &str = "https://tepp.example.test"; + + fn query_body() -> String { + InterpretationRunRequest::new( + INTERPRETATION_RUN_CONTRACT_VERSION, + "orch-cli-idem-1", + "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 query_args() -> [&'static str; 7] { + [ + "create", + "--host", + "127.0.0.1:18082", + "--origin", + ORIGIN, + "--consumer", + CONTEXTUAL_ORCHESTRATOR_CONSUMER_CODE, + ] + } + + #[test] + fn verbs_and_from_args_fail_closed() { + assert_eq!( + InterpretationRunCliVerb::parse("create").expect("verb"), + InterpretationRunCliVerb::Create + ); + assert_eq!(InterpretationRunCliVerb::Create.as_str(), "create"); + assert_eq!( + InterpretationRunCliVerb::parse("CREATE"), + Err(OrchestratorLiveError::InvalidWirePayload) + ); + assert_eq!( + InterpretationRunCliVerb::parse("query"), + Err(OrchestratorLiveError::InvalidWirePayload) + ); + assert_eq!( + InterpretationRunCliInvocation::from_args(Vec::::new(), "").unwrap_err(), + OrchestratorLiveError::InvalidWirePayload + ); + assert_eq!( + InterpretationRunCliInvocation::from_args( + [ + "create", + "--host", + "8.8.8.8:80", + "--origin", + ORIGIN, + "--consumer", + CONTEXTUAL_ORCHESTRATOR_CONSUMER_CODE + ], + query_body() + ) + .unwrap_err(), + OrchestratorLiveError::AuthorizationDenied + ); + assert_eq!( + InterpretationRunCliInvocation::from_args( + [ + "create", + "--host", + "127.0.0.1:18082", + "--origin", + "http://tepp.example.test", + "--consumer", + CONTEXTUAL_ORCHESTRATOR_CONSUMER_CODE + ], + query_body() + ) + .unwrap_err(), + OrchestratorLiveError::InvalidWirePayload + ); + assert_eq!( + InterpretationRunCliInvocation::from_args( + [ + "create", + "--host", + "localhost:18082", + "--origin", + ORIGIN, + "--consumer", + CONTEXTUAL_ORCHESTRATOR_CONSUMER_CODE + ], + query_body() + ) + .unwrap_err(), + OrchestratorLiveError::InvalidWirePayload + ); + assert_eq!( + InterpretationRunCliInvocation::from_args( + [ + "create", + "--host", + "127.0.0.1:18082", + "--origin", + ORIGIN, + "--authorization", + "secret" + ], + query_body() + ) + .unwrap_err(), + OrchestratorLiveError::AuthorizationDenied + ); + } + + #[test] + fn from_args_refuses_naruon_metrics_and_empty_body() { + assert_eq!( + InterpretationRunCliInvocation::from_args( + [ + "create", + "--host", + "127.0.0.1:18082", + "--origin", + ORIGIN, + "--consumer", + "naruon" + ], + query_body() + ) + .unwrap_err(), + OrchestratorLiveError::InvalidWirePayload + ); + assert_eq!( + InterpretationRunCliInvocation::from_args( + [ + "create", + "--host", + "127.0.0.1:18082", + "--origin", + ORIGIN, + "--consumer", + "lineageweave" + ], + query_body() + ) + .unwrap_err(), + OrchestratorLiveError::InvalidWirePayload + ); + assert_eq!( + InterpretationRunCliInvocation::from_args(query_args(), r#"{"rmse":1.0}"#).unwrap_err(), + OrchestratorLiveError::InvalidWirePayload + ); + assert_eq!( + InterpretationRunCliInvocation::from_args(query_args(), "").unwrap_err(), + OrchestratorLiveError::InvalidWirePayload + ); + } + + #[test] + fn compose_is_typed_https_post_without_credentials() { + let invocation = + InterpretationRunCliInvocation::from_args(query_args(), query_body()).expect("inv"); + let http = compose_interpretation_run_cli_http(&invocation).expect("http"); + assert!(http.starts_with("POST /v1/interpretation-runs HTTP/1.1")); + assert!(http.contains("tepp-consumer: contextual-orchestrator")); + assert!(http.contains("idempotency-key: orch-cli-idem-1")); + assert!(!http.to_ascii_lowercase().contains("authorization")); + assert!(!http.contains("/analysis-runs")); + assert!(!http.contains("/v1/exports")); + assert!(!http.contains("/v1/project-histories")); + assert!(!http.contains(SCIENTIFIC_ACCEPTANCE_SCHEMA)); + assert!(!http.contains("rmse")); + } + + #[test] + fn dispatch_returns_hypothetical_accepted_run() { + let mut service = OrchestratorLiveService::new(); + let invocation = + InterpretationRunCliInvocation::from_args(query_args(), query_body()).expect("inv"); + let got = dispatch_interpretation_run_cli(&mut service, &invocation).expect("dispatch"); + assert_eq!(got.status_code, 202, "{}", got.body); + let stdout = render_interpretation_run_cli_stdout(&invocation, &got).expect("stdout"); + let accepted = InterpretationRunAccepted::from_json(&stdout).expect("accepted"); + assert_eq!(accepted.idempotency_key(), "orch-cli-idem-1"); + assert_eq!(accepted.claim_status(), HYPOTHETICAL_CLAIM_STATUS); + assert!(!accepted.scientific_authority()); + assert!(!stdout.contains(SCIENTIFIC_ACCEPTANCE_SCHEMA)); + assert!(!stdout.contains("rmse")); + assert!(!stdout.contains("causal_score")); + } + + #[test] + fn loopback_http1_refuses_non_post_and_wrong_path() { + let invocation = + InterpretationRunCliInvocation::from_args(query_args(), query_body()).expect("inv"); + let mut exchange = contextual_orchestrator_interpretation_run_exchange( + &invocation.origin, + &invocation.request, + ) + .expect("exchange"); + exchange.method = "GET"; + assert_eq!( + loopback_http1_from_interpretation_run_exchange(&exchange, &invocation.host) + .unwrap_err(), + OrchestratorLiveError::InvalidWirePayload + ); + let collection = InterpretationRunHttpExchange { + method: "POST", + target_url: "https://tepp.example.test/v1/analysis-runs".into(), + headers: exchange.headers.clone(), + body: exchange.body.clone(), + }; + assert_eq!( + loopback_http1_from_interpretation_run_exchange(&collection, &invocation.host) + .unwrap_err(), + OrchestratorLiveError::InvalidWirePayload + ); + } + + #[test] + fn render_refuses_metrics_schema_and_identity_mismatch() { + let invocation = + InterpretationRunCliInvocation::from_args(query_args(), query_body()).expect("inv"); + assert_eq!( + render_interpretation_run_cli_stdout( + &invocation, + &OrchestratorLiveResponse { + status_code: 202, + reason_phrase: "Accepted", + body: String::new(), + } + ) + .unwrap_err(), + OrchestratorLiveError::InvalidWirePayload + ); + assert_eq!( + render_interpretation_run_cli_stdout( + &invocation, + &OrchestratorLiveResponse { + status_code: 202, + reason_phrase: "Accepted", + body: r#"{"claim_status":"hypothetical","rmse":1.0}"#.into(), + } + ) + .unwrap_err(), + OrchestratorLiveError::InvalidWirePayload + ); + assert_eq!( + render_interpretation_run_cli_stdout( + &invocation, + &OrchestratorLiveResponse { + status_code: 202, + reason_phrase: "Accepted", + body: format!(r#"{{"schema_version":"{SCIENTIFIC_ACCEPTANCE_SCHEMA}"}}"#), + } + ) + .unwrap_err(), + OrchestratorLiveError::InvalidWirePayload + ); + } + + #[test] + fn execute_over_tcp_and_stdin_reader() { + 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 = + InterpretationRunCliInvocation::from_args(query_args(), query_body()).expect("inv"); + invocation.host = addr.to_string(); + let response = execute_interpretation_run_cli(&invocation).expect("tcp"); + assert_eq!(response.status_code, 202, "{}", response.body); + handle.join().expect("join"); + + invocation.host = "127.0.0.1:1".into(); + assert_eq!( + execute_interpretation_run_cli(&invocation).unwrap_err(), + OrchestratorLiveError::InvalidWirePayload + ); + + let parsed = parse_http_response(b"HTTP/1.1 202 Accepted\r\ncontent-length: 2\r\n\r\n{}") + .expect("parse"); + assert_eq!(parsed.status_code, 202); + assert_eq!( + parse_http_response(b"not-http").unwrap_err(), + OrchestratorLiveError::InvalidWirePayload + ); + assert_eq!(static_reason(202).expect("202"), "Accepted"); + assert_eq!( + static_reason(500).unwrap_err(), + OrchestratorLiveError::InvalidWirePayload + ); + let empty = read_interpretation_run_cli_stdin(true, std::io::empty()).expect("tty"); + assert!(empty.is_empty()); + let piped = + read_interpretation_run_cli_stdin(false, std::io::Cursor::new(b"{}")).expect("piped"); + assert_eq!(piped, "{}"); + } +} diff --git a/crates/orchestrator_live/src/interpretation_run_collection_cli.rs b/crates/orchestrator_live/src/interpretation_run_collection_cli.rs new file mode 100644 index 00000000..d9105099 --- /dev/null +++ b/crates/orchestrator_live/src/interpretation_run_collection_cli.rs @@ -0,0 +1,1010 @@ +//! Operator loopback CLI for contextual-orchestrator interpretation-run collection GET. +//! +//! GAP-003A unique slice: operators run `tepp-interpretation-runs list` to mint +//! `contextual_orchestrator_interpretation_run_collection_exchange` onto spawned +//! `tepp-orchestrator-loopback` TCP. Stdout is a metric-free collection page +//! 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 +//! on this orchestrator-owned adapter. `NaruonLiveService` stays POST-only. +//! This module does not duplicate interpretation-run CLI (#425), collection +//! GET (#433), project-history collection CLI (#428), GET-by-id (#429), +//! retrieval CLI (#431), analysis-run collection CLI (#371), 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_collection_http::{ + parse_interpretation_run_collection_page_cursor, + parse_interpretation_run_collection_page_limit, + refuse_metrics_on_interpretation_run_collection_payload, +}; +use crate::request::{ + require_nonempty, DEFAULT_INTERPRETATION_BYTE_LIMIT, HYPOTHETICAL_CLAIM_STATUS, + INTERPRETATION_RUN_PATH, +}; +use crate::{ + contextual_orchestrator_interpretation_run_collection_exchange, InterpretationRunCollection, + InterpretationRunCollectionHttpExchange, 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 collection CLI. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum InterpretationRunCollectionCliVerb { + /// `GET /v1/interpretation-runs`. + List, +} + +impl InterpretationRunCollectionCliVerb { + /// Parse one exact lowercase verb token. + /// + /// # Errors + /// + /// Returns [`OrchestratorLiveError::InvalidWirePayload`] for an unknown token. + pub fn parse(token: &str) -> Result { + match token { + "list" => Ok(Self::List), + _ => Err(OrchestratorLiveError::InvalidWirePayload), + } + } + + /// Return the canonical lowercase verb token. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::List => "list", + } + } +} + +/// One operator CLI invocation against a loopback collection GET listener. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct InterpretationRunCollectionCliInvocation { + /// CLI verb to execute. + pub verb: InterpretationRunCollectionCliVerb, + /// Loopback `host:port` of `tepp-orchestrator-loopback`. + pub host: String, + /// Published HTTPS origin used to mint the typed collection exchange. + pub origin: String, + /// Published modular consumer. Collection GET admits + /// `contextual-orchestrator` only. + pub consumer: String, + /// Optional exclusive page cursor (`tepp-page-cursor`). + pub page_cursor: Option, + /// Optional page limit (`tepp-page-limit`). + pub page_limit: Option, + /// JSON body. Collection GET requires empty. + pub body: String, +} + +impl InterpretationRunCollectionCliInvocation { + /// Parse argv plus stdin body into a validated loopback collection 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, hostile pagination, 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 = InterpretationRunCollectionCliVerb::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 out-of-bounds 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); + } + if !self.body.is_empty() { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + refuse_scientific_acceptance(&self.body)?; + refuse_metrics_on_interpretation_run_collection_payload(&self.body)?; + parse_interpretation_run_collection_page_limit(self.page_limit.as_deref())?; + parse_interpretation_run_collection_page_cursor(self.page_cursor.as_deref())?; + Ok(()) + } +} + +struct ParsedFlags { + host: Option, + origin: Option, + consumer: Option, + page_cursor: Option, + page_limit: Option, +} + +fn parse_flags(rest: &[String]) -> Result { + let mut flags = ParsedFlags { + host: None, + origin: None, + consumer: None, + page_cursor: None, + page_limit: 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, + "page-cursor" => &mut flags.page_cursor, + "page-limit" => &mut flags.page_limit, + _ => 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: InterpretationRunCollectionCliVerb, + flags: ParsedFlags, + body: String, +) -> Result { + let invocation = InterpretationRunCollectionCliInvocation { + 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()), + page_cursor: flags.page_cursor, + page_limit: flags.page_limit, + 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 collection GET exchange as HTTP/1.1 for a loopback listener. +/// +/// The exchange keeps its HTTPS origin contract. Only the HTTP/1.1 `Host` is +/// the loopback bind address. Public bind hosts fail closed. +/// +/// # 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` with an empty body. +pub fn loopback_http1_from_interpretation_run_collection_exchange( + exchange: &InterpretationRunCollectionHttpExchange, + 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)?; + if path != INTERPRETATION_RUN_PATH { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + for (name, _) in &exchange.headers { + if header_is_credential(name) { + return Err(OrchestratorLiveError::AuthorizationDenied); + } + if name.eq_ignore_ascii_case("idempotency-key") { + 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 collection GET from the typed consumer exchange. +/// +/// # Errors +/// +/// Returns the same fail-closed errors as +/// [`InterpretationRunCollectionCliInvocation::validate`]. +pub fn compose_interpretation_run_collection_cli_http( + invocation: &InterpretationRunCollectionCliInvocation, +) -> Result { + invocation.validate()?; + let exchange = contextual_orchestrator_interpretation_run_collection_exchange( + &invocation.origin, + invocation.page_cursor.as_deref(), + invocation.page_limit.as_deref(), + )?; + loopback_http1_from_interpretation_run_collection_exchange(&exchange, &invocation.host) +} + +/// Dispatch one collection CLI invocation against an in-process listener. +/// +/// # Errors +/// +/// Returns fail-closed validation errors before the HTTP handler runs. +pub fn dispatch_interpretation_run_collection_cli( + service: &mut OrchestratorLiveService, + invocation: &InterpretationRunCollectionCliInvocation, +) -> Result { + let request = compose_interpretation_run_collection_cli_http(invocation)?; + Ok(service.handle_http_request(&request)) +} + +/// Execute one collection CLI invocation over loopback TCP. +/// +/// # Errors +/// +/// Returns fail-closed validation, transport, or response-framing errors. +pub fn execute_interpretation_run_collection_cli( + invocation: &InterpretationRunCollectionCliInvocation, +) -> Result { + let addr = require_loopback_host(&invocation.host)?; + let request = compose_interpretation_run_collection_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 collection pages never print scientific acceptance. +/// +/// # Errors +/// +/// Returns [`OrchestratorLiveError::InvalidWirePayload`] when a receipt carries +/// metric keys, evidence, causal scores, or +/// `tepp.scientific_acceptance.v1`, or when rows violate the exclusive cursor +/// / sort / next-cursor contract. +pub fn render_interpretation_run_collection_cli_stdout( + invocation: &InterpretationRunCollectionCliInvocation, + 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 collection = InterpretationRunCollection::from_json(&response.body)?; + let limit = parse_interpretation_run_collection_page_limit(invocation.page_limit.as_deref())?; + if collection.items.len() > limit { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + let cursor = + parse_interpretation_run_collection_page_cursor(invocation.page_cursor.as_deref())?; + for item in &collection.items { + if item.claim_status != HYPOTHETICAL_CLAIM_STATUS || item.scientific_authority { + return Err(OrchestratorLiveError::ScientificAuthorityRefused); + } + } + for index in 1..collection.items.len() { + if collection.items[index - 1].idempotency_key >= collection.items[index].idempotency_key { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + } + if let Some(cursor) = cursor { + for row in &collection.items { + if row.idempotency_key <= cursor { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + } + } + if let Some(next_cursor) = &collection.next_cursor { + match collection.items.last() { + Some(row) if row.idempotency_key == *next_cursor => {} + Some(_) | None => return Err(OrchestratorLiveError::InvalidWirePayload), + } + } + collection.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; collection 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_collection_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_collection_cli_http, dispatch_interpretation_run_collection_cli, + execute_interpretation_run_collection_cli, + loopback_http1_from_interpretation_run_collection_exchange, parse_http_response, + read_interpretation_run_collection_cli_stdin, + render_interpretation_run_collection_cli_stdout, static_reason, + InterpretationRunCollectionCliInvocation, InterpretationRunCollectionCliVerb, + CONTEXTUAL_ORCHESTRATOR_CONSUMER_CODE, SCIENTIFIC_ACCEPTANCE_SCHEMA, + }; + use crate::{ + compose_interpretation_run_cli_http, + contextual_orchestrator_interpretation_run_collection_exchange, + InterpretationRunCollection, InterpretationRunCollectionHttpExchange, + InterpretationRunRequest, OrchestrationMode, OrchestratorLiveError, + OrchestratorLiveResponse, OrchestratorLiveService, HYPOTHETICAL_CLAIM_STATUS, + INTERPRETATION_RUN_COLLECTION_MAX_LIMIT, 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 = crate::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 list_args() -> [&'static str; 7] { + [ + "list", + "--host", + "127.0.0.1:18082", + "--origin", + ORIGIN, + "--consumer", + CONTEXTUAL_ORCHESTRATOR_CONSUMER_CODE, + ] + } + + fn list_invocation() -> InterpretationRunCollectionCliInvocation { + InterpretationRunCollectionCliInvocation::from_args(list_args(), "").expect("list") + } + + #[test] + fn verbs_parse_and_reject_unknown_tokens() { + assert_eq!( + InterpretationRunCollectionCliVerb::parse("list").expect("list"), + InterpretationRunCollectionCliVerb::List + ); + assert_eq!(InterpretationRunCollectionCliVerb::List.as_str(), "list"); + assert_eq!( + InterpretationRunCollectionCliVerb::parse("LIST"), + Err(OrchestratorLiveError::InvalidWirePayload) + ); + assert_eq!( + InterpretationRunCollectionCliVerb::parse("create"), + Err(OrchestratorLiveError::InvalidWirePayload) + ); + assert_eq!( + InterpretationRunCollectionCliVerb::parse("get"), + Err(OrchestratorLiveError::InvalidWirePayload) + ); + } + + #[test] + fn from_args_refuses_public_bind_localhost_http_and_credentials() { + assert_eq!( + InterpretationRunCollectionCliInvocation::from_args(Vec::::new(), "") + .unwrap_err(), + OrchestratorLiveError::InvalidWirePayload + ); + assert_eq!( + InterpretationRunCollectionCliInvocation::from_args( + ["list", "--host", "8.8.8.8:80", "--origin", ORIGIN], + "" + ) + .unwrap_err(), + OrchestratorLiveError::AuthorizationDenied + ); + assert_eq!( + InterpretationRunCollectionCliInvocation::from_args( + ["list", "--host", "0.0.0.0:80", "--origin", ORIGIN], + "" + ) + .unwrap_err(), + OrchestratorLiveError::AuthorizationDenied + ); + assert_eq!( + InterpretationRunCollectionCliInvocation::from_args( + ["list", "--host", "localhost:18082", "--origin", ORIGIN], + "" + ) + .unwrap_err(), + OrchestratorLiveError::InvalidWirePayload + ); + assert_eq!( + InterpretationRunCollectionCliInvocation::from_args( + [ + "list", + "--host", + "127.0.0.1:18082", + "--origin", + "http://tepp.example.test" + ], + "" + ) + .unwrap_err(), + OrchestratorLiveError::InvalidWirePayload + ); + assert_eq!( + InterpretationRunCollectionCliInvocation::from_args( + [ + "list", + "--host", + "127.0.0.1:18082", + "--origin", + ORIGIN, + "--authorization", + "secret" + ], + "" + ) + .unwrap_err(), + OrchestratorLiveError::AuthorizationDenied + ); + } + + #[test] + fn from_args_refuses_unpublished_consumers_body_and_hostile_pagination() { + assert_eq!( + InterpretationRunCollectionCliInvocation::from_args( + [ + "list", + "--host", + "127.0.0.1:18082", + "--origin", + ORIGIN, + "--consumer", + "naruon" + ], + "" + ) + .unwrap_err(), + OrchestratorLiveError::InvalidWirePayload + ); + assert_eq!( + InterpretationRunCollectionCliInvocation::from_args( + [ + "list", + "--host", + "127.0.0.1:18082", + "--origin", + ORIGIN, + "--consumer", + "lineageweave" + ], + "" + ) + .unwrap_err(), + OrchestratorLiveError::InvalidWirePayload + ); + assert_eq!( + InterpretationRunCollectionCliInvocation::from_args(list_args(), "{}").unwrap_err(), + OrchestratorLiveError::InvalidWirePayload + ); + assert_eq!( + InterpretationRunCollectionCliInvocation::from_args(list_args(), r#"{"rmse":1.0}"#) + .unwrap_err(), + OrchestratorLiveError::InvalidWirePayload + ); + assert_eq!( + InterpretationRunCollectionCliInvocation::from_args( + [ + "list", + "--host", + "127.0.0.1:18082", + "--origin", + ORIGIN, + "--page-limit", + "0" + ], + "" + ) + .unwrap_err(), + OrchestratorLiveError::InvalidWirePayload + ); + assert_eq!( + InterpretationRunCollectionCliInvocation::from_args( + [ + "list", + "--host", + "127.0.0.1:18082", + "--origin", + ORIGIN, + "--page-limit", + &(INTERPRETATION_RUN_COLLECTION_MAX_LIMIT + 1).to_string() + ], + "" + ) + .unwrap_err(), + OrchestratorLiveError::LimitExceeded + ); + } + + #[test] + fn list_assembles_get_without_credentials_or_idempotency() { + let list = InterpretationRunCollectionCliInvocation::from_args( + ["list", "--host", "127.0.0.1:18082", "--origin", ORIGIN], + "", + ) + .expect("default consumer"); + assert_eq!(list.verb, InterpretationRunCollectionCliVerb::List); + assert_eq!(list.consumer, CONTEXTUAL_ORCHESTRATOR_CONSUMER_CODE); + let http = compose_interpretation_run_collection_cli_http(&list).expect("http"); + assert!(http.starts_with("GET /v1/interpretation-runs 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!(!http.contains("/analysis-runs")); + assert!(!http.contains("/v1/project-histories")); + assert!(!http.contains(SCIENTIFIC_ACCEPTANCE_SCHEMA)); + + let paged = InterpretationRunCollectionCliInvocation::from_args( + [ + "list", + "--host", + "127.0.0.1:18082", + "--origin", + ORIGIN, + "--page-cursor", + "idem-a", + "--page-limit", + "8", + ], + "", + ) + .expect("paged"); + let paged_http = compose_interpretation_run_collection_cli_http(&paged).expect("paged"); + assert!(paged_http.contains("tepp-page-cursor: idem-a")); + assert!(paged_http.contains("tepp-page-limit: 8")); + } + + #[test] + fn loopback_http1_refuses_post_nonempty_and_foreign_paths() { + let exchange = + contextual_orchestrator_interpretation_run_collection_exchange(ORIGIN, None, None) + .expect("exchange"); + loopback_http1_from_interpretation_run_collection_exchange(&exchange, "127.0.0.1:18082") + .expect("ok"); + let mut posted = exchange.clone(); + posted.method = "POST"; + assert_eq!( + loopback_http1_from_interpretation_run_collection_exchange(&posted, "127.0.0.1:18082") + .unwrap_err(), + OrchestratorLiveError::InvalidWirePayload + ); + let mut nonempty = exchange.clone(); + nonempty.body = "{}".into(); + assert_eq!( + loopback_http1_from_interpretation_run_collection_exchange( + &nonempty, + "127.0.0.1:18082" + ) + .unwrap_err(), + OrchestratorLiveError::InvalidWirePayload + ); + let foreign = InterpretationRunCollectionHttpExchange { + method: "GET", + target_url: "https://tepp.example.test/v1/analysis-runs".into(), + headers: exchange.headers.clone(), + body: String::new(), + }; + assert_eq!( + loopback_http1_from_interpretation_run_collection_exchange(&foreign, "127.0.0.1:18082") + .unwrap_err(), + OrchestratorLiveError::InvalidWirePayload + ); + let mut credential = exchange; + credential + .headers + .push(("authorization".into(), "Bearer secret".into())); + assert_eq!( + loopback_http1_from_interpretation_run_collection_exchange( + &credential, + "127.0.0.1:18082" + ) + .unwrap_err(), + OrchestratorLiveError::AuthorizationDenied + ); + } + + #[test] + fn dispatch_lists_hypothetical_identities_without_metrics() { + let mut service = OrchestratorLiveService::new(); + assert_eq!( + service + .handle_http_request(&create_http("idem-a")) + .status_code, + 202 + ); + assert_eq!( + service + .handle_http_request(&create_http("idem-b")) + .status_code, + 202 + ); + let listed = dispatch_interpretation_run_collection_cli(&mut service, &list_invocation()) + .expect("list"); + assert_eq!(listed.status_code, 200, "{}", listed.body); + let stdout = render_interpretation_run_collection_cli_stdout(&list_invocation(), &listed) + .expect("out"); + assert!(!stdout.contains(SCIENTIFIC_ACCEPTANCE_SCHEMA)); + assert!(!stdout.contains("rmse")); + assert!(!stdout.contains("evidence_span_ids")); + assert!(!stdout.contains("causal_score")); + assert!(!stdout.contains("findings")); + let page = InterpretationRunCollection::from_json(&stdout).expect("page"); + assert_eq!(page.items.len(), 2); + assert_eq!(page.items[0].idempotency_key, "idem-a"); + assert_eq!(page.items[0].claim_status, HYPOTHETICAL_CLAIM_STATUS); + assert!(!page.items[0].scientific_authority); + assert_eq!(page.items[1].idempotency_key, "idem-b"); + + let paged = InterpretationRunCollectionCliInvocation::from_args( + [ + "list", + "--host", + "127.0.0.1:18082", + "--origin", + ORIGIN, + "--page-limit", + "1", + ], + "", + ) + .expect("limit 1"); + assert_eq!( + render_interpretation_run_collection_cli_stdout(&paged, &listed), + Err(OrchestratorLiveError::InvalidWirePayload) + ); + let first = + dispatch_interpretation_run_collection_cli(&mut service, &paged).expect("page 1"); + let first_json = + render_interpretation_run_collection_cli_stdout(&paged, &first).expect("page 1 out"); + let first_page = InterpretationRunCollection::from_json(&first_json).expect("first"); + assert_eq!(first_page.items.len(), 1); + let cursor = first_page.next_cursor.expect("cursor"); + let second_inv = InterpretationRunCollectionCliInvocation::from_args( + [ + "list", + "--host", + "127.0.0.1:18082", + "--origin", + ORIGIN, + "--page-cursor", + cursor.as_str(), + "--page-limit", + "1", + ], + "", + ) + .expect("page 2"); + let second = + dispatch_interpretation_run_collection_cli(&mut service, &second_inv).expect("page 2"); + let second_json = render_interpretation_run_collection_cli_stdout(&second_inv, &second) + .expect("page 2 out"); + let second_page = InterpretationRunCollection::from_json(&second_json).expect("second"); + assert_eq!(second_page.items.len(), 1); + assert_ne!( + first_page.items[0].idempotency_key, + second_page.items[0].idempotency_key + ); + } + + #[test] + fn render_refuses_metrics_schema_and_empty_bodies() { + let list = list_invocation(); + assert_eq!( + render_interpretation_run_collection_cli_stdout( + &list, + &OrchestratorLiveResponse { + status_code: 200, + reason_phrase: "OK", + body: String::new(), + } + ) + .unwrap_err(), + OrchestratorLiveError::InvalidWirePayload + ); + assert_eq!( + render_interpretation_run_collection_cli_stdout( + &list, + &OrchestratorLiveResponse { + status_code: 200, + reason_phrase: "OK", + body: r#"{"contract_version":1,"items":[],"rmse":1.0}"#.into(), + } + ) + .unwrap_err(), + OrchestratorLiveError::InvalidWirePayload + ); + assert_eq!( + render_interpretation_run_collection_cli_stdout( + &list, + &OrchestratorLiveResponse { + status_code: 400, + reason_phrase: "Bad Request", + body: r#"{"error_code":"invalid_wire_payload"}"#.into(), + } + ) + .unwrap_err(), + OrchestratorLiveError::InvalidWirePayload + ); + let empty = render_interpretation_run_collection_cli_stdout( + &list, + &OrchestratorLiveResponse { + status_code: 200, + reason_phrase: "OK", + body: r#"{"contract_version":1,"items":[]}"#.into(), + }, + ) + .expect("empty"); + assert!(empty.contains("\"items\":[]")); + } + + #[test] + fn execute_over_tcp_and_stdin_reader() { + 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 = list_invocation(); + invocation.host = addr.to_string(); + let response = execute_interpretation_run_collection_cli(&invocation).expect("tcp"); + assert_eq!(response.status_code, 200, "{}", response.body); + let stdout = + render_interpretation_run_collection_cli_stdout(&invocation, &response).expect("out"); + let page = InterpretationRunCollection::from_json(&stdout).expect("empty page"); + assert!(page.items.is_empty()); + handle.join().expect("join"); + + invocation.host = "127.0.0.1:1".into(); + assert_eq!( + execute_interpretation_run_collection_cli(&invocation).unwrap_err(), + OrchestratorLiveError::InvalidWirePayload + ); + + let parsed = + parse_http_response(b"HTTP/1.1 200 OK\r\ncontent-length: 2\r\n\r\n{}").expect("parse"); + assert_eq!(parsed.status_code, 200); + assert_eq!( + parse_http_response(b"not-http").unwrap_err(), + OrchestratorLiveError::InvalidWirePayload + ); + assert_eq!(static_reason(200).expect("200"), "OK"); + assert_eq!( + static_reason(500).unwrap_err(), + OrchestratorLiveError::InvalidWirePayload + ); + + let empty = + read_interpretation_run_collection_cli_stdin(true, std::io::empty()).expect("tty"); + assert!(empty.is_empty()); + let piped = read_interpretation_run_collection_cli_stdin(false, std::io::Cursor::new(b"")) + .expect("pipe"); + assert!(piped.is_empty()); + let leftover = + read_interpretation_run_collection_cli_stdin(false, std::io::Cursor::new(b"leftover")) + .expect("leftover"); + assert_eq!(leftover, "leftover"); + } +} diff --git a/crates/orchestrator_live/src/interpretation_run_collection_http.rs b/crates/orchestrator_live/src/interpretation_run_collection_http.rs new file mode 100644 index 00000000..6d2b4e16 --- /dev/null +++ b/crates/orchestrator_live/src/interpretation_run_collection_http.rs @@ -0,0 +1,556 @@ +//! Provider-owned interpretation-run collection GET contracts. +//! +//! GAP-003A unique slice: `GET /v1/interpretation-runs` enumerates accepted +//! hypothetical interpretation runs on `OrchestratorLiveService` / +//! `tepp-orchestrator-loopback` so operators do not guess idempotency keys. +//! Collection rows stay metric-free identities with `claim_status=hypothetical` +//! and `scientific_authority=false`. `tepp.scientific_acceptance.v1` never +//! appears. The page does not infer causality or call a model provider. This +//! module does not duplicate interpretation-run CLI (#425), project-history +//! collection GET (#424), collection CLI (#428), GET-by-id (#429), retrieval +//! CLI (#431), analysis-run collection GET (#368), Leiden, or GAP-010 +//! Figma/export. 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::mode::OrchestrationMode; +use crate::request::{ + DEFAULT_INTERPRETATION_BYTE_LIMIT, HYPOTHETICAL_CLAIM_STATUS, + INTERPRETATION_RUN_CONTRACT_VERSION, INTERPRETATION_RUN_PATH, from_json, + host_implies_table_access, require_byte_limit, require_contract_version, require_nonempty, + to_json, +}; + +/// Default page size for loopback interpretation-run collection GET. +pub const INTERPRETATION_RUN_COLLECTION_DEFAULT_LIMIT: usize = 32; + +/// Maximum page size accepted on loopback interpretation-run collection GET. +pub const INTERPRETATION_RUN_COLLECTION_MAX_LIMIT: usize = 64; + +/// Maximum opaque cursor / idempotency-key length on the collection path. +pub const INTERPRETATION_RUN_COLLECTION_CURSOR_MAX_LEN: usize = 128; + +const FORBIDDEN_COLLECTION_KEYS: [&str; 14] = [ + "rmse", + "rmse_standard_error", + "mean_bias", + "bias_standard_error", + "interval_coverage", + "se_gate_accepted", + "scientific_acceptance", + "evidence_span_ids", + "tenant_workspace_id", + "compute_budget_tokens", + "causal_score", + "findings", + "evidence_text", + "report", +]; + +/// One metric-free collection row for an accepted interpretation run. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct InterpretationRunCollectionItem { + /// Server-assigned opaque interpretation-run identity. + pub interpretation_run_id: String, + /// Exact request idempotency key that minted the stored run. + pub idempotency_key: String, + /// Selected orchestration mode. + pub orchestration_mode: OrchestrationMode, + /// Fixed claim boundary: accepted output is hypothetical. + pub claim_status: String, + /// Always `false`; LLM output is never scientific authority. + pub scientific_authority: bool, +} + +impl InterpretationRunCollectionItem { + /// Construct a validated metric-free collection row. + /// + /// # Errors + /// + /// Returns a fail-closed error for empty identities or a non-hypothetical + /// claim. + pub fn new( + interpretation_run_id: impl Into, + idempotency_key: impl Into, + orchestration_mode: OrchestrationMode, + claim_status: impl Into, + scientific_authority: bool, + ) -> Result { + let item = Self { + interpretation_run_id: interpretation_run_id.into(), + idempotency_key: idempotency_key.into(), + orchestration_mode, + claim_status: claim_status.into(), + scientific_authority, + }; + item.validate()?; + Ok(item) + } + + fn validate(&self) -> Result<(), OrchestratorLiveError> { + require_nonempty(&self.interpretation_run_id)?; + require_nonempty(&self.idempotency_key)?; + if self.idempotency_key.len() > INTERPRETATION_RUN_COLLECTION_CURSOR_MAX_LEN { + return Err(OrchestratorLiveError::LimitExceeded); + } + if self.claim_status != HYPOTHETICAL_CLAIM_STATUS || self.scientific_authority { + return Err(OrchestratorLiveError::ScientificAuthorityRefused); + } + Ok(()) + } +} + +/// Metric-free interpretation-run collection page. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct InterpretationRunCollection { + /// Semantic contract version. + pub contract_version: u16, + /// Metric-free rows on this page. + pub items: Vec, + /// Exclusive cursor for the next page, when more rows exist. + #[serde(skip_serializing_if = "Option::is_none")] + pub next_cursor: Option, +} + +impl InterpretationRunCollection { + /// Construct a validated collection page. + /// + /// # Errors + /// + /// Returns a fail-closed error for an oversized page or a hostile cursor. + pub fn new( + items: Vec, + next_cursor: Option, + ) -> Result { + if items.len() > INTERPRETATION_RUN_COLLECTION_MAX_LIMIT { + return Err(OrchestratorLiveError::LimitExceeded); + } + for item in &items { + item.validate()?; + } + if let Some(cursor) = next_cursor.as_deref() { + parse_interpretation_run_collection_page_cursor(Some(cursor))?; + } + Ok(Self { + contract_version: INTERPRETATION_RUN_CONTRACT_VERSION, + items, + next_cursor, + }) + } + + /// Parse a collection page. + /// + /// # Errors + /// + /// Returns a size, JSON, version, or claim-boundary error. + pub fn from_json(payload: &str) -> Result { + require_byte_limit(payload, DEFAULT_INTERPRETATION_BYTE_LIMIT)?; + refuse_metrics_on_interpretation_run_collection_payload(payload)?; + let collection: Self = from_json(payload)?; + require_contract_version( + collection.contract_version, + INTERPRETATION_RUN_CONTRACT_VERSION, + )?; + InterpretationRunCollection::new(collection.items, collection.next_cursor) + } + + /// Serialize a validated collection page. + /// + /// # Errors + /// + /// Returns a validation or serialization error. + pub fn to_json(&self) -> Result { + require_contract_version(self.contract_version, INTERPRETATION_RUN_CONTRACT_VERSION)?; + InterpretationRunCollection::new(self.items.clone(), self.next_cursor.clone())?; + let payload = to_json(self)?; + refuse_metrics_on_interpretation_run_collection_payload(&payload)?; + Ok(payload) + } +} + +/// Typed GET exchange for interpretation-run collection. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct InterpretationRunCollectionHttpExchange { + /// HTTP method, always `GET`. + pub method: &'static str, + /// Absolute HTTPS target ending in [`INTERPRETATION_RUN_PATH`]. + pub target_url: String, + /// Exact version, consumer, and content headers. No credentials. + pub headers: Vec<(String, String)>, + /// GET body, always empty. + pub body: String, +} + +/// Whether a path is the interpretation-run collection resource. +#[must_use] +pub fn is_interpretation_run_collection_path(path: &str) -> bool { + path == INTERPRETATION_RUN_PATH +} + +/// Parse the optional `tepp-page-limit` header. +/// +/// # Errors +/// +/// Returns [`OrchestratorLiveError::InvalidWirePayload`] for a non-integer or +/// zero limit, and [`OrchestratorLiveError::LimitExceeded`] above the maximum. +pub fn parse_interpretation_run_collection_page_limit( + raw: Option<&str>, +) -> Result { + let Some(raw) = raw else { + return Ok(INTERPRETATION_RUN_COLLECTION_DEFAULT_LIMIT); + }; + require_nonempty(raw)?; + let limit: usize = raw + .parse() + .map_err(|_| OrchestratorLiveError::InvalidWirePayload)?; + if limit == 0 { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + if limit > INTERPRETATION_RUN_COLLECTION_MAX_LIMIT { + return Err(OrchestratorLiveError::LimitExceeded); + } + Ok(limit) +} + +/// Parse the optional exclusive `tepp-page-cursor` header. +/// +/// # Errors +/// +/// Returns [`OrchestratorLiveError::InvalidWirePayload`] for an empty cursor +/// and [`OrchestratorLiveError::LimitExceeded`] when oversized. +pub fn parse_interpretation_run_collection_page_cursor( + raw: Option<&str>, +) -> Result, OrchestratorLiveError> { + let Some(raw) = raw else { + return Ok(None); + }; + require_nonempty(raw)?; + if raw.contains('/') || raw.contains('\0') { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + if raw.len() > INTERPRETATION_RUN_COLLECTION_CURSOR_MAX_LEN { + return Err(OrchestratorLiveError::LimitExceeded); + } + Ok(Some(raw.to_owned())) +} + +/// Page stored collection rows with an exclusive idempotency-key cursor. +#[must_use] +pub fn page_interpretation_run_collection_items( + mut items: Vec, + cursor: Option<&str>, + limit: usize, +) -> (Vec, Option) { + items.sort_by(|left, right| left.idempotency_key.cmp(&right.idempotency_key)); + let start = cursor.map_or(0, |cursor| { + items + .iter() + .position(|item| item.idempotency_key.as_str() > cursor) + .unwrap_or(items.len()) + }); + let end = (start + limit).min(items.len()); + let next_cursor = if end < items.len() { + Some(items[end - 1].idempotency_key.clone()) + } else { + None + }; + (items[start..end].to_vec(), next_cursor) +} + +/// Refuse metric, evidence, and causal-score keys on collection JSON. +/// +/// Empty payloads are admitted for the GET request body. +/// +/// # Errors +/// +/// Returns [`OrchestratorLiveError::InvalidWirePayload`] when a forbidden key +/// or `tepp.scientific_acceptance.v1` appears, or nonempty JSON is not an +/// object. +pub fn refuse_metrics_on_interpretation_run_collection_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)?; + if !value.is_object() { + return 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_COLLECTION_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(()), + } +} + +/// Build a credential-free contextual-orchestrator collection GET exchange. +/// +/// # Errors +/// +/// Returns a fail-closed origin or pagination error. +pub fn contextual_orchestrator_interpretation_run_collection_exchange( + origin: &str, + page_cursor: Option<&str>, + page_limit: Option<&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); + } + parse_interpretation_run_collection_page_cursor(page_cursor)?; + parse_interpretation_run_collection_page_limit(page_limit)?; + let mut headers = vec![ + ("content-type".into(), "application/json".into()), + ( + "tepp-consumer".into(), + CONTEXTUAL_ORCHESTRATOR_CONSUMER_CODE.into(), + ), + ("tepp-contract-version".into(), "1".into()), + ]; + if let Some(cursor) = page_cursor { + headers.push(("tepp-page-cursor".into(), cursor.to_owned())); + } + if let Some(limit) = page_limit { + headers.push(("tepp-page-limit".into(), limit.to_owned())); + } + Ok(InterpretationRunCollectionHttpExchange { + method: "GET", + target_url: format!("{origin}{INTERPRETATION_RUN_PATH}"), + headers, + body: String::new(), + }) +} + +#[cfg(test)] +mod tests { + use super::{ + INTERPRETATION_RUN_COLLECTION_CURSOR_MAX_LEN, INTERPRETATION_RUN_COLLECTION_MAX_LIMIT, + InterpretationRunCollection, InterpretationRunCollectionItem, + contextual_orchestrator_interpretation_run_collection_exchange, + is_interpretation_run_collection_path, page_interpretation_run_collection_items, + parse_interpretation_run_collection_page_cursor, + parse_interpretation_run_collection_page_limit, + refuse_metrics_on_interpretation_run_collection_payload, + }; + use crate::error::OrchestratorLiveError; + use crate::mode::OrchestrationMode; + use crate::request::INTERPRETATION_RUN_PATH; + + fn sample_item(id: &str, idem: &str) -> InterpretationRunCollectionItem { + InterpretationRunCollectionItem::new( + id, + idem, + OrchestrationMode::Direct, + "hypothetical", + false, + ) + .expect("item") + } + + #[test] + fn collection_exchange_is_metric_free_get_without_credentials() { + let exchange = contextual_orchestrator_interpretation_run_collection_exchange( + "https://tepp.example.test", + Some("idem-a"), + Some("8"), + ) + .expect("exchange"); + assert_eq!(exchange.method, "GET"); + assert!(exchange.target_url.ends_with(INTERPRETATION_RUN_PATH)); + 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_collection_path( + INTERPRETATION_RUN_PATH + )); + assert!(!is_interpretation_run_collection_path( + "/v1/interpretation-runs/extra" + )); + let json = + InterpretationRunCollection::new(vec![sample_item("orch-run-1", "idem-a")], None) + .expect("page") + .to_json() + .expect("json"); + assert!(!json.contains("rmse")); + assert!(!json.contains("evidence_span_ids")); + assert!(!json.contains("tepp.scientific_acceptance.v1")); + InterpretationRunCollection::from_json(&json).expect("roundtrip"); + } + + #[test] + fn collection_payloads_and_origins_fail_closed() { + assert_eq!( + refuse_metrics_on_interpretation_run_collection_payload(""), + Ok(()) + ); + assert_eq!( + refuse_metrics_on_interpretation_run_collection_payload(r#"{"rmse":1.0}"#), + Err(OrchestratorLiveError::InvalidWirePayload) + ); + assert_eq!( + refuse_metrics_on_interpretation_run_collection_payload(r#"{"causal_score":1}"#), + Err(OrchestratorLiveError::InvalidWirePayload) + ); + assert_eq!( + refuse_metrics_on_interpretation_run_collection_payload( + r#"{"schema_version":"tepp.scientific_acceptance.v1"}"# + ), + Err(OrchestratorLiveError::InvalidWirePayload) + ); + assert_eq!( + refuse_metrics_on_interpretation_run_collection_payload("[1]"), + Err(OrchestratorLiveError::InvalidWirePayload) + ); + assert_eq!( + contextual_orchestrator_interpretation_run_collection_exchange( + "http://insecure.example", + None, + None + ), + Err(OrchestratorLiveError::InvalidWirePayload) + ); + assert_eq!( + contextual_orchestrator_interpretation_run_collection_exchange( + "https://user:pass@tepp.example.test", + None, + None + ), + Err(OrchestratorLiveError::InvalidWirePayload) + ); + assert_eq!( + contextual_orchestrator_interpretation_run_collection_exchange( + "https://postgres.example.test", + None, + None + ), + Err(OrchestratorLiveError::InvalidWirePayload) + ); + } + + #[test] + fn collection_pagination_and_claim_boundary_fail_closed() { + assert_eq!( + parse_interpretation_run_collection_page_limit(Some("0")), + Err(OrchestratorLiveError::InvalidWirePayload) + ); + assert_eq!( + parse_interpretation_run_collection_page_limit(Some("nope")), + Err(OrchestratorLiveError::InvalidWirePayload) + ); + assert_eq!( + parse_interpretation_run_collection_page_limit(Some( + &(INTERPRETATION_RUN_COLLECTION_MAX_LIMIT + 1).to_string() + )), + Err(OrchestratorLiveError::LimitExceeded) + ); + assert_eq!( + parse_interpretation_run_collection_page_cursor(Some("idem/slash")), + Err(OrchestratorLiveError::InvalidWirePayload) + ); + assert_eq!( + parse_interpretation_run_collection_page_cursor(Some("idem\0nul")), + Err(OrchestratorLiveError::InvalidWirePayload) + ); + assert_eq!( + parse_interpretation_run_collection_page_cursor(Some( + &"k".repeat(INTERPRETATION_RUN_COLLECTION_CURSOR_MAX_LEN + 1) + )), + Err(OrchestratorLiveError::LimitExceeded) + ); + assert_eq!( + InterpretationRunCollectionItem::new( + " ", + "idem-a", + OrchestrationMode::Direct, + "hypothetical", + false, + ) + .expect_err("empty id"), + OrchestratorLiveError::InvalidWirePayload + ); + assert_eq!( + InterpretationRunCollectionItem::new( + "orch-run-1", + "idem-a", + OrchestrationMode::Direct, + "accepted", + false, + ) + .expect_err("claim"), + OrchestratorLiveError::ScientificAuthorityRefused + ); + assert_eq!( + InterpretationRunCollectionItem::new( + "orch-run-1", + "idem-a", + OrchestrationMode::Direct, + "hypothetical", + true, + ) + .expect_err("authority"), + OrchestratorLiveError::ScientificAuthorityRefused + ); + let first = sample_item("orch-run-1", "idem-a"); + let second = sample_item("orch-run-2", "idem-b"); + let (page, next) = + page_interpretation_run_collection_items(vec![second.clone(), first.clone()], None, 1); + assert_eq!(page, vec![first.clone()]); + assert_eq!(next.as_deref(), Some("idem-a")); + let (rest, done) = page_interpretation_run_collection_items( + vec![first.clone(), second.clone()], + Some("idem-a"), + 1, + ); + assert_eq!(rest, vec![second.clone()]); + assert_eq!(done, None); + InterpretationRunCollection::new(vec![first], Some("idem-a".into())).expect("page"); + assert_eq!( + InterpretationRunCollection::from_json(r#"{"contract_version":9,"items":[]}"#) + .expect_err("version"), + OrchestratorLiveError::UnsupportedContractVersion + ); + } +} diff --git a/crates/orchestrator_live/src/lib.rs b/crates/orchestrator_live/src/lib.rs index 52fd5908..cc431d99 100644 --- a/crates/orchestrator_live/src/lib.rs +++ b/crates/orchestrator_live/src/lib.rs @@ -2,39 +2,110 @@ #![deny(missing_docs)] //! Loopback live HTTP/1.1 listener for contextual-orchestrator interpretation. //! -//! The listener accepts `POST /v1/interpretation-runs` on loopback only. -//! Accepted output is always hypothetical and never scientific authority. +//! The listener accepts `POST /v1/interpretation-runs` and +//! `GET /v1/interpretation-runs` on loopback only. Accepted output is always +//! hypothetical and never scientific authority. Collection GET enumerates +//! metric-free identities so operators do not guess idempotency keys. //! 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). +//! termination or call a model provider (ADR 0010; ADR 0011). The published +//! `tepp-interpretation-runs` CLI mints typed contextual-orchestrator POST and +//! collection GET exchanges onto spawned `tepp-orchestrator-loopback` TCP. mod error; mod http; +mod interpretation_run_cli; +mod interpretation_run_collection_cli; +mod interpretation_run_collection_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; +/// 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. +pub use interpretation_run_cli::contextual_orchestrator_interpretation_run_exchange; +/// Dispatch an interpretation-run CLI invocation against an in-process listener. +pub use interpretation_run_cli::dispatch_interpretation_run_cli; +/// Execute an interpretation-run CLI invocation over loopback TCP. +pub use interpretation_run_cli::execute_interpretation_run_cli; +/// Render a typed interpretation-run exchange onto a loopback HTTP/1.1 request. +pub use interpretation_run_cli::loopback_http1_from_interpretation_run_exchange; +/// Read leftover stdin for the interpretation-run CLI. +pub use interpretation_run_cli::read_interpretation_run_cli_stdin; +/// Refuse scientific-metric keys on interpretation-run CLI JSON. +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; +/// Compose HTTP/1.1 interpretation-run collection GET from a CLI invocation. +pub use interpretation_run_collection_cli::compose_interpretation_run_collection_cli_http; +/// Dispatch an interpretation-run collection CLI invocation in-process. +pub use interpretation_run_collection_cli::dispatch_interpretation_run_collection_cli; +/// Execute an interpretation-run collection CLI invocation over loopback TCP. +pub use interpretation_run_collection_cli::execute_interpretation_run_collection_cli; +/// Render a typed collection GET exchange onto a loopback HTTP/1.1 request. +pub use interpretation_run_collection_cli::loopback_http1_from_interpretation_run_collection_exchange; +/// Read leftover stdin for the interpretation-run collection CLI. +pub use interpretation_run_collection_cli::read_interpretation_run_collection_cli_stdin; +/// Filter collection CLI stdout so listed rows stay hypothetical identities. +pub use interpretation_run_collection_cli::render_interpretation_run_collection_cli_stdout; +/// Loopback interpretation-run collection CLI invocation. +pub use interpretation_run_collection_cli::InterpretationRunCollectionCliInvocation; +/// Loopback interpretation-run collection CLI verb. +pub use interpretation_run_collection_cli::InterpretationRunCollectionCliVerb; +/// 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. +pub use interpretation_run_collection_http::is_interpretation_run_collection_path; +/// Page stored collection rows with an exclusive idempotency-key cursor. +pub use interpretation_run_collection_http::page_interpretation_run_collection_items; +/// Parse the optional exclusive `tepp-page-cursor` header. +pub use interpretation_run_collection_http::parse_interpretation_run_collection_page_cursor; +/// Parse the optional `tepp-page-limit` header. +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; /// 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. pub use request::HYPOTHETICAL_CLAIM_STATUS; /// Supported interpretation-run contract version. pub use request::INTERPRETATION_RUN_CONTRACT_VERSION; -/// Versioned path contextual-orchestrator may POST. +/// 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/request.rs b/crates/orchestrator_live/src/request.rs index 4d6d9d71..a588d974 100644 --- a/crates/orchestrator_live/src/request.rs +++ b/crates/orchestrator_live/src/request.rs @@ -8,7 +8,7 @@ use crate::mode::OrchestrationMode; /// Supported interpretation-run contract version. pub const INTERPRETATION_RUN_CONTRACT_VERSION: u16 = 1; -/// Versioned path contextual-orchestrator may POST. +/// Versioned path contextual-orchestrator may POST or GET. pub const INTERPRETATION_RUN_PATH: &str = "/v1/interpretation-runs"; /// Default maximum interpretation-run JSON payload size in bytes. diff --git a/crates/orchestrator_live/src/service.rs b/crates/orchestrator_live/src/service.rs index 06f4c541..db39dcfd 100644 --- a/crates/orchestrator_live/src/service.rs +++ b/crates/orchestrator_live/src/service.rs @@ -6,7 +6,14 @@ use std::net::{SocketAddr, TcpListener, TcpStream}; use crate::error::OrchestratorLiveError; use crate::http::{ OrchestratorLiveResponse, header_value, map_io_error, parse_headers, parse_request_line, - read_http_request, refuse_live_headers, split_request, status_for, write_response, + read_http_request, refuse_collection_get_headers, refuse_live_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, }; use crate::request::{ INTERPRETATION_RUN_PATH, InterpretationRunAccepted, InterpretationRunRequest, to_json, @@ -17,6 +24,8 @@ use crate::request::{ /// Production interchange remains optional and versioned. This listener binds /// loopback TCP so tests and standalone operation can prove request handling /// without TLS termination, table access, or scientific-authority promotion. +/// `GET /v1/interpretation-runs` enumerates accepted hypothetical runs as +/// metric-free identities. #[derive(Debug)] pub struct OrchestratorLiveService { listener: Option, @@ -162,14 +171,59 @@ impl OrchestratorLiveService { let mut lines = header_block.split("\r\n"); let request_line = lines.next().unwrap_or(""); let (method, path) = parse_request_line(request_line)?; + let headers = parse_headers(lines)?; + if method == "GET" { + return self.list_interpretation_runs(path, &headers, body); + } if method != "POST" || path != INTERPRETATION_RUN_PATH { return Err(OrchestratorLiveError::InvalidWirePayload); } - let headers = parse_headers(lines)?; refuse_live_headers(&headers)?; self.accept_interpretation_run(&headers, body) } + fn list_interpretation_runs( + &self, + path: &str, + headers: &HashMap, + body: &str, + ) -> Result { + if !is_interpretation_run_collection_path(path) { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + if !body.is_empty() { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + refuse_collection_get_headers(headers)?; + let limit = parse_interpretation_run_collection_page_limit( + headers.get("tepp-page-limit").map(String::as_str), + )?; + let cursor = parse_interpretation_run_collection_page_cursor( + headers.get("tepp-page-cursor").map(String::as_str), + )?; + let items = self + .accepted_runs + .values() + .map(|(_, accepted)| { + InterpretationRunCollectionItem::new( + accepted.interpretation_run_id(), + accepted.idempotency_key(), + accepted.orchestration_mode(), + accepted.claim_status(), + accepted.scientific_authority(), + ) + }) + .collect::, _>>()?; + let (page, next_cursor) = + page_interpretation_run_collection_items(items, cursor.as_deref(), limit); + let collection = InterpretationRunCollection::new(page, next_cursor)?; + Ok(OrchestratorLiveResponse::json( + 200, + "OK", + collection.to_json()?, + )) + } + fn accept_interpretation_run( &mut self, headers: &HashMap, diff --git a/crates/orchestrator_live/tests/interpretation_run_cli_contract.rs b/crates/orchestrator_live/tests/interpretation_run_cli_contract.rs new file mode 100644 index 00000000..7f7028ba --- /dev/null +++ b/crates/orchestrator_live/tests/interpretation_run_cli_contract.rs @@ -0,0 +1,86 @@ +//! Contract tests for the contextual-orchestrator interpretation-run loopback CLI. + +use orchestrator_live::{ + CONTEXTUAL_ORCHESTRATOR_CONSUMER_CODE, INTERPRETATION_RUN_CONTRACT_VERSION, + InterpretationRunCliInvocation, InterpretationRunCliVerb, InterpretationRunRequest, + OrchestrationMode, OrchestratorLiveError, compose_interpretation_run_cli_http, +}; + +fn query_body() -> String { + InterpretationRunRequest::new( + INTERPRETATION_RUN_CONTRACT_VERSION, + "orch-cli-contract-1", + "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") +} + +#[test] +fn interpretation_run_cli_is_metric_free_post_without_credentials() { + assert_eq!( + InterpretationRunCliVerb::parse("create").expect("verb"), + InterpretationRunCliVerb::Create + ); + let invocation = InterpretationRunCliInvocation::from_args( + [ + "create", + "--host", + "127.0.0.1:18082", + "--origin", + "https://tepp.example.test", + "--consumer", + CONTEXTUAL_ORCHESTRATOR_CONSUMER_CODE, + ], + query_body(), + ) + .expect("invocation"); + let http = compose_interpretation_run_cli_http(&invocation).expect("http"); + assert!(http.starts_with("POST /v1/interpretation-runs HTTP/1.1")); + assert!(http.contains("tepp-consumer: contextual-orchestrator")); + assert!(!http.contains("authorization")); + assert!(!http.contains("tepp.scientific_acceptance.v1")); + assert!(!http.contains("/analysis-runs")); + assert!(!http.contains("/v1/exports")); +} + +#[test] +fn interpretation_run_cli_refuses_non_loopback_unknown_verbs_and_metrics() { + assert_eq!( + InterpretationRunCliInvocation::from_args( + [ + "create", + "--host", + "8.8.8.8:80", + "--origin", + "https://tepp.example.test" + ], + query_body() + ), + Err(OrchestratorLiveError::AuthorizationDenied) + ); + assert_eq!( + InterpretationRunCliVerb::parse("cancel"), + Err(OrchestratorLiveError::InvalidWirePayload) + ); + assert_eq!( + InterpretationRunCliInvocation::from_args( + [ + "create", + "--host", + "127.0.0.1:18082", + "--origin", + "https://tepp.example.test" + ], + r#"{"rmse":1.0}"# + ), + Err(OrchestratorLiveError::InvalidWirePayload) + ); +} diff --git a/crates/orchestrator_live/tests/interpretation_run_collection_cli_contract.rs b/crates/orchestrator_live/tests/interpretation_run_collection_cli_contract.rs new file mode 100644 index 00000000..428898d1 --- /dev/null +++ b/crates/orchestrator_live/tests/interpretation_run_collection_cli_contract.rs @@ -0,0 +1,87 @@ +//! Contract tests for the contextual-orchestrator interpretation-run collection CLI. + +use orchestrator_live::{ + compose_interpretation_run_collection_cli_http, InterpretationRunCollectionCliInvocation, + InterpretationRunCollectionCliVerb, OrchestratorLiveError, + CONTEXTUAL_ORCHESTRATOR_CONSUMER_CODE, +}; + +#[test] +fn interpretation_run_collection_cli_is_metric_free_get_without_credentials() { + assert_eq!( + InterpretationRunCollectionCliVerb::parse("list").expect("verb"), + InterpretationRunCollectionCliVerb::List + ); + let invocation = InterpretationRunCollectionCliInvocation::from_args( + [ + "list", + "--host", + "127.0.0.1:18082", + "--origin", + "https://tepp.example.test", + "--consumer", + CONTEXTUAL_ORCHESTRATOR_CONSUMER_CODE, + ], + "", + ) + .expect("invocation"); + let http = compose_interpretation_run_collection_cli_http(&invocation).expect("http"); + assert!(http.starts_with("GET /v1/interpretation-runs HTTP/1.1")); + assert!(http.contains("tepp-consumer: contextual-orchestrator")); + assert!(http.contains("content-length: 0")); + assert!(!http.contains("authorization")); + assert!(!http.contains("idempotency-key")); + assert!(!http.contains("tepp.scientific_acceptance.v1")); + assert!(!http.contains("/analysis-runs")); + assert!(!http.contains("/v1/exports")); + assert!(!http.contains("/v1/project-histories")); +} + +#[test] +fn interpretation_run_collection_cli_refuses_non_loopback_unknown_verbs_and_foreign_consumers() { + assert_eq!( + InterpretationRunCollectionCliInvocation::from_args( + [ + "list", + "--host", + "8.8.8.8:80", + "--origin", + "https://tepp.example.test" + ], + "" + ), + Err(OrchestratorLiveError::AuthorizationDenied) + ); + assert_eq!( + InterpretationRunCollectionCliVerb::parse("create"), + Err(OrchestratorLiveError::InvalidWirePayload) + ); + assert_eq!( + InterpretationRunCollectionCliInvocation::from_args( + [ + "list", + "--host", + "127.0.0.1:18082", + "--origin", + "https://tepp.example.test", + "--consumer", + "naruon" + ], + "" + ), + Err(OrchestratorLiveError::InvalidWirePayload) + ); + assert_eq!( + InterpretationRunCollectionCliInvocation::from_args( + [ + "list", + "--host", + "127.0.0.1:18082", + "--origin", + "https://tepp.example.test" + ], + "{}" + ), + Err(OrchestratorLiveError::InvalidWirePayload) + ); +} diff --git a/crates/orchestrator_live/tests/interpretation_run_collection_http_contract.rs b/crates/orchestrator_live/tests/interpretation_run_collection_http_contract.rs new file mode 100644 index 00000000..e63c4c4a --- /dev/null +++ b/crates/orchestrator_live/tests/interpretation_run_collection_http_contract.rs @@ -0,0 +1,80 @@ +//! Contract tests for loopback `GET /v1/interpretation-runs`. + +use orchestrator_live::{ + INTERPRETATION_RUN_PATH, InterpretationRunCollection, InterpretationRunCollectionItem, + OrchestrationMode, OrchestratorLiveError, + contextual_orchestrator_interpretation_run_collection_exchange, + is_interpretation_run_collection_path, refuse_metrics_on_interpretation_run_collection_payload, +}; + +#[test] +fn interpretation_run_collection_is_metric_free_get_without_credentials() { + assert!(is_interpretation_run_collection_path( + INTERPRETATION_RUN_PATH + )); + assert!(!is_interpretation_run_collection_path( + "/v1/interpretation-runs/extra" + )); + let item = InterpretationRunCollectionItem::new( + "orch-run-1", + "idem-1", + OrchestrationMode::Direct, + "hypothetical", + false, + ) + .expect("item"); + let page = InterpretationRunCollection::new(vec![item], None).expect("page"); + let json = page.to_json().expect("json"); + assert!(!json.contains("rmse")); + assert!(!json.contains("tepp.scientific_acceptance.v1")); + assert!(!json.contains("evidence_span_ids")); + assert!(!json.contains("tenant_workspace_id")); + assert!(!json.contains("compute_budget_tokens")); + assert!(!json.contains("causal_score")); + assert!(json.contains("\"claim_status\":\"hypothetical\"")); + assert!(json.contains("\"scientific_authority\":false")); + let exchange = contextual_orchestrator_interpretation_run_collection_exchange( + "https://tepp.example.test", + None, + None, + ) + .expect("exchange"); + assert_eq!(exchange.method, "GET"); + assert!(exchange.target_url.ends_with("/v1/interpretation-runs")); + assert!(exchange.body.is_empty()); + assert!( + !exchange + .headers + .iter() + .any(|(name, _)| name.eq_ignore_ascii_case("authorization") + || name.eq_ignore_ascii_case("idempotency-key")) + ); +} + +#[test] +fn interpretation_run_collection_refuses_metrics_evidence_and_insecure_origins() { + assert_eq!( + refuse_metrics_on_interpretation_run_collection_payload(r#"{"rmse":1.0}"#), + Err(OrchestratorLiveError::InvalidWirePayload) + ); + assert_eq!( + refuse_metrics_on_interpretation_run_collection_payload(r#"{"evidence_text":"x"}"#), + Err(OrchestratorLiveError::InvalidWirePayload) + ); + assert_eq!( + refuse_metrics_on_interpretation_run_collection_payload(r#"{"findings":[]}"#), + Err(OrchestratorLiveError::InvalidWirePayload) + ); + assert_eq!( + contextual_orchestrator_interpretation_run_collection_exchange( + "http://insecure.example", + None, + None + ), + Err(OrchestratorLiveError::InvalidWirePayload) + ); + assert!(!is_interpretation_run_collection_path("/v1/analysis-runs")); + assert!(!is_interpretation_run_collection_path( + "/v1/project-histories" + )); +} diff --git a/crates/orchestrator_live/tests/live_http_contract.rs b/crates/orchestrator_live/tests/live_http_contract.rs index d16f081f..5a60de06 100644 --- a/crates/orchestrator_live/tests/live_http_contract.rs +++ b/crates/orchestrator_live/tests/live_http_contract.rs @@ -8,9 +8,9 @@ use std::time::Duration; use orchestrator_live::{ DEFAULT_INTERPRETATION_BYTE_LIMIT, INTERPRETATION_RUN_CONTRACT_VERSION, - INTERPRETATION_RUN_PATH, InterpretationRunAccepted, InterpretationRunRequest, - LIVE_HEADER_BYTE_LIMIT, LIVE_HEADER_COUNT_LIMIT, OrchestrationMode, OrchestratorLiveError, - OrchestratorLiveService, + INTERPRETATION_RUN_PATH, InterpretationRunAccepted, InterpretationRunCollection, + InterpretationRunRequest, LIVE_HEADER_BYTE_LIMIT, LIVE_HEADER_COUNT_LIMIT, OrchestrationMode, + OrchestratorLiveError, OrchestratorLiveService, }; fn sample_request() -> InterpretationRunRequest { @@ -38,6 +38,15 @@ fn orchestrator_headers(idempotency_key: &str) -> Vec<(String, String)> { ] } +fn collection_headers() -> Vec<(String, String)> { + vec![ + ("Host".into(), "127.0.0.1".into()), + ("content-type".into(), "application/json".into()), + ("tepp-consumer".into(), "contextual-orchestrator".into()), + ("tepp-contract-version".into(), "1".into()), + ] +} + fn http_request(method: &str, path: &str, headers: &[(String, String)], body: &str) -> String { let mut request = format!("{method} {path} HTTP/1.1\r\n"); for (name, value) in headers { @@ -295,6 +304,174 @@ fn handle_http_refuses_methods_paths_and_table_hosts() { assert_eq!(service.handle_http_request(&http10).status_code, 400); } +#[test] +fn handle_http_enumerates_interpretation_runs_on_collection_get() { + let mut service = OrchestratorLiveService::new(); + let empty = service.handle_http_request(&http_request( + "GET", + INTERPRETATION_RUN_PATH, + &collection_headers(), + "", + )); + assert_eq!(empty.status_code, 200); + let empty_page = InterpretationRunCollection::from_json(&empty.body).expect("empty"); + assert!(empty_page.items.is_empty()); + assert_eq!(empty_page.next_cursor, None); + + let first = sample_request(); + assert_eq!( + service + .handle_http_request(&interpretation_http(&first)) + .status_code, + 202 + ); + let second = InterpretationRunRequest::new( + INTERPRETATION_RUN_CONTRACT_VERSION, + "orch-live-idem-002", + "orch-tenant-workspace-demo", + "tepp-snapshot-demo-001", + "2026-08-01T00:00:00Z", + OrchestrationMode::Verify, + 2048, + vec!["span-001".into()], + false, + ) + .expect("second"); + assert_eq!( + service + .handle_http_request(&interpretation_http(&second)) + .status_code, + 202 + ); + + let listed = service.handle_http_request(&http_request( + "GET", + INTERPRETATION_RUN_PATH, + &collection_headers(), + "", + )); + assert_eq!(listed.status_code, 200); + let page = InterpretationRunCollection::from_json(&listed.body).expect("page"); + assert_eq!(page.items.len(), 2); + assert_eq!(page.items[0].idempotency_key, "orch-live-idem-001"); + assert_eq!(page.items[1].idempotency_key, "orch-live-idem-002"); + assert!( + page.items + .iter() + .all(|item| item.claim_status == "hypothetical") + ); + assert!(page.items.iter().all(|item| !item.scientific_authority)); + assert!(!listed.body.contains("rmse")); + assert!(!listed.body.contains("evidence_span_ids")); + assert!(!listed.body.contains("tepp.scientific_acceptance.v1")); + + let mut limited_headers = collection_headers(); + limited_headers.push(("tepp-page-limit".into(), "1".into())); + let limited = service.handle_http_request(&http_request( + "GET", + INTERPRETATION_RUN_PATH, + &limited_headers, + "", + )); + assert_eq!(limited.status_code, 200); + let limited_page = InterpretationRunCollection::from_json(&limited.body).expect("limited"); + assert_eq!(limited_page.items.len(), 1); + assert_eq!( + limited_page.next_cursor.as_deref(), + Some("orch-live-idem-001") + ); + + let mut cursor_headers = collection_headers(); + cursor_headers.push(("tepp-page-cursor".into(), "orch-live-idem-001".into())); + cursor_headers.push(("tepp-page-limit".into(), "1".into())); + let rest = service.handle_http_request(&http_request( + "GET", + INTERPRETATION_RUN_PATH, + &cursor_headers, + "", + )); + assert_eq!(rest.status_code, 200); + let rest_page = InterpretationRunCollection::from_json(&rest.body).expect("rest"); + assert_eq!(rest_page.items.len(), 1); + assert_eq!(rest_page.items[0].idempotency_key, "orch-live-idem-002"); + assert_eq!(rest_page.next_cursor, None); +} + +#[test] +fn handle_http_collection_get_refuses_foreign_consumers_and_hostile_headers() { + let mut service = OrchestratorLiveService::new(); + assert_eq!( + service + .handle_http_request(&http_request( + "GET", + "/v1/interpretation-runs/extra", + &collection_headers(), + "", + )) + .status_code, + 400 + ); + assert_eq!( + service + .handle_http_request(&http_request( + "GET", + INTERPRETATION_RUN_PATH, + &collection_headers(), + "{}", + )) + .status_code, + 400 + ); + let mut with_idem = collection_headers(); + with_idem.push(("idempotency-key".into(), "orch-live-idem-001".into())); + assert_eq!( + service + .handle_http_request(&http_request( + "GET", + INTERPRETATION_RUN_PATH, + &with_idem, + "", + )) + .status_code, + 400 + ); + for consumer in ["naruon", "lineageweave"] { + let mut foreign = collection_headers(); + foreign.retain(|(name, _)| !name.eq_ignore_ascii_case("tepp-consumer")); + foreign.push(("tepp-consumer".into(), consumer.into())); + assert_eq!( + service + .handle_http_request(&http_request("GET", INTERPRETATION_RUN_PATH, &foreign, "",)) + .status_code, + 400, + "consumer={consumer}" + ); + } + let mut slash_cursor = collection_headers(); + slash_cursor.push(("tepp-page-cursor".into(), "idem/slash".into())); + assert_eq!( + service + .handle_http_request(&http_request( + "GET", + INTERPRETATION_RUN_PATH, + &slash_cursor, + "", + )) + .status_code, + 400 + ); + let mut credential = collection_headers(); + credential.push(("Authorization".into(), "Bearer review-agent".into())); + let denied = service.handle_http_request(&http_request( + "GET", + INTERPRETATION_RUN_PATH, + &credential, + "", + )); + assert_eq!(denied.status_code, 403); + assert_eq!(error_code(&denied.body), "authorization_denied"); +} + #[test] fn handle_http_refuses_credential_headers_and_reserved_overrides() { let mut service = OrchestratorLiveService::new(); diff --git a/crates/orchestrator_live/tests/orchestrator_loopback_binary_contract.rs b/crates/orchestrator_live/tests/orchestrator_loopback_binary_contract.rs new file mode 100644 index 00000000..ca2ab8d5 --- /dev/null +++ b/crates/orchestrator_live/tests/orchestrator_loopback_binary_contract.rs @@ -0,0 +1,33 @@ +//! The packaged orchestrator loopback binary serves interpretation-run POST. + +use std::io::{BufRead, BufReader, Read, Write}; +use std::net::TcpStream; +use std::process::{Command, Stdio}; + +#[test] +fn binary_serves_one_bounded_interpretation_run_request() { + let mut child = Command::new(env!("CARGO_BIN_EXE_tepp-orchestrator-loopback")) + .args(["127.0.0.1:0", "1"]) + .stdout(Stdio::piped()) + .spawn() + .expect("spawn orchestrator loopback"); + let mut address = String::new(); + BufReader::new(child.stdout.take().expect("stdout")) + .read_line(&mut address) + .expect("bound address"); + let body = r#"{"contract_version":1,"idempotency_key":"orch-bin-idem-1","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}"#; + let request = format!( + "POST /v1/interpretation-runs HTTP/1.1\r\nHost: {}\r\ncontent-type: application/json\r\ntepp-consumer: contextual-orchestrator\r\ntepp-contract-version: 1\r\nidempotency-key: orch-bin-idem-1\r\ncontent-length: {}\r\n\r\n{body}", + address.trim(), + body.len() + ); + let mut stream = TcpStream::connect(address.trim()).expect("connect"); + stream.write_all(request.as_bytes()).expect("request"); + let mut response = String::new(); + stream.read_to_string(&mut response).expect("response"); + assert!(response.starts_with("HTTP/1.1 202 Accepted"), "{response}"); + assert!(response.contains("hypothetical")); + assert!(response.contains("\"scientific_authority\":false")); + assert!(!response.contains("tepp.scientific_acceptance.v1")); + assert!(child.wait().expect("wait").success()); +} diff --git a/docs/API_CONTRACT.md b/docs/API_CONTRACT.md index b76b688e..c00f0850 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. +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); `tepp-interpretation-runs list` is the operator-visible client of that collection GET (ADR 0070); naruon and LineageWeave stay refused. ## 2. Contract families @@ -20,7 +20,7 @@ Current protected main exposes Rust library/domain contracts. The active stack a | event/relation/membership API | future TEPP crates/services | naruon, analytics, UI | accepted-target | | semantic/topic measurement API | future TEPP measurement service | naruon, batch jobs, visual analytics | accepted-target | | topic-context posterior plausible values | `analysis_engine` `tepp.topic_context_posterior.v1` | fast-mlsirm, LineageWeave | contract-only active-PR | -| LLM interpretation provider port | `orchestrator_live` loopback `POST /v1/interpretation-runs` | contextual-orchestrator | partial | +| LLM interpretation provider port | `orchestrator_live` loopback `POST`/`GET /v1/interpretation-runs` | contextual-orchestrator | partial | | LLM interpretation provider port | `tepp_api` orchestration router + future HTTP gateway | contextual-orchestrator | partial | | model/artifact/export API | `tepp_api` export envelopes + future HTTP service | standalone UI/CWL consumers | partial | | analysis-run request/accepted/status/terminal-result contracts | `tepp_api` v1 wire DTOs | naruon, orchestrator, UI | active product branch | @@ -63,6 +63,7 @@ When the service layer is introduced, use resources such as: POST /v1/evidence-imports GET /v1/evidence-imports/{import_id} POST /v1/interpretation-runs +GET /v1/interpretation-runs POST /v1/analysis-runs POST /v1/temporal-context GET /v1/analysis-runs/{run_id} @@ -204,7 +205,7 @@ Before any naruon, contextual-orchestrator, or NVIDIA NIM submission, callers mu ### contextual-orchestrator -TEPP may call a provider-neutral interpretation/orchestration port for semantic unitization, blinded model review, and evidence-bounded interpretation. Callers first obtain a plan from `tepp_api::route_orchestration` and may bind it with `tepp_api::bind_contextual_orchestrator` using an evidence-manifest digest. The standalone `orchestrator_live::OrchestratorLiveService` also serves a loopback-only `POST /v1/interpretation-runs` proof listener; the listener is not TLS termination. A production live port must pass `service_tls::authorize_orchestrator_live_port` (valid rustls PEM on an `https` bind); loopback plaintext is refused and loopback `https` with valid PEM is authorized as production TLS. The orchestrator does not own TEPP's statistical truth, source evidence, model registry, merge/release authority, or scientific acceptance. Detailed port boundary and credential separation are recorded in [`docs/connectors/contextual-orchestrator-interpretation-port.md`](connectors/contextual-orchestrator-interpretation-port.md). +TEPP may call a provider-neutral interpretation/orchestration port for semantic unitization, blinded model review, and evidence-bounded interpretation. Callers first obtain a plan from `tepp_api::route_orchestration` and may bind it with `tepp_api::bind_contextual_orchestrator` using an evidence-manifest digest. The standalone `orchestrator_live::OrchestratorLiveService` also serves a loopback-only `POST /v1/interpretation-runs` proof listener and `GET /v1/interpretation-runs` collection of accepted hypothetical identities; the listener is not TLS termination. A production live port must pass `service_tls::authorize_orchestrator_live_port` (valid rustls PEM on an `https` bind); loopback plaintext is refused and loopback `https` with valid PEM is authorized as production TLS. The orchestrator does not own TEPP's statistical truth, source evidence, model registry, merge/release authority, or scientific acceptance. Detailed port boundary and credential separation are recorded in [`docs/connectors/contextual-orchestrator-interpretation-port.md`](connectors/contextual-orchestrator-interpretation-port.md). ### organization `.github` diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 2b783c2a..f4566b24 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -106,6 +106,9 @@ The full APA 7th standards/literature register remains `docs/research/standards- | autonomous model proposal separated from verification/publication/review/merge | ADR 0015 | future safe OpenCode/NVIDIA autonomous-development workflow | accepted-target | | contextual-orchestrator execution boundary | ADR 0010/0011 | credential-free `bind_contextual_orchestrator` on the active PR; live HTTP remaining | partial | | contextual-orchestrator live execution boundary | ADR 0010/0011 | loopback listener records mode/budget and refuses scientific authority; provider execution remains accepted-target | partial | +| 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 collection CLI | ADR 0070; ADR 0069; ADR 0010/0011; API contract; RFC 9110 | `orchestrator_live` `tepp-interpretation-runs list` CLI against `tepp-orchestrator-loopback` (`GET /v1/interpretation-runs`); metric-free hypothetical identities; empty stdin admitted; `tepp.scientific_acceptance.v1` never appears; does not infer causality; 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/0064-interpretation-run-cli.md b/docs/adr/0064-interpretation-run-cli.md new file mode 100644 index 00000000..e86d0bf1 --- /dev/null +++ b/docs/adr/0064-interpretation-run-cli.md @@ -0,0 +1,109 @@ +# ADR 0064 — Contextual-orchestrator interpretation-run loopback CLI + +**Decision status:** Accepted +**Implementation maturity:** active-PR +**Date:** 2026-08-31 +**Supersedes:** None; complements ADR 0010 and ADR 0011 for the operator-visible interpretation-run client. Does not supersede ADR 0014 claim-promotion authority. This ADR number is unique on protected main; live vs-main PRs already occupy 0026–0063. + +## Context + +Protected main already serves `POST /v1/interpretation-runs` on +`OrchestratorLiveService`, but operators still had to write a custom binary +and raw HTTP/1.1. Duplicating analysis-run CLIs, export CLIs, temporal-context +CLI, project-history CLI, project-history collection GET, Leiden, Driver p.16, +or GAP-010 Figma/export would collide with live PRs. Naruon and `LineageWeave` +are refused on this orchestrator-owned adapter; `NaruonLiveService` stays +POST-only for analysis-run and export. + +## Decision + +`orchestrator_live` publishes loopback-only `tepp-orchestrator-loopback` and +`tepp-interpretation-runs create`: + +- `tepp-orchestrator-loopback` binds `OrchestratorLiveService` on + `127.0.0.1:18082` by default. Public bind fails closed. +- `create` mints `contextual_orchestrator_interpretation_run_exchange` and + renders through `loopback_http1_from_interpretation_run_exchange` onto + spawned `tepp-orchestrator-loopback` TCP. `--origin` stays the published + HTTPS origin; only `--host` is the loopback bind address. +- Stdin is `InterpretationRunRequest` JSON. Consumer is + `contextual-orchestrator` only. +- Stdout is the accepted hypothetical run. `claim_status` remains + `hypothetical`. `scientific_authority` remains false. + `tepp.scientific_acceptance.v1`, RMSE, bias, coverage, SE-gate, and + `causal_score` keys never appear. +- The CLI does not call a model provider, infer causality, or return a + completed psychometric result. +- Non-loopback hosts, `localhost`, credential-shaped flags, unknown verbs, + empty stdin, unpublished consumers, naruon, LineageWeave, non-`https` + origins, and metric keys fail closed. + +## Alternatives considered + +1. **Keep raw HTTP as the only interpretation-run path** — rejected because + operators still guess framing after the live listener shipped. +2. **Add `create` onto `tepp-analysis-runs`** — rejected because + interpretation-run is a distinct orchestrator projection, and #385 is live. +3. **Open naruon or LineageWeave on this adapter** — rejected; the listener + admits `contextual-orchestrator` only. +4. **Return scientific-acceptance on hypothetical runs** — rejected because + ADR 0010 forbids treating orchestrator output as measurement truth. +5. **Loopback interpretation-run CLI against `tepp-orchestrator-loopback`** — + accepted. + +## Consequences + +- Operators can request a hypothetical interpretation-run acknowledgement + without writing HTTP. +- Interpretation-run stdout cannot be mistaken for a succeeded + scientific-acceptance result or a causal score. +- CLI success is not release evidence and is not an ADR 0014 claim. + +## Failure and recovery + +Non-loopback hosts return authorization denied. Unknown verbs, empty stdin, +credential flags, naruon or LineageWeave consumer codes, and +`scientific_authority: true` fail closed. The in-memory listener is not durable. + +## Security, privacy, scientific-integrity, and governance impact + +- No credential headers cross the consumer boundary. +- The CLI remains loopback-only. +- Process exit 0 on create is not measurement evidence and is not a causal + claim. + +## Compatibility and migration + +`POST /v1/analysis-runs`, `POST /v1/exports`, `POST /v1/temporal-context`, +`POST /v1/project-histories`, and `tepp-loopback` paths are unchanged. + +## Verification + +Falsifiable evidence: + +- CLI create of a hypothetical body returns `claim_status` `hypothetical` + with `scientific_authority` false and no RMSE/bias/coverage/SE-gate/ + `tepp.scientific_acceptance.v1`/`causal_score` keys; +- non-loopback host, `localhost`, credential flags, empty stdin, naruon, + LineageWeave, unknown verbs, and metric keys fail closed; +- `tepp-orchestrator-loopback` serves one bounded POST and refuses public bind; +- Clippy `-D warnings`, `orchestrator_live` tests, rustdoc, and exact-head + review remain required. + +## Rollback and supersession + +Rollback removes `tepp-interpretation-runs` and `tepp-orchestrator-loopback`; +`POST /v1/interpretation-runs` remains valid as a library. A superseding ADR +is required to bind a public address, emit scientific-acceptance on +interpretation-run, infer causality, open naruon or LineageWeave on this +adapter, call a model provider, or treat CLI success as an ADR 0014 claim. + +## Related authority + +- ADR 0010 owns adaptive LLM orchestration and scientific-authority + separation. +- ADR 0011 owns standalone/modular HTTP boundaries. +- ADR 0014 owns scientific claim promotion. +- ADR 0017 owns the hourly proposal gateway. +- RFC 9110 owns POST semantics (Fielding, Nottingham, & Reschke, 2022). It + does not authorize scientific claims. diff --git a/docs/adr/0069-interpretation-run-collection-get.md b/docs/adr/0069-interpretation-run-collection-get.md new file mode 100644 index 00000000..d2c4d1ed --- /dev/null +++ b/docs/adr/0069-interpretation-run-collection-get.md @@ -0,0 +1,107 @@ +# ADR 0069 — Contextual-orchestrator interpretation-run collection GET + +**Decision status:** Accepted +**Implementation maturity:** active-PR +**Date:** 2026-09-01 +**Supersedes:** None; complements ADR 0010, ADR 0011, and ADR 0064 for the operator-visible interpretation-run collection. Does not supersede ADR 0014 claim-promotion authority. This ADR number is unique on this stack versus protected main; live vs-main and sibling GAP-003A PRs already occupy 0026–0068. + +## Context + +Protected main already serves `POST /v1/interpretation-runs` on +`OrchestratorLiveService`, and #425 publishes `tepp-interpretation-runs create`. +Operators still cannot enumerate accepted hypothetical runs without guessing +idempotency keys. Duplicating interpretation-run CLI (#425), project-history +collection GET (#424), collection CLI (#428), GET-by-id (#429), retrieval CLI +(#431), analysis-run collection GET (#368), Leiden, Driver p.16, or GAP-010 +Figma/export would collide with live PRs. Naruon and `LineageWeave` are refused +on this orchestrator-owned adapter; `NaruonLiveService` stays POST-only. + +## Decision + +`orchestrator_live` publishes loopback-only `GET /v1/interpretation-runs` on +`tepp-orchestrator-loopback`: + +- Consumer is `contextual-orchestrator` only. Empty body. Pagination uses + `tepp-page-limit` and exclusive `tepp-page-cursor` headers because the + request-line parser fails closed on query strings. +- `idempotency-key` is refused on collection GET. Extra path segments + (GET-by-id) fail closed on this slice. +- Collection rows are metric-free identities: `interpretation_run_id`, + `idempotency_key`, `orchestration_mode`, `claim_status=hypothetical`, + `scientific_authority=false`. +- `tepp.scientific_acceptance.v1`, RMSE, bias, coverage, SE-gate, + `evidence_span_ids`, `tenant_workspace_id`, `compute_budget_tokens`, + `evidence_text`, `findings`, and `causal_score` never appear. +- The collection does not infer causality, call a model provider, mutate TEPP + state, or return a completed psychometric result. +- This slice does not implement interpretation-run collection CLI, GET-by-id, + or persistence. + +## Alternatives considered + +1. **Keep POST replay as the only retrieval path** — rejected because + operators still guess idempotency keys. +2. **Reuse analysis-run or project-history collection GET** — rejected; those + slices are different live PRs and different resources. +3. **Return evidence spans, tenant, or budget on the list** — rejected because + collection bodies must stay metric-free identities. +4. **Open naruon or LineageWeave on this adapter** — rejected; the listener + admits `contextual-orchestrator` only. +5. **Loopback `GET /v1/interpretation-runs`** — accepted. + +## Consequences + +- Operators can enumerate accepted hypothetical interpretation runs without + writing a second POST. +- Collection JSON cannot be mistaken for a succeeded scientific-acceptance + result or a causal score. +- Collection success is not release evidence and is not an ADR 0014 claim. + +## Failure and recovery + +Non-`contextual-orchestrator` consumers, nonempty GET bodies, present +`idempotency-key`, extra path segments, zero/oversized page limits, empty or +slash/NUL cursors, credential flags, and metric keys fail closed. The +in-memory listener is not durable. + +## Security, privacy, scientific-integrity, and governance impact + +- No credential headers cross the consumer boundary. +- Evidence spans, tenant, and budget stay off the collection page. +- HTTP 200 on collection GET is not measurement evidence and is not a causal + claim. + +## Compatibility and migration + +`POST /v1/interpretation-runs` and `tepp-interpretation-runs create` remain +unchanged. Interpretation-run collection CLI remains a later slice. + +## Verification + +Falsifiable evidence: + +- GET of two accepted runs returns a metric-free page sorted by idempotency + key with `claim_status=hypothetical`, `scientific_authority=false`, and no + RMSE/bias/coverage/SE-gate/`tepp.scientific_acceptance.v1`/`evidence_span_ids`/ + `causal_score` keys; +- GET extra segments, naruon or LineageWeave consumer, nonempty body, present + `idempotency-key`, and metric keys fail closed; +- Clippy `-D warnings`, `orchestrator_live` tests, rustdoc, and exact-head + review remain required. + +## Rollback and supersession + +Rollback removes collection GET; `POST /v1/interpretation-runs` remains valid. +A superseding ADR is required to persist the registry, bind a public address, +emit scientific-acceptance on the list, infer causality, open naruon or +`LineageWeave`, or treat collection success as an ADR 0014 claim. + +## Related authority + +- ADR 0010 owns adaptive LLM orchestration and scientific-authority + separation. +- ADR 0011 owns standalone/modular HTTP boundaries. +- ADR 0064 owns the interpretation-run create CLI. +- ADR 0014 owns scientific claim promotion. +- RFC 9110 owns GET semantics (Fielding, Nottingham, & Reschke, 2022). It does + not authorize scientific claims. diff --git a/docs/adr/0070-interpretation-run-collection-cli.md b/docs/adr/0070-interpretation-run-collection-cli.md new file mode 100644 index 00000000..31c95689 --- /dev/null +++ b/docs/adr/0070-interpretation-run-collection-cli.md @@ -0,0 +1,116 @@ +# ADR 0070 — Contextual-orchestrator interpretation-run collection loopback CLI + +**Decision status:** Accepted +**Implementation maturity:** active-PR +**Date:** 2026-09-01 +**Supersedes:** None; complements ADR 0069 for the operator-visible collection client. Does not supersede ADR 0014 claim-promotion authority. This ADR number is unique on this interpretation stack versus protected main; live vs-main and sibling GAP-003A PRs already occupy 0026–0069. + +## Context + +ADR 0069 serves `GET /v1/interpretation-runs` on `OrchestratorLiveService` / +`tepp-orchestrator-loopback`, but operators still had to write raw HTTP/1.1 to +enumerate accepted hypothetical runs. Duplicating interpretation-run CLI +(#425), collection GET (#433), project-history collection CLI (#428), GET-by-id +(#429), retrieval CLI (#431), analysis-run collection CLI (#371), Leiden, +Driver p.16, or GAP-010 Figma/export would collide with live PRs. Naruon and +`LineageWeave` are refused on this orchestrator-owned adapter; +`NaruonLiveService` stays POST-only. + +## Decision + +`orchestrator_live` publishes a loopback-only `tepp-interpretation-runs list` +verb: + +- `list` mints `contextual_orchestrator_interpretation_run_collection_exchange` + and renders through + `loopback_http1_from_interpretation_run_collection_exchange` onto spawned + `tepp-orchestrator-loopback` TCP. `--origin` stays the published HTTPS origin; + only `--host` is the loopback bind address. +- Empty stdin is admitted. Consumer is `contextual-orchestrator` only. +- Optional `--page-cursor` / `--page-limit` become `tepp-page-cursor` / + `tepp-page-limit` headers because the shared request-line parser fails + closed on query strings. +- Stdout is the metric-free collection page: `interpretation_run_id`, + `idempotency_key`, `orchestration_mode`, `claim_status=hypothetical`, + `scientific_authority=false`. +- `tepp.scientific_acceptance.v1`, RMSE, bias, coverage, SE-gate, evidence + spans, tenant, budget, findings, and `causal_score` never appear. +- The CLI does not infer causality, mutate TEPP state, or return a completed + psychometric result. +- Non-loopback hosts, `localhost`, credential-shaped flags, unknown verbs, + nonempty stdin, unpublished consumers, naruon, LineageWeave, non-`https` + origins, and hostile pagination fail closed. +- Persistence, Compose recovery, and psychometric execution remain GAP-003B. + +## Alternatives considered + +1. **Keep raw HTTP as the only collection path** — rejected because operators + still guess framing after ADR 0069. +2. **Open naruon or LineageWeave on this adapter** — rejected; interpretation + collection GET is contextual-orchestrator only (ADR 0069 / ADR 0010). +3. **Add GET to NaruonLiveService** — rejected; Naruon stays POST-only. +4. **Persist listed rows in PostgreSQL** — rejected as GAP-003B / live draft + #287. +5. **Loopback collection CLI with the same metric-free gates as ADR 0069** — + accepted. + +## Consequences + +- Operators can enumerate accepted hypothetical interpretation runs on the same + loopback listener that created them without writing HTTP. +- Collection pages cannot be mistaken for a succeeded scientific-acceptance + result or a causal score. +- CLI success is not release evidence. + +## Failure and recovery + +Non-loopback hosts return authorization denied. Unknown verbs, metric keys, +nonempty bodies, slash/NUL cursors, zero or non-integer limits, unpublished +consumers, naruon, LineageWeave, and credential flags fail closed. The +in-memory registry is not durable. Non-200 bodies never reach stdout. +Successful pages must remain strictly ordered, respect the requested exclusive +cursor and limit, and bind any next cursor to the page's last row. + +## Security, privacy, scientific-integrity, and governance impact + +- No credential headers cross the consumer boundary. +- The CLI remains loopback-only and size-bounded. +- Process exit 0 on a collection page is not measurement evidence and is not + an ADR 0014 claim. + +## Compatibility and migration + +Collection GET, interpretation-run POST, and `tepp-interpretation-runs create` +remain unchanged. Production adapters may replace loopback while preserving +metric-free collection rows. + +## Verification + +Falsifiable evidence: + +- CLI list JSON has no RMSE/bias/coverage/SE-gate/scientific-acceptance/ + evidence/`findings`/`causal_score` keys; +- CLI list returns accepted hypothetical rows and refuses naruon and + LineageWeave; +- non-loopback host, `localhost`, credential flags, nonempty stdin, and unknown + verbs fail closed; +- Clippy `-D warnings`, `orchestrator_live` tests, rustdoc, and exact-head + review remain required. + +## Rollback and supersession + +Rollback removes the `list` verb and client module; collection GET remains +valid. A superseding ADR is required to persist the registry, bind a public +address, emit scientific-acceptance on the list, open naruon or LineageWeave, +or treat CLI success as an ADR 0014 claim. + +## Related authority + +- ADR 0069 owns loopback interpretation-run collection GET. +- ADR 0064 owns the interpretation-run POST CLI (live #425). +- ADR 0010 owns orchestration mode vocabulary and scientific-authority + separation. +- ADR 0011 owns standalone/modular HTTP boundaries. +- ADR 0014 owns scientific claim promotion. +- RFC 9110 owns GET semantics (Fielding, Nottingham, & Reschke, 2022). It + does not authorize scientific claims. diff --git a/docs/adr/README.md b/docs/adr/README.md index 1254c807..030e758c 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -30,6 +30,9 @@ Read [`ADR_POLICY.md`](ADR_POLICY.md) first. **Decision status and implementatio | [0022](0022-deterministic-analysis-run-execution.md) | Deterministic cutoff-safe analysis-run execution | Accepted | active-PR | Closes the first executable product path from accepted run to digest-bound terminal result without claiming estimator authority. | | [0024](0024-lineage-pair-criterion-and-project-journey-posterior.md) | Independent Event Lineage pair criterion and posterior Project Journey | Proposed | active-PR | Strict artifacts preserve criterion/event-time draws, branches, ties, and CPU/GPU receipts without claiming the scientific estimator is complete. | | [0025](0025-macos-native-rust-mlx-metal-boundary.md) | macOS-native Rust-owned MLX Metal execution | Accepted | accepted-target | Compose authenticates to a native host service; Linux never claims Metal, and actual backend/parity receipts fail closed. | +| [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. | +| [0070](0070-interpretation-run-collection-cli.md) | Loopback `tepp-interpretation-runs list` is contextual-orchestrator GET /v1/interpretation-runs client | Accepted | active-PR | Complements ADR 0069; does not supersede ADR 0014. Unique on this interpretation stack versus protected main (0026–0069 occupied). Does not infer causality. | | [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. | @@ -140,6 +143,8 @@ Use the narrowest owning ADR when decisions overlap: - **accepted-run execution and terminal artifact production:** ADR 0022. - **independent lineage criterion and posterior Project Journey:** ADR 0023. - **macOS-native Rust-owned MLX Metal execution:** ADR 0024. +- **contextual-orchestrator interpretation-run CLI:** ADR 0064. +- **contextual-orchestrator interpretation-run collection GET:** ADR 0069. ## Change and supersession rule diff --git a/docs/connectors/contextual-orchestrator-interpretation-port.md b/docs/connectors/contextual-orchestrator-interpretation-port.md index ce2ce23e..6c4b07f2 100644 --- a/docs/connectors/contextual-orchestrator-interpretation-port.md +++ b/docs/connectors/contextual-orchestrator-interpretation-port.md @@ -15,8 +15,10 @@ TEPP may call a provider-neutral interpretation/orchestration port for semantic LLM/provider settings are execution policy only. Deterministic scientific gates remain authoritative (AGENTS.md §11). A production live bind uses `service_tls::authorize_orchestrator_live_port` and cannot be loopback plaintext. This document does not claim a deployed TLS listener. `orchestrator_live::OrchestratorLiveService` binds loopback TCP and serves -`POST /v1/interpretation-runs`. Accepted output is always hypothetical and -never scientific authority. Non-loopback binds, table-access hosts, and +`POST /v1/interpretation-runs` plus `GET /v1/interpretation-runs`. Accepted +output is always hypothetical and never scientific authority. Collection GET +returns metric-free identities only. `tepp-interpretation-runs list` mints that +collection GET onto spawned loopback TCP. Non-loopback binds, table-access hosts, and review/Copilot/GitHub credential headers fail closed. The listener does not call a model provider. diff --git a/docs/research/interpretation-run-cli.md b/docs/research/interpretation-run-cli.md new file mode 100644 index 00000000..0fbd1d70 --- /dev/null +++ b/docs/research/interpretation-run-cli.md @@ -0,0 +1,60 @@ +# Interpretation-run CLI (doctoring) + +## Scope + +`tepp-interpretation-runs create` is the operator-visible client of loopback +`POST /v1/interpretation-runs` on `OrchestratorLiveService` / +`tepp-orchestrator-loopback`. The CLI mints +`contextual_orchestrator_interpretation_run_exchange` and renders onto spawned +`tepp-orchestrator-loopback` TCP. HTTP method, path, and header semantics +follow current HTTP semantics (Fielding, Nottingham, & Reschke, 2022). +Fail-closed refusal of non-loopback hosts, unpublished consumers, naruon, +`LineageWeave`, review/Copilot/GitHub credential flags, and +scientific-authority promotion is repository contract authority (ADR 0064; +ADR 0010; ADR 0011; ADR 0014), not an RFC inference rule. + +CLI stdout is the accepted hypothetical run. `claim_status` remains +`hypothetical`. `scientific_authority` remains false. +`tepp.scientific_acceptance.v1` never appears. Process exit 0 is not a +completed temporal model, calibrated score, theta estimate, uncertainty +statement, causal inference, or scientific claim. + +## Authority + +### External standards (HTTP only) + +Fielding, R., Nottingham, M., & Reschke, J. (Eds.). (2022). *HTTP semantics* +(RFC 9110). IETF. https://doi.org/10.17487/RFC9110 + +RFC 9110 §9.3.3 describes POST as a method for processing the enclosed +representation. TEPP maps that processing onto a bounded, hypothetical +interpretation-run acknowledgement. The RFC does not define psychometric +acceptance, RMSE, causality, or claim promotion. + +### Internal contract evidence + +- `docs/adr/0064-interpretation-run-cli.md` — this client +- `docs/adr/0010-adaptive-llm-orchestration.md` — mode vocabulary and + scientific-authority separation +- `docs/adr/0011-standalone-modular-msa-boundary.md` — modular HTTP boundary +- `docs/adr/0014-scientific-claim-promotion-and-release-evidence.md` — CLI + success is not a scientific claim +- `crates/orchestrator_live/tests/interpretation_run_cli_contract.rs` — + fail-closed interpretation-run CLI proofs + +## Verification + +- `tepp-interpretation-runs create` of a hypothetical body returns + `claim_status` `hypothetical` with `scientific_authority` false and without + RMSE/bias/coverage/SE-gate keys, `causal_score`, or + `tepp.scientific_acceptance.v1`; +- non-loopback hosts, `localhost`, credential flags, empty stdin, naruon, + LineageWeave, and unknown verbs fail closed; +- `tepp-orchestrator-loopback` serves one bounded POST on loopback only. + +## Non-claims + +This slice does not implement analysis-run CLIs, export CLI, temporal-context +CLI, project-history CLI, GET-by-id, wait CLI, lookup CLI, persistence, +production TLS, Leiden consensus, GAP-010 Figma/export, provider execution, +causal inference, or an ADR 0014 scientific claim-promotion package. diff --git a/docs/research/interpretation-run-collection-cli.md b/docs/research/interpretation-run-collection-cli.md new file mode 100644 index 00000000..5ab4cd55 --- /dev/null +++ b/docs/research/interpretation-run-collection-cli.md @@ -0,0 +1,62 @@ +# Interpretation-run collection CLI (doctoring) + +## Scope + +`tepp-interpretation-runs list` is the operator-visible client of loopback +`GET /v1/interpretation-runs` on `OrchestratorLiveService` / +`tepp-orchestrator-loopback`. The CLI mints +`contextual_orchestrator_interpretation_run_collection_exchange` and renders +onto spawned `tepp-orchestrator-loopback` TCP. HTTP method, path, and header +semantics follow current HTTP semantics (Fielding, Nottingham, & Reschke, +2022). Fail-closed refusal of non-loopback hosts, unpublished consumers, +naruon, `LineageWeave`, review/Copilot/GitHub credential flags, nonempty +stdin, and scientific-authority promotion is repository contract authority +(ADR 0070; ADR 0069; ADR 0010; ADR 0011; ADR 0014), not an RFC inference rule. + +CLI stdout is the metric-free collection page. `claim_status` remains +`hypothetical`. `scientific_authority` remains false. +`tepp.scientific_acceptance.v1` never appears. Process exit 0 is not a +completed temporal model, calibrated score, theta estimate, uncertainty +statement, causal inference, or scientific claim. + +## Authority + +### External standards (HTTP only) + +Fielding, R., Nottingham, M., & Reschke, J. (Eds.). (2022). *HTTP semantics* +(RFC 9110). IETF. https://doi.org/10.17487/RFC9110 + +RFC 9110 §9.3.1 describes GET as a method for retrieving the target resource. +TEPP maps that retrieval onto a bounded, hypothetical interpretation-run +collection. The RFC does not define psychometric acceptance, RMSE, causality, +or claim promotion. + +### Internal contract evidence + +- `docs/adr/0070-interpretation-run-collection-cli.md` — this client +- `docs/adr/0069-interpretation-run-collection-get.md` — collection GET +- `docs/adr/0064-interpretation-run-cli.md` — create CLI +- `docs/adr/0010-adaptive-llm-orchestration.md` — mode vocabulary and + scientific-authority separation +- `docs/adr/0011-standalone-modular-msa-boundary.md` — modular HTTP boundary +- `docs/adr/0014-scientific-claim-promotion-and-release-evidence.md` — CLI + success is not a scientific claim +- `crates/orchestrator_live/tests/interpretation_run_collection_cli_contract.rs` + — fail-closed interpretation-run collection CLI proofs + +## Verification + +- `tepp-interpretation-runs list` of accepted hypothetical runs returns + `claim_status` `hypothetical` with `scientific_authority` false and without + RMSE/bias/coverage/SE-gate keys, `evidence_span_ids`, `causal_score`, or + `tepp.scientific_acceptance.v1`; +- non-loopback hosts, `localhost`, credential flags, nonempty stdin, naruon, + LineageWeave, and unknown verbs fail closed; +- empty stdin is admitted for GET. + +## Non-claims + +This slice does not implement analysis-run CLIs, export CLI, temporal-context +CLI, project-history CLI, GET-by-id, wait CLI, lookup CLI, persistence, +production TLS, Leiden consensus, GAP-010 Figma/export, provider execution, +causal inference, or an ADR 0014 scientific claim-promotion package. diff --git a/docs/research/interpretation-run-collection-http.md b/docs/research/interpretation-run-collection-http.md new file mode 100644 index 00000000..02d2bf64 --- /dev/null +++ b/docs/research/interpretation-run-collection-http.md @@ -0,0 +1,60 @@ +# Interpretation-run collection GET (doctoring) + +## Scope + +`GET /v1/interpretation-runs` is the operator-visible collection of accepted +hypothetical interpretation runs on `OrchestratorLiveService` / +`tepp-orchestrator-loopback`. HTTP method, path, and header semantics follow +current HTTP semantics (Fielding, Nottingham, & Reschke, 2022). Fail-closed +refusal of unpublished consumers, nonempty GET bodies, present +`idempotency-key`, extra path segments, review/Copilot/GitHub credential +flags, and scientific-authority promotion is repository contract authority +(ADR 0069; ADR 0010; ADR 0011; ADR 0014), not an RFC inference rule. + +Collection JSON is metric-free. `claim_status` remains `hypothetical`. +`scientific_authority` remains false. `tepp.scientific_acceptance.v1` never +appears. A 200 collection page is not a completed psychometric result, +calibrated score, theta estimate, uncertainty statement, causal inference, or +scientific claim. + +## Authority + +### External standards (HTTP only) + +Fielding, R., Nottingham, M., & Reschke, J. (Eds.). (2022). *HTTP semantics* +(RFC 9110). IETF. https://doi.org/10.17487/RFC9110 + +RFC 9110 §9.3.1 describes GET as a method for retrieving the target resource. +TEPP maps that retrieval onto a bounded, hypothetical interpretation-run +collection. The RFC does not define psychometric acceptance, RMSE, causality, +or claim promotion. + +### Internal contract evidence + +- `docs/adr/0069-interpretation-run-collection-get.md` — this collection +- `docs/adr/0064-interpretation-run-cli.md` — create CLI +- `docs/adr/0010-adaptive-llm-orchestration.md` — mode vocabulary and + scientific-authority separation +- `docs/adr/0011-standalone-modular-msa-boundary.md` — modular HTTP boundary +- `docs/adr/0014-scientific-claim-promotion-and-release-evidence.md` — HTTP + 200 is not a scientific claim +- `crates/orchestrator_live/tests/interpretation_run_collection_http_contract.rs` + — fail-closed collection proofs +- `crates/orchestrator_live/tests/live_http_contract.rs` — loopback GET proofs +- `docs/adr/0070-interpretation-run-collection-cli.md` — collection CLI + +## Verification + +- `GET /v1/interpretation-runs` of accepted contextual-orchestrator runs + returns `hypothetical` rows without RMSE/bias/coverage/SE-gate keys, + `evidence_span_ids`, `causal_score`, or `tepp.scientific_acceptance.v1`; +- GET extra segments, naruon or LineageWeave consumer, nonempty body, present + `idempotency-key`, and unknown verbs fail closed. + +## Non-claims + +This slice does not implement interpretation-run collection CLI, GET-by-id, +export CLI, analysis-run collection GET, project-history collection GET, wait +CLI, lookup CLI, persistence, production TLS, Leiden consensus, GAP-010 +Figma/export, causal inference, or an ADR 0014 scientific claim-promotion +package.