Pular para o conteúdo

D-5 — Agenda

Parte do Ciclo de Demandas — Fase 1


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.


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

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.

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, publicar
@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();
}
}
  • 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 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 publicados é feita pelo próprio Event Bus (N-0a) no momento da publicação.
  • O OnModuleInit dispara 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 evento parâmetros.atualizados da 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 eventos demanda.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.agregada e demanda.georreferenciada) e o stub de parâmetros.atualizados, que loga e avança o cursor. O stub de demanda.removida_por_votação fica 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 novo agenda.item_disponível até que o topo mude.
// Métodos públicos do D5Service
iniciar(): Promise<void>
despacharEvento(tipo: string, evento: EventoRecebido): Promise<void>
registrarConsumidores(): void
onDemandaRanqueada(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 2
onParametrosAtualizados(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.

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.


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.

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

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

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

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.

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

Três migrations:

  1. 20260810142037_d5_create_schema — Cria o schema d5 e as tabelas d5.backlog_scores, d5.backlog_items, d5.consumer_offset e d5.coordenadas_demanda, com os índices de cada uma.
  2. 20260820153000_d5_agregacao — Cria d5.demandas_agregadas com PK em demanda_id e os índices demandas_agregadas_evento_id_idx e demandas_agregadas_representante_demanda_id_idx.
  3. 20260902192727_d5_conclusao_coletiva — Adiciona as colunas data_conclusao e dias_ate_conclusao a d5.backlog_items.
  4. 20260918150000_d5_backlog_scores_sequence — Adiciona sequence_number a d5.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.

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.

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.


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.

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
}
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.
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 afetada
3. Regenerar o backlog de cada UC afetada e publicar agenda.gerada
4. Avançar cursor em todo caminho terminal

Garantias: 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.

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

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

O 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.atualizado

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

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 evento

Regras por handler:

  • conselheiro.sorteado: linha ausente é inserida com posicao = 0, score = 0 e status atribuido; status concluido ou agregado é ignorado; nos demais, o status vira atribuido e o próximo disponível é anunciado.
  • conselheiro.demanda_iniciada: o status vira em_progresso; se já estiver em_progresso, log idempotente. Não há anúncio.
  • demanda.concluída: o status vira concluido e o próximo disponível é anunciado; se já estiver concluido, log idempotente.
  • demanda.conclusao_confirmada: ratificacao_necessaria = true nã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.

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.

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.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,
}

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 resultado

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

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)

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.

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

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”

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.

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

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;
}
}

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.

A D-5 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.
  • Arquivo de configuração estática d5.constants.ts, que duplica os pesosSituacionais da D-4 para determinar o nível não vencido. Na Fase 2, migra para o consumo de parâmetros.atualizados.

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.


A D-5 não aplica rate limiting. O volume de eventos é limitado indiretamente pelo rate limiting do BFF (D-1a).

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.
Í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.
  • buscarScorePorEventId(): 1 query por evento demanda.ranqueada recebido (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 de agenda.item_disponível.
  • atualizarStatus() e concluirItem(): 1 write por evento de status.
  • removerItensForaDoBacklog(): 1 deleteMany por rebuild, exceto status agregado.

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

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.

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

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.

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: disponivelatribuidoem_progressoconcluido.
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.

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

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
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.
  • Consumo de parâmetros.atualizados com recálculo dinâmico de todos os backlogs
  • Consumo de demanda.removida_por_votação com 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_progressodisponivel)
  • 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_versions com 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_progressoconcluido. 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.



Documento de especificação técnica de implementação.