From ebdf05ca6d7bdc49ba879ace7bf41eb720e3502c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 06:08:22 +0000 Subject: [PATCH] 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.