# Handoff — 13 e 14/07/2026: gate de XSD oficial do BACEN, money path do SPB, R1 em uma linha

`origin/main = 9f2e96b4`

Estado dos serviços em produção ao fim da sessão (cluster `monetarie-greenfield-prod`, 10.50):

| serviço | revisão PROD | rollback | o que entrou |
|---|---|---|---|
| `spb-api` | **`:23`** | `:21` | gate de XSD oficial, R1 em uma linha, dobra das respostas |
| `pix-api` | `:35` | `:34` | carga real, blindagem, ICOM, saldo Conta PI |
| `core-api` | `:26` | `:25` | escala monetária (mundo dividido por endpoint) |

HML: `spb-api:51`. Imagem de produção é **o mesmo digest provado em homologação** (retag, sem rebuild).

Suíte do SPB: **77 falhas** — a linha de base era 78. Nenhuma regressão em toda a sessão.

---

## 1. Incidente de produção: os ecos GEN0001 pararam de sair

A cada 30 minutos o SPB envia um GEN0001 (eco de conectividade) ao BACEN. Em 14/07 eles
começaram a falhar: cinco falharam antes de alguém ver.

```
[RealDispatcher] GEN0001 REJEITADA pelo gate de XSD
/NumSeq: value '001' does not match pattern
```

Causa: o gate de envio que eu tinha adicionado usava o `XSDValidator`, que **nunca leu o
XSD do BACEN** — ele aplica a tabela `@bacen_types`, escrita à mão. A regra de `NumSeq`
exigia exatamente 9 dígitos, e o GEN0001 manda `001`. O esquema oficial diz outra coisa:

```xml
<xs:simpleType name='NumSeq'>
  <xs:restriction base='xs:integer'>
    <xs:totalDigits value='9'/>   <!-- ATÉ 9 dígitos, não exatamente 9 -->
  </xs:restriction>
</xs:simpleType>
```

**A regra estava errada, não a mensagem.** A saída não foi afrouxar o gate (isso seria
trocar um defeito por outro: mensagem torta sai, o BACEN rejeita horas depois, o dinheiro
fica no limbo). A saída foi tirar as regras do BACEN, não da nossa cabeça.

### O que passou a valer

Os **992 XSDs oficiais** do catálogo v5.12 agora vivem dentro do serviço
(`spb/services/bacen_gateway/priv/xsd/v512/`, 16 MB, entram na imagem; o Dockerfile já
instalava `libxml2-utils`). Fonte: `/Users/luizpenha/cecresa/md/XSDDOCV512`.

- `BacenGateway.Validation.OfficialXSD` valida por `xmllint` contra o esquema do BACEN.
- `RealDispatcher`: o gate é **fail-CLOSED pelo XSD oficial**. A tabela interna virou rede
  de segurança — usada só onde não existe XSD oficial, nunca como fonte de verdade.

> `mwbank/LegadoSPB` **não serve** como fonte: conferido, são só binários .NET, zero XSD,
> zero `.cs`, e nenhum arquivo sequer cita `bcb.gov.br`.

### Cinco defeitos reais que a conferência contra os 992 XSDs revelou

1. **O namespace não se deriva da sigla do grupo.** Só GEN usa o próprio nome; 8 famílias
   usam `/MES/` (CAM CCR CCS **COR** CQL ECR LEI PTX SRC) e as outras 21 usam `/SPB/`.
   `STR0008` é `http://www.bcb.gov.br/SPB/STR0008.xsd`. O `base_handler` derivava da
   sigla: acertava GEN por coincidência e **errava 965 dos 992 esquemas**. O BACEN rejeita
   a mensagem inteira (*No matching global declaration available for the validation root*).
   Confirmado por duas fontes independentes: o `targetNamespace` dos XSDs e as amostras
   reais do BACEN em `test/fixtures/bacen_real_samples/`.
2. `BcmsgEnvelope.namespace_for/1` — que se dizia "auditado" — punha **COR em `/SPB/`**.
   O oficial é `/MES/`. São 14 esquemas.
3. **23 módulos do grupo GEN** cravavam `/SPB/GEN####.xsd`. Entre eles o **GEN0020**, que a
   produção já enviou.
4. **134 builders** (CAM 50, CIR 43, TES 25, GEN 11, COR 4, CTP 1) **não emitiam o
   `CodMsg`**, que o XSD exige como primeiro elemento (`fixed='TIPO'`). Todos seriam
   rejeitados pelo BACEN.
5. **LPI0005** (família do espelho da Conta PI): o XSD tem **dois `xs:choice`**
   — (`PercSldRB_CL` | `VlrRB_CL`) e (`PercSldCCME` | `VlrCCME`) — e o builder **exigia e
   emitia os quatro**. Todo LPI0005 que a cabine montava era inválido, e o teste dizia que
   estava certo (o fixture mandava `perc_sld_rb_cl: "SAMPLE"`, que nem `Percentual` é).

### A armadilha que quase passou por verdade

A primeira imagem subiu em homologação com os 992 XSDs dentro e **o gate cego**:

```
XSDS 0 | GEN0001 {:skip, :xsd_ausente} | TED_SEM_VALOR PASSOU!!
```

O caminho vinha de um **atributo de módulo**:

```elixir
@xsd_dir Application.app_dir(:bacen_gateway, "priv/xsd/v512")   # avaliado na COMPILAÇÃO
```

No build stage do Docker isso resolve para `/app/_build/prod/lib/...`, que **não existe na
imagem final** (`/app/lib/bacen_gateway-1.0.0/priv/...`). Todo `validate/2` devolvia
`{:skip, :xsd_ausente}` e o gate degradava **calado** para a tabela escrita à mão. E os
**testes ficavam verdes**, porque em `mix test` o caminho de compilação e o de execução são
o mesmo: a imagem mentia e o teste concordava.

Correção: `xsd_dir/0` resolvido em runtime + `assert_catalogo!/0` no boot
(`Logger.critical` + telemetry se achar menos de 900 XSDs, dizendo o caminho que procurou).

> **Regra para o time:** nunca resolver caminho de `priv/` em atributo de módulo, e sempre
> conferir no container vivo — não no `mix test`.

### Teste completo, não por amostragem

- `xsd_oficial_test.exs`: o namespace é conferido contra os **992 XSDs, um a um**. Prova
  também que a **TED SEM VALOR** (o P0 de 13/07) é reprovada pelo XSD oficial — a tabela
  escrita à mão aprovava — e que **valor negativo é válido** (o tipo `Valor` do BACEN é
  sinalizado; não se inventa regra que o BACEN não tem).
- `xsd_catalogo_completo_test.exs` + `test/support/xsd_sampler.ex`: varre **todo o
  catálogo**, gerando os dados de cada tipo **a partir do próprio esquema** (o de-para
  `NomeNoXSD -> data[:chave]` sai do código do builder; o valor sai das facetas do XSD:
  `pattern`, `enumeration`, `maxLength`, `totalDigits`, base `string|integer|decimal`).

**Gate duro:** os **17 tipos que a produção realmente envia** (lista tirada do banco vivo,
`SELECT DISTINCT message_type ... direction='outbound'`) passam no XSD oficial:

```
GEN0001 GEN0006 GEN0019 GEN0020
LPI0001 LPI0002 LPI0003 LPI0004 LPI0005
SME0001 SME0002 SME0003
STR0004 STR0008 STR0010 STR0013 STR0025
```

### Prova viva em produção

```
[OfficialXSD] catalogo do BACEN carregado: 992 XSDs (v5.12)
GEN0001 :ok | STR0008 :ok | TED sem valor: BARRADA
```

Eco GEN0001 real, disparado pelo caminho de produção (`ScheduledQueryExecutor.execute`) às
**12:41:58 → `r1_confirmed`** (o BACEN confirmou). Dia 13 os ecos estavam `r1_confirmed`;
hoje estavam `failed`; voltaram a `r1_confirmed`.

---

