Multi-tenancy

Status: draft / skeleton. Vedana does not have first-class multi-tenancy primitives. This page describes the patterns that work today on top of the existing data model and the places where you have to be careful about tenant isolation. Validate the recipes against your environment before promoting them to production.

A “tenant” in this guide means an isolated bucket of data and conversations that must not leak into another tenant — typically one paying customer, one company, or one workspace.

What Vedana does not give you

  • No row-level security at the database level. Memgraph has no per-label or per-tenant access control. Postgres RLS is not enabled in the default schema.
  • No tenant-aware retrieval out of the box. RagPipeline issues Cypher generated by the LLM; it does not append WHERE p.tenant_id = $tenant automatically.
  • No isolation in pgvector. All embeddings live in the same table; the vector search returns the top-k across all tenants unless your query filters.
  • No tenant routing in JIMS. contact_id is a flat identifier; nothing prevents two contacts in different tenants from colliding if you reuse the same ID space.

This means tenant isolation is your responsibility as the integrator. The good news: the data model is flexible enough to express it cleanly.

Pattern A — tenant_id as an anchor attribute

The simplest, most explicit pattern: every entity carries a tenant_id attribute.

  1. Add a tenant_id column to every Anchor in the data model (str, embeddable=false).

  2. Populate it during ETL — either as a column read from Grist or as a constant injected by a custom step (see Custom ETL).

  3. Add a “typical question” (playbook entry) to the Data Model that demonstrates the filter:

    MATCH (p:product) WHERE p.tenant_id = $tenant_id AND p.price < $max RETURN p
  4. Make sure every other playbook query also carries WHERE x.tenant_id = $tenant_id. The LLM will follow the patterns it sees.

Caveat: this relies on the LLM consistently appending the filter. For high-stakes data you need a guardrail (see Pattern C).

Pattern B — separate Memgraph / pgvector per tenant

For strict isolation: run one Vedana stack per tenant.

  • One Memgraph instance per tenant (different MEMGRAPH_URI).
  • One Postgres schema per tenant (different DATABASE_URL or search_path).
  • One Grist doc set per tenant (GRIST_DATA_MODEL_DOC_ID, GRIST_DATA_DOC_ID).
  • One jims-api process per tenant, behind a router that maps tenant_id → upstream.

Pros: airtight isolation, easy to reason about, can be scaled / billed independently. Cons: operational cost grows linearly with tenant count; cross-tenant analytics is harder.

This is the recommended pattern for regulated domains (medical, financial, legal).

Pattern C — Cypher guardrail {#pattern-c—cypher-guardrail}

A defense-in-depth layer for Pattern A: intercept every Cypher query before it hits Memgraph and reject queries that don’t filter by tenant_id.

Currently there is no built-in hook for this in vedana_core. You have two options:

  • Subclass RagAgent.text_to_answer_with_vts_and_cypher and wrap the Cypher executor with a validator (similar to the Custom tools recipe).
  • Add a Cypher-rewrite step that parses the query and either appends the filter or rejects it.

TODO — once a stable hook (_cypher_interceptor / register_cypher_validator) lands, document the canonical recipe here. Tracked alongside the custom-tools hook request.

Pattern D — thread-level tenant binding

For chat-only flows (Telegram, widget) where every user belongs to one tenant:

  1. Set thread_config = {"tenant_id": "..."} on every thread (see JIMS Core → ThreadController).
  2. In your custom RagPipeline step, read thread.thread_config["tenant_id"] and pass it into the LLM’s prompt context.
  3. Combine with Pattern A so the LLM has both the value and the playbook patterns.

This handles the “who is asking?” half of the problem cleanly. It still depends on the LLM following Pattern A’s pattern; combine with Pattern C for hard isolation.

Testing for cross-tenant leaks

A reasonable eval recipe:

  1. Load two tenants of synthetic data into the same instance, each with its own tenant_id.
  2. Build a golden dataset where every question carries the asking tenant’s tenant_id and the correct (tenant-scoped) answer.
  3. Add negative examples: questions that ask for data from the other tenant. The judge should mark these as failures if any data from the other tenant appears in the answer.
  4. Run eval — see Evaluation. Aim for 100% on the negative set before going to production.

TODO — publish a reference synthetic dataset for cross-tenant leak testing. Tracked alongside the test-dataset guide.

dm_* tables and tenancy

The dm_anchors, dm_links, dm_anchor_attributes, dm_link_attributes, dm_queries, dm_prompts tables describe the data model itself, not the data. They are not per-tenant — every tenant on the same instance shares the same data model.

If you need a different data model per tenant, that’s a strong signal you should use Pattern B instead.

Checklist for a multi-tenant launch

  • Decide which pattern fits (A / B / C / D, or a combination).
  • Add tenant_id to every Anchor (Pattern A) or split stacks (Pattern B).
  • Bind tenant_id to threads via thread_config (Pattern D).
  • Add cross-tenant negative examples to the golden dataset.
  • Decide on a Cypher guardrail strategy (Pattern C) — even if you start without it, plan when it’s required.
  • Audit pgvector queries — the top-k function does not filter by tenant unless you pass a filter.
  • Document the chosen pattern in your deployment runbook.

Open items

  • First-class Cypher interceptor hook in vedana_core (see also Custom tools).
  • Reference synthetic two-tenant dataset.
  • Built-in tenant_id filter for PGVectorStore similarity search.