Skip to content

feat(api): cancel authorized exports on loopback HTTP - #445

Closed
seonghobae wants to merge 1 commit into
feat/export-collection-get-gap-003afrom
feat/export-cancel-http-gap-003a
Closed

feat(api): cancel authorized exports on loopback HTTP#445
seonghobae wants to merge 1 commit into
feat/export-collection-get-gap-003afrom
feat/export-cancel-http-gap-003a

Conversation

@seonghobae

@seonghobae seonghobae commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Summary

GAP-003A unique operator-visible slice: loopback POST /v1/exports/{export_id}/cancel on AnalysisRunLiveService / tepp-loopback removes one authorized naruon export identity.

  • Empty body admitted; leftover nonempty body fail closed.
  • Public bind / unpublished consumer / present idempotency-key / extra path segments / slash/NUL identities / credential headers fail closed.
  • Metric-free receipts with cancelled=true. Tenant, principal, source text, RMSE, bias, coverage, SE-gate, and tepp.scientific_acceptance.v1 never appear.
  • Cancelled identities drop from collection GET and GET-by-id.
  • Stacked on feat(api): enumerate authorized exports via loopback collection GET #443 (feat/export-collection-get-gap-003a). Does not unstack onto main.
  • ADR 0077 (0026–0076 occupied). Does not reuse 0026–0076.

Explicitly not

Another export collection CLI (#444), export collection GET (#443), interpretation-run cancel CLI (#442), interpretation-run cancel HTTP (#440), interpretation-run retrieval CLI (#439), interpretation-run GET-by-id (#438), interpretation-run collection CLI (#436), export-retrieval CLI (#417), export retrieval GET (#411), export-authorize CLI (#410), analysis-run cancel (#361), Leiden, Driver p.16, GAP-010 Figma/export, persistence/Compose (#287).

Does not add GET to NaruonLiveService. Does not open LineageWeave on this naruon-owned adapter. Does not weaken fail-closed. Does not infer causality. HTTP 200 is not an ADR 0014 claim.

Test plan

  • cargo test -p tepp_api --lib export_cancel
  • cargo test -p tepp_api --lib handler_covers_metric_free_export_cancel
  • cargo test -p tepp_api --test export_cancel_http_contract
  • cargo clippy -p tepp_api --all-targets -- -D warnings
  • cargo doc -p tepp_api --no-deps
  • python3 scripts/validate_documentation.py
  • python3 scripts/check_docstrings.py

Devin Review

POST /v1/exports/{export_id}/cancel removes one metric-free naruon identity
from AnalysisRunLiveService. Receipts stay cancelled=true. LineageWeave is
refused. NaruonLiveService stays POST-only. ADR 0077.
@coderabbitai

coderabbitai Bot commented Sep 1, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Team

Run ID: b58e45ea-bb49-47f5-b403-e9f0136aa898

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Devin Review found 6 potential issues.

Devin Review

Comment on lines +413 to +417
let stored = self
.authorized_exports
.remove(&replay_key)
.ok_or(ApiError::InvalidWirePayload)?;
let cancelled = ExportCancelled::from_retrieval(stored.retrieval)?;

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.

Comment on lines +92 to +94
require_nonempty(&self.decision_code)?;
require_nonempty(&self.purpose)?;
require_nonempty(&self.idempotency_key)?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Invalid cancellation receipts pass validation

Untrusted JSON with any nonempty purpose or decision passes validate. Consumers can accept fabricated authorization metadata as a valid cancellation.

Prompt for agents
Strengthen ExportCancelled validation to preserve the semantic guarantees of the ExportRetrieval from which receipts are derived. from_json currently accepts any nonempty purpose and decision_code, while a legitimate cancel receipt can only represent the known modular_service_consumer purpose and the allowed purpose_bound_export_allowed decision. Validate those exact values, and add contract tests showing unknown purposes and denied decision codes are rejected.
Devin Review

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

Comment on lines +28 to +40
pub struct ExportCancelled {
/// Opaque server-assigned export identity.
pub export_id: String,
/// Opaque artifact identity that was authorized.
pub artifact_id: String,
/// Stable machine-readable authorization decision code.
pub decision_code: String,
/// Declared analytical purpose as a wire name.
pub purpose: String,
/// Exact per-export idempotency key that minted this identity.
pub idempotency_key: String,
/// Always `true` on a successful cancel receipt.
pub cancelled: bool,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔍 Cancellation payload lacks versioning

ExportCancelled exposes no semantic version field or constant. Future payload changes cannot follow the repository’s explicit wire-versioning policy.

Devin Review

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

Comment on lines +131 to +132
/// Extract the opaque export identity from `POST /v1/exports/{export_id}/cancel`.
pub use export_cancel_http::export_cancel_path_id;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔍 Routing parser becomes public API

export_cancel_path_id exposes a server-routing detail while the retrieval equivalent stays private. This creates an unnecessary compatibility obligation.

Devin Review

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

Comment on lines +402 to +405
let consumer = require_headers(headers, self.bound_addr, false)?;
if consumer != NARUON_CONSUMER_CODE {
return Err(ApiError::InvalidWirePayload);
}

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.

Comment on lines +135 to +140
let export_id = decode_path_segment(encoded)?;
require_nonempty(&export_id)?;
if export_id.contains('/') || export_id.contains('\0') {
return Err(ApiError::InvalidWirePayload);
}
if export_id.len() > EXPORT_CANCEL_ID_MAX_LEN {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟨 Oversized encoded identities decode fully

decode_path_segment allocates and decodes the whole segment before enforcing 128 bytes. Repeated requests waste memory and CPU on invalid identities.

Devin Review

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

Copy link
Copy Markdown
Contributor Author

Closing as unsafe mutation lineage; replacement authority is #174. Review proved both a security root cause (any local caller can claim tepp-consumer: naruon and revoke a known export) and transactional defects (removal can occur before receipt construction succeeds). Preserve atomicity, receipt-versioning, semantic-purpose/decision validation, path-bound and fail-closed review evidence for the future authenticated export-operations landing vehicle.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant