Files
deepseek-harness/docs/rfc/008-immutable-public-surfaces.md
Tianyi Cui 89e63f1436 fix(invariants): address Codex review of dev invariants (PR 2)
- 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
2026-06-13 23:50:43 +08:00

2.5 KiB

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.

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. 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.