# P0 de verificação: risco de crédito DUPLO na TED-in (dois emissores sem dedup cruzado)

Data: 2026-07-10
Status: **VERIFICADO EMPIRICAMENTE EM HML (2026-07-10 noite) E CORRIGIDO.** Veredito na seção 6.
Origem: mandato de mapeamento de 2026-07-10 (descoberto ao conciliar divergência entre as trilhas de arquitetura SPB, integração e simulador SPB).

## 0. Veredito empírico (resumo)

Prova E2E sintética em HML (dois eventos publicados no NATS para a mesma STR0008R2 de R$ 0,03, conta 1176749):

- **O dinheiro do cliente NÃO duplica.** A ponte creditou exatamente uma vez (TB +300 subcentavos na conta 10022682, 1 linha de extrato ref `STR20260710990000111`, 1 transação `SPBCRSTR...`, árbitro `credited_member`). O caminho legado nunca chega ao depósito TB porque o payload settled carrega `account_id` STRING (CtCredtd) e `Wallet.deposit` exige inteiro (`spb_handler.ex` rescue de FunctionClauseError).
- **MAS a contabilidade duplicava**: o caminho legado criou um lançamento COSIF órfão no journal do Core (`reference_id IF20260710990000111`, sem movimento TB por trás) ANTES de falhar no depósito, e a ponte criou o seu JE próprio 1 segundo depois. Resultado: 2 JEs no balancete para 1 TED, um deles órfão. Toda TED-in orgânica de produção sujaria o balancete assim.
- **Fix aplicado no Core (TDD)**: `SpbHandler.handle_transaction_updated` para settled/confirmed NÃO rastreado com direção inbound vira espelho de status (audita `SPB_INBOUND_SETTLED_MIRROR`, zero ação de ledger); o crédito e o JE são exclusivos da ponte. Teste de regressão: `test/monetarie/infra/nats/handlers/spb_handler_inbound_settled_mirror_test.exs`. Família SPB de testes do Core: 26 testes, 0 falhas.
- Ganho colateral: a recriação do banco de teste do zero expôs colisão de partições em `20260704133000` com sessão de Postgres fora de UTC (dev local); corrigida na própria migration (igualdade de bound também sob a sessão corrente).

## 1. O achado

Para UMA STR0008R2 recebida (TED entrando para cliente nosso), a cabine SPB publica DOIS eventos ao Core, ambos incondicionais (sem flag), e o Core tem um caminho de crédito de ledger para CADA um, com chaves de idempotência DIFERENTES e sem dedup cruzado identificado.

### Emissor 1: hook legado `monetarie.spb.transactions.settled`

- `spb/services/bacen_gateway/lib/bacen_gateway/message_processor.ex:739` chama `CoreNotifier.notify_inbound_settled` no estágio de recepção, sem flag.
- `spb/services/bacen_gateway/lib/bacen_gateway/nats/core_notifier.ex:69-107`: se `inbound_str_credit?`, publica `monetarie.spb.transactions.settled` com `transaction_id` = NumCtrlIF ou NUOp (`core_notifier.ex:225-229`), `account_id` = CtCredtd, `amount` = VlrLanc, `direction` = inbound.
- No Core: `spb_consumer.ex:93` despacha `monetarie.spb.transactions.*` para o catch-all `SpbHandler.handle_transaction_updated` (`core/backend/lib/monetarie/infra/nats/handlers/spb_handler.ex:60`). Para transação externa não rastreada, cai em `handle_transaction_ledger` (`spb_handler.ex:159`), que credita TigerBeetle via `Wallet.deposit` com transfer id determinístico derivado de `transaction_id`/`message_id` (`spb_handler.ex:357-366`) e cria journal/extrato com `reference_id` = NumCtrlIF/NUOp (`spb_handler.ex:258-271`).

### Emissor 2: ponte canônica `monetarie.spb.credits.inbound` (Fase 4/5)

