# Registro de Bugs — Detalhamento item a item

> Complemento de `17-REGISTRO-DE-BUGS.md`, que traz o índice, a
> tabela-resumo, as regressões v5 → v6 e os itens não verificados.

---

### BUG-SEC-001 — Injeção de SQL por `$_GET['ano']` interpolado em `DB::select`
- **Severidade:** **Crítica** — leitura arbitrária de qualquer tabela do banco por visitante anônimo, sem autenticação. Confirmado em runtime com payloads não destrutivos.  |  **Onde:** `v6/app/Http/Controllers/Portal/LegislationController.php:56` · `v6/app/Http/Controllers/Portal/PublicationController.php:56` · `v6/app/Http/Controllers/Portal/AtasEPontosController.php:60` · `v6/app/Http/Controllers/Portal/ComiteDeInvestimentosController.php:55` · `v5/app/Http/Controllers/Portal/LegislationController.php:54` · `v5/app/Http/Controllers/Portal/PublicationController.php:52`
- **O que acontece:** o filtro de meses monta o SQL por interpolação da superglobal crua: `... extract(year from publication_date) = {$_GET['ano']} ...`. Não há binding, cast nem `clean_slug`. A validação do `$request->ano` existe **depois**, em outro bloco — não protege a query.  |  **Reproduzir:** `GET /legislacao/constituicao-federal?ano=%27` → `HTTP 500`, `SQLSTATE[42601]: unterminated quoted string`. `GET /legislacao/constituicao-federal?ano=2024%20OR%201%3D1` → `SQLSTATE[22P02]` (prova que o valor chegou ao planner como expressão).
- **Impacto:** `UNION SELECT` sobre a tabela `servidores` (nome, CPF) ou `users` (hash de senha) é viável; subqueries e `ORDER BY` para enumeração de colunas também. Ver `15-SEGURANCA-INJECAO-SQL.md` §5.  |  **Correção:** trocar por `DB::select($sql, [$category->id, (int) $request->ano])` e validar `ano` como `integer|min:1900|max:2100` no Form Request. Padrão de referência: `v6/app/Http/Controllers/Portal/RequerimentoController.php:100`.  |  **v7:** nenhum acesso a superglobal em controller; todo input passa por binding; teste de segurança por endpoint injetando `'` e `OR 1=1`, com assert de ausência de `SQLSTATE` na resposta.

### BUG-SEC-002 — Injeção de SQL por concatenação em `whereRaw`
- **Severidade:** **Crítica** — mesma natureza do BUG-SEC-001, explorável por anônimo em rota de busca.  |  **Onde:** `v6/app/Http/Controllers/Portal/BuscaDiariasController.php:33` (`.req_name`), `:38` (`.cpf`), `:41` (`.num_diaria`) · `v6/app/Http/Controllers/Portal/LegislationController.php:203` (`.termo`) · `v5/app/Http/Controllers/Portal/BuscaDiariasController.php:33`, `:38`, `:41`
- **O que acontece:** `whereRaw("translate(lower(name), '...') ilike '%" . $request->req_name . "%'")` concatena o valor cru. No caso do `cpf`, a validação `min:14` é **ortogonal** à injeção: exige comprimento, não formato — 14 caracteres terminando em apóstrofo passam na validação e quebram o SQL.  |  **Reproduzir:** `GET /requerimento-de-diarias?cpf=aaaaaaaaaaaaa%27` → `HTTP 500`, `SQLSTATE[42601]`. Com 13 caracteres a requisição é redirecionada (validação falha); com 14, o SQL quebra.
- **Impacto:** extração de dados pessoais de servidores (nome, CPF) via `UNION SELECT`.  |  **Correção:** `whereRaw("translate(lower(name), '...') ilike ?", ['%'.$request->req_name.'%'])`; para o CPF, validar com máscara de dígitos (`regex:/^\d{11}$/`).  |  **v7:** binding obrigatório; `clean_slug()` banido de qualquer caminho que toque SQL.

