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/export-cancel-http.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- `POST /v1/exports/{export_id}/cancel` on `AnalysisRunLiveService` / `tepp-loopback` removes one authorized naruon export identity (ADR 0077). Metric-free `cancelled=true` receipts only. `tepp.scientific_acceptance.v1` never appears. Does not infer causality. LineageWeave refused. `NaruonLiveService` stays POST-only. Not analysis-run cancel, not interpretation-run 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 @@ -14,6 +14,7 @@ TEPP's approved PRD v0.4 and implementation plan are the primary product baselin
| contextual-orchestrator interpretation port | [`docs/connectors/contextual-orchestrator-interpretation-port.md`](docs/connectors/contextual-orchestrator-interpretation-port.md) |
| Orchestrator live HTTP doctoring | [`docs/research/orchestrator-live-http.md`](docs/research/orchestrator-live-http.md) |
| Export collection GET doctoring | [`docs/research/export-collection-http.md`](docs/research/export-collection-http.md) |
| Export cancel HTTP doctoring | [`docs/research/export-cancel-http.md`](docs/research/export-cancel-http.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
130 changes: 130 additions & 0 deletions crates/tepp_api/src/analysis_run_live.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ use std::collections::HashMap;
use std::io::Write;
use std::net::{SocketAddr, TcpListener};

use crate::export_cancel_http::{export_cancel_path_id, ExportCancelled};
use crate::export_collection_http::{
is_export_collection_path, page_export_collection_items, parse_export_collection_page_cursor,
parse_export_collection_page_limit, ExportCollection,
Expand Down Expand Up @@ -184,6 +185,12 @@ impl AnalysisRunLiveService {
if path == NARUON_EXPORT_PATH {
return self.accept_export(&headers, body);
}
if matches!(
export_cancel_path_id(path),
Ok(_) | Err(ApiError::LimitExceeded)
) {
return self.cancel_export(path, &headers, body);
}
if path != NARUON_ANALYSIS_RUN_PATH
&& path != TEMPORAL_CONTEXT_PATH
&& path != PROJECT_HISTORY_PATH
Expand Down Expand Up @@ -381,6 +388,38 @@ impl AnalysisRunLiveService {
Ok(json_response(200, "OK", collection.to_json()?))
}

fn cancel_export(
&mut self,
path: &str,
headers: &HashMap<String, String>,
body: &str,
) -> Result<NaruonLiveResponse, ApiError> {
let export_id = export_cancel_path_id(path)?;
if !body.trim().is_empty() {
return Err(ApiError::InvalidWirePayload);
}
refuse_metrics_on_export_retrieval_payload(body)?;
let consumer = require_headers(headers, self.bound_addr, false)?;
if consumer != NARUON_CONSUMER_CODE {
return Err(ApiError::InvalidWirePayload);
}
Comment on lines +402 to +405

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟥 Local callers can cancel others' exports

The endpoint trusts tepp-consumer: naruon without caller proof or export ownership. Any local process holding an export ID can revoke it.

Devin Review

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

if headers.contains_key("idempotency-key") {
return Err(ApiError::InvalidWirePayload);
}
let replay_key = self
.exports_by_id
.remove(&export_id)
.ok_or(ApiError::InvalidWirePayload)?;
let stored = self
.authorized_exports
.remove(&replay_key)
.ok_or(ApiError::InvalidWirePayload)?;
let cancelled = ExportCancelled::from_retrieval(stored.retrieval)?;
Comment on lines +413 to +417

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 Failed cancellation still removes export

A slash-bearing authorization key makes from_retrieval fail after the export is removed. The client receives 400, but the export disappears permanently.

Prompt for agents
Make cancel_export atomic and compatible with every export accepted by accept_export. Currently idempotency-key values containing '/' or NUL pass authorization and ExportRetrieval validation, but ExportCancelled::from_retrieval rejects them after exports_by_id and authorized_exports have already been mutated. Validate and serialize the cancellation receipt before changing either map, then commit both removals only after all fallible work succeeds. Also align ExportCancelled validation with the accepted ExportRetrieval contract so an authorized export cannot become uncancellable.
Devin Review

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

let response_body = cancelled.to_json()?;
refuse_metrics_on_export_retrieval_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 @@ -1258,6 +1297,91 @@ mod tests {
);
}

#[test]
fn handler_covers_metric_free_export_cancel() {
use crate::{
AnalyticalPurpose, ExportAuthorizationRequest, ExportCancelled, ExportCollection,
ExportRetrieval,
};

let request = ExportAuthorizationRequest {
tenant_workspace_id: "export-cancel-tenant".into(),
principal_id: "principal-analyst-1".into(),
purpose: AnalyticalPurpose::ModularServiceConsumer,
artifact_id: "artifact-cancel-1".into(),
includes_source_text: false,
};
let body = crate::wire::to_json(&request).expect("export json");
let mut service = AnalysisRunLiveService::new();
let posted = service.handle_http_request(&export_post_http(
&body,
NARUON_CONSUMER_CODE,
"export-cancel-idem-1",
));
assert_eq!(posted.status_code, 200);
let retrieval = ExportRetrieval::from_json(&posted.body).expect("posted retrieval");
let cancelled = service.handle_http_request(&export_cancel_http(
&retrieval.export_id,
NARUON_CONSUMER_CODE,
));
assert_eq!(cancelled.status_code, 200, "{}", cancelled.body);
let parsed = ExportCancelled::from_json(&cancelled.body).expect("cancelled");
assert_eq!(parsed.export_id, retrieval.export_id);
assert_eq!(parsed.artifact_id, "artifact-cancel-1");
assert!(parsed.cancelled);
assert!(!cancelled.body.contains("tenant_workspace_id"));
assert!(!cancelled.body.contains("rmse"));
assert!(!cancelled.body.contains("scientific_acceptance"));
assert_eq!(
service
.handle_http_request(&export_get_http(&retrieval.export_id, NARUON_CONSUMER_CODE))
.status_code,
400
);
let listed = service.handle_http_request(&format!(
"GET {NARUON_EXPORT_PATH} 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"
));
assert_eq!(listed.status_code, 200);
let page: ExportCollection = serde_json::from_str(&listed.body).expect("page");
assert!(page.items.is_empty());
assert_eq!(
service
.handle_http_request(&export_cancel_http(
&retrieval.export_id,
NARUON_CONSUMER_CODE
))
.status_code,
400
);
assert_eq!(
service
.handle_http_request(&export_cancel_http(
&retrieval.export_id,
LINEAGEWEAVE_CONSUMER_CODE
))
.status_code,
400
);
assert_eq!(
service
.handle_http_request(&format!(
"POST {NARUON_EXPORT_PATH}/{}/cancel 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: export-cancel-idem-1\r\ncontent-length: 0\r\n\r\n",
retrieval.export_id
))
.status_code,
400
);
assert_eq!(
service
.handle_http_request(&format!(
"POST {NARUON_EXPORT_PATH}/{}/cancel 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{{}}",
retrieval.export_id
))
.status_code,
400
);
}

fn export_post_http(body: &str, consumer: &str, idempotency_key: &str) -> String {
format!(
"POST {NARUON_EXPORT_PATH} 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: {}\r\n\r\n{body}",
Expand All @@ -1271,6 +1395,12 @@ mod tests {
)
}

fn export_cancel_http(export_id: &str, consumer: &str) -> String {
format!(
"POST {NARUON_EXPORT_PATH}/{export_id}/cancel 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: 0\r\n\r\n"
)
}

struct ScriptedRead {
reader: Cursor<Vec<u8>>,
first_error: Option<std::io::ErrorKind>,
Expand Down
Loading
Loading