diff --git a/CHANGELOG.d/project-history-collection-cli.md b/CHANGELOG.d/project-history-collection-cli.md new file mode 100644 index 000000000..f3042ac12 --- /dev/null +++ b/CHANGELOG.d/project-history-collection-cli.md @@ -0,0 +1 @@ +- `tepp_api` loopback `tepp-project-histories list` enumerates metric-free accepted LineageWeave project-history projections (ADR 0065). Collection CLI stdout refuses RMSE/bias/coverage/SE-gate/scientific-acceptance/evidence/`causal_score` keys, non-200 bodies, and pages not bound to the requested cursor/limit. Naruon refused. Not project-history POST CLI, not collection GET listener, not persistence. diff --git a/CHANGELOG.d/project-history-collection-http.md b/CHANGELOG.d/project-history-collection-http.md new file mode 100644 index 000000000..5540b26e7 --- /dev/null +++ b/CHANGELOG.d/project-history-collection-http.md @@ -0,0 +1 @@ +- `tepp_api` loopback `GET /v1/project-histories` enumerates accepted LineageWeave project-history projections on `tepp-loopback` (ADR 0028). Metric-free `temporal_association_only` identities only. `tepp.scientific_acceptance.v1` never appears. Does not infer causality. Not project-history CLI, not analysis-run collection GET, not persistence. diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 6fa4b9683..f5e80d2d9 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -11,6 +11,8 @@ TEPP's approved PRD v0.4 and implementation plan are the primary product baselin | Architecture | [`ARCHITECTURE.md`](ARCHITECTURE.md) | | Modular/API integration contract | [`docs/API_CONTRACT.md`](docs/API_CONTRACT.md) | | naruon modular consumer contract | [`docs/connectors/naruon-artifact-consumer.md`](docs/connectors/naruon-artifact-consumer.md) | +| Project-history collection GET doctoring | [`docs/research/project-history-collection-http.md`](docs/research/project-history-collection-http.md) | +| Project-history collection CLI doctoring | [`docs/research/project-history-collection-cli.md`](docs/research/project-history-collection-cli.md) | | contextual-orchestrator interpretation port | [`docs/connectors/contextual-orchestrator-interpretation-port.md`](docs/connectors/contextual-orchestrator-interpretation-port.md) | | Orchestrator live HTTP doctoring | [`docs/research/orchestrator-live-http.md`](docs/research/orchestrator-live-http.md) | | UML/runtime/scientific flows | [`docs/UML.md`](docs/UML.md) | diff --git a/crates/tepp_api/Cargo.toml b/crates/tepp_api/Cargo.toml index 47ad7c433..6d161a38f 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-project-histories" +path = "src/bin/tepp_project_histories.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 6768c6ef1..d07b94e82 100644 --- a/crates/tepp_api/src/analysis_run_live.rs +++ b/crates/tepp_api/src/analysis_run_live.rs @@ -19,8 +19,11 @@ use crate::naruon_http::NARUON_ANALYSIS_RUN_PATH; use crate::{ AnalysisRunAccepted, AnalysisRunRequest, ApiError, DEFAULT_PROJECT_HISTORY_BYTE_LIMIT, ErrorEnvelope, NARUON_LIVE_IO_TIMEOUT, NaruonLiveResponse, PROJECT_HISTORY_PATH, - ProjectHistoryProjection, ProjectHistoryRequest, TEMPORAL_CONTEXT_PATH, TemporalContextRequest, - build_temporal_context, project_history_projection, requests_are_idempotent_matches, + ProjectHistoryCollection, ProjectHistoryCollectionItem, ProjectHistoryProjection, + ProjectHistoryRequest, TEMPORAL_CONTEXT_PATH, TemporalContextRequest, build_temporal_context, + is_project_history_collection_path, page_project_history_collection_items, + parse_project_history_collection_page_cursor, parse_project_history_collection_page_limit, + project_history_projection, requests_are_idempotent_matches, }; const MAX_LIVE_REQUEST_BODY_BYTES: usize = DEFAULT_PROJECT_HISTORY_BYTE_LIMIT; @@ -143,6 +146,10 @@ impl AnalysisRunLiveService { let (header_block, body) = split_request_with_limit(request, MAX_LIVE_REQUEST_BODY_BYTES)?; let mut lines = header_block.split("\r\n"); let (method, path) = parse_request_line(lines.next().unwrap_or(""))?; + let headers = parse_headers(&mut lines)?; + if method == "GET" { + return self.list_project_histories(path, &headers, body); + } if method != "POST" || (path != NARUON_ANALYSIS_RUN_PATH && path != TEMPORAL_CONTEXT_PATH @@ -150,7 +157,6 @@ impl AnalysisRunLiveService { { return Err(ApiError::InvalidWirePayload); } - let headers = parse_headers(&mut lines)?; let consumer = require_headers( &headers, self.bound_addr, @@ -235,6 +241,48 @@ impl AnalysisRunLiveService { Ok(json_response(200, "OK", response_body)) } + fn list_project_histories( + &self, + path: &str, + headers: &HashMap, + body: &str, + ) -> Result { + if !is_project_history_collection_path(path) { + return Err(ApiError::InvalidWirePayload); + } + if !body.is_empty() { + return Err(ApiError::InvalidWirePayload); + } + let consumer = require_headers(headers, self.bound_addr, false)?; + if consumer != LINEAGEWEAVE_CONSUMER_CODE { + return Err(ApiError::InvalidWirePayload); + } + let limit = match headers.get("tepp-page-limit") { + Some(value) => parse_project_history_collection_page_limit(Some(value.as_str()))?, + None => parse_project_history_collection_page_limit(None)?, + }; + let cursor = match headers.get("tepp-page-cursor") { + Some(value) => parse_project_history_collection_page_cursor(Some(value.as_str()))?, + None => parse_project_history_collection_page_cursor(None)?, + }; + let items = self + .accepted_project_histories + .values() + .map(|(request, projection)| { + ProjectHistoryCollectionItem::new( + request.project_key.clone(), + request.idempotency_key.clone(), + projection.knowledge_cutoff.clone(), + projection.inference_status.clone(), + ) + }) + .collect::, _>>()?; + let (page, next_cursor) = + page_project_history_collection_items(items, cursor.as_deref(), limit); + let collection = ProjectHistoryCollection::new(page, next_cursor)?; + Ok(json_response(200, "OK", collection.to_json()?)) + } + fn response_from_error(&mut self, error: ApiError) -> NaruonLiveResponse { let request_id = format!("analysis-run-live-{}", self.next_request_serial); self.next_request_serial += 1; @@ -319,7 +367,9 @@ mod tests { ANALYSIS_RUN_CONTRACT_VERSION, AnalysisRunRequest, ApiError, 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, + NARUON_LIVE_HEADER_COUNT_LIMIT, NARUON_LIVE_IO_TIMEOUT, PROJECT_HISTORY_CONTRACT_VERSION, + PROJECT_HISTORY_PATH, ProjectHistoryCollection, ProjectHistoryEvent, ProjectHistoryRequest, + TEMPORAL_CONTEXT_PATH, }; fn sample_run() -> AnalysisRunRequest { @@ -938,6 +988,93 @@ mod tests { ); } + fn sample_project_history(idempotency_key: &str, project_key: &str) -> ProjectHistoryRequest { + ProjectHistoryRequest { + contract_version: PROJECT_HISTORY_CONTRACT_VERSION, + idempotency_key: idempotency_key.into(), + tenant_workspace_id: "history-tenant".into(), + project_key: project_key.into(), + project_name: "Project".into(), + knowledge_cutoff: "2026-08-19T23:59:59Z".into(), + focus_event_id: "focus".into(), + events: vec![ProjectHistoryEvent { + event_id: "focus".into(), + event_type_code: "voc_received".into(), + event_title: "VOC".into(), + occurred_at: "2026-08-19T09:00:00Z".into(), + available_at: "2026-08-19T10:00:00Z".into(), + source_post_id: "post".into(), + evidence_text: "explicit evidence".into(), + actor_ids: Vec::new(), + }], + } + } + + fn project_history_post(request: &ProjectHistoryRequest) -> String { + let body = request.to_json().expect("history json"); + format!( + "POST {PROJECT_HISTORY_PATH} HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: {LINEAGEWEAVE_CONSUMER_CODE}\r\ntepp-contract-version: 1\r\nidempotency-key: {}\r\ncontent-length: {}\r\n\r\n{body}", + request.idempotency_key, + body.len() + ) + } + + #[test] + fn project_history_collection_get_is_metric_free_and_fail_closed() { + let mut service = AnalysisRunLiveService::new(); + let first = sample_project_history("idem-a", "project-a"); + let second = sample_project_history("idem-b", "project-b"); + assert_eq!( + service + .handle_http_request(&project_history_post(&first)) + .status_code, + 200 + ); + assert_eq!( + service + .handle_http_request(&project_history_post(&second)) + .status_code, + 200 + ); + + let list = format!( + "GET {PROJECT_HISTORY_PATH} HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: {LINEAGEWEAVE_CONSUMER_CODE}\r\ntepp-contract-version: 1\r\ncontent-length: 0\r\n\r\n" + ); + let got = service.handle_http_request(&list); + assert_eq!(got.status_code, 200); + let page = ProjectHistoryCollection::from_json(&got.body).expect("page"); + assert_eq!(page.histories.len(), 2); + assert_eq!(page.histories[0].idempotency_key, "idem-a"); + assert_eq!(page.histories[1].project_key, "project-b"); + assert!(!got.body.contains("rmse")); + assert!(!got.body.contains("tepp.scientific_acceptance.v1")); + assert!(!got.body.contains("evidence_text")); + assert!(!got.body.contains("findings")); + assert!(!got.body.contains("causal_score")); + + let limited = format!( + "GET {PROJECT_HISTORY_PATH} HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: {LINEAGEWEAVE_CONSUMER_CODE}\r\ntepp-contract-version: 1\r\ntepp-page-limit: 1\r\ncontent-length: 0\r\n\r\n" + ); + let limited_got = service.handle_http_request(&limited); + let limited_page = + ProjectHistoryCollection::from_json(&limited_got.body).expect("limited page"); + assert_eq!(limited_page.histories.len(), 1); + assert_eq!(limited_page.next_cursor.as_deref(), Some("idem-a")); + + let analysis_get = format!( + "GET {NARUON_ANALYSIS_RUN_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\ncontent-length: 0\r\n\r\n" + ); + assert_eq!(service.handle_http_request(&analysis_get).status_code, 400); + let naruon_list = format!( + "GET {PROJECT_HISTORY_PATH} 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" + ); + assert_eq!(service.handle_http_request(&naruon_list).status_code, 400); + let nonempty = format!( + "GET {PROJECT_HISTORY_PATH} HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: {LINEAGEWEAVE_CONSUMER_CODE}\r\ntepp-contract-version: 1\r\ncontent-length: 2\r\n\r\n{{}}" + ); + assert_eq!(service.handle_http_request(&nonempty).status_code, 400); + } + struct ScriptedRead { reader: Cursor>, first_error: Option, diff --git a/crates/tepp_api/src/bin/tepp_project_histories.rs b/crates/tepp_api/src/bin/tepp_project_histories.rs new file mode 100644 index 000000000..e394aaaaa --- /dev/null +++ b/crates/tepp_api/src/bin/tepp_project_histories.rs @@ -0,0 +1,29 @@ +//! Operator CLI for loopback `LineageWeave` project-history collection GET. + +use std::io::{self, IsTerminal}; +use std::process::ExitCode; + +use tepp_api::{ + ApiError, ProjectHistoryCollectionCliInvocation, execute_project_history_collection_cli, + read_project_history_collection_cli_stdin, render_project_history_collection_cli_stdout, +}; + +fn main() -> ExitCode { + match run() { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("{error}"); + ExitCode::FAILURE + } + } +} + +fn run() -> Result<(), ApiError> { + let args: Vec = std::env::args().skip(1).collect(); + let body = read_project_history_collection_cli_stdin(io::stdin().is_terminal(), io::stdin())?; + let invocation = ProjectHistoryCollectionCliInvocation::from_args(&args, body)?; + let response = execute_project_history_collection_cli(&invocation)?; + let stdout = render_project_history_collection_cli_stdout(&invocation, &response)?; + println!("{stdout}"); + Ok(()) +} diff --git a/crates/tepp_api/src/lib.rs b/crates/tepp_api/src/lib.rs index 876703ebc..590a8c84d 100644 --- a/crates/tepp_api/src/lib.rs +++ b/crates/tepp_api/src/lib.rs @@ -28,6 +28,8 @@ mod naruon_http; mod naruon_live; mod orchestration; mod project_history; +mod project_history_collection_cli; +mod project_history_collection_http; mod project_journey; mod provider_payload; mod temporal_context; @@ -216,6 +218,8 @@ pub use project_history::DEFAULT_PROJECT_HISTORY_BYTE_LIMIT; pub use project_history::DEFAULT_PROJECT_HISTORY_EVENT_LIMIT; /// Supported project-history contract version. pub use project_history::PROJECT_HISTORY_CONTRACT_VERSION; +/// Maximum opaque idempotency-key size shared by project-history APIs. +pub use project_history::PROJECT_HISTORY_IDEMPOTENCY_KEY_MAX_LEN; /// Versioned project-history path. pub use project_history::PROJECT_HISTORY_PATH; /// Explicit source-grounded project event. @@ -230,6 +234,46 @@ pub use project_history::ProjectHistoryProjection; pub use project_history::ProjectHistoryRequest; /// Build a cutoff-safe project-history projection. pub use project_history::project_history_projection; +/// Loopback project-history collection CLI invocation. +pub use project_history_collection_cli::ProjectHistoryCollectionCliInvocation; +/// Loopback project-history collection CLI verb. +pub use project_history_collection_cli::ProjectHistoryCollectionCliVerb; +/// Compose HTTP/1.1 collection GET from a CLI invocation. +pub use project_history_collection_cli::compose_project_history_collection_cli_http; +/// Dispatch a collection CLI invocation against an in-process listener. +pub use project_history_collection_cli::dispatch_project_history_collection_cli; +/// Execute a collection CLI invocation over loopback TCP. +pub use project_history_collection_cli::execute_project_history_collection_cli; +/// Render a typed collection GET exchange as loopback HTTP/1.1. +pub use project_history_collection_cli::loopback_http1_from_project_history_collection_exchange; +/// Read leftover stdin for the project-history collection CLI. +pub use project_history_collection_cli::read_project_history_collection_cli_stdin; +/// Filter collection CLI stdout so the page stays metric-free. +pub use project_history_collection_cli::render_project_history_collection_cli_stdout; +/// Maximum opaque cursor length on project-history collection GET. +pub use project_history_collection_http::PROJECT_HISTORY_COLLECTION_CURSOR_MAX_LEN; +/// Default page size for project-history collection GET. +pub use project_history_collection_http::PROJECT_HISTORY_COLLECTION_DEFAULT_LIMIT; +/// Fixed non-causal inference status on collection rows. +pub use project_history_collection_http::PROJECT_HISTORY_COLLECTION_INFERENCE_STATUS; +/// Maximum page size for project-history collection GET. +pub use project_history_collection_http::PROJECT_HISTORY_COLLECTION_MAX_LIMIT; +/// Metric-free project-history collection page. +pub use project_history_collection_http::ProjectHistoryCollection; +/// One metric-free project-history collection row. +pub use project_history_collection_http::ProjectHistoryCollectionItem; +/// Whether a path is the project-history collection resource. +pub use project_history_collection_http::is_project_history_collection_path; +/// `LineageWeave` GET exchange for project-history collection. +pub use project_history_collection_http::lineageweave_project_history_collection_exchange; +/// Page stored project-history collection rows. +pub use project_history_collection_http::page_project_history_collection_items; +/// Parse the exclusive project-history collection cursor header. +pub use project_history_collection_http::parse_project_history_collection_page_cursor; +/// Parse the project-history collection page-limit header. +pub use project_history_collection_http::parse_project_history_collection_page_limit; +/// Refuse metric, evidence, and causal-score keys on collection JSON. +pub use project_history_collection_http::refuse_metrics_on_project_history_collection_payload; /// Maximum posterior Project Journey artifact size. pub use project_journey::DEFAULT_PROJECT_JOURNEY_BYTE_LIMIT; /// Exact posterior Project Journey schema identity. diff --git a/crates/tepp_api/src/project_history.rs b/crates/tepp_api/src/project_history.rs index 433fab458..f21b71eda 100644 --- a/crates/tepp_api/src/project_history.rs +++ b/crates/tepp_api/src/project_history.rs @@ -29,6 +29,9 @@ pub const DEFAULT_PROJECT_HISTORY_BYTE_LIMIT: usize = 256 * 1024; /// Maximum event count accepted in one project-history request. pub const DEFAULT_PROJECT_HISTORY_EVENT_LIMIT: usize = 128; +/// Maximum opaque idempotency-key size shared by creation and collection cursors. +pub const PROJECT_HISTORY_IDEMPOTENCY_KEY_MAX_LEN: usize = 256; + /// Explicit event evidence supplied by an authorized modular consumer. #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(deny_unknown_fields)] @@ -162,7 +165,8 @@ impl ProjectHistoryRequest { fn validate(&self) -> Result<(), ApiError> { require_contract_version(self.contract_version, PROJECT_HISTORY_CONTRACT_VERSION)?; - validate_bounded_text(&self.idempotency_key, 256)?; + let maximum_key_len = PROJECT_HISTORY_IDEMPOTENCY_KEY_MAX_LEN; + validate_bounded_text(&self.idempotency_key, maximum_key_len)?; validate_bounded_text(&self.tenant_workspace_id, 256)?; validate_bounded_text(&self.project_key, 256)?; validate_bounded_text(&self.project_name, 512)?; diff --git a/crates/tepp_api/src/project_history_collection_cli.rs b/crates/tepp_api/src/project_history_collection_cli.rs new file mode 100644 index 000000000..9e06cad61 --- /dev/null +++ b/crates/tepp_api/src/project_history_collection_cli.rs @@ -0,0 +1,1353 @@ +//! Operator loopback CLI for `LineageWeave` project-history collection GET. +//! +//! GAP-003A unique slice: operators run `tepp-project-histories list` to mint +//! `lineageweave_project_history_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. This +//! module does not duplicate project-history POST CLI (#420), collection GET +//! (#424), temporal-context CLI (#414), export CLIs, analysis-run collection +//! CLI (#371), GET-by-id, Leiden, or GAP-010 Figma/export. Persistence remains +//! GAP-003B. + +use std::collections::HashSet; +use std::fmt::Write as _; +use std::io::{Read, Write}; +use std::net::{SocketAddr, TcpStream}; + +use crate::live_http::map_io_error; +use crate::naruon_http::header_is_credential; +use crate::project_history_collection_http::{ + parse_project_history_collection_page_cursor, parse_project_history_collection_page_limit, + refuse_metrics_on_project_history_collection_payload, +}; +use crate::wire::require_nonempty; +use crate::{ + AnalysisRunLiveService, ApiError, DEFAULT_PROJECT_HISTORY_BYTE_LIMIT, + LINEAGEWEAVE_CONSUMER_CODE, NARUON_LIVE_HEADER_BYTE_LIMIT, NARUON_LIVE_HEADER_COUNT_LIMIT, + NARUON_LIVE_IO_TIMEOUT, NaruonHttpExchange, NaruonLiveResponse, PROJECT_HISTORY_PATH, + ProjectHistoryCollection, lineageweave_project_history_collection_exchange, +}; + +const SCIENTIFIC_ACCEPTANCE_SCHEMA: &str = "tepp.scientific_acceptance.v1"; +const MAXIMUM_HTTP_RESPONSE_BYTES: usize = + NARUON_LIVE_HEADER_BYTE_LIMIT + 4 + DEFAULT_PROJECT_HISTORY_BYTE_LIMIT; + +/// Supported operator verbs for the loopback project-history collection CLI. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ProjectHistoryCollectionCliVerb { + /// `GET /v1/project-histories`. + List, +} + +impl ProjectHistoryCollectionCliVerb { + /// 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 ProjectHistoryCollectionCliInvocation { + /// CLI verb to execute. + pub verb: ProjectHistoryCollectionCliVerb, + /// 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 ProjectHistoryCollectionCliInvocation { + /// Parse argv plus stdin body into a validated loopback collection invocation. + /// + /// # Errors + /// + /// Returns a fail-closed error for unknown verbs, missing required flags, a + /// non-loopback host, a non-`https` origin, an unpublished or naruon + /// consumer, credential-shaped flags, 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 = ProjectHistoryCollectionCliVerb::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_project_history_collection_payload(&self.body)?; + parse_project_history_collection_page_limit(self.page_limit.as_deref())?; + parse_project_history_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: ProjectHistoryCollectionCliVerb, + flags: ParsedFlags, + body: String, +) -> Result { + let invocation = ProjectHistoryCollectionCliInvocation { + 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. +/// +/// The exchange keeps its HTTPS origin contract. Only the HTTP/1.1 `Host` is +/// the loopback bind address. Public bind hosts fail closed. +/// +/// # Errors +/// +/// Returns [`ApiError::AuthorizationDenied`] for a non-loopback host or a +/// credential-bearing header, and [`ApiError::InvalidWirePayload`] when the +/// exchange is not a GET `/v1/project-histories` with an empty body. +pub fn loopback_http1_from_project_history_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 != PROJECT_HISTORY_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 !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_project_history_collection_page_cursor(Some(value)).is_ok(), + "tepp-page-limit" => parse_project_history_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 +/// [`ProjectHistoryCollectionCliInvocation::validate`]. +pub fn compose_project_history_collection_cli_http( + invocation: &ProjectHistoryCollectionCliInvocation, +) -> Result { + invocation.validate()?; + let exchange = lineageweave_project_history_collection_exchange( + &invocation.origin, + invocation.page_cursor.as_deref(), + invocation.page_limit.as_deref(), + )?; + loopback_http1_from_project_history_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_project_history_collection_cli( + service: &mut AnalysisRunLiveService, + invocation: &ProjectHistoryCollectionCliInvocation, +) -> Result { + let request = compose_project_history_collection_cli_http(invocation)?; + Ok(service.handle_http_request(&request)) +} + +/// Execute one collection CLI invocation over loopback TCP against `tepp-loopback`. +/// +/// # Errors +/// +/// Returns fail-closed validation, transport, or response-framing errors. +pub fn execute_project_history_collection_cli( + invocation: &ProjectHistoryCollectionCliInvocation, +) -> Result { + let addr = require_loopback_host(&invocation.host)?; + let request = compose_project_history_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, +/// evidence, causal scores, or `tepp.scientific_acceptance.v1`. +pub fn render_project_history_collection_cli_stdout( + invocation: &ProjectHistoryCollectionCliInvocation, + response: &NaruonLiveResponse, +) -> Result { + invocation.validate()?; + if response.body.is_empty() { + return Err(ApiError::InvalidWirePayload); + } + refuse_scientific_acceptance(&response.body)?; + refuse_metrics_on_project_history_collection_payload(&response.body)?; + if response.status_code != 200 { + return Err(ApiError::InvalidWirePayload); + } + let collection = ProjectHistoryCollection::from_json(&response.body)?; + let limit = parse_project_history_collection_page_limit(invocation.page_limit.as_deref())?; + if collection.histories.len() > limit { + return Err(ApiError::InvalidWirePayload); + } + let cursor = parse_project_history_collection_page_cursor(invocation.page_cursor.as_deref())?; + for index in 1..collection.histories.len() { + if collection.histories[index - 1].idempotency_key + >= collection.histories[index].idempotency_key + { + return Err(ApiError::InvalidWirePayload); + } + } + if let Some(cursor) = cursor { + for row in &collection.histories { + if row.idempotency_key <= cursor { + return Err(ApiError::InvalidWirePayload); + } + } + } + if let Some(next_cursor) = &collection.next_cursor { + match collection.histories.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 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; collection GET refuses a body. +/// +/// # Errors +/// +/// Returns [`ApiError::InvalidWirePayload`] when stdin cannot be read and +/// [`ApiError::LimitExceeded`] when leftover stdin exceeds the project-history +/// wire limit. +pub fn read_project_history_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)] +#[allow(clippy::too_many_lines)] +mod tests { + use std::fmt::Write as _; + + use super::{ + ProjectHistoryCollectionCliInvocation, ProjectHistoryCollectionCliVerb, + SCIENTIFIC_ACCEPTANCE_SCHEMA, compose_project_history_collection_cli_http, + dispatch_project_history_collection_cli, execute_project_history_collection_cli, + loopback_http1_from_project_history_collection_exchange, parse_http_response, + parse_project_history_collection_page_cursor, read_project_history_collection_cli_stdin, + render_project_history_collection_cli_stdout, static_reason, valid_http_field_name, + }; + use crate::{ + AnalysisRunLiveService, ApiError, DEFAULT_PROJECT_HISTORY_BYTE_LIMIT, + LINEAGEWEAVE_CONSUMER_CODE, NARUON_CONSUMER_CODE, NARUON_LIVE_HEADER_BYTE_LIMIT, + NARUON_LIVE_HEADER_COUNT_LIMIT, NaruonHttpExchange, NaruonLiveResponse, + PROJECT_HISTORY_COLLECTION_CURSOR_MAX_LEN, PROJECT_HISTORY_COLLECTION_MAX_LIMIT, + PROJECT_HISTORY_CONTRACT_VERSION, PROJECT_HISTORY_PATH, ProjectHistoryCollection, + ProjectHistoryEvent, ProjectHistoryRequest, + lineageweave_project_history_collection_exchange, + }; + + const ORIGIN: &str = "https://tepp.example.test"; + + fn sample_request(idempotency_key: &str, project_key: &str) -> ProjectHistoryRequest { + ProjectHistoryRequest { + contract_version: PROJECT_HISTORY_CONTRACT_VERSION, + idempotency_key: idempotency_key.into(), + tenant_workspace_id: "history-cli-tenant".into(), + project_key: project_key.into(), + project_name: "Project".into(), + knowledge_cutoff: "2026-08-19T23:59:59Z".into(), + focus_event_id: "focus".into(), + events: vec![ProjectHistoryEvent { + event_id: "focus".into(), + event_type_code: "voc_received".into(), + event_title: "VOC".into(), + occurred_at: "2026-08-19T09:00:00Z".into(), + available_at: "2026-08-19T10:00:00Z".into(), + source_post_id: "post".into(), + evidence_text: "explicit evidence".into(), + actor_ids: Vec::new(), + }], + } + } + + fn project_history_post(request: &ProjectHistoryRequest) -> String { + let body = request.to_json().expect("history json"); + format!( + "POST {PROJECT_HISTORY_PATH} HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: {LINEAGEWEAVE_CONSUMER_CODE}\r\ntepp-contract-version: 1\r\nidempotency-key: {}\r\ncontent-length: {}\r\n\r\n{body}", + request.idempotency_key, + body.len() + ) + } + + fn list_args() -> [&'static str; 7] { + [ + "list", + "--host", + "127.0.0.1:18081", + "--origin", + ORIGIN, + "--consumer", + LINEAGEWEAVE_CONSUMER_CODE, + ] + } + + fn list_invocation() -> ProjectHistoryCollectionCliInvocation { + ProjectHistoryCollectionCliInvocation::from_args(list_args(), "").expect("list") + } + + #[test] + fn verbs_parse_and_reject_unknown_tokens() { + assert_eq!( + ProjectHistoryCollectionCliVerb::parse("list").expect("list"), + ProjectHistoryCollectionCliVerb::List + ); + assert_eq!(ProjectHistoryCollectionCliVerb::List.as_str(), "list"); + assert_eq!( + ProjectHistoryCollectionCliVerb::parse("LIST"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + ProjectHistoryCollectionCliVerb::parse("query"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + ProjectHistoryCollectionCliVerb::parse("get"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + ProjectHistoryCollectionCliVerb::parse("create"), + Err(ApiError::InvalidWirePayload) + ); + } + + #[test] + fn from_args_refuses_empty_unknown_host_naruon_and_credential_flags() { + assert_eq!( + ProjectHistoryCollectionCliInvocation::from_args(Vec::::new(), "").unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + ProjectHistoryCollectionCliInvocation::from_args(["nope"], "").unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + ProjectHistoryCollectionCliInvocation::from_args(["list"], "").unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + ProjectHistoryCollectionCliInvocation::from_args( + ["list", "--host", "127.0.0.1:18081"], + "" + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + ProjectHistoryCollectionCliInvocation::from_args( + ["list", "--host", "8.8.8.8:80", "--origin", ORIGIN], + "" + ) + .unwrap_err(), + ApiError::AuthorizationDenied + ); + assert_eq!( + ProjectHistoryCollectionCliInvocation::from_args( + ["list", "--host", "0.0.0.0:80", "--origin", ORIGIN], + "" + ) + .unwrap_err(), + ApiError::AuthorizationDenied + ); + assert_eq!( + ProjectHistoryCollectionCliInvocation::from_args( + ["list", "--host", "localhost:18081", "--origin", ORIGIN], + "" + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + ProjectHistoryCollectionCliInvocation::from_args( + [ + "list", + "--host", + "127.0.0.1:18081", + "--origin", + "http://tepp.example.test" + ], + "" + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + ProjectHistoryCollectionCliInvocation::from_args( + [ + "list", + "--host", + "127.0.0.1:18081", + "--origin", + ORIGIN, + "--consumer", + NARUON_CONSUMER_CODE + ], + "" + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + ProjectHistoryCollectionCliInvocation::from_args( + [ + "list", + "--host", + "127.0.0.1:18081", + "--origin", + ORIGIN, + "--authorization", + "secret" + ], + "" + ) + .unwrap_err(), + ApiError::AuthorizationDenied + ); + assert_eq!( + ProjectHistoryCollectionCliInvocation::from_args( + [ + "list", + "--host", + "127.0.0.1:18081", + "--origin", + ORIGIN, + "--pretty" + ], + "" + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + ProjectHistoryCollectionCliInvocation::from_args( + [ + "list", + "--host", + "127.0.0.1:18081", + "--origin", + ORIGIN, + "--run-id", + "tepp-run-1" + ], + "" + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + ProjectHistoryCollectionCliInvocation::from_args(list_args(), "{}").unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + ProjectHistoryCollectionCliInvocation::from_args(list_args(), r#"{"rmse":1.0}"#) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + ProjectHistoryCollectionCliInvocation::from_args( + [ + "list", + "--host", + "127.0.0.1:18081", + "--origin", + ORIGIN, + "--page-limit", + "0" + ], + "" + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + ProjectHistoryCollectionCliInvocation::from_args( + [ + "list", + "--host", + "127.0.0.1:18081", + "--origin", + ORIGIN, + "--page-limit", + &(PROJECT_HISTORY_COLLECTION_MAX_LIMIT + 1).to_string() + ], + "" + ) + .unwrap_err(), + ApiError::LimitExceeded + ); + assert_eq!( + ProjectHistoryCollectionCliInvocation::from_args( + [ + "list", + "--host", + "127.0.0.1:18081", + "--origin", + ORIGIN, + "--page-cursor", + "" + ], + "" + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + } + + #[test] + fn list_assembles_default_consumer_and_optional_page_headers() { + let list = ProjectHistoryCollectionCliInvocation::from_args( + ["list", "--host", "127.0.0.1:18081", "--origin", ORIGIN], + "", + ) + .expect("default consumer"); + assert_eq!(list.verb, ProjectHistoryCollectionCliVerb::List); + assert_eq!(list.consumer, LINEAGEWEAVE_CONSUMER_CODE); + assert!(list.page_cursor.is_none()); + assert!(list.page_limit.is_none()); + let http = compose_project_history_collection_cli_http(&list).expect("http"); + assert!(http.starts_with("GET /v1/project-histories HTTP/1.1")); + assert!(http.contains("tepp-consumer: lineageweave")); + assert!(!http.contains("idempotency-key")); + assert!(!http.contains("tepp-page-cursor")); + assert!(!http.contains("tepp-page-limit")); + assert!(!http.contains("authorization")); + assert!(http.contains("content-length: 0")); + + let paged = ProjectHistoryCollectionCliInvocation::from_args( + [ + "list", + "--host", + "127.0.0.1:18081", + "--origin", + ORIGIN, + "--page-cursor", + "idem-1", + "--page-limit", + "8", + ], + "", + ) + .expect("paged"); + let paged_http = compose_project_history_collection_cli_http(&paged).expect("paged http"); + assert!(paged_http.contains("tepp-page-cursor: idem-1")); + assert!(paged_http.contains("tepp-page-limit: 8")); + assert!(paged_http.contains("tepp-consumer: lineageweave")); + } + + #[test] + fn loopback_http1_refuses_post_naruon_and_nonempty_bodies() { + let exchange = + lineageweave_project_history_collection_exchange(ORIGIN, None, None).expect("exchange"); + let http = + loopback_http1_from_project_history_collection_exchange(&exchange, "127.0.0.1:18081") + .expect("http"); + assert!(http.starts_with("GET /v1/project-histories HTTP/1.1")); + + let mut posted = exchange.clone(); + posted.method = "POST"; + assert_eq!( + loopback_http1_from_project_history_collection_exchange(&posted, "127.0.0.1:18081") + .unwrap_err(), + ApiError::InvalidWirePayload + ); + + let mut nonempty = exchange.clone(); + nonempty.body = "{}".into(); + assert_eq!( + loopback_http1_from_project_history_collection_exchange(&nonempty, "127.0.0.1:18081") + .unwrap_err(), + ApiError::InvalidWirePayload + ); + + let mut naruon = exchange.clone(); + naruon.headers = vec![ + ("content-type".into(), "application/json".into()), + ("tepp-consumer".into(), NARUON_CONSUMER_CODE.into()), + ("tepp-contract-version".into(), "1".into()), + ]; + assert_eq!( + loopback_http1_from_project_history_collection_exchange(&naruon, "127.0.0.1:18081") + .unwrap_err(), + ApiError::InvalidWirePayload + ); + + let mut credential = exchange; + credential + .headers + .push(("authorization".into(), "Bearer secret".into())); + assert_eq!( + loopback_http1_from_project_history_collection_exchange(&credential, "127.0.0.1:18081") + .unwrap_err(), + ApiError::AuthorizationDenied + ); + + assert_eq!( + loopback_http1_from_project_history_collection_exchange( + &NaruonHttpExchange { + method: "GET", + target_url: "https://tepp.example.test/v1/analysis-runs".into(), + headers: vec![ + ("content-type".into(), "application/json".into()), + ("tepp-consumer".into(), LINEAGEWEAVE_CONSUMER_CODE.into()), + ("tepp-contract-version".into(), "1".into()), + ], + body: String::new(), + }, + "127.0.0.1:18081" + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + } + + #[test] + fn dispatch_lists_accepted_projections_without_scientific_acceptance() { + let mut service = AnalysisRunLiveService::new(); + let first = sample_request("idem-a", "project-a"); + let second = sample_request("idem-b", "project-b"); + assert_eq!( + service + .handle_http_request(&project_history_post(&first)) + .status_code, + 200 + ); + assert_eq!( + service + .handle_http_request(&project_history_post(&second)) + .status_code, + 200 + ); + + let listed = dispatch_project_history_collection_cli(&mut service, &list_invocation()) + .expect("list"); + assert_eq!(listed.status_code, 200); + let stdout = render_project_history_collection_cli_stdout(&list_invocation(), &listed) + .expect("stdout"); + assert!(!stdout.contains(SCIENTIFIC_ACCEPTANCE_SCHEMA)); + assert!(!stdout.contains("rmse")); + assert!(!stdout.contains("evidence_text")); + assert!(!stdout.contains("findings")); + assert!(!stdout.contains("causal_score")); + let page = ProjectHistoryCollection::from_json(&stdout).expect("page"); + assert_eq!(page.histories.len(), 2); + assert_eq!(page.histories[0].idempotency_key, "idem-a"); + assert_eq!(page.histories[0].project_key, "project-a"); + assert_eq!( + page.histories[0].inference_status, + crate::PROJECT_HISTORY_COLLECTION_INFERENCE_STATUS + ); + assert_eq!(page.histories[1].project_key, "project-b"); + + let mut reversed = page.clone(); + reversed.histories.reverse(); + assert_eq!( + render_project_history_collection_cli_stdout( + &list_invocation(), + &NaruonLiveResponse { + status_code: 200, + reason_phrase: "OK", + body: reversed.to_json().expect("reversed payload"), + }, + ), + Err(ApiError::InvalidWirePayload) + ); + let mut duplicate = page.clone(); + duplicate.histories[1].idempotency_key = duplicate.histories[0].idempotency_key.clone(); + assert_eq!( + render_project_history_collection_cli_stdout( + &list_invocation(), + &NaruonLiveResponse { + status_code: 200, + reason_phrase: "OK", + body: duplicate.to_json().expect("duplicate payload"), + }, + ), + Err(ApiError::InvalidWirePayload) + ); + + let mut wrong_cursor = page.clone(); + wrong_cursor.next_cursor = Some("not-the-last-row".into()); + assert_eq!( + render_project_history_collection_cli_stdout( + &list_invocation(), + &NaruonLiveResponse { + status_code: 200, + reason_phrase: "OK", + body: wrong_cursor.to_json().expect("cursor payload"), + }, + ), + Err(ApiError::InvalidWirePayload) + ); + let empty_with_cursor = ProjectHistoryCollection::new(Vec::new(), Some("next".into())) + .expect("syntactically valid empty cursor page"); + assert_eq!( + render_project_history_collection_cli_stdout( + &list_invocation(), + &NaruonLiveResponse { + status_code: 200, + reason_phrase: "OK", + body: empty_with_cursor.to_json().expect("empty cursor payload"), + }, + ), + Err(ApiError::InvalidWirePayload) + ); + + let paged = ProjectHistoryCollectionCliInvocation::from_args( + [ + "list", + "--host", + "127.0.0.1:18081", + "--origin", + ORIGIN, + "--page-limit", + "1", + ], + "", + ) + .expect("limit 1"); + assert_eq!( + render_project_history_collection_cli_stdout(&paged, &listed), + Err(ApiError::InvalidWirePayload) + ); + let first_page = + dispatch_project_history_collection_cli(&mut service, &paged).expect("page 1"); + let first_json = render_project_history_collection_cli_stdout(&paged, &first_page) + .expect("page 1 stdout"); + let first_collection = ProjectHistoryCollection::from_json(&first_json).expect("first"); + assert_eq!(first_collection.histories.len(), 1); + let cursor = first_collection.next_cursor.expect("cursor"); + let second_page_invocation = ProjectHistoryCollectionCliInvocation::from_args( + [ + "list", + "--host", + "127.0.0.1:18081", + "--origin", + ORIGIN, + "--page-cursor", + cursor.as_str(), + "--page-limit", + "1", + ], + "", + ) + .expect("page 2"); + let beyond_cursor = ProjectHistoryCollectionCliInvocation { + page_cursor: Some("idem-z".into()), + page_limit: None, + ..second_page_invocation.clone() + }; + assert_eq!( + render_project_history_collection_cli_stdout(&beyond_cursor, &listed), + Err(ApiError::InvalidWirePayload) + ); + let second_page = + dispatch_project_history_collection_cli(&mut service, &second_page_invocation) + .expect("page 2"); + let second_json = + render_project_history_collection_cli_stdout(&second_page_invocation, &second_page) + .expect("page 2 stdout"); + let second_collection = ProjectHistoryCollection::from_json(&second_json).expect("second"); + assert_eq!(second_collection.histories.len(), 1); + assert_ne!( + first_collection.histories[0].idempotency_key, + second_collection.histories[0].idempotency_key + ); + + let mut maximum_key_request = sample_request(&"x".repeat(256), "project-max-key"); + maximum_key_request.project_name = "Maximum key".into(); + let mut maximum_key_service = AnalysisRunLiveService::new(); + assert_eq!( + maximum_key_service + .handle_http_request(&project_history_post(&maximum_key_request)) + .status_code, + 200 + ); + let maximum_key_page = + dispatch_project_history_collection_cli(&mut maximum_key_service, &list_invocation()) + .expect("maximum key page"); + let maximum_key_stdout = + render_project_history_collection_cli_stdout(&list_invocation(), &maximum_key_page) + .expect("maximum key stdout"); + assert!(maximum_key_stdout.contains(&maximum_key_request.idempotency_key)); + } + + #[test] + fn render_refuses_metrics_scientific_acceptance_and_empty_bodies() { + let list = list_invocation(); + assert_eq!( + render_project_history_collection_cli_stdout( + &list, + &NaruonLiveResponse { + status_code: 200, + reason_phrase: "OK", + body: String::new(), + } + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + render_project_history_collection_cli_stdout( + &list, + &NaruonLiveResponse { + status_code: 200, + reason_phrase: "OK", + body: r#"{"contract_version":1,"histories":[],"rmse":1.0}"#.into(), + } + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + render_project_history_collection_cli_stdout( + &list, + &NaruonLiveResponse { + status_code: 200, + reason_phrase: "OK", + body: format!( + r#"{{"contract_version":1,"histories":[],"schema_version":"{SCIENTIFIC_ACCEPTANCE_SCHEMA}"}}"# + ), + } + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + render_project_history_collection_cli_stdout( + &list, + &NaruonLiveResponse { + status_code: 400, + reason_phrase: "Bad Request", + body: r#"{"error_code":"invalid_wire_payload"}"#.into(), + }, + ), + Err(ApiError::InvalidWirePayload) + ); + let empty_ok = render_project_history_collection_cli_stdout( + &list, + &NaruonLiveResponse { + status_code: 200, + reason_phrase: "OK", + body: r#"{"contract_version":1,"histories":[]}"#.into(), + }, + ) + .expect("empty"); + assert!(empty_ok.contains("\"histories\":[]")); + } + + #[test] + fn execute_over_tcp_and_parse_response_failures() { + let mut service = AnalysisRunLiveService::bind_loopback().expect("bind"); + let addr = service.local_addr().expect("addr"); + let handle = std::thread::spawn(move || { + drop(service.serve_one()); + }); + let mut invocation = list_invocation(); + invocation.host = addr.to_string(); + let response = execute_project_history_collection_cli(&invocation).expect("tcp"); + assert_eq!(response.status_code, 200); + let stdout = + render_project_history_collection_cli_stdout(&invocation, &response).expect("stdout"); + let page = ProjectHistoryCollection::from_json(&stdout).expect("empty page"); + assert!(page.histories.is_empty()); + handle.join().expect("join"); + + invocation.host = "127.0.0.1:1".into(); + assert_eq!( + execute_project_history_collection_cli(&invocation).unwrap_err(), + ApiError::InvalidWirePayload + ); + + let parsed = + parse_http_response(b"HTTP/1.1 200 OK\r\ncontent-length: 2\r\n\r\n{}").expect("parse"); + assert_eq!(parsed.status_code, 200); + assert_eq!( + parse_http_response(b"not-http").unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + parse_http_response(b"HTTP/1.0 200 OK\r\ncontent-length: 2\r\n\r\n{}").unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!(static_reason(200).expect("200"), "OK"); + assert_eq!(static_reason(400).expect("400"), "Bad Request"); + assert_eq!( + static_reason(500).unwrap_err(), + ApiError::InvalidWirePayload + ); + } + + #[test] + fn stdin_reader_skips_terminal_and_reads_otherwise() { + let empty = read_project_history_collection_cli_stdin(true, std::io::empty()).expect("tty"); + assert!(empty.is_empty()); + let piped = read_project_history_collection_cli_stdin(false, std::io::Cursor::new(b"")) + .expect("empty pipe"); + assert!(piped.is_empty()); + let leftover = + read_project_history_collection_cli_stdin(false, std::io::Cursor::new(b"leftover")) + .expect("piped"); + assert_eq!(leftover, "leftover"); + } + + #[test] + fn remaining_wire_and_argument_failures_are_observable() { + for args in [ + vec!["list", "host"], + vec!["list", "--host"], + vec!["list", "--host", "127.0.0.1:1", "--host", "127.0.0.1:2"], + ] { + assert!(ProjectHistoryCollectionCliInvocation::from_args(args, "").is_err()); + } + let invalid_origin = ProjectHistoryCollectionCliInvocation { + origin: "https://".into(), + ..list_invocation() + }; + assert_eq!( + compose_project_history_collection_cli_http(&invalid_origin), + Err(ApiError::InvalidWirePayload) + ); + + let base_headers = || { + vec![ + ("content-type".into(), "application/json".into()), + ("tepp-consumer".into(), LINEAGEWEAVE_CONSUMER_CODE.into()), + ("tepp-contract-version".into(), "1".into()), + ] + }; + for headers in [ + vec![("bad name".into(), "value".into())], + vec![("x-test".into(), "bad\nvalue".into())], + vec![("x-unknown".into(), "value".into())], + vec![ + ("content-type".into(), "application/json".into()), + ("Content-Type".into(), "application/json".into()), + ], + vec![ + ("tepp-consumer".into(), LINEAGEWEAVE_CONSUMER_CODE.into()), + ("tepp-contract-version".into(), "1".into()), + ], + vec![ + ("content-type".into(), "application/json".into()), + ("tepp-contract-version".into(), "1".into()), + ], + vec![ + ("content-type".into(), "application/json".into()), + ("tepp-consumer".into(), LINEAGEWEAVE_CONSUMER_CODE.into()), + ], + ] { + assert!( + loopback_http1_from_project_history_collection_exchange( + &NaruonHttpExchange { + method: "GET", + target_url: format!("{ORIGIN}{PROJECT_HISTORY_PATH}"), + headers, + body: String::new(), + }, + "127.0.0.1:18081", + ) + .is_err() + ); + } + assert!( + loopback_http1_from_project_history_collection_exchange( + &NaruonHttpExchange { + method: "GET", + target_url: format!("{ORIGIN}{PROJECT_HISTORY_PATH}"), + headers: base_headers(), + body: String::new(), + }, + "127.0.0.1:18081", + ) + .is_ok() + ); + + let oversized_header = format!( + "HTTP/1.1 200 OK\r\nx-pad: {}\r\ncontent-length: 2\r\n\r\n{{}}", + "x".repeat(NARUON_LIVE_HEADER_BYTE_LIMIT) + ); + let many_headers = + (0..NARUON_LIVE_HEADER_COUNT_LIMIT).fold(String::new(), |mut headers, index| { + write!(headers, "x-{index}: y\r\n").expect("write string"); + headers + }); + let too_many_headers = + format!("HTTP/1.1 200 OK\r\n{many_headers}content-length: 2\r\n\r\n{{}}"); + let oversized_body = DEFAULT_PROJECT_HISTORY_BYTE_LIMIT + 1; + for response in [ + oversized_header, + "HTTP/1.1 200 Wrong\r\ncontent-length: 2\r\n\r\n{}".into(), + too_many_headers, + "HTTP/1.1 200 OK\r\nbad name: x\r\ncontent-length: 2\r\n\r\n{}".into(), + "HTTP/1.1 200 OK\r\nx: bad\nvalue\r\ncontent-length: 2\r\n\r\n{}".into(), + "HTTP/1.1 200 OK\r\nx: y\r\nX: z\r\ncontent-length: 2\r\n\r\n{}".into(), + "HTTP/1.1 200 OK\r\ntransfer-encoding: chunked\r\ncontent-length: 2\r\n\r\n{}".into(), + format!("HTTP/1.1 200 OK\r\ncontent-length: {oversized_body}\r\n\r\n"), + "HTTP/1.1 200 OK\r\ncontent-length: 3\r\n\r\n{}".into(), + ] { + assert!(parse_http_response(response.as_bytes()).is_err()); + } + for (code, reason) in [ + (202, "Accepted"), + (403, "Forbidden"), + (413, "Payload Too Large"), + (422, "Unprocessable Entity"), + ] { + assert_eq!(static_reason(code).expect("known status"), reason); + } + + assert_eq!( + read_project_history_collection_cli_stdin( + false, + std::io::Cursor::new(vec![b'x'; DEFAULT_PROJECT_HISTORY_BYTE_LIMIT + 1]), + ), + Err(ApiError::LimitExceeded) + ); + assert_eq!( + read_project_history_collection_cli_stdin(false, std::io::Cursor::new([0xff])), + Err(ApiError::InvalidWirePayload) + ); + assert!(valid_http_field_name("x!#$%&'*+-.^_`|~")); + assert!(!valid_http_field_name("")); + assert!(!valid_http_field_name("bad name")); + let oversized_cursor = "x".repeat(PROJECT_HISTORY_COLLECTION_CURSOR_MAX_LEN + 1); + assert_eq!( + ProjectHistoryCollection::new(Vec::new(), Some(oversized_cursor.clone())), + Err(ApiError::LimitExceeded) + ); + assert_eq!( + parse_project_history_collection_page_cursor(Some(&oversized_cursor)), + Err(ApiError::LimitExceeded) + ); + let mut service = AnalysisRunLiveService::new(); + assert_eq!( + service + .handle_http_request( + "PUT /v1/project-histories HTTP/1.1\r\nhost: 127.0.0.1:18081\r\ncontent-length: 0\r\n\r\n" + ) + .status_code, + 400 + ); + } +} diff --git a/crates/tepp_api/src/project_history_collection_http.rs b/crates/tepp_api/src/project_history_collection_http.rs new file mode 100644 index 000000000..326a10de6 --- /dev/null +++ b/crates/tepp_api/src/project_history_collection_http.rs @@ -0,0 +1,561 @@ +//! Provider-owned project-history collection GET contracts. +//! +//! GAP-003A unique slice: `GET /v1/project-histories` enumerates accepted +//! cutoff-safe project-history projections on `AnalysisRunLiveService` / +//! `tepp-loopback` so operators do not guess idempotency keys. Collection +//! bodies stay metric-free and identity-opaque. `tepp.scientific_acceptance.v1` +//! never appears. The page does not include evidence text, findings, or a +//! causal score. This module does not duplicate project-history CLI (#420), +//! temporal-context CLI (#414), export CLI (#410), export-retrieval GET (#411), +//! analysis-run collection GET (#368), GET-by-id (#359), or GAP-010 +//! Figma/export. Persistence remains GAP-003B. + +use crate::naruon_http::{NaruonHttpExchange, compose_https_target}; +use crate::wire::{ + from_json, require_byte_limit, require_contract_version, require_nonempty, to_json, +}; +use crate::{ + ApiError, DEFAULT_PROJECT_HISTORY_BYTE_LIMIT, PROJECT_HISTORY_IDEMPOTENCY_KEY_MAX_LEN, + PROJECT_HISTORY_PATH, +}; +use serde::{Deserialize, Serialize}; + +/// Supported project-history collection contract version. +pub const PROJECT_HISTORY_COLLECTION_CONTRACT_VERSION: u16 = 1; + +/// Default page size for loopback project-history collection GET. +pub const PROJECT_HISTORY_COLLECTION_DEFAULT_LIMIT: usize = 32; + +/// Maximum page size accepted on loopback project-history collection GET. +pub const PROJECT_HISTORY_COLLECTION_MAX_LIMIT: usize = 64; + +/// Maximum opaque cursor / idempotency-key length on the collection path. +pub const PROJECT_HISTORY_COLLECTION_CURSOR_MAX_LEN: usize = + PROJECT_HISTORY_IDEMPOTENCY_KEY_MAX_LEN; + +/// Fixed non-causal claim boundary echoed on every collection row. +pub const PROJECT_HISTORY_COLLECTION_INFERENCE_STATUS: &str = "temporal_association_only"; + +const FORBIDDEN_COLLECTION_KEYS: [&str; 16] = [ + "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", + "report", + "terminal_result", + "evidence_text", + "findings", + "causal_score", +]; + +/// One metric-free collection row for an accepted project-history projection. +/// +/// The row names the durable project key and idempotency identity. It never +/// carries evidence text, findings, or scientific-acceptance artifacts. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ProjectHistoryCollectionItem { + /// Consumer-owned stable project key. + pub project_key: String, + /// Exact request idempotency key that minted the stored projection. + pub idempotency_key: String, + /// Knowledge cutoff applied to the stored projection. + pub knowledge_cutoff: String, + /// Fixed claim boundary: sequence is association, not causation. + pub inference_status: String, +} + +impl ProjectHistoryCollectionItem { + /// Construct a validated metric-free collection row. + /// + /// # Errors + /// + /// Returns a fail-closed error for empty identities, an oversized + /// idempotency key, or a causal inference status. + pub fn new( + project_key: impl Into, + idempotency_key: impl Into, + knowledge_cutoff: impl Into, + inference_status: impl Into, + ) -> Result { + let item = Self { + project_key: project_key.into(), + idempotency_key: idempotency_key.into(), + knowledge_cutoff: knowledge_cutoff.into(), + inference_status: inference_status.into(), + }; + item.validate()?; + Ok(item) + } + + fn validate(&self) -> Result<(), ApiError> { + require_nonempty(&self.project_key)?; + require_nonempty(&self.idempotency_key)?; + require_nonempty(&self.knowledge_cutoff)?; + if self.idempotency_key.len() > PROJECT_HISTORY_COLLECTION_CURSOR_MAX_LEN { + return Err(ApiError::LimitExceeded); + } + if self.inference_status != PROJECT_HISTORY_COLLECTION_INFERENCE_STATUS { + return Err(ApiError::InvalidWirePayload); + } + Ok(()) + } +} + +/// Versioned metric-free project-history collection page. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ProjectHistoryCollection { + /// Semantic contract version for this payload family. + pub contract_version: u16, + /// Bounded page of metric-free rows, sorted by `idempotency_key`. + pub histories: Vec, + /// Exclusive cursor for the next page when more rows remain. + #[serde(skip_serializing_if = "Option::is_none")] + pub next_cursor: Option, +} + +impl ProjectHistoryCollection { + /// Construct a validated collection page. + /// + /// # Errors + /// + /// Returns a fail-closed error when a row is invalid, the page exceeds the + /// maximum limit, or `next_cursor` is empty or oversized. + pub fn new( + histories: Vec, + next_cursor: Option, + ) -> Result { + let collection = Self { + contract_version: PROJECT_HISTORY_COLLECTION_CONTRACT_VERSION, + histories, + next_cursor, + }; + collection.validate()?; + Ok(collection) + } + + /// Parse and validate a collection payload with the default byte limit. + /// + /// # Errors + /// + /// Returns wire, version, limit, metric-key, or field-validation errors. + pub fn from_json(payload: &str) -> Result { + Self::from_json_with_limit(payload, DEFAULT_PROJECT_HISTORY_BYTE_LIMIT) + } + + /// Parse and validate a collection payload with a caller-supplied limit. + /// + /// # Errors + /// + /// Returns wire, version, limit, metric-key, or field-validation errors. + pub fn from_json_with_limit(payload: &str, maximum_bytes: usize) -> Result { + require_byte_limit(payload, maximum_bytes)?; + refuse_metrics_on_project_history_collection_payload(payload)?; + let collection: Self = from_json(payload)?; + collection.validate()?; + Ok(collection) + } + + /// Serialize this collection after complete validation. + /// + /// # Errors + /// + /// Returns validation or serialization errors. + pub fn to_json(&self) -> Result { + self.validate()?; + let payload = to_json(self)?; + require_byte_limit(&payload, DEFAULT_PROJECT_HISTORY_BYTE_LIMIT)?; + refuse_metrics_on_project_history_collection_payload(&payload)?; + Ok(payload) + } + + fn validate(&self) -> Result<(), ApiError> { + require_contract_version( + self.contract_version, + PROJECT_HISTORY_COLLECTION_CONTRACT_VERSION, + )?; + if self.histories.len() > PROJECT_HISTORY_COLLECTION_MAX_LIMIT { + return Err(ApiError::LimitExceeded); + } + for item in &self.histories { + item.validate()?; + } + if let Some(cursor) = &self.next_cursor { + require_nonempty(cursor)?; + if cursor.len() > PROJECT_HISTORY_COLLECTION_CURSOR_MAX_LEN { + return Err(ApiError::LimitExceeded); + } + } + Ok(()) + } +} + +/// Refuse collection JSON that already carries scientific-metric or evidence keys. +/// +/// Empty payloads fail closed as valid request bodies. Non-object JSON fails +/// closed as invalid wire when nonempty. +/// +/// # Errors +/// +/// Returns [`ApiError::InvalidWirePayload`] when a forbidden metric, evidence, +/// or causal-score key is present. +pub fn refuse_metrics_on_project_history_collection_payload(payload: &str) -> Result<(), ApiError> { + if payload.trim().is_empty() { + return Ok(()); + } + if payload.contains("tepp.scientific_acceptance.v1") { + return Err(ApiError::InvalidWirePayload); + } + let value: serde_json::Value = + serde_json::from_str(payload).map_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 FORBIDDEN_COLLECTION_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(()), + } +} + +/// Parse the optional `tepp-page-limit` header. +/// +/// Absent header uses [`PROJECT_HISTORY_COLLECTION_DEFAULT_LIMIT`]. Zero, a +/// non-integer, or a value above [`PROJECT_HISTORY_COLLECTION_MAX_LIMIT`] fail +/// closed. +/// +/// # Errors +/// +/// Returns [`ApiError::InvalidWirePayload`] for a non-integer and +/// [`ApiError::LimitExceeded`] when the requested page is larger than the +/// maximum. +pub fn parse_project_history_collection_page_limit(raw: Option<&str>) -> Result { + let Some(raw) = raw else { + return Ok(PROJECT_HISTORY_COLLECTION_DEFAULT_LIMIT); + }; + let limit: usize = raw.parse().map_err(|_| ApiError::InvalidWirePayload)?; + if limit == 0 { + return Err(ApiError::InvalidWirePayload); + } + if limit > PROJECT_HISTORY_COLLECTION_MAX_LIMIT { + return Err(ApiError::LimitExceeded); + } + Ok(limit) +} + +/// Parse the optional exclusive `tepp-page-cursor` header. +/// +/// # Errors +/// +/// Returns [`ApiError::InvalidWirePayload`] for an empty cursor and +/// [`ApiError::LimitExceeded`] when the cursor exceeds +/// [`PROJECT_HISTORY_COLLECTION_CURSOR_MAX_LEN`]. +pub fn parse_project_history_collection_page_cursor( + raw: Option<&str>, +) -> Result, ApiError> { + let Some(raw) = raw else { + return Ok(None); + }; + require_nonempty(raw)?; + if raw.len() > PROJECT_HISTORY_COLLECTION_CURSOR_MAX_LEN { + return Err(ApiError::LimitExceeded); + } + Ok(Some(raw.to_owned())) +} + +/// Return whether `path` is exactly the project-history collection resource. +#[must_use] +pub fn is_project_history_collection_path(path: &str) -> bool { + path == PROJECT_HISTORY_PATH +} + +/// Page stored rows after an exclusive cursor, sorted by idempotency key. +#[must_use] +pub fn page_project_history_collection_items( + mut items: Vec, + cursor: Option<&str>, + limit: usize, +) -> (Vec, Option) { + items.sort_by(|left, right| left.idempotency_key.cmp(&right.idempotency_key)); + if let Some(cursor) = cursor { + items.retain(|item| item.idempotency_key.as_str() > cursor); + } + let next_cursor = if items.len() > limit { + Some(items[limit - 1].idempotency_key.clone()) + } else { + None + }; + items.truncate(limit); + (items, next_cursor) +} + +/// Build a provider-owned `GET` project-history collection exchange. +/// +/// The builder refuses non-`https` origins and does not inject credentials. +/// Loopback pagination uses `tepp-page-cursor` and `tepp-page-limit` headers +/// because the shared request-line parser fails closed on query strings. +/// +/// # Errors +/// +/// Returns [`ApiError::InvalidWirePayload`] for a non-`https` origin or an +/// empty cursor, and [`ApiError::LimitExceeded`] when limit or cursor bounds +/// are exceeded. +pub fn lineageweave_project_history_collection_exchange( + origin: &str, + cursor: Option<&str>, + limit: Option<&str>, +) -> Result { + let _ = parse_project_history_collection_page_limit(limit)?; + let _ = parse_project_history_collection_page_cursor(cursor)?; + let target_url = compose_https_target(origin, PROJECT_HISTORY_PATH)?; + let mut headers = vec![ + ("content-type".into(), "application/json".into()), + ("tepp-consumer".into(), "lineageweave".into()), + ("tepp-contract-version".into(), "1".into()), + ]; + if let Some(cursor) = cursor { + headers.push(("tepp-page-cursor".into(), cursor.to_owned())); + } + if let Some(limit) = limit { + headers.push(("tepp-page-limit".into(), limit.to_owned())); + } + Ok(NaruonHttpExchange { + method: "GET", + target_url, + headers, + body: String::new(), + }) +} + +#[cfg(test)] +mod tests { + use super::{ + PROJECT_HISTORY_COLLECTION_CURSOR_MAX_LEN, PROJECT_HISTORY_COLLECTION_INFERENCE_STATUS, + PROJECT_HISTORY_COLLECTION_MAX_LIMIT, ProjectHistoryCollection, + ProjectHistoryCollectionItem, is_project_history_collection_path, + lineageweave_project_history_collection_exchange, page_project_history_collection_items, + parse_project_history_collection_page_cursor, parse_project_history_collection_page_limit, + refuse_metrics_on_project_history_collection_payload, + }; + use crate::ApiError; + + fn sample_item() -> ProjectHistoryCollectionItem { + ProjectHistoryCollectionItem::new( + "project", + "idem-1", + "2026-08-19T23:59:59Z", + PROJECT_HISTORY_COLLECTION_INFERENCE_STATUS, + ) + .expect("item") + } + + #[test] + fn collection_round_trips_and_refuses_hostile_shapes() { + let collection = ProjectHistoryCollection::new(vec![sample_item()], None).expect("page"); + let json = collection.to_json().expect("json"); + assert_eq!( + ProjectHistoryCollection::from_json(&json).expect("decode"), + collection + ); + assert!(!json.contains("rmse")); + assert!(!json.contains("scientific_acceptance")); + assert!(!json.contains("evidence_text")); + assert!(!json.contains("findings")); + assert!(!json.contains("next_cursor")); + assert!(!json.contains("causal_score")); + + assert_eq!( + ProjectHistoryCollectionItem::new( + "", + "idem-1", + "2026-08-19T23:59:59Z", + PROJECT_HISTORY_COLLECTION_INFERENCE_STATUS + ), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + ProjectHistoryCollectionItem::new( + "project", + "", + "2026-08-19T23:59:59Z", + PROJECT_HISTORY_COLLECTION_INFERENCE_STATUS + ), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + ProjectHistoryCollectionItem::new( + "project", + "idem-1", + "2026-08-19T23:59:59Z", + "causal_score" + ), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + ProjectHistoryCollectionItem::new( + "project", + "a".repeat(PROJECT_HISTORY_COLLECTION_CURSOR_MAX_LEN + 1), + "2026-08-19T23:59:59Z", + PROJECT_HISTORY_COLLECTION_INFERENCE_STATUS, + ), + Err(ApiError::LimitExceeded) + ); + + let mut unsupported = collection.clone(); + unsupported.contract_version = 9; + assert_eq!( + unsupported.to_json(), + Err(ApiError::UnsupportedContractVersion) + ); + assert_eq!( + ProjectHistoryCollection::from_json(r#"{"contract_version":9,"histories":[]}"#), + Err(ApiError::UnsupportedContractVersion) + ); + assert_eq!( + ProjectHistoryCollection::from_json( + r#"{"contract_version":1,"histories":[],"extra":true}"# + ), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + ProjectHistoryCollection::from_json_with_limit(&json, 8), + Err(ApiError::LimitExceeded) + ); + assert_eq!( + ProjectHistoryCollection::from_json("[1,2,3]"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + ProjectHistoryCollection::new(vec![sample_item()], Some(String::new())), + Err(ApiError::InvalidWirePayload) + ); + let oversized = vec![sample_item(); PROJECT_HISTORY_COLLECTION_MAX_LIMIT + 1]; + assert_eq!( + ProjectHistoryCollection::new(oversized, None), + Err(ApiError::LimitExceeded) + ); + } + + #[test] + fn collection_payloads_refuse_scientific_metric_and_evidence_keys() { + assert_eq!( + refuse_metrics_on_project_history_collection_payload(""), + Ok(()) + ); + assert_eq!( + refuse_metrics_on_project_history_collection_payload(r#"{"histories":[]}"#), + Ok(()) + ); + assert_eq!( + refuse_metrics_on_project_history_collection_payload(r#"{"rmse":1.0}"#), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + refuse_metrics_on_project_history_collection_payload( + r#"{"histories":[{"evidence_text":"secret"}]}"# + ), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + refuse_metrics_on_project_history_collection_payload(r#"{"causal_score":1}"#), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + refuse_metrics_on_project_history_collection_payload( + r#"{"schema_version":"tepp.scientific_acceptance.v1"}"# + ), + Err(ApiError::InvalidWirePayload) + ); + assert!( + ProjectHistoryCollection::from_json( + r#"{"contract_version":1,"histories":[],"scientific_acceptance":true}"# + ) + .is_err() + ); + } + + #[test] + fn pagination_and_exchange_fail_closed() { + assert_eq!( + parse_project_history_collection_page_limit(None).expect("default"), + 32 + ); + assert_eq!( + parse_project_history_collection_page_limit(Some("0")), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + parse_project_history_collection_page_limit(Some("65")), + Err(ApiError::LimitExceeded) + ); + assert_eq!( + parse_project_history_collection_page_cursor(Some("")), + Err(ApiError::InvalidWirePayload) + ); + assert!(is_project_history_collection_path("/v1/project-histories")); + assert!(!is_project_history_collection_path("/v1/analysis-runs")); + assert!(!is_project_history_collection_path("/v1/temporal-context")); + + let first = sample_item(); + let second = ProjectHistoryCollectionItem::new( + "project-b", + "idem-2", + "2026-08-19T23:59:59Z", + PROJECT_HISTORY_COLLECTION_INFERENCE_STATUS, + ) + .expect("second"); + let (page, cursor) = + page_project_history_collection_items(vec![second.clone(), first.clone()], None, 1); + assert_eq!(page, vec![first.clone()]); + assert_eq!(cursor.as_deref(), Some("idem-1")); + let (rest, done) = + page_project_history_collection_items(vec![second.clone(), first], Some("idem-1"), 32); + assert_eq!(rest, vec![second]); + assert_eq!(done, None); + + let exchange = lineageweave_project_history_collection_exchange( + "https://tepp.example.test", + Some("idem-1"), + Some("8"), + ) + .expect("exchange"); + assert_eq!(exchange.method, "GET"); + assert!(exchange.target_url.ends_with("/v1/project-histories")); + assert!( + !exchange + .headers + .iter() + .any(|(name, _)| name.eq_ignore_ascii_case("authorization")) + ); + assert!(exchange.body.is_empty()); + assert_eq!( + lineageweave_project_history_collection_exchange("http://insecure.example", None, None), + Err(ApiError::InvalidWirePayload) + ); + } +} diff --git a/crates/tepp_api/tests/project_history_collection_cli_contract.rs b/crates/tepp_api/tests/project_history_collection_cli_contract.rs new file mode 100644 index 000000000..54226a4e9 --- /dev/null +++ b/crates/tepp_api/tests/project_history_collection_cli_contract.rs @@ -0,0 +1,144 @@ +//! Contract tests for the `LineageWeave` project-history collection loopback CLI. + +use std::io::{Read as _, Write as _}; +use std::net::TcpListener; +use std::process::Command; +use std::thread; +use tepp_api::{ + ApiError, LINEAGEWEAVE_CONSUMER_CODE, NARUON_CONSUMER_CODE, PROJECT_HISTORY_PATH, + ProjectHistoryCollection, ProjectHistoryCollectionCliInvocation, + ProjectHistoryCollectionCliVerb, compose_project_history_collection_cli_http, + lineageweave_project_history_collection_exchange, + loopback_http1_from_project_history_collection_exchange, +}; + +const ORIGIN: &str = "https://tepp.example.test"; + +#[test] +fn collection_cli_list_is_metric_free_get_without_credentials() { + assert_eq!( + ProjectHistoryCollectionCliVerb::parse("list").expect("list"), + ProjectHistoryCollectionCliVerb::List + ); + let invocation = ProjectHistoryCollectionCliInvocation::from_args( + [ + "list", + "--host", + "127.0.0.1:18081", + "--origin", + ORIGIN, + "--consumer", + LINEAGEWEAVE_CONSUMER_CODE, + ], + "", + ) + .expect("invocation"); + assert_eq!(invocation.consumer, LINEAGEWEAVE_CONSUMER_CODE); + let http = compose_project_history_collection_cli_http(&invocation).expect("http"); + assert!(http.starts_with("GET /v1/project-histories HTTP/1.1")); + assert!(!http.contains("authorization")); + assert!(!http.contains("idempotency-key")); + assert!(!http.contains("copilot")); + assert!(http.contains("tepp-consumer: lineageweave")); + let exchange = + lineageweave_project_history_collection_exchange(ORIGIN, None, None).expect("exchange"); + let rendered = + loopback_http1_from_project_history_collection_exchange(&exchange, "127.0.0.1:18081") + .expect("loopback"); + assert!(rendered.contains(PROJECT_HISTORY_PATH)); + assert_eq!( + ProjectHistoryCollection::new(Vec::new(), None) + .expect("empty") + .contract_version, + 1 + ); +} + +#[test] +fn collection_cli_refuses_naruon_non_loopback_unknown_verbs_and_metric_bodies() { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind loopback"); + let host = listener.local_addr().expect("loopback address").to_string(); + let body = ProjectHistoryCollection::new(Vec::new(), None) + .expect("empty collection") + .to_json() + .expect("collection JSON"); + let expected = serde_json::from_str::(&body).expect("expected JSON"); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept CLI"); + let mut request = [0_u8; 4096]; + let read = stream.read(&mut request).expect("read request"); + assert!(request[..read].ends_with(b"\r\n\r\n")); + write!( + stream, + "HTTP/1.1 200 OK\r\ncontent-length: {}\r\n\r\n{body}", + body.len() + ) + .expect("write response"); + }); + let accepted = Command::new(env!("CARGO_BIN_EXE_tepp-project-histories")) + .args([ + "list", + "--host", + &host, + "--origin", + ORIGIN, + "--page-limit", + "1", + "--page-cursor", + "before", + ]) + .output() + .expect("run successful binary"); + server.join().expect("join loopback server"); + assert!( + accepted.status.success(), + "{}", + String::from_utf8_lossy(&accepted.stderr) + ); + assert!(accepted.stderr.is_empty()); + assert_eq!( + serde_json::from_slice::(&accepted.stdout).expect("stdout JSON"), + expected + ); + + let rejected = Command::new(env!("CARGO_BIN_EXE_tepp-project-histories")) + .arg("unknown") + .output() + .expect("run binary"); + assert!(!rejected.status.success()); + assert!(rejected.stdout.is_empty()); + assert!(!rejected.stderr.is_empty()); + assert_eq!( + ProjectHistoryCollectionCliInvocation::from_args( + ["list", "--host", "8.8.8.8:80", "--origin", ORIGIN], + "" + ), + Err(ApiError::AuthorizationDenied) + ); + assert_eq!( + ProjectHistoryCollectionCliInvocation::from_args( + [ + "list", + "--host", + "127.0.0.1:18081", + "--origin", + ORIGIN, + "--consumer", + NARUON_CONSUMER_CODE + ], + "" + ), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + ProjectHistoryCollectionCliVerb::parse("query"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + ProjectHistoryCollectionCliInvocation::from_args( + ["list", "--host", "127.0.0.1:18081", "--origin", ORIGIN], + r#"{"rmse":1.0}"# + ), + Err(ApiError::InvalidWirePayload) + ); +} diff --git a/crates/tepp_api/tests/project_history_collection_http_contract.rs b/crates/tepp_api/tests/project_history_collection_http_contract.rs new file mode 100644 index 000000000..00e0a1df4 --- /dev/null +++ b/crates/tepp_api/tests/project_history_collection_http_contract.rs @@ -0,0 +1,53 @@ +//! Contract tests for loopback `GET /v1/project-histories`. + +use tepp_api::{ + ApiError, PROJECT_HISTORY_PATH, ProjectHistoryCollection, ProjectHistoryCollectionItem, + is_project_history_collection_path, lineageweave_project_history_collection_exchange, + refuse_metrics_on_project_history_collection_payload, +}; + +#[test] +fn project_history_collection_is_metric_free_get_without_credentials() { + assert!(is_project_history_collection_path(PROJECT_HISTORY_PATH)); + let item = ProjectHistoryCollectionItem::new( + "project", + "idem-1", + "2026-08-19T23:59:59Z", + "temporal_association_only", + ) + .expect("item"); + let page = ProjectHistoryCollection::new(vec![item], None).expect("page"); + let json = page.to_json().expect("json"); + assert!(!json.contains("rmse")); + assert!(!json.contains("tepp.scientific_acceptance.v1")); + assert!(!json.contains("evidence_text")); + assert!(!json.contains("findings")); + let exchange = + lineageweave_project_history_collection_exchange("https://tepp.example.test", None, None) + .expect("exchange"); + assert_eq!(exchange.method, "GET"); + assert!(exchange.target_url.ends_with("/v1/project-histories")); + assert!( + !exchange + .headers + .iter() + .any(|(name, _)| name.eq_ignore_ascii_case("authorization")) + ); +} + +#[test] +fn project_history_collection_refuses_metrics_evidence_and_insecure_origins() { + assert_eq!( + refuse_metrics_on_project_history_collection_payload(r#"{"rmse":1.0}"#), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + refuse_metrics_on_project_history_collection_payload(r#"{"evidence_text":"x"}"#), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + lineageweave_project_history_collection_exchange("http://insecure.example", None, None), + Err(ApiError::InvalidWirePayload) + ); + assert!(!is_project_history_collection_path("/v1/analysis-runs")); +} diff --git a/docs/API_CONTRACT.md b/docs/API_CONTRACT.md index b76b688e1..d178ccacf 100644 --- a/docs/API_CONTRACT.md +++ b/docs/API_CONTRACT.md @@ -69,6 +69,7 @@ GET /v1/analysis-runs/{run_id} POST /v1/analysis-runs/{run_id}/cancel GET /v1/model-artifacts/{artifact_id} GET /v1/exports/{export_id} +GET /v1/project-histories ``` Long-running analysis is durable asynchronous work. `POST /v1/analysis-runs` accepts an idempotency key, immutable input snapshot identity, knowledge cutoff, versioned model contract/configuration, and requested output profile. A retry with the same principal/idempotency key and semantically identical request returns the same run identity; a conflicting body fails closed. @@ -91,6 +92,17 @@ 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/project-histories` enumerates accepted cutoff-safe project-history +projections on `tepp-loopback`. Collection rows stay metric-free identities +(`project_key`, `idempotency_key`, `knowledge_cutoff`, +`inference_status=temporal_association_only`). `tepp.scientific_acceptance.v1`, +evidence text, findings, and causal scores never appear. + +`tepp-project-histories list` is the operator-visible client of that collection +GET. It mints a typed `LineageWeave` exchange onto spawned `tepp-loopback` TCP. +Empty stdin is admitted. Naruon is refused. Process exit 0 is not a scientific +claim and does not infer causality. + The typed status/read contract returns `accepted`, `running`, `succeeded`, or `failed`. Accepted and running statuses contain no measurement result. A terminal status contains exactly one request-bound diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 2b783c2ab..d30554139 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -53,6 +53,8 @@ The full APA 7th standards/literature register remains `docs/research/standards- | PostgreSQL bitemporal/lineage persistence | ADR 0013; Architecture/ERD | `persistence_postgres` migration contracts, in-memory adapters, live SQL session/document SQL port, tenant RLS (`0002` + session GUC/role helpers), `DATABASE_URL` SQLx gate, optional session-affine `live-sqlx` `PgPool` driver, exact-head live PostgreSQL CI with isolation proof, append-only immutability triggers (`0004`), temporal interval ordering CHECKs (`0005`), typed membership assignment (`0006` implemented-main), event-relation/mention/instance SQL (#37–#39 implemented-main), source-artifact SQL (#40 implemented-main), audit-event SQL (#41 implemented-main), concurrent document-write stress (#43 implemented-main), backup/restore integrity revalidation (#44 implemented-main), `revision_order` later-revision system-time ordering implemented-main, entity/project target SQL on PR #131; remaining physical ERD constraints | partial | | 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 project-history collection GET | ADR 0028; API contract; RFC 9110; ADR 0021/0011 | `tepp_api` `GET /v1/project-histories` on `tepp-loopback`; metric-free `temporal_association_only` identities; `tepp.scientific_acceptance.v1` never appears; does not infer causality | active-PR | +| loopback LineageWeave project-history collection CLI | ADR 0065; API contract; RFC 9110; ADR 0028/0021/0011 | `tepp-project-histories list` mints typed `LineageWeave` GET `/v1/project-histories` onto spawned `tepp-loopback` TCP; metric-free `temporal_association_only` identities; naruon refused; `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/0028-project-history-collection-get.md b/docs/adr/0028-project-history-collection-get.md new file mode 100644 index 000000000..770886a4d --- /dev/null +++ b/docs/adr/0028-project-history-collection-get.md @@ -0,0 +1,68 @@ +# ADR 0028 — LineageWeave project-history collection GET + +**Decision status:** Accepted +**Implementation maturity:** active-PR +**Date:** 2026-08-31 +**Supersedes:** None; complements ADR 0021 and ADR 0011 for the operator-visible project-history collection. Does not supersede ADR 0014 claim-promotion authority. This ADR number is unique on protected main; other live PRs may reuse 0028 on unrelated GAP-003A stacks (lifecycle POST). + +## Context + +Protected main already stores accepted project-history projections on `AnalysisRunLiveService` after `POST /v1/project-histories`, and #420 publishes a POST CLI. Operators still cannot enumerate stored projections without guessing idempotency keys. Duplicating analysis-run collection GET (#368), GET-by-id (#359), project-history CLI (#420), temporal-context CLI (#414), export CLI (#410), export-retrieval GET (#411), Leiden, Driver p.16, or GAP-010 Figma/export would collide with live PRs. + +## Decision + +`tepp_api` publishes loopback-only `GET /v1/project-histories` on `tepp-loopback`: + +- Consumer is `lineageweave` only. Empty body. Pagination uses `tepp-page-limit` and exclusive `tepp-page-cursor` headers because the request-line parser fails closed on query strings. +- Collection rows are metric-free identities: `project_key`, `idempotency_key`, `knowledge_cutoff`, `inference_status=temporal_association_only`. +- `tepp.scientific_acceptance.v1`, RMSE, bias, coverage, SE-gate, `evidence_text`, `findings`, and `causal_score` never appear. +- The collection does not infer causality, mutate TEPP state, or return a completed psychometric result. +- GET `/v1/analysis-runs` and GET `/v1/temporal-context` stay fail-closed on this slice. +- This slice does not implement project-history collection CLI, GET-by-id, or persistence. + +## Alternatives considered + +1. **Keep POST replay as the only retrieval path** — rejected because operators still guess idempotency keys. +2. **Reuse analysis-run collection GET (#368)** — rejected; that slice is a different live PR and a different resource. +3. **Return evidence text and findings on the list** — rejected because collection bodies must stay metric-free and identity-opaque. +4. **Loopback `GET /v1/project-histories`** — accepted. + +## Consequences + +- Operators can enumerate accepted project-history projections without writing a second POST. +- Collection stdout 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 GET bodies, zero/oversized page limits, empty cursors, credential flags, and metric keys fail closed. The in-memory listener is not durable. + +## Security, privacy, scientific-integrity, and governance impact + +- No credential headers cross the consumer boundary. +- Evidence text and findings stay off the collection page. +- Process 200 on collection GET is not measurement evidence and is not a causal claim. + +## Compatibility and migration + +`POST /v1/project-histories`, `POST /v1/temporal-context`, `POST /v1/analysis-runs`, and `tepp-loopback` POST paths are unchanged. Project-history collection CLI remains a later slice. + +## Verification + +Falsifiable evidence: + +- GET of two accepted projections returns a metric-free page sorted by idempotency key with `temporal_association_only` and no RMSE/bias/coverage/SE-gate/`tepp.scientific_acceptance.v1`/`evidence_text`/`findings`/`causal_score` keys; +- GET `/v1/analysis-runs`, naruon consumer, nonempty body, and metric keys fail closed; +- Clippy `-D warnings`, `tepp_api` tests, rustdoc, and exact-head review remain required. + +## Rollback and supersession + +Rollback removes collection GET; POST `/v1/project-histories` remains valid. A superseding ADR is required to persist the registry, bind a public address, emit scientific-acceptance on the list, infer causality, or treat collection success as an ADR 0014 claim. + +## Related authority + +- ADR 0021 owns the LineageWeave project-history service boundary. +- ADR 0002 owns six-clock temporal eligibility. +- ADR 0011 owns standalone/modular HTTP boundaries. +- 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/0065-project-history-collection-cli.md b/docs/adr/0065-project-history-collection-cli.md new file mode 100644 index 000000000..0ac41ce5f --- /dev/null +++ b/docs/adr/0065-project-history-collection-cli.md @@ -0,0 +1,116 @@ +# ADR 0065 — LineageWeave project-history collection loopback CLI + +**Decision status:** Accepted +**Implementation maturity:** active-PR +**Date:** 2026-08-31 +**Supersedes:** None; complements ADR 0028 for the operator-visible collection client. Does not supersede ADR 0014 claim-promotion authority. This ADR number is unique on protected main; live vs-main PRs already occupy 0026–0064. + +## Context + +ADR 0028 serves `GET /v1/project-histories` on `AnalysisRunLiveService` / +`tepp-loopback`, but operators still had to write raw HTTP/1.1 to enumerate +accepted cutoff-safe projections. Duplicating project-history POST CLI (#420), +collection GET (#424), temporal-context CLI (#414), export CLIs (#410/#417), +analysis-run collection CLI (#371), GET-by-id, Leiden, Driver p.16, or GAP-010 +Figma/export would collide with live PRs. Naruon is refused on this adapter; +`NaruonLiveService` stays POST-only. + +## Decision + +`tepp_api` publishes a loopback-only `tepp-project-histories` CLI: + +- `list` mints `lineageweave_project_history_collection_exchange` and renders + through `loopback_http1_from_project_history_collection_exchange` onto + spawned `tepp-loopback` TCP. `--origin` stays the published HTTPS origin; + only `--host` is the loopback bind address. +- Empty stdin is admitted. Consumer is `lineageweave` only. +- Optional `--page-cursor` / `--page-limit` become `tepp-page-cursor` / + `tepp-page-limit` headers because the shared request-line parser fails + closed on query strings. +- Stdout is the metric-free collection page: `project_key`, + `idempotency_key`, `knowledge_cutoff`, + `inference_status=temporal_association_only`. +- `tepp.scientific_acceptance.v1`, RMSE, bias, coverage, SE-gate, evidence + text, findings, and `causal_score` never appear. +- The CLI does not infer causality, mutate TEPP state, or return a completed + psychometric result. +- Non-loopback hosts, `localhost`, credential-shaped flags, unknown verbs, + nonempty stdin, unpublished consumers, naruon, non-`https` origins, and + hostile pagination fail closed. +- Persistence, Compose recovery, and psychometric execution remain GAP-003B. + +## Alternatives considered + +1. **Keep raw HTTP as the only collection path** — rejected because operators + still guess framing after ADR 0028. +2. **Add `list` onto the live project-history POST CLI (#420)** — rejected + because that head owns POST query against a different live PR and is not + stacked on collection GET. +3. **Open naruon on this adapter** — rejected; project-history collection GET + is LineageWeave-only (ADR 0028 / ADR 0021). +4. **Persist listed rows in PostgreSQL** — rejected as GAP-003B / live draft + #287. +5. **Loopback collection CLI with the same metric-free gates as ADR 0028** — + accepted. + +## Consequences + +- Operators can enumerate accepted project-history projections on the same + loopback listener that created them without writing HTTP. +- Collection pages cannot be mistaken for a succeeded scientific-acceptance + result or a causal score. +- CLI success is not release evidence. + +## Failure and recovery + +Non-loopback hosts return authorization denied. Unknown verbs, metric keys, +nonempty bodies, unknown cursors, zero or non-integer limits, unpublished +consumers, naruon, and credential flags fail closed. The in-memory registry +is not durable. Non-200 bodies never reach stdout; failures emit only the +stable redacted API error on stderr. Successful pages must remain strictly +ordered, respect the requested exclusive cursor and limit, and bind any next +cursor to the page's last row. + +## Security, privacy, scientific-integrity, and governance impact + +- No credential headers cross the consumer boundary. +- The CLI remains loopback-only and size-bounded. +- Process exit 0 on a collection page is not measurement evidence and is not + an ADR 0014 claim. + +## Compatibility and migration + +Collection GET, project-history POST, temporal-context, and analysis-run +paths are unchanged. The project-history POST CLI binary name +`tepp-project-history` remains owned by ADR 0061 / #420. Production adapters +may replace loopback while preserving metric-free collection rows. + +## Verification + +Falsifiable evidence: + +- CLI list JSON has no RMSE/bias/coverage/SE-gate/scientific-acceptance/ + evidence/`findings`/`causal_score` keys; +- CLI list returns accepted LineageWeave rows and refuses naruon; +- non-loopback host, credential flags, nonempty stdin, and unknown verbs fail + closed; +- Clippy `-D warnings`, `tepp_api` tests, rustdoc, and exact-head review + remain required. + +## Rollback and supersession + +Rollback removes the `tepp-project-histories` binary and client module; +collection GET remains valid. A superseding ADR is required to persist the +registry, bind a public address, emit scientific-acceptance on the list, open +naruon, or treat CLI success as an ADR 0014 claim. + +## Related authority + +- ADR 0028 owns loopback project-history collection GET. +- ADR 0061 owns the project-history POST CLI (live #420). +- ADR 0021 owns the LineageWeave project-history POST boundary. +- ADR 0018 owns consumer-scoped ingress and metric-free receipts. +- ADR 0014 owns scientific claim promotion. +- ADR 0011 owns standalone/modular HTTP boundaries. +- 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 1254c8079..d6d594f84 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -30,6 +30,8 @@ Read [`ADR_POLICY.md`](ADR_POLICY.md) first. **Decision status and implementatio | [0022](0022-deterministic-analysis-run-execution.md) | Deterministic cutoff-safe analysis-run execution | Accepted | active-PR | Closes the first executable product path from accepted run to digest-bound terminal result without claiming estimator authority. | | [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. | +| [0028](0028-project-history-collection-get.md) | Loopback `GET /v1/project-histories` enumerates accepted LineageWeave projections | Accepted | active-PR | Complements ADR 0021/0011; does not supersede ADR 0014. Unique on protected main. Does not infer causality. | +| [0065](0065-project-history-collection-cli.md) | Loopback `tepp-project-histories list` mints typed LineageWeave collection GET | Accepted | active-PR | Complements ADR 0028; unique vs protected main (0026–0064 occupied). Naruon refused. Does not infer causality. | | [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. | @@ -140,6 +142,7 @@ Use the narrowest owning ADR when decisions overlap: - **accepted-run execution and terminal artifact production:** ADR 0022. - **independent lineage criterion and posterior Project Journey:** ADR 0023. - **macOS-native Rust-owned MLX Metal execution:** ADR 0024. +- **LineageWeave project-history collection GET:** ADR 0028. ## Change and supersession rule diff --git a/docs/research/project-history-collection-cli.md b/docs/research/project-history-collection-cli.md new file mode 100644 index 000000000..db1d051ef --- /dev/null +++ b/docs/research/project-history-collection-cli.md @@ -0,0 +1,61 @@ +# Project-history collection CLI (doctoring) + +## Scope + +`tepp-project-histories list` is the operator-visible client of loopback +`GET /v1/project-histories`. HTTP method, path, and header semantics follow +current HTTP semantics (Fielding, Nottingham, & Reschke, 2022). Fail-closed +refusal of non-loopback hosts, unpublished consumers, naruon, review/Copilot/ +GitHub credential flags, and scientific-authority promotion is repository +contract authority (ADR 0065; ADR 0028; ADR 0021; ADR 0011), not an RFC +inference rule. + +CLI stdout is metric-free `ProjectHistoryCollection` JSON. Each row carries +`project_key`, `idempotency_key`, `knowledge_cutoff`, and +`inference_status=temporal_association_only` only. Process exit 0 is not a +completed temporal model, calibrated score, theta estimate, uncertainty +statement, causal inference, or scientific claim. +`tepp.scientific_acceptance.v1` never appears. + +## 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 the target resource's +current state. TEPP maps that retrieval onto a bounded, LineageWeave-owned +collection of metric-free project-history identities. The RFC does not define +psychometric acceptance, RMSE, causality, or claim promotion. + +### Internal contract evidence + +- `docs/adr/0065-project-history-collection-cli.md` — this client +- `docs/adr/0028-project-history-collection-get.md` — collection GET listener +- `docs/adr/0061-project-history-cli.md` — distinct `tepp-project-history` + POST CLI on live #420 +- `docs/adr/0021-lineageweave-project-history-boundary.md` — LineageWeave + POST boundary +- `docs/adr/0014-scientific-claim-promotion-and-release-evidence.md` — CLI + success is not a scientific claim +- `docs/API_CONTRACT.md` — documented collection resource +- `crates/tepp_api/tests/project_history_collection_cli_contract.rs` — + fail-closed collection CLI proofs + +## Verification + +- `tepp-project-histories list` of accepted LineageWeave projections returns + metric-free `temporal_association_only` rows without RMSE/bias/coverage/ + SE-gate keys, `evidence_text`, `findings`, `causal_score`, or + `tepp.scientific_acceptance.v1`; +- naruon consumer, non-loopback hosts, credential flags, nonempty stdin, and + unknown verbs fail closed; +- review, Copilot, GitHub, and bearer flags remain `AuthorizationDenied`. + +## Non-claims + +This slice does not implement project-history POST CLI, GET-by-id, analysis-run +collection CLI, temporal-context CLI, export CLI, persistence, production TLS, +Leiden consensus, or an ADR 0014 scientific claim-promotion package. It does +not infer causality. diff --git a/docs/research/project-history-collection-http.md b/docs/research/project-history-collection-http.md new file mode 100644 index 000000000..7b89626a9 --- /dev/null +++ b/docs/research/project-history-collection-http.md @@ -0,0 +1,55 @@ +# Project-history collection GET (doctoring) + +## Scope + +`GET /v1/project-histories` is the operator-visible collection of accepted +cutoff-safe project-history projections on `AnalysisRunLiveService` / +`tepp-loopback`. HTTP method, path, and header semantics follow current HTTP +semantics (Fielding, Nottingham, & Reschke, 2022). Fail-closed refusal of +unpublished consumers, nonempty GET bodies, review/Copilot/GitHub credential +flags, evidence text, and scientific-authority promotion is repository +contract authority (ADR 0028; ADR 0021; ADR 0011; ADR 0014), not an RFC +inference rule. + +Collection JSON is metric-free. `inference_status` remains +`temporal_association_only`. `tepp.scientific_acceptance.v1` never appears. +A 200 collection page is not a completed temporal model, calibrated score, +theta estimate, uncertainty statement, causal inference, or scientific claim. + +## Authority + +### External standards (HTTP only) + +Fielding, R., Nottingham, M., & Reschke, J. (Eds.). (2022). *HTTP semantics* +(RFC 9110). IETF. https://doi.org/10.17487/RFC9110 + +RFC 9110 §9.3.1 describes GET as a method for retrieving the target resource. +TEPP maps that retrieval onto a bounded, cutoff-safe project-history +collection. The RFC does not define psychometric acceptance, RMSE, causality, +or claim promotion. + +### Internal contract evidence + +- `docs/adr/0028-project-history-collection-get.md` — this collection +- `docs/adr/0021-lineageweave-project-history-boundary.md` — POST boundary +- `docs/adr/0011-standalone-modular-msa-boundary.md` — modular HTTP boundary +- `docs/adr/0014-scientific-claim-promotion-and-release-evidence.md` — HTTP + 200 is not a scientific claim +- `crates/tepp_api/tests/project_history_collection_http_contract.rs` — + fail-closed collection proofs + +## Verification + +- `GET /v1/project-histories` of accepted LineageWeave projections returns + `temporal_association_only` rows without RMSE/bias/coverage/SE-gate keys, + `evidence_text`, `findings`, `causal_score`, or + `tepp.scientific_acceptance.v1`; +- GET `/v1/analysis-runs`, naruon consumer, nonempty body, and unknown verbs + fail closed. + +## Non-claims + +This slice does not implement project-history collection CLI, GET-by-id, +export CLI, analysis-run collection GET, wait CLI, lookup CLI, persistence, +production TLS, Leiden consensus, GAP-010 Figma/export, causal inference, or +an ADR 0014 scientific claim-promotion package.