Pular para o conteúdo

L-3 — Validação e Qualidade

Parte das Colônias de Lugares — subconjunto no MVP; Fase 2 completa


A L-3 determina se um lugar é confiável o suficiente para entrar no mapa como confirmado. No modelo completo, opera por três métodos em ordem de custo crescente: confirmação automática por confiança, reincidência temporal de cadastros e validação social presencial sorteada.

O MVP implementa o subconjunto social e automático: confirmação automática pela tipificação da L-2, confirmação por cidadãos presentes (3 confirmações de cidadãos distintos em 700 m), denúncia com motivo em lista fechada (3 denúncias levam o lugar a disputado), retirada pelo autor e reativação por operador. Reincidência temporal, validação presencial sorteada e revalidação periódica ficam para a Fase 2.

Consome lugar.cadastrado da L-1 e lugar.georreferenciado da L-2. Produz lugar.validado e lugar.desativado, consumidos pela D-7. Não valida demandas. Não analisa cobertura. A análise de cobertura é da L-4.

A L-3 é uma colônia de escrita com face REST no MVP. Os endpoints das ações sociais recebem o header X-Cidadao-Id e respondem às ações do app. A L-3 não importa código de outras colônias; a única dependência externa é o núcleo (EventBus e Registry) e o helper compartilhado de distância.


src/lugar/l-3-validacao-qualidade/
├── l3.module.ts # Module definition — OnModuleInit chama iniciar()
├── l3.service.ts # Lógica de negócio: validação, confirmação, denúncia, retirada
├── l3.repository.ts # Acesso a l3.status_lugares, l3.confirmacoes e l3.denuncias
├── l3.controller.ts # Endpoints REST das ações sociais
├── l3.constants.ts # Constantes: raio, mínimos, status, motivos e eventos
└── dto/
├── criar-confirmacao-lugar.dto.ts
├── criar-denuncia-lugar.dto.ts
└── lugar-validacao-resposta.dto.ts
@Module({
imports: [],
controllers: [L3Controller],
providers: [L3Service, L3Repository, PapelOperadorGuard],
exports: [],
})
export class L3Module implements OnModuleInit {
constructor(private readonly l3Service: L3Service) {}
async onModuleInit(): Promise<void> {
await this.l3Service.iniciar();
}
}

O L3Module é registrado no app.module.ts ao lado de L1Module e L2Module.

  • A colônia escreve somente no schema l3. Nenhum acesso a schema de outra colônia.
  • O PapelOperadorGuard protege apenas a reativação. As ações sociais usam o header X-Cidadao-Id.
  • A L-3 importa o helper distanciaHaversineMetros de src/duplicidade/haversine.ts. O helper é aritmética pura compartilhada, sem estado de outra colônia.
  • O iniciar() segue a ordem do protocolo da N-0a: seed do cursor, replay e inscrição.

O L3Service concentra o consumo de eventos, as regras de validação e a publicação de saída. Os métodos públicos:

Método Responsabilidade
iniciar() Seed do cursor, replay de eventos perdidos e registro dos consumidores
processarLugarCadastrado(evento) Cria o registro provisorio com autor e coordenadas
processarLugarGeorreferenciado(evento) Aplica a confirmação automática por confiança
registrarConfirmacao(lugarId, cidadaoId, localizacao) Registra confirmação com presença e atualiza o total
registrarDenuncia(lugarId, cidadaoId, motivo, localizacao) Registra denúncia com presença e atualiza o total
retirar(lugarId, cidadaoId) Desativa o lugar pelo autor
reativar(lugarId) Devolve o lugar disputado ou desativado a provisório

A L-3 expõe endpoints das ações sociais. A face REST existe porque as ações são síncronas e iniciadas pelo app. O padrão de autenticação segue a D-12: header X-Cidadao-Id com UUID v4, sem token.

