Every packages/*/* README now carries a canonical '## Known Limitations and
Deferred Work' section: condensed, evidence-backed bullets for consumer-visible
gaps (unimplemented features, platform caveats, MVP cuts) and consciously
postponed work (TODO/FIXME/XXX markers, RFC deferrals still open). The ten
pre-existing ad-hoc variants ('What is NOT here (TODO)', 'Deferred',
'Limitations (MVP)', 'Known limitations (tracked TODOs)', ...) are normalized
into the canonical heading.
A new doc-sync gate, scripts/verify-readme-limitations.ts, enforces the shape:
exactly one limitations-like heading per package README, byte-equal to the
canonical h2, with at least one bullet; near-miss headings fail so variants
cannot creep back. Packages with genuinely nothing to declare (dsh-brand,
dsh-timeout, dsh-subagent-mock, dsh-app-boot) are whitelisted in the script and
must NOT carry the section; whitelist entries are validated against the scanned
package set so a rename fails loud.
Wired into the doc-sync chain (package.json) and the run-gates doc-sync leaf
set; the standing rule lands in packages/AGENTS.md and the adding-a-package
cookbook; decision record in
docs/rfc/implemented/process/2026-07-10-readme-known-limitations-gate.md
(RFC index regenerated).
Also fixes two stale '(deferred)' markers claiming dsh-compact-basic is
unimplemented (the dsh-compact seam README's package table and the seam's
module doc comment).
@deepseek-ai/dsh-session-persistence-sqlite
A SQLite durable session-persistence backend — a second SessionPersistence implementation (session persistence), built to validate that the abstract seam and the shared runPersistenceContract suite are genuinely backend-agnostic. It satisfies the SAME contract as dsh-session-persistence-jsonl (append-only, contiguous-seq, lazy materialization, interrupted-turn close on load), expressed over node:sqlite rows instead of file bytes.
Storage model
Each SessionEvent maps 1:1 onto a row in an events table (session_id, seq, type, time, data, source_event_seqs, surface_op) — data is the event payload as JSON text, so the row shape is the event verbatim (including assistant/chunk, keeping seq contiguous). The two TEXT columns source_event_seqs and surface_op are nullable; they store the event's optional surface-metadata fields (see session surface). Out-of-log metadata (SessionHeader) lives in a sessions row. A sessions row is written only by the first append — its existence is the lazy-materialization signal (list reports exactly the sessions that have a row), so no separate column is needed.
The repo's engines.node is ^22.19.0 || >=24.0.0 (Node 22.19+ or 24+), matching the LTS floor required by the installed Pi adapter dependency; node:sqlite itself ships without the --experimental-sqlite flag from Node 22.13 (LTS) and 23.4 / 24 (Current) on. The range deliberately excludes Node 23 because that line is non-LTS/EOL and still has flagged runtime features before 23.6. The database opens with foreign_keys = ON (so ON DELETE CASCADE drops a session's events with its row) and the configured journal_mode (default wal; pick a rollback-journal mode like delete on filesystems where WAL's shared-memory files do not work, e.g. network mounts). The table-layout version is stored in PRAGMA user_version and checked on open: a fresh database is stamped with the current SCHEMA_VERSION; a database written by any other, incompatible build (a non-current user_version, older or newer) is rejected rather than opened against an unknown layout — there is no migration (unreleased software).
Contract semantics over rows
- Append = a transaction.
appendrunsBEGIN/COMMITaround the batch: it materializes thesessionsrow (if still lazy) and INSERTs every event, asserting the contiguous-seq contract first (the first event'sseqmust equal the stored next-seq). A mid-batch failure (a UNIQUE violation on a duplicated seq) rolls back entirely, so the stored log and the in-memory cursor stay consistent. (load()already balanced the stored log, soappendnever has to repair a crash tail.) - Lazy materialization.
create()records intent in memory only — no row is written until the firstappend. A created-but-never-appended session has nosessionsrow, so it is absent fromlist()(which reports exactly the sessions that have a row). - Interrupted-turn close on load.
load()reads every stored event ordered byseqand finds the longest seq-contiguous, parseable prefix — INCLUDING the real events of an interrupted final turn after the lastturn/end(the loop only flushes atturn/end, so a process killed mid-turn leaves real, fully-written rows past it). A single turn can be huge in a long-horizon task, so those events are preserved, never truncated:load()CLOSES the orphaned turn by durably appending the minimal synthetic boundary events (an errortool/resultfor every assistant tool call left unanswered, astep/endif a step was open, then aturn/endcarrying{ kind: 'interrupted' }), inside one transaction that also DELETEs any never-fully-written torn tail row.load()is therefore mutating — after it the stored rows are balanced and the cursor is truthful, so the nextappendcontinues cleanly. The boundary (lastturn/end, torn-tail detection) is computed from theseq/typecolumns so a malformeddatain a torn tail row is never parsed (discarded, not unloadable). A parse error orseqgap inside the committed region (at or before the last realturn/end) makes the session unloadable. A session whose only turn never closed keeps its metadata row and stays present inlist()— the same as the JSONL backend, whose file likewise survives a first append that never reachedturn/end.
Configuration (schemastery)
interface Config {
path: string // SQLite database file path, or ':memory:' for an in-process DB
journalMode?: 'wal' | 'delete' | 'truncate' | 'persist' // journal_mode pragma; default 'wal'
}
Write path
Like the JSONL backend, the plugin also installs the session/event → buffer → session/flush drain: it snapshots each event when buffered (the live session.events object is mutable), persists a fork's seed once on session/created, keeps a per-session write cursor so a resumed session never re-appends stored events, and seeds existing live sessions on apply (HMR does not replay session/created). Dispose awaits every in-flight init + final drain and then closes the database, so no write lands after teardown.
Known Limitations and Deferred Work
- Raw
node:sqlite, pending a cordis database service — the backend holds aDatabaseSyncdirectly; if acordis/db/@cordisjsSQL driver is adopted, the storage driver routes through it (theSessionPersistencecontract would not change) — a marked TODO. DatabaseSyncis synchronous — every append transaction blocks the event loop for its duration; acceptable for local stores, a throughput ceiling for busy multi-session servers.- Only the current
SCHEMA_VERSIONopens — a database written by any other build is rejected rather than migrated (unreleased software; no persisted user data to preserve). - Nothing deletes stored sessions — rows accumulate until removed externally (the seam has no deletion surface;
ON DELETE CASCADEis wired for such out-of-band cleanup).