# W1-F5 - Capacidade extrema PIX (empacotamento de ate 10 transacoes por mensagem) - Plano de implementacao

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans (ou superpowers:subagent-driven-development por task, conforme `docs/plans/2026-07-18-aceleracao-wave1-plano-mestre.md`). TODA task obedece aos gates G1/G2/G3 do plano mestre. Ambientes HML/PRD: LEITURA para investigacao; escrita e deploy SO pelo orquestrador. **DEPLOY PRD desta frente APENAS em janela dedicada com acompanhamento de logs (mandato do dono, 17/07).** Flags de lote nascem OFF; ligar = decisao do dono.

**Goal:** Elevar a vazao de PIX-out da cabine com empacotamento de ate 10 transacoes por pacs.008 (fluxo BACEN oficial, mesmo desenho do legado), sem nunca estourar limites BACEN (token bucket ICOM, ANS 1,6s por transacao), com so POSTs reais/validos, desfecho terminal POR TRANSACAO e fallback unitario estrutural.

**Architecture:** O agrupamento acontece na fronteira do OutboundSender (spi_service), que ja consome a fila durable em batch (poll de ate 100 mensagens): com a flag ON, pacs.008 pendentes do MESMO grupo (prioridade do GrpHdr) que chegaram no mesmo poll sao fundidas em UM envelope com N CdtTrfTxInf (teto 10), assinado UMA vez no HSM e POSTado UMA vez no ICOM. O nucleo transacional por transacao do CoreEventProcessor (burn de E2E, linha em messages, outbox) NAO muda: a durabilidade continua a mesma, e o dedup por message_id ganha a dimensao de lote (batch_message_id). O InboundProcessor da pacs.002 passa a iterar N TxInfAndSts (desfecho por E2E, inclusive lote parcial 9 aceitas + 1 rejeitada). Antes de qualquer lote nosso, o lado de LEITURA vira multi-tx-correto (hoje uma pacs.008 recebida com NbOfTxs>1 creditaria SO a primeira transacao em silencio).

**Tech Stack:** Elixir umbrella `pix/backend` (apps `spi_service`, `shared`, `settlement_service`), NATS JetStream (BaseWorker), HSM RTM (assinatura, sessao cacheada), ICOM CPM/CSM via Finch (+ sidecar mTLS Go opcional), simulador BACEN local (`Shared.Bacen.Simulator`), xmllint/XSD gate v5.12.1.

---

# PARTE A - ESTUDO G1 (contrato BACEN provado com evidencia)

## A0. Veredito do G1

**O empacotamento de ate 10 transacoes por pacs.008 E SUPORTADO pelo SPI real, e o legado ja fazia exatamente isso.** Provas decisivas:

1. **XSD oficial** `/Users/luizpenha/cecresa/md/spi.5.12.1/v5.12.1/xsd/pacs.008.spi.1.15.xsd`, linha 228: `<xs:element maxOccurs="unbounded" name="CdtTrfTxInf" type="CreditTransferTransaction43"/>`. O envelope aceita N transacoes. O mesmo XSD esta versionado no repo em `pix/backend/apps/shared/priv/xsd/spi/v5.12.1/pacs.008.spi.1.15.xsd` (linha 228 identica), ou seja, o nosso proprio gate XSD de envio JA aceita o envelope multi-tx.
2. **Exemplo OFICIAL do catalogo BACEN** `/Users/luizpenha/cecresa/md/spi.5.12.1/v5.12.1/exemplos/pacs008/pacs.008_END_10_msg.xml`: um unico Envelope/AppHdr/Sgntr, `GrpHdr` com `<NbOfTxs>10</NbOfTxs>` (linha 32) e 10 blocos `CdtTrfTxInf`, cada um com `EndToEndId` e `IntrBkSttlmAmt` proprios.
3. **pacs.002 de resposta e POR TRANSACAO**: `pacs.002.spi.1.16.xsd` linha 148: `<xs:element maxOccurs="unbounded" name="TxInfAndSts" type="PaymentTransaction110"/>`; exemplo oficial `pacs.002_SPI_10_msg.xml` traz 10 `TxInfAndSts`, cada um com `OrgnlEndToEndId` + `TxSts` proprios (linhas 35-135).
4. **Manual das Interfaces de Comunicacao v1.12** (`/Users/luizpenha/cecresa/md/Manual das Interfaces de Comunicação-1.12.md`), secao 2.2.1.5: o token bucket do ICOM debita "1 token para cada operacao contida em mensagens pacs.008" (por OPERACAO, nao por mensagem), e o proprio manual exemplifica lote de "500 operacoes por mensagem" no canal secundario. Mensagem multi-operacao e cidada de primeira classe do SPI.
5. **O legado EMPACOTAVA**: `/Users/luizpenha/mwbank/LegadoPIX/SPI/src/SPI.Core.Mensageria.Application.Book.v111/SPI.Core.Mensageria.Application.Book.v111.Rules.MontaMsg.Impl.PACS008/v15.cs:366-382` gera VARIOS `CdtTrfTxInf` num unico envelope a partir de `LotePaymentFullDTO`, com `NbOfTxs` = contagem (linha 310-312, 362); o bloco vem do chunking com `TamanhoBloco` default 10 (`UnitOfWorkRecepcaoLotePaymentDTO.cs:62`; `ConversaoPaymentResultToPaymentFactory.cs:41,55-57`). O "10 por mensagem" do mandato e o mesmo 10 do legado.

**Nuance de honestidade:** o teto de 10 transacoes por pacs.008 NAO e um limite normativo. A regra de campo do `NbOfTxs` no catalogo (`PACS008.xlsx`, extraida do sharedStrings) diz apenas: "Campo obrigatorio deve ser preenchido com o valor maior ou igual a '1' (NbOfTxs >=1)". O XSD e unbounded. Os "10" DOCUMENTADOS no manual ICOM sao outros dois: (a) secao 2.2.1.1, endpoint de ENTRADA: "Ha um limite de 10 mensagens em uma mesma requisicao" (multipart de mensagens por POST); (b) secao 2.2.2, endpoint de SAIDA: "A API retorna, no maximo, 10 mensagens a cada requisicao com multipart". O 10 desta frente vem do mandato do dono + exemplos oficiais do catalogo (que usam exatamente 10) + default do legado. Adotado como knob `PIX_BATCH_MAX_TXS` com default 10 e teto hard 10 nesta wave.

**A restricao que governa o desenho:** em pagamento PRIORITARIO (PAGPRI, o fluxo real-time do cliente) o SPI usa o `AccptncDtTm` DE CADA TRANSACAO para timeout do emissor (PACS008.xlsx, regra do campo `AccptncDtTm`: "Caso o pagamento seja prioritario, esta informacao sera usada para controle de limite de tempo (timeout) do emissor da ordem"; regra do dominio `PAGPRI`: "o SPI considerara o tempo de validacao reportado pelo emissor ... para efeitos de timeout"). E o nosso ANS de 1600ms e POR TRANSACAO (A4). Logo NAO se espera transacao para formar lote: o agrupamento usa apenas o que JA esta em maos no poll do consumidor (janela adicional default = 0ms; knob com teto hard 50ms). Lote e efeito colateral de pressao real de vazao; trafego esparso segue unitario identico ao de hoje.

## A1. XSD pacs.008 SPI v5.12.1: estrutura do envelope multi-tx

Fonte primaria: `/Users/luizpenha/cecresa/md/spi.5.12.1/v5.12.1/xsd/pacs.008.spi.1.15.xsd` (o diretorio `/Users/luizpenha/cecresa/md/XSDDOCV512` e o catalogo SPB/RSFN; os XSDs do SPI vivem em `spi.5.12.1`).

