sprawling-wire 0.0.9

The process boundary of a sprawling city: the frames its clients speak, and the server that answers them.
Documentation
-- 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.wire.spec.Command.Kind

/-!
# wire::command

规定 `command`、`command::no_secret`、`command::wire`(`crates/wire/src/` 下同名的文件)。客户端能让城做的每一件事,与套接字拼不出的那一帧。本文件是 `crates/wire/Spec.lean` 的一个分部;下面每一节保留它在 wire 规格里的标签 §8-n,别处引作 `crates/wire/Spec.lean §8-n`,决定引作 `wire D<n>`。
-/

/-!
### 8-7 一次会话有名字,而名字就是它干活的那个房间

```rust
WireCommand::Dispatch { addr, task, goal, policy, idem, session: Option<SessionName>, effort, … }
// SessionName 住 kernel:一个构造点,内容即一个地址段
```

- **不加第四层**:每个 Run 一个目录会切断 Handoff 的连续性,而连续性正是一个 session 之所以是 session 的东西;`collab` 整套也都建立在「几个居民在同一栋楼里不互相踩」上。
- **地址给楼,名字给会话**:`addr` 可以只是一栋楼;`session` 在它底下开一个房间(`city::room::open`)。重名加数字后缀(`refactor`、`refactor-2`),而不是拒绝——一个人连着开两次同名会话是常事。
- **向已有房间派活即继续那条会话**:它的 `Handoff.md` 与 `JOB.md` 就是连续性。「回复某个 session」因此不需要新概念。
- **`RunId` 不变**:名字是房间的标签,不是 Run 的。一个房间一生中的多次 Run 合起来才是一条会话。
- **`SessionName` 是值类型而非 `String`**:它必须能当一个地址段(无 `/`、无 `.`/`..`、无控制字符、非空、不叫 `.sprawling`),否则一个人输入的字会变成一条路径。
-/

/-!
### 8-18b 派活帧不再携上限

```rust
Dispatch { addr, task, goal, policy, idem, session, effort }   // 删去 budget: BudgetCap
```

**没有人能在一件事跑之前给它定价**,所以说出「跑这件事」的那条帧不带上限。刹车只留一个:`Halt` 关掉一个范围并终止该范围里已经起来的后台成员(`runtime::backlog` 使这句话为真)。kernel 的 `BudgetCap` 类型 及其判定面随之删除(`crates/kernel/Spec.lean` §8-12),`wire` 的 kernel 再导出列表因此少一项 `BudgetCap`——**这是公开面变更**,`web` 与 `sprawling` 两份基线同变更集重生。

- **`BudgetUse` 留在再导出列表里**:成本页读它,五路归因报它。**报告花了多少**与**事前不许花**是两件事,此处只删后者。
- **`Dispatch` 的 reach 不变**(§19-2 仍是 `client`):删的是一个字段,不是一个动词。
- **旧客户端**:`WIRE_V` 进位后在握手期被明确拒绝,所以一条仍然写着 `budget` 的帧到不了服务端;服务端也不再有那个字段可读。
-/

/-!
### 8-26 `ConfigureBuilding` 长出 `desktop`:一栋楼的桌面白名单走同一条帧

```rust
ConfigureBuilding { addr: Address, sandbox: Option<SandboxLimits>, mcp: Option<Vec<McpServer>>,
                    desktop: Option<String>, idem: IdemKey },
```

