Просмотр исходного кода

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.
Tianyi Cui 3 месяцев назад
Родитель
Сommit
11a29fdefe

+ 27 - 0
docs/adr/0012-dev-invariants-over-deep-readonly.md

@@ -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 - 0
docs/adr/README.md

@@ -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 - 1
docs/rfc/005-runtime-validation-and-error-taxonomy.md

@@ -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
docs/rfc/008-immutable-public-surfaces.md

@@ -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
docs/rfc/README.md

@@ -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) |

+ 4 - 0
examples/coding-agent/cordis.yml

@@ -30,6 +30,10 @@
 - id: agents
   name: '@deepseek-ai/dsh-agent'
 
+# Dev-mode event-contract assertions + session-log freeze (off in prod).
+- id: invariants
+  name: '@deepseek-ai/dsh-invariants'
+
 # The DeepSeek adapter. Swap to '@deepseek-ai/dsh-llm-pi-ai' for the
 # pi-ai-backed twin (same config shape; `reasoning: high` replaces
 # thinking/reasoningEffort).

+ 5 - 0
examples/echo-agent/cordis.yml

@@ -27,6 +27,11 @@
 - id: agents
   name: '@deepseek-ai/dsh-agent'
 
+# Dev-mode event-contract assertions + session-log freeze (off in prod;
+# on here so the demo smoke test exercises the contract).
+- id: invariants
+  name: '@deepseek-ai/dsh-invariants'
+
 - id: agent-loop
   name: '@deepseek-ai/dsh-agent-loop'
   config:

+ 46 - 0
packages/invariants/README.md

@@ -0,0 +1,46 @@
+# dsh-invariants
+
+Dev-mode event-contract invariants and session-log freeze. A pure-listener plugin (everything is a plugin) that asserts the harness event contract at runtime and, optionally, freezes logged session-event data so any code that mutates history throws instead of corrupting silently.
+
+**Off in production.** Enable it in tests and the demos, where a contract violation should fail loudly. It costs nothing when not registered, and doubles as executable documentation of the event taxonomy — the assertions *are* the contract.
+
+## Plugin
+
+```ts
+import Invariants from '@deepseek-ai/dsh-invariants'
+
+await ctx.plugin(Invariants)              // freeze on (default)
+await ctx.plugin(Invariants, { freeze: false })  // assert contract, don't freeze
+```
+
+`inject`: none required — it listens on `session/created`, `session/event`, and `agent/status`, all emitted by services it does not depend on directly.
+
+### Config
+
+| Key | Default | Meaning |
+|---|---|---|
+| `freeze` | `true` | Deep-freeze each logged event's data so mutating a logged event throws. Set `false` to assert the contract without freezing. |
+
+## Invariants asserted
+
+Session log (per session):
+
+- **`seq` strictly increases** — the spine of replay equivalence.
+- **turns pair and nest** — `turn/start` opens a turn, `turn/end` closes the matching one; no overlapping turns.
+- **steps nest in turns** — `step/start` opens a step in the open turn; `step/end` closes the matching step.
+- **chunks belong to an open step** — `step/start` precedes its `assistant/chunk`s.
+- **a `tool/result` needs a prior `tool/call`** — but NOT the converse: a `tool/call` may have no result (a thrown `tools/execute` waterfall ends the step with no `tool/result`, which is legal).
+
+Agent status (per agent):
+
+- **legal transitions only** — `idle↔running` and `(idle|running)→disposed`. A no-op transition (`setStatus` dedups, so it never fires) and leaving the terminal `disposed` state are violations.
+
+On any violation it throws `InvariantError` (`code: 'INVARIANT'`).
+
+## Why runtime, not deep-readonly types
+
+A `DeepReadonly<SessionEvent>` is high type-noise across every log consumer, and a plugin can cast straight through it. A dev-mode freeze plus these assertions catch real corruption at zero production cost and zero type noise. The always-on half of that defense — cloning derived messages so request/adapter mutation can't reach back into the log — lives in `dsh-session`'s `deriveMessages`. This package is the dev-mode tripwire. See [ADR 0012](../../docs/adr/0012-dev-invariants-over-deep-readonly.md).
+
+## Seeded sessions
+
+A seeded/forked session arrives with events already in its log (the `Session` constructor copies the seed without emitting `session/event`). On `session/created` the plugin replays the existing log through the checker and freezes those entries, so seeded history is held to the same contract.

