Skip to content

memini-ai-dev v1.0.0 — Embedded pgembed Architecture Design

Author: boomerang-architect (read-only design review) Date: 2026-07-15 Status: ✅ Approved by user. Q1-Q7 decisions baked in. Ready for implementation. Target branch: feature/embedded-pgembed-v1 (to be created from main at 7eed224 = v0.8.2)


Decision Log (Q1–Q7)

# Question Decision
Q1 Data directory location B: XDG ~/.local/share/memini-ai/pgembed/data. server.json at ~/.memini-ai/pgembed/server.json (memini namespace for state).
Q2 Server liveness protocol Cooperative heartbeat (1s client ping, 2s timeout, 5s drain grace, distributed sweep). See Heartbeat Design.
Q3 Team server write-through C: Async dual-write, embedded is canonical, team is fire-and-forget. See Team Write-Through.
Q4 Backwards compat (MEMINI_DB_URL) Breaking change. v0.8.2 users with MEMINI_DB_URL set get pgembed mode by default, must set MEMINI_VECTOR_BACKEND=postgres-external. One-time warning on startup. Optional memini-ai migrate CLI to copy data.
Q5 Delete dead branch LEAVE feature/postgres-in-process-research alone. (The handoff agent's claim that main was overwritten was false; user verified.)
Q6 Fusion default mode fusion_mode="none" for pip install memini-ai (default). fusion_mode="rrf" is set by user when installing with pip install memini-ai[team]. See Team Install Path.
Q7 Vectorscale in embedded A: Use vectorscale (DiskANN) in embedded mode. _detect_vectorscale() at database.py:239 already handles this.

Table of Contents

  1. pgembed Smoke Test — VERIFIED
  2. Driver-Pattern Interface Spec
  3. EmbeddedPGDriver State Machine + Heartbeat Protocol
  4. Multi-Process Attach Algorithm
  5. Config Field Design
  6. RRF Fusion Across Embedded + Team
  7. Failure-Mode Table
  8. Test Strategy
  9. Implementation Plan (Task List)
  10. Risks

1. pgembed Smoke Test — VERIFIED

1.1 Environment

Item Value
Python 3.13.13 (uv venv, .venv/bin/python → cpython-3.13.13-linux-x86_64-gnu)
OS CachyOS Linux, x86_64
pgembed version 0.2.0 (wheel: 28.6MB, installed via uv pip install pgembed)
Dependencies fasteners==0.20, psutil==7.2.2 (auto-installed)
Postgres version 17.9 (PostgreSQL 17.9 on x86_64-pc-linux-gnu, compiled by gcc (GCC) 14.2.1)
Connection method Unix socket (not TCP) — postgresql://postgres:@/postgres?host=/tmp/pgembed-smoke

1.2 Extensions Available (Exact Output)

Verified via asyncpg query against a live pgembed server:

Total extensions: 5
  pg_search                      0.21.14    (not installed)
  pg_textsearch                  0.6.1      (not installed)
  plpgsql                        1.0        1.0
  vector                         0.8.2      (not installed)
  vectorscale                    0.9.0      (not installed)

Critical finding: The handoff doc (section 8) claimed vectorscale was "listed in EXTENSION_NAMES but marked unavailable." This is wrong. vectorscale 0.9.0 IS available in pg_available_extensions and CREATE EXTENSION vectorscale succeeds. Both embedded and team users can get StreamingDiskANN indexes (Q7 decision).

1.3 Port Behavior

pgembed uses Unix domain sockets by default, not TCP ports. URI: postgresql://postgres:@/postgres?host=<data_dir>. No port conflicts, no firewall issues.

1.4 Multi-Process Behavior (Verified)

Test: Start server from Process A, then start a second get_server() on the same data dir from Process B. Same URI returned, only one Postgres process running.

1.5 pgembed API Surface

def get_server(
    pgdata: Union[Path, str],
    cleanup_mode: Optional[str] = 'stop'  # 'stop' = stop on cleanup, None = keep running, 'delete' = stop + delete
) -> PostgresServer: ...

class PostgresServer:
    def get_uri(self) -> str: ...
    def cleanup(self) -> None: ...  # segfaults in psql subprocess on Python 3.13, use asyncpg instead

Note: server.psql() segfaults on this machine (Python 3.13.13, SIGSEGV in bundled psql binary). We use asyncpg connections instead.


2. Driver-Pattern Interface Spec

2.1 Design Goal

The 1820-LOC PostgresDatabase class (at src/memini_ai/postgres/database.py) must work against either an embedded pgembed server or an external Docker Postgres server, with zero changes to its 19 ABC method implementations. The driver abstracts the connection acquisition + lifecycle management.

2.2 Proposed Interface

# src/memini_ai/postgres/driver.py (NEW FILE, ~470 LOC)

from __future__ import annotations

from typing import Protocol, runtime_checkable


@runtime_checkable
class DatabaseDriver(Protocol):
    """Minimal interface for backend-specific connection lifecycle."""

    async def get_uri(self) -> str:
        """Return a PostgreSQL connection URI ready for asyncpg.

        For pgembed: returns the Unix socket URI from get_server().
        For external: returns the configured MEMINI_DB_URL.

        Must be callable before initialize() — the URI is needed
        to create the asyncpg pool at PostgresDatabase.initialize():167.
        Idempotent.
        """
        ...

    async def initialize(self) -> None:
        """Perform backend-specific initialization AFTER the pool exists.

        For pgembed: starts the heartbeat + sweep background task.
        For external: no-op.
        Idempotent.
        """
        ...

    async def shutdown(self) -> None:
        """Gracefully release backend resources.

        For pgembed: stops the heartbeat task, removes this client from
        the clients map, initiates shutdown if last client. Does NOT
        stop the server unless this is the last client AND the sweep
        would also initiate shutdown.
        For external: no-op.
        """
        ...

    def is_ready(self) -> bool:
        """Synchronous check: is the backend accepting connections?

        For pgembed: checks postmaster PID is alive.
        For external: always returns True.
        """
        ...

2.3 Why the Driver Does NOT Own the Pool

The driver hands back a URI. PostgresDatabase continues to own the asyncpg pool (created at database.py:167). This is the right split because: 1. Pool lifecycle is coupled to query patterns (min_size, max_size, SSL, pgvector codec) 2. The 19 ABC methods all call await self._get_pool() (line 278) 3. Minimal surface — 4 methods captures the entire backend difference

2.4 Interaction with Existing Lifecycle

create_database(config)
  → driver = EmbeddedPGDriver(config.pgembed_data_dir)  # or ExternalPGDriver(config.db_url)
  → PostgresDatabase(driver=driver)
  → (lazy) first query calls initialize()
    → uri = await driver.get_uri()
    → asyncpg.create_pool(uri)                          # line 167 (unchanged)
    → await driver.initialize()                         # NEW: backend-specific init
    → _ensure_schema()                                  # line 174 (unchanged)

Total change to PostgresDatabase: ~15 lines modified, 0 lines deleted from the 19 ABC methods.

2.5 ExternalPGDriver (Trivial)

class ExternalPGDriver:
    """Driver for external (Docker/team) Postgres — preserves v0.8.2 behavior."""

    def __init__(self, db_url: str) -> None:
        self._db_url = db_url

    async def get_uri(self) -> str:
        return self._db_url

    async def initialize(self) -> None:
        pass

    async def shutdown(self) -> None:
        pass

    def is_ready(self) -> bool:
        return True

3. EmbeddedPGDriver State Machine + Heartbeat Protocol

This section replaces the original refcount-based design. The user rejected "auto-stop after N idle minutes" in favor of a cooperative heartbeat protocol.

3.1 The server.json Schema

{
  "version": "1.0.0",
  "state": "running",
  "pid": 1162268,
  "uri": "postgresql://postgres:@/postgres?host=/home/jcharles/.local/share/memini-ai/pgembed/data",
  "data_dir": "/home/jcharles/.local/share/memini-ai/pgembed/data",
  "server_started_at": "2026-07-15T12:00:00.000Z",
  "heartbeat_interval_s": 60,
  "heartbeat_timeout_s": 120,
  "grace_margin_s": 10,
  "shutdown_initiated_by": null,
  "shutdown_initiated_at": null,
  "clients": {
    "a1b2c3d4-e5f6-7890-abcd-ef1234567890": {
      "pid": 1162268,
      "last_heartbeat": "2026-07-15T12:05:30.000Z",
      "role": "primary"
    },
    "f9e8d7c6-b5a4-3210-fedc-ba0987654321": {
      "pid": 1163456,
      "last_heartbeat": "2026-07-15T12:05:28.000Z",
      "role": "passive"
    }
  }
}

3.2 Field Reference

Field Type Written by Purpose
version string First client Schema version
state enum All clients starting, running, draining, stopped, dead
pid int First client Postmaster PID (for os.kill(pid, 0))
uri string First client Connection URI
data_dir string First client Path to pgembed data dir
server_started_at ISO 8601 First client, never overwritten Observability: "data dir running on and off for 23 days"
heartbeat_interval_s int First client (config) How often clients ping (default 60)
heartbeat_timeout_s int First client (config) Stale threshold (default 120)
grace_margin_s int First client (config) Buffer for clock skew (default 10)
shutdown_initiated_by string or null Sweeping client Election token
shutdown_initiated_at ISO 8601 or null Sweeping client When drain started
clients map[string→ClientInfo] Each client writes its own entry Per-client heartbeat map

3.3 ClientInfo Sub-object

Field Type Purpose
pid int OS PID of this client process
last_heartbeat ISO 8601 Last time this client wrote a heartbeat
role string "primary" (has active pool) or "passive" (attached, no queries yet)

3.4 State Diagram

                         ┌──────────┐
            create()     │          │  get_server() succeeds
         ──────────────►│ STARTING │──────────────────────┐
                         │          │                      │
                         └────┬─────┘                      │
                              │                            ▼
                              │ get_server() fails    ┌─────────┐
                              └─────────────────────►│         │
                                                     │ RUNNING │◄──── attach() (existing server)
                                                     │         │
                                                     └────┬────┘
              ┌───────────────────────────────────────────┼──────────────────────────┐
              │                                           │                          │
              ▼                                           ▼                          ▼
        ┌─────────┐                                ┌──────────┐               ┌─────────┐
        │         │  explicit shutdown             │          │  crash/      │         │
        │ STOPPED │◄───────────────────────────────│ DRAINING │  kill -9     │  DEAD   │
        │         │  (last client leaves)          │          │─────────────►│         │
        └─────────┘                                └──────────┘               └─────────┘
              │                                           │
              │                                           │ new client heartbeats
              │                                           │ (cancels drain)
              ▼                                           ▼
        (server down,                               ┌─────────┐
         data dir intact)                           │ RUNNING │
                                                    └─────────┘

State triggers:

State Entry Exit
STOPPED Initial; sweep sees all clients stale; explicit memini-ai stop get_uri() reads state, finds stopped, calls get_server() → STARTING
STARTING get_server() called, no existing RUNNING server found Postgres accepts connections → RUNNING; failure → DEAD
RUNNING Postgres ready; new client attaches via heartbeat Last client leaves + sweep sees all stale → DRAINING; process crash → DEAD
DRAINING Last client gone OR explicit shutdown Grace expires → STOPPED; new client heartbeats during grace → back to RUNNING
DEAD Process crash, kill -9, data dir corruption, init failure Next get_uri() detects DEAD, cleans up, restarts from STOPPED → STARTING

3.5 Client-Side Heartbeat Task

class EmbeddedPGDriver:
    def __init__(self, data_dir: str | Path) -> None:
        self._client_id = str(uuid4())          # stable per process
        self._heartbeat_task: asyncio.Task | None = None
        self._heartbeat_stop: asyncio.Event = asyncio.Event()
        self._healthy: bool = True
        self._role: str = "passive"

    async def _heartbeat_loop(self) -> None:
        interval = self._read_state().get("heartbeat_interval_s", 60)
        consecutive_failures = 0

        while not self._heartbeat_stop.is_set():
            try:
                await asyncio.sleep(interval)
            except asyncio.CancelledError:
                break
            if self._heartbeat_stop.is_set():
                break

            try:
                self._write_heartbeat()
                consecutive_failures = 0
            except OSError as e:
                consecutive_failures += 1
                logger.warning("heartbeat_write_failed",
                    client_id=self._client_id, attempt=consecutive_failures, error=str(e))
                if consecutive_failures >= 3:
                    logger.error("heartbeat_write_failed_permanently", client_id=self._client_id)
                    self._healthy = False
                continue

            await self._sweep_stale_clients()

3.6 The Sweep Algorithm (Distributed)

Every client participates. No single point of failure. No external scheduler.

async def _sweep_stale_clients(self) -> None:
    state = self._read_state()
    if state is None or state.get("shutdown_initiated_by") is not None:
        return

    clients = state.get("clients", {})
    if not clients:
        return

    now = datetime.now(timezone.utc)
    timeout_s = state.get("heartbeat_timeout_s", 120)
    grace_s = state.get("grace_margin_s", 10)
    threshold = now - timedelta(seconds=timeout_s + grace_s)

    for client_id, info in clients.items():
        last_hb = datetime.fromisoformat(info["last_heartbeat"])
        if last_hb > threshold:
            return  # At least one client alive

    # ALL stale — initiate shutdown
    if not self._try_claim_shutdown(state):
        return  # Another client beat us

    await asyncio.sleep(5)  # 5s grace for late heartbeats

    # Re-check: did anyone heartbeat during grace?
    state = self._read_state()
    if state:
        for info in state.get("clients", {}).values():
            last_hb = datetime.fromisoformat(info["last_heartbeat"])
            if last_hb > threshold:
                self._clear_shutdown_token()
                return

    await self._stop_server()

3.7 Election: _try_claim_shutdown()

def _try_claim_shutdown(self, state: dict) -> bool:
    with fasteners.InterProcessLock(str(self._state_lock)):
        current = self._read_state()
        if current is None or current.get("shutdown_initiated_by") is not None:
            return False
        current["shutdown_initiated_by"] = self._client_id
        current["shutdown_initiated_at"] = datetime.now(timezone.utc).isoformat()
        current["state"] = "draining"
        self._state_file.write_text(json.dumps(current, indent=2))
        return True

3.8 Detection Latency

Worst case = heartbeat_interval + heartbeat_timeout + grace_margin = 60 + 120 + 10 = 190s from last client crash to shutdown init, plus 5s grace = 195s total.

3.9 Race Conditions

Race Mitigation
Two clients both see all clients stale _try_claim_shutdown() uses fasteners.InterProcessLock; only one writes shutdown_initiated_by
Late heartbeat during sweep grace period 5s grace + 10s grace_margin absorb the race; re-check at end of grace cancels shutdown if needed
New client during DRAINING New client writes its heartbeat, clears shutdown_initiated_by, sets state: "running". Existing client re-checks and cancels drain.
Clock skew grace_margin_s = 10 absorbs up to 10s of NTP stepping. On a single machine (only deployment model), skew is effectively zero.

3.10 Health Check API

def is_healthy(self) -> bool:
    """Check if this client's connection is healthy."""
    if not self._healthy:
        return False
    state = self._read_state()
    if state is None:
        return False
    pid = state.get("pid")
    if pid is None:
        return False
    try:
        os.kill(pid, 0)
        return True
    except OSError:
        return False

def get_health_report(self) -> dict:
    """Detailed health report for memini-ai status / MCP tool."""
    # ... see architect's Section 5.1

The get_status MCP tool is extended to include the health report when running in pgembed mode.


4. Multi-Process Attach Algorithm

4.1 Pseudocode (with heartbeat)

import asyncio
import json
import os
import uuid
from datetime import datetime, timezone, timedelta
from pathlib import Path

import fasteners
import pgembed


class EmbeddedPGDriver:
    """Driver for embedded pgembed Postgres with cooperative heartbeat liveness.

    ONE embedded server, shared by ALL memini-ai processes on the same
    machine. Uses pgembed's native get_server() for process coordination
    and a server.json state file for observability + heartbeat map.
    """

    def __init__(self, data_dir: str | Path) -> None:
        self._data_dir = Path(data_dir).expanduser().resolve()
        # server.json stays in ~/.memini-ai/pgembed/ for namespace consistency
        self._state_dir = Path("~/.memini-ai/pgembed").expanduser().resolve()
        self._state_file = self._state_dir / "server.json"
        self._state_lock = self._state_dir / "server.json.lock"
        self._server = None
        self._uri = None
        self._client_id = str(uuid4())
        self._role = "passive"
        self._healthy = True
        self._heartbeat_task = None
        self._heartbeat_stop = asyncio.Event()
        self._explicit_shutdown = False

        self._state_dir.mkdir(parents=True, exist_ok=True)

    # ── Public API (DatabaseDriver Protocol) ──────────────────────

    async def get_uri(self) -> str:
        if self._uri is not None:
            return self._uri
        existing = self._read_state()
        if existing and existing.get("state") in ("running", "draining"):
            self._uri = await self._attach_to_existing(existing)
            return self._uri
        if existing and existing.get("state") == "dead":
            self._cleanup_stale_state()
        self._uri = await self._start_new_server()
        return self._uri

    async def initialize(self) -> None:
        if self._server is None:
            await self.get_uri()
        self._role = "primary"  # Now actively querying

    async def shutdown(self) -> None:
        # Stop heartbeat first
        if self._heartbeat_task is not None:
            self._heartbeat_stop.set()
            self._heartbeat_task.cancel()
            try:
                await self._heartbeat_task
            except asyncio.CancelledError:
                pass
            self._heartbeat_task = None

        if self._server is None:
            return

        # Remove this client from the map
        self._remove_client()

        # Check if we're the last
        state = self._read_state()
        remaining = state.get("clients", {}) if state else {}
        if not remaining or self._explicit_shutdown:
            await self._stop_server()

    def is_ready(self) -> bool:
        state = self._read_state()
        if state and state.get("state") == "running":
            pid = state.get("pid")
            if pid is not None:
                try:
                    os.kill(pid, 0)
                    return True
                except OSError:
                    return False
        return False

    def request_explicit_shutdown(self) -> None:
        self._explicit_shutdown = True

    # ── Server lifecycle ──────────────────────────────────────────

    async def _attach_to_existing(self, state: dict) -> str:
        self._server = pgembed.get_server(state["data_dir"], cleanup_mode=None)
        uri = self._server.get_uri()
        self._register_client()
        self._start_heartbeat()
        return uri

    async def _start_new_server(self) -> str:
        self._write_initial_state()
        try:
            self._server = pgembed.get_server(str(self._data_dir), cleanup_mode=None)
        except Exception as e:
            self._write_state("dead", refcount=0)
            raise RuntimeError(f"Failed to start embedded Postgres: {e}") from e
        uri = self._server.get_uri()
        self._register_client()
        self._start_heartbeat()
        return uri

    def _cleanup_stale_state(self) -> None:
        if self._state_file.exists():
            self._state_file.unlink()

    # ── State file management ────────────────────────────────────

    def _read_state(self) -> dict | None:
        if not self._state_file.exists():
            return None
        try:
            return json.loads(self._state_file.read_text())
        except (json.JSONDecodeError, OSError):
            return None

    def _write_initial_state(self) -> None:
        with fasteners.InterProcessLock(str(self._state_lock)):
            existing = self._read_state() or {}
            # Preserve server_started_at across restarts
            started_at = existing.get("server_started_at") or datetime.now(timezone.utc).isoformat()
            data = {
                "version": "1.0.0",
                "state": "starting",
                "pid": os.getpid(),  # will be replaced by postmaster pid
                "uri": "",
                "data_dir": str(self._data_dir),
                "server_started_at": started_at,
                "heartbeat_interval_s": 60,
                "heartbeat_timeout_s": 120,
                "grace_margin_s": 10,
                "shutdown_initiated_by": None,
                "shutdown_initiated_at": None,
                "clients": {},
            }
            self._state_file.write_text(json.dumps(data, indent=2))

    def _register_client(self) -> None:
        with fasteners.InterProcessLock(str(self._state_lock)):
            state = self._read_state() or {}
            state.setdefault("clients", {})
            state["clients"][self._client_id] = {
                "pid": os.getpid(),
                "last_heartbeat": datetime.now(timezone.utc).isoformat(),
                "role": self._role,
            }
            # Update postmaster pid if we just started
            if self._server and self._server._postmaster_info:
                state["pid"] = self._server._postmaster_info.pid
            state["state"] = "running"
            state["uri"] = self._uri or state.get("uri", "")
            self._state_file.write_text(json.dumps(state, indent=2))

    def _remove_client(self) -> None:
        with fasteners.InterProcessLock(str(self._state_lock)):
            state = self._read_state()
            if state and self._client_id in state.get("clients", {}):
                del state["clients"][self._client_id]
                self._state_file.write_text(json.dumps(state, indent=2))

    def _write_heartbeat(self) -> None:
        with fasteners.InterProcessLock(str(self._state_lock)):
            state = self._read_state() or {}
            state.setdefault("clients", {})
            state["clients"][self._client_id] = {
                "pid": os.getpid(),
                "last_heartbeat": datetime.now(timezone.utc).isoformat(),
                "role": self._role,
            }
            self._state_file.write_text(json.dumps(state, indent=2))

4.2 Scenario Walkthroughs

Scenario Sequence
Process A starts, no server get_uri() → no state → _start_new_server()pgembed.get_server() → Postgres starts → _register_client()_start_heartbeat()
Process B starts, finds A's server get_uri() → state={running, clients: {A}} → _attach_to_existing()pgembed.get_server() returns cached → _register_client() (B added) → heartbeat starts
Process A exits gracefully shutdown() → stop heartbeat → _remove_client() (A removed) → clients map still has B → server stays
Process A is kill -9'd No shutdown() called → A's heartbeat stops being written → after ~190s, B's sweep sees A stale → B initiates shutdown (or A's heartbeat expires)
Last client exits gracefully shutdown()_remove_client() → clients map empty → _stop_server()cleanup() → state="stopped"
User runs memini-ai stop request_explicit_shutdown()shutdown() → skip clients-empty check → _stop_server() → state="stopped"
Process C starts after server stopped get_uri() → state={stopped} → _start_new_server() → pgembed reuses data dir (memories preserved) → state="running" (preserves server_started_at)
Process D starts, finds DEAD state get_uri() → state={dead} → _cleanup_stale_state()_start_new_server() → pgembed detects data dir, reuses

