- HMR state soundness: inject sessions, rebuild per-session trace by replaying each existing session's log at (re-)apply, so a reload mid-turn no longer falsely rejects the next event - tighten nesting: turn/end rejects an open step; step/start rejects an open step; chunk/message/tool events must name the open turn+step; pendingCalls clears at step/end so a cross-step tool/result can't satisfy a stale call - drop the default export (it stripped the inject metadata when loaded by name; functional plugins expose named exports only — matches tool-bash) - document deepFreeze's top-down precondition; sync RFC 005/008 bodies to the as-implemented decision
27 lines
2.5 KiB
Markdown
27 lines
2.5 KiB
Markdown
# RFC 008: Deep-readonly public surfaces
|
|
|
|
Status: implemented (revised) — the pervasive `DeepReadonly<T>` type flip was rejected in favor of an always-on `deriveMessages` clone plus dev-mode `Object.freeze` + invariants. See [ADR 0012](../adr/0012-dev-invariants-over-deep-readonly.md).
|
|
|
|
## Problem
|
|
|
|
The session log is append-only by contract, but `session.events` returns `readonly SessionEvent[]` whose *elements* are mutable: a plugin can reach in and rewrite history (`events[0].data.content.push(...)`), silently breaking replay equivalence and the derived-history guarantee. The same applies to derived messages and prompt assemblies passed through waterfalls — mutation is sometimes the intended idiom (waterfall middleware mutates the request) and sometimes corruption (mutating a *logged* event), and the types don't distinguish.
|
|
|
|
## Proposal
|
|
|
|
> **Implemented differently — see the Status line and [ADR 0012](../adr/0012-dev-invariants-over-deep-readonly.md).** The `DeepReadonly<T>` design below was rejected as written (compile-only, high type-noise, castable). What shipped: an always-on deep clone in `deriveMessages` (closing the request/adapter aliasing path) plus a dev-mode `Object.freeze` + invariants plugin. The proposal text is kept for the record.
|
|
|
|
Make immutability part of the type where mutation is corruption:
|
|
|
|
- `SessionEvent` data becomes `DeepReadonly` on the way OUT of a session (`events`, `session/event` listeners); `append()` keeps taking plain mutable input. A `DeepReadonly<T>` utility type lands in dsh-llm next to the brand/never helpers.
|
|
- `deriveMessages()` returns deep-readonly messages; the loop clones before handing a mutable request to the `agent/request` waterfall (mutation there is sanctioned — the clone makes the boundary explicit and cheap, once per step).
|
|
- `PromptAssembly` stays mutable through its waterfall (sanctioned) but the registry's internal section list is cloned per assembly (already true).
|
|
- Optionally, dev-mode `Object.freeze` of event data behind the RFC 005 invariants flag, so sanctioned-mutation violations throw in tests rather than corrupting silently.
|
|
|
|
## Plan
|
|
|
|
Introduce `DeepReadonly`, flip the session read paths, fix resulting compile errors in consumers (expected: a handful in tests), add the freeze-in-dev option alongside RFC 005's invariants plugin.
|
|
|
|
## Risks
|
|
|
|
`DeepReadonly` types can produce noisy errors at waterfall boundaries where mutation IS the API — keep the mutable/readonly boundary exactly at "logged vs in-flight" and document it in the session README.
|