# Gap analysis: nossa implementacao DICT contra a API oficial do BACEN

> **Escopo desta versao, para nao repetir overclaim.** A primeira versao deste
> documento cobria SO as operacoes de vinculo e parte das claims, mas se
> chamava "gap analysis" como se fosse completa. Nao era. O inventario real e
> **42 operacoes no cliente e 22 builders de requisicao**. A secao 7 traz a
> medicao de cobertura e o que AINDA falta.

Data: 2026-07-26. Fonte de verdade:
https://www.bcb.gov.br/content/estabilidadefinanceira/pix/API-DICT.html
Fixtures oficiais v2.10 em `apps/shared/test/fixtures/dict/v2.10/`.
Fluxo de negocio de referencia: `LegadoPIX/`.

Motivo desta auditoria: em 26/07 tentei corrigir 3 chaves com `UpdateEntry` e o
BACEN recusou as tres com 400. Eu havia afirmado antes que o contrato estava
certo, sem ter conferido a spec campo a campo. Este documento existe para que
nenhuma operacao do DICT volte a ser enviada sem confronto com o contrato.

---

## 1. O defeito que originou a auditoria

**Achado, verbatim na spec:**

> "Chaves `EVP` (aleatorias) - E permitida a alteracao com os motivos:
> `BRANCH_TRANSFER`, `RECONCILIATION` e `RFB_VALIDATION`."

As 3 chaves sao EVP e enviamos `USER_REQUESTED`. **Nao havia validacao nenhuma
de `Reason` no codigo**: qualquer string era assinada no HSM e mandada ao BACEN.

Nada foi escrito. Confirmado relendo as 3 no BACEN via `GetEntryByCid`, antes e
depois de cada tentativa.

---

## 2. Matriz de `Reason`, oficial x implementado

| operacao | spec do BACEN | antes | agora |
|---|---|---|---|
| CreateEntry | `USER_REQUESTED`, `RECONCILIATION` | sem validacao | gate |
| UpdateEntry | `USER_REQUESTED`, `BRANCH_TRANSFER`, `RECONCILIATION`, `RFB_VALIDATION` | sem validacao | gate |
| **UpdateEntry em EVP** | **so** `BRANCH_TRANSFER`, `RECONCILIATION`, `RFB_VALIDATION` | **sem validacao (o 400)** | gate |
| DeleteEntry | `USER_REQUESTED`, `ACCOUNT_CLOSURE`, `RECONCILIATION`, `FRAUD`, `RFB_VALIDATION` | sem validacao | gate |
| ConfirmClaim | `USER_REQUESTED`, `ACCOUNT_CLOSURE`, `DEFAULT_OPERATION` | sem validacao | gate |
| CancelClaim | `USER_REQUESTED`, `ACCOUNT_CLOSURE`, `FRAUD`, `DEFAULT_OPERATION`, `RECONCILIATION`, `RFB_VALIDATION` | sem validacao | gate |

`PARTICIPANT_EXCLUSION` e de uso interno do BACEN: participante nao emite, e o
gate recusa.

Fonte unica: `Shared.Bacen.Dict.Reason`. Fail-CLOSED, com a mensagem dizendo o
que o BACEN aceita. Motivo invalido morre ANTES de assinar: assinar para o BACEN
recusar gasta HSM e devolve erro mudo (mesma licao do gate de `Purp/Cd` da
pacs.008).

Default de `UpdateEntry` passa a ser `RECONCILIATION`: correcao de cadastro
nossa nao foi pedida pelo cliente, e e o unico motivo valido para TODO tipo de
chave.

---

## 3. Conferido contra a spec, SEM defeito

| item | spec | nosso codigo |
|---|---|---|
| DeleteEntry: metodo e caminho | `POST /entries/{Key}/delete` (nao `DELETE`) | correto |
| DeleteEntry: `<Participant>` | obrigatorio | presente |
| DeleteEntry: `RequestId` | NAO leva | ausente, correto |
| CreateEntry: `RequestId` | obrigatorio, "UUID versao 4" | presente |
| CreateEntry: ordem dos elementos | `Signature`,`Entry`,`Reason`,`RequestId` | bate com a fixture |
| UpdateEntry: estrutura | `Signature`,`Key`,`Account`,`Owner`,`Reason` | bate byte a byte com a fixture |
| UpdateEntry: `RequestId` | NAO leva | ausente, correto |
| CompleteClaim: `RequestId` | obrigatorio | presente |
| Claims: metodos e caminhos | `POST /claims/{id}/{acao}` | correto |
| KeyType | CPF, CNPJ, PHONE, EMAIL, EVP | bate |
| Person Type | NATURAL_PERSON, LEGAL_PERSON | bate, com normalizacao F/J |
| Assinatura | XMLDSig RSA-SHA256, exc-c14n | bate |

---

## 4. Defeitos de contrato corrigidos hoje, fora do `Reason`

| defeito | onde | prova |
|---|---|---|
| conta do titular = id interno da tabela | `CreateEntry` | `<AccountNumber>3236</AccountNumber>` no `CreateEntryRequest` real; BACEN confirmou em `GetEntryByCidResponse` |
| agencia cravada em `"0001"` | `CreateEntry` | mesma requisicao |
| `AccountType` = `CACC` | `CreateEntry` | SCD emite conta de PAGAMENTO = `TRAN` |
| razao social da PJ = nome do usuario logado | `CreateEntry` | `owner_name` vinha de `current_user.name` |
| separador ia ao BACEN | `RequestBuilder.render_account/2` | `100001-2` com hifen; agora limpo na fronteira |

---

## 5. Achados ABERTOS

### 5.1 O corpo do erro do BACEN se perde (P1)

Quando o DICT responde 4xx numa escrita, o cliente devolve
`%Finch.TransportError{reason: :closed}` e **o corpo do problem RFC7807 e
descartado**. O 400 chegou (o log do sidecar registra), mas a explicacao nunca
chega ao operador.

Some-se a isso que o sidecar trunca o corpo em ~512 bytes no log, e como a
assinatura vem primeiro, o `<title>`/`<detail>` nunca aparece.

Consequencia: **hoje qualquer escrita no DICT que falhe e um erro mudo.** Foi
por isso que este 400 custou horas em vez de minutos.

Correcao proposta: propagar `{:error, %{status:, problem:}}` nesse caminho e
subir o limite de log do sidecar. Nao entrou neste pacote.

### 5.2 As 306 chaves no BACEN

306 de 306 chaves ativas estao com `AccountType = CACC`. Por decisao do dono,
ficam como estao. As 3 que nos criamos com o id interno serao corrigidas com
`RECONCILIATION` (motivo valido para EVP).

### 5.3 Baldes de fichas (limites do DICT)

A spec define, entre outros:

| politica | recarga | balde |
|---|---|---|
| `ENTRIES_WRITE` | 1200/min | 36000 |
| `ENTRIES_UPDATE` | 600/min | 600 |
| `CLAIMS_WRITE` | 1200/min | 36000 |
| `CIDS_FILES_WRITE` | 40/dia | 200 |
| `getEntryByCid` | 1200/min | |

E o anti-scan de leitura de vinculo: **"status 200: subtrai 1; status 404:
subtrai 20"** (por usuario) e **"status 404: subtrai 3"** (por participante).
Isso confirma tecnicamente a regra do dono de NUNCA usar `GetEntry` para
investigacao: um 404 custa 20 fichas.

`Shared.Bacen.DictBudget` existe e ja debita, em modo advisory. Auditar os
valores contra esta tabela e frente propria.

---

## 6. Guarda para nao repetir

`Shared.Bacen.Dict.Reason` tem teste por operacao e por tipo de chave, com a
citacao da spec no moduledoc. Qualquer motivo novo exige mexer na matriz, e a
matriz tem a fonte ao lado.

O que faltou antes nao foi conhecimento, foi **confronto**: a regra do EVP esta
escrita na pagina do BACEN e nunca tinha sido lida linha a linha.


---

## 7. Cobertura medida (o que estava protegido e o que nao estava)

Medicao mecanica: para cada builder, existe teste que compara a saida com a
fixture OFICIAL do BACEN?

**Antes desta sessao: 13 de 22 builders protegidos. 9 sem nenhum confronto.**

E os tres sem cobertura mais criticos eram `create_entry`, `update_entry` e
`delete_entry` — exatamente onde TODOS os defeitos de 26/07 moraram:

  * conta = chave primaria da tabela em vez do numero da conta
  * agencia cravada em "0001"
  * `AccountType` CACC numa SCD (conta de pagamento = TRAN)
  * razao social da PJ trocada pelo nome do usuario logado
  * separador (`-`) indo ao BACEN
  * `Reason` proibido para EVP, que gerou o 400

