Pular para o conteúdo

D-7 — Transparência

Colônia de leitura pura — Fase 1


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).


  • 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.

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.

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ígonos
@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();
}
}
  • 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 EventBusModule explicitamente. EventBusModule é @Global(), e o EventBusService é 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 @Throttle do controller. O ThrottlerModule é configurado globalmente, não pelo módulo da D-7.
  • O OnModuleInit dispara 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 HierarquiaUcModule da camada compartilhada (src/shared/hierarquia-uc/). O HierarquiaUcService resolve 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 filtro uc_id de /api/d7/demandas.
  • O módulo lê core.uc_polygons diretamente (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_snapshots com 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.

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ção
processarDemandaRecebida(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 2
processarStub(evento): Promise<void>;
// D7Service consome 27 tipos em produção + 3 stubs.
// Não publica eventos.

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.ts
export 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.

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

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.

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 exceto removida, agregada e concluida; um valor explícito aceita qualquer status válido, inclusive concluida
  • categoria_id — opcional, máx. 10 caracteres
  • uc_id — opcional, UUID v4
  • nivel_precedencia — opcional, inteiro 1 a 5
  • limite — padrão 100, mínimo 1, máximo 1000
  • deslocamento — 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.

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 caracteres
  • status — um dos status válidos de demanda
  • data_inicio / data_fim — ISO-8601, janela sobre data_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.

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.

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.

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 caracteres
  • data_inicio / data_fim — ISO-8601, janela sobre data_conclusao
  • titulo — opcional, trecho do título, busca parcial sem diferenciar maiúsculas
  • limite — padrão 20, mínimo 1, máximo 100
  • deslocamento — 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.


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.

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");
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.

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");
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.

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");

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.

  1. 20260813102504_create_d7_tables — Cria o schema d7, as tabelas timeline_entries, demanda_snapshots, processed_events e consumer_offset, com os índices e a unicidade composta de (event_id, demanda_id). Sem CHECKs.
  2. 20260819143608_d7_geo_projecao — Adiciona coordenada_lat, coordenada_lng e titulo a demanda_snapshots com índice composto de coordenadas; cria d7.lugares_geo com índice composto e ultimo_event_id UNIQUE. Não altera linhas existentes.
  3. 20260820160000_d7_agregacao — Adiciona total_confirmacoes, agregado_representante_id e agregado_membros_ids a demanda_snapshots; cria d7.demanda_evidencias com ultimo_event_id UNIQUE e índice por demanda_id. Não altera linhas existentes.
  4. 20260910_subcategoria_id_d7_snapshots — Adiciona subcategoria_id (VARCHAR(50)) a demanda_snapshots. Alimenta o ícone do marcador no mapa sem depender da categorização da D-3.
  5. 20260911120000_d7_evidencia_object_key — Remove a coluna url de d7.demanda_evidencias e adiciona object_key (TEXT) e possui_dado_sensivel (padrão false). A projeção é limpa antes da alteração, porque é descartável e o rebuild repovoa object_key a partir dos eventos.
  6. 20260914130000_d7_lugares_validacao — Adiciona a d7.lugares_geo descricao, horario_funcionamento, subtipo_confirmado, confianca_tipificacao, status_confianca, metodo_validacao e total_confirmacoes, com índice em status_confianca.
  7. 0012_d7_resumos_caminhos — Adiciona municipio_id, resumo_agregado, resumo_ciclo, resumos_atualizado_em e caminho_dossie a d7.demanda_snapshots e cria d7.caminhos, com a chave composta de município e subcategoria.
  8. 0015_d7_descricoes_midia — Adiciona descricoes_midia (JSONB, default []) a d7.demanda_snapshots e object_key_temp (VARCHAR(500)) a d7.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.

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).

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.

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.

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.

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.

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.

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.


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.

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 retenta
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 faixa

Coordenadas 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.

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_normalizacao
metadata.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 item

A 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.

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_id
nivel_precedencia = payload.nivel_precedencia
metadata.confianca_categorizacao = payload.confianca_categorizacao
metadata.metodo_categorizacao = payload.metodo
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_id
metadata.metodo_resolucao = payload.metodo_resolucao
confianca_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.

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_final
metadata.posicao_ranking = payload.posicao_no_ranking
metadata.versao_parametros = payload.versao_parametros
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.
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.
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.
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_id
metadata.total_elegiveis = payload.total_elegiveis
metadata.seed_sorteio = payload.seed_publico

3.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 evento

3.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 caracteres
atualizacao_id = payload.atualizacao_id (sempre presente)
conteudo_suspeito = payload.conteudo_suspeito quando true
origem_texto, modelo_transcricao e confianca_transcricao = copiados quando presentes
tipo, numero_sequencial e origem_estruturacao = copiados do payload

O 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 + 1
texto_ultima_atualizacao = payload.texto_estruturado sanitizado, truncado em 200 caracteres
metadata.ultimo_tipo_atualizacao = payload.tipo

Com 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.

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.
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 evento
dias_ate_conclusao = payload.dias_ate_conclusao
total_atualizacoes = payload.total_atualizacoes
texto_ultima_atualizacao = payload.resumo_final sanitizado, truncado em 200 caracteres

3.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.

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_offset

Se 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.

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.

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.

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_lugar for residencia ou poligono_uc: avança o cursor e retorna sem projetar. Residências nunca entram no banco de leitura público (LGPD). poligono_uc segue em análise na L-2 no MVP.
  • Caso contrário: upsert em d7.lugares_geo com coordenadas_brutas, tipo_lugar, nome, subtipo, descricao, horario_funcionamento e data_recebida (timestamp do evento). O status = '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 por ultimo_event_id.
  • descricao e horario_funcionamento sã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_confirmado e confianca_tipificacao quando 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.

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_confirmacoes do 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 em d7.demanda_evidencias com a mesma via.

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_ids com os ids dos membros.
  • Cada membro recebe status agregada e agregado_representante_id.
  • Status monotônico: membro com status terminal (concluida, removida) não é rebaixado para agregada.

anexo.processado (produtor D-1c, versão 1.4.0 com object_key_temp):

  • Insere linha em d7.demanda_evidencias com via do payload (captura, confirmacao ou conclusao, com captura como padrão em evento antigo), object_key e object_key_temp quando presente, e gera entrada na timeline.
  • Evento com possui_dado_sensivel = true não projeta a evidência na leitura pública e remove linha existente da mesma evidencia_id (replay idempotente).
  • A leitura casa o object_key_temp da evidência com as entradas de descricoes_midia do snapshot e devolve a descricao da 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.

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 a concluida com data_conclusao do 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 publica demanda.concluída pelo 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 via anexo.processado, com a mesma via.
  • 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.

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' e moderacao_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_sensivel controla a exposição.

moderacao.decidida (produtor D-1d, versão 1.1.0):

  • Trata tipo='texto' e tipo='relato'. No texto, a aprovação libera o conteúdo suspenso: as entradas de timeline marcadas como internas com conteudo_suspeito=true voltam a público e o título do snapshot é atualizado.
  • No relato, a aprovação publica as entradas internas cujo dados_relevantes.atualizacao_id é o referencia_id e repõe texto_ultima_atualizacao com o texto_completo da última entrada, truncado em 200 caracteres. O bloqueio com reaberto=true, que é a reversão de uma aprovação, oculta as entradas do atualizacao_id e zera o texto_ultima_atualizacao. A entrada da decisão usa o demanda_id do payload.
  • O bloqueio não altera nada no texto. O título e as entradas permanecem internos. Quando o bloqueio chega com reaberto=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 removido limpa o conteúdo do texto: o snapshot perde titulo, descricao, texto_ultima_atualizacao e descricoes_midia; as entradas de demanda.normalizada e internas têm a descrição sobrescrita com o marcador Conteúdo removido por decisão de moderação humana. e o titulo e a descricao removidos dos dados_relevantes; a entrada pública da decisão entra na timeline. O resumo público passa a devolver conteudo_removido=true e conteudo_removido_em. A D-1d recusa removido para relato; 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.moderado ou anexo.removido.

anexo.removido (produtor D-1c, versão 1.0.0):

  • Busca a evidência pelo anexo_id, remove a entrada correspondente de descricoes_midia pelo object_key_temp antes 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_removido do resumo público.

Os três tipos entram em TIPOS_EVENTO_CONSUMIDOS e no rebuild.

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_validacao e total_confirmacoes do 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, confirmado ou disputado. O status disputado manté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.

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_agregado no snapshot da demanda representante e atualiza resumos_atualizado_em com o gerado_em do 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_ciclo no snapshot da demanda e atualiza resumos_atualizado_em com o gerado_em do 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.caminhos pela chave (município, subcategoria), com contagem de casos, prazo mediano, canais, órgãos, gargalos, documentos, dossiê, versao_metodo e gerado_em.
  • Propaga o dossiê para o caminho_dossie das demandas do par; quando o perfil vem sem dossiê, zera o campo das demandas do par.
  • Conversores converterCaminho, converterMapaContagens e converterGargalos toleram campo ausente ou inválido com fallback seguro.
  • O rebuild limpa d7.caminhos e 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.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.

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, removida e agregada são terminais: nenhuma transição a partir deles é aplicada.
  • O status só avança pela ordem de precedência de ORDEM_STATUS_DEMANDA (recebida 0, normalizada 1, categorizada 2, georreferenciada 3, ranqueada 4, agendada 5, atribuida 6, em_andamento 7, concluida 8, removida 9, agregada 10). 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.sorteado antes de demanda.recebida) emite log.warn.
  • A conclusão social respeita a mesma monotonicidade: demanda.conclusao_confirmada sem ratificação leva a concluida e 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.

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.

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.

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”

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.

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 UC

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.

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.

