DeepSeek Harness · Persistence & Infrastructure

Persistence Governance: Versions, fork Boundaries, Refuse-to-Parse

How log formats evolve, how fork boundaries are set, and prefer refusing data you can’t read.

Course goalAfter this lesson you can explain three things: why session-log format versions use a single monotone integer; why old and new builds meet different fates when reading each other’s logs; and why, to DSH, silently skipping an unrecognized event is a security incident—better to refuse opening the whole session.
Interactive demo · Log archaeology site

Play first, then talk. Top: a session log on disk—one header line plus a string of events. Below, two loaders read it together: left is DSH’s refuse-to-parse style—error if it can’t read; right is the common best-effort skip style—skip and keep going. Three scenarios, each with a broken log. Hit Play and watch what the same bytes become in each loader.

Log on disk (~/.dsh/sessions/session-42/log.jsonl.zst)
DSH loader (refuse-to-parse)
(no messages restored yet)
best-effort loader (skip style)
(no messages restored yet)
Hit Play and watch both loaders read the same log.
The demo is a teaching simulation, but the two refusal strings on the left are verbatim from coordinator.ts templates at lines 79 and 1064. Real DSH has no best-effort loader on the right—that’s the cautionary foil.
How it works · One integer owns versioning

In one line: DSH’s session log is the single source of truth—resume, fork, and replay all derive from it (see last lesson’s invariant). Truth must outlive any one program version, so format evolution isn’t small: logs written today must be readable by next year’s harness; conversely, when a newer build’s log lands in an older build, the older one must know it can’t read it.

DSH’s versioning is so plain it’s one number: SESSION_FORMAT_VERSION, currently 0, at packages/core/session/src/types.ts line 56. No major.minor. Design-note reason: whether a step can auto-migrate is decided by whether that step’s upgrader can be written—two-level numbering would pre-promise something you don’t know at design time.

The bump rule is clear: bump if and only if an older runtime cannot handle the new log fully correctly in semantics. “Parses without error” doesn’t count—reading through but rebuilding a wrong session is a misread. When unsure, bump: a near-identity upgrader costs almost nothing; missing one bump lets an older build silently corrupt data.

Three fates · direction-aware read rules

When opening a stored log, compare version numbers first—three outcomes, three wholly different treatments:

Open stored log Read header.version equal Read normally Then per-event unknown-type guard Log older Upgrader chain n → n+1 step by step View converts in memory only; file untouched Persist only if the session continues: atomic replace + keep backup Log newer Refuse, and name the direction “Please upgrade the harness” + raw file path Unknown event-type guard In KNOWN_SESSION_EVENT_TYPES? Allow Not in list, but ignorable: true? Skip Not in list, unmarked? Refuse restoring the whole session Both gates sit on the read side: the write side doesn’t vocabulary-check, because refusing on write would stall an active session’s persistence mid-flight
Teaching diagram: adapted from the session-log-version-mechanism Agent Note and coordinator.ts source.

The cell worth savoring is “Refuse, and name the direction.” Early assertVersion threw one vague error for any mismatch; after the change, errors split by direction: if the log is newer, say plainly “written by a newer harness—please upgrade,” with the raw log path; if older but the upgrader chain is broken, say “this build has no upgrade path for it.” Users always see “time to upgrade,” never “file corrupted.” The data isn’t broken—calling it corrupt is unfair.

Reading older logs has another detail. When a new build opens an old log, the upgrader chain converts step by step in memory only—a look doesn’t persist; only if the user actually continues the session does the result atomically replace on disk, keeping a backup of the original. Design notes rejected “auto-migrate on view”: rewrite-on-open turns a read into a destructive write, and a buggy converter would corrupt logs while browsing.

Why “skip unknown events” is a security incident

Version numbers cover structural change, not vocabulary growth: event kinds depend on which plugins are loaded—one integer can’t describe that. DSH’s answer is per-event marking. When the reader hits an unknown type, it refuses to restore the whole session by default, unless the envelope carries writer-declared ignorable: true. The known vocabulary KNOWN_SESSION_EVENT_TYPES isn’t hand-written—a script merges every event declaration in the repo into 44 types, and with the 946-line persistence catalog docs/persistence-catalog.zh.md a dedicated check script keeps it from going stale.

Why default-required, and refuse too much rather than forget the mark? The design note does the math. Forgetting ignorable refuses a recoverable session—users are annoyed; that’s UX. Default-ignorable with the same slip silently restores a mutilated session and the model keeps working on wrong history—that’s a security incident. Scenario A is the latter on stage: skip an unknown event that carried a user message, and the restored chat has the assistant answering a question that isn’t there. Failures are asymmetric, so the fence leans toward the noisy side.

Version is one monotone integer

No major/minor. Whether auto-upgrade is possible is expressed by whether that step’s upgrader exists—the numbering scheme doesn’t pre-promise. Currently SESSION_FORMAT_VERSION = 0.