O iniciar() executa, nesta ordem:

  1. Seed do cursor: seedOffsets(TIPOS_EVENTO_CONSUMIDOS, obterMaiorSequence()). O cursor nasce na maior sequência do log e não sobrescreve cursor existente.
  2. Replay: replayDeSequence(cursor, [tipo]) para cada tipo consumido, despachado pelos handlers existentes.
  3. Inscrição: eventBus.inscrever(tipo, 'L-3', handler) só depois do replay.

O cursor avança em todo caminho terminal, inclusive nos descartes por validação, por escopo ou por idempotência. Handler que lança exceção não avança o cursor; o evento vai para a DLQ e o replay do boot seguinte retenta.


Quatro tabelas próprias. Nenhuma referência a schemas de outras colônias.

model l3_status_lugares {
lugar_id String @id @db.Uuid
cidadao_id String @db.Uuid
localizacao_lat Float
localizacao_lng Float
status_confianca String @default("provisorio") @db.VarChar(20)
metodo_validacao String? @db.VarChar(30)
total_confirmacoes Int @default(0)
total_denuncias Int @default(0)
versao_dados Int @default(1)
event_id String @db.Uuid
correlacao_id String @db.Uuid
publicacao_pendente Boolean @default(false)
criado_em DateTime @default(now()) @db.Timestamptz(2)
atualizado_em DateTime @default(now()) @updatedAt @db.Timestamptz(2)
@@map("status_lugares")
@@schema("l3")
@@index([status_confianca])
@@index([cidadao_id])
}
Coluna Descrição
lugar_id Identificador do lugar. Chave primária e idempotência do cadastro.
cidadao_id Autor do registro. Usado para excluir o autor das ações sociais.
localizacao_lat/lng Coordenada do lugar, preservada para a checagem de presença de 700 m.
status_confianca provisorio, confirmado, disputado ou desativado.
metodo_validacao automatico, social, operador ou retirado_pelo_autor. Ausente enquanto provisório sem ação.
total_confirmacoes Contador de confirmações de cidadãos distintos.
total_denuncias Contador de denúncias de cidadãos distintos.
versao_dados Incrementa a cada transição. Compõe o event_id determinístico da saída.
event_id Evento de lugar.cadastrado que originou o registro.
correlacao_id Correlação herdada do evento de origem.
publicacao_pendente Marca que a última transição ainda não teve o evento publicado. A varredura do boot republica os pendentes com o mesmo event_id determinístico e limpa a marca.
model l3_confirmacoes {
confirmacao_id String @id @db.Uuid
lugar_id String @db.Uuid
cidadao_id String @db.Uuid
localizacao_lat Float
localizacao_lng Float
distancia_metros Float
criado_em DateTime @default(now()) @db.Timestamptz(2)
evento_id String @unique @db.Uuid
@@map("confirmacoes")
@@schema("l3")
@@unique([lugar_id, cidadao_id])
@@index([lugar_id])
}

A chave única (lugar_id, cidadao_id) garante uma confirmação por cidadão e por lugar. A coluna evento_id é derivada do confirmacao_id e torna a projeção idempotente.

model l3_denuncias {
denuncia_id String @id @db.Uuid
lugar_id String @db.Uuid
cidadao_id String @db.Uuid
motivo String @db.VarChar(30)
localizacao_lat Float
localizacao_lng Float
distancia_metros Float
criado_em DateTime @default(now()) @db.Timestamptz(2)
evento_id String @unique @db.Uuid
@@map("denuncias")
@@schema("l3")
@@unique([lugar_id, cidadao_id])
@@index([lugar_id])
}

Mesma estrutura da confirmação, com motivo em lista fechada: nao_existe, dados_incorretos, duplicado e inadequado.

model l3_consumer_offset {
tipo_evento String @id @db.VarChar(255)
last_sequence BigInt @default(0)
updated_at DateTime @default(now()) @db.Timestamptz(2)
@@map("consumer_offset")
@@schema("l3")
}

20260914120000_l3_validacao_lugares cria o schema l3 e as quatro tabelas. 20260918190000_l3_publicacao_pendente adiciona a coluna publicacao_pendente a l3.status_lugares. Aplicadas com migrate deploy no container e prisma generate no host e no container.

