Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 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/export-idempotency-lookup-http.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- `tepp_api` loopback `GET /v1/exports/by-idempotency/{idempotency_key}` returns the metric-free identity of the unique naruon export that used that key on `AnalysisRunLiveService`, so operators can jump from a 200 authorization receipt to `export_id` without scanning identities (ADR 0093). `NaruonLiveService` stays POST-only. LineageWeave is refused. Not GET-by-id, not collection GET, not stored-request GET, not analysis-run lookup, not cancel, 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 @@ -73,6 +73,7 @@ TEPP's approved PRD v0.4 and implementation plan are the primary product baselin
| Analysis engine gap-closure doctoring | [`docs/doctoring/analysis-engine-gap-closure.md`](docs/doctoring/analysis-engine-gap-closure.md) |
| Corpus-split leakage-audit wire doctoring | [`docs/research/corpus-split-manifest-wire.md`](docs/research/corpus-split-manifest-wire.md) |
| Unicode canonical-identity doctoring | [`docs/research/unicode-canonical-identity.md`](docs/research/unicode-canonical-identity.md) |
| Export idempotency-key lookup HTTP doctoring | [`docs/research/export-idempotency-lookup-http.md`](docs/research/export-idempotency-lookup-http.md) |
| Change history | [`CHANGELOG.md`](CHANGELOG.md) |

## Maturity vocabulary
Expand Down
140 changes: 139 additions & 1 deletion crates/tepp_api/src/analysis_run_live.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
//! This module keeps the Naruon compatibility listener intact while providing
//! the shared `/v1/analysis-runs` and cutoff-safe `/v1/temporal-context`
//! boundaries needed by Naruon and `LineageWeave`. Naruon may also POST and
//! GET `/v1/exports/{export_id}` for metric-free purpose-bound retrieval.
//! GET `/v1/exports/{export_id}` for metric-free purpose-bound retrieval
//! and `GET /v1/exports/by-idempotency/{idempotency_key}` for key lookup.
//! It accepts transport acknowledgements, temporal evidence context, and
//! export identities only; completed psychometric results remain outside this
//! crate.
Expand All @@ -13,6 +14,10 @@ use std::io::Write;
use std::net::{SocketAddr, TcpListener};

