---
id: MOD-LAYOUTS-BASICPAGE-RUNTIME
tipo: modulo
onda: 3
status: concluido
unidades: [MOD-LAYOUTS-BASICPAGE]
fontes:
  - V2/Modules/Layouts/BasicPage/
depende_de: [CORE-BOOT, CORE-RUNTIME, SVC-SEO, SVC-SEARCH, SVC-MEDIA, SVC-GALLERY, UI-PORTAL]
consumidores: [CORE-BOOT, SVC-SEO, SVC-SEARCH]
criticidade: alta
dono: PageBasicPage
---

# BasicPage — runtime público, resolução de slug e SEO

> Como um `slug` se transforma em HTML: ordem de registro das rotas, o algoritmo do catch-all, quando o Blade fixo é substituído, o que vai para o índice de busca, como o `<head>` é montado e por que o cache de 5 minutos não é invalidado ao salvar. Complementa [`spec.md`](spec.md) — aqui só o caminho público.

## 0. Ficha de identidade

| Campo | Valor |
|---|---|
| Efeito primário | `GET /{slug}` (um segmento, `[a-z0-9-]+`) resolve uma linha de `v2_basic_pages` com `is_runtime=true` e devolve `200` com `content` cru; sem linha, `404`. As cinco páginas fixas usam a mesma linha para decidir entre Blade histórico e conteúdo runtime. |
| Entrada | `GET /{slug}`; `GET /lgpd`, `/acessibilidade`, `/mapa-do-site`, `/ouvidoria`; `GET /` (de outro módulo). |
| Saída | `200` view `v2-basicpage::portal.runtime` (conteúdo cru) ou view fixa; `404` (`firstOrFail`) ou `404` de rota (regex); `<head>` com title/description/keywords/OG/Twitter/canonical/JSON-LD; headers de cache. |
| Efeitos colaterais | Linha em `v2_public_search_index` e `v2_cms_links_index` por `save()`; entrada no sitemap; `trackAccess()` do `SEOService` a cada render de metadados; chave Redis `page:<sha1(url)>` por 5 min; nenhum log de acesso próprio. |
| Particularidade central | O catch-all é **do Core** (`V2/Core/Routes/fallback.php`) e foi movido para fora do módulo para perder para rotas específicas; além disso, o conteúdo é ecoado sem compilar Blade, então “injeção de asset” não existe no servidor. |
| Como se integra | `BasicPagePortalController` (FQCN direto na rota), `BasicPage::getSeoData/getLayoutConfig`, `SEOService` (singleton + composer de `v2-portal::metadata*`), `PublicIndexerService`/`CMSIndexerService`, `PageCacheMiddleware`/`PublicCacheMiddleware`. |
| Se removido, quebra | Qualquer URL de um segmento que não tenha rota própria (404 global), as quatro páginas institucionais, o `<head>` SEO das páginas do portal (o controller é quem popula o `SEOService` para essas rotas) e a indexação pública de páginas fixas. |
| Superfície pública | `BasicPagePortalController@{runtime,lgpd,acessibilidade,mapaSite,ouvidoria}`, `BasicPage::{getSeoData,getLayoutConfig,syncToSearch}`, rota nomeada `v2.portal.runtime`. |

## 1. Efeito

### 1.1 O que o usuário consegue fazer

- Abrir `/<slug>` de qualquer página criada em `/cms/paginas-fixas/editor/create` e ver o HTML salvo.
- Abrir `/lgpd`, `/acessibilidade`, `/mapa-do-site`, `/ouvidoria` e ver o Blade institucional **ou** o conteúdo runtime, se o CMS tiver salvado algo.
- Ver `<title>`, description, keywords, Open Graph, Twitter Card, canonical e JSON-LD coerentes com o que o CMS gravou em `metadata['seo']`.
- Receber a página do cache Redis por até 5 minutos (`X-Cache-Status: HIT`) e do cache HTTP do navegador/CDN (`max-age=60`).

### 1.2 O que muda no sistema

| Efeito | Onde é produzido | Fonte |
|---|---|---|
| `v2_basic_pages.saved` → dois upserts de índice | hook do model | `Models/BasicPage.php:136-144,222-254` |
| `v2_public_search_index` recebe `module='basicpage'`, `entity_type='V2\Modules\Layouts\BasicPage\Models\BasicPage'`, `entity_id=id`, `slug`, `summary_fragment`, `tags=['pagina-fixa','institucional']`, `search_deep_content=strip_tags(content)` | `PublicIndexerService::index` | `Models/BasicPage.php:240-249`; `V2/Services/Search/Services/PublicIndexerService.php:22-57` |
| `v2_cms_links_index` recebe `module='basicpage'`, `edit_url` do editor | `CMSIndexerService::index` | `Models/BasicPage.php:225-237` |
| `SEOService` (singleton por request) recebe title/description/keywords/cover/schema | `bindBasicPage` | `Controllers/BasicPagePortalController.php:24-50` |
| `<head>` renderiza os valores do singleton, com fallback para `v2_portal_configs` | composer do SEO + partial | `V2/Services/SEO/Providers/SEOServiceProvider.php:22-64`; `V2/layouts/portal/metadata/seo.blade.php:1-47` |
| Cada render de metadados registra visita (`trackAccess`) | composer | `V2/Services/SEO/Providers/SEOServiceProvider.php:38` |
| Resposta 2xx do portal é gravada em Redis `page:<sha1(fullUrl)>` por 300 s | `PageCacheMiddleware` | `V2/Core/Middleware/PageCacheMiddleware.php:25-59` |
| Resposta ganha `Cache-Control: public, max-age=60, must-revalidate` + `Vary` | `PublicCacheMiddleware` | `V2/Core/Middleware/PublicCacheMiddleware.php:87-104` |
| Página aparece no sitemap com `lastmod = updated_at` | `SeoPortalController::addBasicPages` | `V2/Services/SEO/Controllers/SeoPortalController.php:149-172` |
| Nenhum cache é invalidado ao salvar | ausência de `Cache::forget`/`Redis::del` no módulo | busca no diretório: 0 ocorrências |

### 1.3 Contrato observável

| Entrada | Resultado |
|---|---|
| `GET /slug-existente` (`is_runtime=true`) | `200`, `content` cru, SEO do banco, cache 5 min. |
| `GET /slug-existente` (`is_runtime=false`) | `404` (a query filtra `is_runtime`). |
| `GET /slug-inexistente` | `404`; **não** é cacheado (`isSuccessful()` falso). |
| `GET /Slug` ou `/slug_com_underscore` | `404` de rota, sem hit no banco. |
| `GET /noticias` | rota do módulo Notícias (registrada antes do fallback); a linha `slug='noticias'` de `v2_basic_pages` **não** é usada. |
| `GET /lgpd` com `content=''` | `200` view fixa; SEO do banco (ou default do slug). |
| `GET /lgpd` com `content='<h1>x</h1>'` | `200` view runtime; o layout passa a vir de `metadata.layout`, não do `@extends` da view fixa. |
| `GET /` | rota `v2.portal.home` do módulo Homepage (LSE), nunca `BasicPage`. |
| `GET /cms/...` | fora do cache de página (padrão `cms*`). |

