Zion · camada de performance sobre a base

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

61páginas
comprovado
fonte externa
estimativa
—%ainda a validar

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.

  1. pg_dump + a credencial do app não abrem contato. A role do crm-api tem kms:Encrypt e kms:GenerateDataKey para purpose=contact, mas não tem kms:Decrypt. Quem tem Decrypt não tem credencial de Postgres.
  2. Toda decifra deixa rastro. Existe uma porta única, e ela grava em pii_access_log dentro da instância do cliente antes de responder. Não existe caminho de leitura de contato que não gere linha de trilha.
  3. O cliente desliga sozinho. aws kms disable-key executado 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 dadosPlano de envio
Onde viveDentro da zunit (instância do cliente)Conta da Zion
Tem bancoSim (Postgres da zunit)Não — boot reprova se DATABASE_URL estiver preenchido
Tem contato em claroNunca em repousoSomente em heap, ~400 ms por lote
Rotas de leituraSim (API do CRM)Nenhuma — teste no CI varre app.routes
Chave KMSEncrypt, GenerateDataKey (contact) + Decrypt (index)Decrypt (contact)
PersistênciaPostgres + S3Redis 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ênciaDecisãoPor quê
D1Onde decifra: cofre dentro da instância vs. serviço de envio na ZionAmbos, 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.
D2Onde mora o ciphertext do contatoTabela 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.
D3Escopo da DEK: por versão ou por loteTrê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).
D4Uma trilha ou váriaspii_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.
D5Numeração de migrationsSequência única 0058 → 0067, definida na seção 4.6.Quatro frentes propunham 0058 simultaneamente.
D6Nome da unidadezunit = 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.

AspectoComo fica
CMKNa conta dele. A Zion tem, no máximo, grant de Decrypt para o principal do zion-send. Nunca GetKeyPolicy, nunca export.
pg_dump / backupDele. Ele é root do backup. Esta é a razão de A ser defensável mesmo antes da cifra de coluna estar pronta.
Trilha de auditoriaAUDIT_SINK=local — grava no Postgres dele. Espelho para o zion-core só com ZION_AUDIT_MIRROR=true, opt-in, e só metadado.
CloudTrailDele. 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.
Deployzunit-agent em pull: a instância busca o manifesto assinado, verifica com cosign e se atualiza. A Zion nunca entra.
Acesso humano da ZionNão existe. Só quebra-vidro aprovado por ele, com TTL e pgaudit.log=all.
Custo de nuvemDele. É 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.

AspectoComo fica
CMKNa 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 / backupDa 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 clienteRole zunit_ro_cliente sobre o schema leitura, via túnel, default_transaction_read_only=on, statement_timeout=120s, CONNECTION LIMIT 5.
TrilhaIdem A: local, mais âncora de checkpoint em bucket com Object Lock na conta do cliente.
Custo diretoR$ 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 — hoje grep pgcrypto|pgp_sym|Fernet|AES app/ devolve zero fora do lead_export, que cifra apenas o arquivo XLSX de saída — o pg_dump de 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çoCamposEvidênciaCrit.
leads (62 mil a 868 mil linhas)email (unique, index), name, phone_e164 (index), company (o MESMO telefone), notesapp/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
conversationscontact_phone (index), contact_name, last_message_previewapp/db/models/conversation.py:54-55,74ALTA
messagesbody (NOT NULL), media_url, intent, sentimentapp/db/models/message.py:37-39,51-52ALTA
whatsapp_threadsphone_e164, last_message_previewapp/db/models/whatsapp_thread.py:31,35ALTA
whatsapp_messagesbody (Text NOT NULL)app/db/models/whatsapp_message.py:28,37ALTA
email_suppressionsemail É A PRIMARY KEY, texto puro; raw_event guarda o payload cru do Resendapp/db/models/email_suppression.py:36,43; app/api/webhooks/resend.py:160,203ALTA
phone_suppressionsphone_e164 É A PRIMARY KEY; raw_event com o texto do STOPapp/db/models/phone_suppression.py:37,49; app/api/webhooks/evolution.py:307ALTA
telegram_cadastro_leadstelegram_id, username, first_nameapp/db/models/telegram_cadastro.py:22-35MÉDIA
whatsapp_warmup_anchorsphone_e164 (unique)app/db/models/whatsapp_warmup_anchor.py:27MÉDIA
whatsapp_instances / _provisioning_jobsnúmeros dos chips da Zionapp/db/models/whatsapp_instance.py:40; ..._provisioning_job.py:42MÉDIA-BAIXA
journey_events.payloadreasoning da IA + corpo interpolado com {{name}}/{{email}}app/api/leads.py:513-529; app/services/journey_engine.py:322-328MÉDIA
dinhu_client._response_cacheTTLCache na heap do worker com respostas cruas da API do Dinhuapp/services/dinhu_client.py:30,154MÉDIA
Banco dinhu (2º database no mesmo Postgres)snapshots do Supabase dinhutech via anon key com RLS abertaapp/services/snapshot_sync.py:6-7,140-153; app/db/dinhu_session.py:31-34MÉDIA
Logs CloudWatchtelefone, notifyName, primeiro destinatário do lote SMS, e-mail do operadorapp/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-379ALTA
zion.activity_log (fora da instância)user_id, ip, user_agent, payloadapp/zion_audit.py:32,88-111,286ALTA
tracking_linksslug opaco + lead_idsem contatoapp/db/models/tracking_link.py:27-35BAIXA — é 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);
Particionar pii_access_log por 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. DROP de 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 o head_hash descartado.
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. dispatches cresce ~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 schema leitura é o contrato estável. Ele existe justamente para que a Fase 2 possa trocar leads.email por coluna cifrada sem quebrar o BI do cliente. Nenhuma view de leitura expõe lead_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;
company sai junto. Hoje ele guarda o mesmo telefone (app/services/dinhu_sync.py:236-239,259). Qualquer trabalho que cifre só phone_e164 deixa 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

