Pular para o conteúdo

D-6a — Sorteio e Atribuição

Parte da D-6 — Conselheiros


A D-6a gerencia o cadastro de conselheiros elegíveis, executa o sorteio quando há demanda disponível e atribui demandas a conselheiros respeitando a regra central: 1 conselheiro = 1 demanda ativa.

O sorteio é verificável por terceiros. Um seed público é derivado via HMAC-SHA256 sobre o event_id do último evento do barramento no momento da atribuição, combinado com o timestamp ISO-8601 do momento. A lista de elegíveis é ordenada por Fisher-Yates com seed determinístico. Qualquer pessoa pode reproduzir a ordenação e verificar que o selecionado era o correto. Nenhuma parte do sistema pode predeterminar o resultado.

A recusa de atribuição pelo conselheiro é tratada como evento no barramento. Recusas explícitas consecutivas acima do limite parametrizado suspendem o cadastro. A recusa por risco pessoal encerra a atribuição e re-sorteia a demanda sem penalização (motivo risco_pessoal, versão 1.1.0 do evento). A demanda recusada é re-sorteada internamente, sem depender de re-publicação pela D-5.

A D-6a mantém fila interna de demandas que não encontraram conselheiro elegível. Quando um conselheiro é liberado por conclusão de ciclo, a fila é varrida e o sorteio disparado imediatamente.

Não acompanha a execução, não registra atualizações de status, não avalia o conselheiro. Sua responsabilidade termina no momento em que o evento conselheiro.sorteado é publicado. A partir dali, a D-6b assume.



A D-6a é um módulo NestJS com encapsulamento próprio dentro do monolito modular do MVP. É uma colônia de eventos: consome do barramento via EventBusService (N-0a), processa internamente e publica eventos de saída. Toda interação de escrita com o front-end passa pelo BFF da D-1a, que publica os eventos de entrada no barramento.

A D-6a expõe um único endpoint de leitura do próprio estado, GET /d6a/conselheiros/me/situacao, protegido pelo ConselheiroGuard (JWT com sessão Google). O endpoint alimenta o workspace do conselheiro no app e lê apenas o schema d6a. Nenhuma rota de escrita existe na colônia.

src/demanda/d-6a-sorteio-atribuicao/
├── d6a.module.ts # Module definition; importa HierarquiaUcModule da camada compartilhada
├── d6a.service.ts # Fachada: protocolo de consumo, despacho e composição dos services
├── d6a.controller.ts # GET /d6a/conselheiros/me/situacao (leitura do próprio estado)
├── d6a.repository.ts # Acesso a todas as tabelas do schema d6a
├── d6a.constants.ts # Config estática: N recusas e período de suspensão
├── d6a.service.spec.ts # Testes do serviço, do sorteio e da elegibilidade
├── d6a.repository.spec.ts # Testes do repositório
├── d6a.controller.spec.ts # Testes do endpoint de situação
├── services/
│ ├── sorteio.service.ts # Fila global, filtro em degraus, Fisher-Yates, fila de pendentes e publicações
│ ├── elegibilidade.service.ts # Cadastro, ciclo concluído, consistência de ocupados, reativação e stub
│ ├── recusa.service.ts # Handler de recusa, contador, suspensão e re-sorteio
│ └── atribuicao.service.ts # Consultas e transições de atribuição compartilhadas pelos handlers
├── sorteio/
│ ├── fisher-yates.ts # Implementação determinística do Fisher-Yates shuffle
│ ├── seed-generator.ts # Geração do seed público verificável (HMAC-SHA256)
│ └── elegibilidade.ts # Filtro de elegíveis em degraus sobre a cadeia de UCs
├── dto/
│ └── situacao-conselheiro.dto.ts # DTO de resposta do GET de situação do conselheiro
└── types.ts # Tipos internos: ConselheiroElegivel, ResultadoSorteio, EventoRecebido
@Module({
imports: [HierarquiaUcModule], // Hierarquia de UCs compartilhada (src/shared/hierarquia-uc/)
controllers: [D6aController], // Leitura do próprio estado (workspace do conselheiro)
providers: [
D6aService,
D6aRepository,
FisherYates,
SeedGenerator,
ElegibilidadeService,
ConselheiroGuard,
],
exports: [],
})
export class D6aModule implements OnModuleInit {
constructor(private readonly d6aService: D6aService) {}
async onModuleInit() {
await this.d6aService.iniciar();
}
}
  • O módulo não é @Global(). A D-6a não é dependência de nenhuma outra colônia. Outras colônias consomem seus eventos (conselheiro.sorteado, sorteio.sem_candidatos), 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 do publicar().
  • O cadastro de conselheiros (conselheiro.cadastrado) e a recusa de atribuição (conselheiro.atribuicao_recusada) são publicados pelo BFF da D-1a. A D-6a apenas consome esses eventos. O BFF valida os campos e publica no barramento. A única rota da colônia é o GET de situação do conselheiro (GET /d6a/conselheiros/me/situacao), que lê o próprio schema sob o ConselheiroGuard.
  • O OnModuleInit dispara o protocolo de inicialização: carrega config estática, replay de eventos perdidos + registro de handlers.
  • O módulo não registra ThrottlerModule. O GET de situação aplica @Throttle de 60 requisições por minuto no próprio controller; o rate limiting das rotas de escrita é responsabilidade do BFF (D-1a), na borda HTTP.
  • Os parâmetros de anti-acumulação (N_RECUSAS_SUSPENSAO, T_SUSPENSAO_DIAS, TIMEOUT_RESPOSTA_HORAS) são carregados de d6a.constants.ts, arquivo de configuração estática na raiz do módulo no MVP.
  • TIMEOUT_RESPOSTA_HORAS (72 horas) é carregado da config, mas o timeout automático é Fase 2 (ver “O que vai para a Fase 2”). No MVP a verificação é manual. A constante existe como declaração forward-compat, no mesmo padrão do enum timeout_resposta do Registry.
  • O módulo registra handler stub para evento da Fase 2 (conselheiro.elegibilidade_atualizada) que loga e ignora no MVP.
  • O módulo consome duplicidade.agregada (D-12) para remover membros de agregados da fila de pendentes.
  • A D-6a lê core.uc_polygons como exceção controlada de infraestrutura, read-only, para montar a cadeia de UCs da elegibilidade em degraus. É a mesma exceção da D-2 e da L-2. A leitura e o cache em memória (TTL de 6 horas) vivem no módulo compartilhado src/shared/hierarquia-uc/, importado pelo d6a.module.ts e consumido por sorteio/elegibilidade.ts, services/sorteio.service.ts e d6a.service.ts. Não há migration nem escrita.
  • A cobertura da UC de atuação é a própria UC e as unidades menores dentro dela. O sorteio percorre a cadeia ascendente da UC da demanda, do degrau exato à raiz, e o primeiro degrau com elegíveis vence.
export class D6aService {
iniciar(): Promise<void>;
despacharEvento(tipo: string, evento: EventoRecebido): Promise<void>;
registrarConsumidores(): void;
obterSituacaoConselheiro(cidadaoId: string): Promise<SituacaoConselheiro>;
onAgendaItemDisponivel(evento: EventoRecebido): Promise<void>;
onConselheiroCadastrado(evento: EventoRecebido): Promise<void>;
onConselheiroCicloConcluido(evento: EventoRecebido): Promise<void>;
onConselheiroAtribuicaoRecusada(evento: EventoRecebido): Promise<void>;
onDuplicidadeAgregada(evento: EventoRecebido): Promise<void>;
// Sorteio central — chamado por todos os handlers que precisam atribuir demanda
executarSorteio(
ucId: string,
demandaId: string,
dadosDemanda: {
score_final: number;
categoria_id: string;
nivel_precedencia: number;
grupo_id: string | null;
correlacao_id: string;
event_id_origem: string;
},
opcoes?: { excluirConselheiroId?: string },
): Promise<ResultadoSorteio>;
// Stub para Fase 2
onConselheiroElegibilidadeAtualizada(evento: EventoRecebido): Promise<void>;
}
// D6aService consome 5 eventos em produção (um deles da D-12) + 1 stub,
// publica 'conselheiro.sorteado' e 'sorteio.sem_candidatos'

A classe é interna ao módulo. Nenhuma outra colônia injeta D6aService. A comunicação com o exterior é via barramento.

1.5 Colônia de eventos com leitura do próprio estado

Seção intitulada “1.5 Colônia de eventos com leitura do próprio estado”

A D-6a não tem BFF acoplado. É uma colônia de processamento: escuta eventos, mantém estado próprio, executa sorteio e publica resultados. O input de escrita é sempre via barramento. A operação de recusa, assim como o cadastro, chega via evento publicado pelo BFF da D-1a. A resposta síncrona ao front-end é responsabilidade do BFF, não da D-6a.

A colônia mantém um único endpoint de leitura do próprio schema, GET /d6a/conselheiros/me/situacao, que devolve ao titular a situação do seu cadastro (UC, nível, status, data fim da suspensão e atribuição ativa quando existe). O endpoint exige JWT com sessão Google (ConselheiroGuard) e nunca devolve dado de outro cidadão.

1.6 Separação de responsabilidades entre D-6a e D-6b

Seção intitulada “1.6 Separação de responsabilidades entre D-6a e D-6b”
Operação Responsável
Cadastro de conselheiro (entrada no sistema) BFF D-1a → publica conselheiro.cadastrado → D-6a consome
Sorteio e atribuição de demanda D-6a
Recusa de atribuição BFF D-1a (publica conselheiro.atribuicao_recusada) → D-6a (handler)
Liberação de conselheiro após ciclo D-6a (consome conselheiro.ciclo_concluído da D-6b)
Primeiro contato formal (início da demanda) D-6b
Atualizações de acompanhamento D-6b
Conclusão do ciclo D-6b
Avaliação do conselheiro D-6b (Fase 2: D-17)
Progressão entre níveis D-17 (Fase 2)
Suspensão por múltiplas recusas D-6a

Todas as tabelas da D-6a residem no schema d6a do PostgreSQL. Este schema é de uso exclusivo do módulo D-6a. Nenhuma outra colônia lê ou escreve nestas tabelas.

Registro de todos os cidadãos que se cadastraram como conselheiros. Cada linha representa um conselheiro em uma unidade cívica específica. Um mesmo cidadão pode ter múltiplos registros (um por nível de atuação). A cobertura do sorteio é a própria UC de atuação e as unidades menores dentro dela, independentemente do nível cadastrado; a progressão entre níveis continua na Fase 2 (D-17).

CREATE SCHEMA IF NOT EXISTS d6a;
CREATE TABLE d6a.conselheiros (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
cidadao_id UUID NOT NULL,
unidade_civica_id UUID NOT NULL,
nivel_atuacao INTEGER NOT NULL DEFAULT 1,
status VARCHAR(20) NOT NULL DEFAULT 'disponivel',
capacitacao_concluida BOOLEAN NOT NULL DEFAULT false,
data_cadastro TIMESTAMPTZ(2) NOT NULL DEFAULT NOW(),
data_ultima_atribuicao TIMESTAMPTZ(2),
data_suspensao_ate TIMESTAMPTZ(2),
event_id_cadastro UUID NOT NULL,
event_id_ultima_alteracao UUID NOT NULL,
atualizado_em TIMESTAMPTZ(2) NOT NULL DEFAULT NOW(),
CONSTRAINT conselheiros_cidadao_id_unidade_civica_id_nivel_atuacao_key
UNIQUE (cidadao_id, unidade_civica_id, nivel_atuacao)
);
CREATE INDEX conselheiros_unidade_civica_id_status_idx
ON d6a.conselheiros (unidade_civica_id, status);
CREATE INDEX conselheiros_cidadao_id_idx
ON d6a.conselheiros (cidadao_id);
CREATE INDEX conselheiros_event_id_cadastro_idx
ON d6a.conselheiros (event_id_cadastro);

O banco não tem CHECKs de faixa nem de enum. A validação de nivel_atuacao (1 a 7) e de status (disponivel, ocupado, inativo, suspenso) fica em aplicação.

