Pular para o conteúdo

D-1d — Moderação

Parte da D-1 — Ingestão de Demanda


A D-1d é a fila de moderação de conteúdo do MVP. Consome os eventos que carregam sinalização automática (anexo.processado, demanda.normalizada e conselheiro.atualização_publicada), enfileira os itens sinalizados e expõe os endpoints de decisão e de histórico para o moderador. A decisão é humana e auditável, com trilha append-only por item. A classificação automática bloqueia por limiar e a revisão é reativa, no mesmo padrão da D-3.

A colônia não classifica conteúdo. A classificação acontece na D-1c (imagem: NSFW, pessoa e heurística de documento) e na D-1b (texto da demanda: denylist configurável). O texto do relato passa pela mesma denylist na D-6b, ponto único do relato digitado e da transcrição. A D-1d organiza a revisão e publica moderacao.decidida. As reações ficam nas colônias donas do estado: a D-1c aplica a decisão no anexo, remove o objeto do bucket na decisão removido e publica anexo.moderado ou anexo.removido; a D-7 libera o texto, mantém o conteúdo suspenso ou limpa o que foi removido; a N-0d limpa o texto nos schemas de origem.

A comunicação é exclusivamente por eventos. A D-1d importa apenas o núcleo (EventBus e Registry), escreve apenas no schema d1d e não conhece o código das outras colônias.

O que a D-1d não faz:

  • Não executa a remoção física. A remoção é uma decisão da fila; a D-1c apaga a mídia do bucket e a N-0d limpa o texto dos schemas.
  • Não modera áudio. O áudio do relato fica restrito à auditoria e só a transcrição passa pela denylist.
  • Não remove relato. No tipo relato, a decisão removido é recusada com 400 e a remoção física fica para uma leva futura.
  • Não usa modelo de toxicidade de texto. A denylist cobre o MVP.
  • Não descreve imagens nem corrige a descrição automática. A legenda vem do Florence na D-1b; a D-1d persiste a descrição para exibí-la como contexto ao moderador.
  • Não recebe denúncia do cidadão. Denúncia fica para depois do MVP.

A D-1d é um módulo NestJS com encapsulamento próprio dentro do monolito modular. Consome eventos do barramento via EventBusService (N-0a) e expõe controllers REST com guard de papel. É consumidora e produtora de eventos.

src/demanda/d-1d-moderacao/
├── d1d.module.ts # Module definition
├── d1d.service.ts # Consumo, enfileiramento e decisão
├── d1d.constants.ts # Constantes: tipos, status, decisões, limites
├── controllers/
│ └── moderacao.controller.ts # Fila, histórico, decisão e reversão
├── dto/
│ ├── fila-moderacao.dto.ts # Filtros e shape da fila paginada
│ ├── historico-moderacao.dto.ts # Shape do histórico por item
│ └── decidir-moderacao.dto.ts # Contrato da decisão e resposta
├── repositories/
│ ├── fila-moderacao.repository.ts # Acesso a d1d.fila_moderacao
│ ├── decisoes-moderacao.repository.ts # Acesso a d1d.decisoes_moderacao
│ ├── descricoes-midia.repository.ts # Acesso a d1d.descricoes_midia
│ ├── evento-processado.repository.ts # Acesso a d1d.eventos_processados (idempotência)
│ └── consumer-offset.repository.ts # Acesso a d1d.consumer_offset (cursor)
└── (specs ao lado dos arquivos)
@Module({
imports: [],
controllers: [ModeracaoController],
providers: [
D1dService,
FilaModeracaoRepository,
DecisoesModeracaoRepository,
DescricoesMidiaRepository,
EventoProcessadoRepository,
ConsumerOffsetRepository,
],
exports: [],
})
export class D1dModule implements OnModuleInit {
constructor(private readonly d1dService: D1dService) {}
async onModuleInit() {
await this.d1dService.iniciar();
}
}
  • O módulo não é @Global(). A D-1d não é dependência de nenhuma outra colônia.
  • O módulo não importa EventBusModule explicitamente. EventBusModule é @Global() e o EventBusService é injetável sem import.
  • iniciar() no OnModuleInit faz o seed do cursor, o replay dos eventos perdidos e só então registra os consumidores ao vivo. O replay antes da inscrição segue a N-0a.
  • A D-1d é produtora e consumidora. Mantém cursor próprio e tabela de eventos processados no schema d1d.
  • Os controllers ficam fora do prefixo global api. As rotas reais são /admin/moderacao/fila, /admin/moderacao/fila/:item_id/historico, /admin/moderacao/fila/:item_id/decidir e /admin/moderacao/fila/:item_id/reverter, com a exclusão declarada no main.ts ao lado de d6b e admin/d3.
