teqo-backup/AGENTS-campaign.md
Francisco Solla 0822cca82c chore: reduce token overhead in agent workflow
- 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
2026-08-21 20:38:56 -03:00

26 KiB
Raw Blame History

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.

  • campaignUser collection (src/collections/CampaignUser.ts) — a separate Payload auth collection with name, optional contact phone, and required role: coordinator ("Coordenador Geral"), advisor ("Assessor"), candidate ("Candidato"), or leader ("Liderança" — default and least privilege). Staff may sign in by email; leadership accounts use the normalized 11-digit phone as username. It is not users: admin.user remains users, so campaign users cannot log into /admin. Its static auth.tokenExpiration is the 14-day ceiling because Payload's Local API does not accept a per-login TTL and useSessions (enabled) stores/prunes sessions using that configured expiry.
  • Isolated session cookie campaign-token. The flow sets its own httpOnly cookie named campaign-token (constant CAMPAIGN_TOKEN_COOKIE in src/utilities/campaignAuth.ts), scoped to path: '/campanha', sameSite: 'lax', and secure only in production. It is deliberately NOT the default payload-token cookie — 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. setCampaignAuthCookie owns the invariant JWT exp = cookie maxAge: it re-signs the Payload token to 8 hours with jwtSign or 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 the campaign-token cookie, calls payload.auth({ headers }), accepts only user.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). loginCampaign validates input with campaignLoginSchema (src/lib/schemas/campaign-login.ts), calls payload.login({ collection: 'campaignUser' }), chooses the 8-hour / 14-day TTL, and — because the Local API returns a token WITHOUT setting a cookie — calls setCampaignAuthCookie before redirecting to /campanha. logoutCampaign authenticates the current token and delegates revocation of its _sid to Payload's public transactional logoutOperation, 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.tsx renders the <html>/<body> with data-theme="campaign". The public login lives at src/app/(campaign)/campanha/login/ (page.tsx + client LoginForm.tsx). Everything gated sits in the (app) group, whose layout (src/app/(campaign)/campanha/(app)/layout.tsx) calls getCampaignUser() and redirect('/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 setCampaignAuthCookie with the 14-day TTL (enrolling the device is the trust opt-in), and the password path is untouched. Credentials live in campaignWebAuthnCredential (owner-only access in src/utilities/access/webauthnCredentials.ts; update is denied to users because the signature counter and lastUsedAt are written with overrideAccess: true inside the transaction). Four anonymous-or-authenticated JSON routes sit in src/app/(campaign)/campanha/webauthn/ — deliberately outside (app), since two of them run before there is a session and the cookie's /campanha path already covers them. No Consent key, 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. loginOperation requires password. 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 with payload.findByID here. addSessionToUser read-modify-writes the whole user document, so a doc that passed through CampaignUser's afterRead removePrivateAuthFields would be written back without sessions or email — logging the user out of every other device — and with avatar expanded into an object. user.updatedAt = null is set for the same reason Payload's own login does it. The mint runs inside withPayloadTransaction under campaign-webauthn-session:{userId}, because rewriting the whole sessions array makes two concurrent logins lose one.
    • RP ID must match the host that served the page. getCampaignWebAuthnRelyingParty derives rpID/origin from getCampaignInviteBaseURL, and compares the request's host against it — returning null when they differ, which hides the button. Without that comparison a preview served from a different host resolves the canonical origin successfully and the browser throws SecurityError mid-ceremony.
    • The challenge cookie must carry the library's own challenge. It is an HMAC-signed httpOnly cookie (5 min, single-use, path /campanha, precedent supporterImportToken.ts) — but store the exact base64url string generateRegistrationOptions/generateAuthenticationOptions returned. Generating our own alongside theirs makes every register answer 400.
    • Deleting a campaignUser needs the cascade in a hook. Payload emits ON DELETE SET NULL for the required user relationship, which cannot apply to a NOT NULL column; CampaignUser has a beforeDelete that removes the user's credentials in the same transaction. E2E uses a CDP virtual authenticator (WebAuthn.addVirtualAuthenticator, transport: 'internal'), which also makes isUserVerifyingPlatformAuthenticatorAvailable() answer true.
  • Migrations. 20260716_010420_add_campaign_user created the auth collection; 20260728_041958_add_campaign_webauthn_credentials added the WebAuthn credential table; the unpublished 2026-07-17 campaign chain was consolidated into 20260718_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 migration 20260723_200000_remodel_municipalities (+ reconcile 20260723_202000_reconcile_municipality_remodel). Geography is admin-only; staff edit strategy fields and the coordinator assigns advisors.
  • 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" — stateGoal sets the optimistic suggested-goal scenario, margin the pessimistic haircut; read = admin/staff, update = admin/isCampaignUnrestricted; no afterChange hook — 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. Only isCampaignUnrestricted actors move it, every move needs a rationale plus the signals that would reverse it, and the move writes an allocationDecision (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") and candidate ("Candidato", unrestricted visibility via isCampaignUnrestricted) see/manage everything staff sees; advisor ("Assessor") is scoped to municipality.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 declaredVotes and record estimatedVotes (pessimistic/central/optimistic); leaders never see estimates. Aggregates use estimated[S] ?? declared, default central (src/utilities/votePledgeData.ts).
  • Demandas: staff-only create/read (canCreateCampaignDemand); optional leadership provenance selected by staff. Escalated decisions: coordinator or candidate.
  • Dobradinhas: stateDeputy entity + municipality.stateDeputies / leadership.stateDeputies hasMany; UI at /campanha/dobradinhas.
  • Map: staff dashboard (/campanha) loads MunicipalityMapPanel; /campanha/municipios is list/overview only. Keyed by "map key", not ibgeCode (B8 F2, 2026-07-26): mapKeyForMunicipality returns the municipalitySlug for kind === 'zona' and the ibgeCode otherwise, so each of Salvador's 19 ZE is painted, hovered and opened on its own from bahia-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 (baseKeys in BahiaMap) — 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 is featureMapKey (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's ibgeCode, 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 in tests/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: loadLayerFeatures catches 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), while status === 'error' stays reserved for the municipal mesh. competitiveRank is still per city: the TSE artifact only carries federalRankByIbgeCode, 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) — join Contact↔campaign with optional municipality. Fields include source (import_csv | manual | convite | evento | lideranca for leader-created rows).
  • Consent by stable key. campaignConsent.ts was generalized to getConsentByKey / requireConsentByKey (fail-closed). The two keys are apoiador-cadastro (declared support) and apoiador-intencao-voto (vote intention — highlighted, separate consent). The app fails closed if either key is missing — same pattern as lideranca-autopreenchimento.
  • Access. coordinator/candidate see/manage all; advisor sees supporters of administered municipalities; leader may create supporters in linked municipalities and read only rows they created (createdBy); supporters without a municipality remain coordinator-only. Delete is admin-only.
  • Server actions (src/app/(campaign)/campanha/actions/supporter.ts): createSupporter, setSupporterVoteIntention, previewSupporterImport / confirmSupporterImport (CSV, coordinator-only, transactional with req: { transactionID }, dedup by phone via contactPhoneInvariant), and removeSupporterData (deletes the supporter; anonymizes/clears Contact PII only if no other join — leadership/signature/subscription/other supporter — 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 main 2026-07-19). CSV import up to 5000 rows now completes in one transaction: confirmSupporterImportRecord acquires all phone advisory locks, then bulkInsertSupporterImport (src/utilities/supporterImportBulk.ts) inserts contacts + supporters via payload.db.drizzle on the Payload transaction session (getPostgresTransactionDatabase) in 500-row chunks with ON CONFLICT DO NOTHING on (contact_id, municipality_id). Contact.enforceUniqueContactPhone honors context.skipContactPhoneInvariant fail-closed (only inside an active req.transactionID), so the bulk path skips the per-row re-check while the single-create path is unchanged. Preview no longer round-trips the full ok set: previewSupporterImportText stages it in supporterImportBatch (src/collections/SupporterImportBatch.ts, admin-hidden, coordinator/admin only, migration 20260719_011015_add_supporter_import_batch) and returns an HMAC-SHA256 single-use importToken (10-min TTL, actor-bound, src/utilities/supporterImportToken.ts); confirmSupporterImportRecord verifies the token and consumes the batch (delete after commit). The list KPI is a single COUNT(*) FILTER aggregate (src/utilities/supporterListOverviewAggregate.ts) mirroring buildSupporterListWhere + 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 /simplify pass 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' and Consent.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 ONE municipality, with task checklist, update feed and result record. Fields: title, canonical immutable slug, kind / status enums in pt-BR, startAt (required beyond rascunho), municipality (required) + free-text locality, organizations (hasMany — supporting orgs), advisors (staff responsible; ex-coordinators), deputyPresent (checkbox "Deputado presente"), responsible → Contact, leadership optional, tasks/updates arrays (derived fields server-side), and the action result: resultSummary + resultMedia (upload → media) + derived resultRecordedBy/At — queryable history for future activities in the same município. No Consent (internal staff data).
  • Access. coordinator everything; advisor where listed in advisors OR the activity's municipality is administered (may create, auto-included); leader has 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 is realizado.

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 object municipalities, 417 features, properties: { codarea, name }), src/lib/geometries/bahia-identity-territories.topo.json (~15 KB; object territories, 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; object municipalityZones, 19 features, properties: { municipalitySlug, name, ibgeCode }), the Salvador ZE mesh.
  • Code table: src/lib/bahiaMunicipalityCodes.ts — canonical municipality name → IBGE 7-digit codarea, plus codeForMunicipality / municipalityForCode. Fixture tests/fixtures/bahia-municipality-codes.official.json + int tests for bijection/coverage.
  • Runtime helpers: src/lib/bahiaGeometries.ts — typed topologies, feature arrays and getMunicipalityFeature. TopoJSON→GeoJSON decode is LAZY (dynamic import via loadMunicipalityGeometryModule / loadMunicipalityZoneGeometryModule; B5 F1 done) — the default /campanha server graph never pays it. Shared feature types live in bahiaGeometriesTypes.ts: BahiaFeature<P> is the one spelling of Feature<Polygon | MultiPolygon, P>, BahiaMeshFeature is what the map layer receives from any of the three meshes, and geometry math (point-in-polygon, centroid, area, bounds — all built on the exported polygonRingsOf) takes PolygonalFeature, which declares the actual requirement instead of dragging GeoJsonProperties (i.e. any) through the signature.
  • Territory polygons: dissolved from IBGE municipality meshes via bahiaIdentityTerritoryRecords (topojson merge), 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; --report reconciles without writing) downloads IBGE BA_bairros_CD2022.zip from geoftp, parses the shapefile with shpjs (devDependency — that cut is SHP-only, SIRGAS 2000), keeps CD_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 with canonicalizeMunicipalityName, simplifies/quantizes, emits the artifacts above. Cache under data/geometries/ (gitignored; override with GEOMETRIES_CACHE_DIR). Does not touch the database — no assertLocalDatabase / no prod-DB risk. Not part of pnpm build or pnpm 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.