Vapora/adrs/adr-004-surrealdb-database.ncl
Jesús Pérez 75e5ebd9a2
Some checks failed
Documentation Lint & Validation / Markdown Linting (push) Has been cancelled
Documentation Lint & Validation / Validate mdBook Configuration (push) Has been cancelled
Documentation Lint & Validation / Content & Structure Validation (push) Has been cancelled
mdBook Build & Deploy / Build mdBook (push) Has been cancelled
Nickel Type Check / Nickel Type Checking (push) Has been cancelled
Rust CI / Security Audit (push) Has been cancelled
Rust CI / Check + Test + Lint (nightly) (push) Has been cancelled
Rust CI / Check + Test + Lint (stable) (push) Has been cancelled
Documentation Lint & Validation / Lint & Validation Summary (push) Has been cancelled
mdBook Build & Deploy / Documentation Quality Check (push) Has been cancelled
mdBook Build & Deploy / Deploy to GitHub Pages (push) Has been cancelled
mdBook Build & Deploy / Notification (push) Has been cancelled
chore: ontology sync + 4 NCL ADRs + landing page update
on+re:
  - core.ncl: 5 new Practice nodes (notification-channels,
    vapora-capabilities, agent-hot-reload-stable-identity,
    merkle-audit-trail, notification-channels) + 5 new edges;
    knowledge-graph-execution-history updated with HNSW+BM25+RRF
  - state.ncl: production-readiness blocker/catalyst updated (hot-reload
    complete, BudgetManager/LLMRouter still require restart);
    ontoref-integration catalyst updated (vapora-ontology/reflection
    crates, api-catalog.json, nickel contracts)

  ADRs (NCL):
  - adr-013: KG hybrid search — HNSW+BM25+RRF, rejected in-process scan
  - adr-014: capability packages — AgentDefinition→vapora-shared,
    DashMap shard-before-await constraint
  - adr-015: Merkle audit trail — SHA-256 hash chain, rejected HMAC
  - adr-016: agent hot-reload — stable_id=role, learning_profiles survive
    drain, BudgetManager excluded from reload scope

  landing page:
  - 2 new feature boxes: VCS-Agnostic Worktree (jj/git), Ontology Protocol
  - KG box: 20→28 tests, HNSW+BM25+RRF description
  - Agents box: 71→82 tests, hot-reload + stable_id
  - tech stack: Rust 21→23 crates, added jj, Radicle, ontoref badges
  - status badge: 620→691 tests
2026-04-07 21:06:48 +01:00

87 lines
5.1 KiB
Text

let d = import "adr-defaults.ncl" in
d.make_adr {
id = "adr-004",
title = "SurrealDB as the Sole Persistence Layer",
status = 'Accepted,
date = "2024-11-01",
context = "Vapora requires relational storage (projects, tasks), graph traversal (agent relationships, knowledge graph), and document storage (execution history, LLM outputs) — typically requiring three separate databases. As of 2026-03-27, surrealdb v3 is in use (the markdown ADR references 2.3, which is stale). The workspace root Cargo.toml pins `surrealdb = { version = \"3\", features = [\"protocol-ws\", \"rustls\"] }`.",
decision = "SurrealDB is the only database engine in vapora. No PostgreSQL, no SQLite, no MongoDB, no Redis. All persistence goes through the SurrealDB client. Multi-tenancy is enforced via SurrealDB scopes — no application-layer tenant filtering may substitute for scope enforcement.",
rationale = [
{
claim = "Single database eliminates cross-DB transaction complexity",
detail = "If knowledge graph nodes and project tasks were in separate databases, any operation touching both (e.g. recording which task produced which KG node) would require distributed transactions or eventual consistency. SurrealDB handles both in one query.",
},
{
claim = "SurrealDB scopes provide database-level tenant isolation",
detail = "A query executed in scope workspace:X cannot access records in workspace:Y, regardless of application code. This means a bug in the service layer cannot cause a tenant data leak — the database rejects the query.",
},
{
claim = "SurrealQL graph traversal replaces a separate graph database",
detail = "Knowledge graph learning curves, agent relationship traversal, and causal execution chains are expressed as SurrealQL graph queries (->relation->). A separate Neo4j instance would require replication, synchronization, and schema management across two stores.",
},
],
consequences = {
positive = [
"Knowledge graph, task management, and audit trail share a single connection pool",
"SurrealDB scope enforcement is the primary multi-tenancy guarantee",
"All services use parameterized SurrealQL queries — no raw string interpolation into queries",
"WebSocket protocol-ws enables real-time subscriptions from vapora-backend",
],
negative = [
"SurrealDB v3 is a major-version breaking change from v2 — all services must coordinate upgrade simultaneously",
"SurrealDB lacks mature migration tooling compared to PostgreSQL — migrations are manual .surql files",
"No read replica support in current deployment (single instance handles all reads and writes)",
],
},
alternatives_considered = [
{
option = "PostgreSQL + Neo4j",
why_rejected = "Two database engines double operational burden. Cross-DB transactions require two-phase commit or saga patterns. Schema synchronization across both stores is error-prone.",
},
{
option = "MongoDB",
why_rejected = "No native graph traversal. Application code would need to implement graph traversal, duplicating logic that SurrealQL expresses natively. No built-in multi-tenancy scopes.",
},
],
constraints = [
{
id = "no-other-database-engines",
claim = "No crate in the workspace may import postgresql, mongodb, sqlite, or redis client crates",
scope = "vapora (all crates)",
severity = 'Hard,
check = { tag = 'Cargo, crate = "vapora-backend", forbidden_deps = ["sqlx", "sea-orm", "diesel", "mongodb", "redis"] },
rationale = "Adding a second database engine introduces consistency gaps, split connection pools, and dual migration paths.",
},
{
id = "surreal-scopes-for-tenancy",
claim = "All multi-tenant queries must use SurrealDB scopes — no application-layer tenant_id filtering may be the sole isolation mechanism",
scope = "vapora-backend/src/services/",
severity = 'Hard,
check = { tag = 'Grep, pattern = "scope|NS|DB", paths = ["crates/vapora-backend/src/services/"], must_be_empty = false },
rationale = "Application-layer filtering is the second defense layer, not the primary one. A service bug that drops a WHERE clause cannot bypass the DB scope.",
},
{
id = "parameterized-queries-only",
claim = "All SurrealQL queries must use parameterized bindings via .bind() — no string interpolation into query text",
scope = "vapora (all crates using surrealdb)",
severity = 'Hard,
check = { tag = 'Grep, pattern = "format!.*SELECT|INSERT|UPDATE|DELETE", paths = ["crates/"], must_be_empty = true },
rationale = "String-interpolated queries are vulnerable to SurrealQL injection, especially when tenant IDs or user-supplied values appear in query conditions.",
},
],
related_adrs = ["adr-002", "adr-011"],
ontology_check = {
decision_string = "surrealdb v3 is the sole database; scopes enforce multi-tenancy at DB level; no other DB engines; parameterized queries only",
invariants_at_risk = ["surreal-persistence", "multi-tenant-isolation"],
verdict = 'Safe,
},
}