Coluna Tipo Descrição
id UUID PK Identificador interno do registro de conselheiro. No MVP o id é o próprio cidadao_id (identidade unificada). A separação formal entre registro de conselheiro e cidadão retorna na Fase 2 com CPF/gov.br.
cidadao_id UUID FK lógica para o cidadão na C-1 (embarcada no BFF D-1a no MVP). Sem constraint formal — regra de isolamento.
unidade_civica_id UUID UC onde o conselheiro se candidatou. Extraído do payload de conselheiro.cadastrado.
nivel_atuacao INTEGER Nível da UC de atuação escolhida no cadastro, de 1 a 7. O web envia o nível da UC selecionada e a API armazena o valor recebido. A elegibilidade não filtra por esse campo: o sorteio percorre a cadeia de UCs. A progressão formal entre níveis é Fase 2 (D-17).
status VARCHAR(20) Estado atual do conselheiro. Ver transições em 4.6.
capacitacao_concluida BOOLEAN Se completou a capacitação básica. Extraído do payload de conselheiro.cadastrado 1.1.0.
data_cadastro TIMESTAMPTZ(2) Quando o registro foi criado.
data_ultima_atribuicao TIMESTAMPTZ(2) Última vez que foi sorteado. Nulo se nunca foi sorteado.
data_suspensao_ate TIMESTAMPTZ(2) Data até a qual está suspenso. Nulo se não suspenso.
event_id_cadastro UUID event_id do evento conselheiro.cadastrado que originou o registro. Para idempotência.
event_id_ultima_alteracao UUID event_id do último evento que alterou status ou dados. Para rastreamento.
atualizado_em TIMESTAMPTZ(2) Última alteração no registro.

O MVP usa identidade unificada: inserirConselheiro cria o registro com id = cidadao_id. Todo evento de conselheiro carrega o cidadao_id como conselheiro_id, e os dados do conselheiro na D-6a são tratados como identificadores do titular na eliminação da N-0d (d6a.conselheiros.cidadao_id, d6a.atribuicoes.conselheiro_id e d6a.contador_recusas.conselheiro_id). A separação formal entre os dois identificadores retorna na Fase 2, com a identidade verificada por CPF/gov.br.

Histórico de todas as atribuições. Cada linha é um sorteio realizado, bem-sucedido ou não. Registro append-only: uma atribuição recusada gera nova linha na re-sorteio, mas a linha da atribuição recusada permanece.

CREATE TABLE d6a.atribuicoes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
conselheiro_id UUID NOT NULL,
demanda_id UUID NOT NULL,
unidade_civica_id UUID NOT NULL,
seed_publico VARCHAR(128) NOT NULL,
metodo_sorteio VARCHAR(30) NOT NULL DEFAULT 'fisher_yates',
posicao_sorteada INTEGER NOT NULL,
total_elegiveis INTEGER NOT NULL,
lista_elegiveis_hash VARCHAR(64),
status VARCHAR(20) NOT NULL DEFAULT 'ativa',
data_sorteio TIMESTAMPTZ(2) NOT NULL DEFAULT NOW(),
event_id_sorteio UUID NOT NULL,
event_id_encerramento UUID,
score_final NUMERIC(12,2) NOT NULL DEFAULT 0,
categoria_id VARCHAR(10) NOT NULL DEFAULT '',
nivel_precedencia INTEGER NOT NULL DEFAULT 1,
grupo_id UUID,
criado_em TIMESTAMPTZ(2) NOT NULL DEFAULT NOW(),
CONSTRAINT atribuicoes_event_id_sorteio_key
UNIQUE (event_id_sorteio)
);
CREATE INDEX atribuicoes_conselheiro_id_idx
ON d6a.atribuicoes (conselheiro_id);
CREATE INDEX atribuicoes_demanda_id_idx
ON d6a.atribuicoes (demanda_id);
CREATE INDEX atribuicoes_unidade_civica_id_status_idx
ON d6a.atribuicoes (unidade_civica_id, status);
CREATE INDEX atribuicoes_conselheiro_id_status_idx
ON d6a.atribuicoes (conselheiro_id, status);

Sem CHECKs de posicao_sorteada, de total_elegiveis e de status no banco. Os valores são garantidos em aplicação.

Coluna Tipo Descrição
id UUID PK Identificador interno da atribuição.
conselheiro_id UUID FK lógica para d6a.conselheiros.id.
demanda_id UUID FK lógica para o registro de demanda na D-1a.
unidade_civica_id UUID UC onde ocorreu o sorteio.
seed_publico VARCHAR(128) Seed HMAC publicado para auditoria do sorteio.
metodo_sorteio VARCHAR(30) Sempre fisher_yates no MVP.
posicao_sorteada INTEGER Índice do conselheiro selecionado na lista ordenada (0-based).
total_elegiveis INTEGER Número de elegíveis no momento do sorteio.
lista_elegiveis_hash VARCHAR(64) SHA-256 da lista de conselheiro_id ordenada pelo seed. Para verificação externa.
status VARCHAR(20) ativa (em andamento), recusada (conselheiro recusou), concluida (ciclo encerrado pela D-6b).
data_sorteio TIMESTAMPTZ(2) Momento do sorteio.
event_id_sorteio UUID event_id de conselheiro.sorteado associado. UNIQUE — idempotência.
event_id_encerramento UUID event_id do evento que encerrou (conselheiro.atribuicao_recusada ou conselheiro.ciclo_concluído).
score_final NUMERIC(12,2) Score da demanda no ranking no momento do sorteio. Snapshot usado no re-sorteio após recusa.
categoria_id VARCHAR(10) Categoria da demanda no momento do sorteio. Snapshot usado no re-sorteio após recusa.
nivel_precedencia INTEGER Nível de precedência no momento do sorteio. Snapshot usado no re-sorteio após recusa.
grupo_id UUID Agrupamento territorial no momento do sorteio, se aplicável. Snapshot usado no re-sorteio após recusa.
criado_em TIMESTAMPTZ(2) Timestamp de criação do registro.

Fila interna de demandas que receberam agenda.item_disponível mas não encontraram conselheiro elegível. Quando um conselheiro fica disponível na mesma UC, esta fila é varrida para disparar um novo sorteio. Demandas permanecem na fila até serem atribuídas com sucesso.

CREATE TABLE d6a.fila_pendentes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
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,
grupo_id UUID,
data_entrada TIMESTAMPTZ(2) NOT NULL DEFAULT NOW(),
event_id_origem UUID NOT NULL,
tentativas_sorteio INTEGER NOT NULL DEFAULT 0,
CONSTRAINT fila_pendentes_demanda_id_key
UNIQUE (demanda_id)
);
CREATE INDEX fila_pendentes_unidade_civica_id_data_entrada_idx
ON d6a.fila_pendentes (unidade_civica_id, data_entrada ASC);

Sem CHECK de nivel_precedencia no banco.

Coluna Tipo Descrição
id UUID PK Identificador interno da entrada na fila.
demanda_id UUID Demanda aguardando conselheiro. UNIQUE — uma demanda só aparece uma vez na fila.
unidade_civica_id UUID UC da demanda. Chave de busca quando um conselheiro fica disponível.
categoria_id VARCHAR(10) Categoria da demanda. Metadado para log e auditoria.
nivel_precedencia INTEGER Nível de precedência.
score_final NUMERIC(12,2) Score da demanda no ranking.
grupo_id UUID ID do agrupamento territorial, se aplicável.
data_entrada TIMESTAMPTZ(2) Quando entrou na fila. Define ordem de atendimento (FIFO).
event_id_origem UUID event_id do evento que originou a entrada (agenda.item_disponível ou a recusa que re-sorteou a demanda). Para idempotência.
tentativas_sorteio INTEGER Incrementado a cada tentativa de sorteio malsucedida. Para monitoramento.

Contador de recusas consecutivas por conselheiro. Reseta quando o conselheiro aceita uma atribuição. É separado da tabela conselheiros para manter histórico de recusas como fato imutável.

CREATE TABLE d6a.contador_recusas (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
conselheiro_id UUID NOT NULL,
recusas_consecutivas INTEGER NOT NULL DEFAULT 1,
data_ultima_recusa TIMESTAMPTZ(2) NOT NULL DEFAULT NOW(),
demanda_id_recusada UUID NOT NULL,
evento_id_recusa UUID NOT NULL,
CONSTRAINT contador_recusas_evento_id_recusa_key
UNIQUE (evento_id_recusa)
);
CREATE INDEX contador_recusas_conselheiro_id_idx
ON d6a.contador_recusas (conselheiro_id);
Coluna Tipo Descrição
id UUID PK Identificador interno.
conselheiro_id UUID FK lógica para d6a.conselheiros.id.
recusas_consecutivas INTEGER Contagem atual de recusas consecutivas.
data_ultima_recusa TIMESTAMPTZ(2) Timestamp da última recusa registrada.
demanda_id_recusada UUID Demanda que foi recusada.
evento_id_recusa UUID event_id de conselheiro.atribuicao_recusada. UNIQUE — idempotência.

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 d6a.consumer_offset (
tipo_evento VARCHAR(255) PRIMARY KEY,
last_sequence BIGINT NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ(2) NOT NULL DEFAULT NOW()
);

No MVP, a tabela tem 6 linhas, uma por tipo acompanhado: agenda.item_disponível, conselheiro.cadastrado, conselheiro.ciclo_concluído, conselheiro.atribuicao_recusada, conselheiro.elegibilidade_atualizada e duplicidade.agregada.

Duas migrations:

  1. 20260811191728_create_d6a_tables — Cria o schema d6a e as tabelas conselheiros, atribuicoes, fila_pendentes, contador_recusas e consumer_offset, com as constraints UNIQUE e os índices descritos nas seções 2.2 a 2.6.
  2. 20260918160000_d6a_atribuicoes_demanda_snapshot — Adiciona score_final, categoria_id, nivel_precedencia e grupo_id a d6a.atribuicoes, preenchidos a cada sorteio com os dados da demanda e reutilizados no re-sorteio após recusa. As linhas anteriores à migration recebem os defaults (0, string vazia, 1 e nulo).

O seed dos cursores acontece em runtime no iniciar(): seedOffsets() cria uma linha por tipo consumido com obterMaiorSequence(), sem sobrescrever cursor existente e sem seed zero.

Migrations futuras (Fase 2): adição de coluna nivel_progressao em conselheiros para controle multi-nível, índices compostos para queries de elegibilidade por nível.

Não há FKs formais entre tabelas do schema d6a. Os campos conselheiro_id e demanda_id são correlações lógicas. O acoplamento é fraco por design: se uma tabela for migrada para outro schema no futuro, nenhuma constraint de banco impede.

demanda_id aparece em atribuicoes, fila_pendentes e contador_recusas. conselheiro_id aparece em atribuicoes e contador_recusas. São referências lógicas, não estruturais.

contador_recusas como tabela separada de conselheiros. O contador é um fato: cada recusa gera uma linha. A tabela conselheiros mantém o estado atual (status suspenso, data_suspensao_ate). Separar fato de estado permite auditoria completa do histórico de recusas sem poluir a entidade principal com séries temporais. O cálculo de recusas_consecutivas é feito no handler: ao processar conselheiro.atribuicao_recusada, a D-6a busca a data da última atribuição concluída do conselheiro (buscarUltimaAceitacao) e conta as recusas registradas depois dela (contarRecusasDesdeData).

UNIQUE em demanda_id na fila_pendentes. Uma demanda não pode estar duas vezes na fila. Se agenda.item_disponível for republicado pela D-5 (rebuild que reverte atribuido), a D-6a tenta inserir na fila e, se já existe, ignora. A fila representa o conjunto de demandas que estão aguardando. Inserções duplicadas são inofensivas se detectadas.

Índice composto atribuicoes_conselheiro_id_status_idx. A query “este conselheiro tem atribuição ativa?” é executada em todo filtro de elegibilidade. O índice cobre o par (conselheiro_id, status) e atende a busca por atribuições ativas sem indexar as linhas encerradas de forma diferenciada; no volume do MVP, o filtro por status resolve.

Ausência de FK formal em atribuicoes.conselheiro_id → conselheiros.id. Se a D-6a evoluir para microsserviço com banco separado, FKs cross-schema seriam um obstáculo. A integridade referencial é garantida em aplicação: o handler de conselheiro.atribuicao_recusada verifica que o conselheiro_id existe antes de processar.


A D-6a consome cinco tipos de evento como gatilho de processamento, mais um stub da Fase 2, e produz dois. 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-6a.

3.1 Evento consumido: agenda.item_disponível (gatilho primário)

Seção intitulada “3.1 Evento consumido: agenda.item_disponível (gatilho primário)”
Propriedade Valor
Tipo agenda.item_disponível
Schema version 1.0.0
Produtor D-5 (Agenda)
Consumidor D-6a (esta colônia)
Descrição Uma demanda entrou no topo do backlog e está disponível para atribuição a um conselheiro.

