diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 8a1a1ddc1..58e6e7707 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -70,7 +70,7 @@ boundaries above remain the target modular MSA architecture. | `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 | -| `analysis_engine` | bounded cutoff-safe temporal evidence readiness execution, digest-bound terminal artifacts, GAP-003A scientific-acceptance validation runs (`tepp.scientific_acceptance.v1`; not implemented-main), loopback `POST /v1/analysis-runs/{run_id}/execute` that produces that artifact without a caller-supplied payload, the published `tepp-loopback` binary that binds that wrapper, typed naruon/`LineageWeave` execute exchanges, and HTTP/1.1 rendering of those exchanges onto the spawned `tepp-loopback` TCP listener | +| `analysis_engine` | bounded cutoff-safe temporal evidence readiness execution, digest-bound terminal artifacts, GAP-003A scientific-acceptance validation runs (`tepp.scientific_acceptance.v1`; not implemented-main), loopback `POST /v1/analysis-runs/{run_id}/execute` that produces that artifact without a caller-supplied payload, the published `tepp-loopback` binary that binds that wrapper, typed naruon/`LineageWeave` execute exchanges, HTTP/1.1 rendering of those exchanges onto the spawned `tepp-loopback` TCP listener, and the published `tepp-execute` CLI that POSTs those typed exchanges | | `episode_membership` | event-time episode membership containment gate | | `prompt_source` | prompt boilerplate is not unique latent content and not stopword deletion | | `corpus_background` | corpus-background wording is not unique latent content and not stopword deletion | @@ -113,7 +113,7 @@ boundaries above remain the target modular MSA architecture. | `episode_membership` | episode membership cannot escape the episode event-time interval | | `membership_target` | language, episode, template, department, and opportunity-pool targets cannot collapse into entity or project | | `topic_measurement` | logistic-normal ALR/ILR coordinates and the CPU `f64` TRSL-TM reference estimator | -| `analysis_engine` | bounded cutoff-safe temporal evidence readiness execution, digest-bound terminal artifacts, GAP-003A scientific-acceptance validation runs (`tepp.scientific_acceptance.v1`; not implemented-main), loopback `POST /v1/analysis-runs/{run_id}/execute` that produces that artifact without a caller-supplied payload, the published `tepp-loopback` binary that binds that wrapper, typed naruon/`LineageWeave` execute exchanges, and HTTP/1.1 rendering of those exchanges onto the spawned `tepp-loopback` TCP listener | +| `analysis_engine` | bounded cutoff-safe temporal evidence readiness execution, digest-bound terminal artifacts, GAP-003A scientific-acceptance validation runs (`tepp.scientific_acceptance.v1`; not implemented-main), loopback `POST /v1/analysis-runs/{run_id}/execute` that produces that artifact without a caller-supplied payload, the published `tepp-loopback` binary that binds that wrapper, typed naruon/`LineageWeave` execute exchanges, HTTP/1.1 rendering of those exchanges onto the spawned `tepp-loopback` TCP listener, and the published `tepp-execute` CLI that POSTs those typed exchanges | | `psychometric_core` | posterior-aware structural input gates, CWC within/between OLS plus the contextual effect, event-time log-rate, unequal-interval discrete-lag remapping, constant-predictor discrete effect, time-varying-predictor discrete effect (Eq. 14), exact scalar discrete process noise (Driver et al., 2017, Eq. 3), lagged latent covariance and unconditional latent variance (Driver et al., 2017, Eq. 3–4), stationary within-subject variance (Driver et al., 2017, Eq. 4 as `Δt → ∞`; `asymDIFFUSION`), trait-plus-state variance (Driver et al., 2017, §4.3 `TRAITVAR`; not process noise), observed-indicator variance and lagged observed covariance (Driver et al., 2017, Eq. 5; Table 2 `MANIFESTVAR` is `Θ`, not `Var(y)`; `MANIFESTTRAITVAR` is not `MANIFESTVAR`; `Θ` does not enter lagged observed covariance; observed-indicator mean is `τ + λ μ`; `MANIFESTMEANS` is not `E(y)`; `CINT` is not `MANIFESTMEANS`; discrete latent mean is `exp(a Δt) μ_0 + (exp(a Δt) − 1)/a κ`; `T0MEANS` is not `μ_t`; `CINT` is not the discrete increment; evolved observed mean is `τ + λ μ_t`; `τ + λ μ_0` is not `E(y_t)`; contemporaneous `TDPREDEFFECT` impulse is `m x`, not `CINT`, not `TIPREDEFFECT`, and not Voelkle Eq. 14; Eq. 5 of that contemporaneous impulse is `τ + λ(μ_t + m x)`, and `τ + λ μ_t` is not that observed mean; time-independent `TIPREDEFFECT` increment is `A^{-1}[e^{A Δt} − I] B z`, not `CINT`, not `M x`, not Voelkle Eq. 14, and not the coefficient `B`; Eq. 5 of that increment is `τ + λ(μ_t + A^{-1}[e^{A Δt} − I] B z)`, and `τ + λ μ_t` is not that observed mean; `τ + λ(μ_t + m x)` is not that observed mean; `τ + λ(μ_t + e^{a(t−u)} m x)` is not that observed mean when `u ≠ t`; within-interval `TDPREDEFFECT` carry is `e^{A(t−u)} M x` for `t0 < u < t`, not the contemporaneous Dirac, not `CINT`, not `TIPREDEFFECT`, and not Voelkle Eq. 14; Eq. 5 of that carry is `τ + λ(μ_t + e^{a(t−u)} m x)`, and `τ + λ μ_t` is not that observed mean; `τ + λ(μ_t + m x)` is not that carried observed mean when `u ≠ t`; first-occasion `T0TIPREDEFFECT` shift is `t0_b z` and Eq. 3 first-summand carry is `e^{A Δt} t0_b z` (`T0TIPREDEFFECT` is not `TIPREDEFFECT` `B`; `t0_b z` is not `A^{-1}[e^{A Δt} − I] B z`; `e^{A Δt} t0_b z` is not `t0_b z`; Eq. 5 of that carry is `τ + λ(μ_t + e^{a Δt} t0_b z)`, and `τ + λ μ_t` is not that observed mean; `τ + λ(μ_t + A^{-1}[e^{A Δt} − I] B z)` is not that observed mean), first-occasion `T0TDPREDEFFECT` shift is `t0_m x0` and Eq. 3 first-summand carry is `e^{A Δt} t0_m x0` (`T0TDPREDEFFECT` is not `TDPREDEFFECT` `M`; `t0_m x0` is not `M x`; `e^{A Δt} t0_m x0` is not `t0_m x0`; `e^{A Δt} t0_m x0` is not `e^{A(t−u)} M x` for `t0 < u < t`; `t0_m x0` is not `t0_b z`; an impulse at `u ≤ t0` that used `M` is already in `η(t0)` as `TDPREDEFFECT`, not as `T0TDPREDEFFECT`; Eq. 5 of that carry is `τ + λ(μ_t + e^{a Δt} t0_m x0)`, and `τ + λ μ_t` is not that observed mean; `τ + λ(μ_t + A^{-1}[e^{A Δt} − I] B z)` is not that observed mean; `τ + λ(μ_t + e^{a Δt} t0_b z)` is not that observed mean; §7.2 level-change `CINT` is `κ = −a m x` with `a < 0` so `−κ / a = m x` (`−a m x` is not the dissipating Dirac, not a free `CINT`, not `TIPREDEFFECT`, and not the extra near-zero-drift latent process also named in §7.2; Eq. 3 of that setting is `(1 − e^{a Δt}) m x`, which is not `m x`, not `κ`, and not `TIPREDEFFECT`; §7.2 extra-process contribution is `a_{ηξ} x (e^{ε Δt} − e^{a Δt}) / (ε − a)` (`ε = a` is `a_{ηξ} x Δt e^{a Δt}`; identification `TDPREDEFFECT` on the extra process is 1; printed extra `DRIFT` is `−0.000001`; not `κ = −a m x`, not `(1 − e^{a Δt}) m x`, and not the dissipating Dirac `m x`; `ε ≥ 0` fails closed; Eq. 5 of that contribution is `τ + λ(μ_t + a_{ηξ} x (e^{ε Δt} − e^{a Δt}) / (ε − a)`; the extra process has `LAMBDA` 0 and is not an observed indicator; `τ + λ μ_t` is not that observed mean; `τ + λ(μ_t + m x)` is not that observed mean; the contribution is not `E(y_t)`; the evolved-plus-contribution latent mean is not `E(y_t)`; after-t0 extra-process `TDPREDEFFECT` is `a_{ηξ} x (e^{ε(t−u)} − e^{a(t−u)}) / (ε − a)` for `t0 < u < t` while `μ_t` uses `Δt`; Eq. 5 of that after-t0 contribution is `τ + λ(μ_t + a_{ηξ} x (e^{ε(t−u)} − e^{a(t−u)}) / (ε − a)`; the first-occasion extra-process observed mean is not that observed mean when `u ≠ t0`; `e^{a(t−u)} m x` is a Dirac on the original process, not this `DRIFT` drive; §7.2 `asymTIPREDEFFECT` is `-B z / a` for `a < 0` (`-B z / a` is not the coefficient `B`, not `A^{-1}[e^{A Δt} − I] B z`, not `CINT`, and not `M x`; §7.2 `addedTIPREDVAR` is `(B / a)² v`, not `TRAITVAR`, not `asymDIFFUSION`, and not `-B z / a`; Table 2 `asymCINT` is `-κ / a` for `a < 0` and is not `κ`, not `A^{-1}[e^{A Δt} − I] κ`, not `T0MEANS`, and not `-B z / a`; p. 16 stationary `T0MEANS` is `-κ / a + −B z / a` and is not free `T0MEANS`, not `asymCINT` alone, not `asymTIPREDEFFECT` alone, and not the finite-interval discrete latent mean; Eq. 5 of that constrained mean is `τ + λ(−κ / a + −B z / a)`; `τ + λ μ_0` is not that observed mean; `τ + λ(−κ / a)` is not that observed mean when `B z ≠ 0`; `τ + λ μ_t` is not that observed mean; `MANIFESTMEANS` is not `E(y_0)`; the constrained latent mean is not `E(y_0)`; stationary `T0VAR` is `trait + −q / (2 a) + (B / a)² v` (not free `T0VAR`, not `asymDIFFUSION` alone, not `TRAITVAR` alone, not `addedTIPREDVAR` alone, and not the finite-interval discrete latent variance. Eq. 5 of that constrained variance is `λ²(trait + −q / (2 a) + (B / a)² v) + θ + ψ` (JSS PDF re-opened 2026-08-22T03:20Z; form the stationary latent variance first, then `λ² p + θ + ψ`; `λ² p_0` is not that observed variance; `λ²(−q / (2 a)) + θ` is not that observed variance when `TRAITVAR` or `addedTIPREDVAR` is nonzero; `MANIFESTVAR` is not `Var(y_0)`; the constrained latent variance is not `Var(y_0)`); lagged stationary `T0VAR` is `trait + e^{a Δt}(−q / (2 a)) + (B / a)² v` (trait and `addedTIPREDVAR` do not decay; contemporaneous `T0VAR` is not that lagged map; decaying the constrained total as if it were all state is not that lagged map; Eq. 5 of that lagged covariance is `λ²(trait + e^{a Δt}(−q / (2 a)) + (B / a)² v) + ψ`; `Θ` does not enter; contemporaneous `Var(y_0)` is not that lagged observed covariance; the lagged latent covariance is not that observed covariance); later-occasion stationary `T0VAR` is `trait + e^{2 a Δt}(−q / (2 a)) + Q_Δt + (B / a)² v` (trait and `addedTIPREDVAR` do not enter `Q_Δt`; under stationarity that composition equals contemporaneous `T0VAR`; evolving the constrained total as if it were all state is not that later map; the lagged covariance omits `Q_Δt`; `Q_Δt` is not that later map; Eq. 5 of that later-occasion variance is `λ²(trait + e^{2 a Δt}(−q / (2 a)) + Q_Δt + (B / a)² v) + θ + ψ`; lagged observed covariance omits `Q_Δt` and `θ`; `MANIFESTVAR` is not `Var(y_t)`; the later-occasion latent variance is not `Var(y_t)`))), irregular already-centered residual lag, Rubin `T` on OLS loadings, and strong-gated latent means (two-observation residual variance is identically `0` and caps at strong/scalar; Putnick & Bornstein, 2016) | | `validation_core` | RMSE, bias, coverage, graph, and Monte Carlo metrics | | `tepp_api` | versioned DTO, schema, and export contracts | diff --git a/CHANGELOG.d/scientific-acceptance-execute-cli.md b/CHANGELOG.d/scientific-acceptance-execute-cli.md new file mode 100644 index 000000000..10df7d77c --- /dev/null +++ b/CHANGELOG.d/scientific-acceptance-execute-cli.md @@ -0,0 +1,3 @@ +### Added + +- `analysis_engine` GAP-003A execute CLI slice (ADR 0041, active-PR, not implemented-main): published `tepp-execute` POSTs typed naruon and `LineageWeave` execute exchanges onto spawned `tepp-loopback` TCP so operators obtain `tepp.scientific_acceptance.v1` without writing HTTP. Public bind hosts and `localhost` fail closed. Persistence remains GAP-003B. diff --git a/CHANGELOG.md b/CHANGELOG.md index 61c816b2c..09cdc591b 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] +- `analysis_engine` GAP-003A execute CLI slice (ADR 0041, active-PR, not implemented-main): published `tepp-execute` POSTs typed naruon and `LineageWeave` execute exchanges onto spawned `tepp-loopback` TCP so operators obtain `tepp.scientific_acceptance.v1` without writing HTTP. Public bind hosts, `localhost`, credential-shaped flags, empty stdin, LLM recovery, metric keys, and `http://` origins fail closed. This does not duplicate the TCP renderer (#382), execute builders (#381), published binary (#375), engine-execute (#370), lifecycle CLI (#362), create CLI (#385), cancel consumer-parity (#373), cancel CLI (#378), stored-request GET (#377), stored-request consumer-parity (#387), retry-children (#379), idempotency (#380), retry-parent (#384), collection GET (#368), GET (#359), lifecycle POST (#360), cancel HTTP (#361), collection CLI (#371), retry (#369), engine-library (#356), DTO (#358), or persistence (#287); persistence remains GAP-003B. + - `analysis_engine` GAP-003A execute-exchange loopback TCP slice (ADR 0037, active-PR, not implemented-main): `loopback_http1_from_execute_exchange` renders typed naruon and `LineageWeave` execute exchanges onto the spawned `tepp-loopback` TCP listener so POST `/execute` is published consumer HTTP without embedding the library. Public bind hosts, `localhost`, and non-execute exchanges fail closed. This does not duplicate the execute consumer-exchange builders (#381), published binary (#375), engine-execute library (#370), cancel consumer parity (#373), loopback CLI (#362), collection CLI (#371), retry (#369), GET (#359), lifecycle POST (#360), cancel HTTP (#361), collection GET (#368), DTO (#358), or engine library (#356); persistence remains GAP-003B. - `analysis_engine` GAP-003A execute consumer-exchange slice (ADR 0034, active-PR, not implemented-main): `naruon_analysis_run_execute_exchange` and `lineageweave_analysis_run_execute_exchange` mint credential-free HTTPS `POST /v1/analysis-runs/{run_id}/execute` so naruon and `LineageWeave` obtain `tepp.scientific_acceptance.v1` without hand-rolled HTTP or a caller-supplied artifact. The typed body refuses `scientific_acceptance_json`, receipt metric keys, and LLM-authored recovery. Non-`https` origins fail closed. This does not duplicate the published binary (#375), engine-execute library (#370), cancel consumer parity (#373), loopback CLI (#362), collection CLI (#371), retry (#369), GET (#359), lifecycle POST (#360), cancel HTTP (#361), collection GET (#368), DTO (#358), or engine library (#356); persistence remains GAP-003B. diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 8d3a3ecc7..34ca255a4 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -63,6 +63,7 @@ TEPP's approved PRD v0.4 and implementation plan are the primary product baselin | Scientific-acceptance published loopback binary doctoring | [`docs/research/scientific-acceptance-loopback-binary.md`](docs/research/scientific-acceptance-loopback-binary.md) | | Scientific-acceptance execute consumer-exchange doctoring | [`docs/research/scientific-acceptance-execute-consumer-exchange.md`](docs/research/scientific-acceptance-execute-consumer-exchange.md) | | Scientific-acceptance execute-exchange loopback TCP doctoring | [`docs/research/scientific-acceptance-execute-exchange-loopback-tcp.md`](docs/research/scientific-acceptance-execute-exchange-loopback-tcp.md) | +| Scientific-acceptance execute CLI doctoring | [`docs/research/scientific-acceptance-execute-cli.md`](docs/research/scientific-acceptance-execute-cli.md) | | Retention/deletion/legal-hold doctoring | [`docs/research/retention-deletion-legal-hold.md`](docs/research/retention-deletion-legal-hold.md) | | Provider-payload minimization doctoring | [`docs/research/provider-payload-minimization.md`](docs/research/provider-payload-minimization.md) | | Relation absence is not negative evidence | [`docs/research/relation-absence-not-negative.md`](docs/research/relation-absence-not-negative.md) | @@ -157,6 +158,7 @@ TEPP's approved PRD v0.4 and implementation plan are the primary product baselin | Scientific-acceptance published loopback binary doctoring | [`docs/research/scientific-acceptance-loopback-binary.md`](docs/research/scientific-acceptance-loopback-binary.md) | | Scientific-acceptance execute consumer-exchange doctoring | [`docs/research/scientific-acceptance-execute-consumer-exchange.md`](docs/research/scientific-acceptance-execute-consumer-exchange.md) | | Scientific-acceptance execute-exchange loopback TCP doctoring | [`docs/research/scientific-acceptance-execute-exchange-loopback-tcp.md`](docs/research/scientific-acceptance-execute-exchange-loopback-tcp.md) | +| Scientific-acceptance execute CLI doctoring | [`docs/research/scientific-acceptance-execute-cli.md`](docs/research/scientific-acceptance-execute-cli.md) | | Retention/deletion/legal-hold doctoring | [`docs/research/retention-deletion-legal-hold.md`](docs/research/retention-deletion-legal-hold.md) | | Stopword-deletion doctoring | [`docs/research/stopword-deletion.md`](docs/research/stopword-deletion.md) | | Provider-payload minimization doctoring | [`docs/research/provider-payload-minimization.md`](docs/research/provider-payload-minimization.md) | diff --git a/Dockerfile b/Dockerfile index 682c0a18e..7f9992f3e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,13 +1,14 @@ FROM rust:1.98.0-bookworm@sha256:e70e2eec3d495fd5c8e0be74adda86507dfac7f51a724fbf9813ff59b2b247c7 AS build WORKDIR /src COPY . . -RUN cargo build --locked --release -p analysis_engine --bin tepp-loopback +RUN cargo build --locked --release -p analysis_engine --bin tepp-loopback --bin tepp-execute FROM debian:bookworm-slim@sha256:abd67ffcfa541b485a3dff59865ab629aa048a6c613e639d36e7456b0b229241 RUN apt-get update \ && apt-get install --yes --no-install-recommends ca-certificates curl \ && rm -rf /var/lib/apt/lists/* COPY --from=build /src/target/release/tepp-loopback /usr/local/bin/tepp-loopback +COPY --from=build /src/target/release/tepp-execute /usr/local/bin/tepp-execute USER 65532:65532 HEALTHCHECK --interval=10s --timeout=3s --start-period=2s --retries=5 \ CMD curl --fail --silent --show-error \ diff --git a/crates/analysis_engine/Cargo.toml b/crates/analysis_engine/Cargo.toml index 460d3ef7f..615b2ea91 100644 --- a/crates/analysis_engine/Cargo.toml +++ b/crates/analysis_engine/Cargo.toml @@ -35,5 +35,11 @@ path = "src/bin/tepp_loopback.rs" test = false bench = false +[[bin]] +name = "tepp-execute" +path = "src/bin/tepp_execute.rs" +test = false +bench = false + [lints] workspace = true diff --git a/crates/analysis_engine/src/bin/tepp_execute.rs b/crates/analysis_engine/src/bin/tepp_execute.rs new file mode 100644 index 000000000..be9305208 --- /dev/null +++ b/crates/analysis_engine/src/bin/tepp_execute.rs @@ -0,0 +1,32 @@ +//! Operator CLI for POST /execute through typed naruon/`LineageWeave` exchanges. + +use std::io::{self, IsTerminal}; +use std::process::ExitCode; + +use analysis_engine::{ + ScientificAcceptanceExecuteCliInvocation, execute_scientific_acceptance_execute_cli, + read_scientific_acceptance_execute_cli_stdin, render_scientific_acceptance_execute_cli_stdout, +}; +use tepp_api::ApiError; + +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_scientific_acceptance_execute_cli_stdin(io::stdin().is_terminal(), io::stdin())?; + let invocation = ScientificAcceptanceExecuteCliInvocation::from_args(&args, body)?; + let response = execute_scientific_acceptance_execute_cli(&invocation)?; + let stdout = render_scientific_acceptance_execute_cli_stdout(&invocation, &response)?; + println!("{stdout}"); + if (200..300).contains(&response.status_code) { + Ok(()) + } else { + Err(ApiError::InvalidWirePayload) + } +} diff --git a/crates/analysis_engine/src/lib.rs b/crates/analysis_engine/src/lib.rs index 70a4a8472..0220fac22 100644 --- a/crates/analysis_engine/src/lib.rs +++ b/crates/analysis_engine/src/lib.rs @@ -15,11 +15,13 @@ //! binds that wrapper so `POST /v1/analysis-runs/{run_id}/execute` is reachable //! on the loopback listener without embedding this crate. Naruon and //! `LineageWeave` mint that POST through typed execute exchanges in this crate -//! and render them onto the spawned `tepp-loopback` TCP listener. +//! and render them onto the spawned `tepp-loopback` TCP listener. The published +//! `tepp-execute` CLI POSTs those typed exchanges so operators do not write HTTP. mod case_deletion_refit; mod lineage_criterion; mod loopback_execute; +mod loopback_execute_cli; mod topic_context_posterior; mod topic_lineage_artifact; mod validation_run; @@ -64,6 +66,13 @@ pub use loopback_execute::{ lineageweave_analysis_run_execute_exchange, loopback_http1_from_execute_exchange, loopback_http1_from_naruon_exchange, naruon_analysis_run_execute_exchange, }; +/// Operator loopback CLI that POSTs typed execute exchanges to `tepp-loopback`. +pub use loopback_execute_cli::{ + ScientificAcceptanceExecuteCliInvocation, ScientificAcceptanceExecuteCliVerb, + compose_scientific_acceptance_execute_cli_http, dispatch_scientific_acceptance_execute_cli, + execute_scientific_acceptance_execute_cli, read_scientific_acceptance_execute_cli_stdin, + render_scientific_acceptance_execute_cli_stdout, +}; /// Bounded posterior topic-context producer contract and record types. pub use topic_context_posterior::{ TOPIC_CONTEXT_POSTERIOR_BYTE_LIMIT, TOPIC_CONTEXT_POSTERIOR_SCHEMA_VERSION, diff --git a/crates/analysis_engine/src/loopback_execute_cli.rs b/crates/analysis_engine/src/loopback_execute_cli.rs new file mode 100644 index 000000000..2bd098251 --- /dev/null +++ b/crates/analysis_engine/src/loopback_execute_cli.rs @@ -0,0 +1,699 @@ +//! Operator loopback CLI for scientific-acceptance execute. +//! +//! GAP-003A execute-CLI slice: operators run `tepp-execute execute` to POST +//! `/v1/analysis-runs/{run_id}/execute` from the typed naruon/`LineageWeave` +//! execute exchange onto spawned `tepp-loopback` TCP. This module does not +//! duplicate the TCP renderer (#382), execute builders (#381), published +//! binary (#375), engine-execute (#370), lifecycle CLI (#362), create CLI +//! (#385), cancel consumer-parity (#373), cancel CLI (#378), stored-request +//! GET (#377), stored-request consumer-parity (#387), retry-children (#379), +//! idempotency (#380), retry-parent (#384), collection GET (#368), GET +//! (#359), lifecycle POST (#360), cancel HTTP (#361), collection CLI (#371), +//! retry (#369), engine-library (#356), DTO (#358), persistence (#287), +//! Leiden (#351), Driver p.16, CWC/Rubin/ESEM/OLS, GAP-010, or GAP-003C. +//! Persistence remains GAP-003B. + +use std::io::{Read, Write}; +use std::net::{SocketAddr, TcpStream}; +use std::time::Duration; + +use tepp_api::{ + ApiError, LINEAGEWEAVE_CONSUMER_CODE, NARUON_CONSUMER_CODE, NaruonLiveResponse, + SCIENTIFIC_ACCEPTANCE_HTTP_SCHEMA, +}; + +use crate::{ + ScientificAcceptanceExecuteRequest, ScientificAcceptanceLoopbackService, + lineageweave_analysis_run_execute_exchange, loopback_http1_from_execute_exchange, + naruon_analysis_run_execute_exchange, +}; + +/// Bound for one CLI TCP round-trip against spawned `tepp-loopback`. +const EXECUTE_CLI_IO_TIMEOUT: Duration = Duration::from_secs(5); + +/// Supported operator verbs for the loopback execute CLI. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ScientificAcceptanceExecuteCliVerb { + /// `POST /v1/analysis-runs/{run_id}/execute`. + Execute, +} + +impl ScientificAcceptanceExecuteCliVerb { + /// Parse one exact lowercase verb token. + /// + /// # Errors + /// + /// Returns [`ApiError::InvalidWirePayload`] for an unknown token. + pub fn parse(token: &str) -> Result { + match token { + "execute" => Ok(Self::Execute), + _ => Err(ApiError::InvalidWirePayload), + } + } + + /// Return the canonical lowercase verb token. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Execute => "execute", + } + } +} + +/// One operator CLI invocation against spawned `tepp-loopback` TCP. +#[derive(Clone, Debug, PartialEq)] +pub struct ScientificAcceptanceExecuteCliInvocation { + /// CLI verb to execute. + pub verb: ScientificAcceptanceExecuteCliVerb, + /// Loopback `host:port` printed by `tepp-loopback`. + pub host: String, + /// Published HTTPS origin kept on the typed execute exchange. + pub origin: String, + /// Published modular consumer (`naruon` or `lineageweave`). + pub consumer: String, + /// Typed metric-free execute body. + pub execute: ScientificAcceptanceExecuteRequest, +} + +impl ScientificAcceptanceExecuteCliInvocation { + /// Parse argv plus stdin body into a validated loopback execute invocation. + /// + /// # Errors + /// + /// Returns a fail-closed error for unknown verbs, missing required flags, a + /// non-loopback host, `localhost`, an unpublished consumer, credential-shaped + /// flags, empty stdin, LLM recovery, metric keys, or a body that disagrees + /// with optional `--run-id` / `--idempotency-key`. + 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 = ScientificAcceptanceExecuteCliVerb::parse(verb_token)?; + let flags = parse_flags(rest)?; + let body = body.into(); + assemble_invocation(verb, flags, &body) + } + + /// Reject a non-loopback host, unpublished consumer, or hostile execute body. + /// + /// # Errors + /// + /// Returns [`ApiError::AuthorizationDenied`] for a non-loopback host and + /// [`ApiError::InvalidWirePayload`] for empty or unpublished fields. + pub fn validate(&self) -> Result<(), ApiError> { + require_loopback_host(&self.host)?; + require_nonempty(&self.origin)?; + require_nonempty(&self.consumer)?; + if self.consumer != NARUON_CONSUMER_CODE && self.consumer != LINEAGEWEAVE_CONSUMER_CODE { + return Err(ApiError::InvalidWirePayload); + } + self.execute.to_json()?; + self.typed_execute_exchange()?; + Ok(()) + } + + fn typed_execute_exchange(&self) -> Result { + if self.consumer == LINEAGEWEAVE_CONSUMER_CODE { + lineageweave_analysis_run_execute_exchange(&self.origin, &self.execute) + } else if self.consumer == NARUON_CONSUMER_CODE { + naruon_analysis_run_execute_exchange(&self.origin, &self.execute) + } else { + Err(ApiError::InvalidWirePayload) + } + } +} + +struct ParsedFlags { + host: Option, + origin: Option, + consumer: Option, + idempotency_key: Option, + run_id: Option, +} + +fn parse_flags(rest: &[String]) -> Result { + let mut flags = ParsedFlags { + host: None, + origin: None, + consumer: None, + idempotency_key: None, + run_id: None, + }; + let mut index = 0; + while index < rest.len() { + let flag = rest[index].as_str(); + if !flag.starts_with("--") { + return Err(ApiError::InvalidWirePayload); + } + let name = &flag[2..]; + if flag_is_credential(name) { + return Err(ApiError::AuthorizationDenied); + } + let slot = match name { + "host" => &mut flags.host, + "origin" => &mut flags.origin, + "consumer" => &mut flags.consumer, + "idempotency-key" => &mut flags.idempotency_key, + "run-id" => &mut flags.run_id, + _ => return Err(ApiError::InvalidWirePayload), + }; + if slot.is_some() || index + 1 >= rest.len() { + return Err(ApiError::InvalidWirePayload); + } + let value = rest[index + 1].as_str(); + require_nonempty(value)?; + *slot = Some(value.to_owned()); + index += 2; + } + Ok(flags) +} + +fn assemble_invocation( + verb: ScientificAcceptanceExecuteCliVerb, + flags: ParsedFlags, + body: &str, +) -> Result { + if body.is_empty() { + return Err(ApiError::InvalidWirePayload); + } + let execute = ScientificAcceptanceExecuteRequest::from_json(body)?; + if let Some(run_id) = flags.run_id.as_deref() + && run_id != execute.run_id + { + return Err(ApiError::InvalidWirePayload); + } + if let Some(idempotency_key) = flags.idempotency_key.as_deref() + && idempotency_key != execute.idempotency_key + { + return Err(ApiError::InvalidWirePayload); + } + let invocation = ScientificAcceptanceExecuteCliInvocation { + 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()), + execute, + }; + invocation.validate()?; + Ok(invocation) +} + +fn require_loopback_host(host: &str) -> Result { + let addr: SocketAddr = host.parse().map_err(|_| ApiError::InvalidWirePayload)?; + if addr.ip().is_loopback() { + Ok(addr) + } else { + Err(ApiError::AuthorizationDenied) + } +} + +fn require_nonempty(value: &str) -> Result<(), ApiError> { + if value.is_empty() { + Err(ApiError::InvalidWirePayload) + } else { + Ok(()) + } +} + +fn flag_is_credential(name: &str) -> bool { + let lower = name.to_ascii_lowercase(); + lower.contains("authorization") || lower.contains("token") || lower.contains("copilot") +} + +/// Compose HTTP/1.1 from the typed naruon/`LineageWeave` execute exchange. +/// +/// # Errors +/// +/// Returns the same fail-closed errors as the typed execute builders and +/// [`loopback_http1_from_execute_exchange`]. +pub fn compose_scientific_acceptance_execute_cli_http( + invocation: &ScientificAcceptanceExecuteCliInvocation, +) -> Result { + invocation.validate()?; + loopback_http1_from_execute_exchange(&invocation.typed_execute_exchange()?, &invocation.host) +} + +/// Dispatch one execute CLI invocation against an in-process loopback service. +/// +/// # Errors +/// +/// Returns fail-closed validation errors before the HTTP handler runs. +pub fn dispatch_scientific_acceptance_execute_cli( + service: &mut ScientificAcceptanceLoopbackService, + invocation: &ScientificAcceptanceExecuteCliInvocation, +) -> Result { + let request = compose_scientific_acceptance_execute_cli_http(invocation)?; + Ok(service.handle_http_request(&request)) +} + +/// Execute one CLI invocation over loopback TCP against `tepp-loopback`. +/// +/// # Errors +/// +/// Returns fail-closed validation, transport, or response-framing errors. +pub fn execute_scientific_acceptance_execute_cli( + invocation: &ScientificAcceptanceExecuteCliInvocation, +) -> Result { + let addr = require_loopback_host(&invocation.host)?; + let request = compose_scientific_acceptance_execute_cli_http(invocation)?; + let mut stream = TcpStream::connect(addr).map_err(|_| ApiError::InvalidWirePayload)?; + stream + .set_read_timeout(Some(EXECUTE_CLI_IO_TIMEOUT)) + .map_err(|_| ApiError::InvalidWirePayload)?; + stream + .set_write_timeout(Some(EXECUTE_CLI_IO_TIMEOUT)) + .map_err(|_| ApiError::InvalidWirePayload)?; + stream + .write_all(request.as_bytes()) + .map_err(|_| ApiError::InvalidWirePayload)?; + stream.flush().map_err(|_| ApiError::InvalidWirePayload)?; + let mut bytes = Vec::new(); + stream + .read_to_end(&mut bytes) + .map_err(|_| ApiError::InvalidWirePayload)?; + parse_http_response(&bytes) +} + +/// Filter CLI stdout so a non-success execute never prints scientific acceptance. +/// +/// # Errors +/// +/// Returns [`ApiError::InvalidWirePayload`] when a success body omits the +/// engine-produced schema or a non-success body still carries it. +pub fn render_scientific_acceptance_execute_cli_stdout( + invocation: &ScientificAcceptanceExecuteCliInvocation, + response: &NaruonLiveResponse, +) -> Result { + invocation.validate()?; + if response.body.is_empty() { + return Err(ApiError::InvalidWirePayload); + } + if (200..300).contains(&response.status_code) { + if !response.body.contains(SCIENTIFIC_ACCEPTANCE_HTTP_SCHEMA) { + return Err(ApiError::InvalidWirePayload); + } + } else if response.body.contains(SCIENTIFIC_ACCEPTANCE_HTTP_SCHEMA) { + return Err(ApiError::InvalidWirePayload); + } + Ok(response.body.clone()) +} + +/// Read stdin leftover bytes on a non-terminal; empty execute POST is refused. +/// +/// # Errors +/// +/// Returns [`ApiError::InvalidWirePayload`] when stdin cannot be read. +pub fn read_scientific_acceptance_execute_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) + } +} + +fn parse_http_response(bytes: &[u8]) -> Result { + let text = std::str::from_utf8(bytes).map_err(|_| ApiError::InvalidWirePayload)?; + let (header_block, body) = text + .split_once("\r\n\r\n") + .ok_or(ApiError::InvalidWirePayload)?; + let mut lines = header_block.split("\r\n"); + let status_line = lines.next().ok_or(ApiError::InvalidWirePayload)?; + let mut parts = status_line.split(' '); + if parts.next() != Some("HTTP/1.1") { + return Err(ApiError::InvalidWirePayload); + } + let code = parts + .next() + .ok_or(ApiError::InvalidWirePayload)? + .parse::() + .map_err(|_| ApiError::InvalidWirePayload)?; + let reason_phrase = static_reason(code)?; + let mut content_length = None; + for line in lines { + let (name, value) = line.split_once(':').ok_or(ApiError::InvalidWirePayload)?; + if name.eq_ignore_ascii_case("content-length") { + if content_length.is_some() { + return Err(ApiError::InvalidWirePayload); + } + content_length = Some( + value + .trim() + .parse::() + .map_err(|_| ApiError::InvalidWirePayload)?, + ); + } + } + let declared = content_length.ok_or(ApiError::InvalidWirePayload)?; + if declared != body.len() { + return Err(ApiError::InvalidWirePayload); + } + Ok(NaruonLiveResponse { + status_code: code, + reason_phrase, + body: body.to_owned(), + }) +} + +fn static_reason(code: u16) -> Result<&'static str, ApiError> { + match code { + 200 => Ok("OK"), + 202 => Ok("Accepted"), + 400 => Ok("Bad Request"), + 403 => Ok("Forbidden"), + 413 => Ok("Payload Too Large"), + 422 => Ok("Unprocessable Entity"), + _ => Err(ApiError::InvalidWirePayload), + } +} + +#[cfg(test)] +#[allow(clippy::too_many_lines)] +mod tests { + use super::{ + ScientificAcceptanceExecuteCliInvocation, ScientificAcceptanceExecuteCliVerb, + compose_scientific_acceptance_execute_cli_http, flag_is_credential, + read_scientific_acceptance_execute_cli_stdin, + render_scientific_acceptance_execute_cli_stdout, + }; + use crate::{ + ANALYSIS_RUN_EXECUTE_CONTRACT_VERSION, SCIENTIFIC_ACCEPTANCE_SCHEMA_VERSION, + VALIDATION_CPU_F64_MODEL, + }; + use tepp_api::{ + ANALYSIS_RUN_CONTRACT_VERSION, AnalysisRunRequest, ApiError, LINEAGEWEAVE_CONSUMER_CODE, + NARUON_CONSUMER_CODE, NaruonLiveResponse, SCIENTIFIC_ACCEPTANCE_HTTP_PROFILE, + SCIENTIFIC_ACCEPTANCE_HTTP_SCHEMA, naruon_analysis_run_exchange, + }; + + use super::dispatch_scientific_acceptance_execute_cli; + use crate::{ScientificAcceptanceLoopbackService, loopback_http1_from_naruon_exchange}; + + const HTTPS_ORIGIN: &str = "https://tepp.example.com"; + + fn execute_json(run_id: &str, idempotency_key: &str) -> String { + serde_json::json!({ + "contract_version": ANALYSIS_RUN_EXECUTE_CONTRACT_VERSION, + "run_id": run_id, + "idempotency_key": idempotency_key, + "seed": 42, + "se_gate_k": 3.0, + "completed_at": "2026-08-31T13:00:00Z", + "study_label": "loopback-cli-recovery", + "authored_by_llm": false, + "corpus": { + "snapshot_id": "snapshot-execute-cli", + "evidence_units": [ + { + "evidence_id": "evidence-1", + "event_time": "2026-07-01T00:00:00Z", + "available_time": "2026-07-10T00:00:00Z", + "membership_count": 1 + }, + { + "evidence_id": "evidence-2", + "event_time": "2026-07-01T00:00:00Z", + "available_time": "2026-07-20T00:00:00Z", + "membership_count": 1 + }, + { + "evidence_id": "future", + "event_time": "2026-07-01T00:00:00Z", + "available_time": "2026-08-02T00:00:00Z", + "membership_count": 1 + } + ] + }, + "truth": [0.70, 0.55, 0.40, -0.20, 0.85], + "recovered": [0.70, 0.55, 0.40, -0.20, 0.85], + "interval_lower": [0.50, 0.35, 0.20, -0.40, 0.65], + "interval_upper": [0.90, 0.75, 0.60, 0.00, 1.00], + "truth_times": [1.0, 2.0, 3.0, 4.0, 5.0], + "recovered_times": [1.1, 1.9, 3.2, 3.8, 5.1] + }) + .to_string() + } + + fn invocation( + host: &str, + consumer: &str, + run_id: &str, + idempotency_key: &str, + ) -> Result { + ScientificAcceptanceExecuteCliInvocation::from_args( + [ + "execute", + "--host", + host, + "--origin", + HTTPS_ORIGIN, + "--consumer", + consumer, + "--run-id", + run_id, + "--idempotency-key", + idempotency_key, + ], + execute_json(run_id, idempotency_key), + ) + } + + #[test] + fn verbs_parse_and_reject_unknown_tokens() { + assert_eq!( + ScientificAcceptanceExecuteCliVerb::parse("execute").expect("execute"), + ScientificAcceptanceExecuteCliVerb::Execute + ); + assert_eq!( + ScientificAcceptanceExecuteCliVerb::Execute.as_str(), + "execute" + ); + assert_eq!( + ScientificAcceptanceExecuteCliVerb::parse("EXECUTE"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + ScientificAcceptanceExecuteCliVerb::parse("create"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + ScientificAcceptanceExecuteCliVerb::parse("status"), + Err(ApiError::InvalidWirePayload) + ); + assert!(flag_is_credential("authorization")); + assert!(flag_is_credential("admin-token")); + assert!(flag_is_credential("copilot")); + assert!(!flag_is_credential("host")); + } + + #[test] + fn from_args_refuses_empty_public_bind_localhost_and_credentials() { + assert_eq!( + ScientificAcceptanceExecuteCliInvocation::from_args(Vec::::new(), "") + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + invocation("8.8.8.8:80", NARUON_CONSUMER_CODE, "tepp-run-1", "idem-1").unwrap_err(), + ApiError::AuthorizationDenied + ); + assert_eq!( + invocation( + "localhost:18081", + NARUON_CONSUMER_CODE, + "tepp-run-1", + "idem-1" + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + ScientificAcceptanceExecuteCliInvocation::from_args( + [ + "execute", + "--host", + "127.0.0.1:18081", + "--origin", + HTTPS_ORIGIN, + "--authorization", + "Bearer secret" + ], + execute_json("tepp-run-1", "idem-1") + ) + .unwrap_err(), + ApiError::AuthorizationDenied + ); + assert_eq!( + ScientificAcceptanceExecuteCliInvocation::from_args( + [ + "execute", + "--host", + "127.0.0.1:18081", + "--origin", + HTTPS_ORIGIN + ], + "" + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + let mut llm = + serde_json::from_str::(&execute_json("tepp-run-1", "idem-1")) + .expect("json"); + llm["authored_by_llm"] = serde_json::json!(true); + assert_eq!( + ScientificAcceptanceExecuteCliInvocation::from_args( + [ + "execute", + "--host", + "127.0.0.1:18081", + "--origin", + HTTPS_ORIGIN + ], + llm.to_string() + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + ScientificAcceptanceExecuteCliInvocation::from_args( + [ + "execute", + "--host", + "127.0.0.1:18081", + "--origin", + HTTPS_ORIGIN + ], + r#"{"rmse":1.0}"# + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + ScientificAcceptanceExecuteCliInvocation::from_args( + [ + "execute", + "--host", + "127.0.0.1:18081", + "--origin", + "http://tepp.example.com" + ], + execute_json("tepp-run-1", "idem-1") + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + ScientificAcceptanceExecuteCliInvocation::from_args( + [ + "execute", + "--host", + "127.0.0.1:18081", + "--origin", + HTTPS_ORIGIN, + "--run-id", + "other-run" + ], + execute_json("tepp-run-1", "idem-1") + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + read_scientific_acceptance_execute_cli_stdin(true, std::io::empty()).expect("terminal"), + "" + ); + } + + #[test] + fn compose_uses_typed_execute_exchange_without_credentials() { + let naruon = invocation( + "127.0.0.1:18081", + NARUON_CONSUMER_CODE, + "tepp-run-1", + "idem-naruon-execute-cli", + ) + .expect("naruon"); + let http = compose_scientific_acceptance_execute_cli_http(&naruon).expect("http"); + assert!(http.starts_with("POST /v1/analysis-runs/tepp-run-1/execute HTTP/1.1")); + assert!(http.contains("Host: 127.0.0.1:18081")); + assert!(http.contains("tepp-consumer: naruon")); + assert!(!http.to_ascii_lowercase().contains("authorization")); + assert!(!http.contains("scientific_acceptance_json")); + + let lineage = invocation( + "127.0.0.1:18081", + LINEAGEWEAVE_CONSUMER_CODE, + "tepp-run-1", + "idem-lineage-execute-cli", + ) + .expect("lineage"); + let lineage_http = + compose_scientific_acceptance_execute_cli_http(&lineage).expect("lineage http"); + assert!(lineage_http.contains("tepp-consumer: lineageweave")); + assert!(!lineage_http.contains("tepp-consumer: naruon")); + } + + #[test] + fn in_process_cli_execute_returns_scientific_acceptance() { + let mut service = ScientificAcceptanceLoopbackService::new(); + let request = AnalysisRunRequest { + contract_version: ANALYSIS_RUN_CONTRACT_VERSION, + idempotency_key: "idem-naruon-execute-cli".into(), + tenant_workspace_id: "tenant-workspace-execute-cli".into(), + snapshot_id: "snapshot-execute-cli".into(), + knowledge_cutoff: "2026-08-01T00:00:00Z".into(), + model_contract_version: VALIDATION_CPU_F64_MODEL.into(), + output_profile: SCIENTIFIC_ACCEPTANCE_HTTP_PROFILE.into(), + }; + let create = naruon_analysis_run_exchange(HTTPS_ORIGIN, &request).expect("create"); + let create_http = + loopback_http1_from_naruon_exchange(&create, "127.0.0.1:18081").expect("create http"); + let accepted = service.handle_http_request(&create_http); + assert_eq!(accepted.status_code, 202); + let run_id = serde_json::from_str::(&accepted.body) + .expect("accepted json")["run_id"] + .as_str() + .expect("run_id") + .to_owned(); + let invocation = invocation( + "127.0.0.1:18081", + NARUON_CONSUMER_CODE, + &run_id, + "idem-naruon-execute-cli", + ) + .expect("invocation"); + let response = dispatch_scientific_acceptance_execute_cli(&mut service, &invocation) + .expect("dispatch"); + assert_eq!(response.status_code, 200); + let stdout = render_scientific_acceptance_execute_cli_stdout(&invocation, &response) + .expect("stdout"); + assert!(stdout.contains(SCIENTIFIC_ACCEPTANCE_HTTP_SCHEMA)); + assert!(stdout.contains(SCIENTIFIC_ACCEPTANCE_SCHEMA_VERSION)); + assert_eq!( + render_scientific_acceptance_execute_cli_stdout( + &invocation, + &NaruonLiveResponse { + status_code: 400, + reason_phrase: "Bad Request", + body: format!("{{\"{SCIENTIFIC_ACCEPTANCE_HTTP_SCHEMA}\":true}}"), + } + ), + Err(ApiError::InvalidWirePayload) + ); + } +} diff --git a/crates/analysis_engine/tests/execute_cli_contract.rs b/crates/analysis_engine/tests/execute_cli_contract.rs new file mode 100644 index 000000000..a0c3b53a3 --- /dev/null +++ b/crates/analysis_engine/tests/execute_cli_contract.rs @@ -0,0 +1,196 @@ +//! GAP-003A published execute CLI against spawned tepp-loopback TCP. + +use std::io::{BufRead, BufReader, Write}; +use std::process::{Command, Stdio}; + +use analysis_engine::{ + ANALYSIS_RUN_EXECUTE_CONTRACT_VERSION, ScientificAcceptanceExecuteCliInvocation, + ScientificAcceptanceExecuteCliVerb, VALIDATION_CPU_F64_MODEL, + loopback_http1_from_naruon_exchange, +}; +use tepp_api::{ + ANALYSIS_RUN_CONTRACT_VERSION, AnalysisRunRequest, ApiError, LINEAGEWEAVE_CONSUMER_CODE, + NARUON_CONSUMER_CODE, SCIENTIFIC_ACCEPTANCE_HTTP_PROFILE, SCIENTIFIC_ACCEPTANCE_HTTP_SCHEMA, + lineageweave_analysis_run_exchange, naruon_analysis_run_exchange, +}; + +const HTTPS_ORIGIN: &str = "https://tepp.example.com"; + +fn spawn_loopback(request_limit: &str) -> (std::process::Child, String) { + let mut child = Command::new(env!("CARGO_BIN_EXE_tepp-loopback")) + .args(["127.0.0.1:0", request_limit]) + .stdout(Stdio::piped()) + .spawn() + .expect("spawn loopback"); + let mut address = String::new(); + BufReader::new(child.stdout.take().expect("stdout")) + .read_line(&mut address) + .expect("bound address"); + (child, address) +} + +fn request(idempotency_key: &str) -> AnalysisRunRequest { + AnalysisRunRequest { + contract_version: ANALYSIS_RUN_CONTRACT_VERSION, + idempotency_key: idempotency_key.into(), + tenant_workspace_id: "tenant-workspace-execute-cli".into(), + snapshot_id: "snapshot-execute-cli".into(), + knowledge_cutoff: "2026-08-01T00:00:00Z".into(), + model_contract_version: VALIDATION_CPU_F64_MODEL.into(), + output_profile: SCIENTIFIC_ACCEPTANCE_HTTP_PROFILE.into(), + } +} + +fn execute_json(run_id: &str, idempotency_key: &str) -> String { + serde_json::json!({ + "contract_version": ANALYSIS_RUN_EXECUTE_CONTRACT_VERSION, + "run_id": run_id, + "idempotency_key": idempotency_key, + "seed": 42, + "se_gate_k": 3.0, + "completed_at": "2026-08-31T13:00:00Z", + "study_label": "loopback-cli-recovery", + "authored_by_llm": false, + "corpus": { + "snapshot_id": "snapshot-execute-cli", + "evidence_units": [ + { + "evidence_id": "evidence-1", + "event_time": "2026-07-01T00:00:00Z", + "available_time": "2026-07-10T00:00:00Z", + "membership_count": 1 + }, + { + "evidence_id": "evidence-2", + "event_time": "2026-07-01T00:00:00Z", + "available_time": "2026-07-20T00:00:00Z", + "membership_count": 1 + }, + { + "evidence_id": "future", + "event_time": "2026-07-01T00:00:00Z", + "available_time": "2026-08-02T00:00:00Z", + "membership_count": 1 + } + ] + }, + "truth": [0.70, 0.55, 0.40, -0.20, 0.85], + "recovered": [0.70, 0.55, 0.40, -0.20, 0.85], + "interval_lower": [0.50, 0.35, 0.20, -0.40, 0.65], + "interval_upper": [0.90, 0.75, 0.60, 0.00, 1.00], + "truth_times": [1.0, 2.0, 3.0, 4.0, 5.0], + "recovered_times": [1.1, 1.9, 3.2, 3.8, 5.1] + }) + .to_string() +} + +fn accept_run(address: &str, idempotency_key: &str, consumer: &str) -> String { + let request = request(idempotency_key); + let create = if consumer == LINEAGEWEAVE_CONSUMER_CODE { + lineageweave_analysis_run_exchange(HTTPS_ORIGIN, &request).expect("lineage create") + } else { + naruon_analysis_run_exchange(HTTPS_ORIGIN, &request).expect("naruon create") + }; + let http = loopback_http1_from_naruon_exchange(&create, address.trim()).expect("create http"); + let mut stream = std::net::TcpStream::connect(address.trim()).expect("connect"); + stream.write_all(http.as_bytes()).expect("write create"); + let mut response = String::new(); + std::io::Read::read_to_string(&mut stream, &mut response).expect("read create"); + assert!(response.starts_with("HTTP/1.1 202 Accepted"), "{response}"); + let body = response.split("\r\n\r\n").nth(1).expect("body"); + serde_json::from_str::(body).expect("json")["run_id"] + .as_str() + .expect("run_id") + .to_owned() +} + +fn run_execute_cli(host: &str, consumer: &str, body: &str) -> std::process::Output { + let mut child = Command::new(env!("CARGO_BIN_EXE_tepp-execute")) + .args([ + "execute", + "--host", + host, + "--origin", + HTTPS_ORIGIN, + "--consumer", + consumer, + ]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn execute cli"); + child + .stdin + .take() + .expect("stdin") + .write_all(body.as_bytes()) + .expect("write execute"); + child.wait_with_output().expect("wait execute") +} + +#[test] +fn execute_cli_verb_is_execute_only() { + assert_eq!( + ScientificAcceptanceExecuteCliVerb::parse("execute").expect("execute"), + ScientificAcceptanceExecuteCliVerb::Execute + ); + assert_eq!( + ScientificAcceptanceExecuteCliVerb::parse("create"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + ScientificAcceptanceExecuteCliInvocation::from_args( + ["execute", "--host", "8.8.8.8:80", "--origin", HTTPS_ORIGIN], + execute_json("tepp-run-1", "idem-1") + ), + Err(ApiError::AuthorizationDenied) + ); +} + +#[test] +fn naruon_and_lineageweave_execute_cli_over_spawned_loopback_tcp() { + let (mut child, address) = spawn_loopback("4"); + let host = address.trim(); + + let naruon_run_id = accept_run(&address, "idem-naruon-execute-cli", NARUON_CONSUMER_CODE); + let naruon = run_execute_cli( + host, + NARUON_CONSUMER_CODE, + &execute_json(&naruon_run_id, "idem-naruon-execute-cli"), + ); + assert!(naruon.status.success(), "{naruon:?}"); + let naruon_body = String::from_utf8(naruon.stdout).expect("utf8"); + assert!(naruon_body.contains(SCIENTIFIC_ACCEPTANCE_HTTP_SCHEMA)); + assert!(naruon_body.contains("scientific_acceptance")); + + let lineage_run_id = accept_run( + &address, + "idem-lineageweave-execute-cli", + LINEAGEWEAVE_CONSUMER_CODE, + ); + let lineage = run_execute_cli( + host, + LINEAGEWEAVE_CONSUMER_CODE, + &execute_json(&lineage_run_id, "idem-lineageweave-execute-cli"), + ); + assert!(lineage.status.success(), "{lineage:?}"); + let lineage_body = String::from_utf8(lineage.stdout).expect("utf8"); + assert!(lineage_body.contains(SCIENTIFIC_ACCEPTANCE_HTTP_SCHEMA)); + + let refused = Command::new(env!("CARGO_BIN_EXE_tepp-execute")) + .args([ + "execute", + "--host", + "8.8.8.8:80", + "--origin", + HTTPS_ORIGIN, + "--consumer", + NARUON_CONSUMER_CODE, + ]) + .stdin(Stdio::piped()) + .output() + .expect("non-loopback"); + assert!(!refused.status.success()); + assert!(child.wait().expect("wait").success()); +} diff --git a/docs/API_CONTRACT.md b/docs/API_CONTRACT.md index faa872d05..4a40a0cd6 100644 --- a/docs/API_CONTRACT.md +++ b/docs/API_CONTRACT.md @@ -118,7 +118,10 @@ binds that wrapper so operators reach `/execute` without embedding consumer contracts for that POST; they refuse LLM recovery, receipt metric keys, and non-`https` origins. `loopback_http1_from_execute_exchange` renders those exchanges onto the spawned `tepp-loopback` TCP listener; public bind -hosts and `localhost` fail closed. Production TLS remains a later adapter. +hosts and `localhost` fail closed. The published `tepp-execute` CLI POSTs those +typed exchanges from stdin so operators do not write HTTP; public bind hosts, +`localhost`, credential-shaped flags, empty stdin, LLM recovery, metric keys, +and `http://` origins fail closed. Production TLS remains a later adapter. The stacked `analysis_engine` slice provides the first executable service-side path behind these DTOs. It consumes a bounded identity-free snapshot, excludes diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 92e80d707..0e5f3198a 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -60,6 +60,7 @@ The full APA 7th standards/literature register remains `docs/research/standards- | published scientific-acceptance loopback binary | ADR 0033; ADR 0032; API contract; National Academies (2019); Wasserstein & Lazar (2016); Wilson (1927) | `analysis_engine` `tepp-loopback` binary (this PR): packaged listener binds `ScientificAcceptanceLoopbackService` so POST create/execute then GET returns `tepp.scientific_acceptance.v1` without embedding the library; temporal-context health remains; `tepp_api` no longer ships the binary; not implemented-main; Postgres persistence remains GAP-003B | active-PR | | scientific-acceptance execute consumer exchange | ADR 0034; ADR 0032/0033; API contract; National Academies (2019); Wasserstein & Lazar (2016); Wilson (1927) | `analysis_engine` `naruon_analysis_run_execute_exchange` / `lineageweave_analysis_run_execute_exchange` (this PR): typed credential-free HTTPS POST `/execute`; LLM/metric keys/unknown artifact fields/`http://` fail closed; not implemented-main; Postgres persistence remains GAP-003B | active-PR | | scientific-acceptance execute exchange loopback TCP | ADR 0037; ADR 0034/0033/0032; API contract; National Academies (2019); Wasserstein & Lazar (2016); Wilson (1927) | `analysis_engine` `loopback_http1_from_execute_exchange` (this PR): typed naruon/`LineageWeave` execute exchanges render onto the spawned `tepp-loopback` TCP listener; public bind/`localhost`/non-execute exchanges fail closed; GET then returns `tepp.scientific_acceptance.v1`; not implemented-main; Postgres persistence remains GAP-003B | active-PR | +| scientific-acceptance execute loopback CLI | ADR 0041; ADR 0037/0034/0033/0032; API contract; National Academies (2019); Wasserstein & Lazar (2016); Wilson (1927) | `analysis_engine` `tepp-execute` (this PR): published CLI POSTs typed naruon/`LineageWeave` execute exchanges onto spawned `tepp-loopback` TCP; public bind/`localhost`/credential flags/empty stdin/LLM/metric keys/`http://` fail closed; prints `tepp.scientific_acceptance.v1`; 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/0041-scientific-acceptance-execute-cli.md b/docs/adr/0041-scientific-acceptance-execute-cli.md new file mode 100644 index 000000000..8b6bc3377 --- /dev/null +++ b/docs/adr/0041-scientific-acceptance-execute-cli.md @@ -0,0 +1,78 @@ +# ADR 0041 — Scientific-acceptance execute loopback CLI + +**Decision status:** Accepted +**Implementation maturity:** active-PR +**Date:** 2026-08-31 +**Supersedes:** None; complements ADR 0037 (typed execute exchange on loopback TCP), ADR 0034 (typed execute exchanges), and ADR 0033 (published binary). Does not reuse ADR 0030–0040. Does not supersede ADR 0014 claim-promotion authority. + +## Context + +ADR 0034 mints typed naruon and `LineageWeave` execute exchanges. ADR 0037 renders those exchanges onto spawned `tepp-loopback` TCP. Operators still had to write a test or embed the library to POST `/execute`. Duplicating the TCP renderer (#382), execute builders (#381), published binary (#375), engine-execute (#370), lifecycle CLI (#362), create CLI (#385), cancel consumer-parity (#373), cancel CLI (#378), stored-request GET (#377), stored-request consumer-parity (#387), retry-children (#379), idempotency (#380), retry-parent (#384), collection GET (#368), GET (#359), lifecycle POST (#360), cancel HTTP (#361), collection CLI (#371), retry (#369), engine-library (#356), DTO (#358), persistence (#287), Leiden (#351), Driver p.16, CWC/Rubin/ESEM/OLS, GAP-010, or GAP-003C would collide with live PRs. + +## Decision + +`analysis_engine` publishes a loopback-only `tepp-execute` CLI: + +- `execute` POSTs `/v1/analysis-runs/{run_id}/execute` by minting `naruon_analysis_run_execute_exchange` or `lineageweave_analysis_run_execute_exchange` and rendering through `loopback_http1_from_execute_exchange`. +- Stdin is the typed execute body. Empty stdin is refused. Optional `--run-id` and `--idempotency-key` must match the body when present. +- `--origin` remains the published HTTPS origin on the typed exchange. Only `--host` is the loopback bind address printed by `tepp-loopback`. +- Success stdout is the engine-produced `tepp.scientific_acceptance.v1` status. Non-success stdout must not carry that schema. +- Public bind hosts, `localhost`, unpublished consumers, credential-shaped flags, LLM recovery, receipt metric keys, `http://` origins, and unknown verbs fail closed. +- Persistence remains GAP-003B. + +## Non-goals + +- Production TLS, public bind, or durable status storage. +- Leiden community detection, Driver p.16 std-family restoration, or Figma/export work. +- Promoting an ADR 0014 scientific claim from CLI success. +- Another TCP renderer, execute-builder, published-binary, engine-execute, lifecycle CLI, create CLI, cancel, collection, retry, GET, or persistence slice. + +## Alternatives considered + +1. **Keep embedding the library or hand-rolling HTTP as the only operator path** — rejected because GAP-003A is operator-visible and create/cancel already have published CLIs. +2. **Add `execute` onto the live lifecycle CLI (#362) or collection CLI (#371/#385)** — rejected because those heads live in `tepp_api`, cannot depend on `analysis_engine`, and do not own the execute body. +3. **Treat `localhost` as loopback** — rejected; `localhost` is a name, not a loopback bind address. +4. **Persist CLI transcripts in PostgreSQL** — rejected as GAP-003B / live draft #287. + +## Consequences + +- Operators can POST `/execute` from a published CLI using the typed naruon/`LineageWeave` execute exchange against spawned `tepp-loopback` TCP. +- CLI success is not release evidence. + +## Failure and recovery + +Public bind hosts return authorization denied. `localhost`, empty stdin, LLM recovery, metric keys, unpublished consumers, credential flags, `http://` origins, and unknown verbs fail closed. The in-memory registry is not durable. + +## Security, privacy, scientific-integrity, and governance impact + +- No credential headers cross the consumer boundary. +- The CLI remains loopback-only and size-bounded. +- LLM-authored recovery cannot become scientific authority. +- Process exit 0 on a succeeded execute is not an ADR 0014 claim. + +## Compatibility and migration + +Typed execute builders, the TCP renderer, and the published `tepp-loopback` binary are unchanged. Production adapters may replace loopback while preserving metric-free receipts and engine-produced scientific acceptance. + +## Verification + +Falsifiable evidence: + +- public bind hosts and `localhost` fail closed without opening a socket; +- credential-shaped flags, empty stdin, LLM recovery, metric keys, and `http://` origins fail closed; +- naruon and `LineageWeave` `tepp-execute execute` against spawned `tepp-loopback` TCP print `tepp.scientific_acceptance.v1`; +- Clippy `-D warnings`, `analysis_engine` tests, rustdoc, and exact-head review remain required. + +## Rollback and supersession + +Rollback removes the `tepp-execute` binary; typed builders, the TCP renderer, and `tepp-loopback` remain valid. A superseding ADR is required to persist status, bind a public address, or treat CLI success as an ADR 0014 claim. + +## Related authority + +- ADR 0037 owns typed execute exchanges on loopback TCP. +- ADR 0034 owns typed execute consumer exchanges. +- ADR 0033 owns the published `tepp-loopback` binary. +- ADR 0032 owns engine-on-loopback execute. +- ADR 0018 owns consumer-scoped ingress and metric-free `202 Accepted`. +- 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 070f2f868..89a4c46fb 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -37,6 +37,7 @@ Read [`ADR_POLICY.md`](ADR_POLICY.md) first. **Decision status and implementatio | [0033](0033-scientific-acceptance-loopback-binary.md) | Scientific-acceptance published loopback binary | Accepted | active-PR | `tepp-loopback` moves to `analysis_engine` and binds `ScientificAcceptanceLoopbackService` so `/execute` is reachable on the packaged listener. Persistence remains GAP-003B. | | [0034](0034-scientific-acceptance-execute-consumer-exchange.md) | Scientific-acceptance execute consumer exchange | Accepted | active-PR | Naruon and `LineageWeave` mint credential-free POST `/execute` through typed exchanges in `analysis_engine`. Persistence remains GAP-003B. | | [0037](0037-scientific-acceptance-execute-exchange-loopback-tcp.md) | Scientific-acceptance execute exchange on loopback TCP | Accepted | active-PR | Typed naruon/`LineageWeave` execute exchanges render onto the spawned `tepp-loopback` TCP listener. Persistence remains GAP-003B. | +| [0041](0041-scientific-acceptance-execute-cli.md) | Scientific-acceptance execute loopback CLI | Accepted | active-PR | Published `tepp-execute` POSTs typed naruon/`LineageWeave` execute 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. | @@ -149,6 +150,7 @@ Use the narrowest owning ADR when decisions overlap: - **scientific-acceptance published loopback binary:** ADR 0033. - **scientific-acceptance execute consumer exchange:** ADR 0034. - **scientific-acceptance execute exchange on loopback TCP:** ADR 0037. +- **scientific-acceptance execute loopback CLI:** ADR 0041. - **independent lineage criterion and posterior Project Journey:** ADR 0023. - **macOS-native Rust-owned MLX Metal execution:** ADR 0024. diff --git a/docs/connectors/naruon-artifact-consumer.md b/docs/connectors/naruon-artifact-consumer.md index f3179c8db..7f0450c82 100644 --- a/docs/connectors/naruon-artifact-consumer.md +++ b/docs/connectors/naruon-artifact-consumer.md @@ -32,6 +32,7 @@ TEPP remains the scientific authority for estimation, recovery metrics, temporal | Published `tepp-loopback` binary | `analysis_engine` `tepp-loopback` binds `ScientificAcceptanceLoopbackService` so `/execute` is reachable without embedding the library | naruon → TEPP | | Typed scientific-acceptance execute exchange | `analysis_engine` `naruon_analysis_run_execute_exchange` → `POST /v1/analysis-runs/{run_id}/execute` | naruon → TEPP | | Typed execute exchange on spawned `tepp-loopback` TCP | `analysis_engine` `loopback_http1_from_execute_exchange` renders the typed POST onto the packaged loopback listener | naruon → TEPP | +| Published `tepp-execute` CLI | `analysis_engine` `tepp-execute` POSTs the typed naruon execute exchange onto spawned `tepp-loopback` TCP | naruon → TEPP | Committed examples live under `examples/`. Schemas for analysis-run requests and corpus-split manifests live under `schemas/`. diff --git a/docs/research/scientific-acceptance-execute-cli.md b/docs/research/scientific-acceptance-execute-cli.md new file mode 100644 index 000000000..8b84e6f40 --- /dev/null +++ b/docs/research/scientific-acceptance-execute-cli.md @@ -0,0 +1,30 @@ +# Scientific-acceptance execute loopback CLI + +## Scope + +This note doctors the GAP-003A execute CLI slice: + +1. `tepp-execute execute` POSTs `/v1/analysis-runs/{run_id}/execute` from a typed naruon/`LineageWeave` execute exchange; +2. the exchange keeps its HTTPS origin; only `--host` is the loopback bind address printed by `tepp-loopback`; +3. public bind hosts, `localhost`, credential-shaped flags, empty stdin, LLM recovery, metric keys, and `http://` origins fail closed before any socket is opened; +4. naruon and `LineageWeave` CLI execute against spawned `tepp-loopback` TCP print `tepp.scientific_acceptance.v1`. + +Postgres persistence, restart/recovery, and Compose execution remain GAP-003B. This slice is not implemented-main. It does not duplicate the TCP renderer (#382), execute builders (#381), published binary (#375), engine-execute (#370), lifecycle CLI (#362), create CLI (#385), cancel consumer-parity (#373), cancel CLI (#378), stored-request GET (#377), stored-request consumer-parity (#387), retry-children (#379), idempotency (#380), retry-parent (#384), collection GET (#368), GET (#359), lifecycle POST (#360), cancel HTTP (#361), collection CLI (#371), retry (#369), engine-library (#356), DTO (#358), persistence (#287), Leiden (#351), Driver p.16, CWC/Rubin/ESEM/OLS, GAP-010, or GAP-003C. + +## Authoritative sources + +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 + +Wilson, E. B. (1927). Probable inference, the law of succession, and statistical inference. *Journal of the American Statistical Association, 22*(158), 209–212. https://doi.org/10.1080/01621459.1927.10502953 + +## Application + +The National Academies (2019) require that a computational procedure be invoked through the published interface, not an ad-hoc consumer wire. Wasserstein and Lazar (2016) refuse to treat a passing threshold as automatic scientific authority, so the CLI produces the same `tepp.scientific_acceptance.v1` evidence as the library bind and never treats process exit 0 as ADR 0014 promotion. Wilson (1927) supplies the coverage interval already implemented in `validation_core`. TEPP therefore publishes `tepp-execute` as the operator client of the typed execute exchange on spawned `tepp-loopback` TCP, refuses public bind and `localhost`, and reports RMSE, bias, coverage, temporal order, and the SE-aware gate only after engine completion (National Academies of Sciences, Engineering, and Medicine, 2019; Wasserstein & Lazar, 2016; Wilson, 1927). Meredith (1993) remains unread (Unpaywall/OpenAlex 2026-08-31T14:00Z: `is_oa: false`, 0 locations). Mislevy (1991, *Psychometrika, 56*, 177–196) remains unread on the same terms (DOI `10.1007/bf02294457`). + +## Verification + +- public bind hosts and `localhost` fail closed without opening a socket; +- credential-shaped flags, empty stdin, LLM recovery, metric keys, and `http://` origins fail closed; +- naruon and `LineageWeave` `tepp-execute execute` against spawned `tepp-loopback` TCP print `tepp.scientific_acceptance.v1`.