# Desenho: porte do coreproviders para a Monetarie

**Data:** 2026-07-27
**Escopo:** caixa institucional 52600005-0, split geral de comissão, blindagem do
merchant-front e da API, TB Gateway em Go, e a escala de 3 milhões de operações por dia.
**Base de comparação:** `/Users/luizpenha/coreproviders` branch `aviv-hml`, que opera
mais de 2 milhões de operações em 24 horas.
**Regra desta auditoria:** nenhuma afirmação sem `arquivo:linha`. Onde não houve
leitura, está escrito que não houve.

---

## 1. O discernimento que sustenta todo o resto

Os dois sistemas têm **a mesma costura de provedor** e é por isso que o porte é
viável. O que muda é só o que fica embaixo dela.

| | coreproviders (AvivPay) | Monetarie |
|---|---|---|
| Contrato | `services/pix_providers/behaviour.ex` + `provider.ex` | idêntico, mesmos módulos |
| Adapter | `pix_providers/onz/` (24 arquivos: `client`, `auth`, `mapper`, `poller`, `lp_client`, `dict_bucket/`, `endpoints/{dict,med,infractions,icom,qrcode,returns,claims}`) | `pix_providers/in_house/` (5 arquivos: `adapter`, `cabin_operation_query`, `cabin_status_lookup`, `cabin_treasury`, `mgmt_formatter`) |
| Quem fala com o BACEN | **a OnZ CloudPIX**, HTTP puro porta 80 por VPC peering | **a nossa cabine**: `dict_service`, `spi_service`, `settlement_service`, RSFN direto |
| Assinatura | delegada à OnZ | HSM RTM, chave privada nunca na aplicação |
| Balde DICT | espelho Redis do `AvailableTokens` da OnZ | balde nosso, calibrado por `GET /policies/` |

**Consequência operacional deste desenho:** nada da pasta `pix_providers/onz/` é
portado. Tudo o que está acima da costura (contas, subcontas, tarifas, split, API
externa, MED, infração, webhooks, telas) é portável, porque nos dois lados conversa
com a mesma `Provider`. Onde o coreproviders chama `Provider.create_key/3` contra a
OnZ, a Monetarie chama a mesma função contra a cabine por NATS.

---

## 2. Fluxo do dinheiro com a conta 52600005-0

### 2.1. Papel da conta

`52600005-0` é **conta corrente de recebimento de receitas** e exerce o papel de
**caixa institucional**: recebe o valor **líquido**, depois de pagas as comissões dos
intermediários. É o espelho exato do `caixa_aviv` do coreproviders (kind 21, id 10003
na Aviv), decisão do dono em 27/07.

A separação entre reconhecimento contábil e caixa realizado é mantida de propósito: a
tarifa é reconhecida na conta COSIF de receita (`mapped.fee_pix`, código
`7.1.7.05.50.00-001`) e o caixa recebe a apropriação. É o que permite conciliar
receita contra caixa.

### 2.2. O batch

Hoje, na Monetarie, uma transação tarifada gera as pernas T1 a T4, a tarifa cai na
conta COSIF de receita, e termina. Não há apropriação para conta corrente, e o split
só roda no PIX-in e no MED.

Alvo:

```
batch linkado do TigerBeetle (atômico, root = id da operação)
├─ perna 0..3    T1..T4: operação + cobrança da tarifa   →  FEE (COSIF receita)
├─ perna 10..15  split de comissão: FEE → cada intermediário   (teto de 6)
└─ perna 9       apropriação do resíduo: FEE → 52600005-0      (caixa)
                 resíduo = tarifa total − soma das comissões
```

Ou o batch inteiro liquida, ou nada liquida. A perna 9 é montada **depois** das pernas
de split, porque o resíduo depende delas. É a assinatura que o coreproviders já usa:
`build_apropriation_leg(root_id, fee_amount, split_transfers, mapped)`
(`payments/caixa_aviv_mirror.ex`).

O mirror do coreproviders é guardado por quatro condições, todas fail-safe: flag
ligada, caixa mapeada no `MappedCosifAccounts`, tarifa maior que zero, e conta
existente no TigerBeetle. Se qualquer uma falha, ele devolve `[]` e o batch segue sem a
perna, em vez de derrubar a operação. O porte preserva as quatro.

### 2.3. O que falta na Monetarie para ligar a conta

| Requisito | Por quê | Estado hoje |
|---|---|---|
| `accounts.kind = 21` | convenção de caixa institucional | campo `kind` existe (`schemas/relational/account.ex:55`), sem validação nem semântica institucional. O coreproviders valida em `accounts/account.ex:199` |
| conta TigerBeetle correspondente | o mirror não emite a perna se ela não existir | a criar |
| alias `caixa_institucional` no `MappedCosifAccounts` | o mirror lê `mapped.caixa_*`; sem isso devolve `[]` | **ausente**. A struct da Monetarie só tem `fee_pix`; a do coreproviders tem `fee_pix` obrigatório mais `expense_planner_scd` e `caixa_aviv` opcionais |
| isenção de tarifa por código | senão a instituição se autotarifa na devolução PACS.004 | **ausente**. Falta o `@institutional_zero_fee_kinds [21, 22]` de `fee_calculator.ex:49` |

O último item merece destaque. O `FeeCalculator` do coreproviders tem uma **primeira
cláusula** `calculate(%{account_id: id})` que curto-circuita conta institucional para
tarifa zero. O da Monetarie casa só em `%{client_type:, fee_type:}` e não tem esse
curto-circuito. Ligar a 52600005-0 sem portar isso faz a instituição cobrar tarifa de
si mesma toda vez que a conta de devoluções originar uma PACS.004 de sub-rogação.

---

## 3. O motor de tarifa e split: 11 achados

### 3.1. O modelo do coreproviders é GERAL por cascata

`SplitResolver.resolve/4` desce quatro níveis:

```
conta específica → merchant → entidade (merchant_id NULL e account_id NULL) → fallback unificado
```

E o fallback converte `pix_in_transfer` **e** `pix_out_transfer` no mesmo tipo
`pix_transfer`. Ou seja: **uma única configuração de entidade cobre todas as contas nas
duas direções**. É isso que o `EnsureVulciSplit` instala (Vulci, 25 subcentavos fixos,
teto de 6 participantes, idempotente, dry-run por padrão).

A Monetarie tem esse motor **portado fiel, linha a linha**, com `@source` e
`@sha b7d4a770` no cabeçalho. O problema não é o motor. É o que está em volta dele.

### 3.2. Os achados

| # | Achado | Evidência | Gravidade |
|---|---|---|---|
| 1 | Teto de 10 participantes contra motor que suporta 6 | `fee_split_config.ex:32` contra `batch_chain.ex:23` | P0 latente |
| 2 | `client_type: :pj` fixo no PIX-out, tarifando PF | `payments/outbound/pix.ex:426` | P0 vivo |
| 3 | Mesmo hardcode em TED, TEF e no FeeCharger | `ted.ex:250`, `tef.ex:178`, `outbound_requests.ex:213` | P1 |
| 4 | `fee_type: :tef` fora do catálogo. TEF nunca tarifado | `tef.ex:178` contra `fee_config.ex:27` | P1 |
| 5 | `conta_manutencao` vedada para PF lá, não vedada aqui | `@pf_vedacoes_absolutas` contra `@essential_pf_zero` | conferir compliance |
| 6 | Nível merchant da cascata de split morto | `banking/account_resolver.ex:448` | P0 funcional |
| 7 | `SplitResolver` sem `TtlCache` | `split_resolver.ex:18` do CP, ausente aqui | P1 escala |
| 8 | Split não roda no PIX-out nem na devolução | 2 call sites contra 4 | P0 receita |
| 9 | Sem perna de apropriação para o caixa institucional | falta o par de `caixa_aviv_mirror.ex` | P0 receita |
| 10 | Sem `EnsureVulciSplit`, backfill, commands, queries e relatório de comissão | 8 módulos ausentes | P1 operação |
| 11 | Sem isenção de tarifa para conta institucional | `fee_calculator.ex:49` do CP, ausente aqui | P0 ao ligar a 52600005-0 |
| 12 | **O split está desligado nos dois ambientes. Nenhuma comissão jamais foi paga** | ver 3.6 | P0 receita |
| 13 | `execute_and_record/6` não existe no coreproviders: id aleatório e batch separado | `split_distributor.ex:117` | P0 latente |

### 3.6. Detalhe do achado 12: o split nunca rodou

Provado por três caminhos independentes em 27/07:

1. `SplitDistributor.enabled?` lê `Monetarie.Bench.FeatureFlags.enabled?(:enable_fee_split)`
   (`split_distributor.ex:189`).
2. `FeatureFlags.enabled?/1` faz `Keyword.get(config, flag, false)`, ou seja **o padrão
   é falso** (`bench/feature_flags.ex:59`), e `grep -rn "Bench.FeatureFlags\|:enable_fee_split"`
   em `core/backend/config/` não devolve **nenhuma** linha que sete a chave.
3. `aws ecs describe-task-definition` de `monetarie-core-api-prod` não tem **nenhuma**
   variável de ambiente com `SPLIT`, `FEE`, `FLAG` ou `BENCH`.

Logo `enabled?` devolve falso sempre, e `build_split_transfers` sai no primeiro `if` com
`{[], []}`. **Nenhuma comissão de intermediário foi paga, em nenhum ambiente, em nenhum
caminho**, nem sequer nos dois caminhos que têm o call site (PIX-in e MED).

Isso reordena a prioridade: antes de ligar a chave é preciso ter o motor correto, senão
liga-se um motor com o teto de participantes errado (achado 1) e sem o nível merchant
da cascata (achado 6).

### 3.7. Detalhe do achado 13

O coreproviders tem só `build_split_transfers/6` mais `record_splits/4`, de propósito:
as pernas de split entram **no mesmo batch linkado atômico** da operação, com id
determinístico `BatchChain.leg_id(root, 10 + idx)`.

A Monetarie tem esses dois **e mais** um `execute_and_record/6` que não existe lá
(`split_distributor.ex:117`). Ele usa `Monetarie.Util.ID.int128()` como id da
transferência, ou seja **id aleatório**, e submete um **batch separado** por
`Tigerbeetle.create_transfers(linked)`. Duas consequências:

- o TigerBeetle deduplica por id. Id aleatório significa que **um retry paga a comissão
  duas vezes**;
- batch separado não é atômico com a operação: ou paga comissão de operação que não
  liquidou, ou liquida operação sem pagar comissão.

Hoje é código morto (só os testes referenciam), mas está armado. Remover ou reescrever
sobre `leg_id`.

### 3.3. Detalhe do achado 1

Os dois repositórios têm `@max_batch_legs 16` e `leg_id/2` que **levanta exceção**
quando `n >= 16`. Os dois usam `leg_id(root, 10 + idx)` para as pernas de split. O
coreproviders capa em 6 e o comentário explica a conta: `10 + 6 = 16` estouraria. A
Monetarie capa em 10 e o comentário se perdeu no porte. Cadastrar 7 participantes passa
no changeset e depois estoura `raise "Batch leg 16 exceeds max 16"` **dentro do batch
TigerBeetle do PIX-in**. Não é erro de validação, é exceção em runtime na liquidação.

### 3.4. Detalhe do achado 2 e a regra PF x PJ

Regra do dono: **PF não paga PIX, PF paga TED, PJ sempre paga.**

O `FeeCalculator` da Monetarie já implementa isso corretamente:
`@pix_pf_vedado = [:pix_out_transfer, :pix_out_purchase, :pix_in_transfer, :pix_automatic]`
(`fee_calculator.ex:87`) e `check_bacen_vedacoes_pix(%{client_type: :pf, fee_type: ft})`
devolve zero. `ted_out` e `ted_in` ficam de fora, então PF paga TED. E `:pj` não casa
com nenhuma vedação, cai no `general_case` e é sempre cobrado.

**O furo não está na regra, está em quem informa o `client_type`.**
`payments/outbound/pix.ex:426` fixa `client_type: :pj` e descarta o `account_id`
recebido (`_account_id`). Como `:pf` nunca chega ao `check_bacen_vedacoes_pix`, **toda
conta PF é tarifada no PIX-out**, contra a vedação da Resolução BCB 19/2020.

É assimétrico: o PIX-in faz certo, resolvendo o tipo real por
`AccountResolver.resolve_account_context(account_id)` (`pix/tb_first/deposit.ex:253`).
Então PF não é cobrado na entrada e é cobrado na saída.

**Honestidade sobre a origem:** o coreproviders tem **o mesmo hardcode**
(`outbound_payment/pix.ex:1066`, `person_type: :pj`, ocorrência única no arquivo). Lá é
latente, porque o PIX-out sai de merchant PJ. Aqui é ativo, porque a Monetarie tem
conta PF no IB. O defeito veio junto no porte, não foi introduzido aqui.