interface ID1dService {
iniciar(): Promise<void>;
registrarConsumidores(): void;
processarAnexoProcessado(evento: EventoConsultado): Promise<void>;
processarDemandaNormalizada(evento: EventoConsultado): Promise<void>;
processarConselheiroAtualizacaoPublicada(evento: EventoConsultado): Promise<void>;
listarFila(filtros: {
tipo?: string;
status?: string;
limite: number;
deslocamento: number;
}): Promise<{ itens: FilaModeracaoRegistro[]; total: number }>;
listarHistorico(
itemId: string,
): Promise<{ item_id: string; decisoes: DecisaoModeracaoRegistro[] }>;
decidir(
itemId: string,
dados: { decisao: string; motivo?: string; remover_texto_demanda?: boolean },
moderadorId: string,
): Promise<ResultadoDecisao>;
reverter(
itemId: string,
dados: { motivo?: string },
moderadorId: string,
): Promise<ResultadoDecisao>;
}

O decidir valida a existência e o status do item antes de gravar. A resposta é { item_id, tipo, referencia_id, decisao, status }. A decisão removido exige motivo e admite remover_texto_demanda apenas no tipo anexo. No tipo relato, removido responde 400. O reverter aceita somente itens com status aprovado ou bloqueado e publica a decisão oposta com reaberto: true.

A decisão e a reversão atualizam a fila e inserem a linha do histórico na mesma $transaction, com o evento_id gerado antes e reusado na publicação. O listarHistorico confirma a existência do item e devolve as decisões em ordem cronológica.

Método Rota Descrição
GET /admin/moderacao/fila Lista a fila com filtros de tipo e status, paginada. 60/min.
GET /admin/moderacao/fila/:item_id/historico Lista as decisões e reversões do item em ordem cronológica. 60/min.
POST /admin/moderacao/fila/:item_id/decidir Registra a decisão (aprovado, bloqueado ou removido) com motivo e a flag de limpeza do texto. 60/min.
POST /admin/moderacao/fila/:item_id/reverter Reverte uma decisão aprovado ou bloqueado para a oposta, com motivo opcional. 60/min.

O GET da fila responde { itens, total, limite, deslocamento }. Cada item tem item_id, tipo, referencia_id, demanda_id, motivo, score, trecho, descricao_imagem, status, criado_em, decidido_em e moderador_id. O descricao_imagem é nulo quando o item não é anexo ou quando a imagem não tem descrição, e traz o texto exibido (a tradução quando aplicada, senão o original), o original quando houve tradução, o idioma e a marca de tradução. O filtro de tipo aceita anexo, texto e relato. O filtro de status aceita pendente, aprovado, bloqueado e removido; a fila de trabalho do web usa pendente e o histórico usa os decididos. O limite padrão é 50 e o máximo é 100.

O GET do histórico responde { item_id, decisoes }, com cada decisão em { decisao_id, decisao, motivo, moderador_id, reaberto, decidido_em }. A ordem é decidido_em e decisao_id. O motivo é o texto digitado, truncado em 500 caracteres. Códigos de erro: 400 para identificador inválido, 401 para token ausente ou inválido, 403 para acesso sem papel, 404 para item inexistente e 429 para limite de requisições.

O POST decidir responde 201 com { item_id, tipo, referencia_id, decisao, status }. Códigos de erro: 400 para decisão inválida, remoção sem motivo ou removido em item de relato, 401 para token ausente ou inválido, 403 para acesso sem papel, 404 para item inexistente, 409 para item já decidido e 429 para limite de requisições. No tipo texto, a flag remover_texto_demanda é recusada com 400. Na decisão removido, o motivo é obrigatório.

O POST reverter responde 201 com o mesmo shape. Aceita apenas aprovado e bloqueado; item pendente ou removido recebe 409. A decisão removido é irreversível.

O item_id é validado como UUID v4. O moderador que decide vem do JWT, nunca do corpo da requisição.

Os endpoints usam o PapelModeradorGuard. O guard exige Authorization: Bearer <jwt> e o papel moderador ou admin no claim roles. Os papéis são concedidos no login Google quando o cidadao_id está em MODERADORES_CIDADAO_IDS (moderador) ou OPERADORES_CIDADAO_IDS (admin). O guard injeta moderador_id a partir do sub do token.

Ocultar a UI no web é cosmético. A proteção real é o guard da API.


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

Coluna Tipo Descrição
item_id UUID PK ID do item na fila.
tipo VARCHAR(10) anexo, texto ou relato.
referencia_id UUID ID do anexo (tipo anexo), da demanda (tipo texto) ou da atualização do relato (tipo relato).
demanda_id UUID Demanda associada.
motivo VARCHAR(200) Motivo da entrada na fila. Exemplos: nsfw_alto, nsfw_revisao, pessoa_detectada, termos: <lista> ou denylist_texto.
score DOUBLE PRECISION Score do classificador, quando aplicável. Nulo no MVP.
trecho TEXT Trecho do texto sinalizado, nos tipos texto e relato. Nulo no tipo anexo.
midia_object_key VARCHAR(500) Chave temporária da captura no item de anexo, vinda do object_key_temp do anexo.processado 1.4.0. Usada para casar a descrição automática da imagem na listagem da fila. Nulo nos tipos texto e relato.
status VARCHAR(20) pendente, aprovado, bloqueado ou removido. Default pendente.
criado_em TIMESTAMPTZ Momento de entrada na fila.
decidido_em TIMESTAMPTZ Momento da decisão. Nulo enquanto pendente.
moderador_id UUID Moderador que decidiu. Nulo enquanto pendente.
evento_id UUID UNIQUE Evento que originou o item. A restrição de unicidade garante a idempotência.

