- Drop model-selection skill (dead, pool is dormant) - Drop PNG rendering from UI planning (HTML-only, no pnpm ui-draft:render) - Trim code-simplification: 331→~150 lines, Python/React examples moved to reference/ - Compress engineering-brief.md: remove redundant 'Pinhos do repo' (alwaysApply rules) - Deduplicate capture-review-debts: reference decision-quality.md instead of re-explaining - Create concise-output skill (35 lines) to enforce terse agent output - Trim all 99 skill descriptions to ≤1 sentence - Split AGENTS.md into layers: core (73 lines, always) + campaign/public/infra (on-demand) - Decompose plan-issue: exploration sub-agent + parallel plan-writers + register sub-agent - Decompose work-issue: exploration sub-agent + plan-writer + 2 parallel reviewers + debt sub-agent - Update agent-work-issue to match sub-agent pattern
26 KiB
Campaign domain (AGENTS layer — load when touching /campanha)
Campaign auth (/campanha)
The internal /campanha area is gated by its own authentication, deliberately kept isolated from the Payload admin (/admin) so a campaign session and an admin session can coexist in the same browser.
campaignUsercollection (src/collections/CampaignUser.ts) — a separate Payload auth collection withname, optional contactphone, and requiredrole:coordinator("Coordenador Geral"),advisor("Assessor"),candidate("Candidato"), orleader("Liderança" — default and least privilege). Staff may sign in by email; leadership accounts use the normalized 11-digit phone asusername. It is notusers:admin.userremainsusers, so campaign users cannot log into/admin. Its staticauth.tokenExpirationis the 14-day ceiling because Payload's Local API does not accept a per-login TTL anduseSessions(enabled) stores/prunes sessions using that configured expiry.- Isolated session cookie
campaign-token. The flow sets its own httpOnly cookie namedcampaign-token(constantCAMPAIGN_TOKEN_COOKIEinsrc/utilities/campaignAuth.ts), scoped topath: '/campanha',sameSite: 'lax', andsecureonly in production. It is deliberately NOT the defaultpayload-tokencookie — using a distinct name + path is what lets a campaign session and a Payload admin session live side by side without clobbering each other. Login defaults to 8 hours; checking "Lembrar de mim neste dispositivo" opts into 14 days.setCampaignAuthCookieowns the invariant JWTexp= cookiemaxAge: it re-signs the Payload token to 8 hours withjwtSignor keeps the original 14-day token. Password-reset and invite login paths omit the opt-in and therefore stay at 8 hours. - Session verification —
getCampaignUser()(src/utilities/campaignAuth.ts). Reads thecampaign-tokencookie, callspayload.auth({ headers }), accepts onlyuser.collection === 'campaignUser', then reloads that document by ID. The fresh read makes role downgrades effective immediately instead of trusting a stale JWT role. - Login/logout server actions (
src/app/(campaign)/campanha/actions/auth.ts).loginCampaignvalidates input withcampaignLoginSchema(src/lib/schemas/campaign-login.ts), callspayload.login({ collection: 'campaignUser' }), chooses the 8-hour / 14-day TTL, and — because the Local API returns a token WITHOUT setting a cookie — callssetCampaignAuthCookiebefore redirecting to/campanha.logoutCampaignauthenticates the current token and delegates revocation of its_sidto Payload's public transactionallogoutOperation, then clears the cookie (maxAge: 0) and redirects to/campanha/login; malformed tokens or database failures never prevent local cookie/PWA cleanup. - Route layout. The route group's root layout
src/app/(campaign)/layout.tsxrenders the<html>/<body>withdata-theme="campaign". The public login lives atsrc/app/(campaign)/campanha/login/(page.tsx+ clientLoginForm.tsx). Everything gated sits in the(app)group, whose layout (src/app/(campaign)/campanha/(app)/layout.tsx) callsgetCampaignUser()andredirect('/campanha/login')when there is no session — that layout is the barrier. - Biometric login (WebAuthn, B40 — 2026-07-28). A second way into the same session, never a second session: the biometric path ends at the same
setCampaignAuthCookiewith the 14-day TTL (enrolling the device is the trust opt-in), and the password path is untouched. Credentials live incampaignWebAuthnCredential(owner-only access insrc/utilities/access/webauthnCredentials.ts;updateis denied to users because the signature counter andlastUsedAtare written withoverrideAccess: trueinside the transaction). Four anonymous-or-authenticated JSON routes sit insrc/app/(campaign)/campanha/webauthn/— deliberately outside(app), since two of them run before there is a session and the cookie's/campanhapath already covers them. NoConsentkey, and this is not an art. 11 question: the OS never releases the fingerprint, so what we store is a public key.- There is no password-less login in Payload 3.82.0.
loginOperationrequirespassword.issueCampaignWebAuthnSession(src/utilities/campaignWebAuthnSession.ts) mints the session from public exports only:payload.db.findOne→checkLoginPermission→addSessionToUser(payload/shared) →getFieldsToSign+jwtSign. Never load the user withpayload.findByIDhere.addSessionToUserread-modify-writes the whole user document, so a doc that passed throughCampaignUser'safterReadremovePrivateAuthFieldswould be written back withoutsessionsoremail— logging the user out of every other device — and withavatarexpanded into an object.user.updatedAt = nullis set for the same reason Payload's own login does it. The mint runs insidewithPayloadTransactionundercampaign-webauthn-session:{userId}, because rewriting the wholesessionsarray makes two concurrent logins lose one. - RP ID must match the host that served the page.
getCampaignWebAuthnRelyingPartyderivesrpID/originfromgetCampaignInviteBaseURL, and compares the request's host against it — returningnullwhen they differ, which hides the button. Without that comparison a preview served from a different host resolves the canonical origin successfully and the browser throwsSecurityErrormid-ceremony. - The challenge cookie must carry the library's own challenge. It is an HMAC-signed httpOnly cookie (5 min, single-use, path
/campanha, precedentsupporterImportToken.ts) — but store the exact base64url stringgenerateRegistrationOptions/generateAuthenticationOptionsreturned. Generating our own alongside theirs makes everyregisteranswer 400. - Deleting a
campaignUserneeds the cascade in a hook. Payload emitsON DELETE SET NULLfor the requireduserrelationship, which cannot apply to aNOT NULLcolumn;CampaignUserhas abeforeDeletethat removes the user's credentials in the same transaction. E2E uses a CDP virtual authenticator (WebAuthn.addVirtualAuthenticator,transport: 'internal'), which also makesisUserVerifyingPlatformAuthenticatorAvailable()answertrue.
- There is no password-less login in Payload 3.82.0.
- Migrations.
20260716_010420_add_campaign_usercreated the auth collection;20260728_041958_add_campaign_webauthn_credentialsadded the WebAuthn credential table; the unpublished 2026-07-17 campaign chain was consolidated into20260718_010733_consolidate_campaign_schema, which adds role/username auth, contact phone, and the remaining final campaign schema.
Campaign Municípios model (2026-07-23 remodel)
/campanha is organized around municipality ("Município") — the campaign's PREDEFINED operational territory. Deployed to production on 2026-07-23 (destructive remodel migrations applied via the Vercel build). Master plan: docs/plans/remodelagem-municipios.md (supersedes docs/plans/remodelagem-pracas.md).
- Município definition: 435 seeded rows — one per Bahia municipality (416), except Salvador (19 zone municipalities, ZE 1–19); Camaçari is a single whole municipality (ZE 170/171 aggregated). Canonical catalog:
src/lib/municipalityCatalog.ts(tests/fixtures/municipality-catalog.snapshot.json). Seeded by migration20260723_200000_remodel_municipalities(+ reconcile20260723_202000_reconcile_municipality_remodel). Geography is admin-only; staff edit strategy fields and the coordinator assignsadvisors. - Collections (admin group
Campanha):municipality,leadership,votePledge,organization,stateDeputy(dobradinhas),campaignDemand,allocationDecision,municipalityUpdate,campaignInvite,campaignWebAuthnCredential(biometric login — see "Campaign auth"),supporter,activity. Global (same admin group):campaignGoals(E8 "conta da cadeira" —stateGoalsets the optimistic suggested-goal scenario,marginthe pessimistic haircut;read= admin/staff,update= admin/isCampaignUnrestricted; noafterChangehook — nothing caches it). - Engagement level (E14):
municipality.engagementLevel(n0…n4, staff-only, no default) is the campaign's declared investment in the município — N0 monitorar … N4 investimento pleno. OnlyisCampaignUnrestrictedactors move it, every move needs a rationale plus the signals that would reverse it, and the move writes anallocationDecision(outcome: 'movimento') in the same transaction under an advisory lock. Hysteresis rules are pure (src/lib/engagementLevel.ts) and shared by client and server; the override is recorded with the rules it contradicted. Null means "Sem nível" — the triage queue, not a missing value to backfill. - Roles:
coordinator("Coordenador Geral") andcandidate("Candidato", unrestricted visibility viaisCampaignUnrestricted) see/manage everything staff sees;advisor("Assessor") is scoped tomunicipality.advisors;leader("Liderança") is lockdown — home is the supporter contact tool only (LeaderContactsPanel,source: lideranca), reads only supporters they created (createdBy), no municipalities/leaderships/pledges/demands/plans/election data. - Vote pledge asymmetry: staff declare
declaredVotesand recordestimatedVotes(pessimistic/central/optimistic); leaders never see estimates. Aggregates useestimated[S] ?? declared, defaultcentral(src/utilities/votePledgeData.ts). - Demandas: staff-only create/read (
canCreateCampaignDemand); optionalleadershipprovenance selected by staff. Escalated decisions: coordinator or candidate. - Dobradinhas:
stateDeputyentity +municipality.stateDeputies/leadership.stateDeputieshasMany; UI at/campanha/dobradinhas. - Map: staff dashboard (
/campanha) loadsMunicipalityMapPanel;/campanha/municipiosis list/overview only. Keyed by "map key", notibgeCode(B8 F2, 2026-07-26):mapKeyForMunicipalityreturns themunicipalitySlugforkind === 'zona'and theibgeCodeotherwise, so each of Salvador's 19 ZE is painted, hovered and opened on its own frombahia-municipality-zones.topo.json. Every bundle field follows that key (municipalitiesByMapKey,territorialClassByMapKey,projectedValidVotesByMapKey,valuesByYear, …) and each key maps to exactly one catalog entry, so rollups assign instead of accumulating. Salvador's municipal polygon is still drawn, underneath, as a non-interactive base with no data (baseKeysinBahiaMap) — two independently simplified meshes do not close on the same coastline, and deleting it opened a gap in the state outline; being out of the key index, it cannot double-count or steal a hover. Feature→key resolution isfeatureMapKey(municipalitySlug ?? codarea ?? code) — the three are mutually exclusive on the committed meshes, so it is a lookup over distinct names and not a precedence rule; the zone mesh also carries the city'sibgeCode, which is deliberately not a key property because all 19 zones share it. Since B8+ that contract is exercised on real features of all three meshes intests/int/bahiaGeometries.int.spec.ts— over the full 417 / 27 / 19, not on hand-written literals and not on one city feature repeated per zone — and a failure of the zone chunk degrades instead of killing the map:loadLayerFeaturescatches it and paints the state without the ZE (Salvador falls back to its non-interactive base polygon, not to an aggregated one — the bundle is keyed by slug), whilestatus === 'error'stays reserved for the municipal mesh.competitiveRankis still per city: the TSE artifact only carriesfederalRankByIbgeCode, so all 19 zones share the city's placement — which is what the lege... (line truncated to 2000 chars) - URLs:
/campanha/municipios[/slug],/campanha/dobradinhas,/campanha/liderancas[/id],/campanha/organizacoes[/slug],/campanha/demandas[/slug],/campanha/atividades[/slug],/campanha/atividades/giros(compositor de giro, staff),/campanha/apoiadores,/campanha/assessores(coordinator/candidate),/campanha/conceitos(staff glossary),/campanha/perfil.
Campaign supporters (C2)
Nominal supporter registry backing the campaign's base of declared supporters (roadmap item C2; engineering ready and merged to main on 2026-07-18, production-blocked on the legal batch). One collection in admin group Campanha:
supporter(src/collections/Supporter.ts) — joinContact↔campaign with optionalmunicipality. Fields includesource(import_csv | manual | convite | evento | liderancafor leader-created rows).- Consent by stable key.
campaignConsent.tswas generalized togetConsentByKey/requireConsentByKey(fail-closed). The two keys areapoiador-cadastro(declared support) andapoiador-intencao-voto(vote intention — highlighted, separate consent). The app fails closed if either key is missing — same pattern aslideranca-autopreenchimento. - Access.
coordinator/candidatesee/manage all;advisorsees supporters of administered municipalities;leadermay create supporters in linked municipalities and read only rows they created (createdBy); supporters without a municipality remaincoordinator-only. Delete is admin-only. - Server actions (
src/app/(campaign)/campanha/actions/supporter.ts):createSupporter,setSupporterVoteIntention,previewSupporterImport/confirmSupporterImport(CSV,coordinator-only, transactional withreq: { transactionID }, dedup by phone viacontactPhoneInvariant), andremoveSupporterData(deletes thesupporter; anonymizes/clearsContactPII only if no other join —leadership/signature/subscription/othersupporter— references it). - UI
/campanha/apoiadores— list with KPIs (total / "Certo + Tende" / "Indecisos"), filters (vote intention, territory), detail with LGPD consent block + vote-intention segmented control (disabled until consent) + minimal share kit (wa.me+ copy text), and a 3-step CSV import wizard (Upload → Preview → Confirm). Phone is required in v1. - C6 scale layer (merged to
main2026-07-19). CSV import up to 5000 rows now completes in one transaction:confirmSupporterImportRecordacquires all phone advisory locks, thenbulkInsertSupporterImport(src/utilities/supporterImportBulk.ts) inserts contacts + supporters viapayload.db.drizzleon the Payload transaction session (getPostgresTransactionDatabase) in 500-row chunks withON CONFLICT DO NOTHINGon(contact_id, municipality_id).Contact.enforceUniqueContactPhonehonorscontext.skipContactPhoneInvariantfail-closed (only inside an activereq.transactionID), so the bulk path skips the per-row re-check while the single-create path is unchanged. Preview no longer round-trips the fullokset:previewSupporterImportTextstages it insupporterImportBatch(src/collections/SupporterImportBatch.ts, admin-hidden,coordinator/admin only, migration20260719_011015_add_supporter_import_batch) and returns an HMAC-SHA256 single-useimportToken(10-min TTL, actor-bound,src/utilities/supporterImportToken.ts);confirmSupporterImportRecordverifies the token and consumes the batch (delete after commit). The list KPI is a singleCOUNT(*) FILTERaggregate (src/utilities/supporterListOverviewAggregate.ts) mirroringbuildSupporterListWhere+ the access constraint, replacing 3×payload.count. Shared shells —campaignListUrl.ts,CampaignListPagination,campaignFormFields.ts,mapCampaignFormActionError.ts— replace the three per-list pagination components and the duplicated form-error ladders. Scale/DRY follow-ups from the/simplifypass are registered as C8 (docs/plans/escala-dry-pos-c6.md). - Mandatory production blocker: do not import real supporter data or capture vote intention until electoral counsel documents the LGPD art. 11 basis (vote intention is sensitive data), approves the versioned consent texts, and an admin creates
Consent.key = 'apoiador-cadastro'andConsent.key = 'apoiador-intencao-voto'. The app intentionally fails closed when these keys are absent.
Campaign activities (C3, renamed by C13)
Mobilization events / agenda backing the campaign's field operations (roadmap item C3; implemented and merged to main on 2026-07-18). Renamed "Plano de Ação" → "Atividade" on 2026-07-25 (C13): the collection slug is activity, the route is /campanha/atividades, and the vocabulary guard in tests/unit/codebaseConventions.unit.spec.ts now fails the build on actionPlan / plano de ação / /campanha/planos anywhere in src, tests or scripts (migrations excluded — frozen history). One collection in admin group Campanha:
activity(src/collections/Activity.ts) — a calendarizable action/event (caminhada, comício, etc.) anchored on ONEmunicipality, with task checklist, update feed and result record. Fields:title, canonical immutableslug,kind/statusenums in pt-BR,startAt(required beyondrascunho),municipality(required) + free-textlocality,organizations(hasMany — supporting orgs),advisors(staff responsible; ex-coordinators),deputyPresent(checkbox "Deputado presente"),responsible→Contact,leadershipoptional,tasks/updatesarrays (derived fields server-side), and the action result:resultSummary+resultMedia(upload →media) + derivedresultRecordedBy/At— queryable history for future activities in the same município. NoConsent(internal staff data).- Access.
coordinatoreverything;advisorwhere listed inadvisorsOR the activity'smunicipalityis administered (may create, auto-included);leaderhas no access (lockdown). - UI
/campanha/atividades— list with tabs Próximos (default) / Todos / Realizados / Rascunhos, filters (kind, municipality), cards with kind/status badge + date/time + município + "Resp:" + task progress + "Deputado presente"; detail with tabs Visão geral / Tarefas / Atualizações (/campanha/atividades/[slug], create at/campanha/atividades/nova); result form appears for staff when the activity isrealizado.
Campaign map geometries (B2)
Static Bahia map foundation for future Leaflet surfaces (roadmap B2; UI is B3). No migration, collection, Consent, or server action — same static-data pattern as bahiaTerritories / bahiaTseZones.
- Artifacts (committed):
src/lib/geometries/bahia-municipalities.topo.json(~132 KB; Topology objectmunicipalities, 417 features,properties: { codarea, name }),src/lib/geometries/bahia-identity-territories.topo.json(~15 KB; objectterritories, 27 features,properties: { code, name }) and — since B8 F2 (2026-07-26) —src/lib/geometries/bahia-municipality-zones.topo.json(~7.7 KB since B8+ recalibrated it; objectmunicipalityZones, 19 features,properties: { municipalitySlug, name, ibgeCode }), the Salvador ZE mesh. - Code table:
src/lib/bahiaMunicipalityCodes.ts— canonical municipality name → IBGE 7-digitcodarea, pluscodeForMunicipality/municipalityForCode. Fixturetests/fixtures/bahia-municipality-codes.official.json+ int tests for bijection/coverage. - Runtime helpers:
src/lib/bahiaGeometries.ts— typed topologies, feature arrays andgetMunicipalityFeature. TopoJSON→GeoJSON decode is LAZY (dynamic import vialoadMunicipalityGeometryModule/loadMunicipalityZoneGeometryModule; B5 F1 done) — the default/campanhaserver graph never pays it. Shared feature types live inbahiaGeometriesTypes.ts:BahiaFeature<P>is the one spelling ofFeature<Polygon | MultiPolygon, P>,BahiaMeshFeatureis what the map layer receives from any of the three meshes, and geometry math (point-in-polygon, centroid, area, bounds — all built on the exportedpolygonRingsOf) takesPolygonalFeature, which declares the actual requirement instead of draggingGeoJsonProperties(i.e.any) through the signature. - Territory polygons: dissolved from IBGE municipality meshes via
bahiaIdentityTerritoryRecords(topojsonmerge), not the IDE Bahia shapefile (IDE remains provenance/validation reference only). - Rebuild (Salvador zones):
pnpm build:municipality-zone-geometries(scripts/build-municipality-zone-geometries.mjs;--reportreconciles without writing) downloads IBGEBA_bairros_CD2022.zipfrom geoftp, parses the shapefile withshpjs(devDependency — that cut is SHP-only, SIRGAS 2000), keepsCD_MUN === '2927408'(170 polygons) and dissolves neighbourhood→zone against TRE-BA RA 02/2017 (municipalityZoneNeighborhoods.ts). Reconciliation is curated data in the script header, not cleverer matching: an alias table, a table for TRE neighbourhoods with no polygon of their own (the build refuses a merge that would cross zones), and a table for the one neighbourhood the resolution splits mid-street (it goes whole to one ZE — the neighbourhood is the atom). The 5 polygons the resolution never names are assigned by longest shared boundary, a rule validated against the 4 it names under another label. The build fails on an orphan polygon or a TRE neighbourhood with no polygon, so 170/170 → 19/19 is an invariant, not a hope. Same cache dir and the same "does not touch the database" guarantee as the sibling below. - Rebuild:
pnpm build:geometries(scripts/build-bahia-geometries.mjs) downloads IBGE Malhas (qualidade intermediaria) + Localidades, reconciles names withcanonicalizeMunicipalityName, simplifies/quantizes, emits the artifacts above. Cache underdata/geometries/(gitignored; override withGEOMETRIES_CACHE_DIR). Does not touch the database — noassertLocalDatabase/ no prod-DB risk. Not part ofpnpm buildorpnpm dev. Details:docs/plans/mapa-bahia-geometrias.md. Scale/DRY follow-ups from/simplify:docs/plans/escala-dry-pos-b2.md(B5).
Election baseline data (TSE 2022)
Public TSE 2022 results back the electoral baseline (roadmap items A3 + A4). Three collections in admin group Dados Eleitorais: electionTally (per city×zone totals: eligible voters, turnout, valid/blank/null votes), electionCandidateVote (nominal votes per candidate×city×zone), and electionCandidate (candidate registry with a cross-year identityKey hash — no CPF or voter ID is ever persisted). Read access requires an authenticated campaignUser or users account (canReadElectionData); writes are Payload-admin-only, and the import runs via CLI with overrideAccess: true. Since the Município remodel, the consumers are municipalityElectoralBaseline.ts (per-municipality series/turnout), municipalityCandidateComparison.ts (multi-candidate table) and municipalityMapData.ts (map, incl. the diverging compare mode); ticket roles live in BASELINE_TICKET_2022. Seeds cover 2014/2018/2022 (E2 extended the original 2022-only A3 scope). Two caching layers sit on top (2026-07-23 hardening): the immutable Solla/valid-votes series per municipality ships as a committed artifact — pnpm build:election-aggregates → src/lib/electionAggregates/bahia-federal-baseline.json, loader src/lib/bahiaElectionAggregates.ts, snapshot-tested against municipalityCatalog — so the map/dashboard never query these collections for historical years; and the elections-tab loaders (municipalityElectoralBaseline, municipalityCandidateComparison, electionCandidateOptions) are wrapped in unstable_cache under the election-tse tag (bust via POST /api/revalidate?tag=election-tse after re-seeding).
The artifact is at v3: v2 added E8's campoFederalVotesByYear / federalTallyByYear / majoritarian2022, and B13 (2026-07-26) added federalRankByIbgeCode — the candidate's placement among every federal-deputy candidate with votes > 0 in that geography, per year, keyed by IBGE code (417 codes; Salvador's 19 zone slugs sum to the whole city, which is what the map draws) and read through getFederalCompetitiveRank. A geography where he had no votes gets no entry, never rank: 0. Its input is loadFederalVotesByCityZoneAndCandidate in municipalityElectoralBaseline.ts — the sibling of loadCampoFederalVotesByCityZone, same statewide scan, CLI/build-time only. The file is 623 KB against the 700 KB budget asserted in tests/unit/bahiaElectionAggregates.unit.spec.ts; anything new that would blow it needs a real measurement, not a bumped constant. Rank exists only by IBGE code — add a per-slug variant when the dossier or E11 actually needs one.
pnpm db:seed:tse (scripts/seed-tse-results.mjs) downloads the TSE open-data zips (provenance URLs + SHA-256 in the script header), parses the Bahia scope, and imports via payload.db.drizzle in a transaction — idempotent by replace-per-scope (year, office, turn), only voteType: 'nominal' rows in v1. It refuses a non-local DATABASE_URL (same guard family as the other seeds; override with ALLOW_REMOTE_DB=true) and caches downloads under data/tse/ (gitignored). No revalidate step is needed: campaign pages are dynamic with auth, not ISR-cached.
Campaign supporters — LGPD blocker
Mandatory production blocker: do not insert real leadership/support data or enable invites until electoral counsel documents the LGPD art. 11 basis, approves the specific versioned consent text, and an admin creates it with exactly Consent.key = 'lideranca-autopreenchimento'. The app intentionally fails closed when this key is absent.
2026-07-23 remodel (DEPLOYED to production 2026-07-23): "Município" is the operational unit (it replaced "Praça", which replaced "Núcleo Eleitoral"). Municípios are PREDEFINED (seeded, geography read-only): 435 rows — one per Bahia municipality (416), except Salvador (19 zone Municípios, ZE 1–19); Camaçari is a single whole municipality (ZE 170/171 aggregated); shared/leaky TSE zones are cut at the municipal boundary. Code identifier: municipality. Leaderships are one record per person (contact UNIQUE) linked to N municipalities and N organizations; vote pledges are per leadership×municipality with declaredVotes (staff-entered, leader-visible) vs estimatedVotes (three scenarios, staff-only, hidden from the leader). Campaign production data was reset by the remodel migrations (public-site data untouched). Master plan: docs/plans/remodelagem-municipios.md (supersedes remodelagem-pracas.md). Details: "Campaign Municípios model" below.