diff --git a/docs/plans/sollinha-cidades-mais-votado-impl.md b/docs/plans/sollinha-cidades-mais-votado-impl.md
new file mode 100644
index 00000000..e9825fab
--- /dev/null
+++ b/docs/plans/sollinha-cidades-mais-votado-impl.md
@@ -0,0 +1,94 @@
+# Impl: Sollinha — responder “em quais cidades X foi o deputado mais votado” (rank competitivo reverso, flexível)
+
+Status: aprovado
+Atualizado em: 2026-08-09
+Issue: #460
+Intenção: docs/plans/sollinha-cidades-mais-votado.md
+Appetite restante: ~1 dia eng (herdado); sem migration / collection / Consent / UI
+
+## Registro de execução (2026-08-09)
+
+- **Entrega:** implementado e validado — `src/lib/leadingMunicipalities.ts` (path do artefato, puro) + `src/utilities/ai/tools/getLeadingMunicipalities.ts` (tool; SQL window-rank para terceiros) + registro em `tools/index.ts` + seção pt-BR no `systemPrompt.ts`; unit (8) e int (7) specs próprios.
+- **Divergência da hipótese da intenção:** nenhuma nos aceites; a decisão nova de engenharia (SQL `RANK()` window via drizzle no request path para terceiros, mantendo o artefato só para o candidato) foi deliberada e validada por int test contra a fórmula do builder do artefato.
+- **Cobertura por tests:** SQL path com valores exatos da fixture (Salvador dobra zonas numa linha; 2222 top-N), equivalência SQL ≡ fórmula do artefato, resolução por número/nome + fallback de votos + not-found, lockdown de leader (unit).
+- **Correções de fixture:** TSE code de Feira de Santana `35150 → 35157` nos CSVs de fixture (o `35150` não era canônico; `bahiaTseCityCodes`/snapshot oficial pina `35157`). Sem impacto em `electionResultsImport` (totais íntegros) nem `municipalityTicketPartnerData`.
+- **Simplify:** fixes aplicados — `server-only` no tool + guard de convenção alargado para `@payloadcms/db-postgres`; `cityPageSlug` único (lib reusado por tool/teste) matando twin de `entries.length===1`; iterar `municipalityCatalogEntriesForCity` em vez de rebuildar mapa; `HISTORICAL_SERIES_YEARS` reusado (sem tuple local); resolução de candidato busca todas as linhas (`limit: 0`) para a ambigüidade nunca omitir o candidato certo; loader sem throw no `execute` (erro vira `{ error }` no chat); `assertCanReadElectionData` dentro do loader (defesa em profundidade, espelha `municipalityElectoralBaseline`); int test de fallback do registro.
+- **Deferidos (registrados aqui, sem Issue):** (1) fixture de empate no limite do top-N — gatilho: mudar `RANK()`/DENSE_RANK ou o CTE; (2) nota de escopo do terceiro candidato (zona leaky inclusa no escopo cru da cidade) documentada no comentário do loader.
+
+## Leitura da intenção
+
+- **Outcome:** uma tool de leitura no chat Sollinha que responde, para um deputado federal (número/nome; default = candidato da campanha): “em quais municípios ficou em 1º (ou até a posição X)” num ano da série (2014/2018/2022; 2022 default) — com contagem, uma linha por cidade (417, Salvador como UMA cidade, nunca 19), semântica de empate = mesma do mapa, fail-safe “sem dados” quando não houver.
+- **O que NÃO negociar:** leader lockdown (não conversa de municípios); não estender para outros cargos; uma linha por cidade; sem segunda fonte paralela desalinhada para o candidato da campanha (artefato imutável é a fonte da verdade); nunca inventar número; sem migration/collection/Consent.
+- **O que reavaliar:** como derivar o rank de **terceiros candidatos** sem colocar o scan de ~100k linhas no path de request (B13 documenta `loadFederalVotesByCityZoneAndCandidate` como CLI/build-only). Hipótese da intenção é “derivar das coleções de votos”; a forma concreta (SQL window vs. scan no Node) é decisão desta engenharia.
+
+## Abordagem recomendada
+
+```mermaid
+flowchart LR
+ U[chat /campanha] --> T[getLeadingMunicipalities]
+ T --> G{assertCanReadElectionData}
+ G -- leader/deny --> E[error pt-BR]
+ G -- staff --> D{candidate dado?}
+ D -- não/1313 --> A[artefato federalRankByIbgeCode
instantâneo, imutável]
+ D -- outro número/nome --> R[resolve em electionCandidate
/electionCandidateVote]
+ R -- 1 match --> S[SQL window RANK()
election_candidate_vote]
+ R -- >1 match --> O[opções p/ desambiguar]
+ A --> M[monta linhas city/rank/votes/candidates
+ slug p/ link]
+ S --> M2[city_code→city canônico + slug]
+ M --> Out[resposta: contagem + ranking]
+ M2 --> Out
+```
+
+**Opções consideradas:**
+
+- **A — Híbrido fonte dupla sancionada:** candidato da campanha lê o artefato commitado (`federalRankByIbgeCode` + `municipalities[slug].votesByYear`); terceiros derivam ao vivo por uma query SQL window-rank única.
+- **B — SQL window-rank para todos (inclusive Solla):** um único path; re-deriva a cada chamada o que o artefato já precomputa; põe o scan de ~100k linhas no chat (contra a restrição documentada por B13).
+- **C — Scan `loadFederalVotesByCityZoneAndCandidate` no path do chat:** rejeitado por proibição explícita (CLI/build-only).
+- **D — Resolver ranking por cidade via N queries Payload (uma por cidade):** ~417 round-trips por chamada; inviável.
+
+**Recomendação: A.** Porque a intenção sanciona explicitamente o par artefato (candidato) + derivação (terceiros) e o artefato É a fonte do mapa — usar SQL para o candidato criaria a “segunda fonte paralela” que a intenção proíbe, a um custo de ~100k linhas por mensagem. **Rejeitadas:** B/C (scan no request + duplicação do artefato), D (latência).
+
+**Semântica de rank = exatamente a do mapa:** o builder do artefato (`scripts/build-election-aggregates.mjs`) faz `rank = ahead + 1` (ahead = candidatos com votos > próprios), `candidates` = candidatos com votos > 0, e pula cidade onde o próprio candidato tem 0 votos. Isso é idêntico a `RANK() OVER (PARTITION BY city_code ORDER BY votes DESC)` após filtrar `votes > 0` — empate divide colocação e o denominador é honesto. O int test abaixo trava essa equivalência.
+
+### Componentes / mudanças
+
+- **`src/lib/leadingMunicipalities.ts`** (novo, puro — sem import de servidor; testável unit): tipos (`LeadingMunicipalityRow { city, slug, rank, votedCandidates, votes }`, `LeadingMunicipalitiesResult`), `campaignCandidateLeadingMunicipalities(year, topN)` (path do artefato: agrupa `municipalityCatalog` por cidade → `getFederalCompetitiveRank(ibgeCode, year)` filtra `rank ≤ topN` → soma `votesByYear` sobre os slugs da cidade → slug de página `null` para cidade-zona (Salvador) / slug do município caso contrário), `sortLeadingMunicipalityRows` (rank asc, votos desc). Sem duplicar o ranker JS (o SQL é a única implementação; o int test valida a fórmula ad hoc).
+- **`src/utilities/ai/tools/getLeadingMunicipalities.ts`** (novo):
+ - `export const loadLeadingMunicipalitiesForCandidate(payload, { candidateNumber, year, topN })` — executa o SQL window-rank via `payload.db.drizzle` + `sql` de `@payloadcms/db-postgres` (precedente exato `supporterListOverviewAggregate.ts`), mapeia `city_code` → cidade canônica (`municipalityForTseCityCode`) → slug (`municipalityCatalogEntriesForCity`). Exportado para o int test importar o path real.
+ - tool factory `getLeadingMunicipalities(ctx)`: schema `{ candidate?: string, year?=2022, topN?=1 (1..20) }`; `execute` → `assertCanReadElectionData(ctx.user)` fail-closed (leader → `{ error: 'Leitura de dados eleitorais negada.' }`), valida ano em 2014/2018/2022, default/branch do candidato da campanha (inclusive quando o usuário resolve p/ o número 1313) → artefato; senão resolve candidato (`electionCandidate` por número exato ou nome ILIKE urnaName/completeName; fallback `electionCandidateVote` distinct quando o registro não achar; >1 → devolve opções pro modelo desambiguar) e usa o SQL.
+- **Migration:** nenhuma.
+- **Access / Consent:** `assertCanReadElectionData` (única redação da regra); nenhuma chave de Consent.
+- **UI:** Impeccable A — resposta em markdown no chat existente; nenhuma superfície nova.
+- **Registro:** `src/utilities/ai/tools/index.ts` + orientação em `src/utilities/ai/systemPrompt.ts` (pt-BR): quando usar, default Jorge Solla, “uma linha por cidade”, oferecer link via `buildCampaignLinks` quando útil, “sem dados” explícito.
+
+### Dados → forma
+
+- Forma: lista textual no chat — contagem total + linhas ordenadas (colocação, cidade, votos, “de N candidatos votados”). Rejeitada: tabela/badge/UI nova (fora do appetite; rabbit hole) e série/chart (anti-goal “leaderboard de vitórias”). Cada cidade tem um lugar; Salvador linha única (sem link de página única até B178 → `slug: null`).
+
+## Fases verificáveis
+
+1. **Tracer / server** — lib pura + tool + registro + system prompt; unit tests do path do artefato (Solla 2022: `total ≥ 1`, uma linha por cidade, Salvador única, `rank ≤ topN`, ordenação) e do lockdown (leader nega).
+2. **Int test** (`tests/int/leadingMunicipalities.int.spec.ts`, sob `ELECTION_COLLECTIONS_LEASE_KEY` + fixtures TSE como `electionResultsImport.int.spec.ts`): importa fixtures, valida SQL path com outputs exatos (Salvador 1313 = rank 1, 2100 votos, 2 candidatos — zonas dobradas numa cidade; Feira idem; 2222 topN=1 vazio / topN=2 rank 2) e compara SQL × fórmula do builder (consistência de semântica).
+3. **Gates** — `pnpm gate:fast` na iteração; entrega com `pnpm push`.
+
+## Rabbit holes / Não escopo (engenharia)
+
+- Não criar “referência JS duplicada” do ranker (o SQL é a única implementação; teste fixa a semântica).
+- Não estender a `bahiaElectionAggregates.ts` com accessors novos só para isto (itero o catálogo p/ chegar no rank por IBGE).
+- Não travar/cachear o SQL (dados de eleição são imutáveis; cada chamada é boundary e o query é único e indexado).
+- Não tocar nos tools existentes (`getTopDeputies`/`getMunicipalityVotes`) — correção do possível leak de leader neles é item separado (registrar como débito).
+- Não mexer no artefato nem no builder do mapa.
+
+## Riscos e mitigação
+
+- **SQL com casas de enum:** `office`/`turn`/`vote_type` são enums → literais inline vindos de constantes do código (sem input do usuário), números por params — mesmo padrão dos aggregates C6.
+- **Drift de schema da tabela:** nomes de colunas vêm da migration congelada; guarda `payload.db.name === 'postgres'` + `drizzle.execute` presente (precedente do overview de apoiadores).
+- **Lista grande (≥ centenas de cidades) em topN alto:** resposta honesta; o modelo resume; cap `topN ≤ 20`.
+- **Lockdown:** `assertCanReadElectionData` no topo do `execute` (fail-closed); testado.
+- **SQL path vs artefato divergirem pro candidato da campanha:** mesma tabela TSE; int test trava a fórmula; sem mecanismo de drift em prod (artefato pré-computado).
+
+## Aceite de engenharia
+
+- [ ] Aceite de produto da intenção coberto (contagem, top-N, empate, Salvador 1 linha, default Solla, lockdown, sem inventar número)
+- [ ] Invariantes AGENTS/engineering-standards (sem server-only no lib; bypass justificado; pt-BR no prompt; identificadores em inglês)
+- [ ] Testes previstos: unit (artefato + lockdown) e int (SQL path + equivalência de semântica) — onde o path de acesso/dados muda
diff --git a/docs/plans/sollinha-tools-eleitorais-leader-lockdown.md b/docs/plans/sollinha-tools-eleitorais-leader-lockdown.md
new file mode 100644
index 00000000..98b80f3e
--- /dev/null
+++ b/docs/plans/sollinha-tools-eleitorais-leader-lockdown.md
@@ -0,0 +1,39 @@
+# B180 — Sollinha: travar tools eleitorais legadas contra leader (lockdown)
+
+Status: plano
+Atualizado em: 2026-08-09
+Issue: (registrar com depends: [B177])
+Priority: P2
+Model: composer-2.5
+Model-local: deepseek-v4-flash-high
+Impeccable: A — N/A (sem UI; gate no execute das tools)
+Canvas UI: N/A
+Appetite: ~0,5 dia eng; editar duas tools + orientar prompt; sem migration / Consent / collection
+
+## Intenção
+
+A B177 introduziu a tool `getLeadingMunicipalities` com o gate fail-closed `assertCanReadElectionData(ctx.user)` no topo do `execute` (leader lockdown: "liderança não tem essa conversa sobre municípios"). As duas tools eleitorais legadas do chat — `getTopDeputies` (quem foi o mais votado num município) e `getMunicipalityVotes` (votação de Solla num município) — usam `overrideAccess: true` nas queries **sem** gate de papel, então um usuário com role `leader` consegue invocá-las no chat e ler dados eleitorais (o route `api/ai-chat` expõe todas as tools a todos os papéis; o prompt só desencoraja).
+
+Não é uma regressão recente nem vazamento de dado sensível (dados TSE públicos), mas contraria o lockdown de produto: liderança não recebe conversa de municípios/dados eleitorais no Sollinha.
+
+## Objetivo e aceite
+
+- `getTopDeputies` e `getMunicipalityVotes` fazem `assertCanReadElectionData(ctx.user)` no topo do `execute` (try/catch → `{ error: 'Leitura de dados eleitorais negada.' }`, fail-closed, mesmo padrão da B177).
+- Nenhum outro comportamento muda (staff continua idêntico).
+- Sem migration / Consent / collection / UI.
+
+## Direção no codebase
+
+- `src/utilities/ai/tools/getTopDeputies.ts` e `getMunicipalityVotes.ts`: adicionar o gate no `execute`.
+- Registrar gate compartilhado? B177 inlinou try/catch no execute; se o padrão replicar em 3 tools, um helper `withElectionDataGate` pode valer — avaliar no momento (regra: 2 call sites = inline; 3+ = extrair; aqui já são 3 com a nova).
+- Sem mudança no system prompt (já orienta leader a não receber links; o gate agora é enforcement).
+
+## Já resolvido no simplify da B177 (não reabrir)
+
+- Tool nova `getLeadingMunicipalities` já nasce com o gate (e `assertCanReadElectionData` também dentro do loader, defesa em profundidade).
+- Guard de convenção `codebaseConventions` alargado para exigir `server-only` em quem importa `@payloadcms/db-postgres`.
+
+## Explicitamente fora
+
+- Flake de CI `testDatabaseLease.int.spec.ts` (consent restore sob int paralelo) — descartado no triage da B177 (S2); reavaliar se recorrer.
+- Consertar `searchEntities`/outras tools não-eleitorais para leader (fora do escopo: só as eleitorais).
diff --git a/src/lib/leadingMunicipalities.ts b/src/lib/leadingMunicipalities.ts
new file mode 100644
index 00000000..65d65c21
--- /dev/null
+++ b/src/lib/leadingMunicipalities.ts
@@ -0,0 +1,120 @@
+/**
+ * Reversed competitive rank for the Sollinha chat: given a federal deputy
+ * (default: the campaign candidate), answer "in which municipalities was he
+ * the most voted (or up to top-N)" for a year of the TSE series.
+ *
+ * Pure module — no server/database imports. Two producers share one output
+ * shape and the SAME rank semantics the map paints:
+ * - the campaign candidate reads the committed immutable artifact
+ * (`getFederalCompetitiveRank` + `getMunicipalityFederalBaseline`), which
+ * is exactly the map's source;
+ * - any other federal deputy derives live from the TSE collections via a
+ * single SQL window query (see the ai tool in
+ * `src/utilities/ai/tools/getLeadingMunicipalities.ts`).
+ *
+ * Rank semantics (pinned to the artifact builder in
+ * `scripts/build-election-aggregates.mjs`): rank = candidates strictly ahead
+ * + 1 (ties share a placement), the denominator is the candidates with at
+ * least one vote in the city, a city where the candidate took no votes yields
+ * no row, and every city appears exactly once — Salvador's 19 zone
+ * municipalities fold into a single city row.
+ */
+
+import {
+ getFederalCompetitiveRank,
+ getMunicipalityFederalBaseline,
+} from '@/lib/bahiaElectionAggregates'
+import { BASELINE_TICKET_2022 } from '@/lib/electionResults'
+import {
+ municipalityCatalog,
+ municipalityCatalogEntriesForCity,
+ type MunicipalityCatalogEntry,
+} from '@/lib/municipalityCatalog'
+
+export type LeadingMunicipalityRow = {
+ /** Canonical city name where the candidate placed at or above top-N. */
+ city: string
+ /**
+ * Canonical municipality slug for a city-level page link, or `null` when the
+ * city has no single-municipality page (Salvador is 19 zone units — a city
+ * ranking must not point at a zone page).
+ */
+ slug: string | null
+ /** Placement among the federal deputies voted in the city (1 = most voted). */
+ rank: number
+ /** Federal deputies with at least one vote in the city — the "de N candidatos" denominator. */
+ votedCandidates: number
+ /** The candidate's nominal votes inside the city geography (zones folded). */
+ votes: number
+}
+
+export type LeadingMunicipalitiesResult = {
+ candidate: { candidateNumber: number; name: string; party: string | null }
+ year: number
+ topN: number
+ /** Number of cities where the candidate placed at or above top-N. */
+ total: number
+ municipalities: LeadingMunicipalityRow[]
+}
+
+/** Sorts by placement asc, then votes desc — both producers present the same order. */
+export const sortLeadingMunicipalityRows = (
+ rows: readonly LeadingMunicipalityRow[],
+): LeadingMunicipalityRow[] =>
+ [...rows].sort((left, right) => left.rank - right.rank || right.votes - left.votes)
+
+/**
+ * City-level page slug for a ranking row: a city with exactly one catalog unit
+ * links its municipality page; a city split into zone units (Salvador, 19)
+ * has no single page and stays `null`. The single spelling of the row rule.
+ */
+export const cityPageSlug = (entries: readonly MunicipalityCatalogEntry[]): string | null =>
+ entries.length === 1 ? entries[0]!.slug : null
+
+const cityNames = () => new Set(municipalityCatalog.map((entry) => entry.city))
+
+/**
+ * The campaign candidate's reversed ranking straight from the committed
+ * artifact — instant, immutable, exactly the map's competitive placement.
+ * `topN` is not clamped here: the tool clamps before calling.
+ */
+export const campaignCandidateLeadingMunicipalities = (
+ year: number,
+ topN: number,
+): LeadingMunicipalitiesResult => {
+ const rows: LeadingMunicipalityRow[] = []
+
+ for (const city of cityNames()) {
+ const entries = municipalityCatalogEntriesForCity(city)
+ const first = entries[0]!
+ const competitive = getFederalCompetitiveRank(first.ibgeCode, year)
+ if (!competitive || competitive.rank > topN) continue
+
+ let votes = 0
+ for (const entry of entries) {
+ votes += getMunicipalityFederalBaseline(entry.slug).votesByYear[String(year)] ?? 0
+ }
+
+ rows.push({
+ city,
+ // A city with more than one catalog unit (Salvador, 19 zones) shares one
+ // placement and has no single page to link — the zone slugs stay hidden.
+ slug: cityPageSlug(entries),
+ rank: competitive.rank,
+ votedCandidates: competitive.candidates,
+ votes,
+ })
+ }
+
+ return {
+ candidate: {
+ candidateNumber: BASELINE_TICKET_2022.candidate.candidateNumber,
+ name: BASELINE_TICKET_2022.candidate.name,
+ party: BASELINE_TICKET_2022.candidate.party,
+ },
+ year,
+ topN,
+ total: rows.length,
+ municipalities: sortLeadingMunicipalityRows(rows),
+ }
+}
diff --git a/src/utilities/ai/systemPrompt.ts b/src/utilities/ai/systemPrompt.ts
index 5a58c252..3a81de08 100644
--- a/src/utilities/ai/systemPrompt.ts
+++ b/src/utilities/ai/systemPrompt.ts
@@ -29,6 +29,14 @@ export const AI_SYSTEM_PROMPT = `Você é a Sollinha, assistente virtual da camp
- Apresente números grandes com separador de milhar (ex: 1.234.567).
- Percentuais sempre com 1 casa decimal (ex: 24,7%).
+## Reversão de ranking (em quais cidades um deputado foi o mais votado)
+- Quando o usuário perguntar "em quais cidades {eu / o candidato / o deputado X} foi o deputado mais votado", "onde X ficou em primeiro", "onde X se destacou" ou "top N em quais cidades", use a ferramenta "getLeadingMunicipalities".
+- Candidato default = Jorge Solla (número 1313). Se o usuário citar outro deputado federal (número ou nome), passe esse valor no campo "candidate"; se a ferramenta devolver opções ambíguas, pergunte pelo número da urna.
+- A resposta deve listar as cidades com a colocação relativa (ex.: "Salvador — 1º lugar, de 2 candidatos votados"), citar a contagem total ("em N cidades") e manter contexto relativo — nunca vire uma lista de "medalhas".
+- Salvador aparece como UMA cidade (um único lugar), nunca como as 19 zonas; cada cidade aparece uma única vez.
+- Quando a ferramenta devolver zero cidades / "sem dados", diga claramente que não há dados para aquele ano/candidato — nunca invente número.
+- Ofereça link de navegação (buildCampaignLinks) para os municípios citados quando útil (entidade singular/concreta).
+
## Contexto eleitoral
- A eleição para deputado federal usa o sistema proporcional de lista aberta.
- O quociente eleitoral na Bahia em 2022 foi aproximadamente 210.000 votos.
diff --git a/src/utilities/ai/tools/getLeadingMunicipalities.ts b/src/utilities/ai/tools/getLeadingMunicipalities.ts
new file mode 100644
index 00000000..906bce73
--- /dev/null
+++ b/src/utilities/ai/tools/getLeadingMunicipalities.ts
@@ -0,0 +1,398 @@
+import 'server-only'
+
+import { sql } from '@payloadcms/db-postgres'
+import { tool } from 'ai'
+import type { Where } from 'payload'
+import { z } from 'zod'
+
+import type { AIToolContext } from '@/lib/ai/types'
+import { municipalityForTseCityCode } from '@/lib/bahiaTseCityCodes'
+import {
+ BASELINE_TICKET_2022,
+ ELECTION_YEAR_2022,
+ FEDERAL_DEPUTY_OFFICE,
+ HISTORICAL_SERIES_YEARS,
+} from '@/lib/electionResults'
+import {
+ campaignCandidateLeadingMunicipalities,
+ cityPageSlug,
+ sortLeadingMunicipalityRows,
+ type LeadingMunicipalityRow,
+} from '@/lib/leadingMunicipalities'
+import { municipalityCatalogEntriesForCity } from '@/lib/municipalityCatalog'
+import { assertCanReadElectionData } from '@/utilities/campaignAccess'
+import { drizzleResultRows } from '@/utilities/drizzleBulk'
+
+/** Valid election years for the chat — the same TSE series the baseline reads. */
+const VOTE_YEARS: readonly number[] = HISTORICAL_SERIES_YEARS
+
+const MAX_TOP_N = 20
+
+type CandidateIdentity = {
+ candidateNumber: number
+ name: string
+ party: string | null
+}
+
+type DeputyResolution = CandidateIdentity | { error: string; options?: CandidateIdentity[] }
+
+type LeadingMunicipalitiesRow = {
+ city_code: string
+ candidate_number: number
+ candidate_name: string
+ party: string | null
+ votes: number
+ rank: number
+ voted_candidates: number
+}
+
+/**
+ * Per-city window ranking of every federal deputy for one year, filtered to
+ * the target candidate's placements up to `topN`. The `RANK()` over
+ * `votes DESC` after keeping only candidates with votes reproduces the
+ * artifact builder's semantics exactly: rank = candidates strictly ahead + 1
+ * (ties share a placement), the denominator is the voted candidates, a
+ * candidate with no votes in a city yields no row. Zone rows are folded per
+ * city by the CTE, so Salvador's 19 zones rank as one city.
+ *
+ * `office`/`turn`/`vote_type`/`state` are the collection's enum/text columns
+ * and MUST stay inline literals — a param-bound string against an enum column
+ * fails Postgres typing. They come from closed TSE constants, never user
+ * input; only year/candidateNumber/topN are bound.
+ */
+const buildLeadingMunicipalitiesSql = ({
+ year,
+ candidateNumber,
+ topN,
+}: {
+ year: number
+ candidateNumber: number
+ topN: number
+}) => sql`
+ WITH per_city_candidate AS (
+ SELECT
+ "city_code",
+ "candidate_number",
+ MAX("candidate_name") AS "candidate_name",
+ MAX("party") AS "party",
+ SUM("votes") AS "votes"
+ FROM "election_candidate_vote"
+ WHERE "year" = ${year}
+ AND "office" = 'deputado_federal'
+ AND "turn" = '1'
+ AND "vote_type" = 'nominal'
+ AND "state" = 'BA'
+ GROUP BY "city_code", "candidate_number"
+ ),
+ ranked AS (
+ SELECT
+ "city_code",
+ "candidate_number",
+ "candidate_name",
+ "party",
+ "votes",
+ RANK() OVER (PARTITION BY "city_code" ORDER BY "votes" DESC) AS "rank",
+ COUNT(*) OVER (PARTITION BY "city_code") AS "voted_candidates"
+ FROM per_city_candidate
+ WHERE "votes" > 0
+ )
+ SELECT
+ "city_code",
+ "candidate_number",
+ "candidate_name",
+ "party",
+ "votes",
+ "rank",
+ "voted_candidates"
+ FROM ranked
+ WHERE "candidate_number" = ${candidateNumber}
+ AND "rank" <= ${topN}
+ ORDER BY "rank" ASC, "votes" DESC
+`
+
+type PostgresDb = {
+ execute: (query: ReturnType) => Promise
+}
+
+/**
+ * Live reversed ranking for an arbitrary federal deputy, straight from the
+ * TSE vote collections — the only producer for non-campaign candidates. The
+ * heavy window aggregation runs inside Postgres; only the target candidate's
+ * rows travel to Node.
+ *
+ * Third-party ranks read the raw TSE city scope (every row under the city
+ * code, leaky zones included); the campaign candidate always reads the
+ * catalog-cut artifact, so the map's reading is never affected by the cut.
+ */
+export const loadLeadingMunicipalitiesForCandidate = async (
+ payload: AIToolContext['payload'],
+ user: AIToolContext['user'],
+ { candidateNumber, year, topN }: { candidateNumber: number; year: number; topN: number },
+): Promise => {
+ // Fail-closed at the loader too, mirroring the municipality election loaders:
+ // a future caller that forgets the tool gate cannot serve a leader anyway.
+ assertCanReadElectionData(user)
+
+ if (payload.db.name !== 'postgres') {
+ throw new Error('A leitura de cidades líderes exige o adaptador PostgreSQL.')
+ }
+
+ const database = payload.db as unknown as { drizzle?: PostgresDb }
+ const drizzle = database.drizzle
+ if (!drizzle || typeof drizzle.execute !== 'function') {
+ throw new Error('A sessão PostgreSQL da leitura de cidades líderes não está disponível.')
+ }
+
+ const result = await drizzle.execute(
+ buildLeadingMunicipalitiesSql({ year, candidateNumber, topN }),
+ )
+ const rows = (drizzleResultRows(result) as unknown as LeadingMunicipalitiesRow[]) ?? []
+
+ const leading: LeadingMunicipalityRow[] = []
+ for (const row of rows) {
+ // city_code is the catalog's TSE code — every seeded row resolves; an
+ // unknown code is skipped defensively instead of leaking a raw TSE name.
+ const city = municipalityForTseCityCode(row.city_code)
+ if (!city) continue
+ leading.push({
+ city,
+ slug: cityPageSlug(municipalityCatalogEntriesForCity(city)),
+ rank: Number(row.rank),
+ votedCandidates: Number(row.voted_candidates),
+ votes: Number(row.votes),
+ })
+ }
+
+ return sortLeadingMunicipalityRows(leading)
+}
+
+/** The four scope conditions shared by the candidate registry and vote rows. */
+const candidateScopeWhere = (year: number): Where => ({
+ and: [
+ { year: { equals: year } },
+ { office: { equals: FEDERAL_DEPUTY_OFFICE } },
+ { turn: { equals: '1' } },
+ { state: { equals: 'BA' } },
+ ],
+})
+
+/**
+ * Ballot-number or name fragments for one collection. A number matches the
+ * unique per-scope candidateNumber; a name is folded with `like` (Payload's
+ * operator is case-insensitive and adds wildcards).
+ */
+const nameOrNumberFragments = (input: string, nameField: 'urnaName' | 'candidateName'): Where => {
+ const numeric = /^\d+$/.test(input) ? Number(input) : null
+ if (numeric !== null) return { candidateNumber: { equals: numeric } }
+ if (nameField === 'candidateName') return { candidateName: { like: input } }
+ return { or: [{ urnaName: { like: input } }, { completeName: { like: input } }] }
+}
+
+const collectDistinctCandidates = (
+ docs: ReadonlyArray<{ candidateNumber: number; name: string; party: string | null }>,
+): Map => {
+ const byNumber = new Map()
+ for (const doc of docs) {
+ const number = Number(doc.candidateNumber)
+ if (!byNumber.has(number)) {
+ byNumber.set(number, { name: doc.name, party: doc.party })
+ }
+ }
+ return byNumber
+}
+
+/** Fallback: distinct federal-deputy rows of the year when the registry misses. */
+const resolveFromVoteRows = async (
+ payload: AIToolContext['payload'],
+ year: number,
+ input: string,
+): Promise