# Deep audit DICT entries (CRUD) + rate limit + protecao antivarredura

Auditoria de paridade READ-ONLY. Confronto: LEGADO decompilado .NET (CRK/Corner) +
DB vivo SQL Server (`des_4dict`) + scripts SQL  x  NOSSO backend Elixir
(`pix/backend/apps/{dict_service,shared,spi_service,settlement_service}`). Regra:
so afirmo com prova arquivo:linha ou tabela.coluna; sem prova = INCONCLUSIVO.

Foco: ciclo de vida das chaves DICT (CRUD), rate-limit e o MOTOR DE ANTIVARREDURA
que correlaciona consulta DICT x pagamento efetivo.

---

## 0. Sumario executivo

| Area | Veredito | Prova-chave |
|------|----------|-------------|
| Antivarredura (consulta x pagamento) | **AUSENTE (lacuna de feature)** | legado `VerificaProtecao` Infra:9455 + `ValidaProtecaoUseCase.Processar` App:2327 + `TB_REGRAPROTECAO`; nosso grep `regra.?protec\|qtconsult\|consulta.?sem.?pagamento` = 0 resultados; `statistics.ex:26-28` admite "nenhum decisor automatico consome" |
| Rate-limit por cota (tier) | COBERTO (modelo diferente) | nosso `dict_rate_limit.ex:31-40` (tier A-H token bucket) + payer bucket `:99-125` + 404 penalty `:159-177`; legado usa AspNetCoreRateLimit IP-based `CRK40.Core.Middlewares:1721-1816` |
| Penalidade de 404 (anti-enumeracao) | COBERTO (nosso e mais forte aqui) | nosso `dict_rate_limit.ex:159-177` (+5 fichas por 404); legado so cacheia o 404 60s `App:4903-4906` |
| Limite de chaves PF=5 / PJ=20 | COBERTO | legado `ValidaLimiteContas` App:4795-4804 (`NR_QtLimiteContasPF/PJ`); nosso `keys.ex:31-35,1064-1081` |
| Validade da consulta (janela para pagar) | **DIVERGENCIA** | legado 60s (`NR_QtSegundosValidadeConsulta=60`, `des_4dict.TB_PARAMETRO`); nosso E2E TTL 1800s (`e2e_cache.ex:19`) |
| Uso unico da consulta por pagamento | COBERTO (parcial/analogo) | nosso `E2eBurn.burn` (`e2e_burn.ex:42-65`) + `E2eCache` SETEX; legado marca `DhFinalizacao` App:2357 |
| CRUD Consulta (BACEN + base local + por conta) | COBERTO | legado `entriesController` Web.Api:719/755/826; nosso `entry_controller.ex:82-132,264-288` |
| CRUD Inclusao / Exclusao / Bloqueio | COBERTO | legado Web.Api:855/928/902; nosso `entry_controller.ex:36,137,217` |
| Alteracao de vinculo conta/chave (AlteraChave) | **AUSENTE** | legado `AlterarChave` Web.Api:878 + UnitOfWorkAlteracaoChave Infra:3627; nosso: sem rota PUT `/entries/:key`, sem `Keys.update_entry`/alter (router.ex:183-193) |
| DerivaTpChave (endpoint) | PARCIAL | legado endpoint Web.Api:792; nosso so interno `keys.ex:175` (`infer_key_type`), sem rota |
| Consulta de vinculo (ConsultaVinculo) | INCONCLUSIVO/AUSENTE | legado `ValidaConsultaVinculo` App:559; nosso: sem rota equivalente encontrada |

---

## 1. LEGADO — CRUD de chaves (entriesController)

Classe `entriesController : ControllerBase` — `DICT.Web.Api.decompiled.cs:662`. Injeta
`IBuscaChaveUseCase, IExcluirChaveUseCase, IIncluiChaveDTOUseCase,
IAlterarChaveDTOUseCase, ISincronismoUseCase` (`:682`).

