use trust_tasks_rs::{RejectReason, TrustTask};
use vta_sdk::retry_safety::{RetrySafety, retry_safety};
use vti_common::idempotency::{
CacheEntry, ClaimOutcome, CompletedResponse, IdempotencyStore, Principal,
};
use super::helpers::{TrustTaskOutcome, reject_with};
pub(crate) const IDEMPOTENCY_KEY_MEMBER: &str = "idempotencyKey";
const IN_FLIGHT_GRACE_MINS: i64 = 10;
const MAX_CACHED_BODY: usize = 64 * 1024;
const IN_FLIGHT_RETRY_AFTER_SECS: i64 = 2;
const CLASS: vti_common::idempotency::IdempotencyClass =
vti_common::idempotency::IdempotencyClass::NonDestructive;
pub(crate) fn key_of(doc: &TrustTask<serde_json::Value>) -> Option<String> {
let raw = doc.extra.get(IDEMPOTENCY_KEY_MEMBER)?.as_str()?;
let trimmed = raw.trim();
if trimmed.is_empty() || trimmed.len() > 255 {
tracing::debug!(
len = trimmed.len(),
"ignoring unusable idempotencyKey (empty, or over 255 chars)"
);
return None;
}
Some(trimmed.to_string())
}
fn payload_hash(doc: &TrustTask<serde_json::Value>) -> [u8; 32] {
use sha2::{Digest, Sha256};
let mut h = Sha256::new();
h.update(doc.type_uri.to_string().as_bytes());
h.update([0u8]);
h.update(serde_json::to_vec(&doc.payload).unwrap_or_default());
h.finalize().into()
}
pub(crate) enum Claim {
Proceed { key: String, safety: RetrySafety },
Answer(Box<TrustTaskOutcome>),
Skip,
}
pub(crate) async fn claim(
ks: &vti_common::store::KeyspaceHandle,
actor: &str,
doc: &TrustTask<serde_json::Value>,
) -> Claim {
let type_uri = doc.type_uri.to_string();
let Some(safety) = retry_safety(&type_uri).filter(|s| s.needs_key()) else {
return Claim::Skip;
};
let Some(key) = key_of(doc) else {
return Claim::Skip;
};
let store = IdempotencyStore::new(ks.clone());
let principal = Principal::Did(actor.to_string()).hash();
let grace = chrono::Duration::minutes(IN_FLIGHT_GRACE_MINS);
let outcome = match store
.claim(&principal, &key, payload_hash(doc), CLASS, grace)
.await
{
Ok(o) => o,
Err(e) => {
tracing::warn!(error = %e, actor, key, "idempotency claim failed; dispatching unguarded");
return Claim::Skip;
}
};
match outcome {
ClaimOutcome::Claimed => {
tracing::debug!(actor, key, %type_uri, "idempotency key claimed");
Claim::Proceed { key, safety }
}
ClaimOutcome::InFlight => {
let retry_after =
chrono::Utc::now() + chrono::Duration::seconds(IN_FLIGHT_RETRY_AFTER_SECS);
Claim::Answer(Box::new(reject_with(
doc,
RejectReason::Unavailable {
retry_after: Some(retry_after),
},
)))
}
ClaimOutcome::Conflict => Claim::Answer(Box::new(reject_with(
doc,
RejectReason::TaskFailed {
reason: "idempotency key reused for a different request".to_string(),
details: Some(serde_json::json!({
"idempotencyKey": key,
"task": type_uri,
"reason": "this key was already used for a different task or payload; \
answering it from the first request's result would answer the \
wrong question. Use a fresh key, or re-send the original \
request unchanged",
})),
},
))),
ClaimOutcome::Completed(entry) => {
Claim::Answer(Box::new(replay(doc, &entry, &key, &type_uri)))
}
}
}
fn replay(
doc: &TrustTask<serde_json::Value>,
entry: &CacheEntry,
key: &str,
type_uri: &str,
) -> TrustTaskOutcome {
if !entry.has_replayable_response() {
return reject_with(
doc,
RejectReason::TaskFailed {
reason: "already performed; the result is not replayable".to_string(),
details: Some(serde_json::json!({
"idempotencyKey": key,
"task": type_uri,
"completedAt": entry.created_at.to_rfc3339(),
"reason": "this request was already performed and its effect is not \
duplicated. The original response is deliberately not retained \
— it carried secret material, or exceeded the replay size cap \
— so retrieve the result with the corresponding read operation",
})),
},
);
}
let Ok(status) = axum::http::StatusCode::from_u16(entry.response_status) else {
return reject_with(
doc,
RejectReason::InternalError {
reason: "recorded idempotent response has an unusable status".to_string(),
},
);
};
tracing::info!(key, %type_uri, "replaying recorded response");
TrustTaskOutcome {
status,
body: entry.response_body.clone(),
}
}
pub(crate) async fn record_outcome(
ks: &vti_common::store::KeyspaceHandle,
actor: &str,
key: &str,
safety: RetrySafety,
outcome: &TrustTaskOutcome,
) {
let store = IdempotencyStore::new(ks.clone());
let principal = Principal::Did(actor.to_string()).hash();
if !outcome.status.is_success() {
if let Err(e) = store.release(&principal, key).await {
tracing::warn!(error = %e, actor, key, "failed to release an idempotency claim after a failed task");
}
return;
}
let too_large = outcome.body.len() > MAX_CACHED_BODY;
if too_large && safety.response_is_replayable() {
tracing::debug!(
actor,
key,
bytes = outcome.body.len(),
cap = MAX_CACHED_BODY,
"response too large to retain for replay; recording completion only"
);
}
let response = (safety.response_is_replayable() && !too_large).then(|| CompletedResponse {
status: outcome.status.as_u16(),
headers: Vec::new(),
body: outcome.body.clone(),
});
if let Err(e) = store.complete(&principal, key, response).await {
tracing::warn!(error = %e, actor, key, "failed to record an idempotent outcome; a retry may re-run this task");
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::Value;
use trust_tasks_rs::TypeUri;
use vta_sdk::trust_tasks;
fn doc_with(type_uri: &str, payload: Value, key: Option<&str>) -> TrustTask<Value> {
let uri: TypeUri = type_uri.parse().expect("type uri");
let mut d = TrustTask::new("urn:uuid:test", uri, payload);
if let Some(k) = key {
d.extra.insert(
IDEMPOTENCY_KEY_MEMBER.to_string(),
serde_json::json!(k.to_string()),
);
}
d
}
#[test]
fn the_key_is_read_from_the_flattened_extra_member() {
let d = doc_with(
trust_tasks::TASK_KEYS_CREATE_0_1,
serde_json::json!({}),
Some("abc"),
);
assert_eq!(key_of(&d).as_deref(), Some("abc"));
}
#[test]
fn an_unusable_key_reads_as_absent_not_as_an_error() {
for bad in ["", " ", &"x".repeat(256)] {
let d = doc_with(
trust_tasks::TASK_KEYS_CREATE_0_1,
serde_json::json!({}),
Some(bad),
);
assert_eq!(key_of(&d), None, "{bad:?} should read as absent");
}
let none = doc_with(
trust_tasks::TASK_KEYS_CREATE_0_1,
serde_json::json!({}),
None,
);
assert_eq!(key_of(&none), None);
}
#[test]
fn the_key_survives_the_json_round_trip_it_takes_on_the_wire() {
let d = doc_with(
trust_tasks::TASK_KEYS_CREATE_0_1,
serde_json::json!({"a": 1}),
Some("k-1"),
);
let wire = serde_json::to_string(&d).expect("serialise");
assert!(
wire.contains(r#""idempotencyKey":"k-1""#),
"key must be top-level so the proof covers it: {wire}"
);
let back: TrustTask<Value> = serde_json::from_str(&wire).expect("deserialise");
assert_eq!(key_of(&back).as_deref(), Some("k-1"));
}
#[test]
fn the_request_hash_is_stable_across_member_ordering() {
let a = doc_with(
trust_tasks::TASK_KEYS_CREATE_0_1,
serde_json::from_str(r#"{"b":2,"a":1}"#).unwrap(),
None,
);
let b = doc_with(
trust_tasks::TASK_KEYS_CREATE_0_1,
serde_json::from_str(r#"{"a":1,"b":2}"#).unwrap(),
None,
);
assert_eq!(payload_hash(&a), payload_hash(&b));
}
#[test]
fn the_request_hash_separates_different_payloads() {
let a = doc_with(
trust_tasks::TASK_KEYS_CREATE_0_1,
serde_json::json!({"label": "one"}),
None,
);
let b = doc_with(
trust_tasks::TASK_KEYS_CREATE_0_1,
serde_json::json!({"label": "two"}),
None,
);
assert_ne!(payload_hash(&a), payload_hash(&b));
}
#[test]
fn the_request_hash_separates_different_tasks() {
let a = doc_with(
trust_tasks::TASK_KEYS_CREATE_0_1,
serde_json::json!({}),
None,
);
let b = doc_with(
trust_tasks::TASK_WEBVH_DIDS_CREATE_1_0,
serde_json::json!({}),
None,
);
assert_ne!(payload_hash(&a), payload_hash(&b));
}
#[test]
fn only_keyed_tasks_are_worth_a_record() {
assert!(
retry_safety(trust_tasks::TASK_WEBVH_DIDS_CREATE_1_0)
.expect("classified")
.needs_key()
);
assert!(
!retry_safety(trust_tasks::TASK_WEBVH_DIDS_LIST_1_0)
.expect("classified")
.needs_key()
);
}
#[test]
fn secret_bearing_tasks_are_never_recorded_with_a_body() {
assert!(
!retry_safety(trust_tasks::TASK_PROVISION_INTEGRATION_0_3)
.expect("classified")
.response_is_replayable()
);
}
}