4.3 Lock Strategy

  1. pgembed's data-dir lock — prevents two processes from starting Postgres on same data dir. Ground truth.
  2. server.json.lock — protects clients map and shutdown_initiated_by token. Tiny critical sections.
  3. No socket lock — pgembed uses Unix sockets, naturally single-writer.

5. Config Field Design

5.1 New Fields in MeminiConfig

# In src/memini_ai/config.py, inside class MeminiConfig(BaseSettings):

vector_backend: Literal["pgembed", "postgres-external"] = Field(
    default="pgembed",
    alias="MEMINI_VECTOR_BACKEND",
    description=(
        "Vector database backend. 'pgembed' (default) starts an embedded "
        "PostgreSQL 17 server in-process. 'postgres-external' connects to "
        "an external PostgreSQL server via MEMINI_DB_URL (Docker, team server, etc.)."
    ),
)

pgembed_data_dir: str = Field(
    default="~/.local/share/memini-ai/pgembed/data",
    alias="MEMINI_PGEMBED_DATA_DIR",
    description=(
        "Data directory for the embedded pgembed PostgreSQL server. "
        "Persistent across restarts. Default: ~/.local/share/memini-ai/pgembed/data"
    ),
)

team_db_url: str = Field(
    default="",
    alias="MEMINI_TEAM_DB_URL",
    description=(
        "Optional team/shared PostgreSQL server URL for RRF fusion with "
        "embedded results."
    ),
)