| Evidencia | Linha | Conteudo |
|---|---|---|
| N transacoes por envelope | 228 | `maxOccurs="unbounded"` em `CdtTrfTxInf` (tipo `CreditTransferTransaction43`) |
| Contador | 251 | `NbOfTxs` tipo `Max15Numeric` no `GroupHeader93`; regra xlsx: `>= 1`, sem teto |
| Prioridade E POR MENSAGEM | 253 | `PmtTpInf` (tipo `PaymentTypeInformation28`) existe SO no `GroupHeader93` (linha 247) e NAO existe em `CreditTransferTransaction43` (linha 166). Um lote inteiro compartilha `InstrPrty` + `SvcLvl` (PAGPRI/PAGFRD/PAGAGD). Lote so pode agrupar transacoes da MESMA prioridade |
| Relogio por transacao | 170 | `AccptncDtTm` (ISONormalisedDateTime) dentro de `CreditTransferTransaction43`: cada transacao carrega o proprio horario de aceite, usado pelo SPI para timeout em PAGPRI |

Exemplo oficial de 10 (`exemplos/pacs008/pacs.008_END_10_msg.xml`, 28.426 bytes, ~2,8KB/tx):
- 1 `AppHdr` com 1 `BizMsgIdr` (`M0003816612345678901234567890123`) e 1 `Sgntr`: **uma unica assinatura para as 10 transacoes** (amortizacao real de HSM);
- `GrpHdr`: `MsgId` unico, `NbOfTxs=10`, `SttlmMtd CLRG`, `PmtTpInf` com `InstrPrty HIGH` + `SvcLvl Prtry PAGPRI` compartilhados;
- 10 `CdtTrfTxInf` com E2Es distintos sequenciais (`E99999010...78900` ate `...78909`), cada um com `IntrBkSttlmAmt`, `AccptncDtTm`, `Dbtr/Cdtr` proprios.

Tamanho: 10 tx ~ 28KB cru. Com gzip do canal, irrelevante frente ao 413 do ICOM (manual, tabela de codigos: "O limite aceito pela API sera suficiente para a operacao normal do sistema", sem numero publicado). Referencia de transporte SFN (MQ, nao ICOM): 4 MBytes (`/Users/luizpenha/mwbank/md/Manual_de_Redes_do_SFN_Ver_9.3.md:2083`).

## A2. pacs.002, admi.002 e pacs.004: desfecho por transacao e falha de envelope

- `pacs.002.spi.1.16.xsd:148`: `TxInfAndSts maxOccurs="unbounded"`. Exemplo `pacs.002_SPI_10_msg.xml`: 10 blocos, cada um com `OrgnlEndToEndId` e `TxSts` proprios. Exemplo `pacs.002_ERRO_1_msg.xml`: `TxSts RJCT` + `StsRsnInf` com codigo; a rejeicao e um item de `TxInfAndSts`, nao um atributo do envelope.
- **Conclusao estrutural: o SPI pode aceitar 9 e rejeitar 1 do mesmo lote.** Cada linha resolve pelo seu `OrgnlEndToEndId`, independente. O desenho NAO pode assumir que os N status chegam numa unica pacs.002 nem na ordem de envio.
- Falha de ENVELOPE (XML invalido/assinatura): vira **admi.002 referenciando o PI-ResourceId da mensagem** (manual, secao 2.2.1.3: as referencias do PI-ResourceId "sao usadas como identificador das mensagens na API e no corpo das mensagens admi.002 ... quando ocorrer erro de validacao ou assinatura"). Nesse caso as 10 transacoes morrem JUNTAS e cada linha precisa de desfecho terminal proprio derivado do mesmo veredito (task B7).
- pacs.004 (devolucao) tambem e multi-tx no XSD (`pacs.004.spi.1.5.xsd:173`, `TxInf maxOccurs="unbounded"`; o legado tambem empacotava, `MontaMsg.Impl.PACS004/v11.cs:200-202`). **Fora do escopo desta wave** (volume de devolucao nao justifica; registrado).

## A3. Manual ICOM v1.12: limites do canal que o lote deve respeitar

Fonte: `/Users/luizpenha/cecresa/md/Manual das Interfaces de Comunicação-1.12.md`.

1. **Token bucket por PSP (secao 2.2.1.5)**, o "limite BACEN que nunca pode estourar" do mandato:

| Canal | Horario | Bucket max | Recarga/s |
|---|---|---|---|
| Primario (CPM) | qualquer | 3750 | 750 |
| Secundario (regra geral) | 08:00-23:59 | 350 | 35 |
| Secundario (regra geral) | 00:00-07:59 | 5000 | 350 |
| Secundario (trck.002/booktransfer) | qualquer | 1500 | 500 |

Custo: **1 token por OPERACAO em pacs.008/trck.002; 0,2 token por operacao em pacs.002; 1 token por mensagem dos demais tipos**. Estouro = HTTP 429 + `Retry-After` em segundos; o debito acontece no PROCESSAMENTO (saldo pode ficar negativo). Implicacao central: **empacotar NAO economiza token** (custo e por operacao). O ganho do lote e amortizar HSM/HTTP/gzip/validacao. O teto fisico de vazao sustentada no CPM e ~750 operacoes/s com rajada de 3750; o guardiao cliente-side (task B8) espelha exatamente esta tabela.

2. **Multipart no endpoint de ENTRADA (secao 2.2.1.1)**: `POST /api/v1/in/{ispb}/msgs` aceita `multipart/mixed` com limite de 10 MENSAGENS por requisicao. E uma SEGUNDA alavanca de amortizacao (10 pacs.008 num POST), ortogonal ao envelope multi-tx. Resposta 201 com `PI-ResourceId` por mensagem separado por virgula (2.2.1.3). Erro 4xx = NENHUMA gravada; **5xx = "e possivel que algumas mensagens tenham sido gravadas e outras nao. Uma vez que o SPI tem comportamento idempotente, o PSP deve tentar o envio novamente"**. Fora do escopo da wave (YAGNI: o envelope multi-tx ja amortiza o que precisamos e nao muda o contrato HTTP); registrada como alavanca futura.
3. **Conexoes**: HTTP persistente na pratica obrigatorio (aberturas frequentes de TLS penalizam e podem dar 503, secao 2.1.3); conexoes simultaneas limitadas (503, secao 2.1.4); leitura limitada a **6 threads por participante por canal** (secao 2.2.2.10; nosso `@max_slots 6` em `spi_service/lib/spi_service/icom/cpm/coordinator.ex:93`; PROD roda `ICOM_MAX_SLOTS=4` deixando 2 conexoes para envio).
4. **Saida**: long-poll com `Accept: multipart/mixed` retorna ate 10 mensagens por resposta (secao 2.2.2); ja tratado pelo nosso `multipart_parser.ex` (teto defensivo de 5MB por parte, `spi_service/lib/spi_service/icom/multipart_parser.ex:66`) e caracterizado no teste `spi_service/test/spi_service/icom/lote_alta_volumetria_test.exs`. NAO confundir: isso e batching de TRANSPORTE na ingestao, nao empacotamento de CdtTrfTxInf.
5. **Canais (secao 1.2)**: CPM = expectativa de liquidacao imediata (~10s do usuario final); CSM = agendados/grandes lotes. O fluxo desta frente e CPM/PAGPRI. Lotoes de 500 ops/msg do exemplo do manual sao territorio de CSM/PAGAGD (ScheduledPix futura; fora da wave).

## A4. Budget de tempo: ANS 1,6s e POR TRANSACAO

