File format

A format designed from scratch, explicitly independent of SQLite: neither the page layout, the record encoding, nor the journal model derive from it. This page is the on-disk spec — what a byte on disk means, and why the layout gives Arkeion its two headline properties: a single fdatasync per commit, and a history you can prove.

Conventions: header integers are little-endian; B-tree keys use a memcomparable big-endian encoding (so byte order equals key order). The recommended file extension is .arkeion, but it is not enforced — the engine opens any path, zero-config.

Global layout

A database is one file of fixed 4096-byte pages. Only three regions are special; everything else is appended and never overwritten.

db.arkeion — one file · 4096-byte pages page 0 — file header immutable after creation page 1 — meta slot A page 2 — meta slot B the only in-place writes — alternating boot pointers (a cache of the last commit) pages 3… — append-only zone data pages · commit pages — only ever appended, never rewritten grows →
The two meta slots are the only bytes ever overwritten; they hold nothing but pointers. Data and history are append-only.
page 0        File header      (immutable after creation)
page 1        Meta slot A      (alternating rewrite, LMDB-style)
page 2        Meta slot B
page 3…       Append-only zone: data pages and commit pages

On-disk page — 4096 bytes

PAGE_SIZE = 4096 matches a common OS page and logical sector, which minimises torn writes and aligns the encryption unit. PageId = u64, and a page’s byte offset is simply id × 4096.

Every page has the same three-band layout, encrypted or not — the cryptographic reserve (28 B, 0.7%) is always paid, in exchange for a B-tree that is entirely encryption-agnostic.

every page — 4096 bytes nonce 12 B tag 16 B body 4068 B — ciphertext, or plaintext 0 12 28 4096 the tag binds the body to its page_id — a page moved to another offset reads as corruption
Encrypted or not, all 4096 bytes are covered. Integrity is verified on every read.
0..12    nonce   encrypted: 96-bit GCM counter        · plaintext: zeros
12..28   tag     encrypted: GCM tag, AAD = page_id    · plaintext: SHA-256(LE(page_id) ‖ body)[0..16]
28..4096 body    encrypted: ciphertext of the body    · plaintext: the body as-is

The tag binds the content to its page_id: a page relocated to another offset is detected as corruption, not accepted as valid data in the wrong place. Without encryption the nonce must be zero (this is checked). Integrity is always verified on read, in both modes. With encryption on, every page is encrypted except the header and meta slots — and those hold no user data, only magics, pointers and versions (not even table names sit in plaintext).

Page identification

Data pages carry their type in the first body byte:

Type Value Content
B-tree leaf 0x01 Key→value cells
B-tree internal 0x02 Key→child cells
Overflow 0x03 Continuation of large values

Structural pages are identified by an 8-byte magic in body[0..8] — no collision is possible, since no data-type byte equals 0x41 ('A'):

Page Magic Position
Header "ARKEION1" page 0
Meta slot "ARKMETA1" pages 1–2
Commit "ARKCMT01" append zone

File header — body of page 0

0..8     magic            "ARKEION1"
8..12    format_version   u32 = 1
12..16   page_size        u32 = 4096
16..20   flags            u32   bit0 = encryption enabled
20..36   file_id          [16]  random at creation (seed of the genesis chain)
36..52   kdf_salt         [16]  reserved (raw 32-byte key today, no KDF)
52..     reserved         zeros

Meta slot — body of pages 1 and 2

0..8     magic              "ARKMETA1"
8..16    version            u64   version of the last commit reflected
16..24   last_commit_page   u64
24..32   n_pages            u64   file length (in pages) at that commit

The writer alternates A/B. On open, the engine takes the valid slot (tag integrity) with the highest version, then does a forward scan from n_pages in case commits landed after the slot was last updated — the slot update is lazy, deliberately kept off the durability critical path.

Commit page — body, type 0x04

0..8      magic           "ARKCMT01"
8..12     flags           u32   bit0 = compaction checkpoint (vacuum)
12..16    reserved        u32
16..24    version         u64   global monotonic, starts at 1
24..32    parent_page     u64   parent commit ON THE BRANCH (0 = genesis)
32..40    prev_page       u64   previous commit GLOBALLY (0 = genesis)
40..48    timestamp_ms    u64   wall clock, informational (version is authoritative)
48..56    data_root       u64   root of the branch's data tree
56..64    meta_root       u64   root of the global meta tree
64..72    nonce_counter   u64   next GCM counter after this commit
72..80    pages_written   u64   pages added by this commit
80..144   branch          [64]  UTF-8 branch name, zero-padded (max 64 B)
144..176  content_hash    [32]  SHA-256 of the PLAINTEXT bodies written by the commit, in order
176..208  prev_chain      [32]  chain_hash of the previous global commit
208..240  chain_hash      [32]  see below
240..     reserved        zeros

Each commit closes a hash chain over the global order of all commits, across every branch:

hash chain — linear over the global order of every branch commit v41 content_hash of plaintext bodies chain_hash commit v42 content_hash chain_hash commit v43 content_hash chain_hash · head prev_chain prev_chain rewrite any past commit and every later chain_hash changes — verify() checks this end to end
The chain is linear over the global order of commits. Because content_hash covers the plaintext, an auditor verifies with the key in hand — and the chain survives key rotation via vacuum.
chain_hash = SHA-256( prev_chain ‖ content_hash ‖ LE(version) ‖ LE(timestamp_ms)
                      ‖ LE(data_root) ‖ LE(meta_root) ‖ branch )
genesis:     prev_chain = SHA-256( "ARKEION1" ‖ file_id )

