Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.d/interpretation-run-retrieval-http.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions DOCUMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
24 changes: 21 additions & 3 deletions crates/orchestrator_live/src/http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<String, String>,
) -> 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<String, String>,
) -> Result<(), OrchestratorLiveError> {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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),
Expand Down
300 changes: 300 additions & 0 deletions crates/orchestrator_live/src/interpretation_run_retrieval_http.rs

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Coverage evidence needs follow-up

The repository requires 100% branch coverage. Added tests omit retrieval cursor refusal and several decoding branches, while the verification list provides no coverage run.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Original file line number Diff line number Diff line change
@@ -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<String, OrchestratorLiveError> {
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);
Comment on lines +62 to +66

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Accepted runs become unretrievable

When POST accepts a key containing /, controls, or over 128 bytes, interpretation_run_retrieval_path_id rejects it. GET cannot retrieve the accepted run.

Prompt for agents
Unify idempotency-key validation across POST creation, collection rows/cursors, the retrieval exchange builder, and GET path decoding. Today InterpretationRunRequest accepts slash, control-character, and arbitrarily long keys, while interpretation_run_retrieval_path_id rejects them and the exchange builder applies a different subset. Either make every accepted POST key representable by GET-by-id or reject unsupported keys before accepting and storing a run. Add round-trip tests for all boundary characters and the 128-byte limit.
Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

}
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<String, OrchestratorLiveError> {
let payload = to_json(item)?;
refuse_metrics_on_interpretation_run_collection_payload(&payload)?;
Comment on lines +79 to +80

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Invalid identities gain authority

Callers can mutate public item fields before interpretation_run_retrieval_item_json serializes them. It emits non-hypothetical or scientifically authoritative identities without error.

Prompt for agents
Make interpretation_run_retrieval_item_json validate the supplied InterpretationRunCollectionItem before serialization. The fields are public, so construction through InterpretationRunCollectionItem::new is not sufficient. Reuse or expose the collection item's validation rather than duplicating claim-status and scientific-authority checks, and add tests that mutate claim_status and scientific_authority before calling the serializer.
Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +79 to +80

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Reserved text blocks retrieval

When an accepted key contains tepp.scientific_acceptance.v1, refuse_metrics_on_interpretation_run_collection_payload rejects its serialized identity. GET returns 400 for an existing run.

Prompt for agents
Change the metric/scientific-acceptance filter to inspect JSON structure instead of rejecting the raw substring everywhere. Opaque identifier values may legitimately contain tepp.scientific_acceptance.v1. Reject that identifier only where it represents a forbidden schema/key, preserve opaque values, and add a POST-then-GET regression test using an idempotency key containing the text.
Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

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<InterpretationRunRetrievalHttpExchange, OrchestratorLiveError> {
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<String, OrchestratorLiveError> {
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<u8, OrchestratorLiveError> {
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)
);
}
}
Loading
Loading