+ 33 - 0
packages/invariants/package.json

@@ -0,0 +1,33 @@
+{
+  "name": "@deepseek-ai/dsh-invariants",
+  "description": "Dev-mode event-contract invariants + session-log freeze for the DeepSeek Harness",
+  "version": "0.0.1",
+  "private": true,
+  "type": "module",
+  "main": "lib/index.js",
+  "types": "lib/index.d.ts",
+  "exports": {
+    ".": {
+      "types": "./lib/index.d.ts",
+      "default": "./lib/index.js"
+    },
+    "./src/*": "./src/*",
+    "./package.json": "./package.json"
+  },
+  "files": [
+    "lib",
+    "src"
+  ],
+  "license": "BSD-3-Clause",
+  "peerDependencies": {
+    "@deepseek-ai/dsh-agent": "^0.0.1",
+    "@deepseek-ai/dsh-session": "^0.0.1",
+    "cordis": "^4.0.0-rc.6"
+  },
+  "devDependencies": {
+    "@deepseek-ai/dsh-agent": "^0.0.1",
+    "@deepseek-ai/dsh-llm": "^0.0.1",
+    "@deepseek-ai/dsh-session": "^0.0.1",
+    "cordis": "^4.0.0-rc.6"
+  }
+}

+ 193 - 0
packages/invariants/src/index.ts

