Skip to content
Draft
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
515fd3b
feat(api): resolve export identity by idempotency key on loopback
seonghobae Sep 1, 2026
77de1af
feat(api): mint export idempotency-lookup GET from a dedicated CLI
seonghobae Sep 1, 2026
79cb5d6
test(api): reproduce export lookup review defects
seonghobae Sep 1, 2026
e40b407
fix(api): make export idempotency lookup path-safe and metric-recursive
seonghobae Sep 1, 2026
0fd64f7
fix(api): reject reserved export retrieval identities at construction
seonghobae Sep 1, 2026
0c5efc3
merge(api): bring export lookup CLI onto current lookup GET head
seonghobae Sep 1, 2026
29e87b8
feat(api): retrieve stored export authorization by idempotency key
seonghobae Sep 1, 2026
8f38c27
test(security): require export stored-request lookup isolation
seonghobae Sep 1, 2026
45754fd
fix(security): quarantine unscoped export stored-request lookup
seonghobae Sep 1, 2026
14ef78c
docs(security): quarantine unscoped export request lookup
seonghobae Sep 1, 2026
9d61148
docs(security): doctor export lookup quarantine
seonghobae Sep 1, 2026
befd69a
chore(changelog): record stored-request lookup quarantine
seonghobae Sep 1, 2026
da8088b
test(api): preserve opaque export lookup keys
seonghobae Sep 1, 2026
44deacb
fix(api): keep accepted opaque lookup keys addressable
seonghobae Sep 1, 2026
0458c85
fix(cli): preserve opaque export idempotency keys
seonghobae Sep 1, 2026
aef2cc8
test(cli): align opaque export lookup key contract
seonghobae Sep 1, 2026
1950f26
test(api): keep reserved-looking lookup keys addressable
seonghobae Sep 1, 2026
af0dfc2
docs(api): preserve opaque export lookup identity
seonghobae Sep 1, 2026
66289fb
docs(cli): align opaque export lookup keys
seonghobae Sep 1, 2026
0f3bea7
docs(api): doctor opaque export lookup compatibility
seonghobae Sep 1, 2026
777b314
docs(cli): doctor opaque export lookup compatibility
seonghobae Sep 1, 2026
12e8337
docs(api): quarantine unscoped stored-request disclosure
seonghobae Sep 1, 2026
b600198
feat(api): publish quarantine-parity export lookup stored-request CLI
seonghobae Sep 1, 2026
71f34b8
docs(api): align lookup stored-request CLI rustdoc with ADR 0099
seonghobae Sep 1, 2026
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-cli.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- `tepp_api` published `tepp-export-lookup lookup` mints `naruon_export_idempotency_lookup_exchange` onto spawned `tepp-loopback` TCP so operators can resolve a 200 export authorization receipt to `export_id` without writing raw HTTP (ADR 0094). Empty stdin is admitted. `NaruonLiveService` stays POST-only. LineageWeave is refused. Not lookup GET, not GET-by-id, not collection, not stored-request, not analysis-run lookup CLI, not cancel, not GAP-010 Figma/export, not persistence.
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.
2 changes: 2 additions & 0 deletions DOCUMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ 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) |
| Export idempotency-key lookup CLI doctoring | [`docs/research/export-idempotency-lookup-cli.md`](docs/research/export-idempotency-lookup-cli.md) |
| Change history | [`CHANGELOG.md`](CHANGELOG.md) |

## Maturity vocabulary
Expand Down
6 changes: 6 additions & 0 deletions crates/tepp_api/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,11 @@ path = "src/bin/tepp_loopback.rs"
test = false
bench = false

[[bin]]
name = "tepp-export-lookup"
path = "src/bin/tepp_export_lookup.rs"
test = false
bench = false

[lints]
workspace = true
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);
}
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);
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
33 changes: 33 additions & 0 deletions crates/tepp_api/src/bin/tepp_export_lookup.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
//! Operator CLI for loopback naruon export idempotency-key lookup GET.

use std::io::{self, IsTerminal};
use std::process::ExitCode;

use tepp_api::{
execute_export_idempotency_lookup_cli, read_export_idempotency_lookup_cli_stdin,
render_export_idempotency_lookup_cli_stdout, ApiError, ExportIdempotencyLookupCliInvocation,
};

