Factory docs, home
Page navigation

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_id 0x53464a31 "SFJ1", user_version 1), WAL + synchronous = FULL, and the BEGIN IMMEDIATE write 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 to commandId, aggregateId and each event type.
  • Integer rule (checkInteger, journal.ts:507): expectedVersion and afterSequence are safe integers from 0 to Number.MAX_SAFE_INTEGER; limit is 1–1000; busyTimeoutMs is 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): CommandJournal creates the file and schema if the database is empty, and otherwise opens a current journal. Writable.
  • static openExisting(path: string, options?: OpenOptions): CommandJournal is writable and never creates. It uses file: URL mode=rw via pathToFileURL, so #, ?, % and spaces in filenames are literal.
  • static openReadOnly(path: string, options?: OpenOptions): CommandJournal uses SQLite native readOnly: 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 that options is an object: an explicit null argument throws a native TypeError, other primitives fall through to the defaults, and a null field takes its default.
  • path must 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[] } and interface EventDraft { readonly type: string; readonly data: JsonValue }
  • interface DecisionContext { readonly commandId: string; readonly aggregateId: string; readonly version: number }, where version is 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, returning AggregateSnapshot { 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 }: afterSequence is exclusive (default 0); limit is 1–1000 (default 100). page is treated like options: 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): string validates and serialises. It sorts keys by UTF-16 code units, writes no whitespace and uses ECMAScript number formatting (-0 becomes 0).
  • 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__, constructor and prototype.
  • parseJson(text: string): JsonValue is JSON.parse plus deep-freezing, meant for text written by canonicalJson. It enforces no canonical form, reserved-key rule, finiteness (1e400 parses as Infinity) or size and depth limit; only invalid JSON syntax throws, as a native SyntaxError, and very deep input can overflow the stack while freezing. Inside journal methods such failures become StorageError.

Errors: JournalError (with code: JournalErrorCode) and subclasses; see Failure semantics. type CommandField = 'aggregateId' | 'expectedVersion' | 'payload'.