@@ -0,0 +1,193 @@
+/**
+ * Dev-mode invariants: a pure-listener plugin that asserts the harness event
+ * contract at runtime, and (optionally) freezes logged session-event data so
+ * any code that mutates history throws instead of corrupting silently.
+ *
+ * Everything is a plugin — this is just listeners on `session/created`,
+ * `session/event`, and `agent/status`. It is **off in production**: enable it
+ * in tests and the demos, where a contract violation should be a loud failure,
+ * not a subtle one. It doubles as executable documentation of the event
+ * taxonomy: the assertions below ARE the contract.
+ *
+ * Why runtime assertions instead of compile-time deep-readonly types? See
+ * ADR 0012. Briefly: a `DeepReadonly<SessionEvent>` is high type-noise across
+ * every log consumer and a plugin casts straight through it; a dev-mode freeze
+ * + assertions catch real corruption at zero production cost and zero type
+ * noise. The always-on half of that defense (cloning derived messages) lives
+ * in dsh-session; this package is the dev-mode tripwire.
+ *
+ * @module @deepseek-ai/dsh-invariants
+ */
+
+import type { Context } from 'cordis'
+import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
+import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
+
+export const name = 'invariants'
+
+/**
+ * Thrown when a harness event-contract invariant is violated. Plain `Error`
+ * with a `code` for now; a later change promotes the harness error taxonomy.
+ */
+export class InvariantError extends Error {
+  readonly code = 'INVARIANT'
+  constructor(message: string) {
+    super(`invariant violated: ${message}`)
+    this.name = 'InvariantError'
+  }
+}
+
+/** Plugin config. */
+export interface Config {
+  /**
+   * Deep-freeze logged session-event data so mutating a logged event throws.
+   * Default true — this plugin only runs in dev/test, where freezing is the
+   * point. Set false to assert the event contract without freezing.
+   */
+  freeze?: boolean
+}
+
+/** Per-session bookkeeping for the session-log invariants. */
+interface SessionTrace {
+  /** Highest `seq` seen so far (must strictly increase). */
+  lastSeq: number
+  /** Open turn number, or null between turns. */
+  openTurn: number | null
+  /** Open step within the current turn, or null between steps. */
+  openStep: number | null
+  /** Outstanding tool-call ids awaiting a result (a result needs a prior call). */
+  pendingCalls: Set<string>
+}
+
+/** Deep-freeze a value and everything reachable from it. Idempotent. */
+function deepFreeze(value: unknown): void {
+  if (value === null || typeof value !== 'object') return
+  if (Object.isFrozen(value)) return
+  Object.freeze(value)
+  for (const key of Object.keys(value)) {
+    deepFreeze((value as Record<string, unknown>)[key])
+  }
+}
+
+/** Assert one appended event against the per-session invariants. */
+function checkEvent(trace: SessionTrace, event: SessionEvent): void {
+  // seq is strictly monotonic — the spine of replay equivalence. lastSeq
+  // starts at -1, so the first event (seq 0) passes.
+  if (event.seq <= trace.lastSeq) {
+    throw new InvariantError(`seq must strictly increase: saw ${event.seq} after ${trace.lastSeq}`)
+  }
+  trace.lastSeq = event.seq
+
+  // Intentionally non-exhaustive: only events that carry ordering structure
+  // are checked; the rest are trace/replay data with no nesting contract.
+  // eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check
+  switch (event.type) {
+    case 'turn/start': {
+      if (trace.openTurn !== null) {
+        throw new InvariantError(`turn/start ${event.data.turn} while turn ${trace.openTurn} is still open`)
+      }
+      trace.openTurn = event.data.turn
+      break
+    }
+    case 'turn/end': {
+      if (trace.openTurn !== event.data.turn) {
+        throw new InvariantError(`turn/end ${event.data.turn} does not match open turn ${trace.openTurn}`)
+      }
+      trace.openTurn = null
+      trace.openStep = null
+      break
+    }
+    case 'step/start': {
+      if (trace.openTurn !== event.data.turn) {
+        throw new InvariantError(`step/start in turn ${event.data.turn} but open turn is ${trace.openTurn}`)
+      }
+      trace.openStep = event.data.step
+      break
+    }
+    case 'step/end': {
+      if (trace.openStep !== event.data.step) {
+        throw new InvariantError(`step/end ${event.data.step} does not match open step ${trace.openStep}`)
+      }
+      trace.openStep = null
+      break
+    }
+    case 'assistant/chunk': {
+      // A chunk belongs to an open step — step/start must precede it.
+      if (trace.openStep === null) {
+        throw new InvariantError('assistant/chunk outside an open step (step/start must precede its chunks)')
+      }
+      break
+    }
+    case 'tool/call': {
+      trace.pendingCalls.add(event.data.callId)
+      break
+    }
+    case 'tool/result': {
+      // A result needs a prior matching call. (The converse does NOT hold: a
+      // call may have no result — a thrown tools/execute waterfall ends the
+      // step with no tool/result, which is legal.)
+      if (!trace.pendingCalls.delete(event.data.callId)) {
+        throw new InvariantError(`tool/result for ${event.data.callId} with no prior tool/call`)
+      }
+      break
+    }
+  }
+}
+
+/** Legal agent status transitions (the only state machine the loop guarantees). */
+function checkTransition(from: AgentStatus | undefined, to: AgentStatus): void {
+  // First observation: any status is a valid starting point.
+  if (from === undefined) return
+  // A no-op transition is illegal — setStatus dedups, so we never see it.
+  if (from === to) {
+    throw new InvariantError(`agent/status repeated ${to} (no-op transition)`)
+  }
+  // Leaving `disposed` is illegal — disposal is terminal.
+  if (from === 'disposed') {
+    throw new InvariantError(`agent/status left terminal state disposed → ${to}`)
+  }
+  // idle↔running and (idle|running)→disposed are all legal; nothing else exists.
+}
+
+/**
+ * Register the dev-mode invariants. Returns nothing — contributions are
+ * effect-scoped, so disposing the plugin fiber removes all listeners and
+ * stops freezing (HMR-safe).
+ */
+export function apply(ctx: Context, config: Config = {}): void {
+  const freeze = config.freeze ?? true
+  const traces = new WeakMap<Session, SessionTrace>()
+  const lastStatus = new WeakMap<Agent, AgentStatus>()
+
+  const traceFor = (session: Session): SessionTrace => {
+    let trace = traces.get(session)
+    if (!trace) {
+      trace = { lastSeq: -1, openTurn: null, openStep: null, pendingCalls: new Set() }
+      traces.set(session, trace)
+    }
+    return trace
+  }
+
+  ctx.on('session/created', (session) => {
+    // A seeded/forked session arrives with events already in its log — the
+    // constructor copies the seed WITHOUT emitting session/event, so replay
+    // them through the checker here and freeze the existing entries.
+    const trace = traceFor(session)
+    for (const event of session.events) {
+      checkEvent(trace, event)
+      if (freeze) deepFreeze(event)
+    }
+  })
+
+  ctx.on('session/event', (session, event) => {
+    checkEvent(traceFor(session), event)
+    if (freeze) deepFreeze(event)
+  })
+
+  ctx.on('agent/status', (agent, status) => {
+    checkTransition(lastStatus.get(agent), status)
+    lastStatus.set(agent, status)
+  })
+}
+
+export default apply

