From a99cdc77d26488f01c2deb7501dc691bc820ad02 Mon Sep 17 00:00:00 2001 From: Seongho Bae <8172694+seonghobae@users.noreply.github.com> Date: Mon, 31 Aug 2026 15:43:48 +0000 Subject: [PATCH] feat(api): inspect stored analysis-run requests via loopback CLI GAP-003A / issue #166: operators can inspect metric-free snapshot, cutoff, model contract, and output profile through `tepp-analysis-runs stored-request` without writing raw HTTP. tepp.scientific_acceptance.v1 never appears. Stacked on stored-request consumer-parity (#387) over stored-request GET (#377). Does not duplicate retry CLI, cancel/create/status CLIs, or GET-by-id. --- .../analysis-run-stored-request-cli.md | 1 + DOCUMENTATION.md | 1 + crates/tepp_api/Cargo.toml | 6 + .../src/analysis_run_stored_request_cli.rs | 850 ++++++++++++++++++ .../src/analysis_run_stored_request_http.rs | 2 +- crates/tepp_api/src/bin/tepp_analysis_runs.rs | 37 + crates/tepp_api/src/lib.rs | 15 + ...nalysis_run_stored_request_cli_contract.rs | 67 ++ docs/API_CONTRACT.md | 4 +- docs/TRACEABILITY.md | 1 + .../0041-analysis-run-stored-request-cli.md | 70 ++ docs/adr/README.md | 2 + docs/connectors/naruon-artifact-consumer.md | 1 + .../analysis-run-stored-request-cli.md | 59 ++ 14 files changed, 1114 insertions(+), 2 deletions(-) create mode 100644 CHANGELOG.d/analysis-run-stored-request-cli.md create mode 100644 crates/tepp_api/src/analysis_run_stored_request_cli.rs create mode 100644 crates/tepp_api/src/bin/tepp_analysis_runs.rs create mode 100644 crates/tepp_api/tests/analysis_run_stored_request_cli_contract.rs create mode 100644 docs/adr/0041-analysis-run-stored-request-cli.md create mode 100644 docs/research/analysis-run-stored-request-cli.md diff --git a/CHANGELOG.d/analysis-run-stored-request-cli.md b/CHANGELOG.d/analysis-run-stored-request-cli.md new file mode 100644 index 000000000..6655ee075 --- /dev/null +++ b/CHANGELOG.d/analysis-run-stored-request-cli.md @@ -0,0 +1 @@ +- `tepp_api` loopback `tepp-analysis-runs stored-request` inspects metric-free stored create fields (ADR 0041). Snapshot/cutoff/model/profile only. Refuses RMSE/bias/coverage/SE-gate keys and `tepp.scientific_acceptance.v1`. Not stored-request HTTP, not retry CLI, not GET-by-id, not persistence. diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index d726599ac..1b6069c13 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -18,6 +18,7 @@ TEPP's approved PRD v0.4 and implementation plan are the primary product baselin | Analysis-run retry HTTP doctoring | [`docs/research/analysis-run-retry-http.md`](docs/research/analysis-run-retry-http.md) | | Analysis-run stored-request HTTP doctoring | [`docs/research/analysis-run-stored-request-http.md`](docs/research/analysis-run-stored-request-http.md) | | Analysis-run stored-request consumer-parity doctoring | [`docs/research/analysis-run-stored-request-consumer-parity.md`](docs/research/analysis-run-stored-request-consumer-parity.md) | +| Analysis-run stored-request CLI doctoring | [`docs/research/analysis-run-stored-request-cli.md`](docs/research/analysis-run-stored-request-cli.md) | | UML/runtime/scientific flows | [`docs/UML.md`](docs/UML.md) | | Logical/physical ERD | [`docs/ERD.md`](docs/ERD.md) | | Security policy | [`SECURITY.md`](SECURITY.md) | diff --git a/crates/tepp_api/Cargo.toml b/crates/tepp_api/Cargo.toml index 47ad7c433..b3c3d43f4 100644 --- a/crates/tepp_api/Cargo.toml +++ b/crates/tepp_api/Cargo.toml @@ -29,5 +29,11 @@ path = "src/bin/tepp_loopback.rs" test = false bench = false +[[bin]] +name = "tepp-analysis-runs" +path = "src/bin/tepp_analysis_runs.rs" +test = false +bench = false + [lints] workspace = true diff --git a/crates/tepp_api/src/analysis_run_stored_request_cli.rs b/crates/tepp_api/src/analysis_run_stored_request_cli.rs new file mode 100644 index 000000000..9ab8247a0 --- /dev/null +++ b/crates/tepp_api/src/analysis_run_stored_request_cli.rs @@ -0,0 +1,850 @@ +//! Operator loopback CLI for analysis-run stored-request GET. +//! +//! GAP-003A operator-visible client of `GET /v1/analysis-runs/{run_id}/request` +//! (ADR 0034 / live #377, consumer-parity #387). Operators run +//! `tepp-analysis-runs stored-request` to inspect metric-free snapshot, cutoff, +//! model contract, and output profile without writing raw HTTP. +//! `tepp.scientific_acceptance.v1` never appears. This module does not +//! duplicate stored-request HTTP, retry HTTP, retry CLI, collection GET/CLI, +//! cancel/create/status CLIs, or GET-by-id. Persistence remains GAP-003B. + +use std::io::{Read, Write}; +use std::net::{SocketAddr, TcpStream}; + +use crate::analysis_run_stored_request_http::{ + ANALYSIS_RUN_STORED_REQUEST_ID_MAX_LEN, encode_path_segment, + refuse_metrics_on_stored_request_payload, +}; +use crate::lineageweave_http::consumer_is_supported; +use crate::live_http::map_io_error; +use crate::naruon_http::{NARUON_ANALYSIS_RUN_PATH, header_is_credential}; +use crate::wire::require_nonempty; +use crate::{ + AnalysisRunLiveService, AnalysisRunStoredRequest, ApiError, NARUON_LIVE_IO_TIMEOUT, + NaruonLiveResponse, +}; + +const SCIENTIFIC_ACCEPTANCE_SCHEMA: &str = "tepp.scientific_acceptance.v1"; + +/// Supported operator verbs for the loopback stored-request CLI. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum AnalysisRunStoredRequestCliVerb { + /// `GET /v1/analysis-runs/{run_id}/request`. + StoredRequest, +} + +impl AnalysisRunStoredRequestCliVerb { + /// Parse one exact lowercase verb token. + /// + /// # Errors + /// + /// Returns [`ApiError::InvalidWirePayload`] for an unknown token. + pub fn parse(token: &str) -> Result { + match token { + "stored-request" => Ok(Self::StoredRequest), + _ => Err(ApiError::InvalidWirePayload), + } + } + + /// Return the canonical lowercase verb token. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::StoredRequest => "stored-request", + } + } +} + +/// One operator CLI invocation against a loopback stored-request GET listener. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AnalysisRunStoredRequestCliInvocation { + /// CLI verb to execute. + pub verb: AnalysisRunStoredRequestCliVerb, + /// Loopback `host:port` of `tepp-loopback`. + pub host: String, + /// Published modular consumer (`naruon` or `lineageweave`). + pub consumer: String, + /// Opaque server-assigned run identity. + pub run_id: String, + /// JSON body. Stored-request GET requires empty. + pub body: String, +} + +impl AnalysisRunStoredRequestCliInvocation { + /// Parse argv plus stdin body into a validated loopback stored-request invocation. + /// + /// # Errors + /// + /// Returns a fail-closed error for unknown verbs, missing required flags, a + /// non-loopback host, an unpublished consumer, credential-shaped flags, + /// hostile identities, or a nonempty body. + pub fn from_args(args: I, body: impl Into) -> Result + where + I: IntoIterator, + S: AsRef, + { + let tokens: Vec = args + .into_iter() + .map(|token| token.as_ref().to_owned()) + .collect(); + let (verb_token, rest) = tokens.split_first().ok_or(ApiError::InvalidWirePayload)?; + let verb = AnalysisRunStoredRequestCliVerb::parse(verb_token)?; + let flags = parse_flags(rest)?; + assemble_invocation(verb, flags, body.into()) + } + + /// Reject a non-loopback host, unpublished consumer, or hostile inspect body. + /// + /// # Errors + /// + /// Returns [`ApiError::AuthorizationDenied`] for a non-loopback host and + /// [`ApiError::InvalidWirePayload`] or [`ApiError::LimitExceeded`] for + /// empty, unpublished, oversized, or nonempty-body fields. + pub fn validate(&self) -> Result<(), ApiError> { + require_loopback_host(&self.host)?; + require_nonempty(&self.consumer)?; + if !consumer_is_supported(&self.consumer) { + return Err(ApiError::InvalidWirePayload); + } + require_nonempty(&self.run_id)?; + if self.run_id.len() > ANALYSIS_RUN_STORED_REQUEST_ID_MAX_LEN { + return Err(ApiError::LimitExceeded); + } + if !self.body.is_empty() { + return Err(ApiError::InvalidWirePayload); + } + refuse_scientific_acceptance(&self.body)?; + refuse_metrics_on_stored_request_payload(&self.body)?; + Ok(()) + } +} + +struct ParsedFlags { + host: Option, + consumer: Option, + run_id: Option, +} + +fn parse_flags(rest: &[String]) -> Result { + let mut flags = ParsedFlags { + host: None, + consumer: None, + run_id: None, + }; + let mut index = 0; + while index < rest.len() { + let flag = rest[index].as_str(); + if !flag.starts_with("--") { + return Err(ApiError::InvalidWirePayload); + } + let name = &flag[2..]; + if header_is_credential(name) { + return Err(ApiError::AuthorizationDenied); + } + let slot = match name { + "host" => &mut flags.host, + "consumer" => &mut flags.consumer, + "run-id" => &mut flags.run_id, + _ => return Err(ApiError::InvalidWirePayload), + }; + if slot.is_some() || index + 1 >= rest.len() { + return Err(ApiError::InvalidWirePayload); + } + let value = rest[index + 1].as_str(); + require_nonempty(value)?; + *slot = Some(value.to_owned()); + index += 2; + } + Ok(flags) +} + +fn assemble_invocation( + verb: AnalysisRunStoredRequestCliVerb, + flags: ParsedFlags, + body: String, +) -> Result { + let invocation = AnalysisRunStoredRequestCliInvocation { + verb, + host: flags.host.ok_or(ApiError::InvalidWirePayload)?, + consumer: flags + .consumer + .unwrap_or_else(|| crate::NARUON_CONSUMER_CODE.to_owned()), + run_id: flags.run_id.ok_or(ApiError::InvalidWirePayload)?, + body, + }; + invocation.validate()?; + Ok(invocation) +} + +fn require_loopback_host(host: &str) -> Result { + let addr: SocketAddr = host.parse().map_err(|_| ApiError::InvalidWirePayload)?; + if addr.ip().is_loopback() { + Ok(addr) + } else { + Err(ApiError::AuthorizationDenied) + } +} + +/// Compose one HTTP/1.1 stored-request GET for a validated CLI invocation. +/// +/// # Errors +/// +/// Returns the same fail-closed errors as +/// [`AnalysisRunStoredRequestCliInvocation::validate`]. +pub fn compose_analysis_run_stored_request_cli_http( + invocation: &AnalysisRunStoredRequestCliInvocation, +) -> Result { + invocation.validate()?; + let encoded_run_id = encode_path_segment(&invocation.run_id); + let path = format!("{NARUON_ANALYSIS_RUN_PATH}/{encoded_run_id}/request"); + Ok(format!( + "GET {path} HTTP/1.1\r\nHost: {}\r\ncontent-type: application/json\r\ntepp-consumer: {}\r\ntepp-contract-version: 1\r\ncontent-length: 0\r\n\r\n", + invocation.host, invocation.consumer + )) +} + +/// Dispatch one stored-request CLI invocation against an in-process loopback service. +/// +/// # Errors +/// +/// Returns fail-closed validation errors before the HTTP handler runs. +pub fn dispatch_analysis_run_stored_request_cli( + service: &mut AnalysisRunLiveService, + invocation: &AnalysisRunStoredRequestCliInvocation, +) -> Result { + let request = compose_analysis_run_stored_request_cli_http(invocation)?; + Ok(service.handle_http_request(&request)) +} + +/// Execute one stored-request CLI invocation over loopback TCP against `tepp-loopback`. +/// +/// # Errors +/// +/// Returns fail-closed validation, transport, or response-framing errors. +pub fn execute_analysis_run_stored_request_cli( + invocation: &AnalysisRunStoredRequestCliInvocation, +) -> Result { + let addr = require_loopback_host(&invocation.host)?; + let request = compose_analysis_run_stored_request_cli_http(invocation)?; + let mut stream = TcpStream::connect(addr).map_err(|error| map_io_error(&error))?; + stream + .set_read_timeout(Some(NARUON_LIVE_IO_TIMEOUT)) + .map_err(|error| map_io_error(&error))?; + stream + .set_write_timeout(Some(NARUON_LIVE_IO_TIMEOUT)) + .map_err(|error| map_io_error(&error))?; + stream + .write_all(request.as_bytes()) + .map_err(|error| map_io_error(&error))?; + stream.flush().map_err(|error| map_io_error(&error))?; + let 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 stored-request inspect never prints scientific acceptance. +/// +/// # Errors +/// +/// Returns [`ApiError::InvalidWirePayload`] when the body is empty, carries +/// metric keys or the scientific-acceptance schema, or identities do not match +/// the invocation. +pub fn render_analysis_run_stored_request_cli_stdout( + invocation: &AnalysisRunStoredRequestCliInvocation, + response: &NaruonLiveResponse, +) -> Result { + invocation.validate()?; + if response.body.is_empty() { + return Err(ApiError::InvalidWirePayload); + } + refuse_scientific_acceptance(&response.body)?; + refuse_metrics_on_stored_request_payload(&response.body)?; + if !(200..300).contains(&response.status_code) { + return Ok(response.body.clone()); + } + let stored = AnalysisRunStoredRequest::from_json(&response.body)?; + if stored.run_id != invocation.run_id { + return Err(ApiError::InvalidWirePayload); + } + stored.to_json() +} + +fn refuse_scientific_acceptance(body: &str) -> Result<(), ApiError> { + if body.contains(SCIENTIFIC_ACCEPTANCE_SCHEMA) { + Err(ApiError::InvalidWirePayload) + } else { + Ok(()) + } +} + +fn parse_http_response(bytes: &[u8]) -> Result { + let text = std::str::from_utf8(bytes).map_err(|_| ApiError::InvalidWirePayload)?; + let (header_block, body) = text + .split_once("\r\n\r\n") + .ok_or(ApiError::InvalidWirePayload)?; + let mut lines = header_block.split("\r\n"); + let status_line = lines.next().ok_or(ApiError::InvalidWirePayload)?; + let mut parts = status_line.split(' '); + if parts.next() != Some("HTTP/1.1") { + return Err(ApiError::InvalidWirePayload); + } + let code = parts + .next() + .ok_or(ApiError::InvalidWirePayload)? + .parse::() + .map_err(|_| ApiError::InvalidWirePayload)?; + let reason_phrase = static_reason(code)?; + let mut content_length = None; + for line in lines { + let (name, value) = line.split_once(':').ok_or(ApiError::InvalidWirePayload)?; + if name.eq_ignore_ascii_case("content-length") { + if content_length.is_some() { + return Err(ApiError::InvalidWirePayload); + } + content_length = Some( + value + .trim() + .parse::() + .map_err(|_| ApiError::InvalidWirePayload)?, + ); + } + } + let declared = content_length.ok_or(ApiError::InvalidWirePayload)?; + if declared != body.len() { + return Err(ApiError::InvalidWirePayload); + } + Ok(NaruonLiveResponse { + status_code: code, + reason_phrase, + body: body.to_owned(), + }) +} + +fn static_reason(code: u16) -> Result<&'static str, ApiError> { + match code { + 200 => Ok("OK"), + 202 => Ok("Accepted"), + 400 => Ok("Bad Request"), + 403 => Ok("Forbidden"), + 413 => Ok("Payload Too Large"), + 422 => Ok("Unprocessable Entity"), + _ => Err(ApiError::InvalidWirePayload), + } +} + +/// Read stdin leftover bytes on a non-terminal; stored-request GET refuses a body. +/// +/// # Errors +/// +/// Returns [`ApiError::InvalidWirePayload`] when stdin cannot be read. +pub fn read_analysis_run_stored_request_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(|_| ApiError::InvalidWirePayload)?; + Ok(body) + } +} + +#[cfg(test)] +#[allow(clippy::too_many_lines)] +mod tests { + use super::{ + AnalysisRunStoredRequestCliInvocation, AnalysisRunStoredRequestCliVerb, + SCIENTIFIC_ACCEPTANCE_SCHEMA, compose_analysis_run_stored_request_cli_http, + dispatch_analysis_run_stored_request_cli, execute_analysis_run_stored_request_cli, + parse_http_response, read_analysis_run_stored_request_cli_stdin, + render_analysis_run_stored_request_cli_stdout, static_reason, + }; + use crate::{ + ANALYSIS_RUN_CONTRACT_VERSION, ANALYSIS_RUN_STORED_REQUEST_ID_MAX_LEN, AnalysisRunAccepted, + AnalysisRunLiveService, AnalysisRunRequest, AnalysisRunStatusState, + AnalysisRunStoredRequest, ApiError, LINEAGEWEAVE_CONSUMER_CODE, NARUON_CONSUMER_CODE, + NaruonLiveResponse, + }; + + fn request(idempotency_key: &str) -> AnalysisRunRequest { + AnalysisRunRequest { + contract_version: ANALYSIS_RUN_CONTRACT_VERSION, + idempotency_key: idempotency_key.into(), + tenant_workspace_id: "cli-stored-tenant".into(), + snapshot_id: "cli-stored-snapshot".into(), + knowledge_cutoff: "2026-08-01T00:00:00Z".into(), + model_contract_version: "tepp-analysis-run-v1".into(), + output_profile: "calibrated_event_measurement".into(), + } + } + + fn create_http(run: &AnalysisRunRequest, consumer: &str, host: &str) -> String { + let body = run.to_json().expect("json"); + format!( + "POST /v1/analysis-runs HTTP/1.1\r\nHost: {host}\r\ncontent-type: application/json\r\ntepp-consumer: {consumer}\r\ntepp-contract-version: 1\r\nidempotency-key: {}\r\ncontent-length: {}\r\n\r\n{body}", + run.idempotency_key, + body.len() + ) + } + + fn inspect_invocation(run_id: &str) -> AnalysisRunStoredRequestCliInvocation { + AnalysisRunStoredRequestCliInvocation::from_args( + [ + "stored-request", + "--host", + "127.0.0.1:18081", + "--run-id", + run_id, + ], + "", + ) + .expect("stored-request") + } + + #[test] + fn verbs_parse_and_reject_unknown_tokens() { + assert_eq!( + AnalysisRunStoredRequestCliVerb::parse("stored-request").expect("verb"), + AnalysisRunStoredRequestCliVerb::StoredRequest + ); + assert_eq!( + AnalysisRunStoredRequestCliVerb::StoredRequest.as_str(), + "stored-request" + ); + assert_eq!( + AnalysisRunStoredRequestCliVerb::parse("STORED-REQUEST"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + AnalysisRunStoredRequestCliVerb::parse("status"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + AnalysisRunStoredRequestCliVerb::parse("retry"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + AnalysisRunStoredRequestCliVerb::parse("list"), + Err(ApiError::InvalidWirePayload) + ); + } + + #[test] + fn from_args_refuses_empty_unknown_host_and_credential_flags() { + assert_eq!( + AnalysisRunStoredRequestCliInvocation::from_args(Vec::::new(), "").unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + AnalysisRunStoredRequestCliInvocation::from_args(["stored-request"], "").unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + AnalysisRunStoredRequestCliInvocation::from_args( + [ + "stored-request", + "--host", + "8.8.8.8:80", + "--run-id", + "tepp-run-1" + ], + "" + ) + .unwrap_err(), + ApiError::AuthorizationDenied + ); + assert_eq!( + AnalysisRunStoredRequestCliInvocation::from_args( + [ + "stored-request", + "--host", + "127.0.0.1:18081", + "--run-id", + "tepp-run-1", + "--authorization", + "secret" + ], + "" + ) + .unwrap_err(), + ApiError::AuthorizationDenied + ); + assert_eq!( + AnalysisRunStoredRequestCliInvocation::from_args( + [ + "stored-request", + "--host", + "127.0.0.1:18081", + "--run-id", + "tepp-run-1", + "--github-token", + "secret" + ], + "" + ) + .unwrap_err(), + ApiError::AuthorizationDenied + ); + assert_eq!( + AnalysisRunStoredRequestCliInvocation::from_args( + [ + "stored-request", + "--host", + "127.0.0.1:18081", + "--run-id", + "tepp-run-1", + "--pretty" + ], + "" + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + AnalysisRunStoredRequestCliInvocation::from_args( + [ + "stored-request", + "--host", + "127.0.0.1:18081", + "--run-id", + "tepp-run-1" + ], + "{}" + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + AnalysisRunStoredRequestCliInvocation::from_args( + [ + "stored-request", + "--host", + "127.0.0.1:18081", + "--run-id", + "tepp-run-1", + "--consumer", + "other" + ], + "" + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + AnalysisRunStoredRequestCliInvocation::from_args( + [ + "stored-request", + "--host", + "127.0.0.1:18081", + "--run-id", + &"a".repeat(ANALYSIS_RUN_STORED_REQUEST_ID_MAX_LEN + 1) + ], + "" + ) + .unwrap_err(), + ApiError::LimitExceeded + ); + assert_eq!( + AnalysisRunStoredRequestCliInvocation::from_args( + [ + "stored-request", + "--host", + "not-a-socket", + "--run-id", + "tepp-run-1" + ], + "" + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + AnalysisRunStoredRequestCliInvocation::from_args( + [ + "stored-request", + "--host", + "127.0.0.1:18081", + "--run-id", + "tepp-run-1", + "--page-limit", + "1" + ], + "" + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + } + + #[test] + fn stored_request_assembles_default_consumer_and_encoded_path() { + let inspect = inspect_invocation("tepp-run-1"); + assert_eq!(inspect.verb, AnalysisRunStoredRequestCliVerb::StoredRequest); + assert_eq!(inspect.consumer, NARUON_CONSUMER_CODE); + let http = compose_analysis_run_stored_request_cli_http(&inspect).expect("http"); + assert!(http.starts_with("GET /v1/analysis-runs/tepp-run-1/request HTTP/1.1")); + assert!(http.contains("tepp-consumer: naruon")); + assert!(http.contains("content-length: 0")); + assert!(!http.contains("authorization")); + assert!(!http.contains("copilot")); + assert!(!http.contains("/cancel")); + assert!(!http.contains("/retry")); + assert!(!http.contains(SCIENTIFIC_ACCEPTANCE_SCHEMA)); + + let encoded = inspect_invocation("run/../../etc"); + let encoded_http = compose_analysis_run_stored_request_cli_http(&encoded).expect("encoded"); + assert!( + encoded_http.contains("GET /v1/analysis-runs/run%2F..%2F..%2Fetc/request HTTP/1.1") + ); + + let lw = AnalysisRunStoredRequestCliInvocation::from_args( + [ + "stored-request", + "--host", + "127.0.0.1:18081", + "--consumer", + LINEAGEWEAVE_CONSUMER_CODE, + "--run-id", + "tepp-run-1", + ], + "", + ) + .expect("lw"); + let lw_http = compose_analysis_run_stored_request_cli_http(&lw).expect("lw http"); + assert!(lw_http.contains("tepp-consumer: lineageweave")); + } + + #[test] + fn dispatch_inspects_accepted_without_leaking_metrics_or_other_consumers() { + let mut service = AnalysisRunLiveService::new(); + let first = request("cli-stored-idem-1"); + let created = service.handle_http_request(&create_http( + &first, + NARUON_CONSUMER_CODE, + "127.0.0.1:18081", + )); + assert_eq!(created.status_code, 202); + let accepted = AnalysisRunAccepted::from_json(&created.body).expect("accepted"); + let invocation = inspect_invocation(&accepted.run_id); + let got = + dispatch_analysis_run_stored_request_cli(&mut service, &invocation).expect("inspect"); + assert_eq!(got.status_code, 200); + let stdout = + render_analysis_run_stored_request_cli_stdout(&invocation, &got).expect("stdout"); + assert!(!stdout.contains(SCIENTIFIC_ACCEPTANCE_SCHEMA)); + assert!(!stdout.contains("rmse")); + assert!(!stdout.contains("terminal_result")); + assert!(!stdout.contains("tenant_workspace_id")); + let stored = AnalysisRunStoredRequest::from_json(&stdout).expect("stored"); + assert_eq!(stored.run_id, accepted.run_id); + assert_eq!(stored.run_state, AnalysisRunStatusState::Accepted); + assert_eq!(stored.snapshot_id, first.snapshot_id); + assert_eq!(stored.knowledge_cutoff, first.knowledge_cutoff); + assert_eq!(stored.output_profile, first.output_profile); + + let other = AnalysisRunStoredRequestCliInvocation::from_args( + [ + "stored-request", + "--host", + "127.0.0.1:18081", + "--consumer", + LINEAGEWEAVE_CONSUMER_CODE, + "--run-id", + accepted.run_id.as_str(), + ], + "", + ) + .expect("other"); + let isolated = + dispatch_analysis_run_stored_request_cli(&mut service, &other).expect("isolated"); + assert_eq!(isolated.status_code, 400); + let isolated_stdout = + render_analysis_run_stored_request_cli_stdout(&other, &isolated).expect("isolated out"); + assert!(!isolated_stdout.contains(SCIENTIFIC_ACCEPTANCE_SCHEMA)); + assert!(!isolated_stdout.contains("cli-stored-snapshot")); + } + + #[test] + fn render_refuses_metrics_schema_and_empty_bodies() { + let inspect = inspect_invocation("tepp-run-1"); + assert_eq!( + render_analysis_run_stored_request_cli_stdout( + &inspect, + &NaruonLiveResponse { + status_code: 200, + reason_phrase: "OK", + body: String::new(), + } + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + render_analysis_run_stored_request_cli_stdout( + &inspect, + &NaruonLiveResponse { + status_code: 200, + reason_phrase: "OK", + body: r#"{"contract_version":1,"run_id":"tepp-run-1","run_state":"accepted","idempotency_key":"idem-1","snapshot_id":"s","knowledge_cutoff":"2026-08-01T00:00:00Z","model_contract_version":"m","output_profile":"p","rmse":1.0}"#.into(), + } + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + render_analysis_run_stored_request_cli_stdout( + &inspect, + &NaruonLiveResponse { + status_code: 200, + reason_phrase: "OK", + body: format!( + r#"{{"schema_version":"{SCIENTIFIC_ACCEPTANCE_SCHEMA}","run_id":"tepp-run-1"}}"# + ), + } + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + let error_stdout = render_analysis_run_stored_request_cli_stdout( + &inspect, + &NaruonLiveResponse { + status_code: 400, + reason_phrase: "Bad Request", + body: r#"{"error_code":"invalid_wire_payload"}"#.into(), + }, + ) + .expect("error"); + assert!(error_stdout.contains("invalid_wire_payload")); + let ok = render_analysis_run_stored_request_cli_stdout( + &inspect, + &NaruonLiveResponse { + status_code: 200, + reason_phrase: "OK", + body: r#"{"contract_version":1,"run_id":"tepp-run-1","run_state":"accepted","idempotency_key":"idem-1","snapshot_id":"snap","knowledge_cutoff":"2026-08-01T00:00:00Z","model_contract_version":"tepp-analysis-run-v1","output_profile":"calibrated_event_measurement"}"#.into(), + }, + ) + .expect("ok"); + assert!(ok.contains("\"snapshot_id\":\"snap\"")); + assert!(!ok.contains(SCIENTIFIC_ACCEPTANCE_SCHEMA)); + assert_eq!( + render_analysis_run_stored_request_cli_stdout( + &inspect, + &NaruonLiveResponse { + status_code: 200, + reason_phrase: "OK", + body: r#"{"contract_version":1,"run_id":"other-run","run_state":"accepted","idempotency_key":"idem-1","snapshot_id":"snap","knowledge_cutoff":"2026-08-01T00:00:00Z","model_contract_version":"tepp-analysis-run-v1","output_profile":"calibrated_event_measurement"}"#.into(), + } + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + } + + #[test] + fn execute_over_tcp_and_parse_response_failures() { + let mut service = AnalysisRunLiveService::bind_loopback().expect("bind"); + let addr = service.local_addr().expect("addr"); + let created = service.handle_http_request(&create_http( + &request("cli-stored-tcp"), + NARUON_CONSUMER_CODE, + &addr.to_string(), + )); + let accepted = AnalysisRunAccepted::from_json(&created.body).expect("accepted"); + let handle = std::thread::spawn(move || { + drop(service.serve_one()); + }); + let mut invocation = inspect_invocation(&accepted.run_id); + invocation.host = addr.to_string(); + let response = execute_analysis_run_stored_request_cli(&invocation).expect("tcp"); + assert_eq!(response.status_code, 200); + handle.join().expect("join"); + + invocation.host = "127.0.0.1:1".into(); + assert_eq!( + execute_analysis_run_stored_request_cli(&invocation).unwrap_err(), + ApiError::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(), + ApiError::InvalidWirePayload + ); + assert_eq!( + parse_http_response(b"HTTP/1.0 200 OK\r\ncontent-length: 2\r\n\r\n{}").unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + parse_http_response(b"HTTP/1.1 299 Mystery\r\ncontent-length: 2\r\n\r\n{}") + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + parse_http_response( + b"HTTP/1.1 200 OK\r\ncontent-length: 2\r\ncontent-length: 2\r\n\r\n{}" + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + parse_http_response(b"HTTP/1.1 200 OK\r\ncontent-length: 9\r\n\r\n{}").unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + parse_http_response(b"HTTP/1.1 200 OK\r\nbad-header\r\n\r\n{}").unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + parse_http_response(&[0xff, 0xfe]).unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!(static_reason(200).expect("200"), "OK"); + assert_eq!(static_reason(202).expect("202"), "Accepted"); + assert_eq!(static_reason(400).expect("400"), "Bad Request"); + assert_eq!(static_reason(403).expect("403"), "Forbidden"); + assert_eq!(static_reason(413).expect("413"), "Payload Too Large"); + assert_eq!(static_reason(422).expect("422"), "Unprocessable Entity"); + assert_eq!( + static_reason(500).unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + parse_http_response(b"HTTP/1.1\r\ncontent-length: 0\r\n\r\n").unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + parse_http_response(b"HTTP/1.1 abc OK\r\ncontent-length: 0\r\n\r\n").unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + parse_http_response(b"HTTP/1.1 200 OK\r\ncontent-length: x\r\n\r\n").unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + parse_http_response(b"HTTP/1.1 200 OK\r\nhost: 127.0.0.1\r\n\r\n{}").unwrap_err(), + ApiError::InvalidWirePayload + ); + } + + #[test] + fn stdin_reader_skips_terminal_and_reads_otherwise() { + let empty = + read_analysis_run_stored_request_cli_stdin(true, std::io::empty()).expect("tty"); + assert!(empty.is_empty()); + let piped = + read_analysis_run_stored_request_cli_stdin(false, std::io::Cursor::new(b"leftover")) + .expect("piped"); + assert_eq!(piped, "leftover"); + let piped_empty = + read_analysis_run_stored_request_cli_stdin(false, std::io::Cursor::new(b"")) + .expect("empty"); + assert!(piped_empty.is_empty()); + } +} diff --git a/crates/tepp_api/src/analysis_run_stored_request_http.rs b/crates/tepp_api/src/analysis_run_stored_request_http.rs index 653a12665..c6c21b3dc 100644 --- a/crates/tepp_api/src/analysis_run_stored_request_http.rs +++ b/crates/tepp_api/src/analysis_run_stored_request_http.rs @@ -236,7 +236,7 @@ pub fn naruon_analysis_run_stored_request_exchange( }) } -fn encode_path_segment(value: &str) -> String { +pub(crate) fn encode_path_segment(value: &str) -> String { let mut out = String::with_capacity(value.len() + value.len() / 2); let hex = b"0123456789ABCDEF"; for byte in value.bytes() { diff --git a/crates/tepp_api/src/bin/tepp_analysis_runs.rs b/crates/tepp_api/src/bin/tepp_analysis_runs.rs new file mode 100644 index 000000000..e75bdb49b --- /dev/null +++ b/crates/tepp_api/src/bin/tepp_analysis_runs.rs @@ -0,0 +1,37 @@ +//! Operator CLI for loopback analysis-run stored-request GET. + +use std::io::{self, IsTerminal}; +use std::process::ExitCode; + +use tepp_api::{ + AnalysisRunStoredRequestCliInvocation, ApiError, execute_analysis_run_stored_request_cli, + read_analysis_run_stored_request_cli_stdin, render_analysis_run_stored_request_cli_stdout, +}; + +fn main() -> ExitCode { + match run() { + Ok(()) => ExitCode::SUCCESS, + Err(_) => ExitCode::FAILURE, + } +} + +fn run() -> Result<(), ApiError> { + let args: Vec = std::env::args().skip(1).collect(); + match args.first().map(String::as_str) { + Some("stored-request") => run_stored_request(&args), + _ => Err(ApiError::InvalidWirePayload), + } +} + +fn run_stored_request(args: &[String]) -> Result<(), ApiError> { + let body = read_analysis_run_stored_request_cli_stdin(io::stdin().is_terminal(), io::stdin())?; + let invocation = AnalysisRunStoredRequestCliInvocation::from_args(args, body)?; + let response = execute_analysis_run_stored_request_cli(&invocation)?; + let stdout = render_analysis_run_stored_request_cli_stdout(&invocation, &response)?; + println!("{stdout}"); + if (200..300).contains(&response.status_code) { + Ok(()) + } else { + Err(ApiError::InvalidWirePayload) + } +} diff --git a/crates/tepp_api/src/lib.rs b/crates/tepp_api/src/lib.rs index 2be878783..fb423d3bf 100644 --- a/crates/tepp_api/src/lib.rs +++ b/crates/tepp_api/src/lib.rs @@ -18,6 +18,7 @@ mod analysis_run_collection_http; mod analysis_run_live; mod analysis_run_retry_http; mod analysis_run_status_http; +mod analysis_run_stored_request_cli; mod analysis_run_stored_request_http; mod authorization; mod corpus_split_manifest; @@ -119,6 +120,20 @@ pub use analysis_run_retry_http::naruon_analysis_run_retry_exchange; pub use analysis_run_retry_http::refuse_metrics_on_retry_payload; /// Analysis-run status HTTP exchange re-exports. pub use analysis_run_status_http::{ANALYSIS_RUN_ID_MAX_LEN, naruon_analysis_run_status_exchange}; +/// One validated stored-request CLI invocation. +pub use analysis_run_stored_request_cli::AnalysisRunStoredRequestCliInvocation; +/// Loopback stored-request CLI verb. +pub use analysis_run_stored_request_cli::AnalysisRunStoredRequestCliVerb; +/// Compose loopback stored-request GET bytes for a CLI invocation. +pub use analysis_run_stored_request_cli::compose_analysis_run_stored_request_cli_http; +/// Dispatch a stored-request CLI invocation against an in-process listener. +pub use analysis_run_stored_request_cli::dispatch_analysis_run_stored_request_cli; +/// Execute a stored-request CLI invocation over loopback TCP. +pub use analysis_run_stored_request_cli::execute_analysis_run_stored_request_cli; +/// Read leftover stdin for the stored-request CLI. +pub use analysis_run_stored_request_cli::read_analysis_run_stored_request_cli_stdin; +/// Render stored-request CLI stdout with metric-free gates. +pub use analysis_run_stored_request_cli::render_analysis_run_stored_request_cli_stdout; /// Analysis-run stored-request contract version constant. pub use analysis_run_stored_request_http::ANALYSIS_RUN_STORED_REQUEST_CONTRACT_VERSION; /// Maximum opaque run identity length on the stored-request path. diff --git a/crates/tepp_api/tests/analysis_run_stored_request_cli_contract.rs b/crates/tepp_api/tests/analysis_run_stored_request_cli_contract.rs new file mode 100644 index 000000000..94e120045 --- /dev/null +++ b/crates/tepp_api/tests/analysis_run_stored_request_cli_contract.rs @@ -0,0 +1,67 @@ +//! Contract tests for the analysis-run stored-request loopback CLI. + +use tepp_api::{ + AnalysisRunStoredRequestCliInvocation, AnalysisRunStoredRequestCliVerb, ApiError, + NARUON_CONSUMER_CODE, compose_analysis_run_stored_request_cli_http, +}; + +#[test] +fn stored_request_cli_is_metric_free_get_without_credentials() { + assert_eq!( + AnalysisRunStoredRequestCliVerb::parse("stored-request").expect("verb"), + AnalysisRunStoredRequestCliVerb::StoredRequest + ); + let invocation = AnalysisRunStoredRequestCliInvocation::from_args( + [ + "stored-request", + "--host", + "127.0.0.1:18081", + "--run-id", + "tepp-run-1", + ], + "", + ) + .expect("invocation"); + assert_eq!(invocation.consumer, NARUON_CONSUMER_CODE); + let http = compose_analysis_run_stored_request_cli_http(&invocation).expect("http"); + assert!(http.starts_with("GET /v1/analysis-runs/tepp-run-1/request HTTP/1.1")); + assert!(!http.contains("authorization")); + assert!(!http.contains("copilot")); + assert!(!http.contains("tepp.scientific_acceptance.v1")); + assert!(!http.contains("/cancel")); + assert!(!http.contains("/retry")); +} + +#[test] +fn stored_request_cli_refuses_non_loopback_unknown_verbs_and_bodies() { + assert_eq!( + AnalysisRunStoredRequestCliInvocation::from_args( + [ + "stored-request", + "--host", + "8.8.8.8:80", + "--run-id", + "tepp-run-1" + ], + "" + ), + Err(ApiError::AuthorizationDenied) + ); + assert_eq!( + AnalysisRunStoredRequestCliVerb::parse("retry"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + AnalysisRunStoredRequestCliInvocation::from_args( + [ + "stored-request", + "--host", + "127.0.0.1:18081", + "--run-id", + "tepp-run-1" + ], + r#"{"rmse":1.0}"# + ), + Err(ApiError::InvalidWirePayload) + ); +} diff --git a/docs/API_CONTRACT.md b/docs/API_CONTRACT.md index fd5231a7e..f3253fab3 100644 --- a/docs/API_CONTRACT.md +++ b/docs/API_CONTRACT.md @@ -93,7 +93,9 @@ closed. `GET /v1/analysis-runs/{run_id}/request` on the loopback listener returns metric-free stored create fields (`snapshot_id`, `knowledge_cutoff`, `model_contract_version`, `output_profile`) so operators can inspect a listed run before retry. GET-by-id remains a later slice on this protected-main -lineage. `lineageweave_analysis_run_stored_request_exchange` builds the same +lineage. The loopback `tepp-analysis-runs stored-request` CLI is the +operator-visible client for that GET; stdout stays metric-free and +`tepp.scientific_acceptance.v1` never appears. `lineageweave_analysis_run_stored_request_exchange` builds the same GET for LineageWeave. `NaruonLiveService` serves stored-request GET for Naruon only; LineageWeave remains refused on that compatibility listener. diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index da25734e9..0eebcb162 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -58,6 +58,7 @@ The full APA 7th standards/literature register remains `docs/research/standards- | loopback analysis-run retry HTTP | ADR 0032; API contract; RFC 9110 | `tepp_api` `POST /v1/analysis-runs/{run_id}/retry` on `AnalysisRunLiveService`: clones failed/cancelled into a new metric-free `202 Accepted` with a new idempotency key; accepted/running/succeeded/unknown refuse; GET-by-id remains a later slice | active-PR | | loopback analysis-run stored-request GET | ADR 0034; API contract; RFC 9110 | `tepp_api` `GET /v1/analysis-runs/{run_id}/request` on `AnalysisRunLiveService`: metric-free inspect of snapshot/cutoff/model/profile; collection GET lists identity only; GET-by-id remains a later slice | active-PR | | loopback analysis-run stored-request consumer parity | ADR 0040; API contract; RFC 9110 | `tepp_api` LineageWeave stored-request exchange, Naruon compatibility-listener inspect, and `tepp-loopback` TCP create-then-inspect; LineageWeave remains refused on `NaruonLiveService` | active-PR | +| loopback analysis-run stored-request CLI | ADR 0041; API contract; RFC 9110 | `tepp_api` `tepp-analysis-runs stored-request` CLI: operator-visible client of `GET /v1/analysis-runs/{run_id}/request`; metric-free snapshot/cutoff/model/profile; `tepp.scientific_acceptance.v1` never appears | active-PR | | executable cutoff-safe analysis-run readiness | ADR 0021; temporal research; API terminal-result contract | stacked `analysis_engine` PR on #157: availability cutoff, snapshot binding, multiple-membership aggregation, digest-bound artifact, realistic end-to-end tests | active-PR | | delayed-reporting cutoff eligibility in truth corpora | ADR 0002; research | `tepp_simulation` eligible-at-cutoff filter on the active PR | active-PR | | versioned service/API contracts and exports | PRD; API contract; ADR 0011/0013 | `tepp_api` analysis-run/export/JSON-LD/GraphML contracts on protected main (PR #21); HTTP service remaining accepted-target | partial | diff --git a/docs/adr/0041-analysis-run-stored-request-cli.md b/docs/adr/0041-analysis-run-stored-request-cli.md new file mode 100644 index 000000000..1f1504c71 --- /dev/null +++ b/docs/adr/0041-analysis-run-stored-request-cli.md @@ -0,0 +1,70 @@ +# ADR 0041 — Analysis-run stored-request loopback CLI + +**Decision status:** Accepted +**Implementation maturity:** active-PR +**Date:** 2026-08-31 +**Supersedes:** None; complements ADR 0034 for the operator-visible stored-request client. Does not supersede ADR 0014 claim-promotion authority. This ADR number is unique on the stored-request lineage; other live PRs may reuse 0041 on unrelated stacks. + +## Context + +ADR 0034 serves `GET /v1/analysis-runs/{run_id}/request` and ADR 0040 gives LineageWeave a stored-request exchange, but operators still had to write raw HTTP/1.1 to inspect snapshot, cutoff, model contract, and output profile before retry. Duplicating stored-request HTTP, stored-request consumer-parity, retry HTTP, retry CLI (`tepp-retry` on live #394), collection GET/CLI, cancel/create/status CLIs, or GET-by-id would collide with live PRs. + +## Decision + +`tepp_api` publishes a loopback-only `tepp-analysis-runs` CLI on this stored-request lineage: + +- `stored-request` GETs `/v1/analysis-runs/{run_id}/request` with `--run-id`. +- Empty stdin is required. A nonempty body fails closed. +- Stdout is metric-free `AnalysisRunStoredRequest` JSON. `tepp.scientific_acceptance.v1`, RMSE, bias, coverage, SE-gate, `terminal_result`, and `tenant_workspace_id` never appear. +- Non-loopback hosts, unpublished consumers, credential-shaped flags, collection pagination flags, unknown verbs, hostile identities, and metric keys on stdout fail closed. +- This slice does not implement stored-request HTTP (ADR 0034) and does not open GET-by-id on this stack. +- Persistence, Compose recovery, and psychometric execution remain GAP-003B. + +## Alternatives considered + +1. **Keep raw HTTP as the only stored-request path** — rejected because operators still guess framing after ADR 0034. +2. **Add `stored-request` onto the live retry CLI (#394)** — rejected because that head already owns `tepp-retry` POST retry. +3. **Return scientific-acceptance on succeeded inspect** — rejected because stored-request bodies must stay metric-free. +4. **Loopback stored-request CLI stacked on ADR 0034/0040** — accepted. + +## Consequences + +- Operators can inspect stored create fields of a listed run before retry without writing HTTP. +- Inspect stdout cannot be mistaken for a succeeded scientific-acceptance result. +- CLI success is not release evidence. + +## Failure and recovery + +Non-loopback hosts return authorization denied. Unknown verbs, nonempty stdin, unpublished consumers, credential flags, oversized run identities, and collection flags fail closed. Unknown runs and consumer mismatch remain refused by ADR 0034. The in-memory registry is not durable. + +## 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 stored-request inspect is not measurement evidence and is not an ADR 0014 claim. + +## Compatibility and migration + +Stored-request GET HTTP, stored-request consumer-parity, create POST, cancel POST, retry POST, collection GET, temporal-context, and project-history paths are unchanged. The collection/cancel/create `tepp-analysis-runs` verbs live on a parallel stack and merge by combining verbs. Production adapters may replace loopback while preserving metric-free inspect fields. + +## Verification + +Falsifiable evidence: + +- CLI stored-request of an accepted run returns snapshot/cutoff/model/profile with no RMSE/bias/coverage/SE-gate/`tepp.scientific_acceptance.v1` keys; +- another consumer cannot inspect the first consumer's stored request; +- non-loopback host, credential flags, collection flags, nonempty stdin, and unknown verbs fail closed; +- Clippy `-D warnings`, `tepp_api` tests, rustdoc, and exact-head review remain required. + +## Rollback and supersession + +Rollback removes the stored-request verb and `tepp-analysis-runs` binary from this lineage; stored-request GET HTTP remains valid. A superseding ADR is required to persist the registry, bind a public address, emit scientific-acceptance on inspect, or treat CLI success as an ADR 0014 claim. + +## Related authority + +- ADR 0034 owns loopback stored-request GET. +- ADR 0040 owns LineageWeave stored-request-exchange parity. +- ADR 0018 owns consumer-scoped ingress and metric-free `202 Accepted`. +- ADR 0014 owns scientific claim promotion. +- ADR 0011 owns standalone/modular HTTP boundaries. +- 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 6eca379f4..0d1a8eb38 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -35,6 +35,7 @@ Read [`ADR_POLICY.md`](ADR_POLICY.md) first. **Decision status and implementatio | [0032](0032-analysis-run-retry-http.md) | Loopback POST analysis-run retry clones failed/cancelled into a new metric-free 202 | Accepted | active-PR | Complements ADR 0018/0029/0031; does not supersede ADR 0014. ADR 0026–0031 live on other GAP-003A PRs. | | [0034](0034-analysis-run-stored-request-get.md) | Loopback GET analysis-run stored-request is metric-free inspect | Accepted | active-PR | Complements ADR 0018/0031/0032; does not supersede ADR 0014. ADR 0026–0033 live on other GAP-003A PRs. | | [0040](0040-analysis-run-stored-request-consumer-parity.md) | LineageWeave and Naruon compatibility-listener stored-request GET | Accepted | active-PR | Complements ADR 0034/0018; does not supersede ADR 0014. ADR 0026–0039 live on other PRs. | +| [0041](0041-analysis-run-stored-request-cli.md) | Loopback `tepp-analysis-runs stored-request` is stored-request GET client | Accepted | active-PR | Complements ADR 0034/0040; does not supersede ADR 0014. Unique on stored-request lineage. | | [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. | @@ -150,6 +151,7 @@ Use the narrowest owning ADR when decisions overlap: - **analysis-run retry HTTP:** ADR 0032. - **analysis-run stored-request GET:** ADR 0034. - **analysis-run stored-request consumer parity:** ADR 0040. +- **analysis-run stored-request CLI:** ADR 0041. ## Change and supersession rule diff --git a/docs/connectors/naruon-artifact-consumer.md b/docs/connectors/naruon-artifact-consumer.md index 8ab376472..c872e4fe3 100644 --- a/docs/connectors/naruon-artifact-consumer.md +++ b/docs/connectors/naruon-artifact-consumer.md @@ -29,6 +29,7 @@ TEPP remains the scientific authority for estimation, recovery metrics, temporal | HTTP analysis-run collection | `tepp_api` `naruon_analysis_run_collection_exchange` → `GET /v1/analysis-runs` | naruon → TEPP | | HTTP analysis-run cancel | `tepp_api` `naruon_analysis_run_cancel_exchange` → `POST /v1/analysis-runs/{run_id}/cancel` | naruon → TEPP | | HTTP analysis-run stored-request | `tepp_api` `naruon_analysis_run_stored_request_exchange` → `GET /v1/analysis-runs/{run_id}/request` | naruon → TEPP | +| CLI analysis-run stored-request | `tepp_api` `tepp-analysis-runs stored-request` → loopback `GET /v1/analysis-runs/{run_id}/request` | naruon → TEPP | | HTTP export authorize | `tepp_api` `naruon_export_exchange` → `POST /v1/exports` | naruon → TEPP | | Live loopback POST | `tepp_api` `NaruonLiveService` → `POST /v1/analysis-runs` and `/v1/exports` | naruon → TEPP | | Live loopback stored-request GET | `tepp_api` `NaruonLiveService` → `GET /v1/analysis-runs/{run_id}/request` (Naruon only) | naruon → TEPP | diff --git a/docs/research/analysis-run-stored-request-cli.md b/docs/research/analysis-run-stored-request-cli.md new file mode 100644 index 000000000..69af30827 --- /dev/null +++ b/docs/research/analysis-run-stored-request-cli.md @@ -0,0 +1,59 @@ +# Analysis-run stored-request CLI (doctoring) + +## Scope + +`tepp-analysis-runs stored-request` is the operator-visible client of loopback +`GET /v1/analysis-runs/{run_id}/request`. HTTP method, path, and header +semantics follow current HTTP semantics (Fielding, Nottingham, & Reschke, +2022). Fail-closed refusal of non-loopback hosts, unpublished consumers, +review/Copilot/GitHub credential flags, and scientific-authority promotion is +repository contract authority (ADR 0041; ADR 0034; ADR 0018; ADR 0011), not an +RFC inference rule. + +CLI stdout is metric-free `AnalysisRunStoredRequest` JSON. Snapshot, cutoff, +model contract, and output profile are inspectable. `tepp.scientific_acceptance.v1` +never appears. Process exit 0 is not a completed temporal model, calibrated +score, theta estimate, uncertainty statement, 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 a representation of +the target resource. TEPP maps that retrieval onto a bounded, consumer-scoped +inspect of stored create fields. The RFC does not define psychometric +acceptance, RMSE, or claim promotion. + +### Internal contract evidence + +- `docs/adr/0041-analysis-run-stored-request-cli.md` — this client +- `docs/adr/0034-analysis-run-stored-request-get.md` — stored-request GET listener +- `docs/adr/0040-analysis-run-stored-request-consumer-parity.md` — LineageWeave + stored-request-exchange builder +- `docs/adr/0018-consumer-scoped-analysis-run-ingress.md` — closed consumer + registry and metric-free `202 Accepted` +- `docs/adr/0014-scientific-claim-promotion-and-release-evidence.md` — CLI + success is not a scientific claim +- `docs/API_CONTRACT.md` — documented stored-request resource +- `crates/tepp_api/tests/analysis_run_stored_request_cli_contract.rs` — + fail-closed stored-request CLI proofs + +## Verification + +- `tepp-analysis-runs stored-request` of an accepted run returns metric-free + snapshot/cutoff/model/profile without RMSE/bias/coverage/SE-gate keys or + `tepp.scientific_acceptance.v1`; +- another consumer cannot inspect the first consumer's stored request; +- non-loopback hosts, credential flags, collection pagination flags, nonempty + stdin, and unknown verbs fail closed; +- review, Copilot, GitHub, and bearer flags remain `AuthorizationDenied`. + +## Non-claims + +This slice does not implement stored-request GET HTTP, retry HTTP, retry CLI, +collection GET, collection CLI list, cancel HTTP, cancel CLI, create CLI, +status CLI, GET-by-id, persistence, production TLS, Leiden consensus, or an +ADR 0014 scientific claim-promotion package.