NomeConteúdoReversível
0058pii_keyring_auditpgcrypto, crypto_keys, pii_access_log + triggers, pii_access_checkpointsim
0059lead_contactsderivados em leads, lead_contactssim
0060dispatch_envelopecolunas de envelope em dispatches e dispatch_batch_metasim
0061unsubscribe_tokenstabela de slug opacosim
0062suppressions_hmaccolunas hmac + projeção de raw_event + pii_parity_logsim
0063conversations_threads_hmachmac/ct em conversas e threadssim
0064alarm_export_callbackpii_alarm_rule, pii_alarm, export_job, send_callback_inboxsim
0065instance_controlinstance_control, breakglass_sessionsim
0066roles_leiturapapéis, grants, schema leitura, pgaudit, papel cliente_adminsim
0067drop_plaintextirreversível — só com 14 dias de paridade verdenã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

ComponenteArquivoResponsabilidadePapel que carrega
pii_cryptoapp/services/pii/crypto.py (~180 linhas)Único ponto que sabe cifrar e derivar índice cego. Funções puras, sem I/O.todos
keyringapp/services/pii/keyring.pyCiclo de vida da DEK e da PIK. GenerateDataKey na ingestão, Decrypt no cofre. Cache com TTL.api/worker/vault/sender
zunit-vaultimagem única, APP_ROLE=vaultReveal de 1 registro para a tela. Sem DATABASE_URL, sem disco.vault
zion-sendrepo zion-sendMaterialização de lote no disparo. Sem banco, sem rota de leitura.sender
contacts_repoapp/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:

PrincipalAções permitidasCondição
role/zunit-<slug>-api, role/zunit-<slug>-workerkms:Encrypt, kms:GenerateDataKeyEncryptionContext:purpose = contact
idemkms:DecryptEncryptionContext:purpose = index
role/zunit-<slug>-vaultkms:Decryptpurpose IN [contact, index]
role/zion-sendkms:Decryptpurpose = contact
Principal do CLIENTEkms: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