fn main() -> ExitCode {
match run() {
Ok(()) => ExitCode::SUCCESS,
Err(error) => {
eprintln!("tepp-export-lookup: {error}");
ExitCode::FAILURE
}
}
}

fn run() -> Result<(), ApiError> {
let args: Vec<String> = std::env::args().skip(1).collect();
let body = read_export_idempotency_lookup_cli_stdin(io::stdin().is_terminal(), io::stdin())?;
let invocation = ExportIdempotencyLookupCliInvocation::from_args(&args, body)?;
let response = execute_export_idempotency_lookup_cli(&invocation)?;
let stdout = render_export_idempotency_lookup_cli_stdout(&invocation, &response)?;
println!("{stdout}");
if (200..300).contains(&response.status_code) {
Ok(())
} else {
Err(ApiError::InvalidWirePayload)
}
}
36 changes: 27 additions & 9 deletions crates/tepp_api/src/export_http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -197,13 +197,17 @@ fn contains_forbidden_export_key(value: &serde_json::Value) -> bool {
}
}

fn export_retrieval_id_is_reserved(export_id: &str) -> bool {
export_id == "by-idempotency"
}

/// Extract the opaque export identity from `GET /v1/exports/{export_id}`.
///
/// # Errors
///
/// Returns [`ApiError::InvalidWirePayload`] for the collection path, extra
/// segments, a hostile encoding, or an empty identity, and
/// [`ApiError::LimitExceeded`] when the decoded identity exceeds
/// segments, a reserved route identity, a hostile encoding, or an empty
/// identity, and [`ApiError::LimitExceeded`] when the decoded identity exceeds
/// [`EXPORT_RETRIEVAL_ID_MAX_LEN`].
pub(crate) fn export_retrieval_path_id(path: &str) -> Result<String, ApiError> {
let remainder = path
Expand All @@ -216,6 +220,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_retrieval_id_is_reserved(&export_id) {
return Err(ApiError::InvalidWirePayload);
}
if export_id.len() > EXPORT_RETRIEVAL_ID_MAX_LEN {
return Err(ApiError::LimitExceeded);
}
Expand All @@ -224,21 +231,24 @@ pub(crate) fn export_retrieval_path_id(path: &str) -> Result<String, ApiError> {

/// Build a provider-owned `GET` export-retrieval exchange.
///
/// The builder refuses non-`https` origins and empty or oversized identities.
/// It does not inject credentials. The GET body is empty. The identity
/// travels in the path; the builder does not send an `idempotency-key`
/// header.
/// The builder refuses non-`https` origins, empty or oversized identities, and
/// identities reserved for collection sub-routes. It does not inject
/// credentials. The GET body is empty. The identity travels in the path; the
/// builder does not send an `idempotency-key` header.
///
/// # Errors
///
/// Returns [`ApiError::InvalidWirePayload`] for a non-`https` origin or empty
/// identity, and [`ApiError::LimitExceeded`] when the identity exceeds
/// [`EXPORT_RETRIEVAL_ID_MAX_LEN`] bytes.
/// Returns [`ApiError::InvalidWirePayload`] for a non-`https` origin, empty
/// identity, or reserved route identity, and [`ApiError::LimitExceeded`] when
/// the identity exceeds [`EXPORT_RETRIEVAL_ID_MAX_LEN`] bytes.
pub fn naruon_export_retrieval_exchange(
origin: &str,
export_id: &str,
) -> Result<NaruonHttpExchange, ApiError> {
require_nonempty(export_id)?;
if export_retrieval_id_is_reserved(export_id) {
return Err(ApiError::InvalidWirePayload);
}
if export_id.len() > EXPORT_RETRIEVAL_ID_MAX_LEN {
return Err(ApiError::LimitExceeded);
}
Expand Down Expand Up @@ -456,6 +466,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 Expand Up @@ -515,6 +529,10 @@ mod tests {
naruon_export_retrieval_exchange("https://tepp.example.test", ""),
Err(ApiError::InvalidWirePayload)
);
assert_eq!(
naruon_export_retrieval_exchange("https://tepp.example.test", "by-idempotency"),
Err(ApiError::InvalidWirePayload)
);
assert_eq!(
naruon_export_retrieval_exchange(
"https://tepp.example.test",
Expand Down
Loading
Loading