use std::collections::BTreeMap;
use std::sync::Arc;
use crate::budget::BudgetLedger;
use crate::clock::{SharedClock, SystemClock};
use crate::delegation::Delegation;
use crate::error::CoreError;
use crate::intent::SpendIntent;
use crate::policy::{MerchantVerdict, PolicyState};
use crate::replay::ReplayRegistry;
use crate::revocation::RevocationSet;
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DenyReason {
UnknownDelegation,
NotYetValid,
Expired,
Revoked,
Replay,
OverBudget,
Overflow,
InvalidAmount,
InvalidNonce,
InvalidIntent,
RateLimited,
OverCategoryBudget,
MerchantDenied,
MerchantNotAllowed,
QuietHours,
}
impl std::fmt::Display for DenyReason {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let s = match self {
DenyReason::UnknownDelegation => "unknown_delegation",
DenyReason::NotYetValid => "not_yet_valid",
DenyReason::Expired => "expired",
DenyReason::Revoked => "revoked",
DenyReason::Replay => "replay",
DenyReason::OverBudget => "over_budget",
DenyReason::Overflow => "overflow",
DenyReason::InvalidAmount => "invalid_amount",
DenyReason::InvalidNonce => "invalid_nonce",
DenyReason::InvalidIntent => "invalid_intent",
DenyReason::RateLimited => "rate_limited",
DenyReason::OverCategoryBudget => "over_category_budget",
DenyReason::MerchantDenied => "merchant_denied",
DenyReason::MerchantNotAllowed => "merchant_not_allowed",
DenyReason::QuietHours => "quiet_hours",
};
f.write_str(s)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum GateDecision {
Allow { budget_after_cents: u64 },
Deny { reason: DenyReason },
}
impl GateDecision {
pub fn is_allow(&self) -> bool {
matches!(self, GateDecision::Allow { .. })
}
pub fn deny_reason(&self) -> Option<DenyReason> {
match self {
GateDecision::Allow { .. } => None,
GateDecision::Deny { reason } => Some(*reason),
}
}
}
#[cfg(test)]
mod type_tests {
use super::*;
#[test]
fn deny_reason_display_is_snake_case() {
assert_eq!(DenyReason::OverBudget.to_string(), "over_budget");
assert_eq!(
DenyReason::UnknownDelegation.to_string(),
"unknown_delegation"
);
assert_eq!(DenyReason::NotYetValid.to_string(), "not_yet_valid");
assert_eq!(DenyReason::InvalidIntent.to_string(), "invalid_intent");
assert_eq!(DenyReason::RateLimited.to_string(), "rate_limited");
assert_eq!(
DenyReason::OverCategoryBudget.to_string(),
"over_category_budget"
);
assert_eq!(DenyReason::MerchantDenied.to_string(), "merchant_denied");
assert_eq!(
DenyReason::MerchantNotAllowed.to_string(),
"merchant_not_allowed"
);
assert_eq!(DenyReason::QuietHours.to_string(), "quiet_hours");
}
#[test]
fn deny_reason_serde_roundtrip_snake_case() {
for r in [
DenyReason::UnknownDelegation,
DenyReason::NotYetValid,
DenyReason::Expired,
DenyReason::Revoked,
DenyReason::Replay,
DenyReason::OverBudget,
DenyReason::Overflow,
DenyReason::InvalidAmount,
DenyReason::InvalidNonce,
DenyReason::InvalidIntent,
DenyReason::RateLimited,
DenyReason::OverCategoryBudget,
DenyReason::MerchantDenied,
DenyReason::MerchantNotAllowed,
DenyReason::QuietHours,
] {
let json = serde_json::to_string(&r).expect("序列化");
let back: DenyReason = serde_json::from_str(&json).expect("反序列化");
assert_eq!(back, r, "{r} roundtrip");
}
assert_eq!(
serde_json::to_string(&DenyReason::OverBudget).unwrap(),
"\"over_budget\""
);
}
#[test]
fn decision_shape_and_accessors() {
let allow = GateDecision::Allow {
budget_after_cents: 500,
};
assert!(allow.is_allow());
assert_eq!(allow.deny_reason(), None);
let deny = GateDecision::Deny {
reason: DenyReason::Revoked,
};
assert!(!deny.is_allow());
assert_eq!(deny.deny_reason(), Some(DenyReason::Revoked));
}
#[test]
fn decision_serde_roundtrip() {
for d in [
GateDecision::Allow {
budget_after_cents: 1,
},
GateDecision::Deny {
reason: DenyReason::Replay,
},
] {
let json = serde_json::to_string(&d).expect("序列化");
let back: GateDecision = serde_json::from_str(&json).expect("反序列化");
assert_eq!(back, d);
}
}
}
#[derive(Debug)]
pub struct Gate {
delegations: BTreeMap<String, Delegation>,
revocations: RevocationSet,
replay: ReplayRegistry,
ledger: BudgetLedger,
policy_states: BTreeMap<String, PolicyState>,
clock: SharedClock,
}
impl Gate {
pub fn new(clock: SharedClock) -> Self {
Self {
delegations: BTreeMap::new(),
revocations: RevocationSet::new(),
replay: ReplayRegistry::new(),
ledger: BudgetLedger::new(),
policy_states: BTreeMap::new(),
clock,
}
}
pub fn with_system_clock() -> Self {
Self::new(Arc::new(SystemClock))
}
pub fn clock(&self) -> &SharedClock {
&self.clock
}
pub fn with_clock(mut self, clock: SharedClock) -> Self {
self.clock = clock;
self
}
pub fn register_delegation(&mut self, delegation: Delegation) -> Result<(), CoreError> {
delegation.validate()?;
if self.delegations.contains_key(&delegation.id) {
return Err(CoreError::DuplicateDelegation(delegation.id));
}
self.delegations.insert(delegation.id.clone(), delegation);
Ok(())
}
pub fn revoke(&mut self, delegation_id: &str) -> Result<(), CoreError> {
if !self.delegations.contains_key(delegation_id) {
return Err(CoreError::UnknownDelegation(delegation_id.to_string()));
}
self.revocations.revoke(delegation_id);
Ok(())
}
pub fn is_revoked(&self, delegation_id: &str) -> bool {
self.revocations.is_revoked(delegation_id)
}
pub fn delegation(&self, delegation_id: &str) -> Option<&Delegation> {
self.delegations.get(delegation_id)
}
pub fn delegations(&self) -> impl Iterator<Item = &Delegation> {
self.delegations.values()
}
pub fn spent_cents(&self, delegation_id: &str) -> Option<u64> {
self.delegations
.contains_key(delegation_id)
.then(|| self.ledger.spent_cents(delegation_id))
}
pub fn remaining_cents(&self, delegation_id: &str) -> Option<u64> {
let cap = self.delegations.get(delegation_id)?.budget_cap_cents;
Some(self.ledger.remaining_cents(delegation_id, cap))
}
pub fn revocations(&self) -> &RevocationSet {
&self.revocations
}
pub fn replay_registry(&self) -> &ReplayRegistry {
&self.replay
}
pub fn ledger(&self) -> &BudgetLedger {
&self.ledger
}
pub fn velocity_stamps(&self, delegation_id: &str) -> &[u64] {
self.policy_states
.get(delegation_id)
.map(|s| s.velocity_stamps.as_slice())
.unwrap_or(&[])
}
pub fn category_spent_cents(&self, delegation_id: &str, category: &str) -> Option<u64> {
self.policy_states
.get(delegation_id)?
.category_spent_cents
.get(category)
.copied()
}
pub fn policy_states(&self) -> impl Iterator<Item = (&String, &PolicyState)> {
self.policy_states.iter()
}
pub fn evaluate(&self, intent: &SpendIntent) -> GateDecision {
self.evaluate_at(intent, self.clock.now())
}
pub(crate) fn evaluate_at(&self, intent: &SpendIntent, now: u64) -> GateDecision {
if intent.amount_cents == 0 {
return deny(DenyReason::InvalidAmount);
}
if intent.nonce == 0 {
return deny(DenyReason::InvalidNonce);
}
if intent.delegation_id.trim().is_empty() {
return deny(DenyReason::UnknownDelegation);
}
if intent.merchant_id.trim().is_empty() {
return deny(DenyReason::InvalidIntent);
}
let Some(delegation) = self.delegations.get(&intent.delegation_id) else {
return deny(DenyReason::UnknownDelegation);
};
if delegation.not_yet_valid(now) {
return deny(DenyReason::NotYetValid);
}
if delegation.is_expired(now) {
return deny(DenyReason::Expired);
}
if self.revocations.is_revoked(&delegation.id) {
return deny(DenyReason::Revoked);
}
if self.replay.contains(&delegation.nonce_scope, intent.nonce) {
return deny(DenyReason::Replay);
}
let empty = PolicyState::default();
let policy_state = self.policy_states.get(&delegation.id).unwrap_or(&empty);
let policy = &delegation.policy;
match policy.merchant_verdict(&intent.merchant_id) {
Some(MerchantVerdict::Denied) => return deny(DenyReason::MerchantDenied),
Some(MerchantVerdict::NotAllowed) => return deny(DenyReason::MerchantNotAllowed),
None => {}
}
if policy.is_quiet(now) {
return deny(DenyReason::QuietHours);
}
if let Some(v) = &policy.velocity {
if policy_state.in_window_count(now, v.window_secs) >= v.max_spends as usize {
return deny(DenyReason::RateLimited);
}
}
if let Some(cap) = policy.category_caps_cents.get(intent.category.as_str()) {
let spent = policy_state
.category_spent_cents
.get(intent.category.as_str())
.copied()
.unwrap_or(0);
let Some(after) = spent.checked_add(intent.amount_cents) else {
return deny(DenyReason::Overflow);
};
if after > *cap {
return deny(DenyReason::OverCategoryBudget);
}
}
let spent = self.ledger.spent_cents(&delegation.id);
let Some(total) = spent.checked_add(intent.amount_cents) else {
return deny(DenyReason::Overflow);
};
if total > delegation.budget_cap_cents {
return deny(DenyReason::OverBudget);
}
GateDecision::Allow {
budget_after_cents: total,
}
}
pub fn commit(&mut self, intent: &SpendIntent) -> Result<u64, CoreError> {
self.commit_at(intent, self.clock.now())
}
pub(crate) fn commit_at(&mut self, intent: &SpendIntent, now: u64) -> Result<u64, CoreError> {
match self.evaluate_at(intent, now) {
GateDecision::Allow { budget_after_cents } => {
let delegation = self
.delegations
.get(&intent.delegation_id)
.expect("evaluate 放行 ⇒ 委托必已注册");
let nonce_scope = delegation.nonce_scope.clone();
let velocity_enabled = delegation.policy.velocity.is_some();
let capped_category = delegation
.policy
.category_caps_cents
.contains_key(intent.category.as_str())
.then(|| intent.category.clone());
let after = self
.ledger
.commit(&intent.delegation_id, intent.amount_cents)?;
self.replay.consume(&nonce_scope, intent.nonce);
if velocity_enabled || capped_category.is_some() {
let state = self
.policy_states
.entry(intent.delegation_id.clone())
.or_default();
if velocity_enabled {
state.record_velocity_stamp(now);
}
if let Some(category) = capped_category {
state.record_category_spend(&category, intent.amount_cents);
}
}
debug_assert_eq!(after, budget_after_cents);
Ok(after)
}
GateDecision::Deny { reason } => Err(CoreError::CommitRejected(format!(
"delegation={} nonce={} reason={reason}",
intent.delegation_id, intent.nonce
))),
}
}
pub fn decide(&mut self, intent: &SpendIntent) -> GateDecision {
let now = self.clock.now();
match self.evaluate_at(intent, now) {
GateDecision::Allow { budget_after_cents } => {
let after = self
.commit_at(intent, now)
.expect("evaluate_at 放行 ⇒ commit_at 必成功(同一时刻重判)");
debug_assert_eq!(after, budget_after_cents);
GateDecision::Allow {
budget_after_cents: after,
}
}
deny => deny,
}
}
}
fn deny(reason: DenyReason) -> GateDecision {
GateDecision::Deny { reason }
}
#[cfg(test)]
mod tests {
use super::*;
fn gate_with(now: u64) -> (Gate, crate::clock::MockClock) {
let clock = crate::clock::MockClock::new(now);
let mut gate = Gate::new(Arc::new(clock.clone()));
gate.register_delegation(Delegation::new(
"d1",
"boss",
"claude-code",
1000,
1000,
2000,
"agent:claude-code",
))
.expect("样例委托合法");
(gate, clock)
}
fn intent(nonce: u64, amount_cents: u64) -> SpendIntent {
SpendIntent::new("d1", nonce, amount_cents, "jd:shop-1", "grocery", "测试")
}
#[test]
fn allow_happy_path_deducts_budget() {
let (mut gate, _clock) = gate_with(1500);
assert_eq!(
gate.decide(&intent(1, 500)),
GateDecision::Allow {
budget_after_cents: 500
}
);
assert_eq!(gate.remaining_cents("d1"), Some(500));
assert_eq!(gate.spent_cents("d1"), Some(500));
}
#[test]
fn deny_unknown_delegation() {
let (mut gate, _clock) = gate_with(1500);
let i = SpendIntent::new("ghost", 1, 100, "jd:shop-1", "x", "");
assert_eq!(
gate.decide(&i),
GateDecision::Deny {
reason: DenyReason::UnknownDelegation
}
);
assert_eq!(gate.spent_cents("ghost"), None);
}
#[test]
fn deny_not_yet_valid() {
let (mut gate, _clock) = gate_with(999);
assert_eq!(
gate.decide(&intent(1, 100)),
GateDecision::Deny {
reason: DenyReason::NotYetValid
}
);
}
#[test]
fn deny_expired_including_exact_boundary() {
let (mut gate, clock) = gate_with(1999);
assert!(
gate.decide(&intent(1, 100)).is_allow(),
"valid_until 前一秒仍可消费"
);
clock.set_now(2000);
assert_eq!(
gate.decide(&intent(2, 100)),
GateDecision::Deny {
reason: DenyReason::Expired
},
"恰在 valid_until 时刻必须按过期处理(fail-closed)"
);
}
#[test]
fn deny_revoked_and_never_allowed_again() {
let (mut gate, clock) = gate_with(1500);
assert!(gate.decide(&intent(1, 100)).is_allow());
gate.revoke("d1").expect("撤销已注册委托");
assert_eq!(
gate.decide(&intent(2, 100)),
GateDecision::Deny {
reason: DenyReason::Revoked
}
);
clock.advance(100);
assert_eq!(
gate.decide(&intent(3, 1)),
GateDecision::Deny {
reason: DenyReason::Revoked
}
);
assert_eq!(gate.spent_cents("d1"), Some(100));
}
#[test]
fn deny_replay_same_nonce_same_scope() {
let (mut gate, _clock) = gate_with(1500);
assert!(gate.decide(&intent(1, 100)).is_allow());
assert_eq!(
gate.decide(&intent(1, 100)),
GateDecision::Deny {
reason: DenyReason::Replay
}
);
assert_eq!(
gate.decide(&intent(1, 1)),
GateDecision::Deny {
reason: DenyReason::Replay
}
);
}
#[test]
fn replay_is_scoped_by_nonce_scope() {
let clock = crate::clock::MockClock::new(1500);
let mut gate = Gate::new(Arc::new(clock));
gate.register_delegation(Delegation::new(
"d1",
"boss",
"claude-code",
1000,
1000,
2000,
"agent:claude-code",
))
.unwrap();
gate.register_delegation(Delegation::new(
"d2",
"boss",
"claude-code",
1000,
1000,
2000,
"agent:claude-code",
))
.unwrap();
assert!(gate.decide(&intent(1, 100)).is_allow());
let other = SpendIntent::new("d2", 1, 100, "jd:shop-1", "x", "");
assert_eq!(
gate.decide(&other),
GateDecision::Deny {
reason: DenyReason::Replay
},
"同作用域跨委托重放同 nonce 必须被拦"
);
}
#[test]
fn deny_over_budget_but_exact_cap_is_allowed() {
let (mut gate, _clock) = gate_with(1500);
assert!(gate.decide(&intent(1, 500)).is_allow());
assert!(
gate.decide(&intent(2, 500)).is_allow(),
"恰好花满 cap 应放行"
);
assert_eq!(gate.remaining_cents("d1"), Some(0));
assert_eq!(
gate.decide(&intent(3, 1)),
GateDecision::Deny {
reason: DenyReason::OverBudget
}
);
}
#[test]
fn deny_amount_overflow() {
let (mut gate, _clock) = gate_with(1500);
assert!(gate.decide(&intent(1, 500)).is_allow());
let huge = SpendIntent::new("d1", 2, u64::MAX, "jd:shop-1", "x", "");
assert_eq!(
gate.decide(&huge),
GateDecision::Deny {
reason: DenyReason::Overflow
}
);
assert_eq!(gate.spent_cents("d1"), Some(500));
}
#[test]
fn deny_invalid_amount_zero() {
let (mut gate, _clock) = gate_with(1500);
assert_eq!(
gate.decide(&intent(1, 0)),
GateDecision::Deny {
reason: DenyReason::InvalidAmount
}
);
}
#[test]
fn deny_invalid_nonce_zero() {
let (mut gate, _clock) = gate_with(1500);
assert_eq!(
gate.decide(&intent(0, 100)),
GateDecision::Deny {
reason: DenyReason::InvalidNonce
}
);
}
#[test]
fn deny_invalid_intent_empty_merchant() {
let (mut gate, _clock) = gate_with(1500);
let i = SpendIntent::new("d1", 1, 100, " ", "x", "");
assert_eq!(
gate.decide(&i),
GateDecision::Deny {
reason: DenyReason::InvalidIntent
}
);
}
#[test]
fn stage0_matches_intent_validate() {
let (gate, _clock) = gate_with(1500);
let cases = vec![
intent(1, 0), intent(0, 100), SpendIntent::new("", 1, 100, "jd:shop-1", "x", ""), SpendIntent::new("d1", 1, 100, "", "x", ""), SpendIntent::new(" ", 1, 100, "jd:shop-1", "x", ""), ];
for c in cases {
let validates = c.validate();
let decision = gate.evaluate(&c);
if validates.is_ok() {
assert!(decision.is_allow(), "validate 通过但闸拒: {c:?}");
} else {
assert!(
decision.deny_reason().is_some(),
"validate 拒但闸放行: {c:?}"
);
}
}
}
#[test]
fn denied_intent_does_not_consume_nonce() {
let (mut gate, _clock) = gate_with(1500);
assert_eq!(
gate.decide(&intent(1, 5000)),
GateDecision::Deny {
reason: DenyReason::OverBudget
}
);
assert!(
gate.decide(&intent(1, 100)).is_allow(),
"同一 nonce 在拒绝后重发应放行"
);
assert_eq!(gate.spent_cents("d1"), Some(100));
}
#[test]
fn evaluate_is_pure_commit_is_the_only_mutation() {
let (mut gate, _clock) = gate_with(1500);
for _ in 0..5 {
assert!(
gate.evaluate(&intent(1, 100)).is_allow(),
"evaluate 反复调用结果一致且不改状态"
);
}
assert_eq!(gate.spent_cents("d1"), Some(0));
assert!(!gate.replay_registry().contains("agent:claude-code", 1));
gate.commit(&intent(1, 100)).expect("放行后 commit");
assert_eq!(gate.spent_cents("d1"), Some(100));
assert!(gate.replay_registry().contains("agent:claude-code", 1));
}
#[test]
fn commit_rejects_when_gate_would_deny() {
let (mut gate, _clock) = gate_with(1500);
let err = gate.commit(&intent(1, 5000)).unwrap_err();
assert!(matches!(err, CoreError::CommitRejected(_)), "{err}");
assert_eq!(gate.spent_cents("d1"), Some(0));
}
#[test]
fn decide_matches_evaluate_then_commit() {
let (mut gate, _clock) = gate_with(1500);
let d = gate.decide(&intent(1, 300));
let spent = gate.spent_cents("d1");
let replayed = gate.replay_registry().contains("agent:claude-code", 1);
assert_eq!(spent, Some(300));
assert!(replayed);
assert!(d.is_allow());
let (mut gate2, _c) = gate_with(1500);
let verdict = gate2.evaluate(&intent(1, 300));
let after = gate2.commit(&intent(1, 300)).unwrap();
assert_eq!(
verdict,
GateDecision::Allow {
budget_after_cents: after
}
);
}
#[test]
fn register_rejects_invalid_and_duplicate() {
let (mut gate, _clock) = gate_with(1500);
let bad = Delegation::new("bad", "boss", "agent", 0, 1000, 2000, "s");
assert!(matches!(
gate.register_delegation(bad),
Err(CoreError::InvalidDelegation(_))
));
let dup = Delegation::new("d1", "boss", "agent", 1000, 1000, 2000, "s");
assert!(matches!(
gate.register_delegation(dup),
Err(CoreError::DuplicateDelegation(_))
));
assert_eq!(gate.delegations().count(), 1);
}
#[test]
fn revoke_unknown_delegation_is_an_error() {
let (mut gate, _clock) = gate_with(1500);
assert!(matches!(
gate.revoke("ghost"),
Err(CoreError::UnknownDelegation(_))
));
}
}