29 KiB
Impl: S32 — Analytics dos cards personalizados: quantos e quais
Status: aprovado (--auto) Atualizado em: 2026-09-23 Issue: #1273 Intenção: docs/plans/cards-analytics.md Design UI (gate): docs/plans/cards-analytics-ui-design.html Appetite restante: herdado (~0,5–1 dia eng). Corte explícito para caber: um contador por modelo (sem drill-down por dobradinha no v1), sem estado visual novo para a falha da agregação (D7), uma query de agregação por render da home e nenhum widget compartilhado alterado. Fases: server/schema ~55%, UI ~30%, gates ~15%.
Leitura da intenção
- Outcome: o download de qualquer card (6 modelos) conta um evento anônimo "Download do card" com o model id — e, nos modelos com escolha estadual (
time-do-estadual,minha-colinha), também o slug do estadual escolhido; a home staff/campanhamostra um bloco compacto "Cards" com o contador absoluto de cada modelo, lado a lado, na ordem natural do catálogo. - O que NÃO negociar:
- Anonimato fail-closed: sem PII, sem IP persistido, sem cookie/identidade, sem user-agent, sem rastreio entre páginas/modelos; nenhum
Consentnovo. - Fail-soft absoluto: o download nunca é bloqueado nem atrasado; falha da contagem é invisível ao visitante; contador a menos é aceitável.
- Mesmo mecanismo do C213: nenhuma collection/endpoint/serviço paralelo; o card é
subjectType: 'card'da mesmacontentEvente da mesma rota. - Só o download concluído: abertura de galeria, prévia renderizada e estúdio não contam; a foto/recorte local não é tocada.
- Honestidade: os números medem downloads, não pessoas; sem total, %, série, ranking, funil/pageview, dashboard ou rota nova.
- Anonimato fail-closed: sem PII, sem IP persistido, sem cookie/identidade, sem user-agent, sem rastreio entre páginas/modelos; nenhum
- O que reavaliar (hipóteses da "Direção no codebase", checadas):
- O ponto observável do download é único:
handleDownload(CardComposer.tsx:576-590) depois dedownloadBlob, e os dois CTAs (:1180-1193colinha,:1200-1207step result) convergem nele;model.ideselectedDeputy?.slugestão no closure. - O slug do estadual não tem onde viver hoje: a
contentEvent(ContentEvent.ts:50-81) só temtype/subjectType/subjectIde a migration20260923_172437_add_content_events.ts:6-15não temvariant→ este item exige coluna nova (D2). CampaignMetricStripnão entrega a composição do artefato (só até 4 colunas, célulasflex-col, sem caixa de vazio/caption) — é molde, não contrato (D6).- A superfície e o gate existem:
page.tsx:49-56só carrega o resumo parastaff = isStaffCampaignRole; leader nunca vê; communicator é redirecionado antes (D9). - O manifest e2e precisa de uma entrada nova: o mapper da home nasce em
src/lib/card*(prefixo que acorda sófrontend, o site público) e não acorda a família da home (D8).
- O ponto observável do download é único:
Abordagem recomendada
flowchart LR
V["Visitante anônimo"] -->|"baixa o PNG (2 CTAs convergem)"| H["CardComposer.handleDownload"]
H -->|"após downloadBlob · sendCardDownloadEvent(model.id, slug?)"| API["POST /api/content-events"]
API -->|"same-origin → schema → limiter → catálogo (getCardModel/getStateDeputyCard)"| REC["recordContentEvent · Local API overrideAccess"]
REC --> CE[("contentEvent · subjectType=card · subjectId=modelId · variant=slug do estadual")]
CE --> AGG["loadContentEventCountsBySubject — 1 query · 6 ids"]
AGG --> L["loadCampaignHomeSummary → CampaignHomeSummaryView.cardDownloads"]
L --> UI["CampaignHomeCardDownloads · bloco 'Cards' no summarySlot (staff)"]
API -.->|"variante peça: inalterada"| REC
Opções consideradas (abordagem geral): A) estender o mecanismo C213 ponta a ponta (variante aditiva na mesma rota + coluna variant nullable + wrapper de beacon + mapper/agregação reusados + bloco novo na home); B) analytics de cards próprio (collection/rota/agregação novos); C) serviço externo de eventos customizados.
Recomendação: A — o C213 já entregou a collection, o writer, o limiter, a rota e a agregação parametrizada por subjectType; o S32 é a extensão aditiva prevista (o vocabulário já tem card). Cabe no appetite sem nenhum serviço novo e mantém a privacidade fail-closed.
Rejeitadas: B porque a intenção veda explicitamente o segundo analytics e o C213 nasceu genérico para não haver retrabalho; C porque soma um serviço ao homeserver e ainda exigiria eventos customizados por modelo — reavaliar só se a pergunta virar funil/pageview (fora desta fatia).
D1 — Extensão do contrato público
Opções: A) variante aditiva na mesma rota POST /api/content-events (card discriminado por cardModelId), reusando same-origin, limiter e teto de body; B) rota nova /api/card-events; C) segunda collection; D) aceitar subjectType/subjectId arbitrários do cliente.
Recomendação: A — o cliente manda chaves públicas (cardModelId + stateDeputySlug?) e o servidor valida contra o catálogo fechado (isCardModelId/getCardModel, getStateDeputyCard) — nunca aceita id arbitrário. O contrato de peça ({ type, pieceSlug }) fica byte-compatível; exatamente um assunto por body; card só aceita type: 'download' no v1.
Rejeitadas: B porque duplicaria same-origin/limiter/schema/body bounded sem cobertura nova; C porque a intenção e o C213 vedam collection paralela; D porque qualquer um inflaria contador de modelo arbitrário (o catálogo é enum de 6; o gate de publicação da peça não existe aqui, o catálogo é o gate).
D2 — Persistência do slug do estadual
Opções: A) coluna nullable variant (text) em content_event via migration; B) compor o slug no subjectId (ex. time-do-estadual:julio); C) não persistir (só o model id).
Recomendação: A — o C213 previu explicitamente a extensão ("S32 adiciona coluna nullable por migration quando precisar"); subjectId continua sendo o model id (chave do contador por modelo e do índice composto) e o slug vive ao lado. Migration aditiva, sem índice novo — nada lê variant no v1 e índice para leitura futura é migration própria. Item que continua parando mesmo em --auto: criar/aplicar migration exige confirmação humana (§Modo autônomo da skill) — o executor pausa antes de pnpm migrate:create/pnpm migrate e só segue com o OK.
Rejeitadas: B porque quebraria o agrupamento por modelo (a agregação é GROUP BY subject_id, type) e transformaria o slug em parte da chave; C porque viola o aceite ("o evento leva também o slug do estadual escolhido") e joga fora o sinal que a coordenação pediu.
D3 — Tipo do evento
Opções: A) reusar download do vocabulário existente (subjectType card distingue); B) novo tipo download_card no enum (migration de enum).
Recomendação: A — o tipo descreve o ato e o assunto descreve o objeto; a agregação já agrupa por (subject_id, type) dentro de subject_type, então não há ambiguidade e o vocabulário fica estável. Zero migration de enum.
Rejeitadas: B porque infla o vocabulário sem ganho de leitura e exigiria migration de enum desnecessária (mais um stop).
D4 — Transporte client
Opções: A) estender o dono src/lib/contentEvents.ts com um wrapper tipado de card (DRY do beacon sendBeacon/keepalive); B) módulo paralelo src/lib/cardEvents.ts; C) fetch direto no CardComposer.
Recomendação: A — o transporte (sendBeacon → fallback fetch keepalive, nunca lança, nunca atrasa) vira um envio único privado usado pelos dois wrappers; sendContentPieceEvent fica intacto e sendCardDownloadEvent(modelId, stateDeputySlug?) é novo. Uma fonte de comportamento de rede, um pin de "nunca lança".
Rejeitadas: B porque duplicaria o dono do mesmo beacon (segundo caminho para o mesmo endpoint); C porque espalharia a regra de fail-soft no componente e o pin de transporte ficaria sem dono.
D5 — Agregação e mapper
Opções: A) reusar loadContentEventCountsBySubject (1 query, 6 ids, subjectType: 'card') + mapper puro novo em src/lib/ (ordem natural do catálogo, estados); B) reusar contentPieceCirculation (mapper de peça); C) rollup persistido.
Recomendação: A — a agregação do C213 é parametrizada por subjectType justamente para o S32; o mapper de peça tem semântica de peça (id numérico, quatro contadores, neverPublished, zeros × histórico) e não serve ao card (id é o model id, um contador só, o vazio tem desenho próprio). O mapper novo é puro: cardDownloadCountsFromRows (ignora type ≠ download e ids fora do catálogo; normaliza inteiros ≥ 0) e resolveCardDownloadCounts com data | empty | unavailable; os literais do design moram nele (uma fonte, unit-pinned).
Rejeitadas: B porque forçaria condicionais de card dentro do mapper de peça (acoplamento errado e teste de peça a mexer); C porque perde o evento cru e adiciona upsert/lock para um contador de 6 linhas.
D6 — Superfície
Opções: A) estender loadCampaignHomeSummary + componente novo em src/components/campaign/dashboard/ portando o artefato classe-a-classe; B) estender CampaignMetricStrip (6 colunas + layout em linha + vazio); C) improvisar no markup.
Recomendação: A — CampaignMetricStrip é molde, não contrato: é flex-col (rótulo acima do valor), conhece no máximo 4 colunas (gridColsBySize) e não tem caixa de vazio nem caption; estendê-lo mexeria num widget compartilhado (E8 etc.) para um caller. O componente novo (CampaignHomeCardDownloads) porta o artefato: dl com 6 colunas ≥ sm, linhas rótulo-esquerda/valor-direita < sm com min-height: 58px e separadores, tabular-nums, caption de honestidade sempre visível, caixa vazia honesta; tokens bg-card, ring-foreground/10, text-muted-foreground, rounded-xl. A seção entra depois do resumo, dentro do mesmo summarySlot (a página compõe os dois componentes), sem reestruturar o CampaignHomeSummary — o resumo no artefato é contexto e a crítica final (trigger c) decide qualquer ajuste de separação. As pílulas "VALORES FICTÍCIOS DO GATE"/"EXEMPLO" do artefato não são copy de produção.
Rejeitadas: B pelo churn no widget compartilhado e pela estrutura errada (não produz o layout do artefato); C porque o implementador não decide estrutura visual (§Design da execution-pipeline).
D7 — Estado de falha da agregação
Opções: A) estado "indisponível" desenhado — o designer estende o artefato (trigger a) antes do markup; B) omitir a seção inteira quando a agregação falha (fail-soft sem estado novo); C) renderizar o vazio ("Nenhum download ainda") na falha.
Recomendação: B — a falha não é um dado: o bloco é acessório e a ausência não afirma nada (não pode ser confundida com "Nenhum download ainda", que segue reservado aos zeros reais); evita um estado visual novo para uma leitura degradada rara e não inventa estrutura. O domínio mantém unavailable distinto de empty (unit-pinned) e o componente devolve null — quando o produto quiser ver "indisponível", o estado já existe e o designer estende (trigger a).
Rejeitadas: C porque é mentira (a intenção veda); A porque custa uma extensão do artefato + estado novo para um caso raro que o appetite não pede — gatilho de revisitação registrado: leitura degradando com frequência ou pedido explícito de produto → trigger a.
D8 — Testes e manifest
Opções: A) unit (mapper/transporte/componente) + int (rota/loader) + e2e estendendo frontend (download real) e campaignHomeActions (bloco na home), com entry nova no manifest para o mapper da home; B) família e2e nova cardDownloads; C) sem e2e (só unit/int).
Recomendação: A — as duas famílias existem e já cobrem as superfícies: frontend tem os downloads reais de card (frontend.e2e.spec.ts:1848, :2035-2037, :2411) e campaignHomeActions tem a home staff com sessão compartilhada e campaign.fixtures.payload. Unit cobre mapper (ordem/estados/literais) e transporte (nunca lança); int cobre o contrato HTTP (grava com variant; model id inválido, slug em modelo sem stateDeputyPicker, picker sem slug e slug desconhecido → 400 sem gravar; agregação card não vaza para peca nem o inverso) e o loader (data/empty/unavailable). E2E do download observa o POST com page.waitForRequest/postDataJSON (um modelo sem picker + minha-colinha/time-do-estadual com slug). E2E da home pina os 6 rótulos na ordem, a caption e o contador ≥ semeado — contadores de card são globais por model id no DB compartilhado e a família frontend também dispara beacons de card no mesmo run; número exato seria flake, então exato fica no unit/int. Manifest: adicionar src/lib/cardDownloadCounts.ts aos prefixes da entry da home (scripts/lib/e2e-affected-manifest.mjs:296-312) — src/lib/card sozinho acorda só frontend, que não testa a home; schema/vocabulário/rota/collection já caem em frontend/frontendConteudos e src/lib/schemas é risk prefix já mapeado (sem unmapped-risk). Nota honesta: o PR inclui migration → ci-scope classifica curated; as famílias afetadas rodam local (discricionário) e no full do verify do deploy.
Rejeitadas: B porque pagaria project/manifest/fixtures por cobertura que A já dá (C213 fixou "estender, sem família nova"); C porque o POST na mesma rota é contrato HTTP alterado e o wiring do beacon só existe de ponta a ponta no browser.
D9 — Acesso e papéis
Opções: A) herdar o gate de staff da home (bloco só no summarySlot de coordinator/advisor/candidate; leader nunca; communicator redirecionado antes); B) gate novo por role no loader; C) bloco para qualquer sessão de campanha.
Recomendação: A — a home já calcula staff = isStaffCampaignRole(user.role) e só carrega o resumo para staff; leader é lockdown e nunca vê o summarySlot; communicator é redirecionado para a vertical antes (page.tsx:25-56). O agregado é SQL cru sem access control (precedente C213) e o caller é o gate; advisor vê o bloco (é staff; a intenção pede coordenação e assessoria). Nenhum Consent novo — não há PII a consentir.
Rejeitadas: B porque duplicaria um gate existente e divergiria do comportamento da home; C porque exporia o bloco ao leader e a papéis sem home staff.
D10 — Fechamento
Opções: A) uma entrada curta em docs/changelog/2026-09-23-s32.md + Design tier: no PR com a crítica final (trigger c) certificada antes do push; B) sem changelog; C) editar o agregado/HISTORY.
Recomendação: A — OPS44/OPS85: uma entrada por entrega, nunca o agregado (gerado/gitignored); e todo diff que muda estrutura visual fecha com a crítica do designer contra o app renderizado (390/1280 + estados críticos), com o tier no PR. DEGRADED/tier não-primário ⇒ para (não abre PR; comenta a Issue e flipa blocked).
Rejeitadas: B porque o registro commitado é o changelog; C porque o agregado é artefato gerado.
Componentes / mudanças
Schema / dados
src/collections/ContentEvent.ts(editar,:50-81): campovariant(text, opcional, label/description pt-BR — "slug do estadual nos modelos com escolha estadual; nunca um identificador de visitante"); nada de PII; sem hooks (não busta a tag pública); access intacto.- Migration:
pnpm migrate:create add_content_event_variant— coluna nullablevariantemcontent_event(aditiva, sem índice novo); revisar o SQL, commitar.ts/.json/index.ts, rodarpnpm migrate+pnpm generate:typeslocal. STOP humano mesmo em--autoantes de criar/aplicar. src/utilities/content/contentEventWrite.ts(editar,:25-48):ContentEventInputganhavariant?: string; gravavariant: event.variant ?? null; o contrato "nunca lança/nunca atrasa" fica intacto.
Contrato público
src/lib/contentEvents.ts(editar,:60-99): extrai o envio (sendBeacon → fallbackfetch keepalive) para um único caminho privado e adicionasendCardDownloadEvent(modelId: CardModelId, stateDeputySlug?: string | null)com corpo{ type: 'download', cardModelId, stateDeputySlug? };sendContentPieceEventinalterado; nunca lança.src/lib/schemas/contentEvent.ts(editar,:18-25): variante card aditiva —{ type: 'download', cardModelId }validado comisCardModelId(uma fonte) estateDeputySlug?bounded (padrão de slug); contrato de peça inalterado; exatamente um assunto por body.src/app/(frontend)/api/content-events/route.ts(editar,:70-99): ramo card —getCardModel(id fora do catálogo → 400),stateDeputyPickerexige slug resolvido porgetStateDeputyCard(faltando/desconhecido → 400), modelo sem picker rejeita slug → 400; gravarecordContentEvent({ type: 'download', subjectType: 'card', subjectId: modelId, variant: slug ?? null }); mesmoisSameOriginRequest, limiter, body ≤ 4 KB e204 no-store; ramo peça intacto.
Instrumentação (ponto único)
src/components/cards/CardComposer.tsx(editar,handleDownload:576-590): apósdownloadBlob(blob, cardFileName(model.id)),sendCardDownloadEvent(model.id, selectedDeputy?.slug ?? null)— fire-and-forget, sem bloquear/atrasar; erro de geração não conta; os dois CTAs (:1180-1193,:1200-1207) seguem convergindo no mesmo handler.
Leitura / home
src/lib/cardDownloadCounts.ts(novo, puro/client-safe): ordem =CARD_MODELS;cardDownloadCountsFromRows(type-only decontentEvents);resolveCardDownloadCounts→data | empty | unavailable; literais do design (CARD_DOWNLOAD_COUNTS_TITLE/SUBTITLE/CAPTION/EMPTY_TITLE/EMPTY_BODY/EMPTY_CAPTION). ReusaCARD_MODELS(rótulo/ordem) eContentEventAggregateRow(tipo).src/utilities/campaignDashboardData.ts(editar,:231-264):loadCampaignHomeCardDownloads(payload)exportado (1 query comsubjectType: 'card', os 6 ids; falha →{ state: 'unavailable' });CampaignHomeSummaryViewganhacardDownloads;loadCampaignHomeSummaryanexa. ReusaloadContentEventCountsBySubjecte o mapper novo.src/components/campaign/dashboard/CampaignHomeCardDownloads.tsx(novo): porte classe-a-classe do artefato (strip/linhas/caption/caixa vazia);unavailable→null(D7); formata contagens comformatElectionNumber(mesmo formatador da home); semCampaignMetricStrip.src/app/(campaign)/campanha/(app)/page.tsx(editar,:49-56):summarySlotpassa a comporCampaignHomeSummary+CampaignHomeCardDownloads view={summaryView.cardDownloads}.
Testes / manifest / changelog
- Unit:
tests/unit/cardDownloadCounts.unit.spec.ts(novo; ordem/estados/literais/normalização/ids fora do catálogo);tests/unit/contentEvents.unit.spec.ts(editar: pin de campos passa a['type','subjectType','subjectId','variant']sem PII;sendCardDownloadEventcorpo com/sem slug, beacon preferido, fallback, nunca lança);tests/unit/campaignHomeCardDownloads.unit.spec.tsx(novo; 6 rótulos na ordem, vazio desenhado,unavailablenão renderiza "Nenhum download ainda");tests/unit/campaignHomeSummary.unit.spec.tsx(editar:viewganhacardDownloads). - Int:
tests/int/contentEvents.int.spec.ts(editar, reusa o harness existente): POST card grava comvariant; id inválido / slug em modelo sem picker / picker sem slug / slug desconhecido → 400 sem gravar;cardnão vaza parapecanem vice-versa;loadCampaignHomeCardDownloadsdata/empty/unavailable (mock de falha, padrãomockResolvedValueOncedo arquivo). - E2E:
tests/e2e/frontend.e2e.spec.ts(editar; famíliafrontend) — observar o POST no download real (waitForRequest+postDataJSON) para um modelo sem picker e paraminha-colinha/time-do-estadualcom o slug;tests/e2e/campaignHomeActions.e2e.spec.ts(editar; famíliacampaignHomeActions) — semear eventos card viacampaign.fixtures.payload.create({ collection: 'contentEvent', overrideAccess: true }), pinar bloco/rótulos/caption e contador ≥ semeado, e líder sem o bloco. scripts/lib/e2e-affected-manifest.mjs(editar, entry da home:296-312): acrescentarsrc/lib/cardDownloadCounts.ts(o resto já cai nas entries existentes).- Changelog:
docs/changelog/2026-09-23-s32.md(uma entrada curta; nunca o agregado/HISTORY).
Access / Consent: nenhum gate novo — o bloco herda isStaffCampaignRole da home; leader nunca; agregado SQL sem access control com o caller como gate (precedente C213); sem Consent novo (não há PII a consentir) — fail-closed de privacidade.
Dados → forma (pergunta 3 de data-presentation)
- Forma escolhida: seis contadores absolutos lado a lado, na ordem natural do catálogo — 6 colunas no desktop, linhas rótulo-esquerda/valor-direita no mobile, caption "Os números contam downloads, não pessoas ou visitantes únicos." sempre visível; antes do primeiro download, a caixa vazia honesta ("Nenhum download ainda") em vez de seis zeros. É a forma mais pobre que ainda desbloqueia as decisões da intenção (qual modelo divulgar/reforçar; se o funil de cards está sendo usado).
- Rejeitadas: total/soma de "usos" (esconde o modelo — a decisão é por modelo); %/participação (base anônima e desconhecida; sugere precisão inexistente); série/janela/gráfico (vira dashboard; o
createdAtjá habilita a janela como próximo passo, via migration); ranking (a ordem é a do catálogo, nunca placar); barra/heatmap (mesma razão do %); drill-down por dobradinha no v1 (o slug é capturado para leitura futura, mas a superfície são os 6 modelos — ver design "Só seis modelos"). - Honestidade do dado: downloads ≠ pessoas; a superfície não sugere visitante único e não soma sinais.
Fases verificáveis
- Tracer / schema+server — quota ~55%: campo
variant+ migration (STOP humano mesmo em--auto) +generate:types;recordContentEventcomvariant; variante card no schema/rota com validação de catálogo; wrappersendCardDownloadEvent+ call site noCardComposer; units e int verdes antes de seguir. Tracer bullet: baixar um card no dev gera 1 linhasubjectType: card/variante a agregaçãocarddevolve o contador. - UI — quota ~30%: mapper puro +
loadCampaignHomeCardDownloads+CampaignHomeSummaryView.cardDownloads; componente novo portando o artefato classe-a-classe; composição nosummarySlot; unit do mapper/componente verdes.unavailablenão renderiza (D7). - Gates — quota ~15%:
pnpm gate:fastpor iteração;pnpm test:int; e2e local afetado (discricionário:frontend+campaignHomeActions --workers=1); crítica final do designer (trigger c) certificada antes do push comDesign tier:no PR; changelog commitado;pnpm push; PR comCloses #1273;*-impl.mdno commit da entrega.
Rabbit holes / Não escopo (engenharia)
- Drill-down por dobradinha no v1 — o
varianté persistido (aceite) mas não vira segunda superfície/rota; leitura por slug é item próprio (gatilho: produto pedir "qual dobradinha"). - Contar abertura de galeria/prévia/estúdio ou o clique — só o pós-
downloadBlobconta; o resto é abertura, não download. - Segundo mecanismo/collection/rota/serviço — proibido; o card é
subjectTypeda mesmacontentEvent. - Índice em
variant/ rollup persistido / janela-retenção — YAGNI no v1; leitura futura ganha migration própria. - Retry/queue/entrega garantida do beacon — fail-soft: contador a menos é aceitável; nada de fila/outbox.
- Visitor id/cookie/IP/UA/fingerprint/Consent — proibido pela intenção; nenhum campo desses (pin de teste).
- Segunda superfície "para a comunicação" — home staff agora; a vertical depois, se o produto pedir.
- Alterar
CampaignMetricStrip/contentPieceCirculation/agregação — o widget é de outros callers e o mapper de peça tem outra semântica; nada de acoplar card ali.
Riscos e mitigação
- Migration: aditiva (coluna nullable), aplicada pelo deploy antes do build; SQL revisado e
pnpm migratelocal; stop humano mesmo em--auto— sem confirmação, a execução pausa (não é bloqueio silencioso). - Flake de e2e por contador global: contadores de card são por model id no DB compartilhado e a família
frontenddispara beacons no mesmo run — a home pina ≥ semeado; número exato fica no unit/int (isolados). - Beacon perdido (aba fechada/processo morto/400 por drift): aceito (contador a menos);
sendBeaconsobrevive ao clique e o fallbackkeepalivecobre o resto. - Drift do catálogo de estaduais: slug desconhecido → 400 sem gravar; o catálogo é commitado e snapshot-pinned (
state-deputy-catalog.snapshot.json). - Falha da agregação:
{ ok: false }→unavailableno domínio → seção omitida (D7); a home nunca depende do contador. - Cache/tag pública: nenhum hook na collection e nenhum write toca
contentPieces; a home éforce-dynamic. - PR com migration =
curatedno CI: as famílias afetadas rodam no e2e local discricionário e no full doverifydo deploy — registrado honestamente (D8). - Estados não cobertos pelo artefato: só a falha (D7-b, sem markup novo); data/empty são portados do artefato; a crítica final (trigger c) é fail-closed (
DEGRADED⇒ para antes do push).
Desvios registrados na execução
- Crítica final do design (trigger c): a primeira passada foi reprovada pelo
designere os ajustes entraram antes do push — o divisor saiu de baixo do título "Cards" para o fim do resumo (CampaignHomeSummaryganhouborder-b; oh2do bloco neutraliza a regra globalh2 { border-b pb-2 }do stylesheet), o bloco ganhou respiro inferior (pb-4 md:pb-6) e osummarySlotpassou a rolar no mobile (max-md:min-h-0 max-md:overflow-y-autonoCampaignHomeLayout) para as seis linhas e a caption ficarem alcançáveis acima do dock de ações. Re-capturado (390/1280, dados/vazio, com o estado rolado) e certificado em tier primário (Design tier: openai/gpt-5.6-sol). - Simplify: schema da rota com objetos estritos (um body com dois assuntos vira 400, não uma peça silenciosa); write único do ramo card depois dos gates de catálogo (sem duplicar o
recordContentEvent);resolveCardDownloadCounts(nome do plano) no lugar decardDownloadCountsView;CARD_DOWNLOAD_EVENT_TYPEprivado (knip).
Aceite de engenharia
- Aceite de produto da intenção coberto: 1 evento anônimo por download com o model id (+ slug do estadual nos modelos com escolha estadual); bloco "Cards" na home staff com 6 contadores absolutos na ordem do catálogo, sem total/%/série/ranking/dashboard/rota nova.
- Guardrails: sem PII (sem IP persistido, cookie, user-agent, identidade ou rastreio entre modelos); sem
Consentnovo; fail-soft (o download nunca depende da contagem); só o download concluído conta. - Invariantes AGENTS/engineering-standards: mesmo mecanismo do C213 (sem collection/rota paralela); Local API com
overrideAccess: truedocumentado no writer; identificadores em inglês/copy pt-BR; migrations existentes intocadas; nenhum top-level novo emsrc/utilities. - Testes de domínio previstos (unit/int) onde o contrato/write path muda: rota card (grava com
variant; recusas sem gravar), isolamentocard×peca, mapper/estados/literais, transporte nunca lança, loader data/empty/unavailable. - Migration
add_content_event_variant+payload-types.tscommitados; SQL revisado (aditivo);pnpm migratelocal verde; stop humano respeitado. - Manifest e2e com a entry do mapper da home; e2e local afetado rodado (discricionário);
pnpm gate:fastverde. - Design: porte classe-a-classe do artefato (sem as pílulas do gate); crítica final (trigger c) certificada antes do push com
Design tier:no PR. - Changelog
docs/changelog/2026-09-23-s32.md; PR comCloses #1273; impl plan incluído no commit da entrega.
Self-score decision-quality
5/5 — (1) decisões caras com rejeitadas: contrato público (D1), coluna/variant (D2), tipo (D3), transporte (D4), agregação/mapper (D5), superfície (D6), estados (D7), testes/manifest (D8), acesso (D9) e fechamento (D10); (2) cabe no appetite herdado: reusa o mecanismo C213 ponta a ponta e o que nasce é um campo nullable, um wrapper de beacon, um mapper puro e um bloco — sem collection/rota/serviço; (3) rabbit holes nomeados (drill-down, abertura/prévia, segundo mecanismo, rollup/índice, retry, PII/Consent, segunda superfície); (4) depth check reusa loadContentEventCountsBySubject, recordContentEvent, limiter/same-origin/body bounded, CARD_MODELS/getCardModel/getStateDeputyCard, o gate de staff da home e os harnesses de teste — CampaignMetricStrip foi deliberadamente não reusado, com o motivo registrado; (5) outcome intacto: contadores anônimos por modelo na home staff, honestos, fail-soft, sem total/%/série/dashboard, com o slug do estadual capturado sem virar segunda superfície.