Records each Command against one aggregate atomically — new state, result, Events and pending outbox entries — in one local SQLite file, and replays a repeated command ID from its recorded result.
Status: "Accepted local reads and writes" (build review). Local development only: no remote, CI, production, release or customer-value claim, and all 27 epics remain open. Receipts: existing-store opening ("accepted for existing-only writable opening") · read-only opening and inspection ("accepted, no open material findings") · earlier baseline build receipt.
Source: src/journal.ts · src/canonical-json.ts · src/errors.ts
Tests (39 in six suites): journal.test.ts (14) · journal-adversarial.test.ts (9) · journal-open.test.ts (2) · journal-startup-boundaries.test.ts (2) · journal-read-only.test.ts (3) · journal-existing.test.ts (9); child helpers test/helpers/journal-*child.ts. The crashed-writer inspection evidence lives in local-factory-inspection.test.ts and inspection-boundaries.test.ts.
Intelligence: none — Atomic commands, receipts and events; correctness is a transaction property, and a model cannot make a write more atomic or a replay more exact.
Terminology: a command receipt here is the stored row of a Command's inputs and result. It is not the domain Receipt (confirmation of an Action's result) from design.
What it hides
- SQLite schema, markers (
application_id0x53464a31"SFJ1",user_version1), WAL +synchronous = FULL, and theBEGIN IMMEDIATEwrite lock. - Idempotency: receipt lookup, input comparison on canonical JSON, and replay without re-running the decision.
- Optimistic concurrency: an explicit expected version per aggregate, and a global event sequence.
- Strict JSON validation and canonical form, deep-freezing of everything read back, and the mapping of open, read, transaction and close failures to a coded
JournalError.
Public interface
Everything below is exported from src/journal.ts, which re-exports all of src/errors.ts plus canonicalJson, JSON_LIMITS and type JsonValue, except jsonArrayItems, jsonObjectEntries and parseJson: those are exported only by src/canonical-json.ts.
Constants (frozen)
SCHEMA_VERSION = 1(journal.ts:23)JOURNAL_LIMITS = { maxIdLength: 128, maxEventsPerCommand: 100, maxPageSize: 1_000 }(journal.ts:24)JSON_LIMITS = { maxBytes: 1_048_576, maxDepth: 64 }(canonical-json.ts:7); these apply to each document (payload, state, result, each event's data).- ID rule (not exported):
/^[A-Za-z0-9][A-Za-z0-9._:\/-]{0,127}$/. It applies tocommandId,aggregateIdand each eventtype. - Integer rule (
checkInteger,journal.ts:507):expectedVersionandafterSequenceare safe integers from 0 toNumber.MAX_SAFE_INTEGER;limitis 1–1000;busyTimeoutMsis 0–60000. - JSON rule (
canonicalJson, every document):null, booleans, finite numbers, well-formed strings (no unpaired surrogates), plain dense arrays and plain or null-prototype objects; no cycles; at most 64 nested containers; at most 1,048,576 bytes, checked as UTF-16 length while walking and as canonical UTF-8 bytes at the end.
Opening — class CommandJournal (journal.ts:143; private constructor)
static open(path: string, options?: OpenOptions): CommandJournalcreates the file and schema if the database is empty, and otherwise opens a current journal. Writable.static openExisting(path: string, options?: OpenOptions): CommandJournalis writable and never creates. It usesfile:URLmode=rwviapathToFileURL, so#,?,%and spaces in filenames are literal.static openReadOnly(path: string, options?: OpenOptions): CommandJournaluses SQLite nativereadOnly: true. It never creates, migrates, switches to WAL or checkpoints.interface OpenOptions { readonly busyTimeoutMs?: number }: an integer from 0 to 60000, default 5000. Runtime validation checks field values only, not thatoptionsis an object: an explicitnullargument throws a nativeTypeError, other primitives fall through to the defaults, and anullfield takes its default.pathmust be a non-blank string, not':memory:', with no NUL.
Writing
execute(commandId: string, payload: JsonValue, expectedVersion: number, aggregateId: string, decide: Decide): CommandOutcome(journal.ts:224)type Decide = (state: JsonValue, payload: JsonValue, context: DecisionContext) => Decision: trusted, pure and synchronous; it must not call journal methods (journal.ts:50-54) and may run more than once for one command ID (invariant 5).interface Decision { readonly state: JsonValue; readonly result: JsonValue; readonly events: readonly EventDraft[] }andinterface EventDraft { readonly type: string; readonly data: JsonValue }interface DecisionContext { readonly commandId: string; readonly aggregateId: string; readonly version: number }, whereversionis the starting version (0 for a new aggregate).interface CommandOutcome { readonly commandId: string; readonly aggregateId: string; readonly version: number; readonly result: JsonValue; readonly replayed: boolean }
Reading (never calls application code)
readAggregate(aggregateId: string): AggregateSnapshot | undefined, returningAggregateSnapshot { readonly aggregateId: string; readonly version: number; readonly state: JsonValue }.readHistory(aggregateId: string, page?: PageOptions): readonly JournalEvent[]returns that aggregate's Events in commit order.readPendingOutbox(page?: PageOptions): readonly JournalEvent[]returns all outbox Events, oldest first.interface PageOptions { readonly afterSequence?: number; readonly limit?: number }:afterSequenceis exclusive (default 0);limitis 1–1000 (default 100).pageis treated likeoptions: only its field values are validated.interface JournalEvent { readonly sequence: number; readonly aggregateId: string; readonly version: number; readonly index: number; readonly commandId: string; readonly type: string; readonly data: JsonValue }
Lifecycle: close(): void can be called more than once.
Canonical JSON (src/canonical-json.ts; Execution, Portfolio, Project guidance and Repository import it directly)
canonicalJson(value: unknown, label: string): stringvalidates and serialises. It sorts keys by UTF-16 code units, writes no whitespace and uses ECMAScript number formatting (-0becomes0).jsonArrayItems(array: readonly unknown[], path: string): unknown[]accepts only plain, dense arrays with no extra own keys.jsonObjectEntries(object: object, path: string): [string, unknown][]accepts only plain or null-prototype objects. It rejects symbol keys, non-enumerable members, accessors and the keys__proto__,constructorandprototype.parseJson(text: string): JsonValueisJSON.parseplus deep-freezing, meant for text written bycanonicalJson. It enforces no canonical form, reserved-key rule, finiteness (1e400parses asInfinity) or size and depth limit; only invalid JSON syntax throws, as a nativeSyntaxError, and very deep input can overflow the stack while freezing. Inside journal methods such failures becomeStorageError.
Errors: JournalError (with code: JournalErrorCode) and subclasses; see Failure semantics. type CommandField = 'aggregateId' | 'expectedVersion' | 'payload'.
Invariants and guarantees
- One transaction per command. The state upsert, version + 1, command receipt, 1–100 Events and one outbox row per Event commit together under
BEGIN IMMEDIATE, or none of them do (journal.ts:233-261,:304). Tests: the storage-failure rollback test and outbox-trigger rollback (journal.test.ts,journal-adversarial.test.ts); SIGKILL before commit leaves nothing. - All argument checks run before the transaction starts. The order is: closed → read-only →
commandId→expectedVersion→aggregateId→decideis a function → payload canonicalised. Receipt matching, the version check,decideand the validation of its output run inside the transaction, so their failures roll back. Test: "rejects malformed payloads, IDs and versions before recording anything". - Receipt lookup precedes the version check. A known
commandIdwhoseaggregateId,expectedVersionand canonical payload are identical returns the recorded result withreplayed: trueandversion = expectedVersion + 1. That is the version the command produced, not the current one. The decision is not called, even after reopening or after the aggregate has moved on. Any difference throwsCommandConflictErrorwithmismatched. Tests: replay after reopen; "duplicates remain immutable across newer aggregate versions"; SIGKILL after commit. - Version check. The decision runs only if the stored version (0 if absent) equals
expectedVersion; otherwiseVersionConflictError. The update is also guarded byWHERE version = ?. Tests: eight real processes race one version and exactly one is accepted, seven getVERSION_CONFLICT; two connections cannot overwrite the same version. - Decisions are isolated.
decidereceives deep-frozen state (nullwhen the aggregate is absent), the payload re-parsed from canonical text (frozen), and a frozen context. Its output must be exactly{state, result, events}, and each event exactly{type, data}. Anything else fails withDecisionErrorand nothing is recorded: a throw, a Promise, empty, sparse or over-100 events, an event type that breaks the ID rule, or invalid JSON. Stored state may itself benull, so distinguish a new aggregate bycontext.version === 0.decideruns inside the write transaction and must not call journal methods; nothing detects re-entrancy. It may run again for the same command ID when a commit fails, because the rollback leaves no receipt (Test: the storage-failure rollback test retries and the decision runs a second time). - Canonical equality. Payloads compare as canonical text, so key order is irrelevant. Test: "canonicalises JSON…".
- Reads are passive and frozen. Reads and replays never call
decide. Every outcome, snapshot, event array and nested value is frozen. Test: "history, aggregate and outbox reads and replays never run decision callbacks". - Ordering.
sequenceis a global AUTOINCREMENT, so it strictly increases. History and outbox are ordered by it.(aggregate_id, version, idx)is unique inevents, and(aggregate_id, expected_version)is unique inreceipts, so at most one receipt exists per aggregate version. - Fails closed on identity. The application ID,
user_versionand object count are read in one statement (schemaStatus,journal.ts:409). Wrong markers throwUnsupportedSchemaErrorbefore the journal changes anything. A corrupt or truncated file throwsStorageError. In the tested fixtures the main file kept its bytes; SQLite itself opens the file before the markers are read and may still touch coordination files or recover a hot rollback journal (see Trust scope). Tests:journal-adversarial.test.ts(truncated, viaopen),journal-startup-boundaries.test.ts,journal-existing.test.ts(corrupt, viaopenExisting);openReadOnlyhas no corrupt-file test. - Writable opens require WAL (otherwise
StorageError) and setsynchronous = FULL. The WAL switch is the only retried step: it retries only on SQLITE_BUSY, with 5 ms pauses, against a monotonicperformance.now()deadline ofbusyTimeoutMs. Tests:journal-open.test.ts(processes racing onto one fresh file) and a busy WAL switch while the wall clock is stopped. openExistingnever creates. A missing path or missing parent directory isNOT_FOUND, and so is an empty database; the tests show nothing created and the empty file unchanged, with the same SQLite caveat. Its order is markers → statement preparation (tables and columns) → WAL/FULL configuration, so a rejected store keeps its header. Tests:journal-existing.test.ts, including removal between validation and open, and URI-character filenames.openReadOnlyrefuses every command withJournalReadOnlyErrorbefore any transaction or decision, replays included. Each read sees the latest commit, including a live writer's later commits and a crashed writer's committed WAL. Tests:journal-read-only.test.ts(refusal, live writer in one process),inspection-boundaries.test.ts(live writer in another process) andlocal-factory-inspection.test.ts(a killed controller's committed WAL, read throughinspect).
One code-derived caveat that has no dedicated test: open checks markers before changing anything, but it configures WAL/FULL before preparing statements (journal.ts:161-162, :354-357). A store with current markers but missing tables can therefore be switched to WAL before it fails with StorageError. openExisting does not have this gap.
Failure semantics
Every deliberate failure is a JournalError with a code. toJournalError guards each open, read, transaction and close: a JournalError thrown there keeps its class and code, and any other value becomes StorageError with cause. This is not a universal exception boundary: an explicit null options or page argument fails with a native TypeError before validation; parseJson called directly throws a native SyntaxError; and if db.close() throws while a failed open is abandoned (journal.ts:164, :189, :211), that close error escapes unconverted and hides the original.
| Code | Class (extra fields) | When | State after |
|---|---|---|---|
INVALID_INPUT | InvalidInputError | Bad ID, version, page, path, busyTimeoutMs or JSON | Storage untouched |
COMMAND_CONFLICT | CommandConflictError (commandId, mismatched) | Command ID reused with different inputs | Nothing written |
VERSION_CONFLICT | VersionConflictError (aggregateId, expectedVersion, actualVersion) | Stale expected version | Nothing written |
DECISION_FAILED | DecisionError (commandId, cause) | Decision threw, returned a Promise or returned an invalid decision (an InvalidInputError becomes cause) | Rolled back |
UNSUPPORTED_SCHEMA | UnsupportedSchemaError | Foreign database, other application ID, or user_version ≠ 1 | Journal writes nothing; SQLite coordination files may appear |
STORAGE | StorageError (cause) | SQLite busy beyond busyTimeoutMs, I/O, constraint, corrupt file, missing tables or columns, close failure | Documented contract: "the write, if any, was rolled back" |
CLOSED | JournalClosedError | execute or any read after close(); a second close() returns silently | — |
NOT_FOUND | JournalNotFoundError | openExisting or openReadOnly on a missing path or an empty database (the class comment mentions only read-only) | Nothing created |
READ_ONLY | JournalReadOnlyError | execute on an openReadOnly handle | No transaction |
- Unknown vs failed. A thrown error means this call recorded nothing.
COMMAND_CONFLICTmeans an earlier call already recorded that command ID with different inputs, and that receipt stands. If the process dies beforeexecutereturns, the outcome is unknown, not failed. Retry with the samecommandIdand identical inputs:replayed: truemeans it had committed. The same retry also resolves any doubt after aStorageError. - Replay is historical.
replayed: truereports the past result, not current validity. Re-read current state before acting on it; for example, Execution must never launch from a replayed claim. - No internal retry except the WAL switch at open. Lock waits use SQLite's busy timeout; callers own every other retry. Consumers treat
VersionConflictErrorandCommandConflictErroras retry or skip signals (for exampleguarded-verification.ts, andrecordRequestinlocal-factory.ts). decideruns while holding the write lock, with no timeout. Other connections wait up to their ownbusyTimeoutMsand then getStorageError.
Trust scope
Established (local, per build review and receipts):
- Atomic record, replay and version conflict under real concurrent processes.
- SIGKILL before and after commit.
- Concurrent creation of one fresh file.
- Fail-closed handling of foreign, unsupported, corrupt, truncated, empty and missing stores.
openExisting: removal just before opening cannot create a replacement. The receipt recorded 41/41 focused tests; that command included<local evidence file>, which is not in the repository, so the six suites intest/(39 tests) cannot reproduce that count on their own.openReadOnly: no initialisation, commands refused, later commits visible.
Not established:
- Outbox delivery or acknowledgement. None exists, so every Event stays pending indefinitely.
- Migrations. Only schema 1 is accepted.
- Retention and compaction. Receipts and history grow without bound.
- Power-loss durability. Only process-crash evidence exists, despite
synchronous = FULL. - Multiple hosts and network filesystems. Neither is supported; WAL needs local storage.
- Storage-identity fencing. A valid replacement journal is accepted, and an open handle follows a moved file.
- Full schema validation.
openExistingchecks markers and statement preparation, not every constraint orSTRICTdeclaration. - Hot rollback-journal recovery. SQLite opens the file natively before the markers are read, so a writable open may recover a hot rollback journal left by a crashed writer before any check runs; the receipt allows this "normal filesystem semantics" and no dedicated test covers it. Read-only behaviour with a hot rollback journal is not established. Coordination sidecars, by contrast, are covered: read-only readers leave
-waland-shm(journal-existing.test.ts) while the main and WAL images stay unchanged (journal-read-only.test.ts). - Multi-read snapshots. Each read is its own snapshot.
- Re-validation of stored data. Stored JSON is trusted when read back. The file is trusted local storage, and a writer with full access is not defended against.
- Proxy sandboxing and re-entrancy. Canonical validation is a strict-input check for trusted callers.
decideis trusted code; purity is expected, not enforced beyond frozen inputs, and adecidethat calls journal methods is not detected. - Exactly-once effects outside the journal. Replay avoids re-running the decision and the journal update only; a caller's other side effects can repeat.
Composition
Depends on: Node.js ≥ 26.8.1 (package.json engines) with node:sqlite (DatabaseSync), node:fs (existsSync), node:perf_hooks, node:url (pathToFileURL), node:buffer; no third-party runtime dependency; internally only canonical-json.ts and errors.ts. npm run check uses the pinned development dependencies, TypeScript 7.0.2 and @types/node 26.6.3.
Used by:
- Execution,
src/execution.ts:Execution#command→execute;Execution.read→readAggregate;Execution.history→readHistory. It also importscanonicalJson,jsonArrayItemsandjsonObjectEntriesdirectly. - Guarded verification / recovery:
src/attempt-ledger.ts:AttemptLedger#create→execute(…, 0, …)(create-once records); ledger reads usereadAggregate;recordFenceProoftreatsVersionConflictErrorandCommandConflictErroras "already recorded".src/local-factory.ts:withStateusesCommandJournal.openfor writable access oropenReadOnlyfor read-only access, mappingJournalNotFoundErrorto its ownnot-found;existingchecksexistsSyncfirst becauseopenwould create a missing journal;recordRequest→execute, swallowing both conflict errors.src/guarded-verification.ts: catches the conflict errors.
- Portfolio,
src/portfolio.ts:Portfolio#registryCommandandPortfolio#import→execute;listProductsandreadStream→readAggregate/readHistory. It also importsjsonObjectEntriesandparseJsondirectly. - Project guidance,
src/project-guidance.ts:ProductGuidance.setValues→execute;#reconciled→readAggregate/readHistory. It also importsjsonObjectEntriesdirectly. - Dashboard,
src/dashboard-server.ts:startDashboardServerprobes withopenReadOnly(…).close(); GET requests useopenReadOnly; the two POST commands (withExistingJournal,inspectCommand) useopenExisting, neveropen. It importsJournalError,JournalNotFoundError,StorageErrorandUnsupportedSchemaErrorto map failures to HTTP responses. - Repository,
src/repository.ts: imports canonical JSON helpers only and never writes the journal. src/factory-support.ts(digestOf,canonical,asJson) usescanonicalJson.- Product gate and delivery records,
src/product-gate.tsandsrc/delivery-records.ts: every gate command →execute(the gate mapsVersionConflictErrortoSTALEandCommandConflictErrortoCONFLICT);ProductGate.read→readAggregate; the delivery ledger writes create-once records withexecute(…, 0, …)and re-reads the stored record on a replay or a conflict.src/repository-assurance.ts(Repository Assurance) andsrc/repository-collector.ts(Repository collector, accepted as step 1, increment 3) importcanonicalJsononly. Only tests call them.
Changing it safely
- Focused tests:
node --test test/journal.test.ts test/journal-open.test.ts test/journal-read-only.test.ts test/journal-adversarial.test.ts test/journal-startup-boundaries.test.ts test/journal-existing.test.ts. Then run the full gate,npm run check, because every consumer under Composition depends on these semantics and on the error classes (instanceof). - What each suite proves:
journal.test.ts: API contract, validation and rollback.journal-adversarial.test.ts: crashes, races and corruption.journal-open.test.tsandjournal-startup-boundaries.test.ts: concurrent creation, the one-snapshot schema check and the monotonic retry.journal-read-only.test.ts: native read-only behaviour.journal-existing.test.ts: existing-only writable opening.
- Schema changes need a new
SCHEMA_VERSIONand an explicit migration. Never editSCHEMA_SQLin place: existing files would pass the marker check with the wrong tables. - What reviewers check:
- All argument validation happens before
BEGIN IMMEDIATE, and the read-only refusal comes first; decision-output validation stays inside the transaction so it rolls back. - Receipt lookup stays before the version check.
- A replay's
versionisexpectedVersion + 1. schemaStatusstays one statement.openExistingkeeps its order: markers, then statements, then write configuration.- The WAL retry stays limited to SQLITE_BUSY with a monotonic deadline.
- Reads never call application code, and outputs stay frozen.
- Error classes and codes stay stable.
- ID and JSON limits are not widened without checking the consumers' aggregate-ID formats.
- All argument validation happens before
- Receipts to refresh: as of 28 September 2026 the source hashes match the receipts:
journal.tsmatches journal-existingsource.sha256,errors.tsmatches inspection, andcanonical-json.tsmatches the build and continuation receipts. A behavioural change makes the receipt stale (AGENTS.md): fresh independent review, a newdocs/*-receipt.json(never rewrite old hashes), the build review row, the Command journal section of local development, both module pages and the factory model.
