Skip to main content
Shipped baseline: 26 physical tables24 in the public Drizzle schema (identity 10 · memory domain 8 · ops 3 · budget 3) plus leftover subscriptions / stripe_events from the platform split, filtered out of Drizzle (drizzle.config.ts tablesFilter) and not part of the OSS inventory. The scope guard is the physical count: every table beyond 26 needs a one-line justification in the PR description referencing a JTBD. A new OSS-modeled table also raises the Drizzle count (24).

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 (10)

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 (8)

Operations (3)

Budget (3)

Deliberately absent (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, agent schedules/triggers, work contexts, notifications. Hosted Stripe state is not modeled here.

RLS pattern

The one true access path — all DB access goes through:
  • 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).
  • Two-layer tenant isolation (defense in depth): every tenant-scoped query — reads included — carries an explicit WHERE user_id = ... predicate bound to the same userId passed into withTenant(), in addition to the RLS policy. The predicate matches exactly the rows RLS admits, so it changes nothing while RLS functions; it exists so isolation never rests on a single mechanism. Neither layer is optional: RLS catches a forgotten predicate, the predicate keeps queries caller-scoped independently of policy state.
  • 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).