sprawling-wire 0.0.12

The process boundary of a sprawling city: the frames its clients speak, and the server that answers them.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
-- 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

/-!
# wire::reading

规定 `reading`、`answer::rounds`、`answer::evidence`、`answer::cost_of`(`crates/wire/src/` 下同名的文件)。一条账本载荷读成线上的值,给两端共用:回合、证据与一个计划节点的花费。本文件是 `crates/wire/Spec.lean` 的一个分部;下面每一节保留它在 wire 规格里的标签 §8-n,别处引作 `crates/wire/Spec.lean §8-n`,决定引作 `wire D<n>`。

这一分部只有文字:它是说明文档,不是形式规格,这里没有一句是被证明的;它写下的帧形状由 Rust 的类型与 `crates/wire/tests/wire_contract.rs` 钉住的 wire schema(`wire::schema_hash`)守住。
-/

/-!
### 8-21 四种读法回到服务端

```rust
// Query 第 18、19、20 条(声明序,QUERY_NAMES 同序追加)
Rounds   { run: RunId },      // → Answer::Rounds(Box<RoundsAnswer>)
Evidence { run: RunId },      // → Answer::Evidence(EvidenceAnswer)
CostOf   { node: NodeId },    // → Answer::CostOf(CostOfAnswer)

pub struct RoundsAnswer {
    pub run: RunId,
    pub turns: Vec<Turn>,
    pub opened_at: Option<GitOid>,   // 本会话的第一个检查点
}
pub struct Turn {
    pub number: u32, pub opened: Seq,
    pub said: Option<String>, pub spent: Option<UsdMicros>,
    pub used: Option<Used>, pub stopped: Option<String>,
    pub calls: Vec<Call>, pub notes: Vec<Note>,
}
pub struct Call { pub tool: String, pub subject: Option<String>,
                  pub outcome: Outcome, pub at: Seq, pub output: Option<Output> }
pub enum Outcome { Waiting, Answered, Failed }
pub struct Output { pub head: String, pub cut: usize }
pub struct Used { pub input: Tokens, pub output: Tokens, #[serde(default)] pub cached: Option<Tokens> }  // D35
pub enum Note { Refused { error: AxError, at: Seq }, Checkpointed { oid: GitOid, at: Seq },
                Waiting { at: Seq }, Arrived { from: String, said: String, at: Seq },
                Discarded { count: usize, at: Seq }, Unreadable { cause: String, at: Seq } }

pub struct EvidenceAnswer { pub run: RunId, pub items: Vec<EvidenceItem> }
pub struct EvidenceItem { pub at: Seq, pub kind: EvidenceKind,
                          pub locator: Locator, pub picture: Option<Picture> }
pub enum EvidenceKind { Screenshot, Finished }
pub struct Picture { pub media_type: String, pub width: u32, pub height: u32 }

pub struct CostOfAnswer { pub node: NodeId, pub spent: UsdMicros,
                          pub runs: Vec<(RunId, UsdMicros)> }
```

**这里搬的是读法而不是接口。** 一个会话被读成回合、一次跑留下什么证据、一个计划节点花了多少钱——这三件事此前只有 `crates/web` 会算,于是「线就是全部 API」(ARCHITECTURE §8)在这三处是假的:另写一个客户端就得把折叠逻辑照抄一遍,而照抄出来的那一份迟早与这一份不一致。现在三者各是一次查询,答由 `accounting::views` 折出(`crates/sprawling/Spec.lean` §8-47)。

- **`Rounds` 的值类型住 `wire`,折叠住 `accounting::views`。** 值要上线,故必须可序列化;折叠要读账本,故必须在能读账本的那一层。两者切分开来,正是 ARCHITECTURE §9 的形状 2 与形状 7 的分界。
- **`wire::reading` 是第三块**:把一条账本载荷读成上面这些值的那些纯函数(`said_in`/`used_in`/`output_in`/`note_of`)。它住在线这一层而不是服务端,因为**两端都要读**:服务端答 `Rounds` 要它,客户端把推来的 `model_returned` 折进自己的快照也要它(ARCHITECTURE §5 第 12 步:同一个折叠,线的两边)。一份权威,两个调用者。**`Call.subject` 不由这里算**:写方在写 `tool_called` 时把它定下(`crates/kernel/Spec.lean` §8-4:`ToolCalled::subject_of`),折叠读记录里的 `subject` 键;两个读方各按自己的 map 序推一次,同一次调用已经出现过两个名字。
- **读不出的载荷是一条 `Note::Unreadable { cause, at }`,不是没有 note**:`note_of` 认下的种类(被拒、检查点)若载荷读不回它该有的形状,答里留一行,`cause` 说哪一种事件、读到哪一步失败,`at` 指向账本里那条记录。被否:返回 `None`——那样一次被拒在人眼里就是「什么都没发生」,而失败本身被这一层抹掉了。`CheckpointCommitted` 的 `JobPinned` 是一个真答案(派发钉住的是作业不是提交),仍然没有 note。
- **`Changes` 早已在线上**(§8-20),`storage::changes` 一直是它唯一的权威;查过之后不动它——把一件已经做完的事再做一遍就是造第二个权威。
- **`Evidence` 只认写下来的东西**:截图是 `tool_result` 载荷里的 `image` 定位符(`bin::browser_tool::storing::stored` 写的那三项:定位符、两条边、media type),完成证据是 `roadmap_finished` 载荷里的 `evidence` 定位符。**答里恒不携字节**:一张图是一个 `cas:` 定位符,取它是资产端点的事,把 base64 塞进查询答会让「看一眼这次跑干了什么」付上整批像素的代价——与 §8-20 拒绝整批补丁同一条理由。
- **`CostOf` 的分母不在这里**:答只报这个节点上归到的绝对金额与逐跑明细,不报占比。占比需要一个这一端没有的分母(整城总额是 `CostView` 的),而没有分母的百分比正是 `UnplannedProgress` 拒绝拼出来的那种东西。节点到跑的映射由 `roadmap_claimed` 折出(载荷里的 `node` 与记录的 `addr`),钱由 `storage::attribution` 的 `by_run` 给——**不新增任何计价处**。
- **一个本城没认领过的节点答 `CostOf { spent: 0, runs: [] }` 而不是 `Unavailable`**:与 `Changes` 那一条相反,因为这里「没人认领过它」是一个真答案而不是「我读不了」;节点地址本身经 `NodeId` 的手写 `Deserialize` 把过关,读不了的形状根本上不了线。
- **`RunCosts { runs: Vec<RunId> }` → `Answer::RunCosts(RunCostsAnswer { runs: Vec<(RunId, UsdMicros)> })`**:`CostView.by_run` 之外的跑逐个按名字问,一次最多 `RUN_COSTS_MAX`(64)个,按问的顺序答;读不出的跑不出行,零是「没花钱」。钱仍只由 `storage::attribution` 折,冷的一侧从账本折(`crates/sprawling/Spec.lean` §8-90)。
- **`WIRE_V` 15→16,一次进位管三条查询**:名字表长了三项(17→20),故 schema 哈希无论如何都要变。旧页面在握手期被明确拒绝,这正是该机制存在的理由。
- **被否**:(a)把 `Rounds` 并进 `RunHistory` 的答——前者是折叠后的读法,后者是原始记录页,一个答两副形状会让翻页与折叠互相牵制;(b)让 `Evidence` 直接回字节——见上一条;(c)把折叠留在 `wire` 里由客户端调用——那样新客户端仍要自己跑一遍折叠,而这一节整件事就是不要它这么做。
-/

