diff --git a/docs/changelog/2026-08-24-c144.md b/docs/changelog/2026-08-24-c144.md
new file mode 100644
index 00000000..6c9d0ea7
--- /dev/null
+++ b/docs/changelog/2026-08-24-c144.md
@@ -0,0 +1 @@
+**C144 — Formulários de escrita com options write-scoped (municípios só do escopo de edição) (2026-08-24):** `loadWritableMunicipalityOptions` irmã de `loadMunicipalityOptions` em `src/utilities/campaignRelationOptions.ts` resolve o eixo Edição via `getWritableMunicipalityIds` (C141): irrestrito/edita-tudo delega ao loader read; somente_leitura → `[]` sem fetch; carteira → `id: { in }` ordenado por nome. Call sites de escrita trocados (atividade create com split dos filtros read em `atividades/page.tsx`, atividade edit, demanda nova, organização nova+edita, liderança nova+ficha, apoiador novo via `loadSupporterCreatePageData`); giro composer ganha `writeScope` em `loadVisitPlannerRegions`/`loadVisitCandidates` (escopa o fetch, dossier `loadMunicipalityVisitEligibility` segue read); células chip de lideranças e dobradinhas passam a `addableIds` do write scope com guardas (dobradinhas mantém a lente de leitura da carteira para visão-carteira). Sem migration, sem mudança de enforcement. Int pins novos em `campaignAdvisorPermission.int.spec.ts` (matriz do loader, giro scoped vs dossier read, page-data de supporter). `pessoas` fica como follow-up C145 (#878). Plano: `docs/plans/escopo-formularios-escrita-perfil-impl.md`.
diff --git a/docs/plans/escopo-formularios-escrita-perfil-impl.md b/docs/plans/escopo-formularios-escrita-perfil-impl.md
new file mode 100644
index 00000000..c7da2aee
--- /dev/null
+++ b/docs/plans/escopo-formularios-escrita-perfil-impl.md
@@ -0,0 +1,100 @@
+# Impl: Escala pós-C141 — options write-scoped para formulários de escrita
+
+Status: em execução
+Atualizado em: 2026-08-24
+Issue: #769
+Intenção: docs/plans/escopo-formularios-escrita-perfil.md
+Appetite restante: ~0,5–1 dia eng (herdado)
+
+## Leitura da intenção
+
+- **Outcome:** os pickers de município dos formulários de ESCRITA oferecem apenas municípios do escopo de escrita do ator (`getWritableMunicipalityIds`): irrestrito/tudo → todos; carteira → carteira; somente_leitura → `[]` (não abre o form é C142; aqui só não oferecer nada). Superfícies: atividade (create/update + giro), demanda (create), apoiador (novo), liderança (create/edit), organização (create/edit).
+- **O que NÃO negociar:** read surfaces intocadas (listas, filtros, agenda, dossier, mapa) — `loadMunicipalityOptions` continua read-scoped para elas; nenhuma mudança de enforcement (C141 já fail-closed: `activity.ts:128-131,213-216`, `assertMunicipalitiesWithinScope` `leadership.ts:65-80`, ficha `assertPersonContactWritable`, access de demands/supporters/organizations); sem migration; sem mudar semântica de `getWritableMunicipalityIds`/`getAdvisorMunicipalityIds`.
+- **O que reavaliar:** o giro não usa `loadMunicipalityOptions` (usa `loadVisitPlannerRegions`/`loadVisitCandidates`, read-scoped) e as células chip (`MunicipalityPortfolioCell`/`PeopleMunicipalityCell`) recebem `addableIds` de `getAdvisorMunicipalityIds` (READ). Ambos são superfícies de escrita e entram — com o menor delta possível e guardas para não regredir leitura.
+
+## Abordagem recomendada
+
+```mermaid
+flowchart TD
+ A[Write-form page / giro / chip list] --> B{superfície}
+ B -->|forms create/edit| C[loadWritableMunicipalityOptions]
+ B -->|giro composer| D[loadVisitPlannerRegions / loadVisitCandidates + writeScope]
+ B -->|chip addableIds| E[getWritableMunicipalityIds]
+ C --> F[getWritableMunicipalityIds]
+ F -->|null irrestrito/tudo| G[find all, sort name]
+ F -->|[] somente_leitura| H[return [] / skip fetch]
+ F -->|carteira ids| I[find where id in ids, sort name]
+ E --> J[guards de edição: canEditAny / length]
+ D --> K[default read: unchanged; writeScope: id in ids no where]
+```
+
+**Opções (loader):** A) `scope: 'read'|'write'` param no `loadMunicipalityOptions` | B) irmã `loadWritableMunicipalityOptions` em `campaignRelationOptions.ts` | C) filtragem client-side da lista read
+**Recomendação: B** — call sites read ficam literalmente intocados (sem chance de widening acidental por param default), nome espelha `getWritableMunicipalityIds`, grepável; call sites de escrita trocam o import. Verificado: variante read ordena `sort: 'name'`, `select: {name:true}`, `overrideAccess:false` (`campaignRelationOptions.ts:15-26`); `municipality.id` é `number` (Payload numeric id; `getAdvisorMunicipalityIds` já filtra `relationshipId` → `number[]`).
+**Alternativas rejeitadas:** A porque todo call site read passaria a carregar o param e a intenção diz "read surfaces NÃO tocar" — B torna o contrato read imutável por construção; C rejeitada porque vaza o catálogo completo para o client e viola o espírito fail-closed.
+
+**Opções (giro):** 1) write-scope nos loaders via param opcional | 2) fora do escopo, deixar o guard batch C141 como enforcement
+**Recomendação: 1** — o composer É um write-composer (stops viram `activity` drafts; `createTourDraftsFormAction` → `activity.ts:213-216`). Verificado: `loadVisitPlannerRegions` monta as TIs contando municípios da leitura read-scoped (`visitPlannerData.ts:123-146`) e `loadVisitCandidates` monta o bundle (peers de encaixe, sugestão, agregados) a partir do fetch read-scoped (`:155-279`). Para visão-tudo+edita-carteira, hoje o composer oferece os 435 e o servidor rejeita no batch. Filtrar na página rejeitado: a sugestão e os `stopPeersByRegion` seriam computados sobre o escopo read e depois descartados — a conta de "encaixe em giro" ficaria errada. Escopar o **fetch** dentro do loader faz todo o pipeline derivado (aggregates, peers, sugestão) sair consistente. Param `writeScope?: boolean` (default `false` → read) nos dois loaders; `loadMunicipalityVisitEligibility` (card do dossier, read) fica com o default e não muda.
+**Alternativa rejeitada: 2** — a intenção lista "giro" explicitamente como superfície; o custo é um param opcional + um `id: { in }` no where (ou early-return de bundle vazio `{ phase, groups: [] }`).
+
+**Opções (chips `addableIds`):** 1) trocar `getAdvisorMunicipalityIds` → `getWritableMunicipalityIds` nas páginas de lista | 2) fora do escopo
+**Recomendação: 1 parcial** — células chip são controles de escrita (add/remove de vínculos com commit fail-closed). Verificado nos três call sites:
+
+- `liderancas/page.tsx:373,434`: swap limpo — a expressão `canEditAny && administeredIds ? { addableMunicipalityIds: new Set(...) }` já omite o prop quando `canEditAny=false` (somente_leitura), então `[]` é inalcançável; carteira = mesmo conjunto de hoje; edita-tudo (`null`) → prop omitido → todos (o servidor já aceita; paridade com coordinator). Único uso da variável é :434.
+- `dobradinhas/page.tsx:323,356`: swap com guarda `writableIds && writableIds.length > 0 ? new Set(writableIds) : undefined` — sem o length-check, `[]` (somente_leitura) é truthy → `Set([])` → todas as chips somem, regressão de leitura (hoje visível = carteira).
+- `pessoas/page.tsx:544,553`: **fora do escopo** — `buildPeopleEditability` (`:145-168`) não tem branch "null = tudo": para edita-tudo, `administered=null` → `inCarteira` sempre false → edição por linha quebra (hoje `Set(carteira)` deixa linhas da carteira editáveis). O conserto é C142-shaped (branch 'tudo' no helper). Os commits da ficha continuam fail-closed (`assertPersonContactWritable`). Anotar follow-up.
+- Delta de leitura do swap em dobradinhas: somente visão-tudo+somente_leitura passa a exibir chips fora da carteira (hoje `Set(carteira)` os esconde — quirk pré-existente que contraria C141 "visão tudo vê tudo"). Direção correta, aceito e anotado.
+ **Rejeitada: 2** — o under-offer persiste para edita-tudo (picker mais estreito que o contrato do servidor); o custo do swap com guarda é ~6 linhas.
+
+### Componentes / mudanças
+
+| Símbolo | Arquivo | Responsabilidade | Reusa |
+| ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
+| ✚ | `src/utilities/campaignRelationOptions.ts` | `loadWritableMunicipalityOptions(payload, user)`: resolve `getWritableMunicipalityIds`; `null` → delega a `loadMunicipalityOptions` (query idêntica); `[]` → `[]` sem fetch; ids → `find({ where: { id: { in: ids } }, sort: 'name', select: { name: true }, overrideAccess: false, user })` | `getWritableMunicipalityIds` (via `campaignAccess`, import já existente no módulo — sem ciclo novo) |
+| ✎ | `src/app/(campaign)/campanha/(app)/atividades/page.tsx:60` | **split obrigatório**: `municipalityOptions` (read) fica para `ActivityFilters` (:89-93); novo `loadWritableMunicipalityOptions` alimenta `ActivityCreateOverlayHost` (:79-84) | ambos loaders no mesmo `Promise.all` |
+| ✎ | `.../atividades/[slug]/page.tsx:100` | `ActivityEditOverlayHost` passa a receber write-scoped (detail não tem filtro — troca direta) | — |
+| ✎ | `src/utilities/visit/visitPlannerData.ts:123-279` | `writeScope?: boolean` em `loadVisitPlannerRegions` e `loadVisitCandidates`: quando true, resolve writable e (a) `[]` → early `[]` / `{ phase, groups: [] }`, (b) ids → `{ id: { in: ids } }` no where (regions) / `and` com o where de região (candidates); `null` → inalterado | `getWritableMunicipalityIds` |
+| ✎ | `.../atividades/giros/page.tsx:90-93` | passa `{ writeScope: true }` nos dois loaders | — |
+| ✎ | `demandas/nova/page.tsx:33`, `organizacoes/nova/page.tsx:22`, `organizacoes/[slug]/page.tsx:51`, `liderancas/nova/page.tsx:32`, `liderancas/[id]/page.tsx:66` | trocam import/call para o loader write-scoped (forms de create/edit) | — |
+| ✎ | `src/utilities/supporter/supporterPageData.ts:162` | `loadSupporterCreatePageData` usa write-scoped; **:115 (`loadSupportersPageData` → filtros) permanece read** | — |
+| ✎ | `liderancas/page.tsx:373,434`, `dobradinhas/page.tsx:323,356` | `addableIds` de `getWritableMunicipalityIds` (com guardas descritas acima) | — |
+| ✓ | `pessoas/page.tsx`, `agenda/page.tsx:55`, `supporterPageData.ts:115`, `atividades/page.tsx` filtros, dossier | intocados | — |
+
+Migration: sem migration. Access/Consent: sem mudança (enforcement C141 intacto). UI: nenhum componente muda (options são prop-drilled; `RelationMultiSelect`/overlays idênticos).
+
+## Fases verificáveis (tracer first)
+
+1. **Tracer/server — loader + atividade + giro (onde o servidor ACEITA o mismatch)** — `loadWritableMunicipalityOptions`; split em `atividades/page.tsx`; troca no `[slug]`; `writeScope` no visit planner + giros page. Tester int: para advisor visão-tudo+edita-carteira, picker de atividade e candidatos/regiões de giro refletem o eixo (contêm só a carteira); para edita-tudo, todos; para somente_leitura, `[]`; `loadMunicipalityVisitEligibility`/read loaders inalterados para visão-tudo.
+2. **Demanda/apoiador/liderança/organização (UX — servidor já rejeita)** — troca nos 6 forms + chips (liderancas/dobradinhas). Tester int: `loadSupporterCreatePageData` e demais page-data utilitárias retornam options write-scoped; chips via loader direto.
+3. **Gates** — `pnpm gate:fast` (tsc, lint 0 warnings, format:check, knip, cycles, test) + `pnpm build`; `pnpm push`; flips da issue via `pnpm issue`.
+
+## Rabbit holes / Não escopo (engenharia)
+
+- Mudar enforcement C141 (nenhum): servidor já fail-closed em todas as superfícies.
+- Read surfaces: listas, filtros (atividade, agenda, apoiadores), dossier/eligibility card, mapa — intocados.
+- C142 presentation: ocultar/redirecionar forms para somente_leitura (gate `'writable'`), branch 'tudo' no `buildPeopleEditability` de pessoas, mensagens do empty state do giro ("sai da sua carteira" para perfis leitura é impreciso — C142 ajusta).
+- Filtragem client-side (vaza catálogo) e rota-optimizer/adjacência para giro.
+- Sem migration, sem schema, sem componentes novos, sem E2E novo (C142 é dono de presença; e2e HTTP existente cobre botões).
+
+## Riscos e mitigação
+
+- **Regressão de filtros read** ao dividir `atividades/page.tsx`: mitigado pela divisão explícita + pins int (read loader continua retornando tudo para visão-tudo).
+- **Armadilha de truthiness de `[]`** nos chips: `Set([])` esconde TODAS as chips (leitura some). Mitigado: liderancas mantém guarda `canEditAny &&`; dobradinhas ganha length-check. Pessoas fica fora por quebrar `buildPeopleEditability`.
+- **Usuário stale** (visibility/editing): `requireCampaignPageActor` → `getCampaignUser` faz `findByID` por request (`campaignAuth.ts:97-101`) — usuário é fresco; `getWritableMunicipalityIds` documenta exigência.
+- **Vazamento de escopo no giro**: `loadMunicipalityVisitEligibility` compartilha `loadVisitCandidates` — default read mantém o card do dossier; pin int explícito.
+- **Registro editável com município fora do writable** (criado por ator de escopo maior): chip visível no record mas não re-oferecido pelo picker após remoção — fail-safe (servidor rejeita), C142-adjacente; sem ação nesta entrega.
+- **Ciclo de imports**: `campaignRelationOptions.ts` já importa de `campaignAccess` (linha 8); visit planner importa `getWritableMunicipalityIds` de `campaignAccess` — sem novo top-level module (`src/utilities` pinnado), knip satisfeito.
+
+## Aceite de engenharia (checkboxes)
+
+- [x] `loadWritableMunicipalityOptions` em `campaignRelationOptions.ts` (null→delega; []→[] sem fetch; ids→`id: { in }`, `sort: 'name'`)
+- [x] `atividades/page.tsx`: overlay create write-scoped; **filtros continuam read**
+- [x] `atividades/[slug]/page.tsx`: overlay edit write-scoped
+- [x] Giro: `writeScope` em `loadVisitPlannerRegions`/`loadVisitCandidates`; giros page passa true; dossier read intacto
+- [x] `demandas/nova`, `organizacoes/nova`, `organizacoes/[slug]`, `liderancas/nova`, `liderancas/[id]`: forms write-scoped
+- [x] `loadSupporterCreatePageData` write-scoped; `loadSupportersPageData` read intacto
+- [x] Chips: `liderancas` (swap com `canEditAny &&`), `dobradinhas` (swap com length-guard); `pessoas` fora (anotado como C142-shaped)
+- [x] Int pins em `campaignAdvisorPermission.int.spec.ts`: matriz do loader (carteira/tudo/leitura/coordinator), giro scoped vs read, page-data de supporter reflete o eixo; unit: sem mudança (loader é Payload-coupled → int); E2E: não novo
+- [x] Gates: `tsc --noEmit`, `pnpm lint` 0 warnings, `pnpm format:check`, knip (pré-existente no worktree — falha igual no base limpo), `pnpm check:cycles`, `pnpm test` (unit 2465 + int 833), `pnpm build`; e2e da superfície afetada 6/6
+- [ ] Changelog: entrada curta em `docs/changelog/2026-08-24-c144.md`; push + flips da issue
+
+**Self-score (decision-quality): 5/5** — opções (loader irmã vs param; giro writeScope no fetch; chips parcial com guardas) decididas contra o código real verificado (sort/select da variante read, tipos de id, guardas `canEditAny`/length e ausência de branch "null=tudo" em `buildPeopleEditability`, frescor do user por página), com rejeitadas justificadas e sem regressão de read surface.
diff --git a/src/app/(campaign)/campanha/(app)/atividades/[slug]/page.tsx b/src/app/(campaign)/campanha/(app)/atividades/[slug]/page.tsx
index 18fadcfb..c688f5f1 100644
--- a/src/app/(campaign)/campanha/(app)/atividades/[slug]/page.tsx
+++ b/src/app/(campaign)/campanha/(app)/atividades/[slug]/page.tsx
@@ -23,8 +23,8 @@ import { campaignPageMetadata } from '@/lib/campaignPageChrome'
import { loadAccessibleActivityTags } from '@/utilities/activityPageData'
import { formatActivityWhenLabel } from '@/utilities/activityViewModels'
import {
- loadMunicipalityOptions,
loadOrganizationOptions,
+ loadWritableMunicipalityOptions,
} from '@/utilities/campaignRelationOptions'
import { getActivityDetailPageData } from '@/utilities/activityDetailPageData'
@@ -97,7 +97,7 @@ export default async function ActivityDetailPage({
if (canonicalTabRedirect) redirect(canonicalTabRedirect)
const [municipalityOptions, organizationOptions, knownTags] = await Promise.all([
- loadMunicipalityOptions(payload, user),
+ loadWritableMunicipalityOptions(payload, user),
loadOrganizationOptions(payload, user),
loadAccessibleActivityTags(payload, user),
])
diff --git a/src/app/(campaign)/campanha/(app)/atividades/giros/page.tsx b/src/app/(campaign)/campanha/(app)/atividades/giros/page.tsx
index 8e99d26b..f68a65ab 100644
--- a/src/app/(campaign)/campanha/(app)/atividades/giros/page.tsx
+++ b/src/app/(campaign)/campanha/(app)/atividades/giros/page.tsx
@@ -88,8 +88,8 @@ export default async function TourComposerPage({ searchParams }: TourComposerPag
const now = new Date()
const [regions, bundle] = await Promise.all([
- loadVisitPlannerRegions(payload, user),
- region ? loadVisitCandidates(payload, user, { region, now }) : null,
+ loadVisitPlannerRegions(payload, user, { writeScope: true }),
+ region ? loadVisitCandidates(payload, user, { region, now, writeScope: true }) : null,
])
const candidates = bundle?.groups.flatMap((group) => group.candidates) ?? []
const suggestion = composeTourSuggestion(candidates)
diff --git a/src/app/(campaign)/campanha/(app)/atividades/page.tsx b/src/app/(campaign)/campanha/(app)/atividades/page.tsx
index f52fef4d..d06909cb 100644
--- a/src/app/(campaign)/campanha/(app)/atividades/page.tsx
+++ b/src/app/(campaign)/campanha/(app)/atividades/page.tsx
@@ -30,6 +30,7 @@ import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
import {
loadMunicipalityOptions,
loadOrganizationOptions,
+ loadWritableMunicipalityOptions,
} from '@/utilities/campaignRelationOptions'
import { TOUR_COMPOSER_PATH } from '@/utilities/visit/visitPlannerUrl'
@@ -54,13 +55,19 @@ export default async function ActivityListPage({ searchParams }: ActivityListPag
user.role === 'advisor' ? advisorEditingScope(user.visibility, user.editing) : 'tudo'
const now = new Date()
- const [{ result, state }, municipalityOptions, organizationOptions, knownTags] =
- await Promise.all([
- loadActivityListPageData(payload, user, rawSearchParams, now),
- loadMunicipalityOptions(payload, user),
- loadOrganizationOptions(payload, user),
- loadAccessibleActivityTags(payload, user),
- ])
+ const [
+ { result, state },
+ municipalityOptions,
+ writableMunicipalityOptions,
+ organizationOptions,
+ knownTags,
+ ] = await Promise.all([
+ loadActivityListPageData(payload, user, rawSearchParams, now),
+ loadMunicipalityOptions(payload, user),
+ loadWritableMunicipalityOptions(payload, user),
+ loadOrganizationOptions(payload, user),
+ loadAccessibleActivityTags(payload, user),
+ ])
const resolvedUrl = resolveActivityListUrl(rawSearchParams, result.totalPages)
if (resolvedUrl.redirectHref) redirect(resolvedUrl.redirectHref)
@@ -78,7 +85,7 @@ export default async function ActivityListPage({ searchParams }: ActivityListPag
diff --git a/src/app/(campaign)/campanha/(app)/demandas/nova/page.tsx b/src/app/(campaign)/campanha/(app)/demandas/nova/page.tsx
index be7565a8..d317dc38 100644
--- a/src/app/(campaign)/campanha/(app)/demandas/nova/page.tsx
+++ b/src/app/(campaign)/campanha/(app)/demandas/nova/page.tsx
@@ -11,7 +11,7 @@ import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
import { loadActivityRelationOptionById } from '@/utilities/activityRelationOptions'
import { firstValue, strictDecimalInteger } from '@/utilities/campaignListUrl'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
-import { loadMunicipalityOptions } from '@/utilities/campaignRelationOptions'
+import { loadWritableMunicipalityOptions } from '@/utilities/campaignRelationOptions'
import { createDemandFormAction } from './formActions'
export const metadata = campaignPageMetadataFromCatalog('demandasNova')
@@ -30,7 +30,7 @@ export default async function NewDemandPage({ searchParams }: NewDemandPageProps
const requestedActivityId = strictDecimalInteger(firstValue(query.activity))
const requestedMunicipalityId = strictDecimalInteger(firstValue(query.municipality))
const [municipalityOptions, initialActivity] = await Promise.all([
- loadMunicipalityOptions(payload, user),
+ loadWritableMunicipalityOptions(payload, user),
requestedActivityId
? loadActivityRelationOptionById(payload, user, requestedActivityId)
: Promise.resolve(null),
diff --git a/src/app/(campaign)/campanha/(app)/dobradinhas/page.tsx b/src/app/(campaign)/campanha/(app)/dobradinhas/page.tsx
index 609d10ea..02bd24f0 100644
--- a/src/app/(campaign)/campanha/(app)/dobradinhas/page.tsx
+++ b/src/app/(campaign)/campanha/(app)/dobradinhas/page.tsx
@@ -42,7 +42,11 @@ import { toCampaignColumnPickerColumns } from '@/lib/campaignColumnVisibility'
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
import type { MunicipalityPortfolioIndexEntry } from '@/lib/municipalityPortfolio'
import { cn } from '@/lib/utils'
-import { getAdvisorMunicipalityIds, isCampaignUnrestricted } from '@/utilities/campaignAccess'
+import {
+ getAdvisorMunicipalityIds,
+ getWritableMunicipalityIds,
+ isCampaignUnrestricted,
+} from '@/utilities/campaignAccess'
import { readCampaignColumnVisibility } from '@/utilities/campaignColumnVisibilityCookie'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
import {
@@ -312,7 +316,7 @@ export default async function StateDeputiesPage({ searchParams }: StateDeputiesP
leadershipOptions,
advisorOptions,
municipalityIndex,
- administeredIds,
+ addableMunicipalityIds,
] = await Promise.all([
loadStateDeputyListPageData(payload, user, canonicalUrl.state),
isLeadershipVisible ? loadLeadershipOptions(payload, user) : Promise.resolve([]),
@@ -320,7 +324,14 @@ export default async function StateDeputiesPage({ searchParams }: StateDeputiesP
? loadEligibleAdvisorOptions(payload, user)
: Promise.resolve([]),
loadMunicipalityPortfolioIndex(),
- user.role === 'advisor' ? getAdvisorMunicipalityIds(payload, user.id) : null,
+ // C144 — the cell's `addableIds` is ALSO the read lens (chips outside the
+ // set hide, `undefined` shows the whole catalog): a carteira-visão advisor
+ // keeps the carteira set so somente_leitura does not regress into seeing
+ // every município; visão-tudo follows the Edição axis (null → whole
+ // catalog, [] → none offered, carteira → portfolio adds).
+ user.role === 'advisor' && user.visibility === 'carteira'
+ ? getAdvisorMunicipalityIds(payload, user.id)
+ : getWritableMunicipalityIds(payload, user),
])
const resolvedUrl = resolveStateDeputyListUrl(rawSearchParams, totalPages)
if (resolvedUrl.redirectHref) redirect(resolvedUrl.redirectHref)
@@ -353,7 +364,11 @@ export default async function StateDeputiesPage({ searchParams }: StateDeputiesP
})),
canEditAdvisors,
municipalityIndex,
- administeredIds ? new Set(administeredIds) : undefined,
+ // The length-guard keeps `[]` (no writable município) from collapsing
+ // every chip: an empty `Set` hides all municipalities from the cell.
+ addableMunicipalityIds && addableMunicipalityIds.length > 0
+ ? new Set(addableMunicipalityIds)
+ : undefined,
)
return (
diff --git a/src/app/(campaign)/campanha/(app)/liderancas/[id]/page.tsx b/src/app/(campaign)/campanha/(app)/liderancas/[id]/page.tsx
index c63de589..44a65193 100644
--- a/src/app/(campaign)/campanha/(app)/liderancas/[id]/page.tsx
+++ b/src/app/(campaign)/campanha/(app)/liderancas/[id]/page.tsx
@@ -18,9 +18,9 @@ import { isCampaignUnrestricted } from '@/utilities/campaignAccess'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
import {
loadEligibleAdvisorOptions,
- loadMunicipalityOptions,
loadOrganizationOptions,
loadStateDeputyOptions,
+ loadWritableMunicipalityOptions,
} from '@/utilities/campaignRelationOptions'
import { loadLeadershipDetail } from '@/utilities/leadership/leadershipData'
import { loadMunicipalityPortfolioIndex } from '@/utilities/municipality/municipalityPortfolioIndex'
@@ -63,7 +63,7 @@ export default async function LeadershipDetailPage({ params }: LeadershipDetailP
const [municipalityOptions, organizationOptions, stateDeputyOptions, municipalityIndex] =
await Promise.all([
- loadMunicipalityOptions(payload, user),
+ loadWritableMunicipalityOptions(payload, user),
loadOrganizationOptions(payload, user),
loadStateDeputyOptions(payload, user),
loadMunicipalityPortfolioIndex(),
diff --git a/src/app/(campaign)/campanha/(app)/liderancas/nova/page.tsx b/src/app/(campaign)/campanha/(app)/liderancas/nova/page.tsx
index 1d5ae5fd..e2412c21 100644
--- a/src/app/(campaign)/campanha/(app)/liderancas/nova/page.tsx
+++ b/src/app/(campaign)/campanha/(app)/liderancas/nova/page.tsx
@@ -9,9 +9,9 @@ import { Button } from '@/components/ui/button'
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
import {
- loadMunicipalityOptions,
loadOrganizationOptions,
loadStateDeputyOptions,
+ loadWritableMunicipalityOptions,
} from '@/utilities/campaignRelationOptions'
import { createLeadershipFormAction } from './formActions'
@@ -29,7 +29,7 @@ export default async function NewLeadershipPage({ searchParams }: NewLeadershipP
])
const [municipalityOptions, organizationOptions, stateDeputyOptions] = await Promise.all([
- loadMunicipalityOptions(payload, user),
+ loadWritableMunicipalityOptions(payload, user),
loadOrganizationOptions(payload, user),
loadStateDeputyOptions(payload, user),
])
diff --git a/src/app/(campaign)/campanha/(app)/liderancas/page.tsx b/src/app/(campaign)/campanha/(app)/liderancas/page.tsx
index 7fec4f86..5b261dad 100644
--- a/src/app/(campaign)/campanha/(app)/liderancas/page.tsx
+++ b/src/app/(campaign)/campanha/(app)/liderancas/page.tsx
@@ -52,7 +52,7 @@ import {
import { whatsAppHrefForPhone } from '@/lib/phone'
import { MAX_LEADERSHIP_MUNICIPALITIES } from '@/lib/schemas/leadership'
import { cn } from '@/lib/utils'
-import { getAdvisorMunicipalityIds } from '@/utilities/campaignAccess'
+import { getWritableMunicipalityIds } from '@/utilities/campaignAccess'
import { readCampaignColumnVisibility } from '@/utilities/campaignColumnVisibilityCookie'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
import { loadStateDeputyOptions } from '@/utilities/campaignRelationOptions'
@@ -145,7 +145,10 @@ const leadershipColumns = ({
stateDeputyOptions: RelationCellOption[]
municipalityIndex: MunicipalityPortfolioIndexEntry[]
municipalityFilterOptions: LeadershipFilterOption[]
- /** Advisors may only link municipalities they administer; staff: undefined. */
+ /**
+ * C144 — advisors may only add municipalities in their WRITE scope
+ * (`undefined` = whole catalog: unrestricted staff and Edição "Tudo").
+ */
addableMunicipalityIds?: ReadonlySet
/** C142 — false when the advisor has `somente_leitura`: no invite, no per-row edits. */
canEditAny?: boolean
@@ -365,12 +368,12 @@ export default async function LeadershipsPage({ searchParams }: LeadershipsPageP
{ rows, totalDocs, totalPages, filterFacets },
stateDeputyOptions,
municipalityIndex,
- administeredIds,
+ writableIds,
] = await Promise.all([
loadLeadershipListPageData(payload, user, canonicalUrl.state),
isStateDeputyVisible ? loadStateDeputyOptions(payload, user) : Promise.resolve([]),
loadMunicipalityPortfolioIndex(),
- user.role === 'advisor' ? getAdvisorMunicipalityIds(payload, user.id) : null,
+ user.role === 'advisor' ? getWritableMunicipalityIds(payload, user) : null,
])
// C142 — the write scope gates the create button, the invite action, and
@@ -431,7 +434,7 @@ export default async function LeadershipsPage({ searchParams }: LeadershipsPageP
})),
municipalityIndex,
municipalityFilterOptions,
- ...(canEditAny && administeredIds ? { addableMunicipalityIds: new Set(administeredIds) } : {}),
+ ...(canEditAny && writableIds ? { addableMunicipalityIds: new Set(writableIds) } : {}),
canEditAny,
nowMs: Date.now(),
})
diff --git a/src/app/(campaign)/campanha/(app)/organizacoes/[slug]/page.tsx b/src/app/(campaign)/campanha/(app)/organizacoes/[slug]/page.tsx
index fa7d3ce8..f65c3753 100644
--- a/src/app/(campaign)/campanha/(app)/organizacoes/[slug]/page.tsx
+++ b/src/app/(campaign)/campanha/(app)/organizacoes/[slug]/page.tsx
@@ -12,7 +12,7 @@ import { Badge } from '@/components/ui/Badge'
import { campaignPageMetadata } from '@/lib/campaignPageChrome'
import { organizationKindLabels } from '@/lib/schemas/organization'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
-import { loadMunicipalityOptions } from '@/utilities/campaignRelationOptions'
+import { loadWritableMunicipalityOptions } from '@/utilities/campaignRelationOptions'
import { loadOrganizationDetail } from '@/utilities/organizationData'
import { updateOrganizationFormAction } from './formActions'
@@ -48,7 +48,7 @@ export default async function OrganizationDetailPage({ params }: OrganizationDet
const organization = await loadOrganizationDetail(payload, user, slug)
if (!organization) notFound()
- const municipalityOptions = await loadMunicipalityOptions(payload, user)
+ const municipalityOptions = await loadWritableMunicipalityOptions(payload, user)
return (
diff --git a/src/app/(campaign)/campanha/(app)/organizacoes/nova/page.tsx b/src/app/(campaign)/campanha/(app)/organizacoes/nova/page.tsx
index c0413e66..cfa5537c 100644
--- a/src/app/(campaign)/campanha/(app)/organizacoes/nova/page.tsx
+++ b/src/app/(campaign)/campanha/(app)/organizacoes/nova/page.tsx
@@ -8,7 +8,7 @@ import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell
import { Button } from '@/components/ui/button'
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
-import { loadMunicipalityOptions } from '@/utilities/campaignRelationOptions'
+import { loadWritableMunicipalityOptions } from '@/utilities/campaignRelationOptions'
import { createOrganizationFormAction } from './formActions'
export const metadata = campaignPageMetadataFromCatalog('organizacoesNova')
@@ -19,7 +19,7 @@ export default async function NewOrganizationPage() {
getPayload({ config }),
])
- const municipalityOptions = await loadMunicipalityOptions(payload, user)
+ const municipalityOptions = await loadWritableMunicipalityOptions(payload, user)
return (
diff --git a/src/utilities/campaignRelationOptions.ts b/src/utilities/campaignRelationOptions.ts
index f8d4ce7e..582a1a10 100644
--- a/src/utilities/campaignRelationOptions.ts
+++ b/src/utilities/campaignRelationOptions.ts
@@ -5,9 +5,9 @@ import type { Payload } from 'payload'
import type { RelationOption } from '@/components/campaign/shared/RelationMultiSelect'
import { populatedContactName } from '@/lib/relationship'
import type { CampaignUser } from '@/payload-types'
-import { eligibleCampaignStaffWhere } from '@/utilities/campaignAccess'
+import { eligibleCampaignStaffWhere, getWritableMunicipalityIds } from '@/utilities/campaignAccess'
-/** Municipalities the actor may operate on (coordinator: all 436; advisor: administered). */
+/** Municipalities the actor may operate on (coordinator: all 435; advisor: administered). */
export const loadMunicipalityOptions = async (
payload: Payload,
user: CampaignUser,
@@ -26,6 +26,36 @@ export const loadMunicipalityOptions = async (
return result.docs.map((municipality) => ({ id: municipality.id, name: municipality.name }))
}
+/**
+ * C144 — WRITE-scoped variant for the write forms: only municipalities the
+ * actor may write under the Edição axis (`getWritableMunicipalityIds`).
+ * `null` (unrestricted staff or `editing === 'tudo'`) delegates to the read
+ * loader (same query — the whole catalog); `[]` (`somente_leitura`) returns no
+ * options without a fetch; a carteira resolves to exactly the portfolio ids.
+ * Read surfaces keep `loadMunicipalityOptions`.
+ */
+export const loadWritableMunicipalityOptions = async (
+ payload: Payload,
+ user: CampaignUser,
+): Promise => {
+ const writableIds = await getWritableMunicipalityIds(payload, user)
+ if (writableIds === null) return loadMunicipalityOptions(payload, user)
+ if (writableIds.length === 0) return []
+
+ const result = await payload.find({
+ collection: 'municipality',
+ depth: 0,
+ limit: 0,
+ pagination: false,
+ sort: 'name',
+ select: { name: true },
+ where: { id: { in: writableIds } },
+ user,
+ overrideAccess: false,
+ })
+ return result.docs.map((municipality) => ({ id: municipality.id, name: municipality.name }))
+}
+
export const loadOrganizationOptions = async (
payload: Payload,
user: CampaignUser,
diff --git a/src/utilities/supporter/supporterPageData.ts b/src/utilities/supporter/supporterPageData.ts
index c4ef464f..928bcd1c 100644
--- a/src/utilities/supporter/supporterPageData.ts
+++ b/src/utilities/supporter/supporterPageData.ts
@@ -9,7 +9,10 @@ import {
getSupporterRegistrationConsent,
getSupporterVoteIntentionConsent,
} from '@/utilities/campaignConsent'
-import { loadMunicipalityOptions } from '@/utilities/campaignRelationOptions'
+import {
+ loadMunicipalityOptions,
+ loadWritableMunicipalityOptions,
+} from '@/utilities/campaignRelationOptions'
import { createEntityNotFoundError } from '@/utilities/entityNotFound'
import { computeSupporterListOverviewAggregate } from '@/utilities/supporter/supporterListOverviewAggregate'
import {
@@ -159,7 +162,7 @@ export const loadSupporterCreatePageData = async (
user: CampaignUser,
): Promise => {
const [municipalityOptions, registrationConsent, voteIntentionConsent] = await Promise.all([
- loadMunicipalityOptions(payload, user),
+ loadWritableMunicipalityOptions(payload, user),
getSupporterRegistrationConsent(payload),
getSupporterVoteIntentionConsent(payload),
])
diff --git a/src/utilities/visit/visitPlannerData.ts b/src/utilities/visit/visitPlannerData.ts
index 8c03ab70..3ff775b9 100644
--- a/src/utilities/visit/visitPlannerData.ts
+++ b/src/utilities/visit/visitPlannerData.ts
@@ -9,6 +9,7 @@ import { relationshipId, uniqueRelationshipIds } from '@/lib/relationship'
import type { CalendarPhase } from '@/lib/visitPlannerAnchors'
import { DEFAULT_VOTE_ESTIMATE_SCENARIO } from '@/lib/voteEstimate'
import type { CampaignUser, Municipality } from '@/payload-types'
+import { getWritableMunicipalityIds } from '@/utilities/campaignAccess'
import { computeGoalCoverage } from '@/utilities/municipality/goalCoverage'
import { loadStatewideSuggestedGoals } from '@/utilities/municipality/municipalityGoalAccount'
import {
@@ -119,17 +120,28 @@ type VisitPlannerRegionOption = {
* all 27 TIs to an advisor with a six-município portfolio would be offering
* twenty-one dead ends, and evaluating the full eligibility of the state just to
* build a picker would cost the whole planner load.
+ *
+ * C144 — `writeScope` restricts the municipalities to the actor's WRITE scope
+ * (`getWritableMunicipalityIds`): the composer is a write surface, so a
+ * Visão-Tudo/Edição-Carteira advisor must not be offered territories built
+ * over municípios the batch would reject. Default `false` keeps the read
+ * contract for `loadMunicipalityVisitEligibility` (dossier card).
*/
export const loadVisitPlannerRegions = async (
payload: Payload,
user: CampaignUser,
+ { writeScope = false }: { writeScope?: boolean } = {},
): Promise => {
+ const writableIds = writeScope ? await getWritableMunicipalityIds(payload, user) : null
+ if (writeScope && writableIds?.length === 0) return []
+
const result = await payload.find({
collection: 'municipality',
depth: 0,
limit: 0,
pagination: false,
select: { slug: true },
+ where: writableIds ? { id: { in: writableIds } } : {},
user,
overrideAccess: false,
})
@@ -150,14 +162,24 @@ export type VisitPlannerFilter = {
region?: BahiaIdentityTerritory
/** Injected so the calendar phase is testable and stable within a request. */
now?: Date
+ /** C144 — restrict to the actor's WRITE scope (write-composer surfaces). */
+ writeScope?: boolean
}
export const loadVisitCandidates = async (
payload: Payload,
user: CampaignUser,
- { region, now = new Date() }: VisitPlannerFilter = {},
+ { region, now = new Date(), writeScope = false }: VisitPlannerFilter = {},
): Promise => {
- const where: Where = region ? { region: { equals: region } } : {}
+ const writableIds = writeScope ? await getWritableMunicipalityIds(payload, user) : null
+ if (writeScope && writableIds?.length === 0) {
+ return { phase: resolveCalendarPhase(now), groups: [] }
+ }
+
+ const conditions: Where[] = []
+ if (region) conditions.push({ region: { equals: region } })
+ if (writableIds) conditions.push({ id: { in: writableIds } })
+ const where: Where = conditions.length === 0 ? {} : { and: conditions }
const result = await payload.find({
collection: 'municipality',
diff --git a/tests/int/campaignAdvisorPermission.int.spec.ts b/tests/int/campaignAdvisorPermission.int.spec.ts
index 27bfc941..f933f172 100644
--- a/tests/int/campaignAdvisorPermission.int.spec.ts
+++ b/tests/int/campaignAdvisorPermission.int.spec.ts
@@ -20,6 +20,13 @@ import {
} from '@/app/(campaign)/campanha/actions/municipality'
import config from '@/payload.config'
import { canCreateOrganization } from '@/utilities/campaignAccess'
+import { loadWritableMunicipalityOptions } from '@/utilities/campaignRelationOptions'
+import { loadSupporterCreatePageData } from '@/utilities/supporter/supporterPageData'
+import {
+ loadMunicipalityVisitEligibility,
+ loadVisitCandidates,
+ loadVisitPlannerRegions,
+} from '@/utilities/visit/visitPlannerData'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
import { stub } from '../helpers/stub'
@@ -772,3 +779,104 @@ describe('advisor permission profile (C141)', () => {
).rejects.toThrow(/exige Visão/)
})
})
+
+describe('write-scoped municipality options (C144)', () => {
+ beforeAll(async () => {
+ payload = await getPayload({ config: await config })
+ })
+
+ it('loadWritableMunicipalityOptions follows the Edição axis for every profile', async () => {
+ const fixtures = campaignFixtures()
+ const coordinator = await fixtures.createCampaignUser('coordinator')
+ const carteiraAdvisor = await fixtures.createCampaignUser('advisor')
+ const wideReadAdvisor = await fixtures.createCampaignUser('advisor', {
+ visibility: 'tudo',
+ editing: 'carteira',
+ })
+ const wideEditAdvisor = await fixtures.createCampaignUser('advisor', {
+ visibility: 'tudo',
+ editing: 'tudo',
+ })
+ const readOnlyAdvisor = await fixtures.createCampaignUser('advisor', {
+ editing: 'somente_leitura',
+ })
+ const administered = await fixtures.getMunicipality()
+ const outside = await fixtures.getMunicipality()
+ await fixtures.assignMunicipalityAdvisors(administered.id, [
+ carteiraAdvisor.id,
+ wideReadAdvisor.id,
+ wideEditAdvisor.id,
+ readOnlyAdvisor.id,
+ ])
+
+ const idsOf = (options: Awaited>) =>
+ new Set(options.map((option) => option.id))
+
+ // Unrestricted staff and Edição "Tudo" offer the whole catalog.
+ expect(idsOf(await loadWritableMunicipalityOptions(payload, coordinator)).has(outside.id)).toBe(
+ true,
+ )
+ expect(
+ idsOf(await loadWritableMunicipalityOptions(payload, wideEditAdvisor)).has(outside.id),
+ ).toBe(true)
+
+ // The C144 core fix: Visão "Tudo" + Edição "Carteira" offers ONLY the
+ // carteira — the picker can no longer suggest a município the server rejects.
+ for (const advisor of [carteiraAdvisor, wideReadAdvisor]) {
+ const ids = idsOf(await loadWritableMunicipalityOptions(payload, advisor))
+ expect(ids.has(administered.id)).toBe(true)
+ expect(ids.has(outside.id)).toBe(false)
+ }
+
+ // Somente leitura: nothing to write, nothing offered.
+ expect(await loadWritableMunicipalityOptions(payload, readOnlyAdvisor)).toEqual([])
+ })
+
+ it('giro composer writeScope restricts regions and candidates; the dossier card stays read-wide', async () => {
+ const fixtures = campaignFixtures()
+ const wideReadAdvisor = await fixtures.createCampaignUser('advisor', {
+ visibility: 'tudo',
+ editing: 'carteira',
+ })
+ const administered = await fixtures.getMunicipality()
+ const outside = await fixtures.getMunicipality()
+ await fixtures.assignMunicipalityAdvisors(administered.id, [wideReadAdvisor.id])
+
+ // Regions are built only over writable municípios — one portfolio, one count.
+ const regions = await loadVisitPlannerRegions(payload, wideReadAdvisor, { writeScope: true })
+ expect(regions.reduce((sum, region) => sum + region.municipalityCount, 0)).toBe(1)
+
+ // Candidates exclude the readable-but-not-writable município.
+ const bundle = await loadVisitCandidates(payload, wideReadAdvisor, { writeScope: true })
+ const slugs = bundle.groups.flatMap((group) =>
+ group.candidates.map((candidate) => candidate.slug),
+ )
+ expect(slugs).toEqual([administered.slug])
+ expect(slugs).not.toContain(outside.slug)
+
+ // The dossier eligibility card (read surface) keeps the read scope: the
+ // same actor still reads the out-of-carteira município there.
+ const { candidate } = await loadMunicipalityVisitEligibility(
+ payload,
+ wideReadAdvisor,
+ outside.slug,
+ )
+ expect(candidate).not.toBeNull()
+ })
+
+ it('loadSupporterCreatePageData offers only writable municipalities', async () => {
+ const fixtures = campaignFixtures()
+ const wideReadAdvisor = await fixtures.createCampaignUser('advisor', {
+ visibility: 'tudo',
+ editing: 'carteira',
+ })
+ const administered = await fixtures.getMunicipality()
+ const outside = await fixtures.getMunicipality()
+ await fixtures.assignMunicipalityAdvisors(administered.id, [wideReadAdvisor.id])
+
+ const pageData = await loadSupporterCreatePageData(payload, wideReadAdvisor)
+ const ids = new Set(pageData.municipalityOptions.map((option) => option.id))
+ expect(ids.has(administered.id)).toBe(true)
+ expect(ids.has(outside.id)).toBe(false)
+ })
+})