- **Nosso enforcement**: `pix/backend/apps/settlement_service/lib/settlement_service/monitoring/ans_monitor.ex:11` (`@ans_threshold_ms 1600`); `check_ans/3` mede `operation_time -> settlement_time` POR TRANSACAO, chamado por E2E em `spi_service/lib/spi_service/workers/status_updater.ex:285`. Eixo complementar POR MENSAGEM (lag de transporte): `settlement_service/lib/settlement_service/monitoring/icom_lag_monitor.ex:14-17,85` (p95 vs 1600ms, p99 vs 2500ms).
- **Norma BACEN**: regras `AccptncDtTm`/`PAGPRI`/`PAGFRD` citadas em A0/A1 (o timeout do SPI conta do aceite DE CADA transacao; em nao-prioritario o tempo de validacao e desconsiderado).
- **Consequencia numerica**: cada ms de janela de lote e debitado do budget de TODAS as transacoes do lote. Janela default 0ms (so agrupa o que o poll ja entregou; o poll de 200ms do consumidor JA existe hoje e nao muda). Teto hard do knob: 50ms (3,1% do budget). Janelas de 100ms+ ficam proibidas por knob-validation.

## A5. reda.017 PRAZOCONFI (contexto, nao bloqueio)

O PRAZOCONFI materializado em 17/07 (migration `20260717233000`, coluna `confirmation_deadline timestamptz` em `monetarie_settlement.indirect_participants`; handler `process_party_report` em `spi_service/lib/spi_service/workers/inbound_processor.ex:215,2017-2027`) e um atributo POR PARTICIPANTE entregue via `MktSpcfcAttr Nm=PRAZOCONFI / Val=datetime` (exemplo oficial `exemplos/reda017/reda.017_msg.xml`): prazo de confirmacao de migracao de participante indireto. NAO e o ANS de 1,6s e nao interfere no desenho do lote.

## A6. Caminho ATUAL de envio da cabine (mapa provado, arquivo:linha)

Fluxo quente: Core publica `monetarie.core.pix.payment_request` -> `CoreEventProcessor` (settlement_service) -> outbox -> `monetarie.spi.outbound.send` -> `OutboundSender` (spi_service) -> ICOM.

1. **CoreEventProcessor** (`settlement_service/lib/settlement_service/workers/core_event_processor.ex`): `handle_payment_request` (def 227); nucleo transacional `Repo.transaction` (linha 586) faz, POR TRANSACAO: `Shared.E2eBurn.burn(e2e, ...)` (599; colisao = `Repo.rollback(:e2e_already_used)` em 601), `create_transaction` com status PDNG(1) (604, `MessageHistory.record` 613), publica `transaction.created` via outbox (619-638), valida com `Pacs008SendValidator` (653), monta o XML com `Shared.Bacen.Iso20022.MessageBuilder.build("pacs.008", ...)` (679-707) e publica `outbound_msg` (com `xml_content` PRONTO) em `monetarie.spi.outbound.send` (709-727). Tudo commita junto (outbox Oban `:nats_publish`).
2. **E2eBurn** (`shared/lib/shared/e2e_burn.ex:44-62`): `INSERT ... ON CONFLICT (end_to_end_id) DO NOTHING` em `monetarie_spi.e2e_outbound_burns`, no MESMO repo/transacao do chamador. 1 E2E por transacao; um lote de 10 usa 10 E2Es queimados individualmente nas 10 transacoes de origem. **O lote NAO toca a queima** (ela ja aconteceu por transacao antes do envio).
3. **OutboundSender** (`spi_service/lib/spi_service/workers/outbound_sender.ex`): consumidor NATS JetStream via `Shared.Workers.BaseWorker` com `stream MONETARIE_SPI`, `filter monetarie.spi.outbound.>`, **`poll_interval: 200`, `batch_size: 100`, `max_concurrency: 50`** (linhas 32-38). Pipeline do envio (98-111): `pre_send_gate` -> `validate_outbound_or_reject` -> `SanctionsCheck.for_outbound` -> `sign_outbound_xml` -> `validate_signed_xsd_or_reject` -> `claim_send` -> `Client.spi_request(:post, "/api/v1/in/{ispb}/msgs", signed_xml)` (111, path em 837).
4. **Dedup/claim por message_id** (migration `20260716210000`, tabela `monetarie_spi.outbound_send_claims`, PK `message_id varchar(64)`; schema `shared/lib/shared/outbound_send_claim.ex:27-85`): `claim/2` = INSERT ON CONFLICT DO NOTHING; `settle_claim/3` (717-725) marca `"sent"`/`"rejected"` (mantem o claim, nunca reenvia) ou `release` (erro de transporte, DELETE, permite retry). `pre_send_gate` = `gate_by_status` (so envia PDNG, 662-674, **assume 1 transaction_id por mensagem**) + `gate_by_claim` (676-682).
5. **Assinatura HSM: UMA por mensagem.** `sign_outbound_xml/2` (330) -> `XmlSigner.sign_spi` -> 3 digests num unico `SignedInfo` mas **uma unica chamada** `rsa_sign(canonical_signed_info, ...)` (`shared/lib/shared/crypto/xml_signer.ex:86`); HSM real via `RtmHsm.sign_base64` (`xml_signer.ex:364-372`), sessao KMIP cacheada 10min em `:persistent_term` (`shared/lib/shared/crypto/rtm_hsm.ex:34-35,130`). **Empacotar 10 tx = 1 sign-rsa em vez de 10.**
6. **POST ao ICOM**: `Shared.Bacen.Client.spi_request/4` (`shared/lib/shared/bacen/client.ex:195`) via Finch pool `Shared.Finch` (ICOM primario `size: 6, count: 1`, `shared/lib/shared/application.ex:94-95`; `conn_max_idle_time 30s` linha 189), `receive_timeout` 5000ms / `pool_timeout` 3000ms (client.ex:55-56). Sidecar mTLS Go opcional (`pix/services/bacen_mtls_sidecar/main.go`) via `ICOM_PRIMARY_URL`/`ICOM_SECONDARY_URL` (runtime.exs:561-568); `SIDECAR_TLS_SESSION_CACHE` (main.go:269-315,427, default ON cap 64) evita tocar o HSM em handshakes sob rajada.
7. **ACK != liquidacao**: ACK HTTP marca CAS PDNG(1)->ACSP(2) (`mark_tx_sent/1`, 731-751) e confirma bloqueio de saldo (127-135); o desfecho terminal vem na pacs.002.
8. **pacs.002 recebida** (`spi_service/lib/spi_service/workers/inbound_processor.ex`): dispatch linha 201 -> `process_status_report/2` (2139); correlaciona por E2E (`original_end_to_end_id`, 2154-2158; devolucao resolve por RtrId antes, 2160-2166) usando `find_tx_by_e2e` (janela 48h com pruning, fix `79c764d8`); mapeia `map_pacs002_status/1` (2244-2248: ACSP->processing, ACCC/ACSC/STLD->settled, RJCT->rejected); fail-closed com NAK (2206-2213). **HOJE PEGA SO O PRIMEIRO `TxInfAndSts`**: o parse vem de `MessageParser.extract_document/1` que usa `xpath_text` (primeiro no) para `//OrgnlEndToEndId` (message_parser.ex:681) e `//TxSts` (702); `xpath_text/2` retorna `[elem | _]` (765-779).
9. **Builder**: `build_pacs008/1` (`shared/lib/shared/bacen/iso20022/message_builder.ex:163`) e HARDCODED 1 transacao: `<NbOfTxs>1</NbOfTxs>` fixo (173) e um unico bloco `CdtTrfTxInf` (177-193) montado de um mapa unico. Nao ha `Enum.map` sobre lista.
10. **PERF logs**: `spi_service/lib/spi_service/workers/perf_log.ex` emite `[ICOM] MSG_RECV/MSG_OK/MSG_FAIL` com `end2end_ms`/`duration_ms`/`handler_ms` (35-92) + telemetry `[:pix, :icom, :msg_recv]`. NAO ha hoje sub-fases build/sign/post dentro do OutboundSender (task B9).
11. **Nao existe nenhum agregador/batcher de envio hoje** (busca por aggregator/batcher/empacota no umbrella: so relatorios/fees). O empacotamento e greenfield.
12. **Knobs existentes**: `PIX_SHARED_POOL_SIZE` (8, runtime.exs:288; autorizado 8->32), `PIX_SPI_POOL_SIZE` (8), `PIX_SETTLEMENT_POOL_SIZE` (6), `DB_QUEUE_TARGET/INTERVAL`, `BACEN_TIMEOUT`, `ICOM_MAX_SLOTS` (PROD=4), flag `:spi_xsd_gate_enforce` (outbound_sender.ex:372).

