-- This Source Code Form is subject to the terms of the Mozilla Public
-- License, v. 2.0. If a copy of the MPL was not distributed with this
-- file, You can obtain one at https://mozilla.org/MPL/2.0/.
-- Copyright (c) 2026 2youg1 and the sprawling contributors
import crates.kernel.spec.Event.Kind
/-!
# kernel::error
规定 `kernel::error`(`crates/kernel/src/error.rs` 与 `crates/kernel/src/error/` 下的 `code`、`shape`、`refusal`、`provider`):码、恢复语必填的错误与它的构造路径、carrier 声明。本文件是 `crates/kernel/Spec.lean` 的一个分部;下面每一节保留它在 kernel 规格里的标签 §8-n,别处引作 `crates/kernel/Spec.lean §8-n`。
-/
/-!
### 8-1 kernel::error
**类型**:
```rust
pub enum AxCode { PathNotFound, /* …其余 variant,serde 呈现名见下表 */ }
pub struct GateRefusal { // three-part refusal;三段必填
rule: String, violation: String, alternative: String,
}
pub struct AxError { code: AxCode, detail: Box<ErrorDetail> } // 线上是扁平的一个对象,序列化字段序=声明序
struct ErrorDetail {
action: String, subject: String, nearby: Vec<String>, recovery: String, retry: Retry,
retry_after_ms: Option<u64>, // 缺省即不写出;只随 Retry::Yes 出现
account: AccountDisposition, // 缺省 Keep 即不写出;Advance 只随 Retry::No 出现(D53)
gate: Option<GateRefusal>,
provider: Option<ProviderFailureKind>, // 缺席时不上线
}
/// 同一个请求能否再发一次,连同它的效果是否已经落地;线上拼作 "yes"/"no"/"unknown"。
pub enum Retry {
Yes, // 效果没有落地,同一请求稍后可能成功:可以
No, // 同一请求再发一次注定同样失败:不可以
Unknown, // 请求已经发出而回答丢了,效果是否落地无从得知:不知道
}
/// 当前账号还能不能接这个请求;线上拼作 "keep"/"advance",Keep 不写出(D53)。
pub enum AccountDisposition {
Keep, // 换不换号与这次失败无关:是否再发由 retry 决定
Advance, // 这个账号接不了这个请求:换下一个账号
}
pub enum ProviderFailureKind { // 携 serde,内标签 kind
Exchange, Cut, Silence, Refused { status: u32 },
Quota { status: u32 }, // 额度用尽的拒绝:gateway D32 的 AccountUnavailable,恢复语指向补额度或加账号
Overflow { status: u32 }, Unreadable, Reported, Unbuilt,
}
pub enum Carrier { Event(EventKind), Loadtime }
impl AxCode {
pub fn carrier(&self) -> Carrier; // 穷尽 match,无 catch-all;唯一声明位
pub fn as_str(&self) -> &'static str; // "E_…" 拼写,serde 与 Display 共用
}
```
**构造**:字段私有;两个构造子都返回 `ErrorDraft`,`ErrorDraft::with_recovery` 是通向 `AxError` 的唯一门。两条不变量因此由构造路径保证:「Gate 拒绝码 ⇒ gate 三段在场」,以及「凡构造出的 AxError 必带一句恢复语」。
```rust
impl AxError {
/// Non-gate failure. `retry` defaults to `Retry::No` (fail-closed).
pub fn failure(code: AxCode, action: impl Into<String>, subject: impl Into<String>) -> ErrorDraft;
/// Gate refusal. Sets `gate` to the mandatory three parts.
pub fn refusal(code: AxCode, action: impl Into<String>, subject: impl Into<String>, gate: GateRefusal) -> ErrorDraft;
/// 模型调用失败的 E_PROVIDER:种类在场;`retry` 起于 `No`,由 gateway 的 `ProviderFailure::retry` 在 draft 上设定。
pub fn provider(kind: ProviderFailureKind, action: impl Into<String>, subject: impl Into<String>) -> ErrorDraft;
pub fn code(&self) -> &AxCode; pub fn gate(&self) -> Option<&GateRefusal>;
pub fn retry(&self) -> Retry;
pub fn account(&self) -> AccountDisposition;
pub fn provider_failure(&self) -> Option<ProviderFailureKind>;
/// 改写下层抛上来的恢复语;与 `ErrorDraft::with_recovery` 分名,因为它毁掉一句而不是补上第一句。
#[must_use] pub fn rewrite_recovery(self, recovery: impl Into<String>) -> Self;
}
#[must_use = "an ErrorDraft becomes an AxError only through with_recovery"]
pub struct ErrorDraft { /* 私有:尚未拿到恢复语的那六个字段 */ }
impl ErrorDraft {
pub fn with_nearby(self, nearby: Vec<String>) -> Self;
pub fn retriable(self) -> Self; // Retry::Yes,显式声明,默认 No
pub fn retriable_after(self, wait_ms: u64) -> Self; // Retry::Yes,且对端说了最早何时再问
pub fn effect_unknown(self) -> Self; // Retry::Unknown:请求已发出,回答丢了
pub fn account_unusable(self) -> Self; // AccountDisposition::Advance,同时置 Retry::No、清掉等待(D53)
pub fn with_recovery(self, recovery: impl Into<String>) -> AxError; // 唯一出口
}
```
**供应方失败的种类上线,句子不上线**:`provider` 只在 `AxError::provider` 造出的 E_PROVIDER 上在场(gateway 的 `provider_err` 是它的调用处;不是模型调用的 E_PROVIDER,如连接器的 http 客户端,不带种类)。客户端按 `kind` 从 `lang.json` 取人读的出路,城写的 `recovery` 折进「城的原话」供排错;城若只给英文句子,中文界面就只能照抄英文。种类不判「能不能再试一次」:那一判住在 gateway 的 `ProviderFailure::retry`,因为它要看种类之外的东西——连接是否建起(`Exchange` 分 `Yes` 与 `Unknown`)、拒绝的状态码(408/429/5xx 可重试)、流中报错的类型。**被否**:让种类另带一个 `is_retriable`——它看不见这些,会与 `retry` 成为同一事实的两个已经不一致的权威。
**恢复语必填由 typestate 执行,不由四参构造子执行**:`refusal` 已占满四参,再塞恢复语就是第五参,越过参数上限;而恢复语只有 `ErrorDraft::with_recovery` 一个设定处,四参方案则要把 `with_recovery` 留作改写器,同一个名字两份职责。于是「每个 AxError 带恢复语」由编译器检查,grep 查不了它。`AxError` 一经存在即已完工,`with_nearby`/`retriable` 只在 draft 上,故链式调用中 `with_recovery` 恒为最后一环。
**`retry_after_ms` 是失败方对「再问之前至少等多久」的陈述**,读取处是 `AxError::retry_after_ms() -> Option<u64>`。它只能经 `retriable_after` 写入,而该方法同时置 `Retry::Yes`;`effect_unknown` 置 `Unknown` 时一并清掉它,所以无论构造器按什么次序调用,「不是 `Yes` 却带等待」都拼不出来。`None` 时字段不上线、不入账;账本里没有这个键的行读作 `None`(`serde(default)`)。
**`retry` 是三态信封,不是布尔。** 一个 `bool` 只能说「可以」与「不可以」,于是请求已经发出、回答却丢了的那一类失败(流中断、静默超时、发送途中断开)只能被说成「可以」,而那是假话:对端也许已经执行了它、计了费,一个有副作用的动作再发一次就是第二次副作用。第三态 `Unknown` 把「效果是否已经落地」说进信封,决定再发与否的是知道这个动作是否幂等的调用方(runtime 的 watchdog 对一次模型调用照样退避重问,因为它的效果只是一份城里从未收到的回答)。落选的是另加一个 `landed: bool` 字段:「可以」蕴含未落地、「不可以」与落地无关,两字段会拼出「可以且已落地」这样没有意义的组合。线上字段名是 `retry`;`serde(alias = "retriable")` 让账本里写作 `retriable: true/false` 的行读作 `Yes`/`No`。落选的是把等待时长写进 `recovery` 文本:那是给读者的一句话,重试节奏的持有者(runtime 的 watchdog)不该从句子里解析数字。
「gate 码走 `refusal`」由构造纪律+单测保证;`kernel::gate` 是全库唯一 gate 码生产者,citysim 不变量 8 号在系统层复验。derive `Serialize/Deserialize`(Ledger 载荷需要)、`Clone/Debug/PartialEq`;`thiserror::Error` 提供 Display(`{code}: {action} on {subject}`)。
-/
/-!
> 协作组的 `E_WORKTREE_BUSY` 与 `E_DIGEST_SUSPECT` 在各自 SPEC 里答过「能否定义掉」:不能,各自有一个真实的运行期情境。
-/
/-!
装载期七码(`E_CONFIG_INVALID` `E_CAS_CORRUPT` `E_STORAGE_FATAL` `E_WIRE_MISMATCH` `E_LOG_VERSION_UNSUPPORTED` `E_LEDGER_HELD` `E_HISTORY_UNPROVEN`)=C9 唯一例外白名单;`Carrier::Loadtime` 即其类型面。白名单封闭,进表的条件只有一条:**这个码只在本进程此刻写不了账本时出现**,因为它若有账本可写,就必须有 carrier。每一码进表的理由逐条记在 `crates/kernel/Spec.lean` §12。
`E_BUSY` 的 carrier 与 `E_WORKTREE_BUSY` 一样是 `tool_result`,而两者的区别在名字里:那一个说的是工作树这个机制,这一个说的是**同一个地址上有 run 正在工作**,拒绝里点名那条 run,调用方据此先停它再动手。
两条呈现约束:`E_SECRET_EGRESS` 的 subject 只写 SecretRef 与位置、恒不回显命中字节;`E_DISCARD_IRREVERSIBLE` 的 alternative 必须可执行。执行点在各生产模块,此处记为 carrier 表随附契约。
**carrier() 依赖 `EventKind`**:故它与 `event` 模块同一变更集落码;本表与其余全部(AxError/GateRefusal/AxCode/serde/构造子)不依赖它。
-/
namespace Kernel.Error
open Kernel.Event.Kind
/-- 一个码在历史里由谁携带:它的 carrier 事件,或装载期(本进程此刻写不了账本,C9 唯一的例外)。与 `kernel::Carrier` 逐变体同名。 -/
inductive Carrier where
| Event (kind : EventKind)
| Loadtime
deriving DecidableEq, Repr
/-- 错误码的闭集,与 `kernel::AxCode` 逐变体同名,`cargo xtask gates specalign` 双向对账。线上拼写由 `AxCode::as_str` 一处给出(`E_` 加全大写的蛇形词),本模型不抄那张拼写表,只证明它必须满足的性质(下面的 `parse_inverts_as_str`)。 -/
inductive AxCode where
-- 基表
| PathNotFound
| ToolUnknown
| ToolUnavailable
| InvalidArgs
| OutsideWriteDomain
| VersionConflict
| GateDenied
| BudgetExhausted
| Timeout
| Provider
| EvidenceMissing
| LoopSuspected
| LocatorInvalid
| SandboxDenied
| Busy
-- 协作
| DraftStale
| GoalConflict
| TaintedAction
| RepairBusy
| DelegationDepth
-- 治理与设施
| ApprovalPending
| ApprovalDenied
| CrossBuildingDenied
| DigestSuspect
| CredentialMissing
| ModelUnchosen
| ConfigInvalid
| CasCorrupt
| StorageFatal
| WorktreeBusy
| BrowserUnavailable
| EndpointDialectUnsupported
| WireMismatch
| LogVersionUnsupported
| LedgerHeld
| HistoryUnproven
-- 隐私与 Discard
| SecretEgress
| DiscardIrreversible
-- 背压
| BackpressureShed
-- 运行未知
| ToolOutcomeUnknown
-- 计划
| PlanMissing
-- 账号
| ProviderAccountsExhausted
-- 接入:一个 ACP agent 要先登录;一次配对或开页码被拒
| AuthRequired
| PairingRefused
deriving DecidableEq, Repr
/-- carrier 的唯一声明位(`AxCode::carrier`,C9):穷尽,没有通配臂,所以新增一个码而不给它 carrier 是编译错误。每一臂一行,`specalign` 逐臂与 kernel 编译出来的 `carrier()` 对账。
装载期那几臂是封闭的白名单,进表的条件只有一条:这个码只在本进程此刻写不了账本时出现;它若有账本可写,就必须有 carrier。每一码进表的理由在 `crates/kernel/Spec.lean` §12。 -/
def AxCode.carrier : AxCode → Carrier
-- 基表
| .PathNotFound => .Event .ToolResult
| .ToolUnknown => .Event .ToolResult
| .ToolUnavailable => .Event .ToolResult
| .InvalidArgs => .Event .ToolResult
| .OutsideWriteDomain => .Event .GateDenied
| .VersionConflict => .Event .ToolResult
| .GateDenied => .Event .GateDenied
| .BudgetExhausted => .Event .BudgetLimit
| .Timeout => .Event .ToolResult
| .Provider => .Event .ProviderDegraded
| .EvidenceMissing => .Event .ToolResult
| .LoopSuspected => .Event .WatchdogFired
| .LocatorInvalid => .Event .ToolResult
| .SandboxDenied => .Event .ToolResult
| .Busy => .Event .ToolResult
-- 协作
| .DraftStale => .Event .ToolResult
| .GoalConflict => .Event .ToolResult
| .TaintedAction => .Event .GateDenied
| .RepairBusy => .Event .ToolResult
| .DelegationDepth => .Event .GateDenied
-- 治理与设施
| .ApprovalPending => .Event .ApprovalRequested
| .ApprovalDenied => .Event .ApprovalResolved
| .CrossBuildingDenied => .Event .GateDenied
| .DigestSuspect => .Event .ToolResult
| .CredentialMissing => .Event .ToolResult
| .ModelUnchosen => .Event .ToolResult
| .ConfigInvalid => .Loadtime
| .CasCorrupt => .Loadtime
| .StorageFatal => .Loadtime
| .WorktreeBusy => .Event .ToolResult
| .BrowserUnavailable => .Event .ToolResult
| .EndpointDialectUnsupported => .Event .EndpointLost
| .WireMismatch => .Loadtime
| .LogVersionUnsupported => .Loadtime
| .LedgerHeld => .Loadtime
| .HistoryUnproven => .Loadtime
-- 隐私与 Discard
| .SecretEgress => .Event .GateDenied
| .DiscardIrreversible => .Event .GateDenied
-- 背压
| .BackpressureShed => .Event .ToolResult
-- 运行未知
| .ToolOutcomeUnknown => .Event .ToolResult
-- 计划
| .PlanMissing => .Event .ToolResult
-- 账号:与 `E_PROVIDER` 同一个 carrier,因为它说的是同一件事——供应方没有答上来——只是每个账号都试过了(kernel D54)
| .ProviderAccountsExhausted => .Event .ProviderDegraded
-- 接入:两码都在本进程能写账本时产生,答给提出请求的那一方,与其余被拒的请求同一个 carrier
| .AuthRequired => .Event .ToolResult
| .PairingRefused => .Event .ToolResult
/-- 每个码,依 `AxCode::ALL` 的次序。 -/
def AxCode.all : List AxCode := [
.PathNotFound,
.ToolUnknown,
.ToolUnavailable,
.InvalidArgs,
.OutsideWriteDomain,
.VersionConflict,
.GateDenied,
.BudgetExhausted,
.Timeout,
.Provider,
.EvidenceMissing,
.LoopSuspected,
.LocatorInvalid,
.SandboxDenied,
.Busy,
.DraftStale,
.GoalConflict,
.TaintedAction,
.RepairBusy,
.DelegationDepth,
.ApprovalPending,
.ApprovalDenied,
.CrossBuildingDenied,
.DigestSuspect,
.CredentialMissing,
.ModelUnchosen,
.ConfigInvalid,
.CasCorrupt,
.StorageFatal,
.WorktreeBusy,
.BrowserUnavailable,
.EndpointDialectUnsupported,
.WireMismatch,
.LogVersionUnsupported,
.LedgerHeld,
.HistoryUnproven,
.SecretEgress,
.DiscardIrreversible,
.BackpressureShed,
.ToolOutcomeUnknown,
.PlanMissing,
.ProviderAccountsExhausted,
.AuthRequired,
.PairingRefused
]
theorem AxCode.all_complete : ∀ code : AxCode, code ∈ AxCode.all := by
intro code
cases code <;> decide
/-- 读侧的解析(`AxCode::parse`):按 `ALL` 的次序找第一个拼写相同的码,找不到即 `none`,由调用方走拒绝的那一支。拼写函数 `as_str` 是参数,它的值只住在 Rust。 -/
def parse (as_str : AxCode → String) (raw : String) : Option AxCode :=
AxCode.all.find? (fun code => as_str code == raw)
private theorem find_first_spelled {α : Type} (spell : α → String)
(injective : ∀ a b, spell a = spell b → a = b) :
∀ (codes : List α) (code : α), code ∈ codes →
codes.find? (fun other => spell other == spell code) = some code := by
intro codes code member
induction codes with
| nil => cases member
| cons head tail ih =>
rw [List.find?_cons]
by_cases same : head = code
· subst same
simp
· have differs : (spell head == spell code) = false := by
simp only [beq_eq_false_iff_ne, ne_eq]
exact fun equal => same (injective _ _ equal)
rw [differs]
simp only
apply ih
cases member with
| head => exact absurd rfl same
| tail _ rest => exact rest
/-- **每个码的拼写读回它自己,条件是没有两个码共用一个拼写。** 账本、线上帧与客户端都按拼写读码,所以这一条就是「码的拼写稳定」在读侧的意思:写下的拼写读回的恒是写它的那个码。Rust 的 `axcode_parse_roundtrips_every_variant` 逐值检查真实的拼写表,`axcode_is_the_declared_length_and_spelling_is_bijective` 检查它是单射。 -/
theorem parse_inverts_as_str (as_str : AxCode → String)
(injective : ∀ a b, as_str a = as_str b → a = b) (code : AxCode) :
parse as_str (as_str code) = some code :=
find_first_spelled as_str injective AxCode.all code (AxCode.all_complete code)
/-- 单射不是摆设:两个码共用一个拼写时,其中一个永远读不回来。这里 `ToolUnknown` 与 `PathNotFound` 同拼,读回的是排在前面的 `PathNotFound`。 -/
theorem a_shared_spelling_loses_a_code :
parse (fun code => if code = .PathNotFound ∨ code = .ToolUnknown then "E_PATH_NOT_FOUND" else "other")
"E_PATH_NOT_FOUND" ≠ some .ToolUnknown := by
decide
/-- 同一个请求能否再发一次,连同它的效果是否已经落地。与 `kernel::Retry` 逐变体同名。 -/
inductive Retry where
| Yes
| No
| Unknown
deriving DecidableEq, Repr
/-! D53 「这个账号还能不能接这个请求」是 `AxError` 上与 `retry` 并列的第二格:`AccountDisposition`
**决定**:`AxError` 带一格 `account: AccountDisposition`,两臂 `Keep`(换不换号与这次失败无关)与 `Advance`(这个账号接不了这个请求,同一请求在它上面再发一次注定同样失败,换下一个账号可能成功)。它只经 `ErrorDraft::account_unusable` 写入,该方法同时置 `Retry::No` 并清掉等待;`retriable`、`retriable_after`、`effect_unknown` 都把它放回 `Keep`。所以不论构造器按什么次序调用,`Advance` 恒与 `No` 同行(下面的 `no_order_of_calls_advances_a_resendable_failure`):一个说「换号」的失败从不同时说「在原号上稍后再问」或「效果不明」。缺省 `Keep` 不上线、不入账(`skip_serializing_if`),账本里没有这个键的行读作 `Keep`,旧行字节不变;`Advance` 线上拼作 `"account": "advance"`。
**理由**:判定在产错处(gateway 的 `ProviderFailure::account_disposition` 与端点的凭据兑付),读它的在 runtime 与 accounting(`spec/AccountRecovery.lean`),两边不互相依赖,事实只能随错误本身跨过去,与 `retry` 同理(§8-72 说过同样的话)。`Retry` 回答「同一请求在同一个账号上能否再发」,换号回答的是另一问:401 与额度用尽都是 `No`,而 `No` 里也有 400、403、422 这些换号也修不好的请求错误,所以不能从 `retry` 推出换号。
**被否**:①让 runtime 按状态码与错误码自己判换号——同一事实的第二个权威,而且 runtime 看不见流里报错帧的类型;②在 `ProviderFailureKind` 上加一臂——它只在模型调用的 `E_PROVIDER` 上在场,而端点兑付凭据失败的 `E_CREDENTIAL_MISSING` 也要能说「换号」;③一个 `bool`——「换号」与「留号」以外日后若出现第三种(例如「整个供应方都不可用」),布尔拼不出,调用点也不会被穷尽 `match` 逼着表态。
**重开参数**:出现一种失败,换号能修而同一账号稍后再问也能修(`Advance` 与 `Yes` 同时为真)。
-/
/-- 当前账号还能不能接这个请求。与 Rust 的 `AccountDisposition` 逐变体同名(D53)。 -/
inductive AccountDisposition where
| Keep
| Advance
deriving DecidableEq, Repr
/-- `ErrorDraft` 上与重试和换号有关的三个字段:`retry`、`retry_after_ms` 与 `account`。 -/
structure Draft where
retry : Retry
retry_after_ms : Option Nat
account : AccountDisposition
deriving DecidableEq, Repr
/-- `AxError::failure`/`refusal`/`provider` 造出的 draft:`retry` 起于 `No`(fail-closed),不带等待,不换号。 -/
def Draft.fresh : Draft := { retry := .No, retry_after_ms := none, account := .Keep }
/-- `ErrorDraft` 上改重试与换号的四个方法。 -/
inductive Step where
| retriable
| retriable_after (wait_ms : Nat)
| effect_unknown
| account_unusable
/-- 四个方法各自做的事,照 `error::shape` 的实现:`retriable_after` 记下等待再置 `Yes`;`effect_unknown` 置 `Unknown` 并清掉等待;`retriable` 只置 `Yes`;三者都把换号放回 `Keep`。`account_unusable` 置 `Advance`,同时置 `No` 并清掉等待(D53)。 -/
def Draft.apply (draft : Draft) : Step → Draft
| .retriable => { draft with retry := .Yes, account := .Keep }
| .retriable_after wait => { retry := .Yes, retry_after_ms := some wait, account := .Keep }
| .effect_unknown => { retry := .Unknown, retry_after_ms := none, account := .Keep }
| .account_unusable => { retry := .No, retry_after_ms := none, account := .Advance }
/-- 「带着等待」蕴含「可以再试」。 -/
def Draft.coherent (draft : Draft) : Prop :=
draft.retry_after_ms.isSome = true → draft.retry = .Yes
/-- **不论构造器按什么次序调用,「不是 `Yes` 却带等待」都拼不出来。** 这是 `retry_after_ms` 只能经 `retriable_after` 写入、`effect_unknown` 一并清掉它这两条实现选择要买的性质。 -/
theorem no_order_of_calls_waits_without_retrying (steps : List Step) :
(steps.foldl Draft.apply Draft.fresh).coherent := by
have keep : ∀ (draft : Draft) (step : Step), draft.coherent → (draft.apply step).coherent := by
intro draft step held
cases step with
| retriable => intro _; rfl
| retriable_after _ => intro _; rfl
| effect_unknown => intro waits; simp [Draft.apply] at waits
| account_unusable => intro waits; simp [Draft.apply] at waits
have start : Draft.fresh.coherent := by
intro waits
simp [Draft.fresh] at waits
suffices ∀ (draft : Draft), draft.coherent → (steps.foldl Draft.apply draft).coherent from
this _ start
induction steps with
| nil => intro draft held; exact held
| cons step rest ih => intro draft held; exact ih _ (keep draft step held)
/-- 正常路径可实现:一次 `retriable_after` 之后,draft 可以再试且带着那段等待。 -/
example : Draft.fresh.apply (.retriable_after 500) =
{ retry := .Yes, retry_after_ms := some 500, account := .Keep } :=
rfl
/-- 「换号」蕴含「同一请求在原号上不再发、也不等」(D53)。 -/
def Draft.advances_only_when_refused (draft : Draft) : Prop :=
draft.account = .Advance → draft.retry = .No ∧ draft.retry_after_ms = none
/-- **不论构造器按什么次序调用,一个说「换号」的失败都不同时说「稍后再问」或「效果不明」。** 这是 `account` 只能经 `account_unusable` 写入、其余三个方法把它放回 `Keep` 这两条实现选择要买的性质;`spec/AccountRecovery.lean` 因此不必为「`Advance` 且 `Unknown`」另设一臂之外的语义。 -/
theorem no_order_of_calls_advances_a_resendable_failure (steps : List Step) :
(steps.foldl Draft.apply Draft.fresh).advances_only_when_refused := by
have keep : ∀ (draft : Draft) (step : Step),
draft.advances_only_when_refused → (draft.apply step).advances_only_when_refused := by
intro draft step _
cases step <;> simp [Draft.advances_only_when_refused, Draft.apply]
have start : Draft.fresh.advances_only_when_refused := by
simp [Draft.advances_only_when_refused, Draft.fresh]
suffices ∀ (draft : Draft), draft.advances_only_when_refused →
(steps.foldl Draft.apply draft).advances_only_when_refused from this _ start
induction steps with
| nil => intro draft held; exact held
| cons step rest ih => intro draft held; exact ih _ (keep draft step held)
/-- 正常路径可实现:一次 `account_unusable` 之后,draft 换号、不再发、不等。 -/
example : Draft.fresh.apply .account_unusable =
{ retry := .No, retry_after_ms := none, account := .Advance } :=
rfl
end Kernel.Error