/-!
### 8-47b 一次工具调用带上它的起止时刻,一个回合带上它问的模型与它开始等人的时刻

```rust
pub struct Call {
    // …既有字段…
    pub called: TimeMs,            // tool_called 那条记录的 t
    pub answered: Option<TimeMs>,  // 配对上的 tool_result 那条记录的 t;未答为 None
}
pub struct Turn {
    // …既有字段…
    pub model: Option<String>,     // 开这个回合的 model_called 记下的 model
}
pub enum Note {
    // …
    Waiting { at: Seq, t: TimeMs, answered: Option<TimeMs> },
    // t:approval_requested 那条记录的 t;answered:按 approval id 配上的 approval_resolved 的 t
}
```

- **时刻读自账本记录,不读此处的时钟**:与 `Turn.t` 同理,重放的会话报它当初的时刻。`called` 不是 `Option`:一次调用由一条 `EventRecord` 折出,它总带读数。`answered` 与 `outcome` 同时写、同一次配对——`outcome` 为 `Waiting` 时它必为 `None`,窗口外答的调用也是 `None`,不猜。
- **为什么要上线**:run 页的时间透镜原本只能按回合着色,一个回合里模型说话与工具运行各占多久,线上没有数;有了这两个时刻,透镜画的是量出来的段,而不是按回合结局推断的整段。
- **被否:只带一个时长**。时长丢了起点,页面画不出调用在时间轴上的位置,也就排不出并发的两次调用。
- **模型名挂在回合上,不挂在答案上**:`model_called` 每问一次记一次 `model`,一次 run 中途换模型(降级、换端点)时,逐回合的名字才是账本写下的事实;run 页统计栏的「模型」格取最后一个回合的名字,前后不同时列出各个名字。读法与 `Call.subject` 同:取那一键的文本,读不出为 `None`,页面不画名字而不猜。
- **被否:`RoundsAnswer.model` 一个字段**。那得在折叠里挑一个回合的名字当整次 run 的名字,换过模型的 run 上它说错一半。
- **等人从哪一刻开始,读自请求记录**:`Note::Waiting.t` 是 `approval_requested` 那条记录的 `t`,与 `Call.called` 同理不是 `Option`。等到哪一刻结束是 `answered`:`approval_resolved` 记在城自己的 run 下,服务端按 approval id 把它配回请求(`crates/sprawling/Spec.lean` §8-50-1),配不上为 `None`,不猜。
-/

/-!
### 8-48 一次 run 的开头带上由谁派来

```rust
pub struct Opening {
    // …既有字段…
    pub dispatched_by: Option<Who>,  // run_started 的 dispatched_by;缺键为 None
}
```

- **派活者写在 `run_started` 的载荷里,不读那条记录的作者**:`run_started` 的作者恒为 `city`——是城的派活台写下这一行——所以作者说不出这次 run 是人派的、城按日程与计划派的,还是一个居民委派、接替或敲门派的。派活处各自知道答案:人下的 `Dispatch` 写 `person`;计划节点、日程、外来到达与人刚放行的活写 `city`;委派写委派者的地址,接替写前任的地址,敲门叫醒写敲门者的地址。`runtime::RunPlan.dispatched_by` 把它从派活处带到 `run_started`,线上的 `Opening` 原样转述。
- **旧账本里没有这个键**,读作 `None`,页面不画「由谁派来」而不猜。
- **被否:从 `parent`/`predecessor` 推断**。那两个键只说明委派与接替,人派的与城派的在账本里长得一样,推断在最常见的两种派活上答不出来。
-/

/-!
### 8-53 一个回合带出首个内容几时到,每个时刻带出它是不是量出来的;检查点的 note 叫 `checkpointed`

```rust
pub struct Turn {
    // …既有字段…
    pub first_at: Option<TimeMs>,   // 开这个回合的回复记下的 first_at(`crates/kernel/Spec.lean` §8-75);缺席即没量到
    pub timing: Timing,             // `t` 是不是 model_called 自己那一刻
}
pub struct Call {
    // …既有字段…
    pub timing: Timing,             // called 与 answered(在场时)是不是各自那一刻
}
#[serde(rename_all = "snake_case")]
pub enum Timing { Measured, Unmeasured }
pub enum Note {
    // …
    Checkpointed { oid: GitOid, at: Seq },   // 线上 "checkpointed"
}
```

- **`first_at` 照录那一行的键。** 回合里最后一条 `model_returned` 写下的 `first_at`,读不出或缺席即 `None`。首字耗时是 `first_at − t`,线上不另带一个时长:页面手里已有这两个数。
- **`Timing` 答一个问题:两个时刻之差是不是一次测量。** `Measured`:这一行上的每个时刻都是它自己那条记录量下的那一刻(`EventRecord::moment` 答 `Some`)。`Unmeasured`:至少一个不是——账本版本 1 写下的行带的是回合时间戳,同一回合的行同值;或者答复是重启之后城补上的 `E_TOOL_OUTCOME_UNKNOWN`,它记的是城补上它的那一刻(`crates/kernel/Spec.lean` §8-4「信封 `t` 记的是什么」)。时刻本身照旧带出,它仍给出次序;页面不从 `Unmeasured` 的行画用时。
- **`Turn.timing` 只说 `t`**:回合在线上只有这一个时刻;`first_at` 在场即量过,缺席即没有。`Call.timing` 说 `called` 与 `answered` 两个:一次调用的两条记录由同一个构建写下时两者同为量过或同为未量,城补上的答复例外,所以一个值够用。
- **`Checkpointed`**:fence 与 checkpoint 曾是一个概念的两个名字,checkpoint 留下(glossary)。`Note` 不进名字表,改它的标签不动 schema 哈希,所以它随本节的进位落地。
- **进位**:本节的提交是 D1 意义上上一次推送之后第一个名字不变而改形的提交,`WIRE_V` 44 → 45;§8-53 至 §8-58 共用 45。
-/