- **不新起一条命令,也不给 `GovernedDocument` 加变体**。这条帧问的本来就是「这栋楼的 runs 够得到什么」——沙箱、外部服务器、运行中的机器上的哪些窗口,是同一个问题的三面;各自可缺省,缺省即不动那一面。而 `GovernedDocument` 是**城**的三份文件(`<city>/.sprawling/`),桌面白名单是**楼**的(`<building>/.sprawling/`):把楼级路径塞进一个按 city_root 取路径的枚举里,会让那个枚举需要一个只有部分变体用得上的参数(`crates/city/Spec.lean` §8-26 已写下这条)。
- **`desktop` 是文本而不是解析过的值**。读它的那台 server 是它语法的权威,且 fail closed——读不出来的文件关成全拒。城这一侧再抄一份解析器就是第二个权威,而两个权威里迟早有一个把某份文件读成另一种意思。城只保证「写进去的字节就是人给的字节」。
- **`building_configured` 载荷第三个布尔位 `desktop`**:与 `sandbox`/`mcp` 同形,说的是「这一面被写过」而不是写了什么。`city::Written` 把三个布尔收成一个值——一个调用点写 `(true, false, true)` 说不出哪一位是哪一面。
- **页面读它用 `Query::Document`**:`<building>/.sprawling/DESKTOP.toml`。那条查询本来就明说保留子树在这里可读,理由是「治理一栋楼的东西正是这个视图要给人看的」。
-/

/-!
### 8-44 删掉两个拼得出、执行不了的动词

`Command::Takeover` 与 `Command::Rollback` 删除;`COMMAND_NAMES` 从 30 到 28,schema 哈希随之变(golden 见 §2),故同集进位 35→36。

- **它们从来没有执行者**:装配层自上线起就对这两帧以 `not_built` 作答(§19 记的那次失效的三个动词之二),而客户端按 §19-2 的门要求不画它们。一条线上拼得出、任何东西都执行不了的命令,是对客户端的假承诺;删帧之后旧页面在握手期被明确拒绝(§10),而不是拿到一个永远失败的按钮。
- **规则的家在 kernel D2**(回滚=分支+git 还原),含理由、被否方案与重开参数;本节只记线的形状,不复述第二份。
- **`control` 与 reach 表同集缩面**:`Intervention` 少两臂,只剩 Steer/Cancel/Halt 加 Release 返程(§8-4);「中断一个活着的 Run 恒以 Handoff 收尾」的中断动词随之只剩 Steer/Cancel;§19-2 删两行。
- **事件词同集删二**(`crates/kernel/Spec.lean` §8-4 表):`rollback_applied`/`takeover_started` 无生产者,账本从未写下过携它们的行,故已写历史的字节与逐字节重放不受影响;携这两个词的行今天在读侧入口拒(`E_INVALID_ARGS`,kernel `parse_line` 的既有码)。
- **两帧的拒因不再是 `not_built`**:`not_built` 只留给仍在文法里、等待执行者的动词;对这两帧,字节在解码处就不再是命令(§8-37 的 `E_WIRE_MISMATCH` 口径不变)。
-/

/-!
### 8-45 `ConfigureBuilding` 长出第二道阈值的面

`Command::ConfigureBuilding` 多一个可选字段 `context_second_threshold: Option<u64>`,`Query::Config` 的回答多一个 `second: Option<SettledSecond>`;schema 哈希随之变,故同集进位 36→37。

- **一个字段而不是一条新帧**:那条帧问的就是「这栋楼的 runs 按什么规矩来」——沙箱、外部服务器、桌面白名单与第二道阈值是同一个问题的四面;为它单立一帧会给同一个问题两个写入口。
- **线上传裸 `u64`,域的判定不在这条帧上**:合法域与拒因句式是 `kernel::config::SecondThreshold` 的一个构造点(31–90,拒因带域),在这条帧上再判一次就是同一个规则的第二个家;越界值在解析点拒,拒因随答复回到设置页。
- **回答带 `SettledSecond { percent, from }`**:`from` 是说出这个值的那一级文件,理由与 `SettledEffort` 同(`Query::Config` 回答表那一条);缺省不是缺口,而是城一级默认值在生效,页面据此把一个空框画成默认值。
- **线的背面是同一件事**:写入经 `city::write_second_threshold` 落到那一级的 `CONFIG.toml` 的 `[context] second_threshold`,与 `write_effort` 同一扇门(读—改—写整份文件,别人的键原样保留);`building_configured` 的载荷因此从三面到四面(`Written::context`)。
- **`WIRE_V` 的路不单独走**:36→37 记的是这一次面变——给既有命名帧加字段是「语法换形而名字没换」那一类(字段名不进 `COMMAND_NAMES`),与 §8-44 的 35→36 无关;两次都在 §8-1 的 golden 里看得见。
-/