### 1.4 O que este fluxo NÃO faz

- Não cria rota por página: existe **uma** rota `/{slug}` (`v2.portal.runtime`), não uma por registro.
- Não valida se o slug é “reservado” (nada impede criar `slug=lgpd` via CMS? — `unique` bloqueia porque a linha já existe; mas nada impede `slug=noticias` se a linha não existir).
- Não renderiza Blade, não aplica markdown, não sanitiza HTML, não filtra `<script>`.
- Não faz redirect 301/302 de slug antigo, nem canonical para variante.
- Não injeta CSS/JS de `v2_basic_page_assets` no `<head>`/`</body>`.
- Não invalida cache, não aquece cache, não gera página estática.
- Não registra log de acesso próprio (apenas o `trackAccess` do SEO e o cache).

## 2. Abordagem

### 2.1 Estratégia em uma página

O Core monta a tabela de rotas em ordem fixa e reserva o último lugar para `/{slug}`; assim qualquer módulo que declare `/noticias`, `/agenda`, `/busca` vence o catch-all, independentemente da ordem alfabética dos módulos. O controller do catch-all faz **uma** consulta por `slug` **e** `is_runtime`; encontrado, aplica SEO e devolve a view runtime; não encontrado, `firstOrFail` responde 404 antes de qualquer cache de sucesso. As páginas fixas fazem o mesmo, mas sem o filtro `is_runtime` e com uma view Blade por slug como fallback.

```mermaid
flowchart TD
  R[GET /{slug}] --> M{Rota de módulo casa?}
  M -->|sim| H[handler do módulo]
  M -->|não| F[fallback /{slug}]
  F --> W{slug ~ [a-z0-9-]+}
  W -->|não| N404[404 de rota]
  W -->|sim| Q[BasicPage where slug + is_runtime=true]
  Q -->|vazio| N404b[404 firstOrFail]
  Q -->|linha| S[getSeoData -> SEOService]
  S --> V[view portal.runtime]
  V --> C[PageCache put 5 min]
  P[GET /lgpd] --> Q2[BasicPage where slug]
  Q2 --> E{content não vazio?}
  E -->|sim| V
  E -->|não| B[view fixa portal.lgpd]
```

### 2.2 Mecânica passo a passo

1. **Boot**: `V2ServiceProvider::boot()` registra Core → módulos → `mapFallbackRoutes()` → serviços (`V2/Core/Providers/V2ServiceProvider.php:85-218`). O fallback entra com `web`, `MaintenanceMiddleware`, `page.cache`, `public.cache` (`:317-335`).
2. **Matching**: Laravel percorre as rotas na ordem de registro e usa a primeira que casa método + URI; `/{slug}` está por último, então `/noticias` nunca cai nele.
3. **Filtro de forma**: `where('slug','[a-z0-9-]+')` (`V2/Core/Routes/fallback.php:21`). Um segmento com `_`, `%`, `.`, maiúscula ou barra extra nem entra no controller.
4. **Consulta**: `BasicPage::where('slug',$slug)->where('is_runtime',true)->firstOrFail()` (`Controllers/BasicPagePortalController.php:149`). Uma query, dois predicados; `slug` é unique.
5. **SEO**: `bindBasicPage($page)` → `getSeoData()` (banco > defaults do slug > título) → `setTitle/setDescription/setKeywords/setCoverImage/addSchema` no singleton (`:24-50`). Só valores não vazios são aplicados; o serviço mantém o que já estava.
6. **Layout**: `$layoutConfig = $page->getLayoutConfig()` (`:152`) — 9 booleanos com merge `app < slug < metadata`.
7. **Render**: `view('v2-basicpage::portal.runtime', compact('page','layoutParams'))`; a view faz `@extends('v2-portal::app-layout', $layoutParams)` e ecoa `{!! $page->content !!}` (`Views/portal/runtime.blade.php:1-5`).
8. **Composer SEO**: ao renderizar `v2-portal::metadata*`, o composer do `SEOServiceProvider` chama `trackAccess()` e injeta `$seo`, `$seoTitle`, `$seoDescription`, `$seoKeywords`, `$siteTitle`, `$seoGlobalTokens` etc. (`SEOServiceProvider.php:22-64`).
9. **Cache**: `PageCacheMiddleware` calcula `page:<sha1(fullUrl)>`; HIT devolve a resposta serializada com headers; MISS executa e grava se 2xx (`:31-59`).
10. **Headers**: `PublicCacheMiddleware` sobrescreve `Cache-Control` para `public, max-age=60, must-revalidate` e adiciona `Vary: Accept-Encoding` quando o controller não definiu diretivas explícitas (`:87-104`).

### 2.3 Decisões de design e por quê

| Decisão | Efeito | Marca |
|---|---|---|
| Catch-all no Core, registrado por último | rotas específicas sempre vencem; módulos novos não precisam se preocupar com ordem | `[C]` `V2/Core/Routes/fallback.php:7-12`; `V2ServiceProvider.php:134-137,317-335` |
| Regex `[a-z0-9-]+` em vez de `.*` | evita capturar arquivos e caminhos reservados; 404 barato | `[C]` `fallback.php:21`; comentário em `V2/Modules/Layouts/BasicPage/Routes/web.php:16-21` |
| FQCN do controller na rota | independe do `namespace()` do grupo, que é de outro módulo | `[C]` `fallback.php:17-19` |
| `is_runtime` como filtro do catch-all | linha fixa não vira página “genérica” por acidente | `[C]` `BasicPagePortalController.php:149` |
| Fallback por `!empty($page->content)` nas rotas fixas | migração incremental: Blade histórico continua no ar até o CMS salvar | `[C]` `BasicPagePortalController.php:58` |
| SEO aplicado antes da decisão de view | metatags do CMS valem mesmo no Blade fixo | `[C]` `BasicPagePortalController.php:88-95` |
| Cache de página de 5 min sem invalidação | simplicidade; consistência eventual aceita | `[C]` `PageCacheMiddleware.php:14,25-59` |
| `strip_tags(content)` no índice | busca não indexa markup | `[C]` `Models/BasicPage.php:236,248` |
| Catch-up de índice no boot, só console | corrige seed por `DB::table` sem custo por request | `[C]` `V2ServiceProvider.php:145-166` |

### 2.4 Trechos de código pivô

```php
// V2/Core/Routes/fallback.php:19-21 — o catch-all, registrado por último
Route::get('/{slug}', BasicPagePortalController::class . '@runtime')
    ->name('v2.portal.runtime')
    ->where('slug', '[a-z0-9-]+');
```

Uma rota, todos os slugs. O nome `v2.portal.runtime` é usado pelo CMS para montar a URL de qualquer página `is_runtime` (`BasicPageCmsController.php:78`) e pelo botão “Ver no portal” do editor. Se a reimplementação usar `Route::fallback()`, perde-se o `where` e o 404 rápido para maiúsculas/acentos.