Payload esperado (conforme Registry N-0b, v1.0.0):

interface AgendaItemDisponivelPayload {
demanda_id: string;
unidade_civica_id: string;
posicao_no_backlog: number; // Posição no backlog da UC
score_final: number;
categoria_id: string;
nivel_precedencia: number; // 1-5
grupo_id: string | null; // Nulo se demanda solo
timestamp: string; // ISO-8601, campo adicional publicado pela D-5
}
Propriedade Valor
Tipo conselheiro.cadastrado
Schema version 1.1.0 (a 1.0.0 permanece no catálogo)
Produtor BFF da D-1a
Consumidor D-6a (esta colônia)
Descrição Novo cidadão se cadastrou como candidato a conselheiro. A versão 1.1.0 acrescenta capacitacao_concluida.

Payload esperado (v1.1.0):

interface ConselheiroCadastradoPayload {
conselheiro_id: string;
cidadao_id: string;
unidade_civica_id: string;
nivel_atuacao: number; // 1-7, nível da UC escolhida no cadastro
status: string; // 'disponivel'
capacitacao_concluida: boolean; // Obrigatório na 1.1.0
}

Na 1.0.0, o schema não tem capacitacao_concluida. A D-6a trata a ausência do campo como true, no pressuposto de que o cadastro só é possível após a capacitação. O BFF D-1a publica a 1.1.0 com o campo preenchido, e o cadastro exige capacitacao_concluida = true.

3.3 Evento consumido: conselheiro.ciclo_concluído

Seção intitulada “3.3 Evento consumido: conselheiro.ciclo_concluído”
Propriedade Valor
Tipo conselheiro.ciclo_concluído
Schema version 1.0.0
Produtor D-6b (Relatoria e Acompanhamento)
Consumidores D-6a (esta colônia), D-7 (Transparência)
Descrição Conselheiro concluiu o ciclo de acompanhamento de uma demanda. O conselheiro é liberado para nova atribuição.

Payload esperado (conforme Registry N-0b, v1.0.0):

interface ConselheiroCicloConcluidoPayload {
conselheiro_id: string;
demanda_id: string;
unidade_civica_id: string;
motivo_encerramento: string; // 'demanda_concluida' | 'fim_mandato' | 'desistencia'
total_atualizacoes: number;
data_inicio: string; // ISO-8601
data_fim: string; // ISO-8601
dias_duracao?: number;
resumo_final?: string;
}

3.4 Evento consumido: conselheiro.atribuicao_recusada

Seção intitulada “3.4 Evento consumido: conselheiro.atribuicao_recusada”
Propriedade Valor
Tipo conselheiro.atribuicao_recusada
Schema version 1.1.0 (a 1.0.0 permanece no catálogo)
Produtor BFF da D-1a
Consumidor D-6a (esta colônia)
Descrição Conselheiro recusou explicitamente a atribuição de uma demanda. O BFF recebe a requisição do front-end, publica o evento e retorna confirmação ao usuário. A D-6a processa o re-sorteio de forma assíncrona.

Payload esperado (v1.1.0):

interface ConselheiroAtribuicaoRecusadaPayload {
conselheiro_id: string;
demanda_id: string;
unidade_civica_id: string;
motivo?: 'recusa_explicita' | 'risco_pessoal' | 'timeout_resposta' | 'fora_do_territorio';
timestamp: string; // ISO-8601
}

A versão 1.0.0 do schema tem os motivos recusa_explicita, timeout_resposta e fora_do_territorio. A 1.1.0 acrescenta risco_pessoal, que encerra a atribuição e re-sorteia a demanda como qualquer recusa, mas não insere linha em d6a.contador_recusas e não suspende o conselheiro. Sem o campo motivo, a D-6a aplica recusa_explicita. A semântica está no handler da seção 3.12.

O BFF da D-1a recebe a requisição do front-end, publica este evento no barramento e retorna confirmação síncrona ao usuário. O processamento real (contador, suspensão, re-sorteio) é feito pelo handler da D-6a de forma assíncrona. O front-end não recebe o resultado do re-sorteio na mesma resposta HTTP. Para saber se um novo conselheiro foi designado, consulta a timeline (D-7).

3.5 Evento consumido: conselheiro.elegibilidade_atualizada (stub Fase 2)

Seção intitulada “3.5 Evento consumido: conselheiro.elegibilidade_atualizada (stub Fase 2)”
Propriedade Valor
Tipo conselheiro.elegibilidade_atualizada
Schema version 1.0.0
Produtor D-17 (Controle de Mandato e Progressão — Fase 2)
Consumidor D-6a (esta colônia — stub no MVP)
Descrição Mudança nos critérios de elegibilidade: histórico de atuação, progressão de nível.

Payload esperado (stub — processamento adiado para Fase 2):

interface ConselheiroElegibilidadeAtualizadaPayload {
unidade_civica_id: string;
versao_parametros: string;
}
Propriedade Valor
Tipo duplicidade.agregada
Schema version 1.0.0
Produtor D-12 (Detecção de Duplicidade)
Consumidor D-6a (esta colônia)
Descrição Demandas equivalentes foram agregadas sob um representante. Os membros do agregado saem do sorteio. A D-6a remove suas entradas da fila de pendentes.

Payload esperado (conforme Registry N-0b, v1.0.0):

interface DuplicidadeAgregadaPayload {
agregado_id: string;
representante_demanda_id: string;
membros: Array<{ demanda_id: string }>;
mecanismo: string; // 'confirmacao_coletiva'
confirmacoes_gatilho: number;
criterios: Record<string, unknown>;
}

A remoção percorre membros e chama removerFilaPendentePorDemanda para cada demanda_id. A demanda representante permanece na fila. Payload sem agregado_id ou representante_demanda_id é descartado com log.error e o cursor avança. O evento não tem guarda de idempotência própria: a remoção é idempotente por natureza.

Propriedade Valor
Tipo conselheiro.sorteado
Schema version 1.0.0
Produtor D-6a (esta colônia)
Consumidores D-6b (Relatoria), D-5 (Agenda, marca o item como atribuído), D-7 (Transparência) e D-12 (Detecção de Duplicidade, confirma o status atribuida da candidata)
Descrição Um conselheiro foi sorteado e atribuído a uma demanda. O seed do sorteio é público e verificável.

Payload publicado (campos do catálogo; timestamp é campo adicional):

interface ConselheiroSorteadoPayload {
conselheiro_id: string;
demanda_id: string;
unidade_civica_id: string;
metodo_sorteio: string; // 'fisher_yates'
seed_publico: string; // HMAC-SHA256 para verificação independente
lista_elegiveis_hash?: string; // SHA-256 da lista ordenada de elegíveis
posicao_sorteada: number; // Índice 0-based na lista
total_elegiveis: number; // Número de elegíveis no momento
timestamp: string; // ISO-8601
}
Propriedade Valor
Tipo sorteio.sem_candidatos
Schema version 1.0.0
Produtor D-6a (esta colônia)
Consumidores Sistema de notificação (Fase 2). Nenhuma colônia consome o evento no MVP.
Descrição Há demanda disponível para atribuição mas nenhum conselheiro elegível na UC.

Payload publicado (campos do catálogo; timestamp é campo adicional):

interface SorteioSemCandidatosPayload {
demanda_id: string;
unidade_civica_id: string;
total_elegiveis: number; // Será 0
motivo: string; // 'nenhum_cadastrado' | 'todos_ocupados' | 'todos_inativos'
timestamp: string; // ISO-8601
}

3.9 Ordem de operações no handler onAgendaItemDisponivel

Seção intitulada “3.9 Ordem de operações no handler onAgendaItemDisponivel”

O handler chama executarSorteio, que roda sob a fila global de sorteio. Com a cobertura por descendentes, os pools de UCs diferentes se sobrepõem; a fila global serializa toda execução de sorteio.

1. Verificar idempotência do evento
→ buscarFilaPendentePorEventIdOrigem(event_id)
→ se encontrado: log, avançar o cursor de 'agenda.item_disponível' e retornar
2. Validar payload mínimo
→ demanda_id, unidade_civica_id, categoria_id e nivel_precedencia obrigatórios
→ se ausente: log.error, avançar o cursor e retornar
3. Verificar atribuição ativa da demanda
→ buscarAtribuicaoAtivaPorDemanda(demanda_id)
→ se encontrado: log.warn "demanda já atribuída", avançar o cursor e retornar
4. Verificar demanda já na fila de pendentes
→ buscarFilaPendentePorDemandaId(demanda_id)
→ se encontrado: log, avançar o cursor e retornar
5. Executar o sorteio
→ executarSorteio(unidade_civica_id, demanda_id, {
score_final, categoria_id, nivel_precedencia, grupo_id,
correlacao_id: correlacao_id do evento ?? demanda_id,
event_id_origem: event_id
})
6. Avançar o cursor de 'agenda.item_disponível'

O filtro de elegibilidade, a persistência e as publicações ficam em executarSorteio (seções 4.3 e 4.4). O cursor avança em todos os caminhos terminais do handler.

3.10 Ordem de operações no handler onConselheiroCadastrado

Seção intitulada “3.10 Ordem de operações no handler onConselheiroCadastrado”
1. Verificar idempotência
→ buscarConselheiroPorEventIdCadastro(event_id)
→ se encontrado: log, avançar o cursor e retornar
2. Validar payload mínimo
→ conselheiro_id, cidadao_id e unidade_civica_id obrigatórios
→ se ausente: log.error, avançar o cursor e retornar
3. Persistir o conselheiro
→ nivel_atuacao default 1; status default 'disponivel'; capacitacao_concluida default true
→ inserirConselheiro grava id = cidadao_id (identidade unificada)
→ violação de unicidade (mesmo cidadão, UC e nível): log, avançar o cursor e retornar
→ erro de outra natureza é relançado para a DLQ, sem avançar o cursor
4. Verificar a fila de pendentes da UC e dos descendentes
→ pendente = buscarPrimeiroPendentePorUcs([unidade_civica_id, ...descendentes(uc)])
(ordenação por data_entrada ASC)
→ se a demanda do pendente já tem atribuição ativa: removerFilaPendentePorDemanda e encerrar
→ senão: executarSorteio para o pendente
→ sucesso: removerFilaPendentePorDemanda
→ falha: incrementarTentativasFilaPendente
5. Avançar o cursor de 'conselheiro.cadastrado'

3.11 Ordem de operações no handler onConselheiroCicloConcluido

Seção intitulada “3.11 Ordem de operações no handler onConselheiroCicloConcluido”
1. Verificar idempotência
→ buscarAtribuicaoPorEventIdEncerramento(event_id)
→ se encontrado: log, avançar o cursor e retornar
2. Validar payload mínimo
→ conselheiro_id, demanda_id e motivo_encerramento obrigatórios
→ se ausente: log.error, avançar o cursor e retornar
3. Buscar o conselheiro
→ buscarConselheiroPorId(conselheiro_id)
→ se não encontrado: log.warn e varrer a fila da UC informada no payload
(buscarPrimeiroPendentePorUcs com a UC e os descendentes, sorteio, remoção no sucesso e incremento na falha)
→ avançar o cursor e retornar
4. Liberar o conselheiro e encerrar a atribuição
→ atualizarStatusConselheiro(conselheiro.id, 'disponivel', event_id)
→ buscarAtribuicaoAtivaPorConselheiroEDemanda e, se houver,
atualizarStatusAtribuicao(atribuicao.id, 'concluida', event_id)
5. Varrer a fila de pendentes da UC e dos descendentes
→ ucId = unidade_civica_id do payload ?? conselheiro.unidade_civica_id
→ pendente = buscarPrimeiroPendentePorUcs([ucId, ...descendentes(ucId)])
→ se a demanda do pendente já tem atribuição ativa: removerFilaPendentePorDemanda e encerrar
→ senão: executarSorteio para o pendente
→ sucesso: removerFilaPendentePorDemanda
→ falha: incrementarTentativasFilaPendente
6. Avançar o cursor de 'conselheiro.ciclo_concluído'

O ciclo concluído conta como aceitação. O contador de recusas considera apenas as recusas registradas depois da última atribuição concluída do conselheiro.

3.12 Ordem de operações no handler onConselheiroAtribuicaoRecusada