| Verbo/rota | Metodo | Linha | Observacao |
|---|---|---|---|
| GET `ConsultaChave` | `ConsultaChave(tpChave,txChave,nrSpbPagador,nrCpfCnpjPagador,idFimaAFim,estatisticas)` | 719 | consulta DICT no BACEN; envia PI-EndToEndId; 404 -> NotFound |
| GET `ConsultaChaveBaseLocal` | `ConsultaChaveBaseLocal(txChave,tpChave,nrSpbParticipante)` | 755 | so base local (`Contadict`), nao vai ao BACEN |
| GET `DerivaTpChave` | `DerivaTpChave(txChave)` | 792 | deriva tipo pelo formato |
| GET `ConsultaChavePorConta` | `ConsultaChavePorConta(nrSpbParticipante,tpConta,nrAgencia,nrConta)` | 826 | chaves por agencia/conta |
| POST `IncluiChave` | `IncluiChave(ChaveAPIDTO)` | 855 | cadastro |
| PUT `AlteraChave` | `AlteraChave(AlterarChaveAPIDTO)` | 878 | rebind conta/chave (ambas contas da propria IF) |
| PUT `AlteraBloqueioChave` | `AlteraBloqueioChave(BloqueioChaveDTO)` | 902 | bloqueio/desbloqueio; chave bloqueada nao entra no sincronismo |
| DELETE `ExcluiChave` | `ExcluiChave(txChave,tpChave,nrSpbParticipante,tpMotivoOperacao)` | 928 | exclusao com motivo |
| GET `ListaChaves` | `ListaChaves(...filtros..., nrSpbParticipante, ...)` | 962 | lista |
| POST `Sincronismo` | `Sincronismo(SincronismoAPIDTO)` | 992 | sincronismo comandado (5 tipos de chave) |
| POST `keys/VerificarExistenciaChaves` | (keysController) VerifyKeys | (mapa 08 §1) | verificacao em lote |

### 1.1 Validacoes da consulta (ValidaChaveUseCase)

`ConsultaChave` (regra de negocio) — `DICT.Core.Application.decompiled.cs:4806`. Ordem:
1. deriva tipo se ausente (`ValidaDerivaTipoChave`) — App:4812.
2. `ValidaConsultaChave` (tipo, formato, SPB, formato idFimAFim `E`+8 num+data+11 alfanum, <=32) — App:549-605.
3. `ValidarNrSPBParticipanteLogado` (SPB do request == participante do token) — App:4828.
4. **`ValidaProtecao(nrSpbPagador, nrCpfCnpjPagador)` = ANTIVARREDURA** — App:4836 (detalhe §3).
5. gera idFimAFim se vazio (`Validacoes.GerarIdFimAFim`) — App:4848.
6. cache local `ChaveLocal:{tp}_{chave}` (TTL `NR_QtSegundosValidadeConsulta`) — App:4850-4871.
7. base local (`_readRepo.List`) -> cache -> retorna (contabiliza `ConsultaInterna`) — App:4861-4874.
8. cache BACEN `ChaveBacen:...` -> retorna — App:4876-4888.
9. `_receiveService.Receive` (consulta real ao BACEN) -> cache — App:4890-4900.
10. erro: **cacheia tambem o 404** por `NR_QtSegundosValidadeConsulta` — App:4903-4906.

### 1.2 Inclusao + limite PF=5/PJ=20

`IncluiChaveDTOUseCase.IncluirChave` — App:5552. `ChaveAPIDTO` -> `OperacaoFullDTO`;
delega a `FactoryUnitOfWorkInclusao.IncluiChave` — Infra:3493 -> `UnitOfWorkInclusaoChave.IncluirChave`.
Limite aplicado em `ValidaLimiteContas`:

```
ValidaLimiteContas(...) — App:4795-4804
  if (list.Count() >= _parametroReadRepo.NR_QtLimiteContasPF  (PF))
  || (list.Count() >= _parametroReadRepo.NR_QtLimiteContasPJ  (PJ))
     -> LimiteChavesAssociadas "Conta ja ultrapassou o limite de chaves associadas"
```

Chamado tambem na alteracao (`UnitOfWorkAlteracaoChave.AlterarChave` — Infra:3648) e no sincronismo.

Valores (des_4dict.TB_PARAMETRO, prova viva):
```
NR_QtLimiteContasPF               5
NR_QtLimiteContasPJ               20
NR_QtSegundosValidadeConsulta     60
NR_QtLimitReivindicacoes          100
NR_QtSegundosIntervaloConsultaArquivo   6000  (ambiente DES)
NR_QtSegundosIntervaloWorkerProtecao    6000  (ambiente DES)
```

### 1.3 Alteracao de vinculo (AlteraChave) — regra RFB_VALIDATION

`UnitOfWorkAlteracaoChave.AlterarChave` — Infra:3627. Valida, valida SPB logado, valida
horario, valida limite de contas, e para motivo `RFB_VALIDATION` so permite alterar
Nome/Nome Fantasia (bloqueia mudar conta/agencia/tipo) — Infra:3659-3666. Assina XML e
envia `SendUpdate` ao BACEN — Infra:3668-3672.

---

## 2. LEGADO — rate-limit padrao (BACEN/IP)

