# Session KV Store

PostgreSQL-backed key-value storage for session-scoped data that must survive server restarts.

## Purpose

Provides a simple API for tools to persist per-session state without inventing ad-hoc storage mechanisms.

Current consumers:
- **scratchpad** — working notes (`scope='scratchpad'`)

Todo plans used to live here (`scope='todo'`) and now have their own table —
see [todo-store.md](todo-store.md). The rows were migrated on first start and
removed from `session_store`.

Future consumers: recall context, wizard state, user preferences per session.

## Schema

```sql
CREATE TABLE session_store (
    id         SERIAL PRIMARY KEY,
    user_id    TEXT NOT NULL DEFAULT '',
    session_id TEXT NOT NULL,
    scope      TEXT NOT NULL,
    key        TEXT NOT NULL,
    value      TEXT NOT NULL DEFAULT '',
    updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    UNIQUE (user_id, session_id, scope, key)
);
```

**Note on `user_id`:** `NULL` is normalized to an empty string before every operation. This prevents duplicate rows for anonymous sessions because PostgreSQL treats `NULL` as distinct in unique constraints.

## API (`navi.store.KvStore`)

```python
from navi.store import KvStore

store = KvStore("postgresql://...")

await store.get(user_id, session_id, scope, key)       # str | None
await store.set(user_id, session_id, scope, key, value)  # None
await store.get_all(user_id, session_id, scope)        # dict[str, str]
await store.delete(user_id, session_id, scope, key)      # None
await store.clear_scope(user_id, session_id, scope)      # None
await store.search_values(user_id, scope, terms, limit=10)  # list[dict]
```

- `user_id` — nullable for legacy single-user mode.
- `session_id` — the chat session UUID.
- `scope` — namespace: `scratchpad`, or any custom string.
- `key` — arbitrary string identifier within the scope.

`search_values(user_id, scope, terms, limit=10, session_id=None, prefer_session_id=None)` is the read path behind `memory_search(scope="sketches")`: ILIKE against key and value inside a scope, the preferred session ranked first and then newest first; no terms returns the most recent rows.

## Wiring

`KvStore` is created as a singleton in `navi/core/container.py` (`get_kv_store()` exposes it to routes) and injected into `build_default_registries()`, which passes it to `ScratchpadTool` on startup. `TodoTool` gets the dedicated `PgTodoStore` from the same container.