Seção intitulada “3.12 Ordem de operações no handler onConselheiroAtribuicaoRecusada”
1. Verificar idempotência
→ buscarRecusasPorEventoId(event_id) e buscarAtribuicaoPorEventIdEncerramento(event_id)
→ se qualquer um encontrado: log, avançar o cursor e retornar
2. Validar payload mínimo
→ conselheiro_id, demanda_id e unidade_civica_id obrigatórios
→ se ausente: log.error, avançar o cursor e retornar
3. Buscar a atribuição ativa do par conselheiro + demanda
→ buscarAtribuicaoAtivaPorConselheiroEDemanda
→ se não encontrada: log.warn e varrer a fila da UC informada no payload
→ avançar o cursor e retornar
4. Encerrar a atribuição e registrar a recusa
→ atualizarStatusAtribuicao(atribuicao.id, 'recusada', event_id)
→ buscarConselheiroPorCidadao(conselheiro_id): com a identidade unificada, o id do
registro é o cidadao_id do payload
→ se o conselheiro existe e está 'ocupado': atualizarStatusConselheiro(conselheiro_id,
'disponivel', event_id). O conselheiro suspenso não é liberado.
→ se motivo = 'risco_pessoal': log, sem linha em contador_recusas e sem avaliar suspensão
→ senão:
→ ultimaAceitacao = buscarUltimaAceitacao(conselheiro_id), a data da última
atribuição 'concluida'
→ recusas = contarRecusasDesdeData(conselheiro_id, ultimaAceitacao)
→ inserirRecusa com recusas_consecutivas = recusas + 1
→ se recusas + 1 > limiteRecusasSuspensao (3): suspenderConselheiro com
data_suspensao_ate = agora + periodoSuspensaoDias (90) e log.warn
5. Re-sortear a mesma demanda
→ executarSorteio(unidade_civica_id, demanda_id, { score_final, categoria_id,
nivel_precedencia, grupo_id } da atribuição recusada, { excluirConselheiroId })
→ o payload da recusa não carrega os dados da demanda; o snapshot persistido na
atribuição (seção 2.3) alimenta o re-sorteio e a entrada na fila de pendentes
→ sucesso: log
→ falha: a demanda entra na fila (ou tem as tentativas incrementadas) e
`sorteio.sem_candidatos` é publicado
6. Avançar o cursor de 'conselheiro.atribuicao_recusada'

A suspensão acontece na quarta recusa consecutiva. A comparação é estrita (recusasConsecutivas > 3), então o limite de 3 suspende quando a contagem chega a 4.

Cenário Comportamento
Evento agenda.item_disponível reentregue (replay/DLQ) Detectado por event_id em d6a.fila_pendentes.event_id_origem ou por atribuição ativa da demanda. Loga e avança o cursor sem re-sortear. O re-sorteio de pendentes ocorre nos handlers de cadastro/recusa/ciclo e na varredura do boot.
Evento agenda.item_disponível para demanda já atribuída Detectado por demanda_id em d6a.atribuicoes com status = 'ativa'. Log.warn, avança o cursor e retorna.
Evento agenda.item_disponível para demanda já na fila Detectado por demanda_id em d6a.fila_pendentes. Log, avança o cursor e retorna.
Evento conselheiro.cadastrado reentregue Detectado por event_id_cadastro. Log, avança o cursor e retorna.
Evento conselheiro.ciclo_concluído reentregue Detectado por event_id_encerramento. Log, avança o cursor e retorna.
Evento conselheiro.atribuicao_recusada reentregue Detectado por evento_id_recusa ou por event_id_encerramento na atribuição. Log, avança o cursor e retorna.
Payload incompleto em qualquer handler Log.error. O cursor avança em todos os handlers (agenda.item_disponível, conselheiro.cadastrado, conselheiro.ciclo_concluído e conselheiro.atribuicao_recusada).
Erro de banco no INSERT de conselheiro.cadastrado que não seja violação de unicidade Erro relançado para o EventBus, que registra na DLQ, e cursor não avançado. O replay do boot retenta.
conselheiro.atribuicao_recusada processada O re-sorteio usa o snapshot da demanda persistido em d6a.atribuicoes (score_final, categoria_id, nivel_precedencia e grupo_id). Se o sorteio falhar, a fila de pendentes guarda os mesmos dados.
agenda.item_disponível para UC sem conselheiros cadastrados Filtro retorna lista vazia. sorteio.sem_candidatos publicado com motivo nenhum_cadastrado. Demanda entra na fila.
agenda.item_disponível para UC com conselheiros mas todos ocupados Filtro retorna lista vazia. sorteio.sem_candidatos publicado com motivo todos_ocupados. Demanda entra na fila.
agenda.item_disponível para UC com conselheiros mas todos suspensos ou sem capacitação sorteio.sem_candidatos publicado com motivo todos_inativos. Demanda entra na fila.
conselheiro.ciclo_concluído para conselheiro não cadastrado na D-6a Log.warn, varredura da fila da UC informada no payload (sorteio ou incremento de tentativas) e cursor avançado.
conselheiro.atribuicao_recusada sem atribuição ativa correspondente Log.warn, varredura da fila da UC informada no payload e cursor avançado.
Nova recusa de conselheiro já suspenso Registra linha no contador e reagenda a suspensão. A reentrega do mesmo evento é barrada pela unicidade de evento_id_recusa.
Fila de pendentes tem múltiplas demandas; conselheiro liberado Sorteio para a primeira (FIFO). Demais permanecem. Se o sorteio falhar, o incremento de tentativas_sorteio acontece uma única vez, dentro de executarSorteio.
INSERT em d6a.atribuicoes falha (unique event_id_sorteio) Indica caminho duplicado. O erro propaga para a DLQ e o cursor não avança.
Publish de conselheiro.sorteado falha após as tentativas do publicarComRetry Log.error. Estado persistido (atribuição criada, conselheiro marcado ocupado). Erro relançado para a DLQ. Cursor não avança.
Publish de sorteio.sem_candidatos falha após as tentativas do publicarComRetry Log.error. Entrada na fila já persistida. Erro relançado para a DLQ. Cursor não avança. Antes de publicar, o handler consulta o log por correlacao_id: se o evento já existe, não republica.
duplicidade.agregada para membros fora da fila removerFilaPendentePorDemanda não encontra linhas e não falha. Cursor avança.
Seed generator sem último evento do barramento Fallback: gerar(undefined, timestampSorteio) usa um UUID v4 no lugar do último event_id. O sorteio segue determinístico, mas o seed perde o vínculo com o barramento. Log.warn.

Recusa via evento no barramento, publicado pelo BFF da D-1a, não via HTTP direto na D-6a. A alternativa seria a D-6a expor um endpoint REST próprio para recusa. Isso violaria o princípio de que o BFF é o único ponto de entrada HTTP do sistema, criando duas superfícies de entrada para o front-end. Com o BFF publicando o evento, a D-6a permanece uma colônia pura de eventos, no mesmo padrão das demais colônias do pipeline (D-2, D-3, D-4, D-5). A diferença é que o evento de recusa é iniciado pelo front-end, e não derivado de outro evento de negócio. O mecanismo de publicação é o mesmo.

Fila interna de pendentes em vez de depender de re-publicação da D-5. Quando sorteio.sem_candidatos é publicado, a demanda continua no backlog da D-5 com status disponivel. Mas a D-5 não republica agenda.item_disponível para ela. Se a D-6a dependesse exclusivamente desse evento, um conselheiro liberado nunca seria pareado com uma demanda pendente. A fila interna resolve o gap sem alterar o contrato da D-5.

Contador de recusas baseado em “desde a última aceitação”, não em janela de tempo. Recusas em janela de tempo (ex: 3 recusas em 30 dias) seriam contornáveis: o conselheiro recusa, espera 31 dias, recusa de novo. O critério “desde a última aceitação” é imune a estratégias de temporização. Cada aceitação (ciclo concluído) reseta o contador. O conselheiro que nunca aceitou nada acumula recusas até a suspensão.

Re-sorteio da mesma demanda após recusa, sem devolvê-la à D-5. A demanda recusada já passou pelo pipeline completo (D-2, D-3, D-4, D-5). Devolvê-la à D-5 criaria um loop desnecessário e potencialmente infinito se a D-5 a reenfileirasse. A D-6a mantém a demanda em seu próprio fluxo até encontrar um conselheiro que aceite.

Seed derivado do último event_id do barramento e do timestamp do sorteio. Usar apenas o timestamp seria previsível. O event_id do último evento no barramento é imprevisível (UUID v4 gerado por outra colônia) e público (qualquer um pode consultar o event_log). Combinado com o timestamp ISO-8601 do momento, forma um seed que ninguém no sistema consegue predeterminar, mas qualquer um pode verificar depois.


4.1 SeedGenerator.gerar(eventIdAnterior, timestampIso) — pseudocódigo

Seção intitulada “4.1 SeedGenerator.gerar(eventIdAnterior, timestampIso) — pseudocódigo”
função gerar(eventIdAnterior?: string, timestampIso?: string) -> string:
base = eventIdAnterior ?? uuidv4()
payload = base + ":" + (timestampIso ?? "")
seed = HMAC-SHA256(
key = "d6a-sorteio", // chave fixa, pública
message = payload
)
retornar seed // hex string de 64 caracteres

O sorteio chama gerar com o último event_id do barramento, obtido por eventBus.obterUltimoEventId(), e com o timestamp ISO-8601 do momento. Sem evento anterior no barramento, a base cai para UUID v4 e o log registra o fallback. O seed é recomputável: os mesmos argumentos produzem o mesmo resultado.

4.2 FisherYates.ordenar(elegiveis, seed) — pseudocódigo

Seção intitulada “4.2 FisherYates.ordenar(elegiveis, seed) — pseudocódigo”
função ordenar(elegiveis: ConselheiroElegivel[], seed: string) -> ConselheiroElegivel[]:
// Cópia da lista para não mutar o original
lista = [...elegiveis]
n = lista.length
// Gerador pseudoaleatório determinístico baseado em seed
// Usa função de hash iterativa: r_i = SHA-256(seed + i)
// Isso garante que a sequência seja reproduzível dado o mesmo seed
função randomInt(max: number, index: number) -> number:
hash = SHA-256(seed + ":" + index.toString())
// Converte os primeiros 8 caracteres hex do SHA-256 (32 bits) para inteiro
valor = parseInt(hash.substring(0, 8), 16)
retornar valor % max
// Fisher-Yates shuffle
para i de n-1 até 1:
j = randomInt(i + 1, n - 1 - i)
trocar lista[i], lista[j]
retornar lista

A implementação é portável para qualquer linguagem: dada a mesma lista e o mesmo seed, produz a mesma ordenação. O hash SHA-256 é determinístico e amplamente disponível. A função randomInt é derivada exclusivamente do seed e do índice, sem estado interno. Não é um PRNG: é um mapeamento determinístico que garante reprodutibilidade.

Verificação externa (como um auditor reproduz):

  1. Obter seed_publico e lista_elegiveis_hash do evento conselheiro.sorteado
  2. Obter a lista de conselheiros elegíveis do momento (disponível via D-7 ou log público)
  3. Ordenar a lista com o mesmo algoritmo e seed
  4. Verificar que SHA-256 da lista ordenada confere com lista_elegiveis_hash
  5. Verificar que o conselheiro na posicao_sorteada é o mesmo do evento

4.3 ElegibilidadeService.filtrar(ucId) — pseudocódigo

Seção intitulada “4.3 ElegibilidadeService.filtrar(ucId) — pseudocódigo”
função filtrar(ucId: string, opcoes?: { excluirConselheiroId?: string })
-> { elegiveis: ConselheiroElegivel[], motivo?: string, degrau: string | null, cadeia: string[] }:
// 1. Montar a cadeia ascendente da UC da demanda: [exata, pai, avô, ..., raiz]
cadeia = hierarquia.cadeiaAscendente(ucId)
// 2. Buscar de uma vez os conselheiros disponíveis na cadeia inteira
candidatos = repo.buscarConselheirosPorUcsEStatus(cadeia, 'disponivel')
// 3. Excluir conselheiros com atribuição ativa
atribuicoesAtivas = repo.buscarAtribuicoesAtivas()
idsOcupados = new Set(atribuicoesAtivas.map(a => a.conselheiro_id))
// 4. Percorrer os degraus em ordem e parar no primeiro com elegíveis
agora = new Date().toISOString()
para cada degrau em cadeia:
elegiveis = candidatos
.filter(c => c.unidade_civica_id == degrau)
.filter(c => c.capacitacao_concluida == true)
.filter(c => c.data_suspensao_ate == null || c.data_suspensao_ate < agora)
.filter(c => !idsOcupados.has(c.id))
.filter(c => c.id != opcoes?.excluirConselheiroId)
se elegiveis.length > 0:
retornar { elegiveis, degrau, cadeia }
// 5. Nenhum degrau com elegíveis: classificar o motivo sobre a cadeia inteira
totalCadastrados = repo.contarConselheirosPorUcs(cadeia)
se totalCadastrados == 0:
motivo = 'nenhum_cadastrado'
senão:
totalOcupados = repo.contarConselheirosOcupadosPorUcs(cadeia)
motivo = 'todos_ocupados' se totalCadastrados == totalOcupados senão 'todos_inativos'
retornar { elegiveis: [], motivo, degrau: null, cadeia }