**Onde o lote entra sem quebrar nada:** na fronteira do OutboundSender. O consumidor JA recebe ate 100 mensagens por poll; com flag ON, as pacs.008 pendentes do mesmo grupo que vieram no MESMO poll sao fundidas (ate 10) num envelope novo. Nenhuma mudanca de durabilidade (JetStream + outbox seguem identicos), nenhuma janela artificial em trafego esparso (1 mensagem no poll = envio unitario identico ao de hoje), claim por membro preservado + claim de lote novo.

## A7. Como o LEGADO fazia (paridade provada)

Fonte: `/Users/luizpenha/mwbank/LegadoPIX` (decompilado .NET; os caminhos `cecresa/mwbank/*` do mandato nao existem, o espelho real e este).

- **Envio em lote acionavel por endpoint**: `SPI.Web.Api.Controllers/EntradaController.cs:71` (`addLotePagto`); `UnitOfWorkRecepcaoLotePaymentDTO.cs:53-65` valida o lote, le `TamanhoBloco` (default 10, linha 62) e enfileira cada bloco; `ConversaoPaymentResultToPaymentFactory.cs:41,55-57` faz o chunking `Skip/Take(tamanhoBloco)`.
- **Montagem multi-tx**: `MontaMsg.Impl.PACS008/v15.cs:315-382`: `ToXml` aceita single (`PaymentFullDTO`) OU lote (`LotePaymentFullDTO`); `GeraOperacoes` concatena N nos `CdtTrfTxInf` (`AppendJoin`, 380), 1 por pagamento valido; `GeraGroupHeader` grava `<NbOfTxs>{count}</NbOfTxs>` (310-312) com count excluindo pagamentos em erro (362); GrpHdr unico ordenado por `DtHrAcceptance` (322-325).
- **Recebimento multi-tx robusto**: `ConversorEnvioAPI.Impl.PACS008/v15.cs:39-130` le `NbOfTxs`, itera TODOS os `CdtTrfTxInf` (SelectNodes, 43), N=1 vira single e N>1 vira `LotePaymentDTO`; handler de lote em `UnitOfWorkOperacaoRepositoryBase.cs:3138,3729`.
- **pacs.002 com lista**: `PACS002FullDTO.cs:41` (`List<DetalhePACS002FullDTO> Items`); montagem com N `TxInfAndSts` em `MontaMsg.Impl.PACS002/v13.cs:269-278`.
- ANS 1600ms nao esta no codigo legado; esta nos docs do sistema novo (`/Users/luizpenha/cecresa/CECRESA_CODE_REVIEW.md:1605`).

## A8. O que os fixes de 17/07 ja entregaram (nao duplicar)

- `79c764d8` (perf caminho quente da pacs.002): indice `(pi_resource_id, received_at)` em `monetarie_audit.xml_audit_logs` (mata seq scan de 328ms pago em TODA mensagem ICOM) + `find_tx_by_e2e` com janela 48h/partition pruning e fallback no miss. Migration `20260717170000`. Antes do fix o handler de pacs.002 rodava 388-614ms com 100% das operacoes violando o ANS.
- `bcda9cae`: migration `20260717171000` alinhando `netting_operations` (16 colunas, diff contra PROD vivo); mata loop de falha que envenenava a DLQ. Nao e caminho quente do envio.
- Ambos DEPLOYADOS (PRD pix-api:60). **Esta frente nao mexe em indice de auditoria nem netting**; o baseline de medicao e o pos-fix.
- Pendencia conhecida da mesma sessao (memoria `monetarie-pix-prd-latencia-ted-str0010-0717`): partida fria TLS 1-2s com `SIDECAR_TLS_SESSION_CACHE` ausente em PROD; task-def ja AUTORIZADA (CLAUDE.md 17/07). Aplicar na janela dedicada desta frente e ganho de brinde (decisao do orquestrador, nao desta implementacao).

## A9. Simulador e harness de carga existentes

- Simulador BACEN completo local: `pix/backend/apps/shared/lib/shared/bacen/simulator/` (`response_generator.ex`, `scenario_runner.ex`, DICT/claims/MED/CobV), gate `SIMULATOR_ENABLED=true`, subida nativa `scripts/dev_up_all.sh` (exporta `SIMULATOR_ENABLED=true`, `BACEN_ENABLED=false`, HSM/MQ OFF; "NUNCA toca BACEN real").
- **O simulador e estritamente 1:1 hoje**: `response_generator.ex:211` le UM `end_to_end_id`; pacs.002 gerada com `<NbOfTxs>1</NbOfTxs>` e um unico `TxInfAndSts` (`message_builder.ex:369,299-305`); pacs.002 so para pacs.008 NOSSA (Fr = nosso ISPB, response_generator.ex:194-208). Multi-tx no simulador e PRE-REQUISITO do teste de carga do lote (task B6).
- Harness IN 508 ja existe: `shared/lib/shared/bacen/simulator/capacity_test.ex` (`run_spi_capacity(tps: 2000, duration_seconds: 60)`, tier Pequeno 2.000 pacs.008/min; `run_dict_capacity` 1.000/min x10). Estendido na task B10.
- Baselines documentados: leitura ICOM p50 24-28ms (`docs/handoff/2026-07-03-pix-hsm-outage-frontend-i18n-icom-handoff.md:14`); PIX-IN fim-a-fim PROD 07/07 p50 501ms / p95 945ms / p99 2.121ms (`docs/reports/2026-07-09-mapeamento-aviv/07-escala-performance.md:118`); referencia AVIV 812k tx/dia.

## A10. Lacuna REAL de recebimento descoberta pelo estudo (entra no escopo)

O parser inbound pega SEMPRE a primeira ocorrencia: `message_parser.ex:185` (`Regex.run` no primeiro `<EndToEndId>`) e `:673` (`xpath_text(doc, ~c"//EndToEndId")`, primeiro no; implementacao 765-779). **Se QUALQUER PSP nos enviar uma pacs.008 com NbOfTxs>1 (direito dele pelo XSD, e o legado mwbank mandava), hoje processariamos APENAS a primeira transacao e daria ACK: credito PERDIDO em silencio das transacoes 2..N.** Dinheiro que o SPI ja liquidou. O mesmo vale para pacs.002 com N `TxInfAndSts` (so o primeiro status aplicado). Esta frente fecha a leitura ANTES de ligar qualquer lote nosso (tasks B1-B3): primeiro o lado de leitura vira multi-tx-correto, depois o lado de escrita ganha o lote.

## A11. Decisao de arquitetura (sustentada pelo G1)

1. **Leitura primeiro (B1-B3)**: parser com listas + fan-out no InboundProcessor (pacs.008-in credita TODAS as transacoes; pacs.002 aplica TODOS os status). Valor imediato mesmo sem lote nosso: fecha perda silenciosa de dinheiro.
2. **Lote no OutboundSender (B4-B5)**: agrupa por poll (janela adicional 0ms default), chave de grupo = prioridade do GrpHdr (+ canal), teto 10, flag OFF. CoreEventProcessor intocado no nucleo (1 mudanca aditiva: metadados de grupo no `outbound_msg`). Claims por membro preservados + `batch_message_id` novo. Fallback unitario estrutural: qualquer condicao anormal (grupo de 1, membro sem claim, flag OFF, erro de fusao) = envio unitario identico ao de hoje.
3. **Desfecho por transacao (B3/B7)**: pacs.002 por E2E (ja e a chave do `find_tx_by_e2e`); admi.002/StuckOutbound com fan-out para membros do lote; quarentena nunca move dinheiro.
4. **Limites BACEN (B8)**: token bucket cliente espelhando a tabela do manual, modo observacao por default; respeito ao 429/Retry-After.
5. **Medicao (B9-B10)**: PERF por sub-fase (build/sign/post) + metricas de lote; carga no simulador com criterios mensuraveis.
6. **Fora de escopo registrado**: multipart de ENTRADA (10 msgs/POST), lotoes CSM/PAGAGD (500 ops/msg, ScheduledPix), pacs.004 em lote, C14N nativo/CertificatePool ETS/keepalive HSM (recomendacoes 1k TPS: SO entram se as medicoes de B10 provarem gargalo, conforme plano mestre).

