teqo-backup/docs/TECH-DEBT.md
Francisco Solla c3f10cce23 feat(C108): sino de notificações — abrir marca tudo como lido, painel limpo, modal central no desktop
openCampaignNotifications (lista + marca em 1 round trip) substitui
listCampaignNotifications + markAllCampaignNotificationsRead; markAllNotificationsRead
extraída (owner-scoped, idempotente, cap 200) com int test; bell vira Dialog
desktop / Drawer mobile (par C94), sem título/rodapé/botões, skeleton de
loading, safe-area, focus-visible, role=alert; readAt sai do contrato do item;
P4-D ledger atualizado (batch sem transação agora no hot path, self-healing).
2026-08-10 02:56:20 -03:00

110 lines
128 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Tech Debt
Ledger of known engineering debt. Items large enough to be scheduled work become entries in [roadmap.md](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](plans/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](plans/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: `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 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 `.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 `// TODO`s.
- 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.