/-!
### 8-55 一次调用带出它的效果与呈现

```rust
pub struct Call {
    // …既有字段…
    pub effect: Option<kernel::Effect>,        // tool_called 记下的登记(`crates/kernel/Spec.lean` §8-75(b))
    pub render: Option<kernel::RenderIntent>,  // 同上:Generic、Terminal、Diff、Signal 或 Delegate(kernel D37)
}
```

- **照录 `tool_called` 的两个键**,读不出或缺席即 `None`:这件工具没登记,或这一行写在这两个键出现之前。页面据 `render` 选画法(终端、差异、通用),据 `effect` 说这次调用越过了哪一种边界;两者都是 `None` 时按通用画。
- **携 kernel 的类型本身**,不在线上另立枚举(§8-0):`Effect::Write` 带它的写域地址,`Connector` 带服务器的标签,都是登记写下的事实。
- **`Diff.locations` 今天恒为空**:账上记的是登记层面的声明;一次编辑调用改的是哪个文件,读 `subject`。
-/

/-!
### 8-56 被裁掉的输出指向它的原文

```rust
pub struct Output {
    // …既有字段…
    pub pinned: Option<Locator>,   // 这次调用的输出离窗时,原文存在哪;未离窗、旧行或读不出时为 None
}
```

- **只在调用的输出上有值**:`Call.output` 的 `pinned` 读自配对上的 `tool_result` 结果里第一笔离窗账目(`runtime::pipeline::pinned_original`,`crates/runtime/Spec.lean` §8-51(b));`Call.arguments` 也是 `Output`,它的 `pinned` 恒为 `None`——参数从不离窗。
- **原文是命令原本写出的字节**:结果先被 sieve 裁、再被普通搬运存一次时,指的是第一笔账目的原文,不是 sieve 留下的替身。页面拿它经 `Query::Content` 读全文,`cut` 仍只说这个视图裁了几行。
- **旧行明确缺席**:结果里没有账目(没离窗、或写在账目进结果之前)即 `None`,不按相邻的行去猜。
-/

/-! D3 本批上线的字段各读自一个权威,缺席即没有

**决定**:(a) `Turn.first_at` 照录 `model_returned` 的键;`Timing` 由 `EventRecord::moment` 与答复的错误码判出。线上不带首字耗时,也不从相邻行推断一个时刻量没量过。

**理由**:一个事实一个家。时刻语义的权威是 `crates/kernel/Spec.lean` §8-4 与 `EventRecord::moment`;首字耗时是两个时刻之差,页面手里已有这两个数。

**被否**:①线上带一个 `ttft` 时长:派生值在线上有了第二个家,而且 `t` 未量时它要答一个答不了的数;②`Timing` 三值(量过、回合时间戳、城补的):页面对后两种做同一件事——不画用时——第三个值只会逼每个读者多写一臂;要分辨时,`Call.outcome` 与答复内容已经说明那是城补的。

(b) `CommitAnswer.previous` 由账本折出;`CommitAnswer.parents` 在答问时读 git。

**理由**:「同一次 run 的上一个提交」是账本上两行的关系,折叠已经按 `seq` 看过每一行。父提交是提交对象的一部分,oid 就是对它的哈希,账本从未记过它。

**被否**:①在 `checkpoint_committed` 里记下父提交:检查点的写方要多记一个字段,评审落地的合并提交由另一处写,这个键出现之前的每一行都没有它,而这三种情形 git 都答得出;②`previous` 只带 oid:一段的另一端还要一个 `seq`,才能不扫账本就往回读调用;③读 git 在锁内做:一页五百个提交各开一次仓库,别的问题都在等这把锁。

(c) `Call.effect`、`Call.render` 照录 `tool_called` 在调用那一刻记下的登记。

**理由**:登记只在 run 的工具台上存在,读面够不到;记在调用那一行,读面读的是那一刻的事实(kernel D11)。

**被否**:读面按工具名匹配出呈现:每加一件工具都要改这个匹配,楼的 MCP 工具读面不认识。

(d) `Output.pinned` 读自结果里第一笔离窗账目,账目不另记调用 id。

**理由**:账目住在它裁掉的那个结果里,那个结果写在带着 `tool_use_id` 的 `tool_result` 行上,所以调用身份已经由行给出。

**被否**:给 `ResultOffloaded` 加 `tool_use_id`:同一个 id 的第二个家,而这份账目随结果整份进模型的请求字节,多出的键会让每一次被裁的调用多付这些 token。
-/

/-!
### 8-76 外壳读的四样:缓存写、退出码、运行策略、工作树

```rust
pub struct Used {
    // …既有字段…(`cached` 是缓存读,即 `kernel::ModelUsage::cache_read_tokens` 报了的数,没报时缺席,D35)
    #[serde(default)] pub cache_write: Option<Tokens>,   // 缓存写,即 `ModelUsage::cache_write_tokens` 报了的数,没报时缺席
}
pub struct Call {
    // …既有字段…
    #[serde(default)] pub exit_code: Option<i64>,        // exec 的命令以哪个码结束,照录配对的 `tool_result`
}
pub struct Opening {
    // …既有字段…
    #[serde(default)] pub policy: Option<kernel::RunPolicy>,   // `run_started` 记下的运行策略(kernel D12)
}
pub struct RoundsAnswer {
    // …既有字段…
    #[serde(default)] pub worktree: Option<String>,      // 这次 run 被借给的工作树的名字,照录 `worktree_opened`
}
```