Middleware AspNetCoreRateLimit — `CRK40.Core.Middlewares.decompiled.cs:19`
(`using AspNetCoreRateLimit`). Config `RateLimits.ConfigureCustomRateLimits` —
`:1721-1816`, gated por `crk.UseIpRateLimit` (`:1725`), le `IpRateLimiting`/
`IpRateLimitPolicies` (`:1805-1806`), store distribuido em cache (`:1809`). E rate-limit
por IP/politica, NAO por tier de participante A-H nomeado (esse ultimo e conceito do
Manual BACEN v8; o legado modela quota por IP + as RegraProtecao para antivarredura).

---

## 3. LEGADO — MOTOR DE ANTIVARREDURA (consulta x pagamento efetivo)

Este e o controle DISTINTIVO do legado. Duas metades:

### 3.1 Enforcement em tempo de consulta (bloqueia a consulta)

`ValidaChaveUseCase.ValidaProtecao(retorno, nrSpbParticipante, nrCpfCnpjPagador)` — App:648:
```
if (await _regraProtecaoReadRepository.VerificaProtecao(
        nrSpbParticipante, nrCpfCnpjPagador,
        (nrCpfCnpjPagador.Length <= 11) ? NATURAL_PERSON : LEGAL_PERSON))
    retorno.AddErro(ErroProtecaoQtdeRequisicaoSuperAoLimitePermitido,
                    "Quantidade de requisicao superior ao limite permitido.");
```
Chamado dentro de `ConsultaChave` — App:4836 (se falha -> BadRequest ao participante).

`VerificaProtecao` (query real) — `DICT.Core.Infrastructure.decompiled.cs:9455`:
```
from a in RegraProtecoes
join b in Operacoes            on a.SPBParticipante == b.SPBParticipanteRequisicao
join c in OperacaoConsultas    on b.IdOperacao      == c.IdOperacao
where a.SPBParticipante == nrSpbParticipante
  &&  a.IdTipoPessoa    == tipoPessoa
  &&  a.QtSegundosLimite >= DateDiffSecond(b.DataInclusao, now)   // dentro da janela
  &&  b.IdTipoOperacao   == 1                                     // 1 = Consulta
  &&  b.CPFCNPJPessoaRequisicao == nrCpfCnpjPagador               // por pagador
  &&  c.IcConsultaEfetuada                                        // consulta que ACHOU
  &&  c.DhFinalizacao == null                                     // que NAO virou pagamento
group by a.QtConsultasLimite
where grp.Count() >= QtConsultasLimite                            // excedeu o teto
-> Count() > 0  (true = BLOQUEIA)
```
Regra: dentro de `QtSegundosLimite` segundos, se um pagador fez `>= QtConsultasLimite`
consultas DICT que acharam a chave mas NAO viraram pagamento, a proxima consulta e
recusada. E a materializacao da regra BACEN "quem consulta muito sem pagar e limitado".

Suporte a JANELAS EM CAMADA (multi-tier): `VerificaProtecao` casa a regra com uma regra de
janela menor como piso; e `ValidaProtecaoUseCase.Processar` App:2337-2341 seleciona a regra
imediatamente menor (`o.QtSegundosLimite < regra.QtSegundosLimite`) por participante x
tipoPessoa — permite N regras (ex.: X consultas em 60s E Y em 600s).

Contagem auxiliar `GetQtdeConsultasEmAberto(qtSegundos, spb, cpfCnpj)` — Infra:8442:
`Operacoes.Where(qtSeg >= DateDiff && spb && cpfCnpj && OperacaoConsulta.IcConsultaEfetuada
&& OperacaoConsulta.DhFinalizacao == null).Count()` = consultas em aberto (sem pagamento).

### 3.2 Worker de correlacao (marca consulta como paga)

`WorkerProtecao : BackgroundService` — `DICT.Core.Worker.Protecao.decompiled.cs:90`; laco
`while` + `Task.Delay(NR_QtSegundosIntervaloWorkerProtecao * 1000)` (`:130`), chama
`ValidaProtecaoUseCase.Processar` (`:118`).

`ValidaProtecaoUseCase.Processar` — App:2327:
1. `ListByVerificacaoMulti()` = regras devidas (App:2330; `DhUltimaVerificacaoPagto` vencida).
2. Para cada regra: `OperacaoConsultaReadRepository.ListByVerificacaoMulti` colhe consultas na
   janela com `DhFinalizacao == null` (Infra:8435-8440); junta os `IdFimAFim` distintos (App:2342).
3. `ListarPagto` no MULTI/liquidacao (`_receiveService.ListarPagto`, HTTP `UrlApiMulti +
   api/consulta/ListarPagto`, Infra:1143-1158) devolve os pagamentos efetivos daqueles E2E.
