Pular para o conteúdo

L-1 — Cadastro de Lugares

Parte das Colônias de Lugares — Fase 1


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.


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.

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 proximidade

Não há diretório entities/. Os specs ficam ao lado dos arquivos que testam.

@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();
}
}
  • 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 EventBusModule explicitamente. EventBusModule é @Global(), e o EventBusService é injetável sem import.
  • O módulo não importa RegistryModule. A validação de schema dos eventos publicados é feita pelo próprio Event Bus (N-0a) no momento da publicação. A L-1 apenas constrói o payload conforme o contrato do Registry.
  • O OnModuleInit dispara 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 compartilhada distanciaHaversineMetros, e filtra candidatos por raio, sem dependência de banco. Testável isoladamente.

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.

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.


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.

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.

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).

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()
);
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.

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.

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.

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.


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.

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'
}
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.

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.

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.

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.

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.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.

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 * c

O 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.

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.

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”

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.

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.desativado

A 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.

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.

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.

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.


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.

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.
Í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.

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 em event_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 ao core.event_log na 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.

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.

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.


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.

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.

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.


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
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.
  • Extensão PostGIS: coluna geometry GEOMETRY(POINT, 4326), índice GIST, ST_DWithin para deduplicação
  • Constraint UNIQUE em event_id com tratamento de violação no INSERT
  • Validação de subtipo contra lista de subtipos conhecidos da taxonomia de lugares
  • Scheduled job de reconciliação: varre l1.lugares com status = 'pendente_georreferenciamento' e sem evento lugar.cadastrado correspondente, republica
  • Métricas Prometheus: l1_lugares_cadastrados_total, l1_duplicatas_detectadas_total, l1_eventos_descartados_total
  • Índice GIN no payload para 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.



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