- /fotos/encontre + POST /api/fotos/selfie: só o vetor 128-d, sem imagem, sem score e sem nomes de terceiros; fail-closed pela flag photoAlbum.selfieSearchEnabled (default false) e pelos Consents por chave estável (busca-selfie-fotos/busca-selfie-indice, não seedados — a superfície nasce fechada até o aval jurídico/DPIA). - Índice A/C do gate (PR #1370): só quem aderiu é indexado; descriptors do acervo são transitórios. Collection faceSubject (admin-only, sem vínculo com Contact), vínculos matchedPhotos e marker faces.checkedKey no archivePhoto (modelo + enrolledAt). - Engine @vladmandic/face-api nos dois lados: browser lazy com modelos same-origin (copy-face-vision-assets no prebuild/predev/Docker) e CLI node-wasm sem tfjs-node. CLIs ops-only faces:enroll / faces:index --apply|--verify com guardas C231/C232. - Opt-out self-service por reconhecimento (leave-index) + canal de remoção do C233; rate limit próprio (namespace selfie:<hash>) e bounded body extraído do beacon. - UI portada classe-a-classe do design estendido (paridade certificada, tier primário); e2e frontendFotosSelfie (manifest+curado+projeto, engine stubada) e canário do engine real; runbook §C234, contrato em AGENTS-public.md e changelog. - Migration aditiva add_face_subject (SQL revisado); hard-stops aprovados na sessão.
45 KiB
Impl: C234 — Busca por selfie — visitante encontra as fotos em que aparece
Status: aprovado (modo --auto) — hard-stops pendentes de aprovação humana explícita na sessão (migração de schema e contrato público)
Atualizado em: 2026-09-27
Issue: #1369
Intenção: docs/plans/busca-fotos-por-selfie.md
Design UI (gate): docs/plans/busca-fotos-por-selfie-ui-design.html
Appetite restante: herdado (~2–3 dias eng + ops de enrollment/index + aval jurídico de produto) — uma superfície nova de biometria sobre o acervo aprovado do C232/C233 (/fotos/encontre + POST /api/fotos/selfie), sem segundo cadastro de pessoa. Cortes explícitos para caber: enrollment e indexação são ops-only (CLI); sem UI de enrollment; sem captcha; sem modo B (rostos anônimos do acervo); sem retenção de selfie; sem score.
Aprovado pelo próprio agente via
--auto(modo autônomo dowork-issue); os hard-stops continuam exigindo aprovação humana explícita e estão pendentes (migração de schema, contrato público, Consent/DPIA, dependência de engine e enrollment ops-only). Decisão do gate (PR #1370): "Escopo do índice facial A/C no MVP (B só com DPIA + aval jurídico)". Interpretação canônica a implementar: só entram no índice rostos de pessoas que aderiram/consentiram; a busca por selfie só devolve se o rosto consultante já estiver indexado; índices anônimos de rostos do acervo são proibidos (isso é B). Timing: superfície pública de biometria não abre sem revisão jurídica — o código nasce fechado (flag própria defaultfalse+ Consent fail-closed); a pessoa geral (não indexada) recebe vazio honesto.
Leitura da intenção
- Outcome: com consentimento ativo e adesão ao índice, o visitante tira/envia uma selfie, o reconhecimento roda no dispositivo (a imagem nunca sai dele) e ele recebe somente as fotos públicas aprovadas em que aparece — sem nomes de terceiros e sem score; sem consentimento configurado o fluxo recusa com linguagem clara (nem na UI nem no servidor); a remoção (sair do índice / despublicar foto) funciona e fica registrada.
- O que NÃO negociar:
- Escopo A/C travado pelo gate: índice só de rostos consentidos; busca que só devolve para rosto já indexado; proibido indexar rostos anônimos do acervo (B) sem DPIA + aval jurídico.
- Fail-closed de Consent/LGPD: sem Consent resolvido por
keyestável, o fluxo não habilita — nem na UI, nem no servidor (conceito travado do repo). - A selfie nunca sai do dispositivo: ao servidor vai só o vetor (128 floats), nunca imagem; sem upload, sem galeria, sem retenção.
- Resposta mínima: só as fotos aprovadas da pessoa consultante; nunca “outras pessoas parecidas”, nunca nome de terceiro, nunca score/percentual em nenhum estado.
- Nada de foto privada/bruta: o resultado é o mesmo acervo aprovado do C232/C233 (
publicationStatus: 'approved',archivePhotoIsPublic—src/lib/archivePhotoCatalog.ts:128-129). - Sem cadastro paralelo:
faceSubjectnão é pessoa; sem vínculo comContact/leadership; mínimo PII (label interno + consent + vetor). - Antiabuso anônimo (rate limit, same-origin, body cap), sem captcha no MVP.
- O que reavaliar (hipóteses da “Direção no codebase”):
- A intenção cita
src/utilities/ai/rateLimit.tscomo antiabuso; o dono real do throttle anônimo por IP ésrc/utilities/content/contentEventRateLimit.ts(D8) —ai/rateLimit.tsé por usuário de chat, não serve. - A resposta do C233 inclui
people/peopleLabel(src/lib/archivePhotoPublicCatalog.ts:208-230,285) — o view model do C234 precisa cortar esses campos (D5/D9). - O CTA “Encontre você nas fotos” foi deliberadamente removido do artefato do C233 (
src/components/fotos/ArchivePhotoHero.tsx:3-6) — o C234 traz a entrada própria (D9). - “Upload consentido com retenção zero” da intenção: rejeitado — o caminho é vetor-only (sem upload), o que já satisfaz o aceite por construção.
- A intenção cita
Abordagem recomendada
flowchart LR
subgraph OPS["Ops — assessoria · CLI na workstation"]
ENR["pnpm faces:enroll\nselfie + consent"] --> SUB[("faceSubject\nlabel · vector 128d\nconsentHash · status")]
IDX["pnpm faces:index --apply"] --> DLD["downloadPrivateMediaToFile\nsharp ≤1024px"]
DLD --> NODE["engine node-wasm\n@vladmandic/face-api"]
NODE --> CMP["distância euclidiana\nthreshold interno 0.45"]
SUB --> CMP
CMP --> LNK["matchedPhotos\njoin faceSubject↔archivePhoto\ntransação + fresh-read"]
end
subgraph PUB["Público"]
ALB["/fotos · C233"] -- "CTA cena 1" --> ENC["/fotos/encontre\nRSC: publicado? + flag + Consent\nfail-closed · noindex"]
ENC --> BRW["engine on-device (lazy)\nselfie NUNCA sai do aparelho"]
BRW -- "só o vetor 128d · JSON ≤4KB" --> API["POST /api/fotos/selfie\nsame-origin · rate limit\nintent search|leave-index"]
API --> ELG["faceSubject ativos\nconsentHash vigente"]
ELG --> APR["getApprovedArchivePhotoItems\n∩ matchedPhotos"]
APR -- "view sem nomes de terceiros\nsem score · limite 60" --> ENC
API -- "leave-index" --> OUT["status removed\nvector null · removedAt\nmatchedPhotos limpo"]
end
Opções consideradas (abordagem geral): A) engine único @vladmandic/face-api nos dois lados (browser on-device + CLI node-wasm), subjects consentidos em collection nova, vínculos fotos↔subject calculados em lote CLI e consultados por um route handler novo; B) engine/server-side de terceiro com upload da selfie; C) serviço separado (Python/Rust) ou MediaPipe no browser.
Recomendação: A — mantém a selfie no aparelho (o aceite de produto), usa a mesma família de modelo e a mesma matemática no browser e no CLI, reusa os donos existentes (approval gate do C233, downloadPrivateMediaToFile, withPayloadTransaction, getConsentByKey, guardas de CLI do C231/C232) e cabe no appetite sem infra nova.
Rejeitadas: B porque manda biometria de cidadão a terceiro e a selfie sairia do dispositivo (anti-goal explícito); C porque cria runtime/serviço novo fora do appetite e sem precedente no repo.
Componentes / mudanças
Novos — schema/dados (na MESMA migration aditiva add_face_subject)
src/collections/FaceSubject.ts(novo): slugfaceSubject, labels “Pessoa no índice facial”/“Pessoas no índice facial”, grupoComunicação(mesmo dono do acervo,ArchivePhoto.ts:286-288), sem versões; accesspayloadAdminOnlynas quatro operações (precedenteArchivePhoto.ts:300-305); campos:label(text, required; nome de uso interno — nunca público),consent(relationship →consent, required),consentHash(text, readOnly; snapshot dehashConsentContentviagetConsentByKey,src/utilities/campaignConsent.ts:72-100),vector(json,hidden+ readOnly; 128 floats — nunca sai do servidor),model(text, readOnly; ex.face-api@1.7.15/faceRecognitionNet),status(selectactive|removed, defaultactive, index),removedAt(date, readOnly),matchedPhotos(relationship hasMany →archivePhoto, readOnly; join table). Sem hooks.src/collections/ArchivePhoto.ts(editar:311-505): grupofaces(hidden: true) comcheckedAt(date) echeckedKey(text), readOnly — o marker de incremental do lote (D4).defaultColumns/access/hooks intocados.src/globals/PhotoAlbum.ts(editar:61-80): camposelfieSearchEnabled(checkbox, defaultfalse) — kill switch independente da busca por selfie (o Consent referenciado não pode ser apagado enquanto houver subject,Consent.ts:8-52estendido abaixo; sem a flag o “desligar” não teria dono).src/collections/Consent.ts(editar:24-90): somarfaceSubjectàs contagens dobeforeDelete(o Consent da adesão não pode ser apagado por baixo dos subjects).- Migration
add_face_subject(src/migrations/<stamp>_add_face_subject.ts|.json+ registro no fim desrc/migrations/index.ts): tabelaface_subject+ joinface_subject_rels, colunasfaces_checked_at/faces_checked_keyemarchive_photoeselfie_search_enabledno globalphoto_album; SQL revisado à mão; nenhuma migration existente tocada;pnpm migrate:create add_face_subject→pnpm migratesó no banco do worktree →pnpm generate:types.
Novos — contrato puro (client-safe, sem I/O)
src/lib/faceSearch.ts(novo):FACE_SEARCH_DESCRIPTOR_LENGTH = 128,FACE_SEARCH_MODEL = 'face-api@1.7.15/faceRecognitionNet',FACE_SEARCH_MAX_DISTANCE = 0.45(interno, nunca devolvido),FACE_SEARCH_RESULT_LIMIT = 60,FACE_SEARCH_INTENTS = ['search','leave-index'],faceEuclideanDistance(a,b),findFaceMatch({ vector, subjects })(melhor subject sob o threshold),faceSubjectIsEligible(subject, { model, consentHash })(ativo + modelo corrente + hash vigente),toFaceSearchPhotoView(item)(cortapeople/peopleLabel),faceStubDescriptorFromBytes(bytes)(seam determinístico do e2e).src/lib/schemas/faceSearch.ts(novo):z.strictObjectdo body{ vector: number[128] finitos, intent: enum }— fail-closed (moldsrc/lib/schemas/contentEvent.ts:37-59).src/lib/campaignConsentKeys.ts(editar:1-40):FACE_SEARCH_CONSENT_KEY = 'busca-selfie-fotos',FACE_INDEX_CONSENT_KEY = 'busca-selfie-indice'+ mensagens de ausência (padrão:16-19); comentário de cabeçalho ajustado (o arquivo já hospeda a key pública do WhatsApp,:26).
Novos — server-side Payload
src/utilities/faceSubjects/faceSubjectReads.ts(novo,server-only):listSearchableFaceSubjects(payload)(querystatus=active,selectexplícito devector/consentHash/model/matchedPhotos,depth 0, bypass anônimo documentado comoarchivePhotoReads.ts:13-24) elistFaceSubjects(para--verify). Semunstable_cache: a tabela é pequena e o opt-out precisa valer imediatamente (D5).src/utilities/faceSubjects/faceSubjectEnrollment.ts(novo,server-only):enrollFaceSubject({ payload, label, descriptor, consent, subjectId? })— grava/atualiza comoverrideAccess: true(CLI sem sessão) econtext: { faceIndex: true }(precedentesrc/utilities/flickr/archivePhotoCatalog.ts:258-267).src/utilities/faceSubjects/faceSubjectPhotoIndex.ts(novo,server-only):listArchivePhotoFaceIndexQueue({ payload, refresh, subjectId })(aprovadas + marker) eindexArchivePhotoFaces({ payload, item, analyze })— baixa bytes comdownloadPrivateMediaToFile+resolvePrivateMediaStaticDir(src/utilities/privateMedia/privateMediaResponse.ts:314-344,45),sharp≤1024px, descriptors transitórios, e dentro dewithPayloadTransaction(src/utilities/payloadTransaction.ts:86-113) relê os subjects e substitui os vínculosmatchedPhotos(adiciona os que casaram, remove o vínculo que deixou de casar); gravafaces.checkedAt/checkedKey. Analyzer injetado (molde C232).src/utilities/content/contentEventRateLimit.ts(editar:17-25,53-73): budget parametrizávelcheckContentEventRateLimit(key, budget)com o default atual intocado (beacon C213) e budget estrito para a busca (D8).src/utilities/boundedRequestBody.ts(novo): extraireadBoundedBodydesrc/app/(frontend)/api/content-events/route.ts:47-78(cap de bytes em streaming); o route de content-events passa a importar (comportamento idêntico) e o route novo reusa — sem segundo twin.
Novos — rotas públicas
src/app/(frontend)/fotos/encontre/page.tsx(novo): RSC —photoAlbum.published === false→ 404;selfieSearchEnabled !== true→ 404; Consent ausente → 200 com o estado fechado da cena 7; resolve o texto do Consent (renderizado porConsentText, precedentesrc/app/(campaign)/campanha/convite/[token]/page.tsx:49-63) e oremovalChannelUrl;robots: { index:false, follow:false }; semloading.tsx(mesma razão do C233:page.tsx:55-59).src/app/(frontend)/api/fotos/selfie/route.ts(novo):export const dynamic = 'force-dynamic'; mesmo-origem (isSameOriginRequest,src/utilities/sameOriginRequest.ts:33-64); body ≤4KB pelo helper extraído; parse zod; rate limit por IP hasheado; re-checa flag + Consent no servidor;search→ subjects elegíveis → melhor match sob o threshold →getApprovedArchivePhotoItems()(archivePhotoReads.ts:58-61) filtrado pormatchedPhotos→ view model sem nomes/score, limite 60;leave-index→ transação (status:'removed',vector:null,removedAt,matchedPhotos:[]). Envelopes:{ ok:true, found:true, photos }|{ ok:true, found:false }|{ ok:true, removed:true|false }| erros{ ok:false, error }(400 body, 403 origem, 429 rate limit, 404 fechado por flag/publicação, 503 Consent ausente). Nenhuma imagem em nenhum request; nada de score; nada de nomes de terceiros.src/app/(frontend)/fotos/page.tsx(editar:119-157): renderiza<SelfieSearchEntry>(link para/fotos/encontre) só quandoselfieSearchEnabled === true; posição validada pelodesigner(D9).
Novos — UI client
src/components/fotos/faceSearchEngine.ts(novo):computeFaceDescriptor(file)— probes de WebGL/WASM (cardCutout.ts:97-127),import('@vladmandic/face-api')lazy (só quando o fluxo começa), modelos same-origin em/fotos/modelos/, cap ≤1024 (face maior quando há mais de um rosto); stubNEXT_PUBLIC_FACE_SEARCH_STUB=1no moldecardCutout.ts:347-394comwindow.__faceSearchStub.src/components/fotos/SelfieSearchFlow.tsx(novo, client): máquina de estados (consent → pick → processing → results → empty → presence/confirm → removed/no-removal; + engine/unsupported, rate-limited, network); recebe o texto do Consent já renderizado comochildren(regra de client boundary,engineering-standards.mdc:22-25); preview comURL.revokeObjectURL; a selfie nunca vai afetch.src/components/fotos/SelfieSearchEntry.tsxeSelfieSearchResultCard.tsx(novos): entrada na/fotose card do resultado sem “Quem aparece”, reusandoARCHIVE_PHOTO_CARD/botões dearchivePhotoClasses.ts; o clique do card leva ao detalhe do C233 (/fotos?foto=<id>), dono único do detalhe.
Novos — scripts/CLI
scripts/copy-face-vision-assets.mjs(novo, moldescripts/copy-card-vision-assets.mjs:24-47): copiatiny_face_detector(~190KB) +face_landmark_68(~357KB) +face_recognition(~6,4MB) denode_modules/@vladmandic/face-api/modelparapublic/fotos/modelos/(gitignored); idempotente por tamanho; chamado noprebuild(package.json:24), nopredev(:62) e noDockerfile:48.scripts/lib/faceEnrollPlan.mjs+scripts/lib/faceIndexPlan.mjs(novos): parsers de argv e recibos (moldscripts/lib/archiveCatalogPlan.mjs:1-120); pinados emSCRIPTS_SPEC_PINNED(scripts/lib/test-affected-core.mjs:31-111).scripts/enroll-face-subject.mjs(pnpm faces:enroll) escripts/index-archive-faces.mjs(pnpm faces:index— sem--subject: o canário é--limit; adicionar sujeito invalida a revisão de qualquer forma) (novos): guardas descripts/lib/cli.mjs(assertWriteConfirm:312-320,assertEnvironmentDatabaseTarget:238-301,mirroredMediaRequired:197-202), recibo JSON datado emdata/face/, falha isolada e estágio nomeado (moldescripts/catalog-archive-photos.mjs:129-280).package.jsonganha os dois scripts junto de:37-38.
Testes/manifest/migration/ops
- Testes: ver D10. Pins: entry nova em
scripts/lib/e2e-affected-manifest.mjs(:204-240e:448-460,493-552conforme D10), curado:24-58, pintests/unit/e2eAffectedManifest.unit.spec.ts:54-89, projeto emplaywright.config.ts:211-220+ env do stub (:260-300) e workflow (ci-pr.yml:226,deploy.yml:200). - Docs:
AGENTS-public.md:3-7(contrato/fotos/encontre+ API + flag), runbook C234 emdocs/ops/teqo-1313-deploy.md,.env.example(NEXT_PUBLIC_FACE_SEARCH_STUB=junto de:36-37),.gitignore(/public/fotos/modelos/,/data/face/),docs/changelog/2026-09-27-c234.md.
Dados → forma (se aplicável)
N/A — a intenção é explícita: não há dado numérico na superfície. A única saída é a lista de fotos; score/percentual é anti-goal (o view model não tem campo de similaridade — o shape torna a violação impossível, não apenas proibida).
Decisões de engenharia
D1 — Engine facial (browser + CLI)
Opções: A) @vladmandic/face-api 1.7.15 (MIT) nos dois lados — browser dist/face-api.esm.js (TFJS pré-bundlado) e CLI dist/face-api.node-wasm.js com @tensorflow/tfjs + @tensorflow/tfjs-backend-wasm; B) MediaPipe tasks-vision; C) @tensorflow/tfjs-node; D) Playwright headless; E) API de terceiro.
Recomendação: A — um único engine e a mesma matemática nos dois lados (descriptor 128-d, distância euclidiana, threshold estrito 0.45 interno, nunca devolvido); no browser o import é lazy e os modelos (tiny_face_detector + face_landmark_68 + face_recognition, ~7MB) são copiados same-origin para public/fotos/modelos/ (zero CDN, precedente copy-card-vision-assets.mjs); no CLI não há binário nativo (backend wasm do próprio pacote) nem browser — decode com sharp (já dependência) → tf.tensor3d. O modelo é versionado no registro (model no subject; string face-api@1.7.15/faceRecognitionNet) e trocá-lo invalida o índice (re-enrollment, D4/riscos).
Rejeitadas: B porque não tem embedder facial — a geometria de landmarks é fraca demais para similaridade de identidade; C porque o binário nativo no Node 24 é risco operacional (build/AJV) por zero ganho; D porque a inferência no lote via browser headless é infra frágil (e o C232 já rejeitou esse caminho); E porque biometria facial de cidadão não vai a terceiro (guardrail de produto).
D2 — Dados: faceSubject, marker de incremental e kill switch
Opções: A) collection nova faceSubject + grupo hidden faces em archivePhoto + flag selfieSearchEnabled no global photoAlbum, tudo na mesma migration aditiva; B) guardar o descriptor em campos json do próprio archivePhoto; C) reusar Contact/leadership ou tabela fora do Payload.
Recomendação: A — cada subject é um registro auditável (label, consent vinculado, snapshot do hash do texto, modelo, status, removedAt, vínculos), com PII mínima e sem vínculo de pessoa; matchedPhotos e vector são readOnly no admin (só o CLI escreve, com bypass documentado + context); status indexado distingue ativo de removido sem apagar histórico; o marker checkedAt + checkedKey (sha256 de model + id:updatedAt dos subjects elegíveis ordenados) é o único jeito honesto de pular fotos sem reprocessar bytes a cada run (sem a key, um subject novo/atualizado geraria skip errado); a flag selfieSearchEnabled default false é o kill switch da feature — o Consent referenciado não pode ser deletado (Consent.ts:8-52 estendido), então a flag é o “puxar a tomada” sem deploy.
Rejeitadas: B porque um vetor por foto não é o modelo de subjects consentidos (sem status/consent/remoção) e mistura o índice ao acervo; C porque Contact é cadastro de pessoa (invariante do repo) e uma tabela fora do Payload perde migrations/access/admin.
D3 — Enrollment (ops-only, CLI)
Opções: A) CLI pnpm faces:enroll --label "<nome>" --selfie <arquivo> --consent <key> (dry-run default; --apply; --subject <id> para re-enrollment); B) auto-enrollment público a partir da selfie de consulta; C) tela admin de enrollment já no MVP.
Recomendação: A — a adesão é ato da assessoria com a pessoa (consentimento assinado, Consent próprio de enrollment com key estável busca-selfie-indice, hash snapshot no registro); o CLI exige exatamente 1 rosto na selfie (0 ou >1 recusam, fail-closed), valida a key do consent contra a constante e grava status active; guardas FACE_ENROLL_CONFIRM=1 + assertEnvironmentDatabaseTarget + recibo JSON sem vetor nem bytes; --dry-run mostra a contagem de rostos e o que seria gravado. Re-enrollment (--subject) é o caminho previsto para troca de modelo/atualização (a assessoria mantém as selfies fora do site — nada de imagem é guardado pelo Teqo).
Rejeitadas: B porque sem verificação de identidade vira consentimento de terceiro + DoS de opt-out (qualquer um pode remover o rosto alheio do índice) — registrar por quê; C adiada com gatilho (assessoria precisar operar sem CLI).
D4 — Indexação em lote
Opções: A) CLI pnpm faces:index [--apply|--verify] [--limit] [--refresh] rodando na workstation, vínculos transacionais, descriptors transitórios e marker incremental; B) job in-app disparado por after()/fila; C) worker/browser headless.
Recomendação: A — só fotos approved; por foto, baixa os bytes privados (downloadPrivateMediaToFile), redimensiona ≤1024px com sharp, detecta todos os rostos, calcula descriptors que nunca são persistidos (se um subject ativo tiver distância < threshold em qualquer rosto, o vínculo nasce) e, dentro de withPayloadTransaction com fresh-read dos subjects, substitui matchedPhotos (adiciona novos vínculos e remove o vínculo que deixou de casar), gravando o marker; --refresh ignora o marker; --limit canário; --verify é read-only e reporta fotos indexadas/stale/pendentes, subjects ativos/removidos/consentimento vencido e os vínculos por subject (breakdown); recibo JSON honesto em data/face/; idempotente e retomável; documentado que adicionar subject novo reprocessa todas as aprovadas (a checkedKey muda). Guardas: FACE_INDEX_CONFIRM=1, alvo de banco declarado, mídia espelhada (S3) — mesmo combo do C232.
Rejeitadas: B porque o repo não tem fila e o lote pertence ao container de manutenção (precedente C231/C232); C porque browser headless para 6,5k é frágil; e persistir descriptors anônimos é proibido (seria B disfarçado).
D5 — Consulta: rota, API e contrato de resposta
Opções: A) /fotos/encontre (RSC fail-closed) + POST /api/fotos/selfie com { vector, intent }, matching server-side e view model cortado; B) client falando direto com a REST do Payload; C) upload da selfie e matching server-side.
Recomendação: A — a página checa published (404) e selfieSearchEnabled (404) e renderiza a cena 7 (200) sem Consent; o API re-checa tudo no servidor (fail-closed em profundidade), aceita só o vetor (JSON ≤4KB, sem multipart/imagem), exige same-origin, aplica rate limit, resolve o Consent de consulta e só então: search → subjects elegíveis (status active + modelo corrente + consentHash vigente) → melhor distância sob 0.45 → fotos aprovadas ∩ matchedPhotos → view sem people/peopleLabel/score, no máximo FACE_SEARCH_RESULT_LIMIT = 60 (mais recentes primeiro; sem paginação no MVP); sem match ou sem fotos aprovadas → { ok:true, found:false } (a pessoa não indexada recebe vazio honesto, nunca “parecidos”); leave-index → match → transação zera vector, marca removed, removedAt e limpa matchedPhotos; sem match → { ok:true, removed:false } e a UI aponta para o canal de remoção do C233 (nunca inventado — PhotoAlbum.ts:22-37,72-79). A entrada fica só na /fotos (cena 1), sem link no header/rodapé no MVP (não anuncia fluxo fechado; posição validada pelo designer — D9).
Rejeitadas: B impossível/sujo (a collection é admin-only e o client enxergaria índice/metadados); C contraria o anti-goal “a selfie não sai do dispositivo” — é o inverso do desenho.
D6 — Consent: duas chaves estáveis, texto nunca seedado
Opções: A) adicionar as keys em src/lib/campaignConsentKeys.ts (owner atual, client-safe); B) renomear o registry para módulo neutro e reexportar; C) keys hardcoded no CLI/rota.
Recomendação: A — busca-selfie-fotos (consulta; resolve no RSC e na API) e busca-selfie-indice (adesão; resolve no faces:enroll e vira snapshot em consentHash); o arquivo já é o dono dos stable keys e já hospeda key pública (WHATSAPP_SUBSCRIPTION_CONSENT_KEY, src/lib/campaignConsentKeys.ts:26), então só o comentário de cabeçalho muda; a resolução reusa getConsentByKey/requireConsentByKey (src/utilities/campaignConsent.ts:72-111, bypass de leitura documentado) e o texto é renderizado por ConsentText; nenhum texto é seedado por migration (nasce fechado; assessoria/jurídico criam o Consent no admin quando liberarem), e o consentHash vencido deixa o subject inelegível (fail-closed) até re-consentimento. O runbook de abertura (criar o Consent com key+texto → faces:enroll → faces:index → ligar selfieSearchEnabled → verificar) fecha a entrega.
Rejeitadas: B porque o rename move ~15 import sites sem nenhum ganho de comportamento (gatilho: o nome passar a mentir, ex. 3º fluxo público); C porque ID/key hardcoded é proibido pelo invariante.
D7 — Remoção e atendimento
Opções: A) sair do índice self-service por match facial + canal de foto do C233; B) collection de pedidos de remoção com formulário; C) e-mail manual sem registro.
Recomendação: A — o opt-out é o próprio match (D5) e o registro do atendimento é o estado do subject (status removed + removedAt); despublicar foto continua o fluxo do C233 (publicationStatus: removed pela staff + ArchivePhotoRemovalBand/canal — ArchivePhoto.ts:340-348, AGENTS-public.md:5-7). Sem collection nova, sem PII nova, sem Consent novo para pedido.
Rejeitadas: B (formulário/collection/Consent novos, fora do appetite e do invariante); C (não registra e não é fail-closed).
D8 — Antiabuso
Opções: A) reusar o dono do throttle (contentEventRateLimit.ts) com budget parametrizável + same-origin + body cap extraído; B) limiter novo para a feature; C) reusar as constantes atuais como estão.
Recomendação: A — checkContentEventRateLimit(key, budget) mantém o default do beacon (:17-25,53-73) e a busca passa budget estrito (20 requisições / 10 min por IP hasheado; a busca responde a um oráculo de pertencimento — testar vetores para descobrir quem está no índice —, então o budget tem de ser bem menor que o de telemetria); contentEventClientKey (:34-41) continua sendo o único hash de IP (sal por processo, nada persistido); o body reader sai de content-events/route.ts:47-78 para boundedRequestBody.ts e os dois routes consomem o mesmo dono; sem conta e sem captcha no MVP (gatilho registrado). O limiter é bar-raiser, não fronteira de segurança — o gate real é o threshold + Consent.
Rejeitadas: B (segundo limiter = twin); C (120/10min é frouxo demais para biometria); captcha adiado com gatilho (custo de UX/dependência de terceiro).
D9 — UI e extensão do design (trigger do designer)
Opções: A) designer estende/adapta o artefato antes do markup (trigger a/b) e faz a crítica final renderizada (trigger c), com port classe-a-classe; B) portar o artefato literal; C) redesenhar.
Recomendação: A — o artefato cobre 8 cenas, mas falta o recorte de execução: (i) cena 6 no modo A/C — o vazio honesto precisa dizer que a busca cobre quem autorizou participar do índice (sem prometer “todas as fotos”); (ii) resultado sem “Quem aparece” (a cena 5 já não mostra, mas o card do C233 mostra — a variante C234 é nova, ArchivePhotoCard.tsx:53-55); (iii) estados novos: engine indisponível (WebGL/WASM off), erro de engine, rate-limited, erro de rede — mensagens honestas (nunca “tente outra foto” genérico, precedente cardCutout.ts:13-20); (iv) mapeamento de rota: a cena 2 (modal sobre o álbum) vira a página /fotos/encontre e a cena 1 vira a entrada na /fotos (posição do aside validada); (v) opt-out sem match (encaminha ao canal do C233) e confirmação da cena 8; (vi) a11y já desenhada (44px, foco visível, prefers-reduced-motion) mantida. Port com os contratos de classe de archivePhotoClasses.ts e ConsentText; crítica final com screenshots 390/1280; Design tier: no PR.
Rejeitadas: B (o artefato não cobre rota/estados novos — seguir literal é defeito); C (design aprovado existe; redesign é desperdício).
D10 — Verificação por camada (pins e e2e)
Opções: A) unit puro + int das fronteiras Payload/HTTP + e2e novo com stub, no manifest/projeto/curado/pin; B) só int; C) e2e de browser para tudo.
Recomendação: A — unit: tests/unit/faceSearch.unit.spec.ts (distância/limiar/melhor match; elegibilidade por modelo+hash; view sem nomes; derivação do stub), tests/unit/faceSearchRequest.unit.spec.ts (parse fail-closed do body), tests/unit/faceEnrollPlan.unit.spec.ts/faceIndexPlan.unit.spec.ts (parsers/recibos), tests/unit/faceCli.unit.spec.ts (guardas por subprocesso, molde archiveCatalogCli.unit.spec.ts), extensão do unit do rate limit (budget) e das keys de consent. int: tests/int/faceSubjectIndex.int.spec.ts (enrollment grava; index com analyzer stub cria/remove vínculos; removed não casa; curadoria/aprovação intocadas; transação) e tests/int/faceSearchApi.int.spec.ts importando o POST (precedente contentEvents.int.spec.ts:40): sem Consent → recusa; match devolve só aprovadas; sem score e sem nomes; subject removed não casa; foto approved→removed some; opt-out zera vetor e não casa mais; rate limit; same-origin; body inválido. e2e: tests/e2e/frontendFotosSelfie.e2e.spec.ts (serial) com o engine stubado (NEXT_PUBLIC_FACE_SEARCH_STUB=1, seam faceStubDescriptorFromBytes compartilhado entre stub e spec — fixtures sem rede), seeds via REST admin (Consent novo, subject, fotos, global) e cleanup; projeto Playwright frontendFotosSelfie com dependencies: ['frontendFotos'] nos dois modos (serializa contra o flip do kill switch do álbum; o C233 liga/desliga o global em frontendFotos.e2e.spec.ts:335-345); manifest: frontendFotosSelfie na entry do C233 (e2e-affected-manifest.mjs:204-225) e em src/lib/schemas/src/utilities/content (:448-460,493-552), entry nova com src/app/(frontend)/api/fotos, src/lib/faceSearch, src/utilities/faceSubjects, src/collections/FaceSubject.ts, src/collections/Consent.ts; curado E2E_CURATED_SPECS:24-58 + pin tests/unit/e2eAffectedManifest.unit.spec.ts:54-89 (a migration torna o PR high-risk → conjunto curado). Os scripts/planos novos entram em SCRIPTS_SPEC_PINNED.
Rejeitadas: B (o contrato HTTP público de biometria pede prova real); C (browser para lógica pura é caro e frágil).
Fases verificáveis
- Tracer / schema+server — quota ~40%: migration
add_face_subject(collection + join + marker + flag) com SQL revisado +generate:types; keys +src/lib/faceSearch.ts+ schema zod; reads/enrollment/index writes;POST /api/fotos/selfie; guard doConsent.ts; budget do rate limit + extração do body cap. Unit + int verdes antes de seguir. Gate:pnpm gate:fast+ int do caminho. - CLI ops — quota ~20%: spike do engine node-wasm (
face-api.node-wasm.js+ tfjs wasm backend em Node, 1 foto de fixture) antes de construir o CLI;copy-face-vision-assets.mjs+ hooks (package.json,Dockerfile),faceEnrollPlan.mjs/faceIndexPlan.mjs,faces:enroll/faces:indexcom guardas e recibo; unit dos parsers/guardas;--verify/plano contra o banco do worktree. Gate:pnpm gate:fast. - UI/Design — quota ~30%: dispatch do
designer(trigger a/b) e extensão do artefato; componentsSelfieSearch*+ engine + stub;/fotos/encontre+ entrada na/fotos; port classe-a-classe; e2e local--no-deps --project=frontendFotosSelfie; crítica final (trigger c) com screenshots 390/1280. - Gates/entrega — quota ~10%: spec e2e + projeto + manifest + curado + pin;
pnpm gate:fast, int, e2e local;/simplify+capture-review-debts; docs (AGENTS-public.md, runbook C234,.env.example,.gitignore, changelog);pnpm push→ PR Ready comCloses #1369,Design tiere o status dos hard-stops.
Rabbit holes / Não escopo (engenharia)
- “Outras pessoas parecidas”, score/percentual, ranking — vedados; o shape do view model não tem o campo.
- Guardar selfie/descriptor do visitante (galeria, localStorage, cookie, “melhorar o match”) — vedado; vetor só em memória do request.
- Modo B (indexar rostos anônimos do acervo) sem DPIA/aval — proibido pelo gate desta entrega.
- Auto-enrollment público, UI de enrollment, fila de revisão de matches — adiados com gatilho.
- Vídeo/tempo real/câmeras no evento, app nativo, API pública de reconhecimento — fora.
- Vínculo com
Contact/leadership, “quem é quem”, CRM de rostos — invariante. - Reusar o índice semântico do C229 (falas) para rostos — dono e modalidade diferentes.
- Segundo engine, terceiro de reconhecimento, modelo via CDN,
tfjs-node, inferência server-side da selfie — rejeitados em D1/D5. - Segundo limiter, segundo body cap, segundo mecanismo de Consent — D8/D6 (editar o dono).
- Global novo de configuração da busca — a flag vive no
photoAlbum(dono das chaves do álbum); sem terceiro global. - Paginação/ordenação configurável do resultado, contadores de busca, debug de vetores — fora do MVP.
Riscos e mitigação
- Falso positivo de match (sem score exposto): threshold estrito 0.45 + melhor match único + só fotos aprovadas + canal de remoção + opt-out; a assimetria é honesta (o pior caso mostra fotos públicas sem qualquer identificação/nome). Calibrar o threshold no canário de enrollment antes de ligar a flag em produção; gatilho de revisita se a taxa de falso vínculo aparecer em atendimento.
- Engine node-wasm do CLI não validado: a rota
face-api.node-wasm.js+@tensorflow/tfjs-backend-wasmem Node é a aposta do lote; o spike da Fase 2 roda o descriptor sobre a fixture antes de qualquer CLI (fallback documentado: lote via browser headless, rejeitado para o MVP). Se o spike falhar, o plano para emblocked— nunca indexa com engine divergente do browser. - Troca de modelo invalida o índice:
modelno subject +checkedKeyno marker +--verifyapontando stale; re-enrollment exige as selfies mantidas pela assessoria (fora do site) — registrar no runbook. - Engine falha em browser sem WebGL/WASM: probes antes do download + estado honesto “seu navegador não permite…” (sem culpabilizar a selfie); sem fallback silencioso (precedente
cardCutout.ts:13-20,97-127). - ~9MB de bundle/modelos: import dinâmico só quando o fluxo começa + modelos same-origin baixados com progresso; nunca no bundle inicial da
/fotos. - Lote longo (6,5k fotos, workstation):
--limitcanário, falha isolada por foto com estágio nomeado, retomada pela marker, recibo com tempo real; rodar fora da janela de deploy. - Índice vazio no MVP A/C: o vazio honesto (cena 6 com recorte A/C) evita promessa falsa; o runbook exige enroll+index antes de ligar a flag;
hasPublishedArchivePhotoscontinua dono do link “Fotos”. - Oráculo de pertencimento (abuso): rate limit estrito por IP hasheado + threshold + resposta sem ids/scores; nada lista subjects publicamente.
- PR high-risk (migration + lockfile): curado de e2e com
frontendFotosSelfie/frontendFotos+ pin atualizado no mesmo PR; e2e local--no-depsantes do push. - Corrida de e2e com o kill switch do álbum: projeto com
dependencies: ['frontendFotos']nos dois modos + linhas próprias + restauração de global/Consent noafterAll. - Hook de revalidação do
archivePhotodisparando no CLI:revalidateTagSafelyengole fora de request (documents.ts:27-34) e ocontext: { faceIndex: true }mantém a curadoria intocada (ArchivePhoto.ts:168). - Consent apagado por baixo dos subjects: guard de
beforeDeleteestendido (Consent.ts:24-90); texto editado deixa subjects inelegíveis até re-consentimento (fail-closed). - Boot com S3 parcial: mesmo dono de mídia privada; o CLI de index exige mídia espelhada no alvo (
mirroredMediaRequired).
Hard-stops (aprovação humana explícita — valem mesmo em --auto)
- Schema/migration:
faceSubject(+ joinmatchedPhotos), grupo hiddenfacesemarchivePhotoe flagselfieSearchEnabledno globalphotoAlbumna migration aditivaadd_face_subject—pnpm migrate:create→ SQL revisado à mão (nenhum objeto existente recriado) →pnpm migratesó no banco do worktree (teqo_wt<slot>*) →pnpm generate:types; migrations entregues intocadas. ✅ Aprovado na sessão--auto(2026-09-27). - Contrato público/URL e shapes:
/fotos/encontre(noindex; 404 sem flag/publicado, 200 com o estado fechado sem Consent),POST /api/fotos/selfiecom body{ vector: number[128], intent: 'search'|'leave-index' }(JSON ≤4KB; sem imagem) e envelopes{ ok, found, photos }/{ ok, removed }sem score/nomes, com 404 (fechado) e 503 (Consent ausente) como recusas; entrada na/fotosgated pela flag. ✅ Aprovado na sessão--auto(2026-09-27). - Consent/DPIA/chaves e política de nascer fechado: keys
busca-selfie-fotosebusca-selfie-indice, texto não seedado, flag defaultfalse, índice A/C (só consentidos; rostos anônimos proibidos) e aval jurídico + DPIA antes de ligar em produção (timing eleitoral, eleição 04/10/2026); o runbook de abertura documenta o ato. ✅ Aprovado na sessão--auto(2026-09-27). - Dependência nova de engine + assets (disclose):
@vladmandic/face-api@1.7.15(MIT) +@tensorflow/tfjs+@tensorflow/tfjs-backend-wasmnopackage.json/lockfile (lockfile = high-risk), com ~9MB de modelos gitignored empublic/fotos/modelos/servidos same-origin (sem CDN, sem terceiro em runtime);copy-face-vision-assets.mjsnoprebuild/predev/Dockerfile. ✅ Aprovado na sessão--auto(2026-09-27). - Enrollment ops-only vs UI: enrollment e indexação só por CLI (
faces:enroll/faces:index); auto-enrollment público e UI de admin fora do MVP — auto-enrollment rejeitado por ausência de verificação de identidade (consentimento de terceiro + DoS de opt-out); UI adiada com gatilho. ✅ Aprovado na sessão--auto(2026-09-27).
Adiado com gatilho
- Refatoração do
SelfieSearchFlow— 886 linhas/15 estados com render inline;LeaveConfirmDialogreimplementa o chassi do kit (ui/dialog, Radix); intents/envelopes redigitados em vez de derivar delib/faceSearch/rota. Gatilho: próximo toque de comportamento no fluxo (novo estado/envelope), 2º diálogo nas fotos ou requisito de a11y em modal. - 2ª cópia do copiador de assets de visão —
copy-face-vision-assets.mjsquase verbatim decopy-card-vision-assets.mjs. Gatilho: 3ª cópiacopy-*-vision-assets— extrair o loopstat+size+write parascripts/lib/. - Recibos/
reportStamppor CLI de lote — 3ª cópia (archiveCatalogPlan/C232 +faceEnrollPlan+faceIndexPlan) comwriteReportquase verbatim nos scripts. Gatilho: próximo CLI de lote/recibo — extrairreportStamp+writeReportparascripts/lib/cli.mjsjunto dowriteRepoFile. - Reader nomeado do global
photoAlbum— residual (comentário de leitura pública já no ponto da rota). Gatilho: 3º consumidor direto do global (hoje rota/api/fotos/selfie+ 2 RSCs viagetCachedGlobal). - Workspace único por run no
faces:index— hojemkdtemp/rmpor foto; mesmo gatilho do bullet de índice incremental (tempo do lote acima do canário ou aprovadas > ~1k). - UI de enrollment no admin — gatilho: assessoria precisar operar sem CLI (volume de adesões ou equipe sem terminal).
- Captcha/anti-bot — gatilho: padrão de abuso/comportamento de oráculo observado (contadores, não conteúdo).
- Modo B (índice de rostos do acervo com DPIA) — gatilho: DPIA + aval jurídico documentados.
- Re-embedding automático em troca de modelo — gatilho: troca do
FACE_SEARCH_MODEL(vira o item de runbook de migração de índice). - Índice incremental otimizado / sharding por município — gatilho: tempo do lote materialmente acima do canário ou aprovadas > ~1k.
- Revisão/auditoria de matches pela assessoria (admin) — gatilho: primeira queixa de falso vínculo ou pedido do jurídico.
- Extração do registry de consent keys para módulo neutro — gatilho: 3º fluxo público de consentimento ou o nome passar a mentir.
- Extração do rate limit/body cap para módulo neutro — gatilho: 3ª superfície anônima consumindo o mesmo dono.
- Upload consentido da selfie com retenção zero — gatilho: cobertura on-device provar-se insuficiente para o público-alvo e jurídico aprovar o caminho (hoje: rejeitado).
- Paginação/filtros do resultado — gatilho: pessoas com mais de 60 fotos aprovadas no índice.
Fechamento (sessão --auto, 2026-09-27)
- Hard-stops: os cinco aprovados explicitamente pelo humano na sessão (schema/migration, contrato público/URL e shapes, Consent/DPIA/chaves com texto não seedado, dependência nova de engine + assets, enrollment ops-only).
- Desvios registrados na execução: (1)
faces:indexficou sem--subject(canário =--limit; qualquer enrollment invalida a revisão de todas as fotos, então o filtro por sujeito economizava pouco e adicionava estado) e o--verifyganhou o breakdown de vínculos por sujeito; (2) a recusa do endpoint usa 404 para flag/publicação fechadas e 503 para Consent ausente; (3) o rate limit ganhou namespace de chave (selfie:<hash>) sobre o dono compartilhado — o beacon e a busca não consomem mais o mesmo contador; (4) o miolo do card virouArchivePhotoCardBody(dono C233) e a variante C234 só troca o href e omite a linha de terceiros; (5)enrolled_at(nãoupdated_at) é a revisão do sujeito nacheckedKey— o lote escrevematchedPhotose avançaria oupdated_atpara sempre. - Design (trigger c): PARIDADE CERTIFICADA pelo
designer(tier primárioopenai/gpt-5.6-sol, semDEGRADED) em 3 rodadas contra o app renderizado (390/1280): faixa de entrada, consentimento, escolha, processamento, resultado (grade larga, superfície branca, título 34px), presença, diálogo, vazio e erro de engine. Ajustes da crítica aplicados:border-0 pb-0nos títulos (oh2global injeta borda), resultado full-bleed branco com a faixa creme de presença contrastando, scroll/foco no início de cada passo, submit do álbum secundário com a faixa ativa, 44px no "Voltar ao álbum" e no "Voltar sem alterar", copies mobile por breakpoint, ícones semânticos (imagem/aparelho/lixeira),aria-labeldistinto no backdrop do diálogo. /simplify+ débitos: dois revisores (estrutural + qualidade) rodados no diff; fixes aplicados (namespace do rate limit, card body compartilhado, foco/scroll por passo, projeção enxuta do subject, dedup de fixtures int, uso do donowaitForStreamSettledno e2e novo, remoção de código morto). Triagemcapture-review-debts: zeroexpensive_lock≥4 → nenhuma Issue nova; baratos deferidos na seção "Adiado com gatilho" abaixo.
Aceite de engenharia
- Aceite de produto coberto: com Consent + flag + adesão, o visitante recebe só as fotos aprovadas em que aparece (sem terceiros/score); sem Consent (ou sem flag) recusa honesta na UI e no servidor; selfie nunca sobe; remoção/opt-out efetivos e registrados; antiabuso ativo; A/C estrito (rostos anônimos fora).
- Invariantes AGENTS/engineering-standards: sem cadastro paralelo/PII desnecessária; Consent por key estável fail-closed; escrita multi-collection em transação com
req; bypasses documentados (overrideAccessno CLI/na rota);import 'server-only'; identificadores em inglês e copy pt-BR; editar o dono (rate limit, body cap, guard do Consent), sem twins. - Migration aditiva gerada, revista à mão, aplicada local e registrada;
pnpm generate:types; nenhuma migration retroeditada. - Testes de domínio previstos (unit/int) onde o contrato/shape/transações mudam; e2e novo no disco + projeto Playwright +
E2E_AFFECTED_MANIFEST+E2E_CURATED_SPECS+ pin +SCRIPTS_SPEC_PINNED. - UI portada classe-a-classe do artefato estendido pelo
designer(triggers a/b), crítica final (c) com screenshots 390/1280 eDesign tier:no PR. pnpm gate:fastverde; int do caminho verde; e2efrontendFotosSelfie/frontendFotosverde local; runbook +AGENTS-public.md+.env.example+.gitignore+ changelog no fechamento; hard-stops com aprovação explícita antes do push.
Self-score (decision-quality)
Self-score decision-quality: 4/5. (1) todas as decisões caras (engine, schema/marker/kill switch, enrollment ops, lote, contrato de consulta, consent, remoção, antiabuso, UI, verificação) têm Opções + Recomendação + Rejeitadas; (2) cabe no appetite: uma collection nova, uma migration aditiva, uma rota pública nova, reuso dos donos existentes (approval gate/media privada/transação/consent/guardas de CLI) e o lote é máquina, não código; (3) rabbit holes nomeados (parecidos/score, retenção de selfie, modo B, auto-enrollment, segundo limiter/engine, C229, CRM de rostos); (4) depth check: edita donos reais (contentEventRateLimit, body cap do beacon, guard do Consent, global photoAlbum, hook de revalidação do ArchivePhoto) e só cria módulos onde não há dono (faceSubjects/, lib/faceSearch, rotas); (5) outcome preservado — fail-closed, A/C, sem PII desnecessária, sem score, sem reescrever o aceite. O que impede o 5: o threshold 0.45 e a qualidade do engine são aposta não medida (calibração só no canário), e a operação de re-enrollment na troca de modelo depende das selfies mantidas pela assessoria fora do site — ambos registrados como risco com gatilho, não escondidos.