Merge pull request #9 from fsolla/stage

Promote stage → main (auto)
This commit is contained in:
Francisco Solla 2026-07-31 13:43:05 -03:00 committed by GitHub
commit 9e6dd4c061
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 803 additions and 280 deletions

View file

@ -4,6 +4,10 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
---
**Recently resolved (2026-07-31):** **Pass 4 — auditoria de engenharia autônoma + guardrails das misses** — primeira execução em modo autônomo (Cursor Cloud) da skill engineering-audit, com precheck solitário (pool desligado) e primeiro harvest de `kind:agent-miss` (escopo OPS2: nasce `docs/GUARDRAILS.md`). **P0:** os 4 globals públicos (`site-settings`/`home`/`metadata`/`privacy-policy`) aceitavam qualquer autenticado no `update` — JWT de campanha (liderança inclusa) editava o site público via `/api/globals/*`; lockdown `payloadAdminOnly` + pins + guarda ([PR #91](https://github.com/fsolla/teqo/pull/91); editores perdem escrita nos globals de config deliberadamente). **P1:** `seed-posts.mjs` chamava `dieWithLabel` sem importar ([PR #92](https://github.com/fsolla/teqo/pull/92), com guarda de presença de import + `DAY_MS` single-source + ESLint lib→utilities sem type-import + guarda de `overrideAccess` omitido). **Misses:** #73 (deadlock `municipality_rels` — classe reproduzida: 7 violações int + 11 deep links e2e; migração para o alocador com teste de classe reescrito classe-agnóstico + guarda de 3 regras, [PR #93](https://github.com/fsolla/teqo/pull/93)); #52 (Drawer por célula em 3 listas de chips → `CampaignListSheetProvider` + invariante dev + guarda, [PR #94](https://github.com/fsolla/teqo/pull/94)); #53 (portão de readiness do autenticador virtual) + #54 (guarda de goto não assentado + convenção TESTING.md) em [PR #95](https://github.com/fsolla/teqo/pull/95); #83 fechada (guarda `agent:ship` já viva). **Registrado, não retry-fixed:** spec de chips de `campaignLeaderships` falha deterministicamente em dev na árvore limpa (nenhum POST dispara; prod 48/48 verde) — causa raiz via debug workflow (P4-L). Artefatos: seção Pass 4 no plano de qualidade, 15 linhas novas no ledger (2 stale fechadas: P3-A/P3-B), plano [`entrega-engenharia-p4.md`](docs/plans/entrega-engenharia-p4.md) com ondas P4-A→P4-L + mapa de guardas + defer+gatilho.
**Recently resolved (2026-07-31):** **CI build skip para diffs fora da árvore buildável** — `scripts/build-affected.mjs` + `classifyBuildScope` (núcleo em `test-affected-core.mjs`) decidem `run|none`; job `build-scope` barato no `ci-pr.yml` e o job `build` (Postgres + `pnpm build`) só roda em `mode=run`. Docs/tests/tooling/workflows pulam o build; `src/`, `public/` e configs do Next (`next.config`/`tsconfig`/`tailwind`/`postcss`/`components.json`/lockfile) forçam. `gate:ci` espelha. Push em `main` (`ci.yml`) continua sempre full.
**Recently resolved (2026-07-31):** **Consolidação de Issues (project-status)** — merge **B68+** (#39) → **O0+** (#35) (mesmo `model: kimi-k3-low`); **não** merge D3/D4/D5 (modelos mistos kimi vs composer); **não** merge B53–B55 (já `done`); fatia **B79** → chassis (#15) + **B91** busca no drawer (#76, `depends: [B79]`). Planos: `escala-dry-pos-onda0.md`, `busca-global-bottom-drawer.md`, `chassis-bottom-drawer-acoes-rapidas.md`.
## Known Gaps (as of 2026-07-24 — resolve before relying on this file for onboarding new devs)

43
docs/GUARDRAILS.md Normal file
View file

@ -0,0 +1,43 @@
# Guardrails
Ledger dos guardrails determinísticos colhidos de `kind:agent-miss` (fluxo `pnpm agent:file-miss` → harvest no engineering-audit → guarda programática) e dos endurecimentos de guarda existentes. Uma linha por guarda: origem, classe na escada de determinismo, mecanismo, status.
**Classes (escada do Pass 3 D6 / skill engineering-audit passo 4b):** 1 tipo (estado ruim irrepresentável) · 2 ESLint · 3 spec de convenção · 4 análise estática em CI (knip/madge) · 5 pin comportamental · 6 doc/convenção (judgment-only declarado — não fingir que doc é guarda).
## Ativos
| Guarda | Origem | Classe | Mecanismo | Status |
| ---------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- |
| `agent:ship` exige stamp de /simplify casado com HEAD; PR Ready + auto-merge + watch até MERGED | [#83](https://github.com/fsolla/teqo/issues/83) | 3 (script + hooks) | `scripts/agent-ship.mjs`, `.cursor/hooks/guard-pr-create.mjs` (bloqueia `gh pr create` manual), `.cursor/hooks/pr-merge-watchdog.mjs` (re-engata com PR aberto) | vivo desde 2026-07-31 (PR #89); miss fechada no Pass 4 |
| Alocador de municípios em specs — sem slug fixo + mutação; sem `municipio=<slug>` em e2e; regex de nome de catálogo ancorada | [#73](https://github.com/fsolla/teqo/issues/73) | 3 | `tests/unit/testMunicipalityAllocatorConventions.unit.spec.ts` (3 regras; mutações = assign/touch/create\*/`payload.update` municipality) | PR [#93](https://github.com/fsolla/teqo/pull/93) (Pass 4) |
| Sheet compartilhada por lista — chip cells exigem `CampaignListSheetProvider`; invariante dev no overlay | [#52](https://github.com/fsolla/teqo/issues/52) | 3 + 5 | `tests/unit/sharedSheetHostConventions.unit.spec.ts`; `console.error` development-only em `CampaignCellEditOverlay` | PR [#94](https://github.com/fsolla/teqo/pull/94) (Pass 4) |
| Navegação e2e assentada — goto→goto sem settle falha o build | [#54](https://github.com/fsolla/teqo/issues/54) | 3 | `tests/unit/e2eNavigationConventions.unit.spec.ts` (settle = await expect/waitForURL/click/reload/login/catch) | PR [#95](https://github.com/fsolla/teqo/pull/95) (Pass 4) |
| Readiness de autenticador virtual antes da cerimônia WebAuthn | [#53](https://github.com/fsolla/teqo/issues/53) | 3 | mesma spec (spec com `WebAuthn.addVirtualAuthenticator` deve chamar `expectCampaignBiometricsReady(`) | PR [#95](https://github.com/fsolla/teqo/pull/95) (Pass 4) |
| Convenção de navegação e2e (goto = cold load/contrato; in-shell = continuidade) | [#54](https://github.com/fsolla/teqo/issues/54) | 6 (**judgment-only**) | parágrafo em `docs/TESTING.md` | PR [#95](https://github.com/fsolla/teqo/pull/95) (Pass 4) |
| Update de globals públicos nunca "qualquer autenticado" | P0 do Pass 4 (sem issue) | 3 | `tests/unit/globalAccessConventions.unit.spec.ts` ban `Boolean(user)` em `src/globals` | PR [#91](https://github.com/fsolla/teqo/pull/91) (Pass 4) |
| Loaders de notificação tipados pelo ator (recipientID arbitrário irrepresentável) | P0 adjacente do Pass 4 | 1 | assinatura `(payload, user: CampaignUser)` + `overrideAccess: false` sob `canReadOwnNotifications` | PR [#91](https://github.com/fsolla/teqo/pull/91) (Pass 4) |
| Die-helpers da CLI importados onde usados; `DAY_MS` single-source | P1 do Pass 4 (sem issue) | 3 | `tests/unit/scriptCliConventions.unit.spec.ts` | PR [#92](https://github.com/fsolla/teqo/pull/92) (Pass 4) |
| `src/lib` não type-importa `@/utilities/**` (direção única também para tipos) | dodge do Pass 3 (suggestionCatalog) | 2 | ESLint `no-restricted-imports` `allowTypeImports: false` na zona `src/lib` | PR [#92](https://github.com/fsolla/teqo/pull/92) (Pass 4) |
| Local API com `user` sempre declara `overrideAccess`; import dinâmico de módulos server banido em utilities | guarda ausente (medida baseline 59/0) | 3 | `tests/unit/localApiOverrideAccessConventions.unit.spec.ts` | PR [#92](https://github.com/fsolla/teqo/pull/92) (Pass 4) |
## Precedentes vivos (anteriores ao primeiro harvest)
| Guarda | Origem | Classe | Mecanismo |
| ------------------------------------------------------------------------ | ----------------- | ------ | ------------------------------------------------------------------------------------------------ |
| Vocabulário banido (Praça/Núcleo/Plano de Ação, accent-tolerant) | C13 / W4f / P3-K | 3 | `codebaseConventions.unit.spec.ts` |
| `server-only` em utilities acopladas (sweep payload/next/\*) | Pass 2 W2 / P3-K | 3 | `codebaseConventions.unit.spec.ts` |
| Escadas de formActions nos wrappers (`runCampaignFormAction`/`Redirect`) | W4d / C8 F4b | 3 | `codebaseConventions.unit.spec.ts` (filename `*FormActions.ts` — dodge `actions/*.ts` ledgerado) |
| Ratchet de bypass admin não documentado | P3-E | 3 | `codebaseConventions.unit.spec.ts` (por arquivo, só diminui) |
| Mensagens de recusa como constantes + correspondência throw↔safelist | P3-A | 3 | `codebaseConventions.unit.spec.ts:216-263` |
| `as never` banido | Pass 1 | 2 | ESLint `no-restricted-syntax` |
| `src/lib` não importa `utilities/` (valores) | Pass 2 W2 | 2 | ESLint `no-restricted-imports` |
| 0 ciclos de import | Pass 2 | 4 | madge (`pnpm check:cycles`) |
| Dead code (arquivos/deps/exports em erro) | Pass 2 W4b | 4 | knip em CI |
| `migration-lock` (≤1 PR aberto tocando schema) | agentes paralelos | 4 | job em `ci-pr.yml` |
| Seed mínimo obrigatório com migration (contrato manifest) | OPS fluxo PR | 3 + 5 | `tests/unit/seedMinimalManifest.unit.spec.ts` + job CI migrate→seed→int |
| Regex de template ancorada em e2e | P3-C | 3 | `codebaseConventions.unit.spec.ts` (ampliada para literais no Pass 4) |
| Consent fail-closed por chave estável | D3/Onda 0 | 5 | pins int `submitWhatsapp`/liderança/apoiador |
## Misses colhidas aguardando guarda (fila do harvest)
(nenhuma — todas as 5 abertas em 2026-07-31 foram classificadas: 4 com guarda shippada + 1 com guarda já viva. Novas `kind:agent-miss` entram aqui até o próximo audit.)

View file

@ -1,6 +1,6 @@
# Improve Code Quality Plan
Three passes so far: **Pass 1** (2026-07-23/24, phases 0–6 below, all done), **Pass 2** (2026-07-25, see [Pass 2](#pass-2--engineering-consolidation-2026-07-25)) and **Pass 3** (2026-07-28, audit + plan, see [Pass 3](#pass-3--engineering-audit--consolidation-2026-07-28)).
Four passes so far: **Pass 1** (2026-07-23/24, phases 0–6 below, all done), **Pass 2** (2026-07-25, see [Pass 2](#pass-2--engineering-consolidation-2026-07-25)), **Pass 3** (2026-07-28, audit + plan, see [Pass 3](#pass-3--engineering-audit--consolidation-2026-07-28)) and **Pass 4** (2026-07-31, audit + P0/P1 remediation + miss guardrails in-session, see [Pass 4](#pass-4--engineering-audit--remediation--guardrails-2026-07-31)).
## Context
@ -162,3 +162,52 @@ Statuses: pending · in-progress · awaiting-evidence · done · deferred: \<rea
- [ ] P3-A → P3-L as independent deliveries (full gate each; Aikido on edited files)
- [ ] Leftovers and new debts → ledger via `capture-review-debts`; oversized items → Issue rastreável via `plan-issue`
---
# Pass 4 — Engineering Audit + Remediation + Guardrails (2026-07-31)
## Context
- **Started:** 2026-07-31 — first **autonomous (Cursor Cloud)** execution of the `engineering-audit` skill, and the first harvest of `kind:agent-miss` (OPS2 scope: this pass also creates `docs/GUARDRAILS.md`).
- **Precheck (fail-closed):** `agent:gh-doctor` OK (GH_TOKEN, Issues read/write); agent pool **desligado** — solitary audit confirmed.
- **Delta anchor:** Pass 3 (2026-07-28). 880 files touched in 3 days of agent-paradigm work (homeSearch ×28 files, notifications/push ×7+, wizard ×12, WebAuthn ×8, 5 migrations).
- **Method:** canon loaded; hotspot map (delta + churn + static gates); **five parallel sweeps** (lib+scripts, utilities+access, components, app+collections+globals, tests) with measured findings; consolidation hunt under the anti-DRY rule; 5 miss issues harvested and investigated; 16 open ledger rows re-verified; P0/P1 + miss guardrails remediated in-session, each in its own PR with full gate.
- **Baseline:** green — `tsc` 0, `lint` 0 warnings, `knip` 0 findings (known `payload.config.ts` loader noise, ledger P3 — verified still true), madge 0 cycles (**774 files**, +124 since Pass 3).
## Audit headlines (all measured)
- **1 P0:** the four public globals (`site-settings`, `home`, `metadata`, `privacy-policy`) spelled `update: Boolean(user)` — campaign JWTs authenticate against `/api/*`, so any campaign session (leader included) could PATCH the public site. Collections were locked in Phase 0; globals were missed. **Remediated in-session** ([PR #91](https://github.com/fsolla/teqo/pull/91)).
- **1 P1:** `scripts/seed-posts.mjs` called `dieWithLabel` **without importing it** — ReferenceError on the empty-fetch abort path; P3-H half-adoption in the same pass that created the CLI guard. **Remediated in-session** ([PR #92](https://github.com/fsolla/teqo/pull/92)).
- **5 misses → 5 guardrails:** #73 deadlock class **reproduced** (7 int violations + 11 hardcoded e2e deep links) → allocator migration + 3-rule guard ([PR #93](https://github.com/fsolla/teqo/pull/93)); #52 residual per-cell Drawers on 3 chip lists → shared host ×3 + dev invariant + guard ([PR #94](https://github.com/fsolla/teqo/pull/94)); #53 one-shot probe race → readiness gate + pin; #54 measured **0 unsettled goto pairs** → calibrated guard + TESTING.md convention ([PR #95](https://github.com/fsolla/teqo/pull/95)); #83's guard (`agent:ship` + hooks, PR #89) verified alive → issue closed, no new guard.
- **Flake honesty:** `campaignLeaderships` chip spec fails **deterministically in dev** on clean stage (instrumented: no POST in 60 s; not the hydration class) — prod green 48/48. Registered, **not** retry-fixed.
- **Ledger hygiene:** rows 33/34 (safeMessages, petition consentId) verified fixed → closed; row 17 counts → 17 specs/48 cases; rows 35/40/45 updated with verified counts and trigger status (NOT fired); row 19 mitigated (unanchored literals gone + guarded); rows 10/12/14/21/23/28/31 verified still true (23 with updated path). **15 new rows** (P2/P3) with evidence.
- **12 guard dodges measured:** 3 hardened in-session (ESLint lib zone type-imports, CLI import-presence, Local API omission + dynamic-import); the rest → workstreams or registered (map in the plan).
- **Duplication for the plan:** ~500+ measured lines across P4-A…P4-E + P4-G; 12 defer+trigger entries; 6 look-alikes rejected with reason.
## Remediation deliveries (done, full gate each)
| WS | Content | PR | Status |
| --------- | --------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------- | --------------------------------------------------- |
| P4-P0 | Public globals lockdown ×4 + notification bell loaders actor-typed (no bypass) + int pins + `globalAccessConventions` guard | [#91](https://github.com/fsolla/teqo/pull/91) | open (Ready, base stage) |
| P4-P1 | seed-posts import fix + `scriptCliConventions` (die-import, `DAY_MS`) + ESLint lib-zone type-import ban + `localApiOverrideAccessConventions` | [#92](https://github.com/fsolla/teqo/pull/92) | open (Ready, base stage) |
| P4-M73 | Miss #73: allocator migration (int class-agnostic rewrite + e2e claim) + `testMunicipalityAllocatorConventions` (3 rules) | [#93](https://github.com/fsolla/teqo/pull/93) | open (Ready, base stage) — `Closes #73` |
| P4-M52 | Miss #52: `CampaignListSheetProvider` ×3 lists + dev invariant + `sharedSheetHostConventions` | [#94](https://github.com/fsolla/teqo/pull/94) | open (Ready, base stage) — `Closes #52` |
| P4-M53/54 | Misses #53+#54: `expectCampaignBiometricsReady` + unsettled-goto guard + TESTING.md convention | [#95](https://github.com/fsolla/teqo/pull/95) | open (Ready, base stage) — `Closes #53, closes #54` |
## Pass 4 Decisions
| ID | Decision | Rationale |
| --- | --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| D1 | Each remediation in its own PR with the recurrence guard in the **same** delivery | Prevention-first (Pass 3 D6); guards that land later never land |
| D2 | Editors lose writes to the four config globals (deliberate narrowing) | They are site config, not editorial content; `canManagePublishedContent` untouched; widening later is a named-policy decision |
| D3 | #54 guard calibrated to "unsettled goto pairs", not a `goto` ban | Measured: 58 gotos, 0 unsettled; the miss itself warned against a global ban |
| D4 | Dev-mode chip flake registered, not retry-fixed | Instrumentation showed no POST ever fires — not a race; retry masks non-race bugs; prod is the gate |
| D5 | Class-agnostic int fixtures over class-pinned slugs | The pinned behavior (filter/sort by class) never needed reduto/marginal identity — allocation kills the deadlock class without weakening the pin |
| D6 | Aikido scan not run (MCP unavailable in this Cloud environment) | The per-change scan stays mandatory where the server exists; the ledger row (exit-2/SCA-unverified) is unchanged |
## Next Actions
- [ ] Human sign-off = merge of the artifacts PR (plan + ledger + GUARDRAILS.md); remediation PRs merge independently
- [ ] P4-A → P4-L as independent deliveries ([entrega-engenharia-p4.md](plans/entrega-engenharia-p4.md)); oversized items → Issue rastreável via `plan-issue`
- [ ] Leftovers → ledger via `capture-review-debts`

View file

@ -14,13 +14,13 @@ Ledger of known engineering debt. Items large enough to be scheduled work become
| 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 (**15 specs / 31 cases** measured 2026-07-28, Pass 3 — the "7 specs" count is stale; most campaign CRUD still int-only, so the substance holds) | `tests/e2e/` | coverage | medium | L | P2 | open | |
| E2E suite thin (**17 specs / 48 cases** measured 2026-07-31, Pass 4 — grew from 15/31 at Pass 3; 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 — wait for an interactive-state signal instead of the element existing, and anchor fixture lookups to exact names/slugs instead of substring regex | |
| 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 | |
| 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 |
@ -30,23 +30,40 @@ Ledger of known engineering debt. Items large enough to be scheduled work become
| 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 | open — Pass 3 | 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 | open — Pass 3 | 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: `campaignListUrl` 3-level pinning, invite-origin double-pin, 43 manual `fixtures.own()` | P3-C |
| `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 | P3-F |
| 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 filename guard escapes — 7 hand-rolled form-action ladders in `actions/*.ts` invisible to the `*FormActions.ts` guard (3 expressible today with zero delta: profile avatar ×2, leaderSupporter); **convite-login exception stale** (not allowlisted; now expressible via `runCampaignRedirectFormAction`); `password.ts`/`auth.ts` genuinely bespoke but unregistered; staff-page prologue ×22 (~110 lines) with `return null` vs `redirect` divergence ×5; route body schema re-spelled instead of omit/extend (`municipios/advisors/route.ts:16-20`); `requireConsentByKey` called raw where the named wrapper exists (`leaderSupporter.ts:99-104`) | `actions/profile.ts:37-105,107-151`, `actions/leaderSupporter.ts:99-104,151-190`, `campanha/convite/[token]/formActions.ts:68-98`, 22 `(app)` pages | duplication + convention breach | low-medium | M | P3 | partially closed 2026-07-29 (P3-I): `requireCampaignPageActor({gate, redirectTo})` adopted by all 30 `(app)` pages — the `return null` ×5 divergence died (missing session now always redirects to login); guard bans `getCampaignUser()` in route pages. **Remaining open (defer + trigger: the next bespoke ladder or route schema):** 7 hand-rolled `actions/*.ts` ladders (3 expressible: profile avatar ×2, leaderSupporter), stale convite-login exception, password.ts/auth.ts registration in the W4d guard widening, `municipios/advisors/route.ts` omit/extend, `requireConsentByKey` raw call in leaderSupporter | P3-I |
| Route layer: W4d filename guard escapes — 7 hand-rolled form-action ladders in `actions/*.ts` invisible to the `*FormActions.ts` guard (3 expressible today with zero delta: profile avatar ×2, leaderSupporter); **convite-login exception stale** (not allowlisted; now expressible via `runCampaignRedirectFormAction`); `password.ts`/`auth.ts` genuinely bespoke but unregistered; staff-page prologue ×22 (~110 lines) with `return null` vs `redirect` divergence ×5; route body schema re-spelled instead of omit/extend (`municipios/advisors/route.ts:16-20`); `requireConsentByKey` called raw where the named wrapper exists (`leaderSupporter.ts:99-104`) | `actions/profile.ts:37-105,107-151`, `actions/leaderSupporter.ts:99-104,151-190`, `campanha/convite/[token]/formActions.ts:68-98`, 22 `(app)` pages | duplication + convention breach | low-medium | M | P3 | partially closed 2026-07-29 (P3-I): `requireCampaignPageActor({gate, redirectTo})` adopted by all 30 `(app)` pages — the `return null` ×5 divergence died (missing session now always redirects to login); guard bans `getCampaignUser()` in route pages. **Remaining open (defer + trigger: the next bespoke ladder or route schema):** 7 hand-rolled `actions/*.ts` ladders (3 expressible: profile avatar ×2, leaderSupporter), stale convite-login exception, password.ts/auth.ts registration in the W4d guard widening, `municipios/advisors/route.ts` omit/extend. **Verified 2026-07-31 (Pass 4):** new actions (notifications/webauthn/suggestion/wizardLeadership) DO use the shared wrappers — count holds at 7; the raw `requireConsentByKey` call is gone (leaderSupporter uses `requireSupporterRegistrationConsent`); 33/34 pages on `requireCampaignPageActor` + 1 redirect (`assessores/novo`, guard covers only `getCampaignUser`) | P3-I |
| Convention guards dodgeable: banned-vocabulary regex `/praç|núcleo/i` misses unaccented `praca`/`nucleo` (the sibling "Plano de Ação" row was hardened accent-tolerant); `server-only` guard sweeps only `payload\|@payload-config` value imports — `next/cache`/`next/server` importers (`municipalityRevalidation.ts`, `campaignJsonMutationRoute.ts`) are server-bound and invisible; no mechanism stops a client component value-importing the three client-safe modules that drag the 623 KB TSE artifact (`municipalityTerritorialClass`, `municipalityPotential`, `territoryIntraCaptureBenchmark` — B13 avoided by discipline) | `tests/unit/codebaseConventions.unit.spec.ts:50,71-80` | tooling gap | low | S | P3 | closed 2026-07-29 (P3-K): vocabulary regex now `/pra[çc]\|n[úu]cleo/i` (2 genuine-data hits allowlisted); `server-only` sweep widened to `next/cache`/`next/server`/`next/headers` (`campaignJsonMutationRoute` marked); ESLint `no-restricted-imports` bars value-imports of the 3 artifact carriers + the artifact itself from `src/components/**` (type imports safe — measured: the 2 current consumers are type-only) | P3-K |
| Constants/types single-sourcing cluster: territorial-class union declared twice across the lib/utilities boundary (adding a class compiles green and mis-sorts); `DAY_MS` ×3 (one exported from `suggestionCatalog`'s public interface); `1_000_000` vote-count ceiling ×6 across two validation layers; password `8/128` + token `20/256` policies ×2; TSE baseline years mirrored (documented excuse dies with a leaf module); `formatBrazilianPhoneDisplay` near-twin (1 call site); `advisorMunicipalitiesBatchSchema` missing the dedup transform its 3 siblings have; `voteEstimate` formatters re-spell the pt-BR format without the E18 zero-fraction pin; `hasAnyEstimate` weaker twin of `hasAnyVoteEstimate` | `lib/territorialClassSortWeight.ts:10-13`, `lib/suggestionCatalog.ts:15-19,175`, `lib/engagementLevel.ts:97`, `utilities/municipalitySignal.ts:32`, `lib/formData.ts:123-133`, `lib/schemas/votePledge.ts:9,32`, `lib/schemas/invite.ts:11,39`, `lib/voteEstimate.ts:27,102-126`, `lib/schemas/advisor.ts:69-72` | shotgun surgery | low-medium | S each | P3 | closed 2026-07-29 (P3-K): territorial-class union → `lib/territorialClassAnchors.ts` (type error on new class now); `DAY_MS` → `lib/text.ts` (out of `suggestionCatalog`'s public interface); `MAX_VOTE_COUNT` in `lib/schemas/primitives.ts` ×6 folded; `campaignPasswordSchema` exported (invite delegates); `lib/electionYears.ts` leaf (mirror + documented excuse died); `formatBrazilianPhoneDisplay` delegates; batch-schema dedup transform (action's `[...new Set]` died); `formatElectionNumber` in the 3 `voteEstimate` formatters; `hasAnyEstimate` deleted | P3-K |
| Form-action feedback JSX at 15+ call sites with `aria-live` on only 5 — **10 error alerts silent to AT**; success→toast effect ×7; borderless inline-input class ×4 verbatim; deprecated `showLabels` prop with zero call sites (knip can't see props) | 15 forms incl. `LeadershipForm.tsx:162`, `DemandForm.tsx:152`, `MunicipalityUpdateForm.tsx:168`, `PledgeEstimateForm.tsx:74`; `VoteEstimateScenarioStrip.tsx:18,27,32` | a11y + duplication | medium (a11y) | S–M | P3 | closed 2026-07-29 (P3-G): `CampaignFormActionMessage` (aria-live on both variants + `formFieldErrors`) adopted by all 15+ sites — every alert now announces; `useCampaignFormSuccessToast` adopted ×7; `campaignInlineInputClassName` ×4; `showLabels` deleted; JSX ban guard + a11y pin (`campaignFormActionMessage.unit.spec.tsx`, unit include extended to `.tsx`) | P3-G |
| homeSearch text-search pipeline re-spelled per domain (gate staff → normalize → `contains` + `limit: 0` → word-start → relevance → cap), plus **unbounded `limit: 0` reads** ×5 before the in-memory refine | `src/utilities/homeSearch/searchHomeActivities.ts` (88) ↔ `searchHomeDemands.ts` (100), sim 0.72 (~63 shared lines); leaderships 0.53; `limit: 0` at `searchHomeActivities.ts:43`, `Demands:49`, `Leaderships:61`, `Advisors:37`, `StateDeputies:45` | duplication + scale | medium — every keystroke can pull a full collection slice | M | P2 | open — Pass 4 | P4-A |
| HomeSearch result groups ×6 near-verbatim (Advisor↔Leadership, Activity↔Demand almost line-for-line; differ only in id/title/href/secondary) | `src/components/campaign/dashboard/HomeSearch*Group.tsx` (40–63 lines each; Municipality is the real variant) | duplication | low | M | P2 | open — Pass 4 | P4-B |
| `notificationEvents` re-spells names-by-id ×7 instead of the P3-E wrappers | `src/utilities/notification/notificationEvents.ts` (`:41-57`, `:143-174`, `:212-236` — `findByID` + `select:{name}`/`{name,slug}`) | duplication | low | S | P2 | open — Pass 4 | P4-C |
| Notification write path: `markAllCampaignNotificationsRead` updates up to 200 rows with no transaction; `subscribeCampaignPush` validates with `.trim()` only and always creates (re-subscribe → unique violation → generic error); `unsubscribeCampaignPush` deletes with `overrideAccess: true` while `canDeleteOwnPushSubscriptions` already scopes the owner | `src/app/(campaign)/campanha/actions/notifications.ts:69-91,100-143,172-176` | correctness | medium — partial mark-read on mid-batch failure; hostile re-subscribe UX; bypass surface | S | P2 | open — Pass 4 | P4-D |
| Wizard info Drawer chrome spelled ×3 (button → bottom sheet with title+description+body); skip trailing spelled ×2 (13 lines each, both `{label, href}`) | `WizardSignalTypeStep.tsx:95-112`, `WizardTrendChoiceStep.tsx:115-132`, `WizardLeadershipStep.tsx:167-179`; `WizardSignalSkipTrailing.tsx` ↔ `WizardTrendSkipTrailing.tsx` | duplication | low | S | P2 | open — Pass 4 | P4-E |
| `MunicipalityListMobileCards` (client) value-imports `municipalityColumnLabels` from `municipalityListUrl` — a ~19 KB URL module (pulls `isMunicipalitySlug` → `municipalityCatalog`) for a string map whose type already lives in `municipalityLabels` | `src/components/campaign/municipality/MunicipalityListMobileCards.tsx:26` | boundary (B14 class) | low | S | P2 | open — Pass 4 | P4-F |
| `HomeSearchProvider` wraps the whole chrome incl. `actions` + `summarySlot` non-consumers, and the provider value changes on every keystroke | `src/components/campaign/dashboard/CampaignHomeStaffChrome.tsx:41-56`, `useHomeSearchQuery.ts:63-74` | state placement | low — actions/summary re-render per keypress | S | P2 | open — Pass 4 | P4-G |
| Unconditional `aria-live="polite"` per chip cell (~50 always-registered regions on lideranças); the overlay family already lazy-mounts its region from first open | `src/components/campaign/shared/RelationChipCell.tsx:821-823` vs `CampaignCellEditOverlay.tsx:114-120` | a11y scale | low | S | P2 | open — Pass 4 | P4-G |
| Bypass ratchet dodge: one "bypass" comment in the first 40 lines documents **every** `overrideAccess: true` in the file — `notification/*` has 20, `campaignWebAuthnCeremony` 4, zero per-call justifications | `codebaseConventions.unit.spec.ts:541-623`; `src/utilities/notification/*`; `src/utilities/webauthn/campaignWebAuthnCeremony.ts` | guard dodge | medium — the P3-E "comment justifying" intent is satisfied in letter, not spirit | M | P2 | open — Pass 4 | P4-H |
| `campaignLeaderships` chip-cell spec fails **deterministically in dev mode** on clean stage (4/4 runs; stash-and-compare 2026-07-31): instrumented with console/response listeners — **no POST fires in 60 s**; signatures vary per run (option visible + click no-ops; option count 1 → 0 after first fill). Prod build green (48/48 full suite ×3 the same day). NOT the P3-C hydration class (input onChange works, listbox renders); a `toPass` retry was attempted and reverted — retrying cannot fix what is not a race | `tests/e2e/campaignLeaderships.e2e.spec.ts:91-101`; suspect surface `RelationChipCell` suggestion state vs dev-mode RSC streaming of `municipalityIndex` | test-infra / possible dev-mode cell bug | medium — a red local signal teaches people to skip the spec | M | P2 | open — Pass 4 (debug workflow, not retry; `E2E_PROD=1` is the honest local mode meanwhile) | P4-L |
| God modules on the delta: `municipalityPageData` 548 lines / `loadMunicipalityListPageBundle` 168; `municipalityListUrl` 500 lines / 23 exports (widest interface in utilities); `RelationChipCell` 826 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 (656 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) | |
## Smell Inventory
| Smell | Location | Refactoring | Status |

View file

@ -4,9 +4,9 @@
Three layers, all local-database-only (see `local-database` skill; tests refuse any database not ending in `_test`):
- **Unit** (`tests/unit/**/*.unit.spec.ts`, 47 specs as of 2026-07-25 (tooling guards)): pure logic; runs with a deliberately invalid `DATABASE_URL` so nothing can touch a database.
- **Integration** (`tests/int/**/*.int.spec.ts`, 48 specs): real Payload + Postgres (`teqo_test`); campaign fixtures allocate seeded municipalities via a Postgres sequence so parallel spec files never share one, and purge residue on claim. Shared consent rows with stable keys are leased (`tests/helpers/testDatabaseLease.ts`), never owned.
- **E2E** (`tests/e2e`, Playwright, 7 specs): smoke-level; most campaign behavior is pinned at the int layer.
- **Unit** (`tests/unit/**/*.unit.spec.ts`, 117 specs as of 2026-07-31, Pass 4 — +6 convention guards land with the Pass 4 remediation PRs): pure logic; runs with a deliberately invalid `DATABASE_URL` so nothing can touch a database.
- **Integration** (`tests/int/**/*.int.spec.ts`, 71 specs): real Payload + Postgres (`teqo_test`); campaign fixtures allocate seeded municipalities via a Postgres sequence so parallel spec files never share one, and purge residue on claim. Since the miss #73 guardrail (Pass 4), specs that mutate municipalities must use the allocator (no pinned slugs) — enforced by `testMunicipalityAllocatorConventions`. Shared consent rows with stable keys are leased (`tests/helpers/testDatabaseLease.ts`), never owned.
- **E2E** (`tests/e2e`, Playwright, 17 specs / 48 cases): smoke-level; most campaign behavior is pinned at the int layer.
CI (`.github/workflows/ci-pr.yml`) runs parallel jobs — lint (zero warnings), format, typecheck, knip, cycles, unit/int (**affected by the PR diff**: `vitest --changed` against the PR base via `scripts/test-affected.mjs`, full-suite fallback on high-risk paths), e2e (manifest path→spec via `scripts/e2e-affected.mjs` + `scripts/lib/e2e-affected-manifest.mjs`, blocking; production build + `list`/`github` reporters in CI) and build — against a Postgres 17 service with **`db:seed:minimal`**. Push to `main` (`ci.yml`) always runs the full net including e2e.

View file

@ -0,0 +1,187 @@
# Pass 4 — Auditoria de engenharia, remediação P0/P1 e guardrails das misses
## Contexto e manchetes
- **Executado em 2026-07-31, no modo autônomo (Cursor Cloud)** da skill `engineering-audit`: precheck solitário verde (`agent:gh-doctor` OK; pool **desligado**), varredura completa, remediações P0/P1 e guardrails das misses colhidas na mesma sessão, cada um em PR próprio com gate completo. O sign-off dos artefatos é o merge humano deste PR.
- **Âncora de delta:** Pass 3 (2026-07-28). Em 3 dias o paradigma de agentes tocou **880 arquivos** — homeSearch (8 módulos + ~20 componentes), notificações/push (2 collections + 5 módulos), wizard (~12 componentes), WebAuthn (4 rotas + 4 módulos), 5 migrations. A varredura cobriu o repo inteiro com esforço concentrado no delta.
- **Baseline verde:** `tsc` 0 erros, `lint` 0 warnings, `knip` 0 findings (ruído conhecido do `payload.config.ts`, ledger P3), madge 0 ciclos (**774 arquivos**, +124 desde o Pass 3).
- **Manchetes (todas medidas):**
- **1 P0:** os 4 globals públicos (`site-settings`, `home`, `metadata`, `privacy-policy`) com `update: Boolean(user)` — JWT de campanha (liderança inclusa) podia **editar o site público** via `/api/globals/*`. Coleções foram travadas na Fase 0; os globals ficaram. Remediado ([PR #91](https://github.com/fsolla/teqo/pull/91)).
- **1 P1:** `seed-posts.mjs` chamava `dieWithLabel` **sem importar** — ReferenceError no caminho de abort de fetch vazio, invisível a lint/tsc/knip. Meia-adoção do P3-H na mesma passada que criou a guarda da CLI. Remediado ([PR #92](https://github.com/fsolla/teqo/pull/92)).
- **Harvest de 5 `kind:agent-miss` → 5 guardrails:** #73 (classe **reproduzível** — 7 violações int + 11 deep links e2e; migração para o alocador + guarda de 3 regras, [PR #93](https://github.com/fsolla/teqo/pull/93)); #52 (resíduo de Drawer por célula em 3 listas de chips; provider ×3 + invariante dev + guarda, [PR #94](https://github.com/fsolla/teqo/pull/94)); #53 (probe one-shot correndo com o autenticador CDP; portão de readiness + pin, [PR #95](https://github.com/fsolla/teqo/pull/95)); #54 (0 pares goto→goto não assentados medidos; guarda calibrada + convenção, [PR #95](https://github.com/fsolla/teqo/pull/95)); #83 (guarda `agent:ship` **já viva** via PR #89 — verificada e fechada sem guardrail novo).
- **Honestidade de flake:** `campaignLeaderships` (chips) falha **deterministicamente em dev** na árvore limpa (instrumentado: nenhum POST dispara; não é a classe de hidratação P3-C). Prod verde 48/48. **Registrado, não "consertado" com retry** — retry mascara o que não é corrida.
- **2 linhas do ledger stale fechadas:** safeMessages (P3-A) e consentId de petição (P3-B), verificadas no código.
- **Duplicação medida para o plano:** gêmeos de busca homeSearch (~63 de ~90 linhas compartilhadas ×2), HomeSearch\*Group ×6 (2 pares quase verbatim), Wizard info Drawer ×3, WizardSkipTrailing ×2, names-by-id ×7 em notificationEvents, shell de sucesso vazio ×2.
- **12 dodges de guarda registrados** (mapa de guardas abaixo) — 3 endurecidos na sessão, o resto vira workstream P2.
## Protocolo de delta de comportamento (inalterado do Pass 3)
Consolidação pode mudar saídas pequenas (copy, ordenação, timing) desde que **cada delta seja listado por item**; pins existentes são a rede de caracterização, atualizados **deliberadamente** na mesma entrega; contratos congelados (URL B18, schema, Consent/LGPD) fora de alcance sem item nomeado.
## Ondas já executadas na sessão (P0/P1 + misses)
| PR | Conteúdo | Guarda (classe) |
| --------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
| [#91](https://github.com/fsolla/teqo/pull/91) | **P0** globals públicos → `payloadAdminOnly` ×4; bell loaders de notificação tipados pelo ator, sem bypass | `globalAccessConventions` (3); assinatura por ator (1); pins int de lockdown ×17 |
| [#92](https://github.com/fsolla/teqo/pull/92) | **P1** import do `dieWithLabel`; inversão tipo lib→utilities; `DAY_MS` fold; comentário de bypass stale | `scriptCliConventions` (3); ESLint lib `allowTypeImports:false` (2); `localApiOverrideAccessConventions` (3) |
| [#93](https://github.com/fsolla/teqo/pull/93) | **#73** migração int+e2e para o alocador (classe-agnóstico no teste de classe); 11 deep links → `claimMunicipality()` | `testMunicipalityAllocatorConventions`: fixed-slug+mutação, `municipio=<slug>`, regex de nome não ancorada (3) |
| [#94](https://github.com/fsolla/teqo/pull/94) | **#52** `CampaignListSheetProvider` em lideranças/dobradinhas/assessores | `sharedSheetHostConventions` (3); invariante dev no overlay (5); fallback lazy mantido |
| [#95](https://github.com/fsolla/teqo/pull/95) | **#53** `expectCampaignBiometricsReady` ×2; **#54** guarda de goto não assentado + convenção TESTING.md | `e2eNavigationConventions` (3); convenção doc declarada judgment-only (6) |
Gate de cada PR: `pnpm push` (espelho serial do ci-pr: lint, format, typecheck, knip, cycles, unit, int, build, e2e afetado ou full conforme blast radius). Evidência: full e2e prod **48/48** em 3 das 5 execuções; int full **71/529+** em todas.
## Ondas do plano (P2, ordenadas por risco × churn)
### Onda 1 — Consolidação do delta (homeSearch + notificações)
#### P4-A Runner único de busca textual do homeSearch
- **Meta:** `runHomeSearchTextHits({ collection, where, select, mapDoc, tieBreak, cap })` + mappers finos por domínio.
- **Evidência:** `searchHomeActivities.ts` (88 linhas) ↔ `searchHomeDemands.ts` (100) com similaridade 0,72 (~63 linhas compartilhadas: gate staff → normalize → `contains` + `limit: 0` + `overrideAccess: false` → word-start → `compareHomeSearchNameRelevance` → cap); leaderships 0,53.
- **Conhecimento duplicado (nomeado):** o pipeline staff de hits textuais da busca global.
- **Delta de comportamento:** nenhum se caracterizado pelos pins int existentes ×7 suites; caps/rankings inalterados.
- **Guarda determinística:** convenção — novo `searchHome*` deve usar o runner (classe 3); pin de acesso por papel por grupo (classe 5, já existe em parte).
- **Na mesma entrega:** helper `emptyHomeSearchSuccess(resultKind)` (2 shells idênticos, `searchHomeMunicipalities:23-33` vs `loadHomeSearchSuggestions:77-87`).
#### P4-B HomeSearch\*Group ×6 → grupo configurável
- **Meta:** `HomeSearchHitGroup` como dado (`groupId`, `title`, `selectHits`, `rowProps`); Municipality fica como variação estendida (territórios + trailing de votos).
- **Evidência:** 6 componentes de 40–63 linhas; Advisor↔Leadership e Activity↔Demand quase linha a linha (diferem id/título/href/secundário).
- **Conhecimento:** grupo de hits orçado → seção → `HomeSearchHitRow`.
- **Guarda:** snapshot/unit por groupId (aria-label + href) — classe 5.
#### P4-C notificationEvents → `loadNamesByIds` (P3-E)
- **Meta:** os 7 `findByID` + `select:{name}`/`{name,slug}` de `notificationEvents.ts` (254 linhas) viram os wrappers tipados do P3-E (estender se slug precisar).
- **Conhecimento:** resolver nomes de exibição por ids já autorizados — dono único desde 2026-07-29.
- **Guarda:** convenção banindo `findByID`+`select:{name}` ad hoc fora do allowlist (classe 3, mesma família do ratchet P3-E).
#### P4-D Write-path de notificações/push fail-closed
- **Meta:** (a) `markAllCampaignNotificationsRead` em `withPayloadTransaction` (até 200 updates — hoje falha parcial visível); (b) `subscribeCampaignPush` com schema zod em `lib/schemas` + upsert por `(user, endpoint)` (re-subscribe hoje estoura unique → erro genérico); (c) `unsubscribeCampaignPush` delete com `overrideAccess: false` (a policy `canDeleteOwnPushSubscriptions` já escopa o dono — bypass desnecessário).
- **Evidência:** `actions/notifications.ts:69-91,100-143,172-176`.
- **Guarda:** int — falha injetada no N-ésimo update ⇒ 0 ou todos marcados (5); segundo subscribe com o mesmo endpoint ⇒ sucesso (5).
### Onda 2 — Wizard + camada de apresentação do delta
#### P4-E Wizard info Drawer ×3 → `WizardInfoDrawer`
- **Evidência:** chrome idêntico em `WizardSignalTypeStep.tsx:95-112`, `WizardTrendChoiceStep.tsx:115-132`, `WizardLeadershipStep.tsx:167-179` (botão info → bottom sheet com título + descrição + corpo); grids de tiles ficam locais (dado de domínio). Na mesma entrega: `WizardSignalSkipTrailing`/`WizardTrendSkipTrailing` (13 linhas cada, tipos ambos `{label, href}`) → um `CampaignWizardSkipTrailing`.
- **Guarda:** pin visual/a11y do botão info → nome do dialog (5).
#### P4-F `municipalityColumnLabels` fora do `municipalityListUrl` (fronteira B14)
- **Meta:** mover o mapa de labels para `municipalityLabels` (onde o tipo já vive) ou folha só-de-labels.
- **Evidência:** `MunicipalityListMobileCards.tsx:26` (client) value-importa do módulo de URL (~19 KB, que puxa `isMunicipalitySlug` → `municipalityCatalog`) para ler um mapa de strings — a lição dos 21 kB do B14 na versão mobile.
- **Guarda:** convenção/ESLint — client components não value-importam de `*ListUrl` fora de href builders allowlistados (classe 3).
#### P4-G HomeSearchProvider estreito + regiões vivas em escala
- **Meta:** (a) `HomeSearchProvider` envolve só o slot de busca — hoje envolve `actions`+`summarySlot` não-consumidores e o valor muda a cada tecla (`CampaignHomeStaffChrome.tsx:41-56`); (b) a região `aria-live` incondicional por célula de chips (`RelationChipCell.tsx:821-823` — ~50 regiões polite registradas em lideranças) monta no primeiro anúncio não-vazio, seguindo o overlay.
- **Guarda:** pin de árvore React (ações fora do provider) (5); convenção de teto de regiões polite por lista (3).
### Onda 3 — Endurecimento de guardas (dodges medidos)
#### P4-H Ratchet de bypass: fim da "cobertura de cabeçalho"
- **Meta:** comentário com "bypass" nas primeiras 40 linhas deixa de zerar a contagem do arquivo inteiro; vale por-chamada (janela) ou política de módulo nomeada com contagem declarada. `notification/*` (20 bypasses) e `campaignWebAuthnCeremony` (4) recebem justificativas por bloco na mesma entrega.
- **Evidência:** `codebaseConventions.unit.spec.ts:541-623`; P3-E pretendia "comentário justificando", a forma atual satisfaz a letra sem o espírito.
- **Guarda:** o próprio ratchet endurecido (classe 3).
#### P4-I `canReadCampaignUsers` sem enumeração para liderança
- **Meta:** lista de staff restrita a staff/unrestricted; liderança lê a si mesma. Auditar call sites primeiro (homeSearch advisors já tem gate próprio — verificado na varredura).
- **Evidência:** `access/campaignUsers.ts:33-34` = admin OU qualquer `isCampaignUser` (inclui `leader`); ledger P2 pré-existente, re-verificado 2026-07-31.
- **Guarda:** pins int de acesso — líder nega lista, self permitido (5).
#### P4-J `authenticateCampaignToken` sem segunda leitura (RS+)
- **Meta:** `depth: 0` + `select` mínimo; avatar resolvido sem doc inteiro. Pins: `removePrivateAuthFields` + freshness de rebaixamento de papel (pré-requisito do ledger, mantém).
- **Evidência:** `campaignAuth.ts:87-109` — `payload.auth` (a JWT strategy já lê o doc) + `findByID(depth: 1)` full em toda página.
#### P4-K Fixtures int: restos do P3-C
- **Meta:** (a) 28 blocos `.create({ collection: 'campaignUser' })` em 6 arquivos → `fixtures.createCampaignUser` (salvo mints de sessão legítimos); (b) `fixtures.own()` manual — 49 sites (subiu de 43) — ratchet de contagem; (c) invite-origin: pin unit é o dono (~92 linhas puras), o re-pin int (~158 linhas em `campaignInvite.int.spec.ts:394-552`) vira smoke de integração.
- **Guarda:** convenção banindo create crua de campaignUser fora de helpers (3); ratchet `fixtures.own(` ≤ N (3).
### Onda 4 — Debug com evidência (não é retry, não é palpite)
#### P4-L `campaignLeaderships` chips em dev — causa raiz
- **Evidência registrada (2026-07-31):** na árvore limpa de stage, dev mode, 4/4 execuções falham no passo sugestão/commit. Instrumentação com listeners: **nenhum POST em 60s**; assinaturas variando por run — opção visível + clique sem efeito; contagem de options 1 → 0 após o primeiro fill. **Prod verde** (48/48 full). Não é a classe P3-C (onChange do input funciona, listbox renderiza). `toPass` tentado e **revertido** — retry não conserta o que não é corrida.
- **Método:** debug subagent com hipótese + instrumentação no código da célula (estado `open`/`suggesting`, streaming RSC dev vs prod do `municipalityIndex`, `hitsKey`), não especulação.
- **Guarda:** a pin que nascer do fix; até lá, TESTING.md registra `E2E_PROD=1` como modo honesto para o spec (feito no PR #95).
## Mapa de guardas (prevenção de recorrência)
### Guardas NOVAS na sessão (já mergeadas ou em PR próprio)
| Guarda | Classe | Origem |
| ----------------------------------------------------------------------------------------- | ------ | -------------- |
| `globalAccessConventions` — ban `Boolean(user)` em update de globals | 3 | P0 encontrado |
| Bell loaders tipados pelo ator (recipientID não representável) | 1 | P0 adjacente |
| `scriptCliConventions` — die-helper importado + `DAY_MS` single-source | 3 | P1 encontrado |
| ESLint `src/lib`: `@/utilities/**` rejeita type-import | 2 | dodge P3-K |
| `localApiOverrideAccessConventions` — `user` sem `overrideAccess`; import dinâmico server | 3 | guarda ausente |
| `testMunicipalityAllocatorConventions` (3 regras) | 3 | miss #73 |
| `sharedSheetHostConventions` + invariante dev no overlay | 3 + 5 | miss #52 |
| `e2eNavigationConventions` (goto assentado + readiness WebAuthn) | 3 | misses #54/#53 |
| Convenção de navegação e2e em TESTING.md (**judgment-only declarado**) | 6 | miss #54 |
### Guardas existentes endurecidas na sessão
- `codebaseConventions` — a família de regex ancorada ganhou a irmã de literais (regra 3 do spec #73, keyed no catálogo).
- ESLint `src/lib` — comentário da zona agora codifica a política de módulos de contrato.
### Resíduo judgment-only (declarado, sem fingir que doc é guarda)
- Gatilhos de abstração dos itens defer+trigger (tabela abaixo) — convenção em rules/codebase-map.
- Migração `AdvisorsTable` → `CampaignTable` e fim do modo "Editar nome e contato" — decisão de produto (edit-in-place), não de guarda.
- `relationMembershipDelta` vs `leadershipMunicipalityMembership` (set+floor) — look-alike, não duplicação.
### Dodges medidos pendentes (viram workstream ou ficam registrados)
| Guarda | Dodge medido | Destino |
| ----------------------------------- | --------------------------------------------------------------------------------------------------- | ------------------ |
| Ratchet de bypass (P3-E) | header de 40 linhas cobre 20+ chamadas (`notification/*`, `webauthnCeremony`) | P4-H |
| `*FormActions.ts` (W4d) | 7 escadas à mão em `actions/*.ts` (profile ×2, leaderSupporter, password ×4) invisíveis ao filename | defer+trigger |
| Mensagens de recusa (P3-A) | const lançada sem presença em safelist de rota (órfã → erro genérico) | defer+trigger |
| `getCampaignUser` em páginas (P3-I) | só cobre `page.tsx`; `assessores/novo` redirect sem `requireCampaignPageActor` (1 de 34) | defer+trigger |
| Pin de top-level de `utilities/` | é allowlist, não disciplina de domínio | doc (codebase-map) |
| `campaignJsonMutationRoute` | `export const POST = handler` indireto | registrado |
| Vocabulário banido | tokens quebrados por concatenação; `.md`/`.sql` fora de migrations | registrado |
## Registrados como defer + gatilho (sem ID novo)
| Par/candidato | Medida | Gatilho |
| ----------------------------------------------------------------------------- | ---------------------------------------------------------------------- | --------------------------------------------------- |
| Split de `RelationChipCell` (826 linhas; máquinas overflow/membership/chrome) | 11 states, blocos de 80/140/190 linhas | próxima relação por chips OU queixa de escala de AT |
| Codec de sort ×3 + multi-toggle ×4 + round-trip ×6 (P3-F) | verificado 2026-07-31: **gatilho NÃO disparou** | 4º dropdown de sort / 5º multi-filtro |
| Escadas `actions/*.ts` (7) + convite-login + schema advisors route | novas actions (notifications/webauthn/suggestion) **usam** os wrappers | próxima escada bespoke → ampliar guarda W4d |
| `wizardSignalHref`/`wizardTrendHref` (4–5 params posicionais) | 2 call sites com `undefined` placeholder | próximo href da família → options object |
| Split de `suggestionCatalog` (646 linhas, 18 exports) | patternP1 tem 52 linhas | 9º pattern OU 2º consumidor do avaliador |
| Extrair `import-projecao.mjs` (1213 linhas) | maior script do repo | 3ª família de planilha |
| `municipalityPageData` (548) / `municipalityListUrl` (500, 23 exports) | god modules sob contrato congelado B18 | próxima faceta/sort |
| `BahiaMap` (608) + `MunicipalityMapPanel` (734) | clients god, churn alto | próxima feature de mapa que toque ambos |
| `ActivityForm` (515) | formulário único | próxima feature de atividade |
| `agent-pool.mjs` tick (~118) + `derivePoolClaims` (~70) | já fatorado em `scripts/lib/agent-pool-*` | próxima fase do tick |
| `compare ?compare=` sem `unstable_cache` (ledger P3) | 3 anos × scan por candidato | profiling mandar (ladder rung 2) |
| Posts `limit: 0` + filtro em memória (ledger P3) | corpus ~39 artigos | crescimento do corpus |
## Look-alikes rejeitados (não reabrir sem evidência nova)
- `toggleMunicipalityMulti*` × `toggleLeadershipMulti*` (sim 0,24 textual — mesma família de conhecimento do ×4 deferido, não mergear antes do 5º).
- `assertLocalUrl` (stage-snapshot/db-pull, sem override) × `assertLocalDatabase` (com override) — políticas diferentes de propósito.
- `parseEnv` (gate-ci/db-doctor, não muta `process.env`) × `loadCliEnv` (muta) — não forçar.
- `homeSearch*GroupHasHits` ×5 — `array.length > 0` fino demais para abstrair.
- `ui/Sheet` × `ui/Drawer` — consumidor único (Sidebar) não é duplicação.
- `createdBy` em `Notification`/`PushSubscription` — notificações são emitidas pelo sistema; push já carrega hash+timestamp de consentimento.
## Regras de execução (valem para cada workstream)
- Uma consolidação por entrega; gate completo bare (`tsc`, `lint`, `format:check`, `knip`, `check:cycles`, `test`, `build`) + Aikido quando o MCP estiver disponível (nesta sessão o ambiente Cloud não tinha o servidor — o row do ledger segue).
- Commits de estrutura nunca misturam comportamento; teste vermelho no meio do refactor = revert, não debug.
- Contratos congelados: URL (B18), schema (migration = entrega própria), Consent/LGPD fail-closed.
- Lista rejected-with-reason do Pass 3 continua valendo.
- Sobras → ledger via `capture-review-debts`; nada vive só no chat.
- Nenhum item fecha sem a classe de prevenção registrada no PR; guarda chega com o fix.

View file

@ -119,7 +119,10 @@ const eslintConfig = [
{
// src/lib is the pure, client-safe layer (docs/ARCHITECTURE.md): it must
// never depend on Payload/Next server code, on utilities/, or on higher
// layers. Sharing types across the boundary is always fine.
// layers. Types shared with utilities live in lib contract modules and
// utilities re-export FROM lib — a lib module type-importing utilities
// inverts the direction anyway (Pass 4: suggestionCatalog did exactly
// that for a type whose source already lived in lib).
files: ['src/lib/**/*.{ts,tsx}'],
rules: {
'@typescript-eslint/no-restricted-imports': [
@ -129,8 +132,8 @@ const eslintConfig = [
{
group: ['@/utilities/**', '@/components/**', '@/app/**'],
message:
'src/lib is the pure layer — it must not depend on utilities/components/app. Move the pure logic into lib/ or this module into utilities/.',
allowTypeImports: true,
'src/lib is the pure layer — it must not depend on utilities/components/app. Move the pure logic into lib/ or this module into utilities/. Shared types live in lib contract modules.',
allowTypeImports: false,
},
{
group: ['payload', 'payload/**', '@payload-config', 'next', 'next/**'],

View file

@ -27,7 +27,7 @@ import { convertHTMLToLexical, editorConfigFactory } from '@payloadcms/richtext-
import { del as blobDel } from '@vercel/blob'
import { JSDOM } from 'jsdom'
import { getPayload } from 'payload'
import { loadCliEnv } from './lib/cli.mjs'
import { dieWithLabel, loadCliEnv } from './lib/cli.mjs'
import { assertLocalDatabase } from './assert-local-database.mjs'

View file

@ -10,6 +10,7 @@ import {
CampaignListResults,
CampaignTransitionAnchor,
} from '@/components/campaign/shared/CampaignListPending'
import { CampaignListSheetProvider } from '@/components/campaign/shared/CampaignListSheetHost'
import {
CampaignTable,
CampaignTableHead,
@ -249,14 +250,18 @@ export default async function StateDeputiesPage({ searchParams }: StateDeputiesP
<p className="text-sm text-muted-foreground" aria-live="polite">
{sortSummary}
</p>
<CampaignTable
caption={`${sortSummary}. Deputados estaduais com quem a campanha dobra.`}
columns={columns}
columnVisibility={columnVisibility}
rows={rows}
rowKey={(row) => row.id}
empty={<StateDeputyListEmptyState state={state} />}
/>
{/* One shared Drawer for every chip-cell sheet on coarse pointers
(miss #52 — never a Drawer root per opened cell). */}
<CampaignListSheetProvider>
<CampaignTable
caption={`${sortSummary}. Deputados estaduais com quem a campanha dobra.`}
columns={columns}
columnVisibility={columnVisibility}
rows={rows}
rowKey={(row) => row.id}
empty={<StateDeputyListEmptyState state={state} />}
/>
</CampaignListSheetProvider>
{rows.length ? (
<CampaignListFooter
totalDocs={totalDocs}

View file

@ -18,6 +18,7 @@ import {
CampaignListResults,
CampaignTransitionAnchor,
} from '@/components/campaign/shared/CampaignListPending'
import { CampaignListSheetProvider } from '@/components/campaign/shared/CampaignListSheetHost'
import {
CampaignTable,
CampaignTableHead,
@ -402,14 +403,18 @@ export default async function LeadershipsPage({ searchParams }: LeadershipsPageP
<p className="text-sm text-muted-foreground" aria-live="polite">
{sortSummary}
</p>
<CampaignTable
caption={`${sortSummary}. Uma ficha por pessoa — cada liderança pode atuar em vários municípios e organizações.`}
columns={columns}
columnVisibility={columnVisibility}
rows={rows}
rowKey={(row) => row.id}
empty={<LeadershipListEmptyState state={state} />}
/>
{/* One shared Drawer for every chip-cell sheet on coarse pointers
(miss #52 — never a Drawer root per opened cell). */}
<CampaignListSheetProvider>
<CampaignTable
caption={`${sortSummary}. Uma ficha por pessoa — cada liderança pode atuar em vários municípios e organizações.`}
columns={columns}
columnVisibility={columnVisibility}
rows={rows}
rowKey={(row) => row.id}
empty={<LeadershipListEmptyState state={state} />}
/>
</CampaignListSheetProvider>
{rows.length ? (
<CampaignListFooter
totalDocs={totalDocs}

View file

@ -21,6 +21,7 @@ import {
campaignReadCellClassName,
} from '@/components/campaign/shared/CampaignCopyableCell'
import { CampaignListEmptyState } from '@/components/campaign/shared/CampaignListEmptyState'
import { CampaignListSheetProvider } from '@/components/campaign/shared/CampaignListSheetHost'
import { MunicipalityPortfolioCell } from '@/components/campaign/shared/MunicipalityPortfolioCell'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@ -155,223 +156,120 @@ export const AdvisorsTable = ({
const showEmpty = rows.length === 0 && !draft
return (
<div className="flex flex-col gap-4">
<div className="flex flex-wrap justify-end gap-2">
{editing ? (
<Button type="button" variant="outline" className="min-h-11" onClick={leaveEditing}>
Concluir edição
</Button>
) : (
<Button
type="button"
variant="outline"
className="min-h-11"
onClick={() => setEditing(true)}
>
<PencilIcon data-icon="inline-start" aria-hidden="true" />
Editar nome e contato
</Button>
)}
<Button type="button" className="min-h-11" onClick={startDraft} disabled={Boolean(draft)}>
<PlusIcon data-icon="inline-start" aria-hidden="true" />
Novo assessor
</Button>
</div>
{showEmpty ? (
<CampaignListEmptyState
icon={hasQuery ? SearchXIcon : UserCogIcon}
title={hasQuery ? 'Nenhum assessor encontrado' : 'Nenhum assessor cadastrado'}
description={
hasQuery
? 'Ajuste a busca ou limpe o filtro para ver todos.'
: 'Ative a edição e crie a conta na tabela.'
}
>
{!hasQuery ? (
<Button type="button" className="min-h-11" onClick={startDraft}>
<PlusIcon data-icon="inline-start" aria-hidden="true" />
Novo assessor
// One shared Drawer for the portfolio chip cells on coarse pointers
// (miss #52 — never a Drawer root per opened cell).
<CampaignListSheetProvider>
<div className="flex flex-col gap-4">
<div className="flex flex-wrap justify-end gap-2">
{editing ? (
<Button type="button" variant="outline" className="min-h-11" onClick={leaveEditing}>
Concluir edição
</Button>
) : null}
</CampaignListEmptyState>
) : (
<div className="overflow-x-auto rounded-xl border">
<Table className="min-w-[56rem] table-fixed">
<TableHeader>
<TableRow>
<TableHead className="w-[20%]">Nome</TableHead>
<TableHead className="w-[24%]">E-mail</TableHead>
<TableHead className="w-[14%]">Celular</TableHead>
<TableHead>Municípios</TableHead>
<TableHead className="w-28 text-right">
<span className="sr-only">Ações</span>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{draft ? (
<TableRow className="bg-muted/20">
<TableCell>
<Input
value={draft.name}
onChange={(event) => {
const name = event.currentTarget.value
setDraft((current) => (current ? { ...current, name } : current))
}}
placeholder="Nome"
aria-label="Nome do novo assessor"
className={campaignInlineInputClassName}
autoFocus
/>
</TableCell>
<TableCell>
<Input
type="email"
value={draft.email}
onChange={(event) => {
const email = event.currentTarget.value
setDraft((current) => (current ? { ...current, email } : current))
}}
placeholder="E-mail"
aria-label="E-mail do novo assessor"
className={campaignInlineInputClassName}
/>
</TableCell>
<TableCell>
<Input
type="tel"
inputMode="numeric"
value={draft.phone}
onChange={(event) => {
const phone = formatBrazilianPhoneInput(
sanitizeBrazilianPhoneInput(event.currentTarget.value),
)
setDraft((current) => (current ? { ...current, phone } : current))
}}
placeholder="Celular"
aria-label="Celular do novo assessor"
className={campaignInlineInputClassName}
/>
</TableCell>
<TableCell>
<MunicipalityPortfolioCell
ownerId={null}
ownerName={draft.name.trim() || 'um novo assessor'}
municipalityIds={draft.municipalityIds}
municipalityIndex={municipalityIndex}
draft
onDraftChange={(municipalityIds) =>
setDraft((current) => (current ? { ...current, municipalityIds } : current))
}
commitAction={municipalitiesAction}
drawerTitle="Carteira do assessor"
updateErrorMessage="Não foi possível atualizar a carteira."
/>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
<Button
type="button"
variant="ghost"
size="icon"
className="size-10"
aria-label="Cancelar novo assessor"
disabled={isCreating}
onClick={cancelDraft}
>
<XIcon className="size-4" aria-hidden="true" />
</Button>
<Button
type="button"
size="icon"
className="size-10"
aria-label="Salvar novo assessor"
disabled={isCreating}
onClick={saveDraft}
>
{isCreating ? (
<Spinner className="size-4" aria-hidden="true" />
) : (
<SaveIcon className="size-4" aria-hidden="true" />
)}
</Button>
</div>
</TableCell>
) : (
<Button
type="button"
variant="outline"
className="min-h-11"
onClick={() => setEditing(true)}
>
<PencilIcon data-icon="inline-start" aria-hidden="true" />
Editar nome e contato
</Button>
)}
<Button type="button" className="min-h-11" onClick={startDraft} disabled={Boolean(draft)}>
<PlusIcon data-icon="inline-start" aria-hidden="true" />
Novo assessor
</Button>
</div>
{showEmpty ? (
<CampaignListEmptyState
icon={hasQuery ? SearchXIcon : UserCogIcon}
title={hasQuery ? 'Nenhum assessor encontrado' : 'Nenhum assessor cadastrado'}
description={
hasQuery
? 'Ajuste a busca ou limpe o filtro para ver todos.'
: 'Ative a edição e crie a conta na tabela.'
}
>
{!hasQuery ? (
<Button type="button" className="min-h-11" onClick={startDraft}>
<PlusIcon data-icon="inline-start" aria-hidden="true" />
Novo assessor
</Button>
) : null}
</CampaignListEmptyState>
) : (
<div className="overflow-x-auto rounded-xl border">
<Table className="min-w-[56rem] table-fixed">
<TableHeader>
<TableRow>
<TableHead className="w-[20%]">Nome</TableHead>
<TableHead className="w-[24%]">E-mail</TableHead>
<TableHead className="w-[14%]">Celular</TableHead>
<TableHead>Municípios</TableHead>
<TableHead className="w-28 text-right">
<span className="sr-only">Ações</span>
</TableHead>
</TableRow>
) : null}
{rows.map((row) => {
const whatsAppHref = whatsAppHrefForPhone(row.phone)
const emailShown = displayEmail(row.email)
return (
<TableRow key={row.id}>
</TableHeader>
<TableBody>
{draft ? (
<TableRow className="bg-muted/20">
<TableCell>
{editing ? (
<AdvisorDebouncedTextCell
advisorId={row.id}
field="name"
defaultValue={row.name}
ariaLabel={`Nome de ${row.name}`}
formAction={updateProfileAction}
/>
) : (
<Link
href={`/campanha/assessores/${row.id}`}
className={cn(
campaignReadCellClassName,
'font-medium text-primary underline-offset-4 hover:underline',
)}
>
<span className="truncate">{row.name}</span>
</Link>
)}
<Input
value={draft.name}
onChange={(event) => {
const name = event.currentTarget.value
setDraft((current) => (current ? { ...current, name } : current))
}}
placeholder="Nome"
aria-label="Nome do novo assessor"
className={campaignInlineInputClassName}
autoFocus
/>
</TableCell>
<TableCell>
{editing ? (
<AdvisorDebouncedTextCell
advisorId={row.id}
field="email"
type="email"
defaultValue={row.email ?? ''}
placeholder="E-mail"
ariaLabel={`E-mail de ${row.name}`}
placeholderEmailFallback={row.email}
formAction={updateProfileAction}
/>
) : (
<CampaignCopyableCell value={emailShown} label="E-mail" />
)}
<Input
type="email"
value={draft.email}
onChange={(event) => {
const email = event.currentTarget.value
setDraft((current) => (current ? { ...current, email } : current))
}}
placeholder="E-mail"
aria-label="E-mail do novo assessor"
className={campaignInlineInputClassName}
/>
</TableCell>
<TableCell>
{editing ? (
<AdvisorDebouncedTextCell
advisorId={row.id}
field="phone"
type="tel"
defaultValue={row.phone ? formatBrazilianPhoneInput(row.phone) : ''}
placeholder="Celular"
ariaLabel={`Celular de ${row.name}`}
formAction={updateProfileAction}
/>
) : (
<CampaignCopyableCell
value={row.phone}
label="Celular"
displayValue={
row.phone ? formatBrazilianPhoneInput(row.phone) : undefined
}
className="tabular-nums"
/>
)}
<Input
type="tel"
inputMode="numeric"
value={draft.phone}
onChange={(event) => {
const phone = formatBrazilianPhoneInput(
sanitizeBrazilianPhoneInput(event.currentTarget.value),
)
setDraft((current) => (current ? { ...current, phone } : current))
}}
placeholder="Celular"
aria-label="Celular do novo assessor"
className={campaignInlineInputClassName}
/>
</TableCell>
<TableCell>
<MunicipalityPortfolioCell
ownerId={row.id}
ownerName={row.name}
municipalityIds={municipalityIdsByAdvisor.get(row.id) ?? []}
ownerId={null}
ownerName={draft.name.trim() || 'um novo assessor'}
municipalityIds={draft.municipalityIds}
municipalityIndex={municipalityIndex}
draft
onDraftChange={(municipalityIds) =>
setDraft((current) =>
current ? { ...current, municipalityIds } : current,
)
}
commitAction={municipalitiesAction}
drawerTitle="Carteira do assessor"
updateErrorMessage="Não foi possível atualizar a carteira."
@ -379,45 +277,154 @@ export const AdvisorsTable = ({
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
{whatsAppHref ? (
<Button asChild variant="ghost" size="icon" className="size-10">
<a
href={whatsAppHref}
target="_blank"
rel="noopener noreferrer"
aria-label={`Enviar WhatsApp para ${row.name}`}
>
<MessageCircleIcon className="size-4" aria-hidden="true" />
</a>
</Button>
) : (
<Button
type="button"
variant="ghost"
size="icon"
className="size-10"
disabled
aria-label={`WhatsApp indisponível — ${row.name} sem celular`}
>
<MessageCircleIcon className="size-4" aria-hidden="true" />
</Button>
)}
<AdvisorPasswordResetButton
advisorId={row.id}
disabled={!emailShown}
layout="icon"
accessibleName={`Enviar link de senha para ${row.name}`}
formAction={passwordResetAction}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="size-10"
aria-label="Cancelar novo assessor"
disabled={isCreating}
onClick={cancelDraft}
>
<XIcon className="size-4" aria-hidden="true" />
</Button>
<Button
type="button"
size="icon"
className="size-10"
aria-label="Salvar novo assessor"
disabled={isCreating}
onClick={saveDraft}
>
{isCreating ? (
<Spinner className="size-4" aria-hidden="true" />
) : (
<SaveIcon className="size-4" aria-hidden="true" />
)}
</Button>
</div>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
)}
</div>
) : null}
{rows.map((row) => {
const whatsAppHref = whatsAppHrefForPhone(row.phone)
const emailShown = displayEmail(row.email)
return (
<TableRow key={row.id}>
<TableCell>
{editing ? (
<AdvisorDebouncedTextCell
advisorId={row.id}
field="name"
defaultValue={row.name}
ariaLabel={`Nome de ${row.name}`}
formAction={updateProfileAction}
/>
) : (
<Link
href={`/campanha/assessores/${row.id}`}
className={cn(
campaignReadCellClassName,
'font-medium text-primary underline-offset-4 hover:underline',
)}
>
<span className="truncate">{row.name}</span>
</Link>
)}
</TableCell>
<TableCell>
{editing ? (
<AdvisorDebouncedTextCell
advisorId={row.id}
field="email"
type="email"
defaultValue={row.email ?? ''}
placeholder="E-mail"
ariaLabel={`E-mail de ${row.name}`}
placeholderEmailFallback={row.email}
formAction={updateProfileAction}
/>
) : (
<CampaignCopyableCell value={emailShown} label="E-mail" />
)}
</TableCell>
<TableCell>
{editing ? (
<AdvisorDebouncedTextCell
advisorId={row.id}
field="phone"
type="tel"
defaultValue={row.phone ? formatBrazilianPhoneInput(row.phone) : ''}
placeholder="Celular"
ariaLabel={`Celular de ${row.name}`}
formAction={updateProfileAction}
/>
) : (
<CampaignCopyableCell
value={row.phone}
label="Celular"
displayValue={
row.phone ? formatBrazilianPhoneInput(row.phone) : undefined
}
className="tabular-nums"
/>
)}
</TableCell>
<TableCell>
<MunicipalityPortfolioCell
ownerId={row.id}
ownerName={row.name}
municipalityIds={municipalityIdsByAdvisor.get(row.id) ?? []}
municipalityIndex={municipalityIndex}
commitAction={municipalitiesAction}
drawerTitle="Carteira do assessor"
updateErrorMessage="Não foi possível atualizar a carteira."
/>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
{whatsAppHref ? (
<Button asChild variant="ghost" size="icon" className="size-10">
<a
href={whatsAppHref}
target="_blank"
rel="noopener noreferrer"
aria-label={`Enviar WhatsApp para ${row.name}`}
>
<MessageCircleIcon className="size-4" aria-hidden="true" />
</a>
</Button>
) : (
<Button
type="button"
variant="ghost"
size="icon"
className="size-10"
disabled
aria-label={`WhatsApp indisponível — ${row.name} sem celular`}
>
<MessageCircleIcon className="size-4" aria-hidden="true" />
</Button>
)}
<AdvisorPasswordResetButton
advisorId={row.id}
disabled={!emailShown}
layout="icon"
accessibleName={`Enviar link de senha para ${row.name}`}
formAction={passwordResetAction}
/>
</div>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
)}
</div>
</CampaignListSheetProvider>
)
}

View file

@ -1,6 +1,6 @@
'use client'
import { useLayoutEffect, useRef, useState, type ReactNode } from 'react'
import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
import { CampaignHoverTooltip } from '@/components/campaign/shared/CampaignHoverTooltip'
@ -110,6 +110,18 @@ export const CampaignCellEditOverlay = ({
const sharedSheet = useCampaignListSheet()
const wasOpenRef = useRef(open)
// Miss #52: without the provider every opened cell keeps its own Drawer
// root. The fallback below stays for prod resilience, but in dev the
// missing provider is called out the moment a sheet cell mounts.
useEffect(() => {
if (variant !== 'sheet' || sharedSheet) return
if (process.env.NODE_ENV !== 'development') return
console.error(
'CampaignCellEditOverlay: variant="sheet" sem CampaignListSheetProvider — ' +
'cada célula aberta mantém um Drawer próprio (miss #52). Envolva a lista no provider.',
)
}, [variant, sharedSheet])
/**
* The region is mounted from the first open onwards and never unmounted
* again — a live region has to exist BEFORE its text changes or most screen

View file

@ -9,6 +9,8 @@
* one object so recalibration is a one-line diff.
*/
import { DAY_MS } from '@/lib/text'
export const engagementLevels = ['n0', 'n1', 'n2', 'n3', 'n4'] as const
/** `allocationDecision.patternId` under which every level movement is filed. */
@ -94,8 +96,6 @@ export type EngagementLevelMovementInput = {
triangulatedShock: boolean
}
const DAY_MS = 24 * 60 * 60 * 1000
const daysBetween = (from: Date, to: Date): number => (to.getTime() - from.getTime()) / DAY_MS
const isSameCalendarMonth = (left: Date, right: Date): boolean =>

View file

@ -10,9 +10,9 @@ import {
} from '@/lib/electionYears'
import type { EngagementLevel } from '@/lib/engagementLevel'
import { engagementLevelRank } from '@/lib/engagementLevel'
import type { MunicipalityTerritorialClass } from '@/lib/territorialClassAnchors'
import { DAY_MS } from '@/lib/text'
import type { AllocationDecision } from '@/payload-types'
import type { MunicipalityTerritorialClass } from '@/utilities/municipality/municipalityTerritorialClass'
/**
* E11 "motor de sugestões" — the versioned, curated catalog of data→decision

View file

@ -29,7 +29,8 @@ export const searchHomeAdvisors = async (
const normalizedQuery = normalizeHomeSearchName(query)
// Intentional admin bypass — unrestricted staff gate already ran in the route.
// Under access (`overrideAccess: false`): the unrestricted-staff gate ran
// above, so the advisor directory is only queried for coordinator/candidate.
const result = await payload.find({
collection: 'campaignUser',
where: { role: { equals: 'advisor' } },

View file

@ -0,0 +1,86 @@
import { readdirSync, readFileSync } from 'node:fs'
import { relative, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
// Pass 4 (2026-07-31): the engineering standard is "Local API with `user`
// always with `overrideAccess: false`; bypass admin só com comentário
// justificando" — but until now nothing failed the build when a call passed
// `user` and simply omitted `overrideAccess` (Payload then bypasses access
// silently). Measured baseline at adoption: 59 calls with `user`, 0 missing
// (the undocumented-bypass ratchet in codebaseConventions covers the `true`
// side; this guard covers the omission side).
//
// Companion rule: the `server-only` sweep in codebaseConventions only sees
// static imports, so a runtime `await import('payload')` in utilities would
// dodge the mark. Banned outright — use a static import and the mark.
const repoRoot = process.cwd()
const repoPath = (absolute: string) => relative(repoRoot, absolute)
const srcFiles = readdirSync(resolve(repoRoot, 'src'), { recursive: true, withFileTypes: true })
.filter((entry) => entry.isFile() && /\.(ts|tsx)$/.test(entry.name))
.map((entry) => resolve(entry.parentPath, entry.name))
const CALL_PATTERN =
/payload\.(find|create|update|delete|count|findByID|findGlobal|updateGlobal)\s*\(\s*\{/g
/** Brace-matches the first `{…}` object literal starting at `openIndex`. */
const callObjectBody = (source: string, openIndex: number): string => {
let depth = 0
for (let index = openIndex; index < source.length; index++) {
if (source[index] === '{') depth++
else if (source[index] === '}') {
depth--
if (depth === 0) return source.slice(openIndex, index + 1)
}
}
return source.slice(openIndex)
}
describe('Local API overrideAccess discipline (Pass 4)', () => {
it('declares overrideAccess on every payload call that passes user', () => {
const offenders: string[] = []
for (const file of srcFiles) {
const source = readFileSync(file, 'utf8')
CALL_PATTERN.lastIndex = 0
let match: RegExpExecArray | null
while ((match = CALL_PATTERN.exec(source)) !== null) {
const openIndex = source.indexOf('{', match.index)
const body = callObjectBody(source, openIndex)
if (!/\buser\s*:/.test(body)) continue
if (/overrideAccess\s*:/.test(body)) continue
const line = source.slice(0, match.index).split('\n').length
offenders.push(`${repoPath(file)}:${line} (payload.${match[1]})`)
}
}
expect(
offenders,
'Local API with `user` must declare overrideAccess (false under access; true only with the justifying comment)',
).toEqual([])
})
it('bans runtime dynamic import/require of server-bound modules in utilities', () => {
const offenders: string[] = []
const dynamicServerImport =
/\bawait\s+import\(\s*['"](?:payload|@payload-config|next\/(?:cache|server|headers))['"]\s*\)/
const requireServerModule = /\brequire\(\s*['"](?:payload|@payload-config)['"]\s*\)/
for (const file of srcFiles) {
const path = repoPath(file)
if (!path.startsWith('src/utilities/')) continue
const lines = readFileSync(file, 'utf8').split('\n')
for (const [index, line] of lines.entries()) {
if (dynamicServerImport.test(line) || requireServerModule.test(line)) {
offenders.push(`${path}:${index + 1}`)
}
}
}
expect(
offenders,
"use a static import plus `import 'server-only'` — dynamic imports dodge the boundary sweep",
).toEqual([])
})
})

View file

@ -0,0 +1,63 @@
import { readdirSync, readFileSync } from 'node:fs'
import { relative, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
// Pass 4 (2026-07-31): `scripts/seed-posts.mjs` called `dieWithLabel(…)`
// without importing it — a ReferenceError on the empty-fetch abort path that
// lint, typecheck and knip all let through (P3-H half-adopted in the same pass
// that added the guard). The CLI skeleton is only a skeleton when the symbols
// it exports are actually imported.
//
// `DAY_MS` is single-sourced in `src/lib/text.ts` (P3-K); a private re-spell
// survived in `engagementLevel.ts` until this pass — ban the declaration form.
const repoRoot = process.cwd()
const repoPath = (absolute: string) => relative(repoRoot, absolute)
const walkFiles = (root: string, extensions: readonly string[]): string[] =>
readdirSync(root, { recursive: true, withFileTypes: true })
.filter((entry) => entry.isFile() && extensions.some((ext) => entry.name.endsWith(ext)))
.map((entry) => resolve(entry.parentPath, entry.name))
describe('script CLI skeleton (Pass 4)', () => {
const scriptFiles = walkFiles(resolve(repoRoot, 'scripts'), ['.mjs'])
it('imports dieWithLabel / dieAgent wherever they are called', () => {
const offenders: string[] = []
for (const file of scriptFiles) {
const source = readFileSync(file, 'utf8')
for (const symbol of ['dieWithLabel', 'dieAgent'] as const) {
const callsIt = new RegExp(`\\b${symbol}\\s*\\(`).test(source)
if (!callsIt) continue
const definesIt = new RegExp(`export const ${symbol}\\b`).test(source)
const importsIt = new RegExp(`^import\\s*\\{[^}]*\\b${symbol}\\b[^}]*\\}`, 'm').test(source)
if (!definesIt && !importsIt) offenders.push(`${repoPath(file)} (${symbol})`)
}
}
expect(
offenders,
'import the CLI die helper from scripts/lib instead of calling it bare',
).toEqual([])
})
it('keeps DAY_MS single-sourced in src/lib/text.ts', () => {
const offenders: string[] = []
const declaration = /const DAY_MS\s*=/ // this spec quotes the shape itself
for (const root of ['src', 'tests', 'scripts'] as const) {
for (const file of walkFiles(resolve(repoRoot, root), ['.ts', '.tsx', '.mjs'])) {
const path = repoPath(file)
if (path === 'src/lib/text.ts') continue
if (path === repoPath(import.meta.filename)) continue
const lines = readFileSync(file, 'utf8').split('\n')
for (const [index, line] of lines.entries()) {
if (declaration.test(line)) offenders.push(`${path}:${index + 1}`)
}
}
}
expect(offenders, 'import DAY_MS from @/lib/text instead of re-spelling it').toEqual([])
})
})

View file

@ -0,0 +1,41 @@
import { readdirSync, readFileSync } from 'node:fs'
import { relative, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
// Miss #52 (2026-07-30): the municipalities list hydrated ~125 idle Drawer
// roots (25 rows × 5 sheet controls) and prod tripped React #130 — CSS
// `display:none` does not remove hydration cost. The fix pattern is ONE
// shared Drawer per list surface via `CampaignListSheetProvider`, with cell
// bodies portaled into it. The provider reached the municípios mobile cards
// and this pass (2026-07-31) the three chip-relation lists (lideranças,
// dobradinhas, assessores) — this guard keeps the next chip-cell table from
// mounting sheet overlays providerless.
describe('shared sheet host for chip-relation cells (miss #52)', () => {
const componentRoots = ['src/components', 'src/app'] as const
const chipCellUsage = /<MunicipalityPortfolioCell|<LeadershipStateDeputyRelationCell/
it('wraps every chip-relation table in CampaignListSheetProvider', () => {
const offenders: string[] = []
for (const root of componentRoots) {
const files = readdirSync(resolve(process.cwd(), root), {
recursive: true,
withFileTypes: true,
})
.filter((entry) => entry.isFile() && entry.name.endsWith('.tsx'))
.map((entry) => resolve(entry.parentPath, entry.name))
for (const file of files) {
const source = readFileSync(file, 'utf8')
if (!chipCellUsage.test(source)) continue
if (source.includes('CampaignListSheetProvider')) continue
offenders.push(relative(process.cwd(), file))
}
}
expect(
offenders,
'chip-relation cells open sheet overlays on coarse pointers — wrap the table in CampaignListSheetProvider (one shared Drawer per list)',
).toEqual([])
})
})