feat(invariants): dev-mode event-contract assertions + session-log freeze (RFC 005 pt 3, RFC 008)

New @deepseek-ai/dsh-invariants plugin (pure listeners, off in prod) asserts
the event taxonomy at runtime — seq monotonicity, turn/step nesting, a
tool/result needs a prior tool/call (NOT the converse), legal agent/status
transitions — and deep-freezes logged event data so mutating history throws.
Seeded sessions are checked + frozen on session/created.

The real RFC 008 fix is always-on: deriveMessages now structured-clones the
content it emits, so the loop's sanctioned request/adapter mutation can no
longer reach back and rewrite the append-only log. The pervasive
DeepReadonly<T> type flip is rejected (compile-only, high-noise, castable) —
recorded in ADR 0012, which folds in RFC 008. Wired into both demos.
This commit is contained in:
Tianyi Cui
2026-06-13 23:25:12 +08:00
parent 11f85b4f88
commit 11a29fdefe
19 changed files with 626 additions and 11 deletions
@@ -0,0 +1,27 @@
# ADR 0012: Dev-mode invariants over compile-time deep-readonly
Status: accepted (2026-06-13)
## Context
The session log is append-only by contract, but the types don't enforce it: `session.events` returns `readonly SessionEvent[]` whose *elements* are mutable, and `deriveMessages()` handed the logged `content` arrays/blocks out by reference. The loop then passes those derived messages into the `agent/request` waterfall and on to adapters, where mutating the request is sanctioned — so a request middleware could reach back and rewrite history, silently breaking replay equivalence and the derived-history guarantee. Separately, the event taxonomy (turn/step nesting, seq monotonicity, tool-call/result pairing, legal status transitions) was asserted only where individual tests happened to look.
Two ways to defend the log: make immutability part of the type (`DeepReadonly<SessionEvent>` on the way out), or catch corruption at runtime in dev. The RFC (005) proposed the runtime route; RFC 008 proposed the type route.
## Decision
Reject the pervasive `DeepReadonly<T>` type flip. Instead:
1. **Always-on:** `deriveMessages()` deep-clones the content it emits (one `structuredClone` per derived message). In-flight mutation of a request can no longer reach the log — this is the real fix, and it costs nothing meaningful next to a model call.
2. **Dev-mode:** a new `dsh-invariants` plugin (pure listeners, off in production, on in tests and demos) asserts the event contract and `Object.freeze`s logged event data so any *other* code that mutates a logged event throws instead of corrupting silently. Seeded sessions are frozen and checked on `session/created` (the constructor copies the seed without emitting `session/event`).
The invariants encode the *real* contract, not an idealized one: a `tool/call` may have no `tool/result` (a thrown `tools/execute` waterfall ends the step), and both `idle→disposed` and `running→disposed` are legal.
`DeepReadonly` was rejected because it is compile-time only (a plugin casts straight through it), high type-noise across every log/message consumer and adapter, and would force readonly types through code where mutation is the sanctioned API. The clone draws the mutable/immutable boundary exactly at "logged vs in-flight" without any of that noise.
## Consequences
- History corruption is caught loudly in tests and demos, at zero production cost and zero type noise. The trade-off is that the guarantee is dynamic (a dev-mode tripwire) rather than static.
- The invariants plugin doubles as executable documentation of the event taxonomy — the assertions are the contract.
- `Session.events` keeps its `readonly SessionEvent[]` type; no consumer churn.
- This folds in RFC 008 — there is no separate deep-readonly ADR; this records the decision to *not* pursue that approach. `InvariantError` is a plain `Error` with a `code` for now; a later taxonomy change can promote it.
+1
View File
@@ -23,3 +23,4 @@ Do NOT write an ADR for: a mechanical or local choice (a variable name, a one-fi
| [0009](0009-capability-seams.md) | Capability seams — interface / implementation / consumer split | accepted |
| [0010](0010-twin-llm-adapters.md) | Two LLM adapters as a design-verification twin | accepted |
| [0011](0011-runtime-arg-validation.md) | Runtime arg validation at the model boundary | accepted |
| [0012](0012-dev-invariants-over-deep-readonly.md) | Dev-mode invariants over compile-time deep-readonly | accepted |
@@ -1,6 +1,6 @@
# RFC 005: Runtime validation at the model boundary, error taxonomy, dev-mode invariants
Status: partially implemented — part 1 (arg validation) → [ADR 0011](../adr/0011-runtime-arg-validation.md); parts 2-3 in progress
Status: partially implemented — part 1 (arg validation) → [ADR 0011](../adr/0011-runtime-arg-validation.md); part 3 (dev invariants) → [ADR 0012](../adr/0012-dev-invariants-over-deep-readonly.md); part 2 (error taxonomy) in progress
## Problem
+1 -1
View File
@@ -1,6 +1,6 @@
# RFC 008: Deep-readonly public surfaces
Status: proposed
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
+1 -1
View File
@@ -11,4 +11,4 @@ Proposals for substantial future work — reviewed before implementation, unlike
| [005](005-runtime-validation-and-error-taxonomy.md) | Runtime arg validation, structured error taxonomy, dev-mode invariants | partially implemented |
| [006](006-doc-sync-and-api-reports.md) | Doc-sync enforcement and API extractor reports | proposed |
| [007](007-supply-chain-and-vendor-drift.md) | Supply chain checks and vendor drift verification | proposed |
| [008](008-immutable-public-surfaces.md) | Deep-readonly public surfaces | proposed |
| [008](008-immutable-public-surfaces.md) | Deep-readonly public surfaces | implemented (revised) |