Skip to main content
Shipped v1 baseline: 17 physical tables (identity 7 · memory domain 7 · ops 3). The scope guard is the physical count — every table beyond 17 needs a one-line justification in the PR description referencing a JTBD.

Design rules

  1. Single validation boundary: every enum/constraint is defined once in packages/schema (Zod); DB CHECKs are generated from it; services never re-validate.
  2. Append-and-supersede: no destructive merge anywhere. memory_events is append-only (INSERT-only grant for the runtime role).
  3. Tenant isolation: app-layer scoping is the primary path (user_id as the leading column of every composite index); RLS is defense-in-depth on every user-owned table from day 1.
    • Every user-owned table carries user_id explicitly — including relation tables (memory_edges, facts, commitments, consolidation_proposals, memory_events). No relation table derives its tenant through a join; RLS must be evaluable on the row alone.
    • Cross-tenant references are structurally impossible: composite FKs (user_id, memory_id) so an edge/fact/commitment can only point at memories of the same user_id. An edge linking two tenants’ memories must be unrepresentable, not merely forbidden.
    • Tenant-invariant checklist (applied to every table in the schema PR, and to every future table): owns user_id? · RLS policy present? · composite FKs tenant-qualified? · indexes lead with user_id? · isolation test covers its query paths (including edges and facts, not just memories)?
  4. Bi-temporal facts: valid_from/valid_to (world time) + recorded_at (system time).

Tables (v1)

Identity & access (7)

TablePurposeNotes
usersAccount, email, argon2id hashNo RLS (system table)
user_sessionsDashboard sessionsRLS
api_keysHashed, prefix-indexed, revocableRLS
oauth_clients / oauth_codes / oauth_tokens3ngram’s own OAuth 2.1 AS+RS: DCR clients (public AND confidential — client_secret_hash nullable column, since Claude requires client_secret_post), short-lived PKCE codes, rotating access/refresh stateDevice flow cut; revocation surfaces in dashboard. oauth_codes/oauth_tokens are RLS tables
password_reset_tokensSingle-use, time-boxed forgotten-password reset tokens (hashed)RLS
Pre-tenant auth resolution: every user-owned credential table (user_sessions, api_keys, oauth_codes, oauth_tokens) is RLS-enforced like the memory domain. The pre-tenant lookup path (credential hash → user, before tenant context exists) goes exclusively through narrow SECURITY DEFINER resolver functions (auth_resolve_session, auth_resolve_api_key, auth_resolve_oauth_token, auth_consume_oauth_code) with pinned search_path, EXECUTE-granted to the runtime role only. No blanket RLS exemptions on auth tables. Only users and oauth_clients (no user_id) are true system tables.

Memory core (7)

TablePurposeNotes
memoriestopic, content, memory_type (decision/commitment/blocker/fact/preference/pattern/note/event), scope, project, embedding vector(1536), valid_from, valid_to, recorded_at, statusNo commitment columns — see commitments
memory_edgesfrom_id, to_id, edge_type (supersedes/updates/extends/derives), created_by (user/consolidator)The typed-edge relationship model
memory_eventsAppend-only lifecycle audit: event_kind, actor_kind, payloadINSERT-only grant; first-class actor taxonomy on every event
commitmentsOwn entity with explicit FSM: open → waiting → resolved/expired, owner, due, recurrence, next_surfacing_at, FK → memoriesCommitments modeled as their own entity, not columns on memories
factsStructured projection: subject, predicate, value, confidence, bi-temporal cols, FK → source memory
consolidation_proposalsBackground-worker output: candidate edge + rationale + status (proposed/applied/rejected) + similarity and memory_type so the per-type precision bar is auditableAdvisory, type-aware; event type never auto-applies. Edge direction is the locked contract: from_id/to_id are materialized verbatim into memory_edges, so for a supersedes/updates proposal the successor is from_id and the predecessor (the superseded row whose valid_to closes on accept) is to_id — the same successor → predecessor convention revise/search rely on. The consolidator MUST emit rows in this orientation.
scopesUser scope config + aliases (personal/work/custom)Unifies scope config and aliases in one table