Nao foi falta de conhecimento da spec: foi falta de **confronto mecanico**. O
que nao tinha teste contra a fixture foi onde tudo quebrou.

**Depois: `request_builder_contract_test.exs`** compara o esqueleto de elementos
(caminho de cada tag, na ordem, sem assinatura) contra a fixture oficial, para
os 8 builders descobertos que tem fixture:

| builder | fixture |
|---|---|
| create_entry | `entries/CreateEntryRequest.xml` |
| update_entry | `entries/UpdateEntryRequest.xml` |
| delete_entry | `entries/DeleteEntryRequest.xml` |
| check_keys | `keys/CheckKeysRequest.xml` |
| create_cid_set_file | `cids/CreateCidSetFileRequest.xml` |
| create_fraud_marker | `fraud-markers/CreateFraudMarkerRequest.xml` |
| cancel_fraud_marker | `fraud-markers/CancelFraudMarkerRequest.xml` |
| refund_funds_recovery | `funds-recoveries/RefundFundsRecoveryRequest.xml` |

Os oito passam. O de marcador de fraude exercita o mapeamento REAL do cliente
(`fraud_marker_attrs_from_params`, onde `"key"` vira `:key_value`), porque um
teste que montasse os attrs a mao nao pegaria quebra nesse mapeamento.

### 7.1 O que AINDA falta, sem eufemismo

1. **`cancel_funds_recovery`**: nao ha fixture oficial no repo. Sem fixture, sem
   confronto. Precisa ser obtida antes de afirmar qualquer coisa.
2. **Respostas**: este teste cobre REQUISICOES. O `ResponseParser` de cada
   operacao nao foi confrontado com as fixtures de resposta (temos 30+ delas).
3. **Regras de negocio do LegadoPIX**: `LegadoPIX/Pix/DICT` (com DATABASE e
   simulador) NAO foi aberto nesta sessao. O confronto de regra de negocio,
   diferente do confronto de contrato XML, esta por fazer.
4. **Baldes de fichas**: os valores de `Shared.Bacen.DictBudget` nao foram
   conferidos contra a tabela oficial da secao 5.3.
5. **Prazos de claim** (`ResolutionPeriodEnd`, `CompletionPeriodEnd`): a spec
   remete ao Manual de Tempos do Pix, que nao foi lido.


---

## 8. Rodada 2: confronto com o LegadoPIX decompilado

Ferramenta: `ilspycmd` (dotnet 10) sobre `LegadoPIX/Pix/DICT/api/DICT.Core.{Domain,General,Application,Infrastructure}.dll`. O legado e deploy .NET compilado, sem fonte no repo.

### 8.1 Confirmacao independente do 400

