# Multi-Tenancy Runbook

pgContext deliberately avoids introducing a separate, opaque tenant store.
Instead it builds on ordinary PostgreSQL tables, grants, row-level security
(RLS), partitioning, standard B-tree indexes, and telemetry labels.
pgContext search paths recheck source rows against these PostgreSQL
controls on every query, so tenant isolation is enforced by PostgreSQL's
own RLS and grants, not by a separate access-control layer.

## Single Collection, Tenant Filter

For shared-table tenants, store a tenant discriminator on the source table and
register it as a filter field:

```sql
CREATE TABLE docs (
  id bigint PRIMARY KEY,
  tenant_id text NOT NULL,
  embedding pgcontext.vector NOT NULL,
  body text NOT NULL
);

SELECT pgcontext.create_collection('docs', 'public.docs');
SELECT pgcontext.register_vector('docs', 'embedding', 'embedding', 3, 'l2');
SELECT pgcontext.register_filter_column('docs', 'tenant_id', 'tenant_id');
```

Every tenant-scoped read should include the tenant filter:

```sql
SELECT point_id, source_key, score
FROM pgcontext.search(
  'docs',
  '[0,0,0]'::pgcontext.vector,
  '{"must":[{"key":"tenant_id","match":"acme"}]}',
  10
);
```

Create a B-tree index for the tenant discriminator, or run
`pgcontext.index_advisor('docs')` and apply the suggested SQL when it reports a
missing filter index.

## Row-Level Security

Use RLS when tenant isolation must not depend on application-provided filters:

```sql
ALTER TABLE docs ENABLE ROW LEVEL SECURITY;
ALTER TABLE docs FORCE ROW LEVEL SECURITY;

CREATE POLICY docs_tenant_policy
  ON docs
  USING (tenant_id = current_setting('app.tenant_id', true));
```

Search, count, facet, grouped search, backfill, and hybrid query functions read
source tables as the invoking role, so PostgreSQL RLS policies still decide
which source rows are visible.

## Partitioned Tenants

For large tenants or tenant-local maintenance windows, partition the source
table by tenant and register the partitioned parent:

```sql
CREATE TABLE docs (
  id bigint NOT NULL,
  tenant_id text NOT NULL,
  embedding pgcontext.vector NOT NULL,
  PRIMARY KEY (tenant_id, id)
) PARTITION BY LIST (tenant_id);

CREATE TABLE docs_acme PARTITION OF docs FOR VALUES IN ('acme');
```

Registered tenant filters remain ordinary SQL predicates, so PostgreSQL can
apply partition pruning while pgContext keeps one logical collection contract.

## Per-Tenant Recall

Compute exact tenant results first, then compare candidate IDs with
`pgcontext.recall_check`:

```sql
WITH exact AS (
  SELECT array_agg(point_id ORDER BY score, point_id) AS point_ids
  FROM pgcontext.search(
    'docs',
    '[0,0,0]'::pgcontext.vector,
    '{"must":[{"key":"tenant_id","match":"acme"}]}',
    100
  )
)
SELECT *
FROM pgcontext.recall_check(
  (SELECT point_ids FROM exact),
  ARRAY[101, 205, 309]::bigint[],
  0.95
);
```

Run this per tenant or per tenant cohort when changing indexes, quantization, or
candidate budgets.

## Noisy-Neighbor Diagnostics

Record query stats with bounded cohort labels such as `tenant:acme` or
`tier:enterprise`:

```sql
SELECT pgcontext.record_query_stat(
  'docs',
  'tenant:acme',
  'search_filtered',
  10,
  200,
  12.5
);

SELECT cohort, query_kind, avg_latency_ms, latency_bucket, total_candidates
FROM pgcontext.query_cohort_stats()
WHERE collection_name = 'docs'
ORDER BY avg_latency_ms DESC;
```

Use these rows to identify tenants with larger candidate sets, slower latency
buckets, or degraded recall before changing global collection limits.