A correção é pequena: `AccountResolver.resolve_account_context/1` já existe, é cacheada
em ETS e deriva PF ou PJ de `users.user_type` mais o tamanho do `tax_id`
(`banking/account_resolver.ex:456`). Basta os caminhos de saída chamarem o que o
caminho de entrada já chama.

### 3.5. Detalhe do achado 6

`account_resolver.ex:448` diz: *"The second tuple element (`merchant_id`) is always
`nil` in Monetarie. If/when Monetarie adds a merchants concept, this function will be
updated"*.

**Esse comentário está desatualizado.** A Monetarie tem merchants desde 22/07:
migration `20260722100000_create_merchants_and_merchant_users.exs`, schemas
`schemas/merchants/{merchant,merchant_user}.ex`, e o plug `MerchantScope` resolvendo
corretamente. A tabela chegou e o resolver nunca foi atualizado.

Enquanto `merchant_id` vier nil, `find_merchant_split(_entity_id, nil, _fee_type)`
corta na primeira cláusula e **o nível merchant da cascata de split fica morto**. Só
funcionam o nível conta e o nível entidade. Não dá para configurar comissão por
merchant, que é exatamente o caso de uso "fee split para intermediários".

Este achado e o furo da subconta (seção 4) são a **mesma lacuna estrutural**: a conta
não recebe `merchant_id` na criação, e o resolver não lê `merchant_id` da conta.

---

## 4. Abertura de subconta: o P0 de isolamento

`admin/merchants_controller.ex:713` tem 35 linhas. O `SubcontaCreator` do coreproviders
tem 568. Faltam 11 coisas, três delas graves.

**Graves:**

1. **Sem `merchant_id` na conta.** É esse campo que o coreproviders usa para barrar
   reuso entre merchants: `BLOCKED cross-merchant reuse: account belongs to merchant X,
   request from Y` (`subconta_creator.ex:90`). Sem ele **não há isolamento entre
   merchants**.