l3.status_lugares é a raiz por lugar_id. l3.confirmacoes e l3.denuncias apontam para o lugar por lugar_id, sem chave estrangeira declarada entre schemas. Os totais ficam desnormalizados no status para a leitura rápida.

  • Totais desnormalizados em status_lugares evitam contagem a cada leitura e tornam o payload de lugar.validado uma leitura de linha única.
  • versao_dados compõe o event_id determinístico. O retry inline, a varredura de pendentes e o replay republicam a mesma versão sem duplicar o evento no barramento.
  • A marca publicacao_pendente é gravada na mesma atualização que incrementa versao_dados e limpa após a publicação. A varredura do boot recupera a saída perdida sem consultar o log de eventos.
  • A coordenada do autor da confirmação e da denúncia é privada. Nenhum endpoint público devolve a linha individual.

Propriedade Valor
Tipo lugar.cadastrado
Schema version 1.2.0 (versões 1.0.0 e 1.1.0 continuam aceitas)
Produtor L-1
Consumidor L-3 (validação) e D-7 (projeção)
Descrição Lugar registrado com status pendente_georreferenciamento.

Campos usados pela L-3: lugar_id, tipo_lugar, coordenadas_brutas e cidadao_id. O campo descricao da versão 1.2.0 não é usado pela L-3.

Regras do handler:

  • Payload inválido (sem lugar_id, tipo_lugar, cidadao_id ou coordenadas): descarta e avança o cursor.
  • Tipo residencia ou poligono_uc: descarta por LGPD e por escopo, com cursor avançado.
  • Lugar já registrado: idempotente, avança o cursor sem sobrescrever o estado.
  • Caminho normal: insere l3.status_lugares como provisorio, com versao_dados = 1.
Propriedade Valor
Tipo lugar.georreferenciado
Schema version 1.0.0
Produtor L-2
Consumidor L-3, L-1 e E-1
Descrição Lugar com UC resolvida e tipificação enriquecida.

Campos usados pela L-3: lugar_id, confianca_geo, confianca_tipificacao e enriquecimento.divergencia.

Regras do handler:

  • Lugar sem registro na L-3: descarta com aviso e avança o cursor.
  • Lugar com status diferente de provisorio: ignora a validação automática e avança o cursor.
  • Confiança alta em geo e tipificação e sem divergência: confirma com metodo_validacao = 'automatico' e publica lugar.validado.
  • Fora do limiar: mantém provisorio sem publicar.
Propriedade Valor
Tipo lugar.validado
Schema version 1.0.0
Produtor L-3
Consumidor D-7
Descrição Status de confiança do lugar atualizado.
{
"lugar_id": "uuid",
"status_confianca": "provisorio | confirmado | disputado",
"metodo_validacao": "automatico | social | operador",
"total_confirmacoes": 0,
"total_denuncias": 0,
"versao_dados": 1
}

Obrigatórios: lugar_id, status_confianca, total_confirmacoes, total_denuncias e versao_dados. metodo_validacao é opcional e ausente enquanto o lugar é provisório sem ação. O status desativado não circula neste evento; a desativação usa lugar.desativado.

O evento é publicado em toda confirmação e denúncia aceitas, mesmo quando o status não muda. O total atualizado chega à D-7 sem consulta adicional.

Propriedade Valor
Tipo lugar.desativado
Schema version 1.0.0
Produtor L-3
Consumidor D-7
Descrição Lugar retirado do mapa público.
{
"lugar_id": "uuid",
"motivo": "denuncias | retirado_pelo_autor | operador"
}

