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/project-history-cancel-http.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- `POST /v1/project-histories/{idempotency_key}/cancel` on `AnalysisRunLiveService` / `tepp-loopback` removes one accepted LineageWeave project-history identity (ADR 0079). Metric-free `cancelled=true` receipts with `inference_status=temporal_association_only`. `tepp.scientific_acceptance.v1` never appears. Does not infer causality. Naruon refused. `NaruonLiveService` stays POST-only. Not export 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 @@ -13,6 +13,7 @@ TEPP's approved PRD v0.4 and implementation plan are the primary product baselin
| naruon modular consumer contract | [`docs/connectors/naruon-artifact-consumer.md`](docs/connectors/naruon-artifact-consumer.md) |
| Project-history collection GET doctoring | [`docs/research/project-history-collection-http.md`](docs/research/project-history-collection-http.md) |
| Project-history GET-by-id doctoring | [`docs/research/project-history-retrieval-http.md`](docs/research/project-history-retrieval-http.md) |
| Project-history cancel HTTP doctoring | [`docs/research/project-history-cancel-http.md`](docs/research/project-history-cancel-http.md) |
| 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) |
| UML/runtime/scientific flows | [`docs/UML.md`](docs/UML.md) |
Expand Down
101 changes: 96 additions & 5 deletions crates/tepp_api/src/analysis_run_live.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,10 @@ use crate::{
TEMPORAL_CONTEXT_PATH, TemporalContextRequest, build_temporal_context,
is_project_history_collection_path, page_project_history_collection_items,
parse_project_history_collection_page_cursor, parse_project_history_collection_page_limit,
project_history_projection, project_history_retrieval_path_id,
project_history_cancel_path_id, project_history_projection, project_history_retrieval_path_id,
refuse_metrics_on_project_history_collection_payload,
refuse_metrics_on_project_history_retrieval_payload, requests_are_idempotent_matches,
ProjectHistoryCancelled,
};

const MAX_LIVE_REQUEST_BODY_BYTES: usize = DEFAULT_PROJECT_HISTORY_BYTE_LIMIT;
Expand Down Expand Up @@ -161,10 +163,18 @@ impl AnalysisRunLiveService {
}
return Err(ApiError::InvalidWirePayload);
}
if method != "POST"
|| (path != NARUON_ANALYSIS_RUN_PATH
&& path != TEMPORAL_CONTEXT_PATH
&& path != PROJECT_HISTORY_PATH)
if method != "POST" {
return Err(ApiError::InvalidWirePayload);
}
if matches!(
project_history_cancel_path_id(path),
Ok(_) | Err(ApiError::LimitExceeded)
) {
return self.cancel_project_history(path, &headers, body);
Comment on lines +169 to +173

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 paths retain status semantics

Dispatch forwards LimitExceeded paths into the cancel handler, preserving HTTP 413. Other malformed cancel paths cannot reach an existing POST endpoint.

Devin Review

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

}
if path != NARUON_ANALYSIS_RUN_PATH
&& path != TEMPORAL_CONTEXT_PATH
&& path != PROJECT_HISTORY_PATH
{
return Err(ApiError::InvalidWirePayload);
}
Expand Down Expand Up @@ -321,6 +331,40 @@ impl AnalysisRunLiveService {
Ok(json_response(200, "OK", response_body))
}