```php
// V2/Modules/Layouts/BasicPage/Controllers/BasicPagePortalController.php:56-68 — overlay nas páginas fixas
protected function renderWithRuntimeFallback($page, $defaultView, array $hardcodedParams)
{
    if ($page && !empty($page->content)) {
        $layoutConfig = $page->getLayoutConfig();
        $layoutParams = array_merge($hardcodedParams, $layoutConfig);

        return view('v2-basicpage::portal.runtime', [
            'page'         => $page,
            'layoutParams' => $layoutParams,
        ]);
    }

    return null;
}
```

`$defaultView` e `$hardcodedParams` não influenciam o ramo runtime: quando há conteúdo, as flags hardcoded da view fixa são **descartadas** em favor de `metadata.layout`. Na prática os chamadores passam `[]`, então `layoutParams` é exatamente `getLayoutConfig()`.

```php
// V2/Modules/Layouts/BasicPage/Controllers/BasicPagePortalController.php:147-158 — resolução do catch-all
public function runtime($slug)
{
    $page = BasicPage::where('slug', $slug)->where('is_runtime', true)->firstOrFail();
    $this->bindBasicPage($page);

    $layoutConfig = $page->getLayoutConfig();

    return view('v2-basicpage::portal.runtime', [
        'page'         => $page,
        'layoutParams' => $layoutConfig,
    ]);
}
```

Duas condições na mesma query: o slug precisa existir **e** ser runtime. `firstOrFail` lança `ModelNotFoundException` → 404 padrão, sem mensagem customizada. Não há verificação de status, data ou visibilidade.

```php
// V2/Modules/Layouts/BasicPage/Views/portal/runtime.blade.php:1-5 — conteúdo cru, sem compilação
@extends('v2-portal::app-layout', $layoutParams)

@section('main-section-area')
    {!! $page->content !!}
@endsection
```

Este é o contrato de renderização inteiro. `{!! !!}` desabilita escaping e **não** compila Blade: `{{ asset(...) }}`, `@include(...)` e `@if` no `content` aparecem como texto. O layout recebe as 9 flags e decide cabeçalho/rodapé/sidebars/barra de acessibilidade; a view também injeta CSS (`.brand-slider`) e um inicializador de Swiper opcional (`:7-55`).

```php
// V2/Modules/Layouts/BasicPage/Models/BasicPage.php:240-253 — o que vai para o índice público
$publicDto = new PublicSearchDTO(
    $this->title,
    $this->slug,
    'basicpage',
    get_class($this),
    $this->id,
    $this->title . ' - Página governamental fixa.',
    ['pagina-fixa', 'institucional'],
    strip_tags($this->content)
);

// 3. Envia explicitamente aos indexadores
app(CMSIndexerService::class)->index($cmsDto);
app(PublicIndexerService::class)->index($publicDto);
```

`entity_type` é o **FQCN do model** (não um rótulo), `entity_id` é o UUID da página e `module` é literalmente `basicpage`. `summary_fragment` é sempre `"<título> - Página governamental fixa."`, mesmo para páginas runtime criadas pelo CMS; o texto real vai em `search_deep_content`. O índice tem unique `(entity_type, entity_id)` e o índice público resolve a URL por `switch ($module)` — `basicpage` cai no `default` → `url('/'.$slug)` (`V2/Services/Search/Services/SearchService.php:85-89`).

```php
// V2/Core/Providers/V2ServiceProvider.php:145-166 — catch-up de indexação no boot
if (class_exists(\V2\Modules\Layouts\BasicPage\Models\BasicPage::class)
    && $this->app->runningInConsole()
    && \Illuminate\Support\Facades\Schema::hasTable('v2_basic_pages')
    && \Illuminate\Support\Facades\Schema::hasTable('v2_public_search_index')
) {
    try {
        $unsynced = \V2\Modules\Layouts\BasicPage\Models\BasicPage::whereNotExists(function ($query) {
            $query->select(\Illuminate\Support\Facades\DB::raw(1))
                   ->from('v2_public_search_index')
                  ->whereRaw('v2_public_search_index.entity_id = v2_basic_pages.id');
        })->get();

        foreach ($unsynced as $page) {
            $page->syncToSearch();
        }
    } catch (\Exception $e) {
        \Illuminate\Support\Facades\Log::warning('[BasicPage] Inicializacao de busca ignorada: ' . $e->getMessage());
    }
}
```

Quatro guardas antes de tocar o banco: classe carregada, **console** (nunca em request web), tabela de páginas e tabela de índice existentes. A consulta só olha `entity_id`, ignorando `entity_type`/`module` — funciona porque o UUID é único, mas não é a checagem semanticamente correta. Falha é silenciada com `Log::warning`; o boot nunca quebra por causa do índice.

```php
// V2/Core/Middleware/PageCacheMiddleware.php:31-59 — cache de página sem invalidação
$cache = Cache::store($this->store);
$key = $this->cacheKey($request);

$cached = $cache->get($key);
if ($cached) {
    return response($cached['content'], $cached['status'])
        ->withHeaders($cached['headers'])
        ->header('X-Cache-Status', 'HIT');
}

$response = $next($request);

if ($response->isSuccessful()) {
    // ... copia headers exceto set-cookie
    $cache->put($key, [
        'content' => $response->getContent(),
        'status'  => $response->getStatusCode(),
        'headers' => $headers,
    ], $this->ttl);
    $response->headers->set('X-Cache-Status', 'MISS');
}
```

Store `redis`, TTL `5` (minutos, conforme `:13-14`), chave `page:` + `sha1(fullUrl)`. Só GET não-AJAX fora de `cms*`, `api*`, `trabalhe-conosco*`, `associe-se*`, `fale-conosco*`, `_debugbar*` (`:16-23,68-88`). Não há `forget` em nenhum ponto do módulo: salvar conteúdo não invalida a chave.

```php
// V2/Modules/Layouts/BasicPage/Controllers/BasicPagePortalController.php:30-49 — SEO aplicado à página
$seo = $page->getSeoData();

if (!empty($seo['title'])) {
    $this->seo->setTitle($seo['title']);
}
if (!empty($seo['description'])) {
    $this->seo->setDescription($seo['description']);
}
if (!empty($seo['keywords'])) {
    $this->seo->setKeywords($seo['keywords']);
}
if (!empty($seo['image_url'])) {
    $this->seo->setCoverImage($seo['image_url']);
}
if (!empty($seo['json_ld'])) {
    $decoded = is_string($seo['json_ld']) ? json_decode($seo['json_ld'], true) : $seo['json_ld'];
    if ($decoded) {
        $this->seo->addSchema($decoded);
    }
}
```

`json_ld` é aceito como string JSON ou array; só é adicionado se decodificar para algo truthy. O `SEOService` é singleton (`SEOServiceProvider.php:66-69`), então o mesmo objeto lido pelo composer no render é o que o controller populou — a ordem “controller antes da view” é obrigatória.