No MVP, apenas retirado_pelo_autor é publicado. Os motivos denuncias e operador ficam para a Fase 2, quando a disputa tiver revisão manual.

  • lugar.cadastrado: descricao entra em permitidos. O campo é conteúdo público do lugar, exposto no painel de detalhes, e não é dado pessoal do titular.
  • lugar.validado: permitidos com todos os campos do schema. Sem dado pessoal e sem coordenada.
  • lugar.desativado: permitidos com lugar_id e motivo.
  • lugar.georreferenciado permanece sem regra. O payload não tem campo pessoal.
  • Handlers de consumo são idempotentes por lugar_id e por event_id. Replay e reentrega não duplicam registros.
  • A unicidade (lugar_id, cidadao_id) em confirmações e denúncias protege contra corrida. A violação P2002 vira 409 com o identificador existente.
  • O event_id dos eventos publicados é derivado de uuidv5 com namespace fixo: lugar.validado:{lugar_id}:{versao_dados} e lugar.desativado:{lugar_id}:{versao_dados}. O retry republica o mesmo evento.
  • A coluna evento_id de l3.confirmacoes e l3.denuncias também é derivada por uuidv5: lugar.confirmacao:{confirmacao_id} e lugar.denuncia:{denuncia_id}. A unicidade da coluna torna as projeções idempotentes.
  • A publicação usa publicarComRetry (4 tentativas com backoff exponencial a partir de 500 ms). A falha definitiva propaga o erro e o status já está persistido com publicacao_pendente = true. A varredura republicarPublicacoesPendentes do boot republica lugar.validado e lugar.desativado com o mesmo event_id determinístico e limpa a marca. O barramento descarta a duplicata quando a publicação original chegou.

lugar.cadastrado recebido
→ extrai lugar_id, tipo_lugar, cidadao_id e coordenadas_brutas
→ payload inválido: descarta e avança o cursor
→ tipo_lugar fora de [organizacao, equipamento_publico]: descarta e avança o cursor
→ lugar já existe em l3.status_lugares: avança o cursor (idempotente)
→ insere status provisorio com autor, coordenadas, versao_dados = 1
→ avança o cursor

4.2 processarLugarGeorreferenciado() — confirmação automática

Seção intitulada “4.2 processarLugarGeorreferenciado() — confirmação automática”
lugar.georreferenciado recebido
→ extrai lugar_id
→ lugar sem registro na L-3: descarta com aviso e avança o cursor
→ status diferente de provisorio: ignora e avança o cursor
→ confianca_geo = alta e confianca_tipificacao = alta e sem enriquecimento.divergencia:
→ status = confirmado, metodo_validacao = automatico, versao_dados += 1
→ publica lugar.validado
→ caso contrário: mantém provisorio, sem publicar
→ avança o cursor
registrarConfirmacao(lugarId, cidadaoId, localizacao)
→ valida UUIDs e coordenadas
→ lugar inexistente: 404
→ status terminal (disputado ou desativado): 409
→ autor do lugar: 400
→ distância Haversine acima de 700 m: 400
→ confirmação existente do mesmo cidadão: 409 com o identificador
→ insere confirmação com distancia_metros
→ total = contagem de confirmações do lugar
→ total >= 3 e status = provisorio: status = confirmado, metodo_validacao = social
→ atualiza status com o total e versao_dados += 1
→ publica lugar.validado
→ resposta: confirmacao_id, total_confirmacoes, status_confianca
registrarDenuncia(lugarId, cidadaoId, motivo, localizacao)
→ valida UUIDs, coordenadas e motivo em lista fechada
→ lugar inexistente: 404
→ status terminal: 409
→ autor do lugar: 400
→ distância acima de 700 m: 400
→ denúncia existente do mesmo cidadão: 409 com o identificador
→ insere denúncia com motivo e distancia_metros
→ total = contagem de denúncias do lugar
→ total >= 3: status = disputado, metodo_validacao = social
→ atualiza status com o total e versao_dados += 1
→ publica lugar.validado
→ resposta: denuncia_id, total_denuncias, status_confianca

A denúncia não publica lugar.desativado. O lugar disputado sai do mapa público pelo filtro da D-7 e aguarda reativação por operador.

