Pular para o conteúdo

0001 — Multi-Consultancy Foundation (3 níveis de tenancy)

Status

Accepted · 2026-04-26 · Autor: Alexandre Calmon + Claude (sessão de geração do docs/bootstrap-archive/).

Este ADR é fundacional. Toda decisão arquitetural posterior assume os princípios aqui estabelecidos.

Contexto

O v1 do KmGestao começou com modelo de tenancy em 2 níveis: superadmin global (Karla — acessava tudo cross-clinic) + clinic (cliente final). Funcionou pra primeira fase mas tem 3 limitações fundamentais pra escala:

  1. Não escala pra múltiplos consultores — “fazer Karla virar 5 superadmins” significaria todos verem dados de todos
  2. Modelo de affiliate impossível — sem camada consultancy separada, attribution e revenue share são gambiarra
  3. is_superadmin global mistura privilégios — quem configura plataforma também vê dados de paciente; LGPD problemática

Decisão estratégica do Alexandre (2026-04-26): plataforma deve ser multi-consultancy nativa desde o dia 1, com Karla virando “primeira consultoria parceira” e Alexandre virando platform_admin único.

Decisão

Adotamos tenancy em 3 níveis desde a primeira migration:

PLATFORM_ADMIN (Alexandre)
↓ owns
CONSULTANCY (N — Karla + futuros)
↓ owns / serves (3 modos)
CLINIC (N — clientes finais)
↓ has (PII cifrada)
PATIENT

1. Helpers RLS tipados (5 helpers fundamentais)

Substituem o antigo is_superadmin(). Nunca usar is_superadmin no v2.

  • is_platform_admin() — só pra tabelas de configuração, métricas agregadas, audit logs
  • is_consultancy_member(consultancy_id) — só pra tabelas próprias da consultoria
  • consultancy_can_view_clinic(consultancy_id, clinic_id) — pra tabelas operacionais de clínicas (sem PII clínica)
  • consultancy_has_data_access_to(clinic_id) — pra tabelas operacionais COM PII clínica (respeita consent)
  • is_clinic_member(clinic_id) — pra tabelas próprias da clínica

Todos stable security definer set search_path = public.

2. Schema fundacional

