diff --git a/Docker/status-ack/Dockerfile b/Docker/status-ack/Dockerfile new file mode 100644 index 0000000000..2f0da5e05d --- /dev/null +++ b/Docker/status-ack/Dockerfile @@ -0,0 +1,9 @@ +# Immutable live-image ID must already be available on the isolated build host. +ARG BASE_IMAGE +FROM ${BASE_IMAGE} +COPY patch.cjs /tmp/status-ack-patch.cjs +RUN node /tmp/status-ack-patch.cjs /evolution/node_modules/baileys --apply \ + && node --check /evolution/node_modules/baileys/lib/Socket/messages-recv.js \ + && rm /tmp/status-ack-patch.cjs +LABEL ai.xmacna.change="1047-status-stanza-ack-canary-only" +# Original entrypoint and all other files remain inherited; no migration/config change. diff --git a/Docker/status-ack/canary-evidence.cjs b/Docker/status-ack/canary-evidence.cjs new file mode 100644 index 0000000000..72d39ae3a6 --- /dev/null +++ b/Docker/status-ack/canary-evidence.cjs @@ -0,0 +1,52 @@ +/* Evaluate a sanitized canary evidence manifest. Never starts a container or sends messages. */ +'use strict'; +const fs = require('node:fs'); +const EXPECTED_IMAGE = 'sha256:3ce3432e7fb89be020ce4578435a3c06b36e40c18f95aec6ff972e17d43d1a87'; +function evaluate(input) { + const gaps = []; + if (!input || typeof input !== 'object') return { verdict: 'HOLD', gaps: ['manifest missing'], rolloutAuthorized: false }; + if (input.image !== EXPECTED_IMAGE) gaps.push('image differs from reviewed candidate'); + if (input.isolated !== true || input.productionTouched !== false || input.authCloned !== false) gaps.push('isolation not attested'); + if (input.internalConsent !== true) gaps.push('internal test consent missing'); + const cycles = Array.isArray(input.cycles) ? input.cycles : []; + if (cycles.length < 4) gaps.push('fewer than four observed cycles'); + const ids = new Set(); + let previousEnd = null; + for (const [i, cycle] of cycles.entries()) { + const tag = 'cycle ' + (i + 1); + const start = Date.parse(cycle.start); + const end = Date.parse(cycle.end); + if (!Number.isFinite(start) || !Number.isFinite(end) || end - start < 50 * 60 * 1000) gaps.push(tag + ': less than 50 minutes observed'); + if (previousEnd !== null && start !== previousEnd) gaps.push(tag + ': observation has gap or overlap'); + previousEnd = end; + if (!Number.isInteger(cycle.statusReceived) || cycle.statusReceived < 1) gaps.push(tag + ': no real Status stanza observed'); + if (cycle.statusAcked !== cycle.statusReceived) gaps.push(tag + ': missing Status ACK'); + for (const key of ['statusNacks', 'statusErrors', 'streamErrors', 'reconnects']) { + if (cycle[key] !== 0) gaps.push(tag + ': ' + key + ' not zero or unknown'); + } + if (!cycle.logLocator || !/^[a-f0-9]{64}$/i.test(cycle.logSha256 || '')) gaps.push(tag + ': retained log reference/hash missing'); + const pairs = Array.isArray(cycle.inboundPairs) ? cycle.inboundPairs : []; + if (!pairs.length) gaps.push(tag + ': no new inbound/webhook pair'); + for (const pair of pairs) { + const incoming = Date.parse(pair.evolutionAt); + const delivered = Date.parse(pair.webhookAt); + if (!pair.messageIdHash || ids.has(pair.messageIdHash)) gaps.push(tag + ': inbound ID absent or reused'); + ids.add(pair.messageIdHash); + if (!Number.isFinite(incoming) || !Number.isFinite(delivered) || incoming < start || delivered > end || delivered < incoming || delivered - incoming > 600000) gaps.push(tag + ': inbound clock or delivery exceeds limit'); + } + } + return { verdict: gaps.length ? 'HOLD' : 'READY_FOR_REVIEW', gaps, rolloutAuthorized: false, + limit: 'Checks declared metadata only; logs, actual Status receipt and identity require reviewer readback. Rafael review and Vesper approval remain mandatory.' }; +} +module.exports = { evaluate, EXPECTED_IMAGE }; +if (require.main === module) { + try { + const input = JSON.parse(fs.readFileSync(0, 'utf8')); + const result = evaluate(input); + process.stdout.write(JSON.stringify(result, null, 2) + '\n'); + process.exitCode = result.gaps.length ? 2 : 0; + } catch { + process.stdout.write(JSON.stringify({ verdict: 'HOLD', gaps: ['invalid JSON input'], rolloutAuthorized: false }) + '\n'); + process.exitCode = 2; + } +} diff --git a/Docker/status-ack/canary-evidence.test.cjs b/Docker/status-ack/canary-evidence.test.cjs new file mode 100644 index 0000000000..5ca3115fae --- /dev/null +++ b/Docker/status-ack/canary-evidence.test.cjs @@ -0,0 +1,16 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { evaluate, EXPECTED_IMAGE } = require('./canary-evidence.cjs'); +function fixture() { + const start = Date.parse('2026-11-06T10:00:00Z'); + return { image: EXPECTED_IMAGE, isolated: true, productionTouched: false, authCloned: false, internalConsent: true, + cycles: Array.from({ length: 4 }, (_, i) => ({ start: new Date(start + i * 3000000).toISOString(), end: new Date(start + (i + 1) * 3000000).toISOString(), statusReceived: 2, statusAcked: 2, statusNacks: 0, statusErrors: 0, streamErrors: 0, reconnects: 0, logLocator: 'fixture-only-' + i, logSha256: 'a'.repeat(64), inboundPairs: [{ messageIdHash: 'fixture-id-' + i, evolutionAt: new Date(start + i * 3000000 + 10000).toISOString(), webhookAt: new Date(start + i * 3000000 + 21000).toISOString() }] })) }; +} +test('complete declared metadata is only ready for review, never rollout', () => { const r = evaluate(fixture()); assert.equal(r.verdict, 'READY_FOR_REVIEW'); assert.equal(r.rolloutAuthorized, false); }); +test('zero errors without received Status is HOLD', () => { const x = fixture(); x.cycles[0].statusReceived = x.cycles[0].statusAcked = 0; assert.equal(evaluate(x).verdict, 'HOLD'); }); +test('less than 200 observed minutes is HOLD', () => { const x = fixture(); x.cycles.pop(); assert.equal(evaluate(x).verdict, 'HOLD'); }); +test('missing ACK or error is HOLD', () => { for (const key of ['statusAcked', 'statusNacks', 'statusErrors', 'streamErrors', 'reconnects']) { const x = fixture(); x.cycles[0][key] = 1; assert.equal(evaluate(x).verdict, 'HOLD'); } }); +test('gaps, overlaps and replayed inbound ID are HOLD', () => { const x = fixture(); x.cycles[1].start = x.cycles[0].start; x.cycles[1].inboundPairs[0].messageIdHash = x.cycles[0].inboundPairs[0].messageIdHash; assert.equal(evaluate(x).verdict, 'HOLD'); }); +test('late webhook and unknown counters are HOLD', () => { const x = fixture(); x.cycles[0].inboundPairs[0].webhookAt = '2026-11-06T10:40:00Z'; delete x.cycles[1].statusNacks; assert.equal(evaluate(x).verdict, 'HOLD'); }); +test('wrong image, consent or isolation is HOLD', () => { const x = fixture(); x.image = 'unknown'; x.internalConsent = false; x.productionTouched = true; assert.equal(evaluate(x).verdict, 'HOLD'); }); +test('missing retained logs is HOLD', () => { const x = fixture(); delete x.cycles[0].logSha256; assert.equal(evaluate(x).verdict, 'HOLD'); }); diff --git a/Docker/status-ack/patch.cjs b/Docker/status-ack/patch.cjs new file mode 100644 index 0000000000..f6e410685e --- /dev/null +++ b/Docker/status-ack/patch.cjs @@ -0,0 +1,49 @@ +// Audited rc14-only patch; never starts Evolution or opens an auth store. +const fs = require('node:fs'); +const path = require('node:path'); +const crypto = require('node:crypto'); +const BASE_SHA = '850445e6f9e076b1beb98034cbc5cf79562dfda26a44f5a874a84e3609bfd710'; +const MARKER = 'XMACNA_STATUS_STANZA_ACK_1047'; +const sha = value => crypto.createHash('sha256').update(value).digest('hex'); +function once(source, anchor, replacement) { + if (source.split(anchor).length !== 2) throw new Error('Missing or ambiguous rc14 anchor'); + return source.replace(anchor, replacement); +} +function transform(source) { + if (sha(source) !== BASE_SHA) throw new Error('Unreviewed messages-recv.js bytes; refusing patch'); + source = once(source, ' const offlineNodeProcessor = makeOfflineNodeProcessor(new Map([', +` // ${MARKER}: acknowledge the top-level stanza; do not emit messages or read receipts. + const handleStatus = async (node) => { + try { + await sendMessageAck(node); + } + catch (error) { + onUnexpectedError(error, 'acking status stanza'); + } + }; + const offlineNodeProcessor = makeOfflineNodeProcessor(new Map([`); + source = once(source, " ['notification', handleNotification]\n", " ['notification', handleNotification],\n ['status', handleStatus]\n"); + return once(source, ' // recv a message\n', +` ws.on('CB:status', async (node) => { + try { + await processNode('status', node, 'acking status stanza', handleStatus); + } + catch (error) { + // Also covers the existing ignored-JID fast path. + onUnexpectedError(error, 'acking status stanza'); + } + }); + // recv a message +`); +} +if (require.main === module) { + const root = process.argv[2]; + if (!root) throw new Error('Usage: node patch.cjs [--apply]'); + const file = path.join(root, 'lib/Socket/messages-recv.js'); + const original = fs.readFileSync(file, 'utf8'); + const result = transform(original); + const record = { file, before: sha(original), after: sha(result), applied: process.argv.includes('--apply') }; + if (record.applied) fs.writeFileSync(file, result); + process.stdout.write(JSON.stringify(record) + '\n'); +} +module.exports = { transform, BASE_SHA, MARKER, sha }; diff --git a/Docker/status-ack/test.cjs b/Docker/status-ack/test.cjs new file mode 100644 index 0000000000..2dcb8de2ec --- /dev/null +++ b/Docker/status-ack/test.cjs @@ -0,0 +1,72 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const { pathToFileURL } = require('node:url'); +const { transform, BASE_SHA, sha } = require('./patch.cjs'); +const root = process.env.BAILEYS_AUDITED_ROOT; +if (!root) throw new Error('BAILEYS_AUDITED_ROOT must point to the audited, read-only rc14 dependency'); +const original = fs.readFileSync(path.join(root, 'lib/Socket/messages-recv.js'), 'utf8'); +function between(source, a, b) { + assert.equal(source.split(a).length, 2); assert.equal(source.split(b).length, 2); + return source.slice(source.indexOf(a), source.indexOf(b)); +} +async function seam(source, shouldIgnoreJid = () => false) { + const { makeOfflineNodeProcessor } = await import(pathToFileURL(path.join(root, 'lib/Utils/offline-node-processor.js'))); + const { buildAckStanza } = await import(pathToFileURL(path.join(root, 'lib/Utils/stanza-ack.js'))); + const sent = [], errors = [], handled = [], callbacks = new Map(); let fail = false; + const context = { + makeOfflineNodeProcessor, buildAckStanza, + authState: { creds: { me: { id: 'test@s.whatsapp.net' } } }, + logger: { debug() {} }, sendNode: async node => { if (fail) { fail = false; throw Error('network fixture'); } sent.push(node); }, + ev: { buffer() {}, flush() {} }, onUnexpectedError: (error, label) => errors.push(label), + ws: { isOpen: true, on: (name, fn) => callbacks.set(name, fn) }, setImmediate, + shouldIgnoreJid, S_WHATSAPP_NET: 's.whatsapp.net', NACK_REASONS: { UnhandledError: 500 }, + handleMessage: async node => handled.push(node.attrs.id), handleCall: async () => {}, + handleReceipt: async () => {}, handleNotification: async () => {}, + }; + // Run the actual dependency's ack function, routing/queue block and registrations. + // External socket I/O is stubbed; the real queue and ACK builder are imported unchanged. + const ack = between(source, ' const sendMessageAck =', ' const rejectCall ='); + const routing = between(source, ' const processNodeWithBuffer =', " ws.on('CB:ack,class:message'"); + vm.runInNewContext(ack + '\n' + routing, context); + return { callbacks, sent, errors, handled, failNext: () => { fail = true; } }; +} +const candidate = process.env.STATUS_ACK_TEST_BASELINE === '1' ? original : transform(original); +const waitQueue = () => new Promise(resolve => setTimeout(resolve, 10)); +const stanza = (id, offline) => ({ tag: 'status', attrs: { id, from: 'status@broadcast', type: 'media', participant: 'peer@s.whatsapp.net', ...(offline ? { offline: 'true' } : {}) } }); +test('RED seam: audited baseline has no native Status handler', async () => { + assert.equal(sha(original), BASE_SHA); + const before = await seam(original); assert.equal(before.callbacks.has('CB:status'), false); +}); +test('live Status uses real ACK builder; no error/NACK, no bot message', async () => { + const s = await seam(candidate); await s.callbacks.get('CB:status')(stanza('live')); + assert.equal(s.sent.length, 1); assert.equal(s.sent[0].tag, 'ack'); + assert.equal(s.sent[0].attrs.class, 'status'); assert.equal(s.sent[0].attrs.id, 'live'); + assert.equal(s.sent[0].attrs.type, 'media'); assert.equal(s.sent[0].attrs.participant, 'peer@s.whatsapp.net'); + assert.equal(s.sent[0].attrs.error, undefined); assert.deepEqual(s.handled, []); +}); +test('offline Status drains in the existing sequential queue and messages still arrive', async () => { + const s = await seam(candidate); + await s.callbacks.get('CB:status')(stanza('offline-1', true)); + await s.callbacks.get('CB:status')(stanza('offline-2', true)); + await s.callbacks.get('CB:message')({tag:'message',attrs:{id:'next-message',from:'peer@s.whatsapp.net',offline:'true'}}); + await waitQueue(); assert.deepEqual(s.sent.map(x => x.attrs.id), ['offline-1','offline-2']); assert.deepEqual(s.handled,['next-message']); +}); +test('ACK error is contained; later offline message is not stranded', async () => { + const s = await seam(candidate); s.failNext(); + await s.callbacks.get('CB:status')(stanza('fail', true)); + await s.callbacks.get('CB:message')({tag:'message',attrs:{id:'after-error',from:'peer@s.whatsapp.net',offline:'true'}}); + await waitQueue(); assert.equal(s.errors.length, 1); assert.deepEqual(s.handled,['after-error']); +}); +test('ignored-JID ACK error is contained by the listener and next Status succeeds', async () => { + const s = await seam(candidate, () => true); s.failNext(); + await s.callbacks.get('CB:status')(stanza('ignored-fail')); + await s.callbacks.get('CB:status')(stanza('ignored-ok')); + assert.equal(s.errors.length, 1); assert.equal(s.sent[0].attrs.error, undefined); +}); +test('unreviewed bytes and repeat patch are rejected', () => { + assert.throws(() => transform(original+'\n'), /Unreviewed/); + assert.throws(() => transform(transform(original)), /Unreviewed/); +}); diff --git a/docs/status-ack/README.md b/docs/status-ack/README.md new file mode 100644 index 0000000000..b0a493c5ea --- /dev/null +++ b/docs/status-ack/README.md @@ -0,0 +1,32 @@ +# ACK de Status — canário, sem rollout + +Responsável Infra; revisor Rafael (@raffareis). Origem: xmacna/elysium#1047 e WhiskeySockets/Baileys#2858. Não é correção comprovada da surdez específica da Lia (#1044); pool (#1048) permanece separado. + +## Mudança + +A imagem derivada altera somente `node_modules/baileys/lib/Socket/messages-recv.js`, no rc14 auditado. Acrescenta handler da stanza própria Status, ACK sem erro, roteamento pela fila offline e contenção de falha, inclusive no fast path de JID ignorado. Não emite mensagem, leitura de Status, resposta do bot, logout ou QR. Não muda código do Partenon, banco, credenciais, integração ou configurações. + +O patch falha antes de gravar quando o SHA não coincide, âncora falta/duplica ou o candidato já está aplicado. O Dockerfile do canário herda a imagem viva imutável, sem instalar pacotes nem baixar dependências. Nenhum Dockerfile de produção foi alterado. + +## Verificações realizadas + +- Dependência local em leitura tem os MESMOS bytes da viva: SHA850445e6f9e076b1beb98034cbc5cf79562dfda26a44f5a874a84e3609bfd710. +- Testes executam o ACK builder e a fila reais, com o bloco de roteamento extraído do módulo efetivo e somente I/O externo simulado. Baseline falha nos4 casos de Status; candidato6PASS/0skip,≈0,2s. Inclui ordem offline, mensagem posterior, NACK ausente e erro de rede sem prender a fila. Não é prova contra WhatsApp real. +- Imagem isolada construída sem rede06/11 14:36:52Z: `xmacna/evolution-status-ack:canary-20261106`, ID`sha256:3ce3432e7fb89be020ce4578435a3c06b36e40c18f95aec6ff972e17d43d1a87`. Camada≈90kB sobre a base local. `node --check`PASS. Container/ID/start/restarts de produção permaneceram iguais; nenhum canário iniciado. +- BaseID`sha256:06349100dd517ba48ced9f670e5b1b0520e96e911980283d2daff68968851805`, fonte de imagem5955acd0. A base não tem RepoDigest publicado; portanto não substituí por uma imagem upstream diferente por conveniência. + +Comando de teste: `BAILEYS_AUDITED_ROOT= node --test Docker/status-ack/test.cjs`. Adicionar `STATUS_ACK_TEST_BASELINE=1` demonstra falha antes do patch. Não requer install/build da aplicação. + +## Canário real pendente + +Supera Teste Fim e mv-mari-teste estão close. O único teste open encontrado é Meta oficial, que não exercita Baileys. Não copiei sessão/auth de cliente, não religuei número nem gerei QR. AGUARDA Vesper/Rafael: designar um aparelho/número INTERNO disponível e quem confirma mensagens/Status, ou locator de canário Baileys já vinculado fora da produção. O canário usará banco/rede/volumes próprios, sem rotas para dados/filas de cliente. Não reutilizar o container vivo para teste. + +Plano nominal: registrar baseline; usar Status de um contato de teste consentido e entradas com marcador único; verificar ACKs reais e os mesmosIDs no receptor/webhook; observar pelo menos4 ciclos de50min (>=200min), com entrada consentida em cada fronteira. Sem stanza real de Status, zero erros não prova a correção. Coletar quedas/uptime, ACK Status, erros, ingresso e latência; payloads/telefones ficam privados. Se falhar, parar só o canário e preservar logs; nunca fazer QR em loop. + +Rollback do canário: parar apenas seu container e voltar à imagem base imutável com o mesmo volume próprio, sob dono do teste. Não existe rollback de produção nesta entrega porque não houve rollout. Rollout exige evidência real + revisão Rafael + GO Vesper, novo snapshot/rollback/watch. + +Provas privadas: C:/Users/lucca/argos/.local/lia-causa-raiz-20261106/{STATUS-ACK-RED.txt,STATUS-ACK-GREEN.txt,STATUS-ACK-IMAGE-BUILD.json}. Contexto remoto isolado `/tmp/xmacna-status-ack-image-20261106`, registro de build no mesmo diretório. Não declarar canário ou quatro ciclos concluídos. + +## Revisão preparada + +[Pacote nominal do canário](REVISAO-CANARIO.md): pré-condições, matriz, evidência mínima e rollback. Avaliador offline novo com8testes; nenhuma mudança no patch da imagem e nenhuma nova execução dos6verdes anteriores. Canário real continua pendente. diff --git a/docs/status-ack/REVISAO-CANARIO.md b/docs/status-ack/REVISAO-CANARIO.md new file mode 100644 index 0000000000..89fbbaca2d --- /dev/null +++ b/docs/status-ack/REVISAO-CANARIO.md @@ -0,0 +1,43 @@ +# Pacote de revisão — ACK de Status + +Para Rafael (@raffareis), PR xmacna/evolution-api#9. Dono operacional Infra; autorização de rollout Vesper. Este pacote prepara o ensaio; nenhum canário, telefone ou produção foi iniciado por ele. + +## O que revisar + +1. `patch.cjs`: hash exato do rc14, unicidade das âncoras, handler de Status e roteamento na fila offline. Rejeitar drift/patch repetido, sem fallback por conveniência. +2. `test.cjs`: seis provas existentes contra o builder/fila reais; quatro falham no baseline. São testes de código com I/O simulado, não ciclos WhatsApp. +3. `Dockerfile`: só camada sobre base imutável já auditada, sem pacote novo ou Dockerfile de produção. A imagem isolada já foi construída no servidor; [proveniência e IDs](README.md). Não reconstruir no PC durante VR. +4. `canary-evidence.cjs`: oito testes pequenos novos. O avaliador só confere metadados declarados; nunca sobe container, acessa API, pareia, envia ou concede rollout. Mesmo o resultado READY_FOR_REVIEW exige ler os logs e receber a revisão/decisão humanas. + +## Pré-condições do ensaio real + +- Vesper/Rafael indicam número/aparelho INTERNO, custódia e consentimento para mensagens/Status. Teste Fim e Mari estavam close na prova anterior; não repetir busca nem parear por hipótese. +- Um executor e uma VM de ensaio. O container do cliente não é reaproveitado. Banco, volumes, porta e receptor de webhook são próprios; não copiar sessão/auth de cliente, produção, filas ou `.env` vivo. +- A máquina de ensaio deve receber a imagem pelo caminho de artefato aprovado e conferir o ID. Nome sugerido `evo-ack-status-canary-20261106`; deduplicar esse alvo antes de criar. Não há container desse nome criado nesta entrega. +- Receiver de webhook apenas interno de teste, com relógio UTC. As entradas devem ter marcador/ID novo e consentido, sem contato de cliente. Dados e telefones só nos arquivos privados. +- Snapshot e registro de identidade do container/volume/base próprios antes da ação. Sem autorização e custódia, estado é AGUARDA, não tentativa automática. + +## Matriz de aceitação + +| Caso | Prova necessária | Falha/limite | +|---|---|---| +| Status online | Stanza real recebida e ACK sem NACK/class=status | Zero erros sem receber Status não vale | +| Status offline/ordem | ACK e mensagem posterior sem fila presa; logs/ordem preservados | Unidade já cobre mecanismo; canário precisa tráfego real | +| Recepção | Entrada nova por ID/hash e callback no receptor interno em até10min | Não confundir com resposta final de bot | +| Quatro ciclos | Pelo menos4 intervalos contíguos de50min, Status e entrada em cada | Menos de200min ou período sem amostra fica incompleto | +| Regressão | Zero stream/status errors e reconexões nos ciclos declarados | UNKNOWN não vira zero | +| Isolamento | Imagem certa, volume próprio, logs retidos por hash, produção intacta | Divergência bloqueia a conclusão | + +Cada ciclo precisa de UTC de início/fim, contadores explícitos, log locator/SHA e pares `messageIdHash`, `evolutionAt`, `webhookAt`. `messageIdHash` deve ser novo por ciclo. O hash deve ser calculado no servidor sobre o recorte sanitizado fechado, não no F: nem em logs gigantes do PC. O avaliador não autentica o arquivo só por receber uma string SHA; Rafael confere a prova real. + +CLI offline de revisão: `node Docker/status-ack/canary-evidence.cjs < manifesto-sanitizado.json` em shell que suporte redirecionamento. No PowerShell: `Get-Content -Raw -LiteralPath '' | node Docker/status-ack/canary-evidence.cjs`. Exit2=HOLD, exit0=metadados completos para revisão, nunca autorização de deploy. Fixtures nos testes são sintéticas e não são quatro ciclos reais. + +## Interromper e reverter + +Qualquer erro de Status, queda ou perda do par esperado: registrar UTC/contadores/logs e parar somente o canário identificado, preservando volume e logs. Conferir ID/nome do alvo antes de `docker stop`; nunca usar comandos globais ou container de cliente. Retorno à base imutável apenas com o mesmo volume próprio e decisão do dono do teste. Não gerar QR repetidamente para tentar salvar o resultado. + +Não há rollback de produção a executar: produção não foi alterada. Rollout futuro exige Rafael revisar, Vesper aprovar, snapshot/rollback/readback e vigia próprios. A imagem de ACK não comprova resolver a surdez específica da Lia nem o pool #1048. + +## Estado atual + +Imagem e testes mecânicos prontos; canário real AGUARDA custódia do teste e revisão Rafael. Nenhum ciclo real novo. A ferramenta deve retornar HOLD para o manifesto atual vazio, sem substituir a evidência ausente por fixtures. O próximo evento é a indicação do teste interno ou o parecer do revisor; o restante da Infra segue em outros itens leves.