Skip to content

Storage Schema

This page specifies the local persistence layer. A node stores everything in a key-value store (RocksDB) partitioned into column families. Two encodings are used for values: Identity (raw bytes, no framing) and Borsh (the same compact binary encoding used on the wire). Reimplementations are free to choose any backend — what matters for interop is the logical content, not the column layout; this page documents the reference layout because it makes the data model concrete.

Some columns replicate (their content syncs between nodes); others are strictly node-local and never leave the machine.

ColumnScopeHolds
Metareplicatedper-context metadata (root hash, DAG heads, application)
Configreplicatedper-context revision counters
Identityreplicatedcontext member keypairs
Statereplicatedshared application state (the data plane)
Deltareplicatedcausal deltas (legacy per-context DAG)
UnifiedOpreplicatedthe unified op-store — one DAG per scope
Groupreplicatedall governance records (prefix-partitioned, below)
Blobs / Applicationreplicatedcontent-addressed binaries and WASM bundles
Alias / Genericreplicatedname→id mappings; uncategorized
PrivateStatenode-localper-node private state (never synced)
SortedIndexnode-localderived ordering index for sorted collections
ContextLocal, AbsorbBuffer, …node-localleave markers, straggler buffers, migration/blob breadcrumbs

Keys are fixed-size concatenations of 32-byte ids. The leading id is the partition prefix, so a range scan over it returns everything for one context or scope.

KeyColumnLayoutBytes
ContextMetaMetacontext_id(32)32
ContextStateStatecontext_id(32) ‖ state_key(32)64
ContextPrivateStatePrivateStatecontext_id(32) ‖ state_key(32)64
ContextDagDeltaDeltacontext_id(32) ‖ delta_id(32)64
ScopeUnifiedOpUnifiedOpscope(32) ‖ op_id(32)64

The unified op store is the key one for the target model: keyed scope ‖ op_id, so all operations of a scope are a contiguous range, and the value is simply the Borsh-encoded Op from Operations, stored verbatim (Identity codec).

The two structures a reimplementation most needs. Both are Borsh.

struct ContextMeta { // Meta column
application: [u8; 32],
root_hash: [u8; 32], // storage Merkle root = entity root
dag_heads: Vec<[u8; 32]>,
service_name: Option<str>,
}
struct ContextDagDelta { // Delta column (legacy data DAG)
delta_id: [u8; 32], parents: Vec<[u8; 32]>,
actions: Vec<u8>, hlc: HybridTimestamp, applied: bool,
expected_root_hash: [u8; 32],
events: Option<Vec<u8>>, author_id: Option<PublicKey>,
governance_position_blob: Option<Vec<u8>>,
delta_signature: Option<[u8; 64]>,
}

All governance record types share one column, distinguished by a 1-byte prefix followed by the relevant ids. A representative selection:

PrefixRecordKey layout
0x20GroupMeta0x20 ‖ group_id(32)
0x21GroupMember0x21 ‖ group_id(32) ‖ identity(32)
0x26MemberCapability0x26 ‖ group_id(32) ‖ identity(32)
0x30GroupOpLog0x30 ‖ group_id(32) ‖ sequence(8, big-endian)
0x34GroupParentRef0x34 ‖ child_id(32) → parent
0x36NamespaceIdentity0x36 ‖ namespace_id(32) → keypair
0x38NamespaceGovOp0x38 ‖ namespace_id(32) ‖ delta_id(32)
0x3AGroupKeyEntry0x3A ‖ group_id(32) ‖ key_id(32)
0x3CNonceWindow0x3C ‖ group_id(32) ‖ signer(32)

Note the GroupOpLog sequence is big-endian so keys sort in numeric order; id-based keys use the raw 32 bytes. The key_id in GroupKeyEntry is SHA-256(scope_key), matching the key_id a StateDelta advertises on the wire (see Encryption).