O filtro faz uma única consulta de conselheiros para a cadeia inteira e percorre os degraus em ordem. O primeiro degrau com elegíveis vence. As exclusões de capacitação, suspensão vigente, atribuição ativa e recusante são aplicadas por degrau. A contagem de conselheiros ocupados usada no motivo vem das atribuições ativas da cadeia (contarConselheirosOcupadosPorUcs), e não do status ocupado da tabela de conselheiros.

Quando a UC da demanda não está na base, a cadeia contém apenas a própria UC e o comportamento cai no sorteio de UC única. A hierarquia vem do módulo compartilhado src/shared/hierarquia-uc/ (core.uc_polygons com cache em memória de 6 horas), na exceção controlada descrita em 5.6.

4.4 D6aService.executarSorteio(ucId, demandaId, dadosDemanda, opcoes?) — pseudocódigo

Seção intitulada “4.4 D6aService.executarSorteio(ucId, demandaId, dadosDemanda, opcoes?) — pseudocódigo”

Este é o método central, chamado de múltiplos pontos:

  • onAgendaItemDisponivel (nova demanda disponível)
  • onConselheiroCadastrado (novo conselheiro → verifica fila)
  • onConselheiroCicloConcluido (conselheiro liberado → verifica fila)
  • onConselheiroAtribuicaoRecusada (re-sorteio da demanda recusada)

demandaId e dadosDemanda são obrigatórios. O método entra na fila global de sorteio e delega para a execução interna.

função executarSorteio(ucId, demandaId, dadosDemanda, opcoes?) -> ResultadoSorteio:
retornar comFilaGlobal(() => executarSorteioInterno(ucId, demandaId, dadosDemanda, opcoes))
função executarSorteioInterno(ucId, demandaId, dadosDemanda, opcoes?):
// 1. Filtrar elegíveis na cadeia de UCs (degrau exato primeiro)
{ elegiveis, motivo, degrau, cadeia } = elegibilidadeService.filtrar(ucId, opcoes)
// 2. Sem elegíveis
se elegiveis.length == 0:
se repo.buscarFilaPendentePorDemandaId(demandaId) == null:
try:
repo.inserirFilaPendente({
demanda_id: demandaId,
unidade_civica_id: ucId,
categoria_id: dadosDemanda.categoria_id,
nivel_precedencia: dadosDemanda.nivel_precedencia,
score_final: dadosDemanda.score_final,
grupo_id: dadosDemanda.grupo_id,
event_id_origem: dadosDemanda.event_id_origem,
tentativas_sorteio: 1,
})
catch violação de unicidade:
log("Fila pendente já registrada para a demanda")
senão:
repo.incrementarTentativasFilaPendente(demandaId)
publicarSorteioSemCandidatos(ucId, demandaId, motivo ?? 'nenhum_cadastrado',
dadosDemanda.correlacao_id)
retornar { sucesso: false, motivo }
// 3. Executar Fisher-Yates
timestampSorteio = new Date().toISOString()
ultimoEventId = eventBus.obterUltimoEventId()
se ultimoEventId != null:
seed = seedGenerator.gerar(ultimoEventId, timestampSorteio)
senão:
log.warn("último evento do barramento indisponível — seed em fallback UUID")
seed = seedGenerator.gerar(undefined, timestampSorteio)
listaOrdenada = fisherYates.ordenar(elegiveis, seed)
selecionado = listaOrdenada[0]
listaHash = SHA-256(JSON.stringify(listaOrdenada.map(e => e.id)))
eventIdSorteio = uuidv4()
// 4. Persistir a atribuição e ocupar o conselheiro
repo.inserirAtribuicao({
conselheiro_id: selecionado.id,
demanda_id: demandaId,
unidade_civica_id: ucId,
seed_publico: seed,
posicao_sorteada: 0,
total_elegiveis: elegiveis.length,
lista_elegiveis_hash: listaHash,
event_id_sorteio: eventIdSorteio,
score_final: dadosDemanda.score_final,
categoria_id: dadosDemanda.categoria_id,
nivel_precedencia: dadosDemanda.nivel_precedencia,
grupo_id: dadosDemanda.grupo_id,
})
repo.marcarConselheiroOcupado(selecionado.id, eventIdSorteio)
// 5. Registrar a auditoria e publicar conselheiro.sorteado
log("Sorteio concluído", {
demanda_id: demandaId,
uc_id: ucId,
degrau, // UC do degrau vencedor
cadeia, // cadeia ascendente usada no filtro
total_elegiveis: elegiveis.length,
conselheiro_id: selecionado.id,
})
publicarConselheiroSorteado(...) // timestamp: timestampSorteio
retornar {
sucesso: true,
conselheiro_id: selecionado.id,
demanda_id: demandaId,
uc_id: ucId,
seed_publico: seed,
lista_hash: listaHash,
posicao_sorteada: 0,
total_elegiveis: elegiveis.length,
}

As duas escritas da atribuição e do status do conselheiro não usam transação. A remoção da linha da fila de pendentes não acontece aqui: os handlers removem quando o sorteio do pendente tem sucesso, e o incremento de tentativas_sorteio acontece dentro de executarSorteio, uma única vez.

O schema de conselheiro.sorteado não muda. total_elegiveis passa a ser o total do degrau vencedor e o log do sorteio registra degrau e cadeia para auditoria.

4.5 D6aService.iniciar() — protocolo de inicialização

Seção intitulada “4.5 D6aService.iniciar() — protocolo de inicialização”
função iniciar():
se iniciado: retornar
iniciado = true
// 1. Carregar configuração estática
carregarD6aConstants()
// 2. Semear os cursores do banco próprio
maiorSequence = eventBus.obterMaiorSequence()
repo.seedOffsets(TIPOS_EVENTO_CONSUMIDOS, maiorSequence)
// 3. Replay de eventos perdidos, tipo a tipo
offsets = repo.buscarTodosOffsets()
offsetMap = Map(offsets)
para cada tipo em TIPOS_EVENTO_CONSUMIDOS:
cursor = offsetMap.get(tipo) ?? 0
eventos = eventBus.replayDeSequence(cursor, [tipo])
para cada evento em eventos:
try:
despacharEvento(tipo, evento)
catch:
log.error(`Falha no replay de ${tipo} — evento permanece pendente`)
// 4. Registrar consumidores para eventos futuros
registrarConsumidores()
// 5. Consistência: conselheiros 'ocupado' sem atribuição ativa
try: verificarConsistenciaOcupados() catch: log.error
// 6. Fila de pendentes: tentar sortear as demandas com elegíveis disponíveis
try: processarFilaPendentes() catch: log.error
// 7. Reativar conselheiros suspensos com prazo vencido
try: reativarSuspensosVencidos() catch: log.error
log("D-6a Sorteio e Atribuição inicializada")

TIPOS_EVENTO_CONSUMIDOS tem seis entradas: agenda.item_disponível, conselheiro.cadastrado, conselheiro.ciclo_concluído, conselheiro.atribuicao_recusada, conselheiro.elegibilidade_atualizada e duplicidade.agregada. O seed usa o maior sequence_number do barramento no boot e não sobrescreve linhas existentes. Cada tipo é reprocessado a partir do próprio cursor. A flag iniciado torna a chamada idempotente.

Transições válidas de status em d6a.conselheiros:

┌─────────────┐
│ disponivel │ ← cadastro (conselheiro.cadastrado)
│ │ ← ciclo concluído (conselheiro.ciclo_concluído)
│ │ ← fim de suspensão (data_suspensao_ate < NOW)
└──────┬──────┘
│ sorteio executado (conselheiro.sorteado)
┌──────▼──────┐
│ ocupado │
└──────┬──────┘
┌─────────────┼─────────────┐
│ recusa │ ciclo │
│ (atribuicao │ concluído │
│ _recusada) │ │
┌──────▼──────┐ ┌───▼──────────┐ │
│(volta para │ │ disponivel │◄───┘
│ disponivel) │ └──────────────┘
└──────┬──────┘
│ recusas > N consecutivas
┌──────▼──────┐
│ suspenso │ ← data_suspensao_ate = NOW() + T_SUSPENSAO
└──────┬──────┘
│ data_suspensao_ate < NOW (filtro de elegibilidade e reativarSuspensosVencidos no boot)
┌──────▼──────┐
│ disponivel │ ← reabilitado ao expirar a suspensão
└─────────────┘
┌─────────────┐
│ inativo │ ← administrativo (manual, fora do escopo MVP)
└─────────────┘

Transições proibidas:

  • suspensoocupado (suspenso não é elegível)
  • inativo → qualquer outro (reativação é administrativa, Fase 2)
  • ocupadosuspenso (suspensão só ocorre a partir de disponivel, após recusa)
função processarFilaPendentes():
pendentes = repo.buscarTodosPendentes() // ORDER BY data_entrada ASC
para cada pendente em pendentes:
// Verificar se a demanda já foi atribuída enquanto o sistema estava offline
atribuicaoExistente = repo.buscarAtribuicaoAtivaPorDemanda(pendente.demanda_id)
se atribuicaoExistente != null:
repo.removerFilaPendentePorDemanda(pendente.demanda_id)
continuar
// Tentar sortear agora
resultado = executarSorteio(
pendente.unidade_civica_id,
pendente.demanda_id,
{ score_final, categoria_id, nivel_precedencia, grupo_id,
correlacao_id: pendente.demanda_id,
event_id_origem: pendente.event_id_origem }
)
se resultado.sucesso:
repo.removerFilaPendentePorDemanda(pendente.demanda_id)
log("Fila pendente: sorteio concluído após reinício")
senão:
log("Fila pendente: sem elegíveis após reinício", {
demanda_id: pendente.demanda_id,
tentativas: pendente.tentativas_sorteio + 1,
})

A varredura do boot percorre a fila inteira. Nas liberações de conselheiro (cadastro, ciclo concluído e recusa), a busca usa buscarPrimeiroPendentePorUcs([ucId, ...descendentes]), com ordenação por data_entrada ASC, para processar a pendência mais antiga entre a UC e as unidades menores dentro dela. O sorteio de cada pendente percorre a cadeia completa, então uma pendência de bairro pode ser atendida por um conselheiro da cidade.

4.8 verificarConsistenciaOcupados() — inicialização

Seção intitulada “4.8 verificarConsistenciaOcupados() — inicialização”
função verificarConsistenciaOcupados():
// Encontrar conselheiros com status 'ocupado' mas sem atribuição ativa
ocupados = repo.buscarConselheirosPorStatus('ocupado')
para cada c em ocupados:
atribuicao = repo.buscarAtribuicaoAtivaPorConselheiro(c.id)
se atribuicao == null:
log.warn("Conselheiro ocupado sem atribuição ativa. Corrigindo.", {
conselheiro_id: c.id
})
repo.atualizarStatusConselheiro(c.id, 'disponivel', uuidv4())
// motivo 'consistencia-repair' registrado no log

Este cenário pode ocorrer se o sistema cair entre o INSERT em atribuicoes e o UPDATE em conselheiros. A verificação na inicialização corrige o estado.

reativarSuspensosVencidos() — inicialização.

função reativarSuspensosVencidos():
suspensos = repo.buscarSuspensosComPrazoVencido(new Date())
para cada conselheiro em suspensos:
repo.atualizarStatusConselheiro(conselheiro.id, 'disponivel', uuidv4())
// motivo 'reativacao-prazo' registrado no log
log("Conselheiro suspenso reativado após expiração do prazo", {
conselheiro_id: conselheiro.id
})

