# Submissão de PIX e TED: taxonomia de falha e fim da inferência (2026-07-16)

Mandato do dono: mapear, desenhar e resolver toda a submissão de PIX e TED de ponta a ponta para que uma submissão NUNCA falhe "por inferência" — o gatilho foi o PIX do parceiro que morreu com `errorReason: null` depois de uma cadeia de três adivinhações (NAK numa falha que nunca muda; motivo terminal `:e2e_already_used` que não era o real; motivo nulo ao parceiro).

Companheiro: `docs/reports/2026-07-16-partner-api-auditoria-completa.md` (auditoria da Partner API, mesma sessão).

## 1. O caminho, em uma frase por elo

PIX-out: `send_pix` (Core, partner/v2) → NATS `monetarie.core.pix.payment_request` → `CoreEventProcessor` (cabine: resolve E2E do balde DICT → bloqueia saldo PI → queima E2E + cria tx + valida pacs.008 + monta XML, tudo numa transação → publica `outbound.send`) → `OutboundSender` (assina no HSM, POST ICOM) → `StatusUpdater` (pacs.002 ACSC/RJCT) → NATS `monetarie.spi.transaction.*` → `pix_handler` (Core: materializa status + libera hold + webhook).

TED-out: `transfers_controller.ted` → NATS `monetarie.core.spb.transfer_request` → `CoreEventConsumer` (cabine SPB: STR0008 + dispatch MQ) → `LifecycleEngine` (R1/R2 do BACEN) → `CoreNotifier` → NATS `monetarie.spb.transactions.*` → `spb_handler` (Core).

## 2. Defeitos resolvidos nesta sessão (todos com TDD, RED antes)

| # | Onde | Defeito (inferência/perda de motivo/estado enganoso) | Correção |
|---|------|------|----------|
| 1 | Core `pix_handler` | `transaction.error` da cabine trafega `error_code`+`description`; o Core só lia `error_reason`/`reason_*` → tx `rejected` com `error_reason` NULL e webhook `errorReason:null` (o incidente do Herbeth) | `rejection_reason_code/description/error_reason` unem as duas formas; fallback descrição > código; nunca NULL numa rejeição |
| 2 | Cabine `core_event_processor` | reply de erro só com `error_code`/`description` | Aliases `error_reason`/`reason_code`/`reason_description` no payload (contrato fechado nos dois sentidos) |
| 3 | Core `pix_handler` | webhook `pix.payout.failed` sem contraparte e com motivo nulo | Bloco `recipient` (canônico) + `errorReason` da união de formas |
| 4 | Cabine `core_event_processor` | queima de E2E colidindo tratada SEMPRE como rejeição — a reentrega da MESMA operação (ACK perdido) fazia o Core liberar o hold com a pacs.008 já garantida pelo outbox | `resolve_e2e_collision/2`: a linha OUTBOUND diz o dono da queima → mesma operação = ACK idempotente; outra = rejeição com motivo verdadeiro (`E2E_REUSED`). Decisão por DADO, não inferência |
| 5 | Cabine `core_event_processor` | `{:tx_create_failed}` era código morto: `create_transaction` fazia `Repo.rollback` com o erro CRU → o incidente varchar(32) voltava a ser tratado como transitório (NAK 6x → DLQ de veneno, tx presa em PDNG) | `wrap_create_failure/1` embrulha; classificador reconhece exception de insert como determinística (infra DBConnection segue transitória) |
| 6 | Cabine `Shared.E2eBurn` + `core_event_processor` | a queima rodava no `Shared.Repo`, mas a transação no `SettlementService.Repo` — cross-repo: a queima AUTOCOMMITAVA e NÃO fazia rollback quando a transação abortava por falha transitória → reentrega via `:e2e_already_used` FALSO (root do motivo enganoso) | `burn/2` aceita `:repo`; o processor passa o próprio repo → a queima participa do rollback (livre no aborto, persiste no commit) |
| 7 | Core `pix_handler` | `maybe_find_pending_pix` casava "a última PIX pendente da conta" com `amount == 0` = "qualquer valor" → rejeição sem amount terminalizava a PIX ERRADA | exige amount POSITIVO que bata exato; sem isso, não correlaciona |
| 8 | Core `spb_handler` | `maybe_find_pending_ted` casava "a última TED pendente" sem guard de valor NEM idade, e a direção caía em `payload["type"]="spb_transaction"` (casava nada) | `get_pending_ted_if_unique`: só correlaciona quando é INEQUÍVOCO (uma única pendente); direção default "outbound" |
| 9 | Cabine SPB `CoreNotifier` | rejeição do R1/R2 vivo chegava ao Core SEM motivo (o `error_code`/`error_description`/`response_code` de `spb_operations` não trafegava) — a TED aparecia "rejeitada" sem motivo | `notify_status_change/3` + `fetch_rejection_reason` no lifecycle: motivo propagado; `error_reason` cai para o código quando não há descrição |
| 10 | Cabine SPB `CoreEventConsumer` | dispatch publicava `rejected` ao Core em TODA falha, inclusive TRANSITÓRIA (MQ/cert/HSM), e só depois NAKava → Core liberava o hold, no retry a TED saía e o cliente foi reembolsado = DUPLO-DÉBITO | `reject_to_core_if_permanent/2`: rejeição ao Core só em falha PERMANENTE; transitória mantém a TED viva e o hold intacto (mesma classificação `transient?` do NAK/TERM) |
| 11 | Core `partner_v1/pix_controller` | partner `send_pix` não mandava `pix_key`/`creditor_proxy` → a cabine tratava pagamento por chave como MANU e ignorava o E2E do balde DICT (risco de RJCT "E2E ≠ consulta", recebedor default) | envia `pix_key` e `creditor_proxy` (paridade com o v2) |

