SOBERANIA
DE DADOS
Como a Zion opera o CRM de uma casa sem que a base saia do controle dela.
documento de engenharia · não circular com cliente sem revisão
Soberania de dados na Zion — Especificação técnica única
Repo principal: /Users/drm/repositorio/ZION-HOUSE/flow-crm-api (todos os caminhos deste documento são relativos a ele, salvo indicação contrária) Front: /Users/drm/repositorio/ZION-HOUSE/flow-crm Cabeça atual de migration: app/db/migrations/versions/0057_farm_survival.py (verificado) Versão do documento: 1.0 — 05/09/2026
1. O problema em uma página
O que a casa teme
A base de jogadores é o ativo da casa de aposta. Entregá-la a um fornecedor de CRM significa, na prática, entregar uma cópia integral que o fornecedor pode levar embora, vender, ou perder. Nenhuma cláusula contratual resolve isso, porque o risco não é jurídico, é físico: o dado está no banco do outro.
O que precisamos operar mesmo assim
A Zion precisa segmentar, decidir, enviar e medir. Quatro verbos, e apenas um deles (enviar) exige o contato em claro.
Este é o achado que sustenta a arquitetura inteira, e é verificável hoje no código: app/services/lead_segments.py:57-84 opera exclusivamente sobre mrr_cents, withdrawal_cents, bet_volume_cents, ggr_cents, first_deposit_at, last_activity_at, churn_risk e temperatura_atual. Os chips da Segmentação Inteligente idem (app/services/segment_filters.py:62-110). app/api/bi.py e app/api/metrics.py só agregam. O cérebro do CRM já não precisa do contato. Só a ponta de envio precisa.
O que precisamos provar
Três invariantes. Não são slogan: cada uma vira teste automatizado e exercício documentado.
pg_dump+ a credencial do app não abrem contato. A role docrm-apitemkms:Encryptekms:GenerateDataKeyparapurpose=contact, mas não temkms:Decrypt. Quem tem Decrypt não tem credencial de Postgres.- Toda decifra deixa rastro. Existe uma porta única, e ela grava em
pii_access_logdentro da instância do cliente antes de responder. Não existe caminho de leitura de contato que não gere linha de trilha. - O cliente desliga sozinho.
aws kms disable-keyexecutado com a credencial dele para ingestão, materialização de lote, reveal e envio em ≤ 300 s, sem ninguém da Zion no circuito. Segmentação e BI continuam funcionando — é essa assimetria que torna a promessa verificável em vez de retórica.
O tamanho real do problema
Notícia boa: o escopo de PII é três campos — nome, e-mail, telefone. grep -rni 'cpf|documento|birth|nascimento|rg_' app/ devolve um único falso-positivo (a palavra "documento" num comentário de app/services/telegram_mirror.py:89). Não há CPF, não há data de nascimento. Isso torna cifra de coluna um projeto de semanas, não de um ano.
Notícia ruim: hoje o contato vive em claro em oito tabelas, sai por quatro portas de export (uma delas sem senha e sem registro — app/api/dispatches.py:928), viaja reversível em base64 dentro de todo e-mail enviado (app/services/email_unsubscribe.py:46-49) e aparece em claro no CloudWatch em ~10 pontos, apesar de o redator existir pronto e nunca ter sido chamado (app/services/log_redact.py:28-71).
A frase que podemos vender
"A Zion não retém o dado do jogador, decifra somente no instante do disparo, e toda decifra é auditável por você no log da sua própria conta."
Não podemos vender "o dado nunca sai do seu perímetro". O envio on-device monta https://api.whatsapp.com/send?phone={digits}&text={quote(body)} e digita essa URL num cloud phone da GeeLark, provedor fora do Brasil (app/services/dispatch/whatsapp_ondevice.py:216-217). Nenhuma arquitetura de instância dedicada muda isso. Ver seção 11.
2. Arquitetura alvo
2.1 Os dois planos
| Plano de dados | Plano de envio | |
|---|---|---|
| Onde vive | Dentro da zunit (instância do cliente) | Conta da Zion |
| Tem banco | Sim (Postgres da zunit) | Não — boot reprova se DATABASE_URL estiver preenchido |
| Tem contato em claro | Nunca em repouso | Somente em heap, ~400 ms por lote |
| Rotas de leitura | Sim (API do CRM) | Nenhuma — teste no CI varre app.routes |
| Chave KMS | Encrypt, GenerateDataKey (contact) + Decrypt (index) | Decrypt (contact) |
| Persistência | Postgres + S3 | Redis com TTL, appendonly no, save "" |
2.2 Diagrama
┌──────────────────────────────────────────────────────────────────────────────┐
│ PERÍMETRO DO CLIENTE — zunit (1 instância por casa; sem multi-tenant) │
│ Opção A: conta de nuvem do cliente (ele é root) │
│ Opção B: conta dedicada aberta pela Zion (ele tem leitura total + a chave) │
│ │
│ ┌───────────────┐ ┌────────────────┐ ┌──────────────────────────────┐ │
│ │ crm-api │ │ crm-workers │ │ Postgres da zunit │ │
│ │ APP_ROLE=api │◄──►│ APP_ROLE= │◄─►│ ┌──────────────────────────┐ │ │
│ │ │ │ worker │ │ │ leads SEM contato │ │ │
│ │ segmenta │ │ decide │ │ │ player_ref, ggr_cents… │ │ │
│ │ lista/mascara │ │ enfileira │ │ ├──────────────────────────┤ │ │
│ │ audita │ │ EMPACOTA │ │ │ lead_contacts CIFRADO │ │ │
│ └───┬───────┬───┘ └───────┬────────┘ │ │ *_hmac, *_ct, key_ver │ │ │
│ │ │ │ │ ├──────────────────────────┤ │ │
│ │ kms:Encrypt / GenerateDataKey │ │ pii_access_log cadeia │ │ │
│ │ kms:Decrypt SOMENTE purpose=index │ │ de hash no trigger │ │ │
│ │ │ │ │ └──────────────────────────┘ │ │
│ ▼ │ │ └──────────────┬───────────────┘ │
│ ┌────────────────┐ │ │ │
│ │ zunit-vault │ │ ┌──────────────▼───────────────┐ │
│ │ APP_ROLE=vault │ │ │ psql read-only DO CLIENTE │ │
│ │ sem DATABASE_ │ │ │ schema `leitura`, via túnel │ │
│ │ URL, sem disco │ │ │ (sem 5432 exposto) │ │
│ │ rota única: │ │ └──────────────────────────────┘ │
│ │ POST /reveal │ │ │
│ │ kms:Decrypt │ │ │
│ │ (contact) │ │ │
│ └────────────────┘ │ │
└───────────────────────────────┼──────────────────────────────────────────────┘
│ ENVELOPE — o ÚNICO que atravessa a fronteira
│ { dek_wrapped, template_ct, targets[].ct,
│ aad, exp ≤ 900s, sig Ed25519 }
▼
┌─────────────────────────────────────┐
│ fila zion-send-<slug>-<canal> │ SQS, retenção 1800 s,
│ │ SSE-KMS com a CMK do cliente
└─────────────────┬───────────────────┘
┌───────────────────────────────┼──────────────────────────────────────────────┐
│ PERÍMETRO DA ZION — plano de envio efêmero │
│ ▼ │
│ ┌────────────────────────────────────────────────────┐ │
│ │ zion-send APP_ROLE=sender │ kms:Decrypt(contact)│
│ │ SEM DATABASE_URL (guard de boot) │ DEK TTL 300 s ou 0 │
│ │ SEM rota de leitura (test_no_read_routes) │ 1 recibo por lote │
│ │ rootfs ro · /tmp tmpfs · sem swap · RLIMIT_CORE=0 │ │
│ └──┬──────────────┬───────────────┬──────────────────┘ │
│ ▼ ▼ ▼ │
│ SES/Resend/ UniPix Evolution VPS / GeeLark (cloud phone) │
│ Brevo/SMTP (SMS) └─ telefone + texto em querystring ◄── LIMITE │
│ │ │ │ │
│ └──────────────┴───────────────┘ │
│ │ RECIBO — nunca contato, nunca corpo │
└────────────────────┼─────────────────────────────────────────────────────────┘
▼ fila zion-send-cb-<slug>
volta ao crm-api: status por dispatch_id, provider_message_id, body_digest,
recibos de decifragem (cadeia própria), eventos de provedor (allowlist),
inbound cifrado com a chave pública do cliente
2.3 O que trafega entre os planos
Ida (envelope): ciphertext do contato, ciphertext do template, DEK envelopada pela CMK do cliente, aad amarrando cliente+lote+canal+expiração, exp ≤ 900 s, assinatura Ed25519. Nenhum valor legível.
Volta (recibo): dispatch_id (ponteiro opaco que o CRM já tem), estado, provider_message_id, body_digest (sha256 do texto real — prova do que saiu sem guardar o que saiu), recibos de decifragem encadeados por hash, eventos de provedor filtrados por allowlist, e inbound cifrado.
Allowlist do evento de provedor (qualquer outro campo é descartado no adapter, antes de virar recibo): provider, provider_message_id, event, at, bounce_type, complaint_type, smtp_code, error_code
Proibidos por teste automatizado no recibo: to, recipient, destination, subject, body, text, html, headers, numero, phone, jid, notifyName
2.4 Decisões de arquitetura tomadas neste documento
As quatro frentes de desenho divergiam em pontos concretos. Ficam resolvidas assim — não rediscutir na implementação:
| # | Divergência | Decisão | Por quê |
|---|---|---|---|
| D1 | Onde decifra: cofre dentro da instância vs. serviço de envio na Zion | Ambos, com papéis distintos. zion-send (Zion, sem banco) decifra para disparo. zunit-vault (dentro da zunit, sem banco) decifra 1 registro para a tela, exigindo motivo. | Disparo é do plano de envio, por decisão do dono. Reveal de tela é operação do cliente e não deve sair do perímetro dele. |
| D2 | Onde mora o ciphertext do contato | Tabela separada lead_contacts, 1:1 com leads. | Permite REVOKE SELECT ON lead_contacts FROM zunit_ro_cliente (ele vê a base comportamental inteira, não o cofre), purge em um lugar só, e pg_dump de leads limpo. |
| D3 | Escopo da DEK: por versão ou por lote | Três chaves derivadas da mesma CMK. DEK-contato: versionada, para lead_contacts.*_ct. PIK: chave de HMAC do índice cego. DEK-envelope: efêmera, uma por lote, para template_ct/vars_ct. | DEK por lote em repouso obrigaria re-cifrar a base a cada rotação. DEK por destinatário estoura quota do KMS (500 mil GenerateDataKey/dia). |
| D4 | Uma trilha ou várias | pii_access_log é a trilha única de acesso a PII, com cadeia de hash calculada no trigger do Postgres. Absorve os "recibos de decifragem" do plano de envio como linhas action='dispatch.materialize', guardando também a cadeia do emissor. activity_log operacional continua existindo, mas passa a escrever local. | Duas cadeias independentes (banco + emissor) tornam remoção detectável nos dois lados. |
| D5 | Numeração de migrations | Sequência única 0058 → 0067, definida na seção 4.6. | Quatro frentes propunham 0058 simultaneamente. |
| D6 | Nome da unidade | zunit = uma instância dedicada. zunit_id = slug (zu-betx). | Vocabulário único em código, IaC, manifesto e contrato. |
3. As duas opções de hospedagem
O mesmo módulo Terraform, a mesma imagem Docker e o mesmo manifesto rodam nas duas. A diferença é onde a conta está e quem detém quais permissões IAM.
3.1 Opção A — na conta de nuvem do cliente
Provisionamento sem chave estática. O cliente cria na conta dele uma role ZionZunitProvisioner com trust em token.actions.githubusercontent.com, condição sub = repo:ZION-HOUSE/zion-zunit:ref:refs/heads/main, ExternalId combinado, e permissão limitada por tag zunit=<slug> e por região. A Zion entrega o template. Nenhuma credencial de longa duração troca de mãos. O cliente apaga a role quando quiser: o provisionamento da Zion morre na hora, a instância rodando não é afetada.
| Aspecto | Como fica |
|---|---|
| CMK | Na conta dele. A Zion tem, no máximo, grant de Decrypt para o principal do zion-send. Nunca GetKeyPolicy, nunca export. |
pg_dump / backup | Dele. Ele é root do backup. Esta é a razão de A ser defensável mesmo antes da cifra de coluna estar pronta. |
| Trilha de auditoria | AUDIT_SINK=local — grava no Postgres dele. Espelho para o zion-core só com ZION_AUDIT_MIRROR=true, opt-in, e só metadado. |
| CloudTrail | Dele. Cada kms:Decrypt do zion-send aparece no log da conta dele. É a prova mais forte que existe e não depende da palavra da Zion. |
| Deploy | zunit-agent em pull: a instância busca o manifesto assinado, verifica com cosign e se atualiza. A Zion nunca entra. |
| Acesso humano da Zion | Não existe. Só quebra-vidro aprovado por ele, com TTL e pgaudit.log=all. |
| Custo de nuvem | Dele. É a opção mais barata para a Zion e a mais forte de segurança — deve ser o padrão comercial. |
3.2 Opção B — conta dedicada aberta pela Zion
Uma conta AWS por cliente, dentro da Organization, na OU zunits/. Uma conta por cliente é o que dá blast radius isolado, faturamento separado e CloudTrail que a operação da instância não consegue desligar.
SCP obrigatória na OU (é ela que torna B defensável — a Zion se amarra por política, e a amarra é auditável pelo cliente):
Deny aws:RequestedRegion != <regiao contratada>
Deny cloudtrail:StopLogging, cloudtrail:DeleteTrail
Deny s3:PutBucketPolicy no bucket de backup
Deny s3:DeleteObjectVersion no bucket de backup
Deny rds:ModifyDBSnapshotAttribute # fecha exfiltração silenciosa por snapshot cross-account
Deny iam:CreateAccessKey # zero credencial estática de longa duração
Deny kms:ScheduleKeyDeletion sobre a CMK # só o principal do cliente pode
CloudTrail organizacional entrega numa conta de auditoria isolada e numa cópia no bucket do cliente.
| Aspecto | Como fica |
|---|---|
| CMK | Na conta da Zion, mas com key policy dando ao principal do cliente DisableKey, ScheduleKeyDeletion, PutKeyPolicy, ListGrants. É essa linha da policy, e só ela, que é a chave de desligamento. O botão do painel apenas chama a API com a credencial dele. |
pg_dump / backup | Da Zion. Este é o ponto fraco de B. Mitigação obrigatória: backup cifrado com a CMK do cliente, sem kms:Decrypt para nenhum principal da Zion — o backup existe, a Zion não abre. |
| Leitura do cliente | Role zunit_ro_cliente sobre o schema leitura, via túnel, default_transaction_read_only=on, statement_timeout=120s, CONNECTION LIMIT 5. |
| Trilha | Idem A: local, mais âncora de checkpoint em bucket com Object Lock na conta do cliente. |
| Custo direto | R$ 445 a R$ 5.940/mês por instância conforme o porte (seção 8.6). Precisa aparecer na proposta como repasse explícito. |
B sem a cifra de coluna (Fase 2) é indefensável no backup. Enquanto nenhuma coluna for cifrada — hojegrep pgcrypto|pgp_sym|Fernet|AES app/devolve zero fora dolead_export, que cifra apenas o arquivo XLSX de saída — opg_dumpde uma instância na conta da Zion é a base inteira em texto puro numa conta da Zion. Até a Fase 2 fechar: vender A como padrão, e em B entregar o backup cifrado com CMK do cliente (3 dias de trabalho, entra na Fase 6 semana 4).
4. Modelo de dados
4.1 Inventário: onde a PII vive hoje
| Tabela / serviço | Campos | Evidência | Crit. |
|---|---|---|---|
leads (62 mil a 868 mil linhas) | email (unique, index), name, phone_e164 (index), company (o MESMO telefone), notes | app/db/models/lead.py:30-36,57; company recebe o telefone bruto em app/services/dinhu_sync.py:236-239,259. A migration 0028 criou phone_e164 e fez backfill a partir de company, mas não limpou company. | CRÍTICA |
dispatches (1 linha por envio; blast de 500 mil cria 500 mil) | recipient (NOT NULL — e-mail OU telefone em claro), subject, body, provider_raw (JSONB cru) | app/db/models/dispatch.py:82-85,100-102. Sem TTL, sem purge. | CRÍTICA |
conversations | contact_phone (index), contact_name, last_message_preview | app/db/models/conversation.py:54-55,74 | ALTA |
messages | body (NOT NULL), media_url, intent, sentiment | app/db/models/message.py:37-39,51-52 | ALTA |
whatsapp_threads | phone_e164, last_message_preview | app/db/models/whatsapp_thread.py:31,35 | ALTA |
whatsapp_messages | body (Text NOT NULL) | app/db/models/whatsapp_message.py:28,37 | ALTA |
email_suppressions | email É A PRIMARY KEY, texto puro; raw_event guarda o payload cru do Resend | app/db/models/email_suppression.py:36,43; app/api/webhooks/resend.py:160,203 | ALTA |
phone_suppressions | phone_e164 É A PRIMARY KEY; raw_event com o texto do STOP | app/db/models/phone_suppression.py:37,49; app/api/webhooks/evolution.py:307 | ALTA |
telegram_cadastro_leads | telegram_id, username, first_name | app/db/models/telegram_cadastro.py:22-35 | MÉDIA |
whatsapp_warmup_anchors | phone_e164 (unique) | app/db/models/whatsapp_warmup_anchor.py:27 | MÉDIA |
whatsapp_instances / _provisioning_jobs | números dos chips da Zion | app/db/models/whatsapp_instance.py:40; ..._provisioning_job.py:42 | MÉDIA-BAIXA |
journey_events.payload | reasoning da IA + corpo interpolado com {{name}}/{{email}} | app/api/leads.py:513-529; app/services/journey_engine.py:322-328 | MÉDIA |
dinhu_client._response_cache | TTLCache na heap do worker com respostas cruas da API do Dinhu | app/services/dinhu_client.py:30,154 | MÉDIA |
Banco dinhu (2º database no mesmo Postgres) | snapshots do Supabase dinhutech via anon key com RLS aberta | app/services/snapshot_sync.py:6-7,140-153; app/db/dinhu_session.py:31-34 | MÉDIA |
| Logs CloudWatch | telefone, notifyName, primeiro destinatário do lote SMS, e-mail do operador | app/api/webhooks/evolution.py:289; app/services/unipix.py:109-113; app/services/whatsapp_provisioner.py:306-307; app/services/whatsapp_farm_geelark.py:274; app/api/leads.py:373-379 | ALTA |
zion.activity_log (fora da instância) | user_id, ip, user_agent, payload | app/zion_audit.py:32,88-111,286 | ALTA |
tracking_links | slug opaco + lead_id — sem contato | app/db/models/tracking_link.py:27-35 | BAIXA — é o molde a generalizar |
4.2 Definição operacional de PII neste sistema
PII = nome, e-mail, telefone. Mais o conteúdo de conversa (messages.body, whatsapp_messages.body, previews), que é PII mais sensível que o contato.
Não é PII: mrr_cents, ggr_cents, bet_volume_cents, withdrawal_cents, first_deposit_at, last_activity_at, churn_risk, temperatura_atual, stage, source, player_ref, lead_id, dispatch_id, slug, *_hmac. Tudo isso permanece em claro — é o que faz o CRM continuar funcionando.
4.3 O envelope (formato binário, sem tabela de metadado por linha)
byte 0 versão do formato (0x01)
bytes 1..12 nonce (12 bytes, aleatório por operação)
bytes 13..n ciphertext AES-256-GCM
últimos 16 tag GCM
AAD (não armazenado, reconstruído): f"{player_ref}|{field}|{key_version}"
A key_version fica em coluna própria na mesma linha. Isso permite rotação preguiçosa: a DEK nova vale para escritas novas, leituras antigas resolvem sozinhas pela versão da linha. O AAD amarra o ciphertext ao jogador e ao campo — trocar o ciphertext de um jogador pelo de outro dentro do mesmo banco falha na tag do GCM.
4.4 Índice cego (blind index)
Substitui e-mail/telefone como chave de junção em todos os lugares que hoje comparam valor.
email_hmac = HMAC_SHA256(PIK, b"email:" + canon_email(v)) # 32 bytes
phone_hmac = HMAC_SHA256(PIK, b"phone:" + canon_phone(v)) # 32 bytes
O prefixo de domínio impede que um telefone e um e-mail idênticos como string colidam entre campos.
A PIK (chave de índice) é separada da DEK de propósito: a DEK cifra (reversível, valor alto), a PIK indexa (determinística, valor de dicionário). Telefone brasileiro tem espaço de busca pequeno; se a PIK vazasse, hash de telefone cairia por força bruta em minutos. Por isso ela também vive envelopada pela CMK (purpose=index) e também morre na revogação.
4.5 DDL — tabelas novas e alteradas
0058 — chaveiro e trilha (nenhuma coluna de negócio tocada)
CREATE EXTENSION IF NOT EXISTS pgcrypto; -- primeiro uso de cifra no banco deste repo
CREATE TABLE crypto_keys (
version SMALLINT PRIMARY KEY,
purpose TEXT NOT NULL CHECK (purpose IN ('contact','index')),
provider TEXT NOT NULL DEFAULT 'kms', -- 'kms' | 'local'
kms_key_arn TEXT,
wrapped_dek BYTEA NOT NULL, -- CiphertextBlob do KMS
enc_context JSONB NOT NULL, -- {"purpose":"contact","tenant":"casa-x"}
is_active BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
retired_at TIMESTAMPTZ
);
CREATE UNIQUE INDEX uq_crypto_keys_active ON crypto_keys (purpose) WHERE is_active;
CREATE TABLE pii_access_log (
seq BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
ts TIMESTAMPTZ NOT NULL DEFAULT now(),
zunit_id TEXT NOT NULL,
actor_kind TEXT NOT NULL CHECK (actor_kind IN ('user','service','job','db_role','anon','sender')),
actor_org TEXT NOT NULL CHECK (actor_org IN ('zion','cliente','system')),
actor_id UUID,
actor_email TEXT,
actor_role TEXT,
session_id TEXT,
request_id TEXT,
ip INET,
user_agent TEXT,
action TEXT NOT NULL, -- lead.list | lead.read.reveal | dispatch.materialize
-- | export.requested | export.completed | search_exact
resource TEXT NOT NULL, -- leads | dispatches | suppressions | conversations
fields TEXT[] NOT NULL DEFAULT '{}',
row_count INTEGER NOT NULL DEFAULT 0,
reveal BOOLEAN NOT NULL DEFAULT false,
subject_sample UUID[] NOT NULL DEFAULT '{}', -- até 20 lead_ids: ponteiro opaco, não PII
batch_id UUID,
export_id UUID,
query_fingerprint CHAR(64), -- sha256 dos filtros normalizados
query_params JSONB NOT NULL DEFAULT '{}'::jsonb, -- SEM valores de PII
purpose TEXT NOT NULL DEFAULT 'nao_declarada'
CHECK (purpose IN ('operacao_campanha','atendimento_suporte','conferencia_dados',
'auditoria_interna','exigencia_regulatoria','migracao_cliente',
'nao_declarada')),
purpose_note TEXT,
method TEXT, path TEXT, status_code SMALLINT, duration_ms INTEGER,
severity SMALLINT NOT NULL DEFAULT 0, -- 0 info 1 baixo 2 alto 3 crítico
-- espelho da cadeia do emissor (recibos vindos do zion-send)
sender_node_id TEXT,
sender_seq BIGINT,
sender_prev_hash BYTEA,
sender_hash BYTEA,
held_ms INTEGER, -- quanto tempo o plaintext viveu em memória
-- cadeia local, calculada no trigger
prev_hash BYTEA NOT NULL,
row_hash BYTEA NOT NULL
);
CREATE INDEX idx_pii_ts ON pii_access_log (ts DESC);
CREATE INDEX idx_pii_actor_ts ON pii_access_log (actor_id, ts DESC);
CREATE INDEX idx_pii_action_ts ON pii_access_log (action, ts DESC);
CREATE INDEX idx_pii_sev ON pii_access_log (severity, ts DESC) WHERE severity >= 2;
CREATE INDEX idx_pii_bigread ON pii_access_log (row_count DESC, ts DESC) WHERE row_count >= 1000;
CREATE UNIQUE INDEX uq_pii_sender_seq ON pii_access_log (sender_node_id, sender_seq)
WHERE sender_seq IS NOT NULL;
-- cadeia calculada NO BANCO: a aplicação não escolhe o que assina
CREATE OR REPLACE FUNCTION pii_access_chain() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE v_prev BYTEA; v_canon TEXT;
BEGIN
PERFORM pg_advisory_xact_lock(4820251); -- sem isso duas txns leem o mesmo prev
SELECT row_hash INTO v_prev FROM pii_access_log ORDER BY seq DESC LIMIT 1;
NEW.prev_hash := COALESCE(v_prev, '\x00'::bytea);
v_canon := concat_ws('|',
NEW.seq::text, encode(NEW.prev_hash,'hex'),
to_char(NEW.ts AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS.US'),
NEW.actor_kind, NEW.actor_org, COALESCE(NEW.actor_id::text,''),
COALESCE(NEW.actor_email,''), COALESCE(NEW.actor_role,''),
COALESCE(NEW.session_id,''), COALESCE(NEW.request_id,''), COALESCE(NEW.ip::text,''),
NEW.action, NEW.resource, array_to_string(NEW.fields,','),
NEW.row_count::text, NEW.reveal::text, array_to_string(NEW.subject_sample,','),
COALESCE(NEW.query_fingerprint,''), NEW.query_params::text, NEW.purpose,
COALESCE(NEW.method,''), COALESCE(NEW.path,''), COALESCE(NEW.status_code::text,''),
NEW.severity::text, COALESCE(NEW.export_id::text,''),
COALESCE(encode(NEW.sender_hash,'hex'),''));
NEW.row_hash := digest(v_canon, 'sha256');
PERFORM pg_notify('pii_access', json_build_object(
'seq',NEW.seq,'ts',NEW.ts,'action',NEW.action,'actor',COALESCE(NEW.actor_email,''),
'org',NEW.actor_org,'rows',NEW.row_count,'reveal',NEW.reveal,'severity',NEW.severity)::text);
RETURN NEW;
END $$;
CREATE TRIGGER trg_pii_chain BEFORE INSERT ON pii_access_log
FOR EACH ROW EXECUTE FUNCTION pii_access_chain();
CREATE OR REPLACE FUNCTION pii_access_immutable() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN RAISE EXCEPTION 'pii_access_log e append-only (% negado para %)', TG_OP, current_user; END $$;
CREATE TRIGGER trg_pii_no_change BEFORE UPDATE OR DELETE ON pii_access_log
FOR EACH ROW EXECUTE FUNCTION pii_access_immutable();
CREATE TRIGGER trg_pii_no_truncate BEFORE TRUNCATE ON pii_access_log
FOR EACH STATEMENT EXECUTE FUNCTION pii_access_immutable();
CREATE TABLE pii_access_checkpoint (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
from_seq BIGINT NOT NULL,
to_seq BIGINT NOT NULL,
row_count INTEGER NOT NULL,
head_hash BYTEA NOT NULL,
hmac BYTEA NOT NULL, -- HMAC(head_hash||to_seq, AUDIT_ANCHOR_KEY do cliente)
anchor_target TEXT, -- s3-objectlock | webhook | none
anchor_ref TEXT,
anchored_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX uq_checkpoint_to_seq ON pii_access_checkpoint (to_seq);
Particionarpii_access_logpor mês (PARTITION BY RANGE (ts)) desde o dia 1. Volume estimado: ~700 mil linhas/dia em pico, ~180 MB/dia, ~65 GB/ano por instância. Particionar depois, com a tabela cheia, é migração cara.DROPde partição antiga é a única remoção permitida, feita pelo owner, e gera ela mesma uma linha de auditoria e um checkpoint de fechamento que preserva ohead_hashdescartado.
0059 — o cofre de contato
ALTER TABLE leads
ADD COLUMN player_ref VARCHAR(28), -- 'ZP_' + base32(16B). UNIQUE só após backfill
ADD COLUMN email_domain VARCHAR(255),
ADD COLUMN phone_ddd CHAR(2),
ADD COLUMN contact_last4 CHAR(4),
ADD COLUMN name_initials VARCHAR(4),
ADD COLUMN pii_migrated_at TIMESTAMPTZ;
CREATE TABLE lead_contacts (
lead_id UUID PRIMARY KEY REFERENCES leads(id) ON DELETE CASCADE,
player_ref VARCHAR(28) NOT NULL, -- redundante de propósito: entra no AAD
email_hmac BYTEA,
phone_hmac BYTEA,
email_ct BYTEA, -- nonce(12) || ct || tag(16)
phone_ct BYTEA,
name_ct BYTEA,
key_version SMALLINT NOT NULL REFERENCES crypto_keys(version),
index_key_version SMALLINT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX uq_lead_contacts_email_hmac ON lead_contacts (email_hmac) WHERE email_hmac IS NOT NULL;
CREATE INDEX idx_lead_contacts_phone_hmac ON lead_contacts (phone_hmac) WHERE phone_hmac IS NOT NULL;
O player_ref é aleatório, nunca derivado do e-mail — derivar permitiria ataque de dicionário, já que a base de e-mails brasileira é enumerável.
Campos de busca degradada. email_domain, phone_ddd, contact_last4, name_initials são derivados não reversíveis, gerados na ingestão. Cobrem a busca operacional real (achar o jogador que ligou dizendo o final do número, filtrar por DDD ou por domínio) sem reconstruir o contato.
0060 — a fila deixa de ser armazém
ALTER TABLE dispatches
ADD COLUMN recipient_hmac BYTEA,
ADD COLUMN recipient_kind TEXT CHECK (recipient_kind IN ('email','phone')),
ADD COLUMN recipient_ct BYTEA, -- só em envio avulso (lead_id NULL)
ADD COLUMN recipient_kv SMALLINT,
ADD COLUMN recipient_mask VARCHAR(24), -- 'an***@gmail.com' | '+55 81 ****-8899' — só UI
ADD COLUMN vars_ct BYTEA, -- {"primeiro_nome":"Ana","link_cancelar":"..."} cifrado
ADD COLUMN unsub_slug VARCHAR(32),
ADD COLUMN envelope_id UUID,
ADD COLUMN body_digest CHAR(32), -- sha256 do texto real: prova sem guardar
ADD COLUMN body_is_template BOOLEAN NOT NULL DEFAULT FALSE;
CREATE INDEX CONCURRENTLY idx_dispatch_recipient_hmac ON dispatches (recipient_hmac);
CREATE UNIQUE INDEX CONCURRENTLY idx_dispatch_envelope ON dispatches (envelope_id) WHERE envelope_id IS NOT NULL;
-- o cooldown de WhatsApp precisa deste composto (substitui a varredura por valor):
CREATE INDEX CONCURRENTLY idx_dispatch_wa_cooldown ON dispatches (recipient_hmac, channel, sent_at DESC)
WHERE channel = 'whatsapp';
CREATE INDEX CONCURRENTLY idx_dispatch_created_at ON dispatches (created_at);
ALTER TABLE dispatch_batch_meta
ADD COLUMN kid TEXT,
ADD COLUMN key_ref TEXT,
ADD COLUMN dek_wrapped BYTEA, -- DEK do lote, envelopada pela CMK
ADD COLUMN template_ct BYTEA,
ADD COLUMN template_digest CHAR(32);
Retenção — hoje não existe nenhuma.dispatchescresce ~500 mil linhas/dia;recipient_ct(~64 B) +vars_ct(~120 B) somam ~90 GB/ano. Job diário (arq):UPDATE dispatches SET recipient_ct=NULL, vars_ct=NULL, body=NULL WHERE created_at < now() - interval '180 days' AND recipient_ct IS NOT NULL;Janela de 180 dias precisa de decisão jurídica/comercial (seção 10, D-A8).
0061 — descadastro com ponteiro opaco
CREATE TABLE unsubscribe_tokens (
slug VARCHAR(32) PRIMARY KEY, -- secrets.token_urlsafe(16)
lead_id UUID REFERENCES leads(id) ON DELETE SET NULL,
email_hmac BYTEA NOT NULL,
dispatch_id UUID REFERENCES dispatches(id) ON DELETE SET NULL,
channel TEXT NOT NULL DEFAULT 'email',
used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_unsub_lead ON unsubscribe_tokens (lead_id);
-- purge de tokens > 400 dias no mesmo job de retenção
0062 — supressão por hash (dual-write; item de maior risco)
ALTER TABLE email_suppressions ADD COLUMN email_hmac BYTEA;
ALTER TABLE phone_suppressions ADD COLUMN phone_hmac BYTEA;
-- backfill via aplicação, em chunks (o HMAC precisa da PIK)
CREATE UNIQUE INDEX CONCURRENTLY uq_email_supp_hmac ON email_suppressions (email_hmac);
CREATE UNIQUE INDEX CONCURRENTLY uq_phone_supp_hmac ON phone_suppressions (phone_hmac);
-- raw_event deixa de guardar payload cru (vaza destinatário e assunto)
UPDATE email_suppressions SET raw_event = jsonb_build_object(
'type', raw_event->>'type', 'event_id', raw_event->'data'->>'email_id');
UPDATE phone_suppressions SET raw_event = raw_event - 'text';
-- gate da paridade
CREATE TABLE pii_parity_log (
id BIGSERIAL PRIMARY KEY,
observed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
surface TEXT NOT NULL, -- 'suppression.email' | 'suppression.phone' | 'engagement'
by_value INTEGER NOT NULL,
by_hash INTEGER NOT NULL,
only_value INTEGER NOT NULL, -- divergência: casou por valor e NÃO por hash
only_hash INTEGER NOT NULL,
sample JSONB NOT NULL DEFAULT '{}'::jsonb -- hmacs (hex), NUNCA o valor
);
CREATE INDEX idx_parity_when ON pii_parity_log (observed_at DESC) WHERE only_value > 0 OR only_hash > 0;
0063 — conversas e threads
ALTER TABLE conversations
ADD COLUMN contact_phone_hmac BYTEA,
ADD COLUMN contact_phone_ct BYTEA,
ADD COLUMN contact_kv SMALLINT,
ADD COLUMN contact_name_ct BYTEA;
CREATE INDEX idx_conversations_phone_hmac ON conversations (contact_phone_hmac);
ALTER TABLE whatsapp_threads
ADD COLUMN phone_hmac BYTEA, ADD COLUMN phone_ct BYTEA, ADD COLUMN phone_kv SMALLINT;
CREATE INDEX idx_wa_threads_phone_hmac ON whatsapp_threads (phone_hmac);
-- last_message_preview passa a guardar mask_text() em prod
0064 — alarme, export e idempotência de callback
CREATE TABLE pii_alarm_rule (
code TEXT PRIMARY KEY, enabled BOOLEAN NOT NULL DEFAULT true,
severity SMALLINT NOT NULL DEFAULT 2,
action TEXT NOT NULL DEFAULT 'notify' CHECK (action IN ('notify','throttle','block')),
params JSONB NOT NULL DEFAULT '{}'::jsonb,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_by UUID
);
INSERT INTO pii_alarm_rule (code, severity, action, params) VALUES
('EXPORT_VOLUME', 2,'notify', '{"warn_rows":5000,"block_rows":25000}'),
('BUDGET_24H', 3,'block', '{"soft_rows":8000,"hard_rows":20000}'),
('VARREDURA_PAGINADA', 2,'throttle','{"window_min":15,"min_calls":40,"min_distinct":10000}'),
('FORA_DE_JANELA', 1,'notify', '{"start_hour":22,"end_hour":6,"weekend":true}'),
('ORIGEM_NOVA', 1,'notify', '{"lookback_days":30}'),
('SEM_FINALIDADE', 1,'notify', '{}'),
('SILENCIO_TRILHA', 3,'notify', '{"minutes":10}'),
('CADEIA_QUEBRADA', 3,'block', '{}'),
('MATERIALIZE_ANOMALO',2,'notify', '{"desvio_pct":40,"baseline_dias":7}');
CREATE TABLE pii_alarm (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
rule_code TEXT NOT NULL REFERENCES pii_alarm_rule(code),
severity SMALLINT NOT NULL, actor_id UUID, actor_email TEXT,
window_from TIMESTAMPTZ, window_to TIMESTAMPTZ,
rows_involved INTEGER NOT NULL DEFAULT 0,
evidence JSONB NOT NULL DEFAULT '{}'::jsonb, -- {"seqs":[...], "detail":{...}}
dedup_key TEXT, notified_at TIMESTAMPTZ, notified_channels TEXT[] NOT NULL DEFAULT '{}',
acked_at TIMESTAMPTZ, acked_by UUID, resolved_at TIMESTAMPTZ, resolution TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX uq_pii_alarm_open ON pii_alarm (dedup_key) WHERE resolved_at IS NULL;
CREATE TABLE export_job (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
kind TEXT NOT NULL, -- leads | batch_temperature | suppressions
actor_id UUID NOT NULL, actor_email TEXT NOT NULL,
purpose TEXT NOT NULL, purpose_note TEXT,
params JSONB NOT NULL DEFAULT '{}'::jsonb,
row_count_est INTEGER, row_count INTEGER,
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending','blocked','ready','expired','failed')),
approval_required BOOLEAN NOT NULL DEFAULT false, approved_by UUID, approved_at TIMESTAMPTZ,
file_key TEXT, file_sha256 CHAR(64),
password_read_at TIMESTAMPTZ, -- a senha NÃO é persistida: Redis, leitura única
download_count INTEGER NOT NULL DEFAULT 0, downloaded_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_export_actor ON export_job (actor_id, created_at DESC);
CREATE TABLE send_callback_inbox (
receipt_id UUID PRIMARY KEY,
kind TEXT NOT NULL, -- status | provider_event | decrypt | inbound
received_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
0065 — controle da instância
CREATE TABLE instance_control (
id SMALLINT PRIMARY KEY DEFAULT 1 CHECK (id = 1),
zunit_id TEXT NOT NULL,
hospedagem CHAR(1) NOT NULL CHECK (hospedagem IN ('A','B')),
dispatch_enabled BOOLEAN NOT NULL DEFAULT true, -- N1
zion_access_frozen BOOLEAN NOT NULL DEFAULT false, -- N2
key_disabled_at TIMESTAMPTZ, -- N3 (espelho do KMS)
destroy_requested_at TIMESTAMPTZ, -- N4
destroy_approvals JSONB NOT NULL DEFAULT '[]'::jsonb,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_by TEXT
);
CREATE TABLE breakglass_session (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
requested_by TEXT NOT NULL, requested_at TIMESTAMPTZ NOT NULL DEFAULT now(), reason TEXT NOT NULL,
approved_by TEXT, approved_at TIMESTAMPTZ, expires_at TIMESTAMPTZ, revoked_at TIMESTAMPTZ,
pg_role TEXT, queries_count INTEGER NOT NULL DEFAULT 0, report_url TEXT
);
0066 — papéis, grants e o schema leitura
DO $$ BEGIN CREATE ROLE zunit_ro_cliente LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT;
EXCEPTION WHEN duplicate_object THEN null; END $$;
DO $$ BEGIN CREATE ROLE zunit_auditor NOLOGIN; EXCEPTION WHEN duplicate_object THEN null; END $$;
ALTER ROLE zunit_ro_cliente SET default_transaction_read_only = on;
ALTER ROLE zunit_ro_cliente SET statement_timeout = '120s';
ALTER ROLE zunit_ro_cliente SET idle_in_transaction_session_timeout = '60s';
ALTER ROLE zunit_ro_cliente CONNECTION LIMIT 5;
REVOKE ALL ON SCHEMA public FROM zunit_ro_cliente;
REVOKE ALL ON pg_authid FROM PUBLIC;
-- o app nunca apaga a própria trilha
REVOKE ALL ON pii_access_log FROM PUBLIC;
GRANT SELECT, INSERT ON pii_access_log TO zunit_app;
REVOKE UPDATE, DELETE, TRUNCATE ON pii_access_log FROM zunit_app;
-- o cofre é invisível para a role de leitura
REVOKE SELECT ON lead_contacts FROM zunit_ro_cliente, zunit_auditor;
CREATE SCHEMA IF NOT EXISTS leitura AUTHORIZATION zunit_migrator;
GRANT USAGE ON SCHEMA leitura TO zunit_ro_cliente, zunit_auditor;
CREATE OR REPLACE VIEW leitura.leads AS
SELECT id, player_ref, created_at, updated_at, source, stage, temperatura_atual,
mrr_cents, ggr_cents, bet_volume_cents, withdrawal_cents,
first_deposit_at, last_activity_at, churn_risk,
email_domain, phone_ddd, contact_last4, name_initials
FROM public.leads;
CREATE OR REPLACE VIEW leitura.dispatches AS
SELECT id, lead_id, batch_id, channel, status, created_at, sent_at,
provider_message_id, error_code, recipient_mask, body_digest
FROM public.dispatches;
CREATE OR REPLACE VIEW leitura.trilha_acesso AS
SELECT seq, ts, actor_org, actor_email, actor_role, action, resource, fields,
row_count, reveal, purpose, ip, path, severity, encode(row_hash,'hex') AS hash
FROM public.pii_access_log;
GRANT SELECT ON ALL TABLES IN SCHEMA leitura TO zunit_ro_cliente, zunit_auditor;
ALTER DEFAULT PRIVILEGES FOR ROLE zunit_migrator IN SCHEMA leitura
GRANT SELECT ON TABLES TO zunit_ro_cliente, zunit_auditor;
ALTER SYSTEM SET pgaudit.log = 'read,ddl';
ALTER SYSTEM SET pgaudit.role = 'zunit_ro_cliente';
ALTER SYSTEM SET log_line_prefix = '%m [%p] %u@%d app=%a ';
ALTER TABLE users DROP CONSTRAINT IF EXISTS ck_users_role;
ALTER TABLE users ADD CONSTRAINT ck_users_role
CHECK (role IN ('admin','manager','ops','auditor','cliente_admin','pending'));
O schemaleituraé o contrato estável. Ele existe justamente para que a Fase 2 possa trocarleads.emailpor coluna cifrada sem quebrar o BI do cliente. Nenhuma view deleituraexpõelead_contacts.
0067 — corte das colunas em claro (só após paridade verde)
ALTER TABLE leads DROP COLUMN email, DROP COLUMN phone_e164, DROP COLUMN company, DROP COLUMN name;
ALTER TABLE leads ADD CONSTRAINT uq_leads_player_ref UNIQUE (player_ref);
ALTER TABLE dispatches DROP COLUMN recipient, DROP COLUMN body;
ALTER TABLE conversations DROP COLUMN contact_phone, DROP COLUMN contact_name;
ALTER TABLE whatsapp_threads DROP COLUMN phone_e164;
ALTER TABLE email_suppressions DROP CONSTRAINT email_suppressions_pkey;
ALTER TABLE email_suppressions ADD PRIMARY KEY (email_hmac);
ALTER TABLE email_suppressions DROP COLUMN email;
ALTER TABLE phone_suppressions DROP CONSTRAINT phone_suppressions_pkey;
ALTER TABLE phone_suppressions ADD PRIMARY KEY (phone_hmac);
ALTER TABLE phone_suppressions DROP COLUMN phone_e164;
companysai junto. Hoje ele guarda o mesmo telefone (app/services/dinhu_sync.py:236-239,259). Qualquer trabalho que cifre sóphone_e164deixa a base inteira vazando por uma coluna que ninguém lembra que existe. Teste de guarda no CI:SELECT count(*) FROM leads WHERE company ~ '^[0-9]{10,13}$'tem que ser 0.
4.6 Sequência única de migrations
| Nº | Nome | Conteúdo | Reversível |
|---|---|---|---|
| 0058 | pii_keyring_audit | pgcrypto, crypto_keys, pii_access_log + triggers, pii_access_checkpoint | sim |
| 0059 | lead_contacts | derivados em leads, lead_contacts | sim |
| 0060 | dispatch_envelope | colunas de envelope em dispatches e dispatch_batch_meta | sim |
| 0061 | unsubscribe_tokens | tabela de slug opaco | sim |
| 0062 | suppressions_hmac | colunas hmac + projeção de raw_event + pii_parity_log | sim |
| 0063 | conversations_threads_hmac | hmac/ct em conversas e threads | sim |
| 0064 | alarm_export_callback | pii_alarm_rule, pii_alarm, export_job, send_callback_inbox | sim |
| 0065 | instance_control | instance_control, breakglass_session | sim |
| 0066 | roles_leitura | papéis, grants, schema leitura, pgaudit, papel cliente_admin | sim |
| 0067 | drop_plaintext | irreversível — só com 14 dias de paridade verde | não |
Backfill (0059, 0062) roda fora da migration, em script scripts/backfill_pii.py, chunks de 5.000, com CREATE INDEX CONCURRENTLY depois. Estimativa: 868 mil linhas × (1 HMAC + 3 AES-GCM) — CPU é barato (AES-NI ~1 GB/s); o gargalo é I/O de INSERT com dois índices únicos. 30 a 45 min, em janela de baixa (o sync do Dinhu é diário, não contínuo).
5. O cofre de contato
5.1 Componentes
| Componente | Arquivo | Responsabilidade | Papel que carrega |
|---|---|---|---|
pii_crypto | app/services/pii/crypto.py (~180 linhas) | Único ponto que sabe cifrar e derivar índice cego. Funções puras, sem I/O. | todos |
keyring | app/services/pii/keyring.py | Ciclo de vida da DEK e da PIK. GenerateDataKey na ingestão, Decrypt no cofre. Cache com TTL. | api/worker/vault/sender |
zunit-vault | imagem única, APP_ROLE=vault | Reveal de 1 registro para a tela. Sem DATABASE_URL, sem disco. | vault |
zion-send | repo zion-send | Materialização de lote no disparo. Sem banco, sem rota de leitura. | sender |
contacts_repo | app/repositories/contacts_repo.py | Único acesso a lead_contacts. | api/worker |
5.2 A API do pii_crypto
# app/services/pii/crypto.py
def canon_email(s: str) -> str: ... # lower + trim + normalização IDN
def canon_phone(s: str) -> str: ... # DELEGA para whatsapp_anchors.normalize_phone
def blind_index(field: Literal['email','phone'], value: str) -> bytes: ... # 32 bytes
def encrypt(field: str, plaintext: str, player_ref: str) -> bytes: ... # envelope
def mask_email(s: str | None) -> str: ...
def mask_phone(s: str | None) -> str: ... # reusa app/services/log_redact.py:28
def mask_name(s: str | None) -> str: ...
def decrypt(field: str, envelope: bytes, player_ref: str, kv: int) -> str:
if settings.PII_ROLE not in ('vault', 'sender'):
raise PermissionError("decrypt indisponível no papel %s" % settings.PII_ROLE)
...
tests/test_no_decrypt_in_api_role.py sobe o módulo com PII_ROLE=api e afirma que decrypt levanta. tests/test_kms_policy_invariant.py chama kms:Decrypt com EncryptionContext={'purpose':'contact'} usando a role do crm-api e afirma AccessDenied. A invariante é testada em runtime, não só no papel.
5.3 Chaves, derivação e key policy
CMK do cliente alias/zunit-<slug>-data
├── DEK-contato EncryptionContext {"purpose":"contact","tenant":"<slug>"}
│ versionada em crypto_keys, AES-256-GCM, para lead_contacts.*_ct
├── PIK EncryptionContext {"purpose":"index","tenant":"<slug>"}
│ chave de HMAC-SHA256 do índice cego
└── DEK-envelope GenerateDataKey por LOTE, purpose=contact
cifra template_ct e targets[].ct que trafegam pela fila
Key policy — é o documento que prova a promessa:
| Principal | Ações permitidas | Condição |
|---|---|---|
role/zunit-<slug>-api, role/zunit-<slug>-worker | kms:Encrypt, kms:GenerateDataKey | EncryptionContext:purpose = contact |
| idem | kms:Decrypt | EncryptionContext:purpose = index |
role/zunit-<slug>-vault | kms:Decrypt | purpose IN [contact, index] |
role/zion-send | kms:Decrypt | purpose = contact |
| Principal do CLIENTE | kms:DisableKey, kms:EnableKey, kms:ScheduleKeyDeletion, kms:PutKeyPolicy, kms:ListGrants | — |
A última linha, e só ela, é a chave de desligamento. Não é botão de UI: é permissão IAM na conta dele. O botão do painel apenas chama a mesma API com a credencial dele.
Provider local (PII_PROVIDER=local) para dev e para hospedagem sem AWS: PII_MASTER_KEY (32 bytes base64 em env), mesma interface, nenhuma mudança de chamada. _validate_secrets() reprova o boot em prod com PII_ENABLED=true sem PII_KMS_KEY_ARN nem PII_MASTER_KEY.
5.4 Rotação
| Chave | Como rotaciona | Custo | Automatizável |
|---|---|---|---|
| DEK-contato | INSERT de nova versão em crypto_keys + flip de is_active. Nada é re-cifrado: leitura resolve pela key_version da própria linha. | segundos | sim |
| DEK-envelope | Nasce e morre por lote. | zero | n/a |
| PIK (índice) | Exige recalcular email_hmac/phone_hmac da base inteira + todas as supressões + todos os dispatches históricos. Janela de manutenção real. | 40+ min, com dual-index durante a transição | não |
Declarar no contrato: a chave de índice rotaciona sob demanda, com janela agendada. Não vender "tudo rotacionável a qualquer momento".
5.5 Revogação — o que acontece e em quanto tempo
| Ação do cliente | Efeito | Latência |
|---|---|---|
kms:DisableKey | Ingestão falha, materialização de lote falha (503), reveal falha, envio pausa (não descarta) | ≤ PII_DEK_TTL_SECONDS (300 s default; 0 s no modo estrito) |
kms:RevokeGrant do principal do zion-send | idem, só para o plano de envio | idem |
POST /v1/instance/killswitch {"nivel":1} | dispatch_enabled=false; a fila queued é preservada | < 10 s |
POST /v1/send/kill | purge das filas + drop do cache de DEK + parada dos runners | < 5 s |
O modo estrito (PII_DEK_TTL_SECONDS=0) faz um kms:Decrypt por job de 50, zerando a janela ao custo de ~10 mil chamadas KMS/dia por cliente — barato em dinheiro, soma latência e cria dependência dura da disponibilidade do KMS. Oferecer os dois modos e deixar o cliente escolher no contrato é melhor do que decidir por ele. /v1/health do vault e do sender chama DescribeKey a cada 60 s para antecipar.
Prometer desligamento instantâneo é mentira. Prometer 5 minutos é verdade verificável. O SLA declarado é PII_DEK_TTL_SECONDS.
5.6 Falha fechada — regra dura
Sem chave, o serviço de envio não pode:
- continuar mandando com contato ainda em cache além do TTL;
- descartar a fila.
Comportamento obrigatório: KeyUnavailable → devolve o chunk para queued, marca o lote blocked_by_key, notifica, para. tests/test_killswitch_fails_closed.py sobe o sender, desabilita a chave no meio de um lote, e afirma que nada foi enviado e nada foi perdido. Um botão de desligamento que perde 500 mil mensagens nunca será apertado — e botão que ninguém aperta não é garantia, é decoração.
6. O serviço de envio sem memória (zion-send)
6.1 Superfície inteira
GET /healthz → 200 {"ok":true,"node":"send-7c2f","kids_loaded":2,"queue_lag_s":3}
POST /admin/kill → 202 {"purged":1240,"deks_dropped":2} (assinado pela chave do CLIENTE)
POST /w/resend | /w/ses | /w/unipix/entrega | /w/unipix/resposta | /w/evolution → {"ok":true}
Nada mais. Qualquer outro path devolve 404 sem corpo. /healthz nunca expõe contagem de contatos, id de lead ou nome de campanha.
tests/test_no_read_routes.py varre app.routes e falha se existir qualquer GET fora de /healthz.
6.2 Envelope de ida — mensagem em zion-send-<slug>-<canal>
{
"v": 1,
"envelope_id": "0f3c9a52-7b41-4f7e-9a2f-1d8c5b0e6a44",
"zunit_id": "zu-betx",
"batch_id": "b7f2a1c0-1111-4444-8888-aaaabbbbcccc",
"channel": "email",
"provider_hint": "ses",
"kid": "zu-betx-2026-09",
"key_ref": "arn:aws:kms:sa-east-1:481516234211:key/9d0e-...",
"dek_wrapped": "AQIDAHj8...==",
"exp": 1757100900,
"aad": "zu-betx|b7f2a1c0|email|1757100900",
"sender": {"from":"promo@casa.com.br","name":"Casa X","reply_to":["sac@casa.com.br"]},
"policy": {"rate_key":"ses:zu-betx","max_attempts":3,"dek_ttl_s":300},
"template_ct": "3s9Q...base64(nonce12||ct||tag16)",
"targets": [
{"dispatch_id":"6a1f...","player_ref":"ZP_7QK3...","ct":"kP2m...","unsub_slug":"n8Kd2pQr7XmA4bTe1LzC"},
{"dispatch_id":"6a20...","player_ref":"ZP_9MB2...","ct":"9Lx4...","unsub_slug":"7Kq2xR9mA4bTe1LzCn8"}
],
"callback_queue": "https://sqs.sa-east-1.amazonaws.com/4815/zion-send-cb-zu-betx",
"sig": "ed25519:MEUCIQD..."
}
Plaintext de template_ct (aberto só dentro do runner):
{"subject":"{{primeiro_nome}}, seu bônus expira hoje",
"text":"Oi {{primeiro_nome}}, ...\n\nSair: {{link_cancelar}}",
"html":"<p>Oi {{primeiro_nome}} ...</p>"}
Plaintext de targets[].ct:
{"to":"ana@exemplo.com","vars":{"primeiro_nome":"Ana"}}
Regras duras do contrato:
aadamarra o ciphertext a zunit + lote + canal + expiração. Reusar o ct em outro envelope falha na tag do GCM.expmáximo 900 s. Envelope vencido é recusado e devolvido comostate=failed, error=envelope_expired.targetsmáximo 50 (limite do SESSendBulkEmaile da UniPix por request). ~15 KB por mensagem, teto SQS de 256 KB.- Ausência de
sigválida = mensagem descartada e alarme, nunca processada. - Fila:
MessageRetentionPeriod=1800, SSE-KMS com a CMK do cliente, DLQ commaxReceiveCount=5. Na opção A a fila fica na conta dele e ozion-sendassume role cross-account com apenasReceiveMessage/DeleteMessage— cada leitura aparece no CloudTrail dele.
6.3 Recibo de volta — mensagem em zion-send-cb-<slug>
{
"v": 1, "zunit_id": "zu-betx", "node_id": "send-7c2f",
"emitted_at": "2026-09-05T14:02:11.442Z",
"status": [
{"dispatch_id":"6a1f...","state":"sent","provider":"ses",
"provider_message_id":"0100018f2c...","at":"...","body_digest":"9f2a41c7d0b3e58a..."},
{"dispatch_id":"6a20...","state":"failed","provider":"ses","error_code":"MessageRejected","at":"..."}
],
"provider_events": [
{"dispatch_id":"6a1f...","event":"bounce","bounce_type":"Permanent","smtp_code":"550","at":"..."}
],
"decrypts": [
{"receipt_id":"c14a...","key_ref":"arn:aws:kms:...","purpose":"dispatch.email",
"batch_id":"b7f2a1c0","dispatch_ids":["6a1f...","6a20..."],"count":50,
"decrypted_at":"2026-09-05T14:02:09.774Z","held_ms":412,
"seq":880134,"prev_hash":"a1b2...","hash":"d4e5..."}
],
"inbound": [
{"channel":"whatsapp","contact_hmac":"7f3c...","contact_ct":"base64","text_ct":"base64","at":"..."}
],
"sig": "ed25519:MEQCIF..."
}
O zion-send cifra mas não decifra o retorno: o inbound volta cifrado com a chave pública do cliente (kms:Encrypt ou ECIES).
6.4 Fluxo do disparo, passo a passo
- Empacotamento (plano de dados). Claim atômico de sempre —
SELECT ... FOR UPDATE SKIP LOCKED→UPDATE ... RETURNING, commit antes de qualquer HTTP (mecânica já em produção emapp/services/dispatch/email_sender.py:95-112). ORETURNINGmuda de(Dispatch.id, Dispatch.recipient)(hoje, linha 109 — verificado) para(id, lead_id, recipient_hmac, recipient_ct, recipient_kv, vars_ct). O worker carrega os envelopes delead_contactsnum únicoSELECT ... WHERE lead_id = ANY(...). Ele lê BYTEA que não sabe abrir. - Monta o envelope, calcula o AAD, assina Ed25519, publica na SQS.
- Consumo.
ReceiveMessage(visibilidade 300 s). Verifica assinatura,exp, e idempotência (SETNX env:{envelope_id}TTL 24 h). - Abertura da chave.
open_dek(kid, key_ref, dek_wrapped)→kms:Decrypt. Uma chamada por lote por réplica, cacheada porSEND_DEK_TTL_S. Cada chamada emite recibo de decifragem encadeado por hash. Em paralelo, oDecryptaparece no CloudTrail da conta do cliente. - Decifragem e renderização. Abre
template_cte cadatargets[].ctem heap. Interpola, expande spintax (app/services/spintax.py::expandportado parazion_send/render.py), monta a URL de descadastro a partir dounsub_slug—{PUBLIC_BASE_URL}/v1/u/{slug}, sem nenhum dado derivado do endereço — e calculabody_digest. - Envio. Injeta o ponteiro opaco no protocolo do provedor: SES
MessageTags[zdid], Resendtags+X-Entity-Ref-ID, UniPixsmsClienteId(já é opaco hoje —app/services/dispatch/sms_sender.py:112-116), Evolution/GeeLark sem eco → mapapmid:{provider_message_id} → dispatch_idno Redis com TTL de 30 dias. - Zeramento. Ao sair do
try, plaintext é sobrescrito e liberado.held_msmede quanto tempo viveu. - Recibo. Publica na fila de retorno, deleta a mensagem SQS. Nada volta para o banco além de ponteiro opaco.
- Ingestão do recibo (
app/workers/send_callbacks.py). Verifica assinatura, idempotência porreceipt_id(send_callback_inbox), atualizadispatchespordispatch_id(nunca por e-mail/telefone), grava a linha empii_access_logcom os campossender_*da cadeia do emissor, e valida quesender_prev_hashdo recibo N ==sender_hashdo N-1. Cadeia quebrada = alarme, não erro silencioso.
6.5 Webhook de retorno — onde a PII tentaria voltar
O provedor devolve o endereço em claro; foi ele quem entregou. O webhook nunca persiste esse valor. Os handlers migram do CRM para o webhook-relay do plano de envio, que:
- lê
mail.tags.zdid(SES) ou consultapmid:{provider_message_id}no Redis; - calcula
blind_indexcom a PIK; - descarta todo o resto do payload — o
to, osubject, osheaders; - publica
{dispatch_id, event, bounce_type, smtp_code}ou{email_hmac, reason}no recibo.
raw_event=event (o payload cru do Resend, que carrega destinatário e assunto — app/api/webhooks/resend.py:160,203) deixa de existir; vira projeção declarada {type, event_id, bounce_type, received_at}.
Engajamento: record_engagement deixa de casar por func.lower(Lead.email) == addr (app/services/email_engagement.py:64) e passa a casar por dispatch_id → lead_id (que a própria linha já tem) com fallback por LeadContact.email_hmac. A cadeia last_opened_at → temperatura_atual → segmentos temperatura-* → frequency capping → sunset continua inteira.
6.6 Redis do plano de envio
env:{envelope_id} SETNX, TTL 86400s → "1" (idempotência)
dek:{kid}:{batch_id} TTL SEND_DEK_TTL_S → bytes32 (0 no modo estrito)
pmid:{provider_msg_id} TTL 2592000s → dispatch_id (correlação opaca→opaca)
rl:{provider}:{zunit} token bucket Lua (app/services/rate_limiter.py)
cb:pending LIST, TTL 3600s → recibos aguardando flush
chip:{id} SET NX PX 30000, renovado a cada 10s (lock do on-device)
Configuração obrigatória, escrita no compose e verificada no boot: appendonly no, save "", maxmemory-policy volatile-ttl, sem réplica, sem RDB em volume. Redis com persistência ligada seria um banco disfarçado e quebraria a promessa.
6.7 Blindagem do runtime
Dockerfile e compose do zion-send, verificados no boot:
RLIMIT_CORE=0(sem core dump);- swap desligado no host;
/tmpem tmpfs;- rootfs read-only;
logging.Filterglobal que descarta qualquer record cujo texto case com regex de e-mail ou telefone — defesa em profundidade, porque olog.exceptionde um adapter é o vazamento que ninguém revisa.
7. Trilha de auditoria
7.1 Onde ela mora — e por que isso é o defeito mais constrangedor de hoje
app/zion_audit.py:32 lê ZION_AUDIT_DSN e :43-53 abre um pool asyncpg próprio para o zion-core compartilhado. Numa instância dedicada, isso é um canal de saída do perímetro do cliente por desenho — e é justamente a trilha que se quer vender como garantia.
Decisão: AUDIT_SINK plugável, default local no template de provisionamento. O runbook tem passo explícito de verificação (netstat na instância não pode mostrar conexão para o zion-core).
7.2 Interceptação — o gatilho é "saiu PII", não "a rota é interessante"
app/zion_audit.py:255-265 hoje pula GET sem id no path ("lista comum não é interessante pra audit"). É exatamente por isso que a varredura paginada é invisível: 125 chamadas de /v1/leads/page varrem 62 mil leads sem gerar uma única linha.
O pii_audit registra independentemente do middleware:
# app/services/pii_audit.py
def current_scope() -> PiiScope | None: ...
async def record_pii(*, action, resource, fields, row_count, subject_ids=(),
reveal=False, purpose=None, query_params=None,
severity=0, sync=False) -> None: ...
async def flush() -> int
def start_writer(); async def stop_writer()
PiiScope é dependency FastAPI encadeada depois de require_auth/require_role (app/deps.py:17, :72). Resolve ator, request_id (contextvar de app/observability.py), IP, user-agent e finalidade. Os repositórios chamam scope.record(...) ao terminar.
Regra de escrita:
- severidade ≥ 2 (export, reveal em massa) → síncrono, antes de a resposta sair;
- severidade < 2 → fila in-process, flush a cada 250 ms ou 200 linhas, com flush no shutdown e contador de perda exposto em
/health.
O PiiScopeMiddleware é ASGI puro (molde do RequestIdMiddleware em app/observability.py), não BaseHTTPMiddleware — para não criar task extra por request.
O termo buscado nunca entra na trilha._apply_searchgrava{'q_len': len(q), 'q_kind': 'email'|'phone'|'nome'}e oquery_fingerprint. Buscar porana@x.comé PII em si.
7.3 Imutabilidade
Três camadas, em ordem de força:
- Trigger
trg_pii_no_change/trg_pii_no_truncate—UPDATE,DELETEeTRUNCATElevantam exceção. - Grant —
zunit_apptemSELECT, INSERT;UPDATE, DELETE, TRUNCATErevogados (0066). Separação de privilégio no banco, não convenção de código. - Cadeia de hash calculada no trigger — a aplicação não escolhe o que assina. Checkpoint horário com HMAC de
AUDIT_ANCHOR_KEY, ancorado em bucket com Object Lock COMPLIANCE na conta do cliente e/ou webhook dele.
A imutabilidade só é forte quando o destino da âncora está fora do alcance da credencial da instância. Se AUDIT_ANCHOR_KEY vive numa env var de uma instância que a Zion administra (opção B), quem é root ali pode reescrever a trilha e recalcular os checkpoints. Sem Object Lock na conta do cliente, o que se pode prometer é "append-only por desenho e detectável", não "inviolável".
Verificador offline — scripts/verify_chain.py, stdlib apenas, roda contra um dump ou contra o banco, sem confiar em nenhum código da Zion em execução:
python scripts/verify_chain.py --dsn "$AUDITOR_DSN" --anchor-key-file ./minha-chave.key
# OK (18444 linhas, head=3f9a1c...) exit 0
# FALHA na seq 911207 (esperado aa12..., encontrado bb90...) exit 1
É esse artefato que se leva para a due diligence, não o slide.
7.4 Motor de alarme
Nove regras em pii_alarm_rule (o cliente ajusta os números sem deploy, e o ajuste é ele mesmo auditado: action='audit.rule.update'). Duas execuções: inline (bloqueia antes de o dado sair) e batch a cada 60 s (padrões que só existem no agregado).
O detector que fecha o furo real:
VARREDURA_PAGINADA — agrupa por ator + 15 min; conta chamadas de lead.list com
offsets crescentes e soma o distinto de leads tocados (HyperLogLog no Redis).
>= 40 chamadas e > 10.000 leads distintos → alarme severidade 2, action=throttle.
Notificação em ≤ 60 s para severidade ≥ 2: linha em alerts (o feed da UI já lê isso — app/db/models/alert.py), webhook assinado do cliente, e-mail do encarregado de dados. Falha de entrega vira alarme próprio. Definir no onboarding quem recebe — nome e e-mail, não "a equipe".
Webhook para o cliente:
POST <CLIENT_ALERT_WEBHOOK_URL>
X-Zion-Event: pii.alarm | pii.export
X-Zion-Signature: sha256=<hmac do corpo com CLIENT_ALERT_WEBHOOK_SECRET>
X-Zion-Timestamp: 1788... # anti-replay, janela 5 min
{"evento":"pii.alarm","zunit_id":"zu-betx","regra":"VARREDURA_PAGINADA","severidade":2,
"ator":"joao@casa.com.br","janela":["2026-09-04T23:10:00Z","2026-09-04T23:25:00Z"],
"linhas_envolvidas":18400,
"evidencia":{"chamadas":57,"leads_distintos":18400,"seqs":[918100,918101,"..."]},
"painel":"https://crm.casa.com.br/auditoria?alarm=...","alarme_id":"..."}
7.5 Pipeline único de export
Todos os caminhos de arquivo com PII passam por POST /v1/exports. Os quatro atuais:
| Caminho hoje | Problema | Destino |
|---|---|---|
app/api/dispatches.py:928 — CSV nome+email+telefone | sem senha e sem write_activity (verificado: tem require_role, não tem cifra nem registro) | 410 Gone por 30 dias → kind='batch_temperature' |
app/api/leads.py:338-403 — XLSX cifrado até 100 mil linhas | senha no header X-Export-Password (:397), exposto via Access-Control-Expose-Headers (:399-401) — qualquer proxy loga | wrapper fino de export_service |
app/api/suppressions.py:182-190 — CSV de descadastros | sem senha, sem registro | kind='suppressions', saída pseudonimizada |
Bucket S3 público (app/services/storage_s3.py:87,130) — CacheControl: public, max-age=31536000, immutable | URL adivinhável e cacheada por um ano | bucket privado + rota autenticada |
Fluxo do pipeline único:
POST /v1/exports {kind, params, purpose, purpose_note}— finalidade obrigatória, de lista fechada.COUNTantes de materializar.> 5.000linhas → alarme e segue.> 25.000→status='blocked', exige aprovação de um segundo admin (POST /v1/exports/{id}/approve, ator ≠ solicitante).- Grava síncrono
export.requested(severidade 2). - Gera XLSX cifrado em
to_thread(reusabuild_encrypted_export,app/services/lead_export.py:25,70-72,103-108), sobe para bucket privado com expiração de 24 h, calcula sha256, guarda a senha só no Redis (export:pw:{id}, TTL 600 s, leitura única). - Grava
export.completedcomrow_countreal e sha256. GET /v1/exports/{id}/password→ 200 na primeira vez, 410 depois.GET /v1/exports/{id}/file→ o arquivo. Cada uma gera sua própria linha.- Todo export com
row_count > 1.000dispara webhook no ato, independente de haver alarme.
Guard anti-regressão:tests/test_export_paths_guard.pypercorreapp.routese falha se qualquer handler declararresponse_class/media_typede CSV ou XLSX fora da allowlistEXPORT_ROUTES. Sem esse teste, o quinto caminho de export nasce em três meses e ninguém percebe.
7.6 Consulta pelo cliente
GET /v1/audit/access?from=&to=&actor=&action=&min_rows=1000&severity_gte=2&reveal=true&cursor=&limit=200
GET /v1/audit/stream # SSE via LISTEN/NOTIFY (molde do lead_stage_listener, app/main.py:117)
GET /v1/audit/summary?window=24h
GET /v1/audit/verify?from_seq=&to_seq=
GET /v1/audit/alarms?open=true
POST /v1/audit/alarms/{id}/ack {"nota":"era migração combinada"}
POST /v1/audit/alarms/{id}/resolve {"resolucao":"falso positivo"}
GET /v1/audit/rules | PATCH /v1/audit/rules/{code}
GET /v1/audit/export # a própria trilha; não contém PII, não precisa de senha
Papel auditor — não enxerga PII de jogador, só a trilha. Teste de contrato no CI varre os routers e falha se auditor passar em algum endpoint que devolve contato.
GET /v1/audit/summary?window=24h → 200
{"janela":"24h","linhas_pii_lidas":214882,"linhas_pii_em_claro":3140,
"exports":{"quantidade":3,"linhas":41230,"maior":41230},
"atores":[{"email":"ana@casa.com.br","org":"cliente","linhas":41430,"em_claro":41230,"exports":1},
{"email":"ops@zion.com.br","org":"zion","linhas":0,"em_claro":0,"exports":0}],
"materializacoes":{"lotes":4183,"destinatarios":209150,"baseline_7d":198400,"desvio_pct":5.4},
"alarmes_abertos":1,
"cadeia":{"ultimo_checkpoint_seq":918200,"ancorado_em":"2026-09-04T23:00:00Z","ok":true},
"trilha_viva":true}
E o acesso sem passar pelo app da Zion:
-- psql via túnel, papel zunit_ro_cliente
SELECT ts, actor_org, actor_email, action, row_count, reveal, purpose
FROM leitura.trilha_acesso
WHERE ts > now() - interval '1 day' AND (reveal OR row_count > 1000)
ORDER BY ts DESC;
7.7 Auditoria simétrica
pgaudit.role = 'zunit_ro_cliente' registra as consultas do cliente; o mesmo mecanismo registra as do papel de suporte da Zion; actor_org separa os dois em pii_access_log. A simetria é o ponto: a trilha não é vigilância do cliente, é a prova que ele tem contra a Zion — e vice-versa. Isso vai no contrato como escolha de desenho, não pode ser descoberto depois.
8. Provisionamento da instância (zunit)
8.1 IaC
infra/zunit/
├── modules/zunit/ # um módulo, dois provedores (aws | hcloud), a mesma saída
│ ├── versions.tf # terraform >= 1.9, aws ~> 5.0, hcloud ~> 1.48
│ ├── variables.tf # SEM default nas variáveis de conta/rede
│ ├── network.tf # VPC /24, subnet pública única, SG SEM NENHUM INGRESS
│ ├── compute.tf # EC2/Hetzner, volume cifrado pela CMK, cloud-init
│ ├── kms.tf # CMK + alias + key policy (seção 5.3)
│ ├── storage.tf # bucket backup Object Lock COMPLIANCE + bucket uploads PRIVADO
│ ├── secrets.tf # Secrets Manager sob /zunit/<slug>/
│ ├── ingress.tf # Cloudflare Tunnel (HTTP + TCP do Postgres) + Access
│ └── outputs.tf
└── live/<slug>/ # uma pasta por cliente, um state remoto por cliente
├── main.tf
└── terraform.tfvars
Promoção de infra/email-pipeline/ (variables.tf:1-45 hoje tem VPC, SG e role hardcoded com default de uma conta específica — vpc-0aab4a4b22d6b9f41, sg-0c7ce214…, profile jotaeditora-new). Esses valores viram infra/zunit/live/zu-zion/terraform.tfvars; o módulo de e-mail vira submódulo opcional acionado por var.porte >= p2.
Sem NAT Gateway (subnet pública + SG sem ingress + IPv4 a US$ 4/mês em vez de US$ 33) e sem ALB (Cloudflare Tunnel, US$ 0 em vez de US$ 22). Só esses dois cortes economizam ~R$ 300/mês por instância — R$ 108 mil/ano numa frota de 30. E fecham de saída o achado do Perímetro Zero (porta 22 pública ainda aberta no zion-crm-01).
State: por cliente, em bucket separado com cifra por KMS e versionamento. Senhas geradas por random_password e escritas direto no Secrets Manager com lifecycle { ignore_changes }. Nunca output de senha. Na opção A, o state fica no bucket do cliente.
8.2 Registro de frota
Reuso literal de /Users/drm/repositorio/ZION-HOUSE/zion-deploy — YAML validado por ci/validar.py contra schema/manifesto.schema.json, já em produção para o flowbet, e que já reprovou um sumiço de serviço real em 02/08.
envs/zunit-<slug>/INVENTARIO.yaml
envs/zunit-<slug>/crm-api.yaml
envs/zunit-<slug>/crm-workers.yaml
envs/zunit-<slug>/zunit-vault.yaml
Bloco novo no schema: zunit: {id, hospedagem, conta, regiao, porte, kms_key_alias, contato_tecnico}. Regra nova em ci/validar.py: manifesto de zunit sem entrada no INVENTARIO.yaml correspondente reprova.
Editar imagem.image_digest num manifesto É pedir deploy naquela instância. É a única forma de deploy.
8.3 zunit-agent — deploy em pull, sem SSH
Container Python 3.12 (~250 linhas) que roda dentro da instância e é o único que faz mudança lá.
loop a cada 60s:
GET https://frota.zion.../manifesto/<slug>.yaml + .sig (público, sem segredo)
verifica assinatura
se image_digest != rodando:
cosign verify <digest> → imagem não assinada NÃO sobe
docker pull
se deploy.migration: alembic upgrade head como job one-off (papel zunit_migrator)
exit != 0 → NÃO troca a imagem, reporta migration_failed
sobe container novo → espera /health/ready 200 (90s) → troca → derruba velho
não subiu → reverte para o digest anterior sozinho
POST /internal/fleet/heartbeat
{zunit_id, image_digest, alembic_head, uptime_s, killswitch_level,
db_size_bytes, leads_count, queue_depth, ultimo_backup}
Zero PII no heartbeat, por contrato e por teste (tests/test_fleet_no_pii.py reprova qualquer coluna nova em fleet_instances cujo nome case com email|phone|name|cpf|recipient|body).
Inversão da direção do deploy é o que faz a opção A funcionar sem a Zion ter credencial na conta do cliente.
Blindagem do agente (ele é root efetivo no host — mesmo furo já achado no zion-crm-01: sudo NOPASSWD + docker):
- só aceita manifesto assinado;
- só puxa de registry allow-listed;
- não recebe nenhuma entrada da rede (é ele que sai buscando);
- roda com
no-new-privileges; - o socket Docker é mediado por proxy (
tecnativa/docker-socket-proxy) que liberapull/create/start/stope nuncaexec— semdocker exec, o agente não vira shell.
8.4 Deploy e atualização sem tocar no dado
.github/workflows/ci.yml:60-149 hoje deploya um cluster fixo (zion) e serviços fixos na conta 384177185434. Vira:
- Push em
main→ CI (lint + pytest) verde. - Build de uma imagem,
--provenance=false --platform linux/amd64, tag por SHA, push no ECR,cosign signno digest. - Job de rollout escreve o digest nos manifestos do anel corrente e abre commit no zion-deploy.
- Anéis:
zu-zion(instância interna, dados sintéticos) → 1 piloto → resto da frota, 30 min entre anéis. Anel que não fica verde trava o próximo. - Cada
zunit-agentse atualiza sozinho. - Drift aparece como número no painel de frota — "3 instâncias em
a1b2c3, 1 em9f8e7d, 1 com head de migração diferente do repo" — antes de virar incidente.
O passo de migração one-off já está certo hoje (ci.yml:96-118: task efêmera, espera exit code, aborta se ≠ 0). Extrair para .github/actions/zunit-migrate/action.yml e reusar.
Nenhum humano toca em nada. Não existe psql de produção na mão de ninguém, não existe SSH, não existe console. Debug é /health/ready, logs redigidos e métricas. Quando isso não basta: quebra-vidro.
8.5 Chave de desligamento — quatro níveis
| Nível | O que faz | Tempo | Reversível | Quem |
|---|---|---|---|---|
| N1 — Pausar envio | instance_control.dispatch_enabled=false. O scheduler para de reivindicar. Fila queued preservada. | < 10 s | sim, 1 clique | cliente_admin |
| N2 — Congelar acesso da Zion | Incrementa session_version de todo usuário com org='zion' (mecânica já existe: app/deps.py:41-42) e desabilita o OIDC deles. O cliente continua operando. | < 5 s | sim | cliente_admin |
| N3 — Cortar a chave | kms:DisableKey. Materialização, reveal e ingestão param em ≤ TTL. Disparo falha fechado. Banco intacto e legível por ele. | ≤ 300 s | sim (EnableKey) | cliente_admin + MFA |
| N4 — Destruir | Snapshot final cifrado entregue no bucket dele → terraform destroy → kms:ScheduleKeyDeletion (7 dias) → expurgo do backup. | 72 h de espera + 15 min | não | 2 aprovadores + janela 72 h |
Regras de projeto:
- N1 e N2 são reversíveis e sem drama de propósito. O cliente precisa poder usar sem medo, senão nunca usa.
- Cada acionamento e cada reversão entram na trilha e notificam os dois lados.
- N4 exige que o snapshot de portabilidade tenha sido entregue antes. O desligamento não pode deixar o cliente refém do dado dele.
8.6 Quebra-vidro
- Suporte da Zion abre chamado e pede acesso pelo painel de frota.
- O cliente aprova em
POST /v1/instance/breakglass/aprovar, com motivo e duração (máx. 60 min). - A instância cria
zunit_support_<id>com senha efêmera,default_transaction_read_only=on,pgaudit.log='all',statement_timeout=60s. - Ao expirar, o papel é dropado por job. Relatório com todas as consultas é entregue ao cliente automaticamente.
Sem aprovação do cliente, não existe acesso.
8.7 Backup e restauração
pgBackRest (P1) para bucket com Object Lock COMPLIANCE, retenção 30 dias; ou snapshot RDS + cópia cross-account (P2/P3). Fecha o achado "backup deletável pela própria credencial."
Mais importante que o backup: job mensal automatizado que restaura numa instância efêmera, roda SELECT count(*) de sanidade e destrói. Backup não testado não é backup, e sem ele o runbook de reconstrução da opção A é ficção.
POST /v1/instance/backups/exportar gera dump cifrado no bucket do cliente — é o direito de portabilidade, e o que torna o desligamento não-refém.
8.8 Custo
Premissa: cotações públicas set/2026, sa-east-1, R$ 5,40/US$.
| Porte | Perfil | US$/mês | R$/mês |
|---|---|---|---|
| P1 | até 150 mil leads, 500 mil envios/mês — t4g.large + EBS 150 GB + IPv4 + S3 200 GB + KMS + CW | 82 | 445 |
| P2 | até 1 M leads, 3 M envios/mês — c7g.2xlarge + EBS 500 GB + réplica de leitura + backup | 287 | 1.550 |
| P2-RDS | idem, banco gerenciado exigido em contrato | 430 | 2.320 |
| P3 | acima de 2 M leads — 2 hosts + RDS m7g.xlarge Multi-AZ | 1.100 | 5.940 |
Plataforma da Zion (não escala com N, cresce em degraus): CI/CD R$ 150 + registry R$ 150 + observabilidade R$ 300 (até 15 instâncias) a R$ 1.500 (acima de 30) + painel de frota R$ 250 + cofre R$ 100 = R$ 950 a R$ 2.150/mês.
| N (mix 60% P1 / 35% P2 / 5% P3) | Infra | Plataforma | Total/mês | Por cliente |
|---|---|---|---|---|
| 1 | 445 | 950 | 1.395 | 1.395 |
| 5 | 4.235 | 1.200 | 5.435 | 1.087 |
| 15 | 14.865 | 1.500 | 16.365 | 1.091 |
| 40 | 39.640 | 2.150 | 41.790 | 1.045 |
O custo por cliente é essencialmente plano, ~R$ 1.000 a R$ 1.400/mês. Não é defeito de implementação: é a natureza do modelo dedicado. Não existe economia de escala de infraestrutura quando cada cliente tem banco, app e workers próprios. A economia de escala está inteira no custo de software e no custo humano — uma imagem para todos, um pipeline, um painel de frota.
A regra que sustenta o modelo: ZERO fork. Diferença de cliente é env var, feature flag e manifesto — nunca código. Com frota homogênea, uma pessoa opera ~25 instâncias; com fork e deploy manual, opera 4 a 6, e a diferença vira R$ 25 mil/mês de folha a partir de N=10. Um if cliente == 'x' no repo é o fim do modelo, e por isso vira regra de CI (grep que reprova nome de cliente no código de app).
9. O que muda no código hoje
Raiz: /Users/drm/repositorio/ZION-HOUSE/flow-crm-api. Coluna F = fase (seção 10).
9.1 Modelos e configuração
| Arquivo:linha | O que fazer | F |
|---|---|---|
app/db/models/lead.py:30-36,57 | Remover email (unique/index), name, phone_e164, company. Adicionar player_ref, email_domain, phone_ddd, contact_last4, name_initials, pii_migrated_at. | 2 |
app/db/models/dispatch.py:82-85,100-102 | Adicionar as colunas de envelope. recipient/body viram nullable na 0060 e somem na 0067. | 4 |
app/db/models/email_suppression.py:36,43 | email_hmac ao lado; PK troca só na 0067. raw_event vira projeção. | 5 |
app/db/models/phone_suppression.py:37,49 | idem. | 5 |
app/db/models/__init__.py | Exportar CryptoKey, LeadContact, PiiAccessLog, PiiAccessCheckpoint, PiiAlarm, PiiAlarmRule, ExportJob, UnsubscribeToken, InstanceControl, BreakglassSession, SendCallbackInbox, PiiParityLog. | 1 |
app/config.py:11-24 | APP_ROLE aceita 'sender' e 'vault'. Guard de boot: sender/vault com DATABASE_URL preenchido reprova. | 4 |
app/config.py:183 | EMAIL_PIPELINE: str = "legacy" (verificado) vira "queue". Com SEND_PLANE_ENABLED=true, legacy reprova o boot. Primeiro commit da frente de envio. | 0 |
app/config.py:623-677 | _validate_secrets() ganha: PII_ENABLED sem PII_KMS_KEY_ARN/PII_MASTER_KEY reprova em prod; ZUNIT_ID ausente reprova; papel api com kms:Decrypt de purpose=contact reprova. | 1 |
app/config.py (novas) | PII_ENABLED, PII_ROLE, PII_PROVIDER, PII_KMS_KEY_ARN, PII_KMS_ENCRYPTION_CONTEXT_TENANT, PII_DEK_TTL_SECONDS=300, PII_MASTER_KEY, VAULT_BASE_URL, VAULT_TICKET_KEY, VAULT_MTLS_CA, SEND_PLANE_ENABLED, SEND_DEK_TTL_S, MATERIALIZE_MAX_PER_HOUR=1500000, PII_REVEAL_MAX_ROWS=200, PII_BUDGET_SOFT_ROWS=8000, PII_BUDGET_HARD_ROWS=20000, EXPORT_BUCKET, EXPORT_TTL_HOURS=24, EXPORT_APPROVAL_ROWS=25000, AUDIT_SINK='local', ZION_AUDIT_MIRROR=False, AUDIT_ANCHOR_KEY, AUDIT_ANCHOR_S3_BUCKET, CLIENT_ALERT_WEBHOOK_URL, CLIENT_ALERT_WEBHOOK_SECRET, CLIENT_DPO_EMAIL, ZUNIT_ID, ZUNIT_HOSPEDAGEM, ZUNIT_KMS_KEY_ALIAS, ZUNIT_SENDER_URL, ZUNIT_FLEET_URL. | 1-6 |
app/db/session.py:24-32 | connect_args={'server_settings': {'application_name': f'zunit-{ZUNIT_ID}-{APP_ROLE}'}}. Sem isso o pgaudit não separa a Zion do cliente. | 6 |
pyproject.toml:10-31 | Fixar cryptography>=43 explícito (hoje entra só transitivamente por pyjwt[crypto]). boto3 já está. | 1 |
Dockerfile:25 | CMD fixo vira entrypoint que despacha por APP_ROLE. Manter --workers 1 pelo motivo já documentado nas linhas 20-24. | 4 |
9.2 Ingestão — a porta de entrada da PII
| Arquivo:linha | O que fazer | F |
|---|---|---|
app/services/dinhu_sync.py:81-115 | _parse_base_total_csv: ponto de cifragem. Cada linha vira email_hmac, phone_hmac, email_ct, phone_ct, name_ct + derivados, antes de montar o dict de upsert. Cifrar aqui custa 40 linhas; cifrar depois custa 8 tabelas. | 2 |
app/services/dinhu_sync.py:236-239, 259-260 | _merge_row_into_state para de gravar state['company'] = row['phone']; _row_to_new_lead para de emitir email/name/company/phone_e164. Passam a emitir o par (linha de leads sem contato, linha de lead_contacts com envelope). | 2 |
app/services/dinhu_sync.py:313-334 | select(...).where(Lead.email.in_(emails)) vira join(LeadContact).where(LeadContact.email_hmac.in_(hmacs)). É a chave de dedup do sync inteiro — sem isso a importação diária duplica 868 mil linhas na primeira execução. | 2 |
app/services/dinhu_sync.py:382-389 | O insert/update em executemany ganha par para lead_contacts, na mesma transação por chunk (commit por chunk já existe na 389). _UPDATE_COLS (181-196) perde name/company/phone_e164. | 2 |
app/services/dinhu_sync.py:41-46 | _norm_phone some. Delega para pii_crypto.canon_phone. | 1 |
app/services/dinhu_client.py:30,154 | _response_cache guarda respostas cruas com usuários na heap do worker, invisível em qualquer inventário de tabela. Passa a cachear hash + contagem, ou TTL 0 no caminho base-total. | 0 |
app/services/snapshot_sync.py:6-7,140-153 + app/db/dinhu_session.py:31-34 | O segundo banco dinhu e a anon key de terceiro com RLS aberta entram na instância do cliente junto. Vira opcional por ZUNIT_FEATURES (default desligado); quando ligado, exige DSN e chave explícitos daquele cliente — nunca derivar o DSN trocando o nome do banco. | 6 |
9.3 Envio — o corte entre os planos
| Arquivo:linha | O que fazer | F |
|---|---|---|
app/services/dispatch/common.py:111-134 | _load_source_contacts hoje devolve até 1.000.000 de contatos numa lista Python dentro do processo do CRM — o oposto exato do desenho. Passa a devolver (lead_id, recipient_hmac). Os dois chamadores (sms_sender.py:332, whatsapp_sender.py:407) acompanham. | 4 |
app/services/dispatch/common.py:175-192 | _query_suppressed (verificado: EmailSuppression.email.in_(chunk)) troca por email_hmac.in_(chunk). Mantém chunk de 5.000 (limite de 32.767 bind params vale igual para bytea). Durante a paridade, roda as duas e usa a UNIÃO para bloquear, gravando divergência em pii_parity_log. | 5 |
app/services/dispatch/common.py:153-172 | _bulk_insert_dispatches grava sem contato: recipient_hmac, recipient_kind, body = template (não é PII), body_is_template=true. | 4 |
app/services/dispatch/email_sender.py:109 | .returning(Dispatch.id, Dispatch.recipient) (verificado) vira .returning(id, lead_id, recipient_hmac, recipient_ct, recipient_kv, vars_ct). Enquanto o worker ler essa coluna, os dois planos são o mesmo processo. | 4 |
app/services/dispatch/email_sender.py:126-153 | _unsub_msg inteira sai do plano de dados. unsub.unsubscribe_url(recipient) (139) e "to": [recipient] (141) migram para zion_send/providers/email.py. | 4 |
app/services/dispatch/email_sender.py:178-187 | if recipient.lower() in suppressed vira hmac contra hmac. Mesmo canceled com error='suppressed'. | 5 |
app/services/dispatch/email_sender.py:288-360 | _email_worker deixa de ser worker de envio e vira _email_packer: claima, empacota, publica, marca sending. Gate de saldo, gate de quota e kill-switch ficam (são decisão, não envio). | 4 |
app/services/dispatch/email_sender.py:402,420 | Mesmo corte no caminho SES 1-a-1. | 4 |
app/workers/email_sender.py | Deixa de existir no repo do CRM. Vira zion_send/runner.py. Os imports de app.db (30-38: Dispatch, DispatchBatchMeta, EmailSuppression, db_session) são exatamente o acoplamento a matar. | 4 |
app/services/email_queue.py:1-12 | O job deixa de carregar {batch_id, dispatch_ids[]} e passa a carregar o envelope completo. JOB_SIZE=50 já está certo. | 4 |
app/services/dispatch/sms_sender.py:112-116 | SmsEnvio(numero=d.recipient, ...) usa o to vindo do Cofre. cliente_id=str(d.id) já é o ponteiro opaco certo e o callback da UniPix já casa por ele — SMS é o canal mais barato de virar. | 4 |
app/services/dispatch/whatsapp_sender.py:263-268 | send_body = wa_spintax.expand(body) e d.body = send_body saem. Expansão vai para zion_send/render.py; a persistência vira d.body_digest. | 4 |
app/services/dispatch/whatsapp_sender.py:271-275 | send_text(phone=d.recipient, ...) sai. A escolha do chip (wa_picker) FICA no plano de dados: é decisão, e não precisa do número. | 4 |
app/services/dispatch/whatsapp_ondevice.py:444,453 | is_in_cooldown(db, d.recipient) → por hmac; out = (d.id, d.recipient, d.body) → (d.id, envelope, template). O runner inteiro a partir de _run_chip_session migra. | 4 |
app/services/dispatch/whatsapp_ondevice.py:216-217 | A linha permanece tecnicamente igual, mas passa a viver dentro do zion-send. É o teto da promessa comercial (seção 11). | 4 |
app/services/whatsapp_recipient_cooldown.py:36 | Dispatch.recipient == recipient vira recipient_hmac == hmac, com idx_dispatch_wa_cooldown. Sem isso o anti-burst que evita detecção de bot morre. | 4 |
app/services/spintax.py::expand | Portado para zion_send/render.py; fica no CRM só para preview de campanha. | 4 |
9.4 Supressão, engajamento e descadastro — o caminho crítico
| Arquivo:linha | O que fazer | F |
|---|---|---|
app/services/phone_suppression.py:28-31 | canon() fica exatamente como está — é ele que garante que send-time, STOP por keyword e inserção manual comparam a mesma forma. Entra só um passo depois: blind_index('phone', canon(raw)). Menor mudança da frente inteira e a de maior risco se sair errada. | 5 |
app/services/phone_suppression.py:49-72,76-90 | Grava e consulta por phone_hmac. | 5 |
app/services/email_engagement.py:64 | func.lower(Lead.email) == addr vira dispatch_id → lead_id (preferencial) com fallback LeadContact.email_hmac. Quebrar aqui derruba em silêncio last_opened_at → temperatura_atual → segmentos temperatura-* → capping → sunset. | 5 |
app/services/email_unsubscribe.py:42-68 | make_token (b64url(email) + '.' + b64url(hmac[:16])), verify_token e unsubscribe_url saem inteiros. O HMAC só impede forjar descadastro de terceiro; não esconde nada — cada e-mail enviado é hoje um vazamento reversível do endereço em qualquer log de proxy, CDN ou cliente de e-mail. Substituto: slug opaco em unsubscribe_tokens, gerado no plano de envio. | 0 |
app/services/email_unsubscribe.py:81-103 | suppress_email recebe email_hmac (ou lead_id vindo do slug). | 5 |
app/api/webhooks/resend.py:152-167,194-205 | db.get(EmailSuppression, addr) vira lookup por hmac; raw_event=event vira projeção declarada. Handler migra para o webhook-relay. | 5 |
app/api/webhooks/resend.py:174 | O casamento por endereço some: passa a vir de mail.tags.zdid ou do mapa pmid no Redis. | 5 |
app/api/webhooks/evolution.py:295-296,307 | Dedup por external_id fica. raw_event={'text': body[:200]} para de guardar o texto do STOP. | 0 |
app/api/webhooks/evolution.py:289 | log.info("webhook inbound LID %s → %s (notifyName=%r)", phone, resolved, notify_name) passa por mask_phone/mask_text. | 0 |
app/services/unipix.py:109-113 | Para de logar envios[0].numero. | 0 |
app/services/whatsapp_provisioner.py:306-307 | mask_phone. | 0 |
app/services/whatsapp_farm_geelark.py:274 | log.info("digitando numero %s", phone_local) → mask_phone. | 0 |
app/api/leads.py:373-379 | Para de logar user.email (a trilha já registra o ator). | 0 |
app/services/log_redact.py:28-71já existe pronto, commask_phone(preserva DDD + 2 últimos) emask_text('[REDACTED N chars]'em prod, texto limpo em dev, mesmo critériois_prod_env()dos webhooks). São ~10 chamadas. Meio dia de trabalho, e é o item de melhor relação valor/esforço do backlog inteiro.
9.5 Superfície de leitura
| Arquivo:linha | O que fazer | F | |
|---|---|---|---|
app/api/leads.py:110,138 | LeadOut.email: EmailStr sai (vira `str \ | None). Um pseudônimo opaco falha a validação do Pydantic e derruba a listagem inteira com 500 no primeiro deploy — quebra ruidosa e imediata, e por isso melhor que a silenciosa. LeadIn.email continua EmailStr (entrada validada, saída mascarada). LeadOut ganha player_ref, email_masked, phone_masked, name_masked, email_domain, phone_ddd, masked: bool`. | 3 |
app/api/leads.py:158-167 | /v1/leads (limite até 5.000, só Depends(require_auth)): mascarado por padrão, scope.record(action='lead.list'), limite cai para 500. | 3 | |
app/api/leads.py:218-242 | /v1/leads/page — a exfiltração silenciosa: 125 chamadas varrem 62 mil leads sem alarme. Mascarado por padrão; reveal=true exige require_role interno e teto PII_REVEAL_MAX_ROWS=200; contabiliza no mesmo orçamento do export. | 3 | |
app/api/leads.py:600-661 | Timeline: detail=d.recipient (620) → mascarado; detail=(msg.body or '')[:140] (647) → 60 chars com redação por papel; detail=str(je.payload)[:140] (659) → projeção de campos conhecidos (str() do JSONB despeja o corpo interpolado com nome/e-mail num campo de texto). | 3 | |
app/api/leads.py:338-403 | export_leads vira wrapper fino de export_service.request_export(), com purpose obrigatório, budget e export.requested antes de gerar. | 3 | |
app/api/leads.py:397,399-401 | Remover X-Export-Password e o Access-Control-Expose-Headers. Senha em header é logada por ALB, CloudFront e qualquer proxy. Vira GET /v1/exports/{id}/password, leitura única. | 3 | |
app/api/leads.py:380-391 | write_activity(action='lead.export') vira scope.record(...). Já é a semente do alarme — falta o alarme sair da tabela e virar notificação. | 3 | |
app/api/dispatches.py:928-999 | O furo mais grave e o mais barato de fechar. Verificado: require_role('admin','manager') existe, mas não há cifra e não há write_activity — o CSV com Lead.name, Lead.email, Lead.phone_e164 sai puro. Vira 410 Gone por 30 dias; a query (preservando os dois filtros de opt-out, corretos) migra para export_service como kind='batch_temperature'. | 0 | |
app/api/suppressions.py:172-190 | Export migra para export_service (kind='suppressions'), saída pseudonimizada (email_hmac em hex). ?reveal=full exige admin + senha + trilha. Ironia útil: hoje a lista de quem pediu para sair sai sem senha. | 0 | |
app/api/suppressions.py:166,168 | log.info com endereço/telefone em claro → mask_email/mask_phone. | 0 | |
app/repositories/leads_repo.py:31-42 | _apply_search perde o ilike sobre e-mail/telefone. Reescrever para ddd:81, last4:8899, dom:gmail.com, valor completo (igualdade por hmac) e nome por iniciais. Registra {'q_len','q_kind'} — nunca o termo. | 3 | |
app/repositories/leads_repo.py:138-170 | export_rows(exclude_suppressed=True) fica como está (a disciplina de opt-out já está correta). Adicionar registro do row_count real. | 3 | |
app/services/lead_export.py:28-44,75-88 | _COLUMNS mapeia ('Email','email') e _cell_value faz getattr(lead, field, None) com fallback para string vazia: se o campo sumir, o export não quebra — gera planilha com colunas vazias e ninguém percebe. Trocar por acesso explícito que levanta em campo desconhecido, antes de mexer no model. | 2 | |
app/services/lead_export.py:25,58-62,70-72,103-108 | Cifra OOXML, senha de 18 chars e _sanitize_text contra formula injection ficam — já estão certos. | — |
9.6 Decisão, IA e conversas
| Arquivo:linha | O que fazer | F |
|---|---|---|
app/services/journey_engine.py:320-328 | _interpolate monta {{name}} de lead.name or lead.email.split('@')[0]. A interpolação sai do motor de decisão e vai para o plano de envio; o motor emite template + dicionário de vars cifrado. | 4 |
app/services/journey_engine.py:124-125, 89-90 | _eval_condition e _lead_matches_filter param de aceitar email/company como variável de regra. O motor que deveria viver 100% no plano auditável hoje lê e-mail. | 4 |
app/services/journey_engine.py:257-258, app/api/conversations.py:173-196, app/services/davi_agent.py:133-154 | Três caminhos de envio adicionais que leem Conversation.contact_phone direto. Passam pelo Cofre como os outros. | 4 |
app/services/whatsapp_inbox.py:42-51,57-60,106 | Lead.phone_e164 == normalized vira LeadContact.phone_hmac == blind_index(...); get_or_create_thread grava phone_hmac + phone_ct. Toda a caixa de entrada, o handoff e o responder de IA pendem disto. | 4 |
app/services/ai.py:173,191-194, app/services/whatsapp_inbox_ai.py:262, app/services/davi_agent.py:111-113, app/api/leads.py:703-715 | Redigir o contexto do prompt: primeiro nome ou player_ref em vez de nome completo; nunca telefone/e-mail. lead_ctx já é quase todo comportamental (depósito, saque, GGR) — só o campo nome é PII. | 3 |
9.7 Auditoria e instância
| Arquivo:linha | O que fazer | F | |
|---|---|---|---|
app/zion_audit.py:32,43-53 | AUDIT_SINK plugável, default local. Pool asyncpg remoto só com ZION_AUDIT_MIRROR=true. Teste que reprova conexão de saída quando desligado. | 3 | |
app/zion_audit.py:66-111 | write_activity ganha zunit_id, actor_org, rows_affected e delega ao sink. | 3 | |
app/zion_audit.py:255-265 | Mantém o skip para o audit operacional; o pii_audit registra independentemente. | 3 | |
app/zion_audit.py:268-283 | asyncio.create_task fire-and-forget é aceitável para telemetria, não para PII: task que morre com o processo apaga a evidência. Severidade ≥ 2 é síncrona. | 3 | |
app/deps.py:41-42 | session_version já mata todos os tokens antigos no logout — metade da chave de desligamento. Estender require_auth para negar org='zion' quando zion_access_frozen=true (cache Redis de 10 s). | 6 | |
app/deps.py:72-79 | require_role ganha auditor e cliente_admin. Teste de contrato: auditor não passa em nenhum endpoint que devolve PII. | 3 | |
app/api/health.py:20-24 | /health ganha zunit_id, hospedagem, image_digest (env injetada no deploy) e alembic_head (lido no boot, cacheado). É a fonte do painel de frota e do detector de drift. | 6 | |
app/api/uploads.py:22-42 + app/services/storage_s3.py:87,130 | Validação de imagem só acontece com prefix=='telegram' (38); URL pública com max-age=31536000, immutable. Bucket privado + URL assinada com expiração de 15 min. Bucket de export separado. | 0 | |
app/main.py:115-160 | Registrar start_pii_writer(), start_alarm_runner() (60 s) e start_chain_runner() (1 h) no bloco gated por RUN_BACKGROUND_RUNNERS. Adicionar caminho `APP_ROLE=sender | vault` (nenhum router de dados, nenhuma engine). | 3-4 |
app/main.py:126 | _run_dispatch forçado a False com SEND_PLANE_ENABLED=true, com log de boot explícito. Impede religar runners de envio dentro da instância do cliente por engano. | 4 | |
app/main.py:286,326-350 | PiiScopeMiddleware antes do ZionAuditMiddleware; incluir audit_router, exports_router, instance_router, keys_router. | 3 | |
| NOVOS | app/services/pii/{crypto,keyring}.py, app/services/pii_audit.py, app/services/pii_budget.py, app/services/pii_masking.py, app/services/pii_chain.py, app/services/pii_alarm.py, app/services/alarm_notifier.py, app/services/audit_sink.py, app/services/export_service.py, app/services/send_envelope.py, app/repositories/contacts_repo.py, app/workers/send_callbacks.py, app/api/{audit,exports,instance,keys}.py, scripts/backfill_pii.py, scripts/verify_chain.py. Repo separado: zion-send/{runner,keyring,render,relay,emitter,providers/*}.py. | 1-6 |
9.8 CI e testes de invariante
| Arquivo | O que fazer | |||||
|---|---|---|---|---|---|---|
.github/workflows/ci.yml:60-149 | Deploy fixo (cluster zion, conta 384177185434) vira build+cosign sign uma vez e escrita de digest nos manifestos por anel. | |||||
.github/workflows/ci.yml:96-118 | Extrair para .github/actions/zunit-migrate/action.yml. | |||||
tests/test_no_decrypt_in_api_role.py | PII_ROLE=api → decrypt levanta PermissionError. | |||||
tests/test_kms_policy_invariant.py | Role do crm-api com purpose=contact → AccessDenied. | |||||
tests/test_no_read_routes.py | zion-send não tem GET fora de /healthz. | |||||
tests/test_sender_has_no_db.py | APP_ROLE=sender com DATABASE_URL → boot reprova. | |||||
tests/test_receipt_no_pii.py | Recibo não contém nenhum campo da lista proibida. | |||||
tests/test_pii_chain.py | Insere N linhas, verifica, tenta UPDATE/DELETE (deve levantar), apaga como owner e confirma que verify() aponta a seq exata da quebra. | |||||
tests/test_export_paths_guard.py | Nenhum handler CSV/XLSX fora da allowlist. | |||||
tests/test_company_no_phone.py | SELECT count(*) FROM leads WHERE company ~ '^[0-9]{10,13}$' = 0. | |||||
tests/test_phone_canon_convergence.py | Os três normalizadores atuais convergem em 100% sobre 10 mil telefones reais. | |||||
tests/test_killswitch_fails_closed.py | Chave desabilitada no meio do lote → blocked_by_key, nada enviado, nada perdido. | |||||
tests/test_kms_calls_dont_scale_with_recipients.py | Contagem de chamadas KMS não cresce com o número de destinatários (assert dek_scope == 'batch'). | |||||
tests/test_fleet_no_pii.py | Nenhuma coluna de fleet_instances casa com `email\ | phone\ | name\ | cpf\ | recipient\ | body`. |
| CI (grep) | Nenhum nome de cliente no código de app. |
10. Backlog priorizado
10.1 Fases
Fase 0 — Higiene e vedação (1 semana, sem dependência de nada) Muda a conversa comercial em 5 dias.
- Redação de log nos ~10 pontos (
log_redact.pyjá pronto, nunca chamado). - CSV do batch (
dispatches.py:928) → 410 Gone, query migrada. - Export de supressões cifrado e auditado.
- Slug opaco de descadastro no lugar do base64 do e-mail.
- Bucket S3 privado + URL assinada.
EMAIL_PIPELINE=queuecomo default.dinhu_client._response_cachesem PII.raw_eventdo STOP sem o texto.
Pronto quando: grep -rn 'log\.\(info\|warning\|error\)' app/ | grep -i 'phone\|email\|recipient\|notifyName' devolve zero em caminho não redigido; nenhum endpoint devolve CSV/XLSX fora do export_service (teste no CI); nenhum e-mail enviado contém o endereço na URL de descadastro.
Fase 1 — Fundação de cripto (1 semana)
pii_crypto+keyring(KMS e local), migration 0058.- Colapsar os três normalizadores de telefone num só (
dinhu_sync._norm_phone:41-46,whatsapp_inbox._normalize_phone:31-39,whatsapp_anchors.normalize_phone) e provar convergência sobre 10 mil números da base atual. - Testes de propriedade: round-trip de 10 mil valores; AAD trocado tem que falhar; papel
apinão decifra. - Nada em produção ainda. É a semana que decide se o resto funciona.
Pronto quando: os três normalizadores convergem em 100%; test_no_decrypt_in_api_role e test_kms_policy_invariant verdes.
Fase 2 — Cofre em repouso (2 semanas)
- Migrations 0059 + 0060, backfill em chunks com
CREATE INDEX CONCURRENTLYdepois. - Dual-write no
dinhu_sync: grava nos dois lugares. Upsert poremail_hmac. lead_export._cell_valuecom acesso estrito.
Pronto quando: pg_dump da instância mostra lead_contacts como bytes ilegíveis com a base comportamental inteira funcionando ao lado — demonstrável ao cliente na frente dele; o sync diário roda uma semana sem duplicar linha.
Fase 3 — Trilha, alarme e painel (2 semanas, corre em paralelo à 2)
pii_audit+PiiScope+ writer;AUDIT_SINK=local.export_service(pipeline único) +pii_budget+ migration 0064.- Máscara na saída;
LeadOut.emaildeixa de serEmailStr. /v1/audit/*com SSE, verify, alarmes;pii_chaincom checkpoint e âncora;scripts/verify_chain.py.- Front: página
/auditoria(3 abas), tabela de leads com máscara e botão "ver contato", novo fluxo de export com seletor de finalidade.
Pronto quando: uma varredura de 62 mil leads via /page dispara VARREDURA_PAGINADA e chega no webhook do cliente em < 60 s; verify_chain.py detecta a seq exata de uma linha apagada por dentro.
Fase 4 — Plano de envio (3 semanas)
zion-send: consumidor SQS, Ed25519,send-keyring, render, recibos com cadeia, adapters,webhook-relay,callback-emitter.- Corte dos 4 senders (e-mail primeiro — ~90% do volume), migrations 0061 e 0063.
journey_enginesem ler e-mail; inbox 2-way por hash; cooldown por hmac.- Lock de chip por Redis com heartbeat (
SET chip:{id} NX PX 30000), não advisory lock — o send-plane não tem Postgres.
Pronto quando: o e-mail roda inteiro no modelo novo com trilha de decifragem visível; test_no_read_routes, test_sender_has_no_db e test_receipt_no_pii verdes; kill-switch demonstrado ao vivo (cliente desabilita a CMK na conta dele, envio para sozinho em 5 min).
Fase 5 — Supressão por hash + janela de sombra (1 semana de dev + 14 dias de relógio)
- Migration 0062; webhooks gravando por hash com
raw_eventprojetado; engajamento por hmac. - Shadow mode ligado: as duas queries rodam, a união bloqueia, divergência vai para
pii_parity_loge alarma.
Pronto quando: 14 dias consecutivos com only_value = 0 AND only_hash = 0. Qualquer divergência reinicia o relógio. Este é o único gate desta especificação que não se negocia por pressão de prazo.
Fase 6 — zunit e IaC (6 semanas, corre em paralelo desde a semana 1, pessoa de infra)
- Módulo Terraform,
zunit-agent+cosign, papéis PG + schemaleitura+pgaudit, túnel, backup WORM + restauração testada, painel de frota, migrations 0065/0066, kill switch N1–N4, quebra-vidro, piloto real na opção A, runbooks, anexo técnico do contrato.
Pronto quando: terraform apply cria uma instância do zero em < 30 min sem console; nmap na instância não acha porta aberta; restauração mensal automática verde; provisionamento de cliente real por PR.
Fase 7 — Corte (1 dia + risco)
- Migration 0067. Irreversível. Só com Fase 5 verde.
10.2 Calendário consolidado
Premissa: 2 backend sênior + 1 infra sênior, mais 1 front pleno em meio período nas semanas 6-9. Com um sênior a menos, some 4 semanas.
| Sem | Backend A (dado) | Backend B (envio) | Infra |
|---|---|---|---|
| 1 | Fase 0 completa | Fase 1: pii_crypto, keyring, canon único | Módulo Terraform zunit |
| 2 | 0058 + 0059, scripts/backfill_pii.py | Testes de propriedade, key policy KMS | zunit-agent + cosign no CI |
| 3 | Dual-write no dinhu_sync, upsert por hmac | zion-send esqueleto, fila, SendTicket, mTLS | Papéis PG, schema leitura, pgaudit, túnel |
| 4 | pii_audit + export_service + 0064 | Adapters SES/Resend, render, recibos encadeados | Backup WORM + restauração testada |
| 5 | pii_budget, pii_alarm, /v1/audit/*, pii_chain | webhook-relay + callback-ingestor + 0060 | Painel de frota, heartbeat, 0065 |
| 6 | Máscara na saída, LeadOut, busca degradada | Corte do e-mail (feature flag por canal) | Piloto opção A, 0066 |
| 7 | /v1/instance/*, kill switch, front (½) | Corte do SMS, cooldown por hmac | Runbooks, anexo de contrato |
| 8 | 0062 + SHADOW MODE ON | Corte do WhatsApp + on-device, 0061/0063 | Quebra-vidro ponta a ponta |
| 9 | Paridade, front /auditoria | Inbox 2-way por hash, journey_engine limpo | Segundo piloto |
| 10-11 | Relógio da paridade | Tampar buracos, test_killswitch_fails_closed | Frota |
| 12 | 0067 — corte | — | — |
Marcos demonstráveis: S1 (higiene visível e CSV fechado) · S4 (pg_dump ilegível ao vivo) · S6 (kill-switch ao vivo, o cliente aperta) · S9 (painel de auditoria completo) · S12 (corte final).
Esforço bruto: ~30 pessoa-semanas. Calendário: 12 semanas. O que empurra o fim não é trabalho, é a janela de paridade — que é tempo de relógio, não de gente.
10.3 Dependências externas que atrasam se não forem pedidas cedo
| Item | Quando pedir | Bloqueia |
|---|---|---|
| Acesso à conta AWS do cliente (role OIDC, criação da CMK e key policy) | Na assinatura, não no meio | Fase 6 semana 6 |
| Aceite por escrito da perda de busca parcial | Antes da semana 6 | Fase 3 |
| Escolha do modo de TTL da DEK (300 s vs. estrito) | Na proposta | Fase 4 |
| Nome e e-mail do encarregado de dados que recebe alarme | Onboarding | Fase 3 |
| Túnel + mTLS entre o farm de WhatsApp da Zion e a instância do cliente | Semana 3 | Fase 4 — sem folga no cronograma |
| Parecer jurídico sobre região de hospedagem | Semana 1 | Fase 6, e é irreversível depois de assinado |
10.4 Decisões ainda em aberto
| # | Decisão pendente | Quem decide | Prazo | Se não decidir |
|---|---|---|---|---|
| D-A1 | Região de hospedagem para casa regulada. Hetzner é 3-4× mais barato mas não tem região no Brasil. Lei 14.790/2023 + portarias SPA/MF impõem requisitos de hospedagem e de acesso pelo regulador. | Jurídico | Semana 1 | Padrão conservador: sa-east-1 para cliente regulado; Hetzner só para operação não regulada |
| D-A2 | Busca parcial (ilike) morre. %5581% e %gmail% deixam de existir; sobram ddd:, last4:, dom:, valor completo e iniciais. Perda real para o operador de mesa. | Comercial + cliente | Antes da semana 6 | Mostrar as buscas novas funcionando antes de tirar a antiga; sem aceite, não cortar |
| D-A3 | Canal on-device (GeeLark). Fica fora da promessa forte e é vendido como canal de exceção com consentimento explícito, ou é desligado para o cliente que exigir a versão forte. | Dono | Antes da 1ª proposta | Não vender a versão forte |
| D-A4 | Modo de TTL da DEK: 300 s (padrão) ou 0 (estrito, ~10 mil chamadas KMS/dia). | Cliente, no contrato | Por contrato | 300 s, declarado como SLA |
| D-A5 | Opção B exige backup cifrado com CMK sem Decrypt para a Zion? | Dono | Antes de vender B | Tratar como obrigatório (3 dias de trabalho) |
| D-A6 | Segundo banco dinhu + anon key do Supabase de terceiro: some da zunit ou vira credencial do cliente. | Produto | Fase 6 | Desligado por default (ZUNIT_FEATURES) |
| D-A7 | Piso de contrato para opção B (custo direto de R$ 1.000 a R$ 2.400/mês + custo humano). | Comercial | Antes do 1º piloto B | Opção A como padrão abaixo de ~R$ 8-10 mil/mês |
| D-A8 | Retenção de dispatches (proposto 180 dias para ciphertext e corpo; métrica fica). | Jurídico + comercial | Fase 4 | 180 dias |
| D-A9 | Cadência de rotação da PIK (cara, exige janela de manutenção). | Contrato | Antes do 1º contrato | "Sob demanda, com janela agendada" |
11. O que NÃO resolvemos
Os limites honestos. Cada um deles aparece na primeira due diligence técnica da casa; é melhor estarem no anexo do contrato do que serem descobertos.
1. O envio on-device continua fora. app/services/dispatch/whatsapp_ondevice.py:216-217 monta https://api.whatsapp.com/send?phone={digits}&text={quote(body)} e essa URL é digitada num cloud phone da GeeLark (openapi.geelark.com), provedor fora do Brasil. Telefone e corpo da mensagem em querystring, em infraestrutura de terceiro que a Zion não audita. Nenhuma arquitetura de instância dedicada muda isso. Esta especificação reduz a exposição à janela do lote (120 s, com trilha) — não elimina.
2. Auditar não é impedir. A trilha registra que a Zion decifrou o contato no disparo. Ela não impede. A garantia é detectabilidade e não-retenção, não impossibilidade de acesso. Quem quiser impossibilidade precisa da opção A e aceitar que o envio deixe de passar pelo farm da Zion — o que não existe hoje.
3. Os provedores de canal veem o contato — por definição. SES, Resend, Brevo, SMTP, UniPix, Evolution e GeeLark recebem destinatário e corpo. É o trabalho deles. O que muda é que passam a receber de um processo sem banco e sem memória, com retenção declarada por destino no anexo técnico. A lista de saídas do perímetro precisa estar no contrato — quatro itens na opção A com a Fase 2 pronta: envelope cifrado → zion-send; zion-send → provedor; contexto redigido → Anthropic; heartbeat sem PII → painel de frota.
4. Na opção B, o pg_dump é a fronteira — até a Fase 2 fechar. Enquanto nenhuma coluna estiver cifrada, uma instância na conta da Zion contém a base inteira em texto puro numa conta da Zion. A instância dedicada resolve blast radius e auditoria; não resolve isso. Vender A como padrão até a Fase 2 entregar.
5. Imutabilidade forte depende de âncora fora do alcance da instância. Se AUDIT_ANCHOR_KEY vive numa env var de uma instância que a Zion administra, quem for root ali pode reescrever a trilha e recalcular os checkpoints. Sem Object Lock COMPLIANCE na conta do cliente, o que se promete é "append-only por desenho e detectável" — não "inviolável".
6. Revogação não é instantânea. Com PII_DEK_TTL_SECONDS=300, ainda há até 5 minutos de envio com a DEK aberta em heap depois do DisableKey. O modo estrito zera a janela ao custo de latência e de dependência dura do KMS. Prometer 5 minutos é verdade verificável; prometer instantâneo é mentira.
7. Perda real de funcionalidade. (a) Busca parcial sobre e-mail/telefone deixa de existir (app/repositories/leads_repo.py:31-42). (b) O feed de campanha mostra template + body_digest, não o texto exato enviado (hoje d.body = send_body persiste — whatsapp_sender.py:268). (c) Export de batch sai mascarado por padrão. São três conversas com o operador, e elas pertencem à assinatura do contrato, não à entrega.
8. Histórico após exclusão de lead só existe em agregado. ON DELETE CASCADE em lead_contacts deixa dispatches antigos irrecuperáveis. Isso é desejável — é o direito ao esquecimento funcionando — mas quebra relatório histórico que tente reidentificar. recipient_hmac permanece: dá para contar sem identificar.
9. Rotação da chave de índice é cara e não automatizável. Rotacionar a DEK de contato é barato (preguiçoso, por versão). Rotacionar a PIK exige recalcular o índice cego da base inteira, de todas as supressões e de todos os dispatches históricos.
10. O custo por cliente é plano. ~R$ 1.000 a R$ 1.400/mês, sem economia de escala de infraestrutura. É a natureza do modelo dedicado — quem quer economia de escala vende multi-tenant, e multi-tenant é exatamente o que a casa não aceita. A escala está no custo de software e humano, e só se sustenta com zero fork.
11. O risco de supressão silenciosa é o maior do projeto e não desaparece — só é contido. email_suppressions.email e phone_suppressions.phone_e164 são PK textuais hoje, e o filtro pré-envio compara valor em lotes de 5.000 (app/services/dispatch/common.py:175-192, verificado). Se o hash não casar por qualquer diferença de normalização, o sistema não dá erro: continua mandando para quem pediu para sair, no meio de um blast de 500 mil. Isso é multa de LGPD mais queima de reputação de domínio (complaint rate > 0,3% suspende a conta). A janela de sombra de 14 dias é a contenção. Sem ela verde, a 0067 não sobe — nem sob pressão de prazo.
A frase que esta arquitetura sustenta
A base de jogadores fica numa instância que é só sua, num banco onde o contato está cifrado com uma chave que só você controla. A Zion segmenta, decide e mede sem nunca ver o contato. No instante do disparo — e só nele — um serviço sem banco e sem memória abre a chave, envia e esquece, deixando recibo assinado de cada abertura no log da sua própria conta. Quando você quiser, você desliga a chave, sozinho, e em cinco minutos nada mais sai.
Nada além disso.