Documentation/Under the hood
Storage format11 min read

On-disk format specification

On this page

Normative description of every byte Scribe writes to disk. Version 1 of each component is frozen; a change means a new version number. All multi-byte integers are little-endian unless a field is explicitly marked ordered (big-endian, used only inside lexicographically compared index keys). [n] means n raw bytes. str16 is a u16 byte length followed by UTF-8; str32/bytes32 use a u32 length. Digests are BLAKE3-256, 32 bytes, never truncated.

Reference implementation: crates/scribe-format (encodings, keys, descriptor), crates/scribe-objects (pack and loose layout), crates/scribe-metadata (tables), crates/scribe-workspace (workspace records).

1. Identifiers and hashing

typebytesdefinition
RepositoryId, BranchId, WorkspaceId, OperationId16OS random; opaque; never derived from names
ChunkId32blake3(chunk bytes)
FileContentId32blake3(complete file bytes) computed while streaming; never assembled from chunk digests
ManifestId32blake3("scribe:manifest:v1\0" ‖ manifest payload)
ChangeSetId32blake3("scribe:changeset:v1\0" ‖ change set payload)
CommitId32blake3("scribe:commit:v1\0" ‖ commit payload)
ObjectId (physical key)33kind u8 ‖ digest[32]; kind 1 chunk, 2 manifest, 3 change set, 4 commit

Hash algorithm identifier 1 = BLAKE3-256 appears in every envelope. Hex renderings are lowercase.

2. Format descriptor file SCRIBE_FORMAT

Present at the root of a data root, a repository directory, an object store, and a workspace .scribe/. UTF-8, LF or CRLF, one key = value per line, keys [a-z0-9-]+, values without newlines. The first line is exactly scribe-descriptor = 1; a kind line is mandatory. Unknown keys are kept. A required-features value is a comma-separated list; an opener that does not recognize every listed feature refuses to open for mutation.

kindkeys
data-rootcatalog-schema, repository-schema, object-store, envelope, engine (redb-4), incarnation (32 hex), created-ms; after restore also restored-from-incarnation, restored-ms; after auth-init required-features = auth-ownership-v1
repositoryrepository-id, name, schema, engine, object-store, created-ms; after migration migrated-from-schema, migrated-ms
object-storelayout, envelope, small-object-threshold, pack-seal-bytes, compression (none or lz4, immutable); LZ4 stores require feature lz4
workspaceschema, engine, workspace-id

Openers compare each version they know against the descriptor before opening any database; a mismatch is a refusal that names scribe-server migrate.

3. Object envelope v1

Every stored object, loose or packed, is an envelope:

offsetsizefield
04magic SCOB
42envelope version = 1
61kind (1..4)
71hash algorithm = 1
81payload codec: 0 = identity, 1 = bounded LZ4 blocks
91flags = 0; any set bit is an unknown required feature and rejects
102reserved = 0
128stored payload length
20npayload

Identity is the digest of the original canonical payload, after decompression. Codec 0 stores those bytes directly. Codec 1 stores u64 original length followed by independent blocks: u8 codec | u32 original length | u32 stored length | stored bytes, all integers little endian. Each block contains 1 to 262144 original bytes. Block codec 0 is raw (equal lengths); codec 1 is a standard LZ4 block with strictly fewer stored bytes. Unknown codecs, invalid lengths, truncation, and trailing bytes are rejected. There is no terminator; the original object length determines the end.

Repository creation fixes the storage policy for its object store. It cannot be changed through the API or CLI. Under lz4, an object retains codec 0 if the complete encoded representation, including the original-length prefix and block headers, is not smaller. Locations and compression are never part of object identity. Manifests continue to describe original chunk bytes.

4. Payloads

4.1 Manifest v1

text
u16 version = 1
u32 flags = 0                (unknown bits reject)
u8  content codec = 0        (logical chunk contents are original bytes)
u16 chunking profile         (1 = FastCDC-2020, level 1, seed 0, 1/4/16 MiB)
u64 total size
[32] file content id
u32 chunk count
repeat chunk count: [32] chunk id, u32 chunk length

