D-7 — Transparência
Colônia de leitura pura — Fase 1
Propósito
Seção intitulada “Propósito”Consolida o histórico de eventos de cada demanda em visões públicas de leitura. É a colônia de output principal para cidadãos, pesquisadores e auditores. Aplica o padrão CQRS: o write model está nas colônias de origem; aqui ficam as projeções de leitura, otimizadas para consulta.
Duas projeções principais no MVP: timeline por demanda (lista cronológica inversa, com os eventos mais recentes primeiro, e descrição legível em português) e dashboard por unidade cívica (indicadores agregados da unidade e das unidades menores dentro dela, calculados on-demand a partir dos snapshots). A visão pública de conselheiro (demandas acompanhadas, status, tempo médio) entra na Fase 2, quando houver volume suficiente de ciclos concluídos.
Não processa dados novos, não toma decisões, não produz eventos de negócio. Consome eventos de todas as demais colônias e projeta o estado legível. O banco de leitura é descartável e reconstruível a qualquer momento — basta reprocessar todos os eventos do barramento.
A D-7 expõe seus próprios endpoints REST públicos. A regra de que o BFF D-1a é o ponto de entrada HTTP se aplica a operações síncronas de escrita iniciadas pelo front-end. Consulta pública de leitura sem autenticação não se encaixa nessa categoria — o padrão CQRS naturalmente separa a API de leitura (D-7) da API de escrita (BFF D-1a).
Referência
Seção intitulada “Referência”- Ficha técnica da colônia: Apêndice B - Colônias.md, seção “D-7 — Transparência”
- Padrão CQRS e projeções de leitura: contexto_IA.md, seções 10 (Infraestrutura cívica digital) e 22 (O Formigueiro)
- A D-7 é o décimo-terceiro elo na ordem de implementação do MVP (Bloco 2, após D-6b).
- Colônias a montante: todas as colônias de negócio que publicam eventos consumidos pela D-7 (D-1a a D-6b, D-12, D-1c, D-1d e L-1 a L-3).
- Consumidores dos dados da D-7: front-end (via REST API pública), pesquisadores, auditores externos.
1. Estrutura do Módulo NestJS
Seção intitulada “1. Estrutura do Módulo NestJS”A D-7 é um módulo NestJS com encapsulamento próprio dentro do monolito modular do MVP. Consome eventos do barramento via EventBusService (N-0a), persiste projeções de leitura no schema d7 e expõe endpoints REST públicos para consulta. Não publica eventos.
1.1 Árvore de diretórios
Seção intitulada “1.1 Árvore de diretórios”src/demanda/d-7-transparencia/├── d7.module.ts # Module definition├── d7.service.ts # Handlers de evento, idempotência, projeções├── d7.controller.ts # REST público├── d7.repository.ts # Acesso ao schema d7├── types.ts # RegistroTimeline, RegistroSnapshot, IndicadoresUc, FiltrosDashboard e demais tipos internos├── normalizacao-busca.ts # Normalização de nome para a busca de UC (sem maiúsculas, acento ou pontuação)├── uf-ibge.ts # Código IBGE da UF → sigla├── projection/│ ├── descricao-mapper.ts # tipo_evento + payload → descrição legível em português│ ├── dashboard-builder.ts # Indicadores agregados a partir de d7.demanda_snapshots│ ├── relatorio-builder.ts # Relatório agregado da UC por categoria│ ├── rebuild.service.ts # Replay completo do barramento│ ├── d7.constants.ts # Tipos consumidos, limiares, tamanhos de página e bbox do Brasil│ └── bbox-geo.ts # Validação pura da bounding box└── dto/ ├── timeline-query.dto.ts # Query params de GET /api/d7/timeline/:demanda_id ├── timeline-entry.dto.ts # Response do timeline entry ├── dashboard-query.dto.ts # Query params de GET /api/d7/dashboard/:uc_id ├── dashboard-response.dto.ts # Response do dashboard ├── demanda-resumo.dto.ts # Response de GET /api/d7/demanda/:demanda_id/resumo ├── relatorio-demanda.dto.ts # Response de GET /api/d7/demanda/:demanda_id/relatorio ├── relatorio-uc-query.dto.ts # Query params de GET /api/d7/uc/:uc_id/relatorio ├── relatorio-uc-response.dto.ts # Response do relatório da UC ├── historico-uc-query.dto.ts # Query params de GET /api/d7/uc/:uc_id/historico ├── historico-uc-response.dto.ts # Response do histórico da UC ├── demandas-geo-query.dto.ts # Query params de GET /api/d7/demandas ├── demandas-geo-response.dto.ts # Response da listagem de demandas ├── lugares-geo-query.dto.ts # Query params de GET /api/d7/lugares ├── lugares-geo-response.dto.ts # Response da listagem de lugares ├── lugar-resumo.dto.ts # Response de GET /api/d7/lugar/:lugar_id/resumo ├── ranking-uc-query.dto.ts # Query params de GET /api/d7/ranking/:uc_id ├── ranking-uc-response.dto.ts # Response do ranking da UC ├── uc-poligonos-query.dto.ts # Query params de GET /api/d7/uc/poligonos └── uc-poligono-response.dto.ts # Response da listagem de polígonos1.2 Module definition
Seção intitulada “1.2 Module definition”@Module({ imports: [HierarquiaUcModule], controllers: [D7Controller], providers: [ D7Service, RebuildService, D7Repository, DescricaoMapper, RelatorioBuilder, PapelOperadorGuard, { provide: DashboardBuilder, useFactory: (repo: D7Repository): DashboardBuilder => new DashboardBuilder(repo), inject: [D7Repository], }, ], exports: [],})export class D7Module implements OnModuleInit { constructor(private readonly d7Service: D7Service) {}
async onModuleInit(): Promise<void> { await this.d7Service.iniciar(); }}1.3 Pontos de atenção
Seção intitulada “1.3 Pontos de atenção”- O módulo não é
@Global(). A D-7 não é dependência de nenhuma outra colônia. Outras colônias não consomem seus dados diretamente — acessam via REST API pública. - O módulo não importa
EventBusModuleexplicitamente.EventBusModuleé@Global(), e oEventBusServiceé injetável sem import. - O módulo não importa
RegistryModule. A validação de schema dos eventos é feita pelo Event Bus na publicação. A D-7 é consumidora — recebe eventos já validados. - O rate limiting dos endpoints vem dos decorators
@Throttledo controller. OThrottlerModuleé configurado globalmente, não pelo módulo da D-7. - O
OnModuleInitdispara o protocolo de inicialização: seed dos offsets, replay de eventos perdidos e registro dos consumidores. - O módulo não importa
ScheduleModule. Não há operações agendadas na D-7 — todas as atualizações são reativas a eventos. - O módulo importa
HierarquiaUcModuleda camada compartilhada (src/shared/hierarquia-uc/). OHierarquiaUcServiceresolve a subárvore de uma UC com cache em memória de 6 horas e guarda de ciclo; a leitura é read-only e serve as cinco leituras públicas por UC: dashboard, ranking, relatório da UC, histórico da UC e filtrouc_idde/api/d7/demandas. - O módulo lê
core.uc_polygonsdiretamente (read-only, dados de referência) nos endpoints de polígono, no relatório da UC, no resumo e no dashboard. É a exceção controlada registrada no AGENTS.md do repo api; migra para colônia geo dedicada na Fase 2. - O módulo não acessa Redis. Dashboard é calculado on-demand a partir de
d7.demanda_snapshotscom queries agregadas no PostgreSQL. Para o volume do MVP, índices adequados são suficientes. - O processamento é serializado por uma fila interna de promises (
filaProcessamento). O estado de rebuild (iniciarRebuild,estaRebuildando,encerrarRebuild) descarta eventos que chegam durante a reconstrução.
1.4 Serviço — responsabilidades e contrato
Seção intitulada “1.4 Serviço — responsabilidades e contrato”A interface é interna ao módulo. Nenhuma outra colônia injeta D7Service. A comunicação com o exterior é via barramento (consumo de eventos) e REST (exposição de projeções).
Contrato real do serviço:
iniciar(): Promise<void>;registrarConsumidores(): void;despacharEvento(tipoEvento: string, evento: EventoRecebido): Promise<void>;estaRebuildando(): boolean;iniciarRebuild(): boolean;encerrarRebuild(): void;liberarConteudoSuspenso(demandaId: string): Promise<void>;
// Um handler por tipo consumido em produçãoprocessarDemandaRecebida(evento): Promise<void>;processarDemandaNormalizada(evento): Promise<void>;processarDemandaCategorizada(evento): Promise<void>;processarDemandaGeorreferenciada(evento): Promise<void>;processarDemandaRanqueada(evento): Promise<void>;processarRankingAtualizado(evento): Promise<void>;processarAgendaGerada(evento): Promise<void>;processarAgendaItemDisponivel(evento): Promise<void>;processarConselheiroSorteado(evento): Promise<void>;processarConselheiroDemandaIniciada(evento): Promise<void>;processarConselheiroAtualizacaoPublicada(evento): Promise<void>;processarConselheiroPrazoProximo(evento): Promise<void>;processarDemandaConcluida(evento): Promise<void>;processarConselheiroCicloConcluido(evento): Promise<void>;processarLugarCadastrado(evento): Promise<void>;processarLugarGeorreferenciado(evento): Promise<void>;processarLugarValidado(evento): Promise<void>;processarLugarDesativado(evento): Promise<void>;processarDemandaConfirmada(evento): Promise<void>;processarConclusaoConfirmada(evento): Promise<void>;processarDemandaEvidenciaAdicionada(evento): Promise<void>;processarDuplicidadeCandidataDetectada(evento): Promise<void>;processarDuplicidadeAgregada(evento): Promise<void>;processarAnexoProcessado(evento): Promise<void>;processarAnexoModerado(evento): Promise<void>;processarAnexoRemovido(evento): Promise<void>;processarModeracaoDecidida(evento): Promise<void>;
// Stubs da Fase 2processarStub(evento): Promise<void>;
// D7Service consome 27 tipos em produção + 3 stubs.// Não publica eventos.1.5 Controller REST
Seção intitulada “1.5 Controller REST”A D-7 expõe endpoints REST públicos para consulta das projeções. Dados são públicos — sem autenticação. Rate limiting é aplicado por IP. A única exceção é POST /api/d7/rebuild, operação administrativa sob PapelOperadorGuard (JWT de operador e allowlist de IP).
@ApiTags('Transparência')@Controller('d7') // prefixo global /api aplicado no main.tsexport class D7Controller { constructor( private readonly repo: D7Repository, private readonly d7Service: D7Service, private readonly dashboardBuilder: DashboardBuilder, private readonly relatorioBuilder: RelatorioBuilder, private readonly rebuildService: RebuildService, private readonly hierarquia: HierarquiaUcService, ) {}
@Get('timeline/:demanda_id') // 60/min @Get('dashboard/:uc_id') // 30/min @Get('demanda/:demanda_id/resumo') // 60/min @Get('demanda/:demanda_id/relatorio') // 30/min @Get('uc/:uc_id/relatorio') // 15/min @Get('uc/:uc_id/historico') // 60/min @Get('demandas') // 60/min @Get('lugares') // 60/min @Get('lugar/:lugar_id/resumo') // 60/min @Get('ranking/:uc_id') // 60/min @Get('uc/poligonos') // 30/min
@Post('rebuild') @UseGuards(PapelOperadorGuard) @Throttle({ default: { limit: 1, ttl: 3600000 } }) async triggerRebuild(): Promise<{ status: string }> { if (!this.d7Service.iniciarRebuild()) { throw new ConflictException('Reconstrução já em andamento'); } try { await this.rebuildService.rebuildCompleto(); return { status: 'reconstrução concluída' }; } finally { this.d7Service.encerrarRebuild(); } }}Os identificadores de rota são validados como UUID v4 com ParseUUIDPipe (400 quando inválidos). Os DTOs usam class-validator e o Swagger é completo.
A cobertura por UC é resolvida em resolverUcIds(ucId): o controller lê a referência de core.uc_polygons (buscarReferenciaUc), devolve null para o nível 7 (Brasil, sem filtro de UC), [ucId] quando a UC não está na malha e [ucId, ...subárvore] nos demais níveis. A regra vale para o dashboard, o ranking, o relatório da UC, o histórico da UC e o filtro uc_id de getDemandasGeo. O relatório da demanda (GET /api/d7/demanda/:id/relatorio) permanece por UC exata da demanda.
Endpoints
Seção intitulada “Endpoints”| Método | Rota | Descrição |
|---|---|---|
| GET | /api/d7/timeline/:demanda_id |
Timeline completa da demanda, paginada. Ordenação cronológica inversa (DESC, eventos mais recentes primeiro). Query params: limite (padrão 20, máx 100), deslocamento, tipo_evento (filtro opcional). |
| GET | /api/d7/dashboard/:uc_id |
Dashboard de indicadores agregados da unidade cívica e das unidades menores dentro dela. O nível 7 (Brasil) agrega toda a base, inclusive demandas sem UC resolvida. Query params: data_inicio, data_fim (janela temporal, opcional). |
| GET | /api/d7/demanda/:demanda_id/resumo |
Snapshot atual da demanda: status, categoria, UC, conselheiro, timestamps de marcos, coordenadas públicas, agregado, evidências, marca de conteúdo removido e os campos públicos resumo_agregado, resumo_ciclo, resumos_atualizado_em e caminho quando projetados. |
| GET | /api/d7/demanda/:demanda_id/relatorio |
Relatório público completo da demanda, com categoria e subcategoria da taxonomia, responsáveis nos três níveis federativos, evidências, timeline crescente, dias em aberto e os mesmos resumo_agregado, resumo_ciclo, resumos_atualizado_em e caminho do resumo. |
| GET | /api/d7/uc/:uc_id/relatorio |
Relatório público agregado da unidade cívica e das unidades menores dentro dela, com resumo por categoria, responsáveis, demandas agrupadas e sem categoria. O nível 7 (Brasil) agrega toda a base. |
| GET | /api/d7/uc/:uc_id/historico |
Histórico público das demandas concluídas da UC e das unidades menores dentro dela, paginado, com filtros de categoria, período de conclusão e título. O nível 7 (Brasil) agrega toda a base. Contrato na seção 1.12. |
| GET | /api/d7/demandas |
Listagem geoespacial de demandas por bounding box. Exclui removida, agregada e concluida no filtro padrão e devolve total_confirmacoes e agregado_representante_id por linha. Contrato na seção 1.8. |
| GET | /api/d7/lugares |
Listagem geoespacial de lugares por bounding box. Residências nunca são retornadas. Filtra disputado e desativado e devolve status_confianca por linha. Contrato na seção 1.8. |
| GET | /api/d7/lugar/:lugar_id/resumo |
Resumo público de um lugar com status de confiança, tipificação e unidade cívica. 404 para lugar disputado ou desativado. Contrato na seção 1.10. |
| GET | /api/d7/ranking/:uc_id |
Ranking público da UC e das unidades menores dentro dela, ordenado por score final decrescente, com busca opcional por trecho do título e posição derivada da ordenação corrente. O nível 7 (Brasil) ranqueia toda a base. Contrato na seção 1.11. |
| GET | /api/d7/uc/poligonos |
Polígonos de UC por nível (e UC específica), lidos de core.uc_polygons. Contrato na seção 1.8. |
| POST | /api/d7/rebuild |
Dispara reconstrução completa das projeções via replay do barramento. Exige papel de operador. Rate limit: 1/hora; 409 quando uma reconstrução já está em andamento. |
1.6 Separação de responsabilidades — projeções internas da D-7
Seção intitulada “1.6 Separação de responsabilidades — projeções internas da D-7”| Projeção | Responsável | Fonte dos dados | Fase |
|---|---|---|---|
| Timeline da demanda | D-7 (d7.timeline_entries) |
Todos os eventos com demanda_id |
MVP |
| Snapshot da demanda | D-7 (d7.demanda_snapshots) |
Agregado incremental de eventos por demanda_id |
MVP |
| Perfil de caminho por município e subcategoria | D-7 (d7.caminhos) |
caminho.atualizado da D-24 |
MVP |
| Dashboard por UC | D-7 (DashboardBuilder) |
Derivado on-demand de d7.demanda_snapshots agrupado por UC |
MVP |
| Visão de conselheiro | D-7 (a definir) | Derivado de d7.demanda_snapshots + d7.timeline_entries filtrado por conselheiro_id |
Fase 2 |
| Snapshot público versionado | D-23 (Fase 3) | Consolida dados da D-7 + outras colônias | Fase 3 |
| Datasets anonimizados | D-15 (Fase 2) | Consolida projeções da D-7 com anonimização | Fase 2 |
1.7 Descrição legível como função pura
Seção intitulada “1.7 Descrição legível como função pura”O DescricaoMapper é uma função pura: recebe (tipo_evento, payload) e retorna string. Não acessa banco, não tem estado. A troca de idioma é troca dos templates — a lógica de projeção não se altera.
gerar(tipoEvento: string, payload: Record<string, unknown>, timestampPadrao?: string): string;O timestampPadrao é o timestamp do evento no core.event_log, usado como fallback quando o payload não carrega data. Os templates vivem no descricao-mapper.ts; o texto das atualizações do conselheiro vem de gerarDescricaoAtualizacaoPublicada em shared/descricao-atualizacao.ts. Antes de persistir, a descrição passa por sanitizarTexto e o resultado é truncado em 10.000 caracteres. Quando a sanitização redige algum dado, a entrada nasce com visibilidade interno.
Os templates são definidos na seção 4.1. Nenhuma IA é usada na geração das descrições. A geração é substituição de template determinística. O dado bruto de cada evento é preservado em timeline_entries.dados_relevantes (JSONB), permitindo auditoria independente da descrição.
1.8 Endpoints de listagem geoespacial
Seção intitulada “1.8 Endpoints de listagem geoespacial”Os três endpoints seguem o mesmo padrão dos demais: rotas públicas, DTOs com class-validator e Swagger completo. A bounding box compartilha a validação pura validarBboxConsulta em projection/bbox-geo.ts (área de cobertura do Brasil: lat -33.75..5.27, lng -73.99..-28.84; lados mínimo maior que zero e máximo de 2 graus).
GET /api/d7/demandas — rate limit 60/min.
Query:
lat_min,lat_max,lng_min,lng_max— obrigatórios, numéricos, dentro da área Brasil (incluindo Fernando de Noronha)status— opcional, repetível; ausente = todos excetoremovida,agregadaeconcluida; um valor explícito aceita qualquer status válido, inclusiveconcluidacategoria_id— opcional, máx. 10 caracteresuc_id— opcional, UUID v4nivel_precedencia— opcional, inteiro 1 a 5limite— padrão 100, mínimo 1, máximo 1000deslocamento— padrão 0, máximo 100000
Resposta { linhas, total, limite, deslocamento }. Cada linha: { demanda_id, titulo?, coordenada_lat, coordenada_lng, status, categoria_id?, subcategoria_id?, nivel_precedencia?, unidade_civica_id?, data_recebida?, total_confirmacoes, agregado_representante_id? }. Ordenação por data_recebida DESC (NULLS LAST). Snapshot sem coordenadas não aparece na listagem. O filtro uc_id cobre a UC e as unidades menores dentro dela; o nível 7 (Brasil) não aplica filtro de UC. Sem uc_id, o comportamento não muda.
GET /api/d7/lugares — rate limit 60/min.
Query: mesmos parâmetros de bbox da listagem de demandas; tipo_lugar opcional (organizacao ou equipamento_publico; residencia é rejeitado com 400); limite (padrão 100, máximo 1000); deslocamento.
Resposta { linhas, total, limite, deslocamento }. Cada linha: { lugar_id, coordenada_lat, coordenada_lng, tipo_lugar, nome?, subtipo?, unidade_civica_id?, status, status_confianca, data_recebida? }. O filtro exclui disputado e desativado; o lugar provisório permanece visível. A listagem não devolve o contador de confirmações — o contador vive no resumo.
GET /api/d7/uc/poligonos — rate limit 30/min.
Query: nivel opcional (inteiro 1 a 7; ausente devolve todos os níveis); nome opcional (busca parcial normalizada, sem diferenciar maiúsculas, acentos ou pontuação, ex.: “sao paulo” encontra “São Paulo”); uc_id opcional (UUID v4); limite (padrão 100, máximo 500); deslocamento; incluir_geometria (booleano, padrão true).
Resposta { linhas, total, limite, deslocamento }. Cada linha: { uc_id, nome, nivel, parent_uc_id?, parent_nome?, uf?, fonte, geometria? } com geometria GeoJSON, omitida quando incluir_geometria=false. uf é a sigla do estado resolvida de metadados.codigo_uf (vale para municípios e bairros). parent_nome é o nome da unidade cívica pai, resolvido para qualquer nível da hierarquia, agora completa (município→UF→região→Brasil); o seletor do web usa o campo para mostrar o pai de um bairro (SP · São Paulo · Pinheiros) e a busca resolve o nome do pai por uma segunda consulta limitada aos ids da página. No MVP o nível 5 corresponde à unidade da federação e o 6 à macro-região do IBGE; a unidade cívica metropolitana do compilado fica declarada como ausente até haver malha. O seletor do acompanhamento usa os níveis 5 a 7 como atalhos de cobertura (estado, região e Brasil). O seletor de UC do dashboard, do perfil e do filtro de empresas usa incluir_geometria=false e limite pequeno, para respostas leves de autocomplete. A busca por nome usa a coluna core.uc_polygons.nome_busca, mantida pelo trigger uc_polygons_normalizar_nome (migration 20260913120000_uc_polygons_nome_busca). Não há filtro por bbox: filtrar 5.573 municípios com polígonos de até 94 mil vértices em aplicação custaria demais sem PostGIS. A malha municipal completa não é servida de uma vez; a paginação limita o payload.
Regra LGPD fixa: residências nunca entram em endpoint público. A projeção não grava tipo_lugar = 'residencia'; o DTO de lugares rejeita o filtro; o WHERE do repository aplica tipo_lugar IN (organizacao, equipamento_publico) por padrão. A exclusão vale para o MVP e para a Fase 2.
Exceção controlada de leitura: o endpoint de polígonos lê core.uc_polygons diretamente (read-only, dados de referência), registrada no AGENTS.md do repo api. Migra para colônia geo dedicada na Fase 2.
1.9 Endpoints de leitura da demanda e da UC
Seção intitulada “1.9 Endpoints de leitura da demanda e da UC”Os endpoints compilam as projeções existentes em artefatos públicos para consulta e para acionar o órgão responsável. Todos seguem o padrão dos demais: rotas públicas sem autenticação, DTOs com Swagger completo e rate limit por IP. A página imprimível é responsabilidade do web.
GET /api/d7/demanda/:demanda_id/resumo — rate limit 60/min.
Sem query params. 404 quando a demanda não possui snapshot. Devolve título e descrição públicos (omitidos quando ausentes), subcategoria resolvida pela taxonomia (subcategoria_id, nome e descricao_formal), status, categoria e nível, unidade cívica com nome e nível resolvidos de core.uc_polygons, coordenada_lat e coordenada_lng opcionais (lidos de d7.demanda_snapshots, omitidos quando ausentes), datas de recebimento, início e conclusão, conselheiro, confiança geo, score, total de atualizações, dias até a conclusão, texto da última atualização, metadata, total_confirmacoes, agregado de duplicidade (agregado_representante_id e agregado_membros), evidências públicas com URL assinada por 5 minutos e descricao opcional por imagem (cruzando o object_key_temp da evidência com descricoes_midia) e conteudo_removido (booleano, sempre presente) com conteudo_removido_em opcional, derivados da timeline de remoção (anexo.removido ou moderacao.decidida com decisão removido). Os campos resumo_agregado e resumo_ciclo (textos públicos sanitizados das colônias de origem), resumos_atualizado_em (carimbo da última projeção dos resumos) e caminho (perfil agregado do par município e subcategoria, com total_casos, prazo_mediano_dias, canais, orgaos, gargalos, documentos, dossie, versao_metodo e gerado_em) entram quando projetados. O web usa título, descrição e subcategoria no acompanhamento público e no workspace do conselheiro; usa as coordenadas no mini-mapa somente leitura e no deep link do mapa principal; usa a descrição das evidências no alt e no bloco marcado como IA; e usa a marca de remoção no aviso do acompanhamento e no link para a explicação do perfil.
GET /api/d7/demanda/:demanda_id/relatorio — rate limit 30/min.
Sem query params. demanda_id validado como UUID v4 (400 quando inválido). 404 quando a demanda não possui snapshot, com corpo no padrão dos demais 404 da D-7.
Resposta (RelatorioDemandaDto):
{ demanda_id, titulo?, descricao? (descrição pública sanitizada; null quando não projetada ou removida), categoria?: { categoria_id, nome, area_id, area_nome } (resolvida pela taxonomia) subcategoria?: { subcategoria_id, nome, descricao_formal } (resolvida pela taxonomia) status: { codigo, descricao }, data_recebida?, data_conclusao?, coordenadas?: { lat, lng }, unidade_civica_id?, score_final?, posicao_ranking?, total_confirmacoes, agregado_membros: [ { demanda_id, titulo?, data_recebida?, total_confirmacoes } ], evidencias: [ { evidencia_id, tipo, url, via, criado_em, descricao? } ], resumo_agregado?, resumo_ciclo?, resumos_atualizado_em?, caminho?: { municipio_id, subcategoria_id, categoria_id?, total_casos, prazo_mediano_dias?, canais, orgaos, gargalos: [ { descricao, ocorrencias } ], documentos, dossie, versao_metodo, gerado_em }, timeline: [ { id, tipo_evento, timestamp, descricao, dados_relevantes } ], dias_em_aberto?, responsaveis: { federal, estadual, municipal }, gerado_em}posicao_ranking só existe para demanda ranqueada com UC e data de recebimento. dias_em_aberto conta do recebimento até a conclusão (ou até a geração). responsaveis vem do mapa shared/taxonomia/responsaveis-categorias.ts, com responsáveis genéricos quando a categoria é desconhecida. A timeline devolve apenas entradas públicas (visibilidade = 'publico'), sem identidade, em ordem cronológica crescente (do recebimento à conclusão). A subcategoria é resolvida quando o snapshot tem subcategoria_id; o descricao_formal é o texto público da taxonomia que explica do que se trata o tipo de demanda. A descricao é o texto público do cidadão sanitizado na normalização; a descrição automática das imagens vive em descricoes_midia e aparece por evidência no descricao do DTO de evidência e no bloco marcado como IA da vitrine. Depois de um rebuild completo a coluna descricao fica nula, porque o descricao_limpa do log de eventos é hash e não pode ser reconstruído por replay; as descrições de mídia, preservadas em claro pela redação, sobrevivem ao replay. O caminho é o perfil de d7.caminhos lido pela chave da demanda (município e subcategoria, com fallback para a categoria) e some quando o perfil não tem caso ou dossiê. O acompanhamento e o relatório exportado mostram a mesma visão: os dois endpoints devolvem os mesmos campos de resumo e caminho.
GET /api/d7/uc/:uc_id/relatorio — rate limit 15/min.
Query opcionais:
categoria_id— string, máx. 10 caracteresstatus— um dos status válidos de demandadata_inicio/data_fim— ISO-8601, janela sobredata_recebida
UC malformada devolve 400; datas inválidas devolvem 400; data_inicio posterior a data_fim devolve 400. UC sem demandas devolve 200 com total zerado. A UC é a raiz de uma subárvore: o relatório cobre a própria UC e todas as unidades menores dentro dela. O nível 7 (Brasil) não aplica filtro de UC e agrega toda a base. A posição da demanda no próprio relatório continua pela UC exata dela.
Resposta (RelatorioUcDto):
{ uc_id, gerado_em, filtros: { categoria_id?, status?, data_inicio?, data_fim? }, total_geral, resumo_por_categoria: [ { categoria_id, nome, area, total, por_status, mais_antiga: { demanda_id, data_recebida? } | null, responsaveis: { federal, estadual, municipal } } ], demandas: [ { categoria_id, demandas: [ demanda resumida ] } ], sem_categoria: [ demanda resumida ]}Demanda resumida: { demanda_id, titulo?, coordenadas?, subcategoria_id?, subcategoria_nome?, subcategoria_descricao_formal?, status, data_recebida?, score_final?, total_confirmacoes, agregado_membros? }. O agregado_membros traz [ { demanda_id, titulo? } ] quando a demanda representa um agregado de duplicidade, com o ID e o título público de cada membro para link direto na interface. A ordenação interna por categoria usa o score final quando disponível. Demandas sem categoria na taxonomia não entram em resumo_por_categoria: aparecem em sem_categoria, também respeitando os filtros.
A timeline pública paginada ordena em cronologia inversa (mais recentes primeiro). A timeline do relatório da demanda permanece em cronologia crescente.
Os filtros de data do dashboard e do relatório da UC no web aceitam apenas a janela de 01/01/2026 até o dia atual. O limite é de interface: o datepicker desabilita as datas fora da janela e a página valida antes de consultar. A API aceita qualquer data_inicio/data_fim ISO-8601 e mantém a validação de ordem.
1.10 Resumo público de lugar
Seção intitulada “1.10 Resumo público de lugar”O painel de detalhes do app consome o resumo público. GET /api/d7/lugar/:lugar_id/resumo (público, 60/min) devolve 404 quando o lugar não existe, está disputado ou está desativado. Lugar provisorio e confirmado é retornado.
Resposta (LugarResumoDto):
{ lugar_id, tipo_lugar, subtipo?, subtipo_confirmado?, nome?, descricao?, horario_funcionamento?, coordenada_lat, coordenada_lng, status_confianca, metodo_validacao?, total_confirmacoes, confianca_tipificacao?, data_recebida?, unidade_civica?: { id, nome, nivel }}subtipo_confirmado e confianca_tipificacao vêm do enriquecimento da L-2. unidade_civica é resolvido por D7Repository.buscarReferenciaUc e fica ausente quando o lugar não tem UC. O total de confirmações é o contador de cidadãos distintos projetado de lugar.validado.
1.11 Ranking público da UC
Seção intitulada “1.11 Ranking público da UC”GET /api/d7/ranking/:uc_id (público, 60/min). Lista as demandas da unidade cívica e das unidades menores dentro dela, ordenadas por score final decrescente (nulos por último), com desempate pela data de recebimento mais antiga. O nível 7 (Brasil) não aplica filtro de UC e ranqueia toda a base. Demandas agregada, removida e concluida não aparecem. O ranking é o painel do que está em aberto; a consulta pública do que já foi concluído fica no histórico da UC (seção 1.12).
Query: titulo opcional (trecho do título, busca parcial sem diferenciar maiúsculas); limite (padrão 50, máximo 100); deslocamento.
Resposta { linhas, total, limite, deslocamento }. Cada linha: { demanda_id, titulo?, categoria_id?, nivel_precedencia?, status, score_final?, posicao, data_recebida? }. A posição é derivada da ordenação corrente (deslocamento + índice + 1), sem depender de metadados defasados de rankeamento. UC sem demandas devolve 200 com lista vazia.
1.12 Histórico público de demandas concluídas
Seção intitulada “1.12 Histórico público de demandas concluídas”GET /api/d7/uc/:uc_id/historico (público, 60/min). Lista as demandas concluídas da unidade cívica e das unidades menores dentro dela. O mapa mostra o que está em aberto; a consulta pública do que já foi resolvido fica separada, nesta leitura. O nível 7 (Brasil) não aplica filtro de UC e agrega toda a base. O status é fixo em concluida; a rota não aceita filtro de status. Categoria, período e título são os únicos recortes.
Query:
categoria_id— opcional, máx. 10 caracteresdata_inicio/data_fim— ISO-8601, janela sobredata_conclusaotitulo— opcional, trecho do título, busca parcial sem diferenciar maiúsculaslimite— padrão 20, mínimo 1, máximo 100deslocamento— padrão 0
UC malformada devolve 400. Datas malformadas devolvem 400. data_inicio posterior a data_fim devolve 400. UC sem concluídas devolve 200 com lista vazia e total 0. A ordenação é por data_conclusao DESC (NULLS LAST), data_recebida DESC (NULLS LAST) e demanda_id crescente como desempate determinístico.
Resposta (HistoricoUcPaginadoDto) { linhas, total, limite, deslocamento }. Cada linha (HistoricoUcLinhaDto): { demanda_id, titulo?, categoria_id?, subcategoria_id?, unidade_civica_id?, data_recebida?, data_conclusao?, dias_ate_conclusao?, total_confirmacoes }. A linha não traz coordenadas nem autoria; o histórico expõe apenas o que a D-7 já publica.
A consulta da subárvore e a contagem usam o índice existente demanda_snapshots_unidade_civica_id_status_idx, sem índice novo. Medição com 45.000 snapshots sintéticos (13.500 concluídas): consulta da subárvore em 3,459 ms e contagem em 2,916 ms; consulta de UC única em 0,620 ms.
2. Banco de Dados — Schema e Entidades
Seção intitulada “2. Banco de Dados — Schema e Entidades”2.1 Schema d7
Seção intitulada “2.1 Schema d7”Todas as tabelas da D-7 residem no schema d7 do PostgreSQL. Este schema é de uso exclusivo do módulo D-7. Nenhuma outra colônia lê ou escreve nestas tabelas.
2.2 Tabela d7.timeline_entries
Seção intitulada “2.2 Tabela d7.timeline_entries”Registro append-only de cada evento processado pela D-7. Uma linha por evento. É a projeção canônica de linha do tempo — o que está aqui é o que aparece na timeline pública.
CREATE TABLE "d7"."timeline_entries" ( "id" UUID NOT NULL, "demanda_id" UUID NOT NULL, "event_id" UUID NOT NULL, "sequence_number" BIGINT NOT NULL, "tipo_evento" VARCHAR(100) NOT NULL, "timestamp" TIMESTAMPTZ(2) NOT NULL, "descricao" TEXT NOT NULL, "dados_relevantes" JSONB NOT NULL DEFAULT '{}', "visibilidade" VARCHAR(20) NOT NULL DEFAULT 'publico', "unidade_civica_id" UUID, "conselheiro_id" UUID, "correlacao_id" UUID, "criado_em" TIMESTAMPTZ(2) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "timeline_entries_pkey" PRIMARY KEY ("id"));
CREATE UNIQUE INDEX "timeline_entries_event_id_demanda_id_key" ON "d7"."timeline_entries"("event_id", "demanda_id");CREATE INDEX "timeline_entries_demanda_id_timestamp_idx" ON "d7"."timeline_entries"("demanda_id", "timestamp");CREATE INDEX "timeline_entries_demanda_id_tipo_evento_timestamp_idx" ON "d7"."timeline_entries"("demanda_id", "tipo_evento", "timestamp");CREATE INDEX "timeline_entries_unidade_civica_id_idx" ON "d7"."timeline_entries"("unidade_civica_id");CREATE INDEX "timeline_entries_conselheiro_id_idx" ON "d7"."timeline_entries"("conselheiro_id");CREATE INDEX "timeline_entries_event_id_idx" ON "d7"."timeline_entries"("event_id");Colunas — detalhamento
Seção intitulada “Colunas — detalhamento”| Coluna | Tipo | Descrição |
|---|---|---|
id |
UUID PK | Identificador interno da entrada da timeline. Gerado pela D-7. |
demanda_id |
UUID | FK lógica para o registro de demanda. Sem constraint formal — regra de isolamento. |
event_id |
UUID | event_id do evento que originou esta entrada. A unicidade é composta com demanda_id; a idempotência dos handlers é por event_id. |
sequence_number |
BIGINT | sequence_number global do evento no core.event_log. Usado para ordenação em rebuild. |
tipo_evento |
VARCHAR(100) | Tipo do evento conforme Registry (ex: demanda.recebida). |
timestamp |
TIMESTAMPTZ(2) | Timestamp do evento original (do payload timestamp ou do fallback do próprio evento). |
descricao |
TEXT | Descrição legível em português gerada pelo DescricaoMapper, sanitizada e truncada em 10.000 caracteres. Texto que aparece na timeline. |
dados_relevantes |
JSONB | Campos estruturados do evento relevantes para exibição. Ex: para demanda.categorizada: {categoria_id, confianca, metodo}. O payload completo está no core.event_log. |
visibilidade |
VARCHAR(20) | publico (visível sem autenticação) ou interno (apenas auditoria). A entrada nasce interno quando a sanitização de PII redige algum campo ou quando o conteúdo vem sinalizado como suspeito pela denylist; a moderação pode liberá-la. |
unidade_civica_id |
UUID | UC de menor nível associada. Extraído do payload do evento quando disponível; nulo em eventos sem UC. |
conselheiro_id |
UUID | Conselheiro associado ao evento. Extraído do payload. NULL para eventos sem conselheiro. |
correlacao_id |
UUID | correlacao_id do evento de origem, com fallback para o event_id. Para trace distribuído. |
criado_em |
TIMESTAMPTZ(2) | Timestamp de criação do registro na D-7. |
2.3 Tabela d7.demanda_snapshots
Seção intitulada “2.3 Tabela d7.demanda_snapshots”Estado corrente de cada demanda. Uma linha por demanda_id. Atualizada incrementalmente a cada evento processado. Serve como fonte para o dashboard (agregado por UC) e para o endpoint de resumo.
CREATE TABLE "d7"."demanda_snapshots" ( "demanda_id" UUID NOT NULL, "unidade_civica_id" UUID, "categoria_id" VARCHAR(10), "subcategoria_id" VARCHAR(50), "nivel_precedencia" INTEGER, "status" VARCHAR(30) NOT NULL DEFAULT 'recebida', "data_recebida" TIMESTAMPTZ(2), "data_ultima_atualizacao" TIMESTAMPTZ(2) NOT NULL DEFAULT CURRENT_TIMESTAMP, "data_inicio_acompanhamento" TIMESTAMPTZ(2), "data_conclusao" TIMESTAMPTZ(2), "conselheiro_id" UUID, "confianca_geo" DECIMAL(5,4), "score_final" DECIMAL(12,2), "total_atualizacoes" INTEGER NOT NULL DEFAULT 0, "dias_ate_conclusao" INTEGER, "texto_ultima_atualizacao" TEXT, "metadata" JSONB NOT NULL DEFAULT '{}', "coordenada_lat" DOUBLE PRECISION, "coordenada_lng" DOUBLE PRECISION, "titulo" VARCHAR(200), "descricao" TEXT, "total_confirmacoes" INTEGER NOT NULL DEFAULT 0, "agregado_representante_id" UUID, "agregado_membros_ids" JSONB NOT NULL DEFAULT '[]', "municipio_id" UUID, "resumo_agregado" TEXT, "resumo_ciclo" TEXT, "resumos_atualizado_em" TIMESTAMPTZ(2), "caminho_dossie" TEXT, "descricoes_midia" JSONB NOT NULL DEFAULT '[]',
CONSTRAINT "demanda_snapshots_pkey" PRIMARY KEY ("demanda_id"));
CREATE INDEX "demanda_snapshots_unidade_civica_id_idx" ON "d7"."demanda_snapshots"("unidade_civica_id");CREATE INDEX "demanda_snapshots_unidade_civica_id_status_idx" ON "d7"."demanda_snapshots"("unidade_civica_id", "status");CREATE INDEX "demanda_snapshots_conselheiro_id_idx" ON "d7"."demanda_snapshots"("conselheiro_id");CREATE INDEX "demanda_snapshots_status_idx" ON "d7"."demanda_snapshots"("status");CREATE INDEX "demanda_snapshots_coordenada_lat_coordenada_lng_idx" ON "d7"."demanda_snapshots"("coordenada_lat", "coordenada_lng");Colunas — detalhamento
Seção intitulada “Colunas — detalhamento”| Coluna | Tipo | Descrição |
|---|---|---|
demanda_id |
UUID PK | Uma linha por demanda. |
unidade_civica_id |
UUID | UC de menor nível. Preenchido por demanda.georreferenciada. |
categoria_id |
VARCHAR(10) | Categoria atribuída. Preenchido por demanda.categorizada. Formato N.M. |
subcategoria_id |
VARCHAR(50) | Subcategoria escolhida pelo cidadão na captura. Preenchido por demanda.recebida e repassado por demanda.categorizada e demanda.normalizada quando presentes. |
nivel_precedencia |
INTEGER 1-5 | Nível de precedência da categoria. Preenchido por demanda.categorizada. |
status |
VARCHAR(30) | Estado atual no ciclo de vida. Transita conforme eventos (ver 4.2). |
data_recebida |
TIMESTAMPTZ(2) | Quando a demanda foi registrada. Preenchido por demanda.recebida. |
data_ultima_atualizacao |
TIMESTAMPTZ(2) | Última vez que o snapshot foi atualizado. |
data_inicio_acompanhamento |
TIMESTAMPTZ(2) | Quando o conselheiro fez a primeira ação. Preenchido por conselheiro.demanda_iniciada. |
data_conclusao |
TIMESTAMPTZ(2) | Quando a demanda foi concluída. Preenchido por demanda.concluída ou por demanda.conclusao_confirmada sem ratificação. |
conselheiro_id |
UUID | Conselheiro atualmente atribuído. Preenchido por conselheiro.sorteado. |
confianca_geo |
DECIMAL(5,4) | Confiança da resolução geográfica. Preenchido por demanda.georreferenciada a partir do grau textual do payload (alta, media ou baixa), convertido para 0,9, 0,6 e 0,3. |
score_final |
DECIMAL(12,2) | Score no ranking. Preenchido por demanda.ranqueada. |
total_atualizacoes |
INTEGER | Contador de atualizações do conselheiro. Incrementado por conselheiro.atualização_publicada e ajustado na conclusão. |
dias_ate_conclusao |
INTEGER | Dias entre data_recebida e data_conclusao. Calculado na conclusão. |
texto_ultima_atualizacao |
TEXT | Texto da última atualização do conselheiro, sanitizado e truncado em 200 caracteres. Recebe o resumo_final na conclusão. |
metadata |
JSONB | Dados extras. Ex: canal_entrada, metodo_categorizacao, confianca_categorizacao, posicao_ranking, ultimo_evento_id. |
coordenada_lat / coordenada_lng |
DOUBLE PRECISION | Coordenada pública. Vem da localizacao_bruta da captura e é sobrescrita pelas coordenadas de demanda.georreferenciada quando presentes e válidas. Snapshot sem coordenadas não aparece nas listagens geo. |
titulo |
VARCHAR(200) | Título público da demanda. Preenchido por demanda.normalizada; suspenso quando o conteúdo é sinalizado e limpo na remoção por moderação. |
descricao |
TEXT | Descrição pública da demanda. Preenchida por demanda.normalizada a partir do descricao_limpa sanitizado e truncado em 5.000 caracteres; suspensa quando o conteúdo é sinalizado, restaurada na aprovação e limpa na remoção por moderação. Não é reconstruível pelo replay: o descricao_limpa do log é hash. |
total_confirmacoes |
INTEGER | Contador de confirmações de cidadãos distintos. Preenchido por demanda.confirmada. |
agregado_representante_id |
UUID | Representante do agregado a que a demanda pertence. Preenchido por duplicidade.agregada nos membros. |
agregado_membros_ids |
JSONB | Lista de membros do agregado. Preenchido por duplicidade.agregada no representante. |
municipio_id |
UUID | Município da demanda, campo municipio_id de demanda.georreferenciada (nível 4), com fallback pelo quarto elo da cadeia_ucs. |
resumo_agregado |
TEXT | Resumo público do agregado de duplicidade, sanitizado na D-12. Preenchido por demanda.resumo_agregado_atualizado. |
resumo_ciclo |
TEXT | Resumo público do ciclo de acompanhamento, sanitizado na D-6b. Preenchido por demanda.resumo_ciclo_atualizado. |
resumos_atualizado_em |
TIMESTAMPTZ(2) | Carimbo da última projeção de resumo. Recebe o gerado_em do evento; o snapshot guarda um carimbo único para o macro e o micro. |
caminho_dossie |
TEXT | Cópia denormalizada do dossiê do perfil (município, subcategoria) para a demanda. Atualizada quando o perfil muda; zerada quando o perfil fica sem dossiê. |
descricoes_midia |
JSONB (default []) |
Lista [{ object_key, descricao }] com a descrição automática de cada imagem da captura, sanitizada e truncada em 2.000 caracteres por item, com a tradução quando aplicada. Preenchida por demanda.normalizada das versões 1.3.0 e 1.4.0; zerada na remoção de texto, na eliminação e na retenção do titular. A leitura pública casa as entradas com as evidências pelo object_key_temp. |
2.4 Tabela d7.processed_events
Seção intitulada “2.4 Tabela d7.processed_events”Controle de idempotência para eventos que não geram entidade própria. No MVP os handlers usam event_id como chave de idempotência e nenhum fluxo insere nesta tabela; ela existe como espaço reservado para a Fase 2 e o rebuild a limpa.
CREATE TABLE "d7"."processed_events" ( "event_id" UUID NOT NULL, "event_type" VARCHAR(100) NOT NULL, "processed_at" TIMESTAMPTZ(2) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "processed_events_pkey" PRIMARY KEY ("event_id"));
CREATE INDEX "processed_events_event_type_idx" ON "d7"."processed_events"("event_type");2.5 Tabela d7.consumer_offset
Seção intitulada “2.5 Tabela d7.consumer_offset”Controla o cursor de processamento para replay seletivo após falha ou reinicialização. Segue o padrão definido pela N-0a (Event Bus).
CREATE TABLE "d7"."consumer_offset" ( "tipo_evento" VARCHAR(255) NOT NULL, "last_sequence" BIGINT NOT NULL DEFAULT 0, "updated_at" TIMESTAMPTZ(2) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "consumer_offset_pkey" PRIMARY KEY ("tipo_evento"));A tabela tem uma linha por tipo consumido em produção: 30 linhas (ver 3.1 a 3.24). As linhas são criadas no boot por seedOffsets, com skipDuplicates e last_sequence = 0. O cursor avança por avancarOffset, que só grava quando a sequência nova é maior que a atual. Os três tipos da Fase 2 não entram na tabela.
2.6 Migrations
Seção intitulada “2.6 Migrations”20260813102504_create_d7_tables— Cria o schemad7, as tabelastimeline_entries,demanda_snapshots,processed_eventseconsumer_offset, com os índices e a unicidade composta de(event_id, demanda_id). Sem CHECKs.20260819143608_d7_geo_projecao— Adicionacoordenada_lat,coordenada_lngetituloademanda_snapshotscom índice composto de coordenadas; criad7.lugares_geocom índice composto eultimo_event_idUNIQUE. Não altera linhas existentes.20260820160000_d7_agregacao— Adicionatotal_confirmacoes,agregado_representante_ideagregado_membros_idsademanda_snapshots; criad7.demanda_evidenciascomultimo_event_idUNIQUE e índice pordemanda_id. Não altera linhas existentes.20260910_subcategoria_id_d7_snapshots— Adicionasubcategoria_id(VARCHAR(50)) ademanda_snapshots. Alimenta o ícone do marcador no mapa sem depender da categorização da D-3.20260911120000_d7_evidencia_object_key— Remove a colunaurlded7.demanda_evidenciase adicionaobject_key(TEXT) epossui_dado_sensivel(padrão false). A projeção é limpa antes da alteração, porque é descartável e o rebuild repovoaobject_keya partir dos eventos.20260914130000_d7_lugares_validacao— Adiciona ad7.lugares_geodescricao,horario_funcionamento,subtipo_confirmado,confianca_tipificacao,status_confianca,metodo_validacaoetotal_confirmacoes, com índice emstatus_confianca.0012_d7_resumos_caminhos— Adicionamunicipio_id,resumo_agregado,resumo_ciclo,resumos_atualizado_emecaminho_dossiead7.demanda_snapshotse criad7.caminhos, com a chave composta de município e subcategoria.0015_d7_descricoes_midia— Adicionadescricoes_midia(JSONB, default[]) ad7.demanda_snapshotseobject_key_temp(VARCHAR(500)) ad7.demanda_evidencias. Não altera linhas existentes.
Os offsets não têm migration de seed: são criados no boot pelo seedOffsets. Migrations futuras (Fase 2): tabela d7.conselheiro_views para visão de conselheiro, índices GIN em dados_relevantes para queries avançadas, partição de timeline_entries por mês.
2.7 Relações internas
Seção intitulada “2.7 Relações internas”O schema d7 não tem FKs internas. As tabelas timeline_entries e demanda_snapshots são correlacionadas logicamente por demanda_id, mas sem constraint formal — permitem rebuild independente (limpar e reconstruir uma sem afetar a outra).
2.8 Decisões de schema
Seção intitulada “2.8 Decisões de schema”timeline_entries e demanda_snapshots como tabelas separadas.
A timeline é append-only — um INSERT por evento, nunca UPDATE ou DELETE. O snapshot é upsert — uma linha por demanda, atualizada a cada evento. Separar as duas permite que cada uma use a estratégia de persistência adequada e que o rebuild reconstrua ambas independentemente.
demanda_snapshots como fonte do dashboard, não timeline_entries.
O dashboard precisa de agregações por UC (COUNT, AVG, GROUP BY). Fazer essas queries sobre timeline_entries exigiria subqueries para determinar o estado mais recente de cada demanda (ex: último status, última categoria). O demanda_snapshots já contém o estado corrente de cada demanda — uma linha por demanda_id. A query do dashboard é um simples GROUP BY unidade_civica_id sobre demanda_snapshots. Para o volume do MVP (< 1000 demandas), o PostgreSQL executa em < 5ms com o índice demanda_snapshots_unidade_civica_id_idx.
Visão de conselheiro como Fase 2.
A ficha original menciona três projeções: timeline, dashboard e visão de conselheiro. No MVP, timeline + dashboard já cobrem a rastreabilidade completa: qualquer cidadão pode ver o ciclo completo de uma demanda na timeline e o estado agregado da UC no dashboard. A visão de conselheiro (quais demandas um conselheiro acompanhou, tempo médio, status) é consultável indiretamente filtrando demanda_snapshots por conselheiro_id. A projeção dedicada (conselheiro_views) com índices e queries otimizadas entra na Fase 2, quando houver volume suficiente de ciclos concluídos para justificar a complexidade adicional.
PK de demanda_snapshots é demanda_id, status é atributo.
O status evolui com o ciclo de vida da demanda. Se o PK fosse (demanda_id, status), teríamos múltiplas linhas por demanda — essencialmente uma timeline de status. Preferimos uma linha por demanda com o status atual. Para auditoria de mudanças de status, a timeline_entries já contém o histórico completo.
metadata JSONB para campos que variam por tipo de evento.
Campos como confianca_categorizacao, posicao_ranking, metodo_sorteio são relevantes para algumas queries mas não para todas. Colocá-los como colunas dedicadas poluiria o schema com dezenas de colunas nullable. O metadata agrupa esses campos sem perda de estrutura (JSONB é indexável via GIN se necessário na Fase 2).
Endpoint POST /api/d7/rebuild como operação administrativa.
Reconstruir projeções do zero é operação de manutenção, não de negócio. Expor via REST com rate limit agressivo (1/hora) permite que um administrador ou pipeline de CI/CD dispare a reconstrução sem acesso ao banco.
2.9 Projeção geoespacial
Seção intitulada “2.9 Projeção geoespacial”As projeções guardam dados geoespaciais. Sem JSONB para coordenadas: colunas próprias com índice composto resolvem o filtro por bounding box em SQL simples.
Colunas em d7.demanda_snapshots (nullable; a migration 20260819143608_d7_geo_projecao não altera linhas existentes; a coluna descricao entra na baseline consolidada 0002_demanda):
| Coluna | Tipo | Evento-fonte |
|---|---|---|
coordenada_lat |
DOUBLE PRECISION | demanda.recebida.localizacao_bruta.lat (obrigatória no Registry); sobrescrita por demanda.georreferenciada.coordenadas_lat quando presente e válida |
coordenada_lng |
DOUBLE PRECISION | demanda.recebida.localizacao_bruta.lng; sobrescrita por demanda.georreferenciada.coordenadas_lng |
titulo |
VARCHAR(200) | demanda.normalizada.titulo |
descricao |
TEXT | demanda.normalizada.descricao_limpa sanitizado |
Índice composto (coordenada_lat, coordenada_lng) atende o WHERE de bounding box da listagem. Valor ausente ou fora da faixa (lat -90..90, lng -180..180) grava null com log de aviso; snapshot sem coordenadas não aparece na listagem.
Tabela nova d7.lugares_geo (projeção de lugares; L-1 e L-2 são colônias puras de eventos e não expõem REST):
CREATE TABLE "d7"."lugares_geo" ( "lugar_id" UUID NOT NULL, "coordenada_lat" DOUBLE PRECISION NOT NULL, "coordenada_lng" DOUBLE PRECISION NOT NULL, "tipo_lugar" VARCHAR(30) NOT NULL, -- 'organizacao' | 'equipamento_publico' "nome" VARCHAR(200), "subtipo" VARCHAR(100), "unidade_civica_id" UUID, "status" VARCHAR(30) NOT NULL DEFAULT 'cadastrado', "data_recebida" TIMESTAMPTZ(2), "ultimo_event_id" UUID NOT NULL, "atualizado_em" TIMESTAMPTZ(2) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "lugares_geo_pkey" PRIMARY KEY ("lugar_id"));
CREATE UNIQUE INDEX "lugares_geo_ultimo_event_id_key" ON "d7"."lugares_geo"("ultimo_event_id");CREATE INDEX "lugares_geo_coordenada_lat_coordenada_lng_idx" ON "d7"."lugares_geo"("coordenada_lat", "coordenada_lng");| Campo | Evento-fonte |
|---|---|
coordenada_lat/lng |
lugar.cadastrado.coordenadas_brutas |
tipo_lugar, nome, subtipo |
lugar.cadastrado |
data_recebida |
timestamp do evento lugar.cadastrado |
unidade_civica_id |
lugar.georreferenciado.unidade_civica_id |
status |
cadastrado no create; georreferenciado ao consumir lugar.georreferenciado |
ultimo_event_id |
idempotência por evento |
residencia e poligono_uc são descartados no handler com cursor avançado; residências nunca chegam ao banco de leitura público (LGPD). poligono_uc segue em análise na L-2 no MVP e não publica lugar.georreferenciado.
2.10 Rebuild das projeções geo
Seção intitulada “2.10 Rebuild das projeções geo”A migration 20260819143608_d7_geo_projecao adiciona as três colunas ao snapshot e cria d7.lugares_geo. O rebuild reconstrói as projeções geo a partir do replay; nada em d7 depende de seed manual.
2.11 Projeção do agregado
Seção intitulada “2.11 Projeção do agregado”A projeção do agregado estende as leituras públicas de duplicidade. Nenhum payload dos eventos novos carrega cidadao_id: a projeção pública continua sem autoria.
Colunas novas em d7.demanda_snapshots (migration 20260820160000_d7_agregacao, com default; linhas existentes intactas):
| Coluna | Tipo | Evento-fonte |
|---|---|---|
total_confirmacoes |
INTEGER (default 0) | demanda.confirmada.total_confirmacoes |
agregado_representante_id |
UUID (nullable) | duplicidade.agregada — membros apontam para o representante |
agregado_membros_ids |
JSONB (default []) | duplicidade.agregada — o representante lista os membros |
Tabela nova d7.demanda_evidencias:
| Coluna | Tipo | Descrição |
|---|---|---|
evidencia_id |
UUID PK | Identificador da evidência. |
demanda_id |
UUID | Demanda a que a evidência pertence. Índice composto. |
tipo |
VARCHAR(20) | imagem ou audio. |
object_key |
TEXT | Chave do objeto no bucket. A URL é assinada na leitura, com validade de 5 minutos. |
object_key_temp |
VARCHAR(500) | Chave temporária da captura, vinda do anexo.processado 1.4.0. Nula em evidência sem a chave. A leitura casa o campo com as entradas de descricoes_midia do snapshot para expor a descricao por imagem. |
possui_dado_sensivel |
BOOLEAN (default false) | Evidência sensível fica fora das consultas públicas. |
via |
VARCHAR(20) | captura (de anexo.processado), confirmacao ou conclusao (de demanda.evidencia_adicionada). |
criado_em |
TIMESTAMPTZ | Timestamp da projeção. |
ultimo_event_id |
UUID UNIQUE | Idempotência por evento. |
Status novo: agregada, terminal, aplicado aos membros pelo handler de duplicidade.agregada. Membros saem da listagem geo por padrão. O representante carrega o contador e a lista de membros.
2.12 Projeção da validação de lugares
Seção intitulada “2.12 Projeção da validação de lugares”A projeção da validação de lugares estendeu d7.lugares_geo com os campos públicos do lugar e o status de confiança. A migration 20260914130000_d7_lugares_validacao foi aplicada com default nas linhas existentes.
Colunas novas em d7.lugares_geo:
| Coluna | Tipo | Evento-fonte |
|---|---|---|
descricao |
TEXT | lugar.cadastrado.descricao (versão 1.2.0, opcional) |
horario_funcionamento |
VARCHAR(200) | lugar.cadastrado.horario_funcionamento |
subtipo_confirmado |
VARCHAR(50) | lugar.georreferenciado.subtipo_confirmado (tipificação da L-2) |
confianca_tipificacao |
VARCHAR(20) | lugar.georreferenciado.confianca_tipificacao |
status_confianca |
VARCHAR(20), default provisorio |
lugar.validado.status_confianca e lugar.desativado |
metodo_validacao |
VARCHAR(30) | lugar.validado.metodo_validacao |
total_confirmacoes |
INTEGER, default 0 | lugar.validado.total_confirmacoes |
Índice novo em status_confianca. O status_confianca é monotônico no sentido da leitura pública: o rebuild reprojeta a sequência de eventos e o último lugar.validado ou lugar.desativado define o valor final.
O rebuild reconstrói a projeção a partir do replay, com lugar.validado e lugar.desativado em TIPOS_EVENTO_CONSUMIDOS.
O rebuild reprojeta as coordenadas redigidas do core.event_log, arredondadas para 2 casas decimais (cerca de 1 km). Lugares próximos podem ficar com a mesma coordenada na projeção depois do rebuild. A L-3 preserva a coordenada exata no próprio estado, então a presença de 700 m segue correta.
2.13 Tabela d7.caminhos
Seção intitulada “2.13 Tabela d7.caminhos”Perfil agregado do caminho de resolução por (município, subcategoria), alimentado por caminho.atualizado da D-24. A tabela é projeção de leitura: o rebuild a limpa e o replay repovoa a partir dos eventos.
CREATE TABLE "d7"."caminhos" ( "municipio_id" UUID NOT NULL, "subcategoria_id" VARCHAR(50) NOT NULL, "categoria_id" VARCHAR(10), "total_casos" INTEGER NOT NULL DEFAULT 0, "prazo_mediano_dias" INTEGER, "canais" JSONB NOT NULL DEFAULT '{}', "orgaos" JSONB NOT NULL DEFAULT '{}', "gargalos" JSONB NOT NULL DEFAULT '[]', "documentos" JSONB NOT NULL DEFAULT '[]', "dossie" TEXT, "versao_metodo" VARCHAR(20) NOT NULL, "gerado_em" TIMESTAMPTZ(2) NOT NULL, "atualizado_em" TIMESTAMPTZ(2) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "caminhos_pkey" PRIMARY KEY ("municipio_id", "subcategoria_id"));O perfil sem caso ou sem dossiê não é exibido como caminho, e a projeção do par é o caminho de limpeza do caminho_dossie das demandas: quando o perfil fica vazio, o campo das demandas do par é zerado. O caminho_dossie do snapshot é a cópia denormalizada do dossiê para a demanda; os endpoints servem o caminho a partir de d7.caminhos pela chave da demanda (subcategoria com fallback para a categoria), o que cobre a demanda georreferenciada depois da última publicação do perfil. A resposta devolve apenas os campos do contrato do evento, sem taxa_resolucao, que o evento não carrega.
3. Eventos — Contratos Detalhados
Seção intitulada “3. Eventos — Contratos Detalhados”A D-7 consome 30 tipos de evento em produção no MVP e mantém 3 stubs para a Fase 2. Não produz nenhum evento. Os schemas completos estão definidos no Registry (N-0b). Esta seção descreve, para cada evento consumido, o que a D-7 extrai para a timeline e para o snapshot.
Padrão comum a todos os handlers
Seção intitulada “Padrão comum a todos os handlers”1. Despachar o tipo para o processar* correspondente (switch em despacharEvento)
2. Verificar idempotência por event_id → SELECT em d7.timeline_entries WHERE event_id = ? → se encontrado: log "Evento já processado (idempotente)" e avançar o cursor
3. Validar o payload mínimo → demanda_id (ou lugar_id/anexo_id, conforme o tipo) precisa ser UUID válido → se ausente ou inválido: log.error, avançar o cursor e retornar
4. Gerar a descrição legível → descricaoMapper.gerar(tipo_evento, payload, timestamp do evento) → sanitizarTexto na descrição e sanitizarDados nos dados_relevantes → truncar em 10.000 caracteres; visibilidade interna quando a sanitização redige
5. Construir dados_relevantes (JSONB) → extrair campos estruturados para exibição, sem incluir o payload completo
6. Persistir timeline e snapshot na mesma transação → INSERT da timeline entry e INSERT/UPDATE de d7.demanda_snapshots via inserirTimelineESnapshotsAtomico
7. Avançar o consumer offset → UPDATE só quando a sequência nova é maior que a atual (avancarOffset) → o cursor avança em todo caminho terminal, inclusive nos descartes → exceção lançada não avança o cursor: o evento vai para a DLQ e o replay do boot seguinte retenta3.1 Evento consumido: demanda.recebida
Seção intitulada “3.1 Evento consumido: demanda.recebida”| Propriedade | Valor |
|---|---|
| Tipo | demanda.recebida |
| Schema version | 1.1.0 (a 1.0.0 permanece no catálogo) |
| Produtor | D-1a (Captura) |
| Descrição | Ponto de entrada do ciclo. Cria o registro inicial da demanda na timeline e no snapshot. |
Payload esperado:
interface DemandaRecebidaPayload { demanda_id: string; texto_bruto: string; tipo_midia?: 'texto' | 'foto' | 'audio'; localizacao_bruta: { lat: number; lng: number }; // obrigatória no Registry cidadao_id: string; canal: 'app' | 'web' | 'sms' | '156'; timestamp_criacao?: string; termos_versao?: string; consentimentos?: Array<{ finalidade: string; versao: string; aceito_em?: string }>; categoria_id?: string; subcategoria_id?: string;}Descrição gerada: "Demanda registrada via ${canal ?? 'app'} em ${formatarData(payload.timestamp ?? payload.timestamp_criacao, timestamp do evento)}."
Atualização do snapshot:
criar nova linha em d7.demanda_snapshots: demanda_id = payload.demanda_id status = 'recebida' data_recebida = timestamp do evento subcategoria_id = payload.subcategoria_id (quando presente) metadata = { canal: payload.canal } coordenada_lat/lng = localizacao_bruta quando dentro da faixaCoordenadas ausentes ou fora da faixa (lat -90..90, lng -180..180) gravam null com log de aviso. Os dados_relevantes guardam canal e tipo_midia.
3.2 Evento consumido: demanda.normalizada
Seção intitulada “3.2 Evento consumido: demanda.normalizada”| Propriedade | Valor |
|---|---|
| Tipo | demanda.normalizada |
| Schema version | 1.4.0 (as versões 1.0.0 a 1.3.0 permanecem no catálogo) |
| Produtor | D-1b (Normalização) |
| Descrição | Atualiza a timeline com o resultado da normalização. |
Payload esperado:
interface DemandaNormalizadaPayload { demanda_id: string; titulo: string; descricao_limpa: string; // exclusivamente texto do cidadão; aceita vazio coordenadas_validadas: { lat: number; lng: number }; confianca_normalizacao: number; // 0-1 tipo_midia_processada?: 'texto' | 'foto' | 'audio'; entidades_extraidas?: { endereco?: string; cep?: string; nome_rua?: string }; idioma_detectado?: string; categoria_id?: string; subcategoria_id?: string; conteudo_suspeito?: boolean; // campo da versão 1.2.0, sempre presente no payload termos_suspeitos?: string[]; // campo da versão 1.2.0, presente quando há suspeição midias_descritas?: Array<{ // campo da versão 1.3.0 object_key: string; descricao_original: string; descricao_traduzida?: string; traducao_aplicada: boolean; descricao_idioma: string; }>;}Descrição gerada: "Texto normalizado. Título: "${titulo}" — confiança ${porcentagem(confianca)}%."
Atualização do snapshot:
status = 'normalizada'metadata.confianca_normalizacao = payload.confianca_normalizacaometadata.conteudo_suspeito = true (quando sinalizado)titulo = payload.titulo sanitizado (null quando conteudo_suspeito)descricao = payload.descricao_limpa sanitizado e truncado em 5.000 caracteres (null quando conteudo_suspeito ou quando o cidadão não deixou texto)descricoes_midia = payload.midias_descritas mapeado para [{ object_key, descricao }] com a tradução quando aplicada, sanitização de PII e truncamento em 2.000 caracteres por itemA descrição projetada é exclusivamente texto do cidadão desde a 1.4.0. A legenda de cada imagem entra em descricoes_midia, item a item, com a tradução quando aplicada, e alimenta a descricao das evidências na leitura.
Com conteudo_suspeito = true o título e a descrição não entram na vitrine e a entrada nasce com visibilidade interno; a descrição fica preservada em dados_relevantes.descricao para a reposição. A aprovação da moderação libera a entrada e repõe título e descrição no snapshot.
3.3 Evento consumido: demanda.categorizada
Seção intitulada “3.3 Evento consumido: demanda.categorizada”| Propriedade | Valor |
|---|---|
| Tipo | demanda.categorizada |
| Schema version | 1.1.0 (a 1.0.0 permanece no catálogo) |
| Produtor | D-3 (Categorização) |
| Descrição | Atribui categoria e nível de precedência à demanda. |
Payload esperado:
interface DemandaCategorizadaPayload { demanda_id: string; categoria_id: string; nivel_precedencia: number; // 1-5 score_horizontal: number; confianca_categorizacao: number; // 0-1 metodo: 'automatico' | 'manual' | 'revisao_pendente'; sugestoes_alternativas?: Array<{ categoria_id: string; score_confianca: number }>; unidade_civica_id?: string; // campo opcional da versão 1.1.0 nivel_minimo_resolvido?: number; // campo opcional da versão 1.1.0}Descrição gerada: "Categorizada ${metodo === 'automatico' ? 'automaticamente' : 'manualmente'} como: ${categoria_id ?? 'não informada'} (nível ${nivel_precedencia ?? '?'}) — confiança ${porcentagem(confianca)}%."
Atualização do snapshot:
status = 'categorizada'categoria_id = payload.categoria_idnivel_precedencia = payload.nivel_precedenciametadata.confianca_categorizacao = payload.confianca_categorizacaometadata.metodo_categorizacao = payload.metodo3.4 Evento consumido: demanda.georreferenciada
Seção intitulada “3.4 Evento consumido: demanda.georreferenciada”| Propriedade | Valor |
|---|---|
| Tipo | demanda.georreferenciada |
| Schema version | 1.0.0 |
| Produtor | D-2 (Georreferenciamento) |
| Descrição | Define a unidade cívica e a confiança da resolução geográfica. |
Payload esperado:
interface DemandaGeorreferenciadaPayload { demanda_id: string; unidade_civica_id: string; nivel_minimo_resolvido: number; // 1-7 cadeia_ucs: string[]; // cadeia completa do menor ao maior nível metodo_resolucao: 'gps' | 'endereco' | 'inferencia'; confianca_geo: 'alta' | 'media' | 'baixa'; // enum textual no Registry coordenadas_lat?: number; // opcionais; sobrescrevem a coordenada da captura coordenadas_lng?: number;}Descrição gerada: "Localização confirmada dentro da unidade cívica (método: ${metodo_resolucao}) — confiança ${porcentagem(confianca)}%."
O DescricaoMapper converte o grau textual antes de formatar a porcentagem, pela mesma função que alimenta o snapshot.
Atualização do snapshot:
status = 'georreferenciada'unidade_civica_id = payload.unidade_civica_idmetadata.metodo_resolucao = payload.metodo_resolucaoconfianca_geo = converterConfiancaGeo(payload.confianca_geo)se payload.coordenadas_lat/lng presentes e válidas: coordenada_lat = payload.coordenadas_lat coordenada_lng = payload.coordenadas_lng (senão mantém as da captura)A conversão de converterConfiancaGeo mapeia alta para 0,9, media para 0,6 e baixa para 0,3, com valores numéricos repassados direto e entradas desconhecidas viram nulo. Assim a coluna confianca_geo do snapshot é preenchida e o indicador taxa_confianca_geo_baixa do dashboard reflete as demandas com grau baixa abaixo do limiar 0,5.
3.5 Evento consumido: demanda.ranqueada
Seção intitulada “3.5 Evento consumido: demanda.ranqueada”| Propriedade | Valor |
|---|---|
| Tipo | demanda.ranqueada |
| Schema version | 1.1.0 (a 1.0.0 permanece no catálogo) |
| Produtor | D-4 (Priorização e Ranking) |
| Descrição | Atribui score à demanda e a posiciona no ranking. |
Payload esperado:
interface DemandaRanqueadaPayload { demanda_id: string; unidade_civica_id: string; categoria_id: string; // campo da versão 1.1.0 nivel_precedencia: number; // campo da versão 1.1.0 score_final: number; breakdown: { peso_nacional: number; peso_situacional: number; score_horizontal: number; }; posicao_no_ranking: number; total_demandas_na_uc: number; versao_parametros: string; timestamp_calculo: string;}Descrição gerada: "Ranqueada: posição #${posicao_no_ranking ?? '?'} de ${total_demandas_na_uc ?? '?'} na unidade cívica — score ${score_final.toFixed(0)}."
Atualização do snapshot:
status = 'ranqueada'score_final = payload.score_finalmetadata.posicao_ranking = payload.posicao_no_rankingmetadata.versao_parametros = payload.versao_parametros3.6 Evento consumido: ranking.atualizado
Seção intitulada “3.6 Evento consumido: ranking.atualizado”| Propriedade | Valor |
|---|---|
| Tipo | ranking.atualizado |
| Schema version | 1.0.0 |
| Produtor | D-4 (Priorização e Ranking) |
| Descrição | Reconstrução do ranking da UC. Publicado quando há mudança significativa no topo. Atualiza metadados agregados, não a timeline de demandas individuais. |
Payload esperado:
interface RankingAtualizadoPayload { unidade_civica_id: string; motivo: 'demanda_top10' | 'primeira_posicao_alterada'; demanda_id_gatilho: string; top_10: Array<{ posicao: number; demanda_id: string; score_final: number; categoria_id?: string }>; total_demandas: number; versao_parametros: string; timestamp: string;}Descrição gerada: uma entrada por demanda do top_10, com o template "Ranking da unidade cívica atualizado: demanda na posição #${posicao} de ${total_demandas}. Motivo: ${motivo}."
Atualização do snapshot:
Nenhuma — este evento não altera o snapshot de uma demanda específica.Gera uma timeline entry por demanda do top_10.Sem top_10 válido: log.warn e o cursor avança sem projetar.3.7 Evento consumido: agenda.gerada
Seção intitulada “3.7 Evento consumido: agenda.gerada”| Propriedade | Valor |
|---|---|
| Tipo | agenda.gerada |
| Schema version | 1.0.0 |
| Produtor | D-5 (Agenda) |
| Descrição | Backlog da UC reconstruído. Publicado após rebuild da D-5. |
Payload esperado:
interface AgendaGeradaPayload { unidade_civica_id: string; total_itens: number; distribuicao_decay: { nivel_nao_vencido: number | null; itens_nivel_prioritario: number; itens_outros_niveis: number; }; top_10: Array<{ posicao: number; demanda_id: string; score_final: number; categoria_id: string; grupo_id: string | null }>; grupos_territoriais: number; versao_parametros: string; timestamp: string;}Descrição gerada: uma entrada por demanda do top_10, com o template "Agenda da unidade cívica gerada: demanda na posição #${posicao} de ${total_itens} itens (${distribuicao_decay.itens_nivel_prioritario} do nível prioritário). ${grupos_territoriais} grupos territoriais."
Atualização do snapshot:
Para cada demanda do top_10: upsert com status 'agendada'.A transição é monotônica — snapshot em status posterior não regride.Sem top_10 válido: log.warn e o cursor avança sem projetar.3.8 Evento consumido: agenda.item_disponível
Seção intitulada “3.8 Evento consumido: agenda.item_disponível”| Propriedade | Valor |
|---|---|
| Tipo | agenda.item_disponível |
| Schema version | 1.0.0 |
| Produtor | D-5 (Agenda) |
| Descrição | Demanda entrou no topo da fila de disponíveis. |
Payload esperado:
interface AgendaItemDisponivelPayload { demanda_id: string; unidade_civica_id: string; posicao_no_backlog: number; score_final: number; categoria_id: string; nivel_precedencia: number; grupo_id: string | null;}Descrição gerada: "Demanda disponível para conselheiro — posição #${posicao_no_backlog ?? '?'} no backlog da unidade cívica."
Atualização do snapshot:
Nenhuma mudança de status. Gera uma timeline entry com a posição no backlog,o score, a categoria e o nível de precedência.3.9 Evento consumido: conselheiro.sorteado
Seção intitulada “3.9 Evento consumido: conselheiro.sorteado”| Propriedade | Valor |
|---|---|
| Tipo | conselheiro.sorteado |
| Schema version | 1.0.0 |
| Produtor | D-6a (Sorteio e Atribuição) |
| Descrição | Conselheiro foi sorteado e atribuído à demanda. |
Payload esperado:
interface ConselheiroSorteadoPayload { conselheiro_id: string; demanda_id: string; unidade_civica_id: string; metodo_sorteio: 'fisher_yates'; seed_publico: string; lista_elegiveis_hash?: string; posicao_sorteada?: number; total_elegiveis?: number; timestamp: string; // campo adicional fora do catálogo}Descrição gerada: "Conselheiro designado via sorteio em ${formatarData(payload.timestamp, timestamp do evento)}. ${total_elegiveis ?? '?'} elegíveis na unidade cívica."
Atualização do snapshot:
status = 'atribuida'conselheiro_id = payload.conselheiro_idmetadata.total_elegiveis = payload.total_elegiveismetadata.seed_sorteio = payload.seed_publico3.10 Evento consumido: conselheiro.demanda_iniciada
Seção intitulada “3.10 Evento consumido: conselheiro.demanda_iniciada”| Propriedade | Valor |
|---|---|
| Tipo | conselheiro.demanda_iniciada |
| Schema version | 1.0.0 |
| Produtor | D-6b (Relatoria e Acompanhamento) |
| Descrição | Conselheiro fez o primeiro contato formal. |
Payload esperado:
interface ConselheiroDemandaIniciadaPayload { demanda_id: string; conselheiro_id: string; unidade_civica_id: string; tipo_primeira_acao: 'contato_realizado' | 'protocolo_aberto' | 'documento_anexado' | 'entrave_registrado' | 'status_atualizado' | 'prazo_registrado'; timestamp_inicio: string;}Descrição gerada: "Conselheiro iniciou acompanhamento em ${formatarData(timestamp_inicio, timestamp do evento)} via ${tipo_primeira_acao ?? 'contato'}."
Atualização do snapshot:
status = 'em_andamento'data_inicio_acompanhamento = payload.timestamp_inicio ?? timestamp do evento3.11 Evento consumido: conselheiro.atualização_publicada
Seção intitulada “3.11 Evento consumido: conselheiro.atualização_publicada”| Propriedade | Valor |
|---|---|
| Tipo | conselheiro.atualização_publicada |
| Schema version | 1.2.0 (a 1.1.0 e a 1.0.0 permanecem aceitas no replay) |
| Produtor | D-6b (Relatoria e Acompanhamento) |
| Descrição | Atualização do conselheiro processada e publicada. A 1.1.0 carrega a marcação da transcrição automática; a 1.2.0 marca o texto retido pela denylist. |
Payload esperado:
interface ConselheiroAtualizacaoPublicadaPayload { atualizacao_id: string; demanda_id: string; conselheiro_id: string; unidade_civica_id: string; tipo: 'contato_realizado' | 'protocolo_aberto' | 'documento_anexado' | 'entrave_registrado' | 'status_atualizado' | 'prazo_registrado'; texto_estruturado: string; descricao_sanitizada: string; // obrigatória, até 2.000 caracteres conteudo_estruturado?: Record<string, unknown>; origem_estruturacao?: 'conselheiro' | 'ia_assistida'; origem_texto?: 'conselheiro' | 'automatico'; modelo_transcricao?: string; confianca_transcricao?: number; numero_sequencial: number; timestamp?: string; conteudo_suspeito?: boolean; // true quando a denylist encontra termo no texto termos_suspeitos?: string[]; // descartado pela redação do log}Descrição gerada — a descricao_sanitizada tem precedência: quando presente, a descrição é o próprio texto sanitizado, truncado em 200 caracteres. Sem ela, vale o template por tipo:
'contato_realizado' → "Conselheiro realizou contato com ${contato_orgao ?? 'órgão'} via ${canal ?? 'contato'}. ${texto}"'protocolo_aberto' → "Protocolo ${protocolo_numero ?? 'não informado'} aberto em ${orgao ?? 'órgão'}. ${texto}"'entrave_registrado' → "Entrave registrado: ${orgao_envolvido ?? 'órgão'} — ${trecho de descricao_entrave, 100}"'status_atualizado' → "Status atualizado para: ${novo_status ?? 'não informado'}. ${texto}"'prazo_registrado' → "Prazo registrado: ${formatarData(data_estimada) ou 'data não informada'}. ${descricao_prazo}"'documento_anexado' → "Documento anexado: ${tipo_documento ?? 'documento'} — ${descricao}"outro → "Atualização do conselheiro (${tipo}): ${texto}"Dados relevantes da entrada na timeline:
texto_completo = payload.texto_estruturado sanitizado, limitado a 10.000 caracteresatualizacao_id = payload.atualizacao_id (sempre presente)conteudo_suspeito = payload.conteudo_suspeito quando trueorigem_texto, modelo_transcricao e confianca_transcricao = copiados quando presentestipo, numero_sequencial e origem_estruturacao = copiados do payloadO card da timeline renderiza texto_completo com fallback para descricao e mostra o selo “Transcrição automática” quando origem_texto é automatico. O descricao de 200 caracteres continua sendo o resumo curto da entrada; texto_completo é o texto sanitizado integral, limitado a 10.000 caracteres, o mesmo teto do campo texto_estruturado. Depois de um rebuild completo, texto_completo fica vazio, porque o texto_estruturado do log de eventos é hash e não pode ser reconstruído por replay; a entrada mantém a marcação automática e o card cai no fallback para descricao. A marca de suspeição sobrevive ao rebuild, então a entrada sinalizada continua interna. O áudio do relato não entra na projeção nem no payload da timeline.
Atualização do snapshot:
total_atualizacoes = total_atualizacoes + 1texto_ultima_atualizacao = payload.texto_estruturado sanitizado, truncado em 200 caracteresmetadata.ultimo_tipo_atualizacao = payload.tipoCom conteudo_suspeito=true (1.2.0), a entrada nasce com visibilidade='interno' e dados_relevantes.conteudo_suspeito=true, e o texto_ultima_atualizacao do snapshot não é tocado. O contador total_atualizacoes incrementa como nas demais atualizações. O relato limpo segue o fluxo normal e nasce público.
3.12 Evento consumido: conselheiro.prazo_próximo
Seção intitulada “3.12 Evento consumido: conselheiro.prazo_próximo”| Propriedade | Valor |
|---|---|
| Tipo | conselheiro.prazo_próximo |
| Schema version | 1.0.0 |
| Produtor | D-6b (Relatoria — CronJob) |
| Descrição | Prazo registrado está próximo do vencimento. |
Payload esperado:
interface ConselheiroPrazoProximoPayload { demanda_id: string; conselheiro_id: string; unidade_civica_id: string; prazo_id: string; data_estimada: string; dias_restantes: number; descricao_prazo: string; timestamp: string;}Descrição gerada: "Alerta: prazo "${descricao_prazo ?? ''}" vence em ${formatarData(data_estimada, timestamp do evento)} (${dias_restantes ?? '?'} dias restantes)."
Atualização do snapshot:
Nenhuma. Gera uma timeline entry com prazo, data estimada e dias restantes.3.13 Evento consumido: demanda.concluída
Seção intitulada “3.13 Evento consumido: demanda.concluída”| Propriedade | Valor |
|---|---|
| Tipo | demanda.concluída |
| Schema version | 1.0.0 |
| Produtor | D-6b (Relatoria e Acompanhamento) |
| Descrição | Demanda foi encerrada. Fecha o ciclo. |
Payload esperado:
interface DemandaConcluidaPayload { demanda_id: string; unidade_civica_id: string; data_conclusao: string; conselheiro_id: string; categoria_id?: string; dias_ate_conclusao?: number; total_atualizacoes?: number; resumo_final?: string; timestamp?: string;}Descrição gerada: "Demanda concluída em ${formatarData(data_conclusao, timestamp do evento)} após ${dias_ate_conclusao ?? '?'} dias. ${total_atualizacoes ?? '?'} atualizações registradas pelo conselheiro."
Atualização do snapshot:
status = 'concluida'data_conclusao = payload.data_conclusao ?? timestamp do eventodias_ate_conclusao = payload.dias_ate_conclusaototal_atualizacoes = payload.total_atualizacoestexto_ultima_atualizacao = payload.resumo_final sanitizado, truncado em 200 caracteres3.14 Evento consumido: conselheiro.ciclo_concluído
Seção intitulada “3.14 Evento consumido: conselheiro.ciclo_concluído”| Propriedade | Valor |
|---|---|
| Tipo | conselheiro.ciclo_concluído |
| Schema version | 1.0.0 |
| Produtor | D-6b (Relatoria e Acompanhamento) |
| Descrição | Ciclo de atuação do conselheiro encerrado. |
Payload esperado:
interface ConselheiroCicloConcluidoPayload { conselheiro_id: string; demanda_id: string; unidade_civica_id: string; motivo_encerramento: 'demanda_concluida' | 'fim_mandato' | 'desistencia'; total_atualizacoes: number; data_inicio: string; data_fim: string; dias_duracao?: number; resumo_final?: string; timestamp?: string;}Descrição gerada — varia por motivo:
'demanda_concluida' → "Ciclo do conselheiro encerrado: demanda concluída. ${dias_duracao} dias de acompanhamento, ${total_atualizacoes} atualizações."'fim_mandato' → "Ciclo do conselheiro encerrado por fim de mandato. ${dias_duracao} dias de acompanhamento, ${total_atualizacoes} atualizações."'desistencia' → "Ciclo do conselheiro encerrado por desistência. ${dias_duracao} dias de acompanhamento, ${total_atualizacoes} atualizações."Atualização do snapshot:
Nenhuma mudança de status. Gera uma timeline entry com motivo, duração e total de atualizações.3.15 Stubs para Fase 2
Seção intitulada “3.15 Stubs para Fase 2”Três handlers registrados que logam e ignoram no MVP. Eles ficam fora de TIPOS_EVENTO_CONSUMIDOS e fora do replay; o registro usa criarManipuladorStub, que loga tipo, event_id e origem:
| Evento | Produtor (Fase 2) | Comportamento MVP |
|---|---|---|
demanda.removida_por_votação |
D-10 (Votação) | Log e ignora. Fase 2: atualizar status para removida. |
votação.resultado_publicado |
D-10 (Votação) | Log e ignora. Fase 2: entrada na timeline da UC sobre resultado de votação que afetou o ranking. |
hash.checkpoint_publicado |
D-16 (Integridade Criptográfica) | Log e ignora. Fase 2: entrada na timeline global de auditoria. |
anexo.processado não é stub: o handler projeta a evidência em d7.demanda_evidencias com a via do payload e gera entrada na timeline (ver 3.20).
3.16 Ordem de operações — por que não publicar eventos
Seção intitulada “3.16 Ordem de operações — por que não publicar eventos”A D-7 não publica eventos. Isso elimina completamente a ambiguidade “persistir antes de publicar vs. publicar antes de persistir”. O fluxo é linear:
evento recebido → gerar descrição → INSERT timeline_entry → UPSERT demanda_snapshot → UPDATE consumer_offsetSe a transação falhar, o evento vai para a DLQ e o cursor não avança. No reprocessamento, a timeline entry é detectada por event_id (idempotente) e o snapshot é reaplicado.
3.17 Tratamento de erro e idempotência
Seção intitulada “3.17 Tratamento de erro e idempotência”| Cenário | Comportamento |
|---|---|
| Evento reentregue (replay/DLQ) | Detectado por event_id em d7.timeline_entries. Log e retorno. O UPSERT do snapshot é idempotente: reaplicar o mesmo evento não altera o estado. |
Payload sem demanda_id (ou sem o identificador do tipo) |
Log.error. Evento descartado e cursor avançado. |
Evento para demanda_id inexistente no snapshot (ex: conselheiro.sorteado antes de demanda.recebida) |
Cenário de evento fora de ordem. Snapshot é criado com status inferido do tipo de evento. Log.warn. |
UPSERT demanda_snapshot falha |
A transação reverte a timeline também. Log de erro. Evento registrado na DLQ e offset NÃO atualizado. |
| Evento de Fase 2 chega no MVP (ex: ambiente de teste com colônia nova) | Handler stub loga e ignora. Sem efeito colateral. |
Correlação entre eventos quebrada (correlacao_id nulo) |
Usa o event_id como fallback. Preenche na timeline entry. |
Evento descartado durante rebuild (rebuildando = true) |
Handler protegido ignora sem processar. Os eventos que chegam durante o replay são recolhidos pelas iterações seguintes; o cursor não avança para os descartados. |
3.18 Decisões de design com justificativa
Seção intitulada “3.18 Decisões de design com justificativa”D-7 não publica eventos, mesmo sendo o “fim da linha” do pipeline.
A D-7 é o consumidor terminal do ciclo de vida de uma demanda. Os dados que ela projeta são o output público. Publicar eventos de volta ao barramento (ex: timeline.atualizada) criaria um ciclo de feedback desnecessário: nenhuma colônia precisa reagir ao fato de que a D-7 projetou um evento. A D-7 expõe os dados via REST API; o contrato de entrega ao mundo externo é HTTP, não evento.
Snapshot atualizado por UPSERT, não por INSERT de nova versão.
A alternativa seria versionar o snapshot (nova linha a cada evento, com versao incremental). Isso permitiria auditoria de como o snapshot evoluiu, mas duplicaria a função da timeline_entries. O snapshot é o estado corrente; a timeline é o histórico. Juntos cobrem ambas as necessidades.
Um handler por tipo de evento, não um handler genérico com switch.
A alternativa seria um único handler registrado para todos os tipos via wildcard (*) com switch/case interno. O registro explícito por tipo (27 handlers em produção mais os stubs) é mais verboso, mas permite: (a) replay seletivo por tipo — se apenas demanda.concluída ficou atrasado, só ele é reprocessado; (b) evolução independente — adicionar um novo tipo de evento é adicionar um handler, sem alterar os existentes; (c) tipagem forte em cada handler. O despacharEvento centraliza o switch de roteamento.
Descrições geradas deterministicamente, sem IA.
A D-6b usa IA para sugerir estruturação de texto do conselheiro, porque o input é texto livre. A D-7 trabalha com eventos estruturados. Gerar descrição a partir de campos tipados (categoria_id, score_final, timestamp) é substituição de template; IA adicionaria latência e não determinismo sem ganho de qualidade. O dado bruto está preservado em dados_relevantes, e um consumidor que queira outra descrição pode gerá-la a partir do JSON.
3.19 Eventos consumidos de lugar
Seção intitulada “3.19 Eventos consumidos de lugar”Dois tipos consumidos alimentam a projeção d7.lugares_geo (seção 2.9). Eles não geram timeline entry. O cursor avança em todo caminho terminal, inclusive nos descartes.
lugar.cadastrado (produtor L-1, versão 1.2.0 no Registry):
- Se
tipo_lugarforresidenciaoupoligono_uc: avança o cursor e retorna sem projetar. Residências nunca entram no banco de leitura público (LGPD).poligono_ucsegue em análise na L-2 no MVP. - Caso contrário: upsert em
d7.lugares_geocomcoordenadas_brutas,tipo_lugar,nome,subtipo,descricao,horario_funcionamentoedata_recebida(timestamp do evento). Ostatus = 'cadastrado'entra somente no create; o caminho de update preserva o status atual, evitando regressão de lugar já georreferenciado em retry tardio. Idempotência porultimo_event_id. descricaoehorario_funcionamentosão projetados quando presentes; payloads antigos sem os campos seguem aceitos. Coordenadas ausentes ou inválidas descartam o evento com cursor avançado.
lugar.georreferenciado (produtor L-2):
- Atualiza
unidade_civica_id,status = 'georreferenciado',subtipo_confirmadoeconfianca_tipificacaoquando o lugar existe na projeção. - Lugar inexistente loga aviso e avança o cursor. O payload carrega a UC resolvida, não coordenadas; a coordenada segue a do cadastro.
3.20 Eventos consumidos do agregado
Seção intitulada “3.20 Eventos consumidos do agregado”Cinco tipos consumidos do agregado. Nenhum payload carrega cidadao_id. Todos geram entrada na timeline e entram no replay.
demanda.confirmada (produtor D-12):
- Soma o contador do snapshot com
total_confirmacoesdo payload. Evento sem total válido é descartado com cursor avançado.
demanda.evidencia_adicionada (produtor D-12):
- Gera apenas entrada na timeline. A projeção da evidência nasce do processamento da D-1c: a D-1c publica
anexo.processado, que projeta a linha emd7.demanda_evidenciascom a mesmavia.
duplicidade.candidata_detectada (produtor D-12):
- Registra a sinalização de candidatas na timeline da demanda, com o total de candidatas, a origem e os critérios.
duplicidade.agregada (produtor D-12):
- O representante recebe
agregado_membros_idscom os ids dos membros. - Cada membro recebe status
agregadaeagregado_representante_id. - Status monotônico: membro com status terminal (
concluida,removida) não é rebaixado paraagregada.
anexo.processado (produtor D-1c, versão 1.4.0 com object_key_temp):
- Insere linha em
d7.demanda_evidenciascomviado payload (captura,confirmacaoouconclusao, comcapturacomo padrão em evento antigo),object_keyeobject_key_tempquando presente, e gera entrada na timeline. - Evento com
possui_dado_sensivel = truenão projeta a evidência na leitura pública e remove linha existente da mesmaevidencia_id(replay idempotente). - A leitura casa o
object_key_tempda evidência com as entradas dedescricoes_midiado snapshot e devolve adescricaoda imagem no DTO; evidência sem par fica sem o campo.
Os cinco tipos entram em TIPOS_EVENTO_CONSUMIDOS. O rebuild trunca d7.demanda_evidencias antes do replay. As consultas públicas assinam a URL a partir de object_key no momento da leitura, com validade de 5 minutos, e filtram possui_dado_sensivel = false.
3.21 Eventos consumidos da conclusão social
Seção intitulada “3.21 Eventos consumidos da conclusão social”A conclusão social adicionou um tipo consumido e estendeu demanda.evidencia_adicionada. Nenhum payload carrega cidadao_id. Ambos entram no replay e no rebuild.
demanda.conclusao_confirmada (produtor D-12, versão 1.0.0):
- Sempre gera entrada na timeline, sem identidade.
ratificacao_necessaria: false→ snapshot passa aconcluidacomdata_conclusaodo payload.ratificacao_necessaria: true→ snapshot inalterado; a timeline registra que a conclusão coletiva aguarda ratificação do conselheiro. A pendência some da timeline quando a ratificação publicademanda.concluídapelo fluxo normal.
demanda.evidencia_adicionada (produtor D-12, versão vigente 1.3.0):
via: 'conclusao'gera entrada na timeline com a descrição da conclusão. A evidência em si é projetada pela D-1c viaanexo.processado, com a mesmavia.- A versão 1.0.0 com
via: 'confirmacao'continua aceita.
O tipo demanda.conclusao_confirmada entra em TIPOS_EVENTO_CONSUMIDOS. O rebuild trunca d7.demanda_evidencias antes do replay e reprojeta as evidências de conclusão no estado correto. Status monotônico preservado: conclusão social não regride snapshot com status terminal.
3.22 Eventos consumidos de moderação
Seção intitulada “3.22 Eventos consumidos de moderação”Nenhum payload carrega cidadao_id. Os tipos entram no replay e no rebuild.
anexo.moderado (produtor D-1c, versão 1.0.0):
- A evidência só é marcada como não sensível quando
status='ativo'emoderacao_status='aprovado'ao mesmo tempo. Qualquer outra combinação marca a evidência como sensível e ela sai da listagem pública. - A mídia não é removida do banco. A alternância de
possui_dado_sensivelcontrola a exposição.
moderacao.decidida (produtor D-1d, versão 1.1.0):
- Trata
tipo='texto'etipo='relato'. Notexto, a aprovação libera o conteúdo suspenso: as entradas de timeline marcadas como internas comconteudo_suspeito=truevoltam a público e o título do snapshot é atualizado. - No
relato, a aprovação publica as entradas internas cujodados_relevantes.atualizacao_idé oreferencia_ide repõetexto_ultima_atualizacaocom otexto_completoda última entrada, truncado em 200 caracteres. O bloqueio comreaberto=true, que é a reversão de uma aprovação, oculta as entradas doatualizacao_ide zera otexto_ultima_atualizacao. A entrada da decisão usa odemanda_iddo payload. - O bloqueio não altera nada no
texto. O título e as entradas permanecem internos. Quando o bloqueio chega comreaberto=true(reversão de uma aprovação), a D-7 oculta o título e as entradas de normalização de novo. - A decisão
removidolimpa o conteúdo dotexto: o snapshot perdetitulo,descricao,texto_ultima_atualizacaoedescricoes_midia; as entradas dedemanda.normalizadae internas têm a descrição sobrescrita com o marcadorConteúdo removido por decisão de moderação humana.e otituloe adescricaoremovidos dosdados_relevantes; a entrada pública da decisão entra na timeline. O resumo público passa a devolverconteudo_removido=trueeconteudo_removido_em. A D-1d recusaremovidopararelato; evento antigo nessa combinação é ignorado com aviso, sem limpar conteúdo. - Eventos de anexo não são tratados nesta trilha. A D-1c reage à decisão e publica
anexo.moderadoouanexo.removido.
anexo.removido (produtor D-1c, versão 1.0.0):
- Busca a evidência pelo
anexo_id, remove a entrada correspondente dedescricoes_midiapeloobject_key_tempantes de apagar a linha e grava a entrada pública “Anexo removido em definitivo por decisão de moderação humana.”. - O evento alimenta a marca
conteudo_removidodo resumo público.
Os três tipos entram em TIPOS_EVENTO_CONSUMIDOS e no rebuild.
3.23 Eventos consumidos da validação de lugares
Seção intitulada “3.23 Eventos consumidos da validação de lugares”Dois tipos consumidos atualizam d7.lugares_geo (seção 2.12). Eles não geram timeline entry. O cursor avança em todo caminho terminal, inclusive nos descartes.
lugar.validado (produtor L-3, versão 1.0.0):
- Atualiza
status_confianca,metodo_validacaoetotal_confirmacoesdo lugar. - O evento é publicado a cada confirmação e denúncia aceitas, mesmo quando o status não muda.
- Lugar inexistente na projeção loga aviso e avança o cursor.
- O status pode ser
provisorio,confirmadooudisputado. O statusdisputadomantém o lugar fora da listagem pública e o resumo devolve 404.
lugar.desativado (produtor L-3, versão 1.0.0):
- Marca
status_confianca = 'desativado'quando o lugar existe na projeção. - O lugar sai da listagem pública e o resumo devolve 404.
Os dois tipos entram em TIPOS_EVENTO_CONSUMIDOS e no rebuild. GET /api/d7/lugares filtra disputado e desativado; GET /api/d7/lugar/:lugar_id/resumo devolve 404 para os dois status.
3.24 Eventos consumidos dos resumos e do caminho
Seção intitulada “3.24 Eventos consumidos dos resumos e do caminho”Três tipos consumidos alimentam o snapshot e a tabela de perfis (seções 2.3 e 2.13). Nenhum gera entrada de timeline, para não duplicar na vitrine o texto derivado que já aparece nos resumos. O cursor avança em todo caminho terminal, inclusive nos descartes.
demanda.resumo_agregado_atualizado (produtor D-12, versão 1.0.0):
- Grava
resumo_agregadono snapshot da demanda representante e atualizaresumos_atualizado_emcom ogerado_emdo evento. - O texto chega sanitizado da D-12 e não passa por nova sanitização na D-7.
- Demanda sem snapshot registra aviso e avança o cursor.
demanda.resumo_ciclo_atualizado (produtor D-6b, versão 1.0.0):
- Grava
resumo_ciclono snapshot da demanda e atualizaresumos_atualizado_emcom ogerado_emdo evento. - O snapshot guarda um carimbo único para o macro e o micro, porque o evento carrega o momento da geração de cada texto.
- Demanda sem snapshot registra aviso e avança o cursor.
caminho.atualizado (produtor D-24, versão 1.0.0):
- Faz upsert do perfil em
d7.caminhospela chave (município, subcategoria), com contagem de casos, prazo mediano, canais, órgãos, gargalos, documentos, dossiê,versao_metodoegerado_em. - Propaga o dossiê para o
caminho_dossiedas demandas do par; quando o perfil vem sem dossiê, zera o campo das demandas do par. - Conversores
converterCaminho,converterMapaContagenseconverterGargalostoleram campo ausente ou inválido com fallback seguro. - O rebuild limpa
d7.caminhose reprojeta os campos pelo replay, sem tratamento especial.
Os três tipos entram em TIPOS_EVENTO_CONSUMIDOS e no rebuild. GET /api/d7/demanda/:demanda_id/resumo e GET /api/d7/demanda/:demanda_id/relatorio devolvem os mesmos resumo_agregado, resumo_ciclo, resumos_atualizado_em e caminho.
4. Lógica de Negócio — Algoritmos e Fluxos
Seção intitulada “4. Lógica de Negócio — Algoritmos e Fluxos”4.1 DescricaoMapper.gerar(tipoEvento, payload, timestampPadrao?) — templates completos
Seção intitulada “4.1 DescricaoMapper.gerar(tipoEvento, payload, timestampPadrao?) — templates completos”function formatarData(valor: unknown, padrao?: string): string { const candidatos: string[] = []; if (typeof valor === 'string' && valor.length > 0) candidatos.push(valor); if (padrao !== undefined && padrao.length > 0) candidatos.push(padrao); for (const candidato of candidatos) { const data = new Date(candidato); if (!Number.isNaN(data.getTime())) { return data.toLocaleString('pt-BR', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit', }); } } return 'data desconhecida';}
function porcentagem(valor: unknown): string { const numero = typeof valor === 'number' && Number.isFinite(valor) ? valor : NaN; return Number.isNaN(numero) ? '?' : String(Math.round(numero * 100));}
class DescricaoMapper { gerar(tipoEvento: string, payload: Record<string, unknown>, timestampPadrao?: string): string { switch (tipoEvento) { case 'demanda.recebida': return `Demanda registrada via ${payload.canal ?? 'app'} em ${formatarData(payload.timestamp ?? payload.timestamp_criacao, timestampPadrao)}.`;
case 'demanda.normalizada': return `Texto normalizado. Título: "${payload.titulo ?? ''}" — confiança ${porcentagem(payload.confianca_normalizacao)}%.`;
case 'demanda.categorizada': { const metodo = payload.metodo === 'automatico' ? 'automaticamente' : 'manualmente'; return `Categorizada ${metodo} como: ${payload.categoria_id ?? 'não informada'} (nível ${payload.nivel_precedencia ?? '?'}) — confiança ${porcentagem(payload.confianca_categorizacao)}%.`; }
case 'demanda.georreferenciada': return `Localização confirmada dentro da unidade cívica (método: ${payload.metodo_resolucao ?? 'não informado'}) — confiança ${porcentagem(converterConfiancaGeo(payload.confianca_geo))}%.`;
case 'demanda.ranqueada': return `Ranqueada: posição #${payload.posicao_no_ranking ?? '?'} de ${payload.total_demandas_na_uc ?? '?'} na unidade cívica — score ${typeof payload.score_final === 'number' ? payload.score_final.toFixed(0) : '?'}.`;
case 'ranking.atualizado': return `Ranking da unidade cívica atualizado: demanda na posição #${payload.posicao ?? '?'} de ${payload.total_demandas ?? '?'}. Motivo: ${payload.motivo ?? 'não informado'}.`;
case 'agenda.gerada': { const distribuicao = (payload.distribuicao_decay ?? {}) as Record<string, unknown>; return `Agenda da unidade cívica gerada: demanda na posição #${payload.posicao ?? '?'} de ${payload.total_itens ?? '?'} itens (${distribuicao.itens_nivel_prioritario ?? '?'} do nível prioritário). ${payload.grupos_territoriais ?? '?'} grupos territoriais.`; }
case 'agenda.item_disponível': return `Demanda disponível para conselheiro — posição #${payload.posicao_no_backlog ?? '?'} no backlog da unidade cívica.`;
case 'conselheiro.sorteado': return `Conselheiro designado via sorteio em ${formatarData(payload.timestamp, timestampPadrao)}. ${payload.total_elegiveis ?? '?'} elegíveis na unidade cívica.`;
case 'conselheiro.demanda_iniciada': return `Conselheiro iniciou acompanhamento em ${formatarData(payload.timestamp_inicio, timestampPadrao)} via ${payload.tipo_primeira_acao ?? 'contato'}.`;
case 'conselheiro.atualização_publicada': { if (typeof payload.descricao_sanitizada === 'string' && payload.descricao_sanitizada.length > 0) { return truncarTexto(payload.descricao_sanitizada, 200); } const conteudo = (payload.conteudo_estruturado ?? {}) as Record<string, unknown>; const texto = truncarTexto(payload.texto_estruturado, 200); return gerarDescricaoAtualizacaoPublicada(payload.tipo, conteudo, texto); }
case 'conselheiro.prazo_próximo': return `Alerta: prazo "${payload.descricao_prazo ?? ''}" vence em ${formatarData(payload.data_estimada, timestampPadrao)} (${payload.dias_restantes ?? '?'} dias restantes).`;
case 'demanda.concluída': return `Demanda concluída em ${formatarData(payload.data_conclusao, timestampPadrao)} após ${payload.dias_ate_conclusao ?? '?'} dias. ${payload.total_atualizacoes ?? '?'} atualizações registradas pelo conselheiro.`;
case 'conselheiro.ciclo_concluído': return descricaoCicloConcluido( payload.motivo_encerramento, payload.dias_duracao ?? '?', payload.total_atualizacoes ?? '?', );
case 'demanda.confirmada': return `Demanda confirmada por cidadão presente no local. Total de confirmações: ${payload.total_confirmacoes ?? '?'} (via ${payload.mecanismo ?? 'não informado'}).`;
case 'demanda.conclusao_confirmada': { const total = payload.total_conclusoes ?? '?'; if (payload.ratificacao_necessaria === true) { return `Conclusão coletiva de ${total} cidadãos registrada. Aguarda ratificação do conselheiro.`; } return `Demanda concluída por confirmação coletiva de ${total} cidadãos presentes no local.`; }
case 'demanda.evidencia_adicionada': if (payload.via === 'conclusao') { return `Evidência de ${payload.tipo ?? '?'} anexada por cidadão que confirmou a conclusão da demanda.`; } return `Evidência de ${payload.tipo ?? '?'} anexada por cidadão que confirmou a demanda.`;
case 'duplicidade.candidata_detectada': { const total = Array.isArray(payload.candidatas) ? payload.candidatas.length : '?'; return `Candidata a duplicidade detectada com ${total} demanda(s) equivalente(s) por heurística automática.`; }
case 'duplicidade.agregada': return `Demandas equivalentes agregadas sob um representante por confirmação coletiva. Esta demanda é ${payload.papel ?? 'membro'} do agregado.`;
case 'anexo.processado': return `Anexo da captura processado e publicado (${payload.tipo_mime ?? 'tipo não informado'}).`;
case 'anexo.moderado': return payload.status === 'ativo' ? 'Anexo liberado pela moderação humana.' : 'Anexo bloqueado pela moderação humana.';
case 'anexo.removido': return 'Anexo removido em definitivo por decisão de moderação humana.';
case 'moderacao.decidida': { const alvo = payload.tipo === 'texto' ? 'Conteúdo textual' : 'Anexo'; if (payload.decisao === 'removido') { return `${alvo} removido em definitivo por decisão de moderação humana.`; } return payload.decisao === 'aprovado' ? `${alvo} aprovado pela moderação humana.` : `${alvo} bloqueado pela moderação humana.`; }
default: return `Evento ${tipoEvento} registrado em ${formatarData(payload.timestamp, timestampPadrao)}.`; } }}descricaoCicloConcluido cobre os três motivos de encerramento e devolve a mesma estrutura com dias de acompanhamento e atualizações. gerarDescricaoAtualizacaoPublicada, em shared/descricao-atualizacao.ts, cobre os seis tipos de atualização. O formatarData devolve data desconhecida quando nenhum candidato gera data válida; porcentagem devolve ? quando o valor não é numérico.
4.2 Máquina de estados do snapshot
Seção intitulada “4.2 Máquina de estados do snapshot”Transições de status em d7.demanda_snapshots. Derivadas dos eventos consumidos:
┌──────────┐ │ recebida │ ← demanda.recebida (D-1a) └────┬─────┘ │ demanda.normalizada (D-1b) ┌────▼──────────┐ │ normalizada │ └────┬──────────┘ │ demanda.categorizada (D-3) ┌────▼──────────┐ │ categorizada │ └────┬──────────┘ │ demanda.georreferenciada (D-2) ┌────▼──────────────┐ │ georreferenciada │ └────┬──────────────┘ │ demanda.ranqueada (D-4) ┌────▼──────────┐ │ ranqueada │ └────┬──────────┘ │ agenda.gerada (D-5) — se a demanda está no top_10 ┌────▼──────────┐ │ agendada │ └────┬──────────┘ │ conselheiro.sorteado (D-6a) ┌────▼──────────┐ │ atribuida │ └────┬──────────┘ │ conselheiro.demanda_iniciada (D-6b) ┌────▼──────────┐ │ em_andamento │ └────┬──────────┘ │ demanda.concluída (D-6b) ┌────▼──────────┐ │ concluida │ └───────────────┘
│ demanda.conclusao_confirmada (D-12, ratificacao_necessaria: false) ┌────▼──────────┐ │ concluida │ └───────────────┘
┌──────────┐ │ removida │ ← demanda.removida_por_votação (D-10, Fase 2) └──────────┘ (qualquer status pode transitar para removida)
┌──────────┐ │ agregada │ ← duplicidade.agregada (D-12, membros) └──────────┘ (qualquer status pode transitar para agregada)Transições proibidas:
concluida,removidaeagregadasão terminais: nenhuma transição a partir deles é aplicada.- O status só avança pela ordem de precedência de
ORDEM_STATUS_DEMANDA(recebida0,normalizada1,categorizada2,georreferenciada3,ranqueada4,agendada5,atribuida6,em_andamento7,concluida8,removida9,agregada10). Saltos para a frente são aceitos em cenários de eventos fora de ordem; transição inversa ou a partir de status terminal é ignorada. - A criação de snapshot por evento fora de ordem (ex:
conselheiro.sorteadoantes dedemanda.recebida) emite log.warn. - A conclusão social respeita a mesma monotonicidade:
demanda.conclusao_confirmadasem ratificação leva aconcluidae nunca rebaixa um status terminal.
4.3 DashboardBuilder.construir(ucId, filtros) — pseudocódigo
Seção intitulada “4.3 DashboardBuilder.construir(ucId, filtros) — pseudocódigo”O dashboard é calculado on-demand a partir de d7.demanda_snapshots. Nenhuma pré-agregação é mantida: a query é executada a cada requisição. O DashboardBuilder recebe a interface FonteAgregadosDashboard, implementada pelo D7Repository, e dispara as seis consultas em paralelo com Promise.all. Para o volume do MVP, índices adequados garantem resposta < 20ms.
A cobertura entra como ucIds: string[] | null (a própria UC mais os descendentes) e vira unidade_civica_id = ANY($1::uuid[]); null representa o Brasil e omite a condição de UC, com a agregação sobre toda a base. O controller é quem resolve a subárvore e completa o envelope com unidade_civica_id, nome e nível.
função construir(ucIds: string[] | null, filtros: { dataInicio?: string, dataFim?: string }) -> IndicadoresUc:
condicoes = [] params = []
// Cobertura: subárvore da UC ou sem condição no Brasil se ucIds != null: params.push(ucIds) condicoes.push("unidade_civica_id = ANY(${params.length}::uuid[])") se filtros.dataInicio: params.push(filtros.dataInicio) condicoes.push("data_recebida >= $" + params.length) se filtros.dataFim: params.push(filtros.dataFim) condicoes.push("data_recebida <= $" + params.length) where = condicoes.length > 0 ? condicoes.join(" AND ") : "TRUE"
// Contagem por status porStatus = executar( "SELECT status, COUNT(*) as count FROM d7.demanda_snapshots WHERE ${where} GROUP BY status", params ) // Resultado: [{ status: 'concluida', count: 12 }, { status: 'em_andamento', count: 5 }, ...]
// Distribuição por nível de precedência porNivel = executar( "SELECT nivel_precedencia, COUNT(*) as count FROM d7.demanda_snapshots WHERE ${where} AND nivel_precedencia IS NOT NULL GROUP BY nivel_precedencia ORDER BY nivel_precedencia", params )
// Tempo médio até início (apenas demandas que foram iniciadas) tempoInicio = executar( "SELECT AVG( EXTRACT(EPOCH FROM (data_inicio_acompanhamento - data_recebida)) / 3600 ) as media_horas FROM d7.demanda_snapshots WHERE ${where} AND data_inicio_acompanhamento IS NOT NULL", params )
// Tempo médio até conclusão (apenas demandas concluídas) tempoConclusao = executar( "SELECT AVG(dias_ate_conclusao) as media_dias FROM d7.demanda_snapshots WHERE ${where} AND status = 'concluida'", params )
// Conselheiros ativos (count distinct de conselheiro_id em demandas em andamento) // A cobertura da UC vale aqui; os filtros de data não entram. coberturaUc = ucIds != null ? "unidade_civica_id = ANY($1::uuid[])" : "TRUE" conselheirosAtivos = executar( "SELECT COUNT(DISTINCT conselheiro_id) as count FROM d7.demanda_snapshots WHERE ${coberturaUc} AND status IN ('atribuida', 'em_andamento') AND conselheiro_id IS NOT NULL", ucIds != null ? [ucIds] : [] )
// Taxa de confiança geo baixa (confianca < limiar) limiarConfianca = LIMIAR_CONFIANCA_GEO_BAIXA // 0.5 confiancaBaixa = executar( "SELECT COUNT(*) FILTER (WHERE confianca_geo < ?) as baixa_confianca, COUNT(*) FILTER (WHERE confianca_geo IS NOT NULL) as total_com_confianca FROM d7.demanda_snapshots WHERE ${where}", [...params, limiarConfianca] )
totalDemandas = porStatus.reduce((sum, r) => sum + r.count, 0) totalAtivas = porStatus .filter(r => !['concluida', 'removida'].includes(r.status)) .reduce((sum, r) => sum + r.count, 0)
return { total_demandas: totalDemandas, total_demandas_ativas: totalAtivas, distribuicao_por_status: Object.fromEntries(porStatus.map(r => [r.status, r.count])), distribuicao_por_nivel_precedencia: Object.fromEntries(porNivel.map(r => [r.nivel_precedencia, r.count])), tempo_medio_inicio_horas: tempoInicio?.media_horas ?? null, tempo_medio_conclusao_dias: tempoConclusao?.media_dias ?? null, numero_conselheiros_ativos: conselheirosAtivos?.count ?? 0, taxa_confianca_geo_baixa: confiancaBaixa.total_com_confianca > 0 ? confiancaBaixa.baixa_confianca / confiancaBaixa.total_com_confianca : 0, ultima_atualizacao: new Date().toISOString(), }4.4 RebuildService.rebuildCompleto() — reconstrução via replay
Seção intitulada “4.4 RebuildService.rebuildCompleto() — reconstrução via replay”A reconstrução apaga as projeções atuais e reprocessa todos os eventos do barramento em ordem de sequence_number. O controller bloqueia o consumo de novos eventos com d7Service.iniciarRebuild() antes de chamar o rebuild e libera com encerrarRebuild() no finally.
função rebuildCompleto(): // 1. Limpar as projeções atuais limparTimeline() limparSnapshots() limparProcessedEvents() limparLugaresGeo() limparEvidencias()
// 2. Resetar os offsets para 0 resetarOffsets()
// 3. Replay em lotes, com todos os tipos consumidos ultimoProcessado = 0 enquanto true: lote = await eventBus.replayDeSequence(ultimoProcessado, TIPOS_EVENTO_CONSUMIDOS) se lote vazio: quebrar
para cada evento em lote: await this.d7Service.despacharEvento(evento.tipo, evento) ultimoProcessado = evento.sequence_number
// 4. Registrar o resultado no log logger.log("Rebuild completo concluído", { total_timeline_entries, total_snapshots, total_lugares_geo, total_evidencias, ultimo_sequence_processado, duracao_ms, })Enquanto rebuildando = true, o manipulador protegido descarta os eventos que chegam sem processar e sem avançar o cursor. As iterações seguintes do replay recolhem os que chegaram antes do fim do loop; um descarte tardio fica para o replay do boot seguinte. O replayDeSequence pagina internamente em lotes de 1000.
Rebuild e conteúdo removido. A limpeza da moderação é reaplicada no replay: moderacao.decidida com removido limpa de novo o snapshot e as entradas internas, e anexo.removido remove a evidência da projeção. Como o conteúdo pessoal já foi tombstonado no core.event_log pela N-0d, o rebuild não ressuscita texto nem evidência. A marca conteudo_removido do resumo é recalculada da timeline reconstruída.
4.5 D7Service.iniciar() — protocolo de inicialização
Seção intitulada “4.5 D7Service.iniciar() — protocolo de inicialização”função iniciar(): se iniciado: retornar iniciado = true
// 1. Semear o cursor de cada tipo consumido (last_sequence = 0, skipDuplicates) await repo.seedOffsets(TIPOS_EVENTO_CONSUMIDOS)
// 2. Replay de eventos perdidos, tipo a tipo offsets = await repo.buscarOffsets() mapa = new Map(offsets.map(o => [o.tipo_evento, o.last_sequence])) para cada tipo em TIPOS_EVENTO_CONSUMIDOS: ultimoProcessado = mapa.get(tipo) ?? 0 eventos = await eventBus.replayDeSequence(ultimoProcessado, [tipo]) para cada evento: await this.enfileirarProcessamento(tipo, evento)
// 3. Registrar os consumidores this.registrarConsumidores()
logger.log("D-7 Transparência inicializada", { tipos_consumidos: TIPOS_EVENTO_CONSUMIDOS.length, stubs_fase_2: TIPOS_EVENTO_STUB_FASE_2.length, })registrarConsumidores() inscreve os 27 tipos em produção com eventBus.inscrever(tipo, 'D-7', manipulador) e os 3 stubs com criarManipuladorStub. Todos os eventos passam pela fila interna (filaProcessamento), que serializa o processamento.
4.6 Handlers stub para Fase 2
Seção intitulada “4.6 Handlers stub para Fase 2”private criarManipuladorStub(tipoEvento: string): (evento: EventoRecebido) => Promise<void> { return async (evento: EventoRecebido): Promise<void> => { this.logger.log(`Evento ${tipoEvento} ignorado (MVP)`, { event_id: evento.event_id, origem: evento.origem, }); };}
async processarStub(evento: EventoRecebido): Promise<void> { this.logger.log(`Evento ${evento.tipo} ignorado (MVP)`, { event_id: evento.event_id, origem: evento.origem, });}Os eventos da Fase 2 não entram no TIPOS_EVENTO_CONSUMIDOS: são registrados como stub e não têm offset nem replay. O processarStub é o caso default do switch de despacharEvento.
4.7 Casos de borda adicionais
Seção intitulada “4.7 Casos de borda adicionais”| Caso | Comportamento |
|---|---|
Evento para demanda_id que ainda não tem snapshot (fora de ordem) |
Snapshot é criado no UPSERT com status inferido do tipo de evento e campos conhecidos preenchidos. Campos desconhecidos ficam NULL. Log.warn. |
| Replay de 100.000 eventos após rebuild | Processamento sequencial; o replayDeSequence pagina em lotes de 1000. Timeline e snapshot reconstruídos incrementalmente. Sem perda de dados — o event_id garante idempotência se o rebuild for interrompido e retomado. |
Duas requisições simultâneas a POST /api/d7/rebuild |
A primeira marca rebuildando = true; a segunda recebe 409 Conflict. O rate limit de 1/hora reduz a chance de concorrência. |
| Dashboard de UC sem demandas | Retorna objeto com todos os campos zerados/null. total_demandas: 0. |
UC fora da malha (core.uc_polygons sem a UC) |
resolverUcIds devolve a lista unitária com o próprio id; dashboard, ranking e relatório devolvem 200 com indicadores zerados e sem demandas. |
| Dashboard do Brasil (nível 7) sem demandas | resolverUcIds devolve null; a agregação roda sem filtro de UC e devolve 200 com indicadores zerados. |
| Timeline de demanda com mais de 500 entradas | Paginação via limite/deslocamento. O índice composto (demanda_id, timestamp) garante ordenação eficiente. |
demanda.concluída chega antes de conselheiro.demanda_iniciada (evento fora de ordem extrema) |
Snapshot.status transita para concluida. data_inicio_acompanhamento permanece NULL. dias_ate_conclusao vem do payload. |
duplicidade.agregada para membro com status terminal (concluida, removida) |
Status não rebaixado para agregada: a transição a partir de status terminal é ignorada. O membro permanece terminal. |
| Descrição gerada excede 10.000 caracteres | Truncada em 10.000 no INSERT. Texto completo no dados_relevantes. |
Evento com timestamp nulo ou inválido |
A descrição usa o timestamp do próprio evento como fallback (timestampPadrao); a coluna da timeline registra new Date(evento.timestamp). |
| UC tem 500 demandas: dashboard on-demand fica lento? | Com o índice demanda_snapshots_unidade_civica_id_idx, o PostgreSQL conta 500 linhas em < 2ms. A query do dashboard executa 6 consultas agregadas em paralelo. Latência total < 15ms. |
moderacao.decidida com removido para demanda sem snapshot |
O handler limpa o que existir e a entrada da decisão cria o snapshot com o status inferido. Nenhum erro. |
anexo.removido para evidência que não está na projeção |
removerEvidencia não encontra linhas e o handler segue para a timeline. Idempotente. |
Replay de anexo.removido depois do rebuild |
O evento reprocessa a remoção da evidência (idempotente) e grava uma única entrada na timeline, protegida pela unicidade de (event_id, demanda_id). |
| Resumo público de demanda com remoção | conteudo_removido=true e conteudo_removido_em com a data da remoção mais recente, lida da timeline. Sem remoção, conteudo_removido=false e o campo de data omitido. |
4.8 Decisões de design com justificativa
Seção intitulada “4.8 Decisões de design com justificativa”Dashboard calculado on-demand, não pré-agregado. Para o volume do MVP (< 100 demandas/UC), pré-agregar os indicadores do dashboard em uma tabela separada adicionaria complexidade (manter contadores incrementais, recalcular médias) sem ganho de performance perceptível. Se uma UC tiver 500 demandas, o PostgreSQL conta 500 linhas em < 2ms com o índice adequado. Na Fase 2, com escala regional, migrar para pré-agregação incremental ou cache Redis. O contrato da API REST não muda: a resposta tem o mesmo formato, independente de como os indicadores são calculados internamente.
Snapshot com UPSERT por demanda_id, não com versionamento.
O objetivo do snapshot é responder “qual o estado atual desta demanda?” em O(1). Se fosse versionado, responder essa pergunta exigiria buscar a versão mais recente em uma query adicional. A timeline já provê o histórico completo. O snapshot provê o presente. São responsabilidades complementares, não redundantes.
Rebuild como operação explícita, não automática.
O replay na inicialização (iniciar()) cobre falhas e reinicializações. O rebuild completo é operação de manutenção, útil para corrigir corrupção de projeção ou após mudança estrutural nos templates de descrição. O endpoint REST exige papel de operador, tem rate limit de 1/hora e permite disparo por pipeline de CI/CD ou administrador sem acesso ao banco.
visibilidade como coluna na timeline.
A coluna nasce publico e vira interno quando a sanitização de PII redige algum campo ou quando o conteúdo vem sinalizado como suspeito pela denylist. A moderação pode liberar a entrada depois. Manter a coluna desde o início evita migration futura.
Resumos e caminho projetados sem entrada de timeline. O texto do resumo e o dossiê são derivados que já aparecem nas seções próprias do acompanhamento e do relatório. Criar entradas de timeline para eles duplicaria o mesmo texto em duas superfícies da vitrine. A projeção atualiza o snapshot e a tabela de perfis, e o cursor avança normalmente.
5. Integração com o Barramento e Outras Colônias
Seção intitulada “5. Integração com o Barramento e Outras Colônias”5.1 Consumo de eventos via EventBusService
Seção intitulada “5.1 Consumo de eventos via EventBusService”A D-7 injeta EventBusService (do módulo @Global() N-0a) para duas operações: inscrever (registrar consumidores) e replayDeSequence (replay na inicialização e rebuild). Não usa publicar.
@Injectable()export class D7Service { private iniciado = false; private rebuildando = false; private filaProcessamento: Promise<void> = Promise.resolve();
constructor( private readonly eventBus: EventBusService, private readonly repo: D7Repository, private readonly descricaoMapper: DescricaoMapper, ) {}}Os logs usam o Logger do NestJS. Nenhuma dependência além do núcleo.
5.2 Fluxo de eventos — cadeia completa com D-7
Seção intitulada “5.2 Fluxo de eventos — cadeia completa com D-7”Cidadão (app) → POST /demandas (BFF D-1a) → demanda.recebida ──────────────────────────────┐ → [D-1b] → demanda.normalizada ─────────────────┤ → [D-2] → demanda.georreferenciada ───────────┤ → [D-3] → demanda.categorizada ───────────────┤ → [D-4] → demanda.ranqueada ────────────────┤ → ranking.atualizado ───────────────┤ │ ┌──────────────────────────────────────────┘ ▼ [D-7] — esta colônia ├── consome todos os eventos acima │ → cada evento gera 1 timeline_entry + 1 upsert snapshot │ ├── REST: GET /api/d7/timeline/:demanda_id ├── REST: GET /api/d7/dashboard/:uc_id ├── REST: GET /api/d7/demanda/:demanda_id/resumo ├── REST: GET /api/d7/demanda/:demanda_id/relatorio ├── REST: GET /api/d7/uc/:uc_id/relatorio ├── REST: GET /api/d7/uc/:uc_id/historico ├── REST: GET /api/d7/demandas e GET /api/d7/lugares ├── REST: GET /api/d7/lugar/:lugar_id/resumo ├── REST: GET /api/d7/ranking/:uc_id └── REST: GET /api/d7/uc/poligonos
O pipeline continua em paralelo: → [D-5] → agenda.gerada ──────┐ → agenda.item_disponível ─┐ │ → [D-6a] → conselheiro.sorteado ──┤ → [D-6b] → conselheiro.demanda_iniciada ──┤ → conselheiro.atualização_publicada ─┤ → conselheiro.prazo_próximo ─────────┤ → demanda.concluída ─────────────────┤ → conselheiro.ciclo_concluído ────────┤ → [D-12] → duplicidade.candidata_detectada ───┤ → demanda.confirmada ────────────────┤ → demanda.evidencia_adicionada ──────┤ → duplicidade.agregada ──────────────┤ → demanda.conclusao_confirmada ──────┤ → [L-1] → lugar.cadastrado ──────────────────┤ → [L-2] → lugar.georreferenciado ────────────┤ → [L-3] → lugar.validado e lugar.desativado ─┤ → [D-1c] → anexo.processado ──────────────────┤ → anexo.moderado e anexo.removido ───┤ → [D-1d] → moderacao.decidida ────────────────┘ │ ┌───────────────────────────┘ ▼ [D-7] consome todos — timeline + snapshot atualizados incrementalmente │ ▼ Front-end / Cidadão / Pesquisador → GET /api/d7/timeline/:id → vê o ciclo completo da demanda → GET /api/d7/dashboard/:uc → vê indicadores da UC5.3 Publicação de eventos
Seção intitulada “5.3 Publicação de eventos”A D-7 não publica eventos. A seção existe por consistência com o template dos documentos detalhados. Nenhum método publicar é chamado.
5.4 Chamadas síncronas via BFF
Seção intitulada “5.4 Chamadas síncronas via BFF”A D-7 não faz chamadas síncronas a outras colônias. Seus endpoints REST são chamados diretamente pelo front-end ou por clientes externos (pesquisadores, auditores). O BFF D-1a não atua como proxy para a D-7 — consultas de leitura pública não passam pelo BFF.
5.5 Dependências de projeções de leitura
Seção intitulada “5.5 Dependências de projeções de leitura”A D-7 não consome projeções de leitura de outras colônias. Os dados externos que acessa são:
core.event_logviaEventBusService.replayDeSequence()— dependência do núcleo, permitida.core.uc_polygons— leitura direta e somente leitura de dados de referência, para os polígonos de UC e o enriquecimento com nome e nível (exceção controlada registrada no AGENTS.md do repo api). A hierarquia usada na resolução da cobertura vem do módulo compartilhadosrc/shared/hierarquia-uc/, que lêunidade_civica_id,niveleparent_uc_idcom cache em memória de 6 horas e guarda de ciclo.d7.constants.ts— tipos consumidos, limiares e tamanhos de página.
A D-7 é autossuficiente: todo dado que projeta é derivado exclusivamente dos eventos que consome. A leitura de core.uc_polygons serve apenas para rotular a UC e resolver a subárvore; nenhuma query a schemas de outras colônias.
5.6 Independência de Redis
Seção intitulada “5.6 Independência de Redis”A D-7 não utiliza Redis. As projeções residem em PostgreSQL. O dashboard é calculado on-demand via queries agregadas. Para o volume do MVP, a performance é adequada.
5.7 Rate limiting nos endpoints REST
Seção intitulada “5.7 Rate limiting nos endpoints REST”A D-7 aplica rate limiting nos próprios endpoints via @nestjs/throttler. Diferente das colônias puras de eventos (D-5, D-6b), a D-7 é exposta à internet como API pública. Sem rate limiting, scraping massivo poderia degradar o PostgreSQL. O POST /api/d7/rebuild é a única rota sob PapelOperadorGuard, com JWT de operador e allowlist de IP.
@Throttle({ default: { limit: 60, ttl: 60000 } }) // 60 req/min por IP@Get('timeline/:demanda_id')async getTimeline(...) { ... }
@Throttle({ default: { limit: 30, ttl: 60000 } }) // 30 req/min por IP@Get('dashboard/:uc_id')async getDashboard(...) { ... }
@UseGuards(PapelOperadorGuard)@Throttle({ default: { limit: 1, ttl: 3600000 } }) // 1 req/hora por IP@Post('rebuild')async triggerRebuild(...) { ... }6. Performance e Limites
Seção intitulada “6. Performance e Limites”6.1 Rate limiting
Seção intitulada “6.1 Rate limiting”| Endpoint | Limite | Justificativa |
|---|---|---|
GET /api/d7/timeline/:id |
60 req/min por IP | Cada cidadão consulta poucas timelines. Tráfego maior sugere scraping. |
GET /api/d7/dashboard/:uc |
30 req/min por IP | Dashboard é menos consultado que timeline. |
GET /api/d7/demanda/:id/resumo |
60 req/min por IP | Similar à timeline. |
GET /api/d7/demanda/:id/relatorio |
30 req/min por IP | Relatório completo da demanda, com timeline inteira. |
GET /api/d7/uc/:uc_id/relatorio |
15 req/min por IP | Relatório agregado da UC; varre todos os snapshots da UC. |
GET /api/d7/demandas |
60 req/min por IP | Listagem geoespacial para o mapa. |
GET /api/d7/lugares |
60 req/min por IP | Listagem geoespacial para o mapa. |
GET /api/d7/lugar/:id/resumo |
60 req/min por IP | Resumo público do lugar. |
GET /api/d7/ranking/:uc_id |
60 req/min por IP | Ranking público da UC. |
GET /api/d7/uc/:uc_id/historico |
60 req/min por IP | Histórico público das concluídas da UC; varredura paginada sobre os snapshots. |
GET /api/d7/uc/poligonos |
30 req/min por IP | Polígonos de UC; malha municipal é pesada. |
POST /api/d7/rebuild |
1 req/hora por IP | Operação administrativa, sob papel de operador. |
6.2 Cotas e limites de tamanho
Seção intitulada “6.2 Cotas e limites de tamanho”| Limite | Valor | Justificativa |
|---|---|---|
Tamanho máximo de descricao na timeline |
10.000 caracteres | Suficiente para descrições legíveis + trecho de atualização. |
Tamanho máximo da descricao pública no snapshot |
5.000 caracteres | Mesmo limite da captura (d1a.constants.ts:9); d7.constants.ts:44. Parâmetro público nos dois espelhos. |
Tamanho máximo de cada descricao em descricoes_midia |
2.000 caracteres | Mesmo limite da legenda original em demanda.normalizada; TAMANHO_MAXIMO_DESCRICAO_MIDIA em d7.constants.ts:52. Aplicado na projeção e na leitura. |
Tamanho máximo de dados_relevantes (JSONB) |
10 KB | Campos estruturados de cada evento. Suficiente para reconstruir o contexto sem acessar o event_log. |
Tamanho máximo de texto_ultima_atualizacao no snapshot |
200 caracteres | Resumo para exibição no dashboard. Texto completo na timeline. |
| Tamanho de página na timeline | Máximo 100 entradas | Paginação obrigatória. |
| Número máximo de demandas por UC para dashboard | Sem limite no MVP | Na prática < 1000. Se exceder, adicionar paginação no dashboard (Fase 2). |
Profundidade máxima de dados_relevantes (JSONB aninhado) |
3 níveis | Eventos de negócio raramente excedem 3 níveis de aninhamento. |
6.3 Índices e padrões de query
Seção intitulada “6.3 Índices e padrões de query”| Índice | Query atendida |
|---|---|
timeline_entries_pkey (id) |
Acesso por ID interno. |
timeline_entries_event_id_demanda_id_key (UNIQUE composto) |
Idempotência por event_id + demanda_id. |
timeline_entries_demanda_id_timestamp_idx (composto: demanda_id, timestamp) |
Timeline da demanda ordenada por data. Query principal do endpoint GET /timeline/:id. |
timeline_entries_demanda_id_tipo_evento_timestamp_idx (composto: demanda_id, tipo_evento, timestamp) |
Timeline filtrada por tipo de evento. Query param ?tipo_evento=. |
timeline_entries_unidade_civica_id_idx |
Eventos de uma UC específica. |
timeline_entries_conselheiro_id_idx |
Visão de conselheiro na Fase 2. |
timeline_entries_event_id_idx |
Busca por event_id (idempotência dos handlers). |
demanda_snapshots_pkey (demanda_id) |
Resumo de demanda: GET /demanda/:id/resumo. |
demanda_snapshots_unidade_civica_id_idx |
Dashboard: WHERE unidade_civica_id = ANY($1::uuid[]) para a subárvore da UC. Base para todas as queries do DashboardBuilder. |
demanda_snapshots_unidade_civica_id_status_idx (composto) |
Dashboard: contagem por status. WHERE uc = ANY($1::uuid[]) GROUP BY status. Histórico da UC: filtro fixo status = 'concluida' sobre a subárvore, com count e página pelo mesmo índice. |
demanda_snapshots_conselheiro_id_idx |
Visão de conselheiro na Fase 2. |
demanda_snapshots_status_idx |
Monitoramento: “quantas demandas em cada status no sistema todo?”. |
demanda_snapshots_coordenada_lat_coordenada_lng_idx (composto) |
Listagens geoespaciais por bounding box. |
6.4 Padrões de query esperados
Seção intitulada “6.4 Padrões de query esperados”buscarTimelinePorDemanda(demanda_id, { limite, deslocamento, tipoEvento? }): 1 count e 1 findMany por consulta de timeline, filtrandovisibilidade = 'publico'e ordenando portimestamp DESC, sequence_number DESC. Usa índice composto(demanda_id, timestamp)ou(demanda_id, tipo_evento, timestamp).buscarSnapshot(demanda_id): 1 query por consulta de resumo. PK lookup emdemanda_snapshots. O(1).DashboardBuilder.construir(ucIds, filtros): 6 consultas agregadas sobredemanda_snapshots, comWHERE unidade_civica_id = ANY($1::uuid[])na subárvore ou sem condição de UC no Brasil, executadas em paralelo. Os índices tornam cada query < 5ms.inserirTimelineESnapshotsAtomico: 1 transação por evento recebido, com o INSERT da timeline e o INSERT/UPDATE do snapshot. Volume de cerca de 12 escritas por demanda ao longo do ciclo completo.avancarOffset: 1 UPDATE por evento processado, só quando a sequência nova é maior.
Medição de EXPLAIN (ANALYZE, BUFFERS) no dev, com 19 snapshots e a subárvore de São Paulo (nível 5, 2.816 UCs): o ranking com estado executou em 1,493 ms via Bitmap Index Scan em demanda_snapshots_unidade_civica_id_status_idx; a agregação de status com estado, em 0,985 ms via Index Only Scan no mesmo índice; a agregação do Brasil, em 0,051 ms via demanda_snapshots_status_idx. Nenhum índice novo entrou: os índices existentes atendem = ANY e a varredura nacional. A decisão sobre índice adicional fica com a medição na VPS quando o volume crescer.
Volume esperado no MVP: < 100 demandas/dia × ~12 eventos/demanda = ~1.200 eventos/dia. 1.200 INSERTs em timeline_entries + 1.200 UPSERTs em demanda_snapshots + ~200 consultas de timeline/dashboard por dia. Tempo médio de processamento por evento: < 5ms. Tempo médio de resposta da API: < 30ms (query + serialização).
6.5 Estratégia de cache
Seção intitulada “6.5 Estratégia de cache”A D-7 não implementa cache no MVP. As queries são sobre índices PostgreSQL otimizados. A timeline de uma demanda com 12 entradas carrega em < 3ms. O dashboard de uma UC com 500 demandas é computado em < 15ms com as 6 queries agregadas.
Na Fase 2, com escala regional (50.000+ demandas/UC), implementar cache Redis para dashboards com TTL de 60 segundos. A invalidação é trivial: cada handler que atualiza demanda_snapshots invalida a chave d7:dashboard:{uc_id}. O dashboard é recalculado na próxima requisição.
6.6 Projeção de volume
Seção intitulada “6.6 Projeção de volume”| Cenário | Demandas/dia | Eventos/dia (~12/demanda) | Timeline entries | Snapshots | Tamanho estimado |
|---|---|---|---|---|---|
| PoC (1 bairro) | ~20 | ~240 | ~240/dia | ~20 ativos | < 10 MB/mês |
| MVP (1 município) | ~100 | ~1.200 | ~1.200/dia | ~100 ativos | < 50 MB/mês |
| Fase 2 (regional) | ~10.000 | ~120.000 | ~120.000/dia | ~10.000 ativos | < 5 GB/mês (comparticionamento) |
Para Fase 2: particionar timeline_entries por mês e arquivar partições antigas para storage frio. demanda_snapshots tem tamanho constante (~1 linha/demanda) — não particionar.
7. Testabilidade
Seção intitulada “7. Testabilidade”7.1 Como testar o módulo isolado
Seção intitulada “7.1 Como testar o módulo isolado”Teste unitário do D7Service:
O spec usa um duplo em memória do D7Repository (RepoEmMemoria, que mantém timelines, snapshots, offsets, lugares e evidências) e um mock do EventBusService com replayDeSequence e inscrever. O DescricaoMapper entra real, porque é determinístico.
const repo = new RepoEmMemoria();const eventBus = { replayDeSequence: jest.fn().mockResolvedValue([]), inscrever: jest.fn(),};
const module: TestingModule = await Test.createTestingModule({ providers: [ D7Service, DescricaoMapper, { provide: EventBusService, useValue: eventBus }, { provide: D7Repository, useValue: repo }, ],}).compile();
service = module.get(D7Service);Os eventos de teste são criados por um helper criarEvento(eventId, tipo, payload, sequencia) com o formato do EventoConsultado (sequence_number, event_id, tipo, versao_schema, timestamp, origem, correlacao_id, payload).
7.2 Cenários de teste críticos
Seção intitulada “7.2 Cenários de teste críticos”Happy path:
| # | Cenário | Verificação |
|---|---|---|
| T1 | processarDemandaRecebida() com payload completo |
Timeline entry criada com descrição correta. Snapshot criado com status recebida e data_recebida preenchida. |
| T2 | processarDemandaCategorizada() após processarDemandaRecebida() |
Timeline entry criada. Snapshot atualizado: status → categorizada, categoria_id e nivel_precedencia preenchidos. |
| T3 | processarConselheiroAtualizacaoPublicada() — tipo protocolo_aberto |
Timeline entry criada com descrição contendo número de protocolo. Snapshot total_atualizacoes incrementado. |
| T4 | processarDemandaConcluida() com payload completo |
Timeline entry criada com dias até conclusão. Snapshot: status → concluida, data_conclusao e dias_ate_conclusao preenchidos. |
| T5 | Ciclo completo simulado: recebida → normalizada → categorizada → georreferenciada → ranqueada → sorteada → iniciada → atualizacao_publicada (2x) → concluída → ciclo_concluído |
11 timeline entries. Snapshot.status: concluida. total_atualizacoes: 2. |
| T6 | DescricaoMapper.gerar() para cada tipo de evento |
Descrição não vazia, em português, contendo dados do payload. Nenhum erro de template string. |
| T7 | DashboardBuilder.construir(ucId) com 5 demandas em estados variados |
Retorna total_demandas: 5, distribuição por status correta, numero_conselheiros_ativos correto. |
| T8 | GET /api/d7/timeline/:id — paginação |
limite=5, deslocamento=0 retorna 5 entradas. deslocamento=5 retorna próximas entradas. |
Falhas e bordas:
| # | Cenário | Verificação |
|---|---|---|
| T9 | Evento duplicado (mesmo event_id) |
buscarTimelinePorEventId() retorna registro existente. Log. Retorna sem modificar banco. |
| T10 | Payload sem demanda_id |
Log.error. Evento descartado e cursor avançado. Nenhum INSERT/UPSERT. |
| T11 | Evento com timestamp inválido (null, “abc”) |
Descrição gerada com o timestamp do próprio evento como fallback. |
| T12 | conselheiro.sorteado chega antes de demanda.recebida (fora de ordem) |
Snapshot criado com status atribuida e conselheiro_id preenchido, mas data_recebida NULL. Log.warn. |
| T13 | demanda.concluída para demanda inexistente no snapshot (fora de ordem extremo) |
Snapshot criado com status concluida. Demais campos NULL. Timeline entry gerada normalmente. |
| T14 | Descrição excede 10.000 caracteres (payload com texto muito longo) | Truncada em 10.000. Dados completos em dados_relevantes. |
| T15 | conselheiro.atualização_publicada com tipo desconhecido |
Descrição usa template genérico (Atualização do conselheiro (${tipo}): ${texto}). Sem erro. |
| T16 | Dashboard para UC que não existe (sem demandas) | Retorna objeto com todos os campos zerados/null. Status 200, não 404. |
| T17 | Rebuild interrompido no meio (timeout/erro no lote 3 de 10) | Ao reiniciar, o iniciar() relê os offsets e reprocessa os eventos não processados. Timeline e snapshot sem duplicação (idempotência por event_id). |
| T18 | Duas requisições POST /rebuild em paralelo (rate limit bypass) |
A primeira marca rebuildando = true via iniciarRebuild(); a segunda recebe 409 Conflict. |
Teste de integração (com PostgreSQL de teste):
| # | Cenário | Verificação |
|---|---|---|
| T19 | Ciclo completo: 1 demanda com 11 eventos, reprocessados via replay | 11 timeline entries. Snapshot final: status concluida, todos os campos preenchidos. Query do dashboard retorna 1 demanda ativa (antes da conclusão) → 0 ativas (após). |
| T20 | Replay após reinício: 20 eventos, 15 já processados | 15 ignorados por idempotência. 5 novos processados. Sem duplicação na timeline. Snapshots atualizados. |
| T21 | Rebuild completo com 500 eventos reprocessados pelo replay | 500 timeline entries. Snapshots consistentes com último estado de cada demanda. Dashboard reflete estado pós-rebuild. |
| T22 | 3 demandas na mesma UC, 2 concluídas, 1 em andamento | Dashboard: total_demandas: 3, total_ativas: 1, concluida: 2, em_andamento: 1, conselheiros_ativos: 1. |
| T23 | UC com demanda de confiança geo 0.3 (< limiar 0.5) | Dashboard: taxa_confianca_geo_baixa: 1.0 (100% das demandas com confiança conhecida estão abaixo do limiar). |
| T24 | demanda.normalizada 1.4.0 com midias_descritas |
Snapshot grava descricoes_midia com uma entrada por imagem, tradução preferida sobre o original, sanitização de PII e corte em 2.000 caracteres. |
| T25 | Evidência de captura com object_key_temp que casa com descricoes_midia |
Resumo e relatório devolvem descricao no item de evidência; evidência sem par fica sem o campo. |
| T26 | Remoção de texto, remoção de anexo e eliminação do titular | A remoção de texto e a eliminação zeram descricoes_midia; a remoção de anexo apaga apenas a entrada do object_key_temp correspondente. |
7.3 Dados de desenvolvimento local
Seção intitulada “7.3 Dados de desenvolvimento local”A D-7 não tem seed SQL de projeções: elas são descartáveis e se reconstroem por replay. No boot, seedOffsets cria as 27 linhas de consumer_offset com last_sequence = 0 e skipDuplicates, sem sobrescrever cursor existente. Os dados de exemplo vêm do seed do repo (npm run seed:dev), que cria a UC de teste, cidadãos e conselheiros; a partir deles, o fluxo real publica os eventos e a D-7 projeta a timeline e os snapshots.
8. Alinhamento com o MVP
Seção intitulada “8. Alinhamento com o MVP”8.1 O que é MVP obrigatório
Seção intitulada “8.1 O que é MVP obrigatório”| Funcionalidade | Status |
|---|---|
inscrever para os 27 tipos de evento consumidos, com handlers idempotentes |
MVP obrigatório |
3 handlers stub registrados para eventos da Fase 2 (demanda.removida_por_votação, votação.resultado_publicado e hash.checkpoint_publicado) |
MVP obrigatório |
DescricaoMapper com templates em português para todos os 27 tipos de evento |
MVP obrigatório |
timeline_entries — projeção append-only com unicidade (event_id, demanda_id) para idempotência |
MVP obrigatório |
demanda_snapshots — estado corrente de cada demanda atualizado incrementalmente |
MVP obrigatório |
DashboardBuilder — cálculo on-demand de indicadores da UC e dos descendentes a partir de demanda_snapshots (nível 7 agrega toda a base) |
MVP obrigatório |
Endpoint GET /api/d7/timeline/:demanda_id com paginação e filtro por tipo de evento |
MVP obrigatório |
Endpoint GET /api/d7/dashboard/:uc_id com filtros de janela temporal |
MVP obrigatório |
Endpoint GET /api/d7/demanda/:demanda_id/resumo |
MVP obrigatório |
Endpoints GET /api/d7/demanda/:demanda_id/relatorio e GET /api/d7/uc/:uc_id/relatorio |
MVP obrigatório |
Endpoints GET /api/d7/demandas, GET /api/d7/lugares e GET /api/d7/uc/poligonos |
MVP obrigatório |
Endpoints GET /api/d7/lugar/:lugar_id/resumo, GET /api/d7/ranking/:uc_id e GET /api/d7/uc/:uc_id/historico |
MVP obrigatório |
Projeções d7.lugares_geo e d7.demanda_evidencias, com as colunas geo, de agregado e de moderação do snapshot |
MVP obrigatório |
descricoes_midia no snapshot e descricao opcional por evidência no resumo e no relatório, com a limpeza nos caminhos de remoção e LGPD |
MVP obrigatório |
| Rate limiting nos endpoints REST | MVP obrigatório |
RebuildService.rebuildCompleto() com replay do barramento |
MVP obrigatório |
POST /api/d7/rebuild sob papel de operador, com rate limit para trigger administrativo |
MVP obrigatório |
| Consumer offsets para os 27 tipos de evento consumidos | MVP obrigatório |
Propagação de correlacao_id, com fallback para o event_id |
MVP obrigatório |
Logs estruturados com demanda_id, event_id, tipo_evento e uc_id |
MVP obrigatório |
8.2 Simplificações válidas no MVP
Seção intitulada “8.2 Simplificações válidas no MVP”| Simplificação | Justificativa | Quando remover |
|---|---|---|
| Dashboard calculado on-demand, sem pré-agregação incremental | Volume MVP (< 100 demandas/UC) torna a query agregada sobre demanda_snapshots suficientemente rápida. Pré-agregar adicionaria complexidade de manutenção de contadores sem ganho perceptível. |
Migrar para pré-agregação incremental + cache Redis quando o volume de demandas por UC exceder 500. |
| Visão de conselheiro adiada para Fase 2 | Timeline + dashboard já cobrem a rastreabilidade completa do ciclo da demanda. A visão de conselheiro (demandas acompanhadas, tempo médio, status) é consultável indiretamente filtrando por conselheiro_id nos snapshots. Projeção dedicada com índices otimizados entra na Fase 2, quando houver volume suficiente de ciclos concluídos para justificar a complexidade adicional. |
Criar tabela d7.conselheiro_views e endpoint GET /api/d7/conselheiro/:id na Fase 2. |
Descrições hardcoded em português (switch/case no DescricaoMapper) |
Os templates são strings fixas no código. Trocar idioma ou adicionar variação é editar o mapper. Sem complexidade de i18n para 27 tipos de evento. | Extrair templates para arquivo de configuração ou tabela d7.description_templates se houver necessidade de suporte a múltiplos idiomas (Fase 2+). |
dados_relevantes com campos manualmente selecionados por handler, sem schema automático |
Cada handler decide quais campos do payload são relevantes para exibição. Sem dependência de schema Registry para extração. | Automatizar extração de dados_relevantes a partir de anotações no schema Registry (Fase 2+). |
Visibilidade publico/interno limitada à sanitização e à denylist |
A entrada nasce interno quando a sanitização de PII redige algum campo ou quando o conteúdo vem sinalizado como suspeito; a moderação libera ou mantém. A distinção para dados pessoais de outros tipos fica para a Fase 2. |
Implementar lógica de visibilidade baseada em tipo de evento e conteúdo do payload na Fase 2. |
ranking.atualizado gera timeline entries, mas não atualiza snapshots individuais |
O evento é agregado da UC, não de uma demanda específica. A timeline entry registra o fato; os snapshots não são afetados. Suficiente para rastreabilidade no MVP. | Se o front-end do dashboard precisar saber quando cada demanda entrou na agenda, adicionar consumo de agenda.item_disponível para atualizar snapshot correspondente (Fase 2). |
| Sem endpoint de busca full-text na timeline | A timeline é acessada por demanda_id. Busca textual (“quais demandas mencionam Sabesp?”) não é coberta no MVP. |
Adicionar índice GIN em dados_relevantes e endpoint GET /api/d7/search?q= na Fase 2, ou delegar para a D-15 (Pesquisa e Exportação de Dados). |
| Cobertura da UC resolvida pela subárvore em memória | A subárvore vem de core.uc_polygons pelo módulo compartilhado src/shared/hierarquia-uc/, com cache de 6 horas. Materializar caminho na tabela ou adotar PostGIS fica para a Fase 2 do projeto, se o volume crescer. |
Caminho materializado ou índice de subárvore quando a medição justificar. |
Rebuild bloqueia consumo de novos eventos (rebuildando = true) |
Simplificação aceitável: rebuild é operação rara (minutos, não horas) e o volume de eventos descartados é pequeno. As iterações seguintes do replay recolhem os eventos que chegaram durante a reconstrução; um descarte tardio fica para o replay do boot seguinte. | Implementar rebuild não-bloqueante com buffer de eventos recebidos durante a reconstrução (Fase 2). |
8.3 O que vai para a Fase 2
Seção intitulada “8.3 O que vai para a Fase 2”- Visão de conselheiro: tabela
d7.conselheiro_views, endpointGET /api/d7/conselheiro/:id - Processamento dos eventos
votação.resultado_publicado,demanda.removida_por_votaçãoehash.checkpoint_publicado - Índice GIN em
dados_relevantespara busca textual na timeline - Pré-agregação incremental do dashboard com cache Redis (TTL 60s)
- Visibilidade
internopor tipo de evento e conteúdo de dados pessoais além da sanitização - I18n: suporte a múltiplos idiomas via templates externalizados
- Exportação de timeline como PDF/JSON para auditoria externa
- Métricas Prometheus:
d7_timeline_entries_total,d7_snapshots_por_status,d7_dashboard_latency_ms,d7_rebuild_duration_seconds - Particionamento de
timeline_entriespor mês
8.4 Verificação de conflitos com outras colônias
Seção intitulada “8.4 Verificação de conflitos com outras colônias”Verificação 1: A D-7 é consumidora terminal. Há risco de ciclo de eventos? Não. A D-7 não publica eventos. É o sumidouro final do pipeline — consome, projeta, expõe via REST, mas não realimenta o barramento. Nenhuma colônia depende de eventos publicados pela D-7. Não há ciclo.
Verificação 2: A D-7 expõe REST API própria. Isso viola a regra de que o BFF D-1a é o ponto de entrada HTTP? Não. A regra no Apêndice B (Princípio 3) diz: “Exceção única: o BFF da D-1a pode chamar outras colônias via HTTP para operações síncronas de front-end”. Consulta pública de leitura sem autenticação não é operação síncrona de front-end. O padrão CQRS naturalmente separa a API de leitura (D-7) da API de escrita (BFF D-1a). O núcleo também expõe REST própria: a N-0a tem rotas internas de operação, sob papel de operador. O precedente existe.
Verificação 3: A D-7 duplica dados que já existem em outras colônias (ex: categoria_id está na D-3, score_final está na D-4). Isso não viola o princípio de isolamento?
Não. A duplicação é inerente ao padrão CQRS: o write model está nas colônias de origem, o read model é uma projeção desnormalizada. A D-7 não escreve nos schemas das outras colônias. Os dados na D-7 são derivados exclusivamente dos eventos consumidos — se houver divergência, o replay reconstrói a partir dos eventos.
Verificação 4: O demanda_snapshots.status usa nomenclatura diferente de d5.backlog_items.status (em_andamento vs. em_progresso). Isso gera confusão?
São dimensões diferentes. O status na D-7 (demanda_snapshots.status) reflete o ciclo de vida da demanda no sistema como um todo. O status na D-5 (backlog_items.status) reflete a posição da demanda no backlog. São correlacionados mas semanticamente distintos. A timeline pública mostra o status da D-7, que é o que o cidadão vê. A documentação dos endpoints deixa claro qual dimensão cada campo representa.
Verificação 5: A ordem de deploy. Se D-7 subir antes de D-1a publicar eventos?
A D-7 opera em modo “vazio”. Handlers registrados, zero timeline entries, zero snapshots. Os endpoints REST retornam arrays vazios e dashboards zerados. Assim que as colônias a montante começarem a publicar, a D-7 projeta incrementalmente. O iniciar() faz o replay a partir do último offset conhecido. Se não há offset (primeira execução), começa do 0 e processa todos os eventos disponíveis no log. A ordem natural do Bloco 2 (D-6b → D-7) garante que as colônias a montante já estão publicando quando a D-7 sobe em produção.
Verificação 6: Existem consumer offsets. A tabela d7.consumer_offset pode crescer indefinidamente?
Não. A tabela tem exatamente 27 linhas, uma por tipo de evento consumido em produção. Cada linha é atualizada (UPDATE, não INSERT) quando eventos daquele tipo são processados. O número de linhas é constante; os stubs da Fase 2 não entram na tabela.
Verificação 7: O endpoint POST /api/d7/rebuild é operação administrativa. Quem o chama?
No MVP: administrador do sistema via ferramenta de API (curl, Postman) ou script de CI/CD. Na Fase 2: pipeline de deploy que executa rebuild após alteração nos templates de descrição ou na estrutura das projeções. O endpoint exige papel de operador (JWT e allowlist de IP) e o rate limit de 1/hora é a segunda barreira contra abuso.
Referências
Seção intitulada “Referências”- Ficha técnica da colônia: Apêndice B - Colônias.md, seção “D-7 — Transparência”
- Padrão CQRS e projeções de leitura: contexto_IA.md, seção 22 (O Formigueiro)
- Schemas de eventos: N-0b - Registry.md
- Barramento de eventos: N-0a - Event Bus.md
- Observabilidade: N-0c - Observabilidade.md
- Colônias a montante: todas as colônias do pipeline de demanda (D-1a até D-6b).
- Colônias de leitura relacionadas: D-15 (Pesquisa e Exportação de Dados, Fase 2), D-22 (Projeções Independentes, Fase 3) e D-23 (Snapshot Público Versionado, Fase 3).
- Stack de referência e arquitetura do MVP: Apêndice B - Colônias.md, seção “Arquitetura do MVP — Monolito Modular”
- Mapa de dependências de eventos: Apêndice B - Colônias.md, seção “Mapa de Dependências de Eventos entre Colônias”
- Princípios do Formigueiro: Apêndice B - Colônias.md, seção “Princípios herdados do Formigueiro”
Documento de especificação técnica de implementação.