teqo-backup/docs/TECH-DEBT.md

141 KiB
Raw Blame History

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 6 (re-measured: limit: 0 ×5 holds at searchHomeActivities:43 etc.; hit cap now duplicated under two names — HOME_SEARCH_RESULT_HIT_CAP vs local HOME_SEARCH_ACTIVITY/DEMAND_HIT_CAP) | 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 6 (verified: 6 HomeSearch*Group.tsx still near-verbatim) | 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 6 (verified: findByID ×7 in notificationEvents.ts) | P4-C | | Notification write path: markAllNotificationsRead updates up to 200 rows with no transaction (relocated from the removed panel button action by C108 — it now fires on every bell open, so a mid-batch failure leaves a partial mark that the next open completes, idempotently); 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/utilities/notification/notificationList.ts:104-117, src/app/(campaign)/campanha/actions/notifications.ts:88-131,137-145 | correctness | medium — partial mark-read on mid-batch failure (self-healing); hostile re-subscribe UX; bypass surface | S | P2 | open — Pass 6 (verified: notifications.ts:141 still overrideAccess: true) | 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 | closed 2026-08-25 (Pass 6 verify): superseded — the wizard domain was deleted (OPS95); no Wizard*Step.tsx files remain in components/campaign/ | 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 | closed 2026-08-25 (Pass 6 verify): MunicipalityListMobileCards.tsx imports only types now (:3-10) — the municipalityColumnLabels value import is gone | 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 | closed 2026-08-25 (Pass 6 verify): CampaignGlobalSearchProvider (CampaignGlobalSearchMount.tsx:9-14) now wraps only the search body; actions/summarySlot arrive as server slots from above (CampaignHomeStaffChrome.tsx:75-78) — RSC-slot composition, provider value change no longer re-renders them | 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 6 (region moved to RelationChipCell.tsx:1230, still one sr-only role=status aria-live=polite per cell, mounted unconditionally) | 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 | hardened 2026-08-25 (Pass 6, P6-1): header-covered bypasses now count as undocumented; new files pinned at current counts; per-site comments are the only zero. Re-measured worst offenders at hardening time: people/personDelete.ts 28, notification/* 22, googleCalendarSync.ts 6 | 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 | closed 2026-08-10 (P4-L, OPS30): the spec was rewritten by B34 since the ledger entry; with the OPS30 hardening (prewarm completo + login retry) it ran ×3 green in dev a 2 workers (16:07–16:09, load 4–10, B32+B34) + ×1 green com E2E_PROD=1 (16:12, 1.6s/1.9s). Ledger closed with evidence; reopen if an agent reproduces with the new spec | 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 6 (re-measured: RelationChipCell 1.235, municipalityListUrl 740/29 exports, municipalityPageData 747, peopleData 627/19, personDelete 521/28 bypasses, googleCalendarSync 949, campaignNavigationUrls 636 — 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 6 (verified) | | | (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 | closed 2026-08-25 (Pass 6 verify): the page is now a redirect-only stub (redirect(advisorQuickCreateHref), :5-8) — no actor load, nothing to guard | | | 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 6 (verified: AllocationDecision.ts:62 still data.decidedBy = req.user.id) | | | 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 6 (now 7 suites; the Activities/Demands/StateDeputies triplet = 346 LOC, ~132 shared) | | | 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 6 (C113/C115 calendar-family flake under load tracked as issue #906 — 4 distinct failures in 7 full runs; retry-once applied 2026-08-25) | |

| 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 | closed 2026-08-25 (Pass 6 verify): superseded — wizard domain deleted (OPS95); wizardActionChain has 0 importers | 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 6 (verified: activityQuickActions.ts, campaignQuickActionLeadership.ts, campaignQuickActionDemands.ts still re-spell the id list) | 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 6 (re-measured: 208 lines, was 296 — shrunk since 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 6 (WORSE on the flagship route: municipio/[slug]/v2/page.tsx:34-48 runs requireCampaignPageActor + all 4 V2 loaders in generateMetadata and repeats them in the body, zero cache()) | 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 6 (verified: 4 client components import @/utilities/campaignFormFields — CampaignPasswordFields.tsx:5, CampaignProfileSettings.tsx:19, ContactCreateRow.tsx:15, ContactFormFields.tsx:10) | 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 6 (new dodge measured: perfil/page.tsx:25 calls getCampaignUserWithAvatar() — P3-I regex matches only getCampaignUser\( — fixed by P6-2; globalAccessConventions and the others verified still standing) | 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 6 (cutover tracked as issue #335) | B147 | | Saved-filters machinery re-spelled ×6 despite the committed "must not be written twice" comment (useMunicipalitySavedFilters.ts:26): hooks 43/48 LOC, controls 144/160 LOC, navs 154/166 LOC | shared/usePeopleSavedFilters.ts ↔ useMunicipalitySavedFilters.ts, SavePeopleFilterControl ↔ SaveMunicipalityFilterControl, PeopleNavSavedFilters ↔ MunicipalityNavSavedFilters | duplication (B18 machine, store layer already shared) | medium — every saved-filter fix lands twice; the people twin cloned the spec | M | P2 | open — Pass 6 (P6-A: one hook/control/nav trio over the shared store; guard 3 bans new twins) | P6-A | | Advisor-relation cell twins ×3 (89/96/99 LOC) — PeopleAssessoradoCell / LeadershipAdvisorRelationCell / StateDeputyAdvisorRelationCell differ only in FormData field, drawer title, trigger label | components/campaign/ (people, leadership, stateDeputy — the three AdvisorRelationCell files) | duplication (RelationChipCell engine already owns the machine; DIRECTION_COPY-as-data precedent) | medium — a11y/copy drift across 3 surfaces (B37 class regrown) | M | P2 | open — Pass 6 (P6-B: one cell, owner-kind record as data; guard 3 bans new advisor-cell files not delegating) | P6-B | | Filter-bar shells ×13 re-spell the ~60-LOC list-filter machine (hook + query state + chips/seeds/suggestions + omnibox wiring); 9/13 near-clean, StateDeputyFilters 96L ≈ AdvisorFilters 103L | components/campaign/ — the 13 Files.tsx filter shells (~1.700 LOC total) | duplication + divergent change | high — every filter UX change lands 13× | L | P2 | open — Pass 6 (P6-C: shared shell with slots/typed adapter record; omnibox adapters already export the identical 6-fn surface) | P6-C | | searchHome skeletons ×6 share the ~55-LOC machine (staff-gate → contains + limit: 0 → word-start → relevance → cap); cap under two names (HOME_SEARCH_RESULT_HIT_CAP vs the two local HIT_CAP constants = 25) | src/utilities/homeSearch/ (847 LOC / 10 files) | duplication | medium | M | P2 | open — Pass 6 (P6-D: typed core + named wrappers, runStaffEntityMutation pattern) | P6-D | | chipLabel re-spelled in 12/13 omnibox adapters; municipalityOmnibox.ts:401-445 re-implements filterOmniboxSuggestionSeeds incl. local suggestion cap and seedOf | src/utilities/ — the 13 Omnibox adapters (~3.211 LOC family) | duplication | low | S-M | P2 | open — Pass 6 (P6-E: single chipLabel export + delete the holdout re-spell; guard 3) | P6-E | | DAY_MS re-spelled as bare 86_400_000 ×11 in tests + 1 in lib (campaignTime.ts:134 local msPerDay) — the P3-K consolidation claim never caught literals | calendarFeed.int.spec.ts:41,532, homeSearchActivities.int.spec.ts:24, prioritiesTool.unit.spec.ts:13,263, suggestionCatalog.unit.spec.ts:365,404, … | shotgun surgery (P3-K dodge) | low | S | P2 | open — Pass 6 (P6-F: import DAY_MS from @/lib/text; guard 3 flags the literal with allowlist) | P6-F | | Dead modules survive knip because test imports count as usage: projectionSheetParse.ts (173 LOC, 7 exports, 0 production importers) and searchOnlyListOmnibox.ts (98 LOC, 7 exports); dead scripts survive via the knip entry list covering every top-level script: github-pr.mjs, measure-e2e-family.mjs, regenerate-municipality-catalog-snapshot.mjs (0 referencers) | src/lib/projectionSheetParse.ts, src/lib/searchOnlyListOmnibox.ts, scripts/ top-level | dead code + tooling dodge (knip entry list) | low | S | P2 | open — Pass 6 (P6-G: delete the 5; knip config so spec-only modules surface — class 4) | P6-G | | CLI skeleton guard dodged twice: 4 local function die() (check-plans-only-pr-closes.mjs:12, check-changelog-append-only.mjs:29, build-changelog.mjs:35, check-docs-conflict-markers.mjs:19) + ~20 raw process.exit(1) — scriptCliConventions catches neither | scripts/ top-level | guard dodge + duplication (P3-H class) | low | S | P2 | open — Pass 6 (P6-H: extend guard to ban local die() and process.exit; migrate 4 files to dieWithLabel) | P6-H | | taskActions.ts:20-27 hand-rolls try/catch and passes raw error.message to the UI (no safelist); resultFormActions.ts:17 / updateFormActions.ts:16-20 safelist bare literals duplicating the ACTIVITY_RESULT_STAFF_MESSAGE / ACTIVITY_UPDATE constants — the B32+ class, both directions | atividades/[slug]/taskActions.ts, resultFormActions.ts, updateFormActions.ts | correctness (message-only) + B32+ contract | medium — raw Payload/DB errors can reach the toast; literal drift silently collapses refusals | S | P2 | open — Pass 6 (P6-I: wrap in runCampaignFormAction + import constants; guard 2/3 sweeps error.message passthrough and literal safelists) | P6-I | | Public form actions create a new contact row per submission with no phone/email dedup (submitWhatsapp.ts:30, submitPetitionSignature.ts:40, submitCampaignNewsletter.ts:26) — campaign actions dedup via findOrCreateContactByPhone; repeat submissions multiply PII rows and inflate the public signature counter | src/app/(frontend)/actions/ — the 3 submit actions | LGPD hygiene + counter integrity | medium | M | P2 | open — Pass 6 (P6-J: dedupe before create; guard 5 int pin — same input twice → 1 contact) | P6-J | | findPublishedPosts has no select — every listing/article page ships full published docs incl. richText bodies; article pages read 3 full-doc sets per render | src/utilities/posts.ts:102-114; [type]/[category]/[slug]/page.tsx:35,72,142 | over-fetch (engineering-standards: view models with selected fields) | medium — per-render DB payload + cache size | M | P2 | open — Pass 6 (P6-K: listing projection + related-posts from the same selected query; guard 3) | P6-K | | server-only sweep is blind to next/navigation value imports (campaignFormActionError.ts:1 — isomorphic today, invisible either way) and to Payload-as-param modules (personDelete.ts:9) | tests/unit/codebaseConventions.unit.spec.ts server-only sweep; src/utilities/campaignFormActionError.ts:1 | guard dodge (P3-K class) | low — a future server-bound next/navigation module passes green | S | P2 | open — Pass 6 (P6-M: widen regex to next/navigation + allowlist current clean importers) | P6-M | | D1 trigger has no teeth: 12 top-level activity modules + the googleCalendar trio + 3 top-level omniboxes coexist because the pin is a manual allowlist; person/ (1 file) vs people/ (9 files) split-brain | src/utilities/ top-level | organization + guard dodge (P3-J D1) | low (path churn) | M | P2 | open — Pass 6 (P6-N/P6-O: prefix-count spec — N top-level files sharing a domain prefix fail; fold personContactWriteScope.ts into people/) | P6-N | | as unknown as double-cast ×21 spec files — the unguarded twin of the as never ban (electionToolsLockdown 5×, instagramSync 7×, calendarFeed 5×, …); stub<T> (148 uses) exists | tests/unit/ specs | type honesty + guard gap (Pass 1 class) | low | M | P2 | open — Pass 6 (P6-P: swap to stub<T>; ESLint ban in tests — class 2) | P6-P | | Actor-field convention guard regex covers only 3 names (createdBy / decidedBy / declaredBy) — a hand-spelled actor under any other name passes; CampaignDemand is whole-file allowlisted | tests/unit/codebaseConventions.unit.spec.ts:571 | guard dodge (P3-I class) | low — new collection can twin the factory silently | S | P2 | open — Pass 6 (P6-R: invert — flag ANY relationTo: 'campaignUser' actor field not built by the factory) | P6-R | | overrideAccess: true without per-site comment at ~30 of 66 sites across 15 action files (all spot-checked scope-guarded — review-quality debt, not a hole) | actions/ — advisor, leadership, municipality, notifications, person, profile, password | convention erosion | medium — reviewers can't distinguish guarded from unguarded | M | P2 | counted by P6-1 (ratchet pins at current counts; only per-site comments lower them) | P6-1 | | fixtures.own() manual calls grew 49 → 75; fixture hub god modules (campaignFixtures.ts 1.168 LOC, testDatabaseLease.ts 799) absorb every suite's builders | tests/helpers/, tests/int/ | test-infra drift + bloater | low | M | P3 | open — Pass 6 | P6-Q | | Gender labels re-spelled ×4 with a deliberate display-policy fork ('Não binário' vs 'Outro') — merge rejected (look-alike), drift risk noted | contacts/contactListFilters.ts:33, contactListUrl.ts, contactOmnibox.ts, leadership/leadershipUi.ts:3 | shotgun surgery (rejected-merge) | low | S | P3 | open — Pass 6 (defer + trigger: 3rd distinct policy fork re-opens) | P6-S | | getSignatureCount runs an uncached payload.count per render | src/app/(frontend)/actions/getSignatureCount.ts:9 | caching ladder step 1 | low | S | P3 | open — Pass 6 | P6-T | | AI shell has zero live regions across 8 files — assistant turns append with no announcement (B32+ contract not inherited by new domains) | components/campaign/shell/ai/ (incl. CampaignAIChat.tsx:339) | a11y (B32+ class) | low | S | P2 | open — Pass 6 (P6-V: sr-only polite region announcing the answer) | P6-V | | peopleData.ts (627 LOC/19 exports) mixes view models + merge + scope + facets + loader; personDelete.ts (521 LOC) mixes enumeration + guard + 28-bypass cascade | src/utilities/people/ | bloater | low | M | P2 | open — Pass 6 (P6-U: split facets/sort from loader; enumeration read-only split) | P6-U | | campaignNavigationUrls.ts (636 LOC, 3 exports) imports only @/lib modules — pure client-safe code stranded in the server layer | src/utilities/ai/campaignNavigationUrls.ts | boundary (misfiled pure module) | low | M | P2 | open — Pass 6 (move to lib/) | P6-V2 | | Miss #909 residual: the disarm sequence was fixed in the skill (judgment) — deterministic guard shipped as scripts/testing-audit-disarm.mjs (create → disarm → verify null, atomic), wired into the skill's PR step | scripts/testing-audit-disarm.mjs (new), .agents/skills/testing-audit/SKILL.md | guardrail (class 3) | — | S | P1 | closed 2026-08-25 (Pass 6, P6-M909) — Closes #909 | P6-M909 | | Miss #895: intention plans registering a data decision must record the literal values (tables/IDs), not narrative — no deterministic detector possible for missing prose data | .agents/skills/plan-issue/SKILL.md | guardrail (class 6, judgment-only declared) | — | S | P2 | closed 2026-08-25 (Pass 6, P6-M895) — convention registered; declared judgment-only | P6-M895 |

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 .agents/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 lint runs with --max-warnings=0; deliberate placeholders use a _ prefix (argsIgnorePattern).
  • as never is banned (ESLint no-restricted-syntax). Production code uses typed helpers (e.g. hookFilledCreateData for hook-completed create data); tests use stub<T>(partial) which keeps property-level checking. A justified eslint-disable-next-line is the only escape hatch.
  • Dead code is deleted, not commented. knip runs in CI (files/dependencies at error level). After any change: pnpm exec knip and remove what died.
  • Collections always declare access explicitly. Payload's default ("any authenticated user") is treated as a bug — see payloadAdminOnly.
  • 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 /simplify registers surviving debts here or in docs/plans/escala-dry-pos-*.md (existing convention), whichever the item's size warrants.