4. Para cada pagamento retornado, marca `OperacaoConsulta.DhFinalizacao = now` (App:2357-2358)
   -> a consulta virou pagamento e SAI da contagem de varredura.
5. `regra.DhUltimaVerificacaoPagto = now` (App:2361).

A inclusao da chave por aquele idFimAFim tambem finaliza a consulta (`IcConsultaEfetuada=true`,
`DhFinalizacao=now`) — Infra:5575-5579.

### 3.3 Modelo de dados (des_4dict, prova viva)

`TB_REGRAPROTECAO` (colunas):
```
ID_REGRAPROTECAO smallint | NR_SPBPARTICIPANTE nvarchar | ID_TIPOPESSOA tinyint
QT_SEGUNDOSLIMITE int | QT_CONSULTASLIMITE int
QT_SEGUNDOSPERIODICIDADEVERIFPAGTO int | DH_ULTIMAVERIFICACAOPAGTO datetime
```
Linhas (2): `12345678 / PF / 600s / 9000 / 30`  e  `12345678 / PJ / 600s / 9000 / 30`
(valores de teste; o ponto e: regra por participante x tipoPessoa).

`TB_OPERACAOCONSULTA`: `ID_OPERACAO bigint | ID_FIMAFIM char | IC_CONSULTAEFETUADA bit |
DH_FINALIZACAO datetime`. `TB_OPERACAO` guarda `SPBParticipanteRequisicao,
CPFCNPJPessoaRequisicao, IdTipoOperacao (1=Consulta), DataInclusao, IdTipoPessoa`.

---

## 4. NOSSO — CRUD de chaves (EntryController + Keys)

`DictServiceWeb.EntryController` — `entry_controller.ex`. Rotas em `router.ex:183-202`
(pipeline `[:api,:authenticated,:participant,:dict_rated]`, `:117-141`).

| Verbo/rota | Metodo | Linha | Observacao |
|---|---|---|---|
| GET `/entries` | `index` | 14 | lista por participante |
| POST `/entries` | `create` | 36 | cadastro (validacoes §4.1) |
| GET `/entries/by-account` | `search_by_account` | 264 | filtra em memoria (nao query dedicada) |
| GET `/entries/:key` | `show` | 82 | base local primeiro; senao BACEN lookup-to-pay |
| DELETE `/entries/:key` | `delete` | 137 | exclusao; reason default USER_REQUESTED |
| DELETE `/entries/:key/:reason` | `delete_with_reason` | 196 | reason no path (whitelist :176) |
| POST `/entries/:key/block` | `block` | 217 | bloqueio |
| POST `/entries/:key/unblock` | `unblock` | 242 | desbloqueio |
| POST `/keys/check` | BatchKeyController.check | batch_key_controller.ex:40 | verificacao em lote |

`show` (`entry_controller.ex:82-132`): consulta a base LOCAL (`Keys.get_entry`); so se
`:not_found` vai ao BACEN (`show_via_bacen`), que gera+cacheia E2E
(`Shared.E2eCache.generate_and_cache`, `:99`) e chama `Keys.lookup_entry`. Ou seja, une
`ConsultaChaveBaseLocal` + `ConsultaChave` do legado num unico endpoint.

### 4.1 Validacoes da inclusao (Keys.create_entry) + limite

`keys.ex:69-96`. Pipeline `with`: `validate_key_type`, `validate_owner_type`,
`validate_account_type`, `validate_ispb`, `validate_branch_code`, `check_key_not_exists`,
**`check_key_limit`**, `check_no_pending_claim` (no-op `:1098`), `check_ownership_validated`
(OTP posse PHONE/EMAIL, flag `DICT_OWNERSHIP_ENFORCED` default off, `:1083-1091`).

Limite (`keys.ex:1064-1081`):
```
@key_limits %{"F" => 5, "J" => 20}      # keys.ex:31-35
count = keys ativos do (owner_cpf_cnpj, ispb)
if count >= limit -> {:error, :key_limit_exceeded}
```
Paridade EXATA com `NR_QtLimiteContasPF/PJ` do legado (por owner x participante).

### 4.2 Ausencias no CRUD

- **Alteracao de vinculo (AlteraChave)**: nao ha rota PUT `/entries/:key` nem
  `Keys.update_entry`/`alter`/`rebind` (grep = so `update_entry_status` interno `keys.ex:476`,
  usado por sync/CID). O legado tem `AlterarChave` (Web.Api:878, Infra:3627) com regra
  RFB_VALIDATION. **GAP funcional** para reassociar conta/chave da propria IF pela API.
