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..d24a1509f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,10 @@ All notable changes to TEPP are documented here. The format follows Keep a Chang ## [Unreleased] +### Fixed + +- 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..eb183395e 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -12,6 +12,7 @@ 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) | | 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/src/analysis_run_live.rs b/crates/tepp_api/src/analysis_run_live.rs index fd682d7cd..8807ed1d6 100644 --- a/crates/tepp_api/src/analysis_run_live.rs +++ b/crates/tepp_api/src/analysis_run_live.rs @@ -19,11 +19,13 @@ 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, + PROJECT_HISTORY_RETRIEVAL_TENANT_HEADER, 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_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 +150,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 +254,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 +264,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 +291,36 @@ 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 response_body = projection.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; @@ -366,8 +406,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, - TEMPORAL_CONTEXT_PATH, + PROJECT_HISTORY_PATH, ProjectHistoryCollection, ProjectHistoryEvent, + ProjectHistoryProjection, ProjectHistoryRequest, TEMPORAL_CONTEXT_PATH, }; fn sample_run() -> AnalysisRunRequest { @@ -1036,7 +1076,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 +1091,152 @@ 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"); + 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 = ProjectHistoryProjection::from_json(&got.body).expect("retrieved"); + assert_eq!(retrieved, stored); + assert_eq!(retrieved.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_projection = + ProjectHistoryProjection::from_json(&other.body).expect("other tenant projection"); + assert_eq!(other_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/lib.rs b/crates/tepp_api/src/lib.rs index 32b5c6b48..68d4a85ac 100644 --- a/crates/tepp_api/src/lib.rs +++ b/crates/tepp_api/src/lib.rs @@ -29,6 +29,7 @@ mod naruon_live; mod orchestration; mod project_history; mod project_history_collection_http; +mod project_history_retrieval_http; mod project_journey; mod provider_payload; mod temporal_context; @@ -255,6 +256,16 @@ 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; +/// 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; +/// `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_http.rs b/crates/tepp_api/src/project_history_retrieval_http.rs new file mode 100644 index 000000000..44d03e233 --- /dev/null +++ b/crates/tepp_api/src/project_history_retrieval_http.rs @@ -0,0 +1,368 @@ +//! 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::require_nonempty; +use crate::{ApiError, PROJECT_HISTORY_PATH}; + +/// 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"; + +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_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..819f70fa3 100644 --- a/docs/API_CONTRACT.md +++ b/docs/API_CONTRACT.md @@ -98,6 +98,12 @@ 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. + 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..dbc6936c6 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -54,6 +54,7 @@ The full APA 7th standards/literature register remains `docs/research/standards- | known-truth temporal/event simulation manifests | PRD; TRD; Test Strategy | `tepp_simulation` on protected main; recovery metrics in `validation_core` | implemented-main | | versioned service/API contracts and exports | PRD; API contract; ADR 0011/0013 | `tepp_api` analysis-run/export/JSON-LD/GraphML contracts on protected main (PR #21); request-bound terminal result active in PR #157; HTTP service remains accepted-target; the `orchestrator_live` loopback interpretation listener is on this PR | partial | | loopback LineageWeave 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 | | 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..b313a6e48 --- /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 the 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/README.md b/docs/adr/README.md index 8e52d8394..c865f298e 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -31,6 +31,7 @@ Read [`ADR_POLICY.md`](ADR_POLICY.md) first. **Decision status and implementatio | [0024](0024-lineage-pair-criterion-and-project-journey-posterior.md) | Independent Event Lineage pair criterion and posterior Project Journey | Proposed | active-PR | Strict artifacts preserve criterion/event-time draws, branches, ties, and CPU/GPU receipts without claiming the scientific estimator is complete. | | [0025](0025-macos-native-rust-mlx-metal-boundary.md) | macOS-native Rust-owned MLX Metal execution | Accepted | accepted-target | Compose authenticates to a native host service; Linux never claims Metal, and actual backend/parity receipts fail closed. | | [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. | | [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-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.