O legado tinha, em producao, a validacao que faltava aqui:

    if (TpChave == EVP && TpMotivoOperacao != RFB_VALIDATION
        && != BRANCH_TRANSFER && != RECONCILIATION)
        AddErro("Chaves EVP so podem ser alteradas com os motivos
                 'RFB_VALIDATION', 'BRANCH_TRANSFER' ou 'RECONCILIATION'");

Duas fontes independentes, spec do BACEN e codigo do legado, dizem o mesmo.

### 8.2 De onde veio o CACC (evidencia, nao teoria)

O seed do banco do legado, `TB_TIPOCONTA`, tem SO tres tipos:

    1 CACC 'Conta Corrente' | 2 SLRY 'Conta Salario' | 3 SVGS 'Poupanca'

**`TRAN` nao existe no seed**, embora exista no enum .NET (`enumAccountType`:
CACC, SLRY, SVGS, **TRAN**, OTHR, CAHO). O legado foi implantado com dominio de
cooperativa. Isso sustenta, com evidencia, a hipotese do dono sobre a origem do
CACC no restore.

Nosso `@account_types` ja inclui `TRAN`.

Nota: os seeds do banco do legado sao MAIS ANTIGOS que o codigo do legado
(`TB_TIPOMOTIVOREIVINDICACAO` tem 4 valores, o enum .NET tem 6). Ordem de
autoridade: spec do BACEN > enums do legado > seeds do legado.

### 8.3 Regras de negocio do legado: o que faltava aqui

| regra do legado | tinhamos? | agora |
|---|---|---|
| formato da chave por tipo | **sim**, no changeset da `Entry` | mantido, sem duplicar |
| teto de 77 caracteres | **sim**, no mesmo changeset | mantido |
| agencia <= 4 digitos | sim (`validate_branch_code`) | mantido |
| limite de chaves (5 PF / 20 PJ) | sim (`check_key_limit`) | mantido |
| **"nao reivindicar portabilidade/posse para EVP"** | **NAO** | ligado no `create_claim/1` |
| **"nao reivindicar posse para CPF/CNPJ"** | **NAO** | ligado no `create_claim/1` |

Correcao de rota: a primeira versao desta auditoria listou "formato de chave" e
"teto de 77" como lacunas. Estava ERRADO: a validacao mora no changeset do
schema, nao no modulo de contexto, e eu so tinha grepado o contexto. Um segundo
validador de formato teria sido a mesma classe de defeito que este dia expos.

### 8.4 Parsers de resposta

Dos 40 parsers, 35 tinham algum teste e cinco nao tinham nenhum. O mais grave era
`parse_get_entry_response`, que le o vinculo ANTES DE CADA PAGAMENTO. Conferido
contra a fixture oficial: extrai a conta verbatim, sem reformatar. Sem defeito,
agora protegido. Cobertos tambem os dois de marcador de fraude.

Seguem SEM cobertura, por falta de fixture no repo:
`parse_get_fraud_marker_response` e `parse_cancel_funds_recovery_response`.
O primeiro foi resolvido na rodada 3 (secao 9.1); o segundo continua aberto.

### 8.5 Baldes de fichas: cobertura parcial

| politica oficial | modelada em `DictBudget`? |
|---|---|
| `ENTRIES_READ_PARTICIPANT_ANTISCAN` (200 -1, 404 -3) | sim, com tabela A-H |
| `CIDS_FILES_WRITE` (40/dia) | sim |
| `keys/check` (70/min) | sim |
| `ENTRIES_WRITE` | **nao** |
| `ENTRIES_UPDATE` | **nao** |
| `ENTRIES_READ_USER_ANTISCAN`, `CIDS_ENTRIES_READ`, `CLAIMS_WRITE` | **nao** |

As duas do meio afetam DIRETAMENTE o trabalho pendente das 306 chaves: o
confronto sao 306 leituras e a correcao sao N `UpdateEntry`. Resolvido em
parte na rodada 3 (secao 9.2).

**Retratacao.** A primeira versao desta tabela trazia numeros ao lado de cada
politica (`ENTRIES_UPDATE 600/min, balde 600`, `getEntryByCid 1200/min`,
`ENTRIES_READ_USER_ANTISCAN 404 -20`). Aqueles numeros nao vieram do BACEN:
batem exatamente com o `LoadListPolicies()` do **simulador** do legado
(`DICT.Web.Api.Simulador`), que e valor de teste. Foram removidos. O jeito
certo de saber a capacidade real esta na secao 9.2.

### 8.6 Prazos de reivindicacao: NAO VERIFICADO

Nosso codigo usa `@donor_deadline_days 14`, `@claimer_deadline_days 30`,
`@donor_notification_days 7`. A API-DICT **nao define** esses numeros: manda
consultar o *Manual de Tempos do Pix*, que nao esta no repo. **Nao posso
afirmar que 14/30/7 estao certos.** Fica registrado como nao verificado.

**Superado pela rodada 3, secao 9.3**: a pergunta estava errada. Nao importa
qual e o numero, porque o prazo nao e nosso para calcular.

## 9. Rodada 3: o que o LegadoPIX respondeu

Rodada motivada pela pergunta do dono: "por qual motivo voce nao confronta com
o LegadoPIX/*?". Os tres itens que a rodada 2 deixou como "nao verificado"
foram atacados decompilando o legado (`ilspycmd`).

### 9.1 Consultar marcador de fraude devolvia o ID ERRADO (corrigido)

Nao ha fixture `GetFraudMarkerResponse.xml` no repo. A forma veio do legado
decompilado (`DICT.Web.Api.Simulador`), que implementa o simulador do DICT:

```
class ExtendedFraudMarker { Id; Status; CreationTime; LastModified;
                            InfractionReport : FraudMarkerInfraction }
class FraudMarkerInfraction { Id; ReporterParticipant }
```

O tipo e **Extended** porque so a consulta traz `<InfractionReport>`, aninhado
dentro de `<FraudMarker>`. Nosso handler SAX guardava os campos do marcador com
`"FraudMarker" in stack`, condicao que continua verdadeira DENTRO do bloco
aninhado: o `<Id>` da infracao **sobrescrevia** o Id do marcador, e a consulta
devolvia o id errado. O `<ReporterParticipant>`, que diz quem reportou a fraude,
era descartado. Corrigido com escopo por bloco; a infracao agora sai em
`infraction_report`. Commit `bec43c7c`.

### 9.2 Baldes de escrita: o BACEN publica a capacidade, e nunca perguntamos

O legado tem o enum completo das **25 politicas** de limitacao e consome dois
endpoints que nos nunca chamamos para orcamento:
`ReceiveServicePolicies.ReceiveListPolicies` (`GET /policies/`) e `GetPolicy`
(`GET /policies/{Policy}`), com header `PI-RequestingParticipant`. As fixtures
oficiais que ja estavam no repo confirmam a forma:
`AvailableTokens / Capacity / RefillTokens / RefillPeriodSec / Name`.

Ou seja: **nao e preciso chutar capacidade nenhuma — da para perguntar.**

Corrigido: `POST /entries/` e `PUT /entries/{Key}` passam a ter balde
(`:entries_write` / `:entries_update`), e existe `calibrate_all_from_bacen/1`,
que calibra todos os baldes conhecidos com UMA chamada a `GET /policies/`.
Os baldes de escrita nascem **sem capacidade** de proposito: enquanto nao
calibrados, `check/3` passa e emite telemetry `:uncalibrated`. Nao inventamos
numero para parecer orcado. `DeleteEntry` segue sem balde: nada que temos diz
qual balde ele consome. Commit `19b65b71`.

### 9.3 O prazo de reivindicacao nunca foi nosso para calcular (corrigido)

O legado **nao calcula prazo**. `MontaReivindicacaoRetorno`
(`DICT.Core.Application`) faz `dhFimPeriodoResolucao =
retBacen.Claim.ResolutionPeriodEnd` e `dhFimPeriodoConclusao =
retBacen.Claim.CompletionPeriodEnd`: le da resposta, sempre. O parametro local
`DictPeriodoResolucao` nao gera prazo — alimenta o "dias em aberto"
(`TratarDiasAberto`), que e visao de tela.

Nos calculavamos 14/30 dias na criacao e **bloqueavamos operacao** por essa
data (`check_claimer_deadline_not_passed/1`), enquanto o BACEN mandava os dois
prazos na resposta e `parse_create_claim_response/1` ja os extraia. Ninguem
usava. Na fixture oficial os dois prazos sao D+7 da criacao: nem 14, nem 30 —
o numero inventado nao batia nem com o exemplo do proprio BACEN.

O mesmo descarte acontecia nas quatro transicoes do ciclo de vida
(Acknowledge/Confirm/Cancel/Complete), cujas respostas tambem trazem os prazos
justamente porque o BACEN pode **move-los**. Corrigido nos cinco pontos; o
default local so sobrevive como ultimo recurso, para a reivindicacao nao nascer
sem prazo. Commits `5e2bdd6f` e `4be6ef84`.

### 9.4 Continua ABERTO depois da rodada 3

1. **`parse_cancel_funds_recovery_response` sem prova.** Nao ha fixture no repo
   e o legado **nao implementa** funds recovery (a MED 2.0 e posterior a ele:
   o legado so carrega `FundsRecoveryId` como campo de ligacao em infracao e
   devolucao). Nao ha, hoje, fonte para conferir esse parser.
2. **Modelo de `Refund` mais novo que o nosso.** O legado tem, na entidade de
   devolucao, `EffectiveRefundedAmount`, `FundsRecoveryId`, `MonitorAccount`
   ("indica se a conta deve ser monitorada para bloqueios") e um
   `RefundAccount` completo. Nossas fixtures sao v2.10 e nao tem nada disso;
   nosso `build_create_refund_xml/2` tambem nao. **Isso e evidencia de versao,
   nao prova de defeito nosso** — e inventar tag em XML assinado que vai ao
   BACEN seria repetir o erro do dia. Precisa do contrato 2.12.1 de devolucao
   para fechar.
3. **O corpo do erro do BACEN continua se perdendo** (secao 5.1), que e o que
   torna todo diagnostico de 4xx mais caro do que precisava ser.
