Skip to content
Draft
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
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- `tepp_api` adds `lineageweave_analysis_run_idempotency_lookup_exchange`, Naruon compatibility-listener idempotency-lookup GET (accepted creates return a real `run_id`), and a `tepp-loopback` TCP create-then-GET proof (ADR 0047). Metric-free lookup fields are unchanged from ADR 0037. Not GET status, not lifecycle POST, not an ADR 0014 claim.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ All notable changes to TEPP are documented here. The format follows Keep a Chang

## [Unreleased]

- Loopback idempotency-lookup consumer parity: LineageWeave GET-by-idempotency exchange, Naruon compatibility-listener inspect (accepted creates return a real `run_id`), and `tepp-loopback` TCP create-then-GET proof (ADR 0047).

- `tepp_api` serves `GET /v1/analysis-runs` on the shared loopback listener (ADR 0031). Operators enumerate accepted, running, cancelled, and terminal runs as metric-free collection rows. Collection bodies refuse RMSE/bias/coverage/SE-gate/scientific-acceptance/`terminal_result` keys. GET-by-id and running/terminal POST remain later GAP-003A slices; this is not an ADR 0014 claim.

- `tepp_api` serves `POST /v1/analysis-runs/{run_id}/cancel` on the shared loopback listener (ADR 0029). Accepted and running runs become metric-free `cancelled` status. Succeeded, failed, and unknown runs cannot be cancelled. Cancel bodies refuse RMSE/bias/coverage/SE-gate/scientific-acceptance keys. GET status and running/terminal POST remain later GAP-003A slices; this is not an ADR 0014 claim.
Expand Down
1 change: 1 addition & 0 deletions DOCUMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ TEPP's approved PRD v0.4 and implementation plan are the primary product baselin
| Analysis-run stored-request HTTP doctoring | [`docs/research/analysis-run-stored-request-http.md`](docs/research/analysis-run-stored-request-http.md) |
| Analysis-run retry-lineage HTTP doctoring | [`docs/research/analysis-run-retry-lineage-http.md`](docs/research/analysis-run-retry-lineage-http.md) |
| Analysis-run idempotency-key lookup HTTP doctoring | [`docs/research/analysis-run-idempotency-lookup-http.md`](docs/research/analysis-run-idempotency-lookup-http.md) |
| Analysis-run idempotency-lookup consumer-parity doctoring | [`docs/research/analysis-run-idempotency-lookup-consumer-parity.md`](docs/research/analysis-run-idempotency-lookup-consumer-parity.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
2 changes: 2 additions & 0 deletions crates/tepp_api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,8 @@ pub use lineageweave_http::LINEAGEWEAVE_CONSUMER_CODE;
pub use lineageweave_http::NARUON_CONSUMER_CODE;
/// Build a `LineageWeave` analysis-run exchange without provider credentials.
pub use lineageweave_http::lineageweave_analysis_run_exchange;
/// Build a `LineageWeave` idempotency-lookup GET without provider credentials.
pub use lineageweave_http::lineageweave_analysis_run_idempotency_lookup_exchange;
/// Build a `LineageWeave` project-history exchange without provider credentials.
pub use lineageweave_http::lineageweave_project_history_exchange;
/// Build a credential-free `LineageWeave` temporal-context exchange.
Expand Down
72 changes: 71 additions & 1 deletion crates/tepp_api/src/lineageweave_http.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
//! Published modular-consumer identity and `LineageWeave` TEPP exchanges.

use crate::analysis_run_idempotency_lookup_http::naruon_analysis_run_idempotency_lookup_exchange;
use crate::naruon_http::compose_https_target;
use crate::project_history::build_project_history_exchange;
use crate::{
Expand Down Expand Up @@ -38,6 +39,29 @@ pub fn lineageweave_analysis_run_exchange(
Ok(exchange)
}

/// Build a `LineageWeave` → TEPP idempotency-lookup GET without credentials.
///
/// The function reuses TEPP's existing origin and identity validation, then
/// replaces only the published modular-consumer identity. The response remains
/// a metric-free resolve of a 202 receipt key to a durable `run_id`.
///
/// # Errors
///
/// Returns the same fail-closed errors as [`naruon_analysis_run_idempotency_lookup_exchange`].
pub fn lineageweave_analysis_run_idempotency_lookup_exchange(
origin: &str,
idempotency_key: &str,
) -> Result<NaruonHttpExchange, ApiError> {
let mut exchange = naruon_analysis_run_idempotency_lookup_exchange(origin, idempotency_key)?;
let consumer_header = exchange
.headers
.iter_mut()
.find(|(name, _)| name.eq_ignore_ascii_case("tepp-consumer"))
.ok_or(ApiError::InvalidWirePayload)?;
LINEAGEWEAVE_CONSUMER_CODE.clone_into(&mut consumer_header.1);
Ok(exchange)
}

/// Build a credential-free `LineageWeave` temporal-context exchange.
///
/// # Errors
Expand Down Expand Up @@ -94,7 +118,7 @@ pub(crate) fn consumer_is_supported(consumer_code: &str) -> bool {
mod tests {
use super::{
LINEAGEWEAVE_CONSUMER_CODE, NARUON_CONSUMER_CODE, consumer_is_supported,
lineageweave_analysis_run_exchange,
lineageweave_analysis_run_exchange, lineageweave_analysis_run_idempotency_lookup_exchange,
};
use crate::{ANALYSIS_RUN_CONTRACT_VERSION, AnalysisRunRequest, ApiError};

Expand Down Expand Up @@ -132,4 +156,50 @@ mod tests {
Err(ApiError::InvalidWirePayload)
);
}

#[test]
fn lineageweave_idempotency_lookup_exchange_swaps_only_the_consumer_header() {
let exchange = lineageweave_analysis_run_idempotency_lookup_exchange(
"https://tepp.example.test",
"idem-1",
)
.expect("exchange");
assert_eq!(exchange.method, "GET");
assert_eq!(
exchange.target_url,
"https://tepp.example.test/v1/analysis-runs/by-idempotency/idem-1"
);
assert!(exchange.body.is_empty());
assert!(
exchange
.headers
.contains(&("tepp-consumer".into(), LINEAGEWEAVE_CONSUMER_CODE.into()))
);
assert!(
!exchange
.headers
.contains(&("tepp-consumer".into(), NARUON_CONSUMER_CODE.into()))
);
assert!(exchange.headers.iter().all(|(name, _)| {
!matches!(
name.to_ascii_lowercase().as_str(),
"authorization"
| "proxy-authorization"
| "cookie"
| "x-api-key"
| "idempotency-key"
)
}));
assert_eq!(
lineageweave_analysis_run_idempotency_lookup_exchange(
"http://tepp.example.test",
"idem-1"
),
Err(ApiError::InvalidWirePayload)
);
assert_eq!(
lineageweave_analysis_run_idempotency_lookup_exchange("https://tepp.example.test", ""),
Err(ApiError::InvalidWirePayload)
);
}
}
143 changes: 138 additions & 5 deletions crates/tepp_api/src/naruon_live.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ use std::io::{Read, Write};
use std::net::{SocketAddr, TcpListener, TcpStream};
use std::time::Duration;

use crate::analysis_run_idempotency_lookup_http::{
AnalysisRunIdempotencyLookup, analysis_run_idempotency_lookup_path_key,
refuse_metrics_on_idempotency_lookup_payload,
};
use crate::authorization::{
AnalyticalPurpose, ExportAuthorizationRequest, authorize_export, require_export_allowed,
};
Expand All @@ -16,7 +20,7 @@ use crate::live_http::{
use crate::naruon_http::{NARUON_ANALYSIS_RUN_PATH, NARUON_EXPORT_PATH};
use crate::wire::{from_json, to_json};
use crate::{
AnalysisRunAccepted, AnalysisRunRequest, ApiError, ErrorEnvelope,
AnalysisRunAccepted, AnalysisRunRequest, AnalysisRunStatusState, ApiError, ErrorEnvelope,
requests_are_idempotent_matches,
};

Expand Down Expand Up @@ -51,7 +55,7 @@ pub struct NaruonLiveResponse {
/// Production interchange origins remain `https` only. This listener binds
/// loopback TCP so tests and local standalone operation can prove request
/// handling without claiming TLS termination or cross-service table access.
/// This port only accepts versioned naruon POSTs.
/// This port accepts versioned naruon POSTs and Naruon-only idempotency-lookup GET.
#[derive(Debug)]
pub struct NaruonLiveService {
listener: Option<TcpListener>,
Expand Down Expand Up @@ -198,14 +202,24 @@ impl NaruonLiveService {
let mut lines = header_block.split("\r\n");
let request_line = lines.next().unwrap_or("");
let (method, path) = parse_request_line(request_line)?;
let headers = parse_headers(lines)?;
if method == "GET" {
if matches!(
analysis_run_idempotency_lookup_path_key(path),
Ok(_) | Err(ApiError::LimitExceeded)
) {
refuse_live_headers(&headers, self.bound_addr, false)?;
return self.lookup_analysis_run_by_idempotency(path, &headers, body);
}
return Err(ApiError::InvalidWirePayload);
}
if method != "POST" {
return Err(ApiError::InvalidWirePayload);
}
if path != NARUON_ANALYSIS_RUN_PATH && path != NARUON_EXPORT_PATH {
return Err(ApiError::InvalidWirePayload);
}
let headers = parse_headers(lines)?;
refuse_live_headers(&headers, self.bound_addr)?;
refuse_live_headers(&headers, self.bound_addr, true)?;
self.dispatch_path(path, &headers, body)
}

Expand Down Expand Up @@ -252,6 +266,37 @@ impl NaruonLiveService {
Ok(NaruonLiveResponse::json(202, "Accepted", body))
}

fn lookup_analysis_run_by_idempotency(
&self,
path: &str,
_headers: &HashMap<String, String>,
body: &str,
) -> Result<NaruonLiveResponse, ApiError> {
let idempotency_key = analysis_run_idempotency_lookup_path_key(path)?;
if !body.trim().is_empty() {
return Err(ApiError::InvalidWirePayload);
}
refuse_metrics_on_idempotency_lookup_payload(body)?;
let mut matches: Vec<&AnalysisRunAccepted> = self
.accepted_runs
.values()
.filter(|(_, accepted)| accepted.idempotency_key == idempotency_key)
.map(|(_, accepted)| accepted)
.collect();
if matches.len() != 1 {
return Err(ApiError::InvalidWirePayload);
}
let stored = matches.remove(0);
let payload = AnalysisRunIdempotencyLookup::new(
stored.run_id.clone(),
AnalysisRunStatusState::Accepted,
stored.idempotency_key.clone(),
)?;
let response_body = payload.to_json()?;
refuse_metrics_on_idempotency_lookup_payload(&response_body)?;
Ok(NaruonLiveResponse::json(200, "OK", response_body))
}

fn authorize_export(
headers: &HashMap<String, String>,
body: &str,
Expand Down Expand Up @@ -326,6 +371,7 @@ fn status_for(error: ApiError) -> (u16, &'static str) {
fn refuse_live_headers(
headers: &HashMap<String, String>,
bound_addr: Option<SocketAddr>,
require_idempotency: bool,
) -> Result<(), ApiError> {
validate_common_headers(headers, bound_addr)?;
if header_value(headers, "tepp-consumer")? != NARUON_CONSUMER_CODE {
Expand All @@ -334,7 +380,9 @@ fn refuse_live_headers(
if header_value(headers, "tepp-contract-version")? != "1" {
return Err(ApiError::InvalidWirePayload);
}
let _idempotency_key = header_value(headers, "idempotency-key")?;
if require_idempotency {
let _idempotency_key = header_value(headers, "idempotency-key")?;
}
Ok(())
}

Expand Down Expand Up @@ -531,4 +579,89 @@ mod tests {
ApiError::InvalidWirePayload
);
}

#[test]
#[allow(clippy::too_many_lines)]
fn naruon_compatibility_listener_looks_up_accepted_idempotency_key() {
use crate::{
ANALYSIS_RUN_CONTRACT_VERSION, AnalysisRunIdempotencyLookup, AnalysisRunRequest,
};

let run = AnalysisRunRequest {
contract_version: ANALYSIS_RUN_CONTRACT_VERSION,
idempotency_key: "naruon-lookup-idem".into(),
tenant_workspace_id: "naruon-lookup-tenant".into(),
snapshot_id: "naruon-lookup-snapshot".into(),
knowledge_cutoff: "2026-08-01T00:00:00Z".into(),
model_contract_version: "tepp-analysis-run-v1".into(),
output_profile: "calibrated_event_measurement".into(),
};
let body = run.to_json().expect("run json");
let create = format!(
"POST /v1/analysis-runs HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: naruon\r\ntepp-contract-version: 1\r\nidempotency-key: naruon-lookup-idem\r\ncontent-length: {}\r\n\r\n{body}",
body.len()
);
let mut service = NaruonLiveService::new();
let accepted = service.handle_http_request(&create);
assert_eq!(accepted.status_code, 202);
let run_id = serde_json::from_str::<serde_json::Value>(&accepted.body)
.expect("accepted json")["run_id"]
.as_str()
.expect("run_id")
.to_owned();

let inspect =
"GET /v1/analysis-runs/by-idempotency/naruon-lookup-idem HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: naruon\r\ntepp-contract-version: 1\r\ncontent-length: 0\r\n\r\n";
let inspected = service.handle_http_request(inspect);
assert_eq!(inspected.status_code, 200);
let lookup = AnalysisRunIdempotencyLookup::from_json(&inspected.body).expect("lookup");
assert_eq!(lookup.run_id, run_id);
assert_eq!(lookup.run_state, crate::AnalysisRunStatusState::Accepted);
assert_eq!(lookup.idempotency_key, run.idempotency_key);
assert!(!inspected.body.contains("rmse"));
assert!(!inspected.body.contains("scientific_acceptance"));
assert!(!inspected.body.contains("tenant_workspace_id"));
assert!(!inspected.body.contains("snapshot_id"));

let replay = service.handle_http_request(inspect);
assert_eq!(replay.body, inspected.body);

let lineageweave = "GET /v1/analysis-runs/by-idempotency/naruon-lookup-idem HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: lineageweave\r\ntepp-contract-version: 1\r\ncontent-length: 0\r\n\r\n";
assert_eq!(service.handle_http_request(lineageweave).status_code, 400);
assert_eq!(
service
.handle_http_request(
"GET /v1/analysis-runs/by-idempotency/missing-key HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: naruon\r\ntepp-contract-version: 1\r\ncontent-length: 0\r\n\r\n"
)
.status_code,
400
);
assert_eq!(
service
.handle_http_request(
"GET /v1/analysis-runs/by-idempotency/naruon-lookup-idem HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: naruon\r\ntepp-contract-version: 1\r\ncontent-length: 2\r\n\r\n{}"
)
.status_code,
400
);
assert_eq!(
service
.handle_http_request(
"POST /v1/analysis-runs/by-idempotency/naruon-lookup-idem HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: naruon\r\ntepp-contract-version: 1\r\nidempotency-key: naruon-lookup-idem\r\ncontent-length: 0\r\n\r\n"
)
.status_code,
400
);
let oversized = "a".repeat(129);
assert_eq!(
service
.handle_http_request(&format!(
"GET /v1/analysis-runs/by-idempotency/{oversized} HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: naruon\r\ntepp-contract-version: 1\r\ncontent-length: 0\r\n\r\n"
))
.status_code,
413
);
let metrics = "GET /v1/analysis-runs/by-idempotency/naruon-lookup-idem HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: naruon\r\ntepp-contract-version: 1\r\ncontent-length: 16\r\n\r\n{\"rmse\":0.1}";
assert_eq!(service.handle_http_request(metrics).status_code, 400);
}
}
Loading
Loading