# Laudo 11 — Verificação money-path da cabine PIX vs legado CRK (D1, D2, D3, D6, D7)

Auditoria READ-ONLY. Cada veredito com prova arquivo:linha. Base:
`/Users/luizpenha/monetarie/pix/backend/apps`. Regra: achar defeito e PROVAR,
zero inferência.

Módulos lidos na íntegra ou nas seções relevantes:
- `spi_service/lib/spi_service/workers/inbound_processor.ex` (4443 linhas)
- `spi_service/lib/spi_service/workers/status_updater.ex`
- `spi_service/lib/spi_service/workers/outbound_sender.ex`
- `spi_service/lib/spi_service/workers/stuck_outbound_checker.ex`
- `spi_service/lib/spi_service/workers/payment_status_reconciler.ex`
- `spi_service/lib/spi_service/workers/pix_in_orphan_reconciliation.ex`
- `settlement_service/lib/settlement_service/workers/settlement_obligation_worker.ex`
- `shared/lib/shared/outbound_send_claim.ex`, `shared/lib/shared/e2e_burn.ex`
- migração `shared/priv/repo/migrations/20260716160000_create_e2e_outbound_burns.exs`

---

## D1 — Verdade de liquidação (pacs.002 vs camt.054 BOOK)

**VEREDITO: DIVERGÊNCIA CONFIRMADA. O gatilho de crédito ao Core é o pacs.002
ACCC/ACSC/STLD, NÃO o camt.054 BOOK.** O camt.054 BOOK só entra como FALLBACK de
reconciliação quando a pacs.002 se perdeu.

Cadeia de prova (SAÍDA / OUTBOUND, somos o pagador):