---

# PARTE B - TASKS DE IMPLEMENTACAO

Regras gerais: TDD (RED do payload real do G1; fixture inventada = task reprovada); testes focados durante dev, suite completa no gate de merge; commits frequentes na worktree da frente; zero acento em XML de fio (usar `TextSanitizer` existente); unidade monetaria conferida na fronteira (builder recebe o mesmo shape de `p[:amount]` de hoje, NAO reconverter). Comandos rodam de `/Users/luizpenha/monetarie/pix/backend`.

### Task B1: Parser multi-tx honesto (listas) no MessageParser

**CONTRATO PROVADO:** XSD pacs.008 `CdtTrfTxInf maxOccurs=unbounded` (A1); XSD pacs.002 `TxInfAndSts maxOccurs=unbounded` (A2); exemplos oficiais `pacs.008_END_10_msg.xml` e `pacs.002_SPI_10_msg.xml` (fixtures desta task, copiados VERBATIM do catalogo). Hoje `xpath_text` retorna o primeiro no (message_parser.ex:765-779).

**Files:**
- Modify: `pix/backend/apps/shared/lib/shared/bacen/iso20022/message_parser.ex`
- Test: `pix/backend/apps/shared/test/shared/bacen/iso20022/message_parser_multi_tx_test.exs` (novo)
- Fixtures: `pix/backend/apps/shared/test/support/fixtures/spi/pacs.008_END_10_msg.xml` e `pacs.002_SPI_10_msg.xml` (copiar de `/Users/luizpenha/cecresa/md/spi.5.12.1/v5.12.1/exemplos/...`, verbatim)

**Step 1: Escrever os testes RED**

```elixir
defmodule Shared.Bacen.Iso20022.MessageParserMultiTxTest do
  use ExUnit.Case, async: true
  alias Shared.Bacen.Iso20022.MessageParser

  @pacs008_10 File.read!("test/support/fixtures/spi/pacs.008_END_10_msg.xml")
  @pacs002_10 File.read!("test/support/fixtures/spi/pacs.002_SPI_10_msg.xml")

  test "extract_transactions/1 devolve as 10 transacoes do exemplo oficial" do
    assert {:ok, txs} = MessageParser.extract_transactions(@pacs008_10)
    assert length(txs) == 10
    assert Enum.map(txs, & &1.end_to_end_id) |> Enum.uniq() |> length() == 10
    assert hd(txs).end_to_end_id == "E9999901012341234123412345678900"
    assert hd(txs).amount == "1000.00"
    assert Enum.all?(txs, & &1.acceptance_datetime)
  end

  test "extract_transactions/1 com pacs.008 de 1 transacao devolve lista de 1" do
    # usar fixture single existente do repo (ou pacs.008_END_1_msg.xml do catalogo)
  end

  test "extract_status_items/1 devolve os 10 TxInfAndSts com E2E e TxSts proprios" do
    assert {:ok, items} = MessageParser.extract_status_items(@pacs002_10)
    assert length(items) == 10
    assert Enum.all?(items, &(&1.status == "ACCC"))
    assert Enum.map(items, & &1.original_end_to_end_id) |> Enum.uniq() |> length() == 10
  end

  test "nb_of_txs/1 le o contador do GrpHdr" do
    assert {:ok, 10} = MessageParser.nb_of_txs(@pacs008_10)
  end
end
```

**Step 2: Rodar e ver falhar**

Run: `mix test apps/shared/test/shared/bacen/iso20022/message_parser_multi_tx_test.exs`
Expected: FAIL (`extract_transactions/1 undefined`)

**Step 3: Implementar**

Em `message_parser.ex`: `extract_transactions/1` (xmerl `//CdtTrfTxInf` com `xmerl_xpath:string` retornando TODOS os nos; por no, extrair `EndToEndId`, `IntrBkSttlmAmt` + `@Ccy`, `AccptncDtTm`, `Dbtr/Cdtr` basicos com os MESMOS nomes de campo que `extract_document` ja usa); `extract_status_items/1` (`//TxInfAndSts`, por no: `OrgnlEndToEndId`, `OrgnlInstrId`, `TxSts`, `StsRsnInf/Rsn/Cd|Prtry`); `nb_of_txs/1`. NAO alterar as funcoes single existentes (compatibilidade: todo o resto do sistema continua identico). Feed do xmerl em bytes (`:erlang.binary_to_list/1`, licao do defeito do acento de 17/07, inbound_processor.ex:2594).

**Step 4: Rodar e ver passar**

Run: `mix test apps/shared/test/shared/bacen/iso20022/message_parser_multi_tx_test.exs`
Expected: PASS. Regressao focada: `mix test apps/shared/test/shared/bacen/`

**Step 5: Commit**

```bash
git add apps/shared/lib/shared/bacen/iso20022/message_parser.ex apps/shared/test/
git commit -m "feat(pix): MessageParser multi-tx - extract_transactions/extract_status_items/nb_of_txs sobre os exemplos oficiais do catalogo"
```

### Task B2: pacs.008 RECEBIDA com NbOfTxs>1 credita TODAS as transacoes (fecha perda silenciosa)

**CONTRATO PROVADO:** A10 (hoje so a primeira transacao e processada e a mensagem ganha ACK; PIX-in chega LIQUIDADO pelo SPI, entao dropar tx 2..N = dinheiro do cliente perdido). Legado iterava todos os `CdtTrfTxInf` (A7, `ConversorEnvioAPI.Impl.PACS008/v15.cs:43`).

**Files:**
- Modify: `pix/backend/apps/spi_service/lib/spi_service/workers/inbound_processor.ex` (handler da pacs.008 inbound; localizar o dispatch `"pacs.008"` no G1 sonda do Step 1)
- Test: `pix/backend/apps/spi_service/test/spi_service/workers/inbound_processor_multi_tx_pacs008_test.exs` (novo)

**Step 1: Sonda G1 local (sem codigo)**

Ler o dispatch de `"pacs.008"` em `inbound_processor.ex` e o caminho de credito por transacao (funcao que persiste a linha + publica o evento de credito). Anotar no teste, em comentario, arquivo:linha do caminho single. Confirmar a chave de idempotencia por transacao (dedup por E2E) que garante NAK-and-retry seguro.

**Step 2: Teste RED (forma real)**

Montar fixture de pacs.008-in com 2 `CdtTrfTxInf` derivada do exemplo oficial (Fr = ISPB externo, To = 46026562, E2Es distintos, valores distintos). Asserts: (a) as 2 transacoes sao processadas (2 linhas/2 eventos de credito); (b) reprocesso da MESMA mensagem nao duplica credito (idempotencia por E2E); (c) falha em UMA transacao = retorno de erro do handler (NAK, sem ACK parcial silencioso), e no retry a transacao ja processada e pulada pelo dedup.

Run: `mix test apps/spi_service/test/spi_service/workers/inbound_processor_multi_tx_pacs008_test.exs`
Expected: FAIL (hoje so a primeira e processada)

**Step 3: Implementar**

