From ebdf05ca6d7bdc49ba879ace7bf41eb720e3502c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 06:08:22 +0000 Subject: [PATCH 1/2] feat(api): retrieve interpretation runs via loopback GET-by-id GAP-003A unique slice stacked on collection GET: loopback GET /v1/interpretation-runs/{idempotency_key} returns one accepted metric-free hypothetical identity without POST replay. Naruon and LineageWeave refused. ADR 0071. --- .../interpretation-run-retrieval-http.md | 1 + DOCUMENTATION.md | 1 + crates/orchestrator_live/src/http.rs | 24 +- .../src/interpretation_run_retrieval_http.rs | 300 ++++++++++++++++++ crates/orchestrator_live/src/lib.rs | 64 ++-- crates/orchestrator_live/src/service.rs | 67 +++- ...erpretation_run_retrieval_http_contract.rs | 55 ++++ .../tests/live_http_contract.rs | 78 ++++- docs/API_CONTRACT.md | 3 +- docs/TRACEABILITY.md | 1 + .../0071-interpretation-run-retrieval-get.md | 106 +++++++ docs/adr/README.md | 1 + ...extual-orchestrator-interpretation-port.md | 3 +- .../interpretation-run-retrieval-http.md | 63 ++++ 14 files changed, 712 insertions(+), 55 deletions(-) create mode 100644 CHANGELOG.d/interpretation-run-retrieval-http.md create mode 100644 crates/orchestrator_live/src/interpretation_run_retrieval_http.rs create mode 100644 crates/orchestrator_live/tests/interpretation_run_retrieval_http_contract.rs create mode 100644 docs/adr/0071-interpretation-run-retrieval-get.md create mode 100644 docs/research/interpretation-run-retrieval-http.md diff --git a/CHANGELOG.d/interpretation-run-retrieval-http.md b/CHANGELOG.d/interpretation-run-retrieval-http.md new file mode 100644 index 000000000..033c65b45 --- /dev/null +++ b/CHANGELOG.d/interpretation-run-retrieval-http.md @@ -0,0 +1 @@ +- `orchestrator_live` loopback `GET /v1/interpretation-runs/{idempotency_key}` returns one accepted hypothetical interpretation-run identity on `tepp-orchestrator-loopback` without POST replay (ADR 0071). Metric-free identities only (`claim_status=hypothetical`, `scientific_authority=false`). `tepp.scientific_acceptance.v1` never appears. Does not infer causality. Naruon and LineageWeave are refused. Not collection GET, not collection CLI, not persistence. diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 7b3268308..e82530848 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -15,6 +15,7 @@ TEPP's approved PRD v0.4 and implementation plan are the primary product baselin | Orchestrator live HTTP doctoring | [`docs/research/orchestrator-live-http.md`](docs/research/orchestrator-live-http.md) | | Interpretation-run CLI doctoring | [`docs/research/interpretation-run-cli.md`](docs/research/interpretation-run-cli.md) | | Interpretation-run collection GET doctoring | [`docs/research/interpretation-run-collection-http.md`](docs/research/interpretation-run-collection-http.md) | +| Interpretation-run GET-by-id doctoring | [`docs/research/interpretation-run-retrieval-http.md`](docs/research/interpretation-run-retrieval-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/orchestrator_live/src/http.rs b/crates/orchestrator_live/src/http.rs index f95844d78..9135af4b0 100644 --- a/crates/orchestrator_live/src/http.rs +++ b/crates/orchestrator_live/src/http.rs @@ -5,7 +5,7 @@ use std::io::{Read, Write}; use crate::error::OrchestratorLiveError; use crate::request::{ - DEFAULT_INTERPRETATION_BYTE_LIMIT, host_implies_table_access, require_nonempty, + host_implies_table_access, require_nonempty, DEFAULT_INTERPRETATION_BYTE_LIMIT, }; /// Maximum request-line plus header bytes accepted before the body. @@ -211,6 +211,17 @@ pub(crate) fn refuse_collection_get_headers( Ok(()) } +/// GET-by-id admits empty bodies and refuses pagination plus `idempotency-key`. +pub(crate) fn refuse_retrieval_get_headers( + headers: &HashMap, +) -> Result<(), OrchestratorLiveError> { + refuse_collection_get_headers(headers)?; + if headers.contains_key("tepp-page-limit") || headers.contains_key("tepp-page-cursor") { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + Ok(()) +} + fn refuse_common_live_headers( headers: &HashMap, ) -> Result<(), OrchestratorLiveError> { @@ -280,8 +291,8 @@ pub(crate) fn status_for(error: OrchestratorLiveError) -> (u16, &'static str) { mod tests { use super::{ declared_content_length, header_is_credential, map_io_error, parse_headers, - parse_request_line, refuse_collection_get_headers, refuse_live_headers, split_header_line, - split_request, status_for, + parse_request_line, refuse_collection_get_headers, refuse_live_headers, + refuse_retrieval_get_headers, split_header_line, split_request, status_for, }; use crate::error::OrchestratorLiveError; use std::collections::HashMap; @@ -457,6 +468,13 @@ mod tests { ); headers.remove("idempotency-key"); refuse_collection_get_headers(&headers).expect("collection headers"); + refuse_retrieval_get_headers(&headers).expect("retrieval headers"); + headers.insert("tepp-page-limit".into(), "1".into()); + assert_eq!( + refuse_retrieval_get_headers(&headers), + Err(OrchestratorLiveError::InvalidWirePayload) + ); + headers.remove("tepp-page-limit"); headers.insert("tepp-consumer".into(), "naruon".into()); assert_eq!( refuse_collection_get_headers(&headers), diff --git a/crates/orchestrator_live/src/interpretation_run_retrieval_http.rs b/crates/orchestrator_live/src/interpretation_run_retrieval_http.rs new file mode 100644 index 000000000..ff445cae4 --- /dev/null +++ b/crates/orchestrator_live/src/interpretation_run_retrieval_http.rs @@ -0,0 +1,300 @@ +//! Provider-owned interpretation-run GET-by-id contracts. +//! +//! GAP-003A unique slice: `GET /v1/interpretation-runs/{idempotency_key}` +//! returns one accepted metric-free hypothetical identity on +//! `OrchestratorLiveService` / `tepp-orchestrator-loopback` so operators who +//! hold a collection identity do not replay POST. Collection rows stay +//! `claim_status=hypothetical` and `scientific_authority=false`. +//! `tepp.scientific_acceptance.v1` never appears. The retrieval does not infer +//! causality or call a model provider. This module does not duplicate +//! interpretation-run CLI (#425), collection GET (#433), collection CLI +//! (#436), project-history GET-by-id (#429), retrieval CLI (#431), +//! analysis-run GET-by-id (#359), Leiden, or GAP-010 Figma/export. +//! Persistence remains GAP-003B. Naruon and `LineageWeave` are refused. +//! `NaruonLiveService` stays POST-only. + +use crate::error::OrchestratorLiveError; +use crate::interpretation_run_cli::CONTEXTUAL_ORCHESTRATOR_CONSUMER_CODE; +use crate::interpretation_run_collection_http::{ + refuse_metrics_on_interpretation_run_collection_payload, InterpretationRunCollectionItem, + INTERPRETATION_RUN_COLLECTION_CURSOR_MAX_LEN, +}; +use crate::request::{ + host_implies_table_access, require_nonempty, to_json, INTERPRETATION_RUN_PATH, +}; + +/// Maximum opaque idempotency-key length on the retrieval path. +pub const INTERPRETATION_RUN_RETRIEVAL_ID_MAX_LEN: usize = + INTERPRETATION_RUN_COLLECTION_CURSOR_MAX_LEN; + +/// Typed GET exchange for interpretation-run GET-by-id. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct InterpretationRunRetrievalHttpExchange { + /// HTTP method, always `GET`. + pub method: &'static str, + /// Absolute HTTPS target ending in `/v1/interpretation-runs/{key}`. + pub target_url: String, + /// Exact version, consumer, and content headers. No credentials. + pub headers: Vec<(String, String)>, + /// GET body, always empty. + pub body: String, +} + +/// Extract the opaque idempotency key from `GET /v1/interpretation-runs/{key}`. +/// +/// # Errors +/// +/// Returns [`OrchestratorLiveError::InvalidWirePayload`] for the collection +/// path, extra segments, a hostile encoding, empty identity, slash, or NUL, +/// and [`OrchestratorLiveError::LimitExceeded`] when oversized. +pub fn interpretation_run_retrieval_path_id(path: &str) -> Result { + let remainder = path + .strip_prefix(INTERPRETATION_RUN_PATH) + .ok_or(OrchestratorLiveError::InvalidWirePayload)?; + let encoded = remainder + .strip_prefix('/') + .ok_or(OrchestratorLiveError::InvalidWirePayload)?; + if encoded.is_empty() || encoded.contains('/') { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + let idempotency_key = decode_path_segment(encoded)?; + require_nonempty(&idempotency_key)?; + if idempotency_key.contains('/') || idempotency_key.contains('\0') { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + if idempotency_key.len() > INTERPRETATION_RUN_RETRIEVAL_ID_MAX_LEN { + return Err(OrchestratorLiveError::LimitExceeded); + } + Ok(idempotency_key) +} + +/// Serialize one metric-free retrieval identity. +/// +/// # Errors +/// +/// Returns a validation or metric-key error. +pub fn interpretation_run_retrieval_item_json( + item: &InterpretationRunCollectionItem, +) -> Result { + let payload = to_json(item)?; + refuse_metrics_on_interpretation_run_collection_payload(&payload)?; + Ok(payload) +} + +/// Build a credential-free contextual-orchestrator GET-by-id exchange. +/// +/// # Errors +/// +/// Returns a fail-closed origin or identity error. +pub fn contextual_orchestrator_interpretation_run_retrieval_exchange( + origin: &str, + idempotency_key: &str, +) -> Result { + require_nonempty(origin)?; + if !origin.starts_with("https://") || origin.ends_with('/') { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + let rest = origin + .strip_prefix("https://") + .ok_or(OrchestratorLiveError::InvalidWirePayload)?; + if rest.contains('@') || rest.contains('?') || rest.contains('#') || rest.contains('\\') { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + if host_implies_table_access(rest) { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + require_nonempty(idempotency_key)?; + if idempotency_key.contains('/') || idempotency_key.contains('\0') { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + if idempotency_key.len() > INTERPRETATION_RUN_RETRIEVAL_ID_MAX_LEN { + return Err(OrchestratorLiveError::LimitExceeded); + } + let encoded_id = encode_path_segment(idempotency_key); + Ok(InterpretationRunRetrievalHttpExchange { + method: "GET", + target_url: format!("{origin}{INTERPRETATION_RUN_PATH}/{encoded_id}"), + headers: vec![ + ("content-type".into(), "application/json".into()), + ( + "tepp-consumer".into(), + CONTEXTUAL_ORCHESTRATOR_CONSUMER_CODE.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()); + 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); + } + _ => { + let hex = b"0123456789ABCDEF"; + 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(OrchestratorLiveError::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(OrchestratorLiveError::InvalidWirePayload), + } + } + let decoded = String::from_utf8(out).map_err(|_| OrchestratorLiveError::InvalidWirePayload)?; + if decoded.chars().any(char::is_control) { + return Err(OrchestratorLiveError::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(OrchestratorLiveError::InvalidWirePayload), + } +} + +#[cfg(test)] +mod tests { + use super::{ + contextual_orchestrator_interpretation_run_retrieval_exchange, + interpretation_run_retrieval_item_json, interpretation_run_retrieval_path_id, + INTERPRETATION_RUN_RETRIEVAL_ID_MAX_LEN, + }; + use crate::error::OrchestratorLiveError; + use crate::interpretation_run_collection_http::InterpretationRunCollectionItem; + use crate::mode::OrchestrationMode; + use crate::request::INTERPRETATION_RUN_PATH; + + #[test] + fn retrieval_exchange_is_metric_free_get_without_credentials() { + let exchange = contextual_orchestrator_interpretation_run_retrieval_exchange( + "https://tepp.example.test", + "idem-a", + ) + .expect("exchange"); + assert_eq!(exchange.method, "GET"); + assert!(exchange + .target_url + .ends_with("/v1/interpretation-runs/idem-a")); + 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!( + interpretation_run_retrieval_path_id("/v1/interpretation-runs/idem-a").expect("id"), + "idem-a" + ); + let item = InterpretationRunCollectionItem::new( + "orch-run-1", + "idem-a", + OrchestrationMode::Direct, + "hypothetical", + false, + ) + .expect("item"); + let json = interpretation_run_retrieval_item_json(&item).expect("json"); + assert!(!json.contains("rmse")); + assert!(!json.contains("evidence_span_ids")); + assert!(!json.contains("tepp.scientific_acceptance.v1")); + assert!(json.contains("\"claim_status\":\"hypothetical\"")); + assert_eq!(INTERPRETATION_RUN_PATH, "/v1/interpretation-runs"); + } + + #[test] + fn retrieval_path_and_origins_fail_closed() { + assert_eq!( + interpretation_run_retrieval_path_id("/v1/interpretation-runs"), + Err(OrchestratorLiveError::InvalidWirePayload) + ); + assert_eq!( + interpretation_run_retrieval_path_id("/v1/interpretation-runs/"), + Err(OrchestratorLiveError::InvalidWirePayload) + ); + assert_eq!( + interpretation_run_retrieval_path_id("/v1/interpretation-runs/idem-a/extra"), + Err(OrchestratorLiveError::InvalidWirePayload) + ); + assert_eq!( + interpretation_run_retrieval_path_id("/v1/analysis-runs/idem-a"), + Err(OrchestratorLiveError::InvalidWirePayload) + ); + assert_eq!( + interpretation_run_retrieval_path_id("/v1/interpretation-runs/idem%2Fslash"), + Err(OrchestratorLiveError::InvalidWirePayload) + ); + assert_eq!( + interpretation_run_retrieval_path_id("/v1/interpretation-runs/%00"), + Err(OrchestratorLiveError::InvalidWirePayload) + ); + assert_eq!( + interpretation_run_retrieval_path_id(&format!( + "/v1/interpretation-runs/{}", + "a".repeat(INTERPRETATION_RUN_RETRIEVAL_ID_MAX_LEN + 1) + )), + Err(OrchestratorLiveError::LimitExceeded) + ); + assert_eq!( + contextual_orchestrator_interpretation_run_retrieval_exchange( + "http://insecure.example", + "idem-a", + ), + Err(OrchestratorLiveError::InvalidWirePayload) + ); + assert_eq!( + contextual_orchestrator_interpretation_run_retrieval_exchange( + "https://postgres.example.test", + "idem-a", + ), + Err(OrchestratorLiveError::InvalidWirePayload) + ); + assert_eq!( + contextual_orchestrator_interpretation_run_retrieval_exchange( + "https://tepp.example.test", + "idem/slash", + ), + Err(OrchestratorLiveError::InvalidWirePayload) + ); + assert_eq!( + contextual_orchestrator_interpretation_run_retrieval_exchange( + "https://tepp.example.test", + "", + ), + Err(OrchestratorLiveError::InvalidWirePayload) + ); + assert_eq!( + interpretation_run_retrieval_path_id("/v1/interpretation-runs/%zz"), + Err(OrchestratorLiveError::InvalidWirePayload) + ); + } +} diff --git a/crates/orchestrator_live/src/lib.rs b/crates/orchestrator_live/src/lib.rs index d555f1025..260f3c2db 100644 --- a/crates/orchestrator_live/src/lib.rs +++ b/crates/orchestrator_live/src/lib.rs @@ -6,6 +6,7 @@ //! `GET /v1/interpretation-runs` on loopback only. Accepted output is always //! hypothetical and never scientific authority. Collection GET enumerates //! metric-free identities so operators do not guess idempotency keys. +//! GET-by-id returns one of those identities without POST replay. //! Table-access hosts, review/Copilot/GitHub credentials, and //! `COPILOT_GITHUB_TOKEN` fail closed. This crate does not implement TLS //! termination or call a model provider (ADR 0010; ADR 0011). The published @@ -17,26 +18,19 @@ mod error; mod http; mod interpretation_run_cli; mod interpretation_run_collection_http; +mod interpretation_run_retrieval_http; mod mode; mod request; mod service; /// Fail-closed orchestrator live-listener errors. pub use error::OrchestratorLiveError; +/// Loopback live HTTP/1.1 response. +pub use http::OrchestratorLiveResponse; /// Maximum live HTTP header-block size in bytes. pub use http::LIVE_HEADER_BYTE_LIMIT; /// Maximum live HTTP header count. pub use http::LIVE_HEADER_COUNT_LIMIT; -/// Loopback live HTTP/1.1 response. -pub use http::OrchestratorLiveResponse; -/// Published modular consumer for interpretation-run POST. -pub use interpretation_run_cli::CONTEXTUAL_ORCHESTRATOR_CONSUMER_CODE; -/// Loopback interpretation-run CLI invocation. -pub use interpretation_run_cli::InterpretationRunCliInvocation; -/// Loopback interpretation-run CLI verb. -pub use interpretation_run_cli::InterpretationRunCliVerb; -/// Typed HTTPS interpretation-run exchange. -pub use interpretation_run_cli::InterpretationRunHttpExchange; /// Compose HTTP/1.1 interpretation-run POST from a CLI invocation. pub use interpretation_run_cli::compose_interpretation_run_cli_http; /// Build a credential-free contextual-orchestrator interpretation-run exchange. @@ -53,18 +47,14 @@ pub use interpretation_run_cli::read_interpretation_run_cli_stdin; pub use interpretation_run_cli::refuse_metrics_on_interpretation_run_cli_payload; /// Filter interpretation-run CLI stdout so the accepted run stays hypothetical. pub use interpretation_run_cli::render_interpretation_run_cli_stdout; -/// Maximum opaque cursor length on interpretation-run collection GET. -pub use interpretation_run_collection_http::INTERPRETATION_RUN_COLLECTION_CURSOR_MAX_LEN; -/// Default page size for interpretation-run collection GET. -pub use interpretation_run_collection_http::INTERPRETATION_RUN_COLLECTION_DEFAULT_LIMIT; -/// Maximum page size for interpretation-run collection GET. -pub use interpretation_run_collection_http::INTERPRETATION_RUN_COLLECTION_MAX_LIMIT; -/// Metric-free interpretation-run collection page. -pub use interpretation_run_collection_http::InterpretationRunCollection; -/// Typed GET exchange for interpretation-run collection. -pub use interpretation_run_collection_http::InterpretationRunCollectionHttpExchange; -/// One metric-free interpretation-run collection row. -pub use interpretation_run_collection_http::InterpretationRunCollectionItem; +/// Loopback interpretation-run CLI invocation. +pub use interpretation_run_cli::InterpretationRunCliInvocation; +/// Loopback interpretation-run CLI verb. +pub use interpretation_run_cli::InterpretationRunCliVerb; +/// Typed HTTPS interpretation-run exchange. +pub use interpretation_run_cli::InterpretationRunHttpExchange; +/// Published modular consumer for interpretation-run POST. +pub use interpretation_run_cli::CONTEXTUAL_ORCHESTRATOR_CONSUMER_CODE; /// Build a credential-free contextual-orchestrator collection GET exchange. pub use interpretation_run_collection_http::contextual_orchestrator_interpretation_run_collection_exchange; /// Whether a path is the interpretation-run collection resource. @@ -77,8 +67,34 @@ pub use interpretation_run_collection_http::parse_interpretation_run_collection_ pub use interpretation_run_collection_http::parse_interpretation_run_collection_page_limit; /// Refuse metric, evidence, and causal-score keys on collection JSON. pub use interpretation_run_collection_http::refuse_metrics_on_interpretation_run_collection_payload; +/// Metric-free interpretation-run collection page. +pub use interpretation_run_collection_http::InterpretationRunCollection; +/// Typed GET exchange for interpretation-run collection. +pub use interpretation_run_collection_http::InterpretationRunCollectionHttpExchange; +/// One metric-free interpretation-run collection row. +pub use interpretation_run_collection_http::InterpretationRunCollectionItem; +/// Maximum opaque cursor length on interpretation-run collection GET. +pub use interpretation_run_collection_http::INTERPRETATION_RUN_COLLECTION_CURSOR_MAX_LEN; +/// Default page size for interpretation-run collection GET. +pub use interpretation_run_collection_http::INTERPRETATION_RUN_COLLECTION_DEFAULT_LIMIT; +/// Maximum page size for interpretation-run collection GET. +pub use interpretation_run_collection_http::INTERPRETATION_RUN_COLLECTION_MAX_LIMIT; +/// Build a credential-free contextual-orchestrator GET-by-id exchange. +pub use interpretation_run_retrieval_http::contextual_orchestrator_interpretation_run_retrieval_exchange; +/// Serialize one metric-free GET-by-id identity. +pub use interpretation_run_retrieval_http::interpretation_run_retrieval_item_json; +/// Extract the opaque idempotency key from a GET-by-id path. +pub use interpretation_run_retrieval_http::interpretation_run_retrieval_path_id; +/// Typed GET exchange for interpretation-run GET-by-id. +pub use interpretation_run_retrieval_http::InterpretationRunRetrievalHttpExchange; +/// Maximum opaque idempotency-key length on interpretation-run GET-by-id. +pub use interpretation_run_retrieval_http::INTERPRETATION_RUN_RETRIEVAL_ID_MAX_LEN; /// Closed ADR 0010 orchestration-mode vocabulary. pub use mode::OrchestrationMode; +/// Accepted hypothetical interpretation-run response. +pub use request::InterpretationRunAccepted; +/// Interpretation-run create request. +pub use request::InterpretationRunRequest; /// Default maximum interpretation-run JSON payload size in bytes. pub use request::DEFAULT_INTERPRETATION_BYTE_LIMIT; /// Canonical hypothetical claim-status label. @@ -87,9 +103,5 @@ pub use request::HYPOTHETICAL_CLAIM_STATUS; pub use request::INTERPRETATION_RUN_CONTRACT_VERSION; /// Versioned path contextual-orchestrator may POST or GET. pub use request::INTERPRETATION_RUN_PATH; -/// Accepted hypothetical interpretation-run response. -pub use request::InterpretationRunAccepted; -/// Interpretation-run create request. -pub use request::InterpretationRunRequest; /// Loopback live HTTP/1.1 orchestrator listener. pub use service::OrchestratorLiveService; diff --git a/crates/orchestrator_live/src/service.rs b/crates/orchestrator_live/src/service.rs index db39dcfd1..969fa0349 100644 --- a/crates/orchestrator_live/src/service.rs +++ b/crates/orchestrator_live/src/service.rs @@ -5,18 +5,21 @@ use std::net::{SocketAddr, TcpListener, TcpStream}; use crate::error::OrchestratorLiveError; use crate::http::{ - OrchestratorLiveResponse, header_value, map_io_error, parse_headers, parse_request_line, - read_http_request, refuse_collection_get_headers, refuse_live_headers, split_request, - status_for, write_response, + header_value, map_io_error, parse_headers, parse_request_line, read_http_request, + refuse_collection_get_headers, refuse_live_headers, refuse_retrieval_get_headers, + split_request, status_for, write_response, OrchestratorLiveResponse, }; use crate::interpretation_run_collection_http::{ - InterpretationRunCollection, InterpretationRunCollectionItem, is_interpretation_run_collection_path, page_interpretation_run_collection_items, parse_interpretation_run_collection_page_cursor, - parse_interpretation_run_collection_page_limit, + parse_interpretation_run_collection_page_limit, InterpretationRunCollection, + InterpretationRunCollectionItem, +}; +use crate::interpretation_run_retrieval_http::{ + interpretation_run_retrieval_item_json, interpretation_run_retrieval_path_id, }; use crate::request::{ - INTERPRETATION_RUN_PATH, InterpretationRunAccepted, InterpretationRunRequest, to_json, + to_json, InterpretationRunAccepted, InterpretationRunRequest, INTERPRETATION_RUN_PATH, }; /// Loopback live HTTP/1.1 service for contextual-orchestrator interpretation POSTs. @@ -25,7 +28,8 @@ use crate::request::{ /// loopback TCP so tests and standalone operation can prove request handling /// without TLS termination, table access, or scientific-authority promotion. /// `GET /v1/interpretation-runs` enumerates accepted hypothetical runs as -/// metric-free identities. +/// metric-free identities. `GET /v1/interpretation-runs/{idempotency_key}` +/// returns one of those identities without POST replay. #[derive(Debug)] pub struct OrchestratorLiveService { listener: Option, @@ -173,7 +177,10 @@ impl OrchestratorLiveService { let (method, path) = parse_request_line(request_line)?; let headers = parse_headers(lines)?; if method == "GET" { - return self.list_interpretation_runs(path, &headers, body); + if is_interpretation_run_collection_path(path) { + return self.list_interpretation_runs(path, &headers, body); + } + return self.get_interpretation_run(path, &headers, body); } if method != "POST" || path != INTERPRETATION_RUN_PATH { return Err(OrchestratorLiveError::InvalidWirePayload); @@ -224,6 +231,36 @@ impl OrchestratorLiveService { )) } + fn get_interpretation_run( + &self, + path: &str, + headers: &HashMap, + body: &str, + ) -> Result { + let idempotency_key = interpretation_run_retrieval_path_id(path)?; + if !body.is_empty() { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + refuse_retrieval_get_headers(headers)?; + let accepted = self + .accepted_runs + .get(&idempotency_key) + .map(|(_, accepted)| accepted) + .ok_or(OrchestratorLiveError::InvalidWirePayload)?; + let item = InterpretationRunCollectionItem::new( + accepted.interpretation_run_id(), + accepted.idempotency_key(), + accepted.orchestration_mode(), + accepted.claim_status(), + accepted.scientific_authority(), + )?; + Ok(OrchestratorLiveResponse::json( + 200, + "OK", + interpretation_run_retrieval_item_json(&item)?, + )) + } + fn accept_interpretation_run( &mut self, headers: &HashMap, @@ -295,7 +332,7 @@ struct ErrorWire { #[cfg(test)] mod tests { - use super::{OrchestratorLiveService, envelope_json, fallback_envelope_json}; + use super::{envelope_json, fallback_envelope_json, OrchestratorLiveService}; use crate::error::OrchestratorLiveError; #[test] @@ -309,13 +346,11 @@ mod tests { envelope_json(OrchestratorLiveError::LimitExceeded, "req-1".into()) .contains("limit_exceeded") ); - assert!( - envelope_json( - OrchestratorLiveError::ScientificAuthorityRefused, - "req-2".into() - ) - .contains("scientific_authority_refused") - ); + assert!(envelope_json( + OrchestratorLiveError::ScientificAuthorityRefused, + "req-2".into() + ) + .contains("scientific_authority_refused")); assert_eq!( OrchestratorLiveService::new() .serve_accepted(Err(std::io::Error::other("accept"))) diff --git a/crates/orchestrator_live/tests/interpretation_run_retrieval_http_contract.rs b/crates/orchestrator_live/tests/interpretation_run_retrieval_http_contract.rs new file mode 100644 index 000000000..67c3c2b02 --- /dev/null +++ b/crates/orchestrator_live/tests/interpretation_run_retrieval_http_contract.rs @@ -0,0 +1,55 @@ +//! Contract tests for contextual-orchestrator interpretation-run GET-by-id. + +use orchestrator_live::{ + contextual_orchestrator_interpretation_run_retrieval_exchange, + interpretation_run_retrieval_path_id, OrchestratorLiveError, + CONTEXTUAL_ORCHESTRATOR_CONSUMER_CODE, +}; + +#[test] +fn interpretation_run_retrieval_is_metric_free_get_without_credentials() { + let exchange = contextual_orchestrator_interpretation_run_retrieval_exchange( + "https://tepp.example.test", + "orch-live-idem-001", + ) + .expect("exchange"); + assert_eq!(exchange.method, "GET"); + assert!(exchange + .target_url + .ends_with("/v1/interpretation-runs/orch-live-idem-001")); + assert!(exchange.body.is_empty()); + assert!(exchange + .headers + .iter() + .any(|(name, value)| name == "tepp-consumer" + && value == CONTEXTUAL_ORCHESTRATOR_CONSUMER_CODE)); + assert!(!exchange + .headers + .iter() + .any(|(name, _)| name.eq_ignore_ascii_case("authorization") + || name.eq_ignore_ascii_case("idempotency-key"))); + assert_eq!( + interpretation_run_retrieval_path_id("/v1/interpretation-runs/orch-live-idem-001") + .expect("id"), + "orch-live-idem-001" + ); +} + +#[test] +fn interpretation_run_retrieval_refuses_collection_path_extra_segments_and_insecure_origins() { + assert_eq!( + interpretation_run_retrieval_path_id("/v1/interpretation-runs"), + Err(OrchestratorLiveError::InvalidWirePayload) + ); + assert_eq!( + interpretation_run_retrieval_path_id("/v1/interpretation-runs/idem-a/extra"), + Err(OrchestratorLiveError::InvalidWirePayload) + ); + assert_eq!( + contextual_orchestrator_interpretation_run_retrieval_exchange( + "http://tepp.example.test", + "idem-a" + ), + Err(OrchestratorLiveError::InvalidWirePayload) + ); +} diff --git a/crates/orchestrator_live/tests/live_http_contract.rs b/crates/orchestrator_live/tests/live_http_contract.rs index 5a60de060..ed4cde62e 100644 --- a/crates/orchestrator_live/tests/live_http_contract.rs +++ b/crates/orchestrator_live/tests/live_http_contract.rs @@ -7,10 +7,10 @@ use std::thread; use std::time::Duration; use orchestrator_live::{ + InterpretationRunAccepted, InterpretationRunCollection, InterpretationRunRequest, + OrchestrationMode, OrchestratorLiveError, OrchestratorLiveService, DEFAULT_INTERPRETATION_BYTE_LIMIT, INTERPRETATION_RUN_CONTRACT_VERSION, - INTERPRETATION_RUN_PATH, InterpretationRunAccepted, InterpretationRunCollection, - InterpretationRunRequest, LIVE_HEADER_BYTE_LIMIT, LIVE_HEADER_COUNT_LIMIT, OrchestrationMode, - OrchestratorLiveError, OrchestratorLiveService, + INTERPRETATION_RUN_PATH, LIVE_HEADER_BYTE_LIMIT, LIVE_HEADER_COUNT_LIMIT, }; fn sample_request() -> InterpretationRunRequest { @@ -355,11 +355,10 @@ fn handle_http_enumerates_interpretation_runs_on_collection_get() { assert_eq!(page.items.len(), 2); assert_eq!(page.items[0].idempotency_key, "orch-live-idem-001"); assert_eq!(page.items[1].idempotency_key, "orch-live-idem-002"); - assert!( - page.items - .iter() - .all(|item| item.claim_status == "hypothetical") - ); + assert!(page + .items + .iter() + .all(|item| item.claim_status == "hypothetical")); assert!(page.items.iter().all(|item| !item.scientific_authority)); assert!(!listed.body.contains("rmse")); assert!(!listed.body.contains("evidence_span_ids")); @@ -397,6 +396,69 @@ fn handle_http_enumerates_interpretation_runs_on_collection_get() { assert_eq!(rest_page.next_cursor, None); } +#[test] +fn handle_http_retrieves_one_interpretation_run_on_get_by_id() { + let mut service = OrchestratorLiveService::new(); + let first = sample_request(); + assert_eq!( + service + .handle_http_request(&interpretation_http(&first)) + .status_code, + 202 + ); + let got = service.handle_http_request(&http_request( + "GET", + "/v1/interpretation-runs/orch-live-idem-001", + &collection_headers(), + "", + )); + assert_eq!(got.status_code, 200, "{}", got.body); + assert!(got + .body + .contains("\"idempotency_key\":\"orch-live-idem-001\"")); + assert!(got.body.contains("\"claim_status\":\"hypothetical\"")); + assert!(got.body.contains("\"scientific_authority\":false")); + assert!(!got.body.contains("rmse")); + assert!(!got.body.contains("evidence_span_ids")); + assert!(!got.body.contains("tenant_workspace_id")); + assert!(!got.body.contains("tepp.scientific_acceptance.v1")); + assert_eq!( + service + .handle_http_request(&http_request( + "GET", + "/v1/interpretation-runs/missing-key", + &collection_headers(), + "", + )) + .status_code, + 400 + ); + let mut paged = collection_headers(); + paged.push(("tepp-page-limit".into(), "1".into())); + assert_eq!( + service + .handle_http_request(&http_request( + "GET", + "/v1/interpretation-runs/orch-live-idem-001", + &paged, + "", + )) + .status_code, + 400 + ); + assert_eq!( + service + .handle_http_request(&http_request( + "GET", + "/v1/interpretation-runs/orch-live-idem-001/extra", + &collection_headers(), + "", + )) + .status_code, + 400 + ); +} + #[test] fn handle_http_collection_get_refuses_foreign_consumers_and_hostile_headers() { let mut service = OrchestratorLiveService::new(); diff --git a/docs/API_CONTRACT.md b/docs/API_CONTRACT.md index 6fa77f135..3698a7377 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; export retrieval stays a target shape until an executable export route ships. Loopback `tepp-interpretation-runs create` is the operator-visible client for `POST /v1/interpretation-runs` on `tepp-orchestrator-loopback` (ADR 0064); stdout stays metric-free with `claim_status` `hypothetical` and `scientific_authority` false. Loopback `GET /v1/interpretation-runs` enumerates those accepted hypothetical runs as metric-free identities (ADR 0069); naruon and LineageWeave stay refused. +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. Loopback `tepp-interpretation-runs create` is the operator-visible client for `POST /v1/interpretation-runs` on `tepp-orchestrator-loopback` (ADR 0064); stdout stays metric-free with `claim_status` `hypothetical` and `scientific_authority` false. Loopback `GET /v1/interpretation-runs` enumerates those accepted hypothetical runs as metric-free identities (ADR 0069); `GET /v1/interpretation-runs/{idempotency_key}` returns one identity without POST replay (ADR 0071); naruon and LineageWeave stay refused. ## 2. Contract families @@ -64,6 +64,7 @@ POST /v1/evidence-imports GET /v1/evidence-imports/{import_id} POST /v1/interpretation-runs GET /v1/interpretation-runs +GET /v1/interpretation-runs/{idempotency_key} POST /v1/analysis-runs POST /v1/temporal-context GET /v1/analysis-runs/{run_id} diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index caa3ad5f2..4f128ab0f 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -108,6 +108,7 @@ The full APA 7th standards/literature register remains `docs/research/standards- | contextual-orchestrator live execution boundary | ADR 0010/0011 | loopback listener records mode/budget and refuses scientific authority; provider execution remains accepted-target | partial | | loopback contextual-orchestrator interpretation-run CLI | ADR 0064; ADR 0010/0011; API contract; RFC 9110 | `orchestrator_live` `tepp-interpretation-runs create` CLI against `tepp-orchestrator-loopback` (`POST /v1/interpretation-runs`); metric-free hypothetical JSON; `tepp.scientific_acceptance.v1` never appears; does not infer causality; naruon and LineageWeave refused | active-PR | | loopback contextual-orchestrator interpretation-run collection GET | ADR 0069; ADR 0010/0011; API contract; RFC 9110 | `orchestrator_live` `GET /v1/interpretation-runs` on `tepp-orchestrator-loopback`; metric-free hypothetical identities; empty body; no `idempotency-key`; `tepp.scientific_acceptance.v1` never appears; does not infer causality; naruon and LineageWeave refused | active-PR | +| loopback contextual-orchestrator interpretation-run GET-by-id | ADR 0071; ADR 0069; ADR 0010/0011; API contract; RFC 9110 | `orchestrator_live` `GET /v1/interpretation-runs/{idempotency_key}` on `tepp-orchestrator-loopback`; metric-free hypothetical identity without POST replay; empty body; no pagination; `tepp.scientific_acceptance.v1` never appears; does not infer causality; naruon and LineageWeave refused | active-PR | | foundation validation / release-readiness ledger | ADR 0014; Test Strategy | PR #24 `docs/validation/temporal-event-foundation.md` on protected main | implemented-main | | scientific claim promotion separated from design/implementation/release | ADR 0014; ADR policy | `validation_core` exact-head promotion gates on this PR; documentation/CI/domain validation remain; full package/image release bundle remaining | partial | | CSAP/SOC 2/ISO/NIST assurance readiness | `docs/COMPLIANCE_READINESS.md`; research register | repository controls + future deployment evidence | accepted-target / deployment-owned | diff --git a/docs/adr/0071-interpretation-run-retrieval-get.md b/docs/adr/0071-interpretation-run-retrieval-get.md new file mode 100644 index 000000000..01abb5c79 --- /dev/null +++ b/docs/adr/0071-interpretation-run-retrieval-get.md @@ -0,0 +1,106 @@ +# ADR 0071 — Contextual-orchestrator interpretation-run GET-by-id + +**Decision status:** Accepted +**Implementation maturity:** active-PR +**Date:** 2026-09-01 +**Supersedes:** None; complements ADR 0069 for retrieving one accepted identity. Does not supersede ADR 0014 claim-promotion authority. This ADR number is unique on this interpretation stack versus protected main; live vs-main and sibling GAP-003A PRs already occupy 0026–0070. + +## Context + +ADR 0069 enumerates accepted hypothetical interpretation runs as metric-free +identities, and #436 publishes a collection CLI. Operators who hold an +`idempotency_key` from that page still had to replay +`POST /v1/interpretation-runs` to recover the stored identity. Duplicating +interpretation-run CLI (#425), collection GET (#433), collection CLI (#436), +project-history GET-by-id (#429), retrieval CLI (#431), analysis-run GET-by-id +(#359), Leiden, Driver p.16, or GAP-010 Figma/export would collide with live +PRs. Naruon and `LineageWeave` are refused on this orchestrator-owned adapter; +`NaruonLiveService` stays POST-only. + +## Decision + +`orchestrator_live` publishes loopback-only +`GET /v1/interpretation-runs/{idempotency_key}` on `tepp-orchestrator-loopback`: + +- Consumer is `contextual-orchestrator` only. Empty body. The identity travels + in the path. `idempotency-key` and collection pagination headers are refused. +- Extra extra-segments, slash, NUL, and oversized identities fail closed. +- The response is the stored metric-free identity: `interpretation_run_id`, + `idempotency_key`, `orchestration_mode`, `claim_status=hypothetical`, + `scientific_authority=false`. +- `tepp.scientific_acceptance.v1`, RMSE, bias, coverage, SE-gate, + `evidence_span_ids`, `tenant_workspace_id`, `compute_budget_tokens`, + `findings`, and `causal_score` never appear. +- Collection GET (`GET /v1/interpretation-runs` with no extra segment) is + unchanged. +- The retrieval does not infer causality, mutate TEPP state, or return a + completed psychometric result. +- This slice does not implement a retrieval CLI or persistence. + +## Alternatives considered + +1. **Keep POST replay as the only retrieval path** — rejected because operators + still resubmit evidence after ADR 0069. +2. **Return the full accepted POST body** — rejected; GET-by-id stays a + metric-free identity without tenant, budget, or evidence spans. +3. **Reuse analysis-run GET-by-id (#359) or project-history GET-by-id (#429)** — + rejected; those are different live resources. +4. **Loopback `GET /v1/interpretation-runs/{idempotency_key}`** — accepted. + +## Consequences + +- Operators can retrieve one accepted hypothetical identity from a collection + key without writing a second POST. +- Retrieval JSON cannot be mistaken for a succeeded scientific-acceptance + result or a causal score. +- Retrieval success is not release evidence and is not an ADR 0014 claim. + +## Failure and recovery + +Non-`contextual-orchestrator` consumers, nonempty GET bodies, present +`idempotency-key`, pagination headers, extra path segments, slash/NUL keys, +credential flags, missing identities, 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. +- Evidence spans, tenant, and budget stay off the retrieval body. +- HTTP 200 on GET-by-id is not measurement evidence and is not a causal claim. + +## Compatibility and migration + +Collection GET, POST `/v1/interpretation-runs`, and +`tepp-interpretation-runs create`/`list` remain unchanged. A retrieval CLI +remains a later slice. + +## Verification + +Falsifiable evidence: + +- GET of an accepted identity returns `hypothetical` / + `scientific_authority=false` without RMSE/bias/coverage/SE-gate/ + evidence/`causal_score`/`tepp.scientific_acceptance.v1` keys; +- missing identity, extra segments, naruon or LineageWeave, nonempty body, + pagination headers, and unknown keys fail closed; +- Clippy `-D warnings`, `orchestrator_live` tests, rustdoc, and exact-head + review remain required. + +## Rollback and supersession + +Rollback removes GET-by-id; collection GET and POST remain valid. A +superseding ADR is required to persist the registry, bind a public address, +emit scientific-acceptance on retrieval, open naruon or LineageWeave, or treat +retrieval success as an ADR 0014 claim. + +## Related authority + +- ADR 0069 owns loopback interpretation-run collection GET. +- ADR 0070 owns the collection CLI (live #436). +- ADR 0064 owns the interpretation-run POST CLI (live #425). +- ADR 0010 owns orchestration mode vocabulary and scientific-authority + separation. +- ADR 0011 owns standalone/modular HTTP boundaries. +- ADR 0014 owns scientific claim promotion. +- RFC 9110 owns GET semantics (Fielding, Nottingham, & Reschke, 2022). It + does not authorize scientific claims. diff --git a/docs/adr/README.md b/docs/adr/README.md index a5aaa4627..4002ba826 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. | | [0064](0064-interpretation-run-cli.md) | Loopback `tepp-interpretation-runs create` is contextual-orchestrator POST /v1/interpretation-runs client | Accepted | active-PR | Complements ADR 0010/0011; does not supersede ADR 0014. Unique on protected main. Does not infer causality. | | [0069](0069-interpretation-run-collection-get.md) | Loopback `GET /v1/interpretation-runs` enumerates accepted hypothetical interpretation runs | Accepted | active-PR | Complements ADR 0010/0011/0064; does not supersede ADR 0014. Unique on this stack versus protected main (0026–0068 occupied). Does not infer causality. | +| [0071](0071-interpretation-run-retrieval-get.md) | Loopback `GET /v1/interpretation-runs/{idempotency_key}` returns one accepted hypothetical identity | Accepted | active-PR | Complements ADR 0069; does not supersede ADR 0014. Unique on this interpretation stack versus protected main (0026–0070 occupied). Does not infer causality. | | [0023](0023-lineage-criterion-anchor-contract.md) | TEPP-owned Event Lineage criterion anchor | Accepted | active-PR | PR #237 publishes the strict accepted/rejected artifact and identities; estimator execution remains fail-closed future work. | | [0024](0024-independent-topic-importance-anchor.md) | Posterior topic-context producer contract | Accepted | contract-only active-PR | Strict DTO/schema only; the current estimator does not emit it. fast-mlsirm owns case-deletion influence. | | [0001](0001-rust-first-modular-msa.md) | Rust-first numerical core and CPU `f64` reference | Accepted | partial | ADR 0011 owns cross-service/MSA authority; 0001 retains numerical/backend authority. | diff --git a/docs/connectors/contextual-orchestrator-interpretation-port.md b/docs/connectors/contextual-orchestrator-interpretation-port.md index 529676797..d70a725bc 100644 --- a/docs/connectors/contextual-orchestrator-interpretation-port.md +++ b/docs/connectors/contextual-orchestrator-interpretation-port.md @@ -17,7 +17,8 @@ LLM/provider settings are execution policy only. Deterministic scientific gates `orchestrator_live::OrchestratorLiveService` binds loopback TCP and serves `POST /v1/interpretation-runs` plus `GET /v1/interpretation-runs`. Accepted output is always hypothetical and never scientific authority. Collection GET -returns metric-free identities only. Non-loopback binds, table-access hosts, and +returns metric-free identities only. GET-by-id returns one of those identities +without POST replay. Non-loopback binds, table-access hosts, and review/Copilot/GitHub credential headers fail closed. The listener does not call a model provider. diff --git a/docs/research/interpretation-run-retrieval-http.md b/docs/research/interpretation-run-retrieval-http.md new file mode 100644 index 000000000..36359b436 --- /dev/null +++ b/docs/research/interpretation-run-retrieval-http.md @@ -0,0 +1,63 @@ +# Interpretation-run GET-by-id (doctoring) + +## Scope + +`GET /v1/interpretation-runs/{idempotency_key}` is the operator-visible +retrieval of one accepted hypothetical interpretation-run identity on +`OrchestratorLiveService` / `tepp-orchestrator-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 extra-segments, pagination headers, +review/Copilot/GitHub credential flags, and scientific-authority promotion is +repository contract authority (ADR 0071; ADR 0069; ADR 0010; ADR 0011; +ADR 0014), not an RFC inference rule. + +Retrieval JSON is metric-free. `claim_status` remains `hypothetical`. +`scientific_authority` remains false. `tepp.scientific_acceptance.v1` never +appears. A 200 retrieval body 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 the target resource. +TEPP maps that retrieval onto a bounded, hypothetical interpretation-run +identity. The RFC does not define psychometric acceptance, RMSE, causality, +or claim promotion. + +### Internal contract evidence + +- `docs/adr/0071-interpretation-run-retrieval-get.md` — this retrieval +- `docs/adr/0069-interpretation-run-collection-get.md` — collection GET +- `docs/adr/0064-interpretation-run-cli.md` — create CLI +- `docs/adr/0010-adaptive-llm-orchestration.md` — mode vocabulary and + scientific-authority separation +- `docs/adr/0011-standalone-modular-msa-boundary.md` — modular HTTP boundary +- `docs/adr/0014-scientific-claim-promotion-and-release-evidence.md` — HTTP + 200 is not a scientific claim +- `crates/orchestrator_live/tests/interpretation_run_retrieval_http_contract.rs` + — fail-closed GET-by-id proofs +- `crates/orchestrator_live/tests/live_http_contract.rs` — loopback GET-by-id + proofs + +## Verification + +- `GET /v1/interpretation-runs/{idempotency_key}` of an accepted + contextual-orchestrator run returns `hypothetical` with + `scientific_authority` false and without RMSE/bias/coverage/SE-gate keys, + `evidence_span_ids`, `causal_score`, or `tepp.scientific_acceptance.v1`; +- extra extra-segments, naruon or LineageWeave, nonempty body, present + `idempotency-key`, pagination headers, missing identity, slash/NUL keys fail + closed. + +## Non-claims + +This slice does not implement a retrieval CLI, analysis-run GET-by-id, export +GET, project-history GET-by-id, persistence, production TLS, Leiden consensus, +GAP-010 Figma/export, provider execution, causal inference, or an ADR 0014 +scientific claim-promotion package. From 95dbb195943fb17ca79fc316796de738abcfc6de Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 06:16:07 +0000 Subject: [PATCH 2/2] feat(api): retrieve interpretation runs via loopback GET-by-id CLI GAP-003A unique slice stacked on GET-by-id HTTP: published tepp-interpretation-run-get get mints typed contextual-orchestrator GET /v1/interpretation-runs/{idempotency_key} onto spawned tepp-orchestrator-loopback TCP. Naruon and LineageWeave refused. ADR 0072. --- .../interpretation-run-retrieval-cli.md | 1 + DOCUMENTATION.md | 1 + crates/orchestrator_live/Cargo.toml | 6 + .../src/bin/tepp_interpretation_run_get.rs | 34 + .../src/interpretation_run_retrieval_cli.rs | 666 ++++++++++++++++++ crates/orchestrator_live/src/lib.rs | 20 +- ...terpretation_run_retrieval_cli_contract.rs | 148 ++++ docs/API_CONTRACT.md | 2 +- docs/TRACEABILITY.md | 1 + .../0072-interpretation-run-retrieval-cli.md | 106 +++ docs/adr/README.md | 1 + ...extual-orchestrator-interpretation-port.md | 3 +- .../interpretation-run-retrieval-cli.md | 59 ++ 13 files changed, 1045 insertions(+), 3 deletions(-) create mode 100644 CHANGELOG.d/interpretation-run-retrieval-cli.md create mode 100644 crates/orchestrator_live/src/bin/tepp_interpretation_run_get.rs create mode 100644 crates/orchestrator_live/src/interpretation_run_retrieval_cli.rs create mode 100644 crates/orchestrator_live/tests/interpretation_run_retrieval_cli_contract.rs create mode 100644 docs/adr/0072-interpretation-run-retrieval-cli.md create mode 100644 docs/research/interpretation-run-retrieval-cli.md diff --git a/CHANGELOG.d/interpretation-run-retrieval-cli.md b/CHANGELOG.d/interpretation-run-retrieval-cli.md new file mode 100644 index 000000000..9ede01f98 --- /dev/null +++ b/CHANGELOG.d/interpretation-run-retrieval-cli.md @@ -0,0 +1 @@ +- `orchestrator_live` `tepp-interpretation-run-get get` mints a typed contextual-orchestrator `GET /v1/interpretation-runs/{idempotency_key}` onto spawned `tepp-orchestrator-loopback` TCP (ADR 0072). Metric-free hypothetical identities only (`claim_status=hypothetical`, `scientific_authority=false`). `tepp.scientific_acceptance.v1` never appears. Does not infer causality. Naruon and LineageWeave are refused. Not collection CLI, not GET-by-id HTTP-only, not persistence. diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index e82530848..b6da030c1 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -16,6 +16,7 @@ TEPP's approved PRD v0.4 and implementation plan are the primary product baselin | Interpretation-run CLI doctoring | [`docs/research/interpretation-run-cli.md`](docs/research/interpretation-run-cli.md) | | Interpretation-run collection GET doctoring | [`docs/research/interpretation-run-collection-http.md`](docs/research/interpretation-run-collection-http.md) | | Interpretation-run GET-by-id doctoring | [`docs/research/interpretation-run-retrieval-http.md`](docs/research/interpretation-run-retrieval-http.md) | +| Interpretation-run retrieval CLI doctoring | [`docs/research/interpretation-run-retrieval-cli.md`](docs/research/interpretation-run-retrieval-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/orchestrator_live/Cargo.toml b/crates/orchestrator_live/Cargo.toml index 38df1a3bc..74616b8b6 100644 --- a/crates/orchestrator_live/Cargo.toml +++ b/crates/orchestrator_live/Cargo.toml @@ -29,5 +29,11 @@ path = "src/bin/tepp_interpretation_runs.rs" test = false bench = false +[[bin]] +name = "tepp-interpretation-run-get" +path = "src/bin/tepp_interpretation_run_get.rs" +test = false +bench = false + [lints] workspace = true diff --git a/crates/orchestrator_live/src/bin/tepp_interpretation_run_get.rs b/crates/orchestrator_live/src/bin/tepp_interpretation_run_get.rs new file mode 100644 index 000000000..c57a33717 --- /dev/null +++ b/crates/orchestrator_live/src/bin/tepp_interpretation_run_get.rs @@ -0,0 +1,34 @@ +//! Operator CLI for loopback contextual-orchestrator interpretation-run GET-by-id. + +use std::io::{self, IsTerminal}; +use std::process::ExitCode; + +use orchestrator_live::{ + execute_interpretation_run_retrieval_cli, read_interpretation_run_retrieval_cli_stdin, + render_interpretation_run_retrieval_cli_stdout, InterpretationRunRetrievalCliInvocation, + OrchestratorLiveError, +}; + +fn main() -> ExitCode { + match run() { + Ok(()) => ExitCode::SUCCESS, + Err(_) => ExitCode::FAILURE, + } +} + +fn run() -> Result<(), OrchestratorLiveError> { + let args: Vec = std::env::args().skip(1).collect(); + match args.first().map(String::as_str) { + Some("get") => run_get(&args), + _ => Err(OrchestratorLiveError::InvalidWirePayload), + } +} + +fn run_get(args: &[String]) -> Result<(), OrchestratorLiveError> { + let body = read_interpretation_run_retrieval_cli_stdin(io::stdin().is_terminal(), io::stdin())?; + let invocation = InterpretationRunRetrievalCliInvocation::from_args(args, body)?; + let response = execute_interpretation_run_retrieval_cli(&invocation)?; + let stdout = render_interpretation_run_retrieval_cli_stdout(&invocation, &response)?; + println!("{stdout}"); + Ok(()) +} diff --git a/crates/orchestrator_live/src/interpretation_run_retrieval_cli.rs b/crates/orchestrator_live/src/interpretation_run_retrieval_cli.rs new file mode 100644 index 000000000..c59c4e999 --- /dev/null +++ b/crates/orchestrator_live/src/interpretation_run_retrieval_cli.rs @@ -0,0 +1,666 @@ +//! Operator loopback CLI for contextual-orchestrator interpretation-run GET-by-id. +//! +//! GAP-003A unique slice: operators run `tepp-interpretation-run-get get` to +//! mint `contextual_orchestrator_interpretation_run_retrieval_exchange` onto +//! spawned `tepp-orchestrator-loopback` TCP. Stdout is one metric-free +//! hypothetical identity with `claim_status=hypothetical` and +//! `scientific_authority=false`. `tepp.scientific_acceptance.v1` never +//! appears. The CLI does not infer causality or call a model provider. Naruon +//! and `LineageWeave` are refused. `NaruonLiveService` stays POST-only. This +//! module does not duplicate interpretation-run CLI (#425), collection GET +//! (#433), collection CLI (#436), GET-by-id HTTP (#438), project-history +//! retrieval CLI (#431), analysis-run GET-by-id (#359), 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 std::time::Duration; + +use crate::http::{header_is_credential, map_io_error}; +use crate::interpretation_run_cli::CONTEXTUAL_ORCHESTRATOR_CONSUMER_CODE; +use crate::interpretation_run_collection_http::{ + refuse_metrics_on_interpretation_run_collection_payload, InterpretationRunCollectionItem, +}; +use crate::interpretation_run_retrieval_http::{ + contextual_orchestrator_interpretation_run_retrieval_exchange, + interpretation_run_retrieval_item_json, interpretation_run_retrieval_path_id, + InterpretationRunRetrievalHttpExchange, +}; +use crate::request::{ + require_nonempty, DEFAULT_INTERPRETATION_BYTE_LIMIT, HYPOTHETICAL_CLAIM_STATUS, +}; +use crate::{ + OrchestratorLiveError, OrchestratorLiveResponse, OrchestratorLiveService, + LIVE_HEADER_BYTE_LIMIT, LIVE_HEADER_COUNT_LIMIT, +}; + +const SCIENTIFIC_ACCEPTANCE_SCHEMA: &str = "tepp.scientific_acceptance.v1"; +const CLI_IO_TIMEOUT: Duration = Duration::from_secs(2); +const MAXIMUM_HTTP_RESPONSE_BYTES: usize = + LIVE_HEADER_BYTE_LIMIT + 4 + DEFAULT_INTERPRETATION_BYTE_LIMIT; + +/// Supported operator verbs for the loopback interpretation-run retrieval CLI. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum InterpretationRunRetrievalCliVerb { + /// `GET /v1/interpretation-runs/{idempotency_key}`. + Get, +} + +impl InterpretationRunRetrievalCliVerb { + /// Parse one exact lowercase verb token. + /// + /// # Errors + /// + /// Returns [`OrchestratorLiveError::InvalidWirePayload`] for an unknown token. + pub fn parse(token: &str) -> Result { + match token { + "get" => Ok(Self::Get), + _ => Err(OrchestratorLiveError::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 GET-by-id listener. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct InterpretationRunRetrievalCliInvocation { + /// CLI verb to execute. + pub verb: InterpretationRunRetrievalCliVerb, + /// Loopback `host:port` of `tepp-orchestrator-loopback`. + pub host: String, + /// Published HTTPS origin used to mint the typed retrieval exchange. + pub origin: String, + /// Published modular consumer. Retrieval GET admits + /// `contextual-orchestrator` only. + pub consumer: String, + /// Opaque idempotency key that minted the stored identity. + pub idempotency_key: String, + /// JSON body. Retrieval GET requires empty. + pub body: String, +} + +impl InterpretationRunRetrievalCliInvocation { + /// Parse argv plus stdin body into a validated loopback retrieval 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, an unpublished consumer, + /// credential-shaped flags, a hostile identity, 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(OrchestratorLiveError::InvalidWirePayload)?; + let verb = InterpretationRunRetrievalCliVerb::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 [`OrchestratorLiveError::AuthorizationDenied`] for a non-loopback + /// host and [`OrchestratorLiveError::InvalidWirePayload`] or + /// [`OrchestratorLiveError::LimitExceeded`] for empty, unpublished, + /// nonempty-body, or oversized fields. + pub fn validate(&self) -> Result<(), OrchestratorLiveError> { + require_loopback_host(&self.host)?; + require_nonempty(&self.origin)?; + if !self.origin.starts_with("https://") { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + require_nonempty(&self.consumer)?; + if self.consumer != CONTEXTUAL_ORCHESTRATOR_CONSUMER_CODE { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + require_nonempty(&self.idempotency_key)?; + if self.idempotency_key.contains('/') || self.idempotency_key.contains('\0') { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + if !self.body.is_empty() { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + refuse_scientific_acceptance(&self.body)?; + refuse_metrics_on_interpretation_run_collection_payload(&self.body)?; + Ok(()) + } +} + +struct ParsedFlags { + host: Option, + origin: Option, + consumer: Option, + idempotency_key: Option, +} + +fn parse_flags(rest: &[String]) -> Result { + let mut flags = ParsedFlags { + host: None, + origin: None, + consumer: None, + idempotency_key: None, + }; + let mut index = 0; + while index < rest.len() { + let flag = rest[index].as_str(); + if !flag.starts_with("--") { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + let name = &flag[2..]; + if header_is_credential(name) { + return Err(OrchestratorLiveError::AuthorizationDenied); + } + let slot = match name { + "host" => &mut flags.host, + "origin" => &mut flags.origin, + "consumer" => &mut flags.consumer, + "idempotency-key" => &mut flags.idempotency_key, + _ => return Err(OrchestratorLiveError::InvalidWirePayload), + }; + if slot.is_some() || index + 1 >= rest.len() { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + let value = rest[index + 1].as_str(); + require_nonempty(value)?; + *slot = Some(value.to_owned()); + index += 2; + } + Ok(flags) +} + +fn assemble_invocation( + verb: InterpretationRunRetrievalCliVerb, + flags: ParsedFlags, + body: String, +) -> Result { + let invocation = InterpretationRunRetrievalCliInvocation { + verb, + host: flags + .host + .ok_or(OrchestratorLiveError::InvalidWirePayload)?, + origin: flags + .origin + .ok_or(OrchestratorLiveError::InvalidWirePayload)?, + consumer: flags + .consumer + .unwrap_or_else(|| CONTEXTUAL_ORCHESTRATOR_CONSUMER_CODE.to_owned()), + idempotency_key: flags + .idempotency_key + .ok_or(OrchestratorLiveError::InvalidWirePayload)?, + body, + }; + invocation.validate()?; + Ok(invocation) +} + +fn require_loopback_host(host: &str) -> Result { + let addr: SocketAddr = host + .parse() + .map_err(|_| OrchestratorLiveError::InvalidWirePayload)?; + if addr.ip().is_loopback() { + Ok(addr) + } else { + Err(OrchestratorLiveError::AuthorizationDenied) + } +} + +/// Render a typed retrieval GET exchange as HTTP/1.1 for a loopback listener. +/// +/// # Errors +/// +/// Returns [`OrchestratorLiveError::AuthorizationDenied`] for a non-loopback +/// host or a credential-bearing header, and +/// [`OrchestratorLiveError::InvalidWirePayload`] when the exchange is not a +/// GET `/v1/interpretation-runs/{idempotency_key}` with an empty body. +pub fn loopback_http1_from_interpretation_run_retrieval_exchange( + exchange: &InterpretationRunRetrievalHttpExchange, + loopback_host: &str, +) -> Result { + let _addr = require_loopback_host(loopback_host)?; + let host = loopback_host.trim(); + if exchange.method != "GET" { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + if !exchange.body.is_empty() { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + let rest = exchange + .target_url + .strip_prefix("https://") + .ok_or(OrchestratorLiveError::InvalidWirePayload)?; + let path = rest + .find('/') + .map(|index| &rest[index..]) + .ok_or(OrchestratorLiveError::InvalidWirePayload)?; + let _idempotency_key = interpretation_run_retrieval_path_id(path)?; + for (name, _) in &exchange.headers { + if header_is_credential(name) { + return Err(OrchestratorLiveError::AuthorizationDenied); + } + if name.eq_ignore_ascii_case("idempotency-key") + || name.eq_ignore_ascii_case("tepp-page-limit") + || name.eq_ignore_ascii_case("tepp-page-cursor") + { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + } + let mut request = String::new(); + write!( + request, + "{} {path} HTTP/1.1\r\nHost: {host}\r\n", + exchange.method + ) + .map_err(|_| OrchestratorLiveError::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(|_| OrchestratorLiveError::InvalidWirePayload)?; + } + write!(request, "content-length: 0\r\n\r\n") + .map_err(|_| OrchestratorLiveError::InvalidWirePayload)?; + Ok(request) +} + +/// Compose one HTTP/1.1 retrieval GET from the typed consumer exchange. +/// +/// # Errors +/// +/// Returns the same fail-closed errors as +/// [`InterpretationRunRetrievalCliInvocation::validate`]. +pub fn compose_interpretation_run_retrieval_cli_http( + invocation: &InterpretationRunRetrievalCliInvocation, +) -> Result { + invocation.validate()?; + let exchange = contextual_orchestrator_interpretation_run_retrieval_exchange( + &invocation.origin, + &invocation.idempotency_key, + )?; + loopback_http1_from_interpretation_run_retrieval_exchange(&exchange, &invocation.host) +} + +/// Dispatch one retrieval CLI invocation against an in-process listener. +/// +/// # Errors +/// +/// Returns fail-closed validation errors before the HTTP handler runs. +pub fn dispatch_interpretation_run_retrieval_cli( + service: &mut OrchestratorLiveService, + invocation: &InterpretationRunRetrievalCliInvocation, +) -> Result { + let request = compose_interpretation_run_retrieval_cli_http(invocation)?; + Ok(service.handle_http_request(&request)) +} + +/// Execute one retrieval CLI invocation over loopback TCP. +/// +/// # Errors +/// +/// Returns fail-closed validation, transport, or response-framing errors. +pub fn execute_interpretation_run_retrieval_cli( + invocation: &InterpretationRunRetrievalCliInvocation, +) -> Result { + let addr = require_loopback_host(&invocation.host)?; + let request = compose_interpretation_run_retrieval_cli_http(invocation)?; + let mut stream = TcpStream::connect(addr).map_err(|error| map_io_error(&error))?; + stream + .set_read_timeout(Some(CLI_IO_TIMEOUT)) + .map_err(|error| map_io_error(&error))?; + stream + .set_write_timeout(Some(CLI_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 bytes = read_bounded(&mut stream, MAXIMUM_HTTP_RESPONSE_BYTES)?; + parse_http_response(&bytes) +} + +/// Filter CLI stdout so retrieval never prints scientific acceptance. +/// +/// # Errors +/// +/// Returns [`OrchestratorLiveError::InvalidWirePayload`] when a receipt carries +/// metric keys, evidence, causal scores, or +/// `tepp.scientific_acceptance.v1`, or when the identity does not match. +pub fn render_interpretation_run_retrieval_cli_stdout( + invocation: &InterpretationRunRetrievalCliInvocation, + response: &OrchestratorLiveResponse, +) -> Result { + invocation.validate()?; + if response.body.is_empty() { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + refuse_scientific_acceptance(&response.body)?; + refuse_metrics_on_interpretation_run_collection_payload(&response.body)?; + if response.status_code != 200 { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + let parsed: InterpretationRunCollectionItem = serde_json::from_str(&response.body) + .map_err(|_| OrchestratorLiveError::InvalidWirePayload)?; + let item = InterpretationRunCollectionItem::new( + parsed.interpretation_run_id, + parsed.idempotency_key, + parsed.orchestration_mode, + parsed.claim_status, + parsed.scientific_authority, + )?; + if item.idempotency_key != invocation.idempotency_key + || item.claim_status != HYPOTHETICAL_CLAIM_STATUS + || item.scientific_authority + { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + interpretation_run_retrieval_item_json(&item) +} + +fn refuse_scientific_acceptance(body: &str) -> Result<(), OrchestratorLiveError> { + if body.contains(SCIENTIFIC_ACCEPTANCE_SCHEMA) { + Err(OrchestratorLiveError::InvalidWirePayload) + } else { + Ok(()) + } +} + +fn parse_http_response(bytes: &[u8]) -> Result { + let text = std::str::from_utf8(bytes).map_err(|_| OrchestratorLiveError::InvalidWirePayload)?; + let (header_block, body) = text + .split_once("\r\n\r\n") + .ok_or(OrchestratorLiveError::InvalidWirePayload)?; + if header_block.len() > LIVE_HEADER_BYTE_LIMIT { + return Err(OrchestratorLiveError::LimitExceeded); + } + let mut lines = header_block.split("\r\n"); + let status_line = lines + .next() + .ok_or(OrchestratorLiveError::InvalidWirePayload)?; + let mut parts = status_line.split(' '); + if parts.next() != Some("HTTP/1.1") { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + let code = parts + .next() + .ok_or(OrchestratorLiveError::InvalidWirePayload)? + .parse::() + .map_err(|_| OrchestratorLiveError::InvalidWirePayload)?; + let reason_phrase = static_reason(code)?; + let mut content_length = None; + for (index, line) in lines.enumerate() { + if index >= LIVE_HEADER_COUNT_LIMIT { + return Err(OrchestratorLiveError::LimitExceeded); + } + let (name, value) = line + .split_once(':') + .ok_or(OrchestratorLiveError::InvalidWirePayload)?; + if name.eq_ignore_ascii_case("content-length") { + if content_length.is_some() { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + content_length = Some( + value + .trim() + .parse::() + .map_err(|_| OrchestratorLiveError::InvalidWirePayload)?, + ); + } + } + let declared = content_length.ok_or(OrchestratorLiveError::InvalidWirePayload)?; + if declared > DEFAULT_INTERPRETATION_BYTE_LIMIT { + return Err(OrchestratorLiveError::LimitExceeded); + } + if declared != body.len() { + return Err(OrchestratorLiveError::InvalidWirePayload); + } + Ok(OrchestratorLiveResponse { + status_code: code, + reason_phrase, + body: body.to_owned(), + }) +} + +fn static_reason(code: u16) -> Result<&'static str, OrchestratorLiveError> { + match code { + 200 => Ok("OK"), + 202 => Ok("Accepted"), + 400 => Ok("Bad Request"), + 403 => Ok("Forbidden"), + 413 => Ok("Payload Too Large"), + 422 => Ok("Unprocessable Entity"), + _ => Err(OrchestratorLiveError::InvalidWirePayload), + } +} + +/// Read stdin leftover bytes on a non-terminal; retrieval GET admits empty. +/// +/// # Errors +/// +/// Returns [`OrchestratorLiveError::InvalidWirePayload`] when stdin cannot be +/// read and [`OrchestratorLiveError::LimitExceeded`] when leftover stdin +/// exceeds the interpretation-run wire limit. +pub fn read_interpretation_run_retrieval_cli_stdin( + stdin_is_terminal: bool, + mut stdin: impl Read, +) -> Result { + if stdin_is_terminal { + Ok(String::new()) + } else { + let bytes = read_bounded(&mut stdin, DEFAULT_INTERPRETATION_BYTE_LIMIT)?; + String::from_utf8(bytes).map_err(|_| OrchestratorLiveError::InvalidWirePayload) + } +} + +fn read_bounded( + reader: &mut impl Read, + maximum_bytes: usize, +) -> Result, OrchestratorLiveError> { + let mut bytes = Vec::new(); + reader + .take((maximum_bytes + 1) as u64) + .read_to_end(&mut bytes) + .map_err(|error| map_io_error(&error))?; + if bytes.len() > maximum_bytes { + return Err(OrchestratorLiveError::LimitExceeded); + } + Ok(bytes) +} + +#[cfg(test)] +mod tests { + use super::{ + compose_interpretation_run_retrieval_cli_http, + loopback_http1_from_interpretation_run_retrieval_exchange, + read_interpretation_run_retrieval_cli_stdin, InterpretationRunRetrievalCliInvocation, + InterpretationRunRetrievalCliVerb, + }; + use crate::interpretation_run_cli::CONTEXTUAL_ORCHESTRATOR_CONSUMER_CODE; + use crate::{ + contextual_orchestrator_interpretation_run_retrieval_exchange, OrchestratorLiveError, + }; + + const ORIGIN: &str = "https://tepp.example.test"; + + fn get_args() -> [&'static str; 9] { + [ + "get", + "--host", + "127.0.0.1:18082", + "--origin", + ORIGIN, + "--consumer", + CONTEXTUAL_ORCHESTRATOR_CONSUMER_CODE, + "--idempotency-key", + "idem-a", + ] + } + + #[test] + fn from_args_mints_get_and_refuses_fail_closed_inputs() { + assert_eq!( + InterpretationRunRetrievalCliVerb::parse("get").expect("get"), + InterpretationRunRetrievalCliVerb::Get + ); + assert_eq!(InterpretationRunRetrievalCliVerb::Get.as_str(), "get"); + assert_eq!( + InterpretationRunRetrievalCliVerb::parse("list"), + Err(OrchestratorLiveError::InvalidWirePayload) + ); + let get = InterpretationRunRetrievalCliInvocation::from_args(get_args(), "").expect("get"); + assert_eq!(get.verb, InterpretationRunRetrievalCliVerb::Get); + let http = compose_interpretation_run_retrieval_cli_http(&get).expect("http"); + assert!(http.starts_with("GET /v1/interpretation-runs/idem-a HTTP/1.1")); + assert!(http.contains("tepp-consumer: contextual-orchestrator")); + assert!(http.contains("content-length: 0")); + assert!(!http.contains("idempotency-key:")); + assert!(!http.contains("authorization")); + assert_eq!( + InterpretationRunRetrievalCliInvocation::from_args( + [ + "get", + "--host", + "8.8.8.8:80", + "--origin", + ORIGIN, + "--idempotency-key", + "idem-a" + ], + "" + ) + .unwrap_err(), + OrchestratorLiveError::AuthorizationDenied + ); + assert_eq!( + InterpretationRunRetrievalCliInvocation::from_args( + [ + "get", + "--host", + "localhost:18082", + "--origin", + ORIGIN, + "--idempotency-key", + "idem-a" + ], + "" + ) + .unwrap_err(), + OrchestratorLiveError::InvalidWirePayload + ); + assert_eq!( + InterpretationRunRetrievalCliInvocation::from_args( + [ + "get", + "--host", + "127.0.0.1:18082", + "--origin", + "http://tepp.example.test", + "--idempotency-key", + "idem-a" + ], + "" + ) + .unwrap_err(), + OrchestratorLiveError::InvalidWirePayload + ); + assert_eq!( + InterpretationRunRetrievalCliInvocation::from_args( + [ + "get", + "--host", + "127.0.0.1:18082", + "--origin", + ORIGIN, + "--idempotency-key", + "idem-a", + "--authorization", + "secret" + ], + "" + ) + .unwrap_err(), + OrchestratorLiveError::AuthorizationDenied + ); + } + + #[test] + fn from_args_refuses_unpublished_body_pagination_and_non_get() { + assert_eq!( + InterpretationRunRetrievalCliInvocation::from_args( + [ + "get", + "--host", + "127.0.0.1:18082", + "--origin", + ORIGIN, + "--consumer", + "naruon", + "--idempotency-key", + "idem-a" + ], + "" + ) + .unwrap_err(), + OrchestratorLiveError::InvalidWirePayload + ); + assert_eq!( + InterpretationRunRetrievalCliInvocation::from_args(get_args(), "{}").unwrap_err(), + OrchestratorLiveError::InvalidWirePayload + ); + assert_eq!( + InterpretationRunRetrievalCliInvocation::from_args( + [ + "get", + "--host", + "127.0.0.1:18082", + "--origin", + ORIGIN, + "--idempotency-key", + "idem-a", + "--page-limit", + "1" + ], + "" + ) + .unwrap_err(), + OrchestratorLiveError::InvalidWirePayload + ); + let leftover = + read_interpretation_run_retrieval_cli_stdin(false, std::io::Cursor::new(b"leftover")) + .expect("leftover"); + assert_eq!(leftover, "leftover"); + assert!( + read_interpretation_run_retrieval_cli_stdin(true, std::io::empty()) + .expect("tty") + .is_empty() + ); + let exchange = + contextual_orchestrator_interpretation_run_retrieval_exchange(ORIGIN, "idem-a") + .expect("exchange"); + let mut posted = exchange.clone(); + posted.method = "POST"; + assert_eq!( + loopback_http1_from_interpretation_run_retrieval_exchange(&posted, "127.0.0.1:18082") + .unwrap_err(), + OrchestratorLiveError::InvalidWirePayload + ); + } +} diff --git a/crates/orchestrator_live/src/lib.rs b/crates/orchestrator_live/src/lib.rs index 260f3c2db..d157cf9fc 100644 --- a/crates/orchestrator_live/src/lib.rs +++ b/crates/orchestrator_live/src/lib.rs @@ -12,12 +12,14 @@ //! termination or call a model provider (ADR 0010; ADR 0011). The published //! `tepp-interpretation-runs` CLI mints typed contextual-orchestrator //! interpretation-run POST exchanges onto spawned `tepp-orchestrator-loopback` -//! TCP. +//! TCP. `tepp-interpretation-run-get` retrieves one accepted identity without +//! POST replay. mod error; mod http; mod interpretation_run_cli; mod interpretation_run_collection_http; +mod interpretation_run_retrieval_cli; mod interpretation_run_retrieval_http; mod mode; mod request; @@ -79,6 +81,22 @@ pub use interpretation_run_collection_http::INTERPRETATION_RUN_COLLECTION_CURSOR pub use interpretation_run_collection_http::INTERPRETATION_RUN_COLLECTION_DEFAULT_LIMIT; /// Maximum page size for interpretation-run collection GET. pub use interpretation_run_collection_http::INTERPRETATION_RUN_COLLECTION_MAX_LIMIT; +/// Compose one HTTP/1.1 retrieval GET from the typed consumer exchange. +pub use interpretation_run_retrieval_cli::compose_interpretation_run_retrieval_cli_http; +/// Dispatch one retrieval CLI invocation against an in-process listener. +pub use interpretation_run_retrieval_cli::dispatch_interpretation_run_retrieval_cli; +/// Execute one retrieval CLI invocation over loopback TCP. +pub use interpretation_run_retrieval_cli::execute_interpretation_run_retrieval_cli; +/// Render a typed retrieval GET exchange as HTTP/1.1 for a loopback listener. +pub use interpretation_run_retrieval_cli::loopback_http1_from_interpretation_run_retrieval_exchange; +/// Read stdin leftover bytes; retrieval GET admits empty. +pub use interpretation_run_retrieval_cli::read_interpretation_run_retrieval_cli_stdin; +/// Filter CLI stdout so retrieval never prints scientific acceptance. +pub use interpretation_run_retrieval_cli::render_interpretation_run_retrieval_cli_stdout; +/// One operator CLI invocation against a loopback GET-by-id listener. +pub use interpretation_run_retrieval_cli::InterpretationRunRetrievalCliInvocation; +/// Supported operator verbs for the loopback interpretation-run retrieval CLI. +pub use interpretation_run_retrieval_cli::InterpretationRunRetrievalCliVerb; /// Build a credential-free contextual-orchestrator GET-by-id exchange. pub use interpretation_run_retrieval_http::contextual_orchestrator_interpretation_run_retrieval_exchange; /// Serialize one metric-free GET-by-id identity. diff --git a/crates/orchestrator_live/tests/interpretation_run_retrieval_cli_contract.rs b/crates/orchestrator_live/tests/interpretation_run_retrieval_cli_contract.rs new file mode 100644 index 000000000..328f1c950 --- /dev/null +++ b/crates/orchestrator_live/tests/interpretation_run_retrieval_cli_contract.rs @@ -0,0 +1,148 @@ +//! Contract tests for `tepp-interpretation-run-get get`. + +use orchestrator_live::{ + compose_interpretation_run_cli_http, dispatch_interpretation_run_retrieval_cli, + execute_interpretation_run_retrieval_cli, render_interpretation_run_retrieval_cli_stdout, + InterpretationRunCliInvocation, InterpretationRunCollectionItem, InterpretationRunRequest, + InterpretationRunRetrievalCliInvocation, OrchestrationMode, OrchestratorLiveError, + OrchestratorLiveResponse, OrchestratorLiveService, CONTEXTUAL_ORCHESTRATOR_CONSUMER_CODE, + HYPOTHETICAL_CLAIM_STATUS, INTERPRETATION_RUN_CONTRACT_VERSION, +}; + +const ORIGIN: &str = "https://tepp.example.test"; + +fn query_body(idem: &str) -> String { + InterpretationRunRequest::new( + INTERPRETATION_RUN_CONTRACT_VERSION, + idem, + "orch-tenant-demo", + "tepp-snapshot-demo-001", + "2026-08-01T00:00:00Z", + OrchestrationMode::Direct, + 2048, + vec!["span-001".into()], + false, + ) + .expect("request") + .to_json() + .expect("json") +} + +fn create_http(idem: &str) -> String { + let invocation = InterpretationRunCliInvocation::from_args( + [ + "create", + "--host", + "127.0.0.1:18082", + "--origin", + ORIGIN, + "--consumer", + CONTEXTUAL_ORCHESTRATOR_CONSUMER_CODE, + ], + query_body(idem), + ) + .expect("create"); + compose_interpretation_run_cli_http(&invocation).expect("post") +} + +fn get_invocation(idem: &str) -> InterpretationRunRetrievalCliInvocation { + InterpretationRunRetrievalCliInvocation::from_args( + [ + "get", + "--host", + "127.0.0.1:18082", + "--origin", + ORIGIN, + "--idempotency-key", + idem, + ], + "", + ) + .expect("get") +} + +#[test] +fn dispatch_retrieves_one_hypothetical_identity_without_metrics() { + let mut service = OrchestratorLiveService::new(); + assert_eq!( + service + .handle_http_request(&create_http("idem-a")) + .status_code, + 202 + ); + let got = dispatch_interpretation_run_retrieval_cli(&mut service, &get_invocation("idem-a")) + .expect("get"); + assert_eq!(got.status_code, 200, "{}", got.body); + let stdout = render_interpretation_run_retrieval_cli_stdout(&get_invocation("idem-a"), &got) + .expect("out"); + assert!(!stdout.contains("tepp.scientific_acceptance.v1")); + assert!(!stdout.contains("rmse")); + assert!(!stdout.contains("evidence_span_ids")); + assert!(!stdout.contains("tenant_workspace_id")); + assert!(!stdout.contains("causal_score")); + let item: InterpretationRunCollectionItem = serde_json::from_str(&stdout).expect("item"); + assert_eq!(item.idempotency_key, "idem-a"); + assert_eq!(item.claim_status, HYPOTHETICAL_CLAIM_STATUS); + assert!(!item.scientific_authority); + assert_eq!( + dispatch_interpretation_run_retrieval_cli(&mut service, &get_invocation("missing")) + .expect("missing") + .status_code, + 400 + ); +} + +#[test] +fn render_refuses_metrics_schema_and_empty_bodies() { + let get = get_invocation("idem-a"); + assert_eq!( + render_interpretation_run_retrieval_cli_stdout( + &get, + &OrchestratorLiveResponse { + status_code: 200, + reason_phrase: "OK", + body: String::new(), + } + ) + .unwrap_err(), + OrchestratorLiveError::InvalidWirePayload + ); + assert_eq!( + render_interpretation_run_retrieval_cli_stdout( + &get, + &OrchestratorLiveResponse { + status_code: 200, + reason_phrase: "OK", + body: r#"{"interpretation_run_id":"r","idempotency_key":"idem-a","orchestration_mode":"direct","claim_status":"hypothetical","scientific_authority":false,"rmse":1.0}"#.into(), + } + ) + .unwrap_err(), + OrchestratorLiveError::InvalidWirePayload + ); + assert_eq!( + render_interpretation_run_retrieval_cli_stdout( + &get, + &OrchestratorLiveResponse { + status_code: 400, + reason_phrase: "Bad Request", + body: r#"{"error_code":"invalid_wire_payload"}"#.into(), + } + ) + .unwrap_err(), + OrchestratorLiveError::InvalidWirePayload + ); +} + +#[test] +fn execute_over_tcp_returns_missing_identity_as_invalid_wire() { + let mut service = OrchestratorLiveService::bind_loopback().expect("bind"); + let addr = service.local_addr().expect("addr"); + let handle = std::thread::spawn(move || { + drop(service.serve_one()); + }); + let mut invocation = get_invocation("idem-a"); + invocation.host = addr.to_string(); + let response = execute_interpretation_run_retrieval_cli(&invocation).expect("tcp"); + assert_eq!(response.status_code, 400, "{}", response.body); + handle.join().expect("join"); +} diff --git a/docs/API_CONTRACT.md b/docs/API_CONTRACT.md index 3698a7377..abf21d87c 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; export retrieval stays a target shape until an executable export route ships. Loopback `tepp-interpretation-runs create` is the operator-visible client for `POST /v1/interpretation-runs` on `tepp-orchestrator-loopback` (ADR 0064); stdout stays metric-free with `claim_status` `hypothetical` and `scientific_authority` false. Loopback `GET /v1/interpretation-runs` enumerates those accepted hypothetical runs as metric-free identities (ADR 0069); `GET /v1/interpretation-runs/{idempotency_key}` returns one identity without POST replay (ADR 0071); naruon and LineageWeave stay refused. +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. Loopback `tepp-interpretation-runs create` is the operator-visible client for `POST /v1/interpretation-runs` on `tepp-orchestrator-loopback` (ADR 0064); stdout stays metric-free with `claim_status` `hypothetical` and `scientific_authority` false. Loopback `GET /v1/interpretation-runs` enumerates those accepted hypothetical runs as metric-free identities (ADR 0069); `GET /v1/interpretation-runs/{idempotency_key}` returns one identity without POST replay (ADR 0071); `tepp-interpretation-run-get get` mints that GET onto spawned `tepp-orchestrator-loopback` TCP (ADR 0072); naruon and LineageWeave stay refused. ## 2. Contract families diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 4f128ab0f..10eb9afed 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -109,6 +109,7 @@ The full APA 7th standards/literature register remains `docs/research/standards- | loopback contextual-orchestrator interpretation-run CLI | ADR 0064; ADR 0010/0011; API contract; RFC 9110 | `orchestrator_live` `tepp-interpretation-runs create` CLI against `tepp-orchestrator-loopback` (`POST /v1/interpretation-runs`); metric-free hypothetical JSON; `tepp.scientific_acceptance.v1` never appears; does not infer causality; naruon and LineageWeave refused | active-PR | | loopback contextual-orchestrator interpretation-run collection GET | ADR 0069; ADR 0010/0011; API contract; RFC 9110 | `orchestrator_live` `GET /v1/interpretation-runs` on `tepp-orchestrator-loopback`; metric-free hypothetical identities; empty body; no `idempotency-key`; `tepp.scientific_acceptance.v1` never appears; does not infer causality; naruon and LineageWeave refused | active-PR | | loopback contextual-orchestrator interpretation-run GET-by-id | ADR 0071; ADR 0069; ADR 0010/0011; API contract; RFC 9110 | `orchestrator_live` `GET /v1/interpretation-runs/{idempotency_key}` on `tepp-orchestrator-loopback`; metric-free hypothetical identity without POST replay; empty body; no pagination; `tepp.scientific_acceptance.v1` never appears; does not infer causality; naruon and LineageWeave refused | active-PR | +| loopback contextual-orchestrator interpretation-run retrieval CLI | ADR 0072; ADR 0071; ADR 0010/0011; API contract; RFC 9110 | `orchestrator_live` `tepp-interpretation-run-get get` CLI against `tepp-orchestrator-loopback` (`GET /v1/interpretation-runs/{idempotency_key}`); metric-free hypothetical identity; empty stdin admitted; leftover nonempty stdin fail closed; `tepp.scientific_acceptance.v1` never appears; does not infer causality; naruon and LineageWeave refused | active-PR | | foundation validation / release-readiness ledger | ADR 0014; Test Strategy | PR #24 `docs/validation/temporal-event-foundation.md` on protected main | implemented-main | | scientific claim promotion separated from design/implementation/release | ADR 0014; ADR policy | `validation_core` exact-head promotion gates on this PR; documentation/CI/domain validation remain; full package/image release bundle remaining | partial | | CSAP/SOC 2/ISO/NIST assurance readiness | `docs/COMPLIANCE_READINESS.md`; research register | repository controls + future deployment evidence | accepted-target / deployment-owned | diff --git a/docs/adr/0072-interpretation-run-retrieval-cli.md b/docs/adr/0072-interpretation-run-retrieval-cli.md new file mode 100644 index 000000000..24c3897b6 --- /dev/null +++ b/docs/adr/0072-interpretation-run-retrieval-cli.md @@ -0,0 +1,106 @@ +# ADR 0072 — Contextual-orchestrator interpretation-run retrieval CLI + +**Decision status:** Accepted +**Implementation maturity:** active-PR +**Date:** 2026-09-01 +**Supersedes:** None; complements ADR 0071 for operator-visible GET-by-id. Does not supersede ADR 0014 claim-promotion authority. This ADR number is unique on this interpretation stack versus protected main; live vs-main and sibling GAP-003A PRs already occupy 0026–0071. + +## Context + +ADR 0071 publishes loopback `GET /v1/interpretation-runs/{idempotency_key}`. +Operators still had no published binary that mints that typed exchange onto +spawned `tepp-orchestrator-loopback` TCP. Duplicating interpretation-run CLI +(#425), collection GET (#433), collection CLI (#436), GET-by-id HTTP (#438), +project-history retrieval CLI (#431), analysis-run GET-by-id (#359), Leiden, +Driver p.16, or GAP-010 Figma/export would collide with live PRs. Naruon and +`LineageWeave` are refused on this orchestrator-owned adapter; +`NaruonLiveService` stays POST-only. + +## Decision + +`orchestrator_live` publishes `tepp-interpretation-run-get get`: + +- `from_args` plus typed + `contextual_orchestrator_interpretation_run_retrieval_exchange`, + `loopback_http1_from_interpretation_run_retrieval_exchange`, + `dispatch`/`execute`/`render`, and a published `[[bin]]`. +- Consumer is `contextual-orchestrator` only. Empty stdin is admitted; + leftover nonempty stdin fails closed. +- Public bind, `localhost` as a hostname, `http` origins, unpublished + consumers, pagination flags, and credential-shaped flags fail closed. +- Stdout is one metric-free identity: `claim_status=hypothetical`, + `scientific_authority=false`. `tepp.scientific_acceptance.v1` never appears. +- The CLI does not infer causality, mutate TEPP state, or return a completed + psychometric result. +- This slice does not implement persistence. + +## Alternatives considered + +1. **Keep HTTP GET-by-id without a CLI** — rejected; operators still write raw + HTTP after ADR 0071. +2. **Add `get` onto `tepp-interpretation-runs`** — rejected for this slice; + collection CLI (`list`) lives on a parallel stack (#436). A dedicated + retrieval binary avoids colliding with that live PR. +3. **Reuse project-history retrieval CLI (#431)** — rejected; that is a + different live resource. +4. **Published `tepp-interpretation-run-get get`** — accepted. + +## Consequences + +- Operators can retrieve one accepted hypothetical identity from a collection + key without writing a second POST or crafting HTTP by hand. +- Retrieval stdout cannot be mistaken for a succeeded scientific-acceptance + result or a causal score. +- Retrieval success is not release evidence and is not an ADR 0014 claim. + +## Failure and recovery + +Non-`contextual-orchestrator` consumers, nonempty leftover stdin, present +pagination flags, public bind, `localhost`, `http` origins, credential flags, +slash/NUL keys, 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. +- Evidence spans, tenant, and budget stay off the retrieval stdout. +- Process 0 / HTTP 200 on GET-by-id is not measurement evidence and is not a + causal claim. + +## Compatibility and migration + +Collection GET, GET-by-id HTTP, POST `/v1/interpretation-runs`, and +`tepp-interpretation-runs create` remain unchanged. Persistence remains a +later slice. + +## Verification + +Falsifiable evidence: + +- `tepp-interpretation-run-get get` of an accepted identity returns + `hypothetical` / `scientific_authority=false` without RMSE/bias/coverage/ + SE-gate/evidence/`causal_score`/`tepp.scientific_acceptance.v1` keys; +- leftover stdin, naruon or LineageWeave, public bind, `localhost`, `http` + origin, pagination flags, and credential flags fail closed; +- Clippy `-D warnings`, `orchestrator_live` tests, rustdoc, and exact-head + review remain required. + +## Rollback and supersession + +Rollback removes `tepp-interpretation-run-get`; GET-by-id HTTP remains valid. +A superseding ADR is required to persist the registry, bind a public address, +emit scientific-acceptance on retrieval, open naruon or LineageWeave, or treat +retrieval success as an ADR 0014 claim. + +## Related authority + +- ADR 0071 owns loopback interpretation-run GET-by-id. +- ADR 0070 owns the collection CLI (live #436). +- ADR 0069 owns loopback interpretation-run collection GET. +- ADR 0064 owns the interpretation-run POST CLI (live #425). +- ADR 0010 owns orchestration mode vocabulary and scientific-authority + separation. +- ADR 0011 owns standalone/modular HTTP boundaries. +- ADR 0014 owns scientific claim promotion. +- RFC 9110 owns GET semantics (Fielding, Nottingham, & Reschke, 2022). It + does not authorize scientific claims. diff --git a/docs/adr/README.md b/docs/adr/README.md index 4002ba826..2a41dcc1e 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -33,6 +33,7 @@ Read [`ADR_POLICY.md`](ADR_POLICY.md) first. **Decision status and implementatio | [0064](0064-interpretation-run-cli.md) | Loopback `tepp-interpretation-runs create` is contextual-orchestrator POST /v1/interpretation-runs client | Accepted | active-PR | Complements ADR 0010/0011; does not supersede ADR 0014. Unique on protected main. Does not infer causality. | | [0069](0069-interpretation-run-collection-get.md) | Loopback `GET /v1/interpretation-runs` enumerates accepted hypothetical interpretation runs | Accepted | active-PR | Complements ADR 0010/0011/0064; does not supersede ADR 0014. Unique on this stack versus protected main (0026–0068 occupied). Does not infer causality. | | [0071](0071-interpretation-run-retrieval-get.md) | Loopback `GET /v1/interpretation-runs/{idempotency_key}` returns one accepted hypothetical identity | Accepted | active-PR | Complements ADR 0069; does not supersede ADR 0014. Unique on this interpretation stack versus protected main (0026–0070 occupied). Does not infer causality. | +| [0072](0072-interpretation-run-retrieval-cli.md) | Loopback `tepp-interpretation-run-get get` is contextual-orchestrator GET-by-id client | Accepted | active-PR | Complements ADR 0071; does not supersede ADR 0014. Unique on this interpretation stack versus protected main (0026–0071 occupied). Does not infer causality. | | [0023](0023-lineage-criterion-anchor-contract.md) | TEPP-owned Event Lineage criterion anchor | Accepted | active-PR | PR #237 publishes the strict accepted/rejected artifact and identities; estimator execution remains fail-closed future work. | | [0024](0024-independent-topic-importance-anchor.md) | Posterior topic-context producer contract | Accepted | contract-only active-PR | Strict DTO/schema only; the current estimator does not emit it. fast-mlsirm owns case-deletion influence. | | [0001](0001-rust-first-modular-msa.md) | Rust-first numerical core and CPU `f64` reference | Accepted | partial | ADR 0011 owns cross-service/MSA authority; 0001 retains numerical/backend authority. | diff --git a/docs/connectors/contextual-orchestrator-interpretation-port.md b/docs/connectors/contextual-orchestrator-interpretation-port.md index d70a725bc..07941fa03 100644 --- a/docs/connectors/contextual-orchestrator-interpretation-port.md +++ b/docs/connectors/contextual-orchestrator-interpretation-port.md @@ -18,7 +18,8 @@ LLM/provider settings are execution policy only. Deterministic scientific gates `POST /v1/interpretation-runs` plus `GET /v1/interpretation-runs`. Accepted output is always hypothetical and never scientific authority. Collection GET returns metric-free identities only. GET-by-id returns one of those identities -without POST replay. Non-loopback binds, table-access hosts, and +without POST replay. `tepp-interpretation-run-get get` mints that GET onto +spawned loopback TCP. Non-loopback binds, table-access hosts, and review/Copilot/GitHub credential headers fail closed. The listener does not call a model provider. diff --git a/docs/research/interpretation-run-retrieval-cli.md b/docs/research/interpretation-run-retrieval-cli.md new file mode 100644 index 000000000..417dd15b7 --- /dev/null +++ b/docs/research/interpretation-run-retrieval-cli.md @@ -0,0 +1,59 @@ +# Interpretation-run retrieval CLI (doctoring) + +## Scope + +`tepp-interpretation-run-get get` is the operator-visible client of loopback +`GET /v1/interpretation-runs/{idempotency_key}` on +`tepp-orchestrator-loopback`. HTTP method, path, and header semantics follow +current HTTP semantics (Fielding, Nottingham, & Reschke, 2022). Fail-closed +refusal of unpublished consumers, leftover nonempty stdin, public bind, +`localhost`, `http` origins, pagination flags, review/Copilot/GitHub +credential flags, and scientific-authority promotion is repository contract +authority (ADR 0072; ADR 0071; ADR 0010; ADR 0011; ADR 0014), not an RFC +inference rule. + +Stdout is metric-free. `claim_status` remains `hypothetical`. +`scientific_authority` remains false. `tepp.scientific_acceptance.v1` never +appears. Process 0 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 the target resource. +TEPP maps that retrieval onto a bounded, hypothetical interpretation-run +identity. The RFC does not define psychometric acceptance, RMSE, causality, +or claim promotion. + +### Internal contract evidence + +- `docs/adr/0072-interpretation-run-retrieval-cli.md` — this CLI +- `docs/adr/0071-interpretation-run-retrieval-get.md` — GET-by-id HTTP +- `docs/adr/0064-interpretation-run-cli.md` — create CLI +- `docs/adr/0010-adaptive-llm-orchestration.md` — mode vocabulary and + scientific-authority separation +- `docs/adr/0011-standalone-modular-msa-boundary.md` — modular HTTP boundary +- `docs/adr/0014-scientific-claim-promotion-and-release-evidence.md` — process + 0 is not a scientific claim +- `crates/orchestrator_live/tests/interpretation_run_retrieval_cli_contract.rs` + — fail-closed retrieval CLI proofs + +## Verification + +- `tepp-interpretation-run-get get` of an accepted contextual-orchestrator + identity returns `hypothetical` with `scientific_authority` false and + without RMSE/bias/coverage/SE-gate keys, `evidence_span_ids`, + `causal_score`, or `tepp.scientific_acceptance.v1`; +- leftover nonempty stdin, naruon or LineageWeave, public bind, `localhost`, + `http` origin, pagination flags, and credential flags fail closed. + +## Non-claims + +This slice does not implement collection CLI, analysis-run GET-by-id, export +GET, project-history retrieval CLI, persistence, production TLS, Leiden +consensus, GAP-010 Figma/export, provider execution, causal inference, or an +ADR 0014 scientific claim-promotion package.