Índices: [status], [tipo, status] e [demanda_id].

Coluna Tipo Descrição
decisao_id UUID PK ID da linha no histórico.
item_id UUID Item da fila ao qual a decisão pertence.
decisao VARCHAR(20) aprovado, bloqueado ou removido.
motivo VARCHAR(500) Motivo digitado pelo moderador, truncado em 500 caracteres. Nulo quando não informado.
moderador_id UUID Moderador que decidiu.
reaberto BOOLEAN Verdadeiro quando a linha registra uma reversão. Default false.
decidido_em TIMESTAMPTZ Momento da decisão.
evento_id UUID UNIQUE Evento moderacao.decidida publicado pela decisão. A unicidade amarra a trilha à publicação.
criado_em TIMESTAMPTZ Momento de criação da linha.

Índices: [item_id, decidido_em] e [moderador_id].

Coluna Tipo Descrição
descricao_id UUID PK ID da linha.
demanda_id UUID Demanda dona da imagem.
object_key VARCHAR(500) Chave temporária da captura, vinda do object_key de midias_descritas.
descricao_original TEXT Legenda original do Florence.
descricao_traduzida TEXT Tradução para português, nula quando não aplicada.
traducao_aplicada BOOLEAN Verdadeiro quando a tradução foi aplicada. Default false.
descricao_idioma VARCHAR(10) Idioma da legenda original.
criado_em TIMESTAMPTZ Momento de criação da linha.
atualizado_em TIMESTAMPTZ Momento da última atualização da linha.

Unique: [demanda_id, object_key], que sustenta a upsert idempotente.

Coluna Tipo Descrição
event_id UUID PK ID do evento consumido.
evento_tipo VARCHAR(100) Tipo do evento.
processado_em TIMESTAMPTZ Momento do processamento.
criado_em TIMESTAMPTZ Momento de criação do registro.
Coluna Tipo Descrição
tipo_evento VARCHAR(255) PK Tipo de evento consumido.
last_sequence BIGINT Última sequência processada. Default 0.
updated_at TIMESTAMPTZ Momento da última atualização do cursor.

A migration 20260912160000_d1d_moderacao cria o schema d1d e as tabelas da fila, dos eventos processados e do cursor, com a unique em evento_id e os índices da fila. A tabela do histórico entra na migration incremental 0013_d1d_decisoes_moderacao, criada sobre as baselines consolidadas, com os índices [item_id, decidido_em] e [moderador_id]. A tabela das descrições de mídia e a coluna midia_object_key da fila entram na migration incremental 0014_d1d_descricoes_midia, com a unique [demanda_id, object_key].

Não há foreign keys. O referencia_id e o demanda_id são referências lógicas aos schemas de origem, preservadas para auditoria e para o rastreio da decisão.

Uma linha por evento sinalizado. A unique em evento_id impede que um replay ou uma reentrega criem duas entradas para o mesmo evento. O enfileiramento engole a violação de unicidade e segue o fluxo.

O trecho do texto é truncado em 500 caracteres. O limite protege a fila e a interface de trechos muito longos. O conteúdo completo permanece na D-7 e na D-1b.

O motivo é truncado em 200 caracteres. A lista de termos da denylist é resumida no motivo (termos: ...).

O status decidido permanece na linha. A decisão não remove o item do banco. A fila de trabalho filtra por pendente e o item decidido fica auditável.

O status removido é terminal. A limpeza transversal da N-0d pode sobrescrever o trecho e os termos do motivo, mas a linha permanece com status='removido', moderador e data. Itens removidos não voltam para a fila nem aceitam reversão.

A atualização da fila é condicional. O repositório só atualiza linhas pendente na decisão e linhas aprovado ou bloqueado na reversão. A corrida entre dois moderadores termina em 409 na segunda.

O histórico é append-only. Cada decisão e cada reversão inserem uma linha em d1d.decisoes_moderacao. A fila guarda o estado corrente; o histórico guarda a trilha com decisão, motivo, moderador, data e a marca de reversão.

A fila e o histórico gravam na mesma transação. O updateMany condicional e a inserção do histórico rodam em $transaction. Zero linhas no updateMany devolve 409 e nada é gravado.

O evento_id do histórico é o evento publicado. O identificador nasce no service antes da transação e é reusado em moderacao.decidida, o que amarra a linha da trilha ao evento no barramento.

O motivo fica em texto claro no histórico. A trilha é restrita a moderadores e não tem projeção pública. O log do barramento continua com o motivo em hash. A eliminação do titular anonimiza o moderador no histórico, em paridade com a fila.

