Pular para o conteúdo

D-6b — Relatoria e Acompanhamento

Parte da D-6 — Conselheiros


Registra cada atualização feita pelo conselheiro sobre a demanda que acompanha. Transforma o trabalho do conselheiro em eventos estruturados e públicos: contatos realizados, protocolos abertos, documentos anexados, entraves encontrados, mudanças de status e prazos registrados.

A primeira atualização de qualquer tipo para um par conselheiro⇄demanda dispara automaticamente conselheiro.demanda_iniciada. Esse é o evento que muda a demanda de atribuido para em_progresso na D-5 e sinaliza para a D-7 que o conselheiro começou a atuar.

A IA sugere como estruturar o texto da atualização (separar fato de interpretação, padronizar linguagem neutra), mas não publica nada autonomamente. O conselheiro sempre revisa e confirma antes da publicação. A sugestão é marcada como automática; o texto final é do conselheiro.

Não avalia o conselheiro, não decide sobre a demanda, não gera a timeline pública. Isso é responsabilidade da D-7 (Transparência). A D-6b apenas garante que cada ação do conselheiro gere um evento rastreável com timestamp, tipo estruturado e referência ao responsável.



A D-6b é um módulo NestJS com encapsulamento próprio dentro do monolito modular do MVP. É uma colônia orientada a eventos com três endpoints REST: a sugestão de IA, chamada diretamente pelo app; a leitura do acompanhamento ativo do conselheiro; e a ratificação de conclusão coletiva. Consome eventos do barramento via EventBusService (N-0a), processa internamente e publica eventos de saída. A comunicação de escrita com o front-end (atualizações do conselheiro) é mediada pelo BFF da D-1a, que publica conselheiro.atualização_registrada no barramento.

src/demanda/d-6b-relatoria-acompanhamento/
├── d6b.module.ts # Module definition
├── d6b.service.ts # Lógica de negócio: handlers de eventos, orquestração da relatoria
├── d6b.controller.ts # Endpoints REST: sugestão de IA, acompanhamento, ratificação
├── d6b.repository.ts # Acesso a todas as tabelas do schema d6b
├── d6b.service.spec.ts # Testes do serviço e dos handlers
├── d6b.repository.spec.ts # Testes do repositório
├── d6b.controller.spec.ts # Testes dos endpoints
├── d6b-schema.spec.ts # Testes de schema e migration das conclusões sociais
├── relatoria/
│ ├── estruturador-ia.service.ts # Sugestão de estruturação por heurística (fallback)
│ ├── estruturador-ia.service.spec.ts
│ ├── normalizador-atualizacao.ts # Valida campos estruturados por tipo de atualização
│ ├── normalizador-atualizacao.spec.ts
│ ├── verificador-prazos.ts # CronJob: verifica prazos próximos e vencidos
│ ├── verificador-prazos.spec.ts
│ ├── d6b.constants.ts # Config estática: tipos, limiares de prazo, parâmetros da IA
│ ├── triggers-demanda-iniciada.ts # Heurística de primeiro contato formal
│ └── triggers-demanda-iniciada.spec.ts
├── dto/
│ ├── sugerir-estruturacao.dto.ts # Request/Response do endpoint de IA
│ ├── registrar-atualizacao.dto.ts # Contrato do evento conselheiro.atualização_registrada
│ ├── concluir-demanda.dto.ts # Contrato do evento demanda.concluída
│ └── acompanhamento-conselheiro.dto.ts # Resposta do GET de acompanhamento
└── types.ts # Tipos internos: TipoAtualizacao, StatusAcompanhamento, Payloads
@Module({
imports: [ScheduleModule.forRoot()], // @nestjs/schedule para CronJob de verificação de prazos
controllers: [D6bController], // 3 endpoints: sugerir-estruturacao, acompanhamento, ratificar-conclusao
providers: [
D6bService,
D6bRepository,
EstruturadorIaService,
NormalizadorAtualizacao,
VerificadorPrazos,
TriggersDemandaIniciada,
ConselheiroGuard,
],
exports: [],
})
export class D6bModule implements OnModuleInit {
constructor(private readonly d6bService: D6bService) {}
async onModuleInit() {
await this.d6bService.iniciar();
}
}
  • O módulo não é @Global(). A D-6b não é dependência de nenhuma outra colônia. Outras colônias consomem seus eventos (conselheiro.demanda_iniciada, conselheiro.atualização_publicada, conselheiro.prazo_próximo, conselheiro.ciclo_concluído, demanda.concluída), não seu código.
  • O módulo não importa EventBusModule explicitamente. EventBusModule é @Global(), e o EventBusService é injetável sem import.
  • O módulo não importa RegistryModule. A validação de schema dos eventos publicados é feita pelo próprio Event Bus (N-0a) no momento do publicar().
  • O módulo importa ScheduleModule do @nestjs/schedule para o CronJob de verificação de prazos (@Cron no VerificadorPrazos). O cron roda a cada 6 horas e o custo operacional no monolito é desprezível.
  • O OnModuleInit dispara o protocolo de inicialização: carrega config estática, replay de eventos perdidos + registro de handlers.
  • O módulo não registra ThrottlerModule. As rotas do controller aplicam @Throttle próprio (10/min na sugestão, 60/min no acompanhamento e 5/min na ratificação); o rate limiting das rotas de escrita do BFF é responsabilidade da D-1a.
  • O endpoint REST POST /d6b/sugerir-estruturacao é uma exceção ao modelo de colônia de eventos. É chamado diretamente pelo front-end do conselheiro durante o registro de atualização, sob o ConselheiroGuard e com @Throttle de 10/min. O endpoint é stateless: não persiste, só retorna sugestão. A decisão de publicar é do conselheiro, e a publicação ocorre via evento no barramento, não via REST. As rotas de acompanhamento e ratificação completam o conjunto de leituras do workspace, cada uma restrita ao próprio schema d6b.
  • Os parâmetros de IA (iaHabilitada, iaScoreFallback, iaScoreMinimoAuto, IA_TIMEOUT_MS) e de prazo (diasAntecedenciaAlertaPrazo, diasLimiteAcompanhamentoInativo) são carregados de relatoria/d6b.constants.ts, arquivo de configuração estática no MVP.
export class D6bService {
iniciar(): Promise<void>;
reprocessarEventosPerdidos(): Promise<void>;
registrarConsumidores(): void;
obterAcompanhamentoConselheiro(conselheiroId: string): Promise<AcompanhamentoConselheiro>;
onConselheiroSorteado(evento: EventoRecebido): Promise<void>;
onConselheiroAtualizacaoRegistrada(evento: EventoRecebido): Promise<void>;
onConclusaoConfirmada(evento: EventoRecebido): Promise<void>;
ratificarConclusao(demandaId: string, cidadaoId: string): Promise<{ demanda_id: string; status: string }>;
// IA
sugerirEstruturacao(textoBruto: string, tipoAtualizacao: string, demandaId: string): Promise<SugestaoIa>;
}
// D6bService consome 4 eventos em produção
// ('conselheiro.sorteado', 'conselheiro.atualização_registrada', 'conselheiro.atualização_transcrita'
// e 'demanda.conclusao_confirmada'), publica 'conselheiro.demanda_iniciada',
// 'conselheiro.atualização_publicada' (1.2.0 com suspeição, 1.1.0 na transcrição limpa e 1.0.0 no relato digitado limpo),
// 'conselheiro.prazo_próximo', 'conselheiro.ciclo_concluído', 'demanda.concluída' e
// 'demanda.resumo_ciclo_atualizado' (1.0.0)

O ResumoCicloService compõe o resumo público do ciclo com o template determinístico resumo_v1, a partir das atualizações publicadas e do desfecho. O D6bService o chama depois de cada conselheiro.atualização_publicada e no fecho do ciclo.

A classe é interna ao módulo. Nenhuma outra colônia injeta D6bService. A comunicação com o exterior é via barramento (eventos de saída) e via REST (sugestão de IA, acompanhamento e ratificação, chamados pelo app).

A D-6b expõe três endpoints REST, fora do prefixo api (exclusão explícita do main.ts): a sugestão de IA, chamada de forma síncrona pelo front-end do conselheiro durante o fluxo de criação de atualização; o acompanhamento ativo do conselheiro (leitura do próprio estado); e a ratificação de conclusão coletiva.

@Controller('d6b')
export class D6bController {
constructor(private readonly d6bService: D6bService) {}
@Post('sugerir-estruturacao')
@Throttle({ default: { limit: 10, ttl: 60000 } })
@UseGuards(ConselheiroGuard)
async sugerir(@Body() dto: SugerirEstruturacaoDto): Promise<SugestaoIaResponse> {
const sugestao = await this.d6bService.sugerirEstruturacao(
dto.textoBruto,
dto.tipoAtualizacao,
dto.demandaId,
);
return {
titulo_sugerido: sugestao.titulo_sugerido,
texto_sugerido: sugestao.texto_sugerido,
campos_estruturados: sugestao.campos_estruturados,
score_confianca: sugestao.score_confianca,
};
}
@Get('conselheiros/me/acompanhamento')
@Throttle({ default: { limit: 60, ttl: 60000 } })
@UseGuards(ConselheiroGuard)
async obterAcompanhamento(@Req() request: RequisicaoConselheiro) {
return this.d6bService.obterAcompanhamentoConselheiro(request.cidadao_id);
}
@Post('demandas/:demanda_id/ratificar-conclusao')
@HttpCode(201)
@Throttle({ default: { limit: 5, ttl: 60000 } })
@UseGuards(ConselheiroGuard)
async ratificarConclusao(
@Param('demanda_id', new ParseUUIDPipe({ version: '4' })) demandaId: string,
@Req() request: RequisicaoConselheiro,
): Promise<{ demanda_id: string; status: string }> {
return this.d6bService.ratificarConclusao(demandaId, request.cidadao_id);
}
}

O endpoint de sugestão exige sessão Google (ConselheiroGuard) como as demais rotas do workspace e responde 401 sem JWT. A operação segue stateless: nada persiste e nada publica. O acompanhamento e a ratificação exigem o mesmo guard; sem o cidadao_id injetado pelo guard, respondem 401. O limite de requisições da sugestão é 10/min.

Acompanhamento: GET /d6b/conselheiros/me/acompanhamento devolve o acompanhamento ativo do conselheiro autenticado, com status, datas, total de atualizações, pendência de ratificação, prazos registrados e as últimas 50 atualizações em ordem decrescente. Cada atualização leva status: publicada, aguardando_transcricao ou falha_transcricao, derivado do marcador transcricao_status do conteúdo estruturado. Sem acompanhamento ativo, o campo acompanhamento é nulo e as listas vêm vazias. A rota lê apenas o schema d6b e responde 401 sem sessão Google e 429 no limite de 60/min.

Ratificação: a ratificação exige JWT com sessão Google (ConselheiroGuard). A rota responde: 201 { demanda_id, status: 'concluida' } no sucesso, 401 sem sessão Google, 404 sem acompanhamento ativo, 409 sem pendência de conclusão, 403 quando o cidadão não é o conselheiro do acompanhamento e 429 no limite de 5/min. O BFF não participa da rota: ela é chamada diretamente pela interface do conselheiro.

O app chama o endpoint de sugestão durante a tela de “Registrar atualização”:

  1. Conselheiro digita texto livre + seleciona tipo de atualização no front-end.
  2. Front-end chama POST /d6b/sugerir-estruturacao diretamente.
  3. D-6b retorna sugestão com campos estruturados (título sugerido, texto sugerido, campos extraídos).
  4. Conselheiro vê lado a lado com o texto original, edita se quiser.
  5. Conselheiro confirma. O front-end envia POST /api/conselheiros/atualizacoes ao BFF, que publica conselheiro.atualização_registrada no barramento com três blocos: texto_bruto, texto_estruturado (versão final do conselheiro) e sugestao_ia (o que a sugestão retornou, para auditoria).
  6. D-6b processa o evento de forma assíncrona e publica conselheiro.atualização_publicada.

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 BFF D-1a → conselheiro.cadastrado → D-6a
Situação do conselheiro (workspace) D-6a (GET /d6a/conselheiros/me/situacao)
Sorteio e atribuição de demanda D-6a
Recusa de atribuição BFF D-1a → conselheiro.atribuicao_recusada → D-6a
Início formal do acompanhamento D-6b (primeira atualização → conselheiro.demanda_iniciada)
Acompanhamento ativo do conselheiro (workspace) D-6b (GET /d6b/conselheiros/me/acompanhamento)
Registro de atualizações BFF D-1a → conselheiro.atualização_registrada → D-6b
Sugestão de IA para estruturação D-6b (endpoint REST, chamado pelo app)
Alerta de prazo próximo D-6b (CronJob, publica conselheiro.prazo_próximo)
Conclusão da demanda D-6b (publica demanda.concluída + conselheiro.ciclo_concluído)
Pendência e ratificação da conclusão coletiva D-6b (consome demanda.conclusao_confirmada; rota ratificar-conclusao)
Liberação do conselheiro D-6a (consome conselheiro.ciclo_concluído da D-6b)
Avaliação do conselheiro D-17 (Fase 2)
Suspensão por múltiplas recusas D-6a

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

Registro de cada relação conselheiro⇄demanda sob acompanhamento. Uma linha é criada quando a D-6b recebe conselheiro.sorteado. Uma linha pode ser encerrada por conclusão da demanda, fim de mandato, desistência ou reatribuição (outro conselheiro sorteado para a mesma demanda).

