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.

Column Scope Holds
Meta replicated per-context metadata (root hash, DAG heads, application)
Config replicated per-context revision counters
Identity replicated context member keypairs
State replicated shared application state (the data plane)
Delta replicated causal deltas (legacy per-context DAG)
UnifiedOp replicated the unified op-store — one DAG per scope
Group replicated all governance records (prefix-partitioned, below)
Blobs / Application replicated content-addressed binaries and WASM bundles
Alias / Generic replicated name→id mappings; uncategorized
PrivateState node-local per-node private state (never synced)
SortedIndex node-local derived ordering index for sorted collections
ContextLocal, AbsorbBuffer, … node-local leave 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.

Key Column Layout Bytes
ContextMeta Meta context_id(32) 32
ContextState State context_id(32) ‖ state_key(32) 64
ContextPrivateState PrivateState context_id(32) ‖ state_key(32) 64
ContextDagDelta Delta context_id(32) ‖ delta_id(32) 64
ScopeUnifiedOp UnifiedOp scope(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:

Prefix Record Key layout
0x20 GroupMeta 0x20 ‖ group_id(32)
0x21 GroupMember 0x21 ‖ group_id(32) ‖ identity(32)
0x26 MemberCapability 0x26 ‖ group_id(32) ‖ identity(32)
0x30 GroupOpLog 0x30 ‖ group_id(32) ‖ sequence(8, big-endian)
0x34 GroupParentRef 0x34 ‖ child_id(32) → parent
0x36 NamespaceIdentity 0x36 ‖ namespace_id(32) → keypair
0x38 NamespaceGovOp 0x38 ‖ namespace_id(32) ‖ delta_id(32)
0x3A GroupKeyEntry 0x3A ‖ group_id(32) ‖ key_id(32)
0x3C NonceWindow 0x3C ‖ 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) Scope What its presence signals
ContextMigrationFailed per-context The 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.
ContextExecutingBlob per-context A 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).
ContextActivatedBlob per-context The 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.
ContextResyncRequested per-context An 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.
ApplicationPreviousBlob per-application The 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).