wanning-core 0.1.4

Wanning 闸核心:预算 / 撤销 / 重放 / 审计(意图层,不碰资金流)
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
//! 闸的完整运行状态([`WanningState`]):闸 + 审计日志 + 待支付台账 + 时钟。
//!
//! 这是 demo / MCP server 实际持有的对象。职责只有一条:
//! **每一条决策都必须先落审计,再落账本**(write-ahead)——审计写不进去,这笔消费
//! 就不能发生。这样「崩溃后的世界」只会比实时状态**更严格**(多扣不会出现,少扣可能),
//! 永远不会出现「花了钱却查无此账」。待支付(W-53a)走同一纪律:确认/作废的每一行
//! 都先落审计再改台账,被拒的确认一行都不落。
//!
//! 回放([`WanningState::replay`]):从 WAL 逐行重建状态,用记录里的 ts 驱动注入时钟,
//! 并**重算每一条决策**与记录对账;任何不一致立即 fail-closed 报错。回放是确定性的:
//! 同一份 WAL 回放两遍,state hash 相同。

use std::path::Path;
use std::sync::Arc;

use crate::clock::{MockClock, SharedClock, SystemClock};
use crate::delegation::Delegation;
use crate::error::CoreError;
use crate::gate::{Gate, GateDecision};
use crate::intent::SpendIntent;
use crate::pending::{
    PendingError, PendingLedger, PendingOrder, PendingOutcome, PendingReceipt, PendingState,
};
use crate::wal::{fnv1a_64, Wal, WalDecision, WalRecord};

/// 闸 + 审计日志 + 待支付台账 + 时钟的运行时状态。
#[derive(Debug)]
pub struct WanningState {
    gate: Gate,
    wal: Option<Wal>,
    /// 人在环待支付台账(W-53a;实时态与回放态共用同一套应用逻辑)。
    pendings: PendingLedger,
}

impl WanningState {
    /// 纯内存状态(无审计落盘)。回放与测试用。
    pub fn new(clock: SharedClock) -> Self {
        Self {
            gate: Gate::new(clock),
            wal: None,
            pendings: PendingLedger::new(),
        }
    }

    /// 带审计落盘的状态。WAL 打开为追加模式,绝不截断。
    pub fn with_wal(clock: SharedClock, wal_path: impl AsRef<Path>) -> Result<Self, CoreError> {
        Ok(Self {
            gate: Gate::new(clock),
            wal: Some(Wal::open(wal_path)?),
            pendings: PendingLedger::new(),
        })
    }

    /// 生产状态:系统时钟 + 审计落盘。
    ///
    /// **注意:不回放已有 WAL**——闸从空开始,只往后追加。适合「一次进程一次新账」
    /// 的 demo 场景;长期服务重启要接续旧账,用 [`WanningState::live_resuming`]。
    pub fn live(wal_path: impl AsRef<Path>) -> Result<Self, CoreError> {
        Self::with_wal(Arc::new(SystemClock), wal_path)
    }

    /// 断点续跑:先整体回放已有 WAL 对账(损坏/篡改/不一致 → fail-closed 拒启),
    /// 再换回系统时钟、继续往**同一份 WAL** 追加。
    ///
    /// 长期服务(MCP server)重启时用它:账本、撤销、nonce 登记全部从审计接续,
    /// 绝不带着一张空账本接着判——否则重启会把 nonce 洗白、把撤销掉的授权复活。
    ///
    /// 同一份 WAL 同时至多一个**活着的写进程**(`Wal::open` 自动持单写者锁):
    /// 第二个进程 fail-closed 拒启(`CoreError::WalLocked`)。两个平台并挂同一份
    /// WAL(`.mcp.json` + `.trae/mcp.json`)就是真实场景——并发双闸的内存账本
    /// 互不知情,预算硬上限会被合力突破(实测见 `tests/single_writer.rs`)。
    ///
    /// 与 [`WanningState::replay`] 的区别:replay 冻结在「过去的世界」(注入时钟停在
    /// 最后一条记录的 ts、不挂 WAL);本方法校验过后回到「现在的世界」(系统时钟,
    /// 继续写审计)。
    pub fn live_resuming(wal_path: impl AsRef<Path>) -> Result<Self, CoreError> {
        let path = wal_path.as_ref();
        // 先开 WAL(不存在则创建;append-only,绝不截断)——空文件是合法起点。
        let wal = Wal::open(path)?;
        let resumed = Self::replay(path)?;
        let WanningState {
            gate,
            wal: _,
            pendings,
        } = resumed;
        Ok(Self {
            gate: gate.with_clock(Arc::new(SystemClock)),
            wal: Some(wal),
            // 待支付单跨重启存活:人确认的常常是「上一个进程」开的单(W-53a)。
            pendings,
        })
    }

    pub fn gate(&self) -> &Gate {
        &self.gate
    }

    /// 待支付台账(只读)。AI 侧查询自己 pending 状态只到这一层为止(W-53b:
    /// 确认永远不在 AI 工具面上,人在环才不是空转)。
    pub fn pendings(&self) -> &PendingLedger {
        &self.pendings
    }

    /// 按单号查一笔待支付单。
    pub fn pending(&self, pending_id: &str) -> Option<&PendingOrder> {
        self.pendings.get(pending_id)
    }