CREATE SCHEMA IF NOT EXISTS d6b;
CREATE TABLE d6b.acompanhamentos (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
demanda_id UUID NOT NULL,
conselheiro_id UUID NOT NULL,
unidade_civica_id UUID NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'aguardando_inicio',
data_sorteio TIMESTAMPTZ(2) NOT NULL DEFAULT NOW(),
data_inicio TIMESTAMPTZ(2),
data_conclusao TIMESTAMPTZ(2),
motivo_encerramento VARCHAR(30),
total_atualizacoes INTEGER NOT NULL DEFAULT 0,
texto_resumo_final TEXT,
texto_resumo_ciclo TEXT,
pendencia_conclusao JSONB,
saidas_conclusao JSONB,
saidas_conclusao_pendentes BOOLEAN NOT NULL DEFAULT false,
event_id_sorteio UUID NOT NULL,
event_id_ultima_alteracao UUID NOT NULL,
criado_em TIMESTAMPTZ(2) NOT NULL DEFAULT NOW(),
atualizado_em TIMESTAMPTZ(2) NOT NULL DEFAULT NOW()
);
CREATE INDEX acompanhamentos_conselheiro_id_idx
ON d6b.acompanhamentos (conselheiro_id);
CREATE INDEX acompanhamentos_demanda_id_status_idx
ON d6b.acompanhamentos (demanda_id, status);
CREATE INDEX acompanhamentos_unidade_civica_id_status_idx
ON d6b.acompanhamentos (unidade_civica_id, status);
CREATE INDEX acompanhamentos_event_id_sorteio_idx
ON d6b.acompanhamentos (event_id_sorteio);

O banco não tem CHECKs de status nem de coerência entre status e data_conclusao. Os valores são garantidos em aplicação.

Coluna Tipo Descrição
id UUID PK Identificador interno do acompanhamento. Gerado pela D-6b.
demanda_id UUID FK lógica para o registro de demanda. Sem constraint formal — regra de isolamento.
conselheiro_id UUID FK lógica para d6a.conselheiros.id ou cidadao_id.
unidade_civica_id UUID UC da demanda. Extraído do payload de conselheiro.sorteado.
status VARCHAR(20) Estado atual do acompanhamento. Ver transições em 4.8.
data_sorteio TIMESTAMPTZ(2) Quando o conselheiro foi sorteado. Extraído de conselheiro.sorteado.
data_inicio TIMESTAMPTZ(2) Quando o conselheiro fez a primeira ação formal. Preenchido ao publicar conselheiro.demanda_iniciada.
data_conclusao TIMESTAMPTZ(2) Quando o acompanhamento foi encerrado. Preenchido ao publicar conselheiro.ciclo_concluído.
motivo_encerramento VARCHAR(30) Razão do encerramento: demanda_concluida, fim_mandato, desistencia, reatribuido.
total_atualizacoes INTEGER Contador de atualizações registradas. Incrementado no registro de cada atualização, inclusive a pendente de transcrição. A D-7 conta apenas as publicadas.
texto_resumo_final TEXT Resumo do ciclo ao encerrar. Preenchido com o texto estruturado da atualização de conclusão.
texto_resumo_ciclo TEXT Resumo público do ciclo, gerado pelo template resumo_v1 a partir das atualizações publicadas e do desfecho. Atualizado a cada publicação e no fecho, com sanitização de PII antes de persistir e publicar.
pendencia_conclusao JSONB Nulo sem pendência; { conclusao_id, total_conclusoes, data } quando a conclusão coletiva cruzou o limiar e aguarda ratificação. Limpo na ratificação e na conclusão por atualização normal.
saidas_conclusao JSONB Snapshot das saídas da conclusão (demanda.concluída e conselheiro.ciclo_concluído), com tipo, event_id, correlacao_id e payload. Persistido antes da publicação; a varredura do boot republica o que ficou pendente.
saidas_conclusao_pendentes BOOLEAN true enquanto as saídas de conclusão persistidas ainda não foram publicadas. A varredura do boot busca os concluídos com a flag ativa e a desliga após a publicação.
event_id_sorteio UUID event_id do evento conselheiro.sorteado que originou este acompanhamento. Para idempotência.
event_id_ultima_alteracao UUID event_id do último evento que alterou este registro. Para rastreamento.
criado_em TIMESTAMPTZ(2) Timestamp de criação do registro.
atualizado_em TIMESTAMPTZ(2) Última alteração no registro.

A restrição de acompanhamento ativo é um índice único PARCIAL, criado por migration manual (20260815163000_d6b_unique_acompanhamento_ativo):

CREATE UNIQUE INDEX uq_d6b_acompanhamento_demanda_ativa
ON d6b.acompanhamentos (demanda_id)
WHERE status IN ('aguardando_inicio', 'em_andamento');

Ela garante:

  • No máximo um acompanhamento ativo (aguardando_inicio ou em_andamento) por demanda.
  • Múltiplos registros com status concluido, desistencia, fim_mandato ou reatribuido no histórico. Um UNIQUE (demanda_id, status) bloquearia duas reatribuições.

O Prisma não representa índice único parcial no schema; o índice vive na migration. Quando a D-6b recebe conselheiro.sorteado para uma demanda que já tem acompanhamento ativo com outro conselheiro, o anterior é encerrado como reatribuido antes de criar o novo. O índice garante que dois ativos não coexistem.

Histórico de todas as atualizações publicadas pelos conselheiros. A atualização com áudio nasce como linha pendente, com texto_estruturado vazio e conteudo_estruturado.transcricao_status = 'aguardando', e é preenchida uma única vez quando a transcrição chega. A partir da publicação, o append-only vale: nenhuma atualização publicada é alterada ou removida. Cada linha publicada corresponde a um evento conselheiro.atualização_publicada.

CREATE TABLE d6b.atualizacoes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
acompanhamento_id UUID NOT NULL,
demanda_id UUID NOT NULL,
conselheiro_id UUID NOT NULL,
tipo VARCHAR(30) NOT NULL,
texto_bruto TEXT NOT NULL,
texto_estruturado TEXT NOT NULL,
sugestao_ia JSONB,
conteudo_estruturado JSONB NOT NULL DEFAULT '{}',
origem_estruturacao VARCHAR(20) NOT NULL DEFAULT 'conselheiro',
versao_formato INTEGER NOT NULL DEFAULT 1,
sequencia INTEGER NOT NULL,
event_id_publicacao UUID NOT NULL,
correlacao_id UUID NOT NULL,
criado_em TIMESTAMPTZ(2) NOT NULL DEFAULT NOW(),
CONSTRAINT atualizacoes_event_id_publicacao_key
UNIQUE (event_id_publicacao),
CONSTRAINT atualizacoes_acompanhamento_id_fkey
FOREIGN KEY (acompanhamento_id) REFERENCES d6b.acompanhamentos (id)
);
CREATE INDEX atualizacoes_acompanhamento_id_idx
ON d6b.atualizacoes (acompanhamento_id);
CREATE INDEX atualizacoes_demanda_id_idx
ON d6b.atualizacoes (demanda_id);
CREATE INDEX atualizacoes_conselheiro_id_idx
ON d6b.atualizacoes (conselheiro_id);
CREATE INDEX atualizacoes_demanda_id_criado_em_idx
ON d6b.atualizacoes (demanda_id, criado_em);

Sem CHECKs de tipo e de origem_estruturacao no banco. Os valores são validados em aplicação pelo NormalizadorAtualizacao.

Coluna Tipo Descrição
id UUID PK Identificador interno da atualização.
acompanhamento_id UUID FK interna para d6b.acompanhamentos.id.
demanda_id UUID FK lógica para o registro de demanda. Redundância para queries sem JOIN.
conselheiro_id UUID FK lógica para o conselheiro. Redundância para queries sem JOIN.
tipo VARCHAR(30) Tipo estruturado da atualização (ver 2.11).
texto_bruto TEXT Texto original enviado pelo conselheiro, sem processamento. Preservado para auditoria.
texto_estruturado TEXT Versão final revisada pelo conselheiro. É o texto publicado.
sugestao_ia JSONB O que a sugestão retornou antes da revisão do conselheiro. Nulo se origem_estruturacao = 'conselheiro'. Campos: titulo_sugerido, texto_sugerido, campos_estruturados, score_confianca.
conteudo_estruturado JSONB Campos estruturados específicos do tipo de atualização (ex: protocolo_numero, orgao, data_contato). Schema varia por tipo. No relato por áudio, leva audio_object_key, transcricao_status (aguardando, concluida ou falha), modelo_transcricao e confianca_transcricao.
origem_estruturacao VARCHAR(20) conselheiro, ia_assistida ou sistema (atualizações geradas pela conclusão coletiva e pela ratificação).
versao_formato INTEGER Versão do formato de conteudo_estruturado. Incrementado se o schema evoluir.
sequencia INTEGER Posição da atualização no acompanhamento (1, 2, 3…). É o numero_sequencial publicado no evento.
event_id_publicacao UUID event_id do evento que originou a atualização. UNIQUE — idempotência.
correlacao_id UUID correlacao_id do evento de origem. Para trace distribuído.
criado_em TIMESTAMPTZ(2) Timestamp de criação do registro.

Registro de prazos estimados definidos pelos conselheiros. Um acompanhamento pode ter múltiplos prazos ao longo do ciclo. Cada prazo tem uma data de validade e um status de verificação.

CREATE TABLE d6b.prazos (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
acompanhamento_id UUID NOT NULL,
demanda_id UUID NOT NULL,
data_estimada DATE NOT NULL,
descricao TEXT,
status VARCHAR(20) NOT NULL DEFAULT 'vigente',
alerta_enviado BOOLEAN NOT NULL DEFAULT false,
data_alerta TIMESTAMPTZ(2),
event_id_atualizacao UUID NOT NULL,
criado_em TIMESTAMPTZ(2) NOT NULL DEFAULT NOW(),
CONSTRAINT prazos_event_id_atualizacao_key
UNIQUE (event_id_atualizacao),
CONSTRAINT prazos_acompanhamento_id_fkey
FOREIGN KEY (acompanhamento_id) REFERENCES d6b.acompanhamentos (id)
);
CREATE INDEX prazos_acompanhamento_id_idx
ON d6b.prazos (acompanhamento_id);
CREATE INDEX prazos_status_data_estimada_idx
ON d6b.prazos (status, data_estimada);

Sem CHECK de status no banco.

Coluna Tipo Descrição
id UUID PK Identificador interno do prazo.
acompanhamento_id UUID FK interna para d6b.acompanhamentos.id.
demanda_id UUID FK lógica para o registro de demanda.
data_estimada DATE Data estimada de resolução ou próximo marco.
descricao TEXT Descrição do que se espera até essa data.
status VARCHAR(20) vigente (ativo), vencido (passou da data sem resolução), cumprido (resolvido antes/dentro), substituido (substituído por prazo mais recente).
alerta_enviado BOOLEAN Se o alerta conselheiro.prazo_próximo já foi publicado para este prazo.
data_alerta TIMESTAMPTZ(2) Quando o alerta foi publicado.
event_id_atualizacao UUID event_id da atualização que definiu este prazo. UNIQUE — idempotência.
criado_em TIMESTAMPTZ(2) Timestamp de criação do registro.

Controle de idempotência. Cada evento processado com sucesso pela D-6b é registrado aqui. Para eventos consumidos que não geram entidade própria com UNIQUE em event_id, esta tabela é a barreira de duplicação.

CREATE TABLE d6b.processed_events (
event_id UUID PRIMARY KEY,
event_type VARCHAR(100) NOT NULL,
processed_at TIMESTAMPTZ(2) NOT NULL DEFAULT NOW()
);
CREATE INDEX processed_events_event_type_idx
ON d6b.processed_events (event_type);

Controla o cursor de processamento para replay seletivo após falha ou reinicialização. Segue o padrão definido pela N-0a (Event Bus).

CREATE TABLE d6b.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 4 linhas, uma por tipo consumido: conselheiro.sorteado, conselheiro.atualização_registrada, conselheiro.atualização_transcrita e demanda.conclusao_confirmada.

Guarda de sorteio tardio. Registra que a demanda concluiu por confirmação coletiva da D-12. Preenchida em dois pontos: quando demanda.conclusao_confirmada chega com ratificação desnecessária e quando o conselheiro ratifica a conclusão. Um conselheiro.sorteado posterior para a mesma demanda não cria acompanhamento.

CREATE TABLE d6b.conclusoes_sociais (
demanda_id UUID PRIMARY KEY,
evento_id UUID NOT NULL,
criado_em TIMESTAMPTZ(2) NOT NULL DEFAULT NOW(),
CONSTRAINT conclusoes_sociais_evento_id_key
UNIQUE (evento_id)
);

Quatro migrations:

  1. 20260813092442_create_d6b_tables — Cria o schema d6b e as tabelas acompanhamentos, atualizacoes, prazos, processed_events e consumer_offset, com as constraints UNIQUE, as FKs internas e os índices descritos nas seções 2.2 a 2.6.
  2. 20260815163000_d6b_unique_acompanhamento_ativo — Cria o índice único parcial uq_d6b_acompanhamento_demanda_ativa.
  3. 20260902193828_d6b_conclusoes_sociais — Cria a tabela d6b.conclusoes_sociais da guarda de sorteio tardio.
  4. 20260918170000_d6b_saidas_conclusao — Adiciona saidas_conclusao e saidas_conclusao_pendentes a d6b.acompanhamentos para a varredura de saídas de conclusão.
  5. 0011_d6b_resumo_ciclo — Adiciona a coluna texto_resumo_ciclo a d6b.acompanhamentos.

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 avaliacao_conselheiro em acompanhamentos para integração com D-17, índices compostos para queries de dashboard por período.

O schema d6b tem FKs internas entre suas próprias tabelas:

  • d6b.atualizacoes.acompanhamento_id → d6b.acompanhamentos.id
  • d6b.prazos.acompanhamento_id → d6b.acompanhamentos.id

Essas FKs são internas ao schema e não violam o princípio de isolamento. As colunas demanda_id e conselheiro_id em acompanhamentos são correlações lógicas, sem FK formal com schemas de outras colônias.

Índice único parcial em acompanhamentos para evitar concorrência. O cenário de dois conselheiro.sorteado para a mesma demanda com conselheiros diferentes é esperado quando há recusa. O índice único parcial garante que o handler feche o acompanhamento anterior (status reatribuido) antes de inserir o novo (status aguardando_inicio). Sem a restrição, dois acompanhamentos ativos coexistiriam, gerando atualizações conflitantes.

