---
adr: 005
title: "Multi-tenant vs multi-corpus single-tenant — caminho de evolução"
status: Proposed
date: 2026-05-17
owner: sa-principal
related_phase: Fase 5 (multi-corpus) e Fase 6 (multi-tenant opcional)
---

# ADR-005 — Multi-tenant vs multi-corpus single-tenant

## Status

Proposed.

## Context

A visão de longo prazo do brief lista "multi-tenant/multi-corpus". Atualmente o sistema é **single-corpus, single-tenant**:

- Toda tabela de retrieval (`documents`, `chunks`, `entities`, `entity_mentions`) referencia o corpus implícito war.gov/ufo.
- `wiki/` é um único namespace; paths `wiki/documents/<doc-id>.md` colidem se onboardarmos JFK files ou Operação Prato BR.
- `chat_sessions.user_id` aponta `auth.users(id)` direto; sem `tenant_id`.
- RLS policy é `USING (TRUE)` — qualquer signed-up user vê tudo.
- Frontend roteia `/d/<doc-id>` assumindo corpus único.

A diferença semântica importa:

- **Multi-corpus** = vários corpora coexistem mesma equipe os opera (war.gov/ufo + JFK + BR). Visibilidade pode variar (`public` / `unlisted` / `private`).
- **Multi-tenant** = vários **operadores independentes** (organizações, indivíduos) com isolamento de dados, billing, gestão. Persona D do entregável 03.

## Opções consideradas

### Opção A — Pular direto para multi-tenant verdadeiro

- Refactor pesado: `tenant_id` em todas as tabelas; RLS por tenant; signup com tenant; billing.
- **Custo:** 18-25d.
- **Veto Cagan:** prematuro. Não temos demanda nem validação. Persona D é latente.

### Opção B — Multi-corpus single-tenant primeiro; multi-tenant só se houver sinal

- Adicionar `corpus_id` em `documents`/`chunks`/`entities`. Tabela `corpora` controla visibilidade.
- Single-tenant = a equipe do Bureau opera tudo. Usuários signed-up só leem.
- Multi-tenant fica para fase 6, **opcional**, ativado por flag.
- **Custo:** 14d (fase 5) + 8-15d futuro condicional.
- **Prós:** entrega valor real (3 corpora visíveis); reabre multi-tenant só com sinal.
- **Contras:** schema decision não totalmente future-proof — algumas tabelas precisarão de `tenant_id` extra na fase 6.

### Opção C — Deploy por corpus (replicar a stack)

- Cada corpus é um deploy separado (`disclosure.top`, `jfk.disclosure.top`, `prato.disclosure.top`).
- **Custo:** 4d por deploy.
- **Prós:** zero refactor de schema.
- **Contras:** **viola "single pane of glass"**; usuário não pode pesquisar cross-corpus; UX fragmentada; ops complexa (3x containers, 3x backups).

## Decisão

**Adotar Opção B — multi-corpus single-tenant na Fase 5; multi-tenant na Fase 6 só se Persona D mostrar sinal.**

Justificativas:

1. **Cagan:** valida hipótese com sinal mínimo. Multi-corpus desbloqueia Persona D-light (a equipe do Bureau adicionando corpora curados); multi-tenant verdadeiro requer demanda externa real.
2. **Fowler:** arquitetura evolutiva — não over-engineer. `corpus_id` é refactor barato; `tenant_id` adicionado depois é refactor adicional barato. Não há "muralha" entre B e Multi-tenant na fase 6.
3. **Larson:** decisão escrita. Reabre com critério: ≥3 organizações externas pedindo onboard antes de F6 expandir para multi-tenant.

## Implementação — Fase 5 (multi-corpus single-tenant)

### Schema

```sql
-- migration 0005_multicorpus.sql

CREATE TABLE public.corpora (
  corpus_id    TEXT PRIMARY KEY,           -- 'war-gov-ufo', 'jfk-files', 'prato-1977'
  display_name TEXT NOT NULL,
  description  TEXT,
  language_primary TEXT NOT NULL DEFAULT 'en',  -- 'en' | 'pt'
  visibility   TEXT NOT NULL DEFAULT 'public'
               CHECK (visibility IN ('public','unlisted','private')),
  cover_image  TEXT,
  doc_count    INT NOT NULL DEFAULT 0,
  created_at   TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Seed do corpus existente
INSERT INTO public.corpora (corpus_id, display_name, language_primary, visibility)
VALUES ('war-gov-ufo', 'War.gov/UFO — DoD UAP Declassified', 'en', 'public');

-- Adiciona FK em documents
ALTER TABLE public.documents
  ADD COLUMN corpus_id TEXT NOT NULL DEFAULT 'war-gov-ufo'
    REFERENCES public.corpora(corpus_id) ON DELETE RESTRICT;
ALTER TABLE public.documents ALTER COLUMN corpus_id DROP DEFAULT;

-- Idem chunks, entities, entity_mentions (via documents.corpus_id derivado ou denormalizado)
-- Para queries grandes, denormalizar é melhor:
ALTER TABLE public.chunks
  ADD COLUMN corpus_id TEXT NOT NULL DEFAULT 'war-gov-ufo';
ALTER TABLE public.entities
  ADD COLUMN corpus_id TEXT NOT NULL DEFAULT 'war-gov-ufo';
-- Drop defaults após backfill

-- Update RLS
DROP POLICY documents_read ON public.documents;
CREATE POLICY documents_read ON public.documents FOR SELECT USING (
  corpus_id IN (
    SELECT corpus_id FROM public.corpora WHERE visibility = 'public'
  )
);
-- Idem chunks, entities, mentions
```

