Files
deepseek-harness/packages/invariants
Tianyi Cui 5299e43bed fix(session): snapshot seed + appended data at the boundary (review #31)
The source-level JSON-serializability invariant was only a preflight: the
Session constructor copied the seed array but shared every event/data
object with the caller, and append() stored the caller's `data` reference
verbatim. A post-create/post-append mutation could rewrite the durable
log or reintroduce a non-JSON-serializable value AFTER validation, so
session.events could diverge from what was validated / what a backend
can persist.

- ctor deep-clones each seed event after validation (not just the array).
- append() stores structuredClone(data) (serializability already checked,
  so the clone is safe); the returned event carries the same snapshot.

Regression tests: mutating the original seed / the passed append object
after the call leaves session.events unchanged. Adapted the dev-freeze
invariants test to assert on the logged clone (append no longer freezes
the caller's input). Documented isJsonValue's exact scope (own enumerable
string keys, matching JSON.stringify) and synced the README create()
signature with meta.createdAt.
2026-06-15 23:33:00 +08:00
..

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

A functional plugin — register the module namespace (this is what loading by name in cordis.yml does):

import type { Context } from 'cordis'
import * as Invariants from '@deepseek-ai/dsh-invariants'

declare const ctx: Context

await ctx.plugin(Invariants)                     // freeze on (default)
await ctx.plugin(Invariants, { freeze: false })  // assert contract, don't freeze

inject: ['sessions'] — it reads ctx.sessions.list() at apply time to rebuild trace state for sessions that already exist (so a hot reload mid-turn doesn't falsely reject the next event). It listens on session/created, session/event, and agent/status.

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 nestturn/start opens a turn, turn/end closes the matching one; no overlapping turns.
  • steps nest in turnsstep/start opens a step in the open turn; step/end closes the matching step.
  • chunks belong to an open stepstep/start precedes its assistant/chunks.
  • 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 onlyidle↔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.

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.