diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md
index f2594ed8..d1c4f106 100644
--- a/docs/CHANGELOG-AGENTS.md
+++ b/docs/CHANGELOG-AGENTS.md
@@ -10,6 +10,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
**Recently resolved (2026-08-09):** **C93** (Issue #437) — botão "Link de import" da agenda destravado **sem filtros** (sucessor direto do C16): com zero filtros ativos, staff gera o link do feed **completo dentro do escopo de leitura do criador** — coordinator/candidate = tudo; advisor = portfólio dos municípios que administra — com fail-closed/revogação de C16 intactos. O servidor já cobria o caso (vazio = escopo do criador em `buildFeedWhere`/`resolveFeedCreatorAccess`; descrição do campo `filterMunicipality`); a entrega removeu o gate `hasFilters → disabled` de `CalendarFeedButton` (prop morto `state` incluído), manteve `hasFilters` só no "Limpar filtros" e neutralizou a copy do diálogo ("este recorte" → "a agenda"). Testes: 3 int de conteúdo sem filtro (coordinator = A+B; advisor = só o portfólio, nada fora vaza; advisor sem município → feed vazio fail-closed) + unit component (botão habilitado com zero filtros e fluxo nomear→criar). Sem migration, access, Consent ou schema. Intenção [`c93-gerar-link-import-sem-filtros.md`](plans/c93-gerar-link-import-sem-filtros.md); impl [`c93-gerar-link-import-sem-filtros-impl.md`](plans/c93-gerar-link-import-sem-filtros-impl.md).
+**Recently resolved (2026-08-09):** **B180** (Issue #474) — as tools eleitorais legadas do Sollinha (`getTopDeputies`, `getMunicipalityVotes`) ganharam o **gate fail-closed de leader lockdown** que a B177 introduziu só na tool nova: `assertCanReadElectionData(ctx.user)` no topo do `execute` → `{ error: 'Leitura de dados eleitorais negada.' }` antes de qualquer query (o route `api/ai-chat` expõe todas as tools a todos os papéis). O padrão chegou a **3 call sites** → extraído um helper compartilhado `electionDataGate(ctx)` (`src/utilities/ai/tools/electionDataGate.ts`, `true | { error }`), e `getLeadingMunicipalities` refatorado para usá-lo (mensagem com fonte única). Unit tests novos (`electionToolsLockdown.unit.spec.ts`, 11): helper (leader nega/staff passa), as 3 tools com leader + payload stub intocado (fail-closed antes de query, inclusive no path SQL de terceiro candidato) e staff-proceeds por papel (coordinator/advisor/candidate). Sem migration/Consent/UI/prompt. Intenção [`sollinha-tools-eleitorais-leader-lockdown.md`](plans/sollinha-tools-eleitorais-leader-lockdown.md); impl [`sollinha-tools-eleitorais-leader-lockdown-impl.md`](plans/sollinha-tools-eleitorais-leader-lockdown-impl.md).
+
**Recently resolved (2026-08-09):** **B176** (Issue #458) — `/campanha/municipios` ganhou três dimensões de filtro na omnibox — **Dobradinha** (nome do contato), **Liderança** (nome do contato) e **Partido** (sigla) — com recortes "Sem dobradinha"/"Sem liderança"/"Sem partido" (precedente "Sem nível"). Params aditivos `stateDeputy`/`leadership`/`party` na URL congelada (sentinelas `sem_dobradinha`/`sem_lideranca`, reuso de `sem_partido`); `buildMunicipalityListWhere` ganhou um catálogo request-scoped fail-closed (`municipalityRelationSets.ts`, lazy: metade liderança só com filtro ativo) que vira as leituras reversa (`leadership.municipalities`) e cruzada (`stateDeputy.party`) em `where` — sem migration, paginação nativa mantida; facets estendidas (`stateDeputyIDs`/`leadershipOptions`/`parties`) e mapa compartilha o catálogo. **Coluna Dobradinhas liberada ao Assessor** (staff-wide): servidor já estava scoped (B37/B157 `reloadStaffActor` + `canUpdateMunicipality`), a mudança foi só de gate de UI (lista/mobile/picker) + catálogo full-staff para a célula (decisão do gate humano 2026-08-09). Extrato do par "sentinela + `exists:false`" em `campaignListUrl` (`NO_PARTY_FILTER_VALUE`, `splitAbsenceFilterValues`, `collapseListWhereOrBranches`) com 5 call sites. E2E: assessor vê/edita a coluna no portfólio; filtros pelas três dimensões; teste de coluna-restrita invertido. Intenção [`filtros-municipios-dobradinha-lideranca.md`](plans/filtros-municipios-dobradinha-lideranca.md); impl [`filtros-municipios-dobradinha-lideranca-impl.md`](plans/filtros-municipios-dobradinha-lideranca-impl.md).
**Recently resolved (2026-08-09):** **B174 reverso** (sem Issue — pedido direto) — a Sollinha (`/campanha/api/ai-chat`) voltou do `@ai-sdk/deepinfra` para o **`@ai-sdk/deepseek`** (API oficial `api.deepseek.com`): o cache-hit do V4-Flash direto custa **$0.0028/M** contra **$0.018/M** na Deep Infra (~6,4× mais barato), e a Sollinha tem prefixo estável (system prompt + tools + histórico) — o hit ratio alto derruba o custo efetivo mesmo com miss ($0.14 vs $0.09) e output ($0.28 vs $0.18) mais caros. Mesmo modelo (`deepseek-v4-flash`), mesmas tools; `@ai-sdk/deepseek` **pinado em `3.0.19`** (não `^3.0.19`, que flutuaria a 3.0.26 e puxaria a ferroviária `provider@4.0.7` duplicada — o lock ficou na `4.0.4/5.0.18` do `ai@7.0.47`, verificado no lockfile). **STT fica na Deep Infra** (B173): `whisper-large-v3` a $0.00045/min de áudio é o mais barato verificado (Groq $0.0019, OpenAI $0.006); `.env.example` agora documenta **as duas chaves** — `DEEPSEEK_API_KEY` (LLM) e `DEEPINFRA_API_KEY` (STT). Aviso da DeepSeek: aumento de preço anunciado ("em breve"). Sem migration, access, UI ou teste novo; smoke de deploy = 2–3 perguntas forçando tool calling real antes de confiar.
diff --git a/docs/plans/sollinha-tools-eleitorais-leader-lockdown-impl.md b/docs/plans/sollinha-tools-eleitorais-leader-lockdown-impl.md
new file mode 100644
index 00000000..09a6cc29
--- /dev/null
+++ b/docs/plans/sollinha-tools-eleitorais-leader-lockdown-impl.md
@@ -0,0 +1,94 @@
+# Impl: B180 — Sollinha: travar tools eleitorais legadas (getTopDeputies/getMunicipalityVotes) contra leader
+
+Status: aprovado
+Atualizado em: 2026-08-09
+Issue: #474
+Intenção: docs/plans/sollinha-tools-eleitorais-leader-lockdown.md
+Appetite restante: ~0,5 dia eng (herdado); sem migration / Consent / collection / UI
+
+## Registro de execução (2026-08-09)
+
+- **Entrega:** `electionDataGate(ctx)` novo (`src/utilities/ai/tools/electionDataGate.ts`, `true | { error }` — try/catch do `assertCanReadElectionData` + mensagem pt-BR com fonte única); gate no topo do `execute` das 2 tools legadas (`getTopDeputies`, `getMunicipalityVotes`); `getLeadingMunicipalityCities` (B177) refatorado do inline para o helper. Nenhuma outra mudança (payload queries, bypasses justificados, prompt e registro intactos).
+- **Divergência da hipótese da intenção:** nenhuma nos aceites; a decisão de extrair o helper (regra 3+ = extrair, já sinalizada na intenção) foi executada como recomendado no plano.
+- **Cobertura por tests:** `tests/unit/electionToolsLockdown.unit.spec.ts` (11 testes) — helper (leader nega com o shape do chat / staff passa), as 3 tools com `ctx.user` leader + payload stub que **lança se consultado** (prova fail-closed antes de qualquer query; `getLeadingMunicipalities` com candidato não-campanha para forçar o path SQL), staff-proceeds parametrizado por coordinator/advisor/candidate com `find → { docs: [] }` (município real resolve pelo catálogo; shape vazio normal).
+- **Simplify:** fixes do review aplicados — comentários de bypass das 2 tools legadas corrigidos (diziam "collection-level canReadElectionData guard", mas `overrideAccess: true` ignora a collection; o gate real agora é o `electionDataGate` no tool level); lockdown de `getLeadingMunicipalities` testado no path de payload (não só no artefato); staff-proceeds cobre os 3 papéis staff (não só coordinator). Rejeitado criar gate genérico HOF para outros domínios — só eleitorais têm lockdown aqui.
+- **Triage de débitos (gate humano 2026-08-09):** S1 `resolveFederalDeputy`/`resolveFromVoteRows` (exportados, `overrideAccess: true`) não têm assert próprio — único caller é o execute gateado, sem vazamento atual. **Deferido com gatilho: se um novo caller de `resolveFederalDeputy` nascer fora de um tool gateado** (ou a família eleitoral ganhar tool nova), adicionar `assertCanReadElectionData` na resolução (espelho do loader da B177) — threadando `user` na assinatura. S2–S5 descartados (cheap_polish; score ≤2).
+
+## Explicitamente fora (triage)
+
+- `resolveFederalDeputy`/`resolveFromVoteRows` sem assert próprio — deferido com gatilho (S1 acima).
+- Mensagem de erro duplicada entre o throw do assert e o gate — duas superfícies distintas (falha server vs feedback de chat); spec pina o shape do chat.
+- Boilerplate de cast do spec, stub só de `find`, staff-test do execute da B177 — polish de teste, sem valor funcional.
+- `searchEntities`/outras tools não-eleitorais para leader (intenção: fora de escopo).
+- Flake `testDatabaseLease.int.spec.ts` (S2, triage B177).
+- **Deferidos (registrados aqui, sem Issue):** nada novo; flake `testDatabaseLease` permanece fora de escopo (S2, triage B177).
+
+## Leitura da intenção
+
+- **Outcome:** `getTopDeputies` e `getMunicipalityVotes` fazem `assertCanReadElectionData(ctx.user)` no topo do `execute` (fail-closed, mesmo padrão da B177); nenhum outro comportamento muda; sem migration/Consent/collection/UI.
+- **O que NÃO negociar:** leader lockdown (liderança não conversa sobre municípios/dados eleitorais no Sollinha); staff idêntico; sem mudança no system prompt.
+- **O que reavaliar:** a sugestão de helper compartilhado — "2 call sites = inline; 3+ = extrair; aqui já são 3 com a nova" — é decisão desta engenharia (ver abaixo).
+
+## Abordagem recomendada
+
+```mermaid
+flowchart LR
+ R[api/ai-chat
buildAITools ctx] --> T1[getTopDeputies]
+ R --> T2[getMunicipalityVotes]
+ R --> T3[getLeadingMunicipalities]
+ T1 --> G[electionDataGate ctx
novo helper compartilhado]
+ T2 --> G
+ T3 --> G
+ G -- leader/deny --> E[{ error: 'Leitura de dados eleitorais negada.' }]
+ G -- staff --> Q[payload.find
comportamento atual intacto]
+```
+
+**Opções consideradas:**
+
+- **A — Inline nas 2 tools legadas:** replicar o try/catch da B177 em `getTopDeputies` e `getMunicipalityVotes` (3 cópias no total, uma por tool).
+- **B — Helper compartilhado `electionDataGate(ctx)`** em `src/utilities/ai/tools/electionDataGate.ts` (retorna `true | { error }`), usado pelas 3 tools — incluindo refatorar `getLeadingMunicipalities` para usá-lo no lugar do try/catch inline da B177.
+- **C — Gate só na collection (tirar `overrideAccess`):** descartada — a Local API sem `req.user` trata como anônimo e as tools chamam `payload.find` sem contexto autenticado; `canReadElectionData` nega anônimo, então remover o bypass quebraria **staff** também (e o comentário existente já justifica o bypass: "access gated at the tool level").
+
+**Recomendação: B.** A intenção fixa a regra 2=inline/3+=extrair e o padrão chega a 3 call sites; o texto de erro é um contrato fail-closed do chat e merece uma única fonte de verdade. `electionDataGate` é um módulo raso mas de profundidade real: centraliza o try/catch + a mensagem e encapsula o conhecimento "deny no chat = mensagem pt-BR fixa" que hoje vazaria em 3 tools. **Rejeitada:** A (3 cópias da mensagem; drift futuro no texto), C (quebra staff — a Local API das tools não carrega usuário autenticado).
+
+### Componentes / mudanças
+
+- **`electionDataGate`** (`src/utilities/ai/tools/electionDataGate.ts`, novo): `(ctx: AIToolContext): true | { error: string }` — `try { assertCanReadElectionData(ctx.user); return true } catch { return { error: 'Leitura de dados eleitorais negada.' } }`. Sem `server-only` (não importa payload/db-postgres em runtime — só `campaignAccess` + tipo; o guard de convenção só exige `server-only` para import de payload/next/db-postgres). Sem migration.
+- **`getTopDeputies`** (`src/utilities/ai/tools/getTopDeputies.ts`): primeira instrução do `execute`: `const gate = electionDataGate(ctx); if (gate !== true) return gate`. O resto intacto (bypass justificado continua no `payload.find`).
+- **`getMunicipalityVotes`** (`src/utilities/ai/tools/getMunicipalityVotes.ts`): idem — gate no topo do `execute`, antes de qualquer resolução de município.
+- **`getLeadingMunicipalities`** (`src/utilities/ai/tools/getLeadingMunicipalities.ts`): substituir o try/catch inline (linhas 342–346) pelo helper; sem outras mudanças.
+- **Access / Consent:** reusa `assertCanReadElectionData` (única redação da regra, `src/utilities/access/elections.ts`); nenhuma chave de Consent.
+- **UI:** Impeccable A — N/A; resposta de erro já é o shape `{ error }` do chat.
+
+### Dados → forma
+
+N/A — sem dados novos na UI; a mensagem de deny já existe em produção na B177.
+
+## Fases verificáveis
+
+1. **Tracer / server** — helper + gates nas 2 tools legadas + refactor da B177; unit tests novos (`tests/unit/electionToolsLockdown.unit.spec.ts`):
+ - helper: leader → `{ error }`, coordinator → `true`;
+ - as 3 tools com `ctx.user` leader → `{ error: 'Leitura de dados eleitorais negada.' }` e payload stub **não tocado** (fail-closed antes de qualquer query);
+ - staff-proceeds: `getTopDeputies`/`getMunicipalityVotes` com coordinator + payload stub (`find` → `{ docs: [] }`) retornam o shape vazio normal — prova que staff não mudou (nome real de município, e.g. "Feira de Santana", para os resolves puros).
+2. **Gates** — `pnpm gate:fast` na iteração; entrega com `pnpm push`.
+3. **Docs** — entrada curta em `docs/CHANGELOG-AGENTS.md` + este impl plan commitado.
+
+## Rabbit holes / Não escopo (engenharia)
+
+- Não criar "chat gate" genérico (HOF `withX`) para outras domains — só as eleitorais têm lockdown aqui; `calculate`/`searchEntities` etc. continuam como estão (intenção: fora de escopo).
+- Não tocar no system prompt, nem em `buildAITools`/route (nenhuma mudança de registro).
+- Não mexer em `searchEntities`/outras tools não-eleitorais (explicitamente fora da intenção).
+- Não introduzir `server-only` nas tools legadas (não importam payload/db-postgres; o guard de convenção não pede).
+
+## Riscos e mitigação
+
+- **Refactor da B177 (merged ontem):** padrão idêntico, troca mecânica; mitigado por unit test do lockdown nas 3 tools + staff-proceeds.
+- **Drift de mensagem:** a string de erro passa a ter uma única fonte (o helper); os testes pinam o texto nas 3 tools.
+- **Gate antes da resolução de input:** leader recebe deny mesmo com município inválido — intencional (fail-closed, sem leak de comportamento).
+- **Flake de CI `testDatabaseLease.int.spec.ts`:** fora de escopo, descartado no triage da B177 (S2); reavaliar se recorrer.
+
+## Aceite de engenharia
+
+- [ ] Aceite de produto da intenção coberto (2 tools legadas gateadas; leader nega; staff idêntico; sem migration/Consent/UI/prompt)
+- [ ] Invariantes AGENTS/engineering-standards (access por `assertCanReadElectionData` existente; copy pt-BR; identificadores em inglês)
+- [ ] Testes previstos: unit (lockdown nas 3 tools + helper + staff-proceeds) — onde o path de acesso muda
diff --git a/src/utilities/ai/tools/electionDataGate.ts b/src/utilities/ai/tools/electionDataGate.ts
new file mode 100644
index 00000000..a1696f31
--- /dev/null
+++ b/src/utilities/ai/tools/electionDataGate.ts
@@ -0,0 +1,20 @@
+import type { AIToolContext } from '@/lib/ai/types'
+import { assertCanReadElectionData } from '@/utilities/campaignAccess'
+
+/**
+ * Fail-closed election-data gate for the AI chat tools. Returns `true` for
+ * staff actors and the chat-shaped denial for everyone else (leader lockdown:
+ * "liderança não tem essa conversa"). The error object is the exact shape
+ * tools return, so call sites just early-return it — one source of truth for
+ * the message across the three election tools.
+ */
+export const electionDataGate = (
+ ctx: AIToolContext,
+): true | { error: 'Leitura de dados eleitorais negada.' } => {
+ try {
+ assertCanReadElectionData(ctx.user)
+ return true
+ } catch {
+ return { error: 'Leitura de dados eleitorais negada.' }
+ }
+}
diff --git a/src/utilities/ai/tools/getLeadingMunicipalities.ts b/src/utilities/ai/tools/getLeadingMunicipalities.ts
index 906bce73..b5b42264 100644
--- a/src/utilities/ai/tools/getLeadingMunicipalities.ts
+++ b/src/utilities/ai/tools/getLeadingMunicipalities.ts
@@ -20,6 +20,7 @@ import {
type LeadingMunicipalityRow,
} from '@/lib/leadingMunicipalities'
import { municipalityCatalogEntriesForCity } from '@/lib/municipalityCatalog'
+import { electionDataGate } from '@/utilities/ai/tools/electionDataGate'
import { assertCanReadElectionData } from '@/utilities/campaignAccess'
import { drizzleResultRows } from '@/utilities/drizzleBulk'
@@ -339,11 +340,8 @@ export const getLeadingMunicipalities = (ctx: AIToolContext) =>
}),
execute: async ({ candidate, year, topN }) => {
// Leader lockdown: municipal ranking conversations are staff-only.
- try {
- assertCanReadElectionData(ctx.user)
- } catch {
- return { error: 'Leitura de dados eleitorais negada.' }
- }
+ const gate = electionDataGate(ctx)
+ if (gate !== true) return gate
if (!VOTE_YEARS.includes(year)) {
return {
diff --git a/src/utilities/ai/tools/getMunicipalityVotes.ts b/src/utilities/ai/tools/getMunicipalityVotes.ts
index 411feb4f..7b9a03e7 100644
--- a/src/utilities/ai/tools/getMunicipalityVotes.ts
+++ b/src/utilities/ai/tools/getMunicipalityVotes.ts
@@ -11,6 +11,7 @@ import {
} from '@/lib/electionResults'
import { municipalityCatalogEntriesForCity } from '@/lib/municipalityCatalog'
import { resolveMunicipalityName } from '@/lib/municipalityNameAliases'
+import { electionDataGate } from '@/utilities/ai/tools/electionDataGate'
import { municipalityElectionGeographyForSlug } from '@/utilities/municipality/municipalityElectionGeography'
const VOTE_YEARS = [ELECTION_YEAR_2014, ELECTION_YEAR_2018, ELECTION_YEAR_2022] as const
@@ -31,6 +32,10 @@ export const getMunicipalityVotes = (ctx: AIToolContext) =>
municipality: z.string().describe('Municipality name or slug.'),
}),
execute: async ({ municipality }) => {
+ // Leader lockdown: municipal election conversations are staff-only.
+ const gate = electionDataGate(ctx)
+ if (gate !== true) return gate
+
const { payload } = ctx
// Resolve to catalog entry
@@ -80,9 +85,7 @@ async function queryVotes(
limit: 0,
pagination: false,
select: { votes: true },
- // Bypass: election data is public TSE results; access gated by
- // the collection-level canReadElectionData guard. Query scope
- // was resolved against the authenticated user's municipality.
+ // Bypass: public TSE vote rows; access gated by electionDataGate at the tool level.
overrideAccess: true,
}),
payload.find({
@@ -100,8 +103,7 @@ async function queryVotes(
limit: 0,
pagination: false,
select: { votosValidos: true },
- // Bypass: election data is public TSE results; access gated by
- // the collection-level canReadElectionData guard.
+ // Bypass: public TSE tally rows; access gated by electionDataGate at the tool level.
overrideAccess: true,
}),
])
diff --git a/src/utilities/ai/tools/getTopDeputies.ts b/src/utilities/ai/tools/getTopDeputies.ts
index 443d22de..5022de15 100644
--- a/src/utilities/ai/tools/getTopDeputies.ts
+++ b/src/utilities/ai/tools/getTopDeputies.ts
@@ -5,6 +5,7 @@ import type { AIToolContext } from '@/lib/ai/types'
import { ELECTION_YEAR_2022, FEDERAL_DEPUTY_OFFICE } from '@/lib/electionResults'
import { municipalityCatalogEntriesForCity } from '@/lib/municipalityCatalog'
import { resolveMunicipalityName } from '@/lib/municipalityNameAliases'
+import { electionDataGate } from '@/utilities/ai/tools/electionDataGate'
import { municipalityElectionGeographyForSlug } from '@/utilities/municipality/municipalityElectionGeography'
type RankedDeputy = {
@@ -32,6 +33,10 @@ export const getTopDeputies = (ctx: AIToolContext) =>
limit: z.number().optional().default(5).describe('Number of deputies to return (max 20).'),
}),
execute: async ({ municipality, year, limit }) => {
+ // Leader lockdown: municipal election conversations are staff-only.
+ const gate = electionDataGate(ctx)
+ if (gate !== true) return gate
+
const { payload } = ctx
const canonical = resolveMunicipalityName(municipality) ?? municipality
const entries = municipalityCatalogEntriesForCity(canonical)
@@ -63,8 +68,7 @@ export const getTopDeputies = (ctx: AIToolContext) =>
limit: 0,
pagination: false,
select: { candidateNumber: true, candidateName: true, party: true, votes: true },
- // Bypass: election data is public TSE results; access gated by
- // the collection-level canReadElectionData guard.
+ // Bypass: public TSE vote rows; access gated by electionDataGate at the tool level.
overrideAccess: true,
sort: '-votes',
})
diff --git a/tests/unit/electionToolsLockdown.unit.spec.ts b/tests/unit/electionToolsLockdown.unit.spec.ts
new file mode 100644
index 00000000..604d02e6
--- /dev/null
+++ b/tests/unit/electionToolsLockdown.unit.spec.ts
@@ -0,0 +1,105 @@
+import type { Payload } from 'payload'
+import { describe, expect, it } from 'vitest'
+
+import type { AIToolContext } from '@/lib/ai/types'
+import type { CampaignUser } from '@/payload-types'
+import { electionDataGate } from '@/utilities/ai/tools/electionDataGate'
+import { getLeadingMunicipalities } from '@/utilities/ai/tools/getLeadingMunicipalities'
+import { getMunicipalityVotes } from '@/utilities/ai/tools/getMunicipalityVotes'
+import { getTopDeputies } from '@/utilities/ai/tools/getTopDeputies'
+
+import { stub } from '../helpers/stub'
+
+const denied = { error: 'Leitura de dados eleitorais negada.' }
+
+/** A payload whose find throws — any query after a failed gate is a bug. */
+const untouchablePayload = stub({
+ find: () => {
+ throw new Error('gate must fail closed before any payload query')
+ },
+})
+
+const ctxFor = (user: CampaignUser, payload: Payload): AIToolContext => ({ user, payload })
+
+const leader = stub({ collection: 'campaignUser', role: 'leader' })
+const coordinator = stub({ collection: 'campaignUser', role: 'coordinator' })
+
+const emptyDocsPayload = stub({
+ find: async () => ({
+ docs: [],
+ hasNextPage: false,
+ hasPrevPage: false,
+ limit: 0,
+ nextPage: null,
+ pagingCounter: 0,
+ prevPage: null,
+ totalDocs: 0,
+ totalPages: 0,
+ }),
+})
+
+type ExecutableTool = {
+ execute: (args: unknown, options?: unknown) => Promise
+}
+
+describe('electionDataGate (B180)', () => {
+ it('denies a leader with the chat-shaped error', () => {
+ expect(electionDataGate(ctxFor(leader, untouchablePayload))).toEqual(denied)
+ })
+
+ it('lets staff pass', () => {
+ expect(electionDataGate(ctxFor(coordinator, untouchablePayload))).toBe(true)
+ })
+})
+
+describe('election tools leader lockdown (B180)', () => {
+ it('getTopDeputies fails closed before any payload query', async () => {
+ const execute = (
+ getTopDeputies(ctxFor(leader, untouchablePayload)) as unknown as ExecutableTool
+ ).execute
+ expect(execute).toBeTypeOf('function')
+ await expect(execute!({ municipality: 'Feira de Santana' })).resolves.toEqual(denied)
+ })
+
+ it('getMunicipalityVotes fails closed before any payload query', async () => {
+ const execute = (
+ getMunicipalityVotes(ctxFor(leader, untouchablePayload)) as unknown as ExecutableTool
+ ).execute
+ expect(execute).toBeTypeOf('function')
+ await expect(execute!({ municipality: 'Feira de Santana' })).resolves.toEqual(denied)
+ })
+
+ it('getLeadingMunicipalities fails closed before any payload query', async () => {
+ const execute = (
+ getLeadingMunicipalities(ctxFor(leader, untouchablePayload)) as unknown as ExecutableTool
+ ).execute
+ expect(execute).toBeTypeOf('function')
+ // A non-campaign candidate forces the SQL/payload path — the default
+ // candidate reads the committed artifact and never touches payload.
+ await expect(execute!({ candidate: '5000' })).resolves.toEqual(denied)
+ })
+})
+
+describe('election tools staff behavior unchanged (B180)', () => {
+ const staffRoles = ['coordinator', 'advisor', 'candidate'] as const
+ const staffUser = (role: (typeof staffRoles)[number]) =>
+ stub({ collection: 'campaignUser', role })
+
+ for (const role of staffRoles) {
+ it(`getTopDeputies still resolves a municipality for ${role}`, async () => {
+ const execute = (
+ getTopDeputies(ctxFor(staffUser(role), emptyDocsPayload)) as unknown as ExecutableTool
+ ).execute
+ const result = await execute!({ municipality: 'Feira de Santana' })
+ expect(result).toMatchObject({ municipality: 'Feira de Santana', topDeputies: [] })
+ })
+
+ it(`getMunicipalityVotes still resolves a municipality for ${role}`, async () => {
+ const execute = (
+ getMunicipalityVotes(ctxFor(staffUser(role), emptyDocsPayload)) as unknown as ExecutableTool
+ ).execute
+ const result = await execute!({ municipality: 'Feira de Santana' })
+ expect(result).toMatchObject({ municipality: 'Feira de Santana' })
+ })
+ }
+})