diff --git a/CHANGELOG.d/temporal-context-stored-request-cli.md b/CHANGELOG.d/temporal-context-stored-request-cli.md new file mode 100644 index 000000000..67112b7c8 --- /dev/null +++ b/CHANGELOG.d/temporal-context-stored-request-cli.md @@ -0,0 +1 @@ +- `tepp-temporal-context-request get` mints LineageWeave `GET /v1/temporal-context/{idempotency_key}/request` onto spawned `tepp-loopback` TCP (ADR 0092). Empty stdin admitted. Metric-free of RMSE/`tepp.scientific_acceptance.v1`. `inference_status` on the live projection remains `temporal_association_only`. Naruon refused. `NaruonLiveService` stays POST-only. Dedicated binary so it does not collide with `tepp-temporal-context-get` (#452). Does not re-open collection GET or cancel lineages. Not GAP-010 Figma/export, not persistence. diff --git a/CHANGELOG.d/temporal-context-stored-request-get.md b/CHANGELOG.d/temporal-context-stored-request-get.md new file mode 100644 index 000000000..0fa54e44c --- /dev/null +++ b/CHANGELOG.d/temporal-context-stored-request-get.md @@ -0,0 +1 @@ +- `GET /v1/temporal-context/{idempotency_key}/request` returns the stored LineageWeave temporal-context create request on `tepp-loopback` (ADR 0091). Metric-free of RMSE/`tepp.scientific_acceptance.v1`. `inference_status` on the live projection remains `temporal_association_only`. Naruon refused. `NaruonLiveService` stays POST-only. Does not re-open collection GET or cancel lineages. Not GAP-010 Figma/export, not persistence. diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 89442a8ed..fd6acee93 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -14,6 +14,8 @@ TEPP's approved PRD v0.4 and implementation plan are the primary product baselin | 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) | | Temporal-context GET-by-id doctoring | [`docs/research/temporal-context-retrieval-get.md`](docs/research/temporal-context-retrieval-get.md) | +| Temporal-context stored-request GET doctoring | [`docs/research/temporal-context-stored-request-get.md`](docs/research/temporal-context-stored-request-get.md) | +| Temporal-context stored-request CLI doctoring | [`docs/research/temporal-context-stored-request-cli.md`](docs/research/temporal-context-stored-request-cli.md) | | UML/runtime/scientific flows | [`docs/UML.md`](docs/UML.md) | | Logical/physical ERD | [`docs/ERD.md`](docs/ERD.md) | | Security policy | [`SECURITY.md`](SECURITY.md) | diff --git a/crates/tepp_api/Cargo.toml b/crates/tepp_api/Cargo.toml index 47ad7c433..2849d6c4b 100644 --- a/crates/tepp_api/Cargo.toml +++ b/crates/tepp_api/Cargo.toml @@ -29,5 +29,11 @@ path = "src/bin/tepp_loopback.rs" test = false bench = false +[[bin]] +name = "tepp-temporal-context-request" +path = "src/bin/tepp_temporal_context_request.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 caa53ef0a..00f4e25db 100644 --- a/crates/tepp_api/src/analysis_run_live.rs +++ b/crates/tepp_api/src/analysis_run_live.rs @@ -21,8 +21,9 @@ use crate::{ ErrorEnvelope, NARUON_LIVE_IO_TIMEOUT, NaruonLiveResponse, PROJECT_HISTORY_PATH, ProjectHistoryProjection, ProjectHistoryRequest, TEMPORAL_CONTEXT_PATH, TEMPORAL_CONTEXT_RETRIEVAL_INFERENCE_STATUS, TemporalContextRequest, TemporalContextRetrieved, - build_temporal_context, project_history_projection, requests_are_idempotent_matches, - temporal_context_retrieval_path_id, + build_temporal_context, project_history_projection, refuse_metrics_on_temporal_context_stored_request_payload, + requests_are_idempotent_matches, temporal_context_retrieval_path_id, + temporal_context_stored_request_path_id, }; const MAX_LIVE_REQUEST_BODY_BYTES: usize = DEFAULT_PROJECT_HISTORY_BYTE_LIMIT; @@ -43,7 +44,7 @@ pub struct AnalysisRunLiveService { next_request_serial: u64, accepted_runs: HashMap, accepted_project_histories: HashMap, - accepted_temporal_contexts: HashMap, + accepted_temporal_contexts: HashMap, } impl Default for AnalysisRunLiveService { @@ -149,6 +150,12 @@ impl AnalysisRunLiveService { let (method, path) = parse_request_line(lines.next().unwrap_or(""))?; let headers = parse_headers(&mut lines)?; if method == "GET" { + if matches!( + temporal_context_stored_request_path_id(path), + Ok(_) | Err(ApiError::LimitExceeded) + ) { + return self.get_temporal_context_stored_request(path, &headers, body); + } return self.get_temporal_context(path, &headers, body); } if method != "POST" @@ -189,12 +196,16 @@ impl AnalysisRunLiveService { TEMPORAL_CONTEXT_RETRIEVAL_INFERENCE_STATUS, )?; let replay_key = format!("{consumer}\u{1f}{idempotency_key}"); - if let Some(stored) = self.accepted_temporal_contexts.get(&replay_key) { - if stored.knowledge_cutoff != item.knowledge_cutoff { + if let Some((stored_request, stored)) = self.accepted_temporal_contexts.get(&replay_key) + { + if stored_request != &context_request + || stored.knowledge_cutoff != item.knowledge_cutoff + { return Err(ApiError::InvalidWirePayload); } } else { - self.accepted_temporal_contexts.insert(replay_key, item); + self.accepted_temporal_contexts + .insert(replay_key, (context_request.clone(), item)); } } let response = build_temporal_context(&context_request)?; @@ -219,13 +230,44 @@ impl AnalysisRunLiveService { return Err(ApiError::InvalidWirePayload); } let replay_key = format!("{consumer}\u{1f}{idempotency_key}"); - let stored = self + let (_, stored) = self .accepted_temporal_contexts .get(&replay_key) .ok_or(ApiError::InvalidWirePayload)?; Ok(json_response(200, "OK", stored.to_json()?)) } + fn get_temporal_context_stored_request( + &self, + path: &str, + headers: &HashMap, + body: &str, + ) -> Result { + if !body.is_empty() { + return Err(ApiError::InvalidWirePayload); + } + refuse_metrics_on_temporal_context_stored_request_payload(body)?; + if headers.contains_key("idempotency-key") { + return Err(ApiError::InvalidWirePayload); + } + let idempotency_key = temporal_context_stored_request_path_id(path)?; + let consumer = require_headers(headers, self.bound_addr, false)?; + if consumer != LINEAGEWEAVE_CONSUMER_CODE { + return Err(ApiError::InvalidWirePayload); + } + let replay_key = format!("{consumer}\u{1f}{idempotency_key}"); + let (stored_request, projection) = self + .accepted_temporal_contexts + .get(&replay_key) + .ok_or(ApiError::InvalidWirePayload)?; + if projection.inference_status != TEMPORAL_CONTEXT_RETRIEVAL_INFERENCE_STATUS { + return Err(ApiError::InvalidWirePayload); + } + let response_body = stored_request.to_json()?; + refuse_metrics_on_temporal_context_stored_request_payload(&response_body)?; + Ok(json_response(200, "OK", response_body)) + } + fn accept_analysis_run( &mut self, consumer: &str, @@ -376,7 +418,7 @@ mod tests { DEFAULT_ANALYSIS_RUN_BYTE_LIMIT, ErrorEnvelope, LINEAGEWEAVE_CONSUMER_CODE, NARUON_ANALYSIS_RUN_PATH, NARUON_CONSUMER_CODE, NARUON_LIVE_HEADER_BYTE_LIMIT, NARUON_LIVE_HEADER_COUNT_LIMIT, NARUON_LIVE_IO_TIMEOUT, TEMPORAL_CONTEXT_PATH, - TemporalContextRetrieved, + TemporalContextRequest, TemporalContextRetrieved, }; fn sample_run() -> AnalysisRunRequest { @@ -841,6 +883,26 @@ mod tests { .status_code, 400 ); + let stored = service.handle_http_request( + &format!( + "GET {TEMPORAL_CONTEXT_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\ncontent-length: 0\r\n\r\n" + ), + ); + assert_eq!(stored.status_code, 200, "{}", stored.body); + let request = TemporalContextRequest::from_json(&stored.body).expect("stored request"); + assert_eq!(request.knowledge_cutoff, "2026-08-20T00:00:00Z"); + assert!(!stored.body.contains("rmse")); + assert!(!stored.body.contains("tepp.scientific_acceptance.v1")); + assert_eq!( + service + .handle_http_request( + &format!( + "GET {TEMPORAL_CONTEXT_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\ncontent-length: 0\r\n\r\n" + ) + ) + .status_code, + 400 + ); } #[test] diff --git a/crates/tepp_api/src/bin/tepp_temporal_context_request.rs b/crates/tepp_api/src/bin/tepp_temporal_context_request.rs new file mode 100644 index 000000000..f4d189245 --- /dev/null +++ b/crates/tepp_api/src/bin/tepp_temporal_context_request.rs @@ -0,0 +1,35 @@ +//! Operator CLI for loopback `LineageWeave` temporal-context stored-request GET. + +use std::io::{self, IsTerminal}; +use std::process::ExitCode; + +use tepp_api::{ + ApiError, TemporalContextStoredRequestCliInvocation, + execute_temporal_context_stored_request_cli, read_temporal_context_stored_request_cli_stdin, + render_temporal_context_stored_request_cli_stdout, +}; + +fn main() -> ExitCode { + match run() { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("tepp-temporal-context-request: {error}"); + ExitCode::FAILURE + } + } +} + +fn run() -> Result<(), ApiError> { + let args: Vec = std::env::args().skip(1).collect(); + let body = + read_temporal_context_stored_request_cli_stdin(io::stdin().is_terminal(), io::stdin())?; + let invocation = TemporalContextStoredRequestCliInvocation::from_args(&args, body)?; + let response = execute_temporal_context_stored_request_cli(&invocation)?; + let stdout = render_temporal_context_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 49bd78a09..b06c3e057 100644 --- a/crates/tepp_api/src/lib.rs +++ b/crates/tepp_api/src/lib.rs @@ -32,6 +32,8 @@ mod project_journey; mod provider_payload; mod temporal_context; mod temporal_context_retrieval_http; +mod temporal_context_stored_request_http; +mod temporal_context_stored_request_cli; mod wire; /// Terminal analysis-result contract version constant. @@ -299,3 +301,27 @@ pub use temporal_context_retrieval_http::refuse_metrics_on_temporal_context_retr pub use temporal_context_retrieval_http::temporal_context_retrieval_path_id; /// Refuse an empty, oversized, slash, NUL, or control-bearing identity. pub use temporal_context_retrieval_http::validate_temporal_context_registry_identity; +/// Whether `path` is the stored-request extra-segment resource. +pub use temporal_context_stored_request_http::is_temporal_context_stored_request_path; +/// Build a credential-free `LineageWeave` stored-request GET exchange. +pub use temporal_context_stored_request_http::lineageweave_temporal_context_stored_request_exchange; +/// Extract the opaque idempotency key from `GET /v1/temporal-context/{key}/request`. +pub use temporal_context_stored_request_http::temporal_context_stored_request_path_id; +/// Refuse stored-request JSON that already carries scientific-metric keys. +pub use temporal_context_stored_request_http::refuse_metrics_on_temporal_context_stored_request_payload; +/// Supported operator verbs for the loopback temporal-context stored-request CLI. +pub use temporal_context_stored_request_cli::TemporalContextStoredRequestCliVerb; +/// One operator CLI invocation against a loopback stored-request listener. +pub use temporal_context_stored_request_cli::TemporalContextStoredRequestCliInvocation; +/// Render a typed stored-request GET exchange as HTTP/1.1 for a loopback listener. +pub use temporal_context_stored_request_cli::loopback_http1_from_temporal_context_stored_request_exchange; +/// Compose one HTTP/1.1 stored-request GET from the typed `LineageWeave` exchange. +pub use temporal_context_stored_request_cli::compose_temporal_context_stored_request_cli_http; +/// Dispatch one stored-request CLI invocation against an in-process listener. +pub use temporal_context_stored_request_cli::dispatch_temporal_context_stored_request_cli; +/// Execute one stored-request CLI invocation over loopback TCP. +pub use temporal_context_stored_request_cli::execute_temporal_context_stored_request_cli; +/// Filter CLI stdout so stored-request GET never prints scientific acceptance. +pub use temporal_context_stored_request_cli::render_temporal_context_stored_request_cli_stdout; +/// Read stdin leftover bytes on a non-terminal; stored-request GET admits empty. +pub use temporal_context_stored_request_cli::read_temporal_context_stored_request_cli_stdin; diff --git a/crates/tepp_api/src/temporal_context_stored_request_cli.rs b/crates/tepp_api/src/temporal_context_stored_request_cli.rs new file mode 100644 index 000000000..83a1179b8 --- /dev/null +++ b/crates/tepp_api/src/temporal_context_stored_request_cli.rs @@ -0,0 +1,677 @@ +//! Operator loopback CLI for `LineageWeave` temporal-context stored-request GET. +//! +//! GAP-003A unique slice: operators run `tepp-temporal-context-request get` to +//! mint `lineageweave_temporal_context_stored_request_exchange` onto spawned +//! `tepp-loopback` TCP. Stdout is the stored `TemporalContextRequest`. Event +//! labels and actor lists belong to the original create request and are +//! admitted. `tepp.scientific_acceptance.v1` never appears. RMSE and causal +//! scores fail closed. The CLI does not infer causality. Naruon is refused on +//! this `LineageWeave`-owned adapter. `NaruonLiveService` stays POST-only. +//! Dedicated binary so it does not collide with `tepp-temporal-context-get` +//! (#452) or `tepp-temporal-context` (#414). This module does not duplicate +//! stored-request GET (#463), GET-by-id HTTP (#451), retrieval CLI (#452), +//! temporal-context CLI (#414), collection GET/CLI (closed #449/#450), +//! project-history stored-request CLI (#456), interpretation-run stored-request +//! CLI (#454), export stored-request CLI (#459), cancel lineages, 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::temporal_context_retrieval_http::validate_temporal_context_registry_identity; +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, TemporalContextRequest, + lineageweave_temporal_context_stored_request_exchange, + refuse_metrics_on_temporal_context_stored_request_payload, + temporal_context_stored_request_path_id, +}; + +const MAXIMUM_HTTP_RESPONSE_BYTES: usize = + NARUON_LIVE_HEADER_BYTE_LIMIT + 4 + DEFAULT_PROJECT_HISTORY_BYTE_LIMIT; + +/// Supported operator verbs for the loopback temporal-context stored-request CLI. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum TemporalContextStoredRequestCliVerb { + /// `GET /v1/temporal-context/{idempotency_key}/request`. + Get, +} + +impl TemporalContextStoredRequestCliVerb { + /// 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 stored-request listener. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TemporalContextStoredRequestCliInvocation { + /// CLI verb to execute. + pub verb: TemporalContextStoredRequestCliVerb, + /// 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, + /// JSON body. Stored-request GET requires empty. + pub body: String, +} + +impl TemporalContextStoredRequestCliInvocation { + /// Parse argv plus stdin body into a validated loopback stored-request invocation. + /// + /// Empty stdin is admitted. Nonempty leftover stdin fails closed. + /// + /// # 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 = TemporalContextStoredRequestCliVerb::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 hostile identities. + 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); + } + validate_temporal_context_registry_identity(&self.idempotency_key)?; + if !self.body.is_empty() { + return Err(ApiError::InvalidWirePayload); + } + refuse_metrics_on_temporal_context_stored_request_payload(&self.body)?; + Ok(()) + } +} + +struct ParsedFlags { + host: Option, + origin: Option, + consumer: Option, + idempotency_key: Option, +} + +fn parse_flags(rest: &[String]) -> Result { + let mut flags = ParsedFlags { + host: None, + origin: None, + consumer: None, + idempotency_key: None, + }; + let mut index = 0; + while index < rest.len() { + let flag = rest[index].as_str(); + if !flag.starts_with("--") { + return Err(ApiError::InvalidWirePayload); + } + let name = &flag[2..]; + if header_is_credential(name) { + return Err(ApiError::AuthorizationDenied); + } + let slot = match name { + "host" => &mut flags.host, + "origin" => &mut flags.origin, + "consumer" => &mut flags.consumer, + "idempotency-key" => &mut flags.idempotency_key, + _ => return Err(ApiError::InvalidWirePayload), + }; + if slot.is_some() || index + 1 >= rest.len() { + return Err(ApiError::InvalidWirePayload); + } + let value = rest[index + 1].as_str(); + require_nonempty(value)?; + *slot = Some(value.to_owned()); + index += 2; + } + Ok(flags) +} + +fn assemble_invocation( + verb: TemporalContextStoredRequestCliVerb, + flags: ParsedFlags, + body: String, +) -> Result { + let invocation = TemporalContextStoredRequestCliInvocation { + 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)?, + 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. +/// +/// # 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/temporal-context/{idempotency_key}/request` with +/// an empty body. +pub fn loopback_http1_from_temporal_context_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 = temporal_context_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; + for (name, value) in &exchange.headers { + if header_is_credential(name) { + return Err(ApiError::AuthorizationDenied); + } + if name.eq_ignore_ascii_case("idempotency-key") { + return Err(ApiError::InvalidWirePayload); + } + 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" + } + _ => false, + }; + if !valid { + return Err(ApiError::InvalidWirePayload); + } + } + if !has_content_type || !has_consumer || !has_contract { + 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 +/// [`TemporalContextStoredRequestCliInvocation::validate`]. +pub fn compose_temporal_context_stored_request_cli_http( + invocation: &TemporalContextStoredRequestCliInvocation, +) -> Result { + invocation.validate()?; + let exchange = lineageweave_temporal_context_stored_request_exchange( + &invocation.origin, + &invocation.idempotency_key, + )?; + loopback_http1_from_temporal_context_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_temporal_context_stored_request_cli( + service: &mut AnalysisRunLiveService, + invocation: &TemporalContextStoredRequestCliInvocation, +) -> Result { + let request = compose_temporal_context_stored_request_cli_http(invocation)?; + Ok(service.handle_http_request(&request)) +} + +/// Execute one stored-request CLI invocation over loopback TCP. +/// +/// # Errors +/// +/// Returns fail-closed validation, transport, or response-framing errors. +pub fn execute_temporal_context_stored_request_cli( + invocation: &TemporalContextStoredRequestCliInvocation, +) -> Result { + let addr = require_loopback_host(&invocation.host)?; + let request = compose_temporal_context_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. +/// +/// Event labels and actor lists belong to the stored create request and are +/// admitted. RMSE, bias, coverage, SE-gate, and causal-score keys fail closed. +/// `TemporalContextRequest` has no idempotency-key field; identity is the path. +/// +/// # Errors +/// +/// Returns [`ApiError::InvalidWirePayload`] when a body carries metric keys, +/// `tepp.scientific_acceptance.v1`, or a success body that is not a stored +/// [`TemporalContextRequest`]. +pub fn render_temporal_context_stored_request_cli_stdout( + invocation: &TemporalContextStoredRequestCliInvocation, + response: &NaruonLiveResponse, +) -> Result { + invocation.validate()?; + if response.body.is_empty() { + return Err(ApiError::InvalidWirePayload); + } + refuse_metrics_on_temporal_context_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 = TemporalContextRequest::from_json(&response.body)?; + 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 wire limit. +pub fn read_temporal_context_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 tests { + use super::{ + TemporalContextStoredRequestCliInvocation, TemporalContextStoredRequestCliVerb, + compose_temporal_context_stored_request_cli_http, + loopback_http1_from_temporal_context_stored_request_exchange, + read_temporal_context_stored_request_cli_stdin, + }; + use crate::{ + ApiError, LINEAGEWEAVE_CONSUMER_CODE, NaruonHttpExchange, + lineageweave_temporal_context_stored_request_exchange, + }; + + const ORIGIN: &str = "https://tepp.example.test"; + + fn get_args() -> [&'static str; 9] { + [ + "get", + "--host", + "127.0.0.1:18081", + "--origin", + ORIGIN, + "--consumer", + LINEAGEWEAVE_CONSUMER_CODE, + "--idempotency-key", + "idem-a", + ] + } + + #[test] + fn from_args_mints_get_and_refuses_fail_closed_hosts() { + assert_eq!( + TemporalContextStoredRequestCliVerb::parse("get").expect("get"), + TemporalContextStoredRequestCliVerb::Get + ); + assert_eq!(TemporalContextStoredRequestCliVerb::Get.as_str(), "get"); + assert_eq!( + TemporalContextStoredRequestCliVerb::parse("list"), + Err(ApiError::InvalidWirePayload) + ); + let get = + TemporalContextStoredRequestCliInvocation::from_args(get_args(), "").expect("get"); + let http = compose_temporal_context_stored_request_cli_http(&get).expect("http"); + assert!(http.starts_with("GET /v1/temporal-context/idem-a/request HTTP/1.1")); + assert!(http.contains("tepp-consumer: lineageweave")); + assert!(http.contains("content-length: 0")); + assert!(!http.contains("idempotency-key:")); + assert!(!http.contains("authorization")); + assert_eq!( + TemporalContextStoredRequestCliInvocation::from_args( + [ + "get", + "--host", + "8.8.8.8:80", + "--origin", + ORIGIN, + "--idempotency-key", + "idem-a" + ], + "" + ) + .unwrap_err(), + ApiError::AuthorizationDenied + ); + assert_eq!( + TemporalContextStoredRequestCliInvocation::from_args( + [ + "get", + "--host", + "localhost:18081", + "--origin", + ORIGIN, + "--idempotency-key", + "idem-a" + ], + "" + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + TemporalContextStoredRequestCliInvocation::from_args( + [ + "get", + "--host", + "127.0.0.1:18081", + "--origin", + ORIGIN, + "--authorization", + "secret", + "--idempotency-key", + "idem-a" + ], + "" + ) + .unwrap_err(), + ApiError::AuthorizationDenied + ); + } + + #[test] + fn from_args_refuses_naruon_body_slash_and_non_get() { + assert_eq!( + TemporalContextStoredRequestCliInvocation::from_args( + [ + "get", + "--host", + "127.0.0.1:18081", + "--origin", + ORIGIN, + "--consumer", + "naruon", + "--idempotency-key", + "idem-a" + ], + "" + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + TemporalContextStoredRequestCliInvocation::from_args(get_args(), "{}").unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + TemporalContextStoredRequestCliInvocation::from_args( + [ + "get", + "--host", + "127.0.0.1:18081", + "--origin", + ORIGIN, + "--idempotency-key", + "a/b" + ], + "" + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert!( + read_temporal_context_stored_request_cli_stdin(true, std::io::empty()) + .expect("tty") + .is_empty() + ); + let exchange = + lineageweave_temporal_context_stored_request_exchange(ORIGIN, "idem-a").expect("ex"); + let posted = NaruonHttpExchange { + method: "POST", + target_url: exchange.target_url, + headers: exchange.headers, + body: exchange.body, + }; + assert_eq!( + loopback_http1_from_temporal_context_stored_request_exchange( + &posted, + "127.0.0.1:18081" + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + } +} diff --git a/crates/tepp_api/src/temporal_context_stored_request_http.rs b/crates/tepp_api/src/temporal_context_stored_request_http.rs new file mode 100644 index 000000000..7742b7c17 --- /dev/null +++ b/crates/tepp_api/src/temporal_context_stored_request_http.rs @@ -0,0 +1,283 @@ +//! Provider-owned temporal-context stored-request GET contracts. +//! +//! GAP-003A unique slice: `GET /v1/temporal-context/{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 (#451), retrieval CLI (#452), +//! temporal-context CLI (#414), collection GET/CLI (#449/#450 closed), +//! project-history stored-request GET (#455), interpretation-run stored-request +//! GET (#453), export stored-request GET (#457), 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::temporal_context_retrieval_http::{ + TEMPORAL_CONTEXT_RETRIEVAL_ID_MAX_LEN, validate_temporal_context_registry_identity, +}; +use crate::wire::require_nonempty; +use crate::{ApiError, TEMPORAL_CONTEXT_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/temporal-context/{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 temporal_context_stored_request_path_id(path: &str) -> Result { + let remainder = path + .strip_prefix(TEMPORAL_CONTEXT_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() > TEMPORAL_CONTEXT_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_temporal_context_stored_request_path(path: &str) -> bool { + temporal_context_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. The original create +/// request may carry `event_label` and `actor_references`; those keys are not +/// scientific metrics. +/// +/// # Errors +/// +/// Returns [`ApiError::InvalidWirePayload`] when a forbidden metric or causal +/// key is present. +pub fn refuse_metrics_on_temporal_context_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 or identity error. +pub fn lineageweave_temporal_context_stored_request_exchange( + origin: &str, + idempotency_key: &str, +) -> Result { + validate_temporal_context_registry_identity(idempotency_key)?; + if idempotency_key.contains('/') || idempotency_key.contains('\0') { + return Err(ApiError::InvalidWirePayload); + } + if idempotency_key.len() > TEMPORAL_CONTEXT_RETRIEVAL_ID_MAX_LEN { + return Err(ApiError::LimitExceeded); + } + let encoded_id = encode_path_segment(idempotency_key); + let target_path = format!("{TEMPORAL_CONTEXT_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()), + ], + 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_temporal_context_stored_request_path, + lineageweave_temporal_context_stored_request_exchange, + refuse_metrics_on_temporal_context_stored_request_payload, + temporal_context_stored_request_path_id, + }; + use crate::ApiError; + + #[test] + fn stored_request_exchange_is_lineageweave_get_without_credentials() { + let exchange = lineageweave_temporal_context_stored_request_exchange( + "https://tepp.example.test", + "idem-a", + ) + .expect("exchange"); + assert_eq!(exchange.method, "GET"); + assert!( + exchange + .target_url + .ends_with("/v1/temporal-context/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_temporal_context_stored_request_path( + "/v1/temporal-context/idem-a/request" + )); + assert!(!is_temporal_context_stored_request_path( + "/v1/temporal-context/idem-a" + )); + assert_eq!( + temporal_context_stored_request_path_id("/v1/temporal-context/idem-a/request") + .expect("id"), + "idem-a" + ); + assert_eq!( + temporal_context_stored_request_path_id("/v1/temporal-context/idem-a"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + temporal_context_stored_request_path_id("/v1/temporal-context/idem-a/cancel"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + lineageweave_temporal_context_stored_request_exchange( + "http://tepp.example.test", + "idem-a" + ), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + refuse_metrics_on_temporal_context_stored_request_payload(""), + Ok(()) + ); + assert_eq!( + refuse_metrics_on_temporal_context_stored_request_payload(r#"{"rmse":1.0}"#), + Err(ApiError::InvalidWirePayload) + ); + } +} diff --git a/crates/tepp_api/tests/temporal_context_stored_request_cli_contract.rs b/crates/tepp_api/tests/temporal_context_stored_request_cli_contract.rs new file mode 100644 index 000000000..779ebbbb3 --- /dev/null +++ b/crates/tepp_api/tests/temporal_context_stored_request_cli_contract.rs @@ -0,0 +1,219 @@ +//! Contract tests for the `LineageWeave` temporal-context stored-request loopback CLI. + +use tepp_api::{ + AnalysisRunLiveService, ApiError, LINEAGEWEAVE_CONSUMER_CODE, NARUON_CONSUMER_CODE, + NaruonHttpExchange, NaruonLiveResponse, NaruonLiveService, TEMPORAL_CONTEXT_PATH, + TemporalContextRequest, TemporalContextStoredRequestCliInvocation, + TemporalContextStoredRequestCliVerb, compose_temporal_context_stored_request_cli_http, + dispatch_temporal_context_stored_request_cli, + lineageweave_temporal_context_stored_request_exchange, + loopback_http1_from_temporal_context_stored_request_exchange, + read_temporal_context_stored_request_cli_stdin, + render_temporal_context_stored_request_cli_stdout, +}; + +const ORIGIN: &str = "https://tepp.example.test"; +const SCHEMA: &str = "tepp.scientific_acceptance.v1"; +const TEMPORAL_BODY: &str = r#"{"contract_version":1,"consumer_code":"lineageweave","knowledge_cutoff":"2026-08-20T00:00:00Z","subject_post_id":null,"events":[{"event_id":"event-1","source_post_id":"post-1","event_type_code":"order_awarded","event_label":"Order awarded","event_time":"2026-08-01T09:00:00Z","available_time":"2026-08-01T10:00:00Z","project_reference":null,"actor_references":["actor-1"]}]}"#; + +fn post_http(idempotency_key: &str) -> String { + format!( + "POST {TEMPORAL_CONTEXT_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: {idempotency_key}\r\ncontent-length: {}\r\n\r\n{TEMPORAL_BODY}", + TEMPORAL_BODY.len() + ) +} + +fn get_args<'a>(host: &'a str, idempotency_key: &'a str, consumer: &'a str) -> [&'a str; 9] { + [ + "get", + "--host", + host, + "--origin", + ORIGIN, + "--consumer", + consumer, + "--idempotency-key", + idempotency_key, + ] +} + +#[test] +fn verbs_and_from_args_fail_closed() { + assert_eq!( + TemporalContextStoredRequestCliVerb::parse("get").expect("get"), + TemporalContextStoredRequestCliVerb::Get + ); + assert_eq!(TemporalContextStoredRequestCliVerb::Get.as_str(), "get"); + assert_eq!( + TemporalContextStoredRequestCliVerb::parse("list"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + TemporalContextStoredRequestCliInvocation::from_args( + get_args("8.8.8.8:80", "idem-a", LINEAGEWEAVE_CONSUMER_CODE), + "" + ) + .unwrap_err(), + ApiError::AuthorizationDenied + ); + assert_eq!( + TemporalContextStoredRequestCliInvocation::from_args( + get_args("localhost:18081", "idem-a", LINEAGEWEAVE_CONSUMER_CODE), + "" + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + TemporalContextStoredRequestCliInvocation::from_args( + [ + "get", + "--host", + "127.0.0.1:18081", + "--origin", + "http://tepp.example.test", + "--idempotency-key", + "idem-a" + ], + "" + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + TemporalContextStoredRequestCliInvocation::from_args( + [ + "get", + "--host", + "127.0.0.1:18081", + "--origin", + ORIGIN, + "--authorization", + "secret", + "--idempotency-key", + "idem-a" + ], + "" + ) + .unwrap_err(), + ApiError::AuthorizationDenied + ); + assert_eq!( + TemporalContextStoredRequestCliInvocation::from_args( + get_args("127.0.0.1:18081", "idem-a", NARUON_CONSUMER_CODE), + "" + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + TemporalContextStoredRequestCliInvocation::from_args( + get_args("127.0.0.1:18081", "idem-a", LINEAGEWEAVE_CONSUMER_CODE), + "{}" + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + TemporalContextStoredRequestCliInvocation::from_args( + get_args("127.0.0.1:18081", "a/b", LINEAGEWEAVE_CONSUMER_CODE), + "" + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert!( + read_temporal_context_stored_request_cli_stdin(true, std::io::empty()) + .expect("tty") + .is_empty() + ); +} + +#[test] +fn compose_is_typed_https_get_without_credentials() { + let invocation = TemporalContextStoredRequestCliInvocation::from_args( + get_args("127.0.0.1:18081", "idem-a", LINEAGEWEAVE_CONSUMER_CODE), + "", + ) + .expect("invocation"); + let http = compose_temporal_context_stored_request_cli_http(&invocation).expect("http"); + assert!(http.starts_with("GET /v1/temporal-context/idem-a/request HTTP/1.1")); + assert!(http.contains("tepp-consumer: lineageweave")); + assert!(http.contains("content-length: 0")); + assert!(!http.contains("idempotency-key:")); + 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(); + assert_eq!( + service + .handle_http_request(&post_http("idem-a")) + .status_code, + 200 + ); + let invocation = TemporalContextStoredRequestCliInvocation::from_args( + get_args("127.0.0.1:18081", "idem-a", LINEAGEWEAVE_CONSUMER_CODE), + "", + ) + .expect("invocation"); + let got = dispatch_temporal_context_stored_request_cli(&mut service, &invocation).expect("get"); + assert_eq!(got.status_code, 200, "{}", got.body); + let stdout = render_temporal_context_stored_request_cli_stdout(&invocation, &got).expect("out"); + let stored = TemporalContextRequest::from_json(&stdout).expect("stored"); + let original = TemporalContextRequest::from_json(TEMPORAL_BODY).expect("original"); + assert_eq!(stored, original); + assert!(stdout.contains("event_label")); + assert!(!stdout.contains("rmse")); + assert!(!stdout.contains(SCHEMA)); + let missing = TemporalContextStoredRequestCliInvocation::from_args( + get_args("127.0.0.1:18081", "missing", LINEAGEWEAVE_CONSUMER_CODE), + "", + ) + .expect("missing"); + let denied = + dispatch_temporal_context_stored_request_cli(&mut service, &missing).expect("denied"); + assert_eq!(denied.status_code, 400); + assert!( + render_temporal_context_stored_request_cli_stdout(&missing, &denied) + .expect("err") + .contains("invalid_wire_payload") + ); + let mut naruon = NaruonLiveService::new(); + assert_eq!( + naruon + .handle_http_request( + &compose_temporal_context_stored_request_cli_http(&invocation).expect("composed") + ) + .status_code, + 400 + ); + let exchange = + lineageweave_temporal_context_stored_request_exchange(ORIGIN, "idem-a").expect("ex"); + let posted = NaruonHttpExchange { + method: "POST", + target_url: exchange.target_url, + headers: exchange.headers, + body: exchange.body, + }; + assert_eq!( + loopback_http1_from_temporal_context_stored_request_exchange(&posted, "127.0.0.1:18081") + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + render_temporal_context_stored_request_cli_stdout( + &invocation, + &NaruonLiveResponse { + status_code: 200, + reason_phrase: "OK", + body: String::new() + } + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); +} diff --git a/crates/tepp_api/tests/temporal_context_stored_request_cli_tcp_contract.rs b/crates/tepp_api/tests/temporal_context_stored_request_cli_tcp_contract.rs new file mode 100644 index 000000000..93e242c04 --- /dev/null +++ b/crates/tepp_api/tests/temporal_context_stored_request_cli_tcp_contract.rs @@ -0,0 +1,77 @@ +//! Production-path coverage for the temporal-context stored-request loopback CLI. +//! +//! This test crosses a real loopback TCP socket so the public execution path is +//! not proven only by the in-process dispatcher. + +use std::io::{Read, Write}; +use std::net::TcpListener; +use std::thread; + +use tepp_api::{ + LINEAGEWEAVE_CONSUMER_CODE, TemporalContextRequest, TemporalContextStoredRequestCliInvocation, + execute_temporal_context_stored_request_cli, render_temporal_context_stored_request_cli_stdout, +}; + +const ORIGIN: &str = "https://tepp.example.test"; +const TEMPORAL_BODY: &str = r#"{"contract_version":1,"consumer_code":"lineageweave","knowledge_cutoff":"2026-08-20T00:00:00Z","subject_post_id":null,"events":[{"event_id":"event-1","source_post_id":"post-1","event_type_code":"order_awarded","event_label":"Order awarded","event_time":"2026-08-01T09:00:00Z","available_time":"2026-08-01T10:00:00Z","project_reference":null,"actor_references":["actor-1"]}]}"#; + +#[test] +fn execute_traverses_loopback_tcp_and_parses_the_stored_request_response() { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind loopback listener"); + let address = listener.local_addr().expect("listener address"); + + let server = thread::spawn(move || { + let (mut stream, peer) = listener.accept().expect("accept loopback client"); + assert!(peer.ip().is_loopback()); + + let mut request_bytes = [0_u8; 4096]; + let received = stream.read(&mut request_bytes).expect("read request"); + let request = std::str::from_utf8(&request_bytes[..received]).expect("utf8 request"); + assert!(request.starts_with("GET /v1/temporal-context/idem-tcp/request HTTP/1.1\r\n")); + assert!(request.contains("tepp-consumer: lineageweave\r\n")); + assert!(request.contains("content-length: 0\r\n\r\n")); + assert!(!request.to_ascii_lowercase().contains("authorization")); + + let response = format!( + "HTTP/1.1 200 OK\r\ncontent-length: {}\r\n\r\n{}", + TEMPORAL_BODY.len(), + TEMPORAL_BODY + ); + stream + .write_all(response.as_bytes()) + .expect("write response"); + stream.flush().expect("flush response"); + }); + + let host = address.to_string(); + let invocation = TemporalContextStoredRequestCliInvocation::from_args( + [ + "get", + "--host", + host.as_str(), + "--origin", + ORIGIN, + "--consumer", + LINEAGEWEAVE_CONSUMER_CODE, + "--idempotency-key", + "idem-tcp", + ], + "", + ) + .expect("valid invocation"); + + let response = execute_temporal_context_stored_request_cli(&invocation) + .expect("execute over loopback TCP"); + server.join().expect("join loopback server"); + + assert_eq!(response.status_code, 200); + assert_eq!(response.reason_phrase, "OK"); + let stdout = render_temporal_context_stored_request_cli_stdout(&invocation, &response) + .expect("render stored request"); + assert_eq!( + TemporalContextRequest::from_json(&stdout).expect("stored request"), + TemporalContextRequest::from_json(TEMPORAL_BODY).expect("expected request") + ); + assert!(!stdout.contains("rmse")); + assert!(!stdout.contains("tepp.scientific_acceptance.v1")); +} diff --git a/crates/tepp_api/tests/temporal_context_stored_request_http_contract.rs b/crates/tepp_api/tests/temporal_context_stored_request_http_contract.rs new file mode 100644 index 000000000..0e34ef77a --- /dev/null +++ b/crates/tepp_api/tests/temporal_context_stored_request_http_contract.rs @@ -0,0 +1,91 @@ +//! Contract tests for loopback `GET /v1/temporal-context/{key}/request`. + +use tepp_api::{ + AnalysisRunLiveService, ApiError, LINEAGEWEAVE_CONSUMER_CODE, NARUON_CONSUMER_CODE, + TEMPORAL_CONTEXT_PATH, TemporalContextRequest, + lineageweave_temporal_context_stored_request_exchange, + refuse_metrics_on_temporal_context_stored_request_payload, + temporal_context_stored_request_path_id, +}; + +const TEMPORAL_BODY: &str = r#"{"contract_version":1,"consumer_code":"lineageweave","knowledge_cutoff":"2026-08-20T00:00:00Z","subject_post_id":null,"events":[{"event_id":"event-1","source_post_id":"post-1","event_type_code":"order_awarded","event_label":"Order awarded","event_time":"2026-08-01T09:00:00Z","available_time":"2026-08-01T10:00:00Z","project_reference":null,"actor_references":["actor-1"]}]}"#; + +fn post_http(idempotency_key: &str) -> String { + format!( + "POST {TEMPORAL_CONTEXT_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: {idempotency_key}\r\ncontent-length: {}\r\n\r\n{TEMPORAL_BODY}", + TEMPORAL_BODY.len() + ) +} + +#[test] +fn stored_request_get_returns_create_request_and_fails_closed() { + let mut service = AnalysisRunLiveService::new(); + assert_eq!( + service + .handle_http_request(&post_http("idem-a")) + .status_code, + 200 + ); + let got = service.handle_http_request(&format!( + "GET {TEMPORAL_CONTEXT_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\ncontent-length: 0\r\n\r\n" + )); + assert_eq!(got.status_code, 200, "{}", got.body); + let stored = TemporalContextRequest::from_json(&got.body).expect("stored"); + let original = TemporalContextRequest::from_json(TEMPORAL_BODY).expect("original"); + assert_eq!(stored, original); + 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 {TEMPORAL_CONTEXT_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\ncontent-length: 0\r\n\r\n" + )) + .status_code, + 400 + ); + assert_eq!( + service + .handle_http_request(&format!( + "GET {TEMPORAL_CONTEXT_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\ncontent-length: 0\r\n\r\n" + )) + .status_code, + 400 + ); + assert_eq!( + service + .handle_http_request(&format!( + "GET {TEMPORAL_CONTEXT_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\ncontent-length: 0\r\n\r\n" + )) + .status_code, + 400 + ); + let exchange = lineageweave_temporal_context_stored_request_exchange( + "https://tepp.example.test", + "idem-a", + ) + .expect("exchange"); + assert_eq!(exchange.method, "GET"); + assert!( + exchange + .target_url + .ends_with("/v1/temporal-context/idem-a/request") + ); + assert!(exchange.body.is_empty()); + assert_eq!( + temporal_context_stored_request_path_id("/v1/temporal-context/idem-a/request").expect("id"), + "idem-a" + ); + assert_eq!( + temporal_context_stored_request_path_id("/v1/temporal-context/idem-a"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + refuse_metrics_on_temporal_context_stored_request_payload(r#"{"rmse":1.0}"#), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + refuse_metrics_on_temporal_context_stored_request_payload(""), + Ok(()) + ); +} diff --git a/docs/API_CONTRACT.md b/docs/API_CONTRACT.md index bf5a88784..6f149e2fe 100644 --- a/docs/API_CONTRACT.md +++ b/docs/API_CONTRACT.md @@ -66,6 +66,7 @@ POST /v1/interpretation-runs POST /v1/analysis-runs POST /v1/temporal-context GET /v1/temporal-context/{idempotency_key} +GET /v1/temporal-context/{idempotency_key}/request GET /v1/analysis-runs/{run_id} POST /v1/analysis-runs/{run_id}/cancel GET /v1/model-artifacts/{artifact_id} @@ -95,6 +96,10 @@ causality, mutate TEPP state, or return a completed psychometric result. minted when that POST carries an `idempotency-key` header (ADR 0083). Event labels, actor lists, and `tepp.scientific_acceptance.v1` never appear. Naruon is refused. `NaruonLiveService` stays POST-only. Collection GET stays closed. +`GET /v1/temporal-context/{idempotency_key}/request` returns the stored create +request (ADR 0091) so operators who hold the identity do not replay POST. +`tepp-temporal-context-request get` mints that stored-request GET onto spawned +`tepp-loopback` TCP (ADR 0092). The typed status/read contract returns `accepted`, `running`, `succeeded`, or `failed`. Accepted and running statuses contain no measurement result. A diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 1cfe3e59e..8a61ec3e2 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -54,6 +54,8 @@ The full APA 7th standards/literature register remains `docs/research/standards- | known-truth temporal/event simulation manifests | PRD; TRD; Test Strategy | `tepp_simulation` on protected main; recovery metrics in `validation_core` | implemented-main | | 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 temporal-context GET-by-id | ADR 0083; API contract; RFC 9110; ADR 0002/0014 | `tepp_api` `GET /v1/temporal-context/{idempotency_key}` on `tepp-loopback`; metric-free `inference_status=temporal_association_only` identity; `tepp.scientific_acceptance.v1` never appears; does not infer causality; does not re-open collection GET | active-PR | +| loopback LineageWeave temporal-context stored-request GET | ADR 0091; API contract; RFC 9110; ADR 0002/0014 | `tepp_api` `GET /v1/temporal-context/{idempotency_key}/request` on `tepp-loopback`; returns stored create request; metric-free of RMSE/`tepp.scientific_acceptance.v1`; naruon refused; does not re-open collection GET or cancel | active-PR | +| loopback LineageWeave temporal-context stored-request CLI | ADR 0092; API contract; RFC 9110; ADR 0002/0014 | published `tepp-temporal-context-request get` mints `GET /v1/temporal-context/{idempotency_key}/request` onto spawned `tepp-loopback`; returns stored create request; metric-free of RMSE/`tepp.scientific_acceptance.v1`; naruon refused; `NaruonLiveService` stays POST-only; dedicated binary so it does not collide with `tepp-temporal-context-get`; does not re-open collection GET or cancel | 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/0091-temporal-context-stored-request-get.md b/docs/adr/0091-temporal-context-stored-request-get.md new file mode 100644 index 000000000..defbb116c --- /dev/null +++ b/docs/adr/0091-temporal-context-stored-request-get.md @@ -0,0 +1,66 @@ +# ADR 0091 — Loopback temporal-context stored-request GET + +**Decision status:** Accepted +**Implementation maturity:** active-PR +**Date:** 2026-09-01 +**Supersedes:** None; complements ADR 0083. Does not re-open cancel lineages +or collection GET. Does not supersede ADR 0014. Unique versus protected main; +0026–0090 occupied including #459=0090, #457=0089, #456=0088, #455=0087, +#454=0086, #453=0085, #452=0084, #451=0083. + +## Context + +ADR 0083 retrieves one accepted temporal-context identity. Operators still had +no extra-segment GET for the stored LineageWeave create request. +Project-history stored-request GET (#455) is LineageWeave-owned on a different +path. Interpretation-run stored-request GET (#453) is orchestrator-owned. +Export stored-request GET (#457) is naruon-owned. Duplicating GET-by-id (#451), +retrieval CLI (#452), temporal-context CLI (#414), Leiden, or GAP-010 would +collide with live PRs. Cancel and collection lineages stay closed. Naruon is +refused on this LineageWeave-owned adapter. `NaruonLiveService` stays POST-only. + +## Decision + +Publish `GET /v1/temporal-context/{idempotency_key}/request` on +`AnalysisRunLiveService`. Extra-segment parse before GET-by-id. Slash/NUL fail +closed. Empty body. LineageWeave-only. Response is the stored create request. +Scientific-metric keys and `tepp.scientific_acceptance.v1` never appear. +`inference_status` on the live projection remains `temporal_association_only`. +Cancel extra-segment stays refused. `NaruonLiveService` stays POST-only. + +## Alternatives considered + +1. Re-open cancel HTTP — rejected. +2. Return GET-by-id retrieval identity — rejected (ADR 0083). +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, http +origins, unpublished consumers, credential flags, and metric keys fail closed. + +## Verification + +- `GET /v1/temporal-context/{idempotency_key}/request` of an accepted identity + 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 or collection, emit scientific-acceptance, open naruon on this +adapter, add GET to `NaruonLiveService`, or treat retrieval success as an +ADR 0014 claim. + +## Related authority + +ADR 0083, ADR 0002, ADR 0014, RFC 9110 (Fielding, Nottingham, & Reschke, 2022). diff --git a/docs/adr/0092-temporal-context-stored-request-cli.md b/docs/adr/0092-temporal-context-stored-request-cli.md new file mode 100644 index 000000000..e6f47a980 --- /dev/null +++ b/docs/adr/0092-temporal-context-stored-request-cli.md @@ -0,0 +1,67 @@ +# ADR 0092 — Loopback temporal-context stored-request CLI + +**Decision status:** Accepted +**Implementation maturity:** active-PR +**Date:** 2026-09-01 +**Supersedes:** None; complements ADR 0091. Does not re-open cancel lineages +or collection GET. Does not supersede ADR 0014. Unique versus protected main; +0026–0091 occupied including #463=0091, #459=0090, #457=0089. + +## Context + +ADR 0091 publishes `GET /v1/temporal-context/{idempotency_key}/request`. +Operators still had no published binary that mints that GET onto spawned +`tepp-loopback` TCP. Duplicating stored-request GET (#463), GET-by-id (#451), +retrieval CLI (#452), temporal-context CLI (#414), project-history +stored-request CLI (#456), interpretation-run stored-request CLI (#454), +export stored-request CLI (#459), Leiden, or GAP-010 would collide with live +PRs. Cancel and collection lineages stay closed. Naruon is refused on this +LineageWeave-owned adapter. `NaruonLiveService` stays POST-only. + +## Decision + +Publish `tepp-temporal-context-request get` which mints +`lineageweave_temporal_context_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-temporal-context-get` (#452) or `tepp-temporal-context` (#414). Response +is the stored create request. `inference_status` on the live projection remains +`temporal_association_only`. `tepp.scientific_acceptance.v1` never appears. + +## Alternatives considered + +1. Re-open cancel CLI — rejected. +2. Reuse `tepp-temporal-context-get` — rejected; that is ADR 0084. +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-temporal-context-request get` of an accepted identity 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 or collection, emit scientific-acceptance, open naruon on this +adapter, add GET to `NaruonLiveService`, or treat CLI success as an ADR 0014 +claim. + +## Related authority + +ADR 0091, ADR 0083, ADR 0002, ADR 0014, RFC 9110 (Fielding, Nottingham, & Reschke, 2022). diff --git a/docs/adr/README.md b/docs/adr/README.md index 126199cab..0ce2b896d 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -31,6 +31,8 @@ Read [`ADR_POLICY.md`](ADR_POLICY.md) first. **Decision status and implementatio | [0024](0024-lineage-pair-criterion-and-project-journey-posterior.md) | Independent Event Lineage pair criterion and posterior Project Journey | Proposed | active-PR | Strict artifacts preserve criterion/event-time draws, branches, ties, and CPU/GPU receipts without claiming the scientific estimator is complete. | | [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. | | [0083](0083-temporal-context-retrieval-get.md) | Loopback temporal-context GET-by-id | Accepted | active-PR | Complements `POST /v1/temporal-context`; `GET /v1/temporal-context/{idempotency_key}` returns one metric-free LineageWeave identity. Unique versus protected main (0026–0082 occupied on live or closed sibling PRs). Does not re-open collection GET or cancel lineages. | +| [0091](0091-temporal-context-stored-request-get.md) | Loopback temporal-context stored-request GET | Accepted | active-PR | Complements ADR 0083; `GET /v1/temporal-context/{idempotency_key}/request` returns the stored LineageWeave create request. Unique versus protected main (0026–0090 occupied). Does not re-open collection GET or cancel lineages. | +| [0092](0092-temporal-context-stored-request-cli.md) | Loopback temporal-context stored-request CLI | Accepted | active-PR | Complements ADR 0091; `tepp-temporal-context-request get` mints `GET /v1/temporal-context/{idempotency_key}/request` onto spawned `tepp-loopback`. Unique versus protected main (0026–0091 occupied). Does not re-open collection GET or 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/temporal-context-stored-request-cli.md b/docs/research/temporal-context-stored-request-cli.md new file mode 100644 index 000000000..a009cdb44 --- /dev/null +++ b/docs/research/temporal-context-stored-request-cli.md @@ -0,0 +1,15 @@ +# Temporal-context stored-request CLI (doctoring) + +`tepp-temporal-context-request get` mints +`lineageweave_temporal_context_stored_request_exchange` onto spawned +`tepp-loopback` TCP. HTTP semantics follow RFC 9110 (Fielding, Nottingham, & +Reschke, 2022). Fail-closed naruon, leftover stdin, public bind, `localhost`, +`http` origin, unpublished consumer, credential flags, slash/NUL, and +scientific-authority promotion are repository contract (ADR 0092; ADR 0014). + +`inference_status` on the live projection remains `temporal_association_only`. +`tepp.scientific_acceptance.v1` never appears. CLI success is not a scientific +claim. + +Does not re-open cancel lineages, collection GET, GAP-010 Figma/export, +persistence, Leiden, or an ADR 0014 claim-promotion package. diff --git a/docs/research/temporal-context-stored-request-get.md b/docs/research/temporal-context-stored-request-get.md new file mode 100644 index 000000000..3ddf47a12 --- /dev/null +++ b/docs/research/temporal-context-stored-request-get.md @@ -0,0 +1,14 @@ +# Temporal-context stored-request GET (doctoring) + +`GET /v1/temporal-context/{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 0091; 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, collection GET, GAP-010 Figma/export, +persistence, Leiden, or an ADR 0014 claim-promotion package.