use crate::export_http::{export_retrieval_path_id, refuse_metrics_on_export_retrieval_payload};
use crate::export_idempotency_lookup_http::{
ExportIdempotencyLookup, export_idempotency_lookup_path_key,
refuse_metrics_on_export_idempotency_lookup_payload,
};
use crate::lineageweave_http::{
LINEAGEWEAVE_CONSUMER_CODE, NARUON_CONSUMER_CODE, consumer_is_supported,
};
Expand Down Expand Up @@ -162,6 +167,12 @@ impl AnalysisRunLiveService {
let (method, path) = parse_request_line(lines.next().unwrap_or(""))?;
let headers = parse_headers(&mut lines)?;
if method == "GET" {
if matches!(
export_idempotency_lookup_path_key(path),
Ok(_) | Err(ApiError::LimitExceeded)
) {
return self.lookup_export_by_idempotency(path, &headers, body);
}
Comment on lines +170 to +175

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📝 Info: Oversized keys retain limit status

Routing LimitExceeded into the lookup handler preserves the documented 413 response instead of converting oversized keys to malformed-path errors.

Devin Review

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

if matches!(
export_retrieval_path_id(path),
Ok(_) | Err(ApiError::LimitExceeded)
Expand Down Expand Up @@ -342,6 +353,45 @@ impl AnalysisRunLiveService {
Ok(json_response(200, "OK", response_body))
}

fn lookup_export_by_idempotency(
&self,
path: &str,
headers: &HashMap<String, String>,
body: &str,
) -> Result<NaruonLiveResponse, ApiError> {
let idempotency_key = export_idempotency_lookup_path_key(path)?;
if !body.trim().is_empty() {
return Err(ApiError::InvalidWirePayload);
}
let consumer = require_headers(headers, self.bound_addr, false)?;
if consumer != NARUON_CONSUMER_CODE {
return Err(ApiError::InvalidWirePayload);
}
refuse_metrics_on_export_idempotency_lookup_payload(body)?;
let prefix = format!("{consumer}\u{1f}");
let mut matches: Vec<&StoredExport> = self
.authorized_exports
.iter()
.filter(|(replay_key, stored)| {
replay_key.starts_with(&prefix)
&& stored.retrieval.idempotency_key == idempotency_key
})
.map(|(_, stored)| stored)
.collect();
if matches.len() != 1 {
return Err(ApiError::InvalidWirePayload);
}
let stored = matches.remove(0);
Comment on lines +371 to +384

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📝 Info: Ambiguous tenant matches fail closed

The consumer-wide scan cannot select among tenants safely. Requiring one match avoids arbitrary resolution without exposing tenant identity.

Devin Review

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

let payload = ExportIdempotencyLookup::new(
stored.retrieval.export_id.clone(),
stored.retrieval.decision_code.clone(),
stored.retrieval.idempotency_key.clone(),
)?;
let response_body = payload.to_json()?;
refuse_metrics_on_export_idempotency_lookup_payload(&response_body)?;
Ok(json_response(200, "OK", response_body))
}

fn response_from_error(&mut self, error: ApiError) -> NaruonLiveResponse {
let request_id = format!("analysis-run-live-{}", self.next_request_serial);
self.next_request_serial += 1;
Expand Down Expand Up @@ -1202,6 +1252,77 @@ mod tests {
400
);

let looked_up = service.handle_http_request(&export_lookup_http(
"export-idem-1",
NARUON_CONSUMER_CODE,
));
assert_eq!(looked_up.status_code, 200);
let lookup = crate::ExportIdempotencyLookup::from_json(&looked_up.body).expect("lookup");
assert_eq!(lookup.export_id, retrieval.export_id);
assert_eq!(lookup.idempotency_key, "export-idem-1");
assert_eq!(lookup.decision_code, "purpose_bound_export_allowed");
assert!(!looked_up.body.contains("tenant_workspace_id"));
assert!(!looked_up.body.contains("principal_id"));
assert!(!looked_up.body.contains("includes_source_text"));
assert!(!looked_up.body.contains("scientific_acceptance"));
assert!(!looked_up.body.contains("rmse"));
assert_eq!(
service
.handle_http_request(&export_lookup_http(
"export-idem-1",
LINEAGEWEAVE_CONSUMER_CODE
))
.status_code,
400
);
assert_eq!(
service
.handle_http_request(&export_lookup_http("missing-key", NARUON_CONSUMER_CODE))
.status_code,
400
);
assert_eq!(
service
.handle_http_request(&export_lookup_body_http(
"export-idem-1",
NARUON_CONSUMER_CODE,
"{}",
))
.status_code,
400
);
assert_eq!(
service
.handle_http_request(&export_lookup_post_http(
"export-idem-1",
NARUON_CONSUMER_CODE,
))
.status_code,
400
);
assert_eq!(
service
.handle_http_request(&export_get_http("by-idempotency", NARUON_CONSUMER_CODE))
.status_code,
400
);

let mut other_tenant = request.clone();
other_tenant.tenant_workspace_id = "export-live-tenant-b".into();
let other_body = crate::wire::to_json(&other_tenant).expect("other json");
let other_posted = service.handle_http_request(&export_post_http(
&other_body,
NARUON_CONSUMER_CODE,
"export-idem-1",
));
assert_eq!(other_posted.status_code, 200);
assert_eq!(
service
.handle_http_request(&export_lookup_http("export-idem-1", NARUON_CONSUMER_CODE))
.status_code,
400
);

let principal_as_key = service.handle_http_request(&export_post_http(
&body,
NARUON_CONSUMER_CODE,
Expand Down Expand Up @@ -1233,6 +1354,23 @@ mod tests {
)
}

fn export_lookup_http(idempotency_key: &str, consumer: &str) -> String {
export_lookup_body_http(idempotency_key, consumer, "")
}

fn export_lookup_body_http(idempotency_key: &str, consumer: &str, body: &str) -> String {
format!(
"GET {NARUON_EXPORT_PATH}/by-idempotency/{idempotency_key} HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: {consumer}\r\ntepp-contract-version: 1\r\ncontent-length: {}\r\n\r\n{body}",
body.len()
)
}

fn export_lookup_post_http(idempotency_key: &str, consumer: &str) -> String {
format!(
"POST {NARUON_EXPORT_PATH}/by-idempotency/{idempotency_key} HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: {consumer}\r\ntepp-contract-version: 1\r\nidempotency-key: {idempotency_key}\r\ncontent-length: 0\r\n\r\n"
)
}

struct ScriptedRead {
reader: Cursor<Vec<u8>>,
first_error: Option<std::io::ErrorKind>,
Expand Down
7 changes: 7 additions & 0 deletions crates/tepp_api/src/export_http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,9 @@ pub(crate) fn export_retrieval_path_id(path: &str) -> Result<String, ApiError> {
return Err(ApiError::InvalidWirePayload);
}
let export_id = decode_path_segment(encoded)?;
if export_id == "by-idempotency" {
return Err(ApiError::InvalidWirePayload);
Comment thread
seonghobae marked this conversation as resolved.
Outdated
}
if export_id.len() > EXPORT_RETRIEVAL_ID_MAX_LEN {
return Err(ApiError::LimitExceeded);
}
Expand Down Expand Up @@ -456,6 +459,10 @@ mod tests {
export_retrieval_path_id("/v1/exports/a/b"),
Err(ApiError::InvalidWirePayload)
);
assert_eq!(
export_retrieval_path_id("/v1/exports/by-idempotency"),
Err(ApiError::InvalidWirePayload)
);
assert_eq!(
export_retrieval_path_id("/v1/exports/%"),
Err(ApiError::InvalidWirePayload)
Expand Down
Loading
Loading