A descrição de mídia é persistida mesmo no descarte. As midias_descritas de demanda.normalizada entram em d1d.descricoes_midia por upsert idempotente de demanda_id e object_key antes de o evento ser descartado ou enfileirado, nas versões 1.3.0 e 1.4.0. Assim a descrição existe quando o anexo correspondente é enfileirado pela D-1c, que pode chegar antes ou depois da normalização. A eliminação do titular apaga as linhas das demandas dele.

O casamento da descrição usa a chave temporária. O item de anexo guarda midia_object_key com o object_key_temp do anexo.processado 1.4.0. A listagem da fila busca as descrições pelas chaves (demanda_id, midia_object_key) dos itens da página e monta o descricao_imagem do DTO. Item sem descrição correspondente continua válido e leva descricao_imagem nulo.


Propriedade Valor
Tipo anexo.processado
Versão consumida 1.4.0 (as versões 1.0.0 a 1.3.0 seguem no catálogo e são aceitas no replay)
Produtor D-1c (Gestão de Anexos)
Consumidores D-7 (projeção da evidência), D-1d (moderação)

Campos relevantes para a D-1d: anexo_id, demanda_id, object_key_temp, possui_dado_sensivel, categoria_sensivel, motivo_sensivel e moderacao_status.

O item entra na fila quando moderacao_status === 'pendente' ou possui_dado_sensivel === true. O motivo usa motivo_sensivel; sem ele, usa categoria_sensivel; sem os dois, usa o padrão sensivel. O score e o trecho ficam nulos e o object_key_temp vira o midia_object_key do item. Evento sem pendência é descartado com o cursor avançado.

Propriedade Valor
Tipo demanda.normalizada
Versão consumida 1.4.0 (as versões 1.0.0 a 1.3.0 seguem no catálogo e são aceitas no replay)
Produtor D-1b (Normalização)
Consumidores D-2, D-3, D-1d (moderação)

Campos relevantes: demanda_id, titulo, descricao_limpa, conteudo_suspeito, termos_suspeitos e midias_descritas.

As midias_descritas são persistidas em d1d.descricoes_midia por upsert idempotente de demanda_id e object_key, inclusive quando o evento é descartado sem suspeita, para o item de anexo poder exibir a descrição da imagem.

O item entra na fila quando conteudo_suspeito === true. O motivo usa a lista de termos (termos: <lista>) ou o padrão denylist_texto. O trecho é montado com o título e a descrição do cidadão, truncado em 500 caracteres; sem texto do cidadão, leva apenas o título. As legendas automáticas não entram no trecho: a autoria do item de texto é do cidadão. Evento sem conteúdo suspeito é descartado com o cursor avançado depois da persistência das descrições.

3.3 Evento consumido: conselheiro.atualização_publicada (1.2.0)

Seção intitulada “3.3 Evento consumido: conselheiro.atualização_publicada (1.2.0)”
Propriedade Valor
Tipo conselheiro.atualização_publicada
Versão consumida 1.2.0 (a 1.1.0 e a 1.0.0 seguem no catálogo e são aceitas no replay)
Produtor D-6b (Relatoria e Acompanhamento)
Consumidores D-7 (Transparência) e D-1d (moderação)

Campos relevantes: atualizacao_id, demanda_id, tipo, texto_estruturado, descricao_sanitizada, conteudo_suspeito e termos_suspeitos.

A D-6b publica a 1.2.0 apenas quando a denylist encontra termo no texto_estruturado. O item entra na fila quando conteudo_suspeito === true, com tipo='relato', referencia_id igual ao atualizacao_id e demanda_id do payload. O motivo usa a lista de termos (termos: <lista>); sem ela, no replay redigido, usa denylist_texto. O trecho é o texto_estruturado, com fallback para a descricao_sanitizada, truncado em 500 caracteres. O score fica nulo.

Evento sem conteudo_suspeito=true é descartado com o cursor avançado. O replay lê a marca preservada pela redação e degrada o motivo para denylist_texto quando os termos não estão no log.

Propriedade Valor
Tipo moderacao.decidida
Versão 1.1.0
Produtor D-1d
Consumidores D-1c (anexo), D-7 (texto e trilha), N-0d (limpeza transversal)
interface ModeracaoDecididaPayload {
item_id: string;
tipo: 'anexo' | 'texto' | 'relato';
referencia_id: string;
decisao: 'aprovado' | 'bloqueado' | 'removido';
moderador_id: string;
demanda_id?: string; // correlaciona a decisão à demanda
motivo?: string; // máximo 500 caracteres; omitido quando não informado
remover_texto_demanda?: boolean; // só no tipo anexo
reaberto?: boolean; // reversão de decisão
}

O correlacao_id do evento é o demanda_id do item. O event_id é novo a cada decisão e é o mesmo gravado no histórico do item.

No tipo relato, a decisão aceita apenas aprovado e bloqueado; removido responde 400 e não gera evento. O referencia_id do relato é o atualizacao_id.

A versão 1.0.0 permanece no catálogo do Registry para replay e leitura do histórico. A 1.1.0 é a versão publicada e acrescenta demanda_id, remover_texto_demanda, reaberto e o tipo relato. Publicações novas usam 1.1.0 mesmo quando os campos novos não estão presentes.