- **`cache_write` 与 `cached` 分开答**:两者都是 `input` 的一部分,单价不同(`gateway::market` 的 `cache_read_price` 与 `cache_write_price`),合成一个数页面就算不出命中率之外的任何东西。读法仍只有 `used_in` 一处,经 `ModelUsage` 自己的反序列化,旧行的换算由它做;`used` 在场时 `cache_write` 恒在场,`Option` 只为一个旧城答出的帧。
- **`exit_code` 只照录结果里的 `exit_code` 键**:键的拼法与「有码才写码」的规则住 `runtime::tools::exec::outcome`;`runtime::pipeline::exit_code_in` 是这个键的读者,读面经它读。信号停下的命令、城没等到的命令、非 exec 的工具、还没答的调用都是 `None`——没有码就不画码,`-1` 那种编出来的码一概没有。
- **`policy` 照录 `run_started` 的 `policy` 键**:携 kernel 的 `RunPolicy` 本身(四个值:模式、写入限制、准入要求、落地策略),不在线上另立四个平铺字段(D4 同一条理)。写在这个键出现之前的行是 `None`。
- **`worktree` 是 `worktree_opened` 的 `name`**:只有借到自己的工作树的 run 才有这一行;在楼自己的树里干活的 run、窗口没读到那一行的长会话都是 `None`,与 `opening` 同一口径。名字不是路径:路径是一台机器的事实(`kernel::event::record::WorktreeOpened`)。
-/

/-! D13 外壳读的字段在本版之内增加,各自可缺,各读自账本上写下它的那一行

**决定**:§8-76、§8-77、§8-78 的六个字段都是 `Option`、带 `#[serde(default)]`,`WIRE_V` 不动;每个字段照录账本上写下它的那一行(或 kernel 的那个常量),读面不推断。提交的 B3 是宣告它的那一行自己的 BLAKE3(下一行的 `prev` 存的就是它),不是那一行的 `prev`。提交的 ISO 时刻不另设字段:它就是 `CommitAnswer.at`,页面用它画时刻的那一处拼成 ISO。

**理由**:自上一次推送以来 `WIRE_V` 已经进过一位(D1),同一个发布之内不再进位;可缺的字段让一个缓存着的旧页面与一座新城、一个新页面与一座旧城都还连得上,旧的一端读到的只是「没有」。`prev` 是上一行的摘要,拿它当本行的身份会让每个提交都指向它前面那条无关的记录。ISO 只是 `at` 的一种拼法,线上再带一份就是一个时刻两个家,而页面在时间轴上本来就要拼毫秒精度的时刻。

**被否**:①进位到 46——同一个发布里第二次进位,违背「一次发布一次进位」;②把 `cached` 改名为 `cache_read`——改名是改形,旧页面读到的是缺了一个必填字段;③线上带 `iso: String`——见上;④在 `checkpoint_committed` 里另记一个内容摘要——账本的链已经给每一行一个摘要,再记一个是同一行两个身份。

**重开参数**:下一次进位 `WIRE_V` 时,`Option` 可以收紧为必填(`used` 在场时 `cache_write`、`ConfigAnswer.first`)。
-/

/-!
### 8-79 对话头读的三样冻结事实:回复几时返回、派出时的强度、冻下的名字

```rust
pub struct Turn {
    // …既有字段…
    #[serde(default)] pub returned: Option<TimeMs>,   // 回答这个回合的 model_returned 自己那一刻;那一行没量过即 None
}
pub struct Opening {
    // …既有字段…
    #[serde(default)] pub effort: Option<kernel::Effort>,   // `run_started.effort`(`crates/kernel/Spec.lean` §8-85)
    #[serde(default)] pub names: Option<FrozenNames>,       // `run_started.naming` 那一版,从内容库读回
}
pub struct FrozenNames {
    pub mayor: Option<String>,   // 冻下的主 Agent 名字;缺席即那时没人起名,页面画语言表的默认名
}
```

- **`returned` 是答复那一行的 `moment`**(`EventRecord::moment`),与 `first_at` 来自同一行:回合里最后一条 `model_returned`。那一行没量过(账本版本 1)即 `None`,不拿信封的 `t` 顶替。输出速率是 `used.output ÷ (returned − first_at)`,线上不带这个商(D3 同一条理:页面手里已有三个数)。
- **`effort` 照录 `run_started.effort`**:缺席读作「这次派活没说强度」或「这一行早于这个键」,页面都不画强度。
- **`names` 是冻下的那一版,不是今天的。** 读面拿 `run_started.naming` 的摘要从城的内容库取回字节,经 `city::Naming::from_bytes` 读成名字;`naming` 缺席、内容库不再存着那一版、或字节读不成名字,`names` 都是 `None`,页面回退到地址与本地化的角色名(refrain §3-13),不用今天的名字顶替。
- 验收:accounting `views::rounds` 的 `a_turn_reports_when_its_reply_returned`、`the_opening_carries_the_effort_and_the_names_the_run_froze`。
-/

/-! D17 冻下的名字在城里读成类型,线上不带摘要

**决定**:`Opening.names` 携带读好的名字(`FrozenNames`),由读面在作答时从内容库读出;线上不带 `naming` 摘要,页面也不经 `Query::Content` 自己取那份字节。

**理由**:那份字节是 `city::Naming` 的私有编码,读它的规则住 `city`;页面若自己取字节再解析,就是同一种编码的第二个读者,而且读到的是无类型的 JSON。读面本来就在作答时开内容库(前缀、文档范围都这样读),多读一个对象不改锁的次序。

**被否**:①`Opening.naming: B3Hash`,页面再问 `Query::Content`:第二个读者、第二次往返,且内容库的答复是给人看的文本截断,不是给程序读的值;②作答时读今天的 `IdentityAnswer`:一个已经开始的 session 会被改名,与模型请求里冻下的名字不符。

**重开参数**:第二个需要冻下名字的答复出现时(比如 session 列表也要画名字),把读回挪到视图折叠里按 run 缓存,而不是每问一次读一次内容库。
-/

/-! D28 `Call` 带出整数微秒的耗时;skill 与 MCP 的使用各是一个查询

**决定**:`Call` 多一件 `#[serde(default)] took_us: Option<u64>`,照录配对的 `tool_result.took_us`(kernel D20);`Used` 多 `first_us` 与 `took_us` 两件,照录 `model_returned`。缺席时页面退回信封时刻之差,以毫秒显示。显示规则(不到 10 ms 用 µs,10 ms 及以上用 ms)在客户端一处实现。