Invariants and guarantees

  1. 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.
  2. All argument checks run before the transaction starts. The order is: closed → read-only → commandId → expectedVersion → aggregateId → decide is a function → payload canonicalised. Receipt matching, the version check, decide and the validation of its output run inside the transaction, so their failures roll back. Test: "rejects malformed payloads, IDs and versions before recording anything".
  3. Receipt lookup precedes the version check. A known commandId whose aggregateId, expectedVersion and canonical payload are identical returns the recorded result with replayed: true and version = 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 throws CommandConflictError with mismatched. Tests: replay after reopen; "duplicates remain immutable across newer aggregate versions"; SIGKILL after commit.
  4. Version check. The decision runs only if the stored version (0 if absent) equals expectedVersion; otherwise VersionConflictError. The update is also guarded by WHERE version = ?. Tests: eight real processes race one version and exactly one is accepted, seven get VERSION_CONFLICT; two connections cannot overwrite the same version.
  5. Decisions are isolated. decide receives deep-frozen state (null when 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 with DecisionError and 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 be null, so distinguish a new aggregate by context.version === 0. decide runs 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).
  6. Canonical equality. Payloads compare as canonical text, so key order is irrelevant. Test: "canonicalises JSON…".
  7. 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".
  8. Ordering. sequence is a global AUTOINCREMENT, so it strictly increases. History and outbox are ordered by it. (aggregate_id, version, idx) is unique in events, and (aggregate_id, expected_version) is unique in receipts, so at most one receipt exists per aggregate version.
  9. Fails closed on identity. The application ID, user_version and object count are read in one statement (schemaStatus, journal.ts:409). Wrong markers throw UnsupportedSchemaError before the journal changes anything. A corrupt or truncated file throws StorageError. 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, via open), journal-startup-boundaries.test.ts, journal-existing.test.ts (corrupt, via openExisting); openReadOnly has no corrupt-file test.
  10. Writable opens require WAL (otherwise StorageError) and set synchronous = FULL. The WAL switch is the only retried step: it retries only on SQLITE_BUSY, with 5 ms pauses, against a monotonic performance.now() deadline of busyTimeoutMs. Tests: journal-open.test.ts (processes racing onto one fresh file) and a busy WAL switch while the wall clock is stopped.
  11. openExisting never creates. A missing path or missing parent directory is NOT_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.
  12. openReadOnly refuses every command with JournalReadOnlyError before 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) and local-factory-inspection.test.ts (a killed controller's committed WAL, read through inspect).

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.

CodeClass (extra fields)WhenState after
INVALID_INPUTInvalidInputErrorBad ID, version, page, path, busyTimeoutMs or JSONStorage untouched
COMMAND_CONFLICTCommandConflictError (commandId, mismatched)Command ID reused with different inputsNothing written
VERSION_CONFLICTVersionConflictError (aggregateId, expectedVersion, actualVersion)Stale expected versionNothing written
DECISION_FAILEDDecisionError (commandId, cause)Decision threw, returned a Promise or returned an invalid decision (an InvalidInputError becomes cause)Rolled back
UNSUPPORTED_SCHEMAUnsupportedSchemaErrorForeign database, other application ID, or user_version ≠ 1Journal writes nothing; SQLite coordination files may appear
STORAGEStorageError (cause)SQLite busy beyond busyTimeoutMs, I/O, constraint, corrupt file, missing tables or columns, close failureDocumented contract: "the write, if any, was rolled back"
CLOSEDJournalClosedErrorexecute or any read after close(); a second close() returns silently—
NOT_FOUNDJournalNotFoundErroropenExisting or openReadOnly on a missing path or an empty database (the class comment mentions only read-only)Nothing created
READ_ONLYJournalReadOnlyErrorexecute on an openReadOnly handleNo transaction
  • Unknown vs failed. A thrown error means this call recorded nothing. COMMAND_CONFLICT means an earlier call already recorded that command ID with different inputs, and that receipt stands. If the process dies before execute returns, the outcome is unknown, not failed. Retry with the same commandId and identical inputs: replayed: true means it had committed. The same retry also resolves any doubt after a StorageError.
  • Replay is historical. replayed: true reports 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 VersionConflictError and CommandConflictError as retry or skip signals (for example guarded-verification.ts, and recordRequest in local-factory.ts).
  • decide runs while holding the write lock, with no timeout. Other connections wait up to their own busyTimeoutMs and then get StorageError.

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 in test/ (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. openExisting checks markers and statement preparation, not every constraint or STRICT declaration.
  • 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 -wal and -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. decide is trusted code; purity is expected, not enforced beyond frozen inputs, and a decide that 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 imports canonicalJson, jsonArrayItems and jsonObjectEntries directly.
  • Guarded verification / recovery:
    • src/attempt-ledger.ts: AttemptLedger#create → execute(…, 0, …) (create-once records); ledger reads use readAggregate; recordFenceProof treats VersionConflictError and CommandConflictError as "already recorded".
    • src/local-factory.ts: withState uses CommandJournal.open for writable access or openReadOnly for read-only access, mapping JournalNotFoundError to its own not-found; existing checks existsSync first because open would create a missing journal; recordRequest → execute, swallowing both conflict errors.
    • src/guarded-verification.ts: catches the conflict errors.
  • Portfolio, src/portfolio.ts: Portfolio#registryCommand and Portfolio#import → execute; listProducts and readStream → readAggregate / readHistory. It also imports jsonObjectEntries and parseJson directly.
  • Project guidance, src/project-guidance.ts: ProductGuidance.setValues → execute; #reconciled → readAggregate / readHistory. It also imports jsonObjectEntries directly.
  • Dashboard, src/dashboard-server.ts: startDashboardServer probes with openReadOnly(…).close(); GET requests use openReadOnly; the two POST commands (withExistingJournal, inspectCommand) use openExisting, never open. It imports JournalError, JournalNotFoundError, StorageError and UnsupportedSchemaError to 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) uses canonicalJson.
  • Product gate and delivery records, src/product-gate.ts and src/delivery-records.ts: every gate command → execute (the gate maps VersionConflictError to STALE and CommandConflictError to CONFLICT); ProductGate.read → readAggregate; the delivery ledger writes create-once records with execute(…, 0, …) and re-reads the stored record on a replay or a conflict. src/repository-assurance.ts (Repository Assurance) and src/repository-collector.ts (Repository collector, accepted as step 1, increment 3) import canonicalJson only. 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.ts and journal-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_VERSION and an explicit migration. Never edit SCHEMA_SQL in 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 version is expectedVersion + 1.
    • schemaStatus stays one statement.
    • openExisting keeps 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.
  • Receipts to refresh: as of 28 September 2026 the source hashes match the receipts: journal.ts matches journal-existing source.sha256, errors.ts matches inspection, and canonical-json.ts matches the build and continuation receipts. A behavioural change makes the receipt stale (AGENTS.md): fresh independent review, a new docs/*-receipt.json (never rewrite old hashes), the build review row, the Command journal section of local development, both module pages and the factory model.

Source: docs/agents/command-journal.md