Operations (3)

TablePurpose
llm_usagePer-operation token/cost tracking
eval_runsBenchmark run results (golden set, LongMemEval scores) — benchmarks are first-class data
audit_logSecurity audit (actor, action, resource, IP)
Deliberately absent in v1 (schema must not preclude them): teams/sharing (when added: 2 tables max — shares + share_denials with a mode column, not a multi-table matrix), documents/chunks, integrations, billing, agent schedules/triggers, plans, work contexts, notifications.

RLS pattern

-- per user-owned table. NULLIF guard is mandatory: on a pooled connection,
-- current_setting(..., true) returns '' (not NULL) once the GUC has ever been
-- set-and-reset on that backend, and ''::uuid makes the policy THROW.
CREATE POLICY tenant_isolation ON memories
  USING (user_id = NULLIF(current_setting('app.user_id', true), '')::uuid)
  WITH CHECK (user_id = NULLIF(current_setting('app.user_id', true), '')::uuid);
The one true access path — all DB access goes through:
// packages/db — the ONLY exported query entry point
await withTenant(userId, async (tx) => {
  // tx has already run: SET LOCAL app.user_id = $userId
  // all queries here are RLS-scoped AND explicitly WHERE-scoped
});
  • SET LOCAL inside a transaction, per request — never bare SET (a pooled connection leaks session state otherwise).
  • Raw pool access outside withTenant fails lint (custom Biome/ESLint rule).
  • Defense-in-depth: queries still carry explicit WHERE user_id = ...; RLS catches the one you forget.
  • Migrations/seeds: unpooled URL only.
  • Roles/GRANTs: one idempotent scripts/provision-roles.sql, never per-migration guards.

Schema PR definition of done

The table sketches above are conceptual. The schema PR (packages/db) is the binding artifact, and it must define — per table:
  1. Full column list with types, nullability, defaults
  2. Unique constraints — including edge uniqueness: UNIQUE (user_id, from_id, to_id, edge_type) so duplicate edges are unrepresentable
  3. Tenant-invariant checklist (above) applied and ticked per table in the PR description
  4. FSM transition enforcement for commitments: legal transitions encoded as a CHECK or trigger (open → waiting → resolved | expired), illegal transitions rejected by the DB, not just the service
  5. Fact validity constraints: valid_from <= valid_to CHECK; supersession sets valid_to atomically with edge creation (one transaction)
  6. Zod → CHECK generation strategy: the codegen path from packages/schema enums to DB CHECK constraints, with a CI assertion that schema and CHECKs cannot drift (the migration round-trip test covers it)
  7. Migration lint rules wired (forward-safe checks in CI): no NOT NULL without default, no blocking rewrites, unpooled-URL guard

Archived status in GET /api/v1/stats

The GET /api/v1/stats endpoint surfaces archivedMemories — the count of memories where status = 'archived' AND valid_to IS NULL. This is distinct from supersededMemories (which counts any row with valid_to set, regardless of status). A revise of an archived memory closes its valid_to window, moving it into the superseded bucket and out of archived; the two counts are mutually exclusive.

Embeddings

One column, one model: vector(1536) via gateway text-embedding-3-large with dimensions: 1536. Index: HNSW (PG18/pgvector ≥0.8 default choice over ivfflat). Filtered retrieval: two legitimate plan regimes — (A) selective tenant: planner uses the user_id btree + exact sort, perfect recall, the right plan at per-tenant scale; (B) large tenant: HNSW with SET LOCAL hnsw.iterative_scan = relaxed_order so post-filtering doesn’t starve results. Retrieval code sets iterative_scan unconditionally (harmless in regime A) and never forces the index choice — the planner flips regimes at the right scale. A content_hash column enables idempotent backfill (so backfill jobs can detect already-processed rows).