## 2. Uma operação, UMA linha (a R1 aparecia como lançamento)

Uma GEN0001 enviada aparecia como **três lançamentos** na tela de Mensagens: o envio e duas
GEN0001R1. Uma STR0013 enviada tinha duas STR0013R1. **A R1 é a mesma mensagem voltando do
BACEN — ela é o STATUS da linha do envio ("07 - R1 confirmada"), não um registro novo.**

Eram dois defeitos somados.

### 2.1 A R1 era gravada duas vezes (desde 08/07, quando o SPB entrou no ar)

```
12:56:14  STR0013R1  source=ibm_mq_inbound    operation_id = NULL   <- órfã
12:56:19  STR0013R1  source=inbound_consumer  operation_id = 8d5b41f1
```

Mesmo XML (`md5` idêntico), mesmo `NUOp`, cinco segundos de diferença. Em produção: **94
órfãs**, 199 linhas de chegada contra 174 processadas (contagem de recebidas inflada ~2x).

- `MQ.InboundAudit` grava a linha de **chegada** no webhook do MQ. Essa gravação **tem** que
  existir: o sidecar só confirma a sessão do MQ depois do 2xx.
- O `InboundConsumer` gravava **outra** linha, com o vínculo, em vez de completar a primeira.

O `MessageStore.insert/1` **já tinha** `ON CONFLICT (message_id) DO UPDATE` — a
deduplicação existia e funcionava. Só que o consumidor **não passava `message_id`**, então o
store **gerava** um (`str0013r1-inbound-...`) diferente do da chegada (`mq-...`): o conflito
nunca acontecia e nascia a segunda linha.

Correção: `message_id: audit_message_id(raw_id)` (reusa o da chegada) e
`ResponseCorrelation.link_one/1` **no fluxo** — antes a correlação só rodava por tarefa
manual no `release.ex`, por isso a R1 nascia sem `parent_message_id` e sem `operation_id`.

### 2.2 A dobra das respostas estava desarmada pelo "Todos"

A dobra (esconder R1/R2/R3/E na visão geral, mostrando só o fluxo do pai) **já existia** em
`Messages.list_messages/1`. Ela só não se aplica quando há filtro explícito — aí o operador
pediu para ver tudo, senão "Fluxo = Resposta" daria zero.

Mas a tela manda `tp_oper_grid: "todos"` **de propósito**: o parâmetro **ausente** significa
o default de escopo `"F"` no backend, e omiti-lo faria "Todos" se comportar como "Oper
Financeiras" (bug já corrigido antes). O `explicit_filter?` usava `not is_nil/1`, então esse
**neutro contava como filtro deliberado** e a visão geral caía no ramo "mostre tudo".

Correção: `@neutros ["todos", "all", ""]` não contam como filtro. Aplicado em
`list_messages/1` **e** em `count_messages/1` — se só um mudasse, o total mentiria.

> **Lição, e já mordeu duas vezes no SPB:** valor **neutro** de tela ("Todos") não pode ser
> lido como filtro no backend.

### Sem regressão no filtro "Todos" (o teste trava os dois lados)

`messages_lista_dobra_test.exs`, 7 testes contra banco de verdade (o banco de teste sobe
vazio, então o setup semeia `institutions` **e** `message_type_config` — sem o catálogo o
grid não é carimbado e o teste testaria o vazio):

- "Todos" não é filtro → a dobra vale, a R1 não vira linha.
- "Todos" não filtra categoria → nenhuma linha some (o bug original não volta).
- Categoria de verdade (F/C/A/N) → segue sendo filtro explícito, mostra as pernas R1/R2.
- `count_messages/1` conta o mesmo que `list_messages/1` devolve.

Validado no container de produção:

```
R1_SOLTAS_NA_TELA 0
TELA GEN0001 outbound r1_confirmed   (uma linha, com o status da R1)
FAMILIAS GEN,STR,LPI,SME             ("Todos" não esconde nada)
FILTRO_INBOUND STR0008R2, GEN0001R1, STR0013R1   (filtro explícito ainda mostra as pernas)
```