As duas correções de boot (verificarConsistenciaOcupados e reativarSuspensosVencidos) são operações de sistema sem evento de origem. event_id_ultima_alteracao recebe um UUID gerado no momento, e o motivo (consistencia-repair ou reativacao-prazo) fica no log. O filtro de elegibilidade também aceita o conselheiro pela data, mesmo antes da reativação.

async onConselheiroElegibilidadeAtualizada(evento: EventoRecebido): Promise<void> {
this.logger.log('Evento conselheiro.elegibilidade_atualizada ignorado (MVP)', {
event_id: evento.event_id,
conselheiro_id: (evento.payload as Record<string, unknown>)?.conselheiro_id,
});
await this.repo.upsertOffset(
'conselheiro.elegibilidade_atualizada',
BigInt(evento.sequence_number),
);
}
Caso Comportamento
UC sem nenhum conselheiro cadastrado Todo agenda.item_disponível gera sorteio.sem_candidatos com motivo nenhum_cadastrado. Demandas acumulam na fila.
UC com 1 conselheiro apenas, que recusa todas as demandas Primeira atribuição: sorteado. Recusa: re-sorteio → sem outros elegíveis → fila. Próximo agenda.item_disponível: o único conselheiro está disponivel (voltou após recusa) → sorteado novamente → recusa de novo. Após a quarta recusa: suspenso → sorteio.sem_candidatos com motivo todos_inativos.
conselheiro.ciclo_concluído com motivo_encerramento = 'desistencia' Conselheiro liberado (disponivel) mas não reseta histórico. Pode se recadastrar normalmente. A desistência é um fato registrado, não uma penalidade.
Demanda na fila por mais de T dias sem conselheiro Incremento de tentativas_sorteio. Sem ação automática no MVP. D-7 (Transparência) pode expor métrica “demandas aguardando conselheiro”. Fase 2: escalonamento para UC de nível superior via D-14.
Conselheiro sorteado, mas evento conselheiro.sorteado nunca chega à D-6b (perda de evento) O estado na D-6a está correto (ocupado, atribuição ativa). A D-6b nunca inicia o acompanhamento. O conselheiro aparece como “ocupado” indefinidamente. Cenário detectável por: conselheiro ocupado há mais de X dias sem conselheiro.demanda_iniciada correspondente. Necessário console administrativo para liberar manualmente no MVP. Fase 2: timeout automático.
Dois agenda.item_disponível consecutivos, primeiro sorteio em andamento A execução de executarSorteio é serializada pela fila global. O segundo sorteio roda depois da conclusão do primeiro e o filtro de elegibilidade já exclui o conselheiro ocupado.
Conflito de concorrência entre UCs sobrepostas: um conselheiro da cidade concorre em duas demandas de bairros diferentes A fila global (comFilaGlobal, fila de Promises em memória) serializa toda execução de sorteio, porque os pools de UCs diferentes se sobrepõem com a cobertura por descendentes. Válido para instância única do monolito no MVP; lock distribuído é Fase 2.

Serialização dos sorteios por fila global. A cobertura por descendentes faz os pools de UCs diferentes se sobreporem: um conselheiro cadastrado na cidade concorre às demandas dos bairros e um conselheiro do bairro concorre às micro-áreas. O lock por UC deixaria de garantir uma atribuição única por conselheiro entre UCs sobrepostas. A fila global (comFilaGlobal) encadeia as execuções em uma fila de Promises única: o segundo sorteio só começa depois que o primeiro termina e o filtro de elegibilidade consulta o estado já atualizado. A fila é em memória, válida para a instância única do monolito no MVP. A solução é aceitável para o volume do MVP (< 10 sorteios/dia).

Fila FIFO por data_entrada, não por score_final. A D-5 já ordenou por score (ranking) e publicou agenda.item_disponível para o primeiro da fila. A ordem de chegada dos eventos agenda.item_disponível à D-6a reflete a prioridade definida pelo ranking. A fila interna da D-6a preserva essa ordem (FIFO). Reordenar por score internamente seria redundante e potencialmente inconsistente com o backlog da D-5.

Reset de recusas por aceitação (ciclo concluído), não por aceitação de atribuição. O conselheiro pode ter sido sorteado (status ocupado) mas o ciclo pode nem começar (não publicou conselheiro.demanda_iniciada). Se resetássemos na atribuição, o conselheiro acumularia ocupadorecusaocupadorecusa sem nunca ter concluído nada. O reset no ciclo concluído garante que só zera o contador quando há trabalho efetivamente realizado.

Sem notificação push para conselheiros no MVP. O sistema publica conselheiro.sorteado no barramento e na timeline (D-7). O conselheiro descobre que foi sorteado consultando a interface. Notificações (push, email, SMS) são responsabilidade de uma colônia de notificação, não da D-6a. A D-6a publica o fato; a entrega da notificação é consumida por outro módulo (Fase 2).


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-6a injeta EventBusService (do módulo @Global() N-0a) para as operações de barramento: inscrever() (registrar consumidores), publicar() (publicar conselheiro.sorteado e sorteio.sem_candidatos), replayDeSequence() (replay na inicialização), obterMaiorSequence() (seed dos cursores), obterUltimoEventId() (seed do sorteio) e consultarEventos() (guarda de republicação de sorteio.sem_candidatos).

@Injectable()
export class D6aService {
private readonly logger = new Logger(D6aService.name);
constructor(
private readonly eventBus: EventBusService,
private readonly repo: D6aRepository,
private readonly fisherYates: FisherYates,
private readonly seedGenerator: SeedGenerator,
private readonly elegibilidadeService: ElegibilidadeService,
) {}
}

O repositório único concentra o acesso a todas as tabelas do schema d6a. O Logger do NestJS é instanciado no próprio serviço. Nenhuma dependência além do núcleo.

Cidadão (app)
→ POST /api/demandas (BFF D-1a)
→ demanda.recebida (barramento)
→ [D-1b] → demanda.normalizada
→ [D-2] → demanda.georreferenciada
→ [D-3] → demanda.categorizada
→ [D-4] → demanda.ranqueada
→ ranking.atualizado
→ [D-5] → agenda.gerada
→ agenda.item_disponível
[D-6a] — esta colônia
├── conselheiro.sorteado ──────────┐
└── sorteio.sem_candidatos │
│ │
┌──────────────────────────────────────────────────────────────┘
[D-5] atualiza status → 'atribuido' (consome conselheiro.sorteado)
[D-6b] inicia acompanhamento (consome conselheiro.sorteado)
→ conselheiro.demanda_iniciada ────┐
→ ... │
→ conselheiro.ciclo_concluído ─────┤
│ │
└── [D-6a] libera conselheiro,│
varre fila de pendentes │
[D-7] timeline pública ←────────────┘ (consome todos os eventos)
Recusa:
Cidadão (app)
→ POST /api/conselheiros/atribuicoes/:demanda_id/recusar (BFF D-1a)
→ conselheiro.atribuicao_recusada (barramento)
→ [D-6a] processa recusa, re-sorteia
→ conselheiro.sorteado (novo conselheiro)
OU
→ sorteio.sem_candidatos (sem alternativas)
private async publicarConselheiroSorteado(
resultado: {
ucId: string;
conselheiro_id: string;
demanda_id: string;
seed_publico: string;
lista_hash: string;
posicao_sorteada: number;
total_elegiveis: number;
correlacao_id: string;
eventIdSorteio: string;
timestampSorteio: string;
},
): Promise<void> {
try {
await this.eventBus.publicar({
tipo: 'conselheiro.sorteado',
origem: 'D-6a',
versao_schema: '1.0.0',
event_id: resultado.eventIdSorteio,
correlacao_id: resultado.correlacao_id,
payload: {
conselheiro_id: resultado.conselheiro_id,
demanda_id: resultado.demanda_id,
unidade_civica_id: resultado.ucId,
metodo_sorteio: 'fisher_yates',
seed_publico: resultado.seed_publico,
lista_elegiveis_hash: resultado.lista_hash,
posicao_sorteada: resultado.posicao_sorteada,
total_elegiveis: resultado.total_elegiveis,
timestamp: resultado.timestampSorteio,
},
});
} catch (erro) {
this.logger.error('Falha ao publicar conselheiro.sorteado', {
conselheiro_id: resultado.conselheiro_id,
demanda_id: resultado.demanda_id,
erro: erro instanceof Error ? erro.message : String(erro),
});
throw erro;
}
}
private async publicarSorteioSemCandidatos(
ucId: string,
demandaId: string,
motivo: string,
correlacaoId: string,
): Promise<void> {
const jaPublicado = await this.eventBus.consultarEventos({
tipo: 'sorteio.sem_candidatos',
correlacao_id: correlacaoId,
limite: 1,
});
if (jaPublicado.linhas.length > 0) {
this.logger.log('sorteio.sem_candidatos já publicado para a demanda — ignorando', {
demanda_id: demandaId,
correlacao_id: correlacaoId,
});
return;
}
try {
await this.eventBus.publicar({
tipo: 'sorteio.sem_candidatos',
origem: 'D-6a',
versao_schema: '1.0.0',
event_id: uuidv4(),
correlacao_id: correlacaoId,
payload: {
demanda_id: demandaId,
unidade_civica_id: ucId,
total_elegiveis: 0,
motivo,
timestamp: new Date().toISOString(),
},
});
} catch (erro) {
this.logger.error('Falha ao publicar sorteio.sem_candidatos', {
uc_id: ucId,
demanda_id: demandaId,
erro: erro instanceof Error ? erro.message : String(erro),
});
throw erro;
}
}

A D-6a não faz chamadas síncronas a outras colônias. Toda interação de escrita com o front-end é mediada pelo BFF da D-1a. O fluxo de recusa:

  1. Front-end → BFF D-1a (POST /api/conselheiros/atribuicoes/:demanda_id/recusar)
  2. BFF D-1a publica conselheiro.atribuicao_recusada no barramento
  3. BFF retorna confirmação síncrona ao front-end
  4. D-6a processa o evento de forma assíncrona

O front-end não recebe o resultado do re-sorteio na mesma resposta HTTP. Para saber se um novo conselheiro foi sorteado, consulta a timeline (D-7) ou aguarda notificação (Fase 2).

A colônia expõe uma rota própria de leitura do estado do titular, GET /d6a/conselheiros/me/situacao (throttle 60/min, ConselheiroGuard). É a única exceção ao padrão de comunicação via BFF e existe porque a situação do conselheiro é dado do próprio schema d6a. A rota responde { cadastrado: false } para quem nunca se cadastrou e, para quem se cadastrou, devolve UC, nível, status, data fim da suspensão e a atribuição ativa quando existe.

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

  • core.event_log via EventBusService.replayDeSequence(). Dependência do núcleo, permitida.
  • core.event_log para obter o último event_id pelo obterUltimoEventId() (geração de seed). Dependência do núcleo, permitida.
  • core.event_log para a guarda de republicação de sorteio.sem_candidatos (consultarEventos). Dependência do núcleo, permitida.
  • core.uc_polygons via HierarquiaUcRepository.buscarTodasHierarquias(), do módulo compartilhado src/shared/hierarquia-uc/, read-only, para montar a cadeia ascendente e os descendentes da UC. Exceção controlada de infraestrutura, a mesma da D-2 e da L-2, com cache em memória e TTL de 6 horas. Nenhuma escrita.
  • Arquivo de configuração estática d6a.constants.ts com os parâmetros de anti-acumulação.

A D-6a não utiliza Redis. O volume de consultas é baixo (< 10 sorteios/dia, < 50 conselheiros) e o PostgreSQL é suficiente. A fila global comFilaGlobal serializa os sorteios concorrentes em uma fila de Promises em memória, porque os pools de UCs diferentes se sobrepõem. Nenhuma infraestrutura externa é necessária.

O fluxo entre as duas colônias forma um ciclo:

D-6a → conselheiro.sorteado → D-6b (inicia acompanhamento)
D-6b → conselheiro.ciclo_concluído → D-6a (libera conselheiro)

Este ciclo não é vicioso porque ambas as colônias consomem eventos de forma assíncrona. A D-6a publica sem esperar a D-6b. A D-6b processa sem notificar a D-6a síncronamente. É um ciclo de eventos, não de chamadas. Padrão comum em arquiteturas orientadas a eventos.


