From 228d2e7aea9e1a400e2c7057d9afd0c0a948146e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 31 Aug 2026 09:37:55 +0000 Subject: [PATCH 1/2] feat(api): drive scientific acceptance via loopback CLI GAP-003A fifth slice for issue #166. tepp-analysis-run creates, records running/terminal, and reads status on the loopback listener. Accepted and running stay metric-free; tepp.scientific_acceptance.v1 prints only on a succeeded scientific_acceptance_v1 status. Non-loopback hosts, credential flags, and failed-plus-artifact emission fail closed. ADR 0029. --- CHANGELOG.md | 2 + DOCUMENTATION.md | 2 + crates/tepp_api/Cargo.toml | 7 +- crates/tepp_api/src/analysis_run_cli.rs | 1438 +++++++++++++++++ crates/tepp_api/src/bin/tepp_analysis_run.rs | 31 + crates/tepp_api/src/lib.rs | 18 +- .../scientific_acceptance_cli_contract.rs | 62 + docs/API_CONTRACT.md | 6 +- docs/TRACEABILITY.md | 3 +- ...0029-scientific-acceptance-loopback-cli.md | 79 + docs/adr/README.md | 1 + .../scientific-acceptance-loopback-cli.md | 59 + 12 files changed, 1704 insertions(+), 4 deletions(-) create mode 100644 crates/tepp_api/src/analysis_run_cli.rs create mode 100644 crates/tepp_api/src/bin/tepp_analysis_run.rs create mode 100644 crates/tepp_api/tests/scientific_acceptance_cli_contract.rs create mode 100644 docs/adr/0029-scientific-acceptance-loopback-cli.md create mode 100644 docs/research/scientific-acceptance-loopback-cli.md diff --git a/CHANGELOG.md b/CHANGELOG.md index dcbe8b94a..bc6922933 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,8 @@ All notable changes to TEPP are documented here. The format follows Keep a Chang ## [Unreleased] +- `tepp_api` adds the loopback `tepp-analysis-run` CLI so operators can `create`, `running`, `terminal`, and `status` a scientific-acceptance analysis run without writing raw HTTP. Create and running stay metric-free. Only a succeeded status with profile `scientific_acceptance_v1` may print `tepp.scientific_acceptance.v1`. Non-loopback hosts, credential-shaped flags, failed-plus-artifact emission, receipt RMSE/bias/coverage/SE-gate keys, and consumer mismatch fail closed. This is the GAP-003A CLI slice for issue #166; it does not duplicate the `analysis_engine` library bind (#356), the terminal-result DTO wire (#358), the GET status slice (#359), or the lifecycle POST slice (#360); persistence remains GAP-003B. + - `tepp_api` loopback `AnalysisRunLiveService` now serves production `POST /v1/analysis-runs/{run_id}/running` and `POST /v1/analysis-runs/{run_id}/terminal` so accepted/running stay metric-free and only a succeeded status with profile `scientific_acceptance_v1` may return `tepp.scientific_acceptance.v1` after a lifecycle POST. Canonical artifact bytes travel as `scientific_acceptance_json`. Reverse transitions, mutating a terminal run, failed-plus-artifact emission, receipt RMSE/bias/coverage/SE-gate keys, an unknown run, and consumer mismatch fail closed. This is the GAP-003A HTTP lifecycle slice for issue #166; it does not duplicate the `analysis_engine` library bind (#356), the terminal-result DTO wire (#358), or the GET status slice (#359); persistence remains GAP-003B. - `tepp_api` loopback `AnalysisRunLiveService` now serves `GET /v1/analysis-runs/{run_id}` so accepted/running statuses stay metric-free and only a succeeded status with profile `scientific_acceptance_v1` may return `tepp.scientific_acceptance.v1`. Receipt RMSE/bias/coverage/SE-gate keys, a GET body, failed-plus-artifact emission, an all-zero digest, and digest mismatch fail closed. This is the GAP-003A HTTP status slice for issue #166; it does not duplicate the `analysis_engine` library bind (#356) or the terminal-result DTO wire (#358); persistence remains GAP-003B. diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 6fa4b9683..6da8caa14 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -73,6 +73,7 @@ TEPP's approved PRD v0.4 and implementation plan are the primary product baselin | Analysis engine gap-closure doctoring | [`docs/doctoring/analysis-engine-gap-closure.md`](docs/doctoring/analysis-engine-gap-closure.md) | | Corpus-split leakage-audit wire doctoring | [`docs/research/corpus-split-manifest-wire.md`](docs/research/corpus-split-manifest-wire.md) | | Unicode canonical-identity doctoring | [`docs/research/unicode-canonical-identity.md`](docs/research/unicode-canonical-identity.md) | +| Scientific-acceptance loopback CLI doctoring | [`docs/research/scientific-acceptance-loopback-cli.md`](docs/research/scientific-acceptance-loopback-cli.md) | | Change history | [`CHANGELOG.md`](CHANGELOG.md) | ## Maturity vocabulary @@ -155,6 +156,7 @@ TEPP's approved PRD v0.4 and implementation plan are the primary product baselin | Hourly NIM OpenCode doctoring | [`docs/doctoring/hourly-nim-opencode-development.md`](docs/doctoring/hourly-nim-opencode-development.md) | | Corpus-split leakage-audit wire doctoring | [`docs/research/corpus-split-manifest-wire.md`](docs/research/corpus-split-manifest-wire.md) | | Unicode canonical-identity doctoring | [`docs/research/unicode-canonical-identity.md`](docs/research/unicode-canonical-identity.md) | +| Scientific-acceptance loopback CLI doctoring | [`docs/research/scientific-acceptance-loopback-cli.md`](docs/research/scientific-acceptance-loopback-cli.md) | | Change history | [`CHANGELOG.md`](CHANGELOG.md) | ## Maturity vocabulary diff --git a/crates/tepp_api/Cargo.toml b/crates/tepp_api/Cargo.toml index 053199f39..172503e13 100644 --- a/crates/tepp_api/Cargo.toml +++ b/crates/tepp_api/Cargo.toml @@ -29,9 +29,14 @@ path = "src/bin/tepp_loopback.rs" test = false bench = false +[[bin]] +name = "tepp-analysis-run" +path = "src/bin/tepp_analysis_run.rs" +test = false +bench = false + [lints] workspace = true [dev-dependencies] sha2 = { workspace = true } - diff --git a/crates/tepp_api/src/analysis_run_cli.rs b/crates/tepp_api/src/analysis_run_cli.rs new file mode 100644 index 000000000..fcfc3feaf --- /dev/null +++ b/crates/tepp_api/src/analysis_run_cli.rs @@ -0,0 +1,1438 @@ +//! Operator loopback CLI for scientific-acceptance analysis-run lifecycle. +//! +//! GAP-003A fifth slice: operators drive `POST /v1/analysis-runs`, +//! `POST /v1/analysis-runs/{run_id}/running`, +//! `POST /v1/analysis-runs/{run_id}/terminal`, and +//! `GET /v1/analysis-runs/{run_id}` without writing raw HTTP. Create and +//! running stay metric-free. Only a succeeded status whose request profile is +//! `scientific_acceptance_v1` may print `tepp.scientific_acceptance.v1`. This +//! module does not duplicate the library bind, the terminal-result DTO, the +//! GET listener, or the lifecycle POST listener. Persistence remains GAP-003B. + +use std::io::{Read, Write}; +use std::net::{SocketAddr, TcpStream}; + +use crate::analysis_run_status_http::encode_path_segment; +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::scientific_acceptance_http::{ + SCIENTIFIC_ACCEPTANCE_HTTP_PROFILE, SCIENTIFIC_ACCEPTANCE_HTTP_SCHEMA, + refuse_metrics_on_receipt, +}; +use crate::wire::require_nonempty; +use crate::{ + ANALYSIS_RUN_ID_MAX_LEN, AnalysisRunLifecycleTransition, AnalysisRunLiveService, + AnalysisRunRequest, AnalysisRunStatusState, ApiError, NARUON_LIVE_IO_TIMEOUT, + NaruonLiveResponse, +}; + +/// Supported operator verbs for the loopback analysis-run CLI. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum AnalysisRunCliVerb { + /// `POST /v1/analysis-runs` with a metric-free request body. + Create, + /// `GET /v1/analysis-runs/{run_id}`. + Status, + /// `POST /v1/analysis-runs/{run_id}/running`. + Running, + /// `POST /v1/analysis-runs/{run_id}/terminal`. + Terminal, +} + +impl AnalysisRunCliVerb { + /// Parse one exact lowercase verb token. + /// + /// # Errors + /// + /// Returns [`ApiError::InvalidWirePayload`] for an unknown token. + pub fn parse(token: &str) -> Result { + match token { + "create" => Ok(Self::Create), + "status" => Ok(Self::Status), + "running" => Ok(Self::Running), + "terminal" => Ok(Self::Terminal), + _ => Err(ApiError::InvalidWirePayload), + } + } + + /// Return the canonical lowercase verb token. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Create => "create", + Self::Status => "status", + Self::Running => "running", + Self::Terminal => "terminal", + } + } + + const fn requires_stdin_body(self) -> bool { + matches!(self, Self::Create | Self::Terminal) + } +} + +/// One operator CLI invocation against a loopback analysis-run listener. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AnalysisRunCliInvocation { + /// CLI verb to execute. + pub verb: AnalysisRunCliVerb, + /// Loopback `host:port` of `tepp-loopback`. + pub host: String, + /// Published modular consumer (`naruon` or `lineageweave`). + pub consumer: String, + /// Request-bound idempotency key. + pub idempotency_key: String, + /// Server-assigned run identity, required except for `create`. + pub run_id: Option, + /// JSON body. Empty for `status`; constructed for empty `running`. + pub body: String, +} + +impl AnalysisRunCliInvocation { + /// Parse argv plus stdin body into a validated loopback CLI invocation. + /// + /// # Errors + /// + /// Returns a fail-closed error for unknown verbs, missing required flags, + /// a non-loopback host, an unpublished consumer, credential-shaped flags, + /// metric keys on create/running, or a verb/body mismatch. + 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 = AnalysisRunCliVerb::parse(verb_token)?; + let flags = parse_flags(rest)?; + assemble_invocation(verb, flags, body.into()) + } + + /// Reject a non-loopback host, unpublished consumer, or empty identities. + /// + /// # Errors + /// + /// Returns [`ApiError::AuthorizationDenied`] for a non-loopback host and + /// [`ApiError::InvalidWirePayload`] for empty or unpublished 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.idempotency_key)?; + match self.verb { + AnalysisRunCliVerb::Create => { + if self.run_id.is_some() || self.body.is_empty() { + return Err(ApiError::InvalidWirePayload); + } + refuse_metrics_on_receipt(&self.body)?; + let request = AnalysisRunRequest::from_json(&self.body)?; + if request.idempotency_key != self.idempotency_key { + return Err(ApiError::InvalidWirePayload); + } + } + AnalysisRunCliVerb::Status => { + require_status_run_id(self.run_id.as_deref())?; + if !self.body.is_empty() { + return Err(ApiError::InvalidWirePayload); + } + } + AnalysisRunCliVerb::Running => { + let run_id = require_status_run_id(self.run_id.as_deref())?; + refuse_metrics_on_receipt(&self.body)?; + let transition = AnalysisRunLifecycleTransition::from_json(&self.body)?; + if transition.run_state != AnalysisRunStatusState::Running + || transition.run_id != run_id + || transition.idempotency_key != self.idempotency_key + { + return Err(ApiError::InvalidWirePayload); + } + } + AnalysisRunCliVerb::Terminal => { + let run_id = require_status_run_id(self.run_id.as_deref())?; + let transition = AnalysisRunLifecycleTransition::from_json(&self.body)?; + if !matches!( + transition.run_state, + AnalysisRunStatusState::Succeeded | AnalysisRunStatusState::Failed + ) || transition.run_id != run_id + || transition.idempotency_key != self.idempotency_key + { + return Err(ApiError::InvalidWirePayload); + } + } + } + Ok(()) + } +} + +struct ParsedFlags { + host: Option, + consumer: Option, + idempotency_key: Option, + run_id: Option, +} + +fn parse_flags(rest: &[String]) -> Result { + let mut flags = ParsedFlags { + host: None, + consumer: None, + idempotency_key: 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, + "idempotency-key" => &mut flags.idempotency_key, + "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: AnalysisRunCliVerb, + flags: ParsedFlags, + body: String, +) -> Result { + let host = flags.host.ok_or(ApiError::InvalidWirePayload)?; + let consumer = flags + .consumer + .unwrap_or_else(|| crate::NARUON_CONSUMER_CODE.to_owned()); + let invocation = match verb { + AnalysisRunCliVerb::Create => { + let request = AnalysisRunRequest::from_json(&body)?; + if let Some(run_id) = flags.run_id.as_deref() { + require_nonempty(run_id)?; + return Err(ApiError::InvalidWirePayload); + } + if let Some(key) = flags.idempotency_key.as_deref() + && key != request.idempotency_key + { + return Err(ApiError::InvalidWirePayload); + } + AnalysisRunCliInvocation { + verb, + host, + consumer, + idempotency_key: request.idempotency_key, + run_id: None, + body, + } + } + AnalysisRunCliVerb::Status => AnalysisRunCliInvocation { + verb, + host, + consumer, + idempotency_key: flags.idempotency_key.ok_or(ApiError::InvalidWirePayload)?, + run_id: Some(flags.run_id.ok_or(ApiError::InvalidWirePayload)?), + body, + }, + AnalysisRunCliVerb::Running => { + let run_id = flags.run_id.ok_or(ApiError::InvalidWirePayload)?; + let idempotency_key = flags.idempotency_key.ok_or(ApiError::InvalidWirePayload)?; + let body = if body.is_empty() { + AnalysisRunLifecycleTransition::running(run_id.clone(), idempotency_key.clone())? + .to_json()? + } else { + body + }; + AnalysisRunCliInvocation { + verb, + host, + consumer, + idempotency_key, + run_id: Some(run_id), + body, + } + } + AnalysisRunCliVerb::Terminal => { + let transition = AnalysisRunLifecycleTransition::from_json(&body)?; + if let Some(run_id) = flags.run_id.as_deref() + && run_id != transition.run_id + { + return Err(ApiError::InvalidWirePayload); + } + if let Some(key) = flags.idempotency_key.as_deref() + && key != transition.idempotency_key + { + return Err(ApiError::InvalidWirePayload); + } + AnalysisRunCliInvocation { + verb, + host, + consumer, + idempotency_key: transition.idempotency_key.clone(), + run_id: Some(transition.run_id.clone()), + 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) + } +} + +fn require_status_run_id(run_id: Option<&str>) -> Result<&str, ApiError> { + let run_id = run_id.ok_or(ApiError::InvalidWirePayload)?; + require_nonempty(run_id)?; + if run_id.len() > ANALYSIS_RUN_ID_MAX_LEN { + return Err(ApiError::LimitExceeded); + } + Ok(run_id) +} + +/// Compose one HTTP/1.1 request for a validated CLI invocation. +/// +/// # Errors +/// +/// Returns the same fail-closed errors as +/// [`AnalysisRunCliInvocation::validate`]. +pub fn compose_analysis_run_cli_http( + invocation: &AnalysisRunCliInvocation, +) -> Result { + invocation.validate()?; + let (method, path, body) = match invocation.verb { + AnalysisRunCliVerb::Create => ( + "POST", + NARUON_ANALYSIS_RUN_PATH.to_owned(), + invocation.body.as_str(), + ), + AnalysisRunCliVerb::Status => ( + "GET", + format!( + "{NARUON_ANALYSIS_RUN_PATH}/{}", + encode_path_segment( + invocation + .run_id + .as_deref() + .ok_or(ApiError::InvalidWirePayload)? + ) + ), + invocation.body.as_str(), + ), + AnalysisRunCliVerb::Running => ( + "POST", + format!( + "{NARUON_ANALYSIS_RUN_PATH}/{}/running", + encode_path_segment( + invocation + .run_id + .as_deref() + .ok_or(ApiError::InvalidWirePayload)? + ) + ), + invocation.body.as_str(), + ), + AnalysisRunCliVerb::Terminal => ( + "POST", + format!( + "{NARUON_ANALYSIS_RUN_PATH}/{}/terminal", + encode_path_segment( + invocation + .run_id + .as_deref() + .ok_or(ApiError::InvalidWirePayload)? + ) + ), + invocation.body.as_str(), + ), + }; + Ok(format!( + "{method} {path} HTTP/1.1\r\nHost: {}\r\ncontent-type: application/json\r\ntepp-consumer: {}\r\ntepp-contract-version: 1\r\nidempotency-key: {}\r\ncontent-length: {}\r\n\r\n{body}", + invocation.host, + invocation.consumer, + invocation.idempotency_key, + body.len() + )) +} + +/// Dispatch one CLI invocation against an in-process loopback service. +/// +/// # Errors +/// +/// Returns fail-closed validation errors before the HTTP handler runs. +pub fn dispatch_analysis_run_cli( + service: &mut AnalysisRunLiveService, + invocation: &AnalysisRunCliInvocation, +) -> Result { + let request = compose_analysis_run_cli_http(invocation)?; + Ok(service.handle_http_request(&request)) +} + +/// Execute one CLI invocation over loopback TCP against `tepp-loopback`. +/// +/// # Errors +/// +/// Returns fail-closed validation, transport, or response-framing errors. +pub fn execute_analysis_run_cli( + invocation: &AnalysisRunCliInvocation, +) -> Result { + let addr = require_loopback_host(&invocation.host)?; + let request = compose_analysis_run_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 scientific acceptance prints only on succeeded +/// `scientific_acceptance_v1` statuses. +/// +/// # Errors +/// +/// Returns [`ApiError::InvalidWirePayload`] when a receipt carries metric keys, +/// a failed or non-terminal body carries `tepp.scientific_acceptance.v1`, or a +/// scientific-acceptance profile is missing its artifact. +pub fn render_analysis_run_cli_stdout( + invocation: &AnalysisRunCliInvocation, + response: &NaruonLiveResponse, +) -> Result { + invocation.validate()?; + if response.body.is_empty() { + return Err(ApiError::InvalidWirePayload); + } + if !(200..300).contains(&response.status_code) { + refuse_scientific_acceptance_schema(&response.body)?; + return Ok(response.body.clone()); + } + match invocation.verb { + AnalysisRunCliVerb::Create | AnalysisRunCliVerb::Running => { + refuse_metrics_on_receipt(&response.body)?; + refuse_scientific_acceptance_schema(&response.body)?; + Ok(response.body.clone()) + } + AnalysisRunCliVerb::Status | AnalysisRunCliVerb::Terminal => { + render_status_stdout(&response.body) + } + } +} + +fn render_status_stdout(body: &str) -> Result { + let value: serde_json::Value = + serde_json::from_str(body).map_err(|_| ApiError::InvalidWirePayload)?; + let object = value.as_object().ok_or(ApiError::InvalidWirePayload)?; + let run_state = object + .get("run_state") + .and_then(serde_json::Value::as_str) + .ok_or(ApiError::InvalidWirePayload)?; + match run_state { + "accepted" | "running" | "failed" => { + refuse_metrics_on_receipt(body)?; + refuse_scientific_acceptance_schema(body)?; + Ok(body.to_owned()) + } + "succeeded" => render_succeeded_stdout(object, body), + _ => Err(ApiError::InvalidWirePayload), + } +} + +fn render_succeeded_stdout( + object: &serde_json::Map, + body: &str, +) -> Result { + let terminal = object + .get("terminal_result") + .and_then(serde_json::Value::as_object) + .ok_or(ApiError::InvalidWirePayload)?; + let profile = terminal + .get("output_profile") + .and_then(serde_json::Value::as_str) + .ok_or(ApiError::InvalidWirePayload)?; + match ( + profile == SCIENTIFIC_ACCEPTANCE_HTTP_PROFILE, + terminal.get("scientific_acceptance"), + ) { + (true, Some(artifact)) => { + let schema = artifact + .get("schema_version") + .and_then(serde_json::Value::as_str) + .ok_or(ApiError::InvalidWirePayload)?; + if schema == SCIENTIFIC_ACCEPTANCE_HTTP_SCHEMA { + Ok(body.to_owned()) + } else { + Err(ApiError::InvalidWirePayload) + } + } + (false, None) => { + refuse_metrics_on_receipt(body)?; + Ok(body.to_owned()) + } + _ => Err(ApiError::InvalidWirePayload), + } +} + +fn refuse_scientific_acceptance_schema(body: &str) -> Result<(), ApiError> { + if body.contains(SCIENTIFIC_ACCEPTANCE_HTTP_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 when the verb requires a JSON body; refuse leftover piped bytes +/// on `status` and `running` when stdin is not a terminal. +/// +/// # Errors +/// +/// Returns [`ApiError::InvalidWirePayload`] when stdin cannot be read. +pub fn read_analysis_run_cli_stdin( + verb: AnalysisRunCliVerb, + stdin_is_terminal: bool, + mut stdin: impl Read, +) -> Result { + if verb.requires_stdin_body() || !stdin_is_terminal { + let mut body = String::new(); + stdin + .read_to_string(&mut body) + .map_err(|_| ApiError::InvalidWirePayload)?; + Ok(body) + } else { + Ok(String::new()) + } +} + +#[cfg(test)] +#[allow(clippy::too_many_lines)] +mod tests { + use super::{ + AnalysisRunCliInvocation, AnalysisRunCliVerb, compose_analysis_run_cli_http, + dispatch_analysis_run_cli, execute_analysis_run_cli, parse_http_response, + read_analysis_run_cli_stdin, render_analysis_run_cli_stdout, static_reason, + }; + use crate::scientific_acceptance_http::{ + SCIENTIFIC_ACCEPTANCE_HTTP_PROFILE, SCIENTIFIC_ACCEPTANCE_HTTP_SCHEMA, + }; + use crate::{ + ANALYSIS_RUN_CONTRACT_VERSION, ANALYSIS_RUN_ID_MAX_LEN, AnalysisResultSummary, + AnalysisRunAccepted, AnalysisRunLifecycleTransition, AnalysisRunLiveService, + AnalysisRunRequest, AnalysisRunTerminalResult, ApiError, LINEAGEWEAVE_CONSUMER_CODE, + NARUON_CONSUMER_CODE, NaruonLiveResponse, receipt_json_carries_scientific_metrics, + }; + use sha2::{Digest, Sha256}; + + fn request() -> AnalysisRunRequest { + AnalysisRunRequest { + contract_version: ANALYSIS_RUN_CONTRACT_VERSION, + idempotency_key: "cli-idem-1".into(), + tenant_workspace_id: "cli-tenant-1".into(), + snapshot_id: "cli-snapshot-1".into(), + knowledge_cutoff: "2026-08-01T00:00:00Z".into(), + model_contract_version: "validation_cpu_f64_v1".into(), + output_profile: SCIENTIFIC_ACCEPTANCE_HTTP_PROFILE.into(), + } + } + + fn sha256_hex(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let digest = Sha256::digest(bytes); + let mut encoded = String::with_capacity(64); + for byte in digest { + encoded.push(char::from(HEX[usize::from(byte >> 4)])); + encoded.push(char::from(HEX[usize::from(byte & 0x0f)])); + } + encoded + } + + fn create_invocation() -> AnalysisRunCliInvocation { + AnalysisRunCliInvocation::from_args( + [ + "create", + "--host", + "127.0.0.1:18081", + "--consumer", + NARUON_CONSUMER_CODE, + ], + request().to_json().expect("request"), + ) + .expect("create") + } + + #[test] + fn verbs_parse_and_reject_unknown_tokens() { + assert_eq!( + AnalysisRunCliVerb::parse("create").expect("create"), + AnalysisRunCliVerb::Create + ); + assert_eq!( + AnalysisRunCliVerb::parse("status").expect("status"), + AnalysisRunCliVerb::Status + ); + assert_eq!( + AnalysisRunCliVerb::parse("running").expect("running"), + AnalysisRunCliVerb::Running + ); + assert_eq!( + AnalysisRunCliVerb::parse("terminal").expect("terminal"), + AnalysisRunCliVerb::Terminal + ); + assert_eq!( + AnalysisRunCliVerb::parse("CREATE"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!(AnalysisRunCliVerb::Create.as_str(), "create"); + assert_eq!(AnalysisRunCliVerb::Status.as_str(), "status"); + assert_eq!(AnalysisRunCliVerb::Running.as_str(), "running"); + assert_eq!(AnalysisRunCliVerb::Terminal.as_str(), "terminal"); + } + + #[test] + fn from_args_refuses_empty_unknown_host_and_credential_flags() { + assert_eq!( + AnalysisRunCliInvocation::from_args(Vec::::new(), "").unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + AnalysisRunCliInvocation::from_args(["nope"], "").unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + AnalysisRunCliInvocation::from_args(["create"], request().to_json().expect("json")) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + AnalysisRunCliInvocation::from_args( + ["create", "--host"], + request().to_json().expect("json") + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + AnalysisRunCliInvocation::from_args( + ["create", "--host", "8.8.8.8:80"], + request().to_json().expect("json") + ) + .unwrap_err(), + ApiError::AuthorizationDenied + ); + assert_eq!( + AnalysisRunCliInvocation::from_args( + ["create", "--host", "not-a-socket"], + request().to_json().expect("json") + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + AnalysisRunCliInvocation::from_args( + [ + "create", + "--host", + "127.0.0.1:18081", + "--authorization", + "secret" + ], + request().to_json().expect("json") + ) + .unwrap_err(), + ApiError::AuthorizationDenied + ); + assert_eq!( + AnalysisRunCliInvocation::from_args( + ["create", "--host", "127.0.0.1:18081", "--pretty"], + request().to_json().expect("json") + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + AnalysisRunCliInvocation::from_args( + ["create", "--host", "127.0.0.1:18081", "extra"], + request().to_json().expect("json") + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + } + + #[test] + fn create_status_running_and_terminal_assemble() { + let create = create_invocation(); + assert_eq!(create.verb, AnalysisRunCliVerb::Create); + assert!(create.run_id.is_none()); + let http = compose_analysis_run_cli_http(&create).expect("http"); + assert!(http.starts_with("POST /v1/analysis-runs HTTP/1.1")); + + let status = AnalysisRunCliInvocation::from_args( + [ + "status", + "--host", + "127.0.0.1:18081", + "--run-id", + "run-1", + "--idempotency-key", + "cli-idem-1", + ], + "", + ) + .expect("status"); + assert_eq!(status.consumer, NARUON_CONSUMER_CODE); + let status_http = compose_analysis_run_cli_http(&status).expect("status http"); + assert!(status_http.starts_with("GET /v1/analysis-runs/run-1 HTTP/1.1")); + + let running = AnalysisRunCliInvocation::from_args( + [ + "running", + "--host", + "127.0.0.1:18081", + "--consumer", + LINEAGEWEAVE_CONSUMER_CODE, + "--run-id", + "run-1", + "--idempotency-key", + "cli-idem-1", + ], + "", + ) + .expect("running"); + assert_eq!(running.consumer, LINEAGEWEAVE_CONSUMER_CODE); + assert!( + compose_analysis_run_cli_http(&running) + .expect("running http") + .contains("/running") + ); + + let terminal_body = AnalysisRunLifecycleTransition::terminal( + "run-1", + "cli-idem-1", + AnalysisRunTerminalResult::failed( + &request(), + &AnalysisRunAccepted::new("run-1", "accepted", "cli-idem-1").expect("accepted"), + "2026-08-02T03:04:05Z", + "estimation_failed", + ) + .expect("failed"), + None, + ) + .expect("transition") + .to_json() + .expect("json"); + let terminal = AnalysisRunCliInvocation::from_args( + ["terminal", "--host", "127.0.0.1:18081"], + terminal_body, + ) + .expect("terminal"); + assert_eq!(terminal.run_id.as_deref(), Some("run-1")); + assert!( + compose_analysis_run_cli_http(&terminal) + .expect("terminal http") + .contains("/terminal") + ); + } + + #[test] + fn create_and_lifecycle_bodies_fail_closed() { + assert_eq!( + AnalysisRunCliInvocation::from_args(["create", "--host", "127.0.0.1:18081"], "") + .unwrap_err(), + ApiError::InvalidWirePayload + ); + let mut metric_json = request().to_json().expect("json"); + metric_json.pop(); + metric_json.push_str(",\"rmse\":0.1}"); + assert_eq!( + AnalysisRunCliInvocation::from_args( + ["create", "--host", "127.0.0.1:18081"], + metric_json + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + AnalysisRunCliInvocation::from_args( + ["create", "--host", "127.0.0.1:18081", "--run-id", "run-1"], + request().to_json().expect("json") + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + AnalysisRunCliInvocation::from_args( + [ + "create", + "--host", + "127.0.0.1:18081", + "--idempotency-key", + "other" + ], + request().to_json().expect("json") + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + AnalysisRunCliInvocation::from_args( + ["create", "--host", "127.0.0.1:18081", "--consumer", "other"], + request().to_json().expect("json") + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + AnalysisRunCliInvocation::from_args( + [ + "status", + "--host", + "127.0.0.1:18081", + "--run-id", + "run-1", + "--idempotency-key", + "cli-idem-1" + ], + "{}" + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + AnalysisRunCliInvocation::from_args( + [ + "status", + "--host", + "127.0.0.1:18081", + "--idempotency-key", + "cli-idem-1" + ], + "" + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + AnalysisRunCliInvocation::from_args( + [ + "running", + "--host", + "127.0.0.1:18081", + "--idempotency-key", + "cli-idem-1" + ], + "" + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + let oversized = "r".repeat(ANALYSIS_RUN_ID_MAX_LEN + 1); + assert_eq!( + AnalysisRunCliInvocation::from_args( + [ + "status", + "--host", + "127.0.0.1:18081", + "--run-id", + oversized.as_str(), + "--idempotency-key", + "cli-idem-1" + ], + "" + ) + .unwrap_err(), + ApiError::LimitExceeded + ); + assert_eq!( + AnalysisRunCliInvocation::from_args(["terminal", "--host", "127.0.0.1:18081"], "") + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + AnalysisRunCliInvocation::from_args( + [ + "create", + "--host", + "127.0.0.1:18081", + "--host", + "127.0.0.1:9" + ], + request().to_json().expect("json") + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + } + + #[test] + fn running_body_must_stay_running_and_terminal_ids_must_match() { + let running = AnalysisRunLifecycleTransition::running("run-1", "cli-idem-1") + .expect("running") + .to_json() + .expect("json"); + AnalysisRunCliInvocation::from_args( + [ + "running", + "--host", + "127.0.0.1:18081", + "--run-id", + "run-1", + "--idempotency-key", + "cli-idem-1", + ], + running.clone(), + ) + .expect("matching running"); + assert_eq!( + AnalysisRunCliInvocation::from_args( + [ + "running", + "--host", + "127.0.0.1:18081", + "--run-id", + "run-2", + "--idempotency-key", + "cli-idem-1", + ], + running, + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + let terminal_body = AnalysisRunLifecycleTransition::terminal( + "run-1", + "cli-idem-1", + AnalysisRunTerminalResult::failed( + &request(), + &AnalysisRunAccepted::new("run-1", "accepted", "cli-idem-1").expect("accepted"), + "2026-08-02T03:04:05Z", + "estimation_failed", + ) + .expect("failed"), + None, + ) + .expect("transition") + .to_json() + .expect("json"); + assert_eq!( + AnalysisRunCliInvocation::from_args( + ["terminal", "--host", "127.0.0.1:18081", "--run-id", "run-2"], + terminal_body.clone() + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + AnalysisRunCliInvocation::from_args( + [ + "terminal", + "--host", + "127.0.0.1:18081", + "--idempotency-key", + "other" + ], + terminal_body + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + AnalysisRunCliInvocation::from_args( + [ + "running", + "--host", + "127.0.0.1:18081", + "--run-id", + "run-1", + "--idempotency-key", + "cli-idem-1", + ], + AnalysisRunLifecycleTransition::terminal( + "run-1", + "cli-idem-1", + AnalysisRunTerminalResult::failed( + &request(), + &AnalysisRunAccepted::new("run-1", "accepted", "cli-idem-1") + .expect("accepted"), + "2026-08-02T03:04:05Z", + "estimation_failed", + ) + .expect("failed"), + None, + ) + .expect("transition") + .to_json() + .expect("json"), + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + } + + #[test] + fn dispatch_create_running_terminal_then_status_prints_scientific_acceptance() { + let mut service = AnalysisRunLiveService::new(); + let create = create_invocation(); + let accepted = dispatch_analysis_run_cli(&mut service, &create).expect("create"); + assert_eq!(accepted.status_code, 202); + let stdout = render_analysis_run_cli_stdout(&create, &accepted).expect("create stdout"); + assert!(!receipt_json_carries_scientific_metrics(&stdout)); + let accepted_dto = AnalysisRunAccepted::from_json(&stdout).expect("accepted"); + + let running = AnalysisRunCliInvocation::from_args( + [ + "running", + "--host", + "127.0.0.1:18081", + "--run-id", + accepted_dto.run_id.as_str(), + "--idempotency-key", + "cli-idem-1", + ], + "", + ) + .expect("running"); + let running_response = + dispatch_analysis_run_cli(&mut service, &running).expect("running dispatch"); + assert_eq!(running_response.status_code, 200); + let running_stdout = + render_analysis_run_cli_stdout(&running, &running_response).expect("running stdout"); + assert!(running_stdout.contains("\"running\"")); + assert!(!running_stdout.contains(SCIENTIFIC_ACCEPTANCE_HTTP_SCHEMA)); + + let artifact = format!( + r#"{{"schema_version":"{SCIENTIFIC_ACCEPTANCE_HTTP_SCHEMA}","output_profile":"{SCIENTIFIC_ACCEPTANCE_HTTP_PROFILE}","binding_sha256":"{}","run_id":"{}"}}"#, + "ab".repeat(32), + accepted_dto.run_id + ); + let digest = sha256_hex(artifact.as_bytes()); + let terminal = AnalysisRunTerminalResult::succeeded( + &request(), + &accepted_dto, + "artifact-cli-1", + digest, + SCIENTIFIC_ACCEPTANCE_HTTP_SCHEMA, + "2026-08-02T03:04:05Z", + AnalysisResultSummary::new("scientific_acceptance", 4, 8, "validated") + .expect("summary"), + ) + .expect("terminal"); + let transition = AnalysisRunLifecycleTransition::terminal( + accepted_dto.run_id.clone(), + "cli-idem-1", + terminal, + Some(artifact), + ) + .expect("transition"); + let terminal_invocation = AnalysisRunCliInvocation::from_args( + ["terminal", "--host", "127.0.0.1:18081"], + transition.to_json().expect("json"), + ) + .expect("terminal invocation"); + let terminal_response = + dispatch_analysis_run_cli(&mut service, &terminal_invocation).expect("terminal"); + assert_eq!(terminal_response.status_code, 200); + let terminal_stdout = + render_analysis_run_cli_stdout(&terminal_invocation, &terminal_response) + .expect("terminal stdout"); + assert!(terminal_stdout.contains(SCIENTIFIC_ACCEPTANCE_HTTP_SCHEMA)); + + let status = AnalysisRunCliInvocation::from_args( + [ + "status", + "--host", + "127.0.0.1:18081", + "--run-id", + accepted_dto.run_id.as_str(), + "--idempotency-key", + "cli-idem-1", + ], + "", + ) + .expect("status"); + let status_response = dispatch_analysis_run_cli(&mut service, &status).expect("status"); + let status_stdout = + render_analysis_run_cli_stdout(&status, &status_response).expect("status stdout"); + assert_eq!(status_stdout, terminal_stdout); + } + + #[test] + fn render_refuses_metrics_failed_artifact_and_unknown_state() { + let create = create_invocation(); + assert_eq!( + render_analysis_run_cli_stdout( + &create, + &NaruonLiveResponse { + status_code: 202, + reason_phrase: "Accepted", + body: String::new(), + } + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + render_analysis_run_cli_stdout( + &create, + &NaruonLiveResponse { + status_code: 202, + reason_phrase: "Accepted", + body: "{\"run_state\":\"accepted\",\"rmse\":1.0}".into(), + } + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + let error_stdout = render_analysis_run_cli_stdout( + &create, + &NaruonLiveResponse { + status_code: 400, + reason_phrase: "Bad Request", + body: "{\"error_code\":\"invalid_wire_payload\"}".into(), + }, + ) + .expect("error"); + assert!(error_stdout.contains("invalid_wire_payload")); + assert_eq!( + render_analysis_run_cli_stdout( + &create, + &NaruonLiveResponse { + status_code: 400, + reason_phrase: "Bad Request", + body: format!("{{\"schema_version\":\"{SCIENTIFIC_ACCEPTANCE_HTTP_SCHEMA}\"}}"), + } + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + + let status = AnalysisRunCliInvocation::from_args( + [ + "status", + "--host", + "127.0.0.1:18081", + "--run-id", + "run-1", + "--idempotency-key", + "cli-idem-1", + ], + "", + ) + .expect("status"); + assert_eq!( + render_analysis_run_cli_stdout( + &status, + &NaruonLiveResponse { + status_code: 200, + reason_phrase: "OK", + body: format!( + "{{\"run_state\":\"accepted\",\"schema_version\":\"{SCIENTIFIC_ACCEPTANCE_HTTP_SCHEMA}\"}}" + ), + } + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + render_analysis_run_cli_stdout( + &status, + &NaruonLiveResponse { + status_code: 200, + reason_phrase: "OK", + body: format!( + "{{\"run_state\":\"failed\",\"schema_version\":\"{SCIENTIFIC_ACCEPTANCE_HTTP_SCHEMA}\"}}" + ), + } + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + render_analysis_run_cli_stdout( + &status, + &NaruonLiveResponse { + status_code: 200, + reason_phrase: "OK", + body: "{\"run_state\":\"queued\"}".into(), + } + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + render_analysis_run_cli_stdout( + &status, + &NaruonLiveResponse { + status_code: 200, + reason_phrase: "OK", + body: "not-json".into(), + } + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + let failed_ok = render_analysis_run_cli_stdout( + &status, + &NaruonLiveResponse { + status_code: 200, + reason_phrase: "OK", + body: "{\"run_state\":\"failed\",\"run_id\":\"run-1\"}".into(), + }, + ) + .expect("failed"); + assert!(failed_ok.contains("\"failed\"")); + let other_profile = render_analysis_run_cli_stdout( + &status, + &NaruonLiveResponse { + status_code: 200, + reason_phrase: "OK", + body: "{\"run_state\":\"succeeded\",\"terminal_result\":{\"output_profile\":\"other_v1\"}}" + .into(), + }, + ) + .expect("other"); + assert!(other_profile.contains("other_v1")); + assert_eq!( + render_analysis_run_cli_stdout( + &status, + &NaruonLiveResponse { + status_code: 200, + reason_phrase: "OK", + body: format!( + "{{\"run_state\":\"succeeded\",\"terminal_result\":{{\"output_profile\":\"{SCIENTIFIC_ACCEPTANCE_HTTP_PROFILE}\"}}}}" + ), + } + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + render_analysis_run_cli_stdout( + &status, + &NaruonLiveResponse { + status_code: 200, + reason_phrase: "OK", + body: format!( + "{{\"run_state\":\"succeeded\",\"terminal_result\":{{\"output_profile\":\"{SCIENTIFIC_ACCEPTANCE_HTTP_PROFILE}\",\"scientific_acceptance\":{{\"schema_version\":\"other\"}}}}}}" + ), + } + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + render_analysis_run_cli_stdout( + &status, + &NaruonLiveResponse { + status_code: 200, + reason_phrase: "OK", + body: "{\"run_state\":\"succeeded\",\"terminal_result\":{\"output_profile\":\"other_v1\",\"scientific_acceptance\":{}}}" + .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 handle = std::thread::spawn(move || { + drop(service.serve_one()); + }); + let mut invocation = create_invocation(); + invocation.host = addr.to_string(); + let response = execute_analysis_run_cli(&invocation).expect("tcp"); + assert_eq!(response.status_code, 202); + handle.join().expect("join"); + + invocation.host = "127.0.0.1:1".into(); + assert_eq!( + execute_analysis_run_cli(&invocation).unwrap_err(), + ApiError::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(), + 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(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_status_and_reads_otherwise() { + let empty = read_analysis_run_cli_stdin(AnalysisRunCliVerb::Status, true, std::io::empty()) + .expect("tty status"); + assert!(empty.is_empty()); + let piped = read_analysis_run_cli_stdin( + AnalysisRunCliVerb::Status, + false, + std::io::Cursor::new(b"leftover"), + ) + .expect("piped"); + assert_eq!(piped, "leftover"); + let create = read_analysis_run_cli_stdin( + AnalysisRunCliVerb::Create, + true, + std::io::Cursor::new(b"{\"ok\":true}"), + ) + .expect("create"); + assert_eq!(create, "{\"ok\":true}"); + let terminal = read_analysis_run_cli_stdin( + AnalysisRunCliVerb::Terminal, + false, + std::io::Cursor::new(b"{}"), + ) + .expect("terminal"); + assert_eq!(terminal, "{}"); + let running = read_analysis_run_cli_stdin( + AnalysisRunCliVerb::Running, + true, + std::io::Cursor::new(b"ignored"), + ) + .expect("running tty"); + assert!(running.is_empty()); + } +} diff --git a/crates/tepp_api/src/bin/tepp_analysis_run.rs b/crates/tepp_api/src/bin/tepp_analysis_run.rs new file mode 100644 index 000000000..a3d258a36 --- /dev/null +++ b/crates/tepp_api/src/bin/tepp_analysis_run.rs @@ -0,0 +1,31 @@ +//! Operator CLI for loopback scientific-acceptance analysis-run lifecycle. + +use std::io::{self, IsTerminal}; +use std::process::ExitCode; + +use tepp_api::{ + AnalysisRunCliInvocation, AnalysisRunCliVerb, ApiError, execute_analysis_run_cli, + read_analysis_run_cli_stdin, render_analysis_run_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(); + let verb = AnalysisRunCliVerb::parse(args.first().ok_or(ApiError::InvalidWirePayload)?)?; + let body = read_analysis_run_cli_stdin(verb, io::stdin().is_terminal(), io::stdin())?; + let invocation = AnalysisRunCliInvocation::from_args(&args, body)?; + let response = execute_analysis_run_cli(&invocation)?; + let stdout = render_analysis_run_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 facecf471..22094d626 100644 --- a/crates/tepp_api/src/lib.rs +++ b/crates/tepp_api/src/lib.rs @@ -14,10 +14,12 @@ //! only a succeeded status with profile `scientific_acceptance_v1` may return //! `tepp.scientific_acceptance.v1`. `POST /v1/analysis-runs/{run_id}/running` //! and `POST /v1/analysis-runs/{run_id}/terminal` record those statuses on the -//! same loopback listener. +//! same loopback listener. The `tepp-analysis-run` CLI is the operator-visible +//! loopback client for those verbs. mod analysis_result; mod analysis_run; +mod analysis_run_cli; mod analysis_run_lifecycle_http; mod analysis_run_live; mod analysis_run_status_http; @@ -76,6 +78,20 @@ pub use analysis_run::DEFAULT_ANALYSIS_RUN_BYTE_LIMIT; pub use analysis_run::requests_are_idempotent_matches; /// Require exact status binding to a request and accepted receipt. pub use analysis_run::require_status_binding; +/// Loopback analysis-run CLI invocation. +pub use analysis_run_cli::AnalysisRunCliInvocation; +/// Loopback analysis-run CLI verb. +pub use analysis_run_cli::AnalysisRunCliVerb; +/// Compose one HTTP/1.1 request for a CLI invocation. +pub use analysis_run_cli::compose_analysis_run_cli_http; +/// Dispatch a CLI invocation against an in-process loopback service. +pub use analysis_run_cli::dispatch_analysis_run_cli; +/// Execute a CLI invocation over loopback TCP. +pub use analysis_run_cli::execute_analysis_run_cli; +/// Read CLI stdin according to verb and terminal state. +pub use analysis_run_cli::read_analysis_run_cli_stdin; +/// Filter CLI stdout so scientific acceptance prints only when authorized. +pub use analysis_run_cli::render_analysis_run_cli_stdout; /// Lifecycle-transition contract version constant. pub use analysis_run_lifecycle_http::ANALYSIS_RUN_LIFECYCLE_CONTRACT_VERSION; /// Production HTTP running/terminal transition body. diff --git a/crates/tepp_api/tests/scientific_acceptance_cli_contract.rs b/crates/tepp_api/tests/scientific_acceptance_cli_contract.rs new file mode 100644 index 000000000..864b46368 --- /dev/null +++ b/crates/tepp_api/tests/scientific_acceptance_cli_contract.rs @@ -0,0 +1,62 @@ +//! Operator-visible loopback CLI contract for GAP-003A. + +use std::io::{BufRead, BufReader, Write}; +use std::process::{Command, Stdio}; + +use tepp_api::{ + ANALYSIS_RUN_CONTRACT_VERSION, NARUON_CONSUMER_CODE, SCIENTIFIC_ACCEPTANCE_HTTP_PROFILE, + receipt_json_carries_scientific_metrics, +}; + +fn request_json() -> String { + format!( + r#"{{"contract_version":{ANALYSIS_RUN_CONTRACT_VERSION},"idempotency_key":"cli-bin-idem-1","tenant_workspace_id":"cli-bin-tenant","snapshot_id":"cli-bin-snapshot","knowledge_cutoff":"2026-08-01T00:00:00Z","model_contract_version":"validation_cpu_f64_v1","output_profile":"{SCIENTIFIC_ACCEPTANCE_HTTP_PROFILE}"}}"# + ) +} + +#[test] +fn binary_create_stays_metric_free_and_refuses_non_loopback() { + let mut child = Command::new(env!("CARGO_BIN_EXE_tepp-loopback")) + .args(["127.0.0.1:0", "1"]) + .stdout(Stdio::piped()) + .spawn() + .expect("spawn loopback"); + let mut address = String::new(); + BufReader::new(child.stdout.take().expect("stdout")) + .read_line(&mut address) + .expect("bound address"); + let host = address.trim(); + + let mut create = Command::new(env!("CARGO_BIN_EXE_tepp-analysis-run")) + .args(["create", "--host", host, "--consumer", NARUON_CONSUMER_CODE]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .expect("spawn create"); + create + .stdin + .take() + .expect("stdin") + .write_all(request_json().as_bytes()) + .expect("write request"); + let output = create.wait_with_output().expect("create wait"); + assert!(output.status.success()); + let body = String::from_utf8(output.stdout).expect("utf8"); + assert!(!receipt_json_carries_scientific_metrics(&body)); + assert!(body.contains("\"accepted\"")); + assert!(!body.contains("tepp.scientific_acceptance.v1")); + + let refused = Command::new(env!("CARGO_BIN_EXE_tepp-analysis-run")) + .args([ + "create", + "--host", + "8.8.8.8:80", + "--consumer", + NARUON_CONSUMER_CODE, + ]) + .stdin(Stdio::piped()) + .output() + .expect("non-loopback"); + assert!(!refused.status.success()); + assert!(child.wait().expect("wait loopback").success()); +} diff --git a/docs/API_CONTRACT.md b/docs/API_CONTRACT.md index 87a7a4a87..4746e0e5f 100644 --- a/docs/API_CONTRACT.md +++ b/docs/API_CONTRACT.md @@ -105,7 +105,11 @@ bodies stay metric-free, and only a succeeded status with profile `POST /v1/analysis-runs/{run_id}/running` and `POST /v1/analysis-runs/{run_id}/terminal` are the production loopback status-update path that records those statuses; they do not persist and do not -execute psychometric estimation. Production TLS remains a later adapter. +execute psychometric estimation. The loopback `tepp-analysis-run` CLI is the +operator-visible client for create, running, terminal, and status on that +listener: create and running stay metric-free, and only a succeeded status +with profile `scientific_acceptance_v1` may print `tepp.scientific_acceptance.v1`. +Production TLS remains a later adapter. The stacked `analysis_engine` slice provides the first executable service-side path behind these DTOs. It consumes a bounded identity-free snapshot, excludes diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index b90843a85..8d138bc6d 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -54,7 +54,8 @@ The full APA 7th standards/literature register remains `docs/research/standards- | known-truth temporal/event simulation manifests | PRD; TRD; Test Strategy | `tepp_simulation` on protected main; recovery metrics in `validation_core` | implemented-main | | versioned service/API contracts and exports | PRD; API contract; ADR 0011/0013 | `tepp_api` analysis-run/export/JSON-LD/GraphML contracts on protected main (PR #21); request-bound terminal result active in PR #157; HTTP service remains accepted-target; the `orchestrator_live` loopback interpretation listener is on this PR | partial | | loopback analysis-run scientific-acceptance GET | ADR 0027; API contract; RFC 9110; FIPS 180-4 | `tepp_api` `GET /v1/analysis-runs/{run_id}` on `AnalysisRunLiveService` (#359): accepted/running stay metric-free; `tepp.scientific_acceptance.v1` only on succeeded `scientific_acceptance_v1`; not implemented-main | active-PR | -| loopback analysis-run scientific-acceptance lifecycle POST | ADR 0028; API contract; RFC 9110; FIPS 180-4 | `tepp_api` `POST /v1/analysis-runs/{run_id}/running` and `/terminal` on `AnalysisRunLiveService` (this PR): production status-update path; accepted/running stay metric-free; `tepp.scientific_acceptance.v1` only after succeeded `scientific_acceptance_v1`; not implemented-main | active-PR | +| loopback analysis-run scientific-acceptance lifecycle POST | ADR 0028; API contract; RFC 9110; FIPS 180-4 | `tepp_api` `POST /v1/analysis-runs/{run_id}/running` and `/terminal` on `AnalysisRunLiveService` (#360): production status-update path; accepted/running stay metric-free; `tepp.scientific_acceptance.v1` only after succeeded `scientific_acceptance_v1`; not implemented-main | active-PR | +| loopback analysis-run scientific-acceptance CLI | ADR 0029; API contract; RFC 9110; FIPS 180-4 | `tepp_api` `tepp-analysis-run` CLI (this PR): operator-visible create/running/terminal/status client; accepted/running stay metric-free; `tepp.scientific_acceptance.v1` only on succeeded `scientific_acceptance_v1`; not implemented-main | 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/0029-scientific-acceptance-loopback-cli.md b/docs/adr/0029-scientific-acceptance-loopback-cli.md new file mode 100644 index 000000000..6b84e2e0b --- /dev/null +++ b/docs/adr/0029-scientific-acceptance-loopback-cli.md @@ -0,0 +1,79 @@ +# ADR 0029 — Scientific-acceptance loopback CLI + +**Decision status:** Accepted +**Implementation maturity:** active-PR +**Date:** 2026-08-31 +**Supersedes:** None; complements ADR 0027 and ADR 0028 for the operator-visible client. Does not supersede ADR 0014 claim-promotion authority and does not reuse ADR 0026, ADR 0027, or ADR 0028. + +## Context + +ADR 0027 serves `GET /v1/analysis-runs/{run_id}` and ADR 0028 serves production `POST /running` and `POST /terminal` on the loopback listener. Operators still had to write raw HTTP/1.1 to create a run, record running or terminal status, and read the result. Duplicating the GET listener, the lifecycle POST listener, the terminal-result DTO, or the `analysis_engine` library bind would collide with live PRs. + +## Decision + +`tepp_api` publishes a loopback-only `tepp-analysis-run` CLI: + +- `create` POSTs a metric-free analysis-run request. +- `running` POSTs a metric-free running transition. +- `terminal` POSTs a request-bound terminal transition. +- `status` GETs the current status. +- Accepted and running stdout stay metric-free. Only a succeeded status whose request profile is `scientific_acceptance_v1` may print `tepp.scientific_acceptance.v1`. +- Non-loopback hosts, unpublished consumers, credential-shaped flags, reverse transitions, failed-plus-artifact emission, and receipt RMSE/bias/coverage/SE-gate keys fail closed. +- Persistence, Compose recovery, and psychometric execution remain GAP-003B. + +## Non-goals + +- Production TLS, public bind, or durable status storage. +- Leiden community detection, Driver p.16 std-family restoration, or Figma/export work. +- Promoting an ADR 0014 scientific claim from CLI success. + +## Alternatives considered + +1. **Keep raw HTTP as the only operator path** — rejected because the GET and POST listeners are not operator-usable without a client. +2. **Stack the client onto the live lifecycle POST PR as extra HTTP routes** — rejected because that head is already under review as a write-path slice. +3. **Persist CLI transcripts in PostgreSQL** — rejected as GAP-003B / live draft #287. +4. **Loopback CLI with the same metric-free and succeeded-only gates as ADR 0027/0028** — accepted. + +## Consequences + +- Operators can create, run, terminate, and read a scientific-acceptance analysis run on loopback without writing HTTP. +- GET remains the safe read (ADR 0027). POST remains the state change (ADR 0028 / RFC 9110 §9.3.3). The CLI is a client of those paths. +- CLI success is not release evidence. + +## Failure and recovery + +Non-loopback hosts return authorization denied. Unknown verbs, metric keys on create/running, failed-plus-artifact emission, consumer mismatch, and a scientific-acceptance profile without an artifact fail closed. 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. +- SHA-256 digest agreement is a byte-identity check, not a validity claim. +- Process exit 0 on a succeeded scientific-acceptance GET is not release evidence. + +## Compatibility and migration + +GET status, POST create, POST running/terminal, temporal-context, and project-history paths are unchanged. Production adapters may replace loopback while preserving metric-free receipts and the succeeded-only scientific-acceptance rule. + +## Verification + +Falsifiable evidence: + +- CLI create and CLI running JSON have no RMSE/bias/coverage/SE-gate/scientific-acceptance keys; +- CLI terminal succeeded with profile `scientific_acceptance_v1` then CLI status prints `tepp.scientific_acceptance.v1` only when the artifact digest matches; +- CLI failed with an artifact, non-loopback host, credential flags, and consumer mismatch fail closed; +- Clippy `-D warnings`, `tepp_api` tests, rustdoc, and exact-head review remain required. + +## Rollback and supersession + +Rollback removes the CLI binary and client module; GET status and POST lifecycle remain valid. A superseding ADR is required to persist status, bind a public address, or treat CLI success as an ADR 0014 claim. + +## Related authority + +- ADR 0028 owns the HTTP write path. +- ADR 0027 owns the GET status read. +- ADR 0018 owns consumer-scoped ingress and metric-free `202 Accepted`. +- ADR 0022 owns deterministic execution to a digest-bound terminal result. +- ADR 0014 owns scientific claim promotion. +- ADR 0008 owns SHA-256 identity. +- ADR 0011 owns standalone/modular HTTP boundaries. diff --git a/docs/adr/README.md b/docs/adr/README.md index 421caeadd..ad280475b 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -32,6 +32,7 @@ Read [`ADR_POLICY.md`](ADR_POLICY.md) first. **Decision status and implementatio | [0025](0025-macos-native-rust-mlx-metal-boundary.md) | macOS-native Rust-owned MLX Metal execution | Accepted | accepted-target | Compose authenticates to a native host service; Linux never claims Metal, and actual backend/parity receipts fail closed. | | [0027](0027-scientific-acceptance-http-status.md) | Scientific-acceptance loopback HTTP status path | Accepted | active-PR | GET `/v1/analysis-runs/{run_id}` stays metric-free on accepted/running; `tepp.scientific_acceptance.v1` only on succeeded `scientific_acceptance_v1`. | | [0028](0028-scientific-acceptance-http-lifecycle.md) | Scientific-acceptance loopback HTTP lifecycle POST | Accepted | active-PR | POST `/running` and `/terminal` are the production status-update path; GET remains ADR 0027. Persistence remains GAP-003B. | +| [0029](0029-scientific-acceptance-loopback-cli.md) | Scientific-acceptance loopback CLI | Accepted | active-PR | `tepp-analysis-run` drives create/running/terminal/status on loopback; metric-free receipts; `tepp.scientific_acceptance.v1` only on succeeded `scientific_acceptance_v1`. | | [0023](0023-lineage-criterion-anchor-contract.md) | TEPP-owned Event Lineage criterion anchor | Accepted | active-PR | PR #237 publishes the strict accepted/rejected artifact and identities; estimator execution remains fail-closed future work. | | [0024](0024-independent-topic-importance-anchor.md) | Posterior topic-context producer contract | Accepted | contract-only active-PR | Strict DTO/schema only; the current estimator does not emit it. fast-mlsirm owns case-deletion influence. | | [0001](0001-rust-first-modular-msa.md) | Rust-first numerical core and CPU `f64` reference | Accepted | partial | ADR 0011 owns cross-service/MSA authority; 0001 retains numerical/backend authority. | diff --git a/docs/research/scientific-acceptance-loopback-cli.md b/docs/research/scientific-acceptance-loopback-cli.md new file mode 100644 index 000000000..e16a4bce7 --- /dev/null +++ b/docs/research/scientific-acceptance-loopback-cli.md @@ -0,0 +1,59 @@ +# Scientific-acceptance loopback CLI (GAP-003A) + +## Scope + +This note doctors the fifth GAP-003A executable slice in `tepp_api` +(issue #166): + +1. `tepp-analysis-run create` POSTs a metric-free analysis-run receipt; +2. `tepp-analysis-run running` POSTs metric-free running status; +3. `tepp-analysis-run terminal` POSTs a request-bound terminal status; +4. `tepp-analysis-run status` GETs the current status; +5. accepted and running stdout stay metric-free, and only a succeeded status + whose request profile is `scientific_acceptance_v1` may print + `tepp.scientific_acceptance.v1`; +6. non-loopback hosts, credential-shaped flags, failed-plus-artifact emission, + receipt RMSE/bias/coverage/SE-gate keys, and consumer mismatch fail closed. + +This slice does not copy the terminal-result DTO. Library binding remains on +live PR #356. The API wire DTO remains on live PR #358. The GET status path +remains on live PR #359 / ADR 0027. The lifecycle POST path remains on live +PR #360 / ADR 0028. PostgreSQL persistence and Compose recovery remain +GAP-003B. CLI success does not promote an ADR 0014 claim. + +## Authoritative sources + +Fielding, R., Nottingham, M., & Reschke, J. (Eds.). (2022). *HTTP semantics* +(RFC 9110). Internet Engineering Task Force. https://doi.org/10.17487/RFC9110 + +National Institute of Standards and Technology. (2015). *Secure Hash Standard +(SHS)* (FIPS PUB 180-4). https://doi.org/10.6028/NIST.FIPS.180-4 + +Peng, R. D. (2011). Reproducible research in computational science. +*Science, 334*(6060), 1226–1227. https://doi.org/10.1126/science.1213847 + +National Academies of Sciences, Engineering, and Medicine. (2019). +*Reproducibility and replicability in science*. The National Academies Press. +https://doi.org/10.17226/25303 + +## Application + +RFC 9110 §9.3.3 defines POST as the method that processes a representation +according to the resource's own semantics, which remains the verb for +lifecycle transitions; GET remains a safe read (Fielding, Nottingham, & +Reschke, 2022). The CLI is a client of those methods, not a second HTTP +authority. Peng (2011) and the National Academies (2019) require +computational reproducibility to bind identities without treating a receipt +as a scientific claim, so RMSE, bias, coverage, and SE-gate keys stay off +create and running stdout. FIPS 180-4 SHA-256 continues to identify canonical +artifact bytes on the write path (National Institute of Standards and +Technology, 2015). + +## Verification + +- CLI create and CLI running contain neither `scientific_acceptance` nor + `rmse`; +- CLI terminal succeeded with profile `scientific_acceptance_v1` then CLI + status includes `tepp.scientific_acceptance.v1` only when the digest matches; +- failed-plus-artifact, non-loopback host, credential flags, and + consumer/idempotency mismatch fail closed. From d8f3c5faf6aee5791e5752b4d0d02a0726391d26 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 31 Aug 2026 09:42:00 +0000 Subject: [PATCH 2/2] docs(adr): number scientific-acceptance CLI as ADR 0030 Live #361 already owns ADR 0029 for loopback cancel HTTP. Keep CLI authority on a distinct number so the stacked GAP-003A client does not collide. --- docs/TRACEABILITY.md | 2 +- ...ack-cli.md => 0030-scientific-acceptance-loopback-cli.md} | 5 +++-- docs/adr/README.md | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) rename docs/adr/{0029-scientific-acceptance-loopback-cli.md => 0030-scientific-acceptance-loopback-cli.md} (95%) diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 8d138bc6d..3d4fb3e49 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -55,7 +55,7 @@ The full APA 7th standards/literature register remains `docs/research/standards- | versioned service/API contracts and exports | PRD; API contract; ADR 0011/0013 | `tepp_api` analysis-run/export/JSON-LD/GraphML contracts on protected main (PR #21); request-bound terminal result active in PR #157; HTTP service remains accepted-target; the `orchestrator_live` loopback interpretation listener is on this PR | partial | | loopback analysis-run scientific-acceptance GET | ADR 0027; API contract; RFC 9110; FIPS 180-4 | `tepp_api` `GET /v1/analysis-runs/{run_id}` on `AnalysisRunLiveService` (#359): accepted/running stay metric-free; `tepp.scientific_acceptance.v1` only on succeeded `scientific_acceptance_v1`; not implemented-main | active-PR | | loopback analysis-run scientific-acceptance lifecycle POST | ADR 0028; API contract; RFC 9110; FIPS 180-4 | `tepp_api` `POST /v1/analysis-runs/{run_id}/running` and `/terminal` on `AnalysisRunLiveService` (#360): production status-update path; accepted/running stay metric-free; `tepp.scientific_acceptance.v1` only after succeeded `scientific_acceptance_v1`; not implemented-main | active-PR | -| loopback analysis-run scientific-acceptance CLI | ADR 0029; API contract; RFC 9110; FIPS 180-4 | `tepp_api` `tepp-analysis-run` CLI (this PR): operator-visible create/running/terminal/status client; accepted/running stay metric-free; `tepp.scientific_acceptance.v1` only on succeeded `scientific_acceptance_v1`; not implemented-main | active-PR | +| loopback analysis-run scientific-acceptance CLI | ADR 0030; API contract; RFC 9110; FIPS 180-4 | `tepp_api` `tepp-analysis-run` CLI (this PR): operator-visible create/running/terminal/status client; accepted/running stay metric-free; `tepp.scientific_acceptance.v1` only on succeeded `scientific_acceptance_v1`; not implemented-main | 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/0029-scientific-acceptance-loopback-cli.md b/docs/adr/0030-scientific-acceptance-loopback-cli.md similarity index 95% rename from docs/adr/0029-scientific-acceptance-loopback-cli.md rename to docs/adr/0030-scientific-acceptance-loopback-cli.md index 6b84e2e0b..bf717d16c 100644 --- a/docs/adr/0029-scientific-acceptance-loopback-cli.md +++ b/docs/adr/0030-scientific-acceptance-loopback-cli.md @@ -1,9 +1,9 @@ -# ADR 0029 — Scientific-acceptance loopback CLI +# ADR 0030 — Scientific-acceptance loopback CLI **Decision status:** Accepted **Implementation maturity:** active-PR **Date:** 2026-08-31 -**Supersedes:** None; complements ADR 0027 and ADR 0028 for the operator-visible client. Does not supersede ADR 0014 claim-promotion authority and does not reuse ADR 0026, ADR 0027, or ADR 0028. +**Supersedes:** None; complements ADR 0027 and ADR 0028 for the operator-visible client. Does not supersede ADR 0014 claim-promotion authority and does not reuse ADR 0026, ADR 0027, ADR 0028, or ADR 0029 (loopback cancel HTTP on live #361). ## Context @@ -70,6 +70,7 @@ Rollback removes the CLI binary and client module; GET status and POST lifecycle ## Related authority +- ADR 0029 owns the loopback cancel HTTP path (live #361). - ADR 0028 owns the HTTP write path. - ADR 0027 owns the GET status read. - ADR 0018 owns consumer-scoped ingress and metric-free `202 Accepted`. diff --git a/docs/adr/README.md b/docs/adr/README.md index ad280475b..524c1eceb 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -32,7 +32,7 @@ Read [`ADR_POLICY.md`](ADR_POLICY.md) first. **Decision status and implementatio | [0025](0025-macos-native-rust-mlx-metal-boundary.md) | macOS-native Rust-owned MLX Metal execution | Accepted | accepted-target | Compose authenticates to a native host service; Linux never claims Metal, and actual backend/parity receipts fail closed. | | [0027](0027-scientific-acceptance-http-status.md) | Scientific-acceptance loopback HTTP status path | Accepted | active-PR | GET `/v1/analysis-runs/{run_id}` stays metric-free on accepted/running; `tepp.scientific_acceptance.v1` only on succeeded `scientific_acceptance_v1`. | | [0028](0028-scientific-acceptance-http-lifecycle.md) | Scientific-acceptance loopback HTTP lifecycle POST | Accepted | active-PR | POST `/running` and `/terminal` are the production status-update path; GET remains ADR 0027. Persistence remains GAP-003B. | -| [0029](0029-scientific-acceptance-loopback-cli.md) | Scientific-acceptance loopback CLI | Accepted | active-PR | `tepp-analysis-run` drives create/running/terminal/status on loopback; metric-free receipts; `tepp.scientific_acceptance.v1` only on succeeded `scientific_acceptance_v1`. | +| [0030](0030-scientific-acceptance-loopback-cli.md) | Scientific-acceptance loopback CLI | Accepted | active-PR | `tepp-analysis-run` drives create/running/terminal/status on loopback; metric-free receipts; `tepp.scientific_acceptance.v1` only on succeeded `scientific_acceptance_v1`. ADR 0029 is the cancel HTTP path on live #361. | | [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. |