This document specifies client-side responsibilities and APIs for the AI Context Store described in NEW_SPEC.md.
The core storage model is:
- Turn DAG: immutable turns linked by
parent_turn_id, with branch heads (context_id → head_turn_id). - Blob CAS: turn payload bytes deduplicated by
content_hash = BLAKE3(uncompressed_bytes). - Typed payloads: payload bytes are Msgpack encoded with numeric field tags, plus a declared type hint captured at write time.
- Type registry: authoritative descriptors authored by Go and ingested by Rust, used to project typed/shaped JSON for UI and other readers.
This spec covers:
- Writer: Go client that publishes registry bundles and appends turns.
- Readers:
- Go reader (server-side): fetches raw bytes and decodes locally
- JS/TS server-side reader: raw or typed JSON
- Browser/React client: typed JSON only (safe integer handling, bytes rendering)
- Dense wire: Go writes turns as Msgpack blobs with numeric field tags and optional compression.
- Type source of truth: Go defines
TypeID + TypeVersionand publishes a Type Registry Bundle. - Storage is append-only: Rust persists raw bytes + declared type hint per turn. Raw bytes are never mutated.
- UI read: React fetches typed/shaped JSON from Rust (projection), derived from raw bytes using the registry.
- Type hinting: On read, clients may:
- inherit the stored declared type hint (default), or
- request a projection using another version of the same
TypeID(override).
- Forward compatibility: unknown fields are preserved in storage; tags are never reused.
Non-goals (v1):
- Full bidirectional JSON ↔ msgpack round-trip
- Generic cross-language codegen
- Protobuf descriptors (unless you choose protobuf as a codec later)
- Chunk-level dedup inside blobs (v2)
- A context is a trajectory (branch head) identified by
context_id. - A turn is an immutable node in the turn DAG identified by
turn_idand linked to its parent byparent_turn_id. - Branching (“fork from turn X”) creates a new
context_idwhosehead_turn_id = X. - Appending adds a new turn whose
parent_turn_idis the current head (or an explicitly provided parent).
Each turn stores exactly one payload blob plus a declared type hint:
DeclaredTypeRef := { type_id: string, type_version: u32 }
PayloadBytes := Msgpack bytes (optionally compressed on the wire)
Types are defined by the calling software. For AI context, recommended TypeIDs are things like:
com.yourorg.ai.MessageTurncom.yourorg.ai.ToolCallcom.yourorg.ai.ToolResponsecom.yourorg.ai.ImageAttachment
but this is purely convention; the store treats payloads as opaque bytes and relies on the registry for projection.
- TypeID: stable string identifier (never reused), e.g.
com.yourorg.ai.MessageTurn - TypeVersion: monotonically increasing
u32per TypeID - DeclaredTypeRef:
{ type_id, type_version }stored with each turn at write time - Registry Bundle: authoritative descriptors published by Go and ingested by Rust
- Projection: JSON view produced by applying a descriptor to msgpack values (coercions for bytes/u64/enums/timestamps)
- Content hash:
BLAKE3(uncompressed_msgpack_bytes); used as the blob CAS key
- Each payload is a msgpack map from field tag → value.
- Field tag MUST be a msgpack positive integer (
uint). - Rust MUST also accept digit-strings (
"1","42") as tags and normalize to integer (interop tolerance). - Omitting empty/zero fields (
omitempty) is allowed.
Blob-level dedup is maximized when identical logical values serialize to identical bytes.
Go writer SHOULD produce deterministic msgpack bytes for a given Go value by:
- Encoding payloads as a map whose keys are numeric tags.
- Emitting fields sorted by tag ascending.
- Ensuring maps inside the payload use a deterministic key ordering where feasible.
If strict determinism is not achievable for certain payloads, dedup still works for exact byte matches, but hit rate will be lower.
- Msgpack primitives: nil, bool, int/uint, str, bin, array, map
- Nested tagged-map objects (preferred)
- Array-based “positional structs” for primary objects (breaks unknown-field preservation)
- Field-name string keys (except digit-strings tolerated only for interop)
Registry bundles are JSON (or msgpack) describing known types and versions:
{
"registry_version": 1,
"bundle_id": "2025-12-19T20:00:00Z#abc123",
"types": {
"com.yourorg.ai.MessageTurn": {
"versions": {
"1": {
"fields": {
"1": { "name": "role", "type": "u8", "enum": "com.yourorg.ai.Role" },
"2": { "name": "text", "type": "string", "optional": true },
"3": { "name": "tool_call_id", "type": "u64", "optional": true },
"4": { "name": "attachments", "type": "array", "items": "typed_blob", "optional": true }
}
}
}
}
},
"enums": {
"com.yourorg.ai.Role": { "1": "system", "2": "user", "3": "assistant", "4": "tool" }
}
}Field keys are tag numbers (stringified in JSON). Tags MUST be unique per type-version.
For a given TypeID:
- Add a field: allowed (new tag only)
- Remove a field: allowed (descriptor may omit it or mark tombstone)
- Rename a field: allowed (descriptor-only change)
- Change field type: NOT allowed in-place; allocate a new tag (old tag may be tombstoned)
- Reuse a tag: forbidden forever
TypeVersionincrements when any descriptor-visible change occurs (including rename/semantic changes)
Go publishes bundles to Rust; Rust:
- validates monotonic versioning
- validates tag uniqueness/non-reuse
- validates enum references
- persists bundle + per-type/version descriptors
- makes descriptors available to the decode/projection pipeline immediately
There are two surfaces:
- Binary protocol (persistent connection): used by Go writer and Go/TS server-side readers for high-throughput turn IO.
- HTTP/JSON read gateway: used by browser/React for typed projections and developer tooling.
The binary framing is defined in NEW_SPEC.md (length-prefixed frames). This spec defines message-level semantics for typed payloads.
Writers MAY publish bundles over HTTP (recommended) even if they use binary for turn IO.
If publishing over binary, use:
REGISTRY_PUT_BUNDLE payload:
bundle_id_len: u32
bundle_id: [bytes]
bundle_json_len: u32
bundle_json: [bytes]
Idempotent by bundle_id.
APPEND_TURN payload:
context_id: u64
parent_turn_id: u64 // 0 means “use current head”
declared_type_id_len: u32
declared_type_id: [bytes]
declared_type_version: u32
encoding: u32 // 1 = msgpack
compression: u32 // 0 = none, 1 = zstd
uncompressed_len: u32
content_hash_b3_256: [32]
payload_len: u32 // bytes as-sent (compressed if compression != none)
payload_bytes: [payload_len]
idempotency_key_len: u32 // optional but recommended
idempotency_key: [bytes]
Server behavior:
- Resolves parent:
- if
parent_turn_id != 0: append onto that explicit parent (branch-in-place); context head moves to the new turn - else: append onto the current context head
- if
- Decompresses if needed, verifies
uncompressed_len, computesBLAKE3and verifiescontent_hash. - Stores blob in CAS under
content_hashif missing. - Appends a new Turn record with declared type hint and updates the context head.
APPEND_TURN_ACK:
context_id: u64
new_turn_id: u64
new_depth: u32
content_hash_b3_256: [32]
CTX_FORK payload:
base_turn_id: u64
Response:
new_context_id: u64
head_turn_id: u64
head_depth: u32
GET_LAST payload:
context_id: u64
limit: u32
include_payload: u32 // 0 metadata only, 1 include raw bytes (compressed) + declared type
Response returns ordered turns (oldest→newest in the window). Each item contains:
turn_id: u64
parent_turn_id: u64
depth: u32
declared_type_id_len: u32
declared_type_id: [bytes]
declared_type_version: u32
encoding: u32
compression: u32
uncompressed_len: u32
content_hash: [32]
payload_len: u32
payload_bytes: [payload_len] // omitted if include_payload=0
Paging uses GET_BEFORE(context_id, before_turn_id, limit).
The browser cannot practically consume the binary protocol directly (CORS, framing, streaming), and also needs projection/render options; therefore the store exposes a JSON gateway.
PUT /v1/registry/bundles/{bundle_id}
- Body: registry bundle JSON
- Responses:
201 Creatednew bundle204 No Contentidentical bundle already present409 Conflictillegal evolution (tag reuse, regression)
GET /v1/registry/bundles/{bundle_id}
GET /v1/registry/types/{type_id}/versions/{type_version}
Caching:
- MUST support
ETag/If-None-Match.
GET /v1/contexts/{context_id}/turns
Query:
limit=(default 64)before_turn_id=(optional, for paging older turns)view=one of:typed(default),raw,bothtype_hint_mode=:inherit(default): decode using the stored declared type refexplicit: require explicitas_type_idandas_type_versionlatest: decode using latest known version for the storedTypeID
- Overrides (only if mode allows):
as_type_id=...as_type_version=...
- Rendering options:
include_unknown=0|1(default 0 for UI; 1 for debug)bytes_render=base64|hex|len_only(default base64)u64_format=string|number(default string)enum_render=label|number|both(default label)time_render=iso|unix_ms(default iso)
Response for view=typed:
{
"meta": {
"context_id": "…",
"head_turn_id": "…",
"head_depth": 123,
"registry_bundle_id": "…"
},
"turns": [
{
"turn_id": "…",
"parent_turn_id": "…",
"depth": 120,
"declared_type": { "type_id": "…", "type_version": 1 },
"decoded_as": { "type_id": "…", "type_version": 3 },
"data": { "role": "assistant", "text": "…" },
"unknown": { "9": 42 }
}
],
"next_before_turn_id": "…" // for paging older turns
}Response for view=raw includes:
content_hash_b3encodingcompressionbytes_b64(optional depending onbytes_format)uncompressed_len
For view=typed, Rust performs:
- Fetch Turn record (declared type + blob hash).
- Load blob bytes from CAS; decompress if needed.
- Decode msgpack to an intermediate value.
- Normalize keys:
uintand digit-string keys →u64 tag. - Load descriptor based on type hint logic:
- inherit / latest / explicit
- Project:
- tags in payload that exist in descriptor map to
field.name - tags not in descriptor go to
unknown(optional) - missing fields may be omitted or defaulted (future option)
- tags in payload that exist in descriptor map to
- Apply rendering options (bytes, u64, enum, time semantics).
Normative defaults:
u64potentially exceeding JS safe integer: render as stringbytes: base64 by defaultsemantic=unix_ms: ISO-8601 string by default- enums: label if known, else number
- Define and version types:
- choose stable
TypeIDs - manage
TypeVersionincrements - assign numeric tags; never reuse tags
- choose stable
- Publish registry bundles:
- push before using a new type/version (strongly recommended)
- Encode payload bytes:
- msgpack map keyed by numeric tags
- deterministic encoding recommended
- Hash + compress:
- compute
content_hash = BLAKE3(uncompressed_bytes) - optionally zstd compress for the wire
- compute
- Append:
- call
APPEND_TURNwith declared type ref - provide an idempotency key for safe retries
- call
Recommended policy toggles:
- If a write references a registry bundle/type version not known to the server:
- default: server SHOULD accept raw storage (storage-first)
- strict mode: server rejects with
412 Precondition Failed
Default read path is raw, not typed JSON.
- Use binary
GET_LAST/GET_BEFOREwithinclude_payload=1. - For each turn:
- verify/optionally re-hash if needed
- decompress
- decode into the Go struct matching the declared type
Compatibility:
- Go must retain decoders for historical versions used in stored turns, or provide a migration strategy.
- Go should treat unknown tags as preserved/ignored depending on the decoder’s capabilities.
Two modes:
- Raw mode: fetch bytes and decode msgpack in Node (supports BigInt for u64).
- Typed mode: call the HTTP/JSON gateway
view=typedand treat it as a read-model for observability/UI/devtools.
Guidance:
- For model prompting and deterministic reconstruction, prefer raw mode.
- For dashboards and inspection, prefer typed mode.
- Use the HTTP/JSON gateway.
- Default request:
GET /v1/contexts/{id}/turns?view=typed&type_hint_mode=inherit
- Treat
u64as string unless explicitly requested otherwise. - Use paging via
before_turn_id.
Binary protocol errors are returned as an ERROR frame with:
code: u32
detail_len: u32
detail_bytes: [detail_len] // UTF-8 JSON object or plain text
HTTP errors:
{ "error": { "code": "...", "message": "...", "details": { } } }Canonical codes:
404 NotFound: context/turn/blob missing409 Conflict: illegal registry evolution, type hint mismatch, or head CAS conflict (if used)412 PreconditionFailed: strict registry mode rejects unknown type/version422 MissingTypeHint: missing declared type and no explicit hint424 FailedDependency: decode requested but descriptor missing500 DecodeError: msgpack invalid / decompression failure / corruption (include hash mismatch details)
This v1 spec assumes turns are appended as immutable units.
If you need to persist fine-grained incremental updates (e.g., streaming assistant tokens), you have two choices:
- Append small turns (e.g.,
AssistantDeltaturns) and merge at read time (simple, but noisy). - Add v2 support for
PATCH_TURN/APPEND_TEXT_DELTAwith background coalescing (preferred; seeNEW_SPEC.mdv2 list).