diff --git a/CHANGELOG.d/project-history-cli.md b/CHANGELOG.d/project-history-cli.md new file mode 100644 index 000000000..64f4db72b --- /dev/null +++ b/CHANGELOG.d/project-history-cli.md @@ -0,0 +1,2 @@ +- `tepp_api` loopback `tepp-project-history query` mints a typed LineageWeave `POST /v1/project-histories` onto spawned `tepp-loopback` TCP (ADR 0061). Metric-free `temporal_association_only` JSON only. `tepp.scientific_acceptance.v1` never appears. Does not infer causality. Naruon is refused. Not temporal-context CLI, not export CLI, not persistence. +- Fail closed on HTTP field injection, duplicate or transfer-encoded framing, non-2xx stdout, and stdin/response payloads above the existing project-history wire limits. diff --git a/CHANGELOG.d/project-history-stored-request-cli.md b/CHANGELOG.d/project-history-stored-request-cli.md new file mode 100644 index 000000000..84a6c1dcb --- /dev/null +++ b/CHANGELOG.d/project-history-stored-request-cli.md @@ -0,0 +1 @@ +- `tepp-project-history-request get` mints LineageWeave stored-request GET onto spawned `tepp-loopback` TCP (ADR 0088). Metric-free; stored projection `inference_status` remains `temporal_association_only`. Does not infer causality. Naruon refused. `NaruonLiveService` stays POST-only. Does not re-open cancel lineages. Not GAP-010 Figma/export, not persistence. diff --git a/CHANGELOG.d/project-history-stored-request-get.md b/CHANGELOG.d/project-history-stored-request-get.md new file mode 100644 index 000000000..7c36b8a5d --- /dev/null +++ b/CHANGELOG.d/project-history-stored-request-get.md @@ -0,0 +1 @@ +- `GET /v1/project-histories/{idempotency_key}/request` returns the accepted LineageWeave create request on `tepp-loopback` (ADR 0087). Metric-free; stored projection `inference_status` remains `temporal_association_only`. Does not infer causality. Naruon refused. `NaruonLiveService` stays POST-only. Does not re-open cancel lineages. Not GAP-010 Figma/export, not persistence. diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index eb183395e..9ff09f268 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -13,6 +13,8 @@ TEPP's approved PRD v0.4 and implementation plan are the primary product baselin | naruon modular consumer contract | [`docs/connectors/naruon-artifact-consumer.md`](docs/connectors/naruon-artifact-consumer.md) | | Project-history collection GET doctoring | [`docs/research/project-history-collection-http.md`](docs/research/project-history-collection-http.md) | | Project-history GET-by-id doctoring | [`docs/research/project-history-retrieval-http.md`](docs/research/project-history-retrieval-http.md) | +| Project-history stored-request GET doctoring | [`docs/research/project-history-stored-request-get.md`](docs/research/project-history-stored-request-get.md) | +| Project-history stored-request CLI doctoring | [`docs/research/project-history-stored-request-cli.md`](docs/research/project-history-stored-request-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/Cargo.toml b/crates/tepp_api/Cargo.toml index 47ad7c433..d8ff05c11 100644 --- a/crates/tepp_api/Cargo.toml +++ b/crates/tepp_api/Cargo.toml @@ -29,5 +29,17 @@ path = "src/bin/tepp_loopback.rs" test = false bench = false +[[bin]] +name = "tepp-project-history-request" +path = "src/bin/tepp_project_history_request.rs" +test = false +bench = false + +[[bin]] +name = "tepp-project-history" +path = "src/bin/tepp_project_history.rs" +test = false +bench = false + [lints] workspace = true diff --git a/crates/tepp_api/src/analysis_run_live.rs b/crates/tepp_api/src/analysis_run_live.rs index 8807ed1d6..611836235 100644 --- a/crates/tepp_api/src/analysis_run_live.rs +++ b/crates/tepp_api/src/analysis_run_live.rs @@ -25,7 +25,8 @@ use crate::{ is_project_history_collection_path, page_project_history_collection_items, parse_project_history_collection_page_cursor, parse_project_history_collection_page_limit, project_history_projection, project_history_retrieval_path_id, - refuse_metrics_on_project_history_retrieval_payload, requests_are_idempotent_matches, + project_history_stored_request_path_id, refuse_metrics_on_project_history_retrieval_payload, + refuse_metrics_on_project_history_stored_request_payload, requests_are_idempotent_matches, }; const MAX_LIVE_REQUEST_BODY_BYTES: usize = DEFAULT_PROJECT_HISTORY_BYTE_LIMIT; @@ -153,6 +154,12 @@ impl AnalysisRunLiveService { if is_project_history_collection_path(path) { return self.list_project_histories(&headers, body); } + if matches!( + project_history_stored_request_path_id(path), + Ok(_) | Err(ApiError::LimitExceeded) + ) { + return self.get_project_history_stored_request(path, &headers, body); + } if matches!( project_history_retrieval_path_id(path), Ok(_) | Err(ApiError::LimitExceeded) @@ -321,6 +328,40 @@ impl AnalysisRunLiveService { Ok(json_response(200, "OK", response_body)) } + fn get_project_history_stored_request( + &self, + path: &str, + headers: &HashMap, + body: &str, + ) -> Result { + if !body.is_empty() { + return Err(ApiError::InvalidWirePayload); + } + refuse_metrics_on_project_history_stored_request_payload(body)?; + let consumer = require_headers(headers, self.bound_addr, false)?; + if consumer != LINEAGEWEAVE_CONSUMER_CODE { + return Err(ApiError::InvalidWirePayload); + } + if headers.contains_key("tepp-page-limit") || headers.contains_key("tepp-page-cursor") { + return Err(ApiError::InvalidWirePayload); + } + let tenant_workspace_id = header_value(headers, PROJECT_HISTORY_RETRIEVAL_TENANT_HEADER)?; + crate::project_history::validate_project_history_registry_identity(tenant_workspace_id)?; + let idempotency_key = project_history_stored_request_path_id(path)?; + let replay_key = + consumer_tenant_idempotency_key(consumer, tenant_workspace_id, &idempotency_key); + let (stored_request, projection) = self + .accepted_project_histories + .get(&replay_key) + .ok_or(ApiError::InvalidWirePayload)?; + if projection.inference_status != "temporal_association_only" { + return Err(ApiError::InvalidWirePayload); + } + let response_body = stored_request.to_json()?; + refuse_metrics_on_project_history_stored_request_payload(&response_body)?; + Ok(json_response(200, "OK", response_body)) + } + fn response_from_error(&mut self, error: ApiError) -> NaruonLiveResponse { let request_id = format!("analysis-run-live-{}", self.next_request_serial); self.next_request_serial += 1; @@ -1237,6 +1278,51 @@ mod tests { assert!(!collection.body.contains("evidence_text")); } + #[test] + fn project_history_stored_request_get_returns_create_request_and_fails_closed() { + let mut service = AnalysisRunLiveService::new(); + let first = sample_project_history("idem-a", "project-a"); + assert_eq!( + service + .handle_http_request(&project_history_post(&first)) + .status_code, + 200 + ); + let got = service.handle_http_request(&format!( + "GET {PROJECT_HISTORY_PATH}/idem-a/request HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: {LINEAGEWEAVE_CONSUMER_CODE}\r\ntepp-contract-version: 1\r\ntepp-tenant-workspace-id: history-tenant\r\ncontent-length: 0\r\n\r\n" + )); + assert_eq!(got.status_code, 200, "{}", got.body); + let stored = ProjectHistoryRequest::from_json(&got.body).expect("stored"); + assert_eq!(stored, first); + assert!(!got.body.contains("rmse")); + assert!(!got.body.contains("tepp.scientific_acceptance.v1")); + assert!(!got.body.contains("causal_score")); + assert_eq!( + service + .handle_http_request(&format!( + "GET {PROJECT_HISTORY_PATH}/idem-a/request HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: {NARUON_CONSUMER_CODE}\r\ntepp-contract-version: 1\r\ntepp-tenant-workspace-id: history-tenant\r\ncontent-length: 0\r\n\r\n" + )) + .status_code, + 400 + ); + assert_eq!( + service + .handle_http_request(&format!( + "GET {PROJECT_HISTORY_PATH}/idem-a/cancel HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: {LINEAGEWEAVE_CONSUMER_CODE}\r\ntepp-contract-version: 1\r\ntepp-tenant-workspace-id: history-tenant\r\ncontent-length: 0\r\n\r\n" + )) + .status_code, + 400 + ); + assert_eq!( + service + .handle_http_request(&format!( + "GET {PROJECT_HISTORY_PATH}/missing/request HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: {LINEAGEWEAVE_CONSUMER_CODE}\r\ntepp-contract-version: 1\r\ntepp-tenant-workspace-id: history-tenant\r\ncontent-length: 0\r\n\r\n" + )) + .status_code, + 400 + ); + } + struct ScriptedRead { reader: Cursor>, first_error: Option, diff --git a/crates/tepp_api/src/bin/tepp_project_history.rs b/crates/tepp_api/src/bin/tepp_project_history.rs new file mode 100644 index 000000000..8a0b16bcb --- /dev/null +++ b/crates/tepp_api/src/bin/tepp_project_history.rs @@ -0,0 +1,36 @@ +//! Operator CLI for loopback `LineageWeave` project-history POST. + +use std::io::{self, IsTerminal}; +use std::process::ExitCode; + +use tepp_api::{ + ApiError, ProjectHistoryCliInvocation, execute_project_history_cli, + read_project_history_cli_stdin, render_project_history_cli_stdout, +}; + +fn main() -> ExitCode { + match run() { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("{error}"); + ExitCode::FAILURE + } + } +} + +fn run() -> Result<(), ApiError> { + let args: Vec = std::env::args().skip(1).collect(); + match args.first().map(String::as_str) { + Some("query") => run_query(&args), + _ => Err(ApiError::InvalidWirePayload), + } +} + +fn run_query(args: &[String]) -> Result<(), ApiError> { + let body = read_project_history_cli_stdin(io::stdin().is_terminal(), io::stdin())?; + let invocation = ProjectHistoryCliInvocation::from_args(args, body)?; + let response = execute_project_history_cli(&invocation)?; + let stdout = render_project_history_cli_stdout(&invocation, &response)?; + println!("{stdout}"); + Ok(()) +} diff --git a/crates/tepp_api/src/bin/tepp_project_history_request.rs b/crates/tepp_api/src/bin/tepp_project_history_request.rs new file mode 100644 index 000000000..298bf6784 --- /dev/null +++ b/crates/tepp_api/src/bin/tepp_project_history_request.rs @@ -0,0 +1,35 @@ +//! Operator CLI for loopback `LineageWeave` project-history stored-request GET. + +use std::io::{self, IsTerminal}; +use std::process::ExitCode; + +use tepp_api::{ + ApiError, ProjectHistoryStoredRequestCliInvocation, execute_project_history_stored_request_cli, + read_project_history_stored_request_cli_stdin, + render_project_history_stored_request_cli_stdout, +}; + +fn main() -> ExitCode { + match run() { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("tepp-project-history-request: {error}"); + ExitCode::FAILURE + } + } +} + +fn run() -> Result<(), ApiError> { + let args: Vec = std::env::args().skip(1).collect(); + let body = + read_project_history_stored_request_cli_stdin(io::stdin().is_terminal(), io::stdin())?; + let invocation = ProjectHistoryStoredRequestCliInvocation::from_args(&args, body)?; + let response = execute_project_history_stored_request_cli(&invocation)?; + let stdout = render_project_history_stored_request_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 68d4a85ac..a9ac0579b 100644 --- a/crates/tepp_api/src/lib.rs +++ b/crates/tepp_api/src/lib.rs @@ -9,7 +9,9 @@ //! may also request a cutoff-safe project-history projection from explicit //! source evidence. Naruon owns the current purpose-bound export adapter. //! Loopback listeners prove the HTTP boundary without claiming production TLS, -//! causality, or completed psychometric model results. +//! causality, or completed psychometric model results. The published +//! `tepp-project-history` CLI mints typed LineageWeave project-history POST +//! exchanges onto spawned `tepp-loopback` TCP. mod analysis_result; mod analysis_run; @@ -28,8 +30,11 @@ mod naruon_http; mod naruon_live; mod orchestration; mod project_history; +mod project_history_cli; mod project_history_collection_http; mod project_history_retrieval_http; +mod project_history_stored_request_cli; +mod project_history_stored_request_http; mod project_journey; mod provider_payload; mod temporal_context; @@ -232,6 +237,24 @@ pub use project_history::ProjectHistoryProjection; pub use project_history::ProjectHistoryRequest; /// Build a cutoff-safe project-history projection. pub use project_history::project_history_projection; +/// Loopback project-history query CLI invocation. +pub use project_history_cli::ProjectHistoryCliInvocation; +/// Loopback project-history query CLI verb. +pub use project_history_cli::ProjectHistoryCliVerb; +/// Compose HTTP/1.1 project-history POST from a query CLI invocation. +pub use project_history_cli::compose_project_history_cli_http; +/// Dispatch a project-history query CLI invocation against an in-process listener. +pub use project_history_cli::dispatch_project_history_cli; +/// Execute a project-history query CLI invocation over loopback TCP. +pub use project_history_cli::execute_project_history_cli; +/// Render a typed project-history POST exchange as loopback HTTP/1.1. +pub use project_history_cli::loopback_http1_from_project_history_exchange; +/// Read bounded stdin for the project-history query CLI. +pub use project_history_cli::read_project_history_cli_stdin; +/// Refuse scientific metric or causal keys on project-history query CLI JSON. +pub use project_history_cli::refuse_metrics_on_project_history_cli_payload; +/// Render metric-free project-history query CLI stdout. +pub use project_history_cli::render_project_history_cli_stdout; /// Maximum opaque cursor length on project-history collection GET. pub use project_history_collection_http::PROJECT_HISTORY_COLLECTION_CURSOR_MAX_LEN; /// Default page size for project-history collection GET. @@ -266,6 +289,30 @@ pub use project_history_retrieval_http::lineageweave_project_history_retrieval_e pub use project_history_retrieval_http::project_history_retrieval_path_id; /// Refuse scientific-metric and causal-score keys on retrieval JSON. pub use project_history_retrieval_http::refuse_metrics_on_project_history_retrieval_payload; +/// Validated loopback CLI invocation for project-history stored-request GET. +pub use project_history_stored_request_cli::ProjectHistoryStoredRequestCliInvocation; +/// Loopback CLI verb for project-history stored-request GET. +pub use project_history_stored_request_cli::ProjectHistoryStoredRequestCliVerb; +/// Compose HTTP/1.1 from a stored-request CLI invocation. +pub use project_history_stored_request_cli::compose_project_history_stored_request_cli_http; +/// Dispatch a stored-request CLI invocation against an in-process listener. +pub use project_history_stored_request_cli::dispatch_project_history_stored_request_cli; +/// Execute a stored-request CLI invocation over loopback TCP. +pub use project_history_stored_request_cli::execute_project_history_stored_request_cli; +/// Render `tepp-loopback` HTTP/1.1 from a stored-request exchange. +pub use project_history_stored_request_cli::loopback_http1_from_project_history_stored_request_exchange; +/// Read leftover stdin for stored-request GET; empty is admitted. +pub use project_history_stored_request_cli::read_project_history_stored_request_cli_stdin; +/// Filter stored-request CLI stdout so scientific-acceptance never prints. +pub use project_history_stored_request_cli::render_project_history_stored_request_cli_stdout; +/// Whether a path is the project-history stored-request extra-segment. +pub use project_history_stored_request_http::is_project_history_stored_request_path; +/// `LineageWeave` GET exchange for one stored project-history create request. +pub use project_history_stored_request_http::lineageweave_project_history_stored_request_exchange; +/// Extract the opaque idempotency key from a stored-request GET path. +pub use project_history_stored_request_http::project_history_stored_request_path_id; +/// Refuse scientific-metric and causal-score keys on stored-request JSON. +pub use project_history_stored_request_http::refuse_metrics_on_project_history_stored_request_payload; /// Maximum posterior Project Journey artifact size. pub use project_journey::DEFAULT_PROJECT_JOURNEY_BYTE_LIMIT; /// Exact posterior Project Journey schema identity. diff --git a/crates/tepp_api/src/project_history_cli.rs b/crates/tepp_api/src/project_history_cli.rs new file mode 100644 index 000000000..4b3dd9950 --- /dev/null +++ b/crates/tepp_api/src/project_history_cli.rs @@ -0,0 +1,1098 @@ +//! Operator loopback CLI for `LineageWeave` project-history POST. +//! +//! Operator-visible client of `POST /v1/project-histories` on +//! `AnalysisRunLiveService` / `tepp-loopback` (ADR 0021 / ADR 0011). Operators +//! run `tepp-project-history query` to mint +//! `lineageweave_project_history_exchange` onto spawned `tepp-loopback` TCP. +//! Stdout is a metric-free `temporal_association_only` projection. +//! `tepp.scientific_acceptance.v1` never appears. The CLI does not infer +//! causality. Naruon is refused on this LineageWeave-owned adapter. +//! `NaruonLiveService` stays POST-only for analysis-run and export. This +//! module does not duplicate temporal-context CLI, export CLIs, analysis-run +//! CLIs, GET-by-id, Leiden, or GAP-010 Figma/export. Persistence remains +//! GAP-003B. + +use std::collections::HashSet; +use std::fmt::Write as _; +use std::io::{Read, Write}; +use std::net::{SocketAddr, TcpStream}; + +use crate::live_http::map_io_error; +use crate::naruon_http::header_is_credential; +use crate::wire::require_nonempty; +use crate::{ + AnalysisRunLiveService, ApiError, DEFAULT_PROJECT_HISTORY_BYTE_LIMIT, + LINEAGEWEAVE_CONSUMER_CODE, NARUON_LIVE_HEADER_BYTE_LIMIT, NARUON_LIVE_HEADER_COUNT_LIMIT, + NARUON_LIVE_IO_TIMEOUT, NaruonLiveResponse, PROJECT_HISTORY_CONTRACT_VERSION, + PROJECT_HISTORY_PATH, ProjectHistoryHttpExchange, ProjectHistoryProjection, + ProjectHistoryRequest, lineageweave_project_history_exchange, project_history_projection, +}; + +const SCIENTIFIC_ACCEPTANCE_SCHEMA: &str = "tepp.scientific_acceptance.v1"; +const MAXIMUM_HTTP_RESPONSE_BYTES: usize = + NARUON_LIVE_HEADER_BYTE_LIMIT + 4 + DEFAULT_PROJECT_HISTORY_BYTE_LIMIT; + +/// Supported operator verbs for the loopback project-history CLI. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ProjectHistoryCliVerb { + /// `POST /v1/project-histories`. + Query, +} + +impl ProjectHistoryCliVerb { + /// Parse one exact lowercase verb token. + /// + /// # Errors + /// + /// Returns [`ApiError::InvalidWirePayload`] for an unknown token. + pub fn parse(token: &str) -> Result { + match token { + "query" => Ok(Self::Query), + _ => Err(ApiError::InvalidWirePayload), + } + } + + /// Return the canonical lowercase verb token. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Query => "query", + } + } +} + +/// One operator CLI invocation against a loopback project-history listener. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ProjectHistoryCliInvocation { + /// CLI verb to execute. + pub verb: ProjectHistoryCliVerb, + /// Loopback `host:port` of `tepp-loopback`. + pub host: String, + /// Published HTTPS origin used to mint the typed project-history exchange. + pub origin: String, + /// Published modular consumer. Project-history admits `lineageweave` only. + pub consumer: String, + /// Validated cutoff-safe project-history request. + pub request: ProjectHistoryRequest, +} + +impl ProjectHistoryCliInvocation { + /// Parse argv plus stdin JSON into a validated loopback query invocation. + /// + /// # Errors + /// + /// Returns a fail-closed error for unknown verbs, missing flags, a + /// non-loopback host, a non-`https` origin, an unpublished or naruon + /// consumer, credential-shaped flags, metric keys, or an invalid body. + pub fn from_args(args: I, body: impl Into) -> Result + where + I: IntoIterator, + S: AsRef, + { + let tokens: Vec = args + .into_iter() + .map(|token| token.as_ref().to_owned()) + .collect(); + let (verb_token, rest) = tokens.split_first().ok_or(ApiError::InvalidWirePayload)?; + let verb = ProjectHistoryCliVerb::parse(verb_token)?; + let flags = parse_flags(rest)?; + let body = body.into(); + refuse_scientific_acceptance(&body)?; + refuse_metrics_on_project_history_cli_payload(&body)?; + let request = ProjectHistoryRequest::from_json(&body)?; + let invocation = Self { + verb, + host: flags.host.ok_or(ApiError::InvalidWirePayload)?, + origin: flags.origin.ok_or(ApiError::InvalidWirePayload)?, + consumer: flags + .consumer + .unwrap_or_else(|| LINEAGEWEAVE_CONSUMER_CODE.to_owned()), + request, + }; + invocation.validate()?; + Ok(invocation) + } + + /// Reject a non-loopback host, unpublished consumer, or hostile origin. + /// + /// # Errors + /// + /// Returns [`ApiError::AuthorizationDenied`] for a non-loopback host and + /// [`ApiError::InvalidWirePayload`] when the origin is not `https` or the + /// consumer is not `lineageweave`. + pub fn validate(&self) -> Result<(), ApiError> { + require_loopback_host(&self.host)?; + require_nonempty(&self.origin)?; + if !self.origin.starts_with("https://") { + return Err(ApiError::InvalidWirePayload); + } + require_nonempty(&self.consumer)?; + if self.consumer != LINEAGEWEAVE_CONSUMER_CODE { + return Err(ApiError::InvalidWirePayload); + } + Ok(()) + } +} + +struct ParsedFlags { + host: Option, + origin: Option, + consumer: Option, +} + +fn parse_flags(rest: &[String]) -> Result { + let mut flags = ParsedFlags { + host: None, + origin: None, + consumer: None, + }; + let mut index = 0; + while index < rest.len() { + let flag = rest[index].as_str(); + if !flag.starts_with("--") { + return Err(ApiError::InvalidWirePayload); + } + let name = &flag[2..]; + if header_is_credential(name) { + return Err(ApiError::AuthorizationDenied); + } + let slot = match name { + "host" => &mut flags.host, + "origin" => &mut flags.origin, + "consumer" => &mut flags.consumer, + _ => 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 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) + } +} + +/// Render a typed project-history exchange as HTTP/1.1 for a loopback listener. +/// +/// The exchange keeps its HTTPS origin contract. Only the HTTP/1.1 `Host` is +/// the loopback bind address. Public bind hosts fail closed. +/// +/// # Errors +/// +/// Returns [`ApiError::AuthorizationDenied`] for a non-loopback host or a +/// credential-bearing header, and [`ApiError::InvalidWirePayload`] when the +/// exchange is not a POST `/v1/project-histories`. +pub fn loopback_http1_from_project_history_exchange( + exchange: &ProjectHistoryHttpExchange, + loopback_host: &str, +) -> Result { + let _addr = require_loopback_host(loopback_host)?; + let host = loopback_host.trim(); + if exchange.method != "POST" { + return Err(ApiError::InvalidWirePayload); + } + let rest = exchange + .target_url + .strip_prefix("https://") + .ok_or(ApiError::InvalidWirePayload)?; + let path = rest + .find('/') + .map(|index| &rest[index..]) + .ok_or(ApiError::InvalidWirePayload)?; + if path != PROJECT_HISTORY_PATH { + return Err(ApiError::InvalidWirePayload); + } + let body = ProjectHistoryRequest::from_json(&exchange.body)?; + let mut seen = HashSet::with_capacity(exchange.headers.len()); + for (name, value) in &exchange.headers { + if header_is_credential(name) { + return Err(ApiError::AuthorizationDenied); + } + if !valid_http_field_name(name) + || value.chars().any(char::is_control) + || !seen.insert(name.to_ascii_lowercase()) + { + return Err(ApiError::InvalidWirePayload); + } + let valid = match name.to_ascii_lowercase().as_str() { + "content-type" => value == "application/json", + "tepp-consumer" => value == LINEAGEWEAVE_CONSUMER_CODE, + "tepp-contract-version" => value == &PROJECT_HISTORY_CONTRACT_VERSION.to_string(), + "idempotency-key" => value == &body.idempotency_key, + _ => false, + }; + if !valid { + return Err(ApiError::InvalidWirePayload); + } + } + if seen.len() != 4 { + return Err(ApiError::InvalidWirePayload); + } + let mut request = String::new(); + write!( + request, + "{} {path} HTTP/1.1\r\nHost: {host}\r\n", + exchange.method + ) + .map_err(|_| ApiError::InvalidWirePayload)?; + for (name, value) in &exchange.headers { + write!(request, "{name}: {value}\r\n").map_err(|_| ApiError::InvalidWirePayload)?; + } + write!( + request, + "content-length: {}\r\n\r\n{}", + exchange.body.len(), + exchange.body + ) + .map_err(|_| ApiError::InvalidWirePayload)?; + Ok(request) +} + +/// Compose one HTTP/1.1 project-history POST from the typed consumer exchange. +/// +/// # Errors +/// +/// Returns the same fail-closed errors as +/// [`ProjectHistoryCliInvocation::validate`]. +pub fn compose_project_history_cli_http( + invocation: &ProjectHistoryCliInvocation, +) -> Result { + invocation.validate()?; + let exchange = lineageweave_project_history_exchange(&invocation.origin, &invocation.request)?; + loopback_http1_from_project_history_exchange(&exchange, &invocation.host) +} + +/// Dispatch one project-history CLI invocation against an in-process listener. +/// +/// # Errors +/// +/// Returns fail-closed validation errors before the HTTP handler runs. +pub fn dispatch_project_history_cli( + service: &mut AnalysisRunLiveService, + invocation: &ProjectHistoryCliInvocation, +) -> Result { + let request = compose_project_history_cli_http(invocation)?; + Ok(service.handle_http_request(&request)) +} + +/// Execute one project-history CLI invocation over loopback TCP. +/// +/// # Errors +/// +/// Returns fail-closed validation, transport, or response-framing errors. +pub fn execute_project_history_cli( + invocation: &ProjectHistoryCliInvocation, +) -> Result { + let addr = require_loopback_host(&invocation.host)?; + let request = compose_project_history_cli_http(invocation)?; + let mut stream = TcpStream::connect(addr).map_err(|error| map_io_error(&error))?; + stream + .set_read_timeout(Some(NARUON_LIVE_IO_TIMEOUT)) + .map_err(|error| map_io_error(&error))?; + stream + .set_write_timeout(Some(NARUON_LIVE_IO_TIMEOUT)) + .map_err(|error| map_io_error(&error))?; + stream + .write_all(request.as_bytes()) + .map_err(|error| map_io_error(&error))?; + stream.flush().map_err(|error| map_io_error(&error))?; + let bytes = read_bounded(&mut stream, MAXIMUM_HTTP_RESPONSE_BYTES)?; + parse_http_response(&bytes) +} + +/// Filter CLI stdout so project-history never prints scientific acceptance. +/// +/// # Errors +/// +/// Returns [`ApiError::InvalidWirePayload`] when the body is empty, carries +/// metric or causal-score keys, or a success body is not a +/// `temporal_association_only` projection for the requested project. +pub fn render_project_history_cli_stdout( + invocation: &ProjectHistoryCliInvocation, + response: &NaruonLiveResponse, +) -> Result { + invocation.validate()?; + if response.body.is_empty() { + return Err(ApiError::InvalidWirePayload); + } + refuse_scientific_acceptance(&response.body)?; + refuse_metrics_on_project_history_cli_payload(&response.body)?; + if !(200..300).contains(&response.status_code) { + return Err(ApiError::InvalidWirePayload); + } + if response.status_code != 200 { + return Err(ApiError::InvalidWirePayload); + } + let projection = ProjectHistoryProjection::from_json(&response.body)?; + if projection != project_history_projection(&invocation.request)? { + return Err(ApiError::InvalidWirePayload); + } + projection.to_json() +} + +fn refuse_scientific_acceptance(body: &str) -> Result<(), ApiError> { + if body.contains(SCIENTIFIC_ACCEPTANCE_SCHEMA) { + Err(ApiError::InvalidWirePayload) + } else { + Ok(()) + } +} + +/// Refuse project-history JSON that already carries scientific-metric keys. +/// +/// Empty payloads are admitted so missing stdin can fail later as invalid +/// wire. Non-object JSON fails closed. +/// +/// # Errors +/// +/// Returns [`ApiError::InvalidWirePayload`] when a forbidden metric or causal +/// key is present or the payload is a non-empty non-object. +pub fn refuse_metrics_on_project_history_cli_payload(payload: &str) -> Result<(), ApiError> { + const FORBIDDEN: [&str; 13] = [ + "rmse", + "rmse_standard_error", + "mean_bias", + "bias_standard_error", + "interval_coverage", + "coverage_wilson_lower", + "coverage_wilson_upper", + "temporal_order_accuracy", + "se_gate_accepted", + "scientific_acceptance", + "causal_score", + "causality", + "terminal_result", + ]; + if payload.trim().is_empty() { + return Ok(()); + } + let value: serde_json::Value = + serde_json::from_str(payload).map_err(|_| ApiError::InvalidWirePayload)?; + if !value.is_object() { + return Err(ApiError::InvalidWirePayload); + } + if contains_forbidden(&value, &FORBIDDEN) { + return Err(ApiError::InvalidWirePayload); + } + Ok(()) +} + +fn contains_forbidden(value: &serde_json::Value, forbidden: &[&str]) -> bool { + match value { + serde_json::Value::Object(object) => object.iter().any(|(key, nested)| { + forbidden.contains(&key.as_str()) || contains_forbidden(nested, forbidden) + }), + serde_json::Value::Array(values) => values + .iter() + .any(|nested| contains_forbidden(nested, forbidden)), + _ => false, + } +} + +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)?; + if header_block.len() > NARUON_LIVE_HEADER_BYTE_LIMIT { + return Err(ApiError::LimitExceeded); + } + let mut lines = header_block.split("\r\n"); + let status_line = lines.next().ok_or(ApiError::InvalidWirePayload)?; + let (version, status) = status_line + .split_once(' ') + .ok_or(ApiError::InvalidWirePayload)?; + if version != "HTTP/1.1" { + return Err(ApiError::InvalidWirePayload); + } + let (code, reason) = status.split_once(' ').ok_or(ApiError::InvalidWirePayload)?; + let code = code + .parse::() + .map_err(|_| ApiError::InvalidWirePayload)?; + let reason_phrase = static_reason(code)?; + if reason != reason_phrase { + return Err(ApiError::InvalidWirePayload); + } + let mut content_length = None; + let mut seen = HashSet::new(); + for (index, line) in lines.enumerate() { + if index >= NARUON_LIVE_HEADER_COUNT_LIMIT { + return Err(ApiError::LimitExceeded); + } + let (name, value) = line.split_once(':').ok_or(ApiError::InvalidWirePayload)?; + if !valid_http_field_name(name) + || value + .chars() + .any(|character| character.is_control() && character != '\t') + || !seen.insert(name.to_ascii_lowercase()) + || name.eq_ignore_ascii_case("transfer-encoding") + { + return Err(ApiError::InvalidWirePayload); + } + if name.eq_ignore_ascii_case("content-length") { + content_length = Some( + value + .trim() + .parse::() + .map_err(|_| ApiError::InvalidWirePayload)?, + ); + } + } + let declared = content_length.ok_or(ApiError::InvalidWirePayload)?; + if declared > DEFAULT_PROJECT_HISTORY_BYTE_LIMIT { + return Err(ApiError::LimitExceeded); + } + if declared != body.len() { + return Err(ApiError::InvalidWirePayload); + } + Ok(NaruonLiveResponse { + status_code: code, + reason_phrase, + body: body.to_owned(), + }) +} + +fn static_reason(code: u16) -> Result<&'static str, ApiError> { + match code { + 200 => Ok("OK"), + 202 => Ok("Accepted"), + 400 => Ok("Bad Request"), + 403 => Ok("Forbidden"), + 413 => Ok("Payload Too Large"), + 422 => Ok("Unprocessable Entity"), + _ => Err(ApiError::InvalidWirePayload), + } +} + +/// Read stdin leftover bytes on a non-terminal; query requires JSON. +/// +/// # Errors +/// +/// Returns [`ApiError::InvalidWirePayload`] when stdin cannot be read. +pub fn read_project_history_cli_stdin( + stdin_is_terminal: bool, + mut stdin: impl Read, +) -> Result { + if stdin_is_terminal { + Ok(String::new()) + } else { + let bytes = read_bounded(&mut stdin, DEFAULT_PROJECT_HISTORY_BYTE_LIMIT)?; + String::from_utf8(bytes).map_err(|_| ApiError::InvalidWirePayload) + } +} + +fn read_bounded(reader: &mut impl Read, maximum_bytes: usize) -> Result, ApiError> { + let mut bytes = Vec::new(); + reader + .take((maximum_bytes + 1) as u64) + .read_to_end(&mut bytes) + .map_err(|error| map_io_error(&error))?; + if bytes.len() > maximum_bytes { + return Err(ApiError::LimitExceeded); + } + Ok(bytes) +} + +fn valid_http_field_name(name: &str) -> bool { + !name.is_empty() + && name.bytes().all(|byte| { + byte.is_ascii_alphanumeric() + || matches!( + byte, + b'!' | b'#' + | b'$' + | b'%' + | b'&' + | b'\'' + | b'*' + | b'+' + | b'-' + | b'.' + | b'^' + | b'_' + | b'`' + | b'|' + | b'~' + ) + }) +} + +#[cfg(test)] +#[allow(clippy::too_many_lines)] +mod tests { + use std::fmt::Write as _; + + use super::{ + ProjectHistoryCliInvocation, ProjectHistoryCliVerb, SCIENTIFIC_ACCEPTANCE_SCHEMA, + compose_project_history_cli_http, dispatch_project_history_cli, + execute_project_history_cli, loopback_http1_from_project_history_exchange, + parse_http_response, read_project_history_cli_stdin, render_project_history_cli_stdout, + static_reason, + }; + use crate::{ + AnalysisRunLiveService, ApiError, DEFAULT_PROJECT_HISTORY_BYTE_LIMIT, + LINEAGEWEAVE_CONSUMER_CODE, NARUON_CONSUMER_CODE, NARUON_LIVE_HEADER_BYTE_LIMIT, + NARUON_LIVE_HEADER_COUNT_LIMIT, NaruonLiveResponse, NaruonLiveService, + PROJECT_HISTORY_CONTRACT_VERSION, ProjectHistoryEvent, ProjectHistoryHttpExchange, + ProjectHistoryProjection, ProjectHistoryRequest, lineageweave_project_history_exchange, + }; + + const ORIGIN: &str = "https://tepp.example.test"; + + fn sample_event() -> ProjectHistoryEvent { + ProjectHistoryEvent { + event_id: "event-voc".into(), + event_type_code: "voc_received".into(), + event_title: "VOC received".into(), + occurred_at: "2026-07-30T09:00:00Z".into(), + available_at: "2026-07-30T09:00:00Z".into(), + source_post_id: "post-voc".into(), + evidence_text: "evidence for VOC received".into(), + actor_ids: vec!["person-3".into()], + } + } + + fn query_body() -> String { + ProjectHistoryRequest { + contract_version: PROJECT_HISTORY_CONTRACT_VERSION, + idempotency_key: "lineageweave-project-cli-1".into(), + tenant_workspace_id: "tenant-demo".into(), + project_key: "project-acme".into(), + project_name: "Acme renewal".into(), + knowledge_cutoff: "2026-08-19T23:59:59Z".into(), + focus_event_id: "event-voc".into(), + events: vec![ + ProjectHistoryEvent { + event_id: "event-award".into(), + event_type_code: "contract_awarded".into(), + event_title: "Contract award".into(), + occurred_at: "2022-03-11T09:00:00Z".into(), + available_at: "2022-03-11T09:00:00Z".into(), + source_post_id: "post-award".into(), + evidence_text: "evidence for Contract award".into(), + actor_ids: vec!["person-1".into()], + }, + sample_event(), + ], + } + .to_json() + .expect("json") + } + + fn query_args() -> [&'static str; 7] { + [ + "query", + "--host", + "127.0.0.1:18081", + "--origin", + ORIGIN, + "--consumer", + LINEAGEWEAVE_CONSUMER_CODE, + ] + } + + #[test] + fn verbs_and_from_args_fail_closed() { + assert_eq!( + ProjectHistoryCliVerb::parse("query").expect("verb"), + ProjectHistoryCliVerb::Query + ); + assert_eq!(ProjectHistoryCliVerb::Query.as_str(), "query"); + assert_eq!( + ProjectHistoryCliVerb::parse("QUERY"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + ProjectHistoryCliVerb::parse("authorize"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + ProjectHistoryCliInvocation::from_args(Vec::::new(), "").unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + ProjectHistoryCliInvocation::from_args( + [ + "query", + "--host", + "8.8.8.8:80", + "--origin", + ORIGIN, + "--consumer", + LINEAGEWEAVE_CONSUMER_CODE + ], + query_body() + ) + .unwrap_err(), + ApiError::AuthorizationDenied + ); + for args in [ + vec!["query", "host", "value"], + vec!["query", "--unknown", "value"], + vec!["query", "--host"], + vec!["query", "--host", "127.0.0.1:1", "--host", "127.0.0.1:2"], + ] { + assert_eq!( + ProjectHistoryCliInvocation::from_args(args, query_body()).unwrap_err(), + ApiError::InvalidWirePayload + ); + } + assert_eq!( + ProjectHistoryCliInvocation::from_args( + [ + "query", + "--host", + "127.0.0.1:18081", + "--origin", + "http://tepp.example.test", + "--consumer", + LINEAGEWEAVE_CONSUMER_CODE + ], + query_body() + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + ProjectHistoryCliInvocation::from_args( + [ + "query", + "--host", + "localhost:18081", + "--origin", + ORIGIN, + "--consumer", + LINEAGEWEAVE_CONSUMER_CODE + ], + query_body() + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + ProjectHistoryCliInvocation::from_args( + [ + "query", + "--host", + "127.0.0.1:18081", + "--origin", + ORIGIN, + "--authorization", + "secret" + ], + query_body() + ) + .unwrap_err(), + ApiError::AuthorizationDenied + ); + } + + #[test] + fn from_args_refuses_naruon_metrics_and_empty_body() { + assert_eq!( + ProjectHistoryCliInvocation::from_args( + [ + "query", + "--host", + "127.0.0.1:18081", + "--origin", + ORIGIN, + "--consumer", + NARUON_CONSUMER_CODE + ], + query_body() + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + ProjectHistoryCliInvocation::from_args( + [ + "query", + "--host", + "127.0.0.1:18081", + "--origin", + ORIGIN, + "--consumer", + "unpublished" + ], + query_body() + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + ProjectHistoryCliInvocation::from_args(query_args(), r#"{"rmse":1.0}"#).unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + ProjectHistoryCliInvocation::from_args(query_args(), "").unwrap_err(), + ApiError::InvalidWirePayload + ); + let injected = + query_body().replace("lineageweave-project-cli-1", r"safe\r\nx-api-key: secret"); + assert_eq!( + ProjectHistoryCliInvocation::from_args(query_args(), injected).unwrap_err(), + ApiError::InvalidWirePayload + ); + } + + #[test] + fn compose_is_typed_https_post_without_credentials() { + let invocation = + ProjectHistoryCliInvocation::from_args(query_args(), query_body()).expect("inv"); + let http = compose_project_history_cli_http(&invocation).expect("http"); + assert!(http.starts_with("POST /v1/project-histories HTTP/1.1")); + assert!(http.contains("tepp-consumer: lineageweave")); + assert!(http.contains("idempotency-key: lineageweave-project-cli-1")); + assert!(!http.to_ascii_lowercase().contains("authorization")); + assert!(!http.contains("/analysis-runs")); + assert!(!http.contains("/v1/exports")); + assert!(!http.contains("/v1/temporal-context")); + assert!(!http.contains(SCIENTIFIC_ACCEPTANCE_SCHEMA)); + assert!(!http.contains("rmse")); + } + + #[test] + fn dispatch_returns_association_only_and_naruon_live_stays_post_only() { + let mut service = AnalysisRunLiveService::new(); + let invocation = + ProjectHistoryCliInvocation::from_args(query_args(), query_body()).expect("inv"); + let got = dispatch_project_history_cli(&mut service, &invocation).expect("dispatch"); + assert_eq!(got.status_code, 200, "{}", got.body); + let stdout = render_project_history_cli_stdout(&invocation, &got).expect("stdout"); + let projection = ProjectHistoryProjection::from_json(&stdout).expect("projection"); + assert_eq!(projection.project_key, "project-acme"); + assert_eq!(projection.focus_event_id, "event-voc"); + assert_eq!(projection.inference_status, "temporal_association_only"); + assert!(!stdout.contains(SCIENTIFIC_ACCEPTANCE_SCHEMA)); + assert!(!stdout.contains("rmse")); + assert!(!stdout.contains("causal_score")); + + let http = compose_project_history_cli_http(&invocation).expect("http"); + let mut naruon = NaruonLiveService::new(); + assert_eq!(naruon.handle_http_request(&http).status_code, 400); + } + + #[test] + fn loopback_http1_refuses_non_post_and_wrong_path() { + let invocation = + ProjectHistoryCliInvocation::from_args(query_args(), query_body()).expect("inv"); + let mut exchange = + lineageweave_project_history_exchange(&invocation.origin, &invocation.request) + .expect("exchange"); + exchange.method = "GET"; + assert_eq!( + loopback_http1_from_project_history_exchange(&exchange, &invocation.host).unwrap_err(), + ApiError::InvalidWirePayload + ); + let collection = ProjectHistoryHttpExchange { + method: "POST", + target_url: "https://tepp.example.test/v1/temporal-context".into(), + headers: exchange.headers.clone(), + body: exchange.body.clone(), + }; + assert_eq!( + loopback_http1_from_project_history_exchange(&collection, &invocation.host) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + + let fresh = lineageweave_project_history_exchange(&invocation.origin, &invocation.request) + .expect("exchange"); + let mut injected = fresh.clone(); + injected.headers[3].1 = "safe\r\nx-api-key: secret".into(); + assert_eq!( + loopback_http1_from_project_history_exchange(&injected, &invocation.host).unwrap_err(), + ApiError::InvalidWirePayload + ); + let mut extra = fresh; + extra.headers.push(("x-extra".into(), "value".into())); + assert_eq!( + loopback_http1_from_project_history_exchange(&extra, &invocation.host).unwrap_err(), + ApiError::InvalidWirePayload + ); + let mut duplicate = + lineageweave_project_history_exchange(&invocation.origin, &invocation.request) + .expect("exchange"); + duplicate + .headers + .push(("Content-Type".into(), "application/json".into())); + assert_eq!( + loopback_http1_from_project_history_exchange(&duplicate, &invocation.host).unwrap_err(), + ApiError::InvalidWirePayload + ); + let mut missing = + lineageweave_project_history_exchange(&invocation.origin, &invocation.request) + .expect("exchange"); + missing.headers.pop(); + assert_eq!( + loopback_http1_from_project_history_exchange(&missing, &invocation.host).unwrap_err(), + ApiError::InvalidWirePayload + ); + let mut credential = missing.clone(); + credential + .headers + .push(("authorization".into(), "secret".into())); + assert_eq!( + loopback_http1_from_project_history_exchange(&credential, &invocation.host) + .unwrap_err(), + ApiError::AuthorizationDenied + ); + let mut malformed = missing; + malformed.headers.push(("bad name".into(), "value".into())); + assert_eq!( + loopback_http1_from_project_history_exchange(&malformed, &invocation.host).unwrap_err(), + ApiError::InvalidWirePayload + ); + } + + #[test] + fn render_refuses_metrics_schema_and_identity_mismatch() { + let invocation = + ProjectHistoryCliInvocation::from_args(query_args(), query_body()).expect("inv"); + assert_eq!( + render_project_history_cli_stdout( + &invocation, + &NaruonLiveResponse { + status_code: 200, + reason_phrase: "OK", + body: String::new(), + } + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + let mut later_cutoff = + crate::project_history_projection(&invocation.request).expect("projection"); + later_cutoff.knowledge_cutoff = "2026-08-20T23:59:59Z".into(); + assert_eq!( + render_project_history_cli_stdout( + &invocation, + &NaruonLiveResponse { + status_code: 200, + reason_phrase: "OK", + body: later_cutoff.to_json().expect("projection json"), + } + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + let mut mismatched = + crate::project_history_projection(&invocation.request).expect("projection"); + mismatched.project_key = "other-project".into(); + assert_eq!( + render_project_history_cli_stdout( + &invocation, + &NaruonLiveResponse { + status_code: 200, + reason_phrase: "OK", + body: mismatched.to_json().expect("projection json"), + } + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + let valid = crate::project_history_projection(&invocation.request) + .expect("projection") + .to_json() + .expect("projection json"); + let mut wrong_focus = invocation.clone(); + wrong_focus.request.focus_event_id = "event-award".into(); + assert_eq!( + render_project_history_cli_stdout( + &wrong_focus, + &NaruonLiveResponse { + status_code: 200, + reason_phrase: "OK", + body: valid, + } + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + render_project_history_cli_stdout( + &invocation, + &NaruonLiveResponse { + status_code: 202, + reason_phrase: "Accepted", + body: r#"{"error":"pending"}"#.into(), + } + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + super::refuse_metrics_on_project_history_cli_payload("[]").unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + render_project_history_cli_stdout( + &invocation, + &NaruonLiveResponse { + status_code: 400, + reason_phrase: "Bad Request", + body: r#"{"error":"invalid"}"#.into(), + } + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + render_project_history_cli_stdout( + &invocation, + &NaruonLiveResponse { + status_code: 200, + reason_phrase: "OK", + body: r#"{"inference_status":"temporal_association_only","rmse":1.0}"#.into(), + } + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + render_project_history_cli_stdout( + &invocation, + &NaruonLiveResponse { + status_code: 200, + reason_phrase: "OK", + body: format!(r#"{{"schema_version":"{SCIENTIFIC_ACCEPTANCE_SCHEMA}"}}"#), + } + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + } + + #[test] + fn execute_over_tcp_and_stdin_reader() { + let mut service = AnalysisRunLiveService::bind_loopback().expect("bind"); + let addr = service.local_addr().expect("addr"); + let handle = std::thread::spawn(move || { + drop(service.serve_one()); + }); + let mut invocation = + ProjectHistoryCliInvocation::from_args(query_args(), query_body()).expect("inv"); + invocation.host = addr.to_string(); + let response = execute_project_history_cli(&invocation).expect("tcp"); + assert_eq!(response.status_code, 200, "{}", response.body); + handle.join().expect("join"); + + invocation.host = "127.0.0.1:1".into(); + assert_eq!( + execute_project_history_cli(&invocation).unwrap_err(), + ApiError::InvalidWirePayload + ); + + let parsed = + parse_http_response(b"HTTP/1.1 200 OK\r\ncontent-length: 2\r\n\r\n{}").expect("parse"); + assert_eq!(parsed.status_code, 200); + assert_eq!( + parse_http_response(b"not-http").unwrap_err(), + ApiError::InvalidWirePayload + ); + for response in [ + "HTTP/1.0 200 OK\r\ncontent-length: 2\r\n\r\n{}".to_owned(), + "HTTP/1.1 200 Wrong\r\ncontent-length: 2\r\n\r\n{}".to_owned(), + "HTTP/1.1 200 OK\r\ncontent-length: 1\r\n\r\n{}".to_owned(), + ] { + assert_eq!( + parse_http_response(response.as_bytes()).unwrap_err(), + ApiError::InvalidWirePayload + ); + } + assert_eq!( + parse_http_response( + b"HTTP/1.1 200 OK\r\ntransfer-encoding: chunked\r\ncontent-length: 2\r\n\r\n{}" + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + for response in [ + b"HTTP/1.1 200 OK\r\n: value\r\ncontent-length: 2\r\n\r\n{}".as_slice(), + b"HTTP/1.1 200 OK\r\nx-value: bad\0value\r\ncontent-length: 2\r\n\r\n{}".as_slice(), + b"HTTP/1.1 200 OK\r\nx-value: one\r\nX-Value: two\r\ncontent-length: 2\r\n\r\n{}" + .as_slice(), + ] { + assert_eq!( + parse_http_response(response).unwrap_err(), + ApiError::InvalidWirePayload + ); + } + assert_eq!( + parse_http_response( + b"HTTP/1.1 200 OK\r\nx-value:\tvalue\r\ncontent-length: 2\r\n\r\n{}" + ) + .expect("tab header") + .status_code, + 200 + ); + let oversized = format!( + "HTTP/1.1 200 OK\r\ncontent-length: {}\r\n\r\n", + DEFAULT_PROJECT_HISTORY_BYTE_LIMIT + 1 + ); + assert_eq!( + parse_http_response(oversized.as_bytes()).unwrap_err(), + ApiError::LimitExceeded + ); + let long_header = format!( + "HTTP/1.1 200 OK\r\nx-long: {}\r\ncontent-length: 2\r\n\r\n{{}}", + "x".repeat(NARUON_LIVE_HEADER_BYTE_LIMIT) + ); + assert_eq!( + parse_http_response(long_header.as_bytes()).unwrap_err(), + ApiError::LimitExceeded + ); + let mut many_headers = String::new(); + for index in 0..NARUON_LIVE_HEADER_COUNT_LIMIT { + write!(many_headers, "x-{index}: value\r\n").expect("header"); + } + let crowded = format!("HTTP/1.1 200 OK\r\n{many_headers}content-length: 2\r\n\r\n{{}}"); + assert_eq!( + parse_http_response(crowded.as_bytes()).unwrap_err(), + ApiError::LimitExceeded + ); + assert_eq!(static_reason(200).expect("200"), "OK"); + for (code, reason) in [ + (202, "Accepted"), + (400, "Bad Request"), + (403, "Forbidden"), + (413, "Payload Too Large"), + (422, "Unprocessable Entity"), + ] { + assert_eq!(static_reason(code).expect("known status"), reason); + } + assert_eq!( + static_reason(500).unwrap_err(), + ApiError::InvalidWirePayload + ); + let empty = read_project_history_cli_stdin(true, std::io::empty()).expect("tty"); + assert!(empty.is_empty()); + let piped = + read_project_history_cli_stdin(false, std::io::Cursor::new(b"{}")).expect("piped"); + assert_eq!(piped, "{}"); + let exact = vec![b'x'; DEFAULT_PROJECT_HISTORY_BYTE_LIMIT]; + assert_eq!( + read_project_history_cli_stdin(false, std::io::Cursor::new(exact)) + .expect("bounded stdin") + .len(), + DEFAULT_PROJECT_HISTORY_BYTE_LIMIT + ); + let excess = vec![b'x'; DEFAULT_PROJECT_HISTORY_BYTE_LIMIT + 1]; + assert_eq!( + read_project_history_cli_stdin(false, std::io::Cursor::new(excess)).unwrap_err(), + ApiError::LimitExceeded + ); + } +} diff --git a/crates/tepp_api/src/project_history_stored_request_cli.rs b/crates/tepp_api/src/project_history_stored_request_cli.rs new file mode 100644 index 000000000..08b83cda8 --- /dev/null +++ b/crates/tepp_api/src/project_history_stored_request_cli.rs @@ -0,0 +1,701 @@ +//! Operator loopback CLI for `LineageWeave` project-history stored-request GET. +//! +//! GAP-003A unique slice: operators run `tepp-project-history-request get` to mint +//! `lineageweave_project_history_stored_request_exchange` onto spawned +//! `tepp-loopback` TCP. Stdout is the stored `ProjectHistoryRequest`. +//! Stored projection `inference_status` remains `temporal_association_only`. +//! `tepp.scientific_acceptance.v1` never appears. The CLI does not infer +//! causality. Naruon is refused on this `LineageWeave`-owned adapter. +//! `NaruonLiveService` stays POST-only. This module does not duplicate stored-request GET (#455), GET-by-id +//! HTTP (#429), retrieval CLI (#431), collection GET/CLI (#424/#428), +//! POST CLI (#420), interpretation-run stored-request CLI (#454), cancel +//! lineages (closed), Leiden, or GAP-010 Figma/export. Persistence remains +//! GAP-003B. + +use std::collections::HashSet; +use std::fmt::Write as _; +use std::io::{Read, Write}; +use std::net::{SocketAddr, TcpStream}; + +use crate::live_http::map_io_error; +use crate::naruon_http::header_is_credential; +use crate::wire::require_nonempty; +use crate::{ + AnalysisRunLiveService, ApiError, DEFAULT_PROJECT_HISTORY_BYTE_LIMIT, ErrorEnvelope, + LINEAGEWEAVE_CONSUMER_CODE, NARUON_LIVE_HEADER_BYTE_LIMIT, NARUON_LIVE_HEADER_COUNT_LIMIT, + NARUON_LIVE_IO_TIMEOUT, NaruonHttpExchange, NaruonLiveResponse, + PROJECT_HISTORY_RETRIEVAL_TENANT_HEADER, ProjectHistoryRequest, + lineageweave_project_history_stored_request_exchange, project_history_stored_request_path_id, + refuse_metrics_on_project_history_stored_request_payload, +}; + +const MAXIMUM_HTTP_RESPONSE_BYTES: usize = + NARUON_LIVE_HEADER_BYTE_LIMIT + 4 + DEFAULT_PROJECT_HISTORY_BYTE_LIMIT; + +/// Supported operator verbs for the loopback project-history stored-request CLI. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ProjectHistoryStoredRequestCliVerb { + /// `GET /v1/project-histories/{idempotency_key}/request`. + Get, +} + +impl ProjectHistoryStoredRequestCliVerb { + /// Parse one exact lowercase verb token. + /// + /// # Errors + /// + /// Returns [`ApiError::InvalidWirePayload`] for an unknown token. + pub fn parse(token: &str) -> Result { + match token { + "get" => Ok(Self::Get), + _ => Err(ApiError::InvalidWirePayload), + } + } + + /// Return the canonical lowercase verb token. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Get => "get", + } + } +} + +/// One operator CLI invocation against a loopback project-history stored-request listener. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ProjectHistoryStoredRequestCliInvocation { + /// CLI verb to execute. + pub verb: ProjectHistoryStoredRequestCliVerb, + /// Loopback `host:port` of `tepp-loopback`. + pub host: String, + /// Published HTTPS origin used to mint the typed stored-request exchange. + pub origin: String, + /// Published modular consumer. Stored-request GET admits `lineageweave` only. + pub consumer: String, + /// Opaque idempotency key that minted the stored create request. + pub idempotency_key: String, + /// Tenant workspace that owns the stored create request. + pub tenant_workspace_id: String, + /// JSON body. Stored-request GET requires empty. + pub body: String, +} + +impl ProjectHistoryStoredRequestCliInvocation { + /// Parse argv plus stdin body into a validated loopback stored-request invocation. + /// + /// # Errors + /// + /// Returns a fail-closed error for unknown verbs, missing required flags, a + /// non-loopback host, a non-`https` origin, an unpublished or naruon + /// consumer, credential-shaped flags, a hostile identity, or a nonempty + /// body. + pub fn from_args(args: I, body: impl Into) -> Result + where + I: IntoIterator, + S: AsRef, + { + let tokens: Vec = args + .into_iter() + .map(|token| token.as_ref().to_owned()) + .collect(); + let (verb_token, rest) = tokens.split_first().ok_or(ApiError::InvalidWirePayload)?; + let verb = ProjectHistoryStoredRequestCliVerb::parse(verb_token)?; + let flags = parse_flags(rest)?; + assemble_invocation(verb, flags, body.into()) + } + + /// Reject a non-loopback host, unpublished consumer, or hostile GET body. + /// + /// # Errors + /// + /// Returns [`ApiError::AuthorizationDenied`] for a non-loopback host and + /// [`ApiError::InvalidWirePayload`] or [`ApiError::LimitExceeded`] for + /// empty, unpublished, naruon, nonempty-body, or oversized fields. + pub fn validate(&self) -> Result<(), ApiError> { + require_loopback_host(&self.host)?; + require_nonempty(&self.origin)?; + if !self.origin.starts_with("https://") { + return Err(ApiError::InvalidWirePayload); + } + require_nonempty(&self.consumer)?; + if self.consumer != LINEAGEWEAVE_CONSUMER_CODE { + return Err(ApiError::InvalidWirePayload); + } + crate::project_history::validate_project_history_registry_identity(&self.idempotency_key)?; + if self.idempotency_key.contains('/') || self.idempotency_key.contains('\0') { + return Err(ApiError::InvalidWirePayload); + } + crate::project_history::validate_project_history_registry_identity( + &self.tenant_workspace_id, + )?; + if !self.body.is_empty() { + return Err(ApiError::InvalidWirePayload); + } + refuse_metrics_on_project_history_stored_request_payload(&self.body)?; + Ok(()) + } +} + +struct ParsedFlags { + host: Option, + origin: Option, + consumer: Option, + idempotency_key: Option, + tenant_workspace_id: Option, +} + +fn parse_flags(rest: &[String]) -> Result { + let mut flags = ParsedFlags { + host: None, + origin: None, + consumer: None, + idempotency_key: None, + tenant_workspace_id: None, + }; + let mut index = 0; + while index < rest.len() { + let flag = rest[index].as_str(); + if !flag.starts_with("--") { + return Err(ApiError::InvalidWirePayload); + } + let name = &flag[2..]; + if header_is_credential(name) { + return Err(ApiError::AuthorizationDenied); + } + let slot = match name { + "host" => &mut flags.host, + "origin" => &mut flags.origin, + "consumer" => &mut flags.consumer, + "idempotency-key" => &mut flags.idempotency_key, + "tenant-workspace-id" => &mut flags.tenant_workspace_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: ProjectHistoryStoredRequestCliVerb, + flags: ParsedFlags, + body: String, +) -> Result { + let invocation = ProjectHistoryStoredRequestCliInvocation { + verb, + host: flags.host.ok_or(ApiError::InvalidWirePayload)?, + origin: flags.origin.ok_or(ApiError::InvalidWirePayload)?, + consumer: flags + .consumer + .unwrap_or_else(|| LINEAGEWEAVE_CONSUMER_CODE.to_owned()), + idempotency_key: flags.idempotency_key.ok_or(ApiError::InvalidWirePayload)?, + tenant_workspace_id: flags + .tenant_workspace_id + .ok_or(ApiError::InvalidWirePayload)?, + body, + }; + invocation.validate()?; + Ok(invocation) +} + +fn require_loopback_host(host: &str) -> Result { + let addr: SocketAddr = host.parse().map_err(|_| ApiError::InvalidWirePayload)?; + if addr.ip().is_loopback() { + Ok(addr) + } else { + Err(ApiError::AuthorizationDenied) + } +} + +/// Render a typed stored-request GET exchange as HTTP/1.1 for a loopback listener. +/// +/// The exchange keeps its HTTPS origin contract. Only the HTTP/1.1 `Host` is +/// the loopback bind address. Public bind hosts fail closed. GET-by-id, collection, cancel extra-segments, and pagination headers fail closed. +/// +/// # Errors +/// +/// Returns [`ApiError::AuthorizationDenied`] for a non-loopback host or a +/// credential-bearing header, and [`ApiError::InvalidWirePayload`] when the +/// exchange is not a GET `/v1/project-histories/{idempotency_key}/request` with an +/// empty body. +pub fn loopback_http1_from_project_history_stored_request_exchange( + exchange: &NaruonHttpExchange, + loopback_host: &str, +) -> Result { + let _addr = require_loopback_host(loopback_host)?; + let host = loopback_host.trim(); + if exchange.method != "GET" { + return Err(ApiError::InvalidWirePayload); + } + if !exchange.body.is_empty() { + return Err(ApiError::InvalidWirePayload); + } + let rest = exchange + .target_url + .strip_prefix("https://") + .ok_or(ApiError::InvalidWirePayload)?; + let path = rest + .find('/') + .map(|index| &rest[index..]) + .ok_or(ApiError::InvalidWirePayload)?; + let _idempotency_key = project_history_stored_request_path_id(path)?; + let mut seen = HashSet::with_capacity(exchange.headers.len()); + let mut has_content_type = false; + let mut has_consumer = false; + let mut has_contract = false; + let mut has_tenant = false; + for (name, value) in &exchange.headers { + if header_is_credential(name) { + return Err(ApiError::AuthorizationDenied); + } + if !valid_http_field_name(name) + || value.chars().any(char::is_control) + || !seen.insert(name.to_ascii_lowercase()) + { + return Err(ApiError::InvalidWirePayload); + } + let valid = match name.to_ascii_lowercase().as_str() { + "content-type" => { + has_content_type = true; + value == "application/json" + } + "tepp-consumer" => { + has_consumer = true; + value == LINEAGEWEAVE_CONSUMER_CODE + } + "tepp-contract-version" => { + has_contract = true; + value == "1" + } + PROJECT_HISTORY_RETRIEVAL_TENANT_HEADER => { + has_tenant = true; + crate::project_history::validate_project_history_registry_identity(value)?; + true + } + _ => false, + }; + if !valid { + return Err(ApiError::InvalidWirePayload); + } + } + if !has_content_type || !has_consumer || !has_contract || !has_tenant { + return Err(ApiError::InvalidWirePayload); + } + let mut request = String::new(); + write!( + request, + "{} {path} HTTP/1.1\r\nHost: {host}\r\n", + exchange.method + ) + .map_err(|_| ApiError::InvalidWirePayload)?; + for (name, value) in &exchange.headers { + write!(request, "{name}: {value}\r\n").map_err(|_| ApiError::InvalidWirePayload)?; + } + write!(request, "content-length: 0\r\n\r\n").map_err(|_| ApiError::InvalidWirePayload)?; + Ok(request) +} + +/// Compose one HTTP/1.1 stored-request GET from the typed `LineageWeave` exchange. +/// +/// # Errors +/// +/// Returns the same fail-closed errors as +/// [`ProjectHistoryStoredRequestCliInvocation::validate`]. +pub fn compose_project_history_stored_request_cli_http( + invocation: &ProjectHistoryStoredRequestCliInvocation, +) -> Result { + invocation.validate()?; + let exchange = lineageweave_project_history_stored_request_exchange( + &invocation.origin, + &invocation.tenant_workspace_id, + &invocation.idempotency_key, + )?; + loopback_http1_from_project_history_stored_request_exchange(&exchange, &invocation.host) +} + +/// Dispatch one stored-request CLI invocation against an in-process listener. +/// +/// # Errors +/// +/// Returns fail-closed validation errors before the HTTP handler runs. +pub fn dispatch_project_history_stored_request_cli( + service: &mut AnalysisRunLiveService, + invocation: &ProjectHistoryStoredRequestCliInvocation, +) -> Result { + let request = compose_project_history_stored_request_cli_http(invocation)?; + Ok(service.handle_http_request(&request)) +} + +/// Execute one stored-request CLI invocation over loopback TCP against `tepp-loopback`. +/// +/// # Errors +/// +/// Returns fail-closed validation, transport, or response-framing errors. +pub fn execute_project_history_stored_request_cli( + invocation: &ProjectHistoryStoredRequestCliInvocation, +) -> Result { + let addr = require_loopback_host(&invocation.host)?; + let request = compose_project_history_stored_request_cli_http(invocation)?; + let mut stream = TcpStream::connect(addr).map_err(|error| map_io_error(&error))?; + stream + .set_read_timeout(Some(NARUON_LIVE_IO_TIMEOUT)) + .map_err(|error| map_io_error(&error))?; + stream + .set_write_timeout(Some(NARUON_LIVE_IO_TIMEOUT)) + .map_err(|error| map_io_error(&error))?; + stream + .write_all(request.as_bytes()) + .map_err(|error| map_io_error(&error))?; + stream.flush().map_err(|error| map_io_error(&error))?; + let bytes = read_bounded(&mut stream, MAXIMUM_HTTP_RESPONSE_BYTES)?; + parse_http_response(&bytes) +} + +/// Filter CLI stdout so stored-request GET never prints scientific acceptance. +/// +/// Evidence text belongs to the stored create request and is admitted. +/// RMSE, bias, coverage, SE-gate, and causal-score keys fail closed. +/// +/// # Errors +/// +/// Returns [`ApiError::InvalidWirePayload`] when a body carries metric keys, +/// `tepp.scientific_acceptance.v1`, a mismatched identity, or a +/// success body that is not a stored `ProjectHistoryRequest`. +pub fn render_project_history_stored_request_cli_stdout( + invocation: &ProjectHistoryStoredRequestCliInvocation, + response: &NaruonLiveResponse, +) -> Result { + invocation.validate()?; + if response.body.is_empty() { + return Err(ApiError::InvalidWirePayload); + } + refuse_metrics_on_project_history_stored_request_payload(&response.body)?; + if !(200..300).contains(&response.status_code) { + let expected_code = match response.status_code { + 400 => "invalid_wire_payload", + 403 => "authorization_denied", + 413 => "limit_exceeded", + 422 => "unsupported_contract_version", + _ => return Err(ApiError::InvalidWirePayload), + }; + let envelope: ErrorEnvelope = + serde_json::from_str(&response.body).map_err(|_| ApiError::InvalidWirePayload)?; + if envelope.error_code() != expected_code { + return Err(ApiError::InvalidWirePayload); + } + return envelope.to_json(); + } + if response.status_code != 200 { + return Err(ApiError::InvalidWirePayload); + } + let stored = ProjectHistoryRequest::from_json(&response.body)?; + if stored.tenant_workspace_id != invocation.tenant_workspace_id + || stored.idempotency_key != invocation.idempotency_key + { + return Err(ApiError::InvalidWirePayload); + } + stored.to_json() +} + +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)?; + if header_block.len() > NARUON_LIVE_HEADER_BYTE_LIMIT { + return Err(ApiError::LimitExceeded); + } + let mut lines = header_block.split("\r\n"); + let status_line = lines.next().ok_or(ApiError::InvalidWirePayload)?; + let (version, status) = status_line + .split_once(' ') + .ok_or(ApiError::InvalidWirePayload)?; + if version != "HTTP/1.1" { + return Err(ApiError::InvalidWirePayload); + } + let (code, reason) = status.split_once(' ').ok_or(ApiError::InvalidWirePayload)?; + let code = code + .parse::() + .map_err(|_| ApiError::InvalidWirePayload)?; + let reason_phrase = static_reason(code)?; + if reason != reason_phrase { + return Err(ApiError::InvalidWirePayload); + } + let mut content_length = None; + let mut seen = HashSet::new(); + for (index, line) in lines.enumerate() { + if index >= NARUON_LIVE_HEADER_COUNT_LIMIT { + return Err(ApiError::LimitExceeded); + } + let (name, value) = line.split_once(':').ok_or(ApiError::InvalidWirePayload)?; + if !valid_http_field_name(name) + || value + .chars() + .any(|character| character.is_control() && character != '\t') + || !seen.insert(name.to_ascii_lowercase()) + || name.eq_ignore_ascii_case("transfer-encoding") + { + return Err(ApiError::InvalidWirePayload); + } + if name.eq_ignore_ascii_case("content-length") { + content_length = Some( + value + .trim() + .parse::() + .map_err(|_| ApiError::InvalidWirePayload)?, + ); + } + } + let declared = content_length.ok_or(ApiError::InvalidWirePayload)?; + if declared > DEFAULT_PROJECT_HISTORY_BYTE_LIMIT { + return Err(ApiError::LimitExceeded); + } + if declared != body.len() { + return Err(ApiError::InvalidWirePayload); + } + Ok(NaruonLiveResponse { + status_code: code, + reason_phrase, + body: body.to_owned(), + }) +} + +fn static_reason(code: u16) -> Result<&'static str, ApiError> { + match code { + 200 => Ok("OK"), + 202 => Ok("Accepted"), + 400 => Ok("Bad Request"), + 403 => Ok("Forbidden"), + 413 => Ok("Payload Too Large"), + 422 => Ok("Unprocessable Entity"), + _ => Err(ApiError::InvalidWirePayload), + } +} + +/// Read stdin leftover bytes on a non-terminal; stored-request GET admits empty. +/// +/// # Errors +/// +/// Returns [`ApiError::InvalidWirePayload`] when stdin cannot be read and +/// [`ApiError::LimitExceeded`] when leftover stdin exceeds the project-history +/// wire limit. +pub fn read_project_history_stored_request_cli_stdin( + stdin_is_terminal: bool, + mut stdin: impl Read, +) -> Result { + if stdin_is_terminal { + Ok(String::new()) + } else { + let bytes = read_bounded(&mut stdin, DEFAULT_PROJECT_HISTORY_BYTE_LIMIT)?; + String::from_utf8(bytes).map_err(|_| ApiError::InvalidWirePayload) + } +} + +fn read_bounded(reader: &mut impl Read, maximum_bytes: usize) -> Result, ApiError> { + let mut bytes = Vec::new(); + reader + .take((maximum_bytes + 1) as u64) + .read_to_end(&mut bytes) + .map_err(|error| map_io_error(&error))?; + if bytes.len() > maximum_bytes { + return Err(ApiError::LimitExceeded); + } + Ok(bytes) +} + +fn valid_http_field_name(name: &str) -> bool { + !name.is_empty() + && name.bytes().all(|byte| { + byte.is_ascii_alphanumeric() + || matches!( + byte, + b'!' | b'#' + | b'$' + | b'%' + | b'&' + | b'\'' + | b'*' + | b'+' + | b'-' + | b'.' + | b'^' + | b'_' + | b'`' + | b'|' + | b'~' + ) + }) +} + +#[cfg(test)] +mod branch_coverage_tests { + use std::io::{self, Cursor, Read}; + + use super::{ + ProjectHistoryStoredRequestCliInvocation, ProjectHistoryStoredRequestCliVerb, + loopback_http1_from_project_history_stored_request_exchange, parse_http_response, + read_project_history_stored_request_cli_stdin, valid_http_field_name, + }; + use crate::{ + ApiError, DEFAULT_PROJECT_HISTORY_BYTE_LIMIT, LINEAGEWEAVE_CONSUMER_CODE, + lineageweave_project_history_stored_request_exchange, + }; + + fn invocation() -> ProjectHistoryStoredRequestCliInvocation { + ProjectHistoryStoredRequestCliInvocation { + verb: ProjectHistoryStoredRequestCliVerb::Get, + host: "127.0.0.1:18081".into(), + origin: "https://tepp.example.test".into(), + consumer: LINEAGEWEAVE_CONSUMER_CODE.into(), + idempotency_key: "idem-a".into(), + tenant_workspace_id: "tenant-a".into(), + body: String::new(), + } + } + + #[test] + fn invocation_and_flag_error_arms_are_covered() { + let mut value = invocation(); + value.origin = "http://tepp.example.test".into(); + assert_eq!(value.validate(), Err(ApiError::InvalidWirePayload)); + value = invocation(); + value.consumer = "naruon".into(); + assert_eq!(value.validate(), Err(ApiError::InvalidWirePayload)); + value = invocation(); + value.body = "{}".into(); + assert_eq!(value.validate(), Err(ApiError::InvalidWirePayload)); + value = invocation(); + value.tenant_workspace_id = "tenant\nother".into(); + assert_eq!(value.validate(), Err(ApiError::InvalidWirePayload)); + value = invocation(); + value.origin = "https://bad/path".into(); + assert!(super::compose_project_history_stored_request_cli_http(&value).is_err()); + + for args in [ + vec!["get", "host"], + vec!["get", "--host"], + vec!["get", "--host", "a", "--host", "b"], + vec!["get", "--host", ""], + ] { + assert!(ProjectHistoryStoredRequestCliInvocation::from_args(args, "").is_err()); + } + } + + #[test] + fn exchange_header_and_target_error_arms_are_covered() { + let origin = "https://tepp.example.test"; + let base = + lineageweave_project_history_stored_request_exchange(origin, "tenant-a", "idem-a") + .expect("exchange"); + let mut cases = Vec::new(); + let mut value = base.clone(); + value.body = "{}".into(); + cases.push(value); + let mut value = base.clone(); + value.target_url = "http://tepp.example.test/v1/project-histories/idem-a".into(); + cases.push(value); + let mut value = base.clone(); + value.target_url = "https://tepp.example.test".into(); + cases.push(value); + for (name, header_value) in [("bad name", "x"), ("x-good", "bad\nvalue")] { + let mut value = base.clone(); + value.headers.push((name.into(), header_value.into())); + cases.push(value); + } + let mut value = base.clone(); + value + .headers + .push(("content-type".into(), "application/json".into())); + cases.push(value); + for index in 0..base.headers.len() { + let mut value = base.clone(); + value.headers.remove(index); + cases.push(value); + } + for value in cases { + assert!( + loopback_http1_from_project_history_stored_request_exchange( + &value, + "127.0.0.1:18081", + ) + .is_err() + ); + } + } + + #[test] + fn response_parser_and_reader_error_arms_are_covered() { + use std::fmt::Write as _; + + let oversized_header = "x".repeat(crate::NARUON_LIVE_HEADER_BYTE_LIMIT + 1); + let mut many_headers = String::new(); + for index in 0..=crate::NARUON_LIVE_HEADER_COUNT_LIMIT { + write!(many_headers, "x-{index}: b\r\n").expect("string write"); + } + let cases = [ + vec![0xff], + b"HTTP/1.1 200 OK".to_vec(), + format!("{oversized_header}\r\n\r\n").into_bytes(), + b"HTTP/1.0 200 OK\r\ncontent-length: 0\r\n\r\n".to_vec(), + b"HTTP/1.1 nope\r\ncontent-length: 0\r\n\r\n".to_vec(), + b"HTTP/1.1 999 Unknown\r\ncontent-length: 0\r\n\r\n".to_vec(), + b"HTTP/1.1 200 Bad\r\ncontent-length: 0\r\n\r\n".to_vec(), + b"HTTP/1.1 200 OK\r\nbad\r\n\r\n".to_vec(), + b"HTTP/1.1 200 OK\r\nbad name: x\r\ncontent-length: 0\r\n\r\n".to_vec(), + b"HTTP/1.1 200 OK\r\nx-good: bad\x01value\r\ncontent-length: 0\r\n\r\n".to_vec(), + b"HTTP/1.1 200 OK\r\nx-good: a\r\nx-good: b\r\ncontent-length: 0\r\n\r\n".to_vec(), + b"HTTP/1.1 200 OK\r\ntransfer-encoding: chunked\r\n\r\n".to_vec(), + b"HTTP/1.1 200 OK\r\ncontent-length: x\r\n\r\n".to_vec(), + b"HTTP/1.1 200 OK\r\n\r\n".to_vec(), + b"HTTP/1.1 200 OK\r\ncontent-length: 1\r\n\r\n".to_vec(), + format!( + "HTTP/1.1 200 OK\r\ncontent-length: {}\r\n\r\n", + DEFAULT_PROJECT_HISTORY_BYTE_LIMIT + 1 + ) + .into_bytes(), + format!("HTTP/1.1 200 OK\r\n{many_headers}content-length: 0\r\n\r\n").into_bytes(), + ]; + for bytes in cases { + assert!(parse_http_response(&bytes).is_err()); + } + for (code, reason) in [ + (202, "Accepted"), + (400, "Bad Request"), + (403, "Forbidden"), + (413, "Payload Too Large"), + (422, "Unprocessable Entity"), + ] { + let response = format!("HTTP/1.1 {code} {reason}\r\ncontent-length: 0\r\n\r\n"); + assert_eq!( + parse_http_response(response.as_bytes()) + .expect("response") + .status_code, + code + ); + } + assert!(read_project_history_stored_request_cli_stdin(false, Cursor::new([0xff])).is_err()); + assert!( + read_project_history_stored_request_cli_stdin( + false, + Cursor::new(vec![b'a'; DEFAULT_PROJECT_HISTORY_BYTE_LIMIT + 1]), + ) + .is_err() + ); + assert!(read_project_history_stored_request_cli_stdin(false, FailingReader).is_err()); + assert!(!valid_http_field_name("")); + assert!(!valid_http_field_name("bad name")); + } + + struct FailingReader; + + impl Read for FailingReader { + fn read(&mut self, _buffer: &mut [u8]) -> io::Result { + Err(io::Error::other("redacted")) + } + } +} diff --git a/crates/tepp_api/src/project_history_stored_request_http.rs b/crates/tepp_api/src/project_history_stored_request_http.rs new file mode 100644 index 000000000..ef4ed65f8 --- /dev/null +++ b/crates/tepp_api/src/project_history_stored_request_http.rs @@ -0,0 +1,280 @@ +//! Provider-owned project-history stored-request GET contracts. +//! +//! GAP-003A unique slice: `GET /v1/project-histories/{idempotency_key}/request` +//! returns the accepted `LineageWeave` create request on `AnalysisRunLiveService` +//! / `tepp-loopback` so operators who hold a retrieval identity do not replay +//! POST. `inference_status` on the live projection remains +//! `temporal_association_only`. `tepp.scientific_acceptance.v1` never appears. +//! This module does not duplicate GET-by-id (#429), retrieval CLI (#431), +//! collection GET/CLI (#424/#428), POST CLI (#420), interpretation-run +//! stored-request GET (#453), cancel lineages (closed), Leiden, or GAP-010 +//! Figma/export. Persistence remains GAP-003B. Naruon is refused. +//! `NaruonLiveService` stays POST-only. + +use crate::naruon_http::{NaruonHttpExchange, compose_https_target}; +use crate::project_history::validate_project_history_registry_identity; +use crate::project_history_retrieval_http::{ + PROJECT_HISTORY_RETRIEVAL_ID_MAX_LEN, PROJECT_HISTORY_RETRIEVAL_TENANT_HEADER, +}; +use crate::wire::require_nonempty; +use crate::{ApiError, PROJECT_HISTORY_PATH}; + +const FORBIDDEN_STORED_REQUEST_KEYS: [&str; 12] = [ + "rmse", + "rmse_standard_error", + "mean_bias", + "bias_standard_error", + "interval_coverage", + "coverage_wilson_lower", + "coverage_wilson_upper", + "temporal_order_accuracy", + "se_gate_accepted", + "se_gate_k", + "scientific_acceptance", + "causal_score", +]; + +/// Extract the opaque idempotency key from +/// `GET /v1/project-histories/{key}/request`. +/// +/// # Errors +/// +/// Returns [`ApiError::InvalidWirePayload`] for collection, GET-by-id, extra +/// segments, a hostile encoding, or an empty identity, and +/// [`ApiError::LimitExceeded`] when oversized. +pub fn project_history_stored_request_path_id(path: &str) -> Result { + let remainder = path + .strip_prefix(PROJECT_HISTORY_PATH) + .ok_or(ApiError::InvalidWirePayload)?; + let encoded = remainder + .strip_prefix('/') + .ok_or(ApiError::InvalidWirePayload)?; + let (encoded_id, rest) = encoded + .split_once('/') + .ok_or(ApiError::InvalidWirePayload)?; + if rest != "request" || encoded_id.is_empty() { + return Err(ApiError::InvalidWirePayload); + } + let idempotency_key = decode_path_segment(encoded_id)?; + require_nonempty(&idempotency_key)?; + if idempotency_key.contains('/') || idempotency_key.contains('\0') { + return Err(ApiError::InvalidWirePayload); + } + if idempotency_key.len() > PROJECT_HISTORY_RETRIEVAL_ID_MAX_LEN { + return Err(ApiError::LimitExceeded); + } + Ok(idempotency_key) +} + +/// Whether `path` is the stored-request extra-segment resource. +#[must_use] +pub fn is_project_history_stored_request_path(path: &str) -> bool { + project_history_stored_request_path_id(path).is_ok() +} + +/// Refuse stored-request JSON that already carries scientific-metric keys. +/// +/// Empty payloads are admitted for the GET request body. +/// +/// # Errors +/// +/// Returns [`ApiError::InvalidWirePayload`] when a forbidden metric or causal +/// key is present. +pub fn refuse_metrics_on_project_history_stored_request_payload( + payload: &str, +) -> Result<(), ApiError> { + if payload.trim().is_empty() { + return Ok(()); + } + let value: serde_json::Value = + serde_json::from_str(payload).map_err(|_| ApiError::InvalidWirePayload)?; + if !value.is_object() { + return Err(ApiError::InvalidWirePayload); + } + refuse_metrics_on_json(&value) +} + +fn refuse_metrics_on_json(value: &serde_json::Value) -> Result<(), ApiError> { + match value { + serde_json::Value::Object(object) => { + if object + .get("schema_version") + .and_then(serde_json::Value::as_str) + == Some("tepp.scientific_acceptance.v1") + { + return Err(ApiError::InvalidWirePayload); + } + if FORBIDDEN_STORED_REQUEST_KEYS + .iter() + .any(|key| object.contains_key(*key)) + { + return Err(ApiError::InvalidWirePayload); + } + for nested in object.values() { + refuse_metrics_on_json(nested)?; + } + Ok(()) + } + serde_json::Value::Array(items) => { + for nested in items { + refuse_metrics_on_json(nested)?; + } + Ok(()) + } + _ => Ok(()), + } +} + +/// Build a credential-free `LineageWeave` stored-request GET exchange. +/// +/// # Errors +/// +/// Returns a fail-closed origin, tenant, or identity error. +pub fn lineageweave_project_history_stored_request_exchange( + origin: &str, + tenant_workspace_id: &str, + idempotency_key: &str, +) -> Result { + validate_project_history_registry_identity(tenant_workspace_id)?; + validate_project_history_registry_identity(idempotency_key)?; + if idempotency_key.contains('/') || idempotency_key.contains('\0') { + return Err(ApiError::InvalidWirePayload); + } + if idempotency_key.len() > PROJECT_HISTORY_RETRIEVAL_ID_MAX_LEN { + return Err(ApiError::LimitExceeded); + } + let encoded_id = encode_path_segment(idempotency_key); + let target_path = format!("{PROJECT_HISTORY_PATH}/{encoded_id}/request"); + let target_url = compose_https_target(origin, &target_path)?; + Ok(NaruonHttpExchange { + method: "GET", + target_url, + headers: vec![ + ("content-type".into(), "application/json".into()), + ("tepp-consumer".into(), "lineageweave".into()), + ("tepp-contract-version".into(), "1".into()), + ( + PROJECT_HISTORY_RETRIEVAL_TENANT_HEADER.into(), + tenant_workspace_id.into(), + ), + ], + body: String::new(), + }) +} + +fn encode_path_segment(value: &str) -> String { + let mut out = String::with_capacity(value.len() + value.len() / 2); + let hex = b"0123456789ABCDEF"; + for byte in value.bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => { + out.push(byte as char); + } + _ => { + out.push('%'); + out.push(hex[usize::from(byte >> 4)] as char); + out.push(hex[usize::from(byte & 0x0F)] as char); + } + } + } + out +} + +fn decode_path_segment(value: &str) -> Result { + let mut out = Vec::with_capacity(value.len()); + let bytes = value.as_bytes(); + let mut index = 0; + while index < bytes.len() { + match bytes[index] { + b'%' => { + if index + 2 >= bytes.len() { + return Err(ApiError::InvalidWirePayload); + } + let hi = from_hex(bytes[index + 1])?; + let lo = from_hex(bytes[index + 2])?; + out.push((hi << 4) | lo); + index += 3; + } + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => { + out.push(bytes[index]); + index += 1; + } + _ => return Err(ApiError::InvalidWirePayload), + } + } + let decoded = String::from_utf8(out).map_err(|_| ApiError::InvalidWirePayload)?; + if decoded.chars().any(char::is_control) { + return Err(ApiError::InvalidWirePayload); + } + Ok(decoded) +} + +fn from_hex(byte: u8) -> Result { + match byte { + b'0'..=b'9' => Ok(byte - b'0'), + b'A'..=b'F' => Ok(byte - b'A' + 10), + b'a'..=b'f' => Ok(byte - b'a' + 10), + _ => Err(ApiError::InvalidWirePayload), + } +} + +#[cfg(test)] +mod tests { + use super::{ + is_project_history_stored_request_path, + lineageweave_project_history_stored_request_exchange, + project_history_stored_request_path_id, + }; + use crate::ApiError; + + #[test] + fn stored_request_exchange_is_lineageweave_get_without_credentials() { + let exchange = lineageweave_project_history_stored_request_exchange( + "https://tepp.example.test", + "history-tenant", + "idem-a", + ) + .expect("exchange"); + assert_eq!(exchange.method, "GET"); + assert!( + exchange + .target_url + .ends_with("/v1/project-histories/idem-a/request") + ); + assert!(exchange.body.is_empty()); + assert!( + !exchange + .headers + .iter() + .any(|(name, _)| name.eq_ignore_ascii_case("authorization") + || name.eq_ignore_ascii_case("idempotency-key")) + ); + assert!(is_project_history_stored_request_path( + "/v1/project-histories/idem-a/request" + )); + assert!(!is_project_history_stored_request_path( + "/v1/project-histories/idem-a" + )); + assert_eq!( + project_history_stored_request_path_id("/v1/project-histories/idem-a/request") + .expect("id"), + "idem-a" + ); + assert_eq!( + project_history_stored_request_path_id("/v1/project-histories/idem-a"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + project_history_stored_request_path_id("/v1/project-histories/idem-a/cancel"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + lineageweave_project_history_stored_request_exchange( + "http://tepp.example.test", + "history-tenant", + "idem-a" + ), + Err(ApiError::InvalidWirePayload) + ); + } +} diff --git a/crates/tepp_api/tests/project_history_cli_contract.rs b/crates/tepp_api/tests/project_history_cli_contract.rs new file mode 100644 index 000000000..6740078cc --- /dev/null +++ b/crates/tepp_api/tests/project_history_cli_contract.rs @@ -0,0 +1,140 @@ +//! Contract tests for the `LineageWeave` project-history loopback CLI. + +use std::io::Write; +use std::process::{Command, Stdio}; + +use tepp_api::{ + AnalysisRunLiveService, ApiError, LINEAGEWEAVE_CONSUMER_CODE, PROJECT_HISTORY_CONTRACT_VERSION, + ProjectHistoryCliInvocation, ProjectHistoryCliVerb, ProjectHistoryEvent, ProjectHistoryRequest, + compose_project_history_cli_http, +}; + +fn query_body() -> String { + ProjectHistoryRequest { + contract_version: PROJECT_HISTORY_CONTRACT_VERSION, + idempotency_key: "lineageweave-project-cli-contract-1".into(), + tenant_workspace_id: "tenant-demo".into(), + project_key: "project-acme".into(), + project_name: "Acme renewal".into(), + knowledge_cutoff: "2026-08-19T23:59:59Z".into(), + focus_event_id: "event-voc".into(), + events: vec![ProjectHistoryEvent { + event_id: "event-voc".into(), + event_type_code: "voc_received".into(), + event_title: "VOC received".into(), + occurred_at: "2026-07-30T09:00:00Z".into(), + available_at: "2026-07-30T09:00:00Z".into(), + source_post_id: "post-voc".into(), + evidence_text: "evidence for VOC received".into(), + actor_ids: vec!["person-3".into()], + }], + } + .to_json() + .expect("json") +} + +#[test] +fn project_history_cli_is_metric_free_post_without_credentials() { + assert_eq!( + ProjectHistoryCliVerb::parse("query").expect("verb"), + ProjectHistoryCliVerb::Query + ); + let invocation = ProjectHistoryCliInvocation::from_args( + [ + "query", + "--host", + "127.0.0.1:18081", + "--origin", + "https://tepp.example.test", + "--consumer", + LINEAGEWEAVE_CONSUMER_CODE, + ], + query_body(), + ) + .expect("invocation"); + let http = compose_project_history_cli_http(&invocation).expect("http"); + assert!(http.starts_with("POST /v1/project-histories HTTP/1.1")); + assert!(http.contains("tepp-consumer: lineageweave")); + assert!(!http.contains("authorization")); + assert!(!http.contains("tepp.scientific_acceptance.v1")); + assert!(!http.contains("/analysis-runs")); + assert!(!http.contains("/v1/exports")); +} + +#[test] +fn project_history_cli_refuses_non_loopback_unknown_verbs_and_metrics() { + assert_eq!( + ProjectHistoryCliInvocation::from_args( + [ + "query", + "--host", + "8.8.8.8:80", + "--origin", + "https://tepp.example.test" + ], + query_body() + ), + Err(ApiError::AuthorizationDenied) + ); + assert_eq!( + ProjectHistoryCliVerb::parse("cancel"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + ProjectHistoryCliInvocation::from_args( + [ + "query", + "--host", + "127.0.0.1:18081", + "--origin", + "https://tepp.example.test" + ], + r#"{"rmse":1.0}"# + ), + Err(ApiError::InvalidWirePayload) + ); +} + +#[test] +fn project_history_binary_queries_loopback_and_rejects_unknown_verbs() { + let binary = env!("CARGO_BIN_EXE_tepp-project-history"); + let rejected = Command::new(binary).arg("unknown").output().expect("run"); + assert!(!rejected.status.success()); + assert!(!rejected.stderr.is_empty()); + + let mut service = AnalysisRunLiveService::bind_loopback().expect("bind"); + let host = service.local_addr().expect("address").to_string(); + let server = std::thread::spawn(move || service.serve_one().expect("serve")); + let mut child = Command::new(binary) + .args([ + "query", + "--host", + &host, + "--origin", + "https://tepp.example.test", + "--consumer", + LINEAGEWEAVE_CONSUMER_CODE, + ]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .expect("spawn"); + child + .stdin + .take() + .expect("stdin") + .write_all(query_body().as_bytes()) + .expect("write"); + let output = child.wait_with_output().expect("wait"); + server.join().expect("join"); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert!( + String::from_utf8(output.stdout) + .expect("utf8") + .contains("temporal_association_only") + ); +} diff --git a/crates/tepp_api/tests/project_history_collection_integrity_contract.rs b/crates/tepp_api/tests/project_history_collection_integrity_contract.rs new file mode 100644 index 000000000..775bcb514 --- /dev/null +++ b/crates/tepp_api/tests/project_history_collection_integrity_contract.rs @@ -0,0 +1,65 @@ +//! RED regressions for the consolidated project-history collection contract. + +use tepp_api::{ + ApiError, PROJECT_HISTORY_COLLECTION_INFERENCE_STATUS, ProjectHistoryCollection, + ProjectHistoryCollectionItem, page_project_history_collection_items, +}; + +fn item(key: &str, project: &str) -> ProjectHistoryCollectionItem { + ProjectHistoryCollectionItem::new( + project, + key, + "2026-08-19T23:59:59Z", + PROJECT_HISTORY_COLLECTION_INFERENCE_STATUS, + ) + .expect("valid item") +} + +#[test] +fn unknown_cursor_fails_closed_instead_of_skipping_histories() { + let error = page_project_history_collection_items( + vec![item("idem-a", "project-a"), item("idem-b", "project-b")], + Some("idem-between"), + 32, + ) + .expect_err("unknown cursor must not become a lexical seek"); + assert_eq!(error, ApiError::InvalidWirePayload); +} + +#[test] +fn collection_rejects_unsorted_duplicate_and_unbound_cursor_pages() { + assert_eq!( + ProjectHistoryCollection::new( + vec![item("idem-b", "project-b"), item("idem-a", "project-a")], + None, + ), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + ProjectHistoryCollection::new( + vec![item("idem-a", "project-a"), item("idem-a", "project-b")], + None, + ), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + ProjectHistoryCollection::new(vec![item("idem-a", "project-a")], Some("idem-z".into())), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + ProjectHistoryCollection::new(Vec::new(), Some("idem-a".into())), + Err(ApiError::InvalidWirePayload) + ); +} + +#[test] +fn known_cursor_pages_strictly_after_that_row() { + let (page, next) = page_project_history_collection_items( + vec![item("idem-c", "project-c"), item("idem-a", "project-a"), item("idem-b", "project-b")], + Some("idem-a"), + 1, + ) + .expect("known cursor"); + assert_eq!(page, vec![item("idem-b", "project-b")]); + assert_eq!(next.as_deref(), Some("idem-b")); +} diff --git a/crates/tepp_api/tests/project_history_stored_request_cli_contract.rs b/crates/tepp_api/tests/project_history_stored_request_cli_contract.rs new file mode 100644 index 000000000..6de470c4d --- /dev/null +++ b/crates/tepp_api/tests/project_history_stored_request_cli_contract.rs @@ -0,0 +1,443 @@ +//! Contract tests for the `LineageWeave` project-history stored-request loopback CLI. + +use tepp_api::{ + AnalysisRunLiveService, ApiError, LINEAGEWEAVE_CONSUMER_CODE, NARUON_CONSUMER_CODE, + NaruonHttpExchange, NaruonLiveResponse, NaruonLiveService, PROJECT_HISTORY_CONTRACT_VERSION, + PROJECT_HISTORY_PATH, PROJECT_HISTORY_RETRIEVAL_ID_MAX_LEN, ProjectHistoryEvent, + ProjectHistoryRequest, ProjectHistoryStoredRequestCliInvocation, + ProjectHistoryStoredRequestCliVerb, compose_project_history_stored_request_cli_http, + dispatch_project_history_stored_request_cli, execute_project_history_stored_request_cli, + lineageweave_project_history_stored_request_exchange, + loopback_http1_from_project_history_stored_request_exchange, + read_project_history_stored_request_cli_stdin, + render_project_history_stored_request_cli_stdout, +}; + +const ORIGIN: &str = "https://tepp.example.test"; +const SCHEMA: &str = "tepp.scientific_acceptance.v1"; + +fn sample_request(idempotency_key: &str, project_key: &str) -> ProjectHistoryRequest { + ProjectHistoryRequest { + contract_version: PROJECT_HISTORY_CONTRACT_VERSION, + idempotency_key: idempotency_key.into(), + tenant_workspace_id: "history-tenant".into(), + project_key: project_key.into(), + project_name: "Project".into(), + knowledge_cutoff: "2026-08-19T23:59:59Z".into(), + focus_event_id: "focus".into(), + events: vec![ProjectHistoryEvent { + event_id: "focus".into(), + event_type_code: "voc_received".into(), + event_title: "VOC".into(), + occurred_at: "2026-08-19T09:00:00Z".into(), + available_at: "2026-08-19T10:00:00Z".into(), + source_post_id: "post".into(), + evidence_text: "explicit evidence".into(), + actor_ids: Vec::new(), + }], + } +} + +fn project_history_post(request: &ProjectHistoryRequest) -> String { + let body = request.to_json().expect("history json"); + format!( + "POST {PROJECT_HISTORY_PATH} HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: {LINEAGEWEAVE_CONSUMER_CODE}\r\ntepp-contract-version: 1\r\nidempotency-key: {}\r\ncontent-length: {}\r\n\r\n{body}", + request.idempotency_key, + body.len() + ) +} + +fn get_args<'a>(host: &'a str, idempotency_key: &'a str, consumer: &'a str) -> [&'a str; 11] { + [ + "get", + "--host", + host, + "--origin", + ORIGIN, + "--consumer", + consumer, + "--idempotency-key", + idempotency_key, + "--tenant-workspace-id", + "history-tenant", + ] +} + +#[test] +fn verbs_and_from_args_fail_closed() { + assert_eq!( + ProjectHistoryStoredRequestCliVerb::parse("get").expect("get"), + ProjectHistoryStoredRequestCliVerb::Get + ); + assert_eq!(ProjectHistoryStoredRequestCliVerb::Get.as_str(), "get"); + assert_eq!( + ProjectHistoryStoredRequestCliVerb::parse("list"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + ProjectHistoryStoredRequestCliInvocation::from_args( + get_args("8.8.8.8:80", "idem-a", LINEAGEWEAVE_CONSUMER_CODE), + "" + ) + .unwrap_err(), + ApiError::AuthorizationDenied + ); + assert_eq!( + ProjectHistoryStoredRequestCliInvocation::from_args( + [ + "get", + "--host", + "localhost:18081", + "--origin", + ORIGIN, + "--idempotency-key", + "idem-a", + "--tenant-workspace-id", + "history-tenant" + ], + "" + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + ProjectHistoryStoredRequestCliInvocation::from_args( + [ + "get", + "--host", + "127.0.0.1:18081", + "--origin", + "http://tepp.example.test", + "--idempotency-key", + "idem-a", + "--tenant-workspace-id", + "history-tenant" + ], + "" + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + ProjectHistoryStoredRequestCliInvocation::from_args( + [ + "get", + "--host", + "127.0.0.1:18081", + "--origin", + ORIGIN, + "--authorization", + "secret", + "--idempotency-key", + "idem-a", + "--tenant-workspace-id", + "history-tenant" + ], + "" + ) + .unwrap_err(), + ApiError::AuthorizationDenied + ); +} + +#[test] +fn from_args_refuses_naruon_slash_body_size_and_pagination() { + assert_eq!( + ProjectHistoryStoredRequestCliInvocation::from_args( + get_args("127.0.0.1:18081", "idem-a", NARUON_CONSUMER_CODE), + "" + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + ProjectHistoryStoredRequestCliInvocation::from_args( + get_args("127.0.0.1:18081", "idem-a", "unpublished"), + "" + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + ProjectHistoryStoredRequestCliInvocation::from_args( + get_args("127.0.0.1:18081", "idem-a", LINEAGEWEAVE_CONSUMER_CODE), + "{}" + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + ProjectHistoryStoredRequestCliInvocation::from_args( + get_args("127.0.0.1:18081", "idem/slash", LINEAGEWEAVE_CONSUMER_CODE), + "" + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + ProjectHistoryStoredRequestCliInvocation::from_args( + get_args( + "127.0.0.1:18081", + &"a".repeat(PROJECT_HISTORY_RETRIEVAL_ID_MAX_LEN + 1), + LINEAGEWEAVE_CONSUMER_CODE + ), + "" + ) + .unwrap_err(), + ApiError::LimitExceeded + ); + assert_eq!( + ProjectHistoryStoredRequestCliInvocation::from_args( + [ + "get", + "--host", + "127.0.0.1:18081", + "--origin", + ORIGIN, + "--idempotency-key", + "idem-a", + "--page-limit", + "1" + ], + "" + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); +} + +#[test] +fn compose_is_typed_https_get_without_credentials() { + let invocation = ProjectHistoryStoredRequestCliInvocation::from_args( + get_args("127.0.0.1:18081", "idem-a", LINEAGEWEAVE_CONSUMER_CODE), + "", + ) + .expect("invocation"); + let http = compose_project_history_stored_request_cli_http(&invocation).expect("http"); + assert!(http.starts_with("GET /v1/project-histories/idem-a/request HTTP/1.1")); + assert!(http.contains("tepp-consumer: lineageweave")); + assert!(!http.to_ascii_lowercase().contains("authorization")); + assert!(!http.contains("rmse")); + assert!(!http.contains(SCHEMA)); +} + +#[test] +fn lineageweave_cli_retrieves_stored_request_and_naruon_live_stays_post_only() { + let mut service = AnalysisRunLiveService::new(); + let request = sample_request("idem-a", "project-a"); + assert_eq!( + service + .handle_http_request(&project_history_post(&request)) + .status_code, + 200 + ); + let invocation = ProjectHistoryStoredRequestCliInvocation::from_args( + get_args("127.0.0.1:18081", "idem-a", LINEAGEWEAVE_CONSUMER_CODE), + "", + ) + .expect("invocation"); + let got = dispatch_project_history_stored_request_cli(&mut service, &invocation).expect("get"); + assert_eq!(got.status_code, 200, "{}", got.body); + let stdout = render_project_history_stored_request_cli_stdout(&invocation, &got).expect("out"); + assert_eq!( + ProjectHistoryRequest::from_json(&stdout).expect("stored"), + request + ); + assert!(stdout.contains("evidence_text")); + assert!(!stdout.contains("rmse")); + assert!(!stdout.contains(SCHEMA)); + let mut mismatched = ProjectHistoryRequest::from_json(&got.body).expect("body"); + mismatched.tenant_workspace_id = "other-tenant".into(); + assert_eq!( + render_project_history_stored_request_cli_stdout( + &invocation, + &NaruonLiveResponse { + status_code: 200, + reason_phrase: "OK", + body: mismatched.to_json().expect("m") + } + ), + Err(ApiError::InvalidWirePayload) + ); + let missing = ProjectHistoryStoredRequestCliInvocation::from_args( + get_args("127.0.0.1:18081", "missing", LINEAGEWEAVE_CONSUMER_CODE), + "", + ) + .expect("missing"); + let denied = + dispatch_project_history_stored_request_cli(&mut service, &missing).expect("denied"); + assert_eq!(denied.status_code, 400); + assert!( + render_project_history_stored_request_cli_stdout(&missing, &denied) + .expect("err") + .contains("invalid_wire_payload") + ); + let mut naruon = NaruonLiveService::new(); + assert_eq!( + naruon + .handle_http_request( + &compose_project_history_stored_request_cli_http(&invocation).expect("composed") + ) + .status_code, + 400 + ); +} + +#[test] +fn render_refuses_metrics_schema_and_empty_success() { + let invocation = ProjectHistoryStoredRequestCliInvocation::from_args( + get_args("127.0.0.1:18081", "idem-a", LINEAGEWEAVE_CONSUMER_CODE), + "", + ) + .expect("invocation"); + assert_eq!( + render_project_history_stored_request_cli_stdout( + &invocation, + &NaruonLiveResponse { + status_code: 200, + reason_phrase: "OK", + body: String::new() + } + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + render_project_history_stored_request_cli_stdout( + &invocation, + &NaruonLiveResponse { + status_code: 200, + reason_phrase: "OK", + body: r#"{"contract_version":1,"rmse":1.0}"#.into() + } + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + render_project_history_stored_request_cli_stdout( + &invocation, + &NaruonLiveResponse { + status_code: 200, + reason_phrase: "OK", + body: format!(r#"{{"schema_version":"{SCHEMA}"}}"#) + } + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); +} + +#[test] +fn loopback_http1_refuses_non_get_collection_get_by_id_cancel_and_credentials() { + let host = "127.0.0.1:18081"; + let exchange = + lineageweave_project_history_stored_request_exchange(ORIGIN, "history-tenant", "idem-a") + .expect("ex"); + let ok = + loopback_http1_from_project_history_stored_request_exchange(&exchange, host).expect("ok"); + assert!(ok.starts_with("GET /v1/project-histories/idem-a/request HTTP/1.1")); + let mut posted = exchange.clone(); + posted.method = "POST"; + assert_eq!( + loopback_http1_from_project_history_stored_request_exchange(&posted, host).unwrap_err(), + ApiError::InvalidWirePayload + ); + let mut by_id = exchange.clone(); + by_id.target_url = format!("{ORIGIN}{PROJECT_HISTORY_PATH}/idem-a"); + assert_eq!( + loopback_http1_from_project_history_stored_request_exchange(&by_id, host).unwrap_err(), + ApiError::InvalidWirePayload + ); + let mut cancel = exchange.clone(); + cancel.target_url = format!("{ORIGIN}{PROJECT_HISTORY_PATH}/idem-a/cancel"); + assert_eq!( + loopback_http1_from_project_history_stored_request_exchange(&cancel, host).unwrap_err(), + ApiError::InvalidWirePayload + ); + let credentialed = NaruonHttpExchange { + method: "GET", + target_url: format!("{ORIGIN}{PROJECT_HISTORY_PATH}/idem-a/request"), + headers: vec![("authorization".into(), "secret".into())], + body: String::new(), + }; + assert_eq!( + loopback_http1_from_project_history_stored_request_exchange(&credentialed, host) + .unwrap_err(), + ApiError::AuthorizationDenied + ); +} + +#[test] +fn execute_over_tcp_and_stdin_reader() { + let mut service = AnalysisRunLiveService::bind_loopback().expect("bind"); + let addr = service.local_addr().expect("addr").to_string(); + let request = sample_request("idem-tcp", "project-tcp"); + assert_eq!( + service + .handle_http_request(&project_history_post(&request)) + .status_code, + 200 + ); + let handle = std::thread::spawn(move || { + drop(service.serve_one()); + }); + let invocation = ProjectHistoryStoredRequestCliInvocation::from_args( + get_args(addr.as_str(), "idem-tcp", LINEAGEWEAVE_CONSUMER_CODE), + "", + ) + .expect("tcp"); + let response = execute_project_history_stored_request_cli(&invocation).expect("execute"); + assert_eq!(response.status_code, 200, "{}", response.body); + let stored = ProjectHistoryRequest::from_json( + &render_project_history_stored_request_cli_stdout(&invocation, &response).expect("stdout"), + ) + .expect("parsed"); + assert_eq!(stored.project_key, "project-tcp"); + handle.join().expect("join"); + assert!( + read_project_history_stored_request_cli_stdin(true, std::io::empty()) + .expect("tty") + .is_empty() + ); + assert!( + read_project_history_stored_request_cli_stdin(false, std::io::Cursor::new(b"")) + .expect("pipe") + .is_empty() + ); +} + +#[test] +fn binary_reports_redacted_success_and_failure_statuses() { + let mut service = AnalysisRunLiveService::bind_loopback().expect("bind"); + let addr = service.local_addr().expect("addr").to_string(); + let request = sample_request("idem-bin", "project-bin"); + assert_eq!( + service + .handle_http_request(&project_history_post(&request)) + .status_code, + 200 + ); + let handle = std::thread::spawn(move || { + service.serve_one().expect("success request"); + service.serve_one().expect("missing request"); + }); + let binary = env!("CARGO_BIN_EXE_tepp-project-history-request"); + let run = |idempotency_key: &str| { + std::process::Command::new(binary) + .args(get_args(&addr, idempotency_key, LINEAGEWEAVE_CONSUMER_CODE)) + .output() + .expect("binary") + }; + let success = run("idem-bin"); + assert!( + success.status.success(), + "{}", + String::from_utf8_lossy(&success.stderr) + ); + assert!(String::from_utf8_lossy(&success.stdout).contains("project-bin")); + let failure = run("missing"); + assert!(!failure.status.success()); + assert!(String::from_utf8_lossy(&failure.stderr).contains("invalid API wire payload")); + handle.join().expect("server"); +} diff --git a/crates/tepp_api/tests/project_history_stored_request_http_contract.rs b/crates/tepp_api/tests/project_history_stored_request_http_contract.rs new file mode 100644 index 000000000..b6d930ec6 --- /dev/null +++ b/crates/tepp_api/tests/project_history_stored_request_http_contract.rs @@ -0,0 +1,42 @@ +//! Contract tests for `LineageWeave` project-history stored-request GET. + +use tepp_api::{ + lineageweave_project_history_stored_request_exchange, project_history_stored_request_path_id, + ApiError, LINEAGEWEAVE_CONSUMER_CODE, +}; + +#[test] +fn stored_request_exchange_is_metric_free_get_without_credentials() { + let exchange = lineageweave_project_history_stored_request_exchange( + "https://tepp.example.test", + "history-tenant", + "idem-a", + ) + .expect("exchange"); + assert_eq!(exchange.method, "GET"); + assert!(exchange + .target_url + .ends_with("/v1/project-histories/idem-a/request")); + assert!(exchange.body.is_empty()); + assert!(exchange + .headers + .iter() + .any(|(name, value)| name == "tepp-consumer" && value == LINEAGEWEAVE_CONSUMER_CODE)); + assert!(!exchange + .headers + .iter() + .any(|(name, _)| name.eq_ignore_ascii_case("authorization") + || name.eq_ignore_ascii_case("idempotency-key"))); + assert_eq!( + project_history_stored_request_path_id("/v1/project-histories/idem-a/request").expect("id"), + "idem-a" + ); + assert_eq!( + project_history_stored_request_path_id("/v1/project-histories/idem-a"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + project_history_stored_request_path_id("/v1/project-histories/idem-a/cancel"), + Err(ApiError::InvalidWirePayload) + ); +} diff --git a/docs/API_CONTRACT.md b/docs/API_CONTRACT.md index 819f70fa3..1c130dd22 100644 --- a/docs/API_CONTRACT.md +++ b/docs/API_CONTRACT.md @@ -70,6 +70,8 @@ POST /v1/analysis-runs/{run_id}/cancel GET /v1/model-artifacts/{artifact_id} GET /v1/exports/{export_id} GET /v1/project-histories +GET /v1/project-histories/{idempotency_key} +GET /v1/project-histories/{idempotency_key}/request ``` Long-running analysis is durable asynchronous work. `POST /v1/analysis-runs` accepts an idempotency key, immutable input snapshot identity, knowledge cutoff, versioned model contract/configuration, and requested output profile. A retry with the same principal/idempotency key and semantically identical request returns the same run identity; a conflicting body fails closed. @@ -104,6 +106,15 @@ cutoff-safe `ProjectHistoryProjection` on `tepp-loopback`. Consumer is `tepp.scientific_acceptance.v1` and causal scores never appear. The retrieval does not infer causality. +`GET /v1/project-histories/{idempotency_key}/request` returns the accepted +LineageWeave create request on `tepp-loopback`. Consumer is `lineageweave` +only. Stored projection `inference_status` remains `temporal_association_only`. +`tepp.scientific_acceptance.v1` and causal scores never appear. + +`tepp-project-history-request get` is the operator-visible loopback client of +that stored-request GET. Empty stdin is admitted. Naruon is refused. Process +exit 0 is not an ADR 0014 claim. + The typed status/read contract returns `accepted`, `running`, `succeeded`, or `failed`. Accepted and running statuses contain no measurement result. A terminal status contains exactly one request-bound diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index dbc6936c6..8503310ac 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -55,6 +55,8 @@ The full APA 7th standards/literature register remains `docs/research/standards- | versioned service/API contracts and exports | PRD; API contract; ADR 0011/0013 | `tepp_api` analysis-run/export/JSON-LD/GraphML contracts on protected main (PR #21); request-bound terminal result active in PR #157; HTTP service remains accepted-target; the `orchestrator_live` loopback interpretation listener is on this PR | partial | | loopback LineageWeave project-history collection GET | ADR 0028; API contract; RFC 9110; ADR 0021/0011 | `tepp_api` `GET /v1/project-histories` on `tepp-loopback`; metric-free `temporal_association_only` identities; `tepp.scientific_acceptance.v1` never appears; does not infer causality | active-PR | | loopback LineageWeave project-history GET-by-id | ADR 0066; API contract; RFC 9110; ADR 0028/0021/0011 | `tepp_api` `GET /v1/project-histories/{idempotency_key}` on `tepp-loopback`; stored `temporal_association_only` projection; `tepp.scientific_acceptance.v1` never appears; does not infer causality | active-PR | +| loopback LineageWeave project-history stored-request GET | ADR 0087; ADR 0066; API contract; RFC 9110; ADR 0021/0011 | `tepp_api` `GET /v1/project-histories/{idempotency_key}/request` on `tepp-loopback`; returns stored create request; projection `inference_status` remains `temporal_association_only`; naruon refused | active-PR | +| loopback LineageWeave project-history stored-request CLI | ADR 0088; ADR 0087; API contract; RFC 9110; ADR 0066/0021/0011 | `tepp-project-history-request` mints stored-request GET onto `tepp-loopback`; returns stored create request; naruon refused; empty stdin admitted | 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/0087-project-history-stored-request-get.md b/docs/adr/0087-project-history-stored-request-get.md new file mode 100644 index 000000000..bb1b84eab --- /dev/null +++ b/docs/adr/0087-project-history-stored-request-get.md @@ -0,0 +1,61 @@ +# ADR 0087 — Loopback project-history stored-request GET + +**Decision status:** Accepted +**Implementation maturity:** active-PR +**Date:** 2026-09-01 +**Supersedes:** None; complements ADR 0066. Does not re-open cancel lineages. +Does not supersede ADR 0014. Unique versus protected main; 0026–0086 occupied. + +## Context + +ADR 0066 retrieves one accepted project-history projection. Operators still +had no extra-segment GET for the stored LineageWeave create request. +Interpretation-run stored-request GET (#453) is orchestrator-owned. Duplicating +GET-by-id (#429), retrieval CLI (#431), collection GET/CLI, POST CLI, Leiden, +or GAP-010 would collide with live PRs. Cancel lineages stay closed. Naruon is +refused on this LineageWeave-owned adapter. + +## Decision + +Publish `GET /v1/project-histories/{idempotency_key}/request` on +`AnalysisRunLiveService`. Extra-segment parse. Slash/NUL fail closed. Empty +body. LineageWeave-only. Tenant header required. `inference_status` on the +stored projection remains `temporal_association_only`. +`tepp.scientific_acceptance.v1` never appears. Cancel extra-segment stays +refused. `NaruonLiveService` stays POST-only. + +## Alternatives considered + +1. Re-open cancel HTTP — rejected. +2. Return GET-by-id projection — rejected (ADR 0066). +3. Loopback stored-request GET — accepted. + +## Consequences + +HTTP 200 is not measurement evidence and is not an ADR 0014 claim. Sequence +remains association, not causation. + +## Failure and recovery + +Naruon, nonempty bodies, extra segments, slash/NUL, missing keys, missing +tenant, and metric keys fail closed. + +## Verification + +- `GET /v1/project-histories/{idempotency_key}/request` of an accepted history + returns the stored create request without RMSE/`tepp.scientific_acceptance.v1`; +- naruon, GET-by-id path, extra segments, slash/NUL, nonempty body, and missing + keys fail closed; +- Clippy `-D warnings`, `tepp_api` tests, rustdoc, and exact-head review remain + required. + +## Rollback and supersession + +Rollback removes the extra-segment GET; POST and GET-by-id remain valid. A +superseding ADR is required to persist the registry, bind a public address, +re-open cancel, emit scientific-acceptance, open naruon on this adapter, add +GET to `NaruonLiveService`, or treat retrieval success as an ADR 0014 claim. + +## Related authority + +ADR 0066, ADR 0028, ADR 0021, ADR 0014, RFC 9110 (Fielding, Nottingham, & Reschke, 2022). diff --git a/docs/adr/0088-project-history-stored-request-cli.md b/docs/adr/0088-project-history-stored-request-cli.md new file mode 100644 index 000000000..d074f4e71 --- /dev/null +++ b/docs/adr/0088-project-history-stored-request-cli.md @@ -0,0 +1,64 @@ +# ADR 0088 — Loopback project-history stored-request CLI + +**Decision status:** Accepted +**Implementation maturity:** active-PR +**Date:** 2026-09-01 +**Supersedes:** None; complements ADR 0087. Does not re-open cancel lineages. +Does not supersede ADR 0014. Unique versus protected main; 0026–0087 occupied. + +## Context + +ADR 0087 publishes `GET /v1/project-histories/{idempotency_key}/request`. +Operators still had no published binary that mints that GET onto spawned +`tepp-loopback` TCP. Duplicating stored-request GET (#455), GET-by-id (#429), +retrieval CLI (#431), collection GET/CLI (#424/#428), POST CLI (#420), +interpretation-run stored-request CLI (#454), Leiden, or GAP-010 would collide +with live PRs. Cancel lineages stay closed. Naruon is refused on this +LineageWeave-owned adapter. + +## Decision + +Publish `tepp-project-history-request get` which mints +`lineageweave_project_history_stored_request_exchange` onto spawned +`tepp-loopback` TCP. Empty stdin is admitted. Nonempty leftover stdin, public +bind, `localhost`, `http` origin, unpublished consumer, naruon, and credential +flags fail closed. Dedicated binary so it does not collide with +`tepp-project-histories` (#428), `tepp-project-history-get` (#431), or +`tepp-project-history` (#420). Stored projection `inference_status` remains +`temporal_association_only`. `tepp.scientific_acceptance.v1` never appears. + +## Alternatives considered + +1. Re-open cancel CLI — rejected. +2. Reuse `tepp-project-history-get` — rejected; that is ADR 0067. +3. Dedicated stored-request binary — accepted. + +## Consequences + +CLI success is not measurement evidence and is not an ADR 0014 claim. +Sequence remains association, not causation. + +## Failure and recovery + +Naruon, nonempty leftover stdin, extra segments, slash/NUL, missing keys, +public bind, `localhost`, and metric keys fail closed. + +## Verification + +- `tepp-project-history-request get` of an accepted history prints the stored + create request without RMSE/`tepp.scientific_acceptance.v1`; +- naruon, public bind, `localhost`, `http` origin, leftover stdin, slash/NUL, + and missing keys fail closed; +- Clippy `-D warnings`, `tepp_api` tests, rustdoc, and exact-head review remain + required. + +## Rollback and supersession + +Rollback removes the published binary; stored-request GET remains valid. A +superseding ADR is required to persist the registry, bind a public address, +re-open cancel, emit scientific-acceptance, open naruon on this adapter, add +GET to `NaruonLiveService`, or treat CLI success as an ADR 0014 claim. + +## Related authority + +ADR 0087, ADR 0066, ADR 0021, ADR 0014, RFC 9110 (Fielding, Nottingham, & Reschke, 2022). diff --git a/docs/adr/README.md b/docs/adr/README.md index c865f298e..43874dc43 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -32,6 +32,8 @@ Read [`ADR_POLICY.md`](ADR_POLICY.md) first. **Decision status and implementatio | [0025](0025-macos-native-rust-mlx-metal-boundary.md) | macOS-native Rust-owned MLX Metal execution | Accepted | accepted-target | Compose authenticates to a native host service; Linux never claims Metal, and actual backend/parity receipts fail closed. | | [0028](0028-project-history-collection-get.md) | Loopback `GET /v1/project-histories` enumerates accepted LineageWeave projections | Accepted | active-PR | Complements ADR 0021/0011; does not supersede ADR 0014. Unique on protected main. Does not infer causality. | | [0066](0066-project-history-retrieval-get.md) | Loopback `GET /v1/project-histories/{idempotency_key}` retrieves one accepted LineageWeave projection | Accepted | active-PR | Complements ADR 0028; unique vs protected main. Does not infer causality. | +| [0087](0087-project-history-stored-request-get.md) | Loopback project-history stored-request GET | Accepted | active-PR | Complements ADR 0066; `GET /v1/project-histories/{idempotency_key}/request` returns the stored create request. Unique versus protected main (0026–0086 occupied). Does not re-open cancel lineages. | +| [0088](0088-project-history-stored-request-cli.md) | Loopback project-history stored-request CLI | Accepted | active-PR | Complements ADR 0087; `tepp-project-history-request get` mints stored-request GET onto `tepp-loopback`. Unique versus protected main (0026–0087 occupied). Does not re-open cancel lineages. | | [0023](0023-lineage-criterion-anchor-contract.md) | TEPP-owned Event Lineage criterion anchor | Accepted | active-PR | PR #237 publishes the strict accepted/rejected artifact and identities; estimator execution remains fail-closed future work. | | [0024](0024-independent-topic-importance-anchor.md) | Posterior topic-context producer contract | Accepted | contract-only active-PR | Strict DTO/schema only; the current estimator does not emit it. fast-mlsirm owns case-deletion influence. | | [0001](0001-rust-first-modular-msa.md) | Rust-first numerical core and CPU `f64` reference | Accepted | partial | ADR 0011 owns cross-service/MSA authority; 0001 retains numerical/backend authority. | diff --git a/docs/research/project-history-cli.md b/docs/research/project-history-cli.md new file mode 100644 index 000000000..5b106ef26 --- /dev/null +++ b/docs/research/project-history-cli.md @@ -0,0 +1,57 @@ +# Project-history CLI (doctoring) + +## Scope + +`tepp-project-history query` is the operator-visible client of loopback +`POST /v1/project-histories` on `AnalysisRunLiveService` / `tepp-loopback`. +The CLI mints `lineageweave_project_history_exchange` and renders onto spawned +`tepp-loopback` TCP. HTTP method, path, and header semantics follow current +HTTP semantics (Fielding, Nottingham, & Reschke, 2022). Fail-closed refusal of +non-loopback hosts, unpublished consumers, naruon on this adapter, +review/Copilot/GitHub credential flags, and scientific-authority promotion is +repository contract authority (ADR 0061; ADR 0021; ADR 0011; ADR 0014), not an +RFC inference rule. + +CLI stdout is the cutoff-safe `ProjectHistoryProjection`. +`inference_status` remains `temporal_association_only`. +`tepp.scientific_acceptance.v1` never appears. Process exit 0 is not a +completed temporal model, calibrated score, theta estimate, uncertainty +statement, causal inference, 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.3 describes POST as a method for processing the enclosed +representation. TEPP maps that processing onto a bounded, cutoff-safe +project-history projection. The RFC does not define psychometric acceptance, +RMSE, causality, or claim promotion. + +### Internal contract evidence + +- `docs/adr/0061-project-history-cli.md` — this client +- `docs/adr/0021-lineageweave-project-history-boundary.md` — HTTP boundary +- `docs/adr/0011-standalone-modular-msa-boundary.md` — modular HTTP boundary +- `docs/adr/0014-scientific-claim-promotion-and-release-evidence.md` — CLI + success is not a scientific claim +- `crates/tepp_api/tests/project_history_cli_contract.rs` — fail-closed + project-history CLI proofs + +## Verification + +- `tepp-project-history query` of a cutoff-safe LineageWeave body returns + `temporal_association_only` without RMSE/bias/coverage/SE-gate keys, + `causal_score`, or `tepp.scientific_acceptance.v1`; +- non-loopback hosts, `localhost`, credential flags, empty stdin, naruon, and + unknown verbs fail closed; +- `NaruonLiveService` still refuses `POST /v1/project-histories`. + +## Non-claims + +This slice does not implement temporal-context CLI, export CLI, analysis-run +CLIs, GET-by-id, wait CLI, lookup CLI, persistence, production TLS, Leiden +consensus, GAP-010 Figma/export, causal inference, or an ADR 0014 scientific +claim-promotion package. diff --git a/docs/research/project-history-stored-request-cli.md b/docs/research/project-history-stored-request-cli.md new file mode 100644 index 000000000..a787f95a6 --- /dev/null +++ b/docs/research/project-history-stored-request-cli.md @@ -0,0 +1,15 @@ +# Project-history stored-request CLI (doctoring) + +`tepp-project-history-request get` mints +`lineageweave_project_history_stored_request_exchange` onto spawned +`tepp-loopback` TCP. HTTP semantics follow RFC 9110 (Fielding, Nottingham, & +Reschke, 2022). Fail-closed naruon, public bind, `localhost`, leftover stdin, +slash/NUL, credential flags, and scientific-authority promotion are repository +contract (ADR 0088; ADR 0014). + +Stdout is the stored create request. Stored projection `inference_status` +remains `temporal_association_only`. `tepp.scientific_acceptance.v1` never +appears. Process exit 0 is not a scientific claim. + +Does not re-open cancel lineages, GAP-010 Figma/export, persistence, Leiden, +or an ADR 0014 claim-promotion package. diff --git a/docs/research/project-history-stored-request-get.md b/docs/research/project-history-stored-request-get.md new file mode 100644 index 000000000..9b7f9cf11 --- /dev/null +++ b/docs/research/project-history-stored-request-get.md @@ -0,0 +1,14 @@ +# Project-history stored-request GET (doctoring) + +`GET /v1/project-histories/{idempotency_key}/request` returns one accepted +LineageWeave create request on `tepp-loopback`. HTTP semantics follow RFC 9110 +(Fielding, Nottingham, & Reschke, 2022). Fail-closed naruon, extra segments, +slash/NUL, leftover bodies, credential flags, and scientific-authority +promotion are repository contract (ADR 0087; ADR 0014). + +`inference_status` on the stored projection remains `temporal_association_only`. +`tepp.scientific_acceptance.v1` never appears. HTTP 200 is not a scientific +claim. + +Does not re-open cancel lineages, GAP-010 Figma/export, persistence, Leiden, +or an ADR 0014 claim-promotion package.