A D-7 não consome projeções de leitura de outras colônias. Os dados externos que acessa são:

  • core.event_log via EventBusService.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 compartilhado src/shared/hierarquia-uc/, que lê unidade_civica_id, nivel e parent_uc_id com 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.

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.

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(...) { ... }

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.
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.
Í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.
  • buscarTimelinePorDemanda(demanda_id, { limite, deslocamento, tipoEvento? }): 1 count e 1 findMany por consulta de timeline, filtrando visibilidade = 'publico' e ordenando por timestamp 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 em demanda_snapshots. O(1).
  • DashboardBuilder.construir(ucIds, filtros): 6 consultas agregadas sobre demanda_snapshots, com WHERE 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).

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.

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.


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).

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: recebidanormalizadacategorizadageorreferenciadaranqueadasorteadainiciadaatualizacao_publicada (2x) → concluídaciclo_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.

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.


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
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).
  • Visão de conselheiro: tabela d7.conselheiro_views, endpoint GET /api/d7/conselheiro/:id
  • Processamento dos eventos votação.resultado_publicado, demanda.removida_por_votação e hash.checkpoint_publicado
  • Índice GIN em dados_relevantes para busca textual na timeline
  • Pré-agregação incremental do dashboard com cache Redis (TTL 60s)
  • Visibilidade interno por 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_entries por 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.


  • 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.