### 2.5 Algoritmos, consultas e custos

- **Matching**: O(nº de rotas registradas), sem query. O fallback é a última rota; qualquer rota de módulo registrada antes vence.
- **Resolução**: 1 `SELECT` em `v2_basic_pages` por request cache-miss; 0 em cache HIT.
- **SEO**: `getSeoData()` é O(1) (arrays estáticos); `addSchema` acumula em memória; o partial faz `json_encode` de cada schema no `<head>`.
- **Índice**: 2 upserts por `save()`; cada um com `strip_tags` do conteúdo inteiro. `search_vector` é recalculado por trigger no Postgres (`V2/Services/Search/Migrations/2026_06_14_000000_create_search_indexes_tables.php:60-68`).
- **Cache**: chave por URL completa (inclui query string); 5 min fixos, sem `vary` por usuário; headers excluem `Set-Cookie`.
- **Sitemap**: 1 `SELECT slug, updated_at` de todas as páginas, sem filtro por `is_runtime`.

## 3. Particularidades

### 3.1 O que só existe aqui

- **Catch-all declarado no Core, com FQCN do controller de um módulo**: o Core conhece `V2\Modules\Layouts\BasicPage\Controllers\BasicPagePortalController` por `use` (`fallback.php:17`). Remover o módulo quebra o boot de rotas (classe usada só como string, mas o handler falha em runtime).
- **Duas camadas de cache com TTLs diferentes**: Redis 300 s (servidor) + `Cache-Control: max-age=60` (cliente/CDN). O `X-Cache-Status` só reflete a camada Redis.
- **SEO populado pelo controller de portal, não por um trait/base**: as outras páginas costumam montar SEO no próprio controller do módulo; aqui `bindBasicPage` é o único ponto que traduz `metadata['seo']` em metatags, e é reutilizado pelas 4 rotas fixas + catch-all.
- **Sitemap lê a tabela diretamente** (`DB::table('v2_basic_pages')`), sem passar pelo model e sem filtrar runtime — todas as linhas entram, inclusive as “sombra” de outros módulos.
- **Defaults de SEO/JSON-LD para slugs de terceiros** (notícias, associados, agenda…) no model, embora essas rotas não sejam servidas por ele.
- **Conteúdo como HTML de confiança**: não há sanitização, CSP específica por página, nem `Content-Security-Policy`; o autor pode injetar script no portal.

### 3.2 Invariantes e regras não óbvias

| ID | Regra | Efeito | Fonte |
|---|---|---|---|
| `RN-BASICPAGE-RT-01` | Só a **primeira** rota que casa responde | Módulos sempre vencem o fallback; ordem de registro é contrato | `V2ServiceProvider.php:134-137,317-335` |
| `RN-BASICPAGE-RT-02` | Slug do catch-all é case-sensitive e restrito a `[a-z0-9-]` | `/Busca` e `/mapa_do_site` = 404 sem query | `fallback.php:21` |
| `RN-BASICPAGE-RT-03` | `is_runtime=true` é obrigatório no catch-all, mas ignorado nas rotas fixas | Linha criada pela migration só responde em `/lgpd` etc. | `BasicPagePortalController.php:88-95,149` |
| `RN-BASICPAGE-RT-04` | Conteúdo vazio (`''`, `'0'`, `null`) = fallback; qualquer outro valor = runtime | Um espaço já substitui a página inteira | `BasicPagePortalController.php:58` |
| `RN-BASICPAGE-RT-05` | SEO do banco sobrescreve `pageTitle/pageDescription` hardcoded | Editar SEO muda o `<head>` mesmo sem conteúdo runtime | `SEOServiceProvider.php:52-60`; `metadata/seo.blade.php:2-3` |
| `RN-BASICPAGE-RT-06` | `canonical` é sempre `url()->current()` | Não há canonical configurável, nem remoção de query string | `metadata/seo.blade.php:28-29` |
| `RN-BASICPAGE-RT-07` | Indexação não distingue runtime de fixa e não remove ao apagar | Índice pode ficar órfão (sem hook `deleted`) | `Models/BasicPage.php:136-144` |
| `RN-BASICPAGE-RT-08` | Cache só grava 2xx | 404 e 500 nunca são servidos do Redis | `PageCacheMiddleware.php:44` |
| `RN-BASICPAGE-RT-09` | Falha de Redis é fail-open | requisição é servida sem cache, com warning | `PageCacheMiddleware.php:62-65` |
| `RN-BASICPAGE-RT-10` | `cms*`/`api*`/formulários não entram no cache de página | edição vê dados frescos; portal pode ver antigos | `PageCacheMiddleware.php:16-23` |

### 3.3 Casos-limite e comportamentos surpreendentes

- **Slug de módulo com linha em `v2_basic_pages`**: `/noticias` é servido pelo módulo Notícias, mas a linha `slug='noticias'` (criada por `DATA-SEEDS-BASICPAGES`) aparece na listagem do CMS, no índice de busca como `basicpage` e no sitemap. Editar seu conteúdo no editor **não** altera a página pública.
- **Linha fixa sem `is_runtime`**: `GET /acessibilidade` funciona (não filtra), `GET /homepage` não existe como rota (a rota é `/`) e `GET /mapa-do-site` funciona. Já `GET /mapa_do_site` dá 404.
- **Conteúdo só com espaços**: `!empty(' ')` é `true` → view runtime vazia, sem header/footer se o layout assim disser; a página “desaparece” visualmente sem 404.
- **Página criada por `store()`**: `metadata` duplamente codificado (§3.5 do `spec.md`) → `getSeoData()` não vê `metadata['seo']` e o `<title>` cai em `$this->title` (default `?? $this->title`) — não quebra, mas o SEO salvo depois pode não ser lido até corrigir o dado.
- **404 não é cacheado**, mas o navegador pode ter cacheado a versão `200` anterior por 60 s (HTTP) `[C]`; `[I]` depois de remover/alterar conteúdo o usuário pode ver a página antiga mesmo sem Redis.
- **`trackAccess()` roda a cada render de metadados**, inclusive no preview em iframe (é a URL pública), inflando métricas.
- **JSON-LD salvo inválido**: `bindBasicPage` decodifica e ignora silenciosamente se `json_decode` falhar (`!$decoded`), sem log.
- **Slug com query string**: a chave de cache inclui a query, então `?utm=x` cria uma chave nova a cada variação — potencial de inflar Redis em campanhas.

### 3.4 Diferença em relação a LSE/SLE (não confundir)

| Aspecto | BasicPage | LSE (`SVC-LANDINGSECTIONSEDITOR`) | SLE (`Support/StructuralLandingEditor`) |
|---|---|---|---|
| Unidade de conteúdo | string `content` | seções tipadas em `v2_layout_base_configs.payload` | blocos em `v2_structural_portal_contents` |
| Renderização | `{!! content !!}`, sem compilação | renderizadores por tipo de seção | `BlockRenderer::compileString` (`BlockRenderer.php:144-145`) |
| Edição | CodeMirror de HTML/CSS/JS + 9 flags | 21 tipos padrão (36 no boot com os 15 widgets de módulo) | editor estrutural com blocos/áreas |
| Escopo | página inteira | landing (homepage e afins) | estrutura global do portal |
| Assets | `v2_basic_page_assets` (só catálogo/snippet) | mídia por seção | `v2_sle_assets` |