- **DerivaTpChave endpoint**: existe so internamente (`infer_key_type` `keys.ex:175`), sem rota.
- **ConsultaVinculo**: sem rota equivalente encontrada.

---

## 5. NOSSO — rate-limit (DictRateLimit)

`DictServiceWeb.Plugs.DictRateLimit` — `dict_rate_limit.ex`, plug do pipeline `:dict_rated`
(`router.ex:38-39`). Redis token bucket:
- Tiers A-H (capacity + refill/min) — `:31-40`; janela 3600s (`:44`).
- Bucket por instituicao `rate_limit:dict:ispb:<ispb>` — `:80-96` (fail-OPEN se Redis fora `:92-95`).
- Bucket por pagador (PI-PayerId = 1/10 da cota, minimo 5) `rate_limit:dict:payer:<ispb>:<payer>`
  — `:99-125`.
- Categoria resolvida por `Shared.Auth.Institution.category` (ISPB) — `:127-143`; default "H".
- Penalidade de 404 `penalize_not_found/2` consome +5 fichas "to prevent key enumeration"
  — `:159-177`; chamada pelo controller apos not_found (`entry_controller.ex:111,148,226,248`).
- 429 com `retry-after: 60` e headers `x-ratelimit-*` — `:145-190`.

Diferenca de modelo vs legado: nosso rate-limit e por COTA BRUTA (token bucket) por
instituicao/pagador; conta TODA requisicao (achou ou nao) e pune 404 mais forte. NAO
correlaciona com pagamento.

---

## 6. NOSSO — analogo mais proximo da correlacao: E2E (uso unico)

Nao ha motor de antivarredura, mas ha um controle de USO UNICO da consulta:
- `Shared.E2eCache.generate_and_cache(debtor_ispb, pix_key)` gera E2E e grava
  `SETEX key 1800 e2e_id` (`e2e_cache.ex:19,25-31`) — TTL 1800s (30 min).
- O pagamento QUEIMA o E2E: `Shared.E2eBurn.burn/2` (`e2e_burn.ex:42-65`, one-shot
  idempotente; `burned?` `:72-83`) + reserva duravel `Shared.Dict.E2eReservation`.
- Efeito: uma consulta nao pode custear multiplos pagamentos. NAO conta nem limita
  quantas consultas SEM pagamento um pagador faz na janela.

Divergencia de janela: legado valida a consulta por 60s (`NR_QtSegundosValidadeConsulta`);
nosso E2E vive 1800s. Janela mais longa = mais folga para varrer/reusar.

---

## 7. Confirmacao da ausencia (prova negativa)

`grep -rniE "regra.?protec|protection.?rule|qtconsult|consultas.?limit|scan.?detect|
antivarredura|consulta.?sem.?pagamento|query.?without.?payment|verifica.?protec|
correlat.*payment"` em `apps/{dict_service,shared,spi_service,settlement_service}/lib`
= **0 resultados**.

`statistics.ex:26-28` (o proprio codigo admite):
> "Consumo antifraude: hoje nenhum decisor automatico consome estes numeros (o legado
> usava CheckFraud sobre os FraudMarkers do BACEN); o wiring da decisao antifraude fica
> registrado como follow-up do P9a."

Nao existe schema de regra de protecao, nao existe worker de protecao (workers do
dict_service = `dict_external_reconcile_worker`, `dict_inbound_poll_worker`), e o motor de
estatistica (`statistics.ex`) nao alimenta nenhum decisor.

Falso amigo de nome: `DictService.Ownership` cita "Paridade LegadoPIX
(ValidaProtecaoUseCase)" mas reusa o NOME para validacao de POSSE por OTP (PHONE/EMAIL),
que e outra coisa — nao e a antivarredura.

---

## 8. Impacto

Um participante indireto/parceiro pode varrer o DICT (enumerar donos/chaves VALIDAS)
dentro da cota de tier sem que a razao consulta-sem-pagamento seja detectada e
estrangulada. Nossa defesa hoje = cota bruta por tier + custo de 404 + uso unico do E2E.
O legado adicionalmente estrangula quem CONSULTA-ACHA-e-NAO-PAGA por
participante x tipoPessoa x pagador em janelas configuraveis (`TB_REGRAPROTECAO`). E
lacuna de feature regulatoria (o BACEN exige limitacao de consulta desproporcional a
pagamento), nao um bug de comportamento errado. Some-se a divergencia de janela de
validade (60s legado x 1800s nosso) e a ausencia da alteracao de vinculo pela API.