ChaveComo rotacionaCustoAutomatizável
DEK-contatoINSERT de nova versão em crypto_keys + flip de is_active. Nada é re-cifrado: leitura resolve pela key_version da própria linha.segundossim
DEK-envelopeNasce e morre por lote.zeron/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çãonã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 clienteEfeitoLatência
kms:DisableKeyIngestã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-sendidem, só para o plano de envioidem
POST /v1/instance/killswitch {"nivel":1}dispatch_enabled=false; a fila queued é preservada< 10 s
POST /v1/send/killpurge 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:

  • aad amarra o ciphertext a zunit + lote + canal + expiração. Reusar o ct em outro envelope falha na tag do GCM.
  • exp máximo 900 s. Envelope vencido é recusado e devolvido como state=failed, error=envelope_expired.
  • targets máximo 50 (limite do SES SendBulkEmail e da UniPix por request). ~15 KB por mensagem, teto SQS de 256 KB.
  • Ausência de sig válida = mensagem descartada e alarme, nunca processada.
  • Fila: MessageRetentionPeriod=1800, SSE-KMS com a CMK do cliente, DLQ com maxReceiveCount=5. Na opção A a fila fica na conta dele e o zion-send assume role cross-account com apenas ReceiveMessage/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

  1. Empacotamento (plano de dados). Claim atômico de sempre — SELECT ... FOR UPDATE SKIP LOCKEDUPDATE ... RETURNING, commit antes de qualquer HTTP (mecânica já em produção em app/services/dispatch/email_sender.py:95-112). O RETURNING muda 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 de lead_contacts num único SELECT ... WHERE lead_id = ANY(...). Ele lê BYTEA que não sabe abrir.
  2. Monta o envelope, calcula o AAD, assina Ed25519, publica na SQS.
  3. Consumo. ReceiveMessage (visibilidade 300 s). Verifica assinatura, exp, e idempotência (SETNX env:{envelope_id} TTL 24 h).
  4. Abertura da chave. open_dek(kid, key_ref, dek_wrapped)kms:Decrypt. Uma chamada por lote por réplica, cacheada por SEND_DEK_TTL_S. Cada chamada emite recibo de decifragem encadeado por hash. Em paralelo, o Decrypt aparece no CloudTrail da conta do cliente.
  5. Decifragem e renderização. Abre template_ct e cada targets[].ct em heap. Interpola, expande spintax (app/services/spintax.py::expand portado para zion_send/render.py), monta a URL de descadastro a partir do unsub_slug{PUBLIC_BASE_URL}/v1/u/{slug}, sem nenhum dado derivado do endereço — e calcula body_digest.
  6. Envio. Injeta o ponteiro opaco no protocolo do provedor: SES MessageTags[zdid], Resend tags + X-Entity-Ref-ID, UniPix smsClienteId (já é opaco hojeapp/services/dispatch/sms_sender.py:112-116), Evolution/GeeLark sem eco → mapa pmid:{provider_message_id} → dispatch_id no Redis com TTL de 30 dias.
  7. Zeramento. Ao sair do try, plaintext é sobrescrito e liberado. held_ms mede quanto tempo viveu.
  8. Recibo. Publica na fila de retorno, deleta a mensagem SQS. Nada volta para o banco além de ponteiro opaco.
  9. Ingestão do recibo (app/workers/send_callbacks.py). Verifica assinatura, idempotência por receipt_id (send_callback_inbox), atualiza dispatches por dispatch_id (nunca por e-mail/telefone), grava a linha em pii_access_log com os campos sender_* da cadeia do emissor, e valida que sender_prev_hash do recibo N == sender_hash do 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:

  1. mail.tags.zdid (SES) ou consulta pmid:{provider_message_id} no Redis;
  2. calcula blind_index com a PIK;
  3. descarta todo o resto do payload — o to, o subject, os headers;
  4. 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;
  • /tmp em tmpfs;
  • rootfs read-only;
  • logging.Filter global que descarta qualquer record cujo texto case com regex de e-mail ou telefone — defesa em profundidade, porque o log.exception de 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:32ZION_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_search grava {'q_len': len(q), 'q_kind': 'email'|'phone'|'nome'} e o query_fingerprint. Buscar por ana@x.com é PII em si.

7.3 Imutabilidade

Três camadas, em ordem de força:

  1. Trigger trg_pii_no_change / trg_pii_no_truncateUPDATE, DELETE e TRUNCATE levantam exceção.
  2. Grantzunit_app tem SELECT, INSERT; UPDATE, DELETE, TRUNCATE revogados (0066). Separação de privilégio no banco, não convenção de código.
  3. 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 offlinescripts/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 hojeProblemaDestino