    pub fn wal_path(&self) -> Option<&Path> {
        self.wal.as_ref().map(Wal::path)
    }

    /// WAL 当前行数;无 WAL 时为 None。审计证据的「WAL 偏移」即行号。
    pub fn wal_line_count(&self) -> Option<u64> {
        self.wal.as_ref().map(Wal::line_count)
    }

    /// 最近一次追加的 WAL 行号(1-based);无 WAL 时为 None。
    pub fn last_wal_line(&self) -> Option<u64> {
        self.wal_line_count()
    }

    /// 审计完整性链的链尾值(最后一条记录的链值;无 WAL 时为 None)。
    ///
    /// 对账证据之一:实时侧这个值,与读侧 [`read_verified`](crate::wal::read_verified)
    /// 独立重算的链尾必须相等——逐行成链,改历史行而不重算后续整条链,当场现形。
    pub fn audit_chain_tail(&self) -> Option<u64> {
        self.wal.as_ref().map(Wal::chain_tail)
    }

    /// 注册委托:先确认必成,再写审计,再入闸(write-ahead)。
    pub fn register_delegation(&mut self, delegation: Delegation) -> Result<(), CoreError> {
        // 预检与 Gate::register_delegation 同一套规则;先确认「必然成功」,
        // 保证审计记录永远不会描述一次没发生的注册。
        delegation.validate()?;
        if self.gate.delegation(&delegation.id).is_some() {
            return Err(CoreError::DuplicateDelegation(delegation.id));
        }
        let record = WalRecord::RegisterDelegation {
            ts: self.now(),
            delegation: delegation.clone(),
        };
        if let Some(wal) = self.wal.as_mut() {
            wal.append(&record)?;
        }
        self.gate.register_delegation(delegation)
    }

    /// 撤销委托(kill switch):先确认必成,再写审计,再撤销。
    pub fn revoke(&mut self, delegation_id: &str) -> Result<(), CoreError> {
        if self.gate.delegation(delegation_id).is_none() {
            return Err(CoreError::UnknownDelegation(delegation_id.to_string()));
        }
        let record = WalRecord::Revoke {
            ts: self.now(),
            delegation_id: delegation_id.to_string(),
        };
        if let Some(wal) = self.wal.as_mut() {
            wal.append(&record)?;
        }
        self.gate.revoke(delegation_id)
    }

    /// 判定一笔消费意图:evaluate → 写审计 → commit(write-ahead)。
    ///
    /// 返回闸的判定。注意失败语义:
    /// - 审计写失败 → `Err`,**状态零变更**(这笔消费没有发生,也不能发生);
    /// - 审计写成功但 commit 失败(理论不可达)→ `Err`,WAL 领先于账本,
    ///   回放侧只会更严格,不会放水。
    pub fn decide(&mut self, intent: &SpendIntent) -> Result<GateDecision, CoreError> {
        // 时钟只读一次:评估、WAL 记录 ts、落地扣减(含速率窗口时刻)用同一 `now`。
        // 若各读各的,跨秒边界时实时侧速率窗口时刻会漂离 WAL 记录 ts,回放对账
        // 会把诚实账本误判为不一致——单次读是回放可重建的前提。
        let ts = self.now();
        self.evaluate_record_commit(intent, ts)
    }

    /// 人在环待支付(pending_pay 档位,W-53a)的判定入口:
    /// ①意图 + ②审批(与 [`WanningState::decide`] 同一段)→ ③开待支付单。
    ///
    /// - `ttl_secs == 0` 或过期时刻溢出 → 在**任何落账之前**拒绝(API 误用零审计
    ///   噪音,W-25 先例;否则会出现「判定已记账却开不出单」的中间世界);
    /// - 拒绝 → 正常落 Decide 行,不开单(第二返回值 `None`);
    /// - 放行 → 落 Decide 行、扣预算、落 Pending 行、台账开单,返回
    ///   [`PendingReceipt`](crate::pending::PendingReceipt)(单号 + 审批额 +
    ///   过期时刻 + 待支付行号)。
    ///
    /// 预算在**开单时**扣(与闸「放行即记账」同一语义):等人确认期间这笔额度
    /// 已被占用,并发多单不可能合力突破硬上限;确认不二次扣,过期作废不退
    /// (作废是账本事实,退了才给「反复开单洗预算」留门)。
    pub fn decide_opening_pending(
        &mut self,
        intent: &SpendIntent,
        ttl_secs: u64,
    ) -> Result<(GateDecision, Option<PendingReceipt>), CoreError> {
        if ttl_secs == 0 {
            return Err(CoreError::Pending(PendingError::InvalidTtl { ttl_secs }));
        }
        let ts = self.now();
        // 过期时刻先算:溢出与 TTL 一样,必须在任何落账之前 fail-closed。
        let expires_ts = ts.checked_add(ttl_secs).ok_or_else(|| {
            CoreError::LedgerOverflow(format!(
                "待支付过期时刻溢出: 开单时刻 {ts} + TTL {ttl_secs}"
            ))
        })?;
        let verdict = self.evaluate_record_commit(intent, ts)?;
        let GateDecision::Allow { budget_after_cents } = verdict else {
            return Ok((verdict, None));
        };
        let pending_id = self.fresh_pending_id(&intent.delegation_id, intent.nonce, ts);
        let pending_record = WalRecord::Pending {
            ts,
            pending_id: pending_id.clone(),
            delegation_id: intent.delegation_id.clone(),
            intent: intent.clone(),
            approved_amount_cents: intent.amount_cents,
            expires_ts,
        };
        let wal_line = match self.wal.as_mut() {
            Some(wal) => Some(wal.append(&pending_record)?),
            None => None,
        };
        self.pendings.apply_open(PendingOrder {
            pending_id: pending_id.clone(),
            delegation_id: intent.delegation_id.clone(),
            intent: intent.clone(),
            approved_amount_cents: intent.amount_cents,
            created_ts: ts,
            expires_ts,
            state: PendingState::Open,
            proof: None,
            confirmed_ts: None,
        })?;
        Ok((
            GateDecision::Allow { budget_after_cents },
            Some(PendingReceipt {
                pending_id,
                approved_amount_cents: intent.amount_cents,
                expires_ts,
                wal_line,
            }),
        ))
    }

