Skip to main content

wanning_core/
gate.rs

1//! 闸判定面(Gate):四卖点的语义核心。
2//!
3//! fail-closed 检查顺序(先到先拒):
4//!
5//! 0. 意图自身合法性(金额/nonce 为正、必填字段非空)——非法意图不必看委托状态
6//! 1. 未知委托(未注册的 delegation_id)
7//! 2. 未生效 / 已过期(恰在 `valid_until` 按过期处理,半开区间 fail-closed)
8//! 3. 已撤销(kill switch:撤销后**永不允许**;先于 nonce 检查,对齐 Mist
9//!    「已撤销委托新意图一律拒,不耗 nonce/窗口槽」)
10//! 4. 重放((nonce_scope, nonce) 已被成功消费)
11//! 5. 策略与预算(先到先拒:**商户名单 → 禁止时段 → 速率 → 类目 → 总预算**;
12//!    溢出 → Overflow)——全部通过才原子扣减并消耗 nonce
13//!
14//! **任何 Deny 都不消耗 nonce、不动账本、不占速率窗口槽**;只有 Allow 才是
15//! 「这笔消费可以发生」。两阶段 API 供审计层使用(write-ahead,先落审计再扣账):
16//! [`Gate::evaluate`](纯检查)→ 写 WAL → [`Gate::commit`](落地扣减);
17//! [`Gate::decide`] 是「判定+落地」一步到位的便捷入口。
18//!
19//! **单次时钟读**:判定所需的 `now` 由入口(`decide`/`commit`/回放)读一次
20//! 时钟后经 [`Gate::evaluate_at`]/[`Gate::commit_at`] 显式传入。若 evaluate 与
21//! commit 各自读时钟,跨秒边界时实时侧的速率窗口时刻(WAL ts)与回放侧(记录
22//! ts)会漂移,诚实账本的 live_resuming 对账也会 fail-closed——同一个判定必须
23//! 用同一个 now。
24//!
25//! 语义对齐 mist-core 的 `check_budget` 规则 1/3/5(有效期、单笔与总上限)与
26//! 撤销/重放闸口顺序;策略维度(速率/类目/商户/时段)是 W-27 增量,挂在委托上
27//! ([`crate::delegation::Delegation::policy`]),缺省策略行为与本层引入前一致。
28
29use 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/// 拒绝原因。WAL 与对外审计直接落这里(小写蛇形,机器可 diff,人可读)。
44/// (`Ord`/`Hash` 供统计聚合与集合去重使用;派生顺序即枚举声明顺序,不承载语义。)
45#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
46#[serde(rename_all = "snake_case")]
47pub enum DenyReason {
48    /// 委托未注册。
49    UnknownDelegation,
50    /// 委托尚未到生效时刻。
51    NotYetValid,
52    /// 委托已过期(含恰在 `valid_until` 时刻)。
53    Expired,
54    /// 委托已被撤销(kill switch,撤销后永不允许)。
55    Revoked,
56    /// 重放:该 (nonce_scope, nonce) 已被成功消费过。
57    Replay,
58    /// 超出总预算。
59    OverBudget,
60    /// 金额加法溢出(u64),按 fail-closed 处理为拒绝。
61    Overflow,
62    /// 金额非法(0 分)。
63    InvalidAmount,
64    /// nonce 非法(0)。
65    InvalidNonce,
66    /// 意图字段非法(必填字段为空白等),不属于以上具体情形。
67    InvalidIntent,
68    /// 速率限制:滑动窗口内成功放行笔数已达上限(W-27)。
69    RateLimited,
70    /// 类目预算超限(该类目设了上限;未知类目 fail-open,不产生本原因)(W-27)。
71    OverCategoryBudget,
72    /// 商户在黑名单(或与白名单冲突,deny 优先)(W-27)。
73    MerchantDenied,
74    /// 商户不在白名单(白名单非空时;allow 空 = 不设白名单)(W-27)。
75    MerchantNotAllowed,
76    /// 禁止时段(`[from_ts, until_ts)` 绝对 Unix 秒,半开)(W-27)。
77    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/// 闸的判定结果。
104///
105/// `Allow` 携带扣减后的累计消费(`budget_after_cents`),供审计直接引用;
106/// `Deny` 携带原因。金额字段一律分。
107#[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    /// 是否放行(调用方据此决定是否触发下游真实消费)。
115    pub fn is_allow(&self) -> bool {
116        matches!(self, GateDecision::Allow { .. })
117    }
118
119    /// 拒绝原因(Allow 时为 None)。
120    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/// 闸。单实例持有若干委托及其账本/撤销/重放/策略状态。
216#[derive(Debug)]
217pub struct Gate {
218    delegations: BTreeMap<String, Delegation>,
219    revocations: RevocationSet,
220    replay: ReplayRegistry,
221    ledger: BudgetLedger,
222    /// 每委托的策略运行时状态(速率窗口时刻/类目台账)。只在对应维度启用时
223    /// 才有数据——缺省策略的委托在本表恒无条目(零额外内存,回放逐位可重建)。
224    policy_states: BTreeMap<String, PolicyState>,
225    clock: SharedClock,
226}
227
228impl Gate {
229    /// 用给定时钟建闸。
230    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    /// 用系统时钟建闸(生产路径)。
242    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    /// 换时钟句柄(断点续跑用):闸的委托/账本/撤销/nonce 状态都不含时钟,
251    /// 句柄可整体替换——回放对账时用记录 ts 驱动的 [`MockClock`],校验过后
252    /// 换回系统时钟继续服务,状态原封不动。
253    pub fn with_clock(mut self, clock: SharedClock) -> Self {
254        self.clock = clock;
255        self
256    }
257
258    /// 注册一份委托(fail-closed:校验不过 / id 重复 → 拒收)。
259    ///
260    /// nonce_scope 允许与既有委托相同(同一 agent 多份委托共享 nonce 序列,属预期设计)。
261    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    /// 撤销委托(kill switch,单向,幂等)。未知委托 → 错误。
271    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    /// 全部已注册委托(有序,供状态哈希/审计导出)。
288    pub fn delegations(&self) -> impl Iterator<Item = &Delegation> {
289        self.delegations.values()
290    }
291
292    /// 某委托已累计消费(分);未知委托返回 None。
293    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    /// 某委托剩余预算(分);未知委托返回 None。
300    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    /// 某委托的速率窗口成功放行时刻(全量保留,升序);未启用速率或无记录 → 空切片。
318    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    /// 某委托某类目的累计消费(分);未设上限(无台账)或无记录 → None。
326    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    /// 全部策略运行时状态(有序,供状态哈希/审计导出)。
335    pub fn policy_states(&self) -> impl Iterator<Item = (&String, &PolicyState)> {
336        self.policy_states.iter()
337    }
338
339    /// 纯检查:不修改任何状态(写审计层在放行时先落 WAL 再 commit)。
340    pub fn evaluate(&self, intent: &SpendIntent) -> GateDecision {
341        self.evaluate_at(intent, self.clock.now())
342    }
343
344    /// [`Gate::evaluate`] 的显式时刻变体:调用方读一次时钟、传同一 `now`
345    /// 给 evaluate 与 commit,保证实时侧与回放侧的速率窗口判定用同一时刻。
346    pub(crate) fn evaluate_at(&self, intent: &SpendIntent, now: u64) -> GateDecision {
347        // ── 阶段 0:意图自身合法性 ─────────────────────────────────────────
348        // 与 SpendIntent::validate 同一套规则,但这里产出 DenyReason(业务拒绝,
349        // 落审计)而不是 Err(程序错误)。见 gate 测试 `stage0_matches_intent_validate`。
350        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        // ── 阶段 1:未知委托 ─────────────────────────────────────────────
364        let Some(delegation) = self.delegations.get(&intent.delegation_id) else {
365            return deny(DenyReason::UnknownDelegation);
366        };
367
368        // ── 阶段 2:有效期(先看时钟,再看撤销——过期委托连撤销检查都不必做) ──
369        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        // ── 阶段 3:撤销(kill switch)─────────────────────────────────
377        if self.revocations.is_revoked(&delegation.id) {
378            return deny(DenyReason::Revoked);
379        }
380
381        // ── 阶段 4:重放 ────────────────────────────────────────────────
382        if self.replay.contains(&delegation.nonce_scope, intent.nonce) {
383            return deny(DenyReason::Replay);
384        }
385
386        // ── 阶段 5:策略与预算(先到先拒)────────────────────────────────
387        // 顺序:商户名单 → 禁止时段 → 速率 → 类目 → 总预算。
388        // 策略状态按需读取:缺省策略的委托没有运行时状态,零拷贝零分配。
389        let empty = PolicyState::default();
390        let policy_state = self.policy_states.get(&delegation.id).unwrap_or(&empty);
391        let policy = &delegation.policy;
392
393        // 5a 商户名单(deny 优先;allow 空 = 不设白名单)。
394        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        // 5b 禁止时段(绝对 Unix 秒,半开)。
401        if policy.is_quiet(now) {
402            return deny(DenyReason::QuietHours);
403        }
404
405        // 5c 速率限制(滑动窗口,整数秒;只有成功放行才会计入——此刻尚未 commit)。
406        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        // 5d 类目预算(未知类目 fail-open = 无类目预算,总预算仍管;上限 0 = 禁类目)。
413        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        // 5e 总预算(语义对齐 mist-core check_budget 规则 5),先做溢出防御再比上限。
428        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    /// 落地一笔已放行的意图:扣减预算 + 消耗 nonce + 记策略状态。
441    /// 返回扣减后的累计消费(分)。
442    ///
443    /// 前置条件:`evaluate` 对同一意图返回 Allow(WanningState 的 write-ahead 顺序
444    /// 依赖这一点)。commit 自身仍会重新 evaluate 防御 API 误用。
445    pub fn commit(&mut self, intent: &SpendIntent) -> Result<u64, CoreError> {
446        self.commit_at(intent, self.clock.now())
447    }
448
449    /// [`Gate::commit`] 的显式时刻变体(与 [`Gate::evaluate_at`] 同一 `now`)。
450    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                // 策略状态只在对应维度启用时才产生数据(缺省策略零额外状态,
469                // 回放/实时两侧的 state_hash 因此天然一致)。
470                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    /// 判定并落地(便捷入口,任务书 W-03 指定签名)。
493    ///
494    /// 时钟**只读一次**:evaluate 与 commit 用同一 `now`,否则跨秒边界时
495    /// WAL 记录的 ts 与速率窗口时刻可能不一致,回放对账 fail-closed。
496    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                // 不可能失败:evaluate_at 已用同一时刻排除溢出,且中间无状态变更。
501                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    /// ¥10 总预算、有效期 [1000, 2000) 秒、nonce 作用域 agent:claude-code 的样例闸。
523    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        // 撤销后永不允许:时间前进、预算充足、nonce 全新,仍然拒。
609        clock.advance(100);
610        assert_eq!(
611            gate.decide(&intent(3, 1)),
612            GateDecision::Deny {
613                reason: DenyReason::Revoked
614            }
615        );
616        // 撤销不影响既有账本(kill switch 是止血,不是抹账)。
617        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        // 同 nonce、不同金额,一样是重放(重放判定只看 nonce)。
631        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        // 同一 agent 的两份委托共享 nonce_scope → 跨委托重放同 nonce 也被拦。
642        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        // 状态未被污染
704        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        // 闸的阶段 0 与 SpendIntent::validate 必须对同一非法意图给出一致结论,
744        // 否则决策循环先 validate 后提交会出现「validate 过了闸却拒」的口径漂移。
745        let (gate, _clock) = gate_with(1500);
746        let cases = vec![
747            intent(1, 0),                                        // 金额 0
748            intent(0, 100),                                      // nonce 0
749            SpendIntent::new("", 1, 100, "jd:shop-1", "x", ""),  // 空 delegation_id
750            SpendIntent::new("d1", 1, 100, "", "x", ""),         // 空 merchant_id
751            SpendIntent::new(" ", 1, 100, "jd:shop-1", "x", ""), // 空白 delegation_id
752        ];
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        // 拒绝不占号:修好金额后用同一 nonce 重发是合法的。
770        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        // 换一份干净闸,走两阶段路径,结果必须完全一致。
818        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}