L-1 — Cadastro de Lugares
Parte das Colônias de Lugares — Fase 1
Propósito
Seção intitulada “Propósito”A L-1 é o ponto de entrada do dado de lugar no sistema. Consome o evento lugar.recebido, publicado pela D-1a (fluxo do cidadão) ou pela E-1 (endereço de organização), valida regras de negócio, gera o lugar_id definitivo, faz deduplicação preliminar por proximidade geográfica e publica lugar.cadastrado para a L-2 dar continuidade ao georreferenciamento.
Não valida conteúdo semântico, não georreferencia, não verifica se o lugar existe de fato. Aceita, valida, deduplica preliminarmente, registra e publica. O dado bruto nunca é descartado.
A L-1 também consome lugar.georreferenciado da L-2 para atualizar o status dos próprios registros.
A L-1 é uma colônia pura de eventos. Não tem BFF acoplado, não expõe endpoints REST e não faz chamadas síncronas a outras colônias.
1. Estrutura do Módulo NestJS
Seção intitulada “1. Estrutura do Módulo NestJS”A L-1 é um módulo NestJS com encapsulamento próprio dentro do monolito modular do MVP. Consome eventos do barramento via EventBusService (N-0a) e publica o evento de saída ao final do processamento. Não expõe controllers REST.
1.1 Árvore de diretórios
Seção intitulada “1.1 Árvore de diretórios”src/lugar/l-1-cadastro-lugares/├── l1.module.ts # Module definition├── l1.service.ts # Lógica de negócio: validar, deduplicar, persistir, publicar├── l1.constants.ts # Constantes: bounding box, raio de deduplicação, limites, versão do evento├── repositories/│ ├── lugar.repository.ts # Acesso a l1.lugares (insert + query + update)│ └── consumer-offset.repository.ts # Acesso a l1.consumer_offset (cursor de replay)└── services/ └── geo-proximidade.service.ts # Haversine e filtro de proximidadeNão há diretório entities/. Os specs ficam ao lado dos arquivos que testam.
1.2 Module definition
Seção intitulada “1.2 Module definition”@Module({ imports: [], controllers: [], // Colônia pura de eventos — sem REST providers: [ L1Service, LugarRepository, ConsumerOffsetRepository, GeoProximidadeService, ], exports: [],})export class L1Module implements OnModuleInit { constructor(private readonly l1Service: L1Service) {}
async onModuleInit(): Promise<void> { await this.l1Service.iniciar(); }}1.3 Pontos de atenção
Seção intitulada “1.3 Pontos de atenção”- O módulo não é
@Global(). A L-1 não é dependência de nenhuma outra colônia. Outras colônias consomem seu evento (lugar.cadastrado), não seu código. - O módulo não importa
EventBusModuleexplicitamente.EventBusModuleé@Global(), e oEventBusServiceé injetável sem import. - O módulo não importa
RegistryModule. A validação de schema dos eventos publicados é feita pelo próprio Event Bus (N-0a) no momento da publicação. A L-1 apenas constrói o payload conforme o contrato do Registry. - O
OnModuleInitdispara o protocolo de inicialização: seed do cursor, replay de eventos perdidos e registro dos consumidores. - O módulo não registra
ThrottlerModule. O rate limiting é responsabilidade do BFF (D-1a), na borda HTTP. A L-1 processa o que recebe do barramento. - O
GeoProximidadeServiceé um serviço puro de aritmética. Calcula distâncias por Haversine, pela função compartilhadadistanciaHaversineMetros, e filtra candidatos por raio, sem dependência de banco. Testável isoladamente.
1.4 Serviço — responsabilidades e contrato
Seção intitulada “1.4 Serviço — responsabilidades e contrato”Os métodos públicos do L1Service:
| Método | Responsabilidade |
|---|---|
iniciar() |
Seed do cursor, replay de eventos perdidos e registro dos consumidores |
registrarConsumidores() |
Inscreve os handlers de lugar.recebido e lugar.georreferenciado |
processarLugarRecebido(evento) |
Valida, deduplica, persiste o lugar e publica lugar.cadastrado |
processarLugarGeorreferenciado(evento) |
Atualiza o status do registro para georreferenciado |
O serviço não declara interface I*. Nenhuma outra colônia injeta o L1Service. A comunicação com o exterior é via barramento.
1.5 Colônia pura de eventos
Seção intitulada “1.5 Colônia pura de eventos”A L-1 não tem BFF acoplado. É uma colônia de processamento puro: escuta, processa, publica. O input é sempre via barramento, seja do cidadão (D-1a → lugar.recebido) ou da empresa (E-1 → lugar.recebido). A L-1 não sabe e não precisa saber quem originou o evento.
2. Banco de Dados — Schema e Tabelas
Seção intitulada “2. Banco de Dados — Schema e Tabelas”2.1 Schema l1
Seção intitulada “2.1 Schema l1”Todas as tabelas da L-1 residem no schema l1 do PostgreSQL. Este schema é de uso exclusivo do módulo L-1. Nenhuma outra colônia lê ou escreve nestas tabelas.
2.2 Tabela l1.lugares
Seção intitulada “2.2 Tabela l1.lugares”Registro append-only dos lugares recebidos e validados. O dado bruto original é preservado. O status reflete a posição do lugar no pipeline das colônias de lugares.
CREATE SCHEMA IF NOT EXISTS l1;
CREATE TABLE l1.lugares ( id UUID PRIMARY KEY, cidadao_id UUID NOT NULL, tipo_lugar VARCHAR(30) NOT NULL, subtipo VARCHAR(50), nome VARCHAR(200), descricao TEXT, localizacao_lat DOUBLE PRECISION NOT NULL, localizacao_lng DOUBLE PRECISION NOT NULL, horario_funcionamento VARCHAR(200), canal VARCHAR(10) NOT NULL, status VARCHAR(30) NOT NULL DEFAULT 'pendente_georreferenciamento', event_id UUID NOT NULL, correlacao_id UUID NOT NULL, criado_em TIMESTAMPTZ(2) NOT NULL DEFAULT NOW(), atualizado_em TIMESTAMPTZ(2) NOT NULL DEFAULT NOW());
CREATE INDEX lugares_cidadao_id_idx ON l1.lugares (cidadao_id);CREATE INDEX lugares_status_idx ON l1.lugares (status);CREATE INDEX lugares_tipo_lugar_idx ON l1.lugares (tipo_lugar);CREATE INDEX lugares_localizacao_lat_localizacao_lng_idx ON l1.lugares (localizacao_lat, localizacao_lng);CREATE INDEX lugares_event_id_idx ON l1.lugares (event_id);Sem CHECKs no banco. O tipo_lugar, o canal e o status são validados em aplicação, no handler.
Colunas — detalhamento
Seção intitulada “Colunas — detalhamento”| Coluna | Tipo | Descrição |
|---|---|---|
id |
UUID PK | lugar_id definitivo. Gerado pela L-1 durante o processamento. É o identificador usado por todo o sistema de lugares. |
cidadao_id |
UUID | Cidadão que submeteu o lugar. Extraído do payload de lugar.recebido. FK lógica, sem constraint formal. |
tipo_lugar |
VARCHAR(30) | residencia, organizacao, equipamento_publico ou poligono_uc. Validado em aplicação. |
subtipo |
VARCHAR(50) | Subtipo declarado (ex: farmacia, mercado, ubs). Opcional, truncado em 50 caracteres. A validação semântica é da L-2. |
nome |
VARCHAR(200) | Nome do lugar. Truncado em 200 caracteres. |
descricao |
TEXT | Texto livre opcional. O schema de lugar.recebido limita a entrada a 2000 caracteres. |
localizacao_lat |
DOUBLE PRECISION | Latitude validada. |
localizacao_lng |
DOUBLE PRECISION | Longitude validada. |
horario_funcionamento |
VARCHAR(200) | Horário como texto livre. Truncado em 200 caracteres. |
canal |
VARCHAR(10) | app ou web. Valor do payload. Assume app quando ausente. |
status |
VARCHAR(30) | pendente_georreferenciamento (default, aguardando L-2), georreferenciado (L-2 processou) ou erro (falha irrecuperável). |
event_id |
UUID | event_id do evento lugar.recebido que originou este registro. Para rastreabilidade. |
correlacao_id |
UUID | correlacao_id do evento de origem. Para trace distribuído. Assume o lugar_id quando o evento não traz o campo. |
criado_em |
TIMESTAMPTZ(2) | Timestamp de criação. |
atualizado_em |
TIMESTAMPTZ(2) | Timestamp da última alteração (ex: status atualizado após a L-2). |
2.3 Tabela l1.consumer_offset
Seção intitulada “2.3 Tabela l1.consumer_offset”Controla o cursor de processamento para replay seletivo após falha ou reinicialização. Segue o padrão definido pela N-0a (Event Bus).
CREATE TABLE l1.consumer_offset ( tipo_evento VARCHAR(255) PRIMARY KEY, last_sequence BIGINT NOT NULL DEFAULT 0, updated_at TIMESTAMPTZ(2) NOT NULL DEFAULT NOW());Colunas — detalhamento
Seção intitulada “Colunas — detalhamento”| Coluna | Tipo | Descrição |
|---|---|---|
tipo_evento |
VARCHAR(255) PK | Tipo de evento monitorado. |
last_sequence |
BIGINT | Último sequence_number processado com sucesso. |
updated_at |
TIMESTAMPTZ(2) | Data da última atualização do cursor. |
A tabela tem duas linhas: tipo_evento = 'lugar.recebido' e tipo_evento = 'lugar.georreferenciado'. A estrutura com PK em tipo_evento permite extensão futura sem alteração de schema.
2.4 Migration
Seção intitulada “2.4 Migration”Uma migration cria o schema l1: 20260813180817_create_l1_tables cria l1.lugares e l1.consumer_offset com os índices.
O consumer_offset não tem seed por migration. As linhas são criadas no boot com obterMaiorSequence(), sem sobrescrever cursor existente.
Migrations futuras (Fase 2+): extensão para PostGIS (coluna geometry GEOMETRY(POINT, 4326)), índice GIST espacial, partição por UC quando o volume escalar.
2.5 Relações internas
Seção intitulada “2.5 Relações internas”Não há foreign keys entre as tabelas do schema l1. O event_id em l1.lugares referencia o event_id em core.event_log, mas sem FK formal. O log de eventos é schema core, e a regra de isolamento proíbe FKs entre schemas de colônias distintas. A integridade é garantida em aplicação: o L1Service só persiste um lugar se o evento de origem existir no barramento, o que é implícito ao fato de o handler ter sido invocado.
2.6 Decisões de schema
Seção intitulada “2.6 Decisões de schema”id como UUID v4, não hash.
O lugar_id é gerado como UUID v4 pela L-1, não pelo BFF. Diferente do demanda_id do BFF, que também é UUID v4, o lugar_id é gerado apenas quando as validações de negócio passam, o que significa que um lugar_id sempre referencia um lugar válido. A idempotência no barramento (camada N-0a) usa event_id, não lugar_id. Se o BFF retryar a publicação de lugar.recebido, o Event Bus detecta o event_id duplicado e não reentrega à L-1. O lugar_id da L-1 é gerado uma única vez.
Lat/lng como DOUBLE PRECISION, não PostGIS geometry.
A deduplicação preliminar usa distância Haversine em raio de 10 metros. Para esse cálculo, duas colunas DOUBLE PRECISION são suficientes. A extensão PostGIS não se justifica no MVP para a L-1. O custo de instalação e manutenção é maior que o benefício para uma query de proximidade simples. A L-2 e a D-2 também operam sem PostGIS no MVP, compartilhando a leitura de polígonos via core.uc_polygons com GeoJSON e Turf.js. Na Fase 2, a base de polígonos migra para uma colônia dedicada de infraestrutura geoespacial com PostGIS, e todas as colônias (L-1, L-2, D-2) adotam a extensão conforme sua necessidade.
status sem CHECK no banco.
Três estados: pendente_georreferenciamento (após cadastro, aguardando processamento da L-2), georreferenciado (a L-1 atualiza o próprio status ao consumir lugar.georreferenciado) e erro (falha irrecuperável). A validação é feita em aplicação. O banco não declara CHECK porque os handlers aceitam eventos fora de ordem e a decisão de descarte é do fluxo.
Quatro tipos de lugar aceitos.
residencia, organizacao, equipamento_publico e poligono_uc. O tipo poligono_uc é registrado como qualquer outro lugar e publicado em lugar.cadastrado. A L-2 o mantém em análise sem publicar lugar.georreferenciado no MVP.
Truncamento na entrada, não erro de banco.
nome, subtipo e horario_funcionamento são truncados nos limites das colunas antes do INSERT, com log.warn. O INSERT nunca falha por comprimento de campo.
3. Eventos — Contratos Detalhados
Seção intitulada “3. Eventos — Contratos Detalhados”A L-1 consome dois tipos de evento e produz um. Os schemas completos (JSON Schema draft-2020-12) estão definidos no Registry (N-0b). Esta seção descreve os contratos do ponto de vista da L-1: o que espera receber, o que publica e em que ordem.
3.1 Evento consumido: lugar.recebido
Seção intitulada “3.1 Evento consumido: lugar.recebido”| Propriedade | Valor |
|---|---|
| Tipo | lugar.recebido |
| Schema version | 1.0.0 |
| Produtores | D-1a (cidadão), E-1 (organização) |
| Consumidor | L-1 (esta colônia) |
| Descrição | Input bruto de lugar. A L-1 valida regras de negócio, gera lugar_id e publica lugar.cadastrado. |
Payload esperado (conforme o schema lugar.recebido do Registry N-0b):
interface LugarRecebidoPayload { tipo_lugar: string; // 'residencia' | 'organizacao' | 'equipamento_publico' | 'poligono_uc' subtipo?: string; nome?: string; // maxLength 200 descricao?: string; // maxLength 2000 posicao: { // nome do campo no Registry é 'posicao' lat: number; // -90 a 90 lng: number; // -180 a 180 }; horario_funcionamento?: string; // maxLength 200 cidadao_id: string; // UUID v4 canal?: string; // 'app' | 'web'; default 'app'}3.2 Evento produzido: lugar.cadastrado
Seção intitulada “3.2 Evento produzido: lugar.cadastrado”| Propriedade | Valor |
|---|---|
| Tipo | lugar.cadastrado |
| Schema version | 1.2.0 (o catálogo mantém 1.0.0 e 1.1.0) |
| Produtor | L-1 (esta colônia) |
| Consumidores | L-2 (Georreferenciamento e Tipificação), L-3 (Validação e Qualidade) e D-7 (Transparência) |
| Descrição | Lugar validado e registrado no sistema. Status pendente_georreferenciamento. |
Payload publicado (conforme o schema lugar.cadastrado do Registry N-0b, versão 1.2.0):
interface LugarCadastradoPayload { lugar_id: string; // UUID v4 — gerado pela L-1 tipo_lugar: string; subtipo?: string; nome?: string; descricao?: string; // campo opcional da versão 1.2.0; maxLength 5000 coordenadas_brutas: { // nome do campo no Registry é 'coordenadas_brutas' lat: number; lng: number; }; alerta_proximidade?: { // campo opcional da versão 1.1.0; presente quando há duplicata próxima lugar_id_proximo: string; distancia_metros: number; }; cidadao_id: string; canal: string; horario_funcionamento?: string;}O campo descricao é opcional e só entra no payload quando o cidadão informa a descrição no formulário. A L-1 publica a versão 1.2.0 em todos os cadastros, e payloads sem descricao continuam válidos. O limite de entrada de descricao é o do schema de lugar.recebido (2000 caracteres); acima disso o Event Bus rejeita o evento na validação de schema. O campo entra na allowlist de redação como conteúdo público do lugar, ao lado de nome.
3.3 Evento consumido: lugar.georreferenciado
Seção intitulada “3.3 Evento consumido: lugar.georreferenciado”| Propriedade | Valor |
|---|---|
| Tipo | lugar.georreferenciado |
| Schema version | 1.0.0 |
| Produtor | L-2 (Georreferenciamento e Tipificação) |
| Consumidores | L-1 (atualização de status), E-1 (associação territorial) e L-3 (validação) |
| Descrição | Lugar com UC resolvida e cadeia de UCs pai. A L-1 atualiza o status do registro correspondente para georreferenciado. |
Payload esperado (conforme o schema lugar.georreferenciado do Registry N-0b):
interface LugarGeorreferenciadoPayload { lugar_id: string; unidade_civica_id: string; nivel_minimo_resolvido: number; // 1 a 7 cadeia_ucs: string[]; metodo_resolucao: string; // 'gps' | 'endereco' | 'inferencia' confianca_geo: string; // 'alta' | 'media' | 'baixa' tipo_lugar?: string; subtipo_declarado?: string; subtipo_confirmado?: string; confianca_tipificacao?: string; // 'alta' | 'media' | 'baixa' | 'nao_avaliada' enriquecimento?: object;}A L-1 usa apenas o lugar_id. Os demais campos são ignorados no handler de atualização de status.
3.4 Ordem de operações no handler
Seção intitulada “3.4 Ordem de operações no handler”O fluxo em L1Service.processarLugarRecebido() segue esta ordem:
1. Verificar idempotência por event_id → consultar l1.lugares WHERE event_id = ? → se encontrado: log.info("Evento de lugar já processado — ignorando"), acionar garantirPublicacaoLugarCadastrado (republica se o evento não existe no barramento), avançar cursor e retornar
2. Validar campos mínimos de negócio → tipo_lugar em ('residencia', 'organizacao', 'equipamento_publico', 'poligono_uc') → posicao presente com lat e lng numéricos → posicao.lat entre -90 e 90 → posicao.lng entre -180 e 180 → cidadao_id não vazio → descarte: log.error, cursor avançado, sem publicação
3. Validar bounding box → posicao.lat entre LAT_MIN e LAT_MAX → posicao.lng entre LNG_MIN e LNG_MAX → se fora: log.warn("Coordenadas fora da área de operação — descartando"), cursor avançado, sem publicação
4. Deduplicação preliminar por proximidade → buscar candidatos do mesmo tipo_lugar com status diferente de 'erro' na bounding box do raio de 10 metros → filtrar por Haversine e ordenar por distância → se houver próximo: log.info("Possível duplicata detectada") e montar o alerta_proximidade com o mais próximo → o cadastro não é bloqueado
5. Truncar campos no limite das colunas → subtipo em 50, nome em 200, horario_funcionamento em 200, com log.warn
6. Gerar lugar_id → UUID v4 → correlacao_id usa o do evento; na ausência, o lugar_id
7. Persistir no estado próprio → INSERT em l1.lugares com status 'pendente_georreferenciamento'
8. Publicar lugar.cadastrado com publicarComRetry → se falhar após as tentativas: log.error e relançar a exceção O cursor não avança, o evento de entrada vai para a DLQ e o registro permanece em l1.lugares. No replay, a idempotência do passo 1 chama garantirPublicacaoLugarCadastrado e republica a saída
9. Atualizar consumer offset → last_sequence = event.sequence_number para 'lugar.recebido'Persistir antes de publicar garante que o lugar esteja registrado mesmo se o barramento falhar. O cursor avança em todo caminho terminal, inclusive nos descartes por validação, idempotência ou escopo. Handler que relança exceção não avança o cursor.
3.5 Tratamento de erro e idempotência
Seção intitulada “3.5 Tratamento de erro e idempotência”| Cenário | Comportamento |
|---|---|
| Evento reentregue (replay DLQ) | Detectado por event_id no passo 1. O registro existente é encontrado, o log registra o ignorado e garantirPublicacaoLugarCadastrado republica lugar.cadastrado quando o evento não existe no barramento. O event_id da publicação é o lugar_id, o que mantém a idempotência no barramento. |
| Coordenadas inválidas (fora de -90/+90 e -180/+180) | Log.error. Evento descartado. Nenhum registro em l1.lugares. Cursor avança. |
| Coordenadas fora do bounding box | Log.warn. Evento descartado. Nenhum registro. Cursor avança. Útil para detectar tentativas de cadastro fora da área de operação. |
| INSERT falha (violação de PK, erro de banco) | Log.error e exceção propagada. O cursor não avança e o evento vai para a DLQ. No replay, o passo 1 não encontra registro e reprocessa. |
Publicação de lugar.cadastrado falha após o retry |
Log.error e exceção propagada. O registro existe em l1.lugares, o cursor não avança e o evento de entrada vai para a DLQ. No replay, a republicação usa garantirPublicacaoLugarCadastrado. O scheduled job de reconciliação de status = 'pendente_georreferenciamento' sem evento correspondente fica para a Fase 2. |
Evento com tipo_lugar ausente ou inválido |
Log.error. Descartado. Cursor avança. A validação de schema no Registry impediria o evento de ser publicado, mas a L-1 valida explicitamente na entrada. |
| Duplicata detectada (alerta de proximidade) | Não bloqueia. O campo alerta_proximidade é incluído no payload de lugar.cadastrado. A L-3 usa esse metadado. |
3.6 Decisões de design com justificativa
Seção intitulada “3.6 Decisões de design com justificativa”Persistir antes de publicar.
Mesmo princípio da N-0a e do BFF: o estado próprio é a memória da colônia. Se o barramento falhar após o INSERT, o dado está salvo e recuperável. A publicação usa publicarComRetry e a falha definitiva propaga, para que o cursor não avance e o replay cubra o ciclo.
Idempotência por event_id, não por hash de conteúdo.
A idempotência de negócio (hash de conteúdo) é feita pelo BFF na camada HTTP. A L-1 opera na camada de eventos: a mesma origem publicando o mesmo event_id duas vezes deve produzir o mesmo resultado uma única vez. Se dois cidadãos diferentes submeterem lugares idênticos, cada um gera um event_id distinto no barramento, e a L-1 cria dois registros. A deduplicação de conteúdo é da L-3.
Deduplicação preliminar como alerta, não bloqueio. Um falso positivo na deduplicação, bloquear um lugar legítimo, é pior que um falso negativo, deixar passar uma duplicata. A L-1 apenas sinaliza. A L-3 decide sobre a existência de fato do lugar.
Bounding box na L-1 mesmo com o BFF já validando.
O BFF valida bounding box para eventos do cidadão. A E-1 publica lugar.recebido sem passar pelo BFF. A L-1 é a única camada de validação que cobre 100% dos eventos, independente da origem. A validação duplicada para o fluxo do cidadão é redundância barata.
4. Lógica de Negócio — Algoritmos e Fluxos
Seção intitulada “4. Lógica de Negócio — Algoritmos e Fluxos”4.1 L1Service.processarLugarRecebido() — pseudocódigo
Seção intitulada “4.1 L1Service.processarLugarRecebido() — pseudocódigo”função processarLugarRecebido(evento: EventoConsultado):
eventId = evento.event_id sequencia = BigInt(evento.sequence_number)
// 1. Idempotência por event_id existente = lugarRepo.buscarPorEventId(eventId) se existente não é null: logger.log("Evento de lugar já processado — ignorando") await garantirPublicacaoLugarCadastrado(existente) await offsetRepo.upsert('lugar.recebido', sequencia) retornar
payload = evento.payload
// 2. Validar campos mínimos tipoLugar = extrairTexto(payload.tipo_lugar) se tipoLugar é null ou não está em TIPOS_LUGAR_VALIDOS: logger.error("tipo_lugar inválido — descartando") await offsetRepo.upsert('lugar.recebido', sequencia) retornar
posicao = extrairPosicao(payload.posicao) se posicao é null: logger.error("Coordenadas ausentes ou inválidas — descartando") await offsetRepo.upsert('lugar.recebido', sequencia) retornar
se posicao.lat < -90 ou posicao.lat > 90 ou posicao.lng < -180 ou posicao.lng > 180: logger.error("Coordenadas fora do intervalo global — descartando") await offsetRepo.upsert('lugar.recebido', sequencia) retornar
cidadaoId = extrairTexto(payload.cidadao_id) se cidadaoId é null: logger.error("cidadao_id ausente — descartando") await offsetRepo.upsert('lugar.recebido', sequencia) retornar
// 3. Validar bounding box se posicao.lat < LAT_MIN ou posicao.lat > LAT_MAX ou posicao.lng < LNG_MIN ou posicao.lng > LNG_MAX: logger.warn("Coordenadas fora da área de operação — descartando") await offsetRepo.upsert('lugar.recebido', sequencia) retornar
// 4. Deduplicação preliminar candidatos = await lugarRepo.buscarCandidatosProximos( tipoLugar, posicao.lat, posicao.lng, RAIO_DEDUP_METROS, LIMITE_RESULTADOS_PROXIMIDADE) se candidatos.length >= LIMITE_RESULTADOS_PROXIMIDADE: logger.warn("Limite de candidatos de proximidade atingido")
proximos = geoProximidade.filtrarProximos( candidatos, posicao.lat, posicao.lng, RAIO_DEDUP_METROS, LIMITE_RESULTADOS_PROXIMIDADE)
alertaProximidade = null se proximos.length > 0: alertaProximidade = { lugar_id_proximo: proximos[0].id, distancia_metros: proximos[0].distancia_metros, } logger.log("Possível duplicata detectada")
// 5. Truncar campos subtipo = truncar(extrairTexto(payload.subtipo), 50) nome = truncar(extrairTexto(payload.nome), 200) horarioFuncionamento = truncar(extrairTexto(payload.horario_funcionamento), 200) descricao = extrairTexto(payload.descricao) canal = extrairTexto(payload.canal) ?? 'app' // cada truncamento efetivo emite log.warn
// 6. Gerar lugar_id lugarId = UUIDv4() correlacaoId = evento.correlacao_id ?? lugarId
// 7. Persistir await lugarRepo.inserir({ id: lugarId, cidadao_id: cidadaoId, tipo_lugar: tipoLugar, subtipo, nome, descricao, localizacao_lat: posicao.lat, localizacao_lng: posicao.lng, horario_funcionamento: horarioFuncionamento, canal, status: 'pendente_georreferenciamento', event_id: eventId, correlacao_id: correlacaoId, })
// 8. Publicar lugar.cadastrado payloadEvento = { lugar_id: lugarId, tipo_lugar: tipoLugar, coordenadas_brutas: { lat: posicao.lat, lng: posicao.lng }, cidadao_id: cidadaoId, canal, } se subtipo não é null: payloadEvento.subtipo = subtipo se nome não é null: payloadEvento.nome = nome se descricao não é null: payloadEvento.descricao = descricao se horarioFuncionamento não é null: payloadEvento.horario_funcionamento = horarioFuncionamento se alertaProximidade não é null: payloadEvento.alerta_proximidade = alertaProximidade
tentar: await publicarComRetry(eventBus, { tipo: 'lugar.cadastrado', versao_schema: '1.2.0', origem: 'L-1', event_id: lugarId, correlacao_id: correlacaoId, payload: payloadEvento, }) capturar erro: logger.error("Falha ao publicar lugar.cadastrado") relançar erro // cursor não avança; o evento de entrada vai para a DLQ
// 9. Atualizar consumer offset await offsetRepo.upsert('lugar.recebido', sequencia)garantirPublicacaoLugarCadastrado consulta o core.event_log com consultarEventos({ tipo: 'lugar.cadastrado', correlacao_id: lugar.correlacao_id, limite: 1 }). Se já existe, retorna sem republicar. Caso contrário, publica com event_id = lugar.id, reutilizando o registro de l1.lugares.
4.2 Deduplicação por proximidade — algoritmo
Seção intitulada “4.2 Deduplicação por proximidade — algoritmo”função buscarCandidatosProximos(tipoLugar, lat, lng, raioMetros, limite): // Pré-filtro barato em bounding box deltaLat = raioMetros / 111320.0 deltaLng = raioMetros / (111320.0 * cos(radianos(lat)))
candidatos = SELECT id, localizacao_lat, localizacao_lng FROM l1.lugares WHERE tipo_lugar = tipoLugar AND status != 'erro' AND localizacao_lat BETWEEN (lat - deltaLat) AND (lat + deltaLat) AND localizacao_lng BETWEEN (lng - deltaLng) AND (lng + deltaLng) LIMIT limite
retornar candidatos
função filtrarProximos(candidatos, lat, lng, raioMetros, limite): proximos = [] para cada c in candidatos: distancia = calcularDistanciaMetros(lat, lng, c.localizacao_lat, c.localizacao_lng) se distancia < raioMetros: proximos.push({ id: c.id, distancia_metros: arredondar(distancia, 2) })
ordenar proximos por distancia_metros ASC retornar proximos.slice(0, limite)A bounding box aproximada funciona como pré-filtro. O índice lugares_localizacao_lat_localizacao_lng_idx cobre a query. Para um raio de 10 metros, o delta é de cerca de 0.00009 graus, e a query é rápida. O cálculo exato de Haversine é aplicado apenas aos candidatos que passaram pelo pré-filtro. No MVP de bairro, o total de lugares com o mesmo tipo_lugar é pequeno.
4.3 Cálculo de Haversine — implementação de referência
Seção intitulada “4.3 Cálculo de Haversine — implementação de referência”função calcularDistanciaMetros(lat1, lng1, lat2, lng2): R = 6371000 // raio da Terra em metros
φ1 = radianos(lat1) φ2 = radianos(lat2) Δφ = radianos(lat2 - lat1) Δλ = radianos(lng2 - lng1)
a = sin(Δφ / 2)² + cos(φ1) * cos(φ2) * sin(Δλ / 2)² c = 2 * atan2(sqrt(a), sqrt(1 - a))
retornar R * cO GeoProximidadeService.calcularDistanciaMetros() delega para distanciaHaversineMetros de src/duplicidade/haversine.ts, a mesma função usada pela D-12 e pela L-3. A aritmética vive em um único lugar. Na Fase 2, se a L-1 migrar para PostGIS, a query se torna ST_DWithin(geography, geography, raio) com índice GIST. O contrato da função permanece idêntico.
4.4 L1Service.processarLugarGeorreferenciado() — pseudocódigo
Seção intitulada “4.4 L1Service.processarLugarGeorreferenciado() — pseudocódigo”função processarLugarGeorreferenciado(evento: EventoConsultado):
sequencia = BigInt(evento.sequence_number) payload = evento.payload
// 1. Validar lugar_id no payload lugarId = extrairTexto(payload.lugar_id) se lugarId é null: logger.error("lugar_id ausente em lugar.georreferenciado") await offsetRepo.upsert('lugar.georreferenciado', sequencia) retornar
// 2. Buscar registro correspondente lugar = lugarRepo.buscarPorId(lugarId) se lugar é null: logger.warn("lugar.georreferenciado com lugar_id não encontrado") await offsetRepo.upsert('lugar.georreferenciado', sequencia) retornar
// 3. Idempotência: só atualiza se ainda não estiver georreferenciado se lugar.status == 'georreferenciado': logger.log("Lugar já georreferenciado — ignorando") await offsetRepo.upsert('lugar.georreferenciado', sequencia) retornar
// 4. Atualizar status await lugarRepo.atualizarStatus(lugarId, 'georreferenciado') await offsetRepo.upsert('lugar.georreferenciado', sequencia)
logger.log("Status do lugar atualizado para georreferenciado", { lugar_id: lugarId, unidade_civica_id: payload.unidade_civica_id, })4.5 L1Service.iniciar() — protocolo de inicialização
Seção intitulada “4.5 L1Service.iniciar() — protocolo de inicialização”função iniciar():
se iniciado: retornar iniciado = true
// 1. Seed dos cursores com a maior sequência do barramento maiorSequence = await eventBus.obterMaiorSequence() await offsetRepo.seed(TIPOS_EVENTO_CONSUMIDOS, maiorSequence)
// 2. Replay dos eventos perdidos, por tipo await reprocessarEventosPerdidos()
// 3. Registrar os consumidores para eventos futuros registrarConsumidores()
logger.log("L-1 Cadastro de Lugares inicializada")reprocessarEventosPerdidos lê os cursores de l1.consumer_offset, chama eventBus.replayDeSequence(cursor, [tipo]) para cada tipo consumido e despacha pelo handler correspondente. Falha em um evento do replay é logada e o evento permanece pendente; os demais seguem.
4.6 Casos de borda
Seção intitulada “4.6 Casos de borda”| Caso | Comportamento |
|---|---|
| Dois lugares idênticos no mesmo ponto exato (lat/lng iguais, mesmo tipo) | Ambos são cadastrados, cada um com lugar_id distinto. O segundo recebe alerta de proximidade com distancia_metros = 0. A L-3 decide sobre a existência de fato. |
| Lugar com coordenadas nos limites do bounding box | Aceito se lat/lng exatamente iguais a LAT_MIN/LAT_MAX (inclusivo). A validação usa >= e <=. |
Lugar com subtipo vazio ou não reconhecido |
Aceito sem validação semântica. O subtipo é validado pela L-2 no enriquecimento. A L-1 apenas trunca em 50 caracteres e armazena. |
Lugar com nome de 300 caracteres |
Truncado para 200 antes do INSERT, com log.warn. Aplica-se o mesmo a horario_funcionamento (200) e subtipo (50). descricao é TEXT no banco e limitada a 2000 pelo schema de lugar.recebido. |
Evento com cidadao_id que não existe no banco |
A L-1 não valida existência de cidadão. Apenas armazena o cidadao_id do payload. A validação de existência é responsabilidade de quem publica o evento (D-1a ou E-1). |
| Evento da E-1 com coordenadas fora do bounding box | Descartado com log.warn. A E-1 deve validar o endereço antes de publicar. Se falhar, o alerta no log permite correção do cadastro da empresa. |
Tipo poligono_uc |
Aceito e publicado como qualquer lugar. A L-2 o mantém em análise sem publicar lugar.georreferenciado no MVP. |
Dois handlers concorrentes processando o mesmo event_id |
Não ocorre no MVP monolito. Na Fase 2 com múltiplas instâncias, a idempotência por event_id com SELECT antes do INSERT não é atômica. Solução Fase 2: constraint UNIQUE em event_id no banco, com tratamento de violação no INSERT. |
4.7 Decisões de design com justificativa
Seção intitulada “4.7 Decisões de design com justificativa”lugar_id como UUID v4, não hash determinístico.
Se o lugar_id fosse hash do conteúdo, dois lugares com os mesmos dados seriam considerados o mesmo lugar. Isso seria uma decisão de negócio (deduplicação), não de identidade. A L-1 não decide se dois lugares são o mesmo. A L-1 atribui identidade única a cada evento processado e sinaliza proximidade. A identidade é independente do conteúdo.
Handler síncrono (async/await) sem timeout explícito.
No MVP monolito, as operações da L-1 são: validação em memória, uma query de proximidade indexada, um INSERT e uma publicação. Timeout não se justifica. Na Fase 2, com geocodificação externa ou consultas a bases remotas, adicionar Promise.race com timeout configurável.
Consumer offset atualizado depois da publicação.
Se o offset for atualizado antes da publicação e a publicação falhar, o replay após reinício pularia o evento, marcado como processado sem a saída publicada. Atualizar depois da publicação garante que o offset só avança quando o ciclo completo (persistir e publicar) foi concluído. Se a publicação falhar, o offset não avança, e o replay reprocessa o evento. A idempotência por event_id garante que não haverá INSERT duplicado. A regra vale tanto para lugar.recebido quanto para lugar.georreferenciado, e os descartes de validação também avançam o cursor.
5. Integração com o Barramento e Outras Colônias
Seção intitulada “5. Integração com o Barramento e Outras Colônias”5.1 Consumo e publicação no barramento
Seção intitulada “5.1 Consumo e publicação no barramento”A L-1 injeta EventBusService (do módulo @Global() N-0a) e usa o Logger do NestJS. Não há injeção de serviços da N-0c.
| Operação | Chamada | Tipos de evento |
|---|---|---|
| Publicar | publicarComRetry(eventBus, {...}) |
lugar.cadastrado |
| Inscrever | eventBus.inscrever(tipo, 'L-1', handler) |
lugar.recebido e lugar.georreferenciado |
| Replay | eventBus.replayDeSequence(cursor, [tipo]) |
Os dois tipos consumidos |
| Maior sequência | eventBus.obterMaiorSequence() |
Seed dos cursores no boot |
| Consultar | eventBus.consultarEventos({ tipo, correlacao_id, limite }) |
Verificação da saída em garantirPublicacaoLugarCadastrado |
@Injectable()export class L1Service { private readonly logger = new Logger(L1Service.name);
constructor( private readonly eventBus: EventBusService, private readonly lugarRepo: LugarRepository, private readonly offsetRepo: ConsumerOffsetRepository, private readonly geoProximidade: GeoProximidadeService, ) {}}A L-1 depende apenas do núcleo e dos próprios serviços. Nenhuma dependência de outra colônia.
5.2 Fluxo de eventos — cadeia completa
Seção intitulada “5.2 Fluxo de eventos — cadeia completa”Cidadão (app) → POST /lugares (BFF D-1a) → lugar.recebido (barramento) → L-1 (esta colônia) → lugar.cadastrado 1.2.0 → L-2 → lugar.georreferenciado → L-1 (atualiza status) → E-1 → empresa.associação_territorial_definida → L-3 → lugar.validado / lugar.desativado
E-1 (cadastro de organização) → lugar.recebido (barramento, publicado pela E-1) → L-1 → lugar.cadastrado → L-2 → lugar.georreferenciado → L-1 (atualiza status) → E-1 → empresa.associação_territorial_definida → L-3 → lugar.validado / lugar.desativadoA L-1 é agnóstica em relação à origem do evento. O mesmo handler processa lugar.recebido vindo da D-1a ou da E-1. A E-1 publica lugar.recebido com tipo_lugar = 'organizacao' quando uma empresa é cadastrada com endereço de operação. A L-1 trata como qualquer outro lugar; a associação entre o lugar e a entidade empresa é feita pela E-1 ao consumir lugar.georreferenciado da L-2.
5.3 Publicação de lugar.cadastrado
Seção intitulada “5.3 Publicação de lugar.cadastrado”const payloadEvento: Record<string, unknown> = { lugar_id: lugarId, tipo_lugar: tipoLugar, coordenadas_brutas: { lat: posicao.lat, lng: posicao.lng }, cidadao_id: cidadaoId, canal,};if (subtipo !== null) payloadEvento.subtipo = subtipo;if (nome !== null) payloadEvento.nome = nome;if (descricao !== null) payloadEvento.descricao = descricao;if (horarioFuncionamento !== null) payloadEvento.horario_funcionamento = horarioFuncionamento;if (alertaProximidade !== null) payloadEvento.alerta_proximidade = alertaProximidade;
await publicarComRetry(this.eventBus, { tipo: 'lugar.cadastrado', versao_schema: '1.2.0', origem: 'L-1', event_id: lugarId, correlacao_id: correlacaoId, payload: payloadEvento,});O correlacao_id é propagado do evento de origem (lugar.recebido) para o evento de saída (lugar.cadastrado), com fallback para o lugar_id. Isso mantém o trace distribuído: uma ferramenta de observabilidade consegue rastrear o ciclo completo de um lugar desde o POST do cidadão até o georreferenciamento. O event_id é o próprio lugar_id, o que torna a publicação idempotente no barramento. A constante VERSAO_EVENTO_LUGAR_CADASTRADO em l1.constants.ts fixa a versão 1.2.0. Os campos opcionais só entram no payload quando presentes.
5.4 Chamadas síncronas via BFF
Seção intitulada “5.4 Chamadas síncronas via BFF”A L-1 não faz chamadas síncronas a outras colônias. Não expõe endpoints REST. Não atua como proxy. Toda comunicação é via barramento.
5.5 Dependências de projeções de leitura
Seção intitulada “5.5 Dependências de projeções de leitura”A L-1 não consome projeções de leitura de outras colônias. O único dado externo que acessa é o core.event_log via EventBusService.replayDeSequence() e consultarEventos(), dependência do núcleo, permitida.
5.6 Atualização de status via consumo de lugar.georreferenciado
Seção intitulada “5.6 Atualização de status via consumo de lugar.georreferenciado”A L-1 consome lugar.georreferenciado da L-2 e atualiza o próprio status do registro para georreferenciado. Nenhuma colônia escreve no banco de outra.
L-2 processa lugar.cadastrado → point-in-polygon, enriquecimento → publica lugar.georreferenciado (com lugar_id no payload)
L-1 consome lugar.georreferenciado → UPDATE l1.lugares SET status = 'georreferenciado', atualizado_em = NOW() WHERE id = payload.lugar_id → atualiza consumer offset para 'lugar.georreferenciado'O ciclo completo fecha via barramento: D-1a → lugar.recebido → L-1 → lugar.cadastrado → L-2 → lugar.georreferenciado → L-1 (atualização de status). A L-1 é a única colônia que insere e atualiza registros em l1.lugares.
6. Performance e Limites
Seção intitulada “6. Performance e Limites”6.1 Rate limiting
Seção intitulada “6.1 Rate limiting”A L-1 não aplica rate limiting. Essa responsabilidade é do BFF (D-1a), na borda HTTP. O volume de eventos que chega à L-1 é limitado indiretamente pelo rate limiting do BFF e pela frequência de cadastro de organizações na E-1. O barramento entrega o que recebe, e a L-1 processa.
6.2 Cotas e limites de tamanho
Seção intitulada “6.2 Cotas e limites de tamanho”| Limite | Valor | Justificativa |
|---|---|---|
nome (lugar) |
200 caracteres | Igual ao schema de lugar.recebido. Truncado antes do INSERT, com log.warn. |
subtipo |
50 caracteres | Limite da coluna. Truncado com log.warn. |
descricao |
TEXT no banco, 2000 na entrada | O schema de lugar.recebido limita a 2000 caracteres. A L-1 não impõe limite adicional. O schema de lugar.cadastrado 1.2.0 aceita até 5000. |
horario_funcionamento |
200 caracteres | Igual ao schema de lugar.recebido. Truncado com log.warn. |
| Raio de deduplicação | 10 metros | Suficiente para detectar o mesmo estabelecimento cadastrado duas vezes. Não captura duplicatas em quarteirões diferentes. |
| Resultados máximos da query de proximidade | 50 | Limite prático. Se houver mais de 50 lugares do mesmo tipo no raio de 10 m, algo está errado (ex: coordenadas de um shopping center com dezenas de lojas). Log.warn se atingir o limite. |
| Consumer offset | 2 linhas | lugar.recebido e lugar.georreferenciado. |
6.3 Índices e padrões de query
Seção intitulada “6.3 Índices e padrões de query”| Índice | Query atendida |
|---|---|
lugares_pkey (id) |
Acesso direto por lugar_id, usado por processarLugarGeorreferenciado() para atualizar status. |
lugares_event_id_idx (event_id) |
Idempotência: WHERE event_id = ?, toda invocação do handler de lugar.recebido. |
lugares_localizacao_lat_localizacao_lng_idx (lat, lng) |
Deduplicação: pré-filtro por bounding box na busca de proximidade. |
lugares_cidadao_id_idx (cidadao_id) |
“Meus lugares” (futuro): WHERE cidadao_id = ?. |
lugares_status_idx (status) |
Dashboard e scheduled jobs: WHERE status = ?. |
lugares_tipo_lugar_idx (tipo_lugar) |
Análise: distribuição de tipos de lugar. |
consumer_offset_pkey (tipo_evento) |
Inicialização: WHERE tipo_evento = ?. |
O índice composto de coordenadas cobre a primeira coluna e o PostgreSQL filtra a segunda em memória. Para o volume do MVP, a performance é adequada. Na Fase 2, o índice GIST do PostGIS substitui para queries espaciais com aceleração real.
6.4 Padrões de query esperados
Seção intitulada “6.4 Padrões de query esperados”O padrão de acesso dominante é INSERT (criação de lugar). SELECTs são raros e pontuais:
buscarPorEventId(): 1 query por evento recebido (idempotência). Coberta pelo índice emevent_id.buscarCandidatosProximos(): 1 query por evento recebido (deduplicação). Coberta pelo índice composto de coordenadas.buscarTodos(): 1 query na inicialização (consumer offset). Coberta pela PK.consultarEventos(): consulta aocore.event_logna republicação de saída.
Volume esperado no MVP: menos de 100 lugares por dia em um bairro. Total de registros: menos de 10.000 por ano.
6.5 Estratégia de cache
Seção intitulada “6.5 Estratégia de cache”Sem cache na L-1:
- A query de idempotência (
buscarPorEventId) é coberta por índice. - A query de proximidade (
buscarCandidatosProximos) é por bounding box indexada, com conjunto de resultados pequeno. - O consumer offset é consultado uma vez na inicialização.
Adicionar cache aumentaria a complexidade sem benefício mensurável no MVP.
6.6 Projeção de volume
Seção intitulada “6.6 Projeção de volume”| Cenário | Lugares/dia | Tamanho estimado do banco (ano) |
|---|---|---|
| PoC (1 bairro, 10 entusiastas) | ~20 | < 2 MB |
| MVP (1 município, centenas de usuários) | ~100 | < 10 MB |
| Fase 2 (regional) | ~10.000 | < 1 GB |
O crescimento é linear. A tabela l1.lugares é compacta. Para a Fase 2, a estratégia de particionamento segue o padrão do Event Bus: partição por período, se o volume justificar.
7. Testabilidade
Seção intitulada “7. Testabilidade”7.1 Como testar o módulo isolado
Seção intitulada “7.1 Como testar o módulo isolado”O L1Service é testado em um TestingModule do NestJS com o EventBusService, os repositórios e o GeoProximidadeService substituídos por mocks:
const module: TestingModule = await Test.createTestingModule({ providers: [ L1Service, { provide: EventBusService, useValue: mockEventBus }, { provide: LugarRepository, useValue: mockLugarRepo }, { provide: ConsumerOffsetRepository, useValue: mockOffsetRepo }, { provide: GeoProximidadeService, useValue: mockGeoProximidade }, ],}).compile();
service = module.get(L1Service);O GeoProximidadeService tem spec próprio, sem mocks, cobrindo o cálculo de Haversine e o filtro de proximidade.
7.2 Cenários de teste críticos
Seção intitulada “7.2 Cenários de teste críticos”Happy path:
| # | Cenário | Verificação |
|---|---|---|
| T1 | processarLugarRecebido() com payload completo e válido |
INSERT em l1.lugares com status pendente_georreferenciamento. Publicação de lugar.cadastrado com event_id = lugar_id. Consumer offset atualizado. |
| T2 | Payload mínimo | canal assume app. Lugar persistido e evento publicado. |
| T3 | tipo_lugar = 'poligono_uc' |
Aceito, persistido e publicado. |
| T4 | correlacao_id propagado do evento de origem |
Registro e payload de saída com o mesmo correlacao_id. |
| T5 | iniciar() com replay |
Cursores semeados no maior sequence, replay a partir do cursor persistido e consumidores registrados após o replay. |
Validações e descartes:
| # | Cenário | Verificação |
|---|---|---|
| T6 | Evento já processado (mesmo event_id) |
Handler retorna sem INSERT. garantirPublicacaoLugarCadastrado repubblica quando a saída não existe. Cursor avança. |
| T7 | tipo_lugar inválido |
Handler retorna sem INSERT. Log.error. Cursor avança. |
| T8 | Posição ausente | Handler retorna. Log.error. |
| T9 | Coordenadas fora do intervalo global | Handler retorna. Log.error. |
| T10 | Coordenadas fora do bounding box | Handler retorna. Log.warn. Nenhum INSERT. Cursor avança. |
| T11 | cidadao_id vazio |
Handler retorna. Log.error. Nenhum INSERT. |
Deduplicação, truncamento e falhas:
| # | Cenário | Verificação |
|---|---|---|
| T12 | Lugar próximo detectado | Publicação inclui alerta_proximidade com lugar_id_proximo e distancia_metros. |
| T13 | Sem candidatos próximos | Publicação sem alerta_proximidade. |
| T14 | nome com 300 caracteres |
Truncado para 200 com log.warn. |
| T15 | horario_funcionamento com 300 caracteres |
Truncado para 200 com log.warn. |
| T16 | Publicação do lugar.cadastrado falha |
Registro persiste em l1.lugares, exceção propagada e cursor não atualizado. Com retry, a falha transitória é recuperada. |
| T17 | processarLugarGeorreferenciado() com lugar pendente |
Status atualizado para georreferenciado. Cursor avança. |
| T18 | lugar.georreferenciado para lugar já georreferenciado |
Ignorado com cursor avançado. |
| T19 | lugar.georreferenciado sem lugar_id ou sem registro |
Descartado com cursor avançado. |
7.3 Dados de desenvolvimento local
Seção intitulada “7.3 Dados de desenvolvimento local”Os cursores são semeados no boot com obterMaiorSequence(), sem seed manual. Para exercitar o fluxo, publique lugar.recebido pelo POST /lugares do BFF ou insira lugares diretamente:
INSERT INTO l1.lugares (id, cidadao_id, tipo_lugar, subtipo, nome, descricao, localizacao_lat, localizacao_lng, canal, status, event_id, correlacao_id)VALUES ( 'd4e5f6a7-b8c9-4123-defa-123456789abc', 'b2c3d4e5-f6a7-4901-bcde-f12345678901', 'organizacao', 'farmacia', 'Farmacia Sao Joao', 'Farmacia do bairro', -23.55100, -46.63400, 'app', 'pendente_georreferenciamento', 'e3f4a5b6-c7d8-4012-efab-123456789abc', 'e3f4a5b6-c7d8-4012-efab-123456789abc' ), ( 'a1b2c3d4-e5f6-4890-abcd-ef1234567890', 'a1b2c3d4-e5f6-4890-abcd-ef1234567890', 'equipamento_publico', 'ubs', 'UBS Jardim das Flores', NULL, -23.55200, -46.63500, 'app', 'georreferenciado', 'f2e3d4c5-b6a7-4901-cdef-1234567890ab', 'f2e3d4c5-b6a7-4901-cdef-1234567890ab' );Para testes de deduplicação, inserir dois lugares com coordenadas muito próximas (menos de 10 m) e mesmo tipo_lugar. Para testes de bounding box, configurar L1_LAT_MIN, L1_LAT_MAX, L1_LNG_MIN e L1_LNG_MAX nas variáveis de ambiente.
8. Alinhamento com o MVP
Seção intitulada “8. Alinhamento com o MVP”8.1 O que é MVP obrigatório
Seção intitulada “8.1 O que é MVP obrigatório”| Funcionalidade | Status |
|---|---|
Consumo de lugar.recebido com handler idempotente por event_id |
MVP obrigatório |
Validação de campos mínimos de negócio (tipo_lugar, coordenadas, cidadao_id) |
MVP obrigatório |
| Validação de bounding box (coordenadas dentro da área de operação) | MVP obrigatório |
Geração de lugar_id via UUID v4 |
MVP obrigatório |
Persistência em l1.lugares com status pendente_georreferenciamento |
MVP obrigatório |
| Deduplicação preliminar por proximidade (alerta, não bloqueio) | MVP obrigatório |
Publicação de lugar.cadastrado 1.2.0 conforme o Registry |
MVP obrigatório |
| Republicação do cadastro quando a saída não existe no barramento | MVP obrigatório |
Consumo de lugar.georreferenciado com atualização de status |
MVP obrigatório |
Consumer offset para replay após falha (l1.consumer_offset) |
MVP obrigatório |
Propagação de correlacao_id do evento de origem para o evento de saída |
MVP obrigatório |
Truncamento de campos que excedem limites do schema (nome, subtipo, horario_funcionamento) |
MVP obrigatório |
Logs estruturados com lugar_id e event_id |
MVP obrigatório |
8.2 Simplificações válidas no MVP
Seção intitulada “8.2 Simplificações válidas no MVP”| Simplificação | Justificativa | Quando remover |
|---|---|---|
| Deduplicação por Haversine simples, sem PostGIS | O volume de lugares no MVP não justifica a extensão PostGIS. O cálculo em memória com pré-filtro por bounding box é suficiente. | Migrar para PostGIS mais ST_DWithin na Fase 2, quando o volume ou a complexidade de queries espaciais exigir. |
Sem validação de subtipo |
O subtipo é validado pela L-2 no enriquecimento com bases externas. A L-1 apenas trunca e armazena. |
Se a L-2 reportar alta taxa de subtipos inválidos, adicionar validação básica na L-1. |
Sem validação de existência de cidadao_id |
A validação é responsabilidade de quem publica o evento. A L-1 confia no barramento. | Adicionar validação se eventos com cidadao_id inválido se tornarem recorrentes. |
| Sem tabela de deduplicação, apenas alerta no payload | O falso positivo não bloqueia. A L-3 faz a validação de existência de fato. | Substituir por índice de similaridade quando a validação da L-3 evoluir. |
last_sequence como BIGINT do PostgreSQL |
Monotonicidade garantida pelo banco único. | Migrar para UUID v7 ou ULID quando houver escrita distribuída (Fase 2). |
| Sem endpoint REST para consulta de lugares | A consulta é feita pela D-7 via projeções de leitura. A L-1 não serve queries. | Se for necessário um endpoint administrativo de consulta direta, adicionar na Fase 2. |
buscarCandidatosProximos() com limite 50 |
Raio de 10 m com o mesmo tipo de lugar raramente retorna mais que 5 resultados. O limite é margem de segurança. | Tornar configurável via variável de ambiente se necessário. |
8.3 O que vai para a Fase 2
Seção intitulada “8.3 O que vai para a Fase 2”- Extensão PostGIS: coluna
geometry GEOMETRY(POINT, 4326), índice GIST,ST_DWithinpara deduplicação - Constraint UNIQUE em
event_idcom tratamento de violação no INSERT - Validação de
subtipocontra lista de subtipos conhecidos da taxonomia de lugares - Scheduled job de reconciliação: varre
l1.lugarescomstatus = 'pendente_georreferenciamento'e sem eventolugar.cadastradocorrespondente, republica - Métricas Prometheus:
l1_lugares_cadastrados_total,l1_duplicatas_detectadas_total,l1_eventos_descartados_total - Índice GIN no
payloadpara queries de auditoria - Suporte a atualização de lugar (
lugar.atualizado) quando o cidadão corrigir dados de um lugar existente
8.4 Verificação de conflitos com outras colônias
Seção intitulada “8.4 Verificação de conflitos com outras colônias”Resolução de status via evento, não escrita direta.
A L-1 é a única colônia que escreve em l1.lugares. A atualização de status para georreferenciado é feita ao consumir lugar.georreferenciado da L-2, via barramento. Nenhuma colônia escreve no banco de outra. A L-2 publica o evento com o resultado do georreferenciamento; a L-1 consome e atualiza o próprio estado.
Duas origens publicando lugar.recebido.
O Registry (N-0b) define o schema canônico. D-1a e E-1 publicam payloads conformes. Se a E-1 publicar campos extras que a D-1a não publica, a L-1 os ignora. Se faltar campo obrigatório, a validação da L-1 rejeita e o cursor avança. O contrato do Registry é a garantia de compatibilidade.
lugar_id gerado pela L-1 não é conhecido pelo cidadão no momento do POST.
O cidadão recebe rastreamento_id do BFF. A D-7 projeta a timeline de lugares por correlacao_id, que é igual ao rastreamento_id no fluxo do cidadão. O cidadão consulta a timeline e vê o lugar_id quando a L-1 processar.
Write model do lugar no BFF.
O BFF armazena apenas d1a.idempotencia_lugares (hash, rastreamento_id e payload do evento). A L-1 é a única dona do write model de lugares. Consistente com a regra de isolamento.
Referências
Seção intitulada “Referências”- Ficha técnica da colônia: Apêndice B - Colônias.md, seção “Colônia L-1 — Cadastro de Lugares”
- Schemas de eventos: N-0b - Registry.md, schemas
lugar.recebido,lugar.cadastradoelugar.georreferenciado - Barramento de eventos: N-0a - Event Bus.md
- Colônia a jusante: L-2 - Georreferenciamento e Tipificação.md
- Colônia de validação: L-3 - Validação e Qualidade.md
- Colônia com infra compartilhada: D-2 - Georreferenciamento.md (compartilha
core.uc_polygonscom a L-2) - Colônia produtora: D-1a - BFF.md
- Colônia produtora (organizações): E-1 - Cadastro Institucional.md
- Stack de referência e arquitetura do MVP: Apêndice B - Colônias.md, seção “Arquitetura do MVP — Monolito Modular”
- Mapa de dependências de eventos: Apêndice B - Colônias.md, seção “Mapa de Dependências de Eventos entre Colônias”
- Princípios do Formigueiro: Apêndice B - Colônias.md, seção “Princípios herdados do Formigueiro”
- Contexto de gestão: contexto_IA.md, seção 7 (A Gestão)
- Contexto de infraestrutura: contexto_IA.md, seção 10 (Infraestrutura cívica digital)
- Contexto do Formigueiro: contexto_IA.md, seção 22 (Arquitetura técnica — O Formigueiro)
Documento de especificação técnica de implementação.