Consequência prática: **não** implementar BasicPage como “landing de seções”; e **não** reaproveitar o `BlockRenderer` para compilar `content` sem decisão explícita (ele compila qualquer string, mas só é chamado para blocos SLE).

### 3.5 Divergências documentação × código e dívidas

- **“Rede de segurança” que também serve conteúdo**: o comentário chama o fallback de rede de segurança (`fallback.php:7-12`), mas ele é o renderizador principal de qualquer página runtime.
- **`Routes/web.php:16-21` documenta a razão da mudança** (Layouts < Publicacoes alfabeticamente), o que é uma decisão de arquitetura registrada só em comentário — sem teste que a fixe (`LG-BASICPAGE-05` do `spec.md`).
- **Asset injection inexistente apesar do catálogo**: `v2_basic_page_assets` só é lida por `/editor/assets`; nenhuma view de portal a consulta. O snippet CSS/JS contém `{{ asset('storage/…') }}` (Blade) e o conteúdo não é compilado → inutilizável como está (`BasicPageAssetManagerService.php:169-179`; `runtime.blade.php:4`).
- **`ImageRedigestService::redigestContentBlobs` reescreve `v2_basic_pages.content`** como se fosse compilado (`ImageRedigestService.php:217-228,320-330`), reforçando a divergência acima.
- **`homepage()` do portal é código morto**: `/` pertence a `V2/Modules/Layouts/Homepage/Routes/web.php:8`, e a home real é LSE (`HomepagePortalController.php:45-68`); o Blade `portal/homepage.blade.php` é legado.
- **Catch-up de índice compara só `entity_id`** (`V2ServiceProvider.php:156`), ignorando `entity_type` — funciona por sorte (UUID único), mas não é o critério do unique composto.
- **Sitemap inclui páginas que não são servidas pelo BasicPage** e não distingue `is_runtime` (`SeoPortalController.php:149-172`).

## 4. Integrabilidade

### 4.1 Superfície pública

| Símbolo | Tipo | Assinatura | Precondição | Consumidores |
|---|---|---|---|---|
| `v2.portal.runtime` | rota | `GET /{slug}` where `[a-z0-9-]+` | registrada por último pelo Core | links do CMS (`BasicPageCmsController.php:78`), editor, menu |
| `BasicPagePortalController@runtime` | action | `runtime(string $slug): View` | linha com `is_runtime=true` | `fallback.php:19` |
| `BasicPagePortalController@{lgpd,acessibilidade,mapaSite,ouvidoria}` | actions | `(): View` | — | `Routes/web.php:11-14` |
| `BasicPage::getSeoData()` | método | `(): array` | — | portal, listagem CMS, previews |
| `BasicPage::getLayoutConfig()` | método | `(): array{9 bool}` | — | portal runtime e editor |
| `BasicPage::syncToSearch()` | método | `(): void` | tabelas de índice | hook `saved`, boot, reindex |
| `SEOService` (singleton) | serviço | `setTitle/setDescription/setKeywords/setCoverImage/addSchema` | resolver antes do render | `BasicPagePortalController`, `HomepagePortalController`, outros módulos |

### 4.2 Contrato de UI/JS (quando aplicável)

- `portal/runtime.blade.php` não tem JS de aplicação, apenas um inicializador de Swiper para `.brand-slider` (`Views/portal/runtime.blade.php:15-55`), tolerante a ausência da lib.
- Todos os pontos de SEO são renderizados por partials do serviço: `v2-portal::metadata` → `metadata.seo` (`<title>`, `meta`, OG, Twitter, canonical, JSON-LD) e `metadata.gtag`.
- O layout `v2-portal::app-layout` repassa `$layoutParams` para `v2-portal::base`, que inclui `metadata` no `<head>` e renderiza as áreas conforme as flags.
- Não há evento JS, endpoint AJAX ou DOM id próprio do runtime além de `.brand-slider`.

### 4.3 Ordem e ciclo de vida

1. Core registra rotas e o fallback por último.
2. Módulos registram views/rotas; o BasicPage registra `v2-basicpage` e as 4 rotas fixas.
3. `SEOServiceProvider::boot()` registra o composer de metadados.
4. Request → middleware (`web`, maintenance, page.cache, public.cache) → controller → SEO → view → composer → resposta → cache.
5. `save()` do model → indexadores (fora do ciclo de request público).
6. Console: catch-up de índice no boot.

### 4.4 Receitas de uso

```php
// Resolver uma página runtime e montar a URL pública em código
$page = \V2\Modules\Layouts\BasicPage\Models\BasicPage::where('slug', $slug)
    ->where('is_runtime', true)->firstOrFail();

$url = route('v2.portal.runtime', ['slug' => $page->slug]); // /{slug}
```

Resultado: URL válida apenas se o slug casar `[a-z0-9-]+`; para slugs com `_`, a rota não casa e o link responde 404.

```php
// Aplicar SEO de uma BasicPage em um controller de portal próprio
$seo = app(\V2\Services\SEO\Services\SEOService::class);
$data = \V2\Modules\Layouts\BasicPage\Models\BasicPage::where('slug', 'lgpd')->first();
if ($data) {
    $seo->setTitle($data->getSeoData()['title']);
    $seo->addSchema($data->getSeoData()['json_ld']);
}
return view('v2-portal::minha-view'); // o composer lê o singleton no render
```

Resultado: metatags e JSON-LD no `<head>` sem precisar mexer no layout.

```bash
# Forçar reindexação das páginas (após seed por DB::table)
php artisan v2:reindex-public --only=basicpages
# Ou qualquer comando artisan, que dispara o catch-up do boot (só páginas ausentes do índice)
php artisan list
```

Resultado: `v2_public_search_index`/`v2_cms_links_index` alinhados; páginas já indexadas não são atualizadas pelo catch-up (ele só insere as ausentes).

```php
// Limpar o cache de página depois de salvar conteúdo pelo CMS (não é feito pelo módulo)
$redis = \Illuminate\Support\Facades\Redis::connection();
$prefix = config('cache.prefix', 'laravel');
$keys = $redis->keys($prefix . ':page:*');
if (!empty($keys)) { $redis->del($keys); }
```

Resultado: próximas requisições voltam a `X-Cache-Status: MISS`.

### 4.5 Anti-uso

```php
// ERRADO: depender do catch-all para "páginas de módulo"
// Criar slug 'noticias' e esperar que /noticias renderize o conteúdo do BasicPage.
// Efeito: a rota do módulo Notícias vence; o conteúdo nunca aparece.
```

```php
// ERRADO: injetar assets esperando que o servidor os carregue
echo $asset->snippet; // '<script src="{{ asset('storage/...') }}"></script>'
// Efeito no content runtime: a expressão Blade é exibida literalmente (não há compilação).
```

