use chrono::{DateTime, TimeDelta, Utc};
use crate::document::TrustTask;
use crate::error::RejectReason;
pub const DEFAULT_SKEW: TimeDelta = TimeDelta::seconds(60);
pub const DEFAULT_MAX_AGE: TimeDelta = TimeDelta::minutes(5);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct FreshnessPolicy {
pub skew: TimeDelta,
pub max_age: Option<TimeDelta>,
pub require_issued_at: bool,
}
impl Default for FreshnessPolicy {
fn default() -> Self {
Self {
skew: DEFAULT_SKEW,
max_age: None,
require_issued_at: false,
}
}
}
impl FreshnessPolicy {
pub fn consequential() -> Self {
Self {
skew: DEFAULT_SKEW,
max_age: Some(DEFAULT_MAX_AGE),
require_issued_at: true,
}
}
#[must_use]
pub fn with_max_age(mut self, max_age: TimeDelta) -> Self {
self.max_age = Some(max_age);
self
}
#[must_use]
pub fn with_skew(mut self, skew: TimeDelta) -> Self {
self.skew = skew;
self
}
#[must_use]
pub fn requiring_issued_at(mut self) -> Self {
self.require_issued_at = true;
self
}
pub fn record_expiry<P>(
&self,
doc: &TrustTask<P>,
now: DateTime<Utc>,
) -> Option<DateTime<Utc>> {
if let Some(expires_at) = doc.expires_at {
return Some(expires_at);
}
let max_age = self.max_age?;
Some(doc.issued_at.unwrap_or(now) + max_age)
}
}
impl<P> TrustTask<P> {
pub fn validate_freshness(
&self,
now: DateTime<Utc>,
policy: &FreshnessPolicy,
) -> Result<(), RejectReason> {
if let Some(issued_at) = self.issued_at {
if issued_at > now + policy.skew {
return Err(RejectReason::MalformedRequest {
reason: FUTURE_ISSUED_AT.to_string(),
});
}
if let Some(expires_at) = self.expires_at {
if expires_at <= issued_at {
return Err(RejectReason::MalformedRequest {
reason: EXPIRY_NOT_AFTER_ISSUANCE.to_string(),
});
}
}
if let Some(max_age) = policy.max_age {
if issued_at + max_age + policy.skew < now {
return Err(RejectReason::Stale {
detail: StaleReason::OlderThanWindow,
});
}
}
return Ok(());
}
if policy.require_issued_at {
return Err(RejectReason::MalformedRequest {
reason: ISSUED_AT_REQUIRED.to_string(),
});
}
if policy.max_age.is_some() && self.expires_at.is_none() {
return Err(RejectReason::Stale {
detail: StaleReason::Unboundable,
});
}
Ok(())
}
}
pub const FUTURE_ISSUED_AT: &str =
"issuedAt is in the future beyond the consumer's skew tolerance (SPEC §4.2)";
pub const EXPIRY_NOT_AFTER_ISSUANCE: &str =
"expiresAt is not after issuedAt: the document states an empty validity interval (SPEC §4.2)";
pub const ISSUED_AT_REQUIRED: &str =
"issuedAt is required by consumer policy (SPEC §7.2, bounding the duplicate-execution record)";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StaleReason {
OlderThanWindow,
Unboundable,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::StandardCode;
use crate::TypeUri;
fn doc_at(
issued_at: Option<DateTime<Utc>>,
expires_at: Option<DateTime<Utc>>,
) -> TrustTask<serde_json::Value> {
let mut doc = TrustTask::new(
"req-1",
TypeUri::canonical("kyc-handoff", 1, 0).unwrap(),
serde_json::json!({}),
);
doc.issued_at = issued_at;
doc.expires_at = expires_at;
doc
}
fn t(s: &str) -> DateTime<Utc> {
s.parse().unwrap()
}
#[test]
fn future_issued_at_is_rejected_beyond_the_skew_tolerance() {
let now = t("2026-08-26T12:00:00Z");
let policy = FreshnessPolicy::default();
let ok = doc_at(Some(t("2026-08-26T12:00:30Z")), None);
assert!(ok.validate_freshness(now, &policy).is_ok());
let bad = doc_at(Some(t("2026-08-26T12:05:00Z")), None);
let err = bad.validate_freshness(now, &policy).unwrap_err();
assert_eq!(err.code(), StandardCode::MalformedRequest);
assert!(err.wire_message().ends_with(FUTURE_ISSUED_AT));
}
#[test]
fn future_issued_at_message_carries_no_consumer_clock() {
let now = t("2026-08-26T12:00:00Z");
let bad = doc_at(Some(t("2031-01-01T00:00:00Z")), None);
let msg = bad
.validate_freshness(now, &FreshnessPolicy::default())
.unwrap_err()
.wire_message();
assert!(
!msg.contains("2026"),
"wire message leaked the clock: {msg}"
);
assert!(
!msg.contains("2031"),
"wire message echoed the input: {msg}"
);
}
#[test]
fn expiry_at_or_before_issuance_is_rejected() {
let now = t("2026-08-26T12:00:00Z");
let policy = FreshnessPolicy::default();
for expires in ["2026-08-26T11:59:00Z", "2026-08-26T11:59:30Z"] {
let doc = doc_at(Some(t("2026-08-26T11:59:30Z")), Some(t(expires)));
let err = doc.validate_freshness(now, &policy).unwrap_err();
assert_eq!(err.code(), StandardCode::MalformedRequest);
assert!(err.wire_message().ends_with(EXPIRY_NOT_AFTER_ISSUANCE));
}
let good = doc_at(
Some(t("2026-08-26T11:59:30Z")),
Some(t("2026-08-26T12:30:00Z")),
);
assert!(good.validate_freshness(now, &policy).is_ok());
}
#[test]
fn max_age_bounds_the_acceptance_window() {
let now = t("2026-08-26T12:00:00Z");
let policy = FreshnessPolicy::default().with_max_age(TimeDelta::minutes(5));
let fresh = doc_at(Some(t("2026-08-26T11:58:00Z")), None);
assert!(fresh.validate_freshness(now, &policy).is_ok());
let stale = doc_at(Some(t("2026-08-26T11:30:00Z")), None);
let err = stale.validate_freshness(now, &policy).unwrap_err();
assert_eq!(err.code(), StandardCode::Expired);
}
#[test]
fn a_document_with_no_timestamps_is_unboundable_under_a_window() {
let now = t("2026-08-26T12:00:00Z");
let doc = doc_at(None, None);
assert!(doc
.validate_freshness(now, &FreshnessPolicy::default())
.is_ok());
let windowed = FreshnessPolicy::default().with_max_age(TimeDelta::minutes(5));
let err = doc.validate_freshness(now, &windowed).unwrap_err();
assert_eq!(err.code(), StandardCode::Expired);
let bounded = doc_at(None, Some(t("2026-08-26T12:30:00Z")));
assert!(bounded.validate_freshness(now, &windowed).is_ok());
}
#[test]
fn require_issued_at_refuses_a_document_without_one() {
let now = t("2026-08-26T12:00:00Z");
let doc = doc_at(None, Some(t("2026-08-26T12:30:00Z")));
let err = doc
.validate_freshness(now, &FreshnessPolicy::consequential())
.unwrap_err();
assert_eq!(err.code(), StandardCode::MalformedRequest);
assert!(err.wire_message().ends_with(ISSUED_AT_REQUIRED));
}
#[test]
fn record_expiry_prefers_expires_at_then_falls_back_to_the_window() {
let now = t("2026-08-26T12:00:00Z");
let policy = FreshnessPolicy::consequential();
let with_expiry = doc_at(
Some(t("2026-08-26T11:59:00Z")),
Some(t("2026-08-26T18:00:00Z")),
);
assert_eq!(
policy.record_expiry(&with_expiry, now),
Some(t("2026-08-26T18:00:00Z"))
);
let without = doc_at(Some(t("2026-08-26T11:59:00Z")), None);
assert_eq!(
policy.record_expiry(&without, now),
Some(t("2026-08-26T12:04:00Z"))
);
assert_eq!(
FreshnessPolicy::default().record_expiry(&without, now),
None
);
}
}