Na persistência do log, a redação hasheia o motivo e mantém item_id, tipo, referencia_id, decisao, moderador_id, demanda_id, remover_texto_demanda e reaberto.

3.5 Evento da reação da D-1c: anexo.moderado (1.0.0)

Seção intitulada “3.5 Evento da reação da D-1c: anexo.moderado (1.0.0)”
Propriedade Valor
Tipo anexo.moderado
Versão 1.0.0
Produtor D-1c
Consumidores D-7
interface AnexoModeradoPayload {
anexo_id: string;
status: 'ativo' | 'bloqueado';
moderacao_status: 'aprovado' | 'bloqueado';
}

A D-1c consome moderacao.decidida com tipo === 'anexo'. Aprovar define status='ativo', possui_dado_sensivel=false e moderacao_status='aprovado'. Bloquear define status='bloqueado' e moderacao_status='bloqueado', mantendo o valor anterior de possui_dado_sensivel. Os campos moderado_por e moderado_em são gravados em ambos os casos. Depois publica anexo.moderado.

Na decisão removido, a D-1c apaga o objeto permanente (anexos/<hash>) e o temporário (object_key_temp) de todas as cópias do mesmo hash_sha256, em qualquer demanda, marca as linhas com status='removido' e moderacao_status='removido', grava moderado_por e moderado_em, limpa object_key_temp e publica anexo.removido por anexo afetado. A decisão sobre anexo já removido é descartada com o cursor avançado. Falha de remoção no bucket não marca nem publica: o cursor não avança, o evento vai para a DLQ e o replay do boot retenta.

3.6 Evento da reação da D-1c na remoção: anexo.removido (1.0.0)

Seção intitulada “3.6 Evento da reação da D-1c na remoção: anexo.removido (1.0.0)”
Propriedade Valor
Tipo anexo.removido
Versão 1.0.0
Produtor D-1c
Consumidores D-7
interface AnexoRemovidoPayload {
anexo_id: string;
demanda_id: string;
hash_sha256: string;
}

Um evento por anexo afetado. O hash_sha256 permanece como impressão digital, nunca como conteúdo. O object_key não entra no payload: a D-1c não persiste esse campo depois da remoção. A D-7 remove a evidência da projeção e grava a entrada de trilha pública.

A D-7 consome anexo.moderado. A aprovação marca a evidência como não sensível e ela volta à leitura pública. O bloqueio marca a evidência como sensível e ela sai da listagem pública. A mídia não é removida do banco.

Para o texto, a D-7 consome moderacao.decidida com tipo === 'texto'. A aprovação libera o título e as entradas de timeline marcadas como internas (liberarConteudoSuspenso). O bloqueio não libera nada e o conteúdo permanece fora da vitrine. A reversão de bloqueio chega com reaberto=true e oculta o título e as entradas de normalização de novo.

Para o relato, a D-7 consome moderacao.decidida com tipo === 'relato'. A aprovação publica as entradas internas cujo dados_relevantes.atualizacao_id é o referencia_id e repõe texto_ultima_atualizacao com o texto_completo da última entrada, truncado em 200 caracteres. O bloqueio com reaberto=true, que é a reversão de uma aprovação, oculta as entradas do atualizacao_id e zera texto_ultima_atualizacao. O removido não chega, porque a D-1d recusa; evento antigo nessa combinação é ignorado com aviso, sem limpar conteúdo.

Na decisão removido para texto, a D-7 limpa título, descrição e última atualização do snapshot, sobrescreve a descrição das entradas internas com a marca pública “Conteúdo removido por decisão de moderação humana.”, remove título e descrição dos dados_relevantes dessas entradas e grava a entrada da remoção na timeline. No anexo.removido, a D-7 remove a evidência da projeção e grava a entrada pública da remoção. Como o conteúdo já foi tombstonado no log, o rebuild não ressuscita texto nem evidência. O resumo público da demanda expõe conteudo_removido e conteudo_removido_em para alimentar o aviso do acompanhamento.

