This document describes the CXDB system architecture, storage model, and design decisions.
CXDB is a three-tier system optimized for append-dominant AI context storage:
graph TB
UI[React UI<br/>TypeScript/Next.js] --> Gateway
Gateway[Go Gateway<br/>OAuth + Static Serving] --> HTTP
Writer[Go Writer Clients] --> Binary
Binary[Rust Server<br/>Binary Protocol :9009]
HTTP[Rust Server<br/>HTTP Gateway :9010]
Binary --> Storage
HTTP --> Storage
Storage[(Storage Layer<br/>Turn DAG + Blob CAS)]
Storage --> Turns[turns/<br/>Turn DAG]
Storage --> Blobs[blobs/<br/>Blob CAS]
Storage --> Registry[registry/<br/>Type Bundles]
Tier 1: Clients
- Go Writers: High-throughput append clients using binary protocol
- React UI: Browser-based visualization and exploration
- Go Gateway: OAuth proxy and static file server for production deployments
Tier 2: Rust Server
- Binary Protocol (
:9009): Length-prefixed frames for append/read operations - HTTP Gateway (
:9010): JSON API for typed projections and registry management
Tier 3: Storage
- Turn Store: Append-only DAG of turn records
- Blob Store: Content-addressed storage with deduplication
- Registry: Type descriptors for projection
Turns form a directed acyclic graph (DAG) where each turn has exactly one parent (or no parent for roots):
turn_1 (root, depth=1) → turn_2 (depth=2) → turn_3 (depth=3)
↘ turn_4 (depth=3, branch)
Turn Record (fixed-size, 80 bytes):
TurnRecordV1 {
turn_id: u64 // Monotonic, globally unique
parent_turn_id: u64 // 0 for root
depth: u32 // parent.depth + 1
codec: u32 // Reserved (unused in v1)
type_tag: u64 // Reserved (unused in v1)
payload_hash: [32]u8 // BLAKE3-256
flags: u32 // Reserved for tombstone/overlay
created_at_unix_ms: u64 // Timestamp
crc32: u32 // Checksum
}Invariants:
turn_idis unique and never reusedparent_turn_idreferences an earlier turn or is 0depthis consistent with parent linkagepayload_hashreferences an immutable blob
Physical Layout:
turns/
├── turns.log # Append-only turn records (fixed-size)
├── turns.idx # TurnID → offset index
├── turns.meta # Declared type + encoding metadata (variable-length)
└── heads.tbl # Context → head_turn_id mapping (append-only)
Recovery:
- On startup, scan
turns.logandheads.tblsequentially - If a trailing record fails CRC, truncate to last valid position
- Rebuild in-memory index from valid records
All turn payloads are stored in a content-addressed blob store keyed by BLAKE3-256 hash.
Blob Record (variable-size):
BlobRecord {
magic: u32 = 0x42534C42 // 'B''S''L''B'
version: u16 = 1
codec: u16 // 0=none, 1=zstd
raw_len: u32 // Uncompressed size
stored_len: u32 // Compressed size
hash: [32]u8 // BLAKE3-256
stored_bytes: [stored_len]
crc32: u32 // Over header+stored_bytes
}Index Entry (fixed-size, 52 bytes):
BlobIndexEntry {
hash: [32]u8
pack_offset: u64
raw_len: u32
stored_len: u32
codec: u16
reserved: u16
}Physical Layout:
blobs/
├── blobs.pack # Append-only blob records
└── blobs.idx # Hash → offset index (in-memory + file)
Deduplication:
- Before writing, compute
BLAKE3(uncompressed_bytes) - Check index: if hash exists, skip write and return existing entry
- Otherwise, compress with Zstd level 3 and append to pack file
- Update index atomically
Compression Policy:
- Compress with Zstd level 3 by default
- If
compressed_size >= raw_size, store uncompressed (codec=0) - Small blobs (<128 bytes) are stored uncompressed
The registry stores type descriptors for projecting msgpack → typed JSON.
Bundle Format (JSON):
{
"registry_version": 1,
"bundle_id": "2025-01-30T10:00:00Z#abc123",
"types": {
"com.example.Message": {
"versions": {
"1": {
"fields": {
"1": { "name": "role", "type": "string" },
"2": { "name": "text", "type": "string" }
}
}
}
}
}
}Physical Layout:
registry/
├── bundles/
│ └── {bundle_id}.json
└── index.json # Latest bundle for each type_id
Frame Header (16 bytes):
FrameHeader {
len: u32 // Payload length
msg_type: u16 // Message type code
flags: u16 // Frame flags
req_id: u64 // Request ID for multiplexing
}Message Types:
| Code | Name | Description |
|---|---|---|
| 1 | HELLO | Handshake |
| 2 | CTX_CREATE | Create empty context |
| 3 | CTX_FORK | Fork from existing turn |
| 4 | GET_HEAD | Get current head |
| 5 | APPEND_TURN | Append new turn |
| 6 | GET_LAST | Get last N turns |
| 9 | GET_BLOB | Fetch blob by hash |
| 10 | ATTACH_FS | Attach filesystem tree |
| 11 | PUT_BLOB | Store blob |
| 255 | ERROR | Error response |
See protocol.md for wire format details.
JSON API for UI and tooling:
GET /v1/contexts- List contextsGET /v1/contexts/:id/turns- Get turns (with projection)POST /v1/contexts/create- Create contextPOST /v1/contexts/:id/append- Append turnPUT /v1/registry/bundles/:id- Publish type bundle
See http-api.md for complete reference.
When a client requests typed JSON (view=typed), the server:
- Fetch Turn: Load turn record from
turns.log+ metadata fromturns.meta - Load Blob: Fetch payload bytes from blob store using
payload_hash - Decompress: If blob is compressed, decompress with Zstd
- Decode Msgpack: Parse msgpack map with numeric keys
- Load Descriptor: Fetch type descriptor for
type_id:type_versionfrom registry - Project Fields: Map numeric tags → field names using descriptor
- Apply Rendering: Convert u64→string, bytes→base64, enums→labels
- Return JSON: Serialize to JSON with
dataand optionalunknownfields
graph LR
Turn[Turn Record] --> Blob[Load Blob]
Blob --> Decompress
Decompress --> Msgpack[Decode Msgpack]
Msgpack --> Registry[Load Descriptor]
Registry --> Project[Project Fields]
Project --> Render[Apply Rendering]
Render --> JSON[Typed JSON]
Rendering Options:
u64_format=string|number- Large integers as strings (default: string)bytes_render=base64|hex|len_only- Binary data encoding (default: base64)enum_render=label|number|both- Enum display (default: label)time_render=iso|unix_ms- Timestamp format (default: iso)
Store-Level RwLock:
- The
Storeis guarded by aRwLock, not aMutex - Read operations (GetHead, GetLast, GetBlob, search, list contexts) acquire a shared read lock
- Write operations (AppendTurn, CtxCreate, CtxFork, PutBlob) acquire an exclusive write lock
- Multiple readers proceed concurrently; writers are serialized
TurnID Allocation:
- Single global atomic counter
- Linearizable: each turn gets a unique, monotonic ID
Blob Reads (pread):
BlobStore::get()usespread(positional read) via a separate read-only file handle- This avoids seek/read mutation, allowing
&selfaccess under a shared read lock - Multiple threads can read different blobs concurrently without contention
Blob Deduplication:
put_if_absent()checks the in-memory index first (O(1) hash lookup)- If missing, compresses and appends under the write lock
Connection Limits:
- Binary protocol connections are capped at
CXDB_MAX_CONNECTIONS(default: 512) - Read/write timeouts prevent stalled clients from holding threads indefinitely
- Connections beyond the limit are rejected immediately
SSE Event Bus:
- Event subscribers use bounded channels (4096 events max per subscriber)
publish()usestry_send(): slow subscribers have events dropped rather than accumulating unbounded memory- Disconnected subscribers are removed automatically on the next publish
Example Concurrency:
Thread 1 (read): get_last(ctx=1) │ Concurrent (shared read lock)
Thread 2 (read): get_last(ctx=2) │
Thread 3 (read): search("tag=foo") │
Thread 4 (write): append_turn(ctx=1) │ Exclusive (waits for readers to drain)
Thread 5 (read): get_blob(hash) │ Waits for write to finish
Append Turn (10KB payload):
- Compute BLAKE3: ~0.5ms
- Blob dedup check: O(1) hash lookup, ~0.01ms
- Compress (if new): ~0.5ms
- Write turn record: ~0.1ms
- Update head: ~0.1ms
- Total: ~1ms p50, ~10ms p99
Get Last N:
- Walk N parents from head: O(N)
- Load blobs: O(N) hash lookups + decompression
- Decompress (Zstd): ~0.5ms per 10KB blob
- Total: ~1ms for N=10 (warm cache)
Projection (per turn):
- Msgpack decode: ~0.1ms
- Registry lookup: O(1) in-memory
- Field projection: O(fields)
- Total: ~0.2ms per turn
Storage Efficiency:
- Turn record: 80 bytes
- Turn metadata: ~50 bytes (type_id + encoding)
- Blob overhead: ~50 bytes (header + CRC)
- Typical 10KB payload compresses to ~3KB (70% reduction)
- Total: ~3.2KB per 10KB turn
Deduplication Savings:
- Identical payloads: 0 additional storage
- Example: 1000 turns with 50% duplicate content → 50% storage savings
- Fast: 3-4x faster than SHA-256 on modern CPUs
- Secure: 256-bit output, collision-resistant
- Deterministic: Same input → same hash (critical for dedup)
- Dense: Smaller than JSON (no field names repeated)
- Forward-compatible: Unknown tags preserved
- Fast: Faster to parse than JSON
- Numeric tags: Never reused, safe for schema evolution
- Fast: 10x faster compression than gzip -6
- Good ratio: ~70% size reduction on JSON-like data
- Streaming-friendly: Can compress/decompress incrementally
- Crash-safe: Partial writes don't corrupt earlier data
- Fast: No seeks, pure sequential writes (~500 MB/s on SSD)
- Simple recovery: Scan forward, truncate to last valid CRC
- Read scalability: Multiple readers proceed concurrently (reads dominate in practice)
- Correctness: Writers get exclusive access for head updates and appends
- Simple: No complex MVCC or optimistic concurrency
Single-process:
- Contexts: Millions (limited by 64-bit ID space)
- Turns: Billions (limited by 64-bit ID space and disk)
- Concurrent appends: ~10,000/sec (single process, SSD)
- Storage: Limited by disk size (~1GB per 300K turns with 10KB payloads)
Not supported in v1:
- Distributed/replicated storage
- Horizontal read scaling (concurrent reads within single process via RwLock, but single-node)
- Sub-blob chunking for huge payloads (>1MB)
See Roadmap for v2 features.
data/
├── blobs/
│ ├── blobs.pack # Append-only blob records
│ └── blobs.idx # Hash → offset index
├── turns/
│ ├── turns.log # Append-only turn records
│ ├── turns.idx # TurnID → offset index
│ ├── turns.meta # Type + encoding metadata
│ └── heads.tbl # Context → head mapping
├── registry/
│ ├── bundles/
│ │ └── {bundle_id}.json
│ └── index.json
└── fs_trees/ # Optional: filesystem snapshots
└── {tree_hash}/
Metrics (Prometheus format on :9011):
cxdb_turns_total- Total turns appendedcxdb_blobs_total- Total blobs storedcxdb_blob_dedup_hits- Deduplication hitscxdb_append_duration_seconds- Append latency histogramcxdb_storage_bytes- Total storage used
Logs (structured JSON):
CXDB_LOG_LEVEL=debugfor detailed tracing- Request/response logging
- Error stack traces
See troubleshooting.md for debugging.
Authentication:
- Binary protocol (
:9009) has no built-in auth (use TLS + firewall) - HTTP gateway (
:9010) delegates auth to Go gateway (OAuth)
Authorization:
- Single-tenant model (no per-context ACLs in v1)
- Trust all authenticated clients
Content:
- No payload inspection or sanitization
- Clients responsible for input validation
- Stored blobs are opaque bytes
Network:
- Use TLS for production deployments
- Restrict binary protocol to trusted networks
- HTTP gateway should run behind OAuth proxy
See deployment.md for production setup.