Entrada que **não** é resposta (STR0008R2 = TED recebida, GEN0007, STR0016) continua com
linha própria: é mensagem por direito, não perna de um envio nosso.

---

## 3. O resto da sessão (já em produção)

Frentes anteriores desta mesma sessão, todas com relato próprio na memória e nos commits:

- **Primeiro teste de carga real** (13/07): 210 PIX enviados ao BACEN, 207 pacs.002 lidas.
  **Nossa fila: p50 198 ms** — a cabine não é o gargalo. BACEN: p50 9,3 s.
  Revelou 35 de 43 códigos de rejeição com descrição errada, `PI-PayerId` obrigatório no
  GetEntry e `debtor_document` sem validação (210 assinaturas de HSM gastas à toa).
- **Blindagem do money path do SPB**: uma **TED SEM VALOR** era assinada no HSM e enviada ao
  BACEN (`<VlrLanc></VlrLanc>` passava com zero violações); a TED do cliente sumia da fila
  (ACK em erro); o outbox morria em silêncio. Corrigido na raiz (`element/2` explode,
  `maybe_element/2` é o opcional) — vale para os 587 tipos.
- **P0 do ICOM**: a cabine ficava surda ao BACEN por 1 a 2 minutos **a cada deploy** e perdia
  mensagem. Três defeitos.
- **Saldo da Conta PI**: era sobrescrito pelo fechamento de sexta; uma pacs.004 nunca saiu
  por causa de **um acento** (`bad_character 233`) e morria calada.
- **Import do AutBank** ressuscitava histórico como estado vivo (4.392 bloqueios fantasma,
  R$ 168,9 milhões).

Commits: `9f2e96b4 c5b84531 27d59337 28bb1031 f7e452d4 fe538aee be497d32 67e50e98 5f13e679
deb044fe 5ad3f5f0 73ec656c 92f57daa 4f451299 3ea6c842 00770335 b7d37298 7837333a`.

---

## Pendências para a próxima sessão

### P1 — Conversão de BRL errada na tela de Liquidação (10.000x) — PROVADO

`coreadmin.monetarie.internal/dashboard/treasury/settlement`, painel **Movimentos PIX no
Core**. Provado contra o banco vivo de produção:

| exibido na tela | valor real |
|---|---|
| R$ 12.834.700,00 (ted) | **R$ 1.283,47** |
| R$ 200.000.000,00 (pix) | **R$ 20.000,00** |
| R$ 178.137.700,00 (ted) | **R$ 17.813,77** |

Causa: `transactions.amount` está em **base_units** (subcentavos, 1 real = 10.000). O
endpoint `GET /treasury/transactions`
(`admin/coreproviders_parity_controller.ex:2766`, `serialize_transaction_basic/1`) devolve
`amount: tx.amount` **cru**, e a tela
(`core/apps/admin/src/views/treasury/SettlementBalanceView.vue:468`) formata com
`formatBrl(...)`, **sem dividir**.

O mais irônico: a própria tela **já tem** a função certa, `formatBrlFromSubcent/1` (linha
103), que divide por 10.000 — ela só não é usada nessa coluna.

Ao corrigir, seguir o contrato canônico de unidade monetária (memória
`monetarie-money-unit-scale-contract`): decidir **no endpoint** se ele fala base_units ou
reais, e não deixar a decisão para a tela. Conferir os outros painéis da mesma tela
(Histórico do saldo, Consolidado diário) — o consolidado soma `amount` cru em SQL e pode ter
o mesmo erro.

### P2 — Identificador do BACEN por trilho no painel (PIX = E2E, TED = NUOp, boleto = futuro)

Hoje a coluna se chama **EndToEndId** e o TED aparece com `–`, porque `end_to_end_id` é
campo de PIX. O identificador do TED no BACEN existe e **já está no Core**, dentro do
`metadata` da transação:

```json
{"source":"spb_inbound_credit","message_type":"STR0008",
 "num_ctrl_str":"STR20260714033393350", "payer_name":"MUNICIPIO DE RIO DE JANEIRO", ...}
```