1. Verificar idempotência de consumo
→ consultar d1d.eventos_processados WHERE event_id = ?
→ se encontrado: avançar cursor e retornar
2. Extrair os campos do evento
→ anexo.processado: demanda_id, anexo_id, object_key_temp, categoria_sensivel, motivo_sensivel, moderacao_status, possui_dado_sensivel
→ demanda.normalizada: demanda_id, titulo, descricao_limpa, conteudo_suspeito, termos_suspeitos, midias_descritas
→ conselheiro.atualização_publicada: atualizacao_id, demanda_id, texto_estruturado, descricao_sanitizada, conteudo_suspeito, termos_suspeitos
3. Persistir as descrições de mídia (só em demanda.normalizada)
→ upsert em d1d.descricoes_midia por (demanda_id, object_key)
→ roda antes do descarte, para a descrição existir quando o anexo chegar
4. Decidir se há pendência
→ anexo: moderacao_status = 'pendente' ou possui_dado_sensivel = true
→ texto: conteudo_suspeito = true
→ relato: conteudo_suspeito = true
→ sem pendência: registrar evento, avançar cursor e retornar
5. Enfileirar em d1d.fila_moderacao
→ item_id novo (UUID v4), evento_id do evento
→ item de anexo leva midia_object_key = object_key_temp
→ violação de unique em evento_id: item já existe, seguir
6. Registrar o evento e avançar o cursor
1. Buscar o item por item_id
→ não encontrado: HTTP 404
2. Validar o status
→ status diferente de 'pendente': HTTP 409
3. Validar a decisão de remoção
→ removido sem motivo: HTTP 400
→ removido em item de relato: HTTP 400
→ remover_texto_demanda=true em item de texto: HTTP 400
4. Gerar o evento_id e gravar fila e histórico na mesma transação
→ decidido_em = agora; evento_id novo (UUID v4)
→ updateMany WHERE item_id = ? AND status = 'pendente'
→ create no histórico com decisão, motivo, moderador, data, reaberto=false e evento_id
→ zero linhas afetadas: HTTP 409 (corrida com outra decisão) e nada é gravado
5. Publicar moderacao.decidida 1.1.0 via publicarComRetry com o mesmo evento_id
→ decisão: aprovado, bloqueado ou removido
→ remover_texto_demanda só entra no payload quando a decisão é removido e o item é anexo
→ falha na publicação: erro propagado, HTTP 500
6. Retornar { item_id, tipo, referencia_id, decisao, status }
1. Buscar o item por item_id
→ não encontrado: HTTP 404
2. Validar o status
→ removido: HTTP 409 (irreversível)
→ pendente: HTTP 409 (ainda não decidido)
3. Calcular a decisão oposta
→ aprovado vira bloqueado; bloqueado vira aprovado
4. Gerar o evento_id e gravar fila e histórico na mesma transação
→ decidido_em = agora; evento_id novo (UUID v4)
→ updateMany WHERE item_id = ? AND status = <status atual>
→ create no histórico com decisão oposta, moderador, data, reaberto=true e evento_id
→ zero linhas afetadas: HTTP 409 (corrida com outra decisão) e nada é gravado
5. Publicar moderacao.decidida 1.1.0 com reaberto=true via publicarComRetry
→ motivo opcional entra no payload quando informado
→ falha na publicação: erro propagado, HTTP 500
6. Retornar { item_id, tipo, referencia_id, decisao, status }

O iniciar() segue a N-0a:

  1. Seed do cursor: offsetRepo.seed([anexo.processado, demanda.normalizada, conselheiro.atualização_publicada], obterMaiorSequence()), sem sobrescrever cursor existente.
  2. Replay: para cada tipo, eventBus.replayDeSequence(cursor, [tipo]) e despacho pelos handlers. Falha no replay mantém o evento pendente para o próximo boot.
  3. Inscrição: eventBus.inscrever(tipo, 'D-1d', handler) depois do replay.

O cursor avança em todo caminho terminal, inclusive no descarte por validação. Handler que lança exceção não avança o cursor e o evento vai para a DLQ.

Quatro camadas: d1d.eventos_processados por event_id, a unique de d1d.fila_moderacao.evento_id, a unique de d1d.decisoes_moderacao.evento_id e a unique de d1d.descricoes_midia por demanda_id e object_key. O replay do boot não duplica itens, decisões, linhas de histórico nem descrições de mídia.

Cenário Comportamento
Evento sem pendência Descarte com cursor avançado.
Item duplicado no enfileiramento Unique de evento_id barra a segunda linha e o fluxo segue.
Falha ao publicar a decisão Erro propagado, HTTP 500. A fila e o histórico já estão gravados; a republicação no MVP é manual, pelo operador, e a reconciliação automática é Fase 2.
Decisão concorrente no mesmo item A segunda recebe HTTP 409 e nada de novo é gravado.
Remoção sem motivo HTTP 400.
Remoção em item de relato HTTP 400.
Flag de limpeza do texto em item de texto HTTP 400.
Reversão de item removido HTTP 409.
Reversão de item pendente HTTP 409.
Item inexistente HTTP 404.

Origem Condição de entrada Motivo Trecho
anexo.processado moderacao_status='pendente' ou possui_dado_sensivel=true motivo_sensivel, categoria_sensivel ou sensivel nulo
demanda.normalizada conteudo_suspeito=true termos: <lista> ou denylist_texto título + descrição do cidadão, até 500 caracteres
conselheiro.atualização_publicada conteudo_suspeito=true termos: <lista> ou denylist_texto texto_estruturado ou descricao_sanitizada, até 500 caracteres

Pessoa detectada entra na fila mesmo com o anexo ativo. O anexo segue na vitrine enquanto a revisão não decide. O relato suspeito entra na fila e nasce interno na D-7 até a decisão.

O item de anexo guarda o midia_object_key da captura e a listagem casa a descrição automática da imagem pelo par (demanda_id, midia_object_key). O descricao_imagem do item leva o texto exibido, com a tradução em destaque e o original abaixo quando houve tradução; sem descrição correspondente, o campo fica nulo e o card não renderiza o bloco.