```php
// ERRADO: salvar e assumir leitura imediata no portal
$page->content = $novoHtml; $page->save();
// Efeito: pelos próximos 5 min o Redis serve o HTML anterior; o índice de busca, por outro lado, é atualizado na hora.
```

### 4.6 Custos e limites da integração

- 1 query por request miss; 0 em HIT; `trackAccess()` acrescenta sua própria escrita/leitura (ver `SVC-SEO`).
- Cache Redis sem invalidação: até 5 min de defasagem; chave por URL completa (query string varia a chave).
- HTTP cache de 60 s com `must-revalidate`; CDN pode servir por 60 s.
- Sem sanitização: payload de página é ilimitado e executável no navegador do cidadão.
- Índice: 2 upserts por save; conteúdo grande aumenta `search_deep_content` e o custo do `tsvector`.
- 404 custa 1 query; 404 de rota custa 0.

## 5. Rotas e contratos HTTP

| Tipo | Método | URI | Nome | Middleware | Controller@ação | Permissão | Efeito | Fonte |
|---|---|---|---|---|---|---|---|---|
| endpoint (Core) | GET | `/{slug}` `[a-z0-9-]+` | `v2.portal.runtime` | `web`, `MaintenanceMiddleware`, `page.cache`, `public.cache` | `BasicPagePortalController@runtime` | pública | resolve runtime, SEO, view crua; 404 se ausente | `V2/Core/Routes/fallback.php:19-21`; `V2ServiceProvider.php:317-335` |
| endpoint | GET | `/lgpd` | `v2.portal.lgpd` | idem | `@lgpd` | pública | SEO + Blade fixo ou runtime | `Routes/web.php:11` |
| endpoint | GET | `/acessibilidade` | `v2.portal.acessibilidade` | idem | `@acessibilidade` | pública | idem | `Routes/web.php:12` |
| endpoint | GET | `/mapa-do-site` | `v2.portal.mapa_site` | idem | `@mapaSite` | pública | idem | `Routes/web.php:13` |
| endpoint | GET | `/ouvidoria` | `v2.portal.ouvidoria` | idem | `@ouvidoria` | pública | idem | `Routes/web.php:14` |
| endpoint (outro módulo) | GET | `/` | `v2.portal.home` | idem | `HomepagePortalController@index` | pública | homepage LSE; BasicPage não participa | `V2/Modules/Layouts/Homepage/Routes/web.php:8` |

Não há query params, paginação ou filtros no catch-all: o path é o único input. `HEAD` é tratado pelo mesmo handler (Laravel); `POST /{slug}` não existe (404/405).

## 6. Dados e modelo de domínio

### 6.1 Entrada da resolução

| Campo lido | Uso | Coluna |
|---|---|---|
| `slug` | predicado de igualdade (unique) e URL pública | `v2_basic_pages.slug` |
| `is_runtime` | filtro exclusivo do catch-all | `v2_basic_pages.is_runtime` |
| `content` | decisão de fallback e corpo da página | `v2_basic_pages.content` |
| `metadata.layout` | flags do layout | `v2_basic_pages.metadata` (jsonb) |
| `metadata.seo` | title/description/keywords/json_ld/image | `v2_basic_pages.metadata` (jsonb) |
| `title` | fallback de título e `summary_fragment` | `v2_basic_pages.title` |
| `id` | chave do índice | `v2_basic_pages.id` |
| `updated_at` | `lastmod` do sitemap | `v2_basic_pages.updated_at` |

Nenhuma outra tabela é lida no render público. `v2_basic_page_assets` não participa.

### 6.2 Projeção de busca

| Coluna do índice | Valor para BasicPage |
|---|---|
| `id` | `v2_uuid()` novo a cada tentativa (ignorado no conflito) |
| `title` | `v2_basic_pages.title` |
| `slug` | `v2_basic_pages.slug` |
| `module` | `'basicpage'` |
| `entity_type` | `'V2\Modules\Layouts\BasicPage\Models\BasicPage'` |
| `entity_id` | `v2_basic_pages.id` |
| `summary_fragment` | `title . ' - Página governamental fixa.'` |
| `search_deep_content` | `strip_tags(content)` |
| `tags` | `["pagina-fixa","institucional"]` (jsonb) |
| `created_at`/`updated_at` | `NOW()` (o `updated_at` é sempre renovado) |

Unique `(entity_type, entity_id)` garante o upsert (`PublicIndexerService.php:26-50`). O `id` novo em cada tentativa é descartado pelo `ON CONFLICT`. A URL pública **não** vem do índice: `SearchService::publicResultUrl` mapeia `module` → path, e `basicpage`/`default` → `url('/'.$slug)` (`SearchService.php:68-89`).

```mermaid
erDiagram
  V2_BASIC_PAGES ||--o{ V2_PUBLIC_SEARCH_INDEX : "entity_id = id (lógico)"
  V2_BASIC_PAGES ||--o{ V2_CMS_LINKS_INDEX : "entity_id = id (lógico)"
  V2_BASIC_PAGES {
    uuid id PK
    string slug UK
    boolean is_runtime
    text content
    jsonb metadata
  }
  V2_PUBLIC_SEARCH_INDEX {
    uuid id PK
    string module
    string entity_type
    uuid entity_id
    string slug
    text summary_fragment
    text search_deep_content
    jsonb tags
    tsvector search_vector
  }
```

## 7. Fluxos e estados

```mermaid
stateDiagram-v2
  [*] --> SemLinha
  SemLinha --> Fixa: migration/seed (content='')
  SemLinha --> Runtime: store (is_runtime=true)
  Fixa --> RuntimeView: saveContent com content != ''
  RuntimeView --> Fixa: saveContent com content = ''
  Runtime --> RuntimeView: saveContent com content != ''
  RuntimeView --> RuntimeView: saveContent (cache Redis permanece)
```

```mermaid
sequenceDiagram
  actor U as Cidadão
  participant MW as PageCache/PublicCache
  participant RT as Route /{slug}
  participant C as BasicPagePortalController
  participant DB as v2_basic_pages
  participant SEO as SEOService
  participant V as portal.runtime

  U->>MW: GET /minha-pagina
  MW->>MW: page:sha1(url) ?
  alt HIT
    MW-->>U: 200 (HTML antigo, X-Cache-Status: HIT)
  else MISS
    MW->>RT: next()
    RT->>C: runtime('minha-pagina')
    C->>DB: where slug + is_runtime=true
    DB-->>C: linha | vazio
    alt linha
      C->>SEO: setTitle/Description/Keywords/Cover/Schema
      C->>V: page + layoutParams
      V-->>C: HTML
      C-->>MW: 200
      MW->>MW: put page:sha1(url) TTL 300
      MW-->>U: 200 (X-Cache-Status: MISS, Cache-Control: public, max-age=60)
    else vazio
      C-->>MW: ModelNotFoundException -> 404
      MW-->>U: 404 (não cacheado)
    end
  end
```

