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-stored-request-get.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- `GET /v1/interpretation-runs/{idempotency_key}/request` returns the accepted contextual-orchestrator create request on `tepp-orchestrator-loopback` (ADR 0085). Metric-free; `scientific_authority` remains false. Does not infer causality. Naruon and LineageWeave refused. `NaruonLiveService` stays POST-only. Does not re-open cancel lineages. Not GAP-010 Figma/export, not persistence.
1 change: 1 addition & 0 deletions DOCUMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 stored-request GET doctoring | [`docs/research/interpretation-run-stored-request-get.md`](docs/research/interpretation-run-stored-request-get.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
284 changes: 284 additions & 0 deletions crates/orchestrator_live/src/interpretation_run_stored_request_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

Repository rules require 100% production line and branch coverage. The new parser, metric recursion, and service branches lack visible complete coverage evidence.

Devin Review

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

Original file line number Diff line number Diff line change
@@ -0,0 +1,284 @@
//! Provider-owned interpretation-run stored-request GET contracts.
//!
//! GAP-003A unique slice: `GET /v1/interpretation-runs/{idempotency_key}/request`
//! returns the accepted metric-free `InterpretationRunRequest` on
//! `OrchestratorLiveService` / `tepp-orchestrator-loopback` so operators who
//! hold a retrieval identity do not replay POST. The stored request stays
//! `scientific_authority=false`. `tepp.scientific_acceptance.v1` never
//! appears. This module does not duplicate GET-by-id (#438), retrieval CLI
//! (#439), collection GET (#433), collection CLI (#436), create CLI (#425),
//! analysis-run stored-request GET (#377), cancel lineages (closed), 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_retrieval_http::INTERPRETATION_RUN_RETRIEVAL_ID_MAX_LEN;
use crate::request::{host_implies_table_access, require_nonempty, INTERPRETATION_RUN_PATH};

const FORBIDDEN_STORED_REQUEST_KEYS: [&str; 12] = [
"rmse",
"rmse_standard_error",
"mean_bias",
"bias_standard_error",
"interval_coverage",
"se_gate_accepted",
"scientific_acceptance",
"causal_score",
"findings",
"evidence_text",
"report",
"event_label",
];

/// Typed GET exchange for interpretation-run stored-request retrieval.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct InterpretationRunStoredRequestHttpExchange {
/// HTTP method, always `GET`.
pub method: &'static str,
/// Absolute HTTPS target ending in `/v1/interpretation-runs/{key}/request`.
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}/request`.
///
/// # Errors
///
/// Returns [`OrchestratorLiveError::InvalidWirePayload`] for collection,
/// GET-by-id, extra segments, a hostile encoding, empty identity, slash, or
/// NUL, and [`OrchestratorLiveError::LimitExceeded`] when oversized.
pub fn interpretation_run_stored_request_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)?;
let (encoded_id, rest) = encoded
.split_once('/')
.ok_or(OrchestratorLiveError::InvalidWirePayload)?;
if rest != "request" || encoded_id.is_empty() {
return Err(OrchestratorLiveError::InvalidWirePayload);
}
let idempotency_key = decode_path_segment(encoded_id)?;
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 +67 to +76

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 keys become unretrievable

POST accepts idempotency keys containing / or exceeding 128 bytes, but interpretation_run_stored_request_path_id rejects them. Their stored requests can never be retrieved.

Prompt for agents
Align interpretation-run creation and stored-request retrieval identifier contracts. InterpretationRunRequest validation currently accepts idempotency keys that the new path parser cannot represent, including slash-containing and over-128-byte keys. Either reject these keys during POST validation before storing them, with compatibility consideration for existing callers, or define and implement a path encoding/length contract that can retrieve every accepted key. Add tests that POST each boundary case and then retrieve the stored request.
Devin Review

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

}
Ok(idempotency_key)
}

/// Whether `path` is the stored-request extra-segment resource.
#[must_use]
pub fn is_interpretation_run_stored_request_path(path: &str) -> bool {
interpretation_run_stored_request_path_id(path).is_ok()
}

/// Build a credential-free contextual-orchestrator stored-request GET exchange.
///
/// # Errors
///
/// Returns a fail-closed origin or identity error.
pub fn contextual_orchestrator_interpretation_run_stored_request_exchange(
origin: &str,
idempotency_key: &str,
) -> Result<InterpretationRunStoredRequestHttpExchange, 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(InterpretationRunStoredRequestHttpExchange {
method: "GET",
target_url: format!("{origin}{INTERPRETATION_RUN_PATH}/{encoded_id}/request"),
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(),
})
}

/// Refuse stored-request JSON that already carries scientific-metric keys.
///
/// Empty payloads are admitted for the GET request body.
///
/// # Errors
///
/// Returns [`OrchestratorLiveError::InvalidWirePayload`] when a forbidden
/// metric, evidence, or causal-score key is present.
pub fn refuse_metrics_on_interpretation_run_stored_request_payload(
payload: &str,
) -> Result<(), OrchestratorLiveError> {
if payload.trim().is_empty() {
return Ok(());
}
if payload.contains("tepp.scientific_acceptance.v1") {
return Err(OrchestratorLiveError::InvalidWirePayload);
Comment on lines +146 to +147

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Schema-token identifiers block retrieval

refuse_metrics_on_interpretation_run_stored_request_payload scans every string value for the schema token. Any valid identifier containing it makes its stored request unretrievable.

Prompt for agents
Replace the raw payload substring check with structural JSON validation that rejects the scientific-acceptance schema only in contract-bearing keys or fields. Ordinary opaque values such as idempotency_key, tenant_workspace_id, snapshot_id, and evidence_span_ids must remain retrievable when they happen to contain the same text. Add a POST-then-GET regression test with the token inside a valid string field.
Devin Review

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

}
let value: serde_json::Value =
serde_json::from_str(payload).map_err(|_| OrchestratorLiveError::InvalidWirePayload)?;
refuse_metrics_on_json(&value)
}

fn refuse_metrics_on_json(value: &serde_json::Value) -> Result<(), OrchestratorLiveError> {
match value {
serde_json::Value::Object(object) => {
if FORBIDDEN_STORED_REQUEST_KEYS
.iter()
.any(|key| object.contains_key(*key))
{
return Err(OrchestratorLiveError::InvalidWirePayload);
}
for nested in object.values() {
refuse_metrics_on_json(nested)?;
}
Ok(())
}
serde_json::Value::Array(items) => {
for nested in items {
refuse_metrics_on_json(nested)?;
}
Ok(())
}
_ => Ok(()),
}
}

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_stored_request_exchange,
interpretation_run_stored_request_path_id, is_interpretation_run_stored_request_path,
};
use crate::error::OrchestratorLiveError;

#[test]
fn stored_request_exchange_is_metric_free_get_without_credentials() {
let exchange = contextual_orchestrator_interpretation_run_stored_request_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/request"));
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!(is_interpretation_run_stored_request_path(
"/v1/interpretation-runs/idem-a/request"
));
assert!(!is_interpretation_run_stored_request_path(
"/v1/interpretation-runs/idem-a"
));
assert_eq!(
interpretation_run_stored_request_path_id("/v1/interpretation-runs/idem-a/request")
.expect("id"),
"idem-a"
);
assert_eq!(
interpretation_run_stored_request_path_id("/v1/interpretation-runs/idem-a"),
Err(OrchestratorLiveError::InvalidWirePayload)
);
assert_eq!(
interpretation_run_stored_request_path_id("/v1/interpretation-runs/idem-a/cancel"),
Err(OrchestratorLiveError::InvalidWirePayload)
);
assert_eq!(
contextual_orchestrator_interpretation_run_stored_request_exchange(
"http://tepp.example.test",
"idem-a"
),
Err(OrchestratorLiveError::InvalidWirePayload)
);
}
}
13 changes: 13 additions & 0 deletions crates/orchestrator_live/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
//! 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.
//! `GET /v1/interpretation-runs/{idempotency_key}/request` returns the stored
//! create request 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
Expand All @@ -19,6 +21,7 @@ mod http;
mod interpretation_run_cli;
mod interpretation_run_collection_http;
mod interpretation_run_retrieval_http;
mod interpretation_run_stored_request_http;
mod mode;
mod request;
mod service;
Expand Down Expand Up @@ -89,6 +92,16 @@ pub use interpretation_run_retrieval_http::interpretation_run_retrieval_path_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;
/// Build a credential-free contextual-orchestrator stored-request GET exchange.
pub use interpretation_run_stored_request_http::contextual_orchestrator_interpretation_run_stored_request_exchange;
/// Extract the opaque idempotency key from a stored-request GET path.
pub use interpretation_run_stored_request_http::interpretation_run_stored_request_path_id;
/// Whether a path is the stored-request extra-segment resource.
pub use interpretation_run_stored_request_http::is_interpretation_run_stored_request_path;
/// Refuse metric keys on stored-request JSON.
pub use interpretation_run_stored_request_http::refuse_metrics_on_interpretation_run_stored_request_payload;
/// Typed GET exchange for interpretation-run stored-request retrieval.
pub use interpretation_run_stored_request_http::InterpretationRunStoredRequestHttpExchange;
/// Closed ADR 0010 orchestration-mode vocabulary.
pub use mode::OrchestrationMode;
/// Accepted hypothetical interpretation-run response.
Expand Down
32 changes: 32 additions & 0 deletions crates/orchestrator_live/src/service.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
//! Loopback-only live HTTP/1.1 listener for interpretation POSTs (ADR 0010/0011).
//! `GET /v1/interpretation-runs/{idempotency_key}/request` returns the stored
//! create request without POST replay.

use std::collections::HashMap;
use std::net::{SocketAddr, TcpListener, TcpStream};
Expand All @@ -18,6 +20,10 @@ use crate::interpretation_run_collection_http::{
use crate::interpretation_run_retrieval_http::{
interpretation_run_retrieval_item_json, interpretation_run_retrieval_path_id,
};
use crate::interpretation_run_stored_request_http::{
interpretation_run_stored_request_path_id, is_interpretation_run_stored_request_path,
refuse_metrics_on_interpretation_run_stored_request_payload,
};
use crate::request::{
to_json, InterpretationRunAccepted, InterpretationRunRequest, INTERPRETATION_RUN_PATH,
};
Expand All @@ -30,6 +36,8 @@ use crate::request::{
/// `GET /v1/interpretation-runs` enumerates accepted hypothetical runs as
/// metric-free identities. `GET /v1/interpretation-runs/{idempotency_key}`
/// returns one of those identities without POST replay.
/// `GET /v1/interpretation-runs/{idempotency_key}/request` returns the stored
/// create request without POST replay.
#[derive(Debug)]
pub struct OrchestratorLiveService {
listener: Option<TcpListener>,
Expand Down Expand Up @@ -180,6 +188,9 @@ impl OrchestratorLiveService {
if is_interpretation_run_collection_path(path) {
return self.list_interpretation_runs(path, &headers, body);
}
if is_interpretation_run_stored_request_path(path) {
return self.get_interpretation_run_stored_request(path, &headers, body);
}
Comment on lines +191 to +193

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Oversized paths return wrong status

An oversized stored-request path makes is_interpretation_run_stored_request_path return false, so dispatch uses GET-by-id parsing. Clients receive 400 instead of 413.

Prompt for agents
Route syntactically recognizable stored-request resource paths to get_interpretation_run_stored_request even when identifier validation fails. The current is_ok predicate hides LimitExceeded and sends the request to GET-by-id parsing, which converts the response to InvalidWirePayload. Preserve rejection of unrelated extra segments such as /cancel, and add a service-level test asserting 413 for an over-limit /request path.
Devin Review

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

return self.get_interpretation_run(path, &headers, body);
}
if method != "POST" || path != INTERPRETATION_RUN_PATH {
Expand Down Expand Up @@ -261,6 +272,27 @@ impl OrchestratorLiveService {
))
}

fn get_interpretation_run_stored_request(
&self,
path: &str,
headers: &HashMap<String, String>,
body: &str,
) -> Result<OrchestratorLiveResponse, OrchestratorLiveError> {
let idempotency_key = interpretation_run_stored_request_path_id(path)?;
if !body.is_empty() {
return Err(OrchestratorLiveError::InvalidWirePayload);
}
refuse_retrieval_get_headers(headers)?;
let stored = self
.accepted_runs
.get(&idempotency_key)
.map(|(request, _)| request)
.ok_or(OrchestratorLiveError::InvalidWirePayload)?;
let payload = stored.to_json()?;
refuse_metrics_on_interpretation_run_stored_request_payload(&payload)?;
Ok(OrchestratorLiveResponse::json(200, "OK", payload))
}

fn accept_interpretation_run(
&mut self,
headers: &HashMap<String, String>,
Expand Down
Loading
Loading