    /// ④人确认(`wanning confirm` 人工面;**不在 AI 工具面上**,W-53b)。
    ///
    /// 三钉在 [`PendingLedger::check_confirm`]:金额一致 → 幂等 → TTL。被拒的
    /// 确认**一行都不落**;唯一的例外是过期确认——作废本身是账本事实,先落一行
    /// `Terminal{ExpiredVoid}` 把单作废,再拒(第二次确认就是普通的幂等拒)。
    /// 成功 = 落 Confirm 行 + 落 Terminal{Completed} 行,返回完成态的单。
    pub fn confirm_pending(
        &mut self,
        pending_id: &str,
        amount_cents: u64,
        proof: &str,
    ) -> Result<PendingOrder, CoreError> {
        if proof.trim().is_empty() {
            return Err(CoreError::Pending(PendingError::EmptyProof));
        }
        let ts = self.now();
        // 先纯检查(零变更):三钉不过就一行都不写。
        let checked = self.pendings.check_confirm(pending_id, amount_cents, ts);
        if let Err(err @ PendingError::Expired { .. }) = checked {
            // TTL 钉的作废半边:作废是账本事实,落一行终态再拒。
            let record = WalRecord::Terminal {
                ts,
                pending_id: pending_id.to_string(),
                outcome: PendingOutcome::ExpiredVoid,
            };
            if let Some(wal) = self.wal.as_mut() {
                wal.append(&record)?;
            }
            self.pendings.apply_void(pending_id, ts)?;
            return Err(CoreError::Pending(err));
        }
        checked.map_err(CoreError::Pending)?;
        let confirm_record = WalRecord::Confirm {
            ts,
            pending_id: pending_id.to_string(),
            amount_cents,
            proof: proof.to_string(),
        };
        if let Some(wal) = self.wal.as_mut() {
            wal.append(&confirm_record)?;
        }
        self.pendings
            .apply_confirm(pending_id, amount_cents, proof, ts)?;
        let terminal_record = WalRecord::Terminal {
            ts,
            pending_id: pending_id.to_string(),
            outcome: PendingOutcome::Completed,
        };
        if let Some(wal) = self.wal.as_mut() {
            wal.append(&terminal_record)?;
        }
        self.pendings.apply_complete(pending_id)?;
        Ok(self
            .pendings
            .get(pending_id)
            .cloned()
            .expect("确认过的单必在台账"))
    }

    /// 批量物化 TTL 过期:扫出台账里所有**已过期且仍 `Open`** 的单,逐张落
    /// `Terminal{ExpiredVoid}` 行并作废,返回作废的单号(按单号有序)。
    ///
    /// 幂等:已作废/已确认/已完成的单不在扫描范围,再扫一遍返回空。审计展示或
    /// 定时任务用它把「过期作废」从隐式(确认时才撞上)变成显式账本事实。
    pub fn void_expired_pendings(&mut self) -> Result<Vec<String>, CoreError> {
        let ts = self.now();
        let expired: Vec<String> = self
            .pendings
            .iter()
            .filter(|(_, order)| order.state == PendingState::Open && ts >= order.expires_ts)
            .map(|(id, _)| id.clone())
            .collect();
        for pending_id in &expired {
            let record = WalRecord::Terminal {
                ts,
                pending_id: pending_id.clone(),
                outcome: PendingOutcome::ExpiredVoid,
            };
            if let Some(wal) = self.wal.as_mut() {
                wal.append(&record)?;
            }
            self.pendings.apply_void(pending_id, ts)?;
        }
        Ok(expired)
    }

    /// 新单号:`p-` + FNV-1a64(委托 id ‖ nonce ‖ 开单时刻 ‖ 盐)。
    /// 确定性派生(同输入同单号,回放可复算形状),盐自增兜底同刻连开的碰撞。
    fn fresh_pending_id(&self, delegation_id: &str, nonce: u64, ts: u64) -> String {
        let mut salt = 0u8;
        loop {
            let mut bytes = Vec::with_capacity(delegation_id.len() + 17);
            bytes.extend_from_slice(delegation_id.as_bytes());
            bytes.extend_from_slice(&nonce.to_le_bytes());
            bytes.extend_from_slice(&ts.to_le_bytes());
            bytes.push(salt);
            let candidate = format!("p-{:016x}", fnv1a_64(&bytes));
            if !self.pendings.contains_key(&candidate) {
                return candidate;
            }
            salt = salt.wrapping_add(1);
        }
    }

