diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e6dafbcc2..0c56c70ad 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -69,7 +69,7 @@ boundaries above remain the target modular MSA architecture. | `corpus_split` | cutoff-safe, relation-aware partitioning | | `tepp_simulation` | known-truth temporal/event data generation | | `validation_core` | RMSE, bias, coverage, graph, Monte Carlo, and exact-head claim-promotion metrics | -| `tepp_api` | versioned DTO, schema, terminal-result, and export contracts | +| `tepp_api` | versioned DTO, schema, terminal-result, export contracts, and published `tepp-retry-lineage` loopback CLI | | `analysis_engine` | bounded cutoff-safe temporal evidence readiness execution and digest-bound terminal artifacts | | `episode_membership` | event-time episode membership containment gate | | `prompt_source` | prompt boilerplate is not unique latent content and not stopword deletion | diff --git a/CHANGELOG.d/analysis-run-retry-lineage-cli.md b/CHANGELOG.d/analysis-run-retry-lineage-cli.md new file mode 100644 index 000000000..0041b4111 --- /dev/null +++ b/CHANGELOG.d/analysis-run-retry-lineage-cli.md @@ -0,0 +1,3 @@ +### Added + +- `tepp_api` GAP-003A retry-lineage CLI slice (ADR 0048, active-PR, not implemented-main): naruon and `LineageWeave` mint credential-free `GET /v1/analysis-runs/{run_id}/retries` through the published `tepp-retry-lineage` CLI onto spawned `tepp-loopback` TCP. Inspect stays metric-free. Persistence remains GAP-003B. diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b15763e4..a22e998d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,7 @@ All notable changes to TEPP are documented here. The format follows Keep a Chang ## [Unreleased] - Loopback retry-lineage consumer parity: LineageWeave GET-retries exchange, Naruon compatibility-listener inspect (empty `retries` on accepted creates), and `tepp-loopback` TCP create-cancel-retry-inspect proof (ADR 0045). +- Loopback retry-lineage CLI: published `tepp-retry-lineage` mints typed naruon/`LineageWeave` `GET /v1/analysis-runs/{run_id}/retries` onto spawned `tepp-loopback` TCP (ADR 0048). Inspect stays metric-free. Persistence remains GAP-003B. - `tepp_api` serves `GET /v1/analysis-runs` on the shared loopback listener (ADR 0031). Operators enumerate accepted, running, cancelled, and terminal runs as metric-free collection rows. Collection bodies refuse RMSE/bias/coverage/SE-gate/scientific-acceptance/`terminal_result` keys. GET-by-id and running/terminal POST remain later GAP-003A slices; this is not an ADR 0014 claim. diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 50585bac6..f13b70a6d 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -19,6 +19,7 @@ TEPP's approved PRD v0.4 and implementation plan are the primary product baselin | Analysis-run stored-request HTTP doctoring | [`docs/research/analysis-run-stored-request-http.md`](docs/research/analysis-run-stored-request-http.md) | | Analysis-run retry-lineage HTTP doctoring | [`docs/research/analysis-run-retry-lineage-http.md`](docs/research/analysis-run-retry-lineage-http.md) | | Analysis-run retry-lineage consumer-parity doctoring | [`docs/research/analysis-run-retry-lineage-consumer-parity.md`](docs/research/analysis-run-retry-lineage-consumer-parity.md) | +| Analysis-run retry-lineage CLI doctoring | [`docs/research/analysis-run-retry-lineage-cli.md`](docs/research/analysis-run-retry-lineage-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..9b69e27fa 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-retry-lineage" +path = "src/bin/tepp_retry_lineage.rs" +test = false +bench = false + [lints] workspace = true diff --git a/crates/tepp_api/src/analysis_run_retry_lineage_cli.rs b/crates/tepp_api/src/analysis_run_retry_lineage_cli.rs new file mode 100644 index 000000000..0b3ea81bb --- /dev/null +++ b/crates/tepp_api/src/analysis_run_retry_lineage_cli.rs @@ -0,0 +1,429 @@ +//! Operator loopback CLI for analysis-run retry-lineage GET. +//! +//! GAP-003A retry-lineage CLI slice: operators run `tepp-retry-lineage retries` +//! to mint a typed naruon/`LineageWeave` retry-lineage GET onto spawned +//! `tepp-loopback` TCP. Stdout is a metric-free `200 OK` inspect +//! (`"retries": []` when the parent was never retried). +//! `tepp.scientific_acceptance.v1` never appears. Persistence remains +//! GAP-003B. This module does not add GET to `NaruonLiveService` beyond the +//! Naruon-only compatibility inspect already owned by ADR 0045. + +use std::fmt::Write as _; +use std::io::{Read, Write}; +use std::net::{SocketAddr, TcpStream}; + +use crate::lineageweave_http::consumer_is_supported; +use crate::live_http::map_io_error; +use crate::naruon_http::header_is_credential; +use crate::wire::require_nonempty; +use crate::{ + ANALYSIS_RUN_RETRY_LINEAGE_ID_MAX_LEN, AnalysisRunLiveService, AnalysisRunRetryLineage, + ApiError, LINEAGEWEAVE_CONSUMER_CODE, NARUON_CONSUMER_CODE, NARUON_LIVE_IO_TIMEOUT, + NaruonHttpExchange, NaruonLiveResponse, lineageweave_analysis_run_retry_lineage_exchange, + naruon_analysis_run_retry_lineage_exchange, refuse_metrics_on_retry_lineage_payload, +}; + +const SCIENTIFIC_ACCEPTANCE_SCHEMA: &str = "tepp.scientific_acceptance.v1"; + +/// Supported operator verbs for the loopback retry-lineage CLI. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum AnalysisRunRetryLineageCliVerb { + /// `GET /v1/analysis-runs/{run_id}/retries`. + Retries, +} + +impl AnalysisRunRetryLineageCliVerb { + /// Parse one exact lowercase verb token. + /// + /// # Errors + /// + /// Returns [`ApiError::InvalidWirePayload`] for an unknown token. + pub fn parse(token: &str) -> Result { + match token { + "retries" => Ok(Self::Retries), + _ => Err(ApiError::InvalidWirePayload), + } + } + + /// Return the canonical lowercase verb token. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Retries => "retries", + } + } +} + +/// One operator CLI invocation against a loopback retry-lineage GET listener. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AnalysisRunRetryLineageCliInvocation { + /// CLI verb to execute. + pub verb: AnalysisRunRetryLineageCliVerb, + /// Loopback `host:port` of `tepp-loopback`. + pub host: String, + /// Published HTTPS origin used to mint the typed retry-lineage exchange. + pub origin: String, + /// Published modular consumer (`naruon` or `lineageweave`). + pub consumer: String, + /// Opaque server-assigned parent run identity to inspect. + pub run_id: String, + /// JSON body. Retry-lineage GET requires empty. + pub body: String, +} + +impl AnalysisRunRetryLineageCliInvocation { + /// Parse argv plus stdin body into a validated loopback retry-lineage invocation. + /// + /// # Errors + /// + /// Returns a fail-closed error for unknown verbs, missing required flags, a + /// non-loopback host, a non-`https` origin, an unpublished consumer, + /// credential-shaped flags, hostile 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 = AnalysisRunRetryLineageCliVerb::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, nonempty-body, or metric-bearing fields. + pub fn validate(&self) -> Result<(), ApiError> { + require_loopback_host(&self.host)?; + require_nonempty(&self.origin)?; + if !self.origin.starts_with("https://") { + return Err(ApiError::InvalidWirePayload); + } + require_nonempty(&self.consumer)?; + if !consumer_is_supported(&self.consumer) { + return Err(ApiError::InvalidWirePayload); + } + require_nonempty(&self.run_id)?; + if self.run_id.len() > ANALYSIS_RUN_RETRY_LINEAGE_ID_MAX_LEN { + return Err(ApiError::LimitExceeded); + } + if !self.body.is_empty() { + return Err(ApiError::InvalidWirePayload); + } + refuse_scientific_acceptance_schema(&self.body)?; + refuse_metrics_on_retry_lineage_payload(&self.body)?; + Ok(()) + } +} + +struct ParsedFlags { + host: Option, + origin: Option, + consumer: Option, + run_id: Option, +} + +fn parse_flags(rest: &[String]) -> Result { + let mut flags = ParsedFlags { + host: None, + origin: 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, + "origin" => &mut flags.origin, + "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: AnalysisRunRetryLineageCliVerb, + flags: ParsedFlags, + body: String, +) -> Result { + let invocation = AnalysisRunRetryLineageCliInvocation { + verb, + host: flags.host.ok_or(ApiError::InvalidWirePayload)?, + origin: flags.origin.ok_or(ApiError::InvalidWirePayload)?, + consumer: flags + .consumer + .unwrap_or_else(|| NARUON_CONSUMER_CODE.to_owned()), + 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) + } +} + +fn retry_lineage_exchange( + invocation: &AnalysisRunRetryLineageCliInvocation, +) -> Result { + if invocation.consumer == LINEAGEWEAVE_CONSUMER_CODE { + lineageweave_analysis_run_retry_lineage_exchange(&invocation.origin, &invocation.run_id) + } else if invocation.consumer == NARUON_CONSUMER_CODE { + naruon_analysis_run_retry_lineage_exchange(&invocation.origin, &invocation.run_id) + } else { + Err(ApiError::InvalidWirePayload) + } +} + +/// Render a typed retry-lineage exchange as HTTP/1.1 for a bound loopback listener. +/// +/// The exchange keeps its HTTPS origin contract. Only the HTTP/1.1 `Host` is +/// the loopback bind address. Public bind hosts fail closed. +/// +/// # Errors +/// +/// Returns [`ApiError::AuthorizationDenied`] for a non-loopback host or a +/// credential-bearing header, and [`ApiError::InvalidWirePayload`] when the +/// exchange is not a GET `/retries`. +pub fn loopback_http1_from_retry_lineage_exchange( + exchange: &NaruonHttpExchange, + loopback_host: &str, +) -> Result { + let _addr = require_loopback_host(loopback_host)?; + let host = loopback_host.trim(); + if exchange.method != "GET" { + return Err(ApiError::InvalidWirePayload); + } + let rest = exchange + .target_url + .strip_prefix("https://") + .ok_or(ApiError::InvalidWirePayload)?; + let path = rest + .find('/') + .map(|index| &rest[index..]) + .ok_or(ApiError::InvalidWirePayload)?; + if path.rsplit('/').next() != Some("retries") { + return Err(ApiError::InvalidWirePayload); + } + for (name, _) in &exchange.headers { + if header_is_credential(name) { + return Err(ApiError::AuthorizationDenied); + } + } + let mut request = String::new(); + write!( + request, + "{} {path} HTTP/1.1\r\nHost: {host}\r\n", + exchange.method + ) + .map_err(|_| ApiError::InvalidWirePayload)?; + for (name, value) in &exchange.headers { + if name.eq_ignore_ascii_case("host") || name.eq_ignore_ascii_case("content-length") { + continue; + } + write!(request, "{name}: {value}\r\n").map_err(|_| ApiError::InvalidWirePayload)?; + } + write!( + request, + "content-length: {}\r\n\r\n{}", + exchange.body.len(), + exchange.body + ) + .map_err(|_| ApiError::InvalidWirePayload)?; + Ok(request) +} + +/// Compose one HTTP/1.1 retry-lineage GET from the typed consumer exchange. +/// +/// # Errors +/// +/// Returns the same fail-closed errors as +/// [`AnalysisRunRetryLineageCliInvocation::validate`]. +pub fn compose_analysis_run_retry_lineage_cli_http( + invocation: &AnalysisRunRetryLineageCliInvocation, +) -> Result { + invocation.validate()?; + let exchange = retry_lineage_exchange(invocation)?; + loopback_http1_from_retry_lineage_exchange(&exchange, &invocation.host) +} + +/// Dispatch one retry-lineage CLI invocation against an in-process loopback service. +/// +/// # Errors +/// +/// Returns fail-closed validation errors before the HTTP handler runs. +pub fn dispatch_analysis_run_retry_lineage_cli( + service: &mut AnalysisRunLiveService, + invocation: &AnalysisRunRetryLineageCliInvocation, +) -> Result { + let request = compose_analysis_run_retry_lineage_cli_http(invocation)?; + Ok(service.handle_http_request(&request)) +} + +/// Execute one retry-lineage CLI invocation over loopback TCP against `tepp-loopback`. +/// +/// # Errors +/// +/// Returns fail-closed validation, transport, or response-framing errors. +pub fn execute_analysis_run_retry_lineage_cli( + invocation: &AnalysisRunRetryLineageCliInvocation, +) -> Result { + let addr = require_loopback_host(&invocation.host)?; + let request = compose_analysis_run_retry_lineage_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 retry-lineage inspect never prints scientific acceptance. +/// +/// # Errors +/// +/// Returns [`ApiError::InvalidWirePayload`] when a receipt carries metric keys, +/// `tepp.scientific_acceptance.v1`, or a success body that is not a metric-free +/// retry-lineage inspect for the requested `run_id`. +pub fn render_analysis_run_retry_lineage_cli_stdout( + invocation: &AnalysisRunRetryLineageCliInvocation, + response: &NaruonLiveResponse, +) -> Result { + invocation.validate()?; + if response.body.is_empty() { + return Err(ApiError::InvalidWirePayload); + } + refuse_scientific_acceptance_schema(&response.body)?; + refuse_metrics_on_retry_lineage_payload(&response.body)?; + if !(200..300).contains(&response.status_code) { + return Ok(response.body.clone()); + } + if response.status_code != 200 { + return Err(ApiError::InvalidWirePayload); + } + let inspected = AnalysisRunRetryLineage::from_json(&response.body)?; + if inspected.run_id != invocation.run_id { + return Err(ApiError::InvalidWirePayload); + } + inspected.to_json() +} + +fn refuse_scientific_acceptance_schema(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 = match code { + 200 => "OK", + 202 => "Accepted", + 400 => "Bad Request", + 403 => "Forbidden", + 413 => "Payload Too Large", + 422 => "Unprocessable Entity", + _ => return Err(ApiError::InvalidWirePayload), + }; + 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(), + }) +} + +/// Read stdin leftover bytes on a non-terminal; retry-lineage GET requires empty. +/// +/// # Errors +/// +/// Returns [`ApiError::InvalidWirePayload`] when stdin cannot be read. +pub fn read_analysis_run_retry_lineage_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) + } +} diff --git a/crates/tepp_api/src/bin/tepp_retry_lineage.rs b/crates/tepp_api/src/bin/tepp_retry_lineage.rs new file mode 100644 index 000000000..3a79853a7 --- /dev/null +++ b/crates/tepp_api/src/bin/tepp_retry_lineage.rs @@ -0,0 +1,30 @@ +//! Operator CLI for loopback analysis-run retry-lineage GET. + +use std::io::{self, IsTerminal}; +use std::process::ExitCode; + +use tepp_api::{ + AnalysisRunRetryLineageCliInvocation, ApiError, execute_analysis_run_retry_lineage_cli, + read_analysis_run_retry_lineage_cli_stdin, render_analysis_run_retry_lineage_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 body = read_analysis_run_retry_lineage_cli_stdin(io::stdin().is_terminal(), io::stdin())?; + let invocation = AnalysisRunRetryLineageCliInvocation::from_args(&args, body)?; + let response = execute_analysis_run_retry_lineage_cli(&invocation)?; + let stdout = render_analysis_run_retry_lineage_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 f905a280f..93b4d7d90 100644 --- a/crates/tepp_api/src/lib.rs +++ b/crates/tepp_api/src/lib.rs @@ -9,7 +9,9 @@ //! may also request a cutoff-safe project-history projection from explicit //! source evidence. Naruon owns the current purpose-bound export adapter. //! Loopback listeners prove the HTTP boundary without claiming production TLS, -//! causality, or completed psychometric model results. +//! causality, or completed psychometric model results. The published +//! `tepp-retry-lineage` CLI mints typed naruon/`LineageWeave` retry-lineage GET +//! exchanges onto spawned `tepp-loopback` TCP. mod analysis_result; mod analysis_run; @@ -17,6 +19,7 @@ mod analysis_run_cancel_http; mod analysis_run_collection_http; mod analysis_run_live; mod analysis_run_retry_http; +mod analysis_run_retry_lineage_cli; mod analysis_run_retry_lineage_http; mod analysis_run_status_http; mod analysis_run_stored_request_http; @@ -118,6 +121,22 @@ pub use analysis_run_retry_http::AnalysisRunRetryRequest; pub use analysis_run_retry_http::naruon_analysis_run_retry_exchange; /// Refuse scientific-metric keys on a retry payload. pub use analysis_run_retry_http::refuse_metrics_on_retry_payload; +/// Loopback retry-lineage CLI invocation. +pub use analysis_run_retry_lineage_cli::AnalysisRunRetryLineageCliInvocation; +/// Loopback retry-lineage CLI verb. +pub use analysis_run_retry_lineage_cli::AnalysisRunRetryLineageCliVerb; +/// Compose HTTP/1.1 retry-lineage GET from a CLI invocation. +pub use analysis_run_retry_lineage_cli::compose_analysis_run_retry_lineage_cli_http; +/// Dispatch a retry-lineage CLI invocation against an in-process listener. +pub use analysis_run_retry_lineage_cli::dispatch_analysis_run_retry_lineage_cli; +/// Execute a retry-lineage CLI invocation over loopback TCP. +pub use analysis_run_retry_lineage_cli::execute_analysis_run_retry_lineage_cli; +/// Render a typed retry-lineage exchange onto a loopback HTTP/1.1 request. +pub use analysis_run_retry_lineage_cli::loopback_http1_from_retry_lineage_exchange; +/// Read retry-lineage CLI stdin; GET requires empty. +pub use analysis_run_retry_lineage_cli::read_analysis_run_retry_lineage_cli_stdin; +/// Filter retry-lineage CLI stdout so inspect stays metric-free. +pub use analysis_run_retry_lineage_cli::render_analysis_run_retry_lineage_cli_stdout; /// Analysis-run retry-lineage contract version constant. pub use analysis_run_retry_lineage_http::ANALYSIS_RUN_RETRY_LINEAGE_CONTRACT_VERSION; /// Maximum opaque run identity length on the retry-lineage path. diff --git a/crates/tepp_api/tests/analysis_run_retry_lineage_cli_contract.rs b/crates/tepp_api/tests/analysis_run_retry_lineage_cli_contract.rs new file mode 100644 index 000000000..41e2bac2e --- /dev/null +++ b/crates/tepp_api/tests/analysis_run_retry_lineage_cli_contract.rs @@ -0,0 +1,371 @@ +//! GAP-003A naruon/LineageWeave analysis-run retry-lineage CLI. + +use tepp_api::{ + ANALYSIS_RUN_CONTRACT_VERSION, AnalysisRunAccepted, AnalysisRunLiveService, AnalysisRunRequest, + AnalysisRunRetryLineage, AnalysisRunRetryLineageCliInvocation, AnalysisRunRetryLineageCliVerb, + ApiError, LINEAGEWEAVE_CONSUMER_CODE, NARUON_ANALYSIS_RUN_PATH, NARUON_CONSUMER_CODE, + NaruonLiveResponse, compose_analysis_run_retry_lineage_cli_http, + dispatch_analysis_run_retry_lineage_cli, execute_analysis_run_retry_lineage_cli, + read_analysis_run_retry_lineage_cli_stdin, render_analysis_run_retry_lineage_cli_stdout, +}; + +const ORIGIN: &str = "https://tepp.example.test"; +const SCHEMA: &str = "tepp.scientific_acceptance.v1"; + +fn request(idempotency_key: &str) -> AnalysisRunRequest { + AnalysisRunRequest { + contract_version: ANALYSIS_RUN_CONTRACT_VERSION, + idempotency_key: idempotency_key.into(), + tenant_workspace_id: "cli-retries-tenant".into(), + snapshot_id: "cli-retries-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 {NARUON_ANALYSIS_RUN_PATH} 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 cancel_http(run_id: &str, consumer: &str, host: &str, idempotency_key: &str) -> String { + let body = format!( + r#"{{"contract_version":1,"run_id":"{run_id}","idempotency_key":"{idempotency_key}"}}"# + ); + format!( + "POST {NARUON_ANALYSIS_RUN_PATH}/{run_id}/cancel HTTP/1.1\r\nHost: {host}\r\ncontent-type: application/json\r\ntepp-consumer: {consumer}\r\ntepp-contract-version: 1\r\nidempotency-key: {idempotency_key}\r\ncontent-length: {}\r\n\r\n{body}", + body.len() + ) +} + +fn retry_http(run_id: &str, consumer: &str, host: &str, idempotency_key: &str) -> String { + let body = format!( + r#"{{"contract_version":1,"run_id":"{run_id}","idempotency_key":"{idempotency_key}"}}"# + ); + format!( + "POST {NARUON_ANALYSIS_RUN_PATH}/{run_id}/retry HTTP/1.1\r\nHost: {host}\r\ncontent-type: application/json\r\ntepp-consumer: {consumer}\r\ntepp-contract-version: 1\r\nidempotency-key: {idempotency_key}\r\ncontent-length: {}\r\n\r\n{body}", + body.len() + ) +} + +fn retries_args<'a>(host: &'a str, run_id: &'a str, consumer: &'a str) -> [&'a str; 9] { + [ + "retries", + "--host", + host, + "--origin", + ORIGIN, + "--consumer", + consumer, + "--run-id", + run_id, + ] +} + +fn accept_cancel_retry( + service: &mut AnalysisRunLiveService, + idempotency_key: &str, + child_key: &str, + consumer: &str, +) -> (AnalysisRunAccepted, AnalysisRunAccepted) { + let created = service.handle_http_request(&create_http( + &request(idempotency_key), + consumer, + "127.0.0.1:18081", + )); + assert_eq!(created.status_code, 202, "{}", created.body); + let parent = AnalysisRunAccepted::from_json(&created.body).expect("accepted"); + let cancelled = service.handle_http_request(&cancel_http( + &parent.run_id, + consumer, + "127.0.0.1:18081", + idempotency_key, + )); + assert_eq!(cancelled.status_code, 200, "{}", cancelled.body); + let retried = service.handle_http_request(&retry_http( + &parent.run_id, + consumer, + "127.0.0.1:18081", + child_key, + )); + assert_eq!(retried.status_code, 202, "{}", retried.body); + let child_accepted = AnalysisRunAccepted::from_json(&retried.body).expect("child"); + assert_ne!(child_accepted.run_id, parent.run_id); + (parent, child_accepted) +} + +#[test] +fn verbs_and_from_args_fail_closed() { + assert_eq!( + AnalysisRunRetryLineageCliVerb::parse("retries").expect("retries"), + AnalysisRunRetryLineageCliVerb::Retries + ); + assert_eq!(AnalysisRunRetryLineageCliVerb::Retries.as_str(), "retries"); + assert_eq!( + AnalysisRunRetryLineageCliVerb::parse("parent"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + AnalysisRunRetryLineageCliInvocation::from_args(Vec::::new(), "").unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + AnalysisRunRetryLineageCliInvocation::from_args( + retries_args("8.8.8.8:80", "tepp-run-1", NARUON_CONSUMER_CODE), + "", + ) + .unwrap_err(), + ApiError::AuthorizationDenied + ); + assert_eq!( + AnalysisRunRetryLineageCliInvocation::from_args( + [ + "retries", + "--host", + "127.0.0.1:18081", + "--origin", + "http://tepp.example.test", + "--run-id", + "tepp-run-1", + ], + "", + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + AnalysisRunRetryLineageCliInvocation::from_args( + [ + "retries", + "--host", + "localhost:18081", + "--origin", + ORIGIN, + "--run-id", + "tepp-run-1", + ], + "", + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + AnalysisRunRetryLineageCliInvocation::from_args( + [ + "retries", + "--host", + "127.0.0.1:18081", + "--origin", + ORIGIN, + "--authorization", + "secret", + "--run-id", + "tepp-run-1", + ], + "", + ) + .unwrap_err(), + ApiError::AuthorizationDenied + ); + assert_eq!( + AnalysisRunRetryLineageCliInvocation::from_args( + retries_args("127.0.0.1:18081", "tepp-run-1", NARUON_CONSUMER_CODE), + "{}", + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); +} + +#[test] +fn compose_is_typed_https_get_retries_without_credentials() { + let invocation = AnalysisRunRetryLineageCliInvocation::from_args( + retries_args("127.0.0.1:18081", "tepp-run-1", NARUON_CONSUMER_CODE), + "", + ) + .expect("invocation"); + let http = compose_analysis_run_retry_lineage_cli_http(&invocation).expect("http"); + assert!(http.starts_with("GET /v1/analysis-runs/tepp-run-1/retries HTTP/1.1")); + assert!(http.contains("tepp-consumer: naruon")); + assert!(http.contains("content-length: 0")); + assert!(!http.to_ascii_lowercase().contains("authorization")); + assert!(!http.contains("rmse")); + assert!(!http.contains(SCHEMA)); +} + +#[test] +fn naruon_and_lineageweave_cli_inspect_retry_children() { + let mut service = AnalysisRunLiveService::new(); + let (parent, child_accepted) = accept_cancel_retry( + &mut service, + "cli-retries-naruon", + "cli-retries-naruon-child", + NARUON_CONSUMER_CODE, + ); + let invocation = AnalysisRunRetryLineageCliInvocation::from_args( + retries_args( + "127.0.0.1:18081", + parent.run_id.as_str(), + NARUON_CONSUMER_CODE, + ), + "", + ) + .expect("naruon"); + let inspected = + dispatch_analysis_run_retry_lineage_cli(&mut service, &invocation).expect("get"); + assert_eq!(inspected.status_code, 200, "{}", inspected.body); + let stdout = + render_analysis_run_retry_lineage_cli_stdout(&invocation, &inspected).expect("out"); + let payload = AnalysisRunRetryLineage::from_json(&stdout).expect("lineage"); + assert_eq!(payload.run_id, parent.run_id); + assert_eq!(payload.retries.len(), 1); + assert_eq!(payload.retries[0].run_id, child_accepted.run_id); + assert!(!stdout.contains("rmse")); + assert!(!stdout.contains(SCHEMA)); + assert!(!stdout.contains("tenant_workspace_id")); + assert!(!stdout.contains("retried_from")); + + let original = service.handle_http_request(&create_http( + &request("cli-retries-original"), + NARUON_CONSUMER_CODE, + "127.0.0.1:18081", + )); + let original_accepted = AnalysisRunAccepted::from_json(&original.body).expect("original"); + let original_inv = AnalysisRunRetryLineageCliInvocation::from_args( + retries_args( + "127.0.0.1:18081", + original_accepted.run_id.as_str(), + NARUON_CONSUMER_CODE, + ), + "", + ) + .expect("original inv"); + let original_got = + dispatch_analysis_run_retry_lineage_cli(&mut service, &original_inv).expect("original get"); + assert_eq!(original_got.status_code, 200, "{}", original_got.body); + let original_stdout = + render_analysis_run_retry_lineage_cli_stdout(&original_inv, &original_got).expect("empty"); + let original_payload = + AnalysisRunRetryLineage::from_json(&original_stdout).expect("empty retries"); + assert!(original_payload.retries.is_empty()); + assert!(original_stdout.contains("\"retries\":[]")); + + let lineage_pair = accept_cancel_retry( + &mut service, + "cli-retries-lineage", + "cli-retries-lineage-child", + LINEAGEWEAVE_CONSUMER_CODE, + ); + let lineage = AnalysisRunRetryLineageCliInvocation::from_args( + retries_args( + "127.0.0.1:18081", + lineage_pair.0.run_id.as_str(), + LINEAGEWEAVE_CONSUMER_CODE, + ), + "", + ) + .expect("lineage"); + let lineage_http = compose_analysis_run_retry_lineage_cli_http(&lineage).expect("http"); + assert!(lineage_http.contains("tepp-consumer: lineageweave")); + assert!(!lineage_http.contains("tepp-consumer: naruon")); + let lineage_got = + dispatch_analysis_run_retry_lineage_cli(&mut service, &lineage).expect("lineage get"); + assert_eq!(lineage_got.status_code, 200, "{}", lineage_got.body); + let lineage_stdout = + render_analysis_run_retry_lineage_cli_stdout(&lineage, &lineage_got).expect("lineage out"); + let lineage_payload = AnalysisRunRetryLineage::from_json(&lineage_stdout).expect("lineage"); + assert_eq!(lineage_payload.retries.len(), 1); + assert_eq!(lineage_payload.retries[0].run_id, lineage_pair.1.run_id); + + let unknown = AnalysisRunRetryLineageCliInvocation::from_args( + retries_args("127.0.0.1:18081", "missing-run", NARUON_CONSUMER_CODE), + "", + ) + .expect("unknown"); + let denied = dispatch_analysis_run_retry_lineage_cli(&mut service, &unknown).expect("denied"); + assert_eq!(denied.status_code, 400, "{}", denied.body); + let denied_stdout = + render_analysis_run_retry_lineage_cli_stdout(&unknown, &denied).expect("err"); + assert!(denied_stdout.contains("invalid_wire_payload")); + assert!(!denied_stdout.contains(SCHEMA)); +} + +#[test] +fn render_refuses_metrics_and_identity_mismatch() { + let invocation = AnalysisRunRetryLineageCliInvocation::from_args( + retries_args("127.0.0.1:18081", "tepp-run-2", NARUON_CONSUMER_CODE), + "", + ) + .expect("invocation"); + assert_eq!( + render_analysis_run_retry_lineage_cli_stdout( + &invocation, + &NaruonLiveResponse { + status_code: 200, + reason_phrase: "OK", + body: String::new(), + } + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + render_analysis_run_retry_lineage_cli_stdout( + &invocation, + &NaruonLiveResponse { + status_code: 200, + reason_phrase: "OK", + body: r#"{"contract_version":1,"run_id":"tepp-run-2","run_state":"accepted","idempotency_key":"idem-parent","retries":[],"rmse":1.0}"#.into(), + } + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + render_analysis_run_retry_lineage_cli_stdout( + &invocation, + &NaruonLiveResponse { + status_code: 200, + reason_phrase: "OK", + body: r#"{"contract_version":1,"run_id":"tepp-run-1","run_state":"accepted","idempotency_key":"idem-parent","retries":[]}"#.into(), + } + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); +} + +#[test] +fn execute_over_tcp_and_stdin_reader() { + let mut service = AnalysisRunLiveService::bind_loopback().expect("bind"); + let addr = service.local_addr().expect("addr").to_string(); + let (parent, _child_accepted) = accept_cancel_retry( + &mut service, + "cli-retries-tcp", + "cli-retries-tcp-child", + NARUON_CONSUMER_CODE, + ); + let handle = std::thread::spawn(move || { + drop(service.serve_one()); + }); + let invocation = AnalysisRunRetryLineageCliInvocation::from_args( + retries_args(addr.as_str(), parent.run_id.as_str(), NARUON_CONSUMER_CODE), + "", + ) + .expect("tcp"); + let response = execute_analysis_run_retry_lineage_cli(&invocation).expect("execute"); + assert_eq!(response.status_code, 200, "{}", response.body); + let payload = AnalysisRunRetryLineage::from_json(&response.body).expect("tcp lineage"); + assert_eq!(payload.retries.len(), 1); + handle.join().expect("join"); + let empty = read_analysis_run_retry_lineage_cli_stdin(true, std::io::empty()).expect("tty"); + assert!(empty.is_empty()); + let piped = + read_analysis_run_retry_lineage_cli_stdin(false, std::io::Cursor::new(b"")).expect("pipe"); + assert!(piped.is_empty()); +} diff --git a/docs/API_CONTRACT.md b/docs/API_CONTRACT.md index 94b229614..e9abf8755 100644 --- a/docs/API_CONTRACT.md +++ b/docs/API_CONTRACT.md @@ -97,7 +97,9 @@ run before retry. `GET /v1/analysis-runs/{run_id}/retries` on the loopback listener returns metric-free direct retry children of that parent so operators can inspect lineage after retry. An empty `retries` array is `200` when the parent was never retried. `lineageweave_analysis_run_retry_lineage_exchange` -builds the same GET for LineageWeave. `NaruonLiveService` serves retry-lineage GET +builds the same GET for LineageWeave. `tepp-retry-lineage retries` is the published +CLI that mints those typed naruon/`LineageWeave` retry-lineage exchanges onto +spawned `tepp-loopback` TCP. `NaruonLiveService` serves retry-lineage GET for Naruon only and returns an empty `retries` array on accepted creates; LineageWeave remains refused on that compatibility listener. GET-by-id remains a later slice on this protected-main lineage. diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 26f005711..fb1944289 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -59,6 +59,7 @@ The full APA 7th standards/literature register remains `docs/research/standards- | 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 retry-lineage GET | ADR 0035; API contract; RFC 9110 | `tepp_api` `GET /v1/analysis-runs/{run_id}/retries` on `AnalysisRunLiveService`: metric-free direct retry children of a listed parent; empty `retries` when never retried; GET-by-id remains a later slice | active-PR | | loopback analysis-run retry-lineage consumer parity | ADR 0045; API contract; RFC 9110 | `tepp_api` LineageWeave retry-lineage exchange, Naruon compatibility-listener inspect (empty `retries` on accepted creates), and `tepp-loopback` TCP create-cancel-retry-inspect; LineageWeave remains refused on `NaruonLiveService` | active-PR | +| loopback analysis-run retry-lineage CLI | ADR 0048; ADR 0035/0045; API contract; RFC 9110 | `tepp_api` `tepp-retry-lineage` CLI (this PR): typed naruon/`LineageWeave` retry-lineage GET exchanges render onto spawned `tepp-loopback` TCP; public bind/`localhost`/non-`https` fail closed; inspect stays metric-free; not implemented-main; Postgres persistence remains GAP-003B | 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/0048-analysis-run-retry-lineage-cli.md b/docs/adr/0048-analysis-run-retry-lineage-cli.md new file mode 100644 index 000000000..7be08b96d --- /dev/null +++ b/docs/adr/0048-analysis-run-retry-lineage-cli.md @@ -0,0 +1,104 @@ +# ADR 0048 — Loopback analysis-run retry-lineage CLI + +**Decision status:** Accepted +**Implementation maturity:** active-PR +**Date:** 2026-08-31 +**Supersedes:** None; complements ADR 0035 (retry-lineage GET) and ADR 0045 (retry-lineage consumer parity). Does not reuse ADR 0030–0047 numbers from other stacks. Does not supersede ADR 0014 claim-promotion authority. + +## Context + +ADR 0035 owns `GET /v1/analysis-runs/{run_id}/retries`. ADR 0045 owns typed +naruon/`LineageWeave` retry-lineage exchanges. Operators still have to hand-roll +HTTP/1.1 to inspect a parent's retry children on spawned `tepp-loopback`. Retry +CLI (#394), retry-parent CLI (#400), stored-request CLI (#395), status CLI +(#392), collection CLI (#371), cancel CLI (#378), lifecycle CLI (#397), and +idempotency-lookup CLI (#401) are different verbs or different stacks. +`tepp_api` owns retry-lineage; the CLI belongs here. + +## Decision + +Publish `tepp-retry-lineage`: + +- `tepp-retry-lineage retries` mints `naruon_analysis_run_retry_lineage_exchange` + or `lineageweave_analysis_run_retry_lineage_exchange` and renders through + `loopback_http1_from_retry_lineage_exchange`. +- `--origin` stays the published HTTPS origin; only `--host` is the loopback + bind address printed by `tepp-loopback`. +- Empty stdin is required; nonempty GET bodies fail closed. +- Success stdout is a metric-free `200 OK` inspect. `"retries": []` when the + parent was never retried. +- Public bind hosts, `localhost`, unpublished consumers, credential-shaped + flags, and non-`https` origins fail closed. +- Persistence remains GAP-003B. +- This slice does not add GET to `NaruonLiveService` beyond the Naruon-only + compatibility inspect already owned by ADR 0045. LineageWeave remains + refused there. + +## Non-goals + +- Production TLS, public bind, or durable status storage. +- Leiden community detection, Driver p.16 restoration, or Figma/export work. +- Promoting an ADR 0014 scientific claim from HTTP success. +- Retry CLI, retry HTTP, retry consumer-parity, retry-lineage GET, retry-lineage + consumer-parity, retry-parent CLI, or idempotency-lookup CLI slices. + +## Alternatives considered + +1. **Keep hand-rolled retry-lineage HTTP in each operator script** — rejected + because GAP-003A is operator-visible and retry/retry-parent already have + CLIs. +2. **Add `retries` to `tepp-retry` on the retry-CLI stack** — rejected because + that stack does not include retry-lineage GET (#379) or consumer parity + (#399). +3. **Open LineageWeave on `NaruonLiveService`** — rejected; that listener is + Naruon-only (ADR 0011/0018). + +## Consequences + +- Operators can inspect a parent's retry children without embedding the library. +- HTTP 200 on inspect is not release evidence. + +## Failure and recovery + +Non-loopback hosts, `localhost`, non-`https` origins, unpublished consumers, +metric keys, unknown artifact fields, empty identities, nonempty GET bodies, +and unknown runs return a fail-closed API error. The in-memory registry is +not durable. + +## Security, privacy, scientific-integrity, and governance impact + +- No credential headers cross the consumer boundary. +- Retry-lineage remains loopback-served, size-bounded, and content-redacting. +- Inspect receipts stay metric-free. + +## Compatibility and migration + +Retry-lineage GET and consumer exchanges are unchanged. Production adapters may +replace loopback while preserving metric-free inspect receipts. + +## Verification + +Falsifiable evidence: + +- naruon retry-lineage CLI is HTTPS GET `/retries` without credentials or RMSE keys; +- LineageWeave retry-lineage CLI changes only `tepp-consumer`; +- public bind, `localhost`, `http://` origins, and nonempty bodies fail closed; +- create then cancel then retry then typed retries CLI stdout is a metric-free + non-empty child list for both consumers, and never-retried parents print + `"retries": []`; +- Clippy `-D warnings`, `tepp_api` tests, rustdoc, and exact-head review remain + required. + +## Rollback and supersession + +Rollback removes the retry-lineage CLI; retry-lineage GET and consumer exchanges +remain valid. A superseding ADR is required to persist inspect, bind a public +address, or treat HTTP success as an ADR 0014 claim. + +## Related authority + +- ADR 0035 owns retry-lineage GET. +- ADR 0045 owns retry-lineage consumer parity. +- ADR 0018 owns consumer-scoped ingress and metric-free receipts. +- ADR 0011 owns standalone/modular HTTP boundaries. +- ADR 0014 owns scientific claim promotion. diff --git a/docs/adr/README.md b/docs/adr/README.md index cfe86cacc..7a75a70b5 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -36,6 +36,7 @@ Read [`ADR_POLICY.md`](ADR_POLICY.md) first. **Decision status and implementatio | [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. | | [0035](0035-analysis-run-retry-lineage-get.md) | Loopback GET analysis-run retry-lineage is metric-free parent/child inspect | Accepted | active-PR | Complements ADR 0018/0031/0032/0034; does not supersede ADR 0014. ADR 0026–0034 live on other GAP-003A PRs. | | [0045](0045-analysis-run-retry-lineage-consumer-parity.md) | LineageWeave and Naruon compatibility-listener retry-lineage GET | Accepted | active-PR | Complements ADR 0035/0018; does not supersede ADR 0014. ADR 0026–0044 live on other PRs. | +| [0048](0048-analysis-run-retry-lineage-cli.md) | Loopback analysis-run retry-lineage CLI | Accepted | active-PR | Published `tepp-retry-lineage` mints typed naruon/`LineageWeave` retry-lineage GET exchanges onto spawned `tepp-loopback` TCP. Persistence remains GAP-003B. | | [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/connectors/naruon-artifact-consumer.md b/docs/connectors/naruon-artifact-consumer.md index 1346aa4c1..84592f989 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 retry-lineage | `tepp_api` `naruon_analysis_run_retry_lineage_exchange` → `GET /v1/analysis-runs/{run_id}/retries` | naruon → TEPP | +| Typed retry-lineage CLI on spawned `tepp-loopback` TCP | `tepp_api` `tepp-retry-lineage` renders the typed GET onto the packaged loopback listener | 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 retry-lineage GET | `tepp_api` `NaruonLiveService` → `GET /v1/analysis-runs/{run_id}/retries` (Naruon only; empty `retries` on accepted creates) | naruon → TEPP | diff --git a/docs/research/analysis-run-retry-lineage-cli.md b/docs/research/analysis-run-retry-lineage-cli.md new file mode 100644 index 000000000..1da06c330 --- /dev/null +++ b/docs/research/analysis-run-retry-lineage-cli.md @@ -0,0 +1,32 @@ +# Analysis-run retry-lineage CLI + +## Scope + +This note doctors the GAP-003A naruon/`LineageWeave` retry-lineage CLI slice: + +1. `tepp-retry-lineage retries` is the published operator CLI for `GET /v1/analysis-runs/{run_id}/retries`; +2. the CLI mints `naruon_analysis_run_retry_lineage_exchange` or + `lineageweave_analysis_run_retry_lineage_exchange` and renders onto spawned `tepp-loopback` TCP; +3. success stdout is a metric-free `200 OK` inspect (`"retries": []` when never retried); +4. public bind hosts, `localhost`, non-`https` origins, unpublished consumers, and nonempty GET bodies fail closed. + +Postgres persistence, restart/recovery, and Compose execution remain GAP-003B. This slice is not implemented-main. It does not duplicate retry-lineage GET (#379), retry-lineage consumer parity (#399), retry-parent CLI (#400), retry CLI (#394), retry HTTP (#369), retry consumer parity (#393), stored-request CLI (#395), idempotency-lookup CLI (#401), or status CLI (#392). It does not add GET to `NaruonLiveService` beyond the Naruon-only compatibility inspect. + +## Authoritative sources + +Fielding, R., Ed., & Reschke, J., Ed. (2014). *Hypertext Transfer Protocol (HTTP/1.1): Semantics and Content* (RFC 9110). RFC Editor. https://www.rfc-editor.org/rfc/rfc9110 + +National Academies of Sciences, Engineering, and Medicine. (2019). *Reproducibility and replicability in science*. The National Academies Press. https://doi.org/10.17226/25303 + +Wasserstein, R. L., & Lazar, N. A. (2016). The ASA statement on *p*-values: Context, process, and purpose. *The American Statistician, 70*(2), 129–133. https://doi.org/10.1080/00031305.2016.1154108 + +## Application + +RFC 9110 requires that a published method be invoked through a documented interface, not an ad-hoc operator wire. The National Academies (2019) require that a computational procedure be runnable from the published interface. Wasserstein and Lazar (2016) refuse to treat a passing threshold as automatic scientific authority, so the CLI emits the same metric-free inspect as the library bind and never treats HTTP success as ADR 0014 promotion. TEPP therefore gives operators a credential-free retry-lineage CLI, refuses caller-supplied artifacts, and keeps RMSE, bias, coverage, and scientific acceptance off the inspect receipt (Fielding & Reschke, 2014; National Academies of Sciences, Engineering, and Medicine, 2019; Wasserstein & Lazar, 2016). Meredith (1993) remains unread (Unpaywall/OpenAlex 2026-08-31T16:00Z: `is_oa: false`, 0 locations). Mislevy (1991, *Psychometrika, 56*, 177–196) remains unread on the same terms (DOI `10.1007/bf02294457`). + +## Verification + +- naruon retry-lineage CLI is HTTPS GET `/retries` without credentials or RMSE keys; +- LineageWeave retry-lineage CLI changes only `tepp-consumer`; +- public bind, `localhost`, `http://` origins, and nonempty bodies fail closed; +- create then cancel then retry then typed retries CLI stdout is a metric-free non-empty child list for both consumers.