## 8. UI CMS

Este fluxo é consumido pelo CMS em três pontos (detalhes de tela no `spec.md` §8):

- **Listagem**: `index()` monta `portal_url` por `is_runtime`/mapa fixo e entrega `seoData` de cada linha aos modais (`BasicPageCmsController.php:73-96`); qualquer mudança na resolução de URL pública precisa ser refletida aqui e no botão “Ver no portal” do editor (`Views/cms/editor.blade.php:36-52`).
- **Preview**: abre `<iframe src="{{ $url }}">` da URL pública (`Views/cms/preview.blade.php:49-55`), portanto mostra o resultado **com** cache e **com** o overlay runtime; não há render server-side do conteúdo em edição.
- **Editor**: mostra o badge `Conteúdo runtime ativo` quando `content` é não vazio e `Usando fallback (Blade fixo)` caso contrário — o mesmo critério de `renderWithRuntimeFallback` (`Views/cms/editor.blade.php:22-27`).

## 9. UI Portal

| Aspecto | Comportamento |
|---|---|
| Dados exibidos | `content` integral (HTML do autor) + cabeçalho/rodapé/sidebars conforme `layoutParams`. |
| Estrutura | layout de 3 áreas do `v2-portal::base`; a página ocupa `main-section-area`. |
| SEO — title | `bindBasicPage` sempre define `seoTitle`: `metadata.seo.title` > `$seoDefaults[slug].title` > `v2_basic_pages.title`; `pageTitle` + `siteTitle` só são usados pelo partial quando `$seoTitle` não existe (página sem registro). |
| SEO — description/keywords | `seoDescription`/`seoKeywords` com fallback em `v2_portal_configs.descricao/palavras_chaves`. |
| SEO — OG/Twitter | `og:type=website`, `og:url=url()->current()`, `og:title/og:description` iguais aos anteriores, `og:image` = `image_url` (absolutizada com `url()` se não começar por `http`). |
| SEO — canonical | Sempre `url()->current()`; sem override por página. |
| SEO — robots | Nenhuma meta `robots` no partial; não há `noindex` por página. |
| JSON-LD | `metadata.seo.json_ld` (array ou string JSON) + `$schemas` legados passados ao layout; um `<script>` por schema. |
| Mídia | Nenhuma imagem é resolvida pelo módulo; `image_url` só alimenta `og:image`. |
| A11y | Depende do layout global e do HTML do autor; o módulo não adiciona landmarks, skip links nem `lang`. |
| Cache | Redis 5 min (`page:<sha1>`), HTTP 60 s `must-revalidate`, `Vary: Accept-Encoding`; `X-Cache-Status` no Redis. |
| Invalidação | Nenhuma pelo módulo; manual via `Redis::del`/comando do Core. |

## 10. Configuração, permissões e flags

| Item | Default | Efeito neste fluxo | Fonte |
|---|---|---|---|
| `page.cache` (alias) | `PageCacheMiddleware` | liga o cache Redis de 5 min nas rotas web/fallback | `app/Http/Kernel.php` (alias); `V2ServiceProvider.php:297-301,330-334` |
| `public.cache` (alias) | `PublicCacheMiddleware` | adiciona `Cache-Control: public, max-age=60, must-revalidate` e `Vary` | `V2/Core/Middleware/PublicCacheMiddleware.php:87-104` |
| `cache.prefix` | `laravel` | prefixo das chaves `page:*` (usado em limpeza manual) | `V2/Seeds/BasicPages/SucesuHomepageSeeder.php:414-421` |
| `v2.support.email` | `suporte@maxima.inf.br` | não afeta o portal; afeta quem edita o conteúdo que o portal mostra | `config/v2.php:4-6` |
| `module.active:BasicPage` | ativo se sem flag | desativa **apenas** o CMS (`cms/paginas-fixas*`); o portal continua servindo as páginas | `ModuleAccessMiddleware.php:47-67`; `V2ServiceProvider.php:302-306` |
| `v2_portal_configs.titulo_site/descricao/palavras_chaves` | `[L]` (banco) | fallback de SEO quando o singleton não tem valor | `SEOServiceProvider.php:50-63` |
| permissão `BasicPage` | `[L]` | não participa do portal público (sem auth) | `V2PermissionMiddleware.php:76-126` |

## 11. Observabilidade, erros e limites

| Evento | Sinal | Fonte |
|---|---|---|
| Slug inexistente / não-runtime | `404` padrão do Laravel (`ModelNotFoundException`), sem log | `BasicPagePortalController.php:149` |
| Slug fora da regex | `404` de rota, sem consulta | `fallback.php:21` |
| Redis indisponível | `Log::warning` `[PageCache] Redis indisponivel, servindo sem cache: {msg}` + resposta sem cache | `PageCacheMiddleware.php:62-65` |
| Falha de indexação | `Log::error` `[Search] Public Indexing failed: {msg}` (não propaga) | `PublicIndexerService.php:54-56` |
| Falha no catch-up de boot | `Log::warning` `[BasicPage] Inicializacao de busca ignorada: {msg}` | `V2ServiceProvider.php:162-165` |
| Acesso a metadados | `SEOService::trackAccess()` por render | `SEOServiceProvider.php:38` |
| Auditoria do conteúdo | `activity_log` (`basicpage_runtime_save`), não do acesso público | `BasicPageCmsController.php:160-168` |
| Headers de diagnóstico | `X-Cache-Status: HIT\|MISS` | `PageCacheMiddleware.php:39,58` |

Limites: 1 query por miss; cache 300 s; HTTP 60 s; sem rate limit no portal; sem limite de tamanho de `content` no render; erro de view (ex.: `metadata` como string) propaga 500 para o cidadão.

## 12. Seeds e dados iniciais

| Origem | Efeito no fluxo público | Fonte |
|---|---|---|
| Migration `create_basic_pages_table` | cria as 5 linhas fixas com `content=''` → todas as rotas fixas começam no Blade | `Migrations/2026_06_29_092300_create_basic_pages_table.php:37-55` |
| `DATA-SEEDS-BASICPAGES` | cria linhas “sombra” (`noticias`, `associados`, `convenios`, `agenda`, `fale-conosco`, `parceiros-institucionais`), algumas `is_runtime=true`; o portal usa as rotas dos módulos | `V2/Seeds/BasicPages/NoticiasSeeder.php:70-106` |
| `SucesuHomepageSeeder` | zera `content` da homepage (força fallback), grava SEO SUCESU e limpa `page:*` do Redis manualmente | `V2/Seeds/BasicPages/SucesuHomepageSeeder.php:396-421` |
| `V2ServiceProvider` (console) | indexa páginas sem linha em `v2_public_search_index` | `V2ServiceProvider.php:145-166` |
| `v2:reindex-public` | reindexa todas as BasicPages via model (dispara `syncToSearch`) | `V2/Services/Search/Console/Commands/ReindexPublicSearch.php:113-135` |

## 13. Lacunas