/-!
### 8-47c `RestoreDiscard`:回收站的一行按它自己的路回去

```rust
Command::RestoreDiscard { restoration: Restoration, idem: IdemKey }
```

- **帧里带的是那一行的 `restoration` 原样**,不是路径。`DiscardView` 的每一行已经带着它自己的回去的路(`Restoration`),页面把它交回来;城要是改成按路径去查,就得在写线程上为一次还原把整份历史再折一遍,而那一行本来就在页面手里。一个伪造的 `Tracked` 能做到的最多是把城自己历史里的某个文件写回城里它自己的路径——`Address` 爬不出城,`restore` 拒绝 `Address::is_reserved` 的地址,所以受保护的元数据子树(`.sprawling/`、`.git/`)不经这条路写入。
- **三种路,三个回答**:`Tracked(file:<addr>@<oid>)` 由装配层经 `storage::Checkpoint::restore` 写回,再追加 `discard_restored`(载荷与它关掉的那条 `file_discarded` 同形:`paths` 与 `restoration`),`DiscardView` 据此把那一行标成已还原;`Interred` 答 `E_INVALID_ARGS`,recovery 说从内容仓库取回尚未接线;`Rebuildable` 答 `E_INVALID_ARGS`,recovery 就是那条重建的理由——没有存着的字节可放回去。带 `range` 的定位符同样被拒:还原的是整个文件。
- **被否:`RestoreDiscard { path }`**。见第一条;另外,同一路径可以被丢两次,只给路径说不清要回到哪一次。
-/

/-!
### 8-48c `Dispatch` 可以点名这一次的模型

```rust
Dispatch { addr, task, goal, policy, idem, session, effort, model: Option<String> }
```

- **`model` 是城已登记的一个模型 id**,登记在哪个 tag 下都行;`None` 取房间自己那层已冻结的模型,房间尚未冻结时取 `main` tag 的模型。装配按这个 id 在簿子里找到那一条登记,连同它的端点与窗口一起用,保密楼「只用回环端点」的检查照旧由 `ModelBook::select` 做(`crates/sprawling/Spec.lean` §8-10)。
- **被否:借 `SelectModel` 换 tag 再派活**。`SelectModel` 改的是整座城的配置,会在同时跑着的别人的 run 底下换模型;一次派活的选择只该属于这一次派活。
- **被否:接受任意 id,在 `main` 的端点上直接调用**。窗口与输出上限是登记时说出的,未登记的 id 没有这两个数,上下文提醒只能量一个没人给过的数。
- **`WIRE_V` 38→39**:给既有命名帧加字段是「语法换形而名字没换」那一类,§8-1 的 golden 随之变;`client/src/wire.ts` 由 `cargo xtask wire-ts --write` 同集重生成。
-/

/-!
### 8-57 派活带出整份运行策略:`Dispatch.policy`

```rust
Dispatch { addr, task, goal, policy: kernel::RunPolicy, idem, session, effort, model }
// RunPolicy { mode: Mode, write: WriteLimit, admit: AdmissionRequirement, landing: LandingPolicy }
// 线上:{"mode":"work","write":"create","admit":"tested","landing":"experiment"}
```