fusion_mode: Literal["none", "rrf"] = Field(
    default="none",
    alias="MEMINI_FUSION_MODE",
    description=(
        "Fusion mode for multi-backend queries. 'none' (default) queries "
        "only the primary backend. 'rrf' fuses results from embedded + team."
    ),
)

5.2 Backward Compatibility (Q4 Decision: BREAK v0.8.2 — REFUSE TO START)

Q4 decision: vector_backend always wins. v0.8.2 users with MEMINI_DB_URL set but no explicit MEMINI_VECTOR_BACKEND will get a hard RuntimeError on startup. No silent compat, no silent data loss, no surprise empty database. The user MUST explicitly choose.

User Scenario v0.8.2 Config v1.0.0 Behavior
v0.8.2 user with MEMINI_DB_URL set MEMINI_DB_URL=postgresql://... REFUSE TO START. Raises RuntimeError with clear remediation. See Refuse-to-Start Error.
New user, no config Nothing set vector_backend="pgembed" (default), data dir at ~/.local/share/memini-ai/pgembed/data, embedded server auto-starts on first query.
User wants embedded only MEMINI_VECTOR_BACKEND=pgembed Embedded server only.
User wants team only MEMINI_VECTOR_BACKEND=postgres-external + MEMINI_DB_URL=... Team server only. (Same as v0.8.2 with explicit backend selection.)
User wants both fused MEMINI_VECTOR_BACKEND=pgembed + MEMINI_TEAM_DB_URL=... + MEMINI_FUSION_MODE=rrf Embedded + team with RRF fusion.

