D-6a — Sorteio e Atribuição
Parte da D-6 — Conselheiros
Propósito
Seção intitulada “Propósito”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.
Referência
Seção intitulada “Referência”- Ficha técnica da colônia: Apêndice B - Colônias.md, seção “D-6a — Sorteio e Atribuição”
- Algoritmo de sorteio e critérios de elegibilidade: contexto_IA.md, seção 6 (Ciclos de atuação, sorteio e progressão)
- A D-6a é o décimo-primeiro elo na ordem de implementação do MVP (Bloco 2, após D-5).
- Colônia irmã: D-6b - Relatoria e Acompanhamento.md
1. Estrutura do Módulo NestJS
Seção intitulada “1. Estrutura do Módulo NestJS”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.
1.1 Árvore de diretórios
Seção intitulada “1.1 Árvore de diretórios”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, EventoRecebido1.2 Module definition
Seção intitulada “1.2 Module definition”@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(); }}1.3 Pontos de atenção
Seção intitulada “1.3 Pontos de atenção”- 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
EventBusModuleexplicitamente.EventBusModuleé@Global(), e oEventBusServiceé injetável sem import. - O módulo não importa
RegistryModule. A validação de schema dos eventos publicados é feita pelo próprio Event Bus (N-0a) no momento dopublicar(). - 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 oConselheiroGuard. - O
OnModuleInitdispara 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@Throttlede 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 ded6a.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 enumtimeout_respostado 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_polygonscomo 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 compartilhadosrc/shared/hierarquia-uc/, importado pelod6a.module.tse consumido porsorteio/elegibilidade.ts,services/sorteio.service.tsed6a.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.
1.4 Serviço — responsabilidades e contrato
Seção intitulada “1.4 Serviço — responsabilidades e contrato”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 |
2. Banco de Dados — Schema e Entidades
Seção intitulada “2. Banco de Dados — Schema e Entidades”2.1 Schema d6a
Seção intitulada “2.1 Schema d6a”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.
2.2 Tabela d6a.conselheiros
Seção intitulada “2.2 Tabela d6a.conselheiros”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.
Colunas — detalhamento
Seção intitulada “Colunas — detalhamento”| 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.
2.3 Tabela d6a.atribuicoes
Seção intitulada “2.3 Tabela d6a.atribuicoes”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.
Colunas — detalhamento
Seção intitulada “Colunas — detalhamento”| 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. |
2.4 Tabela d6a.fila_pendentes
Seção intitulada “2.4 Tabela d6a.fila_pendentes”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.
Colunas — detalhamento
Seção intitulada “Colunas — detalhamento”| 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. |
2.5 Tabela d6a.contador_recusas
Seção intitulada “2.5 Tabela d6a.contador_recusas”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);Colunas — detalhamento
Seção intitulada “Colunas — detalhamento”| 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. |
2.6 Tabela d6a.consumer_offset
Seção intitulada “2.6 Tabela d6a.consumer_offset”Controla o cursor de processamento para replay seletivo após falha ou reinicialização. Segue o padrão definido pela N-0a (Event Bus).
CREATE TABLE 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.
2.7 Migrations esperadas
Seção intitulada “2.7 Migrations esperadas”Duas migrations:
20260811191728_create_d6a_tables— Cria o schemad6ae as tabelasconselheiros,atribuicoes,fila_pendentes,contador_recusaseconsumer_offset, com as constraints UNIQUE e os índices descritos nas seções 2.2 a 2.6.20260918160000_d6a_atribuicoes_demanda_snapshot— Adicionascore_final,categoria_id,nivel_precedenciaegrupo_idad6a.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,1e 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.
2.8 Relações internas
Seção intitulada “2.8 Relações internas”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.
2.9 Decisões de schema
Seção intitulada “2.9 Decisões de schema”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.
3. Eventos — Contratos Detalhados
Seção intitulada “3. Eventos — Contratos Detalhados”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}3.2 Evento consumido: conselheiro.cadastrado
Seção intitulada “3.2 Evento consumido: conselheiro.cadastrado”| 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;}3.6 Evento consumido: duplicidade.agregada (D-12)
Seção intitulada “3.6 Evento consumido: duplicidade.agregada (D-12)”| 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.
3.7 Evento produzido: conselheiro.sorteado
Seção intitulada “3.7 Evento produzido: conselheiro.sorteado”| 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}3.8 Evento produzido: sorteio.sem_candidatos
Seção intitulada “3.8 Evento produzido: sorteio.sem_candidatos”| 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.
3.13 Tratamento de erro e idempotência
Seção intitulada “3.13 Tratamento de erro e idempotência”| 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. |
3.14 Decisões de design com justificativa
Seção intitulada “3.14 Decisões de design com justificativa”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. Lógica de Negócio — Algoritmos e Fluxos
Seção intitulada “4. Lógica de Negócio — Algoritmos e Fluxos”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 caracteresO 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 listaA 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):
- Obter
seed_publicoelista_elegiveis_hashdo eventoconselheiro.sorteado - Obter a lista de conselheiros elegíveis do momento (disponível via D-7 ou log público)
- Ordenar a lista com o mesmo algoritmo e seed
- Verificar que SHA-256 da lista ordenada confere com
lista_elegiveis_hash - 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.
4.6 Máquina de estados do conselheiro
Seção intitulada “4.6 Máquina de estados do conselheiro”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:
suspenso→ocupado(suspenso não é elegível)inativo→ qualquer outro (reativação é administrativa, Fase 2)ocupado→suspenso(suspensão só ocorre a partir de disponivel, após recusa)
4.7 processarFilaPendentes() — inicialização
Seção intitulada “4.7 processarFilaPendentes() — inicialização”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 logEste 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.
4.9 Handler stub para Fase 2
Seção intitulada “4.9 Handler stub para Fase 2”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), );}4.10 Casos de borda adicionais
Seção intitulada “4.10 Casos de borda adicionais”| 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. |
4.11 Decisões de design com justificativa
Seção intitulada “4.11 Decisões de design com justificativa”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 ocupado → recusa → ocupado → recusa 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”5.1 Consumo de eventos via EventBusService
Seção intitulada “5.1 Consumo de eventos via EventBusService”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.
5.2 Fluxo de eventos — cadeia completa com D-6a
Seção intitulada “5.2 Fluxo de eventos — cadeia completa com D-6a”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)5.3 Publicação de conselheiro.sorteado
Seção intitulada “5.3 Publicação de conselheiro.sorteado”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; }}5.4 Publicação de sorteio.sem_candidatos
Seção intitulada “5.4 Publicação de sorteio.sem_candidatos”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; }}5.5 Chamadas síncronas via BFF
Seção intitulada “5.5 Chamadas síncronas via BFF”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:
- Front-end → BFF D-1a (
POST /api/conselheiros/atribuicoes/:demanda_id/recusar) - BFF D-1a publica
conselheiro.atribuicao_recusadano barramento - BFF retorna confirmação síncrona ao front-end
- 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.
5.6 Dependências de projeções de leitura
Seção intitulada “5.6 Dependências de projeções de leitura”A D-6a não consome projeções de leitura de outras colônias. Os dados externos que acessa são:
core.event_logviaEventBusService.replayDeSequence(). Dependência do núcleo, permitida.core.event_logpara obter o últimoevent_idpeloobterUltimoEventId()(geração de seed). Dependência do núcleo, permitida.core.event_logpara a guarda de republicação desorteio.sem_candidatos(consultarEventos). Dependência do núcleo, permitida.core.uc_polygonsviaHierarquiaUcRepository.buscarTodasHierarquias(), do módulo compartilhadosrc/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.tscom os parâmetros de anti-acumulação.
5.7 Independência de Redis
Seção intitulada “5.7 Independência de Redis”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.
5.8 Dependência circular D-6a ↔ D-6b
Seção intitulada “5.8 Dependência circular D-6a ↔ D-6b”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.
6. Performance e Limites
Seção intitulada “6. Performance e Limites”6.1 Rate limiting
Seção intitulada “6.1 Rate limiting”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.
6.2 Cotas e limites de tamanho
Seção intitulada “6.2 Cotas e limites de tamanho”| 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. |
6.3 Índices e padrões de query
Seção intitulada “6.3 Índices e padrões de query”| Í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. |
6.4 Padrões de query esperados
Seção intitulada “6.4 Padrões de query esperados”buscarConselheirosPorUcsEStatus(ucIds, 'disponivel'): 1 query por sorteio, cominsobre 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 deagenda.item_disponívele nas varreduras de fila. Usa índicedemanda_id.buscarPrimeiroPendentePorUcs(ucIds): 1 query ao liberar conselheiro, comineorderBy data_entrada asc. Usa índice composto(unidade_civica_id, data_entrada).contarRecusasDesdeData(conselheiroId, dataReferencia): 1 query agregada ao processar recusa. Usa índice emconselheiro_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.
6.5 Estratégia de cache
Seção intitulada “6.5 Estratégia de cache”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.
6.6 Projeção de volume
Seção intitulada “6.6 Projeção de volume”| 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 |
7. Testabilidade
Seção intitulada “7. Testabilidade”7.1 Como testar o módulo isolado
Seção intitulada “7.1 Como testar o módulo isolado”Teste unitário do 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).
7.2 Cenários de teste críticos
Seção intitulada “7.2 Cenários de teste críticos”Happy path:
| # | Cenário | Verificação |
|---|---|---|
| T1 | 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. |
7.3 Dados de seed para desenvolvimento local
Seção intitulada “7.3 Dados de seed para desenvolvimento local”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_iddo município,fonte: 'seed-dev'e metadados do código de bairro); - cinco cidadãos de teste;
- três conselheiros em
d6a.conselheiros, comid = cidadao_id,status = 'disponivel',capacitacao_concluida = true: dois no bairro comnivel_atuacao = 2e um no município comnivel_atuacao = 4.
O seed não cria atribuições nem entradas na fila de pendentes.
8. Alinhamento com o MVP
Seção intitulada “8. Alinhamento com o MVP”8.1 O que é MVP obrigatório
Seção intitulada “8.1 O que é MVP obrigatório”| Funcionalidade | Status |
|---|---|
inscrever('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 |
8.2 Simplificações válidas no MVP
Seção intitulada “8.2 Simplificações válidas no MVP”| 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). |
8.3 O que vai para a Fase 2
Seção intitulada “8.3 O que vai para a Fase 2”- Consumo ativo de
conselheiro.elegibilidade_atualizadacom 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.configcom 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.
Referências
Seção intitulada “Referências”- Ficha técnica da colônia: Apêndice B - Colônias.md, seção “D-6a — Sorteio e Atribuição”
- Algoritmo de sorteio e critérios de elegibilidade: contexto_IA.md, seção 6 (Ciclos de atuação, sorteio e progressão)
- Schemas de eventos: N-0b - Registry.md
- Barramento de eventos: N-0a - Event Bus.md
- Observabilidade: N-0c - Observabilidade.md
- Colônias a montante: D-5 - Agenda.md, Apêndice B - Colônias.md, seção “D-12 — Detecção de Duplicidade”
- Colônias a jusante: D-6b - Relatoria e Acompanhamento.md, D-7 - Transparência.md, Apêndice B - Colônias.md, seção “D-12 — Detecção de Duplicidade”
- Stack de referência e arquitetura do MVP: Apêndice B - Colônias.md, seção “Arquitetura do MVP — Monolito Modular”
- Mapa de dependências de eventos: Apêndice B - Colônias.md, seção “Mapa de Dependências de Eventos entre Colônias”
- Princípios do Formigueiro: Apêndice B - Colônias.md, seção “Princípios herdados do Formigueiro”
Documento de especificação técnica de implementação.