app/api/dispatches.py:928 — CSV nome+email+telefonesem 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 linhassenha no header X-Export-Password (:397), exposto via Access-Control-Expose-Headers (:399-401) — qualquer proxy logawrapper fino de export_service
app/api/suppressions.py:182-190 — CSV de descadastrossem senha, sem registrokind='suppressions', saída pseudonimizada
Bucket S3 público (app/services/storage_s3.py:87,130) — CacheControl: public, max-age=31536000, immutableURL adivinhável e cacheada por um anobucket privado + rota autenticada

Fluxo do pipeline único:

  1. POST /v1/exports {kind, params, purpose, purpose_note} — finalidade obrigatória, de lista fechada.
  2. COUNT antes de materializar.
  3. > 5.000 linhas → alarme e segue. > 25.000status='blocked', exige aprovação de um segundo admin (POST /v1/exports/{id}/approve, ator ≠ solicitante).
  4. Grava síncrono export.requested (severidade 2).
  5. Gera XLSX cifrado em to_thread (reusa build_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).
  6. Grava export.completed com row_count real e sha256.
  7. 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.
  8. Todo export com row_count > 1.000 dispara webhook no ato, independente de haver alarme.
Guard anti-regressão: tests/test_export_paths_guard.py percorre app.routes e falha se qualquer handler declarar response_class/media_type de CSV ou XLSX fora da allowlist EXPORT_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 auditornã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 libera pull/create/start/stop e nunca exec — sem docker 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:

  1. Push em main → CI (lint + pytest) verde.
  2. Build de uma imagem, --provenance=false --platform linux/amd64, tag por SHA, push no ECR, cosign sign no digest.
  3. Job de rollout escreve o digest nos manifestos do anel corrente e abre commit no zion-deploy.
  4. 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.
  5. Cada zunit-agent se atualiza sozinho.
  6. Drift aparece como número no painel de frota — "3 instâncias em a1b2c3, 1 em 9f8e7d, 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ívelO que fazTempoReversívelQuem
N1 — Pausar envioinstance_control.dispatch_enabled=false. O scheduler para de reivindicar. Fila queued preservada.< 10 ssim, 1 cliquecliente_admin
N2 — Congelar acesso da ZionIncrementa 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 ssimcliente_admin
N3 — Cortar a chavekms:DisableKey. Materialização, reveal e ingestão param em ≤ TTL. Disparo falha fechado. Banco intacto e legível por ele.≤ 300 ssim (EnableKey)cliente_admin + MFA
N4 — DestruirSnapshot final cifrado entregue no bucket deleterraform destroykms:ScheduleKeyDeletion (7 dias) → expurgo do backup.72 h de espera + 15 minnão2 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

  1. Suporte da Zion abre chamado e pede acesso pelo painel de frota.
  2. O cliente aprova em POST /v1/instance/breakglass/aprovar, com motivo e duração (máx. 60 min).
  3. A instância cria zunit_support_<id> com senha efêmera, default_transaction_read_only=on, pgaudit.log='all', statement_timeout=60s.
  4. 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$.

