Source-owned session immutability and dev-mode invariants
源端拥有的会话不可变性与开发模式不变式
The session log needs two different protections: immutable ownership of each stored fact, and checks for relationships among facts across time and service contracts. Conflating them in an optional development plugin would leave production history vulnerable; trying to express both through TypeScript readonly types would not create a runtime boundary or describe relational rules. The session log is the durable source
English
Problem
The session log needs two different protections: immutable ownership of each stored fact, and checks for relationships among facts across time and service contracts. Conflating them in an optional development plugin would leave production history vulnerable; trying to express both through TypeScript readonly types would not create a runtime boundary or describe relational rules.
The session log is the durable source of truth for replay, request reconstruction, persistence, and user-visible history. Code outside the session package must be able to inspect that history without retaining a reference that can rewrite it later, and inputs accepted from callers must not remain connected to caller-owned mutable objects.
Immutability of individual values is only half of the contract. A log can contain perfectly immutable records whose sequence, turn/step nesting, tool-call pairing, scoped delivery, or reconstructed model request is wrong. Those rules relate multiple records or services and cannot be established by freezing one object.
TypeScript readonly types are not a sufficient runtime boundary. They disappear when the program runs, a cast can bypass them, and a recursive DeepReadonly<T> would spread through every log and message consumer even though some downstream request-processing APIs intentionally work with mutable values.
Decision
Responsibility is split between an always-on storage boundary and optional development assertions.
Session owns immutable history
Session accepts an event only after one recursive pass has materialized a lossless JSON snapshot. That pass rejects unsupported values and produces the exact detached record that enters the log, so validation and storage cannot observe different values from a stateful getter or retain caller-owned nested references.
The accepted event and all of its descendants are deep-frozen before publication. append() returns that owned frozen event, session/event observers receive the same record, and session.events returns a frozen array snapshot. A previously returned array does not grow after a later append. Seed records pass through the same validation, snapshot, and freeze boundary before construction succeeds.
This guarantee belongs in Session, not in an optional listener, because every composition relies on trustworthy history. A production deployment, a focused test, or a custom embedding receives the same storage semantics whether or not development support plugins are registered.
Derived requests remain detached
deriveMessages() projects logged surface events into detached, deep-frozen Message objects and returns a fresh array snapshot. Request assembly can therefore combine derived history with other inputs without exposing a path back into the log. The cache reuses safe immutable projections rather than recloning the complete history for each model call.
Package-owned invariant companions check relationships
dsh-invariants registers the configurable ctx.invariants service and contains no product checks. Every package publishes a ./invariant ownership companion; dsh-session, dsh-agent, dsh-scope, and dsh-agent-loop currently add the rules that require trace state or observation of another seam: monotonic sequence numbers, turn and step nesting, tool-call/result pairing, legal agent-status transitions, subject-correct scoped dispatch, and equality between a loop-built request and the request reconstructed from its session-log prefix. Global enablement and package-name regex filters belong to the service (package-owned invariant service).
When the session companion attaches to an existing or seeded session, it replays the immutable log to rebuild trace state. The service gives each contribution a disposable child fiber, so hot reload is safe in the middle of a turn without giving diagnostics ownership of session storage.
Alternatives considered
Pervasive deep-readonly types
A rejected companion proposal would apply a recursive DeepReadonly<T> type across public log and message surfaces, flipping session read paths (events, session/event listeners, deriveMessages()) to deep-readonly while keeping in-flight waterfalls mutable. That provides editor feedback but not a runtime guarantee: TypeScript types are erased and plugin code can cast through them. It also pushes readonly types into consumers where mutation is intentional. Runtime ownership at the Session boundary protects every caller without that type propagation.
Development-only freezing
Freezing history only when an invariants plugin is installed would make the core guarantee composition-dependent. Code could pass development tests and still corrupt history in production or in a focused composition that omits the plugin. Storage immutability is therefore always on, while the more expensive relational checks remain opt-in development support.
Clone only when deriving messages
Detaching deriveMessages() would protect the most common request path but leave other readers of session.events, append return values, and session-event observers able to mutate durable history. The log must protect its own boundary; derived projections are an additional isolation boundary, not a substitute.
Consequences
- Every accepted live or seeded session event is detached from caller-owned inputs and deeply immutable before any observer can receive it.
session.eventsexposes stable immutable snapshots instead of the private growing array.- Request-side mutation cannot reach stored history through derived messages.
- Development builds can enable relational assertions without changing storage behavior, and disposing or filtering a companion does not weaken log immutability.
dsh-invariantsconfigures global enablement plus package allow/block regex lists; each check remains owned and tested by its product package.- The runtime boundary carries a recursive snapshot-and-freeze cost once per accepted event; later readers and cached projections reuse the owned immutable records.
中文
问题
会话日志需要两种不同的保护:对每条已存储事实的不可变所有权,以及对跨时间和服务约定的事实之间关系的检查。如果将二者混为一个可选的开发插件,生产环境的历史记录将失去保护;如果试图通过 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 过滤器归该服务所有(见包拥有的不变式服务)。
当会话配套插件附加到已有会话或以种子记录初始化的会话时,它回放不可变日志以重建跟踪状态。服务为每项贡献提供一个可 dispose(资源释放)的子 fiber,因此轮次中途热重载是安全的,同时不赋予诊断逻辑对会话存储的所有权。
曾考虑的替代方案
全面的 deep-readonly 类型
一个被否决的配套提案会在公开的日志与消息接口上应用递归 DeepReadonly<T> 类型,将会话读取路径(events、session/event 监听器、deriveMessages())改为深只读,同时保持进行中的 waterfall(瀑布式事件)可变。这能提供编辑器反馈,但无法提供运行时保证:TypeScript 类型在运行时被擦除,插件代码可以通过类型转换绕过。它还会将 readonly 类型推入有意进行修改的消费方。在 Session 边界处的运行时所有权保护所有调用方,无需这种类型传播。
仅在开发模式冻结
仅当不变式插件安装时才冻结历史,会使核心保证依赖于组合方式。代码可能通过开发测试,却在生产环境或省略了该插件的聚焦组合中破坏历史。因此存储不可变性始终启用,而开销更大的关系检查则保持为可选的开发支持。
仅在派生消息时克隆
分离 deriveMessages() 能保护最常见的请求路径,但 session.events 的其他读取者、append 返回值和会话事件观察者仍能修改持久历史。日志必须保护自身的边界;派生投影是额外的隔离边界,而非替代品。
后果
- 每个被接受的实时或种子会话事件在任何观察者接收之前,都已从调用方拥有的输入中分离并深度不可变。
session.events暴露稳定的不可变快照,而非持续增长的私有数组。- 请求侧的修改无法通过派生消息触及已存储的历史。
- 开发构建可以启用关系断言而不改变存储行为;dispose 或过滤一个配套插件不会削弱日志不可变性。
dsh-invariants配置全局启用状态以及包名允许/阻止 regex 列表;每项检查仍由其产品包拥有并测试。- 运行时边界对每个被接受的事件产生一次递归快照与冻结的开销;后续读取者和缓存投影复用已拥有的不可变记录。