1. pacs.002 chega no `InboundProcessor.process_status_report` →
   `map_pacs002_status/1` mapeia **ACCC/ACSC/STLD → "settled"**:
   - `inbound_processor.ex:2568-2574` (`map_pacs002_status("ACCC")="settled"`,
     `"ACSC"="settled"`, `"STLD"="settled"`).
   - O próprio comentário assume a decisão de desenho: `inbound_processor.ex:2557-2567`
     ("Os TRES (ACCC/ACSC/STLD) ... roteiam para 'settled' -> handle_settlement ...
     dispara transaction.settled (netting + credito no Core + comprovante 'Liquidado')").
2. `publish_status_update(...,"settled",...)` gera o evento STATUS_UPDATED →
   `StatusUpdater.handle_settlement` → `settle_payment_line`, que publica o evento
   Core-visível `monetarie.spi.transaction.settled`:
   - `status_updater.ex:196-213` (dispatch), `status_updater.ex:267-312`
     (`settle_payment_line`; publish em `:304-310`).
3. `SettlementService.Workers.SettlementObligationWorker` consome
   `monetarie.spi.transaction.settled` e despacha o crédito final ao Core em
   `monetarie.settlement.transaction.credited`:
   - `settlement_obligation_worker.ex:5-9` (moduledoc: "The SPI StatusUpdater
     publishes this event when a transaction reaches the canonical settled state
     (status_id 4 = ACSC)"), `:45-49` (filtro), `:372-390` (`dispatch_credit`).

O camt.054 BOOK produz o MESMO "settled", mas SÓ como fallback:
- `inbound_processor.ex:1614-1615` (`classify_camt054_entry`: `Sts BOOK` + `DBIT`
  → `{:ok, "settled", ...}`), `:1547-1564` (`resolve_outbound_from_camt054` publica
  `publish_status_update_for_tx(tx, e2e, "settled", ...)`).
- Esse caminho só é acionado por operação PRESA: `PaymentStatusReconciler` dispara
  a camt.060 (que puxa a camt.054) para pacs.008 OUTBOUND parada (ver D6).

INBOUND (somos o credor): o crédito ao cliente é ainda mais cedo — na CHEGADA da
pacs.008 (`process_validated_payment` → `update_pi_balance(:credit,...)` +
`publish_transaction_event(...)`), `inbound_processor.ex:1073-1100`. Isso é
consistente com o modelo canônico do PIX-in (o BACEN liquida ANTES de entregar a
pacs.008 ao recebedor), mas confirma que a cabine NÃO usa o camt.054 BOOK como
autoridade de crédito em nenhuma das direções.

**Impacto real:** o Core credita/netta na CONFIRMAÇÃO da pacs.002 (ACSC/STLD),
não na notificação de lançamento no razão (camt.054 BOOK) como o CRK. ACSC/STLD
são, por ISO 20022, "AcceptedSettlementCompleted"/"Settled" (liquidação concluída),
então não é crédito "antes da liquidação" no sentido literal — mas é uma
DIVERGÊNCIA de AUTORIDADE: o CRK trata pacs.002 como aceite e só credita no
camt.054 BOOK; a nossa cabine credita direto no pacs.002.

---

## D2 — Dedup de inbound antes de creditar

**VEREDITO: COBERTO.** Dedup em 3 camadas por `(ISPB, resource_id)` onde
`resource_id = message_id` (BizMsgIdr), ANTES do crédito.

Prova:
1. Redis SET NX (`check_message_dedup`), TTL 3600s, fail-open p/ DB:
   `inbound_processor.ex:132-171`; chamado antes da transação em pacs.008
   (`:1000`) e pacs.004 (`:1313`).
2. Advisory lock transacional `pg_try_advisory_xact_lock(phash2({ispb,
   resource_id}))` DENTRO da `Repo.transaction`, com re-checagem de existência sob
   o lock: `inbound_processor.ex:3881-3927` (`upsert_inbound_in_tx`).
3. Insert com `ON CONFLICT (ispb, resource_id) DO NOTHING` em `bacen_inbound`:
   `inbound_processor.ex:3919-3925`.
4. Fan-out multi-tx: cada membro recebe identidade determinística
   `"<message_id>-NN"` como resource_id → o dedup passa a valer por transação:
   `inbound_processor.ex:507-529`.
5. Defesa adicional a jusante: transições CAS no `StatusUpdater`
   (`transition_status`, `status_updater.ex:984-1002`) impedem re-crédito por
   reentrega fora de ordem.

**Nuance honesta (não é falha, mas registrar):** a CHAVE de dedup é o MessageId
(BizMsgIdr), não o E2E. Uma reentrega do BACEN carrega o MESMO BizMsgIdr, então
é pega. Na tabela `messages` o único índice único é `(end_to_end_id,
operation_time)` (migração `20260716160000`, moduledoc) — ele NÃO impede E2E
repetido com `operation_time` diferente (essa é a classe do incidente de SAÍDA,
não de entrada). Para reentrega de INBOUND a tripla camada por `(ispb,
resource_id)` cobre.

**Impacto real:** reentregas at-least-once da mesma pacs.008/pacs.004 pelo BACEN
não geram duplo crédito.

---

## D3 — Backfill de órfã pelo camt.054

**VEREDITO: DEFEITO CONFIRMADO (não há backfill). A cabine DETECTA + ALERTA +
registra para triagem manual, mas NUNCA CRIA a operação.**

Prova (caminho síncrono do camt.054):
- `process_notification` (handler camt.054) apenas registra a notificação em
  `bacen_inbound` para auditoria: `inbound_processor.ex:1488-1516`.
- `resolve_outbound_from_camt054` só resolve transações JÁ EXISTENTES:
  - devolução recebida por RtrId: `inbound_return_by_rtr(instr_id)`
    (`:1542-1544`, `:1622-1634`);
  - senão pacs.008 OUTBOUND por E2E: `outbound_tx_by_e2e(e2e)` (`:1547-1550`,
    `:1717-1726`);
  - **nenhum match → `else _ -> :ok`** (`:1565-1566`): nada é criado.

Prova (worker de reconciliação de órfão `PixInOrphanReconciliation`):
- Ele existe e classifica um camt.054 CRDT liquidado sem crédito como órfão, mas
  o caso `no_message` (= exatamente "pagamento original não existe") só é
  registrado + alertado; o operador decide:
  `pix_in_orphan_reconciliation.ex:16-19` (definição), `:52-53` ("O operador
  decide"), `:352-353` (classificação `no_message`), `:437-472`
  (`record_and_alert`: grava `pix_in_orphans` + `Logger.error` + telemetria).
- Auto-remediação (`maybe_auto_republish`) é restrita a `no_outbox_event` (linha
  já creditada, só faltou o evento de outbox) e é gated por flag; NUNCA cria a
  linha do `no_message`: `pix_in_orphan_reconciliation.ex:534-537`.
- camt.054 DBIT/devolução sem lançamento de crédito é explicitamente IGNORADO
  (nem órfão de crédito conta): `pix_in_orphan_reconciliation.ex:321-324`.

**Impacto real:** um retorno/crédito que chega por camt.054 sem envio/operação
conhecida é descartado do fluxo transacional (fica só auditoria em `bacen_inbound`
+ eventual alerta em `pix_in_orphans`), nunca vira operação materializada. O CRK
CRIA a operação; a cabine não. Reconciliação depende de ação do operador.

---

## D6 — camt.060 aos 50min + fechamento por admi.002

**VEREDITO: PARCIAL. O limiar NÃO é 50min: a consulta camt.060 é disparada aos
90 SEGUNDOS por outro worker; o StuckOutboundChecker (30min) é só detecção. O
admi.002 "não existe lançamento" FECHA a consulta como not_found e rejeita a
linha do pagamento.**

Prova — quem dispara a camt.060:
- `PaymentStatusReconciler` (é este que emite a camt.060 de consulta de
  operação): limiar **`@stuck_after_seconds 90`** (90s), tick 30s, lote 10,
  backoff 5min: `payment_status_reconciler.ex:26-32`, `:60-102`, `:104-119`;
  envio real via `send_operation_query` (camt.060, ReqdMsgNmId=camt.054):
  `:153-161`. Moduledoc confirma o desenho: `:1-15`.
- `StuckOutboundChecker` NÃO dispara camt.060 nem muda status: limiar
  `@stuck_after_minutes 30` (30min), detecção pura (1 WARNING agregado por ciclo):
  `stuck_outbound_checker.ex:36-40`, `:10-12` ("NUNCA altera status ... Este
  worker é detecção pura").

Prova — admi.002 fecha:
- `maybe_correlate_admi002_camt060`: o RltdRef/Ref da admi.002 ecoa o MsgId da
  nossa camt.060 → `Camt060Requests.correlate_not_found(ref, reason, ...)` fecha a
  consulta como terminal (fim do falso "timeout"):
  `inbound_processor.ex:1858-1888`.
- `maybe_publish_admi002_rejection`: publica desfecho **"rejected"** na
  transação pacs.008 correlacionada por RltdRef/Ref (a linha do pagamento fecha
  como Rejeitada): `inbound_processor.ex:1931-1964`, `:2016-2052`
  (`publish_status_update_for_tx(tx, e2e, "rejected", %{reason_code:
  "ADMI002",...})`).

**Impacto real:** a intenção do legado (não deixar operação presa; camt.060 +
admi.002 fecham) EXISTE e é mais agressiva (90s vs 50min), mas está partida em
dois workers com limiares diferentes; o "50min" do CRK não tem equivalente
numérico — nós consultamos muito antes.

---

## D7 — Retentativa não re-minta (MessageId/E2E)

**VEREDITO: COBERTO. A identidade (MessageId/BizMsgIdr + E2E) é cunhada UMA vez
na criação e reusada em todas as retentativas; nenhum caminho de retry gera nova
identidade.**

Prova — identidade fixa no registro/XML:
- O `OutboundSender` re-assina o MESMO `xml_content` da mensagem redelivered
  (JetStream reentrega a mesma mensagem): `outbound_sender.ex:426-446`
  (dispatch), `:471` (`sign_outbound_xml(xml, message)` sobre o mesmo xml). O
  MessageId/E2E vivem no xml/registro, não são regenerados aqui.
- O E2E é QUEIMADO na CRIAÇÃO (não no retry): `Shared.E2eBurn.burn` chamado no
  `payment_controller.ex:574` e no `core_event_processor.ex:599`, ambos no ponto
  de criação da operação. `e2e_burn.ex:41-65`.

Prova — guardas contra re-POST:
- Claim durável de ENVIO por `message_id` (`OutboundSendClaim`, INSERT ...
  ON CONFLICT (message_id) DO NOTHING): `outbound_send_claim.ex:31-46`. Uma
  reentrega cujo claim já existe NÃO re-POSTa: `outbound_sender.ex:1295-1301`
  (`gate_by_claim`), `:1329-1332` (`claim_send` imediatamente antes do POST).
- `pre_send_gate` corta reenvio se a linha não está mais PDNG:
  `outbound_sender.ex:1244-1275`, `:1281-1293`.
- Unicidade estrutural do E2E de SAÍDA na CRIAÇÃO (fail-closed antes de qualquer
  transmissão): tabela `e2e_outbound_burns` com PK em `end_to_end_id` (migração
  `20260716160000`, moduledoc explica que o índice único em `messages` sozinho
  não cobre por causa do particionamento). `OutboundSendClaim` moduledoc separa
  os papéis: "o burn do E2E (Shared.E2eBurn) é da CRIACAO da operacao; este claim
  é do POST" — `outbound_send_claim.ex:1-20`.
- Erro AMBÍGUO de transporte (`:timeout` etc.): no caminho de LOTE os claims
  FICAM (não reenvia — `outbound_sender.ex:375-397`). No caminho SINGLE um
  `{:error, reason}` libera o claim e re-POSTa a MESMA identidade
  (`outbound_sender.ex:532-539`); como o BizMsgIdr é o mesmo, o BACEN deduplica.
  Em nenhum caso a identidade é regenerada.

**Impacto real:** reprocessos re-postam o MESMO XML/MessageId/E2E (BACEN
deduplica por BizMsgIdr); não há duplo-pagamento por re-cunhagem de identidade em
timeout de rede.

---

## Resumo dos vereditos

| Item | Veredito | Prova âncora |
|------|----------|--------------|
| D1 | DIVERGÊNCIA CONFIRMADA (credita no pacs.002 ACSC/STLD, não no camt.054 BOOK) | `inbound_processor.ex:2568-2574`; `status_updater.ex:304-310`; `settlement_obligation_worker.ex:5-9,372-390` |
| D2 | COBERTO (dedup 3 camadas por ispb+message_id antes do crédito) | `inbound_processor.ex:138-171,3881-3927` |
| D3 | DEFEITO CONFIRMADO (não faz backfill; só alerta/triagem manual) | `inbound_processor.ex:1565-1566`; `pix_in_orphan_reconciliation.ex:352-353,534-537` |
| D6 | PARCIAL (camt.060 aos 90s, não 50min; admi.002 fecha como not_found + rejeita) | `payment_status_reconciler.ex:27`; `stuck_outbound_checker.ex:37`; `inbound_processor.ex:1858-1888` |
| D7 | COBERTO (identidade cunhada na criação; claim + burn; sem re-mint no retry) | `outbound_sender.ex:471,1295-1332`; `e2e_burn.ex:41-65` |