- `spb/services/bacen_gateway/lib/bacen_gateway/lifecycle_engine.ex:1524` enfileira o evento `credit_received` no outbox NA MESMA transação do INSERT da operação inbound (branch CREATED apenas, ou seja, deduplicado no lado SPB).
- No Core: `spb_consumer.ex:66` despacha para o handler DEDICADO `SpbInboundCreditHandler`, com árbitro de idempotência `spb_inbound_credits.num_ctrl_str` UNIQUE (`spb_inbound_credit_handler.ex:194-217,245`). Credita TigerBeetle e grava extrato/transactions com referência `num_ctrl_str` e `transaction_id` = `"SPBCR" <> num_ctrl_str` (`core/backend/lib/monetarie/use_cases/spb/inbound_credits.ex:300-360`).

### Por que não há proteção cruzada

- O árbitro `num_ctrl_str` do handler dedicado NÃO é consultado pelo caminho legado.
- As referências são diferentes (NumCtrlIF/NUOp no legado vs NumCtrlSTR na ponte), então os transfers TigerBeetle determinísticos NÃO colidem, e os lançamentos de extrato têm `reference` distintos.
- O comentário no próprio `spb_consumer.ex:62-65` reconhece o perigo do catch-all ("NUNCA roteia pelo catch-all SpbHandler.handle_transaction_updated, que posta no ledger para tipo desconhecido"), mas o catch-all continua recebendo o settled do hook legado.

## 2. O que a história recente prova (e o que não prova)

- Os R$ 50.000 de 2026-07-06 (STR0008R2, conta 117674-9) foram creditados pela PONTE: o registro do incidente cita a referência `SPBCRSTR...`, que é o esquema `"SPBCR" <> num_ctrl_str` do `SpbInboundCreditHandler`. Só que aquilo foi um REPLAY MANUAL de um único evento do outbox, durante a pane em que o Core estava surdo ao NATS. Um replay de um evento só não exercita o caminho duplo.
- O histórico do git não ajuda a datar os caminhos (o repositório foi importado por squash em 2026-06-20, os dois emissores aparecem juntos no import).
- Em produção o acervo SPB veio de import AutBank, que não passa por esse pipeline. Não temos evidência de nenhuma TED-in orgânica processada de ponta a ponta com os DOIS eventos fluindo normalmente pelo consumer durável (que só subiu em 2026-07-09, P0.3).
- Conclusão honesta: o duplo crédito é um risco de código com evidência forte, mas não observado em execução. Pode existir um guarda que não encontramos (por exemplo, o `AccountResolver` do caminho legado falhar de forma sistemática para o formato de conta do payload settled). Por isso é VERIFICAÇÃO, não incidente.

## 3. O que pode acontecer se confirmado

Cliente recebe a TED em dobro no TigerBeetle e no extrato (duas linhas com referências diferentes), com prejuízo direto da instituição na devolução/estorno.

## 4. Procedimento de verificação recomendado (HML, nunca em produção)

Mesmo modelo da prova E2E sintética do PIX-in feita em 2026-07-09 (core-api:98):

1. Escolher uma conta de teste em HML e anotar saldo TigerBeetle + extrato.
2. Injetar uma STR0008R2 sintética no ponto de entrada do pipeline SPB de HML (webhook do sidecar MQ ou replay pelo outbox), com NumCtrlSTR/NumCtrlIF/NUOp inéditos.
3. Observar os DOIS subjects no NATS (`monetarie.spb.transactions.settled` e `monetarie.spb.credits.inbound`).
4. Conferir no Core: quantos deposits TigerBeetle, quantas linhas de extrato, quantas linhas em `transactions` e em `spb_inbound_credits`.
5. Resultado esperado se o risco for real: 2 créditos (um com referência NumCtrlIF/NUOp, outro `SPBCR<num_ctrl_str>`).

## 5. Correção provável (decisão do time, depois da prova)

Opções em ordem de preferência:

1. `CoreNotifier.notify_inbound_settled` parar de publicar settled para crédito inbound (a ponte é a dona do crédito) e manter o settled apenas como espelho de STATUS sem ação de ledger; ou
2. No Core, o catch-all `SpbHandler.handle_transaction_updated("settled")` com `direction=inbound` passar a delegar/no-op quando o payload corresponder a um `spb_inbound_credits` existente (dedup cruzado pelo NumCtrlSTR presente no XML); ou
3. Gate por flag em um dos emissores até a consolidação.

Qualquer correção precisa preservar o comportamento provado do incidente de 2026-07-06 (replay de outbox creditando exatamente uma vez).