Rules: sum(length) == total size; chunk count == 0 ⇔ total size == 0; 1 ≤ length ≤ 16 MiB; empty file content id is blake3("") (af1349b9…f3262); repeated chunk ids allowed; total size ≤ 1 PiB; chunk count ≤ 2^30. Executable bits are not in the manifest. Files no larger than the profile minimum (1 MiB) are exactly one chunk.

4.2 Change set v1

text
u16 version = 1
u32 entry count            (≤ 4,000,000)
repeat, strictly ascending by path bytes, no duplicates:
  str16 canonical path
  u8  action: 1 add, 2 edit, 3 delete
  u64 expected base change   (0 for add; the change that produced the base state otherwise)
  if action ≠ 3: [32] manifest id, [32] file content id, u64 size, u8 mode (0 regular, 1 executable)

4.3 Commit v1

text
u16 version = 1
[16] repository id
[16] branch id
u64 change number            (≥ 1)
u64 parent change            (0 = none; must be < change)
u8  branched-from present    (0 or 1)
  if 1: [16] source branch id, u64 source change
[32] change set id
[16] operation id
[16] workspace id
u64 timestamp, Unix milliseconds UTC
str16 author                 (≤ 256 bytes)
str32 message                (≤ 65,536 bytes)

5. Canonical paths

UTF-8, / separator, relative, no empty/./.. components, ≤ 8192 bytes, components ≤ 255 bytes, no control characters, none of < > : " | ? *, no reserved device name stem (CON PRN AUX NUL COM1-9 LPT1-9, any case, with or without extension), no component ending in . or space, no component equal to .scribe (any case). Collision key = NFC normalization then Unicode lowercase; two live paths with equal keys cannot coexist on one branch.

6. Object store layout

text
<store>/SCRIBE_FORMAT
<store>/locator.redb
<store>/loose/<hh>/<kk><digest hex>      hh = first digest byte in hex, kk = kind in 2-digit hex
<store>/packs/<pack id, 8 hex digits>.pack
<store>/staging/                        quarantine temporaries (same filesystem)

Objects whose envelope length ≤ small-object-threshold (default 262,144) are packed; larger ones are loose files containing exactly one envelope.

6.1 Pack file

text
header (16 bytes): "SCPK" | u16 version = 1 | u32 pack id | u16 reserved = 0 | u32 flags = 0
entry (repeated): "SCPE" | [33] object key | u64 envelope length | envelope bytes

Entry length = 45 + envelope length. Packs are append-only. Every pack except the highest-numbered is sealed. The open pack rolls over when an append would exceed pack-seal-bytes (default 256 MiB). After a crash the open pack may have bytes past the last committed length; open truncates them (they were never referenced). A pack shorter than its committed length is corruption and refuses to open.

6.2 Locator database locator.redb

Tables: locator key [u8; 33] object key → value:

text
u8 version = 1
u8 location: 1 loose, 2 packed
  loose:  u64 envelope length
  packed: u32 pack id, u64 entry offset, u64 entry length

meta (&str → bytes): open-pack (u32 LE pack id), open-pack-committed-len (u64 LE). The locator is an index: ObjectStore::rebuild_locator recreates it from packs and loose files (scribe-server verify reports; rebuild is offline).

6.3 Publication order of a batch

  1. staged file data flushed at staging time (loose candidates);
  2. loose renames into shard directories / pack appends;
  3. pack file flushed once per touched pack;
  4. each touched shard directory flushed once; loose/ flushed if a shard was created;
  5. locator transaction commits (Durability::Immediate).

Objects are never deleted, replaced, or compacted in v1.

7. Data root layout

text
<data>/SCRIBE_FORMAT
<data>/LOCK                    exclusive OS lock (flock / LockFileEx) held by any owning process
<data>/catalog.redb
<data>/auth-state.redb          workspace and operation ownership, schema 1 (only after auth-init)
<data>/repos/<repository id hex>/SCRIBE_FORMAT
<data>/repos/<repository id hex>/metadata.redb
<data>/repos/<repository id hex>/objects/            object store (section 6)
<data>/repos/<repository id hex>/metadata.redb.gen1  previous generation after a migration (optional)
<data>/repos/<repository id hex>/MIGRATION, MIGRATED migration checkpoint / record (optional)