+ 239 - 0
packages/invariants/tests/invariants.spec.ts

@@ -0,0 +1,239 @@
+import { describe, expect, it, vi } from 'vitest'
+import { Context } from 'cordis'
+import { CallId } from '@deepseek-ai/dsh-llm'
+import type { Agent } from '@deepseek-ai/dsh-agent'
+import SessionStore from '@deepseek-ai/dsh-session'
+import Invariants, { InvariantError } from '@deepseek-ai/dsh-invariants'
+
+/** A Context with the session store and the invariants plugin registered. */
+async function setup(config?: { freeze?: boolean }) {
+  const ctx = new Context()
+  await ctx.plugin(SessionStore)
+  const fiber = await ctx.plugin(Invariants, config ?? {})
+  return { ctx, fiber }
+}
+
+/** A minimal Agent stand-in for agent/status emission. */
+function mockAgent(id: string): Agent {
+  return { id } as unknown as Agent
+}
+
+describe('session-log invariants', () => {
+  it('accepts a well-formed turn/step/tool sequence', async () => {
+    const { ctx } = await setup({ freeze: false })
+    const session = ctx.sessions.create()
+    expect(() => {
+      session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
+      session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } })
+      session.append('step/start', { turn: 1, step: 1 })
+      session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'h' } })
+      session.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'tool-call', id: CallId('c1'), name: 'echo', arguments: '{}' }] })
+      session.append('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'echo', arguments: '{}' })
+      session.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'ok' }], isError: false })
+      session.append('step/end', { turn: 1, step: 1 })
+      session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+    }).not.toThrow()
+  })
+
+  it('rejects a turn/start while another turn is open', async () => {
+    const { ctx } = await setup({ freeze: false })
+    const session = ctx.sessions.create()
+    session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
+    expect(() => session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }))
+      .toThrow(/turn 1 is still open/)
+  })
+
+  it('rejects a turn/end that does not match the open turn', async () => {
+    const { ctx } = await setup({ freeze: false })
+    const session = ctx.sessions.create()
+    session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
+    expect(() => session.append('turn/end', { turn: 2, reason: { kind: 'completed' } }))
+      .toThrow(/does not match open turn 1/)
+  })
+
+  it('rejects a step/start outside its declared turn', async () => {
+    const { ctx } = await setup({ freeze: false })
+    const session = ctx.sessions.create()
+    session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
+    expect(() => session.append('step/start', { turn: 2, step: 1 })).toThrow(/open turn is 1/)
+  })
+
+  it('rejects a step/end that does not match the open step', async () => {
+    const { ctx } = await setup({ freeze: false })
+    const session = ctx.sessions.create()
+    session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
+    session.append('step/start', { turn: 1, step: 1 })
+    expect(() => session.append('step/end', { turn: 1, step: 2 })).toThrow(/does not match open step 1/)
+  })
+
+  it('rejects an assistant/chunk outside an open step', async () => {
+    const { ctx } = await setup({ freeze: false })
+    const session = ctx.sessions.create()
+    session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
+    expect(() => session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'x' } }))
+      .toThrow(/outside an open step/)
+  })
+
+  it('rejects a tool/result with no prior tool/call', async () => {
+    const { ctx } = await setup({ freeze: false })
+    const session = ctx.sessions.create()
+    session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
+    session.append('step/start', { turn: 1, step: 1 })
+    expect(() => session.append('tool/result', { turn: 1, step: 1, callId: CallId('ghost'), content: [], isError: false }))
+      .toThrow(/no prior tool\/call/)
+  })
+
+  it('allows a tool/call with no matching tool/result (thrown waterfall ends the step)', async () => {
+    const { ctx } = await setup({ freeze: false })
+    const session = ctx.sessions.create()
+    expect(() => {
+      session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
+      session.append('step/start', { turn: 1, step: 1 })
+      session.append('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'echo', arguments: '{}' })
+      session.append('step/end', { turn: 1, step: 1 })
+      session.append('turn/end', { turn: 1, reason: { kind: 'error', message: 'boom' } })
+    }).not.toThrow()
+  })
+
+  it('holds seeded sessions to the contract on session/created', async () => {
+    const { ctx } = await setup({ freeze: false })
+    // A seed whose seq is non-monotonic must be rejected when the session is
+    // created (the constructor copies the seed without emitting session/event).
+    const badSeed = [
+      { type: 'turn/start' as const, seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } },
+      { type: 'turn/start' as const, seq: 0, time: 0, data: { turn: 2, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } },
+    ]
+    expect(() => ctx.sessions.create(undefined, badSeed)).toThrow(InvariantError)
+  })
+
+  it('tracks turns per session independently', async () => {
+    const { ctx } = await setup({ freeze: false })
+    const a = ctx.sessions.create('a')
+    const b = ctx.sessions.create('b')
+    a.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
+    // b is a fresh session — its own turn/start must not see a's open turn.
+    expect(() => b.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })).not.toThrow()
+  })
+})
+
+describe('dev-freeze', () => {
+  it('freezes appended event data so mutating a logged event throws', async () => {
+    const { ctx } = await setup() // freeze defaults true
+    const session = ctx.sessions.create()
+    const event = session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } })
+    expect(Object.isFrozen(event)).toBe(true)
+    expect(Object.isFrozen(event.data)).toBe(true)
+    expect(Object.isFrozen(event.data.content)).toBe(true)
+    expect(() => { (event.data.content[0] as { text: string }).text = 'HACKED' }).toThrow()
+  })
+
+  it('does not freeze when freeze:false', async () => {
+    const { ctx } = await setup({ freeze: false })
+    const session = ctx.sessions.create()
+    const event = session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } })
+    expect(Object.isFrozen(event)).toBe(false)
+  })
+
+  it('freezes seeded events on session/created', async () => {
+    const { ctx } = await setup()
+    const seed = [
+      { type: 'user/message' as const, seq: 0, time: 0, data: { content: [{ type: 'text' as const, text: 'seeded' }], source: { kind: 'user' as const } } },
+    ]
+    const session = ctx.sessions.create(undefined, seed)
+    expect(Object.isFrozen(session.events[0])).toBe(true)
+  })
+
+  it('is idempotent over already-frozen sub-structures', async () => {
+    const { ctx } = await setup()
+    const session = ctx.sessions.create()
+    // Pre-freeze a block before appending; deepFreeze must short-circuit on it
+    // (the already-frozen guard) while still freezing the enclosing event.
+    const block = Object.freeze({ type: 'text' as const, text: 'pre-frozen' })
+    const event = session.append('user/message', { content: [block], source: { kind: 'user' } })
+    expect(Object.isFrozen(event)).toBe(true)
+    expect(Object.isFrozen(event.data.content)).toBe(true)
+    expect(Object.isFrozen(event.data.content[0])).toBe(true)
+  })
+})
+
+describe('agent status invariants', () => {
+  it('accepts legal transitions: idle→running→idle and →disposed', async () => {
+    const { ctx } = await setup({ freeze: false })
+    const agent = mockAgent('a1')
+    expect(() => {
+      ctx.emit('agent/status', agent, 'idle')
+      ctx.emit('agent/status', agent, 'running')
+      ctx.emit('agent/status', agent, 'idle')
+      ctx.emit('agent/status', agent, 'disposed')
+    }).not.toThrow()
+  })
+
+  it('accepts running→disposed', async () => {
+    const { ctx } = await setup({ freeze: false })
+    const agent = mockAgent('a2')
+    ctx.emit('agent/status', agent, 'running')
+    expect(() => { ctx.emit('agent/status', agent, 'disposed') }).not.toThrow()
+  })
+
+  it('rejects a no-op transition', async () => {
+    const { ctx } = await setup({ freeze: false })
+    const agent = mockAgent('a3')
+    ctx.emit('agent/status', agent, 'running')
+    expect(() => { ctx.emit('agent/status', agent, 'running') }).toThrow(/no-op transition/)
+  })
+
+  it('rejects leaving the terminal disposed state', async () => {
+    const { ctx } = await setup({ freeze: false })
+    const agent = mockAgent('a4')
+    ctx.emit('agent/status', agent, 'disposed')
+    expect(() => { ctx.emit('agent/status', agent, 'idle') }).toThrow(/left terminal state disposed/)
+  })
+
+  it('tracks status per agent independently', async () => {
+    const { ctx } = await setup({ freeze: false })
+    const a = mockAgent('a5')
+    const b = mockAgent('b5')
+    ctx.emit('agent/status', a, 'running')
+    // b's first observation is independent of a.
+    expect(() => { ctx.emit('agent/status', b, 'running') }).not.toThrow()
+  })
+})
+
+describe('HMR safety', () => {
+  it('removes all listeners when the plugin fiber is disposed', async () => {
+    const { ctx, fiber } = await setup()
+    const session = ctx.sessions.create()
+    session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
+
+    await fiber.dispose()
+
+    // After disposal: no freezing, no assertions. An event that WOULD have
+    // violated the open-turn rule now passes silently, and is not frozen.
+    const event = session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
+    expect(Object.isFrozen(event)).toBe(false)
+    // A no-op status transition no longer throws either.
+    const agent = mockAgent('hmr')
+    ctx.emit('agent/status', agent, 'idle')
+    expect(() => { ctx.emit('agent/status', agent, 'idle') }).not.toThrow()
+  })
+
+  it('InvariantError carries a stable code', () => {
+    const err = new InvariantError('seq must strictly increase')
+    expect(err).toBeInstanceOf(Error)
+    expect(err.name).toBe('InvariantError')
+    expect(err.code).toBe('INVARIANT')
+    expect(err.message).toBe('invariant violated: seq must strictly increase')
+  })
+
+  it('does not leak listeners across dispose (no stale freezing)', async () => {
+    const { ctx, fiber } = await setup()
+    await fiber.dispose()
+    const spy = vi.fn()
+    ctx.on('session/event', spy)
+    const session = ctx.sessions.create()
+    session.append('user/message', { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' } })
+    // our own spy fires, proving events still flow — but the plugin's frozen.
+    expect(spy).toHaveBeenCalledOnce()
+    expect(Object.isFrozen(session.events[0])).toBe(false)
+  })
+})

+ 15 - 0
packages/invariants/tsconfig.json

@@ -0,0 +1,15 @@
+{
+  "extends": "../../tsconfig.base.json",
+  "compilerOptions": {
+    "rootDir": "src",
+    "outDir": "lib"
+  },
+  "include": ["src"],
+  "references": [
+    { "path": "../../vendor/cosmokit" },
+    { "path": "../../vendor/cordis" },
+    { "path": "../llm" },
+    { "path": "../session" },
+    { "path": "../agent" }
+  ]
+}

+ 13 - 5
packages/session/src/index.ts

@@ -94,6 +94,14 @@ export class Session {
    * - `tool/result` → user message carrying a tool-result block
    * - `context/message` / `steering/message` → tagged synthetic user messages
    *   at their chronological position
+   *
+   * The returned `content` is **deep-cloned** off the logged events: the loop
+   * hands these messages into the mutable `agent/request` waterfall and on to
+   * adapters, where mutating the request is sanctioned — but the session log
+   * is append-only by contract. Cloning at this boundary keeps in-flight
+   * mutation from reaching back and rewriting history (which would silently
+   * break replay equivalence). Cost is one structured clone per step,
+   * negligible next to a model call.
    */
   deriveMessages(): Message[] {
     const messages: Message[] = []
@@ -104,29 +112,29 @@ export class Session {
       // eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check
       switch (event.type) {
         case 'user/message': {
-          messages.push({ role: 'user', content: event.data.content })
+          messages.push({ role: 'user', content: structuredClone(event.data.content) })
           break
         }
         case 'assistant/message': {
-          messages.push({ role: 'assistant', content: event.data.content })
+          messages.push({ role: 'assistant', content: structuredClone(event.data.content) })
           break
         }
         case 'tool/result': {
           const { callId, content, isError } = event.data
           messages.push({
             role: 'user',
-            content: [{ type: 'tool-result', toolCallId: callId, content, isError }],
+            content: [{ type: 'tool-result', toolCallId: callId, content: structuredClone(content), isError }],
           })
           break
         }
         case 'context/message': {
           const { content, source } = event.data
-          messages.push({ role: 'user', content: renderTagged('context', content, source) })
+          messages.push({ role: 'user', content: renderTagged('context', structuredClone(content), source) })
           break
         }
         case 'steering/message': {
           const { content, source } = event.data
-          messages.push({ role: 'user', content: renderTagged('steering', content, source) })
+          messages.push({ role: 'user', content: renderTagged('steering', structuredClone(content), source) })
           break
         }
       }

+ 25 - 0
packages/session/tests/session.spec.ts

@@ -54,6 +54,31 @@ describe('Session', () => {
     expect(replayed.deriveMessages()).toEqual(original.deriveMessages())
     expect(replayed.seq).toBe(original.seq)
   })
+
+  it('isolates the log from mutation through a derived message (append-only contract)', () => {
+    const session = new Session(SessionId('s4'))
+    session.append('user/message', { content: [{ type: 'text', text: 'original' }], source: { kind: 'user' } })
+    session.append('tool/result', {
+      turn: 1, step: 1, callId: CallId('c1'),
+      content: [{ type: 'text', text: 'tool out' }], isError: false,
+    })
+    const before = structuredClone(session.events)
+
+    // A request middleware / adapter mutates the messages it was handed.
+    const messages = session.deriveMessages()
+    const userBlock = messages[0]!.content[0]!
+    if (userBlock.type === 'text') userBlock.text = 'HACKED'
+    const toolBlock = messages[1]!.content[0]!
+    if (toolBlock.type === 'tool-result') {
+      toolBlock.content.push({ type: 'text', text: 'injected' })
+    }
+    messages[0]!.content.push({ type: 'text', text: 'extra' })
+
+    // The log is unchanged: deep-equal to the snapshot taken before mutation.
+    expect(session.events).toEqual(before)
+    // And a fresh derivation still reflects the original content.
+    expect(session.deriveMessages()[0]!.content).toEqual([{ type: 'text', text: 'original' }])
+  })
 })
 
 describe('SessionStore', () => {

+ 1 - 0
scripts/publint-all.ts

@@ -15,6 +15,7 @@ const packages = [
   'packages/llm-pi-ai',
   'packages/bash-local',
   'packages/tool-bash',
+  'packages/invariants',
 ]
 
 const root = resolve(import.meta.dirname, '..')

+ 2 - 1
tsconfig.base.json

@@ -44,7 +44,8 @@
       "@deepseek-ai/dsh-llm-deepseek": ["./packages/llm-deepseek/src"],
       "@deepseek-ai/dsh-llm-pi-ai": ["./packages/llm-pi-ai/src"],
       "@deepseek-ai/dsh-bash-local": ["./packages/bash-local/src"],
-      "@deepseek-ai/dsh-tool-bash": ["./packages/tool-bash/src"]
+      "@deepseek-ai/dsh-tool-bash": ["./packages/tool-bash/src"],
+      "@deepseek-ai/dsh-invariants": ["./packages/invariants/src"]
     }
   }
 }

+ 2 - 1
tsconfig.build.json

@@ -20,6 +20,7 @@
     { "path": "./packages/llm-deepseek" },
     { "path": "./packages/llm-pi-ai" },
     { "path": "./packages/bash-local" },
-    { "path": "./packages/tool-bash" }
+    { "path": "./packages/tool-bash" },
+    { "path": "./packages/invariants" }
   ]
 }

