diff --git a/CHANGELOG.d/analysis-run-wait-cli.md b/CHANGELOG.d/analysis-run-wait-cli.md new file mode 100644 index 000000000..08f433dac --- /dev/null +++ b/CHANGELOG.d/analysis-run-wait-cli.md @@ -0,0 +1 @@ +- `tepp_api` loopback `tepp-analysis-runs wait` polls metric-free status until succeeded or failed (ADR 0030). Timeout `0` fails closed on accepted/running. `tepp.scientific_acceptance.v1` only on succeeded `scientific_acceptance_v1`. Not status CLI, not GET-by-id HTTP, not persistence. diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index b2c379518..ac2309a50 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -14,6 +14,7 @@ TEPP's approved PRD v0.4 and implementation plan are the primary product baselin | Analysis-run status GET doctoring | [`docs/research/scientific-acceptance-http-status.md`](docs/research/scientific-acceptance-http-status.md) | | Analysis-run status consumer-parity doctoring | [`docs/research/analysis-run-status-consumer-parity.md`](docs/research/analysis-run-status-consumer-parity.md) | | Analysis-run status CLI doctoring | [`docs/research/analysis-run-status-cli.md`](docs/research/analysis-run-status-cli.md) | +| Analysis-run wait CLI doctoring | [`docs/research/analysis-run-wait-cli.md`](docs/research/analysis-run-wait-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) | diff --git a/crates/tepp_api/src/analysis_run_wait_cli.rs b/crates/tepp_api/src/analysis_run_wait_cli.rs new file mode 100644 index 000000000..fde281084 --- /dev/null +++ b/crates/tepp_api/src/analysis_run_wait_cli.rs @@ -0,0 +1,509 @@ +//! Operator loopback CLI that waits for analysis-run terminal status. +//! +//! GAP-003A operator-visible client of `GET /v1/analysis-runs/{run_id}` (ADR +//! 0027 / live #359, status CLI #392). Operators run `tepp-analysis-runs wait` +//! to poll until succeeded or failed without writing a poll loop. +//! Accepted/running/failed stdout stays metric-free. +//! `tepp.scientific_acceptance.v1` appears only on a succeeded GET whose +//! request profile is `scientific_acceptance_v1`. This module does not +//! duplicate status CLI, GET-by-id HTTP, lifecycle POST, cancel/create/retry +//! CLIs, or lookup CLI. Persistence remains GAP-003B. + +use std::thread; +use std::time::{Duration, Instant}; + +use crate::analysis_run_status_cli::{ + AnalysisRunStatusCliInvocation, dispatch_analysis_run_status_cli, + execute_analysis_run_status_cli, render_analysis_run_status_cli_stdout, +}; +use crate::naruon_http::header_is_credential; +use crate::wire::require_nonempty; +use crate::{ + AnalysisRunLiveService, AnalysisRunStatus, AnalysisRunStatusState, ApiError, NaruonLiveResponse, +}; + +/// Default wait budget in milliseconds. +pub const ANALYSIS_RUN_WAIT_DEFAULT_TIMEOUT_MS: u64 = 1_000; +/// Maximum wait budget in milliseconds. +pub const ANALYSIS_RUN_WAIT_MAX_TIMEOUT_MS: u64 = 60_000; +/// Default poll interval in milliseconds. +pub const ANALYSIS_RUN_WAIT_DEFAULT_INTERVAL_MS: u64 = 10; +/// Maximum poll interval in milliseconds. +pub const ANALYSIS_RUN_WAIT_MAX_INTERVAL_MS: u64 = 1_000; + +/// Supported operator verbs for the loopback wait CLI. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum AnalysisRunWaitCliVerb { + /// Poll `GET /v1/analysis-runs/{run_id}` until terminal or timeout. + Wait, +} + +impl AnalysisRunWaitCliVerb { + /// Parse one exact lowercase verb token. + /// + /// # Errors + /// + /// Returns [`ApiError::InvalidWirePayload`] for an unknown token. + pub fn parse(token: &str) -> Result { + match token { + "wait" => Ok(Self::Wait), + _ => Err(ApiError::InvalidWirePayload), + } + } + + /// Return the canonical lowercase verb token. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Wait => "wait", + } + } +} + +/// One operator CLI invocation that polls loopback status until terminal. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AnalysisRunWaitCliInvocation { + /// CLI verb to execute. + pub verb: AnalysisRunWaitCliVerb, + /// Underlying status GET invocation. + pub status: AnalysisRunStatusCliInvocation, + /// Inclusive wait budget. + pub timeout: Duration, + /// Sleep between non-terminal polls. + pub interval: Duration, +} + +impl AnalysisRunWaitCliInvocation { + /// Parse argv plus stdin body into a validated loopback wait invocation. + /// + /// # Errors + /// + /// Returns a fail-closed error for unknown verbs, missing required flags, a + /// non-loopback host, unpublished consumers, credential-shaped flags, + /// hostile identities, a nonempty body, or an oversized wait budget. + 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 = AnalysisRunWaitCliVerb::parse(verb_token)?; + let (status_args, timeout_ms, interval_ms) = split_wait_flags(rest)?; + let mut status_tokens = vec!["status".to_owned()]; + status_tokens.extend(status_args); + let status = AnalysisRunStatusCliInvocation::from_args(status_tokens, body)?; + let invocation = Self { + verb, + status, + timeout: Duration::from_millis(timeout_ms), + interval: Duration::from_millis(interval_ms), + }; + invocation.validate()?; + Ok(invocation) + } + + /// Reject an interval longer than the wait budget. + /// + /// # Errors + /// + /// Returns [`ApiError::InvalidWirePayload`] when the interval exceeds the + /// timeout. + pub fn validate(&self) -> Result<(), ApiError> { + self.status.validate()?; + if self.interval > self.timeout && self.timeout != Duration::ZERO { + return Err(ApiError::InvalidWirePayload); + } + Ok(()) + } +} + +fn split_wait_flags(rest: &[String]) -> Result<(Vec, u64, u64), ApiError> { + let mut timeout_ms = ANALYSIS_RUN_WAIT_DEFAULT_TIMEOUT_MS; + let mut interval_ms = ANALYSIS_RUN_WAIT_DEFAULT_INTERVAL_MS; + let mut seen_timeout = false; + let mut seen_interval = false; + let mut status_args = Vec::new(); + 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); + } + if index + 1 >= rest.len() { + return Err(ApiError::InvalidWirePayload); + } + let value = rest[index + 1].as_str(); + require_nonempty(value)?; + match name { + "timeout-ms" => { + if seen_timeout { + return Err(ApiError::InvalidWirePayload); + } + timeout_ms = parse_bounded_ms(value, ANALYSIS_RUN_WAIT_MAX_TIMEOUT_MS)?; + seen_timeout = true; + } + "poll-interval-ms" => { + if seen_interval { + return Err(ApiError::InvalidWirePayload); + } + interval_ms = parse_bounded_ms(value, ANALYSIS_RUN_WAIT_MAX_INTERVAL_MS)?; + seen_interval = true; + } + _ => { + status_args.push(flag.to_owned()); + status_args.push(value.to_owned()); + } + } + index += 2; + } + Ok((status_args, timeout_ms, interval_ms)) +} + +fn parse_bounded_ms(value: &str, maximum: u64) -> Result { + let parsed = value + .parse::() + .map_err(|_| ApiError::InvalidWirePayload)?; + if parsed > maximum { + Err(ApiError::LimitExceeded) + } else { + Ok(parsed) + } +} + +fn is_terminal(state: AnalysisRunStatusState) -> bool { + matches!( + state, + AnalysisRunStatusState::Succeeded | AnalysisRunStatusState::Failed + ) +} + +/// Dispatch wait against an in-process loopback service. +/// +/// # Errors +/// +/// Returns fail-closed validation errors, [`ApiError::LimitExceeded`] when the +/// run stays accepted/running past the budget, or status-path errors. +pub fn dispatch_analysis_run_wait_cli( + service: &mut AnalysisRunLiveService, + invocation: &AnalysisRunWaitCliInvocation, +) -> Result { + invocation.validate()?; + let started = Instant::now(); + loop { + let response = dispatch_analysis_run_status_cli(service, &invocation.status)?; + if is_wait_complete(&response)? { + return Ok(response); + } + if started.elapsed() >= invocation.timeout { + return Err(ApiError::LimitExceeded); + } + if !invocation.interval.is_zero() { + thread::sleep(invocation.interval); + } + } +} + +/// Execute wait over loopback TCP against `tepp-loopback`. +/// +/// # Errors +/// +/// Returns fail-closed validation, transport, timeout, or response errors. +pub fn execute_analysis_run_wait_cli( + invocation: &AnalysisRunWaitCliInvocation, +) -> Result { + invocation.validate()?; + let started = Instant::now(); + loop { + let response = execute_analysis_run_status_cli(&invocation.status)?; + if is_wait_complete(&response)? { + return Ok(response); + } + if started.elapsed() >= invocation.timeout { + return Err(ApiError::LimitExceeded); + } + if !invocation.interval.is_zero() { + thread::sleep(invocation.interval); + } + } +} + +/// Render wait stdout through the status CLI metric-free gates. +/// +/// # Errors +/// +/// Returns the same fail-closed errors as +/// [`render_analysis_run_status_cli_stdout`]. +pub fn render_analysis_run_wait_cli_stdout( + invocation: &AnalysisRunWaitCliInvocation, + response: &NaruonLiveResponse, +) -> Result { + invocation.validate()?; + render_analysis_run_status_cli_stdout(&invocation.status, response) +} + +fn is_wait_complete(response: &NaruonLiveResponse) -> Result { + if !(200..300).contains(&response.status_code) { + return Ok(true); + } + let status = AnalysisRunStatus::from_json(&response.body)?; + Ok(is_terminal(status.run_state)) +} + +#[cfg(test)] +#[allow(clippy::too_many_lines)] +mod tests { + use super::{ + ANALYSIS_RUN_WAIT_DEFAULT_INTERVAL_MS, ANALYSIS_RUN_WAIT_DEFAULT_TIMEOUT_MS, + ANALYSIS_RUN_WAIT_MAX_INTERVAL_MS, ANALYSIS_RUN_WAIT_MAX_TIMEOUT_MS, + AnalysisRunWaitCliInvocation, AnalysisRunWaitCliVerb, dispatch_analysis_run_wait_cli, + execute_analysis_run_wait_cli, render_analysis_run_wait_cli_stdout, + }; + use crate::{ + ANALYSIS_RUN_CONTRACT_VERSION, AnalysisRunAccepted, AnalysisRunLiveService, + AnalysisRunRequest, AnalysisRunStatus, AnalysisRunStatusState, AnalysisRunTerminalResult, + ApiError, NARUON_CONSUMER_CODE, + }; + use std::time::Duration; + + fn request(idempotency_key: &str) -> AnalysisRunRequest { + AnalysisRunRequest { + contract_version: ANALYSIS_RUN_CONTRACT_VERSION, + idempotency_key: idempotency_key.into(), + tenant_workspace_id: "cli-wait-tenant".into(), + snapshot_id: "cli-wait-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, host: &str) -> String { + let body = run.to_json().expect("json"); + format!( + "POST /v1/analysis-runs HTTP/1.1\r\nHost: {host}\r\ncontent-type: application/json\r\ntepp-consumer: {NARUON_CONSUMER_CODE}\r\ntepp-contract-version: 1\r\nidempotency-key: {}\r\ncontent-length: {}\r\n\r\n{body}", + run.idempotency_key, + body.len() + ) + } + + fn wait_args(run_id: &str, key: &str, extra: &[&str]) -> Vec { + let mut args = vec![ + "wait".into(), + "--host".into(), + "127.0.0.1:18081".into(), + "--run-id".into(), + run_id.into(), + "--idempotency-key".into(), + key.into(), + ]; + args.extend(extra.iter().map(|value| (*value).to_owned())); + args + } + + #[test] + fn verbs_parse_and_reject_unknown_tokens() { + assert_eq!( + AnalysisRunWaitCliVerb::parse("wait").expect("verb"), + AnalysisRunWaitCliVerb::Wait + ); + assert_eq!(AnalysisRunWaitCliVerb::Wait.as_str(), "wait"); + assert_eq!( + AnalysisRunWaitCliVerb::parse("WAIT"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + AnalysisRunWaitCliVerb::parse("status"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + AnalysisRunWaitCliVerb::parse("lookup"), + Err(ApiError::InvalidWirePayload) + ); + } + + #[test] + fn from_args_refuses_host_credentials_and_oversized_budgets() { + assert_eq!( + AnalysisRunWaitCliInvocation::from_args(Vec::::new(), "").unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + AnalysisRunWaitCliInvocation::from_args( + [ + "wait", + "--host", + "8.8.8.8:80", + "--run-id", + "tepp-run-1", + "--idempotency-key", + "idem-1" + ], + "" + ) + .unwrap_err(), + ApiError::AuthorizationDenied + ); + assert_eq!( + AnalysisRunWaitCliInvocation::from_args( + wait_args("tepp-run-1", "idem-1", &["--authorization", "secret"]), + "" + ) + .unwrap_err(), + ApiError::AuthorizationDenied + ); + assert_eq!( + AnalysisRunWaitCliInvocation::from_args( + wait_args("tepp-run-1", "idem-1", &["--timeout-ms", "not-a-number"]), + "" + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + AnalysisRunWaitCliInvocation::from_args( + wait_args( + "tepp-run-1", + "idem-1", + &[ + "--timeout-ms", + &(ANALYSIS_RUN_WAIT_MAX_TIMEOUT_MS + 1).to_string() + ] + ), + "" + ) + .unwrap_err(), + ApiError::LimitExceeded + ); + assert_eq!( + AnalysisRunWaitCliInvocation::from_args( + wait_args( + "tepp-run-1", + "idem-1", + &[ + "--poll-interval-ms", + &(ANALYSIS_RUN_WAIT_MAX_INTERVAL_MS + 1).to_string() + ] + ), + "" + ) + .unwrap_err(), + ApiError::LimitExceeded + ); + assert_eq!( + AnalysisRunWaitCliInvocation::from_args( + wait_args( + "tepp-run-1", + "idem-1", + &["--timeout-ms", "10", "--poll-interval-ms", "20"] + ), + "" + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + AnalysisRunWaitCliInvocation::from_args(wait_args("tepp-run-1", "idem-1", &[]), "{}") + .unwrap_err(), + ApiError::InvalidWirePayload + ); + let defaults = + AnalysisRunWaitCliInvocation::from_args(wait_args("tepp-run-1", "idem-1", &[]), "") + .expect("defaults"); + assert_eq!(defaults.verb, AnalysisRunWaitCliVerb::Wait); + assert_eq!( + defaults.timeout, + Duration::from_millis(ANALYSIS_RUN_WAIT_DEFAULT_TIMEOUT_MS) + ); + assert_eq!( + defaults.interval, + Duration::from_millis(ANALYSIS_RUN_WAIT_DEFAULT_INTERVAL_MS) + ); + } + + #[test] + fn wait_times_out_on_accepted_and_returns_failed_terminal() { + let mut service = AnalysisRunLiveService::new(); + let run = request("cli-wait-idem-1"); + let created = service.handle_http_request(&create_http(&run, "127.0.0.1:18081")); + assert_eq!(created.status_code, 202); + let accepted = AnalysisRunAccepted::from_json(&created.body).expect("accepted"); + let timeout = AnalysisRunWaitCliInvocation::from_args( + wait_args( + &accepted.run_id, + "cli-wait-idem-1", + &["--timeout-ms", "0", "--poll-interval-ms", "0"], + ), + "", + ) + .expect("timeout inv"); + assert_eq!( + dispatch_analysis_run_wait_cli(&mut service, &timeout).unwrap_err(), + ApiError::LimitExceeded + ); + + let failed = AnalysisRunTerminalResult::failed( + &run, + &accepted, + "2026-08-02T03:04:05Z", + "non_convergence", + ) + .expect("failed"); + let terminal = AnalysisRunStatus::terminal(&run, &accepted, failed).expect("terminal"); + service + .record_loopback_status(&accepted.run_id, terminal, None) + .expect("recorded"); + let wait = AnalysisRunWaitCliInvocation::from_args( + wait_args( + &accepted.run_id, + "cli-wait-idem-1", + &["--timeout-ms", "1000", "--poll-interval-ms", "0"], + ), + "", + ) + .expect("wait"); + let got = dispatch_analysis_run_wait_cli(&mut service, &wait).expect("terminal wait"); + assert_eq!(got.status_code, 200); + let stdout = render_analysis_run_wait_cli_stdout(&wait, &got).expect("stdout"); + assert!(stdout.contains("\"failed\"")); + assert!(!stdout.contains("tepp.scientific_acceptance.v1")); + assert!(!stdout.contains("rmse")); + let status = AnalysisRunStatus::from_json(&stdout).expect("status"); + assert_eq!(status.run_state, AnalysisRunStatusState::Failed); + assert_eq!(status.run_id, accepted.run_id); + } + + #[test] + fn execute_times_out_over_tcp_on_accepted() { + let mut service = AnalysisRunLiveService::bind_loopback().expect("bind"); + let addr = service.local_addr().expect("addr"); + let run = request("cli-wait-tcp"); + let created = service.handle_http_request(&create_http(&run, &addr.to_string())); + let accepted = AnalysisRunAccepted::from_json(&created.body).expect("accepted"); + let handle = std::thread::spawn(move || { + drop(service.serve_one()); + }); + let mut invocation = AnalysisRunWaitCliInvocation::from_args( + wait_args( + &accepted.run_id, + "cli-wait-tcp", + &["--timeout-ms", "0", "--poll-interval-ms", "0"], + ), + "", + ) + .expect("inv"); + invocation.status.host = addr.to_string(); + assert_eq!( + execute_analysis_run_wait_cli(&invocation).unwrap_err(), + ApiError::LimitExceeded + ); + handle.join().expect("join"); + } +} diff --git a/crates/tepp_api/src/bin/tepp_analysis_runs.rs b/crates/tepp_api/src/bin/tepp_analysis_runs.rs index ed9dfd740..490c957a7 100644 --- a/crates/tepp_api/src/bin/tepp_analysis_runs.rs +++ b/crates/tepp_api/src/bin/tepp_analysis_runs.rs @@ -1,11 +1,13 @@ -//! Operator CLI for loopback analysis-run status GET. +//! Operator CLI for loopback analysis-run status GET and wait. use std::io::{self, IsTerminal}; use std::process::ExitCode; use tepp_api::{ - AnalysisRunStatusCliInvocation, ApiError, execute_analysis_run_status_cli, + AnalysisRunStatusCliInvocation, AnalysisRunWaitCliInvocation, ApiError, + execute_analysis_run_status_cli, execute_analysis_run_wait_cli, read_analysis_run_status_cli_stdin, render_analysis_run_status_cli_stdout, + render_analysis_run_wait_cli_stdout, }; fn main() -> ExitCode { @@ -19,6 +21,7 @@ fn run() -> Result<(), ApiError> { let args: Vec = std::env::args().skip(1).collect(); match args.first().map(String::as_str) { Some("status") => run_status(&args), + Some("wait") => run_wait(&args), _ => Err(ApiError::InvalidWirePayload), } } @@ -35,3 +38,16 @@ fn run_status(args: &[String]) -> Result<(), ApiError> { Err(ApiError::InvalidWirePayload) } } + +fn run_wait(args: &[String]) -> Result<(), ApiError> { + let body = read_analysis_run_status_cli_stdin(io::stdin().is_terminal(), io::stdin())?; + let invocation = AnalysisRunWaitCliInvocation::from_args(args, body)?; + let response = execute_analysis_run_wait_cli(&invocation)?; + let stdout = render_analysis_run_wait_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 200b36c4e..de0e24af2 100644 --- a/crates/tepp_api/src/lib.rs +++ b/crates/tepp_api/src/lib.rs @@ -19,6 +19,7 @@ mod analysis_run; mod analysis_run_live; mod analysis_run_status_cli; mod analysis_run_status_http; +mod analysis_run_wait_cli; mod authorization; mod corpus_split_manifest; mod envelope; @@ -92,6 +93,16 @@ pub use analysis_run_status_cli::read_analysis_run_status_cli_stdin; pub use analysis_run_status_cli::render_analysis_run_status_cli_stdout; /// Analysis-run status HTTP exchange re-exports. pub use analysis_run_status_http::{ANALYSIS_RUN_ID_MAX_LEN, naruon_analysis_run_status_exchange}; +/// One validated wait CLI invocation. +pub use analysis_run_wait_cli::AnalysisRunWaitCliInvocation; +/// Loopback wait CLI verb. +pub use analysis_run_wait_cli::AnalysisRunWaitCliVerb; +/// Dispatch a wait CLI invocation against an in-process listener. +pub use analysis_run_wait_cli::dispatch_analysis_run_wait_cli; +/// Execute a wait CLI invocation over loopback TCP. +pub use analysis_run_wait_cli::execute_analysis_run_wait_cli; +/// Render wait CLI stdout with status metric-free gates. +pub use analysis_run_wait_cli::render_analysis_run_wait_cli_stdout; /// Corpus-split leakage-audit contract version. pub use corpus_split_manifest::CORPUS_SPLIT_MANIFEST_CONTRACT_VERSION; /// Versioned corpus-split leakage-audit manifest. diff --git a/crates/tepp_api/tests/analysis_run_wait_cli_contract.rs b/crates/tepp_api/tests/analysis_run_wait_cli_contract.rs new file mode 100644 index 000000000..05a3a8915 --- /dev/null +++ b/crates/tepp_api/tests/analysis_run_wait_cli_contract.rs @@ -0,0 +1,66 @@ +//! Contract tests for the analysis-run wait loopback CLI. + +use tepp_api::{ + AnalysisRunWaitCliInvocation, AnalysisRunWaitCliVerb, ApiError, NARUON_CONSUMER_CODE, +}; + +#[test] +fn wait_cli_is_status_poll_without_credentials() { + assert_eq!( + AnalysisRunWaitCliVerb::parse("wait").expect("verb"), + AnalysisRunWaitCliVerb::Wait + ); + let invocation = AnalysisRunWaitCliInvocation::from_args( + [ + "wait", + "--host", + "127.0.0.1:18081", + "--run-id", + "tepp-run-1", + "--idempotency-key", + "idem-1", + ], + "", + ) + .expect("invocation"); + assert_eq!(invocation.status.consumer, NARUON_CONSUMER_CODE); + assert!(!invocation.status.host.contains("authorization")); +} + +#[test] +fn wait_cli_refuses_non_loopback_unknown_verbs_and_bodies() { + assert_eq!( + AnalysisRunWaitCliInvocation::from_args( + [ + "wait", + "--host", + "8.8.8.8:80", + "--run-id", + "tepp-run-1", + "--idempotency-key", + "idem-1" + ], + "" + ), + Err(ApiError::AuthorizationDenied) + ); + assert_eq!( + AnalysisRunWaitCliVerb::parse("lookup"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + AnalysisRunWaitCliInvocation::from_args( + [ + "wait", + "--host", + "127.0.0.1:18081", + "--run-id", + "tepp-run-1", + "--idempotency-key", + "idem-1" + ], + r#"{"rmse":1.0}"# + ), + Err(ApiError::InvalidWirePayload) + ); +} diff --git a/docs/API_CONTRACT.md b/docs/API_CONTRACT.md index 51625db58..57bc9f386 100644 --- a/docs/API_CONTRACT.md +++ b/docs/API_CONTRACT.md @@ -103,7 +103,9 @@ bodies stay metric-free, and only a succeeded status with profile loopback `tepp-analysis-runs status` CLI is the operator-visible client for that GET; accepted/running/failed stdout stays metric-free, and `tepp.scientific_acceptance.v1` prints only on succeeded -`scientific_acceptance_v1`. Production +`scientific_acceptance_v1`. The loopback `tepp-analysis-runs wait` CLI polls +that GET until succeeded or failed, or until `--timeout-ms` elapses; timeout +`0` fails closed on accepted or running. Production TLS remains a later adapter. The stacked `analysis_engine` slice provides the first executable service-side diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 661e0dd6e..a5fc2b7ec 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` (this PR): accepted/running stay metric-free; `tepp.scientific_acceptance.v1` only on succeeded `scientific_acceptance_v1`; not implemented-main | active-PR | | analysis-run status consumer parity | ADR 0028; ADR 0027; RFC 9110 | `lineageweave_analysis_run_status_exchange` and `tepp-loopback` TCP GET proof; `NaruonLiveService` stays POST-only | active-PR | | loopback analysis-run status CLI | ADR 0029; API contract; RFC 9110 | `tepp_api` `tepp-analysis-runs status` CLI: operator-visible client of `GET /v1/analysis-runs/{run_id}`; accepted/running/failed stay metric-free; `tepp.scientific_acceptance.v1` only on succeeded `scientific_acceptance_v1` | active-PR | +| loopback analysis-run wait CLI | ADR 0030; API contract; RFC 9110 | `tepp_api` `tepp-analysis-runs wait` CLI: operator-visible poll of GET-by-id until succeeded/failed or timeout; accepted/running/failed stay metric-free; `tepp.scientific_acceptance.v1` only on succeeded `scientific_acceptance_v1` | 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-wait-cli.md b/docs/adr/0030-analysis-run-wait-cli.md new file mode 100644 index 000000000..4ea125f8c --- /dev/null +++ b/docs/adr/0030-analysis-run-wait-cli.md @@ -0,0 +1,69 @@ +# ADR 0030 — Analysis-run wait loopback CLI + +**Decision status:** Accepted +**Implementation maturity:** active-PR +**Date:** 2026-08-31 +**Supersedes:** None; complements ADR 0029 for the operator-visible wait client. Does not supersede ADR 0014 claim-promotion authority. This ADR number is unique on the GET-status lineage; other live PRs may reuse 0030 on unrelated stacks (scientific-acceptance loopback CLI). + +## Context + +ADR 0029 lets operators inspect one status GET, but they still had to write a poll loop to learn when an accepted or running run became succeeded or failed. Duplicating status CLI (#392), GET-by-id HTTP (#359), lifecycle POST (#360), cancel/create/retry/lookup/retry-lineage CLIs, or Leiden would collide with live PRs. + +## Decision + +`tepp_api` publishes a loopback-only `tepp-analysis-runs wait` verb on this GET-status lineage: + +- `wait` polls `GET /v1/analysis-runs/{run_id}` with `--run-id` and `--idempotency-key` until `succeeded` or `failed`, or until `--timeout-ms` elapses. +- Default timeout is 1000 ms (max 60000). Default poll interval is 10 ms (max 1000). Interval longer than a nonzero timeout fails closed. Timeout `0` polls once and fails closed if the run is still accepted or running. +- Stdout reuses ADR 0029 gates. Accepted/running/failed stay metric-free. `tepp.scientific_acceptance.v1` appears only on succeeded `scientific_acceptance_v1`. +- Non-loopback hosts, unpublished consumers, credential-shaped flags, nonempty stdin, unknown verbs, and oversized budgets fail closed. +- This slice does not implement GET-by-id HTTP or lifecycle POST. +- Persistence, Compose recovery, and psychometric execution remain GAP-003B. + +## Alternatives considered + +1. **Keep status GET as the only client** — rejected because operators still write poll loops after ADR 0029. +2. **Add `wait` onto retry-lineage CLI (#403)** — rejected because that head already owns `tepp-retry-lineage`. +3. **Busy-wait without a timeout** — rejected because a hung accepted run must fail closed. +4. **Loopback wait CLI stacked on ADR 0029** — accepted. + +## Consequences + +- Operators can wait for terminal status without writing HTTP or a poll loop. +- Wait stdout cannot treat accepted/running as measurement evidence. +- CLI success is not release evidence. + +## Failure and recovery + +Non-loopback hosts return authorization denied. Unknown verbs, nonempty stdin, unpublished consumers, credential flags, and oversized budgets fail closed. An accepted or running run past `--timeout-ms` returns limit exceeded. Unknown identities remain refused by ADR 0027. + +## Security, privacy, scientific-integrity, and governance impact + +- No credential headers cross the consumer boundary. +- The CLI remains loopback-only and time-bounded. +- Process exit 0 on wait is not an ADR 0014 claim. + +## Compatibility and migration + +Status GET HTTP, status CLI, create POST, temporal-context, and project-history paths are unchanged. Parallel `tepp-analysis-runs` verbs on other stacks merge by combining verbs. + +## Verification + +Falsifiable evidence: + +- wait of a failed run returns metric-free failed status without `tepp.scientific_acceptance.v1`; +- wait of an accepted run with `--timeout-ms 0` fails closed; +- non-loopback host, credential flags, nonempty stdin, and unknown verbs fail closed; +- Clippy `-D warnings`, `tepp_api` tests, rustdoc, and exact-head review remain required. + +## Rollback and supersession + +Rollback removes the wait verb; status GET and status CLI remain valid. A superseding ADR is required to persist the registry, bind a public address, or treat wait success as an ADR 0014 claim. + +## Related authority + +- ADR 0029 owns loopback status CLI. +- ADR 0027 owns loopback GET-by-id status. +- ADR 0018 owns consumer-scoped ingress. +- ADR 0014 owns scientific claim promotion. +- RFC 9110 owns GET semantics (Fielding, Nottingham, & Reschke, 2022). It does not authorize scientific claims. diff --git a/docs/adr/README.md b/docs/adr/README.md index e053e809a..537241f34 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-analysis-run-status-consumer-parity.md) | LineageWeave status-exchange and loopback TCP GET proof | Accepted | active-PR | Complements ADR 0027; does not open `NaruonLiveService` to GET. | | [0029](0029-analysis-run-status-cli.md) | Loopback `tepp-analysis-runs status` is GET-by-id client | Accepted | active-PR | Complements ADR 0027/0028; does not supersede ADR 0014. Unique on GET-status lineage. | +| [0030](0030-analysis-run-wait-cli.md) | Loopback `tepp-analysis-runs wait` polls GET-by-id until terminal | Accepted | active-PR | Complements ADR 0029; does not supersede ADR 0014. Unique on GET-status 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: - **analysis-run scientific-acceptance GET:** ADR 0027. - **analysis-run status consumer parity:** ADR 0028. - **analysis-run status CLI:** ADR 0029. +- **analysis-run wait CLI:** ADR 0030. ## Change and supersession rule diff --git a/docs/connectors/naruon-artifact-consumer.md b/docs/connectors/naruon-artifact-consumer.md index 4ce03e5b1..021881567 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 status | `tepp_api` `naruon_analysis_run_status_exchange` → `GET /v1/analysis-runs/{run_id}` | naruon → TEPP | | HTTP analysis-run status (LineageWeave) | `tepp_api` `lineageweave_analysis_run_status_exchange` → `GET /v1/analysis-runs/{run_id}` | lineageweave → TEPP | | CLI analysis-run status | `tepp_api` `tepp-analysis-runs status` → loopback `GET /v1/analysis-runs/{run_id}` | naruon → TEPP | +| CLI analysis-run wait | `tepp_api` `tepp-analysis-runs wait` → loopback poll of `GET /v1/analysis-runs/{run_id}` until terminal | 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 | diff --git a/docs/research/analysis-run-wait-cli.md b/docs/research/analysis-run-wait-cli.md new file mode 100644 index 000000000..1161d8dd3 --- /dev/null +++ b/docs/research/analysis-run-wait-cli.md @@ -0,0 +1,52 @@ +# Analysis-run wait CLI (doctoring) + +## Scope + +`tepp-analysis-runs wait` is the operator-visible poll client of loopback +`GET /v1/analysis-runs/{run_id}`. HTTP method, path, and header semantics +follow current HTTP semantics (Fielding, Nottingham, & Reschke, 2022). +Fail-closed refusal of non-loopback hosts, unpublished consumers, +review/Copilot/GitHub credential flags, unbounded waits, and +scientific-authority promotion is repository contract authority (ADR 0030; +ADR 0029; ADR 0027; ADR 0018; ADR 0011), not an RFC inference rule. + +CLI stdout reuses status gates. Accepted, running, and failed remain +metric-free. `tepp.scientific_acceptance.v1` appears only on succeeded +`scientific_acceptance_v1`. Process exit 0 is not a completed temporal model, +calibrated score, theta estimate, uncertainty statement, or scientific claim. + +## Authority + +### External standards (HTTP only) + +Fielding, R., Nottingham, M., & Reschke, J. (Eds.). (2022). *HTTP semantics* +(RFC 9110). IETF. https://doi.org/10.17487/RFC9110 + +RFC 9110 §9.3.1 describes GET as a method for retrieving a representation of +the target resource. TEPP maps repeated retrieval onto a bounded wait for +terminal status. The RFC does not define psychometric acceptance, RMSE, or +claim promotion. + +### Internal contract evidence + +- `docs/adr/0030-analysis-run-wait-cli.md` — this client +- `docs/adr/0029-analysis-run-status-cli.md` — single-shot status client +- `docs/adr/0027-scientific-acceptance-http-status.md` — GET-by-id listener +- `docs/adr/0014-scientific-claim-promotion-and-release-evidence.md` — CLI + success is not a scientific claim +- `crates/tepp_api/tests/analysis_run_wait_cli_contract.rs` — fail-closed wait + CLI proofs + +## Verification + +- `tepp-analysis-runs wait` of a failed run returns metric-free failed status + without RMSE/bias/coverage/SE-gate keys or `tepp.scientific_acceptance.v1`; +- wait of an accepted run with `--timeout-ms 0` fails closed; +- non-loopback hosts, credential flags, nonempty stdin, and unknown verbs fail + closed. + +## Non-claims + +This slice does not implement GET-by-id HTTP, status CLI, lifecycle POST, +cancel/create/retry/lookup/retry-lineage CLIs, persistence, production TLS, +Leiden consensus, or an ADR 0014 scientific claim-promotion package.