5.3 create_database() Factory

def create_database(config: MeminiConfig | None = None) -> VectorDatabase:
    if config is None:
        config = get_config()

    # Q4: vector_backend always wins. No auto-detect, no silent compat.
    # v0.8.2 users with MEMINI_DB_URL set MUST set MEMINI_VECTOR_BACKEND=postgres-explicit
    # or run `memini-ai migrate` to copy data to embedded mode.

    from memini_ai.postgres.driver import EmbeddedPGDriver, ExternalPGDriver

    if config.vector_backend == "pgembed":
        data_dir = Path(config.pgembed_data_dir).expanduser()
        driver = EmbeddedPGDriver(data_dir)
    elif config.vector_backend == "postgres-external":
        db_url = config.db_url or os.environ.get("MEMINI_DB_URL", "")
        if not db_url:
            raise ValueError(
                "MEMINI_VECTOR_BACKEND=postgres-external but MEMINI_DB_URL is not set. "
                "Set MEMINI_DB_URL or switch to MEMINI_VECTOR_BACKEND=pgembed for embedded mode."
            )
        driver = ExternalPGDriver(db_url)
    else:
        raise ValueError(f"Unknown vector_backend: {config.vector_backend}")

    from memini_ai.postgres import PostgresDatabase
    db = PostgresDatabase(
        driver=driver,
        project_id=config.project_id,
        sslmode=config.db_sslmode,
        sslrootcert=config.db_sslrootcert,
    )

    if config.fusion_mode == "rrf" and config.team_db_url:
        from memini_ai.memory.rrf_database import RRFDatabase
        team_driver = ExternalPGDriver(config.team_db_url)
        team_db = PostgresDatabase(driver=team_driver, project_id=config.project_id)
        db = RRFDatabase(primary=db, secondary=team_db, k=config.rrf_k)

    return db