O `transactions` tem `end_to_end_id`, `transaction_id`, `metadata` (jsonb) — **não tem**
coluna de NUOp. Os tipos existentes hoje: `deposit, fee, pix, ted, withdrawal`.

Desenho proposto (validar com o time antes de implementar):

1. Um par de campos **agnóstico de trilho** na transação do Core, materializado na escrita:
   `bacen_reference` + `bacen_reference_type`.
   - `pix` → `end_to_end_id` (E2E)
   - `ted` → **NUOp** registrado no BACEN (`bacen_messages.nuop` na cabine SPB) e/ou
     `NumCtrlSTR` (`metadata.num_ctrl_str`, que já chega hoje) — **decidir qual é o
     canônico**; o dono chamou de "uma espécie de E2E do PIX", e o NUOp é o número da
     operação registrado no BACEN.
   - `boleto` (futuro) → deixar o campo pronto para receber a variável correta
     (nosso número / linha digitável / id do título).
2. API: expor `bacenId` + `bacenIdType` no `serialize_transaction_basic/1`.
3. Tela: uma coluna única **"Identificador BACEN"**, com o tipo como chip, **filtrável e
   pesquisável** (o dono pediu explicitamente "bem filtrado").

Ganho: o time identifica a operação no BACEN direto do painel, seja PIX, TED ou boleto.

### P3 — 170 builders ainda emitem XML fora do XSD oficial (dívida MEDIDA)

Lista em `spb/services/bacen_gateway/test/support/xsd_reprovados_baseline.txt`. O teste
exige que ela **não cresça**. Duas famílias:

- ~110 **variantes `E`**: são stubs. O XSD exige que o `SISMSG` carregue a mensagem
  **original** (ex.: o do `STR0013E` exige `SISMSG > STR0013`), e o builder emite
  `<STR0013E/>` vazio. Precisam ser implementadas de verdade.
- ~60 **divergências de ordem de elemento** em CAM/CIR/TES.

Nenhuma delas está entre os 17 tipos que a produção envia. Dizer "100% verde" seria mentira;
o número está exposto e só pode cair.

### P4 — 94 órfãs históricas no banco de produção (decisão tomada: deixar)

São as linhas duplicadas de R1 gravadas entre 08/07 e 14/07. **Não foram apagadas** (regra:
não apagar nada em produção). Já não aparecem na lista, porque a dobra esconde por tipo.
Decisão do dono: deixar como estão.

### P5 — Outros follow-ups abertos

- **Comportamento do sidecar MQ (Java, fora do repo) diante de um 500** do webhook: nunca foi
  provado empiricamente.
- **Atribuição do pagador** no PIX: preferir o `AppHdr Fr` ao `DbtrAgt` do corpo — investigar
  com XML real.
- **Marcadores de fraude** não são simulados.
- **CI não existe.** Fazer no split do monorepo.
- `ResponseCorrelation.run/0` (backfill) segue disponível só como tarefa de release; agora
  que o `link_one/1` roda no fluxo, avaliar se o backfill ainda é necessário.

---

## Gotchas de operação (custaram tempo nesta sessão)

- O `rpc` do ECS **corta a saída** de comandos longos: rode o trabalho pesado dentro de
  `spawn(fn -> ... end)` e leia o resultado pelo banco.
- `zsh` **não faz word-split**: um `for f in $(grep -rl ...)` passa a lista inteira como um
  nome só de arquivo. Use `while IFS= read -r f`.
- Deploy do `spb-api` é **manual** (`deploy_hml_arm64.sh` não suporta): `docker buildx
  --platform linux/arm64` → push ECR → `register-task-definition` **preservando o container
  `spb-mq-sidecar`** e os 60 envs + 7 secrets → `update-service`.
- Produção usa o **mesmo digest** provado em homologação (retag no ECR, sem rebuild).
- `grep` tropeça nos XSDs do BACEN (ISO-8859-1 com CRLF): use Python com `encoding="latin-1"`.
