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:
- Não escala pra múltiplos consultores — “fazer Karla virar 5 superadmins” significaria todos verem dados de todos
- Modelo de affiliate impossível — sem camada
consultancyseparada, attribution e revenue share são gambiarra is_superadminglobal 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) ↓ ownsCONSULTANCY (N — Karla + futuros) ↓ owns / serves (3 modos)CLINIC (N — clientes finais) ↓ has (PII cifrada)PATIENT1. 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 logsis_consultancy_member(consultancy_id)— só pra tabelas próprias da consultoriaconsultancy_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 ConsultancyCREATE 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ínicaCREATE 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 attributionCREATE 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_triggerBEFORE UPDATE OF attribution_consultancy_id, attribution_type ON public.clinicsFOR 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-b2bproí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_type | Quem recebe 50% comissão |
|---|---|
consultancy_acquisition | attribution_consultancy_id permanente (mesmo se trocar consultor) |
marketplace_acquisition | current_consultancy_id flutuante (segue quem atende) |
platform_direct | NULL — plataforma fica com 100% |
Detalhes em ADR 0008.
Consequências
Positivas
- Escalabilidade comercial: plataforma pode ter centenas de consultores parceiros
- LGPD-friendly:
platform_adminsem 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_clinicantes 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_clinicerrado 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_idregistrado 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