diff --git a/CHANGELOG.d/temporal-context-collection-get.md b/CHANGELOG.d/temporal-context-collection-get.md new file mode 100644 index 000000000..9cc4bf933 --- /dev/null +++ b/CHANGELOG.d/temporal-context-collection-get.md @@ -0,0 +1 @@ +- `GET /v1/temporal-context` enumerates accepted LineageWeave temporal-context identities on `tepp-loopback` (ADR 0081). Metric-free `inference_status=temporal_association_only` rows. `tepp.scientific_acceptance.v1` never appears. Does not infer causality. Naruon refused. `NaruonLiveService` stays POST-only. Not temporal-context CLI, not project-history collection GET, not GAP-010 Figma/export, not persistence. diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 6fa4b9683..10174842e 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -13,6 +13,7 @@ TEPP's approved PRD v0.4 and implementation plan are the primary product baselin | naruon modular consumer contract | [`docs/connectors/naruon-artifact-consumer.md`](docs/connectors/naruon-artifact-consumer.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) | +| Temporal-context collection GET doctoring | [`docs/research/temporal-context-collection-get.md`](docs/research/temporal-context-collection-get.md) | | UML/runtime/scientific flows | [`docs/UML.md`](docs/UML.md) | | Logical/physical ERD | [`docs/ERD.md`](docs/ERD.md) | | Security policy | [`SECURITY.md`](SECURITY.md) | diff --git a/crates/tepp_api/src/analysis_run_live.rs b/crates/tepp_api/src/analysis_run_live.rs index 6768c6ef1..6240a07b3 100644 --- a/crates/tepp_api/src/analysis_run_live.rs +++ b/crates/tepp_api/src/analysis_run_live.rs @@ -19,8 +19,12 @@ use crate::naruon_http::NARUON_ANALYSIS_RUN_PATH; use crate::{ AnalysisRunAccepted, AnalysisRunRequest, ApiError, DEFAULT_PROJECT_HISTORY_BYTE_LIMIT, ErrorEnvelope, NARUON_LIVE_IO_TIMEOUT, NaruonLiveResponse, PROJECT_HISTORY_PATH, - ProjectHistoryProjection, ProjectHistoryRequest, TEMPORAL_CONTEXT_PATH, TemporalContextRequest, - build_temporal_context, project_history_projection, requests_are_idempotent_matches, + ProjectHistoryProjection, ProjectHistoryRequest, TEMPORAL_CONTEXT_COLLECTION_INFERENCE_STATUS, + TEMPORAL_CONTEXT_PATH, TemporalContextCollection, TemporalContextCollectionItem, + TemporalContextRequest, build_temporal_context, is_temporal_context_collection_path, + page_temporal_context_collection_items, parse_temporal_context_collection_page_cursor, + parse_temporal_context_collection_page_limit, project_history_projection, + requests_are_idempotent_matches, }; const MAX_LIVE_REQUEST_BODY_BYTES: usize = DEFAULT_PROJECT_HISTORY_BYTE_LIMIT; @@ -41,6 +45,7 @@ pub struct AnalysisRunLiveService { next_request_serial: u64, accepted_runs: HashMap, accepted_project_histories: HashMap, + accepted_temporal_contexts: HashMap, } impl Default for AnalysisRunLiveService { @@ -60,6 +65,7 @@ impl AnalysisRunLiveService { next_request_serial: 1, accepted_runs: HashMap::new(), accepted_project_histories: HashMap::new(), + accepted_temporal_contexts: HashMap::new(), } } @@ -143,6 +149,10 @@ impl AnalysisRunLiveService { let (header_block, body) = split_request_with_limit(request, MAX_LIVE_REQUEST_BODY_BYTES)?; let mut lines = header_block.split("\r\n"); let (method, path) = parse_request_line(lines.next().unwrap_or(""))?; + let headers = parse_headers(&mut lines)?; + if method == "GET" { + return self.list_temporal_contexts(path, &headers, body); + } if method != "POST" || (path != NARUON_ANALYSIS_RUN_PATH && path != TEMPORAL_CONTEXT_PATH @@ -150,19 +160,13 @@ impl AnalysisRunLiveService { { return Err(ApiError::InvalidWirePayload); } - let headers = parse_headers(&mut lines)?; let consumer = require_headers( &headers, self.bound_addr, path == NARUON_ANALYSIS_RUN_PATH || path == PROJECT_HISTORY_PATH, )?; if path == TEMPORAL_CONTEXT_PATH { - if consumer != LINEAGEWEAVE_CONSUMER_CODE { - return Err(ApiError::InvalidWirePayload); - } - let context_request = TemporalContextRequest::from_json(body)?; - let response = build_temporal_context(&context_request)?; - return Ok(json_response(200, "OK", response.to_json()?)); + return self.accept_temporal_context(consumer, &headers, body); } if path == PROJECT_HISTORY_PATH { return self.accept_project_history(consumer, &headers, body); @@ -170,6 +174,67 @@ impl AnalysisRunLiveService { self.accept_analysis_run(consumer, &headers, body) } + fn accept_temporal_context( + &mut self, + consumer: &str, + headers: &HashMap, + body: &str, + ) -> Result { + if consumer != LINEAGEWEAVE_CONSUMER_CODE { + return Err(ApiError::InvalidWirePayload); + } + let context_request = TemporalContextRequest::from_json(body)?; + if let Some(idempotency_key) = headers.get("idempotency-key") { + let item = TemporalContextCollectionItem::new( + idempotency_key.clone(), + context_request.knowledge_cutoff.clone(), + TEMPORAL_CONTEXT_COLLECTION_INFERENCE_STATUS, + )?; + let replay_key = format!("{consumer}\u{1f}{idempotency_key}"); + if let Some(stored) = self.accepted_temporal_contexts.get(&replay_key) { + if stored.knowledge_cutoff != item.knowledge_cutoff { + return Err(ApiError::InvalidWirePayload); + } + } else { + self.accepted_temporal_contexts.insert(replay_key, item); + } + } + let response = build_temporal_context(&context_request)?; + Ok(json_response(200, "OK", response.to_json()?)) + } + + fn list_temporal_contexts( + &self, + path: &str, + headers: &HashMap, + body: &str, + ) -> Result { + if !is_temporal_context_collection_path(path) { + return Err(ApiError::InvalidWirePayload); + } + if !body.is_empty() { + return Err(ApiError::InvalidWirePayload); + } + if headers.contains_key("idempotency-key") { + return Err(ApiError::InvalidWirePayload); + } + let consumer = require_headers(headers, self.bound_addr, false)?; + if consumer != LINEAGEWEAVE_CONSUMER_CODE { + return Err(ApiError::InvalidWirePayload); + } + let limit = parse_temporal_context_collection_page_limit( + headers.get("tepp-page-limit").map(String::as_str), + )?; + let cursor = parse_temporal_context_collection_page_cursor( + headers.get("tepp-page-cursor").map(String::as_str), + )?; + let items = self.accepted_temporal_contexts.values().cloned().collect(); + let (page, next_cursor) = + page_temporal_context_collection_items(items, cursor.as_deref(), limit); + let collection = TemporalContextCollection::new(page, next_cursor)?; + Ok(json_response(200, "OK", collection.to_json()?)) + } + fn accept_analysis_run( &mut self, consumer: &str, @@ -320,6 +385,7 @@ mod tests { DEFAULT_ANALYSIS_RUN_BYTE_LIMIT, ErrorEnvelope, LINEAGEWEAVE_CONSUMER_CODE, NARUON_ANALYSIS_RUN_PATH, NARUON_CONSUMER_CODE, NARUON_LIVE_HEADER_BYTE_LIMIT, NARUON_LIVE_HEADER_COUNT_LIMIT, NARUON_LIVE_IO_TIMEOUT, TEMPORAL_CONTEXT_PATH, + TemporalContextCollection, }; fn sample_run() -> AnalysisRunRequest { @@ -734,6 +800,69 @@ mod tests { assert_eq!(replay.body, accepted.body); } + #[test] + fn temporal_context_collection_get_is_metric_free_and_fail_closed() { + let temporal_body = r#"{"contract_version":1,"consumer_code":"lineageweave","knowledge_cutoff":"2026-08-20T00:00:00Z","subject_post_id":null,"events":[{"event_id":"event-1","source_post_id":"post-1","event_type_code":"order_awarded","event_label":"Order awarded","event_time":"2026-08-01T09:00:00Z","available_time":"2026-08-01T10:00:00Z","project_reference":null,"actor_references":["actor-1"]}]}"#; + let mut service = AnalysisRunLiveService::new(); + let posted = format!( + "POST {TEMPORAL_CONTEXT_PATH} HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: {LINEAGEWEAVE_CONSUMER_CODE}\r\ntepp-contract-version: 1\r\nidempotency-key: idem-a\r\ncontent-length: {}\r\n\r\n{temporal_body}", + temporal_body.len() + ); + assert_eq!(service.handle_http_request(&posted).status_code, 200); + let listed = service.handle_http_request( + &format!( + "GET {TEMPORAL_CONTEXT_PATH} HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: {LINEAGEWEAVE_CONSUMER_CODE}\r\ntepp-contract-version: 1\r\ncontent-length: 0\r\n\r\n" + ), + ); + assert_eq!(listed.status_code, 200, "{}", listed.body); + assert!(!listed.body.contains("rmse")); + assert!(!listed.body.contains("event_label")); + assert!(!listed.body.contains("actor_references")); + assert!(!listed.body.contains("tepp.scientific_acceptance.v1")); + let page = TemporalContextCollection::from_json(&listed.body).expect("page"); + assert_eq!(page.contexts.len(), 1); + assert_eq!(page.contexts[0].idempotency_key, "idem-a"); + assert_eq!(page.contexts[0].inference_status, "temporal_association_only"); + assert_eq!( + service + .handle_http_request( + "GET /v1/analysis-runs HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: lineageweave\r\ntepp-contract-version: 1\r\ncontent-length: 0\r\n\r\n" + ) + .status_code, + 400 + ); + assert_eq!( + service + .handle_http_request( + &format!( + "GET {TEMPORAL_CONTEXT_PATH} HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: {NARUON_CONSUMER_CODE}\r\ntepp-contract-version: 1\r\ncontent-length: 0\r\n\r\n" + ) + ) + .status_code, + 400 + ); + assert_eq!( + service + .handle_http_request( + &format!( + "GET {TEMPORAL_CONTEXT_PATH} 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: idem-a\r\ncontent-length: 0\r\n\r\n" + ) + ) + .status_code, + 400 + ); + assert_eq!( + service + .handle_http_request( + &format!( + "GET {TEMPORAL_CONTEXT_PATH} HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: {LINEAGEWEAVE_CONSUMER_CODE}\r\ntepp-contract-version: 1\r\ncontent-length: 2\r\n\r\n{{}}" + ) + ) + .status_code, + 400 + ); + } + #[test] fn parser_helpers_cover_framing_header_and_limit_edges() { assert_eq!( diff --git a/crates/tepp_api/src/lib.rs b/crates/tepp_api/src/lib.rs index 876703ebc..0259b4a95 100644 --- a/crates/tepp_api/src/lib.rs +++ b/crates/tepp_api/src/lib.rs @@ -31,6 +31,7 @@ mod project_history; mod project_journey; mod provider_payload; mod temporal_context; +mod temporal_context_collection_http; mod wire; /// Terminal analysis-result contract version constant. @@ -282,3 +283,29 @@ pub use temporal_context::TemporalContextTimelineEvent; pub use temporal_context::TemporalTransitionGapCandidate; /// Build a cutoff-safe, non-causal temporal context. pub use temporal_context::build_temporal_context; +/// Supported temporal-context collection contract version. +pub use temporal_context_collection_http::TEMPORAL_CONTEXT_COLLECTION_CONTRACT_VERSION; +/// Default page size for loopback temporal-context collection GET. +pub use temporal_context_collection_http::TEMPORAL_CONTEXT_COLLECTION_DEFAULT_LIMIT; +/// Maximum opaque cursor / idempotency-key length on the collection path. +pub use temporal_context_collection_http::TEMPORAL_CONTEXT_COLLECTION_CURSOR_MAX_LEN; +/// Fixed non-causal claim boundary echoed on every collection row. +pub use temporal_context_collection_http::TEMPORAL_CONTEXT_COLLECTION_INFERENCE_STATUS; +/// Maximum page size accepted on loopback temporal-context collection GET. +pub use temporal_context_collection_http::TEMPORAL_CONTEXT_COLLECTION_MAX_LIMIT; +/// Versioned metric-free temporal-context collection page. +pub use temporal_context_collection_http::TemporalContextCollection; +/// One metric-free collection row for an accepted temporal-context identity. +pub use temporal_context_collection_http::TemporalContextCollectionItem; +/// Return whether `path` is exactly the temporal-context collection resource. +pub use temporal_context_collection_http::is_temporal_context_collection_path; +/// Build a provider-owned `GET` temporal-context collection exchange. +pub use temporal_context_collection_http::lineageweave_temporal_context_collection_exchange; +/// Page stored rows after an exclusive cursor, sorted by idempotency key. +pub use temporal_context_collection_http::page_temporal_context_collection_items; +/// Parse the optional exclusive `tepp-page-cursor` header. +pub use temporal_context_collection_http::parse_temporal_context_collection_page_cursor; +/// Parse the optional `tepp-page-limit` header. +pub use temporal_context_collection_http::parse_temporal_context_collection_page_limit; +/// Refuse collection JSON that already carries scientific-metric or evidence keys. +pub use temporal_context_collection_http::refuse_metrics_on_temporal_context_collection_payload; diff --git a/crates/tepp_api/src/temporal_context_collection_http.rs b/crates/tepp_api/src/temporal_context_collection_http.rs new file mode 100644 index 000000000..e0b9c50b7 --- /dev/null +++ b/crates/tepp_api/src/temporal_context_collection_http.rs @@ -0,0 +1,541 @@ +//! Provider-owned temporal-context collection GET contracts. +//! +//! GAP-003A unique slice: `GET /v1/temporal-context` enumerates accepted +//! cutoff-safe `LineageWeave` temporal-context identities on +//! `AnalysisRunLiveService` / `tepp-loopback` so operators do not guess +//! idempotency keys. Collection bodies stay metric-free and identity-opaque. +//! `tepp.scientific_acceptance.v1` never appears. The page does not include +//! event labels, actor lists, timeline events, evidence text, findings, or a +//! causal score. This module does not duplicate temporal-context CLI (#414), +//! project-history collection GET (#424), interpretation-run collection GET +//! (#433), export collection GET (#443), or GAP-010 Figma/export. Persistence +//! remains GAP-003B. + +use crate::naruon_http::{NaruonHttpExchange, compose_https_target}; +use crate::wire::{ + from_json, require_byte_limit, require_contract_version, require_nonempty, to_json, +}; +use crate::{ApiError, DEFAULT_PROJECT_HISTORY_BYTE_LIMIT, TEMPORAL_CONTEXT_PATH}; +use serde::{Deserialize, Serialize}; + +/// Supported temporal-context collection contract version. +pub const TEMPORAL_CONTEXT_COLLECTION_CONTRACT_VERSION: u16 = 1; + +/// Default page size for loopback temporal-context collection GET. +pub const TEMPORAL_CONTEXT_COLLECTION_DEFAULT_LIMIT: usize = 32; + +/// Maximum page size accepted on loopback temporal-context collection GET. +pub const TEMPORAL_CONTEXT_COLLECTION_MAX_LIMIT: usize = 64; + +/// Maximum opaque cursor / idempotency-key length on the collection path. +pub const TEMPORAL_CONTEXT_COLLECTION_CURSOR_MAX_LEN: usize = 128; + +/// Fixed non-causal claim boundary echoed on every collection row. +pub const TEMPORAL_CONTEXT_COLLECTION_INFERENCE_STATUS: &str = "temporal_association_only"; + +const FORBIDDEN_COLLECTION_KEYS: [&str; 16] = [ + "rmse", + "rmse_standard_error", + "mean_bias", + "bias_standard_error", + "interval_coverage", + "coverage_wilson_lower", + "coverage_wilson_upper", + "temporal_order_accuracy", + "se_gate_accepted", + "se_gate_k", + "scientific_acceptance", + "report", + "terminal_result", + "evidence_text", + "findings", + "causal_score", +]; + +/// One metric-free collection row for an accepted temporal-context identity. +/// +/// The row names the idempotency identity and cutoff. It never carries event +/// labels, actor lists, timeline events, or scientific-acceptance artifacts. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct TemporalContextCollectionItem { + /// Exact request idempotency key that minted the stored identity. + pub idempotency_key: String, + /// Knowledge cutoff applied to the stored identity. + pub knowledge_cutoff: String, + /// Fixed claim boundary: sequence is association, not causation. + pub inference_status: String, +} + +impl TemporalContextCollectionItem { + /// Construct a validated metric-free collection row. + /// + /// # Errors + /// + /// Returns a fail-closed error for empty identities, an oversized + /// idempotency key, slash/NUL, or a causal inference status. + pub fn new( + idempotency_key: impl Into, + knowledge_cutoff: impl Into, + inference_status: impl Into, + ) -> Result { + let item = Self { + idempotency_key: idempotency_key.into(), + knowledge_cutoff: knowledge_cutoff.into(), + inference_status: inference_status.into(), + }; + item.validate()?; + Ok(item) + } + + fn validate(&self) -> Result<(), ApiError> { + require_nonempty(&self.idempotency_key)?; + require_nonempty(&self.knowledge_cutoff)?; + if self.idempotency_key.contains('/') || self.idempotency_key.contains('\0') { + return Err(ApiError::InvalidWirePayload); + } + if self.idempotency_key.len() > TEMPORAL_CONTEXT_COLLECTION_CURSOR_MAX_LEN { + return Err(ApiError::LimitExceeded); + } + if self.inference_status != TEMPORAL_CONTEXT_COLLECTION_INFERENCE_STATUS { + return Err(ApiError::InvalidWirePayload); + } + Ok(()) + } +} + +/// Versioned metric-free temporal-context collection page. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct TemporalContextCollection { + /// Semantic contract version for this payload family. + pub contract_version: u16, + /// Bounded page of metric-free rows, sorted by `idempotency_key`. + pub contexts: Vec, + /// Exclusive cursor for the next page when more rows remain. + #[serde(skip_serializing_if = "Option::is_none")] + pub next_cursor: Option, +} + +impl TemporalContextCollection { + /// Construct a validated collection page. + /// + /// # Errors + /// + /// Returns a fail-closed error when a row is invalid, the page exceeds the + /// maximum limit, or `next_cursor` is empty or oversized. + pub fn new( + contexts: Vec, + next_cursor: Option, + ) -> Result { + let collection = Self { + contract_version: TEMPORAL_CONTEXT_COLLECTION_CONTRACT_VERSION, + contexts, + next_cursor, + }; + collection.validate()?; + Ok(collection) + } + + /// Parse and validate a collection payload with the default byte limit. + /// + /// # Errors + /// + /// Returns wire, version, limit, metric-key, or field-validation errors. + pub fn from_json(payload: &str) -> Result { + Self::from_json_with_limit(payload, DEFAULT_PROJECT_HISTORY_BYTE_LIMIT) + } + + /// Parse and validate a collection payload with a caller-supplied limit. + /// + /// # Errors + /// + /// Returns wire, version, limit, metric-key, or field-validation errors. + pub fn from_json_with_limit(payload: &str, maximum_bytes: usize) -> Result { + require_byte_limit(payload, maximum_bytes)?; + refuse_metrics_on_temporal_context_collection_payload(payload)?; + let collection: Self = from_json(payload)?; + collection.validate()?; + Ok(collection) + } + + /// Serialize this collection after complete validation. + /// + /// # Errors + /// + /// Returns validation or serialization errors. + pub fn to_json(&self) -> Result { + self.validate()?; + let payload = to_json(self)?; + require_byte_limit(&payload, DEFAULT_PROJECT_HISTORY_BYTE_LIMIT)?; + refuse_metrics_on_temporal_context_collection_payload(&payload)?; + Ok(payload) + } + + fn validate(&self) -> Result<(), ApiError> { + require_contract_version( + self.contract_version, + TEMPORAL_CONTEXT_COLLECTION_CONTRACT_VERSION, + )?; + if self.contexts.len() > TEMPORAL_CONTEXT_COLLECTION_MAX_LIMIT { + return Err(ApiError::LimitExceeded); + } + for item in &self.contexts { + item.validate()?; + } + if let Some(cursor) = &self.next_cursor { + require_nonempty(cursor)?; + if cursor.len() > TEMPORAL_CONTEXT_COLLECTION_CURSOR_MAX_LEN { + return Err(ApiError::LimitExceeded); + } + } + Ok(()) + } +} + +/// Refuse collection JSON that already carries scientific-metric or evidence keys. +/// +/// Empty payloads fail closed as valid request bodies. Non-object JSON fails +/// closed as invalid wire when nonempty. +/// +/// # Errors +/// +/// Returns [`ApiError::InvalidWirePayload`] when a forbidden metric, evidence, +/// or causal-score key is present. +pub fn refuse_metrics_on_temporal_context_collection_payload(payload: &str) -> Result<(), ApiError> { + if payload.trim().is_empty() { + return Ok(()); + } + if payload.contains("tepp.scientific_acceptance.v1") { + return Err(ApiError::InvalidWirePayload); + } + let value: serde_json::Value = + serde_json::from_str(payload).map_err(|_| ApiError::InvalidWirePayload)?; + refuse_metrics_on_json(&value) +} + +fn refuse_metrics_on_json(value: &serde_json::Value) -> Result<(), ApiError> { + match value { + serde_json::Value::Object(object) => { + if FORBIDDEN_COLLECTION_KEYS + .iter() + .any(|key| object.contains_key(*key)) + { + return Err(ApiError::InvalidWirePayload); + } + for nested in object.values() { + refuse_metrics_on_json(nested)?; + } + Ok(()) + } + serde_json::Value::Array(items) => { + for nested in items { + refuse_metrics_on_json(nested)?; + } + Ok(()) + } + _ => Ok(()), + } +} + +/// Parse the optional `tepp-page-limit` header. +/// +/// Absent header uses [`TEMPORAL_CONTEXT_COLLECTION_DEFAULT_LIMIT`]. Zero, a +/// non-integer, or a value above [`TEMPORAL_CONTEXT_COLLECTION_MAX_LIMIT`] fail +/// closed. +/// +/// # Errors +/// +/// Returns [`ApiError::InvalidWirePayload`] for a non-integer and +/// [`ApiError::LimitExceeded`] when the requested page is larger than the +/// maximum. +pub fn parse_temporal_context_collection_page_limit(raw: Option<&str>) -> Result { + let Some(raw) = raw else { + return Ok(TEMPORAL_CONTEXT_COLLECTION_DEFAULT_LIMIT); + }; + let limit: usize = raw.parse().map_err(|_| ApiError::InvalidWirePayload)?; + if limit == 0 { + return Err(ApiError::InvalidWirePayload); + } + if limit > TEMPORAL_CONTEXT_COLLECTION_MAX_LIMIT { + return Err(ApiError::LimitExceeded); + } + Ok(limit) +} + +/// Parse the optional exclusive `tepp-page-cursor` header. +/// +/// # Errors +/// +/// Returns [`ApiError::InvalidWirePayload`] for an empty cursor and +/// [`ApiError::LimitExceeded`] when the cursor exceeds +/// [`TEMPORAL_CONTEXT_COLLECTION_CURSOR_MAX_LEN`]. +pub fn parse_temporal_context_collection_page_cursor( + raw: Option<&str>, +) -> Result, ApiError> { + let Some(raw) = raw else { + return Ok(None); + }; + require_nonempty(raw)?; + if raw.len() > TEMPORAL_CONTEXT_COLLECTION_CURSOR_MAX_LEN { + return Err(ApiError::LimitExceeded); + } + Ok(Some(raw.to_owned())) +} + +/// Return whether `path` is exactly the temporal-context collection resource. +#[must_use] +pub fn is_temporal_context_collection_path(path: &str) -> bool { + path == TEMPORAL_CONTEXT_PATH +} + +/// Page stored rows after an exclusive cursor, sorted by idempotency key. +#[must_use] +pub fn page_temporal_context_collection_items( + mut items: Vec, + cursor: Option<&str>, + limit: usize, +) -> (Vec, Option) { + items.sort_by(|left, right| left.idempotency_key.cmp(&right.idempotency_key)); + if let Some(cursor) = cursor { + items.retain(|item| item.idempotency_key.as_str() > cursor); + } + let next_cursor = if items.len() > limit { + Some(items[limit - 1].idempotency_key.clone()) + } else { + None + }; + items.truncate(limit); + (items, next_cursor) +} + +/// Build a provider-owned `GET` temporal-context collection exchange. +/// +/// The builder refuses non-`https` origins and does not inject credentials. +/// Loopback pagination uses `tepp-page-cursor` and `tepp-page-limit` headers +/// because the shared request-line parser fails closed on query strings. +/// +/// # Errors +/// +/// Returns [`ApiError::InvalidWirePayload`] for a non-`https` origin or an +/// empty cursor, and [`ApiError::LimitExceeded`] when limit or cursor bounds +/// are exceeded. +pub fn lineageweave_temporal_context_collection_exchange( + origin: &str, + cursor: Option<&str>, + limit: Option<&str>, +) -> Result { + let _ = parse_temporal_context_collection_page_limit(limit)?; + let _ = parse_temporal_context_collection_page_cursor(cursor)?; + let target_url = compose_https_target(origin, TEMPORAL_CONTEXT_PATH)?; + let mut headers = vec![ + ("content-type".into(), "application/json".into()), + ("tepp-consumer".into(), "lineageweave".into()), + ("tepp-contract-version".into(), "1".into()), + ]; + if let Some(cursor) = cursor { + headers.push(("tepp-page-cursor".into(), cursor.to_owned())); + } + if let Some(limit) = limit { + headers.push(("tepp-page-limit".into(), limit.to_owned())); + } + Ok(NaruonHttpExchange { + method: "GET", + target_url, + headers, + body: String::new(), + }) +} + +#[cfg(test)] +mod tests { + use super::{ + TEMPORAL_CONTEXT_COLLECTION_CURSOR_MAX_LEN, TEMPORAL_CONTEXT_COLLECTION_INFERENCE_STATUS, + TEMPORAL_CONTEXT_COLLECTION_MAX_LIMIT, TemporalContextCollection, + TemporalContextCollectionItem, is_temporal_context_collection_path, + lineageweave_temporal_context_collection_exchange, page_temporal_context_collection_items, + parse_temporal_context_collection_page_cursor, parse_temporal_context_collection_page_limit, + refuse_metrics_on_temporal_context_collection_payload, + }; + use crate::ApiError; + + fn sample_item() -> TemporalContextCollectionItem { + TemporalContextCollectionItem::new( + "idem-1", + "2026-08-20T00:00:00Z", + TEMPORAL_CONTEXT_COLLECTION_INFERENCE_STATUS, + ) + .expect("item") + } + + #[test] + fn collection_round_trips_and_refuses_hostile_shapes() { + let collection = TemporalContextCollection::new(vec![sample_item()], None).expect("page"); + let json = collection.to_json().expect("json"); + assert_eq!( + TemporalContextCollection::from_json(&json).expect("decode"), + collection + ); + assert!(!json.contains("rmse")); + assert!(!json.contains("scientific_acceptance")); + assert!(!json.contains("evidence_text")); + assert!(!json.contains("event_label")); + assert!(!json.contains("actor_references")); + assert!(!json.contains("next_cursor")); + assert!(!json.contains("causal_score")); + + assert_eq!( + TemporalContextCollectionItem::new( + "", + "2026-08-20T00:00:00Z", + TEMPORAL_CONTEXT_COLLECTION_INFERENCE_STATUS + ), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + TemporalContextCollectionItem::new( + "a/b", + "2026-08-20T00:00:00Z", + TEMPORAL_CONTEXT_COLLECTION_INFERENCE_STATUS + ), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + TemporalContextCollectionItem::new( + "idem-1", + "2026-08-20T00:00:00Z", + "causal_score" + ), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + TemporalContextCollectionItem::new( + "a".repeat(TEMPORAL_CONTEXT_COLLECTION_CURSOR_MAX_LEN + 1), + "2026-08-20T00:00:00Z", + TEMPORAL_CONTEXT_COLLECTION_INFERENCE_STATUS, + ), + Err(ApiError::LimitExceeded) + ); + + let mut unsupported = collection.clone(); + unsupported.contract_version = 9; + assert_eq!( + unsupported.to_json(), + Err(ApiError::UnsupportedContractVersion) + ); + assert_eq!( + TemporalContextCollection::from_json(r#"{"contract_version":9,"contexts":[]}"#), + Err(ApiError::UnsupportedContractVersion) + ); + assert_eq!( + TemporalContextCollection::from_json( + r#"{"contract_version":1,"contexts":[],"extra":true}"# + ), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + TemporalContextCollection::from_json_with_limit(&json, 8), + Err(ApiError::LimitExceeded) + ); + assert_eq!( + TemporalContextCollection::new(vec![sample_item()], Some(String::new())), + Err(ApiError::InvalidWirePayload) + ); + let oversized = vec![sample_item(); TEMPORAL_CONTEXT_COLLECTION_MAX_LIMIT + 1]; + assert_eq!( + TemporalContextCollection::new(oversized, None), + Err(ApiError::LimitExceeded) + ); + } + + #[test] + fn collection_payloads_refuse_scientific_metric_and_evidence_keys() { + assert_eq!( + refuse_metrics_on_temporal_context_collection_payload(""), + Ok(()) + ); + assert_eq!( + refuse_metrics_on_temporal_context_collection_payload(r#"{"contexts":[]}"#), + Ok(()) + ); + assert_eq!( + refuse_metrics_on_temporal_context_collection_payload(r#"{"rmse":1.0}"#), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + refuse_metrics_on_temporal_context_collection_payload( + r#"{"contexts":[{"evidence_text":"secret"}]}"# + ), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + refuse_metrics_on_temporal_context_collection_payload(r#"{"causal_score":1}"#), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + refuse_metrics_on_temporal_context_collection_payload( + r#"{"schema_version":"tepp.scientific_acceptance.v1"}"# + ), + Err(ApiError::InvalidWirePayload) + ); + } + + #[test] + fn pagination_and_exchange_fail_closed() { + assert_eq!( + parse_temporal_context_collection_page_limit(None).expect("default"), + 32 + ); + assert_eq!( + parse_temporal_context_collection_page_limit(Some("0")), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + parse_temporal_context_collection_page_limit(Some("65")), + Err(ApiError::LimitExceeded) + ); + assert_eq!( + parse_temporal_context_collection_page_cursor(Some("")), + Err(ApiError::InvalidWirePayload) + ); + assert!(is_temporal_context_collection_path("/v1/temporal-context")); + assert!(!is_temporal_context_collection_path("/v1/analysis-runs")); + assert!(!is_temporal_context_collection_path("/v1/project-histories")); + + let first = sample_item(); + let second = TemporalContextCollectionItem::new( + "idem-2", + "2026-08-20T00:00:00Z", + TEMPORAL_CONTEXT_COLLECTION_INFERENCE_STATUS, + ) + .expect("second"); + let (page, cursor) = + page_temporal_context_collection_items(vec![second.clone(), first.clone()], None, 1); + assert_eq!(page, vec![first.clone()]); + assert_eq!(cursor.as_deref(), Some("idem-1")); + let (rest, done) = + page_temporal_context_collection_items(vec![second.clone(), first], Some("idem-1"), 32); + assert_eq!(rest, vec![second]); + assert_eq!(done, None); + + let exchange = lineageweave_temporal_context_collection_exchange( + "https://tepp.example.test", + Some("idem-1"), + Some("8"), + ) + .expect("exchange"); + assert_eq!(exchange.method, "GET"); + assert!(exchange.target_url.ends_with("/v1/temporal-context")); + assert!( + !exchange + .headers + .iter() + .any(|(name, _)| name.eq_ignore_ascii_case("authorization")) + ); + assert!(exchange.body.is_empty()); + assert_eq!( + lineageweave_temporal_context_collection_exchange("http://insecure.example", None, None), + Err(ApiError::InvalidWirePayload) + ); + } +} diff --git a/crates/tepp_api/tests/temporal_context_collection_http_contract.rs b/crates/tepp_api/tests/temporal_context_collection_http_contract.rs new file mode 100644 index 000000000..692d6414b --- /dev/null +++ b/crates/tepp_api/tests/temporal_context_collection_http_contract.rs @@ -0,0 +1,68 @@ +//! Contract tests for loopback `GET /v1/temporal-context`. + +use std::io::{Read, Write}; + +use tepp_api::{ + AnalysisRunLiveService, LINEAGEWEAVE_CONSUMER_CODE, NARUON_CONSUMER_CODE, + TEMPORAL_CONTEXT_PATH, TemporalContextCollection, +}; + +const TEMPORAL_BODY: &str = r#"{"contract_version":1,"consumer_code":"lineageweave","knowledge_cutoff":"2026-08-20T00:00:00Z","subject_post_id":null,"events":[{"event_id":"event-1","source_post_id":"post-1","event_type_code":"order_awarded","event_label":"Order awarded","event_time":"2026-08-01T09:00:00Z","available_time":"2026-08-01T10:00:00Z","project_reference":null,"actor_references":["actor-1"]}]}"#; + +fn post_http(idempotency_key: &str) -> String { + format!( + "POST {TEMPORAL_CONTEXT_PATH} HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: {LINEAGEWEAVE_CONSUMER_CODE}\r\ntepp-contract-version: 1\r\nidempotency-key: {idempotency_key}\r\ncontent-length: {}\r\n\r\n{TEMPORAL_BODY}", + TEMPORAL_BODY.len() + ) +} + +#[test] +fn collection_get_pages_metric_free_identities() { + let mut service = AnalysisRunLiveService::new(); + assert_eq!(service.handle_http_request(&post_http("idem-b")).status_code, 200); + assert_eq!(service.handle_http_request(&post_http("idem-a")).status_code, 200); + let listed = service.handle_http_request( + &format!( + "GET {TEMPORAL_CONTEXT_PATH} HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: {LINEAGEWEAVE_CONSUMER_CODE}\r\ntepp-contract-version: 1\r\ntepp-page-limit: 1\r\ncontent-length: 0\r\n\r\n" + ), + ); + assert_eq!(listed.status_code, 200, "{}", listed.body); + let page = TemporalContextCollection::from_json(&listed.body).expect("page"); + assert_eq!(page.contexts.len(), 1); + assert_eq!(page.contexts[0].idempotency_key, "idem-a"); + assert_eq!(page.next_cursor.as_deref(), Some("idem-a")); + assert!(!listed.body.contains("event_label")); + assert!(!listed.body.contains("rmse")); +} + +#[test] +fn collection_get_refuses_naruon_and_serves_over_tcp() { + let mut service = AnalysisRunLiveService::bind_loopback().expect("bind"); + assert_eq!(service.handle_http_request(&post_http("idem-tcp")).status_code, 200); + assert_eq!( + service + .handle_http_request( + &format!( + "GET {TEMPORAL_CONTEXT_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" + ) + ) + .status_code, + 400 + ); + let addr = service.local_addr().expect("addr"); + let handle = std::thread::spawn(move || { + drop(service.serve_one()); + }); + let request = format!( + "GET {TEMPORAL_CONTEXT_PATH} HTTP/1.1\r\nHost: {addr}\r\ncontent-type: application/json\r\ntepp-consumer: {LINEAGEWEAVE_CONSUMER_CODE}\r\ntepp-contract-version: 1\r\ncontent-length: 0\r\n\r\n" + ); + let mut stream = std::net::TcpStream::connect(addr).expect("connect"); + stream.write_all(request.as_bytes()).expect("write"); + stream.flush().expect("flush"); + let mut bytes = Vec::new(); + stream.read_to_end(&mut bytes).expect("read"); + let text = String::from_utf8(bytes).expect("utf8"); + assert!(text.contains("HTTP/1.1 200"), "{text}"); + assert!(text.contains("idem-tcp"), "{text}"); + handle.join().expect("join"); +} diff --git a/docs/API_CONTRACT.md b/docs/API_CONTRACT.md index b76b688e1..9ed929e1b 100644 --- a/docs/API_CONTRACT.md +++ b/docs/API_CONTRACT.md @@ -65,6 +65,7 @@ GET /v1/evidence-imports/{import_id} POST /v1/interpretation-runs POST /v1/analysis-runs POST /v1/temporal-context +GET /v1/temporal-context GET /v1/analysis-runs/{run_id} POST /v1/analysis-runs/{run_id}/cancel GET /v1/model-artifacts/{artifact_id} @@ -90,6 +91,11 @@ only events whose availability time is at or before `knowledge_cutoff`, orders them by event time and opaque event ID, and emits adjacent forward temporal associations plus `candidate_not_causal` transition gaps. It does not infer causality, mutate TEPP state, or return a completed psychometric result. +`GET /v1/temporal-context` enumerates accepted metric-free identities minted +when that POST carries an `idempotency-key` header (ADR 0081). Collection rows +stay `inference_status=temporal_association_only`. Event labels, actor lists, +and `tepp.scientific_acceptance.v1` never appear. Naruon is refused. +`NaruonLiveService` stays POST-only. The typed status/read contract returns `accepted`, `running`, `succeeded`, or `failed`. Accepted and running statuses contain no measurement result. A diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 2b783c2ab..c591525a5 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -53,6 +53,7 @@ The full APA 7th standards/literature register remains `docs/research/standards- | PostgreSQL bitemporal/lineage persistence | ADR 0013; Architecture/ERD | `persistence_postgres` migration contracts, in-memory adapters, live SQL session/document SQL port, tenant RLS (`0002` + session GUC/role helpers), `DATABASE_URL` SQLx gate, optional session-affine `live-sqlx` `PgPool` driver, exact-head live PostgreSQL CI with isolation proof, append-only immutability triggers (`0004`), temporal interval ordering CHECKs (`0005`), typed membership assignment (`0006` implemented-main), event-relation/mention/instance SQL (#37–#39 implemented-main), source-artifact SQL (#40 implemented-main), audit-event SQL (#41 implemented-main), concurrent document-write stress (#43 implemented-main), backup/restore integrity revalidation (#44 implemented-main), `revision_order` later-revision system-time ordering implemented-main, entity/project target SQL on PR #131; remaining physical ERD constraints | partial | | known-truth temporal/event simulation manifests | PRD; TRD; Test Strategy | `tepp_simulation` on protected main; recovery metrics in `validation_core` | implemented-main | | versioned service/API contracts and exports | PRD; API contract; ADR 0011/0013 | `tepp_api` analysis-run/export/JSON-LD/GraphML contracts on protected main (PR #21); request-bound terminal result active in PR #157; HTTP service remains accepted-target; the `orchestrator_live` loopback interpretation listener is on this PR | partial | +| loopback LineageWeave temporal-context collection GET | ADR 0081; API contract; RFC 9110; ADR 0002/0014 | `tepp_api` `GET /v1/temporal-context` on `tepp-loopback`; metric-free `inference_status=temporal_association_only` identities; `tepp.scientific_acceptance.v1` never appears; does not infer causality | active-PR | | 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/0081-temporal-context-collection-get.md b/docs/adr/0081-temporal-context-collection-get.md new file mode 100644 index 000000000..8696ca805 --- /dev/null +++ b/docs/adr/0081-temporal-context-collection-get.md @@ -0,0 +1,96 @@ +# ADR 0081 — Loopback temporal-context collection GET + +**Decision status:** Accepted +**Implementation maturity:** active-PR +**Date:** 2026-09-01 +**Supersedes:** None; complements the LineageWeave `POST /v1/temporal-context` +read contract. Does not supersede ADR 0014 claim-promotion authority. This ADR +number is unique versus protected main; live vs-main and sibling GAP-003A PRs +already occupy 0026–0080. + +## Context + +`POST /v1/temporal-context` returns one cutoff-safe association page. Operators +still had no loopback GET that enumerates accepted identities without guessing +idempotency keys. Duplicating temporal-context CLI (#414), project-history +collection GET (#424), interpretation-run collection GET (#433), export +collection GET (#443), Leiden, Driver p.16, or GAP-010 Figma/export would +collide with live PRs. Naruon is refused on this LineageWeave-owned adapter; +`NaruonLiveService` stays POST-only. + +## Decision + +Publish `GET /v1/temporal-context` on `AnalysisRunLiveService` / `tepp-loopback`: + +- Empty body. Present `idempotency-key` header fails closed. +- Public bind, unpublished consumer, and credential headers fail closed. +- Collection rows are metric-free identities with + `inference_status=temporal_association_only`. Event labels, actor lists, + timeline events, evidence text, findings, RMSE, bias, coverage, SE-gate, + causal scores, and `tepp.scientific_acceptance.v1` never appear. +- POST remains a compute-and-return read. An optional `idempotency-key` header + mints the identity into the in-memory collection; POST without that header + stays backward compatible and is not listed. + +## Alternatives considered + +1. **Reuse `GET /v1/project-histories`** — rejected; that collection is + project-history owned. +2. **Add GET to `NaruonLiveService`** — rejected; POST-only. +3. **Loopback `GET /v1/temporal-context`** — accepted. + +## Consequences + +- Operators can list accepted temporal-context identities without a second + POST replay. +- Collection JSON cannot be mistaken for a succeeded scientific-acceptance + result or a causal score. +- Collection success is not release evidence and is not an ADR 0014 claim. + +## Failure and recovery + +Non-LineageWeave consumers, nonempty GET bodies, present `idempotency-key` as +a GET header, slash/NUL identities, credential flags, public bind, and metric +keys fail closed. + +## Security, privacy, scientific-integrity, and governance impact + +- No credential headers cross the consumer boundary. +- Event labels, actor lists, and evidence stay off the collection receipt. +- HTTP 200 on collection is not measurement evidence and is not a causal claim. + +## Compatibility and migration + +POST `/v1/temporal-context` without an idempotency header remains valid. +`NaruonLiveService` POST-only remains unchanged. Persistence remains GAP-003B. + +## Verification + +Falsifiable evidence: + +- `GET /v1/temporal-context` of accepted LineageWeave identities returns + metric-free rows without RMSE/bias/coverage/SE-gate/event-label/actor/ + evidence/findings/causal-score/`tepp.scientific_acceptance.v1` keys; +- naruon, nonempty leftover body, GET `idempotency-key`, slash/NUL identities, + public bind, and unknown keys fail closed; +- `NaruonLiveService` still refuses GET; +- Clippy `-D warnings`, `tepp_api` tests, rustdoc, and exact-head review + remain required. + +## Rollback and supersession + +Rollback removes collection GET; POST remains valid. A superseding ADR is +required to persist the registry, bind a public address, emit +scientific-acceptance on collection, open naruon on this adapter, add GET to +`NaruonLiveService`, or treat collection success as an ADR 0014 claim. + +## Related authority + +- ADR 0002 owns six-clock temporal semantics. +- ADR 0027 owns the temporal-context CLI (live #414). +- ADR 0075 owns export collection GET (live #443). +- ADR 0069 owns interpretation-run collection GET (live #433). +- ADR 0028 owns project-history collection GET (live #424). +- 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 1254c8079..24a87240f 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -30,6 +30,7 @@ Read [`ADR_POLICY.md`](ADR_POLICY.md) first. **Decision status and implementatio | [0022](0022-deterministic-analysis-run-execution.md) | Deterministic cutoff-safe analysis-run execution | Accepted | active-PR | Closes the first executable product path from accepted run to digest-bound terminal result without claiming estimator authority. | | [0024](0024-lineage-pair-criterion-and-project-journey-posterior.md) | Independent Event Lineage pair criterion and posterior Project Journey | Proposed | active-PR | Strict artifacts preserve criterion/event-time draws, branches, ties, and CPU/GPU receipts without claiming the scientific estimator is complete. | | [0025](0025-macos-native-rust-mlx-metal-boundary.md) | macOS-native Rust-owned MLX Metal execution | Accepted | accepted-target | Compose authenticates to a native host service; Linux never claims Metal, and actual backend/parity receipts fail closed. | +| [0081](0081-temporal-context-collection-get.md) | Loopback temporal-context collection GET | Accepted | active-PR | Complements `POST /v1/temporal-context`; `GET /v1/temporal-context` enumerates metric-free LineageWeave identities. Unique versus protected main (0026–0080 occupied). Naruon refused. `NaruonLiveService` stays POST-only. | | [0023](0023-lineage-criterion-anchor-contract.md) | TEPP-owned Event Lineage criterion anchor | Accepted | active-PR | PR #237 publishes the strict accepted/rejected artifact and identities; estimator execution remains fail-closed future work. | | [0024](0024-independent-topic-importance-anchor.md) | Posterior topic-context producer contract | Accepted | contract-only active-PR | Strict DTO/schema only; the current estimator does not emit it. fast-mlsirm owns case-deletion influence. | | [0001](0001-rust-first-modular-msa.md) | Rust-first numerical core and CPU `f64` reference | Accepted | partial | ADR 0011 owns cross-service/MSA authority; 0001 retains numerical/backend authority. | diff --git a/docs/research/temporal-context-collection-get.md b/docs/research/temporal-context-collection-get.md new file mode 100644 index 000000000..0518ec36d --- /dev/null +++ b/docs/research/temporal-context-collection-get.md @@ -0,0 +1,56 @@ +# Temporal-context collection GET (doctoring) + +## Scope + +`GET /v1/temporal-context` is the operator-visible loopback collection that +enumerates accepted LineageWeave temporal-context identities 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 leftover bodies, +present `idempotency-key` on GET, slash/NUL identities, credential flags, +public bind, and scientific-authority promotion is repository contract +authority (ADR 0081; ADR 0002; ADR 0014), not an RFC inference rule. + +Collection rows are metric-free with `inference_status=temporal_association_only`. +Event labels, actor lists, timeline events, evidence text, findings, and +`tepp.scientific_acceptance.v1` never appear. HTTP 200 is not a completed +psychometric result, calibrated score, theta estimate, uncertainty statement, +causal inference, or scientific claim. + +## Authority + +### External standards (HTTP only) + +Fielding, R., Nottingham, M., & Reschke, J. (Eds.). (2022). *HTTP semantics* +(RFC 9110). IETF. https://doi.org/10.17487/RFC9110 + +RFC 9110 §9.3.1 describes GET as a method for retrieving a current +representation. TEPP maps that retrieval onto an in-memory page of metric-free +temporal-context identities. The RFC does not define psychometric acceptance, +RMSE, causality, or claim promotion. + +### Internal contract evidence + +- `docs/adr/0081-temporal-context-collection-get.md` — this GET +- `docs/adr/0002-six-clock-temporal-semantics.md` — cutoff-safe association +- `docs/adr/0014-scientific-claim-promotion-and-release-evidence.md` — HTTP + 200 is not a scientific claim +- `crates/tepp_api/tests/temporal_context_collection_http_contract.rs` — + fail-closed collection proofs + +## Verification + +- `GET /v1/temporal-context` of accepted LineageWeave identities returns + metric-free rows without RMSE/bias/coverage/SE-gate keys, event labels, + actor lists, evidence text, findings, causal scores, or + `tepp.scientific_acceptance.v1`; +- naruon, nonempty leftover body, GET `idempotency-key`, slash/NUL identities, + and public bind fail closed; +- `NaruonLiveService` still refuses GET. + +## Non-claims + +This slice does not implement GAP-010 Figma/export, temporal-context CLI, +project-history collection GET, persistence, production TLS, Leiden consensus, +provider execution, causal inference, or an ADR 0014 scientific +claim-promotion package.