Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions Docker/status-ack/Dockerfile
Original file line number Diff line number Diff line change
@@ -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.
52 changes: 52 additions & 0 deletions Docker/status-ack/canary-evidence.cjs
Original file line number Diff line number Diff line change
@@ -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;
}
}
16 changes: 16 additions & 0 deletions Docker/status-ack/canary-evidence.test.cjs
Original file line number Diff line number Diff line change
@@ -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'); });
49 changes: 49 additions & 0 deletions Docker/status-ack/patch.cjs
Original file line number Diff line number Diff line change
@@ -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 <baileys-root> [--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 };
72 changes: 72 additions & 0 deletions Docker/status-ack/test.cjs
Original file line number Diff line number Diff line change
@@ -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/);
});
32 changes: 32 additions & 0 deletions docs/status-ack/README.md
Original file line number Diff line number Diff line change
@@ -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=<dependência auditada em leitura> 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.
Loading
Loading