| Guide
← Dashboard
Nenhum resultado

Mapa do Sistema

Status: ACTIVE | Ultima revisao: S152 (2026-03-30)

Arquitetura

DASHBOARD (browser) → HTTP/WS → SERVIDOR (VPS FastAPI) → HTTP polling 1-3s → EAs MT5 (MQL5)

Fluxo de Uma Implementacao

 IDEIA / BUG REPORT
      │
      ▼
 CLAUDE INVESTIGA
      │ ←── [100%] Guardian injeta patterns relevantes (59 ativos)
      │ ←── [100%] Spec-loader carrega docs por topico
      │ ←── [100%] Agent-router sugere subagent (VPS/EA/Dashboard)
      │
      ▼
 CLAUDE EDITA CODIGO
      │ ←── [aviso] protect-specs.sh AVISA ao mexer em whitepaper (relaxado em S322; nao bloqueia)
      │ ←── [100%] frontend-lint.py BLOQUEIA globals soltos, CSS injection
      │ ←── [100%] af-stress-protocol.sh AVISA stress test se AF mudou
      │
      ▼
 CLAUDE COMMITA
      │ ←── [100%] validate-commit.sh BLOQUEIA:
      │       • Secrets no staged
      │       • Spec protegido sem [allow-spec]
      │       • .mqh mudou sem EA_BUILD++
      │       • Drift codigo ↔ spec (via spec-code-map.json)
      │       • 3+ arquivos codigo sem memory
      │
      │ ←── [100%] post-commit:
      │       • git push vps master (background)
      │       • sync-docs-vps.sh → specs mudados vao pro /guide
      │
      │ ←── [100%] post-commit-learn.sh sugere /learn se commit tipo fix:
      │
      ▼
 CLAUDE FAZ DEPLOY
      │ ←── [100%] pre-deploy-quality.sh BLOQUEIA se property tests falharem
      │ ←── [100%] protect-ea-deploy.sh BLOQUEIA cp/mv .ex5 direto
      │ ←── [100%] post-deploy-verify.sh checa saude apos deploy
      │
      ▼
 SESSAO TERMINA
      │ ←── [100%] auto-capture.py BLOQUEIA se licoes nao capturadas
      │ ←── [100%] verify-memory.sh verifica memoria salva
      │
      ▼
 PROXIMA SESSAO
      │ ←── [100%] Guardian com novo pattern aprendido
      └── CICLO SE REPETE (auto-melhoria continua)

Hierarquia de Garantias

Nivel Mecanismo Garantia Quando usar
1 Hook (31 ativos) 100% — deterministico Regras criticas, protecoes
2 CLAUDE.md ~90% — pode esquecer Guidelines gerais
3 Spec .md ~70% — so carrega por keyword Referencia detalhada
4 Memory .md ~50% — le se consultado Dados operacionais

Regra de ouro: Se algo precisa ser 100%, precisa de HOOK. Regra so no CLAUDE.md nao basta.

Hooks por Categoria

Bloqueantes (BLOCK — impedem a acao)

Hook Quando O que bloqueia
validate-commit.sh git commit Secrets, drift, specs, build
protect-specs.sh (Tier 1) Edit/Write Whitepapers sem aprovacao
pre-deploy-quality.sh scp .py → VPS Property tests falhando
protect-ea-deploy.sh cp/mv .ex5 Deploy manual (forcar deploy_ea.sh)
frontend-lint.py Edit JS Globals soltos, CSS injection
auto-capture.py Fim sessao Licoes nao capturadas

Alertas (WARN — avisam mas permitem)

Hook Quando O que avisa
protect-specs.sh (Tier 2) Edit/Write Hooks, config, Patterns data
af-stress-protocol.sh Edit AF engine Lembrete de stress test
post-commit-learn.sh Commit fix: Sugere /learn

Silenciosos (monitoram sem interferir)

Hook Quando O que faz
Guardian (59 patterns) Cada mensagem Injeta warnings por keyword
Spec-loader Cada mensagem Carrega docs relevantes
Agent-router Cada mensagem Sugere subagent
session-diagnostics.sh Inicio sessao VPS health, git, EA build
framework-health.py Inicio sessao 9 pilares OK?
spec-freshness.py Inicio sessao Specs desatualizados?
sync-docs-vps.sh Post-commit Sincroniza specs → VPS /docs/
track-skill-usage.py Qualquer skill Registra uso
post-deploy-verify.sh Post-deploy Health + invariantes SQL

Background (24/7 na VPS)

Processo Frequencia O que faz
healthcheck_v2.sh 2min Auto-restart se cair
monitor.sh 5min Telegram se problema
backup_daily.sh 3h UTC DB + configs
_auto_close_expired_pendings 15s Fecha sinais expirados
_periodic_cleanup 15min Limpeza DB
_run_drawdown_checker 5min Alerta drawdown Telegram

Ferramentas de Browser

Ferramenta Melhor para Tokens
Playwright MCP Unico browser MCP (snapshot, screenshot, automacao) ~114k

Skills (invocadas manualmente ou por keyword)

Skill Trigger Garantia
/validate "validar", "varrer tudo", "pre-deploy", "deploy feito", "simular fluxo", "E2E" ~70% (mesclada S271 de /prove + /validate-loop + /quality-gates)
/brainstorming "design", "trade-off" ~70%
/debug-sistematico "debug", "mesmo erro" ~70%
/learn Licao descoberta ~90% (CLAUDE.md)
/compound Fim de sessao ~90% (CLAUDE.md)
/commit Commitar Manual
/deploy Deploy VPS Manual
/status Diagnostico Manual

Auto-Melhoria (ciclo Patterns)

Bug encontrado → /learn captura pattern → Guardian injeta na proxima sessao
     │                                              │
     │                                              ▼
     │                                    Claude evita repetir
     │                                              │
     └──── 3x repetido? → Promover pro CLAUDE.md ──┘

Harness

Para quem chega agora. Este é o mapa do maquinário que vigia o próprio trabalho neste
projeto: o que existe, onde mora, o que garante de verdade e onde ele é frágil.
Números medidos em 2026-08-01 — envelhecem; o comando para remedir está no fim.

Em uma frase

O harness é um sistema imunológico: um conjunto de fiscais automáticos que rodam sozinhos
antes, durante e depois de cada ação, mais um acervo de lições que se injeta no contexto por
palavra-chave. Ele existe porque regra escrita em documento acerta ~90% das vezes, e trava
automática acerta 100%.

O tamanho, medido

Peça Quantos O que é
Fiscais (hooks) 220 scripts que rodam em gatilhos do sistema
— dos quais BLOQUEIAM 60 param a ação; os outros só avisam
Lições (patterns) 738 acervo no cofre, todas chegando à projeção que é lida
Specs 464 documentos de referência, incluindo estes guias
Skills 23 procedimentos que se carregam sob demanda
Comandos 22 atalhos que o dono digita (/validate, /learn…)
Testes 883 arquivos a rede que segura tudo isso

Peso real: os fiscais consomem ~12% do relógio de uma sessão. Em 96.702 execuções
medidas, 94,1% não produziram nada — o silêncio é o estado normal e esperado.

As quatro camadas, da mais forte para a mais fraca

Esta hierarquia é a coisa mais importante deste guia. Ela decide onde escrever uma regra
nova
.

Camada Garantia Quando usar
Fiscal (hook) 100% — determinístico, não dá para esquecer regra crítica, proteção, bloqueio
CLAUDE.md ~90% — pode se perder no ruído orientação geral
Spec ~70% — só carrega por palavra-chave referência detalhada
Memória ~50% — lê se alguém consultar dado operacional

Anti-padrão nº 1 do projeto: regra importante no CLAUDE.md. Se precisa ser 100%,
precisa de fiscal.

Corolário medido, e ele já custou caro: quando duas regras do harness se contradizem, a
contradição não fica neutra — vence a que tem trava, em silêncio. Mudou a doutrina? Procure
o hook que injeta a versão velha, ou ela continua valendo.

Onde cada coisa mora

O que Caminho
Fiscais .claude/hooks/
Registro dos fiscais (quem roda quando) .claude/settings.json
Doutrina de fundo .claude/knowledge/*.md
Procedimentos .claude/skills/<nome>/SKILL.md
Comandos do dono .claude/commands/*.md
Perfis de ajudante .claude/agents/*.md
Lições (fonte da verdade) ~/Dropbox/KnowledgeHub/linniu-copytrade/patterns/
Projeção lida pelo vigia .claude/hooks/guardian-index.json (gerada — nunca editar à mão)
Decisões com trade-off specs/decisions/NNNN-*.md
Testes scripts/tests/

O ciclo de trabalho

/brainstorm  →  desenha, e a spec nasce com as linhas de verificação obrigatórias
     ↓
/plan        →  quebra em tarefas; a última já inclui a conferência adversarial
     ↓
/rapid       →  até 3 arquivos, teste-primeiro
     ↓
/validate    →  roteiro → 5 lentes em paralelo → junta achados → cético
     ↓
/learn       →  lição capturada NA HORA, nunca "depois"

A porta do cético é o /validate. Disparar revisor por fora entrega achado pingado e faz
o dono remontar o quadro de cabeça — é desperdício medido, não preferência de estilo.

Quando a revisão termina

Esta foi a última reforma grande, e ela mudou uma coisa que valia desde sempre.

O laço fecha quando uma passada devolve zero achado de gravidade alta ou crítica. Média e
baixa viram nota — não contam, não bloqueiam, não viram card.

Rede: orçamento de 8 revisores por obra. Conserto nascido de achado gasta do orçamento
e nunca o reabre.

Por quê: aplicando a matemática clássica de inspeção de software aos nossos próprios 307
achados, a estimativa é de ~3.884 defeitos existentes — achamos 7,9%. Revisar até zerar não
termina, e não é por indisciplina. Detalhe, fontes e o preço da troca:
specs/decisions/0038-portao-por-gravidade-e-orcamento-de-revisao.md.

O que conta como alta ou crítica não é a olho: a régua com os quatro níveis, a pergunta que
decide cada um e exemplos reais está em
specs/regua-de-gravidade-do-cetico.md.

As travas que mais importam

Não estão aqui todas as 60 — só as que protegem coisa que não volta:

Trava O que impede
pede-ok-antes-de-subir.py promoção para produção sem o "pode subir" do dono
block-scp-vps-deploy.sh copiar arquivo direto para o servidor, fora do caminho canônico
block-manual-ea-upload.sh subir o robô sem passar pelo script que confere a assinatura
check-ea-no-ordersend.sh o robô mandar ordem por programação em vez de imitar clique humano
quarentena-so-leitura.py ajudante de leitura agir em produção
p367-commit-staging-guard.sh um chat varrer o trabalho do outro num commit sem escopo
validate-commit.sh commitar com a bateria vermelha, segredo no diff ou documento fora de sincronia

Onde o harness é frágil (dito sem enfeite)

Fraqueza Número (medido 2026-08-01, 3ª medição)
Fiscais sem teste 82 de 200 (41%)
Dos que BLOQUEIAM, sem teste 0 de 52
Fiscais que nunca dispararam ver seção seguinte — era o buraco de verdade

Método, para o número ser auditável (sem isto ele não vale nada — ver o aviso abaixo):
denominador = arquivos .py/.sh em .claude/hooks/, fora as bibliotecas _* e os
próprios arquivos de teste; corpus = todo arquivo de teste enumerado do disco (rglob),
fora .venv*, .git e worktrees — deu 992 arquivos em 26 pastas. "Tem rede" = o nome do
fiscal aparece em algum desses arquivos.

⚠️ Terceira versão destes números. As duas primeiras estavam erradas, pelo MESMO motivo,
e a segunda foi a correção da primeira.

Versão Publicado Erro
137 de 220 e 28 de 60 contei exit 2 dentro de comentário como bloqueio; busquei testes em 1 pasta
106 de 210 e 1 de 53 busquei em 3 pastas — o repositório tem 26. Faltaram .claude/hooks/tests/ (27 arquivos) e a raiz de .claude/hooks/ (8)
a tabela acima corpus enumerado do disco, denominador sem libs nem testes

A 2ª versão foi publicada junto com a lição P756, que ensina exatamente a não fazer
isso — e cuja primeira redação repetia o defeito, afirmando que "o repositório tem três
pastas de teste". Quem achou foi um cético independente, relendo a correção.

(Da 1ª versão sobra ainda um acerto: check-ws-auth-tracker.py foi tirado da lista de
"código morto" — é chamado por validate-commit.sh:1076 e tem 11 testes.)

Ressalva de contagem: 1 dos 52 é falso-bloqueador. check-delegation-prompt-report.py
está registrado só em PostToolUse — ali o arquivo já foi salvo, então o exit 2 vira
recado, não impedimento. O próprio hook diz isso no cabeçalho; a contagem é que não distinguia.

O buraco maior: teste de fiscal não prova que o fiscal é chamado

Achado em 2026-08-01 por cético independente, e confirmado com medição própria: sete fiscais
nunca rodaram uma única vez
, e um deles guarda dinheiro.

A causa não estava em nenhum deles. Estava no registro em .claude/settings.json: o padrão
Edit(*/static/js/*) exige um diretório antes de static, e o caminho que o harness casa
não tem. Comparação limpa, em 125.351 registros de telemetria:

Padrão de registro Fiscal que o usa sozinho Execuções
Edit(*server/*.py) — sem barra auto-test.sh 48 ✅
Edit(**/tests/**) check-test-grep-based.py 308 ✅
Edit(*.md) check-jargon-leigo.py 187 ✅
Edit(*/server/*.py)com barra 7 fiscais 0

Os sete que estavam mudos: p284-no-raw-prop-name-compare (guarda de dinheiro — impede duas
contas da mesma empresa de virarem par)
, frontend-lint, check-trycatch-around-api,
check-cross-layer-rule-dup, check-error-msg-jargon, check-state-machine-drift e
check-test-token-login. Os 16 gates foram corrigidos ((*/(*).

A lição, que vale para todo o repositório: um teste de fiscal roda o arquivo do fiscal
direto, com um pacote montado à mão. Ele prova que a lógica dele decide certo. Ele não
prova que o harness algum dia entrega esse pacote a ele. São duas perguntas, e só a segunda
diz se a proteção existe de verdade:

Pergunta Quem responde
A lógica decide certo? o teste em scripts/tests/
Ele é chamado alguma vez? a telemetria em .claude/.fiscais-medidos.jsonl

Por isso a tabela de fraqueza acima ("1 de 53 sem teste") diz menos do que parecia: cobertura de
teste alta convivia com sete guardas desligadas. Um fiscal com 0 execuções e 8 testes verdes é
pior que um sem teste nenhum — porque o verde convence.

Conferência periódica (quais fiscais estão registrados e nunca dispararam):

python scripts/quality/fiscais-mudos.py

Como conferir se este guia envelheceu

python scripts/quality/regua_de_corte.py --versao-min 2

Ele mede peso, silêncio e categoria de cada fiscal na janela recente. Se os números aqui
divergirem muito dele, ele está certo e este documento está velho.

Para descer mais fundo

Assunto Onde
Quando parar de revisar, e a escada de lentes .claude/knowledge/quando-parar-de-revisar.md
Régua de gravidade specs/regua-de-gravidade-do-cetico.md
Como orquestrar vários ajudantes .claude/knowledge/workflow-house-style.md
O fluxo de 6 passos specs/decisions/0042-o-fluxo-de-6-passos-medir-antes-de-desenhar.md
Guia das lições .claude/knowledge/patterns-guide.md
Confiabilidade de ajudante specs/subagent-confiabilidade.md

A Obra do Harness

Status: DRAFT — o diagnóstico está fechado; o desenho não foi aprovado
Tier: L — toca o injetor de contexto, o portão do commit, o quadro de metas, o acervo de lições e o ciclo inteiro de 12 passos; atravessa .claude/hooks/, scripts/quality/, memory/ e specs/.
Autor: Claude + Usuário
Data: 2026-08-10
Sessão: obra do harness, sessão 1
Última conferência contra o CÓDIGO: 2026-08-11, sonda de 5 lentes + 5 céticos — os cinco vereditos voltaram SOBREVIVE: NÃO e 20 números foram corrigidos, três deles invertendo a leitura anterior (§3.19). (Revisões anteriores: 2026-08-10, a 1ª achou 6 contradições; a 2ª achou mais 15, quase todas criadas pela própria 1ª — ver §18)

Esta é a SSoT (fonte única da verdade) desta obra. Toda decisão, medição e mudança de
rumo entra aqui. Se algo só existe na conversa, considere que não existe — foi
exatamente esse o defeito que originou a obra.

Substitui: o primeiro desenho de 5 portões, derrubado no mesmo dia (ver §9).

⚠️ Nota de nascimento, e ela prova a tese da obra: esta spec foi escrita sem a linha
Tier:
e o guarda que exige a seção de pós-implementação deixou passar em silêncio
porque, não achando a declaração de tamanho, ele libera. É o defeito nº 2 do dono
("mentir que terminou") acontecendo dentro do documento que existe para curá-lo.
No acervo há 78 specs com tamanho indeterminado contra 72 L e 16 M.


0. ⏱️ A obra em 60 segundos

Para quem chega agora, ou volta depois de uma compactação. Se você só ler esta seção, sai
sabendo o suficiente para decidir se continua lendo.

Pergunta Resposta em uma linha
Por que esta obra existe? O dono nomeou dois defeitos: eu esqueço e eu minto que terminei. Tudo aqui tem que matar um dos dois (§1)
O que já se sabe? 26 medições, todas com fonte e data (§3). A mais citada é a lei dos 9% (§3.10) — mas ela levou uma ressalva medida: fecha #N tem lembrete há 22 dias e continua em 8%. O que rende não é o lembrete: é o lembrete pedir o que quem escreve já tem na mão
O que já foi decidido? 27 decisões (§4). As quatro que mais mandam: o alvo são os 2 defeitos (D5), peça nova apaga uma velha (D8/D18), desenho novo confere o código e nunca esta spec (D19), e nada se aposenta pela régua de custo — ela morreu (D24)
O que já foi construído? Nada do desenho. Sete desenhos foram propostos e os sete morreram no cético (§9) — o sétimo levou 3 de 3 vereditos contra, o primeiro placar unânime da obra. O que foi construído são seis consertos avulsos achados durante a medição — cada um com o alvo que ele move, em §4-B
Qual é o próximo passo? ⚠️ A sonda de 11/08 (§3.19) matou as duas pistas que existiam para o sexto desenho e devolveu três fatos acionáveis: o mutation_check.py pronto e desligado, o canal único em 23,1% de adoção, e o validate-commit.sh com 68,5% do conserto da vida dele no último mês. Nenhum deles é desenho ainda
O que pode mandar parar tudo? A inversão de §3.11 — mas ela mudou de natureza em 11/08: não é pico, é patamar de ~3 semanas (60,9% → 60,3%). O dono decidiu não parar e otimizar (D16); o número continua de pé e agora tem idade

O estado, sem enfeite: o diagnóstico está maduro e caro (três varreduras, uma sonda de 11
ajudantes, ~3,2 milhões de tokens, nove céticos). O desenho está zerado. Esta spec vale pelo
que ela impede de ser re-proposto (§8 e §9), não pelo que ela já entregou.

⚠️ E ela já mentiu de novo: em 11/08 três dos seus números estavam invertidos e um não
fechava com a própria aritmética (§3.19). O CA-4"a spec não se contradiz" — reprovou pela
terceira vez. Isto não é sinal de que a revisão falhou; é o custo conhecido de um documento
que carrega número medido.
A defesa que funciona é a D19: conferir o código, nunca este texto.

🧭 Onde está cada coisa

Se você quer… Vá para
entender por que a obra existe §1 o alvo · §2 os 6 critérios
ver os números e conferir você mesmo §3 (26 medições, cada uma com fonte e data) · §3.19 é a mais recente e corrige 20 números das outras
saber o que já foi decidido e não re-litigar §4 (27 decisões) · §4-B (qual alvo cada movimento move)
ver onde o fluxo vaza §5 os 12 passos · §6 o mapa peça por peça
comparar com as ferramentas de fora §7 o placar · §7.2 a régua da indústria
saber o que NÃO entra — e não repropor §8 · §8.1 · §9 (os 7 desenhos mortos)
saber o que ainda não se sabe §10 as perguntas abertas
retomar depois de uma compactação §0 (esta) → §4 → §10 → §18 o diário
saber o que FAZER, e em que ordem ⬅️ specs/os-vigias-do-harness.md — a fatia de EXECUÇÃO deste tema

🧭 A divisão de papéis entre este documento e a lista de execução (2026-08-13)

Este arquivo é o por quê: diagnóstico, números, decisões e desenhos mortos. A lista do
o quê fazer mora em specs/os-vigias-do-harness.md, com
13 metas que fecham por commit (fecha meta N @os-vigias-do-harness) — logo o estado é
escrito por máquina e duas conversas podem trabalhar nele sem se atropelar.

Por que a separação, e não uma lista aqui: este arquivo tem 2.043 linhas e status DRAFT. Uma
lista de estado dentro dele seria a segunda lista respondendo "o que falta?" — o defeito
que esta obra existe para combater. Um documento, um papel.

⚠️ E a lista de execução carrega, no topo, as TRÊS ideias que este documento já matou com
medição
(D2 a poda dos mudos §3.14-B · o despachante único §3.12 · a régua de custo §3.15).
Elas foram re-propostas em 2026-08-13 por quem não tinha lido este arquivo — a lição P779 em
estado puro. O apontador cruzado existe para que isso não aconteça uma terceira vez.


📖 Como manter este documento

Estas regras existem para o documento não apodrecer — que é o defeito nº 1 de spec nesta casa.

Regra Por quê
Toda decisão vira linha na §4, com data e motivo decisão sem motivo é re-litigada em duas semanas
Todo número vem com o comando que o produziu número sem comando não pode ser reconferido, e vira folclore
O que fica de FORA é obrigatório (§8) lição P884 desta casa: eu estreito o escopo em silêncio e o dono precisa perguntar "cadê o resto?"
Nada aqui é escrito por máquina, por enquanto quando a §10 virar quadro automático, esta linha muda — e a lição P742 avisa: arquivo que vira projeção continua aceitando escrita à mão, e ela some no próximo rebuild, sem erro
Toda seção que descreve defeito VIVO carrega a data da conferência D19. Sem a data, o texto é histórico, não estado — e foi assim que a §3.7 matou um desenho inteiro
Número derrubado por medição posterior some do texto — vira uma linha de registro, no passado é a lição P904, e esta spec tinha o defeito em 6 lugares até 2026-08-10: afirmava 76% e 171 como fato em seções que outras seções já haviam desmentido
Diário no fim (§18), uma linha por sessão é onde se vê se a obra andou ou parou

1. 🎯 O alvo — nas palavras do dono

"os projetos do github que prometem resolver o principal problema da LLM em planos, que é
esquecer as coisas, mentir que terminou X coisa, isso é grave, meu foco é esse"
— 2026-08-10

"agora confio mais em pegar algo pronto e copiar e melhorar aquilo que já está
comprovando"
— 2026-08-10

São dois defeitos, não um harness bonito. Tudo que entrar nesta obra tem que matar um dos
dois. O resto é enfeite e fica de fora.

Defeito Nome operacional Onde ele mora no ciclo
Eu esqueço a saída do passo vive só na conversa passos 3 e 6 do ciclo (§5)
Eu minto que terminei a conclusão não tem recibo passos 7 e 11 do ciclo (§5)

2. 📏 Os 6 critérios de avaliação

Toda peça — nossa ou copiada — passa por estes seis. Os três marcados 🔑 são os que mudaram
o rumo desta pesquisa e não estavam na lista inicial.

# Critério O que decide
1 Quem julga? máquina (devolve 0 ou 1) · outra IA · o dono
2 Qual é o recibo? o que sobra em disco provando que o passo aconteceu
3 🔑 Sobrevive à compactação? passo cuja saída vive só na conversa vai ser esquecido
4 🔑 Falha barulhenta ou calada? falhar calado é pior que não existir — você acredita que rodou
5 🔑 Custa quanto quando NÃO precisa? portão que cobra pedágio no caso fácil é contornado
6 Dá pra desfazer? irreversível exige aprovação; reversível pode rodar sozinho

Os critérios 3 e 4 são os dois defeitos do dono, em forma de pergunta.


3. 🔢 O que foi MEDIDO — cada um com o comando

Regra: nenhum número entra aqui sem a fonte. Onde a fonte é fraca, está dito.

📅 Leia a data antes do número (D19). Tudo aqui foi medido em 2026-08-10 — a obra
tem um dia de vida —, mas em momentos diferentes do dia, e isso importa:

Seções Quando Reconferidas depois?
3.1 a 3.11 madrugada ❌ não — são janelas móveis, e refazê-las custa a varredura inteira (§8.1)
3.12 a 3.14 manhã ✅ pelos três céticos da obra dos guardas
3.15 a 3.18 tarde ✅ nasceram de cético, com o número reproduzido

Seção marcada ✅ descreve defeito morto; sem ✅, o defeito está vivo na data indicada.

3.1 Manutenção: a ferramenta consome mais que o produto

Reconferido em 2026-08-11 (§3.19). A coluna "% que é conserto" é a medida mais estável
do eixo: nenhuma das quatro se moveu mais de 1 ponto em 24 horas.

Medida (30 dias, 2026-07-122026-08-11) Valor conferido 11/08 Era, em 10/08
Commits totais 2.128 2.101 — ⛔ não comparável, ver o aviso abaixo
% que é conserto — guardas (.claude/hooks/) 55,3% (380 de 687) 56%
% que é conserto — ferramentaria (.claude/ + scripts/) 52,1% (513 de 985) 60%
% que é conserto — servidor (server/) 48,2% (251 de 521) 49%
% que é conserto — a tela (static/) 18,7% (86 de 460) ← a única saudável 18%
git log -n 99999 --since="30 days ago" --format='%s' -- .claude/hooks/ | grep -cE '^fix(\(|:)'  # 380
git rev-list --count --since="30 days ago" HEAD -- .claude/hooks/                               # 687

A tela custa 3× menos que os guardas para manter, no mesmo repositório e no mesmo mês. É a
prova de que 55% não é lei da natureza — é escolha de desenho em algum lugar.

⚠️ Armadilha medida: git log --since=... | wc -l devolve 50 quando o real é 2.092.
Erro de 40×, reproduzido 4 vezes. Use sempre git rev-list --count. (A causa é o rtk, não
o git — §10.)

Armadilha nova, e ela custou a conclusão principal desta seção (lição P927):
janela deslizante medida ontem não se compara com ela mesma hoje. O "2.101 → 2.177" parecia
aceleração de 70 commits e era queda de 49 — ao avançar um dia, a janela ganha o dia novo
e perde um dia inteiro na outra ponta. Agravante: a medição de 10/08 foi feita no MEIO de um
dia que fechou com 142 commits. Tendência só se mede com janelas fixas e disjuntas, com
--until explícito e hora na data (--since=2026-08-11 devolve 0 onde --since="2026-08-11 00:00" devolve 18).

3.2 A pilha de guardas está acelerando — a direção sobrevive, a magnitude está em disputa

Janela Guardas novos/semana — 10/08 Guardas novos/semana — 11/08
180 dias 11,6 10,0
90 dias 13,3 9,9
30 dias 24,5 15,4
7 dias 34,0 16,0
git log -n 99999 --since='7 days ago' --diff-filter=A --format='' --name-only -- '.claude/hooks/*.py' '.claude/hooks/*.sh'

⚠️ As duas colunas aceleram, e é só isso que se pode afirmar. A diferença entre elas não é
desaceleração — é divergência de fórmula
, e a fórmula de 10/08 não ficou registrada. Testado
em 11/08: sem filtrar as subpastas (tests/, _lib/), o número de 7 dias salta de 16 para
41
— ou seja, o pathspec sozinho explica um fator de 2,6×. Enquanto a fórmula de ontem não
for recuperada, não use a comparação entre as colunas, só a tendência dentro de cada uma.

💡 Lição de método: eu reportei 34/semana, um revisor me corrigiu para 13,3, e os dois
estavam certos
— era aceleração. Número em uma janela só mente. (E a lição de 11/08
completa a de 10/08: número na
mesma janela, medido em dois dias, mente igual — P927.)

3.3 ⛔ Teste não protege guarda — A ATRIBUIÇÃO DOS GRUPOS ESTAVA INVERTIDA (11/08)

Esta seção afirmava um fato que a §8 usa para excluir uma ideia. O fato caiu; a exclusão
perde a base. É o achado mais grave da sonda de §3.19.

Grupo Guardas Consertos por guarda (90d)
~~Com teste que os cite~~ ~~131~~ ~~3,52~~
~~Sem nenhum teste~~ ~~87 (39,9%)~~ ~~1,05~~

O que o código diz: o 87 é quem TEM rede, não quem está sem. A fonte é o campo
guardas de .claude/canarios/_exercitados.json, e o próprio
painel_de_guardas.py:505-517 documenta que esse
arquivo lista quem a bateria RODA. O sinal estava trocado.

Medida Valor conferido em 11/08
Guardas com rede (_exercitados.json) 87
Guardas sem rede 100 (painel) a 110 (reconstrução crua)
Desses "sem rede", quantos têm teste dedicado em disco que passa 32 de 110 → ≥29% de falso-positivo
Idade da base de cobertura congelada em 2026-08-03, com 305 commits e 154 arquivos distintos tocados em .claude/hooks/ desde então
python -c "import json;d=json.load(open('.claude/canarios/_exercitados.json',encoding='utf-8'));print(len(d['guardas']))"   # 87
pytest .claude/hooks/tests/test_p846_quarto_arquivo_sem_plano.py   # 19 passed — e ele consta "sem rede"
pytest scripts/tests/test_p600_fala_que_nao_chega.py               # 116 passed — idem

⚠️ O que fica de pé e o que cai:

Afirmação Estado
"Guarda com teste conserta 3,35× mais" SUSPENSA. A razão foi calculada com os dois grupos trocados — ela pode se manter, inverter ou sumir. Não é fato até ser refeita
"É viés de sobrevivência: os testados são os que estão em uso" ⚠️ continua plausível, mas agora é hipótese sem número
"15 guardas concentram 43,3% dos toques de conserto" ⚠️ ver §3.19: em 30 dias, 74,2% do conserto está FORA do top-5, e 144 de 252 guardas (57,1%) levaram conserto. Há cabeça gorda e cauda longa

Consequência que atravessa o documento: a §8 exclui "escrever teste para cada guarda" com o
motivo "medido: guarda com teste conserta 3,35× mais". Esse motivo está suspenso — a linha
foi marcada lá também. A causa que o repositório dá para o fenômeno segue de pé e é outra, escrita
em replay_fiscal.py:3:

"Escrevi um fiscal novo, escrevi 23 testes, todos passaram, e o fiscal errava 13 de 14 vezes
em produção — precisão de 7%. Os testes mediam o fiscal contra a minha IMAGINAÇÃO do que eu
falo. O replay mede contra o transcrito real."

3.4 O portão de etapa que já existe está morto

Medida Valor
stage-sensor-gate.py — execuções 1.217
Bloqueios 1 — e foi falso positivo (barrou comando só-leitura)
A frase que ele exige (Prova: CA-N via [...]) em todo o histórico 0 commits
O bilhete que só avisa (Leigo:) 3.013 commits
A forma livre Prova:, que ninguém cobra e a casa escreve sozinha 25 commits

A casa construiu a fechadura e nunca cortou a chave. E o formato que dispara portão é
evitado; o que não dispara é adotado espontaneamente.

3.5 A lição é escrita bem e devolvida mal

Medida Valor
Lições no acervo 875 (todas com dado — zero sem medição)
Já apareceram ao menos 1 vez 263 (30,1%)
Medidas e nunca apareceram 612 (69,9%)
Das que apareceram — mediana 1 vez (máximo 50)

Guardar não é lembrar. O injetor escolhe 3 por turno sobre 875 candidatas — a conta não
fecha por construção.

3.6 Remendo empilhado na mesma raiz

Medida Valor
Lições sem parente textual 773 de 875 (88,3%)
Lições em família 102 (11,7%), em 41 famílias
A maior família 8 lições sobre a mesma raiz: "declarei pronto sem prova"

Régua usada: a própria da casa, max(Jaccard, Overlap) ≥ 0,3
(check-pattern-duplicate.py:125).

⚠️ Honestidade: minha hipótese era que o acervo inteiro fosse repetição. Não é. A
régua vê palavras, não raízes, então subconta — mas eu estava exagerando e o número corrigiu.

3.7 O injetor de contexto engolia 37% do que deveria entregar — ✅ CONSERTADO em 2026-08-10

CONSERTADO no commit 4f5b8cf77, no mesmo dia em que foi achado. O descarte deixa
recibo visível na saída ([SUMIU: <lente> <n>ch]) e grava no log de erro. Rede permanente:
.claude/hooks/tests/test_descarte_deixa_recibo.py, 7 testes.

⚠️ Esta seção ficou 8 horas descrevendo como VIVO um defeito já morto — e isso custou um
desenho inteiro.
À tarde, oito ajudantes leram esta spec, não leram o código, e propuseram
consertar o que já estava consertado. Foi o defeito crítico nº 1 do quarto desenho
(§9.1). A lição virou a decisão D19.

Medida Valor
Teto do injetor 5.000 caracteres
O que acontece ao estourar descarta o bloco inteiro e segue, sem registrar no log
Turnos que já saem colados no teto 79 de 213 (37,1%)
O que a telemetria registra "falou em 213 de 213"falso-verde

A linha do defeito é unified-context-injector.py:199:
um continue sem chamar o registrador de erro que o próprio arquivo mantém.

Rodando o injetor ao vivo com prompt rico, a lente de sugestão de agente desaparece
inteira
, calada. É o critério 4 (falha calada) no coração da máquina.

Por que isto é o achado mais grave da obra: numa conversa, o dono percebe quando eu
esqueço. Num laço autônomo não há ninguém — as travas sumiriam em 1 turno a cada 3 e o
erro andaria sozinho por horas.

3.8 O quadro de metas depende de eu lembrar — e a adoção medida é 9%

Correção contra a §6.2 desta própria spec, que marcava a peça como "manter" sem ler o que
ela diz sobre si mesma.

O comentário está no próprio código, em
meta_da_obra.py:270:

"escrever fecha meta N na mensagem do commit depende de EU LEMBRAR — e a lei desta casa
diz que humano lembrando morre."

Medida na única obra que adotou Valor
Commits que citam meta 7 de 75 (9%)
Deriva mediana 14 commits
Tabelas de meta no mesmo arquivo 2 — a da máquina e uma à mão, divergindo em 5 pontos

Consequência de desenho: propagar o formato sem apagar a lista à mão não mata o quarto
lugar de verdade — cria o quinto.

3.9 Números que derrubaram propostas minhas

Proposta Devolveria Veredito
Aposentar os 72 guardas sem ato útil 5,8 min/dia não paga a obra
Porteiro único (1 processo no lugar de 38) ~8 a 16 min/dia (estimado) não paga a obra

Piso de processo medido nesta máquina: python + imports típicos = 51,2 ms; bash vazio =
17,1 ms. Cada guarda paga dois arranques.

⚠️ Aqui havia um número que caiu: esta seção afirmava que "~76% do tempo é só ligar
processo"
. A sonda de §3.12 mediu 22,5% — o 76% era uma estimativa de piso dividida
pela duração média
, não uma medição do trabalho real. Fica como registro, no passado, porque
ele sustentou uma proposta (o porteiro único) que morreu por causa disso.


3.10 A LEI DOS 9% — o achado mais acionável da obra

Medida em três mecanismos idênticos, independentes um do outro. Nasceu de o dono contar como
usa o roadmap:
"tenho noção pelo número de cards" — ou seja, o produto do roadmap é o
número, e card aberto depois de pronto é mentira no único número que ele lê.

Os três são a mesma coisa: uma frase na mensagem do commit.

A frase O que ela faz Tem quem cobre? Adoção
fecha #N fecha o card no roadmap ❌ ninguém pergunta 8,0%
fecha meta N fecha a meta na spec ❌ ninguém pergunta 9,0%
Leigo: só documenta, não fecha nada um gancho avisa na hora 83,4%
git log -n 99999 --since='30 days ago' --regexp-ignore-case --extended-regexp --grep='(fecha|closes|resolve) #[0-9]+' --oneline

A lei, em uma frase: mecanismo que depende de eu lembrar de escrever uma frase chega a
~9%; a mesma frase, com um lembrete no momento de escrever, chega a 83%.

A diferença entre 9% e 83% não é desenho — é o lembrete no instante certo. E o lembrete é
a peça mais barata do repertório: já foi provada aqui, em 3.013 commits.

Detalhe que reforça: dos 209 usos de fecha #N em toda a história, 208 aconteceram nos
últimos 90 dias
e 170 nos últimos 30. O hábito é novo e está crescendo — mas a taxa
está travada em 8%. Crescimento de uso não conserta taxa de adoção.

3.11 ⚠️ A INVERSÃO — datada em 22-29/07, e é PATAMAR, não incêndio novo

Achado do ângulo advogado-do-diabo da terceira varredura. Refeito com janelas fixas em
2026-08-11
(§3.19), depois que o cético derrubou a leitura de "acelerou desde ontem".

A série em semanas FIXAS e disjuntas — é ela que mostra tendência; a janela deslizante não
mostra (P927):

Semana fixa Total Ferramenta Produto Ferramenta % Produto %
15/07 → 22/07 562 119 374 21,2% 66,5%
22/07 → 29/07 349 160 175 45,8% ⬅️ a virada 50,1%
29/07 → 05/08 568 346 163 60,9% 28,7%
05/08 → 12/08 546 329 128 60,3% 23,4%
git rev-list --count --since="2026-08-05 00:00" --until="2026-08-12 00:00" HEAD                                     # 546
git rev-list --count --since="2026-08-05 00:00" --until="2026-08-12 00:00" HEAD -- .claude/ scripts/                # 329
git rev-list --count --since="2026-08-05 00:00" --until="2026-08-12 00:00" HEAD -- server/ static/ Experts/ Include/ # 128

A inversão tem ~3 semanas de idade e as duas últimas semanas são planas (60,9% → 60,3%).
Isso muda a natureza do problema: não é um pico da semana desta obra, é o patamar em que a
casa passou a viver
. E os 60,3% são piso, não teto — há 334 commits em 30 dias que não
caem em nenhum dos dois lados (specs/ 397, memory/ 119, .claude/knowledge/ 23 na mesma
janela), e boa parte disso é documentação da própria ferramenta.

Janela mais longa Ferramenta Produto Conferido
180 dias 30,2% 56,3% 11/08
90 dias 25,2% 61,4% 11/08
14 dias 59,7% 27,8% 11/08
~~30 dias~~ ~~42,0%~~ ~~47,2%~~ linha INVÁLIDA — ver abaixo

A linha de 30 dias de 10/08 não fecha com a própria aritmética e foi descartada: o mesmo
briefing dizia 1.161 commits de harness em 2.101 totais, que é 55,3%, não 42,0% — e 42,0% de
2.101 daria 882. Não use aquela linha como base de nada. Medida hoje, a janela de 30 dias dá
45,4% × 43,1%.

⚠️ Interseção declarada (D25): 82 commits em 30 dias (8,5%) tocam ferramenta e
produto e são contados dos dois lados — em 7 dias são 15, em 180 dias são 382. As percentagens
acima somam mais de 100% por isso, e não por erro.

Ressalvas honestas, dos dois lados:

Contra o alarme A favor do alarme
~~7 dias é janela curta~~ — caiu: são 3 semanas de patamar, não um pico referência de mercado põe manutenção de ferramenta em 15-20% do esforço saudável — estamos a 3-4× disso
o produto não parou: 128 commits na última semana fechada os guardas crescem e 55,3% do que se faz neles é conserto, contra 18,7% na tela (§3.1)
a cobertura de teste é pior do que a spec dizia? ⚠️ indefinido — o 87 estava invertido (§3.3) 100 a 110 guardas sem rede, mas com ≥29% de falso-positivo nessa conta (§3.3)

A frase do advogado do diabo, que fica registrada porque é a consequência real:

"Com 60,7% da semana na ferramenta, o próximo defeito que o dono vai sentir não vai ser 'o
agente esqueceu' — vai ser uma conta de prop firm morrendo enquanto eu conserto o guarda que
fiscaliza o guarda."

3.12 ✅ A sonda do despachante — a premissa caiu, e o conserto foi outro

2026-08-10, conferido no código e nos números. 223.392 execuções, janela de 2,74 dias.
O dono pediu para medir antes de construir; foi o que evitou construir a coisa errada.

Os guardas de um lote já rodam em PARALELO — em 10 de 10 eventos o relógio do lote fica
colado no guarda mais lento, não na soma.

Evento Soma dos guardas Relógio real O maior
PreToolUse (12 por lote) 2.624 ms 537 ms 418 ms
UserPromptSubmit (31 por lote) 7.181 ms 751 ms 733 ms
Stop (17 por lote) 6.044 ms 2.057 ms 1.948 ms
O que se acreditava O que a medição diz
despachante único dá 4,2× sequencial seria 2,7× PIOR; paralelo dá 1,46×
76% do tempo é ligar processo 22,5% da soma do trabalho
a fonte externa mede 8,16× não existe lá: são 6,75× / 8,36× / 12,85×, e a causa é reescrita em Rust

A régua que nasceu daqui — quanto do relógio cada guarda POSSUI: num lote paralelo,
acelerar o mais lento só rende até o segundo colocado. Então o valor de um guarda é a
soma, sobre os lotes em que ele foi campeão, de (ele − o segundo). Com essa régua o ranking
muda, e o teto total é 16,5 a 21,2 min/dia de 84,4 min/dia de relógio da frota.

O conserto que saiu disso (commit 27688bfd1): dois guardas pagavam o preço inteiro
sem ter nada a julgar — porque o discriminador barato estava por último.

Guarda Antes Depois
block-worktree-deploy.sh 752,5 ms 78,8 ms 9,5×
auto-format.sh 277,6 ms 54,3 ms 5,1×

O padrão não foi inventado aqui: dangerous-commands.sh já usava esse atalho desde
2026-07-21, com o mesmo comentário explicando o porquê. Foi extensão, não invenção.

⚠️ A bancada mentiu, e a telemetria de produção me pegou. Os dois primeiros atalhos
mediram 9,5× e 5,1× na bancada e renderam ~1,0× em produção. A causa: eles
procuravam o texto-gatilho no pacote inteiro, e o pacote de um Edit carrega o
conteúdo do arquivo — como o trabalho de rotina desta casa é escrever sobre código, o
atalho quase nunca disparava. Consertado nos commits bde1c907a e 99b6ee1c9: agora leem o
caminho de verdade, e o caso realista foi de 375,2 → 72,2 ms e 173,3 → 44,0 ms.

Como eu descobri, e o método vale mais que o conserto: o antes/depois da telemetria dava
pior depois — inclusive nos guardas que eu não tinha tocado. Usei os 97 guardas
intocados como CONTROLE
: a mediana deles ficou 1,74× mais lenta na mesma janela, porque
a máquina estava sob carga (eu mesmo, com 15 ajudantes e baterias). Sem o controle eu teria
concluído "piorou" — ou, pior, teria acreditado na bancada.

O que eu tinha recusado — e o dono mandou consertar na raiz. sync-memory-on-stop.sh
possuía 2,17 min/dia, e eu me recusei a aplicar o atalho barato em bash: o discriminador
teria que comparar caminhos, e o caminho de uma worktree contém o do projeto como
prefixo. Em bash, isso trocaria 0,3 s por perda de lição.

Medindo para o card #915, apareceu algo pior — e vale mais que o tempo. O
grep -Fvx -f dele contra o caderno real (54.917 linhas / 8,1 MB) não terminou em
600 s
, e o prazo do gancho é 10 s. Ou seja: desde que o caderno cresceu, toda
sessão de worktree encerrada teve esse guarda morto no meio, calado, e a lição daquela
sessão nunca voltou. A telemetria registrou as 327 execuções como "ok" — porque, de
fora, morrer no prazo e não ter nada a dizer são a mesma coisa.

Consertado na raiz (commit 7d12bdd67): o arquivo virou Python — um processo em vez de
oito. A comparação virou conjunto (linear), e a pergunta "estou numa worktree?" virou
identidade de diretório (os.path.samefile), que ignora grafia e não cai na armadilha
do prefixo — a mesma armadilha que me fez recusar o conserto em bash simplesmente não
existe
em Python.

Antes Depois
Caminho comum (fora de worktree) 377,7 ms 89,7 ms (4,2×)
Caminho da worktree, 60 mil linhas > 600 s (morto aos 10 s) < 5 s

Rede: .claude/hooks/tests/test_sync_memory_on_stop.py, 9 testes com repositórios de
mentira em pasta temporária — inclui o teto de 5 s com 60 mil linhas, que é a regressão da
bomba, e um teste que declara a lacuna que o guarda NÃO cobre (a grafia POSIX do Git Bash,
/c/Users/...). Lição: P895.

Por que a lacuna é declarada em vez de consertada: medidos 1.084 de 1.084 pacotes
reais, todos trazem o caminho com contrabarra. A grafia POSIX é alcançável no papel e
inalcançável na prática — e o teste existe justamente para que a próxima pessoa saiba disso
sem ter que descobrir de novo.

3.12-B 🧹 A família dos seis: todo git status acordava os porteiros do commit

Achado depois dos primeiros consertos, no mesmo método: medir com carga relevante e
irrelevante. Medido e conferido em 2026-08-10.

Fonte dos números: bancada local, cada guarda alimentado com o pacote real de um
git status e de um git commit, 15 amostras por guarda, mediana — não é um comando
colável; é reprodutível rodando o guarda com o pacote na entrada padrão.

Seis guardas nasceram do mesmo molde e todos começam pela mesma pergunta de verdade
(git commit). Mas o atalho barato deles só saía quando o comando não tinha git — então
git status, git log e git diff, os comandos mais frequentes da casa, atravessavam e
pagavam um interpretador Python cada um.

Filtrar por commit é superset estrito da regra real: todo comando que casa
\bgit\s+commit\b contém, por definição, a palavra commit — inclusive
git commit --no-verify, que é o caso que a sentinela do portão existe para pegar.

E o atalho revelou o segundo custo, que era maior: com o trabalho caro cortado, o que
sobrou no relógio foi o preâmbulo — as três linhas que todo guarda roda antes de qualquer
decisão ($(cat) para ler o pacote, $(dirname) para achar a pasta). Dois processos por
guarda, em todo evento, mesmo quando não há nada a julgar. Trocados por expansão nativa do
bash: IFS= read -r -d '' e ${BASH_SOURCE[0]%/*}.

O caminho inteiro, medido na mesma máquina, com git status:

Estado Soma dos cinco check-*
Como estava de manhã 1.024,4 ms
Depois do atalho por commit 372,3 ms 2,75×
Depois do preâmbulo nativo 116,8 ms 8,8×

Individualmente, agora: 22,6 / 22,8 / 23,1 / 23,4 / 24,8 ms. O piso do bash nesta máquina é
21,3 ms — os cinco custam o arranque do interpretador e praticamente nada além disso. O
portão (validate-commit.sh) saiu de 309,7 para 65,6 ms (4,72×) só com o atalho.

⚠️ Nota de honestidade sobre a medição, e ela é a lição P900 se cobrando: numa passada
o check-price-single-door.sh apareceu com 140,9 ms. Era ruído de carga — repetindo com
15 amostras deu 24,8 ms (mínimo 22,0). Número solitário não vira conclusão.

Rede: 57 testes (test_familia_check_atalho_commit.py), incluindo um que lê a linha do
atalho
e cobra que ela continue filtrando por commit — é o que impede alguém de alargá-la
de novo sem perceber — e outro que exige stderr limpo quando o guarda não bloqueia, que
é a rede que teria pego o defeito da contrabarra (§3.14).

3.13 ✅ A LEI DO CARIMBO funciona — e o "171" era ruído

Replay do motor real (scripts/check-carimbo-de-fato.py) contra o conteúdo de cada arquivo
na época, commit a commit, nos 6.002 commits de 90 dias. 2026-08-10.

Medida Valor
Guardas que chegaram em .claude/hooks/ na janela 185
... destes, com dente (conseguem barrar) 31
... destes, sem carimbo e sem válvula 30
... usaram a válvula [sem-fato-ok] 0
Posteriores ao nascimento da lei (02467c161, 2026-07-29) 4 — e um é arquivo de teste
Posteriores ao portão nativo do git (30b7138f2, 2026-08-07) 0

A explicação inteira está na data. A lei existe desde 29/07, mas o portão nativo
(.git/hooks/commit-msg chamando validate-commit.sh) só foi ligado em 07/08. Antes
disso ela só rodava na cópia que roda antes do git add, com a lista errada. Os três escapes
reais são todos anteriores a 07/08.

Ela morde HOJE — testado, não deduzido. Criei um guarda de mentira com dente e sem
carimbo, pus na fila do git e rodei o portão: saída 2, com a mensagem certa. O portão
inteiro levou ~60 s (a bateria rodou 15 arquivos em 58,6 s — não os 446 s que o comentário
do próprio arquivo cita para o caso pior)
.

⚠️ O limite dela, medido junto: com o arquivo fora da fila do git, o portão sai em
1,17 s e não vê nada. A lei enxerga o que o git enxerga — nem mais, nem menos.

Resíduo consertado no mesmo dia (commit 658b4f5b0): p600-fala-que-nao-chega.py e
p846-quarto-arquivo-sem-plano.py eram os dois legados que barravam sem declarar o fato.
Ambos carimbados.

3.14 ⚔️ O que três céticos independentes fizeram com a obra dos guardas

2026-08-10. Três agentes de contexto limpo, três lentes distintas, disparados em paralelo
sobre o mesmo diff. Um deles me derrubou — e é o mais valioso dos três.

Lente Veredito O que devolveu
Falso-negativo dos atalhos SOBREVIVE: SIM 0 falsos-negativos em 3.314 commits e 11.856 edits reais varridos — mas achou 3 documentos meus mentindo
Paridade da reescrita SOBREVIVE: SIM sha256 idêntico entre o .sh morto e o .py novo sobre o caderno real
Preâmbulo nativo SOBREVIVE: NÃO quebra real: ${BASH_SOURCE[0]%/*} não entende contrabarra

O defeito que o terceiro achou, e por que ele importa mais que o tempo que a obra ganhou:
a expansão nativa do bash corta no /. Num caminho do Windows (C:\...\guarda.sh) ela não
acha barra nenhuma e devolve . — então source ./_telemetry.sh falhava, log_hook_event
deixava de existir e todo bloqueio parava de ser registrado, em silêncio.

🕳️ E aqui está o buraco na minha própria rede: os 52 testes passavam com o source
quebrado
, porque só olhavam o código de saída. O guarda liberava certo, com o canal de
erro gritando No such file or directory, e ninguém via. Virou teste permanente:
guarda que não bloqueia tem que sair calado.

O presente que ninguém pediu: a troca do atalho de git por commit curou um mudo
pré-existente
. Com tabulação entre as duas palavras (git⇥commit), o filtro antigo — que
procurava git com espaço literal — não casava, e os cinco guardas saíam calados. Medido pelo
cético: antes exit=0, agora exit=2. Um portão que não barrava passou a barrar.

O que ficou como nota, e não virou código (baixa, com o número que a torna inalcançável):
o corte do valor no JSON não desescapa, então um caminho contendo aspa emudeceria o guarda —
e aspa é ilegal em nome de arquivo no Windows. O leitor de pacote tem custo linear, e pesaria
acima de ~10 KB — o máximo real medido é 6,8 KB.

As sete lições que a obra deixou (todas no acervo, todas com dado):

Lição O que ela guarda
P894 desenho novo confere o código, nunca a spec — virou a D19
P895 guarda com prazo + trabalho que cresce com o dado = falha calada que a telemetria conta como OK
P899 lição do acervo pode citar mecanismo já aposentado
P900 antes/depois em produção sem grupo de controle mede a carga da máquina, não o conserto
P901 trocar o mecanismo deixa a bancada órfã, e o conserto óbvio a deixa verde pelo motivo errado
P904 a explicação da versão morta fica por cima, descrevendo a viva
P905 comparador A/B cego devolve zero por construção — e zero parece a boa notícia

Palavra de conclusão desta frente, pelo vocabulário P676: Blindado — três céticos
independentes, nenhum achado ALTO ou CRÍTICO aberto. (O preço, dito: os dois últimos commits
de conserto não têm cético em cima deles — conserto nascido de achado gasta orçamento e não
reabre o laço.)

3.14-B 🎯 A sonda que matou a ideia de podar os guardas mudos

2026-08-10, antes de escrever uma linha do quinto desenho. A primeira coisa que a sonda fez
foi matar a minha ideia favorita
— e é para isso que ela existe.

A ideia era: "41,5% dos guardas nunca bloquearam nem falaram; podar esses resolve a
manutenção"
. Parecia óbvia e estava errada.

Medida (90 dias de git × 188 guardas registrados) Valor
Guardas mudos — zero bloqueio, zero fala 78 de 188 = 41,5%
Quanto da manutenção eles consomem 23,7%
Quanto vai para os guardas que funcionam 76,3%
Toques em guarda que são fix: (conserto, não construção) 514 de 814 = 63,1%
Quanto os 15 maiores concentram 40,2%

A manutenção mora nos guardas que funcionam. Podar 41,5% da pilha devolveria menos de um
quarto do esforço — e custaria a proteção que os podados eventualmente dariam. A D2, tomada de
manhã contra a poda por tempo, ganha aqui a segunda perna: não paga por manutenção também.

⚠️ Esta seção esteve FALTANDO até a segunda revisão de 10/08. Os números existiam, tinham
sido medidos e tinham matado uma ideia — e nada disso entrou na spec; só uma menção de passagem
dentro da §3.15. É a lição P884 em estado puro: eu estreito o escopo em silêncio, e quem lê
não tem como saber de onde veio a conclusão.

E foi desta sonda que nasceu a régua que morreu na sequência — a mesma população, olhada por
outro ângulo, com uma aritmética errada. Ver §3.15.

3.15 ⛔ A RÉGUA DE CUSTO POR GUARDA MORREU no dia em que nasceu

2026-08-10, tarde. Três céticos independentes atacaram o quinto desenho; os três derrubaram
algo meu
, e o terceiro reproduziu a minha régua na casa decimal antes de demoli-la.

A régua era: custo de um guarda = toques de conserto no git ÷ (bloqueios + falas). Com ela
eu afirmei que guarda que julga texto custa 200× mais que guarda que julga fato. Os seis
defeitos, todos medidos:

Defeito O número
Janelas incompatíveis numerador de 90 dias (git) ÷ denominador de 1,311 dia (telemetria) — fator 68,6×
O topo é divisão por zero 13 dos 15 "mais caros" têm ZERO atos. O valor 18,0 é 9 ÷ 0,5 — a constante de escape que eu mesmo pus
A lista dos caros É a lista dos mudos os mesmos 41,5% que a sonda da manhã tinha declarado irrelevantes. Eu os matei e os ressuscitei com outro nome
O topo nem são guardas aparecem duas bibliotecas, o próprio medidor e um arquivo de teste
As duas pontas são independentes correlação de postos entre toques e atos: 0,096
Dupla contagem quem bloqueia e fala conta 1 ato como 2

E o erro que teria custado caro: eu propus aposentar check-declare-done-sem-ritual.py
dizendo que ele tinha zero bloqueios. A telemetria diz 22 bloqueios em 31 horas — ele é o
3º guarda que mais barra coisa na casa inteira. E ele julga TEXTO, o que faz dele o
falsificador da minha tese, dentro dos dados que eu usei para escrevê-la.

🏠 A resposta estava em casa, escrita antes de eu errar. O próprio
scripts/quality/painel_de_guardas.py já adverte contra exatamente este viés de janela, no
comentário de FATIA_RECENTE"julgar pela janela é o defeito que este par existe para
consertar"
. A casa documentou o defeito e eu o repeti. É a lição P779 em estado puro.

(A versão anterior desta nota citava números de linha. Eles envelheceram em três horas,
quando eu mesmo mexi no arquivo — por isso a citação agora é pelo
nome do conceito.)

O que sobrevive da régua: nada como número. Como pergunta, sim: "quanto conserto este
guarda consome por ato útil?"
continua sendo a pergunta certa — e responder exige janela alinhada,
ATOS = 0 virando "sem medida" em vez de "caro", e a população filtrada para guardas de verdade.

3.16 ✅ O carimbo de conclusão rendia ZERO em 116 execuções — CONSERTADO em 2026-08-10

CONSERTADO no commit 3e26c68a4, no mesmo dia. A causa não era o gatilho, nem o papel,
nem a decisão: era de onde ele lia. O cético grava o laudo em disco antes de responder
e só depois monta a resposta com o bloco de escopo no fim — dois pedaços em dois lugares, e o
guarda escolhia um. Agora carimba() junta as duas fontes.

Prova com o código real contra os laudos reais (livro desviado para pasta temporária):
17 laudos → 0 carimbos antes, 4 depois — e os 4 são exatamente os de veredito positivo.
Rede: 4 testes novos, 326 verdes em toda a família.

⚠️ E um número meu que estava errado, pelo mesmo erro da P908 — cometido 40 minutos depois
de eu escrevê-la.
Eu disse "2,9% dos laudos trazem o bloco". Esse número mistura duas
populações separadas por uma mudança
: a instrução que manda o cético declarar o escopo entrou
às 05:37 do mesmo dia.

População Laudos Com o bloco
Antes da instrução 478 0 — 0,0%
Depois da instrução 49 15 — 30,6%

A adesão nunca foi 2,9%: era zero antes e 30,6% depois. Janela misturada vira número que
engana, e o alvo do engano fui eu mesmo.

O texto abaixo é o diagnóstico como estava antes do conserto, mantido porque é o que explica a causa.

Medido em 2026-08-10 por mim e, independentemente, por dois céticos. É o defeito real que
sobrou de pé
depois de os três movimentos do quinto desenho caírem.

carimba-blindado-do-cetico.py nasceu às 06:36 de 10/08 (commit 05684ea70) e está
registrado em SubagentStop. Ele faz, derivado, o que o quinto desenho ia construir à mão:
lê o laudo do cético e sobe o degrau do livro de bordo — sem sinal → mexido → testado → validado → blindado.

Medida Valor
Execuções desde que nasceu 116
Eventos que ele gravou 0
Livro de bordo 425 anotações, 100% commit — zero validado, zero blindado
Céticos que terminaram nesse período 65 — e 53 chegaram com o relatório vazio
Chances reais (veredito positivo) 3
Carimbos 0

A causa, reproduzida na bancada: ele exige que o laudo traga um bloco declarando onde o
cético olhou
. Medido nos laudos que existem em disco:

Laudos de cético gravados 525
Com o bloco exigido 15 — 2,9%
E o que são esses 15 laudos sobre o próprio carimbo

O formato só foi cumprido quando o assunto era o formato. É a mesma família da lei dos 9%:
mecanismo que depende de alguém lembrar de escrever um bloco no fim rende 2,9%.

⚠️ Não consertar por reflexo. A saída provável não é cobrar melhor o bloco — é
derivar o escopo do que o cético já produz, coerente com a D13. Mas isso é desenho, e o
desenho desta obra morreu cinco vezes. Mede primeiro.

3.17 ✅ O painel dizia bloqueios e queria dizer bloqueios em PreToolUseCONSERTADO

CONSERTADO no commit 046510e42, no mesmo dia. O campo virou bloqueios_no_portao
— o nome carrega o escopo — e nasceu um segundo, bloqueios_fora_do_portao, para o número
parar de sumir. Os dois saem juntos na ficha; só o do portão entra na taxa de "apita demais",
porque fora dele a saída 2 é o protocolo do evento, não trabalho travado.

A prova, contra a telemetria real: 359 bloqueios estavam invisíveis, em 4 guardas.

Guarda No portão Fora Execuções
check-declare-done-sem-ritual.py 0 23 242
notify-subagent.py 0 319 522
auto-capture.py 0 10 234
check-nonexistence-claim.py 0 7 240

Rede: 4 testes novos — um cobra que o nome curto não volte, outro roda contra a telemetria
real e falha se o guarda de Stop reaparecer com zero. 103 verdes na bateria do painel.

O diagnóstico abaixo é como estava antes do conserto, mantido porque explica a causa.

Conferido no código em 2026-08-10 — e não era bug, era decisão de desenho documentada.

O comentário do campo, no próprio painel, declarava: "só PreToolUse — bloqueio nos outros
eventos tem outro sentido"
. E faz sentido: saída 2 em Stop é o protocolo daquele evento, não
trabalho barrado.

O defeito é o nome. Um campo chamado bloqueios que vale zero para um guarda que barra 22
vezes por dia leva quem consome o painel — eu — à conclusão exata oposta.

Medida Valor
Guardas onde painel e fonte crua divergem nos atos 35 de 188 (18,6%)
Guardas com bloqueios = 0 no painel que barram de verdade 4
O maior deles notify-subagent.py: 314 bloqueios reais, 0 no painel

Isto é a lição P788 acontecendo no instrumento de medição da casa: um proxy barato de
observar vira o número em que se confia, e ele produz confiança porque produz número.

3.17-B ✅ A coerência da spec virou máquina — e ela achou um defeito na primeira passada

2026-08-10, ordem do dono: "sweetspot e robusto". O sweetspot não era peça nova.

O defeito que motivou: contagem que fica para trás numa revisão aconteceu três vezes num
dia
, sempre pelo mesmo mecanismo — a revisão acrescenta seções, e todo número escrito no
resumo envelhece no mesmo instante. É conferível por máquina, então passou a ser.

Onde entrou, e por que aí: scripts/quality/referencias_da_spec.py já existia, já fazia a
metade documento × código (o padrão DOCER) e já era chamado pelo portão do commit.
Ganhou a segunda metade — documento × ele mesmo:

O que ela pega O caso real que a motivou
secao-inexistente §7 citado num documento que não tem seção 7
contagem o resumo dizia 23 decisões; a tabela tinha 26
linha-fora-do-arquivo uma nota citava arquivo.py:241; 3 horas depois o arquivo mudou e o número passou a mentir
data-no-futuro datas de "amanhã", dando impressão de conferência que não houve

Custo no dia-a-dia: zero. Não é gancho, não roda em todo comando — roda onde o verificador
já rodava. Zero guarda novo, zero peça nova: é a D18 cumprida por absorção.

E ela achou um defeito na primeira passada: a lista de "arquivos protegidos, não mexer"
desta spec protegia o caminho antigo server/signals.py, que não existe no repositório. Os reais são
server/af/signals.py e server/routes/signals.py — e o segundo ficava de fora da
proteção
enquanto o texto dava a impressão contrária.

A contagem que não dá para adivinhar: o autor DECLARA. A conferência de decisões funciona
porque | **D7** | é convenção inequívoca — mas ela não generaliza, e a primeira versão parou
aí, deixando "21 medições" sem conferência com a justificativa de que "generalizar acusaria
prosa legítima"
. É verdade, e mesmo assim era desculpa: o problema não era o filtro, era o
texto não dizer o que estava contando.

A saída é a mesma da indústria quando a ferramenta não pode adivinhar a intenção — uma
anotação inline, invisível no texto renderizado, da família de # type: ignore e
<!-- prettier-ignore -->:

| O que já se sabe? | <!--conta:secoes:3-->21 medições, todas com fonte |
Propriedade Como fica
Falso positivo zero por construção — sem anotação, nada é conferido
Anotação com nome errado grita: conta:decisoess não existe — trava desligada em silêncio é o defeito nº 2 do dono
Exemplo em bloco de código ou crase ignorado — senão a própria documentação da ferramenta seria a primeira coisa que ela acusa
Contadores decisoes · secoes (com argumento: secoes:3 conta as 3.x)

⚠️ E há um número aqui que eu deliberadamente NÃO anotei: os "5 desenhos mortos". A spec
tem 3 seções 9.x, porque dois dos cinco desenhos não ganharam seção própria. Anotar
secoes:9 faria a ferramenta cobrar 3 onde o texto diz 5 — e a resposta certa não é mudar
o texto para 3, que estaria errado. Contagem que não é derivável do documento não deve ser
anotada
; anotar seria trocar um número velho por uma conferência falsa.

Rede: 60 testes na bateria. A maioria é de ruído — de propósito, porque aviso que erra
é desligado mentalmente. Dois rodam contra esta spec, um contra as 325 specs reais.

⚔️ E dois céticos independentes derrubaram a primeira versão dela

A peça foi entregue como "nível sênior" e submetida a duas lentes. As duas devolveram
PARCIAL, e — sem se falarem — apontaram o
mesmo mecanismo raiz.

O que mediram Valor
Acusações nas 325 specs reais 12 — e as 12 eram FALSAS (o filtro irmão, no mesmo arquivo, nasceu com 93%)
Quantas eram o mesmo homônimo 7constants.py:1583 casando o arquivo de 99 linhas em vez do de 2.713
Travessia de diretório provada — leu um arquivo do Python, fora do repositório
O typo na anotação mudo em 7 de 7 formas, contra a promessa escrita de que "grita"
conta:decisoes:7 contava todas — o argumento era aceito e ignorado, e o número batia
No portão do commit zero avisos em 2.200 commits — esta spec não está na lista das publicadas

A raiz era uma só: a metade nova não herdou os dois filtros que a metade velha tinha há
meses — exigir a barra no caminho, e recusar caminho de fora do repositório. Um conserto, no
mesmo lugar, matando o falso positivo de 58% e a travessia.

Depois da reforma: 0 acusações nas 325 specs · 21 de 21 casos de prova como
esperado (os 7 typos passaram a morder; os 4 falsos positivos calaram) · custo das 325 caiu
de 1.224 para 915 ms, porque o cache pagou mais do que os filtros custaram.

⚠️ O que ficou DECLARADO e não consertado, porque não dá para consertar honestamente: a
cobertura do detector de linha é 1,9% — ele só morde se o arquivo encolher 68%. O
defeito que motivou a peça (a citação que envelheceu em 3 horas) não é pego por ele. Com
zero falso positivo ele ainda vale o que custa, mas não é o que eu disse que era.

3.18 💸 O custo de oportunidade, com nome e idade

Medido pelo advogado do diabo em 2026-08-10, sobre os 111 commits do espelho do roadmap. É o
número que a D16 (não congelar) precisa encarar de frente.

No roadmap Valor
Cards de produto abertos (servidor, robô, painel, contas) 62
Parados há mais de 30 dias 20
Parados há 50 dias ou mais (e é piso censurado — o espelho só começa em 22/06) 13
Cards de ferramenta nascidos nos últimos 14 dias 54 de 65 (83%)

Entre os parados, três que envolvem dinheiro real: o canário que precisa passar antes de entrar
conta real de mesa proprietária
(#381), a morte por piso passando em silêncio
(#592/#594/#595) e o anti-troca de dia cego das 22h às 00h (#627).

E a ressalva que esta spec fazia caiu. A §3.11 dizia "7 dias é janela curta". Em 14 dias
a inversão persiste: 58,3% ferramenta contra 29,6% produto. São duas semanas, não uma.

O que o roadmap revelou, contra a minha hipótese: o número de cards não cresce por falta de
fechamento — fecha #N já responde por ~75% dos fechamentos, com apenas 8% dos commits
usando-o. Ele cresce por criação: cerca de 340 cards nasceram em 20 dias.

⚠️ Nenhum número desta seção foi reconferido em 11/08. Os cards moram no Postgres e a sonda
de §3.19 foi só-leitura de git e disco. Esta é a maior mancha de "não conferido" que sobrou.


3.19 🔬 A sonda de 2026-08-11 — cinco lentes, cinco céticos, cinco SOBREVIVE: NÃO

Disparada antes de escrever o sexto desenho, cumprindo a D19 (conferir o código, nunca
esta spec) e o CA-5 (medição que matou uma ideia tem seção própria).
11 ajudantes ·
1.150.445 tokens · 37 minutos.
Nenhuma linha de código escrita; nenhuma peça proposta.

O veredito, em uma frase: o diagnóstico da obra continua de pé e do tamanho que ela dizia —
o que caiu foi a leitura dele, e as duas pistas que eu tinha para o sexto desenho.

As três inversões — o que a spec afirmava e o que o código diz

A spec afirmava O código diz Onde foi corrigido
"87 guardas SEM teste" 87 é quem TEM. Sinal invertido; sem rede são 100-110, com ≥29% de falso-positivo §3.3
"a inversão acelerou desde ontem" Datada em 22-29/07; as duas últimas semanas são patamar (60,9% → 60,3%) §3.11
"30 dias: 42,0% × 47,2%" Aritmética não fecha — 1.161 de 2.101 é 55,3%. Linha descartada §3.11

As duas pistas que a sonda MATOU

Pista O que a derrubou
"reformar um punhado de arquivos resolve" 74,2% do conserto está FORA do top-5, e 144 de 252 guardas vivos (57,1%) levaram conserto em 30 dias. Há cabeça gorda e cauda longa
"a ferramenta é mais concentrada que o produto" Invertido, e por falta de grupo de controle (D22). Em 90 dias, 40 arquivos do produto cobrem metade dos toques contra 122 da ferramenta. Concentração é propriedade de repositório, não patologia da ferramenta

O que sobrou ACIONÁVEL — três fatos, cada um com número

Fato Número Comando
🟡 Peça pronta, sem gatilho automático scripts/quality/mutation_check.py, atrás de 3 variáveis de ambiente. ⛔ A linha dizia "zero execuções em 530 sessões" e era FALSA — derrubada em 11/08: são 20 execuções à mão (9 em 10/08, 7 em 22/07, 4 em 07/08). O medidor que dizia zero (.claude/.fiscais-medidos.jsonl) só enxerga ganchos; script solto rodado por comando nunca apareceria ali sed -n '40,46p' scripts/quality/pre-deploy.sh · contagem: tool_use de Bash casando python.*mutation_check\.py\s+\S nos 519 transcritos
Canal único com adoção parcial _lib/entrega.py em 55 de 238 arquivos (23,1%) — 44 .py + 11 .sh listagem dos .sh que citam entrega
A peça mais reincidente da casa validate-commit.sh: 37 dos 54 consertos da vida dele (68,5%) no último mês; 115 commits no total; nasceu em 27/02/26 git log -n 99999 --format='%s' -- .claude/hooks/validate-commit.sh \| grep -cE '^fix'

O preço da D6 (zero descartes) — é baixo, e a palavra "nunca" não se sustenta

44 guardas (23% dos 191 registrados) não registraram bloqueio nem fala na janela medida e
constam sem rede.
O que eles custaram:

Medida Valor
Fatia do conserto da pasta, em 90 dias 7,70% (78 de 1.013 commits) — ou 5,72% por par commit×arquivo
Commits dedicados a eles 6 de 86. Os outros 80 foram carona (mediana 4 arquivos/commit, máx 26)
Quantos tiveram zero commit em 90 dias 14 dos 44

O cético anulou a palavra "nunca", e o motivo importa mais que o número. As duas janelas,
lado a lado (D25): telemetria = 25,64 h contra git = 90 dias — razão 84:1. E as
25,6 h não são escolha analítica: são o resto de uma poda automática
(rotacao_telemetria.py:45, teto de 200 mil linhas,
guarda 150 mil). A janela ENCOLHE conforme a casa fica mais movimentada — então guarda de
evento raro (deploy, compactação, Playwright) aparece mudo por artefato de poda.

Duas provas de que a classificação é frágil:

A frase honesta é: "44 guardas não registraram ato em 25,6 horas". Não é "44 guardas
inúteis"
.

A casa não sabe quantos guardas tem

Quatro denominadores em circulação, e dois scripts da própria casa discordam em 7 lendo o mesmo
settings.json:

Fonte Diz
ls .claude/hooks/*.py *.sh 238 (188 .py + 50 .sh)
painel_de_guardas.py 191 registrados
fiscais-mudos.py 184
Briefing de 10/08 218

Sem um denominador único, toda percentagem sobre "os guardas" é indefinida — e há 29
ferramentas de medição
em scripts/quality/.

O inventário do CA-1 — o que JÁ existe e não precisa ser construído

Peça Estado Número
_medidor.py LIGADO 235 de 238 registros (98,7%)
scripts/quality/painel_de_guardas.py LIGADO (SessionStart) 191 guardas · 1 mudo · 2 apita-demais · 7 fala-pro-vazio · 100 sem rede
scripts/quality/regua_de_corte.py LIGADO 192 fiscais · 151.551 execuções · 5,0% das EXECUÇÕES falaram (não "5% dos guardas" — troca de unidade corrigida)
scripts/quality/fiscais-mudos.py LIGADO 0 mudos · 5 raros (1-5 execuções)
scripts/quality/regras-orfas.py LIGADO 9 docs · 2 órfãs: limites-da-ferramenta.md, tier-l-decision-criteria.md
.claude/hooks/_lib/entrega.py PARCIAL 55 de 238 (23,1%)
.claude/canarios/_exercitados.json DEFASADA congelada em 03/08; 305 commits em .claude/hooks/ desde então
scripts/quality/escuta_de_canarios.py + derivar_canarios.py SEM GATILHO existem, funcionam, e ninguém os dispara: o comando que os roda mora num docstring. Ver §3.20
~~.claude/hooks/tests/test_loop_detector.py — "NÃO RODA, timeout 6m40s"~~ A AFIRMAÇÃO ERA FALSA o caminho estava errado (o teste é tests/hooks/test_loop_detector.py) e ele passa: 10 passed in 0.67s. Achado pelo conferente de referências da própria casa, referencias_da_spec.py, minutos depois de eu escrever a linha

Hipótese testada e DESCARTADA: guarda órfão genuíno — 0 confirmados. Dos 53 candidatos do
grep ingênuo, ~20 foram abertos à mão e todos eram falso-positivo por indireção (fan-out do
unified-context-injector.py, ou instalador de gancho nativo ensure-*-commit-hook.sh).

⚠️ O que a sonda NÃO conseguiu ver

Lista obrigatória (P884): eu estreito o escopo em silêncio e o dono precisa perguntar "cadê o
resto?".

Buraco Por quê
A causa real de 61,6% dos consertos de guarda (306 de 497 em 30 dias) A classificação foi por texto no assunto do commit; a causa mora no corpo ou no diff. 497 corpos não foram lidos
Se a reforma do canal único funcionou ou falhou Os dois detectores da família nasceram dentro da janela (03/08: 9516a48da, 75210d5bb), e 67% dos consertos são em guardas de fev-jun. Pode ser doença viva ou termômetro novo expondo passivo de 5 meses — os dados aceitam as duas
Todos os números do Roadmap (§3.18) Moram no Postgres; a sonda foi só-leitura de git e disco
Oito números da lente de causa Citavam classify2.py como comando — o arquivo não existe no repositório, era artefato volátil de outra sessão. Irreprodutíveis
Se os ~33 candidatos a órfão não abertos escondem um genuíno Só ~20 dos 53 foram verificados à mão
Se test_loop_detector.py passa Morreu por timeout sem uma linha de resultado
A fórmula de "guardas novos/semana" usada em 10/08 Sem ela, a comparação entre as duas colunas da §3.2 é indefinida

🔎 A lição de método que a sonda pagou, e que vale além desta obra: três dos vinte números
corrigidos caíram pelo mesmo defeito — janela relativa usada para afirmar tendência. Virou
a lição P927. Qualquer número medido em --since='N days ago' nesta spec deve ser lido
como fotografia daquele instante, nunca como ponto de uma série.


3.20 🎯 A RAIZ do "sem rede": a verdade de cobertura é uma fotografia que ninguém retira

Medido em 2026-08-11, logo depois da §3.19, seguindo a diretriz do dono: "focando na raiz e
não no sintoma, e também focando em unificação"
. Esta seção existe porque a medição matou
uma hipótese minha
e achou outra — é o CA-5.

O sintoma é conhecido: ~100 guardas aparecem como "sem rede", e a §3.19 mediu ≥29% de
falso-positivo
nessa conta. A causa não é a que eu supus.

A hipótese que eu tinha, e o que a matou

"O painel só olha 2 das 10 pastas de teste da casa
(painel_de_guardas.py:71), então perde os
testes que moram fora delas."

Falsa como causa principal. O código diz o contrário, e o próprio docstring da função já
tinha aprendido a lição em 02/08:

"CITAÇÃO NÃO É COBERTURA. A primeira versão marcava 'tem teste' todo guarda cujo NOME
aparecesse em algum arquivo de teste. Medido: isso marcava 98 guardas, e
44 deles nunca eram
rodados
. A verdade de terreno é outra: quem tem rede é quem a bateria RODA."
painel_de_guardas.py:498-507

As duas pastas são o plano B, usado só quando o arquivo de verdade some — e o painel
avisa na tela quando cai nele ("só CITAÇÃO em teste (fonte fraca)"). O desenho está certo.

A causa real — dois defeitos que se somam

# O defeito A prova
1 A colheita não tem gatilho. O comando que produz a verdade mora num docstring; nenhum gancho, nenhum portão, nenhum cron o dispara busca por escuta_de_canarios em todo o repositório: zero chamadas executáveis — só .planning/, docstrings e um teste que o importa
2 A colheita, quando roda, olha as mesmas 2 pastas de 10 o comando publicado é python -m pytest scripts/tests .claude/hooks/tests -q -p escuta_de_canarios (escuta_de_canarios.py:32)

O estado do arquivo de verdade, hoje:

Medida Valor
Último commit de .claude/canarios/_exercitados.json fa89e3825, 2026-08-03
Commits em .claude/hooks/ desde então 305 — e 154 arquivos distintos tocados
O campo medido_em literalmente "regravado sem data — passe CANARIO_DATA"
Guardas listados 87
git log -n 8 --format='%h %ad %s' --date=short -- .claude/canarios/_exercitados.json
python -c "import json;print(json.load(open('.claude/canarios/_exercitados.json',encoding='utf-8'))['medido_em'])"

⛔ Por que isto não é a raiz que eu disse que era — derrubado no mesmo dia

Esta seção afirmava, em 11/08 pela manhã:

~~"toda decisão de aposentar, absorver ou proteger um guarda depende deste número. A D6 ×
D8 é decidida por ele. A régua de utilidade mínima da §7.2 é decidida por ele."
~~

FALSO, e é a D19 violada por mim: herdei a frase do texto da própria spec sem abrir o
código. É a mesma causa de morte do quarto desenho (§9.1), cometida na seção escrita para
denunciá-la.

O que eu afirmei O que o código diz Prova
o número decide aposentadoria tem UM consumidor executável, e ele imprime os únicos arquivos que leem _exercitados.json são os dois produtores e painel_de_guardas.py:512
ele é cobrado em algum portão roda uma vez, em SessionStart --curto .claude/settings.json:268
"sem rede" é alarme não está na tupla graves — cai na linha de resumo painel_de_guardas.py:788: graves = (SUMIU, QUEBRADO, MUDO, APITA_DEMAIS, FALA_PRO_VAZIO)
ele pode barrar algo main() devolve 0 em todos os caminhos (fail-open declarado) painel_de_guardas.py:900-906
a D6×D8 é decidida por ele as três decisões que eu nomeei já estavam mortas ou excluídas pela própria spec D2 (não podar) · D6 (zero descartes) · D24 (nada se aposenta pela régua) · §3.14-B (a sonda matou podar guardas mudos)

E o "~100 mal classificados" também encolheu, pelo mesmo tipo de erro — eu contei quem está
fora da lista guardas e chamei de "desconhecido", mas o arquivo tem três listas:

Quantos O que é
fora da lista guardas 106 ⛔ o número que eu publiquei
vistos e descartados por REGRA 71 a colheita os VIU rodando com pacote vazio e decidiu, de propósito, que "rodar vazio não é julgar"derivar_canarios.py:86-91
nunca vistos por nenhuma das três 35 ✅ estes sim são "não sei"

O próprio produtor documenta a escolha de régua: "contando qualquer execução, o painel diria
25 guardas sem rede; contando só quem julgou um pacote, são 102"
(derivar_canarios.py:77-80). A distância entre 25
e ~100 é escolha de régua, não fotografia velha.

O que SOBROU de verdade desta seção

O defeito é real, e é menor e mais preciso do que eu escrevi: um instrumento de tela
apresenta 35 guardas que ele nunca mediu como se fossem "sem rede" — afirma o que não
sabe. É o defeito nº 2 do dono ("mentir que terminou") dentro do medidor, e vale conserto.
O que ele não é: um número que decide alguma coisa.

E a LEI da obra continua valendo para a fotografia — só não com a importância que eu dei:

Escrito por máquina disparada por evento = vive. Escrito por humano que precisa
lembrar
= morre — inclusive quando existe lembrete automático.**

⚠️ A armadilha que qualquer conserto ingênuo cai

Colheita PARCIAL é pior que colheita velha. Se o gatilho for pendurado no portão do commit,
que roda uma bateria seletiva (§3.19: "porteiro seletivo, 40 de 129"), o resultado seria
"os guardas cujo teste não rodou desta vez perderam a rede" — o instrumento passaria a
mentir ativamente em vez de apenas envelhecer.

A saída de desenho que isso obriga: o artefato tem que deixar de ser fotografia que
precisa estar completa
e virar livro-caixa que acumula, com data por guarda. Aí colheita
parcial soma em vez de apagar, e a idade fica visível por guarda em vez de global.

Nenhuma linha foi escrita. O desenho vai a cético antes — é o que ficou 250× mais barato (§9.0).

⚔️ O primeiro cético desta seção derrubou um número MEU, desta mesma seção

Lente "a peça já existe" — a que matou cinco desenhos. Veredito: SOBREVIVE: SIM, as quatro
portas confirmaram o diagnóstico. Mas ele achou quatro defeitos adjacentes, e o primeiro é meu.

# O que caiu O certo Prova
A1 "512 commits desde a foto" — ⛔ peguei um número de janela de 14 dias e colei numa afirmação de "desde o commit da foto" 305 commits · 154 arquivos distintos git rev-list --count fa89e3825..HEAD -- .claude/hooks/305; o 512 vinha de --since="14 days ago", que hoje dá 509
A2 "o mesmo mecanismo que o conftest já usa" — ⚠️ não é simétrico o precedente (teto_de_subprocesso) tem aplica() e devolve(), chamados de pytest_configure/pytest_unconfigure; o escuta_de_canarios remenda no corpo do módulo e nunca desfaz conftest.py:41-55 contra escuta_de_canarios.py:172-174; busca por pytest_unconfigure ou devolve no plugin: zero
A3 o artefato tem 3 listas, não 1 guardas 87 · so_rodaram_vazio 92 · so_carregados 16 leitura do JSON
A4 o docstring do painel já envelheceu na direção que esta seção denuncia ele afirma "são 64, não 98"; o arquivo tem 87 painel_de_guardas.py:507

O A1 importa mais pelo que ele é do que pelo tamanho. O argumento sobrevive inteiro em 305 —
o que não sobrevive é a confiança, e este é o documento cuja única função é não mentir. Foi
achado por um cético lendo o rascunho três horas depois de eu escrever a §3.19, que existe
justamente para registrar 20 números que eu tinha errado. (A §0 avisa que revisar documento
longo
cria contradição nova. Esta é a prova, ao vivo, no mesmo dia.)

O A2 muda o desenho, e para pior. O remendo global era o risco marcado como "o que mata o
desenho"
, e a mitigação que eu tinha era "o mecanismo já está provado no conftest". Não
está
: a peça citada como precedente restaura, esta não. Ligar por import deixaria
subprocess.run, Popen e spec_from_file_location remendados até o processo morrer.
Consequência: o movimento do gatilho passa a exigir aplica()/devolve() no plugin — deixou
de ser fiação de 1 linha.

✅ O que FOI FEITO: o painel parou de chamar de "sem rede" quem ele nunca mediu

Do desenho morto sobrou um conserto, e ele é no consumidor, não no produtor. Entregue em
2026-08-11.

A regra, em uma frase: "não está na lista" tinha duas causas, e o painel chamava as
duas de sem rede — afirmando o que não sabia sobre metade delas.

Causa Quantos O honesto
a colheita viu o guarda e decidiu que aquilo não era rede (so_rodaram_vazio, so_carregados) 71 sem rede ✅ já estava certo
a colheita nunca viu — o guarda nasceu depois dela ~30 ⚠️ era sem rede, virou nao da pra dizer

O estado certo já existia no arquivo, criado pelo mesmo raciocínio 9 dias antes, e o
docstring de classificar já explicava por quê:

"porta_usada aceita None de propósito: ... Tratar None como False seria chamar de
'sem tráfego' o que na verdade é
'não medido' — o erro de 2026-08-02, em escala."
painel_de_guardas.py:221-223

Era o mesmo erro, no eixo do lado. É a lição P779 em cheio: a resposta estava em casa, no
arquivo que o desenho já estava lendo.

A prova, no dado real — a linha do bootstrap antes e depois:

antes:  2 evento sem canal · 100 sem rede ·  0 nao da pra dizer · 0 sem trafego · 78 saudavel
depois: 2 evento sem canal ·  70 sem rede · 31 nao da pra dizer · 0 sem trafego · 78 saudavel
O que foi conferido Resultado
bateria do painel 107 verdes (era 104; entram 3 testes novos)
mutação dirigida — apagar o ramo do "não medido" morre (1 falha)
mutação dirigida — fonte cega virar "não sei" para todos morre (1 falha)
restaurado ✅ 107 verdes

A armadilha que o próprio teste da casa pegou, e vale registrar: a primeira versão do
conserto tratava "a fonte não diz quem mais ela viu" como "não medido" — e isso apagaria o
estado sem rede inteiro
para qualquer base antiga. O teste ponta a ponta que já existia
ficou vermelho e me corrigiu. A regra final é mais estreita: o nao da pra dizer só aparece
com prova positiva de que a colheita enxergou parte do mundo.

⚠️ O que este conserto NÃO faz, dito: não regenera a colheita (ela segue de 03/08, sem
gatilho), não muda os 71 legítimos, e não faz o número decidir nada — o painel continua
imprimindo e devolvendo 0. Ele faz o relatório dizer a verdade, que era o tamanho honesto do
problema.


3.21 🚪 O gancho que já está na porta certa — e não abre o envelope (2026-08-11)

Nasceu da cobrança da lição P884: a pesquisa entregou 6 peças e o desenho cobriu 2.
Antes de desenhar o sétimo, varri no CÓDIGO o que cada uma das seis já tem — que é a lente
CA-1, a que matou 5 dos 6 desenhos. Desta vez ela rodou
antes, não depois.

O que a pesquisa dizia, e o que o código diz

A pesquisa-spec-driven-e-harness.md §10 marca dois dos quatro momentos de verificar como
buraco: "antes da 1ª linha de código"falta, e "as N tarefas têm N provas?" ⚠️
"existe o mecanismo, falta ligar ao plano". A segunda afirmação está errada — e a
primeira está certa por um motivo diferente do que a pesquisa supunha.

A medição

# o medidor completo ficou no rascunho da sessão; a regra que ele aplica:
# tarefa = cabeçalho `##|### (Task|Fatia|Frente|Peça|Bloco|A1…)`; comando = ferramenta
# executável (python/pytest/npx/curl/git/psql/ssh/bash/scripts/) em qualquer posição
# de uma linha com rótulo de verificação, ou nas 5 linhas seguintes.
ls ~/.claude/plans/*.md | wc -l
O que Quantos %
Planos aprovados nesta máquina 19
…com seção de verificação no nível do plano 19 100%

O RESTO DESTA MEDIÇÃO FOI DERRUBADO no mesmo dia, por três céticos independentes. Ficam
registrados como o que são — um ponto arbitrário dentro de uma faixa larga:

Eu publiquei O que outros dois mediram, com critérios próprios
124 tarefas 113 (um) · 126 (outro, aplicando a regra que ESTA seção descreve)
103 com campo (83,1%) 82
72 com comando (58,1%) 25 a 76 — ou seja, 22,1% a 67,3%, só trocando o critério

A regra publicada aqui não reproduz os números publicados aqui — ela gera 126/82/55. Um dos
dois está errado e não dá para saber qual, porque o medidor foi descartado no rascunho.

🎯 A leitura honesta: "quantas tarefas carregam comando de prova" não tem resposta
conhecida
nesta casa. Não existe gabarito contado à mão, e o mesmo corpo de 19 planos já
devolveu 3,6%, 58,1%, 87,1%, 22,1% e 67,3% conforme quem conta. O que
sobrevive é a pergunta, não o número.

A causa raiz, e ela é mais interessante que o número: os 19 planos usam 15 formas de
marcar uma tarefa e 23 de declarar a verificação dela, com rotatividade de ~1 forma nova por
plano. Nada nunca exigiu uma gramática — então qualquer leitor automático não lê um formato,
adivinha um dialeto. E o próprio plan.md alimenta a confusão: manda o campo se chamar
Verificacao na linha 133 e escreve Validacao nas linhas 100, 111 e 197.

🚪 A porta: o gancho certo já existe, no instante certo, e não lê o plano

plan-approved-task-tracker.py roda em PostToolUse ExitPlanModeo momento exato da
aprovação do plano, antes da primeira linha de código
. É o momento que a pesquisa chamou de
"a única peça do mercado que vale enxerto".

O que ele recebe O que ele faz com isso
tool_input.plan — o texto inteiro do plano nada
tool_input.planFilePath — o caminho do arquivo nada
✅ injeta um lembrete de texto e grava .claude/.plano-aprovado.json

Confirmado em 26 chamadas reais de ExitPlanMode nos transcritos (eu tinha escrito 5 — era
uma fatia; a varredura completa dos 519 arquivos, deduplicada por identificador de chamada, dá
26)
: o campo plan chega com 4.604 a 23.038 caracteres, e planFilePath vem em 25 das
26
. E a varredura de .claude/hooks/ por planFilePath / .claude/plans devolve uma linha
— uma fixture de teste passando "..." como espaço reservado
(tests/test_plan_task_tracker.py:54). Nenhum guarda da casa lê o texto de um plano.

🎯 A frase que resume: o sensor já está de pé na porta certa, com o envelope na mão, e
nunca o abriu. Não falta guarda novo — falta abrir o envelope.

⚠️ O medidor errou primeiro, e vale registrar

A primeira versão deu 3,6% e estava errada: ela exigia que a linha começasse com a
ferramenta, e a forma real é - **Verificação:** \cd server && pytest …`— o rótulo vem antes. Falso negativo em **13 de 13** tarefas de um plano. Uma versão anterior a essa, frouxa demais (qualquer crase contava), deu **87,1%**. **Os três números saíram do mesmo dado.** Só a conferência à mão de um plano separou o verdadeiro dos dois falsos — é o anti-padrão *"medidor caseiro"* da/sonda`, pego dentro da própria sonda.

As outras quatro peças, conferidas no código

# Peça da pesquisa Estado no código, 11/08
1 Quem julga é sensor que devolve 0/1, não o agente existe e é a força da casa — 44 guardas no portão do commit
2 Quem julga ≠ quem fez existe e tem travapretask-preflight.py, orcamento-de-cetico.py, check-declare-done-sem-ritual.py, os três registrados
5 Teste verde não é prova (SpecBench → mutação) ⚠️ pronto, sem gatilho automáticomutation_check.py em pre-deploy.sh:43, com 20 execuções à mão em 3 dias distintos. (Eu tinha escrito "0 ocorrências em 187.911 linhas de telemetria": aquele diário só mede ganchos, e este é script solto — ele nunca apareceria ali, rodando ou não.)
6 1 conversa = 1 tarefa contradiz a política atual (compactar em vez de fechar). É decisão do dono, não achado técnico

3.22 🚨 A obra já existia, está IMPLEMENTADA desde 27/05, e a corrente dela está partida em dois elos (2026-08-11)

Achado a partir de duas perguntas do dono — "já não temos a skill plan e o template pra spec?"
e
"e a spec template, achou ela?". As duas eram a lente CA-1, e as duas mudaram a resposta.
Esta seção é a mais importante da spec:
ela mostra que sete desenhos tentaram inventar um
sistema que esta casa desenhou, construiu, testou com 42 casos e declarou IMPLEMENTADO há dois
meses e meio.
**

O que existe — specs/harness-3-portoes.md, S384, 2026-05-27

Ela ataca, nomeando, os mesmos quatro furos que a pesquisa de agosto "descobriu": premissa
não re-ancorada · cobertura por arquivo em vez de por requisito · sensor visual decidido no chute
· juiz funcional sendo o mesmo cérebro. Cita as mesmas fontes (harness engineering, ATDD,
"quem julga tem que ser ferramenta que devolve 0/1").

Portão O que faz Estado medido em 11/08
A — premissa exige confirmar o problema contra o terreno real antes de desenhar vivo0g em REQUIRED["L"], tratado em brainstorm-phase-auditor.py:188-193, registrado
B — carimbo cada critério de aceite leva [logic] · [journey] · [visual] ⚠️ parcial — está no specs/TEMPLATE.md, e chegou em 35 de 328 specs
C — sensor verde o commit da tarefa só fecha com o sensor provado; [visual] sem prova de navegador BLOQUEIA órfão — vivo e registrado em Bash(*git commit*), esperando um token que ninguém emite

🔗 A corrente, elo por elo — e ela decai até quase zero

O contrato tem três elos. Medidos:

# Elo Adesão
1 a spec traz CA-N com carimbo de sensor 35 de 328 = 10,7% ⚠️
2 o plano diz qual CA-N cada tarefa satisfaz 1 de 19 = 5,3%
3 o commit carrega Prova: CA-N via [sensor] 1 de 4.000 = 0,025%
# elo 3, o mais fácil de reconferir:
git log --format=%B -n 4000 | grep -ciE 'prova\s*:\s*CA[-_ ]?\w+\s+via'

🎯 A causa, e ela não é desleixo

O elo 2 nunca foi construído. O .claude/commands/plan.md não menciona critério de aceite uma
única vez — nem CA-N, nem Prova:. E git log -S "Prova: CA" nesses arquivos volta vazio:
a linha que o guarda espera nunca existiu no template que deveria emiti-la.

O stage-sensor-gate.py declara no próprio texto que o token é "mandado pelo template do
/plan"
. Ele foi construído contra um produtor que não foi construído junto.

🔎 Esta é a D8 ao contrário e vale como lei: a casa criou uma peça nova (o portão) sem
criar o elo que a alimenta — e o resultado não foi um portão ruim, foi um portão mudo por
dois meses e meio
, disparando em todo commit e nunca vendo nada. Peça nova precisa nascer com
quem a alimenta, não só com quem a chama.

⚠️ O que isto NÃO autoriza a fazer ainda

Ligar o elo 2 hoje emitiria Prova: CA-N apontando para critérios que 90% das specs não têm
carimbados
. E o elo 3 tem dente: [visual] sem prova de navegador barra o commit. Ligar a
corrente com dois elos fracos transforma um guarda correto em falso-alarme — que é o defeito que
esta obra mais mediu. Decisão do dono, 11/08: conferir antes de ligar. Os dois furos foram
conferidos e estão acima; o terceiro (o acervo de specs sem carimbo) segue aberto.


3.23 🧩 A obra do painel, triada — 5 peças que servem, 4 que não (2026-08-11)

O dono mandou analisar specs/painel-de-progresso-do-harness.md, fechada em 11/08 com 10 de 11
metas.
Veredito: ela fez bem, e o resultado mais transferível dela não é o painel — é que
medir encolheu a meta em 6 de 11 casos (metas 3, 4, 6, 8, 11 encolheram; a 9 foi cancelada
por já estar pronta). Esta seção é a triagem, obrigatória pela lição P884: o que entra E o que
fica de fora, com motivo.

# Peça Serve? Por quê / onde entra aqui
1 A LEI: escrito por máquina disparada por evento = vive; escrito por humano que precisa lembrar = morre — inclusive quando existe lembrete automático** é a nossa lei dos 9% endurecida: a prova são dois arquivos mortos que tinham lembrete no arranque
2 O trailer Meta: + o conserto no commit-msg (--in-place, padrão do Change-Id do Gerrit) a mais valiosa é a receita pronta do Prova: CA-N via [sensor] órfão (§3.22). Nasceu depois de 5 remendos numa expressão regular, e a raiz foi trocar prosa por trailer do git
3 O formulário do cético — bloco alvo \| lente no laudo vocabulário fechado, já construído. É o "declare, não adivinhe" que os 3 céticos de 11/08 exigiram
4 Alarme por limiar tentado e DESCARTADO com número — limiar 5 apitaria em 76% dos pontos; limiar 40 em 15% e atravessaria calado o buraco de 50 commits disciplina de falso positivo, do mesmo teto de 10% da D20
5 A unidade "desde a última mudança de HEAD" uma régua servindo dois guardas (orçamento de cético e trava do 4º arquivo)
6 A tabela dos 9 passos originais daquela obra congelada de propósito, e ela mesma registra que mentiu por um dia em 5 passos
7 A pesquisa do formato universal de resultado de teste concluiu "não cobre o alvo" e não mudou o desenho
8 A aba no site + os passos de instalação do gancho na VPS entrega específica daquela obra; zero transferência
9 O bloco de histórico de 313 commits gerado por máquina; ruído para quem lê

🎯 O achado que vale mais que os nove: dois EIXOS, não dois vocabulários

Três sistemas desta casa respondem à mesma família de pergunta. Descobri hoje que dois deles
são complementares e nunca foram ligados:

Sistema Responde Vocabulário Nasceu
Degraus do livro de bordo quão fundo a prova foi mexido · testado · validado · blindado 09-11/08
Carimbos dos 3 portões qual sensor prova [logic] · [journey] · [visual] 27/05
A palavra de conclusão (P676) o que eu afirmo ao dono Entregue · Validado · Blindado

O primeiro e o terceiro já estão alinhados — mesmas palavras, mesmo significado, sem
conflito. O que falta é o fio entre os dois primeiros: se o commit da tarefa carregasse
Prova: CA-N via [journey], o carimbo do degrau sairia sozinho, sem ninguém digitar.
É a
mesma obra vista de dois lados, feita com dois meses e meio de distância — e o elo que falta é
exatamente o da §3.22.

🔧 As obras avulsas do outro chat, e onde elas moram

O dono perguntou por elas por nome (travamento de commit, "pythons fantasmas"). As duas
primeiras têm spec; a terceira é peça de uma delas.

Obra Onde Estado
Travamento do commit · cadeado de 238 min · sobrescrita entre conversas specs/varias-conversas-no-mesmo-repositorio.md CONSTRUÍDO 11/08 — 3 peças: comando único de commit (7f4ef8d89), trava anti-sobrescrita (55d8991cb), teto de tempo (76e7edd71)
O diário do atrito do commit (fatias, cadernos, cards) specs/PLAN-fechar-o-atrito-do-commit.md fatias A-D fechadas, F medida
Os "pythons fantasmas" sem spec própria — é a 3ª peça da primeira: scripts/_lib/teto_de_subprocesso.py medido: 237 de 537 chamadas de subprocesso sem teto de tempo; um travamento entupia a máquina. O contador do arranque vem de .claude/hooks/fotografo-de-fantasmas.py

⚠️ O defeito daquela obra que também é NOSSO

1.261 linhas de spec para uma obra de dois dias. É o mesmo inchaço deste documento, e a
própria seção de pós-implementação dela confessa ter carregado uma régua revogada em
01/08
— dentro da spec de uma obra que existe para perseguir documento que mente.


4. 🎯 As decisões tomadas

Numeradas, com data e motivo. Decisão sem motivo volta a ser discutida.

# Data Decisão Motivo
D1 2026-08-10 Não trocar o modelo de spec pelo spec-kit 1 portão real dele contra 44 aqui; e ele não instala sem quebrar — a trava do 4º arquivo barra a primeira linha de código dele
D2 2026-08-10 Não podar guardas para ganhar tempo devolve 5,8 min/dia. A justificativa original estava inflada 6,98×
D3 2026-08-10 Não construir porteiro único ~8-16 min/dia. Relógio nunca foi o gargalo — manutenção é
D4 2026-08-10 O primeiro desenho (5 portões) morre cético: SOBREVIVE: NÃO, 2 críticos. Detalhe em §9
D5 2026-08-10 O alvo são os 2 defeitos do dono, não "um harness melhor" ordem dele, literal
D6 2026-08-10 Zero descartes — nada do que existe vai pro lixo o mapa (§6) não achou uma única peça errada; achou peças desligadas
D7 2026-08-10 Copiar o princípio do projeto pequeno e o hábito do grande o único que julga com máquina tem 7 estrelas; o de 126 mil só confere se o arquivo existe
D8 2026-08-10 Toda peça nova tem que apagar ou absorver uma existente a pilha cresce 34/semana; somar sem tirar piora
D9 2026-08-10 Portão só liga em modo sombra primeiro o portão existente mordeu 1 de 1 vez, e mordeu errado
D10 2026-08-10 Esta spec é a cobaia da própria obra se as caixas dela ficarem por marcar, a obra se autodesmentiu
D11 2026-08-10 Nenhuma peça desta obra pode depender de eu lembrar de escrever uma frase sem um lembrete no momento certo a lei dos 9% (§3.10), medida em 3 mecanismos: sem lembrete = 8-9%; com lembrete = 83,4%
D12 2026-08-10 O alvo do roadmap é o número não mentir, não o painel ficar bonito palavras do dono: "tenho noção pelo número de cards" — o número é o produto
D13 2026-08-10 Estado derivado (git, banco, telemetria) vence estado escrito à mão, sempre que houver escolha é a única forma que aguenta dois chats no mesmo working tree (§11-B)
~~D14~~ ~~2026-08-10~~ ~~🚩 LINHA VERMELHA: se a versão final desta obra propuser mais de ~5 arquivos novos de guarda, congelar o harness por 30 dias e voltar pro robô~~ · ⛔ REVOGADA no mesmo dia por D16 a inversão de §3.11: 60,6% da semana na ferramenta contra 23,3% no produto
D15 2026-08-10 A obra só se justifica no tamanho de fiação: emendas de 1 a 3 linhas em peças que já rodam, zero guardas novos · (relaxada por D18) as três varreduras concordam: a casa já tem quase tudo; o que falta é a última ligação de cada peça
D16 2026-08-10 Congelar o harness está FORA. O alvo é otimizar o harness, gastando os recursos necessários. D14 fica no documento riscada, como registro de uma proposta minha que o dono recusou ordem dele, literal: "não quero congelar o harness, o foco é otimizar ele, vamos gastar os recursos necessários, prossiga com as pesquisas e análises". A inversão de §3.11 continua verdadeira e continua medida — o que mudou foi o que se faz com ela: o número deixa de ser gatilho de parada e vira alvo de otimização
D17 2026-08-10 O alvo da otimização são os três juntos — não mentir/não esquecer · menos manutenção · menos atrito — e não um deles. Cobrança em §4-B ele recusou escolher um: "é as 3 coisas que vc citou e mais né, fica difícil focar só em 1". Consequência prática: o desenho final tem que dizer, movimento por movimento, qual dos três ele move — movimento que não move nenhum sai
D18 2026-08-10 Guarda novo pode nascer, desde que apague ou absorva um existente — D15 (zero guardas novos) fica relaxada, D8 fica ratificada. E abre-se uma frente de pesquisa nova: qual é o sweetspot da indústria para guardas em ganchos escolha dele entre 3 opções, com o preço na mesa. Palavras dele: "opção 1, hoje em dia, na indústria, qual o sweetspot pra guardas em hooks? quero entender onde podemos estar errando"
D19 2026-08-10 🚨 Desenho novo confere o CÓDIGO, nunca esta spec. E toda seção que descreve defeito vivo passa a carregar a data da última conferência — sem ela, o texto é histórico, não estado quatro desenhos morreram do mesmo defeito, e o quarto morreu por culpa deste documento: a §3.7 ficou 8 horas dizendo "defeito vivo" depois do conserto, e 15 ajudantes construíram em cima do texto velho (§9.1)
D20 2026-08-10 A régua da indústria é tempo, não quantidade. Parar de perguntar "são guardas demais?" e passar a perguntar "quanto tempo se fica parado esperando?" nenhuma fonte publica teto de quantidade para harness de agente — isso foi procurado por 5 ângulos e não existe. O que existe é teto de tempo, taxa de falso positivo (Google desliga acima de 10%) e proporção do acervo que fica ligada (ESLint: 22%)
D21 2026-08-10 O valor de um guarda é quanto do relógio ele POSSUI(ele − o segundo mais lento do lote), somado sobre os lotes em que foi campeão. Não é a duração dele, e não é quantas vezes ele roda os guardas rodam em paralelo (§3.12): acelerar o campeão só rende até o segundo colocado. Sem essa régua, "o guarda dura 1,4 s" leva a otimizar o que não move o relógio
D22 2026-08-10 Medição de conserto exige grupo de controle. Antes/depois em produção só vale com um conjunto de peças não tocadas medido na mesma janela lição P900: o antes/depois deu "pior" nos guardas que eu não tinha tocado — a máquina estava sob carga. Sem o controle eu teria concluído o oposto do verdadeiro
D23 2026-08-10 Rede que só confere o código de saída não é rede. Guarda que não bloqueia tem que sair calado — o canal de erro entra no teste 52 testes passaram com o source quebrado e a telemetria muda por baixo (§3.14). Foi um cético de fora que viu, não a bancada
D24 2026-08-10 A régua de custo por guarda está MORTA. Nada se aposenta por ela — nem os "4 guardas inertes", nem os 78 mudos ela divide 90 dias por 1,3 dia, o topo é divisão por zero, e ela ia me fazer desligar o 3º maior bloqueador da casa (§3.15)
D25 2026-08-10 Toda medição que cruza duas fontes declara as DUAS janelas, lado a lado, antes do resultado. Sem isso o número não entra é o defeito de D24 em uma frase. E o aviso já estava escrito dentro do próprio medidor da casa (painel_de_guardas.py:241-247) — a lição P779: a resposta estava em casa
D26 2026-08-10 O foco é arrumar o harness primeiro, antes de voltar ao produto ordem do dono, literal: "o foco agora é em arrumar o harness primeiro" — dada depois de ver os números do custo de oportunidade (§3.18), não antes
D27 2026-08-10 A conferência de coerência da spec ABSORVE o verificador que já existe — não vira script novo, nem guarda novo. Ela roda onde ele já roda: no portão do commit ordem do dono ao escolher o caminho: "sweetspot e robusto". Robusto porque o portão do commit já chamava a peça (garantia de 100%, não de memória); sweetspot porque custa zero no dia-a-dia — não é gancho, não roda em todo comando. É a D18 cumprida por absorção, que é a forma boa dela (§3.17-B)

4-B. 🎚️ A cobrança da D17 — qual dos três alvos cada movimento move

O dono recusou escolher um alvo. A consequência que ele mesmo apontou é esta tabela:
movimento que não move nenhum dos três sai do desenho.**

O quinto desenho foi escrito e morreu (§9.0). Fica registrado o que ele propunha e o que
cada movimento moveria — porque proposta morta sem motivo escrito volta em duas semanas:

Movimento do quinto desenho 🎯 🧹 🪶 Entrou?
Palavra de conclusão vira linha no commit a peça já existia, e derivada
Aviso do fecha #N no commit existe há 22 dias, e a coluna que ele leria não existe
Guarda de texto nasce com replay alcança 3 de 186 guardas

Os seis movimentos já executados, medidos pela mesma régua — não são o desenho, são
consertos avulsos achados durante a medição:

Movimento executado 🎯 🧹 🪶 Prova
Recibo no descarte do injetor (4f5b8cf77) forte 37,1% dos turnos deixavam de entregar, calados (§3.7)
sync-memory reescrito em Python (7d12bdd67) forte a lição da sessão em worktree nunca voltava; > 600 s → < 5 s (§3.12)
Atalho + preâmbulo nos guardas (198c9d023 e irmãos) forte soma dos cinco: 1.024,4 → 116,8 ms (§3.12-B)
Carimbo nos dois guardas legados (658b4f5b0) barravam sem declarar o fato (§3.13)
Carimbo de conclusão passa a enxergar o laudo (3e26c68a4) forte 116 execuções → 0 carimbos; agora 4 nos mesmos laudos (§3.16)
O campo bloqueios passa a dizer de que evento fala (046510e42) forte 359 bloqueios estavam invisíveis, em 4 guardas (§3.17)

O que essa tabela denuncia, e continua denunciando: dos seis movimentos executados, só
dois tocam 🧹 manutenção, e nenhum a ataca de frente — que é justamente o eixo em que a
§3.11 mostra a casa a 3-4× da referência de mercado. O sexto desenho, se houver, tem que
atacar a coluna do meio
— ou repete o padrão dos cinco anteriores.

🔎 E há um padrão nos seis que vale mais que a tabela: nenhum deles foi desenhado.
Todos apareceram durante uma medição, olhando um número que não fazia sentido. Cinco
desenhos morreram; seis consertos nasceram de sondas. É a evidência mais forte desta obra a
favor do passo 2 do fluxo da casa — medir antes de desenhar.

5. 🗺️ O ciclo completo — 12 passos

Cor do veredito = critério 3 (o que sobra em disco). É o critério do defeito "eu esqueço".

# Passo O que sobra em disco
1 Princípios CLAUDE.md, relido toda mensagem
2 Dizer o que eu quero a spec
3 Tirar a ambiguidade nada próprio — a resposta depende do resumo
4 Medir a premissa o número, só se eu escrever ⚠️
5 Decidir o como a escolha, só se eu escrever ⚠️
6 Virar tarefas o plano é arquivo; o estado das caixas não ⚠️
7 Conferir antes de codar
8 Construir o código
9 Provar cada tarefa o commit — mas o portão de etapa está morto (o stage-sensor-gate.py da §3.4; a lei do carimbo é outro portão e esse morde, §3.13) ⚠️
10 Reconciliar plano × real em obra, card #888 ⚠️
11 Declarar conclusão ⬅️ corrigido 10/08: existe mecanismocarimba-blindado-do-cetico.py, ligado em SubagentStop, deriva o degrau do laudo do cético. Rendia 0 em 116 execuções e foi consertado em 2026-08-10 (§3.16) ⚠️
12 Guardar a lição escreve bem, devolve mal (69,9% mudas) ⚠️

O padrão: passo que produz arquivo sobrevive; passo que produz conversa evapora.
Os três vermelhos são exatamente os três que só produzem conversa.

Onde o spec-kit fica: cobre 7 dos 12 (passos 1, 2, 3, 5, 6, 7, 8). Não tem os passos
4, 9, 10, 11 e 12. Cinco a um a nosso favor — e o único que ele tem e nós não é o passo 7.


6. 📋 O mapa do fluxo atual

Vocabulário: manter = está bom · melhorar = existe e está furado · substituir =
o defeito é de desenho ·
descartar = não paga o que custa.

6.1 O que o dono digita

Comando Veredito Por quê
/sonda manter é a vantagem sobre todo o mercado — nenhuma das 13 ferramentas tem
/brainstorm manter
/plan melhorar vira arquivo, mas ninguém marca as caixas — 4 de 5 planos nunca tiveram uma marcada
/rapid manter
/validate manter é a porta única do cético, já decidido
/learn melhorar escreve bem, devolve mal
/compound manter
/explica manter
/compact manter
/gsd-* manter parcial usar map-codebase, debug, scan; ignorar os que duplicam /plan

6.2 O que roda sozinho

A coluna conferido é a D19 em forma de tabela: sem data, a linha é história, não estado.

Mecanismo Veredito Prova Conferido
Portão do commit (validate-commit.sh) manter a peça mais forte da casa; 8,8× mais rápido desde 10/08 (§3.12-B) 10/08
pre-compact-save.py + rehidratação manter funcionou nesta conversa, duas vezes 10/08
completeness-critic.py melhorar lê a fila do chat, não lê o plano 10/08
Guardião de lições substituir 3 vagas para 875 candidatas 10/08
stage-sensor-gate.py substituir 1.217 execuções, 1 bloqueio, falso 10/08
A LEI DO CARIMBO (validate-commit.sh:2216) manter ⬅️ promovido morde hoje, testado com guarda de mentira: saída 2. Os "171" eram ruído — foram 30, e só 3 contam (§3.13) 10/08
mutation_check.py manter e dar gatilho ⚠️ corrigido 2×: ele não está solto — está no pre-deploy.sh:41, atrás de um opt-in de três variáveis. E não tem zero execuções: são 20, à mão, em 3 dias distintos. A frase "lei dos 9% chegando a 0%" cai junto 11/08
meta_da_obra.py melhorar o próprio código confessa que depende de eu lembrar; 9% de adoção (§3.8) 10/08
unified-context-injector.py melhorar ✅ o defeito de §3.7 foi consertado (4f5b8cf77). O "melhorar" que sobra é outro: ele escolhe 3 de 875 lições por turno 10/08
carimba-blindado-do-cetico.py ⬅️ novo no mapa manter — ✅ consertado nasceu 10/08 e rendia 0 em 116 execuções; agora acende (§3.16, 3e26c68a4) 10/08
painel_de_guardas.py ⬅️ novo no mapa manter — ✅ consertado escondia 359 bloqueios reais atrás de um nome de campo (§3.17, 046510e42) 10/08
Trava do 4º arquivo sem plano manter 10/08
pede-ok-antes-de-subir.py manter 10/08

Placar atualizado: 16 manter · 5 melhorar · 2 substituir · 0 descartar. (Duas peças entraram
no mapa nesta revisão — elas existiam e não estavam listadas, que é a mesma falha da §3.14-B.)

# a conferência do mutation_check, que derrubou o "nunca ligado a nada":
grep -n "mutation_check" scripts/quality/pre-deploy.sh

6.3 Onde as coisas ficam

Lugar Quanto tem Veredito
specs/ 321 documentos melhorar — é acervo, não índice vivo
Cards no roadmap ~900 melhorar — fila que só cresce
Lições 875, com 612 mudas substituir a devolução
memory/ estado da sessão manter
scripts/quality/ 29 ferramentas de medição manter — vale ouro e está subusado

Placar: 14 manter · 6 melhorar · 2 substituir · 0 descartar.


7. 🏆 O placar dos projetos de fora

Confiança: medido = li o código ou a issue · inferido = deduzido do desenho.

Projeto 1 julga 2 recibo 3 sobrevive 4 falha alto 5 custo fácil 6 desfaz
spec-kit 126k ⭐ ⚠️
OpenSpec 64k ⚠️ ⚠️ ⚠️
ruflo 67k ⚠️ ⚠️ ⚠️
BMAD 51k ⚠️ ⚠️ ⚠️
task-master 28k
ccpm 8,3k ⚠️
groundtruth 7 ⭐ ⚠️ ⚠️ ⚠️
AWS Kiro fechado ⚠️ ⚠️ ⚠️ ⚠️

As colunas 3, 5 e 6 são inferidas — nenhuma ferramenta foi instalada.

7.1 Por que nenhum é 10/10 — as quatro brigas

A briga Quem paga o preço
Juiz rigoroso × custo no caso fácil recibo custa trabalho em toda tarefa, inclusive a boba
Falhar alto × ser adotado medido aqui: o que avisa = 3.013 commits; o que bloqueia = 0
Memória perfeita × contexto limpo recarregar tudo piora a resposta
Servir a todos × ser forte só dá pra conferir o que é universal: "o arquivo existe?"

A quarta é a decisiva, e é visível no placar: a única ferramenta que julga com máquina é a
menor por três ordens de grandeza (7 estrelas contra 126 mil). Não é talento — é
consequência. Portão que serve a 126 mil pessoas só pode conferir o que é universal.

O que isso significa aqui: o motivo de nenhum ser 10/10 é o motivo pelo qual o nosso
pode ser — um usuário, uma máquina, um jeito de rodar teste.

O preço: ninguém mantém a nossa ferramenta. Bug no spec-kit, 126 mil pessoas acham. Bug
aqui, o dono acha — e a conta já é 673 consertos/mês contra 363 do produto.

7.2 📏 A régua da indústria para guardas — o que sobreviveu ao cético

Pergunta do dono, literal: "qual o sweetspot pra guardas em hooks? quero entender onde
podemos estar errando"
. Cinco ângulos + síntese + cético, 2026-08-10. O cético derrubou 3 dos
5 movimentos e 2 dos 5 achados — o que está abaixo é só o que
sobrou de pé.

A frase que resume: a indústria não limita quantos porteiros o prédio tem — limita quanto
tempo você fica parado na portaria.
Nós vínhamos contando porteiro.

Dimensão A régua de fora Nós
Guardas por pessoa Google: ~100 analisadores para 25 mil engenheiros 238 em disco / 191 registrados para um dono — ⚠️ o "218" de 10/08 era um quinto denominador; a casa tem quatro em circulação (§3.19) 🔴
Teto de quantidade publicado não existe para harness de agente — procurado por 5 ângulos
Proporção do acervo que fica ligada ESLint: 65 de 294 = 22%; o resto existe e custa zero 100% — não temos o estado "existe mas está desligado". ⚠️ Conferido em 11/08 e continua verdade: o único análogo achado é o mutation_check.py, que está desligado por opt-in de ambiente, não por um estado declarado 🔴
Utilidade mínima Google desliga acima de 10% de falso positivo efetivo ⚠️ INDEFINIDO. O "72 de 187 (38,5%) nunca fizeram ato útil" foi refeito: são 44 que não registraram ato numa janela de 25,6 h — e a palavra "nunca" não se sustenta (§3.19)
Manutenção da própria ferramenta teto de 50% (regra de toil, SRE) 55,3% nos guardas · 52,1% na ferramentaria — acima do teto, mas em patamar, não acelerando (§3.11) 🔴
Avisar × bloquear Facebook: a mesma análise deu ~0% de adesão em lote noturno e >70% falando no momento da decisão 83,4% × 0 aqui — o mesmo formato ✅ nosso padrão está certo
"Peça nova apaga a velha" é literalmente a política publicada de depreciação do ESLint D8/D18

O único movimento que sobreviveu ao cético — e que morreu na sonda, um dia depois. Era
um despachante por evento, em vez de dois processos Python por guarda: hoje 230 dos 233
ganchos rodam python _medidor.py <guarda>, dois arranques cada. O teto estimado era 4,2×
(o "8,16×" que a síntese citou não existe na fonte — o cético abriu o benchmark: são 6,75× /
8,36× / 12,85×, e a causa lá é reescrita em Rust, não despachante)
.

Este movimento está MORTO desde 2026-08-10. A sonda de §3.12 mediu o que a régua de
fora não podia saber: os guardas de um lote já rodam em paralelo. Um despachante
sequencial seria 2,7× PIOR; paralelo daria 1,46×, não 4,2×. Foi a D3 sendo
confirmada por medição própria depois de ter sido tomada por estimativa. O que a régua de
fora devolveu de útil não foi o movimento — foi a régua
(D20: a pergunta certa é "quanto
tempo se fica parado"
, não "são guardas demais?").

A ressalva que o cético fez e que fica registrada: nenhuma fonte de fora cobre o eixo em que
somos genuinamente diferentes — o guarda aqui fala dentro do contexto de quem decide, não
num relatório que alguém abre depois. Toda transferência de número acima é por analogia, não
por equivalência.


8. ❌ O que fica de FORA — e por quê

Seção obrigatória. Lição P884: eu estreito o escopo em silêncio e o dono precisa perguntar
"cadê o resto?".

Ideia Por que não entra
Trocar o modelo de spec pelo spec-kit D1
Podar guardas para ganhar tempo D2 — 5,8 min/dia
Porteiro único D3 — relógio não é o gargalo
~~Escrever teste para cada guarda~~ ⚠️ A EXCLUSÃO PERDEU A BASE em 11/08, e em 19/08 ganhou NÚMERO — que não a ressuscita. O motivo era "guarda com teste conserta 3,35× mais", e a razão foi calculada com os dois grupos trocados (§3.3). Medido em 19/08 com grupo de controle limpo (âncora 43d404e68, 81 com prova · 73 sem): o efeito real é 1,18×, e um placebo (feat/docs/chore contados como conserto) dá 0,82× — o controle negativo que fecha o caso. Mas 1,18× REMOVE A CONDENAÇÃO, não apoia a proposta: é quase-nulo, é correlação (quem escreve prova escolhe o guarda que já dá trabalho), e o custo de escrever as ~73 provas que faltam nunca foi medido por ninguém. É esse custo, não o 3,35×, que decide. A ideia segue na mesa, sem defesa e sem condenação — o que mudou é que agora se sabe exatamente o que falta medir para tirá-la de lá. Recontar: python scripts/quality/custo_de_manutencao_do_guarda.py
Atestado de proveniência (SLSA / Sigstore / in-toto) desenhado para cadeia com várias partes; aqui não há contraparte
Recibo para o carimbo de jornada copiaria um recibo falso — o marcador nasce de qualquer chamada ao navegador
Segundo portão para guarda novo já existe e bloqueia (validate-commit.sh:2216)
Mutação no gancho de fim de turno o gancho tem teto de 15 s; mutação leva ~600 s
Freio na pilha de guardas como portão não há evento de gancho que o sustente — vira doutrina, não trava
Perguntar "qual é a raiz?" não existe UMA raiz em sistema complexo; a pergunta que funciona é "quantos remendos já estão neste mesmo lugar?"

8.1 O que a revisão de 2026-08-10 não conferiu

A P884 obriga esta lista: eu estreito o escopo em silêncio e o dono precisa perguntar
"cadê o resto?". Aqui está o resto, dito antes de ele perguntar.

Não conferido Por quê Risco de estar velho
~~Os números da §3.1 a §3.11~~ CONFERIDOS em 11/08 (§3.19) — e a aposta de que "a ordem de grandeza não muda" estava errada em três deles. O 87 estava invertido, a inversão tinha 3 semanas e não 1 dia, e a linha de 30 dias não fechava com a própria aritmética resolvido — e a lição é que "ordem de grandeza não muda" não é motivo para não conferir
Os números do Roadmap (§3.18) ⬅️ nova os cards moram no Postgres; a sonda de 11/08 foi só-leitura de git e disco 🟡 médio — é a maior mancha que sobrou
A causa de 61,6% dos consertos de guarda ⬅️ nova a classificação de 11/08 leu o assunto do commit; a causa mora no corpo ou no diff 🟡 médio — desenhar sem isso é apostar na família que sobrou por acaso no topo
Seis peças da §6.2 (o crítico de completude, o guardião de lições, o sensor de etapa, o quadro de metas, a trava do 4º arquivo, o pede-OK) não foram tocadas pela obra dos guardas; conferi só as cinco que a obra mexeu 🟢 baixo — e a data 10/08 está na tabela, que é exatamente o ponto da D19
O placar das ferramentas de fora (§7) D1 decidiu não instalar nenhuma; as colunas 3, 5 e 6 seguem inferidas e vão seguir 🟢 baixo — a decisão já foi tomada
O quinto desenho ele não existe. Esta revisão arrumou o diagnóstico, não escreveu solução

9. ⚰️ Por que os desenhos morreram — sete, quase todos pelo mesmo defeito

Registrado para não ser re-proposto. Todos devolveram SOBREVIVE: NÃO de um cético de contexto limpo.

9.-2 O sétimo desenho (2026-08-11, noite) — morreu três vezes, por três motivos diferentes

Três céticos em paralelo, lentes distintas. Os três devolveram SOBREVIVE: NÃO — foi a
primeira vez nesta obra que o placar deu 3 de 3. Nenhuma linha de código escrita.

O que ele propunha: o gancho plan-approved-task-tracker.py, que roda na aprovação do plano
e já recebe o texto inteiro dele, passaria a ler o plano e a nomear as tarefas sem comando
de prova, no lembrete que ele já injeta.

Lente Causa da morte
R2 o alvo O mecanismo é prosa, e prosa não move nada — agora medido. O MESMO envelope faz dois pedidos: um vira chamada de ferramenta (TaskCreate, 97,6% de adesão) e o outro vira texto meu (o placar N/M, 1,70% — reproduzido por mim em 1,93% sobre 34.801 mensagens). O bloco novo era da família do placar. Adesão esperada: 2 a 6%, abaixo da própria lei dos 9% crítico
R3 o interpretador 91,5% de falso positivo, medido implementando a regra. Ela acusaria 71 tarefas nos 19 planos e 65 das acusações são falsas. O teto da casa é 10% (D20); o pior falso-alarme já aberto aqui é 38,7%, e 13,2% já pagou uma obra inteira de conserto crítico
R1 a premissa O número que justificava a obra não se reproduz. Mesmo corpo, 22,1% a 67,3% conforme o critério. A mitigação — "o parser bate 72/124" — valida o interpretador contra ele mesmo, e pior: o medidor foi descartado, então o alvo nem é reconstruível crítico
" Três fatos meus caíram junto: mutation_check.py tem 20 execuções e não zero · são 26 chamadas de aprovação e não 5 · 6 planos sem estrutura de tarefa e não 3 ⚠️ alta
O exemplo O caso escolhido à mão para VENDER o desenho era ele próprio um falso positivo: a tarefa que eu citei como "sem prova" já traz Verificação: clique real no dashboard resolvendo um par de simulador. Metade dos exemplos da proposta acusava tarefa correta crítico

🔎 A causa raiz é a mesma de sempre, num disfarce novo. Desta vez eu não li a spec em vez do
código — eu medi. Mas medi com régua que eu mesmo escrevi, sem gabarito contado à mão, e
tratei o resultado como fato. É a lição P935, registrada por mim duas horas antes, cometida
dentro do desenho escrito para consertar medição.

O que sobreviveu, e é bastante:

  1. A porta está certa. O gancho é o único ponto do ciclo em que plano, pedido e código
    coexistem — e nenhum guarda da casa lê plano (CA-1 confirmada por três varreduras).
  2. O canal entrega. Achado o registro literal da injeção chegando ao modelo: 90 ocorrências
    em 18 conversas. Não é o defeito do sexto desenho.
  3. A raiz é outra, e os dois céticos convergiram nela sem combinar: o problema não é ler o
    plano depois de pronto — é que não existe gramática para escrevê-lo (15 formas de marcar
    tarefa × 23 de declarar verificação; ~1 forma nova por plano). O lugar do conserto é onde o
    plano nasce, não onde ele é lido.
  4. Duas saídas sem parser nenhum, ambas com falso positivo estruturalmente impossível:
    pendurar o pedido na carga do TaskCreate (que é gravada, vira estado derivado e um sensor
    0/1 pode conferir depois, no portão do commit) · ou injetar "o plano está em X, releia as
    verificações antes da 1ª edição"
    usando o planFilePath que já chega — sem acusar ninguém
    por nome
    .

9.-1 O sexto desenho (2026-08-11) — morreu por não ter perguntado quem decide

Três céticos em paralelo, lentes distintas. R1 (a peça já existe) devolveu SOBREVIVE: SIM;
R3 (isto acerta o alvo?) devolveu SOBREVIVE: NÃO com 4 achados altos ou críticos. Nenhuma
linha de código foi escrita.

O que ele propunha: dar gatilho automático à colheita de cobertura dos guardas (o plugin
escuta_de_canarios carregado por conftest.py), e transformar o artefato de fotografia em
livro-caixa com data por guarda.

Movimento Causa da morte
Todo o desenho O número que ele refresca não decide nada. Um consumidor executável, que imprime numa linha de resumo do bootstrap, fora da lista de alarmes, num script que devolve 0 sempre crítico
" 70% do problema vem de REGRA, não de idade. Dos ~100 "sem rede", 71 foram VISTOS pela colheita e descartados de propósito ("rodar vazio não é julgar"); só 35 são de fato desconhecidos. A distância entre 25 e 102 está documentada no produtor como escolha de régua crítico
O gatilho (M1) A outra metade do encanamento continua sem gatilho. derivar_canarios.py — quem escreve o arquivo — tem zero chamadores executáveis. Ligar só o ouvinte pagaria o custo na bateria inteira com o arquivo seguindo congelado ❌ alta
" Contradição interna: a seção "o que não entra" excluía o portão do commit, e o conftest.py da raiz alcança o portão por construçãovalidate-commit.sh roda pytest da raiz em 5 pontos. O risco do remendo global iria parar dentro de portões que fazem exit 2 ❌ alta
" O precedente citado não é simétrico: teto_de_subprocesso tem aplica()/devolve(); escuta_de_canarios remenda no corpo do módulo e nunca desfaz ⚠️ alta (achado do R1)
A absorção declarada (D8) absorver um ritual que nunca rodou é trocar 0 execuções por N. D8 declarada, não cumprida ⚠️ média

O que sobreviveu, e é pouco mas é real: o princípio do livro-caixa (o recibo é derivado,
não escrito à mão — D13 cumprida, ao contrário do quinto desenho), e um conserto de 1 linha
no consumidor
que entrega a parte honesta sem nada do risco (§3.20).

🔎 A causa raiz da morte, e ela é a mesma de sempre: eu construí sobre a frase "toda
decisão depende deste número"
, que estava nesta spec e que eu nunca conferi no código.
É a D19 violada dentro da seção escrita para denunciá-la — e a quinta vez que um desenho
morre por ler o documento em vez do repositório.

A diferença desta vez: o cético que matou levou 18,5 minutos e o desenho nunca entrou
na spec como proposta. O ciclo de matar ideia ruim continua barato.

9.0 O quinto desenho (2026-08-10, tarde) — morreu em 7 minutos, e isso é o progresso

Três céticos, três lentes, 1.135 s o mais longo. Contra os 15 ajudantes e 1,82 milhão de
tokens
que o quarto levou para morrer.

Movimento proposto Causa da morte
A palavra de conclusão vira trailer do commit a peça já existe e é melhorcarimba-blindado-do-cetico.py, derivada, nascida às 06:36 do mesmo dia. Pela quinta vez, o mesmo defeito ❌ crítico
" o mecanismo do Leigo: é o gancho commit-msg, que só recebe o caminho da mensagem — sem transcrito, sem sessão, sem o meu texto. 3 das 4 responsabilidades do guarda que eu ia absorver dependem do texto ❌ crítico
" o trailer é escrito à mão e afirma fato não verificável, contra a própria D13. Precedente na §3.4: o formato que afirma fato tem 0 commits; o que não afirma nada tem 83,4%
O aviso do fecha #N já existe há 22 dias, instalado em .git/hooks/commit-msg. E a coluna que eu ia consultar (doing) não existe no roadmap ❌ crítico
Guarda de texto nasce com replay o princípio sobrevive — é a única ideia que ataca manutenção de frente. O mecanismo não: a lei em que eu ia me apoiar só cobra de quem bloqueia, e 4 dos 5 alvos só avisam; e o replay exige um contrato que 3 de 186 guardas cumprem ⚠️ meio
(a régua que sustentava tudo) morreu junto — ver §3.15 ❌ crítico

A diferença desta vez: nenhuma linha de código foi escrita, o desenho nunca entrou na spec, e
o custo total foi de três agentes. O ciclo de matar ideia ruim ficou ~250× mais barato — que
é, sozinho, o único resultado de desenho que esta obra produziu até agora.

9.1 O quarto desenho (2026-08-10, tarde) — e este morreu por culpa desta spec

Duas varreduras, 15 ajudantes, 1,82 milhão de tokens. Dois relatórios, dois SOBREVIVE: NÃO.

Proposta Causa da morte
Recibo no injetor de contexto já estava construído há 8 horas — commit 4f5b8cf77, do mesmo dia. O desenho leu a §3.7 desta spec, que ainda dizia "defeito vivo", e não leu o código ❌ crítico
Conferente antes de codar, no gancho do plano o gancho de plano disparou 4 vezes em 2,73 dias; o guarda que ele apagaria disparou 1.486. Trocar 1.486 por 4 e chamar de "100% de cobertura" é a lei dos 9% ao contrário ❌ crítico
"43 guardas acordam em todo comando" falso: 19 já são filtrados por condição, só 12 estão soltos
"[allow-spec] é a senha do portão" ele não abre portão nenhum — só silencia um aviso (validate-commit.sh:721-724)
"O portão do carimbo foi consertado em 30b7138f2" aquele commit era sobre quebra de linha. O diagnóstico real segue de pé: o portão morre de relógio, com 446 s de teste na frente dele (validate-commit.sh:287-297)
Mexer na memória de lições arrancaria uma peça declarada indivisível (zera-marcadores-da-sessao.py:44-52) e ressuscitaria o update-scores.py, aposentado por medição
"despachante único dá 8,16×" número emprestado e inflado. A correção da época dizia 4,2× — e essa também caiu no dia seguinte: medindo, sequencial é 2,7× PIOR e paralelo dá 1,46× (§3.12) ⚠️

A causa raiz, e ela é minha: consertei um guarda de manhã, não atualizei a spec, e à tarde
o desenho inteiro foi construído em cima do texto velho. A spec que existe para ser a fonte da
verdade virou a fonte do erro. Decisão que nasceu daqui: D19.

9.2 O primeiro desenho (2026-08-10, manhã)

Portão proposto Causa da morte
Pedir o carimbo no commit apresentado como "só avisa", mas seu único efeito é acionar um bloqueador — e o bloqueador mordeu 1 de 1 vez, errado
Recibo para jornada clonaria um recibo falso
Guarda novo prova que apita já existia no mesmo arquivo e no mesmo gatilho, há meses
Freio na pilha não nomeia evento de gancho
Blindado exigir mutação impossível no evento escolhido (15 s × 600 s)

Três defeitos do portão existente, reproduzidos:

Defeito A prova
Barra comando que não é commit casa git commit dentro de string de outro código
Um ponto-e-vírgula desliga o portão ele corta o comando no primeiro ;, e os commits reais usam ;
O carimbo visual é recibo falso nasce de qualquer chamada ao navegador, sem vínculo com teste, arquivo ou resultado

10. ❓ Perguntas abertas

Ordenadas por quanto elas travam o próximo passo. As respondidas ficam, com a resposta —
apagá-las faria a pergunta voltar em duas semanas.

A FILA DE PESQUISA ESVAZIOU EM 2026-08-19 (meta 15 da obra os-vigias-do-harness).
14 das 17 perguntas têm veredito: 8 respondidas, 6 aposentadas com o motivo escrito.

As 3 que sobram não são pesquisa — são o desenho do G2 em si, e por isso não se
respondem lendo nem medindo: como atacar o esquecimento (🔴 é a obra, 5 desenhos mortos
atrás dela), onde se conserta — onde o plano é lido ou onde ele nasce (🔴 a pista viva) e
o quinto desenho move a coluna manutenção (🔴). Enquanto elas estiverem abertas, o que
falta é decidir e construir, não medir.

⚠️ Aposentar não é resolver, e a diferença está dita linha a linha. Três delas
(N provas · a lição é a certa · os 115 guardas prestam) foram aposentadas porque
responder exigiria um gabarito humano ou um sinal de utilidade que não existe — o preço
de reabrir está escrito em cada uma. Duas (colunas do placar · passo 7) não eram
pergunta: eram decisão já tomada, guardada em forma de dúvida.

Pergunta Estado Trava o desenho?
Como atacar o esquecimento? ⚠️ varredura feita (15 ajudantes); o desenho morreu cinco vezes, o diagnóstico ficou (§9) 🔴 sim — é a obra
Onde se conserta: onde o plano é LIDO ou onde ele NASCE? ⬅️ nova, 11/08 ⚠️ a pergunta mudou de lugar depois de 3 céticos. Ler o plano depois de pronto e acusar por nome dá 91,5% de falso positivo, porque não há gramática: 15 formas de marcar tarefa × 23 de declarar verificação, ~1 forma nova por plano. Os dois céticos convergiram, sem combinar, em consertar onde o plano nasce 🔴 sim — é a pista viva
As N tarefas têm N provas? ⬅️ nova, 11/08 ⚰️ APOSENTADA 19/08 como pergunta de MEDIÇÃO — ela não tem resposta e agora se sabe o custo de ter uma. Para respondê-la seria preciso um gabarito contado à mão: alguém lê os 19 planos e marca tarefa por tarefa. Enquanto não houver, todo medidor automático continuará devolvendo um número diferente (3,6% · 22,1% · 58,1% · 67,3% · 87,1% — cinco medidores, cinco respostas). A pergunta útil que sobrevive no lugar dela é a linha seguinte (onde se conserta), porque ela não precisa deste número. Segue registrado: ❌ SEM RESPOSTA CONHECIDA, e isto é o achado. Não há gabarito contado à mão; o mesmo corpo de 19 planos já devolveu 3,6% · 22,1% · 58,1% · 67,3% · 87,1%. O que se sabe com segurança é só o nível do plano: 19 de 19 (100%) 🟡 parcial
D6 (zero descartes) briga com D8/D18 (peça nova apaga velha) RESOLVIDA 19/08: a briga era falsa, e o terceiro caminho a dissolve (linha abaixo). Elas só se opõem se existir e custar forem a mesma coisa; medido, não são — o custo segue a atenção, e o efeito residual é 1,19×. D6 guarda a EXISTÊNCIA, D8 guarda a SUPERFÍCIE ATIVA, e um terceiro estado (congelado) satisfaz as duas ao mesmo tempo. O que falta é o estado no contrato, não a decisão. Contexto anterior, que continua valendo: ⚠️ COM PREÇO (11/08, §3.19): o conjunto que a D6 protege custa 7,70% do conserto em 90 dias, e só 6 de 86 commits foram dedicados a ele. A briga vale menos do que parecia — e a régua que a decidiria não existe: "nunca bloqueou" mede 25,6 h, não a vida do guarda 🟡 parcial — deixou de ser bloqueio de primeira ordem
Existe um terceiro caminho para a briga D6×D8? ⬅️ nova, 11/08 RESPONDIDA 19/08: existe, tem dois nomes na indústria, e nenhum deles é "desligar". (a) Depreciação sem remoção — ESLint: a política publicada diz que uma regra nunca é removida, salvo se outra regra a substituiu; depreciada, ela entra em congelamento de manutenção — a equipe deixa de fazer conserto, melhoria e até atualização de documentação dela, e ela segue utilizável. (b) Estado opcional com graduação — Tricorder, do Google: análises nascem opcionais, melhoram por feedback, juntam usuários e graduam para ligada-por-padrão. Em janeiro de 2018 eram ~70 análises opcionais e 2.500 projetos com pelo menos uma ligada. O critério de promoção é o mesmo que esta casa já cita em regua_de_corte.py: ruído acima de ~10% derruba do caminho. Por que isto resolve a briga: D6 (zero descartes) e D8 (encolher a superfície ativa) só brigam se "existir" e "custar" forem a mesma coisa. Medido nesta casa em 19/08 (scripts/quality/custo_de_manutencao_do_guarda.py), não são: o custo de um guarda segue a ATENÇÃO que ele recebe (toques), não a existência dele — a razão bruta de 2,68× vira 1,19× depois do controle. Guarda congelado custa perto de zero. O que falta pra usar: o contrato Guarda (onde_moram_os_guardas.py:128) não tem terceiro estado — estar no censo é estar ativo. Acrescentá-lo é acréscimo, não modificação, mas todo consumidor passa a ter que tratá-lo, e é aí que o preço aparece. ⚠️ O 294/65 = 22% citado na versão anterior desta linha NÃO se confirma: a página de regras do ESLint não publica totais agregados. O que se confirma é a POLÍTICA, que é mais útil — e ela fala em congelar, não em desligar. ~~❌ não investigado.~~ O ESLint entrega 294 regras e liga 65 (22%) — nada vai pro lixo (D6 satisfeita) e a superfície ativa encolhe (objetivo da D8). A §7.2 registra que não temos o estado "existe mas está desligado", e a sonda confirmou: o único análogo é o mutation_check.py, desligado por variável de ambiente, não por estado declarado 🔴 sim
Por que o carimbo de conclusão rende zero? RESPONDIDA e CONSERTADA 10/08 (3e26c68a4): ele lia o arquivo do laudo, e o bloco de escopo vive na resposta — 13 de 17 casos. Provado: 17 laudos reais, 0 → 4 carimbos (§3.16)
O quinto desenho move a coluna 🧹 manutenção? ⬅️ nova em aberto. Dos quatro movimentos já feitos, nenhum ataca manutenção (§4-B) — e é o eixo em que estamos a 3-4× da referência 🔴 sim
Quantos guardas a casa tem? ⬅️ nova, 11/08 RESPONDIDA 19/08: são 211, e os três leitores concordam. A unificação da meta 18 (18/08) matou a pergunta e ninguém voltou pra fechá-la. Medido hoje, chamando cada um: onde_moram_os_guardas.guardas_declarados() 211 · fiscais-mudos.registro() 211 · porta_foi_usada.registro_completo() 211 — e o painel imprime 211 no cabeçalho. Os quatro números antigos (238 · 191 · 184 · 218) eram quatro leituras próprias do mesmo settings.json; hoje há uma fonte só e os outros são cascas sobre ela. (238 era contagem de ARQUIVO em disco — hoje 241 — e nunca foi denominador de guarda registrado: a pasta tem peça compartilhada e teste dentro.) ~~❌ quatro denominadores em circulação~~ — 238 em disco · 191 no painel · 184 no fiscais-mudos.py · 218 no briefing de ontem. Dois scripts da própria casa discordam em 7 lendo o mesmo settings.json. Sem denominador único, toda percentagem sobre "os guardas" é indefinida 🟡 parcial — mas envenena todo número relativo
"Escrever teste para cada guarda" volta para a mesa? ⬅️ nova, 11/08 VOLTA — a acusação foi medida e vale 1,18×, não 3,35×. O motivo da exclusão era o custo de manutenção; com o controle aplicado (linha acima) ele quase desaparece. Isso não aprova a ideia: o preço de escrever as provas que faltam continua sem número, e o painel mede hoje 68 guardas sem rede. O que mudou é que a objeção que a barrava não se sustenta. ~~⚠️ a condenação caiu, a absolvição não veio.~~ O motivo da exclusão (3,35× mais conserto) foi calculado com os grupos trocados (§3.3). A ideia não está aprovada — está sem número, dos dois lados 🟡 parcial
Os outros 115 guardas prestam? ⚰️ APOSENTADA 19/08 — impossível hoje, e a causa é ESTRUTURAL, não falta de esforço. São dois buracos independentes, e nenhum se fecha medindo mais: a telemetria não registra "o que este guarda falou mudou uma decisão" (a métrica que o Google usa), e a janela dela é sobra de poda automática — remedida em 32,7 h. Cortando a amostra ao meio, 15% dos guardas trocam de rótulo. Reabrir exige construir o sinal de utilidade, que é obra própria. Segue registrado: ❌ impossível julgar hoje, e 11/08 mostrou por que é pior do que parecia: a telemetria não conta "o que este guarda falou mudou uma decisão" (a métrica do Google) e a janela dela é o resto de uma poda automática — remedida em 19/08: 32,7 h (194.531 registros, 48,7 MB), ainda encolhendo conforme a casa fica movimentada; recontar pelo primeiro e o último ts de .claude/.fiscais-medidos.jsonl. Cortando a amostra ao meio, 15% dos guardas trocam de rótulo 🟡 parcial
A lição devolvida é a certa? ⚰️ APOSENTADA 19/08 pelo mesmo motivo da linha acima, e com o mesmo preço dito. Responder exige gabarito humano — alguém marcando, para uma amostra de mensagens, qual lição deveria ter voltado. Sem isso só se mede variedade, e variedade sozinha premia errar mais espalhado. Enquanto ninguém pagar o gabarito, a resposta honesta é "não sabemos", não um número novo. Segue registrado: ❌ não existe gabarito. Sem ele só dá pra medir variedade — e variedade sozinha premia errar mais espalhado 🟡 parcial
"Guarda com teste = 3,35× mais conserto" significa o quê? RESPONDIDA 19/08: significa quase nada — e o número é 1,19×, não 3,35×. Refeita com os grupos certos e o controle já corrigido por cético independente (âncora 43d404e68 · 2026-08-19): 81 com prova · 73 sem — só quem a colheita VIU. ⚠️ E há 44 guardas (22%) FORA DA CONTA: a colheita nunca os viu, e o descarte não é neutro — 18 deles TÊM prova, são os mais novos, e guarda novo recebe mais conserto na janela. O placar passou a anunciar isso (achado de cético de fecho, 19/08); consertos por guarda 5,37 vs 2,37 = 2,27×. Aí entra o CONTROLE que faltava — contar TODO toque, não só conserto: 8,19 vs 4,25 = 1,93×. Ou seja, guarda com prova é simplesmente guarda mais mexido. Normalizando, a fração de toques que são conserto fica 65,6% vs 55,8% — 1,18×. O efeito real é um décimo do que a frase original acusava. ⚠️ Continua sendo correlação, não causa: quem escreve prova escolhe o guarda que já dá trabalho. ⚠️ A 1ª versão desta linha dizia 130 sem / 2,68× / 2,25× / 1,19×, e o grupo de controle estava CONTAMINADO — montado por subtração, ele fundia "medido e não tem" com "nunca foi visto" (56 de 129), e ainda carregava 12 guardas registrados sem arquivo no disco, entrando como zeros. Derrubado por cético; a conclusão sobreviveu porque o residual é fração de somas e é imune a zeros — sobreviveu pela métrica escolhida, não pelo método. Lição P1122. Recontar (o controle é obrigatório por construção — a peça não sabe imprimir a razão crua sozinha, e o placar traz HEAD + data porque o censo muda entre rodadas): python scripts/quality/custo_de_manutencao_do_guarda.py. Rede: scripts/tests/test_custo_de_manutencao_do_guarda.py (12 provas, 6 de 6 mutantes mortos). ~~⛔ A PERGUNTA CAIU ANTES DA RESPOSTA (11/08).~~ Ela pressupunha que 87 era o grupo sem teste — e era o grupo com (§3.3). A razão inteira precisa ser refeita antes de significar qualquer coisa 🟡 parcial
O passo 7 (conferir antes de codar) tem justificativa? ⚰️ APOSENTADA 19/08 — a resposta já está dada e é "não, e sabemos por quê". Segue registrada: ⚠️ enfraquecida: o /speckit.analyze inteiro tem zero sensores — os 6 passes são texto de prompt, e o único julgamento de máquina são 3 checagens de "o arquivo existe". Era o nosso único enxerto aprovado ⬜ não
As colunas 3, 5 e 6 do placar (§7) se confirmam rodando? ⚰️ APOSENTADA 19/08 — não é pergunta, é decisão já tomada. A D1 decidiu não instalar nenhuma ferramenta de fora; logo elas seguem inferidas por desenho, e não por dívida. Reabrir isto exige revogar a D1, o que é decisão do dono ⬜ não
A causa do truncamento em 50 RESPONDIDA 10/08: é o rtk, não o git. git log \| wc -l = 8.506; rtk git log \| wc -l = 50. Medida 3× (lente, cético, à mão). Manual global corrigido
Por que a lei do carimbo deixou passar 171 guardas? RESPONDIDA 10/08, e não eram 171: eram 30, dos quais 3 contam. Ver §3.13
O mutation_check.py está mesmo solto? RESPONDIDA 10/08, e a metade do número foi CORRIGIDA em 11/08: não está solto — vive no pre-deploy.sh:41, atrás de opt-in de 3 variáveis. Mas não tem zero execuções: são 20, à mão. O "zero" vinha de um medidor que só enxerga ganchos

11. 📚 Onde estão as pesquisas

O quê Onde
A pesquisa das 14 ferramentas, fact-check e crenças derrubadas pesquisa-spec-driven-e-harness.md
A spec derrubada, com os 4 erros escritos dentro guardas-quanto-cada-um-custa.md
O painel de progresso (obra irmã, card #888) painel-de-progresso-do-harness.md
A obra irmã do atrito de commit (outro chat, mesmo repositório) PLAN-fechar-o-atrito-do-commit.md
A régua de custo por guarda regua_de_corte.py
A bancada que mede guarda contra transcrito real replay_fiscal.py
A biblioteca de estudos do dono C:/Users/mrodr/OneDrive/Documentos/Estudos IA/

As redes que a obra deixou (101 testes, todos verdes em 2026-08-10):

Rede Testes O que ela impede de voltar
tests/test_familia_check_atalho_commit.py 57 alguém alargar o filtro por commit · guarda que libera falando no canal de erro
tests/test_protect_specs_atalho.py 25 a lista do atalho sair de sincronia com a lista vigiada
tests/test_guardas_lentos_atalho_barato.py 10 o atalho comer o veredito de quem deveria falar
tests/test_sync_memory_on_stop.py 9 a bomba de 600 s voltar · e declara a lacuna POSIX
tests/test_descarte_deixa_recibo.py 7 o injetor voltar a engolir bloco em silêncio

Fontes externas que mudaram o rumo:

Fonte O que ela fez
Bossavit, Leprechauns of Software Engineering derrubou a justificativa clássica: a curva "bug custa 100× mais tarde" é folclore
DORA 2026 (1.110 engenheiros do Google) adoção de IA sobe throughput e instabilidade juntos
The Specification as Quality Gate (arXiv 2603.25773) IA revisando IA tem erro correlacionado — separar o revisor não basta
SpecBench (arXiv 2605.21384) 97% na bateria visível, 0% na escondida
Senge, Shifting the Burden o remendo alivia e o alívio tira a pressão de consertar a raiz
Dekker / Allspaw "não existe uma raiz" — causas são cada uma necessária, só juntas suficientes

11-B. 👥 Dois chats no mesmo repositório — o desenho aguenta?

Pergunta do dono, 2026-08-10. Não é hipótese: há outro chat trabalhando no repositório
neste momento, na mesma branch, no
mesmo working tree (.git compartilhado via Dropbox).

O que foi medido

Medida — 90 dias, 5.941 commits Valor
Commits que tocam 1 área só 66,0%
Tocam 2 áreas 22,4%
Tocam 3+ áreas (zona de mistura) 11,0%
Tocam 4+ 3,2%
Válvula [commit-ok] usada 0 vezes
Válvula BYPASS_COMMIT_GUARD usada 1 vez

Peça por peça

Mecanismo Aguenta 2 chats? Por quê
Guarda de staging (p367-commit-staging-guard.sh) sim, e é o modelo feito exatamente pra isso, depois de a contaminação acontecer 3× · 0 usos da válvula em 90 dias
Trava do 4º arquivo sem plano conta só o que ela viu passar nesta sessão, não git status — foi corrigida por causa deste problema
Cards (banco Postgres) é servidor, tem transação
Acervo de lições já corrigido: a porta tem trava, depois de perder 3 de 120 linhas
Injetor de contexto roda por processo, não compartilha estado
meta_da_obra.py ⚠️ derivar do git é a decisão certa para dois chats (ambos veem a mesma verdade); o risco é a frase do commit casar com a meta do outro
memory/todos.md (espelho) ⚠️ dois chats regenerando o mesmo arquivo: a última escrita ganha
Esta spec, e toda spec buraco sério dois chats editando o mesmo .md no mesmo working tree = última escrita ganha, em silêncio

O buraco, com todas as letras

Working tree compartilhado não gera conflito de merge — gera sobrescrita. Se dois chats
editam o mesmo documento, o git nem percebe: não há duas versões para reconciliar, há um
arquivo e a última gravação venceu. A fonte da verdade é a peça mais frágil do desenho.

O que protege hoje Garantia
Um dono por obra (este chat nesta spec; o outro no painel) combinado, não travado
Commitar cedo e miúdo, encurtando a janela disciplina, ~90%
Estado importante derivado do git, não escrito à mão é a única saída 100% — e é por isso que o desenho do quadro de metas está certo, mesmo com 9% de adoção

Consequência de desenho, registrada: quanto mais estado desta obra for derivado
(lido do git, do banco, da telemetria) e menos escrito à mão, mais ela aguenta dois
chats. Toda vez que a escolha aparecer, ela pende para o derivado — e o motivo não é elegância,
é este.

12. ✅ Critérios de aceite

Carimbo de sensor: [logic] = teste automatizado · [journey] = fluxo ponta a ponta ·
[visual] = tela real. Estes são os critérios da fase de
desenho; os da construção nascem
quando o desenho for aprovado.

13. 🔬 Lentes de pesquisa

Lente O que devolveu
Em casa (busca em specs/, .claude/hooks/, scripts/) 4 das 6 peças já existiam: mutation_check.py (pronto, desligado), completeness-critic.py (bloqueia, lê a fila errada), replay_fiscal.py (mede guarda contra transcrito real), meta_da_obra.py. Mais regua_de_corte.py e painel_de_guardas.py. Os dois desenhos morreram por propor construir o que já estava lá
Fora (doc oficial, issues, nome do padrão) Os nomes: stage gate · evidence-based verification · durable execution · context rot · shifting the burden (Senge) · "there is no root cause" (Dekker/Allspaw). E o fact-check: 4 de 10 afirmações do texto que originou tudo eram falsas
O que o padrão CUSTA Portão forte só existe em ferramenta de um usuário — o de 126 mil estrelas só confere se o arquivo existe. O preço de poder ser forte: ninguém mantém a nossa ferramenta (673 consertos/mês contra 363 do produto)

Alternativas descartadas e o motivo: ver §8, que é obrigatória e não pode ficar vazia.

14. ⚠️ Estimativa de risco

Risco Probabilidade Impacto Mitigação
Somar à pilha em vez de reformar alta 34 guardas/semana viram 40 D8 — toda peça nova apaga ou absorve uma existente
Ligar portão que morde errado média trabalho legítimo barrado, e o dono perde a confiança na trava D9 — modo sombra primeiro, sempre
A ferramenta quebra e o produto para junto média dia de trabalho parado tudo reversível em um comando; nada substitui peça viva (D6)
Esta spec apodrecer alta vira mais um dos 321 documentos que enganam D10 — ela é a cobaia; CA-3 é o teste
Propor o que já existe alta (aconteceu 2×) obra inteira desperdiçada CA-1 — auditoria com comando registrado antes de qualquer proposta
A spec se contradizer ⬅️ novo altajá aconteceu, em 6 lugares pior que apodrecer: um leitor que abre no meio encontra o número derrubado afirmado como fato, e constrói em cima CA-4 + a regra de manutenção: número derrubado sai do texto e vira linha de registro, no passado (P904)
Ninguém revisar isto de novo média a revisão de 10/08 achou 6 defeitos em 1 dia de deriva; em 30 dias seriam mais nenhuma mitigação automática existe — é a única linha desta tabela sem rede, e fica dita

15. 🧱 Non-goals (NÃO mexer)

16. 🧭 Superfície que a obra cria

17. 🔒 Post-implementação

(Média e baixa viram NOTA: não contam pro fecho, não bloqueiam e não viram card.)

18. 📔 Diário de bordo

Data O que mudou
2026-08-10 Obra nasce. Pesquisa de 14 ferramentas concluída e commitada (934a4df). Primeiro desenho (5 portões) proposto e derrubado por cético no mesmo dia. Mapa do fluxo atual levantado: 14 manter, 6 melhorar, 2 substituir, 0 descartar. Decisões D1 a D10 registradas. Esta spec commitada em b69b312.
2026-08-10 (mesmo dia, depois) Varredura do esquecimento voltou — segundo desenho também derrubado (SOBREVIVE: NÃO). Dois achados entram na spec: §3.7 o injetor de contexto engole 37,1% do que deveria entregar, calado (verificado à mão); §3.8 o quadro de metas depende de eu lembrar, com 9% de adoção. Dois vereditos da §6.2 corrigidos. Commit b61032697.
2026-08-10 (mesmo dia, noite) Spec alinhada ao molde da casa — e o alinhamento revelou que ela nascera sem a linha de tamanho, passando pelo guarda em silêncio. Análise de dois chats no mesmo repositório (§11-B), com o index.lock do outro chat aparecendo ao vivo durante o commit. A lei dos 9% (§3.10) nasce da informação do dono sobre como ele usa o roadmap, e gera D11, D12 e D13. Terceira varredura disparada: spec que apodrece · fila que só cresce · contexto injetado · dois agentes · e um advogado do diabo contra a obra inteira. Nada foi construído ainda.
2026-08-10 (madrugada→manhã) O dono revoga o congelamento (D16), pede os três alvos juntos (D17) e autoriza guarda novo que apague um velho (D18). Manda medir o despachante antes de construir — e a premissa cai (§3.12): os guardas já rodam em paralelo. Nasce a régua de posse do relógio (D21). Manda acelerar os 5 mais lentos e consertar a bomba do #915 na raiz: a família dos seis sai de 1.024,4 → 116,8 ms (§3.12-B) e o sync-memory sai de > 600 s → < 5 s. A lei do carimbo é medida por replay em 6.002 commits e sobrevive (§3.13).
2026-08-10 (madrugada) Três céticos independentes sobre a obra dos guardas (§3.14). Um devolve SOBREVIVE: NÃO — a expansão nativa do bash não entende contrabarra, e o registro de todo bloqueio se perdia em silêncio. Consertado, com a rede que teria pego (stderr limpo). Descoberto de brinde que o diff curou um mudo pré-existente (git+TAB+commit). Sete lições no acervo: P894, P895, P899, P900, P901, P904, P905. Palavra: Blindado.
2026-08-10 (tarde — o quinto desenho e três céticos) Escrevi o quinto desenho em rascunho, fora da spec, e mandei três céticos independentes atacá-lo antes de qualquer linha de código. Os três derrubaram algo meu. O desenho inteiro caiu (§9.0) e, junto, a régua de custo por guarda que eu tinha criado pela manhã (§3.15) — ela dividia 90 dias por 1,3 dia e ia me fazer aposentar o 3º maior bloqueador da casa. Sobraram três fatos: o carimbo de conclusão roda 116× e rende zero (§3.16), o painel chama de bloqueios o que é bloqueios em PreToolUse (§3.17), e o custo de oportunidade tem nome — 62 cards de produto abertos, 13 há 50+ dias (§3.18). Entram D24, D25, D26. Custo de matar o desenho: três agentes, contra 15 ajudantes do anterior.
2026-08-10 (noite — a fila de "arrumar") O dono manda arrumar o harness primeiro, e a fila tinha dois itens, os dois consertados. O carimbo de conclusão (3e26c68a4): rodava 116× e rendia zero porque lia o arquivo do laudo, e a lista do que o cético examinou vive na resposta — 13 de 17 casos. Provado com o código real: 0 → 4 carimbos. O nome do campo do painel (046510e42): bloqueios contava um evento só, e 359 bloqueios reais estavam invisíveis em 4 guardas. Virou bloqueios_no_portao + bloqueios_fora_do_portao, com teste que impede o nome curto de voltar. Lição P908 registrada.
2026-08-10 (noite — SEGUNDA revisão completa da spec) O dono pede para conferir se tudo está anotado. Mais 15 defeitos, e o achado é sobre revisão em si: quase todos foram criados pela primeira revisão — contagens que ficaram para trás (13 medições onde há 20, 23 decisões onde há 26, 4 desenhos onde há 5), citações de número de linha que envelheceram em 3 horas quando eu mesmo mexi no arquivo, e o resumo de 60 segundos se contradizendo consigo mesmo em duas linhas vizinhas. E faltava conteúdo: a sonda que matou a poda dos guardas mudos (23,7% da manutenção) nunca tinha virado seção — virou a §3.14-B. Nascem CA-5 (medição que matou ideia tem seção própria) e a régua de que revisar cria contradição nova.
2026-08-11 (tarde — o sexto desenho nasce e morre no mesmo dia) Escrevi o sexto desenho em rascunho, fora da spec, e mandei três céticos em paralelo, com lentes distintas. R1 (a peça já existe) devolveu SOBREVIVE: SIM — confirmou que não há gatilho por nenhuma das formas de carregar plugin de pytest — mas derrubou um número meu escrito 3 horas antes: o "512 commits" era uma janela de 14 dias colada numa afirmação de "desde a foto"; o certo é 305. R3 (isto acerta o alvo?) devolveu SOBREVIVE: NÃO e matou pela raiz: o número que o desenho refresca não decide nada — um consumidor executável, que imprime numa linha de resumo, fora da lista de alarmes, num script que devolve 0 sempre. E 71 dos ~100 "sem rede" vêm de uma REGRA deliberada, não de defasagem; só 35 são de fato desconhecidos, e o próprio produtor documenta a escolha de régua. A frase que sustentava o desenho — "toda decisão de aposentar depende deste número" — era desta spec, e eu nunca a conferi no código: D19 violada dentro da seção escrita para denunciá-la. Registro em §9.-1. Sobrou um conserto de 1 linha no consumidor, que entrega a parte honesta sem nenhum do risco.
2026-08-11 (a sonda — e o dia em que a spec foi conferida contra o CÓDIGO, não contra si mesma) O dono manda seguir no harness. Em vez de escrever o sexto desenho, disparei uma sonda de 5 lentes + 5 céticos (11 ajudantes, 1.150.445 tokens, 37 min) sobre o eixo manutenção — porque a própria spec mede que 5 desenhos morreram e 6 consertos nasceram de sondas. Os cinco vereditos voltaram SOBREVIVE: NÃO e 20 números foram corrigidos (§3.19). Três invertem a leitura: o 87 era quem TEM teste, não quem está sem (§3.3, e isso derruba a exclusão de "escrever teste para cada guarda" na §8); a inversão está datada em 22-29/07 e é patamar, não aceleração desde ontem (§3.11); e a linha de 30 dias não fechava com a própria aritmética. A sonda matou as duas pistas que eu tinha para o sexto desenho — "reformar um punhado de arquivos" (74,2% do conserto está fora do top-5) e "a ferramenta é mais concentrada que o produto" (invertido: o produto é mais). Sobraram três fatos acionáveis: o mutation_check.py pronto e desligado, o canal único em 23,1%, e o validate-commit.sh com 68,5% do conserto da vida no último mês. Nasce a lição P927 (janela deslizante medida duas vezes não mede tendência) e a pergunta nova do terceiro caminho da briga D6×D8 (o modelo ESLint: 294 existem, 65 ligadas).
2026-08-11 (a cobrança da P884 — as 6 peças da pesquisa, conferidas no código) O dono manda voltar à obra. Em vez do sétimo desenho, varri no código as 6 peças que a pesquisa entregou e que o desenho anterior cobriu pela metade — a lente CA-1 rodando antes, não depois. Resultado em §3.21. Duas peças já existem com trava (sensor 0/1 no portão; juiz ≠ executor), uma está pronta e desligada (mutation_check.py, 0 de 187.911 linhas de telemetria), uma é decisão do dono (1 conversa = 1 tarefa). Das duas que a pesquisa marcava como buraco: "N tarefas = N provas" estava errada — a adesão é 83,1%, e o buraco real é que só 58,1% carregam comando executável, com 25% escrevendo prosa onde a regra pede comando. E "conferir antes da 1ª linha" ganhou nome e endereço: plan-approved-task-tracker.py roda no instante exato, recebe o texto inteiro do plano (4.604-9.957 caracteres, confirmado em 5 chamadas reais) e não o lê — a varredura de .claude/hooks/ por leitura de plano devolve uma fixture de teste. O medidor errou duas vezes antes de acertar (3,6% ancorado errado, 87,1% frouxo demais, 58,1% conferido à mão) — o anti-padrão "medidor caseiro" pego dentro da própria sonda. Nenhuma linha de código de desenho foi escrita.
2026-08-11 (noite — o sétimo desenho e o primeiro placar 3 a 0) O dono escolheu desenhar a abertura do envelope e com cético antes do código. Escrevi o sétimo desenho em rascunho e mandei três céticos de lentes distintas. Os três devolveram SOBREVIVE: NÃO — inédito nesta obra. R2 mediu o golpe em vez de argumentar: o MESMO envelope pede duas coisas, uma vira chamada de ferramenta (97,6%) e a outra vira prosa minha (1,70%; reproduzi em 1,93% sobre 34.801 mensagens) — e o meu bloco era da família da prosa. R3 implementou o meu interpretador e rodou nos 19 planos: 71 acusações, 65 falsas — 91,5%, contra teto de 10% da casa, num lugar onde 13,2% já pagou uma obra de conserto. R1 derrubou os números: o 58,1% vira faixa de 22,1% a 67,3%, o motor de mutação tem 20 execuções e não zero, são 26 chamadas de aprovação e não 5. E o exemplo que eu escolhi à mão para vender o desenho era ele próprio uma acusação falsa. Corrigidos 6 pontos da spec, incluindo o "zero execuções" que estava aqui desde 10/08 e que eu repeti sem conferir. O que sobrevive: a porta está certa, o canal entrega, e a raiz é a gramática ausente — 15 formas de marcar tarefa × 23 de declarar verificação. Duas saídas sem interpretador nenhum ficaram nomeadas.
2026-08-10 (revisão completa — "quero ele 10/10") Revisão da spec inteira contra o código, como manda a D19. Seis contradições internas encontradas e corrigidas: 76% afirmado como fato em duas seções que a §3.12 já havia derrubado para 22,5% · 171 guardas na §6.2 contra os 30/3 da §3.13 · o despachante ainda descrito como "o movimento que sobreviveu" depois de morto · 4,2× em §9.1 · e a afirmação de que o mutation_check.py nunca foi ligado a nada — o código diz que ele está no pre-deploy.sh:41, atrás de um opt-in de três variáveis, com zero execuções em 530 sessões. Entram a §0 (a obra em 60 segundos), a §4-B (a cobrança da D17 — qual alvo cada movimento move), o CA-4 (a spec não se contradiz, que reprovou e por isso existe) e as decisões D22 e D23. Decisões reordenadas.

As 18 Regras

Este arquivo é para o dono, não para a máquina. Ele explica, sem uma linha de jargão, as 18
regras que eu sigo para decidir se uma coisa vale a pena, como fazer, e quando dizer que acabou.
O texto técnico mora em .claude/skills/sweetspot/references/clausulas.md; aqui fica a versão
que se lê de fora.

⚠️ Isto é uma SEGUNDA CÓPIA das mesmas regras, e duas cópias divergem — então a ordem de quem
vence vai escrita na primeira tela.
Se este guia e o arquivo técnico discordarem sobre o que uma
regra manda, o técnico vence. A duplicação é decisão, não descuido: obrigar você a abrir o
arquivo técnico para entender a própria régua era o mesmo que não ter régua.

⚠️ Os números deste guia são fotografias, não medidores. Onde eu digo "48 de 189" ou "6.109
execuções"
, eu vi aquilo num dia. O placar que se atualiza sozinho fica em
.claude/skills/sweetspot/references/placar.md. Número que envelhece e ninguém reconta é a falha
que estas próprias regras mais perseguem
— e um guia leigo não pode carregar os comandos de
reconferir sem virar o jargão que ele veio substituir. O preço está dito: a ordem de grandeza
está aqui, o valor exato mora lá.


🏗️ ANTES DE TUDO: onde esta skill FICA — e o que ela nunca prometeu resolver

⚠️ Esta seção existe porque eu me perdi, e o dono viu antes de mim. Passei uma obra medindo
uma coisa e chamando de melhoria da sweetspot. Eram camadas diferentes, e confundi-las é o
erro mais caro que dá para cometer aqui.

A pergunta dele, que abriu o assunto: "isso é pra skill sweetspot ou é outro conceito?"
Era outro conceito.

As três camadas, e por que uma skill não pode ser as três

                    ┌─────────────────────────────┐
   uma decisão ──►  │ 1. QUE AÇÃO ISSO DISPARA?   │  ◄── NÃO é "que pergunta é essa"
                    │    dá pra desfazer?          │      é "o que acontece se der errado"
                    │    quem mais é atingido?     │
                    └──────────────┬──────────────┘
                                   │
              ┌────────────────────┴────────────────────┐
              ▼                                         ▼
   🔒 IRREVERSÍVEL / ATINGE FORA              🔧 REVERSÍVEL / SÓ AQUI DENTRO
   subir pra produção · dinheiro               card · formato · conserto
   apagar · mensagem em seu nome               medir · escrever spec
              │                                          │
              ▼                                          ▼
   ┌──────────────────────┐                ┌─────────────────────────────┐
   │ PORTÃO DURO          │                │ 2. A RÉGUA DECIDE           │
   │ mecanismo, não texto │                │    ◄── É AQUI que a         │
   │ ⚠️ confiança alta    │                │        `sweetspot` mora     │
   │ NÃO compra passagem  │                └──────────────┬──────────────┘
   └──────────┬───────────┘                               │
              ▼                                           ▼
        ⏳ PARA E ESPERA                    ┌─────────────────────────────┐
                                            │ 3. MEDIR CONTRA O QUE ELE   │
                                            │    DE FATO RESPONDEU        │
                                            └─────────────────────────────┘
camada o que responde onde mora garantia
1. o portão esta decisão pode ser confiada a uma régua? hookspede-ok-antes-de-subir e ato-de-producao-por-mcp barram de verdade ✅ ~100%
2. a régua dado que pode, qual caminho? a sweetspot, as 18 cláusulas ⚠️ ~90% (é texto)
3. a medição o julgamento acertou? os 947 pares de gabarito-do-dono.json ⚠️ sem rotina ainda

⛔ Por que delegar TUDO a uma skill é o desenho errado

A tabela de garantias desta casa já responde: hook vale ~100%, texto vale ~90%. Uma skill é
texto. Pôr o portão do irreversível dentro dela seria trocar uma parede por um lembrete.

E a literatura de 2026 diz a mesma coisa com outras palavras: "alta confiança não compra o
direito de fazer uma ação irreversível sem supervisão"
. O padrão adotado em produção classifica
cada ação por reversibilidade e raio de estrago — e só depois deixa a régua julgar.

A boa notícia: este desenho já está montado aqui, só não estava nomeado. O portão já é
hook; a skill já é a camada 2.

📐 O que a medição de 2026-08-28 provou sobre a camada 1

Os quatro níveis da literatura foram testados nos 929 pares — e aqui eles são DOIS, não quatro:

delegou taxa intervalo
🔒 tier 1 — irreversível / atinge fora 18/168 10,7% [ 6,9..16,3]
🔧 tiers 2, 3 e 4 juntos 188/761 24,7% [21,8..27,9]

Os intervalos não se sobrepõem — a separação é real. Mas os tiers 2, 3 e 4 são
indistinguíveis entre si (25,9% · 24,5% · 24,1%), então o eixo é binário: irreversível, ou
não.

⚠️ E o ganho preditivo global é pequeno, porque o tier 1 é só 18% dos casos:

eixo Brier (menor é melhor)
chute fixo (a taxa média) 0,1726
✅ tier de reversibilidade 0,1696
régua de texto (a cascata) 0,1705

Os três praticamente empatam. A leitura honesta: a reversibilidade funciona como portão
binário
, não como preditor.

⚠️ E o caminho que parecia óbvio e a literatura DERRUBA

Medir a minha confiança e usá-la como portão é o conserto intuitivo — e é o que falha:

achado consequência
confiança verbalizada é sistematicamente superconfiante agentes preveem 73% de sucesso e acertam 35%
usar confiança crua como portão faz escalar DE MENOS justo onde mais precisa
agentes escalam de menos por padrão é o modo de falha dominante da indústria

Fontes: Knowing When to Ask for Help · Act or
Escalate?
· On Verbalized Confidence
Scores
·
confidence-escalation

🔍 O buraco que sobra, dito com todas as letras

A camada 1 tem mecanismo só nas duas pontas que já viraram hook — subir pra produção e as
três ferramentas de produção. Fora delas, ela vive em texto, e eu obedeço ~90% das vezes.

E os 90% estão funcionando, medido: no tier irreversível a delegação cai para 10,7%
contra 24,7% no resto. Comparado ao que a literatura mede — agentes que escalam de menos
por padrão —, estamos do lado certo da falha.

Sintoma de que isso quebrou: eu subir algo para produção sem parar e perguntar.

python .claude/medicoes/2026-08-28-variacoes-da-regua/reversibilidade.py

Os 6 Princípios de Decisão — o mapa das 18 regras

Você não precisa decorar número nenhum. Cada regra tem um NOME curto; o número existe só para a
máquina anotar a nota dela. Quando eu disser "parei pela do teto", é destas que estou falando.

família a pergunta que ela responde as regras o eixo do tempo
O jeito único quantos jeitos de fazer isso existem? Caminho único · Unificação antes (impede nascer) × depois (junta o que separou)
Procurar, não lembrar eu busquei, ou achei que sabia? Os irmãos · Aberto/Fechado · O mundo já resolveu antes de construir × depois de achar defeito
O número honesto esse número quer dizer o quê? Alarme ou ferramenta · Conta o pedaço · Peça nova · Sweetspot · Não medi antes de olhar × ao olhar
O silêncio não vale o que eu deixei de dizer? Robusto · As três linhas · A palavra tudo no fecho
Onde é a causa consertei o que apareceu ou o que causou? A raiz · Está sangrando? agora (estanca) × depois (a raiz)
Quando acaba como sei que terminei? Sem defeito sintético · O teto é o fim, por natureza

O eixo do tempo foi o dono quem viu, olhando Unificação e Caminho único: "uma pra arrumar
e outra pra já nascer certo"
. O padrão se repete em quase toda família — quase sempre há uma
regra que age antes e outra que age depois.

Quem escreveu cada uma

marca origem quando brigam
🟦 as 8 do dono — ele as escreveu num bilhete, com as palavras dele as dele vencem
as 9 minhas — cada uma nasceu de um defeito medido aqui cedem

A maioria tem nome consagrado lá fora, e onde há nome ele vem marcado com 📚 no verbete —
conte os 📚 e você tem o número deste arquivo, sem precisar acreditar em mim. Isso não é
enfeite: nome de indústria carrega décadas de gente aprendendo na dor.

(A primeira versão desta linha dizia "quinze das 18" — um número que não se confere lendo o
arquivo
, porque nem todo verbete com âncora traz o 📚. Um revisor contou 11 e me derrubou.
Afirmação que o próprio documento não sustenta é a falha que a regra Conta o pedaço persegue,
cometida no documento que a explica.)


Família 1 — O jeito único

"se as coisas tiverem um meio só de fazer tal coisa, nada nasce quebrado" — palavras do dono,
e são quase literalmente as da indústria.

Caminho único — o que impede o defeito de nascer

Que exista UM jeito de fazer aquilo.

No dia-a-dia: tomada de três pinos. Ninguém precisa de aviso, de treinamento nem de fiscal —
o plugue errado simplesmente não entra.

Como sei que cumpri: eu conto quantos jeitos de fazer aquilo existem hoje. Mais de um, e o
defeito vai nascer no jeito não pavimentado — é só questão de tempo.

📚 Lá fora chama-se caminho pavimentado, e a frase que resume é governa-se por construção, não
por inspeção
.

⚠️ E não é ordem, é conveniência — isto muda tudo. A literatura é explícita: não é "você DEVE
usar"
, é "se usar, sua vida fica mais fácil". A porta que ninguém usa não é caminho único — é
mais um jeito.

O caso mais usado dela: FALAR com você

(Isto era a regra Canal único, a 18ª. Aposentada em 21/08 por decisão do dono: ela era um caso particular vestido de princípio — "todo aviso sai pela mesma porta" é literalmente "que exista um jeito só de fazer aquilo". Caso particular só pega o caso dele, e foi por isso que ela terminou com zero acertos. O conteúdo ficou; a regra separada, não.)

Todo aviso sai pela mesma porta, no mesmo formato: o que houve · onde · o que fazer agora.

No dia-a-dia: correspondência chega na caixa do correio. Bilhete jogado por cima do muro
também "foi entregue" — e ninguém lê.

A porta única não serve para o seu caso? Mude a porta. Nunca abra a segunda.

⚠️ Foi a mais fraca de todas, e vale contar por quê — porque a história é sobre régua, não sobre
a regra.
Medido em 21/08, com duas réguas diferentes:

como se mede o número
pelo formato do código (quem fala sem usar a porta) 91 de 189
quem não fala nada (e portanto não precisa de porta) 50
quem usa a porta 48
pelo efeito — quem de fato falou pro vazio numa janela de ~28h 8 guardas, 1.054 falas perdidas

A distância entre 91 e 8 é a resposta inteira. Converter 91 arquivos para consertar 8 casos
observados é exatamente o que a regra Sweetspot proíbe. E o pior dos 8 nem é um arquivo do tipo
que uma conversão em massa alcançaria.


Família 2 — Procurar, não lembrar

As três falharam pelo mesmo motivo — eu procurei de cabeça — e as três foram consertadas do
mesmo jeito: colar o comando que rodei. É a família mais coesa das seis.

(Esta seção esteve aninhada sob Unificação até 21/08, e o lugar errado ensinava a regra
errada como dona do caso mais usado dela. A cláusula Canal único foi fundida em
Caminho único, não em Unificação.)

🟦 Unificação — o que arruma depois que já separou

Ao juntar duas peças que fazem a mesma coisa, a que fica tem que ter as garantias das duas.

No dia-a-dia: duas fechaduras na mesma porta e você vai ficar com uma. A nova é bonita e tem
chave eletrônica; a velha é feia e tem tranca de segurança. Ficando com a nova sem olhar, a
porta perdeu a tranca — e ninguém percebe até alguém empurrar.

Nunca a mais nova, nunca a mais usada, nunca a mais limpa. A que fica é a SOMA.

Como sei que cumpri: escrevo uma tabela lado a lado — o que a condenada tinha, o que a eleita
tem, cada item marcado como levado ou descartado com motivo. Se eu não consigo listar o que
a condenada fazia, eu não a li o bastante para apagá-la.

⚠️ Caso real: elegi a mais nova e 88 peças rodaram um dia inteiro com uma proteção a menos.
A "velha" tinha um limite de tempo que a nova não tinha.

📚 Lá fora chama-se a cerca de Chesterton: não derrube a cerca antes de descobrir por que ela
foi posta. Há literatura específica sobre agente de IA removendo o que não entende — que foi
exatamente o que aconteceu nas 88.

E se somar as garantias sair caro, não unifique. Duas peças de propósito é decisão legítima.

🟦 Os irmãos

Achou um defeito, vá procurar os parentes dele — por busca, nunca de cabeça.

No dia-a-dia: o eletricista achou uma tomada queimada. O bom vai olhar as outras do mesmo
circuito antes de guardar a caixa. O ruim troca a tomada e vai embora.

Como sei que cumpri: eu colo a busca que rodei e quantas coisas ela achou. Dizer "procurei"
não vale — não dá para conferir.

É a pior de todas na minha ficha — mais falhas que acertos, e o número exato muda toda semana
(ele mora no placar, não aqui). Nas vezes que errei, eu tinha lido a regra, achei que sabia de
cabeça, e o irmão estava 25 linhas acima, no mesmo arquivo.

⚠️ E ela falhou de novo em 21/08, dentro do commit que criou este guia. Busquei uma frase e
declarei "9 irmãos, todos atualizados"; um revisor independente refez a busca sem diferenciar
maiúscula de minúscula
e achou mais dois vivos — um deles em caixa alta, no cabeçalho da
regra Sweetspot, dizendo quatro sobre uma tabela de cinco. Busca que só pega a grafia que
você lembrou é busca de memória com roupa de comando.

📚 Lá fora chama-se análise de variantes. O Google mede uma coisa assustadora: metade dos
ataques novos que eles encontram são variantes de um bug que já tinha sido corrigido
— porque
ninguém foi olhar os irmãos depois do conserto.

🟦 Aberto/Fechado

Coisa nova entra por um encaixe. Se para acrescentar eu preciso abrir o motor que já funciona, eu
paro.

No dia-a-dia: a caixa de ferramentas tem slots. Régua nova entra num slot vazio. Você não
serra a caixa para caber a régua — e se serrar, todas as outras ferramentas ficam bambas.

Como sei que cumpri: eu colo a busca pelo encaixe. "Não havia encaixe" dito de cabeça é
opinião; a busca colada é fato.

📚 Este é seu E é da indústria ao mesmo tempo: é o O do SOLID, de 1988. Até 20/08 esta regra
existia aqui sem o nome — eu tinha traduzido a ideia e apagado o rótulo, e quando você releu a
lista não reconheceu o próprio critério. Nome consagrado carrega literatura; apelido meu carrega
só a minha explicação.

É a que mais pega defeito de todas (o número está no placar, que se atualiza sozinho). E mesmo
assim falhou 6 vezes numa obra só — e nas 6 o encaixe existia. Numa delas, a peça certa estava
importada 1.260 linhas acima, no mesmo arquivo.

⚠️ E ela falhou de novo em 21/08, com a variante mais traiçoeira: o encaixe tinha DUAS metades e
eu achei uma.
Para este guia aparecer na página do site, é preciso (1) copiar o arquivo pra
máquina e (2) inscrevê-lo na lista de páginas. Eu fiz a primeira, colei a busca, e prometi a você
duas vezes que ele apareceria sozinho
. Não apareceria. O comentário do próprio código dizia a
frase que me desmentia: "copiar é automático, publicar não". Encaixe achado não é encaixe
completo — a pergunta certa é "esta é a única metade?"

O mundo já resolveu

Antes de desenhar, ver se já existe. A ordem é: casa → internet → casa de novo.

No dia-a-dia: antes de inventar gambiarra para a porta que range, você olha na sua caixa de
ferramentas. Não achou? Pergunta o nome da peça na loja — e volta para casa procurar de
novo com o nome certo
, porque muitas vezes ela estava lá e você não sabia como se chama.

A volta é a parte que quase todo mundo pula, e ela é medida: em 12 de 12 achados derrubados
por revisor independente, a resposta já estava neste projeto. Ir direto para a internet não é
diligência — é pular a fonte que tinha a resposta.

Mas quando eu não sei nomear o que procuro, a internet é o dicionário. Um nome técnico vindo de
fora destravou 307 achados que já estavam no disco aqui. O dado estava dentro; o nome estava
fora.

A pergunta não é "como faço isso melhor?" — é "isso deveria ser feito assim?"

Foi esta regra que trouxe todos os nomes de indústria deste guia, num único dia: descobrimos que
quatro regras nossas já eram padrão consagrado sem o nome, e que a nossa análise de causa tinha
dois dos três passos que a definição canônica exige.


Família 3 — O número honesto

Cinco regras, uma pergunta só: esse número quer dizer o quê? É a maior família, e não por
acaso — quase todo erro caro que eu cometi aqui começou num número sem régua.

Alarme ou ferramenta — declare ANTES de ver o número

A mesma quietude significa o oposto nos dois casos.

No dia-a-dia: um extintor que nunca foi usado está ótimo. Uma furadeira que nunca foi
usada foi dinheiro jogado fora.

é um... serve quando zero uso significa
alarme (trava, proteção) não dispara está funcionando
ferramenta (peça que alguém chama) é chamada está morta

Quem chega com o número sem ter declarado antes é ferramenta, sem apelação — senão eu escolho o
rótulo depois de ver o número, que é o mesmo que não ter régua.

⚠️ Aqui mora um buraco que eu achei comparando com a literatura, e ele era numa regra minha:
dizer que alarme calado = está funcionando é conclusão que não se tira olhando. Extintor
quieto pode estar cheio ou vazio. A literatura de controles é explícita: proteção silenciosa
exige teste ativo. Hoje a regra exige a prova que exercita — e a peça que faz isso já rodava
aqui, só faltava a regra depender dela.

Conta o pedaço

Meça o trecho que você vai mexer, nunca a peça inteira — e todo número vem com a régua ao lado.

No dia-a-dia: uma tomada não funciona. Você não conta quantas vezes a casa é usada; conta
quantas vezes aquela tomada é usada. Pode ser a da geladeira ou a de trás do armário, e a
decisão é oposta.

A segunda metade é mais importante que a primeira. Número sem régua não quer dizer nada:

28 trechos repetidos          ← errado: "trecho" não quer dizer nada
2 LINHAS INTEIRAS idênticas   ← certo: dá para conferir

⚠️ O caso, e ele é meu: reportei 28 onde havia 2. Contei com uma janela que passa de
palavra em palavra, então o mesmo pedaço era contado uma vez por posição. O número chegou até você
como fato.

⚠️ E aconteceu de novo no dia seguinte, neste próprio guia: publiquei "55 de 189 usam o canal"
misturando duas contagens — o numerador incluía subpastas, o denominador não. O número certo é
48.
Régua misturada é o defeito que esta família inteira existe para pegar, e ele me pegou duas
vezes em dois dias.

📚 Lá fora chama-se definição operacional, do Deming, e a frase dele é a régua inteira: "não
existe valor verdadeiro de coisa nenhuma; existe o resultado de aplicar um procedimento"
. Mudou o
jeito de contar, mudou o número.

Sintoma de que eu errei: você pergunta "28 o quê, exatamente?" e eu preciso voltar no código
para lembrar como contei.

Peça nova precisa de caso

Quantas vezes a situação já aconteceu de verdade? Zero na janela que dá para medir = não
construo.

No dia-a-dia: não compre guarda-chuva porque pode chover. Compre quando tiver se molhado.

E se já existe peça cuidando daquele mesmo momento, eu mudo ela em vez de criar a próxima.

O número que sustenta isso aqui dentro: temos cerca de 210 travas registradas, a maioria
nunca agiu, e cerca de 8% delas estão quebradas neste momento — e cada uma vira manutenção para
sempre. Se eu não consigo dizer numa frase por que a minha é diferente, eu não construo.

(A frase antes dizia "8% de chance de nascer quebrada", e isso é outra coisa: o número mede
quantas estão quebradas
hoje, não quantas nascem assim. Uma é foto, a outra é taxa — e eu não
medi a taxa. Um revisor pegou.)

📚 Lá fora chama-se YAGNI — "você não vai precisar disso", do Extreme Programming. A nossa é
mais dura que a original:
lá diz "não construa para o futuro imaginado"; aqui é não construa
até o caso ter acontecido E sido contado
.

🟦 Sweetspot

A maior garantia pelo menor custo que se paga TODO DIA. Números escritos antes de qualquer
código:

número a pergunta o corte
Preciso de cada 100 vezes que falar, quantas serão à toa? mais de 10 em 100 → nasce desligada
Barato quantos segundos soma no caminho de todo mundo? compete com o portão, que já leva ~32s
Saldo apaga ou absorve alguma peça que já existe?
Valor quantas vezes fez algo útil numa janela declarada? zero → sem medida, nunca "cara"
Silencia de cada 100 observações, em quantas ela está verde? abaixo de 50% ela virou paisagem
Quem sente quem percebe a diferença, e COMO? "ninguém percebe" não reprova — mas vai escrito, e aí a peça é para a oficina, não para você

No dia-a-dia: alarme de carro que dispara com o vento não é segurança, é barulho — em uma
semana você desliga. E aí, no dia que precisava, ele não estava ligado.

⚠️ O corte do Valor não é enfeite. Sem ele, a régua mataria a trava de comandos perigosos
desta casa: 6.109 passagens pelo portão do commit, para 2 bloqueios. Ela fica calada em 99,9%
das vezes porque comando perigoso é raro. Peça muda e barata é alarme de incêndio: fica.

(A régua vai junto de propósito: são as passagens pelo portão, não as execuções totais, que
naquele dia foram 6.324. Trocar um pelo outro é a mesma família de erro que a regra Conta o pedaço persegue, e um revisor pegou este aqui.)

📚 O Google mede alarme por quatro atributos, e dois são iguais aos nossos. O Silencia é o
quinto, e nasceu de um buraco achado em 21/08: faltava medir quanto tempo o alarme fica tocando
depois que o problema acabou
. Alarme vermelho permanente não é sinal, é parede — e é exatamente o
estado de um alarme desta casa hoje.

🟦 O número decide?

Antes de escolher uma coisa porque ela teve a melhor nota, prove que a nota separa as duas.
Ordem sua, de 28/08: "este é um princípio primordial, testes robustos pra medição".

No dia-a-dia: duas balanças de farmácia, uma marcando 80,1 kg e outra 80,3. Você não
conclui que a segunda é mais pesada — a diferença cabe dentro do erro do aparelho. É preciso
saber de quanto o aparelho erra antes de acreditar na diferença.

O caso que a criou foi meu, e eu ia errar: pontuei sete réguas contra um gabarito e o placar
deu 66,7% · 63,3% · 63,3%. Escolhi a primeira. Quatro conferências depois, nenhuma
sustentava a escolha
— a vantagem inteira era de 3 respostas certas, e uma delas até
inverteu quem ficou em primeiro.

a pergunta, antes de escolher pelo número o que ela reprova
quanto acerta quem só chuta a resposta mais comum? ganhar pouco de quem chuta é ruído
a diferença cabe na margem de erro? margens que se sobrepõem não separam nada
a nota está inflada porque quase tudo é da mesma classe? numa prova em que 90% é passou, quem diz passou sempre acerta 90%
quem montou o gabarito? se fui eu, e eu também escrevi as réguas, medi a minha própria cabeça

⚠️ Ela NÃO manda fazer conta em toda medição. "5 casos em 1.481 arquivos" não precisa de
nada disso — ali não há duas opções disputando. Ela só acorda quando o número vai ESCOLHER.

O que ela custa: uns 10 minutos quando dispara, e às vezes não há amostra que baste. Aí a
saída honesta é dizer "não consegui distinguir" e escolher pelo critério de sempre — dizendo
que foi assim.

Como você vê que eu falhei nela: eu te apresentar um pódio cuja diferença é de um ou dois
casos.

Não medi

"Não sei" é resposta. "Achei que" não é.

No dia-a-dia: o mecânico que diz "não testei o freio, não deu tempo" é honesto e você decide
o que fazer. O que diz "o freio deve estar bom" é o perigoso.

Não consegui medir? Escrevo "não medi X" e sigo. Proibido inventar um medidor caseiro para
produzir o número que eu quero, e proibido abrir investigação de meia hora por causa disso. Seguir
calado é que não vale.

⚠️ Cuidado que um revisor pegou: dizer "não medi" não me libera para construir. Sem esta
linha, eu escapava da regra Peça nova precisa de caso só dizendo que não tinha medido.


Família 4 — O silêncio não vale

Três regras sobre o que eu deixo de dizer. Nenhuma delas é sobre código: são sobre o que
chega até você no fim.

🟦 Robusto

Todo caminho de erro diz, escrito, o que faz quando dá problema: tranca, ou abre avisando o que
se perde.

No dia-a-dia: a catraca do prédio quando falta luz. Ela pode travar (ninguém entra, nem
morador) ou destravar (todo mundo entra, inclusive quem não devia). As duas são decisões
válidas. O inaceitável é ninguém saber qual delas o prédio escolheu.

Não existe "não vai quebrar". Existe "quando quebrar, faz isto" — e isto vai escrito na
própria linha
, não na cabeça de quem construiu. Quando nada está escrito, o padrão é trancar.

📚 Lá fora chama-se padrão à prova de falha, e é de 1975. O que a nossa acrescenta: lá diz que
o padrão deve ser o estado seguro; aqui a escolha vai escrita, porque padrão implícito é
exatamente onde o "abre calado" se esconde.

⚠️ A própria trava de segurança desta casa tem 20 saídas de erro e nenhuma declaração — e duas
delas devolvem "nada encontrado", que ali significa o comando passa.

As três linhas

Toda entrega termina com três coisas, e a falta de qualquer uma reprova sozinha:

O QUE ELA NÃO PEGA .... um caso concreto que pode mesmo acontecer
O QUE ELA COBRA ....... um número medido, nunca um adjetivo
COMO SABER QUE FALHOU . o sintoma, e onde ele aparece

No dia-a-dia: a bula do remédio. Para que serve, a dose, e o efeito colateral. Remédio sem
bula funciona igual — até o dia que não funciona.

É a que mais some no aperto. Quando a resposta está longa e eu quero fechar, é a primeira coisa
que eu corto — e é justamente ela que impede a entrega de virar propaganda.

🟦 A palavra

Três palavras com sentido travado, ditas sem você perguntar.

palavra significa exatamente
Entregue escrito, testado, no ar e conferido ao vivo — e ninguém tentou derrubar
Validado o acima + revisado em laço até o árbitro dizer que o laço fechou para o alvo que você declarou (com o alvo em médio são DUAS passadas limpas seguidas, não uma — descer o alvo encarece a palavra)
Blindado o acima + alguém independente tentou derrubar e não sobrou achado alto nem crítico EM ABERTO (+ mutação dirigida, se for obra de risco) + rodou em produção pelo menos uma vez

No dia-a-dia: a diferença entre "montei o carro", "o mecânico conferiu" e "rodou 1.000 km
na estrada"
. As três são verdade; só a terceira você aposta a viagem.

Zero produção = Entregue, por mais teste e revisão que a obra tenha levado. Esta parte é rigor
acima do que a indústria costuma exigir, e nasceu de um caso concreto: usei Blindado numa peça
com 15 provas verdes e 3 revisores independentes — que nunca disparou uma vez sequer, por um
defeito conhecido do fornecedor.


Família 5 — Onde é a causa

🟦 A raiz, não o sintoma

Consertar o que causou, não o que apareceu — e são TRÊS passos, não dois.

No dia-a-dia: o teto pinga. Passo 1: a telha está trincada. Passo 2: se eu trocar a
telha e continuar pingando, eu estava errado — e escrevo isso ANTES de trocar. Passo 3: o que
impede a trinca de voltar no ano que vem.

O passo 2 é o que separa causa de palpite. Causa que não tem jeito de estar errada é sintoma com
nome bonito.

O passo 3 é novo e nasceu de um buraco meu: eu explicava a causa lindamente e o aprendizado
morria no texto. Consertei três defeitos numa manhã e nenhum dos três virou proteção — viraram
parágrafo. O quarto lugar da mesma falha só não voltou porque um humano leu.

"Não deixei proteção, e o motivo é X" é resposta legítima. O que não vale é o silêncio, que se lê
como "está resolvido".

Está sangrando? — a exceção legítima

Defeito acontecendo neste minuto se conserta antes de qualquer discussão.

No dia-a-dia: a casa está alagando. Você fecha o registro primeiro e descobre depois qual cano
estourou. Discutir a causa com a água subindo é luxo.

Como sei que é sangramento de verdade: eu consigo nomear quem sofre e onde. "Pode
acontecer"
é palpite; "a conversa X leva o commit errado" é fato. Não consigo apontar a vítima?
Então não está sangrando
— e aí a causa vem primeiro, que é o caso normal.

📚 Lá fora chama-se estancar o sangramento e é a primeira regra de resposta a incidente do
Google: "você não está ajudando o usuário se o sistema morrer enquanto você investiga".


Família 6 — Quando acaba

🟦 Sem defeito sintético

A prova tem que exercitar a peça de verdade — e morrer junto com ela.

Os dois nomes são seus, e querem dizer coisas diferentes:

o termo o que é como se pega
sintético a peça "funciona" só porque o teste foi feito sob medida para ela a prova fica verde com a peça vazia
latente está errado, correto no papel, e só aparece no dia raro o caminho nunca rodou de verdade

No dia-a-dia: apertar o botão de teste do alarme de fumaça prova que a pilha tem carga. Não
prova que ele dispara com fumaça.
São coisas diferentes, e só a segunda importa.

Como sei que cumpri: eu esvazio a peça e rodo a prova. Ficou verde? A prova não vale nada, e
é ela que precisa de conserto, não o código.

📚 Lá fora chama-se teste de mutação e defeito latente. O segundo é termo formal de teste de
software — você chegou nele sozinho.

🐦 Canário e 🧪 mutante — são coisas DIFERENTES, e você perguntou por quê

(Escrito a pedido seu, em 2026-08-24: "achei bem interessante o conceito de saber apontar quando
está errado também, e não quando acerta apenas".)

A sua pergunta foi: se ele falha, então ele não acerta — não é a mesma coisa? Não é, e o motivo
é que existem duas formas OPOSTAS de um alarme quebrar — e cada teste sozinho é cego para uma:

o detector está testar "apita com fumaça?" testar "fica quieto sem fumaça?"
apitando o tempo todo ✅ passa — e engana você ❌ pega
com a pilha arrancada ❌ pega ✅ passa — e engana você

Um alarme que nunca dispara é perfeito no teste de não incomodar. Um que dispara sempre é perfeito
no teste de pegar ladrão.
Por isso os dois testes existem, e por isso eles são pares — não
substitutos.

o que é a pergunta que ele faz
🐦 canário o par de testes, sempre os DOIS juntos "ele apita quando deve E cala quando não deve?"
🧪 mutante eu estrago a peça de propósito e rodo a prova "se eu arrancar a pilha, alguém percebe?"

A diferença em uma linha: o canário testa o alarme; o mutante testa quem vigia o alarme.
Mutante verde é o pior resultado da casa — quer dizer que a rede de provas era decoração, e isso
não aparece de nenhum outro jeito, porque enquanto não há fumaça ela parece perfeita.

⚠️ A regra que vale para qualquer medição, e ela custou caro em 24/08: quando eu for provar que
uma régua nova é melhor, a população de teste tem de incluir os casos em que a régua VELHA já
acertava
. Sem esses, o teste só pode confirmar o que eu quero ouvir. Foi assim que um número
meu virou o contrário: medindo só as falhas, a régua nova parecia pior; incluindo os acertos,
apareceu que a antiga acusava 41% a 56% do trabalho BOM.

📁 Onde eles vivem aqui: os canários em .claude/canarios/*.json (um arquivo por guarda, com os
dois lados escritos); os mutantes em scripts/quality/mutation_check.py, que planta os defeitos e
conta quantos a rede matou. Quando um guarda não dá para testar dos dois lados, isso vai escrito
em .claude/canarios/_lacunas.json, com o motivo medido — lacuna declarada vale mais que canário
fingido.

O teto

Quando eu paro de tentar e devolvo a bola. Por número, nunca por cansaço.

No dia-a-dia: você não procura a chave perdida para sempre. Você decide antes: "olho a casa
inteira três vezes, e depois chamo o chaveiro"
. Sem o combinado, você vira a casa até de noite.

Paro no primeiro destes que acontecer, e digo qual foi, com o número:

o que dispara por que este número
5 rodadas que ainda acharam coisa parou de render. Rodada limpa não gasta — ela é progresso
8 revisores na mesma obra orçamento; commitar o que está pronto zera a conta
3 tentativas falharam no mesmo ponto é o desenho que está errado, não o código
a mesma família de defeito pela 3ª vez é raiz — vale a regra A raiz

Bater no teto sem chegar em Validado é resultado legítimo, não fracasso. Eu reporto assim:
"parei no teto tal, depois de tantas; cheguei em Entregue; falta X para Validado".

📚 Lá fora chamam-se regras de parada, e há literatura séria sobre isso na revisão de
documentos e de código — inclusive uma família de métodos que estima quantos defeitos ainda
faltam
para decidir se compensa continuar.

⚠️ O teto não é permissão para parar antes. Ele só vale depois do laço ter rodado — parar na 1ª
rodada citando o teto é a violação que ele mais mata.


O placar, para você conferir se eu obedeço

Cada vez que um revisor independente derruba um trabalho meu, ele anota qual regra estava escrita
e não me impediu
. Isso vira uma ficha:

coluna significa
pegou a regra achou defeito real
falso ela apitou à toa
não protegeu ela estava lá, eu li, e o defeito passou assim mesmo

A terceira é a única que mede efeito em vez de obediência — e é por isso que ela vem do revisor,
não de mim. O placar vivo fica em .claude/skills/sweetspot/references/placar.md, e é gerado por
máquina.

O que a ficha já mostrou, e vale como regra geral: as regras que viram conta acertam quase
sempre; as que viram lembrete erram metade das vezes. Foi por isso que quatro delas passaram a
exigir a busca colada em vez de eu afirmar que procurei.


📋 O que a medição de 2026-08-28 mudou — e o que ela DERRUBOU

Esta seção existe porque o dono perguntou "está salvando todos achados, conclusões e
medições no .md da skill?"
. A resposta honesta era meio: os 13 achados estavam no log
técnico (references/desempenho.md), e este guia — o que ele lê — não tinha nenhum.

Foram 6 investigações, 13 achados e 49 artefatos de medição em 7 pastas sob
.claude/medicoes/2026-08-28-*. Cada uma tem os dados crus e um analise.py que reproduz os
números. O que mudou no seu dia-a-dia está nas duas tabelas abaixo.

O que MUDOU na régua

a coisa antes agora o número que decidiu
ideia nova sendo julgada perdia por 6 pontos só por ter menos eixos medíveis compara-se por média, não por soma — a nota volta a decidir a regra de 27/08 piorou a desvantagem em 2 pontos (era 4)
quando escolher os critérios podia ser depois de olhar as opções antes, sempre constructed criteria, Uhlmann & Cohen 2005 — 3 estudos, e comprometer-se antes eliminou o viés
o peso dos 6 eixos igual, sem ninguém ter medido continua igual, e agora se sabe que não deveria 3 juízes cegos declararam garante 35 / alcança 25 / manter 20 / cabe 11 / instalar 9 — e a campeã de uma matriz real muda A → C

O que a medição DERRUBOU — inclusive coisa minha

a afirmação quem a fez o veredito
"a ficha tem 0 matrizes de campo" eu, publicado como o achado principal são 7, em 4 arquivos — e duas delas decidiram NÃO construir
"o fiscal precisa aprender os 6 eixos" eu, ia commitar ❌ minha régua acusaria 232 linhas inocentes; a régua do conjunto acusa 20 blocos
"a casa já escolhe régua por contexto" eu ❌ são 3 eras; a régua é prevista pela data em 30 de 30
"3 critérios não discriminam" eu ❌ veio de 3 matrizes didáticas; nas de campo eles variam
"o falso foi registrado ontem" você ❌ são 3, e o primeiro é de 24/08 (99002e6c3)

As 3 coisas que continuam ABERTAS

o que por que não foi feito o número
o fiscal não enxerga a ficha principal minha 1ª régua reprovou na própria cláusula 10 0 de 6 eixos na lista dele
o peso dos eixos continua igual mexer nele muda pódio de decisão passada — é porta de mão única 1 matriz de 12 opções, N = 1
a ficha nunca foi cobrada de ninguém ela é doutrina, não guarda — vale ~90% 71 notas registradas, 7 matrizes escritas

⚠️ O que a medição NÃO alcançou, dito: ninguém mediu se você usa os 6 eixos de cabeça,
sem escrever a tabela. Todo número acima é sobre tabela escrita. Se você pontua mentalmente,
o "7 matrizes" subconta — e só você sabe disso.


🔬 O TESTE É ROBUSTO? — as quatro provas, e a resposta é AINDA NÃO (2026-08-28)

Ordem do dono: "primeira etapa é ver se o teste é robusto pra realmente nos dar
resultados que façamos ver qual é o melhor"
. Ele está certo em pôr isso antes de mais
variações — sem isto, cada variação nova produz um número que não decide nada.

Reconferir: python .claude/medicoes/2026-08-28-variacoes-da-regua/robustez.py

As quatro provas, cada uma com o número

# a prova o resultado
1 kappa de Cohen (concordância descontado o acaso) melhor = 0,432 — faixa "ajustável"; nenhuma chega a 0,60
2 baseline: chutar sempre a classe majoritária 56,7% contra 66,7% da melhor — ganho de 3 perguntas
3 McNemar entre as três melhores p = 1,000 — elas são estatisticamente empatadas
4 validação cruzada de 5 dobras, 70 perguntas inverte a ordem: a que perdia passa a liderar

⛔ O que cada uma derruba

A acurácia crua mente. Com D valendo 56,7% da amostra, uma régua que só chutasse D
acertaria 56,7% com kappa zero. É o exemplo canônico da literatura: "se 90% das saídas são
pass, um juiz que sempre diz pass acerta 90% e tem kappa perto de zero"
.

Escolher entre as três melhores é ruído. McNemar entre V5 e V7p = 1,000 — a
diferença de uma pergunta não distingue nada. O que o teste distingue de verdade são as
três piores (p ≈ 0,012).

⚠️ E a validação cruzada INVERTE o pódio:

variação validação simples (30) 5 dobras (70) desvio entre dobras
V5 cascata ✅ 66,7% 71,4% 10,1
V6 63,3% 78,6% 9,0
V7 ❌ 63,3% 84,3% 5,3 — a mais estável

⚠️ Mas esse resultado está CONTAMINADO e vai dito: as 5 dobras incluem as 40 perguntas em
que a V7 foi ajustada. Ela leva vantagem por construção. A validação cruzada limpa exigiria
reajustar a régua dentro de cada dobra
— não foi feito.

Logo o pódio continua indefinido PELA ACURÁCIA, e essa é a resposta honesta.

⚠️ Isto foi resolvido na 2ª rodada, e por outro caminho: o pódio fica definido quando se
pesa o custo do erro em vez de contar acertos — porque as duas colocadas erram em direções
opostas, e só uma delas é cara. A seção O ACHADO QUE INVERTE A CONCLUSÃO, mais abaixo, traz
o número. A frase acima segue valendo para o que ela mede: pela acurácia crua, empate.


⛔ AMPLIAR A AMOSTRA NÃO RESOLVE — e isso não é falta de esforço (2026-08-28, 2ª rodada)

A pergunta que veio antes de ampliar foi: quanto seria preciso ampliar? A conta do
poder estatístico respondeu, e a resposta encerra o caminho.

o que número
perguntas pareadas para distinguir a 1ª da 2ª colocada 1.164
perguntas por grupo, se fossem medições independentes 3.213
perguntas distintas que existem no acervo inteiro 632

⚠️ Classificar TODA pergunta que já existiu nesta casa daria pouco mais da metade do
necessário.
Não é questão de tempo nem de disposição: a população não comporta a
medição
. Recontar leva 5 s — varrer AskUserQuestion nos 4.774 transcritos.

Por que o número explode: o teste pareado só olha os casos em que uma régua acerta e a
outra erra
. Entre a 1ª e a 2ª, isso acontece em 16,7% das perguntas — cinco casos, com
placar 3×2. Cinco casos não sustentam decisão nenhuma, e mais amostra só chega lá devagar.

As duas piores, sim, são distinguíveis — bastam 25 perguntas para separar a melhor
da V1 palavra solta. O teste distingue bom de ruim; ele não distingue bom de bom.

✅ O teste de permutação: as réguas LEEM a pergunta, não pegam carona no acaso

Embaralhei o gabarito 10.000 vezes e comparei com o acerto observado.

variação observado com o gabarito embaralhado p veredito
V5 cascata completa 66,7% 41,2% 0,0015 ✅ lê a pergunta
V6 · V7 63,3% 37,6% 0,0010 ✅ lê a pergunta
V3 pronome primeiro 50,0% 31,8% 0,0098 ✅ lê a pergunta
V1 · V2 36,7% 23,4% 0,0249 ✅ lê a pergunta
V4 pelas opções 40,0% 28,2% 0,0635 não distinguível do acaso

Isto é o teste do vazio aplicado à estatística: se embaralhar o gabarito produzisse o
mesmo acerto, a régua não estaria lendo nada. Seis das sete leem. A V4 — a única que
julga pelas opções em vez da pergunta — é a que não passa, e isso é coerente: as opções
são escritas por mim, não pelo dono.


🎯 O ACHADO QUE INVERTE A CONCLUSÃO: a acurácia global mediu a coisa errada

⚠️ Dois fatos só apareceram na matriz de confusão, e os dois mudam o resultado.

1. A régua SE ABSTÉM — e contar isso como erro mistura coisas opostas

Ela devolve ? quando nenhuma marca casa. Abster não é errar: abster é perguntar, que é
o comportamento seguro e o padrão de hoje.

variação cobertura acerto global acerto quando opina
V5 cascata completa 80,0% 66,7% 83,3% [64..93]
V6 · V7 76,7% 63,3% 82,6% [63..93]
V3 pronome primeiro 63,3% 50,0% 78,9% [57..91]
V1 · V2 50,0% 36,7% 73,3% [48..89]

A melhor régua acerta 5 de cada 6 vezes em que abre a boca — e cala nas outras. O 66,7%
que eu vinha reportando somava as duas coisas.

2. Os erros NÃO custam a mesma coisa — e a direção INVERTE o pódio

erro o que acontece na prática custo
D virou R eu decido sozinho o que era seu ALTO — é o passo 4 do protocolo violado
R virou D pergunto o que a régua já resolvia ⚠️ baixo — uma interrupção
virou ? pergunto por não saber zero — é o padrão de hoje

⚠️ O peso 10× foi declarado ANTES de rodar — é a cláusula 18 aplicada a si mesma
(constructed criteria). E a conclusão é conferida contra pesos de 3 a 30: ela não muda.

variação erro CARO erro barato custo resolve
V5 cascata completa 1 3 13 24/30
V3 pronome primeiro 1 3 13 19/30
V1 · V2 2 2 22 15/30
V6 · V7 3 1 31 23/30
V4 pelas opções 4 2 42 18/30

A V6 e a V7 erram TRÊS VEZES MAIS no lado caro. Pela acurácia global elas estavam
empatadas com a V5 (p = 1,000, indistinguíveis). Pelo custo real, a distância é de 2,4×
— e ela aparece na direção que importa: elas decidem o que é do dono.

Desempate declarado: custo igual → ganha quem responde mais. É como a V5 passa à
frente da V3: mesmo erro, mais perguntas resolvidas.

🏆 A conclusão medida: V5 cascata completa

Ela ganha em todos os pesos de 3 a 30, tem a maior cobertura, o menor erro caro e o maior
acerto quando opina. E — o que importa mais que o pódio — os consertos que a "melhoraram"
(V6, V7) pioraram exatamente o que não podia piorar.

⚠️ O que isto NÃO diz: que a V5 serve como mecanismo. Ela ainda deixa 1 decisão do
dono passar como minha
em 30, e o protocolo dos 5 passos continua sendo julgamento meu.
A recomendação de não construir guarda sobre classificação por palavra FICA de pé.

🔍 Um defeito meu, pego pela própria página

A 1ª versão desta medição tinha duas tabelas se contradizendo: a principal dava V5 em
1º, a de sensibilidade dava V3 — porque só a primeira aplicava o desempate. Consertado, e
a cicatriz fica escrita no código: "ordenar aqui por outro critério faz as duas tabelas se
contradizerem"
.


🔑 O 2º AVALIADOR EXISTIA O TEMPO TODO — e ele já vinha me dizendo, 206 vezes

O item 1 da lista "o que falta" era um segundo avaliador, porque eu escrevi as réguas E o
gabarito. Eu tinha escrito que custaria um agente cego. Custa zero:
a resposta de cada
pergunta está gravada no transcrito, ao lado dela.

AskUserQuestion com resposta (bruto) 2.984
pares (pergunta, resposta) distintos 947 (fator de repetição: 3,15×)
fora da conta ([No preference], vazia) 18
dentro da conta 929
ele mandou eu aplicar a régua 206 — 22,2%
✅ ele exerceu escolha 723 — 77,8%

Uma em cada 4,5 perguntas minhas não precisava existir — e não é interpretação minha,
são as palavras dele, gravadas.

⚠️ Ela mede coisa DIFERENTE dos números anteriores, e tratá-las como concorrentes seria o
erro que esta obra existe para medir:

medida o que olha o que responde valor
9,5% a pergunta, por palavra quantas eu não podia resolver 9,5%
30,0% · 56,7% a pergunta, meu gabarito à mão idem, lendo uma a uma 30% e 56,7%
22,2% a RESPOSTA dele quantas ele disse que eu devia ter resolvido 22,2%

⛔ Eu ia publicar 2,3%, e o número real é 10× maior

A 1ª régua devolveu 20 casos. Só fui olhar o outro eixo porque a doutrina da casa obriga:
"falso positivo e falso negativo são dois eixos; medir só um garante escolher errado quando o
defeito mora no outro"
.

eixo o que a auditoria à mão achou
falso positivo 6 de 20 — 30%: instrução de RITMO ("sem pausas", "vou dormir") não é delegação de DECISÃO
falso negativo 8 de 40 — 20%: ⛔ a forma mais comum de delegação, invisível para a régua

⚠️ A forma que faltava é a mais frequente de todas: ele não diz "você decide" — ele
responde com os CRITÉRIOS. caminho sweetspot e robusta · Robusto, nível sênior, foco na raiz · e na forma mais curta possível, /sweetspot. Isso é o mesmo que dizer "aplique a
régua"
, e não tem uma palavra de delegação dentro.

Corrigidos os dois eixos: 20 → 206. A régua nova foi auditada de novo, 25 de cada lado:
zero falso positivo claro, zero falso negativo.
E o 206 não se move quando o corte de
texto varia de 10 a 200 caracteres.

⚠️ Um terceiro defeito, e ele é do meu próprio TESTE: a prova do [No preference] usava a
fala pura — 15 caracteres, abaixo do mínimo de instrução —, então ela caía na gaveta certa
pelo caminho errado. Apagar a regra inteira deixava a prova verde. Pego por mutação;
hoje as falas de teste são longas de propósito, com um assert exigindo isso.

💡 O que muda na prática

O protocolo dos 5 passos já previa isto, no passo 1: "pontuo as opções pelas cláusulas
ANTES de cogitar perguntar"
. Se eu tivesse feito isso, 206 perguntas não teriam existido.

⚠️ E o passo 4 continua intacto: deploy, produção, dinheiro, preferência dele e rumo da
obra param e esperam. Os 77,8% em que ele exerceu escolha provam que perguntar não é o
defeito — perguntar o que a régua já resolve é.

⚠️ Isto NÃO autoriza construir guarda. A régua lê a RESPOSTA: ela só sabe que eu errei
depois de eu já ter perguntado. O valor é medir a taxa, não impedir a pergunta. E a
recomendação de não classificar a PERGUNTA por palavra continua valendo.

Detalhe completo: .claude/medicoes/2026-08-28-variacoes-da-regua/ACHADO-O-DONO-JA-RESPONDIA.md

python .claude/medicoes/2026-08-28-variacoes-da-regua/gabarito_do_dono.py

⛔ O VEREDITO FINAL: kappa entre avaliadores = 0,147 — a rubrica é AMBÍGUA

Com o 2º avaliador em mãos, a pergunta que a obra inteira perseguia finalmente se
responde:
o meu gabarito mede a realidade, ou mede a minha cabeça?

perguntas da amostra com resposta gravada dele 50 de 70
concordância crua 56,0%
esperada por acaso 48,4%
KAPPA 0,147

O piso de "ajustável" é 0,40. Descontado o acaso, sobra quase nada.

⚠️ O que este número NÃO prova, e vai dito: os dois avaliadores respondem perguntas
diferentes — o meu gabarito responde "quem DEVIA decidir?", o dele responde "quem ele
MANDOU decidir?"
. Kappa baixo entre perguntas diferentes é esperado.

O que ele PROVA, e isto basta: a minha classificação não prediz o comportamento
dele
— e era exatamente por essa predição que eu ia escolher a régua.

⛔ O achado dentro da matriz, e ele é PIOR que perguntar demais

    EU \ ELE      D      R
    D            18      5
    R            14      6

Em 14 de 50 casos (28%) eu classifiquei como a régua decide — e ele exerceu escolha de
verdade.
Se eu tivesse obedecido o meu próprio gabarito, teria decidido sozinho 14 coisas
que ele quis decidir.

É o erro CARO da cláusula do custo assimétrico, e ele é 2,8× mais frequente que o barato.

⚠️ Ressalva declarada: "ele exerceu escolha" não prova que ele PRECISAVA escolher —
pode ter respondido só porque eu perguntei (preferência revelada superestima a
necessidade). Mesmo assim, 28% é grande demais para vir todo de cortesia.

💡 O que sobra de pé, depois de tudo medido

o que veredito
classificar a pergunta por palavra morto — 66,7% com sobreajuste, kappa ≤ 0,43
escolher entre as réguas por acurácia morto — precisaria de 1.164 perguntas e existem 632
escolher entre as réguas por custo assimétrico ✅ decide (V5), e é robusto de peso 3 a 30
o meu gabarito como referência kappa 0,147 contra o avaliador externo
o passo 4 do protocolo (produção, dinheiro, preferência, rumo) intacto, e reforçado — o erro caro é 2,8× o barato
os 5 passos como julgamento meu (~90%) ✅ segue — nenhuma automação os substitui
python .claude/medicoes/2026-08-28-variacoes-da-regua/kappa_entre_avaliadores.py

Duas provas CONGELAM este veredito: se o kappa subir de 0,40 ou o erro caro inverter,
elas ficam vermelhas e mandam remedir antes de citar qualquer número desta página.


🎯 SOBRE O QUÊ ele delega — e o resultado VALIDA o passo 4 do protocolo

Saber que 22,2% das perguntas não precisavam existir não diz o que fazer. Recortando por
tema, dá:

tema delegou total taxa
medir / investigar 9 22 40,9% ⛔ acima
conserto / defeito 28 81 34,6% ⛔ acima
prioridade / por onde 20 68 29,4%
doutrina / regra 12 43 27,9%
formato / apresentação 19 72 26,4%
construir peça / trava 39 156 25,0%
card / fila 10 58 17,2%
produção / deploy 9 96 9,4% bem abaixo

(média geral 22,2%; temas com menos de 15 perguntas ficam de fora — não sustentam percentual)

produção / deploy é o tema em que ele MENOS delega — menos da metade da média. É
evidência empírica, vinda do comportamento dele, de que o passo 4 está certo: deploy,
dinheiro e produção param e esperam.

E o que ele mais delega é decisão TÉCNICA — medir e consertar. É ali que a régua da casa
tem de decidir sozinha.

📉 A curva no tempo — e as DUAS leituras erradas que eu ia publicar

fatia período taxa intervalo de 95%
1 20/07 a 04/08 10,8% [ 7,1..16,1]
2 04/08 a 11/08 31,9% [25,6..38,9]
3 11/08 a 16/08 18,4% [13,5..24,6]
4 16/08 a 23/08 20,5% [15,3..26,9]
5 23/08 a 28/08 29,1% [23,1..35,9]

Erro 1, que eu escrevi antes de conferir: "SUBINDO, está PIORANDO", comparando só a 1ª
com a última. A curva NÃO é monotônica — 3 subidas e 1 descida. É um degrau seguido de
platô oscilante
, não uma trajetória. A leitura honesta: a 1ª fatia é separada das demais; do
resto não dá para dizer se melhora ou piora, e afirmar qualquer das duas é achismo.

Erro 2: a explicação alternativa óbvia era "ele passou a usar a palavra nova".
Conferida e descartada: a skill sweetspot nasceu em 19-20/08 (fatia 4) e o pico é da
fatia 2 (04-11/08) — antes dela existir.

⛔ E um número CIRCULAR que eu cheguei a publicar

Escrevi "99,0% das delegações citam sweetspot". É circular: a régua usa essa
palavra para classificar como delegação, então o resultado é ~100% por construção — mede a
régua, não o comportamento dele.

A pergunta honesta ancora no OUTRO caminho de classificação: das 16 delegações achadas
por "vc decide" — que não menciona a palavra —, 8 a citam assim mesmo: 50,0%. E 46
respostas são só a palavra e nada mais.

python .claude/medicoes/2026-08-28-variacoes-da-regua/estou_melhorando.py

O que ainda falta, agora que o resto foi medido

# o que falta por que estado
~~1~~ ~~2º avaliador cego~~ feito, e de graça — é a resposta dele, 947 pares feito
~~2~~ ~~kappa entre avaliadores~~ feito, e REPROVOU: 0,147 feito
~~3~~ ~~curva de aprendizado~~ ⚠️ feita, e é INDECIDÍVEL — degrau, não tendência feito
~~4~~ ~~recorte por tema~~ feito, e validou o passo 4 feito
5 Krippendorff alpha generaliza o kappa para 3+ avaliadores ⚠️ o único que ainda vale
3 ~~amostra maior~~ morto: precisaria de 1.164 e existem 632 ❌ impossível
4 ~~curva de aprendizado~~ substituída pelo cálculo de poder, que é mais direto ✅ feito
5 ~~teste de permutação~~ ✅ 6 das 7 réguas leem a pergunta ✅ feito
6 ~~matriz de confusão~~ foi ela que achou a abstenção e a assimetria ✅ feito
7 ~~bootstrap × Wilson~~ ✅ os dois intervalos concordam — o n pequeno não distorceu ✅ feito
8 ~~baseline estratificado~~ ✅ 50,8% (contra 56,7% do majoritário) ✅ feito
9 calibração em conjunto congelado re-medir e alertar se cair 2 erros-padrão não rodado

Como reconferir tudo — 2 comandos:

python .claude/medicoes/2026-08-28-variacoes-da-regua/poder.py
python .claude/medicoes/2026-08-28-variacoes-da-regua/abstencao.py

O que este guia não faz

O QUE ELE NÃO PEGA — ele não me obriga a nada. É texto, e texto tem cerca de 90% de adesão
contra quase 100% de uma trava de verdade. Algumas regras têm trava atrás: O teto, A palavra, Sem defeito sintético e Caminho único têm um guarda que barra o trabalho quando eu
desobedeço. Quantas das 18 têm trava eu não contei — e deixar isso escrito, em vez de chutar um
número, é a regra Não medi sendo aplicada a este próprio guia.

O QUE ELE COBRA — nada no seu dia. Ele é lido por você quando você quiser, e por mim quando eu
abrir a régua completa.

COMO SABER QUE FALHOU — você lê uma resposta minha, vê um número solto sem a régua ao lado, ou
uma entrega sem a palavra de fecho, e precisa perguntar "isso está validado mesmo?". A pergunta
é o sintoma.


Livro de bordo

Medido em 2026-08-11 08:00 UTC · a anotação mais recente tem 0.0 dia(s).

Gerado sozinho a cada commit. Não edite à mão — suas edições serão sobrescritas.

parte degrau por quê idade anotações
🟡 Banco de dados mexido a prova (blindado) é anterior ao último commit — envelheceu 1d 4
🟡 Configuração do projeto mexido código mudou, nenhuma prova anotada 0d 11
🟡 Conversa robô↔servidor mexido código mudou, nenhuma prova anotada 0d 3
🟡 Documentação mexido código mudou, nenhuma prova anotada 0d 105
🟡 Endpoints do servidor mexido a prova (blindado) é anterior ao último commit — envelheceu 1d 9
🟡 Login e acesso mexido código mudou, nenhuma prova anotada 0d 1
🟡 Motor de hedge (AF) mexido a prova (blindado) é anterior ao último commit — envelheceu 1d 4
🟡 Núcleo do servidor mexido a prova (blindado) é anterior ao último commit — envelheceu 1d 7
🟡 Rede de testes da tela mexido código mudou, nenhuma prova anotada 1d 23
🟡 Rede de testes do servidor mexido a prova (blindado) é anterior ao último commit — envelheceu 1d 24
🟡 Resto da tela mexido código mudou, nenhuma prova anotada 0d 43
🟡 Sem tema mapeado mexido código mudou, nenhuma prova anotada 0d 1
🟡 Simulador E2E mexido a prova (blindado) é anterior ao último commit — envelheceu 1d 5
🟡 Tela da bancada do robô mexido código mudou, nenhuma prova anotada 2d 2
🟡 Tela de contas mexido código mudou, nenhuma prova anotada 2d 1
🟡 Tela do hedge mexido código mudou, nenhuma prova anotada 1d 11
🔵 Ferramentas de trabalho testado portão do commit: testes passaram em scripts/git/commitar.py, scripts/tests/test_commitar.py 0d 205

O que cada degrau significa

degrau quer dizer
sem sinal nada foi anotado — não confunda com 'está ruim', é ausência de informação
🟡 mexido o código andou e nenhuma prova recente cobre o que ele é hoje
🔵 testado o portão do commit rodou os testes desta parte e eles passaram
🟢 validado passou pelo laço de validação sem achado alto nem crítico
🛡️ blindado o acima + um cético independente tentou derrubar e não conseguiu

O degrau cai sozinho quando o código é tocado depois da prova, ou quando a prova passa de 30 dias.
Nenhum degrau é digitado à mão — todos vêm de anotação com nome de parte.


Mapa da Arquitetura

Status: ACTIVE | Criado: 2026-07-19 (S-atual) | Documento vivo — atualizar selos a cada fase
entregue e promover itens do censo a seção completa (card #568 do Roadmap acompanha).
Fontes: ADR 0029 (a ata oficial da decisão de arquitetura do Portão Único),
specs/identidade-terminal-portao-unico.md, specs/superficie-comunicacao-e-unificacao.md.

Este é o atlas dos pontos únicos do sistema: cada seção mostra um lugar onde antes havia
N cópias espalhadas e hoje (ou em breve) existe UMA fonte de verdade. Este mapa é o índice —
o detalhe fundo vive nos outros livros desta estante (links ao longo do texto).

Como ler os selos: ✅ no ar (te protege hoje) · 🔨 em obra (sendo construído agora) ·
📋 desenho aprovado (decidido, ainda não construído).

O desenho — clique numa peça pra ir à explicação

Trilho 1 · A ordem — do nascimento ao recibo
Trilho 2 · A identidade — quem é quem no prédio
Trilho 3 · A infraestrutura — como o código chega e se protege
Prateleira · A operação no dia-a-dia — peças paralelas (sem ordem entre si)
Em fila · onde ainda há cópias divergindo

1. Como uma conta nasce e entra em operação

Pensa num prédio. A chave que o robô carrega é o crachá: prova que a máquina é
conhecida e diz em qual ala ela entra (o "mundo": real, simulação, stress ou bancada de
dev). O rosto — número do login MT5 + servidor da corretora — escolhe a sala (a conta)
dentro da ala. A chave nunca escolhe a sala.

O caminho, passo a passo (fluxo-alvo, ADR 0029):

  1. Chegada — o robô (EA) sobe numa máquina levando duas coisas na mala: o crachá (a chave)
    e o rosto (login + servidor).
  2. Portaria — o Porteiro confere o crachá: "essa máquina é conhecida e está ativa?" E lê
    no crachá a ala (o mundo).
  3. A sala — procura a conta pelo rosto. A dona da sala é a trinca ala · login · servidor,
    nunca um número solto — dois logins iguais em corretoras diferentes são salas diferentes.
  4. Não achou? A ala decide — na ala real, porta fechada: ninguém entra sem sala
    registrada. Nas alas sim / stress / bancada, cadastra a sala na hora, sempre DENTRO da
    própria ala.
  5. Daí em diante, tudo pelo mesmo portão — o batimento ("estou vivo"), as ordens e as
    confirmações consomem a identidade já resolvida. Ninguém re-pergunta "quem é você?" no meio
    do prédio.

O que cada credencial pode — e não pode:

Credencial No prédio Decide Nunca decide
A chave (no arquivo da máquina) Crachá + seletor de ala "Pode entrar?" e "em qual mundo?" Qual conta é
O rosto (login MT5 + servidor) A cara de quem entra na sala Qual conta, dentro da ala Em qual mundo está

Este fluxo é o desenho aprovado do sistema (ADR 0029, 19/jul). Parte dele já vale hoje;
o resto está entrando por fases — a régua de progresso está na seção 2 abaixo.


2. Identidade — o Portão Único

O problema que essa reforma mata: cada canal de entrada do servidor resolvia sozinho "de qual
conta é esse pedido?". Onde há N porteiros improvisados, sempre sobra um que confere errado.

Antes — cada canal com seu porteiro (fragmentado): quatro portas laterais, cada uma com
critério próprio — a conexão instantânea (WebSocket) e a consulta periódica (Poll HTTP)
resolviam por conta própria; o batimento decidia pela CHAVE (ignorando a corretora) e o
cadastro decidia pelo NÚMERO solto (idem). Agravantes: DOIS classificadores de "mundo" que
discordavam entre si, e o guarda-de-código tinha uma lista de exceção que desligava o alarme
justamente nos infratores. Foi essa classe de brecha que gerou o incidente 83832 — a chave
escolheu a sala errada.

Depois — um portão na borda (unificado): todas as portas dão no mesmo saguão. O portão
(resolve_identidade) confere o crachá, lê a ala numa coluna guardada no banco (não numa
string deduzida) e acha a sala pelo rosto, DENTRO da ala. Sai um objeto pronto —
Identidade { terminal · mundo · conta } — e todo canal só consome; ninguém re-resolve.
Blindagens estruturais: índice único por mundo (cruzar de ala é impossível por construção,
não por disciplina) e um guardião que quebra o build se qualquer canal tentar resolver
pela chave fora do portão — sem lista de exceção.

Antes, a segurança dependia de cada canal LEMBRAR de conferir o rosto; depois, o canal não
tem como esquecer — ele nem resolve, só recebe a identidade pronta. É a diferença entre
disciplina e estrutura.

Régua das fases — o que te protege hoje vs o que vem:

Fase O que entrega Status
F0 O "mundo" vira coluna no banco (com trava de valores) + preenchimento das contas existentes. Invisível pra frota. ✅ no ar
F1 Portão roda em sombra: decide em paralelo e mede divergência — só avança com divergência ZERO na frota real. ✅ no ar (sombra; divergência ZERO medida)
F2 Liga o portão canal por canal (cada um atrás de um interruptor reversível). Fecha os 3 buracos achados na auditoria. 🔨 construída (flag OFF) — aguarda revisão + deploy do dono
F3 A bancada de simulação ganha rosto próprio — e morre o último desvio ("na dúvida, decide pela chave") que só ela usava. 📋 aprovado
F4 Faxina final: consolidar os índices e aposentar de vez a chave gravada na conta. Adiada de propósito (reversibilidade primeiro). 📋 adiada

Honestidade do mapa: a reforma anterior já fez os canais principais conferirem o rosto — o
que sobrou são 3 cantos (achados por uma auditoria com 13 revisores independentes) que
ainda resolvem pela chave/número. A F1 só mede, sem mexer; quem fecha esses cantos de verdade
são as fases F2 e F3.


3. Carteiro — um balcão só cria toda ordem (a desenhar)

Toda ordem nasce num balcão único (create_signal_canonical): o sinal, o pendente, o vínculo
e o recibo são criados juntos, num ato só — ninguém monta ordem "na mão" em outro canto do
sistema. Livro fundo: Carteiros.

4. Pacote único — um formato, dois canais (a desenhar)

O mesmo pacote de dados (_build_ws_payload) viaja pela conexão instantânea (WebSocket) E
pelo poll HTTP — um molde só para as duas rotas, impossível os canais divergirem no conteúdo.
Livro fundo: Carteiros, seção do Entregador.

5. Deploy canônico — um caminho só pra publicar (a desenhar)

Um único caminho de publicação (deploy-canonico.sh → porteiro na VPS, o servidor alugado,
que confere, sincroniza, reinicia e testa) — e as versões novas do robô chegam sozinhas nas
contas (auto-catchup). Livro fundo: Servidor.

6. Mundos — as alas do prédio (a desenhar)

Real, simulação, stress e bancada de dev são um EIXO de primeira classe do sistema. A bancada
de teste não é exceção nem gambiarra: é só um valor não-real desse eixo, isolado por
construção. Livro fundo: Camadas Pool/Sim.


7. Censo — tudo que já virou ponto único

Cada linha é um lugar onde N cópias viraram UMA fonte de verdade. Qualquer linha pode ser
promovida a seção completa deste mapa depois. Censo de 19/jul.

Mecanismo O que virou ponto único Status
Carteiro (criação) Toda ordem nasce num balcão só (create_signal_canonical): sinal + pendente + vínculo + recibo num ato atômico. Migração concluída em mai/26. ✅ no ar
Conferente (recibo) Quando o robô responde "executei / falhei", UM conferidor (apply_ack_canonical) processa o recibo — antes cada rota conferia do seu jeito. ✅ no ar
Plantonista (batimento) O "estou vivo" do robô passa por UM plantonista (apply_heartbeat_canonical), seja pela conexão instantânea ou pelo poll. ✅ no ar
Entregador (pacote único) O mesmo pacote de dados (_build_ws_payload) vai pela conexão instantânea E pelo poll — um molde, duas rotas. ✅ no ar
Porta única no robô Dentro do EA, a recepção de sinal tinha DUAS cópias (instantânea e poll) — e só uma armava a proteção. Virou UMA recepção; a classe do bug "perna nua" morreu. ✅ no ar
Deploy canônico + versão única Um caminho só de publicação, e a versão exibida em todo lugar sai de UMA fonte que sobe a cada deploy (antes: número cravado em 4 lugares, congelado). ✅ no ar
Métricas com linhagem Cada trade carrega um número de jornada (journey_id) e as métricas saem de UM cérebro — antes, telas diferentes calculavam números que divergiam. ✅ no ar
Simulador herda o painel real As métricas mastigadas viraram patrimônio do caminho principal (Real Path); o painel paralelo do SOAK foi absorvido e removido. ✅ no ar
Vigia de saúde O healthcheck que mentia (exames mudos há meses) virou UM vigia honesto + UMA aba Saúde no site. ✅ no ar
Roadmap = TODOs A lista de tarefas vive numa tabela só no banco; a aba Roadmap e o arquivo local são espelhos gerados — ninguém edita cópia à mão. ✅ no ar
Avisos em português claro Toda mensagem de erro que chega em você passa por UM tradutor (user_messages.py) — jargão técnico cru é barrado por guarda automática. ✅ no ar
Duas chaves do robô Chave de entrada (limitada, só cadastro) vs chave por-conta (opera) — papel de cada uma definido num desenho só, trava ligada na frota desde mai/26. ✅ no ar
Catraca de teste na porta Um teste-guarda varre TODAS as portas HTTP do servidor e quebra o build se alguma nascer sem tranca — esquecimento virou impossível (6 furos históricos fechados). ✅ no ar
Sininho filtra por mundo Todo aviso nasce com etiqueta do mundo da conta, e o sino do site filtra na entrada — cenário de simulação não dispara alarme vermelho falso. O Telegram já filtrava; o site passou a filtrar também. ✅ no ar

8. Fila de unificação — onde ainda há cópias divergindo

Cada item nasceu de uma análise de divergência (drift: duas versões da mesma coisa se
desalinhando a cada mudança). Item entregue sobe pro censo; item novo achado em auditoria
entra aqui.

Candidato A divergência de hoje Estado
Portão Único de identidade 3 cantos ainda resolvem conta por chave/número. A fase 1 JÁ RODA em sombra na produção (divergência ZERO medida); a fase 2 (ligar canal a canal) está construída atrás de interruptor desligado, aguardando revisão. É a régua da seção 2. 🔨 em obra (F1 no ar em sombra)
Catraca única de entrada (nega-por-padrão) Hoje cada porta do servidor tranca sozinha (funciona, com a guarda de teste como rede). O desenho final é UMA catraca que barra tudo que não foi liberado. Adiada de propósito: a guarda já entrega ~90% do benefício com risco quase zero. 📋 desenho pronto
Piso de fechamento DOIS temporizadores concorrentes decidem "já posso fechar a posição?" — o desenho aprovado colapsa num piso único com precedência firma > pool > global. 📋 aprovado
Situação única da conta A conta codifica estado em ~8 interruptores soltos e 3 telas separadas de "precisa de você". O desenho aprovado: UMA etiqueta de situação calculada + UMA caixa de entrada de decisões. 📋 aprovado

Sistema de Qualidade

Status: ACTIVE | Ultima revisao: S194 (2026-04-06) — property tests atualizados (test_risk_engine.py), skills /rapid + /triage adicionadas (S192)

SSoT para: Scripts de qualidade, testes automaticos, monitoramento em producao
Relacionados: specs/debugging.md, specs/sweetspot-pilares.md

O que eh

Sistema de 3 camadas que previne e detecta problemas automaticamente:
1. Monitoramento — roda 24/7 na VPS, avisa no Telegram
2. Testes preventivos — roda antes de deployar, encontra bugs antes de irem pra producao
3. Testes de resiliencia — simula falhas pra ver se o servidor aguenta

Onde fica cada arquivo

scripts/quality/
  quality_monitor.py   <- Roda na VPS a cada 15min (cron)
  (watchdog.py APOSENTADO S433 -> dobrado no vigia-mutuo in-server, health_checks.py)
  invariant_check.py   <- Versao standalone dos invariantes (backup)
  reconcile.py         <- Versao standalone da reconciliacao (backup)
  fault_injection.py   <- Simula falhas (rodar manualmente)
  .state/              <- Arquivos de estado (heartbeat, dedup)

tests/property/
  test_pure_functions.py  <- 30 testes: volume, P&L, NaN, market hours
  test_risk_engine.py     <- 22 testes: risk, daily DD, OSB, dist/cush

.claude/hooks/
  pre-deploy-quality.sh   <- Hook: roda testes antes de SCP pro VPS

Camada 1: Quality Monitor (VPS, automatico)

O que faz

A cada 15 minutos, roda 9 queries SQL no banco verificando regras que nunca devem ser quebradas. Se alguma quebrar, manda Telegram.

Cron

*/15 * * * *  quality_monitor.py  (9 checks)
# watchdog.py APOSENTADO S433 -> dobrado no vigia-mutuo in-server (health_checks.py, 5min)

Os 9 checks

# Check Severidade Quando roda O que verifica
1 same_sign_pnl CRITICAL Sempre Ambos lados do hedge com mesmo sinal P&L
2 duplicate_af_trades CRITICAL Sempre Ticket duplicado (lucro contado 2x)
3 net_exceeds_risk HIGH Sempre Custo hedge > risco configurado
4 stale_heartbeat HIGH (sem Telegram**) Sempre* EA sem heartbeat (15min dia util, 30min FDS)
5 phantom_positions HIGH Mercado aberto DB diz posicao aberta, EA nao ve
6 signal_without_ack HIGH Mercado aberto Signal enviado mas ninguem respondeu
7 ghost_positions MEDIUM Mercado aberto EA ve posicao que DB nao conhece
8 orphan_trade_links MEDIUM Sempre Trade links abertos ha 24h+
9 pnl_suspect_unresolved MEDIUM Sempre Pares suspeitos sem atencao ha 12h

*Check 4 (stale_heartbeat) roda sempre mas com tolerancia adaptativa:
- Dia util (mercado aberto): alerta apos 15 minutos sem heartbeat
- FDS/fora de horario: alerta apos 30 minutos (cobre restarts de PC)

Check 4 nao manda Telegram (decisao do dono, 2026-07-30). Motivo: EA offline
JA e' avisado pelo vigia de saude (scripts/vps/healthcheck_v2.sh --full), com
nome da conta, ha quanto tempo e desde quando. Este check repetia a MESMA noticia
15min depois como stale_heartbeat: 4x — sem nome, sem tempo. Ele continua
rodando normalmente: aparece no log, no arquivo de batimento e e' consultavel por
/saude no Telegram; so o alerta duplicado sumiu. A severidade continua HIGH de
proposito
— rebaixar pra MEDIUM so pra conseguir silencio seria mentir sobre a
gravidade. O silencio e' declarado no campo telegram: False do catalogo, e
send_alerts() o respeita. Rede: server/tests/test_saude_sob_demanda.py.

Alertas Telegram

Watchdog (APOSENTADO S433 — dobrado no in-server)

O antigo watchdog.py (cron */30) foi aposentado; vigiar se o quality_monitor parou passou pro vigia-mutuo IN-SERVER (server/routes/health_checks.py::schedule_watchdog_freshness_check, laço de 5min, lê o mesmo heartbeat file). Se passar de 30min sem rodar, avisa no Telegram: "O quality monitor parou de rodar". Detalhe: specs/malha-de-saude.md.

Como adicionar um novo check

Editar quality_monitor.py, adicionar entrada no dict CHECKS:

"nome_do_check": {
    "description": "O que verifica em linguagem simples",
    "severity": "CRITICAL|HIGH|MEDIUM",
    "market_only": True|False,
    "action": "O que fazer quando violacao encontrada",
    "query": "SELECT ... FROM ... WHERE <condicao_de_violacao>"
}

Camada 2: Property Tests (local, antes do deploy)

O que faz

Extrai funcoes do servidor (sem precisar de banco de dados) e joga milhares de inputs aleatorios tentando quebrar. Se quebrar, mostra exatamente qual input causou o problema.

Como rodar

pytest tests/property/ -v       # Rodar todos (52 testes, ~30s)
pytest tests/property/ -q       # Resumido

Os testes

Arquivo Grupo Testes O que verifica
test_pure_functions.py calculate_volume 5 Lot size nunca negativo, nunca NaN, sempre 2 decimais
test_pure_functions.py validate_pair_pnl 6 Validacao hedge: mesmo sinal rejeitado, oposto aceito
test_pure_functions.py clean_nan_json 8 NaN/Infinity limpos do JSON do EA
test_pure_functions.py market_hours 8 Sabado fechado, domingo abre 18h, etc
test_pure_functions.py financial_invariants 3 Propriedades matematicas do hedge
test_risk_engine.py individual_risk 5 Risk nunca negativo pra conta viva, nunca > hard cap $3k, nunca > daily DD
test_risk_engine.py calc_risk 3 Risk do par sempre >= 0
test_risk_engine.py daily_dd 5 DD remaining nunca negativo, nunca excede limite operacional
test_risk_engine.py osb_weight 5 Anti-OSB detection correto
test_risk_engine.py dist_cush 4 Distancia/cushion nunca negativo, dead account risk <= cushion

Nota S194: test_risk_engine.py refatorado — importa funcoes reais de app.af.engine (SSoT) em vez de manter copias locais (FakeProp/FakePA removidos, -154 linhas).

Hook pre-deploy (automatico)

Quando eu faco SCP de arquivo .py pro VPS, o hook pre-deploy-quality.sh roda automaticamente:
- Se testes passam → deploy prossegue
- Se testes falham → deploy BLOQUEADO com mensagem de erro


Camada 3: Fault Injection (manual, sob demanda)

O que faz

Manda requests "errados" de proposito pro servidor e verifica que ele nao crasha (retorna 500).

Como rodar

python scripts/quality/fault_injection.py --all        # Todos os 8 cenarios
python scripts/quality/fault_injection.py --scenario X  # Um cenario especifico
python scripts/quality/fault_injection.py --dry-run     # Mostra o que faria

Os 8 cenarios

Cenario O que simula Esperado
duplicate_signal Signal repetido (mesmo ticket) Anti-loop ignora o 2o
ack_without_signal ACK pra signal que nao existe 404 (nao 500)
malformed_heartbeat Dados com NaN/Infinity Trata sem crashar
rapid_fire_signals 10 signals em 2 segundos Nao crasha
stale_ack ACK pra signal antigo Trata com graca
wrong_direction direction="SIDEWAYS" Nao crasha
zero_volume volume=0 Nao crasha
huge_values Numeros gigantes Nao crasha

Quando rodar


Bugs encontrados ate agora

Data Bug Como encontrou Impacto
2026-03-29 calculate_volume crash NaN Property test (tick_value=inf) Poderia travar calculo de volume
2026-03-29 clean_nan_json arrays Property test ([NaN,...]) NaN passava pro banco
2026-03-29 same_sign_pnl par 195524 Quality monitor (1a rodada) Edge case mercado, nao bug

Metricas de eficacia

Metrica Como medir Meta
Violations no monitor Log + Telegram 0 CRITICAL, < 3 HIGH/dia
Property tests passando pytest tests/property/ 52/52 (100%)
Fault injection passando fault_injection.py --all 8/8 (100%)
Bugs encontrados por property test Contagem no changelog Crescente
Tempo sem bug silencioso Dias desde ultimo bug encontrado pelo monitor Crescente

Copy Trade

Status: ACTIVE | Ultima revisao: S315.1 (2026-05-02) — Onda 5 cleanup: Etapa 4 aponta canonical (create_signal_canonical + dispatch_pending_to_eas). Detalhes em specs/signal-dispatch-canonical.md.

Versao: 2.3
Data: 2026-05-02
Status: v2.3 (S315.1 Onda 5): Etapa 4 atualizada — Signal+PendingSignal+TradeLink+SignalAck criados via create_signal_canonical() (SSoT em server/signal_dispatch.py). WS push pos-commit via dispatch_pending_to_eas() (substitui _push_signals_to_eas legacy, removido). Origin agora canonico via enum OriginType (lista exaustiva — ver specs/signal-dispatch-canonical.md). Fallback signal.origin or "ea" removido em ACK logging (signals legacy ficam origin=NULL, dashboard renderiza "(legacy)"). v2.2: Adicionado secao Trace ID. v2.1: EA remote logs pipeline, mapa de funcoes EA/servidor, ACK validation V1-V4, reconciliation pos-trade, grupo-only routing (S1015).
SSoT para: Ciclo completo de vida de um sinal — da deteccao ate o trade_link


Visao Geral

┌─────────────────────────────────────────────────────────────────────────────┐
│                          CICLO DE VIDA DE UM SINAL                         │
│                                                                            │
│  EA Master                Servidor                    EA Slave             │
│  ─────────               ─────────                   ─────────            │
│  1. Detecta trade ──WS/HTTP──> 2. Valida + armazena                       │
│     (OTT ou SE)                   Signal + PendingSignal                   │
│                                   + TradeLink (origin)                     │
│                                3. Distribui ──WS push──> 4. Recebe sinal  │
│                                   (aplica invert)   HTTP poll (safety net) │
│                                                      5. Executa via GUI   │
│                          6. Recebe ACK <──HTTP POST── 6. Envia ACK        │
│                             SignalAck + TradeLink                          │
│                             PendingSignal.resolved                         │
└─────────────────────────────────────────────────────────────────────────────┘

Etapa 1 — Deteccao de Trade (EA Master)

O EA tem 2 mecanismos paralelos que detectam trades. Ambos alimentam a mesma fila.

Mecanismo A: OnTradeTransaction (OTT) — instantaneo

Aspecto Detalhe
Arquivo Experts/LinniuC.mq5:1342
Trigger TRADE_TRANSACTION_DEAL_ADD (qualquer deal executado)
Latencia ~0ms (evento nativo MT5)
Campo detection_method = "OTT"

Fase 1 (sempre executa, mesmo com g_busy=true):
- Armazena SE_DealHint via SE_StoreDealHint() com: positionId, symbol, entry (IN/OUT), closeReason (SL/TP/STOP_OUT/MANUAL), dealVolume, dealPrice
- TTL do hint: 10 segundos | Buffer: 10 hints max (SE_MAX_DEAL_HINTS)

Fase 2 (so se g_busy=false):
- Chama SnapshotEngine_ProcessCycle() + SnapshotEngine_FlushQueueWeb() imediatamente

Mecanismo B: SnapshotEngine (SE) — polling via timer

Aspecto Detalhe
Arquivo Include/CopyTrade/SnapshotEngine.mqh:628
Trigger Timer a cada InpPollingMs (200ms) + OnTick()
Latencia 0-200ms (pior caso = intervalo do timer)
Campo detection_method = "SE"

Logica de comparacao (SE_CompareSnapshots, linha 252):

Situacao Acao
Ticket em curr mas nao em prev SE_EnqueueOpen()
Ticket em ambos, SL/TP/volume mudou SE_EnqueueModify() (com anti-echo)
Ticket em prev mas nao em curr SE_EnqueueClose() (com anti-echo)

Como decide OTT vs SE: No momento do enqueue, verifica SE_HasDealHint(ticket):
- Hint existe → detection_method = "OTT"
- Hint nao existe → detection_method = "SE"

Regra R1: Primeira execucao = baseline

Na primeira chamada, o SE apenas armazena o snapshot atual como g_se_prevSnap. Nenhum sinal e gerado. Isso evita que todas as posicoes existentes sejam reportadas como "novas" ao iniciar o EA.


Etapa 2 — Fila de Sinais (SE Queue)

Arquivo: SnapshotEngine.mqh

Aspecto Valor
Capacidade SE_MAX_QUEUE = 200 sinais
TTL por item 300 segundos (5 min) — expirado = descartado
Estrutura SE_QueueItem: action, ticket, symbol, direction, volume, sl, tp, retryCount, enqueuedTick, close_reason, detection_method, deal_price (preco de fill exato do OTT), profit (DEAL_PROFIT + COMMISSION + SWAP)
Max retries SE_MAX_RETRIES = 5 — sinal descartado apos 5 falhas
detection_method OTT (OnTradeTransaction), SE (SnapshotEngine), af_engine (AF hedge), af_push_modify (AF MODIFY push)

Regra R2: CLOSE tem prioridade maxima

CLOSE vai para o INICIO da fila (linha 364-377). OPEN e MODIFY vao para o final.

Regra R3: Deduplicacao

MODIFY ja existente na fila para o mesmo ticket: atualiza sl, tp, volume no lugar (nao duplica).

Regra R4: Fila cheia = descarte

Se fila tem 200 itens, novos sinais sao descartados com log [ALERTA] Fila CHEIA.


Etapa 3 — Envio do Sinal (EA → Servidor)

Arquivo: SnapshotEngine.mqh, funcao SnapshotEngine_FlushQueueWeb() (linha 424)

Cooldowns por tipo (independentes entre si)

Tipo Cooldown Efeito
CLOSE 500ms CLOSE nao eh bloqueado por OPEN recente
MODIFY 1000ms Evita flood de MODIFY rapidos
OPEN 500ms Evita flood de OPEN rapidos

Retry por item

Tentativa Delay
1a 1s
2a 2s
3a 4s
4a 8s
5a 16s (cap)
Max retries 10 — depois descarta com log "orphan checker no servidor ira compensar"

Canal primario: WebSocket

Funcao: SE_SendSignalViaWS() (linha 389)

{
  "type": "signal",
  "action": "OPEN",
  "ticket": 123456,
  "symbol": "XAUUSD",
  "direction": "BUY",
  "volume": 0.10,
  "price": 2650.00,
  "sl": 2640.00,
  "tp": 2660.00,
  "close_reason": "",
  "signal_channel": "ws",
  "detection_method": "OTT",
  "queued_duration_ms": 15
}

Se WS_IsConnected() retorna false OU SE_SendSignalViaWS() falha → fallback imediato para HTTP.

Canal fallback: HTTP POST

Funcao: WebBridge_PostSignal() (WebBridge.mqh:686)
Endpoint: POST /api/signals
Headers: X-API-Key, X-Account-Num, Content-Type: application/json

Tipo Tentativas HTTP
CLOSE 3 tentativas com Sleep(500*attempt) entre elas
OPEN/MODIFY 1 tentativa

Etapa 4 — Processamento no Servidor

Arquivo: /opt/copytrade-server/app/routes/signals.py
Endpoint: POST /api/signals

SSoT canonica (S314.6+, parcial — Onda 4 lifecycle pendente): Signal+PendingSignal+TradeLink+SignalAck sao criados via create_signal_canonical() em server/signal_dispatch.py (B7+B11 fix — single source) em rotas dashboard (broadcast_*) + AF engine 100% (af/signals.py + routes/af.py — S315.0). Master OPEN do endpoint EA POST /api/signals (batch insert ~linha 762) ainda usa sa_insert(Signal).values(signal_rows) batch insert manual (NAO o helper create_signal_canonical()) — sera migrado em Onda 4 lifecycle. Atualizacao S361 (doc-fresh): origin e signal_source JA usam enums canonicos (OriginType.EA.value / SignalSource.EA.value, linhas ~747/755), NAO mais strings cruas "ea"/"manual" — o drift R9 de string hardcoded foi resolvido; resta apenas o batch insert nao passar pelo helper unico. WS push pos-commit via dispatch_pending_to_eas(db, trade_group_id) 100% canonical em todos os paths. Detalhes completos + escopo Onda 4: specs/signal-dispatch-canonical.md.

Fluxo geral

  1. Valida API key (X-API-Keyverify_api_key)
  2. Identifica conta master pelo api_key
  3. Processa conforme action

OPEN

  1. Gera trade_group_id (UUID)
  2. Calcula sl_distance = abs(price - sl), tp_distance = abs(tp - price)
  3. Master: create_signal_canonical(origin=OriginType.EA, signal_source=SignalSource.EA, create_pending_signal=False, create_trade_link=True, is_origin_link=True, create_origin_ack=True) — cria Signal + TradeLink(is_origin=True) + SignalAck("ORIGIN") atomico
  4. Busca peers do mesmo group_id (exceto master)
  5. Para cada peer:
  6. Se peer.invert != account.invert → caller aplica _invert_direction/_invert_sl_tp ANTES (R8: caller invert, canonical e agnostica)
  7. create_signal_canonical(create_pending_signal=True, expires_in_seconds=PENDING_SIGNAL_TTL_SECONDS) — cria Signal + PendingSignal (TTL 60s)
  8. Pos-commit: await dispatch_pending_to_eas(db, trade_group_id) faz WS push uniforme via _build_ws_payload (R11)

Regra R5: Circuit Breaker

Se conta slave tem >= CIRCUIT_BREAKER_MAX_POSITIONS (10) posicoes abertas, o OPEN e bloqueado para aquela conta.

MODIFY

  1. Busca TradeLink ativo pelo ticket do master
  2. Busca peers via trade_group_id
  3. Verifica suppress_marker — se existe para trade_group_id + MODIFY, ignora (anti-echo)
  4. Para cada peer: create_signal_canonical(action="MODIFY", ticket=local_ticket_do_peer, ...) — cria Signal + PendingSignal
  5. Caller aplica inversao SL/TP ANTES (R8) se peer.invert != account.invert
  6. Pos-commit: await dispatch_pending_to_eas(db, trade_group_id)

CLOSE

  1. Busca TradeLink ativo pelo ticket
  2. Late DEAL_PROFIT: Se TradeLink ja foi fechado pelo heartbeat (orphan closer), atualiza profit + close_price do link existente e retorna "profit_updated" (sem re-fechar)
  3. Fecha TradeLink da conta master (is_closed=True, closed_at=now)
  4. P&L Priority Chain:
  5. deal_price do EA (preco exato de fill do deal) — prioridade maxima
  6. deal_profit do EA (DEAL_PROFIT + COMMISSION + SWAP consolidado)
  7. Fallback: OpenPosition cache (ultimo bid do heartbeat)
  8. Fallback: position cache (posicao pre-close)
  9. Fallback: zero
  10. Busca peers via trade_group_id
  11. Verifica suppress_marker — se existe para trade_group_id + CLOSE, ignora
  12. Para cada peer aberto: create_signal_canonical(action="CLOSE", ticket=local_ticket_do_peer, ...) — cria Signal + PendingSignal
  13. Cria SuppressMarker com TTL 10s para o trade_group_id (anti-echo servidor)
  14. Trade Event Log: log_trade_event(event_type="CLOSE", close_reason, profit, close_price)
  15. Pos-commit: await dispatch_pending_to_eas(db, trade_group_id)

Etapa 5 — PendingSignal (fila de distribuicao)

Tabela: pending_signals

Campo Descricao
account_id Conta slave destino
signal_id FK para signals
trade_group_id UUID do grupo
expires_at now + 60s (1 minuto, centralizado em constants.py)
resolved false ate execucao ou expiracao

Regra R6: TTL de 5 minutos

PendingSignal expira em 60s (1 min). Sinais OPEN nao executados sao auto-fechados pela background task (a cada 15s). Sinais MODIFY/CLOSE expirados sao marcados resolved=True pelo cleanup periodico (loop a cada 15min em main.py).

Regra R7: Cleanup manual disponivel

Endpoint POST /api/admin/cleanup/pending-signals para forcar limpeza.


Etapa 6 — Distribuicao (Servidor → EA Slave)

Via WebSocket push (instantaneo)

O servidor chama ea_ws_manager.notify_account(account_id, "new_signal") que envia o sinal completo via WS.

Via HTTP polling (safety net)

Endpoint: GET /api/signals/pending
Funcao EA: WebBridge_PollSignals() (WebBridge.mqh:760)

Situacao Intervalo de poll
WS conectado e ativo 10 segundos (safety net)
WS desconectado 1-3 segundos (configuravel InpPollInterval)

Regra R8: Dupla garantia WS + HTTP

O EA SEMPRE faz poll HTTP, mesmo com WS ativo. O WS acelera a entrega, o HTTP garante que nada se perde.

Retomada apos reconexao WS (v3.77+, S253 Onda 2)

Resume semantics — EA informa "ate onde ja processou":

Por que: antes, ao reconectar, servidor re-enviava TODOS os pendings unresolved. Se EA ja processou via HTTP poll no intervalo (comum — HTTP 1-3s vencia WS reconnect 22s backoff na S252), o buffer circular g_processedSignalIds[100] protegia mas gerava log noise + trafego redundante.

Fail-open (backward compat):
- EA velho sem campo: last_signal_id=0 -> envia tudo (comportamento pre-S253 preservado)
- Arquivo corrompido: StringToInteger retorna 0 -> mesmo fallback
- Escrita em disco falha: global atualizado mas nao persistido -> proximo restart re-flusha (buffer 100 IDs protege)

Monotonic: WS_SetLastSignalId so aceita sigId > g_ws_lastSignalId. Evita regressao se LNC_MarkSignalProcessed receber IDs fora de ordem.

Paridade HTTP/WS em detect_missing_positions (v3.77+, S253 Onda 2)

_detect_missing_positions (detecta posicoes orfas comparando HB vs TradeLink) agora roda em AMBOS os paths:
- WS heartbeat (_handle_ws_heartbeat): desde S224+
- HTTP heartbeat (POST /api/heartbeat): desde S253 Onda 2 (antes: delay de 15min via orphan task)

Anti-race: Advisory lock Postgres (pg_try_advisory_lock(account_id)) impede concorrencia. Se WS path ja esta rodando, HTTP path skipa (nao-blocking). Evita double-close de posicao.


Etapa 7 — Execucao do Sinal (EA Slave)

Arquivo: Experts/LinniuC.mq5 + Include/CopyTrade/GUIExecution.mqh

Pre-execucao

Check Detalhe
Anti-retry g_processedSignalIds[] (buffer circular de 100 IDs). Sinal ja processado → ignora, mesmo que execucao tenha falhado
Idade maxima (TimeGMT() - signalTime) > InpSignalMaxAge (60s) → descarta com ACK SKIPPED

OPEN (GUIExecution_Open, GUIExecution.mqh:765)

  1. GUI_AcquireGUILock() — lock exclusivo em arquivo (TTL 30s para stale locks)
  2. GUI_EnsureVisible() — restaura MT5 se minimizado
  3. GUI_CloseAllMT5Dialogs() — fecha dialogos residuais
  4. GUI_OpenF9()WM_COMMAND 32848 abre dialogo "Nova Ordem"
  5. GUI_WaitForDialog() — timeout InpTimeoutMs (5000ms)
  6. GUI_SelectSymbol() — seleciona simbolo no combo (ID 10331)
  7. GUI_WaitForSubDialog() — aguarda controles carregarem
  8. Preenche volume (10333), SL (10334), TP (10336)
  9. Clica BUY (10408) ou SELL (10409)
  10. Loop de confirmacao: aguarda dialogo fechar OU detecta popup do broker
  11. GUI_TryConfirmBrokerPopup() — confirma popups (leverage, risk) ou detecta erros (failed, insufficient)

Pos-execucao:
- GUIExecution_DetectNewTicket() — compara lista de posicoes antes/depois
- Fallback: SE_FindRecentOpenDeal() usa DealHint do OTT
- SE_SuppressTicket(newTicket, 5) — suprime re-deteccao por 5s (Pattern #34, S84)

MODIFY (GUIExecution_Modify, GUIExecution.mqh:844)

  1. GUI_AcquireGUILock()
  2. GUI_OpenPositionDialogSafe(ticket) — localiza na ListView (10328), duplo-clique
  3. GUI_VerifyDialogTicket() — verifica titulo contem ticket esperado
  4. Seleciona "Modificar" no ComboBox de tipo (10338)
  5. Aguarda botao Modificar (10351) aparecer
  6. Preenche SL e TP, clica Modificar
  7. SE_SuppressTicket(localTicket, 10) — suprime por 10 segundos

CLOSE (GUIExecution_Close, GUIExecution.mqh:948)

  1. GUI_AcquireGUILock()
  2. GUI_EnsureVisible()
  3. Ate 3 tentativas com GUI_ForceCloseResidualDialogs() entre elas
  4. GUI_OpenPositionDialogSafe(ticket) — ListView → duplo-clique
  5. Clica botao Close (10410)
  6. Loop aguarda fechamento
  7. SE_SuppressTicket(localTicket, 10) — suprime por 10 segundos

Resiliencia a broker/prop firm lento (S395 — EA 3.114.1)

Principio-mestre: o tempo NUNCA decide sucesso/falha — so o FATO (evento OnTradeTransaction, estado real de posicao, ou popup de rejeicao). O teto de tempo e' so o orcamento de "esperar travado" antes de entregar pra rede de reconciliacao. Caso rapido (broker normal) = identico a antes (~100ms).

Mecanismo Comportamento
Cap de confirmacao configuravel (D2) Laco de confirmacao pos-clique (OPEN/MODIFY/CLOSE) usa g_gui_confirmTimeoutMs (servidor manda confirm_timeout_ms default 15000; fallback local 2000) em vez do for(t<2000) fixo. NAO confundir com a janela-de-aparecer (g_gui_timeoutMs=5000, intocada) nem com o settle de campo do MODIFY.
Fail-fast em rejeicao (R4) GUI_TryConfirmBrokerPopup seta g_gui_lastRejection ao detectar popup de ERRO; os 3 lacos quebram cedo (nao esperam o cap a toa). O caller usa pra separar FAILED-real de cap-estourou.
Fila de confirmacao adiada (D3) Cap estourou SEM fato E SEM rejeicao → LNC_ScheduleConfirm enfileira (g_confirmQueue[4]) + LNC_MarkSignalProcessed (anti-dobra) + solta o lock. LNC_CheckConfirmQueue (no OnTimer, roda mesmo com mercado parado) re-confere: OPEN via SE_FindRecentOpenDeal + fallback HistorySelect correlacionado por tempo; CLOSE via !PositionSelectByTicket. FILLED tardio (deferred_*_confirmed) ou FAILED-real so apos CONFIRM_TOTAL_WINDOW_MS=30s. Anti-orfa: nunca declara FAILED por impaciencia.
MODIFY em ordem fechada → FILLED-noop (D4) Bail !PositionSelectByTicket ANTES de GUIExecution_Modify → ACK FILLED reason="modify_noop_position_closed" (nao FAILED). Evita o alerta enganoso "par desalinhado". Servidor: TradeLink so criado em action=="OPEN" (MODIFY-noop nao ressuscita ticket fechado).
Aviso de delay leigo (D5) ack_helpers.py [DELAY]/[DELAY GUI] usam user_messages.delay_cause_message() — explica a causa (corretora lenta na tela vs espera de entrega) sem jargao. Gate is_test/is_simulator preservado.
Simulacao de latencia (D7) sim_broker_delay_ms (so sandbox; pool SEMPRE 0 por gate server-side) — GUI_SimGatePassed segura a confirmacao pra exercitar o caso lento de forma deterministica.

Pendente (TODO #376): D6 leitura-direta-do-texto da linha (LVM_GETITEMTEXTW) — otimizacao de caso raro (row-shift), adiada. SSoT completo: specs/broker-latency-resilience-S395.md.


Etapa 8 — ACK (EA Slave → Servidor)

Arquivo: WebBridge.mqh:808
Endpoint: POST /api/signals/{signalId}/ack

Payload

{
  "status": "FILLED|FAILED|SKIPPED",
  "local_ticket": 789012,
  "error_msg": "",
  "open_price": 2650.12,
  "applied_sl": 2640.00,
  "applied_tp": 2660.00,
  "actual_volume": 0.10,
  "receive_channel": "ws_push|http_poll",
  "gui_duration_ms": 1450
}

Status possiveis

Status Quando
FILLED Execucao GUI bem-sucedida
FAILED GUI falhou ou ticket nao encontrado
SKIPPED Sinal muito antigo (> InpSignalMaxAge)

Retry do ACK

Fase Tentativas Backoff
Imediata 3x 1s, 2s (exponencial)
Fila persistente Ilimitado (max 20 na fila) 5s → 10s → 20s → 30s (cap)

O que o servidor faz com o ACK

  1. Cria/atualiza SignalAck com todos os campos
  2. Se FILLED: cria TradeLink com is_origin=False, local_ticket do slave
  3. Marca PendingSignal.resolved=True
  4. Push WS para dashboard
  5. MODIFY ACK (v2.0): Captura applied_sl vs target_sl, applied_tp vs target_tp — mostra divergencia entre o que foi pedido e o que o broker aplicou
  6. Trade Event Log: log_trade_event(event_type="ACK", status, action="OPEN"|"MODIFY", context)

Delay Audit Fields (v2.0)

Campo Descricao
server_received_at Timestamp de chegada no servidor
server_processing_ms Tempo do recebimento ate commit no DB
receive_channel ws_push ou http_poll (como o slave recebeu)
gui_duration_ms Tempo de execucao GUI no slave
total_delay_ms De signal.created_at ate ack.executed_at (latencia total)

Exec Slippage Fields (v2.4 — S336+)

Capturados pelo EA via SymbolInfoTick() 1 linha antes de GUIExecution_Open/Close/Modify. Servidor calcula slippages no handler ACK. Backwards-compat: ACKs pre-S336 (EA build < 3.85.3) tem campos NULL.

Campo Tipo Descricao
bid_at_request Float BID via SymbolInfoTick no momento do click GUI (R1)
ask_at_request Float ASK via SymbolInfoTick no mesmo instante (par sempre junto — INV4)
exec_slip_pts Float Slippage GUI+broker: \|deal_price - quote_at_request_lado_correto\|. NULL pra MODIFY (sem deal_price) ou stale (>60s).
pipeline_slip_pts Float Slippage rota inteira: \|deal_price - signal.price\|. Independe de bid/ask.

Lados da cotacao (R2):
- OPEN BUY -> ASK (compra ao ASK)
- OPEN SELL -> BID (vende ao BID)
- CLOSE BUY -> BID (vende pra fechar)
- CLOSE SELL-> ASK (compra pra fechar)
- MODIFY -> guarda bid+ask pra audit (saber se preco passou pelo SL/TP em transito), exec_slip = NULL

Tag close_reason="ROLLOVER" (Layer 1 EA anti-swap): Rollover_TryClose chama Rollover_TagTicket antes do GUI close; SnapshotEngine override close_reason -> "ROLLOVER" em vez de "MANUAL" (default DEAL_REASON_CLIENT). Distingue de Layer 2 servidor (close_reason="ROLLOVER_FALLBACK" via close_pair_positions).

Spec completa: specs/exec-slippage-telemetria.md.


Tabela: trade_links

Campo Master Slave
trade_group_id UUID (gerado no OPEN) mesmo UUID
is_origin True False
local_ticket ticket MT5 master ticket MT5 slave
account_id ID conta master ID conta slave
is_closed false → true false → true

Regra R9: trade_group_id e o elo

Todo MODIFY e CLOSE usa o trade_group_id para encontrar quais slaves precisam ser notificados. Sem TradeLink = sinal nao propaga.


Caminhos Alternativos e Edge Cases

EC1: Falha de WebSocket

Situacao Comportamento
WS desconecta Poll HTTP assume (1-3s). WS tenta reconectar com backoff 10s → 300s
WS morto >30s sem atividade g_ws_connected resetado, volta ao HTTP
WS reconecta Servidor envia PendingSignals pendentes

EC2: Orphan Checker (posicoes orfas) — 2 mecanismos

Mecanismo A — Background task (ativo):

Aspecto Detalhe
Intervalo 15 minutos (main.py:24)
Logica Busca TradeLink WHERE is_closed=False cujo local_ticket nao aparece em open_positions
Acao Marca TradeLink como fechado. Propaga CLOSE para peers abertos (Signal com origin="server", TTL 1min)

Mecanismo B — Heartbeat passivo (v2.0):

Aspecto Detalhe
Trigger Cada heartbeat POST do EA
Logica _detect_missing_positions(): compara posicoes reportadas pelo EA vs TradeLinks abertos
Threshold Posicao ausente por >30s
Acao Cria CLOSE signal com origin="server", propaga para peers

Mecanismo B detecta closes mais rapido que A (dentro de 1 heartbeat vs 15min).

EC3: Anti-Echo (2 niveis)

Problema: EA slave executa um trade → SnapshotEngine detecta como "novo" → reenvia ao servidor → loop infinito.

Nivel 1 — EA (SE_SuppressTicket):

Acao executada TTL de supressao
OPEN 5 segundos
MODIFY 10 segundos
CLOSE 10 segundos

Buffer: g_se_suppressTickets[] (max 20). Antes de enfileirar MODIFY ou CLOSE, verifica SE_IsTicketSuppressed().

Nota: SE_MAX_RETRIES define 5 em SnapshotEngine.mqh:43, mas FlushQueueWeb usa hardcoded >= 10. O valor efetivo e 10 retries.

Nivel 2 — Servidor (suppress_markers):

Origem do sinal TTL do marker
MODIFY/CLOSE (qualquer origem) 10 segundos

Nota (v2.0): Implementacao atual usa 10s para TODAS as origens (EA e dashboard). A diferenciacao 10s/15s documentada na v1.0 nao foi implementada.

Quando master envia MODIFY/CLOSE, servidor verifica se existe marker ativo → ignora (evita loop).

EC4: Partial Close

EC5: Posicao ja fechada no CLOSE

EC6: Signal do Dashboard (broadcast)


Estado Afogado + Visibilidade de Saturacao (S386, v2.4)

NAO altera as regras R1-R10 acima. Adiciona 3o estado de conta entre alive e offline + 2 guardas no dispatch (dedup #330 + back-pressure SKIP). Invariante S2-zero-sum de hedge preservado por construcao.

Nota: as referencias a "invariante S2-zero-sum" abaixo apontam pra regra interna do modulo saturation.py — NAO confundir com a R2 deste documento ("CLOSE tem prioridade maxima").

Problema: o EA executa ordens via cliques de GUI Win32 (GUIExecution.mqh), seriais, no MESMO OnTimer que envia batimento. Quando a tela ocupa o laco, o batimento estica e o servidor fica cego — sem saber distinguir "EA morreu" de "EA esta ocupado executando". Pior: o servidor reemitia CLOSEs do mesmo ticket em loop (classe do #330) achando que o EA nao recebeu.

Solucao: 3o estado saturated + dedup in-flight + back-pressure SKIP. Modulo central: server/saturation.py (3 funcoes puras + store in-memory).

Estados de conta

Estado Quando Efeito no dispatch
alive gap < SATURATED_HB_GAP_SEC (15s) OU gap < 60s sem trabalho pendente Round novo entra, dispatch normal
saturated beacon ea_busy ativo dentro de BEACON_HARD_CAP_SEC (90s) OU gap > 15s com trabalho pendente/sem-ACK Conta NAO entra em round novo; OPEN novo independente eh SKIPADO (S2-zero-sum preservado — perna completante + CLOSE/MODIFY passam)
offline gap > 60s E sem beacon ativo Round novo nao entra; pares ja abertos seguem para fechamento normal

Backstop: mesmo sem beacon (EA velho/travou), inferencia por ritmo marca saturated se gap > 15s com pendente.

Regra R11: Beacon ea_busy (EA → Servidor, dispara-e-esquece)

EA dispara frame WS {"type":"ea_busy","op":<OPEN|MODIFY|CLOSE>,"ticket":<n>,"expected_ms":<n>} IMEDIATAMENTE APOS pegar o lock de GUI (GUI_AcquireGUILock em GUIExecution.mqh), antes do trabalho lento de tela (GUI_EnsureVisible, GUI_ExecuteOpenInternal). Se o lock falhar, o beacon NAO eh enviado. Dispara-e-esquece — sem retry, sem ACK queue, timeout <=200ms. Handler servidor: routes/ea_ws.py::_handle_ws_ea_busy -> saturation.set_busy(account_id, op, ticket). Beacon explica silencio longo (gap > 60s mas conta esta saturated, nao offline).

Regra R12: Dedup in-flight (mata classe do #330 na origem)

Antes de criar PendingSignal pra (conta, ticket, action=CLOSE), signal_dispatch.create_signal_canonical checa se ja ha pendente NAO-resolvido (sem ACK, dentro do TTL) pro mesmo (conta, ticket) -> pula + emite evento DISPATCH_DEDUP + retorna skipped_duplicate=True, skip_reason="dedup_inflight_close". Excecao: ACK FAILED do EA resolve o pendente legado MAS cria retry-PS novo (+70s) — dedup AINDA suprime re-CLOSE server-originated enquanto retry-PS estiver em-voo (correto, anti-#330: nao empilhar enquanto o retry do EA esta pendente). Liberacao real: FILLED, 3 retries exauridos, OU TTL expirou. Escopo: SO CLOSE (idempotente). MODIFY fica com a supressao anti-echo do SuppressMarker (descrita acima nas regras de processamento server-side) + dedup da fila do EA.

Regra R13: Back-pressure por SKIP (hedge gating)

Gating primario em af/signals.py::check_all_online_in_pool (sync+async): conta saturated NAO entra em round novo, mesmo com HB fresco. Reason: "saturated (afogada — GUI gargalo, fora de round novo)". Filtra antes do generate_signals_for_pair/scheduler.

Defesa em profundidade em signal_dispatch.create_signal_canonical: antes do PendingSignal create, consulta should_hold_dispatch(state, action, is_completing_hedge_leg, is_existing_position_op). Se True -> skip + evento DISPATCH_HELD + retorna skipped_duplicate=True, skip_reason="held_saturated".

Invariante S2-zero-sum (CRITICO, regra do modulo saturation.py): should_hold_dispatch retorna False (passa direto) se:
- is_completing_hedge_leg=True (perna que completa par ja aberto — naked leg = quebra zero-sum)
- action in (CLOSE, MODIFY) ou is_existing_position_op=True (mexe posicao broker existente)

Sobra apenas OPEN novo independente em conta saturated como alvo do skip.

Abort do par (HR-iter2-01, race protection): em generate_signals_for_pair (af/signals.py), apos master canonical: se master_result.skip_reason == "held_saturated" (race entre check_all_online e dispatch) -> db.rollback() + raise ValueError. Caller main.py af_scheduler tem except ValueError que loga warning + nao marca pair.status='failed' (proxima rodada reavalia). Slave nunca abre sozinho.

Trade-off honesto: Hold = SKIP, nao ADIA. Signal record fica pra audit, mas sem PendingSignal e sem retry server-side. Quando conta sai de saturated, proximo signal natural (proxima rodada AF / novo broadcast) reentra. Retry temporizado dedicado fica como TODO S386-FU2 (sweetspot inicial confia no fluxo upstream — gating primario cobre 99%).

Visibilidade

Arquivos-chave

Specs relacionados


Sinais AF (AutoFund Hedge) — Canal Isolado (v2.0)

O sistema AF gera sinais que usam a mesma infraestrutura (Signal, PendingSignal, TradeLink) mas com isolacao total do copy-trade normal.

Signal Channel

Canal Origem Distribuicao
ws EA (copy-trade) Broadcast para peers do grupo
af AF engine (hedge) SEM broadcast — direto para contas do par AF
http EA fallback Broadcast normal

AF Signal Types

Tipo Origin detection_method Detalhe
AF OPEN af af_engine sl_distance=0, tp_distance=0 (SL/TP absolutos, EA aplica buffer)
AF CLOSE af_close af_engine trade_group_id = "AF_CLOSE_{pair_id}"
AF MODIFY af_modify af_push_modify Push zone precision (ver §6d AF whitepaper). F2/F3 cap preserva margem ($2-10), F7 desconta sl_buffer do SL.

Isolacao (anti-broadcast)

Mecanismo A — AF Pending Linkage: Quando conta re-detecta sua propria execucao AF (via SE/OTT), servidor reconhece signal_channel='af' no PendingSignal, vincula TradeLink sem criar broadcast novo, auto-ACK como FILLED.

Mecanismo B — Pool Active Suppression: Se conta esta em pool AF ativo/pausado, OPEN cria signal + auto-ACK com status='ORIGIN', define signal_channel='af', retorna 'af_pool_suppressed'. Peers NAO recebem.

P19 (CRITICO): Sem esta isolacao, sinais AF propagariam para TODAS as contas do grupo. Bug causou 63 posicoes e -$46k.

AF signals bypass


Reconciliation Post-Offline (S300)

Mecanismo retroativo que recupera profit/close_price quando o EA volta de
janela offline durante a qual o broker fechou posicoes (SL hit, manual close,
margin call). Causa raiz coberta: profit_source=none em _detect_missing_positions
ao detectar ticket sumido (3 fallbacks retornavam None → profit gravado=0 →
AfPair classificada erroneamente como pnl_suspect).

Caminho retroativo via HistorySelect (3 trigger points no EA)

# Trigger Quando dispara Cobre
1 OnInit Apos boot do EA (recompile, restart MT5) Janela offline + crash recovery
2 WS reconnect callback Apos WS_Reconnect() retornar true Disconnect transiente (proxy, rede)
3 OnTimer A cada 60s (debounce 30s interno) Cinturao+suspensorio: OTT fila 1024, erros transientes

Include/CopyTrade/ReconcileEngine.mqh — modulo:
- RE_FetchOpenTradeLinks: GET /api/ea/open-tradelinks retorna lista de tickets que o servidor ainda considera abertos.
- RE_ScanHistory: HistorySelect(from_time, now) + filtra DEAL_ENTRY_OUT cujo DEAL_POSITION_ID ∈ open_tickets.
- RE_EnqueueRetroactive: POST /api/signals com is_retroactive=true + profit consolidado (R4: DEAL_PROFIT + DEAL_SWAP + DEAL_COMMISSION) + deal_price.
- Mutex g_reconcile_running (R10) impede multi-trigger concorrente.
- Checkpoint local MQL5/Files/reconcile_checkpoint_<account>.txt (R11) reduz custo de scan na N-esima execucao. Primeiro run varre ate 7d atras.

Idempotency Contract (R6)

signals.is_retroactive BOOLEAN NOT NULL DEFAULT FALSE + index parcial:

CREATE UNIQUE INDEX ix_signals_retroactive_unique
  ON signals (source_account, ticket, action)
  WHERE is_retroactive = TRUE;

Garante: 1 retroactive por (conta, ticket, action). Multi-trigger seguro — segunda chamada bate no IntegrityError ou no SELECT prefix-check, retorna skipped_idempotent. Signals normais (is_retroactive=false) NAO sao afetados pelo index.

Override broker-canonical com tolerancia $0.01

_process_retroactive_close em routes/signals.py:
1. Idempotency check (R6): SELECT existing → skip silent.
2. Sanity check (R28): validate_retroactive_profit(profit, balance)|profit| > 2*balance rejeita 422 + Telegram alert.
3. Tolerance check (R5): |old_profit - new_profit| < $0.01 → skip silent (sem audit churn).
4. Override absoluto: link.profit = data.profit; link.close_price = data.deal_price. Broker eh fonte canonica.
5. Audit: log_trade_event(origin='reconcile_retroactive') com before/after profit + delta.
6. Re-validate: atualiza AfTrade.profit do lado; se ambos lados tem profit, chama process_trade_result(pair_id, profit_a, profit_b).

Sanity gate (R28 — defesa adversarial)

validate_retroactive_profit(profit, balance) em af/engine.py:
- balance <= 0 → reject balance_invalid
- |profit| > 2 * balance → reject absurd_value
- caso contrario → ok

Tests cobrem 11 cenarios (5 ex + 3 inv + 3 property-based via Hypothesis em test_reconcile_retroactive.py).

Watchdog 5min + market hours (R8 + R9)

Coroutine async em main.py:_watchdog_offline_with_trade():
- Tick 60s. Lista accounts com TradeLink open.
- Compara max(Heartbeat.created_at) com NOW(). age > 300s E acc ∈ TradeLink open → alert candidato.
- Edge-trigger via dict _watchdog_alerted — alerta dispara 1x na transicao online→offline. Reset com heartbeat fresco.
- R9 market hours: is_market_open(symbol) cruzado antes de alertar (suprime weekend/rollover).
- Telegram via send_watchdog_offline_with_trade com debounce 60s.

Limitacao conhecida

Reconcile cobre apenas CLOSE retroativo. Se EA estava offline durante OPEN, pair vira failed/timeout e nao eh recuperavel via reconcile. Exemplo: Pair 81 (Round 21) — af_trades.status='timeout' sem ACK de OPEN, fix via SQL manual em S299 (CA-8).

Spec autocontida (referencia)

specs/reconciliacao-pos-offline.md — 14 casos de borda, 11 regras, 27 riscos, prior art externo (MQL5 Article #11248, HistorySelect docs). Status: APROVADO (S299) → IMPLEMENTADO (S300).


SL/TP Reconciliation via Heartbeat (v2.0)

Mecanismo server-side que detecta e corrige divergencias de SL/TP entre peers.

Aspecto Detalhe
Trigger Cada heartbeat POST do EA (routes/heartbeat.py)
Logica _reconcile_sl_tp(): compara SL/TP das posicoes do EA vs valores esperados (TradeLink)
Threshold Mismatch detectado
Acao Cria MODIFY signal com origin="server_reconcile" — direto pro peer divergido (sem broadcast)
Cooldown Skip se MODIFY recente (<300s) para o mesmo ticket
Log "[SL/TP-RECONCILE] Mismatch conta X ticket Y: SL=A->B, TP=C->D"

Trade Event Log — Diario de Bordo (v2.0)

Todo evento significativo eh registrado na tabela TradeEvent para auditoria completa.

Funcao: log_trade_event(db, event_type, ...)

Campo Tipo Descricao
event_type str OPEN, MODIFY, CLOSE, PROPAGATE, ACK
account_id int Conta envolvida
ticket int Ticket do broker
trade_group_id str UUID do grupo
signal_id int FK para signals
origin str ea, server_reconcile, af, af_close, af_modify
close_reason str SL, TP, STOP_OUT, MANUAL (so CLOSE)
price float Preco do evento
profit float P&L consolidado (so CLOSE)
volume float Volume
context JSON Metadados extras (distributed_to, applied_sl/tp, etc.)

Pontos de captura

Momento event_type Contexto extra
Signal OPEN criado OPEN distributed_to (numero de peers)
Signal MODIFY criado MODIFY SL/TP alvos
Signal CLOSE criado CLOSE close_reason, profit, close_price
CLOSE propagado pra peers PROPAGATE Peers notificados
ACK recebido (OPEN) ACK status, channel, latency_ms
ACK recebido (MODIFY) ACK applied_sl vs target_sl, applied_tp vs target_tp

Trace ID — Rastreabilidade Ponta-a-Ponta (S224+)

Analogia: O trace_id e como o numero de rastreio de uma encomenda. Cada sinal recebe um codigo unico no momento em que nasce, e esse codigo acompanha TODOS os passos — da deteccao no EA master ate o ACK do EA slave e a timeline no dashboard. Se algo deu errado, basta buscar o trace_id pra ver exatamente onde parou.

Geracao

Aspecto Detalhe
Formato 32 caracteres hexadecimais (padrao W3C Trace Context)
Onde nasce Servidor, no momento do INSERT INTO signals (routes/signals.py)
Header HTTP traceparent (preparado pra integracao futura com OTel/Grafana)
EA fallback Telemetry_GenTraceId() (Telemetry.mqh) gera trace_id local se servidor nao fornecer — composto de account_id + timestamp + tick_counter
Enriquecimento Middleware _ensure_trace_id() (telemetry.py) gera automaticamente se ausente no request

Propagacao por Etapa

Etapa Componente Como trace_id chega
1. Deteccao (EA master) SnapshotEngine.mqh Ainda sem trace_id — sinal detectado localmente
3. Envio (EA -> servidor) WebBridge_PostSignal() Sem trace_id no payload (servidor gera)
4. Processamento (servidor) signals.py INSERT trace_id gerado aqui — salvo em signals.trace_id
5. PendingSignal pending_signals Herdado via signal_id FK (JOIN com signals)
6. Distribuicao (WS push) ea_ws_manager.push_signal() trace_id incluido no payload WS
7. Execucao (EA slave) GUIExecution.mqh hooks EL_SetCurrentContext(signalId, traceId) — todos os EL_Emit() herdam
8. ACK signal_acks.trace_id Salvo na tabela signal_acks via handler HTTP/WS
9. TradeLink trade_links Correlacionado via trade_group_id (mesmo grupo)
10. EventLog signal_events Cada evento carrega trace_id — UNIQUE(signal_id, event_type, seq_num)
11. Telemetria event_stream Eventos telemetricos vinculados pelo mesmo trace_id

Event Types Rastreados (19+)

O EventLog (EventLog.mqh) emite eventos em cada passo da execucao GUI:

Grupo Events Descricao
OPEN gui_s1..gui_s11 11 passos: F9 dialog -> symbol -> volume -> SL/TP -> click -> confirmacao
MODIFY gui_m1..gui_m5 5 passos: dialog -> combo -> painel -> click -> resultado
CLOSE gui_c1..gui_c3 3 passos: dialog -> botao -> resultado
ACK ack_sent, ack_failed Confirmacao de execucao enviada/falhada
Recepcao ea_received EA slave recebeu o sinal

Cada event type tem variante _ok e _fail (ex: gui_s5_ok, gui_s5_fail).

Backward Compatibility

Cenario Comportamento
Signal antigo (pre-S224, sem trace_id) trace_id = NULL — queries usam LEFT JOIN, timeline funciona sem ele
EA versao antiga sem Telemetry.mqh Servidor gera trace_id normalmente — EA so nao emite eventos de telemetria
Mix de EA builds no deploy gradual Campos novos sao opcionais com default no Pydantic schema

Consultas no Dashboard

Acao Endpoint O que mostra
Timeline por signal GET /api/signals/{id}/timeline Todos eventos ordenados por ts_ea ASC com payload expandivel
Timeline por trace GET /api/telemetry/trace/{trace_id} Eventos de telemetria vinculados ao trace
Anomalias recentes GET /api/telemetry/anomalies Eventos com late=true (gap > 10s entre broker e servidor)

Specs Relacionados


Arquivos-Chave

Componente Arquivo Responsabilidade
EA principal Experts/LinniuC.mq5 Orquestrador, OnTimer, OnTradeTransaction
Deteccao Include/CopyTrade/SnapshotEngine.mqh Snapshot, fila, anti-echo EA
HTTP Include/CopyTrade/WebBridge.mqh POST signals, GET pending, ACK, heartbeat
WebSocket Include/CopyTrade/WinHttpWS.mqh WS connect, read, send
GUI Include/CopyTrade/GUIExecution.mqh Win32 automation: OPEN/MODIFY/CLOSE
Settings Include/CopyTrade/SettingsManager.mqh Sync servidor, invert
Servidor sinais routes/signals.py POST signals, GET pending, ACK
Servidor WS routes/ea_ws.py WS /api/ws/ea, push sinais
Servidor cleanup main.py Orphan checker, TTL cleanup
Servidor heartbeat routes/heartbeat.py POST heartbeat, posicoes, sync

Pipeline de Logs Remotos do EA (S1015)

O EA envia logs pro servidor via heartbeat. Pipeline completo:

EA (MQL5)                              Servidor (Python)
─────────                              ─────────────────
LNC_LogRemote(msg)
  → g_logBuffer[100] (circular)
  → LNC_BuildLogJSON()
  → heartbeat body: "last_logs": [...]
                                       POST /api/heartbeat
                                         → heartbeat.py:385-418
                                         → dedup (5min window por account)
                                         → INSERT ea_remote_logs
                                         → level auto-detect:
                                           "ERROR"/"FAIL"/"FALHOU" → ERROR
                                           "WARN"/"SKIP"           → WARN
                                           else                    → INFO

                                       GET /api/heartbeat/diagnostics
                                         → recent_logs (20 ultimas)
                                         → recent_errors (10 ultimas, level=ERROR)

                                       Cleanup: TTL 7 dias (main.py startup)

Eventos Logados pelo EA (28 tipos)

Evento Formato Level
EA iniciado EA iniciado BUILD X.Y.Z INFO
OPEN OK OPEN OK #ticket symbol dir vol=X Xms [gui_trace] INFO
OPEN FAIL (no ticket) OPEN FAIL no-ticket symbol [gui_trace] ERROR
OPEN FAIL (GUI) OPEN FAIL gui symbol [gui_trace] ERROR
MODIFY OK MODIFY OK #ticket Xms [gui_trace] DIAG{...} INFO
MODIFY FAIL MODIFY FAIL #ticket [gui_trace] ERROR
MODIFY SKIP MODIFY SKIP #ticket (already correct) WARN
MODIFY READBACK FAIL MODIFY READBACK FAIL #ticket msg DIAG{...} ERROR
CLOSE OK CLOSE OK #ticket Xms [gui_trace] INFO
CLOSE FAIL CLOSE FAIL #ticket [gui_trace] ERROR
CLOSE already closed CLOSE already closed #ticket INFO
CLOSE race-closed CLOSE race-closed #ticket [gui_trace] INFO
SKIP (signal old) SKIP #signalId old Xs WARN
WS debug [WS-DBG] type=X len=Y INFO
WS price request [WS] Price request recebido: symbol id=X INFO
OTT event [OTT] Deal #X ENTRY_IN/OUT REASON INFO
AutoUpdate diag [AU-DIAG] ... INFO

gui_trace Formato

TAB:0|C1d:rows=1|SEL:0|F9:ok|DLG:found|SYM:set|DIR:set|VOL:set|SLTP:set|PLACE:ok

Cada passo da execucao GUI separado por |. O ultimo passo indica onde parou se falhou.

Queries Uteis

-- Ultimos 30 logs de uma conta
SELECT message, level, created_at FROM ea_remote_logs
WHERE account_id = X ORDER BY created_at DESC LIMIT 30;

-- Erros das ultimas 24h
SELECT account_id, message, created_at FROM ea_remote_logs
WHERE level = 'ERROR' AND created_at > NOW() - INTERVAL '24 hours'
ORDER BY created_at DESC;

-- GUI traces (buscar passos que falharam)
SELECT message FROM ea_remote_logs
WHERE account_id = X AND message LIKE '%gui_trace%'
ORDER BY created_at DESC LIMIT 10;

Mapa de Funcoes EA - Servidor (S1015)

EA: Experts/LinniuC.mq5

Funcao Linha O que faz
LNC_LogRemote(msg) 134 Buffer circular de logs (50 msgs)
LNC_BuildLogJSON() 141 JSON array dos logs pra heartbeat
LNC_BuildDiagnosticsJSON() 162 JSON completo de diagnosticos
LNC_IsSignalProcessed(id) 222 Anti-retry: checa se sinal ja foi processado
LNC_MarkSignalProcessed(id) 229 Marca sinal como processado
ExecuteSingleSignal(json) 523 Executa 1 sinal (OPEN/MODIFY/CLOSE via GUI)
PollAndExecuteSignals() 968 Poll /api/signals/pending + executa
EquityFloor_Check() 1560 Circuit breaker: fecha tudo se equity < floor
OnTimer() 1597 Ciclo 200ms: SE + poll + WS + ACK queue
OnTradeTransaction() 1739 Deteccao instantanea (OTT) de deals

EA: Include/CopyTrade/WebBridge.mqh

Funcao Endpoint Descricao
WebBridge_Heartbeat() POST /api/heartbeat Status: balance, equity, positions, logs
WebBridge_PostSignal() POST /api/signals Master reporta trade detectado
WebBridge_PollSignals() GET /api/signals/pending Slave busca sinais pendentes
WebBridge_SendAck() POST /api/signals/{id}/ack Reporta resultado (FILLED/FAILED)

EA: Include/CopyTrade/GUIExecution.mqh

Funcao Linha Descricao
GUIExecution_Open() 1183 Abre posicao via F9 dialog
GUIExecution_Modify() 1401 Modifica SL/TP via Position dialog
GUIExecution_Close() 1488 Fecha posicao via Position dialog
GUI_Trace(step) 165 Acumula passo no trace buffer
GUI_EnsureTradeTabActive() 472 Garante Trade tab ativa
GUI_AcquireGUILock() 393 Lock exclusivo (1 EA por vez)

Servidor: Endpoints Principais

Metodo Endpoint Arquivo:Linha Descricao
POST /api/signals signals.py:96 Master reporta trade (anti-loop + broadcast)
GET /api/signals/pending signals.py:722 Slave busca sinais pendentes
POST /api/signals/{id}/ack signals.py:811 ACK com validacao V1-V4
POST /api/heartbeat heartbeat.py:201 Status + logs + positions
POST /api/signals/broadcast signals.py:1094 Dashboard broadcast manual
POST /api/signals/broadcast-close signals.py:1532 Fechar todas posicoes de grupo
GET /api/diagnostics heartbeat.py:785 Diagnosticos de todos EAs

ACK Validation V1-V4 (S1015)

Checks automaticos no handler de ACK (HTTP + WS):

Check Condicao Acao
V1: Volume zero actual_volume < 0.01 em OPEN Rejeitar como FAILED + Telegram
V2: Open price zero open_price <= 0 em OPEN Rejeitar como FAILED + Telegram
V3: Ticket reutilizado ticket ja existe em trade_links (outro trade_group) Rejeitar como FAILED + Telegram

Implementado em validate_ack_data() (signals.py), chamado por ambos handlers (HTTP e WS).

Reconciliation Pos-Trade

Apos ambos ACKs de um par AF (_af_ack_count >= 2):
- reconcile_af_pair() verifica: 2 trades, tickets diferentes, direcoes opostas, volumes iguais, open_price > 0
- Se falha: alerta Telegram + bloqueia MODIFY scheduling


EC-Test: Variante de Injecao de Sinais de Teste (Phase C — S219)

Endpoint: POST /api/test/inject (so disponivel quando TEST_INJECTION_ENABLED=true)
signal_source: "test" (vs "ea" no fluxo normal)

O lifecycle normal comeca na Etapa 1 (deteccao de trade no EA master). A injecao de teste entra direto na Etapa 4 (processamento no servidor), pulando as etapas 1-3:

Diferencas vs fluxo normal

Aspecto Fluxo normal Injecao de teste
Origem EA master (OTT ou SnapshotEngine) POST /api/test/inject (servidor)
Auth X-API-Key (ea_api_key) Bearer JWT (dash auth)
signal_source "ea" "test"
Conta target peers do mesmo group_id (broadcast) account_id especifico (obrigatorio is_test=true)
Filtro dashboard Incluido por default Excluido por default (include_test=false)
ACK path EA Slave → /api/ack EA Slave (conta 52) → /api/ack (mesmo handler)

Regra R10 — Isolamento de teste

R10: Sinal signal_source='test' NUNCA distribui para conta is_test=false.
- O handler valida que account.is_test=True antes de criar qualquer PendingSignal.
- Nao ha broadcast de group_id — so a conta alvo recebe.
- Invariant CA-7/R1: COUNT(*) FROM pending_signals JOIN signals JOIN accounts WHERE signal_source='test' AND is_test=false = 0 SEMPRE.
- Conta 52 (Teste Sandbox) esta em blocked_account_ids do pairing AF — nunca entra em round real.

Etapas equivalentes pos-Etapa 4

A partir da distribuicao (Etapa 5), o fluxo e identico ao normal:
- PendingSignal criado → EA da conta 52 polls/recebe WS → executa via GUI automation → ACK volta com ticket real
- signal_source='test' preservado em todos os registros (Signal, SignalAck, TradeLink)
- Dashboard filtra sinais test por default (include_test=false em /api/signals/history e /api/accounts)

Ativacao

O endpoint so existe quando TEST_INJECTION_ENABLED=true (env). Em producao fica false — qualquer chamada retorna 404.
Isolamento via porta: o runner de testes sobe uvicorn :8001 --lifespan off separado do prod :8000.


Carteiros

Status: ACTIVE | Ultima revisao: S396+6 (2026-06-05) — entra o Entregador (camada de entrega unificada, Onda 6). Antes: S342 trio carteiro fechado.

3 carteiros canonicos cobrem o ciclo de vida de cada sinal; o Entregador (Onda 6) e'
a camada de ENTREGA que LEVA o sinal ja criado ate o EA por 1 molde unico, varias rotas.
Cada um eh um helper unico ("1 cabeca, varias bocas") — wrappers HTTP e WS finos delegam
toda a logica pro mesmo helper, eliminando drift por construcao.

Visao geral — quem faz o que

# Personagem Quando dispara Helper Spec
1 Carteiro Canonical (IDA) — CRIA o sinal Dashboard ou EA cria sinal de trade create_signal_canonical em server/signal_dispatch.py specs/signal-dispatch-canonical.md
2 Carteiro Conferente (VOLTA) — confere recibo EA responde se executou (FILLED/FAILED/REJECTED) apply_ack_canonical em server/ack_helpers.py specs/carteiro-conferente-ack.md
3 Carteiro Plantonista (PLANTAO) — ronda telemetria EA bate ponto a cada 10s (WS) ou 30s (HTTP) apply_heartbeat_canonical em server/heartbeat_helpers.py specs/carteiro-plantonista-hb.md
4 Entregador (Onda 6) — LEVA o sinal/avisos ao EA Apos o Carteiro criar; tambem no poll HTTP e no flush da reconexao _build_ws_payload (sinal) + build_ea_update_payload (aviso de update) em signal_dispatch.py/ea_ws.py specs/signal-dispatch-canonical.md secao "## Onda 6"

Entregador NAO e' um 4o carteiro do ciclo (IDA/VOLTA/PLANTAO sao fases — criar, conferir,
rondar). Ele e' a CAMADA DE ENTREGA transversal: 1 envelope padrao + N transportes (push WS /
poll HTTP / flush na reconexao). O Carteiro IDA o usa pra empurrar logo apos criar; o HTTP poll
e o flush o usam pra reentregar minutos depois sem passar pela criacao. Foi a entrega divergir
entre rotas (target_login/origin so num caminho) que motivou a Onda 6.

Fluxo end-to-end

[Dashboard/Master EA]
      |
      | (1) cria sinal
      v
+---------+        sinal       +-----+
|   IDA   |  ---- via WS  ---> |  EA |
+---------+      ou HTTP       +-----+
      ^                          |
      |                          | (2) executou (ou falhou)
      | broadcast WS             v
      |                       +---------+
      +---------------------- |  VOLTA  |
                              +---------+

   [PLANTAO roda em paralelo, sempre — 10s ou 30s]
   +-----------+   HB telemetria   +-----+
   |  PLANTAO  | <---------------- |  EA |
   +-----------+   (vivo? saudo?)  +-----+

Carteiro Canonical (IDA) — create_signal_canonical

Arquivo: server/signal_dispatch.py:create_signal_canonical
Quem chama: 20 callsites em routes/signals.py, af/signals.py, routes/heartbeat.py:_reconcile_sl_tp, etc. (python scripts/show_canonical_api.py --callsites)
Origem: S317 Onda 4a (2026-05-02) — substituiu Signal() raw nas rotas e AF engine.

O que faz

  1. Cria 1 row em signals (a "encomenda")
  2. Cria 1 row em pending_signals (a "lista de espera ate alguem entregar")
  3. Cria 1 row em trade_links se origem eh origin
  4. Opcionalmente cria signal_ack com status=SUCCESS (server-initiated routes)
  5. Logga evento em trade_events para auditoria
  6. Push WS imediato pro EA via dispatch_pending_to_eas

Tabelas tocadas

Tabela Colunas-chave Observacao
signals id, action, symbol, direction, volume, sl, tp, trade_group_id, origin, signal_source, trace_id origin eh OriginType enum (20 valores); signal_source eh SignalSource enum (4 valores)
pending_signals signal_id, account_id, trade_group_id, expires_at, resolved TTL via expires_at (PENDING_SIGNAL_TTL_SECONDS)
trade_links signal_id, account_id, local_ticket, is_origin, trade_group_id local_ticket so chega no ACK FILLED — IDA cria com NULL e VOLTA preenche
signal_acks (opcional) status=SUCCESS pra server-initiated Bypass de aguardo do EA
trade_events event_type=CREATE, origin, context Audit trail JSONB

Regras criticas


Carteiro Conferente (VOLTA) — apply_ack_canonical

Arquivo: server/ack_helpers.py:apply_ack_canonical
Quem chama:
- server/routes/signals.py:ack_signal (HTTP POST /api/ack)
- server/routes/ea_ws.py:_handle_ws_ack (WS msg_type=ack)

Origem: S341 (2026-05-12) — refactor extraiu 600 linhas duplicadas entre HTTP e WS handlers.

O que faz

  1. UPSERT atomico em signal_acks (race-safe via IntegrityError retry com _apply_fields interno)
  2. compute_exec_slippage ANTES do commit (1 commit so — D1 canonical)
  3. Detecta FALSE_ACK via validate_ack_data (V1-V4) e marca status=FAILED se aplicavel
  4. Cria/atualiza trade_links quando status=FILLED (preenche local_ticket)
  5. Marca pending_signals.resolved=True pra acabar o ciclo
  6. Logga trade_events (ACK + MODIFY-BUFFER mismatch + CLOSE P&L cascade)
  7. Dispara AF MODIFY post-fill (reconcile_af_pair + check_and_schedule_modify) quando 2 ACKs FILLED chegam no mesmo trade_group_id AF
  8. Telegram alerts: MODIFY-BUFFER MISMATCH, AF MODIFY BLOCKED, CLOSE RETRY EXHAUSTED
  9. CLOSE FAILED auto-retry (max 3 tentativas) — SE-2 cure do refactor
  10. Broadcast commit_then_publish("ack", ...) no final pro dashboard

Tabelas tocadas

Tabela Colunas-chave Observacao
signal_acks signal_id, account_id, status, local_ticket, executed_at, exec_slip_pts, pipeline_slip_pts, bid_at_request, ask_at_request, ea_received_at_ms, trace_id Status: FILLED, SUCCESS, FAILED, REJECTED, PARTIAL
pending_signals resolved=True apos terminal status Fecha o ciclo da IDA
trade_links local_ticket, close_price, profit, is_closed Preenchido aqui (IDA criou com NULL)
trade_events event_type=ACK, context.profit_source Cascade deal_ack > broker > cache > unknown
signals so leitura (busca por signal_id) Nao escreve

Drift HTTP vs WS (catalog 12 divergencias)

Auditoria completa em specs/scratch/ack-drift-S341.md. 2 bugs latentes corrigidos:
- SE-1: HTTP nao usava commit_then_publish (B5/P224 nao propagado)
- SE-2: WS nao tinha CLOSE FAILED auto-retry (90% dos ACKs vem por WS)

Regras criticas


Carteiro Plantonista (PLANTAO) — apply_heartbeat_canonical

Arquivo: server/heartbeat_helpers.py:apply_heartbeat_canonical
Quem chama:
- server/routes/heartbeat.py:receive_heartbeat (HTTP POST /api/heartbeat)
- server/routes/ea_ws.py:_handle_ws_heartbeat (WS msg_type=heartbeat)

Origem: S342 (2026-05-12) — refactor extraiu 1130 linhas duplicadas entre HTTP e WS handlers.

O que faz (18 sub-tarefas por HB)

  1. _sanitize_nan recursive (NaN/Inf em qualquer profundidade)
  2. Backfill broker se conta foi auto-registrada por WS auth (SE-2)
  3. Zombie check 1x no inicio (timer_tick congelado por 2 HBs)
  4. Update broker_name, mt5_server (com guard 2+ chars apos strip)
  5. terminal_build change alert via Telegram (SE-3 cure)
  6. Update account.last_heartbeat_at SEMPRE (mesmo no throttle WS — S314.3 Bug 3 fix)
  7. Insert Heartbeat row (force_save=True; throttle 30s no WS)
  8. Equity snapshot (cooldown 5min)
  9. Diagnostics cache in-memory
  10. Chart price cache (BID/ASK fresh do MT5)
  11. SymbolInfo cache + DB upsert (tick_value, tick_size, contract_size, etc.)
  12. UPSERT open_positions (delete gone + capture P&L pra TradeLink antes do delete — CR-03)
  13. Settings sync verify (SE-4 cure: ambos canais via commit_then_publish)
  14. EA back-online alert (se estava em _offline_accounts)
  15. Desync check (group_peers mostra outras contas do grupo)
  16. SL/TP reconcile (_reconcile_sl_tp — MODIFY pra peer divergente)
  17. Detect missing positions (_detect_missing_positions com advisory lock)
  18. Broadcast HB unificado (SE-5/SE-6: super-set inclui ea_version + sl/tp/open_price)

Tabelas tocadas

Tabela Colunas-chave Observacao
accounts last_heartbeat_at, broker, mt5_server, terminal_build, desired_version, update_attempts, settings_dirty UPDATE sempre, throttle so afeta heartbeats row
heartbeats balance, equity, margin, free_margin, positions, ea_version, applied_settings, created_at INSERT throttled: WS=30s; HTTP=sempre (polling natural ja 30s)
open_positions account_id, ticket, symbol, direction, volume, profit, sl, tp, open_price, current_price, pips, swap, magic, commission, spread, tick_value, tick_size, profit_at_sl, profit_at_tp Upsert por diff incoming vs existing
equity_snapshots balance, equity, margin, positions Snapshot permanente cada 5min
symbol_info account_id, symbol, tick_value, tick_size, etc. Bulk upsert ON CONFLICT
trade_events event_type=CLOSE, origin=heartbeat_gone, context.profit_source Audit trail das posicoes que sumiram
trade_links is_closed=True, closed_at, close_price, profit Quando ticket some entre HBs

Drift HTTP vs WS (catalog 18 divergencias)

Auditoria completa em specs/scratch/hb-drift-S342.md. 6 bugs latentes corrigidos:
- SE-1: WS NaN/Inf so cobria 5+8 campos
- SE-2: WS auto-reg criava broker=""
- SE-3: WS nao detectava terminal_build change
- SE-4: HTTP settings_verify_fail nao usava commit_then_publish (B5/P224)
- SE-5: broadcast HTTP nao incluia ea_version
- SE-6: broadcast HTTP open_positions sem sl/tp/open_price

Regras criticas

Frequencia em prod


Fluxo de dados na rede (payloads IN/OUT)

Cada carteiro recebe um payload JSON e responde/broadcasta outro. Aqui o que efetivamente trafega entre EA, servidor e dashboard — diferente das tabelas DB acima que mostram o que persiste.

Carteiro Canonical (IDA) — payloads

Entrada: chamada Python interna (nao tem JSON IN — eh callee de 20 callsites).

Saida 1 — push WS pro EA (WS_PAYLOAD_FIELDS, 18 campos exatos — schema estrito, contrato com EA):

Campo Tipo Origem Notas
id int Signal.id Chave de correlacao com SignalAck
action str OPEN/MODIFY/CLOSE Discriminador do tipo
ticket int 0 em OPEN, MT5 ticket em MODIFY/CLOSE
symbol str XAUUSD, BTCUSD, etc
direction str BUY/SELL NUNCA invertido aqui (EA aplica invert)
volume float Lotes
price float Preco entrada (0 em CLOSE)
sl float Stop Loss (preco absoluto)
tp float Take Profit (preco absoluto)
sl_distance float Distancia em pontos (alternativa)
tp_distance float Distancia em pontos (alternativa)
created_at str ISO8601+Z Server time UTC
trade_group_id str AF_P5, AF_R9_P3, etc
signal_channel str|null Canal origem Audit trail
detection_method str|null OTT, SE, manual, AF Audit trail
close_reason str|null SL/TP/STOP_OUT/MANUAL So em CLOSE
target_login int Account.account_num do alvo S396 selo P4: EA confere == ACCOUNT_LOGIN (anti-vazamento entre contas)
origin str Signal.origin ("" se NULL) Onda 6: EXECUTAVEL — EA usa origin=="server_reconcile" pra skipBuffer no MODIFY (LinniuC.mq5:1418/2044)

Saida 2 — broadcast "new_signal" pro dashboard (dashboard ouve via WS, atualiza aba Sinais).


Carteiro Conferente (VOLTA) — payloads

Entrada AckIn (EA -> servidor, schema Pydantic em server/schemas.py:120):

Campo Tipo Obrigatorio Notas
status str SIM FILLED, SUCCESS, FAILED, REJECTED, PARTIAL
local_ticket int NAO (0 default) Ticket MT5 que o EA gerou
error_msg str NAO ("" default) Mensagem de erro se FAILED
open_price float NAO (0 default) Preco que o EA conseguiu
applied_sl float NAO (0 default) SL que o EA conseguiu setar
applied_tp float NAO (0 default) TP idem
actual_volume float NAO (0 default) Volume efetivo (partial fill)
receive_channel str|null NAO http_poll, ws_push (telemetria latencia)
gui_duration_ms int|null NAO Tempo dialog F9
ea_received_at_ms int|null NAO (build 3.85+) T4 epoch ms UTC pra calc latencia exata
deal_price float|null NAO (v3.63+) Preco exato do MT5 deal (CLOSE enrichment)
deal_profit float|null NAO (v3.63+) P&L do MT5 deal
bid_at_request float|null NAO (3.85+) BID via SymbolInfoTick no click GUI (slippage)
ask_at_request float|null NAO (3.85+) ASK idem

Saida 1 — response HTTP (apenas via path HTTP, WS path retorna None): {ok: bool, ack_id: int|null, status: str, errors: list[str]}.

Saida 2 — broadcast "ack" pro dashboard (via commit_then_publish):

Campo Tipo Notas
signal_id int Correlaciona com Signal IDA
account_id int
status str Espelha AckIn.status
action str Vindo do Signal: OPEN/MODIFY/CLOSE
local_ticket int
account_name str (so HTTP path enriquece)
open_price float (so HTTP path enriquece)
applied_sl float (so HTTP path enriquece)
applied_tp float (so HTTP path enriquece)
actual_volume float (so HTTP path enriquece)

Carteiro Plantonista (PLANTAO) — payloads

Entrada HeartbeatIn (EA -> servidor, schema Pydantic em server/schemas.py:46):

Campo Tipo Obrigatorio Notas
account_num int SIM Numero MT5 (chave de auth)
balance float SIM Saldo do broker
equity float SIM Equity (saldo + P&L flutuante)
margin float NAO (0 default) Margem usada
free_margin float NAO (0 default) Margem livre
positions int NAO (0 default) Quantas posicoes abertas
server_time str (max 64) NAO Server time do MT5
ea_version str (max 32) NAO LinniuC_b3.85.5 (broker|build)
open_positions list[PositionItem] NAO Array de posicoes abertas (snapshot)
applied_settings dict|null NAO Snapshot das configs aplicadas pelo EA
broker_name str NAO "Exness", "FTMO", etc
mt5_server str NAO Servidor MT5 reportado
diagnostics dict|null NAO timer_tick, last_logs, etc (zombie detection)
symbol_info list|null NAO tick_value, tick_size, contract_size por simbolo
chart_symbol str|null NAO Simbolo atual no chart EA
chart_bid float|null NAO BID fresh do MT5 (Olheiro consome)
chart_ask float|null NAO ASK fresh idem
terminal_build int|null NAO (v3.85+) Build do MT5 — mudanca dispara alerta

Cada item de open_positions: {ticket, symbol, direction, volume, profit, sl, tp, open_price, current_price, pips, swap, magic, open_time, spread, commission, tick_value, tick_size, profit_at_sl, profit_at_tp} (19 campos).

Saida 1 — response HTTP HeartbeatOut (HTTP-only, enriquecido com auto-update):

Campo Tipo Notas
status str "ok"
account_id int
next_heartbeat str Slot de 30s alinhado ao minuto
wait_seconds int Quanto esperar ate proximo HB
group_peers str "1:ON:2pos:5s:Exness:12345,2:OFF:0pos:never:..."
update_available bool? (so se desired_version pendente)
update_version, update_hash, update_size, update_url, update_force str/int/bool Dados do EaVersion pra EA baixar

Saida 2 — push WS update_push (WS-only, EA recebe via send_text):

{ "type": "update", "version": "3.85.6", "hash": "...", "size": 102400, "url": "/api/ea/download/3.85.6" }

Saida 3 — broadcast "heartbeat" pro dashboard (super-set unificado, SE-5/SE-6 cures):

Campo Tipo Notas
account_id, name int, str
balance, equity, positions float, float, int
ea_version str (SE-5 cure: sempre incluido)
open_positions array Schema super-set: {ticket, symbol, direction, volume, profit, pips, sl, tp, open_price} (SE-6 cure)
settings_dirty bool UI mostra badge "sync pendente"
applied_settings dict (so se truthy)
diagnostics dict (so se truthy)

Saida 4 — alerta drawdown (so se dd_pct > max_risk_pct * 2): broadcast {type: drawdown, account_id, account_name, account_num, drawdown_pct, threshold, balance, equity}.


Observabilidade

Prometheus REMOVIDO em S367. O endpoint /metrics e os counters
copytrade_* existiam mas NADA os consumia (sem Prometheus/Grafana rodando na
VPS). A observabilidade dos carteiros hoje eh via aba de saude customizada
(/api/health, /api/debug/health-full), logs e alertas Telegram. Se um dia
precisar de tendencia historica/grafico, considerar Netdata (1 binario,
zero-config) em vez de reerguer Prometheus+Grafana.


Como validar visualmente

  1. Aba Sinais — cada linha mostra OPEN/MODIFY/CLOSE (IDA cria, VOLTA confirma)
  2. Aba Contasequity, last_heartbeat (PLANTAO atualiza)
  3. Posicoes abertasopen_positions (PLANTAO mantem snapshot)
  4. Notificacao Telegram — alerts disparados pelos 3 carteiros (mt5_build_change, modify_buffer_mismatch, af_modify_blocked, close_retry_exhausted)
  5. /api/debug/health-full (auth JWT) — health consolidado de cada conta com ack_stats_1h, recent_logs, diagnostics

Historico


Slippage Telemetria

Status: ACTIVE | Adicionado S336+ (2026-05-10, deploy 02:20 UTC) — explica em linguagem leiga + tabela tecnica o que cada um dos 4 campos novos em signal_acks significa.

A ideia em 1 paragrafo

Imagina que voce pediu uma pizza por delivery por R$50, mas quando chegou cobraram R$52. A diferenca (R$2) é o slippage — o quanto voce foi "pisado" entre o pedido e o recibo. No copy trade, cada operacao real tem 3 momentos de preco:

  1. Vitrine (o preco que aparecia na hora do clique — bid_at_request / ask_at_request)
  2. Recibo (o preco que o broker realmente entregou — deal_price)
  3. Ordem escrita (o preco que o servidor anotou na ordem original do master — signal.price)

A telemetria S336+ mede duas distancias:

Ambos sao modulo (sempre positivo) e em pontos (a unidade nativa do simbolo: 1 ponto BTCUSD = 0.01, 1 ponto XAUUSD = 0.01).

Fechamento automatico por stop/alvo (SL/TP) — a regua do NIVEL (S388)

Quando a posicao fecha sozinha porque bateu o stop loss ou o take profit, nao houve clique — foi o broker que disparou. Logo nao existe "foto da vitrine" (bid/ask no clique) pra comparar. Antes, esse fechamento ficava sem medicao ("—" no painel) — e esse era o caminho MAIS comum (a maioria das batalhas morre batendo TP/SL).

A correcao S388: pra esse caso a regua certa nao e a cotacao, e o NIVEL pedido. O EA passou a guardar o nivel do stop/alvo que disparou (lendo do historico do broker) junto do preco real do fechamento, e o servidor calcula:

Exemplo real (sandbox, fechamento por SL): voce pediu stop em 73640, o broker fechou exatamente em 73640 → Exec Slip = 0.0 (honrou perfeito). Se tivesse fechado em 73638, seria 0.02 pra baixo (broker escorregou 2 pontos contra voce).

Isso vale pros 3 caminhos de fechamento: ao vivo (deteccao instantanea), por varredura, e ate offline — se o EA estava fora do ar quando o stop bateu, ao voltar ele le do historico e atualiza a medicao. So fechamento MANUAL (sem stop disparado) fica "—" honesto: nao ha "nivel pedido" pra comparar.

Resumo das 3 reguas: abertura/fechamento-por-comando usam a cotacao (foto no clique); fechamento-por-stop usa o nivel; quem nao tem nem clique nem stop (manual) fica "—".

Tabela — o que e preenchido em cada tipo de operacao

Campo (signal_acks) OPEN MODIFY CLOSE
bid_at_request sim — foto do BID 1 tick antes do click sim — foto antes de aplicar SL/TP novo sim — foto antes do click
ask_at_request sim — foto do ASK no mesmo instante sim sim
exec_slip_pts sim — \|deal_price − ASK\| (BUY) ou \|deal_price − BID\| (SELL) NULL — MT5 nao devolve deal_price em MODIFY (sem fill novo) sim — \|deal_price − BID\| (fechar BUY) ou \|deal_price − ASK\| (fechar SELL)
pipeline_slip_pts sim — \|deal_price − signal.price\| (se signal.price > 0) NULL — idem acima sim
open_price (deal_price) sim — broker reporta NULL sim — broker reporta
applied_sl / applied_tp sim sim — valor novo aplicado n/a
actual_volume sim NULL — MODIFY nao altera volume sim
gui_duration_ms sim sim sim

Os NULLs em MODIFY nao sao bugs — sao consequencias fisicas: MT5 MODIFY so altera SL/TP sem criar deal novo, entao nao ha deal_price pra comparar. A foto bid/ask e capturada mesmo assim pra auditar "o preco passou pelo SL em transito?".

Janela de validade da foto (R5)

A foto bid/ask vale por 60 segundos (stale_threshold_ms = 60000). Se o tempo entre captura e fill no broker passar disso, o servidor descarta o calculo de exec_slip e seta NULL — mas mantem bid/ask pra audit trail. Assim sempre se sabe "qual era a vitrine quando clicaram".

Tag close_reason="ROLLOVER" (Layer 1 anti-swap)

Quando o EA fecha uma posicao perto da meia-noite pra fugir do juro overnight (swap), o Rollover_TryClose tagueia o ticket num buffer em memoria (g_rolloverTagged). O SnapshotEngine consulta esse buffer ao montar o CLOSE signal e:

Distingue de Layer 2 servidor (close_reason="ROLLOVER_FALLBACK" via close_pair_positions — fallback se EA falhar). Spec completa: rollover-dupla-camada.md.

Invariantes (provadas via property-based test T4, 1000 runs)

Exemplo real — primeira deteccao live (signal 13123, sandbox)

Coluna Valor
signal_id 13123
action OPEN
symbol BTCUSD
direction BUY
bid_at_request 81460.86
ask_at_request 81465.61
deal_price (open_price) 81465.82
exec_slip_pts 0.21
pipeline_slip_pts NULL (signal.price=0, signal manual)

Calculo: BUY pega ASK como referencia. \|81465.82 − 81465.61\| = 0.21 pts. Significa que o GUI levou ~0.21 ponto a mais que a vitrine mostrava no momento do click.

Onde ver no dashboard

No painel /af (modulo af_hedge.js), cada card de signal completo mostra:

Se ambos slips forem NULL, as linhas sao omitidas (nao aparecem como "—" pra evitar poluicao).

Referencias tecnicas


AF Hedge

Status: ACTIVE | Ultima revisao: S233 (2026-04-14) — addendum S231 invert audit (ver specs/invert-log.md)

Versao: 3.2
Data: 2026-03-29

Addendum S231 (invert audit): o step "Slave signal via invert pipeline" em server/af/signals.py::generate_signals_for_pair agora usa _invert_for_peer() (helper centralizado) que alem de aplicar invert grava audit row em signal_inversions via savepoint fire-and-forget. Zero mudanca de regra — so observabilidade. Detalhes: specs/invert-log.md. [drift-flush S233]
Status: CONFIRMADO — Regras core (S67), R-SAFE v1 (S95), Plano Diario + R-SAFE v2 (S96), Invert Dinamico + Pipeline (S99), Push Zone + MODIFY Post-Fill + Anti-Rollover + Post-Round Validation + Price Freshness + DLQ (S150).
Enforced em: server/af/ (6 modulos) + o miolo puro server/af/core/. (Fase 5 do sim-rapido: scripts/hedge/rules.py + scripts/hedge/test_rules.py — o "cerebro paralelo" — foram REMOVIDOS; a regra vive numa fonte unica.)


1. Regras de Pool (como organizamos as contas)

# Regra Detalhe Se violar
R1 Pool fixo = N cadeiras Sempre N contas ativas. Se morre (F1/F2) ou chega funded: compra nova F1 no slot. Nunca mais, nunca menos. Hoje N=12. Perde throughput (menos) ou gasta atoa (mais)
R7 Nunca same-prop (= mesma Empresa, S362) NUNCA colocar 2 contas da MESMA Empresa (prop_firms.company) uma contra a outra. S362: compara Empresa, nao o nome completo — 2 Programas diferentes da mesma Empresa (ex: "FTMO Swing" + "FTMO Aggressive", ambas company "FTMO") contam como same-prop e NAO pareiam. Se nao tem par de Empresa diferente, conta espera. SEM FALLBACK. Prop detecta hedge = ban
R8 So mesma fase F1 vs F1, F2 vs F2, Funded vs Funded. Nunca misturar fases. Risco inconsistente entre fases
R12 Max 6 contas/prop No maximo 6 contas por pessoa na mesma prop firm. S362: conceitualmente conta por Empresa (a firma ve todos os Programas dela). Limite operacional (nao enforced no pareador). Regra da prop
D6 Parear por distancia Ordenar contas por distancia ao target. Parear vizinhos (adjacentes). Desperdicar trades pareando longe
D7 Impar = 1 ociosa Se N eh impar, 1 conta descansa no dia. Normal. N/A

Roteamento dentro da pool (S392 — nota): a ordem e' entregue pelo NUMERO da conta de cada lado do par (AfPair.account_a_id/account_b_id -> create_signal_canonical(target_account_id=...)), NAO pelo nome do grupo (group_id). O group_id e' so RoTULO de isolamento da dupla; a copia legada que rotearia por nome fica SUPRIMIDA pra conta em pool AF. invert segue sendo a oposicao do hedge (motor forca o invert do parceiro). Provado em producao (7 batalhas -> exatamente os 2 do par). Detalhe + codigo (arquivo:funcao): specs/af-roteamento-conta-vs-grupo.md.

2. Regras de Risco (quanto apostar por partida)

# Regra Detalhe Se violar
R2 F1 max = $2.500 Na fase 1 (Challenge), risco maximo = $2.500 por trade (2.5% de $100k). Ban pela prop
R3 F2 max depende da prop Props COM profit days (5ers, City): max $2.000 (2%). Props SEM profit days: max $2.500 (2.5%). Ban ou trades insuficientes pra profit days
R4 Smart risk Nao apostar mais que precisa pra fechar. Se falta $800: aposta = ceil($800 / (1 - 0.02)) = $816. Minimo entre teto e distancia. Overshoot = dinheiro jogado fora
R5 Look-ahead (zona morta) Antes de apostar, simula resultado. Se deixaria distancia OU colchao entre $0 e $500: reduz o risco. Conta fica "quase la" com micro-trades inuteis
R5b Spread compensation No ULTIMO trade (distance <= max_risk + $150): permite risk = ceil(distance/(1-spread)), mesmo que ultrapasse max_risk interno. Safety cap = max_risk + $150: Funding Pips cap=$2,650 (2.65%, margem $350 pro hard breach 3%). Props com max_risk=$5k: cap=$5,150 (nunca atinge). Seguro com qualquer spread. Look-ahead do OPONENTE continua ativo normalmente. S362 #291: o cap deriva de max_risk_pct/100 * prop_size + $150 (_spread_comp_cap, escala por tamanho da conta; em 100k = identico). O cap e mascarado pelo hard-cap final (push_limit), entao a migracao de prop.max_risk USD pra max_risk_pct e zero mudanca de comportamento — so escala correto p/ contas != 100k. 1 dia a mais por fase
R6 Death trade Colchao < max risk efetivo = modo death trade. Risk = colchao (aposta o que tem, SL/TP cabe na vida restante). Look-ahead nao protege. Ainda limitado pelo oponente (smart risk). Motivo: apostar mais que o colchao = SL ultrapassa piso DD = conta morre intraday mesmo "ganhando". Sangrar devagar numa conta moribunda

3. Regras de Look-ahead (detalhe)

# Regra Detalhe Se violar
L1 Zona morta = $0 a $500 inclusivo Resultado entre $0 e $500 (inclusive) = zona morta. Precisa ser > $500 pra ser OK. Inclui rem <= 0 (morte por spread): contas com colchao em [max_risk, max_risk*(1+spread)) morreriam sem protecao. LA reduz risk pra manter rem > $500. Micro-trades inuteis / morte evitavel
L2 Threshold dinamico = max risk Look-ahead so protege quando distancia OU colchao >= max risk efetivo da conta. Abaixo = 1 trade resolve, ignora look-ahead. F1: threshold $2.500. F2 profit-day (5ers/City): $2.000. F2 normal: $2.500. Deadlock (S65) se threshold muito baixo
L2b Push Zone = sem LA Se distancia OU colchao estao na push zone (dentro de push_limit = max_risk + buffer), look-ahead NAO ativa. Conta vai passar ou morrer em 1 trade — LA seria contraproducente. Ver secao "Push Zone" abaixo. LA bloqueia fechamento natural
L3 Convergencia 3x Look-ahead roda ate 3 iteracoes (ajuste num check pode afetar outro). Resultado sub-otimo

4. Regras de Vida/Morte (lifecycle)

# Regra Detalhe Se violar
D1 Morte = breach real Conta so morre quando balance cai ABAIXO do piso de DD ($90k com 10% DD). Nao tem "eutanasia antecipada". Reciclar conta que ainda tem chance
D2 Reciclar = nova F1 Conta morta: comprar nova prop, voltar pra F1 ($100k). Slot preenchido. Slot desperdicado
D3 Funded = reciclar Conta que passa F2: FUNDED! Comprar nova F1 pro slot. Slot parado
D4 F1 → F2 = CONTA NOVA Passou target F1: status=passed. Prop firma DESATIVA conta F1 e emite NOVA conta com login/senha diferentes pra F2. Usuario cadastra nova conta manualmente no pool como F2. NUNCA promover mesma conta automaticamente — sao contas fisicamente diferentes no broker. Falso funded, equity errada
D4b Cada fase = conta separada Equity da conta F1 eh IRRELEVANTE pra F2 (conta F1 ja desativada). _has_passed() avalia o balance efetivo via _bal(pa): em modo live/demo usa _real_balance (sincronizado do heartbeat, transiente — nunca persistido), em simulacao usa virtual_balance. Virtual balance rastreia APENAS P&L do AF (recalc via recalc_virtual_balance()). P27: mundos separados — NUNCA misturar virtual com real. Avaliar com equity errada
D5 Spread = 2% XAUUSD: ganha = risk * (1 - 0.02), perde = risk * (1 + 0.02). Custo real por trade. Simulacao nao reflete realidade
D6 Transicao = dia seguinte TODA transicao (passed, morte, funded) tem 1 dia de gap. Conta nova entra no pool no dia SEGUINTE. Slot fica vazio no dia da transicao. Na realidade leva dias pra comprar prop

5. Regras de Prop (variam por prop firm)

# Regra Detalhe Se violar
P1 Profit days 5ers e City: 3 dias lucrativos (> $500 cada). Demais: sem exigencia. Se ja bateu target mas falta dias: atrasa 1 dia (micro-lote, sem hedge). Maioria bate naturalmente. Rejeicao pela prop
P2 Min dias trading Varia por prop (3 a 5 dias F1/F2). Completar com 0.01 lote se atingiu target antes. Na simulacao: +1 dia, zero impacto no balance. Rejeicao pela prop
P3 Tipo DD diario Equity (5ers, FTMO, Bright, FundingPips) = queda do pico do dia. Balance (City, FundedNext) = perda liquida. Com 1 trade/dia: nao faz diferenca. So importa com 2+ trades/dia. Daily DD breach

6. CLEAN_PROPS (pool AF v2)

Filtro: steps == '2-step'

S389-cont5 (2026-05-30): removido o termo max_risk_pct >= 2.5% do filtro de
elegibilidade — era a ULTIMA curadoria legada (mesma classe do max_dd >= 10
removido no S382: herdada da era do "10% fixo", NAO-validada). Risco minimo NAO
eh requisito de pool.
Os requisitos REAIS sao: mesmo TAMANHO (prop_size, nivel da
conta), mesma FASE (R8, agrupada no pareamento) e mesmos STEPS (2-step, unico
requisito de prop firm — mantido). Mesa apertada (ex: Alpha 1.5%) opera SEGURA: o
motor cap no teto dela via a FK prop_firm_id -> prop_firms.max_risk_pct (S389
_ROUND_PROP_CACHE). ANTES do S389 ela furava (operava a 2.5% da pool), por isso a
curadoria fazia sentido defensivo; com o S389 virou restricao indevida. Decisao do
usuario. is_af_eligible = (steps == '2-step') — ver _compute_af_eligible.

S382b (2026-05-27): is_af_eligible virou TRAVA DURA de entrada (antes era so
filtro de catalogo, com fallback que deixava mesa nao-elegivel entrar). Agora mesa com
is_af_eligible=false NAO entra em pool nenhuma — bloqueada em
_create_chairs_from_accounts (pula com motivo) e assign_account (400), via helper
_resolve_eligible_prop. Contas JA sentadas nao sao afetadas (trava so vale pra ENTRADA
de conta nova). Decisao do usuario S382.

S382 (2026-05-27): o portao max_dd >= 10% foi REMOVIDO do filtro de
elegibilidade. Era curadoria preventiva NAO-validada (herdada da era do "10% fixo"),
nao uma trava de seguranca real — cada mesa usa seu PROPRIO piso de morte
(_max_dd_val = prop_size*(1-max_dd/100), lido por-mesa). Mesa de 8% validada no
simulador E2E: morre no piso correto (92k em 100k) e o lado perdedor sobrevive ate o
par fechar. Ver specs/prop-firm-dd-form-redesign-S382.md. max_risk migrado de USD
($2.500) pra max_risk_pct (2.5%) em S358/S362.

# Prop Preco F1% F2% Max Risk Profit Days
1 The 5%ers $545 8 5 $5.000 3 lucrativos
2 FTMO Swing $635 10 5 $5.000 nao
3 BrightFunded $582 8 5 $5.000 nao
4 City Traders $689 10 5 $5.000 3 lucrativos
5 FundedNext $549 8 5 $5.000 nao
6 Funding Pips $529 8 5 $2.500 nao

12 cadeiras = CLEAN_PROPS x 2 (2 contas por prop)

Excluidas:
- ~~Alpha Pro: max_risk $1.500 < $2.500 base~~ — S389-cont5: NAO mais excluida (portao max_risk>=2.5% removido; opera capada em 1.5% via S389 _ROUND_PROP_CACHE, validado no sim: par Alpha cap em $1.500 vs $2.500 das demais)
- ~~FTP Classic: max_dd 8% < 10%~~ — S382: NAO mais excluida (portao max_dd removido; 8% e' valido, validado no sim)
- (hoje so mesas NAO-2-step ficam de fora — unico requisito estrutural)

6b. Invert Dinamico por Rodada (S99)

Pares podem ser same-person (5ers-Linniu vs FTMO-Linniu). Pipeline copy-trade usa invert pra hedge. Se ambos tem mesmo invert → mesma direcao → nao eh hedge.

Regra: AF engine garante master.invert != slave.invert antes de criar signal. Se iguais, flipa o slave.

Passo Acao
1 Sortear master/slave (aleatorio)
2 Group_id unico: AF_P{pool}_R{round}_P{pair}
3 Se master.invert == slave.invert → flip slave no DB
4 Sortear direcao (BUY/SELL) + anti-OSB check
5 Criar signal do master via pipeline copy-trade
6 Servidor aplica invert → slave recebe oposto

Pre-condicao flip: 0 open positions na conta (Rule 6 invert-rules.md). Se tem posicao → skip par.

Pares same-person validos. Unica restricao: R7 (nunca same-prop).

Por que pipeline copy-trade: Trade_links automaticos, CLOSE propagacao, anti-echo, 70+ sessoes testado.

6c. Push Zone (precisao no fechamento)

Quando uma conta esta muito perto de passar ou morrer, o risco normal + look-ahead podem ser contraproducentes (reduzir risco = mais trades = mais spread = mais chances de nao fechar). A push zone permite risco levemente acima do max_risk pra fechar em 1 trade.

Aspecto Detalhe
Constante PUSH_BUFFER_PCT = 0.001 (0.1% de $100k = $100 para XAUUSD)
Limite push_limit = max_risk + buffer ($2.600 para F1 XAUUSD)
Ativa quando 0 < distance <= push_limit OU 0 < cushion <= push_limit
Efeito no risco Permite risk > max_risk (ate push_limit) para fechar em 1 trade
Efeito no LA Look-ahead NAO ativa na push zone (L2b) — conta resolve em 1 trade
Uso em MODIFY Detecta near-pass/near-death para agendar MODIFY pos-fill
Config por simbolo presets.py: BTCUSD usa buffer diferente de XAUUSD

Exemplo: Conta F1 com distance=$2.550 (dentro de push_limit=$2.600). Risco normal seria limitado a $2.500 (max_risk). Na push zone: risk = ceil($2.550 / 0.98) = $2.602. Fecha em 1 trade ao inves de 2 (economia de 1 dia + 1 spread).

6d. MODIFY Post-Fill (ajuste de precisao)

Apos AMBOS os lados de um par serem FILLED (executados), o servidor pode agendar um MODIFY (ajuste de SL/TP) para otimizar o fechamento — especialmente quando o par esta na push zone.

Aspecto Detalhe
Trigger Ambos sides FILLED + par na push zone (near-pass ou near-death)
Delay 30-120s apos fill (configuravel) — parece humano
Resultado Novos sinais MODIFY com SL/TP ajustados
Log Decisao salva em risk_detail do par

Framework de seguranca F1-F6

Regra Protecao
F1: Anti-duplicata Flag modify_scheduled impede MODIFY duplicado no mesmo par
F2/F3: Cap pelo PERDEDOR (S258) desired_loss/desired_gain limitados a min(cushion_ou_dist, risk_max(perdedor) + push_buffer_usd) + margin. No hedge, ganho_vencedor == perda_perdedor: quem paga (perdedor) define o cap. Push buffer autoriza zona push near-target/near-death (+$100 default), coerente com get_push_limit usado pra ATIVAR. Regra antiga min(max_a, max_b) era aplicacao indevida da regra de abertura no pos-fill — punia injustamente vencedor fraco. Margin ($2-10) preservada. Em near_death o bug antigo era dormente (c pequeno raramente ativa cap); em near_target materializou no Pool 20 R3 BrightFunded ficando a $23 de passar F1.
F4: Sanidade Shift maximo de SL/TP = 2x distancia original. Rejeita ajustes extremos
F5: Limite original Risco pos-MODIFY nao pode exceder risco original do par
F6: Margem minima Skip MODIFY se par ja esta dentro de margin_max do target
F7: Buffer SL MODIFY SL desconta sl_buffer ($1.00 XAUUSD) da distancia — EA aplica buffer ao SL inclusive no MODIFY, entao sl_dist = desired/vol - buffer. Sem isso, perda real = desired + buffer_cost. So aplica pra SL (death), nao TP (target).

Fluxo:

Ambos FILLED → Detecta push zone → Delay 30-120s → Calcula novo SL/TP
  → F1 (duplicata?) → F2/F3 (mais fraco) → F4 (sanidade) → F5 (limite)
  → F6 (ja proximo?) → Cria sinais MODIFY → EA aplica via GUI

7. Regras de Execucao (EA/servidor, nao simulador)

# Regra Detalhe
E1 1 trade/dia/conta Maximo 1 operacao por conta por dia
E2 SL = TP simetrico Valor aleatorio $25-$50. Buffer $1 no SL
E3 Anti-padrao Variar SL/TP aleatoriamente a cada trade
E4 Janela de abertura 22:30 UTC (30min apos Sydney) ate 12:00 UTC (NY forex open). = 13.5h. Ordens abertas fecham por SL/TP a qualquer hora
E5 Plano diario Servidor gera plano completo no rollover (~22:00 UTC). Usuario aprova via Telegram antes de executar
E6 Pre-check heartbeat ANTES de enviar signal a um par, checar heartbeat de AMBAS as contas (< 90s). Se uma offline: notificar, esperar, skip se timeout
E7 Retry com price gate Se uma conta do par falha: retry via TV WS streaming. So abre se
E8 EA = executor (excecao time-critical) Regra geral: EA executa ordens via GUI e reporta status, servidor decide. Excecao: operacoes time-critical onde latencia de rede eh intoleravel (ex: E9 rollover close) — EA eh PRIMARY, servidor eh fallback+alerta
E9 Rollover Dupla Camada (dual-layer swap close) Camada 1 (EA primary, v3.72.0+): EA fecha posicoes dentro da janela [swap - min_before, swap - max_before] (default [21:00, 21:30] UTC). Horario sorteado POR POSICAO via DJB2 hash deterministico (account+ticket+UTC_date) % window_sec — reproducivel, sem persistencia, dispersa fechamentos entre posicoes pra evitar timing correlacionado. Camada 2 (servidor fallback): se qualquer pair AF ainda estiver executing em T-min_before (default T-30min), servidor forca close emergencial via check_rollover_fallback + Telegram CRITICAL rollover_fallback_fired + safecheck log. EA offline/drift/GUI fail: camada 1 emite WS rollover_close_failed ou rollover_drift e servidor cobre. SSoT detalhado: specs/rollover-dupla-camada.md.
E10 Anti-self-hedge Antes de gerar sinais, verifica se NENHUMA das contas do par tem posicao aberta. Se alguma tem: skip o par. Tambem filtrado no pareamento (contas com posicoes removidas do pool). Previne hedge contra si mesmo

E5: Plano Diario — Fluxo Completo

22:00 UTC (rollover):
  1. Servidor coleta estado atualizado de todas as contas (balance, fase, distancia, colchao)
  2. Gera plano:
     a. Quais contas estao ativas (nao em transicao, nao mortas)
     b. Pareamento (fase, distancia, never same-prop)
     c. Risco por par (smart risk, LA, death trade, R-SAFE4)
     d. Horarios de execucao (R-SAFE6: espacamento entre siblings)
     e. Ordem aleatoria entre siblings da mesma prop
  3. Envia resumo compacto via Telegram
  4. Aguarda aprovacao do usuario

APROVACAO:
  [Aprovar]     -> execucao automatica nos horarios planejados
  [Skip dia]    -> nenhum trade executa
  [Replanejar]  -> recalcula plano com janela restante
                   (bloqueado se ha trades abertos da rodada)

Sem timeout — plano fica pendente ate resposta.
Se demorou: [Replanejar] gera novo plano a partir do horario atual.

E5: Formato Telegram

PLANO 20/Mar — 6 pares, 12 contas

P1: 5ers-A vs FTMO-B | F1 | $2,500 | ~22:40
P2: City-C vs Bright-D | F2 | $1,920 | ~01:15
P3: 5ers-E vs FNext-F | F1 | $2,500 | ~05:45
P4: FTMO-G vs FPips-H | F1 | $2,500 | ~08:00
P5: 5ers-I vs City-J | F1 | $1,200 | ~10:30
P6: Bright-K vs FNext-L | F2 | $2,500 | ~11:45

Same-prop: 5ers(3): 7h+5h | FTMO(2): 9.3h

[Aprovar] [Skip dia] [Replanejar]

E6: Pre-check de Vida

ANTES de enviar signal ao par:
  Conta A: heartbeat < 90s? SIM
  Conta B: heartbeat < 90s? SIM
  -> Ambas online: prosseguir

  Conta B: heartbeat > 90s? -> OFFLINE
  -> Notificar Telegram: "Conta B offline, esperando..."
  -> Esperar ate ficar online (sem timeout fixo, janela de abertura limita)
  -> Se nao voltar antes do proximo par: skip este par

E7: Retry com Price Gate (falha de execucao)

Signal enviado pra A e B (trade_group_id compartilhado)
  A executa, ACK recebido (entry_price salvo)
  B falha (GUI timeout, ACK fail)

Retry via TradingView WS (streaming, tempo real):
  A cada tick: B online (HB < 90s) E |tv_price - entry_A| <= $2?
    SIM -> re-envia signal pra B com MESMO trade_group_id
    NAO -> continua esperando

  Timer random(60, 90)s expira sem B abrir:
    -> Fecha A via auto-close (origin="server_auto_close")
    -> Notifica Telegram

Cenarios:
  A ok B ok              -> hedge OK
  A ok B falha preco OK  -> retry abre B, hedge OK
  A ok B falha preco >$2 -> timeout, fecha A
  A falha B falha        -> nada abriu, notifica, skip

Price gate $2 (XAUUSD): Conservador. Cobre ~15-30s de movimento em NY. Garante hedge simetrico.
Futuro (BTCUSD, USDJPY): Threshold em % do preco em vez de USD fixo.

8. Regras de Simulacao

# Regra Detalhe
S1 2 fases Simulador DEVE modelar F1 E F2 separadamente
S2 Same-phase pareamento Respeitar R8
S3 Spread > 0 Min 1%, padrao 2%. Spread 0% so pra sanity check
S4 Min 500 seeds Monte Carlo com >= 500 seeds
S5 Float (decimal) Balances e riscos em float. Sem arredondamento.
S6 Custo reciclagem Cada morte ou funded = custo da prop (preco normal, sem desconto). ROI desconta TODOS os custos.
S7 Profit days tracking Props com min_profit_days: contar dias lucrativos separadamente. Conta so passa quando balance >= target E profit_days >= exigencia. Na pratica, com risk $2k, matematicamente garantido bater 3 profit days antes do target.

10. Regras Anti-Deteccao Same-Prop (R-SAFE)

Principio: VARIACAO MAXIMA — Entre quaisquer contas same-prop (pessoas diferentes, pares diferentes), nenhuma dimensao observavel pode ter correlacao estatistica. O foco: nunca parecer copy trade.

Aplica-se quando: 2+ contas da mesma prop (pessoas diferentes) estao ativas no mesmo dia — INDEPENDENTE DA FASE. F1 vs F2 da mesma prop TAMBEM precisa divergir. A prop nao sabe o que eh "F1" ou "F2" — ela ve TODAS as contas.

Setup: PCs locais separados por pessoa (IP diferente, KYC diferente). Servidor coordena, EA so executa (E8).

Implementacao: Range Valido. Calcular o conjunto de valores validos ANTES de sortear — garantido encontrar se existir, zero tentativa-e-erro.

# Regra Detalhe Threshold Se violar
R-SAFE1 Espacamento minimo Minimo random(1h, 2h) entre execucao de same-prop siblings. Planejado no rollover como parte do plano diario (E5). Sempre tenta agendar todos os siblings. >= 1h (randomizado) Prop ve abertura correlacionada
R-SAFE2 Price gate Preco atual deve diferir do preco de entrada do sibling em >= rsafe2_price_gate_pct * price / 100 (XAUUSD: 0.35%, BTCUSD: 0.08%, _default: 0.30%). Se nao moveu: retry a cada 5min, ate 60x (rsafe2_max_retries). Se fim da janela (12:00 no timezone do pool, via trading_tz_offset) OU max retries: skip (terminal). S255: threshold em % (era USD) — auto-escala cross-symbol. >= 0.35% (XAUUSD), 0.08% (BTCUSD) Prop ve mesmo preco de entrada
R-SAFE3 Lote != sibling Lote resultante DEVE diferir do lote do sibling. Se igual: re-rolar SL/TP distance ate lote divergir. Hard check APOS todo calculo de risco. Lote = risk / (SL_distance * point_value) — SL/TP distance eh o motor principal de divergencia de lote. >= 0.05 lots Prop ve mesmo volume
R-SAFE4 Risco variavel DESATIVADO (S172). Reduzia risco em 20% quando siblings tinham risco < $100 diferenca. Removido: SL distance aleatorio ja cria variacao suficiente. ~~< $100~~ ~~P&L~~
R-SAFE5 SL/TP nivel absoluto != sibling O NIVEL DE PRECO do SL/TP (nao a distancia) deve diferir >= $10 do sibling. Garante que closes acontecem em precos diferentes. Se nao cabe: skip a conta (sibling que ja executou fica OK). >= $10 USD nivel absoluto Prop ve close correlacionado
R-SAFE6 Horario variavel (graph coloring) Usa graph coloring pra atribuir slots a pares same-prop. rsafe_gap_min (1h), rsafe_gap_max (2h) — gap randomizado por par. Jitter 5-55s por par dentro do slot (anti-robotico). Ordem dos slots randomizada a cada rodada. Janela definida por trading_start/trading_end e trading_tz_offset do pool. Janela configuravel, min ~1-2h Prop detecta padrao temporal

R-SAFE5: Nivel Absoluto (nao distancia)

Problema da versao anterior (S95): Checar distancia SL/TP (>= $3) nao garante closes em precos diferentes. Entry diferente + distancia diferente podem se cancelar:

Conta A: entry $3,050, SL dist $28 -> SL em $3,022
Conta B: entry $3,063, SL dist $41 -> SL em $3,022  <- MESMO nivel!

Distancia diferiu $13 (OK), mas nivel absoluto IDENTICO (PROBLEMA).

Solucao (S96): Checar o nivel absoluto do SL/TP (onde a ordem realmente fecha):

SL_level_A = entry_A - SL_dist_A  (se BUY)
SL_level_B = entry_B - SL_dist_B  (se BUY)
|SL_level_A - SL_level_B| >= $10  <- o que a prop realmente ve

TP_level_A = entry_A + TP_dist_A  (se BUY)
TP_level_B = entry_B + TP_dist_B  (se BUY)
|TP_level_A - TP_level_B| >= $10

Se nao cabe (range valido de SL/TP nao satisfaz >= $10 de nivel absoluto): skip a conta.
O sibling que ja executou continua normal — apenas o que nao conseguiu divergir fica ocioso.

R-SAFE6: Horario Variavel (Plano Diario)

Integrado com o plano diario (E5). Nao eh por sessao (robotico). Nao eh proporcional exato (robotico).

Regras:
- Janela: 22:30 UTC (30min apos Sydney open) ate 12:00 UTC (NY forex open)
- Espacamento entre siblings: minimo random(1h, 2h) por par de siblings, nao fixo
- Gaps irregulares: as vezes 1.5h, as vezes 8h. Sem padrao
- Ordem aleatoria cada dia (quem opera primeiro eh sorteado)
- Todos os siblings sao SEMPRE agendados. Skip so acontece se preco nao coopera no polling

Exemplo com 3 siblings (janela 13.5h):

Dia 1: 22:40, 01:15, 09:50  (gaps: 2.5h, 8.5h)  ordem: C, A, B
Dia 2: 23:55, 06:20, 07:45  (gaps: 6.4h, 1.4h)  ordem: B, C, A
Dia 3: 00:10, 04:30, 11:50  (gaps: 4.3h, 7.3h)  ordem: A, B, C

R-SAFE2: Polling de Preco

Quando chega o horario de um sibling e o preco nao moveu >= gate do sibling anterior:

Timer encerra (ex: 05:45)
  -> Polling a cada 5 min (rsafe2_retry_min, configurable)
  -> A cada check: |preco_atual - entry_sibling| >= rsafe2_price_gate_pct * price / 100?
     SIM -> executa (com jitter R-SAFE6)
     NAO -> re-agenda +5min, incrementa retry counter

  Fim da janela (12:00 no timezone do pool, via trading_tz_offset)
  OU max retries (60, configurable):
  -> Skip terminal (status = skipped_rsafe)

Gates por simbolo (presets.py, S255 em %): XAUUSD: 0.35% | BTCUSD: 0.08% | _default: 0.30%
Configuravel por pool: rsafe2_price_gate_pct no config JSONB (override via API ou dashboard). S255: migrado USD -> % pra auto-escalar cross-symbol.

Ordem de execucao aleatoria

A ordem entre same-prop siblings eh ALEATORIA. Nao eh sempre "Pessoa A primeiro, Pessoa B depois".
Cada dia, o servidor sorteia quem opera primeiro. Isso impede o padrao "Account X sempre precede Account Y".
Com 3 pessoas: ordem A-B-C, B-C-A, C-A-B, etc. Tudo aleatorio.

Fluxo Range Valido (servidor)

PARA CADA conta same-prop que vai operar apos siblings (ordem aleatoria):

  1. Esperar horario planejado (R-SAFE6, definido no plano diario)
  2. Checar preco vs TODOS os siblings ja executados via TradingView WS
     (R-SAFE2: |preco_atual - preco_sibling_N| >= $10 pra cada N?)
     - NAO: esperar ticks ate mover. Delay random(30s, 180s) apos gate.
     - Fim da janela sem mover: skip
  3. Calcular risco normalmente (smart risk, LA, death trade, etc.)
  4. R-SAFE4: checar risco vs TODOS os siblings
     - Se |risco - risco_sibling_N| < $100 pra algum N:
       risco *= random(0.80, 0.90)
  5. Calcular range valido de SL/TP:
     - Range base: $25-50
     - Pra cada sibling: calcular nivel absoluto e excluir SL/TP que ficaria
       a menos de $10 do nivel absoluto de qualquer sibling
     - Range valido = base - todas exclusoes
     - Se range vazio: skip a conta
  6. Sortear SL/TP do range valido
  7. Calcular lote = risco / (SL/TP_distance * valor_ponto)
  8. Checar lote vs TODOS os siblings:
     - Se |lote - lote_sibling_N| < 0.05 pra algum N:
       re-sortear SL/TP do range valido (novo valor muda lote)
       Se persistir: aceitar (risco/preco ja divergiram)
  9. Pre-check heartbeat (E6): ambas contas do par online?
  10. Executar ordem. Se falha: retry com price gate $2 (E7)

Close divergence (garantido por R-SAFE5)

R-SAFE2 (entry >= $10 diferente) + R-SAFE5 (SL/TP nivel absoluto >= $10 diferente) = closes em precos diferentes = closes em momentos diferentes. Garantido, nao por consequencia.

Escalabilidade (N pessoas por prop)

Funciona com N pessoas. Cada adicional = +1 slot de tempo, +1 check pairwise.
3 pessoas: todos checks sao pairwise (A vs B, A vs C, B vs C).

Direcao dos siblings

Coincidencia de direcao (ambos BUY no mesmo dia) NAO eh problema. Cada sibling esta num par diferente — direcao determinada pelo par (anti-OSB). Traders independentes podem comprar no mesmo dia naturalmente.

Futuro (~3 meses): Anti-bot individual

Padroes que nao sao de correlacao same-prop, mas de "parecer humano". Baixa prioridade — implementar quando R-SAFE1-6 estiver rodando em producao. TODOs #105-107.

Impacto na simulacao

No Monte Carlo (50/50, sem preco real), R-SAFE eh modelado como:
- Skip rate: ~10% dos trades second-pair (R-SAFE1+R-SAFE2 combinados)
- Risk reduction: Quando risco proximo do sibling (< $100), multiplicador 0.80-0.90
- Impacto medido (500 seeds): -8% funded, -6% ROI vs baseline. Aceito como custo de seguranca.
- Horario, SL/TP absoluto e lote sao detalhes de execucao sem impacto no 50/50

10b. Validacao Pos-Rodada (L3-L5)

Apos cada rodada completar, o servidor roda 3 camadas de validacao automatica (run_post_round_validation()):

Camada Nome Checks Detalhe
L3 Invariantes I1-I11 Contas, pares, sanidade: contas ativas no pool, pares validos, sem orfaos
L4 Audit R-SAFE I12-I17 Compliance R-SAFE: espacamento (R-SAFE1), preco (R-SAFE2), volume (R-SAFE3), risco (R-SAFE4), niveis SL/TP (R-SAFE5)
L5 Health H1-H5 Dead letters, timeouts, precos stale, falhas de margem, heartbeats ausentes

Dead Letter Queue (DLQ)

Sinais que falharam sao registrados na tabela AfDeadLetter para auditoria:

Campo Valores
failure_type timeout, margin, rsafe, invert, gui_fail, e7_exhausted
Contexto pair_id, account_id, prop_name, reason, attempts, context (JSON)

Volume — Formula de Calculo

volume = risk_usd / ((sl_distance + sl_buffer) / tick_size * tick_value)

Symbol Presets (configuracao por ativo)

Cada simbolo pode ter defaults diferentes via presets.py. Valores customizados por simbolo sao mergeados com config do banco: defaults ← preset ← database.

Parametro XAUUSD (default) BTCUSD
sl_min_pct / sl_max_pct (S255) 0.85% / 1.70% 0.65% / 1.60%
dz_min / dz_max $500 / $1000 $500 / $1000
rsafe2_price_gate_pct (S255) 0.35% 0.08%
rsafe5_min_gap_pct (S255) 0.35% 0.30%
push_buffer_usd $100 $100
spread_pct 2% 3%

S255 USD -> %: 4 thresholds price-facing migrados pra % do preco (auto-escala cross-symbol). dz_min/dz_max permanecem USD (equity-facing). Ver usd-to-pct-migration.md.

Arquitetura de Modulos (servidor)

O codigo AF esta modularizado em 6 arquivos no diretorio server/af/:

Camada Modulo Responsabilidade
Layer 0 constants.py Constantes puras, sem imports internos
Layer 1 risk.py Calculo de risco, R-SAFE
Layer 1 pairing.py Pareamento de contas
Layer 1 audit.py Invariantes, validacao pos-rodada
Layer 2 lifecycle.py Orquestracao de rodadas, lifecycle
Layer 2 signals.py Comunicacao com broker (OPEN/MODIFY/CLOSE)
Helper presets.py Defaults por simbolo

9. Ordem do Dia (processamento na simulacao)

INICIO DO DIA:
  1. Adicionar novas F1 (slots reciclados ontem)
  2. Checar passes (balance >= target + profit days ok) → promover F1→F2 ou FUNDED
  3. Checar mortes (balance < piso DD) → marcar pra reciclar amanha
  4. Parear contas restantes (por fase, distancia, same-prop)
  5. Calcular risco (smart risk + look-ahead)
  6. Executar trades (50/50)
  7. Atualizar balances (float)
FIM DO DIA

11. Operacoes Live (consolidado de af-live-operations.md)

Lifecycle (tudo MANUAL pelo usuario)

Evento Sistema faz Usuario faz
Conta bate meta F1 Sai do pool (stand-by), Telegram avisa Esperar prop aprovar, cadastrar nova conta F2
Conta bate meta F2 Sai do pool (funded), Telegram avisa Solicitar payout, comprar nova F1 pro slot
Conta morre Sai do pool (dead), Telegram avisa Comprar novo teste (~$500+)

Transicoes NAO sao automaticas. Toda mudanca de conta/fase depende de acao humana.

Quando os checks rodam (S163)

Momento Funcao O que checa Arquivo
Inicio da rodada process_round() sync_real_balancescheck_passescheck_deathspair_accounts lifecycle.py:200-211
Apos CADA trade process_trade_result() Atualiza P&L → check_deathscheck_passes lifecycle.py:607-608
Regenerar regenerate_round() sync_real_balancescheck_passescheck_deaths → re-pair lifecycle.py:407-408
Round completo process_trade_result() Se todos pares completaram → round_completedrun_post_round_validation → auto-gera proxima rodada lifecycle.py:617-650

Fluxo automatico: Rodada completa → nova rodada gera automaticamente → checks rodam no inicio → contas mortas/passadas sao excluidas antes de parear.

Gap: Se pool esta inativo (sem rodadas), mortes entre rodadas NAO sao detectadas ate proxima rodada.

Anti-One-Side-Betting (anti-OSB)

Props flaggam contas que operam sempre na mesma direcao. Sistema sorteia direcao aleatoria com peso crescente:
- 1 seguido = ok | 2 seguidos = alerta | 3+ = critico (forca inversao)
- Se ambas contas do par tem peso alto: prioriza a mais critica

Chair Owners (multi-pessoa)

Telegram — Eventos AF

Evento Conteudo
Par iniciado Props, direcao, preco, volume, risco
Par concluido Resultado por lado, custo spread
Rodada concluida Resumo W/L, spread total
Transicao Morte/Funded/Passed + detalhes
Plano diario Resumo com pares + horarios (E5)

Market Hours Guard (S288 — spec: market-hours-guard.md)

Regra (I1): Sistema NUNCA envia OPEN signal pra symbol em mercado fechado.

4 camadas de defesa:

Camada Onde O que faz
1. Per-pair guard main.py:1252 (scheduler) Checa is_market_open(pair.symbol) por par (NAO pool.symbol — bug S287)
2. Dashboard badge routes/af.py + static/js/af_hedge.js Endpoint expoe market_status no par; UI mostra badge amarelo "Mercado fechado"
3. Cleanup periodico _run_af_scheduler a cada 30s Pairs scheduled em symbol fechado >30min viram failed_market_closed (ou failed_swap_window em rollover)
4. TV defensivo is_market_open Camada 4 TV WS stale >5min + hardcoded=open = ambiguidade -> circuit breaker 3 strikes -> alerta HIGH

Por que pair.symbol (nao pool.symbol): AfPair.symbol eh snapshot imutavel no momento de criar par. Pool symbol pode mudar (PUT /api/af/pool/X) mas pares ja criados com symbol antigo precisam ser checados pelo SEU symbol.

Estados terminais novos (TERMINAL_PAIR_STATES):
- failed_market_closed — pair scheduled em symbol fechado >cutoff
- failed_swap_window — pair scheduled em rollover ativo

Categoria separada no daily report (R7): failed_market_closed NAO conta como falha de execucao (sistema funcionou — mercado estava fechado). Renderiza segregado pra nao inflar taxa de erro.

Telegram dedup: f"market_closed:{pool_id}:{symbol}:{YYYYMMDD}" — 1 alerta/pool/symbol/dia.

Bug exposto (S287): Apos PUT /api/af/pool/20 trocando symbol XAU->BTC, par 81 (scheduled XAU) disparou OPEN em sexta 23:25 UTC (XAU fechado). Causa: guard checava pool.symbol (BTC, aberto). Fix: pair.symbol.

Demo vs Real

O sistema NAO diferencia — logica identica. Demo (Exness): morte = resetar gratis. Real (prop firms): morte = comprar novo teste.

Fluxo de reset (S207, teste only): reset-classification → acha cadeira passed/dead → restaura group_id → seta account_id=NULL → cadeira vira registro historico (prop_config tem snapshot: nome, dono, tipo). Conta fica livre pra re-classificar (#2 se mesmo combo prop/fase/dono). Re-adicionar na pool cria cadeira nova. Dashboard mostra cards historicos com saldo congelado (virtual_balance) e label "Saldo final (historico)". Timer da pool usa paused_at como referencia quando pausada (congela countdowns).


Resumo Visual

PAREAMENTO (por fase, separado):
  F1 pool ──┬── F1 vs F1 (prop diferente, adjacente por distancia)
            └── F1 ociosa (se sem par valido)
  F2 pool ──┬── F2 vs F2 (prop diferente, adjacente por distancia)
            └── F2 ociosa (se sem par valido)

RISCO (hierarquia, primeiro que limita ganha):
  1. Teto da fase (F1: $2.500 | F2: $2.000 ou $2.500 conforme prop)
  2. Smart risk: min(teto, ceil(dist / (1-spread)))
  3. Look-ahead: evitar zona morta $0-$500 (so se >= max risk)
     - Distancia < max_risk = reta final (ignora LA distancia)
     - Colchao < max_risk = death trade (ignora LA colchao, risk = colchao)
  4. Risco do PAR = min(risco_A, risco_B)

CICLO DE VIDA:
  Comprar prop → F1 → [passa] → F2 → [passa] → FUNDED ($8k)
                  ↓                     ↓
               [morre]              [morre]
                  ↓                     ↓
              Nova F1               Nova F1

Historico de Regras

Data Regra Mudanca Sessao
2026-03-04 R7 Same-prop proibido, sem fallback S64
2026-03-05 L2 Reta final fix (dist < 2*DZ skip check) S65
2026-03-05 R8 Same-phase only (F1vsF1, F2vsF2) S66
2026-03-05 S1 Simulador DEVE usar 2 fases S66
2026-03-05 R3 F2 risk depende da prop (profit days: 2%, senao: 2.5%) S67
2026-03-05 R6 Death trade: colchao < $2.500 = all-in, sem LA S67
2026-03-05 L2 Threshold unificado $2.500 (distancia E colchao) S67
2026-03-05 D1 Morte = breach real (balance < piso DD), nao threshold fixo S67
2026-03-05 R13 REMOVIDA (piso $100 considerado inutil) S67
2026-03-05 R5 boost REMOVIDA (boost reta final, coberta por L2) S67
2026-03-05 L2 Threshold dinamico = max risk efetivo (nao fixo $2.500) S67
2026-03-05 R6 Death trade threshold = max risk efetivo (consistente com L2) S67
2026-03-05 R2 F1 max = $2.500 universal confirmado (todas props) S67
2026-03-05 Funded vs funded: futuro, nao simulado agora S67
2026-03-05 R5b Spread compensation no fechamento (evita 3o trade em F2) S67
2026-03-05 L1 Zona morta inclusiva: $0 a $500 (inclusive, precisa > $500) S67
2026-03-05 S5-S7 Float, custo reciclagem, profit days tracking S67
2026-03-05 §9 Ordem do dia definida (7 passos) S67
2026-03-05 R5b Clarificado: seguro pra Funding Pips (hard breach 3%). LA do oponente ativo. S68
2026-03-05 D6 Clarificado: F1→F2 TAMBEM tem 1 dia de gap (toda transicao) S68
2026-03-05 L1 Expandido: inclui rem <= 0 (morte por spread). Contas com colchao em [max_risk, max_risk*(1+spread)) agora protegidas. +3.2 LA/seed. S68
2026-03-05 R5b Safety cap: R5b limitado a max_risk + BUFFER ($150). FP: cap $2,650 (2.65%), margem $350 pro hard breach 3%. Seguro com qualquer spread. S68
2026-03-07 R6 Death trade: risk = colchao (nao max_risk). Apostar mais que colchao = SL ultrapassa piso DD = morte intraday. Simulacao: 46.4 funded/yr (vs 48.8), 300% ROI (vs 324%), mas numeros realistas. S69
2026-03-19 R-SAFE1-6 Regras anti-deteccao same-prop: timer minimo 1h, price gate $10-15, lote/risco/SL-TP divergentes, horario variavel Sydney-NY. Check & Re-roll. Principio: variacao maxima. S95
2026-03-19 advancing-front-v2.md arquivado. SSoT reduzido pra 2 arquivos: whitepaper + rules.py S95
2026-03-19 R-SAFE4 Threshold $100 mantido. Sweep (0/50/100/200/500) mostrou custo ~8% funded constante. Seguranca > ROI. S96
2026-03-19 R-SAFE5 Evoluido: distancia SL/TP (>=$3) -> nivel absoluto SL/TP (>=$10). Garante closes em precos diferentes. S96
2026-03-19 R-SAFE6 Evoluido: horario generico -> plano diario. Janela 22:30-12:00 UTC (13.5h). Espacamento random min 1-2h. S96
2026-03-19 E4-E8 Novas regras execucao: janela, plano diario, pre-check HB, retry price gate $2 via TV WS, EA executor. S96
2026-03-19 Versao whitepaper 2.0 -> 3.0 S96
2026-03-19 §6b Invert dinamico por rodada: flip slave se master.invert == slave.invert. Pares same-person validos. Pipeline copy-trade pra signals. S99
2026-03-19 Versao whitepaper 3.0 -> 3.1 S99
2026-03-29 §6c Push Zone: permite risk > max_risk na zona de fechamento (push_limit = max_risk + buffer) S150
2026-03-29 §6d MODIFY Post-Fill: ajuste SL/TP pos-execucao com framework F1-F6 (mais fraco manda, P57) S150
2026-03-29 L2b Push Zone Exemption: LA nao ativa se dist/cushion na push zone S150
2026-03-29 E7 Price freshness (5s max age, 10 retries) + vol mismatch handling S150
2026-03-29 E9 Anti-rollover: fecha pares antes do swap (janela configurable) S150
2026-03-29 E10 Anti-self-hedge: skip par se conta tem posicao aberta S150
2026-03-29 D4b P27: virtual vs real balance — _bal() usa real em live, virtual em sim S150
2026-03-29 R-SAFE2 Retries (60x max) + price gate por simbolo (BTCUSD: $200) S150
2026-03-29 R-SAFE6 Graph coloring + jitter 5-55s por par S150
2026-03-29 §10b Validacao pos-rodada L3-L5 + DLQ + volume formula + presets + arquitetura S150
2026-03-29 Versao whitepaper 3.1 -> 3.2 S150
2026-04-02 §11 Health endpoint: GET /pool/{id}/health — detecta missing snapshots, gap anomalies (>3x gap_max), stuck pairs (>2h executing), balance divergence (>15% virtual vs real). Severidade medium/high. S173
2026-04-02 Versao whitepaper 3.2 -> 3.3 S174
2026-04-06 §6d F2/F3 F2/F3 cap preserva margem: desired = min(cushion, risk_usd) + margin — garante cruzamento floor/target S193
2026-04-06 §6d F7 Novo filtro F7: MODIFY SL desconta sl_buffer ($1.00 XAUUSD) — EA aplica buffer no MODIFY S193
2026-04-06 Versao whitepaper 3.3 -> 3.4 S194
2026-04-19 §6d F2/F3 Cap usa risk_max(perdedor) + push_buffer, nao MIN das duas contas. Corrige case Pool 20 R3 BrightFunded F1 ficar a $23 de passar F1 (vencedor fraco com perdedor forte) S258
2026-04-19 Versao whitepaper 3.4 -> 3.5 S258
2026-05-21 R7 + R-SAFE Empresa: R7 e sibling key R-SAFE comparam prop_firms.company (Empresa), nao o nome completo. 2 Programas da mesma Empresa (FTMO Swing + FTMO Aggressive) contam como same-prop. Coluna company obrigatoria. Preventivo (8 firmas distintas hoje = company=name, zero regressao). S362
2026-05-22 R5b Cap do R5b + fallback de get_max_risk migrados de prop.max_risk (USD fixo em 100k) pra max_risk_pct/100 * prop_size (_spread_comp_cap). Escala por tamanho da conta; em 100k identico. Cap mascarado pelo push_limit → zero mudanca de comportamento. Pre-requisito do drop da coluna max_risk (USD). S362 #291

Anti-Detection

Status: ACTIVE | Ultima revisao: S255 (2026-04-18)

Versao: 1.5
Data: 2026-04-18
Status: Familia R-SAFE completa (R-SAFE1..R-SAFE6), todas ATIVAS. R-SAFE4 foi reativado em v3 (rank-based, multiplicativo) apos rework S172->S2xx. R-SAFE5 e R-SAFE3 unificados em uma passada (R-SAFE5+3). Presets por simbolo (XAUUSD + BTCUSD) introduzidos em server/af/presets.py. S255: 4 thresholds price-facing migrados de USD para % do preco (auto-escala cross-symbol, corrige bug BTC latente).
SSoT para: Regras de anti-deteccao entre contas same-prop no sistema AF


Visao Geral

O AF (Advancing Front) roda hedges pareados entre contas de prop firms. Em producao, cada conta esta em uma prop firm/broker DIFERENTE — nenhum broker individual ve multiplas contas.

Porem: a mesma prop firm pode ter 2+ contas (ex: Alpha Pro Linniu + Alpha Pro Lucas). A PROP FIRM consegue ver ambas.

Objetivo: Trades de contas same-prop devem parecer naturais (nao coordenados/roboticos).

┌─────────────────────────────────────────────────────────────────┐
│                    O QUE A PROP FIRM VE                         │
│                                                                 │
│  Conta Alpha Pro Linniu:                                        │
│    XAUUSD BUY 1.12 lot  SL=2884.50  TP=2898.50  @14:32:05     │
│                                                                 │
│  Conta Alpha Pro Lucas:                                         │
│    XAUUSD SELL 0.94 lot  SL=2909.20  TP=2895.20  @14:32:08    │
│                                                                 │
│  Campos visiveis: Symbol, Direction, Volume, SL, TP, Entry, Time│
│  NAO visiveis: risk em USD, pair ID, trade group, metadata AF   │
└─────────────────────────────────────────────────────────────────┘

Insight Principal

SL distance randomization eh o mecanismo PRIMARIO de anti-deteccao. Um unico SL aleatorio (faixa XAUUSD $25-$50, BTCUSD $400-$1000) cria variacao em cascata em volume, nivel de SL e nivel de TP. As outras R-SAFE sao redes de seguranca para edge cases — timing, price, volume, niveis e sanidade.

SL distance aleatorio (faixa por simbolo)
    │
    ├──> Volume (lots) varia ~50%      ← cascata direta (risk fixo / SL variavel)
    ├──> SL level varia ate metade do range ← cascata direta (entry + SL distance)
    └──> TP level varia ate metade do range ← cascata direta (entry + TP = f(SL))

Regras R-SAFE Ativas (Familia Completa)

Resumo em tabela antes do detalhe:

Regra Faz Onde Quando
R-SAFE1 Espacar entrada de siblings no tempo (1-2h) engine.py::_schedule_pairs_rsafe Na criacao da rodada (scheduling) — enxerga o DIA (semente)
R-SAFE2 Bloquear entrada se preco muito proximo do sibling (retry ate window end) signals.py linha ~607 Na hora de gerar o sinal — enxerga o DIA
R-SAFE3 Garantir volumes distintos entre siblings (>= 0.05 lot) signals.py linha ~772 (junto com R-SAFE5) Selecao do SL distance
R-SAFE4 v3 Reduzir risk dos siblings subsequentes (rank-based, 0.90-0.95^rank) signals.py linha ~671 Apos calcular risk_usd
R-SAFE5 Garantir SL/TP levels distintos (>= $10 apart) signals.py linha ~725 (unificado com R-SAFE3) Selecao do SL distance
R-SAFE6 Sanity check de volume (detectar erro de unidade pips vs price) signals.py linha ~837 Depois de calcular volume

Nota: O comentario do codigo em engine.py::_schedule_pairs_rsafe chama a funcao de "R-SAFE1/R-SAFE6". Isso eh legacy — R-SAFE6 naquele contexto refere-se ao jitter aleatorio de 5-55s aplicado junto do gap. No signals.py o nome R-SAFE6 foi re-usado para o sanity check de volume. Duas coisas diferentes com o mesmo numero — convivem hoje, renomear eh trabalho futuro.


Categorizacao: DECISAO vs EXECUCAO (regua do dono — util pra simulacao)

Pra decidir o que um simulador RAPIDO (moeda 50/50, sem preco real) precisa rodar, as R-SAFE
separam em duas camadas pelo EFEITO no resultado (por FUNCAO, nao pelo numero):

Camada Regras Muda quem ganha/perde? Papel
DECISAO (com quem parear + quanto arriscar) R7 (nunca mesma Empresa) · R-SAFE4 v3 (reducao de risco) · R5b (teto) ✅ sim decidido ANTES da ordem sair. E' o que uma busca de estrategia otimiza
EXECUCAO (como/quando a ordem sai) R-SAFE1 (espacamento) · R-SAFE2 (price gate) · R-SAFE3 (lote≠) · R-SAFE5 (nivel SL/TP≠) · R-SAFE6 (jitter/sanity de volume) ❌ nao (no 50/50) so faz o trade PARECER nao-coordenado pra prop. Nao muda o resultado

Uso: um sim rapido/matematico roda so a camada de DECISAO (identico ao real no que importa) e
ignora a EXECUCAO (disfarce visual, so relevante no broker real). Excecao: R-SAFE2 tem efeito
colateral no $ (skip ~10% -> ~8% menos funded, whitepaper §10) — mas parelho entre estrategias, nao
muda o ranking. R7 nem e R-SAFE (e' pareamento) mas entra na DECISAO por ser absoluta.

R-SAFE6 e' ambiguo (nota acima): jitter de horario (engine) E sanity de volume (signals) — ambas
em EXECUCAO. A categorizacao e' por FUNCAO, entao a ambiguidade nao atrapalha.
Spec da aplicacao: sim-rapido-deterministico-paridade.md §2b.


R-SAFE1: Sibling-Aware Scheduling (timing)

Propriedade Valor
O que faz Espaca o scheduled_at de pares com propriedade em comum por gap de 1-2h (configuravel)
Mecanismo Graph coloring: constroi grafo de conflito (siblings = props compartilhadas), aloca em slots; primeiro aparecimento = stagger normal, reapareceu = last_time + gap + jitter
Configuracoes rsafe_gap_min=3600, rsafe_gap_max=7200 (segundos); rsafe6_jitter_min=5, rsafe6_jitter_max=55
Localizacao server/af/engine.py funcao _schedule_pairs_rsafe (linha 347)
Quando ativa Na criacao da rodada (lifecycle.py linha 268 chama o scheduler)

Por que existe: Se 3 contas same-prop entram em XAUUSD em 60s, a prop firm ve um cluster — obvio que e EA. Espacando 1-2h, o padrao some. Jitter de 5-55s evita ate mesmo granularidade "redonda" (ex: gap exatamente 3600s, 7200s).

Exemplo concreto:

Pool com 3 contas Alpha Pro + 6 outras contas (3 pares total)
Base time = 12:00 UTC

Sem R-SAFE1: 3 pares entrariam em ~12:00, 12:05, 12:10 (stagger normal)
Com R-SAFE1:
  Par 1 (contem Alpha Pro) -> 12:03:24
  Par 2 (sem Alpha Pro)    -> 12:08:11
  Par 3 (contem Alpha Pro) -> 13:15:47  (sibling, gap = 4703s, jitter = +22s)

R-SAFE2: Entry Price Gate (retry ate fechar a janela)

Propriedade Valor
O que faz Se o preco atual esta a menos de rsafe2_price_gate_pct * price / 100 do open_price de um sibling, re-agenda o par. Se a janela de trading fechar (ou exceder rsafe2_max_retries), termina com skipped_rsafe (nao-terminal: tenta varias vezes ANTES de desistir). S255: threshold em pct (era USD) — auto-escala por simbolo
Mecanismo Loop sobre irmas_da_janela (a lista do DIA, round_id=None + desde; a lista por rodada sibling_trades segue alimentando R-SAFE3/4/5); se abs(price - sib.open_price) < gate, empurra scheduled_at em rsafe2_retry_min minutos e levanta ValueError (re-agendamento). Quando utcnow() > window_end OU retries >= max_retries, marca skipped_rsafe + dead letter + WS push
Window end trading_end do pool (HH:MM em timezone do pool), convertido pra UTC via trading_tz_offset. Default: 12:00 timezone local. Se ja passou, rola pro proximo dia
Configuracoes rsafe2_price_gate_pct (XAUUSD: 0.35%, BTCUSD: 0.08%, _default: 0.30%), rsafe2_retry_min=5, rsafe2_max_retries=60
Localizacao server/af/signals.py linhas 607-669
Quando ativa Inicio do fluxo de geracao de sinal, antes de calcular SL/TP

Exemplo concreto:

Sibling (Alpha Pro Linniu) abriu XAUUSD @ 2884.50 hoje as 10:00
Agora eh 11:00, preco atual 2886.30
diff = $1.80 < $10 gate  -> RE-AGENDA pra 11:05
Em 11:05, preco 2889.10. diff = $4.60 < $10  -> RE-AGENDA pra 11:10
Em 11:10, preco 2895.30. diff = $10.80 >= $10 -> OK, continua.

Se a janela fechar antes do gate abrir: par vira skipped_rsafe (terminal).


R-SAFE3: Lot (Volume) Divergence

Propriedade Valor
O que faz Garante |volume_A - volume_B| >= rsafe3_min_lot_diff entre siblings same-prop
Mecanismo Unificado com R-SAFE5 hoje — ambos rodam numa unica enumeracao de candidatos de SL distance (veja "R-SAFE5+3 Unificado" abaixo). Se nao ha candidato que satisfaz ambos, R-SAFE3 eh RELAXADO (prioriza divergencia de niveis)
Configuracoes rsafe3_min_lot_diff=0.05
Localizacao server/af/signals.py linhas 725-822 (dentro do bloco R-SAFE5+3 unified)

R-SAFE4 v3: Cumulative Downward Risk Variance (REATIVADO)

Mudanca importante: Esta regra foi DESABILITADA em S172 (v1.1 do spec) e REATIVADA em v3 com mecanica diferente. A versao antiga (fator fixo 0.80-0.90 contra o sibling "mais fraco") gerava false positives. A v3 eh rank-based e cumulativa.

Propriedade Valor
O que faz Aplica um fator de reducao multiplicativo no risk_usd do par atual, proporcional a quantos siblings same-prop ja abriram trade na mesma rodada com risk similar
Mecanismo Rank = count de siblings na MESMA rodada com |risk_atual - risk_sib| < 100 USD. Se rank > 0, risk_usd *= random.uniform(min, max) ** rank
Configuracoes rsafe4_factor_min=0.90, rsafe4_factor_max=0.95 (5-10% de reducao por nivel de rank)
Localizacao server/af/signals.py linhas 671-694
Ordem natural Como R-SAFE1 embaralha a ordem de entrada (random slot), o rank nao fica sempre na mesma conta — varia por rodada

Exemplo concreto (3 contas Alpha Pro na mesma rodada, risk base $1500 cada):

Primeiro sibling abre:  risk = $1500 (rank 0, sem reducao)
Segundo sibling abre:   rank = 1, factor = 0.93^1 = 0.93 -> $1500 * 0.93 = $1395
Terceiro sibling abre:  rank = 2, factor = 0.91^2 = 0.828 -> $1500 * 0.828 = $1242

Por que funciona em hedge (critica antiga): A critica original era "prop firma nao ve risk em USD". Correto — mas a prop ve VOLUME, e volume = risk / SL_distance. Se os 3 siblings usam SL distances aleatorios E risks diferentes, a dispersao de volume fica consideravelmente maior do que a cascata natural do SL sozinho. Custa ~5-10% de risk por nivel (aceitavel), mas diminui acoplamento visivel.

S396 — Excecao decisive (prioridade pra death/win trade): o corte R-SAFE4 NUNCA cai numa conta decisiva (vai morrer: colchao < teto; OU passar: distancia <= teto). Ela precisa do risco cheio pra resolver — cortar transformaria a death em zumbi (sobra um trocado, nao morre) e a win em quase-passou (fica aquem do alvo), desperdicando o round. O corte cai nas irmas NAO-decisivas (ja contadas no rank das que processam depois). Helper puro rsafe4_decisive_exempt(a, b, cfg) (engine.py) + isencao no bloco R-SAFE4. Gateado por rsafe4_decisive_priority (default on, reversivel). A ordem de abertura NAO muda (continua aleatoria via R-SAFE1) — so o ALVO do corte muda, entao nao introduz padrao de ordem detectavel. Residual aceito: como a ordem e aleatoria, as vezes 2 irmas ficam no risco cheio (decisiva + uma nao-decisiva rank 0) — a randomizacao do SL (disfarce PRIMARIO) ainda da volumes distintos. Spec: specs/rsafe4-decisive-priority.md.


R-SAFE5+3 Unificado: Smart Range Selection

Propriedade Valor
O que faz Enumera TODOS os candidatos de SL distance dentro do range efetivo (step=0.01% do preco, S255). Pra cada candidato, testa: (a) levels (SL/TP) ficam a pelo menos rsafe5_min_gap_pct * price / 100 de qualquer sibling; (b) volume resultante fica a pelo menos rsafe3_min_lot_diff de qualquer sibling. Pega aleatoriamente entre os que passam em AMBOS. S255: thresholds em pct (era USD) — corrige bug BTC onde $10 = 0.016% (quase inexistente)
Fallback degradado Se nenhum candidato passa nos 2 criterios, relaxa R-SAFE3 (soh exige levels OK). Se mesmo assim nao achar, emite warning e usa o SL inicial (sorteado antes)
Configuracoes rsafe5_min_gap_pct (XAUUSD: 0.35%, BTCUSD: 0.30%, _default: 0.30%), rsafe3_min_lot_diff=0.05
Localizacao server/af/signals.py linhas 725-822
Note sl_buffer=1.0 hardcoded no server (pool config armazena pips, EA converte). Range efetivo: [sl_min_usd, sl_max_usd] normal (derivados de sl_min_pct*price/100 e sl_max_pct*price/100); [sl_min_usd, midpoint] quando F1 near-death/near-target (deixa espaco pra MODIFY expandir)

Exemplo concreto (XAUUSD, price=2900.00, sl_min_pct=0.85, sl_max_pct=1.70):

sl_min_usd=24.65, sl_max_usd=49.30, step=0.29 (=0.01%*price)  -> ~85 candidatos
Sibling A: SL=2875.50, TP=2925.50, vol=1.04
Sibling B: SL=2922.00, TP=2878.00, vol=0.88

Candidato d=25.0:
  test_levels = [2874.00, 2926.00, 2925.00, 2875.00]
  2874.00 vs 2875.50 -> diff=$1.50 < $10 -> REJEITA
Candidato d=40.0:
  test_levels = [2859.00, 2941.00, 2940.00, 2860.00]
  Todos >= $10 de cada sibling level -> level_ok
  test_vol = risk / 40 = diff vs 1.04 e 0.88 -> OK
  ACEITA
...
Candidatos validos: 47 SL distances
Escolhe random.choice -> d=34.2 (por exemplo)

R-SAFE6: Volume Sanity Check (unit mismatch)

Propriedade Valor
O que faz Apos calcular volume, se risk_usd > 500 E volume <= volume_min (0.01 lot), levanta erro e manda alerta Telegram
Motivo Detectar bug historico: alguem passou sl_buffer em pips (100) quando esperava preco ($1) e volume veio absurdamente baixo (quase zero)
Localizacao server/af/signals.py linhas 837-850
Nota Mesmo nome do "R-SAFE6 jitter" do scheduling; aqui eh o sanity check de volume. Sao dois usos distintos do nome no codigo atual

Exemplo concreto:

risk_usd = $1500, volume = 0.01 lot (lot_min)
sl_distance = 45.0, sl_buffer = 1.0 (OK)
Mas: alguem chamou calculate_volume com sl=4500 (pips ao inves de $) -> volume vem 0.01
R-SAFE6: volume=0.01 + risk=$1500 = BLOCK + Telegram alert

Faixas de Variacao por Simbolo

Hoje o sistema tem presets para 2 simbolos (XAUUSD e BTCUSD). A pool 28 usa BTCUSD em S254 (antes usava XAUUSD). Presets ficam em server/af/presets.py::SYMBOL_DEFAULTS.

XAUUSD (ouro)

Config (S255): sl_min_pct=0.85%, sl_max_pct=1.70%, sl_buffer=$1.0, contract_size=100, rsafe2_price_gate_pct=0.35% (= $24.65-$49.30 SL / $10.15 gate @ $2900)

Metrica Min Max Variacao Maxima
SL distance $25 $50 50% (100% range)
Volume (risk $1500) 0.30 lot 0.60 lot 50%
Volume (risk $2500) 0.50 lot 1.00 lot 50%
SL level from entry $26 $51 $25 de diferenca
TP level from entry $25 $50 $25 de diferenca

Formula:

volume = risk / (sl_distance * contract_size)

Com risk $1500 e XAUUSD (contract=100):
  SL $25 -> 1500 / (25 * 100) = 0.60 lot
  SL $50 -> 1500 / (50 * 100) = 0.30 lot
  Variacao = (0.60 - 0.30) / 0.60 = 50%

BTCUSD (bitcoin)

Config (S255): sl_min_pct=0.65%, sl_max_pct=1.60%, sl_buffer=$1.0, rsafe2_price_gate_pct=0.08%, spread_pct=3.0 (= $403-$992 SL / $49.60 gate @ $62k)

Metrica Min Max Variacao Maxima
SL distance $400 $1000 60%
SL level from entry ~$401 ~$1001 $600 de diferenca
TP level from entry ~$400 ~$1000 $600 de diferenca
Volume (depende de tick_value BTCUSD, varia por broker) 60%

Nota: BTCUSD tem ADR historica ~$1500/dia, entao o gate R-SAFE2 de $50 abre ~30 pares/dia potenciais (bem mais permissivo proporcionalmente que XAUUSD, que tem gate $10 vs ADR $60 -> 6 pares/dia).

Outros simbolos

presets.py tem defaults para XAGUSD, ETHUSD, US30, US500, USTEC, EURUSD, GBPUSD, USDJPY no dicionario _ADR_ESTIMATES (so ADR, nao preset completo). Hoje nenhum desses roda em producao no AF — soh XAUUSD e BTCUSD. Se forem ativados, cairao no fallback _default (sl_min_pct=0.85%, sl_max_pct=1.70%, rsafe2_gate_pct=0.30%, rsafe5_gap_pct=0.30%) e devem ser calibrados especificamente antes.


Sibling Detection

Propriedade Valor
Funcao _get_sibling_trades_today()
Localizacao server/af/risk.py linha 27
Filtros Same pool + same Empresa + status NOT IN (failed, timeout). O round_id e' CONDICIONAL (risk.py: if round_id is not None) e ha o filtro alternativo desde (por TEMPO). Quem chama decide: R-SAFE3/4/5 passam round_id; o R-SAFE2 passa round_id=None + desde
Cross-round DEPENDE DA REGRA (2026-08-26). R-SAFE1 e R-SAFE2 comparam o dia de negociacao inteiro (ancorado no swap, via _get_trading_day_start); R-SAFE3, R-SAFE4 e R-SAFE5 seguem por rodada. Ver "Escopo por regra" abaixo
Cross-phase SIM — compara F1 vs F2 vs Funded se compartilham prop (a prop nao ve fase)

Logica:

siblings = trades WHERE
    pool_account.pool_id = current_pool
    AND pool_account.prop_name = current_account.prop_name
    AND (pair.round_id = current_round   -- quando o chamador passa round_id (R-SAFE3/4/5)
         OR trade.created_at >= desde)   -- quando o chamador passa desde (R-SAFE2, o DIA)
    AND trade.status NOT IN ('failed', 'timeout')
    AND pair_id != exclude_pair_id

Retorna: pair_id, account_id, pool_account_id, direction, volume, sl_price, tp_price, open_price, risk_usd, created_at (lista de dicts).


Metas da obra

📊 Metas e histórico — 7 de 7

Gerado por scripts/meta_da_obra.py, com o histórico até 2026-08-26 19:37 (GMT-4). Não edite este bloco à mão — a próxima geração sobrescreve. Uma meta muda de estado quando um commit diz fecha, muda ou descarta meta N.

⚠️ O bloco COMMITADO fica um commit atrás, e isso é da natureza da coisa: a meta fecha pelo commit, e um commit não pode conter o registro de si mesmo. O arquivo em disco fica em dia (o gancho regenera logo depois), e o commit seguinte leva a correção junto. Quem lê pelo site do git pode ver um estado defasado.

# meta custo quando (GMT-4) commit
1 A prova que FALHA hoje: a semente nao sobrevive ao aprovar — implementada: a prova que falha antes do conserto (8 casos) 2026-08-26 18:13 0bc2b9153
2 A semente atravessa o botao de aprovar — implementada: a porta unica de agendamento 2026-08-26 18:13 0bc2b9153
3 Mutacao dirigida na semente e no aprovar — implementada: mutacao dirigida: 4 de 4 sabotagens mortas 2026-08-26 18:13 0bc2b9153
4 A spec das regras R-SAFE entra na pagina /guide — implementada: a spec das regras publicada no /guide 2026-08-26 18:13 0bc2b9153
5 Tooltip na tela: por rodada x por dia — implementada: o escopo de cada regra no tooltip da tela 2026-08-26 18:13 0bc2b9153
6 A simulacao E2E nao quebrou — implementada: simulador local: 877 verdes, zero falha, zero regressao 2026-08-26 18:23 eeb6f2690
7 Subir pra producao — implementada: promovido em eeb6f2690, prova de vida rodada dentro da VPS 2026-08-26 18:32 36bacc114

Histórico — 26 commit(s):

quando commit o quê
2026-08-26 19:37 7f122d51c fix(replay): a ultima vermelha da varredora — subprocess.run cru virou a porta unica
2026-08-26 19:33 119667d9f fix(varredoras): mais DUAS catracas aprendem a TERCEIRA categoria — e a 3a mordeu a si m
2026-08-26 19:23 7a011f630 docs(doutrina): a blindagem de import entra na tabela — inclusive a decisao CONTRARIA a
2026-08-26 19:21 e8de9fab4 fix(p846): o contrapeso que faltava — 10 provas de 'tem que barrar', ZERO de 'nao pode'
2026-08-26 19:19 115b8df72 docs(licoes): a metrica que nao pode ver o proprio ganho — cetico derrubou minha conclus
2026-08-26 19:12 060cdac5d feat(guardas): blindagem de import nos 116 — de 53 mortes para ZERO, medido ao vivo
2026-08-26 18:48 82bfbbbe4 feat(rede-torta): a sonda do contrapeso ausente — FERRAMENTA, e o motivo esta escrito ne
2026-08-26 18:41 f620dc57b feat(saude): backup de mutacao orfao e' UMA MORTE do motor — e agora alguem olha
2026-08-26 18:32 36bacc114 chore(deploy): o espacamento entre rodadas esta em producao — com a prova de vida e o li
2026-08-26 18:30 3bee2c8e8 fix(portao da prova): para de mandar cacar base quando a prova E' o conserto
2026-08-26 18:23 eeb6f2690 fix(metas): o quadro nao declarava obra, e por isso NENHUM trailer enderecado fechava
2026-08-26 18:19 89cb0c632 chore(de-quem-e-este-item): fecha a obra — 12 de 12, laco de revisao encerrado
2026-08-26 18:15 5ec34de02 docs(degradado): o residuo escrito — 62% da casa tem o mesmo defeito, e NAO vamos varrer
2026-08-26 18:13 0bc2b9153 docs(guide): as regras anti-deteccao entram na pagina, e cada campo diz se vale por RODA
2026-08-26 18:10 b03e54cfd fix(degradado): a OUTRA lista tambem era minha — 9 guardas na prova, 13 no disco
2026-08-26 18:04 3d84bf525 feat(sinal-9): a contagem de licoes para de sair sem dizer quantas viraram parede
2026-08-26 18:01 f32594a40 fix(af): o espacamento entre rodadas agora ATRAVESSA o botao "Aprovar Rodada"
2026-08-26 18:01 fb080b3b4 test(varredura): a regua do socorro ganha rede propria — e mata o mutante que sobrevivia
2026-08-26 14:22 68bcdc567 feat(catracas): a TERCEIRA categoria — socorro de import tolerante nao e' divida nem cop
2026-08-26 14:13 77ee539f1 feat(sinal-8): placar de mutacao sem dizer o recorte para de sair calado
2026-08-26 14:11 d5a9c20df fix(guardas): fecha as 6 mortes + a prova ganha PROVA DE VIDA do proprio instrumento
2026-08-26 14:01 7e507ee6e feat(parede): assert X or True deixa de ser gravado — a 1a licao de hoje que virou 100
2026-08-26 13:57 95f78294f fix(guardas): a prova parou de usar a MINHA lista e achou 6 mortes que ela nao via
2026-08-26 13:53 b10aaf88c docs(bancada): a lista de dependencias NUNCA descreveu a VPS — ela nasceu como foto de o
2026-08-26 13:53 96af157de docs(rsafe): a obra nasce porque a semente que eu entreguei ontem e' NO-OP em producao
mais 1 commit(s) — veja git log

Escopo por regra: a rodada ou o dia? (2026-08-26)

A regua, em 1 linha (decisao do dono): regra de RISCO e' por RODADA; regra de
ANTI-DETECCAO e' por DIA de negociacao. A prop firm nao sabe que rodada existe — ela le'
o extrato da conta, e a linha "entrou 00:26" continua la' depois de a posicao fechar.

O dia comeca no SWAP, nao a meia-noite (_get_trading_day_start + _safe_swap_hour, em
af/constants.py) — a MESMA porta que decide se a rodada e' a 1a do dia pro reset de PnL.

Regra O que compara Escopo Onde
R-SAFE1 horario de entrada dia a porta agenda_com_memoria_do_dia (af/risk.py), usada pelos quatro caminhos que agendam: nascimento e recall (af/lifecycle.py), aprovar e reset de timers (routes/af.py). Ha prova por AST cobrando — chamador novo REPROVA
R-SAFE2 preco de entrada dia irmas_da_janela (af/signals.py), com round_id=None + desde
R-SAFE3 lote rodada sibling_trades
R-SAFE4 risco (rank) rodada sibling_trades — e' regra de RISCO
R-SAFE5 nivel de SL/TP rodada sibling_trades
R-SAFE6 dois usos do mesmo nome — (a) jitter de 5-55s no agendamento, somado ao gap; (b) sanidade de volume (a) acompanha o R-SAFE1; (b) o proprio trade (a) engine.py, dentro do agendador; (b) signals.py. Nenhum dos dois COMPARA irmas

⚠️ has_siblings (que governa o sorteio de risco) e o rank do R-SAFE4 continuam lendo
sibling_trades, por RODADA.
A lista do dia (irmas_da_janela) alimenta so' o laco do
R-SAFE2 — misturar as duas mudaria o risco sorteado, que nao e' o que a mudanca queria.

⚠️ AS DUAS PONTAS LEEM A MESMA REGUA, e isso e' obrigatorio (licao P1269): se o
agendamento (R-SAFE1) espacar por dia-de-swap e a porta (R-SAFE2) barrar por outra janela, o
agendamento marca um horario que a porta recusa.

O que NAO foi estendido, e o numero: medido nas duplas mesma-Empresa que atravessam rodadas
em 24h — 16 lotes colados (R-SAFE3), 10 niveis de SL e 8 de TP (R-SAFE5), contra
3 de preco (R-SAFE2). ~10x mais adiamentos, sem evidencia de dano. Estender seria construir
por simetria, nao por evidencia.

⚠️ ESTE NUMERO NAO TEM FORMA DE RECONTAR, e isso vai dito (achado de cetico, 26/08). Ele
saiu de uma consulta ad-hoc ao banco de producao no dia da obra e nao ficou em nenhum script
— nem em scripts/, nem em .claude/medicoes/. Pela regua desta casa, numero em doutrina so'
vale escrito junto da forma de reconferir; este nao esta'. Some-se a isso que ele foi medido na
janela de 24h rolante, e a regra que entrou ancora no swap — mediu-se na janela que o
dono mandou trocar. Trate-o como ordem de grandeza, nao como fato, e remeça antes de usa-lo
para reabrir a decisao.

O caso que motivou: round 7671 -> 7672 (06/07, pool 448, 21 min de intervalo) produziu duas
entradas coladas — BrightFunded a $1,88 e City Traders a $1,99, contra os $5,43 exigidos.
Card #1067.


Interacoes com Sistema Existente

Componente Relacao
server/af/signals.py Implementa R-SAFE2, R-SAFE3, R-SAFE4 v3, R-SAFE5, R-SAFE6 (sanity) na generate_signals_for_pair
server/af/engine.py Implementa R-SAFE1 (+ jitter R-SAFE6 scheduling) em _schedule_pairs_rsafe
server/af/risk.py Re-exporta _schedule_pairs_rsafe e expoe _get_sibling_trades_today
server/af/presets.py Presets por simbolo (sl_min_pct, sl_max_pct, dz_min, dz_max, rsafe2_price_gate_pct, rsafe5_min_gap_pct), gate context report (S255)
server/af/lifecycle.py Chama _schedule_pairs_rsafe na criacao da rodada (linha 268)
server/routes/af.py Expoe parametros R-SAFE na API de update de pool (rsafe_gap_min/max, rsafe2_price_gate_pct, rsafe3_min_lot_diff, rsafe4_factor_min/max, rsafe5_min_gap_pct, rsafe6_jitter_min/max) (S255 pct rename)
af-rules-whitepaper.md Regras-pai (R-SAFE eh subset das regras de pool/risco)

Outros Mecanismos de Variacao (fora da familia R-SAFE)

O sistema AF tem varios mecanismos anti-pattern ATIVOS que nao usam o prefixo R-SAFE. Sao menos visiveis porque estao espalhados pelo codigo, mas compoem o cinturao de defesa anti-robotico. Auditados em S254 (2026-04-18).

Mecanismo Localizacao O que faz Motivacao
Direction tie-breaker random server/af/engine.py:157 Quando weight(BUY) == weight(SELL), escolhe random.choice(['BUY','SELL']). Evita vies deterministico Anti-bias
Invert flag por conta accounts.invert (DB) Metade da pool tem invert=true. Quando master manda BUY, 6 EAs abrem BUY e 6 abrem SELL Espalha direcao por conta
MODIFY margin random ($2-10) server/af/engine.py:200 + signals.py:1934 _margin = random.uniform(2.0, 10.0) somado a calculos de R4/R5b/push e MODIFY. Evita landing exato em limites Anti-robotic (TODO #125)
Dead zone target random server/af/engine.py:259 dz_target = random.uniform(dz_min, dz_max). Alvo da DZ varia por trade Anti-robotic H7
~~CLOSE delay random (61-90s)~~ REMOVIDO (S-piso-unificado) ~~server/af/signals.py~~ Timer paralelo eliminado. O fecho por Vol mismatch agora respeita SO o piso unico min_hold (precedencia mesa>pool>global) via can_close_orphan. Conta prop (135-150s) fecha idêntico; conta sem firma cai no piso global (10-20s). Spec: piso-fechamento-unificado.md Prop firm safety (agora via min_hold)
MODIFY delay random (30-120s) server/af/signals.py:1866 delay = random.uniform(modify_delay_min_s=30, modify_delay_max_s=120). Comment explicito: "looks human to prop firms" Anti-pattern human-like
Firing order shuffle server/routes/af.py:2032 random.shuffle(pairs) antes de definir scheduled_at em reset de timers Evita ordem previsivel
Pairs base order shuffle server/af/lifecycle.py:251, 420 random.shuffle(pairs) na criacao/reshuffle da rodada Evita pareamento previsivel

Cobertura por vetor de deteccao

Vetor detectavel por prop firm Mecanismo atuando
Direcao uniforme em todas contas Direction tie-breaker + invert flag 50/50
Trades sempre em niveis "redondos" MODIFY margin random + DZ target random
CLOSEs muito rapidos apos erro Piso min_hold da mesa (135-150s prop; 10-20s global) — S-piso-unificado
MODIFYs instantaneos apos OPEN MODIFY delay random 30-120s
Ordem deterministica de entrada Firing shuffle + pairs base shuffle

Nao confundir com R-SAFE1/R-SAFE6

R-SAFE1 lida com gap entre siblings same-prop (1-2h). R-SAFE6 scheduling eh jitter 5-55s no timing. Os mecanismos desta secao sao de outra natureza: variacao dentro do trade (margin, direction, delays especificos). Complementares, nao substitutos.

Gaps residuais conhecidos

Gap Risco Mitigacao proposta
Horario de news economicas (NFP, FOMC, CPI) Medio (XAUUSD) / Baixo (BTCUSD 24/7) Nao mitigado. Considerar integracao com calendario economico
Symbol rotation intra-pool Baixo (cada prop ve 1 simbolo historico na conta) Non-goal consciente. Revisitar se volume crescer
Dia da semana previsivel Baixo (BlossomBlend varia scheduled_at por equity virtual) Monitorar, nao mitigado explicitamente

Non-goals (reavaliado S254)


Riscos Conhecidos

Risco Probabilidade Impacto Mitigacao
SL aleatorio cai identico para 2 siblings Baixa (range $25, step $0.1 = 250 valores) Medio R-SAFE5+3 re-rolls ate esgotar candidatos
Volume identico apos R-SAFE5 Muito baixa (enumera candidatos ambos-validos) Baixo R-SAFE3 relaxado quando impossivel (levels > volume)
Prop firma correlaciona timing de entrada Baixa Medio R-SAFE1 (gap 1-2h) + jitter 5-55s
Preco entrada identico entre siblings Medio (mercado pode rondar) Alto (obvio que eh bot) R-SAFE2 gate pct (XAUUSD 0.35%, BTCUSD 0.08%) com retry ate window_end
Mesmo simbolo + direcao oposta simultaneo N/A N/A Contas same-prop NUNCA pareadas (R7 do whitepaper)
Volume absurdamente baixo por unit mismatch Baixa (bug historico) Alto (trade nao executa) R-SAFE6 sanity + Telegram alert
Clusterizacao de risk entre siblings Media Medio (prop ve volume similar) R-SAFE4 v3 (cumulative rank factor)

Configuracoes: Cheatsheet de Defaults

Todas configuravaveis via PoolUpdate (server/routes/af.py). Defaults:

Campo Default Descricao
rsafe_gap_min 3600 s R-SAFE1 gap minimo (1h)
rsafe_gap_max 7200 s R-SAFE1 gap maximo (2h)
rsafe6_jitter_min 5 s Jitter no scheduling
rsafe6_jitter_max 55 s Jitter no scheduling
rsafe2_price_gate_pct 0.35% (XAUUSD) / 0.08% (BTCUSD) / 0.30% (_default) S255 — % minimo do preco entre entry e sibling. Auto-escala cross-symbol (era USD)
rsafe2_retry_min 5 min Intervalo entre retries de R-SAFE2
rsafe2_max_retries 60 Maximo de retries antes de terminal skip
rsafe3_min_lot_diff 0.05 lot Divergencia minima de volume
rsafe4_factor_min 0.90 Reducao min de risk (por rank)
rsafe4_factor_max 0.95 Reducao max de risk (por rank)
rsafe4_similar_risk_pct 10.0 % S254 — Gate: % do risk pra considerar siblings 'similar' (rank++). Adaptativo a R4-smart/DD (substitui threshold USD absoluto)
rsafe4_decisive_priority true S396 — Conta decisiva (death/win) NUNCA leva o corte R-SAFE4 (precisa do risco cheio pra resolver). Corte cai nas irmas nao-decisivas. Reversivel (false = legado). Spec: rsafe4-decisive-priority.md
rsafe5_min_gap_pct 0.35% (XAUUSD) / 0.30% (BTCUSD, _default) S255 — % minimo de SL/TP entre siblings. Corrige bug BTC onde $10 era 0.016%
sl_min_pct 0.85% (XAUUSD, _default) / 0.65% (BTCUSD) S255 — SL distance minima em % do preco (era USD)
sl_max_pct 1.70% (XAUUSD, _default) / 1.60% (BTCUSD) S255 — SL distance maxima em % do preco (era USD)
~~close_delay_min_s~~ REMOVIDO (S-piso-unificado) — colapsado no piso min_hold. Knob e leitura eliminados
~~close_delay_max_s~~ REMOVIDO (S-piso-unificado) — idem
trading_end "12:00" (default) Fim da janela de trading (HH:MM local)
trading_tz_offset -4 (default) GMT offset do pool em horas

Historico

Versao Data Mudancas
1.0 2026-02-xx Documentacao inicial — R-SAFE3 + R-SAFE5 ativos, R-SAFE4 desabilitado (S172)
1.1 2026-04-04 R-SAFE2 gate BTCUSD $200->$50 (S181), window end usa trading_tz_offset do pool
1.2 2026-04-18 Atualizacao completa S254: familia R-SAFE1..R-SAFE6 documentada. R-SAFE4 REATIVADO em v3 (rank-based cumulative). R-SAFE5+R-SAFE3 unificados em uma passada. Presets por simbolo (XAUUSD + BTCUSD) documentados. Non-goals reavaliados
1.3 2026-04-18 Segunda passada S254: adicionada secao "Outros Mecanismos de Variacao (fora da familia R-SAFE)" — 8 mecanismos anti-pattern ativos mas previamente nao documentados (direction tie-breaker, invert flag, MODIFY margin random, DZ target random, CLOSE/MODIFY delays, 2 shuffles). Adicionada tabela de cobertura por vetor de deteccao + gaps residuais conhecidos (news timing, symbol rotation, dia da semana)
1.4 2026-04-18 Terceira passada S254: expostos 5 campos hardcoded/ausentes na UI de config do pool — rsafe2_retry_min (5 min), rsafe2_max_retries (60), rsafe4_similar_risk_pct (10%, trocado de threshold USD absoluto pra % — adaptativo a R4-smart/DD cortes), close_delay_min_s (61), close_delay_max_s (90). Patterns P132 (drift spec vs codigo), P133 (3 conceitos de risco em props: prop.max_risk teorico vs phase_ceiling 2.5%/2% vs daily_DD 5%).
1.7 2026-08-26 Escopo por regra (card #1067): R-SAFE1 e R-SAFE2 passam a comparar o dia de negociacao (ancorado no swap_hour_utc), nao so' a rodada. R-SAFE3/4/5 seguem por rodada — medido: 16 lotes / 10 SL / 8 TP colidiriam, contra 3 de preco. Nova secao "Escopo por regra".
1.6 2026-07-06 S-piso-unificado: close_delay (timer paralelo 61-90s do vol_mismatch) REMOVIDO de vez — código do timer, campo da UI (static/index.html/af_hedge.js), schema/validação/writer (routes/af.py), leitura + whitelist de espelho da sim (af_tick_real.py). Piso ÚNICO de fecho = min_hold (precedência mesa>pool>global) via can_close_orphan. Conta prop: zero mudança (min_hold 135-150s já engolia o close_delay); conta sem firma cai no piso global (10-20s). Spec: piso-fechamento-unificado.md.
1.5 2026-04-18 S255 USD -> % migration: 4 thresholds price-facing migrados de USD para % do preco — sl_min_pct (0.85%/0.65% XAU/BTC), sl_max_pct (1.70%/1.60%), rsafe2_price_gate_pct (0.35%/0.08%), rsafe5_min_gap_pct (0.35%/0.30%). Auto-escala cross-symbol sem ATR. Fix bug BTC latente: rsafe5 era 0.016% ($10/$62k), agora 0.30%. dz_min/dz_max permanecem USD (equity-facing, fora de escopo). Migration destrutiva: backend + frontend + DB SQL em uma fase (R17). UI mostra conversao live pct → pips + USD usando preco vivo do symbol_info. Spec: usd-to-pct-migration.md.

Campos (Regras)

Referência rápida pra consultar sempre. Explica cada valor que aparece na
tabela de regras das mesas (prop firms) e nos ajustes da pool — em linguagem
leiga. Última revisão: S362 (2026-05-21).


A confusão mais comum: são 3 limites DIFERENTES

Muita gente mistura "risco" com "drawdown". São três coisas separadas:

Limite Pergunta que ele responde Quem define Exemplo em $100k
Risco por trade Quanto arrisco em UMA operação? A pool (max_risk_f1/f2_pct), com teto da mesa 2,5% = $2.500/trade
Daily DD (drawdown diário) Quanto posso perder SOMANDO o dia inteiro? A prop firm (daily_dd_op_pct) 4,5% = $4.500/dia
Max DD (drawdown total) A partir de quanto a conta MORRE de vez? A prop firm (max_dd) 10% = morre em $90.000

Como eles conversam no motor: o risco por trade é o tamanho normal de cada
aposta. O Daily DD é um teto do dia — se você já perdeu muito hoje, ele
aperta o risco do próximo trade pra não estourar o dia (risco = min(risco_do_trade, quanto_ainda_posso_perder_hoje)). O Max DD é o piso de morte: cruzou, a
conta acabou.

Hoje rodamos ~1 round/dia em demo, então o Daily DD quase nunca "morde" (1
trade de $2.500 < $4.500/dia). Quando operarmos vários trades/dia no real, ele
passa a limitar de verdade.


Regras da PROP FIRM (fixas por programa — tabela prop_firms)

São as regras da mesa. Cada programa (ex: "FTMO Swing") tem as suas. Viram dólar
multiplicando o % pelo tamanho da conta.

Campo O que é (leigo) Unidade Usado hoje?
name Nome do programa (ex "FTMO Swing") texto sim
company Empresa dona do programa (ex "FTMO") — usada pra nunca parear 2 contas da mesma Empresa no hedge (R7) texto sim (S362)
price Custo do desafio USD sim (relatório de custo)
max_dd Piso de morte total: perda máxima desde o início. 10% = conta de $100k morre em $90k % SIM (motor)
daily_dd Perda máxima do dia nominal (a regra oficial da mesa) % rótulo (futuro)
daily_dd_op_pct Perda máxima do dia operacional (com folga: ~0,5% abaixo do nominal pra não chegar no limite). É o que o motor usa % SIM (motor, mas inerte com 1 round/dia)
daily_dd_type Mede o DD diário por equity (valor vivo) ou balance (saldo fechado) enum rótulo (futuro)
target_f1 Alvo de lucro pra passar a Fase 1 % SIM (motor)
target_f2 Alvo de lucro pra passar a Fase 2 % SIM (motor)
min_days_f1 Dias mínimos de trade na Fase 1 dias rótulo (futuro)
min_days_f2 Dias mínimos de trade na Fase 2 dias rótulo (futuro)
min_profit_days Dias mínimos com lucro dias rótulo (futuro)
max_risk_pct Teto da mesa: risco máximo por trade que a prop permite (o "mesa cap") % SIM (motor)
limitation Observação textual (ex "SL obrigatório") texto rótulo
steps Quantas fases o desafio tem (1 step / 2 steps) número sim
max_risk LEGADO USD (risco/trade calibrado em $100k) — sendo aposentado USD em remoção
daily_dd_op LEGADO USD (daily DD calibrado em $100k) — aposentado: motor não lê mais USD não (S362)

Settings da POOL (você ajusta — af_pools.config)

São os ajustes operacionais da pool, não da mesa.

Campo O que é (leigo) Unidade
max_risk_f1_pct Risco por trade na Fase 1 (o "risco por round") %
max_risk_f2_pct Risco por trade na Fase 2 %
dead_zone_min_pct / dead_zone_max_pct Zona morta (folga aleatória anti-detecção) %
equity_floor_enabled Liga/desliga o piso de segurança (airbag) bool
equity_floor_pct Piso de segurança em %: se o equity cair abaixo desse % do tamanho, EA fecha tudo. 8% = fecha em $92k (numa conta de $100k) %
spread_pct Spread assumido no cálculo do hedge %
sl_buffer_* / tp_buffer_* Folga de SL/TP por grupo de ativo (metais/forex/default) pips ou preço
buffer_mode Como o buffer é medido: PIPS ou PRICE enum
swap_hour_utc / janela de trading Horários de rollover e janela operacional hora UTC
max_rounds_per_day Limite de rounds por dia (0 = ilimitado) número
pairing_strategy Algoritmo de pareamento (default greedy) texto

Legado: equity_floor_value (piso em USD) foi trocado por equity_floor_pct
(%) no S362. O servidor ainda lê o USD como fallback pra pools não-migrados.


Como o % vira dólar

A tela de preview das regras (e o motor) pegam o % × tamanho da conta:

Por isso o S362 conectou o tamanho real da conta ao motor: antes ele assumia
$100k fixo (funcionava por coincidência, porque tudo é $100k). Agora escala
sozinho — uma conta de $50k calcula alvo $54k e piso $45k.


O que é futuro / inerte hoje


Lendo o Config Settings da pool (telas novas — S383)

O botão Config Settings de cada pool abre um painel pra ajustar os buffers
(margens de segurança) e conferir como as contas estão. O que cada parte mostra:

Buffer por par (margem de segurança)

O buffer é uma folga que você dá no preço de saída (stop/alvo) pra não ser
tirado por um detalhe do mercado. Ao digitar o buffer de cada tipo (Metais /
Forex / Cripto), o painel mostra, pra cada par real:

Mapa de spread médio

Tabela com o spread típico de cada par, juntado das contas. É uma média que
vai acumulando
(o spread "normal", não o de um instante isolado). Dá pra ver
por par, por mesa (empresa) ou por conta.

Filtro demo / real

Um interruptor "Só contas demo (simulando mesa)". Hoje todas as 12 contas
são demo (treino). Quando entrarem contas reais de prop firm, desmarque pra
ver só elas. Essa marca é definida num checkbox na hora de classificar a
conta
— conta nova entra como real por padrão. No painel inicial, cada
conta demo ganha uma etiqueta azul "demo" no card, pra diferenciar de bate-
pronto.

"Empresa — Desafio"

Onde antes aparecia só o nome do desafio (ex: "Standard 2 Steps"), agora aparece
a empresa junto (ex: "FundedNext — Standard 2 Steps"), pra não confundir qual
mesa é qual.

"Buffers confirmados pelo EA?" — a conferência

O robô (EA) que roda em cada conta recebe os buffers e confirma de volta o
que aplicou. Esse painel compara o que você salvou com o que o robô
confirmou
:


Donos Prop Firms

Por que isto existe: o hedge cruzado assume que cada prop firm é uma empresa independente — dono diferente, time de risco diferente, e elas não comparam contas entre si. Se duas "marcas diferentes" forem do mesmo grupo-mãe, o hedge entre elas vira uma independência ilusória (a empresa percebe que as duas contas são a mesma jogada e pode eliminar as duas). O setor consolida rápido — compras e fusões mês a mês — então esta lista envelhece e precisa de revisão periódica.

Última auditoria: 27 de Maio de 2026 · Próxima revisão recomendada: ~Agosto/2026 (ou assim que sair notícia de nova aquisição).

Veredicto atual

As 11 marcas cadastradas são, hoje, 11 grupos donos distintos — nenhum par da pool compartilha dono, então a premissa do hedge se sustenta. A única mudança recente: a Funded Trading Plus foi comprada pela Instant Funding (grupo Acello, Reino Unido) em 26/05/2026. Como a Instant Funding não está na nossa lista, não cria conflito interno hoje — mas a Funded Trading Plus deixou de ser independente.

Quem é dono de quem

Marca Grupo / dono real País (sede) Independente?
FTMO FTMO s.r.o. (holding OMHC) 🇨🇿 Chéquia Sim
The 5%ers Five Percent Online Ltd 🇬🇧 Reino Unido Sim
FundedNext NEXT Ventures 🇦🇪 Emirados Sim
BrightFunded BrightFunded B.V. / Bright Global FZCO 🇳🇱 Holanda (opera de Dubai) Sim
City Traders Imperium CTI FZCO 🇦🇪 Emirados Sim
Funding Pips ANKH PROP FZCO 🇦🇪 Emirados Sim
Alpha Capital (Alpha Pro 10%) Alpha Capital Group Ltd (Kohler / AMGP) 🇬🇧 Reino Unido Sim
Funded Trading Plus Acello Ltd / Instant Funding 🇬🇧 Reino Unido Trocou de dono 26/05
For Traders FT Trading Ltd + BLN Tech Club DMCC 🇦🇪 Emirados (registro em St. Lucia) Sim
Maven MAVEN LLC 🇱🇨 Saint Lucia (operação em Dubai) Sim
Fintokei Purple Group / Purple Trading 🇨🇿 Chéquia Sim

Marcas-irmãs (não adicionar à pool sem checar)

Estas marcas não estão na nossa lista hoje, mas pertencem ao MESMO grupo de uma marca que já está. Se qualquer uma entrar na pool, não pode parear (hedge) com a marca-mãe — seria a mesma empresa disfarçada. A regra R7 do sistema hoje compara o nome da marca, não o grupo-mãe, então este radar é manual até a regra olhar o grupo.

Marca-irmã Mesmo grupo de Tipo
Instant Funding Funded Trading Plus prop firm (a compradora)
IF Crypto / IF Pro Funded Trading Plus sub-marca / broker do grupo Acello
Trade The Pool The 5%ers prop firm de ações
TSG ("Trade Set Go") The 5%ers broker dos fundadores
FundYourFX The 5%ers prop firm (co-fundador virou CEO)
FNmarkets FundedNext broker próprio do grupo
OANDA FTMO broker (comprado pela FTMO)
Quantlane FTMO tech (comprada pela FTMO)
Alpha Futures / Alpha Prime Alpha Capital sub-marcas internas
Purple Trading Fintokei broker que respalda a Fintokei

Mapa de localização — onde ficam nossas 11

Inspirado no mapa "Locations of Prop Firms" do PropFirmMatch. Repare na concentração: a maioria fica em Emirados e Reino Unido. Atenção importante — ficar no mesmo país NÃO significa mesmo dono; é só onde a empresa se registrou (muitas escolhem Emirados ou paraísos fiscais por imposto e regulação mais leve).

País Marcas Quantas
🇦🇪 Emirados FundedNext · City Traders Imperium · Funding Pips · For Traders 4
🇬🇧 Reino Unido The 5%ers · Alpha Capital · Funded Trading Plus 3
🇨🇿 Chéquia FTMO · Fintokei 2
🇳🇱 Holanda BrightFunded (opera de Dubai) 1
🇱🇨 Saint Lucia Maven (operação em Dubai) 1

O que o "mapa" ensina pro hedge: várias firmas independentes dividem o mesmo endereço (Emirados, principalmente). Isso é normal e não compromete o hedge — o que importa é o dono, não o CEP. O risco de verdade aparece quando duas marcas têm o mesmo grupo-mãe (como Funded Trading Plus e Instant Funding agora) ou usam o mesmo provedor de liquidez nos bastidores — aí a execução pode ficar correlacionada mesmo com donos diferentes.

Log de consolidação do setor (eventos que tocam ou rondam a lista)

Data Evento Toca a lista?
2026-05-26 Instant Funding (Acello Ltd) adquire Funded Trading Plus (+70% receita do grupo) SIM — FTP é a #8. Comprador fora da lista (sem par-problema hoje)
2025-12-01 FTMO fecha compra da OANDA da CVC Asia Fund IV (~US$250M, holding OMHC) Parcial — OANDA é broker, não prop. Risco de correlação de backend
2025-mid Alpha Capital reestrutura controle (Kohler Investment Group + AMGP) Não — entidades fora da lista
2025-05 FundedNext lança FNmarkets (broker próprio, licença Comores) Não — verticalização própria
2023-10 FTMO compra Quantlane Não — tech, fora da lista

Riscos que NÃO são dono compartilhado (mas importam pro risco real)

  1. Correlação de backend/liquidez: duas firmas de donos diferentes podem usar o MESMO provedor de liquidez (o "atacadista" que entrega os preços) → execução/preço correlacionados, mesmo sem dono comum. Mapeamento parcial: CTI=Broctagon, Fintokei=Purple Trading, FTMO→OANDA, FundedNext→FNmarkets, For Traders→DXtrade. Falta confirmar o resto.
  2. Raiz tcheca coincidente: For Traders (Jakub Rož) e Fintokei (David Varga / Purple Group) têm fundadores tchecos, mas SEM vínculo societário comprovado. Só vigilância.

(Estas duas seções vieram de .claude/knowledge/prop-firm-ownership.md, apagado em 2026-08-12. Aquele arquivo era gêmeo deste, sem nenhum apontador vivo e com zero consulta medida em 520 transcritos — mas estas duas seções só existiam lá. Auditoria dos 22 documentos de knowledge/.)

Fontes (prova) — auditoria 27/05/2026


Classificação/Proteção

Toda conta precisa de uma etiqueta pra operar, e o sistema te protege de
transformar sem querer uma conta de verdade numa conta de teste. Esta página
explica as três proteções que trabalham juntas nos bastidores — você quase
nunca vê, mas elas evitam acidentes sérios.

Analogia: crachás numa empresa. Cada pessoa tem um crachá (Visitante,
Funcionário, Diretor). Sem crachá, ninguém entra na área de produção. E é
impossível colar o crachá de "Visitante/teste" numa pessoa que é funcionária
de verdade — o segurança não deixa. Aqui é igual: cada conta tem uma etiqueta,
e não dá pra rebaixar uma conta de mesa a "conta de teste".

1. A etiqueta obrigatória — sem etiqueta, não opera

Cada conta carrega uma etiqueta que diz de que mundo ela é:

A regra é simples: conta sem etiqueta não recebe sinal e não opera. É de
propósito — uma conta "solta", sem classificação, fica de fora por segurança, em
vez de operar por engano. Quando você cadastra uma conta na pool, ela ganha a
etiqueta Demo automaticamente.

2. Não dá pra disfarçar — a trava anti-troca

Você não consegue transformar uma conta de produção (de mesa/pool) numa conta
de teste. Se tentar, aparece um recado claro:

"Essa conta é uma conta de produção (mesa/prop firm) e está (ou esteve)
vinculada a uma pool — por segurança, não dá pra transformá-la em conta de
teste. Se ela é de verdade uma conta aposentada, use 'Aposentar/Reclassificar'
primeiro."

Por que isso existe: as ferramentas de teste conseguem disparar ordens de
mentira. Se alguém pudesse disfarçar uma conta de verdade como "teste" e depois
apontar uma ferramenta dessas nela, dispararia ordem numa conta que não devia. A
trava fecha esse caminho. Pra reaproveitar uma conta de verdade, o caminho certo é
Aposentar/Reclassificar primeiro (isso solta a classificação de produção com
segurança).

3. As ferramentas de teste só mexem na cobaia — o "cinto"

As ferramentas que forçam situações de teste (forçar uma trava, forçar uma troca
de versão do robô, etc.) só funcionam na conta-cobaia (Sandbox). Mesmo que
alguém vire a etiqueta de uma conta de verdade pra "teste", essas ferramentas
recusam — elas não confiam só na etiqueta, elas conferem qual conta é.
É um segundo cadeado por cima do primeiro: cinto e suspensório.

4. Aposentar de vez troca a chave — o robô velho perde o acesso

Quando você aposenta uma conta por desfecho terminal (ela morreu ou passou de
vez), a chave de acesso dela é trocada. Assim, o robô que estava naquela conta
perde o acesso e não fica pendurado operando algo que já acabou.

E se você reaproveitar a conta depois? Sem problema — quando você reclassifica
a conta pra usar de novo, o robô reconecta sozinho com a chave nova, sem você
precisar fazer nada. Se em vez de aposentar você só está reclassificando (reuso
imediato na mesma conta), a chave é preservada — o mesmo robô continua sem
interrupção.

Em resumo

Você quase nunca interage com isso diretamente — são redes de segurança que
trabalham em silêncio pra que um clique errado não vire um trade indevido.


EA Architecture

Status: ACTIVE | Ultima revisao: S326 (2026-05-04) — bump EA v3.82.0 -> v3.84.2 (atual). Drift coberto desde S294: S300 RECONCILE retroativo pos-offline (ReconcileEngine.mqh + 3 hooks LinniuC.mq5), S301 fix log spam ReconcileEngine (gate 60s OnTimer), S313 remove MODIFY precheck por tolerancia (formula 5point10^(digits-1) sempre dava 0.5 USD constante), S315.2 popup_text Unicode escape \uXXXX (preserva PT-BR no JSON), S319 cleanup WebBridge_PostReverse codigo morto. S326 Sessao A+B (NOVO): telemetria T4 EXATA via campo ea_received_at_ms no ACK payload. Buffer circular g_signalReceivedAtMs[64] em WebBridge.mqh + helper RecordSignalReceived chamado dentro de LNC_BeginSignalObs (cobre WS via ExecuteSingleSignal:728 + HTTP via PollAndExecuteSignals:1221). Helper _S326_NowEpochMs() usa TimeGMT()*1000 com fallback TimeCurrent() se TimeGMT()=0. Sandbox account_id=3 validado live em 4 signals (WS+HTTP+OPEN/MODIFY/CLOSE). Modulos e responsabilidades inalterados. Tabela WebBridge.mqh "v3.69.0" e nota historica preservada. [allow-spec]

SSoT para: Modulos EA, compilacao, versionamento, erros MT5, armadilhas
Versao: 2.4 (EA v3.84.2, atualizado S326 — 2026-05-04). Desde 2.3: S300 ReconcileEngine retroativo, S301 fix log spam gate 60s, S313 remove MODIFY precheck constante, S315.2 popup_text Unicode escape, S319 cleanup PostReverse, S326 telemetria T4 ea_received_at_ms (Sessao A backend+UI + Sessao B EA exato).
Consolida: memory/ea-modules.md + .claude/knowledge/copytrade-ea.md
Relacionados: specs/invert-rules.md (inversao), specs/auto-update-flow.md (auto-update), specs/settings-sync-guardian.md (sync settings), memory/business-constants.md (constantes)

LinniuC.mq5 (Orquestrador, ~1245 linhas)

Inputs

OnInit (13 passos)

  1. Verificar DLLs | 2. FindMT5Window | 3. OpenProcess ListView
  2. Init lock file gui_lock_<HWND>.txt | 5. SettingsManager_Init
  3. WebBridge_Init | 7. SymbolResolver_Init | 8. SnapshotEngine_Init
  4. Init local signal files (offline) | 10. Panel_Init
  5. EventSetMillisecondTimer(200) | 12. Print banner | 13. g_initOK=true

OnTimer — 2 ciclos

PollAndExecuteSignals

Poll -> parse JSON -> para cada signal: resolve symbol -> apply buffers -> GUI execute -> detect local ticket -> ACK (FILLED/FAILED)

Ticket Detection (v3.29.3+)

Modulos (.mqh)

Modulo Linhas Prefixo Responsabilidade
WebBridge.mqh 809 g_wb_ HTTP via WinInet. Heartbeat, poll, ACK, signal post. Headers: X-API-Key + X-Account-Num. v3.69.0: ACK JSON precos usam Digits() do simbolo (antes: hardcoded 5). v3.84.0+ (S326 Sessao B): novo bloco no inicio do arquivo com struct SSignalRcvTs, buffer circular g_signalReceivedAtMs[64] (TTL implicito por overwrite, suficiente pra multi-ordem N=20 stress) + 3 helpers (_S326_NowEpochMs com fallback TimeCurrent quando TimeGMT()=0, RecordSignalReceived(signalId), GetSignalReceivedMs(signalId)). WebBridge_SendAck agora chama GetSignalReceivedMs(signalId) e adiciona campo "ea_received_at_ms":<long> no JSON do ACK quando valor > 0 (omitido quando 0 = entrada nao encontrada, preserva backward compat). Helpers movidos de LinniuC.mq5 em v3.84.1 (forward-dep cross-file MQL5 falhava silente).
GUIExecution.mqh 1185 g_gui_ GUI automation Win32. OPEN=F9, MODIFY=ListView+DBLCLK, CLOSE=ListView+Fechar. Lock file anti-conflito. v3.83.8 (S315.2): GUI_TryConfirmBrokerPopup(hDlg, &popupText) retorna texto do popup via out param. GUI_JsonEscapeStr faz Unicode escape canonico \uXXXX (BMP) em chars >= 0x7F pra preservar acentos PT-BR no JSON HTTP body sem quebrar FastAPI parser. 3 emits gui_*_manual_close (OPEN s12 / MODIFY m6 / CLOSE) incluem field popup_text no payload signal_events. Spec: specs/decisions/0014-popup-text-mql5-unicode-escape.md
SnapshotEngine.mqh 417 g_se_ Detecta OPEN/MODIFY/CLOSE. Compare snapshots -> enfileira. Cooldowns (CLOSE 0.5s, MODIFY 1s, OPEN 0.5s — OPEN guard via SE_SuppressTicket(newTicket, 5s)). SE_MAX_RETRIES=5: sinal descartado apos 5 falhas. SE_MAX_QUEUE=200
SymbolResolver.mqh 401 g_sr_ 3 camadas: Override -> Cache normalizado -> Alias (30 hardcoded). Buffers por classe
SettingsManager.mqh 541 g_sm_ Cascata: SERVER -> LOCAL+JSON -> LOCAL -> OFFLINE. Persiste em Common/Files/. Init nunca falha. v3.66.0: Settings Sync Guardian — WS push notifica EA de mudancas, EA verifica e aplica (ver specs/settings-sync-guardian.md). v3.75.0: Config Freshness — AgeSec(), IsFresh(), IsStale(), CheckFreshness(). Config >5min sem sync = stale. Correcao doc (G7/G18): em stale o Rollover camada 1 NAO pausa — o EA age sozinho com selfSwap DST-aware (Rollover_Check em Experts/LinniuC.mq5:3171, lógica stale :3186-3190: stale=IsStale() -> operativeSwap=selfSwap) e pula o drift-check contra o site caido. BuildAppliedJSON reporta config_age_sec + config_fresh
PanelDisplay.mqh 440 g_pnl_ Painel Apple Finance Dark. Rounded corners, semi-transparent. Throttle 2s
AutoUpdate.mqh 662 g_au_ States: IDLE->WAITING->DOWNLOADING->VERIFYING->LAUNCHING->DONE. Lock file anti-race. PS1 profile-aware
EventLog.mqh 294 g_el_ / EL_ v3.73.2 (Fase C obs): Buffer per-signal de eventos de observabilidade (struct EL_Event, max 500 com FIFO overflow + WARN). EL_Emit() persiste cada evento em MQL5/Files/signal_events_pending.jsonl (append) — R3 zero-perda em crash. EL_LoadPersistent() em OnInit recupera buffer pos-crash. EL_FlushBulk() em OnTimer envia via POST /api/events (throttle 5s, batch 50, idempotente no servidor via UNIQUE). EL_RewritePersistFile + EL_BuildBulkJson auxiliares. Auto seq_num per-signal via scan linear. Sem call sites de emit ainda — integracao em WebBridge_SendAck/GUIExecution S1-S11/Poll fica em C10-C14. Spec: specs/ea-observability.md
WinInetHTTP.mqh 325 -- Conexao persistente, request por chamada, leitura em chunks 8KB. Sem whitelist MT5
WinHttpWS.mqh 391 -- WebSocket async via DLL (MQL5 tem sockets TCP nativos mas sincrono/bloqueante; DLL da async + WS pronto + sem whitelist). Auth via 1a mensagem JSON. Fallback: HTTP polling sempre ativo
asyncwebsocket.mqh 502 -- DLL wrapper para WS async
asyncwinhttp.mqh 233 -- DLL wrapper para WinHTTP async
Caderneta.mqh ~ Cad_ / g_cad_ v3.154.0+ (Fatia 2 restart resiliente): diario em disco MQL5\Files\linniuc_caderneta_<login>.jsonl — 1 linha JSONL por evento com digito verificador (FNV-1a hex8, Cad_Checksum) e hora de parede UTC (nunca tick-count, que zera no reboot). Eventos: intent_armed (ANTES do clique F9), intent_resolved, idw_arm/disarm, trap_arm/cancel, executed. Cad_Compact no boot reescreve so o vivo. Linha com checksum ruim -> descarta + g_cad_needsFullReconcile. Instrumentacao SO nos call-sites nao-selados (LinniuC.mq5, GUIExecution.mqh). Isolamento de teste via g_cad_fileOverride. Selo S5: Caderneta OBSERVA, nunca altera logica congelada.
BootGate.mqh ~ (puro) + LNC_BootGate* v3.154.2+ (Fatia 2 E3): portao de boot — apos restart, portas FECHADAS pra OPEN novo ate: ler caderneta -> cruzar broker (posicoes + HistorySelect por position_id) -> resolver intents pendurados. intent_armed sem evidencia no broker -> FAILED_UNKNOWN (R1: NUNCA re-executa; politica F6 existente decide). Teto 60s -> modo degradado (CLOSE/MODIFY de posicao existente PODE, OPEN novo NAO). g_bootWatermark (menor intent nao-resolvido -1) enviado como last_signal_id no poll (E4). Heartbeat manda boot_gate/boot_degraded -> servidor alerta crash-loop se boot_gate >3min (E6, health_guard.boot_crashloop). Decisor PURO testavel (TestBootGate.mqh).
ReconcileEngine.mqh ~210 RE_ / g_reconcile_ v3.83.3 (S301): Reconcile retroativo pos-offline. 3 trigger points: OnInit + OnTimer (gate 60s no LinniuC.mq5 + debounce interno 30s) + WS reconnect. RE_FetchOpenTradeLinks (GET /api/ea/open-tradelinks) + RE_ScanHistory (HistorySelect filtra DEAL_ENTRY_OUT por position_id) + RE_EnqueueRetroactive (POST /api/signals com is_retroactive=true). Profit canonico R4: DEAL_PROFIT + DEAL_SWAP + DEAL_COMMISSION. Mutex g_reconcile_running (R10) + checkpoint local (R11). Servidor garante idempotency (R6). NAO substitui _detect_missing_positions — eh fallback adicional. Decisao S302 (specs/decisions/0009-reconcile-architecture-simples-vs-esperto.md): manter gate 60s simples. Tentativa de smart short-circuit (v3.83.4) revertida — ganho irrelevante (~0.6s CPU/min total na pool) em troca de cobertura degradada nos cenarios 4 (OTT missado) e 5 (Snapshot bug). Specs: specs/reconciliacao-pos-offline.md + ADR 0009.

SnapshotEngine — Comportamentos Criticos

Restart: Posicoes existentes viram snapshot inicial. NAO gera falsos OPEN ao reiniciar.

Anti-Echo (Pattern #34):
- SE_SuppressTicket(ticket, TTL): impede re-deteccao de ticket recem-operado
- OPEN: 5s TTL (chamado em LinniuC.mq5 apos GUI execute com sucesso)
- MODIFY/CLOSE: 10s TTL (chamado dentro do SnapshotEngine)

SettingsManager — Invert:
- ONLINE: Invert eh informacional. Servidor ja entrega sinais invertidos
- OFFLINE/LOCAL: EA faz inversao localmente via ApplyInversion
- SyncFromServer: So sobrescreve campo se servidor envia valor nao-nulo

OTT Fast Close (v3.39.0+)

Quando um deal fecha (SL/TP/STOP_OUT/MANUAL), o EA detecta no callback OnTradeTransaction e envia CLOSE direto via WebSocket — sem esperar o polling do SnapshotEngine (3s).

Aspecto Detalhe
Latencia 10-50ms (vs 3s via SE polling)
Payload deal_price (fill exato) + deal_profit (DEAL_PROFIT + COMMISSION + SWAP)
Fallback Se WS fail → SnapshotEngine envia via HTTP normalmente
Guard SE_MarkHintSent(posId) evita duplicata na fila SE
Close reasons SL, TP, STOP_OUT, MANUAL

Heartbeat Server-Side Tracking (S314.3, Bug 3 fix Opcao C)

EA manda HB cada 10s (WS) ou 30s (HTTP fallback). Servidor tem 2 lugares pra rastrear:

Lugar Campo Throttle Lag Uso
accounts.last_heartbeat_at (NOVO S314.3) UPDATED em CADA HB NENHUM <=10s API canonica is_account_alive(), EA Tester badge
heartbeats table (legacy) INSERT row + created_at WS_HB_DB_INTERVAL=30s (R2) ate 30s Historico, balance/equity audit, callsites legacy (lazy migration)

Helper canonico (server/account_filters.py:is_account_alive):

from app.account_filters import is_account_alive
if not is_account_alive(account, threshold_sec=30):
    return jsonify({"error": "EA offline"}), 503

NULL-safe: retorna False se last_heartbeat_at IS NULL (conta nova/pre-migration).

Adopcao lazy — 6 callsites legacy (ea_update.py:529, monitoring.py:64, daily_report.py,
settings.py, heartbeat.py:1184, accounts.py:565) continuam usando
heartbeats.created_at. Cada PR que tocar arquivo migra pro helper. (S320: tournament.py removido.)

Spec completo: heartbeat-last-seen.md.

Pulso leve vs Censo pesado (S386 Fase 2)

A partir de S386, o batimento (WS_SendHeartbeat em Include/CopyTrade/WinHttpWS.mqh) virou pulso leve — enriquecido com campos novos pra observabilidade da saturacao SEM tocar caminhos pesados:

Campo De onde vem Custo
balance, equity, positions_count cache do OnTradeTransaction (ja calculados) zero
queue_depth SnapshotEngine_QueueCount() O(1)
oldest_queued_age_ms SnapshotEngine_OldestQueuedAgeMs() — MAX de now - enqueuedTick per-item, GetTickCount monotonico O(N) na fila ate teto pequeno
busy, busy_op, busy_ticket globais em GUIExecution.mqh (g_gui_busyOp, g_gui_busyTicket, g_gui_busySince), setados pelo beacon. NOTA: o pulso transmite apenas busy/busy_op/busy_ticket; o servidor calcula busy_since server-side via set_busy() no momento que recebe o beacon (nao depende do pulso transmitir). zero
last_gui_ms GUIExecution.mqh::GUIExecution_LastGuiMs() (retorna g_gui_lastAnyMs — duracao da ULTIMA op de GUI, qualquer tipo) zero
ea_version, timer_tick constantes/contador zero

Invariante R1 (S386): WS_SendHeartbeat NUNCA chama HistorySelect* nem GUI_*. Censo pesado (WB_BuildPositionsJSON) fica no ciclo separado de ~30s, inalterado.

Beacon ea_busy (S386 Fase 2)

GUIExecution.mqh em OPEN/MODIFY/CLOSE dispara WS_SendBusyBeacon(op, ticket, expected_ms) IMEDIATAMENTE APOS pegar o lock de GUI (GUI_AcquireGUILock), antes do trabalho lento de tela. Se o lock falhar, o beacon NAO eh enviado. Frame WS {"type":"ea_busy",...}, dispara-e-esquece (timeout <=200ms, sem retry, sem ACK queue). Globais g_gui_busyOp + g_gui_busyTicket + g_gui_busySince setados sincronos pro pulso reportar status atual. Limpeza ocorre via GUI_ClearBusy() (em GUIExecution.mqh:204, chamado pelo GUI_ReleaseGUILock em :694) ao final de cada op. O servidor reflete o estado via pulso quando g_gui_busyOp="". Em paralelo, o ACK transporta gui_duration_ms (montado em Include/CopyTrade/WebBridge.mqh:1249) pra calcular latencia.

Spec completo: saturacao-visibilidade-gui-S386.md + signal-lifecycle.md secao "Estado Afogado".

MODIFY Readback Validation (v3.48.0+)

Apos GUIExecution_Modify retornar sucesso, o EA le de volta os valores do broker e so envia FILLED se realmente aplicou:

GUI Input Strategy (v3.49.0+)

O EA usa 3 estrategias em cascata pra preencher campos do dialog F9:

Prioridade Metodo Como funciona Quando falha
1 (primario) GUI_TypeText (WM_CHAR) Envia caractere por caractere — unico que atualiza o valor INTERNO do MT5 Terminal extremamente lento
2 (fallback) SlowType Mesmo que #1 mas com 20ms delay por caractere Raro
3 (ultimo) WM_SETTEXT Muda display mas NAO valor interno — broker pode rejeitar Campo "validado" pelo MT5

Sync Barrier (WaitForEditValue):
1. SendMessageW(WM_NULL) — flush da fila de mensagens do OS (barreira de sincronizacao)
2. Check imediato — funciona 95%+ das vezes (sem polling)
3. Fallback poll 500ms — captura edge cases (MT5 reformatando valor)

Descoberta (S124): WM_SETTEXT sozinho causa "Invalid Volume" — MT5 precisa de WM_CHAR real pra validar.

Volume/SL/TP Safety Guards (v3.32.0+)

Guard Threshold Acao se violar
Volume Guard \|actual - expected\| / expected > 10% ACK = FAILED, servidor fecha ambos lados (delay 61-90s)
SL/TP Readback Tolerancia dinamica por simbolo ACK = FAILED com diagnostico

Remote Logging — LNC_LogRemote (v3.34.0+)

Buffer circular das ultimas 15 linhas de log significativas. Enviado no heartbeat campo last_logs.

Exemplos:

"OPEN OK #123456 XAUUSD BUY vol=0.10 250ms [S1-S10]"
"MODIFY READBACK FAIL #123456 SL=2049.5(tgt=2048.5)"
"OTT Deal ENTRY_OUT SL posId=123456 P&L=150.50 reason=SL"
"EQUITY FLOOR BREACHED: 89850.00"

Usado pra diagnostico remoto sem acesso ao terminal MT5.

GUI-TRACE Steps (v3.34.0+)

Cada passo da execucao GUI eh logado com timing:

Step Acao Timing tipico
S1 CloseAllDialogs 5ms
S2 F9 sent 1ms
S3 Dialog appears (WaitForDialog) 50-250ms
S4 SelectSymbol 50-150ms
S5 SubDialog visible 10-50ms
S6 Controls found (hVol, hSL, hTP) 5ms
S6b QuickFlush vol 10ms
S7 Volume set (TypeText) 100-200ms
S8 SL set 100-200ms
S9 TP set 100-200ms
S10 VOL validation (final check) 5ms

Trace acumulado em g_gui_traceLog (pipe-separated), enviado nos remote logs.

Buffer SL/TP (EA-side)

Servidor envia SL/TP raw (precos absolutos). EA aplica buffer localmente:

Input Default Descricao
InpSLBuf_Metals 100 pips ($1.00 XAUUSD) Buffer SL metais
InpTPBuf_Metals 0 Buffer TP metais (sem buffer)
InpSLBuf_Forex 0 Buffer SL forex
InpTPBuf_Forex 0 Buffer TP forex
InpSLBuf_Default 0 Buffer outros
InpBufferMode PIPS PIPS (pontos fixos) ou PRICE (absoluto)

Logica: Buffer adicionado ao SL (margem extra contra slippage). TP geralmente sem buffer.

Equity Floor Circuit Breaker (v3.65.0)

Aspecto Detalhe
Input InpEquityFloor (double, 0=desabilitado, ex: 90000)
Check OnTimer + PollAndExecuteSignals
Trigger AccountInfoDouble(ACCOUNT_EQUITY) < equity_floor
Efeito g_floorBreached = true → EA para TUDO (OPEN/MODIFY/CLOSE rejeitados)
Recovery NAO tem — one-way (feature de seguranca, nao bug)
Complementa Server-side equity floor check (redundancia)

Rollover Dual-Layer — Camada 1 EA (v3.72.0)

Aspecto Detalhe
Inputs InpRolloverEnabled (bool), InpRolloverSwapHourUTC (int, default 22), InpRolloverMinBefore (60), InpRolloverMaxBefore (30), InpRolloverClockDriftMax (60 seg)
Check Rollover_Check() chamado no OnTimer apos EquityFloor_Check()
Janela ativa [swap - min_before, swap - max_before] — default [21:00, 21:30] UTC
Sorteio DJB2 hash (account_login + ticket + UTC_date) % window_sec via Rollover_ComputeOffsetSec() — deterministico, sem persistencia. Mesma posicao mesmo dia = mesmo horario
Execucao Rollover_TryClose() usa GUI Win32 (F9) — mesmo padrao de EquityFloor_Check
Retry Rollover_ScheduleRetry() + Rollover_ProcessRetries() — jitter 15s/30s/45s, max 3 tentativas
Deduplicacao Rollover_AlreadyDone() + Rollover_MarkDone() via g_rolloverDoneTickets[] — evita reabrir dialog no mesmo ticket
Clock drift Se |TimeGMT() - rollover_server_time| > drift_max: desabilita camada 1 + WS rollover_drift — servidor cobre em T-30 (camada 2)
Eventos WS rollover_close_ok, rollover_close_failed (apos 3 fails), rollover_drift
Fallback Servidor (check_rollover_fallback em server/af/signals.py) acorda em T-30 se pair ainda executing + envia Telegram CRITICAL
SSoT specs/rollover-dupla-camada.md (desenho), specs/af-rules-whitepaper.md §E9 (regra)

Anti-pattern nota: sorteio eh POR POSICAO (nao por par/pool) — evita 5 fechamentos correlatos que prop firm identifica como carimbo de robo.

Compilacao

Compilar: bash compile.sh [LinniuC|RandomTrader|RiskCalculator]

Deploy completo: Usar bash deploy_ea.sh (NUNCA copiar .ex5 manualmente). O script: compila, copia .ex5 Terminal→Dropbox, sincroniza pra VPS.

compile.sh: Detecta worktree via git rev-parse (S301), define DBX apropriado, sincroniza .mqh do $DBX/Include/CopyTrade/ -> $MQL5/Include/CopyTrade/ antes de compilar, chama MetaEditor CLI, copia .ex5 de volta pro $DBX.

Terminal MT5: C:\Users\mrodr\AppData\Roaming\MetaQuotes\Terminal\53785E099C927DB68A545C249CDBCE06\MQL5
Include/CopyTrade: Pasta REAL (S301+) — antes era junction apontando pro Dropbox/main, mas isso vazava arquivos da worktree pro main local quando alguem editava .mqh fora do main. Agora cada bash compile.sh sincroniza explicitamente do $DBX (worktree-aware) pro Terminal.

Dois deploys, dois universos

O sistema tem dois fluxos de deploy distintos com riscos diferentes:

deploy-canonico.sh (servidor) deploy_ea.sh (EA)
O que sobe Codigo Python do servidor Binario .ex5 do EA
Origem do codigo Commits do git (git push) Arquivo .mq5 no disco (working dir)
Pra onde vai VPS (FastAPI restart automatico) VPS + auto-update na pool de N EAs
Automatico? SIM, via post-commit hook a cada commit NAO, precisa rodar manualmente
Precisa guard contra dirty? NAO — git push so envia commits, dirty no working dir fica pra tras SIM — deploy_ea.sh compila o .mq5 no disco, dirty vira binario na pool

Por que a assimetria eh deliberada:
- Compilar EA exige MetaEditor (Windows GUI), nao roda em CI
- Pool baixa .ex5 em coordenacao — erro propaga pra todas as N contas de uma vez
- Quality gate proposital: passo manual forca validacao no sandbox antes de propagar
- Server restart eh trivial (segundos), reverter eh facil; EA deploy mexe com ordens reais em 12 mesas prop

Implicacao operacional: depois de git commit no main, o servidor ja foi atualizado em background. Mas o EA so atualiza quando voce explicitamente roda bash deploy_ea.sh (com guard que verifica commit limpo + avisa se rodando de worktree).

Workflow Worktree (S301+)

Quando trabalhar numa worktree paralela (criada via Claude Desktop ou bash scripts/paralelo.sh create <nome>):

Passo Comando Efeito
1. Editar .mq5/.mqh direto na worktree edits ficam isolados na worktree
2. Compilar bash compile.sh (de dentro da worktree) detecta worktree, sincroniza .mqh da worktree -> Terminal MT5, compila
3. Testar restart EA no MT5 EA roda codigo da worktree, isolado do main
4. Deploy pra VPS bash deploy_ea.sh (de dentro da worktree) guard recusa se ha mudancas nao-commitadas; senao compila + SCP + EaVersion + auto-update na pool
5. Commit git commit na worktree fica em claude/<nome> ou paralelo/<nome>
6. Merge pro main (OBRIGATORIO) git merge claude/<nome> no main local post-commit auto-empurra pra VPS

Risco se pular passo 6: pool roda binario da worktree, mas codigo fonte fica SO na branch. Proximo bash deploy_ea.sh rodado do main compila codigo DIFERENTE/ANTIGO -> regressao silenciosa na pool.

Mitigacao S301++ (commit 125da3f): deploy_ea.sh agora tem guard:
- Aborta se ha mudancas tracked nao-commitadas (forca commit antes de deploy)
- Avisa (nao bloqueia) se rodar de worktree, lembrando do merge pos-validacao
- Override emergencia: DEPLOY_DIRTY_OK=1 bash deploy_ea.sh

Versionamento

Regra: SEMPRE incrementar EA_BUILD ao modificar EA ou qualquer .mqh. Formato: MAJOR.MINOR.PATCH.
CUIDADO: "3.9" > "3.10" em string compare! Sempre usar 2+ digitos: "3.09"
Verificar versao: Log inicializacao | Heartbeat ea_version | Dashboard aba Contas | API GET /accounts

Mixed versions no mesmo grupo: Heartbeat intervals diferentes (WS vs polling), bugs corrigidos em versoes diferentes, ghost WS connections. Recomendacao: Manter TODAS as contas do mesmo grupo na MESMA versao.

Auto-Update v3 Safety

CRITICO: Auto-update NUNCA deve rodar durante trade ativo — EA verifica g_se_hasOpenPositions.

WS Reconnect (v3.77.0+, S253):
- Backoff exponencial: 3s → 6s → 12s → 24s → 48s → 60s (cap 1min). Antes v3.76-: 10-300s (cap 5min) — WS raramente vencia HTTP em reconnect
- Jitter por conta: account_num % 15 (0-14s). Antes v3.76-: % 7 (0-6s). Spread maior anti-storm com 13 EAs simultaneos
- Flush parcial: _flush_pending_signals usa continue em erro (S253) — antes break deixava pendings 4o/5o orfaos ate proximo reconnect
- Health check 60s: se connected=true mas sem msgs -> reset
- Fallback: 3x WS fail -> HTTP polling (2-5s adaptive). WS retenta cada 120s
- Constantes em WinHttpWS.mqh: WS_RECONNECT_BASE_SEC=3, WS_RECONNECT_MAX_SEC=60, WS_RECONNECT_JITTER_MAX_SEC=15

Retomada apos reconexao (server-side, ea_ws.py):
- _flush_pending_signals (linha 600): ao EA reconectar + autenticar, servidor busca PendingSignal da conta com resolved=False e expires_at nao vencido -> re-envia todos via WS. Log [WS-FLUSH] Sent N pending signals to account X. TTL = PENDING_SIGNAL_TTL_SECONDS=60s (sinal velho = pending_expired)
- _detect_missing_positions (linha 647): compara posicoes reportadas no heartbeat vs TradeLink is_closed=False. Ticket sumiu -> cria CLOSE signals pros pares (sibling accounts) automaticamente. Grace 30s pos-abertura pra evitar falso positivo
- Ghost connection (ea_ws.py:59-66): EA reconecta -> servidor fecha WS antiga com code 1000 reason "replaced". Evita 2 WS abertas mesma conta disputando sinais
- Auth: 2 modos — query params ou 1a mensagem JSON {"type":"auth"}. Timeout 10s. Version gate MIN_WS_VERSION=3.0.4
- Flush ACK (v3.76.0+, "tick cinza"): EA envia {"type":"flush_ack","signal_id":N} imediatamente ao receber signal via WS (ANTES de executar). Servidor handler _handle_ws_flush_ack marca pending_signals.ws_received_at = now. Separado de resolved=True (tick azul = ACK de execucao). Permite distinguir "nao chegou" de "chegou mas nao executou". EAs <3.76.0 nao emitem flush_ack — coluna fica NULL e polling HTTP continua sendo rede de seguranca

Erros MT5 Comuns

Codigo Nome Causa Fix
10004 REQUOTE Preco mudou Retry com slippage maior
10006 REQUEST_REJECTED Broker rejeitou Checar restricoes da conta
10014 INVALID_VOLUME Lote invalido Checar min/max/step do simbolo
10016 INVALID_STOPS SL/TP invalido Distancia minima, checar invert
10017 TRADE_DISABLED Trading off Horario, permissoes, AlgoTrading
10018 MARKET_CLOSED Mercado fechado Esperar abertura
10019 NOT_ENOUGH_MONEY Sem margem Balance/leverage insuficiente

"EA nao faz nada": Checar aba Experts no MT5. Se nenhum log -> EA pode estar em loop ou OnTimer nao esta sendo chamado.

Regras MQL5 (OBRIGATORIAS)

PROIBIDO: OrderSend / CTrade (REGRA ABSOLUTA)

Prop firms detectam ordens de EA (magic number, filling flags, deal comment). GUI automation cria ordens que parecem MANUAIS (F9 dialog).

Descoberta (S73): EA usava OrderSend desde v3.12.0 disfarçado como "fill mode fix" sem usuario saber.

ACK Format Completo

Campo OPEN MODIFY CLOSE Descricao
status X X X FILLED, FAILED, SKIPPED
ticket X X X Ticket local do broker
error_msg X X X Mensagem se FAILED
open_price X X Preco de abertura/modificacao
sl X X SL aplicado
tp X X TP aplicado
volume X Volume executado
channel X X X ws_push ou http_poll
latency_ms X X X Tempo de execucao GUI (ms)
deal_price X Preco de fill exato do deal
profit X DEAL_PROFIT + COMMISSION + SWAP

OrderCalcProfit Fallback

Quando OrderCalcProfit() falha (XAUUSD durante rollover, exoticos), EA usa formula manual:
profit = (price_diff) * volume * pip_value. Garante que AF tem dados pra calculo de risco mesmo em periodos com bugs do broker.

API Key via arquivo (v3.25.1+)

EA le key de MQL5/Files/copytrade_key.txt no OnInit. Prioridade: FILE (se len >= 10) > INPUT. Whitespace trimmed.

Build Version History

Versao Feature principal
3.25.0 WS Reconnect backoff (5s→15s→30s)
3.29.3 OnTradeTransaction como primario pra ticket detection
3.32.0 Volume/SL/TP safety guards (abort on mismatch)
3.34.0 GUI-TRACE 50 logs + LNC_LogRemote buffer
3.39.0 OTT Fast Close via WS
3.44.0 Sync barrier (WM_NULL) em SetEditText
3.49.0 TypeText (WM_CHAR) como primario pra GUI input
3.48.0 MODIFY diagnostic (g_gui_modifyDiag) + readback validation
3.58.0 Buffer SL no EA only, servidor envia SL/TP raw
3.65.0 Equity Floor Circuit Breaker
3.72.0 Rollover Dual-Layer — camada 1 EA primary (TODO #152)
3.76.0 WS flush_ack "tick cinza" (S251/S252) — EA confirma recepcao via WS
3.77.0 WS backoff tuning (S253 Onda 1): 10-300s → 3-60s, jitter 7→15, break→continue
3.78.0 WS resume semantics (S253 Onda 2): EA envia last_signal_id no auth, servidor filtra flush. _detect_missing_positions com paridade HTTP/WS + advisory lock
3.79.0 Onda 3: zombie detector (g_timerTickCount no HB + alerta Telegram) + ACK queue 20→100 + dump persistente em disco

Armadilhas


Servidor

Status: ACTIVE | Ultima revisao: S221 (2026-04-13) — drift fixes: --host 127.0.0.1 (nao 0.0.0.0), PG 16 (nao 15), js_error_reporter movido pra endpoints [allow-spec] | Drift S294 (2026-04-25): apos S288-S289 main.py absorveu 3 features novas (Market Hours Guard Camadas 1/4 — ver specs/market-hours-guard.md, Correlation ID end-to-end HTTP middleware, Exception Alerter Telegram com dedup). telegram_alerts.py absorveu Telegram dry-run + AfAuditLog (sim-e2e-real F2). Stack/nginx/systemd/PG/cron/rollback inalterados. Cron VPS atual (S293 D''): backup_daily 03h, healthcheck_v2 /2, monitor /5, error-monitor /30 (sem LLM, S293), build-context, quality_monitor, audit-export. daily-audit + weekly-research REMOVIDOS S293. Drift S354 (2026-05-19): post-receive agora sincroniza scripts/ (causa-raiz drift Abr-25 — crons server-path rodavam código congelado); 7 crons versionados ganharam liveness marker (trap EXIT/var/log/copytrade-cron-*-last-success.txt) que torna E23 executável. Cron VPS atual = 16 jobs (S433: +ea_rollover reinstalado, -watchdog.py aposentado; ver seção Cron + índice único em malha-de-saude.md). Cache-bust cap 3 (2026-07-07): o passo 0/2 do deploy-canonico.sh (re-taguear ?v= dos assets pra furar cache do browser) generalizou de "só index.html" pra por-container*: agora varre TODO arquivo com refs ?v= — inclui static/js/index-inline.js, que faz _loadScript('/static/js/XXX.js?v=gHASH') LAZY com o ?v= hardcoded (ex: ea_tester.js, simulacao_e2e.js, sim_e2e_battery.js, saude.js). Antes esses lazy NUNCA eram bumpados → deploy servia JS velho até bump manual (mordeu 2x na frente do EA Tester). Ordem: inline primeiro (avança o próprio hash), index.html depois. Motor: fn cachebust_container() em scripts/deploy-canonico.sh. [allow-spec]

SSoT para: Stack VPS, deploy, nginx, systemd, backup, rollback, Telegram, PostgreSQL
Consolida: memory/server-infra.md + .claude/knowledge/copytrade-ops.md + .claude/knowledge/copytrade-server.md + .claude/knowledge/copytrade-rollback.md

Stack

Internet -> Cloudflare (CDN/WAF) -> nginx (SSL linniuc.com) -> uvicorn 127.0.0.1:8000 (1 worker) -> FastAPI
                                                                                            |
                                                                                       PostgreSQL 16

Acesso

Systemd Service

[Service]
User=copytrade
WorkingDirectory=/opt/copytrade-server
ExecStart=/opt/copytrade-server/venv/bin/gunicorn app.main:app \
  --bind 127.0.0.1:8000 --workers 1 \
  --worker-class uvicorn.workers.UvicornWorker \
  --timeout 120 --graceful-timeout 30 --keep-alive 5 \
  --access-logfile - --error-logfile - --log-level info
Restart=always | RestartSec=5

1 worker (single-process). Restart limit: 5 em 10s, depois "failed".

Drift fix S373 (2026-05-23): ExecStart real usa gunicorn (gerenciador de processos que supervisiona o worker — reinicia no crash, mata se travar >120s) com worker uvicorn (UvicornWorker), nao uvicorn direto como dizia antes. 1 worker, bind so em localhost. Verificado via systemctl cat copytrade. [allow-spec]

systemctl restart copytrade        # restart
systemctl reset-failed copytrade   # limpar failed
journalctl -u copytrade -f         # logs real-time
journalctl -u copytrade --since '5m ago' --no-pager  # ultimos 5min

Nginx

Config: /etc/nginx/sites-enabled/copytrade. HTTP->HTTPS redirect. www->non-www redirect.
SSL: /etc/ssl/linniuc.com.pem + .key. Certbot auto-renew.
WS: proxy_http_version 1.1, upgrade headers, timeout 600s.

Erro Causa Fix
502 Uvicorn down systemctl restart copytrade
504 Request >60s Query lenta ou endpoint travado
nginx -t && systemctl reload nginx  # testar + reload sem downtime
certbot certificates                # validade SSL

Background Tasks (main.py lifespan)

Task Intervalo Funcao
offline_checker 30s Detecta EAs offline >90s, Telegram alert
drawdown_checker 5min Alerta drawdown por tipo conta
daily_report 23:55 UTC Relatorio diario Telegram
periodic_cleanup 15min Limpa heartbeats antigos, reconcilia trade_links orfaos, resolve pending expirados
auto_close_pendings 15s Auto-close stale pending signals (sinais nao consumidos)
initial_cleanup 30s apos start Resolve pending expirados na inicializacao
tv_price_ws continuo WebSocket TradingView precos (watchdog 45s)
telegram_bot continuo Polling comandos Telegram
af_scheduler continuo Orquestracao AutoFund — plano diario, execucao de pares
af_scheduler Block 7 ~5s (dentro do scheduler) Swap close checker — fecha pares antes do rollover
af_scheduler Block 8 ~5s (dentro do scheduler) MODIFY fallback — re-triggers stale modify_scheduled >3min (S154)
af_scheduler Block 9 ~5s (dentro do scheduler) Alert latch timeout — publica msg Telegram parcial se 2o ACK nao chegou em 10s (S258)
af_scheduler DST check ~1h (dentro do scheduler) Auto-adjust swap_hour_utc: 21 (verao) / 22 (inverno) (S154)
af_daily_report 22:00 UTC Relatorio AF diario via Telegram

Endpoints auxiliares (nao background task):
- POST /api/js-error (main.py:835-854) — erros JS do dashboard vao pro Telegram (max 50/uptime via contador _JS_ERROR_MAX, dedup). Nao eh background task; eh endpoint HTTP normal chamado pelo window.onerror do dashboard.

Anti-spam Telegram (S389): o agrupador de rajada (burst aggregator em telegram_alerts.py) agora age SO em erros/riscos — alertas de status (EA online/offline/atualizado/rollout/restart/login ok/reconcile ok) sempre chegam individuais. Erros comprimidos sao resgataveis via comando /erros no bot. SSoT: specs/telegram-antispam-so-erros.md.

Cron

Índice único de TODOS os auditores/vigias (cron + in-server + Telegram + quem-vigia-quem):
specs/malha-de-saude.md (SSoT da malha de saúde). A lista abaixo é o crontab; a semântica de
auditoria (o que checa, sobreposições, drifts) vive lá.

0 3 * * *    backup_daily.sh           # Backup diario 3h UTC (VPS-only)
*/2 * * * *  scripts/vps/healthcheck_v2.sh --watch  # Vigia UNIFICADO modo leve: restart conservador (S407 #409)
*/5 * * * *  scripts/vps/healthcheck_v2.sh --full   # Vigia UNIFICADO modo cheio: exames de banco + JSON + Telegram dedup (S407 #409)
# monitor.sh APOSENTADO (S407 #409): fundido no healthcheck_v2 --full. Vigia mutuo: server vigia frescor do JSON (schedule_watchdog_freshness_check).
0 * * * *    build-context.sh          # Contexto pre-compilado do bot
*/30 * * * * error-monitor.sh          # Erros journalctl -> Telegram. Julga por TEXTO, nao por prioridade (2026-08-01: filtrava -p err/warning e via ZERO — o journald estampa o stream do gunicorn como info). Detalhe: malha-de-saude.md
*/15 * * * * quality_monitor.py        # Quality monitor unificado
# watchdog.py APOSENTADO S433 — dobrado no vigia-mutuo in-server (health_checks.py, 5min)
30 3 * * *   cron/purge_signal_events.sh   # Retention signal_events 90d
0 * * * *    cron/health_audit.sh          # Traffic por endpoint -> alerta
0 4 * * *    cleanup_login_attempts.sh     # Purga login_attempts >30d
45 3 * * *   cron/audit-export.sh          # Export audit .csv.gz (6 anos)
30 4 * * *   cron/hunter_nightly.sh        # Caca-Bugs pente fino 12 cacadores -> Telegram so se quebrar
0 12 * * *   vps/drift-detect.sh           # Detecta edicao direta VPS
0 10 * * 0   spec-freshness-alert.sh       # Freshness specs (VPS-only)

Fonte da verdade do crontab (S417): scripts/vps/crontab.canonical (versionado) +
scripts/vps/install-crontab.sh (instalador idempotente, roda na VPS). NÃO editar o crontab na mão
— foi a edição VPS-only que fez o error-monitor sumir sem rastro. Mudou cron? Edita o .canonical,
push, e roda o instalador.

Deploy de scripts/ (S354): post-receive sincroniza scripts/
/opt/copytrade-server/scripts/ (rsync SEM --delete — preserva VPS-only
como spec-freshness-alert.sh). Antes (até Abr-25) só server/static/docs
eram rsync'd → crons server-path rodavam código congelado. Crons chamados de
/opt/copytrade-code/scripts/ (build-context, error-monitor, drift-detect)
sempre foram frescos (checkout direto do hook).

Liveness markers E23 (#257): os 9 crons versionados gravam epoch UTC em
/var/log/copytrade-cron-<name>-last-success.txt via trap EXIT (só em exit
0). GET /api/health/cron-status lê isso → estação E23 da bateria EA Tester
sai de WARN permanente (available=false) para executável.

Alerta proativo de cron stale (#321, S378+): antes, a estação E23 só pegava
um cron caído manualmente (rodar a bateria) — drift-detect ficou DOWN 6 dias
sem ninguém ver. Agora uma task de fundo no próprio servidor (schedule_cron_stale_check
em server/routes/health_checks.py, disparada no lifespan do main.py) checa
o cron-status a cada CRON_STALE_CHECK_INTERVAL_SEC (6h) e manda Telegram por
cron stale, com dedup por nome via send_telegram_alert(category="cron_stale", dedup_key=<nome>, cooldown=24h) (≤1 alerta/cron/dia). Decisão de design: mora
no servidor (sempre-vivo), NÃO num cron — um cron vigiando crons teria o MESMO
ponto único de falha que isto quer eliminar ("quem vigia o vigia"); se o servidor
cair, tudo já alerta. Função pura testável stale_crons_for_alert(status, expected=None)
(filtra stale=True E dentro da allowlist; available=false[], degradação graciosa).
Limitação conhecida: um cron que nunca gravou marcador é invisível (sem marcador = fora do
mapa); pega o caso real "rodou antes, parou agora" (marcador envelhece > 24h).

S417 — 3 endurecimentos (após o error-monitor cair do crontab e o alerta repetir a cada deploy):
- PROB-A (cron caiu sem rastro): o crontab agora tem fonte da verdade versionada em
scripts/vps/crontab.canonical + instalador idempotente scripts/vps/install-crontab.sh.
Editar crontab na mão é o anti-pattern que fez o error-monitor sumir (VPS-only, sem rastro).
- PROB-B (órfão alarma pra sempre): o caminho de alerta (stale_crons_for_alert) filtra
por _CRON_EXPECTED_DEFAULT (9 crons que escrevem marcador). Marcador órfão de cron
decomissionado segue visível no painel E23 (_read_cron_status NÃO filtra — diagnóstico) mas
nunca dispara Telegram. env CRON_EXPECTED (csv) sobrescreve; vazia → usa a constante (não
fail-open total). Stale fora da allowlist → logger.info, nunca Telegram.
- PROB-C (re-spam no restart): dedup persistido em disco (CRON_STALE_DEDUP_PATH, default
ao lado do monitor_status.json em /opt/copytrade-server/), map nome→dia-UTC. Antes era só
in-memory: cada deploy zerava e re-alertava 180s depois. Agora ≤1 alerta/cron/dia mesmo com N
deploys. Fail-open: erro de IO → alerta (nunca silencia).

Deploy Checklist (OBRIGATORIO)

Pre-deploy:
1. Syntax check: python -c "import ast; ast.parse(open('file.py').read())"
2. scp arquivo [email protected]:/opt/copytrade-server/app/
3. Import test: ssh root@... 'cd /opt/copytrade-server && python -c "from app.modulo import funcao"'

Deploy:
4. Upload TODOS os arquivos ANTES de restart (deploy atomico)
5. ssh root@... 'systemctl restart copytrade'

Post-deploy:
6. systemctl is-active copytrade -> "active"
7. journalctl -u copytrade --since '30s ago' -> sem erros
8. curl -s https://linniuc.com/api/health -> {"status":"ok"}
9. EAs reconectaram? (ct_status)

REGRA: Restart causa ~2-3s downtime com 4-6 HTTP 502 nos heartbeats. ESPERADO.

Horarios a EVITAR: 23:50-00:05 (daily report), Sun 22:00 UTC (abertura mercado), durante trade ativo.

Quanto o deploy demora, e POR QUE — MEDIDO em 2026-08-21

A resposta curta: ~70s do disparo ao "concluido", e a maior fatia (53s) e a bateria
bloqueante pytest -m trap_gate, que roda contra staging ANTES de tocar o runtime.

Esta secao existe porque a pergunta "da pra deixar o deploy mais rapido?" ja foi feita e
respondida com medicao — sem ela, a proxima pessoa remede tudo ou, pior, remove a bateria
achando que sao 50s de gordura. Nao sao.

Onde vao os 53s da bateria

pedaco tempo % o que acontece
preparo 26,6s 53% _ensure_sqlite_schema (autouse em server/tests/conftest.py) roda create_all ANTES DE CADA uma das 353 provas
coleta 14,3s 28% o pytest IMPORTA os 806 arquivos de server/tests/ antes de filtrar pela marca
execucao real 7,1s 14% o trabalho que voce quer pagar
limpeza 5,1s 10% teardown

Para cada 1 segundo testando, gastam-se ~6 preparando e escolhendo. E os dois maiores
NAO sao desperdicio:

A otimizacao obvia foi TESTADA e REPROVADA

"Aponte os 50 arquivos em vez da pasta inteira" — parece resolver os 14,3s. Medido:

forma tempo provas
pasta inteira (como e' hoje) 54s 433
apontando os arquivos 68s (26% PIOR) 422

E perde 2 arquivos, porque a bateria tem DUAS portas de entrada, nao uma:

porta como entra arquivos
por NOME casa um dos 15 fragmentos de _TRAP_GATE_SUBSTR (server/tests/conftest.py) 50
por MARCA explicita o arquivo declara pytestmark = pytest.mark.trap_gate 2

Total: 52 arquivos. Qualquer lista montada lendo NOMES e' cega pra segunda porta — e a
cegueira e' silenciosa (a bateria roda, fica verde, e 2 arquivos ficaram de fora). Ironia
registrada: um dos dois e' test_g3_post_receive_trap_enforcement.py, que vigia se a
bateria esta mesmo sendo chamada no deploy, e se marcou de proposito pra se proteger.

O que a bateria cobre — e por que o cacador noturno NAO substitui

Os 52 arquivos sao de UM tema so': posicao aberta que pode ficar largada (armadilha 21,
perna orfa 13, solo/ronda/persist 8, corridas/forcador/vigia 8+2). O criterio da lista
curada e' "tema sem segunda rede".

Os 12 cacadores do hunter_nightly.sh (cron 04:30 UTC) nao cobrem armadilha nem perna
orfa
— sao limbo, modify, dd_breach, dd_recog, invert, rollover, pairing, rsafe, carteiro, conferencia, zerosum, faxina. Remover a bateria deixaria 34 dos 52 sem rede
automatica nenhuma
. E os metodos sao diferentes: o cacador varre combinacoes num banco
descartavel; a bateria roda os testes contra o staging, com o codigo que vai publicar.

Decisoes do dono (2026-08-21) — nao reabrir sem pedido dele

  1. Os 53s FICAM. Sao infraestrutura de seguranca, nao gordura.
  2. Tema novo nao entra na lista curada so' por ser importante. O anti-swap foi avaliado
    e ficou de fora: ele tem DUAS redes (o robo fecha, o servidor cobre), e o criterio e'
    "sem segunda rede".

A unica hipotese com potencial real, NAO testada

Criar a estrutura do banco uma vez por arquivo em vez de por prova (potencial ~20s).
Descartada pelo RISCO, nao por medicao — mexe exatamente na peca que impede falha falsa.
Se um dia os 53s incomodarem de verdade, e' ali que se deve olhar.

Recontar (obrigatorio antes de citar qualquer numero acima)

⚠️ Os numeros VARIAM ~5% entre execucoes — e' medicao de relogio numa maquina com outras
conversas rodando, nao constante. Segunda passada conferida no mesmo dia: preparo 25,94s
(documentado 26,6), execucao 6,75s (7,1), limpeza 4,32s (5,1) — a contagem de arquivos (52)
e as PROPORCOES sao estaveis; os decimais nao.
Se voce recontar e der diferente na 2a casa,
a doutrina nao esta errada: use a SUA medicao e nao reescreva esta secao por causa disso.
Reescreva se a PROPORCAO mudar (ex: execucao real passar de 14% pra 40%) — ai algo mudou de
verdade.

# tempo total + quantas provas
time python -m pytest server/tests/ -m trap_gate -q -p no:randomly

# onde vao os segundos (preparo vs execucao)
python -m pytest server/tests/ -m trap_gate -q -p no:randomly --durations=0 2>&1   | grep -E "^[0-9]+\.[0-9]+s (call|setup|teardown)"   | awk '{gsub("s","",$1); soma[$2]+=$1; n[$2]++} END {for (k in soma) printf "%-9s %7.2fs em %4d
", k, soma[k], n[k]}'

# quantos arquivos a bateria pega HOJE (as duas portas)
python -m pytest server/tests/ -m trap_gate --collect-only -q -p no:randomly 2>/dev/null   | grep "::" | sed 's/::.*//' | sort -u | wc -l

Rollback

Deploy/mudanca + sistema quebrou?
  |
  +-- Servidor 502? -> §1 Rollback servidor
  +-- DB corrompido? -> §2 Restore DB
  +-- EA crasha?     -> §3 Rollback EA (ver specs/ea-architecture.md)
  +-- Config errada? -> §4 Revert config
  +-- Tela branca?   -> §5 Rollback frontend (ver specs/dashboard.md)

§1 Servidor: cp arquivo.py.bak arquivo.py && systemctl restart copytrade. Sem .bak: git show HEAD~1:scripts/server/ARQUIVO.py.

§2 DB: systemctl stop copytrade && psql < BACKUP.sql && systemctl start copytrade. Parcial: pg_restore -t TABELA.

§4 Config: ct_update_account(account_id=ID, field="group_id", value="GRUPO") ou DB direto.

Checklist pos-rollback: Servidor ativo? Logs sem erros? EAs reconectaram? Posicoes intactas? Dashboard funcional?

PostgreSQL

Pool (S361/S406, conferido S-atual): prod 25 fixas + 25 overflow = 50 (DB_POOL_SIZE/DB_MAX_OVERFLOW, database.py:36-37); sim 6 + 4 = 10 (sim_engine bulkhead S406, database.py:74-75); pool_timeout=5s, recycle 30min. Teto: 50+10=60 < ~70 (RAM-seguro 2GB) << 97 (copytrade_user; superuser reserva 3). Escalar além de ~25 contas: PgBouncer ou +RAM, NÃO esticar o teto do PG. (Drift corrigido: dizia "5+10=15", era estimativa antiga.)
pg_stat_statements: Habilitado (PG 16). MCP Postgres Pro disponivel.

psql -U copytrade_user -d copytrade -c "VACUUM ANALYZE;"

Tabelas que crescem: heartbeats (cleanup 6h >24h), signal_acks (monitorar), trade_links (ok se <100k).
Migrations: Sem Alembic. ALTER TABLE manual. SEMPRE backup antes. Deploy codigo DEPOIS do ALTER.

Telegram

2 sistemas (mesmo bot @linniuc_vps_bot):
- telegram_alerts.py — Alertas automaticos (thread sync, non-blocking). Debounce por chave+cooldown
- telegram_bot.py — Bot interativo (asyncio polling). Comandos /s, /p, /help

Anti-spam: _should_send(key, cooldown). Chave ACK: ack_failed_{account_name}_{action} (5min).
Cooldown reseta no restart do servidor.
Severidade: _send(text, level="INFO") — prefix emoji por nivel: CRITICAL 🚨, ERROR ❌, WARNING ⚠️, INFO (sem prefix), DEBUG 🔍.

Portao de saida HTML — OBRIGATORIO (2026-08-04)

Todo envio com parse_mode=HTML passa por _sanitize_html_for_telegram. O Telegram le
< como abertura de etiqueta: um entry diff $8.97 < $14.00 derruba a mensagem INTEIRA com
400 "can't parse entities: Unsupported start tag". Nao e' truncamento — some tudo, calado.

Escape cego nao serve: o servidor usa ~1.490 etiquetas HTML de proposito. O portao e' um
filtro com lista de permissao — preserva o que o Telegram documenta e neutraliza o resto.
Idempotente; preserva &lt; &gt; &amp; &quot; ja escapadas (sem escape duplo) e converte
entidade NUMERICA (&#x27;) pro caractere, que o Telegram nao aceita.

Grupo Etiquetas Atributo
Sem atributo b strong i em u ins s strike del pre tg-spoiler nenhum aceito
Atributo OPCIONAL code (class="language-*") · blockquote (expandable) vale nua
Atributo OBRIGATORIO a (href) · span (class="tg-spoiler") · tg-emoji (emoji-id em 1..int64) nua NAO passa

Conferido contra a fonte (Bot API 10.2 + parser parse_html do tdlib): emoji-id tem
FAIXA, nao so forma — 0 e valores fora do int64 dao 400. tg-time existe e nos filtramos
de proposito (cosmetico; nao emitimos). Bloquear <a> nu e' conservadorismo nosso, nao
protecao — a sem href nao gera 400. Entidade numerica e' aceita pelo Telegram; nos
convertemos pro caractere por outro motivo (html.escape gera &#x27;).

A coluna "obrigatorio" nao e' detalhe: <span> nu virava 400 ("Tag span must have class
tg-spoiler") — o filtro deixava passar 3 familias que CAUSAM o erro que ele existe pra impedir.

Pareamento por pilha (2a passada): etiqueta so sobrevive se o PAR dela sobrevive. Sem isso,
<b class="x">X</b> saia com a abertura escapada e o </b> intacto -> 400 "Unmatched end tag":
o filtro FABRICAVA o erro. Cobre tambem "Unclosed tag". Saida sempre equilibrada.

Caminho Onde entra o filtro
Funil de alertas telegram_alerts.py _do_telegram_post
Bot interativo (respostas a comando) telegram_bot.py _send_message
Relatorio diario AF af_daily_report.py _send_telegram
Relatorio diario geral daily_report.py _send_telegram
Envio manual do dashboard routes/admin.py send_telegram_message

Rede: 400 com "parse entities" no corpo reenvia sem parse_mode
(_reenvia_sem_formatacao + _strip_html_for_plain_text). Aviso feio ainda avisa.
So existe no funil de alertas — nos outros 4 caminhos a garantia e' o filtro sozinho.

Tamanho: cortar_para_telegram corta o texto CRU e filtra depois (o pareamento cuida da
etiqueta que o corte deixou orfa). Cortar DEPOIS partia o <pre> ao meio. Mede em unidades
UTF-16, que e' como o Telegram conta — emoji fora do plano basico vale 1 em len() e 2 pra API.

Formatacao e' do CANAL, nao da mensagem: o sino do site (_mirror_to_dashboard) recebe o
texto SEM etiqueta, porque o dashboard desenha com escapeHtml (static/js/websocket.js:653)
e mostrava <b> literal. Medido: 424 de 3.371 avisos espelhados carregavam etiqueta.

Ao criar caminho NOVO de Telegram: passe pelo filtro. Cron/shell tem remendo proprio
(scripts/vps/_deploy_changelog.py, healthcheck_v2.sh, drift-detect.sh) — foram 3
remendos isolados do mesmo defeito antes de alguem consertar na raiz (licao P805).
Testes: server/tests/test_telegram_html_sanitize.py.

Middlewares

MCP Server (Local)

Claude Code (Win11) --stdio--> copytrade_mcp.py (FastMCP 3.1, Python 3.14) --HTTPS--> linniuc.com/api/*

Auth JWT auto-renovacao. Cache TTL 15s/30s. 15 tools read-only.

Scripts Operacionais

Script Funcao
deploy.sh Deploy seguro: backup -> syntax -> import -> restart -> health -> auto-rollback
healthcheck_v2.sh Watchdog: servico, HTTP, disco, heartbeat age, orphan links
monitor.sh Proativo: failed ACKs, pending, stale, orphans, disco, DB size
backup.sh / backup_daily.sh Backup manual / diario
restore.sh Restaura backup

Account Onboarding

Adicionar: DB insert ou Dashboard Admin > Contas > Adicionar. Instalar EA, configurar key.
Remover: Fechar posicoes -> SET is_active=false -> remover EA do chart. NAO deletar do DB.

Referencia Rapida

Preciso de... Comando
Disco df -h /
RAM free -h
CPU top -bn1 | head -5
Logs journalctl -u copytrade --since '5m ago'
SSL certbot certificates
Nginx nginx -t && systemctl status nginx
Firewall ufw status (portas: 22, 80, 443)
Clock timedatectl status
DB size SELECT pg_size_pretty(pg_database_size('copytrade'));

Armadilhas


Dashboard

Status: ACTIVE | Ultima revisao: S233 (2026-04-14) — drift flush: S11 closePair #160 fix, test accounts toggle, signal timeline modal, pool-paused timer freeze, historical chairs P53-safe [allow-spec] | Drift S294 (2026-04-25): apos S256-S289 — Tools tab ganhou Simulacao E2E sub-pill (S277-S280, ver specs/sim-e2e-real-S281.md), market_status badge S288 em af_hedge.js, swap_after_buffer_minutes input no form AF Pool, error feedback closePair/closeAllPool, Health Audit widget Sistema>Saude. Estrutura JS de namespace CT. + wsEvents inalterada. [allow-spec] | S311 (2026-04-29): classificacao PASSOU/BREACH em overview.js + af_hedge.js agora consome prop_rules.target_pct/max_dd da API (era hardcode 8%/10%/100k duplicado). Ver secao "Classificacao de Contas Prop". [allow-spec] | S344 (2026-05-12): 3 bugs descobertos em wsEvents handlers via validacao visual Playwright. Fixes deployados: (a) handler 1 nao re-renderiza em modify_scheduled/modify_done (preserva badge cronometro), (b) setInterval do countdown agora tem ref pra clearInterval em modify_done, (c) catalogo de armadilhas em .claude/knowledge/dashboard-handler-traps.md. Ver secao "Bugs S344 — MODIFY badge handlers". [allow-spec] | S391 (2026-05-30): secao "Ultima Rodada" (rounds encerrados) em af_hedge.js agora agrupa cards por tipo (Concluidas x Nao executadas), espelhando o S388 da secao ativa — cada sub-grid com altura uniforme, mata o buraco branco entre card alto (CONCLUIDO) e baixo (TIMEOUT/PAUSA ROLLOVER). Helper puro splitLastRoundByType em af_hedge_logic.js (testado em vitest). Cabecalho de grupo so quando ha 2+ classes. Ver specs/af-last-round-group-by-type.md. [allow-spec] | S396+7 (2026-06-05): mini-box de precos (Entry/SL/TP/Exit em buildFighterHtml, af_hedge.js) reformado pra corrigir desalinhamento. SL e TP migraram pra um grid de 2 colunas (.af-sltp-grid no index.html: rotulo a direita | valor a esquerda) pra ficarem ALINHADOS ENTRE SI e imunes as decoracoes (✓ de MODIFY, * de colchao, destaque). Antes cada linha era um inline-block centralizado sozinho -> SL/TP saltavam ate ~10px na horizontal e o "SL ajustado" (✓ + caixinha + valor + ) VAZAVA pra 2 linhas em card estreito, deixando aquele card mais alto e quebrando a harmonia dos concluidos. Entry e Exit seguem centralizados cada um. Destaque de MODIFY virou outline (nao ocupa espaco no layout) no lugar de fundo+borda+padding. Lucro-projetado do live preservado. Validado por medicao Playwright no proprio buildFighterHtml: delta SL-TP=0, altura dos concluidos identica (86px). [allow-spec] | S396+8 (2026-06-05): Entry e Exit TAMBEM entraram no grid (mesma .af-sltp-grid, agora com .af-price-grid junto) pra os 4 numeros (Entry/SL/TP/Exit) encostarem na MESMA coluna — corrige o desalinhamento residual que o S396+7 deixou (Entry/Exit ainda eram frases centralizadas soltas, e o numero caia em X diferente por linha: Entry ~+14px, Exit ~+10px, SL/TP no centro). Ordem (Entry topo / Exit base) e bloco centralizado preservados; o respiro Entry->SL e TP->Exit vira padding .sep-b/.sep-t nessas celulas. Validado por medicao Playwright na funcao real: spread dos 4 numeros = 0px (coluna perfeita) em todos os cards. HOTFIX (mesma sessao): mover Entry/Exit pra fora das div.af-fighter-detail tirou o font-size:11px que vinha herdado -> os numeros do grid passaram a herdar 16px do card (GIGANTES, destoando do resto do card que esta em 11px) -> "cagou tudo, ficou gigante". Fix: font-size:11px + line-height:1.5 EXPLICITOS no proprio .af-sltp-grid. Confirmado por medicao (16px->11px) e screenshot antes/depois. Licao: o harness mascarou porque o card de teste nao tinha font-size base (tudo herdava igual e parecia coeso); reproduzir SEMPRE com o mesmo contexto de font-size do site. [allow-spec]

SSoT para: Frontend SPA, tabs, deploy, armadilhas, XSS, temas
Consolida: memory/dashboard-features.md + .claude/knowledge/copytrade-dashboard.md + memory/dashboard-sidebar-notes.md

Stack

Arquivos

Arquivo Responsabilidade
index.html Layout principal, sidebar, modais (~500 linhas)
api.js Estado global (window.CT), API wrappers, helpers
app.js Init, auth, tabs, toasts (~1475 linhas)
websocket.js WS client, reconnect, wsEvents event emitter
overview.js Tab Overview (cards equity, grupos)
accounts.js Tab Contas (CRUD, remote settings)
positions.js Tab Posicoes (close/modify individual)
admin.js Tab Admin (cleanup, debug) (~800 linhas)
remote_settings.js Modal Remote Settings (6 buffers)
delay.js Delay Analysis (stats + tabela)
ea_update.js Auto-update UI (upload, release, rollback)
health.js Tab Saude (diagnostics, logs)
roadmap.js Kanban roadmap
af_hedge.js AF Hedge tab (pair battles, pool config, force execute, history, debug mode, swap countdown, compact timeline) (~3860 linhas)
af_hedge_logic.js Helper functions para operacoes AF hedge (~150 linhas)
journal.js Tab Sistema > Journal (diario de trades, CSV export UTF-8, filtros) (~360 linhas)
prop_firms.js Tab Cadastros > Prop Firms (tabela comparativa + CRUD + arquivar/desarquivar) (~350 linhas)
prop_firm_logic.js Helper PURO de mesa: fase de entrada, visibilidade por fase, rotulo "(encerrada)", contador (~90 linhas). Testado em vitest (tests/unit/prop_firm_arquivar_logic.test.js). Spec: specs/prop-firm-arquivar-desarquivar.md
risk_calc_v2.js Risk Calculator V2 (3 modos: %Bal, %Eq, USD) (~980 linhas)
~~simulator.js~~ REMOVIDO na Fase 5 (sim-rapido): a aba Monte Carlo antigo + o endpoint /api/simulator/run + o pacote scripts/hedge/ foram aposentados (endpoint ja retornava 500 em prod desde S280). O sucessor eh o simulador E2E/rapido (simulacao_e2e.js).
styles.css Estilos globais + componentes extraidos (~3100 linhas)
themes.css Overrides de temas (OBRIGATORIO)

Arquitetura JS (S127)

Namespace CT.*

Todo estado compartilhado vive em window.CT = {} (definido em api.js). Variáveis: CT.token, CT.accounts, CT.allSignals, CT.equityChart, CT.chartHours, CT.refreshInterval, CT.notifSound, CT.ws, CT.wsHealthy, CT.syncingAccounts, CT.syncConfirmedAt, CT.afCurrentPool, CT.afPools, etc. Código novo DEVE usar CT.* — nunca criar variáveis globais soltas.

Event Emitter wsEvents

WebSocket handlers registram-se via wsEvents.on('event', fn) em vez de monkey-patching handleWS. Definido em websocket.js. Eventos: heartbeat, signal, ack, reverse, login_failed, settings_synced, alert, account_new, af_pair_update. Módulos que escutam: af_hedge.js (af_pair_update), remote_settings.js (heartbeat), websocket.js (signal, ack, account_new, login_failed, heartbeat).

CSS

Todo CSS vive em styles.css e themes.css. NUNCA injetar CSS via JavaScript (document.createElement('style')). Seções em styles.css: base + Simulator + Delay Analysis + Remote Settings + Modify Modal + ACK Spinner + AF Hedge (battle cards, fighter panels, insight cards) + Journal + Prop Firms.

Tabs (5 grupos, consolidado S107)

  1. Visao Geral (Alt+1) — Summary cards por grupo, equity chart overlay, desync alert sonoro, contas. Contas: classificacao obrigatoria (Mesa/Fase/Tamanho/Steps/Dono), botao X excluir permanente (hard delete com confirmacao dupla), auto-nome sem contar deletadas/indefinidas
  2. AF Hedge (Alt+2) — Pools, battle cards, force execute, pair history, debug mode, swap close countdown (global + per-card via WS), compact battle timeline. Ferramentas: Validar (in-memory, S170), Reset Historico, Deletar Pool (confirmacao dupla: modal + digitar nome). Simular Round e Stress Test removidos (deprecated 410, S170). Arquivo: af_hedge.js
  3. Battle cards (S205): Badges amigaveis (Risco Ajustado, Risco Otimizado, Ultima Chance) com tooltips. Mini-panel centralizado (Entry+SL+TP+Exit) com fundo sutil e borda arredondada. SL mostra badge "+" quando buffer aplicado (sl_buffer_offset via risk_detail, fallback $1 XAUUSD). Footer: mini-tabela de metricas com 4 linhas: Entry Gap (pts + $), Exit Gap (pts + $), Hedge Cost ($ + %), Exit Slip (pts + $) — cada uma com dot colorido (verde/amarelo/vermelho por threshold). Labels: "Risco desta batalha" (header), "Risco Max" (fighter). Nomes usam getAccountDisplay() — busca nome completo + cor do dono de CT.accounts (alinhado com Visao Geral)
  4. Trading (Alt+3) — Sub-pills: Sinais | Ordens | Posicoes
  5. Sinais: Historico filtrado, delay analysis (cards + tabela expansivel)
  6. Ordens: Broadcast, risk calculator V2, price ruler, preview por conta
  7. Posicoes: Posicoes abertas, close/modify, risco %, auto-refresh 30s
  8. Sistema (Alt+4) — Sub-pills: Saude | Journal | Configuracoes
  9. Saude: Health cards, monitor, diagnostics grid, trade trace timeline
  10. Journal: Diario de trades, CSV export UTF-8, filtros
  11. Configuracoes: Sessions, IP whitelist, users, audit, EA versions, master key
  12. Tools (Alt+5) — Sub-pills: Roadmap | Simulador | Simulacao E2E | Saude | EA Tester
  13. Roadmap: Kanban drag-and-drop (4 colunas), CRUD cards com tags
  14. Simulador: Monte Carlo AF v2
  15. Simulacao E2E / Saude / EA Tester: spec-driven sub-pills
  16. Cadastros (Alt+6, S359) — Sub-pills: Donos | Prop Firms (movida de Tools)
  17. Donos (S359, spec aba-cadastros-donos-conta.md): CRUD AccountOwner (Linniu/Lucas/etc).
    Risco em % por fase per dono (risk_mode='inherit_pool' | 'custom', max_risk_f{1,2}_pct).
    Mesa cap absoluto (D7 S358). Delete bloqueado se TEM contas FK vivas OU mortas
    (R8: usuario so renomeia, propaga via FK automatic). Preview tabela por conta
    mostra MIN(mesa, requested). Arquivo: cadastros.js. Hash legacy /tools/propfirms
    redireciona pra /cadastros/propfirms (window 30d).
  18. Prop Firms: Tabela comparativa (movida de Tools). Arquivar/desarquivar
    (2026-07-29, spec prop-firm-arquivar-desarquivar.md):
    botao por linha; a lista
    esconde as encerradas por padrao (controle "Mostrar mesas encerradas" revela); o
    contador mede sobre TODAS. Arquivar = "nao se compra mais", NUNCA mexe em conta que
    ja roda. No modal de classificar, mesa encerrada some na FASE DE ENTRADA (derivada de
    phases[0], nao "F1" chumbado) e aparece nas seguintes; a mesa que a conta ja usa
    nunca some. GET /api/prop-firms filtra is_active por padrao
    (?include_archived=true traz tudo); o servidor recusa a classificacao com
    ?permitir_mesa_encerrada=true como escape explicito.

Atalhos: Alt+1..5 (tabs), R (refresh), Esc (fechar modal), ? (ajuda)
Backwards compat: switchTab('signals') redireciona automaticamente para Trading > Sinais

Browser Automation (validacao visual)

Browser MCP (unico — S160):

Ferramenta Melhor para Config Brave
Playwright MCP Automacao, snapshots, screenshots, debug --executable-path pro Brave, --isolated, --image-responses omit

Login: Usar conta test_runner (NAO admin). Credenciais em .secrets.local e tests/test-helpers.js.
- Campos: #loginUser + #loginPass (submit com \n no campo senha)
- Apos login: #loginPage some e #dashboard aparece

Exemplo @playwright/cli:

playwright-cli open https://linniuc.com --headed   # abre Brave
TEST_PASS=$(grep TEST_PASS .secrets.local | cut -d= -f2)  # le a senha viva do arquivo gitignored
playwright-cli fill e8 "test_runner"                # usuario (ref do snapshot)
playwright-cli fill e11 "$TEST_PASS"                # senha
playwright-cli click e12                            # botao Entrar
playwright-cli screenshot                           # salva PNG em .playwright-cli/
playwright-cli close                                # fecha browser

Navegacao — SEMPRE usar eval (NAO clicar):

// Tabs principais (div.nav-tab, NAO sao buttons)
switchTab('overview')   // Visao Geral
switchTab('tournament')  // AF Hedge (S320: data-tab="tournament" eh naming legacy AF — backend tournament removido, frontend ID mantido por compat com af_hedge.js + app.js + overview.js)
switchTab('trading')    // Trading
switchTab('system')     // Sistema
switchTab('tools')      // Tools

// Sub-tabs (div.sub-pill)
switchSub('tools', 'roadmap')    // Tools > Roadmap
switchSub('tools', 'propfirms')  // Tools > Prop Firms
switchSub('tools', 'simulator')  // Tools > Simulador
switchSub('trading', 'signals')  // Trading > Sinais
switchSub('trading', 'orders')   // Trading > Ordens
switchSub('trading', 'positions') // Trading > Posicoes

Regras browser:
- Browser eh Brave, nao Chrome. SEMPRE rodar Brave-Debug.bat antes do DevTools MCP
- new_tab com URL no payload (NUNCA vazio + navigate separado)
- list_tabs ANTES de close_tab
- NAO usar tab principal do usuario — sempre nova tab
- Usar test_runner pra login automatizado, NUNCA admin

Padroes de Codigo

// Fetch com auth
const token = localStorage.getItem('ct_token');
const resp = await fetch('/api/endpoint', {
    headers: { 'Authorization': `Bearer ${token}` }
});

// WS
const ws = new WebSocket('wss://linniuc.com/api/ws/dashboard');
ws.onopen = () => ws.send(JSON.stringify({ token: localStorage.getItem('ct_token') }));

// setInterval (LIMPAR antes de criar novo — evita memory leak)
if (window._refreshInterval) clearInterval(window._refreshInterval);
window._refreshInterval = setInterval(refreshData, 5000);

Data e hora — a cascata do relogio do site (OBRIGATORIO)

Nenhuma hora do painel pode sair do relogio do sistema de quem esta olhando. O
servidor escreve as frases no relogio de operacao da mesa; enquanto os dois coincidem
ninguem ve, e foi assim que o mesmo prazo apareceu como 16:00 e 20:00 no mesmo card
(licao P817). SSoT: relogio-unico-do-painel.md.

Ordem Degrau Onde vive
1o Override da chamada — so afeta "por cima" {tz} no CT.fmtHora(iso, {tz})
2o Relogio universal do site CT.SITE_TZ em static/js/api.js
3o UTC — rede, se o universal for invalido fixo
// LER instante do servidor (ISO naive, sem 'Z')   -> CT.utcDate  (licao P537)
// ESCREVER instante na tela                       -> CT.fmt*     (licao P817)
CT.fmtHora(iso)                  // 16:00        ({segundos:true} -> 16:00:00)
CT.fmtData(iso)                  // 06/08/2026   ({curto:true}    -> 06/08)
CT.fmtDataHora(iso)              // 06/08/2026 16:00
CT.fmt(iso, {weekday:'short'})   // cru: qualquer opcao do Intl, com o fuso ja injetado
CT.fmtHora(iso, {tz:'UTC'})      // override pontual, so naquela chamada

// PROIBIDO — projeta no fuso do PC de quem abriu a tela:
new Date(x).toLocaleTimeString('pt-BR')

DURACAO nao entra na cascata. "ha 5 min", "2h 30min" sao subtracao de milissegundos;
converter fuso ali quebra a conta. Formate duracao a mao, como sempre.

Mudar o relogio do site = mudar SITE_TIMEZONE em server/constants.py. E' o unico
lugar. O CT.SITE_TZ do JavaScript e' SEMENTE: ele pinta a tela nos milissegundos antes da
1a resposta chegar e vale de reserva se a rede cair — e um teste OBRIGA os dois a baterem.

A pool que nao declara fuso proprio HERDA esse mesmo relogio (decisao do dono,
2026-08-06). No formulario da pool isso aparece como a opcao "herda o relogio do site", com
o valor herdado a vista no rotulo (GMT-4 · herdado do Campo Grande) — "GMT-4" sozinho nao
distingue "eu escolhi" de "eu herdei", e e' essa duvida que faz mexer no que ja esta certo.
Pool que declara um fuso proprio continua mandando nele.

⚠️ Esta linha ja mentiu DUAS vezes. (1) Dizia que o fuso da pool decidia "quando cai o
swap" — nao decide, e nunca decidiu. (2) Dizia que mudar o relogio do site era mudar
CT.SITE_TZ — deixou de ser verdade quando o relogio mudou de casa pro servidor.

O swap segue fora de tudo isso: guardado em swap_hour_utc e comparado em UTC
(is_near_swap), e' um momento do mundo, igual pra todo fuso. Nem o fuso da pool nem o
relogio do site movem a virada de juros um minuto — o que o fuso faz e' so' exibir ela
convertida no formulario. Isso e' trava, nao promessa: TestOSwapNaoSeMoveComOFuso percorre
5 fusos exigindo o mesmo veredito.

Rede: tests/unit/relogio_do_painel.test.js e tests/unit/pool_herda_relogio_do_site.test.js
(ambos sob Pacific/Kiritimati, +14) + server/tests/test_relogio_do_site.py.

Risk Calculator (Tab Ordens)

3 modos: % Balance, % Equity, USD fixo. Formula: riskMoney / (slDist / tickSize * tickValue)

WebSocket Events

Lista completa (definida em websocket.js via wsEvents):
- heartbeat (balance/equity/positions)
- signal (novo signal)
- ack (confirmacao — MODIFY/CLOSE/OPEN)
- ea_status (online/offline)
- af_pair_update (critico AF — usado por af_hedge.js pra live updates de pares)
- reverse, login_failed, settings_synced, account_new, alert (auxiliares)

Como a tela se mantem atualizada — remendo vs desenho completo (2026-08-04)

A regra, em 1 frase: o remendo barato cuida do que so' troca de NUMERO; qualquer coisa
que muda o DESENHO exige redesenho — e quem decide isso e' uma assinatura de conteudo,
nunca uma contagem nem um return seco.

Por que existe: uma varredura de 2026-08-04 achou 18 defeitos de "so' fica certo depois do
F5", e 7 deles eram a mesma familia: alguem escreveu um atualizador incremental pra evitar
piscar de tela e o pos num caminho que curto-circuitava o desenho completo. Tudo que o remendo
nao cobria congelava — e o que congelava eram justamente os campos derivados (custos, selos,
banners, botoes), que dependem de dado que so' vem de endpoint e nunca caberia no payload do
push. Licao P795.

Onde Quem decide O que o remendo cobre O que exige redesenho
Card de batalha (af_hedge.js) o proprio ramo do handler nada — status terminal SEMPRE recarrega Exit Gap, Hedge Cost, Slippage, prateleira "Concluidas desta rodada"
Cartao de conta (overview.js) _assinaturaDoCartao(a) saldo, equity, P&L, posicoes, batimento, selo online, AFOGADO nome, classificacao, INVERT, versao do robo, chave de entrada, DESYNC, grupo

As tres regras que nao se negociam:

  1. Numero volatil NAO entra na assinatura. Saldo e posicoes mudam a cada batimento; po-los
    ali ressuscita o piscar que o remendo existe pra evitar. Campo derivado deles entra como
    veredito booleano — o DESYNC entra como "tem dessincronia sim/nao", nunca last_positions.
  2. Carimbo no elemento (card.dataset.assinatura). Primeira passada so' carimba (o elemento
    acabou de nascer do desenho completo). Depois de trocar o no' via outerHTML, re-carimbar
    no elemento NOVO
    — senao a proxima comparacao roda contra um carimbo que nunca foi escrito.
  3. Contagem de elementos continua valendo — pra add/remove. api.js compara quantos cartoes
    com quantas contas pra detectar conta que nasceu ou sumiu, unica coisa que o remendo
    por-elemento nao faz. O defeito nunca foi a contagem existir; foi ela ser o UNICO gatilho.

Rajada vira UM redesenho: _afAgendarRecarga(atraso, chave) coalesce avisos que chegam
juntos (4 pares fechando em sequencia = 1 redesenho, nao 4 x 5 chamadas de API). E o redesenho
e' PULADO se o card nem esta na tela — batalha de outra pool nao remonta o seu painel.

Corolario do servidor: se o site reage ao aviso indo BUSCAR dado, o aviso sai depois do
commit. process_trade_result (server/af/lifecycle.py) publica o af_pair_update de
completed apos o db.commit(); o payload e' montado antes porque o commit expira os objetos
ORM. Mesmo principio ja registrado em server/config_gate.py. Rede:
server/tests/test_aviso_de_par_concluido_depois_do_commit.py.

Aba que se atualiza sozinha usa guarda de aba ativa (padrao de websocket.js:168-171):
Journal, Terminais e Saude so' recarregam se a aba estiver visivel. Excecao deliberada: o
contador de usuarios online da barra do topo roda SEM guarda — ele aparece em toda tela.

Redes: tests/unit/af_card_concluido_atualiza_sozinho.test.js,
cartao_de_conta_atualiza_sozinho.test.js, journal_filtro_e_atualizacao.test.js,
terminais_e_contador_online.test.js, delay_filtro_de_grupo.test.js,
ea_update_ouvintes_ws.test.js, cor_do_dono_invalida_cache.test.js,
abrir_pool_leva_pra_pool_certa.test.js, revisar_resultado_busca_no_servidor.test.js,
painel_config_remota_e_historico.test.js. Achados completos:
.planning/varredura-precisa-de-f5-achados.md.

Classificacao de Contas Prop (PASSOU / BREACH / em progresso)

SSoT: Tabela prop_firms no DB. Backend enriquece cada conta no payload /api/accounts com prop_rules (objeto com target_pct, max_dd, daily_dd, equity_floor, target_usd, etc) ja ajustado por phase (F1 vs F2). Codigo: server/routes/accounts.py funcao _get_prop_rules.

Regra (overview.js + af_hedge.js):

// Frontend NUNCA reimplementa lookup de prop firm. Usa o que vem da API:
var baseSize = parseInt((a.prop_size || '100k').replace('k','')) * 1000 || 100000;
var targetPct = (a.prop_rules && a.prop_rules.target_pct) || 8;   // fallback so se prop_rules vier null
var maxDdPct = (a.prop_rules && a.prop_rules.max_dd) || 10;
var target = baseSize * (1 + targetPct / 100);
var ddFloor = baseSize * (1 - maxDdPct / 100);
var isDead = equity < ddFloor;       // BREACH (perdeu mais que max_dd)
var isPassed = equity >= target;     // PASSOU (alcancou target_pct)
// senao: em progresso (visivel em "Contas Prop", nao em "Inativas (N)")

Anti-pattern (S311 fix): dicionario hardcoded JS tipo _propTargets = {'FTMO Swing':10, ...} ou 100000 hardcoded como baseSize. Duplica info do DB e bugifica ao adicionar prop firm nova ou conta de tamanho diferente. Smoking gun original: card mostrava "Meta 10%" (correto, do prop_rules) mas tag "PASSOU" (errado, do hardcode 8%) na mesma tela.

Filtro "Inativas (N)": Conta com isDead || isPassed cai aqui. Default escondido (_ovShowInactive = false). Checkbox toggle revela.

Theme System

XSS (OBRIGATORIO)

Regra: NUNCA inserir dados da API via innerHTML sem escapeHtml() de api.js.

Status S82: 11 pontos XSS encontrados/corrigidos. Pendentes: positions.js (data-ticket), remote_settings.js (rsModalAccountId)

Deploy Frontend

# 1. Copiar
scp app.js admin.js styles.css [email protected]:/opt/copytrade-server/app/static/
# 2. Cache bust (OBRIGATORIO)
ssh [email protected] "sed -i 's/v=[0-9]*/v=$(date +%Y%m%d%H)/' /opt/copytrade-server/app/static/index.html"
# 3. NAO precisa restart (nginx serve static files)
# 4. Verificar em aba anonima

Debugging Visual

Tela branca: F12 -> Console -> erro JS | Network -> app.js (200 vs 404) | Se erro -> const TDZ ou fetch sem try/catch
Dados nao atualizam: Network -> requests sendo feitas? | setInterval limpo? | WS desconectou?
Layout quebrado: Elements -> CSS | styles.css carregou? | media queries

Armadilhas

Armadilha Fix
const TDZ Declarar ANTES de usar em template literals
Fetch sem try/catch SEMPRE envolver fetch
Login redirect loop Limpar ct_token, recarregar
innerHTML XSS SEMPRE usar escapeHtml()
apiPost() retorna {ok, data} Acessar resp.data, checar resp.ok
apiFetch timeout 8s S255: retry silencioso 1x em 1s em GET/HEAD se 502/503/504/AbortError (absorve restart do backend em deploys). POST/PUT/DELETE nao retry (nao-idempotente). Endpoints CPU-bound reais (simulador/export) ainda devem usar fetch() direto com timeout custom
CSS var inexistente Consultar themes.css. Validos: --bg-card, --text-primary, --text-muted, --border
Tabs fora do .main TODA tab-content DEVE estar dentro de div.main
Cache bust no deploy Sem ?v= atualizado, browser serve versao antiga
themes.css removido Quebra todos os temas. NUNCA remover
Chart.js resize infinito Canvas DEVE estar em div com position:relative;height:Xpx
~~showToast vs toast~~ CORRIGIDO S92. ea_update.js agora usa toast() de api.js
Validacao em browser separado NUNCA usar browser principal do usuario

Mudancas recentes (S230-S233)

Mudanca Arquivo Nota
S11 #160 closePair af_hedge.js:480-535 afConfirm refatorado: Promise com handlers escopados (sem singleton _afConfirmCb). Cada chamada tem settled local + cleanup(val), listener Escape/Enter, resolve direto. Corrige trava em double-click ou re-entrada por WS refresh. closePair(pairId, btn) agora recebe btn explicito (nao mais window.event). Teste Playwright: tests/af_confirm_modal.spec.js (regressao reentrante)
Test accounts toggle index.html:688-700, accounts.js Checkbox "Mostrar contas de teste" + banner amarelo quando ligado. Separa visao normal de sandbox
Signal timeline modal signal_timeline_modal.js, af_hedge.js botao showTradeTimeline Novo JS carregado em index.html, botao "Timeline" nos cards live/completed
Pool-paused timer freeze af_hedge.js:1298-1360 Cronometros usam refMs = paused_at || Date.now(). Quando pool pausada, nenhum timer avanca. Inclui getStatusInfo(p, refMs) e isTimerExpired com mesma referencia
sl_buffer em cards live af_hedge.js:1403-1410 rd.sl_buffer_offset injetado em posA/posB pra mostrar indicador "+" no side P&L ao vivo (nao so historico)
Historical chairs P53-safe af_hedge.js:2638-2720 Chairs com status in ('passed','dead') e sem account_id ficam como _isHistorical, renderizam "Saldo final (historico)" em vez de "Aguardando sync MT5"
Add chair modal prefill af_hedge.js:3148-3160 _populateAddChairFilters agora le require_type/firm/phase/size do pool config e pre-seleciona filtros
getAccountDisplay 3o arg af_hedge.js:268-290 Novo parametro fallbackOwner pra color lookup quando Account tem nome generico (Conta #N). Usa prop_name + "F<phase>" + owner como fallback de display

Faixa de avisos do card de batalha — .af-flagstrip (S394)

Problema: a 1a linha do topo do card amontoava ate ~8 carimbos (overflow:hidden cortava) e a explicacao deles vivia em tooltip de hover (title nativo / .af-tip:hover) — que nao funciona em toque (celular). P388.

Solucao: buildFlagStrip(p, rd, ctx) (af_hedge.js, antes de renderPairCard) re-deriva os carimbos CONDICIONAIS como tags por extenso sempre visiveis numa faixa .af-flagstrip logo abaixo da linha de identidade. Cobre: close_reason (Fechado no alvo/stop, anti-swap robo/site, fecho anormal), MODIFY (SL ajustado/abortado/agendado), rechamado, mercado fechado, motivo de espera (rsafe2/stale/swap_window/cotacao), risco (ultima chance/otimizado/ajustado) e colchao do SL.

Pos-vida do card de batalha — limbo → resolvido → arquivo (S402)

Problema (incidente 2026-06-09): relogio vazado matou 3 batalhas no papel e a tela escondeu o estrago: gaveta "Ultima Rodada" lembrava o recolhido PRA SEMPRE (chave global), card de limbo nao dizia que as ordens seguiam VIVAS no broker, contador "1 concluido" sem card visivel, e o reconcile fazia o card SUMIR da secao roxa pra gaveta fechada. Spec: af-batalhas-legibilidade-pos-vida-S402.md.

Invariante de ouro (testado — fuzz 100 seeds em af_hedge_logic.test.js): todo par visivel aparece em EXATAMENTE 1 secao — nem some, nem duplica.

Seletores Importantes

// Login (NAO tem #loginBtn!)
document.querySelector('.login-box .btn-primary')
// Sidebar
document.querySelector('.sidebar')
// Tabelas: #positionsTable, #signalsTable, #accountsTable
// Modais: #editModal
// Sidebar mode: localStorage "layoutSidebar" (NAO "dashboardLayout")
// Theme: #themeSelector > #themePanel

Debugging

Status: ACTIVE | Ultima revisao: S151 (2026-03-29)

SSoT para: Metodologia debug, checklists, ferramentas, race conditions
Consolida: memory/debugging-playbook.md + .claude/knowledge/copytrade-debug.md + .claude/knowledge/debug-sistematico.md
Relacionados: specs/signal-lifecycle.md, specs/invert-rules.md, memory/business-constants.md

Principio

Sem root cause = fix aleatorio. Debugging sistematico: ~95% 1st-time fix. Random fixes: ~40%.
Isolar ANTES de consertar. Entender o que acontece, NAO "mudar e ver se melhora".

Fluxo Universal

1. REPRODUZIR  -> Confirmar que o problema existe agora (nao assumir)
2. LOCALIZAR   -> Qual camada? EA / Servidor / Dashboard / Banco / Rede
3. INSTRUMENTAR -> Coletar dados: logs, queries, screenshots
4. ISOLAR      -> 1 hipotese testavel por vez
5. CORRIGIR    -> Fix cirurgico, minimo necessario
6. VALIDAR     -> Confirmar fix funciona E nao quebrou mais nada
7. DOCUMENTAR  -> Atualizar known-issues.md e changelog.md

Checklist de Contexto (OBRIGATORIO — ANTES de diagnosticar)

1. QUE DIA/HORA? -> FDS? Feriado? Fora de sessao?
   - Forex: seg 00:00 -> sex 22:00 UTC. FECHADO sab/dom
   - Metals (XAUUSD): similar Forex
   - Crypto: 24/7 mas brokers podem ter manutencao
   - Indices: sessoes especificas, nao 24h

2. QUAL BROKER? -> Exness (streaming parcial) vs Capital Point (corta tudo)

3. QUAL SIMBOLO? -> Cada simbolo tem sua sessao

4. CONTROLAR VARIAVEIS -> Se 2 contas diferem: versao EA? Broker? Grupo? PC?
   Correlacao != Causalidade

5. SO DEPOIS -> Se contexto nao explica, AI SIM investigar codigo

Regra de ouro: Contexto simples (mercado fechado, broker offline) eh MAIS provavel que bug.

Regra de Investigacao Completa

1. IMPACTO PRIMEIRO -> open_positions: quem tem posicao aberta?
                    -> trade_links is_closed=false orfaos?
                    -> Posicao SEM par = DESYNC ATIVO
2. DEPOIS SINTOMA   -> debounce, logs, spam
3. NUNCA reportar "zero posicoes" sem conferir TODAS as contas

4 Fases (NAO PULAR)

Fase 1: Root Cause Investigation

CopyTrade-specific:
- [ ] Broker market hours? Terminal MT5 conectado? VPS SSH reachavel? DB connection?

Fase 2: Pattern Analysis

Fase 3: Hypothesis Testing

Fase 4: Implementation

Red Flags (PARAR)

CopyTrade: Fluxo de Copy (EA-to-EA)

1. Conta abre trade -> SnapshotEngine detecta -> POST /api/signals
2. Servidor cria Signal + TradeLink(origin) + PendingSignal
3. Destino poll -> GUIExecution abre -> POST ack (FILLED)
4. Servidor cria TradeLink(slave) -> resolve PendingSignal

Timing: Poll 1-3s, GUI 3-7s, ACK imediato (retry 3x). Total: ~5-10s.

Anti-Loop (4 camadas)

  1. trade_links: Ticket ja em trade_links -> descarta
  2. pending_signals: PendingSignal ativo -> suprime sinais da conta
  3. suppress_markers: MODIFY/CLOSE TTL 10s -> descarta echo
  4. Processed Signal Tracker (EA): g_processedSignalIds[] 100 IDs circular

Quality Monitor (9 checks automaticos, S149)

Roda a cada 15min na VPS (scripts/quality/quality_monitor.py). Market-aware (weekday/weekend thresholds). Dedup 1h.

Check Severidade O que detecta
same_sign_pnl CRITICAL Ambos lados do par AF lucraram (impossivel em hedge)
duplicate_af_trades CRITICAL Trade duplicado no mesmo par AF
net_exceeds_risk HIGH Custo liquido do par excede risco planejado
stale_heartbeat HIGH EA sem heartbeat > threshold (15min dia util, 30min FDS)
phantom_positions HIGH Posicao no broker sem TradeLink correspondente (>30min)
signal_without_ack HIGH Signal distribuido sem ACK em >5min
ghost_positions MEDIUM TradeLink aberto mas posicao nao existe no broker (>10min)
orphan_trade_links MEDIUM TradeLink orfao (>24h sem par)
pnl_suspect_unresolved MEDIUM Par P&L suspeito sem resolucao

Reconcile (reconcile.py): Detecta phantoms, ghosts, e P&L drift. Roda standalone ou via cron.

Alertas: CRITICAL/HIGH → Telegram imediato. MEDIUM → log only.

Race Conditions Conhecidas

CLOSE antes de OPEN ACK: PendingSignal bloqueia CLOSE enquanto OPEN pendente. Se expirou (>60s) -> orphan checker pega.

MODIFY antes de FILL: EA ignora MODIFY (posicao nao existe). SL/TP reconciliation no heartbeat corrige (debounce 30s).

Duplicate ACK: Servidor usa idempotency — segundo ACK com mesmo signal_id = ignora.

Ferramentas MCP

Ferramenta Quando usar
ct_trace(trade_group_id) Rastrear um trade especifico (timeline completa)
ct_signals(hours, limit) Ver sinais recentes
ct_positions Posicoes abertas de todas contas (detectar desync)
ct_delay Latencia entre pares
ct_diagnostics Telemetria EA (uptime, erros/h, ultimo erro)
ct_errors(hours) Erros recentes

Trace completo: Identificar trade_group_id (dashboard/DB) -> ct_trace -> procurar gaps (signal sem ACK, ACK FAILED).

Checklists por Camada

EA offline

  1. Rodando? tasklist | grep terminal64
  2. Heartbeat? SELECT * FROM heartbeats WHERE account_id=X ORDER BY created_at DESC LIMIT 3
  3. Log EA: powershell Get-Content ...Logs/YYYYMMDD.log -Encoding Unicode -Tail 50

Signal nao executou

  1. Signal existe? 2. Pending criado? 3. EA recebeu? 4. ACK enviado? 5. Trade link? 6. Se FAILED: motivo?

GUI execution falhou

  1. Qual operacao? 2. Log "[GUI]" 3. Posicao existe? 4. Lock file? 5. MT5 minimizado? 6. Popup broker?

Orfaos: WHERE is_closed=false AND created_at < NOW() - INTERVAL '1h'
Duplicados: GROUP BY trade_group_id HAVING COUNT(*) > 4

Dashboard nao atualiza

  1. Servidor rodando? 2. Health OK? 3. WS conectado? (F12 Network) 4. Erro JS? 5. Cache? (Ctrl+Shift+R)

Colunas Corretas (NAO chutar)

Data Sources (Dashboard vs API vs DB)

Dado Dashboard Diferenca possivel
Posicoes open_positions (heartbeat snapshot) Cache browser (refresh)
Equity Ultimo heartbeat Se stale, mostra dado antigo
EAs online last_seen < 90s Poll interval

Blast Radius

Arquivos P0 (afeta TUDO): signals.py, ea_ws.py, heartbeat.py, settings.py, main.py.
Mudanca -> backup + teste local + deploy horario seguro + monitorar 5min.

Ferramentas Rapidas

Ferramenta Comando
Logs EA powershell Get-Content ...Logs/YYYYMMDD.log -Encoding Unicode -Tail N
Logs servidor ssh root@... "journalctl -u copytrade --since '5 min ago' --no-pager"
DB query ssh root@... 'PGPASSWORD=... psql -U copytrade_user -d copytrade -c "..."'
Health curl -s https://linniuc.com/api/health

Invert Rules

Status: ACTIVE | Ultima revisao: S230 (2026-04-14) — revisado, mudancas em signals.py e GUIExecution.mqh desde S194 sao de observabilidade (EventLog S228 C10-C14, Fase F simetria Modify/Close 3.73.6, Telegram alerts ack_failed, drift sync). Nenhuma mudanca toca logica de inversao. Ver specs/ea-observability.md.

Versao: 1.2
Data: 2026-04-14
SSoT para: Logica exata de inversao de sinais no CopyTrade


Conceito

Analogia: Invert e como um espelho. Quando o master abre BUY, o slave invertido abre SELL. O SL e TP tambem trocam de lugar (o que era protecao vira alvo, e vice-versa).

Por que existe: No hedge, um lado PRECISA ser o oposto do outro. Invert automatiza isso.


Regra 1: Quem inverte

Modo Quem faz Quando
Online (InpOnlineMode=true) Servidor Ao criar o Signal para o peer. EA recebe JA invertido.
Local (InpOnlineMode=false) EA Ao ler o sinal, antes de executar.

No modo online, o EA slave NAO aplica inversao — o sinal ja chega invertido.


Regra 2: Condicao de inversao

EA-to-EA (sinal vindo de outro EA)

INVERTE se: peer.invert != master.invert
Master invert Peer invert Resultado
false false Copia normal
false true Inverte
true false Inverte
true true Copia normal

Dois EAs com invert=true no mesmo grupo copiam entre si SEM inverter. So inverte quando os flags sao DIFERENTES.

Broadcast (sinal vindo do dashboard)

INVERTE se: account.invert == true

No broadcast nao ha conta de origem, entao a condicao e simplesmente if acct.invert.


Regra 3: O que e invertido (formula)

Pseudo-codigo (identico no servidor e no EA)

funcao inverter(direction, sl, tp):
    direction = "SELL" se era "BUY", "BUY" se era "SELL"
    sl, tp = tp, sl    // swap simples
    sl_distance, tp_distance = tp_distance, sl_distance  // swap

Exemplo concreto

MASTER abre:  BUY XAUUSD,  SL = 2000,  TP = 2100
SLAVE recebe: SELL XAUUSD, SL = 2100,  TP = 2000
                           (era TP)     (era SL)

Codigo real — Servidor (signals.py:78-86)

def _invert_direction(direction: str) -> str:
    return "SELL" if direction == "BUY" else "BUY" if direction == "SELL" else direction

def _invert_sl_tp(sl: float, tp: float, invert: bool):
    if invert:
        return tp, sl   # swap simples
    return sl, tp

Codigo real — EA (GUIExecution.mqh:1527-1531)

void GUIExecution_ApplyInversion(string &direction, string &sl, string &tp)
{
   direction = (direction == "BUY") ? "SELL" : "BUY";
   string tmp = sl; sl = tp; tp = tmp;
}

Regra 4: Inversao por tipo de sinal

Tipo Direction inverte? SL/TP inverte? Distancias invertem?
OPEN Sim Sim Sim
MODIFY (EA-to-EA) Sim (por consistencia) Sim Nao (MODIFY nao tem distancias)
MODIFY (broadcast) Nao (direction vazia) Sim Nao
CLOSE Nao Nao Nao

CLOSE nao inverte NADA. O servidor usa o local_ticket do TradeLink para saber qual posicao fechar. Direction/SL/TP sao zerados no CLOSE.


Regra 5: Inversao local (EA offline)

Quando InpOnlineMode=false e invert=true:

Tipo Comportamento
OPEN GUIExecution_ApplyInversion(direction, sl, tp) — inverte tudo
MODIFY Passa dummyDir = "" — so SL/TP sao swapados, direction nao muda
CLOSE Nenhuma inversao

Regra 6: Protecao contra mudanca

# accounts.py:147-148
if data.invert != account.invert:
    _check_open_positions(db, account_id, "invert")
# Lanca HTTP 409 se conta tem posicoes abertas

NAO e possivel mudar o flag invert de uma conta que tem posicoes abertas. Fechar todas antes.


Regra 7: Onde o flag vive

Local Campo Tipo
PostgreSQL accounts.invert Boolean, default False
EA input InpInvertSignal bool, default false
EA runtime g_sm_settings.invert Sobrescrito pelo servidor via SettingsManager_Sync()

O servidor e a fonte da verdade. O EA sincroniza o valor no heartbeat.


Gotchas (armadilhas conhecidas)

# Armadilha Detalhe
G1 broadcast-modify: enviar valores originais Dashboard deve enviar SL/TP para a posicao ORIGINAL (nao invertida). O servidor faz o swap. Enviar valores ja invertidos = dupla inversao = errado.
G2 Validacao de SL/TP apos swap BUY: SL < preco, TP > preco. SELL: SL > preco, TP < preco. Apos swap, os valores continuam validos para a direcao oposta porque o que era SL do BUY (abaixo) vira TP do SELL (tambem abaixo).
G3 Partial close Detectado como MODIFY com close_reason="PARTIAL_CLOSE". Inversao identica a MODIFY normal — sem tratamento especial.
G4 Modo local sem protecao Se EA offline e invert=true, inverte localmente sem validacao do servidor. Se flag estiver errado no input = trades invertidos errado.

Auto-Update EA

Status: ACTIVE | Ultima revisao: S230 (2026-04-14) — revisado, mudancas em ea_ws.py desde S173 sao de observabilidade (Telegram alerts ack_failed, Fase E S228) e NAO afetam o fluxo de auto-update. Logica inalterada. Ver specs/ea-observability.md.

Versao: 1.3
Data: 2026-04-14
SSoT para: Fluxo completo de auto-update do EA (upload → staged rollout → restart)
Relacionado: specs/settings-sync-guardian.md — Settings Sync Guardian (v3.66.0, WS push + verify) complementa o auto-update com sync de configuracoes em tempo real


Conceito

Analogia: E como atualizar um app no celular. O operador faz upload da nova versao, o sistema distribui primeiro pros "beta testers" (contas early), e so depois libera pro resto (contas stable). O EA se auto-atualiza, reinicia o MT5, e volta a operar.


Fluxo Ponta a Ponta

OPERADOR                          SERVIDOR                              EA
─────────                        ─────────                            ────
1. Upload .ex5 via Dashboard  →  2. Salva + SHA256 → ea_versions(pending)
3. Clica "Release"            →  4. stage=testing → desired_version nas early
                                 5. Background monitor (30min)
                                                                     6. Heartbeat HTTP → update_available=true
                                                                     7. Posicoes=0? → Download + Verify + Launch
                                                                     8. Gera PS1, mata MT5, copia .ex5, reinicia
                                                                     9. Proximo HB: versao nova → limpa desired
                                 10. Contas early atualizaram →
                                     stage=stable → desired nas stable
                                                                     11. Contas stable: mesmo fluxo (6-9)

Maquina de Estados (EA)

AU_IDLE ──[update_available=true]──→ posicoes=0? ──sim──→ AU_DOWNLOADING
    ↑                                    |                      |
    |                                   nao               [HTTP GET .ex5]
    |                              AU_WAITING                   |
    |                           (max 30min)              AU_VERIFYING
    |                                    |              [SHA256 + size]
    +────[timeout 30min]─────────────────+                      |
                                                        AU_LAUNCHING
                                                     [gera PS1, ShellExecute]
                                                              |
                                                          AU_DONE
                                                    [Sleep(2s) + ExpertRemove]
Estado Descricao
AU_IDLE (0) Esperando update
AU_WAITING (1) Update disponivel, aguardando 0 posicoes
AU_DOWNLOADING (2) Baixando .ex5
AU_VERIFYING (3) Verificando SHA256 + tamanho
AU_LAUNCHING (4) Gerando PS1 + lancando PowerShell
AU_DONE (5) Script lancado, EA se mata
AU_ERROR (-1) Erro — retry no proximo ciclo
AU_LOCKED (-2) Outro EA no mesmo terminal ja esta atualizando

Como o EA Detecta Update

Fonte 1: Heartbeat HTTP (principal)

Resposta do POST /api/heartbeat inclui:

{
  "update_available": true,
  "update_version": "3.25.0",
  "update_hash": "1869006665f4eae46...",
  "update_size": 337958,
  "update_url": "/api/ea/download/3.25.0",
  "update_force": false
}

Fonte 2: WebSocket push (secundario)

Mensagem tipo "update" com version + hash + size + url. EA monta fakeHB e chama mesma funcao.

{"type": "update", "version": "3.65.0", "hash": "abc123...", "size": 337958, "url": "/api/ea/download/3.65.0"}

Nota: WS push NAO inclui update_force (sempre assume force=false). Se force necessario, usar heartbeat HTTP.
Armadilha: Com WS ativo, HTTP roda so a cada 120s. Update pode demorar ate 2min pra ser detectado via HTTP.

Fonte 3: Auto-Catchup (automatico)

Se conta NAO tem desired_version definido, o heartbeat promove automaticamente para a versao mais nova ELEGIVEL — sem acao do operador ("pegar o trem"). O conjunto de stages elegiveis depende do tipo da conta:

Tipo de conta Stages elegiveis Pega build testing?
Pool / real (is_test=false) stable (+is_stable=true) NAO
Sandbox dogfood (is_test=true, nao-sim, nao-stress) testing, stable, completed SIM (S378)

S378 — Dogfood ring: a bancada de dev (sandbox is_test) recebe automaticamente o build MAIS NOVO, mesmo em testing, igual ao "dogfood" da industria (no interno auto-instala o experimental, distinto do canary que vai pra subset de usuarios reais). Isso permite que a bancada e o teste "Validar tudo" exercitem o build novo sem o operador ter que promover pra stable. INV-LEAK: conta nao-dogfood NUNCA pega testing (property-tested em test_dogfood_catchup_s378.py).

Implementado em heartbeat_helpers.py (pick_catchup_version + is_dogfood_account + eligible_catchup_stages), chamado pelo _compute_update_info. Guard espelha can_force_downgrade (routes/ea_tester.py). Util quando contas novas sao criadas apos um release, e essencial pro vai-volta do teste restart+reconcile.


Comparacao de Versao

// MAJOR.MINOR.PATCH — componente por componente
AU_CompareVersions("3.25.0", "3.24.1")   positivo (3.25 > 3.24)
AU_CompareVersions("3.25.0", "3.25.0")   0 (iguais = nao atualiza)
AU_CompareVersions("3.24.0", "3.25.0")   negativo (downgrade bloqueado)

Downgrade so permitido com force=true (rollback via dashboard).


Download e Verificacao

Etapa Detalhe
Download GET /api/ea/download/{version} via WinInet (bypassa restricoes MT5)
Arquivo staging Common/Files/linniuc_update.dat
Sanity check receivedSize >= 1000 bytes E receivedSize == expectedSize
SHA256 CryptEncode(CRYPT_HASH_SHA256, ...) — compara com hash do servidor
Sem hash Fallback: verificacao apenas por tamanho

Posicoes Abertas

Situacao Comportamento
0 posicoes Adquire lock → AU_DOWNLOADING
>0 posicoes, force=false AU_WAITING (sem lock, aguarda ate 30min)
>0 posicoes, force=true Adquire lock → AU_DOWNLOADING (ignora posicoes)
Timeout 30min Volta para AU_IDLE (desiste, tenta no proximo ciclo)

Lock file so e adquirido quando pronto para baixar. Nao segura o lock durante a espera.


Lock File (anti-race entre EAs)

Aspecto Detalhe
Arquivo Common/Files/linniuc_update.lock
Formato PID|SYMBOL|TIMESTAMP
Lock stale >5 minutos → removido automaticamente
Double-check Apos criar lock: Sleep(200ms) + re-le pra confirmar que e nosso

Script PS1 (7 passos)

O EA gera dinamicamente Common/Files/linniuc_updater.ps1:

  1. Kill MT5Stop-Process -Id $mt5pid -Force (aguarda ate 20s)
  2. Backup — copia LinniuC.ex5LinniuC.ex5.bak
  3. Renomeia .mq5 — move para .mq5.disabled (evita recompilacao)
  4. Copia .ex5 — de linniuc_update.datMQL5/Experts/LinniuC.ex5
  5. Chart injection (v2):
  6. Detecta profile ativo via common.ini (ProfileLast=)
  7. Busca .chr apenas no profile ativo
  8. Se >1 charts com EA: remove extras
  9. Se 0 charts com EA: injeta no primeiro .chr
  10. Corrige InpWebAPIKey no .chr
  11. Restart MT5Start-Process terminal64.exe
  12. Cleanup — remove linniuc_update.dat + linniuc_update.lock

Se erro: Bloco catch restaura .ex5.bak, remove lock, reinicia MT5.


Staged Rollout

pending → testing (contas early) → stable (contas stable) → completed
                                           ↘ rolled_back
Fase Contas Quando avanca
testing Early (ids 3, 4 — TESTE) Ao clicar "Release"
stable Stable (ids 1, 2, 22-26) Automatico quando early adotam (monitor 30min)
rolled_back Nenhuma Manual via dashboard

Monitor de rollout (background, 30min)


Servidor — Deteccao de Loop

# heartbeat.py
if desired_clean == current_build:
    # Update concluido! Limpa desired_version
    account.desired_version = None
    account.update_attempts = 0
elif attempts >= 10:
    # Desistir: loop detectado
    account.desired_version = None
    alert_ea_update_failed(...)
else:
    # So conta tentativa se pos_count == 0
    # (Com posicoes = EA em AU_WAITING, nao e falha)
    if pos_count == 0:
        account.update_attempts += 1

Constantes

Constante Valor Onde
AU_WAIT_TIMEOUT 30 min AutoUpdate.mqh:50
AU_MAX_RETRIES 3 AutoUpdate.mqh:51
Lock expiry 5 min AutoUpdate.mqh
Check interval 30s AutoUpdate.mqh
Min file size 1000 bytes AutoUpdate.mqh
Max tentativas servidor 10 heartbeat.py
Rollout monitor 30 min (30s x 60) ea_update.py
Upload max size 50 MB ea_update.py
Double-check sleep 200ms AutoUpdate.mqh
Kill MT5 timeout 20s (10x 2s) PS1 script

Endpoints

Method Endpoint Acao
POST /api/ea/upload Upload .ex5 + version + changelog
POST /api/ea/release/{version}?force= Inicia rollout (testing → early)
POST /api/ea/rollback/{version} Reverte (limpa desired de todas contas)
GET /api/ea/versions Lista versoes com stage/downloads
GET /api/ea/rollout-status Status por conta (current vs desired)
GET /api/ea/download/{version} Serve .ex5 binario
DELETE /api/ea/versions/{version} Remove versao (so pending)

Tabela DB: ea_versions

Coluna Tipo Descricao
version varchar Ex: "3.25.0"
file_path varchar Caminho fisico no VPS
file_hash varchar SHA256
file_size int Bytes
changelog text Descricao
is_stable boolean Promovido?
is_active boolean Disponivel?
rollout_stage varchar pending/testing/stable/rolled_back
download_count int Total downloads
uploaded_at timestamp Data upload

Campos em accounts: update_group (early/stable), desired_version (ex: "b3.25.0"), update_attempts (0-10)


Armadilhas Conhecidas

# Armadilha Status
A1 Prefixo "b" em desired_version causa loop infinito Corrigido (fix_update_loop.py)
A2 SHA256 nunca verificado (antes 3.10.1) Corrigido
A3 Download parcial aceito Corrigido
A4 Loop cross-session (retryCount nao persiste) Corrigido (AU_SaveFailedVersion + cooldown 1h)
A5 Race condition .chr (multiplos EAs) Corrigido (lock + profile ativo)
A6 API key perdida no .chr apos update Corrigido (PS1 corrige)
A7 WS push nao inclui update_force Comportamento: assume force=false
A8 Versao-sonda do gate EA Tester (0.0.0-eagate-probe) INERTE by design. _compute_update_info incrementa update_attempts pra ela (sinal de deteccao pro gate) mas retorna ANTES de emitir update_available — o EA nunca a baixa/aplica. Parse (0,0) => auto-catchup do pool nunca a oferece. So-sandbox is_test. Ver specs/ea-tester-gate-contrato-v2.md (Onda O4).

Mudancas Pendentes (decisao do usuario, S73)

# Mudanca Estado atual Desejado
M1 Pular staged rollout Release faz early→stable automatico Release direto pra todos. Staged so quando solicitado
M2 Atualizar com posicoes abertas Espera 30min por 0 posicoes Atualiza direto. Usuario avisa se update afeta ordens
M3 Notificar falha do PS1 Restaura backup silenciosamente Enviar Telegram alert de falha
M4 Lock por terminal Lock global (bloqueia todos os terminais) Lock por terminal (paralelo entre terminais diferentes)
M5 Retry com backoff crescente Retry a cada heartbeat (~30s) sem backoff Backoff: 1min → 5min → 15min → 30min entre tentativas

Banco de Dados

Ultima revisao: S372 (2026-05-23) — DROP COLUMN accounts.prop_firm (era copia stale; Account.prop_firm agora @hybrid_property que deriva prop_firm_id->prop_firms.name). Anterior S294 (2026-04-25) — drift bookkeeping apos S288-S293 em server/models.py: schema base inalterado, snapshot prod em memory/prod-schema.json continua autoritativo. Validate via hook schema-drift-check em todo commit. Mudancas absorvidas: senha plain auto-delete 30d (security S272), modelo S267 sim-e2e (AfRound/AfPair/AfTrade ja documentados em outras specs). PG 15 -> 16 ja drift-fixed em S221. [allow-spec]

Conexao

PGPASSWORD=(ver .secrets.local) psql -U copytrade_user -h localhost -d copytrade

Tabelas (13 descritas a mao — ver a secao gerada no fim para as 54)

accounts

Contas MT5 registradas. Cada EA autentica via api_key.
| Coluna | Tipo | Default | Descricao |
|--------|------|---------|-----------|
| id | serial PK | auto | |
| name | varchar | | Nome amigavel |
| broker | varchar | | Nome da corretora |
| account_num | bigint UNIQUE | | Numero da conta MT5 |
| api_key | varchar | | Chave individual do EA |
| group_id | varchar | | Grupo de copy (ex: Grupo01, TESTE) |
| invert | boolean | | BUY<>SELL, SL<>TP |
| is_active | boolean | | EA ativo? |
| is_deleted | boolean | false | Soft delete |
| deleted_at | timestamp | | |
| created_at | timestamp | | |
| pool | varchar | '' | Pool de torneio |
| max_risk_pct | float | 2.0 | Risco maximo % |
| poll_interval | int | 3 | Segundos entre polls |
| sl_buffer | float | 0.0 | Buffer SL legado |
| tp_buffer | float | 0.0 | Buffer TP legado |
| sl_buffer_metals | float | 0.0 | Buffer SL metais |
| tp_buffer_metals | float | 0.0 | Buffer TP metais |
| sl_buffer_forex | float | 0.0 | Buffer SL forex |
| tp_buffer_forex | float | 0.0 | Buffer TP forex |
| sl_buffer_default | float | 0.0 | Buffer SL default |
| tp_buffer_default | float | 0.0 | Buffer TP default |
| buffer_mode | varchar | 'PIPS' | PIPS ou PRICE |
| settings_dirty | boolean | false | Pendente sync no EA |
| mt5_server | varchar | '' | Servidor MT5 reportado |
| account_type | varchar | 'undefined' | undefined/prop/normal/bonus (v2.71: default changed) |
| account_phase | varchar | '' | F1/F2/Funded/'' |
| bonus_pct | float | 0.0 | % bonus |
| prop_firm_id | int FK | NULL | S361 G.2 / S372: FK pra prop_firms.id (ON DELETE RESTRICT) — fonte UNICA do nome da mesa. Coluna copia prop_firm DROPADA em S372 (era stale apos rename); Account.prop_firm virou @hybrid_property que deriva prop_firm_obj.name (live). CHECK accounts_prop_firm_id_required_chk exige NOT NULL EXCETO se is_deleted=true OU is_test=true. Indice idx_accounts_prop_firm_id |
| prop_size | varchar | '' | 10k/25k/50k/100k/200k |
| prop_steps | varchar | '' | DEPRECATED S361: ainda existe pra compat legacy (af.py:608/3011/3035 + 5 callsites). Backfilled via FK lookup prop_firms.steps. DROP fisico pendente em TODO MED-04 (limpar refs Python primeiro) |
| update_group | varchar | 'stable' | early/stable (auto-update) |
| desired_version | varchar | | Versao desejada pra auto-update |
| update_attempts | int | 0 | Tentativas de update (max 5) |
| terminal_build | int | NULL | Build do MT5 (enviado 1x por sessao no HB). Mudanca dispara alerta Telegram (S265) |
| last_heartbeat_at | timestamp | NULL | Ultimo HB recebido (WS ou HTTP), atualizado SEM throttle (S314.3 Bug 3 fix Opcao C). Tabela heartbeats continua throttled em 30s. Helper is_account_alive() em account_filters.py. Spec: specs/heartbeat-last-seen.md |
| guard_lot_cap | real | NULL | S396 Parte 2: override do teto de lote da trava de seguranca POR CONTA. Precede a config da pool em get_guard_limits (override > pool > default); NULL = herda a pool. Vale ate sem pool (sandbox usa valor alto = frouxa). Migration S396_guard_overrides.sql. Spec: specs/blindagem-execucao-prop-firm.md |
| guard_max_concurrent | int | NULL | S396 Parte 2: override do max de posicoes simultaneas da trava POR CONTA. Precede a pool; NULL = herda |
| guard_max_rounds_per_day | int | NULL | S396 Parte 2: override do max de rodadas/dia da trava POR CONTA. Precede a pool; NULL = herda (0 = sem limite) |

signals

Sinais de trade (OPEN/MODIFY/CLOSE). Criados pelo EA ou broadcast do dashboard.
| Coluna | Tipo | Descricao |
|--------|------|-----------|
| id | serial PK | |
| group_id | varchar | Grupo destino |
| action | varchar | OPEN/MODIFY/CLOSE |
| ticket | bigint | Ticket MT5 de referencia |
| symbol | varchar | BTCUSD, EURUSD etc |
| direction | varchar | BUY/SELL |
| volume | float | Lotes |
| price | float | Preco de abertura |
| sl | float | Stop Loss |
| tp | float | Take Profit |
| source_account | int FK | Conta que originou |
| created_at | timestamp | |
| expires_at | timestamp | TTL |
| origin | varchar | 'ea' ou 'dashboard' |
| trade_group_id | varchar | UUID agrupador de trades relacionados |
| sl_distance | float | Distancia SL em preco |
| tp_distance | float | Distancia TP em preco |
| signal_channel | varchar(20) | Canal de envio (ws/poll/af/group_copy) |
| detection_method | varchar(30) | Metodo de deteccao (ex: af_push_modify, af_engine, group_copy, OTT, SE) |
| close_reason | varchar(20) | Motivo do close |
| queued_duration_ms | int | Tempo na fila (ms) |
| server_received_at | timestamp | Quando servidor recebeu |
| server_processing_ms | int | Tempo de processamento servidor (ms) |
| signal_source | varchar(16) | S219: 'manual' (default) ou 'test' |
| trace_id | varchar(32) | S224: W3C-inspired trace identifier (nullable pre-migration) |
| is_retroactive | boolean | S300: TRUE = signal CLOSE retroativo via historico MT5 (reconcile pos-offline). Index parcial UNIQUE(source_account, ticket, action) WHERE is_retroactive=TRUE garante idempotency (R6 da spec) |

signal_acks

Confirmacao de execucao de sinais pelos EAs.
| Coluna | Tipo | Descricao |
|--------|------|-----------|
| id | serial PK | |
| signal_id | int FK | Sinal confirmado |
| account_id | int FK | Conta que executou |
| status | varchar | ORIGIN/FILLED/FAILED/SKIPPED |
| local_ticket | bigint | Ticket local criado |
| error_msg | text | Mensagem de erro se FAILED |
| executed_at | timestamp | |
| open_price | float | Preco real de abertura |
| applied_sl | float | SL aplicado |
| applied_tp | float | TP aplicado |
| actual_volume | float | Volume real executado |
| receive_channel | varchar(20) | Canal de recebimento (ws/poll) |
| gui_duration_ms | int | Tempo de execucao GUI (ms) |
| total_delay_ms | int | Delay total sinal->execucao (ms) |
| trace_id | varchar(32) | S224: carrega mesmo trace_id do signal pai (nullable pre-migration) |
| ea_received_at_ms | bigint | S326: epoch ms (relogio do EA) de quando o EA recebeu o signal. Buffer circular em WebBridge.mqh + helper RecordSignalReceived. Permite medir latencia EA-side vs server-side |
| bid_at_request | float | S336 exec-slippage: bid no momento que EA pediu cotacao (pre-OrderSend). Base pra exec_slip_pts |
| ask_at_request | float | S336 exec-slippage: ask no momento que EA pediu cotacao (pre-OrderSend) |
| exec_slip_pts | float | S336 exec-slippage: slippage de execucao em pontos (open_price vs bid/ask_at_request). Mede slippage do broker no fill |
| pipeline_slip_pts | float | S336 exec-slippage: slippage de pipeline em pontos (preco do signal vs bid/ask_at_request). Mede atraso sinal->EA pedir cotacao |

signal_events (S224 — ea-observability spec)

Event sourcing do ciclo de vida de cada signal (append-only, PARTITIONED BY RANGE(ts_server) daily, 90d retention).
Timeline 1-click: SELECT * FROM signal_events WHERE signal_id=X ORDER BY seq_num.
| Coluna | Tipo | Descricao |
|--------|------|-----------|
| id | bigserial PK composto | Parte do PK (id + ts_server exigido pela particao) |
| signal_id | int FK signals | Signal dono do evento |
| trace_id | varchar(32) | W3C-inspired 128-bit hex, nullable pra backward-compat |
| account_id | int | Conta (nao FK pra permitir conta deletada) |
| event_type | varchar(32) | signal_created, ea_received, gui_s1_ok..s11_ok, ack_sent, ack_failed |
| seq_num | smallint | Per-signal 1,2,3... ordem dentro do signal |
| ts_ea | timestamptz | Timestamp origem no EA (nullable) |
| ts_server | timestamptz PK composto | Quando chegou no servidor (chave de particao) |
| payload | jsonb | Contexto rico (error, duration, SL/TP aplicado, etc) |

Indexes (propagados automaticamente pras particoes):
- (signal_id, seq_num) — timeline query principal
- trace_id partial WHERE IS NOT NULL — correlacao cross-signal
- (event_type, ts_server) — stats por tipo no periodo

UNIQUE constraint (idempotencia R7): (signal_id, event_type, seq_num, ts_server) — permite retry ON CONFLICT DO NOTHING.

Particionamento: 7 particoes pre-criadas (CURRENT_DATE..+6d) + signal_events_default safety net. Cron scripts/cron/purge_signal_events.sh cria ahead + DROP >90d + VACUUM.

Vinculo entre trades copiados. Permite propagar CLOSE/MODIFY.
| Coluna | Tipo | Descricao |
|--------|------|-----------|
| id | serial PK | |
| trade_group_id | varchar | UUID do grupo |
| signal_id | int FK | Sinal que criou |
| account_id | int FK | Conta dona |
| local_ticket | bigint | Ticket MT5 |
| is_origin | boolean | true = quem originou |
| is_closed | boolean | false | Trade fechado? |
| created_at | timestamp | |
| closed_at | timestamp | |
| close_price | float | Preco de fechamento |
| profit | float | P&L |
| close_provisional | boolean NOT NULL default false | S267: fechamento via snapshot do ultimo HB (peer estava offline). Removido quando ACK WS traz deal_price/deal_profit reais (Fase B Caso 1). |

Indexes: idx_tl_tgid, idx_tl_account_ticket, idx_tl_close_provisional (parcial: WHERE close_provisional = true — S267)

pending_signals

Fila de sinais aguardando execucao. TTL default 60s (PENDING_SIGNAL_TTL_SECONDS). TTL 86400s (24h) quando awaiting_peer_return=True (S267 PENDING_TTL_PEER_OFFLINE).
| Coluna | Tipo | Descricao |
|--------|------|-----------|
| id | serial PK | |
| account_id | int FK | Conta destino |
| symbol | varchar | |
| direction | varchar | BUY/SELL |
| volume | float NOT NULL | |
| signal_id | int FK | |
| trade_group_id | varchar | |
| expires_at | timestamp NOT NULL | TTL 60s default, 24h se awaiting_peer_return |
| resolved | boolean | false (true = ACK de execucao recebido) |
| ws_received_at | timestamp | EA flush_ack via WS (NULL = sem confirmacao, polling HTTP cobre) |
| awaiting_peer_return | boolean NOT NULL default false | S267: true no CLOSE origin='ea' quando peer stale. TTL 24h. Cleanup main.py Block 11. |
| created_at | timestamp | |

Indexes: idx_ps_account_symbol, idx_ps_tgid, idx_ps_ws_ack_missing (parcial: ws_received_at IS NULL AND resolved=false)

suppress_markers

Anti-echo para MODIFY/CLOSE. TTL 10 segundos.
| Coluna | Tipo | Descricao |
|--------|------|-----------|
| id | serial PK | |
| trade_group_id | varchar | |
| action | varchar | MODIFY/CLOSE |
| expires_at | timestamp | TTL 10s |
| created_at | timestamp | |

open_positions

Snapshot de posicoes abertas, atualizado via heartbeat.
| Coluna | Tipo | Descricao |
|--------|------|-----------|
| id | serial PK | |
| account_id | int FK | |
| ticket | bigint | |
| symbol | varchar | |
| direction | varchar | |
| volume | float | |
| open_price | float | |
| current_price | float | |
| sl | float | |
| tp | float | |
| profit | float | |
| pips | float | |
| swap | float | |
| magic | bigint | |
| open_time | varchar | |
| updated_at | timestamp | |
| tick_value | float | Valor do tick |
| tick_size | float | Tamanho do tick |
| spread | float | Spread em valor de preco |
| commission | float | Comissao total dos deals |
| profit_at_sl | float | P&L projetado se bater SL (OrderCalcProfit) |
| profit_at_tp | float | P&L projetado se bater TP (OrderCalcProfit) |

heartbeats

Heartbeats dos EAs (balance, equity, posicoes). Tabela grande (~20MB).
| Coluna | Tipo | Descricao |
|--------|------|-----------|
| id | serial PK | |
| account_id | int FK | |
| balance | float | |
| equity | float | |
| margin | float | |
| free_margin | float | |
| positions | int | Quantidade |
| server_time | varchar | Hora do servidor MT5 |
| ea_version | varchar | Build do EA |
| created_at | timestamp | |
| applied_settings | jsonb | Settings aplicados pelo EA |

ea_remote_logs

Logs persistidos dos EAs. TTL 7 dias.
| Coluna | Tipo | Descricao |
|--------|------|-----------|
| id | serial PK | |
| account_id | int FK | |
| level | varchar | 'INFO' |
| message | text | |
| created_at | timestamp | now() |

ea_versions

Versoes do EA para auto-update.
| Coluna | Tipo | Descricao |
|--------|------|-----------|
| id | serial PK | |
| version | varchar | Ex: 3.3.4 |
| file_path | varchar | Caminho do .ex5 |
| file_hash | varchar | SHA256 |
| file_size | int | |
| changelog | text | |
| is_stable | boolean | |
| is_active | boolean | |
| rollout_stage | varchar | pending/early/stable |
| download_count | int | |
| uploaded_at | timestamp | |
| stable_at | timestamp | |
| force_update | boolean | false | Forcar update mesmo com posicoes abertas |
| soak_passed | boolean | S422: bateria EA Tester PASS (gate _validate_soak_passed exige True pra stable) |
| soak_passed_hash | varchar(64) | Onda C: SHA256 do binario que estava verde; == file_hash pra valer |
| soak_passed_at | timestamp | Frente A furo #4: quando o soak foi carimbado (proveniencia) |
| soak_passed_by | varchar(50) | Frente A furo #4: quem carimbou (username do JWT) |

equity_snapshots

Snapshots periodicos de equity por conta.
| Coluna | Tipo | Descricao |
|--------|------|-----------|
| id | serial PK | |
| account_id | int FK | |
| balance | float | |
| equity | float | |
| margin | float | |
| free_margin | float | |
| positions | int | |
| snapshot_at | timestamp | now() |

symbol_info

Info de simbolos reportada pelos EAs (tick_value, tick_size, etc).
| Coluna | Tipo | Descricao |
|--------|------|-----------|
| id | serial PK | |
| account_id | int FK | |
| symbol | varchar | |
| tick_value | float | |
| tick_size | float | |
| contract_size | float | |
| digits | int | |
| point | float | |
| volume_min | float | 0.01 |
| volume_max | float | 100.0 |
| volume_step | float | 0.01 |
| spread | float | Spread atual |
| bid | float | Preco bid |
| ask | float | Preco ask |
| swap_long | float | Swap compra |
| swap_short | float | Swap venda |
| updated_at | timestamp | now() |

todos

Lista de pendencias do projeto (fonte unica de verdade). Kanban do site e todos.md sao "janelas" pra esta tabela.
| Coluna | Tipo | Default | Descricao |
|--------|------|---------|-----------|
| id | serial PK | auto | |
| title | varchar(200) | | Titulo da tarefa |
| description | text | '' | Descricao detalhada |
| priority | varchar(10) | 'media' | alta/media/baixa |
| status | varchar(10) | 'backlog' | backlog/next/doing/done |
| tags | text[] | '{}' | Tags (ex: bug, feature, infra) |
| created_at | timestamp | now() | |
| updated_at | timestamp | now() | |
| completed_at | timestamp | null | Preenchido quando status=done |

prop_firms (v2.71, expandida S161)

Prop firms com regras completas. SSoT para AF engine e classificacao de contas.
| Coluna | Tipo | Default | Descricao |
|--------|------|---------|-----------|
| id | serial PK | auto | |
| name | varchar(100) UNIQUE | | Nome Desafio (label UI S370): identidade unica da mesa (FTMO Swing, FundedNext). Chave de busca lower(trim(name)) + exibicao. So o rotulo da UI mudou (Nome -> Nome Desafio); coluna name inalterada |
| company | varchar(100) NOT NULL | '' | S362: Empresa (companhia, ex: "FTMO"). R7 pareia comparando company, nao name — 2 desafios da mesma Empresa nunca pareiam num hedge. Backfill company=name. CRUD obrigatorio (datalist) |
| steps | varchar(20) | '2-step' | S361 G.2: renomeada de default_steps. CHECK constraint prop_firms_steps_chk enforced em {'1-step','2-step','3-step','instant','sem-fase'} |
| phases | JSON | [] | Fases disponiveis (F1, F2, Funded) |
| sizes | JSON | [] | Tamanhos disponiveis (10k, 25k, etc) |
| is_builtin | boolean | false | Se veio pre-cadastrada (seed) |
| created_at | timestamp | now() | |
| price | int | 0 | Preco USD (referencia 100k) |
| leverage | varchar(20) | '' | Alavancagem (ex: 1:100) |
| max_dd | float | 10.0 | Max DD % (estatico, base saldo inicial) |
| daily_dd | float | 5.0 | Daily DD % |
| daily_dd_type | varchar(10) | 'equity' | 'equity' ou 'balance' |
| target_f1 | float | 8.0 | Meta F1 % |
| target_f2 | float | 5.0 | Meta F2 % |
| min_days_f1 | int | 0 | Dias minimos trading F1 |
| min_days_f2 | int | 0 | Dias minimos trading F2 |
| min_profit_days | int | 0 | Dias lucrativos obrigatorios |
| max_risk | int | 5000 | Risco operacional por trade USD (100k ref) |
| daily_dd_op | int | 4500 | DD operacional diario USD (100k ref) |
| news_eval | varchar(100) | '' | Restricoes noticias avaliacao |
| news_funded | varchar(100) | '' | Restricoes noticias funded |
| limitation | varchar(200) | '--' | Limitacoes especiais |
| has_200k | boolean | false | Tem conta 200k? |
| is_active | boolean | true | Ativa no sistema? |
| is_af_eligible | boolean | true | Elegivel pro AF engine? (auto: risk>=2500 AND dd>=10) |
| min_hold_min_s | int NOT NULL | 0 | S422 (perna-orfa-fecho-blindado): piso de min-hold da MESA em segundos (minimo do range). Tempo minimo que a posicao fica aberta antes de poder fechar (mesas rigidas tratam fecho <5min como breach). >0 vence a hierarquia mesa>pool>global. 0 = mesa nao impoe piso. Entra no config_snapshot do round |
| min_hold_max_s | int NOT NULL | 0 | S422: maximo do range de min-hold (sorteio [min,max] por par, persistido em risk_detail.min_hold_until) |
| updated_at | timestamp | null | Ultima atualizacao |

account_resets (v2.71)

Historico de resets de classificacao de conta.
| Coluna | Tipo | Default | Descricao |
|--------|------|---------|-----------|
| id | serial PK | auto | |
| account_id | int FK | | Conta resetada |
| reset_at | timestamp | now() | Quando |
| pool_id | int | null | Pool de onde saiu |
| pool_name | varchar(100) | '' | Nome da pool (snapshot) |
| previous_type | varchar(20) | '' | Tipo anterior |
| previous_firm | varchar(100) | '' | Prop firm anterior |
| previous_phase | varchar(20) | '' | Fase anterior |

Tabelas auxiliares

users — Usuarios do dashboard (username, password_hash, display_name, is_active, timezone)
- timezone varchar(64) NOT NULL DEFAULT 'America/Campo_Grande' — fuso de LEITURA (so exibicao).
Nunca entra na decisao do robo: janela de operacao e swap vem da cascata do servidor.
user_sessions — Sessoes ativas (username, ip, user_agent, login_at, last_activity, request_count)
login_attempts — Tentativas de login (username_tried, password_tried, ip, success, fail_reason)
page_visits — Visitas a paginas (ip, path, user_agent, username, visited_at)
audit_log — Log de acoes (user, action, detail, created_at)
ip_whitelist — IPs permitidos (ip_address, description, is_active)

Tabelas AF v2 (S78)

af_pools — Pools AF v2 (name, mode varchar(20) default 'live' LEGACY, validated bool default false, symbol, status, spread_pct, trade_interval_sec, trade_timeout_sec, group_id, config JSONB, created_at, updated_at). S170: mode é legacy (código não lê mais). validated controla se pool pode operar.
af_pool_accounts — Contas no pool com prop atribuida (pool_id FK, account_id FK nullable UNIQUE (uq_account_one_pool — 1 conta = 1 pool), prop_name, prop_config JSONB, phase, virtual_balance, profitable_days, trading_days, status active/dead/funded/paused/passed, chair_number, transition_at, daily_pnl, daily_pnl_peak, direction_history JSONB default '[]' — ultimas 10 direcoes executadas pro anti-OSB, created_at, updated_at). S207: prop_config agora inclui snapshot: owner, account_name, account_type, original_group_id. Cadeiras passed/dead com account_id=NULL usam prop_config pra display historico (nome, cor, badge). virtual_balance serve como saldo congelado.
af_rounds — Rodadas de trading (pool_id FK, round_number, status pending/executing/completed/failed, pairs_count, started_at, completed_at, summary JSONB, config_snapshot JSONB nullable — S306 ADR-0011: snapshot imutavel do pool.config quando round foi criado; lida via app.af.config_helper.get_round_config(round, pool) durante execucao; NULL em rounds legacy → fallback pra pool.config; created_at)
af_pairs — Pares/batalhas dentro de rodada (round_id FK, pool_id FK, account_a_id FK, account_b_id FK CHECK(a != b — chk_pair_diff_accounts), risk_usd, risk_detail JSONB, symbol, direction_a, volume, sl_price/tp_price colunas SQL legadas — guardam half-width do colchao (distancia em points, NAO preco absoluto); Python/API/dashboard acessam como sl_distance/tp_distance via alias no model AfPair (S257); precos absolutos REAIS ficam em af_trades.sl_price/tp_price, scheduled_at, status, winner_pool_account_id FK, profit_a, profit_b, spread_cost, created_at, completed_at)
af_trades — Trades individuais (pair_id FK, pool_account_id FK, account_id FK, signal_id FK, direction, volume, sl/tp/open/close price, profit, status, local_ticket bigint)
af_audit_log — Log imutavel INSERT-only (pool_id FK, action, entity_type, entity_id, detail text)
af_dead_letters — Dead Letter Queue: sinais AF que falharam (pool_id FK, pair_id, account_id, prop_name, failure_type varchar(50), reason text, attempts int, context JSONB). Tipos: timeout, margin, rsafe, invert, e7_exhausted, gui_fail
symbol_presets — Presets de configuracao por simbolo (id PK, symbol varchar(20) UNIQUE, config JSONB, spread_pct float, created_at, updated_at). Campos preset: sl_min/max, dz_min/max, rsafe2_price_gate, modify_margin_min/max, push_buffer_usd. Defaults hardcoded em server/af/presets.py (XAUUSD, BTCUSD, _default).

trade_events (S128)

Diario de bordo: cada evento na vida de uma posicao. Append-only, cleanup 90 dias.
| Coluna | Tipo | Descricao |
|--------|------|-----------|
| id | bigserial PK | |
| created_at | timestamptz | Quando o evento foi registrado |
| event_type | varchar(30) | OPEN, MODIFY, CLOSE, PROPAGATE, ACK |
| account_id | int FK accounts | Conta envolvida |
| ticket | bigint | Ticket MT5 |
| trade_group_id | varchar(64) | Trade group (ex: TG_xxx, AF_P123) |
| signal_id | int | Signal que gerou o evento |
| origin | varchar(40) | ea, server_cleanup, server_auto_close, ws_detect, heartbeat_gone, admin_reconcile, af_orphan_cleanup |
| close_reason | varchar(30) | Motivo do close (se aplicavel) |
| price | float | Preco da operacao |
| profit | float | P&L capturado |
| volume | float | Volume em lots |
| context | jsonb | Dados extras (symbol, direction, reason, etc) |
Indices: trade_group_id, (account_id, created_at), (event_type, created_at), created_at
Endpoints: GET /api/af/trade-events/pair/{pair_id}, GET /api/af/trade-events/account/{account_id}, GET /api/af/trade-events/{trade_group_id}

event_stream (S243 — Telemetria Sweetspot v8)

Append-only. Todo evento telemetrico do EA aterriza aqui. Dedupe via event_id UNIQUE. Retencao 30 dias (D5).
| Coluna | Tipo | Descricao |
|--------|------|-----------|
| id | serial PK | |
| event_id | varchar(64) UNIQUE | ID deterministico gerado pelo EA (hash position_id+deal_time+symbol) |
| trace_id | varchar(32) | W3C Trace Context 32-hex. Auto-enriquecido pelo server se ausente (I12) |
| account_id | int FK accounts NULL | Conta relacionada (nullable pra eventos globais) |
| event_type | varchar(32) | signal_received, order_sent, order_rejected, gap_alert, etc |
| ts_broker | timestamp | Quando o EA observou o evento no broker |
| ts_received | timestamp | Quando o servidor recebeu (auto-preenchido) |
| gap_ms | int | ts_received - ts_broker em ms. late=true se > 5000 (I1) |
| payload | jsonb | Dados do evento (slip_pts, latency_ms, spread_pts, etc) |
| late | bool | gap_ms > 5000ms (R1) |
| clock_offset_ms | int NULL | Offset de clock reportado pelo EA |
| ea_instance_id | varchar(64) NULL | Identificador da instancia do EA |
| created_at | timestamp | Insert time |
Indices: trace_id, account_id, event_type, ts_received, (account_id, ts_received DESC)
Endpoints: POST /api/telemetry/events, GET /api/telemetry/trace/{trace_id}, GET /api/telemetry/anomalies, GET /admin/health

event_outbox (S243 — Telemetria Sweetspot v8)

Worker queue simples via Postgres NOTIFY/LISTEN. Trigger trg_event_outbox_notify dispara pg_notify('event_outbox_new', id) em cada insert.
| Coluna | Tipo | Descricao |
|--------|------|-----------|
| id | serial PK | |
| event_stream_id | int FK event_stream | Evento original |
| published | bool | Worker marca true apos processar |
| attempts | int | Retries do worker |
| last_error | text NULL | Ultima msg de erro se worker falhou |
| created_at | timestamp | |
| published_at | timestamp NULL | Quando worker marcou published |
Indices: (published WHERE published=false) partial, event_stream_id

Role telemetry_ro (S243 — I7 enforcement mecanico)

Role Postgres NOLOGIN com SELECT only em signals, signal_acks, open_positions, af_trades, event_stream, event_outbox. INSERT/UPDATE/DELETE revogados explicitamente. Garante que pipeline de telemetria nao corrompe tabelas de trading mesmo com bug de codigo (I7). Worker e dashboard de telemetria DEVEM usar essa role.

Tabelas geradas do codigo (2026-08-08)

As 41 secoes abaixo foram GERADAS de server/models.py, que e' a fonte da verdade do schema.
Ate 2026-08-08 este documento descrevia 13 tabelas e o cabecalho anunciava 24, enquanto
o codigo tinha 54 — 41 tabelas inteiras nunca chegaram aqui, incluindo af_pools,
af_pool_accounts, prop_firms, terminals e as do simulador. A lacuna apareceu quando um
fiscal me cobrou provar que desired_version era coluna de accounts: a coluna estava, mas as
tabelas de pool que eu ia citar em seguida, nao.

A coluna Descricao sai VAZIA quando o codigo nao tem comentario na linha. Documentacao
gerada que adivinha o proposito de um campo e' pior que documentacao ausente — ela tem cara de
autoridade. Quem souber o proposito, escreva; o resto fica em branco, honesto.

Regenerar: o gerador le server/models.py com ast e imprime as secoes que faltam. A rede
que impede a lacuna de voltar e' scripts/tests/test_db_schema_cobre_todas_as_tabelas.py.

signal_inversions

Coluna Tipo Default Extras Descricao
id Integer PK
signal_id Integer ForeignKey('signals.id'), index
account_id Integer ForeignKey('accounts.id'), NOT NULL
reason String(30) NOT NULL
signal_type String(10) NOT NULL
original_direction String(10) ''
inverted_direction String(10) ''
original_sl Float 0
original_tp Float 0
inverted_sl Float 0
inverted_tp Float 0
created_at DateTime datetime.utcnow index

health_suggestions

Ficha do Guarda de Saude da Pool (spec: guarda-de-saude-pool.md).
| Coluna | Tipo | Default | Extras | Descricao |
|--------|------|---------|--------|-----------|
| id | Integer | | PK | |
| account_id | Integer | | ForeignKey('accounts.id'), NOT NULL | |
| signal_type | String(20) | | NOT NULL | |
| severity | String(10) | | NOT NULL | weak | strong |
| status | String(16) | 'open' | NOT NULL | |
| channel_sent | Boolean | False | NOT NULL | |
| evidence | Text | | | JSON serializado (str) |
| first_seen | DateTime | datetime.utcnow | | |
| last_seen | DateTime | datetime.utcnow | | |
| snooze_until | DateTime | | | |
| created_at | DateTime | datetime.utcnow | | |

users

Usuario de login do dashboard (admin/zwrt/test_runner).
| Coluna | Tipo | Default | Extras | Descricao |
|--------|------|---------|--------|-----------|
| id | Integer | | PK, index | |
| username | String(50) | | UNIQUE, NOT NULL | |
| password_hash | String(200) | | NOT NULL | |
| display_name | String(100) | | NOT NULL | |
| is_active | Boolean | True | | |
| created_at | DateTime | func.now() | | |
| timezone | String(64) | 'America/Campo_Grande' | NOT NULL | |

audit_log

Coluna Tipo Default Extras Descricao
id Integer PK, index
user String(50) NOT NULL
action String(50) NOT NULL
detail String(500) ''
created_at DateTime func.now()

user_sessions

Coluna Tipo Default Extras Descricao
id Integer PK
username String(50) NOT NULL
display_name String(100) ''
ip_address String(50) ''
user_agent String(500) ''
login_at DateTime datetime.utcnow
last_activity DateTime datetime.utcnow
request_count Integer 0

page_visits

Coluna Tipo Default Extras Descricao
id Integer PK
ip_address String(50) NOT NULL
path String(200) '/'
user_agent String(500) ''
username String(50) ''
visited_at DateTime datetime.utcnow

login_attempts

Registra TODAS as tentativas de login (sucesso e falha).
| Coluna | Tipo | Default | Extras | Descricao |
|--------|------|---------|--------|-----------|
| id | Integer | | PK | |
| username_tried | String(100) | | NOT NULL | |
| password_tried | String(100) | '' | | |
| ip_address | String(50) | '' | | |
| user_agent | String(500) | '' | | |
| success | Boolean | False | | |
| fail_reason | String(100) | '' | | |
| created_at | DateTime | datetime.utcnow | | |

ip_whitelist

IPs autorizados a acessar o dashboard. Whitelist vazia = permite todos.
| Coluna | Tipo | Default | Extras | Descricao |
|--------|------|---------|--------|-----------|
| id | Integer | | PK, index | |
| ip_address | String(45) | | NOT NULL | suporta IPv6 |
| description | String(200) | '' | | |
| created_at | DateTime | datetime.utcnow | | |
| is_active | Boolean | True | | |

signal_events

Event log append-only do ciclo de vida de cada signal.
| Coluna | Tipo | Default | Extras | Descricao |
|--------|------|---------|--------|-----------|
| id | BigInteger | | PK | |
| signal_id | Integer | | ForeignKey('signals.id'), NOT NULL | |
| trace_id | String(32) | | | |
| account_id | Integer | | NOT NULL | |
| event_type | String(32) | | NOT NULL | |
| seq_num | SmallInteger | | NOT NULL | |
| ts_ea | DateTime(timezone=True) | | | |
| ts_server | DateTime(timezone=True) | datetime.utcnow | NOT NULL | |
| payload | JSONB | dict | NOT NULL | |

af_pools

Pool de contas para o sistema Advancing Front v2.
| Coluna | Tipo | Default | Extras | Descricao |
|--------|------|---------|--------|-----------|
| id | Integer | | PK | |
| name | String(100) | | NOT NULL | |
| mode | String(20) | 'live' | | Legacy — code no longer reads this |
| symbol | String(20) | 'XAUUSD' | | |
| status | String(20) | 'active' | | |
| spread_pct | Float | 2.0 | | |
| trade_interval_sec | Integer | 300 | | |
| trade_timeout_sec | Integer | 900 | | |
| group_id | String(50) | | NOT NULL | |
| config | JSONB | {} | | |
| max_risk_f1_pct | Float | 2.5 | NOT NULL | |
| max_risk_f2_pct | Float | 2.0 | NOT NULL | |
| max_risk_f3_pct | Float | 1.0 | NOT NULL | |
| dead_zone_min_pct | Float | 0.5 | NOT NULL | |
| dead_zone_max_pct | Float | 1.0 | NOT NULL | |
| created_at | DateTime | datetime.utcnow | | |
| updated_at | DateTime | datetime.utcnow | | |
| phase | SmallInteger | | | |
| model_type | String(20) | | | ex '2-step' — valida ligacao por tipo |
| account_size | Integer | | | USD — valida ligacao por tamanho |
| next_pool_id | Integer | | ForeignKey('af_pools.id', ondelete='SET NULL') | |

symbol_presets

Preset de configuracao por simbolo (ex: XAUUSD, BTCUSD).
| Coluna | Tipo | Default | Extras | Descricao |
|--------|------|---------|--------|-----------|
| id | Integer | | PK | |
| symbol | String(20) | | NOT NULL, UNIQUE | |
| config | JSONB | {} | | |
| spread_pct | Float | | | |
| created_at | DateTime | datetime.utcnow | | |
| updated_at | DateTime | datetime.utcnow | | |

af_pool_accounts

Conta vinculada a um pool AF com prop firm atribuida.
| Coluna | Tipo | Default | Extras | Descricao |
|--------|------|---------|--------|-----------|
| id | Integer | | PK | |
| pool_id | Integer | | ForeignKey('af_pools.id'), NOT NULL | |
| account_id | Integer | | ForeignKey('accounts.id') | |
| prop_name | String(50) | | NOT NULL | |
| prop_config | JSONB | | NOT NULL | |
| phase | Integer | 1 | | |
| profitable_days | Integer | 0 | | |
| trading_days | Integer | 0 | | |
| status | String(20) | 'active' | | |
| chair_number | Integer | | NOT NULL | |
| transition_at | DateTime | | | |
| daily_pnl | Float | 0.0 | | |
| daily_pnl_peak | Float | 0.0 | | |
| direction_history | JSONB | [] | | |
| created_at | DateTime | datetime.utcnow | | |
| updated_at | DateTime | datetime.utcnow | | |
| created_from_zombie_id | Integer | | ForeignKey('af_pool_accounts.id', ondelete='SET NULL') | |
| recycle_reason | Text | | | S370: alinhado a prod (text) — drift S360 |
| journey_id | Integer | | ForeignKey('account_journeys.id', ondelete='SET NULL') | |

af_rounds

Rodada AF — 1 rodada = 1 dia em live, varias em demo.
| Coluna | Tipo | Default | Extras | Descricao |
|--------|------|---------|--------|-----------|
| id | Integer | | PK | |
| pool_id | Integer | | ForeignKey('af_pools.id'), NOT NULL | |
| round_number | Integer | | NOT NULL | |
| status | String(20) | 'pending' | | |
| pairs_count | Integer | 0 | | |
| started_at | DateTime | | | |
| completed_at | DateTime | | | |
| summary | JSONB | {} | | |
| config_snapshot | JSONB | | | |
| created_at | DateTime | datetime.utcnow | | |

af_pairs

Par de batalha dentro de uma rodada AF.
| Coluna | Tipo | Default | Extras | Descricao |
|--------|------|---------|--------|-----------|
| id | Integer | | PK | |
| round_id | Integer | | ForeignKey('af_rounds.id'), NOT NULL | |
| pool_id | Integer | | ForeignKey('af_pools.id'), NOT NULL | |
| account_a_id | Integer | | ForeignKey('af_pool_accounts.id') | |
| account_b_id | Integer | | ForeignKey('af_pool_accounts.id') | |
| risk_usd | Float | | NOT NULL | |
| risk_detail | JSONB | {} | | |
| symbol | String(20) | | NOT NULL | |
| direction_a | String(10) | | NOT NULL | |
| volume | Float | | | |
| sl_distance | Float | | | |
| tp_distance | Float | | | |
| scheduled_at | DateTime | | | |
| status | String(20) | 'scheduled' | | |
| winner_pool_account_id | Integer | | ForeignKey('af_pool_accounts.id') | |
| profit_a | Float | | | |
| profit_b | Float | | | |
| hedge_cost | Float | | | |
| created_at | DateTime | datetime.utcnow | | |
| completed_at | DateTime | | | |
| owner_snapshot_json | JSONB | | | |
| recovery_phase | String(20) | 'none' | NOT NULL | |
| closing_committed_at | DateTime | | | |

af_trades

Trade individual AF — cada par gera 2 trades.
| Coluna | Tipo | Default | Extras | Descricao |
|--------|------|---------|--------|-----------|
| id | Integer | | PK | |
| pair_id | Integer | | ForeignKey('af_pairs.id'), NOT NULL | |
| pool_account_id | Integer | | ForeignKey('af_pool_accounts.id'), NOT NULL | |
| account_id | Integer | | ForeignKey('accounts.id'), NOT NULL | |
| signal_id | Integer | | ForeignKey('signals.id') | |
| direction | String(10) | | NOT NULL | |
| volume | Float | | | |
| sl_price | Float | | | |
| tp_price | Float | | | |
| open_price | Float | | | |
| close_price | Float | | | |
| profit | Float | | | |
| status | String(20) | 'pending' | | |
| local_ticket | BigInteger | | | |
| opened_at | DateTime | | | |
| closed_at | DateTime | | | |
| created_at | DateTime | datetime.utcnow | | |

af_audit_log

Log imutavel de todas as acoes AF.
| Coluna | Tipo | Default | Extras | Descricao |
|--------|------|---------|--------|-----------|
| id | Integer | | PK | |
| pool_id | Integer | | ForeignKey('af_pools.id') | |
| action | String(50) | | NOT NULL | |
| entity_type | String(30) | | | |
| entity_id | Integer | | | |
| detail | Text | | | |
| created_at | DateTime | datetime.utcnow | | |

account_journeys

Etiqueta de jornada (spec metricas-fonte-unica-linhagem): correlation id que costura
| Coluna | Tipo | Default | Extras | Descricao |
|--------|------|---------|--------|-----------|
| id | Integer | | PK | = journey_id |
| prop_firm_id | Integer | | ForeignKey('prop_firms.id') | |
| owner_id | Integer | | | |
| state | String(24) | 'in_f1' | NOT NULL | prod=NOT NULL (add_account_journeys.sql) |
| is_simulator | Boolean | False | NOT NULL | P53: real e sim NUNCA se misturam |
| died_at_phase | SmallInteger | | | |
| opened_at | DateTime | datetime.utcnow | | |
| closed_at | DateTime | | | |
| updated_at | DateTime | datetime.utcnow | | |

journey_events

Diario ESTRUTURADO da jornada (abordagem B do brainstorm): cada acontecimento com
| Coluna | Tipo | Default | Extras | Descricao |
|--------|------|---------|--------|-----------|
| id | Integer | | PK | |
| journey_id | Integer | | ForeignKey('account_journeys.id', ondelete='SET NULL') | |
| account_id | Integer | | | |
| pool_id | Integer | | | |
| round_id | Integer | | | |
| event_type | String(20) | | NOT NULL | |
| phase | SmallInteger | | NOT NULL | |
| prop_name | String(50) | | | |
| chair_number | Integer | | | |
| amount | Float | | | |
| reason | String(20) | | | |
| is_simulator | Boolean | False | NOT NULL | LOW-03: NOT NULL (= migracao prod) |
| occurred_t | Float | | | |
| created_at | DateTime | datetime.utcnow | | |

af_dead_letters

Dead Letter Queue — sinais AF que falharam todas tentativas.
| Coluna | Tipo | Default | Extras | Descricao |
|--------|------|---------|--------|-----------|
| id | Integer | | PK | |
| pool_id | Integer | | ForeignKey('af_pools.id') | |
| pair_id | Integer | | | |
| account_id | Integer | | | |
| prop_name | String(50) | | | |
| failure_type | String(50) | | NOT NULL | timeout, margin, rsafe, invert, gui_fail |
| reason | Text | | | |
| attempts | Integer | 1 | | |
| context | JSONB | | | dados extras: preço, volume, drift, etc |
| created_at | DateTime | datetime.utcnow | | |

trade_events

Diario de bordo: cada evento na vida de uma posicao (open, modify, close, propagate, ack).
| Coluna | Tipo | Default | Extras | Descricao |
|--------|------|---------|--------|-----------|
| id | BigInteger | | PK | |
| created_at | DateTime(timezone=True) | datetime.utcnow | NOT NULL | |
| event_type | String(30) | | NOT NULL | |
| account_id | Integer | | ForeignKey('accounts.id') | |
| ticket | BigInteger | | | |
| trade_group_id | String(64) | | | |
| signal_id | Integer | | | |
| origin | String(40) | | | |
| close_reason | String(30) | | | |
| price | Float | 0 | | |
| profit | Float | 0 | | |
| volume | Float | 0 | | |
| context | JSONB | {} | | |

prop_firms

Prop firms cadastradas (FTMO, FundedNext, etc). Builtins + custom.
| Coluna | Tipo | Default | Extras | Descricao |
|--------|------|---------|--------|-----------|
| id | Integer | | PK | |
| name | String(100) | | UNIQUE, NOT NULL | |
| company | String(100) | '' | NOT NULL | |
| steps | String(20) | '2-step' | | |
| phases | JSON | [] | | |
| sizes | JSON | [] | | |
| is_builtin | Boolean | False | | |
| created_at | DateTime | datetime.utcnow | | |
| price | Integer | 0 | | |
| leverage | String(20) | '' | | |
| max_dd | Float | 10.0 | | |
| payout | Float | 0.8 | NOT NULL | |
| daily_dd | Float | 5.0 | | |
| daily_dd_type | String(10) | 'equity' | | |
| target_f1 | Float | 8.0 | | |
| target_f2 | Float | 5.0 | | |
| min_days_f1 | Integer | 0 | | |
| min_days_f2 | Integer | 0 | | |
| min_profit_days | Integer | 0 | | |
| max_risk_pct | Float | 5.0 | NOT NULL | |
| daily_dd_op_pct | Float | 4.5 | NOT NULL | |
| min_hold_min_s | Integer | 0 | NOT NULL | |
| min_hold_max_s | Integer | 0 | NOT NULL | |
| limitation | String(200) | '--' | | |
| has_200k | Boolean | False | | |
| is_active | Boolean | True | | |
| is_af_eligible | Boolean | True | | |
| is_simulator | Boolean | False | NOT NULL | |
| max_dd_style | String(20) | 'static' | NOT NULL | |
| daily_dd_style | String(20) | 'static' | NOT NULL | |
| news_phases | JSON | list | | |
| news_minutes_before | Integer | 0 | NOT NULL | |
| news_minutes_after | Integer | 0 | NOT NULL | |
| news_penalty | String(300) | '' | NOT NULL | |
| news_notes | String(300) | '' | NOT NULL | |
| updated_at | DateTime | | | |

account_owners

Dono real de contas (Linniu, Lucas, etc) — entidade de perfil rico.
| Coluna | Tipo | Default | Extras | Descricao |
|--------|------|---------|--------|-----------|
| id | Integer | | PK | |
| name | String(100) | | NOT NULL | |
| color | String(20) | '#58a6ff' | | 58a6ff") # hex color for UI |
| created_at | DateTime | datetime.utcnow | | |
| risk_mode | String(16) | 'inherit_pool' | NOT NULL | |
| max_risk_f1_pct | Float | | | |
| max_risk_f2_pct | Float | | | |
| max_risk_f3_pct | Float | | | |

account_resets

Historico de resets de classificacao de conta.
| Coluna | Tipo | Default | Extras | Descricao |
|--------|------|---------|--------|-----------|
| id | Integer | | PK | |
| account_id | Integer | | ForeignKey('accounts.id'), NOT NULL | |
| reset_at | DateTime | datetime.utcnow | | |
| pool_id | Integer | | | |
| pool_name | String(100) | '' | | |
| previous_type | String(20) | '' | | |
| previous_firm | String(100) | '' | | |
| previous_phase | String(20) | '' | | |

terminals

Faixa 1 (specs/identidade-terminal-chave-vs-conta-login.md, decisão (e)): registro de
| Coluna | Tipo | Default | Extras | Descricao |
|--------|------|---------|--------|-----------|
| id | Integer | | PK | |
| api_key | String(64) | | UNIQUE, NOT NULL | crachá do terminal |
| world | String(10) | 'real' | NOT NULL | |
| nickname | String(30) | | | |
| seq_no | Integer | | | |
| status | String(20) | 'active' | NOT NULL | active|revoked |
| last_ip | String(64) | | | último IP visto (do heartbeat) |
| last_login_served | BigInteger | | | login (account_num) reportado no último HB |
| last_mt5_server | String(100) | | | servidor do último login servido |
| first_seen_at | DateTime | datetime.utcnow | NOT NULL | primeira aparição |
| last_heartbeat_at | DateTime | | | último HB observado |
| revoked_at | DateTime | | | quando foi revogado (Faixa 4) |
| approved_at | DateTime | | | quando o dono abençoou o terminal |
| approved_by | String(50) | | | como: backfill-E1 | classify: | telegram-aprovar |
| hb_count | BigInteger | 0 | NOT NULL | nº de HBs observados |
| hdr_login | BigInteger | | | login reportado NO CABEÇALHO (X-MT5-Login) |
| hdr_mt5_server | String(100) | | | servidor reportado no cabeçalho (X-MT5-Server) |
| hdr_seen_at | DateTime | | | último HB/pedido que trouxe os 2 cabeçalhos |
| divergence_count | BigInteger | 0 | NOT NULL | chave!=rosto acumulado |
| last_divergence_at | DateTime | | | |
| serving_account_id | Integer | | ForeignKey('accounts.id', ondelete='SET NULL') | |

terminal_shadow_divergence

O3 · FASE 2 — O LIVRO DA SOMBRA INVERTIDA.
| Coluna | Tipo | Default | Extras | Descricao |
|--------|------|---------|--------|-----------|
| id | Integer | | PK | |
| key_tail | String(8) | | NOT NULL | 6 últimos da chave — NUNCA a chave inteira |
| door | String(16) | | NOT NULL | poll | nucleo | ws | hb — por onde entrou |
| shape | String(120) | | NOT NULL | |
| kind | String(12) | | NOT NULL | veredito | mundo |
| severity | String(10) | 'perigosa' | NOT NULL | |
| old_accepts | Boolean | | NOT NULL | |
| new_accepts | Boolean | | NOT NULL | |
| old_world | String(10) | | | |
| new_world | String(10) | | | |
| new_reason | String(20) | | | |
| terminal_id | Integer | | | sem FK: a linha sobrevive à faxina do crachá |
| hits | BigInteger | 0 | NOT NULL | |
| first_at | DateTime | datetime.utcnow | NOT NULL | |
| last_at | DateTime | datetime.utcnow | NOT NULL | |

terminal_shadow_stats

O3 · FASE 2 — O DENOMINADOR da sombra (linha ÚNICA, id=1).
| Coluna | Tipo | Default | Extras | Descricao |
|--------|------|---------|--------|-----------|
| id | Integer | | PK | |
| comparacoes | BigInteger | 0 | NOT NULL | |
| concordancias | BigInteger | 0 | NOT NULL | |
| por_porta | JSON | | | {"poll": N, "nucleo": M} — cobertura por porta |
| desde | DateTime | | | 1ª comparação registrada (o marco zero) |
| ultima_em | DateTime | | | |

terminal_serving_log

O2 (§4 item 10) — o FILME: uma linha por PERÍODO em que uma máquina serviu uma conta.
| Coluna | Tipo | Default | Extras | Descricao |
|--------|------|---------|--------|-----------|
| id | Integer | | PK | |
| terminal_id | Integer | | ForeignKey('terminals.id', ondelete='CASCADE'), NOT NULL | |
| account_id | Integer | | ForeignKey('accounts.id', ondelete='SET NULL') | |
| started_at | DateTime | datetime.utcnow | NOT NULL | |
| ended_at | DateTime | | | NULL = ainda servindo |

leader_lease

F.1 — ESPELHO do plantão (quem é o EA titular por conta). NÃO é o relógio da eleição.
| Coluna | Tipo | Default | Extras | Descricao |
|--------|------|---------|--------|-----------|
| account_id | Integer | | ForeignKey('accounts.id', ondelete='CASCADE'), PK | |
| leader_key | String(64) | | NOT NULL | crachá (api_key) do terminal titular |
| updated_at | DateTime(timezone=True) | func.now() | NOT NULL | |

account_challenges

S370: ledger de tentativas de desafio por conta (login).
| Coluna | Tipo | Default | Extras | Descricao |
|--------|------|---------|--------|-----------|
| id | Integer | | PK | |
| account_id | Integer | | ForeignKey('accounts.id'), NOT NULL | |
| prop_firm_id | Integer | | | |
| owner_id | Integer | | | |
| account_phase | String(20) | | | |
| prop_size | String(20) | | | |
| combo_key | Text | | | S370b: chave do #N (empresa|passos|tamanho|fase|owner) |
| name | String(100) | | | |
| name_seq | Integer | | | |
| opened_at | DateTime | datetime.utcnow | | |
| closed_at | DateTime | | | |
| outcome | String(20) | 'active' | | active|passed|dead|funded|reset |
| final_balance | Float | | | |
| final_equity | Float | | | |

bancada_keys

A CASA PRÓPRIA das chaves de bancada (#602, pré-requisito da obra O2 passo 4).
| Coluna | Tipo | Default | Extras | Descricao |
|--------|------|---------|--------|-----------|
| api_key | String(64) | | PK | |
| account_id | Integer | | ForeignKey('accounts.id', ondelete='CASCADE'), NOT NULL, index | |
| created_at | DateTime | datetime.utcnow | NOT NULL | |

event_stream

Coluna Tipo Default Extras Descricao
id Integer PK
event_id String(64) UNIQUE, NOT NULL
trace_id String(32) NOT NULL
account_id Integer ForeignKey('accounts.id')
event_type String(32) NOT NULL
ts_broker DateTime NOT NULL
ts_received DateTime datetime.utcnow NOT NULL
gap_ms Integer
payload JSONB dict NOT NULL
late Boolean False NOT NULL
clock_offset_ms Integer
ea_instance_id String(64)
created_at DateTime datetime.utcnow NOT NULL

event_outbox

Coluna Tipo Default Extras Descricao
id Integer PK
event_stream_id Integer ForeignKey('event_stream.id'), NOT NULL
published Boolean False NOT NULL
attempts Integer 0 NOT NULL
last_error Text
created_at DateTime datetime.utcnow NOT NULL
published_at DateTime

sim_runs

S326-cont #3+#4: snapshot persistente de SimRunner pra sobreviver a
| Coluna | Tipo | Default | Extras | Descricao |
|--------|------|---------|--------|-----------|
| id | String(64) | | PK | RUN_xxx |
| session_id | String(64) | | NOT NULL, index | SIM_xxx |
| mode | String(20) | | NOT NULL | 'rapido' | 'completo' | 'real' |
| status | String(30) | 'running' | NOT NULL, index | |
| started_at | DateTime | datetime.utcnow | NOT NULL, index | |
| finished_at | DateTime | | | |
| abort_reason | Text | | | |
| config_json | JSONB | | | payload original do start (sync com prod-schema) |
| summary_json | JSONB | | | final summary (status_counts + post_validate) |

ea_tester_runs

S358: 1 row por bateria EA Tester executada (rapida 4 estacoes ou
| Coluna | Tipo | Default | Extras | Descricao |
|--------|------|---------|--------|-----------|
| run_id | UUID(as_uuid=False) | | PK | |
| account_id | Integer | | ForeignKey('accounts.id'), NOT NULL | |
| started_at | DateTime(timezone=True) | | NOT NULL | |
| ended_at | DateTime(timezone=True) | | | |
| mode | String(20) | | NOT NULL | ver MODOS_DE_BATERIA |
| agg | String(10) | | | ver VEREDITOS ("PASS*" cabe: 5 de 10 chars) |
| ok_count | Integer | 0 | NOT NULL | |
| warn_count | Integer | 0 | NOT NULL | |
| fail_count | Integer | 0 | NOT NULL | |
| total | Integer | 0 | NOT NULL | |
| aborted | Boolean | False | NOT NULL | |
| orphans_remaining | Integer | 0 | NOT NULL | |
| cleanup_verify_failed | Boolean | False | NOT NULL | |
| invert_baseline | Boolean | | | |
| ea_version | String(50) | | | |
| terminal_build | Integer | | | |
| symbol | String(20) | | | par usado (BTCUSD weekend / EURUSD dia util) |
| weekend | Boolean | | | rodou sabado/domingo? |
| regime | String(20) | | | 'forex_aberto' | 'cripto_24_7' |
| running_hash | String(64) | | | |
| created_at | DateTime(timezone=True) | func.now() | NOT NULL | |

ea_tester_run_steps

S358: 1 row por estacao da bateria (max 25). motivo_leigo + classifier_id
| Coluna | Tipo | Default | Extras | Descricao |
|--------|------|---------|--------|-----------|
| id | BigInteger | | PK | |
| run_id | UUID(as_uuid=False) | | ForeignKey('ea_tester_runs.run_id', ondelete='CASCADE'), NOT NULL | |
| station_id | String(50) | | NOT NULL | |
| station_name | String(120) | | NOT NULL | |
| station_order | Integer | | NOT NULL | |
| status | String(10) | | NOT NULL | 'ok' | 'warn' | 'failed' | 'skipped' | 'insight' (S430) |
| elapsed_ms | Integer | | | |
| attempts | Integer | | | |
| motivo_leigo | Text | | | |
| motivo_classifier_id | String(50) | | | |
| drill_json | JSONB | | | |

ea_tester_inject_ledger

G1 (blindagem-final §3): ledger APPEND-ONLY de proveniencia — cada inject de
| Coluna | Tipo | Default | Extras | Descricao |
|--------|------|---------|--------|-----------|
| id | BigInteger | | PK | |
| run_id | String(36) | | NOT NULL | |
| account_id | Integer | | NOT NULL | |
| signal_id | Integer | | | |
| station_id | String(50) | | | futuro: correspondencia por-estacao |
| action | String(16) | | | |
| created_at | DateTime | datetime.utcnow | NOT NULL | |

battery_locks

Frente C Entrega 1: trava server-side da bancada (bateria EA Tester) por conta.
| Coluna | Tipo | Default | Extras | Descricao |
|--------|------|---------|--------|-----------|
| account_id | Integer | | PK | 1 lock por conta (a bancada e unica) |
| run_id | String(36) | | NOT NULL | |
| operator | String(80) | | | username do JWT que segurou |
| acquired_at | DateTime | datetime.utcnow | NOT NULL | |
| heartbeat_at | DateTime | datetime.utcnow | NOT NULL | |
| expires_at | DateTime | | NOT NULL | |

sim_run_history

Sim E2E Battery (2026-05-19): 1 row por run do simulador Real Path
| Coluna | Tipo | Default | Extras | Descricao |
|--------|------|---------|--------|-----------|
| run_id | String(40) | | PK | |
| session_id | String(120) | | NOT NULL | |
| scenario_kind | String(40) | | | |
| subtype | String(40) | | | |
| mode | String(20) | | | |
| started_at | DateTime(timezone=True) | | NOT NULL | |
| ended_at | DateTime(timezone=True) | | | |
| status | String(20) | | NOT NULL | 'queued' | 'running' | 'done' | 'error' | 'stopped' |
| total_stations | Integer | 0 | NOT NULL | |
| ok_count | Integer | 0 | NOT NULL | |
| failed_count | Integer | 0 | NOT NULL | |
| elapsed_ms | Integer | | | |
| result_pass | Boolean | | | |
| summary_json | JSONB | | | |
| result_json | JSONB | | | |
| created_at | DateTime(timezone=True) | func.now() | NOT NULL | |
| favorite | Boolean | False | NOT NULL | |
| favorite_note | String(280) | | | |

sim_run_stations

Sim E2E Battery (2026-05-19): 1 row por estacao do run (~10-12 pra
| Coluna | Tipo | Default | Extras | Descricao |
|--------|------|---------|--------|-----------|
| id | BigInteger | | PK | |
| run_id | String(40) | | ForeignKey('sim_run_history.run_id', ondelete='CASCADE'), NOT NULL | |
| station_id | String(50) | | NOT NULL | |
| station_label | String(120) | | NOT NULL | |
| station_order | Integer | | NOT NULL | |
| status | String(10) | | NOT NULL | |
| started_at | DateTime(timezone=True) | | | |
| ended_at | DateTime(timezone=True) | | | |
| elapsed_ms | Integer | | | |
| live_msg | Text | | | |
| error_msg | Text | | | |
| drill_json | JSONB | | | |

ring_rollout_plans

Plano de rollout de UMA promoção de anel (pool/stable).
| Coluna | Tipo | Default | Extras | Descricao |
|--------|------|---------|--------|-----------|
| id | Integer | | PK | |
| ring | String(20) | | NOT NULL | "pool" | "stable" |
| version | String(20) | | NOT NULL | versão-alvo do pin (sem prefixo 'b') |
| mode | String(20) | 'staggered' | NOT NULL | all_at_once | staggered |
| ceiling_min | Integer | 30 | NOT NULL | teto do jitter (minutos) |
| created_at | DateTime | datetime.utcnow | | |
| created_by | String(50) | | | |
| ungated | Boolean | False | NOT NULL | |
| active | Boolean | True | NOT NULL | |

ring_rollout_items

Uma conta dentro de um plano de rollout, com o instante (release_at) em que o
| Coluna | Tipo | Default | Extras | Descricao |
|--------|------|---------|--------|-----------|
| id | Integer | | PK | |
| plan_id | Integer | | ForeignKey('ring_rollout_plans.id'), NOT NULL | |
| account_id | Integer | | ForeignKey('accounts.id'), NOT NULL | |
| release_at | DateTime | | NOT NULL | |
| applied | Boolean | False | NOT NULL | |

ring_bypasses

Declaração de "pulei o anel de propósito", com prazo (padrão break-glass). Mover a frota sem
mover o alvo do anel é capacidade pedida (deploy_ea.sh com SEM_ANEL=1); o problema nunca foi
o pulo, foi ele não se declarar — sem isso o alarme de drift grita igual pra "eu pedi" e pra
"mexeram no robô por fora". Append-only: nunca se apaga uma declaração, só se cria outra; a
vigente é a mais recente do anel com expires_at no futuro.
Índice: ix_ring_bypasses_ring_expires (ring, expires_at).
| Coluna | Tipo | Default | Extras | Descricao |
|--------|------|---------|--------|-----------|
| id | Integer | | PK | |
| ring | String(20) | | NOT NULL | "pool" ou "stable" |
| version | String(20) | | NOT NULL | versão que a frota foi levada a rodar |
| declared_by | String(50) | | | quem pediu o pulo |
| declared_at | DateTime | tempo.agora_real | NOT NULL | relógio da APLICAÇÃO, não do banco — o mesmo que mede o prazo do anel |
| expires_at | DateTime | | NOT NULL | sem default em lugar nenhum, de propósito: break-glass sem prazo não deve existir |
| reason | String(300) | | | por que, pra quando o lembrete chegar dias depois |


Validacao Sim E2E

O que eh: check rapido pra confirmar que o cenario modify_dw_stress_real
esta funcionando ponta-a-ponta no servidor real (nao em mock).

Quando rodar: depois de mexer em signals.py (handler MODIFY), runner.py
(path do scenario), vea_client.py (simulador do EA), ou no caminho de
near_death/near_target da engine.py. Tambem antes de bater o branch
em prod.

Tempo: ~20s pra rodar os 2 quadrantes (death + target).


O que esse cenario testa (em linguagem leve)

Imagina que duas contas estao no hedge — uma comprando, outra vendendo
o mesmo par. Quando uma delas chega muito perto da morte (saldo prox.
do piso de DD) ou muito perto da meta (saldo perto do target), o
sistema deve ajustar o SL/TP dessa conta automaticamente, sem mexer
no lado oposto.

O cenario forca esse estado de propostito:

Em ambos os casos: o outro lado do hedge nao pode ser tocado (regra
P223 — invariante hedge zero-sum).


Como rodar

Pre-condicoes:
- VPS copytrade rodando + TEST_INJECTION_ENABLED=true no env
- JWT admin valido
- Nenhum outro run SIM em flight

Disparar os 2 quadrantes via Bash:

TOKEN=$(curl -s -X POST https://linniuc.com/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"username":"admin","password":"..."}' | jq -r .access_token)

for SUB in death target; do
  curl -sS -X POST https://linniuc.com/api/sim/scenarios/modify_dw_stress_real \
    -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
    -d "{\"subtype\":\"$SUB\",\"auto_cleanup\":true,\"timeout_sec\":180,\"seed\":0}" \
    | jq '{status, pass: .result.pass, foco_USD: .result.foco_USD, elapsed_sec}'
done

Resposta esperada (cada chamada):
- HTTP 200 em ~9s
- status: "done", pass: true
- foco_USD entre 54 e 56


Checklist objetivo de validacao

O que checa (leigo) Esperado Death (S340) Target (S340)
Veredicto unico — missao toda OK? True True ✅ True ✅
Perda/ganho do trade-alvo no range (cushion/dist $50 + margin random 4-6) $54-$56 $54.90 ✅ $55.44 ✅
Booleano alvo (cruza com outras regras) True True ✅ True ✅
Outro lado do hedge ficou parado (diff < $5) True diff $0.39 ✅ diff $1.81 ✅
Almofada aleatoria anti-robotica dentro do range pool config $4-$6 $5.11 ✅ $5.53 ✅
HB injection — empurrou saldo forcado nas 2 contas 2/2 sucessos 2/2 ✅ 2/2 ✅
MODIFY dispara — gate near_* ativa e handler executa antes do timeout <60s <1s ✅ <1s ✅
Resposta sincrona antes do nginx fechar (504) <60s 8.78s ✅ 9.05s ✅
Cleanup auto — contas/pool de teste apagadas 0 residual 0 ✅ 0 ✅
Telegram dry_run — alerts em modo ensaio 2 por run 2 ✅ 2 ✅
Sem quebra nos EAs reais da pool 20 pos-deploy sem novos erros 2h so 502s transient ✅ idem ✅

Se falhar

pass: false com error: "MODIFY nao disparou em 60s"

Significa que o gate near_death/near_target nao ativou. Causas comuns:

  1. HB injection nao gravou no DB — server descarta HB via WS path se
    throttle WS_HB_DB_INTERVAL=30s esta ativo. Solucao: usar force_http=True
    no _send_hb_now (replica caminho que EA real usa em WS-down).
  2. _real_balance ficou stale — virtual_time avancou muito no SimClock
    e HB virou EXPIRED. Solucao: NAO chamar _sim_tick apos HB inject no
    scenario.
  3. Prop firm com max_dd diferente de 10%cushion = balance - dd_floor
    sai negativo, gate nao ativa. Verificar prop_config da pool SIM.

HTTP 504 do nginx

Run levou mais de 60s. Provavel timeout do poll MODIFY (60s wait). Roda em
~9s pos-fix S340. Se passar de 30s, investigar logs do servidor.

pair_id nao criado

Engine recusou o par. Olhar logs do _af_scheduler — provavel margin
validation, swap hour ou trading window (todos devem estar bypassados pela
config SIM).


Padroes aprendidos (S340 — pattern P224)

  1. VEA deve replicar EA real: comparar payload MQL5 + thresholds
    server-side ANTES de aprovar review E2E. Senao bugs como throttle WS
    ou heartbeat_interval divergente passam despercebidos.
  2. emit() kwargs colidem com **result: se o dict de result ja tem
    chave X, NAO passar X=... explicito alem do **result. Exception
    no caminho sobrescreve o diagnostico real.
  3. HB injection precisa de path HTTP (force_http=True): WS path tem
    throttle 30s server-side, HTTP nao. Replica caminho que EA real usa
    em WS-down.
  4. Resposta sincrona OK ate 60s do nginx: scenario roda em ~9s
    pos-fix. Se passar disso, voltar a investigar.

Spec tecnica completa: sim-e2e-real-S281.md (regras R1-R8 do real_path).
Pattern P224 (KnowledgeHub): "VEA deve replicar EA real — comparar
payload MQL5 + thresholds server-side antes de approval de review E2E".

[allow-spec]


Real Path do Simulador

O que este guia responde: "quando eu rodo o simulador no modo real, o que ele de fato faz,
passo a passo?" — em linguagem de quem opera, não de quem programa.


A ideia em 1 parágrafo

O real path é o simulador dirigindo o servidor de verdade (o mesmo agendador, o mesmo
motor de hedge, o mesmo ciclo de vida das contas que rodam em produção) — só que com contas
de mentira marcadas como simulação e um EA virtual (um programinha em Python, o "VEA")
fazendo o papel do EA: confirmar ordens e mandar batimento de saldo. Nada é mock degradado: é
linha real de banco com etiqueta is_simulator=true. Por isso o sim pega bug de servidor que
um teste matemático nunca pegaria — porque é o servidor REAL reagindo.

Analogia: é um ensaio geral de teatro no palco de verdade, com o elenco de verdade e a
peça de verdade — só os atores principais (os EAs) são dublês que seguem o roteiro.


Onde isso roda

Peça O que é
Gatilho POST /api/sim/scenarios/... (só liga com TEST_INJECTION_ENABLED=true). Em produção a flag é false → os endpoints dormem (403/404).
Servidor local python scripts/run_sim_local.py sobe um servidor próprio em :8001 com a flag ligada + Postgres local de teste (copytrade_test:5433).
EA virtual (VEA) server/sim/vea_client.py — conecta no WebSocket real como ea_version="SIM_v1", escuta sinais e responde (ACK + heartbeat).
Mesas / contas / pool linhas reais marcadas: contas is_simulator=true, pool com prefixo SIM_, mesas prop [SIM] ....

Regra de ouro: a etiqueta is_simulator só serve pra (a) esconder do dashboard real e
(b) limpar depois. Nunca entra na lógica de comportamento — a mesa SIM resolve, pareia e
calcula risco IDÊNTICO a uma real.


O fluxo de uma RUN, estação por estação

  [classificar]
      │  conta nasce avulsa → escolhe mesa+fase+dono → ganha #N + ciclo de desafio
      ▼
  [criar pool + sentar nas cadeiras]
      │  N contas viram cadeiras (AfPoolAccount). Empresas distintas = pode parear (R7)
      ▼
  [subir os VEAs]  ── cada conta ganha 1 EA virtual que autentica no WebSocket
      ▼
  ┌────────────────── LOOP de rodadas ──────────────────┐
  │  batimento de saldo (heartbeat) de todas as contas    │
  │      ▼                                                │
  │  agendador REAL pareia + abre o par (sinais canônicos)│
  │      ▼                                                │
  │  VEA confirma a ordem (ACK)                           │
  │      ▼                                                │
  │  fecha o par zero-sum (um ganha o que o outro perde)  │
  │      ▼                                                │
  │  ciclo de vida decide: passou de fase? morreu? ───────┼──► [transições]
  │      ▼                                                │
  │  recompra: morto/funded → nasce substituto T+1        │
  └───────────────────────────────────────────────────────┘
      ▼
  [resumo da run]  ── as 22 métricas: passes/mortes/substitutos + taxa de sucesso por
                      fase, custo por aprovação, velocidade de graduação, comportamento
                      (limbo/zigzag/ociosidade) e ROI virtual

Mapeando cada estação pra função real do servidor (pra quem quiser cavar):

Estação Função real O que prova O que pode quebrar
Classificar sync_classify_cycle (routes/accounts.py) #N temporal + account_challenges aberto + nome montado #N errado; ciclo órfão; vínculo conta-filha (S385)
Criar pool create_sim_pool (sim/af_tick_real.py) pool SIM_* + N contas is_simulator + cadeiras capacidade; mesa não resolve por nome
Parear agendador real + pairing (R7) nunca parear 2 da mesma Empresa pareamento trava se poucas Empresas distintas
Abrir + ACK sinais canônicos + VEA sinal chega, EA virtual confirma VEA não confirma; sinal perdido
Fechar zero-sum process_trade_result P&L do par soma zero assimetria SL/TP; slippage
Passar de fase check_passescreate_opc3_f2_substitutes (sim/runner.py) F1 passa cria conta F2 NOVA — a velha fica passed, NUNCA muta promoção que muta conta velha (modelo errado de prop firm)
Morrer por DD check_deaths (af/lifecycle.py) saldo < piso da mesa → morta + saldo congelado morte não reconhecida; $NaN no card (S374)
Recompra recycle intent + apply_recycle_batch morto/funded → substituto novo T+1 substituto não nasce; substituto duplicado

Os DOIS gatilhos de morte por drawdown (importante)

Em produção a conta morre por DD de duas formas — e desde S378 #8 o sim cobre as duas:

  1. Pela via do SALDO (check_deaths): no fim da rodada, se o saldo fechado cruzou o piso
    da mesa (piso = tamanho × (1 − max_dd/100)), a conta é marcada morta e o saldo é congelado.
    O VEA cobre isso (manda saldo no heartbeat → servidor lê → check_deaths). É o que o
    cenário dd_recognition valida.
  2. Pela via da EQUITY, dentro do trade (_handle_equity_breach, routes/ea_ws.py): o EA
    de VERDADE vê a equity tocar o piso AO VIVO (antes do trade fechar), fecha tudo na hora e
    reporta equity_floor_breach. É o gatilho REAL e mais rápido em produção (foi a origem do
    bug $NaN do S374). Desde S378 #8 o VEA cobre essa via também: novo método
    report_equity_breach() emite o mesmo frame WS que o EA real. Cenário dedicado:
    dd_breach_intratrade — sobe VEA conectado, ele emite o frame, o handler real marca
    dead + congela saldo + grava AuditLog.

Os DOIS pisos têm que bater: o que o EA recebe (get_equity_floor, via FK da mesa) ==
o que o motor usa pra declarar morte (_max_dd_val, via snapshot na cadeira). Se divergirem
(snapshot velho vs mesa viva), é exatamente a classe de bug "DD não-reconhecido".


As estações do cockpit (cenários prontos)

Endpoint O que exercita
POST /api/sim/scenarios/classify_cycle Classificação: nascer→#1, reset→#2, vínculo conta-filha S385 (consume-once)
POST /api/sim/scenarios/dd_recognition Gatilho A (via SALDO): reconhecimento de DD por perfil de mesa (8%/10%/5%): pisos batem, mesmo saldo morre na apertada e sobrevive na larga, congela, daily cap por perfil
POST /api/sim/scenarios/dd_breach_intratrade Gatilho B (via EQUITY ao vivo): VEA conectado emite frame equity_floor_breach → handler real marca dead + congela saldo + grava AuditLog (S378 #8)
POST /api/sim/scenarios/modify_dw_stress_real MODIFY perto da morte/alvo (cenário focado de 1 par, E2E real com VEA + agendador)

O Real Path principal (a run multi-rodada deste mapa, com as 22 métricas no resumo) é
disparado por POST /api/sim/scenarios/start_real. Histórico e resultado: /real_path/history
e /real_path/report/{run_id}. Os cenários acima são provas focadas de um pedaço só.

Rede de segurança automatizada (rodam no sqlite rápido + Postgres real):
test_dd_recognition_S378.py, test_dd_breach_intratrade_S378.py,
test_classify_cycle_sim_S378.py, test_journey_2step_S378.py
(jornada inteira numa tacada + modos de falha).


Como rodar uma run de verdade

# 1. sobe o servidor local com o sim ligado (Postgres copytrade_test migrado)
python scripts/run_sim_local.py        # uvicorn :8001 + VEA aponta de volta pra :8001

# 2. dispara uma estação (token admin no servidor local)
curl -sS -X POST http://127.0.0.1:8001/api/sim/scenarios/dd_recognition \
  -H "Authorization: Bearer $TOKEN" | jq '{all_passed, cross_profile}'

Pré-requisito do ambiente: o Postgres local copytrade_test precisa estar com as migrações
em dia (ex: S382 colunas de estilo de DD/news, S385 vínculo conta-filha) — senão qualquer query
de mesa quebra. Produção já tem; o local de dev pode ficar pra trás. Aplicar as migrações faltantes
de server/migrations/ antes de rodar.


Isolamento e limpeza


SSoT técnica: specs/sim-e2e-real-S281.md (endpoints + padrões anti-flake) +
specs/sim-cockpit-validacao-completa-S378.md (cockpit S378). Este guia é o mapa leigo do fluxo.


Camadas EA/Site

Status: ACTIVE | Ultima revisao: S348+1 (2026-05-15) | Documento vivo — atualizar conforme novas features entram.

Visao geral

O CopyTrade tem 2 camadas de logica:

Cada feature/funcao vive em uma das 3 categorias: so EA, so Site, ou dupla camada (defesa em profundidade — EA + Site cobrindo a mesma coisa, com responsabilidades complementares).

A regra geral: se um bug naquela camada pode causar PERDA REAL ou VAZAMENTO PRA PROD, criar dupla camada. Senao, single-layer eh aceitavel.


Mapa das 25 estacoes + 10 sub-asserts da bateria "Validar completo"

Total: 25 estacoes, ~115s tipico. Distribuicao por camada: EA-only 4, SITE-only 9, DUPLA 12.

# Estacao Camada Onde vive Vale dupla camada? Por que
1 HB shape DUPLA EA envia HB, Site recebe + valida campos ja eh EA reporta saude, Site consolida e alerta se stale
2 Sandbox limpa pre-flight DUPLA Site dispatch CLOSE, EA executa ja eh Limpeza so funciona com ambos
3 Olheiro SL/TP SITE Validacao SL/TP no servidor nao Dominio do servidor. EA so executa
4 Run-all OPEN/MODIFY/CLOSE DUPLA Site cria signal, EA executa via GUI ja eh Cadeia inteira do ciclo de vida
5 Invert (BUY→SELL + audit + SL/TP trocados) DUPLA Site insere signal_inversions, EA aplica GUIExecution_ApplyInversion ja eh Audit no Site, execucao no EA
6 Symbol resolve EA SymbolResolver.mqh nao Broker eh dominio do EA
7 Buffers SL/TP multi-asset EA _apply_buffers() no EA nao Calculo proximo do tick
8 Mini-stress 3 contas DUPLA Site spawn_stress_pool, EAs executam paralelo ja eh Pool + broadcast + execucao em massa
9 6 cenarios de erro (M1 SWAP: antes de WS kill) DUPLA volume_inv valida no Site; margin/sym_fake erra no EA ja eh Cada preset testa camada diferente
10 WS fallback HTTP poll DUPLA EA reconecta canal, Site responde HTTP ja eh Canal eh propriedade compartilhada
11 Reconcile pos-restart EA (opt-in) DUPLA Site mantem fila, EA puxa pos-reboot ja eh Fila no Site, replay no EA
12 Equity floor breach + reset DUPLA _af_runtime_validator no Site + EquityFloor_Check no EA ja eh EA defende mesmo offline; Site valida + reseta
13 Auto-update simulado DUPLA Site PATCH desired_version, EA detecta + reporta ja eh Site agenda, EA executa
14 Telemetria 10 campos DUPLA Site coleta timeline, EA emite eventos ja eh Eventos no EA, agregacao no Site
15 Rollover diario antes do swap DUPLA (ja aprovada em specs/rollover-dupla-camada.md) EA primary + Site fallback ja eh Critico: se Site cair entre T-60 e T-30, EA fecha sozinho
16 Isolamento de grupo SITE Filtragem target_group_id via API key da conta nao (recalibrado) Single-layer forte. Dupla seria nice-to-have, nao urgente
17 Cap de lote (max_lots) SITE Servidor valida lots antes de despachar nao (recalibrado, ver TODO) Verificar se servidor ja valida. Se nao, dupla camada vale
18 Validacao payload (timestamp futuro, JSON quebrado, null bytes) SITE Pydantic + sanitizacao explicita nao EA recebe estrutura ja sanitizada
19 MODIFY queue overflow (>4) EA MOD_VERIFY_QUEUE_SIZE=4 no EA nao Fila eh design interno do EA
20 Race OPEN+CLOSE 50ms DUPLA Lifecycle state machine no Site + g_busy no EA ja eh Site sequencia, EA respeita flag
21 Replay ACK antigo DUPLA idempotency_unique index (server) + g_processedSignalIds[100] ring buffer (EA, LinniuC.mq5:162) ja eh (auditado S354) Dedup signal_id 2 camadas. Replay >100 sinais atras: so server pega
22 Signal source=test em conta prod SITE R3 do sim-e2e: signal_source='test' NUNCA em conta is_test=false nao (recalibrado) Single-layer suficiente. Dupla nice-to-have
23 Cron health pre-flight (NOVO) SITE 5 crons rodaram <24h? Disco OK? nao Pura infra servidor. Executável desde #257 (S354): 7 crons gravam liveness marker /var/log/copytrade-cron-*-last-success.txt; GET /api/health/cron-status lê. Antes era WARN permanente.
24 AF runtime pre-flight (NOVO) SITE Sem round stuck >30min, pending <50, par offline OK nao Estado AF eh servidor
25 OnTradeTransaction crash test (NOVO) EA positionId=0 fake: EA nao crasha, reporta erro nao Robustez interna EA

Sub-asserts (embutidos em estacao existente, sem custo separado)

Onde O que valida Camada
E1 HB shape ea_version bate com server_version (/api/health) — detecta deploy mismatch DUPLA
E4 run-all Ticket aparece em _ticketListPanel (capitulo C1 multi-ticket selector) EA
E4 run-all signal_lifecycle transicao PENDING→DISPATCHED→ACKED→RESOLVED em ordem SITE
E4 run-all ACK retornou com target_account_id=3 (defesa anti-vazamento) DUPLA
E4 run-all Fila MODIFY verify 5s pos-MODIFY foi processada (g_modVerifyQueue) EA
E4 run-all _af_runtime_validator confirma estado consistente pos-trade (detecta EA inert 15.6/15.9) DUPLA
E5 invert SL e TP trocados de fato no broker (nao so direction) — pos-G11 auditoria EA EA
E12 equity floor Pos-breach + restore, EA volta a operar (floor_reset funcionou) EA · forçar breach com segurança = spec ea-tester-e12-e15-force-breach-S354.md (F11, aguarda execução)
E14 telemetria WS push reload UI tabela <2s SITE
Pre-flight global localStorage.ct_token valido + >5min restante; is_test=true na conta 3 SITE

Decisoes pendentes (recalibradas pos-discussao S348+1)


Como adicionar nova feature: arvore de decisao

Nova feature precisa rodar:
├── So no MT5 (broker, GUI, simbolo)? → EA-only
├── So no servidor (orquestracao, AF, audit, lifecycle, telegram)? → Site-only
├── Ambos (signal, ACK, settings sync)? → Dupla camada NATURAL
└── Tem risco de PERDA REAL ou VAZAMENTO se 1 camada falhar? → Dupla camada OBRIGATORIA

Lista de criterios pra dupla camada obrigatoria:

  1. Perda real garantida (rollover, equity floor, cap de lote, posicao orfa em janela critica)
  2. Vazamento sandbox → prod (signal test, isolamento de grupo, isolamento de conta)
  3. Estado corrompido silente (race conditions com efeito permanente)
  4. Hedge/invert errado (BUY virou SELL erradamente, SL/TP trocados)

Como atualizar este documento

Esta aba /guide#camadas-ea-vs-site eh fonte da verdade do mapa de camadas. Sempre que adicionar/remover feature da bateria de validacao, ou descobrir que algo precisa virar dupla camada, atualizar:

  1. Editar specs/camadas-ea-vs-site.md (este arquivo)
  2. Rodar bash scripts/guide-update-and-verify.sh camadas-ea-vs-site — script automatiza:
  3. Validar markdown sintaxe
  4. Commit + push pra VPS (post-receive sync)
  5. Aguardar 5s + curl /guide pra confirmar render OK
  6. Imprime URL final + sugere abrir browser
  7. Confirmar visualmente em https://linniuc.com/guide#camadas-ea-vs-site

Relacionado


Status Planos

Derivado automaticamente das fases de cada specs/*-PLAN.md. Sem marcacao manual. Estado vem de git + ledgers de review/validate + todos. Fingerprint de conteudo: 5be4fe43.

Legenda: · Planejado · ~ Em obra · + Construido · R Revisado · Provado · 🔴 Bloqueado (flag ortogonal)

brainstorm-phase-auditor — 0/6 Provado

Fase Estado Descricao
F1 + Construido Trace emitter (per-brainstorm jsonl + model_version)
F2 + Construido Emissores forte (0f/1/4/5/6/7b) + fraco (2/3)
F3 + Construido Conformance check PreToolUse /plan (tier-aware + model move)
F4 + Construido Retry cap + fail-safe + SessionStart cleanup
F5 + Construido Wiring settings.json + dogfood S349 + teste negativo
F6 + Construido Post-impl: code-review LOOP P228 + validate

ea-tester-bateria — 0/11 Provado

Fase Estado Descricao
F1 R Revisado RED tests T1-T4
F2 R Revisado E1-E4 (HB, cleanup, Olheiro, run-all)
F3 R Revisado E5-E7 (invert, symbol, buffers) + SA-3
F4 R Revisado E9 6 cenarios erro + E10 WS fallback
F5 R Revisado E12 equity_floor + E13 auto-update
F6 R Revisado E14 + reconcile + cancel/beforeunload
F7 R Revisado toggles + Telegram opt-in + P228 LOOP
F8 + Construido Multi-aba lock BroadcastChannel + polish
F9 + Construido E15 Rollover + E22 prod + SA-1..SA-4
F10 ~ Em obra E16-E21 + SA-5..SA-8
F11 ~ Em obra E23-E25 + 3 endpoints servidor + SA-9/SA-10

spec-status-tracker — 8/10 Provado

Fase Estado Descricao
F1 ✓ Provado Contract parser
F2 ✓ Provado Git-derived states
F3 ✓ Provado Review marker reader
F4 ✓ Provado Validate-ledger reader
F5 ✓ Provado Blocked flag from todos
F6 ✓ Provado Aggregator + cache + CLI
F7 ✓ Provado Contract migration + reality check
F8 R Revisado Publish /guide (P243-aware)
F9 ✓ Provado 4 hooks wiring
F10 ~ Em obra Post-impl: code-review LOOP + validate

Camadas Pool/Sim

Status: ACTIVE | Criado: S362 (2026-05-22) | Atualizado: S364 (#E6 reset unificado + #297 piso diário) | Documento vivo — atualizar quando uma camada mudar de lado.

Visao geral (analogia do restaurante)

Rodar a pool tem 4 camadas. Pensa num restaurante:

  1. Gerente (agendador) — decide QUANDO abrir uma batalha nova.
  2. Chef (engine/cerebro) — dado um grupo de contas, decide a "receita".
  3. Cozinheiro (execucao) — faz a ordem acontecer no broker.
  4. Conta do dia (reset) — zera a perda acumulada do dia no horario certo.

O simulador eh uma cozinha de treino: usa o MESMO chef (receita identica),
mas NAO tem gerente (roda os pratos que voce pedir, sem horario nem limite) e usa
um cozinheiro de mentira (EA-fantasma / VEA). A "conta do dia" (reset) ja foi unificada
no treino canonico (S363 #E6)
— ele agora le a mesma folha do restaurante; so os treinos
antigos (matematico / validator) ainda usam papelzinho separado.

Principio que decide compartilhar vs fingir:
- E uma DECISAO/REGRA de negocio (parear, risco, direcao, morte/pass, cap de DD,
quando zerar o contador)? -> logica unica, compartilhada.
- E uma ENTRADA DO MUNDO (heartbeat, saldo do broker, relogio, execucao no MT5)?
-> fingida no simulador de proposito (rapido + deterministico + sem efeito real).


As 4 camadas — mapa rapido

# Camada O que faz Pool real (producao) Simulador Compartilha?
1 Gerente (agendador) Decide QUANDO abrir batalha: mercado aberto? teto de rodadas/dia? intervalo passou? ja tem uma rolando? _run_af_scheduler (main.py) + check_daily_round_limit (constants.py) NAO tem — roda N rodadas direto (rounds=N) NAO — so producao. Sim substitui pelo proprio loop
2 Chef (engine) Quem joga contra quem, quanto apostar, pra que lado, quem morreu/passou, quanto ainda pode perder no dia + se a migalha do dia nao vale trade (piso #297) server/af/engine.py (pair_accounts, calc_risk, _choose_direction, find_dead/passed, _daily_dd_remaining, piso min_daily_risk_pct em _individual_risk) usa os MESMOS (sim passa daily_floor_active) SIM — fonte unica
3 Cozinheiro (execucao) Faz a ordem acontecer + espera "fiz" + atualiza saldo EA real (GUI Win32 no MT5) EA-fantasma (VEA) ou so anota o resultado (random walk) NAO — fingido de proposito
4 Conta do dia (reset) Zera a perda acumulada do dia no horario de virada (swap) reset_daily_pnl (lifecycle.py) + helpers (constants.py) canonico (runner) consome o MESMO reset_daily_pnl (S363 #E6); math (harness.py) / validator.py / af_tick_real.py mantem reset proprio SIM no canonico (S363) — math/validator fora de escopo

O que o simulador FINGE (entradas do mundo — saudavel)

Entrada Pool real Simulador finge como
"A conta esta viva agora?" (heartbeat) pinga o EA e espera resposta (fail-closed) conta-fantasma sempre responde / pula o ping
Saldo real no broker le do que a conta reporta (sync_real_balances) saldo inventado do resultado da batalha
Abrir ordem no MetaTrader manda o robo clicar (GUI Win32) so registra "fiz" (VEA) ou calcula resultado direto
Que horas sao / mercado aberto relogio real + is_market_open / janela de swap relogio virtual (SimClock) — roda quando quiser
Quando comecar a proxima batalha espera intervalo + checa teto + se ja tem uma rolando dispara em sequencia, sem esperar

Por que o teto de rodadas/dia (max_rounds_per_day) nao vale no sim: ele mora na
camada 1 (gerente), nao na camada 2 (chef). O simulador nao usa gerente, entao nunca
le esse teto — independente de ter valor ou nao na config. So producao enforca.


Reset do dia (camada 4) — regra unica IMPLEMENTADA (S363 #E6)

Piso de risco minimo diario (camada 2, chef) — S364 #297


Resumo de 1 linha

Chef (engine) = compartilhado (inclui o piso de risco diario #297). Gerente (agendador +
teto de rodadas) e cozinheiro (execucao) = so producao, o sim substitui. Reset do dia: regra
unica ENTREGUE no sim canonico (S363 #E6)
— math/validator ainda separados de proposito.


Como atualizar este documento

Esta aba /guide#camadas-pool-vs-simulador eh referencia rapida das camadas. Quando uma
peca mudar de lado (ex: a regra unica do reset for implementada), atualizar:

  1. Editar specs/camadas-pool-vs-simulador.md (este arquivo)
  2. Rodar bash scripts/guide-update-and-verify.sh camadas-pool-vs-simulador (commit + push + verify render)
  3. Confirmar em https://linniuc.com/guide#camadas-pool-vs-simulador

Relacionado


Piso Risco/Dia

Status: ATIVO (dormente até o "Daily DD" ser ligado na pool) | Criado: S364 (2026-05-23) | #297

A ideia em 1 minuto

Cada conta tem um tanque de gasolina do dia: quanto ela ainda pode perder hoje antes de
estourar o limite diário da mesa (o "Daily DD"). Cada mesa enche esse tanque com um tamanho
diferente (umas 5%, outras 3%, outras 4% do valor da conta).

O Piso de Risco Diário é a marca de "reserva" no tanque: quando a gasolina do dia cai
abaixo dessa marca, a conta encosta o carro (não abre mais trade) e descansa até amanhã,
quando o tanque enche de novo.

Por que existe: sem o piso, quando sobra muito pouco no tanque, o sistema ainda tentaria
abrir o menor trade possível (0,01 lote) — e esse trade mínimo pode custar MAIS que o que
sobrou, furando o próprio limite diário que deveria respeitar. O piso para antes disso.

Quando o piso age (e quando NÃO age)

Como configurar

No modal Config da pool (aba AF Hedge), ao lado do toggle "Daily DD", tem
o campo "Piso Risco/Dia (%)":

Em uma frase

"Não vale abrir o 2º trade do dia se o que a conta ainda pode arriscar hoje virou migalha
(< 0,5%) — ela descansa e tenta amanhã com o tanque cheio."


GUI Ordens

Status: ACTIVE | Criado S371 (2026-05-23) | Revisado S423 (2026-06-21) — blindagem das 3 ações destrutivas (verificar-antes-de-apertar) na frota, EA 3.123.1 | Fonte: GUIExecution.mqh + GuardLimits.mqh + telemetria signal_events

Visão geral (a analogia)

O EA não usa API de trade (OrderSend/CTrade). Ele age como um humano clicando na
janela do MT5 — abre o diálogo de ordem (tecla F9), digita volume/SL/TP e clica
Comprar/Vender/Fechar, via automação Win32 (user32.dll). Motivo: mesas prop
detectam EA via API; cliques "parecem manuais". Toda ação passa por uma trava
única
(GUI lock) — só uma ação por vez, nunca dois cliques concorrentes.

A blindagem: verificar ANTES de apertar (padrão atual, S423)

Antes a regra era "aperta e torce" — o EA clicava a ação destrutiva e esperava dar certo.
Agora toda ação que mexe em dinheiro CONFERE o estado da tela antes de apertar e DESISTE
com motivo claro se o que ele vê não bate com o pedido.
Nunca mais "paga pra ver".

Vale pras 3 ações:
- Abrir: se a janela não está no estado certo de abrir (ex: o botão de 1-clique do Livro
de Ofertas, que abriria um lote pelado errado), recusa em vez de arriscar.
- Modificar (stop/alvo): lê o campo de volta ANTES de clicar "Modificar". Se o valor
reverteu, ficou vazio, ou diverge do pedido → aborta (não grava stop errado em silêncio).
Confere de novo depois do clique (tolerância por pip).
- Fechar 1 perna do hedge: casa o número EXATO do ticket no botão certo + confere de novo
antes do clique + exige que caia EXATAMENTE uma posição (delta −1). Se fecharia a perna
errada ou as duas (close-by), aborta; se caiu número errado, marca FALHA.

O motivo SEMPRE aparece: quando recusa, o EA manda o motivo nos 2 canais — o técnico (log) e
o leigo (traduzido pro seu Telegram, sino e modal do card). Você nunca fica sem saber POR QUE
uma ação foi barrada. Hoje há 6 motivos de recusa traduzidos (stop reverteu, campo vazio, perna
errada, fecharia as duas, número não bateu, Livro de Ofertas).

As 4 camadas de defesa (quando cada abort dispara)

Os abortos não estão todos no mesmo ponto — formam 4 camadas, cada uma conferindo algo
diferente, da mais cedo pra mais tarde. A diferença-chave: as primeiras barram sem nem abrir
a janela de ordem
; só a Camada 2 (a blindagem) precisa abrir a janela pra LER a tela.

Camada Quando O que confere Exemplos de recusa
0 — Servidor antes de chegar no robô preço confiável + mercado aberto preço velho/sem referência, mercado fechado
1 — Pré-voo robô recebeu o sinal, janela AINDA fechada regra da mesa (sabe sem olhar a tela) lote acima do teto, posições simultâneas demais, rodadas/dia demais, ordem duplicada → nem abre a janela (0 cliques)
2 — Na tela (a blindagem) janela F9 aberta, lendo o diálogo a tela bate com o pedido? botão do Livro de Ofertas, campo de stop vazio/revertido, botão de fechar da perna certa
3 — Pós-clique depois de apertar o resultado bateu? não abriu exatamente +1, não caiu exatamente −1, popup de erro do broker

A diferença que importa: a Camada 1 barra pela regra — o robô já sabe o lote e quantas
posições tem sem olhar nada, então recusa antes de mexer na janela. A Camada 2 (a blindagem
nova) barra pela realidade da tela — só dá pra conferir DEPOIS que a janela abriu e ela lê os
campos. As duas se somam (defesa em profundidade): uma pega o que é proibido pela regra, a outra
pega o que a tela mostrar de errado na hora. A tabela detalhada de cada trava (com o motivo leigo)
vem logo abaixo, em "Travas de segurança".

Os 3 modos (chaves liga/desliga do EA)

Modo Input do EA O que faz Em produção (pool)
Visual InpVisualMode Traz a janelinha pra frente + pausa 400ms antes de cada clique, pra um humano VER o EA operando ao vivo OFF (ninguém olhando) → sem pausa, mais rápido
Log detalhado InpDebugLog / coluna accounts.debug_log (server-push, S371) Grava o passo-a-passo [GUI-TRACE] de cada etapa OFF (senão enche o log) — liga por conta pelo painel só pra debug
Debug GUI InpGUIDebug Mostra as tripas do diálogo (combo, leitura de campos, settle, readback) OFF — só investigação pesada

O debug_log agora é ligável por conta direto pelo painel (sem mexer no gráfico
do PC daquela conta): PUT /api/accounts/{id}/settings {"debug_log": true} → o EA
aplica em runtime no próximo sync (P310).

O popup do broker (ler + decidir)

A Exness mostra um aviso de alavancagem antes de cada trade. O EA lê o texto do
aviso (varre os campos de texto da janelinha) e decide:
- Texto de aviso normal ("alavancagem", "risco", "confirma") → confirma e segue.
- Texto de ERRO ("saldo insuficiente", "rejeitado", "off quotes", "inválido") →
aborta e marca a ordem como FALHA — não finge que deu certo.

Fluxo de cliques por ação (+ tempos reais medidos na sandbox)

Ação Cliques de trade Etapas principais Tempo típico
Abrir 1 (Comprar OU Vender) F9 → selecionar símbolo → sub-diálogo → setar volume → SL → TP → clicar direção → confirmar popup ~1,0–1,3s
Modificar 2 (abrir painel "Modificar a Posição" + Modificar) abrir diálogo da posição → painel modificar → setar SL/TP → Modificar → confirmar ~0,5s
Fechar 1 (Fechar #ticket… a mercado) abrir diálogo da posição (duplo-clique na linha) → clicar Fechar do ticket certo → confirmar popup → detectar fechamento ~0,7s

As esperas são event-driven (sai assim que o resultado chega; marca 0ms quando
rápido). A seleção de símbolo dá 0ms quando já está no símbolo certo (não redigita à
toa). O maior pedaço do Abrir é confirmar o popup do broker + fechar a janelinha
(~400ms + ~250ms). Nenhum clique de trade redundante.

Travas de segurança (o que aborta ou marca FALHA)

Trava Ação Quando dispara Resultado (+ motivo leigo)
Erro do broker todas Popup com texto de erro (rejeitado/insuficiente/off quotes) Aborta + ACK FALHA (não finge sucesso)
Volume não entra Abrir Campo de volume diverge do alvo após retries ABORTA com ZERO ordens (nunca abre com tamanho errado)
Livro de Ofertas (S423) Abrir O botão clicável seria o do Livro de Ofertas (1-clique, lote pelado) Aborta — "abertura recusada (botão errado da janela)"
Delta de posição Abrir Após abrir, não apareceu EXATAMENTE +1 posição (veio 0 ou 2) ACK FALHA (pega anomalia / posição manual concorrente)
Readback do stop (S423) Modificar O campo de SL/TP reverteu, ficou vazio, ou diverge do pedido ANTES de clicar Aborta sem clicar — "stop reverteu" / "campo vazio"; posição segue com o stop anterior
Botão certo por ticket (S371+S423) Fechar Casa o botão Fechar pelo número EXATO do ticket (match ancorado, não pedaço) Nunca fecha a perna oposta por engano
Fecharia as duas (S423) Fechar O botão clicável fecharia as DUAS pernas do hedge (close-by) Aborta — "fecharia as duas pernas"; nada foi fechado
Delta −1 (S423) Fechar Após fechar, não caiu EXATAMENTE uma posição ACK FALHA — "número de posições não caiu uma" (marca pra conferência)
GUI lock todas Sempre Uma ação GUI por vez; auto-libera se travar >5min (sentinela)

Os motivos viram texto leigo no seu Telegram + sino + modal do card (6 motivos
traduzidos). O motivo técnico cru NUNCA aparece pra você — sempre a versão em português claro.

Onde a correção do PAR mora: quem fica comprado vs vendido é decidido e blindado
no servidor (motor AF + hedge guards), NÃO no EA — o EA só executa o sinal que
chega. Não há trava EA-side de "par errado" porque o EA não conhece o par.

Quão completo está (status S423)

Aspecto Status Nota
Blindagem 3 ações (verificar-antes-de-apertar) ✅ Na frota S423 Abrir/Modificar/Fechar conferem a tela e abortam com motivo leigo; EA 3.123.1 nas 13 contas
Precisão de cliques ✅ Preciso 1–2 cliques de trade por ação, sem redundância
Performance ✅ Otimizado Esperas event-driven; OPEN ~1s, MODIFY ~0,5s, CLOSE ~0,7s
Fechar ticket correto ✅ Corrigido S371 Bug "fechar 1 fechava as 2 opostas" resolvido (casar botão por ticket). Ver ea-gui-close-opposite-double-INVESTIGATION.md
Telemetria ✅ Refinada ms por etapa em signal_events (gui_s1..s12 / m1..m6 / c1..c3) + dump de controles (GUI_DumpButtons, debug-gated)
Detecção de erro do broker ✅ Ativa Lê popup, distingue erro de confirmação
Rede 2-opostas (server-side) ✅ Ativa S371 Alerta Telegram se uma conta tiver BUY+SELL mesmo símbolo abertos (pré-condição do bug raro)

Validação ao vivo S423 (sandbox id=3, b3.123.1): E2E em 2 passadas — fechar 1 perna do
hedge deixou a outra de pé nas duas (pernas opostas: 1ª fechou a comprada, 2ª a vendida =
delta −1 confirmado); ajuste de stop aceito sem falso-aborto; 6/6 motivos de recusa
traduzidos pra leigo no servidor. Rollout fleet COMPLETO (deploy_ea.sh, 13/13 contas
em 3.123.1, 0 erros). Antecedente: bug "fechar 1 fechava as 2 opostas" resolvido em S371,
agora endurecido (match ancorado por ticket) e validado na frota inteira.


Rechamar Contas

Quando uma rodada começa, algumas contas podem ficar de fora — por exemplo o
computador de um dos PCs caiu e os EAs ficaram offline. A rodada abre com menos
duplas do que poderia. O Raio-X da rodada mostra quem ficou de fora e por quê;
o Rechamar Contas deixa você trazer de volta quem voltou, com 1 clique, sem
mexer nas batalhas que já estão rolando.

Analogia: o ônibus de alguns jogadores quebrou e a partida começou com poucas
duplas. Quando o ônibus chega, aparece um aviso "os atrasados chegaram — quer
incluir?". Você clica e o sistema monta duplas novas na mesma partida.

O Raio-X da rodada

No card da rodada (aba AF Hedge) aparece a faixa "Raio-X da rodada · X de Y
em batalha · Z de fora"
. Abrindo, você vê cada conta de fora com o motivo em
linguagem clara e um ícone do "balde":

Quando o botão "Rechamar" aparece

O servidor fica de olho: quando 2 ou mais contas transitórias voltam a dar
sinal de vida
(ou fecham a posição que tinham aberta) e dá pra formar pelo menos
uma dupla de empresas diferentes, aparece a faixa "🔄 N conta(s) voltaram"
com o botão Rechamar N contas. Você também recebe o aviso na hora (notificação
instantânea no painel).

Só aparece enquanto a rodada está em andamento. Rodada encerrada não rechama.

O que o clique faz (e as regras que respeita)

Ao clicar, o sistema:

  1. Confere quem está online de verdade naquele instante (quem não responder
    fica de fora de novo — sem risco de abrir sozinho).
  2. Monta duplas novas só com quem voltou + quem estava online mas sem par,
    sempre cruzando empresas diferentes (a regra de nunca parear a mesma
    empresa continua valendo).
  3. Não toca nas duplas que já estão em batalha — só acrescenta as novas.
  4. Respeita o espaçamento de horário (anti-detecção): a dupla nova não abre
    perto demais de outra dupla da mesma empresa mesmo que essa já tenha
    fechado
    — porque a prop firm viu aquele trade. Também segue a checagem de
    preço e o piso de risco do dia, igual a qualquer dupla normal.
  5. Abre os dois lados juntos ou nenhum (hedge nunca fica com uma perna solta).

O selo "RECHAMADO"

Toda dupla criada por rechamada ganha o selo 🔄 RECHAMADO no card. É
transparência: ela entrou depois do início da rodada, então o lucro/prejuízo
dela pode ser diferente das duplas que abriram no começo.

Em resumo


Cotação Recusada

Status: ATIVO (no ar desde EA 3.101.0) | Criado: S392 (2026-05-30) | #352

A ideia em 1 minuto

Antes de abrir um par de hedge, o sistema precisa de um preço de referência pra colocar os
freios (stop loss e take profit) no lugar certo. Se esse preço estiver velho, travado ou
esquisito
, abrir o par é como estacionar de olhos vendados — os freios saem tortos e a
corretora recusa calada. Foi o que aconteceu em 29/05: 6 contas tentaram abrir num preço
congelado e falharam todas.

Agora o sistema confere o preço antes e, se não confia, não abre e te avisa o motivo
num selo no card da batalha — em vez de abrir torto.

O que o selo mostra

No card do par, quando ele está esperando em vez de abrir, aparece um selo com o motivo em
português (passe o mouse pra ler a explicação completa no balãozinho):

Nunca aparece código interno no selo — só português claro.

O que o sistema confere no preço

  1. De quem veio: só aceita preço dos robôs do próprio grupo do par. Resposta de uma conta de
    fora é ignorada.
  2. Se está fresco: o robô manda junto o horário do último tick (a última vez que o preço
    mexeu). Tick velho (mais de 30s) = recusa "cotação velha".
  3. Se bate com o mercado: cruza com a referência do TradingView. Se o preço fugiu mais de 3%
    dela, recusa.

Se passar em tudo: abre normal, idêntico ao de sempre (zero diferença no dia bom). Só barra
quando o preço é ruim.

Por que isso te protege

O selo só tira aberturas ruins — nunca adia uma boa sem motivo. Num hedge, abrir um lado com
preço errado quebra o espelho e gera perda que o outro lado não compensa. Recusar protege as
duas pernas
igualmente.

Em uma frase

"Se o preço pra abrir o par não é confiável, o sistema não abre, espera o próximo bom, e te diz
o porquê no card — em vez de abrir torto e a corretora recusar calada."


Virada de Juros

Status: ATIVO (no ar desde EA 3.103.0) | Criado: S392 (2026-05-30)

A ideia em 1 minuto

Toda madrugada a corretora "vira o dia" e cobra um juro de rolagem (swap) de quem ficou com
posição aberta. Pra evitar essa cobrança (e o risco de o preço dar um salto na virada), o
sistema fecha os pares de hedge um pouco antes dessa hora.

A novidade (build 3.103): o robô agora sabe sozinho que horas são essas — ele calcula pelo
padrão de Nova York, ajustando o horário de verão automaticamente. Então mesmo se o site
cair
, o robô fecha na hora certa por conta própria.

Como funciona (dupla camada)

O que mudou no build 3.103

  1. Robô calcula o horário sozinho: padrão de Nova York com horário de verão (21h UTC no
    verão, 22h UTC no inverno). Não depende mais do site mandar o horário — se o site cair, ainda
    fecha certo.
  2. Nunca fecha posição recém-aberta: o robô só fecha pela virada de juros uma posição que
    já viveu tempo suficiente pra estar de fato na janela. Antes, num caso raro, ele podia
    matar um par recém-aberto em segundos (foi exatamente o que derrubou o par 8875 em ~5s em
    29/05). Corrigido.
  3. Avisa se o horário do site divergir: se o horário que o site manda for diferente do que o
    robô calculou (ex: config da pool com horário de verão desatualizado), o robô avisa — e
    usa o do site quando a config está fresca, ou o calculado quando o site está fora.

Importante: isso não muda stop loss, take profit, direção ou tamanho da ordem. Só afeta
quando fechar pela virada de juros (e passa pela trava de hedge — fecha as duas pernas
juntas).

Em uma frase

"O robô fecha o par antes da cobrança de juro da madrugada, sabendo a hora sozinho mesmo com o
site fora — e sem nunca matar um par recém-aberto por engano."


Baixar Log

Status: ATIVO (no ar desde EA 3.99.0 / servidor S388) | Criado: S392 (2026-05-30) | Admin

A ideia em 1 minuto

O painel ao vivo só guarda as últimas 15 linhas do que o robô faz. Numa rajada (abrir um par
= ~12 cliques, fechar = ~4) isso estoura na hora e some antes de você ver. Quando um problema
já aconteceu, esse resumo curto não conta a história toda.

Agora dá pra pedir o diário completo do dia (o log inteiro do MT5) de qualquer conta
até das máquinas remotas — pra investigar com calma depois.

Como funciona

  1. Você (admin) pede: "conta X, sobe teu log do dia tal".
  2. O robô daquela conta lê o arquivo de log inteiro do dia no MT5 e sobe em pedacinhos pro
    servidor (sem travar — ele continua operando normalmente enquanto manda).
  3. O servidor remonta o arquivo e deixa baixável pra você ler.

Funciona pela mesma conexão robô↔servidor que já existe, então alcança as contas dos outros PCs
também.

Detalhes que importam

Quando usar

Quando um par fez algo estranho e o painel ao vivo já perdeu as linhas — ex: um par que fechou em
segundos, uma ordem que falhou, um clique que não saiu. O diário completo tem tudo, sem janela e
sem perda por timing.

Em uma frase

"Quando o painel ao vivo já apagou as linhas, peça o diário completo do dia daquela conta (até
das remotas) e investigue o que aconteceu com calma."


Conta Caiu na Batalha

Quando uma conta para de dar sinal de vida (PC desligou, internet caiu, MetaTrader fechou), o sistema nunca fecha a ordem dela só por estar offline. As ordens de proteção (alvo e stop) ficam guardadas no broker e funcionam sozinhas, mesmo com o robô desligado. Esta página explica, em ordem, o que o sistema faz em cada momento.

A regra de ouro

Uma ordem só fecha pelos motivos normais do hedge:

  1. Bateu o alvo (TP) ou o stop (SL) dela no broker — funciona offline, é ordem real lá.
  2. O outro lado da batalha fechou — aí o par dela também tem que fechar (regra do hedge: fechou de um lado, fecha do outro).
  3. Você mandou fechar na mão (botão de fechar par ou pânico).
  4. O airbag da mesa disparou (o dinheiro tocou o piso de perda da prop firm).

Estar offline não está nessa lista. Conta no escuro = a ordem dela rola até o alvo/stop dela. Cruzar a virada de juros (swap) nesse estado é um custo já previsto e aceito.

O que acontece em cada momento

Antes da batalha abrir, conta offline: o sistema confere o sinal de vida das duas contas antes de abrir. Sem sinal, não abre — adia a batalha e tenta de novo quando a conta voltar.

Batalha rodando, conta cai: nada fecha. A ordem segue protegida pelo alvo/stop no broker. No card aparece o aviso de "conta offline há X" e, se você quiser não esperar, um botão para encerrar a batalha no papel (isso não fecha a ordem — só destrava o sistema).

A batalha decidiu (um lado fechou) com a outra conta offline: o sistema guarda o recado de fechar por até 24 horas. Quando a conta voltar, ela fecha. O card mostra o resultado daquele lado como "≈ Último valor registrado (há X+)" em laranja — esse número é o último que a conta reportou ANTES de cair, não o fechamento real. O "+" quer dizer "pelo menos esse tempo" (o congelamento foi antes do fim da batalha).

O selo "≈ Último valor registrado"

Sempre que você ver o laranja num card, leia assim: "esse valor pode estar desatualizado — a conta estava offline e o número real do broker ainda não chegou". O Saldo e o Equity da conta no card também ganham um selo de "congelado há X" quando a conta está sem dar sinal.

Esses avisos somem sozinhos quando a conta volta: o sistema busca o valor verdadeiro do broker e troca o número estimado pelo real. Você não precisa fazer nada.

Como o valor real é acertado quando a conta volta

Duas formas, automáticas, nesta ordem:

  1. O broker reportou o fechamento — o sistema usa esse valor direto.
  2. A posição já tinha fechado no alvo/stop durante o apagão — o sistema compara o saldo da conta antes e depois e descobre quanto rendeu de verdade (bate ao centavo, com a virada de juros incluída).

Em qualquer dúvida (saldo velho, outra ordem mexeu na conta no meio, valor fora do esperado), o sistema NÃO chuta: mantém o laranja e o aviso, porque mostrar "ainda não sei o real" é mais honesto que inventar um número.

Resumo de uma linha

Conta offline muda como o sistema espera e registra — nunca muda se uma ordem fecha. Fechar só acontece pelos motivos normais do hedge, e o número estimado vira o real sozinho quando a conta volta.


Vigia de Recusa

Quando o robô coloca uma ordem pela tela do MT5 (pra parecer manual e não ser detectado por prop
firm), ele precisa saber com certeza o desfecho: a ordem abriu, foi recusada (e por quê),
ou ainda está no ar. Isso é vital pro hedge — se uma perna do par abre e a outra é recusada sem o
robô perceber, sobra uma perna nua (sozinha, sem proteção), o pior cenário numa conta de prop firm.

Este guia explica, sem jargão, como o robô descobre isso e por que o desenho é robusto.

A ideia central: 2 postos de fiscalização

Uma ordem pode ser recusada em dois lugares diferentes — e o lugar (não o motivo) decide quem
detecta a recusa:

Robô clica a ordem POSTO 1 — o TERMINAL (seu PC) confere LOCAL, antes de enviar recusou → Diálogo F9 pega sem dinheiro · lote inválido stop inválido negociação desabilitada passou ↓ manda pro servidor do broker POSTO 2 — o SERVIDOR do broker decide (pode demorar / async) recusou → OnTradeTransaction pega mercado fechado · requote sem cotação PROP FIRM nega depois aceitou ↓ ABRIU ✓ → PositionsTotal pega (+0,57s) Journal no disco RESERVA de TODOS os postos (verbatim, 🐢 lento) A sacada que mata a confusão: O robô NÃO precisa adivinhar em qual posto cada recusa cai. O F9 vigia o Posto 1 e o OnTradeTransaction vigia o Posto 2 — os DOIS estão de olho ao mesmo tempo. Caiu no 1, o F9 pega; caiu no 2, o OTT pega. Juntos = tudo. O disco é a rede que pega QUALQUER coisa que escape dos dois (só que devagar).

Posto 1 — o terminal (seu PC): o MT5 confere a ordem localmente, antes de enviar. Se reprova
aqui, mostra o motivo no diálogo F9 na hora. São recusas que o terminal já sabe de cabeça:

Recusa do Posto 1 (local) Por que é local
Sem dinheiro (margem) o terminal sabe seu saldo
Lote inválido (abaixo do mínimo) o terminal sabe os limites do ativo
Stop inválido (SL/TP perto demais) o terminal sabe a distância mínima
Negociação desabilitada o terminal sabe se o ativo está liberado

Posto 2 — o servidor do broker: se passou no Posto 1, a ordem é enviada e o servidor decide.
A recusa aqui pode ser na hora (broker rápido) ou depois (assíncrona — a prop firm aceita e
nega segundos depois). São recusas que só o servidor conhece:

Recusa do Posto 2 (servidor)
Mercado fechado
Requote / preço mudou
Sem cotação
Prop firm aceita e nega depois (o perigo da perna nua)

A pergunta que confunde — "o F9 pega TODAS as recusas?" — tem resposta simples: NÃO, e nem
precisa.
O F9 pega todas as do Posto 1; o OnTradeTransaction pega todas as do Posto 2. Os
dois vigiam ao mesmo tempo, então juntos cobrem tudo. O Journal no disco é a rede pra qualquer
escape (mais lento).

As 4 testemunhas (quem vê primeiro vence)

Ao clicar a ordem, o robô arma 4 testemunhas ao mesmo tempo. A primeira que enxergar o
desfecho avisa o servidor e desarma as outras (não tem aviso duplicado):

Robô clica a ordem arma 4 testemunhas (a 1ª a ver vence) PositionsTotal pega: ABRIU (fill) +0,57s o mais rápido OnTradeTransaction abriu + recusa do SERVIDOR + motivo +1,3s motivo universal (código) Diálogo F9 recusa LOCAL (sem dinheiro etc) + motivo ~95ms o robô já lê isso Journal no disco RESERVA: tudo, verbatim 🐢 lento flush ~30s a min 1 aviso de desfecho a 1ª a ver desarma as outras Servidor cuida da perna órfã Rede embaixo de TUDO (se as 4 falharem) stop-loss vive no broker (sempre) · o robô desiste por tempo aos ~55s · fecho da órfã só no piso de 135s — tudo isso chega folgado antes do piso

Testemunha O que pega Velocidade Dá o motivo?
PositionsTotal abriu (a posição apareceu) +0,57s (o mais rápido)
OnTradeTransaction abriu + recusa do servidor +1,3s ✅ código + texto do broker (universal)
Diálogo F9 recusa local (Posto 1) ~95ms (enquanto o diálogo está aberto) ⚠️ texto do broker (nem sempre universal)
Journal no disco TUDO (reserva) 🐢 lento (~30s a minutos) ✅ verbatim

Por que +1,3s do OnTradeTransaction é aceitável: o fecho da perna órfã só acontece no piso de
tempo mínimo de vida (135s)
. Qualquer das 4 testemunhas (e até a rede de 55s) chega muito antes
disso. Não precisa ser instantâneo — precisa ser antes do piso.

Por que não lemos o Journal do MT5 "ao vivo"

O Journal do MT5 (a aba que lista "ordem enviada / recusada / executada") seria a fonte perfeita:
pega tudo. O problema: não dá pra lê-lo em tempo real. Provamos cada caminho:

Tentativa de ler o Journal ao vivo Resultado
Arquivo de log no disco ❌ o MT5 grava em blocos, com atraso (a doc oficial confirma)
Ler a janela do Journal pela API do Windows ❌ é uma lista "virtual" — não devolve o texto
UI Automation / acessibilidade ❌ o MT5 desenha a janela na mão, sem expor o texto
Forçar o flush do log ❌ só funciona pro log do próprio robô, não o do broker
Injetar código no MT5 / OCR da tela ⚠️ frágil e detectável por prop firm — descartado

Por isso o robô reporta do que ELE consegue ler (as 4 testemunhas acima), e o Journal no disco fica
só como reserva durável (lenta).


Em uma frase: o robô vigia os 2 postos ao mesmo tempo (F9 no terminal, OnTradeTransaction no
servidor), confirma o "abriu" pelo PositionsTotal, e tem o Journal no disco + o stop-loss como redes —
então nenhuma perna fica nua sem o robô saber.


Preço do VEA

Status: ACTIVE | Guia leigo dos valores do preço sintético do simulador.
SSoT técnico: specs/sim-vea-preco-temporal-fiel.md + server/sim/runner.py (make_seeded_price_book).

O que é

No simulador, o "robô-fake" (VEA) precisa reportar um preço pro ouro (XAUUSD). Esse preço
não é dado real de mercado — é uma fórmula determinística que anda no tempo (mesma
semente + mesmo instante = mesmo preço, replay perfeito). Ele existe pra exercitar a engine
do site
(anti-detecção R-SAFE, abrir/fechar par, lifecycle), NÃO pra prever o mercado real.

Os valores que usamos

Valor Atual O que controla
Base 4000 nível do preço (centro da onda) — bate com a cotação real do ouro hoje
Banda ±3% o quanto o preço sobe/desce em torno da base (≈ 3880–4120)
Período 40 min quanto tempo leva pra completar 1 ciclo (sobe e desce)
Formato triângulo inclinação constante (sobe reto, desce reto), reverte à média
Ruído < 0,30% um tiquinho aleatório pra não ficar liso demais (abaixo do gate da R-SAFE2)

Com isso o preço anda ~$0,20/s → ~1,5% em 5 min (= 5× o gate de 0,30% da R-SAFE2). A %
não muda com a base — só o nível: por isso a R-SAFE é intocada (o gate é 0,30% do preço).

Por que esses valores (o raciocínio)

O que é realista — e o que NÃO é

Aspecto Veredicto
Faixa de 1 trade (SL/TP ~$17-34 em torno da base) ✅ realista
Movimento ao longo de 1 trade ✅ plausível (sessão um pouco agitada)
Tendência de dias/semanas ❌ não existe (a onda reverte à média a cada 40 min)
Previsão de lucro real da estratégia ❌ o ROI da sim é mecânico, não palpite de mercado

Em resumo: os valores fazem sentido pro que a métrica mede — saúde da engine
(R-SAFE atua? pares abrem? lifecycle flui? ROI mecânico fecha?). Não são previsão de mercado.

Consideração de realismo

A base já está em $4000 (cotação real do ouro hoje) — feito nesta sessão; foi neutro pra
R-SAFE (o gate é 0,30% do preço, escala junto) e os testes passaram a ler a base do código em
vez de fixar 2000. Pendência ainda em aberto (opcional): pra uma sessão "mais padrão" (menos
agitada), a banda poderia cair de ±3% pra ~1,5-2% — ao custo de mais reagendamentos da
R-SAFE2
(a única trava que custa tempo). Como esse custo é ~0,5s/rodada, é trade-off de
realismo, não de velocidade.

Tempo: 1 rodada = +1 hora virtual (mas métrica é por RODADA)

O relógio virtual do sim avança +1 hora por rodada (_sim_tick(delta_sec=3600)). O avanço
extra da R-SAFE2 (quando reagenda) tem teto de 30 min/rodada — sempre menor que a 1 hora,
pra nunca cruzar a virada de juros / fim do dia. Por isso toda rodada cabe folgada no dia e
nenhuma é abortada por tempo.

Importante: as métricas são contadas por RODADA, não por dia de calendário (no cérebro
das métricas, 1 rodada = 1 dia lógico — round_seconds = SECONDS_PER_DAY). Então o fato de o
relógio andar só 1h/rodada não distorce nenhuma métrica — elas se moldam pela rodada.


Teste que Reprova

A ideia mais importante de teste que existe: um teste que só sabe passar não
vale nada. Ele tem que PROVAR que sabe reprovar quando deveria.

A pergunta que muda tudo

A pergunta errada — a que quase todo mundo faz — é: "meu teste passou?"

A pergunta certa é: "meu teste CONSEGUE reprovar quando o código está quebrado?"

Um teste que fica verde é reconfortante. Mas um teste que fica verde mesmo quando
o código está errado
é pior que teste nenhum — porque dá uma falsa sensação de
segurança. Ele vira um vigia dormindo no posto: parece que tem alguém cuidando, mas
não tem.

Por isso, nos testes do EA Tester (a bancada que decide se um robô pode subir pra
rodar com dinheiro real), a gente coloca de propósito valores e situações que
têm que dar errado — e exige que o teste os reprove. Se ele não reprovar, o
teste é que está com defeito.

Os 3 mecanismos "feitos pra falhar"

Não é um truque só — são três, cada um num nível diferente.

Mecanismo O que faz Analogia Se ele falhar em falhar...
Controle negativo Alimenta a própria checagem com um dado sabidamente-ruim e EXIGE que ela reprove Teste de COVID numa amostra que você SABE que é negativa — se der positivo, o kit está quebrado A checagem diz "ok" pro lixo → ela é mentirosa (não consegue ficar vermelha)
Teste de mutação A gente quebra o código de verdade (temporariamente) e confirma que o teste fica vermelho; depois restaura Pra testar o alarme de incêndio, você acende um fósforo embaixo — se não apitar, o alarme é decorativo Quebrei o código e o teste continuou verde → o teste é tautológico (não vigia nada)
Casos tristes Cada checagem tem ramos explícitos "esse input DEVE dar reprovado" (não só o caminho feliz) Testar a fechadura tentando abrir com a chave errada, não só com a certa Só testei o caminho feliz → um bug no caminho triste passa batido

A diferença entre eles: o controle negativo testa se a régua sabe medir
(alimenta ela com lixo). O teste de mutação testa se o teste está grudado no
código certo
(quebra o código de verdade). Os casos tristes garantem que os
caminhos de erro também são exercitados, não só o sucesso.

De onde vem (nada é invenção nossa)

Vale a honestidade: nenhuma dessas ideias é nova nem exclusiva daqui. São práticas
estabelecidas, algumas com décadas.

Ideia Origem Idade
Controle negativo Ciência experimental de laboratório Décadas
Teste de mutação Academia de ciência da computação Desde ~1971
Negative testing (casos tristes) QA clássico "Teste 101"

O que é incomum não é conhecer os nomes — é exigir os três como porteiro. A
maioria dos times escreve só o caminho feliz, vê verde, e chama de pronto. Fazer do
controle negativo + teste de mutação um requisito obrigatório pra uma verificação
"contar" é disciplina acima da média — mas é disciplina madura, não moda passageira.

É "arquitetura moderna"?

Separando duas coisas:

Resumindo: não é tecnologia moderna, é higiene de teste que muitos conhecem mas
poucos aplicam com rigor
.

Por que importa AQUI (a aposta é dinheiro real)

O custo de todo esse cuidado é proporcional ao risco. Essas verificações não são
enfeite: elas decidem se um robô sobe pra operar numa conta de prop firm (mesa que
banca o trader). Um teste falso-verde — que aprova sem realmente checar —
deixaria passar um robô quebrado direto pro dinheiro real.

Quanto maior a aposta, mais você paga pra garantir que o guarda não está dormindo.
Num script de brincadeira, teste falso-verde é chato. Aqui, seria caro.

Exemplo concreto: fraco vs forte

Verificação fraca (só caminho feliz): manda uma ordem, o robô responde "abri",
e a gente acredita na palavra dele.

mandei OPEN -> robô respondeu "FILLED" -> teste VERDE

O furo: e se o robô responder "FILLED" mas não tiver posição nenhuma atrás
(um fantasma)? O teste fraco não percebe — ele confia no recibo do próprio robô.

Verificação forte (Contrato v2, a que usamos hoje): o veredito lê uma fonte
independente
— a lista real de posições do broker — e ainda tem controle negativo
+ teste de mutação por trás.

mandei OPEN -> robô respondeu "FILLED" com ticket X
  -> confiro na lista REAL do broker: o ticket X existe como posição viva?
     - existe  -> VERDE (efeito confirmado por quem não é o próprio robô)
     - fantasma -> VERMELHO (o robô mentiu)
  + controle negativo: alimento a checagem com "ticket=0" e exijo que ela reprove
  + teste de mutação: quebro a checagem e confirmo que o teste fica vermelho

A diferença não é o robô — é quem dá a palavra final. No fraco, é o próprio robô.
No forte, é uma testemunha independente + a garantia de que a régua sabe medir.


A sacada, no fundo, é uma inversão mental: parar de perguntar "passou?" e passar
a perguntar "consegue reprovar quando deveria?". Um teste que nunca viu o vermelho
é um alarme que nunca foi testado com fumaça.