    /// ①意图 + ②审批共用的判定段:evaluate → 写 Decide 行 → 放行则 commit。
    /// [`WanningState::decide`] 与 [`WanningState::decide_opening_pending`] 都走
    /// 这一段,判定面绝不两套。
    fn evaluate_record_commit(
        &mut self,
        intent: &SpendIntent,
        ts: u64,
    ) -> Result<GateDecision, CoreError> {
        let verdict = self.gate.evaluate_at(intent, ts);
        let spent_after = match verdict {
            // Allow 携带的就是「扣减后的累计消费」,直接取用,不重算。
            GateDecision::Allow { budget_after_cents } => budget_after_cents,
            GateDecision::Deny { .. } => self.gate.spent_cents(&intent.delegation_id).unwrap_or(0),
        };
        let record = WalRecord::Decide {
            ts,
            decision: match verdict {
                GateDecision::Allow { .. } => WalDecision::Allow,
                GateDecision::Deny { .. } => WalDecision::Deny,
            },
            delegation_id: intent.delegation_id.clone(),
            intent: intent.clone(),
            reason: verdict.deny_reason(),
            budget_after_cents: spent_after,
        };
        if let Some(wal) = self.wal.as_mut() {
            wal.append(&record)?;
        }
        match verdict {
            GateDecision::Allow { budget_after_cents } => {
                let after = self.gate.commit_at(intent, ts)?;
                debug_assert_eq!(after, budget_after_cents);
                Ok(GateDecision::Allow {
                    budget_after_cents: after,
                })
            }
            deny => Ok(deny),
        }
    }

    /// 闸状态指纹(FNV-1a 64,非密码学,仅用于确定性对账)。
    ///
    /// 覆盖:委托集、账本、撤销集、nonce 登记集、策略运行时状态(W-27 速率
    /// 窗口时刻与类目台账——随 commit 演化的状态必须进指纹,否则「速率窗口跨
    /// 重启被洗掉」这类回放缺失对账不出来)、待支付台账(W-53a:单的状态演化
    /// 必须进指纹,否则「重启洗掉确认」对账不出来);全部按有序迭代序列化,
    /// 因此「同一份 WAL 回放两遍 hash 必相同」由构造保证。
    ///
    /// `pendings` 键只在台账非空时出现——老账本(无人待支付)的指纹与 W-53
    /// 之前逐字节相同,不制造一次全体哈希漂移。
    pub fn state_hash(&self) -> u64 {
        let mut snapshot = serde_json::json!({
            "delegations": self.gate.delegations().collect::<Vec<_>>(),
            "spent_cents": self.gate.ledger().entries().collect::<Vec<_>>(),
            "revoked": self.gate.revocations().iter().collect::<Vec<_>>(),
            "used_nonces": self.gate.replay_registry().iter().collect::<Vec<_>>(),
            "policy_states": self.gate.policy_states().collect::<Vec<_>>(),
        });
        if !self.pendings.is_empty() {
            snapshot["pendings"] = serde_json::to_value(self.pendings.iter().collect::<Vec<_>>())
                .expect("待支付台账可序列化");
        }
        fnv1a_64(snapshot.to_string().as_bytes())
    }

    fn now(&self) -> u64 {
        self.gate.clock().now()
    }

