D-1d — Moderação
Parte da D-1 — Ingestão de Demanda
Propósito
Seção intitulada “Propósito”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ãoremovidoé 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.
1. Estrutura do Módulo NestJS
Seção intitulada “1. Estrutura do Módulo NestJS”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.
1.1 Árvore de diretórios
Seção intitulada “1.1 Árvore de diretórios”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)1.2 Module definition
Seção intitulada “1.2 Module definition”@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(); }}1.3 Pontos de atenção
Seção intitulada “1.3 Pontos de atenção”- O módulo não é
@Global(). A D-1d não é dependência de nenhuma outra colônia. - O módulo não importa
EventBusModuleexplicitamente.EventBusModuleé@Global()e oEventBusServiceé injetável sem import. iniciar()noOnModuleInitfaz 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/decidire/admin/moderacao/fila/:item_id/reverter, com a exclusão declarada nomain.tsao lado ded6beadmin/d3.
1.4 Serviços — responsabilidades e contratos
Seção intitulada “1.4 Serviços — responsabilidades e contratos”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.
1.5 Controllers — endpoints expostos
Seção intitulada “1.5 Controllers — endpoints expostos”| 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.
1.6 Autenticação e papéis
Seção intitulada “1.6 Autenticação e papéis”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.
2. Banco de Dados — Schema e Entidades
Seção intitulada “2. Banco de Dados — Schema e Entidades”2.1 Schema d1d
Seção intitulada “2.1 Schema d1d”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.
2.2 Tabela d1d.fila_moderacao
Seção intitulada “2.2 Tabela d1d.fila_moderacao”| 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].
2.3 Tabela d1d.decisoes_moderacao
Seção intitulada “2.3 Tabela d1d.decisoes_moderacao”| 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].
2.4 Tabela d1d.descricoes_midia
Seção intitulada “2.4 Tabela d1d.descricoes_midia”| 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.
2.5 Tabela d1d.eventos_processados
Seção intitulada “2.5 Tabela d1d.eventos_processados”| 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. |
2.6 Tabela d1d.consumer_offset
Seção intitulada “2.6 Tabela d1d.consumer_offset”| 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. |
2.7 Migration
Seção intitulada “2.7 Migration”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].
2.8 Relações internas
Seção intitulada “2.8 Relações internas”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.
2.9 Decisões de schema
Seção intitulada “2.9 Decisões de schema”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.
3. Eventos — Contratos Detalhados
Seção intitulada “3. Eventos — Contratos Detalhados”3.1 Evento consumido: anexo.processado (1.4.0)
Seção intitulada “3.1 Evento consumido: anexo.processado (1.4.0)”| 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.
3.2 Evento consumido: demanda.normalizada (1.4.0)
Seção intitulada “3.2 Evento consumido: demanda.normalizada (1.4.0)”| 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.
3.4 Evento produzido: moderacao.decidida (1.1.0)
Seção intitulada “3.4 Evento produzido: moderacao.decidida (1.1.0)”| 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.
3.7 Reação da D-7
Seção intitulada “3.7 Reação da D-7”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.
3.8 Ordem de operações — enfileirar
Seção intitulada “3.8 Ordem de operações — enfileirar”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 cursor3.9 Ordem de operações — decidir
Seção intitulada “3.9 Ordem de operações — decidir”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 }3.10 Ordem de operações — reverter
Seção intitulada “3.10 Ordem de operações — reverter”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 }3.11 Protocolo de consumo com replay
Seção intitulada “3.11 Protocolo de consumo com replay”O iniciar() segue a N-0a:
- Seed do cursor:
offsetRepo.seed([anexo.processado, demanda.normalizada, conselheiro.atualização_publicada], obterMaiorSequence()), sem sobrescrever cursor existente. - 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. - 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.
3.12 Idempotência
Seção intitulada “3.12 Idempotência”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.
3.13 Tratamento de erro e reentrega
Seção intitulada “3.13 Tratamento de erro e reentrega”| 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. |
4. Lógica de Negócio — Filtros e Decisão
Seção intitulada “4. Lógica de Negócio — Filtros e Decisão”4.1 Filtros de entrada da fila
Seção intitulada “4.1 Filtros de entrada da fila”| 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.
4.2 Consulta da fila
Seção intitulada “4.2 Consulta da fila”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.
4.3 Decisão
Seção intitulada “4.3 Decisão”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.
4.4 Classificação que alimenta a fila
Seção intitulada “4.4 Classificação que alimenta a fila”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.
4.5 LGPD e retenção
Seção intitulada “4.5 LGPD e retenção”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.
5. Interface web de moderação
Seção intitulada “5. Interface web de moderação”5.1 Tela /moderacao
Seção intitulada “5.1 Tela /moderacao”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.
5.2 Papéis no front
Seção intitulada “5.2 Papéis no front”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.
6. Alinhamento com o MVP
Seção intitulada “6. Alinhamento com o MVP”6.1 O que é MVP obrigatório
Seção intitulada “6.1 O que é MVP obrigatório”| 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 |
6.2 Simplificações válidas no MVP
Seção intitulada “6.2 Simplificações válidas no MVP”| 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.
6.3 O que vai para a Fase 2
Seção intitulada “6.3 O que vai para a Fase 2”- 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.
Referências
Seção intitulada “Referências”- Parte da D-1: D-1a - Captura.md, D-1b - Normalização.md, D-1c - Gestão de Anexos.md
- Leitura pública: D-7 - Transparência.md
- Ficha da colônia: Apêndice B - Colônias.md, seção “D-1d — Moderação”
- Barramento de eventos: N-0a - Event Bus.md
- Catálogo de eventos: N-0b - Registry.md
- Direitos do titular:
src/nucleo/n-0d-direitos-titular/no repomvp-api