-- Camada Consultancy
CREATE TABLE public.consultancies (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
legal_name TEXT NOT NULL,
trade_name TEXT,
cnpj TEXT UNIQUE,
email TEXT NOT NULL,
phone TEXT,
address JSONB DEFAULT '{}',
status TEXT NOT NULL DEFAULT 'active'
CHECK (status IN ('active', 'suspended', 'archived')),
-- Credenciamento (regra: só por convite, não auto-cadastro)
credentialed_status TEXT NOT NULL DEFAULT 'pending_invitation'
CHECK (credentialed_status IN ('pending_invitation', 'invited',
'pending_approval', 'credentialed',
'suspended', 'revoked')),
credentialed_at TIMESTAMPTZ,
credentialed_revoked_at TIMESTAMPTZ,
credentialed_revoked_reason TEXT,
-- Marketplace
public_slug TEXT UNIQUE,
public_profile JSONB DEFAULT '{}',
accepts_new_clinics BOOLEAN NOT NULL DEFAULT TRUE,
-- Branding (Ideia 2)
branding JSONB DEFAULT '{}',
-- White-label F1.5+
subdomain_active BOOLEAN NOT NULL DEFAULT FALSE,
custom_domain TEXT UNIQUE,
-- Billing B2B (Ideia 1)
platform_billing_state TEXT NOT NULL DEFAULT 'current'
CHECK (platform_billing_state IN ('current', 'past_due',
'suspended_for_billing', 'revoked')),
-- Consent em camadas (ADR 0009)
data_sharing_with_platform TEXT NOT NULL DEFAULT 'denied'
CHECK (data_sharing_with_platform IN ('granted', 'denied')),
data_sharing_granted_at TIMESTAMPTZ,
data_sharing_granted_by_user_id UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ
);
-- Membros de uma consultoria (n:n com auth.users)
CREATE TABLE public.consultancy_members (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
consultancy_id UUID NOT NULL REFERENCES public.consultancies(id),
user_id UUID NOT NULL REFERENCES auth.users(id),
role_key TEXT NOT NULL CHECK (role_key IN ('consultancy_owner',
'consultancy_admin',
'consultancy_analyst')),
status TEXT NOT NULL DEFAULT 'active'
CHECK (status IN ('invited', 'active', 'suspended', 'left')),
invited_at TIMESTAMPTZ,
accepted_at TIMESTAMPTZ,
permissions_override JSONB DEFAULT '[]',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
UNIQUE (consultancy_id, user_id)
);
-- Camada Clinic (pode ser direta com plataforma, sem consultor)
CREATE TABLE public.clinics (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
legal_name TEXT NOT NULL,
trade_name TEXT,
cnpj TEXT UNIQUE,
email TEXT NOT NULL,
phone TEXT,
address JSONB DEFAULT '{}',
-- Tenancy (Ideias 1+2)
consultancy_id UUID REFERENCES public.consultancies(id), -- NULLABLE
is_direct_with_platform BOOLEAN NOT NULL DEFAULT FALSE,
CHECK (
(consultancy_id IS NOT NULL AND is_direct_with_platform = FALSE) OR
(consultancy_id IS NULL AND is_direct_with_platform = TRUE)
),
-- Attribution (Ideia 1) — IMUTÁVEL após primeiro set
attribution_type TEXT NOT NULL DEFAULT 'platform_direct'
CHECK (attribution_type IN ('consultancy_acquisition',
'marketplace_acquisition',
'platform_direct')),
attribution_consultancy_id UUID REFERENCES public.consultancies(id),
attribution_set_at TIMESTAMPTZ,
current_consultancy_id UUID REFERENCES public.consultancies(id),
-- Engagement state
current_engagement_status TEXT NOT NULL DEFAULT 'no_consultancy'
CHECK (current_engagement_status IN ('no_consultancy', 'active',
'cancellation_in_progress',
'cancellation_agreed',
'transitioning')),
-- Billing (Ideia 1)
billing_via TEXT NOT NULL DEFAULT 'pending'
CHECK (billing_via IN ('pending', 'consultancy_asaas',
'platform_fallback', 'platform_direct')),
billing_via_changed_at TIMESTAMPTZ,
current_asaas_subscription_id TEXT,
current_asaas_account TEXT CHECK (current_asaas_account IN ('consultor', 'plataforma')),
platform_subscription_state TEXT NOT NULL DEFAULT 'pending_first_payment'
CHECK (platform_subscription_state IN ('pending_first_payment',
'trialing', 'active',
'past_due', 'suspended',
'cancelled')),
-- White-label (Ideia 2)
public_slug TEXT UNIQUE,
subdomain_active BOOLEAN NOT NULL DEFAULT FALSE,
custom_domain TEXT UNIQUE,
-- Branding
branding JSONB DEFAULT '{}',
-- Programa Acolhe (Ideia 4)
social_program_status TEXT NOT NULL DEFAULT 'active'
CHECK (social_program_status IN ('active', 'opted_out_paying_full',
'suspended_for_compliance')),
social_program_joined_at TIMESTAMPTZ DEFAULT NOW(),
social_program_total_attendances INT DEFAULT 0,
-- Consent (ADR 0009)
data_sharing_with_consultancy TEXT NOT NULL DEFAULT 'denied'
CHECK (data_sharing_with_consultancy IN ('granted', 'denied')),
data_sharing_granted_at TIMESTAMPTZ,
data_sharing_granted_by_user_id UUID,
data_sharing_categories JSONB, -- preparação F2 (granular)
status TEXT NOT NULL DEFAULT 'active'
CHECK (status IN ('active', 'suspended', 'archived')),
provisioned_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ
);
-- Membros de uma clínica
CREATE TABLE public.clinic_members (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
clinic_id UUID NOT NULL REFERENCES public.clinics(id),
user_id UUID NOT NULL REFERENCES auth.users(id),
role_key TEXT NOT NULL CHECK (role_key IN ('clinic_owner', 'clinic_admin', 'clinic_staff')),
status TEXT NOT NULL DEFAULT 'active'
CHECK (status IN ('invited', 'active', 'suspended', 'left')),
permissions_override JSONB DEFAULT '[]',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
accepted_at TIMESTAMPTZ,
deleted_at TIMESTAMPTZ,
UNIQUE (clinic_id, user_id)
);
-- Trigger imutabilidade de attribution
CREATE OR REPLACE FUNCTION public.prevent_attribution_mutation()
RETURNS TRIGGER LANGUAGE plpgsql AS $$
BEGIN
IF OLD.attribution_consultancy_id IS NOT NULL
AND NEW.attribution_consultancy_id IS DISTINCT FROM OLD.attribution_consultancy_id THEN
RAISE EXCEPTION 'attribution_consultancy_id é IMUTÁVEL após primeiro set (regra inviolável ADR 0001)';
END IF;
IF OLD.attribution_type != 'platform_direct'
AND NEW.attribution_type != OLD.attribution_type THEN
RAISE EXCEPTION 'attribution_type é IMUTÁVEL após primeiro set (regra inviolável ADR 0001)';
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER prevent_attribution_mutation_trigger
BEFORE UPDATE OF attribution_consultancy_id, attribution_type ON public.clinics
FOR EACH ROW EXECUTE FUNCTION public.prevent_attribution_mutation();

3. Custom Access Token Hook