5.4 Refuse-to-Start Error

When a user has MEMINI_DB_URL set (indicating v0.8.2 external-Postgres setup) but MEMINI_VECTOR_BACKEND is not explicitly set, create_database() raises a clear error BEFORE starting any database:

# In create_database(), called at server startup:
if (
    (config.db_url or os.environ.get("MEMINI_DB_URL"))
    and "MEMINI_VECTOR_BACKEND" not in os.environ
):
    raise RuntimeError(
        "memini-ai v1.0.0: MEMINI_DB_URL is set but MEMINI_VECTOR_BACKEND is not.\n"
        "\n"
        "v1.0.0 defaults to 'pgembed' (embedded PostgreSQL) which IGNORES MEMINI_DB_URL.\n"
        "Your v0.8.2 setup connected to an external Postgres server via MEMINI_DB_URL.\n"
        "\n"
        "To keep using your external server:\n"
        "  export MEMINI_VECTOR_BACKEND=postgres-external\n"
        "\n"
        "To switch to embedded mode (and copy your data):\n"
        "  unset MEMINI_DB_URL\n"
        "  memini-ai migrate --from='<your old MEMINI_DB_URL>'\n"
        "\n"
        "Set MEMINI_VECTOR_BACKEND explicitly to suppress this error."
    )

Rationale (user decision, 2026-07-15): - User explicitly said "I don't give a rats ass about backwards compat. We don't build around tech debt." - Silent compat (auto-detect from MEMINI_DB_URL) would silently route around vector_backend — the exact kind of tech debt the user wants to avoid - Soft warning + start would silently drop the user's external DB connection on first query — silent data loss - Hard error forces explicit choice, no surprises, no "what happened to my memories" support tickets - The error is loud, specific, and gives two clear paths forward

5.5 Config Validation

@field_validator("vector_backend")
@classmethod
def _validate_vector_backend(cls, v: str) -> str:
    allowed = {"pgembed", "postgres-external"}
    if v not in allowed:
        raise ValueError(f"vector_backend must be one of {allowed}, got '{v}'")
    return v

@field_validator("fusion_mode")
@classmethod
def _validate_fusion_mode(cls, v: str) -> str:
    allowed = {"none", "rrf"}
    if v not in allowed:
        raise ValueError(f"fusion_mode must be one of {allowed}, got '{v}'")
    return v

5.5 Config Validation

@field_validator("vector_backend")
@classmethod
def _validate_vector_backend(cls, v: str) -> str:
    allowed = {"pgembed", "postgres-external"}
    if v not in allowed:
        raise ValueError(f"vector_backend must be one of {allowed}, got '{v}'")
    return v

@field_validator("fusion_mode")
@classmethod
def _validate_fusion_mode(cls, v: str) -> str:
    allowed = {"none", "rrf"}
    if v not in allowed:
        raise ValueError(f"fusion_mode must be one of {allowed}, got '{v}'")
    return v

6. RRF Fusion Across Embedded + Team

6.1 What Already Exists (Reused As-Is)

src/memini_ai/memory/rrf.py (250 LOC): - reciprocal_rank_fusion(ranked_lists, k=60) — pure function, accepts variable-length inputs - rrf_with_limit(ranked_lists, k, limit) — top-N convenience wrapper - rrf_search(conn, query_text, embedder, ...) — intra-server fan-out (384+1024 columns)

The new fusion is one level up: fan out across TWO Postgres servers, each of which may internally do its own 384+1024 RRF.

6.2 RRFDatabase Wrapper

# src/memini_ai/memory/rrf_database.py (NEW FILE, ~210 LOC)