查询面多三帧:`Query::SkillUsage { skill: Option<String> }` 答 `SkillUsageAnswer { skills: Vec<SkillUsageLine> }`,每行是一个 skill 的内容版本(摘要、时刻、写它的那一行)、最后一次审核(`skill_audited`,kernel D23)、最后一次使用、使用列表(run、居民、时刻、读的是哪一部分)与按天的计数,书架上从没被用过的 skill 也有一行,计数为零;`Query::McpUsage { server: Option<String> }` 答按服务器与工具折叠的同形一张表;`Query::UsageExport { what: UsageKind, format: ExportFormat }` 答 JSONL 或 CSV 的正文,`UsageKind { Skills, Mcp }`、`ExportFormat { Jsonl, Csv }`。三帧都是 `VerbClass::Read`。审核本身由城在落位之后做(city D19),不另开命令帧;装 skill 的门是 `InstallSkill`(D32),每一行算什么、怎样导出在 D33。它们与本版其他改形同一次 `WIRE_V` 进位(D22)。

**理由**:使用早已在 `tool_called` 里(kernel D23),视图只是按 skill 折叠;折叠住 accounting,线上只给答复的形状。导出走查询而不是让页面拼文件,是因为折叠规则只在 Rust 一处。

**被否**:①页面拉全部 `tool_called` 自己折:浏览器里多一份折叠规则;②导出写进城目录的文件:城目录多一份没人清理的派生物。

**重开参数**:使用列表大到一次答复装不下时,加分页游标。
-/

/-! D33 一次 skill 使用与一次 MCP 使用各从哪一行折出,按天怎么数,导出的每一行长什么样

**决定**:折叠是 accounting 里一个读账本的纯函数,`SkillUsage`、`McpUsage` 与 `UsageExport` 三帧读同一个函数的结果(D28)。

```rust
// wire::answer::usage
pub struct SkillUsageAnswer { pub skills: Vec<SkillUsageLine> }                 // 名字序
pub struct SkillUsageLine { name: String, held: Vec<HeldSkill>, versions: Vec<SkillVersion>, uses: Vec<SkillUse>, per_day: Vec<DayCount> }
pub struct HeldSkill { shelf: SkillShelf, digest: B3Hash, audit: SkillAudit }   // 每格书架一项;书架上没有了为空
#[serde(tag = "state")] pub enum SkillAudit { Unaudited, Audited { verdict: AuditVerdict, at: Seq }, Stale { audited: B3Hash } }
pub struct SkillVersion { digest: B3Hash, seq: Seq, at: TimeMs, run: RunId, #[serde(default)] author: VersionAuthor }   // 账上第一次出现这一版的那一行
#[serde(tag = "by")] pub enum VersionAuthor { Shelved { seq: Seq, from: ShelvedFrom }, OutsideShelf, #[default] Unrecorded }   // 谁把这一版放上书架;ShelvedFrom 是 kernel 的
pub struct SkillUse { run: RunId, resident: Option<Address>, seq: Seq, at: TimeMs, part: String, digest: B3Hash, outcome: UseOutcome }
pub enum UseOutcome { Ok, Failed, Unknown }
pub struct DayCount { day: String, count: u32 }                                  // `YYYY-MM-DD`,旧日在前
pub struct McpUsageAnswer { servers: Vec<McpServerUsage> }                       // 服务器序,`None` 在最后
pub struct McpServerUsage { server: Option<String>, configured: bool, tools: Vec<McpToolUsage> }
pub struct McpToolUsage { tool: String, uses: Vec<McpUse>, per_day: Vec<DayCount> }
pub struct McpUse { run: RunId, resident: Option<Address>, seq: Seq, at: TimeMs, outcome: UseOutcome }
pub enum UsageKind { Skills, Mcp }
pub enum ExportFormat { Jsonl, Csv }
pub struct UsageExportAnswer { what: UsageKind, format: ExportFormat, body: String }
```

- **内容版本**是账上第一次以某个摘要说出这件 skill 的那一行:一行 `skill_shelved`(kernel D23,城经上架的门落下这一版),或一个钉住它的 `run_started`;`seq`、时刻与 run 是那一行的。`author` 说谁放上去的:有一行 `skill_shelved` 带这个摘要时是 `Shelved`,携那一行的 `seq` 与它的 `source`;没有而这个名字在钉住它的那一刻之前已有过 `skill_shelved` 时是 `OutsideShelf`——内容在城的门之外被改过,页面写「在架外」;这个名字在那一刻之前从没有过 `skill_shelved` 时是 `Unrecorded`——这一版在城开始记上架之前就在架上。判法只看账本次序,所以重放答同一张表。
- **审核**按每格书架此刻的摘要,由 `city::library::audit_state` 读账上这个名字的每一行 `skill_audited` 判出;`Stale` 就是「内容改过,请再审一次」。
- **一次 skill 使用**是一行 `tool_called`,它所在的 run 的 `run_started` 钉住了一件 skill(名字与哈希,`crates/runtime/Spec.lean` §8-11),而这一行是:`describe`,问的就是那个名字(部分记作 `guide`);或 `read`,路径是那个名字(部分记作 `SKILL.md`)或 `<名字>/<相对路径>`(部分记作那个相对路径)。认的是「这个 run 钉住了这个名字」,不从路径的写法猜:一个恰好与某件 skill 同名的普通文件,在没钉住它的 run 里不是一次使用。使用的内容版本是钉住时的哈希,所以 skill 改过之后,旧的使用仍指向它当时读到的那一版。
- **一次 MCP 使用**是一行 `tool_called`,它记下的 `effect` 是 `Connector { label }`:服务器就是那个 `label`,工具是行上的名字去掉 `<label>_` 前缀。这个 `effect` 是 `agent_protocols::mcp::tools` 注册工具时写下、随行进账本的,所以它说的是调用那一刻的服务器,配置改过、服务器删掉之后仍然对,答问也不必握手。`call` 一行指名的工具按第一个 `_` 拆:`kernel::ServerLabel` 只收小写字母与数字,服务器名里没有 `_`;拆出的头是已知的服务器名(账上见过的与此刻配置的)才归属,因为城自己的工具名里也有 `_`。头不是已知服务器名的,归在 `server: None` 下,页面写作「已移除」并给出完整工具名(accounting D49)。
- **结果**也算:每次使用带上配对的 `tool_result` 是否成功;没有配对行(run 被杀)记作未知。
- **按天数**:按信封时刻的 UTC 日历日分桶,`day` 写作 `YYYY-MM-DD`;导出带每一行的毫秒时刻,要按本地时区重新分桶的分析自己做。
- **从没用过的**:每一格书架(城库、每栋楼的楼架、城外书架)上扫到的每一件 skill 都有一行,没被用过的计数为零、使用列表为空;MCP 是每栋楼的配置此刻列出的每个服务器,没用过的服务器工具表为空:它此刻提供哪些工具要握一次手,那是 `McpHealth` 的问题。
- **导出**:每次使用一行,列序固定:`kind`(`skill`|`mcp`)、`name`、`server`、`part`、`run`、`resident`(房间地址)、`seq`、`at_ms`、`digest`、`outcome`(`ok`|`failed`|`unknown`)。JSONL 每行一个对象、键即列名、缺席的值写 `null`;CSV 首行是列名,按 RFC 4180 加引号,行尾 `\r\n`,缺席的值为空。正文以 UTF-8 回答,页面经浏览器的下载交给 User,城目录里不写文件(D28)。