Redundância de demanda_id e conselheiro_id em atualizacoes. Esses campos existem em acompanhamentos e poderiam ser obtidos via JOIN. A redundância permite queries diretas por demanda ou conselheiro sem JOIN, útil para a D-7 (que consumirá a projeção de leitura). O custo de armazenamento é desprezível (dois UUIDs extras por linha).

Tabela prazos separada de atualizacoes. Um prazo é definido em uma atualização de tipo prazo_registrado, mas tem ciclo de vida próprio: pode ser substituído por prazo mais recente, vencido ou cumprido. A tabela separada permite que o CronJob de verificação faça queries eficientes (WHERE status = 'vigente' AND data_estimada <= ?) sem carregar o conteúdo textual das atualizações.

Índice prazos_status_data_estimada_idx. A query do CronJob de verificação é “prazos vigentes com alerta_enviado = false cuja data estimada está a menos de N dias”. O índice composto cobre o filtro por status e faixa de data; o filtro por alerta_enviado é resolvido na leitura.

Tabela processed_events com PK em event_id. Para eventos conselheiro.atualização_registrada, a própria tabela atualizacoes tem UNIQUE em event_id_publicacao. Mas conselheiro.sorteado não gera linha em atualizacoes. A tabela processed_events é a barreira de idempotência genérica para os eventos consumidos.

2.11 Tipos de atualização e seus campos estruturados

Seção intitulada “2.11 Tipos de atualização e seus campos estruturados”

Cada tipo de atualização tem um schema de conteudo_estruturado validado pelo NormalizadorAtualizacao. Campos obrigatórios e valores de enum variam por tipo.

Tipo Descrição Campos estruturados (JSONB)
contato_realizado Conselheiro fez contato com órgão, empresa ou pessoa relevante. canal (obrigatório; telefone/email/presencial/oficio), contato_nome, contato_orgao, resultado
protocolo_aberto Número de protocolo formal em órgão público. protocolo_numero (obrigatório), orgao (obrigatório), data_protocolo, link_acompanhamento
documento_anexado Documento adicionado ao acompanhamento. tipo_documento (obrigatório), descricao (obrigatório), anexo_id (referência ao evento anexo.processado da D-1c)
entrave_registrado Bloqueio ou dificuldade encontrada. orgao_envolvido (obrigatório), descricao_entrave (obrigatório), gravidade (baixa/media/alta)
status_atualizado Mudança de status da demanda no mundo real. novo_status (obrigatório; pendente/em_andamento/aguardando/concluido/bloqueado), fonte_informacao
prazo_registrado Prazo estimado para resolução ou próximo marco. data_estimada (obrigatório; AAAA-MM-DD, hoje ou futuro), descricao_prazo, fonte_estimativa

O NormalizadorAtualizacao valida os campos obrigatórios, os enums de canal, gravidade e novo_status, o formato do protocolo_numero e a data de data_estimada (formato e rejeição de data no passado).

Limitação do MVP: o tipo documento_anexado não carrega anexo real. A atualização publica texto descritivo e o campo anexo_id fica reservado para a Fase 2, quando o pipeline de anexo da relatoria existir. O contrato do campo permanece como referência do desenho futuro.


A D-6b consome três tipos de evento como gatilho de processamento e produz cinco. 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-6b.

3.1 Evento consumido: conselheiro.sorteado (gatilho primário)

Seção intitulada “3.1 Evento consumido: conselheiro.sorteado (gatilho primário)”
Propriedade Valor
Tipo conselheiro.sorteado
Schema version 1.0.0
Produtor D-6a (Sorteio e Atribuição)
Consumidores D-6b (esta colônia), D-5 (Agenda), 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. A D-6b inicia o acompanhamento.