class RRFDatabase(VectorDatabase):
    """Wraps two VectorDatabase instances and fuses query results via RRF.

    Writes go to primary only. Queries fan out to both backends in
    parallel and fuse ranked lists using RRF.

    Tie-breaking: same memory_id in both → primary (embedded) wins.
    Failure mode: secondary unreachable → degrade to primary-only.
    """

    def __init__(self, primary: VectorDatabase, secondary: VectorDatabase, k: int = 60) -> None:
        self._primary = primary
        self._secondary = secondary
        self._k = k
        self._initialized = False
        self._team_write_failures: int = 0

    async def initialize(self) -> None:
        if self._initialized:
            return
        await self._primary.initialize()
        try:
            await self._secondary.initialize()
        except Exception:
            logger.warning("team_server_init_failed", message="Team server unavailable at startup")
        self._initialized = True

    async def add_memory(self, entry: MemoryEntry) -> str:
        await self.initialize()
        memory_id = await self._primary.add_memory(entry)
        if self._secondary is not None:
            asyncio.create_task(self._write_to_team(entry, memory_id))  # Q3: fire-and-forget
        return memory_id

    async def _write_to_team(self, entry: MemoryEntry, memory_id: str) -> None:
        try:
            await self._secondary.add_memory(entry)
        except Exception as e:
            self._team_write_failures += 1
            logger.warning("team_write_failed", memory_id=memory_id, error=str(e)[:200])

    async def get_memory(self, memory_id: str, include_archived: bool = False) -> MemoryEntry | None:
        await self.initialize()
        result = await self._primary.get_memory(memory_id, include_archived)
        if result is not None:
            return result
        try:
            return await self._secondary.get_memory(memory_id, include_archived)
        except Exception:
            return None

    async def query_memories(self, vector, options, collection_name=None) -> list[MemoryEntry]:
        await self.initialize()
        primary_task = asyncio.create_task(self._primary.query_memories(vector, options, collection_name))
        secondary_task = asyncio.create_task(self._secondary.query_memories(vector, options, collection_name))
        results_primary = await primary_task
        try:
            results_secondary = await secondary_task
        except Exception as e:
            logger.warning("team_server_query_failed", error=str(e)[:200], fallback="embedded_only")
            return results_primary

        ranked_ids = [[e.id for e in results_primary], [e.id for e in results_secondary]]
        fused_ids = rrf_with_limit(ranked_ids, k=self._k, limit=options.top_k)
        entries_by_id = {e.id: e for e in results_primary}
        for e in results_secondary:
            entries_by_id.setdefault(e.id, e)
        return [entries_by_id[mid] for mid in fused_ids if mid in entries_by_id]

    # ... all other read methods delegate to primary, same pattern

6.3 Tie-Breaking Policy

Situation Policy
Memory in both, same content RRF boosts naturally (sum of both contributions)
Memory in both, different content Primary (embedded) wins via setdefault()
Memory only in embedded Single-contribution RRF score
Memory only in team Single-contribution RRF score, re-hydrated from team
Team unreachable Degrade to embedded-only
Primary (embedded) fails Propagates to caller (embedded is authoritative)

6.4 MCP Tool Surface

Existing query_memories MCP tool — no change. Fusion is transparent. get_status extended with embedded_server health report when in pgembed mode.

6.5 Team Install Path (Q6 Decision)

# pyproject.toml
[project.optional-dependencies]
team = []  # No extra deps needed; asyncpg is already core

pip install memini-ai[team] is identical to pip install memini-ai in terms of installed packages. The distinction is documented, not enforced: the README tells users to set:

export MEMINI_VECTOR_BACKEND=pgembed
export MEMINI_TEAM_DB_URL=postgresql://team-server/memini
export MEMINI_FUSION_MODE=rrf

The [team] extra exists as a discoverable marker for "I want team-aware setup." Future versions could add additional deps (e.g., a team-server-specific client) without breaking the marker.


7. Failure-Mode Table

# Scenario Embedded State Team State Expected Behavior Severity
1 User has only embedded configured RUNNING N/A Return embedded results. No MEMINI_TEAM_DB_URL. create_database() returns plain PostgresDatabase.
2 User has only team configured N/A RUNNING Return team results. MEMINI_DB_URL set. Identical to v0.8.2.
3 User has both, team reachable RUNNING RUNNING RRF-fuse both. Parallel queries. Primary (embedded) wins on content conflicts.
4 User has both, team unreachable RUNNING DOWN Degrade to embedded-only. secondary_task raises → caught → log warning → return primary. LOW
5 User has both, team unreachable at startup RUNNING DOWN RRFDatabase.initialize() catches secondary init failure → logs warning → continues. LOW
6 User has both, embedded crashed mid-query CRASHED RUNNING primary_task raises → propagates to caller. On retry, EmbeddedPGDriver detects DEAD and auto-restarts. HIGH
7 User has both, embedded crashed, auto-restart DEAD → STARTING → RUNNING RUNNING Next get_uri() detects DEAD → cleanup → restart. Memoris preserved. MEDIUM
8 User has neither NOT CONFIGURED NOT CONFIGURED create_database() raises clear ValueError. HIGH
9 Team has vectorscale, embedded does not HNSW DiskANN Same query, different index. RRF fuses ranks, not raw scores. Not a problem. LOW
10 Team has different schema version than embedded RUNNING (v1.0.0) RUNNING (v0.8.2) RRFDatabase.initialize() checks both. If incompatible, logs warning, disables team fusion. User must migrate team. MEDIUM
11 Embedded data dir corrupted DEAD RUNNING Driver detects corruption → DEAD. If team available, degrades to team-only. User must run memini-ai init --reset. HIGH
12 Disk full on embedded data dir RUNNING (writes fail) RUNNING add_memory fails. Heartbeat writes also fail → after 3 failures, self._healthy = False. Reads may still work. HIGH
13 Embedded server port/socket conflict N/A N/A Cannot happen. pgembed uses Unix domain sockets.
14 Two users on same machine, different data dirs RUNNING (user A) RUNNING (user B) Each has own MEMINI_PGEMBED_DATA_DIR → independent servers.
15 NEW (Q2): heartbeat write fails (disk full) RUNNING any Client logs warning, increments failure counter. At 3 failures, marks self._healthy = False. Server stays up — client's own queries still work. LOW
16 NEW (Q2): all clients stale (last crashed) DRAINING → STOPPED any Surviving client's sweep detects, initiates shutdown via election token. 5s grace period. Re-checks at end of grace; cancels if new heartbeat seen. Worst case: 195s from last crash. MEDIUM
17 NEW (Q2): two clients both see all stale DRAINING any _try_claim_shutdown() uses lock; only one writes shutdown_initiated_by. Second backs off. LOW
18 NEW (Q2): new client during DRAINING RUNNING any New client writes heartbeat, clears shutdown_initiated_by, sets state="running". Existing client re-checks, cancels drain. LOW
19 NEW (Q3): team write fails (network blip) RUNNING RUNNING team_write_failed warning logged, _team_write_failures counter incremented. No retry, no surface to user. LOW
20 NEW (Q4): v0.8.2 user with MEMINI_DB_URL set, no MEMINI_VECTOR_BACKEND NOT STARTED N/A RuntimeError raised at startup with clear remediation. User must set MEMINI_VECTOR_BACKEND=postgres-external or run memini-ai migrate. No silent data loss. MEDIUM