**理由**:一次使用必须能在重放时由同一个函数算出同一张表,所以只读账本里已有的行;用 `run_started` 钉住的名字来认,是因为那是 run 自己说过「我能读这几件」的唯一一处,路径的写法会把巧合当成使用。按 UTC 分桶,是因为折叠在 Rust 一处、在城里算,城不知道页面在哪个时区,而导出的毫秒时刻让任何分桶都做得出来。

**被否**:①读文件系统的访问时间:Windows、macOS 与 Linux 上 atime 的语义各不相同,Linux 默认 `relatime` 一天只更新一次,而且它说不出是哪个 run;②按页面所在时区分桶:同一个城在两台设备上答两张表;③任何 `<x>_<y>` 都按第一个 `_` 归给服务器 `x`:城自己的工具名(`call` 也能指名它们)会被当成一个叫 `x` 的服务器;④按此刻的 MCP 登记认服务器:要握手才知道工具名,服务器删掉之后它的使用全成了「已移除」。

**重开参数**:一次查询在一座大城里要读的行多到答复超过 100 ms 时,把折叠改成随账本增量维护;User 要求按本地日历日看时,加一个由页面送来的 UTC 偏移参数。

**三个平台**:折叠只读账本,三个平台相同;CSV 的 `\r\n` 是 RFC 4180 的规定,与平台无关。
-/

/-! D35 `Used` 的两个缓存数,provider 没报时缺席

**决定**:`Used.cached` 是 `#[serde(default)] Option<Tokens>`,与 `cache_write` 同形:`kernel::CacheCount::Reported(n)` 读成 `Some(n)`,`Unreported` 读成 `None`(kernel D36)。`cache_write` 的 `None` 原来只表示「旧城的帧」,现在同时表示「provider 没报」,两者对页面是同一件事:不知道。页面在 `None` 处写 `lang.json` 里的「未知」字样,而不是 0 或 0%。这一改形与本版其他改形同一次 `WIRE_V` 进位(D22)。三个平台上形状相同。

**理由**:线上的 0 页面只能照写成「命中 0%」,而一个从不报缓存的 provider 的命中率是不知道,不是零;把这个区别在线上丢掉,kernel 记下它就没有用处。

**被否**:①线上照旧写 0、另加一个「报了没有」的布尔:读者照样会先读到那个 0;②`cached` 保持必有、只让 `cache_write` 可缺:缓存读正是页面算命中率的那个数。

**重开参数**:provider 开始分别报告「没有缓存功能」与「有缓存但这次没报」时,`None` 拆成两臂。
-/

/-! D34 一个停在同步 `send` 上的 run,在 `RunSummary` 上多一个 `waiting`;`send` 的 `wait` 是工具参数,不是线上帧

**决定**:`RunSummary` 多一件 `#[serde(default)] waiting: Option<Waiting>`,`Waiting { on: Address, until: TimeMs }`:这个 run 最近一行 `signal_wait_started`(kernel D32)还没有配对的 `signal_wait_ended` 时,`on` 是它等的房间,`until` 是那一行的 `deadline_ms`——城的注入时钟读出的 epoch 毫秒,本来就是墙钟时刻,所以不必换算;配对行到了、或这次跑冻结了,就缺席。这一折是 `storage::hot` 的(storage D29,`frozen_run_never_waits`),`accounting` 的 `summarize` 只搬运。`RunSummary` 自 `answer.rs` 分出到 `answer/run_summary.rs`,`Answer::Run` 装的是 `Box<RunSummary>`:线上的形状不变(serde 对 `Box` 透明),只是这一臂不再把整个 `Answer` 撑大。直播的页面从事件流里自己折同一对种类,查询答的是没看过流的读者。`send` 的同步开关是 `send` 工具的输入 `wait: bool`(缺省 `false`,collab D9),走模型的工具调用,不经过线上的任何一帧;线上只多这一件字段,与本版其他改形同一次 `WIRE_V` 进位(D22)。

**理由**:一个停着等回信的 run 与一个卡住的 run 在 `last_kind` 上看起来一样,页面与 watchdog 都要能把两者分开,所以等待要成为 run 的状态的一部分;它从账本的两行读出,重放与远程设备看到的是同一个状态。`until` 用墙钟时刻而不是剩余毫秒,是因为答复会被缓存与转发,剩余时间一离开城就不对了。

**被否**:①一个新的 `RunWaiting` 帧:等待是 run 的一个状态,不是一类消息,单开一帧的页面要把两路拼回一个 run;②把 `wait` 放进一个 User 发的命令:同步与否是发信的那个 run 的选择(D89 第 1 条:C 是 `send` 上的开关),User 不在这条路上。

**重开参数**:一个 run 可以同时等两个房间时,`waiting` 改成一张表。
-/

/-! D36 一条到达的话按 `SignalId` 配回它的发出:说话的是谁、说了什么、几时到

```rust
pub enum Note {
    // …
    Arrived {
        from: Option<String>,   // steer:source;signal:配上的 signal_enqueued 的 from;配不上为 None
        said: Option<String>,   // steer:text;signal:配上的那一行载荷里的 text;配不上为 None
        by: Speaker,            // User、City 或 Resident(D41 说拼法的出处)
        t: TimeMs,              // 这一行(steer_received/signal_consumed)的 t
        handback: Option<HandbackNote>,   // D38
        at: Seq,
    },
}
#[serde(rename_all = "snake_case")]
pub enum Speaker { User, City, Resident }
```