PortePerfilUS$/mêsR$/mês
P1até 150 mil leads, 500 mil envios/mês — t4g.large + EBS 150 GB + IPv4 + S3 200 GB + KMS + CW82445
P2até 1 M leads, 3 M envios/mês — c7g.2xlarge + EBS 500 GB + réplica de leitura + backup2871.550
P2-RDSidem, banco gerenciado exigido em contrato4302.320
P3acima de 2 M leads — 2 hosts + RDS m7g.xlarge Multi-AZ1.1005.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)InfraPlataformaTotal/mêsPor cliente
14459501.3951.395
54.2351.2005.4351.087
1514.8651.50016.3651.091
4039.6402.15041.7901.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:linhaO que fazerF
app/db/models/lead.py:30-36,57Remover 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-102Adicionar as colunas de envelope. recipient/body viram nullable na 0060 e somem na 0067.4
app/db/models/email_suppression.py:36,43email_hmac ao lado; PK troca só na 0067. raw_event vira projeção.5
app/db/models/phone_suppression.py:37,49idem.5
app/db/models/__init__.pyExportar CryptoKey, LeadContact, PiiAccessLog, PiiAccessCheckpoint, PiiAlarm, PiiAlarmRule, ExportJob, UnsubscribeToken, InstanceControl, BreakglassSession, SendCallbackInbox, PiiParityLog.1
app/config.py:11-24APP_ROLE aceita 'sender' e 'vault'. Guard de boot: sender/vault com DATABASE_URL preenchido reprova.4
app/config.py:183EMAIL_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-32connect_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-31Fixar cryptography>=43 explícito (hoje entra só transitivamente por pyjwt[crypto]). boto3 já está.1
Dockerfile:25CMD 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:linhaO que fazerF
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-334select(...).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-389O 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-34O 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:linhaO que fazerF
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-187if 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,420Mesmo corte no caminho SES 1-a-1.4
app/workers/email_sender.pyDeixa 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-12O 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-116SmsEnvio(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-268send_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-275send_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,453is_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-217A 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:36Dispatch.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::expandPortado 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:linhaO que fazerF
app/services/phone_suppression.py:28-31canon() 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-90Grava e consulta por phone_hmac.5
app/services/email_engagement.py:64func.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-68make_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-103suppress_email recebe email_hmac (ou lead_id vindo do slug).5
app/api/webhooks/resend.py:152-167,194-205db.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:174O 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,307Dedup por external_id fica. raw_event={'text': body[:200]} para de guardar o texto do STOP.0
app/api/webhooks/evolution.py:289log.info("webhook inbound LID %s → %s (notifyName=%r)", phone, resolved, notify_name) passa por mask_phone/mask_text.0
app/services/unipix.py:109-113Para de logar envios[0].numero.0
app/services/whatsapp_provisioner.py:306-307mask_phone.0
app/services/whatsapp_farm_geelark.py:274log.info("digitando numero %s", phone_local)mask_phone.0
app/api/leads.py:373-379Para de logar user.email (a trilha já registra o ator).0
app/services/log_redact.py:28-71 já existe pronto, com mask_phone (preserva DDD + 2 últimos) e mask_text ('[REDACTED N chars]' em prod, texto limpo em dev, mesmo critério is_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:linhaO que fazerF
app/api/leads.py:110,138LeadOut.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/pagea 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-661Timeline: 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-403export_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-401Remover 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-391write_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-999O 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-190Export 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,168log.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-170export_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-108Cifra OOXML, senha de 18 chars e _sanitize_text contra formula injection ficam — já estão certos.

9.6 Decisão, IA e conversas

Arquivo:linhaO que fazerF
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-154Trê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,106Lead.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-715Redigir 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:linhaO que fazerF
app/zion_audit.py:32,43-53AUDIT_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-111write_activity ganha zunit_id, actor_org, rows_affected e delega ao sink.3
app/zion_audit.py:255-265Mantém o skip para o audit operacional; o pii_audit registra independentemente.3
app/zion_audit.py:268-283asyncio.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-42session_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-79require_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,130Validaçã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-160Registrar 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=sendervault` (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-350PiiScopeMiddleware antes do ZionAuditMiddleware; incluir audit_router, exports_router, instance_router, keys_router.3
NOVOSapp/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

ArquivoO que fazer
.github/workflows/ci.yml:60-149Deploy fixo (cluster zion, conta 384177185434) vira build+cosign sign uma vez e escrita de digest nos manifestos por anel.
.github/workflows/ci.yml:96-118Extrair para .github/actions/zunit-migrate/action.yml.
tests/test_no_decrypt_in_api_role.pyPII_ROLE=apidecrypt levanta PermissionError.
tests/test_kms_policy_invariant.pyRole do crm-api com purpose=contactAccessDenied.
tests/test_no_read_routes.pyzion-send não tem GET fora de /healthz.
tests/test_sender_has_no_db.pyAPP_ROLE=sender com DATABASE_URL → boot reprova.
tests/test_receipt_no_pii.pyRecibo não contém nenhum campo da lista proibida.
tests/test_pii_chain.pyInsere 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.pyNenhum handler CSV/XLSX fora da allowlist.
tests/test_company_no_phone.pySELECT count(*) FROM leads WHERE company ~ '^[0-9]{10,13}$' = 0.
tests/test_phone_canon_convergence.pyOs três normalizadores atuais convergem em 100% sobre 10 mil telefones reais.
tests/test_killswitch_fails_closed.pyChave desabilitada no meio do lote → blocked_by_key, nada enviado, nada perdido.
tests/test_kms_calls_dont_scale_with_recipients.pyContagem de chamadas KMS não cresce com o número de destinatários (assert dek_scope == 'batch').
tests/test_fleet_no_pii.pyNenhuma 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.py já 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=queue como default.
  • dinhu_client._response_cache sem PII.
  • raw_event do 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 api nã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 CONCURRENTLY depois.
  • Dual-write no dinhu_sync: grava nos dois lugares. Upsert por email_hmac.
  • lead_export._cell_value com 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.email deixa de ser EmailStr.
  • /v1/audit/* com SSE, verify, alarmes; pii_chain com 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_engine sem 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_event projetado; engajamento por hmac.
  • Shadow mode ligado: as duas queries rodam, a união bloqueia, divergência vai para pii_parity_log e 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 + schema leitura + 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.

SemBackend A (dado)Backend B (envio)Infra
1Fase 0 completaFase 1: pii_crypto, keyring, canon únicoMódulo Terraform zunit
20058 + 0059, scripts/backfill_pii.pyTestes de propriedade, key policy KMSzunit-agent + cosign no CI
3Dual-write no dinhu_sync, upsert por hmaczion-send esqueleto, fila, SendTicket, mTLSPapéis PG, schema leitura, pgaudit, túnel
4pii_audit + export_service + 0064Adapters SES/Resend, render, recibos encadeadosBackup WORM + restauração testada
5pii_budget, pii_alarm, /v1/audit/*, pii_chainwebhook-relay + callback-ingestor + 0060Painel de frota, heartbeat, 0065
6Máscara na saída, LeadOut, busca degradadaCorte do e-mail (feature flag por canal)Piloto opção A, 0066
7/v1/instance/*, kill switch, front (½)Corte do SMS, cooldown por hmacRunbooks, anexo de contrato
80062 + SHADOW MODE ONCorte do WhatsApp + on-device, 0061/0063Quebra-vidro ponta a ponta
9Paridade, front /auditoriaInbox 2-way por hash, journey_engine limpoSegundo piloto
10-11Relógio da paridadeTampar buracos, test_killswitch_fails_closedFrota
120067 — 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

ItemQuando pedirBloqueia
Acesso à conta AWS do cliente (role OIDC, criação da CMK e key policy)Na assinatura, não no meioFase 6 semana 6
Aceite por escrito da perda de busca parcialAntes da semana 6Fase 3
Escolha do modo de TTL da DEK (300 s vs. estrito)Na propostaFase 4
Nome e e-mail do encarregado de dados que recebe alarmeOnboardingFase 3
Túnel + mTLS entre o farm de WhatsApp da Zion e a instância do clienteSemana 3Fase 4 — sem folga no cronograma
Parecer jurídico sobre região de hospedagemSemana 1Fase 6, e é irreversível depois de assinado

10.4 Decisões ainda em aberto

#Decisão pendenteQuem decidePrazoSe não decidir
D-A1Regiã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ídicoSemana 1Padrão conservador: sa-east-1 para cliente regulado; Hetzner só para operação não regulada
D-A2Busca 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 + clienteAntes da semana 6Mostrar as buscas novas funcionando antes de tirar a antiga; sem aceite, não cortar
D-A3Canal 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.DonoAntes da 1ª propostaNão vender a versão forte
D-A4Modo de TTL da DEK: 300 s (padrão) ou 0 (estrito, ~10 mil chamadas KMS/dia).Cliente, no contratoPor contrato300 s, declarado como SLA
D-A5Opção B exige backup cifrado com CMK sem Decrypt para a Zion?DonoAntes de vender BTratar como obrigatório (3 dias de trabalho)
D-A6Segundo banco dinhu + anon key do Supabase de terceiro: some da zunit ou vira credencial do cliente.ProdutoFase 6Desligado por default (ZUNIT_FEATURES)
D-A7Piso de contrato para opção B (custo direto de R$ 1.000 a R$ 2.400/mês + custo humano).ComercialAntes do 1º piloto BOpção A como padrão abaixo de ~R$ 8-10 mil/mês
D-A8Retenção de dispatches (proposto 180 dias para ciphertext e corpo; métrica fica).Jurídico + comercialFase 4180 dias
D-A9Cadência de rotação da PIK (cara, exige janela de manutenção).ContratoAntes 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.