8. Test Strategy

8.1 pgembed Driver Tests (~40 tests, no Docker)

Isolation: per-test temp data dir, cleanup_mode='stop'.

Category What It Tests Est.
Driver lifecycle get_uri()initialize()shutdown()is_ready() 4
Server start/stop Verify postmaster.pid, asyncpg connection, cleanup 3
Schema init _ensure_schema(), 13 tables, extensions installed 3
Vector operations Insert + query 384-dim vector, cosine distance 5
Multi-process attach Subprocess + parent share server, both query 4
Heartbeat writes Client registers, writes heartbeat, others see it 3
Heartbeat stale sweep All clients stale → sweep initiates shutdown, election works 4
Grace period cancel New client heartbeats during drain → shutdown cancelled 2
Crash recovery kill -9 a client, verify heartbeat expires, sweep cleans up 3
State file correctness server.json reflects state through transitions 4
Health check is_healthy(), get_health_report() 3
Subtotal 40

8.2 RRF Fusion Tests (~15 tests)

Isolation: fake VectorDatabase implementations with canned ranked lists.

Category What It Tests Est.
Fusion correctness Known ranked lists → expected RRF output 5
Team unreachable Mock raises → degrade to primary-only 2
Tie-breaking Same memory_id in both → primary wins 2
Empty results One or both backends empty → no crash 2
Parallel execution Both queries fire concurrently 2
Write delegation add_memory hits primary, async fires to team 2
Subtotal 15

8.3 Existing Test Suite

Parametrize pg_db fixture for both backends — 809 existing tests × 2 backends = 1618 test runs in CI. Add MEMINI_VECTOR_BACKEND env var to control which backend is used in CI.

8.4 Config Validation Tests (~10 tests)

Test What It Tests Est.
Valid backends "pgembed", "postgres-external" accepted 2
Invalid backend "chdb" raises ValidationError 1
Valid fusion modes "none", "rrf" accepted 2
Invalid fusion mode "condorcet" raises 1
v0.8.2 compat warning MEMINI_DB_URL set + default vector_backend → warning printed 1
Explicit override MEMINI_VECTOR_BACKEND=postgres-external + MEMINI_DB_URL → no warning 1
Subtotal 8

8.5 CLI Tests (~7 tests)

Test What It Tests Est.
memini-ai init Creates data dir, starts server, reports URI 2
memini-ai status Reports server state, memory count, health report 2
memini-ai stop Stops server, verifies server.json shows "stopped" 2
memini-ai stop --force Stops even with active handles 1
Subtotal 7

8.6 Total New Tests

Area New Tests
EmbeddedPGDriver 40
RRFDatabase fusion 15
Config validation 8
CLI commands 7
Total ~70

9. Implementation Plan (Task List)

9.1 Task Table

# Task Files Touched LOC Est Type Depends On
T1 Create feature/embedded-pgembed-v1 from main (7eed224) 0 git
T2 Add DatabaseDriver Protocol + ExternalPGDriver src/memini_ai/postgres/driver.py (NEW) ~80 coder-friendly T1
T3 Add EmbeddedPGDriver with state machine + heartbeat + sweep src/memini_ai/postgres/driver.py (append) ~390 needs architect review T2
T4 Add config fields: vector_backend, pgembed_data_dir, team_db_url, fusion_mode src/memini_ai/config.py ~40 coder-friendly T1
T5 Modify PostgresDatabase.__init__ to accept driver instead of db_url src/memini_ai/postgres/database.py ~15 coder-friendly T2, T3
T6 Update create_database() factory with v0.8.2 compat warning src/memini_ai/memory/database.py ~50 coder-friendly T4, T5
T7 Add RRFDatabase wrapper for embedded+team fusion with async dual-write src/memini_ai/memory/rrf_database.py (NEW) ~210 needs architect review T6
T8 Add memini-ai init/status/stop/migrate CLI commands src/memini_ai/cli.py (NEW) ~200 coder-friendly T3, T6
T9 Write EmbeddedPGDriver tests (40 tests) tests/test_pgembed_driver.py (NEW) ~400 coder-friendly T3
T10 Write RRFDatabase fusion tests (15 tests) tests/test_rrf_database.py (NEW) ~200 coder-friendly T7
T11 Write config validation + v0.8.2 compat warning tests (8 tests) tests/test_config.py (append) ~80 coder-friendly T4
T12 Parametrize existing test suite for pgembed backend tests/conftest.py, tests/test_postgres_database.py ~50 coder-friendly T6, T9
T13 Update pyproject.toml: add pgembed>=0.2.0 dep, optional [team] extra pyproject.toml ~10 coder-friendly T1
T14 Update README, CHANGELOG, write memini-ai migrate script README.md, CHANGELOG.md, scripts/migrate_external_to_embedded.py (NEW) ~400 coder-friendly T13
T15 Release: bumpversion --minor --apply, tag v1.0.0, push, publish to PyPI 0 release T1-T14

9.2 Dependency Graph

T1 (branch)
 ├─ T2 (Protocol + ExternalPGDriver) ─────────────┐
 ├─ T3 (EmbeddedPGDriver + heartbeat) ────────────┤
 ├─ T4 (config fields) ───────────────────────────┤
 └─ T13 (pyproject.toml) ─────────────────────────┤
 T2 + T3 ──► T5 (modify PostgresDatabase.__init__) ┤
 T4 + T5 ──► T6 (update create_database factory)   │
 T6 ──────► T7 (RRFDatabase wrapper)               │
 T3 + T6 ──► T8 (CLI commands)                     │
 T3 ──────► T9 (driver tests)                      │
 T7 ──────► T10 (fusion tests)                     │
 T4 ──────► T11 (config tests)                     │
 T6 + T9 ──► T12 (parametrize existing tests)       │
 T13 ─────► T14 (docs + migration script)          │
 T1-T14 ──► T15 (release)                          │

9.3 Parallelizable Groups

Group Tasks Parallel? Reason
A T2, T3, T4, T13 Independent files
B T5, T8, T9, T11 T5: T2+T3. T8: T3+T6. T9: T3. T11: T4.
C T7, T12 T7: T6. T12: T6+T9.
D T10 After T7 Needs RRFDatabase
E T14 After T13 Needs pyproject.toml
F T15 After all Release