**决定**:`signal_consumed` 的载荷只有 `{id, by}`(kernel `SignalConsumed`),话本身在发信那一行 `signal_enqueued` 里,而那一行记在发信者的 run 下,不在收信者的会话里。所以 `wire::note_of` 读到 `signal_consumed` 时只读出 `by: Resident`、`t` 与 `at`,`from` 与 `said` 留 `None`;服务端的 rounds 折叠(`accounting::views::rounds::paired`)按 `SignalId` 在账本里从这一行往前找配对的 `signal_enqueued`,找到了才填 `from`(发信者)与 `said`(载荷的 `text`)。往前找有界:整本账里最多 `SENDING_REACH`(4096)行,界外配不上就留 `None`,页面写「不知道」而不猜。`steer_received` 一行自带 `source` 与 `text`(kernel `SteerReceived`),直接读;读不回这个形状的载荷是一条 `Note::Unreadable`。`by` 按 steer 的 `source` 分三臂(D41):User 入口写下的那个拼法是 `User`,城市自己的话(策略变更、同步等待超时)是 `City`,居民的 steer(`@<room> …`)与每一条 signal 都是 `Resident`,因为 User 与城市只经 steer 说话。

**理由**:原来的读法在 `signal_consumed` 上读 `source`/`from`/`text` 三个它根本不带的键,于是 `said` 恒为空、`from` 落到这一行的作者——也就是收信者自己:页面上每一条到达的话都像是收信者对自己说了一句空话。

**被否**:①让 kernel 在 `signal_consumed` 里再抄一遍 `from` 与 `text`:同一句话在账本里存两份,kernel `SignalConsumed` 的文档明说历史不需要它两次;②从 `SignalId` 的字面(`<run>-s<n>`)解析出发信者的 run 再去读那个 run:id 的拼法是 collab 的铸造约定,不是一条文法,交接(`handback-<run>`)与阻塞通知已经用了别的拼法。

**重开参数**:一座城里一条信从发出到被取走之间隔的账本行常常超过 `SENDING_REACH` 时,给索引加一张按 `SignalId` 的表。

**三个平台**:只读账本、只折叠,Windows、macOS、Linux 相同。
-/

/-! D37 一次同步 `send` 的等待是回合上的一条 note

```rust
pub enum Note {
    // …
    AwaitingReply {
        on: Address,                 // signal_wait_started 的 on
        until: TimeMs,               // 它的 deadline_ms(D34:本来就是墙钟毫秒)
        t: TimeMs,                   // 这一行的 t
        ended: Option<ReplyEnded>,   // 同一 run 里按 signal 配上的 signal_wait_ended
        at: Seq,
    },
}
pub struct ReplyEnded { pub by: ReplyEnd, pub t: TimeMs }
#[serde(rename_all = "snake_case")]
pub enum ReplyEnd { Reply, Timeout, Left }
```

**决定**:`signal_wait_started` 出一条 `AwaitingReply`;`signal_wait_ended` 不出 note,折叠在同一会话里按 `signal` 把它配到那条等待上,写 `ended`。三臂与 kernel `WaitEnd` 一一对应(kernel D32 的 `waits_end`),回信那一臂不带回信的 id:回信本身是同一回合里的一条 `Arrived`。窗口里没有结束行为 `None`:还在等,或者结束落在窗口外。

**理由**:一个停在 `send` 上的回合,页面原来只看得见一次调用后什么都没发生;等的是谁、等到几时、怎么结束的,账本里都有。

**被否**:把 `WaitEnd` 原样搬上线:它的 `Reply` 臂带 `SignalId`,而线上的 `Note` 有 JSON schema,kernel 的这个类型没有;页面也用不上那个 id。

**三个平台**:相同。
-/

/-! D38 一条交接(handback)到达时说它完成了还是停了,以及是哪一次会话

```rust
pub enum HandbackNote {
    Finished { verified_by: String },
    Stopped { because: String },
}
```

**决定**:交接的标记由 collab 写在 signal 载荷里(`handback` 标签,collab `Handback::signal`),唯一的逆读是 `collab::Handback::from_signal`。rounds 折叠在 D36 配上 `signal_enqueued` 之后,用这个逆读读那条 signal:是交接就填 `Arrived.handback`,不是就 `None`。是哪一次会话由 `Arrived.session` 说(D43):派活台把交接记在子会话的 run 下(`accounting::worker::dispatching::handback`),所以它就是子会话。`said` 仍是载荷的 `text`。

**理由**:父会话收到的交接原来与普通一句话没有区别;完成与停下、谁验的、为什么停,是父会话接下来要做什么的依据。

**被否**:在线上再读一遍 `handback` 标签:那是 collab 写法的第二份读法。

**三个平台**:相同。
-/

/-! D39 Inbox的一行带上那条话的第一行

```rust
pub struct SignalLine {
    // …既有字段…
    #[serde(default)]
    pub first_line: Option<String>,   // 载荷 text 的第一行;没有 text 为 None
}
```

**决定**:`InboxAnswer.waiting` 的每一行带 `first_line`:发信那一行载荷里 `text` 的第一行(按 `\n` 切,去掉行尾 `\r`),载荷没有 `text` 时为 `None`。整段话不上线:Inbox是「有什么在等」,读全文是打开那条信的事。

**理由**:只有 id、种类、发信者与时刻的一行,User 看不出要不要先处理它。

**重开参数**:页面要按字数而不是按行截断时,改成一个字符上限。

**三个平台**:`\r\n` 与 `\n` 都按一行切,三个平台相同。

D36 至 D39 与本版其他改形同一次 `WIRE_V` 进位(D22):51 → 52。
-/

/-! D41 steer 的说话人拼法只在 kernel 定义一次,wire 读它,不抄

**决定**:`steer_received.source` 里 User 与城市的两个拼法是 kernel 的 `SteerReceived::PERSON_SOURCE`(`user`)与 `SteerReceived::CITY_SOURCE`(`city`)。`runtime::conversation::Speaker::recorded` 写它们、`from_recorded` 读回它们,`wire::note_of` 也读它们,三处都不再写字面量;wire 没有自己的常量。`Speaker` 因此是三臂:`User`、`City`、`Resident`,城市的话不再算作一个叫 `city` 的居民。

**理由**:wire 只依赖 kernel 与 documents(ARCHITECTURE.md 的 depmap),读不到 runtime;原先 wire 里抄了一份 `"user"`,runtime 改拼法时 wire 照旧编译、照旧通过,只是此后每一句 User 的话都被画成居民的。两边都依赖 kernel,而这两个拼法是账本行 `steer_received` 的一部分,所以出处放在定义这一行的 kernel 记录上。

