diff --git a/CHANGELOG.d/analysis-run-collection-http.md b/CHANGELOG.d/analysis-run-collection-http.md new file mode 100644 index 000000000..c85ed89e0 --- /dev/null +++ b/CHANGELOG.d/analysis-run-collection-http.md @@ -0,0 +1 @@ +- `tepp_api` loopback `GET /v1/analysis-runs` enumerates metric-free accepted, running, cancelled, and terminal runs (ADR 0031). Collection bodies refuse RMSE/bias/coverage/SE-gate/scientific-acceptance keys. Not GET-by-id, not lifecycle POST, not persistence. diff --git a/CHANGELOG.md b/CHANGELOG.md index 29a24a159..b55a4f9c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,8 @@ All notable changes to TEPP are documented here. The format follows Keep a Chang ## [Unreleased] +- `tepp_api` serves `GET /v1/analysis-runs` on the shared loopback listener (ADR 0031). Operators enumerate accepted, running, cancelled, and terminal runs as metric-free collection rows. Collection bodies refuse RMSE/bias/coverage/SE-gate/scientific-acceptance/`terminal_result` keys. GET-by-id and running/terminal POST remain later GAP-003A slices; this is not an ADR 0014 claim. + - `tepp_api` serves `POST /v1/analysis-runs/{run_id}/cancel` on the shared loopback listener (ADR 0029). Accepted and running runs become metric-free `cancelled` status. Succeeded, failed, and unknown runs cannot be cancelled. Cancel bodies refuse RMSE/bias/coverage/SE-gate/scientific-acceptance keys. GET status and running/terminal POST remain later GAP-003A slices; this is not an ADR 0014 claim. - `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. diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 5ada27f8e..b29d53702 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -14,6 +14,7 @@ TEPP's approved PRD v0.4 and implementation plan are the primary product baselin | contextual-orchestrator interpretation port | [`docs/connectors/contextual-orchestrator-interpretation-port.md`](docs/connectors/contextual-orchestrator-interpretation-port.md) | | Orchestrator live HTTP doctoring | [`docs/research/orchestrator-live-http.md`](docs/research/orchestrator-live-http.md) | | Analysis-run cancel HTTP doctoring | [`docs/research/analysis-run-cancel-http.md`](docs/research/analysis-run-cancel-http.md) | +| Analysis-run collection HTTP doctoring | [`docs/research/analysis-run-collection-http.md`](docs/research/analysis-run-collection-http.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_collection_http.rs b/crates/tepp_api/src/analysis_run_collection_http.rs new file mode 100644 index 000000000..7c8bd1882 --- /dev/null +++ b/crates/tepp_api/src/analysis_run_collection_http.rs @@ -0,0 +1,529 @@ +//! Provider-owned analysis-run collection GET contracts. +//! +//! GAP-003A seventh slice: `GET /v1/analysis-runs` enumerates accepted, +//! running, cancelled, and terminal runs on the shared loopback listener so +//! operators do not guess run identities. Collection bodies stay metric-free. +//! `tepp.scientific_acceptance.v1` never appears on the list. GET-by-id (#359), +//! lifecycle POST (#360), cancel HTTP (#361), and loopback CLI (#362) remain +//! other live slices. 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::{ + ANALYSIS_RUN_STATUS_PATH, AnalysisRunStatusState, ApiError, DEFAULT_ANALYSIS_RUN_BYTE_LIMIT, +}; +use serde::{Deserialize, Serialize}; + +/// Supported analysis-run collection contract version. +pub const ANALYSIS_RUN_COLLECTION_CONTRACT_VERSION: u16 = 1; + +/// Default page size for loopback collection GET. +pub const ANALYSIS_RUN_COLLECTION_DEFAULT_LIMIT: usize = 32; + +/// Maximum page size accepted on loopback collection GET. +pub const ANALYSIS_RUN_COLLECTION_MAX_LIMIT: usize = 64; + +/// Maximum opaque cursor / run-identity length on the collection path. +pub const ANALYSIS_RUN_COLLECTION_CURSOR_MAX_LEN: usize = 128; + +const FORBIDDEN_COLLECTION_KEYS: [&str; 13] = [ + "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", +]; + +/// One metric-free collection row for an analysis run. +/// +/// The row names the durable identity and lifecycle state. It never carries a +/// terminal result or scientific-acceptance artifact. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct AnalysisRunCollectionItem { + /// Opaque server-assigned run identity. + pub run_id: String, + /// Current lifecycle state. + pub run_state: AnalysisRunStatusState, + /// Exact request idempotency key. + pub idempotency_key: String, +} + +impl AnalysisRunCollectionItem { + /// Construct a validated metric-free collection row. + /// + /// # Errors + /// + /// Returns a fail-closed error for empty identities or an oversized run + /// identity. + pub fn new( + run_id: impl Into, + run_state: AnalysisRunStatusState, + idempotency_key: impl Into, + ) -> Result { + let item = Self { + run_id: run_id.into(), + run_state, + idempotency_key: idempotency_key.into(), + }; + item.validate()?; + Ok(item) + } + + fn validate(&self) -> Result<(), ApiError> { + require_nonempty(&self.run_id)?; + require_nonempty(&self.idempotency_key)?; + if self.run_id.len() > ANALYSIS_RUN_COLLECTION_CURSOR_MAX_LEN { + return Err(ApiError::LimitExceeded); + } + Ok(()) + } +} + +/// Versioned metric-free analysis-run collection page. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct AnalysisRunCollection { + /// Semantic contract version for this payload family. + pub contract_version: u16, + /// Bounded page of metric-free run rows, sorted by `run_id`. + pub runs: Vec, + /// Exclusive cursor for the next page when more rows remain. + #[serde(skip_serializing_if = "Option::is_none")] + pub next_cursor: Option, +} + +impl AnalysisRunCollection { + /// 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( + runs: Vec, + next_cursor: Option, + ) -> Result { + let collection = Self { + contract_version: ANALYSIS_RUN_COLLECTION_CONTRACT_VERSION, + runs, + 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_ANALYSIS_RUN_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_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_ANALYSIS_RUN_BYTE_LIMIT)?; + refuse_metrics_on_collection_payload(&payload)?; + Ok(payload) + } + + fn validate(&self) -> Result<(), ApiError> { + require_contract_version( + self.contract_version, + ANALYSIS_RUN_COLLECTION_CONTRACT_VERSION, + )?; + if self.runs.len() > ANALYSIS_RUN_COLLECTION_MAX_LIMIT { + return Err(ApiError::LimitExceeded); + } + for item in &self.runs { + item.validate()?; + } + if let Some(cursor) = &self.next_cursor { + require_nonempty(cursor)?; + if cursor.len() > ANALYSIS_RUN_COLLECTION_CURSOR_MAX_LEN { + return Err(ApiError::LimitExceeded); + } + } + Ok(()) + } +} + +/// Refuse collection JSON that already carries scientific-metric keys. +/// +/// Empty payloads fail closed: collection GET has an empty request body and a +/// nonempty object response. Non-object JSON fails closed as invalid wire. +/// +/// # Errors +/// +/// Returns [`ApiError::InvalidWirePayload`] when a forbidden metric key is +/// present or the payload is a non-object. +pub fn refuse_metrics_on_collection_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)?; + 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 [`ANALYSIS_RUN_COLLECTION_DEFAULT_LIMIT`]. Zero, a +/// non-integer, or a value above [`ANALYSIS_RUN_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_collection_page_limit(raw: Option<&str>) -> Result { + let Some(raw) = raw else { + return Ok(ANALYSIS_RUN_COLLECTION_DEFAULT_LIMIT); + }; + let limit: usize = raw.parse().map_err(|_| ApiError::InvalidWirePayload)?; + if limit == 0 { + return Err(ApiError::InvalidWirePayload); + } + if limit > ANALYSIS_RUN_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 +/// [`ANALYSIS_RUN_COLLECTION_CURSOR_MAX_LEN`]. +pub fn parse_collection_page_cursor(raw: Option<&str>) -> Result, ApiError> { + let Some(raw) = raw else { + return Ok(None); + }; + require_nonempty(raw)?; + if raw.len() > ANALYSIS_RUN_COLLECTION_CURSOR_MAX_LEN { + return Err(ApiError::LimitExceeded); + } + Ok(Some(raw.to_owned())) +} + +/// Return whether `path` is exactly the analysis-run collection resource. +#[must_use] +pub fn is_analysis_run_collection_path(path: &str) -> bool { + path == ANALYSIS_RUN_STATUS_PATH +} + +/// Build a provider-owned `GET` analysis-run 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 naruon_analysis_run_collection_exchange( + origin: &str, + cursor: Option<&str>, + limit: Option<&str>, +) -> Result { + let _ = parse_collection_page_limit(limit)?; + let _ = parse_collection_page_cursor(cursor)?; + let target_url = compose_https_target(origin, ANALYSIS_RUN_STATUS_PATH)?; + let mut headers = vec![ + ("content-type".into(), "application/json".into()), + ("tepp-consumer".into(), "naruon".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::*; + + fn sample_item() -> AnalysisRunCollectionItem { + AnalysisRunCollectionItem::new("tepp-run-1", AnalysisRunStatusState::Accepted, "idem-1") + .expect("item") + } + + #[test] + fn collection_round_trips_and_refuses_hostile_shapes() { + let collection = AnalysisRunCollection::new(vec![sample_item()], None).expect("page"); + let json = collection.to_json().expect("json"); + assert_eq!( + AnalysisRunCollection::from_json(&json).expect("decode"), + collection + ); + assert!(!json.contains("rmse")); + assert!(!json.contains("scientific_acceptance")); + assert!(!json.contains("terminal_result")); + assert!(!json.contains("next_cursor")); + + assert_eq!( + AnalysisRunCollectionItem::new("", AnalysisRunStatusState::Accepted, "idem-1"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + AnalysisRunCollectionItem::new("tepp-run-1", AnalysisRunStatusState::Accepted, ""), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + AnalysisRunCollectionItem::new( + "a".repeat(ANALYSIS_RUN_COLLECTION_CURSOR_MAX_LEN + 1), + AnalysisRunStatusState::Accepted, + "idem-1", + ), + Err(ApiError::LimitExceeded) + ); + + let mut unsupported = collection.clone(); + unsupported.contract_version = 9; + assert_eq!( + unsupported.to_json(), + Err(ApiError::UnsupportedContractVersion) + ); + assert_eq!( + AnalysisRunCollection::from_json(r#"{"contract_version":9,"runs":[]}"#), + Err(ApiError::UnsupportedContractVersion) + ); + assert_eq!( + AnalysisRunCollection::from_json(r#"{"contract_version":1,"runs":[],"extra":true}"#), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + AnalysisRunCollection::from_json_with_limit(&json, 8), + Err(ApiError::LimitExceeded) + ); + assert_eq!( + AnalysisRunCollection::from_json("[1,2,3]"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + AnalysisRunCollection::new(vec![sample_item()], Some(String::new())), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + AnalysisRunCollection::new( + vec![sample_item()], + Some("a".repeat(ANALYSIS_RUN_COLLECTION_CURSOR_MAX_LEN + 1)), + ), + Err(ApiError::LimitExceeded) + ); + let oversized = vec![sample_item(); ANALYSIS_RUN_COLLECTION_MAX_LIMIT + 1]; + assert_eq!( + AnalysisRunCollection::new(oversized, None), + Err(ApiError::LimitExceeded) + ); + let with_cursor = + AnalysisRunCollection::new(vec![sample_item()], Some("tepp-run-1".into())) + .expect("cursor page"); + assert!( + with_cursor + .to_json() + .expect("cursor json") + .contains("next_cursor") + ); + } + + #[test] + fn collection_payloads_refuse_scientific_metric_keys() { + assert_eq!(refuse_metrics_on_collection_payload(""), Ok(())); + assert_eq!(refuse_metrics_on_collection_payload(" "), Ok(())); + assert_eq!( + refuse_metrics_on_collection_payload(r#"{"runs":[]}"#), + Ok(()) + ); + for key in FORBIDDEN_COLLECTION_KEYS { + let payload = format!(r#"{{"{key}":1,"runs":[]}}"#); + assert_eq!( + refuse_metrics_on_collection_payload(&payload), + Err(ApiError::InvalidWirePayload), + "key={key}" + ); + let nested = format!(r#"{{"contract_version":1,"runs":[{{"{key}":0}}]}}"#); + assert_eq!( + refuse_metrics_on_collection_payload(&nested), + Err(ApiError::InvalidWirePayload), + "nested key={key}" + ); + } + assert_eq!( + AnalysisRunCollection::from_json( + r#"{"contract_version":1,"runs":[],"scientific_acceptance":{}}"# + ), + Err(ApiError::InvalidWirePayload) + ); + } + + #[test] + fn collection_path_is_exact_and_page_headers_are_bounded() { + assert!(is_analysis_run_collection_path("/v1/analysis-runs")); + assert!(!is_analysis_run_collection_path("/v1/analysis-runs/")); + assert!(!is_analysis_run_collection_path( + "/v1/analysis-runs/tepp-run-1" + )); + assert!(!is_analysis_run_collection_path( + "/v1/analysis-runs/tepp-run-1/cancel" + )); + assert_eq!(parse_collection_page_limit(None).expect("default"), 32); + assert_eq!(parse_collection_page_limit(Some("1")).expect("one"), 1); + assert_eq!(parse_collection_page_limit(Some("64")).expect("max"), 64); + assert_eq!( + parse_collection_page_limit(Some("0")), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + parse_collection_page_limit(Some("65")), + Err(ApiError::LimitExceeded) + ); + assert_eq!( + parse_collection_page_limit(Some("two")), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!(parse_collection_page_cursor(None).expect("none"), None); + assert_eq!( + parse_collection_page_cursor(Some("tepp-run-1")).expect("cursor"), + Some("tepp-run-1".into()) + ); + assert_eq!( + parse_collection_page_cursor(Some("")), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + parse_collection_page_cursor(Some( + &"a".repeat(ANALYSIS_RUN_COLLECTION_CURSOR_MAX_LEN + 1) + )), + Err(ApiError::LimitExceeded) + ); + } + + #[test] + fn collection_exchange_gets_https_path_without_credentials() { + let exchange = + naruon_analysis_run_collection_exchange("https://tepp.example.com", None, None) + .expect("exchange"); + assert_eq!(exchange.method, "GET"); + assert_eq!( + exchange.target_url, + "https://tepp.example.com/v1/analysis-runs" + ); + assert!(exchange.body.is_empty()); + assert!( + exchange + .headers + .iter() + .any(|(name, value)| name == "tepp-consumer" && value == "naruon") + ); + assert!( + !exchange + .headers + .iter() + .any(|(name, _)| name.contains("authorization") + || name.contains("copilot") + || name.contains("idempotency")) + ); + + let paged = naruon_analysis_run_collection_exchange( + "https://tepp.example.com", + Some("tepp-run-1"), + Some("8"), + ) + .expect("paged"); + assert!( + paged + .headers + .iter() + .any(|(name, value)| name == "tepp-page-cursor" && value == "tepp-run-1") + ); + assert!( + paged + .headers + .iter() + .any(|(name, value)| name == "tepp-page-limit" && value == "8") + ); + + assert_eq!( + naruon_analysis_run_collection_exchange("http://tepp.example.com", None, None), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + naruon_analysis_run_collection_exchange("https://tepp.example.com", Some(""), None), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + naruon_analysis_run_collection_exchange("https://tepp.example.com", None, Some("99")), + Err(ApiError::LimitExceeded) + ); + } +} diff --git a/crates/tepp_api/src/analysis_run_live.rs b/crates/tepp_api/src/analysis_run_live.rs index d4a7b4cc0..c761f055b 100644 --- a/crates/tepp_api/src/analysis_run_live.rs +++ b/crates/tepp_api/src/analysis_run_live.rs @@ -6,7 +6,8 @@ //! acknowledgements and temporal evidence context only; completed psychometric //! results remain outside this crate. `POST /v1/analysis-runs/{run_id}/cancel` //! is the operator-visible cancel path: accepted and running runs become -//! metric-free `cancelled` status. GET status and running/terminal POST +//! metric-free `cancelled` status. `GET /v1/analysis-runs` enumerates those +//! runs without guessing identities. GET-by-id and running/terminal POST //! transitions remain later slices. use std::collections::HashMap; @@ -16,6 +17,11 @@ use std::net::{SocketAddr, TcpListener}; use crate::analysis_run_cancel_http::{ AnalysisRunCancelRequest, analysis_run_cancel_path_run_id, refuse_metrics_on_cancel_payload, }; +use crate::analysis_run_collection_http::{ + AnalysisRunCollection, AnalysisRunCollectionItem, is_analysis_run_collection_path, + parse_collection_page_cursor, parse_collection_page_limit, + refuse_metrics_on_collection_payload, +}; use crate::lineageweave_http::{LINEAGEWEAVE_CONSUMER_CODE, consumer_is_supported}; use crate::live_http::{ header_value, map_io_error, parse_headers, parse_request_line, read_http_request_with_limit, @@ -161,10 +167,13 @@ 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_analysis_runs(path, &headers, body); + } if method != "POST" { return Err(ApiError::InvalidWirePayload); } - let headers = parse_headers(&mut lines)?; if matches!( analysis_run_cancel_path_run_id(path), Ok(_) | Err(ApiError::LimitExceeded) @@ -280,10 +289,70 @@ impl AnalysisRunLiveService { Ok(json_response(200, "OK", response_body)) } + fn list_analysis_runs( + &self, + path: &str, + headers: &HashMap, + body: &str, + ) -> Result { + if !is_analysis_run_collection_path(path) { + return Err(ApiError::InvalidWirePayload); + } + if !body.trim().is_empty() { + return Err(ApiError::InvalidWirePayload); + } + let consumer = require_headers(headers, self.bound_addr, false)?; + refuse_metrics_on_collection_payload(body)?; + let limit = + parse_collection_page_limit(headers.get("tepp-page-limit").map(String::as_str))?; + let cursor = + parse_collection_page_cursor(headers.get("tepp-page-cursor").map(String::as_str))?; + let mut rows: Vec<&LiveAnalysisRun> = self + .accepted_runs + .values() + .filter(|stored| stored.consumer == consumer) + .collect(); + rows.sort_by(|left, right| left.accepted.run_id.cmp(&right.accepted.run_id)); + let start = match cursor { + Some(cursor) => { + let position = rows + .iter() + .position(|stored| stored.accepted.run_id == cursor) + .ok_or(ApiError::InvalidWirePayload)?; + position + 1 + } + None => 0, + }; + let page = rows.get(start..).unwrap_or(&[]); + let (visible, remainder) = if page.len() > limit { + page.split_at(limit) + } else { + (page, &[] as &[&LiveAnalysisRun]) + }; + let mut items = Vec::with_capacity(visible.len()); + for stored in visible { + items.push(AnalysisRunCollectionItem::new( + stored.accepted.run_id.clone(), + stored.run_state, + stored.accepted.idempotency_key.clone(), + )?); + } + let next_cursor = if remainder.is_empty() { + None + } else { + visible.last().map(|stored| stored.accepted.run_id.clone()) + }; + let collection = AnalysisRunCollection::new(items, next_cursor)?; + let response_body = collection.to_json()?; + refuse_metrics_on_collection_payload(&response_body)?; + Ok(json_response(200, "OK", response_body)) + } + /// Test-only seam that records a non-accepted loopback state. /// - /// Used to prove cancel of running, succeeded, and failed runs without - /// duplicating the live POST running/terminal lifecycle slice. + /// Used to prove cancel and collection of running, succeeded, failed, and + /// cancelled runs without duplicating the live POST running/terminal + /// lifecycle slice. #[cfg(test)] fn force_loopback_run_state( &mut self, @@ -1025,6 +1094,208 @@ mod tests { ); } + fn collection_http(consumer: &str, extra: &[(&str, &str)]) -> String { + let mut request = format!("GET {NARUON_ANALYSIS_RUN_PATH} HTTP/1.1\r\n"); + write!( + request, + "Host: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: {consumer}\r\ntepp-contract-version: 1\r\n" + ) + .expect("collection headers"); + for (name, value) in extra { + write!(request, "{name}: {value}\r\n").expect("extra header"); + } + request.push_str("content-length: 0\r\n\r\n"); + request + } + + #[test] + #[allow(clippy::too_many_lines)] + fn handler_covers_metric_free_collection_get() { + use crate::{AnalysisRunCollection, AnalysisRunStatusState}; + + let run = sample_run(); + let mut service = AnalysisRunLiveService::new(); + let empty = service.handle_http_request(&collection_http(NARUON_CONSUMER_CODE, &[])); + assert_eq!(empty.status_code, 200); + let empty_page = AnalysisRunCollection::from_json(&empty.body).expect("empty"); + assert!(empty_page.runs.is_empty()); + assert_eq!(empty_page.next_cursor, None); + assert!(!empty.body.contains("scientific_acceptance")); + assert!(!empty.body.contains("rmse")); + assert!(!empty.body.contains("terminal_result")); + + let accepted = + service.handle_http_request(&valid_request(&run, NARUON_CONSUMER_CODE, "127.0.0.1")); + assert_eq!(accepted.status_code, 202); + let mut second = run.clone(); + second.idempotency_key = "analysis-live-idem-002".into(); + assert_eq!( + service + .handle_http_request(&valid_request(&second, NARUON_CONSUMER_CODE, "127.0.0.1")) + .status_code, + 202 + ); + let mut third = run.clone(); + third.idempotency_key = "analysis-live-idem-003".into(); + assert_eq!( + service + .handle_http_request(&valid_request(&third, NARUON_CONSUMER_CODE, "127.0.0.1")) + .status_code, + 202 + ); + service + .force_loopback_run_state("tepp-run-2", AnalysisRunStatusState::Running) + .expect("running"); + service + .force_loopback_run_state("tepp-run-3", AnalysisRunStatusState::Cancelled) + .expect("cancelled"); + let mut failed = run.clone(); + failed.idempotency_key = "analysis-live-idem-004".into(); + assert_eq!( + service + .handle_http_request(&valid_request(&failed, NARUON_CONSUMER_CODE, "127.0.0.1")) + .status_code, + 202 + ); + service + .force_loopback_run_state("tepp-run-4", AnalysisRunStatusState::Failed) + .expect("failed"); + let mut succeeded = run.clone(); + succeeded.idempotency_key = "analysis-live-idem-005".into(); + assert_eq!( + service + .handle_http_request(&valid_request( + &succeeded, + NARUON_CONSUMER_CODE, + "127.0.0.1" + )) + .status_code, + 202 + ); + service + .force_loopback_run_state("tepp-run-5", AnalysisRunStatusState::Succeeded) + .expect("succeeded"); + assert_eq!( + service + .handle_http_request(&valid_request( + &run, + LINEAGEWEAVE_CONSUMER_CODE, + "127.0.0.1" + )) + .status_code, + 202 + ); + + let listed = service.handle_http_request(&collection_http(NARUON_CONSUMER_CODE, &[])); + assert_eq!(listed.status_code, 200); + let page = AnalysisRunCollection::from_json(&listed.body).expect("page"); + assert_eq!(page.runs.len(), 5); + assert_eq!(page.next_cursor, None); + assert_eq!(page.runs[0].run_id, "tepp-run-1"); + assert_eq!(page.runs[0].run_state, AnalysisRunStatusState::Accepted); + assert_eq!(page.runs[1].run_state, AnalysisRunStatusState::Running); + assert_eq!(page.runs[2].run_state, AnalysisRunStatusState::Cancelled); + assert_eq!(page.runs[3].run_state, AnalysisRunStatusState::Failed); + assert_eq!(page.runs[4].run_state, AnalysisRunStatusState::Succeeded); + assert!(!listed.body.contains("scientific_acceptance")); + assert!(!listed.body.contains("rmse")); + assert!(!listed.body.contains("terminal_result")); + + let lineage = + service.handle_http_request(&collection_http(LINEAGEWEAVE_CONSUMER_CODE, &[])); + let lineage_page = AnalysisRunCollection::from_json(&lineage.body).expect("lineage"); + assert_eq!(lineage_page.runs.len(), 1); + assert_eq!(lineage_page.runs[0].run_id, "tepp-run-6"); + + let first = service.handle_http_request(&collection_http( + NARUON_CONSUMER_CODE, + &[("tepp-page-limit", "2")], + )); + let first_page = AnalysisRunCollection::from_json(&first.body).expect("first"); + assert_eq!(first_page.runs.len(), 2); + assert_eq!(first_page.next_cursor.as_deref(), Some("tepp-run-2")); + let second_page_http = service.handle_http_request(&collection_http( + NARUON_CONSUMER_CODE, + &[("tepp-page-cursor", "tepp-run-2"), ("tepp-page-limit", "2")], + )); + let second_page = AnalysisRunCollection::from_json(&second_page_http.body).expect("second"); + assert_eq!(second_page.runs.len(), 2); + assert_eq!(second_page.runs[0].run_id, "tepp-run-3"); + assert_eq!(second_page.next_cursor.as_deref(), Some("tepp-run-4")); + let last_page = AnalysisRunCollection::from_json( + &service + .handle_http_request(&collection_http( + NARUON_CONSUMER_CODE, + &[("tepp-page-cursor", "tepp-run-4"), ("tepp-page-limit", "2")], + )) + .body, + ) + .expect("last"); + assert_eq!(last_page.runs.len(), 1); + assert_eq!(last_page.runs[0].run_id, "tepp-run-5"); + assert_eq!(last_page.next_cursor, None); + + assert_eq!( + service + .handle_http_request(&collection_http( + NARUON_CONSUMER_CODE, + &[("tepp-page-cursor", "missing")], + )) + .status_code, + 400 + ); + assert_eq!( + service + .handle_http_request(&collection_http( + NARUON_CONSUMER_CODE, + &[("tepp-page-limit", "0")], + )) + .status_code, + 400 + ); + assert_eq!( + service + .handle_http_request(&collection_http( + NARUON_CONSUMER_CODE, + &[("tepp-page-limit", "65")], + )) + .status_code, + 413 + ); + assert_eq!( + service + .handle_http_request(&format!( + "GET {NARUON_ANALYSIS_RUN_PATH}/tepp-run-1 HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: naruon\r\ntepp-contract-version: 1\r\ncontent-length: 0\r\n\r\n" + )) + .status_code, + 400 + ); + assert_eq!( + service + .handle_http_request(&format!( + "GET {NARUON_ANALYSIS_RUN_PATH} HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: naruon\r\ntepp-contract-version: 1\r\ncontent-length: 2\r\n\r\n{{}}" + )) + .status_code, + 400 + ); + assert_eq!( + service + .handle_http_request(&format!( + "GET {NARUON_ANALYSIS_RUN_PATH}?cursor=tepp-run-1 HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: naruon\r\ntepp-contract-version: 1\r\ncontent-length: 0\r\n\r\n" + )) + .status_code, + 400 + ); + assert_eq!( + service + .handle_http_request(&format!( + "GET {NARUON_ANALYSIS_RUN_PATH} HTTP/1.1\r\ncontent-length: 0\r\n\r\n" + )) + .status_code, + 400 + ); + } + #[test] fn temporal_read_headers_and_defensive_write_edges_are_covered() { let run = sample_run(); diff --git a/crates/tepp_api/src/lib.rs b/crates/tepp_api/src/lib.rs index cdc7f7cf9..2acf87810 100644 --- a/crates/tepp_api/src/lib.rs +++ b/crates/tepp_api/src/lib.rs @@ -14,6 +14,7 @@ mod analysis_result; mod analysis_run; mod analysis_run_cancel_http; +mod analysis_run_collection_http; mod analysis_run_live; mod analysis_run_status_http; mod authorization; @@ -80,6 +81,28 @@ pub use analysis_run_cancel_http::AnalysisRunCancelRequest; pub use analysis_run_cancel_http::naruon_analysis_run_cancel_exchange; /// Refuse scientific-metric keys on a cancel payload. pub use analysis_run_cancel_http::refuse_metrics_on_cancel_payload; +/// Analysis-run collection contract version constant. +pub use analysis_run_collection_http::ANALYSIS_RUN_COLLECTION_CONTRACT_VERSION; +/// Maximum exclusive collection cursor length. +pub use analysis_run_collection_http::ANALYSIS_RUN_COLLECTION_CURSOR_MAX_LEN; +/// Default collection page size. +pub use analysis_run_collection_http::ANALYSIS_RUN_COLLECTION_DEFAULT_LIMIT; +/// Maximum collection page size. +pub use analysis_run_collection_http::ANALYSIS_RUN_COLLECTION_MAX_LIMIT; +/// Versioned metric-free analysis-run collection page. +pub use analysis_run_collection_http::AnalysisRunCollection; +/// One metric-free collection row. +pub use analysis_run_collection_http::AnalysisRunCollectionItem; +/// True when the path is exactly the analysis-run collection resource. +pub use analysis_run_collection_http::is_analysis_run_collection_path; +/// Build a Naruon analysis-run collection GET exchange. +pub use analysis_run_collection_http::naruon_analysis_run_collection_exchange; +/// Parse the exclusive collection page cursor. +pub use analysis_run_collection_http::parse_collection_page_cursor; +/// Parse the collection page limit. +pub use analysis_run_collection_http::parse_collection_page_limit; +/// Refuse scientific-metric keys on a collection payload. +pub use analysis_run_collection_http::refuse_metrics_on_collection_payload; /// Consumer-neutral loopback analysis-run service. pub use analysis_run_live::AnalysisRunLiveService; /// Analysis-run status HTTP exchange re-exports. diff --git a/crates/tepp_api/tests/analysis_run_collection_http_contract.rs b/crates/tepp_api/tests/analysis_run_collection_http_contract.rs new file mode 100644 index 000000000..7b9476fa7 --- /dev/null +++ b/crates/tepp_api/tests/analysis_run_collection_http_contract.rs @@ -0,0 +1,81 @@ +//! Contract tests for the analysis-run collection GET exchange. + +use tepp_api::{ + ANALYSIS_RUN_COLLECTION_CONTRACT_VERSION, ANALYSIS_RUN_COLLECTION_MAX_LIMIT, + AnalysisRunCollection, AnalysisRunCollectionItem, AnalysisRunStatusState, ApiError, + is_analysis_run_collection_path, naruon_analysis_run_collection_exchange, + parse_collection_page_limit, refuse_metrics_on_collection_payload, +}; + +#[test] +fn collection_exchange_is_https_get_without_credentials_or_metrics() { + let exchange = naruon_analysis_run_collection_exchange("https://tepp.example.test", None, None) + .expect("exchange"); + assert_eq!(exchange.method, "GET"); + assert_eq!( + exchange.target_url, + "https://tepp.example.test/v1/analysis-runs" + ); + assert!(exchange.body.is_empty()); + assert!( + exchange + .headers + .iter() + .any(|(name, value)| name == "tepp-consumer" && value == "naruon") + ); + assert!( + !exchange + .headers + .iter() + .any(|(name, _)| name.contains("authorization") + || name.contains("token") + || name.contains("copilot") + || name.contains("idempotency")) + ); + assert_eq!( + ANALYSIS_RUN_COLLECTION_CONTRACT_VERSION, + AnalysisRunCollection::new(Vec::new(), None) + .expect("empty") + .contract_version + ); + assert!(is_analysis_run_collection_path("/v1/analysis-runs")); + assert!(!is_analysis_run_collection_path( + "/v1/analysis-runs/tepp-run-1" + )); +} + +#[test] +fn collection_contract_refuses_table_access_and_metric_keys() { + for origin in [ + "http://tepp.example.test", + "https://db.postgres.example", + "https://jdbc.example", + ] { + assert_eq!( + naruon_analysis_run_collection_exchange(origin, None, None), + Err(ApiError::InvalidWirePayload), + "origin={origin}" + ); + } + assert_eq!( + parse_collection_page_limit(Some(&(ANALYSIS_RUN_COLLECTION_MAX_LIMIT + 1).to_string())), + Err(ApiError::LimitExceeded) + ); + assert_eq!( + refuse_metrics_on_collection_payload(r#"{"rmse":1.0}"#), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + refuse_metrics_on_collection_payload(r#"{"scientific_acceptance":{}}"#), + Err(ApiError::InvalidWirePayload) + ); + let item = + AnalysisRunCollectionItem::new("tepp-run-9", AnalysisRunStatusState::Cancelled, "idem-9") + .expect("item"); + let json = AnalysisRunCollection::new(vec![item], None) + .expect("page") + .to_json() + .expect("json"); + assert!(!json.contains("terminal_result")); + assert!(!json.contains("scientific_acceptance")); +} diff --git a/docs/API_CONTRACT.md b/docs/API_CONTRACT.md index 8dc69e27d..88782097a 100644 --- a/docs/API_CONTRACT.md +++ b/docs/API_CONTRACT.md @@ -8,7 +8,7 @@ TEPP must work both as a standalone product and as a modular CWL component. Integrations with `naruon`, `contextual-orchestrator`, `.github`, or other repositories use explicit versioned API/artifact contracts. Cross-service direct table access is prohibited. -Current protected main exposes Rust library/domain contracts. The active stack adds a loopback HTTP/1.1 listener for naruon analysis-run, LineageWeave temporal-context, and export POSTs, including `POST /v1/project-histories` on the `AnalysisRunLiveService` contract boundary and `POST /v1/analysis-runs/{run_id}/cancel` for metric-free cancellation of accepted or running runs. `tepp-loopback` runs the shared consumer listener on `127.0.0.1:18081` by default; a caller may pass another loopback socket address and an optional maximum request count as its two arguments. The container is intended for a trusted same-host or shared-network-namespace sidecar, checks readiness through a synthetic bounded temporal-context request, and deliberately cannot bind a public or bridge address. It is not a production TLS/`$PORT` service. Endpoint examples below that are not covered by `NaruonLiveService` or `AnalysisRunLiveService` remain target interface shapes; export retrieval stays a target shape until an executable export route ships. +Current protected main exposes Rust library/domain contracts. The active stack adds a loopback HTTP/1.1 listener for naruon analysis-run, LineageWeave temporal-context, and export POSTs, including `POST /v1/project-histories` on the `AnalysisRunLiveService` contract boundary, `POST /v1/analysis-runs/{run_id}/cancel` for metric-free cancellation of accepted or running runs, and `GET /v1/analysis-runs` for metric-free enumeration of accepted, running, cancelled, and terminal runs. `tepp-loopback` runs the shared consumer listener on `127.0.0.1:18081` by default; a caller may pass another loopback socket address and an optional maximum request count as its two arguments. The container is intended for a trusted same-host or shared-network-namespace sidecar, checks readiness through a synthetic bounded temporal-context request, and deliberately cannot bind a public or bridge address. It is not a production TLS/`$PORT` service. Endpoint examples below that are not covered by `NaruonLiveService` or `AnalysisRunLiveService` remain target interface shapes; export retrieval stays a target shape until an executable export route ships. ## 2. Contract families @@ -64,6 +64,7 @@ POST /v1/evidence-imports GET /v1/evidence-imports/{import_id} POST /v1/interpretation-runs POST /v1/analysis-runs +GET /v1/analysis-runs POST /v1/temporal-context GET /v1/analysis-runs/{run_id} POST /v1/analysis-runs/{run_id}/cancel @@ -80,8 +81,11 @@ request-bound `AnalysisRunTerminalResult`; consumers validate its request, receipt, snapshot, cutoff, model, profile, and idempotency bindings before treating it as measurement evidence. `POST /v1/analysis-runs/{run_id}/cancel` on the loopback listener transitions accepted or running runs to cancelled; -succeeded, failed, and unknown runs fail closed. GET status remains a later -slice on this protected-main lineage. +succeeded, failed, and unknown runs fail closed. `GET /v1/analysis-runs` on +the loopback listener returns a metric-free collection of those states so +operators do not guess run identities. Collection bodies never carry +`tepp.scientific_acceptance.v1`. GET-by-id remains a later slice on this +protected-main lineage. The stacked `analysis_engine` slice provides the first executable service-side path behind these DTOs. It consumes a bounded identity-free snapshot, excludes diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index d0242669e..b4509e6fd 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 analysis-run cancel HTTP | ADR 0029; API contract; RFC 9110 | `tepp_api` `POST /v1/analysis-runs/{run_id}/cancel` on `AnalysisRunLiveService`: metric-free cancelled status for accepted/running runs; succeeded/failed/unknown refuse; GET status remains a later slice | active-PR | +| loopback analysis-run collection GET | ADR 0031; API contract; RFC 9110 | `tepp_api` `GET /v1/analysis-runs` on `AnalysisRunLiveService`: metric-free enumeration of accepted/running/cancelled/terminal runs; collection bodies refuse scientific-acceptance and RMSE keys; GET-by-id remains a later slice | 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/0031-analysis-run-collection-get.md b/docs/adr/0031-analysis-run-collection-get.md new file mode 100644 index 000000000..32ae3f8e5 --- /dev/null +++ b/docs/adr/0031-analysis-run-collection-get.md @@ -0,0 +1,72 @@ +# ADR 0031 — Analysis-run collection GET path + +**Decision status:** Accepted +**Implementation maturity:** active-PR +**Date:** 2026-08-31 +**Supersedes:** None; complements ADR 0018 and ADR 0029 for the operator-visible collection read. Does not supersede ADR 0014 claim-promotion authority. ADR 0026–0030 remain on live GAP-003A engine-library, terminal-wire DTO, GET-by-id, lifecycle-POST, cancel, and loopback-CLI slices. + +## Context + +Protected main and the live cancel slice accept analysis runs on loopback but refuse `GET /v1/analysis-runs`. Operators therefore cannot enumerate accepted, running, cancelled, or terminal runs without guessing run identities. Returning RMSE, bias, coverage, SE-gate, or `tepp.scientific_acceptance.v1` on the list would treat enumeration as measurement evidence. Only a succeeded single-run GET with output profile `scientific_acceptance_v1` may return that artifact (#359). Stacking this slice onto GET-by-id, lifecycle POST, or CLI would duplicate those heads. + +## Decision + +`AnalysisRunLiveService` serves `GET /v1/analysis-runs` on loopback: + +- The collection lists the calling consumer's runs sorted by opaque `run_id`. +- Each row is metric-free: `run_id`, `run_state`, and `idempotency_key` only. +- `tepp.scientific_acceptance.v1`, RMSE, bias, coverage, SE-gate, report, and `terminal_result` keys never appear on the list. +- Accepted, running, cancelled, succeeded, and failed states are listed. Succeeded rows still omit the artifact. +- Bounded cursor pagination uses `tepp-page-cursor` and `tepp-page-limit` headers because the shared request-line parser fails closed on query strings. Default limit is 32; maximum is 64. An unknown cursor fails closed. +- Empty collections return `200` with `runs: []`. GET-by-id, query strings, and nonempty GET bodies fail closed. +- Cancel POST, create POST, and consumer isolation are unchanged. Persistence remains GAP-003B. + +## Alternatives considered + +1. **Stack collection GET onto the live GET-by-id PR** — rejected because that head already owns single-run status and a parallel stack would duplicate it. +2. **Return `tepp.scientific_acceptance.v1` on succeeded collection rows** — rejected because collection bodies must stay metric-free; only a succeeded single-run GET with profile `scientific_acceptance_v1` may return the artifact. +3. **Query-string pagination** — rejected because `parse_request_line` fails closed on `?` to refuse hostile URLs. +4. **Header-paginated metric-free collection GET on loopback** — accepted. + +## Consequences + +- Operators can enumerate runs on the same loopback listener that created them. +- Collection pages cannot be mistaken for a succeeded scientific-acceptance result. +- GET-by-id may later return a digest-bound artifact without changing these collection gates. + +## Failure and recovery + +Unknown collection paths, GET-by-id, query strings, nonempty bodies, unknown cursors, zero or non-integer limits, metric keys, unpublished consumers, and non-loopback hosts return a redacted `400` envelope. Oversized page limits and cursors return `413`. Credential headers remain `403`. The in-memory registry is not durable; a restart requires re-POSTing the original metric-free create requests. Callers must not fabricate a succeeded scientific-acceptance artifact from a collection row. + +## Security, privacy, scientific-integrity, and governance impact + +- No credential headers cross the consumer boundary. +- Collection remains loopback-only, size-bounded, consumer-scoped, and content-redacting. +- HTTP `200` on a collection page is not measurement evidence and is not release evidence. + +## Compatibility and migration + +Create POST, cancel POST, temporal-context, and project-history paths are unchanged. GET-by-id remains refused on this slice. Production adapters may replace loopback while preserving metric-free collection rows and the artifact refusal. + +## Verification + +Falsifiable evidence: + +- GET collection JSON has no RMSE/bias/coverage/SE-gate/scientific-acceptance/`terminal_result` keys; +- GET lists accepted, running, cancelled, succeeded, and failed rows for one consumer; +- GET does not leak another consumer's runs; +- unknown cursor, GET-by-id, query strings, and nonempty bodies fail closed; +- Clippy `-D warnings`, `tepp_api` tests, rustdoc, and exact-head review remain required. + +## Rollback and supersession + +Rollback removes collection GET dispatch; POST create receipts and cancel remain valid. A superseding ADR is required to persist the registry, bind a public address, emit scientific-acceptance on the list, or treat HTTP success as an ADR 0014 claim. + +## Related authority + +- ADR 0018 owns consumer-scoped ingress and metric-free `202 Accepted`. +- ADR 0029 owns loopback cancel. +- ADR 0027 owns GET-by-id status (live on another PR). +- ADR 0014 owns scientific claim promotion. +- ADR 0011 owns standalone/modular HTTP boundaries. +- RFC 9110 owns GET semantics (Fielding, Nottingham, & Reschke, 2022). It does not authorize scientific claims. diff --git a/docs/adr/README.md b/docs/adr/README.md index e54dce807..b4111e69c 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. | | [0029](0029-analysis-run-cancel-http.md) | Loopback POST analysis-run cancel is metric-free cancelled status | Accepted | active-PR | Complements ADR 0018; does not supersede ADR 0014. ADR 0026–0028 live on other GAP-003A PRs. | +| [0031](0031-analysis-run-collection-get.md) | Loopback GET analysis-run collection is metric-free enumeration | Accepted | active-PR | Complements ADR 0018/0029; does not supersede ADR 0014. ADR 0026–0030 live on other GAP-003A PRs. | | [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. | @@ -142,6 +143,7 @@ Use the narrowest owning ADR when decisions overlap: - **independent lineage criterion and posterior Project Journey:** ADR 0023. - **macOS-native Rust-owned MLX Metal execution:** ADR 0024. - **analysis-run cancel HTTP:** ADR 0029. +- **analysis-run collection GET:** ADR 0031. ## Change and supersession rule diff --git a/docs/connectors/naruon-artifact-consumer.md b/docs/connectors/naruon-artifact-consumer.md index db635810d..5bdc328a1 100644 --- a/docs/connectors/naruon-artifact-consumer.md +++ b/docs/connectors/naruon-artifact-consumer.md @@ -26,6 +26,7 @@ TEPP remains the scientific authority for estimation, recovery metrics, temporal | GraphML relation export | `tepp_api` `GraphMlExport` | TEPP → naruon | | purpose-bound export auth | `tepp_api` `authorize_export` with `ModularServiceConsumer` | TEPP gate | | HTTP analysis-run create | `tepp_api` `naruon_analysis_run_exchange` → `POST /v1/analysis-runs` | naruon → TEPP | +| HTTP analysis-run collection | `tepp_api` `naruon_analysis_run_collection_exchange` → `GET /v1/analysis-runs` | naruon → TEPP | | HTTP analysis-run cancel | `tepp_api` `naruon_analysis_run_cancel_exchange` → `POST /v1/analysis-runs/{run_id}/cancel` | naruon → TEPP | | HTTP export authorize | `tepp_api` `naruon_export_exchange` → `POST /v1/exports` | naruon → TEPP | | Live loopback POST | `tepp_api` `NaruonLiveService` → `POST /v1/analysis-runs` and `/v1/exports` | naruon → TEPP | @@ -51,6 +52,7 @@ When naruon requests an export, TEPP evaluates `AnalyticalPurpose::ModularServic - export interchange without a nonempty per-export idempotency key → reject; - lexical method codes (`tfidf`, `bm25`, `keyword`) claiming TEPP inference → reject; - scientific-metric keys (`rmse`, `bias`, `coverage`, `se_gate`, `scientific_acceptance`, `report`) on a cancel body → reject; +- scientific-metric keys (`rmse`, `bias`, `coverage`, `se_gate`, `scientific_acceptance`, `report`, `terminal_result`) on a collection body → reject; - cancel of a succeeded, failed, or unknown analysis run → reject. ## Authority sources diff --git a/docs/research/analysis-run-collection-http.md b/docs/research/analysis-run-collection-http.md new file mode 100644 index 000000000..c7d83b1e9 --- /dev/null +++ b/docs/research/analysis-run-collection-http.md @@ -0,0 +1,58 @@ +# Analysis-run collection HTTP (doctoring) + +## Scope + +`AnalysisRunLiveService` serves `GET /v1/analysis-runs` on a loopback-only +HTTP/1.1 listener. HTTP method, path, and header semantics follow current HTTP +semantics (Fielding, Nottingham, & Reschke, 2022). Fail-closed refusal of +non-loopback binds, table-access hosts, review/Copilot/GitHub credential +headers, and scientific-authority promotion is repository contract authority +(ADR 0018; ADR 0011; ADR 0031), not an RFC inference rule. + +Collection responses are metric-free `AnalysisRunCollection` JSON. Each row +carries `run_id`, `run_state`, and `idempotency_key` only. HTTP `200` is not a +completed temporal model, calibrated score, theta estimate, uncertainty +statement, or scientific claim. `tepp.scientific_acceptance.v1` never appears +on the list. + +## Authority + +### External standards (HTTP only) + +Fielding, R., Nottingham, M., & Reschke, J. (Eds.). (2022). *HTTP semantics* +(RFC 9110). IETF. https://doi.org/10.17487/RFC9110 + +RFC 9110 §9.3.1 describes GET as a method for retrieving the target resource's +current state. TEPP maps that retrieval onto a bounded, consumer-scoped +collection of metric-free run rows. The RFC does not define psychometric +acceptance, RMSE, or claim promotion. + +### Internal contract evidence + +- `docs/adr/0031-analysis-run-collection-get.md` — collection authority and + metric-free list rows +- `docs/adr/0029-analysis-run-cancel-http.md` — cancelled is a listable + metric-free state +- `docs/adr/0018-consumer-scoped-analysis-run-ingress.md` — closed consumer + registry and metric-free `202 Accepted` +- `docs/adr/0014-scientific-claim-promotion-and-release-evidence.md` — HTTP + success is not a scientific claim +- `docs/API_CONTRACT.md` — documented collection resource +- `crates/tepp_api/tests/analysis_run_collection_http_contract.rs` — + fail-closed collection exchange proofs + +## Verification + +- loopback `GET /v1/analysis-runs` of accepted, running, cancelled, succeeded, + and failed runs returns metric-free rows without RMSE/bias/coverage/SE-gate + keys or `tepp.scientific_acceptance.v1`; +- another consumer cannot read the first consumer's rows; +- unknown cursor, GET-by-id, query strings, and nonempty GET bodies fail + closed; +- review, Copilot, GitHub, and bearer headers remain `AuthorizationDenied`. + +## Non-claims + +This slice does not implement GET-by-id, running/terminal POST, loopback CLI, +persistence, production TLS, Leiden consensus, or an ADR 0014 scientific +claim-promotion package. diff --git a/schemas/analysis_run_collection_v1.json b/schemas/analysis_run_collection_v1.json new file mode 100644 index 000000000..53f652842 --- /dev/null +++ b/schemas/analysis_run_collection_v1.json @@ -0,0 +1,32 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://tepp.local/schemas/analysis_run_collection_v1.json", + "title": "AnalysisRunCollectionV1", + "type": "object", + "additionalProperties": false, + "required": [ + "contract_version", + "runs" + ], + "properties": { + "contract_version": { "type": "integer", "const": 1 }, + "runs": { + "type": "array", + "maxItems": 64, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["run_id", "run_state", "idempotency_key"], + "properties": { + "run_id": { "type": "string", "minLength": 1, "maxLength": 128, "pattern": ".*\\S.*" }, + "run_state": { + "type": "string", + "enum": ["accepted", "running", "succeeded", "failed", "cancelled"] + }, + "idempotency_key": { "type": "string", "minLength": 1, "pattern": ".*\\S.*" } + } + } + }, + "next_cursor": { "type": "string", "minLength": 1, "maxLength": 128, "pattern": ".*\\S.*" } + } +}