Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Published `tepp-export-lookup-request get` is quarantine-parity of ADR 0099: the typed exchange returns `authorization_denied` after origin/key validation and never prints a stored export-authorization request (ADR 0100). Empty stdin admitted. Public bind/`localhost`/`http` origin/unpublished consumer/LineageWeave/credential flags fail closed. Does not weaken ADR 0099. `NaruonLiveService` stays POST-only. Does not re-open cancel lineages.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Security quarantine for `GET /v1/exports/by-idempotency/{idempotency_key}/request`: exact-head review found that consumer-only lookup could disclose a stored authorization request across Naruon tenant namespaces. The client builder now fails closed until an authenticated tenant/principal binding exists; the live response guard refuses tenant/principal identity, and raw or percent-decoded slash keys are rejected. ADR 0099 records the repair. The metric-free idempotency lookup remains separate.
4 changes: 4 additions & 0 deletions DOCUMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,10 @@ 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) |
| Export idempotency-key lookup stored-request GET doctoring | [`docs/research/export-idempotency-lookup-stored-request-http.md`](docs/research/export-idempotency-lookup-stored-request-http.md) |
| Export idempotency-key lookup stored-request CLI doctoring | [`docs/research/export-idempotency-lookup-stored-request-cli.md`](docs/research/export-idempotency-lookup-stored-request-cli.md) |
| Change history | [`CHANGELOG.md`](CHANGELOG.md) |

## Maturity vocabulary
Expand Down
12 changes: 12 additions & 0 deletions crates/tepp_api/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,17 @@ 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

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

[lints]
workspace = true
186 changes: 185 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,10 @@
//! 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.
//! `GET /v1/exports/by-idempotency/{idempotency_key}/request` returns the stored
//! export-authorization request of that unique accepted export.
//! It accepts transport acknowledgements, temporal evidence context, and
//! export identities only; completed psychometric results remain outside this
//! crate.
Expand All @@ -13,6 +16,14 @@ 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::export_idempotency_lookup_stored_request_http::{
export_idempotency_lookup_stored_request_path_key,
refuse_metrics_on_export_lookup_stored_request_payload,
};
use crate::lineageweave_http::{
LINEAGEWEAVE_CONSUMER_CODE, NARUON_CONSUMER_CODE, consumer_is_supported,
};
Expand Down Expand Up @@ -162,6 +173,18 @@ 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_stored_request_path_key(path),
Ok(_) | Err(ApiError::LimitExceeded)
) {
return self.lookup_export_stored_request_by_idempotency(path, &headers, body);
}
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 +365,79 @@ 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 lookup_export_stored_request_by_idempotency(
&self,
path: &str,
headers: &HashMap<String, String>,
body: &str,
) -> Result<NaruonLiveResponse, ApiError> {
let idempotency_key = export_idempotency_lookup_stored_request_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_lookup_stored_request_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 response_body = crate::wire::to_json(&stored.request)?;
refuse_metrics_on_export_lookup_stored_request_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 +1298,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 +1400,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)
}
}
42 changes: 42 additions & 0 deletions crates/tepp_api/src/bin/tepp_export_lookup_request.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
//! Operator CLI for quarantined naruon export lookup stored-request GET.
//!
//! Compose returns `authorization_denied` after origin/key validation (ADR
//! 0099). This binary never prints a stored export-authorization request.

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

use tepp_api::{
ApiError, ExportIdempotencyLookupStoredRequestCliInvocation,
execute_export_idempotency_lookup_stored_request_cli,
read_export_idempotency_lookup_stored_request_cli_stdin,
render_export_idempotency_lookup_stored_request_cli_stdout,
};

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

fn run() -> Result<(), ApiError> {
let args: Vec<String> = std::env::args().skip(1).collect();
let body = read_export_idempotency_lookup_stored_request_cli_stdin(
io::stdin().is_terminal(),
io::stdin(),
)?;
let invocation = ExportIdempotencyLookupStoredRequestCliInvocation::from_args(&args, body)?;
let response = execute_export_idempotency_lookup_stored_request_cli(&invocation)?;
let stdout =
render_export_idempotency_lookup_stored_request_cli_stdout(&invocation, &response)?;
println!("{stdout}");
if (200..300).contains(&response.status_code) {
Ok(())
} else {
Err(ApiError::InvalidWirePayload)
}
}
Loading
Loading