No handler da pacs.008 inbound: usar `MessageParser.extract_transactions/1`; `Enum.reduce_while` processando cada transacao pelo MESMO caminho single existente (reuso, zero fork de logica); acumular resultados; qualquer erro nao-idempotente = `{:error, ...}` propagado (BaseWorker NAK). Telemetria: `[:spi, :inbound, :multi_tx]` com `nb_of_txs` quando > 1 (visibilidade: hoje nao sabemos se alguem nos manda lote).

**Step 4: Verificar**

Run: `mix test apps/spi_service/test/spi_service/workers/inbound_processor_multi_tx_pacs008_test.exs && mix test apps/spi_service/test/spi_service/workers/ --only pacs008`
Expected: PASS + zero regressao no single.

**Step 5: Commit**

```bash
git commit -am "fix(pix): pacs.008 recebida com NbOfTxs>1 credita todas as transacoes (antes: so a primeira, ACK silencioso)"
```

### Task B3: pacs.002 com N TxInfAndSts aplica TODOS os status (lote parcial 9x1)

**CONTRATO PROVADO:** A2 (exemplo oficial com 10 `TxInfAndSts` independentes; `pacs.002_ERRO` prova RJCT por item); A6.8 (hoje `process_status_report` usa o primeiro `OrgnlEndToEndId`/`TxSts` do envelope).

**Files:**
- Modify: `pix/backend/apps/spi_service/lib/spi_service/workers/inbound_processor.ex:2139-2248` (`process_status_report/2`)
- Test: `pix/backend/apps/spi_service/test/spi_service/workers/inbound_processor_pacs002_multi_status_test.exs` (novo)

**Step 1: Teste RED**

Fixtures: (a) pacs.002 com 10 `TxInfAndSts` ACCC (verbatim do catalogo, E2Es remapeados para 10 transacoes semeadas em PDNG/ACSP); (b) pacs.002 MISTA: 9 ACCC + 1 RJCT AB09 (derivada, forma real dos itens). Asserts: 10 linhas atualizadas com desfecho proprio; na mista, 9 settled + 1 rejected com reason AB09; publish de `status_update` por transacao; item cujo E2E nao correlaciona nao bloqueia os demais mas propaga erro no final (NAK; reprocesso e idempotente porque aplicar status ja aplicado e no-op CAS).

Run: `mix test apps/spi_service/test/spi_service/workers/inbound_processor_pacs002_multi_status_test.exs`
Expected: FAIL (so o primeiro status aplicado)

**Step 2: Implementar**

`process_status_report/2` passa a: `MessageParser.extract_status_items/1`; para 1 item, caminho atual intacto (incl. resolucao por RtrId de pacs.004 e fallback por `original_msg_id`); para N itens, iterar aplicando o pipeline por item (correlacao `find_tx_by_e2e` por item; `map_pacs002_status/1` por item; `publish_status_update/3` por item). Acumular `{oks, erros}`; erros != [] -> `{:error, {:partial_status_apply, erros}}` (NAK, fail-closed preservado 2206-2213). Cuidar: o fallback por `original_msg_id` (2158) so vale para envelope de 1 item (num lote ele e ambiguo; para N itens exigir E2E por item).

**Step 3: Verificar**

Run: `mix test apps/spi_service/test/spi_service/workers/inbound_processor_pacs002_multi_status_test.exs && mix test apps/spi_service/test/spi_service/workers/inbound_processor*`
Expected: PASS + zero regressao (incl. testes de correlacao/pruning de `79c764d8`).

**Step 4: Commit**

```bash
git commit -am "feat(pix): pacs.002 multi-status - desfecho POR transacao, lote parcial 9 aceitas + 1 rejeitada provado"
```

### Task B4: Builder pacs.008 com N transacoes (retrocompativel)

**CONTRATO PROVADO:** A1 (XSD unbounded, exemplo oficial de 10, `PmtTpInf` SO no GrpHdr, `AccptncDtTm` por transacao); A6.9 (builder hardcoded `NbOfTxs=1` em message_builder.ex:173, bloco unico 177-193). Legado: `GeraOperacoes` com AppendJoin (A7).

**Files:**
- Modify: `pix/backend/apps/shared/lib/shared/bacen/iso20022/message_builder.ex:163-...` (`build_pacs008/1`)
- Test: `pix/backend/apps/shared/test/shared/bacen/iso20022/message_builder_pacs008_batch_test.exs` (novo)

**Step 1: Teste RED**

```elixir
test "build_pacs008 com :transactions gera N CdtTrfTxInf, NbOfTxs=N e valida no XSD oficial" do
  txs = for i <- 0..9, do: %{end_to_end_id: e2e(i), amount: "10.0#{i}", acceptance_datetime: dt(i),
                              debtor_name: ..., debtor_document: ..., creditor_ispb: ..., ...}
  {:ok, xml} = MessageBuilder.build("pacs.008", base_params() |> Map.put(:transactions, txs))
  assert xml =~ "<NbOfTxs>10</NbOfTxs>"
  assert length(String.split(xml, "<CdtTrfTxInf>")) == 11
  # gate XSD real (mesmo validador do OutboundSender) contra priv/xsd/spi/v5.12.1/pacs.008.spi.1.15.xsd
  assert :ok = validate_against_official_xsd(xml)
end

test "shape antigo (mapa unico) continua identico byte a byte ao snapshot atual" do
  # golden test: build single ANTES e DEPOIS da mudanca produz o mesmo XML
end

test "lote com prioridades misturadas e recusado" do
  assert {:error, :mixed_priority_batch} = MessageBuilder.build("pacs.008", params_com_prioridades_diferentes())
end
```

Run: `mix test apps/shared/test/shared/bacen/iso20022/message_builder_pacs008_batch_test.exs`
Expected: FAIL

**Step 2: Implementar**

`build_pacs008/1`: se `params[:transactions]` ausente, embrulhar o mapa atual em lista de 1 (caminho antigo = caso especial do novo; golden test garante byte-igual). `NbOfTxs` = length; bloco `CdtTrfTxInf` vira funcao `render_cdt_trf_tx_inf(p, tx)` mapeada sobre a lista; campos de GrpHdr (prioridade, SttlmMtd) validados HOMOGENEOS (`:mixed_priority_batch` se nao); `AccptncDtTm` POR transacao (nunca herdar do lote); `TextSanitizer` aplicado por transacao (zero acento no fio). Formato de valor identico ao atual (mesma funcao de formatacao; unidade NAO muda nesta task).

**Step 3: Verificar**

Run: `mix test apps/shared/test/shared/bacen/iso20022/`
Expected: PASS (novo + golden + suite existente do builder intacta).

**Step 4: Commit**

```bash
git commit -am "feat(pix): build_pacs008 aceita :transactions (N CdtTrfTxInf, NbOfTxs=N, prioridade homogenea, XSD-valido); shape single byte-igual"
```

### Task B5: Agrupamento no OutboundSender (flag OFF, fallback unitario estrutural)

**CONTRATO PROVADO:** A6 (poll batch_size 100 ja existe; claim por message_id; 1 assinatura por envelope; gate_by_status assume 1 transaction_id); A0/A4 (janela adicional default 0ms; teto 10); A3 (custo de token identico; ganho = HSM/HTTP).

**Files:**
- Create: `pix/backend/apps/spi_service/lib/spi_service/workers/outbound_batch.ex` (funcoes puras de agrupamento/fusao)
- Modify: `pix/backend/apps/spi_service/lib/spi_service/workers/outbound_sender.ex` (desvio com flag no processamento do poll)
- Modify: `pix/backend/apps/settlement_service/lib/settlement_service/workers/core_event_processor.ex:709-724` (aditivo: `batch_key` com a prioridade no `outbound_msg`)
- Create migration: `pix/backend/apps/shared/priv/repo/migrations/20260718XXXXXX_add_batch_message_id_to_outbound_send_claims.exs` (`ALTER TABLE monetarie_spi.outbound_send_claims ADD COLUMN IF NOT EXISTS batch_message_id varchar(64); CREATE INDEX IF NOT EXISTS ... ON ... (batch_message_id)` - aditiva, idempotente, no MESMO commit do codigo que a usa)
- Modify: `pix/backend/config/runtime.exs` (knobs)
- Test: `pix/backend/apps/spi_service/test/spi_service/workers/outbound_batch_test.exs` + `outbound_sender_batch_test.exs` (novos)