- **一个字段,四个必填的键**:`mode`(`chat|work`)、`write`(`full|create`)、`admit`(`standing|tested|contract_kept|double_validated`)、`landing`(`ordinary|experiment`)。值集与拼法只住 kernel(`crates/kernel/Spec.lean` §8-77、§8-78),本 crate 再导出 `RunPolicy`、`Mode`、`WriteLimit`、`AdmissionRequirement`、`LandingPolicy` 五个名字,页面按 `wire.ts` 里生成的字面量拼。缺一个键、或一个认不出的词,帧在反序列化处即拒,不落成默认值。
- **页面怎么选**:写域选择给 `write` 的两项(普通 `full`、只读可新建 `create`);`/admit tested|contract|double` 依次填 `tested`、`contract_kept`、`double_validated`,不说时填 `standing`;试验填 `landing: "experiment"`;计划经 `/plan` 进入固定的 SDD 工作流,帧上是 `mode: "work"`。这些控件归客户端的展开面(client/Spec.lean §4-41),本节只定帧。
- **城自己派的活不经这个帧**:计划、日程、来信与编辑器经 ACP 派来的活由装配层取 `RunPolicy::of(Mode::Work)`;委派与敲门继承说话那一方的整份策略(`crates/sprawling/Spec.lean` §8-133)。
- **账上读得到**:装配层把收到的策略原样写进这次 run 的 `run_started.policy`,所以一次派活选了什么,回放与 playback 从账本读,不从线上猜。
- **`WIRE_V` 不另进位**:`Dispatch` 的名字没变而形状变了,这正是 D1 说的改形,与本批其余改形共用 45。
-/

/-! D4 `Dispatch` 带一个 `policy`,而不是四个平铺的字段

**决定**:运行策略在帧上是一个嵌套的值 `policy`,形状就是 `kernel::RunPolicy`;`mode` 不再是 `Dispatch` 的顶层字段(§8-57)。

**理由**:四个值总是一起走——线上、账本的 `run_started`、装配层的 `Assignment` 都是整份地拿、整份地传——一起走的值是一个值。帧上的形状与账本上的形状相同,页面、回放与 playback 读的是同一个东西,没有一层把四个字段重新拼成一个结构。四个键都必填:选择要求不等于拿到证据,而一个缺省成「免检」的键正是这条规则要挡住的。

**被否**:①保留顶层 `mode`、平铺加 `write`/`admit`/`landing` 三个带缺省的字段:旧页面可以不改,但缺省值会替没选过的人做一个选择,而帧本来就因改形要换一次版本;②`policy` 里的键带缺省:同一条理由。

**重开参数**:运行策略多出一个只有部分派活才需要的值时,重议那个值要不要缺省。
-/

/-! D32 `InstallSkill` 是 User 往书架上加 skill 的命令:来源是一个四臂的值,落点沿用 `Shelf`

**决定**:命令面多一个动词(与本版其他改形同一次 `WIRE_V` 进位,D22),类 `client`、`VerbClass::LocalOnly`(与 `PutShelved` 同类:书架在保留子树里,远程设备不往城里装东西):

```rust
Command::InstallSkill { source: SkillSource, shelf: Shelf, section: String, idem: IdemKey }
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum SkillSource {
    Path { path: String },                                   // User 写下的绝对路径或 `~` 开头,照原样,不在页面上改写
    Git { url: String, rev: Option<String>, subdir: Option<String> },   // 只收 https://
    SkillsSh { name: String },                               // `<owner>/<repo>/<skill>`
    Shipped,                                                 // 二进制自带的那几件
}
```