**被否**:①让 wire 依赖 runtime:把整个 runtime 拉进一个浏览器与服务端共用的线协议 crate,只为两个字符串;②在 accounting 折叠时把说话人算好再交给 wire:`note_of` 的另一个调用者(wire 自己的测试与客户端的同一条规则)就拿不到它。

**重开参数**:`source` 的整套文法(信封属性)也要在 wire 里读回时,把 `from_recorded` 的解析整体移进 kernel。

**三个平台**:只有字符串比较,Windows、macOS、Linux 相同。
-/

/-! D42 一次 `send` 调用带上它那封信落在哪里

```rust
pub struct Call {
    // …既有字段…
    #[serde(default)]
    pub landing: Option<kernel::event::record::Landing>,   // delivered|queued|knocked;配不上为 None
}
```

**决定**:rounds 折叠(`accounting::views::rounds::paired`)在同一会话的行里配三样:`signal` 工具的一次 `send` 调用(`tool_called` 的 `name` 是 `signal`、`args.action` 是 `send`),它答复之前、同一个 run 写下的 `signal_enqueued`(收信房间与调用参数的 `to` 相同、还没配过的那一次调用里最早的一个),以及按 `SignalId` 配上的 `signal_landed`(kernel `spec/Event/Record.lean` D38;三条投递路径都把这一行记在发信 run 下,所以它就在同一会话的窗口里)。三样都配上,`landing` 照录那一行的三臂;`delegate` 与别的工具恒为 `None`。kernel 的 `Landing` 直接上线(它的三臂是线上的拼法),不在 wire 另立一个同形的枚举。

`None` 有三种来由,页面都画工具自己答的东西(`send` 答的 `delivered` 与同步时的 `waiting`),不猜:这一行出现之前写下的账本(「没有记下」),落点行落在窗口外,以及调用还没答复、信还没投。

**理由**:工具答复的那一刻还不知道信落在哪里(collab D17),页面要在发信那一行上画「投进正在跑的 run」「排队」「敲门开了新 run」,就要读投递那一刻写下的那一行;按 `SignalId` 配,与 D36 配到达的话是同一个键。

**被否**:①页面自己拉 `signal_landed` 再配:浏览器里多一份配对规则;②按调用与信在窗口里的先后位置配:同一个回合并行的两次 `send` 会配错,按收信房间配才说得清是哪一次。

**重开参数**:一次调用可以发出几封信(广播)时,`landing` 改成一张按收信房间的表。

**三个平台**:只读账本、只折叠,Windows、macOS、Linux 相同。
-/

/-! D43 一条到达的信说出它的种类与发信的那一次会话

```rust
pub enum Note {
    // …
    Arrived {
        // …D36 的字段…
        kind: Option<SignalKind>,   // 配上的 signal_enqueued 的种类;steer 与配不上为 None
        session: Option<RunId>,     // 配上的 signal_enqueued 记在哪个 run 下;steer 与配不上为 None
    },
}
pub enum HandbackNote {
    Finished { verified_by: String },
    Stopped { because: String },
}
```

**决定**:rounds 折叠在 D36 配上 `signal_enqueued` 之后,从那一行再读两样:信的种类(collab `Signal::kind`,线上是种类表的那个词)与那一行的 `run`,也就是发信的那一次会话(relay 路径是调用 `send` 的 run,交接是落地的子 run,落定投递是正在落定的 run,kernel D38)。交接的会话就是这个 `session`,所以 `HandbackNote` 不再另带 `session`:同一个事实只有 `Arrived.session` 一个家。steer 自带说话人(D36、D41),不是一封信,两样都为 `None`;配不上时也为 `None`,页面写「信」并链到发信房间,不猜会话。

**理由**:信件卡原来只能写「信」、只能链到发信的房间(client D86 的重开参数):线上没有种类,也没有发信 run。两样都在 `signal_enqueued` 那一行上,读面配那一行时一起读出,不多一次读盘。

**被否**:①从 `SignalId` 的字面解析出发信 run:id 的拼法是 collab 的铸造约定(D36 被否①);②`HandbackNote` 保留 `session` 与 `Arrived.session` 并存:两份拷贝今天相同,但没有东西把它们绑在一起。

**三个平台**:相同。

D42、D43 与本波其他改形同一次 `WIRE_V` 进位(D22)。
-/

/-! D48 每个 shell 解释器的调用数与按类的失败数是一个查询,与 skill、MCP 的使用同一遍折叠

```rust
// Query
Shells,                                   // VerbClass::Read
// Answer
Shells(Box<ShellsAnswer>),
pub struct ShellsAnswer { pub interpreters: Vec<ShellCalls> }        // 按解释器名排序
pub struct ShellCalls {
    pub interpreter: String,              // `result.interpreter`;没有这个字段的旧记录记作 `system`
    pub calls: u64,                       // 有退出码的 shell 行数
    pub failures: BTreeMap<String, u64>,  // 只列出现过的类;键是 `runtime::FailureClass::as_str`
}
```

**决定**:`Query::Shells` 答整本账本上每个 shell 解释器的读数。折叠是 `runtime::ShellTally`(`crates/runtime/spec/Tools/Exec.lean` D30),线上的答面与 `sprawling view --shells` 的每一行同形:同一份折叠,两种写法。读面是 accounting 的使用折叠(`views::usage::Usage`):它本来就在快照放开之后从头读一遍账本、看每一条 `tool_result`,多交一份给 `ShellTally`,不另读一遍。账本读不了时答 `Unavailable` 并带原因(D47)。页面把这张表画在工具(MCP)页的使用表旁边,类名按 `lang.json` 措辞,认不得的类原样写出。

**理由**:pwsh 7 是否该成为默认(runtime D30)要看这份读数;只有命令行能读时,用浏览器的 User 看不到它。

**被否**:①一个枚举做 `failures` 的键:类的拼法只由 `runtime::FailureClass` 定义,wire 不依赖 runtime,再写一份枚举就是第二个权威;②热视图逐条折叠:读数只在页面打开时要,常驻折叠让每一条记录都付代价。

**三个平台**:折叠相同;解释器的名字随平台(Windows 上常见 `system` 即 cmd、`pwsh`,macOS 与 Linux 上 `system` 即 sh)。

与 D47 同一次 `WIRE_V` 进位。
-/