retirar(lugarId, cidadaoId)
→ valida UUIDs
→ lugar inexistente: 404
→ cidadão diferente do autor: 403 sem revelar a autoria
→ status terminal: 409
→ status = desativado, metodo_validacao = retirado_pelo_autor, versao_dados += 1
→ publica lugar.desativado com motivo retirado_pelo_autor
→ resposta: lugar_id, status_confianca = desativado
reativar(lugarId)
→ valida UUID
→ lugar inexistente: 404
→ status não terminal: 409
→ status = provisorio, metodo_validacao = operador, versao_dados += 1
→ publica lugar.validado
→ resposta: lugar_id, status_confianca = provisorio

A reativação preserva os totais e o histórico. O lugar volta ao mapa público para nova validação social.

iniciar()
→ guarda de idempotência (iniciado)
→ maiorSequence = eventBus.obterMaiorSequence()
→ seedOffsets([lugar.cadastrado, lugar.georreferenciado], maiorSequence)
→ para cada tipo: replayDeSequence(cursor, [tipo]) e despacho pelo handler
→ inscreve lugar.cadastrado e lugar.georreferenciado
→ republicaPublicacoesPendentes(): busca até 100 status com publicacao_pendente,
republica lugar.validado ou lugar.desativado com o mesmo event_id e limpa a marca
Caso Comportamento
Evento de lugar residência ou polígono de UC Descarte com cursor avançado.
lugar.georreferenciado sem registro na L-3 Descarte com aviso e cursor avançado.
Lugar já confirmado recebe lugar.georreferenciado Validação automática ignorada.
Coordenada degradada pelo replay A redação arredonda a coordenada no log para cerca de 1 km. Eventos processados ao vivo preservam a coordenada exata. A presença de 700 m pode degradar no replay.
Duas confirmações simultâneas do mesmo cidadão A unicidade do banco resolve; uma vira 409.
Confirmação e denúncia do autor 400.
  • O status provisorio é o estado inicial e o estado de retorno da reativação. O lugar provisório fica visível no mapa com marcação distinta.
  • A confirmação automática é conservadora: exige alta nas duas confianças e ausência de divergência.
  • A denúncia dispara disputa, não desativação. A reativação é manual, por operador.
  • A retirada é exclusiva do autor e não revela a autoria para terceiros.
  • A publicação é recuperável: a transição grava publicacao_pendente, a publicação bem-sucedida limpa a marca e a varredura do boot republica o que ficou pendente com o event_id determinístico.

Todos com @ApiTags, @ApiOperation e @ApiResponse completos, no padrão da D-12. Header X-Cidadao-Id obrigatório nas ações sociais.

Propriedade Valor
Status 201
Rate limit 5/min
Corpo { localizacao_lat, localizacao_lng }
Resposta { confirmacao_id, total_confirmacoes, status_confianca }
Erros 400 header ausente, DTO inválido, autor ou fora do raio; 404 lugar inexistente; 409 repetida ou status terminal; 429 rate limit
Propriedade Valor
Status 201
Rate limit 5/min
Corpo { motivo, localizacao_lat, localizacao_lng }
Resposta { denuncia_id, total_denuncias, status_confianca }
Erros 400 header ausente, motivo inválido, autor ou fora do raio; 404 lugar inexistente; 409 repetida ou status terminal; 429 rate limit
Propriedade Valor
Status 200
Rate limit 5/min
Corpo vazio
Resposta { lugar_id, status_confianca: 'desativado' }
Erros 400 header ausente; 403 terceiro; 404 lugar inexistente; 409 status terminal; 429 rate limit
Propriedade Valor
Status 200
Rate limit 60/min
Guard PapelOperadorGuard
Resposta { lugar_id, status_confianca: 'provisorio' }
Erros 401 token ausente; 403 sem papel; 404 lugar inexistente; 409 status não terminal; 429 rate limit

6. Integração com o Barramento e Outras Colônias