- `shelf` 是已有的 `Shelf { Library, Building(Address) }`,`section` 是格名;`Shipped` 时城忽略这两个值、放进城库的 `shipped` 格(city D20),帧上仍要写,于是一个形状盖住四臂。
- 字段的合法性由城在执行者里判,线上只判形:`path` 与 `url` 非空、`name` 恰好三段、`section` 是单段名(与 `Slot` 同一条规则,判法只在 city 一处,线上不复写)。失败以命令的拒词回到页面:`E_INVALID_ARGS`(相对路径、非 https、名字不是三段、clone 里找不到或找到多个同名目录、§8-28 的各条拒因)、`E_PATH_NOT_FOUND`(来源不在)、`E_TOOL_UNAVAILABLE`(PATH 上没有 git,或 clone 失败)、`E_TIMEOUT`(clone 超时)、`E_VERSION_CONFLICT`(规划与落位之间来源被换)、`E_CAS_CORRUPT`、`E_STORAGE_FATAL`;每一条带动作、主体与恢复语,恢复语归城(city D8)。
- 执行者写 `skill_shelved`(kernel D23),随后由城另起审核(city D19);回执只说这一次落没落位,审核结论经 `SkillUsage` 读回。
- `PutShelved` 留在文法里、仍答 `not_built`:它写的是一份文本(居民自建技能、在页面上改一件已有的),与从远处装一个包是两件事;它的执行者落地时同样只经 `library::install`。

**理由**:一个包有目录与脚本,`PutShelved` 只携一份文本,装不进来;来源的三种远处在城里变成一个本地目录再进 §8-28 的门(city D20),所以线上只需说出「从哪来、放哪格」。路径照原样携带,是因为 Windows 的盘符与反斜杠、POSIX 的斜杠都只有城那一侧的 `Path` 读得对,页面改写一次就多一处会错的地方。

**被否**:①给 `PutShelved` 加一个 `source` 字段:一帧两种意思,`text` 与 `source` 必有一个空着;②三个来源各一个动词:执行者是同一条路,拒词也同形,三帧只是同一个 match 拆开写;③页面先发一个「规划」命令拿哈希、再发「批准」:User 在自己的页面上按下的就是批准,而 §8-28 的 TOCTOU 复查已经守住规划与落位之间的那道缝。

**重开参数**:居民(而不是 User)需要装 skill 时,重议「规划—批准」两步;一件包大到一次 clone 超过 `CLONE_TIMEOUT` 时,重议命令改成后台任务带进度。
-/

namespace Wire.Command

open Wire.Command.Kind

/-- `Command<Secret>` 的模型:泛型于 secret 的携带者。`PutSecret` 是唯一携它的一臂,其余每一个动词不携 secret,以 `Kind.Command` 的名字代表(字段与 secret 无关,模型不写)。 -/
inductive Carrying (Secret : Type) where
  | PutSecret (value : Secret)
  | Other (verb : Command)

/-- `NoSecret`:无值可造的类型。`WireCommand` 是 `Command<NoSecret>`,套接字所能携的全部。 -/
abbrev WireCommand := Carrying Empty

/-- **套接字拼不出 `PutSecret`**:`WireCommand` 的那一臂要一个 `NoSecret` 的值,而它没有值。Rust 侧由类型与 `tests/ui/put_secret_onto_the_wire.rs` 的反例守住,这里是同一个论证。 -/
theorem a_socket_cannot_spell_put_secret (c : WireCommand) : ∀ v, c ≠ .PutSecret v :=
  fun v => v.elim

/-- `impl From<WireCommand> for Command`:总函数,`PutSecret` 一臂写作对空值的匹配。 -/
def fromWire {Secret : Type} : WireCommand → Carrying Secret
  | .PutSecret value => value.elim
  | .Other verb => .Other verb

/-- 从线上来的命令进城时动词不变。 -/
theorem fromWire_keeps_the_verb {Secret : Type} (verb : Command) :
    (fromWire (.Other verb : WireCommand) : Carrying Secret) = .Other verb :=
  rfl

/-- 进城的命令永远不是 `PutSecret`:凭证只经 `/enroll` 在进程里成形(§8-2)。 -/
theorem nothing_from_the_wire_enrols_a_secret {Secret : Type} (c : WireCommand) (v : Secret) :
    fromWire c ≠ .PutSecret v := by
  cases c with
  | PutSecret value => exact value.elim
  | Other verb => simp [fromWire]

end Wire.Command