| ID | Lacuna | Onde procurar | Impacto |
|---|---|---|---|
| `LG-BASICPAGE-RT-01` | Não foi possível confirmar se algum pipeline externo compila `v2_basic_pages.content` antes do render. | deploy/plugins, `compileString` fora do repo | Define se snippets Blade funcionam no conteúdo. |
| `LG-BASICPAGE-RT-02` | Não há registro de invalidação de cache após edição; não se sabe se cron/hook de deploy limpa `page:*`. | `ClearPageCache`, crontab, CI | Janela real de conteúdo desatualizado. |
| `LG-BASICPAGE-RT-03` | Não foi medido o custo de `trackAccess()` por render nem seu efeito em páginas em cache (o partial roda no MISS, não no HIT). | `SVC-SEO` | Métricas de acesso subestimadas em HIT. |
| `LG-BASICPAGE-RT-04` | Não há teste que fixe a precedência fallback × rotas de módulo. | `tests/`, `LG-CORE-BOOT-05` | Regressão silenciosa ao reordenar o boot. |
| `LG-BASICPAGE-RT-05` | Não se conhece o conteúdo de `v2_portal_configs` em produção (fallback de SEO). | banco/produção | Título/description exibidos quando a página não tem SEO. |

## 14. Checklist de paridade

- [ ] `PC-BASICPAGE-RT-01` — `GET /noticias` executa o controller do módulo Notícias, nunca `BasicPagePortalController@runtime`. Fonte: `V2ServiceProvider.php:134-137,317-335`.
- [ ] `PC-BASICPAGE-RT-02` — `GET /{slug}` com `is_runtime=true` retorna `200` e o HTML salvo **sem** escaping. Fonte: `BasicPagePortalController.php:149-157`; `runtime.blade.php:4`.
- [ ] `PC-BASICPAGE-RT-03` — `GET /{slug}` com linha `is_runtime=false` retorna `404`. Fonte: `BasicPagePortalController.php:149`.
- [ ] `PC-BASICPAGE-RT-04` — `GET /Maiuscula`, `/com_underscore` e `/com.espaco` retornam `404` sem query. Fonte: `fallback.php:21`.
- [ ] `PC-BASICPAGE-RT-05` — `GET /lgpd` com `content=''` renderiza o Blade fixo; com qualquer conteúdo renderiza `portal.runtime`. Fonte: `BasicPagePortalController.php:56-68,88-95`.
- [ ] `PC-BASICPAGE-RT-06` — SEO do banco sobrescreve `pageTitle/pageDescription` hardcoded nas quatro páginas fixas. Fonte: `SEOServiceProvider.php:52-60`; `metadata/seo.blade.php:2-3`.
- [ ] `PC-BASICPAGE-RT-07` — `canonical` é `url()->current()` e não há meta `robots`. Fonte: `metadata/seo.blade.php:1-47`.
- [ ] `PC-BASICPAGE-RT-08` — JSON-LD de `metadata.seo.json_ld` aparece como `<script type="application/ld+json">`; JSON inválido é ignorado sem erro. Fonte: `BasicPagePortalController.php:44-49`.
- [ ] `PC-BASICPAGE-RT-09` — Salvar uma página produz upsert imediato em `v2_public_search_index` e `v2_cms_links_index`. Fonte: `Models/BasicPage.php:136-144,222-254`.
- [ ] `PC-BASICPAGE-RT-10` — `php artisan` indexa páginas ausentes do índice; request web não executa o catch-up. Fonte: `V2ServiceProvider.php:145-166`.
- [ ] `PC-BASICPAGE-RT-11` — `php artisan v2:reindex-public` atualiza `summary_fragment`/`search_deep_content` de todas as páginas. Fonte: `ReindexPublicSearch.php:113-135`.
- [ ] `PC-BASICPAGE-RT-12` — Segunda requisição idêntica em <5 min retorna `X-Cache-Status: HIT`; salvar não invalida. Fonte: `PageCacheMiddleware.php:31-59`.
- [ ] `PC-BASICPAGE-RT-13` — `404` nunca é servido do Redis. Fonte: `PageCacheMiddleware.php:44`.
- [ ] `PC-BASICPAGE-RT-14` — Resposta pública traz `Cache-Control: public, max-age=60, must-revalidate` e `Vary: Accept-Encoding`. Fonte: `PublicCacheMiddleware.php:87-104`.
- [ ] `PC-BASICPAGE-RT-15` — Nenhum CSS/JS de `v2_basic_page_assets` é carregado automaticamente no portal. Fonte: `runtime.blade.php:1-56` (ausência).
- [ ] `PC-BASICPAGE-RT-16` — Linhas de `v2_basic_pages` aparecem no sitemap com `priority 1.0/daily` (homepage) e `0.6/monthly` (demais). Fonte: `SeoPortalController.php:149-172`.
- [ ] `PC-BASICPAGE-RT-17` — `GET /` é servida pela homepage LSE, não por `BasicPagePortalController@homepage`. Fonte: `V2/Modules/Layouts/Homepage/Routes/web.php:8`; `HomepagePortalController.php:45-68`.

## 15. Evidências consultadas

- `V2/Core/Routes/fallback.php:1-21`; `V2/Core/Providers/V2ServiceProvider.php:60-218,289-335`.
- `V2/Modules/Layouts/BasicPage/Controllers/BasicPagePortalController.php:1-159`; `Routes/web.php:1-21`.
- `V2/Modules/Layouts/BasicPage/Models/BasicPage.php:32-255`.
- `V2/Modules/Layouts/BasicPage/Views/portal/{runtime,lgpd,acessibilidade,mapa_site,ouvidoria,homepage}.blade.php`.
- `V2/layouts/portal/{app-layout,base,metadata}.blade.php`; `V2/layouts/portal/metadata/{seo,gtag}.blade.php`.
- `V2/Core/Middleware/{PageCacheMiddleware,PublicCacheMiddleware,ModuleAccessMiddleware,V2PermissionMiddleware}.php`.
- `V2/Services/SEO/{Providers/SEOServiceProvider.php,Services/SEOService.php,Controllers/SeoPortalController.php}`.
- `V2/Services/Search/{Services/PublicIndexerService.php,Services/SearchService.php,DTOs/PublicSearchDTO.php,DTOs/CMSIndexDTO.php,Console/Commands/ReindexPublicSearch.php,Migrations/2026_06_14_000000_create_search_indexes_tables.php}`.
- `V2/Services/Media/Services/ImageRedigestService.php:38-70,217-340`; `V2/Modules/Layouts/BasicPage/Services/BasicPageAssetManagerService.php:169-186`.
- `V2/Modules/Layouts/Homepage/{Routes/web.php,Controllers/HomepagePortalController.php}`; `V2/Modules/Support/StructuralLandingEditor/Services/BlockRenderer.php:130-160`; `V2/Services/LandingSectionsEditor/README.md`.
- `V2/Seeds/BasicPages/{NoticiasSeeder,SucesuHomepageSeeder}.php`; `config/v2.php:1-14`; `config/app.php:84`.
