1use 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#[derive(Debug)]
24pub struct WanningState {
25 gate: Gate,
26 wal: Option<Wal>,
27}
28
29impl WanningState {
30 pub fn new(clock: SharedClock) -> Self {
32 Self {
33 gate: Gate::new(clock),
34 wal: None,
35 }
36 }
37
38 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 pub fn live(wal_path: impl AsRef<Path>) -> Result<Self, CoreError> {
51 Self::with_wal(Arc::new(SystemClock), wal_path)
52 }
53
54 pub fn live_resuming(wal_path: impl AsRef<Path>) -> Result<Self, CoreError> {
69 let path = wal_path.as_ref();
70 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 pub fn wal_line_count(&self) -> Option<u64> {
89 self.wal.as_ref().map(Wal::line_count)
90 }
91
92 pub fn last_wal_line(&self) -> Option<u64> {
94 self.wal_line_count()
95 }
96
97 pub fn audit_chain_tail(&self) -> Option<u64> {
102 self.wal.as_ref().map(Wal::chain_tail)
103 }
104
105 pub fn register_delegation(&mut self, delegation: Delegation) -> Result<(), CoreError> {
107 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 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 pub fn decide(&mut self, intent: &SpendIntent) -> Result<GateDecision, CoreError> {
145 let ts = self.now();
149 let verdict = self.gate.evaluate_at(intent, ts);
150 let spent_after = match verdict {
151 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 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 pub fn replay(wal_path: impl AsRef<Path>) -> Result<Self, CoreError> {
209 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 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 }
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 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 let a = state.decide(&intent(1, 500)).expect("判定");
340 assert!(a.is_allow());
341 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 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 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 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 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); 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 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 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 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 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 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 #[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 } let resumed = WanningState::live_resuming(&path).expect("续跑");
592 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 assert!(
602 resumed.gate().clock().now() > 1_700_000_000,
603 "续跑必须用系统时钟,得到 {}",
604 resumed.gate().clock().now()
605 );
606
607 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}