L-2 — Georreferenciamento e Tipificação
Parte das Colônias de Lugares — Fase 1
Propósito
Seção intitulada “Propósito”A L-2 resolve a questão territorial dos lugares cadastrados pela L-1: a qual unidade cívica pertencem e em qual nível? Também enriquece a tipificação cruzando o subtipo declarado pelo cidadão com fontes abertas, para verificar se ele é plausível para aquelas coordenadas.
Consome lugar.cadastrado da L-1 e produz lugar.georreferenciado. O evento de saída é consumido por três colônias: a L-1 (atualiza o próprio status do registro), a E-1 (define a associação territorial de organizações) e a L-3 (ponto de partida para validação de qualidade, na Fase 2). É análoga à D-2 do fluxo de demandas, com a responsabilidade adicional da tipificação.
Não valida se o lugar existe de fato. Não aciona validação social. Apenas resolve geometria territorial e enriquece o tipo declarado. O dado bruto original (preservado pela L-1) nunca é alterado.
A L-2 é 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-2 é um módulo NestJS com encapsulamento próprio dentro do monolito modular do MVP. Consome eventos do barramento via EventBusService (N-0a) e publica eventos 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-2-georreferenciamento-tipificacao/├── l2.module.ts # Module definition├── l2.service.ts # Lógica de negócio: resolver UC, enriquecer tipo, publicar├── l2.constants.ts # Constantes: raio OSM, TTLs, enums de confiança├── config/│ └── osm-tag-mapping.json # Mapeamento OSM tag → subtipo de lugar├── repositories/│ ├── resolucao.repository.ts # Acesso a l2.resolucoes (insert + query)│ ├── consumer-offset.repository.ts # Acesso a l2.consumer_offset (cursor de replay)│ └── core-geo.repository.ts # Leitura de core.uc_polygons com snapshot em memória└── services/ ├── point-in-polygon.service.ts # Point-in-polygon (Turf.js) com pré-filtro de bbox ├── geo-cache.service.ts # Cache de resoluções geo (in-memory) ├── osm-client.service.ts # Cliente HTTP para a Overpass API ├── osm-cache.service.ts # Cache de respostas OSM (in-memory) ├── osm-tag-mapping.ts # Carregamento do mapeamento e resolução de tags └── tipificacao.service.ts # Comparação entre subtipo declarado e OSMOs testes ficam ao lado dos arquivos testados: l2.service.spec.ts, geo-cache.service.spec.ts, osm-cache.service.spec.ts, osm-client.service.spec.ts, point-in-polygon.service.spec.ts, tipificacao.service.spec.ts e core-geo.repository.spec.ts.
1.2 Module definition
Seção intitulada “1.2 Module definition”@Module({ imports: [], controllers: [], // Colônia pura de eventos — sem REST providers: [ L2Service, ResolucaoRepository, ConsumerOffsetRepository, CoreGeoRepository, PointInPolygonService, GeoCacheService, OsmCacheService, OsmClientService, TipificacaoService, ], exports: [],})export class L2Module implements OnModuleInit { constructor(private readonly l2Service: L2Service) {}
async onModuleInit(): Promise<void> { await this.l2Service.iniciar(); }}1.3 Pontos de atenção
Seção intitulada “1.3 Pontos de atenção”- O módulo não é
@Global(). A L-2 não é dependência de nenhuma outra colônia. Outras colônias consomem seu evento (lugar.georreferenciado), 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 dopublicar(). A L-2 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 de handlers. - O módulo não registra
ThrottlerModule— rate limiting é responsabilidade exclusiva do BFF (D-1a), na borda HTTP. A L-2 processa o que recebe do barramento. - O
CoreGeoRepositoryacessacore.uc_polygonsem modo somente leitura e mantém um snapshot em memória com TTL de 6 horas. É uma simplificação de MVP (ver seção 8.2). Na Fase 2, a base de polígonos migra para uma colônia dedicada que publica eventos de sincronização.
1.4 Serviço — responsabilidades e contrato
Seção intitulada “1.4 Serviço — responsabilidades e contrato”// L2Service consome 'lugar.cadastrado', publica 'lugar.georreferenciado'iniciar(): Promise<void>;processarLugarCadastrado(evento: EventoConsultado): Promise<void>;registrarConsumidores(): void;O service não expõe interface formal. Nenhuma outra colônia injeta L2Service. 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-2 não tem BFF acoplado. É uma colônia de processamento puro: escuta, processa, publica. O input é sempre via barramento — exclusivamente lugar.cadastrado da L-1. A L-2 não sabe e não precisa saber se o lugar foi originado por um cidadão (D-1a) ou por uma organização (E-1).
1.6 Dependência de leitura do núcleo (simplificação MVP)
Seção intitulada “1.6 Dependência de leitura do núcleo (simplificação MVP)”A L-2 acessa core.uc_polygons em modo leitura para realizar point-in-polygon. Esta é uma exceção controlada à regra de isolamento, válida apenas no MVP monolito. Justificativa: a base de polígonos de UCs é um dado de infraestrutura fundamental, tão essencial quanto o Registry. Duplicá-la em cada colônia que faz point-in-polygon (D-2 e L-2) no MVP geraria complexidade de sincronização desproporcional ao benefício.
Na Fase 2, a base de polígonos migra para uma colônia dedicada de infraestrutura geoespacial que publica eventos de atualização de polígonos. D-2 e L-2 mantêm réplicas locais sincronizadas por evento. A consulta direta ao core é removida.
2. Banco de Dados — Schema e Entidades
Seção intitulada “2. Banco de Dados — Schema e Entidades”2.1 Schema l2
Seção intitulada “2.1 Schema l2”Todas as tabelas da L-2 residem no schema l2 do PostgreSQL. Este schema é de uso exclusivo do módulo L-2. Nenhuma outra colônia lê ou escreve nestas tabelas.
2.2 Tabela l2.resolucoes
Seção intitulada “2.2 Tabela l2.resolucoes”Registro do resultado do georreferenciamento e enriquecimento de tipificação para cada lugar processado. Uma linha por lugar_id.
CREATE SCHEMA IF NOT EXISTS l2;
CREATE TABLE l2.resolucoes ( id UUID NOT NULL, lugar_id UUID NOT NULL, unidade_civica_id UUID NOT NULL, nivel_minimo_resolvido INTEGER NOT NULL, cadeia_ucs UUID[], metodo_resolucao VARCHAR(20) NOT NULL, confianca_geo VARCHAR(10) NOT NULL, tipo_lugar VARCHAR(30) NOT NULL, subtipo_declarado VARCHAR(50), subtipo_confirmado VARCHAR(50), confianca_tipificacao VARCHAR(20) NOT NULL, enriquecimento_fonte VARCHAR(10), enriquecimento_dados JSONB, divergencia_tipificacao JSONB, event_id UUID NOT NULL, correlacao_id UUID NOT NULL, criado_em TIMESTAMPTZ(2) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT resolucoes_pkey PRIMARY KEY (id));
CREATE UNIQUE INDEX resolucoes_lugar_id_key ON l2.resolucoes (lugar_id);CREATE INDEX resolucoes_unidade_civica_id_idx ON l2.resolucoes (unidade_civica_id);CREATE INDEX resolucoes_event_id_idx ON l2.resolucoes (event_id);CREATE INDEX resolucoes_confianca_geo_confianca_tipificacao_idx ON l2.resolucoes (confianca_geo, confianca_tipificacao);O banco não tem CHECKs para confianca_geo, confianca_tipificacao, metodo_resolucao ou nivel_minimo_resolvido. Os valores são garantidos pelo código que preenche a linha e pelo schema do Registry na publicação.
Colunas — detalhamento
Seção intitulada “Colunas — detalhamento”| Coluna | Tipo | Descrição |
|---|---|---|
id |
UUID PK | Identificador interno da resolução. Gerado pelo Prisma. |
lugar_id |
UUID | FK lógica para l1.lugares.id. Sem constraint formal — regra de isolamento. |
unidade_civica_id |
UUID | UC de menor nível onde o ponto está contido. FK lógica para a base de UCs. |
nivel_minimo_resolvido |
INTEGER 1-7 | Nível da UC resolvida. Se não há polígono de bairro (nível 2), retorna nível 4 (município). Informa a granularidade da resolução. |
cadeia_ucs |
UUID[] | Array ordenado de UCs pai, da menor resolvida até a nacional. [uc_nivel_N, uc_nivel_N+1, ..., uc_nivel_7]. |
metodo_resolucao |
VARCHAR(20) | gps para coordenadas diretas do dispositivo, endereco para geocodificação de texto, inferencia para estimativa. No MVP, sempre gps. |
confianca_geo |
VARCHAR(10) | alta para GPS (MVP), media para endereço textual, baixa para inferência. |
tipo_lugar |
VARCHAR(30) | residencia, organizacao ou equipamento_publico. Mantido do payload de entrada. |
subtipo_declarado |
VARCHAR(50) | Subtipo original do payload de lugar.cadastrado. |
subtipo_confirmado |
VARCHAR(50) | Subtipo após enriquecimento. Igual ao declarado se confirmado, diferente se houve divergência, NULL se enriquecimento não encontrou correspondência. |
confianca_tipificacao |
VARCHAR(20) | alta (OSM confirmou subtipo), media (OSM encontrou algo próximo mas diferente), baixa (OSM não encontrou nada no raio), nao_avaliada (OSM indisponível ou tipo_lugar = 'residencia'). |
enriquecimento_fonte |
VARCHAR(10) | Fonte do enriquecimento: osm no MVP. cnpj, cnes, inep na Fase 2. NULL se enriquecimento não executado. |
enriquecimento_dados |
JSONB | Dados brutos do enriquecimento: nome, categoria, tags e distância da fonte. NULL se enriquecimento não executado ou não encontrou dados. |
divergencia_tipificacao |
JSONB | Presente quando OSM sugere tipo diferente do declarado: { "declarado": "farmacia", "osm": "supermercado", "distancia_metros": 5.2 }. NULL quando não há divergência. |
event_id |
UUID | event_id do evento lugar.cadastrado que originou esta resolução. Para rastreabilidade. |
correlacao_id |
UUID | correlacao_id do evento de origem. Para trace distribuído. Quando o evento não traz o campo, o lugar_id assume. |
criado_em |
TIMESTAMPTZ(2) | Timestamp de criação da resolução. |
2.3 Tabela l2.consumer_offset
Seção intitulada “2.3 Tabela l2.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 l2.consumer_offset ( tipo_evento VARCHAR(255) PRIMARY KEY, last_sequence BIGINT NOT NULL DEFAULT 0, updated_at TIMESTAMPTZ(2) NOT NULL DEFAULT CURRENT_TIMESTAMP);No MVP, a tabela tem uma linha: tipo_evento = 'lugar.cadastrado'. A estrutura com PK em tipo_evento permite extensão futura sem alteração de schema.
2.4 Tabela core.uc_polygons (dependência de leitura do núcleo)
Seção intitulada “2.4 Tabela core.uc_polygons (dependência de leitura do núcleo)”A L-2 consulta esta tabela do schema core em modo somente leitura para realizar point-in-polygon. É uma simplificação de MVP (ver seção 1.6 e 8.2). A tabela é populada pelos scripts de seed territorial (descritos no SDS da D-2, seção 8.5) e nenhuma colônia de negócio escreve nela.
Estrutura de referência:
CREATE TABLE core.uc_polygons ( id UUID NOT NULL, unidade_civica_id UUID NOT NULL, nivel INTEGER NOT NULL, parent_uc_id UUID, nome VARCHAR(255) NOT NULL, nome_busca VARCHAR(255) NOT NULL DEFAULT '', geometria JSONB NOT NULL, -- GeoJSON Polygon ou MultiPolygon fonte VARCHAR(50) NOT NULL, metadados JSONB NOT NULL DEFAULT '{}', populacao_estimada INTEGER, criado_em TIMESTAMPTZ(2) NOT NULL DEFAULT CURRENT_TIMESTAMP, atualizado_em TIMESTAMPTZ(2) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uc_polygons_pkey PRIMARY KEY (id));
CREATE INDEX uc_polygons_unidade_civica_id_idx ON core.uc_polygons (unidade_civica_id);CREATE INDEX uc_polygons_nivel_idx ON core.uc_polygons (nivel);As colunas fonte, metadados e populacao_estimada (migration 20260811195030_add_fonte_metadados_populacao_uc_polygons) são usadas pelos scripts de seed IBGE. populacao_estimada permanece NULL no MVP, porque nenhuma colônia consome população. A coluna nome_busca (migration 20260913120000_uc_polygons_nome_busca) mantém o nome em minúscula, sem acento e sem pontuação, por trigger, para a busca por nome na D-7.
O campo geometria armazena o polígono no formato GeoJSON ({ "type": "Polygon", "coordinates": [...] }). A escolha por JSONB em vez de PostGIS GEOMETRY é deliberada para o MVP: evita a instalação da extensão PostGIS e permite point-in-polygon via Turf.js. Na Fase 2, migra para GEOMETRY(POLYGON, 4326) com índice GIST e ST_Contains.
2.5 Migrations
Seção intitulada “2.5 Migrations”Migrations reais do schema l2 e da base territorial:
20260809112210_d2_uc_polygons_resolucoes_geo— Criacore.uc_polygons(migration única compartilhada com o núcleo e a D-2).20260811195030_add_fonte_metadados_populacao_uc_polygons— Adicionafonte,metadadosepopulacao_estimadaacore.uc_polygons.20260813182624_create_l2_tables— Cria o schemal2, a tabelal2.resolucoese a tabelal2.consumer_offset.20260913120000_uc_polygons_nome_busca— Adicionanome_buscae o trigger de normalização do nome.
Seed inicial do consumer_offset: no boot, offsetRepo.seed(['lugar.cadastrado'], obterMaiorSequence()) cria a linha com a maior sequência do log sem sobrescrever cursor existente.
Migrations futuras (Fase 2): migração de geometria JSONB para geometry GEOMETRY(POLYGON, 4326) com PostGIS, substituição do CoreGeoRepository por réplica local sincronizada via evento, índices GIST espaciais, tabela l2.enriquecimentos para múltiplas fontes e partição por UC quando o volume escalar.
2.6 Relações internas
Seção intitulada “2.6 Relações internas”Não há foreign keys entre as tabelas do schema l2. O lugar_id em l2.resolucoes referencia l1.lugares.id, mas sem FK formal — schemas de colônias distintas não podem ter FKs entre si. A integridade é garantida em aplicação: o L2Service só processa lugares cujo lugar_id foi publicado pela L-1 no barramento.
2.7 Decisões de schema
Seção intitulada “2.7 Decisões de schema”Tabela única l2.resolucoes em vez de tabelas separadas para território e tipificação.
A resolução territorial e o enriquecimento de tipificação são processados no mesmo fluxo, para o mesmo lugar_id, como uma unidade atômica de trabalho. Separar em duas tabelas criaria uma relação 1:1 desnecessária no MVP. Na Fase 2, quando o enriquecimento incluir múltiplas fontes (CNPJ, CNES, Inep) com esquemas distintos, uma tabela de enriquecimentos separada (l2.enriquecimentos) permitirá múltiplas linhas por lugar_id com fonte e dados próprios.
cadeia_ucs como UUID[] em vez de tabela de junção.
O array de UUIDs é compacto, a consulta é sempre “dado um lugar, quais UCs o contêm?” e nunca “dada uma UC, quais lugares ela contém?” (essa é responsabilidade da L-4 na Fase 2). Um array PostgreSQL é suficiente e evita uma tabela de junção. A ordem é garantida: do menor nível resolvido até o nacional.
divergencia_tipificacao como JSONB separado de enriquecimento_dados.
A divergência é um campo de negócio com semântica própria: sinaliza conflito entre o declarado e o observado. Mantê-la separada do enriquecimento bruto facilita queries de auditoria (WHERE divergencia_tipificacao IS NOT NULL) e alimenta a L-3 (Fase 2) com prioridade de validação.
confianca_geo sempre alta no MVP.
Todos os lugares chegam à L-2 com coordenadas GPS do dispositivo — a L-1 já validou lat/lng, bounding box e descartou entradas inválidas. O campo existe para forward compatibility com endereços textuais (Fase 2), quando a D-1a ou E-1 poderão aceitar endereço em texto e a L-2 precisará geocodificar antes do point-in-polygon.
3. Eventos — Contratos Detalhados
Seção intitulada “3. Eventos — Contratos Detalhados”A L-2 consome um tipo 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-2: o que espera receber, o que publica e em que ordem.
3.1 Evento consumido: lugar.cadastrado
Seção intitulada “3.1 Evento consumido: 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 (Cadastro de Lugares) |
| Consumidor | L-2 (esta colônia) |
| Descrição | Lugar validado e registrado pela L-1, pronto para georreferenciamento e enriquecimento de tipificação. |
Payload esperado (conforme o schema lugar.cadastrado do Registry N-0b):
interface LugarCadastradoPayload { lugar_id: string; // UUID v4 — gerado pela L-1 tipo_lugar: string; // 'residencia' | 'organizacao' | 'equipamento_publico' | 'poligono_uc' subtipo?: string; // ex: 'farmacia', 'ubs', 'mercado' nome?: string; descricao?: string; // campo opcional da versão 1.2.0; ignorado pela L-2 coordenadas_brutas: { lat: number; lng: number; }; alerta_proximidade?: { // presente apenas quando L-1 detectou duplicata lugar_id_proximo: string; distancia_metros: number; }; cidadao_id: string; canal: string; horario_funcionamento?: string;}3.2 Evento produzido: lugar.georreferenciado
Seção intitulada “3.2 Evento produzido: lugar.georreferenciado”| Propriedade | Valor |
|---|---|
| Tipo | lugar.georreferenciado |
| Schema version | 1.0.0 |
| Produtor | L-2 (esta colônia) |
| Consumidores | L-1 (atualização de status), E-1 (associação territorial de organizações), L-3 (validação, na Fase 2) |
| Descrição | Lugar com UC resolvida, cadeia de UCs pai completa e tipificação enriquecida com nível de confiança. |
Payload publicado (conforme o schema lugar.georreferenciado do Registry N-0b):
interface LugarGeorreferenciadoPayload { lugar_id: string; unidade_civica_id: string; // UC de menor nível resolvida nivel_minimo_resolvido: number; // 1-7 — nível da UC resolvida cadeia_ucs: string[]; // [uc_nivel_N, ..., uc_nivel_7] 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?: { fonte: string; // 'osm' no MVP dados?: Record<string, unknown>; // dados brutos da fonte divergencia?: { declarado: string; encontrado: string; distancia_metros: number; }; };}3.3 Ordem de operações no handler
Seção intitulada “3.3 Ordem de operações no handler”O fluxo em L2Service.processarLugarCadastrado() segue esta ordem exata:
1. Verificar idempotência por event_id → consultar l2.resolucoes WHERE event_id = ? → se encontrado: republicar lugar.georreferenciado e avançar o cursor
2. Validar lugar_id → ausente: log.error, cursor avança sem processar
3. Validar tipo_lugar contra TIPOS_LUGAR_VALIDOS → ausente ou fora da lista: log.error, cursor avança sem processar
4. Polígono de UC (tipo_lugar = 'poligono_uc') → log "capturado e mantido em análise", cursor avança SEM georreferenciar nem publicar (a inserção em core.uc_polygons é Fase 2)
5. Extrair coordenadas do payload → lat = payload.coordenadas_brutas.lat → lng = payload.coordenadas_brutas.lng → ausentes: log.error, cursor avança sem processar
6. Resolver território (point-in-polygon) → consultar cache geo: l2:geo:{lat}:{lng} → se cache hit: usar UC resolvida do cache → se cache miss: → carregar o snapshot de polígonos (TTL de 6 horas) → executar point-in-polygon do menor para o maior nível → ao encontrar UC contendo o ponto: parar (menor nível resolvido) → resolver cadeia de UCs pai → armazenar no cache geo → se nenhum polígono contiver o ponto: log.error, cursor avança sem processar
7. Enriquecer tipificação (pulado para tipo_lugar = 'residencia') → consultar cache OSM: l2:osm:{lat}:{lng} → se cache hit: usar resultado do cache → se cache miss: → chamar Overpass API com bounding box ao redor das coordenadas → armazenar resposta no cache OSM → mapear tags OSM para subtipos de lugar → comparar com subtipo_declarado: → match exato → confianca_tipificacao = 'alta', subtipo_confirmado = declarado → match próximo mas diferente → confianca_tipificacao = 'media', registrar divergência → sem match → confianca_tipificacao = 'baixa', subtipo_confirmado = null → se OSM indisponível (timeout/erro): → confianca_tipificacao = 'nao_avaliada', subtipo_confirmado = null
8. Persistir em l2.resolucoes
9. Publicar lugar.georreferenciado no barramento → se a publicação falhar: log.error e exceção relançada; o cursor não avança. O replay seguinte encontra a resolução pela idempotência e republica a saída.
10. Atualizar o consumer offsetPersistir antes de publicar garante que a resolução esteja registrada mesmo se o barramento falhar. Se o passo 9 falhar, a resolução fica em l2.resolucoes mas o evento lugar.georreferenciado não foi publicado. A DLQ registra a falha. No replay, a idempotência por event_id no passo 1 detecta que a resolução já existe e apenas republica o evento.
3.4 Tratamento de erro e idempotência
Seção intitulada “3.4 Tratamento de erro e idempotência”| Cenário | Comportamento |
|---|---|
| Evento reentregue (replay DLQ) | Detectado por event_id no passo 1. Registro existente encontrado. Log.info, republica lugar.georreferenciado e avança o cursor. |
lugar_id ausente |
Log.error. Evento descartado. Cursor avança. |
tipo_lugar ausente ou inválido |
Log.error. Evento descartado. Cursor avança. |
tipo_lugar = 'poligono_uc' |
Log “capturado e mantido em análise”. Cursor avança sem georreferenciar nem publicar. |
| Coordenadas ausentes no payload | Log.error. Evento descartado. Nenhum registro em l2.resolucoes. Cursor avança. A L-1 não deveria publicar lugar.cadastrado sem coordenadas. Se ocorrer, é bug na L-1 ou violação de schema. |
| Point-in-polygon não encontra UC (ponto fora de todos os polígonos) | Log.error. Ponto pode estar em área não coberta (ex: zona rural sem polígono definido). Nenhum registro em l2.resolucoes. Cursor avança. Na Fase 2, tenta resolver no nível municipal por fallback (distância ao centroide mais próximo). |
| Overpass API timeout (5s) | Log.warn. Enriquecimento marcado como nao_avaliada. Resolução territorial prossegue normalmente. O lugar é georreferenciado sem enriquecimento. |
| Overpass API retorna erro 5xx ou 429 | Log.warn. Idem timeout. Sem retry — o enriquecimento é melhoria, não bloqueio. A L-3 (Fase 2) tentará novamente. |
| Overpass API retorna resposta vazia (sem elementos no raio) | Log.info. confianca_tipificacao = 'baixa'. Nenhum erro — simplesmente não há dado OSM naquelas coordenadas. |
INSERT falha (violação de unique em lugar_id) |
Log.error. Indica que o mesmo lugar_id foi processado duas vezes com event_id diferente — cenário impossível no MVP (a L-1 gera lugar_id único). Se ocorrer, o segundo INSERT falha e o erro é registrado na DLQ. |
Publish de lugar.georreferenciado falha |
Log.error. Registro existe em l2.resolucoes. DLQ registrada. O handler de replay republica o evento ao encontrar o registro existente. |
| Caches em memória perdidos em reinicialização | Degradação graciosa: point-in-polygon repete e Overpass é consultada novamente até o cache repopular. Performance reduzida temporariamente, funcionalidade preservada. |
3.5 Idempotência na reentrega — republicação do evento de saída
Seção intitulada “3.5 Idempotência na reentrega — republicação do evento de saída”Diferente da L-1, que apenas ignora eventos reentregues, a L-2 republica lugar.georreferenciado ao detectar um event_id já processado. Justificativa: o consumidor principal do evento é a E-1 (associação territorial de organizações). Se a E-1 estava fora do ar quando a L-2 publicou o evento pela primeira vez, a republicação no replay garante que a E-1 receba o dado. A L-1, que também consome este evento para atualizar status, é idempotente: se o status já for georreferenciado, apenas loga e retorna.
função processarLugarCadastrado(evento: EventoConsultado):
existente = resolucaoRepo.buscarPorEventId(evento.event_id) se existente não é null: logger.log("Evento já processado — republicando lugar.georreferenciado", { event_id: evento.event_id, lugar_id: existente.lugar_id, }) await this.publicarLugarGeorreferenciado(existente, evento.correlacao_id) await offsetRepo.upsert('lugar.cadastrado', evento.sequence_number) retornar
// ... processamento normal ...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, do BFF e da L-1: o estado próprio é a memória da colônia. Se o barramento falhar após o INSERT, a resolução está salva e recuperável.
Republicar evento de saída no replay.
A L-2 é um elo crítico na cadeia de lugares — sem lugar.georreferenciado, a E-1 não consegue associar organizações a UCs. A republicação no replay garante entrega at-least-once para consumidores que possam ter perdido o evento original. A L-1 e a E-1 devem ser idempotentes no consumo.
Enriquecimento OSM como melhoria, não bloqueio.
Se a Overpass API estiver indisponível, o lugar é georreferenciado com confianca_tipificacao = 'nao_avaliada'. A resolução territorial (point-in-polygon) é local e determinística — nunca depende de serviço externo. O enriquecimento agrega valor, mas sua ausência não interrompe o pipeline.
Snapshot de polígonos com TTL de 6 horas.
A lista de polígonos fica em memória no CoreGeoRepository com carregadoEm, e cada polígono leva o próprio bounding box. A recarga automática após 6 horas dispensa reiniciar a API depois de um novo seed IBGE. O pré-filtro do PointInPolygonService descarta pelo bbox antes de chamar o Turf.
Cache de duas camadas em memória: geo + OSM.
O cache geo (l2:geo:*) evita point-in-polygon repetido para coordenadas idênticas ou muito próximas. O cache OSM (l2:osm:*) evita chamadas repetidas à Overpass API para a mesma região. Ambos são Map em memória no MVP. Redis (compartilhado com D-2 e D-4) entra na Fase 2, com múltiplas instâncias do monolito.
4. Lógica de Negócio — Algoritmos e Fluxos
Seção intitulada “4. Lógica de Negócio — Algoritmos e Fluxos”4.1 L2Service.processarLugarCadastrado() — pseudocódigo
Seção intitulada “4.1 L2Service.processarLugarCadastrado() — pseudocódigo”função processarLugarCadastrado(evento: EventoConsultado):
eventId = evento.event_id sequencia = evento.sequence_number payload = evento.payload
// 1. Idempotência por event_id (com republicação) existente = resolucaoRepo.buscarPorEventId(eventId) se existente não é null: logger.log("Evento já processado — republicando lugar.georreferenciado", { event_id: eventId, lugar_id: existente.lugar_id, }) await this.publicarLugarGeorreferenciado(existente, evento.correlacao_id) await offsetRepo.upsert('lugar.cadastrado', sequencia) retornar
// 2. Validar lugar_id lugarId = extrairTexto(payload.lugar_id) se lugarId é null: logger.error("lugar_id ausente em lugar.cadastrado — descartando") await offsetRepo.upsert('lugar.cadastrado', sequencia) retornar
// 3. Validar tipo_lugar tipoLugar = extrairTexto(payload.tipo_lugar) se tipoLugar é null ou não está em TIPOS_LUGAR_VALIDOS: logger.error("tipo_lugar inválido em lugar.cadastrado — descartando") await offsetRepo.upsert('lugar.cadastrado', sequencia) retornar
// 4. Polígono de UC: capturado e mantido em análise (Fase 2) se tipoLugar == 'poligono_uc': logger.log("Lugar do tipo poligono_uc capturado e mantido em análise") await offsetRepo.upsert('lugar.cadastrado', sequencia) retornar
// 5. Extrair coordenadas coordenadas = extrairCoordenadas(payload.coordenadas_brutas) se coordenadas é null: logger.error("Coordenadas ausentes em lugar.cadastrado — descartando") await offsetRepo.upsert('lugar.cadastrado', sequencia) retornar
lat = coordenadas.lat lng = coordenadas.lng
// 6. Resolver território resultado = geoCache.obter(lat, lng) se resultado é null: poligonos = await coreGeoRepo.buscarTodosOrdenadosPorNivel() resultado = pointInPolygonService.resolverTerritorio(lat, lng, poligonos) se resultado é null: logger.error("Point-in-polygon falhou — ponto fora de todos os polígonos") await offsetRepo.upsert('lugar.cadastrado', sequencia) retornar geoCache.definir(lat, lng, resultado)
// 7. Enriquecer tipificação subtipoDeclarado = extrairTexto(payload.subtipo) tipificacao = await tipificacaoService.enriquecer(lat, lng, tipoLugar, subtipoDeclarado)
// 8. Persistir resolucao = await resolucaoRepo.inserir({ lugar_id: lugarId, unidade_civica_id: resultado.unidade_civica_id, nivel_minimo_resolvido: resultado.nivel_minimo_resolvido, cadeia_ucs: resultado.cadeia_ucs, metodo_resolucao: 'gps', confianca_geo: 'alta', tipo_lugar: tipoLugar, subtipo_declarado: subtipoDeclarado, subtipo_confirmado: tipificacao.subtipo_confirmado, confianca_tipificacao: tipificacao.confianca, enriquecimento_fonte: tipificacao.fonte, enriquecimento_dados: tipificacao.dados, divergencia_tipificacao: tipificacao.divergencia, event_id: eventId, correlacao_id: evento.correlacao_id ?? lugarId, })
// 9. Publicar await this.publicarLugarGeorreferenciado(resolucao, evento.correlacao_id)
// 10. Atualizar consumer offset await offsetRepo.upsert('lugar.cadastrado', sequencia)4.2 resolverTerritorio() — point-in-polygon com cache
Seção intitulada “4.2 resolverTerritorio() — point-in-polygon com cache”A busca no cache e o point-in-polygon vivem em pontos distintos do módulo. O L2Service consulta o GeoCacheService e, no miss, carrega o snapshot pelo CoreGeoRepository e delega o cálculo ao PointInPolygonService.
função no L2Service (trecho da resolução):
cacheHit = geoCache.obter(lat, lng) se cacheHit não é null: resultado = cacheHit senão: poligonos = await coreGeoRepo.buscarTodosOrdenadosPorNivel() resultado = pointInPolygonService.resolverTerritorio(lat, lng, poligonos) se resultado é null: // seção 3.3, passo 6: descarte com log.error geoCache.definir(lat, lng, resultado)
função resolverTerritorio(lat, lng, poligonos) -> ResultadoTerritorio | null:
ponto = turf.point([lng, lat]) // Turf.js usa [lng, lat]
para cada poligono em poligonos: se pontoDentroDoBbox(poligono.bbox, lat, lng) é falso: continuar tentar: contido = turf.booleanPointInPolygon(ponto, poligono.geometria) se contido: return { unidade_civica_id: poligono.unidade_civica_id, nivel_minimo_resolvido: poligono.nivel, cadeia_ucs: resolverCadeiaUcs(poligono, poligonos), } capturar erro: logger.warn("Polígono inválido ignorado: uc_id=...")
return nullAlgoritmo de point-in-polygon:
A implementação usa @turf/boolean-point-in-polygon (Turf.js). O algoritmo interno é ray casting: traça uma semirreta do ponto ao infinito e conta interseções com as arestas do polígono. Número ímpar de interseções = ponto dentro. A complexidade é O(v) por polígono, onde v é o número de vértices.
Cache de coordenadas com arredondamento: Coordenadas são arredondadas para 5 casas decimais (~1.1 metros de precisão no Equador). Dois lugares na mesma calçada produzem a mesma chave de cache. O TTL de 30 dias reflete a estabilidade dos polígonos de UC — eles só mudam quando há recorte territorial oficial (IBGE), o que ocorre a cada censo decenal.
Ordem de avaliação dos níveis:
Os polígonos são carregados ordenados por nível (1 a 7). O algoritmo para no primeiro match — o nível mais granular que contém o ponto. Se não houver polígonos de níveis 1-3 (comum no MVP), o primeiro match será nível 4 (município). O campo nivel_minimo_resolvido informa ao consumidor qual granularidade foi alcançada.
4.3 resolverCadeiaUcs() — subida na hierarquia
Seção intitulada “4.3 resolverCadeiaUcs() — subida na hierarquia”A cadeia é resolvida na lista de polígonos já carregada, sem consulta por nível.
função resolverCadeiaUcs(uc: UcPolygon, todosPoligonos: UcPolygon[]) -> UUID[]:
cadeia = [uc.unidade_civica_id] parentId = uc.parent_uc_id
enquanto parentId não é null: pai = todosPoligonos.find(p => p.unidade_civica_id === parentId) se pai é undefined: logger.warn("Hierarquia de UCs quebrada — parent_uc_id sem correspondência: ...") break cadeia.push(pai.unidade_civica_id) parentId = pai.parent_uc_id
return cadeiaA cadeia é construída subindo de pais até o nível 7 (nacional). O resultado é um array ordenado do menor nível resolvido até o nacional: [uc_nivel_4, uc_nivel_5, uc_nivel_6, uc_nivel_7].
4.4 TipificacaoService.enriquecer() — OSM com cache
Seção intitulada “4.4 TipificacaoService.enriquecer() — OSM com cache”O enriquecimento vive no TipificacaoService. O service do lugar só chama tipificacaoService.enriquecer(lat, lng, tipoLugar, subtipoDeclarado).
função enriquecer(lat, lng, tipoLugar, subtipoDeclarado) -> ResultadoTipificacao:
// Residências não passam por enriquecimento de tipificação se tipoLugar == 'residencia': return resultadoSemEnriquecimento('nao_avaliada')
// 1. Verificar cache OSM elementosOsm = osmCache.obter(lat, lng) se elementosOsm é null: elementosOsm = await osmClient.consultar(lat, lng) se elementosOsm não é null: osmCache.definir(lat, lng, elementosOsm)
// 2. OSM indisponível se elementosOsm é null: return resultadoSemEnriquecimento('nao_avaliada')
// 3. OSM respondeu sem elementos se elementosOsm.length == 0: return { confianca: 'baixa', subtipo_confirmado: null, fonte: 'osm', dados: { elementos_encontrados: 0, raio_consulta: 50 }, divergencia: null, }
// 4. Mapear o elemento mais próximo maisProximo = elementosOsm[0] // o cliente ordena por distância subtipoOsm = mapearTagOsmParaSubtipo(maisProximo.tags, carregarMapeamentoOsm())
// 5. Comparar com o subtipo declarado se subtipoDeclarado é null: return { confianca: 'media', subtipo_confirmado: subtipoOsm, fonte: 'osm', dados: dadosDoElemento(maisProximo), divergencia: null }
se subtipoOsm == subtipoDeclarado: return { confianca: 'alta', subtipo_confirmado: subtipoDeclarado, fonte: 'osm', dados: dadosDoElemento(maisProximo), divergencia: null }
// Match diferente: registra divergência e mantém o declarado return { confianca: 'media', subtipo_confirmado: subtipoDeclarado, fonte: 'osm', dados: dadosDoElemento(maisProximo), divergencia: { declarado: subtipoDeclarado, encontrado: subtipoOsm, distancia_metros: maisProximo.distancia, }, }dadosDoElemento monta { nome_osm, categoria_osm, distancia_metros, tags }, com categoria_osm vinda de amenity, shop ou office.
4.5 consultarOverpass() — cliente HTTP para Overpass API
Seção intitulada “4.5 consultarOverpass() — cliente HTTP para Overpass API”função consultar(lat, lng) no OsmClientService -> ElementoOsm[] | null:
raio = 50 metros query = Overpass com nodes e ways que tenham name nas chaves amenity, shop, office, leisure e tourism, around:raio,lat,lng, com [out:json][timeout:5] e out body center qt
tentar: resposta = await fetch('https://overpass-api.de/api/interpreter', { method: 'POST', body: query, headers: { 'Content-Type': 'text/plain' }, signal: AbortSignal.timeout(5000), })
se resposta não é ok: logger.warn("Overpass API retornou status não-200") return null
dados = await resposta.json()
para cada elemento em dados.elements: se elemento.tags.name existe: elementoLat = elemento.lat ?? elemento.center.lat elementoLng = elemento.lon ?? elemento.center.lon distancia = haversine(lat, lng, elementoLat, elementoLng) elementos.push({ osm_id, tipo, lat, lng, distancia, tags })
elementos.sort(por distancia ASC) return elementos
capturar erro: logger.warn("Overpass API indisponível ou timeout") return null4.6 mapearTagOsmParaSubtipo() — mapeamento configurável
Seção intitulada “4.6 mapearTagOsmParaSubtipo() — mapeamento configurável”função mapearTagOsmParaSubtipo(tags, mapeamento) -> string | null:
// Prioridade: amenity > shop > office > leisure > tourism para cada chaveOsm em ['amenity', 'shop', 'office', 'leisure', 'tourism']: valorOsm = tags[chaveOsm] se valorOsm existe e mapeamento[chaveOsm][valorOsm] existe: return mapeamento[chaveOsm][valorOsm]
return nullO mapeamento é carregado na primeira chamada de config/osm-tag-mapping.json, relativo ao módulo, e permanece em memória de processo. Falha na leitura cai no MAPEAMENTO_PADRAO embutido em osm-tag-mapping.ts, com aviso no log.
Mapeamento OSM → subtipo padrão (MVP):
| Tag OSM | Subtipo |
|---|---|
amenity=pharmacy |
farmacia |
amenity=hospital |
hospital |
amenity=clinic |
ubs |
amenity=doctors |
consultorio |
amenity=dentist |
consultorio_odontologico |
amenity=school |
escola |
amenity=university |
universidade |
amenity=library |
biblioteca |
amenity=marketplace |
mercado |
amenity=police |
delegacia |
amenity=fire_station |
bombeiros |
amenity=townhall |
prefeitura |
amenity=courthouse |
forum |
amenity=post_office |
correios |
amenity=bank |
banco |
amenity=restaurant |
restaurante |
amenity=cafe |
cafeteria |
amenity=bar |
bar |
amenity=place_of_worship |
templo_religioso |
amenity=community_centre |
centro_comunitario |
amenity=parking |
estacionamento |
shop=supermarket |
supermercado |
shop=convenience |
mercado |
shop=bakery |
padaria |
shop=hardware |
loja_construcao |
shop=clothes |
loja_roupas |
office=government |
orgao_publico |
leisure=park |
parque |
leisure=sports_centre |
centro_esportivo |
leisure=fitness_centre |
academia |
tourism=hotel |
hotel |
tourism=museum |
museu |
A L-2 não define política de categorização. O mapeamento é um parâmetro técnico, auditável e substituível sem recompilação.
4.7 L2Service.iniciar() — protocolo de inicialização
Seção intitulada “4.7 L2Service.iniciar() — protocolo de inicialização”função iniciar(): se já iniciado: retornar
// 1. Iniciar os caches geoCache.iniciar() osmCache.iniciar()
// 2. Seed do cursor na maior sequência do log (sem sobrescrever) maiorSequence = await eventBus.obterMaiorSequence() await offsetRepo.seed(['lugar.cadastrado'], maiorSequence)
// 3. Replay de eventos perdidos durante inatividade await this.reprocessarEventosPerdidos() // lê o cursor em consumer_offset, chama replayDeSequence(cursor, ['lugar.cadastrado']) // e despacha cada evento pelo handler; falha por evento é logada e o evento // permanece pendente para o próximo boot
// 4. Registrar handler para eventos futuros this.registrarConsumidores() // eventBus.inscrever('lugar.cadastrado', 'L-2', this.processarLugarCadastrado.bind(this))
logger.log("L-2 Georreferenciamento e Tipificação inicializada")4.8 Casos de borda
Seção intitulada “4.8 Casos de borda”| Caso | Comportamento |
|---|---|
| Lugar em coordenadas oceânicas ou de fronteira (fora de todos os polígonos) | resolverTerritorio() retorna null. Evento descartado com log.error. Cursor avança. Na Fase 2, fallback para UC do centróide mais próximo. |
| Polígono com geometria inválida (self-intersection, anel externo não fechado) | Turf.js lança erro. Capturado, log.warn com uc_id do polígono problemático. O polígono é pulado — a iteração continua no próximo. Se nenhum polígono válido contiver o ponto, evento descartado. |
Hierarquia de UCs quebrada (parent_uc_id apontando para UC inexistente) |
resolverCadeiaUcs() para ao encontrar parent_uc_id sem correspondência. A cadeia retornada é parcial (do nível encontrado até onde a hierarquia existe). Log.warn com os IDs ausentes. |
Lugar com subtipo que não existe no mapeamento OSM |
mapearTagOsmParaSubtipo() retorna null. Mesmo que OSM encontre elemento, a comparação falha (null ≠ declarado). confianca_tipificacao = 'media' com divergência registrada. |
Lugar com tipo_lugar = 'organizacao' mas subtipo = null |
Enriquecimento OSM prossegue. Se encontrar elemento, subtipo_confirmado é preenchido com o mapeamento OSM e confianca = 'media'. Se não encontrar, confianca = 'baixa'. |
| Overpass API retorna elemento a 45 metros (dentro do raio de 50m) mas em outra rua | O elemento é incluído na lista. Se for o mais próximo, é usado para comparação. A distância é registrada. A L-3 (Fase 2) pode usar a distância como fator adicional de confiança. |
Dois handlers concorrentes processando o mesmo event_id |
Impossível no MVP monolito (single-threaded event loop). O unique de l2.resolucoes.lugar_id e a idempotência por event_id cobrem a reentrega. |
| Reinicialização — caches em memória vazios | geoCache.iniciar() e osmCache.iniciar() criam Maps vazios. Caches repopulam com novas resoluções. Funcionalidade preservada. |
4.9 Decisões de design com justificativa
Seção intitulada “4.9 Decisões de design com justificativa”Point-in-polygon com Turf.js, não PostGIS.
Turf.js é uma biblioteca JavaScript madura para operações geoespaciais. booleanPointInPolygon é ray casting padrão, testado em produção. A alternativa PostGIS exigiria extensão no PostgreSQL, migração do campo geometria para GEOMETRY e queries SQL com ST_Contains. Para o volume do MVP (< 100 lugares/dia), a diferença de performance é irrelevante. Turf.js elimina a dependência da extensão PostGIS no MVP. A migração para PostGIS na Fase 2 é documentada e o contrato da função resolverTerritorio() permanece idêntico.
Cache geo com arredondamento de 5 casas decimais. 5 casas decimais correspondem a ~1.1 metros no Equador. Dois lugares na mesma fachada de um prédio compartilham a mesma chave de cache. A probabilidade de colisão entre lugares diferentes (ex: dois lados da mesma rua) é baixa — e mesmo se ocorrer, o cache armazena a UC, que é a mesma para ambos os lados da rua. O pior caso (falso positivo de cache) é inócuo: retorna a UC correta.
Enriquecimento apenas para organizações e equipamentos públicos.
Residências (tipo_lugar = 'residencia') não têm correspondência significativa no OSM. Consultar a Overpass API para cada casa cadastrada seria desperdício de recursos e não produziria dados úteis. O handler pula o enriquecimento para este tipo, retornando confianca_tipificacao = 'nao_avaliada'.
Mapeamento OSM → subtipo como configuração externa, não hardcoded. O mapeamento entre tags OSM e subtipos de lugar é uma decisão de domínio, não de implementação. Carregá-lo de um arquivo de configuração permite ajuste sem recompilação e mantém a L-2 como aplicadora de regras, não definidora de política.
Timeout de 5 segundos na Overpass API. A Overpass API pública tem latência típica de 500ms-2s para queries simples. Um timeout de 5s cobre o caso normal com margem para picos. Se a API não responder em 5s, o enriquecimento é abortado — o lugar é georreferenciado sem tipificação. O timeout não é configurável no MVP para manter simplicidade; variável de ambiente na Fase 2.
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 de eventos via EventBusService
Seção intitulada “5.1 Consumo de eventos via EventBusService”A L-2 injeta EventBusService (do módulo @Global() N-0a) para três operações: inscrever() (registrar handler para lugar.cadastrado), publicar() (publicar lugar.georreferenciado) e replayDeSequence() (replay na inicialização).
@Injectable()export class L2Service { private readonly logger = new Logger(L2Service.name);
constructor( private readonly eventBus: EventBusService, private readonly resolucaoRepo: ResolucaoRepository, private readonly offsetRepo: ConsumerOffsetRepository, private readonly coreGeoRepo: CoreGeoRepository, private readonly pointInPolygonService: PointInPolygonService, private readonly geoCache: GeoCacheService, private readonly osmCache: OsmCacheService, private readonly tipificacaoService: TipificacaoService, ) {}}O logging usa o Logger do NestJS. A L-2 não injeta ObservabilityService nem MetricsService da N-0c. Nenhuma dependência além do núcleo e do CoreGeoRepository (simplificação MVP — ver seção 1.6).
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] → lugar.cadastrado → [L-2] — esta colônia → lugar.georreferenciado → [L-1] (atualiza status para 'georreferenciado') → [E-1] (associação territorial da organização, se tipo_lugar = 'organizacao') → [L-3] (validação de qualidade)
E-1 (cadastro de organização) → lugar.recebido (barramento, publicado pela E-1) → [L-1] → lugar.cadastrado → [L-2] — esta colônia → lugar.georreferenciado → [L-1] (atualiza status) → [E-1] → empresa.associação_territorial_definidaA L-2 é agnóstica em relação à origem do lugar. O mesmo handler processa lugar.cadastrado independentemente de o lugar ter sido originado por um cidadão (D-1a) ou por uma organização (E-1). A E-1, ao consumir lugar.georreferenciado, casa o lugar_id com a organização que o publicou e define a associação territorial.
5.3 Publicação de lugar.georreferenciado
Seção intitulada “5.3 Publicação de lugar.georreferenciado”private async publicarLugarGeorreferenciado( resolucao: ResolucaoRegistro, correlacaoId: string | null,): Promise<void> { const payload: Record<string, unknown> = { lugar_id: resolucao.lugar_id, unidade_civica_id: resolucao.unidade_civica_id, nivel_minimo_resolvido: resolucao.nivel_minimo_resolvido, cadeia_ucs: resolucao.cadeia_ucs, metodo_resolucao: resolucao.metodo_resolucao, confianca_geo: resolucao.confianca_geo, tipo_lugar: resolucao.tipo_lugar, confianca_tipificacao: resolucao.confianca_tipificacao, }; if (resolucao.subtipo_declarado !== null) { payload.subtipo_declarado = resolucao.subtipo_declarado; } if (resolucao.subtipo_confirmado !== null) { payload.subtipo_confirmado = resolucao.subtipo_confirmado; } if (resolucao.enriquecimento_fonte !== null) { const enriquecimento: Record<string, unknown> = { fonte: resolucao.enriquecimento_fonte, }; if (resolucao.enriquecimento_dados !== null) { enriquecimento.dados = resolucao.enriquecimento_dados; } if (resolucao.divergencia_tipificacao !== null) { enriquecimento.divergencia = resolucao.divergencia_tipificacao; } payload.enriquecimento = enriquecimento; }
try { await publicarComRetry(this.eventBus, { tipo: 'lugar.georreferenciado', versao_schema: '1.0.0', origem: 'L-2', event_id: uuidv4(), correlacao_id: correlacaoId ?? resolucao.lugar_id, payload, }); } catch (erro: unknown) { this.logger.error('Falha ao publicar lugar.georreferenciado', { lugar_id: resolucao.lugar_id, unidade_civica_id: resolucao.unidade_civica_id, erro: erro instanceof Error ? erro.message : String(erro), }); throw erro; }}A publicação usa publicarComRetry (4 tentativas com backoff de 500, 1000 e 2000 ms). A falha definitiva relança a exceção: o registro já existe em l2.resolucoes e a DLQ registra a falha do handler.
5.4 Chamadas síncronas via BFF
Seção intitulada “5.4 Chamadas síncronas via BFF”A L-2 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-2 não consome projeções de leitura de outras colônias. Os dados externos que acessa são:
core.event_logviaEventBusService.replayDeSequence()— dependência do núcleo, permitida.core.uc_polygonsviaCoreGeoRepository— simplificação MVP, será removida na Fase 2 (ver seção 1.6).- Overpass API (serviço externo público, não é colônia).
5.6 Caches em memória (sem Redis)
Seção intitulada “5.6 Caches em memória (sem Redis)”A L-2 usa Map em memória para duas famílias de cache:
| Chave | Conteúdo | TTL |
|---|---|---|
l2:geo:{lat}:{lng} |
{ unidade_civica_id, nivel_minimo_resolvido, cadeia_ucs } |
30 dias (limpeza a cada hora por setInterval) |
l2:osm:{lat}:{lng} |
Array de elementos OSM | 7 dias (limpeza a cada hora por setInterval) |
A chave geo usa 5 casas decimais e a chave OSM usa 4 casas. O snapshot de polígonos do CoreGeoRepository é um terceiro cache em memória, com TTL de 6 horas e recarga automática na primeira consulta após o vencimento.
Sem Redis no MVP — os caches não sobrevivem a reinicializações e são repopulados por novas resoluções. Redis (compartilhado com D-2 e D-4, isolamento por prefixo de chave) entra na Fase 2, com múltiplas instâncias do monolito.
6. Performance e Limites
Seção intitulada “6. Performance e Limites”6.1 Rate limiting
Seção intitulada “6.1 Rate limiting”A L-2 não aplica rate limiting. O volume de eventos que chega à L-2 é limitado indiretamente pelo rate limiting do BFF (D-1a) e pela frequência de cadastro de organizações na E-1. O barramento entrega o que recebe, e a L-2 processa.
Rate limiting de API externa: A Overpass API pública tem limite implícito (fair use policy). O cache OSM (TTL 7 dias por coordenada) reduz drasticamente as chamadas: uma vez consultada uma coordenada, lugares próximos usam o cache. O volume esperado no MVP (< 100 lugares/dia, muitos na mesma região) resulta em < 20 chamadas/dia à Overpass API — muito abaixo de qualquer limite.
6.2 Cotas e limites de tamanho
Seção intitulada “6.2 Cotas e limites de tamanho”| Limite | Valor | Justificativa |
|---|---|---|
| Raio de consulta OSM | 50 metros | Suficiente para encontrar o estabelecimento na mesma calçada ou quarteirão. Uma farmácia a 200m não é a mesma. |
| Timeout Overpass API | 5 segundos | Latência típica 500ms-2s. Timeout de 5s cobre picos sem bloquear o pipeline. |
| Cache geo TTL | 30 dias (2.592.000s) | Polígonos de UC mudam a cada censo (~10 anos). 30 dias é conservador. |
| Cache OSM TTL | 7 dias (604.800s) | Estabelecimentos podem mudar, mas não diariamente. 7 dias equilibra frescor e economia de chamadas. |
| Arredondamento cache geo | 5 casas decimais | ~1.1m de precisão. Suficiente para agrupar lugares na mesma fachada. |
| Arredondamento cache OSM | 4 casas decimais | ~11m de precisão. A Overpass API retorna elementos em raio de 50m — arredondar para 11m agrupa consultas na mesma quadra. |
| Tamanho máximo da resposta OSM | 100 elementos | Limitado pela própria Overpass API (padrão). A query da L-2 não usa out body sem limite. |
| Snapshot de polígonos TTL | 6 horas (21.600.000ms) | Recarga automática após novo seed, sem reiniciar a API. |
| Polígonos carregados em memória | ~5.570 municípios + UFs + nacional | No pior caso (sem índices de níveis 1-3), ~5.600 polígonos. Cada polígono GeoJSON tem em média 200 vértices (~3 KB). Total: ~17 MB em memória. Carregado uma vez a cada 6 horas. |
6.3 Índices e padrões de query
Seção intitulada “6.3 Índices e padrões de query”| Índice | Query atendida |
|---|---|
resolucoes_lugar_id_key (lugar_id, UNIQUE) |
Idempotência de negócio (lugar já processado com outro event_id?). Acesso por lugar_id. |
resolucoes_event_id_idx (event_id) |
Idempotência por event_id — toda invocação do handler. |
resolucoes_unidade_civica_id_idx (unidade_civica_id) |
“Lugares nesta UC” (dashboard L-4, Fase 2). |
resolucoes_confianca_geo_confianca_tipificacao_idx (confianca_geo, confianca_tipificacao) |
Priorização da L-3 (Fase 2): lugares com baixa confiança primeiro. |
uc_polygons_unidade_civica_id_idx (unidade_civica_id) |
Buscas por UC no snapshot e na D-7. |
uc_polygons_nivel_idx (nivel) |
resolverTerritorio(): carregar polígonos ordenados por nível. |
6.4 Padrões de query esperados
Seção intitulada “6.4 Padrões de query esperados”O padrão de acesso dominante é INSERT (resolução de lugar), com SELECT apenas para idempotência e cache:
buscarPorEventId(): 1 query por evento recebido (idempotência). Coberta pelo índice emevent_id.buscarTodosOrdenadosPorNivel(): 1 query a cada 6 horas (snapshot de polígonos), não por evento. Coberta pelo índice emnivel.inserir(): 1 insert por lugar resolvido.- Cadeia de UCs: resolvida em memória sobre o snapshot, sem query por nível.
Volume esperado no MVP: < 100 lugares/dia. Tempo médio de processamento por lugar: < 200ms (point-in-polygon em memória + cache). Com OSM cache miss: < 2s adicionais (chamada à Overpass API). Com OSM cache hit: < 5ms adicional.
6.5 Estratégia de cache
Seção intitulada “6.5 Estratégia de cache”Cache geo (point-in-polygon):
Map<string, ResultadoTerritorio>com TTL de 30 dias, gerenciado porsetIntervalde limpeza a cada hora.
Cache OSM (enriquecimento):
Map<string, ElementoOsm[]>com TTL de 7 dias, gerenciado porsetIntervalde limpeza a cada hora.
Snapshot de polígonos (em memória):
- Os polígonos de
core.uc_polygonssão carregados peloCoreGeoRepositoryordenados por nível e mantidos em memória com TTL de 6 horas. Após o vencimento, a próxima consulta recarrega o snapshot. O métodoinvalidarCache()permite recarga manual. Não há dependência de reimplantação para atualizar a base.
Justificativa para caches em memória (sem Redis no MVP):
O monolito roda em processo único — o Map é mais rápido que Redis (zero latência de rede) e elimina uma dependência de infraestrutura. A perda de cache em reinicializações resulta em mais chamadas à Overpass API e mais point-in-polygon repetidos temporariamente, mas a funcionalidade principal (georreferenciamento) não é afetada. Redis entra na Fase 2 quando houver múltiplas instâncias da API.
6.6 Projeção de volume
Seção intitulada “6.6 Projeção de volume”| Cenário | Lugares/dia | Point-in-polygon/dia | Chamadas OSM/dia (cache miss) |
|---|---|---|---|
| PoC (1 bairro, 10 entusiastas) | ~20 | ~20 (~5 únicos por cache geo) | ~5 (mesma região, cache OSM cobre) |
| MVP (1 município, centenas de usuários) | ~100 | ~100 (~30 únicos) | ~15 |
| Fase 2 (regional) | ~10.000 | ~10.000 (~2.000 únicos) | ~500 |
O cache reduz as operações mais caras (point-in-polygon e chamada OSM) em 70-85% no MVP, assumindo concentração geográfica típica de lugares cadastrados (bairros comerciais, corredores de serviços).
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”Teste unitário do L2Service:
beforeEach(async () => { const module = await Test.createTestingModule({ providers: [ L2Service, { provide: EventBusService, useValue: mockEventBus }, { provide: ResolucaoRepository, useValue: mockResolucaoRepo }, { provide: ConsumerOffsetRepository, useValue: mockOffsetRepo }, { provide: CoreGeoRepository, useValue: mockCoreGeoRepo }, PointInPolygonService, GeoCacheService, OsmCacheService, { provide: TipificacaoService, useValue: mockTipificacao }, ], }).compile();
service = module.get(L2Service); geoCache = module.get(GeoCacheService); osmCache = module.get(OsmCacheService);
mockEventBus.inscrever = jest.fn(); mockEventBus.publicar = jest.fn(); mockEventBus.replayDeSequence.mockResolvedValue([]); mockEventBus.obterMaiorSequence.mockResolvedValue(41);
mockResolucaoRepo.buscarPorEventId.mockResolvedValue(null); mockResolucaoRepo.inserir.mockImplementation((dados) => Promise.resolve({ id: 'uuid', ...dados, criado_em: new Date().toISOString() }), );
mockOffsetRepo.seed.mockResolvedValue(undefined); mockOffsetRepo.buscarTodos.mockResolvedValue([]); mockOffsetRepo.upsert.mockResolvedValue(undefined);
mockCoreGeoRepo.buscarTodosOrdenadosPorNivel.mockResolvedValue([ // polígonos de exemplo com bbox calculado ]);
mockTipificacao.enriquecer.mockResolvedValue({ confianca: 'alta', subtipo_confirmado: 'farmacia', fonte: 'osm', dados: { nome_osm: 'Farmácia São João', distancia_metros: 5.2 }, divergencia: null, });});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 | processarLugarCadastrado() com payload completo, UC resolvida, OSM confirma subtipo |
INSERT em l2.resolucoes com confianca_geo = 'alta', confianca_tipificacao = 'alta'. publicar() chamado com tipo lugar.georreferenciado e versao_schema = '1.0.0'. Payload contém cadeia_ucs com array de 4 elementos. Consumer offset atualizado. |
| T2 | iniciar() sem eventos perdidos |
seed() chamado com obterMaiorSequence(). replayDeSequence() chamado com o cursor. inscrever() registrado para lugar.cadastrado. |
| T3 | iniciar() com eventos perdidos |
replayDeSequence() retorna 3 eventos. processarLugarCadastrado() chamado 3 vezes. Cache geo e OSM inicializados. |
| T4 | Payload sem subtipo (nulo) |
Enriquecimento OSM prossegue. subtipo_confirmado preenchido a partir do OSM. confianca_tipificacao = 'media' (sem declaração para confirmar). |
| T5 | tipo_lugar = 'residencia' |
Enriquecimento de tipificação pulado. confianca_tipificacao = 'nao_avaliada'. Resolução territorial prossegue normalmente. |
Cache:
| # | Cenário | Verificação |
|---|---|---|
| T6 | Cache geo hit — coordenadas já resolvidas | geoCache.obter() retorna resultado. Nenhum point-in-polygon executado. CoreGeoRepository não é chamado. |
| T7 | Cache OSM hit — região já consultada | osmCache.obter() retorna elementos. Nenhuma chamada à Overpass API. TipificacaoService usa o cache. |
Falhas e bordas:
| # | Cenário | Verificação |
|---|---|---|
| T8 | Evento já processado (mesmo event_id) — com republicação |
buscarPorEventId() retorna registro. lugar.georreferenciado republicado. Log.info emitido. Cursor avança. |
| T9 | Point-in-polygon não encontra UC (ponto fora de todos os polígonos) | resolverTerritorio() retorna null. Handler retorna sem INSERT. Log.error emitido. Cursor avança. |
| T10 | Polígono com geometria inválida | Turf.js lança erro. Erro capturado. Polígono problemático é pulado. Se próximo polígono contiver o ponto, processamento prossegue. |
| T11 | Hierarquia quebrada — parent_uc_id sem correspondência |
resolverCadeiaUcs() retorna cadeia parcial. Log.warn com IDs ausentes. Processamento prossegue com cadeia truncada. |
| T12 | Overpass API timeout | osmClient.consultar() retorna null. confianca_tipificacao = 'nao_avaliada'. Resolução territorial publicada normalmente. |
| T13 | Overpass API retorna array vazio | confianca_tipificacao = 'baixa'. enriquecimento_dados contém { elementos_encontrados: 0, raio_consulta: 50 }. |
| T14 | OSM encontra elemento com subtipo diferente do declarado | confianca_tipificacao = 'media'. divergencia_tipificacao preenchido. subtipo_confirmado mantém o declarado. |
| T15 | OSM encontra elemento com tag não mapeada | mapearTagOsmParaSubtipo() retorna null. confianca_tipificacao = 'media' com divergência (declarado vs. null). |
| T16 | publicar() de lugar.georreferenciado falha nas 4 tentativas |
Registro persiste em l2.resolucoes. Erro relançado para DLQ. Consumer offset NÃO atualizado. |
| T17 | INSERT falha com violação de unique (lugar_id duplicado) |
Erro capturado. DLQ registrada. |
| T18 | Reinicialização — cache geo vazio | geoCache.iniciar() cria Map em memória. Resoluções repetem point-in-polygon até repopular. Funcionalidade preservada. |
| T19 | Reinicialização — cache OSM vazio | osmCache.iniciar() cria Map em memória. Overpass é consultada novamente até repopular. Funcionalidade preservada. |
| T20 | Evento com coordenadas ausentes | Log.error. Handler retorna sem INSERT. Cursor avança. Defesa em profundidade — a L-1 não deveria publicar assim. |
| T21 | Evento com lugar_id ausente |
Log.error. Handler retorna sem INSERT. Cursor avança. |
| T22 | Evento com tipo_lugar inválido |
Log.error. Handler retorna sem INSERT. Cursor avança. |
| T23 | Evento com tipo_lugar = 'poligono_uc' |
Log “capturado e mantido em análise”. Sem INSERT e sem publicação. Cursor avança. |
Teste de integração (com PostgreSQL de teste e Event Bus real):
| # | Cenário | Verificação |
|---|---|---|
| T24 | Ciclo completo: lugar.cadastrado → lugar.georreferenciado |
1 linha em l2.resolucoes. 1 evento em core.event_log com tipo lugar.georreferenciado. correlacao_id propagado. Cache geo populado. |
| T25 | Replay após reinício: 3 eventos no log, 1 já processado | iniciar() processa 2 eventos novos, republica evento do já processado. count(*) em l2.resolucoes = 3. lugar.georreferenciado publicado 3 vezes. |
| T26 | Dois lugares na mesma coordenada (cache geo hit no segundo) | Primeiro: point-in-polygon executado, cache geo populado. Segundo: cache hit, point-in-polygon pulado. Ambos georreferenciados com mesma UC. |
| T27 | Lugar de organização com subtipo = 'farmacia', OSM confirma |
confianca_tipificacao = 'alta', subtipo_confirmado = 'farmacia', sem divergência. |
| T28 | Lugar com subtipo = 'farmacia', OSM encontra supermercado |
confianca_tipificacao = 'media', subtipo_confirmado = 'farmacia' (mantido), divergencia_tipificacao registrada. |
7.3 Dados de seed para desenvolvimento local
Seção intitulada “7.3 Dados de seed para desenvolvimento local”O seed real do ambiente de desenvolvimento é o npx prisma db seed, que importa a malha municipal 2025 e a malha de bairros do Censo 2022. O SQL abaixo é ilustrativo de um cenário mínimo.
-- Polígonos de UC de exemplo (no schema core)INSERT INTO core.uc_polygons (id, unidade_civica_id, nivel, parent_uc_id, nome, geometria, fonte, metadados, populacao_estimada)VALUES ( 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', 'uc-jardim-flores', 2, 'uc-municipio-sp', 'Jardim das Flores', '{"type":"Polygon","coordinates":[[[-46.635,-23.553],[-46.633,-23.553],[-46.633,-23.551],[-46.635,-23.551],[-46.635,-23.553]]]}', 'ibge', '{"codigo_bairro":"355030805","codigo_municipio":"3550308"}', NULL ), ( 'b2c3d4e5-f6a7-8901-bcde-f12345678901', 'uc-municipio-sp', 4, 'uc-estado-sp', 'São Paulo', '{"type":"Polygon","coordinates":[[[-47.0,-24.0],[-46.0,-24.0],[-46.0,-23.0],[-47.0,-23.0],[-47.0,-24.0]]]}', 'ibge', '{"codigo_municipio":"3550308","codigo_uf":"35"}', NULL ), ( 'c3d4e5f6-a7b8-9012-cdef-123456789012', 'uc-estado-sp', 5, 'uc-brasil', 'São Paulo (estado)', '{"type":"Polygon","coordinates":[[[-53.0,-25.5],[-44.0,-25.5],[-44.0,-19.5],[-53.0,-19.5],[-53.0,-25.5]]]}', 'ibge', '{"codigo_uf":"35"}', NULL ), ( 'd4e5f6a7-b8c9-0123-defa-123456789abc', 'uc-brasil', 7, NULL, 'Brasil', '{"type":"Polygon","coordinates":[[[-74.0,-34.0],[-34.0,-34.0],[-34.0,6.0],[-74.0,6.0],[-74.0,-34.0]]]}', 'ibge', '{}', NULL );
-- Resolução de exemplo (lugar já georreferenciado; o cursor do consumer_offset é semeado no boot)INSERT INTO l2.resolucoes (id, lugar_id, unidade_civica_id, nivel_minimo_resolvido, cadeia_ucs, metodo_resolucao, confianca_geo, tipo_lugar, subtipo_declarado, subtipo_confirmado, confianca_tipificacao, enriquecimento_fonte, enriquecimento_dados, divergencia_tipificacao, event_id, correlacao_id)VALUES ( 'e5f6a7b8-c9d0-1234-efab-567890abcdef', 'd4e5f6a7-b8c9-0123-defa-123456789abc', -- lugar_id da farmácia do seed da L-1 'uc-jardim-flores', 2, ARRAY['uc-jardim-flores', 'uc-municipio-sp', 'uc-estado-sp', 'uc-brasil'], 'gps', 'alta', 'organizacao', 'farmacia', 'farmacia', 'alta', 'osm', '{"nome_osm":"Farmácia São João","categoria_osm":"pharmacy","distancia_metros":5.2,"tags":{"amenity":"pharmacy","name":"Farmácia São João"}}', NULL, 'e3f4a5b6-c7d8-9012-efab-123456789abc', 'e3f4a5b6-c7d8-9012-efab-123456789abc' );Para testes de divergência, inserir um lugar com subtipo que conflite com o OSM (ex: declarado farmacia, OSM retorna supermercado). Para testes de OSM indisponível, mockar osmClient.consultar() para retornar null.
7.4 Arquivo de configuração OSM para testes
Seção intitulada “7.4 Arquivo de configuração OSM para testes”{ "amenity": { "pharmacy": "farmacia", "hospital": "hospital", "clinic": "ubs", "doctors": "consultorio", "dentist": "consultorio_odontologico", "school": "escola", "university": "universidade", "library": "biblioteca", "marketplace": "mercado", "police": "delegacia", "fire_station": "bombeiros", "townhall": "prefeitura", "courthouse": "forum", "post_office": "correios", "bank": "banco", "restaurant": "restaurante", "cafe": "cafeteria", "bar": "bar", "place_of_worship": "templo_religioso", "community_centre": "centro_comunitario", "parking": "estacionamento" }, "shop": { "supermarket": "supermercado", "convenience": "mercado", "bakery": "padaria", "hardware": "loja_construcao", "clothes": "loja_roupas" }, "office": { "government": "orgao_publico" }, "leisure": { "park": "parque", "sports_centre": "centro_esportivo", "fitness_centre": "academia" }, "tourism": { "hotel": "hotel", "museum": "museu" }}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 |
|---|---|
inscrever('lugar.cadastrado') com handler idempotente por event_id |
MVP obrigatório |
Point-in-polygon via Turf.js contra core.uc_polygons |
MVP obrigatório |
| Resolução de UC de menor nível com cadeia completa de UCs pai | MVP obrigatório |
| Cache geo de resoluções (in-memory) | MVP obrigatório |
| Snapshot de polígonos com TTL e pré-filtro por bounding box | MVP obrigatório |
| Enriquecimento de tipificação via Overpass API (OSM) para organizações e equipamentos públicos | MVP obrigatório |
| Cache OSM de respostas da Overpass API (in-memory) | MVP obrigatório |
| Mapeamento configurável OSM tag → subtipo de lugar | MVP obrigatório |
Cálculo de confianca_geo e confianca_tipificacao |
MVP obrigatório |
| Registro de divergência entre subtipo declarado e OSM | MVP obrigatório |
Captura de poligono_uc mantida em análise, sem publicação |
MVP obrigatório |
Persistência em l2.resolucoes com todos os campos de resolução e enriquecimento |
MVP obrigatório |
Publicação de lugar.georreferenciado com payload conforme Registry |
MVP obrigatório |
Republicação de lugar.georreferenciado no replay para garantir entrega à E-1 |
MVP obrigatório |
Consumer offset para replay após falha (l2.consumer_offset) |
MVP obrigatório |
Propagação de correlacao_id do evento de origem para o evento de saída |
MVP obrigatório |
Logs estruturados com lugar_id, event_id, correlacao_id, UC resolvida e confiança |
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 |
|---|---|---|
CoreGeoRepository com leitura direta de core.uc_polygons |
Duplicar a base de polígonos em D-2 e L-2 no MVP geraria complexidade de sincronização sem benefício. A base no core é uma dependência de infraestrutura, análoga ao Registry. |
Migrar para colônia dedicada de geo na Fase 2. Cada colônia (D-2, L-2) mantém réplica local sincronizada por evento. |
| Polígono como GeoJSON em JSONB, sem PostGIS | Instalar e manter a extensão PostGIS adiciona complexidade operacional. Turf.js resolve point-in-polygon com performance adequada para o volume do MVP. | Migrar para GEOMETRY(POLYGON, 4326) + ST_Contains + índice GIST quando volume ou complexidade de queries espaciais exigir (Fase 2). |
| Polígonos carregados integralmente em memória, com TTL de 6 horas | ~5.600 polígonos municipais ocupam ~17 MB. Cabem folgadamente na memória de qualquer servidor moderno, e a recarga periódica dispensa reiniciar a API após novo seed. | Migrar para queries espaciais indexadas quando volume de polígonos crescer com níveis 1-3 detalhados (> 100K polígonos). |
| Enriquecimento apenas via OSM | CNPJ (Receita Federal) e bases de equipamentos públicos (CNES, Inep) são fontes valiosas mas exigem integração adicional e manutenção de bases locais. O OSM cobre o caso mais comum (estabelecimentos comerciais e equipamentos públicos com nome). | Adicionar fontes CNPJ, CNES e Inep na Fase 2, com tabela l2.enriquecimentos separada para múltiplas fontes. |
| Sem retry em falha de enriquecimento | O enriquecimento é melhoria, não requisito funcional. Um lugar sem tipificação enriquecida ainda é georreferenciado e visível no sistema. | Adicionar retry com backoff para fontes externas na Fase 2. |
| Timeout OSM fixo de 5s | Cobrir 95% dos casos sem complexidade de configuração. | Tornar configurável via variável de ambiente na Fase 2. |
metodo_resolucao sempre gps |
Todos os lugares no MVP chegam com coordenadas GPS do dispositivo. Endereço textual não é suportado pela D-1a ou E-1 no MVP. | Adicionar geocodificação de endereço textual (Nominatim/Photon) na Fase 2. |
confianca_geo sempre alta |
Consequência do ponto anterior. O campo existe para forward compatibility. | Passa a variar quando endereços textuais forem suportados. |
| Sem endpoint REST para consulta de resoluções | A consulta é feita pela D-7 (Transparência) e L-4 (Fase 2) via projeções de leitura. A L-2 não serve queries. | Se necessário para debug operacional, adicionar endpoint administrativo na Fase 2. |
8.3 O que vai para a Fase 2
Seção intitulada “8.3 O que vai para a Fase 2”- Migração de
core.uc_polygonspara colônia dedicada de infraestrutura geoespacial, com réplicas locais em D-2 e L-2 sincronizadas por evento - PostGIS:
GEOMETRY(POLYGON, 4326), índice GIST,ST_Containspara point-in-polygon,ST_DWithinpara queries de proximidade - Enriquecimento por CNPJ (base da Receita Federal) para
tipo_lugar = 'organizacao' - Enriquecimento por CNES (saúde) e Censo Escolar/Inep (educação) para
tipo_lugar = 'equipamento_publico' - Tabela
l2.enriquecimentosseparada para múltiplas fontes de enriquecimento porlugar_id - Geocodificação de endereço textual (Nominatim/Photon) como fallback quando coordenadas GPS não disponíveis
- Retry com backoff exponencial para APIs externas (Overpass, CNPJ, CNES, Inep)
- Métricas Prometheus:
l2_lugares_georreferenciados_total,l2_point_in_polygon_duration_ms,l2_osm_call_total,l2_osm_cache_hit_ratio,l2_divergencias_detectadas_total - Cache de polígonos com invalidação seletiva por evento de atualização de polígono
- Inserção automática dos polígonos de
poligono_ucemcore.uc_polygonspela colônia de infraestrutura geoespacial - Suporte a re-georreferenciamento quando polígonos de UC são atualizados (
poligono.uc_atualizado→ recalcular lugares afetados)
8.4 Verificação de conflitos com outras colônias
Seção intitulada “8.4 Verificação de conflitos com outras colônias”Conflito potencial: D-2 e L-2 compartilham core.uc_polygons — quem mantém a tabela?
Avaliação: a tabela core.uc_polygons é criada por migration compartilhada e populada pelos scripts de seed territorial (IBGE e, nas fases seguintes, OSM e mapeamento coletivo). Nenhuma colônia de negócio escreve nela. Na Fase 2, uma colônia dedicada de infraestrutura geoespacial assume a manutenção e publica eventos de atualização. Sem conflito.
Conflito potencial: L-2 e D-2 implementam algoritmos de point-in-polygon ligeiramente diferentes, gerando UCs divergentes para a mesma coordenada.
Avaliação: o algoritmo (ray casting sobre o mesmo polígono GeoJSON) deve produzir o mesmo resultado determinístico. Para garantir, ambas as colônias usam a mesma versão do Turf.js. Testes de integração cruzada (mesma coordenada → mesma UC em D-2 e L-2) devem fazer parte da suíte de regressão. Na Fase 2, com PostGIS, ST_Contains é o algoritmo canônico.
Conflito potencial: E-1 depende de lugar.georreferenciado para definir associação territorial, mas o evento pode chegar antes do cadastro da empresa estar completo.
Avaliação: a E-1 publica lugar.recebido durante o cadastro. A L-1 processa e publica lugar.cadastrado. A L-2 processa e publica lugar.georreferenciado. A E-1 consome lugar.georreferenciado e casa com a organização pendente. Se o evento chegar antes do INSERT da organização na E-1, a E-1 deve armazenar temporariamente ou reconsultar. Isso é responsabilidade da E-1, não da L-2. Sem conflito para a L-2.
Conflito potencial: o CoreGeoRepository acessa tabela do schema core — violação da regra de isolamento?
Avaliação: sim, é uma violação controlada e documentada. A regra geral é que colônias só dependem do núcleo (Event Bus + Registry). A base de polígonos é estendida ao núcleo como dependência de infraestrutura no MVP, sob a mesma justificativa do Registry: é um dado fundamental sem o qual point-in-polygon é impossível. A exceção é temporária e será removida na Fase 2 com a colônia dedicada de geo. Documentado nas seções 1.6 e 8.2.
Referências
Seção intitulada “Referências”- Ficha técnica da colônia: Apêndice B - Colônias.md, seção “Colônia L-2 — Georreferenciamento e Tipificação”
- Schemas de eventos: N-0b - Registry.md, seções
lugar.cadastradoelugar.georreferenciado - Barramento de eventos: N-0a - Event Bus.md
- Observabilidade: N-0c - Observabilidade.md
- Colônia a montante: L-1 - Cadastro de Lugares.md
- Colônia a jusante (status): L-1 - Cadastro de Lugares.md (consumo de
lugar.georreferenciado) - Colônia a jusante (territorial): E-1 - Cadastro Institucional.md (consumo de
lugar.georreferenciado) - Colônia análoga no fluxo de demandas: D-2 - Georreferenciamento.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 (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.