Logs and runtime files (scribe-server.pid, listen, shutdown-request) live in separately configured directories and are disposable.

7.1 Catalog catalog.redb (schema 1)

tablekeyvalue
meta&strschema = u32 LE 1
repos[u8;16] repository idu8 1 ‖ str16 name ‖ u8 state (1 creating, 2 ready) ‖ u64 created ms ‖ [16] main branch id
repo_names&str name[u8;16] repository id

Creation writes creating, builds the directory, then ready; startup removes creating leftovers with their directories.

8. Repository metadata metadata.redb

Schema 3 is what this build serves. It consists of the schema-1 tables below, the schema-2 branch tables, and the schema-3 collision index (section 12). Every value starts with a u8 record version (1). Keys:

tablekeyvalue
meta&strschema u32; repository-id [16]; incarnation [16]; repo-meta = u8 1 ‖ u64 head change ‖ u64 lock generation
branches[u8;16] branch idu8 1 ‖ str16 name ‖ u64 created change ‖ u8 branched-from present (‖ [16] branch ‖ u64 change) ‖ u8 state (1 active)
commitsu64 change (redb numeric)u8 1 ‖ [32] commit id ‖ [16] branch ‖ u64 parent ‖ [32] change set id ‖ [16] operation ‖ [16] workspace ‖ u64 timestamp ms ‖ u32 entry count ‖ str16 author ‖ str32 message
currentbranch[16] ‖ pathFileRecord: u8 1 ‖ [32] manifest ‖ [32] content ‖ u64 size ‖ u8 mode ‖ u64 change
current_foldbranch[16] ‖ collision key bytesexact path bytes
historybranch[16] ‖ path ‖ 0x00 ‖ ordered u64 changeHistoryRecord: u8 1 ‖ u8 action ‖ u64 change ‖ u8 state present (‖ FileRecord body without its version byte)
change_pathsbranch[16] ‖ ordered u64 change ‖ pathHistoryRecord
workspaces[u8;16] workspace idu8 1 ‖ [16] branch ‖ u16 view count ‖ (str16 prefix)* ‖ u64 created ms ‖ str16 label
lockspath (repository-wide)u8 1 ‖ u64 generation ‖ [16] workspace ‖ [16] branch ‖ str16 author ‖ u64 acquired ms
operations[u8;16] operation idu8 1 ‖ [32] fingerprint ‖ u64 accepted change (0 = rejected) ‖ u16 error code ‖ str16 detail ‖ u64 recorded ms

Ordering guarantees: paths never contain 0x00, so path ‖ 0x00 ‖ change keeps one path's revisions contiguous and before path ‖ '/' ‖ …. Branch prefixes make per-branch scans key-prefix scans. current_fold is rebuildable from current; commits, history, change_paths are re-derivable from the immutable commit and change set objects (slow path); everything else is authoritative and must be backed up.

Operation fingerprint = blake3("scribe:operation-fingerprint:v1\0" ‖ repo ‖ branch ‖ workspace ‖ change set id ‖ u64 len ‖ author ‖ u64 len ‖ message).

9. Workspace .scribe/ (schema 1; schema 2 adds workflows_v2, section 12)

text
.scribe/SCRIBE_FORMAT
.scribe/state.redb
.scribe/ignore              optional workspace-local ignore rules (not versioned)
.scribe/cache/              object store (section 6) holding objects ≤ 256 KiB and manifests
.scribe/tmp/                reconstruction temporaries (`<hex>-<index>-<pid>.part`), cleaned at open
.scribe/recovery/           displaced local contents: `<unix ms>-<32 hex random>`; the sync report maps each to its path
.scribe/LOCK                local operation lock

state.redb tables:

tablekeyvalue
meta&strschema u32; workspace-id, repository-id, branch-id, incarnation [16]; repository-name, server, author UTF-8; view = u16 count ‖ (str16)*; readonly-mode u8; transfer-compression u8 (0 none, 1 LZ4); synced u64; journal-target u64 (present only while a journal exists)
baselinepathu8 1 ‖ [32] manifest ‖ [32] content ‖ u64 size ‖ u8 mode ‖ u64 change ‖ u64 fp size ‖ u64 fp mtime s ‖ u32 fp mtime ns
openedpathu8 1 ‖ u8 action ‖ u64 opened ms
pending[u8;16] operation idu8 1 ‖ [32] change set id ‖ bytes32 change set payload ‖ str16 author ‖ str32 message ‖ u64 created ms ‖ u32 n ‖ (str16 path ‖ fingerprint 20 bytes)*
journalpathu8 1 ‖ u8 target present (‖ bytes16 BaselineRecord) ‖ u8 done ‖ u8 recovery name present (‖ str16)
local_lockspathu64 generation
deferredpathJournalEntry

10. Backup and export containers

BACKUP_MANIFEST: text, first line scribe-backup-manifest = 1, then key = value lines (created-ms, source-incarnation, repository-<id> = <name> head=<n> commit=<hex>) and one file = <blake3 hex> <size> <relative path> per copied file.

Metadata export (.scmx): "SCMX" | u16 version = 1 | [16] repository id | u32 table count | per table: str16 name, u64 row count, (bytes32 key, bytes32 value)* | [32] blake3 of everything before it.

11. Limits that are part of the format

Path 8192 B; component 255 B; file 1 PiB; chunk 16 MiB; manifest chunks 2^30; change set entries 4,000,000; author 256 B; message 64 KiB; repository name 128 B; view prefixes 256.

12. Branch extension: repository schema 2 and 3, workspace schema 2, commit v2

The v1 encodings above are unchanged. Repository schema 2 adds:

TableKeyValue
branch_heads_v2BranchId (16 bytes)u64 head change
merge_parents_v2u64 accepted merge changeBranchId (16 bytes), u64 source change
branch_operations_v2OperationId (16 bytes)version-2 branch outcome
branch_pending_v2OperationId (16 bytes)version-2 branch snapshot intent

Integers use little endian. Branch rows retain their v1 byte layout under schema 2; state 2 means an unpublished snapshot. Current/history/collision keys retain the branch reservation. Snapshot history rows anchor the inherited file state; user-facing path history follows the recorded source origin.

Commit v2 is u16(2), a u32-length-prefixed complete v1 commit payload, then a u8 optional-parent flag. A present parent is BranchId plus u64 ChangeNumber. Its identity hashes scribe:commit:v2\0 followed by this complete payload. The stored commit index row retains its v1 layout and contains the v2 identity. Normal commits still use commit v1. The repository's global change sequence orders all commits; branch heads and first/second parents describe ancestry.

Workspace schema 2 adds workflows_v2 (UTF-8 string keys to explicit versioned bytes). It holds pending branch/switch intent, one active resolution workflow, and per-path immutable base/ours/theirs/target references plus selection and completion flags. Planning completes before application starts. File application, base/opened updates, and the resolution completion marker are coordinated with recovery protection. Accepted-submit finalization clears the active workflow in the same local transaction as its pending submit and baseline changes. See scribe-workspace/src/workflows.rs and scribe-client/src/conflicts.rs.

Repository schema 3: indexed historical path relations

Schema 3 includes all schema-2 tables and adds fold_history_v3. The key is BranchId(16) ‖ UTF-8 collision_key(path) ‖ 0x00 ‖ ordered_u64(change); only the ordered change segment uses big endian. The value is bytes_u16(original canonical path) ‖ HistoryRecord(v1). The table name and repository schema version identify the encoding; v1 bytes are unchanged.

At one folded path/revision a live addition wins over a deletion (case-only replacement). Branch snapshot pages populate the inherited anchors. Every subsequent acceptance updates this index in the same durable transaction as history and branch head. Exact and ancestor states use a reverse bounded range seek; descendants page over distinct folded paths, counting tombstones against the page budget. Reads at pinned revisions are stable across later submissions.

migrate --branches builds the index in 1,024-row Immediate transactions while holding exclusive root ownership. The schema marker advances only after the whole index is durable. Replaying after interruption is idempotent. Doctor checks both directions against history without mutation. Physical backups and logical export/import include the table.

Source docs/on-disk-format.mdSnapshot 93d02b17