diff --git a/CHANGELOG.d/project-history-retrieval-cli.md b/CHANGELOG.d/project-history-retrieval-cli.md new file mode 100644 index 000000000..66e2ac09b --- /dev/null +++ b/CHANGELOG.d/project-history-retrieval-cli.md @@ -0,0 +1 @@ +- `tepp_api` publishes `tepp-project-history-get` for loopback `GET /v1/project-histories/{idempotency_key}` (ADR 0067). Stdout is one stored `temporal_association_only` projection. `tepp.scientific_acceptance.v1` never appears. Does not infer causality. Not collection CLI, not GET-by-id HTTP, not persistence. diff --git a/CHANGELOG.d/project-history-retrieval-http.md b/CHANGELOG.d/project-history-retrieval-http.md new file mode 100644 index 000000000..51d54a0a4 --- /dev/null +++ b/CHANGELOG.d/project-history-retrieval-http.md @@ -0,0 +1 @@ +- `tepp_api` loopback `GET /v1/project-histories/{idempotency_key}` retrieves one accepted LineageWeave project-history projection on `tepp-loopback` (ADR 0066). `inference_status` remains `temporal_association_only`. `tepp.scientific_acceptance.v1` never appears. Does not infer causality. Not collection GET, not collection CLI, not persistence. diff --git a/CHANGELOG.md b/CHANGELOG.md index 062a69412..e5f6223b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,12 @@ All notable changes to TEPP are documented here. The format follows Keep a Chang ## [Unreleased] +### Fixed + +- `tepp-project-history-get` now requires and forwards the tenant workspace, accepts the same bounded identities as POST/GET, validates identity-bound success receipts and status-matched redacted error envelopes, and prints redacted failures to stderr. + +- Project-history collection and GET-by-id now require the tenant workspace identity, use the consumer/tenant/idempotency registry boundary, and accept the POST contract's full 256-byte key range, preventing cross-tenant disclosure, duplicate-key pagination loss, and unretrievable accepted histories; structured schema validation no longer rejects harmless evidence-text mentions. + - `event_core` adds bounded Allen interval-consistency classification, atomic path-consistency closure, contradiction/resource refusals, and an explicit dependency-error fallback without claiming unrestricted global satisfiability. - `psychometric_core` recovers the Driver, Oud, and Voelkle (2017, Table 2, p. 12 `MANIFESTTRAITVAR`; §7.1, p. 19; p. 16 `MANIFESTTRAITVARstd`; footnote 4; 2017-era ctsem `summary.ctsemFit.R`; JSS PDF re-opened 2026-08-27T14:20Z from https://www.jstatsoft.org/index.php/jss/article/download/v077i05/1104) scalar standardised manifest-trait variance on current main after `0ce16e8` dropped the pre-consolidation code while research notes already named the map (register items 83–84). Table 2 names `MANIFESTTRAITVAR` `Ψ_τ` the additional time-invariant variance-covariance on the measurement level and sets it `NULL` when there is no manifest trait. Equation 5 writes `Γ ~ N(τ, Ψ)` and names that covariance the manifest traits. Section 7.1 names manifest traits stable individual differences in indicator levels, distinct from process-level `TRAITVAR` `φ_ξ`. Page 16 prints standardised matrices with the suffix `std` when appropriate. The printed example on p. 16 is `discreteDRIFTstd`, not `MANIFESTTRAITVARstd`. Footnote 4 standardises using only the relevant variance, not the total. The relevant variance for that named indicator-level correlation is `MANIFESTTRAITVAR`, not process-level `TRAITVAR` and not residual `MANIFESTVAR` `θ`. The 2017-era source forms `MANIFESTTRAITVARstd` only when `MANIFESTTRAITVAR != 0`, as `solve(sqrt(diag(MANIFESTTRAITVAR) + ridging)) %&% MANIFESTTRAITVAR` when `verbose = TRUE`. OpenMx `%&%` is `t(A) %*% B %*% A`. Unlike `TRAITVARstd`, that formation adds `diag(c(ridging), n.manifest)`. The default `ridging = FALSE` adds 0, not `0.0001`; that ridge is a numerical hack and is not this exact map. The scalar correlation is `ψ / ψ = 1` after strictly positive `MANIFESTTRAITVAR`. Form strictly positive `ψ` first, then `1 / √ψ`, then `(1 / √ψ) ψ (1 / √ψ)`. Unstandardised `MANIFESTTRAITVAR` is defined for a zero trait; standardised `MANIFESTTRAITVAR` is not. Zero `MANIFESTTRAITVAR` skips forming `MANIFESTTRAITVARstd` in the 2017-era source and fails closed here. Indicator-level trait variance is an event-time structural quantity, so a non-event clock fails closed. `MANIFESTTRAITVAR` does not require stable `a < 0`. Distinct positive `ψ` recover the same 1. `trait / trait = 1` is `TRAITVARstd` and recovers the same number and remains a distinct named quantity. `θ` is `MANIFESTVAR` and is measurement error, not this correlation. Meredith (1993) remains unread (web search 2026-08-27T14:20Z: Springer/Cambridge Core paywalled; Unpaywall historically `is_oa: false`; Springer `content/pdf` is an HTML stub). Mislevy (1991, *Psychometrika, 56*, 177–196) remains unread on the same terms (DOI `10.1007/bf02294457`). Still not a Kalman filter, not a matrix `expm`, not ESEM estimation, not DSEM, and not ctsem estimation. diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 92c4bb9d5..3daa0177f 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -12,6 +12,8 @@ TEPP's approved PRD v0.4 and implementation plan are the primary product baselin | 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 GET-by-id doctoring | [`docs/research/project-history-retrieval-http.md`](docs/research/project-history-retrieval-http.md) | +| Project-history GET-by-id CLI doctoring | [`docs/research/project-history-retrieval-cli.md`](docs/research/project-history-retrieval-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..cb691af39 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-history-get" +path = "src/bin/tepp_project_history_get.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 fd682d7cd..b8b3f809a 100644 --- a/crates/tepp_api/src/analysis_run_live.rs +++ b/crates/tepp_api/src/analysis_run_live.rs @@ -19,11 +19,14 @@ 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, - 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, + PROJECT_HISTORY_RETRIEVAL_TENANT_HEADER, ProjectHistoryCollection, + ProjectHistoryCollectionItem, ProjectHistoryProjection, ProjectHistoryRequest, + ProjectHistoryRetrievalReceipt, 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, + project_history_retrieval_path_id, refuse_metrics_on_project_history_retrieval_payload, + requests_are_idempotent_matches, }; const MAX_LIVE_REQUEST_BODY_BYTES: usize = DEFAULT_PROJECT_HISTORY_BYTE_LIMIT; @@ -148,7 +151,16 @@ impl AnalysisRunLiveService { 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 is_project_history_collection_path(path) { + return self.list_project_histories(&headers, body); + } + if matches!( + project_history_retrieval_path_id(path), + Ok(_) | Err(ApiError::LimitExceeded) + ) { + return self.get_project_history(path, &headers, body); + } + return Err(ApiError::InvalidWirePayload); } if method != "POST" || (path != NARUON_ANALYSIS_RUN_PATH @@ -243,13 +255,9 @@ impl AnalysisRunLiveService { 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); } @@ -257,15 +265,18 @@ impl AnalysisRunLiveService { if consumer != LINEAGEWEAVE_CONSUMER_CODE { return Err(ApiError::InvalidWirePayload); } - let limit = parse_project_history_collection_page_limit( - headers.get("tepp-page-limit").map(String::as_str), - )?; - let cursor = parse_project_history_collection_page_cursor( - headers.get("tepp-page-cursor").map(String::as_str), - )?; + let page_limit = headers.get("tepp-page-limit").map(String::as_str); + let page_cursor = headers.get("tepp-page-cursor").map(String::as_str); + let limit = parse_project_history_collection_page_limit(page_limit)?; + let cursor = parse_project_history_collection_page_cursor(page_cursor)?; + let tenant_workspace_id = header_value(headers, PROJECT_HISTORY_RETRIEVAL_TENANT_HEADER)?; + crate::project_history::validate_project_history_registry_identity(tenant_workspace_id)?; + let tenant_prefix = format!("{consumer}\u{1f}{tenant_workspace_id}\u{1f}"); let items = self .accepted_project_histories - .values() + .iter() + .filter(|(registry_identity, _)| registry_identity.starts_with(&tenant_prefix)) + .map(|(_, stored)| stored) .map(|(request, projection)| { ProjectHistoryCollectionItem::new( request.project_key.clone(), @@ -281,6 +292,41 @@ impl AnalysisRunLiveService { Ok(json_response(200, "OK", collection.to_json()?)) } + fn get_project_history( + &self, + path: &str, + headers: &HashMap, + body: &str, + ) -> Result { + if !body.is_empty() { + return Err(ApiError::InvalidWirePayload); + } + refuse_metrics_on_project_history_retrieval_payload(body)?; + let consumer = require_headers(headers, self.bound_addr, false)?; + if consumer != LINEAGEWEAVE_CONSUMER_CODE { + return Err(ApiError::InvalidWirePayload); + } + if headers.contains_key("tepp-page-limit") || headers.contains_key("tepp-page-cursor") { + return Err(ApiError::InvalidWirePayload); + } + let tenant_workspace_id = header_value(headers, PROJECT_HISTORY_RETRIEVAL_TENANT_HEADER)?; + crate::project_history::validate_project_history_registry_identity(tenant_workspace_id)?; + let idempotency_key = project_history_retrieval_path_id(path)?; + let replay_key = + consumer_tenant_idempotency_key(consumer, tenant_workspace_id, &idempotency_key); + let (_, projection) = self + .accepted_project_histories + .get(&replay_key) + .ok_or(ApiError::InvalidWirePayload)?; + let receipt = project_history_retrieval_receipt( + tenant_workspace_id, + idempotency_key, + projection.clone(), + )?; + let response_body = receipt.to_json()?; + Ok(json_response(200, "OK", response_body)) + } + fn response_from_error(&mut self, error: ApiError) -> NaruonLiveResponse { let request_id = format!("analysis-run-live-{}", self.next_request_serial); self.next_request_serial += 1; @@ -317,6 +363,14 @@ fn consumer_tenant_idempotency_key( format!("{consumer}\u{1f}{tenant_workspace_id}\u{1f}{idempotency_key}") } +fn project_history_retrieval_receipt( + tenant_workspace_id: &str, + idempotency_key: String, + projection: ProjectHistoryProjection, +) -> Result { + ProjectHistoryRetrievalReceipt::new(tenant_workspace_id, idempotency_key, projection) +} + fn status_for(error: ApiError) -> (u16, &'static str) { match error { ApiError::InvalidWirePayload => (400, "Bad Request"), @@ -358,7 +412,7 @@ mod tests { use super::{ AnalysisRunLiveService, consumer_tenant_idempotency_key, declared_content_length, error_envelope_json, host_implies_table_access, map_io_error, parse_headers, - require_headers, split_header_line, status_for, + project_history_retrieval_receipt, require_headers, split_header_line, status_for, }; use crate::live_http::{host_is_loopback, read_http_request, split_request}; use crate::{ @@ -366,7 +420,8 @@ mod tests { DEFAULT_ANALYSIS_RUN_BYTE_LIMIT, ErrorEnvelope, LINEAGEWEAVE_CONSUMER_CODE, NARUON_ANALYSIS_RUN_PATH, NARUON_CONSUMER_CODE, NARUON_LIVE_HEADER_BYTE_LIMIT, NARUON_LIVE_HEADER_COUNT_LIMIT, NARUON_LIVE_IO_TIMEOUT, PROJECT_HISTORY_CONTRACT_VERSION, - PROJECT_HISTORY_PATH, ProjectHistoryCollection, ProjectHistoryEvent, ProjectHistoryRequest, + PROJECT_HISTORY_PATH, ProjectHistoryCollection, ProjectHistoryEvent, + ProjectHistoryProjection, ProjectHistoryRequest, ProjectHistoryRetrievalReceipt, TEMPORAL_CONTEXT_PATH, }; @@ -1036,7 +1091,7 @@ mod tests { ); 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" + "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-tenant-workspace-id: history-tenant\r\ncontent-length: 0\r\n\r\n" ); let got = service.handle_http_request(&list); assert_eq!(got.status_code, 200); @@ -1051,28 +1106,173 @@ mod tests { 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" + "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-tenant-workspace-id: history-tenant\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 continued = 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-tenant-workspace-id: history-tenant\r\ntepp-page-limit: 1\r\ntepp-page-cursor: idem-a\r\ncontent-length: 0\r\n\r\n" + ); + let continued_page = + ProjectHistoryCollection::from_json(&service.handle_http_request(&continued).body) + .expect("continued page"); + assert_eq!(continued_page.histories[0].idempotency_key, "idem-b"); + + let missing_tenant = 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" + ); + assert_eq!( + service.handle_http_request(&missing_tenant).status_code, + 400 + ); 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" + "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\ntepp-tenant-workspace-id: history-tenant\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{{}}" + "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-tenant-workspace-id: history-tenant\r\ncontent-length: 2\r\n\r\n{{}}" ); assert_eq!(service.handle_http_request(&nonempty).status_code, 400); } + #[test] + fn project_history_collection_scopes_duplicate_and_maximum_keys_by_tenant() { + let mut service = AnalysisRunLiveService::new(); + let maximum_key = "k".repeat(256); + let first = sample_project_history(&maximum_key, "project-a"); + let mut other_tenant = sample_project_history(&maximum_key, "project-b"); + other_tenant.tenant_workspace_id = "other-tenant".into(); + assert_eq!( + service + .handle_http_request(&project_history_post(&first)) + .status_code, + 200 + ); + assert_eq!( + service + .handle_http_request(&project_history_post(&other_tenant)) + .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\ntepp-tenant-workspace-id: history-tenant\r\ncontent-length: 0\r\n\r\n" + ); + let page = ProjectHistoryCollection::from_json(&service.handle_http_request(&list).body) + .expect("tenant page"); + assert_eq!(page.histories.len(), 1); + assert_eq!(page.histories[0].idempotency_key, maximum_key); + assert_eq!(page.histories[0].project_key, "project-a"); + } + + #[test] + fn project_history_retrieval_get_returns_stored_projection_and_fails_closed() { + let mut service = AnalysisRunLiveService::new(); + let first = sample_project_history("idem-a", "project-a"); + let posted = service.handle_http_request(&project_history_post(&first)); + assert_eq!(posted.status_code, 200); + let stored = ProjectHistoryProjection::from_json(&posted.body).expect("stored"); + assert!( + project_history_retrieval_receipt("tenant\nother", "idem-a".into(), stored.clone(),) + .is_err() + ); + let mut corrupted_service = AnalysisRunLiveService::new(); + let mut corrupted_projection = stored.clone(); + corrupted_projection.inference_status = "causal".into(); + corrupted_service.accepted_project_histories.insert( + consumer_tenant_idempotency_key(LINEAGEWEAVE_CONSUMER_CODE, "history-tenant", "idem-a"), + (first.clone(), corrupted_projection), + ); + let corrupted = corrupted_service.handle_http_request(&format!( + "GET {PROJECT_HISTORY_PATH}/idem-a HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: {LINEAGEWEAVE_CONSUMER_CODE}\r\ntepp-contract-version: 1\r\ntepp-tenant-workspace-id: history-tenant\r\ncontent-length: 0\r\n\r\n" + )); + assert_eq!(corrupted.status_code, 400); + let mut other_tenant = sample_project_history("idem-a", "project-b"); + other_tenant.tenant_workspace_id = "other-tenant".into(); + other_tenant.project_name = "Other project".into(); + assert_eq!( + service + .handle_http_request(&project_history_post(&other_tenant)) + .status_code, + 200 + ); + + let got = service.handle_http_request(&format!( + "GET {PROJECT_HISTORY_PATH}/idem-a HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: {LINEAGEWEAVE_CONSUMER_CODE}\r\ntepp-contract-version: 1\r\ntepp-tenant-workspace-id: history-tenant\r\ncontent-length: 0\r\n\r\n" + )); + assert_eq!(got.status_code, 200); + let retrieved = ProjectHistoryRetrievalReceipt::from_json(&got.body).expect("retrieved"); + assert_eq!(retrieved.tenant_workspace_id, "history-tenant"); + assert_eq!(retrieved.idempotency_key, "idem-a"); + assert_eq!(retrieved.projection, stored); + assert_eq!( + retrieved.projection.inference_status, + "temporal_association_only" + ); + assert!(!got.body.contains("rmse")); + assert!(!got.body.contains("tepp.scientific_acceptance.v1")); + assert!(!got.body.contains("causal_score")); + + let other = service.handle_http_request(&format!( + "GET {PROJECT_HISTORY_PATH}/idem-a HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: {LINEAGEWEAVE_CONSUMER_CODE}\r\ntepp-contract-version: 1\r\ntepp-tenant-workspace-id: other-tenant\r\ncontent-length: 0\r\n\r\n" + )); + let other_receipt = + ProjectHistoryRetrievalReceipt::from_json(&other.body).expect("other tenant receipt"); + assert_eq!(other_receipt.tenant_workspace_id, "other-tenant"); + assert_eq!(other_receipt.projection.project_key, "project-b"); + + let missing_tenant = service.handle_http_request(&format!( + "GET {PROJECT_HISTORY_PATH}/idem-a 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!(missing_tenant.status_code, 400); + let foreign_tenant = service.handle_http_request(&format!( + "GET {PROJECT_HISTORY_PATH}/idem-a HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: {LINEAGEWEAVE_CONSUMER_CODE}\r\ntepp-contract-version: 1\r\ntepp-tenant-workspace-id: foreign-tenant\r\ncontent-length: 0\r\n\r\n" + )); + assert_eq!(foreign_tenant.status_code, 400); + + let unknown = service.handle_http_request(&format!( + "GET {PROJECT_HISTORY_PATH}/missing HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: {LINEAGEWEAVE_CONSUMER_CODE}\r\ntepp-contract-version: 1\r\ntepp-tenant-workspace-id: history-tenant\r\ncontent-length: 0\r\n\r\n" + )); + assert_eq!(unknown.status_code, 400); + let naruon = service.handle_http_request(&format!( + "GET {PROJECT_HISTORY_PATH}/idem-a HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: {NARUON_CONSUMER_CODE}\r\ntepp-contract-version: 1\r\ntepp-tenant-workspace-id: history-tenant\r\ncontent-length: 0\r\n\r\n" + )); + assert_eq!(naruon.status_code, 400); + let extra = service.handle_http_request(&format!( + "GET {PROJECT_HISTORY_PATH}/idem-a/extra HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: {LINEAGEWEAVE_CONSUMER_CODE}\r\ntepp-contract-version: 1\r\ntepp-tenant-workspace-id: history-tenant\r\ncontent-length: 0\r\n\r\n" + )); + assert_eq!(extra.status_code, 400); + let paged = service.handle_http_request(&format!( + "GET {PROJECT_HISTORY_PATH}/idem-a HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: {LINEAGEWEAVE_CONSUMER_CODE}\r\ntepp-contract-version: 1\r\ntepp-tenant-workspace-id: history-tenant\r\ntepp-page-limit: 1\r\ncontent-length: 0\r\n\r\n" + )); + assert_eq!(paged.status_code, 400); + let cursor = service.handle_http_request(&format!( + "GET {PROJECT_HISTORY_PATH}/idem-a HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: {LINEAGEWEAVE_CONSUMER_CODE}\r\ntepp-contract-version: 1\r\ntepp-tenant-workspace-id: history-tenant\r\ntepp-page-cursor: idem-a\r\ncontent-length: 0\r\n\r\n" + )); + assert_eq!(cursor.status_code, 400); + let nonempty = service.handle_http_request(&format!( + "GET {PROJECT_HISTORY_PATH}/idem-a HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: {LINEAGEWEAVE_CONSUMER_CODE}\r\ntepp-contract-version: 1\r\ntepp-tenant-workspace-id: history-tenant\r\ncontent-length: 2\r\n\r\n{{}}" + )); + assert_eq!(nonempty.status_code, 400); + let wrong_method = service.handle_http_request(&format!( + "PUT {PROJECT_HISTORY_PATH}/idem-a HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: {LINEAGEWEAVE_CONSUMER_CODE}\r\ntepp-contract-version: 1\r\ntepp-tenant-workspace-id: history-tenant\r\ncontent-length: 0\r\n\r\n" + )); + assert_eq!(wrong_method.status_code, 400); + let collection = service.handle_http_request(&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-tenant-workspace-id: history-tenant\r\ncontent-length: 0\r\n\r\n" + )); + assert_eq!(collection.status_code, 200); + assert!(!collection.body.contains("evidence_text")); + } + struct ScriptedRead { reader: Cursor>, first_error: Option, diff --git a/crates/tepp_api/src/bin/tepp_project_history_get.rs b/crates/tepp_api/src/bin/tepp_project_history_get.rs new file mode 100644 index 000000000..31d73cc93 --- /dev/null +++ b/crates/tepp_api/src/bin/tepp_project_history_get.rs @@ -0,0 +1,33 @@ +//! Operator CLI for loopback `LineageWeave` project-history GET-by-id. + +use std::io::{self, IsTerminal}; +use std::process::ExitCode; + +use tepp_api::{ + ApiError, ProjectHistoryRetrievalCliInvocation, execute_project_history_retrieval_cli, + read_project_history_retrieval_cli_stdin, render_project_history_retrieval_cli_stdout, +}; + +fn main() -> ExitCode { + match run() { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("tepp-project-history-get: {error}"); + ExitCode::FAILURE + } + } +} + +fn run() -> Result<(), ApiError> { + let args: Vec = std::env::args().skip(1).collect(); + let body = read_project_history_retrieval_cli_stdin(io::stdin().is_terminal(), io::stdin())?; + let invocation = ProjectHistoryRetrievalCliInvocation::from_args(&args, body)?; + let response = execute_project_history_retrieval_cli(&invocation)?; + let stdout = render_project_history_retrieval_cli_stdout(&invocation, &response)?; + println!("{stdout}"); + if (200..300).contains(&response.status_code) { + Ok(()) + } else { + Err(ApiError::InvalidWirePayload) + } +} diff --git a/crates/tepp_api/src/lib.rs b/crates/tepp_api/src/lib.rs index 32b5c6b48..b5e79adb1 100644 --- a/crates/tepp_api/src/lib.rs +++ b/crates/tepp_api/src/lib.rs @@ -29,6 +29,8 @@ mod naruon_live; mod orchestration; mod project_history; mod project_history_collection_http; +mod project_history_retrieval_cli; +mod project_history_retrieval_http; mod project_journey; mod provider_payload; mod temporal_context; @@ -255,6 +257,34 @@ pub use project_history_collection_http::parse_project_history_collection_page_c 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; +/// Loopback project-history retrieval CLI invocation. +pub use project_history_retrieval_cli::ProjectHistoryRetrievalCliInvocation; +/// Loopback project-history retrieval CLI verb. +pub use project_history_retrieval_cli::ProjectHistoryRetrievalCliVerb; +/// Compose HTTP/1.1 retrieval GET from a CLI invocation. +pub use project_history_retrieval_cli::compose_project_history_retrieval_cli_http; +/// Dispatch a retrieval CLI invocation against an in-process listener. +pub use project_history_retrieval_cli::dispatch_project_history_retrieval_cli; +/// Execute a retrieval CLI invocation over loopback TCP. +pub use project_history_retrieval_cli::execute_project_history_retrieval_cli; +/// Render a typed retrieval GET exchange as loopback HTTP/1.1. +pub use project_history_retrieval_cli::loopback_http1_from_project_history_retrieval_exchange; +/// Read leftover stdin for the project-history retrieval CLI. +pub use project_history_retrieval_cli::read_project_history_retrieval_cli_stdin; +/// Filter retrieval CLI stdout so scientific-acceptance never appears. +pub use project_history_retrieval_cli::render_project_history_retrieval_cli_stdout; +/// Maximum opaque idempotency-key length on project-history GET-by-id. +pub use project_history_retrieval_http::PROJECT_HISTORY_RETRIEVAL_ID_MAX_LEN; +/// Header carrying the authorized tenant on project-history GET-by-id. +pub use project_history_retrieval_http::PROJECT_HISTORY_RETRIEVAL_TENANT_HEADER; +/// Identity-bound project-history GET-by-id receipt. +pub use project_history_retrieval_http::ProjectHistoryRetrievalReceipt; +/// `LineageWeave` GET exchange for one stored project-history projection. +pub use project_history_retrieval_http::lineageweave_project_history_retrieval_exchange; +/// Extract the opaque idempotency key from a project-history retrieval path. +pub use project_history_retrieval_http::project_history_retrieval_path_id; +/// Refuse scientific-metric and causal-score keys on retrieval JSON. +pub use project_history_retrieval_http::refuse_metrics_on_project_history_retrieval_payload; /// 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..2b3918538 100644 --- a/crates/tepp_api/src/project_history.rs +++ b/crates/tepp_api/src/project_history.rs @@ -162,8 +162,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)?; - validate_bounded_text(&self.tenant_workspace_id, 256)?; + validate_project_history_registry_identity(&self.idempotency_key)?; + validate_project_history_registry_identity(&self.tenant_workspace_id)?; validate_bounded_text(&self.project_key, 256)?; validate_bounded_text(&self.project_name, 512)?; validate_bounded_text(&self.focus_event_id, 256)?; @@ -391,6 +391,14 @@ fn validate_bounded_text(value: &str, maximum_bytes: usize) -> Result<(), ApiErr Ok(()) } +pub(crate) fn validate_project_history_registry_identity(value: &str) -> Result<(), ApiError> { + if value.chars().any(char::is_control) { + return Err(ApiError::InvalidWirePayload); + } + validate_bounded_text(value, 256)?; + Ok(()) +} + fn validate_code(value: &str) -> Result<(), ApiError> { validate_bounded_text(value, 64)?; if !value @@ -686,6 +694,13 @@ mod tests { #[test] fn request_refuses_missing_focus_bad_codes_and_excess_events() { + let mut control_identity = request_with_single_event(); + control_identity.tenant_workspace_id = "\ntenant".into(); + assert_eq!( + project_history_projection(&control_identity), + Err(ApiError::InvalidWirePayload) + ); + let mut missing_focus = request_with_single_event(); missing_focus.focus_event_id = "missing".into(); assert_eq!( diff --git a/crates/tepp_api/src/project_history_collection_http.rs b/crates/tepp_api/src/project_history_collection_http.rs index 13efec2bf..e6612b537 100644 --- a/crates/tepp_api/src/project_history_collection_http.rs +++ b/crates/tepp_api/src/project_history_collection_http.rs @@ -11,10 +11,14 @@ //! Figma/export. Persistence remains GAP-003B. use crate::naruon_http::{NaruonHttpExchange, compose_https_target}; +use crate::project_history::validate_project_history_registry_identity; 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_PATH}; +use crate::{ + ApiError, DEFAULT_PROJECT_HISTORY_BYTE_LIMIT, PROJECT_HISTORY_PATH, + PROJECT_HISTORY_RETRIEVAL_TENANT_HEADER, +}; use serde::{Deserialize, Serialize}; /// Supported project-history collection contract version. @@ -27,7 +31,7 @@ pub const PROJECT_HISTORY_COLLECTION_DEFAULT_LIMIT: usize = 32; 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 = 128; +pub const PROJECT_HISTORY_COLLECTION_CURSOR_MAX_LEN: usize = 256; /// Fixed non-causal claim boundary echoed on every collection row. pub const PROJECT_HISTORY_COLLECTION_INFERENCE_STATUS: &str = "temporal_association_only"; @@ -318,14 +322,16 @@ pub fn page_project_history_collection_items( /// /// # Errors /// -/// Returns [`ApiError::InvalidWirePayload`] for a non-`https` origin or an -/// empty cursor, and [`ApiError::LimitExceeded`] when limit or cursor bounds -/// are exceeded. +/// Returns [`ApiError::InvalidWirePayload`] for a non-`https` origin, invalid +/// tenant identity, or empty cursor, and [`ApiError::LimitExceeded`] when +/// tenant, limit, or cursor bounds are exceeded. pub fn lineageweave_project_history_collection_exchange( origin: &str, + tenant_workspace_id: &str, cursor: Option<&str>, limit: Option<&str>, ) -> Result { + validate_project_history_registry_identity(tenant_workspace_id)?; 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)?; @@ -333,6 +339,10 @@ pub fn lineageweave_project_history_collection_exchange( ("content-type".into(), "application/json".into()), ("tepp-consumer".into(), "lineageweave".into()), ("tepp-contract-version".into(), "1".into()), + ( + PROJECT_HISTORY_RETRIEVAL_TENANT_HEADER.into(), + tenant_workspace_id.to_owned(), + ), ]; if let Some(cursor) = cursor { headers.push(("tepp-page-cursor".into(), cursor.to_owned())); @@ -450,6 +460,13 @@ mod tests { ProjectHistoryCollection::new(vec![sample_item()], Some(String::new())), Err(ApiError::InvalidWirePayload) ); + assert_eq!( + ProjectHistoryCollection::new( + vec![sample_item()], + Some("a".repeat(PROJECT_HISTORY_COLLECTION_CURSOR_MAX_LEN + 1)), + ), + Err(ApiError::LimitExceeded) + ); let oversized = vec![sample_item(); PROJECT_HISTORY_COLLECTION_MAX_LIMIT + 1]; assert_eq!( ProjectHistoryCollection::new(oversized, None), @@ -513,6 +530,12 @@ mod tests { parse_project_history_collection_page_cursor(Some("")), Err(ApiError::InvalidWirePayload) ); + assert_eq!( + parse_project_history_collection_page_cursor(Some( + &"a".repeat(PROJECT_HISTORY_COLLECTION_CURSOR_MAX_LEN + 1) + )), + Err(ApiError::LimitExceeded) + ); 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")); @@ -536,12 +559,17 @@ mod tests { let exchange = lineageweave_project_history_collection_exchange( "https://tepp.example.test", + "tenant-a", Some("idem-1"), Some("8"), ) .expect("exchange"); assert_eq!(exchange.method, "GET"); assert!(exchange.target_url.ends_with("/v1/project-histories")); + assert!(exchange.headers.contains(&( + crate::PROJECT_HISTORY_RETRIEVAL_TENANT_HEADER.into(), + "tenant-a".into(), + ))); assert!( !exchange .headers @@ -550,7 +578,21 @@ mod tests { ); assert!(exchange.body.is_empty()); assert_eq!( - lineageweave_project_history_collection_exchange("http://insecure.example", None, None), + lineageweave_project_history_collection_exchange( + "http://insecure.example", + "tenant-a", + None, + None, + ), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + lineageweave_project_history_collection_exchange( + "https://tepp.example.test", + "", + None, + None, + ), Err(ApiError::InvalidWirePayload) ); } diff --git a/crates/tepp_api/src/project_history_retrieval_cli.rs b/crates/tepp_api/src/project_history_retrieval_cli.rs new file mode 100644 index 000000000..d4d1c4b50 --- /dev/null +++ b/crates/tepp_api/src/project_history_retrieval_cli.rs @@ -0,0 +1,695 @@ +//! Operator loopback CLI for `LineageWeave` project-history GET-by-id. +//! +//! GAP-003A unique slice: operators run `tepp-project-history-get get` to mint +//! `lineageweave_project_history_retrieval_exchange` onto spawned +//! `tepp-loopback` TCP. Stdout is one stored cutoff-safe +//! `ProjectHistoryProjection` with `inference_status=temporal_association_only`. +//! `tepp.scientific_acceptance.v1` never appears. The CLI does not infer +//! causality. Naruon is refused on this LineageWeave-owned adapter. +//! `NaruonLiveService` stays POST-only. This module does not duplicate +//! collection GET (#424), collection CLI (#428), GET-by-id HTTP (#429), +//! project-history POST CLI (#420), temporal-context CLI (#414), export +//! retrieval CLI (#417), stored-request CLI (#395), analysis-run GET-by-id, +//! Leiden, or GAP-010 Figma/export. Persistence remains GAP-003B. + +use std::collections::HashSet; +use std::fmt::Write as _; +use std::io::{Read, Write}; +use std::net::{SocketAddr, TcpStream}; + +use crate::live_http::map_io_error; +use crate::naruon_http::header_is_credential; +use crate::wire::require_nonempty; +use crate::{ + AnalysisRunLiveService, ApiError, DEFAULT_PROJECT_HISTORY_BYTE_LIMIT, ErrorEnvelope, + LINEAGEWEAVE_CONSUMER_CODE, NARUON_LIVE_HEADER_BYTE_LIMIT, NARUON_LIVE_HEADER_COUNT_LIMIT, + NARUON_LIVE_IO_TIMEOUT, NaruonHttpExchange, NaruonLiveResponse, + PROJECT_HISTORY_RETRIEVAL_TENANT_HEADER, ProjectHistoryRetrievalReceipt, + lineageweave_project_history_retrieval_exchange, project_history_retrieval_path_id, + refuse_metrics_on_project_history_retrieval_payload, +}; + +const MAXIMUM_HTTP_RESPONSE_BYTES: usize = + NARUON_LIVE_HEADER_BYTE_LIMIT + 4 + DEFAULT_PROJECT_HISTORY_BYTE_LIMIT; + +/// Supported operator verbs for the loopback project-history retrieval CLI. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ProjectHistoryRetrievalCliVerb { + /// `GET /v1/project-histories/{idempotency_key}`. + Get, +} + +impl ProjectHistoryRetrievalCliVerb { + /// Parse one exact lowercase verb token. + /// + /// # Errors + /// + /// Returns [`ApiError::InvalidWirePayload`] for an unknown token. + pub fn parse(token: &str) -> Result { + match token { + "get" => Ok(Self::Get), + _ => Err(ApiError::InvalidWirePayload), + } + } + + /// Return the canonical lowercase verb token. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Get => "get", + } + } +} + +/// One operator CLI invocation against a loopback project-history GET-by-id listener. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ProjectHistoryRetrievalCliInvocation { + /// CLI verb to execute. + pub verb: ProjectHistoryRetrievalCliVerb, + /// Loopback `host:port` of `tepp-loopback`. + pub host: String, + /// Published HTTPS origin used to mint the typed retrieval exchange. + pub origin: String, + /// Published modular consumer. Retrieval GET admits `lineageweave` only. + pub consumer: String, + /// Opaque idempotency key that minted the stored projection. + pub idempotency_key: String, + /// Tenant workspace that owns the stored projection. + pub tenant_workspace_id: String, + /// JSON body. Retrieval GET requires empty. + pub body: String, +} + +impl ProjectHistoryRetrievalCliInvocation { + /// Parse argv plus stdin body into a validated loopback retrieval invocation. + /// + /// # Errors + /// + /// Returns a fail-closed error for unknown verbs, missing required flags, a + /// non-loopback host, a non-`https` origin, an unpublished or naruon + /// consumer, credential-shaped flags, a hostile identity, or a nonempty + /// body. + pub fn from_args(args: I, body: impl Into) -> Result + where + I: IntoIterator, + S: AsRef, + { + let tokens: Vec = args + .into_iter() + .map(|token| token.as_ref().to_owned()) + .collect(); + let (verb_token, rest) = tokens.split_first().ok_or(ApiError::InvalidWirePayload)?; + let verb = ProjectHistoryRetrievalCliVerb::parse(verb_token)?; + let flags = parse_flags(rest)?; + assemble_invocation(verb, flags, body.into()) + } + + /// Reject a non-loopback host, unpublished consumer, or hostile GET body. + /// + /// # Errors + /// + /// Returns [`ApiError::AuthorizationDenied`] for a non-loopback host and + /// [`ApiError::InvalidWirePayload`] or [`ApiError::LimitExceeded`] for + /// empty, unpublished, naruon, nonempty-body, or oversized fields. + pub fn validate(&self) -> Result<(), ApiError> { + require_loopback_host(&self.host)?; + require_nonempty(&self.origin)?; + if !self.origin.starts_with("https://") { + return Err(ApiError::InvalidWirePayload); + } + require_nonempty(&self.consumer)?; + if self.consumer != LINEAGEWEAVE_CONSUMER_CODE { + return Err(ApiError::InvalidWirePayload); + } + crate::project_history::validate_project_history_registry_identity(&self.idempotency_key)?; + crate::project_history::validate_project_history_registry_identity( + &self.tenant_workspace_id, + )?; + if !self.body.is_empty() { + return Err(ApiError::InvalidWirePayload); + } + refuse_metrics_on_project_history_retrieval_payload(&self.body)?; + Ok(()) + } +} + +struct ParsedFlags { + host: Option, + origin: Option, + consumer: Option, + idempotency_key: Option, + tenant_workspace_id: Option, +} + +fn parse_flags(rest: &[String]) -> Result { + let mut flags = ParsedFlags { + host: None, + origin: None, + consumer: None, + idempotency_key: None, + tenant_workspace_id: None, + }; + let mut index = 0; + while index < rest.len() { + let flag = rest[index].as_str(); + if !flag.starts_with("--") { + return Err(ApiError::InvalidWirePayload); + } + let name = &flag[2..]; + if header_is_credential(name) { + return Err(ApiError::AuthorizationDenied); + } + let slot = match name { + "host" => &mut flags.host, + "origin" => &mut flags.origin, + "consumer" => &mut flags.consumer, + "idempotency-key" => &mut flags.idempotency_key, + "tenant-workspace-id" => &mut flags.tenant_workspace_id, + _ => return Err(ApiError::InvalidWirePayload), + }; + if slot.is_some() || index + 1 >= rest.len() { + return Err(ApiError::InvalidWirePayload); + } + let value = rest[index + 1].as_str(); + require_nonempty(value)?; + *slot = Some(value.to_owned()); + index += 2; + } + Ok(flags) +} + +fn assemble_invocation( + verb: ProjectHistoryRetrievalCliVerb, + flags: ParsedFlags, + body: String, +) -> Result { + let invocation = ProjectHistoryRetrievalCliInvocation { + verb, + host: flags.host.ok_or(ApiError::InvalidWirePayload)?, + origin: flags.origin.ok_or(ApiError::InvalidWirePayload)?, + consumer: flags + .consumer + .unwrap_or_else(|| LINEAGEWEAVE_CONSUMER_CODE.to_owned()), + idempotency_key: flags.idempotency_key.ok_or(ApiError::InvalidWirePayload)?, + tenant_workspace_id: flags + .tenant_workspace_id + .ok_or(ApiError::InvalidWirePayload)?, + body, + }; + invocation.validate()?; + Ok(invocation) +} + +fn require_loopback_host(host: &str) -> Result { + let addr: SocketAddr = host.parse().map_err(|_| ApiError::InvalidWirePayload)?; + if addr.ip().is_loopback() { + Ok(addr) + } else { + Err(ApiError::AuthorizationDenied) + } +} + +/// Render a typed retrieval 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. Collection paths +/// and pagination headers fail closed. +/// +/// # Errors +/// +/// Returns [`ApiError::AuthorizationDenied`] for a non-loopback host or a +/// credential-bearing header, and [`ApiError::InvalidWirePayload`] when the +/// exchange is not a GET `/v1/project-histories/{idempotency_key}` with an +/// empty body. +pub fn loopback_http1_from_project_history_retrieval_exchange( + exchange: &NaruonHttpExchange, + loopback_host: &str, +) -> Result { + let _addr = require_loopback_host(loopback_host)?; + let host = loopback_host.trim(); + if exchange.method != "GET" { + return Err(ApiError::InvalidWirePayload); + } + if !exchange.body.is_empty() { + return Err(ApiError::InvalidWirePayload); + } + let rest = exchange + .target_url + .strip_prefix("https://") + .ok_or(ApiError::InvalidWirePayload)?; + let path = rest + .find('/') + .map(|index| &rest[index..]) + .ok_or(ApiError::InvalidWirePayload)?; + let _idempotency_key = project_history_retrieval_path_id(path)?; + let mut seen = HashSet::with_capacity(exchange.headers.len()); + let mut has_content_type = false; + let mut has_consumer = false; + let mut has_contract = false; + let mut has_tenant = false; + for (name, value) in &exchange.headers { + if header_is_credential(name) { + return Err(ApiError::AuthorizationDenied); + } + if !valid_http_field_name(name) + || value.chars().any(char::is_control) + || !seen.insert(name.to_ascii_lowercase()) + { + return Err(ApiError::InvalidWirePayload); + } + let valid = match name.to_ascii_lowercase().as_str() { + "content-type" => { + has_content_type = true; + value == "application/json" + } + "tepp-consumer" => { + has_consumer = true; + value == LINEAGEWEAVE_CONSUMER_CODE + } + "tepp-contract-version" => { + has_contract = true; + value == "1" + } + PROJECT_HISTORY_RETRIEVAL_TENANT_HEADER => { + has_tenant = true; + crate::project_history::validate_project_history_registry_identity(value)?; + true + } + _ => false, + }; + if !valid { + return Err(ApiError::InvalidWirePayload); + } + } + if !has_content_type || !has_consumer || !has_contract || !has_tenant { + return Err(ApiError::InvalidWirePayload); + } + let mut request = String::new(); + write!( + request, + "{} {path} HTTP/1.1\r\nHost: {host}\r\n", + exchange.method + ) + .map_err(|_| ApiError::InvalidWirePayload)?; + for (name, value) in &exchange.headers { + write!(request, "{name}: {value}\r\n").map_err(|_| ApiError::InvalidWirePayload)?; + } + write!(request, "content-length: 0\r\n\r\n").map_err(|_| ApiError::InvalidWirePayload)?; + Ok(request) +} + +/// Compose one HTTP/1.1 retrieval GET from the typed `LineageWeave` exchange. +/// +/// # Errors +/// +/// Returns the same fail-closed errors as +/// [`ProjectHistoryRetrievalCliInvocation::validate`]. +pub fn compose_project_history_retrieval_cli_http( + invocation: &ProjectHistoryRetrievalCliInvocation, +) -> Result { + invocation.validate()?; + let exchange = lineageweave_project_history_retrieval_exchange( + &invocation.origin, + &invocation.tenant_workspace_id, + &invocation.idempotency_key, + )?; + loopback_http1_from_project_history_retrieval_exchange(&exchange, &invocation.host) +} + +/// Dispatch one retrieval CLI invocation against an in-process listener. +/// +/// # Errors +/// +/// Returns fail-closed validation errors before the HTTP handler runs. +pub fn dispatch_project_history_retrieval_cli( + service: &mut AnalysisRunLiveService, + invocation: &ProjectHistoryRetrievalCliInvocation, +) -> Result { + let request = compose_project_history_retrieval_cli_http(invocation)?; + Ok(service.handle_http_request(&request)) +} + +/// Execute one retrieval CLI invocation over loopback TCP against `tepp-loopback`. +/// +/// # Errors +/// +/// Returns fail-closed validation, transport, or response-framing errors. +pub fn execute_project_history_retrieval_cli( + invocation: &ProjectHistoryRetrievalCliInvocation, +) -> Result { + let addr = require_loopback_host(&invocation.host)?; + let request = compose_project_history_retrieval_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 retrieval never prints scientific acceptance. +/// +/// Evidence text and findings belong to the stored projection and are admitted. +/// RMSE, bias, coverage, SE-gate, and causal-score keys fail closed. +/// +/// # Errors +/// +/// Returns [`ApiError::InvalidWirePayload`] when a receipt carries metric keys, +/// `tepp.scientific_acceptance.v1`, a non-association inference status, or a +/// success body that is not a stored `ProjectHistoryProjection`. +pub fn render_project_history_retrieval_cli_stdout( + invocation: &ProjectHistoryRetrievalCliInvocation, + response: &NaruonLiveResponse, +) -> Result { + invocation.validate()?; + if response.body.is_empty() { + return Err(ApiError::InvalidWirePayload); + } + refuse_metrics_on_project_history_retrieval_payload(&response.body)?; + if !(200..300).contains(&response.status_code) { + let expected_code = match response.status_code { + 400 => "invalid_wire_payload", + 403 => "authorization_denied", + 413 => "limit_exceeded", + 422 => "unsupported_contract_version", + _ => return Err(ApiError::InvalidWirePayload), + }; + let envelope: ErrorEnvelope = + serde_json::from_str(&response.body).map_err(|_| ApiError::InvalidWirePayload)?; + if envelope.error_code() != expected_code { + return Err(ApiError::InvalidWirePayload); + } + return envelope.to_json(); + } + if response.status_code != 200 { + return Err(ApiError::InvalidWirePayload); + } + let receipt = ProjectHistoryRetrievalReceipt::from_json(&response.body)?; + if receipt.tenant_workspace_id != invocation.tenant_workspace_id + || receipt.idempotency_key != invocation.idempotency_key + { + return Err(ApiError::InvalidWirePayload); + } + receipt.projection.to_json() +} + +fn parse_http_response(bytes: &[u8]) -> Result { + let text = std::str::from_utf8(bytes).map_err(|_| ApiError::InvalidWirePayload)?; + let (header_block, body) = text + .split_once("\r\n\r\n") + .ok_or(ApiError::InvalidWirePayload)?; + if header_block.len() > NARUON_LIVE_HEADER_BYTE_LIMIT { + return Err(ApiError::LimitExceeded); + } + let mut lines = header_block.split("\r\n"); + let status_line = lines.next().ok_or(ApiError::InvalidWirePayload)?; + let (version, status) = status_line + .split_once(' ') + .ok_or(ApiError::InvalidWirePayload)?; + if version != "HTTP/1.1" { + return Err(ApiError::InvalidWirePayload); + } + let (code, reason) = status.split_once(' ').ok_or(ApiError::InvalidWirePayload)?; + let code = code + .parse::() + .map_err(|_| ApiError::InvalidWirePayload)?; + let reason_phrase = static_reason(code)?; + if reason != reason_phrase { + return Err(ApiError::InvalidWirePayload); + } + let mut content_length = None; + let mut seen = HashSet::new(); + for (index, line) in lines.enumerate() { + if index >= NARUON_LIVE_HEADER_COUNT_LIMIT { + return Err(ApiError::LimitExceeded); + } + let (name, value) = line.split_once(':').ok_or(ApiError::InvalidWirePayload)?; + if !valid_http_field_name(name) + || value + .chars() + .any(|character| character.is_control() && character != '\t') + || !seen.insert(name.to_ascii_lowercase()) + || name.eq_ignore_ascii_case("transfer-encoding") + { + return Err(ApiError::InvalidWirePayload); + } + if name.eq_ignore_ascii_case("content-length") { + content_length = Some( + value + .trim() + .parse::() + .map_err(|_| ApiError::InvalidWirePayload)?, + ); + } + } + let declared = content_length.ok_or(ApiError::InvalidWirePayload)?; + if declared > DEFAULT_PROJECT_HISTORY_BYTE_LIMIT { + return Err(ApiError::LimitExceeded); + } + if declared != body.len() { + return Err(ApiError::InvalidWirePayload); + } + Ok(NaruonLiveResponse { + status_code: code, + reason_phrase, + body: body.to_owned(), + }) +} + +fn static_reason(code: u16) -> Result<&'static str, ApiError> { + match code { + 200 => Ok("OK"), + 202 => Ok("Accepted"), + 400 => Ok("Bad Request"), + 403 => Ok("Forbidden"), + 413 => Ok("Payload Too Large"), + 422 => Ok("Unprocessable Entity"), + _ => Err(ApiError::InvalidWirePayload), + } +} + +/// Read stdin leftover bytes on a non-terminal; retrieval GET admits empty. +/// +/// # Errors +/// +/// Returns [`ApiError::InvalidWirePayload`] when stdin cannot be read and +/// [`ApiError::LimitExceeded`] when leftover stdin exceeds the project-history +/// wire limit. +pub fn read_project_history_retrieval_cli_stdin( + stdin_is_terminal: bool, + mut stdin: impl Read, +) -> Result { + if stdin_is_terminal { + Ok(String::new()) + } else { + let bytes = read_bounded(&mut stdin, DEFAULT_PROJECT_HISTORY_BYTE_LIMIT)?; + String::from_utf8(bytes).map_err(|_| ApiError::InvalidWirePayload) + } +} + +fn read_bounded(reader: &mut impl Read, maximum_bytes: usize) -> Result, ApiError> { + let mut bytes = Vec::new(); + reader + .take((maximum_bytes + 1) as u64) + .read_to_end(&mut bytes) + .map_err(|error| map_io_error(&error))?; + if bytes.len() > maximum_bytes { + return Err(ApiError::LimitExceeded); + } + Ok(bytes) +} + +fn valid_http_field_name(name: &str) -> bool { + !name.is_empty() + && name.bytes().all(|byte| { + byte.is_ascii_alphanumeric() + || matches!( + byte, + b'!' | b'#' + | b'$' + | b'%' + | b'&' + | b'\'' + | b'*' + | b'+' + | b'-' + | b'.' + | b'^' + | b'_' + | b'`' + | b'|' + | b'~' + ) + }) +} + +#[cfg(test)] +mod branch_coverage_tests { + use std::io::{self, Cursor, Read}; + + use super::{ + ProjectHistoryRetrievalCliInvocation, ProjectHistoryRetrievalCliVerb, + loopback_http1_from_project_history_retrieval_exchange, parse_http_response, + read_project_history_retrieval_cli_stdin, valid_http_field_name, + }; + use crate::{ + ApiError, DEFAULT_PROJECT_HISTORY_BYTE_LIMIT, LINEAGEWEAVE_CONSUMER_CODE, + lineageweave_project_history_retrieval_exchange, + }; + + fn invocation() -> ProjectHistoryRetrievalCliInvocation { + ProjectHistoryRetrievalCliInvocation { + verb: ProjectHistoryRetrievalCliVerb::Get, + host: "127.0.0.1:18081".into(), + origin: "https://tepp.example.test".into(), + consumer: LINEAGEWEAVE_CONSUMER_CODE.into(), + idempotency_key: "idem-a".into(), + tenant_workspace_id: "tenant-a".into(), + body: String::new(), + } + } + + #[test] + fn invocation_and_flag_error_arms_are_covered() { + let mut value = invocation(); + value.origin = "http://tepp.example.test".into(); + assert_eq!(value.validate(), Err(ApiError::InvalidWirePayload)); + value = invocation(); + value.consumer = "naruon".into(); + assert_eq!(value.validate(), Err(ApiError::InvalidWirePayload)); + value = invocation(); + value.body = "{}".into(); + assert_eq!(value.validate(), Err(ApiError::InvalidWirePayload)); + value = invocation(); + value.tenant_workspace_id = "tenant\nother".into(); + assert_eq!(value.validate(), Err(ApiError::InvalidWirePayload)); + value = invocation(); + value.origin = "https://bad/path".into(); + assert!(super::compose_project_history_retrieval_cli_http(&value).is_err()); + + for args in [ + vec!["get", "host"], + vec!["get", "--host"], + vec!["get", "--host", "a", "--host", "b"], + vec!["get", "--host", ""], + ] { + assert!(ProjectHistoryRetrievalCliInvocation::from_args(args, "").is_err()); + } + } + + #[test] + fn exchange_header_and_target_error_arms_are_covered() { + let origin = "https://tepp.example.test"; + let base = lineageweave_project_history_retrieval_exchange(origin, "tenant-a", "idem-a") + .expect("exchange"); + let mut cases = Vec::new(); + let mut value = base.clone(); + value.body = "{}".into(); + cases.push(value); + let mut value = base.clone(); + value.target_url = "http://tepp.example.test/v1/project-histories/idem-a".into(); + cases.push(value); + let mut value = base.clone(); + value.target_url = "https://tepp.example.test".into(); + cases.push(value); + for (name, header_value) in [("bad name", "x"), ("x-good", "bad\nvalue")] { + let mut value = base.clone(); + value.headers.push((name.into(), header_value.into())); + cases.push(value); + } + let mut value = base.clone(); + value + .headers + .push(("content-type".into(), "application/json".into())); + cases.push(value); + for index in 0..base.headers.len() { + let mut value = base.clone(); + value.headers.remove(index); + cases.push(value); + } + for value in cases { + assert!( + loopback_http1_from_project_history_retrieval_exchange(&value, "127.0.0.1:18081",) + .is_err() + ); + } + } + + #[test] + fn response_parser_and_reader_error_arms_are_covered() { + use std::fmt::Write as _; + + let oversized_header = "x".repeat(crate::NARUON_LIVE_HEADER_BYTE_LIMIT + 1); + let mut many_headers = String::new(); + for index in 0..=crate::NARUON_LIVE_HEADER_COUNT_LIMIT { + write!(many_headers, "x-{index}: b\r\n").expect("string write"); + } + let cases = [ + vec![0xff], + b"HTTP/1.1 200 OK".to_vec(), + format!("{oversized_header}\r\n\r\n").into_bytes(), + b"HTTP/1.0 200 OK\r\ncontent-length: 0\r\n\r\n".to_vec(), + b"HTTP/1.1 nope\r\ncontent-length: 0\r\n\r\n".to_vec(), + b"HTTP/1.1 999 Unknown\r\ncontent-length: 0\r\n\r\n".to_vec(), + b"HTTP/1.1 200 Bad\r\ncontent-length: 0\r\n\r\n".to_vec(), + b"HTTP/1.1 200 OK\r\nbad\r\n\r\n".to_vec(), + b"HTTP/1.1 200 OK\r\nbad name: x\r\ncontent-length: 0\r\n\r\n".to_vec(), + b"HTTP/1.1 200 OK\r\nx-good: bad\x01value\r\ncontent-length: 0\r\n\r\n".to_vec(), + b"HTTP/1.1 200 OK\r\nx-good: a\r\nx-good: b\r\ncontent-length: 0\r\n\r\n".to_vec(), + b"HTTP/1.1 200 OK\r\ntransfer-encoding: chunked\r\n\r\n".to_vec(), + b"HTTP/1.1 200 OK\r\ncontent-length: x\r\n\r\n".to_vec(), + b"HTTP/1.1 200 OK\r\n\r\n".to_vec(), + b"HTTP/1.1 200 OK\r\ncontent-length: 1\r\n\r\n".to_vec(), + format!( + "HTTP/1.1 200 OK\r\ncontent-length: {}\r\n\r\n", + DEFAULT_PROJECT_HISTORY_BYTE_LIMIT + 1 + ) + .into_bytes(), + format!("HTTP/1.1 200 OK\r\n{many_headers}content-length: 0\r\n\r\n").into_bytes(), + ]; + for bytes in cases { + assert!(parse_http_response(&bytes).is_err()); + } + for (code, reason) in [ + (202, "Accepted"), + (400, "Bad Request"), + (403, "Forbidden"), + (413, "Payload Too Large"), + (422, "Unprocessable Entity"), + ] { + let response = format!("HTTP/1.1 {code} {reason}\r\ncontent-length: 0\r\n\r\n"); + assert_eq!( + parse_http_response(response.as_bytes()) + .expect("response") + .status_code, + code + ); + } + assert!(read_project_history_retrieval_cli_stdin(false, Cursor::new([0xff])).is_err()); + assert!( + read_project_history_retrieval_cli_stdin( + false, + Cursor::new(vec![b'a'; DEFAULT_PROJECT_HISTORY_BYTE_LIMIT + 1]), + ) + .is_err() + ); + assert!(read_project_history_retrieval_cli_stdin(false, FailingReader).is_err()); + assert!(!valid_http_field_name("")); + assert!(!valid_http_field_name("bad name")); + } + + struct FailingReader; + + impl Read for FailingReader { + fn read(&mut self, _buffer: &mut [u8]) -> io::Result { + Err(io::Error::other("redacted")) + } + } +} diff --git a/crates/tepp_api/src/project_history_retrieval_http.rs b/crates/tepp_api/src/project_history_retrieval_http.rs new file mode 100644 index 000000000..9c6c1b869 --- /dev/null +++ b/crates/tepp_api/src/project_history_retrieval_http.rs @@ -0,0 +1,433 @@ +//! Provider-owned project-history GET-by-id contracts. +//! +//! GAP-003A unique slice: `GET /v1/project-histories/{idempotency_key}` +//! returns one accepted cutoff-safe `ProjectHistoryProjection` on +//! `AnalysisRunLiveService` / `tepp-loopback` so operators who hold a +//! collection identity do not replay POST. `tepp.scientific_acceptance.v1` +//! never appears. The retrieval does not infer causality. This module does +//! not duplicate collection GET (#424), collection CLI (#428), project-history +//! POST CLI (#420), temporal-context CLI (#414), export retrieval GET (#411), +//! analysis-run GET-by-id (#359), or GAP-010 Figma/export. Persistence remains +//! GAP-003B. `NaruonLiveService` stays POST-only. + +use crate::naruon_http::{NaruonHttpExchange, compose_https_target}; +use crate::project_history::validate_project_history_registry_identity; +use crate::wire::{from_json, require_byte_limit, require_nonempty, to_json_with_limit}; +use crate::{ + ApiError, DEFAULT_PROJECT_HISTORY_BYTE_LIMIT, PROJECT_HISTORY_PATH, ProjectHistoryProjection, +}; +use serde::{Deserialize, Serialize}; + +/// Maximum opaque idempotency-key length on the retrieval path. +pub const PROJECT_HISTORY_RETRIEVAL_ID_MAX_LEN: usize = 256; + +/// Header carrying the authorized project-history tenant on GET-by-id. +pub const PROJECT_HISTORY_RETRIEVAL_TENANT_HEADER: &str = "tepp-tenant-workspace-id"; + +/// Identity-bound receipt returned by project-history GET-by-id. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ProjectHistoryRetrievalReceipt { + /// Tenant workspace authorized by the retrieval request. + pub tenant_workspace_id: String, + /// Idempotency key decoded from the retrieval path. + pub idempotency_key: String, + /// Stored cutoff-safe project-history projection. + pub projection: ProjectHistoryProjection, +} + +impl ProjectHistoryRetrievalReceipt { + /// Construct and validate an identity-bound retrieval receipt. + /// + /// # Errors + /// + /// Returns a registry-identity or projection validation error. + pub fn new( + tenant_workspace_id: impl Into, + idempotency_key: impl Into, + projection: ProjectHistoryProjection, + ) -> Result { + let receipt = Self { + tenant_workspace_id: tenant_workspace_id.into(), + idempotency_key: idempotency_key.into(), + projection, + }; + receipt.validate()?; + Ok(receipt) + } + + /// Parse and validate a bounded retrieval receipt. + /// + /// # Errors + /// + /// Returns a size, JSON, identity, or projection validation error. + pub fn from_json(payload: &str) -> Result { + require_byte_limit(payload, DEFAULT_PROJECT_HISTORY_BYTE_LIMIT)?; + let receipt: Self = from_json(payload)?; + receipt.validate()?; + Ok(receipt) + } + + /// Serialize a fully validated retrieval receipt. + /// + /// # Errors + /// + /// Returns an identity, projection, or size validation error. + pub fn to_json(&self) -> Result { + self.validate()?; + to_json_with_limit(self, DEFAULT_PROJECT_HISTORY_BYTE_LIMIT) + } + + fn validate(&self) -> Result<(), ApiError> { + validate_project_history_registry_identity(&self.tenant_workspace_id)?; + validate_project_history_registry_identity(&self.idempotency_key)?; + self.projection.to_json()?; + Ok(()) + } +} + +const FORBIDDEN_RETRIEVAL_KEYS: [&str; 12] = [ + "rmse", + "rmse_standard_error", + "mean_bias", + "bias_standard_error", + "interval_coverage", + "coverage_wilson_lower", + "coverage_wilson_upper", + "temporal_order_accuracy", + "se_gate_accepted", + "se_gate_k", + "scientific_acceptance", + "causal_score", +]; + +/// Extract the opaque idempotency key from `GET /v1/project-histories/{key}`. +/// +/// # Errors +/// +/// Returns [`ApiError::InvalidWirePayload`] for the collection path, extra +/// segments, a hostile encoding, or an empty identity, and +/// [`ApiError::LimitExceeded`] when the decoded identity exceeds +/// [`PROJECT_HISTORY_RETRIEVAL_ID_MAX_LEN`]. +pub fn project_history_retrieval_path_id(path: &str) -> Result { + let remainder = path + .strip_prefix(PROJECT_HISTORY_PATH) + .ok_or(ApiError::InvalidWirePayload)?; + let encoded = remainder + .strip_prefix('/') + .ok_or(ApiError::InvalidWirePayload)?; + if encoded.is_empty() || encoded.contains('/') { + return Err(ApiError::InvalidWirePayload); + } + let idempotency_key = decode_path_segment(encoded)?; + require_nonempty(&idempotency_key)?; + if idempotency_key.len() > PROJECT_HISTORY_RETRIEVAL_ID_MAX_LEN { + return Err(ApiError::LimitExceeded); + } + Ok(idempotency_key) +} + +/// Refuse retrieval JSON that already carries scientific-metric or causal keys. +/// +/// Empty payloads are admitted for the GET request body. Evidence text and +/// findings belong to the stored projection and are not refused here. +/// +/// # Errors +/// +/// Returns [`ApiError::InvalidWirePayload`] when a forbidden metric or causal +/// key is present, the scientific-acceptance schema is claimed, or nonempty +/// JSON is not an object. +pub fn refuse_metrics_on_project_history_retrieval_payload(payload: &str) -> Result<(), ApiError> { + if payload.trim().is_empty() { + return Ok(()); + } + let value: serde_json::Value = + serde_json::from_str(payload).map_err(|_| ApiError::InvalidWirePayload)?; + if !value.is_object() { + return Err(ApiError::InvalidWirePayload); + } + refuse_metrics_on_json(&value) +} + +fn refuse_metrics_on_json(value: &serde_json::Value) -> Result<(), ApiError> { + match value { + serde_json::Value::Object(object) => { + if object + .get("schema_version") + .and_then(serde_json::Value::as_str) + == Some("tepp.scientific_acceptance.v1") + { + return Err(ApiError::InvalidWirePayload); + } + if FORBIDDEN_RETRIEVAL_KEYS + .iter() + .any(|key| object.contains_key(*key)) + { + return Err(ApiError::InvalidWirePayload); + } + for nested in object.values() { + refuse_metrics_on_json(nested)?; + } + Ok(()) + } + serde_json::Value::Array(items) => { + for nested in items { + refuse_metrics_on_json(nested)?; + } + Ok(()) + } + _ => Ok(()), + } +} + +/// Build a provider-owned `GET` project-history retrieval exchange. +/// +/// The builder refuses non-`https` origins and empty or oversized identities. +/// It does not inject credentials. The GET body is empty. The identity +/// travels in the path. +/// +/// # Errors +/// +/// Returns [`ApiError::InvalidWirePayload`] for a non-`https` origin or empty +/// identity, and [`ApiError::LimitExceeded`] when the identity exceeds +/// [`PROJECT_HISTORY_RETRIEVAL_ID_MAX_LEN`] bytes. +pub fn lineageweave_project_history_retrieval_exchange( + origin: &str, + tenant_workspace_id: &str, + idempotency_key: &str, +) -> Result { + validate_project_history_registry_identity(tenant_workspace_id)?; + validate_project_history_registry_identity(idempotency_key)?; + let encoded_id = encode_path_segment(idempotency_key); + let target_path = format!("{PROJECT_HISTORY_PATH}/{encoded_id}"); + let target_url = compose_https_target(origin, &target_path)?; + Ok(NaruonHttpExchange { + method: "GET", + target_url, + headers: vec![ + ("content-type".into(), "application/json".into()), + ("tepp-consumer".into(), "lineageweave".into()), + ("tepp-contract-version".into(), "1".into()), + ( + PROJECT_HISTORY_RETRIEVAL_TENANT_HEADER.into(), + tenant_workspace_id.into(), + ), + ], + body: String::new(), + }) +} + +fn encode_path_segment(value: &str) -> String { + let mut out = String::with_capacity(value.len() + value.len() / 2); + let hex = b"0123456789ABCDEF"; + for byte in value.bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => { + out.push(byte as char); + } + _ => { + out.push('%'); + out.push(hex[usize::from(byte >> 4)] as char); + out.push(hex[usize::from(byte & 0x0F)] as char); + } + } + } + out +} + +fn decode_path_segment(value: &str) -> Result { + let mut out = Vec::with_capacity(value.len()); + let bytes = value.as_bytes(); + let mut index = 0; + while index < bytes.len() { + match bytes[index] { + b'%' => { + if index + 2 >= bytes.len() { + return Err(ApiError::InvalidWirePayload); + } + let hi = from_hex(bytes[index + 1])?; + let lo = from_hex(bytes[index + 2])?; + out.push((hi << 4) | lo); + index += 3; + } + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => { + out.push(bytes[index]); + index += 1; + } + _ => return Err(ApiError::InvalidWirePayload), + } + } + let decoded = String::from_utf8(out).map_err(|_| ApiError::InvalidWirePayload)?; + if decoded.chars().any(char::is_control) { + return Err(ApiError::InvalidWirePayload); + } + Ok(decoded) +} + +fn from_hex(byte: u8) -> Result { + match byte { + b'0'..=b'9' => Ok(byte - b'0'), + b'A'..=b'F' => Ok(byte - b'A' + 10), + b'a'..=b'f' => Ok(byte - b'a' + 10), + _ => Err(ApiError::InvalidWirePayload), + } +} + +#[cfg(test)] +mod tests { + use super::{ + PROJECT_HISTORY_RETRIEVAL_ID_MAX_LEN, lineageweave_project_history_retrieval_exchange, + project_history_retrieval_path_id, refuse_metrics_on_project_history_retrieval_payload, + }; + use crate::ApiError; + + #[test] + fn retrieval_exchange_is_metric_free_get_without_credentials() { + let exchange = lineageweave_project_history_retrieval_exchange( + "https://tepp.example.test", + "tenant-a", + "idem-a", + ) + .expect("exchange"); + assert_eq!(exchange.method, "GET"); + assert!( + exchange + .target_url + .ends_with("/v1/project-histories/idem-a") + ); + assert!(exchange.body.is_empty()); + assert!( + !exchange + .headers + .iter() + .any(|(name, _)| name.eq_ignore_ascii_case("authorization")) + ); + assert_eq!( + project_history_retrieval_path_id("/v1/project-histories/idem-a").expect("id"), + "idem-a" + ); + let encoded = lineageweave_project_history_retrieval_exchange( + "https://tepp.example.test", + "tenant-a", + "idem/slash", + ) + .expect("encoded"); + assert!(encoded.target_url.contains("idem%2Fslash")); + assert_eq!( + project_history_retrieval_path_id("/v1/project-histories/idem%2Fslash") + .expect("decoded slash"), + "idem/slash" + ); + } + + #[test] + fn retrieval_path_and_payload_fail_closed() { + assert_eq!( + project_history_retrieval_path_id("/v1/project-histories"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + project_history_retrieval_path_id("/v1/project-histories/"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + project_history_retrieval_path_id("/v1/project-histories/idem-a/extra"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + project_history_retrieval_path_id("/v1/analysis-runs/idem-a"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + project_history_retrieval_path_id(&format!( + "/v1/project-histories/{}", + "a".repeat(PROJECT_HISTORY_RETRIEVAL_ID_MAX_LEN + 1) + )), + Err(ApiError::LimitExceeded) + ); + assert_eq!( + lineageweave_project_history_retrieval_exchange( + "http://insecure.example", + "tenant-a", + "idem-a", + ), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + lineageweave_project_history_retrieval_exchange( + "https://tepp.example.test", + "tenant-a", + "", + ), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + refuse_metrics_on_project_history_retrieval_payload(""), + Ok(()) + ); + assert_eq!( + refuse_metrics_on_project_history_retrieval_payload(r#"{"findings":[]}"#), + Ok(()) + ); + assert_eq!( + refuse_metrics_on_project_history_retrieval_payload(r#"{"rmse":1.0}"#), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + refuse_metrics_on_project_history_retrieval_payload(r#"{"causal_score":1}"#), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + refuse_metrics_on_project_history_retrieval_payload( + r#"{"schema_version":"tepp.scientific_acceptance.v1"}"# + ), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + refuse_metrics_on_project_history_retrieval_payload( + r#"{"evidence_text":"mentions tepp.scientific_acceptance.v1 as untrusted text"}"# + ), + Ok(()) + ); + assert_eq!( + refuse_metrics_on_project_history_retrieval_payload("[]"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + refuse_metrics_on_project_history_retrieval_payload(r#"{"nested":[1]}"#), + Ok(()) + ); + assert_eq!( + project_history_retrieval_path_id("/v1/project-histories/%zz"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + lineageweave_project_history_retrieval_exchange( + "https://tepp.example.test", + "\ntenant", + "idem-a", + ), + Err(ApiError::InvalidWirePayload) + ); + } + + #[test] + fn retrieval_path_decoding_covers_hostile_and_encoded_bytes() { + for path in [ + "/v1/project-histories/%", + "/v1/project-histories/idem!", + "/v1/project-histories/%00", + "/v1/project-histories/%FF", + ] { + assert_eq!( + project_history_retrieval_path_id(path), + Err(ApiError::InvalidWirePayload) + ); + } + assert_eq!( + project_history_retrieval_path_id("/v1/project-histories/idem%2fslash") + .expect("lowercase hex"), + "idem/slash" + ); + } +} diff --git a/crates/tepp_api/tests/project_history_collection_http_contract.rs b/crates/tepp_api/tests/project_history_collection_http_contract.rs index 00e0a1df4..976cd7aff 100644 --- a/crates/tepp_api/tests/project_history_collection_http_contract.rs +++ b/crates/tepp_api/tests/project_history_collection_http_contract.rs @@ -22,9 +22,13 @@ fn project_history_collection_is_metric_free_get_without_credentials() { 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"); + let exchange = lineageweave_project_history_collection_exchange( + "https://tepp.example.test", + "tenant-a", + None, + None, + ) + .expect("exchange"); assert_eq!(exchange.method, "GET"); assert!(exchange.target_url.ends_with("/v1/project-histories")); assert!( @@ -46,7 +50,12 @@ fn project_history_collection_refuses_metrics_evidence_and_insecure_origins() { Err(ApiError::InvalidWirePayload) ); assert_eq!( - lineageweave_project_history_collection_exchange("http://insecure.example", None, None), + lineageweave_project_history_collection_exchange( + "http://insecure.example", + "tenant-a", + None, + None, + ), Err(ApiError::InvalidWirePayload) ); assert!(!is_project_history_collection_path("/v1/analysis-runs")); diff --git a/crates/tepp_api/tests/project_history_retrieval_cli_contract.rs b/crates/tepp_api/tests/project_history_retrieval_cli_contract.rs new file mode 100644 index 000000000..0f0021192 --- /dev/null +++ b/crates/tepp_api/tests/project_history_retrieval_cli_contract.rs @@ -0,0 +1,508 @@ +//! Contract tests for the `LineageWeave` project-history retrieval loopback CLI. + +use tepp_api::{ + AnalysisRunLiveService, ApiError, LINEAGEWEAVE_CONSUMER_CODE, NARUON_CONSUMER_CODE, + NaruonHttpExchange, NaruonLiveResponse, NaruonLiveService, PROJECT_HISTORY_CONTRACT_VERSION, + PROJECT_HISTORY_PATH, PROJECT_HISTORY_RETRIEVAL_ID_MAX_LEN, ProjectHistoryEvent, + ProjectHistoryProjection, ProjectHistoryRequest, ProjectHistoryRetrievalCliInvocation, + ProjectHistoryRetrievalCliVerb, ProjectHistoryRetrievalReceipt, + compose_project_history_retrieval_cli_http, dispatch_project_history_retrieval_cli, + execute_project_history_retrieval_cli, lineageweave_project_history_retrieval_exchange, + loopback_http1_from_project_history_retrieval_exchange, + read_project_history_retrieval_cli_stdin, render_project_history_retrieval_cli_stdout, +}; + +const ORIGIN: &str = "https://tepp.example.test"; +const SCHEMA: &str = "tepp.scientific_acceptance.v1"; + +fn sample_request(idempotency_key: &str, project_key: &str) -> ProjectHistoryRequest { + ProjectHistoryRequest { + contract_version: PROJECT_HISTORY_CONTRACT_VERSION, + idempotency_key: idempotency_key.into(), + tenant_workspace_id: "history-tenant".into(), + project_key: project_key.into(), + project_name: "Project".into(), + knowledge_cutoff: "2026-08-19T23:59:59Z".into(), + focus_event_id: "focus".into(), + events: vec![ProjectHistoryEvent { + event_id: "focus".into(), + event_type_code: "voc_received".into(), + event_title: "VOC".into(), + occurred_at: "2026-08-19T09:00:00Z".into(), + available_at: "2026-08-19T10:00:00Z".into(), + source_post_id: "post".into(), + evidence_text: "explicit evidence".into(), + actor_ids: Vec::new(), + }], + } +} + +fn project_history_post(request: &ProjectHistoryRequest) -> String { + let body = request.to_json().expect("history json"); + format!( + "POST {PROJECT_HISTORY_PATH} HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: {LINEAGEWEAVE_CONSUMER_CODE}\r\ntepp-contract-version: 1\r\nidempotency-key: {}\r\ncontent-length: {}\r\n\r\n{body}", + request.idempotency_key, + body.len() + ) +} + +fn get_args<'a>(host: &'a str, idempotency_key: &'a str, consumer: &'a str) -> [&'a str; 11] { + [ + "get", + "--host", + host, + "--origin", + ORIGIN, + "--consumer", + consumer, + "--idempotency-key", + idempotency_key, + "--tenant-workspace-id", + "history-tenant", + ] +} + +#[test] +fn verbs_and_from_args_fail_closed() { + assert_eq!( + ProjectHistoryRetrievalCliVerb::parse("get").expect("get"), + ProjectHistoryRetrievalCliVerb::Get + ); + assert_eq!(ProjectHistoryRetrievalCliVerb::Get.as_str(), "get"); + assert_eq!( + ProjectHistoryRetrievalCliVerb::parse("list"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + ProjectHistoryRetrievalCliVerb::parse("GET"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + ProjectHistoryRetrievalCliInvocation::from_args(Vec::::new(), "").unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + ProjectHistoryRetrievalCliInvocation::from_args( + get_args("8.8.8.8:80", "idem-a", LINEAGEWEAVE_CONSUMER_CODE), + "", + ) + .unwrap_err(), + ApiError::AuthorizationDenied + ); + assert_eq!( + ProjectHistoryRetrievalCliInvocation::from_args( + [ + "get", + "--host", + "127.0.0.1:18081", + "--origin", + "http://tepp.example.test", + "--idempotency-key", + "idem-a", + ], + "", + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + ProjectHistoryRetrievalCliInvocation::from_args( + [ + "get", + "--host", + "localhost:18081", + "--origin", + ORIGIN, + "--idempotency-key", + "idem-a", + ], + "", + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + ProjectHistoryRetrievalCliInvocation::from_args( + [ + "get", + "--host", + "127.0.0.1:18081", + "--origin", + ORIGIN, + "--authorization", + "secret", + "--idempotency-key", + "idem-a", + ], + "", + ) + .unwrap_err(), + ApiError::AuthorizationDenied + ); +} + +#[test] +fn from_args_accepts_slash_and_refuses_naruon_body_size_tenant_and_pagination() { + assert_eq!( + ProjectHistoryRetrievalCliInvocation::from_args( + get_args("127.0.0.1:18081", "idem-a", NARUON_CONSUMER_CODE), + "", + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + ProjectHistoryRetrievalCliInvocation::from_args( + get_args("127.0.0.1:18081", "idem-a", "unpublished"), + "", + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + ProjectHistoryRetrievalCliInvocation::from_args( + get_args("127.0.0.1:18081", "idem-a", LINEAGEWEAVE_CONSUMER_CODE), + "{}", + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert!( + ProjectHistoryRetrievalCliInvocation::from_args( + get_args("127.0.0.1:18081", "idem/slash", LINEAGEWEAVE_CONSUMER_CODE), + "", + ) + .is_ok() + ); + assert_eq!( + ProjectHistoryRetrievalCliInvocation::from_args( + get_args( + "127.0.0.1:18081", + &"a".repeat(PROJECT_HISTORY_RETRIEVAL_ID_MAX_LEN + 1), + LINEAGEWEAVE_CONSUMER_CODE, + ), + "", + ) + .unwrap_err(), + ApiError::LimitExceeded + ); + assert_eq!( + ProjectHistoryRetrievalCliInvocation::from_args( + [ + "get", + "--host", + "127.0.0.1:18081", + "--origin", + ORIGIN, + "--idempotency-key", + "idem-a", + "--page-limit", + "1", + ], + "", + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + ProjectHistoryRetrievalCliInvocation::from_args( + [ + "get", + "--host", + "127.0.0.1:18081", + "--origin", + ORIGIN, + "--idempotency-key", + "idem-a", + ], + "", + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + let mut hostile_tenant = get_args("127.0.0.1:18081", "idem-a", LINEAGEWEAVE_CONSUMER_CODE); + hostile_tenant[10] = "tenant\nother"; + assert_eq!( + ProjectHistoryRetrievalCliInvocation::from_args(hostile_tenant, "").unwrap_err(), + ApiError::InvalidWirePayload + ); +} + +#[test] +fn compose_is_typed_https_get_without_credentials() { + let invocation = ProjectHistoryRetrievalCliInvocation::from_args( + get_args("127.0.0.1:18081", "idem-a", LINEAGEWEAVE_CONSUMER_CODE), + "", + ) + .expect("invocation"); + let http = compose_project_history_retrieval_cli_http(&invocation).expect("http"); + assert!(http.starts_with("GET /v1/project-histories/idem-a HTTP/1.1")); + assert!(http.contains("tepp-consumer: lineageweave")); + assert!(http.contains("tepp-tenant-workspace-id: history-tenant")); + assert!(http.contains("content-length: 0")); + assert!(!http.to_ascii_lowercase().contains("authorization")); + assert!(!http.contains("idempotency-key:")); + assert!(!http.contains("tepp-page-limit")); + assert!(!http.contains("rmse")); + assert!(!http.contains(SCHEMA)); + assert!(!http.contains("/analysis-runs")); +} + +#[test] +fn lineageweave_cli_retrieves_stored_projection_and_naruon_live_stays_post_only() { + let mut service = AnalysisRunLiveService::new(); + let request = sample_request("idem-a", "project-a"); + let posted = service.handle_http_request(&project_history_post(&request)); + assert_eq!(posted.status_code, 200, "{}", posted.body); + let stored = ProjectHistoryProjection::from_json(&posted.body).expect("stored"); + + let invocation = ProjectHistoryRetrievalCliInvocation::from_args( + get_args("127.0.0.1:18081", "idem-a", LINEAGEWEAVE_CONSUMER_CODE), + "", + ) + .expect("invocation"); + let got = dispatch_project_history_retrieval_cli(&mut service, &invocation).expect("get"); + assert_eq!(got.status_code, 200, "{}", got.body); + let stdout = render_project_history_retrieval_cli_stdout(&invocation, &got).expect("out"); + let retrieved = ProjectHistoryProjection::from_json(&stdout).expect("retrieved"); + assert_eq!(retrieved, stored); + assert_eq!(retrieved.inference_status, "temporal_association_only"); + assert!(stdout.contains("evidence_text")); + assert!(!stdout.contains("rmse")); + assert!(!stdout.contains(SCHEMA)); + assert!(!stdout.contains("causal_score")); + + let mut mismatched = ProjectHistoryRetrievalReceipt::from_json(&got.body).expect("receipt"); + mismatched.tenant_workspace_id = "other-tenant".into(); + assert_eq!( + render_project_history_retrieval_cli_stdout( + &invocation, + &NaruonLiveResponse { + status_code: 200, + reason_phrase: "OK", + body: mismatched.to_json().expect("mismatched receipt"), + }, + ), + Err(ApiError::InvalidWirePayload) + ); + let mut mismatched = ProjectHistoryRetrievalReceipt::from_json(&got.body).expect("receipt"); + mismatched.idempotency_key = "other-id".into(); + for status_code in [200, 202] { + assert_eq!( + render_project_history_retrieval_cli_stdout( + &invocation, + &NaruonLiveResponse { + status_code, + reason_phrase: "OK", + body: mismatched.to_json().expect("mismatched receipt"), + }, + ), + Err(ApiError::InvalidWirePayload) + ); + } + + let missing = ProjectHistoryRetrievalCliInvocation::from_args( + get_args("127.0.0.1:18081", "missing", LINEAGEWEAVE_CONSUMER_CODE), + "", + ) + .expect("missing"); + let denied = dispatch_project_history_retrieval_cli(&mut service, &missing).expect("denied"); + assert_eq!(denied.status_code, 400, "{}", denied.body); + let denied_stdout = + render_project_history_retrieval_cli_stdout(&missing, &denied).expect("err"); + assert!(denied_stdout.contains("invalid_wire_payload")); + assert!(!denied_stdout.contains(SCHEMA)); + + let mut naruon = NaruonLiveService::new(); + let composed = compose_project_history_retrieval_cli_http(&invocation).expect("composed"); + assert_eq!(naruon.handle_http_request(&composed).status_code, 400); +} + +#[test] +fn render_refuses_metrics_schema_and_empty_success() { + let invocation = ProjectHistoryRetrievalCliInvocation::from_args( + get_args("127.0.0.1:18081", "idem-a", LINEAGEWEAVE_CONSUMER_CODE), + "", + ) + .expect("invocation"); + assert_eq!( + render_project_history_retrieval_cli_stdout( + &invocation, + &NaruonLiveResponse { + status_code: 200, + reason_phrase: "OK", + body: String::new(), + } + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + render_project_history_retrieval_cli_stdout( + &invocation, + &NaruonLiveResponse { + status_code: 200, + reason_phrase: "OK", + body: r#"{"contract_version":1,"rmse":1.0}"#.into(), + } + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + render_project_history_retrieval_cli_stdout( + &invocation, + &NaruonLiveResponse { + status_code: 200, + reason_phrase: "OK", + body: format!(r#"{{"schema_version":"{SCHEMA}"}}"#), + } + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + for (status_code, error_code) in [(500, "invalid_wire_payload"), (400, "limit_exceeded")] { + assert_eq!( + render_project_history_retrieval_cli_stdout( + &invocation, + &NaruonLiveResponse { + status_code, + reason_phrase: "Error", + body: format!( + r#"{{"error_code":"{error_code}","message":"redacted","request_id":"req-1","retryable":false}}"# + ), + }, + ), + Err(ApiError::InvalidWirePayload) + ); + } + for (status_code, error_code) in [ + (403, "authorization_denied"), + (413, "limit_exceeded"), + (422, "unsupported_contract_version"), + ] { + let rendered = render_project_history_retrieval_cli_stdout( + &invocation, + &NaruonLiveResponse { + status_code, + reason_phrase: "Error", + body: format!( + r#"{{"error_code":"{error_code}","message":"redacted","request_id":"req-1","retryable":false}}"# + ), + }, + ) + .expect("status-matched envelope"); + assert!(rendered.contains(error_code)); + } +} + +#[test] +fn loopback_http1_refuses_non_get_collection_extra_and_credentials() { + let host = "127.0.0.1:18081"; + let exchange = + lineageweave_project_history_retrieval_exchange(ORIGIN, "history-tenant", "idem-a") + .expect("ex"); + let ok = loopback_http1_from_project_history_retrieval_exchange(&exchange, host).expect("ok"); + assert!(ok.starts_with("GET /v1/project-histories/idem-a HTTP/1.1")); + let mut posted = exchange.clone(); + posted.method = "POST"; + assert_eq!( + loopback_http1_from_project_history_retrieval_exchange(&posted, host).unwrap_err(), + ApiError::InvalidWirePayload + ); + let mut collection = exchange.clone(); + collection.target_url = format!("{ORIGIN}{PROJECT_HISTORY_PATH}"); + assert_eq!( + loopback_http1_from_project_history_retrieval_exchange(&collection, host).unwrap_err(), + ApiError::InvalidWirePayload + ); + let mut extra = exchange.clone(); + extra.target_url = format!("{ORIGIN}{PROJECT_HISTORY_PATH}/idem-a/extra"); + assert_eq!( + loopback_http1_from_project_history_retrieval_exchange(&extra, host).unwrap_err(), + ApiError::InvalidWirePayload + ); + let mut paged = exchange.clone(); + paged.headers.push(("tepp-page-limit".into(), "1".into())); + assert_eq!( + loopback_http1_from_project_history_retrieval_exchange(&paged, host).unwrap_err(), + ApiError::InvalidWirePayload + ); + let credentialed = NaruonHttpExchange { + method: "GET", + target_url: format!("{ORIGIN}{PROJECT_HISTORY_PATH}/idem-a"), + headers: vec![("authorization".into(), "secret".into())], + body: String::new(), + }; + assert_eq!( + loopback_http1_from_project_history_retrieval_exchange(&credentialed, host).unwrap_err(), + ApiError::AuthorizationDenied + ); +} + +#[test] +fn execute_over_tcp_and_stdin_reader() { + let mut service = AnalysisRunLiveService::bind_loopback().expect("bind"); + let addr = service.local_addr().expect("addr").to_string(); + let request = sample_request("idem-tcp", "project-tcp"); + assert_eq!( + service + .handle_http_request(&project_history_post(&request)) + .status_code, + 200 + ); + let handle = std::thread::spawn(move || { + drop(service.serve_one()); + }); + let invocation = ProjectHistoryRetrievalCliInvocation::from_args( + get_args(addr.as_str(), "idem-tcp", LINEAGEWEAVE_CONSUMER_CODE), + "", + ) + .expect("tcp"); + let response = execute_project_history_retrieval_cli(&invocation).expect("execute"); + assert_eq!(response.status_code, 200, "{}", response.body); + let stdout = + render_project_history_retrieval_cli_stdout(&invocation, &response).expect("stdout"); + let retrieved = ProjectHistoryProjection::from_json(&stdout).expect("parsed"); + assert_eq!(retrieved.inference_status, "temporal_association_only"); + assert_eq!(retrieved.project_key, "project-tcp"); + handle.join().expect("join"); + let empty = read_project_history_retrieval_cli_stdin(true, std::io::empty()).expect("tty"); + assert!(empty.is_empty()); + let piped = + read_project_history_retrieval_cli_stdin(false, std::io::Cursor::new(b"")).expect("pipe"); + assert!(piped.is_empty()); +} + +#[test] +fn binary_reports_redacted_success_and_failure_statuses() { + let mut service = AnalysisRunLiveService::bind_loopback().expect("bind"); + let addr = service.local_addr().expect("addr").to_string(); + let request = sample_request("idem-bin", "project-bin"); + assert_eq!( + service + .handle_http_request(&project_history_post(&request)) + .status_code, + 200 + ); + let handle = std::thread::spawn(move || { + service.serve_one().expect("success request"); + service.serve_one().expect("missing request"); + }); + let binary = env!("CARGO_BIN_EXE_tepp-project-history-get"); + let run = |idempotency_key: &str| { + std::process::Command::new(binary) + .args(get_args(&addr, idempotency_key, LINEAGEWEAVE_CONSUMER_CODE)) + .output() + .expect("binary") + }; + let success = run("idem-bin"); + assert!(success.status.success()); + assert!(String::from_utf8_lossy(&success.stdout).contains("project-bin")); + assert!(success.stderr.is_empty()); + let failure = run("missing"); + assert!(!failure.status.success()); + assert!(String::from_utf8_lossy(&failure.stderr).contains("invalid API wire payload")); + assert!(!String::from_utf8_lossy(&failure.stderr).contains("history-tenant")); + handle.join().expect("server"); +} diff --git a/crates/tepp_api/tests/project_history_retrieval_http_contract.rs b/crates/tepp_api/tests/project_history_retrieval_http_contract.rs new file mode 100644 index 000000000..a78a120fa --- /dev/null +++ b/crates/tepp_api/tests/project_history_retrieval_http_contract.rs @@ -0,0 +1,67 @@ +//! Contract tests for loopback `GET /v1/project-histories/{idempotency_key}`. + +use tepp_api::{ + ApiError, PROJECT_HISTORY_PATH, lineageweave_project_history_retrieval_exchange, + project_history_retrieval_path_id, refuse_metrics_on_project_history_retrieval_payload, +}; + +#[test] +fn project_history_retrieval_is_metric_free_get_without_credentials() { + let exchange = lineageweave_project_history_retrieval_exchange( + "https://tepp.example.test", + "tenant-a", + "idem-a", + ) + .expect("exchange"); + assert_eq!(exchange.method, "GET"); + assert!( + exchange + .target_url + .ends_with("/v1/project-histories/idem-a") + ); + assert!(exchange.body.is_empty()); + assert!( + exchange + .headers + .iter() + .any(|(name, value)| { name == "tepp-tenant-workspace-id" && value == "tenant-a" }) + ); + assert!( + !exchange + .headers + .iter() + .any(|(name, _)| name.eq_ignore_ascii_case("authorization")) + ); + assert_eq!( + project_history_retrieval_path_id("/v1/project-histories/idem-a").expect("id"), + "idem-a" + ); + assert_eq!( + project_history_retrieval_path_id(PROJECT_HISTORY_PATH), + Err(ApiError::InvalidWirePayload) + ); +} + +#[test] +fn project_history_retrieval_refuses_metrics_naruon_origins_and_collection_path() { + assert_eq!( + refuse_metrics_on_project_history_retrieval_payload(r#"{"rmse":1.0}"#), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + refuse_metrics_on_project_history_retrieval_payload(r#"{"causal_score":1}"#), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + lineageweave_project_history_retrieval_exchange( + "http://insecure.example", + "tenant-a", + "idem-a", + ), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + project_history_retrieval_path_id("/v1/analysis-runs/idem-a"), + Err(ApiError::InvalidWirePayload) + ); +} diff --git a/docs/API_CONTRACT.md b/docs/API_CONTRACT.md index 0cdadec28..32f549ce8 100644 --- a/docs/API_CONTRACT.md +++ b/docs/API_CONTRACT.md @@ -98,6 +98,16 @@ projections on `tepp-loopback`. Collection rows stay metric-free identities `inference_status=temporal_association_only`). `tepp.scientific_acceptance.v1`, evidence text, findings, and causal scores never appear. +`GET /v1/project-histories/{idempotency_key}` retrieves one accepted +cutoff-safe `ProjectHistoryProjection` on `tepp-loopback`. Consumer is +`lineageweave` only. `inference_status` remains `temporal_association_only`. +`tepp.scientific_acceptance.v1` and causal scores never appear. The retrieval +does not infer causality. + +`tepp-project-history-get get` is the operator-visible loopback client of that +GET-by-id. Empty stdin is admitted. Naruon is refused. Process exit 0 is not +an ADR 0014 claim. + The typed status/read contract returns `accepted`, `running`, `succeeded`, or `failed`. Accepted and running statuses contain no measurement result. A terminal status contains exactly one request-bound diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index fffa48453..0ed7d147e 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -54,6 +54,8 @@ The full APA 7th standards/literature register remains `docs/research/standards- | known-truth temporal/event simulation manifests | PRD; TRD; Test Strategy | `tepp_simulation` on protected main; recovery metrics in `validation_core` | implemented-main | | versioned service/API contracts and exports | PRD; API contract; ADR 0011/0013 | `tepp_api` analysis-run/export/JSON-LD/GraphML contracts on protected main (PR #21); request-bound terminal result active in PR #157; HTTP service remains accepted-target; the `orchestrator_live` loopback interpretation listener is on this PR | partial | | loopback LineageWeave project-history collection GET | ADR 0028; API contract; RFC 9110; ADR 0021/0011 | `tepp_api` `GET /v1/project-histories` on `tepp-loopback`; metric-free `temporal_association_only` identities; `tepp.scientific_acceptance.v1` never appears; does not infer causality | active-PR | +| loopback LineageWeave project-history GET-by-id | ADR 0066; API contract; RFC 9110; ADR 0028/0021/0011 | `tepp_api` `GET /v1/project-histories/{idempotency_key}` on `tepp-loopback`; stored `temporal_association_only` projection; `tepp.scientific_acceptance.v1` never appears; does not infer causality | active-PR | +| loopback LineageWeave project-history GET-by-id CLI | ADR 0067; API contract; RFC 9110; ADR 0066/0028/0021/0011 | `tepp-project-history-get` mints GET-by-id onto `tepp-loopback`; stored `temporal_association_only` projection; `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 index 770886a4d..4cb0960ed 100644 --- a/docs/adr/0028-project-history-collection-get.md +++ b/docs/adr/0028-project-history-collection-get.md @@ -13,7 +13,7 @@ Protected main already stores accepted project-history projections on `AnalysisR `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. +- Consumer is `lineageweave` only. The required `tepp-tenant-workspace-id` scopes every page to one validated tenant. 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. diff --git a/docs/adr/0066-project-history-retrieval-get.md b/docs/adr/0066-project-history-retrieval-get.md new file mode 100644 index 000000000..ca2fb07fc --- /dev/null +++ b/docs/adr/0066-project-history-retrieval-get.md @@ -0,0 +1,69 @@ +# ADR 0066 — LineageWeave project-history GET-by-id + +**Decision status:** Accepted +**Implementation maturity:** active-PR +**Date:** 2026-08-31 +**Supersedes:** None; complements ADR 0028 and ADR 0021 for retrieving one accepted projection. Does not supersede ADR 0014 claim-promotion authority. This ADR number is unique on protected main; live vs-main PRs occupy 0026–0064 and stacked #428 occupies 0065. + +## Context + +ADR 0028 enumerates accepted project-history projections as metric-free identities, and #428 publishes a collection CLI. Operators who hold an `idempotency_key` from that page still had to replay `POST /v1/project-histories` to recover the stored cutoff-safe projection. Duplicating collection GET (#424), collection CLI (#428), project-history POST CLI (#420), temporal-context CLI (#414), export retrieval GET (#411), analysis-run GET-by-id (#359), Leiden, Driver p.16, or GAP-010 Figma/export would collide with live PRs. + +## Decision + +`tepp_api` publishes loopback-only `GET /v1/project-histories/{idempotency_key}` on `tepp-loopback`: + +- Consumer is `lineageweave` only. Empty body. The idempotency identity travels in the path and the authorized tenant travels in `tepp-tenant-workspace-id`. +- The response is an identity-bound receipt carrying the authorized tenant, decoded idempotency key, and stored cutoff-safe `ProjectHistoryProjection`. `inference_status` remains `temporal_association_only`. +- `tepp.scientific_acceptance.v1`, RMSE, bias, coverage, SE-gate, and `causal_score` never appear. +- Collection GET (`GET /v1/project-histories` with no extra segment) is unchanged. +- Missing or mismatched tenant identity, pagination headers, naruon, nonempty bodies, extra path segments, and unknown keys fail closed. +- The retrieval does not infer causality, mutate TEPP state, or return a completed psychometric result. +- `NaruonLiveService` stays POST-only. This slice does not implement a retrieval CLI or persistence. + +## Alternatives considered + +1. **Keep POST replay as the only retrieval path** — rejected because operators still resubmit evidence after ADR 0028. +2. **Return only the collection row** — rejected; that identity is already on the list. GET-by-id recovers the stored projection. +3. **Reuse analysis-run GET-by-id (#359) or export retrieval GET (#411)** — rejected; those are different live resources. +4. **Loopback `GET /v1/project-histories/{idempotency_key}`** — accepted. + +## Consequences + +- Operators can retrieve one accepted projection from a collection identity without writing a second POST. +- Retrieval stdout cannot be mistaken for a succeeded scientific-acceptance result or a causal score. +- Retrieval success is not release evidence and is not an ADR 0014 claim. + +## Failure and recovery + +Non-`lineageweave` consumers, nonempty GET bodies, collection pagination headers, extra path segments, unknown keys, 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. +- The retrieval remains loopback-only, size-bounded, and directly keyed by consumer, tenant, and idempotency identity. +- Process 200 on GET-by-id is not measurement evidence and is not a causal claim. + +## Compatibility and migration + +Collection GET, POST `/v1/project-histories`, temporal-context, and analysis-run paths are unchanged. A retrieval CLI remains a later slice. + +## Verification + +Falsifiable evidence: + +- GET of an accepted projection returns `temporal_association_only` without RMSE/bias/coverage/SE-gate/`tepp.scientific_acceptance.v1`/`causal_score` keys; +- cross-tenant GET, missing tenant, collection GET, naruon consumer, nonempty body, extra segments, and unknown keys fail closed; +- Clippy `-D warnings`, `tepp_api` tests, rustdoc, and exact-head review remain required. + +## Rollback and supersession + +Rollback removes GET-by-id; collection GET and POST remain valid. A superseding ADR is required to persist the registry, bind a public address, emit scientific-acceptance on retrieval, infer causality, or treat retrieval success as an ADR 0014 claim. + +## Related authority + +- ADR 0028 owns loopback collection GET. +- ADR 0021 owns the LineageWeave project-history service boundary. +- 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/0067-project-history-retrieval-cli.md b/docs/adr/0067-project-history-retrieval-cli.md new file mode 100644 index 000000000..ccbb2fbf7 --- /dev/null +++ b/docs/adr/0067-project-history-retrieval-cli.md @@ -0,0 +1,114 @@ +# ADR 0067 — LineageWeave project-history GET-by-id loopback CLI + +**Decision status:** Accepted +**Implementation maturity:** active-PR +**Date:** 2026-08-31 +**Supersedes:** None; complements ADR 0066 for the operator-visible retrieval client. Does not supersede ADR 0014 claim-promotion authority. This ADR number is unique on protected main; live vs-main PRs occupy 0026–0066 (#430 occupies 0066 vs-main) and stacked #428 occupies 0065. Stacked #429 occupies 0066 on this lineage. + +## Context + +ADR 0066 / #429 serves `GET /v1/project-histories/{idempotency_key}` on +`AnalysisRunLiveService` / `tepp-loopback`, but operators still had to write +raw HTTP/1.1 to recover one stored cutoff-safe projection. Duplicating +collection GET (#424), collection CLI (#428), GET-by-id HTTP (#429), +project-history POST CLI (#420), temporal-context CLI (#414), export retrieval +CLI (#417), stored-request CLI (#395), analysis-run 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-history-get` CLI: + +- `get` mints `lineageweave_project_history_retrieval_exchange` and renders + through `loopback_http1_from_project_history_retrieval_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. +- `--idempotency-key` is the path identity and required `--tenant-workspace-id` selects its authorized tenant registry. Pagination flags fail closed. +- A successful GET returns an identity-bound receipt; the CLI compares both tenant and idempotency key before printing only the validated projection. +- Stdout is the stored `ProjectHistoryProjection`. `inference_status` remains + `temporal_association_only`. Evidence text and findings belong to the stored + projection. +- `tepp.scientific_acceptance.v1`, RMSE, bias, coverage, SE-gate, 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, control + identities, and pagination headers fail closed. Percent-encoded `/` keys + round-trip because the POST contract admits them. +- Persistence, Compose recovery, and psychometric execution remain GAP-003B. + +## Alternatives considered + +1. **Keep raw HTTP as the only retrieval path** — rejected because operators + still guess framing after ADR 0066. +2. **Add `get` onto the live project-history POST CLI (#420)** — rejected + because that head owns POST query against a different live PR. +3. **Add `get` onto collection CLI (#428)** — rejected; that sibling stack is + not this GET-by-id lineage. +4. **Open naruon on this adapter** — rejected; project-history GET-by-id is + LineageWeave-only (ADR 0066 / ADR 0021). +5. **Loopback retrieval CLI with the same fail-closed gates as ADR 0066** — + accepted. + +## Consequences + +- Operators can recover one accepted projection from a collection identity + without writing HTTP or replaying POST. +- Retrieval stdout 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, unpublished consumers, naruon, and credential flags fail +closed. The in-memory registry is not durable. + +## Security, privacy, scientific-integrity, and governance impact + +- No credential headers cross the consumer boundary. +- The CLI remains loopback-only and size-bounded. +- Process exit 0 on retrieval is not measurement evidence and is not an + ADR 0014 claim. + +## Compatibility and migration + +GET-by-id HTTP, collection GET, project-history POST, and analysis-run paths +are unchanged. The POST CLI binary name `tepp-project-history` remains owned +by ADR 0061 / #420. The collection CLI binary `tepp-project-histories` remains +owned by ADR 0065 / #428. + +## Verification + +Falsifiable evidence: + +- CLI get JSON is a stored `temporal_association_only` projection without + RMSE/bias/coverage/SE-gate/`tepp.scientific_acceptance.v1`/`causal_score`; +- CLI get returns an accepted LineageWeave projection and refuses naruon; +- non-loopback host, credential flags, nonempty stdin, pagination flags, and + unknown verbs fail closed; +- `NaruonLiveService` still refuses the composed GET; +- Clippy `-D warnings`, `tepp_api` tests, rustdoc, and exact-head review + remain required. + +## Rollback and supersession + +Rollback removes the `tepp-project-history-get` binary and client module; +GET-by-id HTTP remains valid. A superseding ADR is required to persist the +registry, bind a public address, emit scientific-acceptance on retrieval, open +naruon, or treat CLI success as an ADR 0014 claim. + +## Related authority + +- ADR 0066 owns loopback project-history GET-by-id. +- 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 8e52d8394..b076b8132 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -31,6 +31,8 @@ Read [`ADR_POLICY.md`](ADR_POLICY.md) first. **Decision status and implementatio | [0024](0024-lineage-pair-criterion-and-project-journey-posterior.md) | Independent Event Lineage pair criterion and posterior Project Journey | Proposed | active-PR | Strict artifacts preserve criterion/event-time draws, branches, ties, and CPU/GPU receipts without claiming the scientific estimator is complete. | | [0025](0025-macos-native-rust-mlx-metal-boundary.md) | macOS-native Rust-owned MLX Metal execution | Accepted | accepted-target | Compose authenticates to a native host service; Linux never claims Metal, and actual backend/parity receipts fail closed. | | [0028](0028-project-history-collection-get.md) | Loopback `GET /v1/project-histories` enumerates accepted LineageWeave projections | Accepted | active-PR | Complements ADR 0021/0011; does not supersede ADR 0014. Unique on protected main. Does not infer causality. | +| [0066](0066-project-history-retrieval-get.md) | Loopback `GET /v1/project-histories/{idempotency_key}` retrieves one accepted LineageWeave projection | Accepted | active-PR | Complements ADR 0028; unique vs protected main. Does not infer causality. | +| [0067](0067-project-history-retrieval-cli.md) | Loopback `tepp-project-history-get` retrieves one accepted LineageWeave projection | Accepted | active-PR | Complements ADR 0066; unique vs protected main. 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. | diff --git a/docs/research/project-history-retrieval-cli.md b/docs/research/project-history-retrieval-cli.md new file mode 100644 index 000000000..c107b3b65 --- /dev/null +++ b/docs/research/project-history-retrieval-cli.md @@ -0,0 +1,55 @@ +# Project-history GET-by-id CLI (doctoring) + +## Scope + +`tepp-project-history-get get` is the operator-visible client of loopback +`GET /v1/project-histories/{idempotency_key}` 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, and scientific-authority promotion is repository contract authority +(ADR 0067; ADR 0066; ADR 0028; ADR 0021; ADR 0011; ADR 0014), not an RFC +inference rule. + +Stdout is the stored `ProjectHistoryProjection`. `inference_status` remains +`temporal_association_only`. `tepp.scientific_acceptance.v1` never appears. +Process exit 0 is not a completed temporal model, calibrated score, theta +estimate, uncertainty statement, causal inference, or scientific claim. + +## Authority + +### External standards (HTTP only) + +Fielding, R., Nottingham, M., & Reschke, J. (Eds.). (2022). *HTTP semantics* +(RFC 9110). IETF. https://doi.org/10.17487/RFC9110 + +RFC 9110 §9.3.1 describes GET as a method for retrieving the target resource. +TEPP maps that retrieval onto one bounded, cutoff-safe project-history +projection. The RFC does not define psychometric acceptance, RMSE, causality, +or claim promotion. + +### Internal contract evidence + +- `docs/adr/0067-project-history-retrieval-cli.md` — this CLI +- `docs/adr/0066-project-history-retrieval-get.md` — GET-by-id HTTP +- `docs/adr/0028-project-history-collection-get.md` — collection GET +- `docs/adr/0021-lineageweave-project-history-boundary.md` — POST boundary +- `docs/adr/0014-scientific-claim-promotion-and-release-evidence.md` — process + exit 0 is not a scientific claim +- `crates/tepp_api/tests/project_history_retrieval_cli_contract.rs` — + fail-closed CLI proofs + +## Verification + +- `tepp-project-history-get get` of an accepted LineageWeave projection + returns `temporal_association_only` without RMSE/bias/coverage/SE-gate + keys, `causal_score`, or `tepp.scientific_acceptance.v1`; +- naruon consumer, nonempty stdin, pagination flags, public bind, + `localhost`, and credential flags fail closed; +- `NaruonLiveService` refuses the composed GET. + +## Non-claims + +This slice does not implement collection CLI, POST CLI, persistence, +production TLS, Leiden consensus, or an ADR 0014 scientific claim-promotion +package. diff --git a/docs/research/project-history-retrieval-http.md b/docs/research/project-history-retrieval-http.md new file mode 100644 index 000000000..014a1d47c --- /dev/null +++ b/docs/research/project-history-retrieval-http.md @@ -0,0 +1,54 @@ +# Project-history GET-by-id (doctoring) + +## Scope + +`GET /v1/project-histories/{idempotency_key}` is the operator-visible retrieval +of one accepted cutoff-safe project-history projection 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, and scientific-authority promotion is +repository contract authority (ADR 0066; ADR 0028; ADR 0021; ADR 0011; +ADR 0014), not an RFC inference rule. + +The response is the stored `ProjectHistoryProjection`. `inference_status` +remains `temporal_association_only`. `tepp.scientific_acceptance.v1` never +appears. A 200 retrieval 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 one bounded, cutoff-safe project-history +projection. The RFC does not define psychometric acceptance, RMSE, causality, +or claim promotion. + +### Internal contract evidence + +- `docs/adr/0066-project-history-retrieval-get.md` — this retrieval +- `docs/adr/0028-project-history-collection-get.md` — collection GET +- `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_retrieval_http_contract.rs` — + fail-closed retrieval proofs + +## Verification + +- `GET /v1/project-histories/{idempotency_key}` of an accepted LineageWeave + projection returns `temporal_association_only` without RMSE/bias/coverage/ + SE-gate keys, `causal_score`, or `tepp.scientific_acceptance.v1`; +- collection GET remains metric-free identities; +- naruon consumer, nonempty body, extra segments, and unknown keys fail closed. + +## Non-claims + +This slice does not implement collection CLI, retrieval CLI, GET-by-id for +analysis runs, persistence, production TLS, Leiden consensus, or an ADR 0014 +scientific claim-promotion package.