diff --git a/CHANGELOG.d/temporal-context-collection-cli.md b/CHANGELOG.d/temporal-context-collection-cli.md new file mode 100644 index 000000000..a41af0edc --- /dev/null +++ b/CHANGELOG.d/temporal-context-collection-cli.md @@ -0,0 +1 @@ +- `tepp-temporal-contexts list` mints LineageWeave `GET /v1/temporal-context` onto spawned `tepp-loopback` TCP (ADR 0082). Metric-free `inference_status=temporal_association_only` receipts. `tepp.scientific_acceptance.v1` never appears. Does not infer causality. Naruon refused. `NaruonLiveService` stays POST-only. Not temporal-context CLI, not collection GET, not GAP-010 Figma/export, not persistence. diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 10174842e..8e15ffb90 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -14,6 +14,7 @@ TEPP's approved PRD v0.4 and implementation plan are the primary product baselin | 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 collection GET doctoring | [`docs/research/temporal-context-collection-get.md`](docs/research/temporal-context-collection-get.md) | +| Temporal-context collection CLI doctoring | [`docs/research/temporal-context-collection-cli.md`](docs/research/temporal-context-collection-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..486935c17 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-contexts" +path = "src/bin/tepp_temporal_contexts.rs" +test = false +bench = false + [lints] workspace = true diff --git a/crates/tepp_api/src/bin/tepp_temporal_contexts.rs b/crates/tepp_api/src/bin/tepp_temporal_contexts.rs new file mode 100644 index 000000000..df2863714 --- /dev/null +++ b/crates/tepp_api/src/bin/tepp_temporal_contexts.rs @@ -0,0 +1,30 @@ +//! Operator CLI for loopback `LineageWeave` temporal-context collection GET. + +use std::io::{self, IsTerminal}; +use std::process::ExitCode; + +use tepp_api::{ + execute_temporal_context_collection_cli, read_temporal_context_collection_cli_stdin, + render_temporal_context_collection_cli_stdout, ApiError, TemporalContextCollectionCliInvocation, +}; + +fn main() -> ExitCode { + match run() { + Ok(()) => ExitCode::SUCCESS, + Err(_) => ExitCode::FAILURE, + } +} + +fn run() -> Result<(), ApiError> { + let args: Vec = std::env::args().skip(1).collect(); + let body = read_temporal_context_collection_cli_stdin(io::stdin().is_terminal(), io::stdin())?; + let invocation = TemporalContextCollectionCliInvocation::from_args(&args, body)?; + let response = execute_temporal_context_collection_cli(&invocation)?; + let stdout = render_temporal_context_collection_cli_stdout(&invocation, &response)?; + println!("{stdout}"); + if response.status_code == 200 { + Ok(()) + } else { + Err(ApiError::InvalidWirePayload) + } +} diff --git a/crates/tepp_api/src/lib.rs b/crates/tepp_api/src/lib.rs index 0259b4a95..b3b97fe5b 100644 --- a/crates/tepp_api/src/lib.rs +++ b/crates/tepp_api/src/lib.rs @@ -31,6 +31,7 @@ mod project_history; mod project_journey; mod provider_payload; mod temporal_context; +mod temporal_context_collection_cli; mod temporal_context_collection_http; mod wire; @@ -309,3 +310,19 @@ pub use temporal_context_collection_http::parse_temporal_context_collection_page pub use temporal_context_collection_http::parse_temporal_context_collection_page_limit; /// Refuse collection JSON that already carries scientific-metric or evidence keys. pub use temporal_context_collection_http::refuse_metrics_on_temporal_context_collection_payload; +/// Supported operator verbs for the loopback temporal-context collection CLI. +pub use temporal_context_collection_cli::TemporalContextCollectionCliVerb; +/// One operator CLI invocation against a loopback collection GET listener. +pub use temporal_context_collection_cli::TemporalContextCollectionCliInvocation; +/// Compose one HTTP/1.1 collection GET from the typed `LineageWeave` exchange. +pub use temporal_context_collection_cli::compose_temporal_context_collection_cli_http; +/// Dispatch one collection CLI invocation against an in-process listener. +pub use temporal_context_collection_cli::dispatch_temporal_context_collection_cli; +/// Execute one collection CLI invocation over loopback TCP. +pub use temporal_context_collection_cli::execute_temporal_context_collection_cli; +/// Render a typed collection GET exchange as HTTP/1.1 for a loopback listener. +pub use temporal_context_collection_cli::loopback_http1_from_temporal_context_collection_exchange; +/// Read stdin leftover bytes on a non-terminal; collection GET admits empty. +pub use temporal_context_collection_cli::read_temporal_context_collection_cli_stdin; +/// Filter CLI stdout so collection pages never print scientific acceptance. +pub use temporal_context_collection_cli::render_temporal_context_collection_cli_stdout; diff --git a/crates/tepp_api/src/temporal_context_collection_cli.rs b/crates/tepp_api/src/temporal_context_collection_cli.rs new file mode 100644 index 000000000..501d57a3d --- /dev/null +++ b/crates/tepp_api/src/temporal_context_collection_cli.rs @@ -0,0 +1,686 @@ +//! Operator loopback CLI for `LineageWeave` temporal-context collection GET. +//! +//! GAP-003A unique slice: operators run `tepp-temporal-contexts list` to mint +//! `lineageweave_temporal_context_collection_exchange` onto spawned +//! `tepp-loopback` TCP. Stdout is a metric-free +//! `temporal_association_only` collection page. `tepp.scientific_acceptance.v1` +//! never appears. 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` (#414). This +//! module does not duplicate temporal-context collection GET (#449), +//! temporal-context CLI (#414), project-history collection CLI (#428), export +//! collection CLI (#444), interpretation-run collection CLI (#436), 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_collection_http::{ + parse_temporal_context_collection_page_cursor, parse_temporal_context_collection_page_limit, + refuse_metrics_on_temporal_context_collection_payload, +}; +use crate::wire::require_nonempty; +use crate::{ + lineageweave_temporal_context_collection_exchange, AnalysisRunLiveService, ApiError, + DEFAULT_PROJECT_HISTORY_BYTE_LIMIT, LINEAGEWEAVE_CONSUMER_CODE, NARUON_LIVE_HEADER_BYTE_LIMIT, + NARUON_LIVE_HEADER_COUNT_LIMIT, NARUON_LIVE_IO_TIMEOUT, NaruonHttpExchange, NaruonLiveResponse, + TEMPORAL_CONTEXT_PATH, TemporalContextCollection, +}; + +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 temporal-context collection CLI. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum TemporalContextCollectionCliVerb { + /// `GET /v1/temporal-context`. + List, +} + +impl TemporalContextCollectionCliVerb { + /// Parse one exact lowercase verb token. + /// + /// # Errors + /// + /// Returns [`ApiError::InvalidWirePayload`] for an unknown token. + pub fn parse(token: &str) -> Result { + match token { + "list" => Ok(Self::List), + _ => Err(ApiError::InvalidWirePayload), + } + } + + /// Return the canonical lowercase verb token. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::List => "list", + } + } +} + +/// One operator CLI invocation against a loopback collection GET listener. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TemporalContextCollectionCliInvocation { + /// CLI verb to execute. + pub verb: TemporalContextCollectionCliVerb, + /// Loopback `host:port` of `tepp-loopback`. + pub host: String, + /// Published HTTPS origin used to mint the typed collection exchange. + pub origin: String, + /// Published modular consumer. Collection GET admits `lineageweave` only. + pub consumer: String, + /// Optional exclusive page cursor (`tepp-page-cursor`). + pub page_cursor: Option, + /// Optional page limit (`tepp-page-limit`). + pub page_limit: Option, + /// JSON body. Collection GET requires empty. + pub body: String, +} + +impl TemporalContextCollectionCliInvocation { + /// Parse argv plus stdin body into a validated loopback collection 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, hostile pagination, 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 = TemporalContextCollectionCliVerb::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 out-of-bounds 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); + } + if !self.body.is_empty() { + return Err(ApiError::InvalidWirePayload); + } + refuse_scientific_acceptance(&self.body)?; + refuse_metrics_on_temporal_context_collection_payload(&self.body)?; + refuse_event_pii(&self.body)?; + parse_temporal_context_collection_page_limit(self.page_limit.as_deref())?; + parse_temporal_context_collection_page_cursor(self.page_cursor.as_deref())?; + Ok(()) + } +} + +struct ParsedFlags { + host: Option, + origin: Option, + consumer: Option, + page_cursor: Option, + page_limit: Option, +} + +fn parse_flags(rest: &[String]) -> Result { + let mut flags = ParsedFlags { + host: None, + origin: None, + consumer: None, + page_cursor: None, + page_limit: 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, + "page-cursor" => &mut flags.page_cursor, + "page-limit" => &mut flags.page_limit, + _ => 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: TemporalContextCollectionCliVerb, + flags: ParsedFlags, + body: String, +) -> Result { + let invocation = TemporalContextCollectionCliInvocation { + 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()), + page_cursor: flags.page_cursor, + page_limit: flags.page_limit, + 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 collection 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` with an empty body. +pub fn loopback_http1_from_temporal_context_collection_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)?; + if path != TEMPORAL_CONTEXT_PATH { + return Err(ApiError::InvalidWirePayload); + } + 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" + } + "tepp-page-cursor" => parse_temporal_context_collection_page_cursor(Some(value)).is_ok(), + "tepp-page-limit" => parse_temporal_context_collection_page_limit(Some(value)).is_ok(), + _ => 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 collection GET from the typed `LineageWeave` exchange. +/// +/// # Errors +/// +/// Returns the same fail-closed errors as +/// [`TemporalContextCollectionCliInvocation::validate`]. +pub fn compose_temporal_context_collection_cli_http( + invocation: &TemporalContextCollectionCliInvocation, +) -> Result { + invocation.validate()?; + let exchange = lineageweave_temporal_context_collection_exchange( + &invocation.origin, + invocation.page_cursor.as_deref(), + invocation.page_limit.as_deref(), + )?; + loopback_http1_from_temporal_context_collection_exchange(&exchange, &invocation.host) +} + +/// Dispatch one collection CLI invocation against an in-process listener. +/// +/// # Errors +/// +/// Returns fail-closed validation errors before the HTTP handler runs. +pub fn dispatch_temporal_context_collection_cli( + service: &mut AnalysisRunLiveService, + invocation: &TemporalContextCollectionCliInvocation, +) -> Result { + let request = compose_temporal_context_collection_cli_http(invocation)?; + Ok(service.handle_http_request(&request)) +} + +/// Execute one collection CLI invocation over loopback TCP. +/// +/// # Errors +/// +/// Returns fail-closed validation, transport, or response-framing errors. +pub fn execute_temporal_context_collection_cli( + invocation: &TemporalContextCollectionCliInvocation, +) -> Result { + let addr = require_loopback_host(&invocation.host)?; + let request = compose_temporal_context_collection_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 collection pages never print scientific acceptance. +/// +/// # Errors +/// +/// Returns [`ApiError::InvalidWirePayload`] when a receipt carries metric keys, +/// event labels, actor lists, or `tepp.scientific_acceptance.v1`. +pub fn render_temporal_context_collection_cli_stdout( + invocation: &TemporalContextCollectionCliInvocation, + response: &NaruonLiveResponse, +) -> Result { + invocation.validate()?; + if response.body.is_empty() { + return Err(ApiError::InvalidWirePayload); + } + refuse_scientific_acceptance(&response.body)?; + refuse_metrics_on_temporal_context_collection_payload(&response.body)?; + refuse_event_pii(&response.body)?; + if response.status_code != 200 { + return Err(ApiError::InvalidWirePayload); + } + let collection = TemporalContextCollection::from_json(&response.body)?; + let limit = parse_temporal_context_collection_page_limit(invocation.page_limit.as_deref())?; + if collection.contexts.len() > limit { + return Err(ApiError::InvalidWirePayload); + } + let cursor = parse_temporal_context_collection_page_cursor(invocation.page_cursor.as_deref())?; + for index in 1..collection.contexts.len() { + if collection.contexts[index - 1].idempotency_key + >= collection.contexts[index].idempotency_key + { + return Err(ApiError::InvalidWirePayload); + } + } + if let Some(cursor) = cursor { + for row in &collection.contexts { + if row.idempotency_key <= cursor { + return Err(ApiError::InvalidWirePayload); + } + } + } + if let Some(next_cursor) = &collection.next_cursor { + match collection.contexts.last() { + Some(row) if row.idempotency_key == *next_cursor => {} + Some(_) | None => return Err(ApiError::InvalidWirePayload), + } + } + collection.to_json() +} + +fn refuse_scientific_acceptance(body: &str) -> Result<(), ApiError> { + if body.contains(SCIENTIFIC_ACCEPTANCE_SCHEMA) { + Err(ApiError::InvalidWirePayload) + } else { + Ok(()) + } +} + +fn refuse_event_pii(body: &str) -> Result<(), ApiError> { + if body.contains("event_label") + || body.contains("actor_references") + || body.contains("timeline_events") + || body.contains("evidence_text") + { + Err(ApiError::InvalidWirePayload) + } else { + Ok(()) + } +} + +fn parse_http_response(bytes: &[u8]) -> Result { + let text = std::str::from_utf8(bytes).map_err(|_| ApiError::InvalidWirePayload)?; + let (header_block, body) = text + .split_once("\r\n\r\n") + .ok_or(ApiError::InvalidWirePayload)?; + 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 = match code { + 200 => "OK", + 202 => "Accepted", + 400 => "Bad Request", + 403 => "Forbidden", + 413 => "Payload Too Large", + 422 => "Unprocessable Entity", + _ => return Err(ApiError::InvalidWirePayload), + }; + 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(), + }) +} + +/// Read stdin leftover bytes on a non-terminal; collection 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_collection_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::{ + compose_temporal_context_collection_cli_http, + loopback_http1_from_temporal_context_collection_exchange, + read_temporal_context_collection_cli_stdin, TemporalContextCollectionCliInvocation, + TemporalContextCollectionCliVerb, + }; + use crate::lineageweave_temporal_context_collection_exchange; + use crate::{ApiError, LINEAGEWEAVE_CONSUMER_CODE, NaruonHttpExchange}; + + const ORIGIN: &str = "https://tepp.example.test"; + + fn list_args() -> [&'static str; 7] { + [ + "list", + "--host", + "127.0.0.1:18081", + "--origin", + ORIGIN, + "--consumer", + LINEAGEWEAVE_CONSUMER_CODE, + ] + } + + #[test] + fn from_args_mints_list_and_refuses_fail_closed_inputs() { + assert_eq!( + TemporalContextCollectionCliVerb::parse("list").expect("list"), + TemporalContextCollectionCliVerb::List + ); + assert_eq!(TemporalContextCollectionCliVerb::List.as_str(), "list"); + assert_eq!( + TemporalContextCollectionCliVerb::parse("cancel"), + Err(ApiError::InvalidWirePayload) + ); + let list = TemporalContextCollectionCliInvocation::from_args(list_args(), "").expect("list"); + let http = compose_temporal_context_collection_cli_http(&list).expect("http"); + assert!(http.starts_with("GET /v1/temporal-context 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!( + TemporalContextCollectionCliInvocation::from_args( + ["list", "--host", "8.8.8.8:80", "--origin", ORIGIN], + "" + ) + .unwrap_err(), + ApiError::AuthorizationDenied + ); + assert_eq!( + TemporalContextCollectionCliInvocation::from_args( + ["list", "--host", "localhost:18081", "--origin", ORIGIN], + "" + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + TemporalContextCollectionCliInvocation::from_args( + [ + "list", + "--host", + "127.0.0.1:18081", + "--origin", + "http://tepp.example.test" + ], + "" + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + TemporalContextCollectionCliInvocation::from_args( + [ + "list", + "--host", + "127.0.0.1:18081", + "--origin", + ORIGIN, + "--authorization", + "secret" + ], + "" + ) + .unwrap_err(), + ApiError::AuthorizationDenied + ); + } + + #[test] + fn from_args_refuses_naruon_body_and_non_get() { + assert_eq!( + TemporalContextCollectionCliInvocation::from_args( + [ + "list", + "--host", + "127.0.0.1:18081", + "--origin", + ORIGIN, + "--consumer", + "naruon" + ], + "" + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + TemporalContextCollectionCliInvocation::from_args(list_args(), "{}").unwrap_err(), + ApiError::InvalidWirePayload + ); + let leftover = + read_temporal_context_collection_cli_stdin(false, std::io::Cursor::new(b"leftover")) + .expect("leftover"); + assert_eq!(leftover, "leftover"); + assert!( + read_temporal_context_collection_cli_stdin(true, std::io::empty()) + .expect("tty") + .is_empty() + ); + let exchange = + lineageweave_temporal_context_collection_exchange(ORIGIN, None, None).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_collection_exchange(&posted, "127.0.0.1:18081") + .unwrap_err(), + ApiError::InvalidWirePayload + ); + } +} diff --git a/crates/tepp_api/tests/temporal_context_collection_cli_contract.rs b/crates/tepp_api/tests/temporal_context_collection_cli_contract.rs new file mode 100644 index 000000000..391d3cfab --- /dev/null +++ b/crates/tepp_api/tests/temporal_context_collection_cli_contract.rs @@ -0,0 +1,111 @@ +//! Contract tests for `tepp-temporal-contexts list`. + +use tepp_api::{ + compose_temporal_context_collection_cli_http, dispatch_temporal_context_collection_cli, + execute_temporal_context_collection_cli, render_temporal_context_collection_cli_stdout, + AnalysisRunLiveService, ApiError, LINEAGEWEAVE_CONSUMER_CODE, TEMPORAL_CONTEXT_PATH, + TemporalContextCollection, TemporalContextCollectionCliInvocation, NaruonLiveResponse, +}; + +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"]}]}"#; + +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 list_invocation(host: &str) -> TemporalContextCollectionCliInvocation { + TemporalContextCollectionCliInvocation::from_args( + [ + "list", + "--host", + host, + "--origin", + ORIGIN, + "--consumer", + LINEAGEWEAVE_CONSUMER_CODE, + ], + "", + ) + .expect("list") +} + +#[test] +fn dispatch_lists_one_metric_free_page() { + let mut service = AnalysisRunLiveService::new(); + assert_eq!( + service.handle_http_request(&post_http("idem-cli")).status_code, + 200 + ); + let listed = + dispatch_temporal_context_collection_cli(&mut service, &list_invocation("127.0.0.1:18081")) + .expect("list"); + assert_eq!(listed.status_code, 200, "{}", listed.body); + let stdout = render_temporal_context_collection_cli_stdout( + &list_invocation("127.0.0.1:18081"), + &listed, + ) + .expect("out"); + assert!(!stdout.contains("tepp.scientific_acceptance.v1")); + assert!(!stdout.contains("rmse")); + assert!(!stdout.contains("event_label")); + assert!(!stdout.contains("actor_references")); + let page = TemporalContextCollection::from_json(&stdout).expect("page"); + assert_eq!(page.contexts.len(), 1); + assert_eq!(page.contexts[0].idempotency_key, "idem-cli"); + assert_eq!(page.contexts[0].inference_status, "temporal_association_only"); +} + +#[test] +fn render_refuses_metrics_schema_and_empty_bodies() { + let list = list_invocation("127.0.0.1:18081"); + assert_eq!( + render_temporal_context_collection_cli_stdout( + &list, + &NaruonLiveResponse { + status_code: 200, + reason_phrase: "OK", + body: String::new(), + } + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + render_temporal_context_collection_cli_stdout( + &list, + &NaruonLiveResponse { + status_code: 200, + reason_phrase: "OK", + body: r#"{"contract_version":1,"contexts":[],"rmse":1.0}"#.into(), + } + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + let http = compose_temporal_context_collection_cli_http(&list).expect("http"); + assert!(http.starts_with("GET /v1/temporal-context HTTP/1.1")); +} + +#[test] +fn execute_over_tcp_lists_authorized_identities() { + let mut service = AnalysisRunLiveService::bind_loopback().expect("bind"); + assert_eq!( + service.handle_http_request(&post_http("idem-tcp")).status_code, + 200 + ); + let addr = service.local_addr().expect("addr"); + let handle = std::thread::spawn(move || { + drop(service.serve_one()); + }); + let invocation = list_invocation(&addr.to_string()); + let response = execute_temporal_context_collection_cli(&invocation).expect("tcp"); + assert_eq!(response.status_code, 200, "{}", response.body); + let stdout = render_temporal_context_collection_cli_stdout(&invocation, &response).expect("out"); + let page = TemporalContextCollection::from_json(&stdout).expect("page"); + assert_eq!(page.contexts[0].idempotency_key, "idem-tcp"); + handle.join().expect("join"); +} diff --git a/docs/API_CONTRACT.md b/docs/API_CONTRACT.md index 9ed929e1b..13565cdcb 100644 --- a/docs/API_CONTRACT.md +++ b/docs/API_CONTRACT.md @@ -92,8 +92,10 @@ them by event time and opaque event ID, and emits adjacent forward temporal associations plus `candidate_not_causal` transition gaps. It does not infer causality, mutate TEPP state, or return a completed psychometric result. `GET /v1/temporal-context` enumerates accepted metric-free identities minted -when that POST carries an `idempotency-key` header (ADR 0081). Collection rows -stay `inference_status=temporal_association_only`. Event labels, actor lists, +when that POST carries an `idempotency-key` header (ADR 0081). Published +`tepp-temporal-contexts list` mints that collection GET onto spawned +`tepp-loopback` TCP (ADR 0082). Collection rows stay +`inference_status=temporal_association_only`. Event labels, actor lists, and `tepp.scientific_acceptance.v1` never appear. Naruon is refused. `NaruonLiveService` stays POST-only. diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index c591525a5..a62e72bc7 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -54,6 +54,7 @@ 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 collection GET | ADR 0081; API contract; RFC 9110; ADR 0002/0014 | `tepp_api` `GET /v1/temporal-context` on `tepp-loopback`; metric-free `inference_status=temporal_association_only` identities; `tepp.scientific_acceptance.v1` never appears; does not infer causality | active-PR | +| loopback LineageWeave temporal-context collection CLI | ADR 0082; API contract; RFC 9110; ADR 0081/0002/0014 | `tepp-temporal-contexts list` mints that collection GET onto spawned `tepp-loopback` TCP; metric-free `inference_status=temporal_association_only`; `tepp.scientific_acceptance.v1` never appears; does not infer causality | 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/0082-temporal-context-collection-cli.md b/docs/adr/0082-temporal-context-collection-cli.md new file mode 100644 index 000000000..8d716d91b --- /dev/null +++ b/docs/adr/0082-temporal-context-collection-cli.md @@ -0,0 +1,99 @@ +# ADR 0082 — Loopback temporal-context collection CLI + +**Decision status:** Accepted +**Implementation maturity:** active-PR +**Date:** 2026-09-01 +**Supersedes:** None; complements ADR 0081 for operator-visible collection GET. +Does not supersede ADR 0014 claim-promotion authority. This ADR number is +unique versus protected main; live vs-main and sibling GAP-003A PRs already +occupy 0026–0081. + +## Context + +ADR 0081 enumerates accepted temporal-context identities on +`AnalysisRunLiveService`. Operators still had no published binary that mints +that GET onto spawned `tepp-loopback` TCP. Duplicating temporal-context +collection GET (#449), temporal-context CLI (#414), project-history collection +CLI (#428), export collection CLI (#444), interpretation-run collection CLI +(#436), Leiden, Driver p.16, or GAP-010 Figma/export would collide with live +PRs. Naruon is refused on this LineageWeave-owned adapter; +`NaruonLiveService` stays POST-only. + +## Decision + +Publish `tepp-temporal-contexts list`: + +- Pattern: `from_args` + typed `lineageweave_temporal_context_collection_exchange` + + `loopback_http1_from_temporal_context_collection_exchange` + + `dispatch`/`execute`/`render` + published `[[bin]]`. +- Empty stdin is admitted. Nonempty leftover stdin fails closed. +- Public bind, `localhost` host, `http` origin, unpublished consumer, and + credential flags fail closed. +- Stdout is one metric-free collection page with + `inference_status=temporal_association_only`. Event labels, actor lists, + timeline events, evidence text, findings, RMSE, bias, coverage, SE-gate, + causal scores, and `tepp.scientific_acceptance.v1` never appear. +- Dedicated binary so it does not collide with `tepp-temporal-context` (#414). + +## Alternatives considered + +1. **Reuse `tepp-temporal-context`** — rejected; that CLI is POST. +2. **Add GET to `NaruonLiveService`** — rejected; POST-only. +3. **Published `tepp-temporal-contexts list`** — accepted. + +## Consequences + +- Operators can list accepted temporal-context identities without a second + collection GET PR. +- Collection JSON cannot be mistaken for a succeeded scientific-acceptance + result or a causal score. +- Collection success is not release evidence and is not an ADR 0014 claim. + +## Failure and recovery + +Non-LineageWeave consumers, nonempty leftover stdin, present +`idempotency-key` as an HTTP header, credential flags, public bind, and metric +keys fail closed. TCP execute does not fall back to an empty in-process +listener. + +## Security, privacy, scientific-integrity, and governance impact + +- No credential headers cross the consumer boundary. +- Event labels, actor lists, and evidence stay off the collection receipt. +- HTTP 200 on collection is not measurement evidence and is not a causal claim. + +## Compatibility and migration + +Collection GET, POST `/v1/temporal-context`, and `NaruonLiveService` POST-only +remain unchanged. Persistence remains GAP-003B. + +## Verification + +Falsifiable evidence: + +- `tepp-temporal-contexts list` of accepted identities returns metric-free + rows without RMSE/bias/coverage/SE-gate/event-label/actor/evidence/findings/ + causal-score/`tepp.scientific_acceptance.v1` keys; +- naruon, nonempty leftover stdin, `localhost`, `http` origin, public bind, + and unknown keys fail closed; +- `NaruonLiveService` still refuses GET; +- Clippy `-D warnings`, `tepp_api` tests, rustdoc, and exact-head review + remain required. + +## Rollback and supersession + +Rollback removes the published binary; collection GET remains valid. A +superseding ADR is required to persist the registry, bind a public address, +emit scientific-acceptance on collection, open naruon on this adapter, add GET +to `NaruonLiveService`, or treat collection success as an ADR 0014 claim. + +## Related authority + +- ADR 0081 owns loopback temporal-context collection GET. +- ADR 0027 owns the temporal-context CLI (live #414). +- ADR 0076 owns the export collection CLI (live #444). +- ADR 0070 owns interpretation-run collection CLI (live #436). +- ADR 0065 owns project-history collection CLI (live #428). +- ADR 0014 owns scientific claim promotion. +- RFC 9110 owns GET semantics (Fielding, Nottingham, & Reschke, 2022). It + does not authorize scientific claims. diff --git a/docs/adr/README.md b/docs/adr/README.md index 24a87240f..f0299eb06 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -31,6 +31,7 @@ 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. | | [0081](0081-temporal-context-collection-get.md) | Loopback temporal-context collection GET | Accepted | active-PR | Complements `POST /v1/temporal-context`; `GET /v1/temporal-context` enumerates metric-free LineageWeave identities. Unique versus protected main (0026–0080 occupied). Naruon refused. `NaruonLiveService` stays POST-only. | +| [0082](0082-temporal-context-collection-cli.md) | Loopback temporal-context collection CLI | Accepted | active-PR | Complements ADR 0081; published `tepp-temporal-contexts list` mints LineageWeave `GET /v1/temporal-context` onto spawned `tepp-loopback` TCP. Unique versus protected main (0026–0081 occupied). | | [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-collection-cli.md b/docs/research/temporal-context-collection-cli.md new file mode 100644 index 000000000..b037c52a5 --- /dev/null +++ b/docs/research/temporal-context-collection-cli.md @@ -0,0 +1,55 @@ +# Temporal-context collection CLI (doctoring) + +## Scope + +`tepp-temporal-contexts list` is the operator-visible loopback CLI that mints a +typed LineageWeave `GET /v1/temporal-context` onto spawned `tepp-loopback` +TCP. HTTP method, path, and header semantics follow current HTTP semantics +(Fielding, Nottingham, & Reschke, 2022). Fail-closed refusal of unpublished +consumers, nonempty leftover stdin, `localhost`, `http` origin, credential +flags, public bind, and scientific-authority promotion is repository contract +authority (ADR 0082; ADR 0081; ADR 0014), not an RFC inference rule. + +Stdout is metric-free with `inference_status=temporal_association_only`. Event +labels, actor lists, timeline events, evidence text, findings, and +`tepp.scientific_acceptance.v1` never appear. HTTP 200 is not a completed +psychometric result, 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.1 describes GET as a method for retrieving a current +representation. TEPP maps that retrieval onto an in-memory page of metric-free +temporal-context identities. The RFC does not define psychometric acceptance, +RMSE, causality, or claim promotion. + +### Internal contract evidence + +- `docs/adr/0082-temporal-context-collection-cli.md` — this CLI +- `docs/adr/0081-temporal-context-collection-get.md` — collection GET +- `docs/adr/0014-scientific-claim-promotion-and-release-evidence.md` — HTTP + 200 is not a scientific claim +- `crates/tepp_api/tests/temporal_context_collection_cli_contract.rs` — + fail-closed CLI proofs + +## Verification + +- `tepp-temporal-contexts list` of accepted LineageWeave identities returns + metric-free rows without RMSE/bias/coverage/SE-gate keys, event labels, + actor lists, evidence text, findings, causal scores, or + `tepp.scientific_acceptance.v1`; +- naruon, nonempty leftover stdin, `localhost`, `http` origin, and public bind + fail closed; +- `NaruonLiveService` still refuses GET. + +## Non-claims + +This slice does not implement GAP-010 Figma/export, temporal-context POST CLI, +project-history collection CLI, persistence, production TLS, Leiden consensus, +provider execution, causal inference, or an ADR 0014 scientific +claim-promotion package.