A listagem aceita tipo, status, limite (1 a 100, default 50) e deslocamento. O tipo aceita anexo, texto e relato. O status aceita pendente, aprovado, bloqueado e removido. O total considera os filtros. A ordenação é por criado_em crescente. A página resolve as descrições de mídia em uma consulta por chaves (demanda_id, midia_object_key) e monta o descricao_imagem de cada item.

O histórico por item aceita um item_id validado como UUID v4 e devolve as decisões e reversões em ordem cronológica. Cada linha tem decisão, motivo, moderador, marca de reversão e data. A trilha é append-only: a reversão insere uma linha nova em vez de sobrescrever a anterior.

A decisão tem três valores: aprovado, bloqueado e removido. Aprovar libera o conteúdo na vitrine. Bloquear mantém o conteúdo oculto. Remover apaga a mídia do armazenamento e limpa o texto dos schemas, sem possibilidade de reversão.

O motivo é opcional em aprovar e bloquear e obrigatório em remover. Na remoção de anexo, o moderador pode marcar remover_texto_demanda para limpar também o texto da demanda. A marca é recusada no tipo texto.

O tipo relato aceita apenas aprovado e bloqueado. A decisão removido responde 400, porque a remoção física de relato depende de estender a N-0d aos schemas da D-6b, o que fica para uma leva futura.

A reversão aceita apenas aprovado e bloqueado e publica a decisão oposta com reaberto=true. O status removido é terminal. A decisão de remoção não apaga mídia nem texto por si: a D-1c executa a remoção física do anexo e a N-0d limpa o texto nos schemas.

Toda decisão e reversão insere uma linha no histórico do item com decisão, motivo, moderador, data, marca de reversão e o evento_id publicado. O motivo fica legível apenas nessa trilha, restrita a moderadores; o log do barramento segue com hash.

A D-1c classifica imagem com dois pipelines locais (@huggingface/transformers, mesmo cache de modelos da D-1b):

Modelo Tarefa Limiar
AdamCodd/vit-base-nsfw-detector classificação NSFW 0,85 bloqueio, 0,50 revisão
skillsafe-ai/detr-resnet-50 detecção de pessoa (classe person do COCO) 0,50, sinalização apenas

A heurística de documento e PII (regiões de texto e padrões de CPF, CNPJ e telefone) continua cobrindo o caso de documento. Falha, timeout ou modelo indisponível degradam para a heurística, sem interromper o processamento do anexo. As features D1C_NSFW_HABILITADO e D1C_PESSOA_HABILITADA desligam cada classificador.

A D-1b aplica a denylist de texto lida de MODERACAO_DENYLIST_TEXTO (lista separada por vírgula, normalizada sem acento e em minúscula). A lista vazia ou ausente mantém o comportamento sem filtro. Os termos ficam restritos ao secret de ambiente e não são publicados nos espelhos de parâmetros.

A eliminação a pedido do titular (N-0d) anonimiza o trecho e o moderador_id da fila. O trecho vira [conteúdo removido a pedido do titular] e o moderador vira o placeholder anônimo. A eliminação anonimiza também o moderador_id das linhas de d1d.decisoes_moderacao, em paridade com a fila, e apaga as descrições automáticas das demandas do titular em d1d.descricoes_midia, porque a legenda descreve a imagem enviada por ele. O motivo da decisão permanece na trilha, que é restrita a moderadores e não tem projeção pública. A anonimização por retenção de prazo também sobrescreve o trecho dos itens ligados às demandas alcançadas.

Na remoção por moderação, a N-0d sobrescreve o trecho e os termos do motivo dos itens da demanda atingida, dentro da exceção transversal do núcleo. O item permanece na fila com o status removido, o moderador e a data, para auditoria.


A tela fica no perfil do cidadão e é visível apenas para papéis de moderação. Tem a alternância entre Pendentes e Decididos, abas de Anexos, Textos e Relatos, lista paginada da fila, preview de mídia pelo GET /api/anexos/:id/download, decisão com confirmação, histórico por item e estados de carregando, vazio, erro com “Tentar novamente” e offline.

Nos Pendentes, o card oferece Aprovar, Bloquear e Remover. No tipo relato, o card mostra o trecho do conselheiro sem preview de mídia e sem o botão Remover. O diálogo de remoção explica a irreversibilidade, exige motivo e pede a confirmação explícita; no tipo anexo, oferece o checkbox “Remover também o texto desta demanda”. Nos Decididos, o filtro cobre aprovados, bloqueados e removidos, cada card mostra o badge do status e a ação Reverter aparece apenas para aprovados e bloqueados, com confirmação. Itens removidos mostram o aviso de conteúdo removido e não carregam preview. Nos itens decididos, o collapsible “Histórico de decisões” carrega sob demanda as decisões e reversões do item, com decisão, id abreviado do moderador, marca “você” quando é o autor da sessão, data, motivo e marca de reversão. O card do anexo mostra o bloco de descrição automática com o selo próprio, a tradução em destaque e o original do Florence logo abaixo, rotulado com o idioma, quando houve tradução; sem descrição, o bloco não renderiza e ele não aparece em item de texto nem de relato. O id da demanda no card leva um atalho que abre /acompanhamento/:demanda_id em nova guia, com target="_blank", rel="noreferrer", ícone de link externo, rótulo acessível próprio e foco visível, sem sair da moderação. As ações ficam bloqueadas sem conexão.

