# Agent Note: 源端拥有的会话不可变性与开发模式不变式

Status: implemented

[English](2026-06-11-dev-invariants-over-deep-readonly.md) | 中文

## 问题

会话日志需要两种不同的保护：对每条已存储事实的不可变所有权，以及对跨时间和服务约定的事实之间关系的检查。如果将二者混为一个可选的开发插件，生产环境的历史记录将失去保护；如果试图通过 TypeScript readonly 类型同时表达两者，既无法建立运行时边界，也无法描述关系规则。

会话日志是回放、请求重建、持久化与用户可见历史的持久真源。会话包外部的代码必须能检视历史，但不能保留一个可在之后改写历史的引用；从调用方接受的输入也不能继续连接到调用方拥有的可变对象。

单个值的不可变性只是约定的一半。一份日志可以包含完全不可变的记录，但其序列、轮次/步骤嵌套、工具调用配对、作用域分发或重建的模型请求是错误的。这些规则涉及多条记录或多个服务，无法通过冻结单个对象来建立。

TypeScript readonly 类型不是充分的运行时边界。它们在程序运行时消失，类型转换可以绕过它们，而递归的 `DeepReadonly<T>` 会扩散到每个日志和消息消费方，尽管某些下游请求处理 API 有意使用可变值。

## 决策

职责划分给始终启用的存储边界和可选的开发断言。

### Session 拥有不可变历史

`Session` 仅在一次递归遍历完成无损 JSON 快照的物化之后才接受事件。该遍历拒绝不支持的值，并产出进入日志的已分离的确切记录，因此验证与存储不会从有状态的 getter 观察到不同的值，也不会保留调用方拥有的嵌套引用。

被接受的事件及其所有后代在发布前被深度冻结。`append()` 返回由 Session 拥有的冻结事件，`session/event` 观察者接收同一记录，`session.events` 返回冻结的数组快照。先前返回的数组不会因后续 append 而增长。种子记录在构造成功前经过相同的验证、快照与冻结边界。

此保证属于 `Session` 而非可选监听器，因为每种组合都依赖可信的历史。无论是否注册了开发支持插件，生产部署、聚焦测试或自定义嵌入都获得相同的存储语义。

### 派生请求保持分离

`deriveMessages()` 将已记录的表面事件投影为分离的、深度冻结的 `Message` 对象，并返回一份新的数组快照。因此请求组装可以将派生历史与其他输入组合，而不会暴露一条回到日志的路径。缓存复用安全的不可变投影，而非为每次模型调用重新克隆完整历史。

### 包拥有的不变式配套插件检查关系

`dsh-invariants` 注册可配置的 `ctx.invariants` 服务，本身不包含产品检查。每个包发布一个 `./invariant` 所有权配套插件；`dsh-session`、`dsh-agent`、`dsh-scope` 和 `dsh-agent-loop` 目前添加需要跟踪状态或观察另一个 seam 的规则：单调递增的序列号、轮次与步骤嵌套、工具调用/结果配对、合法的 agent（智能体）状态转换、主体正确的作用域分发，以及循环构建的请求与从其会话日志前缀重建的请求之间的相等性。全局启用和包名 regex 过滤器归该服务所有（见[包拥有的不变式服务](2026-07-19-package-owned-invariant-service.md)）。

当会话配套插件附加到已有会话或以种子记录初始化的会话时，它回放不可变日志以重建跟踪状态。服务为每项贡献提供一个可 dispose（资源释放）的子 fiber，因此轮次中途热重载是安全的，同时不赋予诊断逻辑对会话存储的所有权。

## 曾考虑的替代方案

### 全面的 deep-readonly 类型

一个被否决的配套提案会在公开的日志与消息接口上应用递归 `DeepReadonly<T>` 类型，将会话读取路径（`events`、`session/event` 监听器、`deriveMessages()`）改为深只读，同时保持进行中的 waterfall（瀑布式事件）可变。这能提供编辑器反馈，但无法提供运行时保证：TypeScript 类型在运行时被擦除，插件代码可以通过类型转换绕过。它还会将 readonly 类型推入有意进行修改的消费方。在 `Session` 边界处的运行时所有权保护所有调用方，无需这种类型传播。

### 仅在开发模式冻结

仅当不变式插件安装时才冻结历史，会使核心保证依赖于组合方式。代码可能通过开发测试，却在生产环境或省略了该插件的聚焦组合中破坏历史。因此存储不可变性始终启用，而开销更大的关系检查则保持为可选的开发支持。

### 仅在派生消息时克隆

分离 `deriveMessages()` 能保护最常见的请求路径，但 `session.events` 的其他读取者、append 返回值和会话事件观察者仍能修改持久历史。日志必须保护自身的边界；派生投影是额外的隔离边界，而非替代品。

## 后果

- 每个被接受的实时或种子会话事件在任何观察者接收之前，都已从调用方拥有的输入中分离并深度不可变。
- `session.events` 暴露稳定的不可变快照，而非持续增长的私有数组。
- 请求侧的修改无法通过派生消息触及已存储的历史。
- 开发构建可以启用关系断言而不改变存储行为；dispose 或过滤一个配套插件不会削弱日志不可变性。
- `dsh-invariants` 配置全局启用状态以及包名允许/阻止 regex 列表；每项检查仍由其产品包拥有并测试。
- 运行时边界对每个被接受的事件产生一次递归快照与冻结的开销；后续读取者和缓存投影复用已拥有的不可变记录。
