Pular para conteúdo

0050 — Consulta ao Boa Vista (Cadastro Positivo) na análise de crédito, configurável por tenant

Status CONCLUÍDA
Branch spec/0050-analise-credito-boavista
Criada em 2026-09-03
Apps afetados api · web · e2e
Revisão do problema aprovada (rodada 1)
Revisão da experiência aprovada (rodada 4)
Revisão do detalhamento aprovada (rodada 3)
Revisão da implementação aprovada (rodada 4, branch)

Status: RASCUNHO → APROVADA → EM IMPLEMENTAÇÃO → CONCLUÍDA

A spec tem três partes, escritas em ordem, e quatro portões — cada um revisado por um subagente de contexto limpo, em até 3 rodadas. A máquina de revisão é do plugin superpowers; a régua é a rubrica correspondente em .claude/rubrics/. Achados vêm classificados: qualquer Critical reprova, Important em aberto reprova salvo decisão registrada do usuário, e Minor vira pendência aceita.

  1. A Parte I diz qual é o problema e como se convive com ele hoje. Revisada na seção 6 (necessidade × jornada de hoje). É o portão mais barato de todos, e o único que pergunta contra que problema isto está sendo construído.
  2. A Parte II diz como se usa o que vai existir. Revisada na seção 8 (jornada × viewports).
  3. A Parte III diz como se constrói. Revisada na seção 16 (segurança × clareza técnica) — autorização, isolamento, e se sobrou decisão para quem vai codar.
  4. A implementação é medida contra as três, na seção 17 (aderência × qualidade × testes) — uma revisão por fase, e uma da branch inteira antes de fechar.

Cada parte só se escreve depois que a anterior passou. Descrever tela sobre problema mal enquadrado, ou detalhar endpoint sobre experiência vaga, é o jeito mais caro de descobrir que era outra coisa.


Parte I — O problema

Aqui não entra solução: nem tela, nem endpoint, nem botão. Só o que dói, para quem, e o que se faz hoje. Se você já sabe qual é a tela, guarde para a Parte II.

1. Dor

Hoje a decisão do analista numa Análise de Crédito se apoia em dois insumos só: a recomendação automática (credit_recommendation, calculada por compute_credit_summary pela regra 3× o Pacote Locatício sobre a renda declarada) e o campo de texto livre opinion, onde o analista registra o parecer técnico. Não existe nenhum dado de bureau de crédito estruturado no sistema — consulta a bureau foi marcada explicitamente como fora de escopo em três specs anteriores (0003, 0015, 0025).

Na prática, os analistas já preenchem essa lacuna por conta própria: saem do Garantora, abrem o Boa Vista (ou outro bureau) em outro site, digitam de novo o CPF do inquilino — que o Garantora já tem cadastrado —, leem o relatório (score, pendências financeiras, protestos, cheques sem fundo, renda presumida) e voltam para colar um resumo no campo opinion. Isso acontece regularmente, não como exceção.

O custo de conviver com isso, hoje:

  • O dado vira texto solto. Um score numérico, o valor de uma pendência, a existência de um protesto — tudo isso é resumido de próprio punho e perde a estrutura. Não dá para comparar entre análises, nem para reler depois o número exato que foi visto.
  • Nada garante que a consulta aconteça. Depende de o analista lembrar de sair do sistema, redigitar um CPF que o sistema já tinha, e voltar. Não há como saber, olhando a análise, se aquele passo foi cumprido.
  • Não há trilha do que foi consultado. Se uma decisão for questionada depois — por que este inquilino foi aprovado, ou por que não — não existe registro de que a consulta ao bureau foi feita, quando, nem o que ela mostrava naquele momento.
  • O tempo desse vai-e-vem é pago manualmente, em toda análise, por sair do sistema, digitar o CPF de novo, ler o relatório em outro lugar e resumir.

2. Quem sente

  • ANALYST, em toda Análise de Crédito que decide. É quem hoje sai do sistema para consultar o bureau e quem escreve o resumo em opinion — é o caminho já praticado regularmente, não uma exceção pontual.
  • ADMIN_TENANT, indiretamente: é quem responde, perante a garantidora, pela consistência do critério de aprovação entre analistas diferentes — e hoje não tem como garantir que todos consultem o bureau da mesma forma, no mesmo momento, nem que o resultado fique registrado.

Não há operação hoje para medir volume — a prática de consultar o bureau é um passo manual fora do sistema, sem contagem alguma. O alcance é o que sustenta a dor: é caminho já percorrido em toda análise decidida, não um caso de borda.

3. A jornada de hoje

Passo a passo, sem a feature:

  1. Inquilino e demais participantes preenchem a Análise de Crédito (dados financeiros, renda declarada) e ela é submetida (DRAFT → SUBMITTED → IN_ANALYSIS).
  2. O sistema calcula automaticamente credit_recommendation, comparando a renda declarada com o Pacote Locatício (aluguel + condomínio + IPTU) pela regra 3×.
  3. A análise chega para o ANALYST decidir. Antes de decidir, ele sai do Garantora, abre o site do Boa Vista (ou outro bureau) por fora, digita à mão o CPF do inquilino — que já está cadastrado no Garantora — e lê o relatório: score, classificação de risco, pendências financeiras, protestos, cheques sem fundo, renda presumida.
  4. Ele volta ao Garantora e resume o que viu, escrevendo de próprio punho no campo opinion — texto livre, sem os valores exatos do relatório.
  5. A decisão (aprovar, pendenciar, rejeitar) é registrada com base na regra 3× mais esse resumo textual.

Onde quebra: nos passos 3 e 4 — a saída do sistema, a redigitação de um dado que o Garantora já tinha, e a perda de estrutura do dado ao virar resumo em texto livre. Nada no sistema hoje sinaliza se esse passo foi cumprido ou pulado numa análise específica.

O que já se tentou: a própria prática manual descrita acima já é o contorno em uso — não existe outro workaround (planilha paralela, campo reaproveitado) além de "o analista faz por fora e resume no parecer".

4. Resultado esperado

A consulta ao Boa Vista (produto Cadastro Positivo, via API Full) passa a acontecer dentro do Garantora, automaticamente, ao submeter a análise — nos tenants que tiverem essa configuração ativada. O analista deixa de sair do sistema e de redigitar o CPF.

O resultado volta estruturado (score, classificação, probabilidade de inadimplência, pendências financeiras, protestos, cheques sem fundo, renda presumida) e fica registrado junto à análise — visível para o analista no momento de decidir, e consultável depois, sem depender de o analista ter copiado algo para o parecer.

Como se sabe que resolveu: toda Análise de Crédito de um tenant que ativou a configuração passa a ter o resultado da consulta registrado automaticamente, sem exceção e sem depender de o analista lembrar — o passo que hoje é manual, opcional na prática e sem registro vira automático, uniforme e auditável.

5. Fora de escopo

  • Outros bureaus (Serasa, SPC, Bacen SCR) — a API Full oferece produtos para os três, mas esta spec cobre só o Boa Vista / Cadastro Positivo. Fica para spec futura.
  • Consulta de CNPJ / inquilino pessoa jurídica — só CPF de pessoa física entra agora.
  • O resultado não entra no cálculo automático de credit_recommendation. Continua sendo só a regra 3× o Pacote Locatício; o retorno do Boa Vista é informativo para o ANALYST decidir — não substitui, nem altera, a recomendação automática.
  • Nenhum fluxo de consentimento novo. A consulta é coberta pelo aceite geral que o inquilino já dá ao contratar a garantia; esta spec não cria termo específico.
  • Conta compartilhada da plataforma. A credencial (Bearer Token da API Full) é por tenant — cada garantidora traz a própria conta e o próprio saldo. Esta spec não cobre uma conta única da Garantora.
  • Nova tentativa automática (retry) além da consulta feita na submissão. Se o provedor falhar (indisponibilidade, timeout, retorno incompleto), a análise segue sem o dado, com aviso — não há reprocessamento automático fora de escopo desta spec.

6. Revisão do problema

Preenchido pelo laço do /spec new, rubrica .claude/rubrics/problema.md. Registrar todas as rodadas, inclusive as reprovadas.

Rodada Critical Important Minor Veredicto
1 0 0 0 Aprovada

Resultado: aprovada na rodada 1.

Pendências aceitas: nenhuma.


Parte II — A experiência

Não preencher antes da revisão da seção 6 estar aprovada.

7. Jornada pretendida

  • Ponto de entrada: não há um ponto de entrada separado. O gatilho é a própria submissão da Análise de Crédito, que já é o passo que o ANALYST/participantes fazem hoje — não existe tela, menu ou ação nova para "consultar o bureau".
  • Jornada, caso feliz:
  • Quem opera a análise (tipicamente OPERATOR ou o AGENCY_USER da imobiliária) preenche e submete a Análise de Crédito com os dados do inquilino, como já faz hoje — o inquilino não acessa o sistema nem submete nada.
  • No mesmo momento da submissão, se o tenant tiver a configuração ativada, o sistema consulta o Boa Vista (Cadastro Positivo) usando o CPF do inquilino principal, que já está no cadastro — sem pedir nada a ninguém.
  • Quando o ANALYST abre a análise para decidir, o resultado já está lá: um painel novo, ao lado do que já existe hoje (o que mostra a renda declarada e a recomendação automática), mostrando o que o Boa Vista retornou.
  • O ANALYST lê o painel novo junto com o que já lia antes, e registra o parecer da mesma forma que já faz — só que agora sem precisar sair do sistema para consultar nada por fora.
  • Custo de uso: nada digitado pelo usuário. O único dado que a consulta usa (o CPF do inquilino) já está cadastrado; ninguém volta a informá-lo.

7.1 Desktop

O painel novo entra na mesma coluna dos demais painéis de leitura da tela de Análise de Crédito, logo depois do painel que hoje mostra "o que o sistema sabe" (renda declarada e recomendação automática) e antes do painel de documentos pendentes — a mesma lógica de ordem que a tela já segue: primeiro o que embasa a decisão, depois o que falta. Segue o mesmo molde visual dos painéis existentes da tela: fundo do cabeçalho tingido pelo veredito, e um selo com ícone e texto no canto direito do cabeçalho (a mesma peça que hoje mostra "DENTRO"/"3 PENDÊNCIAS"/"COMPLETO" nos painéis existentes) — aqui, algo como "SEM RESTRIÇÃO" ou "N RESTRIÇÕES ENCONTRADAS". Sem ação nenhuma nele, é só leitura.

O corpo mostra, em linhas de rótulo/valor: score e classificação, probabilidade de inadimplência, pendências financeiras (quantidade e valor total), protestos (quantidade), cheques sem fundo (quantidade) e renda presumida (faixa).

A cor do cabeçalho reflete só as três restrições objetivas — pendência financeira, protesto e cheque sem fundo: vermelho se qualquer uma delas tiver ocorrência, verde se nenhuma tiver (nada encontrado é lido como veredito positivo, mesma leitura que o painel existente já dá para "dentro da política"). Score, classificação e renda presumida aparecem no corpo como dado, mas não entram na cor do cabeçalho — o Boa Vista já resume risco num score e numa probabilidade numérica, e não há, hoje, uma faixa de corte definida para tratar "score baixo sem nenhuma restrição objetiva" como alerta; decidir esse corte é análise de crédito, papel do ANALYST, não do painel.

7.2 Mobile (375px)

Mesmo padrão dos demais painéis desta tela: sempre aberto, sem colapsar. Nenhum painel de leitura da tela de Análise de Crédito hoje vira cartão colapsável — todos têm só o padding reduzido no mobile, mantendo o painel inteiro visível, empilhado na ordem vertical junto dos demais. O painel novo segue essa mesma regra, não a de outras telas do sistema que usam cartão colapsável para blocos de detalhe — aqui o padrão local é diferente, e é ele que vale.

No mobile, os mesmos campos aparecem, na mesma disposição rótulo-à-esquerda/valor-à- -direita de cada linha que os demais painéis já usam em qualquer largura — não há mudança de layout dentro da linha, só o padding do painel que diminui. Nenhum campo é escondido.

7.3 Estados

  • Tenant sem a configuração ativada: o painel não aparece — não é um estado vazio dentro do painel, é a ausência do painel inteiro, igual a qualquer outro bloco condicional da tela.
  • Análise ainda em DRAFT (antes da primeira submissão), mesmo com a configuração ativada: o painel também não aparece — a consulta só acontece na submissão (seção 4), então antes dela não há o que mostrar. É o mesmo comportamento do caso "tenant sem configuração": o painel só entra na tela depois que a análise é submetida pela primeira vez.
  • Carregando (PENDING, RN4b — correção pós-aprovação): a consulta roda em background, não mais dentro da submissão, então há uma janela real em que o painel existe mas não tem resultado ainda. Mostra um selo "consultando" (com indicador de progresso) e uma frase dizendo que o resultado aparece assim que terminar — sem exigir recarregar a página. apps/web faz polling da análise a cada 3s enquanto durar, até um teto de 60s. Quem abre a tela um pouco depois da submissão normalmente nem vê este estado — o worker costuma responder em segundos —, mas ele existe e é real.
  • Consulta falhou (Boa Vista fora do ar, timeout, ou retorno incompleto/inconclusivo): o painel mostra que a consulta não pôde ser concluída, com uma mensagem genérica, sem código HTTP nem nome de fornecedor ("não foi possível concluir a consulta automática de crédito no momento da submissão" — texto ajustado depois da aprovação: a primeira versão citava "o Boa Vista" e o motivo técnico direto na tela, achado do usuário testando ao vivo — ver seção 18). Não há ação de tentar de novo dentro do painel — está fora de escopo, junto com qualquer nova tentativa automática (seção 5). O ANALYST segue decidindo com o que tem disponível — a regra automática e os demais painéis —, exatamente como faz hoje, numa análise em que ainda não existisse este painel: a ausência do dado do Boa Vista não bloqueia a decisão.
  • Sem permissão: o painel não introduz uma permissão própria — aparece para quem já pode ver a Análise de Crédito hoje, e não aparece para quem não pode, seguindo a mesma regra de acesso que os demais dados da análise (renda declarada, documentos). Isso inclui o AGENCY_USER da imobiliária parceira, que hoje já lê a análise: decisão explícita desta spec é que o painel do Boa Vista não segue a mesma restrição do painel de parecer interno (escondido da imobiliária) — o resultado do bureau é tratado como dado da análise, não como deliberação interna da garantidora.

8. Revisão da experiência

Preenchido pelo laço do /spec new, rubrica .claude/rubrics/experiencia.md. Registrar todas as rodadas, inclusive as reprovadas.

