Persistence and Database Design
JSONL, SQLite, generic Storage, and data ownership
Conclusion: persistence is a portfolio of ownership contracts, not “the database”
DeepSeek Harness has three deliberately separate persistence families. SessionPersistence owns the authoritative event log and its non-replayable header. The generic Storage/Domain stack owns host-side records whose meaning comes from a consumer-owned schema. The attachment store owns immutable binary objects while Session events retain only content-addressed references. Workspace then composes Domain state with Session headers; it does not own either project directories or Session logs.
This separation is the chapter's most important result. JSONL and SQLite are alternative physical providers for the same Session contract, but the generic JSON and SQLite backends are not Session databases. Likewise, a Workspace account is not a foreign-key ownership claim over a transcript, and an attachment reference is not the object itself.
1. The ownership map prevents accidental cascades
| Owner | Durable data | Authoritative for | Explicitly does not own |
|---|---|---|---|
SessionPersistence | SessionHeader plus contiguous SessionEvents | Model-visible history, replay, resume, and fork facts | Workspace labels, binary image bytes, project files |
| Storage backend | Opaque JSON KV units | Atomic, durable media operations | Record schemas, write ordering, domain meaning |
| Domain | Schema-validated records and optional global state | In-memory current view, serialized writes, change events | Session-event semantics or cross-domain transactions |
| Attachment store | Immutable content-addressed bytes | Object integrity and verified image metadata | Message ordering and reference lifetime |
| Workspace | Canonical path, title, ordered candidate ids, registry order/archive state | Host-side grouping and display order | The directory, its files, or the referenced Session logs |
The Service Definition says that backends store existing SessionEvents as the event-sourced log and keep non-replayable SessionHeader metadata separately. Its API distinguishes raw artifact location, lazy creation, durable append, mutating load, non-mutating inspect, physical suffix read, and lightweight listing.PERSIST-SEAM-API
2. SessionPersistence is a behavioral seam, not a CRUD repository
create(header) // reserve identity; may remain purely in memory
append(id, contiguousBatch) // resolve only after durability
inspect(id) // balanced logical view; no physical repair
load(id) // commit repair when cold, then return a balanced view
prepare(id) // reserve the exact unpublished Session for resume
readFrom(id, seq) // detached, non-mutating stored suffix
list / listSnapshots // materialized logs only
The seam makes crash semantics part of the interface. A complete, valid interrupted final turn is preserved and deterministically closed, while a provider returns a contiguous prefix plus an opaque marker for the suffix it classifies as unrecoverable. That abstraction is broader than a literally partial physical record; Sections 6 and 8 audit the concrete classification. A created-but-never-appended Session may leave no artifact at all. SQLite therefore cannot be substituted by implementing ordinary create/read/update/delete calls—the provider must implement append continuity, identity, repair, and quiescent shutdown.
The shared backend contract requires atomic header-plus-first-batch materialization, an opaque torn-tail marker, source-qualified revisions, and durability before appendBatch resolves. It explicitly permits repair to be non-atomic so a file backend can truncate and append closers in two synced steps.PERSIST-BACKEND-CONTRACT
3. The coordinator is the real portability layer
Snapshot admission
Headers and event batches are losslessly JSON-snapshotted before they wait on a chain.
Serialize by id
Every operation for one Session ID joins the same promise chain.
Validate and commit
Format and seq continuity are checked before the provider's one durable primitive.
Publish state
The in-memory cursor advances and prepared views invalidate only after durability.
PersistenceCoordinator owns lazy identity collision checks, write-behind controllers, per-ID serialization, prepared-session caching, live adoption, crash-repair sequencing, revision retries, and disposal. Providers contribute media primitives rather than reimplementing this state machine.
The coordinator keeps backend state by Session ID, lifecycle/write-behind state by exact live Session, retirement drains, a four-phase preparation pool, and one promise chain per ID. A committed append advances materialized and cursor only after appendBatch returns.PERSIST-COORDINATOR
inspect may reuse an immutable prepared graph without repair; load and prepare reserve and commit repair; readFrom bypasses that cache and never repairs. These are deliberately different ownership operations.PERSIST-READ-FACES
4. Write-behind separates hot-path admission from a durable quiescent point
session/event → structuredClone → pending queue
first item arms one fixed deadline
deadline → stable batch → backend append
failure → batch restored at queue head; automatic retries pause
flush → cancel timer → join active write → retry and drain to empty
Later events do not extend the current batching deadline. Events admitted during one write form a follow-up batch. A background failure is reported while the exact batch remains retained; explicit flush is the retry and quiescence barrier. Backend disposal drains controllers before it closes media.
SessionWriteBehind copies admitted events, shares concurrent flush callers through one barrier, and restores a failed batch in front of newer pending events. The queue never silently acknowledges a failed durable write.PERSIST-WRITE-BEHIND
5. JSONL is a logical event stream with two physical encodings
| Property | Raw .jsonl | Default .jsonl.zstd |
|---|---|---|
| Header | First newline-terminated record | One checksummed header frame |
| Append batch | New JSONL lines | One independent checksummed frame |
| Chunk packing | Eligible delta runs may become lossless packed rows; load expands them back to events | |
| Suffix seek | None: readFrom still scans the physical prefix, then returns only the suffix | |
| Raw export | Returns the exact logical JSONL text, preserving packed rows and serialization | |
The configured root is required, then resolved once in the constructor so later process.cwd() changes cannot split one backend. Session IDs are injectively escaped before becoming path segments. Header identity and the derived cwd/id path are checked before repair or append, and a root mixing compression suffixes or the retired flat layout is rejected rather than guessed.
The provider resolves its root once, exposes an absolute location, and deliberately omits seek-capable loadStoredFrom. The format uses a dedicated header record followed by verbatim or packed storage rows, with path-safe encoding for unvalidated branded Session IDs.PERSIST-JSONL-ROOTPERSIST-JSONL-FORMAT
6. JSONL durability is strong, but its repair is intentionally two-stage
| Operation | Durability boundary | Failure behavior |
|---|---|---|
| First append | Synced temporary file; no-overwrite publish; parent-directory sync on POSIX | Same-ID publication collision rejects instead of replacing a log |
| Later append | Append encoded batch and fsync file | Write/sync failure truncates back to the previous byte length |
| Repair | Truncate and fsync, then append recovered records plus synthetic closers and fsync | A crash between the two steps can leave a shorter but still recoverable open tail |
Raw mode tolerates only an incomplete final record or a defect strictly after the last committed turn/end. Zstandard mode rejects a broken complete frame, but can salvage complete JSONL records emitted from a structurally incomplete final frame. It truncates at that frame's start, then rewrites only recovered records and closers—not the earlier committed prefix.
The reader returns a byte-offset marker plus any complete events recovered from an incomplete final frame. commitRepair first fsyncs truncation, then appends recovered events and closers through the normal synced append path. The coordinator contract makes this non-atomic sequence legal.PERSIST-JSONL-REPAIR
The raw scanner also accepts complete newline-terminated rows. If such a row is invalid JSON, has an invalid storage shape, or expands to a sequence gap, the scanner records the issue but throws only if a later decoded row closes the Turn. Otherwise finish() returns the byte offset before the defect, so repair may discard a physically complete row and all later rows in the final open Turn. The policy protects every closed Turn, but it cannot prove that this suffix was never durably committed.PERSIST-JSONL-SCANPERSIST-JSONL-REPAIR
7. Session SQLite maps the same log contract onto strict rows
| Table | Role | Identity/durability detail |
|---|---|---|
persistence_state | Singleton store UUID | Prevents equal local counters from comparing across stores |
sessions | Header columns, incarnation UUID, revision counter | Row existence is lazy materialization |
events | One row per event, primary key (session_id, seq) | Foreign-key cascade and strict columns |
The database carries both application_id and user_version. A pristine database is initialized and stamped; a nonempty unversioned database, a foreign application identity, or any version other than SCHEMA_VERSION = 15 refuses. Despite the word “version,” there is no migration path in this pre-release implementation.
Initialization holds BEGIN IMMEDIATE, checks schema ownership, creates three STRICT tables, assigns the store identity, and stamps application/schema versions before applying the requested journal mode. Existing incompatible media is rejected rather than altered into a guessed shape.PERSIST-SQLITE-SCHEMA
8. SQLite append and repair gain transaction atomicity and real suffix reads
First materialization and every event in its batch commit in one transaction. Later append batches do the same and increment the Session revision once. Load scans a transactionally consistent full row set, identifies the first invalid tail seq, then repairs by deleting seq >= tornMarker, inserting deterministic closers, and incrementing the revision in one transaction.
scanRows rejects an unparsable row or sequence gap at or before the last valid turn/end, but treats a later defect as a torn tail. commitRepair atomically deletes only that suffix and inserts closers; no valid committed row before the marker is rewritten.PERSIST-SQLITE-SCANPERSIST-SQLITE-REPAIR
A SQLite row can be transactionally present yet contain bad JSON or the wrong sequence. When that defect lies after the last valid turn/end, scanRows labels the row and every later row a never-committed torn tail, and mutating load can delete them. This is a reasonable availability choice, but “after the last closed Turn” is a semantic recovery rule—not physical evidence that the rows were torn.PERSIST-SQLITE-SCANPERSIST-SQLITE-REPAIR
loadStoredFrom executes WHERE session_id = ? AND seq >= ? ORDER BY seq and scans only that selected region. It is explicitly non-mutating: a bad selected tail is shortened in the returned view, not repaired.PERSIST-SQLITE-SUFFIX
9. The documented WAL default has a direct-constructor trap
The plugin schema defaults journalMode to wal, but SqliteSessionPersistence's constructor supplies fallback values only for cache size and batch delay. It casts the still-optional journalMode to required and passes it onward; openDatabase calls journalMode.toUpperCase(). Therefore new SqliteSessionPersistence(ctx, { path }) fails asynchronously instead of using WAL. The direct-constructor test avoids the defect by explicitly passing journalMode: 'wal'.PERSIST-SESSION-SQLITE-CONSTRUCTORPERSIST-SESSION-SQLITE-CONSTRUCTOR-TESTPERSIST-SQLITE-SCHEMA
The same shape appears in the generic SQLite storage backend: Schemastery promises a WAL default, while its public constructor casts an omitted optional value and the open path dereferences it. Loader-normalized plugin construction is safe; direct programmatic construction without journalMode is not.
SqliteStorageBackend({ path }) passes an undefined journal mode to a later toUpperCase() for the same reason. Defaults that are part of a public config contract need constructor-side normalization or a constructor type that requires already-normalized config.PERSIST-STORAGE-SQLITE-CONSTRUCTORPERSIST-STORAGE-SQLITE-OPEN
10. Generic Storage deliberately knows less than SessionPersistence
Storage hub
├─ backend registry: name → one medium and optional facets
└─ mounted forms: domain today
KV backend contract
open(unit descriptor) → loadAll / putRecord / deleteRecord / setGlobal / close
Domain form
spec + zod schemas + route → authoritative memory + one write chain + change events
KV values are opaque JSON to the backend. A unit guarantees each single call is atomic and durable, but explicitly does not serialize concurrent calls. The Domain layer supplies that ordering, validates data when opening, applies backend durability before changing its in-memory map, and emits domain/changed only after both agree.
The backend contract assigns media ownership and per-call durability to KvUnit, while leaving schemas and concurrent ordering to callers. A backend unregister operation does not close the medium; the provider plugin owns that lifecycle.PERSIST-STORAGE-SEAMPERSIST-STORAGE-REGISTRY
One per-domain promise chain makes update functions observe the value at their queue slot. A rejected backend write leaves memory untouched; a successful write updates memory before emitting a contained notification.PERSIST-DOMAIN-ORDER
The generic SQLite provider gives each unit a row in units, an optional row in unit_globals, and one STRICT u_<unit>_<table> table per declared table. Its physical user_version is 1; any other stamped layout refuses instead of migrating. Each KV mutation is one prepared statement, so the provider supplies per-call atomicity while Domain still supplies write order.PERSIST-STORAGE-SQLITE-SCHEMAPERSIST-STORAGE-SQLITE-UNIT
11. JSON KV publication is carefully durable, but relative roots remain dynamic
Every JSON KV mutation republishes the entire human-readable unit file. The writer creates a same-directory owner-only temporary file, writes and fsyncs it, atomically renames it over the target, then fsyncs the parent directory on POSIX. Windows receives atomic replacement through libuv but no explicit write-through flag. There is no cross-process writer lock.
Temp-file and target share a directory, so rename is the publication point; file sync precedes it and POSIX directory sync follows it. A failed path removes the temporary file and propagates the error.PERSIST-JSON-ATOMIC
rename() happens before the parent-directory fsync. If that fsync rejects, writeAtomic rejects after the target may already expose the new bytes. JsonKvUnit then rolls its state back, and Domain leaves its own memory unchanged because the backend call failed. The caller therefore receives failure while a reopen may observe the new file; crash durability is also uncertain. The backend needs an explicit post-publication policy—such as poisoning/reloading the unit or preserving enough state to reconcile—rather than treating every rejection as pre-publication failure.PERSIST-JSON-ATOMICPERSIST-JSON-UNIT-ROLLBACKPERSIST-DOMAIN-ORDER
The config correctly requires an explicit root, but its schema accepts relative strings and JsonStorageBackend stores the string verbatim. mkdir(root) and join(root, unit.json) run later, so a process-wide cwd change can redirect later unit opens. The Session JSONL provider avoids this exact class by resolving its root once in its constructor.PERSIST-JSON-ROOT-GAPPERSIST-JSONL-ROOT
12. Domain close has a one-way failure state
The happy path is sound: close() immediately rejects new writes, drains the settled write-chain tail, closes the backend unit, marks reads closed, then releases the facility's name reservation. Repeated close calls share one promise.
If unit.close() rejects, disposing remains true, closed remains false, onClosed() never frees the name, and disposal permanently retains the rejected promise. The resulting handle rejects all new writes but still permits reads; reopening the name also remains impossible.PERSIST-DOMAIN-CLOSE-GAP
13. Attachments use commit-before-reference and content-addressed ownership
Admission
Limit encoded bytes, fully decode the raster, verify format, dimensions, and pixel count.
Address
Hash exact encoded bytes into an opaque sha256: attachment id.
Publish
Sync a private staging file, publish with an exclusive hard link, and sync directory ancestors.
Reference
Only then may a Session event contain id, media type, byte length, dimensions, and sanitized display name.
Deduplication is safe because an existing object is read and hashed before it is accepted. Reads re-hash bytes, compare stored metadata with the logged reference, and preserve cancellation. Objects are retained indefinitely because resumed and forked Sessions may share them; no Session or Workspace deletion implicitly garbage-collects them.PERSIST-ATTACH-LIMITS
The attachment seam exposes immutable references rather than paths or provider URLs. The local provider stores owner-private objects beneath a versioned root and publishes a durable reference only after content and namespace durability checks.PERSIST-ATTACH-SEAMPERSIST-ATTACH-STORE
14. Attachment read-time decoding is documented incorrectly
The local-backend README says both write admission and reads fully decode the raster. Production source says the opposite: admission calls image.raw().toBuffer() for a full decode, while a digest-verified read calls probeImage(), which only asks Sharp for header metadata. The code's rationale is to avoid replay-time pixel amplification because the digest proves these are the exact bytes admitted earlier.PERSIST-ATTACH-DECODE-DOCPERSIST-ATTACH-DECODE-SOURCEPERSIST-ATTACH-DECODE-IMPL
The implementation still verifies integrity and reference metadata; the drift is about computational work and malformed-payload revalidation, not about skipping digest verification. Documentation should say “full decode at admission, header probe after digest verification on read.”
15. Workspace is a recoverable two-write registry over Domain state
The workspace domain (version 2) stores one record per Workspace—canonical path, title, ordered candidate Session IDs, and timestamps—plus global initialization, Workspace order, archive IDs, and an optional pending create/delete marker. First startup lists only Session headers, canonicalizes valid cwd directories, groups them, and writes the initialized marker last.PERSIST-WORKSPACE-INITPERSIST-WORKSPACE-BOOTSTRAP
Create/delete cannot be one generic cross-record transaction, so the registry writes a pending marker before the record/order pair can diverge. Startup completes only the named mutation; unexplained order/table, duplicate-path, or duplicate-session ownership still fails loud. Workspace deletion removes the registration and account only, never the directory, files, live Session, persisted log, or attachment.PERSIST-WORKSPACE-MUTATIONPERSIST-WORKSPACE-OWNERSHIP
The zod spec projects one workspaces table and one global state slot through Domain storage. Runtime validation separately checks order completeness, unique paths, and one-Workspace-per-session accounting.PERSIST-WORKSPACE-SCHEMAPERSIST-WORKSPACE-INVARIANTS
16. Workspace title, schema, and refresh contracts have visible gaps
| Finding | What source does | Consequence |
|---|---|---|
| Stale title parameter | create(path, title?) remains public, but a source TODO says its last production caller was deleted | README/API surface advertises a branch that shipped consumers no longer exercise |
| Loose scalar schemas | Workspace id, path, title, Session ids, and timestamps are plain z.string() | Empty/whitespace title, non-UUID ids, non-ISO timestamps, and non-canonical stored paths pass the schema |
| Partial compensation | Startup separately checks duplicate order ids, missing/orphan rows, duplicate paths, and duplicate Session accounting | Cross-record integrity is stronger than the zod shape, but scalar semantics remain unchecked |
| No public full refresh | Only startup clears/replaces the header index; uncached lookup calls list() and incrementally indexes returned headers | An externally deleted cached Session is not removed by that incremental pass; restart is the only guaranteed full purge |
| Cached fast path | readSessionHeader returns a cached header without re-listing or re-statting cwd | External cwd damage is observed only when another path happens to re-index that header or on restart |
The implementation accepts an optional raw title at create and any string at setTitle; the durable record schema imposes no nonblank or timestamp/UUID/path refinements. The source itself marks the create-time title parameter for removal.PERSIST-WORKSPACE-TITLE-GAPPERSIST-WORKSPACE-TITLE-SETTERPERSIST-WORKSPACE-SCHEMA
The README says external deletion or cwd damage appears after the “next refresh or restart,” but production has no public full-refresh operation. Runtime misses call incremental indexHeaders without clearing absent ids, while cached hits do no refresh at all. Restart is the only explicit full replacement path.PERSIST-WORKSPACE-REFRESHPERSIST-WORKSPACE-REFRESH-DOC
17. A useful Codex comparison: similar media, different authority topology
For Codex users, the filenames can look deceptively familiar. At official public Codex commit 66919805ea080053d1933b6b43afeb0d8bf70c91, the rollout module says JSONL Session rollouts are persisted for replay or later inspection, and its recorder writes canonical rollout items to JSONL. The public config labels sqlite_home as the directory for the SQLite state DB; the App Server's thread/list contract says its default path may scan JSONL rollouts to repair metadata, while useStateDbOnly opts out.
DeepSeek Harness makes JSONL and SQLite interchangeable authoritative Session-log providers behind one behavioral seam. Its generic SQLite state, Workspace records, and attachment objects remain separate ownership domains. The transferable lesson is not “JSONL versus SQLite”; it is to state which medium can reconstruct which facts, which medium is merely an index or projection, and which repairs are allowed to mutate authority.
18. Audit result: strong contracts, with seven concrete follow-ups
- Normalize SQLite defaults at constructor boundaries. Fix both direct-constructor
journalModepaths or require a normalized config type. - Name the torn-tail policy precisely. Document that both providers may discard physically complete defective records after the last closed Turn; consider preserving diagnostics or requiring explicit repair when physical tearing is not established.
- Resolve JSON KV post-publication failures. Keep Domain, unit memory, and the published target in a defined state when directory sync fails after rename.
- Freeze JSON KV root identity. Resolve an explicit relative root once, matching Session JSONL behavior.
- Specify failed Domain closure. Decide retry and name-release semantics so a close error cannot strand a readable-but-unwritable handle.
- Correct attachment documentation. State full decode on admission and digest-plus-header verification on read.
- Tighten or document Workspace boundaries. Remove the dead create-time title branch, validate user-facing title/timestamps/ids as intended, and expose or stop promising a runtime full refresh.
The system's central design remains coherent: durable facts have explicit owners and providers share one Session contract. Recovery is conservative through the last closed Turn, while the final open tail follows a broader policy that should be named explicitly. The static gaps cluster around repair classification, post-publication failure, configuration, lifecycle, documentation, and cache refresh—not around confusion over which component owns the event log.
My Learning Notes
Autosaved only in this browser. Nothing is uploaded or committed. Export Markdown whenever you want to keep a copy.