# SLM NeoDB

> NeoDB is a commercial document database and full-text search engine written in Rust. It combines RocksDB (storage + WAL) with Tantivy (search + aggregations) to deliver **per-document ACID**, full-text search, real-time analytics, and vector search in one engine — unifying what is usually a stack of four separate systems (relational store, search engine, vector database, cache) without the operational complexity of running and integrating them.

**Website:** https://neodb.slm.cloud

### Documentation index

| Document | URL | What it covers |
|---|---|---|
| **learn.md** *(this file)* | https://neodb.slm.cloud/docs/learn.md | AI context file — what NeoDB is, ES comparison, licensing, types, query DSL, integration patterns |
| API Reference | https://neodb.slm.cloud/docs/api-reference.md | Every HTTP endpoint with curl examples, request/response schemas |
| Quick Start | https://neodb.slm.cloud/docs/quickstart.md | Full banking example: schema, CRUD, search, aggregations, security — from zero in 15 min |
| Integration Guide | https://neodb.slm.cloud/docs/NEODB-INTEGRATION.md | Paste-into-AI guide for Cursor, Claude Code, Lovable, Bolt, v0 — code patterns, security rules |
| Migration from Elasticsearch | https://neodb.slm.cloud/docs/migration-from-elasticsearch.md | Side-by-side ES→NeoDB mapping for every operation, query, and type |
| Vector Search Guide | https://neodb.slm.cloud/docs/vector-search-guide.md | Embeddings, KNN, schemas, models, chunking — how to store and search vectors in NeoDB |
| RAG Pipeline Guide | https://neodb.slm.cloud/docs/rag-guide.md | Complete 2026 RAG pipeline: NeoDB retrieval + reranking + context compression + LLM |
| Architecture | https://neodb.slm.cloud/docs/architecture.md | RocksDB + Tantivy internals, ACID protocol, WAL, column families, internal structs |
| Design Philosophy | https://neodb.slm.cloud/docs/philosophy.md | The "engine is smart, developer is simple" principle and all design decisions |
| Technical Spec | https://neodb.slm.cloud/docs/spec.md | Complete product contract — all types, queries, security, error codes |
| Deployment Guide | https://neodb.slm.cloud/docs/deployment.md | Production setup: systemd, Caddy/TLS, backups, monitoring, best practices |
| License Lifecycle | https://neodb.slm.cloud/docs/license-lifecycle.md | Key management in production: server migration, IP changes, Kubernetes, rotation, expiration, revocation |
| PCI DSS | https://neodb.slm.cloud/docs/pci.md | PCI DSS v4.0 compliance mapping, blind fields, audit log, CIDR binding |
| Auditor's Guide | https://neodb.slm.cloud/docs/audit-verification.md | Evidence collection, control verification, audit trail reconstruction |
| Roadmap | https://neodb.slm.cloud/docs/roadmap.md | Phase-by-phase feature roadmap and exit criteria |
| Commercial / Battlecard | https://neodb.slm.cloud/docs/commercial.md | Competitive positioning vs PostgreSQL, Elasticsearch, Meilisearch, Typesense |
| Executive Brief | https://neodb.slm.cloud/docs/SLM-NeoDB-Brief-Ejecutivo-v1.0.md | Non-technical executive summary for CTOs, CFOs, decision makers |
| Security Superiority | https://neodb.slm.cloud/docs/SLM-NeoDB-Security-Superiority-v1.0.md | Deep-dive security architecture vs the competition |

---

## What NeoDB is

NeoDB is a single-process, embedded database that handles:
- **Storage:** Document storage with per-document ACID writes (RocksDB WAL + fsync before ACK)
- **Search:** Full-text search, exact matches, fuzzy, wildcard, phrase queries
- **Analytics:** Aggregations (sum, avg, min, max, count, histogram, date histogram, terms, percentiles)
- **Vector search:** KNN cosine similarity for embeddings/RAG
- **Geo:** Radius and bounding-box queries
- **Scripting:** Rhai sandbox for update scripts, score functions, script aggregations

**Design philosophy:** The engine is smart so the developer stays simple.

- `text` → the engine internally creates 3 Tantivy indices (tokenized, exact, fast field). You just write `"text"`.
- `number[n]` → one type for all numerics. No `long` vs `double` vs `decimal`. `number[2]` means 2 decimal places.
- `vector` → auto-detects dimensions, normalizes, picks search algorithm.
- Queries → the engine picks the optimal index based on query type. You don't think about internals.

