From a4d3d43f0566dbc2a6395eb56579604492f53636 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 31 Aug 2026 18:06:26 +0000 Subject: [PATCH 1/4] feat(api): retrieve authorized exports via loopback GET GAP-003A unique slice: AnalysisRunLiveService mints a metric-free export_id on naruon POST /v1/exports and serves GET /v1/exports/{id}. NaruonLiveService stays POST-only. Persistence remains GAP-003B. --- ARCHITECTURE.md | 2 +- CHANGELOG.d/export-retrieval-http.md | 1 + CHANGELOG.md | 2 + crates/tepp_api/src/analysis_run_live.rs | 317 ++++++++++++- crates/tepp_api/src/export_http.rs | 430 ++++++++++++++++++ crates/tepp_api/src/lib.rs | 11 + crates/tepp_api/src/naruon_http.rs | 2 +- .../tests/export_retrieval_http_contract.rs | 83 ++++ docs/API_CONTRACT.md | 4 +- docs/TRACEABILITY.md | 2 +- docs/adr/0053-export-retrieval-get.md | 130 ++++++ docs/adr/README.md | 2 + docs/connectors/naruon-artifact-consumer.md | 1 + docs/research/export-retrieval-http.md | 35 ++ docs/research/naruon-http-interchange.md | 4 +- schemas/export_retrieval_v1.json | 31 ++ 16 files changed, 1036 insertions(+), 21 deletions(-) create mode 100644 CHANGELOG.d/export-retrieval-http.md create mode 100644 crates/tepp_api/src/export_http.rs create mode 100644 crates/tepp_api/tests/export_retrieval_http_contract.rs create mode 100644 docs/adr/0053-export-retrieval-get.md create mode 100644 docs/research/export-retrieval-http.md create mode 100644 schemas/export_retrieval_v1.json diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e6dafbcc2..ccac930bf 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -69,7 +69,7 @@ boundaries above remain the target modular MSA architecture. | `corpus_split` | cutoff-safe, relation-aware partitioning | | `tepp_simulation` | known-truth temporal/event data generation | | `validation_core` | RMSE, bias, coverage, graph, Monte Carlo, and exact-head claim-promotion metrics | -| `tepp_api` | versioned DTO, schema, terminal-result, and export contracts | +| `tepp_api` | versioned DTO, schema, terminal-result, export, and loopback export-retrieval contracts | | `analysis_engine` | bounded cutoff-safe temporal evidence readiness execution and digest-bound terminal artifacts | | `episode_membership` | event-time episode membership containment gate | | `prompt_source` | prompt boilerplate is not unique latent content and not stopword deletion | diff --git a/CHANGELOG.d/export-retrieval-http.md b/CHANGELOG.d/export-retrieval-http.md new file mode 100644 index 000000000..60e198535 --- /dev/null +++ b/CHANGELOG.d/export-retrieval-http.md @@ -0,0 +1 @@ +- `tepp_api` loopback `GET /v1/exports/{export_id}` returns the metric-free identity of one purpose-bound export that `AnalysisRunLiveService` authorized, so operators can retrieve that export without scanning artifacts (ADR 0053). `NaruonLiveService` stays POST-only. GET-by-id remains refused. Not lifecycle POST, not cancel, not collection GET, not retry, not stored-request GET, not retry-lineage GET, not lookup GET, not retry-parent GET, not GAP-010 Figma/export, not persistence. diff --git a/CHANGELOG.md b/CHANGELOG.md index 062a69412..04ccc12ca 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` loopback `GET /v1/exports/{export_id}` returns the metric-free identity of one purpose-bound export that `AnalysisRunLiveService` authorized, so operators can retrieve that export without scanning artifacts (ADR 0053). `NaruonLiveService` stays POST-only. GET-by-id remains refused. Not lifecycle POST, not cancel, not collection GET, not retry, not stored-request GET, not retry-lineage GET, not lookup GET, not retry-parent GET, not GAP-010 Figma/export, not persistence. + - `event_core` adds bounded Allen interval-consistency classification, atomic path-consistency closure, contradiction/resource refusals, and an explicit dependency-error fallback without claiming unrestricted global satisfiability. - `psychometric_core` recovers the Driver, Oud, and Voelkle (2017, Table 2, p. 12 `MANIFESTTRAITVAR`; §7.1, p. 19; p. 16 `MANIFESTTRAITVARstd`; footnote 4; 2017-era ctsem `summary.ctsemFit.R`; JSS PDF re-opened 2026-08-27T14:20Z from https://www.jstatsoft.org/index.php/jss/article/download/v077i05/1104) scalar standardised manifest-trait variance on current main after `0ce16e8` dropped the pre-consolidation code while research notes already named the map (register items 83–84). Table 2 names `MANIFESTTRAITVAR` `Ψ_τ` the additional time-invariant variance-covariance on the measurement level and sets it `NULL` when there is no manifest trait. Equation 5 writes `Γ ~ N(τ, Ψ)` and names that covariance the manifest traits. Section 7.1 names manifest traits stable individual differences in indicator levels, distinct from process-level `TRAITVAR` `φ_ξ`. Page 16 prints standardised matrices with the suffix `std` when appropriate. The printed example on p. 16 is `discreteDRIFTstd`, not `MANIFESTTRAITVARstd`. Footnote 4 standardises using only the relevant variance, not the total. The relevant variance for that named indicator-level correlation is `MANIFESTTRAITVAR`, not process-level `TRAITVAR` and not residual `MANIFESTVAR` `θ`. The 2017-era source forms `MANIFESTTRAITVARstd` only when `MANIFESTTRAITVAR != 0`, as `solve(sqrt(diag(MANIFESTTRAITVAR) + ridging)) %&% MANIFESTTRAITVAR` when `verbose = TRUE`. OpenMx `%&%` is `t(A) %*% B %*% A`. Unlike `TRAITVARstd`, that formation adds `diag(c(ridging), n.manifest)`. The default `ridging = FALSE` adds 0, not `0.0001`; that ridge is a numerical hack and is not this exact map. The scalar correlation is `ψ / ψ = 1` after strictly positive `MANIFESTTRAITVAR`. Form strictly positive `ψ` first, then `1 / √ψ`, then `(1 / √ψ) ψ (1 / √ψ)`. Unstandardised `MANIFESTTRAITVAR` is defined for a zero trait; standardised `MANIFESTTRAITVAR` is not. Zero `MANIFESTTRAITVAR` skips forming `MANIFESTTRAITVARstd` in the 2017-era source and fails closed here. Indicator-level trait variance is an event-time structural quantity, so a non-event clock fails closed. `MANIFESTTRAITVAR` does not require stable `a < 0`. Distinct positive `ψ` recover the same 1. `trait / trait = 1` is `TRAITVARstd` and recovers the same number and remains a distinct named quantity. `θ` is `MANIFESTVAR` and is measurement error, not this correlation. Meredith (1993) remains unread (web search 2026-08-27T14:20Z: Springer/Cambridge Core paywalled; Unpaywall historically `is_oa: false`; Springer `content/pdf` is an HTML stub). Mislevy (1991, *Psychometrika, 56*, 177–196) remains unread on the same terms (DOI `10.1007/bf02294457`). Still not a Kalman filter, not a matrix `expm`, not ESEM estimation, not DSEM, and not ctsem estimation. diff --git a/crates/tepp_api/src/analysis_run_live.rs b/crates/tepp_api/src/analysis_run_live.rs index 6768c6ef1..0ca7dbd97 100644 --- a/crates/tepp_api/src/analysis_run_live.rs +++ b/crates/tepp_api/src/analysis_run_live.rs @@ -2,29 +2,43 @@ //! //! This module keeps the Naruon compatibility listener intact while providing //! the shared `/v1/analysis-runs` and cutoff-safe `/v1/temporal-context` -//! boundaries needed by Naruon and `LineageWeave`. It accepts transport -//! acknowledgements and temporal evidence context only; completed psychometric -//! results remain outside this crate. +//! boundaries needed by Naruon and `LineageWeave`. Naruon may also POST and +//! GET `/v1/exports/{export_id}` for metric-free purpose-bound retrieval. +//! It accepts transport acknowledgements, temporal evidence context, and +//! export identities only; completed psychometric results remain outside this +//! crate. use std::collections::HashMap; use std::io::Write; use std::net::{SocketAddr, TcpListener}; -use crate::lineageweave_http::{LINEAGEWEAVE_CONSUMER_CODE, consumer_is_supported}; +use crate::export_http::{export_retrieval_path_id, refuse_metrics_on_export_retrieval_payload}; +use crate::lineageweave_http::{ + LINEAGEWEAVE_CONSUMER_CODE, NARUON_CONSUMER_CODE, consumer_is_supported, +}; use crate::live_http::{ header_value, map_io_error, parse_headers, parse_request_line, read_http_request_with_limit, split_request_with_limit, validate_common_headers, }; -use crate::naruon_http::NARUON_ANALYSIS_RUN_PATH; +use crate::naruon_http::{NARUON_ANALYSIS_RUN_PATH, NARUON_EXPORT_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, + AnalysisRunAccepted, AnalysisRunRequest, AnalyticalPurpose, ApiError, + DEFAULT_PROJECT_HISTORY_BYTE_LIMIT, ErrorEnvelope, ExportAuthorizationRequest, ExportRetrieval, + NARUON_LIVE_IO_TIMEOUT, NaruonLiveResponse, PROJECT_HISTORY_PATH, ProjectHistoryProjection, + ProjectHistoryRequest, TEMPORAL_CONTEXT_PATH, TemporalContextRequest, authorize_export, build_temporal_context, project_history_projection, requests_are_idempotent_matches, + require_export_allowed, }; const MAX_LIVE_REQUEST_BODY_BYTES: usize = DEFAULT_PROJECT_HISTORY_BYTE_LIMIT; +#[derive(Clone, Debug, Eq, PartialEq)] +struct StoredExport { + consumer: String, + request: ExportAuthorizationRequest, + retrieval: ExportRetrieval, +} + #[cfg(test)] use crate::live_http::{declared_content_length, host_implies_table_access, split_header_line}; @@ -39,8 +53,11 @@ pub struct AnalysisRunLiveService { bound_addr: Option, next_run_serial: u64, next_request_serial: u64, + next_export_serial: u64, accepted_runs: HashMap, accepted_project_histories: HashMap, + authorized_exports: HashMap, + exports_by_id: HashMap, } impl Default for AnalysisRunLiveService { @@ -58,8 +75,11 @@ impl AnalysisRunLiveService { bound_addr: None, next_run_serial: 1, next_request_serial: 1, + next_export_serial: 1, accepted_runs: HashMap::new(), accepted_project_histories: HashMap::new(), + authorized_exports: HashMap::new(), + exports_by_id: HashMap::new(), } } @@ -143,14 +163,28 @@ 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(""))?; - if method != "POST" - || (path != NARUON_ANALYSIS_RUN_PATH - && path != TEMPORAL_CONTEXT_PATH - && path != PROJECT_HISTORY_PATH) + let headers = parse_headers(&mut lines)?; + if method == "GET" { + if matches!( + export_retrieval_path_id(path), + Ok(_) | Err(ApiError::LimitExceeded) + ) { + return self.read_export(path, &headers, body); + } + return Err(ApiError::InvalidWirePayload); + } + if method != "POST" { + return Err(ApiError::InvalidWirePayload); + } + if path == NARUON_EXPORT_PATH { + return self.accept_export(&headers, body); + } + if path != NARUON_ANALYSIS_RUN_PATH + && path != TEMPORAL_CONTEXT_PATH + && path != PROJECT_HISTORY_PATH { return Err(ApiError::InvalidWirePayload); } - let headers = parse_headers(&mut lines)?; let consumer = require_headers( &headers, self.bound_addr, @@ -235,6 +269,92 @@ impl AnalysisRunLiveService { Ok(json_response(200, "OK", response_body)) } + fn accept_export( + &mut self, + headers: &HashMap, + body: &str, + ) -> Result { + let consumer = require_headers(headers, self.bound_addr, true)?; + if consumer != NARUON_CONSUMER_CODE { + return Err(ApiError::InvalidWirePayload); + } + let request: ExportAuthorizationRequest = crate::wire::from_json(body)?; + let idempotency_key = header_value(headers, "idempotency-key")?; + if idempotency_key == request.principal_id { + return Err(ApiError::InvalidWirePayload); + } + if request.purpose != AnalyticalPurpose::ModularServiceConsumer { + return Err(ApiError::AuthorizationDenied); + } + let decision = authorize_export(&request)?; + require_export_allowed(&decision)?; + let replay_key = consumer_tenant_idempotency_key( + consumer, + &request.tenant_workspace_id, + idempotency_key, + ); + if let Some(stored) = self.authorized_exports.get(&replay_key) { + if stored.request == request { + refuse_metrics_on_export_retrieval_payload(&stored.retrieval.to_json()?)?; + return Ok(json_response(200, "OK", stored.retrieval.to_json()?)); + } + return Err(ApiError::InvalidWirePayload); + } + let export_id = format!("tepp-export-{}", self.next_export_serial); + self.next_export_serial += 1; + let retrieval = ExportRetrieval::new( + export_id.clone(), + request.artifact_id.clone(), + decision.decision_code().to_owned(), + request.purpose.wire_name(), + idempotency_key, + )?; + let response_body = retrieval.to_json()?; + refuse_metrics_on_export_retrieval_payload(&response_body)?; + self.exports_by_id.insert(export_id, replay_key.clone()); + self.authorized_exports.insert( + replay_key, + StoredExport { + consumer: consumer.to_owned(), + request, + retrieval, + }, + ); + Ok(json_response(200, "OK", response_body)) + } + + fn read_export( + &self, + path: &str, + headers: &HashMap, + body: &str, + ) -> Result { + let export_id = export_retrieval_path_id(path)?; + if !body.trim().is_empty() { + return Err(ApiError::InvalidWirePayload); + } + let consumer = require_headers(headers, self.bound_addr, false)?; + if consumer != NARUON_CONSUMER_CODE { + return Err(ApiError::InvalidWirePayload); + } + refuse_metrics_on_export_retrieval_payload(body)?; + let replay_key = self + .exports_by_id + .get(&export_id) + .cloned() + .ok_or(ApiError::InvalidWirePayload)?; + let stored = self + .authorized_exports + .get(&replay_key) + .ok_or(ApiError::InvalidWirePayload)?; + if stored.consumer != consumer { + return Err(ApiError::InvalidWirePayload); + } + let response_body = stored.retrieval.to_json()?; + refuse_metrics_on_export_retrieval_payload(&response_body)?; + Ok(json_response(200, "OK", response_body)) + } + fn response_from_error(&mut self, error: ApiError) -> NaruonLiveResponse { let request_id = format!("analysis-run-live-{}", self.next_request_serial); self.next_request_serial += 1; @@ -318,8 +438,9 @@ mod tests { use crate::{ ANALYSIS_RUN_CONTRACT_VERSION, AnalysisRunRequest, ApiError, DEFAULT_ANALYSIS_RUN_BYTE_LIMIT, ErrorEnvelope, LINEAGEWEAVE_CONSUMER_CODE, - NARUON_ANALYSIS_RUN_PATH, NARUON_CONSUMER_CODE, NARUON_LIVE_HEADER_BYTE_LIMIT, - NARUON_LIVE_HEADER_COUNT_LIMIT, NARUON_LIVE_IO_TIMEOUT, TEMPORAL_CONTEXT_PATH, + NARUON_ANALYSIS_RUN_PATH, NARUON_CONSUMER_CODE, NARUON_EXPORT_PATH, + NARUON_LIVE_HEADER_BYTE_LIMIT, NARUON_LIVE_HEADER_COUNT_LIMIT, NARUON_LIVE_IO_TIMEOUT, + TEMPORAL_CONTEXT_PATH, }; fn sample_run() -> AnalysisRunRequest { @@ -938,6 +1059,172 @@ mod tests { ); } + #[test] + #[allow(clippy::too_many_lines)] + fn handler_covers_metric_free_export_retrieval_get() { + use crate::{AnalyticalPurpose, ExportAuthorizationRequest, ExportRetrieval}; + + let request = ExportAuthorizationRequest { + tenant_workspace_id: "export-live-tenant".into(), + principal_id: "principal-analyst-1".into(), + purpose: AnalyticalPurpose::ModularServiceConsumer, + artifact_id: "artifact-live-1".into(), + includes_source_text: false, + }; + let body = crate::wire::to_json(&request).expect("export json"); + let mut service = AnalysisRunLiveService::new(); + let posted = service.handle_http_request(&export_post_http( + &body, + NARUON_CONSUMER_CODE, + "export-idem-1", + )); + assert_eq!(posted.status_code, 200); + let retrieval = ExportRetrieval::from_json(&posted.body).expect("posted retrieval"); + assert_eq!(retrieval.artifact_id, "artifact-live-1"); + assert_eq!(retrieval.purpose, "modular_service_consumer"); + assert_eq!(retrieval.decision_code, "purpose_bound_export_allowed"); + assert_eq!(retrieval.idempotency_key, "export-idem-1"); + assert!(!posted.body.contains("tenant_workspace_id")); + assert!(!posted.body.contains("principal_id")); + assert!(!posted.body.contains("includes_source_text")); + assert!(!posted.body.contains("scientific_acceptance")); + assert!(!posted.body.contains("rmse")); + + let replay = service.handle_http_request(&export_post_http( + &body, + NARUON_CONSUMER_CODE, + "export-idem-1", + )); + assert_eq!(replay.status_code, 200); + assert_eq!(replay.body, posted.body); + + let got = service + .handle_http_request(&export_get_http(&retrieval.export_id, NARUON_CONSUMER_CODE)); + assert_eq!(got.status_code, 200); + assert_eq!( + ExportRetrieval::from_json(&got.body).expect("got"), + retrieval + ); + + assert_eq!( + service + .handle_http_request(&export_get_http( + &retrieval.export_id, + LINEAGEWEAVE_CONSUMER_CODE + )) + .status_code, + 400 + ); + assert_eq!( + service + .handle_http_request(&export_post_http( + &body, + LINEAGEWEAVE_CONSUMER_CODE, + "export-idem-2" + )) + .status_code, + 400 + ); + assert_eq!( + service + .handle_http_request(&export_get_http("missing-export", NARUON_CONSUMER_CODE)) + .status_code, + 400 + ); + assert_eq!( + service + .handle_http_request(&format!( + "GET {NARUON_EXPORT_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: 0\r\n\r\n" + )) + .status_code, + 400 + ); + assert_eq!( + service + .handle_http_request(&format!( + "GET {NARUON_EXPORT_PATH}/{}/extra 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", + retrieval.export_id + )) + .status_code, + 400 + ); + assert_eq!( + service + .handle_http_request(&format!( + "GET {NARUON_EXPORT_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{{}}", + retrieval.export_id + )) + .status_code, + 400 + ); + assert_eq!( + service + .handle_http_request(&format!( + "POST {NARUON_EXPORT_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\nidempotency-key: export-idem-1\r\ncontent-length: 0\r\n\r\n", + retrieval.export_id + )) + .status_code, + 400 + ); + 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 + ); + + let mut denied = request.clone(); + denied.purpose = AnalyticalPurpose::OperationalMonitoring; + denied.includes_source_text = true; + let denied_body = crate::wire::to_json(&denied).expect("denied json"); + assert_eq!( + service + .handle_http_request(&export_post_http( + &denied_body, + NARUON_CONSUMER_CODE, + "export-idem-denied", + )) + .status_code, + 403 + ); + + let mut conflict = request.clone(); + conflict.artifact_id = "artifact-live-2".into(); + let conflict_body = crate::wire::to_json(&conflict).expect("conflict json"); + assert_eq!( + service + .handle_http_request(&export_post_http( + &conflict_body, + NARUON_CONSUMER_CODE, + "export-idem-1", + )) + .status_code, + 400 + ); + + let principal_as_key = service.handle_http_request(&export_post_http( + &body, + NARUON_CONSUMER_CODE, + "principal-analyst-1", + )); + assert_eq!(principal_as_key.status_code, 400); + } + + fn export_post_http(body: &str, consumer: &str, idempotency_key: &str) -> String { + format!( + "POST {NARUON_EXPORT_PATH} HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: {consumer}\r\ntepp-contract-version: 1\r\nidempotency-key: {idempotency_key}\r\ncontent-length: {}\r\n\r\n{body}", + body.len() + ) + } + + fn export_get_http(export_id: &str, consumer: &str) -> String { + format!( + "GET {NARUON_EXPORT_PATH}/{export_id} HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: {consumer}\r\ntepp-contract-version: 1\r\ncontent-length: 0\r\n\r\n" + ) + } + struct ScriptedRead { reader: Cursor>, first_error: Option, diff --git a/crates/tepp_api/src/export_http.rs b/crates/tepp_api/src/export_http.rs new file mode 100644 index 000000000..bd563bf2e --- /dev/null +++ b/crates/tepp_api/src/export_http.rs @@ -0,0 +1,430 @@ +//! Provider-owned export retrieval GET contracts. +//! +//! GAP-003A unique slice: `GET /v1/exports/{export_id}` returns the +//! metric-free identity of one purpose-bound export that `tepp-loopback` +//! already authorized. `POST /v1/exports` on `NaruonLiveService` authorizes +//! without minting a retrievable identity. Operators who hold a 200 decision +//! therefore cannot jump back to that export. This module does not serve +//! GET-by-id (#359), lifecycle POST (#360), cancel HTTP (#361), collection +//! GET (#368), retry POST (#369), stored-request GET (#377), retry-lineage +//! GET (#379), lookup GET (#380), retry-parent GET (#384), wait CLI (#406), +//! 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_ANALYSIS_RUN_BYTE_LIMIT, NARUON_EXPORT_PATH}; +use serde::{Deserialize, Serialize}; + +/// Maximum length accepted for an opaque export identity in the retrieval path. +pub const EXPORT_RETRIEVAL_ID_MAX_LEN: usize = 128; + +/// Supported export-retrieval contract version. +pub const EXPORT_RETRIEVAL_CONTRACT_VERSION: u16 = 1; + +const FORBIDDEN_EXPORT_RETRIEVAL_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", + "tenant_workspace_id", + "principal_id", + "includes_source_text", +]; + +/// Metric-free identity of one authorized export. +/// +/// Operators jump from a 200 authorization receipt to the durable +/// `export_id` without scanning artifacts. The payload never carries a +/// terminal result, source body, or scientific-acceptance artifact. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ExportRetrieval { + /// Semantic contract version for this payload family. + pub contract_version: u16, + /// Opaque server-assigned export identity. + pub export_id: String, + /// Opaque artifact identity that was authorized. + pub artifact_id: String, + /// Stable machine-readable authorization decision code. + pub decision_code: String, + /// Declared analytical purpose as a wire name. + pub purpose: String, + /// Exact per-export idempotency key that minted this identity. + pub idempotency_key: String, +} + +impl ExportRetrieval { + /// Construct a validated metric-free export-retrieval payload. + /// + /// # Errors + /// + /// Returns a fail-closed error for empty identities, an oversized + /// identity, an unknown purpose wire name, or an unsupported contract + /// version. + pub fn new( + export_id: impl Into, + artifact_id: impl Into, + decision_code: impl Into, + purpose: impl Into, + idempotency_key: impl Into, + ) -> Result { + let retrieval = Self { + contract_version: EXPORT_RETRIEVAL_CONTRACT_VERSION, + export_id: export_id.into(), + artifact_id: artifact_id.into(), + decision_code: decision_code.into(), + purpose: purpose.into(), + idempotency_key: idempotency_key.into(), + }; + retrieval.validate()?; + Ok(retrieval) + } + + /// Parse and validate an export-retrieval 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 an export-retrieval 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_export_retrieval_payload(payload)?; + let retrieval: Self = from_json(payload)?; + retrieval.validate()?; + Ok(retrieval) + } + + /// Serialize this export-retrieval payload 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_export_retrieval_payload(&payload)?; + Ok(payload) + } + + fn validate(&self) -> Result<(), ApiError> { + require_contract_version(self.contract_version, EXPORT_RETRIEVAL_CONTRACT_VERSION)?; + require_nonempty(&self.export_id)?; + require_nonempty(&self.artifact_id)?; + require_nonempty(&self.decision_code)?; + require_nonempty(&self.purpose)?; + require_nonempty(&self.idempotency_key)?; + if !purpose_is_known(&self.purpose) { + return Err(ApiError::InvalidWirePayload); + } + if self.export_id.len() > EXPORT_RETRIEVAL_ID_MAX_LEN + || self.artifact_id.len() > EXPORT_RETRIEVAL_ID_MAX_LEN + || self.idempotency_key.len() > EXPORT_RETRIEVAL_ID_MAX_LEN + { + return Err(ApiError::LimitExceeded); + } + Ok(()) + } +} + +fn purpose_is_known(purpose: &str) -> bool { + matches!( + purpose, + "scientific_validation" + | "operational_monitoring" + | "partner_disclosure" + | "modular_service_consumer" + ) +} + +/// Refuse export-retrieval JSON that already carries scientific-metric keys. +/// +/// Empty payloads are admitted for the GET request body. 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-empty non-object. +pub fn refuse_metrics_on_export_retrieval_payload(payload: &str) -> Result<(), ApiError> { + if payload.trim().is_empty() { + return Ok(()); + } + let value: serde_json::Value = + serde_json::from_str(payload).map_err(|_| ApiError::InvalidWirePayload)?; + let Some(object) = value.as_object() else { + return Err(ApiError::InvalidWirePayload); + }; + if FORBIDDEN_EXPORT_RETRIEVAL_KEYS + .iter() + .any(|key| object.contains_key(*key)) + { + return Err(ApiError::InvalidWirePayload); + } + Ok(()) +} + +/// Extract the opaque export identity from `GET /v1/exports/{export_id}`. +/// +/// # Errors +/// +/// Returns [`ApiError::InvalidWirePayload`] for the collection path, extra +/// segments, a hostile encoding, or an empty identity, and +/// [`ApiError::LimitExceeded`] when the decoded identity exceeds +/// [`EXPORT_RETRIEVAL_ID_MAX_LEN`]. +pub(crate) fn export_retrieval_path_id(path: &str) -> Result { + let remainder = path + .strip_prefix(NARUON_EXPORT_PATH) + .ok_or(ApiError::InvalidWirePayload)?; + let encoded = remainder + .strip_prefix('/') + .ok_or(ApiError::InvalidWirePayload)?; + if encoded.is_empty() || encoded.contains('/') { + return Err(ApiError::InvalidWirePayload); + } + let export_id = decode_path_segment(encoded)?; + if export_id.len() > EXPORT_RETRIEVAL_ID_MAX_LEN { + return Err(ApiError::LimitExceeded); + } + Ok(export_id) +} + +/// Build a provider-owned `GET` export-retrieval exchange. +/// +/// The builder refuses non-`https` origins and empty or oversized identities. +/// It does not inject credentials. The GET body is empty. The identity +/// travels in the path; the builder does not send an `idempotency-key` +/// header. +/// +/// # Errors +/// +/// Returns [`ApiError::InvalidWirePayload`] for a non-`https` origin or empty +/// identity, and [`ApiError::LimitExceeded`] when the identity exceeds +/// [`EXPORT_RETRIEVAL_ID_MAX_LEN`] bytes. +pub fn naruon_export_retrieval_exchange( + origin: &str, + export_id: &str, +) -> Result { + require_nonempty(export_id)?; + if export_id.len() > EXPORT_RETRIEVAL_ID_MAX_LEN { + return Err(ApiError::LimitExceeded); + } + let encoded_id = encode_path_segment(export_id); + let target_path = format!("{NARUON_EXPORT_PATH}/{encoded_id}"); + let target_url = compose_https_target(origin, &target_path)?; + Ok(NaruonHttpExchange { + method: "GET", + target_url, + headers: vec![ + ("content-type".into(), "application/json".into()), + ("tepp-consumer".into(), "naruon".into()), + ("tepp-contract-version".into(), "1".into()), + ], + body: String::new(), + }) +} + +fn encode_path_segment(value: &str) -> String { + let mut out = String::with_capacity(value.len() + value.len() / 2); + let hex = b"0123456789ABCDEF"; + for byte in value.bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => { + out.push(byte as char); + } + _ => { + out.push('%'); + out.push(hex[usize::from(byte >> 4)] as char); + out.push(hex[usize::from(byte & 0x0F)] as char); + } + } + } + out +} + +fn decode_path_segment(value: &str) -> Result { + let mut out = Vec::with_capacity(value.len()); + let bytes = value.as_bytes(); + let mut index = 0; + while index < bytes.len() { + match bytes[index] { + b'%' => { + if index + 2 >= bytes.len() { + return Err(ApiError::InvalidWirePayload); + } + let hi = from_hex(bytes[index + 1])?; + let lo = from_hex(bytes[index + 2])?; + out.push((hi << 4) | lo); + index += 3; + } + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => { + out.push(bytes[index]); + index += 1; + } + _ => return Err(ApiError::InvalidWirePayload), + } + } + let decoded = String::from_utf8(out).map_err(|_| ApiError::InvalidWirePayload)?; + if decoded.is_empty() || decoded.contains('/') || decoded.contains('\0') { + return Err(ApiError::InvalidWirePayload); + } + Ok(decoded) +} + +fn from_hex(byte: u8) -> Result { + match byte { + b'0'..=b'9' => Ok(byte - b'0'), + b'A'..=b'F' => Ok(byte - b'A' + 10), + b'a'..=b'f' => Ok(byte - b'a' + 10), + _ => Err(ApiError::InvalidWirePayload), + } +} + +#[cfg(test)] +mod tests { + use super::{ + EXPORT_RETRIEVAL_CONTRACT_VERSION, EXPORT_RETRIEVAL_ID_MAX_LEN, ExportRetrieval, + export_retrieval_path_id, naruon_export_retrieval_exchange, + refuse_metrics_on_export_retrieval_payload, + }; + use crate::ApiError; + + #[test] + fn retrieval_round_trips_and_refuses_metrics() { + let retrieval = ExportRetrieval::new( + "export-1", + "artifact-1", + "purpose_bound_export_allowed", + "modular_service_consumer", + "export-idem-1", + ) + .expect("new"); + assert_eq!( + retrieval.contract_version, + EXPORT_RETRIEVAL_CONTRACT_VERSION + ); + let json = retrieval.to_json().expect("json"); + assert_eq!(ExportRetrieval::from_json(&json).expect("parse"), retrieval); + assert!(!json.contains("tenant_workspace_id")); + assert!(!json.contains("principal_id")); + assert!(!json.contains("includes_source_text")); + assert!(!json.contains("scientific_acceptance")); + assert_eq!(refuse_metrics_on_export_retrieval_payload(&json), Ok(())); + assert_eq!(refuse_metrics_on_export_retrieval_payload(""), Ok(())); + assert_eq!( + refuse_metrics_on_export_retrieval_payload(r#"{"rmse":1.0}"#), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + ExportRetrieval::new("", "a", "c", "modular_service_consumer", "k"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + ExportRetrieval::new("e", "a", "c", "unknown_purpose", "k"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + ExportRetrieval::new( + "e".repeat(EXPORT_RETRIEVAL_ID_MAX_LEN + 1), + "a", + "c", + "modular_service_consumer", + "k", + ), + Err(ApiError::LimitExceeded) + ); + } + + #[test] + fn path_parser_and_exchange_fail_closed() { + assert_eq!( + export_retrieval_path_id("/v1/exports/export-1").expect("id"), + "export-1" + ); + assert_eq!( + export_retrieval_path_id("/v1/exports"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + export_retrieval_path_id("/v1/exports/"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + export_retrieval_path_id("/v1/exports/a/b"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + export_retrieval_path_id("/v1/analysis-runs/export-1"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + export_retrieval_path_id(&format!( + "/v1/exports/{}", + "a".repeat(EXPORT_RETRIEVAL_ID_MAX_LEN + 1) + )), + Err(ApiError::LimitExceeded) + ); + let exchange = + naruon_export_retrieval_exchange("https://tepp.example.test", "export-1").expect("ex"); + assert_eq!(exchange.method, "GET"); + assert_eq!( + exchange.target_url, + "https://tepp.example.test/v1/exports/export-1" + ); + assert!(exchange.body.is_empty()); + assert!( + !exchange + .headers + .iter() + .any(|(name, _)| name.contains("idempotency") + || name.contains("authorization") + || name.contains("token") + || name.contains("copilot")) + ); + assert_eq!( + naruon_export_retrieval_exchange("http://tepp.example.test", "export-1"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + naruon_export_retrieval_exchange("https://db.postgres.example", "export-1"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + naruon_export_retrieval_exchange("https://tepp.example.test", ""), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + naruon_export_retrieval_exchange( + "https://tepp.example.test", + &"a".repeat(EXPORT_RETRIEVAL_ID_MAX_LEN + 1) + ), + Err(ApiError::LimitExceeded) + ); + let encoded = + naruon_export_retrieval_exchange("https://tepp.example.test", "exp/../x").expect("enc"); + assert!(encoded.target_url.contains("exp%2F..%2Fx")); + assert_eq!( + export_retrieval_path_id("/v1/exports/exp%2F..%2Fx"), + Err(ApiError::InvalidWirePayload) + ); + } +} diff --git a/crates/tepp_api/src/lib.rs b/crates/tepp_api/src/lib.rs index 876703ebc..bd8a933e0 100644 --- a/crates/tepp_api/src/lib.rs +++ b/crates/tepp_api/src/lib.rs @@ -20,6 +20,7 @@ mod corpus_split_manifest; mod envelope; mod error; mod export; +mod export_http; mod lineage_criterion_anchor; mod lineage_pair_criterion; mod lineageweave_http; @@ -95,6 +96,16 @@ pub use export::GraphMlExport; pub use export::JsonLdExport; /// Reproducibility manifest. pub use export::ReproducibilityManifest; +/// Export-retrieval contract version constant. +pub use export_http::EXPORT_RETRIEVAL_CONTRACT_VERSION; +/// Maximum export identity length on the retrieval path. +pub use export_http::EXPORT_RETRIEVAL_ID_MAX_LEN; +/// Metric-free export retrieval payload. +pub use export_http::ExportRetrieval; +/// Build a naruon export-retrieval GET exchange. +pub use export_http::naruon_export_retrieval_exchange; +/// Refuse scientific-metric keys on export-retrieval JSON. +pub use export_http::refuse_metrics_on_export_retrieval_payload; /// Analytical export purpose. pub use authorization::AnalyticalPurpose; diff --git a/crates/tepp_api/src/naruon_http.rs b/crates/tepp_api/src/naruon_http.rs index c5e277ec7..a70abd804 100644 --- a/crates/tepp_api/src/naruon_http.rs +++ b/crates/tepp_api/src/naruon_http.rs @@ -18,7 +18,7 @@ pub const NARUON_TEPP_INFERENCE_METHOD: &str = "tepp_topic_measurement"; /// One fail-closed HTTP exchange naruon may send to a TEPP origin. #[derive(Clone, Debug, Eq, PartialEq)] pub struct NaruonHttpExchange { - /// HTTP method (`POST` for both create and export-authorize). + /// HTTP method (`POST` for create and export-authorize; `GET` for export retrieval). pub method: &'static str, /// Absolute `https` URL on the versioned TEPP path. pub target_url: String, diff --git a/crates/tepp_api/tests/export_retrieval_http_contract.rs b/crates/tepp_api/tests/export_retrieval_http_contract.rs new file mode 100644 index 000000000..2fd47f51f --- /dev/null +++ b/crates/tepp_api/tests/export_retrieval_http_contract.rs @@ -0,0 +1,83 @@ +//! Contract tests for the export retrieval GET exchange. + +use tepp_api::{ + ApiError, EXPORT_RETRIEVAL_CONTRACT_VERSION, EXPORT_RETRIEVAL_ID_MAX_LEN, ExportRetrieval, + naruon_export_retrieval_exchange, refuse_metrics_on_export_retrieval_payload, +}; + +#[test] +fn export_retrieval_exchange_is_https_get_without_credentials_or_metrics() { + let exchange = naruon_export_retrieval_exchange("https://tepp.example.test", "export-9") + .expect("exchange"); + assert_eq!(exchange.method, "GET"); + assert_eq!( + exchange.target_url, + "https://tepp.example.test/v1/exports/export-9" + ); + 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")) + ); + let payload = ExportRetrieval::new( + "export-9", + "artifact-9", + "purpose_bound_export_allowed", + "modular_service_consumer", + "export-idem-9", + ) + .expect("payload"); + assert_eq!(payload.contract_version, EXPORT_RETRIEVAL_CONTRACT_VERSION); + let json = payload.to_json().expect("json"); + assert_eq!(refuse_metrics_on_export_retrieval_payload(&json), Ok(())); + assert!(!json.contains("scientific_acceptance")); + assert!(!json.contains("tenant_workspace_id")); + assert!(!json.contains("principal_id")); + assert!(!json.contains("includes_source_text")); + assert!(!json.contains("terminal_result")); +} + +#[test] +fn export_retrieval_contract_refuses_table_access_and_metric_keys() { + for origin in [ + "http://tepp.example.test", + "https://db.postgres.example", + "https://jdbc.example", + ] { + assert_eq!( + naruon_export_retrieval_exchange(origin, "export-9"), + Err(ApiError::InvalidWirePayload), + "origin={origin}" + ); + } + assert_eq!( + naruon_export_retrieval_exchange( + "https://tepp.example.test", + &"a".repeat(EXPORT_RETRIEVAL_ID_MAX_LEN + 1) + ), + Err(ApiError::LimitExceeded) + ); + assert_eq!( + refuse_metrics_on_export_retrieval_payload(r#"{"rmse":1.0}"#), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + refuse_metrics_on_export_retrieval_payload(r#"{"scientific_acceptance":{}}"#), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + refuse_metrics_on_export_retrieval_payload(r#"{"includes_source_text":true}"#), + Err(ApiError::InvalidWirePayload) + ); +} diff --git a/docs/API_CONTRACT.md b/docs/API_CONTRACT.md index b76b688e1..c72e0334e 100644 --- a/docs/API_CONTRACT.md +++ b/docs/API_CONTRACT.md @@ -1,14 +1,14 @@ # TEPP API and Modular Integration Contract **Status:** Accepted target contract; exact endpoints are introduced only with executable services. -**Last reviewed:** 2026-08-24 +**Last reviewed:** 2026-08-31 **Last reviewed:** 2026-08-21 ## 1. Authority boundary 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. `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. `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. Loopback `GET /v1/exports/{export_id}` on `AnalysisRunLiveService` is the executable export-retrieval route (ADR 0053); `NaruonLiveService` stays POST-only. ## 2. Contract families diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 2b783c2ab..2584be164 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -52,7 +52,7 @@ The full APA 7th standards/literature register remains `docs/research/standards- | recovery metrics (RMSE, bias, coverage, graph, temporal order, Monte Carlo SE gates) | PRD; Test Strategy; ADR 0007/0014 | `validation_core` on protected main (PR #19); SE-aware Monte Carlo gates included | implemented-main | | 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 | +| versioned service/API contracts and exports | PRD; API contract; ADR 0011/0013/0053 | `tepp_api` analysis-run/export/JSON-LD/GraphML contracts on protected main (PR #21); loopback `GET /v1/exports/{export_id}` is the executable retrieval route on this PR; request-bound terminal result active in PR #157; production TLS remaining | partial | | 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/0053-export-retrieval-get.md b/docs/adr/0053-export-retrieval-get.md new file mode 100644 index 000000000..803436261 --- /dev/null +++ b/docs/adr/0053-export-retrieval-get.md @@ -0,0 +1,130 @@ +# ADR 0053 — Loopback export retrieval GET path + +**Decision status:** Accepted +**Implementation maturity:** active-PR +**Date:** 2026-08-31 +**Supersedes:** None; complements ADR 0009, ADR 0011, and ADR 0018 for the operator-visible jump from a purpose-bound export authorization to a durable export identity. Does not supersede ADR 0014 claim-promotion authority. ADR 0026–0052 remain on live GAP-003A engine-library, terminal-wire DTO, GET-by-id, lifecycle-POST, cancel, loopback-CLI, collection-GET, retry, stored-request, retry-lineage, lookup, retry-parent, wait-CLI, and analysis-run profile slices. +**Figma File ID:** N/A — this increment changes a Rust service crate and has no user-interface surface. +**Storybook inventory:** N/A — no reusable web object or interaction changed. + +## Context + +`POST /v1/exports` on `NaruonLiveService` authorizes a purpose-bound export and +returns a decision without minting a retrievable identity. JSON-LD and GraphML +envelopes exist as library contracts. Operators who hold a 200 authorization +therefore cannot address that export later. Returning RMSE, bias, coverage, +SE-gate, source text, or `tepp.scientific_acceptance.v1` on the retrieval body +would treat authorization identity as measurement evidence. GET-by-id (#359) +is status/terminal by `run_id` on another stack and remains 400 here. GAP-010 +Figma/export is a visual-analytics workflow and is not this HTTP identity. + +## Decision + +`AnalysisRunLiveService` (`tepp-loopback`) serves naruon-only export routes on +loopback: + +- `POST /v1/exports` mints an opaque `export_id`, stores a metric-free + retrieval receipt in memory, and returns HTTP 200 with that receipt. +- `GET /v1/exports/{export_id}` returns the same metric-free receipt. +- The payload is `export_id`, `artifact_id`, `decision_code`, `purpose`, and + `idempotency_key`. +- `tepp.scientific_acceptance.v1`, RMSE, bias, coverage, SE-gate, report, + `terminal_result`, `tenant_workspace_id`, `principal_id`, + `includes_source_text`, and source bodies never appear. +- LineageWeave is refused. Naruon owns the current purpose-bound export + adapter. `NaruonLiveService` stays POST-only. +- Empty GET bodies only. Query strings, collection GET `/v1/exports`, + GET-by-id analysis-run paths, POST `/v1/exports/{export_id}`, and nonempty + GET bodies fail closed. +- The identity travels in the path. The NARUON GET exchange does not send an + `idempotency-key` header or credentials. +- Unknown identities fail closed. Persistence remains GAP-003B. + +## Non-goals + +- Production TLS, public bind, or durable export storage. +- Leiden community detection, Driver p.16 std-family restoration, or + Figma/export work (GAP-010). +- Promoting an ADR 0014 scientific claim from HTTP success. +- Duplicating GET `/v1/analysis-runs/{run_id}`, POST running/terminal, POST + cancel, GET collection, POST retry, GET stored-request, GET retry-lineage, + lookup GET, retry-parent GET, wait CLI, or adding GET to `NaruonLiveService`. +- Returning JSON-LD or GraphML envelopes on this identity route. + +## Alternatives considered + +1. **Ask operators to re-POST authorization** — rejected because a 200 + decision is not an addressable identity and conflicting retries fail closed. +2. **Return `tepp.scientific_acceptance.v1` or JSON-LD on succeeded retrieval** + — rejected because retrieval bodies must stay metric-free and GAP-010 + visual/export envelopes remain later work. +3. **Add GET to `NaruonLiveService`** — rejected because that listener stays + POST-only except existing Naruon-only analysis-run inspects on other stacks. +4. **Metric-free export retrieval GET on `AnalysisRunLiveService`** — accepted. + +## Consequences + +- Operators can resolve a 200 export authorization to a durable `export_id` + without scanning artifacts. +- Retrieval pages cannot be mistaken for a succeeded scientific-acceptance + result or a JSON-LD/GraphML envelope. +- GET-by-id analysis-run status may later return a digest-bound artifact + without changing these retrieval gates. + +## Failure and recovery + +Unknown identities, extra path segments, collection GET, query strings, +nonempty GET bodies, metric keys, LineageWeave, unpublished consumers, +consumer mismatch, and non-loopback hosts return a redacted `400` envelope. +Oversized identities return `413`. Credential headers remain `403`. The +in-memory registry is not durable; a restart requires re-POSTing the original +metric-free authorization. Callers must not fabricate a succeeded +scientific-acceptance artifact from a retrieval payload. + +## Security, privacy, scientific-integrity, and governance impact + +- No credential headers cross the consumer boundary. +- Export retrieval remains loopback-only, size-bounded, naruon-scoped, and + content-redacting. +- HTTP `200` on a retrieval payload is not measurement evidence and is not + release evidence. +- Tenant, principal, and source-text flags stay off the retrieval body so + retrieval cannot become a PII oracle. + +## Compatibility and migration + +`NaruonLiveService` POST `/v1/exports` still returns an authorization +decision without an `export_id`. Create 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 +retrieval fields and the artifact refusal. + +## Verification + +Falsifiable evidence: + +- GET retrieval JSON has no RMSE/bias/coverage/SE-gate/scientific-acceptance/ + `terminal_result`/`tenant_workspace_id`/`principal_id`/`includes_source_text` + keys; +- POST then GET of the minted `export_id` returns the matching `artifact_id`; +- GET does not leak another consumer's export; +- collection GET, GET-by-id, query strings, nonempty bodies, LineageWeave, + unknown identities, and POST `/v1/exports/{export_id}` fail closed; +- `NaruonLiveService` still refuses GET; +- Clippy `-D warnings`, `tepp_api` tests, rustdoc, and exact-head review + remain required. + +## Rollback and supersession + +Rollback removes export retrieval GET dispatch and the `AnalysisRunLiveService` +export POST mint; `NaruonLiveService` POST authorization remains valid. A +superseding ADR is required to persist the registry, bind a public address, +emit scientific-acceptance or JSON-LD on retrieval, add GET to +`NaruonLiveService`, or treat HTTP success as an ADR 0014 claim. + +## Related authority + +- ADR 0009 owns purpose-bound disclosure without blanket masking. +- ADR 0011 owns standalone/CWL MSA service authority. +- ADR 0018 owns consumer-scoped ingress and metric-free receipts. +- ADR 0027 owns GET-by-id status (live on another PR). diff --git a/docs/adr/README.md b/docs/adr/README.md index 1254c8079..6c820a918 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. | +| [0053](0053-export-retrieval-get.md) | Loopback export retrieval GET | Accepted | active-PR | `AnalysisRunLiveService` mints a metric-free `export_id` on naruon `POST /v1/exports` and serves `GET /v1/exports/{export_id}`; `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. | @@ -140,6 +141,7 @@ Use the narrowest owning ADR when decisions overlap: - **accepted-run execution and terminal artifact production:** ADR 0022. - **independent lineage criterion and posterior Project Journey:** ADR 0023. - **macOS-native Rust-owned MLX Metal execution:** ADR 0024. +- **loopback export retrieval identity:** ADR 0053. ## Change and supersession rule diff --git a/docs/connectors/naruon-artifact-consumer.md b/docs/connectors/naruon-artifact-consumer.md index 9c6f6d185..097e0b598 100644 --- a/docs/connectors/naruon-artifact-consumer.md +++ b/docs/connectors/naruon-artifact-consumer.md @@ -28,6 +28,7 @@ TEPP remains the scientific authority for estimation, recovery metrics, temporal | HTTP analysis-run create | `tepp_api` `naruon_analysis_run_exchange` → `POST /v1/analysis-runs` | 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 | +| Live loopback export retrieval | `tepp_api` `AnalysisRunLiveService` → `POST /v1/exports` then `GET /v1/exports/{export_id}` | naruon → TEPP | Committed examples live under `examples/`. Schemas for analysis-run requests and corpus-split manifests live under `schemas/`. diff --git a/docs/research/export-retrieval-http.md b/docs/research/export-retrieval-http.md new file mode 100644 index 000000000..ca329ee51 --- /dev/null +++ b/docs/research/export-retrieval-http.md @@ -0,0 +1,35 @@ +# Export retrieval HTTP (doctoring) + +## Scope + +Operators who receive a 200 purpose-bound export authorization still cannot +address that export later. `POST /v1/exports` on `NaruonLiveService` returns a +decision without minting an `export_id`. `GET /v1/exports/{export_id}` on +`AnalysisRunLiveService` is the first executable retrieval route. HTTP method, +path, `Host`, and `Transfer-Encoding` semantics follow current HTTP semantics +(Fielding, Nottingham, & Reschke, 2022). Fail-closed refusal of table-access +URLs, review/Copilot/NIM/proxy credential headers, metric keys, LineageWeave +on this naruon-owned adapter, and non-loopback binds is repository contract +authority, not an RFC inference rule. + +The live listener is loopback HTTP/1.1 with an installed read/write deadline. +It is not a production TLS/`$PORT` service. Persistence remains GAP-003B. +JSON-LD/GraphML envelopes, Figma views, and GAP-010 visual export workflows +remain later work. + +## Internal contract evidence + +- ADR 0053 owns this retrieval GET. +- ADR 0009 owns purpose-bound disclosure without blanket masking. +- ADR 0011 owns the standalone/CWL MSA boundary. +- `docs/API_CONTRACT.md` names `GET /v1/exports/{export_id}` as the target + retrieval shape. +- `docs/connectors/naruon-artifact-consumer.md` records naruon as the current + purpose-bound export adapter. + +## Non-goals + +GET-by-id status (#359), lifecycle POST, cancel, collection GET, retry, +stored-request GET, retry-lineage GET, lookup GET, retry-parent GET, wait CLI, +Leiden, Driver p.16 std-family restoration, Figma/export (GAP-010), and +Compose persistence (GAP-003B). diff --git a/docs/research/naruon-http-interchange.md b/docs/research/naruon-http-interchange.md index a8245ebdb..781d3a843 100644 --- a/docs/research/naruon-http-interchange.md +++ b/docs/research/naruon-http-interchange.md @@ -3,7 +3,9 @@ ## Scope naruon may submit analysis-run requests and request purpose-bound exports only -through versioned `https` POST paths owned by TEPP. HTTP method, path, `Host`, +through versioned `https` POST paths owned by TEPP. Export retrieval is the +loopback `GET /v1/exports/{export_id}` identity route on +`AnalysisRunLiveService` (ADR 0053). HTTP method, path, `Host`, and `Transfer-Encoding` semantics follow current HTTP semantics (Fielding, Nottingham, & Reschke, 2022). Knowledge-cutoff instants use RFC 3339 (Klyne & Newman, 2002). Fail-closed refusal of table-access URLs, diff --git a/schemas/export_retrieval_v1.json b/schemas/export_retrieval_v1.json new file mode 100644 index 000000000..2fe9b815e --- /dev/null +++ b/schemas/export_retrieval_v1.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://tepp.local/schemas/export_retrieval_v1.json", + "title": "ExportRetrievalV1", + "type": "object", + "additionalProperties": false, + "required": [ + "contract_version", + "export_id", + "artifact_id", + "decision_code", + "purpose", + "idempotency_key" + ], + "properties": { + "contract_version": { "type": "integer", "const": 1 }, + "export_id": { "type": "string", "minLength": 1, "maxLength": 128, "pattern": ".*\\S.*" }, + "artifact_id": { "type": "string", "minLength": 1, "maxLength": 128, "pattern": ".*\\S.*" }, + "decision_code": { "type": "string", "minLength": 1, "pattern": ".*\\S.*" }, + "purpose": { + "type": "string", + "enum": [ + "scientific_validation", + "operational_monitoring", + "partner_disclosure", + "modular_service_consumer" + ] + }, + "idempotency_key": { "type": "string", "minLength": 1, "maxLength": 128, "pattern": ".*\\S.*" } + } +} From b81f283b5aafafae438266c7c98c0ac72caf3526 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 31 Aug 2026 18:08:07 +0000 Subject: [PATCH 2/4] docs(adr): use unique 0054 for export retrieval GET ADR 0053 is already live on #409 (Pareto candidate-K vs main). Rename this stack's decision identity rather than collide. --- CHANGELOG.d/export-retrieval-http.md | 2 +- CHANGELOG.md | 2 +- docs/API_CONTRACT.md | 2 +- docs/TRACEABILITY.md | 2 +- ...3-export-retrieval-get.md => 0054-export-retrieval-get.md} | 4 ++-- docs/adr/README.md | 4 ++-- docs/research/export-retrieval-http.md | 2 +- docs/research/naruon-http-interchange.md | 2 +- 8 files changed, 10 insertions(+), 10 deletions(-) rename docs/adr/{0053-export-retrieval-get.md => 0054-export-retrieval-get.md} (98%) diff --git a/CHANGELOG.d/export-retrieval-http.md b/CHANGELOG.d/export-retrieval-http.md index 60e198535..69702a08c 100644 --- a/CHANGELOG.d/export-retrieval-http.md +++ b/CHANGELOG.d/export-retrieval-http.md @@ -1 +1 @@ -- `tepp_api` loopback `GET /v1/exports/{export_id}` returns the metric-free identity of one purpose-bound export that `AnalysisRunLiveService` authorized, so operators can retrieve that export without scanning artifacts (ADR 0053). `NaruonLiveService` stays POST-only. GET-by-id remains refused. Not lifecycle POST, not cancel, not collection GET, not retry, not stored-request GET, not retry-lineage GET, not lookup GET, not retry-parent GET, not GAP-010 Figma/export, not persistence. +- `tepp_api` loopback `GET /v1/exports/{export_id}` returns the metric-free identity of one purpose-bound export that `AnalysisRunLiveService` authorized, so operators can retrieve that export without scanning artifacts (ADR 0054). `NaruonLiveService` stays POST-only. GET-by-id remains refused. Not lifecycle POST, not cancel, not collection GET, not retry, not stored-request GET, not retry-lineage GET, not lookup GET, not retry-parent GET, not GAP-010 Figma/export, not persistence. diff --git a/CHANGELOG.md b/CHANGELOG.md index 04ccc12ca..93e5b3335 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,7 +38,7 @@ All notable changes to TEPP are documented here. The format follows Keep a Chang ## [Unreleased] -- `tepp_api` loopback `GET /v1/exports/{export_id}` returns the metric-free identity of one purpose-bound export that `AnalysisRunLiveService` authorized, so operators can retrieve that export without scanning artifacts (ADR 0053). `NaruonLiveService` stays POST-only. GET-by-id remains refused. Not lifecycle POST, not cancel, not collection GET, not retry, not stored-request GET, not retry-lineage GET, not lookup GET, not retry-parent GET, not GAP-010 Figma/export, not persistence. +- `tepp_api` loopback `GET /v1/exports/{export_id}` returns the metric-free identity of one purpose-bound export that `AnalysisRunLiveService` authorized, so operators can retrieve that export without scanning artifacts (ADR 0054). `NaruonLiveService` stays POST-only. GET-by-id remains refused. Not lifecycle POST, not cancel, not collection GET, not retry, not stored-request GET, not retry-lineage GET, not lookup GET, not retry-parent GET, not GAP-010 Figma/export, not persistence. - `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/docs/API_CONTRACT.md b/docs/API_CONTRACT.md index c72e0334e..1142e99fe 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. `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. Loopback `GET /v1/exports/{export_id}` on `AnalysisRunLiveService` is the executable export-retrieval route (ADR 0053); `NaruonLiveService` stays POST-only. +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. `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. Loopback `GET /v1/exports/{export_id}` on `AnalysisRunLiveService` is the executable export-retrieval route (ADR 0054); `NaruonLiveService` stays POST-only. ## 2. Contract families diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 2584be164..20d4b7f01 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -52,7 +52,7 @@ The full APA 7th standards/literature register remains `docs/research/standards- | recovery metrics (RMSE, bias, coverage, graph, temporal order, Monte Carlo SE gates) | PRD; Test Strategy; ADR 0007/0014 | `validation_core` on protected main (PR #19); SE-aware Monte Carlo gates included | implemented-main | | 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/0053 | `tepp_api` analysis-run/export/JSON-LD/GraphML contracts on protected main (PR #21); loopback `GET /v1/exports/{export_id}` is the executable retrieval route on this PR; request-bound terminal result active in PR #157; production TLS remaining | partial | +| versioned service/API contracts and exports | PRD; API contract; ADR 0011/0013/0054 | `tepp_api` analysis-run/export/JSON-LD/GraphML contracts on protected main (PR #21); loopback `GET /v1/exports/{export_id}` is the executable retrieval route on this PR; request-bound terminal result active in PR #157; production TLS remaining | partial | | 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/0053-export-retrieval-get.md b/docs/adr/0054-export-retrieval-get.md similarity index 98% rename from docs/adr/0053-export-retrieval-get.md rename to docs/adr/0054-export-retrieval-get.md index 803436261..668cbde01 100644 --- a/docs/adr/0053-export-retrieval-get.md +++ b/docs/adr/0054-export-retrieval-get.md @@ -1,9 +1,9 @@ -# ADR 0053 — Loopback export retrieval GET path +# ADR 0054 — Loopback export retrieval GET path **Decision status:** Accepted **Implementation maturity:** active-PR **Date:** 2026-08-31 -**Supersedes:** None; complements ADR 0009, ADR 0011, and ADR 0018 for the operator-visible jump from a purpose-bound export authorization to a durable export identity. Does not supersede ADR 0014 claim-promotion authority. ADR 0026–0052 remain on live GAP-003A engine-library, terminal-wire DTO, GET-by-id, lifecycle-POST, cancel, loopback-CLI, collection-GET, retry, stored-request, retry-lineage, lookup, retry-parent, wait-CLI, and analysis-run profile slices. +**Supersedes:** None; complements ADR 0009, ADR 0011, and ADR 0018 for the operator-visible jump from a purpose-bound export authorization to a durable export identity. Does not supersede ADR 0014 claim-promotion authority. ADR 0026–0053 remain on live GAP-003A engine-library, terminal-wire DTO, GET-by-id, lifecycle-POST, cancel, loopback-CLI, collection-GET, retry, stored-request, retry-lineage, lookup, retry-parent, wait-CLI, and analysis-run profile slices. **Figma File ID:** N/A — this increment changes a Rust service crate and has no user-interface surface. **Storybook inventory:** N/A — no reusable web object or interaction changed. diff --git a/docs/adr/README.md b/docs/adr/README.md index 6c820a918..5e43e54fb 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -30,7 +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. | -| [0053](0053-export-retrieval-get.md) | Loopback export retrieval GET | Accepted | active-PR | `AnalysisRunLiveService` mints a metric-free `export_id` on naruon `POST /v1/exports` and serves `GET /v1/exports/{export_id}`; `NaruonLiveService` stays POST-only. | +| [0054](0054-export-retrieval-get.md) | Loopback export retrieval GET | Accepted | active-PR | `AnalysisRunLiveService` mints a metric-free `export_id` on naruon `POST /v1/exports` and serves `GET /v1/exports/{export_id}`; `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. | @@ -141,7 +141,7 @@ Use the narrowest owning ADR when decisions overlap: - **accepted-run execution and terminal artifact production:** ADR 0022. - **independent lineage criterion and posterior Project Journey:** ADR 0023. - **macOS-native Rust-owned MLX Metal execution:** ADR 0024. -- **loopback export retrieval identity:** ADR 0053. +- **loopback export retrieval identity:** ADR 0054. ## Change and supersession rule diff --git a/docs/research/export-retrieval-http.md b/docs/research/export-retrieval-http.md index ca329ee51..4e2f102c5 100644 --- a/docs/research/export-retrieval-http.md +++ b/docs/research/export-retrieval-http.md @@ -19,7 +19,7 @@ remain later work. ## Internal contract evidence -- ADR 0053 owns this retrieval GET. +- ADR 0054 owns this retrieval GET. - ADR 0009 owns purpose-bound disclosure without blanket masking. - ADR 0011 owns the standalone/CWL MSA boundary. - `docs/API_CONTRACT.md` names `GET /v1/exports/{export_id}` as the target diff --git a/docs/research/naruon-http-interchange.md b/docs/research/naruon-http-interchange.md index 781d3a843..597d17aca 100644 --- a/docs/research/naruon-http-interchange.md +++ b/docs/research/naruon-http-interchange.md @@ -5,7 +5,7 @@ naruon may submit analysis-run requests and request purpose-bound exports only through versioned `https` POST paths owned by TEPP. Export retrieval is the loopback `GET /v1/exports/{export_id}` identity route on -`AnalysisRunLiveService` (ADR 0053). HTTP method, path, `Host`, +`AnalysisRunLiveService` (ADR 0054). HTTP method, path, `Host`, and `Transfer-Encoding` semantics follow current HTTP semantics (Fielding, Nottingham, & Reschke, 2022). Knowledge-cutoff instants use RFC 3339 (Klyne & Newman, 2002). Fail-closed refusal of table-access URLs, From 93b3fa9574ac6e56f99fde11143177791d4d76f3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 04:07:22 +0900 Subject: [PATCH 3/4] fix(api): harden export retrieval capabilities --- crates/tepp_api/src/analysis_run_live.rs | 40 +++-- crates/tepp_api/src/export_http.rs | 147 +++++++++++++++--- .../tests/export_retrieval_http_contract.rs | 4 + docs/adr/0054-export-retrieval-get.md | 16 +- docs/connectors/naruon-artifact-consumer.md | 1 + schemas/export_retrieval_v1.json | 2 +- 6 files changed, 165 insertions(+), 45 deletions(-) diff --git a/crates/tepp_api/src/analysis_run_live.rs b/crates/tepp_api/src/analysis_run_live.rs index 0ca7dbd97..a5f1f9f93 100644 --- a/crates/tepp_api/src/analysis_run_live.rs +++ b/crates/tepp_api/src/analysis_run_live.rs @@ -34,7 +34,6 @@ const MAX_LIVE_REQUEST_BODY_BYTES: usize = DEFAULT_PROJECT_HISTORY_BYTE_LIMIT; #[derive(Clone, Debug, Eq, PartialEq)] struct StoredExport { - consumer: String, request: ExportAuthorizationRequest, retrieval: ExportRetrieval, } @@ -53,7 +52,6 @@ pub struct AnalysisRunLiveService { bound_addr: Option, next_run_serial: u64, next_request_serial: u64, - next_export_serial: u64, accepted_runs: HashMap, accepted_project_histories: HashMap, authorized_exports: HashMap, @@ -75,7 +73,6 @@ impl AnalysisRunLiveService { bound_addr: None, next_run_serial: 1, next_request_serial: 1, - next_export_serial: 1, accepted_runs: HashMap::new(), accepted_project_histories: HashMap::new(), authorized_exports: HashMap::new(), @@ -300,8 +297,7 @@ impl AnalysisRunLiveService { } return Err(ApiError::InvalidWirePayload); } - let export_id = format!("tepp-export-{}", self.next_export_serial); - self.next_export_serial += 1; + let export_id = uuid::Uuid::now_v7().hyphenated().to_string(); let retrieval = ExportRetrieval::new( export_id.clone(), request.artifact_id.clone(), @@ -312,14 +308,8 @@ impl AnalysisRunLiveService { let response_body = retrieval.to_json()?; refuse_metrics_on_export_retrieval_payload(&response_body)?; self.exports_by_id.insert(export_id, replay_key.clone()); - self.authorized_exports.insert( - replay_key, - StoredExport { - consumer: consumer.to_owned(), - request, - retrieval, - }, - ); + self.authorized_exports + .insert(replay_key, StoredExport { request, retrieval }); Ok(json_response(200, "OK", response_body)) } @@ -347,9 +337,6 @@ impl AnalysisRunLiveService { .authorized_exports .get(&replay_key) .ok_or(ApiError::InvalidWirePayload)?; - if stored.consumer != consumer { - return Err(ApiError::InvalidWirePayload); - } let response_body = stored.retrieval.to_json()?; refuse_metrics_on_export_retrieval_payload(&response_body)?; Ok(json_response(200, "OK", response_body)) @@ -593,6 +580,14 @@ mod tests { .status_code, 400 ); + assert_eq!( + service + .handle_http_request(&format!( + "PUT {NARUON_ANALYSIS_RUN_PATH} HTTP/1.1\r\ncontent-length: 0\r\n\r\n" + )) + .status_code, + 400 + ); for request_line in [ "POST /wrong HTTP/1.1", "POST /v1/analysis-runs HTTP/1.0", @@ -1080,6 +1075,9 @@ mod tests { )); assert_eq!(posted.status_code, 200); let retrieval = ExportRetrieval::from_json(&posted.body).expect("posted retrieval"); + let parsed_export_id = + uuid::Uuid::parse_str(&retrieval.export_id).expect("UUID capability"); + assert_eq!(parsed_export_id.get_version_num(), 7); assert_eq!(retrieval.artifact_id, "artifact-live-1"); assert_eq!(retrieval.purpose, "modular_service_consumer"); assert_eq!(retrieval.decision_code, "purpose_bound_export_allowed"); @@ -1210,6 +1208,16 @@ mod tests { "principal-analyst-1", )); assert_eq!(principal_as_key.status_code, 400); + assert_eq!( + service + .handle_http_request(&export_post_http( + &body, + NARUON_CONSUMER_CODE, + &"k".repeat(crate::EXPORT_RETRIEVAL_ID_MAX_LEN + 1), + )) + .status_code, + 413 + ); } fn export_post_http(body: &str, consumer: &str, idempotency_key: &str) -> String { diff --git a/crates/tepp_api/src/export_http.rs b/crates/tepp_api/src/export_http.rs index bd563bf2e..36e986072 100644 --- a/crates/tepp_api/src/export_http.rs +++ b/crates/tepp_api/src/export_http.rs @@ -23,6 +23,9 @@ pub const EXPORT_RETRIEVAL_ID_MAX_LEN: usize = 128; /// Supported export-retrieval contract version. pub const EXPORT_RETRIEVAL_CONTRACT_VERSION: u16 = 1; +/// The only authorization decision that may mint a retrieval receipt. +pub const EXPORT_RETRIEVAL_ALLOWED_DECISION_CODE: &str = "purpose_bound_export_allowed"; + const FORBIDDEN_EXPORT_RETRIEVAL_KEYS: [&str; 16] = [ "rmse", "rmse_standard_error", @@ -136,6 +139,9 @@ impl ExportRetrieval { if !purpose_is_known(&self.purpose) { return Err(ApiError::InvalidWirePayload); } + if self.decision_code != EXPORT_RETRIEVAL_ALLOWED_DECISION_CODE { + return Err(ApiError::AuthorizationDenied); + } if self.export_id.len() > EXPORT_RETRIEVAL_ID_MAX_LEN || self.artifact_id.len() > EXPORT_RETRIEVAL_ID_MAX_LEN || self.idempotency_key.len() > EXPORT_RETRIEVAL_ID_MAX_LEN @@ -171,18 +177,26 @@ pub fn refuse_metrics_on_export_retrieval_payload(payload: &str) -> Result<(), A } let value: serde_json::Value = serde_json::from_str(payload).map_err(|_| ApiError::InvalidWirePayload)?; - let Some(object) = value.as_object() else { + if !value.is_object() { return Err(ApiError::InvalidWirePayload); - }; - if FORBIDDEN_EXPORT_RETRIEVAL_KEYS - .iter() - .any(|key| object.contains_key(*key)) - { + } + if contains_forbidden_export_key(&value) { return Err(ApiError::InvalidWirePayload); } Ok(()) } +fn contains_forbidden_export_key(value: &serde_json::Value) -> bool { + match value { + serde_json::Value::Object(object) => object.iter().any(|(key, value)| { + FORBIDDEN_EXPORT_RETRIEVAL_KEYS.contains(&key.as_str()) + || contains_forbidden_export_key(value) + }), + serde_json::Value::Array(values) => values.iter().any(contains_forbidden_export_key), + _ => false, + } +} + /// Extract the opaque export identity from `GET /v1/exports/{export_id}`. /// /// # Errors @@ -284,7 +298,7 @@ fn decode_path_segment(value: &str) -> Result { } } let decoded = String::from_utf8(out).map_err(|_| ApiError::InvalidWirePayload)?; - if decoded.is_empty() || decoded.contains('/') || decoded.contains('\0') { + if decoded.contains('/') || decoded.contains('\0') { return Err(ApiError::InvalidWirePayload); } Ok(decoded) @@ -302,18 +316,19 @@ fn from_hex(byte: u8) -> Result { #[cfg(test)] mod tests { use super::{ - EXPORT_RETRIEVAL_CONTRACT_VERSION, EXPORT_RETRIEVAL_ID_MAX_LEN, ExportRetrieval, - export_retrieval_path_id, naruon_export_retrieval_exchange, - refuse_metrics_on_export_retrieval_payload, + EXPORT_RETRIEVAL_ALLOWED_DECISION_CODE, EXPORT_RETRIEVAL_CONTRACT_VERSION, + EXPORT_RETRIEVAL_ID_MAX_LEN, ExportRetrieval, export_retrieval_path_id, + naruon_export_retrieval_exchange, refuse_metrics_on_export_retrieval_payload, }; use crate::ApiError; #[test] + #[allow(clippy::too_many_lines)] fn retrieval_round_trips_and_refuses_metrics() { let retrieval = ExportRetrieval::new( "export-1", "artifact-1", - "purpose_bound_export_allowed", + EXPORT_RETRIEVAL_ALLOWED_DECISION_CODE, "modular_service_consumer", "export-idem-1", ) @@ -330,28 +345,97 @@ mod tests { assert!(!json.contains("scientific_acceptance")); assert_eq!(refuse_metrics_on_export_retrieval_payload(&json), Ok(())); assert_eq!(refuse_metrics_on_export_retrieval_payload(""), Ok(())); + assert_eq!( + refuse_metrics_on_export_retrieval_payload(r#"{"nested":["safe"]}"#), + Ok(()) + ); assert_eq!( refuse_metrics_on_export_retrieval_payload(r#"{"rmse":1.0}"#), Err(ApiError::InvalidWirePayload) ); assert_eq!( - ExportRetrieval::new("", "a", "c", "modular_service_consumer", "k"), + refuse_metrics_on_export_retrieval_payload(r#"{"nested":{"rmse":1.0}}"#), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + refuse_metrics_on_export_retrieval_payload(r#"{"nested":[{"rmse":1.0}]}"#), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + refuse_metrics_on_export_retrieval_payload("[]"), Err(ApiError::InvalidWirePayload) ); assert_eq!( - ExportRetrieval::new("e", "a", "c", "unknown_purpose", "k"), + ExportRetrieval::new( + "", + "a", + EXPORT_RETRIEVAL_ALLOWED_DECISION_CODE, + "modular_service_consumer", + "k" + ), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + ExportRetrieval::new("e", "a", "denied", "modular_service_consumer", "k"), + Err(ApiError::AuthorizationDenied) + ); + assert_eq!( + ExportRetrieval::new( + "e", + "a", + EXPORT_RETRIEVAL_ALLOWED_DECISION_CODE, + "unknown_purpose", + "k" + ), Err(ApiError::InvalidWirePayload) ); assert_eq!( ExportRetrieval::new( "e".repeat(EXPORT_RETRIEVAL_ID_MAX_LEN + 1), "a", - "c", + EXPORT_RETRIEVAL_ALLOWED_DECISION_CODE, "modular_service_consumer", "k", ), Err(ApiError::LimitExceeded) ); + assert_eq!( + ExportRetrieval::new( + "e", + "a".repeat(EXPORT_RETRIEVAL_ID_MAX_LEN + 1), + EXPORT_RETRIEVAL_ALLOWED_DECISION_CODE, + "modular_service_consumer", + "k", + ), + Err(ApiError::LimitExceeded) + ); + assert_eq!( + ExportRetrieval::new( + "e", + "a", + EXPORT_RETRIEVAL_ALLOWED_DECISION_CODE, + "modular_service_consumer", + "k".repeat(EXPORT_RETRIEVAL_ID_MAX_LEN + 1), + ), + Err(ApiError::LimitExceeded) + ); + for purpose in [ + "scientific_validation", + "operational_monitoring", + "partner_disclosure", + "modular_service_consumer", + ] { + assert!( + ExportRetrieval::new( + "e", + "a", + EXPORT_RETRIEVAL_ALLOWED_DECISION_CODE, + purpose, + "k" + ) + .is_ok() + ); + } } #[test] @@ -372,6 +456,26 @@ mod tests { export_retrieval_path_id("/v1/exports/a/b"), Err(ApiError::InvalidWirePayload) ); + assert_eq!( + export_retrieval_path_id("/v1/exports/%"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + export_retrieval_path_id("/v1/exports/%00"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + export_retrieval_path_id("/v1/exports/%2f"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + export_retrieval_path_id("/v1/exports/%GG"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + export_retrieval_path_id("/v1/exports/!"), + Err(ApiError::InvalidWirePayload) + ); assert_eq!( export_retrieval_path_id("/v1/analysis-runs/export-1"), Err(ApiError::InvalidWirePayload) @@ -391,14 +495,13 @@ mod tests { "https://tepp.example.test/v1/exports/export-1" ); assert!(exchange.body.is_empty()); - assert!( - !exchange - .headers - .iter() - .any(|(name, _)| name.contains("idempotency") - || name.contains("authorization") - || name.contains("token") - || name.contains("copilot")) + assert_eq!( + exchange.headers, + vec![ + ("content-type".into(), "application/json".into()), + ("tepp-consumer".into(), "naruon".into()), + ("tepp-contract-version".into(), "1".into()), + ] ); assert_eq!( naruon_export_retrieval_exchange("http://tepp.example.test", "export-1"), diff --git a/crates/tepp_api/tests/export_retrieval_http_contract.rs b/crates/tepp_api/tests/export_retrieval_http_contract.rs index 2fd47f51f..05143f9ad 100644 --- a/crates/tepp_api/tests/export_retrieval_http_contract.rs +++ b/crates/tepp_api/tests/export_retrieval_http_contract.rs @@ -76,6 +76,10 @@ fn export_retrieval_contract_refuses_table_access_and_metric_keys() { refuse_metrics_on_export_retrieval_payload(r#"{"scientific_acceptance":{}}"#), Err(ApiError::InvalidWirePayload) ); + assert_eq!( + refuse_metrics_on_export_retrieval_payload(r#"{"nested":{"rmse":1.0}}"#), + Err(ApiError::InvalidWirePayload) + ); assert_eq!( refuse_metrics_on_export_retrieval_payload(r#"{"includes_source_text":true}"#), Err(ApiError::InvalidWirePayload) diff --git a/docs/adr/0054-export-retrieval-get.md b/docs/adr/0054-export-retrieval-get.md index 668cbde01..5c35d52ed 100644 --- a/docs/adr/0054-export-retrieval-get.md +++ b/docs/adr/0054-export-retrieval-get.md @@ -23,8 +23,9 @@ Figma/export is a visual-analytics workflow and is not this HTTP identity. `AnalysisRunLiveService` (`tepp-loopback`) serves naruon-only export routes on loopback: -- `POST /v1/exports` mints an opaque `export_id`, stores a metric-free - retrieval receipt in memory, and returns HTTP 200 with that receipt. +- `POST /v1/exports` mints an unguessable UUID v7 bearer-capability + `export_id`, stores a metric-free retrieval receipt in memory, and returns + HTTP 200 with that receipt. - `GET /v1/exports/{export_id}` returns the same metric-free receipt. - The payload is `export_id`, `artifact_id`, `decision_code`, `purpose`, and `idempotency_key`. @@ -36,9 +37,11 @@ loopback: - Empty GET bodies only. Query strings, collection GET `/v1/exports`, GET-by-id analysis-run paths, POST `/v1/exports/{export_id}`, and nonempty GET bodies fail closed. -- The identity travels in the path. The NARUON GET exchange does not send an - `idempotency-key` header or credentials. -- Unknown identities fail closed. Persistence remains GAP-003B. +- The bearer capability travels in the path. The NARUON GET exchange does not + send an `idempotency-key` header or additional credential headers. +- Unknown capabilities fail closed. Possession grants retrieval of only the + metric-free receipt, so callers must treat the path as sensitive. Persistence + remains GAP-003B. ## Non-goals @@ -84,7 +87,8 @@ scientific-acceptance artifact from a retrieval payload. ## Security, privacy, scientific-integrity, and governance impact - No credential headers cross the consumer boundary. -- Export retrieval remains loopback-only, size-bounded, naruon-scoped, and +- Export retrieval remains loopback-only, unguessable-capability protected, + size-bounded, naruon-scoped, and content-redacting. - HTTP `200` on a retrieval payload is not measurement evidence and is not release evidence. diff --git a/docs/connectors/naruon-artifact-consumer.md b/docs/connectors/naruon-artifact-consumer.md index 097e0b598..f9f356c6d 100644 --- a/docs/connectors/naruon-artifact-consumer.md +++ b/docs/connectors/naruon-artifact-consumer.md @@ -49,6 +49,7 @@ When naruon requests an export, TEPP evaluates `AnalyticalPurpose::ModularServic - redefinition of reserved headers (`content-type`, `tepp-consumer`, `tepp-contract-version`, `idempotency-key`) via extra headers → reject; - export interchange without a nonempty per-export idempotency key → reject; +- export retrieval without the server-minted UUID v7 bearer capability → reject; - lexical method codes (`tfidf`, `bm25`, `keyword`) claiming TEPP inference → reject. ## Authority sources diff --git a/schemas/export_retrieval_v1.json b/schemas/export_retrieval_v1.json index 2fe9b815e..9364305df 100644 --- a/schemas/export_retrieval_v1.json +++ b/schemas/export_retrieval_v1.json @@ -16,7 +16,7 @@ "contract_version": { "type": "integer", "const": 1 }, "export_id": { "type": "string", "minLength": 1, "maxLength": 128, "pattern": ".*\\S.*" }, "artifact_id": { "type": "string", "minLength": 1, "maxLength": 128, "pattern": ".*\\S.*" }, - "decision_code": { "type": "string", "minLength": 1, "pattern": ".*\\S.*" }, + "decision_code": { "type": "string", "const": "purpose_bound_export_allowed" }, "purpose": { "type": "string", "enum": [ From 1e468f62ec47f3476a7b4d18ed2980451dc425cf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 31 Aug 2026 19:59:16 +0000 Subject: [PATCH 4/4] feat(api): retrieve authorized exports via loopback CLI GAP-003A unique slice: tepp-export-get mints typed naruon GET /v1/exports/{export_id} onto spawned tepp-loopback TCP. LineageWeave is refused. NaruonLiveService stays POST-only. Persistence remains GAP-003B. ADR 0055 on the #411 lineage. --- CHANGELOG.d/export-retrieval-cli.md | 3 + crates/tepp_api/Cargo.toml | 6 + crates/tepp_api/src/bin/tepp_export_get.rs | 30 ++ crates/tepp_api/src/export_retrieval_cli.rs | 432 ++++++++++++++++++ crates/tepp_api/src/lib.rs | 21 +- .../tests/export_retrieval_cli_contract.rs | 371 +++++++++++++++ docs/API_CONTRACT.md | 4 +- docs/TRACEABILITY.md | 1 + docs/adr/0055-export-retrieval-cli.md | 114 +++++ docs/adr/README.md | 2 + docs/connectors/naruon-artifact-consumer.md | 1 + docs/research/export-retrieval-cli.md | 54 +++ docs/research/export-retrieval-http.md | 5 +- docs/research/naruon-http-interchange.md | 3 +- 14 files changed, 1041 insertions(+), 6 deletions(-) create mode 100644 CHANGELOG.d/export-retrieval-cli.md create mode 100644 crates/tepp_api/src/bin/tepp_export_get.rs create mode 100644 crates/tepp_api/src/export_retrieval_cli.rs create mode 100644 crates/tepp_api/tests/export_retrieval_cli_contract.rs create mode 100644 docs/adr/0055-export-retrieval-cli.md create mode 100644 docs/research/export-retrieval-cli.md diff --git a/CHANGELOG.d/export-retrieval-cli.md b/CHANGELOG.d/export-retrieval-cli.md new file mode 100644 index 000000000..a808cf9f0 --- /dev/null +++ b/CHANGELOG.d/export-retrieval-cli.md @@ -0,0 +1,3 @@ +### Added + +- `tepp_api` GAP-003A export-retrieval CLI slice (ADR 0055, active-PR, not implemented-main): naruon mints credential-free `GET /v1/exports/{export_id}` through the published `tepp-export-get` CLI onto spawned `tepp-loopback` TCP. Retrieval stays metric-free. LineageWeave is refused. `NaruonLiveService` stays POST-only. Persistence remains GAP-003B. diff --git a/crates/tepp_api/Cargo.toml b/crates/tepp_api/Cargo.toml index 47ad7c433..39a5ee29c 100644 --- a/crates/tepp_api/Cargo.toml +++ b/crates/tepp_api/Cargo.toml @@ -29,5 +29,11 @@ path = "src/bin/tepp_loopback.rs" test = false bench = false +[[bin]] +name = "tepp-export-get" +path = "src/bin/tepp_export_get.rs" +test = false +bench = false + [lints] workspace = true diff --git a/crates/tepp_api/src/bin/tepp_export_get.rs b/crates/tepp_api/src/bin/tepp_export_get.rs new file mode 100644 index 000000000..b39b32d3c --- /dev/null +++ b/crates/tepp_api/src/bin/tepp_export_get.rs @@ -0,0 +1,30 @@ +//! Operator CLI for loopback purpose-bound export retrieval GET. + +use std::io::{self, IsTerminal}; +use std::process::ExitCode; + +use tepp_api::{ + ApiError, ExportRetrievalCliInvocation, execute_export_retrieval_cli, + read_export_retrieval_cli_stdin, render_export_retrieval_cli_stdout, +}; + +fn main() -> ExitCode { + match run() { + Ok(()) => ExitCode::SUCCESS, + Err(_) => ExitCode::FAILURE, + } +} + +fn run() -> Result<(), ApiError> { + let args: Vec = std::env::args().skip(1).collect(); + let body = read_export_retrieval_cli_stdin(io::stdin().is_terminal(), io::stdin())?; + let invocation = ExportRetrievalCliInvocation::from_args(&args, body)?; + let response = execute_export_retrieval_cli(&invocation)?; + let stdout = render_export_retrieval_cli_stdout(&invocation, &response)?; + println!("{stdout}"); + if (200..300).contains(&response.status_code) { + Ok(()) + } else { + Err(ApiError::InvalidWirePayload) + } +} diff --git a/crates/tepp_api/src/export_retrieval_cli.rs b/crates/tepp_api/src/export_retrieval_cli.rs new file mode 100644 index 000000000..9ee95f253 --- /dev/null +++ b/crates/tepp_api/src/export_retrieval_cli.rs @@ -0,0 +1,432 @@ +//! Operator loopback CLI for purpose-bound export retrieval GET. +//! +//! GAP-003A unique slice: operators run `tepp-export-get get` to mint a typed +//! naruon export-retrieval GET onto spawned `tepp-loopback` TCP. Stdout is a +//! metric-free `200 OK` identity (`export_id`, `artifact_id`, `decision_code`, +//! `purpose`, `idempotency_key`). `tepp.scientific_acceptance.v1` never +//! appears. `LineageWeave` is refused. `NaruonLiveService` stays POST-only. +//! Persistence remains GAP-003B. This module does not duplicate POST +//! authorize CLI (`tepp-exports`), GET-by-id, wait CLI, Leiden, or GAP-010 +//! Figma/export. + +use std::fmt::Write as _; +use std::io::{Read, Write}; +use std::net::{SocketAddr, TcpStream}; + +use crate::live_http::map_io_error; +use crate::naruon_http::{NARUON_EXPORT_PATH, header_is_credential}; +use crate::wire::require_nonempty; +use crate::{ + AnalysisRunLiveService, ApiError, EXPORT_RETRIEVAL_ID_MAX_LEN, ExportRetrieval, + NARUON_CONSUMER_CODE, NARUON_LIVE_IO_TIMEOUT, NaruonHttpExchange, NaruonLiveResponse, + naruon_export_retrieval_exchange, refuse_metrics_on_export_retrieval_payload, +}; + +const SCIENTIFIC_ACCEPTANCE_SCHEMA: &str = "tepp.scientific_acceptance.v1"; + +/// Supported operator verbs for the loopback export-retrieval CLI. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ExportRetrievalCliVerb { + /// `GET /v1/exports/{export_id}`. + Get, +} + +impl ExportRetrievalCliVerb { + /// Parse one exact lowercase verb token. + /// + /// # Errors + /// + /// Returns [`ApiError::InvalidWirePayload`] for an unknown token. + pub fn parse(token: &str) -> Result { + match token { + "get" => Ok(Self::Get), + _ => Err(ApiError::InvalidWirePayload), + } + } + + /// Return the canonical lowercase verb token. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Get => "get", + } + } +} + +/// One operator CLI invocation against a loopback export-retrieval GET listener. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ExportRetrievalCliInvocation { + /// CLI verb to execute. + pub verb: ExportRetrievalCliVerb, + /// Loopback `host:port` of `tepp-loopback`. + pub host: String, + /// Published HTTPS origin used to mint the typed export-retrieval exchange. + pub origin: String, + /// Published modular consumer. Export retrieval is naruon-only. + pub consumer: String, + /// Opaque server-assigned export identity to retrieve. + pub export_id: String, + /// JSON body. Export-retrieval GET requires empty. + pub body: String, +} + +impl ExportRetrievalCliInvocation { + /// Parse argv plus stdin body into a validated loopback retrieval invocation. + /// + /// # Errors + /// + /// Returns a fail-closed error for unknown verbs, missing required flags, a + /// non-loopback host, a non-`https` origin, a non-naruon consumer, + /// credential-shaped flags, hostile identities, or a nonempty body. + pub fn from_args(args: I, body: impl Into) -> Result + where + I: IntoIterator, + S: AsRef, + { + let tokens: Vec = args + .into_iter() + .map(|token| token.as_ref().to_owned()) + .collect(); + let (verb_token, rest) = tokens.split_first().ok_or(ApiError::InvalidWirePayload)?; + let verb = ExportRetrievalCliVerb::parse(verb_token)?; + let flags = parse_flags(rest)?; + assemble_invocation(verb, flags, body.into()) + } + + /// Reject a non-loopback host, unpublished consumer, or hostile GET body. + /// + /// # Errors + /// + /// Returns [`ApiError::AuthorizationDenied`] for a non-loopback host and + /// [`ApiError::InvalidWirePayload`] or [`ApiError::LimitExceeded`] for + /// empty, unpublished, oversized, nonempty-body, or metric-bearing fields. + pub fn validate(&self) -> Result<(), ApiError> { + require_loopback_host(&self.host)?; + require_nonempty(&self.origin)?; + if !self.origin.starts_with("https://") { + return Err(ApiError::InvalidWirePayload); + } + require_nonempty(&self.consumer)?; + if self.consumer != NARUON_CONSUMER_CODE { + return Err(ApiError::InvalidWirePayload); + } + require_nonempty(&self.export_id)?; + if self.export_id.len() > EXPORT_RETRIEVAL_ID_MAX_LEN { + return Err(ApiError::LimitExceeded); + } + if !self.body.is_empty() { + return Err(ApiError::InvalidWirePayload); + } + refuse_scientific_acceptance_schema(&self.body)?; + refuse_metrics_on_export_retrieval_payload(&self.body)?; + Ok(()) + } +} + +struct ParsedFlags { + host: Option, + origin: Option, + consumer: Option, + export_id: Option, +} + +fn parse_flags(rest: &[String]) -> Result { + let mut flags = ParsedFlags { + host: None, + origin: None, + consumer: None, + export_id: None, + }; + let mut index = 0; + while index < rest.len() { + let flag = rest[index].as_str(); + if !flag.starts_with("--") { + return Err(ApiError::InvalidWirePayload); + } + let name = &flag[2..]; + if header_is_credential(name) { + return Err(ApiError::AuthorizationDenied); + } + let slot = match name { + "host" => &mut flags.host, + "origin" => &mut flags.origin, + "consumer" => &mut flags.consumer, + "export-id" => &mut flags.export_id, + _ => return Err(ApiError::InvalidWirePayload), + }; + if slot.is_some() || index + 1 >= rest.len() { + return Err(ApiError::InvalidWirePayload); + } + let value = rest[index + 1].as_str(); + require_nonempty(value)?; + *slot = Some(value.to_owned()); + index += 2; + } + Ok(flags) +} + +fn assemble_invocation( + verb: ExportRetrievalCliVerb, + flags: ParsedFlags, + body: String, +) -> Result { + let invocation = ExportRetrievalCliInvocation { + verb, + host: flags.host.ok_or(ApiError::InvalidWirePayload)?, + origin: flags.origin.ok_or(ApiError::InvalidWirePayload)?, + consumer: flags + .consumer + .unwrap_or_else(|| NARUON_CONSUMER_CODE.to_owned()), + export_id: flags.export_id.ok_or(ApiError::InvalidWirePayload)?, + body, + }; + invocation.validate()?; + Ok(invocation) +} + +fn require_loopback_host(host: &str) -> Result { + let addr: SocketAddr = host.parse().map_err(|_| ApiError::InvalidWirePayload)?; + if addr.ip().is_loopback() { + Ok(addr) + } else { + Err(ApiError::AuthorizationDenied) + } +} + +fn export_retrieval_exchange( + invocation: &ExportRetrievalCliInvocation, +) -> Result { + if invocation.consumer == NARUON_CONSUMER_CODE { + naruon_export_retrieval_exchange(&invocation.origin, &invocation.export_id) + } else { + Err(ApiError::InvalidWirePayload) + } +} + +/// Render a typed export-retrieval exchange as HTTP/1.1 for a bound loopback listener. +/// +/// The exchange keeps its HTTPS origin contract. Only the HTTP/1.1 `Host` is +/// the loopback bind address. Public bind hosts fail closed. +/// +/// # Errors +/// +/// Returns [`ApiError::AuthorizationDenied`] for a non-loopback host or a +/// credential-bearing header, and [`ApiError::InvalidWirePayload`] when the +/// exchange is not a GET `/v1/exports/{export_id}`. +pub fn loopback_http1_from_export_retrieval_exchange( + exchange: &NaruonHttpExchange, + loopback_host: &str, +) -> Result { + let _addr = require_loopback_host(loopback_host)?; + let host = loopback_host.trim(); + if exchange.method != "GET" { + return Err(ApiError::InvalidWirePayload); + } + let rest = exchange + .target_url + .strip_prefix("https://") + .ok_or(ApiError::InvalidWirePayload)?; + let path = rest + .find('/') + .map(|index| &rest[index..]) + .ok_or(ApiError::InvalidWirePayload)?; + let remainder = path + .strip_prefix(NARUON_EXPORT_PATH) + .ok_or(ApiError::InvalidWirePayload)?; + let encoded = remainder + .strip_prefix('/') + .ok_or(ApiError::InvalidWirePayload)?; + if encoded.is_empty() || encoded.contains('/') { + return Err(ApiError::InvalidWirePayload); + } + for (name, _) in &exchange.headers { + if header_is_credential(name) { + return Err(ApiError::AuthorizationDenied); + } + } + let mut request = String::new(); + write!( + request, + "{} {path} HTTP/1.1\r\nHost: {host}\r\n", + exchange.method + ) + .map_err(|_| ApiError::InvalidWirePayload)?; + for (name, value) in &exchange.headers { + if name.eq_ignore_ascii_case("host") || name.eq_ignore_ascii_case("content-length") { + continue; + } + write!(request, "{name}: {value}\r\n").map_err(|_| ApiError::InvalidWirePayload)?; + } + write!( + request, + "content-length: {}\r\n\r\n{}", + exchange.body.len(), + exchange.body + ) + .map_err(|_| ApiError::InvalidWirePayload)?; + Ok(request) +} + +/// Compose one HTTP/1.1 export-retrieval GET from the typed naruon exchange. +/// +/// # Errors +/// +/// Returns the same fail-closed errors as +/// [`ExportRetrievalCliInvocation::validate`]. +pub fn compose_export_retrieval_cli_http( + invocation: &ExportRetrievalCliInvocation, +) -> Result { + invocation.validate()?; + let exchange = export_retrieval_exchange(invocation)?; + loopback_http1_from_export_retrieval_exchange(&exchange, &invocation.host) +} + +/// Dispatch one export-retrieval CLI invocation against an in-process loopback service. +/// +/// # Errors +/// +/// Returns fail-closed validation errors before the HTTP handler runs. +pub fn dispatch_export_retrieval_cli( + service: &mut AnalysisRunLiveService, + invocation: &ExportRetrievalCliInvocation, +) -> Result { + let request = compose_export_retrieval_cli_http(invocation)?; + Ok(service.handle_http_request(&request)) +} + +/// Execute one export-retrieval CLI invocation over loopback TCP against `tepp-loopback`. +/// +/// # Errors +/// +/// Returns fail-closed validation, transport, or response-framing errors. +pub fn execute_export_retrieval_cli( + invocation: &ExportRetrievalCliInvocation, +) -> Result { + let addr = require_loopback_host(&invocation.host)?; + let request = compose_export_retrieval_cli_http(invocation)?; + let mut stream = TcpStream::connect(addr).map_err(|error| map_io_error(&error))?; + stream + .set_read_timeout(Some(NARUON_LIVE_IO_TIMEOUT)) + .map_err(|error| map_io_error(&error))?; + stream + .set_write_timeout(Some(NARUON_LIVE_IO_TIMEOUT)) + .map_err(|error| map_io_error(&error))?; + stream + .write_all(request.as_bytes()) + .map_err(|error| map_io_error(&error))?; + stream.flush().map_err(|error| map_io_error(&error))?; + let mut bytes = Vec::new(); + stream + .read_to_end(&mut bytes) + .map_err(|error| map_io_error(&error))?; + parse_http_response(&bytes) +} + +/// Filter CLI stdout so export retrieval never prints scientific acceptance. +/// +/// # Errors +/// +/// Returns [`ApiError::InvalidWirePayload`] when a receipt carries metric keys, +/// `tepp.scientific_acceptance.v1`, or a success body that is not a metric-free +/// export-retrieval identity for the requested `export_id`. +pub fn render_export_retrieval_cli_stdout( + invocation: &ExportRetrievalCliInvocation, + response: &NaruonLiveResponse, +) -> Result { + invocation.validate()?; + if response.body.is_empty() { + return Err(ApiError::InvalidWirePayload); + } + refuse_scientific_acceptance_schema(&response.body)?; + refuse_metrics_on_export_retrieval_payload(&response.body)?; + if !(200..300).contains(&response.status_code) { + return Ok(response.body.clone()); + } + if response.status_code != 200 { + return Err(ApiError::InvalidWirePayload); + } + let retrieved = ExportRetrieval::from_json(&response.body)?; + if retrieved.export_id != invocation.export_id { + return Err(ApiError::InvalidWirePayload); + } + retrieved.to_json() +} + +fn refuse_scientific_acceptance_schema(body: &str) -> Result<(), ApiError> { + if body.contains(SCIENTIFIC_ACCEPTANCE_SCHEMA) { + Err(ApiError::InvalidWirePayload) + } else { + Ok(()) + } +} + +fn parse_http_response(bytes: &[u8]) -> Result { + let text = std::str::from_utf8(bytes).map_err(|_| ApiError::InvalidWirePayload)?; + let (header_block, body) = text + .split_once("\r\n\r\n") + .ok_or(ApiError::InvalidWirePayload)?; + let mut lines = header_block.split("\r\n"); + let status_line = lines.next().ok_or(ApiError::InvalidWirePayload)?; + let mut parts = status_line.split(' '); + if parts.next() != Some("HTTP/1.1") { + return Err(ApiError::InvalidWirePayload); + } + let code = parts + .next() + .ok_or(ApiError::InvalidWirePayload)? + .parse::() + .map_err(|_| ApiError::InvalidWirePayload)?; + let reason_phrase = match code { + 200 => "OK", + 202 => "Accepted", + 400 => "Bad Request", + 403 => "Forbidden", + 413 => "Payload Too Large", + 422 => "Unprocessable Entity", + _ => return Err(ApiError::InvalidWirePayload), + }; + let mut content_length = None; + for line in lines { + let (name, value) = line.split_once(':').ok_or(ApiError::InvalidWirePayload)?; + if name.eq_ignore_ascii_case("content-length") { + if content_length.is_some() { + return Err(ApiError::InvalidWirePayload); + } + content_length = Some( + value + .trim() + .parse::() + .map_err(|_| ApiError::InvalidWirePayload)?, + ); + } + } + let declared = content_length.ok_or(ApiError::InvalidWirePayload)?; + if declared != body.len() { + return Err(ApiError::InvalidWirePayload); + } + Ok(NaruonLiveResponse { + status_code: code, + reason_phrase, + body: body.to_owned(), + }) +} + +/// Read stdin leftover bytes on a non-terminal; export-retrieval GET requires empty. +/// +/// # Errors +/// +/// Returns [`ApiError::InvalidWirePayload`] when stdin cannot be read. +pub fn read_export_retrieval_cli_stdin( + stdin_is_terminal: bool, + mut stdin: impl Read, +) -> Result { + if stdin_is_terminal { + Ok(String::new()) + } else { + let mut body = String::new(); + stdin + .read_to_string(&mut body) + .map_err(|_| ApiError::InvalidWirePayload)?; + Ok(body) + } +} diff --git a/crates/tepp_api/src/lib.rs b/crates/tepp_api/src/lib.rs index bd8a933e0..8da9cccc8 100644 --- a/crates/tepp_api/src/lib.rs +++ b/crates/tepp_api/src/lib.rs @@ -9,7 +9,9 @@ //! may also request a cutoff-safe project-history projection from explicit //! source evidence. Naruon owns the current purpose-bound export adapter. //! Loopback listeners prove the HTTP boundary without claiming production TLS, -//! causality, or completed psychometric model results. +//! causality, or completed psychometric model results. The published +//! `tepp-export-get` CLI mints typed naruon export-retrieval GET exchanges +//! onto spawned `tepp-loopback` TCP. `LineageWeave` is refused on this adapter. mod analysis_result; mod analysis_run; @@ -21,6 +23,7 @@ mod envelope; mod error; mod export; mod export_http; +mod export_retrieval_cli; mod lineage_criterion_anchor; mod lineage_pair_criterion; mod lineageweave_http; @@ -106,6 +109,22 @@ pub use export_http::ExportRetrieval; pub use export_http::naruon_export_retrieval_exchange; /// Refuse scientific-metric keys on export-retrieval JSON. pub use export_http::refuse_metrics_on_export_retrieval_payload; +/// Loopback export-retrieval CLI invocation. +pub use export_retrieval_cli::ExportRetrievalCliInvocation; +/// Loopback export-retrieval CLI verb. +pub use export_retrieval_cli::ExportRetrievalCliVerb; +/// Compose HTTP/1.1 export-retrieval GET from a CLI invocation. +pub use export_retrieval_cli::compose_export_retrieval_cli_http; +/// Dispatch an export-retrieval CLI invocation against an in-process listener. +pub use export_retrieval_cli::dispatch_export_retrieval_cli; +/// Execute an export-retrieval CLI invocation over loopback TCP. +pub use export_retrieval_cli::execute_export_retrieval_cli; +/// Render a typed export-retrieval exchange onto a loopback HTTP/1.1 request. +pub use export_retrieval_cli::loopback_http1_from_export_retrieval_exchange; +/// Read export-retrieval CLI stdin; GET requires empty. +pub use export_retrieval_cli::read_export_retrieval_cli_stdin; +/// Filter export-retrieval CLI stdout so identity stays metric-free. +pub use export_retrieval_cli::render_export_retrieval_cli_stdout; /// Analytical export purpose. pub use authorization::AnalyticalPurpose; diff --git a/crates/tepp_api/tests/export_retrieval_cli_contract.rs b/crates/tepp_api/tests/export_retrieval_cli_contract.rs new file mode 100644 index 000000000..5cad968d4 --- /dev/null +++ b/crates/tepp_api/tests/export_retrieval_cli_contract.rs @@ -0,0 +1,371 @@ +//! GAP-003A naruon export-retrieval CLI. + +use tepp_api::{ + AnalysisRunLiveService, AnalyticalPurpose, ApiError, EXPORT_RETRIEVAL_ID_MAX_LEN, + ExportAuthorizationRequest, ExportRetrieval, ExportRetrievalCliInvocation, + ExportRetrievalCliVerb, LINEAGEWEAVE_CONSUMER_CODE, NARUON_CONSUMER_CODE, NARUON_EXPORT_PATH, + NaruonHttpExchange, NaruonLiveResponse, NaruonLiveService, compose_export_retrieval_cli_http, + dispatch_export_retrieval_cli, execute_export_retrieval_cli, + loopback_http1_from_export_retrieval_exchange, naruon_export_retrieval_exchange, + read_export_retrieval_cli_stdin, render_export_retrieval_cli_stdout, +}; + +const ORIGIN: &str = "https://tepp.example.test"; +const SCHEMA: &str = "tepp.scientific_acceptance.v1"; + +fn authorize_body() -> String { + serde_json::to_string(&ExportAuthorizationRequest { + tenant_workspace_id: "cli-export-tenant".into(), + principal_id: "principal-analyst-cli".into(), + purpose: AnalyticalPurpose::ModularServiceConsumer, + artifact_id: "artifact-cli-1".into(), + includes_source_text: false, + }) + .expect("json") +} + +fn export_post_http(body: &str, idempotency_key: &str) -> String { + format!( + "POST {NARUON_EXPORT_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\nidempotency-key: {idempotency_key}\r\ncontent-length: {}\r\n\r\n{body}", + body.len() + ) +} + +fn mint_export(service: &mut AnalysisRunLiveService, idempotency_key: &str) -> ExportRetrieval { + let posted = service.handle_http_request(&export_post_http(&authorize_body(), idempotency_key)); + assert_eq!(posted.status_code, 200, "{}", posted.body); + ExportRetrieval::from_json(&posted.body).expect("minted") +} + +fn get_args<'a>(host: &'a str, export_id: &'a str, consumer: &'a str) -> [&'a str; 9] { + [ + "get", + "--host", + host, + "--origin", + ORIGIN, + "--consumer", + consumer, + "--export-id", + export_id, + ] +} + +#[test] +fn verbs_and_from_args_fail_closed() { + assert_eq!( + ExportRetrievalCliVerb::parse("get").expect("get"), + ExportRetrievalCliVerb::Get + ); + assert_eq!(ExportRetrievalCliVerb::Get.as_str(), "get"); + assert_eq!( + ExportRetrievalCliVerb::parse("authorize"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + ExportRetrievalCliVerb::parse("GET"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + ExportRetrievalCliInvocation::from_args(Vec::::new(), "").unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + ExportRetrievalCliInvocation::from_args( + get_args("8.8.8.8:80", "export-1", NARUON_CONSUMER_CODE), + "", + ) + .unwrap_err(), + ApiError::AuthorizationDenied + ); + assert_eq!( + ExportRetrievalCliInvocation::from_args( + [ + "get", + "--host", + "127.0.0.1:18081", + "--origin", + "http://tepp.example.test", + "--export-id", + "export-1", + ], + "", + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + ExportRetrievalCliInvocation::from_args( + [ + "get", + "--host", + "localhost:18081", + "--origin", + ORIGIN, + "--export-id", + "export-1", + ], + "", + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + ExportRetrievalCliInvocation::from_args( + [ + "get", + "--host", + "127.0.0.1:18081", + "--origin", + ORIGIN, + "--authorization", + "secret", + "--export-id", + "export-1", + ], + "", + ) + .unwrap_err(), + ApiError::AuthorizationDenied + ); +} + +#[test] +fn from_args_refuses_body_consumer_size_and_unknown_flags() { + assert_eq!( + ExportRetrievalCliInvocation::from_args( + get_args("127.0.0.1:18081", "export-1", NARUON_CONSUMER_CODE), + "{}", + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + ExportRetrievalCliInvocation::from_args( + get_args("127.0.0.1:18081", "export-1", LINEAGEWEAVE_CONSUMER_CODE), + "", + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + ExportRetrievalCliInvocation::from_args( + get_args("127.0.0.1:18081", "export-1", "unpublished"), + "", + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + ExportRetrievalCliInvocation::from_args( + get_args( + "127.0.0.1:18081", + &"e".repeat(EXPORT_RETRIEVAL_ID_MAX_LEN + 1), + NARUON_CONSUMER_CODE, + ), + "", + ) + .unwrap_err(), + ApiError::LimitExceeded + ); + assert_eq!( + ExportRetrievalCliInvocation::from_args( + [ + "get", + "--host", + "127.0.0.1:18081", + "--origin", + ORIGIN, + "--idempotency-key", + "export-idem-1", + "--export-id", + "export-1", + ], + "", + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); +} + +#[test] +fn compose_is_typed_https_get_without_credentials() { + let invocation = ExportRetrievalCliInvocation::from_args( + get_args("127.0.0.1:18081", "export-1", NARUON_CONSUMER_CODE), + "", + ) + .expect("invocation"); + let http = compose_export_retrieval_cli_http(&invocation).expect("http"); + assert!(http.starts_with("GET /v1/exports/export-1 HTTP/1.1")); + assert!(http.contains("tepp-consumer: naruon")); + assert!(http.contains("content-length: 0")); + assert!(!http.to_ascii_lowercase().contains("authorization")); + assert!(!http.contains("idempotency-key")); + assert!(!http.contains("rmse")); + assert!(!http.contains(SCHEMA)); + assert!(!http.contains("/analysis-runs")); +} + +#[test] +fn naruon_cli_retrieves_minted_export_and_naruon_live_stays_post_only() { + let mut service = AnalysisRunLiveService::new(); + let minted = mint_export(&mut service, "cli-export-idem-1"); + let invocation = ExportRetrievalCliInvocation::from_args( + get_args( + "127.0.0.1:18081", + minted.export_id.as_str(), + NARUON_CONSUMER_CODE, + ), + "", + ) + .expect("naruon"); + let got = dispatch_export_retrieval_cli(&mut service, &invocation).expect("get"); + assert_eq!(got.status_code, 200, "{}", got.body); + let stdout = render_export_retrieval_cli_stdout(&invocation, &got).expect("out"); + let payload = ExportRetrieval::from_json(&stdout).expect("retrieval"); + assert_eq!(payload, minted); + assert_eq!(payload.artifact_id, "artifact-cli-1"); + assert_eq!(payload.decision_code, "purpose_bound_export_allowed"); + assert!(!stdout.contains("rmse")); + assert!(!stdout.contains(SCHEMA)); + assert!(!stdout.contains("tenant_workspace_id")); + assert!(!stdout.contains("principal_id")); + assert!(!stdout.contains("includes_source_text")); + assert!(!stdout.contains("source_text")); + + let missing = ExportRetrievalCliInvocation::from_args( + get_args("127.0.0.1:18081", "missing-export", NARUON_CONSUMER_CODE), + "", + ) + .expect("missing"); + let denied = dispatch_export_retrieval_cli(&mut service, &missing).expect("denied"); + assert_eq!(denied.status_code, 400, "{}", denied.body); + let denied_stdout = render_export_retrieval_cli_stdout(&missing, &denied).expect("err"); + assert!(denied_stdout.contains("invalid_wire_payload")); + assert!(!denied_stdout.contains(SCHEMA)); + + let mut naruon = NaruonLiveService::new(); + let composed = compose_export_retrieval_cli_http(&invocation).expect("composed"); + assert_eq!(naruon.handle_http_request(&composed).status_code, 400); +} + +#[test] +fn render_refuses_metrics_schema_and_identity_mismatch() { + let invocation = ExportRetrievalCliInvocation::from_args( + get_args("127.0.0.1:18081", "export-2", NARUON_CONSUMER_CODE), + "", + ) + .expect("invocation"); + assert_eq!( + render_export_retrieval_cli_stdout( + &invocation, + &NaruonLiveResponse { + status_code: 200, + reason_phrase: "OK", + body: String::new(), + } + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + render_export_retrieval_cli_stdout( + &invocation, + &NaruonLiveResponse { + status_code: 200, + reason_phrase: "OK", + body: r#"{"contract_version":1,"export_id":"export-2","artifact_id":"a","decision_code":"purpose_bound_export_allowed","purpose":"modular_service_consumer","idempotency_key":"k","rmse":1.0}"#.into(), + } + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + render_export_retrieval_cli_stdout( + &invocation, + &NaruonLiveResponse { + status_code: 200, + reason_phrase: "OK", + body: format!(r#"{{"schema_version":"{SCHEMA}"}}"#), + } + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + render_export_retrieval_cli_stdout( + &invocation, + &NaruonLiveResponse { + status_code: 200, + reason_phrase: "OK", + body: r#"{"contract_version":1,"export_id":"export-1","artifact_id":"a","decision_code":"purpose_bound_export_allowed","purpose":"modular_service_consumer","idempotency_key":"k"}"#.into(), + } + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); +} + +#[test] +fn loopback_http1_refuses_non_get_and_collection_paths() { + let host = "127.0.0.1:18081"; + let exchange = naruon_export_retrieval_exchange(ORIGIN, "export-1").expect("ex"); + let ok = loopback_http1_from_export_retrieval_exchange(&exchange, host).expect("ok"); + assert!(ok.starts_with("GET /v1/exports/export-1 HTTP/1.1")); + let mut posted = exchange.clone(); + posted.method = "POST"; + assert_eq!( + loopback_http1_from_export_retrieval_exchange(&posted, host).unwrap_err(), + ApiError::InvalidWirePayload + ); + let mut collection = exchange.clone(); + collection.target_url = format!("{ORIGIN}{NARUON_EXPORT_PATH}"); + assert_eq!( + loopback_http1_from_export_retrieval_exchange(&collection, host).unwrap_err(), + ApiError::InvalidWirePayload + ); + let mut extra = exchange; + extra.target_url = format!("{ORIGIN}{NARUON_EXPORT_PATH}/export-1/extra"); + assert_eq!( + loopback_http1_from_export_retrieval_exchange(&extra, host).unwrap_err(), + ApiError::InvalidWirePayload + ); + let credentialed = NaruonHttpExchange { + method: "GET", + target_url: format!("{ORIGIN}{NARUON_EXPORT_PATH}/export-1"), + headers: vec![("authorization".into(), "secret".into())], + body: String::new(), + }; + assert_eq!( + loopback_http1_from_export_retrieval_exchange(&credentialed, host).unwrap_err(), + ApiError::AuthorizationDenied + ); +} + +#[test] +fn execute_over_tcp_and_stdin_reader() { + let mut service = AnalysisRunLiveService::bind_loopback().expect("bind"); + let addr = service.local_addr().expect("addr").to_string(); + let minted = mint_export(&mut service, "cli-export-tcp"); + let handle = std::thread::spawn(move || { + drop(service.serve_one()); + }); + let invocation = ExportRetrievalCliInvocation::from_args( + get_args( + addr.as_str(), + minted.export_id.as_str(), + NARUON_CONSUMER_CODE, + ), + "", + ) + .expect("tcp"); + let response = execute_export_retrieval_cli(&invocation).expect("execute"); + assert_eq!(response.status_code, 200, "{}", response.body); + let stdout = render_export_retrieval_cli_stdout(&invocation, &response).expect("stdout"); + assert_eq!(ExportRetrieval::from_json(&stdout).expect("parsed"), minted); + handle.join().expect("join"); + let empty = read_export_retrieval_cli_stdin(true, std::io::empty()).expect("tty"); + assert!(empty.is_empty()); + let piped = read_export_retrieval_cli_stdin(false, std::io::Cursor::new(b"")).expect("pipe"); + assert!(piped.is_empty()); +} diff --git a/docs/API_CONTRACT.md b/docs/API_CONTRACT.md index 1142e99fe..2b0d34a8d 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. `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. Loopback `GET /v1/exports/{export_id}` on `AnalysisRunLiveService` is the executable export-retrieval route (ADR 0054); `NaruonLiveService` stays POST-only. +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. `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. Loopback `GET /v1/exports/{export_id}` on `AnalysisRunLiveService` is the executable export-retrieval route (ADR 0054); `tepp-export-get get` is the published CLI that mints `naruon_export_retrieval_exchange` onto spawned `tepp-loopback` TCP (ADR 0055). `NaruonLiveService` stays POST-only. LineageWeave is refused on this naruon-owned adapter. ## 2. Contract families @@ -196,7 +196,7 @@ TEPP owns its application/API state, authorized evidence, model runs, and artifa ### naruon -`naruon` may submit evidence/analysis requests or consume versioned topic/event/psychometric artifacts. It must not treat lexical heuristics as TEPP topic inference and must not read TEPP database tables directly. HTTP interchange is `tepp_api::naruon_analysis_run_exchange` / `naruon_export_exchange` (`POST /v1/analysis-runs` and `/v1/exports` over `https` only). Detailed modular surfaces and failure modes are recorded in [`docs/connectors/naruon-artifact-consumer.md`](connectors/naruon-artifact-consumer.md). +`naruon` may submit evidence/analysis requests or consume versioned topic/event/psychometric artifacts. It must not treat lexical heuristics as TEPP topic inference and must not read TEPP database tables directly. HTTP interchange is `tepp_api::naruon_analysis_run_exchange` / `naruon_export_exchange` (`POST /v1/analysis-runs` and `/v1/exports` over `https` only). Export retrieval is `tepp_api::naruon_export_retrieval_exchange` (`GET /v1/exports/{export_id}` over `https` only) rendered by `tepp-export-get`. Detailed modular surfaces and failure modes are recorded in [`docs/connectors/naruon-artifact-consumer.md`](connectors/naruon-artifact-consumer.md). ### Provider payload minimization diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 20d4b7f01..244399862 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/0054 | `tepp_api` analysis-run/export/JSON-LD/GraphML contracts on protected main (PR #21); loopback `GET /v1/exports/{export_id}` is the executable retrieval route on this PR; request-bound terminal result active in PR #157; production TLS remaining | partial | +| loopback export retrieval CLI | ADR 0055; ADR 0054; API contract; RFC 9110 | `tepp_api` `tepp-export-get` CLI (this PR): typed naruon export-retrieval GET exchanges render onto spawned `tepp-loopback` TCP; public bind/`localhost`/non-`https`/LineageWeave fail closed; identity stays metric-free; not implemented-main; Postgres persistence remains GAP-003B | 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/0055-export-retrieval-cli.md b/docs/adr/0055-export-retrieval-cli.md new file mode 100644 index 000000000..62049d61e --- /dev/null +++ b/docs/adr/0055-export-retrieval-cli.md @@ -0,0 +1,114 @@ +# ADR 0055 — Loopback export retrieval CLI + +**Decision status:** Accepted +**Implementation maturity:** active-PR +**Date:** 2026-08-31 +**Supersedes:** None; complements ADR 0054 (export retrieval GET) for the operator-visible client. Does not reuse ADR 0026 (export-authorize CLI / engine-library stacks), ADR 0053 (Pareto profile), or ADR 0054. Does not supersede ADR 0014 claim-promotion authority. +**Figma File ID:** N/A — this increment changes a Rust service crate and has no user-interface surface. +**Storybook inventory:** N/A — no reusable web object or interaction changed. + +## Context + +ADR 0054 owns `GET /v1/exports/{export_id}` on `AnalysisRunLiveService` and the +typed `naruon_export_retrieval_exchange`. Operators still have to hand-roll +HTTP/1.1 to retrieve a minted export identity on spawned `tepp-loopback`. +`tepp-exports authorize` (#410) POSTs authorization on `NaruonLiveService` and +does not retrieve. Wait CLI (#406), lookup CLI (#401), retry-parent CLI +(#400), and temporal-context CLI (#414) are different verbs or different +stacks. `tepp_api` owns export retrieval; the CLI belongs here. + +## Decision + +Publish `tepp-export-get`: + +- `tepp-export-get get` mints `naruon_export_retrieval_exchange` and renders + through `loopback_http1_from_export_retrieval_exchange`. +- `--origin` stays the published HTTPS origin; only `--host` is the loopback + bind address printed by `tepp-loopback`. +- Empty stdin is required; nonempty GET bodies fail closed. +- Success stdout is a metric-free `200 OK` identity (`export_id`, + `artifact_id`, `decision_code`, `purpose`, `idempotency_key`). +- Public bind hosts, `localhost`, unpublished consumers, LineageWeave, + credential-shaped flags, and non-`https` origins fail closed. +- The GET exchange does not send an `idempotency-key` header. +- Persistence remains GAP-003B. +- This slice does not add GET to `NaruonLiveService`. LineageWeave remains + refused on this naruon-owned adapter. + +## Non-goals + +- Production TLS, public bind, or durable export storage. +- Leiden community detection, Driver p.16 restoration, or Figma/export work. +- Promoting an ADR 0014 scientific claim from HTTP success. +- Export-authorize CLI (`tepp-exports`), export retrieval GET HTTP, GET-by-id, + wait CLI, or adding GET to `NaruonLiveService`. + +## Alternatives considered + +1. **Keep hand-rolled export-retrieval HTTP in each operator script** — + rejected because GAP-003A is operator-visible and authorize already has a + CLI on another stack. +2. **Add `get` to `tepp-exports` on the authorize-CLI stack** — rejected + because that stack does not include retrieval GET (#411) and targets + `NaruonLiveService`. +3. **Open LineageWeave or add GET to `NaruonLiveService`** — rejected; Naruon + owns the current purpose-bound export adapter (ADR 0011/0018/0054). +4. **Metric-free naruon export-retrieval CLI on `tepp-loopback`** — accepted. + +## Consequences + +- Operators can retrieve a minted export identity without embedding the + library or writing HTTP/1.1. +- HTTP 200 on retrieval is not release evidence. + +## Failure and recovery + +Non-loopback hosts, `localhost`, non-`https` origins, unpublished consumers, +LineageWeave, metric keys, empty identities, nonempty GET bodies, and unknown +capabilities return a fail-closed API error. The in-memory registry is not +durable; a restart requires re-POSTing the original metric-free +authorization. + +## Security, privacy, scientific-integrity, and governance impact + +- No credential headers cross the consumer boundary. +- Export retrieval remains loopback-served, size-bounded, naruon-scoped, and + content-redacting. +- Retrieval receipts stay metric-free. Tenant, principal, and source-text + flags stay off stdout. + +## Compatibility and migration + +Export retrieval GET and the naruon GET exchange are unchanged. Production +adapters may replace loopback while preserving metric-free retrieval fields. +`tepp-exports authorize` remains the POST client on `NaruonLiveService`. + +## Verification + +Falsifiable evidence: + +- naruon export-retrieval CLI is HTTPS GET `/v1/exports/{export_id}` without + credentials, RMSE keys, or an `idempotency-key` header; +- LineageWeave, public bind, `localhost`, `http://` origins, and nonempty + bodies fail closed; +- POST mint then typed GET CLI stdout matches the minted `artifact_id` and + never prints `tepp.scientific_acceptance.v1`; +- `NaruonLiveService` still refuses the composed GET; +- Clippy `-D warnings`, `tepp_api` tests, rustdoc, and exact-head review + remain required. + +## Rollback and supersession + +Rollback removes the export-retrieval CLI; export retrieval GET and the +naruon GET exchange remain valid. A superseding ADR is required to persist +retrieval, bind a public address, emit scientific-acceptance or JSON-LD on +retrieval, add GET to `NaruonLiveService`, or treat HTTP success as an +ADR 0014 claim. + +## Related authority + +- ADR 0054 owns export retrieval GET. +- ADR 0009 owns purpose-bound disclosure without blanket masking. +- ADR 0018 owns consumer-scoped ingress and metric-free receipts. +- ADR 0011 owns standalone/modular HTTP boundaries. +- ADR 0014 owns scientific claim promotion. diff --git a/docs/adr/README.md b/docs/adr/README.md index 5e43e54fb..f998317b0 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. | | [0054](0054-export-retrieval-get.md) | Loopback export retrieval GET | Accepted | active-PR | `AnalysisRunLiveService` mints a metric-free `export_id` on naruon `POST /v1/exports` and serves `GET /v1/exports/{export_id}`; `NaruonLiveService` stays POST-only. | +| [0055](0055-export-retrieval-cli.md) | Loopback export retrieval CLI | Accepted | active-PR | `tepp-export-get get` mints typed naruon `GET /v1/exports/{export_id}` onto spawned `tepp-loopback` TCP; LineageWeave is 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. | @@ -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. - **loopback export retrieval identity:** ADR 0054. +- **loopback export retrieval CLI:** ADR 0055. ## Change and supersession rule diff --git a/docs/connectors/naruon-artifact-consumer.md b/docs/connectors/naruon-artifact-consumer.md index f9f356c6d..db089b58d 100644 --- a/docs/connectors/naruon-artifact-consumer.md +++ b/docs/connectors/naruon-artifact-consumer.md @@ -29,6 +29,7 @@ TEPP remains the scientific authority for estimation, recovery metrics, temporal | 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 | | Live loopback export retrieval | `tepp_api` `AnalysisRunLiveService` → `POST /v1/exports` then `GET /v1/exports/{export_id}` | naruon → TEPP | +| Live loopback export retrieval CLI | `tepp_api` `tepp-export-get get` → typed `naruon_export_retrieval_exchange` on `tepp-loopback` | naruon → TEPP | Committed examples live under `examples/`. Schemas for analysis-run requests and corpus-split manifests live under `schemas/`. diff --git a/docs/research/export-retrieval-cli.md b/docs/research/export-retrieval-cli.md new file mode 100644 index 000000000..8291e44d5 --- /dev/null +++ b/docs/research/export-retrieval-cli.md @@ -0,0 +1,54 @@ +# Export retrieval CLI + +## Scope + +This note doctors the GAP-003A naruon export-retrieval CLI slice: + +1. `tepp-export-get get` is the published operator CLI for + `GET /v1/exports/{export_id}`; +2. the CLI mints `naruon_export_retrieval_exchange` and renders onto spawned + `tepp-loopback` TCP; +3. success stdout is a metric-free `200 OK` identity; +4. public bind hosts, `localhost`, non-`https` origins, unpublished consumers, + LineageWeave, and nonempty GET bodies fail closed. + +Postgres persistence, restart/recovery, and Compose execution remain GAP-003B. +This slice is not implemented-main. It does not duplicate export retrieval GET +(#411), export-authorize CLI (#410), GET-by-id, wait CLI, lookup CLI, or +temporal-context CLI. It does not add GET to `NaruonLiveService`. + +## Authoritative sources + +Fielding, R., Nottingham, M., & Reschke, J. (Eds.). (2022). *HTTP semantics* +(RFC 9110). IETF. https://doi.org/10.17487/RFC9110 + +National Academies of Sciences, Engineering, and Medicine. (2019). +*Reproducibility and replicability in science*. The National Academies Press. +https://doi.org/10.17226/25303 + +Wasserstein, R. L., & Lazar, N. A. (2016). The ASA statement on *p*-values: +Context, process, and purpose. *The American Statistician, 70*(2), 129–133. +https://doi.org/10.1080/00031305.2016.1154108 + +## Application + +RFC 9110 requires that a published method be invoked through a documented +interface, not an ad-hoc operator wire. The National Academies (2019) require +that a computational procedure be runnable from the published interface. +Wasserstein and Lazar (2016) refuse to treat a passing threshold as automatic +scientific authority, so the CLI emits the same metric-free identity as the +library bind and never treats HTTP success as ADR 0014 promotion. TEPP +therefore gives operators a credential-free export-retrieval CLI, refuses +LineageWeave on this naruon-owned adapter, and keeps RMSE, bias, coverage, and +scientific acceptance off the retrieval receipt (Fielding, Nottingham, & +Reschke, 2022; National Academies of Sciences, Engineering, and Medicine, +2019; Wasserstein & Lazar, 2016). + +## Verification + +- naruon export-retrieval CLI is HTTPS GET `/v1/exports/{export_id}` without + credentials or RMSE keys; +- LineageWeave, public bind, `localhost`, `http://` origins, and nonempty + bodies fail closed; +- POST mint then typed GET CLI stdout matches the minted `artifact_id`; +- `NaruonLiveService` still refuses GET. diff --git a/docs/research/export-retrieval-http.md b/docs/research/export-retrieval-http.md index 4e2f102c5..7a62b27dd 100644 --- a/docs/research/export-retrieval-http.md +++ b/docs/research/export-retrieval-http.md @@ -31,5 +31,6 @@ remain later work. GET-by-id status (#359), lifecycle POST, cancel, collection GET, retry, stored-request GET, retry-lineage GET, lookup GET, retry-parent GET, wait CLI, -Leiden, Driver p.16 std-family restoration, Figma/export (GAP-010), and -Compose persistence (GAP-003B). +export-authorize CLI, Leiden, Driver p.16 std-family restoration, Figma/export +(GAP-010), and Compose persistence (GAP-003B). The operator CLI for this GET +is ADR 0055 on a stacked PR. diff --git a/docs/research/naruon-http-interchange.md b/docs/research/naruon-http-interchange.md index 597d17aca..071663489 100644 --- a/docs/research/naruon-http-interchange.md +++ b/docs/research/naruon-http-interchange.md @@ -5,7 +5,8 @@ naruon may submit analysis-run requests and request purpose-bound exports only through versioned `https` POST paths owned by TEPP. Export retrieval is the loopback `GET /v1/exports/{export_id}` identity route on -`AnalysisRunLiveService` (ADR 0054). HTTP method, path, `Host`, +`AnalysisRunLiveService` (ADR 0054). `tepp-export-get get` is the published +CLI for that GET (ADR 0055). HTTP method, path, `Host`, and `Transfer-Encoding` semantics follow current HTTP semantics (Fielding, Nottingham, & Reschke, 2022). Knowledge-cutoff instants use RFC 3339 (Klyne & Newman, 2002). Fail-closed refusal of table-access URLs,