+ 2 - 1
tsconfig.typecheck.json

@@ -26,7 +26,8 @@
       "@deepseek-ai/dsh-llm-deepseek": ["./packages/llm-deepseek/src"],
       "@deepseek-ai/dsh-llm-pi-ai": ["./packages/llm-pi-ai/src"],
       "@deepseek-ai/dsh-bash-local": ["./packages/bash-local/src"],
-      "@deepseek-ai/dsh-tool-bash": ["./packages/tool-bash/src"]
+      "@deepseek-ai/dsh-tool-bash": ["./packages/tool-bash/src"],
+      "@deepseek-ai/dsh-invariants": ["./packages/invariants/src"]
     }
   },
   "include": ["packages/*/src", "packages/*/tests", "examples", "scripts"]

+ 15 - 0
yarn.lock

@@ -611,6 +611,21 @@ __metadata:
   languageName: unknown
   linkType: soft
 
+"@deepseek-ai/dsh-invariants@workspace:packages/invariants":
+  version: 0.0.0-use.local
+  resolution: "@deepseek-ai/dsh-invariants@workspace:packages/invariants"
+  dependencies:
+    "@deepseek-ai/dsh-agent": "npm:^0.0.1"
+    "@deepseek-ai/dsh-llm": "npm:^0.0.1"
+    "@deepseek-ai/dsh-session": "npm:^0.0.1"
+    cordis: "npm:^4.0.0-rc.6"
+  peerDependencies:
+    "@deepseek-ai/dsh-agent": ^0.0.1
+    "@deepseek-ai/dsh-session": ^0.0.1
+    cordis: ^4.0.0-rc.6
+  languageName: unknown
+  linkType: soft
+
 "@deepseek-ai/dsh-llm-deepseek@npm:^0.0.1, @deepseek-ai/dsh-llm-deepseek@workspace:packages/llm-deepseek":
   version: 0.0.0-use.local
   resolution: "@deepseek-ai/dsh-llm-deepseek@workspace:packages/llm-deepseek"