**Your object is your document.** The JSON your form POSTs or your API receives already *is* the object — write it to NeoDB unchanged and it is immediately durable, searchable, and aggregatable. No shredding it across normalized tables, no reassembling it with JOINs. Capture the data as it arrives, then layer search, typed aggregations, and vector/RAG over the *same* documents when you know what you need — no migration, no ETL, no separate analytics or vector store. Big-data analytics and AI retrieval are embedded in the same engine as your operational data. See [Design philosophy](https://neodb.slm.cloud/docs/philosophy.md).

---

## The data model: few indices, many types

This is the one concept to get right — and it is **the opposite of how modern Elasticsearch works**, so read this even if you know ES.

- An **index** is a namespace — a container for related data. You have **few** of them: typically one per application, per tenant, or per environment.
- A **type** is a kind of object inside an index — `user`, `session`, `transaction`, `magic_link`. You have **many** types in one index.
- Each **type has its own independent schema** (mapping *per object*, not per index). The schema of `user` does not affect `transaction`, even in the same index.

```
ung/persona      ← index "ung", type "persona"
ung/session      ← same index, different type, different schema
ung/magic_link   ← same index again
```

### Why this is the opposite of Elasticsearch

Elasticsearch *had* multiple types per index, deprecated them in 6.x, and **removed them entirely in 7.x** — everything is now `_doc`. The guidance became "one index per entity type." NeoDB went the other way and keeps **types as first-class citizens**: `_type` is real and meaningful, every type carries its own schema and metadata, and many types live together in one index.

This lineage comes from the **SLM Framework**, which modeled objects — each with its own type and metadata — *inside* an index on top of Elasticsearch. NeoDB makes that model native to the engine: types and per-object metadata are part of its DNA, not a convention bolted on.

### The mistake to avoid: one index per type

Coming from modern ES, the instinct is to create one index per entity. **Don't.** That produces index sprawl that fights the model:

| Anti-pattern (ES habit) | Correct (NeoDB) |
|---|---|
| `ung_personas/persona` | `ung/persona` |
| `ung_sessions/session` | `ung/session` |
| `ung_auth_pending/magic_link` | `ung/magic_link` |

With one index per app you get **simple authorization** (one SLM-KEY scoped to `ung`, no globs), **trivial cross-type aggregations** within the index, and **the index as the atomic unit** for backup and (future) replication — `ung` is the whole app.

### How to scale the index axis

You add indices along **tenant** or **environment**, never along type:

- **Multi-tenant:** `tenant_acme/user`, `tenant_globex/user` — one index per tenant, one SLM-KEY per tenant.
- **Environments:** `ung-dev`, `ung-staging`, `ung-prod` — one index per environment.

Always: one index per *boundary*, many types inside it.

### High-volume streams: time-based indices and glob queries

The "few indices" rule is about your *application* model. For **unbounded, append-only streams** — logs, events, transactions — you deliberately split along **time**, not type: one index per day or month.

```
trxs-2026-01/tx
trxs-2026-02/tx
trxs-2026-03/tx
```

Query across them with a **wildcard in the index name** — the same pattern as Elasticsearch:

```bash
# Search every month at once
POST /trxs-2026-*/tx/_search
{ "query": { "range": { "monto": { "gte": 100 } } } }

# Aggregations merge across all matched indices
POST /trxs-2026-*/tx/_search
{ "size": 0, "aggs": { "total": { "sum": { "field": "monto" } } } }
# → metric sums are added; terms buckets are combined by key
```

- **Reads, search, and aggregations** accept a glob in the index segment and run across every matching index, merging the results.
- **Writes always target one concrete index** (the current period, e.g. `trxs-2026-03`). A wildcard in a write index name is **rejected with `400 INVALID_REQUEST`** — wildcards are read-only, so a stray `*` in a write never silently creates a phantom index.

Splitting by time keeps each index bounded — fast queries, and cheap retention (drop an old month by deleting its index) — while glob queries give you the whole history on demand. This complements "few indices, many types": few indices for your app model, time-rotated indices for high-volume streams.

---

## Where NeoDB fits — one engine instead of four

Every data engine is optimized for a profile and defers elsewhere. In practice, a modern application ends up running **several systems in parallel and integrating them** — a relational store, a search engine, a vector database, and a cache. NeoDB collapses that stack into one engine.

| System | Optimized for | Typically complemented by another component for |
|---|---|---|
| **PostgreSQL** | Transactional and relational integrity | Full-text and vector search at scale |
| **Elasticsearch** | Search and analytics | Strong consistency / ACID guarantees |
| **MongoDB** | Flexible document schema | Built-in full-text search and analytics; strong-consistency defaults |
| **Pinecone / vector DBs** | Vector similarity (KNN) | A primary datastore (they are a complement, not a system of record) |
| **NeoDB** | **Documents + search + vectors + analytics + audit, with per-document ACID** | **Unifies the layers above into a single binary** |

NeoDB does not replace PostgreSQL at what PostgreSQL does best. It replaces the **four-system stack** you would otherwise run to get documents, search, vectors, and analytics with strong consistency — in one binary. You compete against integration cost, not against any single tool's strength.

> **A note on transactions.** NeoDB guarantees ACID **per document**. Multi-entity operations are modeled with the aggregate pattern (one business operation = one atomic document — the same principle behind event-sourced ledgers); invariants that genuinely span aggregates use saga/outbox coordination. Multi-document transactions are on the v2 roadmap. See [Building transactional systems on NeoDB](#building-transactional-systems-on-neodb).

---

## Why NeoDB instead of Elasticsearch / OpenSearch

| Capability | Elasticsearch | OpenSearch | NeoDB |
|---|---|---|---|
| Consistency | Eventual (1-2s lag) | Eventual | **Strong — write visible immediately** |
| WAL with fsync | Partial | Partial | **Full — ACK after fsync** |
| Runtime | JVM (4-16 GB RAM) | JVM | **Rust — ~128 MB RAM** |
| Numeric types | float/double/long/integer/scaled_float | same | **`number[n]` — one type** |
| Exact match on text | Requires `.keyword` subfield | same | **Built into `text` — no `.keyword`** |
| Auth | Basic/API key | same | **SLM-KEY + CIDR binding** |
| Soft deletes | Not native | Not native | **Native — `_deleted: true`** |
| Crash recovery | Manual replay | Manual replay | **Automatic WAL replay on restart** |
| License | SSPL / proprietary | Apache 2 | **Commercial — see Editions** |
| Starting price | ~$95/mo (Elastic Cloud) | ~$65/mo (AWS) | **Free (Developer tier)** |

**Key ACID difference:** Elasticsearch is near-real-time. A write returns 200 before the document is indexed and visible. NeoDB returns 201 only after the WAL fsync completes — the document is immediately available for reads and search. This is critical for financial systems, audit logs, and any use case where "I wrote it, I can read it back immediately" is a requirement.

**Key memory difference:** A minimal Elasticsearch production cluster needs 3 nodes × 16 GB RAM. NeoDB runs the same workload in a single node at ~128 MB.

**Key simplicity difference:** Elasticsearch's `text` fields require `.keyword` subfields for exact matches, sorting, and aggregations. NeoDB `text` does all of this automatically — one field, all capabilities.

---

## Data Types

NeoDB has exactly 7 field types:

| Type | Description | Search | Filter | Sort | Aggregation |
|---|---|---|---|---|---|
| `text` | Any string. Full-text + exact + fast field, all automatic. | ✅ | ✅ | ✅ | ✅ |
| `number[n]` | Numeric with `n` decimal places. `number[0]`=integer, `number[2]`=money. | — | ✅ | ✅ | ✅ |
| `datetime` | Timestamps in 6 formats (see below). Stored as UTC i64. | — | ✅ | ✅ | ✅ |
| `boolean` | true/false. | — | ✅ | ✅ | ✅ |
| `blind` | Sensitive field. Engine stores BLAKE3(value), discards original. Search by exact match only (engine hashes query before lookup). | — | Exact only | ❌ | ❌ |
| `geo` | lat/lon coordinates. Radius and bounding-box queries. | — | ✅ | ❌ | ❌ |
| `vector[n]` | AI embeddings of dimension n. KNN cosine similarity. | — | KNN | By score | ❌ |

### number[n] rules
- `number[2]` with `"25.10"` → stored as `2510` (i64 scaled). Correct.
- `number[2]` with `"25.126"` → **rejected** with `PRECISION_EXCEEDED`. The engine never rounds silently.
- `number[2]` with `"hello"` → **rejected** with `INVALID_TYPE`.

### datetime accepted formats
```
"2026-03-22T19:00:00.000Z"   ISO 8601 with Z
"2026-03-22T19:00:00Z"       ISO 8601 without ms
"2026-03-22 19:00:00"        SQL style
"2026-03-22"                 Date only (assumes 00:00:00 UTC)
1774226788183                Epoch milliseconds (13 digits)
1774226788                   Epoch seconds (10 digits, × 1000)
```
All other formats → rejected with `UNSUPPORTED_DATE_FORMAT`.

---

## HTTP API

### URL structure

```
/{index}/{type}/{id}
```

- **index** — logical namespace, *few of them* (one per app / tenant / environment), e.g. `banco`, `tenant_acme`, `ung`
- **type** — kind of object within the index, *many of them*, each with its own schema, e.g. `user`, `transaction`, `session`
- **id** — document ID (your string, or omit for auto-generated)

> One index, many types — not one index per type. This is the opposite of modern Elasticsearch; see [The data model](#the-data-model-few-indices-many-types).

### Authentication

Every request (except `/_health`) must include:
```
SLM-KEY: key_id:key_secret
```

The key is validated against: CIDR whitelist → BLAKE3 hash comparison → quota → permissions. Invalid keys receive a TCP reset with no HTTP response (silent drop).

**Security rule: the SLM-KEY is a backend secret. Never put it in frontend code.**

### Core operations

```bash
# Create / replace (PUT)
PUT /index/type/id
{"field": "value", ...}
→ 201 Created

# Create with auto-ID (POST)
POST /index/type
{"field": "value", ...}
→ 201 Created, {"_id": "auto-generated-id", ...}

# Read
GET /index/type/id
→ 200, document with _source

# Partial update (PATCH)
PATCH /index/type/id
{"field": "new-value"}

# Optimistic concurrency (PUT)
PUT /index/type/id?version=3
→ 409 VERSION_CONFLICT if current version != 3

# Soft delete
DELETE /index/type/id
→ marks _deleted: true, not physically removed

# Search
POST /index/type/_search
{"query": {"match": {"field": "value"}}}

# Bulk ingest — NDJSON, one plain document per line (_id optional)
POST /bank/tx/_bulk
{"_id": "tx-001", "amount": "150.00", "concept": "payment"}
{"amount": "75.00", "concept": "fee"}

# Bulk upsert — create-or-replace, idempotent (safe to re-run a seed/import)
POST /bank/tx/_bulk?mode=upsert
{"_id": "tx-001", "amount": "150.00", "concept": "payment"}
```

> **Loading data idempotently?** Use `_bulk?mode=upsert`: existing `_id`s are
> replaced, new ones created, in a single batched call — no need to delete first
> or handle "already exists" errors. Default `mode=create` rejects an existing
> `_id` with `DOCUMENT_EXISTS`.

### Response envelope

```json
{
  "_id": "doc-001",
  "_index": "banco",
  "_type": "user",
  "_version": 1,
  "_created_at": 1774226788183,
  "_updated_at": 1774226788183,
  "_source": {
    "nombre": "Gonzalo",
    "email": "gonzalo@example.com"
  }
}
```

User fields are always in `_source`. Fields prefixed with `_` are engine metadata.

---

## Schema Definition

```bash
POST /index/type/_schema
{
  "strict_mode": true,
  "fields": {
    "nombre":     "text",
    "email":      "text",
    "pin":        "blind",
    "saldo":      "number[2]",
    "fecha":      "datetime",
    "activo":     "boolean",
    "ubicacion":  "geo",
    "embedding":  "vector[1536]"
  }
}
```

### `strict_mode` — when to use which

- **`strict_mode: true` — the engine enforces your contract.** Any field not in the schema is **rejected with `400`** (a loud failure, not a silently-stored stray field). Use it wherever the shape is known and correctness matters: **authentication** (`session`, `magic_link`, credentials), **money** (`number[n]`), and **`blind` / sensitive** fields. A typo like `passwrod` then fails fast, instead of silently creating a junk field while the real `password` goes missing.

- **`strict_mode: false` — capture now, model later.** Unknown fields are stored as-is, with their types inferred. Use it for ingest where the shape is still evolving or you do not control it — incoming forms, third-party payloads, exploratory data. You lose nothing: you can declare a schema and switch to `strict_mode: true` later, over the same documents.

> **Recommendation:** start `false` while you capture, then tighten to `true` once the shape is settled — especially for auth, money, and sensitive fields, where a strict engine is exactly what you want. Declaring monetary fields as `number[n]` also guarantees exact decimals in storage and aggregations (see [Data Types](#data-types)).

> **Note:** The object form `{"type": "text"}` is also accepted as an alias for `"text"`. Only the `type` key is recognized — any other keys will return a 400 error.

---

## Query DSL

NeoDB's query DSL is a strict subset of Elasticsearch's. These queries work identically:

```json
// Match all documents
{"query": {"match_all": {}}}

// Full-text match — ignora mayúsculas y acentos; agregá "fuzziness" para typos
{"query": {"match": {"nombre": "gonzalo"}}}

// Exact term — case y acento SENSIBLE; para IDs/códigos/enums, no para búsqueda de texto
{"query": {"term": {"status": "active"}}}

// Range
{"query": {"range": {"saldo": {"gte": 100, "lte": 5000}}}}

// Boolean
{"query": {"bool": {
  "must": [{"match": {"concepto": "pago"}}],
  "filter": [{"range": {"fecha": {"gte": "2026-01-01"}}}],
  "must_not": [{"term": {"status": "cancelled"}}]
}}}

// IDs
{"query": {"ids": ["id-1", "id-2", "id-3"]}}

// Wildcard
{"query": {"wildcard": {"email": "*.@example.com"}}}

// Fuzzy
{"query": {"fuzzy": {"nombre": {"value": "gonzalo", "fuzziness": 1}}}}

// Match phrase
{"query": {"match_phrase": {"concepto": "transferencia internacional"}}}

// Nested geo
{"query": {"geo_distance": {"field": "ubicacion", "lat": 9.01, "lon": -79.51, "distance": "10km"}}}
```

### `term` vs `match` — la decisión que más confunde

NeoDB no separa `keyword` de `text`: **el operador define la semántica**, no el tipo del campo.

| Querés… | Usá | Ejemplo | Resultado |
|---|---|---|---|
| Match **exacto** (IDs, códigos, enums, slugs) | `term` / `terms` | `{"term": {"estado": "CDMX"}}` | Solo `"CDMX"`. **No** trae `"cdmx"` ni `"Cdmx"`. |
| Buscar por **similitud** (nombres, texto) | `match` | `{"match": {"nombre": "garcia"}}` | Trae `"García"`, `"GARCIA"`, `"garcía"`. |
| Similitud **con typos** | `match` + `fuzziness` | `{"match": {"nombre": {"query": "rodrigez", "fuzziness": "AUTO"}}}` | Trae `"Rodríguez"`. |

- **`term` es sensible a mayúsculas y acentos** — compara el valor tal cual se guardó. Es lo correcto para identificadores, donde `"USER-01"` y `"user-01"` son cosas distintas.
- **`match` ignora mayúsculas y acentos** por diseño, y con `fuzziness` (`"AUTO"`, o un número) tolera errores de tipeo — como el `match` de Elasticsearch.

> Regla de oro: **`term` = "es esto". `match` = "se parece a esto".**
> Si buscás como humano, es `match`. Si comparás un identificador, es `term`.

**What NeoDB does NOT support from Elasticsearch:**
- `_doc` as a type (types are real in NeoDB)
- `campo.keyword` subfields (not needed — `text` handles both)
- Painless scripting (use Rhai)
- JOINs between indices (model relationships with references + selective denormalization — see [Modeling relationships](#modeling-relationships))
- Multi-document transactions (each document is atomic; cross-doc transactions are v2)

### The search response: `total` and `total_relation`

Every search response carries both fields — read them together:

```json
{"total": 1000, "total_relation": "gte", "results": [...]}
```

- `"eq"` — `total` is the exact number of matching documents.
- `"gte"` — there are **at least** that many; the count was capped by the engine's internal
  window of `max(size, 1000)` documents per index.

When is it always `"eq"`?

- `match_all` — counted from the index, any size.
- Any query whose full result fits inside the window.
- **Exact-translation queries**: `term`/`terms` on a declared `boolean` or `blind` field, and
  `bool` combinations of those. The engine counts these directly in the index — exact totals
  at near-zero cost even over millions of matches, no flag needed.

For everything else (`term` on text, `match`, ranges…), a saturated window reports a floor,
not a fact. If you need the exact number, raise `size` above the expected count (honored well
past 100k) and check that `total_relation` came back `"eq"`. In glob searches (`idx-*`) the
window applies **per index**, so a single saturated index makes the combined total a floor.

---

## Aggregations

```json
POST /banco/tx/_search
{
  "query": {"range": {"fecha": {"gte": "2026-01-01"}}},
  "aggs": {
    "total": {"sum": {"field": "monto"}},
    "avg_monto": {"avg": {"field": "monto"}},
    "por_tipo": {"terms": {"field": "tipo", "size": 10}},
    "por_dia": {
      "date_histogram": {"field": "fecha", "interval": "day"},
      "aggs": {"diario": {"sum": {"field": "monto"}}}
    }
  }
}
```

Aggregations work on `text`, `number[n]`, `datetime`, and `boolean` fields. They do **not** apply to `vector[n]`, `geo`, or `blind` fields.

---

## Vector Search (RAG)

```json
POST /docs/chunk/_search
{
  "query": { "knn": { "field": "embedding", "vector": [0.023, -0.041, ...], "k": 5, "min_score": 0.5 } },
  "filter": { "term": { "tenant_id": "acme" } },
  "fields": ["text", "tenant_id"]
}
```

Notes (the exact, current shape):

- The KNN node goes **inside `query`**, and the vector field is **`vector`** (not `query_vector`). A query operator at the body root, or `query_vector`, is rejected with `400 INVALID_QUERY`.
- **`filter` (top-level) restricts the results** — essential for multi-tenant RAG. `filter` is applied to the KNN candidates, so a tenant filter keeps only that tenant. (`post_filter` and a `filter` array behave the same.)
- Results are **ordered by cosine similarity desc**; the score is at **`_source._score`**. `min_score` filters by threshold.
- By default the response echoes the stored document, **including the `embedding`**. Use a **`fields` projection** (e.g. `"fields": ["text", "tenant_id"]`) to drop the bulky vector from the response.

NeoDB does not generate embeddings. You generate them with any model (OpenAI, Ollama, Cohere, etc.) and store them in a `vector[n]` field. NeoDB does the KNN search. **Distance metric: cosine** (v1; not configurable per field).

---

## Idempotency

Pass `SLM-OPERATION-ID: <uuid>` in the request header. If the same operation ID is received twice, the second request returns the same response as the first with no duplicate write.

```bash
curl -X PUT /index/type/id \
  -H "SLM-KEY: key_id:secret" \
  -H "SLM-OPERATION-ID: $(uuidgen)" \
  -d '{"field": "value"}'
```

---

## Commercial Editions and Licensing

NeoDB uses a hardware-locked commercial license. The **Developer** edition is free and requires no license key. Paid tiers (Starter, Business, Enterprise) require a license obtained from the NeoDB license portal.

### Editions

| Edition | Price | Max docs | Max indices | Max nodes | Notes |
|---|---|---|---|---|---|
| **Developer** | Free | 100K | 3 | 1 | No production safeguards, no SLA |
| **Starter** | $99/mo | 1M | 10 | 1 | Cloud-only |
| **Business** | $999/mo | 10M | Unlimited | 4 | Cloud or on-prem |
| **Enterprise** | Custom | 100M+ | Unlimited | 10+ | Full HA, dedicated, all features |

Enterprise limits are negotiated per deal. The license file carries the exact limits agreed to.

### How licensing works (technical flow)

1. **Acquire** — customer buys via sales, receives a license key (opaque 40-char string, shown once, e.g. `SLM-ENT-abc123...`).

2. **Activate** — on first startup with `--license-key SLM-ENT-abc123`, NeoDB sends an activation request to `https://license.neodb.slm.cloud/activate` containing:
   - The license key
   - A BLAKE3 hardware fingerprint (hash of CPU serial + motherboard UUID + disk serials + MAC addresses)
   - NeoDB version, OS, hostname

3. **License file** — the license server validates the key, records the hardware fingerprint as an activated machine, and returns a signed binary license file (Ed25519 signature). NeoDB caches this locally.

4. **Ongoing validation** — on each startup, NeoDB verifies the license file signature with the embedded Ed25519 public key. If valid and not expired, the instance starts. If the license file is missing or signature fails, NeoDB starts in Developer mode (capped at Developer limits).

5. **Machine slots** — each edition allows a fixed number of simultaneously activated machines. Activating beyond the limit is rejected. A customer (or admin) can deactivate a machine via the portal to free a slot.

6. **Expiry** — licenses have a fixed term (e.g., 1 year, 3 years). NeoDB enforces expiry at startup. Renewal extends the `valid_until` date and reissues the license file.

### What the developer needs to do

```bash
# Starter/Business/Enterprise: provide the license key at startup
./neodb --data-dir ./data --license-key SLM-ENT-abc123...

# Or via environment variable
LICENSE_KEY=SLM-ENT-abc123... ./neodb --data-dir ./data

# Developer (free): just start without a license key
./neodb --data-dir ./data
```

The license check is silent. If the key is valid, the instance runs at the licensed tier. If not, it runs at Developer limits.

### License portal

Customers manage their licenses at the portal:
- View activated machines and their hardware fingerprints
- Deactivate machines (free a slot for a new activation)
- See license expiry date and edition limits

---

## Security Model

### SLM-KEY format and creation

```
key_id:key_secret
```

Example: `key_a1b2c3d4e5f6:SLM-PRD-abc123def456...`

Keys are managed via the API:

```bash
# Create a key with read-only access from a specific subnet
POST /_security/keys
{
  "name": "analytics-service",
  "allowed_ips": ["10.0.1.0/24"],
  "permissions": [
    {"index_pattern": "banco", "actions": ["Read", "Search"]}
  ]
}
→ {"key_id": "key_abc123", "key_secret": "SLM-...", ...}

// Las acciones van capitalizadas: Read, Write, Delete, Search, Bulk, Schema,
// BlobRead, BlobWrite, Admin.
//
// Obligatorios (son decisiones de seguridad que el motor no puede suponer):
//   name, allowed_ips, y permissions con index_pattern + actions.
// Opcionales con default:
//   types (default ["*"]), denied_fields (default []),
//   quotas (default 1000 rps / 10000 MB por día), expires_at.
//
// Ojo: una cuota en 0 no significa "sin límite" — bloquea todo. Se rechaza al crear.
// Para deshabilitar una llave, PATCH con "active": false.
```

The `key_secret` is shown once and never stored in plaintext. Store it securely (environment variable, secrets manager).

### CIDR binding

Each key has a CIDR whitelist. A request from an IP outside it receives **`403` with an empty body and `Connection: close`** — the same opaque answer as any other credential failure, so an attacker cannot tell which check rejected them. A stolen key is useless outside the authorized network.

### Request validation order (silent drops at each step)

0. `SLM-KEY` present but malformed (not `key_id:secret`) — **`400 MALFORMED_KEY`**. It is a
   format error: it touches no key and the format is public, so it leaks nothing.
1. `SLM-KEY` header present — else opaque `403`
2. `key_id` exists — else opaque `403`
3. Key is active — else opaque `403`
4. BLAKE3(key_secret received) == stored hash — else opaque `403`
6. Key not expired — else TCP reset
7. Origin IP in key's CIDR — else TCP reset
8. Quota (RPS) — if exceeded: **429** (only HTTP error returned at this layer)
9. Permission rule matches — else TCP reset
10. Action allowed by rule — else TCP reset

Steps 1–7 and 9–10 return no HTTP response. An attacker gets no feedback on which step failed.

### Field masking

Fields can be masked per key. Masked fields are removed from responses before the bytes leave the engine:

```json
{
  "permissions": [{
    "index": "banco",
    "type": "user",
    "actions": ["read", "search"],
    "masked_fields": ["pin", "cuenta.numero"]
  }]
}
```

Masking uses dot notation for nested fields. It never modifies storage — only the serialized response.

---

## Backend Integration Pattern

```typescript
// lib/neodb.ts — all server-side, never exposed to browser
const NEODB_URL = process.env.NEODB_URL ?? "http://127.0.0.1:7700";
const NEODB_KEY = process.env.NEODB_KEY!; // SLM-KEY — never send to frontend

async function neodb<T>(method: string, path: string, body?: unknown): Promise<T> {
  const res = await fetch(`${NEODB_URL}${path}`, {
    method,
    headers: {
      "SLM-KEY": NEODB_KEY,
      "Content-Type": "application/json",
    },
    body: body ? JSON.stringify(body) : undefined,
  });
  if (!res.ok) {
    const err = await res.json().catch(() => ({ error: "UNKNOWN" }));
    throw new Error(`NeoDB ${res.status}: ${err.error} — ${err.message}`);
  }
  return res.json();
}

// Usage
const doc = await neodb("GET", "/banco/user/user-001");
const results = await neodb("POST", "/banco/user/_search", {
  query: { match: { nombre: "gonzalo" } },
});
await neodb("PUT", "/banco/user/user-001", { nombre: "Gonzalo", saldo: "1500.00" });
```

---

## Error Format

All errors use this envelope:

```json
{
  "status": 400,
  "error": "PRECISION_EXCEEDED",
  "message": "Field 'saldo' expects number[2], received 3 decimal places.",
  "details": {},
  "request_id": "req_abc123",
  "timestamp": 1774226788183
}
```

Common error codes:
- `DOCUMENT_EXISTS` (409) — PUT with same ID and no `?version`
- `VERSION_CONFLICT` (409) — PUT with `?version=n` but current version differs
- `NOT_FOUND` (404) — document not found or soft-deleted
- `PRECISION_EXCEEDED` (400) — `number[n]` field got too many decimal places
- `INVALID_TYPE` (400) — field value doesn't match declared type
- `UNSUPPORTED_DATE_FORMAT` (400) — datetime field got an unrecognized format
- `UNKNOWN_FIELD` (400) — strict mode, field not in schema
- `RATE_LIMITED` (429) — exceeded key's RPS quota

---

## When to Use NeoDB

**NeoDB is a great fit for:**
- Fintech / financial systems modeled as event-sourced ledgers (immutable transaction records, per-document ACID, audit) — see [Building transactional systems on NeoDB](#building-transactional-systems-on-neodb)
- Healthcare and government records organized by aggregate (episode, case, file) with strong audit and search
- Multi-tenant SaaS with per-tenant data isolation (separate indices per tenant)
- Document-centric apps that need search without a separate search service
- RAG / AI apps that combine structured filtering with vector KNN
- Audit logs and compliance records (soft deletes, version history, immutable writes)
- IoT / time-series with heavy filtering and aggregations

**Pair NeoDB with a system of record when you need:**
- Fully serializable, interactive `BEGIN … COMMIT` transactions with snapshot isolation across many rows (NeoDB does atomic multi-document writes via `POST /_atomic` — all-or-nothing — but not snapshot isolation)
- Relational data with complex JOINs across entities (use PostgreSQL alongside)
- Message queuing or pub/sub (use Kafka, NATS)
- In-memory caching (use Redis)

---

## Building transactional systems on NeoDB

NeoDB guarantees ACID **per document**: each write is atomic and durable (WAL + fsync before the ACK). For an operation that must write **several documents indivisibly**, **`POST /_atomic`** applies a batch of mixed create/update/delete ops **all-or-nothing** — either every op lands or none does. (NeoDB does not provide interactive `BEGIN … COMMIT` with snapshot isolation; that is a separate concern.) This section shows how to model transactional systems: **default to aggregates**, and reach for `/_atomic` when an operation genuinely spans documents.

### The rule: one business operation = one document

Define your aggregate boundary so that any invariant that must hold atomically lives inside a **single document**. This is not a workaround — it is how the most demanding ledger and accounting systems are built (event sourcing / double-entry as immutable events).

**Example — a bank transfer.** The naïve relational model mutates two balance rows and needs a multi-row transaction. The correct, scalable model records the transfer as **one immutable event**:

```bash
# One atomic document captures the whole operation. WAL + fsync = all-or-nothing.
PUT /bank/ledger_entry/txn-7f3a
{
  "type": "transfer",
  "from_account": "acc-001",
  "to_account": "acc-002",
  "amount": "100.00",
  "status": "posted"
}
```

Account balances are a **projection** derived from ledger entries, not mutable rows. The failure mode "money debited but not credited" cannot occur, because the debit and the credit are facts of **one** atomic write. Combined with NeoDB's idempotency (`SLM-OPERATION-ID`, exactly-once), a retried request never double-posts.

The same pattern applies elsewhere: an enrollment-with-payment is one enrollment event; a hospital admission-with-charge is one admission document; a government filing is one case document with an immutable history.

### When the operation truly spans several documents

Sometimes one operation must touch several documents indivisibly and they can't be a single aggregate — e.g. debit one account, credit another, and record a movement (different types), or move an item between two tenant indices. Use **`POST /_atomic`**: send an array of `create` / `update` / `delete` ops and the engine applies them **all-or-nothing** in one atomic write.

```bash
POST /_atomic
{
  "ops": [
    { "op": "update", "index": "bank", "type": "account",  "id": "A", "if_version": 3, "doc": { "balance": "900.00" } },
    { "op": "update", "index": "bank", "type": "account",  "id": "B", "if_version": 7, "doc": { "balance": "1100.00" } },
    { "op": "create", "index": "bank", "type": "movement", "doc": { "from": "A", "to": "B", "amount": "100.00" } }
  ]
}
```

Either all three land or none do. Highlights:

- **All-or-nothing**, across multiple indices and types in one batch.
- **Per-op optimistic preconditions** — `if_version` applies an op only if the document is still at that version.
- **Per-op authorization** — every op is checked against the key's permissions; if any op is not allowed (or fails validation/precondition), the **whole batch is rejected with zero writes** and the response lists every offending op.
- **Idempotent** — set `SLM-OPERATION-ID`; retrying the same batch never double-applies.

`/_atomic` gives **atomic writes**, not snapshot isolation — for the canonical invariants (transfer, debit/credit) that is exactly what's required. For invariants that coordinate **external systems** (a third-party payment gateway, another database), use the **saga / outbox** pattern with compensating actions: `/_atomic` is atomic for documents inside NeoDB, not for the outside world.

### What NeoDB gives you to build on

- **Per-document atomicity + durability** — WAL with fsync before ACK.
- **Atomic multi-document writes** — `POST /_atomic` applies a mixed create/update/delete batch all-or-nothing, with per-op `if_version` and per-op authorization.
- **Optimistic concurrency** — per-document `version`; concurrent updates to the same document conflict with `409` instead of silently overwriting.
- **Idempotency** — `SLM-OPERATION-ID` makes retries exactly-once (per write and per `/_atomic` batch).

---

## Modeling relationships

NeoDB has no engine-side JOINs — but relationships are not a problem. You model them two ways and choose per access pattern.

### Reference + assemble — load an entity and its graph

Store related ids, fetch children with **batched** queries (never one-by-one), and assemble in your service:

```bash
# A persona references its cars by id
PUT /fleet/persona/p-1
{ "nombre": "Ana", "car_ids": ["car-7", "car-9"] }

# Fetch ALL referenced cars in ONE round trip
POST /fleet/car/_search
{ "query": { "ids": ["car-7", "car-9"] } }

# Fetch ALL fines for those cars in ONE round trip
POST /fleet/multa/_search
{ "query": { "terms": { "car_id": ["car-7", "car-9"] } } }
```

A few sub-millisecond round trips — not N+1 — and you assemble the composite JSON in your microservice, exactly how GraphQL resolvers and microservice composition already work.

### Selective denormalization — query *across* the relationship

When you need to filter, sort, or aggregate by a field on a *related* entity, co-locate that field on the document you query — a write-time decision about what to bring together. The classic JOIN question — *personas with unpaid fines over $500, ranked by total debt* — becomes one pass, no JOIN:

```bash
# The fine carries the fields you filter/sort/aggregate by
PUT /fleet/multa/m-1
{ "car_id": "car-7", "persona_id": "p-1", "monto": 750, "estado": "impaga" }

# One query: filter + group-by + sum. No JOIN.
POST /fleet/multa/_search
{
  "size": 0,
  "query": { "bool": { "must": [
    { "term":  { "estado": "impaga" } },
    { "range": { "monto": { "gte": 500 } } }
  ] } },
  "aggs": { "por_persona": {
    "terms": { "field": "persona_id" },
    "aggs":  { "deuda": { "sum": { "field": "monto" } } }
  } }
}
# → buckets per persona_id with summed "deuda", in a single pass.
```

> Declare monetary fields as `number[n]` (e.g. `monto: number[2]`) for exact cents in sums and ranges — see [Data Types](#data-types).

The document model trades a read-time JOIN for a write-time choice about what to co-locate. The relational JOIN only wins for *ad-hoc* cross-entity queries you did not model for — and in a system you control, you model for your access patterns.

### The one real cost: referential integrity

NeoDB does not verify that a referenced id exists, nor cascade on delete. Your application maintains referential integrity. That is the concrete price of the document model — pay it deliberately.

---

## Full Documentation

- **API Reference** — https://neodb.slm.cloud/docs/api-reference.md
- **Quickstart (banking example)** — https://neodb.slm.cloud/docs/quickstart.md
- **Migration from Elasticsearch** — https://neodb.slm.cloud/docs/migration-from-elasticsearch.md
- **RAG / Vector search guide** — https://neodb.slm.cloud/docs/rag-guide.md
- **Security architecture** — https://neodb.slm.cloud/docs/pci.md
- **Deployment guide** — https://neodb.slm.cloud/docs/deployment.md
- **Design philosophy** — https://neodb.slm.cloud/docs/philosophy.md
- **SLM verifying public key** — https://neodb.slm.cloud/slm-public-key.txt
