Add a pluggable storage backend trait to Vestige, enabling PostgreSQL (+pgvector) as an alternative to the current SQLite+FTS5+USearch stack. Simultaneously add HTTP MCP transport with API key authentication to enable centralized/remote deployment.
This keeps the existing local-first SQLite mode fully intact while opening up a server deployment model.
## Motivation
Vestige currently runs as a local process per machine (MCP via stdio, SQLite in `~/.vestige/`). This works great for single-machine use but doesn't support:
- **Multi-machine access**: Same memory brain from laptop, desktop, and server
- **Multi-agent access**: Multiple AI clients hitting one memory store concurrently
SQLite's single-writer model and lack of native network protocol make it unsuitable as a centralized server. PostgreSQL is a natural fit: built-in concurrency (MVCC), authentication, replication, and with `pgvector` + built-in FTS it collapses three separate storage layers into one.
## Design
### Storage Trait
The core abstraction. All 29 cognitive modules interact with storage exclusively through this trait (or a small family of traits).
-`trait_variant::make` generates a `MemoryStore` trait alias with `Send`-bound futures, allowing `Arc<dyn MemoryStore>` for runtime backend selection. `LocalMemoryStore` is the base (usable in single-threaded contexts), `MemoryStore` is the Send variant for Axum/tokio.
-`embedding: Option<Vec<f32>>` — dimensions determined at runtime by the configured fastembed model. The backend stores whatever it gets.
- The trait is intentionally flat. The cognitive modules (FSRS-6, spreading activation, synaptic tagging, prediction error gating, etc.) sit *above* this trait and don't need to know about the backend.
-`search()` does hybrid RRF fusion at the backend level — both SQLite and Postgres implementations handle this internally.
### Backend: SQLite (existing, refactored)
Wraps the current implementation behind the trait:
```
SqliteMemoryStore
├── rusqlite connection pool (r2d2 or deadpool)
├── FTS5 virtual table (keyword search)
├── USearch HNSW index (vector search, behind RwLock)
└── WAL mode + busy timeout for concurrent readers
```
No behavioral changes — just the trait boundary.
### Backend: PostgreSQL (new)
```
PgMemoryStore
├── sqlx::PgPool (connection pool, compile-time checked queries)
On init, the backend reads the embedding dimension from the first stored vector (or from config) and validates consistency.
For pgvector: you can either create the column as `vector(768)` (fixed, faster) or unconstrained `vector` (flexible, slightly slower). Recommendation: fixed dimension derived from config, with a migration path if the model changes.
### Emergent Domain Model
Instead of user-defined tenants, domains emerge automatically from the data via clustering. The user never has to decide where a memory belongs — the system figures it out.
#### Pipeline
```
Phase 1: Accumulate (cold start, 0 → N memories)
│ All memories stored with domains = [] (unclassified)
│ No classification overhead, just embed and store
│ Threshold N is configurable, default ~150 memories
│
Phase 2: Discover (triggered once at threshold, or manually)
│ Run HDBSCAN on all embeddings:
│ - min_cluster_size: ~10
│ - min_samples: ~5
│ - No eps parameter needed (unlike DBSCAN)
│ - Automatically determines number of clusters
│ - Handles variable-density clusters
│ - Border points between clusters flagged naturally
│
│ For each cluster, extract:
│ - Centroid (mean embedding)
│ - Top terms (TF-IDF or frequency over cluster members)
/// Soft-assign all existing memories after discovery or re-clustering.
/// Returns number of memories whose domains changed.
pub async fn reassign_all(
&self,
store: &dyn MemoryStore,
domains: &[Domain],
) -> Result<usize> {
// Load all memories, re-score, update domains + domain_scores
// Batched to avoid loading everything into memory at once
todo!()
}
}
```
**Key distinction from the previous design:** there's no "closest wins" or "margin" logic. Every domain gets a score, and *all* domains above threshold are assigned. A memory about "deploying zinit containers via BGP-routed network" might score 0.78 on "dev" and 0.72 on "infra" — it gets both. A memory about "solar panel output today" scores 0.85 on "home" and 0.31 on everything else — it only gets "home".
The raw `domain_scores` are always stored, so you (or the dashboard) can see *why* a memory was classified the way it was, and the threshold can be adjusted retroactively without re-computing embeddings.
#### Search Behavior
- **Default (no domain filter)**: searches all memories across all domains
- **Domain-scoped**: `domains: Some(vec!["dev"])` — only memories tagged with `dev`
- **Multi-domain**: `domains: Some(vec!["dev", "infra"])` — memories in either
- **MCP clients can set `X-Vestige-Domain` header** for default scoping, but the system works fine without it
#### HDBSCAN Implementation
HDBSCAN (Hierarchical DBSCAN) over the embedding vectors. Advantages over plain DBSCAN:
- **No `eps` parameter** — the hardest thing to tune in DBSCAN. HDBSCAN determines density thresholds from the data hierarchy.
- **Variable-density clusters** — a tight cluster of networking memories and a spread-out cluster of personal memories are both detected correctly.
- **Border points** — memories between clusters are identified as low-confidence members, which aligns perfectly with soft assignment.
Implementation: the `hdbscan` crate in Rust. Load all embeddings into memory (at 768d × f32 × 10k memories ≈ 30MB — fine), cluster, compute centroids, soft-assign all memories against the centroids.
let centroids = clusterer.calc_centers(Center::Centroid, &labels).unwrap();
// Group indices by label, ignoring noise (-1)
let mut clusters: HashMap<i32,Vec<usize>> = HashMap::new();
for (i, &label) in labels.iter().enumerate() {
if label >= 0 {
clusters.entry(label).or_default().push(i);
}
}
(clusters.into_values().collect(), centroids)
}
```
After HDBSCAN produces hard clusters, the soft-assignment pass (Phase 3) immediately re-scores all memories — including the ones HDBSCAN assigned — against the computed centroids. So HDBSCAN's hard labels are only used to *define* the centroids. The actual domain assignments always come from the continuous similarity scores.
This works identically for both SQLite and Postgres backends — clustering runs in Rust application code, results are written back to the storage layer.
Some(k) if verify_api_key(store.as_ref(), k).await => {
Ok(next.run(request).await)
}
_ => Err(StatusCode::UNAUTHORIZED),
}
}
```
#### Client Configuration
```json
// Claude Desktop / Claude Code — single key, all domains
{
"mcpServers": {
"vestige": {
"url": "http://vestige.local:3927/mcp",
"headers": {
"Authorization": "Bearer vst_a1b2c3..."
}
}
}
}
```
No domain header needed — searches all domains by default. The MCP tools include an optional `domain` parameter for scoped queries if the LLM or user wants to narrow down.
Alternatively, scope a connection to a specific domain:
```json
// Domain-scoped connection (e.g., for a home automation agent)
1.**Trait granularity**: One big `MemoryStore` trait or split into `MemoryStore + SchedulingStore + GraphStore + DomainStore`? Splitting is cleaner but means more `dyn` parameters threading through handlers.
2.**Embedding on insert**: Should the storage backend call fastembed, or should the caller always provide the embedding? Current design says caller provides it, keeping the backend pure storage. But this means every client needs fastembed locally even if the DB is remote. For the server model, having the server compute embeddings makes more sense.
3.**pgvector dimension**: Fixed (e.g., `vector(768)`) or unconstrained (`vector`)? Fixed is faster for HNSW but requires migration if model changes.
4.**Sync conflict resolution for federation**: LWW per-UUID is simple but lossy. CRDTs would be more correct but massively more complex. For FSRS state specifically, merging review event logs would be ideal.
5.**Dashboard auth**: The 3D dashboard currently runs unauthenticated on localhost. With remote access, it needs the same auth. Should it use the same API keys or have a separate session/cookie mechanism?
6.**HDBSCAN `min_cluster_size`**: The main tuning knob. Too small → noisy micro-clusters. Too large → distinct topics get merged. Default of 10 should work for most cases, but may need a manual override or auto-sweep (run with several values, pick the one with best silhouette score).
7.**Domain drift**: Over time, the character of a domain changes. How aggressively should re-clustering reshape existing domains? Conservative (only propose splits/merges, never auto-apply) vs. aggressive (auto-reassign memories whose scores drifted below threshold)?
8.**Spreading activation across domains**: When searching within a single domain, should graph edges that cross into other domains be followed? Probably yes for recall quality, but with decaying weight as you cross boundaries.
9.**Threshold tuning**: The `assign_threshold` (0.65 default) determines how many memories are multi-domain vs single-domain vs unclassified. Too low → everything is multi-domain (useless). Too high → too many unclassified. Could be auto-tuned per dataset by targeting a specific unclassified ratio (e.g., "keep fewer than 10% unclassified").