Skip to main content

wanning_core/
state.rs

1//! 闸的完整运行状态([`WanningState`]):闸 + 审计日志 + 待支付台账 + 时钟。
2//!
3//! 这是 demo / MCP server 实际持有的对象。职责只有一条:
4//! **每一条决策都必须先落审计,再落账本**(write-ahead)——审计写不进去,这笔消费
5//! 就不能发生。这样「崩溃后的世界」只会比实时状态**更严格**(多扣不会出现,少扣可能),
6//! 永远不会出现「花了钱却查无此账」。待支付(W-53a)走同一纪律:确认/作废的每一行
7//! 都先落审计再改台账,被拒的确认一行都不落。
8//!
9//! 回放([`WanningState::replay`]):从 WAL 逐行重建状态,用记录里的 ts 驱动注入时钟,
10//! 并**重算每一条决策**与记录对账;任何不一致立即 fail-closed 报错。回放是确定性的:
11//! 同一份 WAL 回放两遍,state hash 相同。
12
13use std::path::Path;
14use std::sync::Arc;
15
16use crate::clock::{MockClock, SharedClock, SystemClock};
17use crate::delegation::Delegation;
18use crate::error::CoreError;
19use crate::gate::{Gate, GateDecision};
20use crate::intent::SpendIntent;
21use crate::pending::{
22    PendingError, PendingLedger, PendingOrder, PendingOutcome, PendingReceipt, PendingState,
23};
24use crate::wal::{fnv1a_64, Wal, WalDecision, WalRecord};
25
26/// 闸 + 审计日志 + 待支付台账 + 时钟的运行时状态。
27#[derive(Debug)]
28pub struct WanningState {
29    gate: Gate,
30    wal: Option<Wal>,
31    /// 人在环待支付台账(W-53a;实时态与回放态共用同一套应用逻辑)。
32    pendings: PendingLedger,
33}
34
35impl WanningState {
36    /// 纯内存状态(无审计落盘)。回放与测试用。
37    pub fn new(clock: SharedClock) -> Self {
38        Self {
39            gate: Gate::new(clock),
40            wal: None,
41            pendings: PendingLedger::new(),
42        }
43    }
44
45    /// 带审计落盘的状态。WAL 打开为追加模式,绝不截断。
46    pub fn with_wal(clock: SharedClock, wal_path: impl AsRef<Path>) -> Result<Self, CoreError> {
47        Ok(Self {
48            gate: Gate::new(clock),
49            wal: Some(Wal::open(wal_path)?),
50            pendings: PendingLedger::new(),
51        })
52    }
53
54    /// 生产状态:系统时钟 + 审计落盘。
55    ///
56    /// **注意:不回放已有 WAL**——闸从空开始,只往后追加。适合「一次进程一次新账」
57    /// 的 demo 场景;长期服务重启要接续旧账,用 [`WanningState::live_resuming`]。
58    pub fn live(wal_path: impl AsRef<Path>) -> Result<Self, CoreError> {
59        Self::with_wal(Arc::new(SystemClock), wal_path)
60    }
61
62    /// 断点续跑:先整体回放已有 WAL 对账(损坏/篡改/不一致 → fail-closed 拒启),
63    /// 再换回系统时钟、继续往**同一份 WAL** 追加。
64    ///
65    /// 长期服务(MCP server)重启时用它:账本、撤销、nonce 登记全部从审计接续,
66    /// 绝不带着一张空账本接着判——否则重启会把 nonce 洗白、把撤销掉的授权复活。
67    ///
68    /// 同一份 WAL 同时至多一个**活着的写进程**(`Wal::open` 自动持单写者锁):
69    /// 第二个进程 fail-closed 拒启(`CoreError::WalLocked`)。两个平台并挂同一份
70    /// WAL(`.mcp.json` + `.trae/mcp.json`)就是真实场景——并发双闸的内存账本
71    /// 互不知情,预算硬上限会被合力突破(实测见 `tests/single_writer.rs`)。
72    ///
73    /// 与 [`WanningState::replay`] 的区别:replay 冻结在「过去的世界」(注入时钟停在
74    /// 最后一条记录的 ts、不挂 WAL);本方法校验过后回到「现在的世界」(系统时钟,
75    /// 继续写审计)。
76    pub fn live_resuming(wal_path: impl AsRef<Path>) -> Result<Self, CoreError> {
77        let path = wal_path.as_ref();
78        // 先开 WAL(不存在则创建;append-only,绝不截断)——空文件是合法起点。
79        let wal = Wal::open(path)?;
80        let resumed = Self::replay(path)?;
81        let WanningState {
82            gate,
83            wal: _,
84            pendings,
85        } = resumed;
86        Ok(Self {
87            gate: gate.with_clock(Arc::new(SystemClock)),
88            wal: Some(wal),
89            // 待支付单跨重启存活:人确认的常常是「上一个进程」开的单(W-53a)。
90            pendings,
91        })
92    }
93
94    pub fn gate(&self) -> &Gate {
95        &self.gate
96    }
97
98    /// 待支付台账(只读)。AI 侧查询自己 pending 状态只到这一层为止(W-53b:
99    /// 确认永远不在 AI 工具面上,人在环才不是空转)。
100    pub fn pendings(&self) -> &PendingLedger {
101        &self.pendings
102    }
103
104    /// 按单号查一笔待支付单。
105    pub fn pending(&self, pending_id: &str) -> Option<&PendingOrder> {
106        self.pendings.get(pending_id)
107    }
108
109    pub fn wal_path(&self) -> Option<&Path> {
110        self.wal.as_ref().map(Wal::path)
111    }
112
113    /// WAL 当前行数;无 WAL 时为 None。审计证据的「WAL 偏移」即行号。
114    pub fn wal_line_count(&self) -> Option<u64> {
115        self.wal.as_ref().map(Wal::line_count)
116    }
117
118    /// 最近一次追加的 WAL 行号(1-based);无 WAL 时为 None。
119    pub fn last_wal_line(&self) -> Option<u64> {
120        self.wal_line_count()
121    }
122
123    /// 审计完整性链的链尾值(最后一条记录的链值;无 WAL 时为 None)。
124    ///
125    /// 对账证据之一:实时侧这个值,与读侧 [`read_verified`](crate::wal::read_verified)
126    /// 独立重算的链尾必须相等——逐行成链,改历史行而不重算后续整条链,当场现形。
127    pub fn audit_chain_tail(&self) -> Option<u64> {
128        self.wal.as_ref().map(Wal::chain_tail)
129    }
130
131    /// 注册委托:先确认必成,再写审计,再入闸(write-ahead)。
132    pub fn register_delegation(&mut self, delegation: Delegation) -> Result<(), CoreError> {
133        // 预检与 Gate::register_delegation 同一套规则;先确认「必然成功」,
134        // 保证审计记录永远不会描述一次没发生的注册。
135        delegation.validate()?;
136        if self.gate.delegation(&delegation.id).is_some() {
137            return Err(CoreError::DuplicateDelegation(delegation.id));
138        }
139        let record = WalRecord::RegisterDelegation {
140            ts: self.now(),
141            delegation: delegation.clone(),
142        };
143        if let Some(wal) = self.wal.as_mut() {
144            wal.append(&record)?;
145        }
146        self.gate.register_delegation(delegation)
147    }
148
149    /// 撤销委托(kill switch):先确认必成,再写审计,再撤销。
150    pub fn revoke(&mut self, delegation_id: &str) -> Result<(), CoreError> {
151        if self.gate.delegation(delegation_id).is_none() {
152            return Err(CoreError::UnknownDelegation(delegation_id.to_string()));
153        }
154        let record = WalRecord::Revoke {
155            ts: self.now(),
156            delegation_id: delegation_id.to_string(),
157        };
158        if let Some(wal) = self.wal.as_mut() {
159            wal.append(&record)?;
160        }
161        self.gate.revoke(delegation_id)
162    }
163
164    /// 判定一笔消费意图:evaluate → 写审计 → commit(write-ahead)。
165    ///
166    /// 返回闸的判定。注意失败语义:
167    /// - 审计写失败 → `Err`,**状态零变更**(这笔消费没有发生,也不能发生);
168    /// - 审计写成功但 commit 失败(理论不可达)→ `Err`,WAL 领先于账本,
169    ///   回放侧只会更严格,不会放水。
170    pub fn decide(&mut self, intent: &SpendIntent) -> Result<GateDecision, CoreError> {
171        // 时钟只读一次:评估、WAL 记录 ts、落地扣减(含速率窗口时刻)用同一 `now`。
172        // 若各读各的,跨秒边界时实时侧速率窗口时刻会漂离 WAL 记录 ts,回放对账
173        // 会把诚实账本误判为不一致——单次读是回放可重建的前提。
174        let ts = self.now();
175        self.evaluate_record_commit(intent, ts)
176    }
177
178    /// 人在环待支付(pending_pay 档位,W-53a)的判定入口:
179    /// ①意图 + ②审批(与 [`WanningState::decide`] 同一段)→ ③开待支付单。
180    ///
181    /// - `ttl_secs == 0` 或过期时刻溢出 → 在**任何落账之前**拒绝(API 误用零审计
182    ///   噪音,W-25 先例;否则会出现「判定已记账却开不出单」的中间世界);
183    /// - 拒绝 → 正常落 Decide 行,不开单(第二返回值 `None`);
184    /// - 放行 → 落 Decide 行、扣预算、落 Pending 行、台账开单,返回
185    ///   [`PendingReceipt`](crate::pending::PendingReceipt)(单号 + 审批额 +
186    ///   过期时刻 + 待支付行号)。
187    ///
188    /// 预算在**开单时**扣(与闸「放行即记账」同一语义):等人确认期间这笔额度
189    /// 已被占用,并发多单不可能合力突破硬上限;确认不二次扣,过期作废不退
190    /// (作废是账本事实,退了才给「反复开单洗预算」留门)。
191    pub fn decide_opening_pending(
192        &mut self,
193        intent: &SpendIntent,
194        ttl_secs: u64,
195    ) -> Result<(GateDecision, Option<PendingReceipt>), CoreError> {
196        if ttl_secs == 0 {
197            return Err(CoreError::Pending(PendingError::InvalidTtl { ttl_secs }));
198        }
199        let ts = self.now();
200        // 过期时刻先算:溢出与 TTL 一样,必须在任何落账之前 fail-closed。
201        let expires_ts = ts.checked_add(ttl_secs).ok_or_else(|| {
202            CoreError::LedgerOverflow(format!(
203                "待支付过期时刻溢出: 开单时刻 {ts} + TTL {ttl_secs} 秒"
204            ))
205        })?;
206        let verdict = self.evaluate_record_commit(intent, ts)?;
207        let GateDecision::Allow { budget_after_cents } = verdict else {
208            return Ok((verdict, None));
209        };
210        let pending_id = self.fresh_pending_id(&intent.delegation_id, intent.nonce, ts);
211        let pending_record = WalRecord::Pending {
212            ts,
213            pending_id: pending_id.clone(),
214            delegation_id: intent.delegation_id.clone(),
215            intent: intent.clone(),
216            approved_amount_cents: intent.amount_cents,
217            expires_ts,
218        };
219        let wal_line = match self.wal.as_mut() {
220            Some(wal) => Some(wal.append(&pending_record)?),
221            None => None,
222        };
223        self.pendings.apply_open(PendingOrder {
224            pending_id: pending_id.clone(),
225            delegation_id: intent.delegation_id.clone(),
226            intent: intent.clone(),
227            approved_amount_cents: intent.amount_cents,
228            created_ts: ts,
229            expires_ts,
230            state: PendingState::Open,
231            proof: None,
232            confirmed_ts: None,
233        })?;
234        Ok((
235            GateDecision::Allow { budget_after_cents },
236            Some(PendingReceipt {
237                pending_id,
238                approved_amount_cents: intent.amount_cents,
239                expires_ts,
240                wal_line,
241            }),
242        ))
243    }
244
245    /// ④人确认(`wanning confirm` 人工面;**不在 AI 工具面上**,W-53b)。
246    ///
247    /// 三钉在 [`PendingLedger::check_confirm`]:金额一致 → 幂等 → TTL。被拒的
248    /// 确认**一行都不落**;唯一的例外是过期确认——作废本身是账本事实,先落一行
249    /// `Terminal{ExpiredVoid}` 把单作废,再拒(第二次确认就是普通的幂等拒)。
250    /// 成功 = 落 Confirm 行 + 落 Terminal{Completed} 行,返回完成态的单。
251    pub fn confirm_pending(
252        &mut self,
253        pending_id: &str,
254        amount_cents: u64,
255        proof: &str,
256    ) -> Result<PendingOrder, CoreError> {
257        if proof.trim().is_empty() {
258            return Err(CoreError::Pending(PendingError::EmptyProof));
259        }
260        let ts = self.now();
261        // 先纯检查(零变更):三钉不过就一行都不写。
262        let checked = self.pendings.check_confirm(pending_id, amount_cents, ts);
263        if let Err(err @ PendingError::Expired { .. }) = checked {
264            // TTL 钉的作废半边:作废是账本事实,落一行终态再拒。
265            let record = WalRecord::Terminal {
266                ts,
267                pending_id: pending_id.to_string(),
268                outcome: PendingOutcome::ExpiredVoid,
269            };
270            if let Some(wal) = self.wal.as_mut() {
271                wal.append(&record)?;
272            }
273            self.pendings.apply_void(pending_id, ts)?;
274            return Err(CoreError::Pending(err));
275        }
276        checked.map_err(CoreError::Pending)?;
277        let confirm_record = WalRecord::Confirm {
278            ts,
279            pending_id: pending_id.to_string(),
280            amount_cents,
281            proof: proof.to_string(),
282        };
283        if let Some(wal) = self.wal.as_mut() {
284            wal.append(&confirm_record)?;
285        }
286        self.pendings
287            .apply_confirm(pending_id, amount_cents, proof, ts)?;
288        let terminal_record = WalRecord::Terminal {
289            ts,
290            pending_id: pending_id.to_string(),
291            outcome: PendingOutcome::Completed,
292        };
293        if let Some(wal) = self.wal.as_mut() {
294            wal.append(&terminal_record)?;
295        }
296        self.pendings.apply_complete(pending_id)?;
297        Ok(self
298            .pendings
299            .get(pending_id)
300            .cloned()
301            .expect("确认过的单必在台账"))
302    }
303
304    /// 批量物化 TTL 过期:扫出台账里所有**已过期且仍 `Open`** 的单,逐张落
305    /// `Terminal{ExpiredVoid}` 行并作废,返回作废的单号(按单号有序)。
306    ///
307    /// 幂等:已作废/已确认/已完成的单不在扫描范围,再扫一遍返回空。审计展示或
308    /// 定时任务用它把「过期作废」从隐式(确认时才撞上)变成显式账本事实。
309    pub fn void_expired_pendings(&mut self) -> Result<Vec<String>, CoreError> {
310        let ts = self.now();
311        let expired: Vec<String> = self
312            .pendings
313            .iter()
314            .filter(|(_, order)| order.state == PendingState::Open && ts >= order.expires_ts)
315            .map(|(id, _)| id.clone())
316            .collect();
317        for pending_id in &expired {
318            let record = WalRecord::Terminal {
319                ts,
320                pending_id: pending_id.clone(),
321                outcome: PendingOutcome::ExpiredVoid,
322            };
323            if let Some(wal) = self.wal.as_mut() {
324                wal.append(&record)?;
325            }
326            self.pendings.apply_void(pending_id, ts)?;
327        }
328        Ok(expired)
329    }
330
331    /// 新单号:`p-` + FNV-1a64(委托 id ‖ nonce ‖ 开单时刻 ‖ 盐)。
332    /// 确定性派生(同输入同单号,回放可复算形状),盐自增兜底同刻连开的碰撞。
333    fn fresh_pending_id(&self, delegation_id: &str, nonce: u64, ts: u64) -> String {
334        let mut salt = 0u8;
335        loop {
336            let mut bytes = Vec::with_capacity(delegation_id.len() + 17);
337            bytes.extend_from_slice(delegation_id.as_bytes());
338            bytes.extend_from_slice(&nonce.to_le_bytes());
339            bytes.extend_from_slice(&ts.to_le_bytes());
340            bytes.push(salt);
341            let candidate = format!("p-{:016x}", fnv1a_64(&bytes));
342            if !self.pendings.contains_key(&candidate) {
343                return candidate;
344            }
345            salt = salt.wrapping_add(1);
346        }
347    }
348
349    /// ①意图 + ②审批共用的判定段:evaluate → 写 Decide 行 → 放行则 commit。
350    /// [`WanningState::decide`] 与 [`WanningState::decide_opening_pending`] 都走
351    /// 这一段,判定面绝不两套。
352    fn evaluate_record_commit(
353        &mut self,
354        intent: &SpendIntent,
355        ts: u64,
356    ) -> Result<GateDecision, CoreError> {
357        let verdict = self.gate.evaluate_at(intent, ts);
358        let spent_after = match verdict {
359            // Allow 携带的就是「扣减后的累计消费」,直接取用,不重算。
360            GateDecision::Allow { budget_after_cents } => budget_after_cents,
361            GateDecision::Deny { .. } => self.gate.spent_cents(&intent.delegation_id).unwrap_or(0),
362        };
363        let record = WalRecord::Decide {
364            ts,
365            decision: match verdict {
366                GateDecision::Allow { .. } => WalDecision::Allow,
367                GateDecision::Deny { .. } => WalDecision::Deny,
368            },
369            delegation_id: intent.delegation_id.clone(),
370            intent: intent.clone(),
371            reason: verdict.deny_reason(),
372            budget_after_cents: spent_after,
373        };
374        if let Some(wal) = self.wal.as_mut() {
375            wal.append(&record)?;
376        }
377        match verdict {
378            GateDecision::Allow { budget_after_cents } => {
379                let after = self.gate.commit_at(intent, ts)?;
380                debug_assert_eq!(after, budget_after_cents);
381                Ok(GateDecision::Allow {
382                    budget_after_cents: after,
383                })
384            }
385            deny => Ok(deny),
386        }
387    }
388
389    /// 闸状态指纹(FNV-1a 64,非密码学,仅用于确定性对账)。
390    ///
391    /// 覆盖:委托集、账本、撤销集、nonce 登记集、策略运行时状态(W-27 速率
392    /// 窗口时刻与类目台账——随 commit 演化的状态必须进指纹,否则「速率窗口跨
393    /// 重启被洗掉」这类回放缺失对账不出来)、待支付台账(W-53a:单的状态演化
394    /// 必须进指纹,否则「重启洗掉确认」对账不出来);全部按有序迭代序列化,
395    /// 因此「同一份 WAL 回放两遍 hash 必相同」由构造保证。
396    ///
397    /// `pendings` 键只在台账非空时出现——老账本(无人待支付)的指纹与 W-53
398    /// 之前逐字节相同,不制造一次全体哈希漂移。
399    pub fn state_hash(&self) -> u64 {
400        let mut snapshot = serde_json::json!({
401            "delegations": self.gate.delegations().collect::<Vec<_>>(),
402            "spent_cents": self.gate.ledger().entries().collect::<Vec<_>>(),
403            "revoked": self.gate.revocations().iter().collect::<Vec<_>>(),
404            "used_nonces": self.gate.replay_registry().iter().collect::<Vec<_>>(),
405            "policy_states": self.gate.policy_states().collect::<Vec<_>>(),
406        });
407        if !self.pendings.is_empty() {
408            snapshot["pendings"] = serde_json::to_value(self.pendings.iter().collect::<Vec<_>>())
409                .expect("待支付台账可序列化");
410        }
411        fnv1a_64(snapshot.to_string().as_bytes())
412    }
413
414    fn now(&self) -> u64 {
415        self.gate.clock().now()
416    }
417
418    /// 从 WAL 回放重建状态(确定性;损坏行 / 完整性链断裂 / 对账不一致 → fail-closed)。
419    ///
420    /// 返回的状态:
421    /// - 时钟是被注入的 [`MockClock`],冻结在最后一条记录的 ts(回放是「过去的世界」,
422    ///   不适合继续判定新意图——要续,就重新 `live()` 开一个新 WAL);
423    /// - 未挂 WAL(回放不追加记录)。
424    pub fn replay(wal_path: impl AsRef<Path>) -> Result<Self, CoreError> {
425        // 读回即验完整性链(seq/prev 逐行核),再逐行重算对账。
426        let records = crate::wal::read_verified(wal_path)?.records;
427        let clock = MockClock::new(0);
428        let mut state = WanningState::new(Arc::new(clock.clone()));
429        // 最近一次放行的意图(①②审批的回放锚):③待支付行必须挂在这上面,
430        // 没有放行就没有待支付(W-53a 语义对账)。拒绝不改锚——锚只认放行。
431        let mut last_allow: Option<SpendIntent> = None;
432        for (line_no, record) in records {
433            let record_ts = record.ts();
434            clock.set_now(record_ts);
435            match record {
436                WalRecord::RegisterDelegation { delegation, .. } => state
437                    .gate
438                    .register_delegation(delegation)
439                    .map_err(|e| CoreError::WalMismatch {
440                        line: line_no,
441                        message: format!("重放注册失败: {e}"),
442                    })?,
443                WalRecord::Revoke { delegation_id, .. } => state
444                    .gate
445                    .revoke(&delegation_id)
446                    .map_err(|e| CoreError::WalMismatch {
447                        line: line_no,
448                        message: format!("重放撤销失败: {e}"),
449                    })?,
450                WalRecord::Decide {
451                    decision,
452                    intent,
453                    reason,
454                    budget_after_cents,
455                    ..
456                } => {
457                    // 重算用记录自身的 ts(与 clock.set_now 同一时刻):速率窗口等
458                    // 依赖「判定时刻」的检查必须在记录 ts 上复现,绝不能看回放进程
459                    // 的真实时钟。
460                    let ts = record_ts;
461                    let verdict = state.gate.evaluate_at(&intent, ts);
462                    match (verdict, decision, reason) {
463                        (
464                            GateDecision::Allow {
465                                budget_after_cents: recomputed,
466                            },
467                            WalDecision::Allow,
468                            None,
469                        ) => {
470                            if recomputed != budget_after_cents {
471                                return Err(CoreError::WalMismatch {
472                                    line: line_no,
473                                    message: format!(
474                                        "放行记录的累计消费与重算不一致:记录 {budget_after_cents} / 重算 {recomputed}"
475                                    ),
476                                });
477                            }
478                            state.gate.commit_at(&intent, ts).map_err(|e| {
479                                CoreError::WalMismatch {
480                                    line: line_no,
481                                    message: format!("重放扣减失败: {e}"),
482                                }
483                            })?;
484                            last_allow = Some(intent);
485                        }
486                        (
487                            GateDecision::Deny { reason: recomputed },
488                            WalDecision::Deny,
489                            Some(recorded_reason),
490                        ) if recomputed == recorded_reason => {
491                            // 拒绝:状态零变更,只需口径一致。
492                        }
493                        (verdict, decision, reason) => {
494                            return Err(CoreError::WalMismatch {
495                                line: line_no,
496                                message: format!(
497                                    "重算判定与记录不一致:重算 {verdict:?} / 记录 {decision:?} reason={reason:?}"
498                                ),
499                            });
500                        }
501                    }
502                }
503                WalRecord::Pending {
504                    pending_id,
505                    delegation_id,
506                    intent: row_intent,
507                    approved_amount_cents,
508                    expires_ts,
509                    ..
510                } => {
511                    // ③待支付行的四道语义闸:锚在最近一次放行的**同一意图**上
512                    // (防无放行开单 / 换意图夹带)、行自洽(审批额 = 意图额、
513                    // 委托一致、过期时刻真的在开单时刻之后)、单号与意图首次出现。
514                    let anchored_on_allow = last_allow.as_ref() == Some(&row_intent);
515                    let self_consistent = row_intent.amount_cents == approved_amount_cents
516                        && row_intent.delegation_id == delegation_id
517                        && expires_ts > record_ts;
518                    let first_open = !state
519                        .pendings
520                        .contains_intent(&delegation_id, row_intent.nonce);
521                    if !anchored_on_allow || !self_consistent || !first_open {
522                        return Err(CoreError::WalMismatch {
523                            line: line_no,
524                            message: format!(
525                                "待支付行与放行记录不一致:锚定放行 {anchored_on_allow} / \
526                                 行自洽 {self_consistent} / 单号与意图首次出现 {first_open}"
527                            ),
528                        });
529                    }
530                    state
531                        .pendings
532                        .apply_open(PendingOrder {
533                            pending_id,
534                            delegation_id,
535                            intent: row_intent,
536                            approved_amount_cents,
537                            created_ts: record_ts,
538                            expires_ts,
539                            state: PendingState::Open,
540                            proof: None,
541                            confirmed_ts: None,
542                        })
543                        .map_err(|e| CoreError::WalMismatch {
544                            line: line_no,
545                            message: format!("重放开单失败: {e}"),
546                        })?;
547                }
548                WalRecord::Confirm {
549                    pending_id,
550                    amount_cents,
551                    proof,
552                    ..
553                } => {
554                    // ④确认行走实时侧同一套三钉;空凭证的确认行实时侧写不出来,
555                    // 回放侧同样拒(实时侧先拒、根本不落行,这里只是对账兜底)。
556                    if proof.trim().is_empty() {
557                        return Err(CoreError::WalMismatch {
558                            line: line_no,
559                            message: "确认行的支付凭证为空(实时侧写不出这种行)".to_string(),
560                        });
561                    }
562                    state
563                        .pendings
564                        .apply_confirm(&pending_id, amount_cents, &proof, record_ts)
565                        .map_err(|e| CoreError::WalMismatch {
566                            line: line_no,
567                            message: format!("重放确认失败: {e}"),
568                        })?;
569                }
570                WalRecord::Terminal {
571                    pending_id,
572                    outcome,
573                    ..
574                } => {
575                    // ⑤终态行由同一套状态机核:完成必须已确认,作废必须真过期。
576                    let applied = match outcome {
577                        PendingOutcome::Completed => state.pendings.apply_complete(&pending_id),
578                        PendingOutcome::ExpiredVoid => {
579                            state.pendings.apply_void(&pending_id, record_ts)
580                        }
581                    };
582                    applied.map_err(|e| CoreError::WalMismatch {
583                        line: line_no,
584                        message: format!("重放终态失败: {e}"),
585                    })?;
586                }
587            }
588        }
589        Ok(state)
590    }
591}
592
593#[cfg(test)]
594mod tests {
595    use super::*;
596    use crate::clock::{Clock, MockClock};
597    use crate::gate::DenyReason;
598
599    fn tmp_wal(tag: &str) -> std::path::PathBuf {
600        use std::sync::atomic::{AtomicU64, Ordering};
601        static SEQ: AtomicU64 = AtomicU64::new(0);
602        let dir = std::env::temp_dir().join("wanning-state-tests");
603        std::fs::create_dir_all(&dir).expect("建临时目录");
604        // pid + 原子序号 + 纳秒:裸 pid 跨轮运行会撞残留账本(W-21 教训,W-43b 轮补齐)。
605        let nanos = std::time::SystemTime::now()
606            .duration_since(std::time::UNIX_EPOCH)
607            .map(|d| d.as_nanos())
608            .unwrap_or(0);
609        dir.join(format!(
610            "{tag}-{}-{}-{nanos}.jsonl",
611            std::process::id(),
612            SEQ.fetch_add(1, Ordering::SeqCst)
613        ))
614    }
615
616    fn delegation() -> Delegation {
617        Delegation::new(
618            "d1",
619            "boss",
620            "claude-code",
621            1000,
622            1000,
623            2000,
624            "agent:claude-code",
625        )
626    }
627
628    /// 续跑测试专用:回放侧时钟停在记录 ts(如 1500),续跑后是真实「现在」——
629    /// 委托窗口必须同时覆盖两个世界(1500 之前生效、系统时钟下未过期)。
630    fn long_lived_delegation() -> Delegation {
631        Delegation::new(
632            "d1",
633            "boss",
634            "claude-code",
635            1000,
636            1000,
637            SystemClock.now().checked_add(86_400).expect("有效期溢出"),
638            "agent:claude-code",
639        )
640    }
641
642    fn intent(nonce: u64, amount_cents: u64) -> SpendIntent {
643        SpendIntent::new("d1", nonce, amount_cents, "jd:shop-1", "grocery", "测试")
644    }
645
646    #[test]
647    fn allow_and_deny_are_both_recorded() {
648        let path = tmp_wal("both");
649        let clock = MockClock::new(1500);
650        let mut state = WanningState::with_wal(Arc::new(clock.clone()), &path).expect("开 WAL");
651        state.register_delegation(delegation()).expect("注册");
652
653        // 放行
654        let a = state.decide(&intent(1, 500)).expect("判定");
655        assert!(a.is_allow());
656        // 拒绝(超额)
657        let d = state.decide(&intent(2, 9000)).expect("判定");
658        assert_eq!(d.deny_reason(), Some(DenyReason::OverBudget));
659
660        let records = crate::wal::read_records(&path).expect("读回");
661        assert_eq!(records.len(), 3, "注册 + 放行 + 拒绝");
662        let (_, first_decide) = &records[1];
663        let (_, second_decide) = &records[2];
664        match (first_decide.kind(), first_decide.ts(), second_decide.kind()) {
665            ("decide", 1500, "decide") => {}
666            other => panic!("记录形状不符: {other:?}"),
667        }
668        // deny 记录在案(带 reason、不带 budget 变化)
669        let crate::wal::WalRecord::Decide {
670            decision,
671            reason,
672            budget_after_cents,
673            ..
674        } = second_decide
675        else {
676            panic!("第二条决策记录应是 Decide");
677        };
678        assert_eq!(*decision, WalDecision::Deny);
679        assert_eq!(*reason, Some(DenyReason::OverBudget));
680        assert_eq!(*budget_after_cents, 500, "拒绝不改账本,累计消费仍是 500");
681    }
682
683    #[test]
684    fn write_ahead_audit_failure_leaves_state_untouched() {
685        // 审计写不进去 → 消费不能发生(状态零变更)。
686        // 构造:占用目标路径为目录,使 WAL 打开即失败。
687        let dir = std::env::temp_dir().join("wanning-state-tests");
688        std::fs::create_dir_all(&dir).expect("建临时目录");
689        // 路径名同理带纳秒:上一轮残留的同名目录会让本轮占位失败。
690        let nanos = std::time::SystemTime::now()
691            .duration_since(std::time::UNIX_EPOCH)
692            .map(|d| d.as_nanos())
693            .unwrap_or(0);
694        let path = dir.join(format!("dir-as-wal-{nanos}.jsonl"));
695        std::fs::create_dir_all(&path).expect("占位为目录");
696
697        let err = WanningState::with_wal(Arc::new(MockClock::new(1500)), &path).unwrap_err();
698        assert!(matches!(err, CoreError::WalIo(_)), "{err}");
699    }
700
701    #[test]
702    fn replay_rebuilds_state_and_is_deterministic() {
703        let path = tmp_wal("replay");
704        let clock = MockClock::new(1500);
705        let mut state = WanningState::with_wal(Arc::new(clock.clone()), &path).expect("开 WAL");
706        state.register_delegation(delegation()).expect("注册");
707        state.decide(&intent(1, 500)).expect("放行");
708        state.decide(&intent(2, 9000)).expect("超额拒");
709        state.decide(&intent(3, 100)).expect("再放行");
710        state.revoke("d1").expect("撤销");
711        state.decide(&intent(4, 100)).expect("撤销后拒");
712
713        let live_hash = state.state_hash();
714        assert_eq!(
715            state.gate().spent_cents("d1"),
716            Some(600),
717            "实时累计消费 = 500 + 100"
718        );
719
720        // 回放两遍,hash 必须一致且等于实时状态。
721        let replayed = WanningState::replay(&path).expect("回放");
722        let hash_once = replayed.state_hash();
723        let replayed_again = WanningState::replay(&path).expect("回放二遍");
724        let hash_twice = replayed_again.state_hash();
725
726        assert_eq!(hash_once, hash_twice, "回放两遍 hash 必相同(确定性)");
727        assert_eq!(hash_once, live_hash, "回放态必须与实时态完全一致");
728        assert_eq!(replayed.gate().spent_cents("d1"), Some(600));
729        assert!(replayed.gate().is_revoked("d1"));
730        assert!(
731            replayed
732                .gate()
733                .replay_registry()
734                .contains("agent:claude-code", 1),
735            "重放登记也必须被重建"
736        );
737        assert_eq!(replayed.wal_line_count(), None, "回放态不追加记录");
738    }
739
740    #[test]
741    fn replay_uses_recorded_ts_so_expiry_reproduces() {
742        // 实时判定依赖时钟;回放若用真实时钟,过期委托会判成 Expired 与记录不符。
743        // 这里验证回放按记录 ts 驱动,过期/未过期的判定都能精确复现。
744        let path = tmp_wal("expiry");
745        let clock = MockClock::new(1500);
746        let mut state = WanningState::with_wal(Arc::new(clock.clone()), &path).expect("开 WAL");
747        state.register_delegation(delegation()).expect("注册");
748        state.decide(&intent(1, 100)).expect("放行");
749        clock.set_now(2000); // 推到过期
750        state.decide(&intent(2, 100)).expect("过期拒");
751
752        let replayed = WanningState::replay(&path).expect("回放");
753        assert_eq!(replayed.state_hash(), state.state_hash());
754    }
755
756    #[test]
757    fn replay_fails_closed_on_corrupted_line() {
758        let path = tmp_wal("replay-corrupt");
759        let mut state = WanningState::with_wal(Arc::new(MockClock::new(1500)), &path).expect("开");
760        state.register_delegation(delegation()).expect("注册");
761        drop(state);
762        // 追加半行
763        use std::io::Write;
764        let mut f = std::fs::OpenOptions::new()
765            .append(true)
766            .open(&path)
767            .expect("开");
768        f.write_all(b"{\"kind\":\"decide\",\"ts\":1,\"dele\n")
769            .expect("追加坏行");
770        drop(f);
771
772        match WanningState::replay(&path) {
773            Err(CoreError::WalBadLine { line, .. }) => assert_eq!(line, 2),
774            other => panic!("应 fail-closed 报错,实际 {other:?}"),
775        }
776    }
777
778    #[test]
779    fn replay_fails_closed_when_record_disagrees_with_recomputation() {
780        // 手工构造一条与闸语义矛盾的记录:同一 nonce 两次「放行」。
781        let path = tmp_wal("replay-tampered");
782        let mut state = WanningState::with_wal(Arc::new(MockClock::new(1500)), &path).expect("开");
783        state.register_delegation(delegation()).expect("注册");
784        state.decide(&intent(1, 100)).expect("放行");
785        drop(state);
786        // 篡改:把同 nonce 的第二次放行直接写进 WAL(实时闸根本不可能放行它)。
787        // 包裹形态与真实写入完全一致(seq 接续、prev = 前两行的链尾)——这正是链的
788        // 已知边界:尾行内容没有后继行引用,链验不住,靠回放重算(语义对账)抓住。
789        use std::io::Write;
790        let verified = crate::wal::read_verified(&path).expect("读已有历史");
791        let forged = crate::wal::WalLine {
792            seq: verified.records.len() as u64 + 1,
793            prev: verified.tail,
794            rec: WalRecord::Decide {
795                ts: 1500,
796                decision: WalDecision::Allow,
797                delegation_id: "d1".to_string(),
798                intent: intent(1, 100),
799                reason: None,
800                budget_after_cents: 200,
801            },
802        };
803        let mut f = std::fs::OpenOptions::new()
804            .append(true)
805            .open(&path)
806            .expect("开");
807        f.write_all(serde_json::to_string(&forged).unwrap().as_bytes())
808            .and_then(|()| f.write_all(b"\n"))
809            .expect("追加");
810        drop(f);
811
812        match WanningState::replay(&path) {
813            Err(CoreError::WalMismatch { line, message }) => {
814                assert_eq!(line, 3, "不一致要指到行");
815                assert!(message.contains("不一致"), "{message}");
816            }
817            other => panic!("篡改记录必须 fail-closed,实际 {other:?}"),
818        }
819    }
820
821    #[test]
822    fn audit_chain_tail_matches_independent_read_side_recompute() {
823        // 完整性链对账证据:实时链尾 == 读侧逐行独立重算的链尾(两条路径各算各的)。
824        let path = tmp_wal("chain-tail");
825        let mut state = WanningState::with_wal(Arc::new(MockClock::new(1500)), &path).expect("开");
826        state.register_delegation(delegation()).expect("注册");
827        state.decide(&intent(1, 500)).expect("放行");
828        state.decide(&intent(2, 9000)).expect("超额拒");
829        state.revoke("d1").expect("撤销");
830
831        let live_tail = state.audit_chain_tail().expect("必有 WAL");
832        let verified = crate::wal::read_verified(&path).expect("读回验链");
833        assert_eq!(verified.tail, live_tail, "读侧独立重算链尾 == 实时链尾");
834        assert_eq!(
835            WanningState::replay(&path).expect("回放").state_hash(),
836            state.state_hash(),
837            "链验过后,回放对账照常成立"
838        );
839    }
840
841    #[test]
842    fn live_resuming_fails_closed_on_broken_chain() {
843        // 历史行被改(改的是不参与判定的 memo,语义对账抓不住)→ 链断 → 续跑拒启。
844        // 至少三行:被改行必须有后继行引用它的链值,尾行是链的已知边界。
845        let path = tmp_wal("resume-chain");
846        {
847            let mut state =
848                WanningState::with_wal(Arc::new(MockClock::new(1500)), &path).expect("开");
849            state.register_delegation(delegation()).expect("注册");
850            state.decide(&intent(1, 100)).expect("放行");
851            state.decide(&intent(2, 9000)).expect("超额拒");
852        }
853        let mut lines = crate::wal::raw_lines(&path).expect("读 WAL");
854        let mut value: serde_json::Value = serde_json::from_str(&lines[1]).expect("行是 JSON");
855        value["rec"]["intent"]["memo"] = serde_json::json!("被改写的备注");
856        lines[1] = value.to_string();
857        std::fs::write(&path, lines.join("\n") + "\n").expect("重写 WAL");
858
859        match WanningState::live_resuming(&path) {
860            Err(CoreError::WalChainBroken { line, .. }) => {
861                assert_eq!(line, 3, "断链点 = 被改行的下一行(prev 对不上)")
862            }
863            other => panic!("链断裂必须拒启,实际 {other:?}"),
864        }
865    }
866
867    #[test]
868    fn state_hash_changes_when_state_changes() {
869        let path = tmp_wal("hash");
870        let clock = MockClock::new(1500);
871        let mut state = WanningState::with_wal(Arc::new(clock.clone()), &path).expect("开");
872        state.register_delegation(delegation()).expect("注册");
873        let h0 = state.state_hash();
874        state.decide(&intent(1, 100)).expect("放行");
875        let h1 = state.state_hash();
876        state.revoke("d1").expect("撤销");
877        let h2 = state.state_hash();
878        assert_ne!(h0, h1, "扣减后 hash 必变");
879        assert_ne!(h1, h2, "撤销后 hash 必变");
880    }
881
882    #[test]
883    fn empty_wal_replays_to_empty_state() {
884        let path = tmp_wal("empty");
885        std::fs::write(&path, "").expect("写空文件");
886        let replayed = WanningState::replay(&path).expect("空 WAL 是合法状态");
887        assert_eq!(
888            replayed.state_hash(),
889            WanningState::new(Arc::new(MockClock::new(0))).state_hash()
890        );
891    }
892
893    // -----------------------------------------------------------------------
894    // 断点续跑(live_resuming):长期服务重启必须从审计接续,绝不带空账本接着判
895    // -----------------------------------------------------------------------
896
897    #[test]
898    fn live_resuming_carries_ledger_revocations_and_nonces() {
899        let path = tmp_wal("resume");
900        {
901            let clock = MockClock::new(1500);
902            let mut state = WanningState::with_wal(Arc::new(clock.clone()), &path).expect("开");
903            state
904                .register_delegation(long_lived_delegation())
905                .expect("注册");
906            state.decide(&intent(1, 500)).expect("放行");
907            state.decide(&intent(2, 100)).expect("再放行");
908            state.revoke("d1").expect("撤销");
909        } // drop:进程「重启」
910
911        let resumed = WanningState::live_resuming(&path).expect("续跑");
912        // 账本/撤销/nonce 全部接续。
913        assert_eq!(resumed.gate().spent_cents("d1"), Some(600));
914        assert!(resumed.gate().is_revoked("d1"), "撤销必须跨重启存活");
915        assert_eq!(
916            resumed.state_hash(),
917            WanningState::replay(&path).expect("回放").state_hash(),
918            "续跑态与回放态必须一致"
919        );
920        // 时钟已回到「现在」:系统时钟,而非回放的冻结时刻 1500。
921        assert!(
922            resumed.gate().clock().now() > 1_700_000_000,
923            "续跑必须用系统时钟,得到 {}",
924            resumed.gate().clock().now()
925        );
926
927        // 续跑后的闸照常判定,且继续写同一份 WAL:撤销态下新意图被拒、旧 nonce 重放
928        // 也被拒(闸口径 revoked 先于 replay,两条都落到拒),账本不动。
929        let mut resumed = resumed;
930        let deny = resumed.decide(&intent(3, 100)).expect("判定");
931        assert_eq!(deny.deny_reason(), Some(DenyReason::Revoked));
932        let replay_deny = resumed.decide(&intent(1, 100)).expect("判定");
933        assert_eq!(replay_deny.deny_reason(), Some(DenyReason::Revoked));
934        assert_eq!(resumed.gate().spent_cents("d1"), Some(600), "账本不动");
935        let records = crate::wal::read_records(&path).expect("读回");
936        assert_eq!(records.len(), 6, "注册+2 放行+撤销+续跑后 2 条拒绝");
937    }
938
939    #[test]
940    fn live_resuming_on_fresh_wal_starts_empty() {
941        let path = tmp_wal("resume-fresh");
942        let mut state = WanningState::live_resuming(&path).expect("新 WAL 直接续跑=空账开张");
943        assert_eq!(
944            state.state_hash(),
945            WanningState::replay(&path).expect("回放").state_hash()
946        );
947        state
948            .register_delegation(long_lived_delegation())
949            .expect("注册");
950        assert!(state.decide(&intent(1, 100)).expect("判定").is_allow());
951    }
952
953    #[test]
954    fn live_resuming_fails_closed_on_corrupted_wal() {
955        let path = tmp_wal("resume-corrupt");
956        {
957            let mut state =
958                WanningState::with_wal(Arc::new(MockClock::new(1500)), &path).expect("开");
959            state.register_delegation(delegation()).expect("注册");
960        }
961        use std::io::Write;
962        let mut f = std::fs::OpenOptions::new()
963            .append(true)
964            .open(&path)
965            .expect("开");
966        f.write_all(b"{\"kind\":\"decide\",\"ts\":1,\"dele\n")
967            .expect("追加坏行");
968        drop(f);
969
970        match WanningState::live_resuming(&path) {
971            Err(CoreError::WalBadLine { line, .. }) => assert_eq!(line, 2),
972            other => panic!("审计损坏必须拒启,实际 {other:?}"),
973        }
974    }
975}