AkurAI Build
Menu

Public repository / f6f5dc7059b40f7a4042c85f7c49597638a67522

AkurAI-Framework / AGENTS_DATA.md

Latest change 4ed55d17e0979479dc70291811217113b6062fa0 - docs: split AGENTS.md into domain-indexed subfiles + add breaking-changes guide by Ólafur Búi Ólafsson

# AGENTS_DATA.md — the data engine

> How the embedded database is built out of small crates, what the shared B+tree
> keyspace looks like, and how the `?search` / embedding lifecycle works. Read
> [`AGENTS_CRATES.md`](./AGENTS_CRATES.md) for each crate's file list; this file
> is about how they **compose**.

## The stack

Everything durable sits on one crash-safe B+tree file. Two independent data
models share it, plus the semantic-search layer on top:

```
                         ┌────────── crates/cli ──────────┐
                         │ wires HTTP edge + ?search glue  │
                         └───────┬──────────────┬──────────┘
              SQL path           │              │     records path
   ┌─────────────────────────────┘              └────────────────────────────┐
   │                                                                          │
crates/sql   →  crates/sqlexec                          crates/toml  →  crates/collections
(text→AST)      (AST→rows)                              (parse        (schema + CRUD +
                    │                                     collections.toml)   substring search)
                    │                                                          │   │
                    └──────────────┬───────────────────────────────┬──────────┘   │
                                   ▼                                ▼               ▼
                            crates/storage (single-file CoW B+tree)        crates/vector
                                                                   (embeddings + cosine/rank)
```

- **`crates/storage`** — the substrate. A single-file, copy-on-write, crash-safe
  B+tree: `get`/`insert`/`delete`/`range`, atomic `commit()`. Nothing is durable
  until `commit()`; a torn commit rolls back to the last good root via the
  double-buffered checksummed meta page.
- **`crates/sql` + `crates/sqlexec`** — the relational query path. `sql` parses
  text to an AST (parser-only, never panics). `sqlexec` runs that AST over the
  B+tree: a catalog, a typed row codec, a WHERE evaluator, and the `Executor`
  (CREATE / INSERT / SELECT end-to-end).
- **`crates/collections`** — the auto-API record path. A declarative schema
  (`Collection` of typed `Field`s) yields CRUD + substring search + a `/_meta`
  manifest, with the engine owning `id`/`created`. This is the PocketBase-style
  surface that becomes REST.
- **`crates/vector`** — the semantic-search primitives layered on the record
  path: an f32 codec to store embeddings as B+tree blobs, `cosine`/`rank`, and a
  plain-HTTP embedding client.
- **`crates/toml`** — parses `backend/collections.toml`, the declarative source
  of truth (principle #5), into the schema the CLI hands to `collections`.

The two data models are **deliberately separate** today: SQL tables and
auto-API collections are different surfaces over the same tree, kept apart by
their key prefixes (below). The CLI is the only place they meet.

## The shared keyspace

All of it lives in one B+tree, partitioned by key prefix. Three reserved zones:

| Zone | Key shape | Owner | Notes |
|------|-----------|-------|-------|
| **Collection records** | `coll:<name>:<id_be>` (8-byte big-endian id) | `crates/collections` | sorts in numeric/insertion order; `id` auto-increments |
| **Collection counter** | `coll:<name>:_seq` (BE u64) | `crates/collections` | last id used; shares the `coll:<name>:` prefix but differs in length |
| **SQL catalog** | `\x00cat:<table>` | `crates/sqlexec` | table schema bytes; survives reopen |
| **SQL rowid counter** | `\x00seq:<table>` (BE u64) | `crates/sqlexec` | seeded at 0 on `CREATE TABLE` |
| **SQL row data** | `<table>:<rowid_be>` | `crates/sqlexec` | the table's own rows |

Two invariants make this collision-free:

1. **The `\0` (`\x00`) prefix is reserved for SQL metadata.** SQL identifiers are
   never allowed to begin with `\x00`, so `\x00cat:` / `\x00seq:` can never
   collide with table data keys (`<table>:<rowid>`).
2. **The `coll:` prefix is reserved for the auto-API record engine.** Within a
   collection, record keys are *exactly* `len("coll:<name>:") + 8` bytes. The
   `_seq` counter (and any future per-collection meta key) shares the prefix but
   has a different length, so a `range` scan filters strictly on length and never
   confuses records with the counter. Big-endian ids mean a prefix range scan
   yields records in id (insertion) order for free.

When adding a new keyspace, pick a prefix that can't collide with these and
document it **here** plus in the owning crate's module docs.

## Collection record shape

A stored record is a JSON object whose first two keys are always the
engine-assigned `id` (int) and `created` (unix seconds, int), followed by the
supplied schema fields in declaration order. The engine **owns** `id`/`created`:
input may not set them, and `update` may not patch them. Field validation and
coercion (e.g. `Int` accepted where `Float` is declared) happen on `create`;
`update` re-validates only the patched keys. `list` returns newest-first;
`search` is a case-insensitive substring scan over `Text` fields only.

The `/_meta` manifest (`collections::meta`) describes every collection's fields
(`name`/`type`/`required`/`embed`) and the REST endpoints the CLI exposes:
`list` / `create` / `get` / `update` / `delete` / `search` under
`/api/collections/<name>/records`. The CLI merges this into the framework-wide
`GET /api/_meta` (principle #6).

## The embedding / `?search` lifecycle

Semantic search is built-in and free on every collection, but the **model runs
externally** (an OpenAI-compatible `/v1/embeddings` endpoint, e.g. Titan
EmbeddingGemma at `:8081`). The framework owns storage + ranking in pure Rust;
it never embeds in-process.

Write path (on `create`, for fields marked `embed`):

1. `collections` stores the record under `coll:<name>:<id_be>` and exposes the
   `embed` flag in `/_meta` — but **embeds nothing itself**.
2. The CLI's vector layer reads the flag, calls `vector::embed(endpoint, model,
   text)` (which POSTs to `<AKURAI_EMBED_URL>/v1/embeddings`), and stores the
   returned vector as a little-endian f32 blob (`vector::encode`) in the B+tree
   alongside the record.

Read path (`GET …/records?search=<query>`):

1. With `AKURAI_EMBED_URL` set: embed the query, `vector::decode` each stored
   embedding, `vector::cosine` against it, and `vector::rank` the top-k.
2. With **no** endpoint configured: fall back to `collections`' substring
   `search`. **That fallback lives in the CLI, not in `vector`** — the vector
   crate is config-free, endpoint/model are always parameters.

Honest constraints (don't fake around them):

- **No TLS.** `vector`'s HTTP client is plain HTTP only; an `https://` endpoint
  returns `EmbedError::TlsUnsupported` (std has no TLS; TLS terminates at the
  edge, principle #2). Point it at a plain-HTTP embedding endpoint behind your
  own edge.
- **Brute-force cosine first.** Fine at small/medium scale; HNSW is the later
  scale optimization (queued in [`BATCHES.md`](./BATCHES.md)), droppable behind
  the same `rank` API.
- `cosine` returns `0.0` for mismatched lengths or a zero-norm vector, and
  `decode` returns `None` for a non-multiple-of-4 blob — the search path never
  panics on bad data.

## Reference docs

User-facing docs for this domain (keep them updated with the code, principle #10
— see [`AGENTS_DOCS.md`](./AGENTS_DOCS.md)):
`site/content/docs/{storage,sql,sql-executor,collections,semantic-search,toml}.md`.