Skip to main content

wanning_core/
state.rs

1//! 闸的完整运行状态([`WanningState`]):闸 + 审计日志 + 时钟。
2//!
3//! 这是 demo / 未来 MCP server 实际持有的对象。职责只有一条:
4//! **每一条决策都必须先落审计,再落账本**(write-ahead)——审计写不进去,这笔消费
5//! 就不能发生。这样「崩溃后的世界」只会比实时状态**更严格**(多扣不会出现,少扣可能),
6//! 永远不会出现「花了钱却查无此账」。
7//!
8//! 回放([`WanningState::replay`]):从 WAL 逐行重建状态,用记录里的 ts 驱动注入时钟,
9//! 并**重算每一条决策**与记录对账;任何不一致立即 fail-closed 报错。回放是确定性的:
10//! 同一份 WAL 回放两遍,state hash 相同。
11
12use std::path::Path;
13use std::sync::Arc;
14
15use crate::clock::{MockClock, SharedClock, SystemClock};
16use crate::delegation::Delegation;
17use crate::error::CoreError;
18use crate::gate::{Gate, GateDecision};
19use crate::intent::SpendIntent;
20use crate::wal::{fnv1a_64, Wal, WalDecision, WalRecord};
21
22/// 闸 + 审计日志 + 时钟的运行时状态。
23#[derive(Debug)]
24pub struct WanningState {
25    gate: Gate,
26    wal: Option<Wal>,
27}
28
29impl WanningState {
30    /// 纯内存状态(无审计落盘)。回放与测试用。
31    pub fn new(clock: SharedClock) -> Self {
32        Self {
33            gate: Gate::new(clock),
34            wal: None,
35        }
36    }
37
38    /// 带审计落盘的状态。WAL 打开为追加模式,绝不截断。
39    pub fn with_wal(clock: SharedClock, wal_path: impl AsRef<Path>) -> Result<Self, CoreError> {
40        Ok(Self {
41            gate: Gate::new(clock),
42            wal: Some(Wal::open(wal_path)?),
43        })
44    }
45
46    /// 生产状态:系统时钟 + 审计落盘。
47    ///
48    /// **注意:不回放已有 WAL**——闸从空开始,只往后追加。适合「一次进程一次新账」
49    /// 的 demo 场景;长期服务重启要接续旧账,用 [`WanningState::live_resuming`]。
50    pub fn live(wal_path: impl AsRef<Path>) -> Result<Self, CoreError> {
51        Self::with_wal(Arc::new(SystemClock), wal_path)
52    }
53
54    /// 断点续跑:先整体回放已有 WAL 对账(损坏/篡改/不一致 → fail-closed 拒启),
55    /// 再换回系统时钟、继续往**同一份 WAL** 追加。
56    ///
57    /// 长期服务(MCP server)重启时用它:账本、撤销、nonce 登记全部从审计接续,
58    /// 绝不带着一张空账本接着判——否则重启会把 nonce 洗白、把撤销掉的授权复活。
59    ///
60    /// 同一份 WAL 同时至多一个**活着的写进程**(`Wal::open` 自动持单写者锁):
61    /// 第二个进程 fail-closed 拒启(`CoreError::WalLocked`)。两个平台并挂同一份
62    /// WAL(`.mcp.json` + `.trae/mcp.json`)就是真实场景——并发双闸的内存账本
63    /// 互不知情,预算硬上限会被合力突破(实测见 `tests/single_writer.rs`)。
64    ///
65    /// 与 [`WanningState::replay`] 的区别:replay 冻结在「过去的世界」(注入时钟停在
66    /// 最后一条记录的 ts、不挂 WAL);本方法校验过后回到「现在的世界」(系统时钟,
67    /// 继续写审计)。
68    pub fn live_resuming(wal_path: impl AsRef<Path>) -> Result<Self, CoreError> {
69        let path = wal_path.as_ref();
70        // 先开 WAL(不存在则创建;append-only,绝不截断)——空文件是合法起点。
71        let wal = Wal::open(path)?;
72        let resumed = Self::replay(path)?;
73        Ok(Self {
74            gate: resumed.gate.with_clock(Arc::new(SystemClock)),
75            wal: Some(wal),
76        })
77    }
78
79    pub fn gate(&self) -> &Gate {
80        &self.gate
81    }
82
83    pub fn wal_path(&self) -> Option<&Path> {
84        self.wal.as_ref().map(Wal::path)
85    }
86
87    /// WAL 当前行数;无 WAL 时为 None。审计证据的「WAL 偏移」即行号。
88    pub fn wal_line_count(&self) -> Option<u64> {
89        self.wal.as_ref().map(Wal::line_count)
90    }
91
92    /// 最近一次追加的 WAL 行号(1-based);无 WAL 时为 None。
93    pub fn last_wal_line(&self) -> Option<u64> {
94        self.wal_line_count()
95    }
96
97    /// 审计完整性链的链尾值(最后一条记录的链值;无 WAL 时为 None)。
98    ///
99    /// 对账证据之一:实时侧这个值,与读侧 [`read_verified`](crate::wal::read_verified)
100    /// 独立重算的链尾必须相等——逐行成链,改历史行而不重算后续整条链,当场现形。
101    pub fn audit_chain_tail(&self) -> Option<u64> {
102        self.wal.as_ref().map(Wal::chain_tail)
103    }
104
105    /// 注册委托:先确认必成,再写审计,再入闸(write-ahead)。
106    pub fn register_delegation(&mut self, delegation: Delegation) -> Result<(), CoreError> {
107        // 预检与 Gate::register_delegation 同一套规则;先确认「必然成功」,
108        // 保证审计记录永远不会描述一次没发生的注册。
109        delegation.validate()?;
110        if self.gate.delegation(&delegation.id).is_some() {
111            return Err(CoreError::DuplicateDelegation(delegation.id));
112        }
113        let record = WalRecord::RegisterDelegation {
114            ts: self.now(),
115            delegation: delegation.clone(),
116        };
117        if let Some(wal) = self.wal.as_mut() {
118            wal.append(&record)?;
119        }
120        self.gate.register_delegation(delegation)
121    }
122
123    /// 撤销委托(kill switch):先确认必成,再写审计,再撤销。
124    pub fn revoke(&mut self, delegation_id: &str) -> Result<(), CoreError> {
125        if self.gate.delegation(delegation_id).is_none() {
126            return Err(CoreError::UnknownDelegation(delegation_id.to_string()));
127        }
128        let record = WalRecord::Revoke {
129            ts: self.now(),
130            delegation_id: delegation_id.to_string(),
131        };
132        if let Some(wal) = self.wal.as_mut() {
133            wal.append(&record)?;
134        }
135        self.gate.revoke(delegation_id)
136    }
137
138    /// 判定一笔消费意图:evaluate → 写审计 → commit(write-ahead)。
139    ///
140    /// 返回闸的判定。注意失败语义:
141    /// - 审计写失败 → `Err`,**状态零变更**(这笔消费没有发生,也不能发生);
142    /// - 审计写成功但 commit 失败(理论不可达)→ `Err`,WAL 领先于账本,
143    ///   回放侧只会更严格,不会放水。
144    pub fn decide(&mut self, intent: &SpendIntent) -> Result<GateDecision, CoreError> {
145        // 时钟只读一次:评估、WAL 记录 ts、落地扣减(含速率窗口时刻)用同一 `now`。
146        // 若各读各的,跨秒边界时实时侧速率窗口时刻会漂离 WAL 记录 ts,回放对账
147        // 会把诚实账本误判为不一致——单次读是回放可重建的前提。
148        let ts = self.now();
149        let verdict = self.gate.evaluate_at(intent, ts);
150        let spent_after = match verdict {
151            // Allow 携带的就是「扣减后的累计消费」,直接取用,不重算。
152            GateDecision::Allow { budget_after_cents } => budget_after_cents,
153            GateDecision::Deny { .. } => self.gate.spent_cents(&intent.delegation_id).unwrap_or(0),
154        };
155        let record = WalRecord::Decide {
156            ts,
157            decision: match verdict {
158                GateDecision::Allow { .. } => WalDecision::Allow,
159                GateDecision::Deny { .. } => WalDecision::Deny,
160            },
161            delegation_id: intent.delegation_id.clone(),
162            intent: intent.clone(),
163            reason: verdict.deny_reason(),
164            budget_after_cents: spent_after,
165        };
166        if let Some(wal) = self.wal.as_mut() {
167            wal.append(&record)?;
168        }
169        match verdict {
170            GateDecision::Allow { budget_after_cents } => {
171                let after = self.gate.commit_at(intent, ts)?;
172                debug_assert_eq!(after, budget_after_cents);
173                Ok(GateDecision::Allow {
174                    budget_after_cents: after,
175                })
176            }
177            deny => Ok(deny),
178        }
179    }
180
181    /// 闸状态指纹(FNV-1a 64,非密码学,仅用于确定性对账)。
182    ///
183    /// 覆盖:委托集、账本、撤销集、nonce 登记集、策略运行时状态(W-27 速率
184    /// 窗口时刻与类目台账——随 commit 演化的状态必须进指纹,否则「速率窗口跨
185    /// 重启被洗掉」这类回放缺失对账不出来);全部按有序迭代序列化,
186    /// 因此「同一份 WAL 回放两遍 hash 必相同」由构造保证。
187    pub fn state_hash(&self) -> u64 {
188        let snapshot = serde_json::json!({
189            "delegations": self.gate.delegations().collect::<Vec<_>>(),
190            "spent_cents": self.gate.ledger().entries().collect::<Vec<_>>(),
191            "revoked": self.gate.revocations().iter().collect::<Vec<_>>(),
192            "used_nonces": self.gate.replay_registry().iter().collect::<Vec<_>>(),
193            "policy_states": self.gate.policy_states().collect::<Vec<_>>(),
194        });
195        fnv1a_64(snapshot.to_string().as_bytes())
196    }
197
198    fn now(&self) -> u64 {
199        self.gate.clock().now()
200    }
201
202    /// 从 WAL 回放重建状态(确定性;损坏行 / 完整性链断裂 / 对账不一致 → fail-closed)。
203    ///
204    /// 返回的状态:
205    /// - 时钟是被注入的 [`MockClock`],冻结在最后一条记录的 ts(回放是「过去的世界」,
206    ///   不适合继续判定新意图——要续,就重新 `live()` 开一个新 WAL);
207    /// - 未挂 WAL(回放不追加记录)。
208    pub fn replay(wal_path: impl AsRef<Path>) -> Result<Self, CoreError> {
209        // 读回即验完整性链(seq/prev 逐行核),再逐行重算对账。
210        let records = crate::wal::read_verified(wal_path)?.records;
211        let clock = MockClock::new(0);
212        let mut state = WanningState::new(Arc::new(clock.clone()));
213        for (line_no, record) in records {
214            let record_ts = record.ts();
215            clock.set_now(record_ts);
216            match record {
217                WalRecord::RegisterDelegation { delegation, .. } => state
218                    .gate
219                    .register_delegation(delegation)
220                    .map_err(|e| CoreError::WalMismatch {
221                        line: line_no,
222                        message: format!("重放注册失败: {e}"),
223                    })?,
224                WalRecord::Revoke { delegation_id, .. } => state
225                    .gate
226                    .revoke(&delegation_id)
227                    .map_err(|e| CoreError::WalMismatch {
228                        line: line_no,
229                        message: format!("重放撤销失败: {e}"),
230                    })?,
231                WalRecord::Decide {
232                    decision,
233                    intent,
234                    reason,
235                    budget_after_cents,
236                    ..
237                } => {
238                    // 重算用记录自身的 ts(与 clock.set_now 同一时刻):速率窗口等
239                    // 依赖「判定时刻」的检查必须在记录 ts 上复现,绝不能看回放进程
240                    // 的真实时钟。
241                    let ts = record_ts;
242                    let verdict = state.gate.evaluate_at(&intent, ts);
243                    match (verdict, decision, reason) {
244                        (
245                            GateDecision::Allow {
246                                budget_after_cents: recomputed,
247                            },
248                            WalDecision::Allow,
249                            None,
250                        ) => {
251                            if recomputed != budget_after_cents {
252                                return Err(CoreError::WalMismatch {
253                                    line: line_no,
254                                    message: format!(
255                                        "放行记录的累计消费与重算不一致:记录 {budget_after_cents} / 重算 {recomputed}"
256                                    ),
257                                });
258                            }
259                            state.gate.commit_at(&intent, ts).map_err(|e| {
260                                CoreError::WalMismatch {
261                                    line: line_no,
262                                    message: format!("重放扣减失败: {e}"),
263                                }
264                            })?;
265                        }
266                        (
267                            GateDecision::Deny { reason: recomputed },
268                            WalDecision::Deny,
269                            Some(recorded_reason),
270                        ) if recomputed == recorded_reason => {
271                            // 拒绝:状态零变更,只需口径一致。
272                        }
273                        (verdict, decision, reason) => {
274                            return Err(CoreError::WalMismatch {
275                                line: line_no,
276                                message: format!(
277                                    "重算判定与记录不一致:重算 {verdict:?} / 记录 {decision:?} reason={reason:?}"
278                                ),
279                            });
280                        }
281                    }
282                }
283            }
284        }
285        Ok(state)
286    }
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292    use crate::clock::{Clock, MockClock};
293    use crate::gate::DenyReason;
294
295    fn tmp_wal(tag: &str) -> std::path::PathBuf {
296        let dir = std::env::temp_dir().join("wanning-state-tests");
297        std::fs::create_dir_all(&dir).expect("建临时目录");
298        dir.join(format!("{tag}-{}.jsonl", std::process::id()))
299    }
300
301    fn delegation() -> Delegation {
302        Delegation::new(
303            "d1",
304            "boss",
305            "claude-code",
306            1000,
307            1000,
308            2000,
309            "agent:claude-code",
310        )
311    }
312
313    /// 续跑测试专用:回放侧时钟停在记录 ts(如 1500),续跑后是真实「现在」——
314    /// 委托窗口必须同时覆盖两个世界(1500 之前生效、系统时钟下未过期)。
315    fn long_lived_delegation() -> Delegation {
316        Delegation::new(
317            "d1",
318            "boss",
319            "claude-code",
320            1000,
321            1000,
322            SystemClock.now().checked_add(86_400).expect("有效期溢出"),
323            "agent:claude-code",
324        )
325    }
326
327    fn intent(nonce: u64, amount_cents: u64) -> SpendIntent {
328        SpendIntent::new("d1", nonce, amount_cents, "jd:shop-1", "grocery", "测试")
329    }
330
331    #[test]
332    fn allow_and_deny_are_both_recorded() {
333        let path = tmp_wal("both");
334        let clock = MockClock::new(1500);
335        let mut state = WanningState::with_wal(Arc::new(clock.clone()), &path).expect("开 WAL");
336        state.register_delegation(delegation()).expect("注册");
337
338        // 放行
339        let a = state.decide(&intent(1, 500)).expect("判定");
340        assert!(a.is_allow());
341        // 拒绝(超额)
342        let d = state.decide(&intent(2, 9000)).expect("判定");
343        assert_eq!(d.deny_reason(), Some(DenyReason::OverBudget));
344
345        let records = crate::wal::read_records(&path).expect("读回");
346        assert_eq!(records.len(), 3, "注册 + 放行 + 拒绝");
347        let (_, first_decide) = &records[1];
348        let (_, second_decide) = &records[2];
349        match (first_decide.kind(), first_decide.ts(), second_decide.kind()) {
350            ("decide", 1500, "decide") => {}
351            other => panic!("记录形状不符: {other:?}"),
352        }
353        // deny 记录在案(带 reason、不带 budget 变化)
354        let crate::wal::WalRecord::Decide {
355            decision,
356            reason,
357            budget_after_cents,
358            ..
359        } = second_decide
360        else {
361            panic!("第二条决策记录应是 Decide");
362        };
363        assert_eq!(*decision, WalDecision::Deny);
364        assert_eq!(*reason, Some(DenyReason::OverBudget));
365        assert_eq!(*budget_after_cents, 500, "拒绝不改账本,累计消费仍是 500");
366    }
367
368    #[test]
369    fn write_ahead_audit_failure_leaves_state_untouched() {
370        // 审计写不进去 → 消费不能发生(状态零变更)。
371        // 构造:占用目标路径为目录,使 WAL 打开即失败。
372        let dir = std::env::temp_dir().join("wanning-state-tests");
373        std::fs::create_dir_all(&dir).expect("建临时目录");
374        let path = dir.join(format!("dir-as-wal-{}.jsonl", std::process::id()));
375        std::fs::create_dir_all(&path).expect("占位为目录");
376
377        let err = WanningState::with_wal(Arc::new(MockClock::new(1500)), &path).unwrap_err();
378        assert!(matches!(err, CoreError::WalIo(_)), "{err}");
379    }
380
381    #[test]
382    fn replay_rebuilds_state_and_is_deterministic() {
383        let path = tmp_wal("replay");
384        let clock = MockClock::new(1500);
385        let mut state = WanningState::with_wal(Arc::new(clock.clone()), &path).expect("开 WAL");
386        state.register_delegation(delegation()).expect("注册");
387        state.decide(&intent(1, 500)).expect("放行");
388        state.decide(&intent(2, 9000)).expect("超额拒");
389        state.decide(&intent(3, 100)).expect("再放行");
390        state.revoke("d1").expect("撤销");
391        state.decide(&intent(4, 100)).expect("撤销后拒");
392
393        let live_hash = state.state_hash();
394        assert_eq!(
395            state.gate().spent_cents("d1"),
396            Some(600),
397            "实时累计消费 = 500 + 100"
398        );
399
400        // 回放两遍,hash 必须一致且等于实时状态。
401        let replayed = WanningState::replay(&path).expect("回放");
402        let hash_once = replayed.state_hash();
403        let replayed_again = WanningState::replay(&path).expect("回放二遍");
404        let hash_twice = replayed_again.state_hash();
405
406        assert_eq!(hash_once, hash_twice, "回放两遍 hash 必相同(确定性)");
407        assert_eq!(hash_once, live_hash, "回放态必须与实时态完全一致");
408        assert_eq!(replayed.gate().spent_cents("d1"), Some(600));
409        assert!(replayed.gate().is_revoked("d1"));
410        assert!(
411            replayed
412                .gate()
413                .replay_registry()
414                .contains("agent:claude-code", 1),
415            "重放登记也必须被重建"
416        );
417        assert_eq!(replayed.wal_line_count(), None, "回放态不追加记录");
418    }
419
420    #[test]
421    fn replay_uses_recorded_ts_so_expiry_reproduces() {
422        // 实时判定依赖时钟;回放若用真实时钟,过期委托会判成 Expired 与记录不符。
423        // 这里验证回放按记录 ts 驱动,过期/未过期的判定都能精确复现。
424        let path = tmp_wal("expiry");
425        let clock = MockClock::new(1500);
426        let mut state = WanningState::with_wal(Arc::new(clock.clone()), &path).expect("开 WAL");
427        state.register_delegation(delegation()).expect("注册");
428        state.decide(&intent(1, 100)).expect("放行");
429        clock.set_now(2000); // 推到过期
430        state.decide(&intent(2, 100)).expect("过期拒");
431
432        let replayed = WanningState::replay(&path).expect("回放");
433        assert_eq!(replayed.state_hash(), state.state_hash());
434    }
435
436    #[test]
437    fn replay_fails_closed_on_corrupted_line() {
438        let path = tmp_wal("replay-corrupt");
439        let mut state = WanningState::with_wal(Arc::new(MockClock::new(1500)), &path).expect("开");
440        state.register_delegation(delegation()).expect("注册");
441        drop(state);
442        // 追加半行
443        use std::io::Write;
444        let mut f = std::fs::OpenOptions::new()
445            .append(true)
446            .open(&path)
447            .expect("开");
448        f.write_all(b"{\"kind\":\"decide\",\"ts\":1,\"dele\n")
449            .expect("追加坏行");
450        drop(f);
451
452        match WanningState::replay(&path) {
453            Err(CoreError::WalBadLine { line, .. }) => assert_eq!(line, 2),
454            other => panic!("应 fail-closed 报错,实际 {other:?}"),
455        }
456    }
457
458    #[test]
459    fn replay_fails_closed_when_record_disagrees_with_recomputation() {
460        // 手工构造一条与闸语义矛盾的记录:同一 nonce 两次「放行」。
461        let path = tmp_wal("replay-tampered");
462        let mut state = WanningState::with_wal(Arc::new(MockClock::new(1500)), &path).expect("开");
463        state.register_delegation(delegation()).expect("注册");
464        state.decide(&intent(1, 100)).expect("放行");
465        drop(state);
466        // 篡改:把同 nonce 的第二次放行直接写进 WAL(实时闸根本不可能放行它)。
467        // 包裹形态与真实写入完全一致(seq 接续、prev = 前两行的链尾)——这正是链的
468        // 已知边界:尾行内容没有后继行引用,链验不住,靠回放重算(语义对账)抓住。
469        use std::io::Write;
470        let verified = crate::wal::read_verified(&path).expect("读已有历史");
471        let forged = crate::wal::WalLine {
472            seq: verified.records.len() as u64 + 1,
473            prev: verified.tail,
474            rec: WalRecord::Decide {
475                ts: 1500,
476                decision: WalDecision::Allow,
477                delegation_id: "d1".to_string(),
478                intent: intent(1, 100),
479                reason: None,
480                budget_after_cents: 200,
481            },
482        };
483        let mut f = std::fs::OpenOptions::new()
484            .append(true)
485            .open(&path)
486            .expect("开");
487        f.write_all(serde_json::to_string(&forged).unwrap().as_bytes())
488            .and_then(|()| f.write_all(b"\n"))
489            .expect("追加");
490        drop(f);
491
492        match WanningState::replay(&path) {
493            Err(CoreError::WalMismatch { line, message }) => {
494                assert_eq!(line, 3, "不一致要指到行");
495                assert!(message.contains("不一致"), "{message}");
496            }
497            other => panic!("篡改记录必须 fail-closed,实际 {other:?}"),
498        }
499    }
500
501    #[test]
502    fn audit_chain_tail_matches_independent_read_side_recompute() {
503        // 完整性链对账证据:实时链尾 == 读侧逐行独立重算的链尾(两条路径各算各的)。
504        let path = tmp_wal("chain-tail");
505        let mut state = WanningState::with_wal(Arc::new(MockClock::new(1500)), &path).expect("开");
506        state.register_delegation(delegation()).expect("注册");
507        state.decide(&intent(1, 500)).expect("放行");
508        state.decide(&intent(2, 9000)).expect("超额拒");
509        state.revoke("d1").expect("撤销");
510
511        let live_tail = state.audit_chain_tail().expect("必有 WAL");
512        let verified = crate::wal::read_verified(&path).expect("读回验链");
513        assert_eq!(verified.tail, live_tail, "读侧独立重算链尾 == 实时链尾");
514        assert_eq!(
515            WanningState::replay(&path).expect("回放").state_hash(),
516            state.state_hash(),
517            "链验过后,回放对账照常成立"
518        );
519    }
520
521    #[test]
522    fn live_resuming_fails_closed_on_broken_chain() {
523        // 历史行被改(改的是不参与判定的 memo,语义对账抓不住)→ 链断 → 续跑拒启。
524        // 至少三行:被改行必须有后继行引用它的链值,尾行是链的已知边界。
525        let path = tmp_wal("resume-chain");
526        {
527            let mut state =
528                WanningState::with_wal(Arc::new(MockClock::new(1500)), &path).expect("开");
529            state.register_delegation(delegation()).expect("注册");
530            state.decide(&intent(1, 100)).expect("放行");
531            state.decide(&intent(2, 9000)).expect("超额拒");
532        }
533        let mut lines = crate::wal::raw_lines(&path).expect("读 WAL");
534        let mut value: serde_json::Value = serde_json::from_str(&lines[1]).expect("行是 JSON");
535        value["rec"]["intent"]["memo"] = serde_json::json!("被改写的备注");
536        lines[1] = value.to_string();
537        std::fs::write(&path, lines.join("\n") + "\n").expect("重写 WAL");
538
539        match WanningState::live_resuming(&path) {
540            Err(CoreError::WalChainBroken { line, .. }) => {
541                assert_eq!(line, 3, "断链点 = 被改行的下一行(prev 对不上)")
542            }
543            other => panic!("链断裂必须拒启,实际 {other:?}"),
544        }
545    }
546
547    #[test]
548    fn state_hash_changes_when_state_changes() {
549        let path = tmp_wal("hash");
550        let clock = MockClock::new(1500);
551        let mut state = WanningState::with_wal(Arc::new(clock.clone()), &path).expect("开");
552        state.register_delegation(delegation()).expect("注册");
553        let h0 = state.state_hash();
554        state.decide(&intent(1, 100)).expect("放行");
555        let h1 = state.state_hash();
556        state.revoke("d1").expect("撤销");
557        let h2 = state.state_hash();
558        assert_ne!(h0, h1, "扣减后 hash 必变");
559        assert_ne!(h1, h2, "撤销后 hash 必变");
560    }
561
562    #[test]
563    fn empty_wal_replays_to_empty_state() {
564        let path = tmp_wal("empty");
565        std::fs::write(&path, "").expect("写空文件");
566        let replayed = WanningState::replay(&path).expect("空 WAL 是合法状态");
567        assert_eq!(
568            replayed.state_hash(),
569            WanningState::new(Arc::new(MockClock::new(0))).state_hash()
570        );
571    }
572
573    // -----------------------------------------------------------------------
574    // 断点续跑(live_resuming):长期服务重启必须从审计接续,绝不带空账本接着判
575    // -----------------------------------------------------------------------
576
577    #[test]
578    fn live_resuming_carries_ledger_revocations_and_nonces() {
579        let path = tmp_wal("resume");
580        {
581            let clock = MockClock::new(1500);
582            let mut state = WanningState::with_wal(Arc::new(clock.clone()), &path).expect("开");
583            state
584                .register_delegation(long_lived_delegation())
585                .expect("注册");
586            state.decide(&intent(1, 500)).expect("放行");
587            state.decide(&intent(2, 100)).expect("再放行");
588            state.revoke("d1").expect("撤销");
589        } // drop:进程「重启」
590
591        let resumed = WanningState::live_resuming(&path).expect("续跑");
592        // 账本/撤销/nonce 全部接续。
593        assert_eq!(resumed.gate().spent_cents("d1"), Some(600));
594        assert!(resumed.gate().is_revoked("d1"), "撤销必须跨重启存活");
595        assert_eq!(
596            resumed.state_hash(),
597            WanningState::replay(&path).expect("回放").state_hash(),
598            "续跑态与回放态必须一致"
599        );
600        // 时钟已回到「现在」:系统时钟,而非回放的冻结时刻 1500。
601        assert!(
602            resumed.gate().clock().now() > 1_700_000_000,
603            "续跑必须用系统时钟,得到 {}",
604            resumed.gate().clock().now()
605        );
606
607        // 续跑后的闸照常判定,且继续写同一份 WAL:撤销态下新意图被拒、旧 nonce 重放
608        // 也被拒(闸口径 revoked 先于 replay,两条都落到拒),账本不动。
609        let mut resumed = resumed;
610        let deny = resumed.decide(&intent(3, 100)).expect("判定");
611        assert_eq!(deny.deny_reason(), Some(DenyReason::Revoked));
612        let replay_deny = resumed.decide(&intent(1, 100)).expect("判定");
613        assert_eq!(replay_deny.deny_reason(), Some(DenyReason::Revoked));
614        assert_eq!(resumed.gate().spent_cents("d1"), Some(600), "账本不动");
615        let records = crate::wal::read_records(&path).expect("读回");
616        assert_eq!(records.len(), 6, "注册+2 放行+撤销+续跑后 2 条拒绝");
617    }
618
619    #[test]
620    fn live_resuming_on_fresh_wal_starts_empty() {
621        let path = tmp_wal("resume-fresh");
622        let mut state = WanningState::live_resuming(&path).expect("新 WAL 直接续跑=空账开张");
623        assert_eq!(
624            state.state_hash(),
625            WanningState::replay(&path).expect("回放").state_hash()
626        );
627        state
628            .register_delegation(long_lived_delegation())
629            .expect("注册");
630        assert!(state.decide(&intent(1, 100)).expect("判定").is_allow());
631    }
632
633    #[test]
634    fn live_resuming_fails_closed_on_corrupted_wal() {
635        let path = tmp_wal("resume-corrupt");
636        {
637            let mut state =
638                WanningState::with_wal(Arc::new(MockClock::new(1500)), &path).expect("开");
639            state.register_delegation(delegation()).expect("注册");
640        }
641        use std::io::Write;
642        let mut f = std::fs::OpenOptions::new()
643            .append(true)
644            .open(&path)
645            .expect("开");
646        f.write_all(b"{\"kind\":\"decide\",\"ts\":1,\"dele\n")
647            .expect("追加坏行");
648        drop(f);
649
650        match WanningState::live_resuming(&path) {
651            Err(CoreError::WalBadLine { line, .. }) => assert_eq!(line, 2),
652            other => panic!("审计损坏必须拒启,实际 {other:?}"),
653        }
654    }
655}