    /// 从 WAL 回放重建状态(确定性;损坏行 / 完整性链断裂 / 对账不一致 → fail-closed)。
    ///
    /// 返回的状态:
    /// - 时钟是被注入的 [`MockClock`],冻结在最后一条记录的 ts(回放是「过去的世界」,
    ///   不适合继续判定新意图——要续,就重新 `live()` 开一个新 WAL);
    /// - 未挂 WAL(回放不追加记录)。
    pub fn replay(wal_path: impl AsRef<Path>) -> Result<Self, CoreError> {
        // 读回即验完整性链(seq/prev 逐行核),再逐行重算对账。
        let records = crate::wal::read_verified(wal_path)?.records;
        let clock = MockClock::new(0);
        let mut state = WanningState::new(Arc::new(clock.clone()));
        // 最近一次放行的意图(①②审批的回放锚):③待支付行必须挂在这上面,
        // 没有放行就没有待支付(W-53a 语义对账)。拒绝不改锚——锚只认放行。
        let mut last_allow: Option<SpendIntent> = None;
        for (line_no, record) in records {
            let record_ts = record.ts();
            clock.set_now(record_ts);
            match record {
                WalRecord::RegisterDelegation { delegation, .. } => state
                    .gate
                    .register_delegation(delegation)
                    .map_err(|e| CoreError::WalMismatch {
                        line: line_no,
                        message: format!("重放注册失败: {e}"),
                    })?,
                WalRecord::Revoke { delegation_id, .. } => state
                    .gate
                    .revoke(&delegation_id)
                    .map_err(|e| CoreError::WalMismatch {
                        line: line_no,
                        message: format!("重放撤销失败: {e}"),
                    })?,
                WalRecord::Decide {
                    decision,
                    intent,
                    reason,
                    budget_after_cents,
                    ..
                } => {
                    // 重算用记录自身的 ts(与 clock.set_now 同一时刻):速率窗口等
                    // 依赖「判定时刻」的检查必须在记录 ts 上复现,绝不能看回放进程
                    // 的真实时钟。
                    let ts = record_ts;
                    let verdict = state.gate.evaluate_at(&intent, ts);
                    match (verdict, decision, reason) {
                        (
                            GateDecision::Allow {
                                budget_after_cents: recomputed,
                            },
                            WalDecision::Allow,
                            None,
                        ) => {
                            if recomputed != budget_after_cents {
                                return Err(CoreError::WalMismatch {
                                    line: line_no,
                                    message: format!(
                                        "放行记录的累计消费与重算不一致:记录 {budget_after_cents} / 重算 {recomputed}"
                                    ),
                                });
                            }
                            state.gate.commit_at(&intent, ts).map_err(|e| {
                                CoreError::WalMismatch {
                                    line: line_no,
                                    message: format!("重放扣减失败: {e}"),
                                }
                            })?;
                            last_allow = Some(intent);
                        }
                        (
                            GateDecision::Deny { reason: recomputed },
                            WalDecision::Deny,
                            Some(recorded_reason),
                        ) if recomputed == recorded_reason => {
                            // 拒绝:状态零变更,只需口径一致。
                        }
                        (verdict, decision, reason) => {
                            return Err(CoreError::WalMismatch {
                                line: line_no,
                                message: format!(
                                    "重算判定与记录不一致:重算 {verdict:?} / 记录 {decision:?} reason={reason:?}"
                                ),
                            });
                        }
                    }
                }
                WalRecord::Pending {
                    pending_id,
                    delegation_id,
                    intent: row_intent,
                    approved_amount_cents,
                    expires_ts,
                    ..
                } => {
                    // ③待支付行的四道语义闸:锚在最近一次放行的**同一意图**上
                    // (防无放行开单 / 换意图夹带)、行自洽(审批额 = 意图额、
                    // 委托一致、过期时刻真的在开单时刻之后)、单号与意图首次出现。
                    let anchored_on_allow = last_allow.as_ref() == Some(&row_intent);
                    let self_consistent = row_intent.amount_cents == approved_amount_cents
                        && row_intent.delegation_id == delegation_id
                        && expires_ts > record_ts;
                    let first_open = !state
                        .pendings
                        .contains_intent(&delegation_id, row_intent.nonce);
                    if !anchored_on_allow || !self_consistent || !first_open {
                        return Err(CoreError::WalMismatch {
                            line: line_no,
                            message: format!(
                                "待支付行与放行记录不一致:锚定放行 {anchored_on_allow} / \
                                 行自洽 {self_consistent} / 单号与意图首次出现 {first_open}"
                            ),
                        });
                    }
                    state
                        .pendings
                        .apply_open(PendingOrder {
                            pending_id,
                            delegation_id,
                            intent: row_intent,
                            approved_amount_cents,
                            created_ts: record_ts,
                            expires_ts,
                            state: PendingState::Open,
                            proof: None,
                            confirmed_ts: None,
                        })
                        .map_err(|e| CoreError::WalMismatch {
                            line: line_no,
                            message: format!("重放开单失败: {e}"),
                        })?;
                }
                WalRecord::Confirm {
                    pending_id,
                    amount_cents,
                    proof,
                    ..
                } => {
                    // ④确认行走实时侧同一套三钉;空凭证的确认行实时侧写不出来,
                    // 回放侧同样拒(实时侧先拒、根本不落行,这里只是对账兜底)。
                    if proof.trim().is_empty() {
                        return Err(CoreError::WalMismatch {
                            line: line_no,
                            message: "确认行的支付凭证为空(实时侧写不出这种行)".to_string(),
                        });
                    }
                    state
                        .pendings
                        .apply_confirm(&pending_id, amount_cents, &proof, record_ts)
                        .map_err(|e| CoreError::WalMismatch {
                            line: line_no,
                            message: format!("重放确认失败: {e}"),
                        })?;
                }
                WalRecord::Terminal {
                    pending_id,
                    outcome,
                    ..
                } => {
                    // ⑤终态行由同一套状态机核:完成必须已确认,作废必须真过期。
                    let applied = match outcome {
                        PendingOutcome::Completed => state.pendings.apply_complete(&pending_id),
                        PendingOutcome::ExpiredVoid => {
                            state.pendings.apply_void(&pending_id, record_ts)
                        }
                    };
                    applied.map_err(|e| CoreError::WalMismatch {
                        line: line_no,
                        message: format!("重放终态失败: {e}"),
                    })?;
                }
            }
        }
        Ok(state)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::clock::{Clock, MockClock};
    use crate::gate::DenyReason;

    fn tmp_wal(tag: &str) -> std::path::PathBuf {
        use std::sync::atomic::{AtomicU64, Ordering};
        static SEQ: AtomicU64 = AtomicU64::new(0);
        let dir = std::env::temp_dir().join("wanning-state-tests");
        std::fs::create_dir_all(&dir).expect("建临时目录");
        // pid + 原子序号 + 纳秒:裸 pid 跨轮运行会撞残留账本(W-21 教训,W-43b 轮补齐)。
        let nanos = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .map(|d| d.as_nanos())
            .unwrap_or(0);
        dir.join(format!(
            "{tag}-{}-{}-{nanos}.jsonl",
            std::process::id(),
            SEQ.fetch_add(1, Ordering::SeqCst)
        ))
    }

    fn delegation() -> Delegation {
        Delegation::new(
            "d1",
            "boss",
            "claude-code",
            1000,
            1000,
            2000,
            "agent:claude-code",
        )
    }

    /// 续跑测试专用:回放侧时钟停在记录 ts(如 1500),续跑后是真实「现在」——
    /// 委托窗口必须同时覆盖两个世界(1500 之前生效、系统时钟下未过期)。
    fn long_lived_delegation() -> Delegation {
        Delegation::new(
            "d1",
            "boss",
            "claude-code",
            1000,
            1000,
            SystemClock.now().checked_add(86_400).expect("有效期溢出"),
            "agent:claude-code",
        )
    }

    fn intent(nonce: u64, amount_cents: u64) -> SpendIntent {
        SpendIntent::new("d1", nonce, amount_cents, "jd:shop-1", "grocery", "测试")
    }

    #[test]
    fn allow_and_deny_are_both_recorded() {
        let path = tmp_wal("both");
        let clock = MockClock::new(1500);
        let mut state = WanningState::with_wal(Arc::new(clock.clone()), &path).expect("开 WAL");
        state.register_delegation(delegation()).expect("注册");

        // 放行
        let a = state.decide(&intent(1, 500)).expect("判定");
        assert!(a.is_allow());
        // 拒绝(超额)
        let d = state.decide(&intent(2, 9000)).expect("判定");
        assert_eq!(d.deny_reason(), Some(DenyReason::OverBudget));

        let records = crate::wal::read_records(&path).expect("读回");
        assert_eq!(records.len(), 3, "注册 + 放行 + 拒绝");
        let (_, first_decide) = &records[1];
        let (_, second_decide) = &records[2];
        match (first_decide.kind(), first_decide.ts(), second_decide.kind()) {
            ("decide", 1500, "decide") => {}
            other => panic!("记录形状不符: {other:?}"),
        }
        // deny 记录在案(带 reason、不带 budget 变化)
        let crate::wal::WalRecord::Decide {
            decision,
            reason,
            budget_after_cents,
            ..
        } = second_decide
        else {
            panic!("第二条决策记录应是 Decide");
        };
        assert_eq!(*decision, WalDecision::Deny);
        assert_eq!(*reason, Some(DenyReason::OverBudget));
        assert_eq!(*budget_after_cents, 500, "拒绝不改账本,累计消费仍是 500");
    }

    #[test]
    fn write_ahead_audit_failure_leaves_state_untouched() {
        // 审计写不进去 → 消费不能发生(状态零变更)。
        // 构造:占用目标路径为目录,使 WAL 打开即失败。
        let dir = std::env::temp_dir().join("wanning-state-tests");
        std::fs::create_dir_all(&dir).expect("建临时目录");
        // 路径名同理带纳秒:上一轮残留的同名目录会让本轮占位失败。
        let nanos = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .map(|d| d.as_nanos())
            .unwrap_or(0);
        let path = dir.join(format!("dir-as-wal-{nanos}.jsonl"));
        std::fs::create_dir_all(&path).expect("占位为目录");

        let err = WanningState::with_wal(Arc::new(MockClock::new(1500)), &path).unwrap_err();
        assert!(matches!(err, CoreError::WalIo(_)), "{err}");
    }

    #[test]
    fn replay_rebuilds_state_and_is_deterministic() {
        let path = tmp_wal("replay");
        let clock = MockClock::new(1500);
        let mut state = WanningState::with_wal(Arc::new(clock.clone()), &path).expect("开 WAL");
        state.register_delegation(delegation()).expect("注册");
        state.decide(&intent(1, 500)).expect("放行");
        state.decide(&intent(2, 9000)).expect("超额拒");
        state.decide(&intent(3, 100)).expect("再放行");
        state.revoke("d1").expect("撤销");
        state.decide(&intent(4, 100)).expect("撤销后拒");

        let live_hash = state.state_hash();
        assert_eq!(
            state.gate().spent_cents("d1"),
            Some(600),
            "实时累计消费 = 500 + 100"
        );

        // 回放两遍,hash 必须一致且等于实时状态。
        let replayed = WanningState::replay(&path).expect("回放");
        let hash_once = replayed.state_hash();
        let replayed_again = WanningState::replay(&path).expect("回放二遍");
        let hash_twice = replayed_again.state_hash();

        assert_eq!(hash_once, hash_twice, "回放两遍 hash 必相同(确定性)");
        assert_eq!(hash_once, live_hash, "回放态必须与实时态完全一致");
        assert_eq!(replayed.gate().spent_cents("d1"), Some(600));
        assert!(replayed.gate().is_revoked("d1"));
        assert!(
            replayed
                .gate()
                .replay_registry()
                .contains("agent:claude-code", 1),
            "重放登记也必须被重建"
        );
        assert_eq!(replayed.wal_line_count(), None, "回放态不追加记录");
    }

    #[test]
    fn replay_uses_recorded_ts_so_expiry_reproduces() {
        // 实时判定依赖时钟;回放若用真实时钟,过期委托会判成 Expired 与记录不符。
        // 这里验证回放按记录 ts 驱动,过期/未过期的判定都能精确复现。
        let path = tmp_wal("expiry");
        let clock = MockClock::new(1500);
        let mut state = WanningState::with_wal(Arc::new(clock.clone()), &path).expect("开 WAL");
        state.register_delegation(delegation()).expect("注册");
        state.decide(&intent(1, 100)).expect("放行");
        clock.set_now(2000); // 推到过期
        state.decide(&intent(2, 100)).expect("过期拒");

        let replayed = WanningState::replay(&path).expect("回放");
        assert_eq!(replayed.state_hash(), state.state_hash());
    }

    #[test]
    fn replay_fails_closed_on_corrupted_line() {
        let path = tmp_wal("replay-corrupt");
        let mut state = WanningState::with_wal(Arc::new(MockClock::new(1500)), &path).expect("");
        state.register_delegation(delegation()).expect("注册");
        drop(state);
        // 追加半行
        use std::io::Write;
        let mut f = std::fs::OpenOptions::new()
            .append(true)
            .open(&path)
            .expect("");
        f.write_all(b"{\"kind\":\"decide\",\"ts\":1,\"dele\n")
            .expect("追加坏行");
        drop(f);

        match WanningState::replay(&path) {
            Err(CoreError::WalBadLine { line, .. }) => assert_eq!(line, 2),
            other => panic!("应 fail-closed 报错,实际 {other:?}"),
        }
    }

    #[test]
    fn replay_fails_closed_when_record_disagrees_with_recomputation() {
        // 手工构造一条与闸语义矛盾的记录:同一 nonce 两次「放行」。
        let path = tmp_wal("replay-tampered");
        let mut state = WanningState::with_wal(Arc::new(MockClock::new(1500)), &path).expect("");
        state.register_delegation(delegation()).expect("注册");
        state.decide(&intent(1, 100)).expect("放行");
        drop(state);
        // 篡改:把同 nonce 的第二次放行直接写进 WAL(实时闸根本不可能放行它)。
        // 包裹形态与真实写入完全一致(seq 接续、prev = 前两行的链尾)——这正是链的
        // 已知边界:尾行内容没有后继行引用,链验不住,靠回放重算(语义对账)抓住。
        use std::io::Write;
        let verified = crate::wal::read_verified(&path).expect("读已有历史");
        let forged = crate::wal::WalLine {
            seq: verified.records.len() as u64 + 1,
            prev: verified.tail,
            rec: WalRecord::Decide {
                ts: 1500,
                decision: WalDecision::Allow,
                delegation_id: "d1".to_string(),
                intent: intent(1, 100),
                reason: None,
                budget_after_cents: 200,
            },
        };
        let mut f = std::fs::OpenOptions::new()
            .append(true)
            .open(&path)
            .expect("");
        f.write_all(serde_json::to_string(&forged).unwrap().as_bytes())
            .and_then(|()| f.write_all(b"\n"))
            .expect("追加");
        drop(f);

        match WanningState::replay(&path) {
            Err(CoreError::WalMismatch { line, message }) => {
                assert_eq!(line, 3, "不一致要指到行");
                assert!(message.contains("不一致"), "{message}");
            }
            other => panic!("篡改记录必须 fail-closed,实际 {other:?}"),
        }
    }

    #[test]
    fn audit_chain_tail_matches_independent_read_side_recompute() {
        // 完整性链对账证据:实时链尾 == 读侧逐行独立重算的链尾(两条路径各算各的)。
        let path = tmp_wal("chain-tail");
        let mut state = WanningState::with_wal(Arc::new(MockClock::new(1500)), &path).expect("");
        state.register_delegation(delegation()).expect("注册");
        state.decide(&intent(1, 500)).expect("放行");
        state.decide(&intent(2, 9000)).expect("超额拒");
        state.revoke("d1").expect("撤销");

        let live_tail = state.audit_chain_tail().expect("必有 WAL");
        let verified = crate::wal::read_verified(&path).expect("读回验链");
        assert_eq!(verified.tail, live_tail, "读侧独立重算链尾 == 实时链尾");
        assert_eq!(
            WanningState::replay(&path).expect("回放").state_hash(),
            state.state_hash(),
            "链验过后,回放对账照常成立"
        );
    }

    #[test]
    fn live_resuming_fails_closed_on_broken_chain() {
        // 历史行被改(改的是不参与判定的 memo,语义对账抓不住)→ 链断 → 续跑拒启。
        // 至少三行:被改行必须有后继行引用它的链值,尾行是链的已知边界。
        let path = tmp_wal("resume-chain");
        {
            let mut state =
                WanningState::with_wal(Arc::new(MockClock::new(1500)), &path).expect("");
            state.register_delegation(delegation()).expect("注册");
            state.decide(&intent(1, 100)).expect("放行");
            state.decide(&intent(2, 9000)).expect("超额拒");
        }
        let mut lines = crate::wal::raw_lines(&path).expect("读 WAL");
        let mut value: serde_json::Value = serde_json::from_str(&lines[1]).expect("行是 JSON");
        value["rec"]["intent"]["memo"] = serde_json::json!("被改写的备注");
        lines[1] = value.to_string();
        std::fs::write(&path, lines.join("\n") + "\n").expect("重写 WAL");

        match WanningState::live_resuming(&path) {
            Err(CoreError::WalChainBroken { line, .. }) => {
                assert_eq!(line, 3, "断链点 = 被改行的下一行(prev 对不上)")
            }
            other => panic!("链断裂必须拒启,实际 {other:?}"),
        }
    }

    #[test]
    fn state_hash_changes_when_state_changes() {
        let path = tmp_wal("hash");
        let clock = MockClock::new(1500);
        let mut state = WanningState::with_wal(Arc::new(clock.clone()), &path).expect("");
        state.register_delegation(delegation()).expect("注册");
        let h0 = state.state_hash();
        state.decide(&intent(1, 100)).expect("放行");
        let h1 = state.state_hash();
        state.revoke("d1").expect("撤销");
        let h2 = state.state_hash();
        assert_ne!(h0, h1, "扣减后 hash 必变");
        assert_ne!(h1, h2, "撤销后 hash 必变");
    }

    #[test]
    fn empty_wal_replays_to_empty_state() {
        let path = tmp_wal("empty");
        std::fs::write(&path, "").expect("写空文件");
        let replayed = WanningState::replay(&path).expect("空 WAL 是合法状态");
        assert_eq!(
            replayed.state_hash(),
            WanningState::new(Arc::new(MockClock::new(0))).state_hash()
        );
    }

    // -----------------------------------------------------------------------
    // 断点续跑(live_resuming):长期服务重启必须从审计接续,绝不带空账本接着判
    // -----------------------------------------------------------------------

    #[test]
    fn live_resuming_carries_ledger_revocations_and_nonces() {
        let path = tmp_wal("resume");
        {
            let clock = MockClock::new(1500);
            let mut state = WanningState::with_wal(Arc::new(clock.clone()), &path).expect("");
            state
                .register_delegation(long_lived_delegation())
                .expect("注册");
            state.decide(&intent(1, 500)).expect("放行");
            state.decide(&intent(2, 100)).expect("再放行");
            state.revoke("d1").expect("撤销");
        } // drop:进程「重启」

        let resumed = WanningState::live_resuming(&path).expect("续跑");
        // 账本/撤销/nonce 全部接续。
        assert_eq!(resumed.gate().spent_cents("d1"), Some(600));
        assert!(resumed.gate().is_revoked("d1"), "撤销必须跨重启存活");
        assert_eq!(
            resumed.state_hash(),
            WanningState::replay(&path).expect("回放").state_hash(),
            "续跑态与回放态必须一致"
        );
        // 时钟已回到「现在」:系统时钟,而非回放的冻结时刻 1500。
        assert!(
            resumed.gate().clock().now() > 1_700_000_000,
            "续跑必须用系统时钟,得到 {}",
            resumed.gate().clock().now()
        );

        // 续跑后的闸照常判定,且继续写同一份 WAL:撤销态下新意图被拒、旧 nonce 重放
        // 也被拒(闸口径 revoked 先于 replay,两条都落到拒),账本不动。
        let mut resumed = resumed;
        let deny = resumed.decide(&intent(3, 100)).expect("判定");
        assert_eq!(deny.deny_reason(), Some(DenyReason::Revoked));
        let replay_deny = resumed.decide(&intent(1, 100)).expect("判定");
        assert_eq!(replay_deny.deny_reason(), Some(DenyReason::Revoked));
        assert_eq!(resumed.gate().spent_cents("d1"), Some(600), "账本不动");
        let records = crate::wal::read_records(&path).expect("读回");
        assert_eq!(records.len(), 6, "注册+2 放行+撤销+续跑后 2 条拒绝");
    }

    #[test]
    fn live_resuming_on_fresh_wal_starts_empty() {
        let path = tmp_wal("resume-fresh");
        let mut state = WanningState::live_resuming(&path).expect("新 WAL 直接续跑=空账开张");
        assert_eq!(
            state.state_hash(),
            WanningState::replay(&path).expect("回放").state_hash()
        );
        state
            .register_delegation(long_lived_delegation())
            .expect("注册");
        assert!(state.decide(&intent(1, 100)).expect("判定").is_allow());
    }

    #[test]
    fn live_resuming_fails_closed_on_corrupted_wal() {
        let path = tmp_wal("resume-corrupt");
        {
            let mut state =
                WanningState::with_wal(Arc::new(MockClock::new(1500)), &path).expect("");
            state.register_delegation(delegation()).expect("注册");
        }
        use std::io::Write;
        let mut f = std::fs::OpenOptions::new()
            .append(true)
            .open(&path)
            .expect("");
        f.write_all(b"{\"kind\":\"decide\",\"ts\":1,\"dele\n")
            .expect("追加坏行");
        drop(f);

        match WanningState::live_resuming(&path) {
            Err(CoreError::WalBadLine { line, .. }) => assert_eq!(line, 2),
            other => panic!("审计损坏必须拒启,实际 {other:?}"),
        }
    }
}