**Knobs (defaults CONSERVADORES):** `PIX_OUTBOUND_BATCH_ENABLED=false` (mestre); `PIX_BATCH_MAX_TXS=10` (teto hard 10, validado no boot); `PIX_BATCH_WINDOW_MS=0` (teto hard 50; 0 = so agrupa o que o poll ja entregou). Ligar em qualquer ambiente = decisao do dono.

**Step 1: Testes RED das funcoes puras (`outbound_batch_test.exs`)**

- `group/2` particiona mensagens pacs.008 pendentes por `batch_key` (prioridade+canal) em grupos de ate `max_txs`, preservando ordem; mensagens sem `batch_key` (formato antigo em voo durante deploy) NUNCA entram em lote (fallback unitario: retrocompatibilidade nas duas direcoes de deploy).
- `merge/1` funde N `outbound_msg` (cada um com `xml_content` single pronto) num envelope novo: extrai os blocos `<CdtTrfTxInf>` de cada XML (nosso proprio builder canonico, forma deterministica), gera `GrpHdr` novo com `NbOfTxs=N` e `batch_message_id` novo (mesmo formato `M` + ISPB + 23 alfanum do gerador existente), reusa a validacao de homogeneidade da B4. Erro de fusao de QUALQUER natureza = `{:unit, msgs}` (fallback, nunca perde mensagem).

**Step 2: Testes RED do sender (`outbound_sender_batch_test.exs`)**

- flag OFF (default): comportamento byte-identico ao atual (teste de caracterizacao).
- flag ON, poll com 12 pacs.008 do mesmo grupo: 2 POSTs (10+2), 1 assinatura por POST, TODOS os 12 claims de membro gravados ANTES do POST + claim do lote com `batch_message_id`, 12 `mark_tx_sent` no ACK.
- membro ja claimado (redelivery): e EXCLUIDO do lote e os demais seguem.
- erro de transporte do POST do lote: release dos claims de TODOS os membros; na redelivery os membros voltam UNITARIOS (flag de retry: lote so na primeira tentativa - falha de lote nunca vira loop de lote).
- rejeicao sincrona do BACEN ao envelope: settle "rejected" nos membros + desfecho por linha com o motivo real.
- poll com 1 mensagem: envio unitario identico ao de hoje (zero janela adicional).

Run: `mix test apps/spi_service/test/spi_service/workers/outbound_batch_test.exs apps/spi_service/test/spi_service/workers/outbound_sender_batch_test.exs`
Expected: FAIL

**Step 3: Implementar**

`OutboundBatch` puro (group/merge). No `OutboundSender`: quando flag ON e o lote de poll contem 2+ pacs.008 elegiveis do mesmo grupo, rodar o pipeline (gates por MEMBRO -> fusao -> assinatura UNICA do envelope -> XSD gate no envelope -> claims por membro + claim do lote -> POST -> settle por membro). Membro reprovado em gate individual sai do lote e segue o caminho unitario proprio. `CoreEventProcessor`: adicionar `batch_key` (prioridade ja conhecida na montagem) ao `outbound_msg` (aditivo; consumidor antigo ignora o campo).

**Step 4: Verificar**

Run: `mix test apps/spi_service/test/spi_service/workers/ && mix test apps/settlement_service/test/ --only core_event`
Expected: PASS; caracterizacao flag OFF intacta.

**Step 5: Commit**

```bash
git commit -am "feat(pix): empacotamento de ate 10 pacs.008 no OutboundSender (flag OFF; 1 assinatura HSM por lote; claims por membro + batch_message_id; fallback unitario estrutural)"
```

### Task B6: Simulador multi-tx (pre-requisito da carga)

**CONTRATO PROVADO:** A9 (simulador 1:1: response_generator.ex:211 le um E2E; builder do simulador fixa NbOfTxs=1). Exemplos oficiais de 10 como forma-alvo.

**Files:**
- Modify: `pix/backend/apps/shared/lib/shared/bacen/simulator/response_generator.ex` (iterar `extract_transactions/1` da nossa pacs.008; gerar pacs.002 com N `TxInfAndSts`)
- Modify: `pix/backend/apps/shared/lib/shared/bacen/iso20022/message_builder.ex` (build de pacs.002 com `items:` lista, shape do exemplo oficial; uso interno do simulador)
- Modify: `pix/backend/apps/shared/lib/shared/bacen/simulator/scenario_runner.ex` (cenario novo `:partial_reject`, ex.: 9 ACCC + 1 RJCT AB09)
- Test: `pix/backend/apps/shared/test/shared/bacen/simulator/response_generator_multi_tx_test.exs` (novo)

**Steps:** (1) RED: enviar ao gerador uma pacs.008 nossa com 10 CdtTrfTxInf e afirmar pacs.002 com 10 `TxInfAndSts` correlacionados por E2E; cenario `:partial_reject` devolve 9 ACCC + 1 RJCT com reason valido do enum (`RejectCodes.sanitize_pacs002` continua o gate); regra P0-1 (so responde pacs.008 NOSSA) preservada. (2) FAIL. (3) Implementar iterando `extract_transactions/1` (B1). (4) PASS + regressao `mix test apps/shared/test/shared/bacen/simulator/`. (5) Commit `feat(pix): simulador responde pacs.008 multi-tx com pacs.002 multi-status (+cenario partial_reject)`.

### Task B7: Falha de ENVELOPE do lote: fan-out terminal para os membros

**CONTRATO PROVADO:** A2 (admi.002 referencia o PI-ResourceId/mensagem: as N transacoes morrem juntas); A6.4 (claims com `batch_message_id` da B5 dao o mapa lote->membros); mecanica atual de admi.002/StuckOutboundChecker (correlacao via `bacen_outbound`, arco R6: rejeicao terminal via VoidGuard atras de flag; sem evidencia = quarentena barulhenta, NUNCA mover dinheiro no escuro).

**Files:**
- Modify: handler de admi.002 no `inbound_processor.ex` + `StuckOutboundChecker` (localizar por sonda no Step 1; core: `apps/.../workers/stuck_outbound_checker.ex` no Core e o correlato da cabine)
- Test: `pix/backend/apps/spi_service/test/spi_service/workers/admi002_batch_fanout_test.exs` (novo)

**Steps:** (1) Sonda G1: ler a correlacao atual de admi.002 (por PI-ResourceId/MsgId em `bacen_outbound`) e anotar arquivo:linha no teste. (2) RED: admi.002 de erro de validacao apontando para `batch_message_id` com 10 membros -> as 10 linhas ganham desfecho terminal proprio (rejeitada com o motivo do admi.002), hold liberado pelo funil UNICO existente (HoldRelease via evento, nunca credito direto), claims settled "rejected"; lote SEM veredito no SLA -> membros entram na MESMA quarentena/alerta do StuckOutbound atual (nenhum caminho novo de dinheiro). (3) Implementar: resolucao `batch_message_id -> membros` via `outbound_send_claims`; fan-out reusando o caminho terminal single. (4) PASS + regressao stuck/admi. (5) Commit `feat(pix): admi.002/stuck de lote faz fan-out terminal por membro (quarentena preservada)`.

### Task B8: Guardiao de limites ICOM (token bucket cliente) + respeito ao 429

**CONTRATO PROVADO:** A3.1 (tabela oficial: CPM 3750/750 por s; custo 1 token por OPERACAO de pacs.008; 429 + `Retry-After`; debito no processamento, saldo pode ficar negativo).

