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.
Column families
Section titled “Column families”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 |
Key byte-layouts
Section titled “Key byte-layouts”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).
Key value structures
Section titled “Key value structures”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]>,}The Group column prefix scheme
Section titled “The Group column prefix scheme”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).
Node-local migration & recovery markers
Section titled “Node-local migration & recovery markers”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 shadowPutreturns the staged value; a shadowDeletereturnsNone; 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.
See also
Section titled “See also”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).