Source-owned session immutability
源端拥有的会话不可变性
The session log needs immutable ownership of each stored fact. Making that protection an optional development plugin would leave production history vulnerable; expressing it through TypeScript readonly types would not create a runtime boundary. 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 insp
English
Problem
The session log needs immutable ownership of each stored fact. Making that protection an optional development plugin would leave production history vulnerable; expressing it through TypeScript readonly types would not create a runtime boundary.
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 does not establish relationships among records. 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, cannot be established by freezing one object, and are outside this decision.
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
Session owns an always-on storage boundary.
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, and session/event observers and eventAt(seq) receive the same record. snapshotEvents(fromSeq?, toSeqExclusive?) returns a frozen array snapshot; a previously returned array does not grow after a later append. seq reads the current length without materializing an array. Synchronous historical readers are deprecated under the event-read policy. 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 regardless of which 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.
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 a development 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.
Clone only when deriving messages
Detaching deriveMessages() would protect the most common request path but leave other readers of snapshotEvents(), eventAt(), 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.
- Existing
snapshotEvents()andeventAt()callers retain immutable read results while their migration is deferred;seqreads the log length without copying the array. - Request-side mutation cannot reach stored history through derived messages.
- 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 拥有不可变历史
Session 仅在一次递归遍历完成无损 JSON 快照的物化之后才接受事件。该遍历拒绝不支持的值,并产出进入日志的已分离的确切记录,因此验证与存储不会从有状态的 getter 观察到不同的值,也不会保留调用方拥有的嵌套引用。
被接受的事件及其所有后代在发布前被深度冻结。append() 返回由 Session 拥有的冻结事件,session/event 观察者和 eventAt(seq) 接收同一记录。snapshotEvents(fromSeq?, toSeqExclusive?) 返回冻结的数组快照;先前返回的数组不会因后续 append 而增长。seq 无需物化数组即可读取当前长度。同步历史读取方法按事件读取策略弃用。种子记录在构造成功前经过相同的验证、快照与冻结边界。
此保证属于 Session 而非可选监听器,因为每种组合都依赖可信的历史。无论注册了哪些插件,生产部署、聚焦测试或自定义嵌入都获得相同的存储语义。
派生请求保持分离
deriveMessages() 将已记录的表面事件投影为分离的、深度冻结的 Message 对象,并返回一份新的数组快照。因此请求组装可以将派生历史与其他输入组合,而不会暴露一条回到日志的路径。缓存复用安全的不可变投影,而非为每次模型调用重新克隆完整历史。
曾考虑的替代方案
全面的 deep-readonly 类型
一个被否决的配套提案会在公开的日志与消息接口上应用递归 DeepReadonly<T> 类型,将会话读取路径(events、session/event 监听器、deriveMessages())改为深只读,同时保持进行中的 waterfall(瀑布式事件)可变。这能提供编辑器反馈,但无法提供运行时保证:TypeScript 类型在运行时被擦除,插件代码可以通过类型转换绕过。它还会将 readonly 类型推入有意进行修改的消费方。在 Session 边界处的运行时所有权保护所有调用方,无需这种类型传播。
仅在开发模式冻结
仅当开发插件安装时才冻结历史,会使核心保证依赖于组合方式。代码可能通过开发测试,却在生产环境或省略了该插件的聚焦组合中破坏历史。因此存储不可变性始终启用。
仅在派生消息时克隆
分离 deriveMessages() 能保护最常见的请求路径,但 snapshotEvents()、eventAt() 的其他读取者、append 返回值和会话事件观察者仍能修改持久历史。日志必须保护自身的边界;派生投影是额外的隔离边界,而非替代品。
后果
- 每个被接受的实时或种子会话事件在任何观察者接收之前,都已从调用方拥有的输入中分离并深度不可变。
- 现有
snapshotEvents()和eventAt()调用方在暂缓迁移期间仍获得不可变的读取结果;seq无需复制数组即可读取日志长度。 - 请求侧的修改无法通过派生消息触及已存储的历史。
- 运行时边界对每个被接受的事件产生一次递归快照与冻结的开销;后续读取者和缓存投影复用已拥有的不可变记录。