2. **Sem chave EVP registrada no DICT.** No coreproviders é passo transacional dentro
   do `Ecto.Multi`: se o registro falha, a subconta inteira é revertida. E a chave vai
   com **CNPJ e razão social do merchant**, nunca com o CPF do operador
   (`subconta_creator.ex:184`: *"Subcontas belong to the merchant PJ, the user is just
   an operator"*).
3. **Senha temporária no corpo do JSON** (`temporaryPassword: password`), em vez de
   e-mail de convite.

**Demais:** validação de CPF e CNPJ com dígito verificador; bloqueio do documento do
próprio titular; bloqueio de CPF já vinculado a outro e-mail; criação do `MerchantUser`
com `role` e teto de `capabilities`; `AccountAccess`; `disable_ib`; e `"name" => email`,
que faz o nome do titular virar o e-mail.

**Adaptação obrigatória no porte:** onde o `SubcontaCreator` chama
`Provider.create_key(entity_id, dict_params, merchant_cnpj)` contra o adapter OnZ, a
Monetarie chama a mesma função contra o adapter `in_house`, que resolve por NATS no
`dict_service`. A assinatura é a mesma; o que muda é o adapter registrado para a
entidade.

---

## 5. Segurança do merchant-front e da API

Decisão do dono em 27/07: **MFA obrigatório no merchant-front** para o operador humano,
e **whitelist de IP fail-closed para tudo que é API**, configurável pelo próprio
merchant no painel e totalmente integrada ao WAF da AWS.

### 5.1. O que já está certo e não se mexe

A API externa da Monetarie está **mais dura que a do coreproviders**:
`require_ip_whitelist: true` fail-closed em todo `/api/external`, incluindo o
`/balance` (`router.ex:282`); HMAC-SHA512 nos POSTs de dinheiro com
`Plug.Crypto.secure_compare`; idempotência com replay de 24 horas só de 2xx; rate limit
distribuído de 90 mil por 60 segundos; e a segunda camada no IPSet do WAF. O painel do
merchant já escreve a whitelist (`v2/api_key_controller.ex:82`). Essa frente é
validação, não obra.

### 5.2. Os três furos

**MFA opcional no merchant-front.** `merchant/auth_controller.ex:94` faz
`if user.totp_enabled do` e, no `else`, entrega token de **8 horas só com senha**. É
exatamente o furo fechado hoje nos 4 consoles admin pelo commit `c6bcc223` ("Senha
sozinha nao vale sessao"), e que continua aberto justamente na porta das subcontas.

**Integração com o WAF incompleta.** O coreproviders enfileira o sync **a cada mudança
de chave** (`use_cases/api_keys/commands.ex:193`) e faz um sync **no boot**
(`application.ex:495`). A Monetarie tem só o reconciliador de cron a cada 5 minutos
(`config/runtime.exs:659`) e **nenhum dos dois gatilhos**. Resultado: o merchant altera
a whitelist no painel, a camada de banco passa a valer na hora, e a borda do WAF fica
até 5 minutos desatualizada.

Vale registrar o alerta que o coreproviders aprendeu na prática: o WAF **recusa CIDR
`/0`** e recusa o `UpdateIPSet` inteiro, então uma entrada ruim congela a allowlist de
todos. A Monetarie já tem o `ApiKeyBundle` que descarta `/0` na saída; confirmar que a
validação de entrada também barra.

**`IpWhitelistRule` é CRUD órfão.** A tabela, o schema
(`schemas/security/ip_whitelist_rule.ex`) e a tela admin existem, e **nenhum plug
consome**. `grep -rn "IpWhitelistRule"` devolve apenas o próprio controller. Cadastra e
não bloqueia nada. Como a decisão do dono põe a whitelist na camada de API key, este
CRUD precisa de destino: ou vira a fonte da whitelist administrativa, ou sai.

---

## 6. TB Gateway em Go

### 6.1. Por que

A Monetarie tem **a mesma arquitetura NIF** que gerou o incidente na Aviv: pool de
conexões `:tb_0` a `:tb_N` (`application.ex:379`), `deposit_coalescer`,
`batch_accumulator` e `circuit_breaker`.

O diagnóstico do coreproviders, medido e documentado em
`docs/plans/2026-07-07-tb-gateway-grpc-design.md`:

- o cluster TigerBeetle está **ocioso** (CPU 1 a 2 por cento). Não é o gargalo;
- o gargalo é o **cliente**: `clients_max=64` é limite **hard**, compilado na imagem
  oficial, e o NIF é **1 request em voo por conexão**;
- subir o pool é o anti-padrão que a documentação do TigerBeetle proíbe, e foi o que
  causou o incidente de 07/07 por evicção de sessão ao cruzar 64;
- a saída oficial é **batching**: menos clientes, requests maiores. O client oficial
  agrega submissões concorrentes automaticamente, em lotes de até 8189.

### 6.2. O desenho

Serviço gRPC em Go, **um único client oficial compartilhado por instância**, 2 a 3
instâncias ECS. O total de sessões TigerBeetle passa a ser o número de instâncias do
gateway, e para de crescer com o número de nós do backend. O backend Elixir vira
cliente gRPC. Stateless, puro pass-through.

### 6.3. Faseamento, que é inegociável

| Fase | Escopo | Risco |
|---|---|---|
| 1 | só leitura: `lookup_account` do balance-check do PIX-out | zero no caminho do dinheiro |
| 2 | escrita: `create_transfers` do depósito PIX-in e do débito PIX-out, com idempotência dedicada | alto, exige prova em HML |
| 3 | o resto: queries e bulk reads. Aposenta o NIF e o coalescer manual | médio |

**Invariante:** enquanto uma fase não estiver provada, o caminho antigo pelo NIF segue
vivo como fallback, com troca por feature flag **por tipo de chamada**. Flags a portar:
`TB_GATEWAY_LOOKUP_ENABLED`, `TB_GATEWAY_WRITE_ENABLED`, `TB_GATEWAY_ADDR`,
`TB_GATEWAY_DEADLINE_MS`, `TB_GATEWAY_WRITE_DEADLINE_MS`, `TB_GATEWAY_KEEPALIVE_MS`,
`TB_GATEWAY_RECONNECT_DEBOUNCE_MS`.

Duas lições já pagas pela Aviv que entram no porte desde o primeiro dia: o debounce de
reconexão (`RECONNECT_DEBOUNCE_MS`, máximo um drop de canal a cada 10 segundos) e a
remoção do `GRPC.Stub.disconnect`, que causa `FunctionClauseError` no grpc-elixir
0.11.5.

---

## 7. Escala: 3 milhões de operações por dia

Mix informado pelo dono: **75 por cento PIX-in, praticamente todo por QR Code, e 25 por
cento PIX-out.** Ou seja 2,25 milhões de entradas e 750 mil saídas.

### 7.1. O balde do DICT não é a parede

- PIX-in por QR **não consome ficha nenhuma** do nosso balde. Quem consulta o DICT é o
  PSP do pagador; nós só servimos o payload assinado em `GET /qr/v2/:access_token`.
- **A ficha volta.** Está implementado e documentado no
  `dict_budget_calibration_worker.ex`: o BACEN devolve uma ficha quando a ordem de
  pagamento chega ao SPI, e o espelho replica quando a pacs.008 daquele `EndToEndId`
  liquida e passa pelo `StatusUpdater`. Recalibração contra `GET /policies/` a cada 5
  minutos.
- Logo o balde da categoria G se comporta como **limite de concorrência, não de vazão
  diária**: teto de 250 consultas em voo que ainda não viraram pagamento.
- Conta: 750 mil por dia são 8,7 por segundo de média. Com a latência PIX de produção
  já medida, de 3,1 segundos, são cerca de 27 consultas em voo contra teto de 250.
  **Folga de nove vezes.** O aperto só aparece em pico sustentado acima de 80 por
  segundo.
- A tabela de categorias do plug foi conferida contra o Manual Operacional do DICT
  versão 8.2 item 13.1 e corrigida em 26/07 (`dict_rate_limit.ex:31`). Não é preciso
  pedir reenquadramento para este mix.

### 7.2. O que realmente aperta

**O JWS do QR é re-assinado a cada GET, sem cache** (`payload_controller.ex:39`). São
2,25 milhões de assinaturas RSA por dia. É assinatura local, não HSM: o `JwsSigner` usa
`CertificateManager.get_private_key` mais `JOSE.JWS.sign`. A 26 por segundo de média é
barato, mas é desperdício puro e vira gargalo de CPU no pico. Cachear por
`access_token`, invalidando na mudança de status.

**O `SplitResolver` sem `TtlCache`.** O coreproviders cacheia a cascata resolvida
(inclusive o `nil`) por 60 segundos, justamente porque são até 6 idas ao banco e
**todas erram** para a conta comum sem split. Na Monetarie isso roda cru no hot path de
2,25 milhões de depósitos por dia.

**O TB Gateway**, seção 6, é o item estrutural desta lista.

---

## 7.3. O que o banco de produção respondeu em 27/07

Sonda somente leitura, por `bin/monetarie rpc` no nó vivo do `core-api` de produção
(ECS Exec, sem criar task nem alterar nada).

| Pergunta | Resposta de produção |
|---|---|
| A conta 52600005 existe? | **Sim.** `id 2630`, `kind 3`, agência `0001`, número `052600005`, DV `0`, `account_type payment`, COSIF `4.9.8.10.01.10.002` |
| Que kinds existem? | **só `kind 3`**, nas 1607 contas. Não existe conta institucional |
| merchants e merchant_users | **0 e 0** |
| Contas com `merchant_id` | **0 de 1607** |
| Subcontas órfãs | **0**, porque nenhuma subconta foi criada |
| `fee_split_configs` | **0** |
| `fee_split_transactions` | **0** |
| `fee_transactions` | **0** |
| Movimento real | **140 PIX e 138 TED, todos em julho de 2026** |
| Volume por dia, últimos 7 dias | entre **1 e 63** |
| Pico por hora, últimos 7 dias | **49** |

Duas leituras que precisam ficar registradas para não se perder:

**1. As 14.219 linhas de `transactions` com `type='fee'` são do ETL do legado, não do
motor vivo.** Elas vão de julho de 2025 a junho de 2026, em curva decrescente, e são
**zero em julho de 2026**, que é justamente o mês de todo o movimento PIX e TED real.
O `FeeCharger` grava em `fee_transactions` (`fee_charger.ex:616`), e essa tabela está
vazia. Conclusão precisa: **o motor de tarifa portado nunca cobrou nada em produção, e
o split nunca pagou nada.**

**2. Não há remediação de dados a fazer nas subcontas.** Como `merchants` está zerada,
nenhuma subconta nasceu pelo caminho fraco. Dá para consertar o `SubcontaCreator` antes
de o primeiro dado nascer torto. É a melhor notícia desta auditoria.

## 7.4. O alvo de 3 milhões por dia é construção de capacidade, não otimização

A produção hoje faz entre 1 e 63 transações por dia, com pico de 49 por hora. O alvo de
3 milhões por dia são 34,7 por segundo de média. **O alvo é da ordem de 2.500 vezes o
pico atual.**

E a infraestrutura está dimensionada para o volume de hoje, não para o alvo:

| Item | Valor em produção |
|---|---|
| `core-api` | `launchType EC2`, **desired 1** |
| Instâncias do cluster EC2 | **2 x `t4g.small`** (burstable), 1846 MB registrados, 822 MB livres |
| Task do `core-api` | **512 unidades de CPU (meia vCPU)** e 1024 MB |
| `TB_POOL_SIZE` | **1** |
| Créditos de CPU | 576, no teto, porque não há carga |
| `pix-api`, `spb-api`, `sta-api` | Fargate |

O `core-api` é quem faz o ledger, a materialização, a tarifa e o split. Ele roda hoje em
**meia vCPU de uma instância burstable**, com **uma única sessão TigerBeetle**. Sob carga
sustentada a `t4g.small` cai para o baseline quando os créditos acabam.

Ou seja: antes do TB Gateway, antes do cache de JWS, antes do balde do DICT, a primeira
parede é o dimensionamento do `core-api`. O TB Gateway continua necessário, porque
subir `TB_POOL_SIZE` é o anti-padrão que a documentação do TigerBeetle proíbe, mas ele
resolve a sessão, não a CPU.

## 7.5. MED e infração: paridade de contrato (auditado em 27/07)

O discernimento vale integralmente aqui. No coreproviders o transporte é o adapter
OnZ; na Monetarie é a nossa cabine por NATS. A comparação abaixo é de **contrato e
máquina de estados**, nunca de transporte.

### 7.5.1. Brecha VIVA de isolamento entre subcontas (P0)

O coreproviders fechou este exato buraco no commit `e356b14f`
("fecha vazamento entre subcontas + abertura de infracao/MED nas 3 telas") criando
o `PixCompliance.ScopeGuard`. O motivo, no `@moduledoc` dele:

> os endpoints consultam a OnZ, que **NAO conhece a nossa estrutura de subcontas**,
> ela so sabe do ISPB da instituicao. Sem um filtro nosso, o retorno traz MED de
> TODAS as contas da entidade, e o detalhe por id vira IDOR.

**A nossa cabine também não conhece subconta.** Ela também opera por ISPB da
instituição. O transporte muda, a vulnerabilidade não.

Estado na Monetarie, conferido superfície por superfície:

| Superfície | Escopo | Veredito |
|---|---|---|
| `partner_v1` (API de clientes, `api.monetarie.com`) | `with_partner_account` + `ensure_account_transaction` amarrando pelo `rootTransactionId` / `end_to_end_id`; `scoped_med/4` e `scoped_infraction/4`; **404 e nunca 403** | **BLINDADA.** Equivale ao `ScopeGuard` |
| `v2/merchant_portal_controller.ex:571,586` | `authorize_merchant` e depois `pix_provider().list_infractions(entity_id, params)`, resultado devolvido **cru** | **VAZA** |
| `v2/dict_controller.ex:242,254` | `authorize(conn, merchant_id)` e depois `Provider.list_infractions(entity_id, params)`, resultado **cru** | **VAZA** |

Nas duas superfícies que vazam, `authorize` só prova que o usuário pertence
**àquele** merchant. A consulta seguinte é feita com o `entity_id`, que é o escopo da
**instituição inteira**, e o retorno não é filtrado. Consequência: qualquer merchant
autenticado enxerga infração de todos os outros, e o detalhe por id aceita qualquer
id.

`grep -rn "ScopeGuard\|filter_owned"` na Monetarie não devolve nada. O único
`owned?/2` existente é o de `external/transactions_controller.ex:461`, que cobre
transações e não compliance.

A correção não é inventada: o padrão certo já existe **dentro da própria Monetarie**,
no `partner_v1`. É estender esse mesmo gate às duas superfícies do merchant.

### 7.5.2. Superfície `/api/integration/*` ausente por inteiro

O coreproviders tem um router dedicado (`integration_router.ex`), autenticado por
**token de integração** (`IntegrationTokenAuth`), isolado da `/api/external`, com
HMAC nas escritas. Serve para gestão programática de MED por sistemas internos,
escopada tenant-wide pelo `entity_id` do token. São 10 rotas:
`GET/PUT /med/config`, `GET/PUT /infraction/config`, `GET /med`, `GET /med/:id`,
`POST /med/:id/defense`, `POST /med/:id/hold`, `POST /infraction/:id/hold`,
`POST /med/:id/respond`.

Na Monetarie **não existe**: nem o router, nem o plug de autenticação.

E aqui está o detalhe que dói: a Monetarie **tem a tela que emite os tokens**
(`GET/POST /med-integration-tokens` e `POST /med-integration-tokens/:id/revoke`, no
parity controller). Ou seja, **emite credencial para uma API que não existe**.

### 7.5.3. Ações administrativas ausentes

Presentes no coreproviders e sem equivalente na Monetarie:

| Rota do coreproviders | O que faz |
|---|---|
| `GET /infractions/export` | exportação de infrações |
| `POST /infractions` | abrir infração pelo admin |
| `POST /med-recoveries` | criar recuperação MED |
| `GET /med-defenses` | listar defesas pendentes |
| `POST /med-cautelar-blocks/:id/evidence` | anexar evidência ao bloqueio |
| `POST /med-cautelar-blocks/:id/execute-return` | executar a devolução |
| `GET /med-cautelar-blocks/:id/refunds` | refunds do bloqueio |
| `POST /med/cautelar/manual` | bloqueio cautelar manual |
| `POST /med/drain/:account_id` | drenar conta sem fundo (o `unfunded_drainer.ex` existe aqui, o endpoint não) |
| `GET /pix-compliance/med-notifications` | notificações de MED |

Nos casos de uso, faltam `manual_cautelar_block.ex`, `orphan_hold_reconciler.ex`,
`infraction_enrichment.ex`, `close_reconciliation.ex`, `recovery_queries.ex`,
`report.ex` e o próprio `scope_guard.ex`.

### 7.5.4. O que a Monetarie tem a mais

Não é só déficit. A Monetarie tem no `partner_v1` o `POST /pix/med/:id/refund` e o
`GET /pix/med/:id/graph`, e no merchant o fluxo de defesa com **upload e remoção de
documentos** (`upload_defense_document`, `delete_defense_document`,
`save_defense`, `submit_defense`), que o coreproviders não tem nesse formato.

## 8. O que ainda não foi auditado

Registrado para não virar afirmação sem prova:

- MED e infração campo a campo, na API e nas 3 telas;
- contrato `/api/external` payload a payload contra a documentação publicada;
- conteúdo das 94 telas homônimas do merchant-front (nome igual não é código igual). O
  único nome ausente é `WebhookDeliveriesView.vue`; os outros quatro são renomeação;
- PIX-in e PIX-out ponta a ponta;
- **estado vivo na AWS**: se `AWS_WAF_SYNC_ENABLED` está ligado (o padrão do código é
  desligado), se já existem subcontas em produção criadas pelo caminho fraco (o que
  abriria frente de remediação de dados), e se a conta 52600005-0 já existe em
  `accounts` e no TigerBeetle.

Regra do `CLAUDE.md`: número copiado a mão envelhece, comando não. Nada de estado de
infraestrutura entra neste documento sem `./scripts/estado-vivo.sh`.

---

## 9. Ordem de execução

1. **Estado vivo na AWS** (tarefa 8). Bloqueia decisões sobre a 52600005-0 e sobre
   remediação de subcontas.
2. **Motor de tarifa e split** (tarefa 3) mais o **caixa institucional** (seção 2).
   Trilha do dinheiro: constrói e testa em homologação, e o deploy em produção é
   agendado pelo dono, porque a regra proíbe deploy de pix ou spb com o dono operando
   o caminho do dinheiro.
3. **Subconta** (tarefa 4) e **segurança** (tarefa 5). Trilha independente, não toca no
   TigerBeetle, entra primeiro em produção.
4. **Escala** (tarefa 7). Cache do JWS e `TtlCache`, ambos isolados.
5. **TB Gateway** (tarefa 6). Trilha longa, faseada, com o NIF vivo como fallback.
6. **Fechar a auditoria** (tarefa 1), em paralelo com tudo.
