Pluggable storage backend, network access, and emergent domain classification. Introduces MemoryStore + Embedder traits, PgMemoryStore alongside SqliteMemoryStore, HTTP MCP + API key auth, and HDBSCAN-based domain clustering. Phase 5 federation deferred to a follow-up ADR. - docs/adr/0001-pluggable-storage-and-network-access.md -- Accepted - docs/plans/0001-phase-1-storage-trait-extraction.md - docs/plans/0002-phase-2-postgres-backend.md - docs/plans/0003-phase-3-network-access.md - docs/plans/0004-phase-4-emergent-domain-classification.md - docs/prd/001-getting-centralized-vestige.md -- source RFC
14 KiB
ADR 0001: Pluggable Storage Backend, Network Access, and Emergent Domains
Status: Accepted Date: 2026-04-21 Related: docs/prd/001-getting-centralized-vestige.md
Context
Vestige v2.x runs as a per-machine local process: stdio MCP transport, SQLite +
FTS5 + USearch HNSW in ~/.vestige/, fastembed locally for embeddings. This is
ideal for single-machine single-agent use but blocks three real needs:
- Multi-machine access -- same memory brain from laptop, desktop, server
- Multi-agent access -- multiple AI clients against one store concurrently
- Future federation -- syncing memory between decentralized nodes (MOS / Threefold grid)
SQLite's single-writer model and lack of a native network protocol make it unsuitable as a centralized server. PostgreSQL + pgvector collapses our three storage layers (SQLite, FTS5, USearch) into one engine with MVCC concurrency, auth, and replication.
Separately, Vestige today has no notion of domain or project scope -- all memories share one namespace. For a multi-machine brain, users want soft topical boundaries ("dev", "infra", "home") without manual tenanting. HDBSCAN clustering on embeddings produces these boundaries from the data itself.
The PRD at docs/prd/001-getting-centralized-vestige.md sketches the full design.
This ADR records the architectural decisions and resolves the open questions from
that document.
Decision
Introduce two new trait boundaries, a network transport layer, and a domain classification module. All four changes ship in parallel phases.
Trait boundaries:
MemoryStore-- single trait covering CRUD, hybrid search, FSRS scheduling, graph edges, and domains. One big trait, not four.Embedder-- separate trait for text-to-vector encoding. Storage never calls fastembed directly. Callers (cognitive engine locally, HTTP server remotely) compute embeddings and pass them into the store.
Backends:
SqliteMemoryStore-- existing code refactored behind the trait, no behavior change.PgMemoryStore-- new, using sqlx + pgvector + tsvector. Selectable at runtime viavestige.toml.
Network:
- MCP over Streamable HTTP on the existing Axum server.
- API key auth middleware (blake3-hashed, stored in
api_keystable). - Dashboard uses the same API keys for login, then signed session cookies for subsequent requests.
Domain classification:
- HDBSCAN clustering over embeddings to discover domains automatically.
- Soft multi-domain assignment -- raw similarity scores stored per memory, every domain above a threshold is assigned.
- Conservative drift handling -- propose splits/merges, never auto-apply.
Architecture Overview
Component Breakdown
-
Embeddertrait (new modulecrates/vestige-core/src/embedder/)async fn embed(&self, text: &str) -> Result<Vec<f32>>fn model_name(&self) -> &strfn dimension(&self) -> usize- Impls:
FastembedEmbedder(local ONNX, today), futureJinaEmbedder,OpenAiEmbedder, etc. - Stays pluggable forever -- no lock-in to fastembed or to nomic-embed-text.
-
MemoryStoretrait (new modulecrates/vestige-core/src/storage/trait.rs)- One trait, ~25 methods across CRUD, search, FSRS, graph, domain sections.
- Uses
trait_variant::maketo generate aSend-bound variant forArc<dyn MemoryStore>in Axum/tokio contexts. - The 29 cognitive modules operate exclusively through this trait. No direct SQLite or Postgres access from the modules.
-
SqliteMemoryStore(refactor of existingcrates/vestige-core/src/storage/sqlite.rs)- Existing rusqlite + FTS5 + USearch code, wrapped behind the trait.
- Add
domains TEXT[]equivalent (JSON-encoded array column in SQLite). - Add
domain_scoresJSON column. - No behavioral change for current users.
-
PgMemoryStore(newcrates/vestige-core/src/storage/postgres.rs)sqlx::PgPoolwith compile-time checked queries.- pgvector HNSW index for vector search, tsvector + GIN for FTS.
- Native array columns for
domains, JSONB fordomain_scoresandmetadata. - Hybrid search via RRF (Reciprocal Rank Fusion) in a single SQL query.
-
Model registry
- Per-database table
embedding_modelwith(name, dimension, hash, created_at). - Both backends refuse writes from an embedder whose signature doesn't match the registered row.
- Model swap =
vestige migrate --reembed --model=<new>, O(n) cost, explicit.
- Per-database table
-
DomainClassifiercognitive module (newcrates/vestige-core/src/neuroscience/domain_classifier.rs)- Owns the HDBSCAN discovery pass (using the
hdbscancrate). - Computes soft-assignment scores for every memory against every centroid.
- Stores raw
domain_scores: HashMap<String, f64>per memory; thresholds into thedomainsarray usingassign_threshold(default 0.65). - Runs discovery on demand (
vestige domains discover) or during dream consolidation passes.
- Owns the HDBSCAN discovery pass (using the
-
HTTP MCP transport (extension of existing Axum server in
crates/vestige-mcp/src/)- New route
POST /mcpfor Streamable HTTP JSON-RPC. - New route
GET /mcpfor SSE (for long-running operations). - REST API under
/api/v1/for direct HTTP clients (non-MCP integrations). - Auth middleware validates
Authorization: Bearer ...orX-API-Key, plus signed session cookies for dashboard.
- New route
-
Key management (new
crates/vestige-mcp/src/auth/)api_keystable -- blake3-hashed keys, scopes, optional domain filter, last-used timestamp.- CLI:
vestige keys create|list|revoke.
-
FSRS review event log (future-proofing for federation)
- New table
review_events-- append-only(memory_id, timestamp, rating, prior_state, new_state). - Current
schedulingtable becomes a materialized view over the event log (reconstructible from events). - Phase 5 federation merges event logs, not derived state. Zero cost today, avoids lock-in tomorrow.
- New table
Data Flow
Local mode (stdio MCP, unchanged UX):
stdio client -> McpServer -> CognitiveEngine -> FastembedEmbedder -> MemoryStore (SQLite)
Server mode (HTTP MCP, new):
Remote client -> Axum HTTP -> auth middleware -> CognitiveEngine
-> FastembedEmbedder (server-side) -> MemoryStore (Postgres)
The cognitive engine is backend-agnostic. The embedder and the store are both
swappable. The 7-stage search pipeline (overfetch -> cross-encoder rerank ->
temporal -> accessibility -> context match -> competition -> spreading activation)
sits above the MemoryStore trait and works identically against either backend.
Orthogonality of HDBSCAN and Reranking
HDBSCAN and the cross-encoder reranker solve different problems and both stay:
- HDBSCAN discovers domains by clustering embeddings. Runs once per discovery pass. Produces centroids. Used to filter search candidates, not to rank them.
- Cross-encoder reranker (Jina Reranker v1 Turbo) scores query-document pairs at search time. Runs on every search. Produces ranked results.
Domain membership is a filter applied before or during overfetch; reranking runs on whatever candidate set survives the filter.
Alternatives Considered
| Alternative | Pros | Cons | Why Not |
|---|---|---|---|
Split into 4 traits (MemoryStore + SchedulingStore + GraphStore + DomainStore) |
Cleaner interface segregation | Every module holds 4 trait objects, coordinates transactions across them | One trait is fine in Rust; extract sub-traits later if a genuine need appears |
| Embedding computed inside the backend | Simpler call sites for callers | Backend becomes aware of embedding models; can't support remote clients without local fastembed | Keep storage pure; separate Embedder trait handles pluggability |
Unconstrained pgvector vector (no dimension) |
Flexible for model swaps | HNSW still needs fixed dims at index creation; hides a meaningful migration as "silent" | Fixed dimension per install, explicit --reembed migration |
| Dashboard separate auth (cookies only, no keys) | Simpler dashboard UX | Two auth systems to maintain | Shared API keys with session cookie layer on top |
Auto-tuned assign_threshold targeting an unclassified ratio |
Adapts to corpus | Hard to debug ("why did this memory change domain?"); magical | Static 0.65 default, config-tunable, dashboard shows domain_scores for manual retuning |
| Aggressive drift (auto-reassign memories whose scores drifted) | Always up-to-date domains | Breaks user muscle memory; silent reshuffling | Conservative: always propose, user accepts |
| CRDTs for federation state | Mathematically clean merges | Massive complexity, performance cost, overkill | Defer; design FSRS as event log now so any future sync model works |
Consequences
Positive
- Single memory brain accessible from every machine.
- Multi-agent concurrent access via Postgres MVCC.
- Natural topical scoping emerges from data, not manual tenants.
- Future embedding model swaps are a config + migration, not a rewrite.
- Federation has a clean on-ramp (event log merge) without committing now.
- The
Embedder/MemoryStoresplit unlocks other storage backends later (Redis, Qdrant, Iroh-backed blob store, etc.) with minimal work.
Negative
- Operating a Postgres instance is more work than managing a SQLite file.
- Users who stay on SQLite gain nothing from this ADR (but lose nothing either).
- Migration (
vestige migrate --from sqlite --to postgres) is a sensitive operation for users with months of memories -- needs strong testing. - HDBSCAN + re-soft-assignment runs in O(n) over all embeddings. At 100k+ memories this starts to matter; manageable but not free.
Risks
- Trait abstraction leaks: a cognitive module might need backend-specific behavior (e.g., Postgres triggers for tsvector). Mitigation: keep such logic inside the backend impl; the trait stays pure. Escalation: if a module genuinely cannot express what it needs through the trait, the trait grows, not the module bypasses.
- Embedding model drift: users on older fastembed versions silently producing slightly different vectors after a fastembed upgrade. Mitigation: model hash in the registry, refuse mismatched writes, surface a clear error.
- Auth misconfiguration: a user binds to
0.0.0.0without settingauth.enabled = true. Mitigation: refuse to start with non-localhost bind and auth disabled. Hard error, not a warning. - Re-clustering feedback loop: dream consolidation proposes re-clusters, which the user accepts, which changes classifications, which affects future retrievals, which affect future dreams. Mitigation: cap re-cluster frequency (every 5th dream by default), require explicit user acceptance of proposals.
- Cross-domain spreading activation weight (0.5 default): arbitrary choice; could be too aggressive or too lax. Mitigation: config-tunable; instrument retrieval quality metrics in the dashboard so the user sees impact.
Resolved Decisions (from Q&A)
| # | Question | Resolution |
|---|---|---|
| 1 | Trait granularity | Single MemoryStore trait |
| 2 | Embedding on insert | Caller provides; separate Embedder trait for pluggability |
| 3 | pgvector dimension | Fixed per install, derived from Embedder::dimension() at schema init |
| 4 | Federation sync | Defer algorithm; store FSRS reviews as append-only event log now |
| 5 | Dashboard auth | Shared API keys + signed session cookie |
| 6 | HDBSCAN min_cluster_size |
Default 10; user reruns with --min-cluster-size N; no auto-sweep |
| 7 | Domain drift | Conservative -- always propose splits/merges, never auto-apply |
| 8 | Cross-domain spreading activation | Follow with decay factor 0.5 (tunable) |
| 9 | Assignment threshold | Static 0.65 default, config-tunable, raw domain_scores stored for introspection |
Implementation Plan
Five phases, each independently shippable.
Phase 1: Storage trait extraction
- Define
MemoryStoreandEmbeddertraits investige-core. - Refactor
SqliteMemoryStoreto implementMemoryStore; no behavior change. - Refactor
FastembedEmbedderto implementEmbedder. - Add
embedding_modelregistry table; enforce consistency on write. - Add
domains TEXT[]-equivalent anddomain_scoresJSON columns to SQLite (empty for all existing rows). - Convert all 29 cognitive modules to operate via the traits.
- Acceptance: existing test suite passes unchanged. Zero warnings.
Phase 2: PostgreSQL backend
PgMemoryStorewith sqlx, pgvector, tsvector.- sqlx migrations (
crates/vestige-core/migrations/postgres/). - Backend selection via
vestige.toml[storage]section. vestige migrate --from sqlite --to postgrescommand.vestige migrate --reembedcommand for model swaps.- Acceptance: full test suite runs green against Postgres with a testcontainer.
Phase 3: Network access
- Streamable HTTP MCP route on Axum (
POST /mcp,GET /mcpfor SSE). - REST API under
/api/v1/. - API key table + blake3 hashing +
vestige keys create|list|revoke. - Auth middleware (Bearer, X-API-Key, session cookie).
- Refuse non-localhost bind without auth enabled.
- Acceptance: MCP client over HTTP works from a second machine; dashboard login flow works; unauth requests return 401.
Phase 4: Emergent domain classification
DomainClassifiermodule using thehdbscancrate.vestige domains discover|list|rename|mergeCLI.- Automatic soft-assignment pipeline (compute
domain_scoreson ingest, threshold intodomains). - Re-cluster every Nth dream consolidation (default 5); surface proposals in the dashboard.
- Context signals (git repo, IDE) as soft priors on classification.
- Cross-domain spreading activation with 0.5 decay.
- Acceptance: on a corpus of 500+ mixed memories, discover produces sensible clusters; search scoped to a domain returns tightly relevant results.
Phase 5: Federation (future, explicitly out of scope for this ADR's
acceptance)
- Node discovery (Mycelium / mDNS).
- Memory sync protocol over append-only review events and LWW-per-UUID for memory records.
- Explicit follow-up ADR before any code.
Open Questions
None at ADR acceptance time. Follow-up items that are implementation choices, not architectural:
- Precise cross-domain decay weight (start at 0.5, instrument, tune)
- Dashboard histogram of
domain_scores(UX design detail) - Whether to gate Postgres behind a Cargo feature flag (
postgres-backend) or always compile it in (lean toward feature flag to keep SQLite-only builds small)