diff --git a/CHANGELOG.d/analysis-run-lifecycle-cli.md b/CHANGELOG.d/analysis-run-lifecycle-cli.md new file mode 100644 index 000000000..6ccf981af --- /dev/null +++ b/CHANGELOG.d/analysis-run-lifecycle-cli.md @@ -0,0 +1 @@ +- `tepp_api` GAP-003A lifecycle CLI slice (ADR 0030, active-PR, not implemented-main): naruon and `LineageWeave` mint credential-free `POST /v1/analysis-runs/{run_id}/running` and `/terminal` through the published `tepp-lifecycle` CLI onto spawned `tepp-loopback` TCP. Empty stdin is admitted for `running`; terminal requires typed JSON. Metric-free status is unchanged from ADR 0028/0029. Persistence remains GAP-003B. diff --git a/CHANGELOG.md b/CHANGELOG.md index b9e116cfc..e6427964a 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` publishes `tepp-lifecycle` so operators can POST running and terminal status from naruon or LineageWeave exchanges onto spawned `tepp-loopback` TCP (ADR 0030). Empty stdin is admitted for `running`; terminal requires typed JSON. Metric-free status is unchanged from ADR 0028/0029. Not GET, not retry, not an ADR 0014 claim. + - `tepp_api` adds LineageWeave running/terminal exchange builders, Naruon compatibility-listener lifecycle POST, and a `tepp-loopback` TCP create-then-running proof so both published consumers can record metric-free running status without minting a foreign consumer header. `NaruonLiveService` stays POST-only and still refuses LineageWeave. Reverse transitions, mutating a terminal run, GET, receipt RMSE/bias/coverage/SE-gate keys, an unknown run, and consumer mismatch fail closed. This is the GAP-003A lifecycle consumer-parity slice for issue #166 stacked on #360; it does not duplicate the shared-listener lifecycle POST (#360), GET status (#359), status consumer-parity (#383), cancel (#361/#373), the terminal-result DTO (#358), or the `analysis_engine` library bind (#356); 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. diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index a37274f24..6a34e01f1 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -12,6 +12,7 @@ TEPP's approved PRD v0.4 and implementation plan are the primary product baselin | Modular/API integration contract | [`docs/API_CONTRACT.md`](docs/API_CONTRACT.md) | | naruon modular consumer contract | [`docs/connectors/naruon-artifact-consumer.md`](docs/connectors/naruon-artifact-consumer.md) | | Analysis-run lifecycle consumer-parity doctoring | [`docs/research/analysis-run-lifecycle-consumer-parity.md`](docs/research/analysis-run-lifecycle-consumer-parity.md) | +| Analysis-run lifecycle CLI doctoring | [`docs/research/analysis-run-lifecycle-cli.md`](docs/research/analysis-run-lifecycle-cli.md) | | contextual-orchestrator interpretation port | [`docs/connectors/contextual-orchestrator-interpretation-port.md`](docs/connectors/contextual-orchestrator-interpretation-port.md) | | Orchestrator live HTTP doctoring | [`docs/research/orchestrator-live-http.md`](docs/research/orchestrator-live-http.md) | | UML/runtime/scientific flows | [`docs/UML.md`](docs/UML.md) | @@ -111,6 +112,7 @@ TEPP's approved PRD v0.4 and implementation plan are the primary product baselin | Modular/API integration contract | [`docs/API_CONTRACT.md`](docs/API_CONTRACT.md) | | naruon modular consumer contract | [`docs/connectors/naruon-artifact-consumer.md`](docs/connectors/naruon-artifact-consumer.md) | | Analysis-run lifecycle consumer-parity doctoring | [`docs/research/analysis-run-lifecycle-consumer-parity.md`](docs/research/analysis-run-lifecycle-consumer-parity.md) | +| Analysis-run lifecycle CLI doctoring | [`docs/research/analysis-run-lifecycle-cli.md`](docs/research/analysis-run-lifecycle-cli.md) | | contextual-orchestrator interpretation port | [`docs/connectors/contextual-orchestrator-interpretation-port.md`](docs/connectors/contextual-orchestrator-interpretation-port.md) | | UML/runtime/scientific flows | [`docs/UML.md`](docs/UML.md) | | Logical/physical ERD | [`docs/ERD.md`](docs/ERD.md) | diff --git a/crates/tepp_api/Cargo.toml b/crates/tepp_api/Cargo.toml index 053199f39..fdca77b52 100644 --- a/crates/tepp_api/Cargo.toml +++ b/crates/tepp_api/Cargo.toml @@ -29,6 +29,12 @@ path = "src/bin/tepp_loopback.rs" test = false bench = false +[[bin]] +name = "tepp-lifecycle" +path = "src/bin/tepp_lifecycle.rs" +test = false +bench = false + [lints] workspace = true diff --git a/crates/tepp_api/src/analysis_run_lifecycle_cli.rs b/crates/tepp_api/src/analysis_run_lifecycle_cli.rs new file mode 100644 index 000000000..610326055 --- /dev/null +++ b/crates/tepp_api/src/analysis_run_lifecycle_cli.rs @@ -0,0 +1,496 @@ +//! Operator loopback CLI for analysis-run running and terminal POST. +//! +//! GAP-003A lifecycle CLI slice: operators run `tepp-lifecycle running` or +//! `tepp-lifecycle terminal` to record metric-free status from the typed +//! naruon/`LineageWeave` lifecycle exchange onto spawned `tepp-loopback` TCP. +//! Empty stdin is admitted for `running`; terminal requires typed JSON. +//! `tepp.scientific_acceptance.v1` never appears. Persistence remains GAP-003B. + +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_ID_MAX_LEN, AnalysisRunLifecycleTransition, AnalysisRunLiveService, + AnalysisRunStatus, AnalysisRunStatusState, ApiError, LINEAGEWEAVE_CONSUMER_CODE, + NARUON_CONSUMER_CODE, NARUON_LIVE_IO_TIMEOUT, NaruonHttpExchange, NaruonLiveResponse, + SCIENTIFIC_ACCEPTANCE_HTTP_SCHEMA, lineageweave_analysis_run_running_exchange, + lineageweave_analysis_run_terminal_exchange, naruon_analysis_run_running_exchange, + naruon_analysis_run_terminal_exchange, refuse_metrics_on_receipt, +}; + +/// Supported operator verbs for the loopback lifecycle CLI. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum AnalysisRunLifecycleCliVerb { + /// `POST /v1/analysis-runs/{run_id}/running`. + Running, + /// `POST /v1/analysis-runs/{run_id}/terminal`. + Terminal, +} + +impl AnalysisRunLifecycleCliVerb { + /// Parse one exact lowercase verb token. + /// + /// # Errors + /// + /// Returns [`ApiError::InvalidWirePayload`] for an unknown token. + pub fn parse(token: &str) -> Result { + match token { + "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::Running => "running", + Self::Terminal => "terminal", + } + } +} + +/// One operator CLI invocation against a loopback lifecycle POST listener. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AnalysisRunLifecycleCliInvocation { + /// CLI verb to execute. + pub verb: AnalysisRunLifecycleCliVerb, + /// Loopback `host:port` of `tepp-loopback`. + pub host: String, + /// Published HTTPS origin used to mint the typed lifecycle exchange. + pub origin: String, + /// Published modular consumer (`naruon` or `lineageweave`). + pub consumer: String, + /// Opaque server-assigned run identity. + pub run_id: String, + /// Exact request idempotency key. + pub idempotency_key: String, + /// Optional typed lifecycle JSON. Empty POST is admitted only for running. + pub body: String, +} + +impl AnalysisRunLifecycleCliInvocation { + /// Parse argv plus stdin body into a validated loopback lifecycle 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, metric bodies, empty + /// terminal stdin, or a typed body that does not match the path identity + /// and verb. + 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 = AnalysisRunLifecycleCliVerb::parse(verb_token)?; + let flags = parse_flags(rest)?; + assemble_invocation(verb, flags, body.into()) + } + + /// Reject a non-loopback host, unpublished consumer, or hostile lifecycle body. + /// + /// # Errors + /// + /// Returns [`ApiError::AuthorizationDenied`] for a non-loopback host and + /// [`ApiError::InvalidWirePayload`] or [`ApiError::LimitExceeded`] for + /// empty, unpublished, oversized, metric-bearing, or verb-mismatched 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)?; + require_nonempty(&self.idempotency_key)?; + if self.run_id.len() > ANALYSIS_RUN_ID_MAX_LEN { + return Err(ApiError::LimitExceeded); + } + refuse_scientific_acceptance_schema(&self.body)?; + refuse_metrics_on_receipt(&self.body)?; + if self.body.is_empty() { + return match self.verb { + AnalysisRunLifecycleCliVerb::Running => Ok(()), + AnalysisRunLifecycleCliVerb::Terminal => Err(ApiError::InvalidWirePayload), + }; + } + let transition = AnalysisRunLifecycleTransition::from_json(&self.body)?; + if transition.run_id != self.run_id || transition.idempotency_key != self.idempotency_key { + return Err(ApiError::InvalidWirePayload); + } + match self.verb { + AnalysisRunLifecycleCliVerb::Running => { + if transition.run_state == AnalysisRunStatusState::Running { + Ok(()) + } else { + Err(ApiError::InvalidWirePayload) + } + } + AnalysisRunLifecycleCliVerb::Terminal => match transition.run_state { + AnalysisRunStatusState::Succeeded | AnalysisRunStatusState::Failed => Ok(()), + AnalysisRunStatusState::Accepted | AnalysisRunStatusState::Running => { + Err(ApiError::InvalidWirePayload) + } + }, + } + } +} + +struct ParsedFlags { + host: Option, + origin: Option, + consumer: Option, + run_id: Option, + idempotency_key: Option, +} + +fn parse_flags(rest: &[String]) -> Result { + let mut flags = ParsedFlags { + host: None, + origin: None, + consumer: None, + run_id: None, + idempotency_key: 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, + "idempotency-key" => &mut flags.idempotency_key, + _ => 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: AnalysisRunLifecycleCliVerb, + flags: ParsedFlags, + body: String, +) -> Result { + let invocation = AnalysisRunLifecycleCliInvocation { + 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)?, + idempotency_key: flags.idempotency_key.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 lifecycle_transition( + invocation: &AnalysisRunLifecycleCliInvocation, +) -> Result { + if invocation.body.is_empty() { + AnalysisRunLifecycleTransition::running(&invocation.run_id, &invocation.idempotency_key) + } else { + AnalysisRunLifecycleTransition::from_json(&invocation.body) + } +} + +fn lifecycle_exchange( + invocation: &AnalysisRunLifecycleCliInvocation, +) -> Result { + let transition = lifecycle_transition(invocation)?; + let lineage = invocation.consumer == LINEAGEWEAVE_CONSUMER_CODE; + let naruon = invocation.consumer == NARUON_CONSUMER_CODE; + match (invocation.verb, lineage, naruon) { + (AnalysisRunLifecycleCliVerb::Running, true, false) => { + lineageweave_analysis_run_running_exchange(&invocation.origin, &transition) + } + (AnalysisRunLifecycleCliVerb::Terminal, true, false) => { + lineageweave_analysis_run_terminal_exchange(&invocation.origin, &transition) + } + (AnalysisRunLifecycleCliVerb::Running, false, true) => { + naruon_analysis_run_running_exchange(&invocation.origin, &transition) + } + (AnalysisRunLifecycleCliVerb::Terminal, false, true) => { + naruon_analysis_run_terminal_exchange(&invocation.origin, &transition) + } + _ => Err(ApiError::InvalidWirePayload), + } +} + +/// Render a typed lifecycle 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 POST `/running` or `/terminal`. +pub fn loopback_http1_from_lifecycle_exchange( + exchange: &NaruonHttpExchange, + loopback_host: &str, +) -> Result { + let _addr = require_loopback_host(loopback_host)?; + let host = loopback_host.trim(); + if exchange.method != "POST" { + 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)?; + let suffix = path.rsplit('/').next(); + if suffix != Some("running") && suffix != Some("terminal") { + 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 lifecycle POST from the typed consumer exchange. +/// +/// # Errors +/// +/// Returns the same fail-closed errors as +/// [`AnalysisRunLifecycleCliInvocation::validate`]. +pub fn compose_analysis_run_lifecycle_cli_http( + invocation: &AnalysisRunLifecycleCliInvocation, +) -> Result { + invocation.validate()?; + let exchange = lifecycle_exchange(invocation)?; + loopback_http1_from_lifecycle_exchange(&exchange, &invocation.host) +} + +/// Dispatch one lifecycle CLI invocation against an in-process loopback service. +/// +/// # Errors +/// +/// Returns fail-closed validation errors before the HTTP handler runs. +pub fn dispatch_analysis_run_lifecycle_cli( + service: &mut AnalysisRunLiveService, + invocation: &AnalysisRunLifecycleCliInvocation, +) -> Result { + let request = compose_analysis_run_lifecycle_cli_http(invocation)?; + Ok(service.handle_http_request(&request)) +} + +/// Execute one lifecycle CLI invocation over loopback TCP against `tepp-loopback`. +/// +/// # Errors +/// +/// Returns fail-closed validation, transport, or response-framing errors. +pub fn execute_analysis_run_lifecycle_cli( + invocation: &AnalysisRunLifecycleCliInvocation, +) -> Result { + let addr = require_loopback_host(&invocation.host)?; + let request = compose_analysis_run_lifecycle_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 lifecycle receipts never print 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 +/// `200` status matching the verb. +pub fn render_analysis_run_lifecycle_cli_stdout( + invocation: &AnalysisRunLifecycleCliInvocation, + response: &NaruonLiveResponse, +) -> Result { + invocation.validate()?; + if response.body.is_empty() { + return Err(ApiError::InvalidWirePayload); + } + refuse_scientific_acceptance_schema(&response.body)?; + refuse_metrics_on_receipt(&response.body)?; + if !(200..300).contains(&response.status_code) { + return Ok(response.body.clone()); + } + if response.status_code != 200 { + return Err(ApiError::InvalidWirePayload); + } + let status = AnalysisRunStatus::from_json(&response.body)?; + if status.run_id != invocation.run_id || status.idempotency_key != invocation.idempotency_key { + return Err(ApiError::InvalidWirePayload); + } + match invocation.verb { + AnalysisRunLifecycleCliVerb::Running => { + if status.run_state != AnalysisRunStatusState::Running { + return Err(ApiError::InvalidWirePayload); + } + } + AnalysisRunLifecycleCliVerb::Terminal => match status.run_state { + AnalysisRunStatusState::Succeeded | AnalysisRunStatusState::Failed => {} + AnalysisRunStatusState::Accepted | AnalysisRunStatusState::Running => { + return Err(ApiError::InvalidWirePayload); + } + }, + } + status.to_json() +} + +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 = 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; empty running POST is admitted. +/// +/// # Errors +/// +/// Returns [`ApiError::InvalidWirePayload`] when stdin cannot be read. +pub fn read_analysis_run_lifecycle_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_lifecycle.rs b/crates/tepp_api/src/bin/tepp_lifecycle.rs new file mode 100644 index 000000000..851fa8e87 --- /dev/null +++ b/crates/tepp_api/src/bin/tepp_lifecycle.rs @@ -0,0 +1,30 @@ +//! Operator CLI for loopback analysis-run running and terminal POST. + +use std::io::{self, IsTerminal}; +use std::process::ExitCode; + +use tepp_api::{ + AnalysisRunLifecycleCliInvocation, ApiError, execute_analysis_run_lifecycle_cli, + read_analysis_run_lifecycle_cli_stdin, render_analysis_run_lifecycle_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_lifecycle_cli_stdin(io::stdin().is_terminal(), io::stdin())?; + let invocation = AnalysisRunLifecycleCliInvocation::from_args(&args, body)?; + let response = execute_analysis_run_lifecycle_cli(&invocation)?; + let stdout = render_analysis_run_lifecycle_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 973f67de8..e586a96fc 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 published `tepp-lifecycle` CLI mints those POSTs +//! onto spawned `tepp-loopback` TCP. mod analysis_result; mod analysis_run; +mod analysis_run_lifecycle_cli; mod analysis_run_lifecycle_http; mod analysis_run_live; mod analysis_run_status_http; @@ -76,6 +78,22 @@ 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 lifecycle CLI invocation. +pub use analysis_run_lifecycle_cli::AnalysisRunLifecycleCliInvocation; +/// Loopback lifecycle CLI verb. +pub use analysis_run_lifecycle_cli::AnalysisRunLifecycleCliVerb; +/// Compose one HTTP/1.1 lifecycle POST from a CLI invocation. +pub use analysis_run_lifecycle_cli::compose_analysis_run_lifecycle_cli_http; +/// Dispatch one lifecycle CLI invocation against an in-process listener. +pub use analysis_run_lifecycle_cli::dispatch_analysis_run_lifecycle_cli; +/// Execute one lifecycle CLI invocation over loopback TCP. +pub use analysis_run_lifecycle_cli::execute_analysis_run_lifecycle_cli; +/// Render a typed lifecycle exchange as loopback HTTP/1.1. +pub use analysis_run_lifecycle_cli::loopback_http1_from_lifecycle_exchange; +/// Read leftover stdin for the lifecycle CLI. +pub use analysis_run_lifecycle_cli::read_analysis_run_lifecycle_cli_stdin; +/// Filter lifecycle CLI stdout so receipts stay metric-free. +pub use analysis_run_lifecycle_cli::render_analysis_run_lifecycle_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/analysis_run_lifecycle_cli_contract.rs b/crates/tepp_api/tests/analysis_run_lifecycle_cli_contract.rs new file mode 100644 index 000000000..549105f05 --- /dev/null +++ b/crates/tepp_api/tests/analysis_run_lifecycle_cli_contract.rs @@ -0,0 +1,393 @@ +//! GAP-003A naruon/LineageWeave analysis-run lifecycle CLI. + +use tepp_api::{ + ANALYSIS_RUN_CONTRACT_VERSION, AnalysisRunAccepted, AnalysisRunLifecycleCliInvocation, + AnalysisRunLifecycleCliVerb, AnalysisRunLifecycleTransition, AnalysisRunLiveService, + AnalysisRunRequest, AnalysisRunStatus, AnalysisRunStatusState, AnalysisRunTerminalResult, + ApiError, LINEAGEWEAVE_CONSUMER_CODE, NARUON_ANALYSIS_RUN_PATH, NARUON_CONSUMER_CODE, + NaruonLiveResponse, compose_analysis_run_lifecycle_cli_http, + dispatch_analysis_run_lifecycle_cli, execute_analysis_run_lifecycle_cli, + read_analysis_run_lifecycle_cli_stdin, render_analysis_run_lifecycle_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-lifecycle-tenant".into(), + snapshot_id: "cli-lifecycle-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 lifecycle_args<'a>( + verb: &'a str, + host: &'a str, + run_id: &'a str, + idempotency_key: &'a str, + consumer: &'a str, +) -> [&'a str; 11] { + [ + verb, + "--host", + host, + "--origin", + ORIGIN, + "--consumer", + consumer, + "--run-id", + run_id, + "--idempotency-key", + idempotency_key, + ] +} + +fn accept( + service: &mut AnalysisRunLiveService, + idempotency_key: &str, + consumer: &str, +) -> 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); + AnalysisRunAccepted::from_json(&created.body).expect("accepted") +} + +#[test] +fn verbs_and_from_args_fail_closed() { + assert_eq!( + AnalysisRunLifecycleCliVerb::parse("running").expect("running"), + AnalysisRunLifecycleCliVerb::Running + ); + assert_eq!(AnalysisRunLifecycleCliVerb::Running.as_str(), "running"); + assert_eq!(AnalysisRunLifecycleCliVerb::Terminal.as_str(), "terminal"); + assert_eq!( + AnalysisRunLifecycleCliVerb::parse("retry"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + AnalysisRunLifecycleCliInvocation::from_args(Vec::::new(), "").unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + AnalysisRunLifecycleCliInvocation::from_args( + lifecycle_args( + "running", + "8.8.8.8:80", + "tepp-run-1", + "idem-1", + NARUON_CONSUMER_CODE + ), + "", + ) + .unwrap_err(), + ApiError::AuthorizationDenied + ); + assert_eq!( + AnalysisRunLifecycleCliInvocation::from_args( + [ + "running", + "--host", + "127.0.0.1:18081", + "--origin", + "http://tepp.example.test", + "--run-id", + "tepp-run-1", + "--idempotency-key", + "idem-1", + ], + "", + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + AnalysisRunLifecycleCliInvocation::from_args( + [ + "running", + "--host", + "localhost:18081", + "--origin", + ORIGIN, + "--run-id", + "tepp-run-1", + "--idempotency-key", + "idem-1", + ], + "", + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); +} + +#[test] +fn unpublished_consumer_credentials_and_empty_terminal_fail_closed() { + assert_eq!( + AnalysisRunLifecycleCliInvocation::from_args( + lifecycle_args( + "running", + "127.0.0.1:18081", + "tepp-run-1", + "idem-1", + "unpublished" + ), + "", + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + AnalysisRunLifecycleCliInvocation::from_args( + [ + "running", + "--host", + "127.0.0.1:18081", + "--origin", + ORIGIN, + "--authorization", + "secret", + "--run-id", + "tepp-run-1", + "--idempotency-key", + "idem-1", + ], + "", + ) + .unwrap_err(), + ApiError::AuthorizationDenied + ); + assert_eq!( + AnalysisRunLifecycleCliInvocation::from_args( + lifecycle_args( + "terminal", + "127.0.0.1:18081", + "tepp-run-1", + "idem-1", + NARUON_CONSUMER_CODE + ), + "", + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); +} + +#[test] +fn compose_is_typed_https_post_running_without_credentials() { + let invocation = AnalysisRunLifecycleCliInvocation::from_args( + lifecycle_args( + "running", + "127.0.0.1:18081", + "tepp-run-1", + "idem-1", + NARUON_CONSUMER_CODE, + ), + "", + ) + .expect("invocation"); + let http = compose_analysis_run_lifecycle_cli_http(&invocation).expect("http"); + assert!(http.starts_with("POST /v1/analysis-runs/tepp-run-1/running HTTP/1.1")); + assert!(http.contains("tepp-consumer: naruon")); + assert!(http.contains("idempotency-key: idem-1")); + assert!(!http.to_ascii_lowercase().contains("authorization")); + assert!(!http.contains("rmse")); + assert!(!http.contains(SCHEMA)); +} + +#[test] +fn naruon_and_lineageweave_cli_record_running_status() { + let mut service = AnalysisRunLiveService::new(); + let naruon = accept(&mut service, "cli-lifecycle-naruon", NARUON_CONSUMER_CODE); + let invocation = AnalysisRunLifecycleCliInvocation::from_args( + lifecycle_args( + "running", + "127.0.0.1:18081", + naruon.run_id.as_str(), + "cli-lifecycle-naruon", + NARUON_CONSUMER_CODE, + ), + "", + ) + .expect("naruon"); + let running = dispatch_analysis_run_lifecycle_cli(&mut service, &invocation).expect("running"); + assert_eq!(running.status_code, 200, "{}", running.body); + let stdout = render_analysis_run_lifecycle_cli_stdout(&invocation, &running).expect("stdout"); + let status = AnalysisRunStatus::from_json(&stdout).expect("status"); + assert_eq!(status.run_state, AnalysisRunStatusState::Running); + assert!(!stdout.contains("rmse")); + assert!(!stdout.contains(SCHEMA)); + + let lineage = accept( + &mut service, + "cli-lifecycle-lineage", + LINEAGEWEAVE_CONSUMER_CODE, + ); + let lineage_invocation = AnalysisRunLifecycleCliInvocation::from_args( + lifecycle_args( + "running", + "127.0.0.1:18081", + lineage.run_id.as_str(), + "cli-lifecycle-lineage", + LINEAGEWEAVE_CONSUMER_CODE, + ), + "", + ) + .expect("lineage"); + let lineage_http = compose_analysis_run_lifecycle_cli_http(&lineage_invocation).expect("http"); + assert!(lineage_http.contains("tepp-consumer: lineageweave")); + assert!(!lineage_http.contains("tepp-consumer: naruon")); + let lineage_running = + dispatch_analysis_run_lifecycle_cli(&mut service, &lineage_invocation).expect("lineage"); + assert_eq!(lineage_running.status_code, 200, "{}", lineage_running.body); + let lineage_stdout = + render_analysis_run_lifecycle_cli_stdout(&lineage_invocation, &lineage_running) + .expect("lineage stdout"); + assert!(lineage_stdout.contains("\"running\"")); + assert!(!lineage_stdout.contains(SCHEMA)); + + let mismatched = AnalysisRunLifecycleCliInvocation::from_args( + lifecycle_args( + "running", + "127.0.0.1:18081", + naruon.run_id.as_str(), + "cli-lifecycle-naruon", + LINEAGEWEAVE_CONSUMER_CODE, + ), + "", + ) + .expect("mismatch"); + let denied = dispatch_analysis_run_lifecycle_cli(&mut service, &mismatched).expect("denied"); + assert_eq!(denied.status_code, 400, "{}", denied.body); + let denied_stdout = + render_analysis_run_lifecycle_cli_stdout(&mismatched, &denied).expect("err"); + assert!(denied_stdout.contains("invalid_wire_payload")); + assert!(!denied_stdout.contains(SCHEMA)); +} + +#[test] +fn terminal_cli_records_failed_status() { + let mut service = AnalysisRunLiveService::new(); + let accepted = accept(&mut service, "cli-lifecycle-failed", NARUON_CONSUMER_CODE); + let run = request("cli-lifecycle-failed"); + let failed = AnalysisRunTerminalResult::failed( + &run, + &accepted, + "2026-08-02T03:04:05Z", + "estimation_failed", + ) + .expect("failed"); + let transition = AnalysisRunLifecycleTransition::terminal( + accepted.run_id.clone(), + run.idempotency_key.clone(), + failed, + None, + ) + .expect("transition"); + let body = transition.to_json().expect("json"); + let invocation = AnalysisRunLifecycleCliInvocation::from_args( + lifecycle_args( + "terminal", + "127.0.0.1:18081", + accepted.run_id.as_str(), + "cli-lifecycle-failed", + NARUON_CONSUMER_CODE, + ), + body, + ) + .expect("terminal"); + let http = compose_analysis_run_lifecycle_cli_http(&invocation).expect("http"); + assert!(http.contains("/terminal HTTP/1.1")); + let response = dispatch_analysis_run_lifecycle_cli(&mut service, &invocation).expect("post"); + assert_eq!(response.status_code, 200, "{}", response.body); + let stdout = render_analysis_run_lifecycle_cli_stdout(&invocation, &response).expect("stdout"); + let status = AnalysisRunStatus::from_json(&stdout).expect("status"); + assert_eq!(status.run_state, AnalysisRunStatusState::Failed); + assert!(!stdout.contains("rmse")); + assert!(!stdout.contains(SCHEMA)); +} + +#[test] +fn render_refuses_empty_and_metrics() { + let invocation = AnalysisRunLifecycleCliInvocation::from_args( + lifecycle_args( + "running", + "127.0.0.1:18081", + "tepp-run-1", + "idem-1", + NARUON_CONSUMER_CODE, + ), + "", + ) + .expect("invocation"); + assert_eq!( + render_analysis_run_lifecycle_cli_stdout( + &invocation, + &NaruonLiveResponse { + status_code: 200, + reason_phrase: "OK", + body: String::new(), + } + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + render_analysis_run_lifecycle_cli_stdout( + &invocation, + &NaruonLiveResponse { + status_code: 200, + reason_phrase: "OK", + body: r#"{"contract_version":1,"run_id":"tepp-run-1","run_state":"running","idempotency_key":"idem-1","rmse":1.0}"#.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 accepted = accept(&mut service, "cli-lifecycle-tcp", NARUON_CONSUMER_CODE); + let handle = std::thread::spawn(move || { + drop(service.serve_one()); + }); + let invocation = AnalysisRunLifecycleCliInvocation::from_args( + lifecycle_args( + "running", + addr.as_str(), + accepted.run_id.as_str(), + "cli-lifecycle-tcp", + NARUON_CONSUMER_CODE, + ), + "", + ) + .expect("tcp"); + let response = execute_analysis_run_lifecycle_cli(&invocation).expect("execute"); + assert_eq!(response.status_code, 200, "{}", response.body); + handle.join().expect("join"); + let empty = read_analysis_run_lifecycle_cli_stdin(true, std::io::empty()).expect("tty"); + assert!(empty.is_empty()); + let piped = + read_analysis_run_lifecycle_cli_stdin(false, std::io::Cursor::new(b"{}")).expect("pipe"); + assert_eq!(piped, "{}"); +} diff --git a/crates/tepp_api/tests/loopback_binary_contract.rs b/crates/tepp_api/tests/loopback_binary_contract.rs index 41ab57d57..b0563ba24 100644 --- a/crates/tepp_api/tests/loopback_binary_contract.rs +++ b/crates/tepp_api/tests/loopback_binary_contract.rs @@ -74,3 +74,58 @@ fn binary_records_a_lineageweave_running_transition_over_tcp() { assert!(!status.contains("scientific_acceptance")); assert!(child.wait().expect("wait").success()); } + +#[test] +fn binary_records_running_status_from_tepp_lifecycle_cli() { + let mut child = Command::new(env!("CARGO_BIN_EXE_tepp-loopback")) + .args(["127.0.0.1:0", "2"]) + .stdout(Stdio::piped()) + .spawn() + .expect("spawn loopback service"); + let mut address = String::new(); + BufReader::new(child.stdout.take().expect("stdout")) + .read_line(&mut address) + .expect("bound address"); + let host = address.trim(); + let body = r#"{"contract_version":1,"idempotency_key":"loopback-lifecycle-cli-idem","tenant_workspace_id":"loopback-lifecycle-cli-tenant","snapshot_id":"loopback-lifecycle-cli-snapshot","knowledge_cutoff":"2026-08-01T00:00:00Z","model_contract_version":"tepp-analysis-run-v1","output_profile":"calibrated_event_measurement"}"#; + let create = format!( + "POST /v1/analysis-runs HTTP/1.1\r\nHost: {host}\r\ncontent-type: application/json\r\ntepp-consumer: lineageweave\r\ntepp-contract-version: 1\r\nidempotency-key: loopback-lifecycle-cli-idem\r\ncontent-length: {}\r\n\r\n{body}", + body.len() + ); + let mut stream = TcpStream::connect(host).expect("connect create"); + stream.write_all(create.as_bytes()).expect("create"); + let mut created = String::new(); + stream.read_to_string(&mut created).expect("created"); + assert!(created.starts_with("HTTP/1.1 202 Accepted")); + let json_start = created.find("{\"contract_version\"").expect("json"); + let accepted: serde_json::Value = + serde_json::from_str(&created[json_start..]).expect("accepted json"); + let run_id = accepted["run_id"].as_str().expect("run_id"); + let output = Command::new(env!("CARGO_BIN_EXE_tepp-lifecycle")) + .args([ + "running", + "--host", + host, + "--origin", + "https://tepp.example.test", + "--consumer", + "lineageweave", + "--run-id", + run_id, + "--idempotency-key", + "loopback-lifecycle-cli-idem", + ]) + .stdin(Stdio::null()) + .output() + .expect("tepp-lifecycle"); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8(output.stdout).expect("utf8"); + assert!(stdout.contains("\"run_state\":\"running\"")); + assert!(!stdout.contains("rmse")); + assert!(!stdout.contains("scientific_acceptance")); + assert!(child.wait().expect("wait").success()); +} diff --git a/docs/API_CONTRACT.md b/docs/API_CONTRACT.md index 12bc7d6be..15d0a7386 100644 --- a/docs/API_CONTRACT.md +++ b/docs/API_CONTRACT.md @@ -8,7 +8,7 @@ TEPP must work both as a standalone product and as a modular CWL component. Integrations with `naruon`, `contextual-orchestrator`, `.github`, or other repositories use explicit versioned API/artifact contracts. Cross-service direct table access is prohibited. -Current protected main exposes Rust library/domain contracts. The active stack adds a loopback HTTP/1.1 listener for naruon analysis-run, LineageWeave temporal-context, and export POSTs, including `POST /v1/project-histories` on the `AnalysisRunLiveService` contract boundary. `tepp-loopback` runs the shared consumer listener on `127.0.0.1:18081` by default; a caller may pass another loopback socket address and an optional maximum request count as its two arguments. The container is intended for a trusted same-host or shared-network-namespace sidecar, checks readiness through a synthetic bounded temporal-context request, and deliberately cannot bind a public or bridge address. It is not a production TLS/`$PORT` service. Endpoint examples below that are not covered by `NaruonLiveService` or `AnalysisRunLiveService` remain target interface shapes; export retrieval stays a target shape until an executable export route ships. +Current protected main exposes Rust library/domain contracts. The active stack adds a loopback HTTP/1.1 listener for naruon analysis-run, LineageWeave temporal-context, and export POSTs, including `POST /v1/project-histories` on the `AnalysisRunLiveService` contract boundary, `POST /v1/analysis-runs/{run_id}/running` and `/terminal` for metric-free lifecycle status, and the published `tepp-lifecycle` CLI that mints those POSTs onto spawned `tepp-loopback` TCP. `tepp-loopback` runs the shared consumer listener on `127.0.0.1:18081` by default; a caller may pass another loopback socket address and an optional maximum request count as its two arguments. The container is intended for a trusted same-host or shared-network-namespace sidecar, checks readiness through a synthetic bounded temporal-context request, and deliberately cannot bind a public or bridge address. It is not a production TLS/`$PORT` service. Endpoint examples below that are not covered by `NaruonLiveService` or `AnalysisRunLiveService` remain target interface shapes; export retrieval stays a target shape until an executable export route ships. ## 2. Contract families diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 0ccb1e3b1..139d78b67 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -56,6 +56,7 @@ The full APA 7th standards/literature register remains `docs/research/standards- | 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 lifecycle consumer parity | ADR 0029; API contract; RFC 9110; FIPS 180-4 | `tepp_api` LineageWeave running/terminal exchanges, Naruon compatibility-listener lifecycle POST, and `tepp-loopback` TCP running proof (this PR): stacked on #360; `NaruonLiveService` stays POST-only and Naruon-only; not implemented-main | active-PR | +| loopback analysis-run lifecycle CLI | ADR 0030; API contract; RFC 9110 | `tepp_api` `tepp-lifecycle` running/terminal CLI onto spawned `tepp-loopback` TCP: metric-free status from typed naruon/`LineageWeave` exchanges; stacked on #388; 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/0030-analysis-run-lifecycle-cli.md b/docs/adr/0030-analysis-run-lifecycle-cli.md new file mode 100644 index 000000000..847f7f814 --- /dev/null +++ b/docs/adr/0030-analysis-run-lifecycle-cli.md @@ -0,0 +1,106 @@ +# ADR 0030 — Loopback analysis-run lifecycle CLI + +**Decision status:** Accepted +**Implementation maturity:** active-PR +**Date:** 2026-08-31 +**Supersedes:** None; complements ADR 0028 (lifecycle POST) and ADR 0029 (lifecycle consumer parity). Does not reuse ADR 0030 numbers from other stacks (scientific-acceptance execute CLI on a sibling lineage). Does not supersede ADR 0014 claim-promotion authority. + +## Context + +ADR 0028 owns `POST /v1/analysis-runs/{run_id}/running` and +`POST /v1/analysis-runs/{run_id}/terminal`. ADR 0029 owns typed +naruon/`LineageWeave` lifecycle exchanges and Naruon compatibility-listener +POST. Operators still have to hand-roll HTTP/1.1 to record running or terminal +status on spawned `tepp-loopback`. Scientific-acceptance execute CLI (#362), +retry CLI (#394), status CLI (#392), cancel CLI (#378), create CLI (#385), and +collection CLI (#371) are different verbs or different stacks. `tepp_api` owns +lifecycle; the CLI belongs here. + +## Decision + +Publish `tepp-lifecycle`: + +- `tepp-lifecycle running` and `tepp-lifecycle terminal` mint + `naruon_analysis_run_running_exchange` / + `naruon_analysis_run_terminal_exchange` or the LineageWeave equivalents and + render through `loopback_http1_from_lifecycle_exchange`. +- `--origin` stays the published HTTPS origin; only `--host` is the loopback + bind address printed by `tepp-loopback`. +- Empty stdin is admitted for `running`; terminal requires typed JSON matching + `--run-id`, `--idempotency-key`, and a terminal `run_state`. +- Success stdout is a metric-free `200` status. `tepp.scientific_acceptance.v1` + never appears. +- Public bind hosts, `localhost`, unpublished consumers, credential-shaped + flags, and non-`https` origins fail closed. +- `NaruonLiveService` stays POST-only. Persistence remains GAP-003B. + +## Non-goals + +- Production TLS, public bind, or durable status storage. +- GET on `NaruonLiveService`. +- Leiden community detection, Driver p.16 restoration, or Figma/export work. +- Promoting an ADR 0014 scientific claim from HTTP success. +- Execute CLI, retry CLI, status CLI, cancel CLI, create CLI, collection CLI, + or another lifecycle POST/consumer-parity slice. + +## Alternatives considered + +1. **Keep hand-rolled lifecycle HTTP in each operator script** — rejected + because GAP-003A is operator-visible and create/retry already have CLIs. +2. **Add `running`/`terminal` to `tepp-retry` or execute CLI** — rejected + because those stacks do not include lifecycle POST (#360/#388). +3. **Reuse scientific-acceptance execute CLI (#362)** — rejected; that CLI + drives engine execute, not running/terminal status. + +## Consequences + +- Operators can record running and terminal status without embedding the + library. +- HTTP 200 on lifecycle is not release evidence. + +## Failure and recovery + +Non-loopback hosts, `localhost`, non-`https` origins, unpublished consumers, +metric keys, empty terminal stdin, unknown artifact fields, empty identities, +consumer mismatch, reverse transitions, and mutating a terminal run 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. +- Lifecycle remains loopback-served, size-bounded, and content-redacting. +- Running and failed receipts stay metric-free. + +## Compatibility and migration + +Create and lifecycle HTTP exchanges are unchanged. Production adapters may +replace loopback while preserving metric-free running/terminal receipts. + +## Verification + +Falsifiable evidence: + +- naruon running CLI is HTTPS POST `/running` without credentials or RMSE keys; +- LineageWeave running CLI changes only `tepp-consumer`; +- public bind, `localhost`, `http://` origins, unpublished consumers, and empty + terminal stdin fail closed; +- create then typed running CLI then stdout is metric-free `200` for both + consumers; consumer mismatch is `400`; +- create then failed terminal CLI records `Failed` without scientific + acceptance; +- Clippy `-D warnings`, `tepp_api` tests, rustdoc, and exact-head review remain + required. + +## Rollback and supersession + +Rollback removes the lifecycle CLI; lifecycle HTTP and consumer exchanges +remain valid. A superseding ADR is required to persist status, bind a public +address, or treat HTTP success as an ADR 0014 claim. + +## Related authority + +- ADR 0028 owns lifecycle POST. +- ADR 0029 owns lifecycle 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 1e4395e4a..550d73d4f 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -33,6 +33,7 @@ Read [`ADR_POLICY.md`](ADR_POLICY.md) first. **Decision status and implementatio | [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-analysis-run-lifecycle-consumer-parity.md) | Analysis-run lifecycle consumer parity | Accepted | active-PR | LineageWeave running/terminal exchanges plus Naruon compatibility-listener lifecycle POST; `NaruonLiveService` stays POST-only. | +| [0030](0030-analysis-run-lifecycle-cli.md) | Loopback analysis-run lifecycle CLI | Accepted | active-PR | Complements ADR 0028/0029; `tepp-lifecycle` running/terminal onto `tepp-loopback`. Unique on this lineage. | | [0023](0023-lineage-criterion-anchor-contract.md) | TEPP-owned Event Lineage criterion anchor | Accepted | active-PR | PR #237 publishes the strict accepted/rejected artifact and identities; estimator execution remains fail-closed future work. | | [0024](0024-independent-topic-importance-anchor.md) | Posterior topic-context producer contract | Accepted | contract-only active-PR | Strict DTO/schema only; the current estimator does not emit it. fast-mlsirm owns case-deletion influence. | | [0001](0001-rust-first-modular-msa.md) | Rust-first numerical core and CPU `f64` reference | Accepted | partial | ADR 0011 owns cross-service/MSA authority; 0001 retains numerical/backend authority. | @@ -146,6 +147,7 @@ Use the narrowest owning ADR when decisions overlap: - **scientific-acceptance loopback GET:** ADR 0027. - **scientific-acceptance loopback lifecycle POST:** ADR 0028. - **analysis-run lifecycle consumer parity:** ADR 0029. +- **analysis-run lifecycle CLI:** ADR 0030. ## Change and supersession rule diff --git a/docs/connectors/naruon-artifact-consumer.md b/docs/connectors/naruon-artifact-consumer.md index 9ce1e08e0..eccab117d 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 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 lifecycle POST | `tepp_api` `NaruonLiveService` → `POST /v1/analysis-runs/{run_id}/running` and `/terminal` (Naruon-only; LineageWeave uses `AnalysisRunLiveService`) | naruon → TEPP | +| Loopback lifecycle CLI | `tepp_api` `tepp-lifecycle` → `POST /v1/analysis-runs/{run_id}/running` and `/terminal` | naruon → TEPP | Committed examples live under `examples/`. Schemas for analysis-run requests and corpus-split manifests live under `schemas/`. diff --git a/docs/research/analysis-run-lifecycle-cli.md b/docs/research/analysis-run-lifecycle-cli.md new file mode 100644 index 000000000..0c0ef6114 --- /dev/null +++ b/docs/research/analysis-run-lifecycle-cli.md @@ -0,0 +1,45 @@ +# Analysis-run lifecycle CLI (GAP-003A) + +## Scope + +This note doctors the published `tepp-lifecycle` CLI stacked on ADR 0028/0029 +and issue #166: + +1. `tepp-lifecycle running` mints a typed naruon or `LineageWeave` running + exchange and POSTs it onto spawned `tepp-loopback` TCP; +2. `tepp-lifecycle terminal` requires typed JSON and records a request-bound + terminal status; +3. empty stdin is admitted for running; public bind, `localhost`, unpublished + consumers, credential flags, and non-`https` origins fail closed; +4. stdout stays metric-free; `tepp.scientific_acceptance.v1` never appears; +5. `NaruonLiveService` stays POST-only. + +This slice does not duplicate the shared-listener lifecycle POST (#360), +lifecycle consumer-parity (#388), GET status (#359), cancel, collection, retry, +the terminal-result DTO (#358), or the engine library (#356). PostgreSQL +persistence remains GAP-003B. HTTP 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 + +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 keeps POST as the lifecycle write. The CLI mints the same +exchanges ADR 0029 already publishes and only rewrites the HTTP/1.1 `Host` to +the loopback bind address. Consumer identity stays a header swap so +LineageWeave cannot be forced to mint a Naruon-labelled transition. + +## Non-application + +This note does not authorize public bind, TLS termination, durable status +storage, opening `NaruonLiveService` to LineageWeave or GET, or treating HTTP +`200` as a scientific claim.