1use std::collections::BTreeMap;
30use std::sync::Arc;
31
32use crate::budget::BudgetLedger;
33use crate::clock::{SharedClock, SystemClock};
34use crate::delegation::Delegation;
35use crate::error::CoreError;
36use crate::intent::SpendIntent;
37use crate::policy::{MerchantVerdict, PolicyState};
38use crate::replay::ReplayRegistry;
39use crate::revocation::RevocationSet;
40
41use serde::{Deserialize, Serialize};
42
43#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
46#[serde(rename_all = "snake_case")]
47pub enum DenyReason {
48 UnknownDelegation,
50 NotYetValid,
52 Expired,
54 Revoked,
56 Replay,
58 OverBudget,
60 Overflow,
62 InvalidAmount,
64 InvalidNonce,
66 InvalidIntent,
68 RateLimited,
70 OverCategoryBudget,
72 MerchantDenied,
74 MerchantNotAllowed,
76 QuietHours,
78}
79
80impl std::fmt::Display for DenyReason {
81 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
82 let s = match self {
83 DenyReason::UnknownDelegation => "unknown_delegation",
84 DenyReason::NotYetValid => "not_yet_valid",
85 DenyReason::Expired => "expired",
86 DenyReason::Revoked => "revoked",
87 DenyReason::Replay => "replay",
88 DenyReason::OverBudget => "over_budget",
89 DenyReason::Overflow => "overflow",
90 DenyReason::InvalidAmount => "invalid_amount",
91 DenyReason::InvalidNonce => "invalid_nonce",
92 DenyReason::InvalidIntent => "invalid_intent",
93 DenyReason::RateLimited => "rate_limited",
94 DenyReason::OverCategoryBudget => "over_category_budget",
95 DenyReason::MerchantDenied => "merchant_denied",
96 DenyReason::MerchantNotAllowed => "merchant_not_allowed",
97 DenyReason::QuietHours => "quiet_hours",
98 };
99 f.write_str(s)
100 }
101}
102
103#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
108pub enum GateDecision {
109 Allow { budget_after_cents: u64 },
110 Deny { reason: DenyReason },
111}
112
113impl GateDecision {
114 pub fn is_allow(&self) -> bool {
116 matches!(self, GateDecision::Allow { .. })
117 }
118
119 pub fn deny_reason(&self) -> Option<DenyReason> {
121 match self {
122 GateDecision::Allow { .. } => None,
123 GateDecision::Deny { reason } => Some(*reason),
124 }
125 }
126}
127
128#[cfg(test)]
129mod type_tests {
130 use super::*;
131
132 #[test]
133 fn deny_reason_display_is_snake_case() {
134 assert_eq!(DenyReason::OverBudget.to_string(), "over_budget");
135 assert_eq!(
136 DenyReason::UnknownDelegation.to_string(),
137 "unknown_delegation"
138 );
139 assert_eq!(DenyReason::NotYetValid.to_string(), "not_yet_valid");
140 assert_eq!(DenyReason::InvalidIntent.to_string(), "invalid_intent");
141 assert_eq!(DenyReason::RateLimited.to_string(), "rate_limited");
142 assert_eq!(
143 DenyReason::OverCategoryBudget.to_string(),
144 "over_category_budget"
145 );
146 assert_eq!(DenyReason::MerchantDenied.to_string(), "merchant_denied");
147 assert_eq!(
148 DenyReason::MerchantNotAllowed.to_string(),
149 "merchant_not_allowed"
150 );
151 assert_eq!(DenyReason::QuietHours.to_string(), "quiet_hours");
152 }
153
154 #[test]
155 fn deny_reason_serde_roundtrip_snake_case() {
156 for r in [
157 DenyReason::UnknownDelegation,
158 DenyReason::NotYetValid,
159 DenyReason::Expired,
160 DenyReason::Revoked,
161 DenyReason::Replay,
162 DenyReason::OverBudget,
163 DenyReason::Overflow,
164 DenyReason::InvalidAmount,
165 DenyReason::InvalidNonce,
166 DenyReason::InvalidIntent,
167 DenyReason::RateLimited,
168 DenyReason::OverCategoryBudget,
169 DenyReason::MerchantDenied,
170 DenyReason::MerchantNotAllowed,
171 DenyReason::QuietHours,
172 ] {
173 let json = serde_json::to_string(&r).expect("序列化");
174 let back: DenyReason = serde_json::from_str(&json).expect("反序列化");
175 assert_eq!(back, r, "{r} roundtrip");
176 }
177 assert_eq!(
178 serde_json::to_string(&DenyReason::OverBudget).unwrap(),
179 "\"over_budget\""
180 );
181 }
182
183 #[test]
184 fn decision_shape_and_accessors() {
185 let allow = GateDecision::Allow {
186 budget_after_cents: 500,
187 };
188 assert!(allow.is_allow());
189 assert_eq!(allow.deny_reason(), None);
190
191 let deny = GateDecision::Deny {
192 reason: DenyReason::Revoked,
193 };
194 assert!(!deny.is_allow());
195 assert_eq!(deny.deny_reason(), Some(DenyReason::Revoked));
196 }
197
198 #[test]
199 fn decision_serde_roundtrip() {
200 for d in [
201 GateDecision::Allow {
202 budget_after_cents: 1,
203 },
204 GateDecision::Deny {
205 reason: DenyReason::Replay,
206 },
207 ] {
208 let json = serde_json::to_string(&d).expect("序列化");
209 let back: GateDecision = serde_json::from_str(&json).expect("反序列化");
210 assert_eq!(back, d);
211 }
212 }
213}
214
215#[derive(Debug)]
217pub struct Gate {
218 delegations: BTreeMap<String, Delegation>,
219 revocations: RevocationSet,
220 replay: ReplayRegistry,
221 ledger: BudgetLedger,
222 policy_states: BTreeMap<String, PolicyState>,
225 clock: SharedClock,
226}
227
228impl Gate {
229 pub fn new(clock: SharedClock) -> Self {
231 Self {
232 delegations: BTreeMap::new(),
233 revocations: RevocationSet::new(),
234 replay: ReplayRegistry::new(),
235 ledger: BudgetLedger::new(),
236 policy_states: BTreeMap::new(),
237 clock,
238 }
239 }
240
241 pub fn with_system_clock() -> Self {
243 Self::new(Arc::new(SystemClock))
244 }
245
246 pub fn clock(&self) -> &SharedClock {
247 &self.clock
248 }
249
250 pub fn with_clock(mut self, clock: SharedClock) -> Self {
254 self.clock = clock;
255 self
256 }
257
258 pub fn register_delegation(&mut self, delegation: Delegation) -> Result<(), CoreError> {
262 delegation.validate()?;
263 if self.delegations.contains_key(&delegation.id) {
264 return Err(CoreError::DuplicateDelegation(delegation.id));
265 }
266 self.delegations.insert(delegation.id.clone(), delegation);
267 Ok(())
268 }
269
270 pub fn revoke(&mut self, delegation_id: &str) -> Result<(), CoreError> {
272 if !self.delegations.contains_key(delegation_id) {
273 return Err(CoreError::UnknownDelegation(delegation_id.to_string()));
274 }
275 self.revocations.revoke(delegation_id);
276 Ok(())
277 }
278
279 pub fn is_revoked(&self, delegation_id: &str) -> bool {
280 self.revocations.is_revoked(delegation_id)
281 }
282
283 pub fn delegation(&self, delegation_id: &str) -> Option<&Delegation> {
284 self.delegations.get(delegation_id)
285 }
286
287 pub fn delegations(&self) -> impl Iterator<Item = &Delegation> {
289 self.delegations.values()
290 }
291
292 pub fn spent_cents(&self, delegation_id: &str) -> Option<u64> {
294 self.delegations
295 .contains_key(delegation_id)
296 .then(|| self.ledger.spent_cents(delegation_id))
297 }
298
299 pub fn remaining_cents(&self, delegation_id: &str) -> Option<u64> {
301 let cap = self.delegations.get(delegation_id)?.budget_cap_cents;
302 Some(self.ledger.remaining_cents(delegation_id, cap))
303 }
304
305 pub fn revocations(&self) -> &RevocationSet {
306 &self.revocations
307 }
308
309 pub fn replay_registry(&self) -> &ReplayRegistry {
310 &self.replay
311 }
312
313 pub fn ledger(&self) -> &BudgetLedger {
314 &self.ledger
315 }
316
317 pub fn velocity_stamps(&self, delegation_id: &str) -> &[u64] {
319 self.policy_states
320 .get(delegation_id)
321 .map(|s| s.velocity_stamps.as_slice())
322 .unwrap_or(&[])
323 }
324
325 pub fn category_spent_cents(&self, delegation_id: &str, category: &str) -> Option<u64> {
327 self.policy_states
328 .get(delegation_id)?
329 .category_spent_cents
330 .get(category)
331 .copied()
332 }
333
334 pub fn policy_states(&self) -> impl Iterator<Item = (&String, &PolicyState)> {
336 self.policy_states.iter()
337 }
338
339 pub fn evaluate(&self, intent: &SpendIntent) -> GateDecision {
341 self.evaluate_at(intent, self.clock.now())
342 }
343
344 pub(crate) fn evaluate_at(&self, intent: &SpendIntent, now: u64) -> GateDecision {
347 if intent.amount_cents == 0 {
351 return deny(DenyReason::InvalidAmount);
352 }
353 if intent.nonce == 0 {
354 return deny(DenyReason::InvalidNonce);
355 }
356 if intent.delegation_id.trim().is_empty() {
357 return deny(DenyReason::UnknownDelegation);
358 }
359 if intent.merchant_id.trim().is_empty() {
360 return deny(DenyReason::InvalidIntent);
361 }
362
363 let Some(delegation) = self.delegations.get(&intent.delegation_id) else {
365 return deny(DenyReason::UnknownDelegation);
366 };
367
368 if delegation.not_yet_valid(now) {
370 return deny(DenyReason::NotYetValid);
371 }
372 if delegation.is_expired(now) {
373 return deny(DenyReason::Expired);
374 }
375
376 if self.revocations.is_revoked(&delegation.id) {
378 return deny(DenyReason::Revoked);
379 }
380
381 if self.replay.contains(&delegation.nonce_scope, intent.nonce) {
383 return deny(DenyReason::Replay);
384 }
385
386 let empty = PolicyState::default();
390 let policy_state = self.policy_states.get(&delegation.id).unwrap_or(&empty);
391 let policy = &delegation.policy;
392
393 match policy.merchant_verdict(&intent.merchant_id) {
395 Some(MerchantVerdict::Denied) => return deny(DenyReason::MerchantDenied),
396 Some(MerchantVerdict::NotAllowed) => return deny(DenyReason::MerchantNotAllowed),
397 None => {}
398 }
399
400 if policy.is_quiet(now) {
402 return deny(DenyReason::QuietHours);
403 }
404
405 if let Some(v) = &policy.velocity {
407 if policy_state.in_window_count(now, v.window_secs) >= v.max_spends as usize {
408 return deny(DenyReason::RateLimited);
409 }
410 }
411
412 if let Some(cap) = policy.category_caps_cents.get(intent.category.as_str()) {
414 let spent = policy_state
415 .category_spent_cents
416 .get(intent.category.as_str())
417 .copied()
418 .unwrap_or(0);
419 let Some(after) = spent.checked_add(intent.amount_cents) else {
420 return deny(DenyReason::Overflow);
421 };
422 if after > *cap {
423 return deny(DenyReason::OverCategoryBudget);
424 }
425 }
426
427 let spent = self.ledger.spent_cents(&delegation.id);
429 let Some(total) = spent.checked_add(intent.amount_cents) else {
430 return deny(DenyReason::Overflow);
431 };
432 if total > delegation.budget_cap_cents {
433 return deny(DenyReason::OverBudget);
434 }
435 GateDecision::Allow {
436 budget_after_cents: total,
437 }
438 }
439
440 pub fn commit(&mut self, intent: &SpendIntent) -> Result<u64, CoreError> {
446 self.commit_at(intent, self.clock.now())
447 }
448
449 pub(crate) fn commit_at(&mut self, intent: &SpendIntent, now: u64) -> Result<u64, CoreError> {
451 match self.evaluate_at(intent, now) {
452 GateDecision::Allow { budget_after_cents } => {
453 let delegation = self
454 .delegations
455 .get(&intent.delegation_id)
456 .expect("evaluate 放行 ⇒ 委托必已注册");
457 let nonce_scope = delegation.nonce_scope.clone();
458 let velocity_enabled = delegation.policy.velocity.is_some();
459 let capped_category = delegation
460 .policy
461 .category_caps_cents
462 .contains_key(intent.category.as_str())
463 .then(|| intent.category.clone());
464 let after = self
465 .ledger
466 .commit(&intent.delegation_id, intent.amount_cents)?;
467 self.replay.consume(&nonce_scope, intent.nonce);
468 if velocity_enabled || capped_category.is_some() {
471 let state = self
472 .policy_states
473 .entry(intent.delegation_id.clone())
474 .or_default();
475 if velocity_enabled {
476 state.record_velocity_stamp(now);
477 }
478 if let Some(category) = capped_category {
479 state.record_category_spend(&category, intent.amount_cents);
480 }
481 }
482 debug_assert_eq!(after, budget_after_cents);
483 Ok(after)
484 }
485 GateDecision::Deny { reason } => Err(CoreError::CommitRejected(format!(
486 "delegation={} nonce={} reason={reason}",
487 intent.delegation_id, intent.nonce
488 ))),
489 }
490 }
491
492 pub fn decide(&mut self, intent: &SpendIntent) -> GateDecision {
497 let now = self.clock.now();
498 match self.evaluate_at(intent, now) {
499 GateDecision::Allow { budget_after_cents } => {
500 let after = self
502 .commit_at(intent, now)
503 .expect("evaluate_at 放行 ⇒ commit_at 必成功(同一时刻重判)");
504 debug_assert_eq!(after, budget_after_cents);
505 GateDecision::Allow {
506 budget_after_cents: after,
507 }
508 }
509 deny => deny,
510 }
511 }
512}
513
514fn deny(reason: DenyReason) -> GateDecision {
515 GateDecision::Deny { reason }
516}
517
518#[cfg(test)]
519mod tests {
520 use super::*;
521
522 fn gate_with(now: u64) -> (Gate, crate::clock::MockClock) {
524 let clock = crate::clock::MockClock::new(now);
525 let mut gate = Gate::new(Arc::new(clock.clone()));
526 gate.register_delegation(Delegation::new(
527 "d1",
528 "boss",
529 "claude-code",
530 1000,
531 1000,
532 2000,
533 "agent:claude-code",
534 ))
535 .expect("样例委托合法");
536 (gate, clock)
537 }
538
539 fn intent(nonce: u64, amount_cents: u64) -> SpendIntent {
540 SpendIntent::new("d1", nonce, amount_cents, "jd:shop-1", "grocery", "测试")
541 }
542
543 #[test]
544 fn allow_happy_path_deducts_budget() {
545 let (mut gate, _clock) = gate_with(1500);
546 assert_eq!(
547 gate.decide(&intent(1, 500)),
548 GateDecision::Allow {
549 budget_after_cents: 500
550 }
551 );
552 assert_eq!(gate.remaining_cents("d1"), Some(500));
553 assert_eq!(gate.spent_cents("d1"), Some(500));
554 }
555
556 #[test]
557 fn deny_unknown_delegation() {
558 let (mut gate, _clock) = gate_with(1500);
559 let i = SpendIntent::new("ghost", 1, 100, "jd:shop-1", "x", "");
560 assert_eq!(
561 gate.decide(&i),
562 GateDecision::Deny {
563 reason: DenyReason::UnknownDelegation
564 }
565 );
566 assert_eq!(gate.spent_cents("ghost"), None);
567 }
568
569 #[test]
570 fn deny_not_yet_valid() {
571 let (mut gate, _clock) = gate_with(999);
572 assert_eq!(
573 gate.decide(&intent(1, 100)),
574 GateDecision::Deny {
575 reason: DenyReason::NotYetValid
576 }
577 );
578 }
579
580 #[test]
581 fn deny_expired_including_exact_boundary() {
582 let (mut gate, clock) = gate_with(1999);
583 assert!(
584 gate.decide(&intent(1, 100)).is_allow(),
585 "valid_until 前一秒仍可消费"
586 );
587 clock.set_now(2000);
588 assert_eq!(
589 gate.decide(&intent(2, 100)),
590 GateDecision::Deny {
591 reason: DenyReason::Expired
592 },
593 "恰在 valid_until 时刻必须按过期处理(fail-closed)"
594 );
595 }
596
597 #[test]
598 fn deny_revoked_and_never_allowed_again() {
599 let (mut gate, clock) = gate_with(1500);
600 assert!(gate.decide(&intent(1, 100)).is_allow());
601 gate.revoke("d1").expect("撤销已注册委托");
602 assert_eq!(
603 gate.decide(&intent(2, 100)),
604 GateDecision::Deny {
605 reason: DenyReason::Revoked
606 }
607 );
608 clock.advance(100);
610 assert_eq!(
611 gate.decide(&intent(3, 1)),
612 GateDecision::Deny {
613 reason: DenyReason::Revoked
614 }
615 );
616 assert_eq!(gate.spent_cents("d1"), Some(100));
618 }
619
620 #[test]
621 fn deny_replay_same_nonce_same_scope() {
622 let (mut gate, _clock) = gate_with(1500);
623 assert!(gate.decide(&intent(1, 100)).is_allow());
624 assert_eq!(
625 gate.decide(&intent(1, 100)),
626 GateDecision::Deny {
627 reason: DenyReason::Replay
628 }
629 );
630 assert_eq!(
632 gate.decide(&intent(1, 1)),
633 GateDecision::Deny {
634 reason: DenyReason::Replay
635 }
636 );
637 }
638
639 #[test]
640 fn replay_is_scoped_by_nonce_scope() {
641 let clock = crate::clock::MockClock::new(1500);
643 let mut gate = Gate::new(Arc::new(clock));
644 gate.register_delegation(Delegation::new(
645 "d1",
646 "boss",
647 "claude-code",
648 1000,
649 1000,
650 2000,
651 "agent:claude-code",
652 ))
653 .unwrap();
654 gate.register_delegation(Delegation::new(
655 "d2",
656 "boss",
657 "claude-code",
658 1000,
659 1000,
660 2000,
661 "agent:claude-code",
662 ))
663 .unwrap();
664 assert!(gate.decide(&intent(1, 100)).is_allow());
665 let other = SpendIntent::new("d2", 1, 100, "jd:shop-1", "x", "");
666 assert_eq!(
667 gate.decide(&other),
668 GateDecision::Deny {
669 reason: DenyReason::Replay
670 },
671 "同作用域跨委托重放同 nonce 必须被拦"
672 );
673 }
674
675 #[test]
676 fn deny_over_budget_but_exact_cap_is_allowed() {
677 let (mut gate, _clock) = gate_with(1500);
678 assert!(gate.decide(&intent(1, 500)).is_allow());
679 assert!(
680 gate.decide(&intent(2, 500)).is_allow(),
681 "恰好花满 cap 应放行"
682 );
683 assert_eq!(gate.remaining_cents("d1"), Some(0));
684 assert_eq!(
685 gate.decide(&intent(3, 1)),
686 GateDecision::Deny {
687 reason: DenyReason::OverBudget
688 }
689 );
690 }
691
692 #[test]
693 fn deny_amount_overflow() {
694 let (mut gate, _clock) = gate_with(1500);
695 assert!(gate.decide(&intent(1, 500)).is_allow());
696 let huge = SpendIntent::new("d1", 2, u64::MAX, "jd:shop-1", "x", "");
697 assert_eq!(
698 gate.decide(&huge),
699 GateDecision::Deny {
700 reason: DenyReason::Overflow
701 }
702 );
703 assert_eq!(gate.spent_cents("d1"), Some(500));
705 }
706
707 #[test]
708 fn deny_invalid_amount_zero() {
709 let (mut gate, _clock) = gate_with(1500);
710 assert_eq!(
711 gate.decide(&intent(1, 0)),
712 GateDecision::Deny {
713 reason: DenyReason::InvalidAmount
714 }
715 );
716 }
717
718 #[test]
719 fn deny_invalid_nonce_zero() {
720 let (mut gate, _clock) = gate_with(1500);
721 assert_eq!(
722 gate.decide(&intent(0, 100)),
723 GateDecision::Deny {
724 reason: DenyReason::InvalidNonce
725 }
726 );
727 }
728
729 #[test]
730 fn deny_invalid_intent_empty_merchant() {
731 let (mut gate, _clock) = gate_with(1500);
732 let i = SpendIntent::new("d1", 1, 100, " ", "x", "");
733 assert_eq!(
734 gate.decide(&i),
735 GateDecision::Deny {
736 reason: DenyReason::InvalidIntent
737 }
738 );
739 }
740
741 #[test]
742 fn stage0_matches_intent_validate() {
743 let (gate, _clock) = gate_with(1500);
746 let cases = vec![
747 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", ""), ];
753 for c in cases {
754 let validates = c.validate();
755 let decision = gate.evaluate(&c);
756 if validates.is_ok() {
757 assert!(decision.is_allow(), "validate 通过但闸拒: {c:?}");
758 } else {
759 assert!(
760 decision.deny_reason().is_some(),
761 "validate 拒但闸放行: {c:?}"
762 );
763 }
764 }
765 }
766
767 #[test]
768 fn denied_intent_does_not_consume_nonce() {
769 let (mut gate, _clock) = gate_with(1500);
771 assert_eq!(
772 gate.decide(&intent(1, 5000)),
773 GateDecision::Deny {
774 reason: DenyReason::OverBudget
775 }
776 );
777 assert!(
778 gate.decide(&intent(1, 100)).is_allow(),
779 "同一 nonce 在拒绝后重发应放行"
780 );
781 assert_eq!(gate.spent_cents("d1"), Some(100));
782 }
783
784 #[test]
785 fn evaluate_is_pure_commit_is_the_only_mutation() {
786 let (mut gate, _clock) = gate_with(1500);
787 for _ in 0..5 {
788 assert!(
789 gate.evaluate(&intent(1, 100)).is_allow(),
790 "evaluate 反复调用结果一致且不改状态"
791 );
792 }
793 assert_eq!(gate.spent_cents("d1"), Some(0));
794 assert!(!gate.replay_registry().contains("agent:claude-code", 1));
795 gate.commit(&intent(1, 100)).expect("放行后 commit");
796 assert_eq!(gate.spent_cents("d1"), Some(100));
797 assert!(gate.replay_registry().contains("agent:claude-code", 1));
798 }
799
800 #[test]
801 fn commit_rejects_when_gate_would_deny() {
802 let (mut gate, _clock) = gate_with(1500);
803 let err = gate.commit(&intent(1, 5000)).unwrap_err();
804 assert!(matches!(err, CoreError::CommitRejected(_)), "{err}");
805 assert_eq!(gate.spent_cents("d1"), Some(0));
806 }
807
808 #[test]
809 fn decide_matches_evaluate_then_commit() {
810 let (mut gate, _clock) = gate_with(1500);
811 let d = gate.decide(&intent(1, 300));
812 let spent = gate.spent_cents("d1");
813 let replayed = gate.replay_registry().contains("agent:claude-code", 1);
814 assert_eq!(spent, Some(300));
815 assert!(replayed);
816 assert!(d.is_allow());
817 let (mut gate2, _c) = gate_with(1500);
819 let verdict = gate2.evaluate(&intent(1, 300));
820 let after = gate2.commit(&intent(1, 300)).unwrap();
821 assert_eq!(
822 verdict,
823 GateDecision::Allow {
824 budget_after_cents: after
825 }
826 );
827 }
828
829 #[test]
830 fn register_rejects_invalid_and_duplicate() {
831 let (mut gate, _clock) = gate_with(1500);
832 let bad = Delegation::new("bad", "boss", "agent", 0, 1000, 2000, "s");
833 assert!(matches!(
834 gate.register_delegation(bad),
835 Err(CoreError::InvalidDelegation(_))
836 ));
837 let dup = Delegation::new("d1", "boss", "agent", 1000, 1000, 2000, "s");
838 assert!(matches!(
839 gate.register_delegation(dup),
840 Err(CoreError::DuplicateDelegation(_))
841 ));
842 assert_eq!(gate.delegations().count(), 1);
843 }
844
845 #[test]
846 fn revoke_unknown_delegation_is_an_error() {
847 let (mut gate, _clock) = gate_with(1500);
848 assert!(matches!(
849 gate.revoke("ghost"),
850 Err(CoreError::UnknownDelegation(_))
851 ));
852 }
853}