37 KiB
Impl: S37 — Central de Conteúdos — lideranças que aparecem nas peças (propriedade e filtro)
Status: aprovado (modo --auto) — hard-stops de schema/URL público pendentes de aprovação humana explícita
Atualizado em: 2026-09-24
Issue: #1303
Intenção: docs/plans/central-conteudos-liderancas.md
Design UI (gate): docs/plans/central-conteudos-liderancas-ui-design.html
Appetite restante: herdado (~2 dias eng) — uma propriedade nova na ficha (C211) + uma faceta nova no catálogo (S27), sem fluxo novo. Corte explícito para caber: catalogação automática intocada (sem sugestão de nomes), sem filtro/coluna na lista interna, sem e2e de browser do picker (unit de componente + int + e2e público cobrem), personalidades por texto livre (a semente do catálogo é o roster S30).
Aprovado pelo próprio agente via
--auto(modo autônomo dowork-issue); a pausa do GATE virou apresentação-no-chat. Os hard-stops abaixo seguem exigindo aprovação humana explícita —--autoaprova o plano, não o schema nem o contrato público.
Leitura da intenção
- Outcome: a ficha da peça registra dois recortes de "quem aparece" — lideranças da campanha (registros existentes) e figuras públicas (texto curado com catálogo) — e o catálogo público ganha uma faceta
Liderançasque une os dois, mostrando só nomes de quem aparece em peça publicada (fail-closed); o nome entra na busca por termo e ninguém vê contato, território, status ou votos. - O que NÃO negociar:
- Fail-closed de exposição: a faceta deriva só de peças publicadas; despublicar a última peça com um nome remove o nome na hora (tag
contentPiecesjá busta em todo write). - Nada de dado interno: sem telefone, e-mail, municípios, organizações,
supportStatus,estimatedVotes; leader lockdown eConsent/LGPD intocados. - Sem cadastro paralelo de pessoa: liderança = registro existente (
leadership→contact); figura pública = texto + catálogo (moldeinstitution). - Uma faceta só (decisão literal do humano: "Os dois"); curadoria humana manda — nada de a catalogação sobrescrever/decidir por gente.
- Nomes só de exibição: sem contagem, ranking, página por pessoa.
- Fail-closed de exposição: a faceta deriva só de peças publicadas; despublicar a última peça com um nome remove o nome na hora (tag
- O que reavaliar (hipóteses da "Direção no codebase"):
- "A catalogação sugere" — não no v1: menção no transcript ≠ aparição na peça; sugerir nomes por match textual poluiria uma lista pública (D5 rejeita A/C e mantém
contentPieceCataloging.tsintocado). - "A faceta pública deriva das peças" — confirmado, mas a denormalização precisa morar na peça: o read público (
contentPieceReads.ts:26-43) temselectexplícito e não pode ganhar a relação (depth 1 vazariaContact). Entra um snapshot de nomes na própria peça (D1/D3). - "A ficha marca a liderança existente" — o access real nega
communicator(canReadLeadership→resolveProfileScopedRead,src/utilities/access/leaderships.ts:31-32), que é justamente quem edita a ficha; sem um caminho gated a persona principal fica cega (D1). - "Figuras públicas no molde de Instituição" — o molde é texto + catálogo, sem relação com
stateDeputy; a semente vem dostateDeputyCatalog(S30) para não duplicar as 53 linhas (D2).
- "A catalogação sugere" — não no v1: menção no transcript ≠ aparição na peça; sugerir nomes por match textual poluiria uma lista pública (D5 rejeita A/C e mantém
Abordagem recomendada
flowchart LR
FICHA["/campanha/comunicacao/conteudos/[id]\nContentPieceForm · 'Quem aparece na peça'"] --> LP["Lideranças da campanha\nbusca async (server action gated)\nprojeção {id, nome}"]
FICHA --> FP["Figuras públicas\ncatálogo S30 + texto livre\ncanonicaliza grafia"]
LP --> REL["contentPiece.leaders (relationship hasMany)"]
FP --> TXT["contentPiece.publicFigures (text hasMany)"]
REL --> HOOK["beforeValidate\nsnapshot leaderNames (só nomes) + searchText"]
TXT --> HOOK
HOOK --> PUB["publish (kill switch)\nrevalidateTag contentPieces"]
PUB --> READS["contentPieceReads select explícito\nleaderNames + publicFigures\nNUNCA leaders/Contact"]
READS --> VM["toContentPiecePublicItem\npeople + metaLabel"]
VM --> FACET["faceta única 'lideranca'\nunião por slugify(nome)"]
VM --> SEARCH["searchText normalizado\nq acha pelo nome"]
FACET --> BOARD["/conteudos?lideranca=…"]
PUB -. "despublica a última peça do nome" .-> FACET
Opções consideradas (abordagem geral): A) relationship leaders + snapshot público de nomes na peça + busca interna gated com projeção mínima + figuras em texto/catálogo derivado do S30 + faceta única derivada dos nomes publicados; B) tudo texto livre na peça (liderança e figura), sem vínculo nem catálogo; C) relação direta com stateDeputy para as figuras e liberar canReadLeadership para a comunicação.
Recomendação: A — preserva o dono de cada mecanismo (registro de Liderança, roster S30, contrato público do S27, hook de denormalização do C211), mantém a fronteira público→interno intacta (o read público nunca toca leadership/contact) e cabe no appetite sem collection nova.
Rejeitadas: B porque perde o vínculo com o registro existente e o casamento de grafias (Q3-B/C do planejamento já rejeitadas); C porque a relação com stateDeputy só cobre as dobradinhas (exclui personalidades), carrega access/PII de campanha para o read público e ampliar o RBAC da liderança inteira por um picker de nomes é blast radius desproporcional (D1).
D1 — Referência interna à liderança e o access do communicator (caro)
Opções: A) campo leaders (relationship hasMany → leadership) + snapshot readOnly leaderNames derivado por hook + loader de busca gated com bypass intencional e documentado, projeção fixa {id, nome}; B) só texto de nomes (leaderNames texto livre), sem relação; C) relação com busca via overrideAccess: false e picker restrito a coordinator/candidate; D) ampliar canReadLeadership para incluir communicator.
Recomendação: A — o campo relacional é o registro existente (anti-cadastro-paralelo) e o snapshot resolve a fronteira pública sem join. A busca interna é a única leitura nova: novo src/utilities/content/contentPieceLeaderOptions.ts (server-only) com
searchContentPieceLeaderOptions(payload, actor, query)eresolveContentPieceLeaderOptions(payload, actor, ids)→{ id: number; label: string }[];- gate
canReadCommunicationCatalog(actor.role)(src/lib/campaignRoles.ts:27-28) fail-closed (negado devolve[]/lança conforme o caller); isContactSearchQueryReady(mín. 2 chars,src/lib/contactSearchQuery.ts:9) e tetoCONTENT_PIECE_LEADER_SEARCH_LIMIT = 20;- query
payload.find({ collection: 'leadership', where: { 'contact.name': { contains: trimmed } }, depth: 1, select: { contact: true }, overrideAccess: true })com comentário de bypass intencional ("só o nome de exibição sai deste módulo; nenhum outro campo deleadership/Contacté lido, retornado, logado ou serializado") — mesmo precedente de bypass documentado da geografia (ContentPiece.ts:175-178), não uma concessão de RBAC; - o mapeamento devolve somente
{ id, label }; ids sem nome resolvido são descartados. O snapshotleaderNamesé derivado nobeforeValidate(deriveContentPieceCatalogIndex,ContentPiece.ts:147-196) só quandodata.leaders !== undefined(relationshipIdnormalizado, ordem preservada; nomes em falta são pulados) e reusa o mesmo módulo com bypass documentado (contexto de sistema). O hook não marca os campos como curados (D5). Rejeitadas: B porque "marcar a liderança" vira texto sem lastro no dono e não distingue "liderança que existe" de "nome digitado"; C porque bloqueia a persona que edita a ficha (ocommunicatoré quem opera a Central,requireCampaignPageActor({ gate: 'communicationCatalog' })+canReadContentPiece) — contraria o outcome; D porque concede a leitura inteira deleadership(municípios,supportStatus,user,consent, PII deContactvia depth) para a comunicação por causa de um picker, mexe no dono do RBAC e amplia a superfície de vazamento sem necessidade.
D2 — Recorte de figuras públicas: storage e catálogo/semente (caro)
Opções: A) publicFigures (text hasMany, valores canônicos/curtos) + catálogo estático novo src/lib/publicFigureCatalog.ts derivado do stateDeputyCatalog (S30) + lista explícita (vazia no v1) de personalidades, com canonicalização de grafia no save; B) um único text (molde institution literal); C) relationship hasMany → stateDeputy; D) texto livre sem catálogo.
Recomendação: A — a peça costuma ter mais de uma pessoa (dobradinha + personalidade) e o design desenha chips com lista sugerida ("Digite ou escolha uma figura pública…"). O catálogo:
PublicFigureKind = 'dobradinha' | 'personalidade'; entradas{ slug, name, kind };publicFigureCatalog = stateDeputyCatalog.map(...)comslug: 'estadual-<slug>'(prefixo evita colisão com personalidades; as 53 linhas não são copiadas — o roster S30 é o dono)+ PUBLIC_FIGURE_PERSONALITIES(constante explícita, vazia no v1: inventar nomes seria dado de produto sem lastro; "outras personalidades" entram por texto livre e viram data edit quando valer catalogar);filterPublicFigures(query)para o picker (accent/case-insensitive sobrename/slug, comofilterStateDeputyCards,src/lib/stateDeputyCatalog.ts:115-122);resolvePublicFigureName(value)— fold accent/case/pontuação comnormalizeSearchPhrase(src/lib/wordStartFilter.ts:1-8) sobreslug+name; ambíguo →null;normalizeContentPiecePublicFigures(values)— trim,resolvePublicFigureName(v) ?? v, dedupe por forma normalizada, tetoCONTENT_PIECE_PUBLIC_FIGURES_MAX. A canonicalização roda na action (updateContentPieceForActor) e no hook (gravações diretas/admin passam pelo mesmo filtro), reusandonormalizeForSearch/slugifydo contrato. Rejeitadas: B porque força protocolo de separador e transforma os chips em uma string (a faceta e os chips precisam de itens); C porque só cobre as dobradinhas (personalidades ficariam de fora), traria o access destateDeputypara o caminho público e criaria duas fontes para "figura pública" (roster + catálogo); D porque perde o casamento de grafias (Q3-C já rejeitada no planejamento).
D3 — A faceta pública une os dois recortes sem ler leadership/Contact (caro)
Opções: A) uma faceta lideranca (param slug, pt-BR, como instituicao) derivada em memória da união leaderNames ∪ publicFigures dos itens publicados, chave slugify(nome), label = nome exibido; B) duas facetas (lideranca + figuras); C) chave por id/slug de origem.
Recomendação: A — o read público seleciona somente leaderNames/publicFigures da própria peça (contentPieceReads.ts, select explícito; nunca leaders), então a faceta só existe a partir do que está publicado (fail-closed por construção, sem query de publicação extra). A união vive no contrato puro src/lib/contentPieceCatalog.ts (contentPiecePublicPeople(item) → { slug, name }[], dedup por slugify), consumida por contentPieceCatalogFacets e por filterContentPieceCatalogItems (AND com as demais facetas, como hoje). Homônimos colapsam no mesmo filtro por design: a faceta é um filtro de nome de exibição, não de identidade — documentado no código e pinado em unit (o mesmo slug para "Dra. Elaine"/"Dra Elaine" vira uma opção só; o label vence o último item na ordem estável).
Rejeitadas: B porque contraria o literal do humano ("Os dois" na mesma faceta) e duplicaria a superfície de filtro; C porque ids de liderança vazariam identidade interna na URL pública, não existem para figuras e quebrariam o contrato de slug do S27.
Extras de UI cobertos pelo design (cena 03): caption "Em peças publicadas" dentro do dropdown da faceta Lideranças e placeholder da busca "…assunto, cidade ou pessoa…".
D4 — Nome na busca e no card público (caro)
Opções: A) nomes entram no searchText (type + chamada) e o metaLabel do card lidera com o primeiro nome; B) nomes só na faceta, fora da busca/card; C) linha própria de "quem aparece" no card e/ou na página da peça.
Recomendação: A — o aceite é explícito ("buscar o nome de quem aparece acha a peça") e o design desenha o nome na linha de metadados do card ("Nome da liderança · Tema"). Mudanças: contentPieceSearchText (src/lib/contentPiece.ts:323-336) ganha leaderNames/publicFigures no input (normalizados) com o call site atualizado (ContentPiece.ts:187-194, com fallback originalDoc como cidade/região); metaLabel (contentPieceCatalog.ts:524-526) passa a [primeiroNome, tema/institution, local] — card sem pessoa permanece byte-idêntico (os pins do S27/S28 seguem). A página da peça não ganha linha nova no v1 (o artefato cena 04 do S27 não a cobre) — vira extensão de design se o gate quiser.
Rejeitadas: B porque contraria o aceite; C porque é markup sem design (gap com trigger (a), não invenção do implementador).
D5 — Colisão com a regra curated da catalogação automática (caro)
Opções: A) acrescentar leaders e publicFigures ao enum persistido content_piece_curated_fields; B) não acrescentar — o vocabulário continua exatamente os campos que o job pode preencher, e a action não marca os novos; C) acrescentar só publicFigures "para o futuro sugeridor".
Recomendação: B — curatedFields existe para arbitrar job×curadoria (contentPieceJob.ts só escreve campo fora da lista); como o job nunca escreve os campos novos (nomes são curadoria manual; menção ≠ aparição), incluí-los seria vocabulário morto e obrigaria um ALTER TYPE enum_content_piece_curated_fields sem consumidor. contentPieceCataloging.ts e content_piece_curated_fields ficam intocados.
Rejeitadas: A e C porque criam estado/enum sem escritor (a "sugestão" da intenção é permissiva, não requisito); gatilho: se um sugeridor de figuras for aprovado, o item dele adiciona o valor do enum na própria migration, junto do matcher.
D6 — UI dos dois pickers sem a 4ª cópia do listbox (barato de reverter, mas forma)
Opções: A) um componente de domínio ContentPiecePeopleField (chips + painel inline) sobre os primitivos Command/CommandInput/CommandList/CommandItem (src/components/ui/Command.tsx) com dois usos — liderança async e figura local+texto livre; B) 4ª cópia do listbox inline de StateDeputySelect; C) CommandDialog modal (ResponsibleMultiSelect); D) Base UI Combobox flutuante (FormCombobox).
Recomendação: A — MunicipalityRelationEditor.tsx:397-440 já usa Command inline (precedente direto) e o cmdk entrega teclado/a11y sem nova cópia; o componente é de domínio (2 usos no mesmo form), sem generalizar o StateDeputySelect do Studio S30 (que declara o gatilho de revisita "a fourth consumer", que assim não dispara).
Rejeitadas: B (quarta cópia manual = o gatilho de revisita declarado no próprio arquivo, com custo de a11y); C (modal ≠ cenas 01/02, que desenham painel inline); D (popup flutuante ≠ painel inline do artefato).
D7 — Verificação por camada (barato de reverter, mas explícito)
Opções: A) unit de componente do picker (testing-library, search stub) + int do action/loader/contrato + e2e público browser (frontendConteudos) + render browserless da ficha (campaignSpeechAcervo); B) e2e de browser novo só para o picker; C) só int/unit (sem e2e público novo).
Recomendação: A — a fiação do client (chips, hidden inputs, debounce, remoção) é barata no unit de componente (há precedente activityOverlay.unit.spec.tsx); o action + snapshot + gate + fail-closed são domínio (int); o journey "marcado → filtro público" é o contrato do S27 e roda no frontendConteudos (seeding REST admin com contact+leadership+leaders, o caminho real do hook). Nenhum project/spec novo (sem custo de manifest).
Rejeitadas: B porque duplica setup de login/browser por cobertura que o unit de componente + int já dão (gatilho: quebra real de fiação no browser); C porque o contrato público novo (faceta + nome no card) precisa de prova HTTP real.
Componentes / mudanças
Schema / dados
src/collections/ContentPiece.ts(editar): camposleaders(relationship →leadership, hasMany, label "Lideranças da campanha",maxRows: CONTENT_PIECE_LEADERS_MAX),leaderNames(text hasMany, readOnly — "Nomes para a Central pública"; snapshot),publicFigures(text hasMany, label "Figuras públicas",maxRowsemaxLengthdo dono);deriveContentPieceCatalogIndexganha a derivação condicional deleaderNames(relation touched, ordem dos ids, nomes em falta pulados), a normalização depublicFigurese os dois campos emcontentPieceSearchText.src/lib/contentPiece.ts(editar):CONTENT_PIECE_LEADERS_MAX,CONTENT_PIECE_PUBLIC_FIGURES_MAX,CONTENT_PIECE_PUBLIC_FIGURE_MAX_LENGTH,CONTENT_PIECE_LEADER_SEARCH_LIMIT;ContentPieceSearchInput+leaderNames/publicFigures.src/lib/publicFigureCatalog.ts(novo, puro/client-safe): catálogo derivado dostateDeputyCatalog+PUBLIC_FIGURE_PERSONALITIES,filterPublicFigures,resolvePublicFigureName,normalizeContentPiecePublicFigures,isPublicFigureCatalogName.- Migration:
pnpm migrate:create add_content_piece_people→20260924_<HHMMSS>_add_content_piece_people, aditiva: tabelacontent_piece_rels(relationship hasManyleaders),content_piece_leader_namesecontent_piece_public_figures(text hasMany). SemALTER TYPE(D5) e sem backfill — peças antigas ficam sem pessoas até a ficha editar.pnpm migratelocal +pnpm generate:types(regerarsrc/payload-types.ts); nunca editar migration existente.
Access / busca interna
src/utilities/content/contentPieceLeaderOptions.ts(novo,server-only): D1 — gatecanReadCommunicationCatalog,isContactSearchQueryReady, teto 20, projeção{id, label}, bypass intencional comentado (só nome de exibição).src/app/(campaign)/campanha/actions/contentPieces.ts(editar):searchContentPieceLeaderOptionsForActor(query)(server action; gate + erroCONTENT_PIECE_FORBIDDEN_MESSAGE);updateContentPieceForActoraceitaleaderIds/publicFigures, canonicaliza figuras e persisteleaders/publicFigures(sem novoscuratedFields).src/lib/schemas/contentPiece.ts(editar):leaderIds(ints, teto) epublicFigures(strings, teto/tamanho) emcontentPieceUpdateRequestSchema.src/app/(campaign)/campanha/(app)/comunicacao/conteudos/formActions.ts(editar): lêgetAll('leaderIds')(ints válidos) erepeatedFormTexts(formData, 'publicFigures').src/utilities/content/contentPiecePageData.ts(editar):contentPieceDetailSelect+leaders/publicFigures; o detalhe resolveleaderOptionsviaresolveContentPieceLeaderOptionse devolvepublicFigures.
Contrato público (S27)
src/lib/contentPieceCatalog.ts(editar):'lideranca'emCONTENT_PIECE_CATALOG_FACETS(append, ordem Tipo·Cidade·Região·Tema·Instituição·Lideranças) + label "Lideranças";liderancaem params/parse (slugFacet)/href;contentPiecePublicPeople;contentPieceCatalogFacets.lideranca; filtro AND;ContentPiecePublicSource/ContentPiecePublicItemcomleaderNames/publicFigures;metaLabelcom o primeiro nome.src/utilities/content/contentPieceReads.ts(editar):selectpúblico ganhaleaderNames: true, publicFigures: true— jamaisleaders.src/components/conteudos/ContentPieceFilters.tsx(editar): caption "Em peças publicadas" no dropdown da facetalideranca(design cena 03); placeholder "Encontre uma peça por assunto, cidade ou pessoa…". O chip/hidden input/remoção são automáticos viaCONTENT_PIECE_CATALOG_FACETSeContentPieceCatalogFacets(Record obriga a chave nova em tempo de compilação).- Card público: sem mudança de arquivo —
CardMeta(ContentPieceCard.tsx:96-97) já renderizametaLabel.
UI interna (Impeccable C — port classe-a-classe)
src/components/campaign/content/ContentPiecePeopleField.tsx(novo, client): bloco "Quem aparece na peça" (heading, ajuda "Marque somente pessoas visíveis…", badge "Curadoria humana", empty state "Ninguém marcado…"), dois pickers (liderança: busca async via propsearchLeaders; figura: catálogo local + "Usar «texto»"), chips removíveis e hidden inputs repetidosleaderIds/publicFigures; reusaCommandinline +Badge.src/components/campaign/content/ContentPieceForm.tsx(editar): propsleaderOptions,publicFigures,searchLeaders; render do fieldsetsm:col-span-2.src/app/(campaign)/campanha/(app)/comunicacao/conteudos/[id]/page.tsx(editar): passa os novos props (osearchLeadersé a server action, padrão já usado comformAction).src/app/(campaign)/campanha/(app)/comunicacao/conteudos/page.tsx: sem mudança (Q(a) da intenção: sem filtro/coluna interno no v1).- Changelog:
docs/changelog/2026-09-24-s37.md(entrada curta; nunca editar o agregado/HISTORY).
Access / Consent: um bypass intencional documentado (D1) e mais nada; nenhum Consent novo, nenhum cadastro de pessoa, leader intocado.
Dados → forma (se aplicável)
N/A — a faceta é lista de nomes, sem contagem, série, ranking ou score (declarado na intenção); os nomes no card são metadado de conteúdo, não apresentação de dado.
Design tier
Artefato aprovado no gate: docs/plans/central-conteudos-liderancas-ui-design.html. Superfícies cobertas e como o port as atende:
- Cena 01 — ficha interna desktop 1280: bloco "Quem aparece na peça" com os dois recortes lado a lado, chips, painéis inline (liderança e figura), badge "Curadoria humana" e as duas linhas de ajuda;
ContentPiecePeopleFieldporta classe-a-classe. - Cena 02 — ficha mobile 390: empilhamento dos dois recortes e o estado "Ninguém marcado. A peça pode ser publicada normalmente…" (o form já é responsivo; o empty state é copy do artefato).
- Cena 03 — catálogo público desktop/mobile: chip
Liderançasna fileira, dropdown com a caption "EM PEÇAS PUBLICADAS", chip ativo "Lideranças · Nome ×", card com o nome na linha de metadados e o placeholder "assunto, cidade ou pessoa…"; o box tracejado "Regra visível do gate" é lido como anotação do gate (a regra vira a caption do dropdown), não como UI. - Cena 04/estado de peça sem ninguém marcado: coberto pelo empty state da cena 02.
Não cobertas → trigger (a) (dispatch do designer antes do markup final, se aplicável):
- Posição do bloco no formulário completo do C211 — o artefato mostra um close-up (Título → bloco → Instituição/Cidade) e omite Descrição/Tipo/Data/Temas/Transcrição; o port insere o fieldset
sm:col-span-2depois de Temas, antes de Cidade e o designer valida no gate. - Picker no mobile do desktop — o artefato cobre o painel inline em 390; o componente mantém o mesmo painel em qualquer largura (sem cena extra) — designer valida.
- Linha de "quem aparece" na página individual da peça — não desenhada; fica fora do v1 e entra como cena nova se o gate quiser.
- Placeholder mobile "Busque uma pessoa…" — lido como mock; vale o texto do desktop ("assunto, cidade ou pessoa").
Fases verificáveis
- Tracer / schema+server — quota ~45%: constants + campos + hook (snapshot e
searchText) + migration + types;publicFigureCatalog;contentPieceLeaderOptions+ server action; contrato público (liderancaem facets/parse/href/filter/VM/metaLabel) + select do read público; unit + int do caminho (ficha marca → publica →/conteudos?lideranca=acha; despublica → some) verdes antes de seguir. - UI (port classe-a-classe) — quota ~35%:
ContentPiecePeopleField+ form + ficha page + caption/placeholder dos filtros; unit de componente do picker. - Gates — quota ~20%:
pnpm gate:fast(lint/format/typecheck/knip/cycles/unit);pnpm test:int; e2efrontendConteudos(curado, roda pelo PR high-risk) +campaignSpeechAcervolocal (pnpm test:e2e --no-deps -- tests/e2e/campaignSpeechAcervo.e2e.spec.ts --workers=1);pnpm push→ PR--base main(a verificação viva é o e2e de rotas reais).
Testes previstos
tests/unit/contentPieceCatalog.unit.spec.ts(editar): parse delideranca(slug válido/ inválido/desconhecido → null); href canônico com a faceta na ordem fixa;contentPieceCatalogFacets.lideranca= união líderes+figuras, dedup porslugify, só de itens publicados, homônimo colapsa; filtroliderancacombinado comtipo/q(AND); VM comleaderNames/publicFiguresemetaLabelliderando pelo primeiro nome (e inalterado sem pessoas).tests/unit/contentPiece.unit.spec.ts(editar):contentPieceSearchTextinclui e normaliza nomes de liderança e figuras (acento/caixa); limites novos.tests/unit/publicFigureCatalog.unit.spec.ts(novo): catálogo deriva as 53 dobradinhas dostateDeputyCatalog(contagem/nomes/slugprefixado);resolvePublicFigureNameaccent/pontuação-insensível, ambíguo → null, desconhecido → null;normalizeContentPiecePublicFiguresdedupe/canonicalização/teto;filterPublicFigures.tests/unit/contentPiecePeopleField.unit.spec.tsx(novo, componente com testing-library): busca stub devolve opções, seleção vira chip + hidden input; remoção limpa; texto livre vira figura; estado vazio; limite.tests/int/contentPiece.int.spec.ts(editar; fixtures já criamcontact/leadership): action de busca — communicator recebe{id,label}e nada além (shape assertado), advisor/leader negados; save da ficha persisteleaders, snapshotleaderNamesna ordem e figuras canonicalizadas; limpar funciona; leitura pública deriva faceta/nome, rascunho/despublicado nunca aparece,qacha pelo nome;getPublishedContentPieceRecordsnão trazleaders/contact; job (runContentPieceJobcom stub) não toca os campos novos.tests/e2e/frontendConteudos.e2e.spec.ts(editar): seeding REST admin decontact+leadership+ peça comleadersepublicFigures;/conteudosmostra o chipLideranças, filtra por?lideranca=<slug>e o card mostra o nome; despublicar a peça remove o nome da faceta; cleanup de contatos/lideranças noafterAll.tests/e2e/campaignSpeechAcervo.e2e.spec.ts(editar, seção C211): a ficha renderiza o bloco "Quem aparece na peça" com o chip da liderança selecionada para o communicator; advisor segue negado.- Fixtures:
tests/helpers/campaignFixtures.tsintocado (contact/leadership já na union e no cleanup).
Pins a atualizar (valores exatos)
scripts/lib/e2e-affected-manifest.mjs:159-179(entrada S27): acrescentar'src/lib/publicFigureCatalog.ts'→['frontendConteudos'].scripts/lib/e2e-affected-manifest.mjs:440-464(vertical de comunicação/C211): acrescentar'src/lib/publicFigureCatalog.ts'(o picker e o int da Central usam o catálogo).tests/unit/e2eAffectedManifest.unit.spec.ts:54-79— intocado:frontendConteudosjá está no curado; nenhum risk prefix novo.tests/unit/codebaseConventions.unit.spec.ts:202-237e:409-556— intocado: sem rota POST nova (a busca é server action) e nenhum módulo novo no top-level desrc/utilities/(tudo emsrc/utilities/content/).tests/unit/contentPieceCatalog.unit.spec.ts,tests/unit/contentPiece.unit.spec.ts,tests/int/contentPiece.int.spec.tse os dois e2e — estendidos conforme "Testes previstos".src/payload-types.ts— regenerado (pnpm generate:types), nunca editado à mão.
Rabbit holes / Não escopo (engenharia)
- Sugerir nomes automaticamente no job (match de transcript no catálogo) — menção ≠ aparição; catálogo intocado (D5); gatilho: pedido explícito da comunicação, com item próprio.
- Filtro/coluna na lista interna de peças — só se o gate pedir (Q(a) da intenção); hoje a lista fica como está.
- Relação com
stateDeputy,Speech.mentionedPeople/C200, acervo — superfícies distintas; não copiar o par nem acoplar. - Página pública por pessoa, diretório, contagem/placar de aparições, analytics — vedados pela intenção.
- Mostrar nomes na página individual da peça — sem cena; trigger (a) se o gate quiser.
- Tocar
src/utilities/access/*/ampliarcanReadLeadership— rejeitado em D1; o bypass vive no domínio de conteúdo, documentado. - Tocar
StateDeputySelect/Studio S30 — fora; o componente novo é de domínio. - Backfill de peças existentes — sem dados; a faceta nasce vazia e cresce com a curadoria.
Consent/Contact/collection novos — proibidos; nenhuma PII nova é capturada.- e2e de browser novo para o picker — unit de componente + int + e2e público cobrem (D7); gatilho: falha de fiação real.
Riscos e mitigação
- Bypass de nomes para o
communicator(novo caminho de leitura): gatecanReadCommunicationCatalog+ projeção fixa{id, label}+ teto e mínimo de 2 chars; unit/int assertam o shape e o negado; comentário de bypass explícito no módulo; se um campo a mais vazar no retorno, o teste quebra. - Snapshot
leaderNamesdefasado (contato renomeado fora da ficha): o form envialeaderIdsem todo save, então o hook recomputa no toque; o job/publish não limpam; gatilho: se renomes virarem frequentes, recomputar também na entrada empublicado. searchTextdessincronizado em update parcial: hook com fallbackoriginalDoc(padrãoderiveSpeakerNames); int cobre publish que envia sóstatus.leaders/Contactvazando no read público:selectexplícito nunca inclui a relação; teste int asserta que o record cru não temleaderse que o VM não carrega ids/contato; a faceta lê só o snapshot.- Faceta com homônimos/variações: colapso por
slugifydocumentado + unit; a faceta é filtro por nome, não identidade. - Migration aditiva em collection publicada: só tabelas novas, sem backfill; o deploy já aplica
payload migrateantes do build; revisar o SQL gerado e rodarpnpm migrate+ int local antes do push. - PR high-risk (migration): o CI seleciona o curado (
frontendConteudos);campaignSpeechAcervoroda no full doverifydo deploy e localmente com o comando da Fase 3. - e2e de pessoas com cleanup: contatos/lideranças criados no spec entram nas listas de limpeza na ordem FK (peça → liderança → contato).
Hard-stops (aprovação humana explícita — valem mesmo em --auto)
- Schema/migration (Fase 1):
pnpm migrate:create add_content_piece_people+pnpm migratesó no banco local do worktree (teqo_wt*/teqo_wt*_test) — aditiva, sem backfill, semALTER TYPE.--autoaprova o plano, não o schema. Aprovada na sessão--auto(2026-09-24) — criar/aplicar só no banco local do worktree; nunca apontar para banco remoto fora do deploy. - Contrato público (Fase 1): faceta nova
liderancano param de/conteudos(aditiva, append na ordem fixa) + os camposleaderNames/publicFiguresno shape público selecionado e nometaLabeldo card. Aprovado na sessão--auto(2026-09-24). - Bypass intencional de Local API (D1): leitura de nomes de liderança para o
communicatorcom projeção{id, nome}— decisão de segurança mais sensível do item (self-score abaixo). Aprovado na sessão--auto(2026-09-24), com gate + projeção fixa + teto + testes de shape.
Aceite de engenharia
- Aceite de produto da intenção coberto: ficha registra os dois recortes (liderança existente + figura pública em texto com catálogo); UMA faceta
Liderançasune os dois; nomes na busca por termo e no card; fail-closed (rascunho/despublicado nunca expõe nome); curadoria manda. - Guardrails: sem dado interno de liderança na superfície pública; sem cadastro paralelo; sem
Consentnovo; leader lockdown eestimatedVotesintocados;consent/LGPD intocados. - Invariantes AGENTS/engineering-standards: bypass de Local API único e documentado (D1) com projeção mínima; escrita multi-campo na action existente (sem transação nova — um único
payload.update); identificadores em inglês/copy pt-BR; migrations existentes intocadas; sempush. - Testes de domínio previstos (unit/int) onde access e write paths mudam; pins de manifest atualizados; e2e público estendido.
pnpm gate:fastverde;pnpm test:intdo spec verde; e2efrontendConteudosverde;campaignSpeechAcervorodado local verde;pnpm pushvia GitHub.
Decisões de engenharia
- D1 — Liderança + access: relationship hasMany
leaders+ snapshotleaderNames+ loader gated com bypass intencional, projeção{id, nome}, teto 20 e mín. 2 chars. Rejeitadas: texto sem vínculo (B), picker só para coordinator/candidate (C) e ampliarcanReadLeadership(D). - D2 — Figuras públicas:
text hasMany+ catálogo derivado dostateDeputyCatalog+ personalidades por data edit + canonicalização de grafia no save. Rejeitadas: single text (B), relação comstateDeputy(C), texto sem catálogo (D). - D3 — Faceta: uma
liderancaderivada em memória da união dos nomes dos itens publicados, chaveslugify, homônimos colapsam; read público nunca tocaleadership/Contact. Rejeitadas: duas facetas (B) e chave por id/slug de origem (C). - D4 — Busca/card: nomes no
searchText(type + call site) emetaLabelliderando com o primeiro nome; página da peça fora do v1. Rejeitadas: fora da busca (B) e linha nova sem design (C). - D5 — Curadoria:
content_piece_curated_fieldsintocado; sem sugeridor automático (D2/A e C rejeitadas); gatilho registrado. - D6 — UI: painel inline sobre
Command(precedenteMunicipalityRelationEditor), sem a 4ª cópia do listbox e sem tocar o Studio S30. Rejeitadas: cópia manual (B), modal (C), popup flutuante (D). - D7 — Verificação: unit de componente + int + e2e público + render browserless da ficha; sem spec novo. Rejeitadas: e2e de browser do picker (B) e só unit/int (C).
- Sem Consent, sem collection nova, sem
ALTER TYPE, sem rota nova — registrado, não presumido.
Self-score
Self-score decision-quality: 4/5. (1) Todas as decisões caras (referência + access, storage/catálogo de figuras, faceta única sem PII, busca/card, colisão com curatedFields) têm Opções + Recomendação + Rejeitadas explícitas; (2) cabe no appetite herdado: sem collection/rota nova, uma migration aditiva, reusa Command inline, o hook de denormalização, o contrato público do S27, normalizeForSearch/slugify, stateDeputyCatalog e o padrão de server action como prop; (3) rabbit holes nomeados (sugestão automática, filtro interno, stateDeputy/C200, ranking, página por pessoa, ampliar RBAC, backfill, browser e2e); (4) depth check: donos existentes editados (contrato público, collection/hook, action, form) e só nasce módulo onde não há dono (publicFigureCatalog, contentPieceLeaderOptions, ContentPiecePeopleField); (5) outcome preservado — fail-closed, uma faceta, curadoria manual, sem PII e sem reescrever o aceite. O que impede o 5 é o bypass intencional de leitura de nomes de liderança para o communicator (D1): é a decisão de segurança mais sensível do item e pede confirmação humana explícita no gate, mesmo com projeção mínima, gate e testes de shape.
Simplify — défers deste triage (2026-09-24)
Duas revisões paralelas (estrutural + qualidade) rodaram sobre o diff; os achados aplicáveis foram corrigidos na sessão (null-safety do hook, select honesto, ordem de submissão preservada nos chips, slugify/normalizeSearchPhrase do catálogo, PeopleChip/orderOptionsById extraídos, leaderIds removido do page data, gate num só lugar). Ficaram deferidos, com gatilho:
- Defer (estados async dos pickers). O par loading/failed de
ContentPiecePeopleFieldrepete o deResponsibleMultiSelect/AsyncSearchCombobox(3º consumidor). Gatilho: um 4º consumidor dos estados assíncronos ou o próximo item que mexer em um dos três → extrair um shell único (shared/AsyncSearchStates), sem tocar nos fluxos aprovados. - Defer (projeção de liderança).
contentPieceLeaderOptionsrepete a projeção/label deactivityLeadershipOptions(que usaoverrideAccess: false— semântica de access diferente da deste item). Gatilho: um 3º consumidor do nome de exibição de liderança ou o próximo item que alterar o label (ex.: C200/speakers) → extrair a projeção pura compartilhada. - Descartado:
facet === 'lideranca'repetido no componente de filtros (leitura local, 3 pontos);select: { contact: true }(o plano D1 o escolheu; o tipo do Payload não aceita select aninhado e o{ id, label }+ int test são a fronteira do shape);PUBLIC_FIGURE_PERSONALITIES/kindvazios (seam aprovado no plano, v1 deliberadamente vazio).
Fechamento (sessão --auto, 2026-09-24)
- Design (trigger c): certificado PARIDADE pelo
designer(tier primário, semDEGRADED) após 3 passadas contra o app renderizado (390/1280): ficha com/sem pessoas (cenas 01/01A/02) e catálogo com a faceta aberta/filtrada/card (cena 03). - Hard-stops: aprovados explicitamente pelo humano na sessão (migration aditiva só no banco local, faceta/contrato público aditivo e o bypass D1 com projeção
{id, nome}+ testes de shape).