A D-6a não aplica rate limiting nos handlers de evento. O volume de eventos é limitado indiretamente pelo rate limiting do BFF (D-1a), que é o ponto de entrada das operações de escrita do front-end. A rota de leitura GET /d6a/conselheiros/me/situacao aplica @Throttle de 60 requisições por minuto por IP no próprio controller.

Limite Valor Justificativa
Número máximo de conselheiros por UC (nível 1) ~50 Referência do modelo: 2-4 no nível 1. Margem de 10x para folga.
Número máximo de demandas na fila de pendentes por UC 200 Determinado pelo backlog máximo da D-5.
Recusas consecutivas para suspensão (N) 3 Valor inicial parametrizável. A comparação é estrita: suspende quando a contagem passa de 3.
Período de suspensão (dias) 90 Aproximadamente metade de um ciclo de atuação (18 meses / 6). Parametrizável.
Tempo máximo sem resposta do conselheiro (horas) 72 MVP: verificação manual. Fase 2: timeout automático gera conselheiro.atribuicao_recusada com motivo timeout_resposta.
Tamanho máximo de lista de elegíveis 50 Determinado pelo número de conselheiros por UC.
Seed público 64 caracteres (hex SHA-256) Suficiente para verificação.
Índice Query atendida
conselheiros_pkey (id) Acesso por ID em updates de status e liberação.
conselheiros_cidadao_id_unidade_civica_id_nivel_atuacao_key (UNIQUE) Idempotência de cadastro — evita duplicação.
conselheiros_unidade_civica_id_status_idx (composto) Filtro de elegibilidade: “todos os disponiveis da UC X”, com in sobre a cadeia de UCs. Query principal do sorteio.
conselheiros_cidadao_id_idx Busca por cidadão (ex: “quais UCs este cidadão é conselheiro?”).
conselheiros_event_id_cadastro_idx Idempotência de conselheiro.cadastrado.
atribuicoes_pkey (id) Acesso por ID.
atribuicoes_event_id_sorteio_key (UNIQUE) Idempotência do sorteio.
atribuicoes_demanda_id_idx “Esta demanda já foi sorteada?” — verificação no handler de agenda.item_disponível.
atribuicoes_unidade_civica_id_status_idx (composto) “Atribuições ativas na UC X”.
atribuicoes_conselheiro_id_status_idx (composto) “Qual a atribuição ativa do conselheiro Y?”. Query central do filtro de elegibilidade.
fila_pendentes_unidade_civica_id_data_entrada_idx (composto) “Primeira demanda pendente entre a UC e os descendentes”. Varrimento ao liberar conselheiro.
fila_pendentes_demanda_id_key (UNIQUE) Idempotência: mesma demanda não entra duas vezes na fila.
contador_recusas_conselheiro_id_idx “Quantas recusas consecutivas do conselheiro Y?”.
contador_recusas_evento_id_recusa_key (UNIQUE) Idempotência de conselheiro.atribuicao_recusada.
  • buscarConselheirosPorUcsEStatus(ucIds, 'disponivel'): 1 query por sorteio, com in sobre a cadeia. Usa índice composto (unidade_civica_id, status).
  • buscarAtribuicoesAtivas(): 1 query por verificação de elegibilidade. Usa índice (conselheiro_id, status).
  • buscarAtribuicaoAtivaPorConselheiro(conselheiroId): usada na verificação de consistência do boot e no endpoint de situação. Usa índice (conselheiro_id, status).
  • buscarAtribuicaoAtivaPorDemanda(demandaId): 1 query no handler de agenda.item_disponível e nas varreduras de fila. Usa índice demanda_id.
  • buscarPrimeiroPendentePorUcs(ucIds): 1 query ao liberar conselheiro, com in e orderBy data_entrada asc. Usa índice composto (unidade_civica_id, data_entrada).
  • contarRecusasDesdeData(conselheiroId, dataReferencia): 1 query agregada ao processar recusa. Usa índice em conselheiro_id.
  • HierarquiaUcRepository.buscarTodasHierarquias() (módulo compartilhado): 1 query a cada TTL de 6 horas do cache de hierarquia. Sem índice específico (tabela de referência pequena).

Volume esperado no MVP: < 10 agenda.item_disponível/dia, < 5 cadastros/dia, < 3 ciclos concluídos/dia, < 2 recusas/dia. Tempo médio de processamento: < 5ms para todas as operações.

O cache em memória da hierarquia de UCs vive no módulo compartilhado src/shared/hierarquia-uc/ (HierarquiaUcService, TTL de 6 horas), usado pela elegibilidade e pela fila de pendentes. O volume de dados de conselheiros é pequeno (< 50 conselheiros, < 100 atribuições ativas, < 50 demandas na fila). Consultas ao PostgreSQL com índices adequados têm latência < 1ms. Cache de conselheiros seria overengineering para o MVP.

Cenário Conselheiros cadastrados Sorteios/dia Fila pendente média Recusas/dia
PoC (1 bairro) ~5 ~3 0-2 ~1
MVP (1 município) ~30 ~10 0-5 ~2
Fase 2 (regional) ~500 ~100 10-30 ~15

Teste unitário do D6aService (d6a.service.spec.ts):

beforeEach(async () => {
resetarD6aConstants();
mockEventBus = {
inscrever: jest.fn(),
publicar: jest.fn().mockResolvedValue({ sequence_number: 20n, event_id: 'published-id' }),
replayDeSequence: jest.fn().mockResolvedValue([]),
obterMaiorSequence: jest.fn().mockResolvedValue(100),
obterUltimoEventId: jest.fn().mockResolvedValue('ultimo-evt-barramento'),
consultarEventos: jest.fn().mockResolvedValue({ linhas: [], total: 0 }),
};
mockRepo = {
seedOffsets: jest.fn().mockResolvedValue(undefined),
buscarFilaPendentePorEventIdOrigem: jest.fn().mockResolvedValue(null),
buscarAtribuicaoAtivaPorDemanda: jest.fn().mockResolvedValue(null),
buscarConselheiroPorEventIdCadastro: jest.fn().mockResolvedValue(null),
buscarConselheiroPorId: jest.fn().mockResolvedValue(null),
buscarConselheiroPorCidadao: jest.fn().mockResolvedValue(null),
buscarAtribuicaoPorEventIdEncerramento: jest.fn().mockResolvedValue(null),
buscarRecusasPorEventoId: jest.fn().mockResolvedValue(null),
buscarAtribuicaoAtivaPorConselheiroEDemanda: jest.fn().mockResolvedValue(null),
buscarAtribuicaoAtivaPorConselheiro: jest.fn().mockResolvedValue(null),
buscarConselheirosPorUcsEStatus: jest.fn().mockResolvedValue([]),
buscarConselheirosPorStatus: jest.fn().mockResolvedValue([]),
buscarSuspensosComPrazoVencido: jest.fn().mockResolvedValue([]),
contarConselheirosPorUcs: jest.fn().mockResolvedValue(0),
contarConselheirosOcupadosPorUcs: jest.fn().mockResolvedValue(0),
buscarAtribuicoesAtivas: jest.fn().mockResolvedValue([]),
buscarTodosPendentes: jest.fn().mockResolvedValue([]),
buscarPrimeiroPendentePorUcs: jest.fn().mockResolvedValue(null),
buscarFilaPendentePorDemandaId: jest.fn().mockResolvedValue(null),
buscarUltimaAceitacao: jest.fn().mockResolvedValue(null),
contarRecusasDesdeData: jest.fn().mockResolvedValue(0),
inserirConselheiro: jest.fn().mockResolvedValue(undefined),
atualizarStatusConselheiro: jest.fn().mockResolvedValue(undefined),
marcarConselheiroOcupado: jest.fn().mockResolvedValue(undefined),
suspenderConselheiro: jest.fn().mockResolvedValue(undefined),
inserirAtribuicao: jest.fn().mockResolvedValue(undefined),
atualizarStatusAtribuicao: jest.fn().mockResolvedValue(undefined),
inserirFilaPendente: jest.fn().mockResolvedValue(undefined),
removerFilaPendentePorDemanda: jest.fn().mockResolvedValue(undefined),
incrementarTentativasFilaPendente: jest.fn().mockResolvedValue(undefined),
inserirRecusa: jest.fn().mockResolvedValue(undefined),
upsertOffset: jest.fn().mockResolvedValue(undefined),
buscarTodosOffsets: jest.fn().mockResolvedValue([]),
};
mockElegibilidade = {
filtrar: jest.fn().mockResolvedValue({
elegiveis: [
conselheiroElegivel('c1', 'cid1', ucId),
conselheiroElegivel('c2', 'cid2', ucId),
conselheiroElegivel('c3', 'cid3', ucId),
],
degrau: ucId,
cadeia: [ucId],
}),
};
const module = await Test.createTestingModule({
providers: [
D6aService,
{ provide: EventBusService, useValue: mockEventBus },
{ provide: D6aRepository, useValue: mockRepo },
{ provide: FisherYates, useValue: mockFisherYates },
{ provide: SeedGenerator, useValue: mockSeedGenerator },
{ provide: ElegibilidadeService, useValue: mockElegibilidade },
],
}).compile();
service = module.get<D6aService>(D6aService);
});

