sprawling-kernel 0.0.12

Every decision of a sprawling city - addresses, events, gates, plans and errors - with nothing that touches a disk.
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
-- 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