Seção intitulada “6. Integração com o Barramento e Outras Colônias”
D-1a → lugar.recebido
→ L-1 → lugar.cadastrado 1.2.0
→ L-2 → lugar.georreferenciado
→ L-3 → lugar.validado / lugar.desativado
→ D-7 → d7.lugares_geo (status_confianca, metodo_validacao, total_confirmacoes)

A D-7 consome lugar.validado e lugar.desativado e mantém a projeção d7.lugares_geo. O GET /api/d7/lugares filtra disputado e desativado e devolve status_confianca. O GET /api/d7/lugar/:lugar_id/resumo devolve 404 para lugar disputado ou desativado.

A L-3 não chama outras colônias. A face REST responde ao app e publica eventos. A comunicação com as demais colônias é exclusivamente via barramento.


  • A checagem de presença usa Haversine em memória, pela função compartilhada distanciaHaversineMetros de src/duplicidade/haversine.ts. Sem PostGIS no MVP.
  • O status é leitura de linha única por lugar_id; as contagens usam índice por lugar_id.
  • O replay do boot é paginado em lotes de 1000 pelo EventBus. A varredura de publicações pendentes cobre até 100 linhas por boot.
  • A unicidade (lugar_id, cidadao_id) resolve a corrida de confirmações e denúncias sem lock de aplicação.

Cobertura do subconjunto no MVP:

  • Serviço: criação provisória, confirmação automática, 3 confirmações, autor excluído, fora do raio, 3 denúncias, retirada, reativação, repetição, replay sem duplicata e republicação de publicação pendente no boot.
  • Controller: 400 sem header, 400 autor, 400 fora do raio, 404, 409 terminal, 409 repetido, 403 na retirada de terceiro, 401/403 na reativação e 429.
  • Isolamento: nenhum import de outra colônia e nenhum acesso a schema de outra colônia.

Parâmetro Valor Domínio Fonte Revisão
Raio de presença da validação de lugar 700 m L-3 l3.constants.ts fixo_mvp
Confirmações para confirmar lugar 3 de cidadãos distintos L-3 l3.constants.ts fixo_mvp
Denúncias para disputar lugar 3 de cidadãos distintos L-3 l3.constants.ts fixo_mvp
Confirmação automática de lugar confianca_geo='alta' e confianca_tipificacao='alta' sem divergência L-3 l3.constants.ts fixo_mvp
Rate limit de confirmações de lugar 5/min L-3 l3.controller.ts fixo
Rate limit de denúncias de lugar 5/min L-3 l3.controller.ts fixo
Rate limit de retirada de lugar 5/min L-3 l3.controller.ts fixo
Rate limit de reativação de lugar 60/min L-3 l3.controller.ts fixo

  • A L-3 guarda o cidadao_id do autor e das ações sociais para impedir repetição e excluir o autor. Nenhum identificador de confirmador ou denunciante entra em evento público ou endpoint de leitura.
  • A coordenada de presença é guardada apenas no schema l3, com a distância calculada. Nenhum endpoint público devolve a linha individual.
  • A N-0d anonimiza l3.status_lugares.cidadao_id, l3.confirmacoes.cidadao_id e l3.denuncias.cidadao_id na eliminação do titular.
  • A redação do log de eventos remove cidadao_id dos payloads persistidos e arredonda coordenadas ao nível da UC.

  • Reincidência temporal: múltiplos cidadãos cadastrando o mesmo lugar em janela de tempo. Agrupamento por proximidade com DBSCAN e Haversine.
  • Validação social presencial sorteada: sorteio auditável de cidadãos com vínculo validado na UC, no padrão da D-6a, com foto e geolocalização no raio.
  • Evento lugar.validação_social_solicitada e evento consumido lugar.validação_social_concluída.
  • Revalidação periódica de lugares confirmados a cada 24 meses.
  • Deduplicação definitiva de lugares, substituindo o alerta de proximidade da L-1.
  • Revisão manual do operador para disputas por denúncia, com tela própria. No MVP existe apenas a reativação por endpoint.
  • Foto de validação processada pela D-1c, com hash SHA-256 e metadados EXIF.