Unknown events are required by default

The reader refuses to interpret logs with unknown types unless the event has ignorable: true. Directional refusal beats best-effort parse; silent skip is a misread.

fork boundaries written twice

Header seedLength is the durable lineage boundary; the in-log session/end-seed event serves readers who only get stored bytes. seed.length replaces neither.

fork’s dual boundaries · seedLength and session/end-seed

Forking a session deep-copies events from the source up to a stable point as the child session’s seed. The hard part is the boundary: the child’s log front half is inherited seed, the back half is its own writes—and byte-wise they look identical. Where’s the cut?

The intuitive answer is count seed events at construction—seed.length. That answer fails quietly: a restored session uses the full stored log as construction seed, so the seed.length boundary drifts forward on every reopen; header seedLength keeps the value from the original fork. So DSH writes the boundary twice: first in the header—fork() writes parentSession and seedLength into create metadata; second in the log—a seeded session appends session/end-seed as its first live write after the seed, for consumers who only get stored bytes.

This boundary event solves a concrete problem. Seed history may hold an unpaired compaction/start—was that “last lifecycle crashed mid-compaction” or “compacting right now”? Bytes alone can’t tell. With session/end-seed, unpaired starts before it belong to a finished lifecycle. The type’s JSDoc is blunt: Session’s constructor is the only legal writer; a plugin that appends one on its own silently reclassifies all prior live work as seed history.

A side note on big-log restore cost. Restoring a session with 1.3M events and 62 MiB compressed, DSH never materializes the full plaintext; this round of work cut restore admit from ~600ms to 263ms (Agent Note 2026-08-05). Checksums and freezes weren’t cut: persistent storage is a runtime boundary—the fence itself doesn’t move.

Key evidence · The refusal in the original text

“Direction-aware” in source is a five-line helper sessionFormatVersionRefusal: if the version is greater, the copy is “written by a newer harness—upgrade the harness to open it”; if smaller with no upgrade path, “this build has no upgrade path for it.” Coordinators’ load checks and every storage backend share it—backends refuse foreign versions with it before decoding any structure, so users always see “please upgrade,” never “corrupt.” The red error on the demo’s left is that original text.

Source:packages/session/session-persistence/src/coordinator.ts lines 77–81; verified on 2026-08-13.

The “unknown events refuse by default” guard is shorter—one loop: type in the list, or writer marked ignorable, allow; else throw, with event type, seq, and a directional hint that it was “likely written by a newer harness”:

packages/session/session-persistence/src/coordinator.tslines 1061–1066
  private assertEventsSupported(meta: SessionHeader, events: readonly SessionEvent[]): void {
    for (const event of events) {
      if (KNOWN_SESSION_EVENT_TYPES.has(event.type) || event.ignorable === true) continue
      throw this.unsupported(meta, `session "${meta.id}" contains event type "${event.type}" (seq ${event.seq}) unknown to this harness and not marked ignorable; refusing to interpret the log — it was likely written by a newer harness`)
    }
  }
Source snapshot note:Based on the local deepseek-harness-master repo; verified against packages/session/session-persistence/src/coordinator.ts, verified on 2026-08-13. Code blocks keep the original source text.
Side-by-side · How others treat data they can’t read

Grok Build

Session-summary reads take the standard serde path. In the resume pre-read loop at persistence.rs lines 700–725, an unreadable or unparseable summary.json is continued—no error, no trace.

No session-related serde struct marks deny_unknown_fields; unknown fields are silently dropped by default—fields a new version adds vanish when an old version reads and writes back. Common for fast-iterating products; it just hands format-evolution correctness to the assumption “don’t mix old and new.”

Claude Code

Sessions live as .jsonl under ~/.claude, with resume and view. Study materials (claude-code-sourcemap study chapters) cover startup, context management, and observability, but show no restored code for session-log format negotiation or refusing unknown records.

On published evidence, behavior on unrecognized data is unknown. Closed products can fall back on “the client is always newest”; DSH is open infrastructure where versions coexist for a long time—that fallback doesn’t hold—so refusal rules live in the reader.

Classroom Exercise
01

Pick a default for your plugin event

You wrote a DSH plugin that appends a custom myplugin/audit event to the session log for each tool call’s audit info. Walk two cases: without ignorable, the user copies the log to a same-version harness without your plugin—what happens? (Hint: KNOWN_SESSION_EVENT_TYPES is generated from in-repo declarations; out-of-repo plugin events are outside the list by construction.) With ignorable: true, what happens, and where did your audit info go in the rebuild? Which events fit each choice—measure with “would losing it change how the rest of the log is interpreted?”

Takeaway:Version is one monotone integer; read rules split by direction: equal → read normally, older → upgrader chain in memory, newer → refuse plainly and point to “please upgrade.” Unknown events refuse by default—over-refusing is UX, silent skip is a security incident. fork boundaries are written twice: header seedLength plus in-log session/end-seed; seed.length replaces neither.