9.4 LOC Summary

Category New Files Modified Files New LOC Modified LOC
Driver 1 0 ~470 0
RRFDatabase 1 0 ~210 0
Config 0 1 ~40 0
Database 0 2 ~50 ~15
CLI 1 0 ~200 0
Tests 2 2 ~680 ~50
Docs + Migration 2 2 ~400 0
Total 7 7 ~2,050 ~65

10. Risks

# Risk Severity Likelihood Mitigation
1 pgembed 0.2.0 is unmaintained (last release Mar 2026) MEDIUM LOW Apache 2.0, wraps standard Postgres 17 binaries. Can fork if needed. Wheel is self-contained (64MB).
2 Unix socket path length limits (108 bytes on Linux) LOW LOW ~/.local/share/memini-ai/pgembed/data is 40 chars. Well within limit.
3 Data dir grows unbounded (Postgres WAL, dead tuples) MEDIUM MEDIUM Postgres autovacuum enabled by default. Add memini-ai vacuum CLI in v1.1.0.
4 Heartbeat detection latency (195s worst case) LOW Acceptable: server consumes ~50MB RAM idle. 3 minutes of idle RAM after last client crashes is a reasonable trade-off for no external scheduler.
5 server.psql() segfaults on Python 3.13 (pgembed's bundled psql) LOW We use asyncpg, not server.psql().
6 pgembed wheel is 64MB — install size LOW Acceptable for zero-dependency Postgres. Alternative (Docker) is 200MB+. The [postgres-external] extra lets users opt out (but the dep is still pulled in v1.0.0; will be optional in v1.1).
7 Q4 breaking change annoys v0.8.2 users MEDIUM MEDIUM One-time startup warning with clear remediation. Optional memini-ai migrate script. User explicitly approved.
8 Q3 team write failures are silent (no retry) LOW MEDIUM Counter exposed in get_status. User can monitor. Fire-and-forget was an explicit user choice.
9 First-client writes heartbeat config — subsequent clients must accept LOW LOW Config is written once with defaults (60s/120s/10s). Subsequent clients read and adopt. If first client has weird config, others follow. (Could add a "config mismatch" warning in v1.1.)
10 vectorscale 0.9.0 in pgembed may differ from team server's version LOW MEDIUM _detect_vectorscale() gracefully falls back to HNSW.
11 Two memini-ai processes on different machines (network scenario) Out of scope for v1.0.0. pgembed is local-only. Network access would need a real Postgres server (postgres-external mode).
12 Migration script complexity (external → embedded) MEDIUM MEDIUM memini-ai migrate uses pg_dump on the source + pg_restore on the target. Standard Postgres tooling, well-tested. ~200 LOC.

Appendix A: Key File Reference

File LOC Role in v1.0.0
src/memini_ai/postgres/database.py 1820 Sacred — ~15 lines changed (driver instead of db_url). 19 ABC methods unchanged.
src/memini_ai/memory/database.py 319 Factory create_database() — ~50 LOC change for backend selection + v0.8.2 compat warning
src/memini_ai/memory/rrf.py 250 Reused as-isreciprocal_rank_fusion() already accepts variable-length inputs. No changes.
src/memini_ai/memory/system.py 1039 Unchanged.
src/memini_ai/config.py 572 +4 fields (vector_backend, pgembed_data_dir, team_db_url, fusion_mode), +2 validators
src/memini_ai/postgres/schema.py 631 Unchanged — same 13 tables, same extensions, same indexes on both backends
src/memini_ai/postgres/queries.py 730 Unchanged — same 63 query constants, same SQL on both backends
src/memini_ai/postgres/driver.py NEW ~470 LOC — DatabaseDriver Protocol + EmbeddedPGDriver (heartbeat, sweep, state machine) + ExternalPGDriver (trivial)
src/memini_ai/memory/rrf_database.py NEW ~210 LOC — RRFDatabase wrapper: writes go to primary, async dual-write to team, queries RRF-fuse both
src/memini_ai/cli.py NEW ~200 LOC — init, status, stop, migrate
scripts/migrate_external_to_embedded.py NEW ~200 LOC — pg_dump from external + pg_restore to embedded
tests/test_pgembed_driver.py NEW ~400 LOC — 40 tests for EmbeddedPGDriver lifecycle, heartbeat, sweep, multi-process, crash recovery, state file
tests/test_rrf_database.py NEW ~200 LOC — 15 tests for RRFDatabase fusion, degradation, tie-breaking, parallel execution, async dual-write
pyproject.toml EDIT +pgembed>=0.2.0 in core deps, optional [team] extra (no extra deps, just a marker)

Appendix B: Handoff Doc Corrections

The handoff doc (git show 8ce151a:HANDOFF-v1.0.0-pgembed.md) contained inaccuracies corrected by the smoke test:

Claim in Handoff Reality
"vectorscale — listed but marked unavailable" 0.9.0 IS available. CREATE EXTENSION vectorscale succeeds.
"pgembed uses TCP ports" Uses Unix domain sockets. URI: postgresql://postgres:@/postgres?host=<data_dir>.
"server.psql() works" Segfaults on Python 3.13.13. Use asyncpg.
"chDB code exists on main and needs deletion" Only on abandoned feature/chdb-migration branch. Not on main. Q5: leave the branch.
"cli.py needs rewriting for chdb" Doesn't exist on main. We're creating it fresh for init/status/stop/migrate.
"1-second startup, no Docker" Correct — verified.
"real Postgres 17 with pgvector bundled" Correct — PostgreSQL 17.9, pgvector 0.8.2, vectorscale 0.9.0.

Appendix C: What We Are NOT Building

  • NOT migrating the 941 live memories from memini-postgres to pgembed. Docker container stays. pgembed is for NEW deployments.
  • NOT changing the MCP tool surface. All 35+ tools work identically.
  • NOT touching the 19 ABC methods in PostgresDatabase. Stable core.
  • NOT touching the feature/postgres-in-process-research branch. Q5: leave it alone.
  • NOT adding runtime backend switching. Backend is chosen at startup via config.
  • NOT supporting Windows. pgembed is Linux/macOS only.
  • NOT building a "list backends" or "switch backend at runtime" feature.
  • NOT auto-installing pgembed as opt-in. v1.0.0 ships it as a hard dep. v1.1+ may make it optional via [embedded] extra.