Payload esperado (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.2 Evento consumido: conselheiro.atualização_registrada

Seção intitulada “3.2 Evento consumido: conselheiro.atualização_registrada”
Propriedade Valor
Tipo conselheiro.atualização_registrada
Schema version 1.1.0 (a 1.0.0 permanece aceita)
Produtor BFF da D-1a
Consumidor D-6b (esta colônia)
Descrição Conselheiro revisou, confirmou e publicou uma atualização. O BFF recebe a confirmação do front-end, publica este evento e retorna confirmação ao usuário. A D-6b processa de forma assíncrona.

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

interface ConselheiroAtualizacaoRegistradaPayload {
conselheiro_id: string;
demanda_id: string;
unidade_civica_id: string;
tipo: string; // um dos 6 tipos validados pelo NormalizadorAtualizacao
texto_bruto: string; // 0 a 10.000 caracteres; vazio quando há áudio
texto_estruturado: string; // 0 a 10.000 caracteres; vazio quando há áudio
audio_object_key?: string; // chave do relato em áudio no caminho privado do cidadão
conteudo_estruturado?: Record<string, unknown>;
origem_estruturacao?: 'conselheiro' | 'ia_assistida';
sugestao_ia?: Record<string, unknown>; // campo adicional publicado pelo BFF
timestamp: string; // ISO-8601
}

O catálogo exige timestamp e limita os textos a 10.000 caracteres. Sem audio_object_key, os textos continuam exigindo pelo menos 1 caractere. sugestao_ia não faz parte do schema do catálogo; o BFF o publica como campo adicional quando o conselheiro usou a sugestão.

Quando o payload traz audio_object_key, a D-6b insere a atualização em estado pendente, com o texto vazio, e aguarda o conselheiro.atualização_transcrita publicado pela D-1b. A transcrição aplica o texto na linha pendente, marca a origem automática e publica a atualização. Sem sucesso na transcrição, a linha fica marcada com falha e nada é publicado. O áudio não é publicado e fica restrito à auditoria.

Fluxo de revisão (o que ocorre antes deste evento ser publicado):

Front-end: conselheiro digita texto livre + seleciona tipo
→ App: chama POST /d6b/sugerir-estruturacao diretamente
→ D-6b: retorna sugestão (título, texto sugerido, campos)
→ Front-end: exibe texto original + sugestão lado a lado
→ conselheiro edita se necessário
→ conselheiro clica "Publicar"
→ BFF D-1a: publica conselheiro.atualização_registrada no barramento
→ BFF: retorna confirmação síncrona ao front-end
→ D-6b: processa assíncrona → publica conselheiro.atualização_publicada

3.2.1 Evento consumido: conselheiro.atualização_transcrita

Seção intitulada “3.2.1 Evento consumido: conselheiro.atualização_transcrita”
Propriedade Valor
Tipo conselheiro.atualização_transcrita
Schema version 1.0.0
Produtor D-1b (Normalização)
Consumidor D-6b (esta colônia)
Descrição Resultado da transcrição em segundo plano do relato em áudio do conselheiro. O atualizacao_event_id é o event_id do conselheiro.atualização_registrada correspondente e casa com a linha pendente por d6b.atualizacoes.event_id_publicacao.

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

interface ConselheiroAtualizacaoTranscritaPayload {
atualizacao_event_id: string; // event_id do atualização_registrada
conselheiro_id: string;
demanda_id: string;
unidade_civica_id: string;
sucesso: boolean;
texto_transcrito: string; // vazio quando sucesso = false
confianca: number;
motivo_falha?: string; // removido na redação
timestamp: string;
}

Comportamento:

  • Com sucesso, a D-6b aplica texto_transcrito em texto_estruturado, grava conteudo_estruturado.transcricao_status = 'concluida', modelo_transcricao e confianca_transcricao, e publica conselheiro.atualização_publicada com origem_texto, modelo_transcricao e confianca_transcricao. A versão é a 1.2.0 quando a denylist encontra termo no texto transcrito e a 1.1.0 no texto limpo. O workspace usa a marcação gravada no conteúdo estruturado para o selo de texto automático com a confiança.
  • Sem sucesso, a linha fica com transcricao_status = 'falha' e nada é publicado. O painel orienta o conselheiro a registrar o relato por texto.
  • A publicação usa os dados da linha e não depende de o acompanhamento estar ativo.
  • Evento sem linha correspondente é descartado com log e o cursor avança. A idempotência é por event_id em d6b.processed_events, e a linha só é preenchida enquanto transcricao_status = 'aguardando'.
  • O prazo_registrado com áudio já grava o prazo no registro da atualização; a transcrição não altera prazos.
  • A primeira atualização com áudio continua publicando conselheiro.demanda_iniciada no registro, antes da transcrição.

3.3 Evento produzido: conselheiro.demanda_iniciada

Seção intitulada “3.3 Evento produzido: conselheiro.demanda_iniciada”
Propriedade Valor
Tipo conselheiro.demanda_iniciada
Schema version 1.0.0
Produtor D-6b (esta colônia)
Consumidores D-5 (Agenda — transição atribuidoem_progresso), D-7 (Transparência)
Descrição Conselheiro fez o primeiro contato formal. Publicado automaticamente na primeira atualização registrada para o par conselheiro⇄demanda.

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

interface ConselheiroDemandaIniciadaPayload {
demanda_id: string;
conselheiro_id: string;
unidade_civica_id: string;
tipo_primeira_acao: string; // Tipo da atualização que disparou o início
timestamp_inicio: string; // ISO-8601
}

Momento da publicação: dentro do handler onConselheiroAtualizacaoRegistrada, após persistir a atualização e atualizar o acompanhamento de aguardando_inicio para em_andamento. As escritas e a publicação não usam transação única: a atualização é persistida primeiro, o acompanhamento é atualizado em seguida e o evento é publicado depois.

3.4 Evento produzido: conselheiro.atualização_publicada

Seção intitulada “3.4 Evento produzido: conselheiro.atualização_publicada”
Propriedade Valor
Tipo conselheiro.atualização_publicada
Schema version 1.2.0 (a 1.1.0 e a 1.0.0 permanecem no catálogo)
Produtor D-6b (esta colônia)
Consumidores D-7 (Transparência — timeline da demanda), D-24 (Memória de Caminhos — campos estruturados do caminho) e D-1d (moderação do relato)
Descrição Atualização do conselheiro processada, validada e publicada. Contém o texto final e os campos estruturados. A atualização transcrita leva a marcação automática. A 1.2.0 marca o texto retido pela denylist. O campo opcional caminho leva a cópia sanitizada dos campos do caminho usada pela D-24.

Payload publicado (conforme Registry N-0b, v1.2.0):

interface ConselheiroAtualizacaoPublicadaPayload {
atualizacao_id: string; // ID interno da atualização (UUID)
demanda_id: string;
conselheiro_id: string;
unidade_civica_id: string;
tipo: string; // Tipo de atualização
texto_estruturado: string; // Texto final (não o texto_bruto, que fica no estado próprio)
descricao_sanitizada: string; // Descrição curta derivada do tipo e dos campos, com PII sanitizada
conteudo_estruturado: Record<string, unknown>; // Campos estruturados específicos
caminho?: CaminhoAtualizacaoPublicada; // Cópia sanitizada dos campos do caminho para a D-24
origem_estruturacao: string; // 'conselheiro' | 'ia_assistida'
origem_texto?: string; // 'automatico' quando o texto veio de transcrição
modelo_transcricao?: string; // Modelo que produziu a transcrição
confianca_transcricao?: number; // Confiança da transcrição
numero_sequencial: number; // Sequência da atualização no acompanhamento (1, 2, 3...)
timestamp: string; // ISO-8601
conteudo_suspeito?: boolean; // true quando a denylist encontra termo no texto
termos_suspeitos?: string[]; // Termos que dispararam a sinalização
}

descricao_sanitizada é obrigatória no catálogo (até 2.000 caracteres). A D-6b a gera com gerarDescricaoAtualizacaoPublicada a partir do tipo e do conteúdo estruturado, com o texto truncado, e a passa por sanitizarTexto antes de publicar. O caminho é gerado por gerarCaminhoSanitizado, também com sanitizarTexto e truncamento por campo, e é preservado pela redação do log para o rebuild da D-24. O evento é o que a D-7 usa para a timeline pública, e a atualização de conclusão não gera conselheiro.atualização_publicada: a conclusão aparece na timeline por demanda.concluída.

Denylist de texto na publicação. Antes de publicar, a D-6b verifica o texto_estruturado com verificarDenylist do helper compartilhado src/shared/moderacao/denylist-texto.ts, o mesmo da D-1b. A lista vem de MODERACAO_DENYLIST_TEXTO e fica restrita ao secret de ambiente. A escolha da versão segue o resultado:

Condição Versão publicada
Denylist encontra termo 1.2.0, com conteudo_suspeito=true e termos_suspeitos
Texto limpo vindo de transcrição 1.1.0, com a marcação automática
Texto limpo digitado 1.0.0

A checagem cobre o relato digitado e o transcrito, porque a publicação é o ponto único dos dois caminhos. O relato suspeito nasce interno na D-7 e entra na fila da D-1d como relato; o relato limpo segue público sem passar pela fila. Na redação do log, conteudo_suspeito é preservado e termos_suspeitos é descartado, como em demanda.normalizada.

Propriedade Valor
Tipo conselheiro.prazo_próximo
Schema version 1.0.0
Produtor D-6b (esta colônia — CronJob)
Consumidores Sistema de notificação (Fase 2), D-7 (Transparência)
Descrição Um prazo registrado está próximo do vencimento. Publicado N dias antes da data estimada.

Payload publicado:

interface ConselheiroPrazoProximoPayload {
demanda_id: string;
conselheiro_id: string;
unidade_civica_id: string;
prazo_id: string; // ID do prazo em d6b.prazos
data_estimada: string; // Data limite (ISO-8601 date)
dias_restantes: number;
descricao_prazo: string;
timestamp: string; // ISO-8601
}

Frequência de verificação: a cada 6 horas. Antecedência padrão: 3 dias. Se um prazo vigente tem data estimada dentro do limite de N dias e ainda não recebeu alerta (alerta_enviado = false), o evento é publicado e alerta_enviado é marcado como true.

Propriedade Valor
Tipo demanda.concluída
Schema version 1.0.0
Produtor D-6b (esta colônia)
Consumidores D-5 (Agenda, transição para concluido), D-7 (Transparência) e D-12 (Detecção de Duplicidade, índice da candidata)
Descrição A demanda foi concluída. Publicado quando o conselheiro marca a demanda como resolvida.

Payload publicado:

interface DemandaConcluidaPayload {
demanda_id: string;
unidade_civica_id: string;
data_conclusao: string; // ISO-8601
conselheiro_id: string;
categoria_id?: string; // campo adicional, presente quando o conteúdo traz categoria_id
dias_ate_conclusao: number; // Dias entre sorteio e conclusão
total_atualizacoes: number; // Total de atualizações no ciclo, incluindo a de conclusão
resumo_final: string; // Texto estruturado da atualização de conclusão
timestamp: string; // ISO-8601
}

Publicado antes de conselheiro.ciclo_concluído. A distinção é semântica: demanda.concluída informa que a demanda foi resolvida; conselheiro.ciclo_concluído informa que o conselheiro está liberado. O estado do acompanhamento é persistido primeiro; os dois eventos são publicados em seguida, nessa ordem, sem transação única.

3.7 Evento produzido: conselheiro.ciclo_concluído

Seção intitulada “3.7 Evento produzido: conselheiro.ciclo_concluído”
Propriedade Valor
Tipo conselheiro.ciclo_concluído
Schema version 1.0.0
Produtor D-6b (esta colônia)
Consumidores D-6a (Sorteio — libera conselheiro), D-7 (Transparência), D-16 (Controle de Mandato, Fase 2)
Descrição Ciclo de acompanhamento do conselheiro foi encerrado.

Payload publicado (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; // Dias entre início e fim
resumo_final?: string;
timestamp: string; // ISO-8601
}

3.7.1 Evento produzido: demanda.resumo_ciclo_atualizado

Seção intitulada “3.7.1 Evento produzido: demanda.resumo_ciclo_atualizado”
Propriedade Valor
Tipo demanda.resumo_ciclo_atualizado
Schema version 1.0.0
Produtor D-6b (esta colônia)
Consumidores D-7 (Transparência)
Descrição Resumo público do ciclo de acompanhamento, publicado a cada atualização publicada e no fecho.

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

interface DemandaResumoCicloAtualizadoPayload {
demanda_id: string;
resumo: string; // template resumo_v1, teto de 6000 caracteres
total_atualizacoes: number;
data_inicio: string | null; // ISO-8601; nulo com o ciclo sem primeira ação formal
data_fim: string | null; // ISO-8601; nulo com o ciclo em andamento
versao_metodo: string; // 'resumo_v1'
gerado_em: string; // ISO-8601
fontes: Array<{ atualizacao_id: string }>;
}

O texto é sanitizado antes de persistir e publicar. O event_id é determinístico por demanda e total de atualizações, o que evita republicação idêntica. O fecho entra no snapshot saidas_conclusao e a varredura do boot republica a saída quando a publicação falha. A D-6b não consome caminho.atualizado no MVP: o dossiê do caminho chega ao conselheiro pela projeção da D-7.

3.8 Ordem de operações no handler onConselheiroSorteado

Seção intitulada “3.8 Ordem de operações no handler onConselheiroSorteado”
1. Verificar idempotência
→ repo.buscarProcessedEvent(event_id)
→ se processado: 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. Guarda de sorteio tardio
→ repo.buscarConclusaoSocial(demanda_id)
→ se a demanda concluiu socialmente: log.warn, registrar processed_event,
avançar o cursor e retornar
4. Verificar acompanhamento ativo da demanda
→ ativo = repo.buscarAcompanhamentoAtivoPorDemanda(demanda_id)
→ se existe com o mesmo conselheiro: log.warn, avançar o cursor e retornar
→ se existe com outro conselheiro:
→ repo.encerrarAcompanhamento(ativo.id, { status: 'reatribuido',
motivo_encerramento: 'reatribuido', data_conclusao: agora, event_id })
→ repo.inserirProcessedEvent(ativo.event_id_sorteio, 'conselheiro.sorteado')
→ log com o acompanhamento anterior e o novo conselheiro
5. Criar o acompanhamento
→ repo.inserirAcompanhamento com status 'aguardando_inicio' e data_sorteio
do timestamp do payload (ou do momento, se ausente)
→ repo.inserirProcessedEvent(event_id, 'conselheiro.sorteado')
6. Avançar o cursor de 'conselheiro.sorteado'

As escritas não usam transação. O registro do processed_event propaga erro de banco: o handler lança, o EventBus registra na DLQ e o cursor não avança. A violação de unicidade do próprio event_id é o único caso já coberto pela guarda de idempotência.

3.9 Ordem de operações no handler onConselheiroAtualizacaoRegistrada

Seção intitulada “3.9 Ordem de operações no handler onConselheiroAtualizacaoRegistrada”
1. Verificar idempotência
→ repo.buscarAtualizacaoPorEventIdPublicacao(event_id)
→ se encontrada: log, avançar o cursor e retornar
2. Validar payload mínimo
→ conselheiro_id, demanda_id, unidade_civica_id, tipo, texto_bruto e
texto_estruturado obrigatórios
→ se ausente: log.error, avançar o cursor e retornar
→ tipo precisa estar em TIPOS_ATUALIZACAO_VALIDOS
→ origem_estruturacao aceita 'conselheiro' ou 'ia_assistida' (default 'conselheiro')
→ origem 'ia_assistida' exige sugestao_ia
→ normalizador.validar(tipo, conteudo_estruturado ?? {}) precisa retornar válido
3. Buscar o acompanhamento ativo
→ repo.buscarAcompanhamentoAtivoPorConselheiroEDemanda(conselheiro_id, demanda_id)
→ se não encontrado: log.warn, avançar o cursor e retornar
4. Entrar no lock por acompanhamento (fila de Promises por acompanhamento.id)
→ reler o acompanhamento ativo dentro do lock para pegar o total_atualizacoes vigente
→ se o acompanhamento encerrou nesse intervalo: log.warn, avançar o cursor e retornar
5. Determinar a primeira atualização e a nova sequência
→ primeiraAtualizacao = triggers.ehPrimeiraAtualizacao(acompanhamento.status)
→ novoTotal = acompanhamento.total_atualizacoes + 1
6. Persistir a atualização
→ repo.inserirAtualizacao com sequencia = novoTotal, event_id_publicacao = event_id,
correlacao_id = correlacao_id do evento ?? event_id
7. Atualizar o acompanhamento
→ repo.registrarAtualizacaoNoAcompanhamento: total_atualizacoes = novoTotal,
status 'em_andamento' e data_inicio na primeira atualização, event_id
8. Se tipo = 'prazo_registrado'
→ repo.marcarPrazosVigentesSubstituidos(acompanhamento.id)
→ repo.inserirPrazo com data_estimada do conteúdo estruturado
9. Se tipo = 'status_atualizado' e novo_status = 'concluido'
→ se primeira atualização: publicar conselheiro.demanda_iniciada
→ concluirDemanda (seção 3.10)
→ avançar o cursor e retornar. A atualização de conclusão não gera
conselheiro.atualização_publicada; a conclusão aparece por demanda.concluída.
10. Se o payload traz audio_object_key
→ a linha foi persistida com texto_estruturado vazio e
conteudo_estruturado.transcricao_status = 'aguardando'
→ se primeira atualização: publicar conselheiro.demanda_iniciada
→ não publicar conselheiro.atualização_publicada
→ avançar o cursor e retornar. A publicação fica para a transcrição (3.9.1)
11. Publicar eventos
→ se primeira atualização: conselheiro.demanda_iniciada
→ conselheiro.atualização_publicada com a versão da denylist de texto:
1.2.0 com suspeição e 1.0.0 no texto digitado limpo
12. Avançar o cursor de 'conselheiro.atualização_registrada'

As escritas e as publicações não usam transação. O lock em memória por acompanhamento serializa as atualizações concorrentes e a ratificação; vale para instância única do monolito no MVP, e o lock distribuído fica para a Fase 2.

3.9.1 Ordem de operações no handler onAtualizacaoTranscrita

Seção intitulada “3.9.1 Ordem de operações no handler onAtualizacaoTranscrita”
1. Verificar idempotência por event_id em d6b.processed_events
→ se já processado: avançar o cursor e retornar
2. Validar o payload mínimo
→ atualizacao_event_id e sucesso obrigatórios
→ se ausente: log.error, avançar o cursor e retornar
3. Buscar a atualização por event_id_publicacao = atualizacao_event_id
→ se não encontrada: log.warn, registrar processed_event, avançar o cursor e retornar
4. Buscar o acompanhamento por id (não exige status ativo)
→ se não encontrado: log.error e avançar o cursor
5. Entrar no lock por acompanhamento e reler a linha
→ se a linha sumiu ou o status não é 'aguardando_transcricao':
log.warn, registrar processed_event, avançar o cursor e retornar
6. Sem sucesso na transcrição
→ repo.atualizarTranscricaoPorEventId com transcricao_status = 'falha'
→ registrar processed_event, avançar o cursor e retornar sem publicar
7. Com sucesso
→ repo.atualizarTranscricaoPorEventId com texto e transcricao_status = 'concluida'
→ publicar conselheiro.atualização_publicada com origem_texto 'automatico',
modelo_transcricao e confianca_transcricao (publicarComRetry). A versão é a 1.2.0
quando a denylist encontra termo no texto e a 1.1.0 no texto limpo
→ registrar processed_event e avançar o cursor

A deduplicação da saída é da própria linha: a publicação usa os dados persistidos e o event_id_publicacao, e a D-7 recebe o evento uma única vez por linha.

3.10 Método concluirDemanda(acompanhamento, atualizacao, novoTotal, eventId, correlacaoId)

Seção intitulada “3.10 Método concluirDemanda(acompanhamento, atualizacao, novoTotal, eventId, correlacaoId)”

Chamado quando o conselheiro registra uma atualização de tipo status_atualizado com novo_status = 'concluido'. Marca a demanda como concluída e encerra o ciclo do conselheiro publicando dois eventos distintos.

função concluirDemanda(acompanhamento, atualizacao, novoTotal, eventId, correlacaoId):
dataConclusao = agora
diasAteConclusao = ceil(
(dataConclusao.getTime() - acompanhamento.data_sorteio.getTime())
/ (1000 * 60 * 60 * 24)
)
dataInicio = acompanhamento.data_inicio ?? dataConclusao.toISOString()
// 1. Encerrar o acompanhamento
→ repo.concluirAcompanhamento(acompanhamento.id, {
data_conclusao: dataConclusao,
texto_resumo_final: atualizacao.texto_estruturado,
event_id: eventId
})
// grava status 'concluido', motivo_encerramento 'demanda_concluida'
// e limpa pendencia_conclusao
→ repo.inserirProcessedEvent(eventId, 'conclusao_demanda')
// 2. Persistir as saídas de conclusão como pendentes
→ repo.registrarSaidasConclusaoPendentes(acompanhamento.id, [
{ tipo: 'demanda.concluída', event_id: uuid, correlacao_id: correlacaoId,
payload: {
demanda_id: acompanhamento.demanda_id,
unidade_civica_id: acompanhamento.unidade_civica_id,
data_conclusao: dataConclusao.toISOString(),
conselheiro_id: acompanhamento.conselheiro_id,
categoria_id: atualizacao.conteudo_estruturado.categoria_id (quando presente),
dias_ate_conclusao: diasAteConclusao,
total_atualizacoes: novoTotal,
resumo_final: atualizacao.texto_estruturado,
timestamp: dataConclusao.toISOString()
} },
{ tipo: 'conselheiro.ciclo_concluído', event_id: uuid, correlacao_id: correlacaoId,
payload: {
conselheiro_id: acompanhamento.conselheiro_id,
demanda_id: acompanhamento.demanda_id,
unidade_civica_id: acompanhamento.unidade_civica_id,
motivo_encerramento: 'demanda_concluida',
total_atualizacoes: novoTotal,
data_inicio: dataInicio,
data_fim: dataConclusao.toISOString(),
dias_duracao: dias entre data_inicio e data_fim,
resumo_final: atualizacao.texto_estruturado,
timestamp: dataConclusao.toISOString()
} },
])
// saidas_conclusao guarda o snapshot; saidas_conclusao_pendentes = true
// 3. Publicar demanda.concluída com retry inline (publicarComRetry)
// 4. Publicar conselheiro.ciclo_concluído com retry inline (publicarComRetry)
// 5. Marcar as saídas como publicadas
→ repo.marcarSaidasConclusaoPublicadas(acompanhamento.id)

Sem transação: o acompanhamento é persistido primeiro, as saídas entram no snapshot e só então os dois eventos são publicados, nessa ordem. Cada evento usa o event_id persistido no snapshot; a idempotência do barramento absorve uma republicação. Se uma das publicações falhar definitivamente, o erro é relançado, o cursor não avança e a varredura republicarSaidasConclusaoPendentes do boot seguinte republica as duas saídas a partir do snapshot. O caminho de conclusão vale para a atualização status_atualizado com novo_status = 'concluido' e para a ratificação da conclusão coletiva.

Os encerramentos que não passam por atualização do conselheiro (fim_mandato e desistencia) não estão implementados no MVP. O desenho previsto:

  • o status e o motivo_encerramento do acompanhamento recebem o motivo;
  • data_conclusao é preenchida;
  • publica-se conselheiro.ciclo_concluído com o motivo, data_inicio (nula se o acompanhamento nunca iniciou), data_fim, dias_duracao e resumo_final;
  • demanda.concluída não é publicado. A demanda permanece no backlog em em_progresso na D-5 e pode ser reatribuída a outro conselheiro quando a D-6a processar conselheiro.ciclo_concluído.

Os motivos serão acionados por eventos da D-17 (Controle de Mandato) ou por ação do conselheiro no front-end.

3.12 Ordem de operações no CronJob VerificadorPrazos

Seção intitulada “3.12 Ordem de operações no CronJob VerificadorPrazos”
@Cron('0 */6 * * *') // A cada 6 horas
função verificarPrazosProximos():
config = carregarD6bConstants()
dataLimite = agora + config.diasAntecedenciaAlertaPrazo dias // fim do dia
alertasEnviados = 0
prazosProximos = repo.buscarPrazosVigentesAte(dataLimite)
// status 'vigente', alerta_enviado = false, data_estimada <= dataLimite
para cada prazo em prazosProximos:
acompanhamento = repo.buscarAcompanhamentoPorId(prazo.acompanhamento_id)
se acompanhamento == null:
continuar
se acompanhamento.status NOT IN ('aguardando_inicio', 'em_andamento'):
repo.marcarPrazoVencido(prazo.id)
continuar
publicarPrazoProximo(prazo, acompanhamento)
repo.marcarPrazoAlertaEnviado(prazo.id)
alertasEnviados += 1
prazosVencidos = repo.buscarPrazosVencidos(inicio do dia)
para cada prazo em prazosVencidos:
repo.marcarPrazoVencido(prazo.id)
log("Verificação de prazos concluída", {
alertas_enviados: alertasEnviados,
prazos_vencidos: prazosVencidos.length,
})

publicarPrazoProximo publica conselheiro.prazo_próximo com versao_schema 1.0.0, correlacao_id da demanda, data_estimada no formato AAAA-MM-DD e dias_restantes calculado em dias UTC entre hoje e a data. descricao_prazo vira string vazia quando o prazo não tem descrição. A falha de publicação relança o erro: o prazo não é marcado e o próximo ciclo tenta de novo.

Cenário Comportamento
Evento conselheiro.sorteado reentregue (replay/DLQ) Detectado por event_id em d6b.processed_events. Log, cursor avançado.
Evento conselheiro.atualização_registrada reentregue Detectado por event_id em d6b.atualizacoes.event_id_publicacao. Log, cursor avançado.
Evento demanda.conclusao_confirmada reentregue Detectado por event_id em d6b.processed_events. Log, cursor avançado.
Payload incompleto em qualquer handler Log.error. O cursor avança em todos os handlers.
conselheiro.atualização_registrada para acompanhamento inexistente ou já encerrado Log.warn, cursor avançado. Ocorre quando o BFF publica após o ciclo ter sido encerrado.
conselheiro.sorteado para demanda com acompanhamento ativo de outro conselheiro Fecha o anterior como reatribuido, registra o event_id_sorteio anterior em processed_events e cria o novo acompanhamento. Comportamento esperado em recusa.
conselheiro.sorteado para demanda com acompanhamento do mesmo conselheiro Log.warn, cursor avançado, sem duplicação.
conselheiro.sorteado tardio para demanda concluída socialmente d6b.conclusoes_sociais bloqueia a abertura. Registra processed_event, cursor avançado.
INSERT em d6b.atualizacoes falha (unique event_id_publicacao) Indica caminho duplicado. O erro propaga para a DLQ e o cursor não avança.
Publish de conselheiro.demanda_iniciada falha após as tentativas do publicarComRetry Log.error. Estado persistido (atualização criada, acompanhamento em em_andamento). Erro relançado para a DLQ. Cursor não avança.
Publish de conselheiro.atualização_publicada falha após as tentativas do publicarComRetry Log.error. Estado persistido. Erro relançado para a DLQ. Cursor não avança.
Publish de demanda.concluída falha após persistir a conclusão Log.error. Acompanhamento já está concluido, a atualização e o snapshot saidas_conclusao persistidos. Erro relançado para a DLQ. Cursor não avança. A varredura republicarSaidasConclusaoPendentes do boot seguinte republica as duas saídas pelo snapshot.
Publish de conselheiro.ciclo_concluído falha após publicar demanda.concluída Log.error. demanda.concluída já foi publicado; o snapshot ainda está pendente. Erro relançado para a DLQ. Cursor não avança. A varredura do boot republica as duas saídas; a idempotência do barramento absorve a que já entrou.
Falha de inserirProcessedEvent ou upsertOffset Erro propagado pelo repositório (sem catch silencioso). O handler lança, o EventBus registra na DLQ e o cursor não avança. O replay do boot retenta.
CronJob de prazos falha (exceção não tratada) O @Cron captura e loga. O próximo ciclo (6 horas depois) tenta novamente. Nenhum estado é corrompido.
Publicação do CronJob falha para um prazo O erro relança e interrompe o ciclo. O prazo não é marcado como alertado e entra no próximo ciclo.
Duas execuções concorrentes de onConselheiroAtualizacaoRegistrada para o mesmo acompanhamento O lock em memória por acompanhamento.id serializa as execuções; cada uma relê o total_atualizacoes vigente dentro do lock e grava sequencia distinta. A unicidade de event_id_publicacao continua barrando o mesmo evento. Lock distribuído fica para a Fase 2.

Publicação de dois eventos na conclusão: demanda.concluída antes de conselheiro.ciclo_concluído. A D-5 consome demanda.concluída para atualizar o backlog. A D-6a consome conselheiro.ciclo_concluído para liberar o conselheiro. São responsabilidades disjuntas. Publicar eventos separados em vez de um evento unificado permite que cada consumidor reaja apenas ao que lhe interessa. A ordem importa: se conselheiro.ciclo_concluído for publicado primeiro, a D-6a libera o conselheiro e pode sorteá-lo para uma nova demanda antes que a D-5 atualize o backlog, gerando uma janela de inconsistência. Publicar demanda.concluída primeiro garante que o backlog esteja atualizado quando o conselheiro for liberado.

Endpoints REST na D-6b para sugestão de IA, não para publicação de atualização. A alternativa seria a D-6b expor um endpoint para o conselheiro publicar atualizações diretamente, sem passar pelo BFF. Isso violaria o princípio de que o BFF é o ponto de entrada HTTP do sistema. O endpoint de sugestão de IA é uma exceção justificada: é uma operação de leitura (não altera estado) que precisa de resposta síncrona para o fluxo de UI, pois o conselheiro está esperando a sugestão para revisar. A publicação em si (operação de escrita) segue o fluxo padrão: BFF → barramento → D-6b.

Primeira atualização dispara conselheiro.demanda_iniciada automaticamente. A alternativa seria exigir que o conselheiro clicasse em “Iniciar demanda” antes de poder registrar atualizações. Isso adiciona um passo extra na UI e pode gerar esquecimento: o conselheiro sorteado que esquece de clicar “Iniciar” fica em aguardando_inicio indefinidamente. A heurística automática elimina esse risco e registra no payload qual foi a primeira ação concreta (tipo_primeira_acao), preservando a rastreabilidade.

Fechamento de acompanhamento anterior na reatribuição. Quando a D-6a re-sorteia após recusa, publica um novo conselheiro.sorteado para a mesma demanda. A D-6b recebe e, em vez de criar um segundo acompanhamento ativo, fecha o anterior como reatribuido. A alternativa seria consumir conselheiro.atribuicao_recusada, mas isso adicionaria mais um tipo de evento consumido. A abordagem escolhida mantém a interface de eventos mais enxuta e resolve o problema no próprio handler de conselheiro.sorteado, que é o ponto natural de início de acompanhamento.

3.15 Evento consumido: demanda.conclusao_confirmada

Seção intitulada “3.15 Evento consumido: demanda.conclusao_confirmada”

A conclusão coletiva da D-12 chega à relatoria com replay e cursor próprios (consumer_offset, tipo demanda.conclusao_confirmada). O handler onConclusaoConfirmada é idempotente por event_id.

1. Verificar idempotência por event_id em d6b.processed_events
→ se processado: avançar o cursor, retornar
2. Validar payload mínimo
→ demanda_id, conclusao_id, data_conclusao, total_conclusoes e
ratificacao_necessaria obrigatórios
→ se ausente: log.error, avançar o cursor, retornar
3. Se ratificacao_necessaria = false:
→ registrar a conclusão social em d6b.conclusoes_sociais (guarda de sorteio tardio)
4. Buscar o acompanhamento ativo da demanda
→ se não existe: log, registrar processed_event, avançar o cursor, retornar
5. Entrar no lock por acompanhamento e reler o ativo
→ o relê pega o total_atualizacoes vigente; se encerrou nesse intervalo:
log, registrar processed_event, avançar o cursor, retornar
6. Gravar a pendência no acompanhamento
→ registrarPendenciaConclusao com { conclusao_id, total_conclusoes, data }
→ novoTotal = total_atualizacoes + 1
→ inserirAtualizacao de sistema no histórico: tipo 'status_atualizado',
conteudo_estruturado { novo_status: 'conclusao_confirmada', total_conclusoes,
data_conclusao }, origem_estruturacao 'sistema', sequencia = novoTotal
→ registrarAtualizacaoNoAcompanhamento com total_atualizacoes = novoTotal
→ registrar processed_event, avançar o cursor

Acompanhamento já encerrado (concluído, desistência, fim de mandato, reatribuído) não é encontrado pela busca de ativo: o evento avança o cursor sem gerar pendência. A atualização de sistema entra no histórico com a sequência seguinte e incrementa total_atualizacoes do acompanhamento, mantendo o contador coerente com a maior sequencia gravada.

3.16 Rota de ratificação: POST /d6b/demandas/:demanda_id/ratificar-conclusao

Seção intitulada “3.16 Rota de ratificação: POST /d6b/demandas/:demanda_id/ratificar-conclusao”
1. Validar sessão Google (ConselheiroGuard)
→ sem JWT válido com auth_provider google: 401
2. Buscar acompanhamento ativo da demanda
→ sem acompanhamento ativo: 404
3. Entrar no lock por acompanhamento e reler o ativo
→ sem acompanhamento ativo: 404
4. Validar pendência
→ pendencia_conclusao null: 409 "Demanda sem pendência de conclusão"
5. Validar conselheiro
→ cidadao_id do token ≠ conselheiro_id do acompanhamento: 403
6. Registrar a atualização de sistema (tipo 'status_atualizado',
conteudo_estruturado { novo_status: 'concluido' },
origem_estruturacao 'sistema', sequencia = total_atualizacoes + 1)
→ atualizar o acompanhamento com a nova contagem e, se for a primeira
atualização, status 'em_andamento' e data_inicio
7. Chamar concluirDemanda (mesmo fluxo da conclusão por atualização normal)
→ publica demanda.concluída e conselheiro.ciclo_concluído
com motivo_encerramento 'demanda_concluida'
→ limpa a pendência (pendencia_conclusao = null)
8. Registrar a conclusão social em d6b.conclusoes_sociais
9. Retornar { demanda_id, status: 'concluida' } (201)

A ratificação reusa o fluxo existente: não cria motivo de encerramento novo. A conclusão por atualização normal do conselheiro (novo_status concluido) também limpa a pendência, para o caso de o conselheiro concluir pelo caminho tradicional antes de ratificar.

conselheiro.sorteado que chega depois da conclusão social não cria acompanhamento. O handler de sorteio consulta d6b.conclusoes_sociais: demanda registrada ali já encerrou por confirmação coletiva, então o evento registra processed_event e avança o cursor sem abrir acompanhamento. A tabela d6b.conclusoes_sociais guarda demanda_id, evento_id e timestamp, e é preenchida em dois pontos: quando demanda.conclusao_confirmada chega com ratificação desnecessária e na ratificação. A monotonicidade é preservada: a demanda concluída socialmente não regride a atribuida por sorteio tardio.


4.1 EstruturadorIaService.sugerir(textoBruto, tipoAtualizacao, demandaId) — pseudocódigo

Seção intitulada “4.1 EstruturadorIaService.sugerir(textoBruto, tipoAtualizacao, demandaId) — pseudocódigo”
função sugerir(textoBruto, tipoAtualizacao, demandaId) -> SugestaoIa:
// 1. Validar o tipo
se tipoAtualizacao não está em TIPOS_ATUALIZACAO_VALIDOS:
lançar BadRequestException("Tipo de atualização inválido...")
// 2. Caminho da IA
se carregarD6bConstants().iaHabilitada:
construirPrompt(textoBruto, tipoAtualizacao, demandaId)
retornar sugerirFallback(textoBruto, tipoAtualizacao)
// 3. Caminho padrão do MVP
retornar sugerirFallback(textoBruto, tipoAtualizacao)
função sugerirFallback(textoBruto, tipoAtualizacao) -> SugestaoIa:
retornar {
titulo_sugerido: extrairTitulo(textoBruto),
texto_sugerido: textoBruto.trim(),
campos_estruturados: extrairCamposPorRegex(textoBruto, tipoAtualizacao),
score_confianca: carregarD6bConstants().iaScoreFallback, // 0,5
}

A chamada ao modelo de linguagem não existe no MVP: mesmo com iaHabilitada = true, o serviço monta o prompt e devolve o fallback. extrairTitulo usa a primeira frase, truncada em 100 caracteres com reticências. extrairCamposPorRegex extrai protocolo_numero em protocolo_aberto, data_estimada (dd/mm/aaaa convertida para ISO) em prazo_registrado e novo_status em status_atualizado.

A sugestão com score 0,5 sinaliza ao front-end que a sugestão é heurística e o conselheiro vê a indicação de revisão. A funcionalidade não é bloqueante: o conselheiro pode editar ou ignorar a sugestão e publicar com origem_estruturacao = 'conselheiro'.

4.2 NormalizadorAtualizacao.validar(tipo, conteudoEstruturado) — pseudocódigo

Seção intitulada “4.2 NormalizadorAtualizacao.validar(tipo, conteudoEstruturado) — pseudocódigo”
função validar(tipo: string, conteudo: Record<string, unknown>) -> ResultadoValidacao:
erros = []
se tipo não está em TIPOS_ATUALIZACAO_VALIDOS:
retornar { valido: false, erros: [{ campo: 'tipo', mensagem: 'Tipo de atualização inválido.' }] }
para cada campo em CAMPOS_OBRIGATORIOS_POR_TIPO[tipo]:
se conteudo[campo] é undefined, null ou string vazia:
erros.push({ campo, mensagem: 'Campo obrigatório.' })
para cada par (campo, valores) de VALORES_PERMITIDOS:
se conteudo[campo] existe e não está em valores:
erros.push({ campo, mensagem: `Valor inválido. Esperado um dos: ${valores.join(', ')}.` })
se tipo == 'protocolo_aberto' e conteudo.protocolo_numero preenchido:
se !validarProtocoloNumero(conteudo.protocolo_numero):
erros.push({ campo: 'protocolo_numero', mensagem: 'Formato de protocolo inválido.' })
se tipo == 'prazo_registrado' e conteudo.data_estimada definida:
resultado = validarDataEstimada(conteudo.data_estimada)
se resultado == 'formato_invalido':
erros.push({ campo: 'data_estimada', mensagem: 'Data inválida.' })
senão se resultado == 'no_passado':
erros.push({ campo: 'data_estimada', mensagem: 'Data estimada no passado.' })
retornar { valido: erros.length == 0, erros }

CAMPOS_OBRIGATORIOS_POR_TIPO cobre canal (contato), protocolo_numero e orgao (protocolo), tipo_documento e descricao (documento), orgao_envolvido e descricao_entrave (entrave), novo_status (status) e data_estimada (prazo). VALORES_PERMITIDOS cobre os enums de canal (telefone, email, presencial, oficio), gravidade (baixa, media, alta) e novo_status (pendente, em_andamento, aguardando, concluido, bloqueado). O protocolo aceita letras, números, ponto, barra, espaço e hífen, com pelo menos 4 caracteres. A data estimada exige o formato AAAA-MM-DD, aceita o dia de hoje e rejeita datas passadas.

4.3 D6bService.iniciar() — protocolo de inicialização

Seção intitulada “4.3 D6bService.iniciar() — protocolo de inicialização”
função iniciar():
se iniciado: retornar
iniciado = true
// 1. Carregar configuração estática
carregarD6bConstants()
// 2. Semear os cursores do banco próprio
maiorSequence = eventBus.obterMaiorSequence()
repo.seedOffsets([
'conselheiro.sorteado',
'conselheiro.atualização_registrada',
'demanda.conclusao_confirmada',
], maiorSequence)
// 3. Replay de eventos perdidos, tipo a tipo
reprocessarEventosPerdidos()
// 4. Registrar consumidores para eventos futuros
registrarConsumidores()
// 5. Acompanhamentos aguardando_inicio antigos (possível conselheiro inativo)
try: verificarAcompanhamentosInativos() catch: log.error
// 6. Saídas de conclusão pendentes (publicação falhou depois da persistência)
try: republicarSaidasConclusaoPendentes() catch: log.error
log("D-6b Relatoria e Acompanhamento inicializada")

reprocessarEventosPerdidosbuscarOffsets() e, para cada um dos três tipos, chama replayDeSequence(cursor, [tipo]) e despacha pelo handler correspondente, logando falhas sem interromper os demais tipos. registrarConsumidores inscreve os três tipos via inscrever. A flag iniciado torna a chamada idempotente. O seed usa o maior sequence_number do barramento no boot e não sobrescreve linhas existentes.

republicarSaidasConclusaoPendentes busca os acompanhamentos concluido com saidas_conclusao_pendentes = true (limite de LIMITE_VARREDURA_SAIDAS_CONCLUSAO, 100 por boot), republica cada evento do snapshot saidas_conclusao com publicarComRetry e desliga a flag. A idempotência do barramento absorve os eventos já publicados; falha em um acompanhamento é logada e mantém a flag para o próximo boot.

4.4 verificarAcompanhamentosInativos() — inicialização

Seção intitulada “4.4 verificarAcompanhamentosInativos() — inicialização”
função verificarAcompanhamentosInativos():
config = carregarD6bConstants()
dataCorte = agora - config.diasLimiteAcompanhamentoInativo dias // 7
inativos = repo.buscarAcompanhamentosInativosDesde(dataCorte)
// status 'aguardando_inicio' com criado_em anterior ao corte
para cada acompanhamento em inativos:
log.warn("Acompanhamento sem início após 7 dias do sorteio", {
acompanhamento_id: acompanhamento.id,
demanda_id: acompanhamento.demanda_id,
conselheiro_id: acompanhamento.conselheiro_id,
data_sorteio: acompanhamento.data_sorteio,
})
// MVP: apenas log. Fase 2: timeout automático → conselheiro.atribuicao_recusada
função construirPrompt(textoBruto: string, tipo: string, demandaId: string) -> string:
basePrompt = "Você é um assistente de relatoria cívica. "
basePrompt += "Seu papel é estruturar informações mantendo linguagem neutra "
basePrompt += "e separando fatos de interpretações. Não invente dados que "
basePrompt += "não estão no texto original.\n\n"
retornar basePrompt +
"Demanda de referência: " + demandaId + ".\n" +
"Tipo de atualização: " + tipo + ".\n\n" +
"Texto original: " + textoBruto

Existe um prompt único, com a referência da demanda e o tipo. O prompt só é montado quando iaHabilitada = true e não é enviado a nenhum modelo no MVP; a sugestão devolvida é sempre o fallback heurístico da seção 4.1.

Definidos em relatoria/d6b.constants.ts:

export const TIPOS_ATUALIZACAO_VALIDOS: TipoAtualizacao[] = [...]; // os 6 tipos
export const DIAS_ANTECEDENCIA_ALERTA_PRAZO = 3;
export const DIAS_LIMITE_ACOMPANHAMENTO_INATIVO = 7;
export const IA_HABILITADA = false;
export const IA_SCORE_FALLBACK = 0.5;
export const IA_SCORE_MINIMO_AUTO = 0.7;
export const IA_TIMEOUT_MS = 5000;
export const CRON_VERIFICACAO_PRAZOS = '0 */6 * * *';
export const VERSAO_METODO_RESUMO = 'resumo_v1';
export const VERSAO_SCHEMA_RESUMO_CICLO = '1.0.0';
export const TAMANHO_MAXIMO_RESUMO_CICLO = 6000;
export const TAMANHO_MAXIMO_TRECHO_RESUMO_CICLO = 400;

carregarD6bConstants() devolve diasAntecedenciaAlertaPrazo, diasLimiteAcompanhamentoInativo, iaHabilitada, iaScoreFallback e iaScoreMinimoAuto. No MVP iaHabilitada = false e a sugestão é sempre o fallback heurístico com score fixo 0,5. IA_SCORE_MINIMO_AUTO e IA_TIMEOUT_MS existem como declaração para a Fase 2, quando o modelo de linguagem for ligado; não são lidos por nenhum fluxo atual.

O ResumoCicloService usa VERSAO_METODO_RESUMO e VERSAO_SCHEMA_RESUMO_CICLO na publicação, TAMANHO_MAXIMO_RESUMO_CICLO como teto do texto e TAMANHO_MAXIMO_TRECHO_RESUMO_CICLO como teto de cada trecho. O resumo não prescreve e é marcado como automático.

O endpoint POST /d6b/sugerir-estruturacao é stateless. A cada chamada:

1. Recebe { textoBruto, tipoAtualizacao, demandaId }
2. Valida o tipo (400 se inválido)
3. Se iaHabilitada = true:
monta o prompt → retorna o fallback com score 0,5
Senão:
retorna o fallback com score 0,5
4. Retorna SugestaoIaResponse ao app
5. NÃO persiste nada. NÃO publica eventos.

Transições válidas de status em d6b.acompanhamentos:

┌─────────────────────┐
│ aguardando_inicio │ ← conselheiro.sorteado (D-6a)
└──────────┬──────────┘
│ primeira atualização
┌──────────▼──────────┐
│ em_andamento │
└──────────┬──────────┘
┌────────────────────┼────────────────────┐
│ status_atualizado │ fim_mandato │ desistencia
│ + novo_status = │ (D-17, │ (Fase 2)
│ 'concluido' │ Fase 2) │
┌────▼────────┐ ┌──────▼──────┐ ┌───────▼──────┐
│ concluido │ │ fim_mandato │ │ desistencia │
└─────────────┘ └─────────────┘ └──────────────┘
┌─────────────┐
│ reatribuido │ ← novo conselheiro.sorteado para mesma demanda
└─────────────┘ (vindo de aguardando_inicio ou em_andamento)
Transições proibidas:
- concluido → qualquer outro (demanda encerrada não reabre)
- fim_mandato → qualquer outro
- desistencia → qualquer outro
- reatribuido → qualquer outro
- em_andamento → aguardando_inicio (não há "desiniciar" demanda)

Pendência de ratificação. A pendência não altera o status do acompanhamento. demanda.conclusao_confirmada com acompanhamento ativo grava pendencia_conclusao e mantém o status corrente (aguardando_inicio ou em_andamento). A transição para concluido ocorre apenas na ratificação ou na conclusão por atualização normal, pelos dois caminhos descritos na seção “Rota de ratificação”.

Caso Comportamento
Conselheiro sorteado, mas nunca registra atualização Acompanhamento fica aguardando_inicio. verificarAcompanhamentosInativos() loga após 7 dias. Sem ação automática no MVP.
Conselheiro registra atualização de tipo status_atualizado com novo_status = 'concluido' mas a demanda não está resolvida Erro humano. A D-6b não valida a veracidade da conclusão. O sistema confia na declaração do conselheiro. Mecanismo de correção: contestação pela comunidade (Fase 2) ou reabertura via nova demanda.
Mesmo conselheiro sorteado duas vezes para demandas diferentes Dois acompanhamentos independentes em em_andamento. A D-6b não impõe o limite de 1 conselheiro = 1 demanda ativa; confia no controle da D-6a.
Atualização com origem_estruturacao = 'ia_assistida' e sugestao_ia ausente Payload inválido. O handler exige a presença de sugestao_ia quando a origem é ia_assistida; log.error, cursor avançado.
CronJob de prazos encontra acompanhamento encerrado com prazo ainda vigente Verifica acompanhamento.status. Se encerrado, marca prazo como vencido.
Dois conselheiro.sorteado para demandas diferentes do mesmo conselheiro em curto intervalo Dois acompanhamentos criados. Ambos aguardando_inicio. Ambos podem ser iniciados. Cenário improvável porque a D-6a publica conselheiro.sorteado sequencialmente para a mesma UC.
Conselheiro publica 50 atualizações em 1 hora (burst) Permitido. total_atualizacoes é incrementado. O rate limiting das atualizações é do BFF (5/min); as rotas próprias da D-6b têm throttle de 10/min na sugestão, 60/min no acompanhamento e 5/min na ratificação. Se necessário, a D-7 pode expor métrica de frequência de atualizações.
Duas atualizações concorrentes do mesmo acompanhamento O lock em memória por acompanhamento serializa as execuções e relê o total gravado; cada atualização recebe uma sequencia distinta.
Publicação de conclusão falha definitivamente O snapshot saidas_conclusao permanece pendente e a varredura do boot seguinte republica demanda.concluída e conselheiro.ciclo_concluído.
Conclusão coletiva chega com acompanhamento ativo Pendência gravada no acompanhamento; status inalterado até a ratificação.
Conclusão coletiva chega sem acompanhamento ativo Evento processado e cursor avançado, sem pendência. A demanda conclui pela D-5/D-7.
Ratificação sem pendência 409 “Demanda sem pendência de conclusão”.
Ratificação por cidadão diferente do conselheiro 403 “Apenas o conselheiro do acompanhamento pode ratificar”.
Ratificação de acompanhamento já encerrado 404 “Acompanhamento ativo não encontrado para a demanda”.
Sorteio tardio de demanda concluída socialmente Não cria acompanhamento: d6b.conclusoes_sociais bloqueia a abertura.
Conclusão social com ratificação desnecessária Registrada em d6b.conclusoes_sociais para a guarda de sorteio tardio.

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 e publicação de eventos via EventBusService

Seção intitulada “5.1 Consumo e publicação de eventos via EventBusService”

A D-6b injeta EventBusService (do módulo @Global() N-0a) para as operações de barramento: inscrever() (registrar consumidores), publicar() (publicar eventos de saída), replayDeSequence() (replay na inicialização) e obterMaiorSequence() (seed dos cursores).

@Injectable()
export class D6bService {
private readonly logger = new Logger(D6bService.name);
constructor(
private readonly eventBus: EventBusService,
private readonly repo: D6bRepository,
private readonly estruturadorIa: EstruturadorIaService,
private readonly normalizador: NormalizadorAtualizacao,
private readonly triggers: TriggersDemandaIniciada,
) {}
}

O repositório único concentra o acesso a todas as tabelas do schema d6b. O Logger do NestJS é instanciado no próprio serviço. O VerificadorPrazos é um provider separado, com o próprio logger, que injeta o repositório e o barramento. Nenhuma dependência além do núcleo.

Cidadão (app)
→ ... pipeline completo ...
→ D-6a → conselheiro.sorteado ─────────────┐
│ │
▼ │
[D-6b] — esta colônia │
├── consome conselheiro.sorteado ◄───────┘
│ └─> cria acompanhamento
├── endpoint REST: POST /d6b/sugerir-estruturacao
│ └─> chamado pelo app durante fluxo de atualização
│ └─> retorna sugestão de IA (stateless)
├── endpoint REST: GET /d6b/conselheiros/me/acompanhamento
│ └─> leitura do próprio acompanhamento (workspace do conselheiro)
├── endpoint REST: POST /d6b/demandas/:demanda_id/ratificar-conclusao
│ └─> ratificação da conclusão coletiva (ConselheiroGuard)
├── consome conselheiro.atualização_registrada (BFF D-1a)
│ └─> processa → publica:
│ ├── conselheiro.demanda_iniciada ──> [D-5] [D-7]
│ ├── conselheiro.atualização_publicada ──> [D-7]
│ ├── demanda.concluída ──> [D-5] [D-7]
│ └── conselheiro.ciclo_concluído ──> [D-6a] [D-7]
├── consome demanda.conclusao_confirmada (D-12)
│ └─> grava pendência de ratificação ou a guarda de sorteio tardio
└── CronJob (@every 6h)
└─> verifica prazos → publica conselheiro.prazo_próximo ──> [Notif.] [D-7]
Recusa (não consumido diretamente):
BFF → conselheiro.atribuicao_recusada → [D-6a]
→ D-6a publica novo conselheiro.sorteado
→ D-6b fecha acompanhamento anterior (reatribuido) e cria novo
private async publicarConselheiroDemandaIniciada(
acompanhamento: AcompanhamentoRegistro,
tipoPrimeiraAcao: string,
correlacaoId: string,
): Promise<void> {
try {
await publicarComRetry(this.eventBus, {
tipo: 'conselheiro.demanda_iniciada',
origem: 'D-6b',
versao_schema: '1.0.0',
event_id: uuidv4(),
correlacao_id: correlacaoId,
payload: {
demanda_id: acompanhamento.demanda_id,
conselheiro_id: acompanhamento.conselheiro_id,
unidade_civica_id: acompanhamento.unidade_civica_id,
tipo_primeira_acao: tipoPrimeiraAcao,
timestamp_inicio: new Date().toISOString(),
},
});
} catch (erro) {
this.logger.error('Falha ao publicar conselheiro.demanda_iniciada', {
demanda_id: acompanhamento.demanda_id,
erro: erro instanceof Error ? erro.message : String(erro),
});
throw erro;
}
}

As cinco publicações usam publicarComRetry (4 tentativas, backoff 500/1000/2000 ms): conselheiro.demanda_iniciada, conselheiro.atualização_publicada, conselheiro.prazo_próximo, demanda.concluída e conselheiro.ciclo_concluído. A falha definitiva é logada e o erro relançado. As duas de conclusão usam os event_id persistidos no snapshot saidas_conclusao, o que permite a republicação pela varredura do boot sem duplicar no barramento.

A D-6b expõe três endpoints REST, todos fora do prefixo api e sob o ConselheiroGuard. A sugestão de estruturação (POST /d6b/sugerir-estruturacao, 10/min) é chamada diretamente pelo front-end do conselheiro durante o registro de atualização, sem intermediação do BFF: a operação é stateless e o app a chama com o JWT da sessão. O acompanhamento (GET /d6b/conselheiros/me/acompanhamento, 60/min) e a ratificação (POST /d6b/demandas/:demanda_id/ratificar-conclusao, 5/min) também são chamados diretamente pelo app. Esses são os únicos pontos de entrada síncronos da colônia.

A D-6b não faz chamadas síncronas a outras colônias. Não atua como proxy.

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

  • core.event_log via EventBusService.replayDeSequence() e obterMaiorSequence(). Dependência do núcleo, permitida.
  • Arquivo de configuração estática relatoria/d6b.constants.ts com os parâmetros de IA, prazos e limites.

A D-6b não utiliza Redis. O volume de dados é pequeno (< 100 acompanhamentos ativos, < 500 atualizações/dia no MVP) e o PostgreSQL com índices adequados é suficiente. O CronJob de prazos roda em memória no próprio processo NestJS (@Cron), sem necessidade de fila distribuída.

O fluxo entre as duas colônias forma um ciclo. A D-6b não se comunica diretamente com a D-6a: consome conselheiro.sorteado e produz conselheiro.ciclo_concluído. A D-6a faz o inverso. Ambas consomem eventos de forma assíncrona. É um ciclo de eventos, não de chamadas síncronas, padrão comum em arquiteturas orientadas a eventos.


O volume de atualizações é limitado indiretamente pelo rate limiting do BFF (D-1a), que aplica 5/min nas rotas de escrita do conselheiro. As três rotas da própria D-6b aplicam @Throttle no controller: 10/min em POST /d6b/sugerir-estruturacao, 60/min no GET /d6b/conselheiros/me/acompanhamento e 5/min no POST /d6b/demandas/:demanda_id/ratificar-conclusao. O endpoint de sugestão é o mais pesado e por isso tem limite próprio de 10/min.

Limite Valor Justificativa
Tamanho máximo de texto_bruto por atualização 10.000 caracteres Suficiente para relatos detalhados. Validado no BFF antes de publicar.
Tamanho máximo de texto_estruturado por atualização 10.000 caracteres Mesmo limite do texto bruto.
Tamanho máximo de conteudo_estruturado (JSONB) 5 KB Campos curtos e tipados.
Tamanho máximo de sugestao_ia (JSONB) 10 KB Inclui texto sugerido + campos.
Número máximo de atualizações por acompanhamento 500 Média esperada: 5-20. Teto alto para evitar que ciclo excepcionalmente complexo seja bloqueado.
Dias de antecedência para alerta de prazo 3 Padrão. Configurável em d6b.constants.ts.
Frequência do CronJob de prazos 6 horas Balance entre responsividade e carga.
Timeout da chamada de IA 5 segundos Declarado em IA_TIMEOUT_MS para a Fase 2; não há chamada de modelo no MVP.
Acompanhamentos ativos simultâneos por conselheiro 1 (via D-6a) Garantido pela D-6a: 1 conselheiro = 1 demanda ativa. A D-6b não impõe o limite, mas confia no controle da D-6a.
Acompanhamentos ativos simultâneos por demanda 1 Garantido pelo índice único parcial uq_d6b_acompanhamento_demanda_ativa em d6b.acompanhamentos.
Acompanhamentos com saída de conclusão republicados por boot 100 Teto da varredura republicarSaidasConclusaoPendentes (LIMITE_VARREDURA_SAIDAS_CONCLUSAO).
Índice Query atendida
acompanhamentos_pkey (id) Acesso por ID em updates de status.
uq_d6b_acompanhamento_demanda_ativa (UNIQUE parcial) Garantia de no máximo um acompanhamento ativo por demanda. Usado no handler de conselheiro.sorteado.
acompanhamentos_conselheiro_id_idx “Quais demandas este conselheiro está acompanhando?”.
acompanhamentos_demanda_id_status_idx “Acompanhamento ativo da demanda X?”.
acompanhamentos_unidade_civica_id_status_idx Dashboard D-7: “acompanhamentos ativos na UC X”.
acompanhamentos_event_id_sorteio_idx Idempotência de conselheiro.sorteado.
atualizacoes_pkey (id) Acesso por ID.
atualizacoes_event_id_publicacao_key (UNIQUE) Idempotência de conselheiro.atualização_registrada.
atualizacoes_acompanhamento_id_idx “Todas as atualizações deste acompanhamento”. Timeline da D-7.
atualizacoes_demanda_id_idx “Todas as atualizações desta demanda”. Timeline da D-7.
atualizacoes_conselheiro_id_idx “Todas as atualizações deste conselheiro”. Visão de conselheiro da D-7.
atualizacoes_demanda_id_criado_em_idx (composto) Consultas por demanda ordenadas por data.
prazos_pkey (id) Acesso por ID.
prazos_event_id_atualizacao_key (UNIQUE) Idempotência de prazo registrado.
prazos_acompanhamento_id_idx “Todos os prazos deste acompanhamento”.
prazos_status_data_estimada_idx (composto) CronJob: “prazos vigentes próximos do vencimento”. Query principal do VerificadorPrazos.
processed_events_pkey (event_id) Idempotência genérica.
processed_events_event_type_idx Depuração: “eventos processados por tipo”.
conclusoes_sociais_pkey (demanda_id) Guarda de sorteio tardio por demanda.
conclusoes_sociais_evento_id_key (UNIQUE) Idempotência da conclusão social registrada.
  • buscarAcompanhamentoAtivoPorDemanda(demanda_id): 1 query por conselheiro.sorteado, conselheiro.atualização_registrada, demanda.conclusao_confirmada e pela ratificação. Usa índice composto (demanda_id, status).
  • buscarAcompanhamentoAtivoPorConselheiro(conselheiro_id): 1 query na leitura do workspace. Usa índice em conselheiro_id.
  • buscarAtualizacoesPorAcompanhamento(acompanhamento_id): 1 query na leitura do workspace, com as últimas 50 por criado_em e sequencia decrescentes. Usa índice em acompanhamento_id.
  • buscarPrazosVigentesAte(data_limite): 1 query a cada 6 horas (CronJob), com status = 'vigente' e alerta_enviado = false. Usa índice composto (status, data_estimada).
  • buscarPrazosVencidos(hoje): 1 query a cada 6 horas (CronJob), com status = 'vigente' e data_estimada anterior a hoje. Usa o mesmo índice.
  • buscarSaidasConclusaoPendentes(limite): 1 query no boot, com status = 'concluido' e saidas_conclusao_pendentes = true.
  • inserirAtualizacao: 1 write por atualização recebida.

Volume esperado no MVP: < 20 conselheiro.sorteado/dia, < 50 conselheiro.atualização_registrada/dia, < 3 conclusões/dia. Tempo médio de processamento: < 10ms para os handlers e para a sugestão (heurística local).

A D-6b não implementa cache. As consultas públicas são feitas pela D-7 (Transparência) em seu próprio banco de leitura. A leitura direta da colônia é apenas a do próprio conselheiro, no endpoint de acompanhamento.

A sugestão de IA pode se beneficiar de cache se o mesmo texto for submetido múltiplas vezes (conselheiro editando e reenviando), mas o volume não justifica a complexidade no MVP.

Cenário Acompanhamentos ativos Atualizações/dia Conclusões/dia Alertas de prazo/dia
PoC (1 bairro) 3-5 5-10 1 0-1
MVP (1 município) 20-50 30-80 3-5 1-5
Fase 2 (regional) 200-500 500-2000 50-100 20-100

Teste unitário do D6bService (d6b.service.spec.ts):

beforeEach(async () => {
resetarD6bConstants();
mockEventBus = {
inscrever: jest.fn(),
publicar: jest.fn().mockResolvedValue({ sequence_number: 20n, event_id: 'published-id' }),
replayDeSequence: jest.fn().mockResolvedValue([]),
obterMaiorSequence: jest.fn().mockResolvedValue(100),
};
mockRepo = {
seedOffsets: jest.fn().mockResolvedValue(undefined),
upsertOffset: jest.fn().mockResolvedValue(undefined),
buscarOffsets: jest.fn().mockResolvedValue([]),
buscarProcessedEvent: jest.fn().mockResolvedValue(false),
inserirProcessedEvent: jest.fn().mockResolvedValue(undefined),
buscarAcompanhamentoAtivoPorDemanda: jest.fn().mockResolvedValue(null),
buscarAcompanhamentoAtivoPorConselheiro: jest.fn().mockResolvedValue(null),
buscarAcompanhamentoAtivoPorConselheiroEDemanda: jest.fn().mockResolvedValue(null),
buscarPrazosPorAcompanhamento: jest.fn().mockResolvedValue([]),
buscarAtualizacoesPorAcompanhamento: jest.fn().mockResolvedValue([]),
buscarAcompanhamentoPorId: jest.fn().mockResolvedValue(null),
buscarAcompanhamentosInativosDesde: jest.fn().mockResolvedValue([]),
inserirAcompanhamento: jest
.fn()
.mockResolvedValue(criarAcompanhamento({ id: 'a1', status: 'aguardando_inicio' })),
encerrarAcompanhamento: jest.fn().mockResolvedValue(undefined),
registrarAtualizacaoNoAcompanhamento: jest.fn().mockResolvedValue(undefined),
concluirAcompanhamento: jest.fn().mockResolvedValue(undefined),
registrarPendenciaConclusao: jest.fn().mockResolvedValue(undefined),
buscarConclusaoSocial: jest.fn().mockResolvedValue(false),
registrarConclusaoSocial: jest.fn().mockResolvedValue(undefined),
buscarAtualizacaoPorEventIdPublicacao: jest.fn().mockResolvedValue(null),
inserirAtualizacao: jest.fn().mockImplementation((dados) =>
Promise.resolve({
id: 'u1',
tipo: dados.tipo,
texto_estruturado: dados.texto_estruturado,
conteudo_estruturado: dados.conteudo_estruturado,
origem_estruturacao: dados.origem_estruturacao,
}),
),
marcarPrazosVigentesSubstituidos: jest.fn().mockResolvedValue(undefined),
inserirPrazo: jest.fn().mockResolvedValue(undefined),
registrarSaidasConclusaoPendentes: jest.fn().mockResolvedValue(undefined),
marcarSaidasConclusaoPublicadas: jest.fn().mockResolvedValue(undefined),
buscarSaidasConclusaoPendentes: jest.fn().mockResolvedValue([]),
};
mockEstruturadorIa = {
sugerir: jest.fn().mockResolvedValue({
titulo_sugerido: 'Título',
texto_sugerido: 'Texto sugerido',
campos_estruturados: {},
score_confianca: 0.5,
}),
};
const module = await Test.createTestingModule({
providers: [
D6bService,
{ provide: EventBusService, useValue: mockEventBus },
{ provide: D6bRepository, useValue: mockRepo },
{ provide: EstruturadorIaService, useValue: mockEstruturadorIa },
NormalizadorAtualizacao,
TriggersDemandaIniciada,
],
}).compile();
service = module.get<D6bService>(D6bService);
});

As demais suítes cobrem o repositório (d6b.repository.spec.ts), os endpoints (d6b.controller.spec.ts), o VerificadorPrazos (relatoria/verificador-prazos.spec.ts), o normalizador, o estruturador e o schema com a migration das conclusões sociais (d6b-schema.spec.ts).

Happy path:

# Cenário Verificação
T1 onConselheiroSorteado() com payload completo Acompanhamento criado com status aguardando_inicio. processed_events registrado.
T2 onConselheiroAtualizacaoRegistrada() — primeira atualização de um acompanhamento Atualização persistida. Acompanhamento status → em_andamento. data_inicio preenchida. conselheiro.demanda_iniciada publicado. conselheiro.atualização_publicada publicado.
T3 onConselheiroAtualizacaoRegistrada() — segunda atualização (não é a primeira) Atualização persistida. Acompanhamento mantém em_andamento. Apenas conselheiro.atualização_publicada publicado (sem conselheiro.demanda_iniciada).
T4 onConselheiroAtualizacaoRegistrada() — tipo status_atualizado com novo_status = 'concluido' Atualização persistida. demanda.concluída publicado. conselheiro.ciclo_concluído publicado com motivo_encerramento = 'demanda_concluida'. Acompanhamento status → concluido.
T5 onConselheiroAtualizacaoRegistrada() — tipo prazo_registrado Atualização persistida. Prazo registrado em d6b.prazos com status vigente. Prazo anterior (se existir) marcado substituido.
T6 sugerirEstruturacao() com texto e tipo válidos Retorna sugestão com titulo_sugerido, texto_sugerido, campos_estruturados, score_confianca. NÃO persiste. NÃO publica eventos.
T7 CronJob verificarPrazosProximos() com prazo a 2 dias do vencimento conselheiro.prazo_próximo publicado. alerta_enviado = true.
T8 Conclusão de demanda: demanda.concluída publicado antes de conselheiro.ciclo_concluído Ordem verificada no mock: publicar chamado primeiro para demanda.concluída, depois para conselheiro.ciclo_concluído.

Falhas e bordas:

# Cenário Verificação
T9 onConselheiroSorteado() para demanda já com acompanhamento ativo de outro conselheiro Acompanhamento anterior status → reatribuido. Novo acompanhamento criado com status aguardando_inicio.
T10 onConselheiroSorteado() duplicado (mesmo event_id) Detectado em processed_events. Log, cursor avançado.
T11 onConselheiroAtualizacaoRegistrada() duplicado (mesmo event_id) Detectado em atualizacoes.event_id_publicacao. Log, cursor avançado.
T12 onConselheiroAtualizacaoRegistrada() para acompanhamento inexistente ou já encerrado Log.warn, cursor avançado.
T13 Payload sem demanda_id ou conselheiro_id Log.error, cursor avançado.
T14 Payload com tipo inválido Log.error, cursor avançado.
T15 Payload prazo_registrado sem data_estimada Log.error, cursor avançado.
T16 Payload protocolo_aberto sem protocolo_numero Log.error, cursor avançado.
T17 Payload com origem_estruturacao = 'ia_assistida' sem sugestao_ia Log.error, cursor avançado.
T18 sugerirEstruturacao() com tipo inválido BadRequestException (400).
T19 sugerirEstruturacao() com IA desabilitada Retorna fallback com score_confianca = 0.5.
T20 publicar('conselheiro.demanda_iniciada') falha após o publicarComRetry Log.error. Estado persistido. Erro relançado para a DLQ. Cursor não avança.
T21 publicar('conselheiro.atualização_publicada') falha após o publicarComRetry Log.error. Estado persistido. Erro relançado para a DLQ. Cursor não avança.
T22 CronJob falha com exceção @Cron captura. Log.error. Próximo ciclo tenta novamente.
T23 Prazo com alerta_enviado = true não gera novo alerta A consulta do CronJob filtra alerta_enviado = false.
T24 demanda.conclusao_confirmada sem acompanhamento ativo Registra a conclusão social quando a ratificação é desnecessária, grava processed_event, cursor avançado, sem pendência.
T25 demanda.conclusao_confirmada reentregue Detectado em processed_events. Cursor avançado.
T26 conselheiro.sorteado tardio para demanda concluída socialmente Não cria acompanhamento: a guarda em d6b.conclusoes_sociais bloqueia.
T27 ratificarConclusao() sem pendência, sem acompanhamento ou com cidadão diferente 409, 404 e 403, respectivamente.
T28 ratificarConclusao() com pendência Atualização de sistema registrada, demanda.concluída e conselheiro.ciclo_concluído publicados, pendência limpa, conclusão social registrada, retorno 201.
T34 demanda.conclusao_confirmada com acompanhamento ativo A atualização de sistema usa sequencia = total_atualizacoes + 1 e incrementa total_atualizacoes do acompanhamento.
T35 Duas execuções concorrentes para o mesmo acompanhamento O lock serializa; cada atualização recebe sequencia distinta (1 e 2).
T36 Conclusão persiste as saídas antes de publicar registrarSaidasConclusaoPendentes é chamado antes do publish; marcarSaidasConclusaoPublicadas fecha o ciclo.
T37 Boot com saída de conclusão pendente republicarSaidasConclusaoPendentes relê o snapshot, republica os dois eventos e desliga a flag.

Teste de integração:

# Cenário Verificação
T29 Ciclo completo: D-6a → D-6b (sorteio → 3 atualizações → conclusão) 1 acompanhamento criado. 3 atualizações persistidas. conselheiro.demanda_iniciada (1x). conselheiro.atualização_publicada (2x, a conclusão não publica). demanda.concluída + conselheiro.ciclo_concluído (1x cada).
T30 Recusa → re-sorteio → novo conselheiro → atualização → conclusão Conselheiro 1: acompanhamento reatribuido. Conselheiro 2: acompanhamento concluido. Atualizações do conselheiro 1 são ignoradas se chegarem após a reatribuição.
T31 3 prazos registrados, 1 vence amanhã, 2 vencem na semana que vem CronJob: 1 alerta publicado. alerta_enviado marcado. 2 prazos sem alerta. Vencidos verificados no próximo ciclo.
T32 Sugestão de IA → conselheiro edita → publica com ia_assistida sugestao_ia preservada no evento. texto_estruturado é a versão editada. texto_bruto é o original.
T33 Replay após reinício: 5 eventos, 3 já processados 3 ignorados por idempotência. 2 novos processados. Acompanhamentos e atualizações sem duplicação.

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 a UC de bairro de teste, cinco cidadãos e dois conselheiros em d6a.conselheiros. Não há seed de d6b.acompanhamentos, d6b.atualizacoes nem d6b.prazos: os acompanhamentos nascem do fluxo de eventos, do conselheiro.sorteado ao conselheiro.atualização_registrada.


Funcionalidade Status
inscrever('conselheiro.sorteado') com handler idempotente MVP obrigatório
inscrever('conselheiro.atualização_registrada') com handler idempotente MVP obrigatório
inscrever('demanda.conclusao_confirmada') com pendência de ratificação e guarda de sorteio tardio MVP obrigatório
Endpoint REST POST /d6b/sugerir-estruturacao com ConselheiroGuard, throttle 10/min e fallback heurístico MVP obrigatório
Endpoint REST GET /d6b/conselheiros/me/acompanhamento com ConselheiroGuard e throttle 60/min MVP obrigatório
Endpoint REST POST /d6b/demandas/:demanda_id/ratificar-conclusao com ConselheiroGuard e throttle 5/min MVP obrigatório
Criação de acompanhamento ao receber conselheiro.sorteado MVP obrigatório
Fechamento de acompanhamento anterior no re-sorteio (reatribuido) MVP obrigatório
Heurística de primeira atualização → conselheiro.demanda_iniciada MVP obrigatório
Normalização e validação de campos estruturados por tipo MVP obrigatório
Registro de todos os 6 tipos de atualização com texto_bruto preservado MVP obrigatório
Preservação de sugestao_ia para auditoria MVP obrigatório
Publicação de conselheiro.demanda_iniciada MVP obrigatório
Publicação de conselheiro.atualização_publicada com descricao_sanitizada MVP obrigatório
Denylist de texto na publicação, com a 1.2.0 e a marca de suspeição MVP obrigatório
Publicação de demanda.concluída + conselheiro.ciclo_concluído na conclusão MVP obrigatório
Snapshot saidas_conclusao com republicação pendente no boot MVP obrigatório
Serialização em memória das atualizações por acompanhamento MVP obrigatório
CronJob de verificação de prazos (@Cron) com publicação de conselheiro.prazo_próximo MVP obrigatório
Consumer offsets para os 3 tipos de evento MVP obrigatório
processed_events para idempotência genérica MVP obrigatório
Propagação de correlacao_id MVP obrigatório
Logs estruturados com demanda_id, conselheiro_id, acompanhamento_id e event_id MVP obrigatório
Simplificação Justificativa Quando remover
IA opera em modo fallback (iaHabilitada = false) Evita dependência de API externa para o MVP. A sugestão funciona com heurísticas regex. Mesmo com a flag ligada, o serviço ainda devolve o fallback e apenas monta o prompt. Ligar a chamada ao modelo de linguagem e usar IA_SCORE_MINIMO_AUTO e IA_TIMEOUT_MS (Fase 2).
Conclusão de demanda via status_atualizado com novo_status = 'concluido', sem formulário dedicado No MVP, a conclusão é um caso especial do tipo status_atualizado (por atualização do conselheiro ou pela ratificação da conclusão coletiva). Criar evento dedicado demanda.conclusão_registrada com formulário de conclusão (resumo, evidências, avaliação pós-execução) na Fase 2.
Atualização do tipo documento_anexado carrega texto descritivo, sem anexo real O pipeline de anexo da relatoria não existe no MVP. O campo anexo_id fica reservado. Integrar a D-1c ao registro de atualização quando o pipeline de anexo da relatoria existir (Fase 2).
Encerramento administrativo (fim_mandato, desistencia) não implementado Front-end de conselheiro no MVP cobre cadastro, atribuição, acompanhamento e atualizações. Os motivos dependem da D-17. Criar os fluxos de desistência e de encerramento de mandato (Fase 2, integrado com D-17).
Sem notificação push ao conselheiro sobre prazo próximo O sistema publica conselheiro.prazo_próximo no barramento e na timeline. O conselheiro descobre consultando a interface. Adicionar colônia de notificação que consome conselheiro.prazo_próximo e envia push/email (Fase 2).
Sem validação de que o conselheiro está ativo na D-6a ao processar atualização A D-6b confia que o BFF publicou conselheiro.atualização_registrada apenas para conselheiros com atribuição ativa. Se houver inconsistência, o handler rejeita por falta de acompanhamento ativo. Consumir evento da D-6a ou consultar projeção para validação cruzada (Fase 2).
Sem distinção entre “demanda concluída” e “demanda arquivada” A conclusão é binária no MVP. Não há workflow de “concluída aguardando validação” ou “concluída e arquivada após avaliação”. Adicionar estados intermediários e avaliação pós-execução (Fase 2, integrado com a D-21 de Monitoramento Executivo).
Lock em memória nas atualizações do mesmo acompanhamento, sem lock distribuído O mutex por acompanhamento.id serializa as execuções na instância única do monolito. A unicidade de event_id_publicacao segue como barreira do mesmo evento. Adicionar lock distribuído quando o monolito for particionado (Fase 2).
Configuração de IA estática em d6b.constants.ts, sem hot-swap A troca exige redeploy no MVP. Adicionar colônia de configuração dinâmica ou consumir parâmetros.atualizados para os parâmetros de IA (Fase 2).
  • IA completa com modelo de linguagem (BERTimbau ou similar) para sugestão de estruturação com alta confiança
  • Formulário dedicado de conclusão de demanda (demanda.conclusão_registrada) com resumo, evidências e avaliação
  • Encerramento de ciclo por fim_mandato integrado com D-17 (Controle de Mandato)
  • Encerramento de ciclo por desistencia via front-end do conselheiro
  • Lock distribuído para as atualizações concorrentes quando o monolito for particionado
  • Notificação push/email para conselheiro.prazo_próximo
  • Validação cruzada de status do conselheiro com a D-6a
  • Avaliação pós-execução da demanda (2 meses, 6 meses, 1 ano, 5 anos, conforme os ciclos de avaliação)
  • Integração com D-21 (Monitoramento Executivo) para rastreamento de desempenho do executor
  • Métricas Prometheus: d6b_acompanhamentos_ativos, d6b_atualizacoes_por_tipo, d6b_tempo_medio_conclusao, d6b_taxa_conclusao, d6b_alertas_prazo
  • Tabela d6b.config com histórico de versões de parâmetros de IA e prazos
  • Hot-swap de modelo de IA sem redeploy

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-6b introduz conselheiro.atualização_registrada e conselheiro.atualização_publicada no catálogo. Avaliação: os dois tipos estão registrados no Registry (N-0b). O Registry é versionado, e adicionar versões não quebra compatibilidade: conselheiro.atualização_publicada evoluiu para 1.1.0 e 1.2.0 como adições opcionais. Nenhuma outra colônia consome conselheiro.atualização_registrada; conselheiro.atualização_publicada é consumido pela D-7 (Transparência), pela D-24 (Memória de Caminhos) e pela D-1d (moderação do relato).

Conflito potencial: a D-6b publica demanda.concluída, que a D-5 consome. Avaliação: o Registry (N-0b) define demanda.concluída como produzido pela D-6b, e a D-5 especifica o consumo deste evento (ver D-5 - Agenda.md, seção “Evento consumido: demanda.concluída”). A ficha da D-6b no Apêndice B precisa listar este evento; a conferência é da fase do Apêndice B.

Conflito potencial: o payload de conselheiro.atualização_publicada inclui numero_sequencial. O que acontece se duas atualizações forem processadas em ordem diferente da publicação? Avaliação: o numero_sequencial é derivado de acompanhamento.total_atualizacoes no momento do processamento. O lock em memória por acompanhamento serializa as execuções e cada uma relê o contador dentro do lock, então duas atualizações do mesmo acompanhamento gravam sequências distintas na ordem de processamento. O lock distribuído para o cenário de múltiplas instâncias está na lista da Fase 2.

Conflito potencial: a D-6b e a D-5 mantêm status diferentes para a mesma demanda (em_andamento vs. em_progresso). Isso não gera inconsistência? Avaliação: são dimensões diferentes. A D-6b controla o status do acompanhamento (relação conselheiro⇄demanda). A D-5 controla o status da demanda no backlog. Os estados são correlacionados mas independentes: aguardando_inicio (D-6b) ≈ atribuido (D-5), em_andamento (D-6b) ≈ em_progresso (D-5). A divergência só ocorre se um evento for perdido, e nesse caso o mecanismo de replay resolve.

Conflito potencial: a ordem de deploy. Se D-6b subir antes de D-6a publicar conselheiro.sorteado? Avaliação: a D-6b opera em modo degradado. Sem eventos, não há acompanhamentos. Os handlers ficam registrados e processarão eventos assim que a D-6a começar a publicar. O iniciar() faz replay a partir do último cursor conhecido. Ordem natural do Bloco 2 (D-5 → D-6a → D-6b → D-7) garante que as colônias a montante já estão publicando quando a D-6b sobe em produção.



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