Tampering with any past write, on any branch, breaks the chain from that point forward. A commit with flags.checkpoint = 1 is written by vacuum: its prev_chain carries the head chain_hash of the truncated history, so the chain stays continuous and verifiable even though the old pages are gone.

B-tree pages

leaf (0x01):     [type u8][flags u8][ncells u16] · [plen varint][prefix]? · cells · pointers
                 flags bit0 = COMPRESSED PREFIX: a [plen varint][prefix] shared by every key
                   follows the header, and each cell stores only the SUFFIX key[plen..];
                   full key = prefix ++ suffix. Without the bit: empty prefix, cell = whole key.
                 cell: [cell-flags u8][klen|slen varint][key|suffix][vlen varint][val]
                 cell-flags bit0 = overflow: [...][total len varint][first overflow u64]
                 (a value pushing the cell past 1280 B goes to overflow; key max 1024 B)
internal (0x02): [type][flags][ncells u16][rightmost u64] · cells: [klen varint][key][child u64]
                 cell.key = exclusive upper bound of its child
overflow (0x03): [type][flags][len u16][next u64][bytes…]

Each node ends with an array of cell pointers — one u16 LE per cell, growing from the end of the node toward the content — so descent, get and scans do in-page binary search without materialising the node, and appending the maximum key stays O(1). Cost: 2 B per cell.

Prefix compression in leaves. Keys in a leaf usually share a long prefix — postings for one term ([0x03, fts_id, 0x00, term_id]), entries of one index ([0x02, index_id]). Storing that prefix once per page instead of once per cell shrinks every index (it is a big part of why full-text came in below SQLite’s FTS5), with no change to the logical model: hot-path comparisons strip the target’s prefix and compare suffixes zero-copy; only the scan cursor rebuilds the full key. The append cursor appends a suffix when the key shares the prefix, so the active rightmost leaf stays compressed with O(1) appends, and older pages are read the same way.

Invariants checked on decode: keys are non-empty and strictly increasing within a node. delete does not rebalance underfull nodes — it only drops empty ones; vacuum rebalances on rewrite.

Key spaces

The catalogue stores a schema version with each table. The logical schema has grown v7 (CHECK constraints) → v8 (full-text) → v9 (vector) → v10 (vector re-rank) — each step adds key spaces, so the on-disk format_version (page 0) stays at 1. This is the canonical progression the SQL, full-text and vector docs refer to.

Data tree (data_root — forks with the branch)

[0x00, 0x01, table_name UTF-8]              → {table_id u32, schema}   catalogue
[0x00, 0x02, table_id BE]                   → next_rowid u64           rowid counter
[enc_oint(table_id), enc_oint(rowid)]       → record (row)
[0x02, index_id BE, value*, rowid u64 BE]   → secondary index entry

enc_oint is an order-preserving, self-delimiting, variable-length i64: a one-byte header encodes the sign and the number of significant bytes, so small magnitudes take ~2 B while the B-tree’s (table_id, rowid) order is preserved exactly. The row key needs no namespace byte — table_id ≥ 1 gives an enc_oint header of 0x80+, disjoint from 0x00 (catalogue) and 0x02 (index), so a key self-identifies from its first byte. Index entries keep a fixed rowid u64 BE as a trailing suffix after the variable-length value.

The schema lives in the data tree on purpose: a migration on a branch changes the schema only on that branch.

Meta tree (meta_root — global, linear, never forks)

[0x01, branch_name]                → {head_version u64, head_page u64}    refs
[0x02, version BE]                 → {commit_page u64, ts_ms u64, branch}  history index
[0x03, ts_ms BE, version BE]       → ∅                                     AS OF TIMESTAMP

Record (row)

[ncols varint][tag u8 × ncols][payloads in order]

tags:  0 NULL · 1 FALSE · 2 TRUE · 3 INTEGER (zigzag varint)
       4 REAL (f64 LE, 8 B) · 5 TEXT (varint len + UTF-8) · 6 BLOB (varint len + bytes)

Missing trailing columns read as NULL — which is what lets ALTER TABLE ADD COLUMN work without rewriting a single existing row.

Commit and recovery protocol

A write, from the single writer:

1. new pages (copy-on-write)  → append
2. commit page                → append
3. fdatasync                  ← the durability point — exactly one
4. alternating meta slot      → write in place (lazy hint; no fsync)

One fdatasync per commit, with no barrier between the data pages and the commit page. The per-page integrity tag makes any partially-written or unwritten page unreadable; recovery scans in order and stops at the first unreadable page. Because the data pages are appended before the commit page, a half-written commit is never adopted — only a commit whose pages are all readable (the fdatasync ran to completion) becomes the head. Dropping the second barrier is worth roughly 2–2.5× the durable-write rate versus a SQLite rollback journal, on par with WAL, while keeping full single-file durability.

Open and recovery:

1. Validate the header (magic, format version, flags).
2. Read meta A and B → candidate = the valid one with the highest version. Both corrupt → full scan.
3. Validate the candidate commit page (tag + magic + optional chain).
4. Scan forward from n_pages: each further valid commit page advances the head.
5. A broken tail (pages no valid commit covers) is ignored.

Step 5 is the “WAL replay”: there is no redo and no undo, only discarding what was never confirmed.

Vacuum (compaction)

vacuum(retention) rewrites the live versions into a temporary file according to the policy — KeepAll | KeepLast(u64) | KeepSince(SystemTime) — writes a checkpoint commit that chains onto the truncated history, then does an atomic fsync + rename over the original. It doubles as the key-rotation mechanism: everything is rewritten under the new key.