Resolve CHANGELOG/GUARDRAILS conflicts (keep audit entry + vivo guard) and mark the P5-P1 TECH-DEBT row closed with the remediation.
128 KiB
Tech Debt
Ledger of known engineering debt. Items large enough to be scheduled work become entries in roadmap.md (linked in the Ref column); this file tracks the full inventory so nothing is lost. Sourced initially from the 2026-07-23 engineering-standards audits.
Debt Ledger
| Item | Location | Type | Risk | Effort | Priority | Status | Ref |
|---|---|---|---|---|---|---|---|
| 164 unused exports + 60 unused types reported by knip (warn-level; 158 at Pass 1) | god modules, ui kit families | dead code | low | M | P2 | closed 2026-07-25 (Pass 2 W4b): 0 findings; exports/types/enumMembers at error in CI; tests entered the knip graph; duplicates off (only hits were the 6 deliberate access aliases canUpdateX = canReadX) |
Pass 2 W4b |
knip loader noise: Error loading src/payload.config.ts (server-only guard) |
knip run output | tooling noise | none (exit 0, static graph resolves) | S | P3 | open | |
Aikido scan unusable: aikido_full_scan answers Opengrep exit code 2 for any input, including a one-line probe file, so the per-change security scan the rules mandate cannot complete. What it reported is an artifact of the failed run, not attributable to the code under review; the Aikido feed lists nothing for this repo. The shpjs devDependency added by B8 F2 is therefore SCA-unverified |
Aikido MCP / local scanner | tooling outage (security gate) | medium — the gate is skipped, not passed | S | P2 | recovered 2026-07-27 (B34): aikido_full_scan completed with 0 findings on the changed files; only taint analysis timed out. Exit 2 did return in the same day's follow-up pass, and the useful detail is that it is not a total outage: the wrapper reports the error while Opengrep's own summary says Ran 316 rules on 1 file: 0 findings, so SAST results are present and clean — the exit code tracks "some files were skipped or only partially analyzed", not a failed scan. Read the summary text before treating a red scan as a blocked gate. The SCA half (incl. shpjs) remains unverified |
|
| Missing-consent window fixture releases its exclusive lease during the observation window — readers can interleave (rare flake). Full fix: session-level advisory lock spanning delete→op→restore + updated fault-path pins | tests/helpers/testDatabaseLease.ts (withMissingInviteConsentFixture) |
test-infra race | low (ms-wide window; shared leases added 2026-07-23 narrow it) | M | P2 | open | |
users collection has no roles (all admins omnipotent) |
src/collections/Users.ts |
RBAC | medium (small trusted admin set) | M (schema migration) | P1 before widening /admin |
closed 2026-07-27 (admin|editor; migration 20260727_032523_add_users_roles; roles-admin-payload.md) |
AGENTS Known Gaps #1 |
Any campaign user can enumerate staff names/roles (canReadCampaignUsers) |
src/utilities/access/campaignUsers.ts |
info disclosure | low | S | P2 | open | |
removeSupporterData deletes via overrideAccess: true while canDeleteSupporter is admin-only |
src/app/(campaign)/campanha/actions/supporter.ts |
access asymmetry (intentional LGPD path) | low | S | P3 | closed 2026-07-29 (P3-D): documented in-code — the delete is the staff-driven titular-removal flow, scope already proven by assertCanManageSupporter on the exact row; day-to-day deletes stay admin-only |
|
Legacy public consent flows hardcode document IDs (consent: 2) |
src/app/(frontend)/actions/submitWhatsapp.ts |
correctness | medium | S | P1 | closed 2026-07-25 (Pass 2 D3): resolves Consent.key = 'whatsapp-inscricao' fail-closed; migration 20260725_170000 tags the live doc (id 2, key vazio) com a chave |
AGENTS Known Gaps #2 |
| E2E suite thin (17 specs / 51 cases measured 2026-08-03, Pass 5 — grew from 17/48 at Pass 4; most campaign CRUD still int-only, so the substance holds) | tests/e2e/ |
coverage | medium | L | P2 | open | |
| CI runs on GitHub Actions only (no secondary pipeline) | .github/workflows/ci.yml |
process | low | M | P3 | open | |
E2E campaign journeys flake with 2 workers — root cause narrowed 2026-07-26 (B13), and it is not only latency: (a) campaignMunicipalities/campaign-pwa click the leader consent checkbox before hydration attaches the handler, and (b) the municipality fixture picks names by regex, so "Conde" also matches "Condeúba". Reproduced on a clean main tree, so it is not a B13 regression; both specs pass in isolation |
tests/e2e/campaignMunicipalities.e2e.spec.ts, tests/e2e/campaign-pwa.e2e.spec.ts |
test-infra | medium (masks real failures in the delivery gate) | S | P2 | open — mitigated 2026-07-31 (Pass 4): hydration helpers adopted (checkRadixWhenHydrated); the /Cairu/i substring literals are GONE and a catalog-keyed guard bans unanchored municipality-name regexes (miss #73, PR #93); prod full suite 48/48 green three times this session. What remains is the dev-mode campaignLeaderships chip failure — different class, own Pass 4 row below |
|
Two e2e tests fail deterministically at one worker on a clean main tree (measured 2026-07-27 by stashing the whole B34 delivery and re-running): campaignNearestMunicipality "coordinator in Salvador is sent to the zone list" cannot find the Salvador — ZE 1 link on ?q=Salvador, and campaignMunicipalities "advisor opens a demand and decides it" cannot find its demand heading. These are not the 2-worker flake in the row above — retrying and lowering concurrency do not help — so the gate currently ships with two red specs whose failure nobody has diagnosed. Widened 2026-07-28 (B17), same stash-and-compare method: it is not two tests but most of campaignMunicipalities — five failed at one worker on the clean tree (:56, :125, :292, :394, :446) against four with the delivery applied, and the failing set is not stable between runs (page.waitForURL timeouts, a getByLabel('Média') strict-mode violation matching both the popover trigger and its input, a click whose target the bottom nav intercepts). Practical consequence for the next delivery: comparing the count of red tests against a stashed tree is the only way to read this spec — naming the two tests above is no longer enough. 2026-07-28 (Pass 3): root cause measured — unanchored municipality-name regex at campaignMunicipalities.e2e.spec.ts:263 (23/435 prefix collisions in the catalog: Conde/Condeúba, Catu/Caturama, Laje ×3, Barra ×4); the getByLabel('Média') strict-mode violation and the hydration-race consent checkbox are unchanged; fixture drift measured — the e2e cleanup lacks E14's engagementLevel/levelNote/catalog-name reset and owns+deletes the leased apoiador-cadastro row. Fix scheduled as P3-C (entrega-engenharia-p3.md) |
tests/e2e/campaignNearestMunicipality.e2e.spec.ts:156, tests/e2e/campaignMunicipalities.e2e.spec.ts:56,125,292,394,446 |
broken test (or real regression) | high — a permanently red gate trains everyone to ignore it, which is how a real failure ships | S | P1 | closed 2026-07-29 (P3-C): full suite 31/31 green at 2 workers. Root causes fixed — stale sort-summary copy (déficit da meta→Cobertura), exact trigger name vs appended status suffix (now anchored regex), option click not selecting (role=option + toPass hydration-retry loop, same class as checkRadixWhenHydrated), stretched-overlay click needing force, leader login using the advisor's password (each createCampaignUser mints its own), and 30 s test timeout → 60 s under cold-compile load. Guards: unanchored-template-regex ban in codebaseConventions, global expect 10 s |
|
| Post listing cache holds all published posts, filters in memory | src/utilities/posts.ts |
scale (fine at ~tens of posts) | low | M | P3 | open — watch corpus growth | |
Petition generateStaticParams uses uncached getDocuments without select/depth: 0 |
src/utilities/documents.ts consumers |
over-fetch (build-time) | low | S | P2 | closed 2026-07-23 (Phase 2: getPetitionIds with depth: 0 + empty select) |
|
Compare mode (?compare=) still queries per candidate uncached cross-request |
src/utilities/municipalityMapData.ts |
perf | low | M | P3 | open — React cache() dedups in-request; add unstable_cache if profiling warrants. Verified still true 2026-07-31 (Pass 4): path moved to src/utilities/municipality/municipalityMapData.ts:177-218 → loadCandidateVotesByCityZone (municipalityElectoralBaseline.ts:242-278), 3 years in parallel, no unstable_cache |
|
Map bundle serializes all years × scenarios (~100KB raw), +63.6 KB after B13 (competitiveRankByYear is ~57 KB of it: three years × 417 codes × {rank, candidates}, paid on every dashboard load even by the coordinator who never opens the "posição no município" scale) |
municipalityMapContract.ts consumers |
payload size | low — gzip crushes repeating keys to ~10KB on the wire; instant year/scale switching is a core analysis flow worth the bytes | M | P3 | the 2026-07-23 deferral asked for profiling before acting; B13 supplied the number, so the trim is now scheduled as B13+ F4 (roadmap fill-in) | B13+ F4 |
Props→state useEffect mirrors in list controls/comboboxes |
MunicipalityListExpectedVotesControl, AsyncSearchCombobox, StrictCombobox |
pattern | none — reviewed 2026-07-23: guarded-equality sync is the correct "server echo vs local draft" reconciliation (A10 pattern), not derived state | — | — | closed (accepted as correct) | |
User-visible "Praça" copy survived the M1 rename (map panel title "Mapa das Praças", admin field descriptions, provisional Onda 0 consent texts, SupportStatusBadge summary, fallback/error strings, code comments) |
rg -n 'Praça' src (~96 hits in 53 files, ~55 user-visible — 2026-07-25 audit) |
copy drift | low (cosmetic; consent texts are provisional Onda 0 copies) | S | P2 | closed 2026-07-28 (Pass 3 verify): W4f executed 2026-07-25; the remainder is the provisional Onda 0 consent texts (legal batch, D6) | Pass 2 W4f |
Access checks re-read the actor per Local API call: getFreshCampaignUser caches in req.context, but a payload.find({ user, overrideAccess: false }) that passes no req gets a fresh context, so one /campanha/municipios render issues ~5 identical campaignUser reads on top of the layout's own pair. Re-measured 2026-07-27 (B34) on /campanha/liderancas, where it is an order of magnitude worse: payload.find on leadership at depth: 1 with an actor and overrideAccess: false costs 371 statements / 185 ms, of which 367 are the identical select … from "campaign_user" — the same read once per document and per populated relation. The controls isolate the cause: overrideAccess: true is 4 SQL / 11 ms, and depth: 0 with the actor is 103 SQL / 47 ms, so it is the access layer on the populate path getting a req that does not carry the memo |
src/utilities/access/shared.ts + every overrideAccess: false caller |
perf (round-trip fan-out) | medium — 185 ms of a 229 ms page load, and B34 made that page one the mesa re-renders on every chip toggle (see F7); each statement is ~2 ms local but crosses gru1↔sa-east-1 in production |
M | — | closed 2026-07-28 (B34+) — memoizePerRequest keys on the req object (WeakMap), not req.context (measured: 367 contexts, 1 req; int campaignAccessRequestMemo pins 1 campaign_user read per scoped leadership list at depth 1 and role downgrade on the next op). Role-mutation audit: reloadUnrestrictedActor bypasses the memo path; pins cover cross-actor isolation |
|
authenticateCampaignToken reads the actor twice: Payload's JWT strategy already fetches the user document, then findByID(depth: 1) re-reads it — the second read's only unique contribution is populating avatar for the sidebar |
src/utilities/campaignAuth.ts |
perf (a round trip in the layout, so on every page) | low | S | P2 | open — dropping it needs the avatar resolved another way, plus pins on removePrivateAuthFields and on role-downgrade freshness |
RS+ |
LeadershipStateDeputyRelationCell (B31) keeps its own Popover, its own ResizeObserver collapse math and a 16 px "Ver mais…", while the sibling column in the same table now uses the shared MunicipalityPortfolioCell (always-mounted on pointer-fine, Drawer on pointer-coarse, 24 px targets, ARIA combobox). Two chip editors, one table, different keyboard and touch contracts |
src/components/campaign/leadership/LeadershipStateDeputyRelationCell.tsx vs src/components/campaign/shared/MunicipalityPortfolioCell.tsx |
duplication + a11y drift | low functionally, medium for consistency — the dobradinha chips are the only ones in the table a thumb cannot edit safely | M | P2 | closed 2026-07-28 (Pass 3 verify): resolved by B37 — the cell moved to shared/ as a thin wrapper over RelationChipCell (chip/search/copy only; the ~165 duplicated lines no longer exist, and ResizeObserver lives in exactly one component). Original status: open 2026-07-27 (B34), trigger fired — the reuse review measured ~105 line-for-line duplicated lines of ResizeObserver collapse math between the two cells (constants, the first-callback guard, the lastVisibleTop + 1 filter, the trailing-width loop, the expandToggle), including one verbatim comment. Scheduled as B34+ F2 (useChipRowOverflow); the duplication predates B34 (it was born between AdvisorMunicipalityCell and B31), which is why it is an extraction and not a regression. B37 still does not by itself justify a generic abstraction — it plugs the shared cell into a third relation. Re-measured post-rebase: ~165 lines, not ~105 — the twin also copies the sameIdSet prop adoption, the showAll/measuring/clamping derivation, the expandToggle, and the optimistic delta with per-delta rollback and toast, so what comes out is useRelationChipCell (measurement plus the optimistic reducer). Which means B37 does not retire this: whoever deletes LeadershipStateDeputyRelationCell does. The drift is now three-way in one row — that cell opens a Popover from a pencil on touch, while the column beside it opens a Drawer, and the Status column's own comment asserts the table is Popover-only |
B34 |
B34 shipped a measured First Load JS regression: /campanha/liderancas 246 → 304 kB and /campanha/assessores 253 → 295 kB (Next's minified figure, two full builds against a stashed clean main). Assessores already imported the static Bahia catalog, so its +42 kB is the cell (Drawer, links, combobox contract, measuring machinery) and only the remaining ~16 kB on lideranças is the catalog reaching a route that lacked it — the same cost B14 already named. The two fixes oppose each other: pulling the catalog out of the browser fattens the payload of every chip toggle, shrinking the payload to {id,slug} keeps the bundle |
src/lib/municipalityPortfolio.ts, src/utilities/municipalityPortfolioIndex.ts, src/components/campaign/shared/MunicipalityPortfolioCell.tsx |
perf (bundle + per-render payload) | medium — two of the most-used staff lists, on phones over 4G | M | — | closed 2026-07-28 (B34+ F1) — index {id,slug}; name/region from municipalityCatalog (int pins slug+region vs catalog). RSC −21,9 kB raw/rota; First Load JS unchanged vs clean main (306/298/271 kB). Drawer next/dynamic deferred |
B34+ ✓ |
AdvisorsTable mixes an edit mode (name/e-mail/phone behind an "Editar nome e contato" toggle) with a column that is always live (municípios). The toggle survives because those are text fields with no chip readout, but the row now speaks two interaction grammars at once |
src/components/campaign/advisor/AdvisorsTable.tsx |
UX inconsistency | low | M | P3 | open 2026-07-27 (B34) — resolve by giving the text fields the same edit-in-place treatment, not by re-hiding the chips | B34 |
| safeMessages entries that can never match their thrown gate messages — exact-string matching in mapCampaignFormActionError collapses them to the generic error: "movem demandas", "registram custos", "registram estimativas" (thrown with "e o candidato", safelisted without) and escalated-demand decide ('…Coordenador Geral ou Candidato.' vs '…Coordenador Geral.') — the last user-reachable (advisor deciding an escalated demanda gets the generic message). +1 dead entry ("podem abrir demandas", thrown nowhere). Same collapse class in copy: podem gerenciar lideranças. ×3, podem gerenciar apoiadores. ×3, gerenciam organizações. ×3, anexam comprovantes. ×2, 'Celular brasileiro inválido.' ×3 | throws: actions/demand.ts:63,98, actions/votePledge.ts:143, collections/CampaignDemand.ts:113; safelists: demandas/[slug]/formActions.ts:26,27,77, pledgeFormActions.ts:63, demandas/nova/formActions.ts:34 | correctness (message-only; writes still correctly refused) | medium — real refusal reasons never reach the UI | S | P2 | closed 2026-07-31 (Pass 4 verify): P3-A landed 2026-07-29 — shared consts in lib/schemas/campaignDemand.ts:69-74 / votePledge.ts:57-58 imported by both throws and safelists (CAMPAIGN_DEMAND_TRANSITION_SAFE_MESSAGES inclui escalada, VOTE_PLEDGE_ESTIMATE_STAFF_MESSAGE); old mismatched strings absent from src/; guard at codebaseConventions.unit.spec.ts:216-263 bans literals in throws/safeMessages; dead "podem abrir demandas" absent | P3-A |
| Petition signature flow trusts a client-posted consentId — written into both signature and subscription with no server-side resolution, while the page renders the text from petition.form.consent; a tampered request records consent to a different document than shown (LGPD provenance weakened, no leak). The WhatsApp sibling resolves by stable key, fail-closed (Pass 2 D3) | src/app/(frontend)/actions/submitPetitionSignature.ts:9,40,49; supplier src/components/PetitionForm.tsx:55-63 | LGPD correctness | medium | S | P2 | closed 2026-07-31 (Pass 4 verify): P3-B landed 2026-07-29 — submitPetitionSignature.ts:19-31 resolves consent from petition.form.consent; petitionFormSchema has no consentId (lib/schemas/petition-form.ts:5); PetitionForm receives only consentHTML; multi-write still transactional (:33-78) | P3-B |
| E2E/int fixture drift: e2e cleanup lacks E14's engagementLevel/levelNote/catalog-name reset (int got it 2026-07-27); e2e proxy owns+deletes the shared leased-key consent row apoiador-cadastro and reimplements the lease machinery; 28 hand-written campaignUser blocks (~280 lines) where one builder serves; campaignListUrl contract pinned at 3 levels (shared spec + 5 domain re-pins); invite-origin double-pinned across suites (~160 pure lines paying int cost); 43 manual fixtures.own() redundant with the undocumented auto-ownership proxy | tests/e2e/fixtures/campaignE2EFixtures.ts, tests/helpers/campaignFixtures.ts, tests/e2e/campaignMunicipalities.e2e.spec.ts:12-53 | test-infra drift | medium — flaky/lying fixtures in the delivery gate | M | P2 | partially closed 2026-07-29 (P3-C): e2e cleanup reset aligned (name/engagementLevel/levelNote), leased apoiador-cadastro consent via ensureLeasedConsent, 28 hand-written campaignUser blocks → fixtures.createCampaignUser builder. Remaining open (verified 2026-07-31, Pass 4 — counts drifted): campaignListUrl domain re-pins now 6 (shared spec stays the contract owner), invite-origin double-pin still ~158 pure int lines (unit owns the policy, 92 lines), manual fixtures.own() now 49 (was 43) — folded into P4-K | P3-C |
| Advisor-scope RBAC fragment spelled 7× across 6 access modules + near-verbatim scoped-read prologue twins + memoized getAccessible*Ids machine triplicated (+ getAccessibleContactIds missing its siblings' isCampaignUnrestricted → null early return — safe today only by call order) + access micro-cluster (byte-identical advisor/leader branches in canCreateSupporter; cross-domain alias allocationDecisions→municipalityUpdates; deprecated LeadershipConsentDescriptor wrapper still with 4 call sites) | src/utilities/access/votePledges.ts:46, demands.ts:42, municipalityUpdates.ts:40, supporters.ts:48, activities.ts:43, contacts.ts:29,44, leaderships.ts:41,73,114, municipalities.ts:140-169, leaderships.ts:86-129, contacts.ts:23-100 | duplication (policy spelled N times) | medium — a rule change must land in 7 spots | M | P2 | closed 2026-07-29 (P3-D): advisorMunicipalityScopeWhere + resolveActorScopedRead + resolveAccessibleIds in access/shared.ts; branches collapsed; LeadershipConsentDescriptor deleted (full ConsentDescriptor now); contacts gained the unrestricted early return; guard spec bans the fragment outside access/shared.ts (8-file allowlist); unit pin accessShared.unit.spec.ts; 807 unit + 483 int green. The allocationDecisions→municipalityUpdates alias was verified CORRECT (deliberate same-policy reuse, documented) and stays | P3-D |
| "Resolve display names by ids" spelled 7× (one already a type-losing slug-union → Record<string,unknown>) with ~8 admin bypasses missing the mandatory justification comment (plus ~10 more across WebAuthn ceremony, import token, phone invariant, consent, invite, map/page data) | campaignDashboardData.ts:57-74, leadershipData.ts:56-72, organizationData.ts:64-79, campaignDemandData.ts:67-133,250-259, stateDeputyData.ts:251-266, votePledgeData.ts:130-146, activityDetailPageData.ts:25-51, municipalityUpdatePageData.ts:86-99 | duplication + convention breach | low-medium | M | P2 | closed 2026-07-29 (P3-E): typed wrappers in loadNamesByIds.ts (bypass comment once; the Record<string,unknown> union died); ratchet guard in codebaseConventions pins per-file undocumented-bypass counts (only shrinks); plan-named sites documented | P3-E |
| Pledge aggregate fold spelled twice — list vs dossier surfaces must never disagree (E9 had to add lastPledgeAt to both); staff scope-read proliferation ×3 sharing a 5-field base select (E11's own-read documented) | votePledgeData.ts:53-107 vs votePledgeViews.ts:80-108; campaignMunicipalityScope.ts:39-59, municipalityTriggers.ts:202-243, visitPlannerData.ts:162-188 | duplication | medium — two surfaces can silently diverge | M | P2 | closed 2026-07-29 (P3-E): aggregatePledgesWhere delegates the bucket fold to aggregateMunicipalityPledgesFromRows (int pin: dossiê === lista); loadMunicipalityScope(payload, user, where, { extraSelect }) with extras in the React cache() key (unit pin: cache-key separation), adopted by municipalityTriggers — its own município read died | P3-E |
| MUNICIPALITY_CATALOG_CACHE_TAG lives beside a next/cache import (no server-only) and is imported by a pure module — latent bundle hazard of the B14/B34+ class; electionCache.ts (tag alone) is the correct precedent | src/utilities/municipalityRevalidation.ts:1,11 + importers revalidateRequest.ts:3, municipalityPortfolioIndex.ts:9 | boundary | low | S | P2 | closed 2026-07-29 (P3-E): tag moved to municipalityCatalogCache.ts (tag alone, server-only, the electionCache precedent); municipalityRevalidation.ts marked server-only | P3-E |
| List-URL machine family spelled per domain: sort-toggle ×4, sort-value codec ×3, multi-toggle+page-reset ×4, canonicalize-round-trip ×6, serialization tail ×4; plus firstValue ×3 spellings, XNotFoundError ×3, parseRegionsParam+canonicalTerritoryBySearchValue ×2 (bundle-constrained — can't join campaignListUrl) | municipalityListUrl.ts:426,440,451,508, leadershipListUrl.ts:192,203,216, stateDeputyListUrl.ts:158,165,178,202, territoryListUrl.ts:130,138,150,189, detailTabUi.ts:8-10, municipalityUpdatePageData.ts:39-42 | duplication | medium | M | P2 | partially closed 2026-07-29 (P3-F): createSortToggleHref adopted ×4; territoryRegionParam.ts (byte-identical parser died ×2, bundle-constrained as planned); createEntityNotFoundError adopted ×3. Remaining open (defer + trigger: 3rd sort dropdown or a 5th multi-filter): sort-value codec ×3, multi-toggle+page-reset ×4, canonicalize-round-trip ×6, serialization tail ×4, firstValue private spellings ×3. Verified 2026-07-31 (Pass 4): trigger NOT fired — still 3 codecs / 4 multi-toggles; leadership has no sort dropdown codec | P3-F |
| Legacy filter shells ActivityFilters↔SupporterFilters duplicate ~50 lines each and bypass useCampaignListFilterNavigation (they lose its no-op navigation guard — an RSC round trip fires even when re-selecting the active value); CampaignSearchForm hand-serializes ?q= past the canonical builders (latent: first future filter param silently stripped on search submit); search idiom split (4 lists debounce vs 3 explicit submit, no recorded decision) | components/campaign/activity/ActivityFilters.tsx:80-146,185-213, components/campaign/supporter/SupporterFilters.tsx:109-146,156-227, components/campaign/shared/CampaignSearchForm.tsx:30-31,41-43 | duplication + latent bug | medium | M | P2 | closed 2026-07-29 (P3-F): useCampaignFilterValues (committed-href no-op guard) + CampaignCollapsibleFilterPanel extracted and adopted by both shells; CampaignSearchForm.filterParams carries the canonical serialization of active filters (organizações adopted — ?kind= survives a search submit); search idiom unified on 1 s debounce everywhere (SEARCH_DEBOUNCE_MS), explicit submit kept as the immediate path in CampaignSearchForm — decision recorded here | P3-F |
| Scripts re-spell the CLI skeleton: die ×7, sha256 ×5, download→cache→sha256 ×5 (~20 lines each), writeJson/writeText ×5 (the newest script copied the OLD form — live drift), cacheDir ×5, dotenv preamble ×6; two municipality-name resolvers diverged — the supporter/CSV path lacks the TSE alias table and rejects 4 of 5 curated spellings ("Camacã", "QUINJINGUE", "Santa Terezinha", "Muquém do São Francisco"); LOCAL_HOSTS ×2 diverged; one-shot generate-remodel-municipalities-migration.mjs left behind holding 2 allowlist entries | scripts/seed-tse-results.mjs, build-bahia-geometries.mjs, build-municipality-zone-geometries.mjs, build-bahia-demographics.mjs, build-bahia-tse-city-codes.mjs, seed-posts.mjs, db-pull.mjs; lib/schemas/supporter.ts:19-28 vs lib/electionResults.ts:120-184 | duplication + latent import bug | medium | M | P2 | closed 2026-07-29 (P3-H): scripts/lib/cli.mjs (dieWithLabel/sha256Hex/loadCliEnv/ensureCachedDownload/writeRepoFile/LOCAL_HOSTS/isRemoteDbOverrideSet) adopted by 10 scripts, artifacts proven byte-identical (only accessed dates moved, reverted); latent bug fixed: single fold municipalityNameAliases.ts — supporter import now accepts all 5 curated spellings, pinned for BOTH paths in municipalityNameAliases.unit.spec.ts; db-pull gained the postgres host (deliberate); one-shot deleted + 2 allowlist entries freed; CLI-skeleton ban guard (die/sha256/dotenv/LOCAL_HOSTS) | P3-H |
| utilities/ subfoldering trigger (Pass 2 D1) has FIRED: 122 top-level modules (was 92); new in 30 days (git --diff-filter=A): municipality +21, supporter +10, territory +5, leadership +5, webauthn +5, visit +4 — 6 domains past "3rd new module in a month". Pure modules stranded in the layer: campaignTime.ts (140 lines, zero imports), relationship.ts (33 lines, zero imports) → lib/ | src/utilities/ | organization | low (path churn) | M | P2 | closed 2026-07-29 (P3-J): 41 modules moved — municipality/ (incl. campaignMunicipalityScope, goalCoverage), supporter/, territory/ (+loadTerritoryOverview), leadership/, visit/, webauthn/; direct imports, no barrels; campaignTime + relationship → lib/; top-level of src/utilities/ pinned in codebaseConventions (D1 trigger encoded — new top-level module fails the build unregistered); codebase-map.mdc + ARCHITECTURE.md updated in the same delivery | P3-J |
| createdBy/actor-stamp field duplicated verbatim across 5–7 collections + stamping hook ×3 (organic drift already: Supporter/Leadership grew read:, CampaignInvite is required); a field factory precedent exists (voteEstimateScenarioFields); trimmedText ×4 byte-identical in collection configs (pure helper outside lib/) | CampaignDemand.ts:382-394, Organization.ts:121-133, StateDeputy.ts:105-117, AllocationDecision.ts:140-151, VotePledge.ts:211-222, Supporter.ts:214-227, Leadership.ts:222-234; hooks Organization.ts:54-62, StateDeputy.ts:54-62, Leadership.ts:63-69 | duplication | low | S | P3 | closed 2026-07-29 (P3-I): campaignAuditFields.ts (systemStampedActorField with required/indexed/readAccess data + stampCampaignCreatedBy) adopted by 9 collections — zero schema delta (declaredBy keeps indexed: false, no migration); trimmedText → lib/text.ts ×4; guard bans hand-spelled actor fields | P3-I |
| Route layer: W4d form-action guard + staff-page/route-schema leftovers — filename guard escaped actions/*.ts ladders (closed OPS14 #241); password.ts/auth.ts bespoke but now allowlisted; route body schema re-spelled instead of omit/extend (municipios/advisors/route.ts:16-20) | actions/auth.ts, actions/password.ts, municipios/advisors/route.ts:16-20 | duplication + convention breach | low | S–M | P3 | partially closed 2026-08-02 (OPS14 #241 / P3-I): guard sweeps mapCampaignFormActionError across (campaign) (not filename); profile avatar ×2, leaderSupporter, convite-login migrated to wrappers; auth/password allowlisted with reason. Remaining open (defer + trigger: next bespoke ladder or route schema): municipios/advisors/route.ts omit/extend | P3-I |
| Convention guards dodgeable: banned-vocabulary regex /praç|núcleo/i misses unaccented praca/nucleo (the sibling "Plano de Ação" row was hardened accent-tolerant); server-only guard sweeps only payload\|@payload-config value imports — next/cache/next/server importers (municipalityRevalidation.ts, campaignJsonMutationRoute.ts) are server-bound and invisible; no mechanism stops a client component value-importing the three client-safe modules that drag the 623 KB TSE artifact (municipalityTerritorialClass, municipalityPotential, territoryIntraCaptureBenchmark — B13 avoided by discipline) | tests/unit/codebaseConventions.unit.spec.ts:50,71-80 | tooling gap | low | S | P3 | closed 2026-07-29 (P3-K): vocabulary regex now /pra[çc]\|n[úu]cleo/i (2 genuine-data hits allowlisted); server-only sweep widened to next/cache/next/server/next/headers (campaignJsonMutationRoute marked); ESLint no-restricted-imports bars value-imports of the 3 artifact carriers + the artifact itself from src/components/** (type imports safe — measured: the 2 current consumers are type-only) | P3-K |
| Constants/types single-sourcing cluster: territorial-class union declared twice across the lib/utilities boundary (adding a class compiles green and mis-sorts); DAY_MS ×3 (one exported from suggestionCatalog's public interface); 1_000_000 vote-count ceiling ×6 across two validation layers; password 8/128 + token 20/256 policies ×2; TSE baseline years mirrored (documented excuse dies with a leaf module); formatBrazilianPhoneDisplay near-twin (1 call site); advisorMunicipalitiesBatchSchema missing the dedup transform its 3 siblings have; voteEstimate formatters re-spell the pt-BR format without the E18 zero-fraction pin; hasAnyEstimate weaker twin of hasAnyVoteEstimate | lib/territorialClassSortWeight.ts:10-13, lib/suggestionCatalog.ts:15-19,175, lib/engagementLevel.ts:97, utilities/municipalitySignal.ts:32, lib/formData.ts:123-133, lib/schemas/votePledge.ts:9,32, lib/schemas/invite.ts:11,39, lib/voteEstimate.ts:27,102-126, lib/schemas/advisor.ts:69-72 | shotgun surgery | low-medium | S each | P3 | closed 2026-07-29 (P3-K): territorial-class union → lib/territorialClassAnchors.ts (type error on new class now); DAY_MS → lib/text.ts (out of suggestionCatalog's public interface); MAX_VOTE_COUNT in lib/schemas/primitives.ts ×6 folded; campaignPasswordSchema exported (invite delegates); lib/electionYears.ts leaf (mirror + documented excuse died); formatBrazilianPhoneDisplay delegates; batch-schema dedup transform (action's [...new Set] died); formatElectionNumber in the 3 voteEstimate formatters; hasAnyEstimate deleted | P3-K |
| Form-action feedback JSX at 15+ call sites with aria-live on only 5 — 10 error alerts silent to AT; success→toast effect ×7; borderless inline-input class ×4 verbatim; deprecated showLabels prop with zero call sites (knip can't see props) | 15 forms incl. LeadershipForm.tsx:162, DemandForm.tsx:152, MunicipalityUpdateForm.tsx:168, PledgeEstimateForm.tsx:74; VoteEstimateScenarioStrip.tsx:18,27,32 | a11y + duplication | medium (a11y) | S–M | P3 | closed 2026-07-29 (P3-G): CampaignFormActionMessage (aria-live on both variants + formFieldErrors) adopted by all 15+ sites — every alert now announces; useCampaignFormSuccessToast adopted ×7; campaignInlineInputClassName ×4; showLabels deleted; JSX ban guard + a11y pin (campaignFormActionMessage.unit.spec.tsx, unit include extended to .tsx) | P3-G |
| homeSearch text-search pipeline re-spelled per domain (gate staff → normalize → contains + limit: 0 → word-start → relevance → cap), plus unbounded limit: 0 reads ×5 before the in-memory refine | src/utilities/homeSearch/searchHomeActivities.ts (88) ↔ searchHomeDemands.ts (100), sim 0.72 (~63 shared lines); leaderships 0.53; limit: 0 at searchHomeActivities.ts:43, Demands:49, Leaderships:61, Advisors:37, StateDeputies:45 | duplication + scale | medium — every keystroke can pull a full collection slice | M | P2 | open — Pass 4 | P4-A |
| HomeSearch result groups ×6 near-verbatim (Advisor↔Leadership, Activity↔Demand almost line-for-line; differ only in id/title/href/secondary) | src/components/campaign/dashboard/HomeSearch*Group.tsx (40–63 lines each; Municipality is the real variant) | duplication | low | M | P2 | open — Pass 4 | P4-B |
| notificationEvents re-spells names-by-id ×7 instead of the P3-E wrappers | src/utilities/notification/notificationEvents.ts (:41-57, :143-174, :212-236 — findByID + select:{name}/{name,slug}) | duplication | low | S | P2 | open — Pass 4 | P4-C |
| Notification write path: markAllCampaignNotificationsRead updates up to 200 rows with no transaction; subscribeCampaignPush validates with .trim() only and always creates (re-subscribe → unique violation → generic error); unsubscribeCampaignPush deletes with overrideAccess: true while canDeleteOwnPushSubscriptions already scopes the owner | src/app/(campaign)/campanha/actions/notifications.ts:69-91,100-143,172-176 | correctness | medium — partial mark-read on mid-batch failure; hostile re-subscribe UX; bypass surface | S | P2 | open — Pass 4 | P4-D |
| Wizard info Drawer chrome spelled ×3 (button → bottom sheet with title+description+body); skip trailing spelled ×2 (13 lines each, both {label, href}) | WizardSignalTypeStep.tsx:95-112, WizardTrendChoiceStep.tsx:115-132, WizardLeadershipStep.tsx:167-179; WizardSignalSkipTrailing.tsx ↔ WizardTrendSkipTrailing.tsx | duplication | low | S | P2 | open — Pass 4 | P4-E |
| MunicipalityListMobileCards (client) value-imports municipalityColumnLabels from municipalityListUrl — a ~19 KB URL module (pulls isMunicipalitySlug → municipalityCatalog) for a string map whose type already lives in municipalityLabels | src/components/campaign/municipality/MunicipalityListMobileCards.tsx:26 | boundary (B14 class) | low | S | P2 | open — Pass 4 | P4-F |
| HomeSearchProvider wraps the whole chrome incl. actions + summarySlot non-consumers, and the provider value changes on every keystroke | src/components/campaign/dashboard/CampaignHomeStaffChrome.tsx:41-56, useHomeSearchQuery.ts:63-74 | state placement | low — actions/summary re-render per keypress | S | P2 | open — Pass 4 | P4-G |
| Unconditional aria-live="polite" per chip cell (~50 always-registered regions on lideranças); the overlay family already lazy-mounts its region from first open | src/components/campaign/shared/RelationChipCell.tsx:821-823 vs CampaignCellEditOverlay.tsx:114-120 | a11y scale | low | S | P2 | open — Pass 4 | P4-G |
| Bypass ratchet dodge: one "bypass" comment in the first 40 lines documents every overrideAccess: true in the file — notification/* has 20, campaignWebAuthnCeremony 4, zero per-call justifications | codebaseConventions.unit.spec.ts:541-623; src/utilities/notification/*; src/utilities/webauthn/campaignWebAuthnCeremony.ts | guard dodge | medium — the P3-E "comment justifying" intent is satisfied in letter, not spirit | M | P2 | open — Pass 4 | P4-H |
| campaignLeaderships chip-cell spec fails deterministically in dev mode on clean stage (4/4 runs; stash-and-compare 2026-07-31): instrumented with console/response listeners — no POST fires in 60 s; signatures vary per run (option visible + click no-ops; option count 1 → 0 after first fill). Prod build green (48/48 full suite ×3 the same day). NOT the P3-C hydration class (input onChange works, listbox renders); a toPass retry was attempted and reverted — retrying cannot fix what is not a race | tests/e2e/campaignLeaderships.e2e.spec.ts:91-101; suspect surface RelationChipCell suggestion state vs dev-mode RSC streaming of municipalityIndex | test-infra / possible dev-mode cell bug | medium — a red local signal teaches people to skip the spec | M | P2 | open — Pass 4 (debug workflow, not retry; E2E_PROD=1 is the honest local mode meanwhile) | P4-L |
| God modules on the delta: municipalityPageData 503 lines / loadMunicipalityListPageBundle 168; municipalityListUrl 500 lines / 23 exports (widest interface in utilities); RelationChipCell 841 lines / ~11 state hooks (measurement 80, commit 140, render 190) | src/utilities/municipality/municipalityPageData.ts:307, src/utilities/municipality/municipalityListUrl.ts, src/components/campaign/shared/RelationChipCell.tsx | bloater | low | L | P2 | open — Pass 4 (defer + trigger: next facet/sort; next chip relation or AT-scale complaint) | |
| createSupporter does not mark touchedMunicipalities — a supporter-only fixture path can leave strategy fields dirty on the row | tests/helpers/campaignFixtures.ts:769-799 | test-infra honesty | low | S | P3 | open — Pass 4 | |
| (app)/assessores/novo/page.tsx redirects without requireCampaignPageActor — the P3-I guard bans getCampaignUser() in pages but not a missing actor helper (1 of 34 pages) | src/app/(campaign)/campanha/(app)/assessores/novo/page.tsx:1-6 | convention gap | low (layout still session-gates) | S | P3 | open — Pass 4 | |
| deriveDecisionAuthor re-spells the actor stamp (data.decidedBy = req.user.id) while campaignAuditFields.ts owns the pattern (stampCampaignCreatedBy hardcodes createdBy) | src/collections/AllocationDecision.ts:53-57 | duplication | low | S | P3 | open — Pass 4 | |
| homeSearch int suites cloned ×7 (662 LOC of same-shaped it('returns empty…'/'scopes…'/'rejects leaders…') — describe.each over domains would own the shared contract | tests/int/homeSearch*.int.spec.ts | duplication | low | M | P3 | open — Pass 4 | |
| Flake inventory: fixed timeouts (CEREMONY_TIMEOUT=60s, HomeActions timeout: 15000 ×many, ColumnPicker REFRESH=20s), Date.now() markers in fixture names, e2e deep links now claimed (Pass 4 PR #93) but shared leased consent rows remain load-bearing | tests/e2e/*, tests/int/municipalityPageData.int.spec.ts | flake risk | low | M | P3 | open — Pass 4 (prefer event waits; allocate municipalities — both guarded since Pass 4) | |
| P5-P1 (remediated in-session): createCampaignNotification scheduled push with queueMicrotask under req.transactionID — microtasks run before withPayloadTransaction commit, so push could fire for an uncommitted / rolled-back notification | src/utilities/notification/createCampaignNotification.ts:38-40; src/utilities/payloadTransaction.ts:40-65 | correctness (txn ordering) | high — push for phantom notification; noisy failure on rollback | S | P1 | closed 2026-08-03 (Pass 5 P1) — after-commit registry + existence-safe send | P5-P1 |
| Wizard/action href knowledge dual-switched (wizardActionChain chain + previous) and wide URL surface (campaignActionRoutes 252 LOC / 31 exports) | src/lib/wizardActionChain.ts; src/lib/campaignActionRoutes.ts | duplication + wide interface | medium — every new wizard step touches both switches | M | P2 | open — Pass 5 | P5-A |
| Staff-detail wizard action id list re-spelled ×3 (activity / leadership / demand adapters) | src/lib/activityQuickActions.ts:18-24; campaignQuickActionLeadership.ts:19-25; campaignQuickActionDemands.ts:14-20 | duplication | low | S | P2 | open — Pass 5 | P5-B |
| /campanha/acoes/[slug] page is a 296-line auth+load+branch dispatcher | src/app/(campaign)/campanha/(app)/acoes/[slug]/page.tsx | bloater / divergent change | medium — every quick-action lands here | M | P2 | open — Pass 5 | P5-C |
| Detail pages double-run actor+load in generateMetadata and page body (org / dobradinha / demanda) | organizacoes/[slug]/page.tsx, dobradinhas/[slug]/page.tsx, demandas/[slug]/page.tsx | duplication | low — extra DB round-trips per detail view | S | P2 | open — Pass 5 | P5-D |
| Client auth forms value-import pure helpers from utilities/campaignFormFields | LoginForm.tsx, ForgotPasswordForm.tsx ← src/utilities/campaignFormFields.ts | boundary | low | S | P2 | open — Pass 5 | P5-E |
| Guard dodges measured: globalAccessConventions only => Boolean(user); Local API shorthand user,; scripts local function die(; allocator find-by-slug+mutate / encoded municipio; e2e settle tokens in comments | tests/unit/*Conventions.unit.spec.ts; scripts/check-plans-only-pr-closes.mjs:12-15 | guard dodge | medium on access guards | M | P2 | open — Pass 5 | P5-G |
| /campanha/municipio/[slug]/v2 parallel detail shell (B147) alongside /municipios/[slug] | src/app/(campaign)/campanha/(app)/municipio/[slug]/v2/ | intentional parallel — cutover debt | low until cutover; then divergent change | M | P3 | open — Pass 5 (defer + trigger: v2 replaces v1) | B147 |
Smell Inventory
| Smell | Location | Refactoring | Status |
|---|---|---|---|
| God module: RBAC for every collection in one file (964 lines, ~80 exports) | src/utilities/campaignAccess.ts |
split per domain under src/utilities/access/ |
done 2026-07-23 (14 domain modules; campaignAccess.ts kept as re-export surface) |
| God module: ~71 exports of electoral math | src/lib/electionInsights.ts |
split by concern when next touched | done 2026-07-25 (Pass 2 W4a): module DELETED; survivors in electionFormat.ts + voteTrend.ts |
| Multi-feature action bag: CRUD + CSV import pipeline + LGPD in one file (722 lines) | src/app/(campaign)/campanha/actions/supporter.ts |
split CRUD vs import | done 2026-07-23 (supporterImport.ts) |
| Clone twins: create/update/staff-assert/unique-conflict skeleton | actions/organization.ts ↔ actions/stateDeputy.ts |
extract shared factory | done 2026-07-23 (runStaffEntityMutation in campaignEntityActions.ts) |
14 near-identical formActions.ts try/catch→mapError→redirect/return ladders |
src/app/(campaign)/campanha/**/formActions.ts |
shared form-action wrapper | done 2026-07-25 (Pass 2 W4d): runCampaignFormAction + 13 ações migradas; exceções documentadas (atividades/formActions, apoiadores/[id], convite login). C8 F4b ✓ 2026-07-27: guard ampliado para *FormActions.ts + taskActions.ts; 6 irmãos migrados; taskActions.ts allowlisted — futuros siblings não driftam silenciosamente |
Flat 80+-file src/utilities/ junk drawer; lib/ vs utilities/ boundary undocumented |
src/utilities/, src/lib/ |
document convention + move misfiled modules | done 2026-07-23 (convention in .cursor/rules/engineering-standards.mdc; RefreshRouteOnSave moved to components/) |
Praça→Município rename leftovers in identifiers (PLAZA_MAP_YEARS, ACCESSIBLE_PLAZA_IDS_CONTEXT_KEY, …) |
map/list utilities | finish rename | done 2026-07-23 |
Props→state useEffect mirrors |
MunicipalityListExpectedVotesControl, comboboxes |
key-remount or fully controlled | closed 2026-07-23 (accepted as correct — see Debt Ledger row) |
| Detail-page RSCs mixing loading + tab UI + forms (300+ lines) | municipios/[slug]/page.tsx, planos/[slug]/page.tsx |
extract tab loaders/sections | done 2026-07-25 (Pass 2 W4c): tabs em MunicipalityDetailTabs.tsx / ActionPlanOverviewTab.tsx; gêmeos de tab dedupados em detailTabUi.ts |
| God module: list URL + filters + sort + labels + freshness in one file (765 lines, 59 exports) | src/utilities/municipalityUi.ts |
split along url/filters/sort/labels/freshness seams | done 2026-07-25 (Pass 2 W1-D1): municipalityListUrl / municipalityListFilters / municipalityLabels / municipalitySignal |
components/campaign/ flat with 106 files; utilities/ flat with 92 |
src/components/campaign/, src/utilities/ |
within-layer domain subfolders | components done 2026-07-25 (Pass 2 W2, 15 domínios); utilities/ deferred with trigger: a domain gaining its 3rd new module in a month gets its subfolder |
Sprout / Wrap Register
| Host | Sprouted/Wrapped | Notes |
|---|---|---|
| (none yet) |
Adopted Conventions
- Zero lint warnings.
pnpm lintruns with--max-warnings=0; deliberate placeholders use a_prefix (argsIgnorePattern). as neveris banned (ESLintno-restricted-syntax). Production code uses typed helpers (e.g.hookFilledCreateDatafor hook-completed create data); tests usestub<T>(partial)which keeps property-level checking. A justifiedeslint-disable-next-lineis the only escape hatch.- Dead code is deleted, not commented. knip runs in CI (files/dependencies at error level). After any change:
pnpm exec knipand remove what died. - Collections always declare
accessexplicitly. Payload's default ("any authenticated user") is treated as a bug — seepayloadAdminOnly. - Generated migration signatures are exempt from unused-args linting (
src/migrations/**override); never hand-edit migration content for lint cosmetics. - Shared consent fixture rows are leased, never owned in integration tests (
tests/helpers/testDatabaseLease.ts); deleting them steals from parallel spec files.
Debt Budget & Broken-Windows Policy
- New warnings/
as never/unused exports fail or get ledgered the same day — no untracked// TODOs. - Each delivery that runs
/simplifyregisters surviving debts here or indocs/plans/escala-dry-pos-*.md(existing convention), whichever the item's size warrants.