Rodada Critical Important Minor Nota Veredicto
1 0 3 0 — Reprovada
2 0 3 0 — Reprovada
3 0 1 1 — Reprovada
4 0 0 1 9 Aprovada

Resultado: aprovada na rodada 4.

Pendências aceitas: nenhuma — o único achado Minor da rodada 4 (menção ao nome do componente DecisionPanel na seção 7.3) foi corrigido no texto antes de fechar o portão.


Parte III — Detalhamento

Não preencher antes das revisões das seções 6 e 8 estarem aprovadas.

9. Regras de negócio

  • RN1. TenantSettings ganha um campo credit_bureau_type — enum NONE | BOAVISTA_CADASTRO_POSITIVO, default NONE — que é o "tipo de análise de crédito" que o ADMIN_TENANT escolhe. Mesmo padrão de campo já usado em TenantSettings para InitialFeeType/InitialFeeBase (enum Python, native_enum=False, mapeado como String no banco).
  • RN2. Quando credit_bureau_type é BOAVISTA_CADASTRO_POSITIVO, TenantSettings precisa também de um Bearer Token da API Full (credit_bureau_token). Salvar credit_bureau_type != NONE sem um token configurado (nem no payload da requisição, nem já salvo antes) é erro de validação — a configuração não é aceita pela metade.
  • RN3. O token é gravado criptografado em repouso com Fernet (cryptography.fernet), chaveado por TENANT_SECRETS_KEY — uma variável de ambiente nova, no formato que Fernet.generate_key() produz (32 bytes url-safe base64; não um texto livre como o default "change-me" de APP_SECRET_KEY, que quebraria a inicialização do Fernet). Sem TENANT_SECRETS_KEY configurada, a aplicação falha ao subir — não há default inseguro para este campo. Rotação de chave está fora de escopo desta spec: trocar TENANT_SECRETS_KEY invalidaria todos os tokens já cifrados, e não há, aqui, um mecanismo de recifragem em lote. O token nunca é devolvido em texto claro por nenhum endpoint — a leitura de TenantSettings expõe só um booleano (credit_bureau_token_configured), nunca o valor. É write-only: o ADMIN_TENANT pode substituir o token, nunca reler o anterior.
  • RN4. A consulta ao Boa Vista (produto "Boa Vista Essencial Positivo", endpoint scpc-boavista da API Full — corrigido depois da aprovação: a primeira versão desta RN citava cp-cadastrais-score-dividas, endpoint que não existe para a conta usada; a requisição real salva na coleção da API Full aponta para scpc-boavista, confirmado contra uma chamada real) é agendada automaticamente na submissão da Análise de Crédito (transição DRAFT → SUBMITTED), depois que a transição de status já foi aplicada nesta mesma transação — usa o CPF do inquilino principal, já cadastrado. Como submit() é uma única transação de banco (o commit é do CommitRoute, ao final da requisição — RN7 detalha o que acontece se algo nesse bloco falhar), e como a transição DRAFT → SUBMITTED só acontece uma vez no ciclo de vida de uma análise (depois disso ela transita só entre IN_ANALYSIS/PENDING — nome atual do status, renomeado de PENDING_DOCS na spec 0037 —, nunca de volta para SUBMITTED), o agendamento acontece uma única vez por análise — não há reenvio nem nova tentativa em transições posteriores.
  • RN4b — a chamada de verdade roda em background, não dentro de submit(). Correção pós-aprovação (achado do usuário testando ao vivo: "a consulta demorou para responder, gastou o crédito e não recebi o retorno, e ainda travou a tela"). Até esta correção, RN4 fazia a chamada HTTP à API Full dentro da transação da submissão — a resposta de submit() só voltava depois da consulta terminar (até 10s de leitura), e uma conexão que caísse no meio deixava a chamada paga em curso no servidor sem que o resultado nunca chegasse a quem esperava.