Validação: core 7753/0; cabine PIX settlement 1738/0, spi 963/0, shared 851/0; SPB consumers+nats 46/0, lifecycle 61/0.

## 3. Lacunas mapeadas que ficam para decisão/handoff (não resolvidas nesta sessão)

Estas são reais, mas ou são políticas ou exigem mudança mais larga/arriscada em money path. Documentadas para o dono decidir.

**PIX**
- **OutboundSender fail-closed pós-build não terminaliza pacs.008** (assinatura HSM/XSD/sanções/BACEN sync 4xx): a tx fica PDNG, `confirm_block` nunca roda, bloqueio PI e hold presos, parceiro em "processing" eterno. Mesma classe do incidente por uma porta diferente (depois do build). `publish_result` vai para `monetarie.spi.outbound.result` que NÃO tem consumidor. Precisa de um caminho que terminalize a tx e avise o Core.
- **`StuckOutboundChecker` só loga**: pacs.008 presa >30min gera warning, nunca muda status/libera hold/avisa o Core. Precisa virar terminalização com motivo.
- **Payloads sem `event`** (`payables`/`open_finance` PIX): a cabine descarta em silêncio (catch-all ACK). Precisa de `event: "payment_request"` ou fail-fast no Core.
- **Outbox cross-repo** (`publish_async` usa Oban default = `Shared.Repo`, transação em `SettlementService.Repo`): o job de `transaction.created`/`outbound.send` commita independente do rollback da linha `messages`. Risco de evento publicado para tx que não existe. Arquitetural — avaliar com cuidado.
- **`resolve_sender_ispb`/`local_instrument` por heurística**: ISPB do remetente e instrumento inferidos por fallback; podem sair divergentes e ser barrados pelo BACEN sem motivo claro. Revisar com regra explícita.

**TED**
- **`accepted` nunca termina**: os watchdogs (`StaleHoldChecker`, `SpbStatusReconciliation`) só olham `processing`; se o R1/R2 se perde depois do `accepted`, o hold fica preso para sempre. Incluir `accepted` no varredor.
- **Timeout de TED não avisa o parceiro**: `notify_payout_timeout` só dispara webhook para `type=="pix"`. TED que expira só é vista por polling.
- **`TedController.create` (merchant) não segura nem debita fundos**: a TED sai no BACEN e o saldo do cliente fica intacto (nunca debita). Divergente das portas partner/v2.
- **Feriado/fora de janela = rejeição terminal + DLQ**, em vez de agendamento para o próximo dia útil.
- **`transient?` catch-all trata desconhecido como transitório**: um erro determinístico não catalogado gera NAK até esgotar retries. Combinado com o fix 10, o rescue de exceção agora é suprimido (não avisa o Core) — money-safe (nunca duplo-débito), mas pode reter uma TED de falha permanente até a DLQ. Precisa de um catálogo de permanência mais completo + terminalização pós-DLQ.

## 4. Princípio aplicado

Onde havia inferência no money path, a decisão passou a ser por DADO explícito: o dono da queima de E2E (linha OUTBOUND), a classe de falha (determinística vs transitória, catálogo `transient?`), a unicidade da pendência (uma só, senão não correlaciona), e o motivo verdadeiro propagado ponta a ponta (nunca NULL numa rejeição). O que ainda depende de heurística está listado na seção 3 para virar regra explícita.
