From 504793d88c6b754f5181f48dc7abde073ff9146a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 07:29:09 +0000 Subject: [PATCH 1/2] feat(api): enumerate authorized exports via loopback collection GET GAP-003A unique slice stacked on export retrieval GET: loopback GET /v1/exports lists metric-free purpose-bound identities on AnalysisRunLiveService / tepp-loopback. LineageWeave refused. NaruonLiveService stays POST-only. ADR 0075. --- CHANGELOG.d/export-collection-get.md | 1 + DOCUMENTATION.md | 1 + crates/tepp_api/src/analysis_run_live.rs | 72 +++++-- crates/tepp_api/src/export_collection_http.rs | 204 ++++++++++++++++++ crates/tepp_api/src/lib.rs | 19 ++ .../tests/export_collection_http_contract.rs | 33 +++ docs/API_CONTRACT.md | 3 +- docs/TRACEABILITY.md | 2 +- docs/adr/0075-export-collection-get.md | 100 +++++++++ docs/adr/README.md | 1 + docs/connectors/naruon-artifact-consumer.md | 1 + docs/research/export-collection-http.md | 54 +++++ 12 files changed, 472 insertions(+), 19 deletions(-) create mode 100644 CHANGELOG.d/export-collection-get.md create mode 100644 crates/tepp_api/src/export_collection_http.rs create mode 100644 crates/tepp_api/tests/export_collection_http_contract.rs create mode 100644 docs/adr/0075-export-collection-get.md create mode 100644 docs/research/export-collection-http.md diff --git a/CHANGELOG.d/export-collection-get.md b/CHANGELOG.d/export-collection-get.md new file mode 100644 index 000000000..696b98f0a --- /dev/null +++ b/CHANGELOG.d/export-collection-get.md @@ -0,0 +1 @@ +- `tepp_api` loopback `GET /v1/exports` enumerates authorized purpose-bound export identities on `AnalysisRunLiveService` / `tepp-loopback` (ADR 0075). Metric-free receipts only. `tepp.scientific_acceptance.v1` never appears. Does not infer causality. LineageWeave refused. `NaruonLiveService` stays POST-only. Not export retrieval GET, not GAP-010 Figma/export, not persistence. diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 6fa4b9683..9eb2aa102 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -13,6 +13,7 @@ TEPP's approved PRD v0.4 and implementation plan are the primary product baselin | naruon modular consumer contract | [`docs/connectors/naruon-artifact-consumer.md`](docs/connectors/naruon-artifact-consumer.md) | | contextual-orchestrator interpretation port | [`docs/connectors/contextual-orchestrator-interpretation-port.md`](docs/connectors/contextual-orchestrator-interpretation-port.md) | | Orchestrator live HTTP doctoring | [`docs/research/orchestrator-live-http.md`](docs/research/orchestrator-live-http.md) | +| Export collection GET doctoring | [`docs/research/export-collection-http.md`](docs/research/export-collection-http.md) | | UML/runtime/scientific flows | [`docs/UML.md`](docs/UML.md) | | Logical/physical ERD | [`docs/ERD.md`](docs/ERD.md) | | Security policy | [`SECURITY.md`](SECURITY.md) | diff --git a/crates/tepp_api/src/analysis_run_live.rs b/crates/tepp_api/src/analysis_run_live.rs index a5f1f9f93..bb3ce1c82 100644 --- a/crates/tepp_api/src/analysis_run_live.rs +++ b/crates/tepp_api/src/analysis_run_live.rs @@ -3,7 +3,8 @@ //! 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`. Naruon may also POST and -//! GET `/v1/exports/{export_id}` for metric-free purpose-bound retrieval. +//! GET `/v1/exports/{export_id}` for metric-free purpose-bound retrieval and +//! `GET /v1/exports` to enumerate those identities. //! It accepts transport acknowledgements, temporal evidence context, and //! export identities only; completed psychometric results remain outside this //! crate. @@ -12,9 +13,13 @@ use std::collections::HashMap; use std::io::Write; use std::net::{SocketAddr, TcpListener}; +use crate::export_collection_http::{ + is_export_collection_path, page_export_collection_items, parse_export_collection_page_cursor, + parse_export_collection_page_limit, ExportCollection, +}; 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, + consumer_is_supported, LINEAGEWEAVE_CONSUMER_CODE, NARUON_CONSUMER_CODE, }; use crate::live_http::{ header_value, map_io_error, parse_headers, parse_request_line, read_http_request_with_limit, @@ -22,12 +27,12 @@ use crate::live_http::{ }; use crate::naruon_http::{NARUON_ANALYSIS_RUN_PATH, NARUON_EXPORT_PATH}; use crate::{ - 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, + authorize_export, build_temporal_context, project_history_projection, + requests_are_idempotent_matches, require_export_allowed, AnalysisRunAccepted, + AnalysisRunRequest, AnalyticalPurpose, ApiError, ErrorEnvelope, ExportAuthorizationRequest, + ExportRetrieval, NaruonLiveResponse, ProjectHistoryProjection, ProjectHistoryRequest, + TemporalContextRequest, DEFAULT_PROJECT_HISTORY_BYTE_LIMIT, NARUON_LIVE_IO_TIMEOUT, + PROJECT_HISTORY_PATH, TEMPORAL_CONTEXT_PATH, }; const MAX_LIVE_REQUEST_BODY_BYTES: usize = DEFAULT_PROJECT_HISTORY_BYTE_LIMIT; @@ -162,6 +167,9 @@ impl AnalysisRunLiveService { let (method, path) = parse_request_line(lines.next().unwrap_or(""))?; let headers = parse_headers(&mut lines)?; if method == "GET" { + if is_export_collection_path(path) { + return self.list_exports(&headers, body); + } if matches!( export_retrieval_path_id(path), Ok(_) | Err(ApiError::LimitExceeded) @@ -342,6 +350,37 @@ impl AnalysisRunLiveService { Ok(json_response(200, "OK", response_body)) } + fn list_exports( + &self, + headers: &HashMap, + body: &str, + ) -> Result { + if !body.trim().is_empty() { + return Err(ApiError::InvalidWirePayload); + } + refuse_metrics_on_export_retrieval_payload(body)?; + let consumer = require_headers(headers, self.bound_addr, false)?; + if consumer != NARUON_CONSUMER_CODE { + return Err(ApiError::InvalidWirePayload); + } + if headers.contains_key("idempotency-key") { + return Err(ApiError::InvalidWirePayload); + } + let limit = + parse_export_collection_page_limit(headers.get("tepp-page-limit").map(String::as_str))?; + let cursor = parse_export_collection_page_cursor( + headers.get("tepp-page-cursor").map(String::as_str), + )?; + let items = self + .authorized_exports + .values() + .map(|stored| stored.retrieval.clone()) + .collect(); + let (page, next_cursor) = page_export_collection_items(items, cursor.as_deref(), limit); + let collection = ExportCollection::new(page, next_cursor)?; + Ok(json_response(200, "OK", collection.to_json()?)) + } + 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; @@ -417,17 +456,16 @@ mod tests { use std::time::Duration; use super::{ - AnalysisRunLiveService, consumer_tenant_idempotency_key, declared_content_length, - error_envelope_json, host_implies_table_access, map_io_error, parse_headers, - require_headers, split_header_line, status_for, + consumer_tenant_idempotency_key, declared_content_length, error_envelope_json, + host_implies_table_access, map_io_error, parse_headers, require_headers, split_header_line, + status_for, AnalysisRunLiveService, }; use crate::live_http::{host_is_loopback, read_http_request, split_request}; 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_EXPORT_PATH, - NARUON_LIVE_HEADER_BYTE_LIMIT, NARUON_LIVE_HEADER_COUNT_LIMIT, NARUON_LIVE_IO_TIMEOUT, - TEMPORAL_CONTEXT_PATH, + AnalysisRunRequest, ApiError, ErrorEnvelope, ANALYSIS_RUN_CONTRACT_VERSION, + DEFAULT_ANALYSIS_RUN_BYTE_LIMIT, LINEAGEWEAVE_CONSUMER_CODE, 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 { @@ -1135,7 +1173,7 @@ mod tests { "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 + 200 ); assert_eq!( service diff --git a/crates/tepp_api/src/export_collection_http.rs b/crates/tepp_api/src/export_collection_http.rs new file mode 100644 index 000000000..425165448 --- /dev/null +++ b/crates/tepp_api/src/export_collection_http.rs @@ -0,0 +1,204 @@ +//! Provider-owned export collection GET contracts. +//! +//! GAP-003A unique slice: `GET /v1/exports` enumerates metric-free identities +//! of purpose-bound exports that `AnalysisRunLiveService` / `tepp-loopback` +//! already authorized. Operators do not guess `export_id` values. This module +//! does not duplicate export retrieval GET (#411), export-retrieval CLI +//! (#417), export-authorize CLI (#410), interpretation-run collection GET +//! (#433), project-history collection GET (#424), GET-by-id (#359), Leiden, +//! or GAP-010 Figma/export. Persistence remains GAP-003B. `LineageWeave` is +//! refused. `NaruonLiveService` stays POST-only. + +use serde::{Deserialize, Serialize}; + +use crate::export_http::{ + refuse_metrics_on_export_retrieval_payload, ExportRetrieval, EXPORT_RETRIEVAL_ID_MAX_LEN, +}; +use crate::naruon_http::{compose_https_target, NaruonHttpExchange, NARUON_EXPORT_PATH}; +use crate::wire::{require_byte_limit, require_nonempty, to_json}; +use crate::{ApiError, DEFAULT_ANALYSIS_RUN_BYTE_LIMIT}; + +/// Default page size for export collection GET. +pub const EXPORT_COLLECTION_DEFAULT_LIMIT: usize = 32; +/// Maximum page size for export collection GET. +pub const EXPORT_COLLECTION_MAX_LIMIT: usize = 64; +/// Maximum opaque cursor length on export collection GET. +pub const EXPORT_COLLECTION_CURSOR_MAX_LEN: usize = EXPORT_RETRIEVAL_ID_MAX_LEN; + +/// Metric-free export collection page. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ExportCollection { + /// Metric-free authorized export identities on this page. + pub items: Vec, + /// Exclusive `export_id` cursor for the next page, if any. + #[serde(skip_serializing_if = "Option::is_none")] + pub next_cursor: Option, +} + +impl ExportCollection { + /// Construct a validated collection page. + /// + /// # Errors + /// + /// Returns a fail-closed error for oversized pages or hostile cursors. + pub fn new(items: Vec, next_cursor: Option) -> Result { + if items.len() > EXPORT_COLLECTION_MAX_LIMIT { + return Err(ApiError::LimitExceeded); + } + if let Some(cursor) = next_cursor.as_deref() { + require_nonempty(cursor)?; + if cursor.len() > EXPORT_COLLECTION_CURSOR_MAX_LEN { + return Err(ApiError::LimitExceeded); + } + if cursor.contains('/') || cursor.contains('\0') { + return Err(ApiError::InvalidWirePayload); + } + } + let collection = Self { items, next_cursor }; + let payload = to_json(&collection)?; + require_byte_limit(&payload, DEFAULT_ANALYSIS_RUN_BYTE_LIMIT)?; + refuse_metrics_on_export_retrieval_payload(&payload)?; + Ok(collection) + } + + /// Serialize this collection after metric refusal. + /// + /// # Errors + /// + /// Returns a validation or metric-key error. + pub fn to_json(&self) -> Result { + let payload = to_json(self)?; + require_byte_limit(&payload, DEFAULT_ANALYSIS_RUN_BYTE_LIMIT)?; + refuse_metrics_on_export_retrieval_payload(&payload)?; + Ok(payload) + } +} + +/// Whether a path is the export collection resource. +#[must_use] +pub fn is_export_collection_path(path: &str) -> bool { + path == NARUON_EXPORT_PATH +} + +/// Parse the optional `tepp-page-limit` header. +/// +/// # Errors +/// +/// Returns [`ApiError::InvalidWirePayload`] for a non-integer and +/// [`ApiError::LimitExceeded`] when above [`EXPORT_COLLECTION_MAX_LIMIT`]. +pub fn parse_export_collection_page_limit(raw: Option<&str>) -> Result { + let Some(raw) = raw else { + return Ok(EXPORT_COLLECTION_DEFAULT_LIMIT); + }; + require_nonempty(raw)?; + let limit: usize = raw.parse().map_err(|_| ApiError::InvalidWirePayload)?; + if limit == 0 { + return Err(ApiError::InvalidWirePayload); + } + if limit > EXPORT_COLLECTION_MAX_LIMIT { + return Err(ApiError::LimitExceeded); + } + Ok(limit) +} + +/// Parse the optional exclusive `tepp-page-cursor` header. +/// +/// # Errors +/// +/// Returns a fail-closed error for empty, slash, NUL, or oversized cursors. +pub fn parse_export_collection_page_cursor(raw: Option<&str>) -> Result, ApiError> { + let Some(raw) = raw else { + return Ok(None); + }; + require_nonempty(raw)?; + if raw.contains('/') || raw.contains('\0') { + return Err(ApiError::InvalidWirePayload); + } + if raw.len() > EXPORT_COLLECTION_CURSOR_MAX_LEN { + return Err(ApiError::LimitExceeded); + } + Ok(Some(raw.to_owned())) +} + +/// Page stored collection rows with an exclusive `export_id` cursor. +#[must_use] +pub fn page_export_collection_items( + mut items: Vec, + cursor: Option<&str>, + limit: usize, +) -> (Vec, Option) { + items.sort_by(|left, right| left.export_id.cmp(&right.export_id)); + let start = cursor.map_or(0, |cursor| { + items + .iter() + .position(|item| item.export_id.as_str() > cursor) + .unwrap_or(items.len()) + }); + let end = (start + limit).min(items.len()); + let next_cursor = (end < items.len()).then(|| items[end - 1].export_id.clone()); + (items[start..end].to_vec(), next_cursor) +} + +/// Build a credential-free naruon collection GET exchange. +/// +/// # Errors +/// +/// Returns a fail-closed origin error. +pub fn naruon_export_collection_exchange(origin: &str) -> Result { + let target_url = compose_https_target(origin, NARUON_EXPORT_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(), + }) +} + +#[cfg(test)] +mod tests { + use super::{ + is_export_collection_path, naruon_export_collection_exchange, + parse_export_collection_page_cursor, parse_export_collection_page_limit, + EXPORT_COLLECTION_MAX_LIMIT, + }; + use crate::naruon_http::NARUON_EXPORT_PATH; + use crate::ApiError; + + #[test] + fn collection_exchange_is_metric_free_get_without_credentials() { + assert!(is_export_collection_path(NARUON_EXPORT_PATH)); + assert!(!is_export_collection_path("/v1/exports/export-1")); + let exchange = + naruon_export_collection_exchange("https://tepp.example.test").expect("exchange"); + assert_eq!(exchange.method, "GET"); + assert!(exchange.target_url.ends_with("/v1/exports")); + assert!(exchange.body.is_empty()); + assert!(!exchange + .headers + .iter() + .any(|(name, _)| name.eq_ignore_ascii_case("authorization") + || name.eq_ignore_ascii_case("idempotency-key"))); + assert_eq!( + naruon_export_collection_exchange("http://tepp.example.test"), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + parse_export_collection_page_limit(None).expect("default"), + 32 + ); + assert_eq!( + parse_export_collection_page_limit(Some("99")), + Err(ApiError::LimitExceeded) + ); + assert_eq!( + parse_export_collection_page_cursor(Some("a/b")), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!(EXPORT_COLLECTION_MAX_LIMIT, 64); + } +} diff --git a/crates/tepp_api/src/lib.rs b/crates/tepp_api/src/lib.rs index bd8a933e0..da946cef1 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_collection_http; mod export_http; mod lineage_criterion_anchor; mod lineage_pair_criterion; @@ -104,6 +105,24 @@ pub use export_http::EXPORT_RETRIEVAL_ID_MAX_LEN; pub use export_http::ExportRetrieval; /// Build a naruon export-retrieval GET exchange. pub use export_http::naruon_export_retrieval_exchange; +/// Build a credential-free naruon export collection GET exchange. +pub use export_collection_http::naruon_export_collection_exchange; +/// Whether a path is the export collection resource. +pub use export_collection_http::is_export_collection_path; +/// Page stored export collection rows with an exclusive export-id cursor. +pub use export_collection_http::page_export_collection_items; +/// Parse the optional exclusive `tepp-page-cursor` header. +pub use export_collection_http::parse_export_collection_page_cursor; +/// Parse the optional `tepp-page-limit` header. +pub use export_collection_http::parse_export_collection_page_limit; +/// Metric-free export collection page. +pub use export_collection_http::ExportCollection; +/// Maximum opaque cursor length on export collection GET. +pub use export_collection_http::EXPORT_COLLECTION_CURSOR_MAX_LEN; +/// Default page size for export collection GET. +pub use export_collection_http::EXPORT_COLLECTION_DEFAULT_LIMIT; +/// Maximum page size for export collection GET. +pub use export_collection_http::EXPORT_COLLECTION_MAX_LIMIT; /// Refuse scientific-metric keys on export-retrieval JSON. pub use export_http::refuse_metrics_on_export_retrieval_payload; diff --git a/crates/tepp_api/tests/export_collection_http_contract.rs b/crates/tepp_api/tests/export_collection_http_contract.rs new file mode 100644 index 000000000..6044fdd88 --- /dev/null +++ b/crates/tepp_api/tests/export_collection_http_contract.rs @@ -0,0 +1,33 @@ +//! Contract tests for naruon export collection GET. + +use tepp_api::{ + is_export_collection_path, naruon_export_collection_exchange, ApiError, NARUON_CONSUMER_CODE, +}; + +#[test] +fn export_collection_is_metric_free_get_without_credentials() { + let exchange = + naruon_export_collection_exchange("https://tepp.example.test").expect("exchange"); + assert_eq!(exchange.method, "GET"); + assert!(exchange.target_url.ends_with("/v1/exports")); + assert!(exchange.body.is_empty()); + assert!(exchange + .headers + .iter() + .any(|(name, value)| name == "tepp-consumer" && value == NARUON_CONSUMER_CODE)); + assert!(!exchange + .headers + .iter() + .any(|(name, _)| name.eq_ignore_ascii_case("authorization") + || name.eq_ignore_ascii_case("idempotency-key"))); + assert!(is_export_collection_path("/v1/exports")); + assert!(!is_export_collection_path("/v1/exports/export-1")); +} + +#[test] +fn export_collection_refuses_insecure_origins() { + assert_eq!( + naruon_export_collection_exchange("http://tepp.example.test"), + Err(ApiError::InvalidWirePayload) + ); +} diff --git a/docs/API_CONTRACT.md b/docs/API_CONTRACT.md index 1142e99fe..04c0bbb69 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); `GET /v1/exports` enumerates those identities (ADR 0075); `NaruonLiveService` stays POST-only. ## 2. Contract families @@ -68,6 +68,7 @@ POST /v1/temporal-context GET /v1/analysis-runs/{run_id} POST /v1/analysis-runs/{run_id}/cancel GET /v1/model-artifacts/{artifact_id} +GET /v1/exports GET /v1/exports/{export_id} ``` diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 20d4b7f01..2bef6941c 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/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 | +| versioned service/API contracts and exports | PRD; API contract; ADR 0011/0013/0054/0075 | `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; loopback `GET /v1/exports` enumerates authorized identities 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/0075-export-collection-get.md b/docs/adr/0075-export-collection-get.md new file mode 100644 index 000000000..321a68560 --- /dev/null +++ b/docs/adr/0075-export-collection-get.md @@ -0,0 +1,100 @@ +# ADR 0075 — Loopback export collection GET + +**Decision status:** Accepted +**Implementation maturity:** active-PR +**Date:** 2026-09-01 +**Supersedes:** None; complements ADR 0054 for enumerating authorized export identities. Does not supersede ADR 0014 claim-promotion authority. This ADR number is unique versus protected main; live vs-main and sibling GAP-003A PRs already occupy 0026–0074. + +## Context + +ADR 0054 retrieves one authorized export by `export_id`. Operators who hold a +200 authorization receipt still had no loopback path to enumerate minted +identities without guessing UUIDs. Duplicating export retrieval GET (#411), +export-retrieval CLI (#417), export-authorize CLI (#410), interpretation-run +collection GET (#433), project-history collection GET (#424), Leiden, Driver +p.16, or GAP-010 Figma/export would collide with live PRs. LineageWeave is +refused on this naruon-owned adapter; `NaruonLiveService` stays POST-only. + +## Decision + +`AnalysisRunLiveService` publishes loopback-only `GET /v1/exports` on +`tepp-loopback`: + +- Consumer is `naruon` only. Empty body. Identity does not travel in a header. + `idempotency-key` is refused. +- Extra path segments fail closed as GET-by-id parsing, not as collection. +- Pagination uses `tepp-page-limit` (default 32, max 64) and exclusive + `tepp-page-cursor` on `export_id`. +- Each row is the same metric-free `ExportRetrieval` identity as ADR 0054: + `export_id`, `artifact_id`, `decision_code=purpose_bound_export_allowed`, + `purpose`, `idempotency_key`. Tenant, principal, source text, RMSE, bias, + coverage, SE-gate, and `tepp.scientific_acceptance.v1` never appear. +- Collection does not infer causality, persist, or return a completed + psychometric result. +- This slice does not implement a collection CLI. + +## Alternatives considered + +1. **Keep GET-by-id without a collection** — rejected; operators still guess + UUID v7 identities after ADR 0054. +2. **Reuse interpretation-run collection GET (#433)** — rejected; that is a + different live resource and a contextual-orchestrator consumer. +3. **Add GET collection to `NaruonLiveService`** — rejected; that listener + stays POST-only. +4. **Loopback `GET /v1/exports`** — accepted. + +## Consequences + +- Operators can enumerate authorized export identities without guessing + `export_id`. +- Collection JSON cannot be mistaken for a succeeded scientific-acceptance + result or a causal score. +- Collection success is not release evidence and is not an ADR 0014 claim. + +## Failure and recovery + +Non-`naruon` consumers, nonempty GET bodies, present `idempotency-key`, extra +path segments, slash/NUL cursors, credential flags, and metric keys fail +closed. The in-memory listener is not durable. + +## Security, privacy, scientific-integrity, and governance impact + +- No credential headers cross the consumer boundary. +- Tenant, principal, and source text stay off the collection page. +- HTTP 200 on collection is not measurement evidence and is not a causal + claim. + +## Compatibility and migration + +GET-by-id, POST `/v1/exports` on `AnalysisRunLiveService`, and +`NaruonLiveService` POST-only remain unchanged. A collection CLI remains a +later slice. Persistence remains GAP-003B. + +## Verification + +Falsifiable evidence: + +- GET collection of authorized exports returns metric-free identities without + RMSE/bias/coverage/SE-gate/tenant/principal/source-text/ + `tepp.scientific_acceptance.v1` keys; +- LineageWeave, nonempty body, present `idempotency-key`, extra segments, and + unknown keys fail closed; +- `NaruonLiveService` still refuses GET; +- Clippy `-D warnings`, `tepp_api` tests, rustdoc, and exact-head review + remain required. + +## Rollback and supersession + +Rollback removes collection GET; GET-by-id and POST remain valid. A +superseding ADR is required to persist the collection, bind a public address, +emit scientific-acceptance on collection, open LineageWeave, add GET to +`NaruonLiveService`, or treat collection success as an ADR 0014 claim. + +## Related authority + +- ADR 0054 owns loopback export retrieval GET. +- ADR 0055 owns the export-retrieval CLI (live #417). +- ADR 0026 owns the export-authorize CLI (live #410). +- ADR 0014 owns scientific claim promotion. +- RFC 9110 owns GET semantics (Fielding, Nottingham, & Reschke, 2022). It + does not authorize scientific claims. diff --git a/docs/adr/README.md b/docs/adr/README.md index 5e43e54fb..9153090dc 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. | +| [0075](0075-export-collection-get.md) | Loopback export collection GET | Accepted | active-PR | Complements ADR 0054; `GET /v1/exports` enumerates metric-free authorized identities. Unique versus protected main (0026–0074 occupied). `NaruonLiveService` stays POST-only. | | [0023](0023-lineage-criterion-anchor-contract.md) | TEPP-owned Event Lineage criterion anchor | Accepted | active-PR | PR #237 publishes the strict accepted/rejected artifact and identities; estimator execution remains fail-closed future work. | | [0024](0024-independent-topic-importance-anchor.md) | Posterior topic-context producer contract | Accepted | contract-only active-PR | Strict DTO/schema only; the current estimator does not emit it. fast-mlsirm owns case-deletion influence. | | [0001](0001-rust-first-modular-msa.md) | Rust-first numerical core and CPU `f64` reference | Accepted | partial | ADR 0011 owns cross-service/MSA authority; 0001 retains numerical/backend authority. | diff --git a/docs/connectors/naruon-artifact-consumer.md b/docs/connectors/naruon-artifact-consumer.md index f9f356c6d..90bea97d9 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 collection | `tepp_api` `AnalysisRunLiveService` → `GET /v1/exports` | 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-collection-http.md b/docs/research/export-collection-http.md new file mode 100644 index 000000000..c05f823d3 --- /dev/null +++ b/docs/research/export-collection-http.md @@ -0,0 +1,54 @@ +# Export collection GET (doctoring) + +## Scope + +`GET /v1/exports` is the operator-visible enumeration of authorized +purpose-bound export identities on `AnalysisRunLiveService` / +`tepp-loopback`. HTTP method, path, and header semantics follow current HTTP +semantics (Fielding, Nottingham, & Reschke, 2022). Fail-closed refusal of +unpublished consumers, nonempty GET bodies, present `idempotency-key`, extra +path segments, review/Copilot/GitHub credential flags, and +scientific-authority promotion is repository contract authority (ADR 0075; +ADR 0054; ADR 0014), not an RFC inference rule. + +Collection JSON is metric-free. Tenant, principal, source text, and +`tepp.scientific_acceptance.v1` never appear. HTTP 200 is not a completed +psychometric result, calibrated score, theta estimate, uncertainty statement, +causal inference, or scientific claim. + +## Authority + +### External standards (HTTP only) + +Fielding, R., Nottingham, M., & Reschke, J. (Eds.). (2022). *HTTP semantics* +(RFC 9110). IETF. https://doi.org/10.17487/RFC9110 + +RFC 9110 §9.3.1 describes GET as a method for retrieving a current +representation of the target resource. TEPP maps that retrieval onto a +bounded, in-memory page of metric-free export identities. The RFC does not +define psychometric acceptance, RMSE, causality, or claim promotion. + +### Internal contract evidence + +- `docs/adr/0075-export-collection-get.md` — this collection +- `docs/adr/0054-export-retrieval-get.md` — GET-by-id +- `docs/adr/0014-scientific-claim-promotion-and-release-evidence.md` — HTTP + 200 is not a scientific claim +- `crates/tepp_api/tests/export_collection_http_contract.rs` — fail-closed + collection proofs + +## Verification + +- `GET /v1/exports` of authorized naruon exports returns metric-free + identities without RMSE/bias/coverage/SE-gate keys, tenant, principal, + source text, or `tepp.scientific_acceptance.v1`; +- LineageWeave, nonempty body, present `idempotency-key`, extra path + segments, slash/NUL cursors fail closed; +- `NaruonLiveService` still refuses GET. + +## Non-claims + +This slice does not implement a collection CLI, GAP-010 Figma/export, +analysis-run collection GET, interpretation-run collection GET, persistence, +production TLS, Leiden consensus, provider execution, causal inference, or an +ADR 0014 scientific claim-promotion package. From 95ab519fdb39c66a574d1452e969b0b80b9e4ba9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 09:34:40 +0000 Subject: [PATCH 2/2] feat(api): list authorized exports via loopback collection CLI Publish tepp-export-list list so operators mint naruon GET /v1/exports onto spawned tepp-loopback TCP. Receipts stay metric-free. LineageWeave is refused. NaruonLiveService stays POST-only. ADR 0076. --- CHANGELOG.d/export-collection-cli.md | 1 + DOCUMENTATION.md | 1 + crates/tepp_api/Cargo.toml | 6 + crates/tepp_api/src/bin/tepp_export_list.rs | 30 + crates/tepp_api/src/export_collection_cli.rs | 575 ++++++++++++++++++ crates/tepp_api/src/lib.rs | 17 + .../tests/export_collection_cli_contract.rs | 116 ++++ docs/API_CONTRACT.md | 2 +- docs/TRACEABILITY.md | 2 +- docs/adr/0076-export-collection-cli.md | 100 +++ docs/adr/README.md | 1 + docs/connectors/naruon-artifact-consumer.md | 1 + docs/research/export-collection-cli.md | 55 ++ 13 files changed, 905 insertions(+), 2 deletions(-) create mode 100644 CHANGELOG.d/export-collection-cli.md create mode 100644 crates/tepp_api/src/bin/tepp_export_list.rs create mode 100644 crates/tepp_api/src/export_collection_cli.rs create mode 100644 crates/tepp_api/tests/export_collection_cli_contract.rs create mode 100644 docs/adr/0076-export-collection-cli.md create mode 100644 docs/research/export-collection-cli.md diff --git a/CHANGELOG.d/export-collection-cli.md b/CHANGELOG.d/export-collection-cli.md new file mode 100644 index 000000000..c653d55ec --- /dev/null +++ b/CHANGELOG.d/export-collection-cli.md @@ -0,0 +1 @@ +- `tepp-export-list list` mints naruon `GET /v1/exports` onto spawned `tepp-loopback` TCP (ADR 0076). Metric-free receipts only. `tepp.scientific_acceptance.v1` never appears. Does not infer causality. LineageWeave refused. `NaruonLiveService` stays POST-only. Not export-retrieval CLI, not GAP-010 Figma/export, not persistence. diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 9eb2aa102..137d5e040 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -14,6 +14,7 @@ TEPP's approved PRD v0.4 and implementation plan are the primary product baselin | contextual-orchestrator interpretation port | [`docs/connectors/contextual-orchestrator-interpretation-port.md`](docs/connectors/contextual-orchestrator-interpretation-port.md) | | Orchestrator live HTTP doctoring | [`docs/research/orchestrator-live-http.md`](docs/research/orchestrator-live-http.md) | | Export collection GET doctoring | [`docs/research/export-collection-http.md`](docs/research/export-collection-http.md) | +| Export collection CLI doctoring | [`docs/research/export-collection-cli.md`](docs/research/export-collection-cli.md) | | UML/runtime/scientific flows | [`docs/UML.md`](docs/UML.md) | | Logical/physical ERD | [`docs/ERD.md`](docs/ERD.md) | | Security policy | [`SECURITY.md`](SECURITY.md) | diff --git a/crates/tepp_api/Cargo.toml b/crates/tepp_api/Cargo.toml index 47ad7c433..600c88d51 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-list" +path = "src/bin/tepp_export_list.rs" +test = false +bench = false + [lints] workspace = true diff --git a/crates/tepp_api/src/bin/tepp_export_list.rs b/crates/tepp_api/src/bin/tepp_export_list.rs new file mode 100644 index 000000000..6bb89880e --- /dev/null +++ b/crates/tepp_api/src/bin/tepp_export_list.rs @@ -0,0 +1,30 @@ +//! Operator CLI for loopback naruon export collection GET. + +use std::io::{self, IsTerminal}; +use std::process::ExitCode; + +use tepp_api::{ + execute_export_collection_cli, read_export_collection_cli_stdin, + render_export_collection_cli_stdout, ApiError, ExportCollectionCliInvocation, +}; + +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_collection_cli_stdin(io::stdin().is_terminal(), io::stdin())?; + let invocation = ExportCollectionCliInvocation::from_args(&args, body)?; + let response = execute_export_collection_cli(&invocation)?; + let stdout = render_export_collection_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_collection_cli.rs b/crates/tepp_api/src/export_collection_cli.rs new file mode 100644 index 000000000..c349161d7 --- /dev/null +++ b/crates/tepp_api/src/export_collection_cli.rs @@ -0,0 +1,575 @@ +//! Operator loopback CLI for naruon export collection GET. +//! +//! GAP-003A unique slice: operators run `tepp-export-list list` to mint +//! `naruon_export_collection_exchange` onto spawned `tepp-loopback` TCP. +//! Stdout is one metric-free collection page of authorized identities. +//! `tepp.scientific_acceptance.v1` never appears. `LineageWeave` is refused. +//! `NaruonLiveService` stays POST-only. This module does not duplicate export +//! collection GET (#443), export-retrieval CLI (#417), export retrieval GET +//! (#411), export-authorize CLI (#410), interpretation-run collection CLI +//! (#436), Leiden, or GAP-010 Figma/export. Persistence remains GAP-003B. + +use std::fmt::Write as _; +use std::io::{Read, Write}; +use std::net::{SocketAddr, TcpStream}; + +use crate::live_http::map_io_error; +use crate::naruon_http::header_is_credential; +use crate::wire::require_nonempty; +use crate::{ + naruon_export_collection_exchange, parse_export_collection_page_cursor, + parse_export_collection_page_limit, refuse_metrics_on_export_retrieval_payload, + AnalysisRunLiveService, ApiError, ExportCollection, NARUON_CONSUMER_CODE, + NARUON_LIVE_IO_TIMEOUT, NaruonHttpExchange, NaruonLiveResponse, +}; + +const SCIENTIFIC_ACCEPTANCE_SCHEMA: &str = "tepp.scientific_acceptance.v1"; + +/// Supported operator verbs for the loopback export-collection CLI. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ExportCollectionCliVerb { + /// `GET /v1/exports`. + List, +} + +impl ExportCollectionCliVerb { + /// Parse one exact lowercase verb token. + /// + /// # Errors + /// + /// Returns [`ApiError::InvalidWirePayload`] for an unknown token. + pub fn parse(token: &str) -> Result { + match token { + "list" => Ok(Self::List), + _ => Err(ApiError::InvalidWirePayload), + } + } + + /// Return the canonical lowercase verb token. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::List => "list", + } + } +} + +/// One operator CLI invocation against a loopback export-collection GET listener. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ExportCollectionCliInvocation { + /// CLI verb to execute. + pub verb: ExportCollectionCliVerb, + /// Loopback `host:port` of `tepp-loopback`. + pub host: String, + /// Published HTTPS origin used to mint the typed collection exchange. + pub origin: String, + /// Published modular consumer. Collection GET is naruon-only. + pub consumer: String, + /// Optional page size for `tepp-page-limit`. + pub page_limit: Option, + /// Optional exclusive `export_id` cursor for `tepp-page-cursor`. + pub page_cursor: Option, + /// JSON body. Collection GET requires empty. + pub body: String, +} + +impl ExportCollectionCliInvocation { + /// Parse argv plus stdin body into a validated loopback collection invocation. + /// + /// Empty stdin is admitted. Nonempty leftover stdin fails closed. + /// + /// # 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 pagination, 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 = ExportCollectionCliVerb::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, nonempty-body, or hostile pagination 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); + } + parse_export_collection_page_limit(self.page_limit.as_deref())?; + parse_export_collection_page_cursor(self.page_cursor.as_deref())?; + 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, + page_limit: Option, + page_cursor: Option, +} + +fn parse_flags(rest: &[String]) -> Result { + let mut flags = ParsedFlags { + host: None, + origin: None, + consumer: None, + page_limit: None, + page_cursor: 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, + "page-limit" => &mut flags.page_limit, + "page-cursor" => &mut flags.page_cursor, + _ => 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: ExportCollectionCliVerb, + flags: ParsedFlags, + body: String, +) -> Result { + let invocation = ExportCollectionCliInvocation { + 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()), + page_limit: flags.page_limit, + page_cursor: flags.page_cursor, + body, + }; + invocation.validate()?; + Ok(invocation) +} + +fn require_loopback_host(host: &str) -> Result { + let addr: SocketAddr = host.parse().map_err(|_| ApiError::InvalidWirePayload)?; + if addr.ip().is_loopback() { + Ok(addr) + } else { + Err(ApiError::AuthorizationDenied) + } +} + +/// Render a typed export-collection exchange as HTTP/1.1 for a loopback listener. +/// +/// # 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` with an empty body. +pub fn loopback_http1_from_export_collection_exchange( + exchange: &NaruonHttpExchange, + loopback_host: &str, +) -> Result { + let _addr = require_loopback_host(loopback_host)?; + let host = loopback_host.trim(); + if exchange.method != "GET" { + return Err(ApiError::InvalidWirePayload); + } + if !exchange.body.is_empty() { + return Err(ApiError::InvalidWirePayload); + } + let rest = exchange + .target_url + .strip_prefix("https://") + .ok_or(ApiError::InvalidWirePayload)?; + let path = rest + .find('/') + .map(|index| &rest[index..]) + .ok_or(ApiError::InvalidWirePayload)?; + if !crate::is_export_collection_path(path) { + return Err(ApiError::InvalidWirePayload); + } + for (name, _) in &exchange.headers { + if header_is_credential(name) { + return Err(ApiError::AuthorizationDenied); + } + if name.eq_ignore_ascii_case("idempotency-key") { + return Err(ApiError::InvalidWirePayload); + } + } + let mut request = String::new(); + write!( + request, + "{} {path} HTTP/1.1\r\nHost: {host}\r\n", + exchange.method + ) + .map_err(|_| ApiError::InvalidWirePayload)?; + for (name, value) in &exchange.headers { + 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: 0\r\n\r\n").map_err(|_| ApiError::InvalidWirePayload)?; + Ok(request) +} + +/// Compose one HTTP/1.1 collection GET from the typed naruon exchange. +/// +/// # Errors +/// +/// Returns the same fail-closed errors as +/// [`ExportCollectionCliInvocation::validate`]. +pub fn compose_export_collection_cli_http( + invocation: &ExportCollectionCliInvocation, +) -> Result { + invocation.validate()?; + let mut exchange = naruon_export_collection_exchange(&invocation.origin)?; + if let Some(limit) = invocation.page_limit.as_deref() { + exchange + .headers + .push(("tepp-page-limit".into(), limit.to_owned())); + } + if let Some(cursor) = invocation.page_cursor.as_deref() { + exchange + .headers + .push(("tepp-page-cursor".into(), cursor.to_owned())); + } + loopback_http1_from_export_collection_exchange(&exchange, &invocation.host) +} + +/// Dispatch one collection CLI invocation against an in-process listener. +/// +/// # Errors +/// +/// Returns fail-closed validation errors before the HTTP handler runs. +pub fn dispatch_export_collection_cli( + service: &mut AnalysisRunLiveService, + invocation: &ExportCollectionCliInvocation, +) -> Result { + let request = compose_export_collection_cli_http(invocation)?; + Ok(service.handle_http_request(&request)) +} + +/// Execute one collection CLI invocation over loopback TCP. +/// +/// # Errors +/// +/// Returns fail-closed validation, transport, or response-framing errors. +pub fn execute_export_collection_cli( + invocation: &ExportCollectionCliInvocation, +) -> Result { + let addr = require_loopback_host(&invocation.host)?; + let request = compose_export_collection_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 collection 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 +/// collection page. +pub fn render_export_collection_cli_stdout( + invocation: &ExportCollectionCliInvocation, + 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 response.status_code != 200 { + return Err(ApiError::InvalidWirePayload); + } + let parsed: ExportCollection = + serde_json::from_str(&response.body).map_err(|_| ApiError::InvalidWirePayload)?; + let collection = ExportCollection::new(parsed.items, parsed.next_cursor)?; + collection.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; collection GET admits empty. +/// +/// # Errors +/// +/// Returns [`ApiError::InvalidWirePayload`] when stdin cannot be read. +pub fn read_export_collection_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) + } +} + +#[cfg(test)] +mod tests { + use super::{ + compose_export_collection_cli_http, loopback_http1_from_export_collection_exchange, + read_export_collection_cli_stdin, ExportCollectionCliInvocation, ExportCollectionCliVerb, + }; + use crate::{ + naruon_export_collection_exchange, ApiError, NARUON_CONSUMER_CODE, NaruonHttpExchange, + }; + + const ORIGIN: &str = "https://tepp.example.test"; + + fn list_args() -> [&'static str; 7] { + [ + "list", + "--host", + "127.0.0.1:18081", + "--origin", + ORIGIN, + "--consumer", + NARUON_CONSUMER_CODE, + ] + } + + #[test] + fn from_args_mints_list_and_refuses_fail_closed_inputs() { + assert_eq!( + ExportCollectionCliVerb::parse("list").expect("list"), + ExportCollectionCliVerb::List + ); + assert_eq!(ExportCollectionCliVerb::List.as_str(), "list"); + assert_eq!( + ExportCollectionCliVerb::parse("get"), + Err(ApiError::InvalidWirePayload) + ); + let list = ExportCollectionCliInvocation::from_args(list_args(), "").expect("list"); + assert_eq!(list.verb, ExportCollectionCliVerb::List); + let http = compose_export_collection_cli_http(&list).expect("http"); + assert!(http.starts_with("GET /v1/exports HTTP/1.1")); + assert!(http.contains("tepp-consumer: naruon")); + assert!(http.contains("content-length: 0")); + assert!(!http.contains("idempotency-key:")); + assert!(!http.contains("authorization")); + assert_eq!( + ExportCollectionCliInvocation::from_args( + ["list", "--host", "8.8.8.8:80", "--origin", ORIGIN], + "" + ) + .unwrap_err(), + ApiError::AuthorizationDenied + ); + assert_eq!( + ExportCollectionCliInvocation::from_args( + ["list", "--host", "localhost:18081", "--origin", ORIGIN], + "" + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + ExportCollectionCliInvocation::from_args( + [ + "list", + "--host", + "127.0.0.1:18081", + "--origin", + "http://tepp.example.test" + ], + "" + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + ExportCollectionCliInvocation::from_args( + [ + "list", + "--host", + "127.0.0.1:18081", + "--origin", + ORIGIN, + "--authorization", + "secret" + ], + "" + ) + .unwrap_err(), + ApiError::AuthorizationDenied + ); + } + + #[test] + fn from_args_refuses_unpublished_body_export_id_and_non_get() { + assert_eq!( + ExportCollectionCliInvocation::from_args( + [ + "list", + "--host", + "127.0.0.1:18081", + "--origin", + ORIGIN, + "--consumer", + "lineageweave" + ], + "" + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + ExportCollectionCliInvocation::from_args(list_args(), "{}").unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + ExportCollectionCliInvocation::from_args( + [ + "list", + "--host", + "127.0.0.1:18081", + "--origin", + ORIGIN, + "--export-id", + "export-1" + ], + "" + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + let leftover = read_export_collection_cli_stdin(false, std::io::Cursor::new(b"leftover")) + .expect("leftover"); + assert_eq!(leftover, "leftover"); + assert!(read_export_collection_cli_stdin(true, std::io::empty()) + .expect("tty") + .is_empty()); + let exchange = naruon_export_collection_exchange(ORIGIN).expect("exchange"); + let posted = NaruonHttpExchange { + method: "POST", + target_url: exchange.target_url, + headers: exchange.headers, + body: exchange.body, + }; + assert_eq!( + loopback_http1_from_export_collection_exchange(&posted, "127.0.0.1:18081").unwrap_err(), + ApiError::InvalidWirePayload + ); + } +} diff --git a/crates/tepp_api/src/lib.rs b/crates/tepp_api/src/lib.rs index da946cef1..e87d10914 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_collection_cli; mod export_collection_http; mod export_http; mod lineage_criterion_anchor; @@ -123,6 +124,22 @@ pub use export_collection_http::EXPORT_COLLECTION_CURSOR_MAX_LEN; pub use export_collection_http::EXPORT_COLLECTION_DEFAULT_LIMIT; /// Maximum page size for export collection GET. pub use export_collection_http::EXPORT_COLLECTION_MAX_LIMIT; +/// Supported operator verbs for the loopback export-collection CLI. +pub use export_collection_cli::ExportCollectionCliVerb; +/// One operator CLI invocation against a loopback export-collection GET listener. +pub use export_collection_cli::ExportCollectionCliInvocation; +/// Compose one HTTP/1.1 collection GET from the typed naruon exchange. +pub use export_collection_cli::compose_export_collection_cli_http; +/// Dispatch one collection CLI invocation against an in-process listener. +pub use export_collection_cli::dispatch_export_collection_cli; +/// Execute one collection CLI invocation over loopback TCP. +pub use export_collection_cli::execute_export_collection_cli; +/// Render a typed export-collection exchange as HTTP/1.1 for a loopback listener. +pub use export_collection_cli::loopback_http1_from_export_collection_exchange; +/// Read stdin leftover bytes on a non-terminal; collection GET admits empty. +pub use export_collection_cli::read_export_collection_cli_stdin; +/// Filter CLI stdout so collection never prints scientific acceptance. +pub use export_collection_cli::render_export_collection_cli_stdout; /// Refuse scientific-metric keys on export-retrieval JSON. pub use export_http::refuse_metrics_on_export_retrieval_payload; diff --git a/crates/tepp_api/tests/export_collection_cli_contract.rs b/crates/tepp_api/tests/export_collection_cli_contract.rs new file mode 100644 index 000000000..ef8ccb23e --- /dev/null +++ b/crates/tepp_api/tests/export_collection_cli_contract.rs @@ -0,0 +1,116 @@ +//! Contract tests for `tepp-export-list list`. + +use tepp_api::{ + compose_export_collection_cli_http, dispatch_export_collection_cli, + execute_export_collection_cli, render_export_collection_cli_stdout, AnalysisRunLiveService, + AnalyticalPurpose, ApiError, ExportAuthorizationRequest, ExportCollection, + ExportCollectionCliInvocation, NARUON_CONSUMER_CODE, NARUON_EXPORT_PATH, NaruonLiveResponse, +}; + +const ORIGIN: &str = "https://tepp.example.test"; + +fn authorize_body() -> String { + let request = ExportAuthorizationRequest { + tenant_workspace_id: "export-cli-tenant".into(), + principal_id: "principal-analyst-1".into(), + purpose: AnalyticalPurpose::ModularServiceConsumer, + artifact_id: "artifact-cli-1".into(), + includes_source_text: false, + }; + serde_json::to_string(&request).expect("json") +} + +fn authorize_http(idem: &str) -> String { + let body = authorize_body(); + 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: {idem}\r\ncontent-length: {}\r\n\r\n{body}", + body.len() + ) +} + +fn list_invocation() -> ExportCollectionCliInvocation { + ExportCollectionCliInvocation::from_args( + [ + "list", + "--host", + "127.0.0.1:18081", + "--origin", + ORIGIN, + "--consumer", + NARUON_CONSUMER_CODE, + ], + "", + ) + .expect("list") +} + +#[test] +fn dispatch_lists_one_metric_free_identity() { + let mut service = AnalysisRunLiveService::new(); + assert_eq!( + service + .handle_http_request(&authorize_http("export-idem-1")) + .status_code, + 200 + ); + let listed = dispatch_export_collection_cli(&mut service, &list_invocation()).expect("list"); + assert_eq!(listed.status_code, 200, "{}", listed.body); + let stdout = render_export_collection_cli_stdout(&list_invocation(), &listed).expect("out"); + assert!(!stdout.contains("tepp.scientific_acceptance.v1")); + assert!(!stdout.contains("rmse")); + assert!(!stdout.contains("tenant_workspace_id")); + assert!(!stdout.contains("principal_id")); + assert!(!stdout.contains("includes_source_text")); + let page: ExportCollection = serde_json::from_str(&stdout).expect("page"); + assert_eq!(page.items.len(), 1); + assert_eq!(page.items[0].idempotency_key, "export-idem-1"); + assert_eq!(page.items[0].decision_code, "purpose_bound_export_allowed"); +} + +#[test] +fn render_refuses_metrics_schema_and_empty_bodies() { + let list = list_invocation(); + assert_eq!( + render_export_collection_cli_stdout( + &list, + &NaruonLiveResponse { + status_code: 200, + reason_phrase: "OK", + body: String::new(), + } + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + assert_eq!( + render_export_collection_cli_stdout( + &list, + &NaruonLiveResponse { + status_code: 200, + reason_phrase: "OK", + body: r#"{"items":[{"contract_version":1,"export_id":"e","artifact_id":"a","decision_code":"purpose_bound_export_allowed","purpose":"modular_service_consumer","idempotency_key":"k","rmse":1.0}]}"#.into(), + } + ) + .unwrap_err(), + ApiError::InvalidWirePayload + ); + let http = compose_export_collection_cli_http(&list).expect("http"); + assert!(http.starts_with("GET /v1/exports HTTP/1.1")); +} + +#[test] +fn execute_over_tcp_returns_empty_collection() { + let mut service = AnalysisRunLiveService::bind_loopback().expect("bind"); + let addr = service.local_addr().expect("addr"); + let handle = std::thread::spawn(move || { + drop(service.serve_one()); + }); + let mut invocation = list_invocation(); + invocation.host = addr.to_string(); + let response = execute_export_collection_cli(&invocation).expect("tcp"); + assert_eq!(response.status_code, 200, "{}", response.body); + let stdout = render_export_collection_cli_stdout(&invocation, &response).expect("out"); + let page: ExportCollection = serde_json::from_str(&stdout).expect("page"); + assert!(page.items.is_empty()); + handle.join().expect("join"); +} diff --git a/docs/API_CONTRACT.md b/docs/API_CONTRACT.md index 04c0bbb69..9ddcb82fe 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); `GET /v1/exports` enumerates those identities (ADR 0075); `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); `GET /v1/exports` enumerates those identities (ADR 0075); published `tepp-export-list list` mints that collection GET onto spawned `tepp-loopback` TCP (ADR 0076); `NaruonLiveService` stays POST-only. ## 2. Contract families diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 2bef6941c..43ce789f8 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/0054/0075 | `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; loopback `GET /v1/exports` enumerates authorized identities 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/0075/0076 | `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; loopback `GET /v1/exports` enumerates authorized identities; `tepp-export-list list` mints that collection GET onto spawned `tepp-loopback` TCP 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/0076-export-collection-cli.md b/docs/adr/0076-export-collection-cli.md new file mode 100644 index 000000000..e64806376 --- /dev/null +++ b/docs/adr/0076-export-collection-cli.md @@ -0,0 +1,100 @@ +# ADR 0076 — Loopback export collection CLI + +**Decision status:** Accepted +**Implementation maturity:** active-PR +**Date:** 2026-09-01 +**Supersedes:** None; complements ADR 0075 for operator-visible collection GET. +Does not supersede ADR 0014 claim-promotion authority. This ADR number is +unique versus protected main; live vs-main and sibling GAP-003A PRs already +occupy 0026–0075. + +## Context + +ADR 0075 enumerates authorized export identities on +`AnalysisRunLiveService`. Operators still had no published binary that mints +that GET onto spawned `tepp-loopback` TCP. Duplicating collection GET (#443), +export-retrieval CLI (#417), export retrieval GET (#411), export-authorize CLI +(#410), interpretation-run collection CLI (#436), Leiden, Driver p.16, or +GAP-010 Figma/export would collide with live PRs. LineageWeave is refused on +this naruon-owned adapter; `NaruonLiveService` stays POST-only. + +## Decision + +Publish `tepp-export-list list`: + +- Pattern: `from_args` + typed `naruon_export_collection_exchange` + + `loopback_http1_from_export_collection_exchange` + + `dispatch`/`execute`/`render` + published `[[bin]]`. +- Empty stdin is admitted. Nonempty leftover stdin fails closed. +- Public bind, `localhost` host, `http` origin, unpublished consumer, and + credential flags fail closed. +- Stdout is one metric-free collection page. Tenant, principal, source text, + RMSE, bias, coverage, SE-gate, and `tepp.scientific_acceptance.v1` never + appear. +- Dedicated binary so it does not collide with `tepp-export-get` (#417) or + `tepp-exports` (#410). + +## Alternatives considered + +1. **Reuse `tepp-export-get get`** — rejected; that CLI is GET-by-id (#417). +2. **Reuse `tepp-exports authorize`** — rejected; that CLI is POST (#410). +3. **Add GET collection to `NaruonLiveService`** — rejected; POST-only. +4. **Published `tepp-export-list list`** — accepted. + +## Consequences + +- Operators can enumerate authorized export identities without guessing + `export_id` and without a second collection GET PR. +- Collection JSON cannot be mistaken for a succeeded scientific-acceptance + result or a causal score. +- Collection success is not release evidence and is not an ADR 0014 claim. + +## Failure and recovery + +Non-`naruon` consumers, nonempty leftover stdin, present `idempotency-key`, +extra path segments, slash/NUL cursors, credential flags, public bind, and +metric keys fail closed. TCP execute does not fall back to an empty +in-process listener. + +## Security, privacy, scientific-integrity, and governance impact + +- No credential headers cross the consumer boundary. +- Tenant, principal, and source text stay off the collection page. +- HTTP 200 on collection is not measurement evidence and is not a causal + claim. + +## Compatibility and migration + +GET-by-id, collection GET, POST `/v1/exports`, and `NaruonLiveService` +POST-only remain unchanged. Persistence remains GAP-003B. + +## Verification + +Falsifiable evidence: + +- `tepp-export-list list` of authorized exports returns metric-free identities + without RMSE/bias/coverage/SE-gate/tenant/principal/source-text/ + `tepp.scientific_acceptance.v1` keys; +- LineageWeave, nonempty leftover stdin, present `idempotency-key`, extra + segments, public bind, `localhost`, `http` origin, and unknown keys fail + closed; +- `NaruonLiveService` still refuses GET; +- Clippy `-D warnings`, `tepp_api` tests, rustdoc, and exact-head review + remain required. + +## Rollback and supersession + +Rollback removes the published binary; collection GET remains valid. A +superseding ADR is required to persist the collection, bind a public address, +emit scientific-acceptance on collection, open LineageWeave, add GET to +`NaruonLiveService`, or treat collection success as an ADR 0014 claim. + +## Related authority + +- ADR 0075 owns loopback export collection GET. +- ADR 0054 owns loopback export retrieval GET. +- ADR 0055 owns the export-retrieval CLI (live #417). +- ADR 0026 owns the export-authorize CLI (live #410). +- ADR 0014 owns scientific claim promotion. +- RFC 9110 owns GET semantics (Fielding, Nottingham, & Reschke, 2022). It + does not authorize scientific claims. diff --git a/docs/adr/README.md b/docs/adr/README.md index 9153090dc..501f9086b 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -32,6 +32,7 @@ Read [`ADR_POLICY.md`](ADR_POLICY.md) first. **Decision status and implementatio | [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. | | [0075](0075-export-collection-get.md) | Loopback export collection GET | Accepted | active-PR | Complements ADR 0054; `GET /v1/exports` enumerates metric-free authorized identities. Unique versus protected main (0026–0074 occupied). `NaruonLiveService` stays POST-only. | +| [0076](0076-export-collection-cli.md) | Loopback export collection CLI | Accepted | active-PR | Complements ADR 0075; published `tepp-export-list list` mints naruon `GET /v1/exports` onto spawned `tepp-loopback` TCP. Unique versus protected main (0026–0075 occupied). | | [0023](0023-lineage-criterion-anchor-contract.md) | TEPP-owned Event Lineage criterion anchor | Accepted | active-PR | PR #237 publishes the strict accepted/rejected artifact and identities; estimator execution remains fail-closed future work. | | [0024](0024-independent-topic-importance-anchor.md) | Posterior topic-context producer contract | Accepted | contract-only active-PR | Strict DTO/schema only; the current estimator does not emit it. fast-mlsirm owns case-deletion influence. | | [0001](0001-rust-first-modular-msa.md) | Rust-first numerical core and CPU `f64` reference | Accepted | partial | ADR 0011 owns cross-service/MSA authority; 0001 retains numerical/backend authority. | diff --git a/docs/connectors/naruon-artifact-consumer.md b/docs/connectors/naruon-artifact-consumer.md index 90bea97d9..a4edac3a8 100644 --- a/docs/connectors/naruon-artifact-consumer.md +++ b/docs/connectors/naruon-artifact-consumer.md @@ -30,6 +30,7 @@ TEPP remains the scientific authority for estimation, recovery metrics, temporal | 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 collection | `tepp_api` `AnalysisRunLiveService` → `GET /v1/exports` | naruon → TEPP | +| Live loopback export collection CLI | `tepp-export-list list` → spawned `tepp-loopback` TCP `GET /v1/exports` | 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-collection-cli.md b/docs/research/export-collection-cli.md new file mode 100644 index 000000000..ea69664cc --- /dev/null +++ b/docs/research/export-collection-cli.md @@ -0,0 +1,55 @@ +# Export collection CLI (doctoring) + +## Scope + +`tepp-export-list list` is the operator-visible loopback CLI that mints a +typed naruon `GET /v1/exports` onto spawned `tepp-loopback` TCP. HTTP method, +path, and header semantics follow current HTTP semantics (Fielding, +Nottingham, & Reschke, 2022). Fail-closed refusal of unpublished consumers, +nonempty leftover stdin, present `idempotency-key`, extra path segments, +review/Copilot/GitHub credential flags, public bind, `localhost`, `http` +origin, and scientific-authority promotion is repository contract authority +(ADR 0076; ADR 0075; ADR 0014), not an RFC inference rule. + +Stdout is metric-free. Tenant, principal, source text, and +`tepp.scientific_acceptance.v1` never appear. HTTP 200 is not a completed +psychometric result, calibrated score, theta estimate, uncertainty statement, +causal inference, or scientific claim. + +## Authority + +### External standards (HTTP only) + +Fielding, R., Nottingham, M., & Reschke, J. (Eds.). (2022). *HTTP semantics* +(RFC 9110). IETF. https://doi.org/10.17487/RFC9110 + +RFC 9110 §9.3.1 describes GET as a method for retrieving a current +representation of the target resource. TEPP maps that retrieval onto a +bounded, in-memory page of metric-free export identities. The RFC does not +define psychometric acceptance, RMSE, causality, or claim promotion. + +### Internal contract evidence + +- `docs/adr/0076-export-collection-cli.md` — this CLI +- `docs/adr/0075-export-collection-get.md` — collection GET +- `docs/adr/0014-scientific-claim-promotion-and-release-evidence.md` — HTTP + 200 is not a scientific claim +- `crates/tepp_api/tests/export_collection_cli_contract.rs` — fail-closed + CLI proofs + +## Verification + +- `tepp-export-list list` of authorized naruon exports returns metric-free + identities without RMSE/bias/coverage/SE-gate keys, tenant, principal, + source text, or `tepp.scientific_acceptance.v1`; +- LineageWeave, nonempty leftover stdin, present `idempotency-key`, extra + path segments, slash/NUL cursors, public bind, `localhost`, and `http` + origin fail closed; +- `NaruonLiveService` still refuses GET. + +## Non-claims + +This slice does not implement GAP-010 Figma/export, analysis-run collection +CLI, interpretation-run collection CLI, persistence, production TLS, Leiden +consensus, provider execution, causal inference, or an ADR 0014 scientific +claim-promotion package.