Hook Postgres public.custom_access_token_hook(event jsonb) RETURNS jsonb injeta no JWT:

  • is_platform_admin (boolean)
  • consultancy_ids (uuid[])
  • current_consultancy_id (uuid | null)
  • consultancy_role (text | null)
  • clinic_ids (uuid[])
  • current_clinic_id (uuid | null)
  • role_key (text | null)
  • permissions (text[])
  • current_clinic_subscription_status (text | null)
  • aal (already provided by Supabase)

SECURITY DEFINER + set search_path = public. Acessível APENAS por supabase_auth_admin (não authenticated/anon/public — escalation se tivessem).

4. Princípio “no-spam B2B” (busca unidirecional)

Regra inviolável: clínica busca consultor; consultor nunca busca clínica.

  • Marketplace /consultores (público + autenticado por clínica órfã) → existe
  • “Marketplace de clínicas órfãs pra consultor escolher” → NÃO EXISTE. Lint lint:no-spam-b2b proíbe rotas com pattern /consultancy/(marketplace|search|browse)-clinic*

Helper consultancy_can_view_clinic(consultancy_id, clinic_id) retorna false para clínicas que não estão em current_consultancy_id ou attribution_consultancy_id.

5. Attribution diferenciada (3 modos)

who_receives_commission(clinic_id) aplica regra:

attribution_typeQuem recebe 50% comissão
consultancy_acquisitionattribution_consultancy_id permanente (mesmo se trocar consultor)
marketplace_acquisitioncurrent_consultancy_id flutuante (segue quem atende)
platform_directNULL — plataforma fica com 100%

Detalhes em ADR 0008.

Consequências

Positivas

  • Escalabilidade comercial: plataforma pode ter centenas de consultores parceiros
  • LGPD-friendly: platform_admin sem PII operacional reduz superfície de risco
  • Modelo de affiliate viável: revenue share + attribution diferenciada
  • Confiança comercial: consultor sabe que dono da plataforma não vê seus clientes
  • Curadoria controlada: credenciamento por convite (não auto-cadastro)
  • Escalável globalmente: mesma estrutura replicável internacionalmente

Negativas / custos

  • Schema mais complexo que tenancy 2 níveis (mais 5 tabelas, 8+ helpers)
  • Edge functions com check adicional (consultancy_can_view_clinic antes de qualquer mutação)
  • Trigger de imutabilidade adiciona overhead de validação em UPDATEs

Riscos

  • Vazamento entre tenants: pior bug possível. Mitigação: cobertura RLS testada com 2 consultorias seed; cada query validada com 2 usuários de consultorias diferentes
  • Bug em helper RLS expõe dados: consultancy_can_view_clinic errado bypassa boundary. Mitigação: tests E2E rigorosos; review obrigatório de qualquer mudança em helpers RLS
  • Edge function esquecida sem check: bug silencioso. Mitigação: lint lint:consultancy-scope + test que verifica que toda função de mutação valida membership

Alternativas consideradas

1. Manter tenancy 2 níveis (status quo do v1)

Múltiplos is_superadmin = true cada um vendo “suas” clínicas via filtro na query.

  • Prós: zero migração de schema
  • Contras: filtro na query é mascarável (qualquer dev novo, bug ou edge function ignora e expõe tudo). RLS é única defesa real em depth. Rejeitado — viola princípio defense in depth.

2. Tenancy 4 níveis (platform → org → consultancy → clinic)

Adiciona camada org (organização que detém múltiplas consultorias).

  • Prós: flexibilidade para “consultoria-mãe” tipo holding
  • Contras: caso de uso real raro; complica RLS sem benefício; YAGNI
  • Rejeitado — over-engineering.

3. Multi-consultancy n:n (clínica pode ter múltiplos consultores)

Tabela consultancies_clinics n:n.

  • Prós: flexibilidade pra “trocar de consultor” e “co-consultoria”
  • Contras: complica RLS (qual consultor cobra?), complica billing (split entre consultores?), complica contratos (qual signatário?). “Trocar de consultor” é raro e pode ser resolvido como transferência (UPDATE de current_consultancy_id registrado em audit + clinic_consultancy_engagements).
  • Rejeitado — over-engineering.

Atualizações de documentos exigidas neste ADR

  • AGENTS.md §2 (stack), §5 (regras invioláveis 5.2.1-5.2.5), §7 (inventário)
  • BLUEPRINT.md §1 (diagrama tenancy), §3 (camadas de segurança), §6 (princípios)
  • SISTEMA-DOCUMENTACAO.md §1 (catálogo)
  • Glossário (termos platform_admin, consultancy, clinic, helpers RLS)

Referências

  • ADR 0008 (modelo financeiro com attribution)
  • ADR 0009 (consent em camadas)
  • ADR 0010 (marketplace + no-spam B2B)
  • AGENTS.md §5.2 (regras de tenancy)
  • BLUEPRINT.md §1