### BUG-SEC-003 — 80 controllers do CMS perdem `check.route.permission`
- **Severidade:** **Crítica** — a autorização do CMS é inexistente por omissão.  |  **Onde:** causa raiz em `v6/app/Http/Controllers/Prefecture/Controller.php:20` (e `v5/app/Http/Controllers/Prefecture/Controller.php:20`); caso canônico em `v6/app/Http/Controllers/Prefecture/SupplierController.php:30`; inventário de 54 controllers em `Prefecture\`, 9 em `Admin\` e 17 em `Portal\` (ver `16-AUTORIZACAO-ROTA-SEM-AUTENTICACAO.md` §3).
- **O que acontece:** `Prefecture\Controller::__construct()` é o **único** ponto que registra `$this->middleware('check.route.permission')`. 80 controllers sobrescrevem `__construct()` sem chamar `parent::__construct()` — o middleware nunca é registrado e nem `check.logged` protege a rota.  |  **Reproduzir:** `php artisan route:list` mostra apenas `web` nas rotas Write. `curl` anônimo a `POST /cms/fornecedores` cria fornecedor (confirmado em v5 e v6).
- **Impacto:** criação, alteração e exclusão de dados do CMS por qualquer visitante. O CSRF **não** protege: o token é vinculado à sessão anônima e qualquer página pública o entrega.  |  **Correção:** (a) aplicar `parent::__construct()` nos 80 controllers; (b) melhor — registrar o middleware no **grupo de rotas** do CMS (`v6/app/Http/Kernel.php`), nunca no construtor. Note que `'before' => 'csrf'` no `Route::group` do Laravel 5.5 **não** aplica middleware — o certo é `'middleware' => ['web', 'cms']`.  |  **v7:** autorização por padrão no grupo de rotas; `Gate`/Policies no lugar de `has_access()`; teste automatizado que enumere todas as rotas de escrita e exija 403 sem autenticação.

### BUG-SEC-004 — Rotas de escrita do CMS sem `check.logged`
- **Severidade:** **Crítica** — escrita sem login, confirmada em runtime.  |  **Onde:** `v6/routes/basepro.php:352` (`supplier.update`), `:354` (`supplier.store`, `supplier.destroy`) · `v6/routes/basepro.php:201` (e-vacina, `Route::resource`) · `v6/routes/basepro.php:155` (`ajax.survey.deleteByLote`) · `v6/routes/basepro.php:173` (`categoria-conteudo`)
- **O que acontece:** o grupo `/cms` herda `web` (CSRF + sessão) mas não exige login em várias rotas de escrita. Combinado com o BUG-SEC-003, a sessão nem é verificada.  |  **Reproduzir:** `POST /cms/ajax/assuntos/deleteAll` sem cookie de sessão → `HTTP 200 {"success":true,...}` e linhas apagadas de `ombudsman_subjects`. Mesma resposta em setores, respostas prontas, formas de resposta, origens e tipos de chamado.
- **Impacto:** exclusão em massa de dados da Ouvidoria e do cadastro de fornecedores; manipulação do `fornecedor_ativo` exibido no portal.  |  **Correção:** `->middleware('check.logged')` em todas as rotas de escrita + `Policy` por recurso; guarda interna `has_access()` nos controllers destrutivos.  |  **v7:** grupo `cms` com `auth` + `can:` por rota.

### BUG-SEC-005 — `CheckRoutePermission` libera qualquer rota `*.create/store/update/destroy`
- **Severidade:** **Crítica** — mesmo autenticado, um usuário de baixo privilégio escreve em qualquer recurso.  |  **Onde:** `v6/app/Http/Middleware/CheckRoutePermission.php:75` (o `array_where`) e `:59` (o `getName()`)
- **O que acontece:** `$accessArr = explode('.', $access)` quebra o nome da rota; o `array_where` compara o **valor** do segmento contra os **valores** de `$resourceRoutes` (`['update','store','destroy']`). Para `supplier.update`, `$accessArr = ['supplier','update']` e `'update'` casa — a rota é liberada. O comentário do código diz "segmento que contém a permissão", mas a comparação é por valor, não por chave.  |  **Reproduzir:** logar como usuário do papel `Básico` e executar `PUT /cms/fornecedores/atualizar/{id}` — o middleware retorna `true`.
- **Impacto:** a autorização do CMS é inefetiva também para usuários logados; papéis `Básico`, `Licitações` e `Lai` escrevem em todos os módulos.  |  **Correção:** comparar a **chave** (`$key`) contra os segmentos, não o valor; e nunca liberar `create/store/update/destroy` por esse caminho.  |  **v7:** Policies do Laravel (`Gate::define` / `can:`) — a comparação por nome de rota é frágil e não se traduz.

### BUG-SEC-006 — Chave da API Gemini versionada em texto claro
- **Severidade:** **Crítica** — credencial de serviço pago exposta no histórico do Git, nas duas versões.  |  **Onde:** `v5/database/seeds/AddToConfigurationsGeminiApiKeyValue.php:16` · `v6/database/seeds/AddToConfigurationsGeminiApiKeyValue.php:18`
- **O que acontece:** o literal da chave está em um seeder versionado. Na v6 o seeder só grava quando o valor está vazio (melhoria parcial), mas o literal continua no repositório e no histórico.
- **Impacto:** a chave deve ser considerada comprometida; custo de API e mascaramento LGPD sob controle de terceiros.  |  **Correção:** rotacionar a chave **antes** de qualquer deploy e mover o valor para `.env`/vault, com migration que extraia o valor de `configurations.gemini_api_key`.  |  **v7:** nenhum segredo em seed ou migration; cofre de segredos com rotação documentada.

### BUG-SEC-007 — Rota pública de geração de conteúdo por IA
- **Severidade:** **Crítica** — consumo de recurso pago por anônimo.  |  **Onde:** `v5/routes/web.php:203` · `v5/app/Http/Requests/GeminiPromptRequest.php:16` (`authorize()` retorna `true`)
- **O que acontece:** `Route::post('/preencher-formulario', 'Prefecture\GeminiController@preencher')->name('gemini.ia')` fica no grupo público de `web.php`, sem `check.logged`, sem `check.route.permission` e sem `throttle`.
- **Impacto:** qualquer visitante anônimo consome a cota da prefeitura; sem `throttle` não há limite de taxa.  |  **Correção:** mover a rota para o CMS autenticado e aplicar `->middleware(['auth','throttle:10,1'])`.  |  **v7:** IA só dentro do CMS autenticado, com limite de taxa e registro de auditoria.

### BUG-SEC-008 — Rotas do Drive/galeria respondem sem autenticação
- **Severidade:** **Crítica** — leitura e escrita do acervo de arquivos sem login.  |  **Onde:** `v6/app/Http/Controllers/Prefecture/ArchiveController.php:33` · `v5/app/Http/Controllers/Prefecture/ArchiveController.php:38` · `v5/app/Http/Controllers/Prefecture/ArchiveCategoryController.php:16`
- **O que acontece:** os dois controllers sobrescrevem `__construct()` sem `parent::__construct()` (o mesmo mecanismo do BUG-SEC-003). `GET /cms/drive/categorias` responde 200 e `POST /cms/drive/cadastrar/categoria` cria categoria sem sessão; na v6 as rotas `cms/galeria/*` respondem sem sessão.
- **Impacto:** criação de categorias, upload e listagem do acervo por anônimo.  |  **Correção:** mesma de BUG-SEC-003 — middleware no grupo de rotas.  |  **v7:** autorização por grupo; teste de superfície sobre todas as rotas `cms/*`.

### BUG-SEC-009 — Senha do certificado passada a `shell_exec` sem escape
- **Severidade:** **Crítica** — execução de comando com entrada do usuário.  |  **Onde:** `v6/app/Http/Controllers/Prefecture/DiarioCertificadosController.php:67` · `v5/app/Http/Controllers/Prefecture/DiarioCertificadosController.php:78`
- **O que acontece:** a senha do certificado A1 é interpolada em `shell_exec("openssl pkcs12 -in $nomeCertPFX ... -passin pass:$senha")` sem `escapeshellarg()`. As demais variáveis do comando também não são escapadas.  |  **Reproduzir:** senha contendo `;` ou `$(...)` no cadastro do certificado vira comando executado pelo servidor.
- **Impacto:** execução remota de código no servidor, com as permissões do usuário do PHP.  |  **Correção:** `escapeshellarg()` em **todos** os argumentos do `openssl`; preferir a biblioteca PHP OpenSSL (`openssl_pkcs12_read`) ao `shell_exec`.  |  **v7:** nunca invocar `shell_exec`/`exec` com dado de usuário; usar a extensão OpenSSL.

### BUG-SEC-010 — `clean_slug()` usado como sanitização de SQL
- **Severidade:** **Alta** — proteção inconsistente e insuficiente; é a razão pela qual os pontos "não exploráveis" escapam hoje.  |  **Onde:** `v6/app/Helpers/functions.php:823` (definição) · aplicação inconsistente em `v6/app/Http/Controllers/Portal/LegislationController.php:75` e `:127` (com `clean_slug`) vs. `:203` (sem).
- **O que acontece:** `clean_slug()` remove acentos e caracteres fora de `[A-Za-z0-9. ]`. Aplicado a SQL, funciona por acaso: não impede `%` e `_` (metacaracteres de `LIKE`) e depende de estar no lugar certo — o mesmo controller usa a sanitização em duas linhas e não em outra.
- **Impacto:** proteção frágil; qualquer refatoração que mova um parâmetro reintroduz a injeção.  |  **Correção:** remover `clean_slug()` de qualquer caminho que toque SQL; binding resolve o problema.  |  **v7:** `clean_slug()` só para normalização de exibição.

### BUG-SEC-011 — Chave da API em log de atividades e na query string
- **Severidade:** **Alta** — credencial exposta em log, histórico de proxy e histórico do navegador.  |  **Onde:** `v5/app/Http/Controllers/Prefecture/ConfigurationController.php:200` e `:315` (gravam o valor no `activity_log.properties`) · `v5/app/Http/Controllers/Prefecture/GeminiController.php:31` e `v5/app/Http/Traits/DataMaskTrait.php:241` (`?key=` na URL)
- **O que acontece:** `ConfigurationController` grava o valor integral da configuração no `activity_log.properties`; `GeminiController` e `DataMaskTrait` concatenam a chave à URL da API externa (`?key=`).
- **Impacto:** o `activity_log` vira cofre de segredos; a URL com a chave fica em log de acesso, proxy e histórico.  |  **Correção:** filtrar chaves sensíveis no `withProperty`; mover a chave para o corpo/header da requisição à API externa.  |  **v7:** allowlist de campos auditáveis; segredos nunca entram em log nem em URL.  |  
### BUG-SEC-012 — Mensagem de exceção devolvida ao cliente
- **Severidade:** **Alta** — stack trace, caminho de arquivo e URL com credencial expostos.  |  **Onde:** `v5/app/Http/Controllers/Prefecture/GeminiController.php:84` · `v6/app/Exceptions/Handler.php:51`
- **O que acontece:** o handler concatena `$exception` na mensagem JSON enviada ao navegador; no controller do Gemini a mensagem de erro do Guzzle pode conter a URL completa com a chave.
- **Impacto:** O Guzzle devolve a URL completa da chamada, com a chave; o `Handler` anexa a exceção (com stack trace, caminho de arquivo e DSN do banco) à resposta JSON do navegador.  |  **Correção:** mensagem genérica ao cliente; detalhe apenas no log (`Log::error($e)`).  |  **v7:** handler único com `ProblemDetails`/resposta JSON padronizada e sem vazamento.
### BUG-SEC-013 — `dd()` em produção vaza stack trace
- **Severidade:** **Alta** — 7 pontos que matam a requisição e expõem o dump.  |  **Onde:** `v6/app/Repositories/Repository.php:67` (crítico — `catch (\Throwable)` no `upInsert()` de **todos** os repositórios) · `v6/app/Http/Controllers/Prefecture/ContentController.php:1294` e `:1303` · `v6/app/Http/Controllers/Prefecture/OmbudsmanAnswerController.php:230` e `:234` · `v6/app/Http/Controllers/Prefecture/OmbudsmanRequestController.php:220`
- **O que acontece:** `dd()` faz dump e `exit(1)`; o `try/catch` dos controllers nunca é alcançado — o operador vê o dump em vez da mensagem amigável.
- **Impacto:** vazamento de stack trace e de dados de conexão; perda da mensagem de erro tratamento  |  **Correção:** `report($e)` + `return false` no repositório; nos controllers, exceção capturada e mensagem amigável.  |  **v7:** nada de `dd()`/`dump()` em código de produção — regra de lint.

### BUG-SEC-014 — Exceções internas exibidas ao operador
- **Severidade:** **Alta** — mensagem de banco/arquivo exposta na tela do CMS.  |  **Onde:** `v5/app/Http/Controllers/Prefecture/EstagiarioController.php:47` e `:89` · `v6/app/Http/Controllers/Prefecture/ConfigurationController.php:263`
- **O que acontece:** O `catch` do controller formata `$e->getMessage()` e a exibe na tela do CMS. A mensagem de exceção do PostgreSQL inclui nome de tabela, coluna e fragmento da query.
- **Impacto:** O operador vê detalhes internos de banco e de arquivo — que também podem ser capturados por screenshot em vídeo de treinamento.  |  **Impacto na v7:** Mensagem de erro ao operador sempre genérica e versionada; detalhe apenas no log de aplicação.  |  **Correção:** `report($e)` e mensagem genérica; detalhe apenas no log.
### BUG-SEC-015 — Datatable de configurações expõe segredos
- **Severidade:** **Alta** — qualquer operador com acesso à tela lê **todas** as chaves de `configurations`, incluindo a chave da API Gemini.  |  **Onde:** `v6/app/Http/Controllers/Prefecture/DatatableController.php:384` (`get_configuracoes()` → `DB::table("configuracoes")->get()`) · view `v6/database/migrations/2023_04_17_144615_create_view_to_configuracoes.php:17` (não filtra por categoria nem mascara `value`)
- **O que acontece:** `get_configuracoes()` faz `DB::table("configuracoes")->get()` — sem filtro por `category_configurations_id` e sem mascarar `value`; a view `configuracoes` também não filtra por categoria e expõe `value` indexado por `row_number()`.
- **Impacto:** Qualquer operador com acesso à tela lê a chave da API Gemini, credenciais de e-mail e qualquer outro segredo cadastrado em `configurations`.  |  **Correção:** filtrar por `category_configurations_id` da tela; mascarar colunas sensíveis (`value` → `•••`).  |  **v7:** tela de configurações só exibe a categoria aberta; segredos com `type = password` nunca serializados.
### BUG-SEC-016 — XSS armazenado via `{!! !!}`
- **Severidade:** **Alta** — HTML do Summernote executado no navegador do cidadão.  |  **Onde:** `v6/resources/views/portal/layouts/modelo1/home/aviso.blade.php:13` · `v6/resources/views/portal/layouts/modelo1/legislacao/view.blade.php:18` · `v6/resources/views/portal/layouts/modelo1/informacoes-instituicao/acesso-rapido/perguntas-frequentes.blade.php:36` · `v6/resources/views/portal/layouts/barra-privacidade/barra-privacidade.blade.php:14` · `v6/resources/views/portal/layouts/modelo1/licitacao/licitacao-antiga/show.blade.php`
- **O que acontece:** o conteúdo HTML do editor rico é impresso sem escape nem sanitização no servidor.
- **Impacto:** um operador com permissão de escrita (ou um XSS menor) executa script no navegador de todos os visitantes do portal.  |  **Correção:** sanitizar com allowlist na **entrada** (HTML Purifier) e nunca confiar em `{!! !!}` para conteúdo de usuário.  |  **v7:** allowlist de tags; `<x-html>` com sanitização centralizada.

### BUG-SEC-017 — SQL injection no filtro público de contratos e editais (v5)
- **Severidade:** **Alta** — concatenação direta do parâmetro de URL no SQL, em rota pública.  |  **Onde:** `v5/app/Http/Controllers/Portal/WorksController.php:33` (`getNonAccentedILikeSearch()`) e `:104` (uso em `?contract=`/`?edital=`)
- **O que acontece:** `like '%$search%'` montado por concatenação; `prepareStringToSearch()` não remove aspas simples.
- **Impacto:** O filtro público de contratos e editais do portal de Obras permite `UNION SELECT` sobre tabelas com CPF e dados de servidores.  |  **Correção:** binding (`whereRaw($sql, [$termo])`) e o `TRANSLATE` parametrizado.  |  **v7:** nenhum SQL montado por concatenação de string PHP.
### BUG-SEC-018 — Termo do usuário concatenado no SQL na API Swagger
- **Severidade:** **Alta** — mesma classe do BUG-SEC-017, agora na API pública de órgãos.  |  **Onde:** `v6/app/Http/Controllers/Swagger/V1/Portal/Controller.php:109` (`caseInsensitiveAccentFreeSearch`)
- **O que acontece:** `caseInsensitiveAccentFreeSearch()` monta o `TRANSLATE(...)` concatenando o termo recebido na query string, sem binding nem escape de apóstrofo.
- **Impacto:** A API pública de órgãos permite leitura arbitrária do banco por `UNION SELECT`.  |  **Impacto na v7:** Binding em toda a camada de API; teste de segurança por endpoint Swagger.  |  **Correção:** binding + `ilike` com `?`.
### BUG-SEC-019 — Cartão SUS completo em texto claro no log
- **Severidade:** **Alta** — dado sensível de saúde exposto em log e em arquivo exportado.  |  **Onde:** `v5/app/Http/Filter/FilterFilaEsperaSus.php:31` · `v5/resources/views/exports/fila-espera-sus.blade.php:44`
- **O que acontece:** `FilterFilaEsperaSus` registra em log o cartão SUS completo do titular (número, órgão emissor, validade) e a planilha exportada ao operador traz o mesmo conteúdo sem máscara.
- **Impacto:** o cartão SUS é pesquisável e fica gravado em claro no log de aplicação e na planilha exportada ao operador.  |  **Correção:** mascarar no log; no export, aplicar a mesma máscara LGPD já existente no módulo de Ouvidoria.  |  **v7:** mascaramento em toda saída que não seja a ficha de atendimento.  |  
### BUG-SEC-020 — `nameFile` do usuário não sanitizado
- **Severidade:** **Alta** — o nome enviado pelo navegador é concatenado ao caminho de gravação.  |  **Onde:** `v5/app/Http/Controllers/Prefecture/ArchiveController.php:123` e `:125`
- **O que acontece:** o Laravel 5.5 normaliza o path com `trim($path.'/'.$name, '/')` mas **não resolve `..`**.
- **Impacto:** Um `nameFile` com `..` pode gravar fora do diretório de uploads; um nome não sanitizado também quebra o `storeAs`.  |  **Impacto na v7:** Nome de arquivo gerado no servidor; o nome original do usuário fica só como metadado.  |  **Correção:** gerar o nome no servidor (`Str::random()`) e preservar o nome original apenas como metadado.
### BUG-SEC-021 — Upload sem validação de tipo, MIME ou tamanho
- **Severidade:** **Alta** — aceita qualquer arquivo; o `acceptFiles` do JS é contornável.  |  **Onde:** `v6/app/Http/Controllers/Prefecture/ArchiveController.php:86` (upload) · `v6/app/Http/Controllers/Prefecture/ServiceLetter/ServiceLetterController.php` (anexo da Carta de Serviços) · `v6/app/Http/Controllers/Prefecture/CalendarEvent/CalendarEventController.php:366` (endpoint legado que aceita 25 extensões)
- **O que acontece:** O controller aceita o arquivo sem `mimes:`, sem `max:` e sem verificação da extensão real pelo conteúdo. A única restrição é o `acceptFiles` do plugin JS, que é contornável por requisição direta.
- **Impacto:** Upload de executável ou de arquivo que o servidor processa como imagem; consumo de disco sem limite.  |  **Impacto na v7:** Form Request com `mimes:` e `max:`; verificação da extensão real pelo `finfo` no upload.  |  **Correção:** Form Request com `mimes:`, `max:` e verificação da extensão real pelo conteúdo do arquivo.
### BUG-DATA-001 — `OmbudsmanRequestController@update` altera sempre o primeiro registro
- **Severidade:** **Crítica** — perda silenciosa de dados de manifestações em produção.  |  **Onde:** `v6/app/Http/Controllers/Prefecture/OmbudsmanRequestController.php:50` (`OmbudsmanRequest::first()`) e `:51` (`->update($request->all())`)
- **O que acontece:** `update()` não filtra por `id` e usa `$request->all()` sem validação. A rota é gerada por `Route::resource`, então o `{id}` da URL é ignorado.  |  **Reproduzir:** `PUT /cms/chamados-da-ouvidoria/{id}` (qualquer id) sobrescreve a **primeira** manifestação da tabela — inclusive `protocol`, `status_id`, `ip` e `token`.
- **Impacto:** dados de citizens corrompidos sem erro visível; o histórico de-protocolo fica corrompido.  |  **Correção:** `OmbudsmanRequest::findOrFail($id)`, Form Request com `allowed` explícito e `authorize()`.  |  **v7:** `Route::modelBinding` + Policy; nunca `$request->all()` em update.

### BUG-DATA-002 — Exclusão de anexo apaga o arquivo errado
- **Severidade:** **Crítica** — apaga o anexo de outra licitação.  |  **Onde:** `v6/app/Http/Controllers/Prefecture/Bidding/BiddingController.php:292` (`find($request->get('licitacao'))`), `:293` e `:295`
- **O que acontece:** `$archive` é buscado com o **id da licitação** em vez do `archive_id`. A linha correta some do banco, mas o binário removido do disco é o de `archives.id == biddings.id`.
- **Impacto:** anexo some da listagem e continua ocupando storage — ou o anexo errado é removido.  |  **Correção:** buscar por `$request->get('archive_id')` e validar a posse do anexo pela licitação.  |  **Impacto na v7:** Exclusão de anexo por `archive_id`, validando a posse do anexo pelo recurso pai; job de limpeza dos binários órfãos.
### BUG-DATA-003 — Coluna `contracts.agent` removida na v6
- **Severidade:** **Alta** — perda de schema: o filtro por responsável do contrato existia na v5 e não existe na v6.  |  **Onde:** migration **ausente na v6**: `v5/database/migrations/2025_06_07_004403_add_agent_column_to_contracts_table.php:12`; uso na v5 em `v5/app/Http/Controllers/Portal/ContractController.php:66`, `:92` e `v5/app/Http/Model/Prefecture/Contract.php:33`; ausente em `v6/app/Http/Model/Prefecture/Contract.php:24`
- **O que acontece:** A migration `2025_06_07_004403_add_agent_column_to_contracts_table.php` existe na v5 e não na v6; a coluna é usada no filtro do portal e gravada pelo CMS na v5, e o model da v6 não a declara.
- **Impacto:** o portal da v6 perdeu o campo "Agente/Responsável" e o filtro correspondente; dados existentes na v5 não têm destino na v6.  |  **Correção:** migration idempotente na v7 recriando `contracts.agent` + migration de backfill a partir da base v5, se o negócio confirmar o uso.  |  **v7:** decidir com o negócio se o campo volta; se voltar, é coluna com FK para `users`, não texto livre.  |  
### BUG-DATA-004 — `archive_categories` e `archives.category_id` removidas na v6
- **Severidade:** **Alta** — perda de schema junto com a funcionalidade de categorização.  |  **Onde:** `v5/database/migrations/2025_10_16_105153_create_archive_categories_table.php` e `v5/database/migrations/2025_10_16_105256_add_category_into_archives.php` — **ambos ausentes na v6**; controller `v5/app/Http/Controllers/Prefecture/ArchiveCategoryController.php:16` ausente na v6.
- **O que acontece:** A v5 tem a tabela `archive_categories`, a coluna `archives.category_id`, o model e o controller; a v6 não tem nenhum deles. As categorias criadas na v5 ficam sem destino de migração.
- **Impacto:** a v6 não tem como categorizar o acervo; a coluna `archives.category_id` da v5 não tem destino.  |  **Correção:** recriar a tabela e a coluna na v7 com FK real, e migrar os valores.  |  **v7:** categoria de arquivo como entidade de primeira classe com CRUD completo (a v5 só tinha `getCategories` e `store`).  |  
### BUG-DATA-005 — Chave privada do certificado em texto claro no banco
- **Severidade:** **Alta** — material criptográfico exposto.  |  **Onde:** `v6/app/Http/Controllers/Prefecture/DiarioCertificadosController.php:58` (caminho) e `:99` (gravação do `cert`) · model `v6/app/Http/Model/Prefecture/DiarioCerticado.php:14`
- **O que acontece:** `DiarioCertificadosController` extrai a chave privada do `.pfx` e a grava no campo `cert` do model `DiarioCerticado`, em texto claro.
- **Impacto:** Qualquer acesso ao banco lê a chave privada do certificado de assinatura digital do Diário Oficial — permite forjar assinatura.  |  **Impacto na v7:** Chave privada nunca no banco; `.pfx` em storage privado cifrado, carregado só no momento da assinatura.  |  **Correção:** armazenar o `.pfx` cifrado (envelope) ou apenas em storage privado com URL assinada de uso único.
### BUG-DATA-006 — `generation_status = 'pending'` sem consumidor
- **Severidade:** **Alta** — a coluna sugere um fluxo assíncrono que não existe.  |  **Onde:** `v6/app/Http/Controllers/Prefecture/DiarioController.php:259` (gravação) · `v6/app/Http/Controllers/Portal/DiarioController.php:72` (leitura — não há consumidor)
- **O que acontece:** `DiarioController` grava `generation_status = 'pending'` ao enfileirar; o `Portal\DiarioController` apenas consulta o valor para decidir a espera — não existe consumidor que processe o estado e o leve a `processed`.
- **Impacto:** o Diário Oficial marca o documento como pendente e nada jamais o processa; o operador não sabe se a geração terminou.  |  **Correção:** Ou implementar o worker que consome `pending` e transiciona o estado, ou remover a coluna e gravar o PDF de forma síncrona.  |  **Impacto na v7:** Coluna `status` com enum (`pending|processing|processed|failed`) e job de fila com retry; o portal lê o estado sem adivinhar.
### BUG-DATA-007 — Voto aceito em enquete encerrada ou em rascunho
- **Severidade:** **Alta** — integridade do resultado da enquete.  |  **Onde:** `v6/app/Http/Controllers/Portal/SurveyController.php:91`
- **O que acontece:** `sendSurvey()` grava o voto sem verificar o `status` nem a janela de vigência da enquete: um rascunho ou uma enquete já encerrada aceita voto.
- **Impacto:** Resultado da enquete manipulável — a enquete deixa de ser dado confiável para decisão.  |  **Impacto na v7:** Regra de domínio `Survey::isOpen()` consultada no servidor antes de registrar o voto.  |  **Correção:** validar `status` e a janela de vigência no servidor antes de registrar o voto.
### BUG-DATA-008 — Sem unicidade — voto duplicado aceito indefinidamente
- **Severidade:** **Alta** — um mesmo visitante altera o resultado à vontade.  |  **Onde:** `v6/database/migrations/2022_01_17_100753_create_survey_results_table.php:17`
- **O que acontece:** A migration `create_survey_results_table` não cria índice único em `(survey_id, voter)`; o voter é apenas o IP e o browser, sem expiração.
- **Impacto:** O mesmo visitante pode votar infinitas vezes e alterar o resultado da enquete.  |  **Impacto na v7:** Voto único por identidade declarada (CPF opcional), com `unique` no banco.  |  **Correção:** índice único em `(survey_id, voter_fingerprint)` ou limite por IP/sessão com expiração.
### BUG-DATA-009 — Voto aceito com `survey_answer_id` de outra enquete
- **Severidade:** **Alta** — inconsistência referencial entre enquete e resposta.  |  **Onde:** `v6/app/Http/Controllers/Portal/SurveyController.php:97`
- **O que acontece:** `sendSurvey()` valida apenas que `survey_answer_id` foi enviado; não confere se a resposta pertence à enquete do voto.
- **Impacto:** Voto registrado com resposta de outra enquete — o resultado fica inconsistente.  |  **Impacto na v7:** FK composta entre `survey_results`, `survey_answers` e `surveys`.  |  **Correção:** validar que a resposta pertence à enquete do voto (FK composta).
### BUG-DATA-010 — `published_at` gravado em formato de 12 horas
- **Severidade:** **Alta** — ordenação e comparação por data ficam incorretas.  |  **Onde:** `v6/app/Http/Model/Prefecture/DynamicForms/Questionnaire.php:43` (format `h:i:s A`)
- **O que acontece:** `Questionnaire` grava `published_at` com `format('Y-m-d h:i:s')` — o `h` é a hora em 12 horas, sem AM/PM; a ordenação por data fica errada a partir das 12:00.
- **Impacto:** Formulários publicados no período da tarde aparecem antes dos da manhã (e vice-versa).  |  **Impacto na v7:** Cast `datetime` no model; `timestampTz` com timezone explícito.  |  **Correção:** gravar `Y-m-d H:i:s` (ou `timestampTz`).
### BUG-DATA-011 — `required_if` aponta para campo inexistente
- **Severidade:** **Alta** — a obrigatoriedade condicional nunca é exercitada.  |  **Onde:** `v6/app/Http/Requests/Prefecture/DynamicForms/Questionnaire/SaveRequest.php:56` (referência a `style`, o campo real é `ask_type_id`) e chave duplicada no array de regras.
- **O que acontece:** A regra `required_if:categories.*.asks.*.style,...` referencia `style`, campo que não existe — o correto é `ask_type_id`; além disso a chave `style` está duplicada no array de regras.
- **Impacto:** A obrigatoriedade condicional nunca é exercitada: o formulário salva incompleto.  |  **Impacto na v7:** Regras de validação por tipo de pergunta, derivadas da configuração do formulário.  |  **Correção:** corrigir o nome do campo e remover a chave duplicada.
### BUG-DATA-012 — `SaveRequest` sobrescreve o `id` do formulário com `null`
- **Severidade:** **Alta** — a validação de existência nunca é exercitada e o id do formulário é descartado.  |  **Onde:** `v5/app/Http/Requests/Prefecture/Works/SaveRequest.php:80` (`'id' => $this->route('obra')`) — o parâmetro de rota do CMS é `{id}`, não `{obra}`
- **O que acontece:** `prepareForValidation()` faz `'id' => $this->route('obra')`, mas o parâmetro de rota do CMS é `{id}` — o `merge()` sobrescreve o id enviado pelo formulário com `null`, tornando inerte a regra `'id' => ['nullable','exists:works,id']`.
- **Impacto:** A validação de existência nunca dispara; um id inexistente passa pela validação.  |  **Impacto na v7:** Form Request com `route()` explícito e `Rule::exists()`; `FormRequest` tipado por rota.  |  **Correção:** `$this->route('id')` (ou `route()->parameter('id')`).
### BUG-DATA-013 — `scopeByStatusNoticias` retorna dentro do `foreach`
- **Severidade:** **Alta** — o filtro de notícias publicadas examina apenas o primeiro registro do lote.  |  **Onde:** `v6/app/Http/Model/Prefecture/Content.php:145`
- **O que acontece:** `scopeByStatusNoticias` materializa o lote com `$query->get()` e só então decide o filtro, com `return` dentro do `foreach` — o escopo é interrompido no primeiro registro.
- **Impacto:** O filtro de notícias publicadas examina só o primeiro item do lote; a listagem fica inconsistente conforme o tamanho do lote.  |  **Impacto na v7:** Scopes puros, sem execução de query, com testes unitários por scope.  |  **Correção:** `->where('status', true)->whereNotNull('publication_date')` no próprio scope, sem materializar o lote.
### BUG-DATA-014 — Escopo de categoria grava filtro na sessão
- **Severidade:** **Alta** — o cidadão vê a categoria errada conforme a navegação prévia.  |  **Onde:** `v6/app/Http/Model/Prefecture/Content.php:216` (`session(['categorySlugSession' => $category])`) · limpeza só em `v6/app/Http/Controllers/Portal/NewsController.php:116`
- **O que acontece:** `scopeByCategoryContentNews` grava `session(['categorySlugSession' => $category])` e usa o valor da sessão dentro do `whereHas`; apenas `indexContentsAjax` faz `session()->pull('categorySlugSession')`.
- **Impacto:** O cidadão que navega por outra rota herda o filtro da requisição anterior e vê a categoria errada.  |  **Impacto na v7:** Scope puro com filtro por argumento; nenhum estado de filtro em sessão.  |  **Correção:** passar a categoria como argumento do scope, sem estado em sessão.
### BUG-DATA-015 — `update()` cria obra nova quando o id da URL não existe
- **Severidade:** **Alta** — duplicação silenciosa de registro.  |  **Onde:** `v5/app/Http/Controllers/Prefecture/Works/WorkController.php:213` (`Work::query()->updateOrCreate(['id' => $id], ...)` com `$id = null`)
- **O que acontece:** `WorkController@update` faz `Work::query()->updateOrCreate(['id' => $id], ...)` com `$id` vindo da rota; se o id não existir, o `updateOrCreate` cria uma nova obra em vez de falhar.
- **Impacto:** Registro duplicado, com id diferente do da URL — a listagem pública passa a exibir a mesma obra duas vezes.  |  **Impacto na v7:** `findOrFail()` em toda rota de edição; 404 em recurso inexistente.  |  **Correção:** `findOrFail($id)` no update.
### BUG-FUNC-001 — Classe `ServiceLetterPdfService` não existe
- **Severidade:** **Crítica** — a funcionalidade "Gerar PDF" da Carta de Serviços está quebrada.  |  **Onde:** `v6/app/Http/Controllers/Prefecture/ServiceLetter/ServiceLetterPdfController.php:62` (`new ServiceLetterPdfService()`) · import em `:15` · `v6/app/Services/` contém apenas `PortalTransparenciaService.php`, `FPdfService.php` e as pastas `Pdf/` e `Dashboard/`
- **O que acontece:** o `Error` (classe não encontrada) **não** é capturado pelo `catch (Exception $e)` — é `Error`, não `Exception` — logo resulta em 500 sem tratamento.
- **Impacto:** o gerador e o download de PDF do portal da Carta de Serviços estão indisponíveis.  |  **Correção:** implementar `ServiceLetterPdfService::generate()` reaproveitando `v6/app/Services/FPdfService.php`; e capturar `Throwable`, não `Exception`.  |  **Impacto na v7:** todo serviço usado por controller é uma classe com DI; nenhum `new` de classe ausente; captura de `Throwable` no tratamento de erro.

### BUG-FUNC-002 — `Route::resource` aponta para métodos inexistentes
- **Severidade:** **Crítica** — rotas que respondem 500 ao serem acessadas.  |  **Onde:** `v6/routes/basepro.php:76` (`changelog.show` → `Admin\ChangeLogController@show`, inexistente) · `v6/routes/basepro.php:71` (`Route::resource('log')` → `ActivityLogController`, 6 ações inexistentes) · `v6/routes/basepro.php:201` (`Route::resource('e-vacina')` — 4 das 7 rotas sem método) · `v6/routes/basepro.php:171` (rotas de menu item) · `v6/routes/basepro.php:184` (`Route::resource('licitacao')` gera `show` inexistente) · `v6/routes/basepro.php:202` (`agenda-evento.show`) · `v6/routes/basepro.php:203` (`categoria-agenda-evento.create`/`.show`)
- **O que acontece:** `Route::resource` gera automaticamente as rotas `index/create/store/show/edit/update/destroy`; vários controllers não implementam todas essas ações, e a rota resultante aponta para um método inexistente.
- **Impacto:** HTTP 500 ao abrir as telas correspondentes no CMS — o operador não consegue acessar a funcionalidade.  |  **Impacto na v7:** Rotas declaradas uma a uma; teste que chama cada rota e exige status abaixo de 500.  |  **Correção:** implementar os métodos faltantes ou remover as rotas; na v7, declarar rotas explicitamente em vez de `Route::resource` quando a ação não existe.  |  **v7:** rotas geradas apenas por convenção que exista de fato no controller.
### BUG-FUNC-003 a BUG-FUNC-012 — Módulos removidos na v6 (regressão funcional)
- **Severidade:** **Crítica** — 8 módulos inteiros desapareceram; ver §4 (Regressões v5 → v6) para o inventário e as evidências.  |  **Onde:** ver a tabela em §4.
- **O que acontece:** obras, obras-paralisadas, concursos-pss, estagiarios, fila-espera-sus, licitantes-sancionados, terceirizados, relatorio-estatistico-ouvidoria e o fluxo de geração do Gemini não têm controllers, models, requests, migrations, rotas, views, seeds de menu nem recursos na v6. Nenhum ADR, ticket ou changelog no repositório justifica a remoção.
- **Impacto:** o município perde a funcionalidade; a v7 precisa **reconstruir**, não migrar.  |  **Correção:** reconstruir cada módulo a partir do dossiê da v5, que é a única especificação funcional disponível.  |  **Impacto na v7:** cada módulo reconstruído vira um resource com as 4 permissões, testes de feature próprios e migração de dados idempotente.

### BUG-FUNC-013 — Erro 500 quando o slug não existe
- **Severidade:** **Alta** — erro fatal em vez de página de "conteúdo não encontrado"; afeta 5 módulos com o mesmo padrão.  |  **Onde:** `v6/app/Http/Controllers/Portal/QuickAccessController.php:49` (acessa `$contents->id` **antes** do teste `if (!$contents)` em `:53`) · `v6/app/Http/Controllers/Portal/CalendarEventController.php:74` · `v6/app/Http/Controllers/Portal/ComiteDeInvestimentosController.php:17` e `:27` · `v6/app/Http/Controllers/Portal/AtasEPontosController.php:28` · `v6/app/Http/Controllers/Portal/PublicationController.php:157`
- **O que acontece:** Em 5 controllers o acesso a propriedade do model (`->id`, `->slug`) acontece **antes** do teste de existência, tornando inalcançável a view de conteúdo não encontrado.
- **Impacto:** Qualquer URL com slug inexistente devolve 500 em vez de 404 — erro genérico que impede o usuário de distinguir inexistência de servidor fora do ar.  |  **Impacto na v7:** `firstOrFail()` / `abort(404)` em toda rota de detalhe; teste por slug inexistente.  |  **Correção:** `firstOrFail()`; a view de "não encontrado" só é alcançável **depois** da verificação.
### BUG-FUNC-014 — Conteúdo despublicado continua acessível
- **Severidade:** **Alta** — rascunho publicado por engano fica no ar.  |  **Onde:** `v6/app/Http/Controllers/Portal/PublicationController.php:157` · `v6/app/Http/Controllers/Portal/AtasEPontosController.php:124` · `v6/app/Http/Controllers/Portal/ComiteDeInvestimentosController.php:119` · `v6/app/Http/Controllers/Portal/GlossaryController.php:33`
- **O que acontece:** A consulta de detalhe (`Content::where('slug', $slug2)->first()`) não filtra `status`; o conteúdo com `status = false` (rascunho) é servido normalmente e ainda incrementa `clicks`.
- **Impacto:** Norma, publicação ou verbete despublicado no CMS fica acessível pela URL — e indexável por buscadores.  |  **Impacto na v7:** Escopo de publicação único (global scope ou repositório) aplicado na camada de consulta.  |  **Correção:** filtrar `status = true` em toda consulta de exibição.
### BUG-FUNC-015 — `view()` serve conteúdo de qualquer módulo
- **Severidade:** **Alta** — a rota de detalhe não restringe a categoria: o slug de um conteúdo de outro módulo é servido pela página deste.  |  **Onde:** `v6/app/Http/Controllers/Portal/AtasEPontosController.php:124` · `v6/app/Http/Controllers/Portal/ComiteDeInvestimentosController.php:119`
- **O que acontece:** O método `view()` busca o conteúdo apenas pelo slug, sem filtrar `category_contents_id` nem `deleted_at`.
- **Impacto:** O slug de um conteúdo de outro módulo é servido pela página deste (ex.: um verbete de glossário aparece na página de Atas).  |  **Impacto na v7:** Repositório por módulo, com filtro de categoria obrigatório.  |  **Correção:** filtrar por `category_contents_id` (ou pela raiz da categoria) na consulta de detalhe.
### BUG-FUNC-016 — Nomes de rota duplicados
- **Severidade:** **Alta** — `route()` resolve para a última registrada; links gerados apontam para o contexto errado.  |  **Onde:** `v6/routes/basepro.php:548` (`static.show` — duplica `v6/routes/web.php:227`) · `v6/routes/web.php:165` a `:168` (4 URIs com o nome `transparencia.index`) · `v6/routes/web.php:78` a `:83` (`a-instituicao.index` duplicado)
- **O que acontece:** `static.show` é declarado em `v6/routes/web.php:227` (`/pagina/{slug}`) e em `v6/routes/basepro.php:548` (`/{slug}`); `transparencia.index` em 4 URIs (`v6/routes/web.php:165` a `:168`); `a-instituicao.index` em 2 (`v6/routes/web.php:78` a `:83`). O Laravel resolve o nome para a última rota registrada.
- **Impacto:** `route('static.show')` gera o catch-all `/{slug}` e a página `/pagina/{slug}` fica inacessível por link; `route('transparencia.index')` aponta só para a última das 4 URIs.  |  **Impacto na v7:** Nomes de rota únicos, verificados por teste que percorre `Route::getRoutes()` e falha em duplicidade.  |  **Correção:** renomear as rotas duplicadas com prefixo do contexto (`a-instituicao-secretarias.index`).
### BUG-FUNC-017 — Referência a nome de rota inexistente
- **Severidade:** **Alta** — `RouteNotFoundException` (HTTP 500).  |  **Onde:** `v6/app/Http/Controllers/Portal/PortalController.php:332` (`route('a-instituicao-secretarias.index', $slug2)` — o nome não existe em nenhum arquivo de `v6/routes/`)
- **O que acontece:** O `switch` do `PortalController` chama `route('a-instituicao-secretarias.index', $slug2)`, mas esse nome não existe em nenhum arquivo de `v6/routes/`.
- **Impacto:** `RouteNotFoundException` — HTTP 500 ao acessar `/portal/secretarias/{slug}`.  |  **Impacto na v7:** Teste que verifica se todo nome de rota referenciado no código existe.  |  **Correção:** criar a rota com esse nome ou corrigir a referência; teste automatizado que verifique todos os nomes de rota usados.
### BUG-FUNC-018 — Formulário do CMS de A Instituição aponta para A Cidade
- **Severidade:** **Alta** — o operador copia e divulga uma URL errada.  |  **Onde:** `v6/resources/views/prefecture/contents/a-instituicao/form.blade.php:21` (`{{ url('a-cidade') }}/`) e `:30` (`route('a-cidade.index', $content->slug)`)
- **O que acontece:** O formulário de A Instituição mostra o prefixo `{{ url('a-cidade') }}/` e o link Acessar usa `route('a-cidade.index', $content->slug)` — cópia do formulário de A Cidade.
- **Impacto:** O operador copia e divulga um endereço que não existe; o link Acessar leva à página errada.  |  **Impacto na v7:** Componente Blade único de formulário institucional, parametrizado pelo slug do módulo.  |  **Correção:** `{{ url('a-instituicao') }}/` e `route('a-instituicao.index', ...)`.
### BUG-FUNC-019 — `layout` inválido derruba o portal inteiro
- **Severidade:** **Alta** — um valor errado no CMS coloca o portal em manutenção, sem aviso.  |  **Onde:** `v6/app/Http/Traits/GetInformationsPrefectureTrait.php:96` — se a view do layout ativo não existir, o código grava `pagina_em_manutencao = 1` e faz `abort(404)`.
- **O que acontece:** `GetInformationsPrefectureTrait` verifica se a view do layout ativo existe; se não, grava `pagina_em_manutencao = 1` no banco e faz `abort(404)`.
- **Impacto:** Salvar `layout = 'modelo9'` no CMS coloca o portal inteiro em manutenção — sem aviso, sem validação no formulário e com efeito imediato.  |  **Impacto na v7:** Enum de templates validado no servidor; template inexistente nunca é gravado.  |  **Correção:** validar o layout contra a lista de templates no formulário do CMS, antes de gravar.
### BUG-FUNC-020 — Falha silenciosa ao salvar evento com `final_date` vazio
- **Severidade:** **Alta** — o operador recebe confirmação e o evento não é salvo.  |  **Onde:** `v6/app/Http/Requests/CalendarEventRequest.php:36` · `v6/app/Http/Traits/AppTrait.php:288` · `v6/app/Repositories/Eloquent/Prefecture/CalendarEvent/CalendarEventRepositoryDefaultImpl.php:25` e `:50`
- **O que acontece:** `CalendarEventRequest` aceita `final_date` vazio; o `AppTrait` e o `CalendarEventRepositoryDefaultImpl` gravam a data sem o campo preenchido e o controller não sinaliza a falha ao operador.  |  **Impacto:** O operador recebe confirmação de sucesso, mas o evento fica sem data final — a agenda pública exibe um evento truncado.  |  **Correção:** Exigir `final_date` quando `start_date` existir (ou gravar `null` de forma explícita e sinalizar).  |  **Impacto na v7:** Regra `required_with` no Form Request; retorno de erro estruturado.
### BUG-FUNC-021 — Evento sem categoria quebra o card da listagem pública
- **Severidade:** **Alta** — 500 na listagem do portal quando um evento não tem categoria.  |  **Onde:** `v6/resources/views/portal/layouts/calendar-events/index.blade.php:41` e `:51` · `v6/app/Repositories/Eloquent/Prefecture/CalendarEvent/CalendarEventRepositoryDefaultImpl.php:28`
- **O que acontece:** A listagem pública de eventos acessa a categoria do evento sem verificar se ela existe (`CalendarEventRepositoryDefaultImpl` e a view `index.blade.php`); um evento sem categoria quebra o card.  |  **Impacto:** HTTP 500 na listagem de eventos do portal — a página do módulo fica fora do ar por causa de um registro mal cadastrado.  |  **Correção:** Tratar a categoria nula (`optional()`) ou impedir o cadastro sem categoria.  |  **Impacto na v7:** Relação `belongsTo` com `withDefault()`; validação de categoria obrigatória no cadastro.
