D-5 — Agenda
Parte do Ciclo de Demandas — Fase 1
Propósito
Seção intitulada “Propósito”A D-5 transforma o ranking produzido pela D-4 em backlog de trabalho concreto para as unidades cívicas. Organiza as demandas em fila ordenada, aplica a distribuição por decaimento geométrico e agrupa demandas por proximidade geográfica para otimizar a atuação dos conselheiros.
O agrupamento é sugestivo, não mandatório: demandas próximas geograficamente, do mesmo nível e categoria, podem ser agrupadas para que um conselheiro as trate em sequência. O conselheiro decide se atua como demanda única ou agrupada. O backlog é público, acessível por API via D-7 (Transparência), paginado e filtrável por nível, categoria, status e território. A listagem pública existe em GET /api/d7/demandas, com filtros por bbox, status, categoria, UC e nível de precedência. O filtro por território é parcial (bbox e UC resolvida). A listagem geo exclui demandas agregada por padrão e devolve total_confirmacoes, e o resumo da unidade cívica na D-7 expõe membros e evidências do agregado. A listagem completa do backlog por UC com agregações segue pendente.
Não executa as demandas. Não aloca conselheiros. Não re-rankeia. Apenas consome o ranking, mantém o estado da fila de trabalho e publica o backlog ordenado para o restante do pipeline.
Referência
Seção intitulada “Referência”- Ficha técnica da colônia: Apêndice B - Colônias.md, seção “D-5 — Agenda”
- Fórmula de priorização e distribuição por decaimento: contexto_IA.md, seção 7 (A Gestão)
- A D-5 é o décimo elo na ordem de implementação do MVP (Bloco 2, após D-4).
1. Estrutura do Módulo NestJS
Seção intitulada “1. Estrutura do Módulo NestJS”A D-5 é um módulo NestJS com encapsulamento próprio dentro do monolito modular do MVP. Consome eventos do barramento via EventBusService (N-0a) e publica eventos ao final do processamento. Não expõe controllers REST. É uma colônia pura de eventos.
1.1 Árvore de diretórios
Seção intitulada “1.1 Árvore de diretórios”src/demanda/d-5-agenda/├── agenda/│ ├── agenda-builder.ts # Reconstrói backlog completo: decaimento geométrico + geo clustering│ ├── agenda-builder.spec.ts│ ├── distribuicao-capacidade.ts # Determina nível não vencido, aplica decaimento geométrico│ ├── distribuicao-capacidade.spec.ts│ ├── geo-clustering.ts # DBSCAN com Haversine para agrupamento territorial│ └── geo-clustering.spec.ts├── d5.constants.ts # Config estática: pesos situacionais, capacidade por UC, raio e minPts do clustering├── d5.module.ts # Module definition├── d5.repository.ts # Acesso a todas as tabelas do schema d5├── d5-schema.spec.ts # Testes de schema e migrations da agregação e da conclusão coletiva├── d5.service.spec.ts # Testes unitários do service└── d5.service.ts # Lógica de negócio: consumir eventos, atualizar backlog, publicar1.2 Module definition
Seção intitulada “1.2 Module definition”@Module({ imports: [], controllers: [], providers: [D5Service, D5Repository, AgendaBuilder, DistribuicaoCapacidade, GeoClustering], exports: [],})export class D5Module implements OnModuleInit { constructor(private readonly d5Service: D5Service) {}
async onModuleInit(): Promise<void> { await this.d5Service.iniciar(); }}1.3 Pontos de atenção
Seção intitulada “1.3 Pontos de atenção”- O módulo não é
@Global(). A D-5 não é dependência de nenhuma outra colônia. Outras colônias consomem seus eventos (agenda.gerada,agenda.item_disponível), não seu código. - O módulo não importa
EventBusModuleexplicitamente.EventBusModuleé@Global(), e oEventBusServiceé injetável sem import. - O módulo não importa
RegistryModule. A validação de schema dos eventos publicados é feita pelo próprio Event Bus (N-0a) no momento da publicação. - O
OnModuleInitdispara o protocolo de inicialização: seed do cursor, replay de eventos perdidos e registro de handlers. - O módulo não registra
ThrottlerModule. Rate limiting é responsabilidade exclusiva do BFF (D-1a), na borda HTTP. - Os pesos situacionais e os parâmetros da distribuição são carregados de
d5.constants.ts, arquivo de configuração estática no MVP. Duplica a fonte da D-4 (formula-params.config.ts) para os pesos situacionais. Na Fase 2, ambas migram para o consumo do eventoparâmetros.atualizadosda D-19. - A D-5 não acessa o estado interno da D-4 (ranking em memória). Mantém projeção própria do ranking em PostgreSQL (
d5.backlog_scores), populada a partir dos eventosdemanda.ranqueada. O isolamento é total: a D-5 reconstrói o backlog exclusivamente a partir da sua projeção, sem ler dados de nenhuma outra colônia. - O módulo registra consumidores para oito tipos de evento em produção (
demanda.ranqueada,ranking.atualizado,conselheiro.sorteado,conselheiro.demanda_iniciada,demanda.concluída,demanda.conclusao_confirmada,duplicidade.agregadaedemanda.georreferenciada) e o stub deparâmetros.atualizados, que loga e avança o cursor. O stub dedemanda.removida_por_votaçãofica registrado sem cursor e fora do replay, para a Fase 2. - Os handlers de entrada passam por uma fila única (
filaProcessamento), que serializa o processamento na ordem de chegada e evita concorrência entre eventos da mesma UC. - O anúncio do topo do backlog é guardado por UC (
anunciosPorUc): uma demanda já anunciada não gera novoagenda.item_disponívelaté que o topo mude.
1.4 Serviço — responsabilidades e contrato
Seção intitulada “1.4 Serviço — responsabilidades e contrato”// Métodos públicos do D5Serviceiniciar(): Promise<void>despacharEvento(tipo: string, evento: EventoRecebido): Promise<void>registrarConsumidores(): voidonDemandaRanqueada(evento): Promise<void>onRankingAtualizado(evento): Promise<void>onConselheiroSorteado(evento): Promise<void>onConselheiroDemandaIniciada(evento): Promise<void>onDemandaConcluida(evento): Promise<void>onConclusaoConfirmada(evento): Promise<void>onDuplicidadeAgregada(evento): Promise<void>onDemandaGeorreferenciada(evento): Promise<void>onDemandaRemovidaPorVotacao(evento): Promise<void> // stub da Fase 2onParametrosAtualizados(evento): Promise<void> // stub da Fase 2
// D5Service consome oito tipos de evento em produção + o stub de parâmetros.atualizados// com cursor + o stub de demanda.removida_por_votação registrado sem cursor,// publica 'agenda.gerada' e 'agenda.item_disponível'O serviço é interno ao módulo. Nenhuma outra colônia injeta D5Service. A comunicação com o exterior é via barramento.
1.5 Colônia pura de eventos
Seção intitulada “1.5 Colônia pura de eventos”A D-5 não tem BFF acoplado. É uma colônia de processamento puro: escuta, mantém projeção do ranking, aplica decaimento geométrico, agrupa, publica. O input é sempre via barramento. A visibilidade pública do backlog é fornecida pela D-7 (Transparência), que projeta os eventos agenda.gerada e agenda.item_disponível em endpoints REST.
1.6 Projeção própria como fonte da verdade do ranking para a D-5
Seção intitulada “1.6 Projeção própria como fonte da verdade do ranking para a D-5”A D-5 mantém sua própria projeção do ranking (d5.backlog_scores), populada exclusivamente pelo evento demanda.ranqueada. Quando ranking.atualizado chega, a D-5 reconstrói o backlog a partir dessa projeção, nunca do estado interno da D-4 (ranking em memória) nem do schema d4.
A escolha é deliberada e preserva o isolamento entre colônias. Cada demanda.ranqueada contém demanda_id, unidade_civica_id, categoria_id, nivel_precedencia, score_final, breakdown completo, posicao_no_ranking e versao_parametros. Esses campos são suficientes para a D-5 manter uma projeção fiel do estado do ranking sem depender de leitura externa.
O ranking da D-4 é estado interno em memória daquela colônia, e a D-5 não o lê. Se no futuro a D-4 migrar para outro mecanismo de ranking, a D-5 não é afetada. O contrato são os eventos, não a estrutura interna de cache.
2. Banco de Dados — Schema e Entidades
Seção intitulada “2. Banco de Dados — Schema e Entidades”2.1 Schema d5
Seção intitulada “2.1 Schema d5”Todas as tabelas da D-5 residem no schema d5 do PostgreSQL. Este schema é de uso exclusivo do módulo D-5. Nenhuma outra colônia lê ou escreve nestas tabelas.
2.2 Tabela d5.backlog_scores
Seção intitulada “2.2 Tabela d5.backlog_scores”Projeção própria do ranking, mantida pela D-5 a partir dos eventos demanda.ranqueada. Uma linha por demanda. O inserirScore() cria a linha no primeiro evento da demanda; o atualizarScore() reescreve a mesma linha quando um evento novo chega para a mesma demanda, inclusive o event_id e o sequence_number. A coluna event_id tem índice único e detecta a reentrega do mesmo evento; a coluna sequence_number guarda a sequência do último evento aplicado e descarta evento fora de ordem, sem sobrescrever o score.
CREATE SCHEMA IF NOT EXISTS d5;
CREATE TABLE d5.backlog_scores ( id UUID NOT NULL, demanda_id UUID NOT NULL, unidade_civica_id UUID NOT NULL, categoria_id VARCHAR(10) NOT NULL, nivel_precedencia INTEGER NOT NULL, score_final NUMERIC(12,2) NOT NULL, peso_nacional NUMERIC(5,2) NOT NULL, peso_situacional NUMERIC(5,4) NOT NULL, score_horizontal INTEGER NOT NULL, posicao_ranking INTEGER NOT NULL, versao_parametros VARCHAR(20) NOT NULL, event_id UUID NOT NULL, correlacao_id UUID NOT NULL, sequence_number BIGINT NOT NULL DEFAULT 0, criado_em TIMESTAMPTZ(2) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT backlog_scores_pkey PRIMARY KEY (id));
CREATE UNIQUE INDEX backlog_scores_event_id_key ON d5.backlog_scores (event_id);CREATE INDEX backlog_scores_demanda_id_idx ON d5.backlog_scores (demanda_id);CREATE INDEX backlog_scores_unidade_civica_id_idx ON d5.backlog_scores (unidade_civica_id);CREATE INDEX backlog_scores_unidade_civica_id_score_final_idx ON d5.backlog_scores (unidade_civica_id, score_final DESC);Colunas — detalhamento
Seção intitulada “Colunas — detalhamento”| Coluna | Tipo | Descrição |
|---|---|---|
id |
UUID PK | Identificador interno do registro. Gerado pela D-5. |
demanda_id |
UUID | Referência lógica ao registro de demanda na D-1a, sem constraint formal. Vale a regra de isolamento. |
unidade_civica_id |
UUID | UC de menor nível resolvida. Extraído do payload de demanda.ranqueada. |
categoria_id |
VARCHAR(10) | Identificador da categoria. Formato N.M. |
nivel_precedencia |
INTEGER | Nível de precedência da categoria (1 a 5). Usado pela distribuição de capacidade. |
score_final |
NUMERIC(12,2) | Score calculado pela D-4. É a base da ordenação do backlog. |
peso_nacional |
NUMERIC(5,2) | Peso estrutural por nível de precedência vigente no cálculo. |
peso_situacional |
NUMERIC(5,4) | Peso dinâmico da UC para o nível no momento do cálculo. |
score_horizontal |
INTEGER | Score da categoria. |
posicao_ranking |
INTEGER | Posição que a demanda ocupava no ranking da UC quando foi ranqueada. |
versao_parametros |
VARCHAR(20) | Versão dos parâmetros usados no cálculo. MVP: "mvp-v1". |
event_id |
UUID | event_id do último evento demanda.ranqueada aplicado à linha. Único. |
correlacao_id |
UUID | correlacao_id do evento de origem. Para trace distribuído. |
sequence_number |
BIGINT | Sequência do último demanda.ranqueada aplicado à linha. Evento com sequência menor ou igual é descartado. |
criado_em |
TIMESTAMPTZ(2) | Timestamp de criação do registro. |
O banco não tem CHECKs de faixa. Os limites de nivel_precedencia, score_final e score_horizontal são garantidos pelo código e pelo Registry.
2.3 Tabela d5.backlog_items
Seção intitulada “2.3 Tabela d5.backlog_items”O backlog propriamente dito. Uma linha por demanda do backlog ativo da UC. A posição é a ordem final após a distribuição por decaimento e o agrupamento geográfico. O rebuild remove as linhas fora da seleção com deleteMany; linhas com status agregado são preservadas.
CREATE TABLE d5.backlog_items ( demanda_id UUID NOT NULL, unidade_civica_id UUID NOT NULL, posicao INTEGER NOT NULL, score NUMERIC(12,2) NOT NULL, status VARCHAR(20) NOT NULL DEFAULT 'disponivel', grupo_id UUID, categoria_id VARCHAR(10) NOT NULL, nivel_precedencia INTEGER NOT NULL, event_id UUID NOT NULL, entrou_em TIMESTAMPTZ(2) NOT NULL DEFAULT CURRENT_TIMESTAMP, atualizado_em TIMESTAMPTZ(2) NOT NULL DEFAULT CURRENT_TIMESTAMP, data_conclusao TIMESTAMPTZ(2), dias_ate_conclusao INTEGER,
CONSTRAINT backlog_items_pkey PRIMARY KEY (demanda_id));
CREATE INDEX backlog_items_unidade_civica_id_status_idx ON d5.backlog_items (unidade_civica_id, status);CREATE INDEX backlog_items_unidade_civica_id_posicao_idx ON d5.backlog_items (unidade_civica_id, posicao);CREATE INDEX backlog_items_grupo_id_idx ON d5.backlog_items (grupo_id);Colunas — detalhamento
Seção intitulada “Colunas — detalhamento”| Coluna | Tipo | Descrição |
|---|---|---|
demanda_id |
UUID PK | Uma linha por demanda no backlog. |
unidade_civica_id |
UUID | UC a que o backlog pertence. |
posicao |
INTEGER | Posição no backlog da UC. 1 = próximo a ser atribuído. |
score |
NUMERIC(12,2) | Score da demanda. Referência para ordenação. |
status |
VARCHAR(20) | Estado atual no backlog. Ver transições em 4.5. |
grupo_id |
UUID | Identificador do grupo territorial gerado pelo DBSCAN. NULL se a demanda é solo. Sem FK: os grupos são efêmeros. |
categoria_id |
VARCHAR(10) | Categoria da demanda. |
nivel_precedencia |
INTEGER | Nível de precedência (1 a 5). |
event_id |
UUID | event_id do evento que alterou o status por último (demanda.ranqueada, conselheiro.sorteado, duplicidade.agregada, etc.). Para auditoria. |
entrou_em |
TIMESTAMPTZ(2) | Quando a demanda entrou no backlog. |
atualizado_em |
TIMESTAMPTZ(2) | Última alteração de status. |
data_conclusao |
TIMESTAMPTZ(2) | Data da conclusão, quando houver. Preenchida por demanda.concluída ou pela conclusão coletiva. |
dias_ate_conclusao |
INTEGER | Dias entre a entrada no backlog e a conclusão. |
O banco não tem CHECK de status nem de faixa. Os valores de status, posicao e nivel_precedencia são garantidos pelo código.
Status e significado
Seção intitulada “Status e significado”| Status | Significado | Quem publica o evento que causa a transição |
|---|---|---|
disponivel |
Demanda está no backlog e pode ser atribuída a um conselheiro. | D-5 (agenda.gerada / agenda.item_disponível) |
atribuido |
Conselheiro foi sorteado, mas ainda não iniciou formalmente. Estado interno: não gera novo agenda.item_disponível. |
D-6a (conselheiro.sorteado) |
em_progresso |
Conselheiro fez o primeiro contato formal. | D-6b (conselheiro.demanda_iniciada) |
concluido |
Demanda encerrada. | D-6b (demanda.concluída) ou D-12 (demanda.conclusao_confirmada sem ratificação) |
agregado |
Demanda absorvida por um agregado da D-12. Sai da ordenação e nunca gera agenda.item_disponível. |
D-12 (duplicidade.agregada) |
removido |
Retirada do backlog por votação. Reservado à Fase 2; o código do MVP não transita para esse status. | D-10 (demanda.removida_por_votação, Fase 2) |
2.4 Tabela d5.coordenadas_demanda
Seção intitulada “2.4 Tabela d5.coordenadas_demanda”Projeção local de coordenadas alimentada por demanda.georreferenciada. Serve ao DBSCAN do rebuild. Uma linha por demanda com coordenadas resolvidas.
CREATE TABLE d5.coordenadas_demanda ( demanda_id UUID NOT NULL, lat DOUBLE PRECISION NOT NULL, lng DOUBLE PRECISION NOT NULL, criado_em TIMESTAMPTZ(2) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT coordenadas_demanda_pkey PRIMARY KEY (demanda_id));2.5 Tabela d5.consumer_offset
Seção intitulada “2.5 Tabela d5.consumer_offset”Controla o cursor de processamento para replay seletivo após falha ou reinicialização. Segue o padrão definido pela N-0a (Event Bus).
CREATE TABLE d5.consumer_offset ( tipo_evento VARCHAR(255) PRIMARY KEY, last_sequence BIGINT NOT NULL DEFAULT 0, updated_at TIMESTAMPTZ(2) NOT NULL DEFAULT CURRENT_TIMESTAMP);No MVP, a tabela tem uma linha por tipo consumido (demanda.ranqueada, ranking.atualizado, conselheiro.sorteado, conselheiro.demanda_iniciada, demanda.concluída, demanda.conclusao_confirmada, demanda.georreferenciada, parâmetros.atualizados e duplicidade.agregada), seedadas com obterMaiorSequence() no boot. O stub de demanda.removida_por_votação não entra no cursor nem no replay; fica apenas registrado para a Fase 2. O cursor de cada tipo avança em todo caminho terminal do handler.
2.6 Tabela d5.demandas_agregadas
Seção intitulada “2.6 Tabela d5.demandas_agregadas”Registra os membros de cada agregado de duplicidade publicado pela D-12. Uma linha por demanda_id membro. Serve à guarda de evento tardio: uma demanda já agregada não volta ao backlog quando um demanda.ranqueada atrasado chega.
CREATE TABLE d5.demandas_agregadas ( demanda_id UUID NOT NULL, agregado_id UUID NOT NULL, representante_demanda_id UUID NOT NULL, evento_id UUID NOT NULL, agregado_em TIMESTAMPTZ(2) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT demandas_agregadas_pkey PRIMARY KEY (demanda_id));
CREATE INDEX demandas_agregadas_evento_id_idx ON d5.demandas_agregadas (evento_id);CREATE INDEX demandas_agregadas_representante_demanda_id_idx ON d5.demandas_agregadas (representante_demanda_id);2.7 Migrations esperadas
Seção intitulada “2.7 Migrations esperadas”Três migrations:
20260810142037_d5_create_schema— Cria o schemad5e as tabelasd5.backlog_scores,d5.backlog_items,d5.consumer_offseted5.coordenadas_demanda, com os índices de cada uma.20260820153000_d5_agregacao— Criad5.demandas_agregadascom PK emdemanda_ide os índicesdemandas_agregadas_evento_id_idxedemandas_agregadas_representante_demanda_id_idx.20260902192727_d5_conclusao_coletiva— Adiciona as colunasdata_conclusaoedias_ate_conclusaoad5.backlog_items.20260918150000_d5_backlog_scores_sequence— Adicionasequence_numberad5.backlog_scores, com default 0, para descartar reentrega fora de ordem.
Migrations futuras (Fase 2): coluna de capacidade por UC em tabela de configuração e índices compostos para queries de rebuild.
2.8 Relações internas
Seção intitulada “2.8 Relações internas”Não há foreign keys no schema d5. backlog_items.demanda_id e backlog_scores.demanda_id referenciam demandas de outras colônias sem FK formal, pela regra de isolamento. grupo_id referencia um grupo efêmero em memória, sem tabela.
2.9 Decisões de schema
Seção intitulada “2.9 Decisões de schema”Tabela d5.backlog_scores separada de d5.backlog_items.
O score é produzido pela D-4 e a D-5 o projeta. O backlog é estado volátil derivado pela D-5. Separar as tabelas permite que a projeção de scores seja atualizada a cada evento sem tocar no estado do backlog, e que o rebuild leia de backlog_scores e reescreva backlog_items com preocupações de consistência diferentes.
PK de backlog_items é demanda_id, não (unidade_civica_id, posicao).
Uma demanda pertence a exatamente uma UC e ocupa exatamente uma posição no backlog daquela UC. demanda_id como PK é suficiente e simplifica updates de status (que referenciam a demanda, não a posição). A posição é recalculada a cada rebuild e a cada inserção incremental. Não é identidade, é atributo volátil.
Sem tabela de grupos.
Os agrupamentos do DBSCAN são efêmeros: vivem no resultado em memória do GeoClustering e apenas o grupo_id gerado é persistido em backlog_items. O rebuild regenera os grupos a cada execução, e a inserção incremental não agrupa. Uma tabela de grupos exigiria sincronizar composição e centroide sem benefício para o volume do MVP.
Status atribuido como estado interno.
Cinco status descrevem o backlog: disponivel, atribuido, em_progresso, concluido e agregado. O atribuido cobre o intervalo entre conselheiro.sorteado (D-6a) e conselheiro.demanda_iniciada (D-6b). Enquanto atribuido, a demanda não é anunciada como disponivel para novo sorteio. Para consumo externo (D-7), atribuido é projetado como disponivel: o cidadão vê que a demanda ainda não foi iniciada, e o sistema não a oferece para outro conselheiro. A distinção é operacional, não de transparência.
posicao_ranking em backlog_scores e posicao em backlog_items, campos distintos.
posicao_ranking é a posição no ranking da D-4 no momento do cálculo (fato histórico). posicao é a posição no backlog da D-5 após aplicar decaimento e clustering (estado corrente). Uma demanda pode ser #15 no ranking mas #3 no backlog porque demandas acima dela são de níveis já vencidos que a distribuição por decaimento reduz.
Config estática duplicada da D-4 para peso_situacional.
A D-5 precisa saber qual nível de precedência está “não vencido” para aplicar a distribuição por decaimento. Essa informação depende do peso_situacional por UC e nível, o mesmo dado que a D-4 usa para calcular o score. No MVP, ambas as colônias carregam os mesmos valores de arquivos de configuração estática. É duplicação aceitável: o custo de inconsistência é baixo (os valores são calibrados manualmente e mudam raramente) e o custo de criar um fluxo de eventos só para isso no MVP seria desproporcional. Na Fase 2, ambas migram para consumir parâmetros.atualizados da D-19.
3. Eventos — Contratos Detalhados
Seção intitulada “3. Eventos — Contratos Detalhados”A D-5 consome oito tipos de evento como gatilho de processamento (demanda.ranqueada, ranking.atualizado, conselheiro.sorteado, conselheiro.demanda_iniciada, demanda.concluída, demanda.conclusao_confirmada, duplicidade.agregada e demanda.georreferenciada), mantém o stub de parâmetros.atualizados no cursor e o stub de demanda.removida_por_votação registrado para a Fase 2, sem cursor, e produz dois eventos. Os schemas completos (JSON Schema draft-2020-12) estão definidos no Registry (N-0b). Esta seção descreve os contratos do ponto de vista da D-5.
3.1 Evento consumido: demanda.ranqueada (gatilho primário)
Seção intitulada “3.1 Evento consumido: demanda.ranqueada (gatilho primário)”| Propriedade | Valor |
|---|---|
| Tipo | demanda.ranqueada |
| Schema version | 1.1.0 |
| Produtor | D-4 (Priorização e Ranking) |
| Consumidor | D-5 (esta colônia) |
| Descrição | Demanda com score final calculado, breakdown e posição no ranking da UC. Publicado individualmente para cada demanda que entra no ranking. |
Payload esperado (conforme Registry N-0b, v1.1.0):
interface DemandaRanqueadaPayload { demanda_id: string; unidade_civica_id: string; categoria_id: string; nivel_precedencia: number; // 1-5 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; // ISO-8601}A D-5 exige categoria_id e nivel_precedencia para a projeção. Um evento da versão 1.0.0, sem os dois campos, é descartado com log.warn e cursor avançado.
3.2 Evento consumido: ranking.atualizado (gatilho de rebuild)
Seção intitulada “3.2 Evento consumido: ranking.atualizado (gatilho de rebuild)”| Propriedade | Valor |
|---|---|
| Tipo | ranking.atualizado |
| Schema version | 1.0.0 |
| Produtor | D-4 (Priorização e Ranking) |
| Consumidor | D-5 (esta colônia) |
| Descrição | Publicado quando o ranking de uma UC sofre mudança significativa. Sinaliza que vale a pena reconstruir o backlog completo. |
Payload esperado:
interface RankingAtualizadoPayload { unidade_civica_id: string; motivo: string; // '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; // ISO-8601}A D-5 usa o evento apenas como gatilho de rebuild: o payload não alimenta a projeção. A reconstrução lê d5.backlog_scores, que já tem os scores atualizados pelos demanda.ranqueada.
3.3 Evento consumido: conselheiro.sorteado
Seção intitulada “3.3 Evento consumido: conselheiro.sorteado”| Propriedade | Valor |
|---|---|
| Tipo | conselheiro.sorteado |
| Schema version | 1.0.0 |
| Produtor | D-6a (Sorteio e Atribuição) |
| Consumidores | D-5 (esta colônia), D-6b (Relatoria), D-7 (Transparência), D-12 (Detecção de Duplicidade) |
| Descrição | Conselheiro foi sorteado e atribuído a uma demanda. A D-5 usa este evento para marcar a demanda como atribuido e anunciar o próximo disponível da UC. |
Payload esperado:
interface ConselheiroSorteadoPayload { conselheiro_id: string; demanda_id: string; unidade_civica_id: string; metodo_sorteio: 'fisher_yates'; seed_publico: string; // Para auditoria do sorteio lista_elegiveis_hash?: string; posicao_sorteada?: number; total_elegiveis?: number;}A D-5 usa demanda_id, unidade_civica_id e conselheiro_id. Demanda fora do backlog é inserida com posicao = 0 e status atribuido, sem rebuild. Demanda com status concluido ou agregado é ignorada.
3.4 Evento consumido: conselheiro.demanda_iniciada
Seção intitulada “3.4 Evento consumido: conselheiro.demanda_iniciada”| Propriedade | Valor |
|---|---|
| Tipo | conselheiro.demanda_iniciada |
| Schema version | 1.0.0 |
| Produtor | D-6b (Relatoria e Acompanhamento) |
| Consumidores | D-5 (esta colônia), D-7 (Transparência) |
| Descrição | Conselheiro fez o primeiro contato formal. É este evento que muda a demanda de atribuido para em_progresso. |
Payload esperado:
interface ConselheiroDemandaIniciadaPayload { demanda_id: string; conselheiro_id: string; unidade_civica_id: string; timestamp_inicio: string; // ISO-8601}3.5 Evento consumido: demanda.concluída
Seção intitulada “3.5 Evento consumido: demanda.concluída”| Propriedade | Valor |
|---|---|
| Tipo | demanda.concluída |
| Schema version | 1.0.0 |
| Produtor | D-6b (Relatoria e Acompanhamento) |
| Consumidores | D-5 (esta colônia), D-7 (Transparência) |
| Descrição | Demanda encerrada. A D-5 atualiza o status para concluido e anuncia o próximo item disponível da UC. |
Payload esperado:
interface DemandaConcluidaPayload { demanda_id: string; unidade_civica_id: string; data_conclusao: string; // ISO-8601 conselheiro_id: string; categoria_id?: string; dias_ate_conclusao?: number;}A D-5 usa demanda_id, unidade_civica_id e dias_ate_conclusao no log. A linha do backlog recebe o status concluido; se o item não estiver na seleção do próximo rebuild, a linha é removida.
3.6 Evento consumido: demanda.conclusao_confirmada
Seção intitulada “3.6 Evento consumido: demanda.conclusao_confirmada”| Propriedade | Valor |
|---|---|
| Tipo | demanda.conclusao_confirmada |
| Schema version | 1.0.0 |
| Produtor | D-12 (Detecção de Duplicidade) |
| Consumidor | D-5 (esta colônia) |
| Descrição | Três conclusões de cidadãos distintos confirmaram a demanda como resolvida. A D-5 conclui o item fora de em_progresso; com conselheiro sorteado, a ratificação fica com a D-6b. |
Payload esperado:
interface DemandaConclusaoConfirmadaPayload { demanda_id: string; unidade_civica_id: string; conclusao_id: string; total_conclusoes: number; // >= 3 data_conclusao: string; // ISO-8601 mecanismo: 'confirmacao_coletiva'; ratificacao_necessaria: boolean;}Comportamento por estado do item:
| Estado do item | Comportamento |
|---|---|
ratificacao_necessaria = true |
Nenhuma mudança no backlog. A conclusão aguarda a ratificação na D-6b, que publica demanda.concluída pelo fluxo normal. Cursor avançado. |
| Item inexistente | Log.warn. Cursor avançado. |
em_progresso |
Ignorado com log: aguarda ratificação na D-6b. Cursor avançado. |
agregado |
Ignorado com log (estado terminal). Cursor avançado. |
concluido |
Idempotente. Cursor avançado. |
disponivel ou atribuido |
Status concluido, com data_conclusao e dias_ate_conclusao calculados a partir de entrou_em, e anúncio do próximo disponível da UC. |
3.7 Evento consumido: duplicidade.agregada
Seção intitulada “3.7 Evento consumido: duplicidade.agregada”| Propriedade | Valor |
|---|---|
| Tipo | duplicidade.agregada |
| Schema version | 1.0.0 |
| Produtor | D-12 (Detecção de Duplicidade) |
| Consumidor | D-5 (esta colônia) |
| Descrição | Agregado de demandas consolidadas por confirmação coletiva: um representante e uma lista de membros. |
Payload esperado:
interface DuplicidadeAgregadaPayload { agregado_id: string; representante_demanda_id: string; membros: Array<{ demanda_id: string }>; mecanismo: 'confirmacao_coletiva'; confirmacoes_gatilho: number; criterios: Record<string, unknown>;}Ordem de operações no handler (onDuplicidadeAgregada):
1. Validar payload (agregado_id, representante_demanda_id e membros não vazio)2. Para cada membro diferente do representante: → registrar em d5.demandas_agregadas → buscar a linha em d5.backlog_items → se a linha existir e o status não for 'agregado': atualizar status = 'agregado' → sem linha, buscar o score para descobrir a UC afetada3. Regenerar o backlog de cada UC afetada e publicar agenda.gerada4. Avançar cursor em todo caminho terminalGarantias: itens agregado saem da ordenação e nunca geram agenda.item_disponível. demanda.ranqueada tardia de um membro não reativa o item: a guarda consulta d5.demandas_agregadas. O representante preserva sua posição.
3.8 Evento produzido: agenda.gerada
Seção intitulada “3.8 Evento produzido: agenda.gerada”| Propriedade | Valor |
|---|---|
| Tipo | agenda.gerada |
| Schema version | 1.0.0 |
| Produtor | D-5 (esta colônia) |
| Consumidores | D-7 (Transparência): dashboard e timeline |
| Descrição | Publicado quando o backlog de uma unidade cívica é reconstruído. Contém o resultado da aplicação da distribuição por decaimento, os 10 primeiros itens e metadados de agrupamento. |
Payload publicado:
interface AgendaGeradaPayload { unidade_civica_id: string; total_itens: number; distribuicao_decay: { nivel_nao_vencido: number | null; // Nível de precedência com maior peso situacional acima do limiar itens_nivel_prioritario: number; // Quantos itens vieram do nível não vencido (alocados por decaimento geométrico) itens_outros_niveis: number; // Quantos itens vieram dos demais níveis (alocados por decaimento geométrico) }; top_10: Array<{ posicao: number; demanda_id: string; score_final: number; categoria_id: string; grupo_id: string | null; }>; grupos_territoriais: number; // Quantos grupos DBSCAN gerou (0 se nenhum) versao_parametros: string; timestamp: string; // ISO-8601}O evento é publicado nos rebuilds: ranking.atualizado e duplicidade.agregada. A publicação usa publicarComRetry (4 tentativas, backoff de 500 ms a 2000 ms).
3.9 Evento produzido: agenda.item_disponível
Seção intitulada “3.9 Evento produzido: agenda.item_disponível”| Propriedade | Valor |
|---|---|
| Tipo | agenda.item_disponível |
| Schema version | 1.0.0 |
| Produtor | D-5 (esta colônia) |
| Consumidores | D-6a (Sorteio e Atribuição), D-14 (Agregação Multinível, Fase 2) |
| Descrição | Publicado quando uma demanda entra no topo da fila de disponíveis. É o sinal para a D-6a iniciar o sorteio de um conselheiro. |
Payload publicado:
interface AgendaItemDisponivelPayload { demanda_id: string; unidade_civica_id: string; posicao_no_backlog: number; // Posição no backlog da UC (após decaimento e clustering) score_final: number; categoria_id: string; nivel_precedencia: number; grupo_id: string | null; // Nulo se demanda solo timestamp: string; // ISO-8601}O evento é publicado em três situações: na inserção incremental que ocupa a primeira posição da UC, no rebuild quando o topo disponível muda em relação ao último anúncio e no anúncio do próximo disponível após conselheiro.sorteado, demanda.concluída ou conclusão coletiva. A publicação usa publicarComRetry (4 tentativas, backoff de 500 ms a 2000 ms).
3.10 Ordem de operações no handler onDemandaRanqueada
Seção intitulada “3.10 Ordem de operações no handler onDemandaRanqueada”1. Verificar idempotência por event_id → buscarScorePorEventId() → se encontrado: log, avançar o cursor e retornar
2. Validar payload mínimo → demanda_id e unidade_civica_id obrigatórios → se ausente: log.error, avançar o cursor e retornar
3. Validar os campos da versão 1.1.0 → categoria_id e nivel_precedencia obrigatórios → se ausente: log.warn, avançar o cursor e retornar
4. Guarda de evento tardio → buscarAgregacaoPorDemandaId() em d5.demandas_agregadas → se a demanda já é membro de agregado: log, avançar o cursor e retornar
5. Persistir a projeção do score → se já existe linha para a demanda: se o sequence_number do evento é menor ou igual ao registrado: log, avançar o cursor e retornar atualizarScore() com os campos do evento → senão: inserirScore()
6. Atualizar o backlog → se a demanda já tem linha em d5.backlog_items: upsert preservando posicao, status e grupo_id → senão: capacidade = config da UC ou capacidadePadrao itens = contarItensPorUc() (exclui concluídos e agregados) se itens < capacidade: inserir item com posicao = itens + 1, status 'disponivel' e grupo_id nulo se itens == 0: publicar agenda.item_disponível e registrar em anunciosPorUc senão: log de demanda fora da capacidade
7. Avançar o cursor → upsert do offset de demanda.ranqueadaO caminho incremental não aplica decaimento nem agrupamento: a capacidade funciona como teto de contagem e o grupo_id da linha nova fica nulo. A distribuição por decaimento e o DBSCAN rodam no rebuild.
3.11 Ordem de operações no handler onRankingAtualizado
Seção intitulada “3.11 Ordem de operações no handler onRankingAtualizado”1. Validar payload mínimo → unidade_civica_id obrigatório → se ausente: log.error, avançar o cursor e retornar
2. Reconstruir o backlog completo da UC → regenerarBacklog(unidade_civica_id, event_id): a. ler d5.backlog_scores da UC por score decrescente, excluindo agregados b. aplicar DistribuicaoCapacidade c. aplicar GeoClustering sobre as demandas com coordenadas d. ordenar prioritárias e demais por score decrescente, desempate por demanda_id e. upsert em d5.backlog_items preservando o status anterior f. remover as linhas fora da seleção, exceto agregado → publicar agenda.gerada → se o topo disponível mudou em relação a anunciosPorUc: publicar agenda.item_disponível
3. Avançar o cursor → upsert do offset de ranking.atualizadoO handler não tem guarda de idempotência por event_id: o rebuild é idempotente por natureza (upserts por demanda_id), a publicação de agenda.gerada sai com event_id novo a cada execução e o anúncio do topo é guardado por anunciosPorUc.
3.12 Ordem de operações nos handlers de status
Seção intitulada “3.12 Ordem de operações nos handlers de status”Os handlers onConselheiroSorteado, onConselheiroDemandaIniciada, onDemandaConcluida e onConclusaoConfirmada seguem o mesmo padrão, com idempotência derivada do estado do item:
1. Validar payload mínimo → demanda_id obrigatório para todos → se ausente: log.error, avançar o cursor e retornar
2. Buscar a linha em d5.backlog_items → se não encontrada: log.warn e tratamento específico do handler → se encontrada: verificar o estado atual (ver 4.5)
3. Aplicar a transição, quando válida → atualizarStatus() ou concluirItem() → anunciar o próximo disponível quando a vaga é liberada
4. Avançar o cursor → upsert do offset do tipo do eventoRegras por handler:
conselheiro.sorteado: linha ausente é inserida composicao = 0,score = 0e statusatribuido; statusconcluidoouagregadoé ignorado; nos demais, o status viraatribuidoe o próximo disponível é anunciado.conselheiro.demanda_iniciada: o status viraem_progresso; se já estiverem_progresso, log idempotente. Não há anúncio.demanda.concluída: o status viraconcluidoe o próximo disponível é anunciado; se já estiverconcluido, log idempotente.demanda.conclusao_confirmada:ratificacao_necessaria = truenão altera o backlog; nos demais casos vale a tabela da seção 3.6.
Não existe tabela d5.processed_events: a idempotência dos eventos de status é derivada do estado do item.
3.13 Tratamento de erro e idempotência
Seção intitulada “3.13 Tratamento de erro e idempotência”| Cenário | Comportamento |
|---|---|
Evento demanda.ranqueada reentregue (replay/DLQ) |
Detectado por event_id em d5.backlog_scores. Log e cursor avançado, sem reprocessar. |
Evento ranking.atualizado reentregue |
Sem guarda por event_id: o rebuild roda de novo. As escritas são idempotentes por demanda_id, agenda.gerada é republicado com event_id novo e o anúncio do topo é suprimido quando a demanda já foi anunciada. |
Evento de status (conselheiro.sorteado, etc.) reentregue |
Idempotência derivada do estado do item. Log quando já está no estado de destino. |
| Payload incompleto em qualquer handler | Log.error e cursor avançado. Evento descartado. |
demanda.ranqueada sem categoria_id ou nivel_precedencia |
Log.warn e cursor avançado. Evento descartado. |
| UC sem config de pesos situacionais | DistribuicaoCapacidade usa distribuição uniforme: os primeiros capacidade itens por score entram, sem separação por nível. |
conselheiro.sorteado para demanda fora do backlog |
Log.warn. Insere a linha com posicao = 0, score = 0 e status atribuido. Não reconstrói o backlog. |
conselheiro.demanda_iniciada para demanda já em_progresso |
Idempotente. Log, sem ação. |
demanda.concluída para demanda já concluido |
Idempotente. Log, sem ação. |
Inserção em d5.backlog_scores falha (unique em event_id) |
Exceção propagada. O evento vai para a DLQ e o cursor não avança. |
Publish de agenda.gerada falha |
Log.error e exceção propagada. O estado em d5.backlog_items permanece. O evento vai para a DLQ e o cursor não avança. |
Publish de agenda.item_disponível falha |
Log.error e exceção propagada. agenda.gerada e as linhas do backlog já foram persistidos. O evento vai para a DLQ e o cursor não avança. |
Reconstrução de backlog durante iniciar() com 0 eventos de replay |
Nenhum rebuild. O backlog mantém o estado anterior. |
3.14 Decisões de design com justificativa
Seção intitulada “3.14 Decisões de design com justificativa”A D-5 consome conselheiro.sorteado.
Entre o sorteio (D-6a) e o início efetivo (D-6b), há um intervalo em que a demanda está atribuída mas não iniciada. Sem consumir conselheiro.sorteado, a D-5 anunciaria agenda.item_disponível para a mesma demanda nesse intervalo, causando sorteios duplicados na D-6a. O status atribuido cobre esse intervalo sem alterar o contrato público: para a D-7, projeta-se como disponivel.
Projeção própria (d5.backlog_scores) em vez de leitura do estado interno da D-4.
A D-4 mantém o ranking em memória (Map), reconstruído do PostgreSQL no boot. Seria tentador para a D-5 consultar o estado da D-4, mas a escolha pela projeção própria prioriza isolamento sobre conveniência. Cada demanda.ranqueada carrega os campos necessários. O custo de manter a projeção é um INSERT por evento, insignificante no volume do MVP (< 100 demandas/dia). Se no futuro o ranking da D-4 migrar para Redis, a D-5 continua lendo sua projeção: o contrato são os eventos, e a migração é interna à D-4.
Rebuild completo em ranking.atualizado, inserção incremental em demanda.ranqueada.
O ranking.atualizado é o gatilho de reconstrução. Quando chega, a D-5 reconstrói o backlog inteiro da UC: decaimento geométrico, DBSCAN e reordenação completa. Isso garante que o backlog reflita o estado mais recente do ranking para os itens que importam (top 10 e primeira posição). A inserção incremental de demanda.ranqueada não aplica decaimento nem agrupamento: projeta o score, preserva a linha existente ou ocupa a próxima posição livre enquanto houver capacidade.
agenda.gerada publicado no rebuild; agenda.item_disponível também no incremental.
agenda.gerada é o evento de backlog reconstruído. A D-7 o consome para atualizar o dashboard. Publicá-lo a cada inserção incremental seria ruidoso: 100 demandas/dia gerariam 100 agenda.gerada por UC. O rebuild é o momento canônico de publicação. agenda.item_disponível é mais frequente: sempre que há um novo item disponivel no topo, a D-6a precisa saber.
Status atribuido não exposto na API pública (D-7 projeta como disponivel).
O cidadão não precisa distinguir entre “ninguém pegou ainda” e “alguém foi sorteado mas ainda não começou”. Para ele, a demanda segue “disponível” até que o conselheiro a inicie formalmente. A distinção é operacional, interna ao pipeline D-5→D-6a→D-6b. Se no futuro o cidadão quiser saber que um conselheiro já foi designado, a D-7 pode expor o campo conselheiro_id do evento conselheiro.sorteado na timeline, sem precisar do status atribuido.
4. Lógica de Negócio — Algoritmos e Fluxos
Seção intitulada “4. Lógica de Negócio — Algoritmos e Fluxos”4.1 AgendaBuilder.reconstruir(ucId, eventIdGatilho) — pseudocódigo
Seção intitulada “4.1 AgendaBuilder.reconstruir(ucId, eventIdGatilho) — pseudocódigo”função reconstruir(ucId: string, eventIdGatilho: string) -> AgendaBuildResult:
// 1. Ler os scores ativos da UC, por score decrescente, excluindo agregados scores = repo.buscarScoresAtivosPorUc(ucId)
se scores está vazio: repo.removerItensPorUc(ucId) // deleteMany, exceto status agregado return { ucId, total_itens: 0, distribuicao: { nivelNaoVencido: null, itensPrioritario: 0, itensOutros: 0 }, top10: [], gruposTerritoriais: 0, versaoParametros: config.versao, }
// 2. Carregar as coordenadas das demandas da UC // Fonte: projeção local d5.coordenadas_demanda, alimentada por // demanda.georreferenciada (ver 4.6) coordenadas = repo.buscarCoordenadasPorUc(ucId) coordsMap = Map(demanda_id -> {lat, lng})
// 3. Aplicar decaimento geométrico distribuicao = distribuicao.aplicar(scores, ucId)
// 4. Montar as demandas com coordenadas para o clustering demandasGeo = distribuicao.selecionadas com coordenada conhecida
// 5. Aplicar agrupamento geográfico grupos = geoClustering.agrupar(demandasGeo) // Retorna Map<grupo_id, { demanda_ids, categoria_id, centroide, raio_metros }> // Demanda solo não entra em grupo
// 6. Montar lista ordenada final // Primeiro as demandas do nível prioritário, depois as demais; // cada bloco ordenado por score decrescente, desempate por demanda_id prioritaria = distribuicao.selecionadas.filter(pertence_ao_nivel_prioritario) outras = distribuicao.selecionadas.filter(NÃO pertence_ao_nivel_prioritario) prioritaria.sort(por score desc, desempate por demanda_id) outras.sort(por score desc, desempate por demanda_id) backlogOrdenado = [...prioritaria, ...outras]
// 7. Preservar os status anteriores statusAnteriores = repo.buscarStatusPorUc(ucId) // O status anterior é mantido quando existe; senão, 'disponivel'. // 'atribuido' e 'concluido' permanecem no rebuild.
// 8. Persistir cada linha (posição 1-based) e remover as fora da seleção para i de 0 até backlogOrdenado.length - 1: item = backlogOrdenado[i] repo.upsertItem({ demanda_id, unidade_civica_id: ucId, posicao: i + 1, score: item.score_final, status: statusAnteriores[item.demanda_id] ?? 'disponivel', grupo_id: grupoPorDemanda[item.demanda_id] ?? null, categoria_id, nivel_precedencia, event_id: eventIdGatilho, }) repo.removerItensForaDoBacklog(ucId, backlogOrdenado.map(i => i.demanda_id))
// 9. Montar o top 10 para o evento top10 = backlogOrdenado.slice(0, 10)
return { ucId, total_itens, distribuicao, top10, gruposTerritoriais: grupos.size, versaoParametros: config.versao }O rebuild não usa transação única: as escritas são upserts idempotentes por demanda_id e a repetição converge para o mesmo estado.
4.2 DistribuicaoCapacidade.aplicar(scores, ucId) — pseudocódigo
Seção intitulada “4.2 DistribuicaoCapacidade.aplicar(scores, ucId) — pseudocódigo”função aplicar(scores: BacklogScore[], ucId: string) -> DistribuicaoResult:
// 1. Carregar a capacidade da UC capacidade = config.capacidadePorUc[ucId] ?? config.capacidadePadrao
se scores está vazio: return { nivelNaoVencido: null, itensPrioritario: 0, itensOutros: 0, selecionadas: [] }
// 2. Determinar nível não vencido // Nível não vencido = nível com maior peso_situacional acima do limiar (0.1) // Se empate: escolhe o de nível de precedência mais baixo (mais crítico) pesosUC = config.pesosSituacionais[ucId] nivelNaoVencido = null maiorPeso = config.limiarPesoNaoVencido
se pesosUC existe: para nivel de 1 a 5: peso = pesosUC[nivel] se peso != undefined e peso > maiorPeso: maiorPeso = peso nivelNaoVencido = nivel senão se peso == maiorPeso e nivelNaoVencido != null e nivel < nivelNaoVencido: nivelNaoVencido = nivel // desempate por criticidade
// 3. Se nenhum nível acima do limiar: distribuição uniforme se nivelNaoVencido == null: selecionadas = scores.slice(0, capacidade) return { nivelNaoVencido: null, itensPrioritario: 0, itensOutros: selecionadas.length, selecionadas: selecionadas, }
// 4. Separar demandas por nível // Agrupa todas as demandas por nivel_precedencia demandasPorNivel = new Map<number, BacklogScore[]>() para cada s em scores: nivel = s.nivel_precedencia se !demandasPorNivel.has(nivel): demandasPorNivel.set(nivel, []) demandasPorNivel.get(nivel).push(s)
// 5. Aplicar decaimento geométrico // Para cada nível j, peso bruto = f ^ |j - nível_corrente| // Depois normaliza: capacidade_j = floor(capacidade * peso_bruto_j / soma_pesos) f = config.fatorDecaimento // 0.40 somaPesos = 0 pesosPorNivel = new Map<number, number>()
para nivel de 1 a 5: distancia = Math.abs(nivel - nivelNaoVencido) pesoBruto = Math.pow(f, distancia) pesosPorNivel.set(nivel, pesoBruto) somaPesos += pesoBruto
// Distribuir capacidade entre níveis, garantindo que o nível corrente receba // pelo menos 1 vaga se houver demandas nele e capacidade disponível capacidadeRestante = capacidade capacidadePorNivel = new Map<number, number>()
// Ordenar níveis: corrente primeiro, depois por distância crescente niveisOrdenados = [nivelNaoVencido] para nivel de 1 a 5: se nivel != nivelNaoVencido: niveisOrdenados.push(nivel)
para cada nivel em niveisOrdenados: pesoBruto = pesosPorNivel.get(nivel) capacidadeAlocada = Math.floor(capacidade * pesoBruto / somaPesos) // Garantir ao menos 1 vaga para o nível corrente se houver demandas se nivel == nivelNaoVencido e capacidadeAlocada == 0 e (demandasPorNivel.get(nivel)?.length ?? 0) > 0: capacidadeAlocada = 1 capacidadePorNivel.set(nivel, capacidadeAlocada) capacidadeRestante -= capacidadeAlocada
// Distribuir capacidade restante (arredondamento) para o nível corrente se capacidadeRestante > 0: capacidadePorNivel.set( nivelNaoVencido, capacidadePorNivel.get(nivelNaoVencido) + capacidadeRestante )
// 6. Selecionar top N de cada nível (já ordenados por score DESC) selecionadas = [] itensPrioritario = 0 itensOutros = 0
para cada nivel em niveisOrdenados: demandasNivel = demandasPorNivel.get(nivel) ?? [] vagas = capacidadePorNivel.get(nivel) ?? 0 selecionadasNivel = demandasNivel.slice(0, vagas)
para cada d em selecionadasNivel: d.pertence_ao_nivel_prioritario = (nivel == nivelNaoVencido) selecionadas.push(d)
se nivel == nivelNaoVencido: itensPrioritario = selecionadasNivel.length senão: itensOutros += selecionadasNivel.length
return { nivelNaoVencido: nivelNaoVencido, itensPrioritario: itensPrioritario, itensOutros: itensOutros, selecionadas: selecionadas, }4.3 Parâmetros estáticos do MVP
Seção intitulada “4.3 Parâmetros estáticos do MVP”Arquivo d5.constants.ts, carregado por carregarD5Constants() com cache em variável de módulo:
interface D5Constants { // Pesos situacionais por UC e nível, mesma fonte da D-4 // Determina qual nível está "não vencido" para o decaimento pesosSituacionais: Record<string, Record<number, number>>;
// Capacidade máxima do backlog por UC (número de conselheiros ativos) // Usado como nível corrente no decaimento capacidadePorUc: Record<string, number>;
// Parâmetros do DBSCAN clustering: { raioMetros: number; // Distância máxima para formar grupo (default: 500) minPontos: number; // Mínimo de demandas para formar grupo (default: 2) };
// Fator de decaimento geométrico para distribuição de capacidade // entre níveis de precedência. Referência: 0.40. fatorDecaimento: number; // Default: 0.40
// Limiar para determinar nível "não vencido" limiarPesoNaoVencido: number; // Default: 0.1
// Capacidade padrão se UC não configurada capacidadePadrao: number; // Default: 10
versao: string; // 'mvp-v1'}Valores de exemplo:
pesosSituacionais: { // UC de teste do seed de desenvolvimento (prisma/seed-dev.ts) 'f0000001-0000-4000-8000-000000000001': { 1: 0.85, // Nível 1 não vencido (maior peso > 0.1) 2: 0.60, 3: 0.75, 4: 0.40, 5: 0.20, },},capacidadePorUc: { 'f0000001-0000-4000-8000-000000000001': 10,},clustering: { raioMetros: 500, minPontos: 2,},fatorDecaimento: 0.40,limiarPesoNaoVencido: 0.1,capacidadePadrao: 10,versao: 'mvp-v1',resetarD5Constants() limpa o cache nos testes. No MVP, esta configuração é estática. Duplica a fonte da D-4 (formula-params.config.ts) para os pesosSituacionais. Na Fase 2, os valores são recebidos via evento parâmetros.atualizados da D-19 e armazenados em tabela versionada.
4.4 GeoClustering.agrupar(demandas) — pseudocódigo
Seção intitulada “4.4 GeoClustering.agrupar(demandas) — pseudocódigo”função agrupar(demandas: DemandaGeo[]) -> Map<string, GrupoInfo>: // O raio e o mínimo de pontos vêm de config.clustering
resultado = new Map<string, GrupoInfo>()
// Se nem o mínimo de pontos foi atingido no total, não há grupo se demandas.length < config.clustering.minPontos: return resultado
// Pré-agrupamento: só clusteriza demandas da mesma UC + categoria gruposPorChave = new Map<string, DemandaGeo[]>() para cada d em demandas: chave = `${d.uc_id}:${d.categoria_id}` gruposPorChave.get(chave).push(d)
para cada grupo em gruposPorChave: se grupo.length < config.clustering.minPontos: continuar
// Matriz de distâncias Haversine n = grupo.length distancias = matriz n×n para i de 0 a n-1: para j de i+1 a n-1: d = haversine(grupo[i].lat, grupo[i].lng, grupo[j].lat, grupo[j].lng) distancias[i][j] = d distancias[j][i] = d
// DBSCAN simplificado visitados = new Set() clusters = []
para i de 0 a n-1: se visitados.has(i): continuar visitados.add(i)
vizinhos = índices j com distancias[i][j] <= config.clustering.raioMetros
se vizinhos.length + 1 >= config.clustering.minPontos: cluster = [i] fila = [...vizinhos] enquanto fila não vazia: p = fila.shift() se visitados.has(p): continuar visitados.add(p) cluster.push(p) para k de 0 a n-1: se !visitados.has(k) e distancias[p][k] <= config.clustering.raioMetros: fila.push(k) clusters.push(cluster)
para cada cluster em clusters: se cluster.length < config.clustering.minPontos: continuar
grupoId = uuidv4() demandaIds = cluster.map(i => grupo[i].demanda_id) centroideLat = média(cluster.map(i => grupo[i].lat)) centroideLng = média(cluster.map(i => grupo[i].lng)) raio = máximo das distâncias de Haversine do centroide aos pontos
resultado.set(grupoId, { id: grupoId, demanda_ids: demandaIds, categoria_id: grupo[0].categoria_id, centroide: { lat: centroideLat, lng: centroideLng }, raio_metros: raio, })
return resultadoO agrupamento roda apenas no rebuild do AgendaBuilder. A inserção incremental de demanda.ranqueada grava grupo_id = NULL, porque os grupos são efêmeros e não há tabela para consultá-los entre execuções.
4.5 Máquina de estados do backlog
Seção intitulada “4.5 Máquina de estados do backlog”Transições de status em d5.backlog_items:
Fluxo principal: disponivel → atribuido (conselheiro.sorteado, D-6a) atribuido → em_progresso (conselheiro.demanda_iniciada, D-6b) em_progresso → concluido (demanda.concluída, D-6b)
Conclusão coletiva (demanda.conclusao_confirmada, D-12): disponivel → concluido (sem ratificação necessária) atribuido → concluido (sem ratificação necessária) em_progresso → sem transição (aguarda ratificação na D-6b) concluido → sem transição (idempotente)
Agregação: qualquer status → agregado (duplicidade.agregada, D-12; estado terminal)
Preservação no rebuild: o status anterior da linha é mantido; atribuido e concluido não regridem.
Transições proibidas: concluido → disponivel concluido → em_progresso agregado → qualquer outro removido → qualquer outro (Fase 2) em_progresso → disponivel (desistência de conselheiro fica para a Fase 2)4.6 Projeção de coordenadas para o DBSCAN
Seção intitulada “4.6 Projeção de coordenadas para o DBSCAN”O DBSCAN exige coordenadas geográficas de cada demanda, e o evento demanda.ranqueada não as carrega. A D-5 mantém a projeção local d5.coordenadas_demanda, alimentada por demanda.georreferenciada (D-2). O handler onDemandaGeorreferenciada grava coordenadas_lat e coordenadas_lng quando os campos estão presentes; sem eles, apenas avança o cursor.
O rebuild consulta a projeção para as demandas com score na UC. Demanda sem coordenada fica fora do DBSCAN e permanece solo, com grupo_id nulo. O backlog funciona normalmente sem agrupamento geográfico.
4.7 D5Service.iniciar() — protocolo de inicialização
Seção intitulada “4.7 D5Service.iniciar() — protocolo de inicialização”função iniciar():
se já iniciado: retornar
carregarD5Constants()
// 1. Semear o cursor dos tipos consumidos, sem sobrescrever maiorSequence = await eventBus.obterMaiorSequence() await repo.seedOffsets(TIPOS_EVENTO_CONSUMIDOS, maiorSequence)
// 2. Replay de eventos perdidos durante inatividade await reprocessarEventosPerdidos()
// 3. Registrar consumidores para eventos futuros registrarConsumidores()
logger.log('D-5 Agenda inicializada')O reprocessarEventosPerdidos() percorre os nove tipos de TIPOS_EVENTO_CONSUMIDOS, lê o cursor de cada um, chama replayDeSequence(cursor, [tipo]) e despacha cada evento por despacharEvento(). Falha no despacho é logada e o evento permanece pendente, com o cursor parado.
O registrarConsumidores() inscreve os dez handlers, envoltos pelo criarManipulador(), que serializa o processamento na fila única.
4.8 Handlers stub para Fase 2
Seção intitulada “4.8 Handlers stub para Fase 2”async onDemandaRemovidaPorVotacao(evento: EventoRecebido): Promise<void> { this.logger.log('Evento demanda.removida_por_votação ignorado (MVP)', { event_id: evento.event_id, }); // Fase 2: atualizar status para 'removido', rebalancear backlog}
async onParametrosAtualizados(evento: EventoRecebido): Promise<void> { this.logger.log('Evento parâmetros.atualizados ignorado (MVP — config estática)', { event_id: evento.event_id, }); await this.repo.upsertOffset('parâmetros.atualizados', BigInt(evento.sequence_number)); // Fase 2: recarregar a configuração, reconstruir o backlog de todas as UCs}4.9 Casos de borda adicionais
Seção intitulada “4.9 Casos de borda adicionais”| Caso | Comportamento |
|---|---|
| UC sem demandas no ranking | Rebuild gera backlog vazio. agenda.gerada publicado com total_itens: 0. Nenhum agenda.item_disponível. |
| UC com 1 demanda apenas | Backlog com 1 item, posição 1. agenda.item_disponível publicado. Se a demanda é do nível prioritário, itens_outros_niveis = 0. |
| UC com 200 demandas, capacidade = 10 | Só as primeiras por score entram no backlog, depois do decaimento. As demais permanecem apenas na projeção d5.backlog_scores. Um rebuild posterior volta a selecionar as primeiras; demandas novas ocupam as vagas que abrirem. |
| Demanda com coordenadas nulas | Excluída do DBSCAN. Tratada como solo (grupo_id = NULL). O backlog funciona sem clustering para ela. |
| DBSCAN gera grupo de 10 demandas | As demandas do grupo compartilham o grupo_id; as posições seguem a ordenação por score. A D-6a recebe agenda.item_disponível com grupo_id preenchido, e o conselheiro pode escolher pegar o grupo inteiro. |
Rebuild de UC que tem backlog_items sem projeção correspondente |
Não há scores para a UC. O rebuild remove as linhas da UC e devolve backlog vazio. |
conselheiro.sorteado para demanda com grupo_id != null |
Apenas a demanda específica é marcada atribuido. As outras demandas do grupo permanecem disponivel. O agrupamento é sugestivo; o conselheiro decide se pega o grupo. |
4.10 Decisões de design com justificativa
Seção intitulada “4.10 Decisões de design com justificativa”Distribuição por decaimento aplicada na seleção de demandas, não na ordenação.
A regra determina quantas demandas de cada nível entram no backlog, aplicando a fórmula de decaimento geométrico com fator f (referência: 0,40) sobre os 5 níveis de precedência. O nível corrente (não vencido) recebe a maior fatia (~61% com f=0,40); os demais níveis recebem fatias decrescentes conforme a distância. Dentro de cada fatia, a ordenação é por score DESC, e o ranking da D-4 é respeitado. A D-5 não altera a ordem relativa entre demandas do mesmo nível.
Status anterior preservado no rebuild.
O rebuild mantém o status que a linha já tinha: atribuido, em_progresso e concluido não regridem. Preservar atribuido evita que um atribuição em andamento se perca quando o ranking muda, o que geraria um segundo agenda.item_disponível para a mesma demanda.
Demandas concluídas permanecem em d5.backlog_items enquanto selecionadas.
O status concluido mantém o registro enquanto a demanda está entre as selecionadas do backlog. Quando um rebuild a deixa fora da seleção, a linha é removida, como qualquer outra fora do backlog; a projeção em d5.backlog_scores permanece.
Membros de agregado ficam marcados agregado, nunca são apagados.
Quando a D-12 publica duplicidade.agregada, a D-5 marca os membros com status agregado, os registra em d5.demandas_agregadas e regenera o backlog. O registro permanece para auditoria. A guarda de demanda.ranqueada tardia consulta d5.demandas_agregadas: membro não reativa linha. Só o representante permanece no backlog e pode gerar agenda.item_disponível, preservando a regra de 1 conselheiro = 1 demanda.
Rebalanceamento acionado por sorteio e conclusão.
Após conselheiro.sorteado, demanda.concluída e a conclusão coletiva, a D-5 consulta o primeiro item disponivel da UC e publica agenda.item_disponível se o topo mudou em relação ao último anúncio. A guarda anunciosPorUc evita republicação repetida do mesmo item.
5. Integração com o Barramento e Outras Colônias
Seção intitulada “5. Integração com o Barramento e Outras Colônias”5.1 Consumo de eventos via EventBusService
Seção intitulada “5.1 Consumo de eventos via EventBusService”A D-5 injeta EventBusService (do módulo @Global() N-0a) para quatro operações: inscrever() (registrar handlers), publicar() (publicar agenda.gerada e agenda.item_disponível), replayDeSequence() (replay na inicialização) e obterMaiorSequence() (seed do cursor).
@Injectable()export class D5Service { constructor( private readonly eventBus: EventBusService, private readonly repo: D5Repository, private readonly agendaBuilder: AgendaBuilder, ) {}}A D-5 injeta apenas o núcleo (EventBus), o repositório próprio e o AgendaBuilder. DistribuicaoCapacidade e GeoClustering são usados dentro do AgendaBuilder.
5.2 Fluxo de eventos — cadeia completa
Seção intitulada “5.2 Fluxo de eventos — cadeia completa”Cidadão (app) → POST /demandas (BFF D-1a) → demanda.recebida (barramento) → [D-1b] → demanda.normalizada → [D-2] → demanda.georreferenciada → [D-3] → demanda.categorizada (v1.1.0) → [D-4] → demanda.ranqueada ──────────────┐ → ranking.atualizado ──────────────┤ │ ┌────────────────────────────────────────┐ ▼ [D-5] (esta colônia) → agenda.gerada (D-7) → agenda.item_disponível (D-6a) │ ▼ [D-6a] → conselheiro.sorteado ───────────┐ │ │ ▼ │ [D-6b] → conselheiro.demanda_iniciada ────┤ → demanda.concluída ───────────────┘ │ ┌────────────────────────────────────────┘ ▼ [D-5] atualiza status (atribuido / em_progresso / concluido) e anuncia o próximo disponível
[D-12] → duplicidade.agregada → [D-5] marca os membros como agregado e regenera o backlog [D-12] → demanda.conclusao_confirmada (sem ratificação) → [D-5] conclui o item fora de em_progresso5.3 Publicação de agenda.gerada
Seção intitulada “5.3 Publicação de agenda.gerada”private async publicarAgendaGerada( resultado: AgendaBuildResult, correlacaoId: string,): Promise<void> { try { await publicarComRetry(this.eventBus, { tipo: 'agenda.gerada', origem: 'D-5', versao_schema: '1.0.0', event_id: uuidv4(), correlacao_id: correlacaoId, payload: { unidade_civica_id: resultado.ucId, total_itens: resultado.total_itens, distribuicao_decay: { nivel_nao_vencido: resultado.distribuicao.nivelNaoVencido, itens_nivel_prioritario: resultado.distribuicao.itensPrioritario, itens_outros_niveis: resultado.distribuicao.itensOutros, }, top_10: resultado.top10, grupos_territoriais: resultado.gruposTerritoriais, versao_parametros: resultado.versaoParametros, timestamp: new Date().toISOString(), }, }); } catch (erro) { this.logger.error('Falha ao publicar agenda.gerada', { uc_id: resultado.ucId, erro: erro instanceof Error ? erro.message : String(erro), }); throw erro; }}A publicação usa publicarComRetry (4 tentativas, backoff de 500 ms a 2000 ms).
5.4 Publicação de agenda.item_disponível
Seção intitulada “5.4 Publicação de agenda.item_disponível”private async publicarAgendaItemDisponivel( demandaId: string, ucId: string, posicao: number, score: number, categoriaId: string, nivelPrecedencia: number, grupoId: string | null, correlacaoId: string,): Promise<void> { try { await publicarComRetry(this.eventBus, { tipo: 'agenda.item_disponível', origem: 'D-5', versao_schema: '1.0.0', event_id: uuidv4(), correlacao_id: correlacaoId, payload: { demanda_id: demandaId, unidade_civica_id: ucId, posicao_no_backlog: posicao, score_final: score, categoria_id: categoriaId, nivel_precedencia: nivelPrecedencia, grupo_id: grupoId, timestamp: new Date().toISOString(), }, }); } catch (erro) { this.logger.error('Falha ao publicar agenda.item_disponível', { demanda_id: demandaId, erro: erro instanceof Error ? erro.message : String(erro), }); throw erro; }}5.5 Chamadas síncronas via BFF
Seção intitulada “5.5 Chamadas síncronas via BFF”A D-5 não faz chamadas síncronas a outras colônias. Não expõe endpoints REST. Não atua como proxy. Toda comunicação é via barramento.
5.6 Dependências de projeções de leitura
Seção intitulada “5.6 Dependências de projeções de leitura”A D-5 não consome projeções de leitura de outras colônias. Os dados externos que acessa são:
core.event_logviaEventBusService.replayDeSequence(), dependência do núcleo permitida.- Arquivo de configuração estática
d5.constants.ts, que duplica ospesosSituacionaisda D-4 para determinar o nível não vencido. Na Fase 2, migra para o consumo deparâmetros.atualizados.
5.7 Independência de Redis
Seção intitulada “5.7 Independência de Redis”A D-5 não utiliza Redis. Diferente da D-4 (que mantém o ranking em memória) e da D-2/L-2 (que mantêm caches geo em memória), a D-5 opera exclusivamente sobre PostgreSQL para seu estado próprio e sobre eventos do barramento para entrada de dados.
A decisão é compatível com o MVP: o volume de consultas ao backlog é baixo (< 100 demandas/dia, < 20 UCs) e o rebuild ocorre apenas em ranking.atualizado (eventos raros). Consultas frequentes ao backlog são feitas pela D-7 (Transparência) em seu próprio banco de leitura. A D-5 não é consultada diretamente.
6. Performance e Limites
Seção intitulada “6. Performance e Limites”6.1 Rate limiting
Seção intitulada “6.1 Rate limiting”A D-5 não aplica rate limiting. O volume de eventos é limitado indiretamente pelo rate limiting do BFF (D-1a).
6.2 Cotas e limites de tamanho
Seção intitulada “6.2 Cotas e limites de tamanho”| Limite | Valor | Justificativa |
|---|---|---|
| Tamanho do backlog por UC | Limitado pela capacidade da UC | A capacidade padrão é 10; as demais demandas ficam na projeção d5.backlog_scores. |
Tamanho máximo de d5.backlog_scores por UC |
10.000 linhas | Projeção histórica. Fase 2 pode exigir particionamento por UC. |
| Raio de clustering padrão | 500 m | Caminhável em ~7 minutos. |
| DBSCAN minPts | 2 | Forma grupo com ao menos 2 demandas próximas. |
| Capacidade padrão | 10 | Fallback para UC sem valor próprio na config. |
| Grupos por UC | Sem limite explícito | Os grupos vivem em memória durante o rebuild; o volume do MVP gera poucos grupos. |
6.3 Índices e padrões de query
Seção intitulada “6.3 Índices e padrões de query”| Índice | Query atendida |
|---|---|
backlog_scores_event_id_key (UNIQUE) |
Idempotência de demanda.ranqueada e vínculo da linha ao último evento. |
backlog_scores_demanda_id_idx |
Busca por demanda específica (atualizarScore). |
backlog_scores_unidade_civica_id_idx |
Filtro por UC. |
backlog_scores_unidade_civica_id_score_final_idx (composto) |
Rebuild: todos os scores da UC X por score decrescente. Query principal do AgendaBuilder. |
backlog_items_unidade_civica_id_status_idx (composto) |
Busca do primeiro disponivel da UC e contagem de itens por status. |
backlog_items_unidade_civica_id_posicao_idx (composto) |
Ordenação do backlog por posição. |
backlog_items_grupo_id_idx |
Busca de demandas de um grupo específico. |
6.4 Padrões de query esperados
Seção intitulada “6.4 Padrões de query esperados”buscarScorePorEventId(): 1 query por eventodemanda.ranqueadarecebido (idempotência).buscarScoresAtivosPorUc(): 1 query por rebuild, com exclusão das demandas agregadas; usa o índice composto(unidade_civica_id, score_final DESC).upsertItem(): 1 write por demanda que entra no backlog ou é reposicionada no rebuild.buscarPrimeiroDisponivel(): 1 query a cada anúncio deagenda.item_disponível.atualizarStatus()econcluirItem(): 1 write por evento de status.removerItensForaDoBacklog(): 1deleteManypor rebuild, exceto statusagregado.
Volume esperado no MVP: < 100 demanda.ranqueada/dia, < 5 ranking.atualizado/dia, < 20 eventos de status/dia. Tempo médio de processamento: < 10ms para incremental, < 50ms para rebuild com DBSCAN (N <= 50).
6.5 Estratégia de cache
Seção intitulada “6.5 Estratégia de cache”A D-5 não implementa cache. O backlog é consultado exclusivamente pela D-7 (Transparência), que mantém suas próprias projeções de leitura. A D-5 é write-only do ponto de vista de consulta externa. Ninguém a lê diretamente.
Internamente, o AgendaBuilder consulta d5.backlog_scores com índice composto. Para o volume do MVP, a performance é adequada sem cache.
6.6 Projeção de volume
Seção intitulada “6.6 Projeção de volume”| Cenário | Demandas/dia | Backlogs ativos | Tamanho médio/backlog | Rebuilds/dia |
|---|---|---|---|---|
| PoC (1 bairro) | ~20 | 1 | 10 | ~2 |
| MVP (1 município) | ~100 | ~10 | 10-20 | ~5 |
| Fase 2 (regional) | ~10.000 | ~200 | 20-50 | ~50 |
7. Testabilidade
Seção intitulada “7. Testabilidade”7.1 Como testar o módulo isolado
Seção intitulada “7.1 Como testar o módulo isolado”Teste unitário do D5Service:
beforeEach(async () => { const module = await Test.createTestingModule({ providers: [ D5Service, { provide: EventBusService, useValue: mockEventBus }, { provide: D5Repository, useValue: mockRepo }, { provide: AgendaBuilder, useValue: mockAgendaBuilder }, DistribuicaoCapacidade, GeoClustering, ], }).compile();
service = module.get<D5Service>(D5Service);
// Setup padrão dos mocks mockRepo.buscarScorePorEventId.mockResolvedValue(null); mockRepo.buscarScorePorDemandaId.mockResolvedValue(null); mockRepo.buscarAgregacaoPorDemandaId.mockResolvedValue(null); mockRepo.inserirScore.mockResolvedValue({ id: 'score-id', demanda_id: 'dem-1', unidade_civica_id: 'f0000001-0000-4000-8000-000000000001', categoria_id: '1.1', nivel_precedencia: 1, score_final: 8075.0, peso_nacional: 100, peso_situacional: 0.85, score_horizontal: 95, posicao_ranking: 1, versao_parametros: 'mvp-v1', event_id: 'evt-1', correlacao_id: 'corr-1', criado_em: new Date().toISOString(), }); mockRepo.buscarItem.mockResolvedValue(null); mockRepo.upsertItem.mockResolvedValue(undefined); mockRepo.buscarStatusPorUc.mockResolvedValue([]); mockRepo.buscarPrimeiroDisponivel.mockResolvedValue(null); mockRepo.buscarCoordenadasPorUc.mockResolvedValue([]); mockRepo.buscarTodosOffsets.mockResolvedValue([]);
mockAgendaBuilder = { reconstruir: jest.fn() }; mockAgendaBuilder.reconstruir.mockResolvedValue({ ucId: 'f0000001-0000-4000-8000-000000000001', total_itens: 0, distribuicao: { nivelNaoVencido: 1, itensPrioritario: 0, itensOutros: 0 }, top10: [], gruposTerritoriais: 0, versaoParametros: 'mvp-v1', });
mockEventBus.obterMaiorSequence.mockResolvedValue(100); mockEventBus.replayDeSequence.mockResolvedValue([]); mockEventBus.publicar.mockResolvedValue({ sequence_number: 10n, event_id: 'pub-evt-id', tipo: 'agenda.gerada', }); mockEventBus.inscrever.mockImplementation(() => {});});O d5-schema.spec.ts verifica as tabelas d5.demandas_agregadas, a coluna sequence_number de d5.backlog_scores e as colunas de conclusão, e os specs agenda-builder.spec.ts, distribuicao-capacidade.spec.ts e geo-clustering.spec.ts cobrem o rebuild, o decaimento e o DBSCAN isoladamente.
7.2 Cenários de teste críticos
Seção intitulada “7.2 Cenários de teste críticos”Happy path:
| # | Cenário | Verificação |
|---|---|---|
| T1 | onDemandaRanqueada() com payload completo, primeira demanda da UC |
inserirScore() e upsertItem() chamados, com posição 1. Sem rebuild. publicar('agenda.item_disponível') chamado. Cursor atualizado. |
| T2 | onDemandaRanqueada() com payload completo, UC já tem backlog |
inserirScore() chamado. Item inserido após a contagem atual (contarItensPorUc). Sem anúncio quando a posição não é a primeira e o topo não mudou. |
| T3 | onRankingAtualizado() com payload completo |
reconstruir(ucId, event_id) chamado. publicar('agenda.gerada') chamado. publicar('agenda.item_disponível') para o topo quando ainda não anunciado. |
| T4 | onConselheiroSorteado() com demanda no backlog |
Status atualizado para atribuido. publicar('agenda.item_disponível') do próximo disponível quando o topo muda. |
| T5 | onConselheiroDemandaIniciada() com demanda atribuido |
Status atualizado para em_progresso. Nenhum evento publicado. |
| T6 | onDemandaConcluida() com demanda em_progresso |
Status atualizado para concluido. publicar('agenda.item_disponível') do próximo disponível quando o topo muda. |
Falhas e bordas:
| # | Cenário | Verificação |
|---|---|---|
| T7 | Evento demanda.ranqueada já processado (mesmo event_id) |
buscarScorePorEventId() retorna registro. Nenhuma escrita nem publicação. Cursor avançado. |
| T8 | Evento ranking.atualizado já processado |
Sem guarda por event_id: o rebuild roda de novo e agenda.gerada é republicado. O agenda.item_disponível é suprimido quando o topo já foi anunciado. |
| T9 | Payload sem unidade_civica_id |
Log.error. Cursor avançado. Evento descartado. |
| T10 | Payload sem demanda_id em evento de status |
Log.error. Cursor avançado. Evento descartado. |
| T11 | UC sem config de pesos situacionais (distribuição uniforme) | nivelNaoVencido = null. Os primeiros capacidade itens por score entram, sem separação por nível. |
| T12 | conselheiro.sorteado para demanda fora do backlog |
Log.warn. Linha inserida com posicao = 0 e status atribuido. Sem rebuild. |
| T13 | conselheiro.demanda_iniciada para demanda já em_progresso |
Idempotente. Log. Sem ação. |
| T14 | demanda.concluída para demanda já concluido |
Idempotente. Log. Sem ação. |
| T15 | publicar('agenda.gerada') falha |
Log.error. Exceção propagada. Cursor NÃO atualizado. |
| T16 | publicar('agenda.item_disponível') falha |
Log.error. Exceção propagada. Cursor NÃO atualizado. |
| T17 | DBSCAN não forma grupos (demandas muito distantes) | grupos_territoriais = 0. Backlog ordenado sem agrupamento. Funcionalidade preservada. |
| T18 | Rebuild com UC vazia (0 scores na projeção) | agenda.gerada com total_itens: 0. Nenhum agenda.item_disponível. Linhas da UC removidas, exceto agregadas. |
Teste de integração:
| # | Cenário | Verificação |
|---|---|---|
| T19 | Ciclo completo: D-4→D-5→D-6a→D-6b com 5 demandas em 1 UC | 5 linhas em d5.backlog_scores. O primeiro evento insere o item no backlog; o rebuild roda no ranking.atualizado. Status transitam: disponivel → atribuido → em_progresso → concluido. |
| T20 | Rebuild com DBSCAN formando 2 grupos e 1 solo | Dois grupo_id distintos gravados em backlog_items, cada um com as demandas do grupo; a demanda solo com grupo_id = NULL. agenda.gerada reporta grupos_territoriais: 2. |
| T21 | Replay após reinício: 5 eventos, 2 já processados | 3 novos scores inseridos. Os 2 já processados apenas avançam o cursor, sem republicação. Um ranking.atualizado reentregue roda o rebuild e republica agenda.gerada. |
| T22 | UC com 2 demandas, capacidade = 1, ambas entram no backlog? | Só uma. O decaimento com f=0,40 aloca floor(1 × 1,0 / 1,6496) = 0 para o nível corrente e floor(1 × 0,4 / 1,6496) = 0 para o nível adjacente; a capacidade restante (1) vai para o nível corrente, que seleciona sua melhor demanda. Resultado: 1 item do nível prioritário. |
| T23 | conselheiro.sorteado seguido de ranking.atualizado antes de conselheiro.demanda_iniciada |
O rebuild preserva o status atribuido. A atribuição em andamento permanece e o item não é reanunciado para novo sorteio. |
| T24 | demanda.conclusao_confirmada sem ratificação para item disponivel |
Status concluido com data_conclusao e dias_ate_conclusao a partir de entrou_em. publicar('agenda.item_disponível') do próximo disponível quando o topo muda. |
| T25 | demanda.conclusao_confirmada para item em_progresso |
Nenhuma mudança no backlog. Cursor avançado; a ratificação fica com a D-6b. |
7.3 Dados de seed para desenvolvimento local
Seção intitulada “7.3 Dados de seed para desenvolvimento local”O cursor de d5.consumer_offset é seedado no boot por iniciar() com obterMaiorSequence(); não há passo manual. O seed abaixo ilustra a projeção de uma UC de desenvolvimento com o peso situacional padrão 1.0.
INSERT INTO d5.backlog_scores ( id, demanda_id, unidade_civica_id, categoria_id, nivel_precedencia, score_final, peso_nacional, peso_situacional, score_horizontal, posicao_ranking, versao_parametros, event_id, correlacao_id)VALUES ( '50000000-0000-0000-0000-000000000001', 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', '00000000-0000-0000-0000-000000000001', '1.1', 1, 9500.00, 100, 1.0, 95, 1, 'mvp-v1', 'e0000000-0000-0000-0000-000000000001', 'c0000000-0000-0000-0000-000000000001' ), ( '50000000-0000-0000-0000-000000000002', 'b2c3d4e5-f6a7-8901-bcde-f12345678901', '00000000-0000-0000-0000-000000000001', '3.1', 3, 5100.00, 60, 1.0, 85, 2, 'mvp-v1', 'e0000000-0000-0000-0000-000000000002', 'c0000000-0000-0000-0000-000000000002' ), ( '50000000-0000-0000-0000-000000000003', 'c3d4e5f6-a7b8-9012-cdef-123456789012', '00000000-0000-0000-0000-000000000001', '3.4', 3, 4680.00, 60, 1.0, 78, 3, 'mvp-v1', 'e0000000-0000-0000-0000-000000000003', 'c0000000-0000-0000-0000-000000000003' ), ( '50000000-0000-0000-0000-000000000004', 'd4e5f6a7-b8c9-0123-defa-234567890123', '00000000-0000-0000-0000-000000000001', '1.2', 1, 8000.00, 100, 1.0, 80, 4, 'mvp-v1', 'e0000000-0000-0000-0000-000000000004', 'c0000000-0000-0000-0000-000000000004' ), ( '50000000-0000-0000-0000-000000000005', 'e5f6a7b8-c9d0-1234-efab-345678901234', '00000000-0000-0000-0000-000000000001', '3.9', 3, 3600.00, 60, 1.0, 60, 5, 'mvp-v1', 'e0000000-0000-0000-0000-000000000005', 'c0000000-0000-0000-0000-000000000005' );
-- Backlog correspondente: a UC não está em pesosSituacionais, então a-- distribuição é uniforme e os 5 itens entram, ordenados por score.INSERT INTO d5.backlog_items ( demanda_id, unidade_civica_id, posicao, status, grupo_id, score, categoria_id, nivel_precedencia, event_id)VALUES ('a1b2c3d4-e5f6-7890-abcd-ef1234567890', '00000000-0000-0000-0000-000000000001', 1, 'disponivel', NULL, 9500.00, '1.1', 1, 'ranking.atualizado'), ('d4e5f6a7-b8c9-0123-defa-234567890123', '00000000-0000-0000-0000-000000000001', 2, 'disponivel', NULL, 8000.00, '1.2', 1, 'ranking.atualizado'), ('b2c3d4e5-f6a7-8901-bcde-f12345678901', '00000000-0000-0000-0000-000000000001', 3, 'disponivel', NULL, 5100.00, '3.1', 3, 'ranking.atualizado'), ('c3d4e5f6-a7b8-9012-cdef-123456789012', '00000000-0000-0000-0000-000000000001', 4, 'disponivel', NULL, 4680.00, '3.4', 3, 'ranking.atualizado'), ('e5f6a7b8-c9d0-1234-efab-345678901234', '00000000-0000-0000-0000-000000000001', 5, 'disponivel', NULL, 3600.00, '3.9', 3, 'ranking.atualizado');8. Alinhamento com o MVP
Seção intitulada “8. Alinhamento com o MVP”8.1 O que é MVP obrigatório
Seção intitulada “8.1 O que é MVP obrigatório”| Funcionalidade | Status |
|---|---|
inscrever('demanda.ranqueada') com handler idempotente por event_id |
MVP obrigatório |
inscrever('ranking.atualizado') com rebuild completo via AgendaBuilder |
MVP obrigatório |
inscrever('conselheiro.sorteado') com transição para atribuido |
MVP obrigatório |
inscrever('conselheiro.demanda_iniciada') com transição para em_progresso |
MVP obrigatório |
inscrever('demanda.concluída') com transição para concluido |
MVP obrigatório |
inscrever('demanda.conclusao_confirmada') com conclusão fora de em_progresso |
MVP obrigatório |
inscrever('duplicidade.agregada') com desativação de membros e rebuild |
MVP obrigatório |
inscrever('demanda.georreferenciada') alimentando d5.coordenadas_demanda |
MVP obrigatório |
Projeção d5.backlog_scores populada de demanda.ranqueada, com upsert por demanda |
MVP obrigatório |
AgendaBuilder.reconstruir() com decaimento e DBSCAN |
MVP obrigatório |
Config estática (d5.constants.ts) com pesos situacionais, capacidade e parâmetros de clustering |
MVP obrigatório |
Determinação do nível não vencido via peso_situacional > limiar |
MVP obrigatório |
Publicação de agenda.gerada com distribuição por decaimento, top 10 e grupos territoriais |
MVP obrigatório |
Publicação de agenda.item_disponível com posição, score e grupo_id |
MVP obrigatório |
Anúncio do próximo disponível após sorteio e conclusão, com guarda anunciosPorUc |
MVP obrigatório |
Fila única de processamento (filaProcessamento) serializando os handlers |
MVP obrigatório |
Stubs de demanda.removida_por_votação e parâmetros.atualizados |
MVP obrigatório |
| Consumer offsets para os nove tipos consumidos | MVP obrigatório |
Propagação de correlacao_id |
MVP obrigatório |
Logs estruturados com demanda_id, unidade_civica_id, status e event_id |
MVP obrigatório |
8.2 Simplificações válidas no MVP
Seção intitulada “8.2 Simplificações válidas no MVP”| Simplificação | Justificativa | Quando remover |
|---|---|---|
Config de decaimento estática duplicada da D-4 (d5.constants.ts) |
Sem sistema de parametrização dinâmica em produção no MVP. Duplicação é aceitável: os valores mudam raramente e são calibrados manualmente. | Substituir por consumo do evento parâmetros.atualizados da D-19 na Fase 2. |
| DBSCAN com Haversine em memória (sem PostGIS) | PostGIS adiciona dependência de extensão PostgreSQL. Para o volume do MVP (< 50 demandas/UC), o cálculo em memória é adequado. | Migrar para PostGIS com índices geoespaciais se o volume de clustering exigir (Fase 2+). |
Coordenadas via projeção local de demanda.georreferenciada (d5.coordenadas_demanda) |
O evento demanda.ranqueada não carrega coordenadas, e a projeção evita mudar o schema da D-4. |
Sem mudança prevista. |
| Sem endpoint REST para consulta de backlog | A consulta pública é feita pela D-7 (Transparência). A D-5 é write-only para consulta externa. | Mantido como está; não é simplificação, é design. |
| Capacidade da UC fixa em config, não derivada do número real de conselheiros ativos | A D-5 não consulta a D-6a para saber quantos conselheiros estão ativos. Usa valor configurado manualmente. | Consumir evento da D-6a com número de conselheiros ativos por UC, ou consultar projeção da D-7. |
| Inserção incremental sem decaimento e sem agrupamento | O caminho incremental compara a contagem de itens com a capacidade e insere a demanda no fim. Decaimento e DBSCAN rodam no rebuild, quando o backlog inteiro é reordenado. | Reavaliar se o volume tornar o rebuild frequente. |
Sem guarda de idempotência por event_id no ranking.atualizado |
O rebuild é idempotente por natureza e o anúncio do topo é guardado por anunciosPorUc. A guarda adicional não mudaria o estado final. |
Sem mudança prevista. |
| Demandas sem coordenadas ficam fora do DBSCAN | O agrupamento exige a coordenada; a demanda sem ela permanece solo. O backlog funciona normalmente sem clustering. | Consumir coordenadas de outra fonte quando necessário. |
8.3 O que vai para a Fase 2
Seção intitulada “8.3 O que vai para a Fase 2”- Consumo de
parâmetros.atualizadoscom recálculo dinâmico de todos os backlogs - Consumo de
demanda.removida_por_votaçãocom remoção e rebalanceamento - Consumo de evento de número de conselheiros ativos por UC (capacidade dinâmica)
- Tratamento de desistência de conselheiro (
em_progresso→disponivel) - Migração para PostGIS para clustering geoespacial em escala
- Métricas Prometheus:
d5_backlog_size_by_uc,d5_agenda_gerada_total,d5_item_disponivel_total,d5_rebuild_duration_ms - Tabela
d5.param_versionscom histórico de versões de parâmetros de decaimento
8.4 Verificação de conflitos com outras colônias
Seção intitulada “8.4 Verificação de conflitos com outras colônias”Conflito potencial: D-5 consome conselheiro.sorteado. Isso conflita com a D-6a ou D-6b?
Avaliação: não conflita. A D-6a publica conselheiro.sorteado. A D-6b também consome esse evento para iniciar o acompanhamento. A D-5 o consome para o controle de status (atribuido). São responsabilidades disjuntas sobre o mesmo evento, padrão normal em arquitetura orientada a eventos.
Conflito potencial: D-5 duplica pesosSituacionais da D-4. Se um valor for alterado em uma e não na outra, o backlog diverge do ranking?
Avaliação: o backlog é derivado do ranking. Se a D-4 usar peso_situacional = 0.85 para o nível 1 e a D-5 usar 0.75, a distribuição por decaimento pode selecionar um nível corrente diferente do que o ranking reflete. O resultado é que o backlog prioriza um nível que o ranking não está priorizando. No MVP, como ambas leem de arquivos de configuração que devem ser mantidos em sincronia manual, o risco é mitigado por processo (code review, testes de integração). Na Fase 2, ambas consomem parâmetros.atualizados da mesma fonte, e o problema desaparece.
Conflito potencial: a D-5 reconstrói o backlog no ranking.atualizado, mas ranking.atualizado só é publicado em mudanças significativas (top 10, 1ª posição). Demandas que entram no ranking fora do top 10 nunca disparam rebuild?
Avaliação: correto. A D-5 também processa demanda.ranqueada incrementalmente. Uma demanda que entra na posição 55 do ranking não dispara ranking.atualizado, mas demanda.ranqueada é publicado de qualquer forma. A D-5 recebe esse evento, atualiza a projeção d5.backlog_scores e, havendo vaga na capacidade, insere a demanda no backlog. O rebuild completo (ranking.atualizado) só ocorre quando o topo muda. Isso é intencional: o backlog não precisa ser reconstruído porque uma demanda entrou na posição 55; ela não afeta a ordem das que já estão no backlog.
Conflito potencial: demanda.concluída é publicado pela D-6b.
Avaliação: a D-6b (D-6b - Relatoria e Acompanhamento.md) lista demanda.concluída como evento produzido e o publica antes de conselheiro.ciclo_concluído na mesma transação, quando a demanda é resolvida. A D-5 consome demanda.concluída para a transição em_progresso → concluido. Sem inconsistência.
Conflito potencial: a ordem de deploy. Se D-5 subir antes de D-4 publicar demanda.ranqueada?
Avaliação: a D-5 opera em modo degradado. Sem eventos demanda.ranqueada, não há projeção, não há backlog. Os handlers ficam registrados e processarão eventos assim que a D-4 começar a publicar. O iniciar() faz replay a partir do último offset conhecido. Sem offset (primeira execução), começa do 0 e processa todos os eventos disponíveis no log. Ordem natural do Bloco 2 (D-4 → D-5 → D-6a) garante que a D-4 já está publicando quando a D-5 sobe em produção.
Referências
Seção intitulada “Referências”- Ficha técnica da colônia: Apêndice B - Colônias.md, seção “D-5 — Agenda”
- Fórmula de priorização e distribuição por decaimento: contexto_IA.md, seção 7 (A Gestão)
- Schemas de eventos: N-0b - Registry.md
- Barramento de eventos: N-0a - Event Bus.md
- Observabilidade: N-0c - Observabilidade.md
- Colônias a montante: D-4 - Priorização e Ranking.md
- Colônias a jusante: D-6a - Sorteio e Atribuição.md, D-7 - Transparência.md
- 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.