System Journal

The system journal records what an instance DID, one record per operational event: a store opened or closed, a checkpoint written, a backup taken or failed, damage found, a rollback, a permission denied, the system clock stepping back. A person reads it later ("what happened to this store last night?"); a host forwards it to its own logging.

It sits beside three other streams and replaces none of them:

StreamHolds
the WAL (Journal and Recovery)the changes to the DATA, commit by commit
Workload statisticsnumbers per time bucket
tracing (the CLI's GRAPHERSAL_LOG)developer diagnostics, unstable
the system journalindividual operational events, declared and stable

The journal decides nothing: no part of Graphersal reads it to decide anything, and a failed journal write never fails the operation it describes (it is a tracing warning and a counted lost record, SystemJournal::lost_records). Records hold names, counts, durations and ids: never data values, query text or secrets.

Records

Every record has the same envelope plus typed fields:

FieldMeaning
seqthe local sequence number: monotonic, never reused; the cursor of a reader
originthe instance that created the record: a random id and a readable name
origin_seqits sequence number there; (origin, origin_seq) identifies a record everywhere
timemicroseconds since the Unix epoch (UTC), from the instance clock that also times the commits: a record about commit N is never earlier than commit N
levelerror, warn, notice or info (most severe first)
sourcea dotted hierarchy: store, store.backup, store.damage, auth, clock
kindthe stable event code (store.backup_done); every kind is declared below
messagethe rendered English text
op, parentthe operation a record belongs to, and its parent operation (when set)
committhe commit in the WAL the event relates to (when set)
principalwho: the channel the work came through (cli, mcp, playground, python, host)
fieldsthe kind's declared fields

A reader of an older version shows a record of a kind it does not know by its message and fields.

A recommendation field says how to fix what the record reports, in the layout of every Graphersal note: a short headline, then each command on its own line under a # comment saying what it does. The text form of a record (JournalRecord::to_line, graphersal store journal, the playground's journal panel) keeps the one-line fields in parentheses on the record's line and puts a field of several lines below it, indented:

2026-10-09T08:53:20.000000Z #12 error  model.unavailable the model acme/tiny is missing: queries with it fail (repo=acme/tiny, identity=model2vec/acme/tiny@0123456789ab, cause=missing)
  recommendation: download the model again and import it into the store, or restore the store from a backup or replica
    # download the model's files again
    graphersal embed-model download acme/tiny
    # import them into the store in place of the damaged copy
    graphersal embed-model import acme/tiny --into <store> --replace

The JSON forms (--json, GET /api/journal, MCP, Python) keep the field as one string with its line breaks.

Repeats and states

The journal is append-only, so a repeat is collected BEFORE it is written, never by rewriting a record. Every kind declares a collapse key (its kind, its source and some of its fields; never the time). The first occurrence is written at once. A record whose key equals one of the last five written records is counted in memory instead; the group is closed by ONE journal.repeated record (with the original's origin_seq, the count and the time range) when the original leaves the last five records, when an hour has passed since the original (a continuing problem shows again), or when the journal is flushed or closed. A crash loses only the count of an open group.

A state (store.damage_found, store.read_only) is written once, its repeats are counted while it holds, a journal.state_reminder repeats its current values once per hour, and its ..._cleared kind ends it.

Where it is kept

  • A graph without a store keeps its journal in memory: the newest 10 000 records (JournalPage::truncated says when older ones are gone). The playground's graphs too.
  • A Store keeps it in its directory (format spec 4.7): segments journal/<20 digits>.jnl (the name is the first seq the segment may hold), append-only, framed like the WAL (sync marker, length, header and body CRC: a torn tail is cut by the next writer, a damaged record is skipped by resyncing on the next frame). A new segment starts past 4 MiB (StoreOptions::with_journal_segment_bytes), at an open by another instance, and after a failed write. There is no retention limit.
  • error records are written and synced at once. The others are batched in memory and written as described in When records appear, so the journal never writes in the middle of another operation of the store; a crash loses at most about the last second of records.
  • A store opened read-only (a backup, maintenance mode, a store holding something critical of a newer version) reads its stored journal; its new records stay in memory. Once damage is found while a store is open (either damage policy), its journal writes nothing more to the store's files: the records stay in memory and in the tracing mirror.
  • The instance identity lives in the instance-local file INSTANCE at the store root (a random id and a name: StoreOptions::with_instance_name, else the host name). No copy carries it: a restored backup or a converted store opened elsewhere is another instance, which writes into a new segment.
  • The journal is never store damage: verify lists its problems as notes (VerifyReport::journal_notes), and its files are left out of the damage found while open.

When records appear

  • In the process that holds the graph or the store a record is visible at once: every read (Graph::system_journal(), Store::journal(), the session, GET /api/journal, the MCP tool journal, the playground, Python's graph.journal/store.journal, the REPL) answers from the records in memory, written or not, and reads only older records from the segments, so a page never has a gap or a duplicate, before or after the batch is written.
  • On disk, and so for another process (graphersal store journal or Store::journal_dir on a store a server holds open), a record appears within about a second: a store on a file system writes the batch half a second after its first record, from a thread of its own, independent of the statistics job stats_flush. The thread writes only while no other operation of the store runs (a checkpoint, prune, compaction, rollback, attic change, verify, backup, fork, export or close; commits are not waited for, they write other files); during a long checkpoint the batch waits for its end.
  • Without threads (wasm32), and for a store in memory or in a host's own StoreDir, the host calls Store::flush_journal(): the playground does on its every-second tick (Session::tick_jobs). The system job stats_flush, SystemJournal::flush (which also closes the groups of repeats), the close and the next error record write the batch too, as does a batch of 1000 records or 1 MiB.

Copies

PathThe journal
full backup, ZIP backupevery segment (the newest up to its last complete record), checked frame by frame in the store and in the copy; a problem is a BackupReport::warnings entry, never a failed backup; listed in BackupReport::journal_files
incremental backupsegments the backup lacks are copied, the backup's newest one gets the new tail appended (a backup segment that is not a prefix of the store's is left as it is, with a warning)
restorethe backup's journal becomes the store's; the restoring instance continues in a new segment
backup from memorywhat is readable is copied
convertcarried (INSTANCE is not)
fork, attic fork, repairNOT carried: the new store's first record store.created names the store and the commit it was made from
.gsnap exportnot carried (a state, not operations)

Reading it

  • Rust: Graph::system_journal() (every graph), Store::journal() (an open store), Store::journal_dir(dir, &query) (a closed store, or one open elsewhere: no lock, nothing written). SystemJournal::query(&JournalQuery) filters by time range (since/until), level (min_level), source (source_prefix: store matches store.backup), kind, op and origin, from a cursor (after(seq)), at most limit records (default 1000): JournalPage::next_after is the next cursor, more says that more follow. JournalQuery::newest_first() answers the NEWEST matching records instead, newest first (a stored journal is read from its last segment backwards, one segment in memory at a time, and no older segment than needed is read): next_before is the cursor of the next older page (before(seq)), more says that older ones exist, and next_after is the newest record covered, so a reader shows the end of a long journal at once and then follows it. SystemJournal::subscribe(after) follows the journal (wait(timeout) blocks natively, WebAssembly polls); add_sink forwards every written record to a host's JournalSink; open_operations() is the last record of each operation without a _done/_failed record. Facade: graphersal::system_journal.
  • tracing: every written record is mirrored under the target graphersal::journal at its level (notice as info); the CLI shows them on stderr with GRAPHERSAL_LOG=info.
  • CLI: graphersal store journal <DIR> [--level L] [--since TIME] [--source P] [--kind K] [--after SEQ | --last N] [--limit N] [--follow] [--json]; --last N prints only the newest N records (The graphersal store Command).
  • Dev server: GET /api/journal?after=N&level=..&source=..&kind=..&limit=.. (newest_first=true&before=N: newest first, paging backwards), and the read-only MCP tool journal (the same keys) (Dev Server and MCP).
  • Python: graph.journal(...) (every graph; without a store its in-memory journal), store.journal(...) and Store.journal_dir(path, ...) (a closed store, or one open elsewhere), with the keyword arguments after, before, newest_first, since, until, level, source, kind, op, origin, limit and policy. A page is a dict {"records", "next_after", "truncated", "more", "notes"}; each record has the keys of store journal --json (Python).
  • Playground: Catalog ▸ System journal… shows the served graph's journal, newest first, with a level and a source filter, in the WebAssembly playground (the graph's store in memory) and over the dev server. It opens on the newest 300 records (a newest-first page), then reads only the records written since, every five seconds; "Show more" shows 300 more rows, reading older pages backwards (Web Playground).
  • Permissions: reading is the resource Journal (Resource::Journal, Read); the session, the dev server and MCP ask the authorizer before they answer. AccessPolicy::read_only() allows it. The Rust API itself asks nothing (it is the host's).

The journal has no DSL access yet.

Event catalogue

Every kind the journal writes, generated from the declarations in the code (GRAPHERSAL_UPDATE_BOOK=1 cargo test -p graphersal --lib catalogue):

KindLevelSourceFormFieldsDescription
journal.repeatedinfojournaleventof_origin_seq: the origin_seq of the original record; of_kind: the kind of the original record; count: how many more times it occurred; first_us: the first repeat (µs since the Unix epoch); last_us: the last repeat (µs since the Unix epoch)Closes a group of repeats: the record of_origin_seq occurred count more times between first_us and last_us (written when the original leaves the last five records, after an hour, or at close). Its level is the original's.
journal.state_reminderinfojournalevent, collapsed by statestate: the kind of the state; since_us: when the state began (µs since the Unix epoch); count: how many times it was reported again since the last recordA state still holds (once per hour while it does), with its current values. Its level is the state's.
journal.records_lostwarnjournalevent, collapsed by kindcount: the records not writtenWriting journal records failed; this many records are missing from the stored journal (they were kept in memory only).
clock.step_backwarnclockevent, collapsed by kindsystem_us: the system clock's reading (µs); floor_us: the instance clock (µs); behind_ms: how far the system clock is behind (ms)The system clock stepped back more than a second behind the instance clock; times stay at the instance clock until the system clock catches up.
store.openedinfostoreeventcommit_seq: the position after the open; replayed: WAL commits replayed onto the snapshot; load_ms: how long the open took (ms); read_only: whether it opened read-only (a backup, maintenance mode, ...)The store was opened.
store.recoverednoticestore.recoveryeventtorn_tail_cut_bytes: bytes of an incomplete last WAL record cut off; intent_completed: the interrupted operation completed (empty: none); tmp_removed: temporary files removed; wal_cut: whether a refused WAL record was cut offThe open repaired the traces of a crash or an interrupted operation.
store.closedinfostoreeventcommit_seq: the last commit; clean: whether it was closed cleanlyThe store was closed.
store.read_onlywarnstorestate until store.read_only_clearedreason: whyThe store is read-only: it holds something critical this version does not know.
store.read_only_clearednoticestoreends store.read_only-The store is writable again. Not emitted yet: a later version (a store turns writable only at a new open today).
store.checkpoint_doneinfostore.checkpointeventcommit_seq: the snapshot's position; merged: whether it was merged from the last snapshot and the WAL; bytes: the snapshot's size; ms: how long it took; automatic: whether the store started it itselfA checkpoint wrote a snapshot.
store.checkpoint_failederrorstore.checkpointevent, collapsed by kinderror: the error; automatic: whether the store started it itselfA checkpoint failed; nothing visible was written (level warn when the memory ceiling refused it).
store.backup_donenoticestore.backupeventtarget: where it went; mode: full, increment, zip or memory; commit_seq: the backup's position; bytes: the bytes writtenA backup was written and verified.
store.backup_failederrorstore.backupevent, collapsed by targettarget: where it should have gone; side: where damage was found: store, backup or none; error: the errorA backup failed (level warn when the memory ceiling refused it).
store.prune_donenoticestore.pruneeventup_to: the position pruned up to; snapshots_removed: snapshots removed; segments_removed: WAL segments removed; bytes: bytes freed; marks_unreachable: marks no longer reachableOld snapshots and WAL were removed.
store.damage_founderrorstore.damagestate per first_path until store.damage_clearedfound_by: the operation that found it; problems: how many problems; first_path: the first damaged fileDamage was found in the store's files while it is open.
store.damage_clearednoticestore.damageends store.damage_foundfirst_path: the file that was damagedDamage found earlier is gone. Not emitted yet: a later version (damage is cleared by a repair into a new store today).
store.maintenance_modeerrorstore.damageeventtrigger: what the open ran intoThe store opened in maintenance mode (read-only): its files are damaged.
store.repairednoticestore.damageeventtarget: the new store; commit_seq: its position; diverged: whether the salvaged state diverged from the WALThe store's salvaged state was written as a new store.
store.rolled_backnoticestore.rollbackeventtarget: the commit rolled back to; mode: move or delete; attic_entry?: the attic entry of the later historyThe store was rolled back; the later history moved into the attic (or was deleted).
store.attic_restorednoticestore.atticevententry: the attic entryAn attic entry was restored as the store's current history.
store.attic_removednoticestore.atticevententry: the attic entryAn attic entry was removed.
store.creatednoticestoreeventhow: create, packed, fork, attic_fork or repair; from_store_id?: the store it was made from; from_commit?: the position it was made fromThe store was created (the first record of every store).
store.upgradednoticestore.upgradeeventfrom_version: the old store format version; to_version: the new one; in_place: whether it was upgraded in place; models: the embedding models of models/ that became blobs (Graphersal 0.4.0 stores)The store was upgraded to this version's store format.
store.verifiedinfostore.verifyeventproblems: damage found; warnings: warnings; notes: notes (the system journal's among them)The store's files were verified (level warn when problems or warnings were found).
auth.deniedwarnauthevent, collapsed by action, resource_kind, channelaction: the action asked; resource_kind: the kind of resource; channel: the channel the work came throughThe authorizer denied a request (one per execution; no query text).
blob.damagederrorblobevent, collapsed by hash, failurehash: the blob's hash (hex); length: its length in bytes; failure: what failed: missing, length, crc or hash; found_by: the operation that found it; referrers?: the catalog definitions that reference it, as kind:name; recommendation: how to get the bytes backA blob is missing or damaged (never store damage: the store keeps working).
blob.repairednoticeblobeventhash: the blob's hash (hex); how: rewritten (written again) or restored (copied back); source?: where a restored blob came from (graphersal store restore-blobs --from)A damaged blob was replaced by intact bytes of the same hash.
blob.droppedwarnblobeventhash: the blob's hash (hex); referrers: the catalog definitions left out with it, as kind:nameA repair left out a missing or damaged blob and the catalog definitions referencing it (graphersal store repair --drop-damaged-blobs).
blob.collectedinfoblobeventcount: blobs removed; bytes: bytes freed; by: prune, attic_remove or rollback_deleteBlobs no retained history references were removed.
catalog.closed_commitnoticecatalogeventops: the catalog and schema changes of the commit; kinds: the definition kinds it changed; wal_tail_commits: the commits after the newest snapshot read to find the current catalog; ms: how long it tookA catalog change was appended to a CLOSED store's WAL as one commit, without loading the graph (format spec 12.12).
ledger.rebuiltnoticeledgereventframes: the frames written; reason: missing or damagedThe derived blob ledger was missing or damaged and was rebuilt from the WAL.
model.importednoticemodeleventrepo: its repository; identity: its identity; bytes: the bytes of its blob; default: whether it became the default model; closed?: true when the change was committed to a closed store (format spec 12.12)An embedding model was imported into the store (one commit defining it).
model.migratednoticemodeleventrepo: its repository (local/<12 hex> when 0.4.0 kept none); identity: its identity now; former_identity: its identity in the 0.4.0 store; bytes: the bytes of its blob; closed?: true when the change was committed to a closed store (format spec 12.12)An embedding model a Graphersal 0.4.0 store kept under models/ became a blob with a catalog definition (store upgrade to store format version 4; the migration commit).
model.replacednoticemodeleventrepo: its repository; old_identity: the replaced revision's identity; new_identity: the new revision's identity; closed?: true when the change was committed to a closed store (format spec 12.12)A new revision of an embedding model replaced the current one (one commit).
model.removednoticemodeleventrepo: its repository; identity: its identity; closed?: true when the change was committed to a closed store (format spec 12.12)An embedding model was removed from the store's current state (one commit).
model.default_changednoticemodeleventfrom: the repository of the previous default (empty: none); to: the repository of the new default (empty: none); closed?: true when the change was committed to a closed store (format spec 12.12)The default embedding model changed (one commit).
model.loadedinfomodelevent, collapsed by identityrepo: its repository; identity: its identity; load_bytes: the memory the loaded model takes, measured at the load (counted by the tracking allocator, else the loader's estimate), not its size on disk; ms: how long the load tookAn embedding model was loaded into memory.
model.unloadedinfomodelevent, collapsed by identityrepo: its repository; identity: its identityAn embedding model no open position uses was unloaded.
model.load_failederrormodelevent, collapsed by identity, causerepo: its repository; identity: its identity; cause: damaged, missing, does_not_fit or invalid; needed: the bytes a load needs (blob and loaded model); recommendation: how to bring the model backAn embedding model the schema needs did not load: the store did not open.
model.unavailableerrormodelevent, collapsed by identity, causerepo: its repository; identity: its identity; cause: damaged, missing, does_not_fit or invalid; recommendation: how to bring the model backAn embedding model nothing needs at open is missing or damaged: the store works, queries with the model fail.
vector_fill.startednoticevector_filleventreason: rule_added, rule_changed or model_replaced; rules: the rules filled: element, label, from, to, model; elements: the elements to embed; estimate_ms?: the estimated time, when the model's speed is knownA vector fill started under the write lock (a vector rule added or changed, or its model replaced): the whole database is locked until it ends.
vector_fill.donenoticevector_filleventembedded: the elements embedded; removed: the vectors removed (a source without text); elapsed_ms: how long the fill held the lockA vector fill ended with the commit of its unit (commit); op is its start record.
vector_fill.failederrorvector_filleventcause: the error; elapsed_ms: how long the fill held the lock; embedded_before_failure: the elements embedded before it failedA vector fill failed (the model, the memory ceiling, a cancel): its unit was rolled back, nothing was written.
index.unique_duplicateswarnindexevent, collapsed by namename: the index; key: its property key; repeated: how many values repeat; values: the first repeated values with their counts and example ids; recommendation: how to fix itA unique property index is not served after the open: its data holds repeated values (a store written without the constraint, or an index stored without the create check). Queries scan it and writes are not checked against it until it is dropped and created again after the repeats are removed.