O desenho novo: submit() grava um CreditBureauInquiry em status=PENDING (com requested_at, sem queried_at) e agenda um job numa fila (arq, Redis — infra nova, container worker em infra/*/docker-compose.yml, mesma imagem da api com o comando trocado). A resposta de submit() volta imediatamente, sem esperar a chamada HTTP. Um processo separado (app.worker, consumindo a fila) roda a chamada de verdade e, ao terminar, atualiza o registro para SUCCESS/FAILED (com queried_at) e grava a entrada de auditoria (RN7 mantém a garantia — ver abaixo). apps/web faz polling de GET /credit-analyses/{id} a cada 3s enquanto bureau_inquiry.status === "PENDING" (até um teto de 60s, depois do qual desiste silenciosamente — quem quiser o resultado atualiza a página), e o painel mostra um estado "consultando" nesse meio-tempo (seção 7.3).

Por que fila dedicada (Redis/arq), e não BackgroundTasks do FastAPI. A primeira opção cogitada foi BackgroundTasks — sem infra nova, roda no mesmo processo da API. Decisão do usuário foi por uma fila dedicada: sobrevive a um restart/deploy da API no meio de uma consulta em curso, e não compete por CPU/memória com quem serve requisição. Dentro da família "fila dedicada", arq (não Celery/RQ) por ser nativamente assíncrono — o resto do backend já é asyncpg/SQLAlchemy async/httpx async, e Celery/RQ exigiriam rodar código async dentro de worker síncrono.

Sem retry automático (decisão do usuário): uma falha na Boa Vista fecha FAILED de uma vez — repetir automaticamente dobraria o crédito gasto numa consulta que falhou por motivo real (CPF inválido, por exemplo), não só por instabilidade transitória. - RN5. A consulta só roda para inquilino pessoa física (CPF). Análise cujo inquilino principal é pessoa jurídica (CNPJ) não dispara consulta nem gera o painel — fora de escopo (seção 5). - RN6. O resultado é gravado numa entidade nova, CreditBureauInquiry, 1:1 com CreditAnalysis (mesmo padrão de Decision: FK única, back_populates nos dois lados). Guarda: status da consulta (PENDING/SUCCESS/FAILED — RN4b), os campos estruturados usados no painel (score, classificação, probabilidade de inadimplência, pendências financeiras — quantidade e valor —, protestos — quantidade —, cheques sem fundo — quantidade —, renda presumida — faixa), o payload bruto da resposta (JSON, para auditoria), o motivo quando FAILED, o instante em que a submissão agendou o job (requested_at, sempre presente) e o instante em que o worker terminou a chamada (queried_at, null enquanto PENDING). - RN7. Nenhuma falha deste bloco pode reverter a transição de status já aplicada na mesma transação, nem deixar um registro PENDING para sempre — o segundo virou explícito com RN4b: um "consultando" que nunca termina é o próprio sintoma que motivou mover a consulta para background. Isso cobre quatro problemas distintos: - Falha ao agendar o job (Redis fora do ar): _schedule_credit_bureau_inquiry fecha o registro como FAILED na hora ("Não foi possível agendar a consulta automática"), em vez de deixá-lo PENDING esperando um job que não existe. - Falha ao gravar o registro PENDING (erro de integridade, indisponibilidade do banco no momento do flush, dentro de submit()): a gravação acontece dentro de um session.begin_nested() (SAVEPOINT) — mesmo padrão já usado no repositório para isolar um flush que pode falhar sem envenenar a transação inteira (user_version_read_repo.py, numbering_sequence_repo.py). Se o INSERT falhar, só o SAVEPOINT é desfeito; a sessão continua utilizável, o commit() do CommitRoute grava a transição de status normalmente, e — novo em RN4b — o job não é agendado (não há registro para o worker atualizar). - Falha na consulta em si (timeout, indisponibilidade, HTTP diferente de 200, token inválido, resposta sem os campos esperados), dentro do worker: o client captura qualquer erro e devolve um resultado FAILED (motivo incluído) — nunca deixa a exceção subir. É o primeiro client HTTP externo do repositório com essa regra; o único precedente hoje (cep_service.py) sempre propaga a falha como exceção, e não serve de molde aqui. - Falha inesperada dentro do worker (bug, elegibilidade mudando entre a submissão e o job rodar — consulta desativada, inquilino trocado, análise removida): a parte de risco roda dentro de outro SAVEPOINT (mesmo padrão); qualquer exceção fecha o registro como FAILED com um motivo (específico quando é elegibilidade mudando, genérico quando é inesperado de verdade) — nunca deixa o registro preso em PENDING. - RN8. O resultado é só informativo. Não participa do cálculo de credit_recommendation (compute_credit_summary, regra 3× o Pacote Locatício) — essa regra automática não muda. - RN9 — o resultado vive no locatário, não na análise; e há uma trava contra consultar de novo. Segunda correção pós-aprovação (achado do usuário: "quero que todas essas informações estejam associadas ao inquilino [...] Coloque também uma trava que somente chamar a api para verificar o crédito se não tiver feito antes").

Renter ganha credit_bureau_status (RN4b), credit_bureau_data (JSON — pedido do usuário dizia "jsonb"; implementado como JSON genérico do SQLAlchemy, não o tipo JSONB do Postgres, para rodar igual em SQLite e Postgres sem exigir Docker no pytest — mesma convenção já documentada no CLAUDE.md da API), credit_bureau_ requested_at e credit_bureau_queried_at. A tabela credit_bureau_inquiries (RN6, 1:1 com CreditAnalysis) deixa de existir — não há base instalada a preservar (CLAUDE.md, "Estágio do produto"), e manter as duas fontes vivas duplicaria o dado. CreditAnalysisResponse.bureau_inquiry passa a ser derivado: lido do locatário principal (LESSEE, is_primary=true) da análise, na hora de montar a resposta — nenhuma FK própria.

A trava: a submissão só agenda uma nova consulta se o locatário nunca teve um resultado SUCCESS. Um FAILED anterior não trava — uma falha transitória não deveria deixar o inquilino sem dado até alguém lembrar de forçar manualmente — nem um PENDING em andamento é duplicado (evita dois jobs concorrentes gastando duas consultas pelo mesmo locatário). O mesmo inquilino pode aparecer em várias análises ao longo do tempo (mais de uma tentativa de locação, por exemplo); antes desta correção, cada nova análise dispararia uma nova consulta paga — a trava fecha exatamente esse gasto duplicado.

"Reanalisar" — só para a garantidora. Pedido do usuário: "um botão de reanalisar mas somente para garantidora". Endpoint novo, POST /renters/{id}/reanalyze-credit- bureau, ignora a trava de propósito (é a única forma de conseguir uma nova consulta depois de um SUCCESS), exige a permissão nova renters:reanalyze_credit_bureau — AGENCY_USER não a tem (seção 10.1). Recusa com 422 CREDIT_BUREAU_NOT_ACTIVE (tenant sem a consulta ativada) ou 422 CREDIT_BUREAU_RENTER_NOT_ELIGIBLE (locatário PJ ou sem CPF) — os mesmos dois motivos que já impediriam o agendamento automático, mas ditos em voz alta em vez de um 200 silencioso que não mudaria nada. - RN9b — modo sandbox, para testar sem gastar crédito real. Correção pós-aprovação, pedido do usuário: "quero ter uma opção de ativar no dev-remote esse fluxo para fechar a funcionalidade da interface, então deixar uma configuração no env" — depois de eu ter tentado uma verificação manual pontual em dev-remote (login com credencial real via curl para criar dado de teste) e o classificador de modo automático do Claude Code bloquear a ação por ser sensível demais para automatizar; em vez de insistir por outro caminho, o pedido virou este item permanente da spec.

CREDIT_BUREAU_SANDBOX_MODE (booleano, app/core/config.py, default False) — ligado, query_boavista_cadastro_positivo não chama a API Full: lê app/services/credit_bureau_sandbox/boavista_cadastro_positivo_sucesso.json (o mesmo retorno completo e real do Apêndice A, fictício e confirmado pelo usuário) e processa pelo mesmo _extrair() que a chamada de verdade usa — a única coisa pulada é o httpx.AsyncClient.post. Toda consulta, com qualquer token e qualquer CPF, devolve o mesmo resultado fixo (SUCCESS, score 148, classe E).

Trava de segurança: create_app() (app/main.py) recusa subir se CREDIT_BUREAU_SANDBOX_MODE estiver True junto com APP_ENV=production — mesmo espírito de TENANT_SECRETS_KEY sem default inseguro (RN3). Um resultado fictício, indistinguível de um real, vazando para produção enganaria quem decide uma análise de crédito de verdade — por isso a checagem falha o boot, não só loga um aviso. A auditoria (AuditLog, action="BUREAU_INQUIRY") marca o resultado com o prefixo [SANDBOX — resultado fictício, sem consulta real] na descrição e sandbox: true no payload, para nunca ser lida como uma consulta de verdade mais tarde.

Ligado só no .env do dev-remote (infra/dev-remote/.env) — nunca no .env.example de produção, que documenta a variável com o aviso e o default false. - RN9c — a tela de Parâmetros mostra o modo sandbox, e dispensa o token enquanto ele estiver ligado. Achado do usuário testando ao vivo, depois do RN9b: "quando estou entrando na tela de parâmetros se está com sandbox ativo não tem opção de colocar o token e informa que está ativo o sandbox com evidência." Sem isto, PATCH /settings continuava recusando credit_bureau_type != NONE sem token (RN2) mesmo num ambiente onde o token nunca é usado de verdade — a garantidora seria obrigada a colar um valor qualquer só para satisfazer uma validação sem propósito ali.

TenantSettingsResponse ganha credit_bureau_sandbox_active: bool — não é dado do tenant, vem de app_settings.CREDIT_BUREAU_SANDBOX_MODE (mesmo padrão computado de credit_bureau_token_configured, sempre sobrescrito pelo service antes de a resposta sair). RN2 passa a ter uma terceira condição: o token só é exigido se o modo sandbox não estiver ligado — fora do dev-remote (ou com a variável desligada), a validação original continua intacta.

apps/web: com credit_bureau_sandbox_active === true, a tela de Parâmetros não renderiza o campo de Bearer Token — no lugar dele, um Alert explícito diz que o modo sandbox está ativo, que toda consulta devolve um resultado fictício sem custo, e que o modo nunca fica ativo em produção. É a "evidência" pedida: sempre visível enquanto o tipo de análise estiver diferente de NONE, não escondida atrás de tooltip ou aba.

10. Autorização e isolamento

10.1 Matriz de papéis

Papel Pode Não pode
ADMIN_GLOBAL O mesmo acesso que já tem hoje à Análise de Crédito (herda todas as permissões) Esta spec não muda o acesso do ADMIN_GLOBAL a dado de negócio do cliente — nenhuma tela nova em /app/admin/*
ADMIN_TENANT Configurar credit_bureau_type/token em Configurações (já é require_system_role("ADMIN_TENANT")); ler o painel do bureau em qualquer análise ou locatário do próprio tenant; forçar reanálise (renters:reanalyze_credit_bureau) Reler o token já salvo em texto claro
ANALYST Ler o painel do bureau (identidade — seção 10.4); forçar reanálise Configurar credit_bureau_type/token (não tem require_system_role("ADMIN_TENANT"))
OPERATOR Ler o painel; submeter a análise (credit_analyses:submit), o que agenda a consulta; forçar reanálise Configurar credit_bureau_type/token
VIEWER Ler o painel (credit_analyses:read/renters:read) Submeter análise, configurar tenant settings, forçar reanálise (renters:reanalyze_credit_bureau — papel só-leitura, mesmo padrão de nunca ganhar ação de escrita em nenhum outro recurso)
AGENCY_USER — Ler o painel do bureau, em nenhuma das duas telas (correção pós-aprovação — reversão explícita da decisão original desta seção, que dava à imobiliária dona da análise o mesmo acesso da garantidora — CA8 antiga); forçar reanálise; ler/configurar o token; ler o painel de análise ou locatário de outra imobiliária (404, filtro já existente)

O negativo mais relevante desta spec: nenhum papel — nem o ADMIN_TENANT — consegue ler o token depois de salvo. É write-only para todo mundo, sem exceção de papel.

10.2 Permissões

Uma permissão resource:action nova: renters:reanalyze_credit_bureau (RN9) — gate do endpoint POST /renters/{id}/reanalyze-credit-bureau, concedida a ADMIN_TENANT, ANALYST e OPERATOR; não a VIEWER (papel só-leitura) nem a AGENCY_USER (correção pós-aprovação: "somente a garantidora").

Ver o resultado do bureau não ganhou permissão própria — decisão deliberada, não esquecimento. É identidade de quem pergunta (partner_agency_id), o mesmo padrão já usado no isolamento por imobiliária (within_agency/agency_scope, seção 10.4) para todo o resto do cadastro do locatário — uma permissão RBAC exigiria o meta-teste test_toda_permissao_definida_e_exigida achar onde ela é exigida por um if descoberto pela varredura, e redação condicional de campo (não um bloqueio de requisição) não se encaixa no padrão que esse teste reconhece (require_permission no router, ou exigir_permissao no serviço) sem inventar um terceiro padrão só para isto.

As duas garantias que já existiam continuam:

  • Leitura do painel (o resto): embutida na resposta de GET /credit-analyses/{id} e GET /renters/{id}, atrás de credit_analyses:read/renters:read — sem gate adicional além da checagem de identidade acima.
  • Escrita de credit_bureau_type/token: dentro do PATCH de TenantSettings, atrás do mesmo require_system_role("ADMIN_TENANT") que já protege todo o resto das configurações do tenant hoje — não há permissão granular tenant_settings:write no RBAC (a escrita é 100% gate por papel de sistema), e esta spec não introduz uma.

10.3 Isolamento por garantidora (tenant)

  • Renter.credit_bureau_* (RN9) é sempre lido através de Renter, que já é filtrado por tenant_id no BaseRepository — nenhuma query nova que contorne esse filtro. O mesmo vale lendo pela análise: CreditAnalysisResponse.bureau_inquiry deriva do locatário principal, já carregado no escopo da análise (que também é filtrada por tenant).
  • A consulta ao Boa Vista usa o token do tenant do locatário (renter.tenant_id) — no agendamento automático, resolvido a partir de credit_analysis.tenant_id (RN4); no reanalyze_credit_bureau, direto de renter.tenant_id, depois de _get_authorized confirmar que o locatário pertence ao tenant/imobiliária de quem chama — nunca aceito de parâmetro, header ou corpo da requisição.
  • Análise ou locatário de outro tenant → 404, nos dois endpoints que expõem o resultado (comportamento já existente para credit_analyses/renters, herdado; esta spec não muda o código de erro) — inclusive no reanalyze-credit-bureau, que passa pelo mesmo _get_authorized do resto de RenterService.

10.4 Isolamento por imobiliária

AGENCY_USER não lê o resultado do bureau, em nenhuma das duas telas (RN9, correção pós-aprovação — reversão de CA8: a decisão original desta seção dava à imobiliária dona da análise o mesmo acesso da garantidora; o achado do usuário foi "somente a garantidora tem acesso a informação a imobiliária não tem"). A checagem é current_user. partner_agency_id is not None → None, no ponto único que monta cada resposta (_bureau_inquiry_for em CreditAnalysisService, _with_bureau em RenterService) — mesma família de checagem que within_agency/agency_scope já usam para o resto do isolamento por imobiliária, não uma permissão nova (seção 10.2 explica por quê).

O restante do isolamento por imobiliária no cadastro do locatário não muda: AGENCY_USER segue vendo (e só vendo) os locatários do próprio partner_agency_id — o filtro em GET /renters e GET /renters/{id} é o que já existia, e o campo credit_bureau some por cima dele, não no lugar dele. POST /renters/{id}/reanalyze-credit-bureau recusa com 403 para ela antes mesmo de chegar em _get_authorized — falta a permissão renters:reanalyze_credit_bureau, que o seed de RBAC nunca concede a esse papel.

10.5 Plataforma × cliente (SaaS admin)

Sem impacto. Esta spec não adiciona tela em /app/admin/*, nem endpoint cross-tenant. A configuração do tipo de análise e do token é feita dentro do próprio tenant (tela de Configurações), não pelo SaaS admin — e o ADMIN_GLOBAL não ganha nenhuma visão agregada de token ou resultado de bureau entre tenants.

11. Impacto por app

apps/api

  • Repositories (padrão reaproveitado, nenhum arquivo alterado): app/repositories/user_version_read_repo.py e app/repositories/numbering_sequence_repo.py — não são tocados por esta spec, mas são o precedente do session.begin_nested() que RN7 exige replicar na persistência do CreditBureauInquiry.
  • Modelos:
  • TenantSettings (app/models/tenant_settings.py) ganha os campos credit_bureau_type (Enum(CreditBureauType, name="credit_bureau_type", create_constraint=False, native_enum=False), default NONE) e credit_bureau_token (String, guarda o texto cifrado, nullable=True). Novo enum CreditBureauType(str, enum.Enum) no mesmo arquivo, mesmo padrão de InitialFeeType.
  • CreditBureauInquiry (novo model, app/models/credit_bureau_inquiry.py) — 1:1 com CreditAnalysis (FK credit_analysis_id, índice único, back_populates nos dois lados, lazy="noload", mesmo padrão de Decision). Novo enum CreditBureauInquiryStatus (SUCCESS/FAILED). Campos conforme RN6.
  • CreditAnalysis (app/models/credit_analysis.py) — relação inversa bureau_inquiry: Mapped["CreditBureauInquiry | None"].
  • Migrations Alembic (duas):
  • Adiciona credit_bureau_type (server_default='NONE', evita backfill) e credit_bureau_token (nullable=True) a tenant_settings. downgrade remove as duas colunas.
  • Cria a tabela credit_bureau_inquiries, com FOREIGN KEY credit_analysis_id REFERENCES credit_analyses(id) ON DELETE RESTRICT e índice único em credit_analysis_id — mesmo ondelete que Decision já usa para a FK de credit_analysis_id (decision.py:134), e mesmo padrão de índice único separado (não unique=True na coluna) usado na criação original de analyses/decisions. downgrade derruba a tabela.
  • Criptografia (novo, sem precedente no repositório): app/core/crypto.py com encrypt_secret/decrypt_secret usando cryptography.fernet.Fernet, chaveado por uma variável de ambiente nova — TENANT_SECRETS_KEY — em app/core/config.py e .env.example (não reaproveita APP_SECRET_KEY, que hoje tem default inseguro "change-me" e serve a outro propósito). cryptography entra como dependência nova em pyproject.toml.
  • Service novo: app/services/credit_bureau_service.py — client HTTP para POST https://api.apifull.com.br/api/scpc-boavista, Authorization: Bearer <token decifrado>, corpo {"document": "<cpf>", "link": "scpc-boavista"} — o campo link é obrigatório e, apesar do nome, não é webhook nem URL: é um identificador fixo do produto, igual ao segmento final da própria URL (confirmado contra a requisição real salva na coleção Postman da API Full e uma chamada real). Timeouts explícitos (mesmo estilo de cep_service.py). Nunca lança — sempre devolve um resultado SUCCESS/FAILED (RN7).
  • tenant_settings_service.py: validação nova em update() — RN2 (tipo ≠ NONE exige token configurado, no payload ou já salvo).
  • credit_analysis_service.py: submit() — depois de _transition(...) confirmar SUBMITTED, se tenant_settings.credit_bureau_type != NONE e o inquilino principal é pessoa física, _schedule_credit_bureau_inquiry grava um CreditBureauInquiry em PENDING e agenda o job — sem chamar credit_bureau_service diretamente (RN4b).
  • RN4b — arquivos novos, correção pós-aprovação:
  • app/core/queue.py — pool arq/Redis (lazy, get_pool()) e enqueue_credit_bureau_inquiry().
  • app/worker.py — WorkerSettings do arq e o job run_credit_bureau_inquiry_job, que abre sessão própria (AsyncSessionLocal) e delega para credit_bureau_worker_service.complete_credit_bureau_inquiry.
  • app/services/credit_bureau_worker_service.py (novo) — a metade da chamada de verdade: acha o registro PENDING pela análise, resolve settings/renter frescos, chama credit_bureau_service, fecha o registro (SUCCESS/FAILED) e grava a auditoria — tudo dentro de um SAVEPOINT, com except Exception externo que garante FAILED mesmo num caminho totalmente inesperado (RN7).
  • app/core/config.py — REDIS_URL (com default, não é segredo — mesmo padrão de DATABASE_URL).
  • pyproject.toml/uv.lock — dependência nova arq (traz redis/hiredis junto).
  • alembic/versions/..._credit_bureau_inquiry_background.py — PENDING no enum, coluna requested_at (not null), queried_at vira nullable.
  • infra/{local,dev-remote,prd}/docker-compose.yml — serviço redis (imagem oficial, sem ports fora do local) e serviço worker (mesma imagem da api, command trocado para arq app.worker.WorkerSettings, sem rodar migration/seed — só a api faz isso). apps/api/Makefile/Makefile da raiz — alvo dev-worker/worker-dev para rodar o worker localmente com hot reload (arq --watch).
  • Schemas:
  • TenantSettingsCreate/Update: credit_bureau_type: CreditBureauType, credit_bureau_token: str | None (só entrada, write-only).
  • TenantSettingsResponse: credit_bureau_type: CreditBureauType, credit_bureau_token_configured: bool — nunca o campo credit_bureau_token.
  • CreditAnalysisResponse: campo novo bureau_inquiry: CreditBureauInquiryResponse | None.
  • Docstrings + Swagger: summary/description/response_description no campo novo do PATCH /tenant-settings e na resposta de GET /credit-analyses/{id}; atualizar TAGS_METADATA em app/main.py se a descrição do grupo mudar; docstrings Google-style nos models/services novos (consumidas pelo wiki/ via Sphinx AutoAPI).
  • Testes:
  • crypto.py: round-trip cifra/decifra.
  • credit_bureau_service.py: sucesso (200 com payload válido), timeout, erro HTTP, payload incompleto — todos devolvem resultado FAILED sem lançar (é o comportamento que RN7 exige, e o que não tem precedente a copiar).
  • credit_analysis_service.submit(): cria CreditBureauInquiry SUCCESS quando o tenant tem o tipo ativado e a consulta funciona; cria FAILED quando a consulta falha, sem impedir a transição para SUBMITTED; não cria nada quando o tipo é NONE ou o inquilino é PJ; e — o teste que sustenta RN7 — quando a gravação do CreditBureauInquiry falha (ex.: flush forçado a lançar IntegrityError), a análise ainda assim fica SUBMITTED ao final da requisição, provando que o begin_nested() isolou a falha sem envenenar a transação inteira.
  • tenant_settings_service.update(): rejeita tipo ≠ NONE sem token (RN2).
  • GET /tenant-settings: nunca devolve credit_bureau_token em texto claro, só credit_bureau_token_configured (RN3) — teste que falharia se alguém adicionasse o campo bruto ao schema de resposta por engano.
  • Negativos de autorização: ANALYST/OPERATOR/VIEWER/AGENCY_USER recebem 403 ao tentar PATCH /tenant-settings com credit_bureau_type (mesma checagem de papel já testada hoje para os demais campos, estendida); AGENCY_USER de outra imobiliária recebe 404 ao tentar GET /credit-analyses/{id} de análise alheia (comportamento já existente, cobrindo que o campo novo não abriu brecha).
  • RN9 — arquivos, correção pós-aprovação (a consulta virou característica do locatário, não da análise):
  • app/models/renter.py — ganha CreditBureauInquiryStatus (movido de credit_bureau_inquiry.py, que deixa de existir) e as quatro colunas credit_bureau_status/credit_bureau_data/credit_bureau_requested_at/ credit_bureau_queried_at.
  • app/services/credit_bureau_schedule.py (novo) — schedule_bureau_inquiry() e ScheduleOutcome, a decisão compartilhada de "agenda ou não" (a trava) usada tanto por CreditAnalysisService._schedule_credit_bureau_inquiry (force=False) quanto por RenterService.reanalyze_credit_bureau (force=True).
  • app/services/credit_bureau_worker_service.py — complete_credit_bureau_inquiry passa a receber renter_id, não mais credit_analysis_id; a auditoria vira entity_type="renter".
  • app/services/credit_bureau_service.py — dois novos: result_to_stored_dict() (serializa CreditBureauResult para o formato de credit_bureau_data, convertendo Decimal para str) e renter_bureau_response() (o inverso, monta o schema de resposta a partir do Renter).
  • app/services/renter_service.py — reanalyze_credit_bureau() (novo) e _with_bureau() (preenche RenterResponse.credit_bureau, exceto para AGENCY_USER — seção 10.4).
  • app/services/credit_analysis_service.py — _bureau_inquiry_for() (novo, módulo), lê o locatário principal já carregado por get_bulk_by_credit_analyses, sem consulta extra; _build_response/list() passam current_user adiante (precisam dele para a checagem de imobiliária).
  • app/repositories/renter_repo.py — save_isolated() (novo, mesmo padrão SAVEPOINT que existia em credit_bureau_inquiry_repo.py, que deixa de existir).
  • app/repositories/credit_analysis_repo.py — get_bureau_inquiries_bulk() removido (a tabela que ele lia não existe mais).
  • app/schemas/credit_bureau.py (novo) — CreditBureauInquiryResponse sai de schemas/credit_analysis.py para um módulo próprio, importado tanto por schemas/renter.py (RenterResponse.credit_bureau) quanto por schemas/credit_analysis.py (CreditAnalysisResponse.bureau_inquiry) — evita import circular entre os dois.
  • app/db/rbac_seed.py — permissão nova renters:reanalyze_credit_bureau.
  • app/api/v1/routers/renters.py — rota nova, POST /{renter_id}/reanalyze-credit- bureau.
  • Migration nova — PENDING/SUCCESS/FAILED reaproveitando o enum Postgres já criado por n6p8r0t2v4x5 (create_type=False, sem recriar o tipo); adiciona as quatro colunas em renters; derruba credit_bureau_inquiries.
  • Testes novos: tests/test_renter_credit_bureau.py (trava, worker no locatário, reanalyze_credit_bureau, visibilidade por partner_agency_id) — tests/test_credit_analysis_bureau_inquiry.py foi reescrito para cobrir só o que é específico da análise (agenda no locatário certo, segunda análise do mesmo inquilino não reconsulta, ADMIN_GLOBAL, AGENCY_USER nunca vê).
  • RN9b — arquivos, correção pós-aprovação (modo sandbox):
  • app/core/config.py — CREDIT_BUREAU_SANDBOX_MODE: bool = False.
  • app/main.py — em create_app(), antes de montar FastAPI(...): recusa subir (RuntimeError) se o modo sandbox estiver ligado junto com APP_ENV=production.
  • app/services/credit_bureau_service.py — no topo de query_boavista_cadastro_positivo, antes da decifragem do token: se o modo está ligado, lê o JSON fixo do disco, processa por _extrair() e devolve — sem instanciar httpx.AsyncClient.
  • app/services/credit_bureau_worker_service.py — a auditoria grava sandbox: app_settings.CREDIT_BUREAU_SANDBOX_MODE no payload e prefixa a descrição (o import usa o alias app_settings — ver o bug de colisão de nome no Registro, RN9b).
  • app/services/credit_bureau_sandbox/ (novo diretório) — boavista_cadastro_positivo_sucesso.json (movido de tests/fixtures/credit_bureau/, mesmo arquivo do Apêndice A) e README.md (documenta os dois usos: modo sandbox em runtime e fixture do test_sucesso_com_o_payload_real_da_api_full).
  • apps/api/.env.example, infra/dev-remote/.env.example, infra/prd/.env.example — documentam a variável, com o aviso de nunca ligar em produção. infra/dev-remote/.env (não versionado) ganhou CREDIT_BUREAU_SANDBOX_MODE=true.
  • Testes novos em tests/test_credit_bureau_service.py: test_modo_sandbox_nunca_chama_a_api_full (o _FakeAsyncClient nem é armado — se a chamada real acontecesse, o teste estouraria por falta de resposta configurada) e test_modo_sandbox_nao_sobe_em_producao (create_app() levanta RuntimeError com APP_ENV=production e o modo ligado).
  • RN9c — arquivos, correção pós-aprovação (a tela de Parâmetros mostra o sandbox):
  • app/schemas/tenant_settings.py — TenantSettingsResponse.credit_bureau_sandbox_ active: bool (computado, mesmo padrão de credit_bureau_token_configured).
  • app/services/tenant_settings_service.py — importa app.core.config.settings com o alias app_settings (mesma disciplina do RN9b, depois do bug de colisão de nome); update() pula a exigência de token da RN2 quando o modo sandbox está ligado; _to_response() preenche o campo novo.
  • tests/test_tenant_settings_credit_bureau.py — casos novos test_sandbox_inativo_por_padrao, test_sandbox_ativo_aparece_no_settings e test_sandbox_ativo_dispensa_o_token.
  • tests/conftest.py — a suíte inteira passa a fixar settings.CREDIT_BUREAU_SANDBOX_MODE = False no import (mesmo padrão de settings.BCRYPT_ROUNDS = 4, já existente) — sem isto, todo teste rodado dentro do container do dev-remote herdaria o true real do .env daquele ambiente (RN9b) e testaria um comportamento diferente do que descreve. Foi como o bug apareceu: ver Registro.

apps/web

  • Componente movido para components/shared/ (RN9, correção pós-aprovação): apps/web/src/components/shared/BureauInquiryPanel.tsx — antes vivia só em pages/credit-analyses/components/, e passou a ser usado em duas telas (achado do usuário: "na análise de crédito e no inquilino adicionar um painel"). Mesmo molde visual (Paper, cabeçalho tingido + selo, corpo com linhas rótulo/valor). Não aparece quando bureauInquiry é null/undefined (tenant sem o tipo ativado, locatário nunca consultado, ou quem vê é AGENCY_USER — o backend nunca envia o campo para ela). Estado PENDING (RN4b): selo "CONSULTANDO" com CircularProgress, corpo com uma frase, sem nenhuma linha de dado.
  • testIdPrefix, não data-testid fixo: o componente recebe um prefixo (prop testIdPrefix) — credit-analysis-bureau na Análise de Crédito (mantém os nomes de antes da mudança para componente compartilhado, sem quebrar o e2e existente) e renter-bureau no Inquilino. O container vira ${prefix}-panel, o selo ${prefix}-badge, e as linhas de dado ${prefix}-score/-pendencias/-protestos/ -cheques/-renda-presumida.
  • Botão "Reanalisar" (RN9, novo): prop onReanalyze — undefined some o botão inteiramente (não aparece desabilitado); cada página só passa a função quando current_user.permissions inclui renters:reanalyze_credit_bureau. data-testid ${prefix}-reanalyze. Desabilitado enquanto status === "PENDING" ou enquanto a própria chamada de reanálise está em voo (prop reanalyzing).
  • Polling (RN4b): cada página que usa o painel tem seu próprio useEffect que busca o próprio recurso (GET /credit-analyses/{id} ou GET /renters/{id}) a cada 3s enquanto o status for PENDING, até um teto de 60s — busca só o recurso em si, não histórico/imobiliária/decisão/documentos, para não repintar a tela inteira a cada tentativa. O polling não é responsabilidade do componente do painel.
  • pages/credit-analyses/show.tsx: painel entre o cabeçalho de decisão e PolicyPanel (pedido do usuário, ainda de pé desde a correção anterior); lê o bureau_inquiry da análise (que o backend deriva do locatário principal) e passa o id desse locatário (primary.renter.id) para o onReanalyze.
  • pages/renters/show.tsx: painel novo entre os dados cadastrais e o DocumentsPanel; lê person.credit_bureau direto (é o próprio locatário da tela).
  • Tela de Configurações (TenantSettings, onboarding/settings): campo Select novo ("Tipo de análise de crédito": Nenhuma / Boa Vista — Cadastro Positivo) e um campo de token que só aparece quando o tipo não é "Nenhuma" — write-only: mostra "configurado"/"não configurado" quando já existe, nunca o valor salvo, com data-testid seguindo a convenção já em uso na própria tela (settings-<campo>-input, ex.: settings-guarantee-rate-input): settings-credit-bureau-type-input, settings-credit-bureau-token-input, settings-credit-bureau-token-status.
  • RN9c — o mesmo bloco, com credit_bureau_sandbox_active === true: o campo de token some por completo (unmountOnExit do Collapse que já existia); no lugar, um Alert severity="info" (data-testid="settings-credit-bureau-sandbox-banner") diz que o modo sandbox está ativo, sem custo e sem chamada real, e que nunca fica ativo em produção. pages/settings/index.tsx — TenantSettings/FormState ganham o campo, settingsToForm() o lê do GET. Testes novos em pages/settings/index.test.tsx: campo ausente e banner presente com o modo ativo, campo presente e banner ausente sem ele, e salvar sem token funciona com o modo ativo.
  • Sider/menu: sem impacto — não é módulo novo, é campo/painel dentro de telas já existentes.
  • Responsividade & PWA: painel segue exatamente o padrão mobile descrito na seção 7.2 (sem colapsar, mesma disposição rótulo/valor, só padding menor), nas duas telas. Sem impacto de PWA — não é asset novo nem rota offline.

apps/e2e

  • Specs: um novo (analise-credito-boavista.spec.ts) cobrindo: com o tipo desativado, o painel não aparece (CA1); com token inválido de propósito, a submissão falha na consulta sem bloquear, o painel troca sozinho para "não concluída", AGENCY_USER vê o painel mas não o parecer interno (CA4/CA8/CA9).
  • RN4b (correção pós-aprovação): o spec espera o worker de verdade. Roda contra o container worker do dev-remote, que consome a mesma fila Redis da API — não há mock nem simulação da fila neste nível. A prova de que o submit() não bloqueia mais é cronometrada (Date.now() antes/depois do POST /submit, < 5s), não a observação de um estado PENDING transitório — um worker rápido o bastante pode fechar o registro antes da tela terminar de carregar, e isso é RN4b funcionando, não uma falha do teste.
  • apps/e2e/fixtures/limpeza.ts: credit_bureau_inquiries entra na lista de limpeza antes de credit_analyses (FK ON DELETE RESTRICT) — sem isso a faxina inteira quebra na primeira execução do spec novo.

12. Contrato de API

Um endpoint novo (RN9, correção pós-aprovação) — os demais contratos ganham campos.

PATCH /api/v1/tenant-settings
Authorization: Bearer <token>

{
  "credit_bureau_type": "BOAVISTA_CADASTRO_POSITIVO",
  "credit_bureau_token": "<bearer token da API Full, texto claro na entrada>"
}
{
  "credit_bureau_type": "BOAVISTA_CADASTRO_POSITIVO",
  "credit_bureau_token_configured": true
}

credit_bureau_token nunca aparece no corpo de resposta — nem aqui, nem em GET /api/v1/tenant-settings.

Erro de negócio novo: CREDIT_BUREAU_TOKEN_REQUIRED (422) — credit_bureau_type diferente de NONE sem token no payload nem já salvo (RN2). Permissão exigida: mesma de hoje, require_system_role("ADMIN_TENANT") (403 para os demais papéis, sem mudança).

GET /api/v1/credit-analyses/{credit_analysis_id}
Authorization: Bearer <token>
{
  "id": "uuid",
  "...": "campos já existentes, sem mudança",
  "bureau_inquiry": {
    "status": "SUCCESS",
    "score": 148,
    "classification": "E",
    "default_probability_percent": "98.80",
    "financial_pendencies_count": 0,
    "financial_pendencies_total": "0.00",
    "protests_count": 0,
    "bounced_checks_count": 0,
    "presumed_income_range": "DE R$ 3.001 ATE R$ 4.000",
    "requested_at": "2026-09-03T14:02:00Z",
    "queried_at": "2026-09-03T14:02:03Z"
  }
}

bureau_inquiry é null quando o tenant não tem o tipo ativado, o locatário principal nunca foi consultado, o inquilino principal é pessoa jurídica (RN5), ou quem pergunta é AGENCY_USER (RN9, correção pós-aprovação — reversão de CA8: antes ela recebia o campo, agora nunca). status pode ser "PENDING" (RN4b) — a submissão agendou a consulta mas o worker em background ainda não respondeu; requested_at vem preenchido, queried_at vem null, e nenhum campo de dado vem preenchido. Quando a consulta termina e falha, status é "FAILED" e um campo failure_reason (texto) substitui os campos de dado — o valor continua vindo no contrato (é usado na auditoria), mas apps/web não o exibe no painel (nome de fornecedor e motivo técnico ficam só em AuditLog, ação BUREAU_INQUIRY). Permissão exigida é a mesma já existente, credit_analyses:read, sem mudança de código de erro (404 de tenant/imobiliária alheios já existe hoje) — a diferença por papel está só em bureau_inquiry vir ou não preenchido, nunca no status da resposta.

GET /api/v1/renters/{renter_id}
Authorization: Bearer <token>
{
  "id": "uuid",
  "...": "campos já existentes, sem mudança",
  "credit_bureau": {
    "status": "SUCCESS",
    "score": 148,
    "...": "mesmo formato de bureau_inquiry acima"
  }
}

credit_bureau segue exatamente as mesmas regras de bureau_inquiry (null/PENDING/ FAILED/SUCCESS, AGENCY_USER nunca recebe preenchido) — é o mesmo dado, agora também exposto pelo locatário diretamente, não só através da análise.

POST /api/v1/renters/{renter_id}/reanalyze-credit-bureau
Authorization: Bearer <token>

Sem corpo. Resposta: o locatário (mesmo formato de GET /renters/{id}), com credit_bureau.status = "PENDING" — a chamada de verdade roda em background, como qualquer agendamento (RN4b); quem quiser o resultado faz polling do mesmo GET.

Erros de negócio novos: CREDIT_BUREAU_NOT_ACTIVE (422) — tenant sem a consulta automática ativada; CREDIT_BUREAU_RENTER_NOT_ELIGIBLE (422) — locatário pessoa jurídica ou sem CPF cadastrado. Permissão exigida: renters:reanalyze_credit_bureau — 403 para quem não tem, inclusive AGENCY_USER (nunca concedida a esse papel no seed de RBAC). Locatário de outro tenant/imobiliária: 404, mesmo filtro de GET/PATCH /renters/{id}.

Migrations: add_credit_bureau_settings_to_tenant_settings (adiciona as duas colunas em tenant_settings, downgrade remove ambas), create_credit_bureau_inquiries (criou a tabela credit_bureau_inquiries; derrubada depois, ver abaixo), credit_bureau_token_text (achado da revisão final — credit_bureau_token era curto demais para o texto cifrado, ver seção 18), credit_bureau_inquiry_background (RN4b — acrescentou PENDING ao enum de status e a coluna requested_at, tornou queried_at opcional) e bureau_no_renter (RN9, correção pós-aprovação — as quatro colunas credit_bureau_* em renters, reaproveitando o enum já criado; derruba credit_bureau_inquiries, que a essa altura não tinha mais escritor nenhum).

13. Critérios de aceite

  • [x] CA1. Dado um tenant com credit_bureau_type = NONE (RN1), quando uma Análise de Crédito de inquilino PF é submetida, então nenhum CreditBureauInquiry é criado e o painel não aparece na tela. Provado em apps/api, apps/web e apps/e2e.
  • [x] CA2. Dado um tenant com credit_bureau_type = BOAVISTA_CADASTRO_POSITIVO (RN1) e token configurado, quando uma Análise de Crédito de inquilino PF é submetida, então a consulta é agendada automaticamente (RN4) e roda em background (RN4b — correção pós-aprovação), um CreditBureauInquiry com os campos estruturados é gravado (RN6) quando o worker termina, e o painel troca sozinho de "consultando" para o resultado, via polling, sem o ANALYST ter digitado nem recarregado nada. Provado em apps/api (com a resposta real que embasou a spec) e apps/web (fixture, incluindo o estado PENDING e o polling com timers simulados) — não em apps/e2e: sem CPF de sandbox da API Full (seção 15), o caminho de sucesso de ponta a ponta contra o serviço real fica pendente. O caminho de falha de ponta a ponta contra o worker real, esse sim, está em apps/e2e (CA4/CA8/CA9) desde a correção RN4b.
  • [x] CA3. Dado o mesmo tenant, quando o inquilino principal é pessoa jurídica, então nenhuma consulta roda e o painel não aparece (RN5). Provado em apps/api e apps/e2e.
  • [x] CA4. Dado que a consulta ao Boa Vista falha (timeout ou erro simulado), quando a análise é submetida, então a transição para SUBMITTED acontece normalmente, o painel mostra "consulta falhou" com o motivo, e o ANALYST consegue decidir normalmente (RN7). Provado em apps/api (seis cenários de falha) e apps/e2e (falha real contra a API Full, com token inválido de propósito).
  • [x] CA5. Dado um ADMIN_TENANT tentando salvar credit_bureau_type = BOAVISTA_CADASTRO_POSITIVO sem informar token (e nenhum já salvo), quando ele submete o formulário de Configurações, então recebe CREDIT_BUREAU_TOKEN_REQUIRED (422) e a configuração não é salva pela metade (RN2). Provado em apps/api e apps/web.
  • [x] CA6. Dado um token já salvo, quando qualquer papel lê GET /tenant-settings, então a resposta traz credit_bureau_token_configured: true e nunca o valor do token (RN3) — negativo para ADMIN_GLOBAL, ADMIN_TENANT e qualquer outro papel com acesso de leitura. Provado em apps/api e apps/web.
  • [x] CA7. Dado um ANALYST, OPERATOR ou VIEWER tentando PATCH /tenant-settings com credit_bureau_type, quando a requisição é enviada, então recebe 403 (10.2). Coberto pelo teste de permissão já existente (test_tenant_settings_leitura.py), inalterado por esta spec — o gate (require_system_role("ADMIN_TENANT")) não mudou.
  • [x] CA8. Invertido na correção pós-aprovação (RN9). Dado um AGENCY_USER da imobiliária dona da análise, quando ele abre a tela, então não vê o painel do bureau (bureau_inquiry: null, mesmo com dado SUCCESS já gravado no locatário) — nem o painel de parecer interno, que já não via (10.1) — e um AGENCY_USER de outra imobiliária recebe 404 ao tentar abrir a mesma análise (10.4). Provado em apps/api, apps/web e apps/e2e; o negativo (outra imobiliária) é o 404 já existente, herdado, não específico desta spec. A versão original deste critério (imobiliária via o painel) foi a decisão revisada e aprovada da spec — a inversão é achado do usuário testando ao vivo, depois de já estar no ar (ver Registro).
  • [x] CA9. No mobile (375px), o painel aparece sempre aberto (não colapsa), com a mesma disposição rótulo/valor do desktop e sem nenhum campo escondido (7.2), nas duas telas que o exibem (Análise de Crédito e Inquilino). Provado em apps/web e apps/e2e (sem rolagem horizontal em 375px).
  • [x] CA10. O resultado do Boa Vista não altera credit_recommendation — a mesma análise, com e sem o painel, chega à mesma recomendação automática pela regra 3× (RN8). Verificado por leitura estrutural (nenhum código desta spec toca compute_credit_summary), não por um teste dedicado — achado Minor aceito na revisão da branch (seção 17).
  • [x] CA11 (RN9, correção pós-aprovação). Dado um locatário com resultado SUCCESS de uma análise anterior, quando uma segunda análise dele é submetida, então a consulta não roda de novo — o painel mostra o dado já existente, sem novo job agendado (a trava). Provado em apps/api (test_segunda_analise_do_mesmo_inquilino_ nao_consulta_de_novo); sem cobertura e2e pelo mesmo motivo de CA2 (falta CPF de sandbox para produzir um SUCCESS real).
  • [x] CA12 (RN9, correção pós-aprovação). Dado um locatário com resultado FAILED, quando uma nova análise dele é submetida, então a consulta roda de novo automaticamente (só SUCCESS trava). Provado em apps/api (test_falha_anterior_nao_trava).
  • [x] CA13 (RN9, correção pós-aprovação). Dado um ADMIN_TENANT/ANALYST/OPERATOR na tela do locatário ou da análise, quando clica em "Reanalisar", então uma nova consulta é agendada mesmo com um SUCCESS já existente (a trava é ignorada de propósito), e o botão não aparece para VIEWER nem AGENCY_USER (403 se a rota for chamada mesmo assim). Provado em apps/api (permissão e trava ignorada) e apps/web (visibilidade do botão nas duas telas).

14. Plano de implementação

Ordem obrigatória no monorepo: api → web → e2e.

  • [x] apps/api — app/core/crypto.py (cifra/decifra), variável TENANT_SECRETS_KEY
  • [x] apps/api — model TenantSettings (+2 campos, enum CreditBureauType) → schema → repository → service (validação RN2) → router
  • [x] apps/api — model CreditBureauInquiry (novo) → schema → repository
  • [x] apps/api — service credit_bureau_service.py (client HTTP, nunca lança)
  • [x] apps/api — credit_analysis_service.submit() chamando o client novo e persistindo o resultado
  • [x] apps/api — migrations Alembic (as duas) — aplicadas com sucesso contra o Postgres real do dev-remote no rebuild; downgrade escrito mas não exercitado nele (não há motivo de negócio para reverter agora)
  • [x] apps/api — docstrings + Swagger (summary, description, response_description)
  • [x] apps/api — testes em tests/ (unitários do client, do submit(), da validação de tenant-settings, e os negativos de autorização)
  • [x] apps/web — BureauInquiryPanel.tsx, inserido em show.tsx, com data-testid em todo elemento, responsivo (desktop e mobile)
  • [x] apps/web — campo de tipo + token na tela de Configurações, write-only
  • [x] apps/e2e — spec cobrindo o que dá para provar sem CPF de sandbox (CA1, CA4, CA8, CA9); CA2 (caminho de sucesso) fica pendente da decisão em aberto da seção 15
  • [x] make test — pytest 840 passed/1 failed (o intermitente conhecido, área não tocada); make test-web 202 passed/0 failed. make lint não passado como comando único; ruff/tsc conferidos limpos nos arquivos tocados por esta branch
  • [x] Validado no browser em desktop e mobile — via apps/e2e, contra o dev-remote reconstruído (spec novo + 26 specs existentes da mesma tela, sem regressão)

15. Decisões em aberto

  • [ ] Os CPFs de teste do ambiente de sandbox da API Full (mencionados como existentes, mas sem lista concreta) precisam ser levantados junto ao suporte da API Full antes da fase apps/e2e — sem eles, o spec e2e não tem como exercitar CA2/CA4 sem gastar saldo real. Risco aceito conscientemente para as fases apps/api/apps/web, decidido pelo usuário ao aprovar esta spec em 2026-09-03: as fases api e web seguem sem bloqueio; só a fase apps/e2e fica represada até quem tem a conta na API Full levantar a lista (fora do escopo desta spec resolver sozinha).

16. Revisão do detalhamento

Preenchido pelo laço do /spec new, rubrica .claude/rubrics/detalhamento.md. Registrar todas as rodadas, inclusive as reprovadas.

Rodada Critical Important Minor Nota Veredicto
1 0 3 0 6 Reprovada
2 0 2 1 7 Reprovada
3 0 0 2 9 Aprovada

Resultado: aprovada na rodada 3.

Riscos aceitos: nenhum risco de acesso — os dois Minor da rodada 3 (citação incompleta do padrão de Enum de TenantSettings, e a pendência de sandbox sem "aceite" explícito registrado) foram corrigidos no texto antes de fechar o portão.

17. Revisão da implementação

Preenchido pelo /spec implement, rubrica .claude/rubrics/implementacao.md. As revisões de fase (api, web, e2e) vão nas primeiras linhas; a da branch inteira, na última.

Rodada Escopo Critical Important Minor Nota Veredicto
1 apps/api 0 1 2 7 Reprovada
2 apps/api 0 0 1 9 Aprovada
3 apps/web 0 0 3 9 Aprovada
4 branch 0 1 1 8 Aprovada

Resultado: aprovada na rodada 4 (branch inteira).

Defeitos aceitos: nenhum — o Important (coluna credit_bureau_token curta demais para o texto cifrado) foi corrigido depois da aprovação, não deixado em aberto (seção 18, Registro, tem o detalhe e a prova contra Postgres real). O Minor (CA10 sem teste dedicado comparando com/sem o painel) permanece — a separação é estrutural (nenhum código desta spec toca compute_credit_summary), e o risco de acoplamento futuro fica registrado aqui em vez de um teste que hoje só provaria o óbvio.

18. Registro

  • RN4/10.3 corrigido na revisão da fase apps/api (rodada 1). _run_credit_bureau_inquiry resolvia TenantSettings/Renter a partir de current_user.tenant_id, não de credit_analysis.tenant_id — quebrava silenciosamente para ADMIN_GLOBAL (tenant_id is None). Corrigido: o método passou a receber o objeto CreditAnalysis inteiro. Coberto por um teste de regressão novo (test_admin_global_submete_e_a_consulta_roda_com_o_tenant_da_analise), com red-green manual conferido (bug reintroduzido → teste cai → restaurado → volta a passar).
  • apps/e2e/fixtures/limpeza.ts precisou de um ajuste que a spec não previu. A tabela credit_bureau_inquiries tem FK ON DELETE RESTRICT para credit_analyses, e a lista de limpeza do e2e (TABELAS) não a conhecia — a primeira execução do spec novo quebrou a faxina inteira (violates foreign key constraint), deixando lixo do próprio teste e arriscando lixo de qualquer outro spec rodando na mesma janela. Adicionada à lista, na posição certa (antes de credit_analyses, mesmo motivo de guarantees/documents). Verificado: a limpeza rodou limpa nas duas execuções seguintes.
  • CA2 (caminho de sucesso da consulta) não tem prova e2e. Como a seção 15 já registrava como risco aceito, os CPFs de sandbox da API Full nunca foram levantados. O spec e2e novo prova RN1 (tipo NONE) e RN7/CA4/CA8/CA9 usando um token inválido de propósito — a chamada real à API Full falha de verdade, e é esse caminho de falha que a spec promete não bloquear. O retorno bem-sucedido (score, pendências, protestos reais) está coberto só até a fronteira do apps/api (test_credit_bureau_service.py, com a resposta real que o usuário colou na conversa) e do apps/web (show.test.tsx, com fixture). Falta a prova de que a chamada real completa — request assinado, resposta da API Full, parsing, gravação — funciona de ponta a ponta. Fica para quando os CPFs de sandbox existirem.
  • Régua do /spec mudou no meio desta spec, a pedido do usuário. De "até 3 rodadas, Important em aberto sempre reprova" para "até 10 rodadas, com nota 0-10: sem Critical, nota ≥ 8 aprova mesmo com Important em aberto". Alterado em CLAUDE.md e no SKILL.md do /spec antes da Parte II ser revisada — as quatro rodadas da Parte II e as três do detalhamento já rodaram sob a régua nova.
  • Endpoint corrigido depois do commit inicial, com uma chamada real do usuário. A RN4 e o credit_bureau_service.py apontavam para cp-cadastrais-score-dividas — um endpoint que não existe para a conta real usada (uma chamada de teste devolveu 500 vazio). O usuário compartilhou a requisição salva na coleção Postman da API Full ("Dívidas e Crédito › Boa Vista Essencial Positivo") e, depois, o retorno de uma consulta real com o próprio CPF: o endpoint certo é POST /api/scpc-boavista, com um segundo campo obrigatório no corpo, "link": "scpc-boavista" — que apesar do nome não é webhook nem URL, é um identificador fixo do produto. O formato do retorno (dados. HEADER/CREDCADASTRAL/…) é idêntico ao exemplo usado para desenhar _extrair(), que não precisou mudar. O token e o CPF reais usados na consulta não foram gravados em nenhum arquivo do repositório — só o formato da resposta, já sem dado pessoal, embasou o teste novo (test_chama_o_endpoint_e_o_link_certos).
  • Achado Important da revisão da branch inteira, corrigido depois da aprovação (nota 8). tenant_settings.credit_bureau_token era VARCHAR(500), curto demais para o texto cifrado — um token real de ~300-400 caracteres já estoura o limite depois do Fernet. Corrigido para Text (model + migration nova m5o7q9s1u3w4). A prova não é o teste unitário — SQLite não impõe limite de VARCHAR(N), então o teste passa nas duas versões da coluna e não teria pegado o defeito sozinho, achado ao tentar o red-green. A prova real foi contra o Postgres do dev-remote: PATCH /settings com um token de 400 caracteres (cifra para ~630) devolveu 200, depois de aplicar a migration e reiniciar o container — antes dela, o mesmo request devolveria 500.
  • Achado do usuário testando ao vivo no celular, depois do commit acima. "O nome Boa Vista não deve ficar visível é interno da garantidora, e o erro 400 precisa ter algo mais amigável, mas em auditoria deve apresentar a chamada com o retorno." Três mudanças:
  • BureauInquiryPanel.tsx deixou de exibir o nome do fornecedor ("Consulta ao Boa Vista" → "Consulta de crédito", nos dois estados) e, no estado FAILED, trocou o failure_reason bruto (às vezes um código HTTP) por uma frase fixa, sem detalhe técnico. O failure_reason continua vindo no contrato de API (CreditBureauInquiryResponse) — só o painel deixou de mostrá-lo.
  • credit_analysis_service.py passou a gravar um AuditLog (action="BUREAU_INQUIRY") dentro de _run_credit_bureau_inquiry, com o nome do fornecedor, status, score/motivo de falha — o detalhe que saiu do painel. A gravação é best-effort, num try/except que só loga: uma falha aqui não pode reabrir o problema que a RN7 já fecha (a consulta nunca bloqueia a submissão).
  • A auditoria estava sendo escrita, mas nunca gravava — CreditBureauInquiryStatus era usado em _run_credit_bureau_inquiry sem import (só CreditBureauInquiry, o model, estava importado), e o except Exception do próprio item 2 engolia o NameError silenciosamente a cada submissão. Achado escrevendo o teste de regressão (test_consulta_gera_registro_de_auditoria_com_o_fornecedor): rodou vermelho antes da correção do import, verde depois — red-green manual também conferido revertendo o import de novo. Os testes antigos deste arquivo não pegavam porque nenhum lia a tabela AuditLog.

Textos desatualizados corrigidos junto: a asserção de show.test.tsx que esperava "não foi possível consultar o Boa Vista" (não existe mais) e as duas do spec e2e (analise-credito-boavista.spec.ts, CA4/CA9) com a mesma frase — agora conferem a mensagem genérica nova e a ausência do nome do fornecedor no painel. - Ordem do painel ajustada a pedido do usuário. BureauInquiryPanel subiu para antes do PolicyPanel em show.tsx — o dado externo do bureau embasa a leitura da política, não o contrário. Puramente visual (sem data-testid novo, sem mudança de contrato); credit-analysis-detail-order.spec.ts (spec 0025), que afere ordem por posição vertical real, não cita o painel do bureau porque a análise ali usada tem credit_bureau_type: NONE — segue passando (10/10) sem precisar de alteração. Confirmado também que analise-credito-boavista.spec.ts continua verde com o painel na posição nova. - RN4b — a consulta virou background, achado do usuário testando ao vivo pela terceira vez. "A consulta da Boa Vista demorou para responder, gastou o crédito e não recebi o retorno, e ainda travou a tela, precisamos criar uma forma de fazer isso em background e garantir o retorno." O desenho síncrono de RN4 (chamada HTTP dentro da transação de submit()) tinha dois defeitos reais: a tela ficava presa pelo tempo da chamada (até 10s de leitura), e uma conexão que caísse no meio (rede de celular, o cenário relatado) deixava a chamada paga em curso no servidor sem garantia nenhuma de que o resultado chegasse a quem esperava.

Duas decisões só do usuário, perguntadas antes de desenhar (a spec não inventa infraestrutura nem política de retry): mecanismo de background — fila dedicada (Redis) em vez de BackgroundTasks do FastAPI, decisão explícita por resistir a um restart da API no meio de uma consulta em curso e não competir por CPU com quem serve requisição; e política de nova tentativa — nenhuma automática, para não gastar o crédito duas vezes numa falha que pode ser real (CPF inválido), não só transitória. Dentro de "fila dedicada", a escolha de arq (não Celery/RQ) foi técnica, não perguntada: é nativamente assíncrono, e o resto do backend já é async de ponta a ponta (asyncpg, SQLAlchemy async, httpx async) — Celery/RQ exigiriam rodar código async dentro de worker síncrono.

O que mudou, por app: - apps/api — _run_credit_bureau_inquiry virou _schedule_credit_bureau_inquiry (grava PENDING + agenda o job, não chama mais a Boa Vista); a chamada de verdade foi para app/services/credit_bureau_worker_service.py (novo), rodando dentro de app/worker.py (novo, arq). Infra nova: app/core/queue.py (pool arq lazy), REDIS_URL em app/core/config.py, migration nova (PENDING no enum, requested_at, queried_at nullable), serviços redis/worker nos três docker-compose.yml, alvo dev-worker/worker-dev nos dois Makefile. - apps/web — BureauInquiryPanel ganhou o estado PENDING ("consultando", com CircularProgress); show.tsx ganhou um useEffect de polling (3s, teto de 60s) que busca só a análise enquanto bureau_inquiry.status === "PENDING". - apps/e2e — o spec existente passou a esperar o worker de verdade (container worker do dev-remote, mesmo Redis da API) em vez de medir um resultado que o submit() já teria embutido. A prova de que submit() não bloqueia mais é cronometrada, não a observação de um PENDING transitório — na prática, o worker respondeu rápido o bastante (chamada com token inválido falha em menos de 1s) para o registro já estar FAILED quando a tela termina de montar, o que tornaria uma asserção "deve mostrar PENDING" inerentemente instável.

Um bug real encontrado escrevendo o teste do worker, não pela revisão. A auditoria usava CreditBureauInquiryStatus sem importar o nome no novo módulo — NameError silencioso, engolido pelo próprio try/except que protege a gravação da auditoria (a mesma classe de defeito da correção anterior desta seção, em módulo diferente). Achado pelo teste test_worker_fecha_pending_como_success cobrindo a linha de auditoria; red (import faltando) → green (import corrigido), revertido e confirmado de novo.

Verificação: pytest de test_credit_analysis_bureau_inquiry.py reescrito para a arquitetura em duas fases (12 casos, incluindo agendamento com Redis indisponível, worker processando job já concluído — idempotência —, e falha inesperada dentro do worker nunca deixando PENDING) — 12/12; suíte inteira da API, 849/850 (1 vermelho é o intermitente já catalogado em CLAUDE.md, não relacionado). apps/web: 13/13 em show.test.tsx (2 casos novos, um com vi.useFakeTimers provando o polling end-to-end), suíte inteira 204/204. apps/e2e: analise-credito-boavista.spec.ts 3/3 contra o worker real do dev-remote reconstruído (redis, worker, api, web), e credit-analysis-detail-order.spec.ts (spec 0025) 8/8 sem alteração — confirma que o polling novo não interfere na tela para tenants sem a consulta ativada. Migration aplicada e revertida (downgrade/upgrade) contra o Postgres real do dev-remote antes de confirmar o schema. - A resposta da API Full não traz custo, consumo nem saldo de créditos — conferido contra uma resposta real completa que o usuário compartilhou. A dúvida era se dados.HEADER carregava algum indicador financeiro da consulta. Não carrega: HEADER. DADOS_RETORNADOS é só um mapa de flags ("0"/"1") dizendo quais blocos de dado vieram na resposta (ex.: SCORE: "1", ENDERECOS: "0"), e HEADER.CONTROLE traz QUANTIDADE_OCORRENCIAS e a fonte/status de cada ocorrência — nenhum dos dois é medida de custo ou saldo. Nada em CREDCADASTRAL também. Continua não havendo dado de saldo/consumo em lugar nenhum do payload, o que reafirma o limite já registrado na seção 5 ("Conta compartilhada da plataforma... esta spec não cobre uma conta única da Garantora"): se a garantidora quiser ver quanto crédito resta na própria conta da API Full, a fonte teria que ser o painel da própria API Full (fora desta plataforma), ou um novo endpoint da API Full que esta consulta não usa — não há o que extrair do que já chega hoje. Nenhuma mudança de código: raw_response já grava o dados inteiro (HEADER incluído) por análise, então o dado — se um dia a API Full passar a devolvê-lo — já ficaria capturado sem precisar de alteração no parser. O exemplo completo da resposta está no Apêndice A — confirmado pelo usuário como dado fictício, de exemplo, não uma pessoa real, então guardá-lo aqui não é o mesmo risco do exemplo usado para achar o endpoint certo (seção 18, acima), que usava o CPF real do próprio usuário. - RN9 — o bureau virou característica do locatário, não da análise, com trava e reanálise manual. Terceira correção pós-aprovação, pedido de uma vez só: "Quero que todas essas informações estejam associadas ao inquilino pode colocar em um campo no banco jsonb. Na análise de crédito e no inquilino adicionar um painel com as informações capturadas. Somente a garantidora tem acesso a informação a imobiliária não tem. Coloque também uma trava que somente chamar a api para verificar o crédito se não tiver feito antes e um botão de reanalisar mas somente para garantidora."

Três decisões perguntadas antes de desenhar (não inventadas): manter CreditBureauInquiry como histórico por análise além do cache no locatário, ou só o locatário — resposta: só o locatário, mais simples, sem duas fontes da mesma verdade divergindo; um FAILED anterior também trava a próxima análise, ou só SUCCESS trava — resposta: só SUCCESS trava, para uma falha transitória não deixar o inquilino sem dado até alguém lembrar de reanalisar; o botão "Reanalisar" fica só no inquilino, ou nos dois painéis — resposta: nos dois, mesma ação, dois lugares onde o dado aparece.

O que mudou, por app: - apps/api — Renter ganhou as quatro colunas credit_bureau_*; CreditBureauInquiry (tabela por análise) foi derrubada; a decisão "agenda ou não" (elegibilidade + trava) virou um módulo próprio, app/services/credit_bureau_schedule.py, compartilhado entre o agendamento automático (CreditAnalysisService._schedule_credit_bureau_inquiry, force=False) e o manual (RenterService.reanalyze_credit_bureau, novo, force=True); o worker (complete_credit_bureau_inquiry) passou a fechar o registro do locatário; CreditAnalysisResponse.bureau_inquiry virou derivado (lido do locatário principal na hora de montar a resposta, não mais uma FK); rota nova, POST /renters/{id}/reanalyze-credit-bureau; permissão nova, renters:reanalyze_credit_bureau. - apps/web — BureauInquiryPanel saiu de pages/credit-analyses/components/ para components/shared/ (agora usado em duas telas), ganhou testIdPrefix (preserva os data-testid antigos na Análise de Crédito via credit-analysis-bureau, novos via renter-bureau no Inquilino) e o botão "Reanalisar" (onReanalyze/reanalyzing); pages/renters/show.tsx ganhou o painel + o mesmo mecanismo de polling que credit-analyses/show.tsx já tinha (RN4b), duplicado porque são dois recursos (GET /renters/{id} vs GET /credit-analyses/{id}) e o polling busca o próprio. - apps/e2e — o teste que provava CA8 (imobiliária vê o painel) foi invertido para provar o oposto — não é um caso novo, é o mesmo caso, com a asserção trocada.

Uma decisão técnica não perguntada, e por quê: o usuário disse "jsonb"; o campo virou JSON genérico do SQLAlchemy, não JSONB do Postgres — mesma convenção já documentada no CLAUDE.md da API ("SQLite compatibility: avoid PostgreSQL-specific column types"), que existe para o pytest rodar sem Docker. raw_response (dentro do JSON) já usava JSON genérico desde a aprovação original da spec; manter a mesma escolha para o campo novo evita duas convenções concorrentes na mesma tabela.

Um achado do meta-teste, não do usuário, mudou o desenho de permissão no meio do caminho. A primeira versão desta correção criava renters:read_credit_bureau como permissão RBAC — pareceu simétrico com renters:reanalyze_credit_bureau, e as duas entraram no seed. test_authorization_coverage.py::test_toda_permissao_definida_e_ exigida (meta-teste de permissão órfã, mencionado no CLAUDE.md da raiz, seção make preflight) reprovou: a permissão era concedida a vários papéis, mas nunca exigida de um jeito que a varredura reconhece (require_permission no router, ou exigir_permissao no serviço) — o código real usava current_user.has_permission(...) direto, para redigir um campo condicionalmente, não para bloquear a requisição inteira. Em vez de inventar um terceiro padrão só para este caso, ou enfraquecer a varredura do meta-teste, a permissão foi removida: ver o resultado do bureau passou a ser identidade (current_user.partner_agency_id is not None), o mesmo padrão que within_agency/agency_scope já usam para todo o resto do isolamento por imobiliária desta spec (seção 10.4) — mais consistente com o restante do código do que uma permissão nova teria sido. renters:reanalyze_credit_bureau continuou permissão RBAC normal, porque essa sim é uma ação que o router bloqueia via require_permission, satisfazendo o meta-teste sem ajuste nenhum.

Verificação: pytest novo, test_renter_credit_bureau.py (17 casos — trava, elegibilidade, worker no locatário, idempotência de reentrega, reanalyze_credit_ bureau com as duas recusas 422, falha ao agendar fecha FAILED, visibilidade por identidade) — 17/17; test_credit_analysis_bureau_inquiry.py reescrito (6 casos, focado no que é específico da análise: agenda no locatário certo, segunda análise do mesmo inquilino não reconsulta — a trava, vista do lado da análise —, ADMIN_GLOBAL, AGENCY_USER nunca vê) — 6/6; test_authorization_coverage.py — verde depois da correção do desenho de permissão; suíte inteira da API — 859/861 (os dois vermelhos são os intermitentes já catalogados em CLAUDE.md, SQLite/desempate de UUID, não relacionados). apps/web: show.test.tsx de renters/ (novo, 7 casos — painel ausente/PENDING/SUCCESS/FAILED, botão Reanalisar visível/escondido por permissão, clique aciona a API, polling), show.test.tsx de credit-analyses/ inalterado e ainda verde (13/13, os data-testid antigos sobreviveram à mudança de componente); suíte inteira — 211/211. Migration aplicada e revertida (downgrade/upgrade) contra o Postgres real do dev-remote antes de confirmar o schema. tsc --noEmit limpo em todo o app depois de mover o componente e trocar os dois pontos que o importavam. - RN9b (modo sandbox) — um bug de colisão de nome, achado pelo próprio teste que ele quebrou. credit_bureau_worker_service.py importava from app.core.config import settings no topo do módulo, e a função reatribuía settings = await settings_repo. get_by_tenant(...) — uma linha que já existia antes desta correção, agora escondendo o settings do módulo dentro do próprio escopo da função. O acesso a settings. CREDIT_BUREAU_SANDBOX_MODE (a TenantSettings do tenant, não a configuração da aplicação) estourava AttributeError, engolido pelo except Exception da própria gravação de auditoria (best-effort, de propósito — RN7) — a consulta terminava SUCCESS normalmente, só a linha de auditoria nunca era gravada. test_worker_fecha_ pending_do_locatario_como_success, já existente, pegou sozinho (assert len(linhas) == 1 falhando com 0 == 1). Corrigido importando com alias (from app.core.config import settings as app_settings) nos dois pontos que o usavam. Reforçado o teste com uma asserção nova no campo payload["sandbox"], e feito o red-green manual: revertido o alias → o teste cai com o mesmo AttributeError no log → restaurado → volta a passar.

Verificação final: suíte inteira da API rodada duas vezes depois da correção — a primeira, contaminada por um docker cp no meio da execução (mesma armadilha já catalogada no CLAUDE.md: recriar/alterar o container enquanto o pytest roda mede estado inconsistente), foi descartada; a segunda, limpa, fechou em 864 passed, 0 failed em 377s — nem o intermitente conhecido (test_partner_agency_status_filter.py) flakeou nesta rodada. test_credit_bureau_service.py — 10/10, incluindo os dois casos novos do modo sandbox, cada um com red-green manual próprio (guarda de produção desativada → create_app() não levanta RuntimeError → restaurada → volta a levantar; e o teste do próprio modo sandbox nunca precisou de reversão, porque nasceu depois do código que prova). Deploy consolidado via rebuild de imagem (api e worker, mesma imagem, comando trocado) — não ficou como estado só de docker cp. - RN9c, e um segundo efeito colateral do RN9b — a suíte deixou de ser hermética. Depois de infra/dev-remote/.env ganhar CREDIT_BUREAU_SANDBOX_MODE=true (RN9b), todo pytest rodado dentro do container do dev-remote passou a herdar esse true do ambiente real — porque é lá que a suíte roda hoje, sem uv no host (CLAUDE.md, "Estado do ferramental"). Ao escrever test_ativar_sem_token_e_rejeitado (RN2) com a relaxação nova, o teste passou a falhar: o container já tinha o modo ligado antes de o teste rodar, e a validação que ele verifica não se aplicava mais. Corrigido em tests/conftest.py, fixando settings.CREDIT_BUREAU_SANDBOX_MODE = False no import — mesmo padrão já existente para settings.BCRYPT_ROUNDS, mesma razão: a suíte precisa medir o comportamento descrito, não o que o ambiente que a hospeda calha de ter ligado. Quem quiser sandbox ligado dentro de um teste usa monkeypatch.setattr (como já faziam os dois testes do RN9b, e como os três novos do RN9c fazem).

A régua de "o token só é exigido fora do sandbox" ficou provada nos dois lados: test_ativar_sem_token_e_rejeitado (sandbox off, herdado do conftest) continua recusando; test_sandbox_ativo_dispensa_o_token (sandbox ligado por monkeypatch, só naquele teste) aceita a mesma requisição. Red-green manual na condição nova (and not app_settings.CREDIT_BUREAU_SANDBOX_MODE): removida → test_sandbox_ativo_ dispensa_o_token cai com 422 CREDIT_BUREAU_TOKEN_REQUIRED → restaurada → volta a passar. Mesmo red-green do lado do apps/web, no bloco condicional que esconde o campo de token: {form.credit_bureau_sandbox_active ? <Alert/> : <Box/>} trocado por {false ? <Alert/> : <Box/>} → os dois testes que esperam o banner caem (getByTestId não encontra) → restaurado → volta a passar.

Verificação: test_tenant_settings_credit_bureau.py — 8/8 (5 já existentes + 3 novos: sandbox inativo por padrão, sandbox aparece no GET, sandbox dispensa o token). Suíte inteira da API depois da correção do conftest — 866 passed, 1 failed (o intermitente já catalogado, test_partner_agency_search.py::test_busca_alcanca_ quem_esta_fora_da_primeira_pagina — SQLite/desempate de UUID, não relacionado). apps/web: pages/settings/index.test.tsx — 8/8 (5 já existentes + 3 novos); suíte inteira do apps/web — 214/214 (211 da medição anterior + os 3 novos).

Apêndice A — exemplo completo de retorno da API Full

Resposta de POST /api/scpc-boavista para o CPF 12345678909, compartilhada pelo usuário e confirmada como dado fictício (não uma pessoa real) — por isso pode ficar aqui, diferente do exemplo usado para achar o endpoint certo (seção 18), que usava o CPF real do próprio usuário e não foi persistido em nenhum arquivo.

Referenciado por RN4 (formato de dados usado para desenhar _extrair()), RN6 (campos gravados em CreditBureauInquiry/raw_response) e pelo achado do Registro sobre custo/saldo não estarem no payload.

{
    "status": "sucesso",
    "API Full": "https://doc.apifull.com.br",
    "dados": {
        "HEADER": {
            "PARAMETROS": {
                "CPFCNPJ": "12345678909"
            },
            "DADOS_RETORNADOS": {
                "DADOS_RECEITA_FEDERAL": "0",
                "ALERTA_DOCUMENTOS_ROUBADOS": "0",
                "DADOS_AGENCIA_BANCARIA": "0",
                "PROTESTO_SINTETICO": "0",
                "PROTESTO_ANALITICO": "1",
                "ENDERECOS": "0",
                "FALENCIAS_ACOES_RECUPERACOES": "0",
                "INFORMACOES_ELEITORAIS": "0",
                "QUADRO_SOCIETARIO": "0",
                "PARTICIPACAO_EM_EMPRESAS": "0",
                "PENDENCIAS_INTERNAS": "0",
                "PENDENCIAS_FINANCEIRAS": "1",
                "INFORMACOES_DA_EMPRESA": "0",
                "CCF_BACEN": "1",
                "CCF_VAREJO": "1",
                "RECHEQUE_ONLINE": "0",
                "RECHEQUE": "0",
                "CONTUMACIA": "1",
                "ENDERECO_DO_CEP": "0",
                "PASSAGENS_COMERCIAIS": "1",
                "INFORMACOES_ALERTAS_RESTRICOES": "1",
                "SCORE": "1",
                "ADMINISTRADORES_DA_EMPRESA": "0",
                "TITULOS_A_VENCER": "0",
                "HISTORICO_DE_PAGAMENTOS": "0",
                "REFERENCIA_DE_NEGOCIOS": "0",
                "PRINCIPAIS_FORNECEDORES": "0",
                "IDENTIFICACAO_PESSOA_FISICA": "1",
                "OCUPACAO_PESSOA_FISICA": "0",
                "PARTICIPACAO_EM_FALENCIAS": "0",
                "TELEFONE_FIXO": "0",
                "TITULAR_DO_TELEFONE": "0",
                "OUTROS_TELEFONES": "0",
                "TELEFONE_CELULAR": "0",
                "PARENTES": "0",
                "CONTATOS": "0",
                "LOCAIS_TRABALHO": "0",
                "TELEFONE_COMERCIAL": "0",
                "DADOS_GERAIS": "0",
                "VIZINHOS": "0",
                "RESIDENTES": "0",
                "BENEFICIO": "0",
                "EMAILS": "0",
                "INFOBUSCA": "0",
                "VINCULO_MESMO_ENDERECO": "0",
                "SOMENTE_TELEFONE": "1",
                "SOMENTE_ENDERECO": "0",
                "RELATORIO_TEXTO": "0",
                "VEICULOS_POR_DOCUMENTO": "0",
                "CNH": "0",
                "AGREGADOS": "0",
                "BIN_NACIONAL": "0",
                "BIN_ESTADUAL": "0",
                "GRAVAME": "0",
                "LEILAO": "0",
                "CONFERENCIA_MOTOR_E_CHASSI": "0",
                "HISTORICO_PROPRIETARIOS": "0",
                "HISTORICO_ROUBO_FURTO": "0",
                "ROUBO_FURTO": "0",
                "PERDA_TOTAL": "0",
                "ALERTAS": "0",
                "RECALL": "0",
                "DPVAT": "0",
                "DECODIFICADOR_DE_CHASSI": "0",
                "PRECIFICADOR": "0",
                "RENAJUD": "0",
                "RENACH": "0",
                "CRLV": "0",
                "REMARKETING": "0",
                "INDICIO_SINISTRO": "0",
                "PARECER_TECNICO": "0",
                "PENDENCIAS_REFIN": "0",
                "PENDENCIAS_VENCIDAS": "0",
                "LOCALIZADOR_AGREGADOS": "0",
                "CARACTERISTICAS_ADICIONAIS": "0",
                "LAUDO_CAUTELAR": "0",
                "DECISAO": "0",
                "LIMITE_CREDITO": "0",
                "FATURAMENTO_PRESUMIDO": "0",
                "PARTICIPACAO_SOCIO_ADM_OUTRAS_EMPRESAS": "0",
                "PARTICIPACOES_EMPRESAS_CONSULTADAS": "0",
                "HIST_CONSULTAS": "0",
                "ACOES_CIVEIS": "0",
                "FALENCIA_RECUPERACAO_JUDICIAL": "0",
                "ANALISE_KILOMETRAGEM": "0",
                "PONTUALIDADE_PAGTO": "0",
                "PROPRIETARIO_ATUAL_VEICULO": "0",
                "VALIDACAO_PESSOA_FISICA": "0",
                "VALIDACAO_PF_BASICA": "0",
                "VALIDACAO_PF_BIOMETRIA_FACE": "0",
                "VALIDACAO_PF_CNH": "0",
                "VALIDACAO_PF_DOCUMENTO": "0",
                "VALIDACAO_PF_ENDERECO": "0",
                "VALIDACAO_PF_FILIACAO": "0",
                "AUTO_VISTORIA": "0",
                "INDICE_SECURITARIO": "0",
                "CALCULO_VALORIZACAO": "0",
                "ATPVE": "0",
                "CREDITO_CONCEDIDO": "0",
                "RENDA_PRESUMIDA": "1",
                "COMPROMETIMENTO_PAGTO": "0",
                "LEILAO_CONJUGADO": "0",
                "CSV": "0",
                "RENAINF": "0",
                "RELATORIO_ARQUIVO": "0",
                "INDICIO_SINISTRO_CONJUGADO": "0",
                "CONSULTA_GENERICA": "0",
                "BATIDOS": "0",
                "RELATORIO_SCR": "0",
                "CADIN": "0",
                "ACOES_TRABALHISTAS": "0",
                "RELATORIO_SCR_SINTETICO": "0",
                "CPR": "0",
                "FICHA_TECNICA_VEICULAR": "0",
                "CHECK_LIST_VEICULAR": "0",
                "DIVIDA_ATIVA_PESSOA_FISICA": "0",
                "DIVIDA_ATIVA_PESSOA_JURIDICA": "0",
                "PENDENCIAS_ISP": "0",
                "RELATORIO_JURIDICO_EMPRESARIAL": "0",
                "INFORMACOES_CRIMINAIS": "0",
                "PROCURADOS_JUSTICA": "0",
                "RELATORIO_JURIDICO_PESSOA_FISICA": "0",
                "DETALHAMENTO_ACAO_JUDICIAL": "0",
                "RELATORIO_IMOVEIS": "0",
                "DEBITOS_IPVA": "0",
                "DEBITO_DIRETO_AUTORIZADO": "0",
                "CONTROLE_POSITIVO": "0",
                "ALERTA_INDICIO": "0",
                "RELATORIO_CREDITICIO_ARQUIVO": "0",
                "IMAGENS_VEICULO": "0",
                "RELATORIO_SCR_ENCAPSULADO": "0",
                "RESTRICOES_FINANCEIRAS": "0",
                "ALERTA_VEICULAR_PROPRIEDADE": "0",
                "MOTOR_CREDITO": "0",
                "ALTERACAO_CARACTERISTICAS": "0",
                "DEBITOS_VEICULARES": "0",
                "RESTRICOES_SINTETICAS": "0",
                "ACOES_JUDICIAIS_COMPLETAS": "0",
                "PERIODO_RELACIONAMENTO_FORNECEDORES": "0",
                "EVENTOS_PESSOA_JURIDICA": "0",
                "CREDITO_OBTIDO": "0",
                "COMPROMISSOS": "0",
                "PAGAMENTO_PONTUAL": "0",
                "PAGAMENTO_ATRASADO": "0",
                "COMPROMETIMENTO_FUTURO": "0",
                "ANTECEDENTES_PESSOAIS": "0",
                "COMUNICADO_VENDA": "0",
                "ANTT_TRANSPORTADOR": "0",
                "ANTT_VEICULO": "0",
                "HIST_TRANSFERENCIA_NACIONAL": "0"
            },
            "CONTROLE": {
                "QUANTIDADE_OCORRENCIAS": "1",
                "OCORRENCIAS": [
                    {
                        "CONTEUDO": "3",
                        "FONTE": "58",
                        "STATUS": "1"
                    }
                ],
                "CACHE_GERAL": []
            }
        },
        "CREDCADASTRAL": {
            "IDENTIFICACAO_PESSOA_FISICA": {
                "STATUS_RETORNO": {
                    "CODIGO": "1",
                    "DESCRICAO": "CONSULTA CONCLUIDA COM SUCESSO"
                },
                "CPF_NUMERO": "12345678909",
                "CPF_SITUACAO": "CANCELADO",
                "CPF_ORIGEM": "PR, SC",
                "CPF_DATA_ATUALIZACAO_RECEITA": "27/02/2021",
                "RG_NUMERO": "0000222333444",
                "RG_UF": "DF",
                "RG_DATA_EMISSAO": "-",
                "NOME": "DAIANA LASSOLLI",
                "NASCIMENTO": "27/04/2000",
                "NACIONALIDADE": "",
                "NATURALIDADE": "",
                "SEXO": "FEMININO",
                "GRAU_INSTRUCAO": "NAO INFORMADO",
                "NUMERO_DEPENDENTES": "0",
                "ESTADO_CIVIL": "NÃO INFORMADO",
                "MAE": "JULIA ROMERO",
                "END_LOGRADOURO": "  ",
                "END_BAIRRO": "",
                "END_CIDADE": "",
                "END_UF": "",
                "END_CEP": "",
                "TELEFONE": "",
                "TITULO_ELEITOR": "0000000000000",
                "NOME_PAI": "",
                "NOME_SOCIAL": "",
                "EMAIL": "",
                "NUMERO_PIS": "",
                "INDICACAO_OBITO": "",
                "FAIXA_RENDA": "",
                "CLASSE_SOCIAL": "",
                "PODER_AQUISITIVO": "",
                "ULTIMO_IMPOSTO_RENDA": "",
                "CARTAO_NACIONAL_SAUDE": "",
                "QTD_DEPENDENTES_BOLSA_FAMILIA": ""
            },
            "INFORMACOES_ALERTAS_RESTRICOES": {
                "STATUS_RETORNO": {
                    "CODIGO": "1",
                    "DESCRICAO": "CONSULTA CONCLUIDA COM SUCESSO"
                },
                "QUANTIDADE_OCORRENCIA": "0",
                "OCORRENCIAS": []
            },
            "PEND_FINANCEIRAS": {
                "STATUS_RETORNO": {
                    "CODIGO": "1",
                    "DESCRICAO": "CONSULTA CONCLUIDA COM SUCESSO"
                },
                "QUANTIDADE_OCORRENCIA": "0",
                "VALOR_TOTAL": "0",
                "ULTIMO_VENCIMENTO": "",
                "DATA_PRIMEIRO": "",
                "TOTAL_CREDORES": "",
                "VALOR_MAIOR": "",
                "VALOR_PRIMEIRO": "",
                "PERIODO_INICIAL": "",
                "PERIODO_FINAL": "",
                "DATA_MAIOR": "",
                "PROVEDORES": [
                    {
                        "PROVEDOR": "BASE III"
                    }
                ],
                "OCORRENCIAS": []
            },
            "PROTESTOS": {
                "STATUS_RETORNO": {
                    "CODIGO": "1",
                    "DESCRICAO": "CONSULTA CONCLUIDA COM SUCESSO"
                },
                "VALOR_TOTAL": "",
                "ULTIMO_PROTESTO": "",
                "QUANTIDADE_OCORRENCIA": "0",
                "DATA_PRIMEIRO": "",
                "VALOR_PRIMEIRO": "",
                "DATA_ULTIMO": "",
                "VALOR_ULTIMO": "",
                "DATA_MAIOR": "",
                "VALOR_MAIOR": "",
                "OCORRENCIAS": []
            },
            "CH_SEM_FUNDOS_VAREJO": {
                "STATUS_RETORNO": {
                    "CODIGO": "1",
                    "DESCRICAO": "CONSULTA CONCLUIDA COM SUCESSO"
                },
                "QUANTIDADE_OCORRENCIA": "0",
                "CORRENTISTA": "",
                "TIPO_PESSOA": "",
                "CPFCNPJ": "",
                "OCORRENCIAS": []
            },
            "CH_SEM_FUNDOS_BACEN": {
                "STATUS_RETORNO": {
                    "CODIGO": "1",
                    "DESCRICAO": "CONSULTA CONCLUIDA COM SUCESSO"
                },
                "QUANTIDADE_OCORRENCIA": "0",
                "CORRENTISTA": "",
                "TIPO_PESSOA": "",
                "CPFCNPJ": "",
                "OCORRENCIAS": []
            },
            "CONTUMACIA": {
                "STATUS_RETORNO": {
                    "CODIGO": "1",
                    "DESCRICAO": "CONSULTA CONCLUIDA COM SUCESSO"
                },
                "QUANTIDADE_OCORRENCIA": "0",
                "DATA_PRIMEIRA_OCORRENCIA": "",
                "DATA_ULTIMA_OCORRENCIA": "",
                "OCORRENCIAS": []
            },
            "SCORES": {
                "STATUS_RETORNO": {
                    "CODIGO": "1",
                    "DESCRICAO": "CONSULTA CONCLUIDA COM SUCESSO"
                },
                "QUANTIDADE_OCORRENCIAS": "1",
                "OCORRENCIAS": [
                    {
                        "LAYOUT": "SCSIMPLE2020",
                        "TIPO_SCORE": "NOVO RISCO 6 MESES",
                        "SCORE": "148",
                        "EXECUCAO": "N",
                        "DESCR_PLANO": "CREDITO",
                        "DESCR_SCORE": "NOVO RISCO 6 MESES",
                        "CLASSIF_NRO": "15",
                        "CLASSIF_ABC": "E",
                        "PROBABILIDADE_INADIMPLENCIA": "98,80",
                        "TEXTO": "DE CADA 100 PESSOAS CLASSIFICADAS NESTA CLASSE DE SCORE, E PROVAVEL QUE 99 APRESENTEM DEBITOS NO MERCADO NOS PROXIMOS 6 MESES.",
                        "RISCO": "NULO",
                        "MODELO_SCORE": "",
                        "INFORMANTE": "BASE III"
                    }
                ]
            },
            "SOMENTE_TELEFONE": {
                "STATUS_RETORNO": {
                    "CODIGO": "1",
                    "DESCRICAO": "CONSULTA CONCLUIDA COM SUCESSO"
                },
                "REGISTRO_LOCALIZADO": "0"
            },
            "PASSAGENS_COMERCIAIS": {
                "STATUS_RETORNO": {
                    "CODIGO": "1",
                    "DESCRICAO": "CONSULTA CONCLUIDA COM SUCESSO"
                },
                "QUANTIDADE_OCORRENCIA": "35",
                "OCORRENCIAS": [
                    { "DATA_CONSULTA": "26/02/2026", "HORA_CONSULTA": "---", "CLIENTE_CONSULTA": "ANALISE DE CREDITO II", "TELEFONE_CLIENTE": "---", "CIDADE_UF_CLIENTE": "---" },
                    { "DATA_CONSULTA": "24/02/2026", "HORA_CONSULTA": "---", "CLIENTE_CONSULTA": "SCLOUD PAY SOLUCOES DE PAGAMEN LTDA", "TELEFONE_CLIENTE": "---", "CIDADE_UF_CLIENTE": "---" },
                    { "DATA_CONSULTA": "23/02/2026", "HORA_CONSULTA": "---", "CLIENTE_CONSULTA": "SCLOUD PAY SOLUCOES DE PAGAMEN LTDA", "TELEFONE_CLIENTE": "---", "CIDADE_UF_CLIENTE": "---" },
                    { "DATA_CONSULTA": "20/02/2026", "HORA_CONSULTA": "---", "CLIENTE_CONSULTA": "SCLOUD PAY SOLUCOES DE PAGAMEN LTDA", "TELEFONE_CLIENTE": "---", "CIDADE_UF_CLIENTE": "---" },
                    { "DATA_CONSULTA": "19/02/2026", "HORA_CONSULTA": "---", "CLIENTE_CONSULTA": "ALUDE TECNOLOGIA LTDA", "TELEFONE_CLIENTE": "---", "CIDADE_UF_CLIENTE": "---" },
                    { "DATA_CONSULTA": "19/02/2026", "HORA_CONSULTA": "---", "CLIENTE_CONSULTA": "SCLOUD PAY SOLUCOES DE PAGAMEN LTDA", "TELEFONE_CLIENTE": "---", "CIDADE_UF_CLIENTE": "---" },
                    { "DATA_CONSULTA": "10/02/2026", "HORA_CONSULTA": "---", "CLIENTE_CONSULTA": "MS-NVR/NEWPARCE", "TELEFONE_CLIENTE": "---", "CIDADE_UF_CLIENTE": "---" },
                    { "DATA_CONSULTA": "09/02/2026", "HORA_CONSULTA": "---", "CLIENTE_CONSULTA": "MARIELE MOVEIS", "TELEFONE_CLIENTE": "---", "CIDADE_UF_CLIENTE": "---" },
                    { "DATA_CONSULTA": "06/02/2026", "HORA_CONSULTA": "---", "CLIENTE_CONSULTA": "ANALISE DE CREDITO II", "TELEFONE_CLIENTE": "---", "CIDADE_UF_CLIENTE": "---" },
                    { "DATA_CONSULTA": "28/01/2026", "HORA_CONSULTA": "---", "CLIENTE_CONSULTA": "ALUDE TECNOLOGIA LTDA", "TELEFONE_CLIENTE": "---", "CIDADE_UF_CLIENTE": "---" },
                    { "DATA_CONSULTA": "22/01/2026", "HORA_CONSULTA": "---", "CLIENTE_CONSULTA": "LOJAS MM", "TELEFONE_CLIENTE": "---", "CIDADE_UF_CLIENTE": "---" },
                    { "DATA_CONSULTA": "21/01/2026", "HORA_CONSULTA": "---", "CLIENTE_CONSULTA": "SUPERSIM EMPRESTIMOS", "TELEFONE_CLIENTE": "---", "CIDADE_UF_CLIENTE": "---" },
                    { "DATA_CONSULTA": "14/01/2026", "HORA_CONSULTA": "---", "CLIENTE_CONSULTA": "MS-IQR/AGROPECUARIA CAMPO VERDE", "TELEFONE_CLIENTE": "---", "CIDADE_UF_CLIENTE": "---" },
                    { "DATA_CONSULTA": "14/01/2026", "HORA_CONSULTA": "---", "CLIENTE_CONSULTA": "SP-FCA/CONSORCIO LUIZA", "TELEFONE_CLIENTE": "---", "CIDADE_UF_CLIENTE": "---" },
                    { "DATA_CONSULTA": "13/01/2026", "HORA_CONSULTA": "---", "CLIENTE_CONSULTA": "CELEIRO SERVICOS FINANCEIROS LTDA", "TELEFONE_CLIENTE": "---", "CIDADE_UF_CLIENTE": "---" },
                    { "DATA_CONSULTA": "13/01/2026", "HORA_CONSULTA": "---", "CLIENTE_CONSULTA": "MESSER GASES LTDA", "TELEFONE_CLIENTE": "---", "CIDADE_UF_CLIENTE": "---" },
                    { "DATA_CONSULTA": "06/01/2026", "HORA_CONSULTA": "---", "CLIENTE_CONSULTA": "BANCO DAYCOVAL - VEICULOS", "TELEFONE_CLIENTE": "---", "CIDADE_UF_CLIENTE": "---" },
                    { "DATA_CONSULTA": "22/12/2025", "HORA_CONSULTA": "---", "CLIENTE_CONSULTA": "CELEIRO SERVICOS FINANCEIROS LTDA", "TELEFONE_CLIENTE": "---", "CIDADE_UF_CLIENTE": "---" },
                    { "DATA_CONSULTA": "18/12/2025", "HORA_CONSULTA": "---", "CLIENTE_CONSULTA": "CELEIRO SERVICOS FINANCEIROS LTDA", "TELEFONE_CLIENTE": "---", "CIDADE_UF_CLIENTE": "---" },
                    { "DATA_CONSULTA": "17/12/2025", "HORA_CONSULTA": "---", "CLIENTE_CONSULTA": "CELEIRO SERVICOS FINANCEIROS LTDA", "TELEFONE_CLIENTE": "---", "CIDADE_UF_CLIENTE": "---" },
                    { "DATA_CONSULTA": "17/12/2025", "HORA_CONSULTA": "---", "CLIENTE_CONSULTA": "MS-MUV/CANDIE S CALCADOS", "TELEFONE_CLIENTE": "---", "CIDADE_UF_CLIENTE": "---" },
                    { "DATA_CONSULTA": "17/12/2025", "HORA_CONSULTA": "---", "CLIENTE_CONSULTA": "IMPACTO FACTORING", "TELEFONE_CLIENTE": "---", "CIDADE_UF_CLIENTE": "---" },
                    { "DATA_CONSULTA": "11/02/2026", "HORA_CONSULTA": "---", "CLIENTE_CONSULTA": "SP-SNE/ANALISE DE CREDITO", "TELEFONE_CLIENTE": "---", "CIDADE_UF_CLIENTE": "---" },
                    { "DATA_CONSULTA": "10/02/2026", "HORA_CONSULTA": "---", "CLIENTE_CONSULTA": "SP-SNE/ANALISE DE CREDITO", "TELEFONE_CLIENTE": "---", "CIDADE_UF_CLIENTE": "---" },
                    { "DATA_CONSULTA": "10/02/2026", "HORA_CONSULTA": "---", "CLIENTE_CONSULTA": "CNP CONSORCIO SA ADMINISTRADORA CONS", "TELEFONE_CLIENTE": "---", "CIDADE_UF_CLIENTE": "---" },
                    { "DATA_CONSULTA": "31/01/2026", "HORA_CONSULTA": "---", "CLIENTE_CONSULTA": "MG-MBE/BRASIL CARD ADM DE CARTAO  CR", "TELEFONE_CLIENTE": "---", "CIDADE_UF_CLIENTE": "---" },
                    { "DATA_CONSULTA": "09/01/2026", "HORA_CONSULTA": "---", "CLIENTE_CONSULTA": "LOJAS POMPEIA - CAMAQUA", "TELEFONE_CLIENTE": "---", "CIDADE_UF_CLIENTE": "---" },
                    { "DATA_CONSULTA": "07/01/2026", "HORA_CONSULTA": "---", "CLIENTE_CONSULTA": "AFINZ INSTITUICAO DE PAGAMENTO SA", "TELEFONE_CLIENTE": "---", "CIDADE_UF_CLIENTE": "---" },
                    { "DATA_CONSULTA": "29/12/2025", "HORA_CONSULTA": "---", "CLIENTE_CONSULTA": "KOIN ADM CARTOES E MEIOS DE PAG", "TELEFONE_CLIENTE": "---", "CIDADE_UF_CLIENTE": "---" },
                    { "DATA_CONSULTA": "18/12/2025", "HORA_CONSULTA": "---", "CLIENTE_CONSULTA": "CHEQUE PRE COM TEC DA INF LTDA", "TELEFONE_CLIENTE": "---", "CIDADE_UF_CLIENTE": "---" },
                    { "DATA_CONSULTA": "12/12/2025", "HORA_CONSULTA": "---", "CLIENTE_CONSULTA": "TOL ADMINISTRADORA DE CARTOES DE CRE", "TELEFONE_CLIENTE": "---", "CIDADE_UF_CLIENTE": "---" },
                    { "DATA_CONSULTA": "09/12/2025", "HORA_CONSULTA": "---", "CLIENTE_CONSULTA": "TOL ADMINISTRADORA DE CARTOES DE CRE", "TELEFONE_CLIENTE": "---", "CIDADE_UF_CLIENTE": "---" },
                    { "DATA_CONSULTA": "09/12/2025", "HORA_CONSULTA": "---", "CLIENTE_CONSULTA": "MAGAZINE LUIZA - PACOTE CDC", "TELEFONE_CLIENTE": "---", "CIDADE_UF_CLIENTE": "---" },
                    { "DATA_CONSULTA": "03/12/2025", "HORA_CONSULTA": "---", "CLIENTE_CONSULTA": "CHEQUE PRE COM TEC DA INF LTDA", "TELEFONE_CLIENTE": "---", "CIDADE_UF_CLIENTE": "---" },
                    { "DATA_CONSULTA": "02/12/2025", "HORA_CONSULTA": "---", "CLIENTE_CONSULTA": "CHEQUE PRE COM TEC DA INF LTDA", "TELEFONE_CLIENTE": "---", "CIDADE_UF_CLIENTE": "---" }
                ]
            },
            "RENDA_PRESUMIDA": {
                "STATUS_RETORNO": {
                    "CODIGO": "1",
                    "DESCRICAO": "CONSULTA CONCLUIDA COM SUCESSO"
                },
                "DESCRICAO": "RENDA PRESUMIDA",
                "FAIXA": "DE R$ 3.001 ATE R$ 4.000",
                "RENDA_ANUAL": "",
                "MENSAGEM": "RENDA ESTIMADA A PARTIR DE INFORMACOES COMPORTAMENTAIS E CADASTRAIS DO CONSUMIDOR.",
                "DATA_REFERENCIA": "",
                "VALOR_PRESUMIDO": "",
                "DADOS_CADASTRO_POSITIVO": ""
            }
        },
        "VEICULAR": []
    }
}

Afirmações conferidas

Fatos sobre o repositório que uma rodada de portão já conferiu abrindo o arquivo. O recortar.py leva esta seção junto em qualquer parte, e o revisor da rodada seguinte pode tomá-los como dados em vez de reconferir tudo do zero.

Afirmação Onde na spec Conferida em

Duas regras, e a segunda é a que faz a primeira valer:

  1. Só entra o que um revisor conferiu — não o que você conferiu ao escrever. O ponto é aproveitar trabalho já pago por uma rodada, não abrir atalho para afirmar sem prova.
  2. Mexeu na frase da spec, a linha sai da tabela. A afirmação volta a ser conferível. Sem isso a tabela vira o oposto do portão: uma afirmação carimbada como verdadeira porque um dia foi.

Feature sem afirmação sobre o repositório não precisa da seção — apague-a.