A handful of node-local columns hold no protocol state — they are breadcrumbs an in-progress (or stalled) upgrade leaves behind, and the signal an operator requested a recovery. None of them synchronize; each gets its own column so its context_id-only (or application_id-only) key can’t collide with a same-shaped key in a shared column. For most of them the presence of the row is the signal and the value (if any) carries a reason or a pinned blob id. They are worth inspecting directly when an upgrade looks stuck (the recover a stuck upgrade runbook uses exactly these).

Marker (column)ScopeWhat its presence signals
ContextMigrationFailedper-contextThe last migration attempt did not complete. The value’s kind byte categorises the failure: 1 = the migration check aborted, 2 = the migrate apply errored. Read by the migration heartbeat; self-clears when a later migrate commits (or a resync settles). A missing row = no failure on record.
ContextExecutingBlobper-contextA logically-aborted migration left this context executing OLD bytecode. The version-stable bundle id’s Application row already holds the NEW bytecode, so this pin keeps reads on the pre-upgrade code until a migrate succeeds. Deleted on a successful migrate. A missing row = “execute the row’s bytecode” (the normal case).
ContextActivatedBlobper-contextThe bytecode blob this context last activated — set when a migration commits or a code-only swap applies, and moved forward only. The single up-to-date check everywhere is marker == group.app_key; if it lags the group’s current app key, this node has not caught up to the activated code.
ContextResyncRequestedper-contextAn operator requested a full-state resync of this context (the recovery for a context stranded with no migration path). Its presence admits a snapshot full-state replacement; it is cleared once that snapshot completes.
ApplicationPreviousBlobper-applicationThe bytecode blob an in-place (same-id) install overwrote. It is the source for the executing-blob pin above and the pre-install ABI the downgrade gate compares against. A missing row = no prior in-place install on record.

These columns are auto-created from the column set at open time (no DB migration), so they exist even on a node that has never upgraded — they simply hold no rows until a migration writes one.

Atomic writes: temporal staging and the WriteBatch

Section titled “Atomic writes: temporal staging and the WriteBatch”

A node never dribbles individual puts to the backend in the middle of an operation. Writes flow through a small stack of composable layers (crates/store/src/layer.rs:16-61): Layer is the base marker, ReadLayer adds get/has/iter, and WriteLayer adds put/delete/apply/commit. LayerExt lets any layer wrap itself — .temporal() produces a staging shadow, .read_only() a read-only view — so the stack composes without the backend knowing.

The staging layer is Temporal (crates/store/src/layer/temporal.rs:11-104). It wraps a mutable inner layer and holds a shadow Transaction. Every put/delete records the op into the shadow only; nothing touches the inner layer yet. Reads consult the shadow first, then fall through to the inner layer:

  • get: a shadow Put returns the staged value; a shadow Delete returns None; a shadow miss delegates to the inner layer.
  • iteration merges the inner layer’s keys with the shadow’s, hiding any key the shadow has deleted.

So an operation reads its own uncommitted writes, while the persisted store stays untouched until the end. commit() is a single line — self.inner.apply(&self.shadow) — handing the whole staged transaction to the inner layer’s apply. On the RocksDB-backed Store that lands as one WriteBatch (crates/store/src/lib.rs:127-135): every put and delete in the set — across column families — is persisted together, or none is. Drop the Temporal without committing and the staged set is simply discarded.

The same all-or-nothing primitive is exposed directly as StoreBatch (crates/store/src/batch.rs:9-77) for callers that must move several keys together (for example, cascade-delta records plus their context’s dag_heads): put/delete stage into a Transaction, commit() is one Store::apply, and a serialization error surfaces at stage time — before anything is written.

That is the on-disk shape of everything the rest of the reference describes. Continue to Limits, capabilities & roles for the fixed constants the store enforces, or revisit Operations (whose Op is what the unified store holds) and Projection (whose root hash is the root_hash in ContextMeta).