fn cancel_project_history(
&mut self,
path: &str,
headers: &HashMap<String, String>,
body: &str,
) -> Result<NaruonLiveResponse, ApiError> {
if !body.trim().is_empty() {
return Err(ApiError::InvalidWirePayload);
Comment on lines +340 to +341

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Whitespace bodies cancel histories

A whitespace-only body passes trim().is_empty() and cancels the history. The contract requires every nonempty body to fail closed.

Suggested change
if !body.trim().is_empty() {
return Err(ApiError::InvalidWirePayload);
if !body.is_empty() {
return Err(ApiError::InvalidWirePayload);
}
Devin Review

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

}
refuse_metrics_on_project_history_collection_payload(body)?;
let consumer = require_headers(headers, self.bound_addr, false)?;
if consumer != LINEAGEWEAVE_CONSUMER_CODE {
return Err(ApiError::InvalidWirePayload);
}
if headers.contains_key("idempotency-key")
|| headers.contains_key("tepp-page-limit")
|| headers.contains_key("tepp-page-cursor")
{
return Err(ApiError::InvalidWirePayload);
}
let tenant_workspace_id = header_value(headers, PROJECT_HISTORY_RETRIEVAL_TENANT_HEADER)?;
crate::project_history::validate_project_history_registry_identity(tenant_workspace_id)?;
let idempotency_key = project_history_cancel_path_id(path)?;
let replay_key =
consumer_tenant_idempotency_key(consumer, tenant_workspace_id, &idempotency_key);
Comment on lines +354 to +358

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: Tenant scoping isolates cancellation

The removal key combines consumer, tenant, and decoded identifier. Matching identifiers in different tenants address distinct stored histories.

Devin Review

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

let (request, projection) = self
.accepted_project_histories
.remove(&replay_key)
.ok_or(ApiError::InvalidWirePayload)?;
Comment on lines +354 to +362

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟥 Tenant header permits unauthorized cancellation

Any local process can set tepp-tenant-workspace-id and tepp-consumer, then cancel a matching history. The endpoint verifies identity strings but no authorization grant.

Devin Review

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

let cancelled = ProjectHistoryCancelled::from_stored(&request, &projection)?;
let response_body = cancelled.to_json()?;
Ok(json_response(200, "OK", response_body))
Comment on lines +359 to +365

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 receipts still delete histories

A project key containing tepp.scientific_acceptance.v1 makes to_json() fail after removal. The caller receives an error, but the history is gone.

Suggested change
let (request, projection) = self
.accepted_project_histories
.remove(&replay_key)
.ok_or(ApiError::InvalidWirePayload)?;
let cancelled = ProjectHistoryCancelled::from_stored(&request, &projection)?;
let response_body = cancelled.to_json()?;
Ok(json_response(200, "OK", response_body))
let (request, projection) = self
.accepted_project_histories
.get(&replay_key)
.ok_or(ApiError::InvalidWirePayload)?;
let cancelled = ProjectHistoryCancelled::from_stored(request, projection)?;
let response_body = cancelled.to_json()?;
self.accepted_project_histories.remove(&replay_key);
Ok(json_response(200, "OK", response_body))
Devin Review

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

}

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 @@ -1237,6 +1281,53 @@ mod tests {
assert!(!collection.body.contains("evidence_text"));
}

#[test]
fn project_history_cancel_removes_identity_and_fails_closed() {
let mut service = AnalysisRunLiveService::new();
let first = sample_project_history("idem-a", "project-a");
let posted = service.handle_http_request(&project_history_post(&first));
assert_eq!(posted.status_code, 200);
let cancelled = service.handle_http_request(&format!(
"POST {PROJECT_HISTORY_PATH}/idem-a/cancel HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: {LINEAGEWEAVE_CONSUMER_CODE}\r\ntepp-contract-version: 1\r\ntepp-tenant-workspace-id: history-tenant\r\ncontent-length: 0\r\n\r\n"
));
assert_eq!(cancelled.status_code, 200, "{}", cancelled.body);
let parsed =
crate::ProjectHistoryCancelled::from_json(&cancelled.body).expect("cancelled");
assert!(parsed.cancelled);
assert_eq!(parsed.project_key, "project-a");
assert_eq!(parsed.idempotency_key, "idem-a");
assert_eq!(parsed.inference_status, "temporal_association_only");
assert!(!cancelled.body.contains("evidence_text"));
assert!(!cancelled.body.contains("findings"));
assert!(!cancelled.body.contains("rmse"));
assert!(!cancelled.body.contains("tepp.scientific_acceptance.v1"));
let missing = service.handle_http_request(&format!(
"GET {PROJECT_HISTORY_PATH}/idem-a HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: {LINEAGEWEAVE_CONSUMER_CODE}\r\ntepp-contract-version: 1\r\ntepp-tenant-workspace-id: history-tenant\r\ncontent-length: 0\r\n\r\n"
));
assert_eq!(missing.status_code, 400);
let listed = service.handle_http_request(&format!(
"GET {PROJECT_HISTORY_PATH} HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: {LINEAGEWEAVE_CONSUMER_CODE}\r\ntepp-contract-version: 1\r\ntepp-tenant-workspace-id: history-tenant\r\ncontent-length: 0\r\n\r\n"
));
assert_eq!(listed.status_code, 200);
assert!(!listed.body.contains("idem-a"));
let replay = service.handle_http_request(&format!(
"POST {PROJECT_HISTORY_PATH}/idem-a/cancel HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: {LINEAGEWEAVE_CONSUMER_CODE}\r\ntepp-contract-version: 1\r\ntepp-tenant-workspace-id: history-tenant\r\ncontent-length: 0\r\n\r\n"
));
assert_eq!(replay.status_code, 400);
let naruon = service.handle_http_request(&format!(
"POST {PROJECT_HISTORY_PATH}/idem-a/cancel HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: {NARUON_CONSUMER_CODE}\r\ntepp-contract-version: 1\r\ntepp-tenant-workspace-id: history-tenant\r\ncontent-length: 0\r\n\r\n"
));
assert_eq!(naruon.status_code, 400);
let with_key = service.handle_http_request(&format!(
"POST {PROJECT_HISTORY_PATH}/idem-a/cancel HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: {LINEAGEWEAVE_CONSUMER_CODE}\r\ntepp-contract-version: 1\r\nidempotency-key: idem-a\r\ntepp-tenant-workspace-id: history-tenant\r\ncontent-length: 0\r\n\r\n"
));
assert_eq!(with_key.status_code, 400);
let nonempty = service.handle_http_request(&format!(
"POST {PROJECT_HISTORY_PATH}/idem-a/cancel HTTP/1.1\r\nHost: 127.0.0.1\r\ncontent-type: application/json\r\ntepp-consumer: {LINEAGEWEAVE_CONSUMER_CODE}\r\ntepp-contract-version: 1\r\ntepp-tenant-workspace-id: history-tenant\r\ncontent-length: 2\r\n\r\n{{}}"
));
assert_eq!(nonempty.status_code, 400);
}

struct ScriptedRead {
reader: Cursor<Vec<u8>>,
first_error: Option<std::io::ErrorKind>,
Expand Down
9 changes: 9 additions & 0 deletions crates/tepp_api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ mod naruon_http;
mod naruon_live;
mod orchestration;
mod project_history;
mod project_history_cancel_http;
mod project_history_collection_http;
mod project_history_retrieval_http;
mod project_journey;
Expand Down Expand Up @@ -266,6 +267,14 @@ pub use project_history_retrieval_http::lineageweave_project_history_retrieval_e
pub use project_history_retrieval_http::project_history_retrieval_path_id;
/// Refuse scientific-metric and causal-score keys on retrieval JSON.
pub use project_history_retrieval_http::refuse_metrics_on_project_history_retrieval_payload;
/// Metric-free cancelled project-history identity.
pub use project_history_cancel_http::ProjectHistoryCancelled;
/// Maximum opaque idempotency-key length on the cancel path.
pub use project_history_cancel_http::PROJECT_HISTORY_CANCEL_ID_MAX_LEN;
/// Extract the opaque idempotency key from `POST /v1/project-histories/{key}/cancel`.
pub use project_history_cancel_http::project_history_cancel_path_id;
/// Build a credential-free `LineageWeave` cancel POST exchange.
pub use project_history_cancel_http::lineageweave_project_history_cancel_exchange;
/// Maximum posterior Project Journey artifact size.
pub use project_journey::DEFAULT_PROJECT_JOURNEY_BYTE_LIMIT;
/// Exact posterior Project Journey schema identity.
Expand Down
Loading
Loading