As suítes d6a.repository.spec.ts e d6a.controller.spec.ts cobrem a identidade unificada no insert, a conversão dos registros, a busca por cidadão e a situação devolvida pelo controller. As suítes dos services (services/*.spec.ts) cobrem a elegibilidade em degraus, a fila global, a fila de pendentes por descendentes e a hierarquia de UCs. Os mocks de EventBusService e PrismaService reutilizam os helpers de src/test/ (criarEventBusMock e criarPrismaMock).

Happy path:

# Cenário Verificação
T1 onAgendaItemDisponivel() com conselheiros elegíveis Fisher-Yates chamado. conselheiro.sorteado publicado com seed, hash, total_elegiveis. Conselheiro status → ocupado. Atribuição criada com status ativa.
T2 onConselheiroCadastrado() com payload completo v1.1.0 Conselheiro inserido com capacitacao_concluida = true. Se há demanda na fila, sorteio disparado.
T3 onConselheiroCicloConcluido() com atribuição ativa Conselheiro status → disponivel. Atribuição status → concluida. Fila varrida.
T4 onConselheiroAtribuicaoRecusada() com 1 recusa (abaixo de N) Atribuição status → recusada. Contador incrementado. Re-sorteio executado. Conselheiro NÃO suspenso.
T5 Fisher-Yates determinístico Mesmos elegíveis + mesmo seed = mesma ordenação. Verificável externamente.

Falhas e bordas:

# Cenário Verificação
T6 onAgendaItemDisponivel() com UC sem conselheiros sorteio.sem_candidatos publicado com motivo nenhum_cadastrado. Demanda na fila.
T7 onAgendaItemDisponivel() com UC com conselheiros mas todos ocupados sorteio.sem_candidatos publicado com motivo todos_ocupados. Demanda na fila.
T8 onAgendaItemDisponivel() com UC com conselheiros mas todos suspensos ou sem capacitação sorteio.sem_candidatos publicado com motivo todos_inativos. Demanda na fila.
T9 onAgendaItemDisponivel() para demanda já atribuída buscarAtribuicaoAtivaPorDemanda retorna registro. Log.warn, cursor avançado, retorno sem ação.
T10 onConselheiroCadastrado() com duplicata (mesmo cidadao + UC + nível) Unicidade violada. Log, cursor avançado, retorno sem erro.
T11 onConselheiroCicloConcluido() para conselheiro sem atribuição ativa Conselheiro liberado mesmo assim. Fila varrida.
T12 onConselheiroAtribuicaoRecusada() com a quarta recusa consecutiva Atribuição recusada. Contador chega a 4. Conselheiro status → suspenso. data_suspensao_ate preenchida com +90 dias.
T13 onConselheiroAtribuicaoRecusada() exatamente no limite de 3 recusas Contador registra 3. Nenhuma suspensão.
T14 onConselheiroAtribuicaoRecusada() seguido de re-sorteio sem elegíveis Atribuição recusada. Re-sorteio falha. Demanda vai para fila. sorteio.sem_candidatos publicado.
T15 Payload sem demanda_id em agenda.item_disponível Log.error. Cursor avançado.
T16 Payload sem conselheiro_id em conselheiro.cadastrado Log.error. Cursor avançado.
T17 conselheiro.cadastrado 1.0.0 (sem capacitacao_concluida) Tratado como capacitacao_concluida = true. Conselheiro elegível.
T18 Conselheiro com data_suspensao_ate no passado Filtro de elegibilidade o aceita como disponível.
T19 Fila de pendentes com 5 demandas; conselheiro liberado Sorteio para a primeira (FIFO). 4 permanecem.
T20 publicar('conselheiro.sorteado') falha Log.error. Estado já persistido (atribuição criada, conselheiro ocupado). Erro relançado para a DLQ. Cursor não avança.
T21 publicar('sorteio.sem_candidatos') falha Log.error. Entrada na fila já persistida. Erro relançado para a DLQ. Cursor não avança. A guarda por correlacao_id evita republicação em retry.

Teste de integração:

# Cenário Verificação
T22 Ciclo completo: D-5→D-6a→D-6b com 3 demandas e 2 conselheiros 2 conselheiro.sorteado publicados. 1 sorteio.sem_candidatos. Conselheiros status ocupado. Atribuições criadas.
T23 Recusa → re-sorteio → aceitação → ciclo concluído → novo sorteio 1ª atribuição recusada. Contador = 1. 2ª atribuição (outro conselheiro) aceita. Ciclo concluído. Primeiro conselheiro liberado, sorteado para nova demanda.
T24 Quarta recusa consecutiva → suspensão (limite 3, comparação estrita >) Conselheiro suspenso na quarta recusa. status = 'suspenso', data_suspensao_ate = NOW() + 90 dias.
T25 Replay após reinício: 10 eventos, 6 já processados 4 novos processados. 6 ignorados por idempotência. Consistência de ocupados verificada. Fila de pendentes processada. Suspensos vencidos reativados.
T26 Cadastro de conselheiro com fila de pendentes Conselheiro cadastrado → fila varrida → sorteio executado → demanda removida da fila.
T27 Filtro de elegibilidade exclui conselheiro com atribuição ativa 3 conselheiros disponiveis, 1 com atribuição ativa. Filtro retorna 2. Sorteio usa apenas os 2.
T28 agenda.item_disponível reentregue após sorteio bem-sucedido Detectado por demanda_id em atribuicoes com status = 'ativa'. Log.warn, cursor avançado, sem ação.
T29 conselheiro.atribuicao_recusada com motivo risco_pessoal Atribuição encerrada e re-sorteio executado. Nenhuma linha em contador_recusas. Status do conselheiro volta a disponivel (sem suspensão).
T30 conselheiro.atribuicao_recusada reentregue (replay/DLQ) Detectado por evento_id_recusa no contador e por event_id_encerramento na atribuição. Log, cursor avançado, sem re-sortear.
T31 conselheiro.atribuicao_recusada libera o conselheiro recusante Após qualquer motivo de recusa, o conselheiro ocupado volta a disponivel e entra de novo no filtro de elegibilidade. Conselheiro suspenso não é liberado.
T32 duplicidade.agregada com membros na fila Cada demanda_id de membros é removido da fila de pendentes. O representante permanece na fila. Cursor avançado.
T33 Suspenso com data_suspensao_ate vencida no boot reativarSuspensosVencidos devolve o conselheiro a disponivel com um UUID novo em event_id_ultima_alteracao e o motivo reativacao-prazo no log.
T34 Re-sorteio após recusa sem elegíveis A fila de pendentes guarda score_final, categoria_id, nivel_precedencia e grupo_id da atribuição recusada, não os placeholders.
T35 Falha do sorteio de um pendente no cadastro/ciclo/recusa tentativas_sorteio é incrementado uma única vez, dentro de executarSorteio.

Os cursores não entram por migration nem por seed SQL. seedOffsets() os cria no boot com obterMaiorSequence(), sem sobrescrever linhas existentes.

O seed de desenvolvimento (prisma/seed-dev.ts, npm run seed:dev) cria, de forma idempotente:

  • o município de teste f0000001-0000-4000-8000-000000000002 (nível 4, fonte: 'seed-dev'), criado como pai do bairro;
  • a UC de bairro de teste f0000001-0000-4000-8000-000000000001 (Bairro Teste, nível 2, com polígono, parent_uc_id do município, fonte: 'seed-dev' e metadados do código de bairro);
  • cinco cidadãos de teste;
  • três conselheiros em d6a.conselheiros, com id = cidadao_id, status = 'disponivel', capacitacao_concluida = true: dois no bairro com nivel_atuacao = 2 e um no município com nivel_atuacao = 4.

O seed não cria atribuições nem entradas na fila de pendentes.


Funcionalidade Status
inscrever('agenda.item_disponível') com handler idempotente MVP obrigatório
inscrever('conselheiro.cadastrado') com suporte a 1.1.0 (capacitacao_concluida) MVP obrigatório
inscrever('conselheiro.ciclo_concluído') com liberação de conselheiro e varredura de fila MVP obrigatório
inscrever('conselheiro.atribuicao_recusada') com contador, suspensão e re-sorteio MVP obrigatório
inscrever('duplicidade.agregada') com remoção dos membros da fila de pendentes MVP obrigatório
Fisher-Yates determinístico com seed verificável (HMAC-SHA256) MVP obrigatório
Filtro de elegibilidade por status, UC, capacitação e ausência de atribuição ativa MVP obrigatório
Fila interna de demandas pendentes (d6a.fila_pendentes) MVP obrigatório
Publicação de conselheiro.sorteado com seed, hash e metadados de auditoria MVP obrigatório
Publicação de sorteio.sem_candidatos com motivo e guarda de republicação MVP obrigatório
Endpoint de recusa no BFF D-1a (POST /api/conselheiros/atribuicoes/:demanda_id/recusar) que publica conselheiro.atribuicao_recusada MVP obrigatório
Motivo risco_pessoal na recusa, sem linha no contador e sem suspensão MVP obrigatório
GET /d6a/conselheiros/me/situacao com ConselheiroGuard e throttle 60/min MVP obrigatório
Config estática (d6a.constants.ts) com N recusas e período de suspensão MVP obrigatório
Verificação de consistência na inicialização (ocupados sem atribuição) MVP obrigatório
Processamento de fila pendente na inicialização MVP obrigatório
Reativação de suspensos vencidos na inicialização MVP obrigatório
Handler stub para conselheiro.elegibilidade_atualizada MVP obrigatório
Consumer offsets para os 6 tipos de evento MVP obrigatório
Propagação de correlacao_id MVP obrigatório
Logs estruturados com conselheiro_id, demanda_id, uc_id e event_id MVP obrigatório
Simplificação Justificativa Quando remover
Vínculo autodeclarado (confia no unidade_civica_id do cadastro) Validação de vínculo (D-9) é Fase 2. MVP confia na autodeclaração. Substituir por verificação de vínculo quando vínculo.validado estiver disponível (Fase 2).
Cobertura por descendentes sem progressão formal A UC de atuação cobre ela mesma e as unidades menores dentro dela, e o sorteio percorre a cadeia em degraus. A progressão entre níveis e a avaliação do conselheiro continuam na Fase 2 (D-17). Ativar a progressão e os critérios de nível quando a D-17 estiver ativa.
Capacitação autodeclarada no cadastro (campo capacitacao_concluida no evento) Sem sistema de certificação formal no MVP. O BFF exige conclusão de capacitação antes de publicar o evento. Consumir evento capacitação.concluída da D-17 e validar contra registro externo (Fase 2).
Sem notificação push ao conselheiro sorteado O sistema publica o evento; o conselheiro consulta a interface. Notificações são Fase 2. Adicionar colônia de notificação que consome conselheiro.sorteado e envia push/email (Fase 2).
Sem timeout automático de resposta do conselheiro Conselheiro pode permanecer ocupado indefinidamente sem iniciar a demanda. Timeout manual via console. Implementar scheduled job que verifica conselheiros ocupado há > T horas sem conselheiro.demanda_iniciada e publica conselheiro.atribuicao_recusada(motivo=timeout_resposta) (Fase 2).
Suspensão por data fixa (NOW() + 90 dias), com reativação no boot Suspensos com data_suspensao_ate vencida são reativados no boot (reativarSuspensosVencidos, status volta a disponivel) e o filtro de elegibilidade também aceita o conselheiro pela data. Publicar evento conselheiro.suspensao_expirada e notificar o cidadão (Fase 2).
Fila global de sorteio em memória A cobertura por descendentes sobrepõe os pools de UCs diferentes; a fila global serializa toda execução. Suficiente para o monolito em instância única. Sem dependência de Redis. Migrar para lock distribuído quando o monolito for particionado (Fase 2).
Cadastro e recusa de conselheiro via BFF D-1a (publica eventos), sem endpoints REST de escrita na D-6a Consistente com o padrão MVP: o BFF centraliza as operações de escrita do front-end. A D-6a mantém um único endpoint de leitura do próprio estado (GET /d6a/conselheiros/me/situacao), sob o ConselheiroGuard. Na Fase 2, com C-1 como serviço independente, o cadastro de conselheiro pode migrar para API própria.
Identidade unificada: id do registro de conselheiro é o cidadao_id No MVP, o cadastro cria d6a.conselheiros com id = cidadao_id. Reduz o pooling de identidade sem CPF/gov.br e permite o ConselheiroGuard validar a sessão Google contra os dados da colônia. Retornar à separação formal na Fase 2, com identidade verificada por CPF/gov.br.
Seed com fallback para UUID v4 se barramento indisponível Raro no monolito (Event Bus no mesmo processo). Fallback mantém o sorteio funcional, mas seed perde vínculo com o barramento. Remover fallback quando o barramento tiver SLA de disponibilidade (Fase 2).
  • Consumo ativo de conselheiro.elegibilidade_atualizada com atualização de critérios
  • Validação de vínculo com a UC (consumo de vínculo.validado)
  • Verificação de capacitação contra registro externo (capacitação.concluída)
  • Progressão entre níveis (consumo de progressão.habilitada)
  • Timeout automático de resposta e re-sorteio
  • Job de reabilitação de conselheiros suspensos com notificação
  • Notificação push/email ao conselheiro sorteado
  • Lock distribuído (Redis) para sorteios concorrentes
  • Métricas Prometheus: d6a_conselheiros_ativos, d6a_sorteios_total, d6a_fila_pendentes_size, d6a_recusas_total, d6a_taxa_recusas
  • Tabela d6a.config com histórico de versões de parâmetros de anti-acumulação

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: a D-6a consome conselheiro.atribuicao_recusada, novo tipo no catálogo. Avaliação: o tipo está registrado no Registry (N-0b) nas versões 1.0.0 e 1.1.0. O Registry é versionado, e adicionar um tipo não quebra compatibilidade. Nenhuma outra colônia consome o evento; a D-6a mesma o consome. O schema do payload está definido na seção 3.4.

Conflito potencial: a D-6a consome conselheiro.cadastrado 1.1.0 com capacitacao_concluida. Avaliação: o campo é obrigatório na 1.1.0 e o BFF da D-1a publica a 1.1.0 com o valor preenchido. A D-6a também trata a ausência como true, o que mantém o handler funcional se um payload 1.0.0 for reentregue.

Conflito potencial: o ciclo D-6a → D-6b → D-6a pode gerar loop infinito de eventos? Avaliação: não. Cada evento tem um event_id único. Os handlers de todas as colônias são idempotentes por event_id. Se um evento for reentregue por replay, o handler detecta e ignora. O mesmo evento não gera o mesmo evento de volta: a D-6a publica conselheiro.sorteado e a D-6b consome e publica conselheiro.demanda_iniciada, tipos diferentes. O ciclo é de fluxo de trabalho, não de eventos recursivos.

Conflito potencial: a D-5 também consome conselheiro.sorteado para marcar status atribuido. A D-6a e a D-5 podem divergir sobre o estado do conselheiro? Avaliação: a D-5 mantém status de backlog; a D-6a mantém status de conselheiro. São estados diferentes sobre entidades diferentes. A D-5 marca a demanda como atribuido; a D-6a marca o conselheiro como ocupado. Não há divergência possível porque são dimensões ortogonais. A D-5 não precisa saber se o conselheiro está ocupado ou suspenso.

Conflito potencial: a ordem de deploy. Se a D-6a subir antes da D-5 publicar agenda.item_disponível? Avaliação: a D-6a opera em modo degradado. Sem eventos, não há sorteios. Os handlers ficam registrados e processarão eventos assim que a D-5 começar a publicar. O iniciar() faz replay a partir do último offset conhecido. Ordem natural do Bloco 2 (D-4 → D-5 → D-6a) garante que a D-5 já está publicando quando a D-6a sobe em produção.



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