A fila se atualiza sozinha a cada 30 segundos, apenas com a aba visível e online, de forma silenciosa e pausada durante decisão ou reversão. O 409 continua sendo a trava de concorrência e recarrega a fila na hora.

O preview baixa o binário com o JWT da sessão. A resposta 302 do endpoint de download é seguida pelo cliente, que envia apenas o Authorization; o navegador descarta esse header na troca de origem e a URL assinada do storage carrega a autorização.

Sem sessão ou sem o papel de moderação, a rota redireciona para a página inicial. No deploy de mesma origem, o nginx do web faz proxy de /admin/moderacao/ para a API. Sem o proxy, o fallback da SPA devolve o index.html e a fila falha.

O hook usePapeis decodifica o claim roles do JWT guardado em rc_jwt e expõe podeModerar. O card no perfil e a entrada no menu usam esse hook. A rota /moderacao redireciona para a página inicial quando não há sessão ou papel de moderação. A ocultação é cosmética; a proteção real é o guard da API.


Funcionalidade Status
Consumo de anexo.processado, demanda.normalizada e conselheiro.atualização_publicada com replay e cursor MVP obrigatório
Fila d1d.fila_moderacao com idempotência por evento_id MVP obrigatório
Histórico d1d.decisoes_moderacao append-only, com decisão, motivo, moderador, data e reversão MVP obrigatório
Endpoints /admin/moderacao/fila, /admin/moderacao/fila/:item_id/historico, /admin/moderacao/fila/:item_id/decidir e /admin/moderacao/fila/:item_id/reverter com papel de moderação MVP obrigatório
Evento moderacao.decidida 1.1.0 com auditoria do moderador MVP obrigatório
Decisão removido com motivo obrigatório e irreversível, recusada no tipo relato MVP obrigatório
Denylist no relato do conselheiro e fila com tipo relato MVP obrigatório
Reação da D-1c e da D-7 às decisões, com liberação e re-ocultação do relato MVP obrigatório
LGPD na fila e no histórico (trecho e moderador anonimizados) MVP obrigatório
Descrição automática de cada imagem no card do anexo, com tradução e original MVP obrigatório
Atalho do card para a página da demanda em nova guia MVP obrigatório
Tela de moderação no web com abas, histórico por item e atualização automática MVP obrigatório
Simplificação Justificativa Quando remover
Fila única com filtro de tipo Volume do MVP é baixo e a separação por abas no web cobre a leitura. Reavaliar com volume real.
score sempre nulo na fila Os scores ficam no anexo e no evento; a fila guarda o motivo. Quando a interface precisar do score por item.
Denylist como lista de termos Cobre o MVP sem dependência de modelo. Modelo de toxicidade na Fase 2.
Detecção de pessoa apenas sinaliza A decisão de exibir pessoa é humana. Reavaliar com o fluxo de contestação.
Remoção física apenas na decisão removido O bloqueio continua sendo o caminho comum; a remoção é reservada a conteúdo claramente ilegal. Reavaliar com o fluxo de encaminhamento a autoridade.
Sem reserva de item na fila O updateMany condicional com 409 e a atualização automática de 30 s cobrem o volume do MVP. Reavaliar com o volume real de moderação.
Sem remoção física de relato Conteúdo ilegal em relato fica apenas bloqueado; exige estender a N-0d aos schemas da D-6b. Leva futura, se o operador pedir.
Descrição automática sem correção manual O moderador usa a descrição como contexto; o selo marca a origem automática e o valor de confiança não é exibido, porque o Florence devolve um constante. Leva futura, se o operador pedir.

A limitação anterior de descrição restrita à fila foi reavaliada e encerrada. A descricao_limpa carrega exclusivamente o texto do cidadão e as legendas ganharam projeção própria na D-7 e bloco marcado como IA no web. A fila segue usando a descrição automática como contexto do moderador.

  • Modelo de toxicidade de texto.
  • Detecção facial dedicada.
  • Denúncia do cidadão.
  • Fluxo de contestação da decisão na timeline e no acompanhamento.
  • Encaminhamento do hash do conteúdo removido a autoridade.
  • Rename do cache compartilhado de IA para IA_CACHE_MODELOS.
  • Remoção física de relato, com a N-0d estendida aos schemas da D-6b.
  • Reserva ou claim de item com TTL, se o volume exigir.
  • Notificação em tempo real da fila, no lugar do intervalo de 30 segundos.
  • Edição ou correção manual da descrição automática pelo moderador.
  • Descrição automática de áudio de anexo.