In one sentence. Everything ComputeFlux saves to disk is encrypted with a key that exists nowhere but inside the processor, so a stolen drive yields nothing but noise — and the database on top of it is built from five narrow, predictable tools rather than one flexible one.
Picture it like this. A warehouse of locked boxes. Someone who breaks in can read the labels on the outside — "billing, March", "customer keys" — and count the boxes. But no crowbar opens one; the only key is fused into the warehouse walls themselves. That's the honest shape of what this article describes: the contents are unreadable, the labels are not. Most write-ups would quietly skip that second half.
Why it matters. "We encrypt data at rest" almost always means the cloud provider holds the key and could decrypt on request or on subpoena. Here, no one holds the key — not the operator, not the host, not the person who walked out with the drive.
Want the storage engine rather than the data model? Skip to PebbleDB inside a TEE.
ComputeFlux's blockchain is not a general-purpose database with a consensus layer bolted on. It's a deterministic state machine: every read and write must produce identical results on every validator node, whatever the operating system, CPU architecture, or Go compiler version. And as Article 1 established, every value that touches disk is sealed with a key derived from the CPU's hardware root of trust.
This article covers both halves of that story — the contract storage abstraction ComputeFlux's WASM and Go-native contracts are built on, and the sealed PebbleDB engine underneath that actually persists the bytes. Determinism drives the first. TEE sealing shapes the second.
Why Five Primitives Instead of One ORM
The immediate temptation when designing contract storage is to provide a single, generic key-value interface (or a full ORM) and let contract developers model their data however they wish. Most blockchains do exactly this. ComputeFlux explicitly rejects that approach, for three reasons.
First, query semantics must be explicit. An ORM's WHERE clause compiles to an opaque query plan — neither deterministic (different Go compiler versions may optimize differently) nor bounded (a naive full-table scan on a growing dataset becomes increasingly expensive). ComputeFlux's five storage primitives — StoreValue, StoreMapping, IndexedList, IndexedStore, and StoreList2D — each expose a well-defined access pattern (exact key lookup, prefix scan, range scan, indexed query), so the performance characteristics of every data access are visible in the contract code itself.
Second, index maintenance must be transactional. When a Provider struct is updated (see Article 6), its Bleve index entries must be regenerated atomically with the data write. An ORM that lazily updates indexes or uses background indexers would break consensus — one validator could see stale index data while another sees fresh data. IndexedStore and IndexedList embed index maintenance directly into their Put/Update/Delete methods, guaranteeing primary data and all secondary indexes update within the same storage transaction.
Third, migration safety. Blockchain state grows monotonically; data written today may need to be read years from now by a very different version of the contract code. The five primitives impose a uniform key structure (namespace_prefix_suffix) that makes prefix-based scanning predictable and migrations auditable — a property that turns out to matter a great deal, as the case study at the end of this article shows.
The Namespace + Prefix Key Model
Every storage key follows a strict convention: namespace + "_" + keyPrefix + suffix. A Gateway API key's storage key, for example, is "gateway_v2_api_key_" + hexKey. The namespace ("gateway", "gov", "auth") isolates different contract modules; the key prefix ("v2_api_key_", "model_") isolates different data types within a module; the suffix identifies the specific record. Note where the version marker sits: v2 leads the key prefix rather than trailing it, which — as the case study at the end of this article explains — is not a cosmetic choice.
This structure enables the storage layer's most important operation: ListByPrefix. Because PebbleDB maintains keys in lexicographic order, scanning all keys starting with "gateway_v2_api_key_" returns exactly the V2 API key records, in order, with no false positives and no secondary filtering. That matters in three places. Migrations: a V15 migration scanned every V1 API key by prefix and converted it, a story the case study below returns to. Batch operations: DeleteByPrefix atomically removes an entire data class. Debugging: operators can inspect every key under a namespace using nothing but PebbleDB's built-in range scan.
Data keys and index keys are separated by convention: data keys use a "flat" suffix (a pure hex string, no underscore), while index keys embed an underscore (e.g., "v2_api_key_idx_OwnerKey_123_abc"). List and ListRaw filter out suffix values containing _, ensuring index entries are never accidentally returned as data records — a simple but load-bearing invariant enforced entirely by naming convention, with no schema or metadata table involved. It wasn't always there: the case study below describes a migration that ran before the filter existed and paid for its absence.
IndexedStore vs IndexedList: When to Use Which
The distinction is about the primary key's nature, not just the presence of indexes.
IndexedList is modeled after a database auto-increment table. Its primary key is a monotonically increasing unsigned integer managed internally by a counter: Insert reads the counter, increments it, and uses the new value as the key. This suits ordered, append-mostly data like model registrations (Article 6) or governance proposal submissions (Article 17), where the List method naturally implements chronological pagination.
IndexedStore uses a caller-provided primary key — for API keys, the hex-encoded truncated hash of the secret. This suits records whose natural identifier isn't sequential, or that are created and accessed by a known identifier rather than by position.
The critical difference for index maintenance: IndexedList.Insert atomically allocates the next ID, writes the record, and updates all indexes — there's no window where a concurrent transaction could see the ID allocated but the record not yet written. IndexedStore.Put assumes the caller already has the key, so it's a single write with index updates and no allocation step.
IndexedList.RebuildIndex is a migration utility: when a new idx tag is added to a struct field, historical data lacks index entries for it, and RebuildIndex iterates all existing records, populating the new index. This is deliberately not automatic on startup — automatic reindexing would make consensus depend on the order of data discovery, which is exactly the kind of non-determinism this whole design is built to avoid.
The idx Struct Tag System
Index configuration uses Go struct tags with compile-time validation. A field tagged idx:"exact" creates an exact-match index; idx:"range" creates a range-scan-capable index. The tag processor runs at initialization time via reflect, panicking on incompatible type/tag combinations — misconfigurations are caught at startup, not at query time.
The query system supports QueryAND (intersection) and QueryOR (union). An AND query across three indexes — say, models with InputModalities=3 AND ContextLength>32000 AND ProviderType=0 — scans each index independently and computes the intersection of resulting ID sets. This is efficient when individual indexes are highly selective, but degrades toward a full scan when any index returns a large fraction of records; mergeIDs uses a simple nested-loop intersection, prioritizing simplicity and determinism over raw performance.
Range indexes store numeric values as zero-padded 20-digit strings, so lexicographic order matches numeric order — a property PebbleDB's range scan exploits directly.
ScaleEncode/ScaleDecode: Deterministic Serialization
A value written by contract version N must be readable by version N+1 without ambiguity. That rules out JSON (whitespace and field ordering aren't canonical), Protobuf (field ordering depends on schema definition order, which can change), and any format where a single logical value has multiple valid byte representations.
ComputeFlux uses a SCALE-style (Simple Concatenated Aggregate Little-Endian) codec with three tiers of dispatch. Contract-defined types like TrackData and ProviderInfo get generated ScaleEncoder/ScaleDecoder methods. Primitives go through reflect-based type switching. Containers recurse: Option<T> encodes as 0x00 for None and 0x01 + ScaleEncode(v) for Some, and slices encode element by element behind a compact length prefix. Compact integer encoding — one byte for 0–63, two for 64–16383, and so on — saves space on the small integers this system stores constantly, at the cost of one conditional branch per decode.
Bleve Full-Text Search: The Trade-Offs
The model catalog supports free-text search via Bleve, an embedded full-text search engine chosen over Elasticsearch (too heavy — a separate process, network communication) and SQLite FTS (too SQL-centric for a key-value-native system). Bleve operates purely in-process and rebuilds its index from chain state on startup.
Crucially, Bleve indexes are not consensus-critical — each node rebuilds independently from the same chain state, so two nodes may show subtly different search results if running different Bleve library versions. This is acceptable because search only affects display ordering and filtering, never state transitions. The memory cost is real enough that indexing is optional: validators not serving the Gateway API can skip it entirely. A background refresh on a configurable interval keeps the index eventually consistent with chain state.
The five storage primitives are a deliberate constraint system — a domain-specific language for blockchain state where the constraints (uniform key structure, transactional index maintenance, deterministic encoding) prevent entire classes of bugs rather than limiting expressiveness. That constraint system runs on top of something with its own set of hard trade-offs: sealed PebbleDB.
PebbleDB Inside a TEE: Two Hard Constraints Colliding
PebbleDB inside a TEE is where two sets of assumptions collide. The LSM-tree's performance characteristics assume unbuffered disk I/O with predictable latency. The application-level sealing ComputeFlux layers on top (Article 1) encrypts every stored value with a key derived from the enclave's identity. Put the two together and you get performance effects that never appear when PebbleDB runs on a plain Linux host with no sealing at all.
ComputeFlux currently opens PebbleDB with its stock configuration — no custom memtable size, compaction thread count, or level thresholds. The day-to-day work here is therefore less about hand-picked constants than about knowing where the sealing overhead actually sits.
PebbleDB's LSM-tree has three tiers, each with different I/O patterns:
Memtable: an in-memory skip list that absorbs writes. A Set(key, value) writes to the memtable and appends a record to the Write-Ahead Log (WAL) on disk. The memtable write itself needs no I/O; the WAL append is what actually goes to disk on every write. A larger memtable reduces write amplification but increases memory consumption and crash-recovery latency (a larger WAL must replay on restart) — the usual LSM-tree trade-off, currently left at PebbleDB's default rather than tuned for this workload.
SSTables: immutable, sorted files with bloom filters, produced when a full memtable flushes to level 0. Level-0 SSTables may overlap in key range; level-1 and deeper are compacted into non-overlapping ranges through merge-sort. Compaction is where the LSM-tree pays for its fast writes — a single key-value pair may be read and rewritten multiple times as it percolates down through levels.
WAL: the durability mechanism, fsynced before acknowledging a write and replayed on restart. Its sequential access pattern is the cheapest I/O PebbleDB does.
Why Values, Not Just the WAL, Are Sealed
A naive TEE storage design might encrypt only the WAL and leave SSTables unencrypted, on the assumption they're opaque binary blobs. This is wrong for two reasons. First, PebbleDB's SSTable format is an open specification — an attacker who exfiltrates an SSTable file can parse its block index and read every value stored inside it: API keys, wallet addresses, billing records, provider credentials. Second, it breaks the trust model's consistency. The TEE guarantees data is encrypted in EPC memory and in transport; an unencrypted SSTable means that on disk it is plaintext. Readable from a decommissioned SSD, a stolen backup tape, or a cloud provider's disk snapshot, with no cryptography in the way.
ComputeFlux's DB wrapper handles this at the application level rather than intercepting disk writes. Every Set and Get on the wrapped database runs the value through EGo's ecrypto.SealWithProductKey/Unseal before PebbleDB ever sees it, so PebbleDB only ever writes and reads ciphertext into its SSTables and WAL.
One consequence deserves to be stated precisely: this seals values, not keys. The lexicographically-ordered keys described earlier — namespace, prefix, suffix — sit in the LSM-tree as plaintext bytes, because PebbleDB has to compare and range-scan them directly. An attacker holding a raw SSTable or WAL file can still read the key structure: which namespaces exist, roughly how many records of each type, an API key's hex identifier. The values attached to those keys stay unreadable without the enclave's sealing key.
The Performance Cost of Sealing
Sealing happens once per Set or Get, on the value bytes. It is not a transparent per-4KB-page interception of every disk syscall. So the cost scales with how many values you read and write, not with PebbleDB's internal page or block size.
That has a useful consequence. PebbleDB's internal housekeeping, compaction included, only ever handles ciphertext it was already handed. Merging SSTables never requires decrypting and re-encrypting each value, because a k-way merge on keys never needs to look inside an opaque value. The sealing cost is paid once, at the application layer, and not again each time data moves between LSM-tree levels.
PebbleDB's block cache still matters for a different reason: it avoids re-reading a block from disk on repeated lookups of the same hot keys. The cache holds ciphertext — the wrapper's Unseal still runs on every Get — so the saving is the disk round-trip, not the decryption. ComputeFlux does not currently configure a non-default block cache size — this is an area where custom tuning, if it exists, would need to be verified against the deployed configuration rather than assumed from a fixed constant.
Namespace + Prefix Isolation: Preventing Cross-Contract Data Leaks
PebbleDB has no built-in concept of tables or access control — any code with a *pebble.DB handle can Get() or Set() any key. Since ComputeFlux stores data for many modules (API keys, governance proposals, provider models, billing records, per-contract state) in one flat key space, isolation has to be enforced by convention and by the WASM runtime's import whitelist, not by the database itself:
gateway_v2_api_key_<hex> → APIKeyInfoV2
gateway_models_<id> → ModelInfo
gateway_providers_<id> → ProviderInfo
gov_proposal_<id> → Proposal
contract_<contractID>_<key> → Per-contract key-value store
A WASM contract (Article 5) can only call host functions explicitly exported by the runtime. The host functions for storage access prepend the calling contract's ID as a key prefix before forwarding to PebbleDB — Contract A calling host_db_set("balance", value) produces key contract_A_balance; Contract B cannot read this because it cannot construct the contract_A_ prefix itself. The runtime derives it from the calling contract's identity, which the contract cannot forge. This is capability security: a contract's storage authority is determined entirely by its identity, enforced at the narrow WASM-host boundary rather than scattered throughout the codebase.
Case Study: The API Key V2 Prefix Overlap Bug
The V15 migration that added a Prefix field to APIKeyInfo (giving API keys human-readable prefixes like sk-prod-) is a good illustration of what happens when the namespace + prefix model above isn't followed strictly enough. The migration used ListRaw — which returns undecoded bytes and lets the caller skip individually corrupted records rather than aborting the entire scan on the first bad one — to read all keys under the "gateway_api_key_" prefix. But that prefix matched both data records (gateway_api_key_<hexKey>) and index records (gateway_api_key_idx_OwnerKey_<value>_<hexKey>), because the index keys extended the data prefix with idx_ instead of living in a genuinely separate namespace. ScaleDecode correctly failed to parse index values as APIKeyInfo structs, but the raw keys were still being returned, risking a migration that re-inserted index keys as if they were data.
The fix was a one-line heuristic: discard any key whose suffix contains _, exploiting the fact that hex-encoded data keys never contain underscores while index keys always do (from the _idx_FieldName_ infix). It worked, but it's fragile — a future key type with a legitimately underscore-containing suffix would be silently, incorrectly excluded. The real lesson stuck: index entries belong under a genuinely separate prefix from data entries, not an extension of one. Newer storage primitives follow that rule, and IndexedStore's documentation warns explicitly about the overlap risk this migration exposed.
The V2 key prefix at the top of this article is that lesson made concrete. V1's records live under api_key_, so a scan for them inevitably swept up their own index entries. V2 doesn't extend that prefix — it leads with the version instead, giving v2_api_key_, a range that shares no prefix with V1's data or V1's indexes. The transposition looks like a naming preference and is actually the fix.
Key Takeaways
- Constraints beat flexibility when every machine must agree. A general-purpose database would let two validators reach different answers to the same query — different compiler, different optimizer, different result. Five narrow primitives with visible cost make disagreement structurally impossible.
- Indexes update inside the same transaction as the data. Background or lazy indexing is normal in ordinary systems and fatal in a blockchain: one node would see fresh data, another stale, and consensus would break.
- Values are sealed; keys are not. An attacker holding a raw disk image learns the shape of the data — which namespaces exist, roughly how many records of each type — but cannot read a single value. Worth stating plainly, because the shape itself is information.
- The encryption is paid once, not continuously. Because sealing happens at the application layer rather than per disk page, the database's constant internal reshuffling (compaction) moves already-encrypted bytes around and never needs to decrypt them.
- A real bug, described rather than buried. A migration scanned records by name prefix and accidentally matched index entries as if they were data. The one-line fix worked but was fragile; the durable lesson — indexes belong in a genuinely separate namespace — is now baked into the newer primitives.
Contract storage and sealed PebbleDB are the persistence layer everything else in ComputeFlux writes through: governance proposals, provider registrations, billing records, and the WASM contract state covered in Article 5, WASM Contract Upgrades — where migration atomicity depends directly on the transactional guarantees described here.
Articles 1 and 2 have now secured data in one place: at rest, on one machine, inside one enclave. But ComputeFlux's most sensitive material — the keys that authorize spending and signing — must never sit whole in any single place, however well sealed. That's the next problem.
Next — Article 3: DKG Threshold Cryptography: how a private key can exist and be used without ever being assembled anywhere.