### Paths do `wiki/`

**Decisão dentro do ADR:** manter `wiki/` em raiz, identificar corpus via DB. Os paths físicos seguem `wiki/<corpus_id>/documents/...` mas com **link simbólico** `wiki/documents` → `wiki/war-gov-ufo/documents` por compat.

Trade-off: refactor de paths impacta `web/lib/wiki.ts` e `next.config.ts outputFileTracingIncludes`. Migration script `scripts/maintain/45_relayout_wiki.py` faz a movimentação atômica + cria os symlinks.

Para corpora futuros (JFK, Prato), paths já nascem `wiki/<corpus_id>/...`.

### Frontend

- Switcher de corpus na navbar: `<select>` listando corpora `public`. Estado em cookie `corpus_id`.
- Rotas: `/c/<corpus_id>` (overview do corpus); `/d/<doc-id>` mantém-se mas exibe `corpus_id` como breadcrumb.
- Search global tem checkbox "incluir todos os corpora" (default off — busca só no current).
- Chat session vincula `corpus_id` (adicionar coluna em `chat_sessions`).

### Onboard de corpora-piloto

Para validar:

1. **JFK files** — corpus pequeno (~50 docs), alta visibilidade, valida pipeline single-language EN.
2. **Operação Prato 1977** — corpus médio (~30 docs), valida bilíngue real (PT-BR como primary).

Cada onboard é um run da pipeline `scripts/ingest/*` com `--corpus-id <id>`. Validação: hybrid search funciona scoped ao corpus.

## Implementação — Fase 6 (multi-tenant verdadeiro, opcional)

**Critério para abrir:** ≥3 organizações externas distintas pedindo onboard com isolamento.

Adições:

```sql
ALTER TABLE public.corpora
  ADD COLUMN owner_org_id UUID REFERENCES public.organizations(org_id);

CREATE TABLE public.organizations (
  org_id     UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name       TEXT NOT NULL,
  plan       TEXT NOT NULL DEFAULT 'free' CHECK (plan IN ('free','indie','team','enterprise')),
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE public.memberships (
  user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
  org_id  UUID NOT NULL REFERENCES public.organizations(org_id) ON DELETE CASCADE,
  role    TEXT NOT NULL CHECK (role IN ('owner','admin','editor','viewer')),
  PRIMARY KEY (user_id, org_id)
);

-- RLS adicional
CREATE POLICY documents_private_read ON public.documents FOR SELECT USING (
  corpus_id IN (
    SELECT c.corpus_id FROM public.corpora c
    LEFT JOIN public.memberships m ON c.owner_org_id = m.org_id
    WHERE c.visibility = 'public'
       OR (c.visibility IN ('unlisted','private') AND m.user_id = auth.uid())
  )
);
```

Billing fica fora do escopo (decisão de produto, não de arquitetura). Stripe self-host via webhooks pode ser adicionado sem refactor de schema.

## Consequences

### Positivas

- Multi-corpus desbloqueia Persona D-light + roadmap (JFK, Prato, etc.).
- API REST e MCP server (Fase 5) se beneficiam — `?corpus_id=` filter natural.
- Caminho gradual para multi-tenant verdadeiro sem retrabalho.

### Negativas

- Refactor de paths `wiki/` é tocar muita coisa (lib/wiki.ts, next.config.ts, scripts).
- RLS policies ficam um pouco mais complexas — precisa de testes.
- Switcher de corpus na UI = novo state model no frontend.

### Neutras

- Mantém VPS Docker Compose; não vai pra Kubernetes.
- Bilíngue stack já pronto (cada corpus tem `language_primary`, mas chunks já são EN+PT).

## Validação

- Migration runs em <5min em staging.
- 3 corpora visíveis na navbar; switcher troca contexto.
- Hybrid search scoped ao corpus selecionado por default; checkbox "todos" funciona.
- RLS test: usuário sem membership em corpus `private` recebe 0 rows.
- Onboard de JFK: pipeline `scripts/ingest/*` com `--corpus-id jfk-files` produz `wiki/jfk-files/...` e popula DB sem afetar war-gov-ufo.

## Risco de abertura externa (relevante para fase 6)

Multi-tenant traz riscos novos:

- **PII em corpora privados** — política de retenção, compliance LGPD/GDPR.
- **Custo de LLM por tenant** — quem paga o enrichment? OAuth quota não cobre N tenants.
- **Abuse**: tenant tóxico envenenando corpus público (poisoning).

Esses ficam fora do escopo do ADR-005; serão tratados em ADR futuros se a fase 6 abrir.

## Referências

- Gap "multi-tenant/multi-corpus" do brief (visão longo prazo).
- Persona D do entregável 03.
- `/Users/guto/ufo/infra/supabase/migrations/0002_chunks_retrieval.sql` (schema atual single-corpus).