**Files:**
- Create: `pix/backend/apps/shared/lib/shared/bacen/icom_token_budget.ex` (ETS/atomics por canal; debito = numero de operacoes da mensagem)
- Modify: `pix/backend/apps/spi_service/lib/spi_service/workers/outbound_sender.ex` (consulta antes do POST; tratamento de 429 com `Retry-After`)
- Modify: `pix/backend/config/runtime.exs` (knobs `PIX_ICOM_BUDGET_MODE=observe|enforce|off`, default `observe`; parametros do bucket com defaults = tabela do manual)
- Test: `pix/backend/apps/shared/test/shared/bacen/icom_token_budget_test.exs` (novo)

**Steps:** (1) RED: bucket CPM inicia 3750; debitar 10 (lote) consome 10; recarga 750/s; abaixo de zero em modo `observe` = so telemetry `[:pix, :icom, :budget]` + log warning (NUNCA bloqueia); em `enforce` = atrasa/NAK com backoff ate saldo positivo; resposta 429 real do ICOM registra `Retry-After` e o sender NAK-a com o atraso respeitado (sonda: como o `Client`/sender tratam 429 hoje; caracterizar antes). (2) FAIL. (3) Implementar (`:atomics` + timestamp de recarga preguicosa; sem GenServer no caminho quente). (4) PASS. (5) Commit `feat(pix): token bucket cliente do ICOM (observe por default) + 429/Retry-After respeitado`.

### Task B9: PERF por sub-fase do envio + metricas de lote

**CONTRATO PROVADO:** A6.10 (PerfLog cobre recv/done; NAO ha sub-fases build/sign/post); padrao de log AVIV chave=valor parseavel por CloudWatch Logs Insights.

**Files:**
- Modify: `pix/backend/apps/spi_service/lib/spi_service/workers/perf_log.ex` + `outbound_sender.ex`
- Test: `pix/backend/apps/spi_service/test/spi_service/workers/perf_log_phases_test.exs` (novo)

**Steps:** (1) RED: envio unitario emite linha `[ICOM] SEND_OK rid=... e2e=... batch=1 gate_ms=.. sign_ms=.. xsd_ms=.. post_ms=..`; envio em lote emite `batch=N batch_key=... merge_ms=..` + telemetry `[:pix, :outbound, :send]` com medidas por fase e `[:pix, :outbound, :batch]` com `size`. (2) FAIL. (3) Implementar com `System.monotonic_time` por etapa do `with` (98-111). (4) PASS. (5) Commit `feat(pix): PERF por sub-fase do envio (gate/sign/xsd/post) + metricas de lote`.

### Task B10: Carga no simulador local com criterios mensuraveis

**CONTRATO PROVADO:** A9 (CapacityTest IN 508 existente: `run_spi_capacity(tps: 2000, ...)`); A4 (ANS 1600ms por transacao); baseline PROD p50 501ms PIX-in / pacs.002 handler pos-fix.

**Files:**
- Modify: `pix/backend/apps/shared/lib/shared/bacen/simulator/capacity_test.ex` (modo lote: `run_spi_capacity(tps: N, batch: true)`; contadores de POSTs e assinaturas via telemetry da B9)
- Create: `docs/reports/2026-07-XX-f5-carga-lote-simulador.md` (numeros medidos, gerado ao rodar)

**Roteiro (local, `scripts/dev_up_all.sh pix`, `SIMULATOR_ENABLED=true`, BACEN/HSM OFF):**
1. Baseline unitario: 2.000 pacs.008/min por 60s (IN 508). Registrar p50/p95/p99 de `SEND_OK post_ms` e `duration_ms`, contagem de assinaturas (=N) e POSTs (=N), breaches de ANS (esperado: 0).
2. Lote ON (`PIX_BATCH_MAX_TXS=10`, janela 0): mesma carga. **Criterios de aceite (medidos, nao estimados):**
   - assinaturas <= N/8 e POSTs <= N/8 (lotes parciais admitidos);
   - p50 de `duration_ms` por transacao NAO PIOR que o baseline (+10% de tolerancia) e p95 MENOR OU IGUAL ao baseline sob rajada;
   - ANS breaches = 0 nas duas rodadas;
   - ZERO linha sem desfecho (toda transacao termina settled/rejected no cenario `:partial_reject` incluido: 9+1 por lote);
   - claims: zero vazamento (`outbound_send_claims` sem membro orfao de `batch_message_id`).
3. Rajada de estresse: 500 tx injetadas de uma vez; medir tempo total de escoamento lote vs unitario (alvo: reducao >= 50%) e pico de uso do token budget (B8, modo observe: nenhum estouro do bucket 3750 simulado).
4. Cenarios de falha: `:outbound_timeout` num POST de lote (release + retry unitario provado vivo no simulador); `:partial_reject`.

Commit: `test(pix): carga do lote no simulador - criterios p50/p95/ANS/claims medidos` + relatorio com os numeros.

### Task B11: Validacao viva (G3) + preparacao da janela de deploy

**Files:**
- Create: `docs/operator/2026-07-XX-runbook-f5-lote-pix-deploy-janela.md`

**Conteudo obrigatorio do runbook (escrito nesta task, executado SO pelo orquestrador):**
1. **HML (G3 da frente)**: deploy com flags OFF; validar caminho unitario intacto (1 PIX real do parceiro via tunel SSM 18080, pacs.002 e extrato conferidos); ligar `PIX_OUTBOUND_BATCH_ENABLED=true` SO em HML; injetar rajada de 12 PIX reais/validos (parceiro OAuth); provar VIVO: 2 POSTs (10+2) nos logs `SEND_OK batch=10/2`, 12 desfechos individuais, webhooks/extrato por transacao, ICOM CPM/CSM saudaveis. Evidencia colada no fecho da frente.
2. **Migrations**: `20260718XXXXXX` (batch_message_id) via rpc, HML antes de PRD, idempotente.
3. **PRD**: SO em janela dedicada com acompanhamento de logs (mandato); deploy com flags OFF (comportamento identico ao atual); ICOM CPM+CSM reassumidos pos-swap (lider + sessoes, zero janela surda); rollback = revisao anterior anotada; **ligar o lote em PRD = decisao explicita do dono em janela propria**, com vigia de `SEND_OK batch=`, `[:pix, :ans, :breach]`, DLQ e token budget em modo observe.
4. **Regras duras herdadas**: nunca deployar durante operacao ao vivo do money-path; freeze durante as sequencias do cliente (21/07); em producao nao existe teste (validacao definitiva = trafego real acompanhado).

## Sequencia e dependencias

B1 -> B2 -> B3 (leitura correta primeiro; valor mesmo sem lote) -> B4 -> B5 (escrita em lote, flag OFF) -> B6 (simulador) -> B7 (falha de envelope) -> B8 (budget) -> B9 (PERF) -> B10 (carga medida) -> B11 (runbook/G3). B6 pode rodar em paralelo com B4-B5 (depende so de B1). Revisor de spec + revisor de qualidade por task; revisao adversarial da frente inteira + suite completa no fecho (protocolo do plano mestre).

## Fora de escopo (registrado, nao perdido)

- Multipart no endpoint de ENTRADA (10 mensagens/POST, manual 2.2.1.1): segunda alavanca de amortizacao HTTP; so se B10 provar que o POST ainda e gargalo.
- Lotoes CSM/PAGAGD (500 ops/msg do exemplo do manual): territorio do ScheduledPix; frente propria.
- pacs.004 em lote (XSD suporta; legado fazia): volume nao justifica agora.
- C14N nativo, CertificatePool ETS, keepalive HSM (recomendacoes 1k TPS): entram SOMENTE se as medicoes de B10/HML provarem gargalo (plano mestre F5).
- `SIDECAR_TLS_SESSION_CACHE` em PROD: ja autorizado (17/07); aplicacao e decisao do orquestrador na janela, nao codigo desta frente.
