Infra
RN-224 — A migração cria a extensão pgvector sozinha, de forma idempotente
A migration 0045 executa CREATE EXTENSION IF NOT EXISTS vector antes de
criar a tabela, em vez de assumir que docker/postgres/init.sql já rodou —
esse arquivo só executa na PRIMEIRA inicialização do volume Postgres, e um
ambiente com volume antigo pode não ter a extensão. IF NOT EXISTS é
idempotente: local (onde a extensão já estava instalada) e um ambiente
novo passam pela mesma linha sem diferença de comportamento.
- Onde:
apps/api/src/db/migrations/0045_shallow_randall.sql - ADR: 0079
O papel dbre vira dois scripts mecânicos (RN-400..403, ADR 0093)
RN-402 — O relatório de backup relê exatamente a lógica de collectBackup(), nunca uma segunda forma de calcular
relatorio-backup.ts não é um gauge Prometheus novo — é a MESMA leitura
de backup_runs que DomainGaugesCollector.collectBackup() já faz
(último SUCESSO, para idade e tamanho; a ÚLTIMA execução, para pegar o
caso de estar falhando há dias com um backup bom mais antigo), sob
demanda. avaliarBackup é pura — recebe as duas linhas e o instante
atual, devolve status
(ok|atrasado|nunca_houve|falha_recente_com_sucesso_antigo). O
limiar de "atrasado" (26h = BACKUP_AGE_ATRASADO_SEGUNDOS) é o MESMO do
alerta brabo-backup-atrasado
(deploy/k8s/observability/alerts/brabo-alerts.yaml), duplicado (não
importado — o YAML do Grafana não é lido pelo processo Node) e sujeito a
divergir se um lado mudar sem o outro.
- Onde:
apps/api/scripts/relatorio-backup.ts(avaliarBackup, linha 82;BACKUP_AGE_ATRASADO_SEGUNDOS, linha 54) - Teste:
apps/api/test/scripts/relatorio-backup.spec.ts— nunca houve backup, backup ok, atrasado, exatamente no limiar (não conta como atrasado), falha recente com sucesso antigo, atrasado tem prioridade sobre falha recente, idade nunca negativa - ADR: 0093
RN-403 — O relatório de backup NÃO reexecuta o restore; ele aponta para o procedimento já testado
O procedimento de restaurar de verdade (make test-restore /
deploy/k8s/test-restore.sh) já foi executado e está documentado em
docs/runbook.md#restore, com RTO real (~40s contra um banco de ~108 KB)
registrado na seção "Última execução verificada" daquele documento.
relatorio-backup.ts responde uma pergunta mais estreita e mais
frequente — "o backup que esse restore usaria está saudável agora?" — e
cita o runbook para quem precisa de fato restaurar, em vez de duplicar o
procedimento ou reexecutá-lo.
- Onde:
apps/api/scripts/relatorio-backup.ts(mensagem final deimprimir, citandodocs/runbook.md#restore) - Teste: não aplicável (é texto estático apontando para o runbook,
não lógica); a existência do procedimento testado é RN de
docs/runbook.md#restoreem si - ADR: 0093
"N agentes online" no dashboard — status AO VIVO, nunca presença histórica (RN-409, ADR 0097)
RN-413 — Templates de prompt vivem fora do código, versionados no grafo
Todo prompt de agente era heredoc Elixir inline até esta entrega —
identidades (Engine.Harness.Agents), kickoffs de PO/Arquiteto/Dev
Lead/UX/Infra, o prompt de sumarização do ContextManager. Um template
de prompt agora pode viver como (:PromptTemplate {name})-[:HAS_VERSION]-> (:PromptVersion {version, body, hash, active}) no Neo4j, gravado via
POST /internal/graph/prompt-templates e lido via GET /internal/graph/prompt-templates/:name. Upsert é idempotente por hash —
gravar o MESMO conteúdo duas vezes não cria versão nova, só a versão com
hash igual já existente é devolvida.
Primeira leva extraída para prompts/*.md (front-matter name/version,
placeholders documentados numa seção "Variáveis" para trechos que no
.ex original eram interpolação): ux-designer-identity,
psychologist-kickoff, anamnese-kickoff, context-manager-summarize.
scripts/dev/seed-prompts.ts lê prompts/*.md, calcula hash sha256 do
corpo e envia ao endpoint acima. Nenhum .ex foi editado nesta
entrega — os quatro GenServers continuam com o texto inline; consumir
os templates do grafo (fonte :graph do InstructionFiles, precedência
db > graph > dir > root) é a Onda seguinte, declarada fora daqui.
- Onde:
apps/api/src/application/use-cases/graph/upsert-prompt-template.use-case.ts,get-prompt-template.use-case.ts;apps/api/src/interfaces/http/internal/internal-graph.controller.ts;prompts/*.md,prompts/README.md;scripts/dev/seed-prompts.ts(SECAO_DE_DOCUMENTACAO,corpoSemDocumentacao,parseFrontMatter) - Teste: casos de uso com
GraphStoremockado (upsert com mesmo hash não duplica) + teste de integração contra Neo4j real, pulando graciosamente quando indisponível;scripts/dev/seed-prompts.spec.ts(parsing de front-matter, hash determinístico, template malformado reprova com mensagem clara; e, desde a AT-244, a varredura de TODOprompts/*.md: o corpo semeado não tem a seção de documentação e cada placeholder aparece nele uma vez só, e o sumarizador recebe os turnos uma vez só pelo mesmo render do engine)
Desde a AT-244 a seção "## Variáveis" de cada arquivo NÃO é semeada: o corpo do template termina na linha
## Variáveis, e o seeder descarta dela em diante. Antes o corpo levava a seção inteira — documentação que cita os mesmos{{placeholders}}do corpo —, e como cada consumidor troca TODAS as ocorrências (String.replace/3), comgraph_templates_enabled?ligada o modelo recebia a documentação e o dado DUAS vezes: o sumarizador doContextManageros turnos em dobro, os kickoffs do Psicólogo e da Anamnese o log de eventos em dobro, e a identidade do UX (atrás dagraph_instruction_templates_enabled?) a documentação em todo turno. Os quatro arquivos tinham a forma; o corte mora no SEEDER, e não no render, porque há um render por consumidor e a pergunta "o que é corpo" é sobre o arquivo, que só o seeder lê. As versões subiram (anamnese-kickoffeux-designer-identitypara"2",psychologist-kickoffecontext-manager-summarizepara"3") com o MESMO texto de prompt. Não fecha sozinho numa instalação que já semeou: a versão ATIVA no grafo segue a antiga, com a documentação, até rodarscripts/dev/seed-prompts.tsde novo — o hash do corpo mudou, então o seeder cria a versão nova e desativa a anterior. Com as flags desligadas (o default) o defeito nunca existiu: vale o texto inline, que nunca teve a seção.
- Origem: decisão do dono do produto, inspirada no repositório ErickWendel/neo4j-ai-experiments (ver ADR 0099)
RN-415 — Modelos Ollama garantidos no boot, degradação do RAG declarada
nomic-embed-text (RAG_EMBEDDING_MODEL) nunca era puxado
automaticamente — bug real, não só desta feature: o entrypoint do
serviço ollama só puxava llama3.2:1b, e o RAG degradava para
léxico-only em SILÊNCIO em qualquer ambiente limpo, sem nenhum sinal de
que isso estava acontecendo. Serviço novo ollama-model-loader (one-shot,
docker-compose.yml dev e prod), lendo OLLAMA_REQUIRED_MODELS
(default gemma3:1b,yi-coder:1.5b,nomic-embed-text), aditivo ao serviço
ollama existente — o entrypoint dele continua intocado.
A degradação deixou de ser silenciosa: rag_search (RN-414) e a busca
híbrida da api já devolviam degraded: true quando o embedding falhava
— o que faltava era um CONSUMIDOR que tornasse essa flag visível. Agora
que a tool existe, o próprio modelo vê o aviso no texto formatado, e o
model-loader reduz a chance de a degradação acontecer em primeiro lugar.
- Onde:
docker/docker-compose.yml,docker/docker-compose.prod.yml,docker/ollama/pull-models.sh;deploy/k8s/base/neo4j/,deploy/k8s/base/ollama/job-model-loader.yaml(template, NÃO wireado em nenhuma kustomization —deploy/k8s/ainda não tem Serviceollamapra apontar, ligar isso hoje faria CrashLoopBackOff em todo deploy real) - Teste:
scripts/dev/verificar-modelos-ollama.sh, executado de ponta a ponta com um modelo pequeno real (all-minilm) para não pagar o custo de vários GB da lista de produção numa máquina compartilhada - Origem: achado durante a fundação do grafo de conhecimento — ver ADR 0100
Terminal do runner local preso em "Abrindo terminal..." para sempre (RN-433)
Achado testando um projeto novo no modo runner (ADR 0103/0104) e abrindo
Code → Dev → Terminal: a tela nunca saía do skeleton de carregamento, e o
console mostrava socket do terminal com erro/socket do terminal fechado
em loop, com backoff crescente até estabilizar em ~5-6s — o padrão do
backoff PADRÃO do phoenix.js.
RN-433 — O socket do terminal desiste depois de um timeout, o default de ENGINE_PUBLIC_URL é alcançável PELO BROWSER, e o endpoint não duplica path
Três defeitos empilhados. Os dois primeiros foram achados investigando o sintoma; o TERCEIRO só apareceu depois de corrigir os dois primeiros — sem eles, o browser nunca alcançava o engine pra revelar o bug de path por baixo. Corrigir só um deixa os outros de pé:
ENGINE_PUBLIC_URL(RN-419) nunca tinha default nenhum nodocker/docker-compose.yml— sóENGINE_URL(http://engine:4000, hostname que SÓ resolve dentro da rede do Compose). O fallback do código (process.env.ENGINE_PUBLIC_URL ?? process.env.ENGINE_URL ?? …,request-runner-ticket.use-case.ts) caía silenciosamente no valor errado, e o browser tentava abrir um WebSocket contra um hostname que ele nunca resolve.docker/docker-compose.ymlganhouENGINE_PUBLIC_URL: ${ENGINE_PUBLIC_URL:-http://localhost:4000}no serviçoapi— o MESMO default queVITE_ENGINE_URLjá usa no serviçoweb, pelo mesmo motivo.- Mesmo com a URL certa, um transporte que NUNCA abre (engine fora do ar,
firewall, reconfiguração futura errada) não tinha teto nenhum:
apps/web/src/lib/terminal-channel.tsnunca passavareconnectAfterMsproSocket, então ophoenix.jscaía no backoff PADRÃO dele e ficava tentando reconectar sozinho pra sempre — o próprio docblock do módulo já declarava a intenção de "sem reconexão automática" (mesma régua desession-channel.ts/RN-108), mas nunca era cumprida na prática. SemonError/onClosechamandohandlers.onErro,TerminalPanelficava preso em'carregando'(RN-088 — toda tela distingue carregando/erro/ vazio — violada em silêncio). Corrigido comreconnectAfterMsdevolvendo um valor que praticamente nunca dispara (24h, mesmo padrão desession-channel.ts) e um timeout PRÓPRIO de 8s (TIMEOUT_CONEXAO_MS): seonOpennão disparar antes disso, o módulo chamahandlers.onErrocom mensagem acionável e desconecta. - O defeito que de fato impedia a conexão, mascarado pelos dois
acima:
terminal-channel.tsconcatenava'/runner/websocket'a umengineWsUrlque a api já devolve PRONTO —ws://host:porta/runner(engineWsUrlPublico()) —, e o PRÓPRIOSocketdophoenix.jsainda acrescenta/websocketsozinho no construtor (this.endPoint = ${endPoint}/websocket). O resultado batia no engine comoGET /runner/runner/websocket/websocket, quePhoenix.Router.NoRouteErrorrecusa — confirmado no log do engine (docker logs brabo-engine-1) durante a verificação end-to-end desta correção.apps/runner/src/channel.ts(o lado do CLI) já fazia certo — passaengineWsUrldireto proSocket, sem tocar — e o teste determinal-channel.tsnunca pegou porque o mock degetTerminalTicketusava umengineWsUrl(http://engine.local, sem/runner) que não reflete o contrato real da api; corrigido junto. Fix:terminal-channel.tspassa a usarengineWsUrl(só com o.replace(/^http/, 'ws')defensivo) direto, sem concatenar path nenhum.
Verificado END TO END contra exp001 (modo runner, sem brabo-runner
real conectado): antes da correção, a aba girava pra sempre; depois, o
log do engine mostra CONNECTED TO EngineWeb.RunnerSocket →
JOINED terminal:<projectId> → pty_resize descartado — sem runner conectado, e a tela mostra RunnerOnboardingPanel com o comando
brabo-runner em menos de 1s — nunca o timeout de 8s do item 2, que
segue como rede de segurança pro caso do engine estar genuinamente fora
do ar.
- Onde:
docker/docker-compose.yml,.env.example,apps/web/src/lib/terminal-channel.ts - Teste:
apps/web/src/lib/terminal-channel.test.ts(socket que nunca abre chamaonErrodentro do timeout, sem reconexão automática do phoenix;engineWsUrldo mock corrigido pra refletir o contrato real — já com/runner, sem/websocket— e a asserção do caminho feliz passa a exigir a URL usada noSocketIGUAL aoengineWsUrldo ticket, sem concatenação) - ADR: nenhum — correção de bug restaurando o comportamento que o ADR 0103/0104 e o próprio docblock do módulo já declaravam como intenção
- Origem: achado testando manualmente um projeto novo no modo
runner; o item 3 só apareceu ao verificar a correção dos itens 1/2 ponta a ponta no browser contra o engine real
Ollama nativo no bootstrap dev e pull de modelo Hugging Face (RN-461..463, ADR 0114/0115)
RN-461 — Ollama nativo: pergunta uma vez, a resposta persiste em .env, nunca pergunta de novo
scripts/dev/preflight.mjs detecta um Ollama nativo já escutando em
OLLAMA_PORT (mesmo default, 11434, de uma instalação nativa) confirmando
por HTTP (GET /api/tags) que é mesmo um Ollama, não qualquer processo na
porta. Confirmado, PERGUNTA uma única vez se é para usar essa instância —
default "Sim" quando não há TTY (o bootstrap.sh roda o comando com stdin
de /dev/null de propósito, para não roubar as setas do usuário) — e
grava a resposta em .env (OLLAMA_MODE=host|container). A partir daí a
detecção nem roda de novo: com OLLAMA_MODE já presente, a função
retorna cedo. ollama/ollama-model-loader entram em profiles: ["local-llm"] no compose para não subirem quando o modo é host. A
ÚNICA forma de reabrir a pergunta é o item de menu dedicado "Docker ›
Reconfigurar Ollama", que apaga as chaves gravadas — nunca uma pergunta
espontânea de novo enquanto elas existirem.
- Onde:
scripts/dev/env-file.mjs:41(escreverEnv),scripts/dev/preflight.mjs:157(ehOllama),scripts/dev/preflight.mjs:202(perguntarUsoDoOllama),scripts/dev/preflight.mjs:238(detectarOllamaNativo),scripts/dev/reconfigurar-ollama.sh,scripts/dev/perfil-ollama.sh,docker/docker-compose.yml:82,119(profiles: ["local-llm"]) - Teste:
scripts/dev/bootstrap.spec.ts(cobre a fiação do menu — o perfil condicional em Deploy › All/Create e o item dedicado "Reconfigurar Ollama"). A lógica de detecção/persistência em si (detectarOllamaNativo/escreverEnv) foi validada ponta a ponta manualmente contra um servidor HTTP fake e contra o Ollama real desta máquina (ver ADR 0114) — lacuna declarada: não existe suíte automatizada própria parapreflight.mjshoje. - ADR: 0114
- Origem: pedido do dono do produto, achado real durante a execução (colisão de porta com Ollama nativo)
O consentimento da base, no host (RN-511)
RN-511 — A base é CONSENTIDA uma vez no host, o compartilhamento é provado MONTANDO, e sem terminal o passo relata em vez de consentir
A RN-500 construiu a base e disse o que fazer quando ela falta —
projectsBase: null, e o modo "Pasta montada" não é oferecido. O que faltava
era alguém pedir que ela fosse configurada: nada no produto o fazia, e o
resultado é que um modo pronto ficou inalcançável para quem instala.
O passo de consentimento vive em pnpm bootstrap (Docker › Base de projetos), propõe $HOME/projetos-brabo e aceita outro caminho digitado.
O nome não é preferência: .env.example já usa ~/brabo-projetos como exemplo
de PROJECT_WORKSPACES_HOST_DIR, a raiz que o PRODUTO gerencia, e propor o
mesmo nome para as duas variáveis seria andar para dentro da colisão que o
ADR 0141 recusou — <base>/loja cairia na mesma pasta física de um projeto
container cujo workspace_dir_name também fosse loja, e o bootstrap daria
git init dentro do projeto do outro.
O nome é só a esquiva; a proteção é mecanismo. validarBase recusa
qualquer candidata que se sobreponha — nos DOIS sentidos — ao checkout do Brabo
(a guarda que a RN-500 já tinha) ou a PROJECT_WORKSPACES_HOST_DIR (a
regra nova), além de recusar ~, caminho relativo e a raiz. A recusa vale para
o caminho DIGITADO, não só para o default; um default bem escolhido só evita
que a recusa seja a primeira coisa que o usuário veja.
O compartilhamento do Docker Desktop é provado MONTANDO, nunca lido de
settings.json. Aquele arquivo não é documentado, muda entre versões e entre
macOS e Windows, e descreve o que o usuário CONFIGUROU — não o que o daemon
fará. É a mesma régua de "capability só é declarada quando provada por
execução" dos ADRs 0041/0042, aplicada ao host. A prova é uma sentinela, e
não um ls seco: pasta vazia e pasta não compartilhada produzem a mesma
listagem vazia, então o script escreve um arquivo e pergunta se o container o
enxerga. Só em macOS e Windows (process.platform); no Linux não há lista de
compartilhamento, e a checagem é existência e escrita. É a SEGUNDA guarda de
plataforma do produto, simétrica à primeira (validarDirDentroDoHomeNoLinux,
no runner): cada uma restringe apenas onde a restrição significa alguma coisa.
A prova tem três desfechos, e eles não colapsam: provado; reprovado (a variável não é gravada, e a recusa nomeia o caminho e onde adicioná-lo); e não consegui provar — sem imagem Docker local para montar —, que grava a variável e diz em voz alta que a prova não rodou, com o comando para rodá-la depois. "Não consegui olhar" virar "está tudo bem" é exatamente como uma base não compartilhada chegaria ao usuário na forma de um container que sobe com a pasta vazia, longe da tela onde a escolha foi feita.
Sem TTY, o passo RELATA e não faz nada. O item de menu do bootstrap.sh
roda com stdin vindo de /dev/null — de propósito, senão qualquer coisa que
leia stdin rouba as setas do usuário — então de lá o script só pode relatar, e
a saída diz o comando que pergunta. É o mesmo desenho de
perguntarUsoDoOllama, que sem terminal aplica o default e avisa em vez de
travar. Um script de consentimento que "consente" sozinho num pipe de CI é a
negação da palavra. Pelo mesmo motivo preflight.mjs relata o estado da
base e nunca pergunta: a resposta é um caminho no disco de alguém, e não existe
default que se possa aplicar em silêncio.
mkdir -p só acontece depois do aceite e da validação — criar pasta a
partir de um caminho que ainda não passou pelas recusas é criar pasta em
qualquer lugar que o processo alcance. E valor já presente nunca é sobrescrito
sem confirmação.
- Onde:
scripts/dev/consentir-base.mjs(o I/O: perguntar, provar, criar, gravar);scripts/dev/base-de-projetos.mjs:143(basePadrao),:177(validarBase) e:208(exigeProvaDeCompartilhamento) — a decisão pura;scripts/dev/env-file.mjs(lerEnv/escreverEnv, extraídas depreflight.mjs, que rodaawait main()no topo e por isso não pode ser importado);scripts/dev/bootstrap.sh(item1.6);scripts/dev/preflight.mjs(relatarBaseDeProjetos) - Teste:
scripts/dev/base-de-projetos.spec.ts(describe('basePadrao'),describe('validarBase')— caminho feliz e as seis recusas — edescribe('exigeProvaDeCompartilhamento'));scripts/dev/bootstrap.spec.ts(a árvore do menu, 25 folhas) - ADR: 0146
- Origem: FASE 28, sessão 2
O broker sobe por padrão no compose local (RN-512)
RN-512 — O broker sai do profiles no compose LOCAL e só nele, e o DOCKER_GID passa a ser relatado no preflight
O broker nasceu sob profiles: ["container-broker"] com uma justificativa que
o próprio ADR 0130 escreveu: "dar acesso ao Docker do host a um processo que
nada chama ainda seria uma mudança de postura para toda máquina de
desenvolvimento, em troca de nada". As duas metades morreram. "Nada o
chama" é falso desde os ADRs 0133/0134/0136 — o broker tem quatro chamadores
reais. E "em troca de nada" deixou de descrever a troca quando o ADR 0144
fez mounted subir container PELO BROKER e o ADR 0146 tornou mounted o
padrão local: com o profile desligado, o modo padrão termina em
BrokerIndisponivelError.
Então ele sai do profiles em docker/docker-compose.yml — e só ali. Em
docker/docker-compose.prod.yml o profile FICA, e os dois arquivos passam a
divergir de propósito.
A assimetria é o que o socket significa de cada lado. Quem roda pnpm dev
já tem o socket do Docker — está rodando docker compose, contra o daemon
do próprio host —, então o broker recebê-lo não concede nada que o operador já
não possua. A superfície marginal é o HTTP do próprio broker, que não publica
porta, vive numa rede internal: true que só a api alcança, e compara
BRABO_SERVICE_TOKEN em tempo constante. Em produção o cálculo é o oposto: o
socket é fronteira de privilégio de verdade, o operador não é o desenvolvedor,
e nada nesta fase muda o modo padrão de uma instalação de produção.
As cinco camadas de contenção não são tocadas. Isto muda quando o
broker roda, nunca o que ele aceita: ele continua recebendo projectId e
uma das cinco operações, continua indo à api ler a decisão do Arquiteto, e
continua sem parâmetro nenhum onde se escreva privileged, cap_add,
network: host ou um -v livre. A contenção nunca foi o profile.
A consequência ambiental, e por que ela ganhou relato. DOCKER_GID deixa
de importar só para quem ligava o profile de propósito — alguém que estava, por
definição, prestando atenção nele — e passa a valer para qualquer pessoa que
rode pnpm dev. O socket é root:docker no host e o processo do broker roda
non-root; o group_add do compose é o que lhe dá o grupo, e o default 999 é
o mais comum e está errado em várias distribuições. Errar não quebra o
boot — o broker sobe normalmente —, quebra o USO: toda operação morre com
permission denied no socket, e o sintoma aparece muito depois, quando alguém
propõe container_start. Por isso preflight.mjs passa a relatar o
estado a cada pnpm dev.
O relato tem três desfechos, e eles não colapsam: bate; diverge (com a
origem do valor efetivo — configurado × default — porque as duas pedem
instruções diferentes); e não se aplica, quando a máquina não tem grupo
docker. O terceiro não é erro nem "não consegui": em macOS e Windows o
Docker Desktop não usa grupo unix, e num Docker rootless o socket é do próprio
usuário. Tratar a ausência como divergência acusaria metade das máquinas de um
defeito que elas não têm — o mesmo cuidado de baseSobrepoeOCheckout, que
devolve false para checkout desconhecido. E ele relata, nunca bloqueia:
pnpm dev sobe igual.
Sair do profiles não bastava, e o defeito só apareceu na primeira subida
por padrão. A imagem de desenvolvimento preparava o pnpm com corepack como
root no build e rodava como uid 1000, que não lê aquele cache; sem cache o
corepack tenta baixar o pnpm no start, e a única rede do broker é
internal: true — a tentativa morria com getaddrinfo EAI_AGAIN registry.npmjs.org e o container saía com 1, cinco segundos depois de subir. O
serviço também não tinha healthcheck, então up --wait imprimia Healthy e o
reset total anunciava "reset completo" com o processo morto. Enquanto o serviço
esteve sob profile isso nunca apareceu: quem o ligava de propósito era quem já
estava olhando. A correção tira o registry do caminho de runtime — o
pnpm install migra para um passo de BUILD e COREPACK_HOME passa a um
diretório com o dono do uid de runtime — e não abre a rede, que é a
primeira das cinco camadas acima. Como os três node_modules são volumes
nomeados montados por cima do que a imagem instalou, e o Docker só semeia
volume vazio, docker/broker/entrypoint.sh reconcilia os três contra uma
cópia guardada fora de /workspace, carimbada com o sha256 do pnpm-lock.yaml
do build — o mesmo carimbo que, comparado com o lockfile da árvore, avisa que a
imagem está velha sem recusar subir. O compose de PRODUÇÃO não sofria disto:
Dockerfile.prod já instalava em estágio de build, removia corepack da
imagem final e já tinha HEALTHCHECK. Desde a AT-360 nenhuma imagem usa
corepack (o Node 25+ não o traz): o pnpm é instalado do tarball do registry,
conferido contra um sha512 gravado no Dockerfile, em /usr/local/bin, e
COREPACK_HOME saiu do broker de dev — com a versão igual à do packageManager
não há cache a ler nem download a tentar no start (medido sob --network none).
- Onde:
docker/docker-compose.yml(serviçobroker, semprofiles, comhealthcheck) edocker/docker-compose.prod.yml(comprofiles, e a nota que impede uniformizar os dois);docker/broker/Dockerfile(o pnpm pinado por sha512, opnpm installde build e a cópia em/opt/broker-deps);docker/broker/entrypoint.sh(a reconciliação dos três volumes e o aviso de lockfile);scripts/dev/docker-gid.mjs(avaliarDockerGid,mensagemDoDockerGid,GID_PADRAO_DO_COMPOSE— a decisão pura);scripts/dev/preflight.mjs(relatarDockerGid,gidDoGrupoDocker) - Teste:
scripts/dev/docker-gid.spec.ts— o caminho feliz, as duas divergências (default ausente e valor errado), o "não se aplica" sem grupo docker, e o default que por acaso ACERTA - ADR: 0146, ponto 3
- Origem: FASE 28, sessão 4
FASE 29 — a instalação de uma linha: assinar o que se publica
RN-527 — A instalação sobe de um compose PRÓPRIO, por imagem escolhida, e confere a saúde antes de dizer que instalou
Revisada pela RN-575 (ADR 0162): o serviço
brokerpassou a existir neste compose, desligado sob o profilecontainer-brokere ligado pelo instalador só com consentimento; as imagens obrigatórias são CINCO. O resto desta regra vale como está.
docker/docker-compose.install.yml é o que roda na máquina de quem instalou, e
ele não é o docker-compose.prod.yml. Três diferenças, cada uma com um
motivo:
- as cinco imagens vêm de variável obrigatória (
${BRABO_API_IMAGE:?…}), sem default. Com default, uma variável ausente subiria metade da stack com uma imagem que ninguém escolheu, e o erro apareceria como comportamento estranho em vez de recusa; - não há bloco
build:em lugar nenhum: este arquivo não constrói, e é o ponto dele; - o serviço
brokernão existe aqui, porque a imagem dele não é publicada (docker-bake.hcltem quatro alvos). Consequência declarada: com--source=ghcr, projetomountednão sobe container (ADR 0150, decisão 7).
O docker-compose.prod.yml fica intacto — ele se declara compose de
validação, é o que docker/smoke.sh usa construindo local de propósito, e
promovê-lo quebraria esse uso. O custo dos dois arquivos é declarado, e o teto
contra a divergência é mecânico:
scripts/dev/composes-em-conformidade.spec.ts reprova se o de instalação
ganhar serviço, volume ou porta que o de validação não tenha, se algum build:
sobreviver, ou se uma imagem de terceiro afrouxar para uma tag diferente.
A fonte da imagem é escolha, e "oficial" é resultado de verificação.
--source=ghcr (default) resolve por digest a partir do images.json da
Release; --source=local roda docker buildx bake e exige árvore limpa e em
tag — imagem construída de árvore suja não é a versão que ela diz ser, e o
marcador registraria uma mentira.
Os segredos nascem uma vez e persistem. Os cinco de
RN-114 mais o NEO4J_PASSWORD, gerados com
openssl rand no mesmo molde de docker/smoke.sh:31-57 — inclusive o detalhe
de o NEO4J_PASSWORD ser -hex e não -base64, porque uma / quebra o parse
de NEO4J_AUTH. O .env é criado vazio e com modo 600 ANTES de receber
conteúdo: criar com o umask do usuário e apertar depois deixaria os segredos
legíveis por uma janela, e é justamente o arquivo que não pode ter essa janela.
E o .env é um arquivo que o COMPOSE parseia, e se prova contra ele
(AT-083). O SECRET_KEY_BASE saía de openssl rand -base64 64, que QUEBRA a
linha aos 64 caracteres: os 88 do segredo iam em duas linhas, e a segunda ficava
solta no .env. Medido em 100 gerações, o Compose recusou 49 ("unexpected
character in variable name") e ACEITOU 51 — com o segredo cortado em 64
caracteres, que ainda é o mínimo do Phoenix e por isso subia sem aviso. Todo
base64 passa agora por segredo_base64 (| tr -d '\n'), e o arquivo é escrito
por escrever_env — função, e não corpo de main, para que o spec grave o
MESMO arquivo que a instalação grava.
Não há passo de migrate, e não deve haver: o compose encadeia api →
migrate-api com service_completed_successfully, e up --wait espera. Um
segundo lugar mandando migrar seria a segunda fonte da mesma verdade.
E o instalador pergunta antes de afirmar. Depois do up --wait — que já
espera o healthcheck — ele bate no /health da api e do engine, e só então diz
que instalou. É a régua que o reset-total.sh custou a aprender: ele anunciava
"reset completo" com a api em Exited (1).
Onde: install.sh (raiz), docker/docker-compose.install.yml.
Teste: scripts/dev/install.spec.ts (o plano, sem TTY nem efeito) e
scripts/dev/composes-em-conformidade.spec.ts (a divergência entre os dois
composes); scripts/dev/install-env.spec.ts gera os segredos e o .env pelas
funções de verdade, passa o arquivo por docker compose config contra o compose
de instalação e cobra que CADA segredo chegue INTEIRO aos serviços (pula
nomeando o motivo sem docker compose), com a geração antiga fixada como
reprovada.
Origem: FASE 29, sessão 4 (ADR 0150).
Instalar o runner (sessão 7) e migrar uma instalação anterior (sessão 6)
seguem fora, e o --print-plan os declara nao-nesta-versao.
O backup cobre os DOIS volumes que são fonte de verdade, e roda sem cluster (RN-528)
RN-528 — git_local_repos entra no backup, o destino pode ser disco, e o restore de compose reusa as MESMAS três validações
O backup cobria um banco e rodava num lugar. Três consequências, e a
terceira é a que custa código: make test-restore exige kubectl, um cluster e
o CronJob brabo-backup no namespace, e docker-compose.prod.yml não tinha
serviço backup nenhum — a imagem era construída pelo bake e usada só no k8s.
Migrar uma instalação por compose não tinha caminho provado.
Classificar antes de copiar. O movimento fácil seria "fazer backup de todos
os volumes", e ele é errado: dos sete, neo4j_data e project_workspaces são
DERIVADOS, ollama_data é re-obtenível e brabo_projects_base é do usuário.
Copiá-los dá impressão de cobertura sem acrescentar recuperação. Fonte de
verdade são DOIS: pgdata — que já vinha, como dump lógico e nunca como cópia
do diretório de dados de um Postgres em execução — e git_local_repos, que
nenhum backup cobria. Um projeto com provider local guarda o repositório
bare ali dentro, e ele NÃO é reconstruível do Postgres: o event log guarda a
narrativa, não os objetos do git. A prosa do runbook dizia o contrário ("the
real repositories live in GitHub/GitLab") e é essa frase que explica como o
volume passou anos sem cobertura — ela vale para github/gitlab, e para
local não há upstream nenhum.
A garantia do arquivo dos repos é declarada, e é menor do que "snapshot".
Nada é quiesced. O que se garante é consistência POR REFERÊNCIA — o git escreve
os objetos antes de mover a ref, e troca a ref por rename atômico, então um
tar que cruza um git push pega o valor velho ou o novo, nunca metade de um.
O que NÃO se garante é instante global (o tar percorre a árvore ao longo de um
período) nem conectividade dos objetos (a imagem não tem git, de propósito). E
um gc concorrente apagando packfile vira FALHA e não arquivo parcial: o status
do tar é capturado num arquivo, porque em POSIX sh o status de um cano é o
do último comando — sem isso o arquivo incompleto se anunciaria como bom, e
backup que mente é pior do que backup que falha, porque só o segundo vira
alerta. *.lock fica de fora: restaurar um lock órfão produz repositório em que
todo git recusa operar.
Destino em disco, S3 opcional. As cinco variáveis de S3 deixam de ser
obrigatórias e passam a ser o que já eram na prática — a configuração de UM dos
destinos. BACKUP_DIR definida escolhe disco; ausente, S3, que é como o CronJob
do k8s continua rodando sem uma linha de mudança. A inferência (em vez de uma
variável de destino obrigatória) é o que mantém aquele caminho intacto: um
destino explícito quebraria o CronJob no dia do deploy, não no do commit. Quatro
prefixos e não dois (daily/, weekly/, git-daily/, git-weekly/) porque a
retenção é por CONTAGEM: dois tipos de arquivo sob um prefixo fariam "manter 7"
significar três backups e meio.
O restore de compose muda o invólucro, nunca o julgamento.
test-restore-compose.sh dispara um backup REAL pela mesma imagem e o mesmo
comando de produção, e chama o MESMO brabo-restore, com as MESMAS três
validações (lista de tabelas contra a origem, contagem das críticas na janela do
backup, continuidade densa da seq de session_events).
deploy/k8s/test-restore.sh fica intacto — unificá-los faria o caminho de
compose depender de kubectl, que é a dependência que ele existe para não ter.
E restore.sh continua sem tocar a database de ORIGEM, propriedade que não se
negocia por conveniência de migração. O passo que só o compose pode cobrar é o
terceiro: verificar o arquivo dos repos, porque no k8s aquele volume nem é
montado no pod (pular é correto) e sob compose ele é (pular seria falso verde).
Restaurar os repos é comando IRMÃO, não fase do outro — são dois artefatos
com dois julgamentos, e juntá-los faria falha de git reprovar a validação do
event log. Ele verifica por default e só escreve com --restaurar; recusa
extrair por cima de repos existentes (a mistura de dois estados não levanta erro
nenhum e só aparece num fetch com história errada); e — achado da execução
real — recusa ANTES do tar quando o volume não aceita escrita, nomeando
--user <dono do volume> (lido por stat; root não serve com cap_drop: ALL). git_local_repos é compartilhado por três imagens com uids
diferentes e carrega o dono de quem o montou primeiro (medido: 1000:1000,
modo 0755) enquanto esta imagem roda como uid 70. Quando a extração roda como
root, o que sai é devolvido ao dono do DIRETÓRIO e nunca deixado como root:
senão o volume voltaria íntegro e inútil, com api e engine sem conseguir
escrever no que acabou de ser restaurado.
Neo4j recebe reprojeção, não backup. Restaurar uma projeção possivelmente
velha ao lado de um Postgres restaurado noutro instante dá dois estados
derivados de momentos diferentes, sem nada que os concilie. A reprojeção não
foi construída aqui (é o BRB-018): até existir, instalação migrada nasce com
o grafo VAZIO — degradação conhecida e nomeada, não perda de dado, porque o RAG
vive no pgvector e vem no dump. Desde a RN-569 ela existe
(grafo:reprojetar), e o grafo vazio passa a durar até alguém rodá-la.
- Onde:
docker/backup/lib.sh:28(destino_tipo, a inferência que mantém o CronJob intacto),:65(destino_esperarperguntando por ESCRITA e não por existência),:116(destino_enviar, escrita em.parciale rename),:181(a garantia dos bare repos, escrita junto do código que a produz),:232(git_arquivar, o status dotarnum arquivo),:272(git_restaurar, a devolução do dono) e:286(git_raiz_gravavel);docker/backup/backup.sh:31(destino_preparar— as cinco variáveis de S3 deixando de ser obrigatórias),:149(os três desfechos do volume de repos, sem colapsar "não montado" com "vazio"),:170(a conferência do status dotar) e:220(a poda dos quatro prefixos);docker/backup/restore.sh:28(o mesmo destino do backup) e:120,:184,:215(as três validações e o veredito, sem uma linha de mudança);docker/backup/restore-git.sh:43(verificar como default),:109(a recusa de extrair por cima) e:123(a recusa por escrita, com o conserto nomeado);docker/backup/test-restore-compose.sh:60(o--profilenoconfige não norun);docker/docker-compose.prod.yml:392(o serviçobackup, sobprofileserestart: "no") e:550(o volume que NÃO entra na classificação dos sete);docker/backup/Dockerfile.prod:80(o/backupscriado echown-ado ANTES doUSER);Makefile:54(test-restore-compose) - Teste:
scripts/ci/backup-lib.spec.ts— as funções numshde verdade, contra um diretório de verdade: as cinco operações do destino em disco; o objeto ausente medindo 0; a escrita atômica e o.parcialque a listagem ignora; os prefixos irmãos que não se misturam; o destino que existe e NÃO aceita escrita (caso de falha); "não montado" × "montado e vazio"; o arquivo dos repos com status 0 e conteúdo relido;*.lockeobjects/tmp_*fora; o ciclo arquivar→restaurar devolvendo byte a byte; a recusa de restaurar onde não se pode escrever; e o tar truncado que reprova ao ser LIDO, nunca pelo tamanho - ADR: 0152
- Origem: FASE 29, sessão 5. Exercitado de verdade contra o compose de
desenvolvimento com dados reais (55 tabelas, 422 eventos, os bare repos de
exp001eexp004), incluindo a restauração num volume novo conferida comgit fsck— o registro está nodocs/runbook.md, seção "Last verified run — compose, disk destination". Fica declarado e NÃO feito: o caminho S3 do adaptador não tem cobertura automatizada (exigiria um endpoint), e continua sendo o mesmo comandoawsde antes; a reprojeção do Neo4j é o BRB-018; e nada aqui agenda o backup num compose — quem o dispara é um humano ou o instalador (sessão 6)
FASE 29 — a instalação de uma linha: assinar o que se publica
RN-530 — Migrar exige backup PROVADO: o instalador só ganha o direito de apagar depois de restaurar
Instalar por cima de uma instalação existente não é oferecido: ou se migra,
ou se para. Um up sobre volumes de outra versão é o tipo de estrago que não
avisa.
A migração tem uma ordem, e o passo do meio é o que dá sentido a ela: backup → PROVAR que restaura → perguntar → apagar → instalar → restaurar.
A prova não é zelo. Um backup que ninguém tentou restaurar é um arquivo, e a
hora de descobrir isso não é depois do down -v — por isso o instalador roda
docker/backup/test-restore-compose.sh (as MESMAS três validações de
restore.sh, ADR 0152) antes de qualquer deleção. Backup que falha, ou prova
que falha, interrompem a migração sem apagar nada, e a mensagem diz onde o
backup ficou.
O destino do backup é uma pasta do HOST, e isso é o detalhe que faz a
diferença entre migrar e perder: o default do compose é o volume nomeado
backup_local, que o down -v apagaria junto com o que se quer preservar.
O que a migração apaga: os volumes nomeados da instalação (pgdata
inclusive), os containers e a rede. O que ela nunca apaga: a base de
projetos — quando BRABO_PROJECTS_BASE aponta para uma pasta do host, a linha
do compose é bind-mount, e down -v não toca bind —, a pasta de espelho
(RN-516) e o backup recém-provado.
O dump do Postgres não é restaurado automaticamente sobre o banco novo:
brabo-restore valida contra uma database de teste e nunca sobrescreve a
origem (ADR 0152), e restaurar sobre um banco já populado é operação
destrutiva que o instalador não toma sozinho. Os repositórios git locais, sim —
eles são fonte de verdade que nasceria vazia.
Revisado pelo ADR 0256 e pela RN-998 (repositório
brabo, AT-738). Desde o instalador da linha multi-repo, a migração RESTAURA o dump sozinha, mas só num banco recém-criado e VAZIO (brabo-restore --banco-vazio, que recusa se houver qualquer tabela de aplicação), antes de subir a api e o engine. O que este parágrafo dizia segue valendo para banco populado: obrabo-restoresem argumento continua só validando numa database de teste.
Onde: install.sh, migrar_instalacao_anterior e restaurar_apos_migrar.
Teste: scripts/dev/prova-de-restauracao-env.spec.ts (neste repositório: o
.env do instalador passa pelo Compose da prova) e, no repositório brabo,
scripts/dev/install-arquivos-da-instalacao.spec.ts (a prova viaja na Release e
é conferida). A ordem "prova antes da deleção" não tem spec próprio hoje: o
scripts/dev/install.spec.ts que esta regra citava não existe (AT-738, achado 10).
Origem: FASE 29, sessão 6 (ADR 0150,
ADR 0152).
O repositório nasce no aceite do handoff ao Arquiteto, e a execução não começa sem ele (RN-582)
RN-599 — No compose de dev a api encontra o broker sozinha, e a falta da pasta gerenciada é dita a cada subida
O broker sobe por padrão no compose de DEV desde a RN-512, mas a api
recebia BROKER_URL vazia (${BROKER_URL:-}, e a linha vinha comentada no
.env.example). O resultado, medido no teste do dono em 26/09: com o broker de
pé ao lado, brokerConfigurado vinha false e a criação de projeto só liberava
runner (RN-573). Duas regras fecham isso:
BROKER_URLtem default no compose de dev, e só nele. O default éhttp://broker:8090— o serviço e a porta (BROKER_PORT) que o MESMO arquivo declara, e o teste trava os dois juntos: um default que apontasse para outro lugar trocaria "sem broker" por "broker inalcançável". O valor do.envcontinua vencendo. Os composes de PRODUÇÃO e de INSTALAÇÃO seguem${BROKER_URL:-}, de propósito: lá o broker está sobprofiles: ["container-broker"], e quem o liga grava a URL (oinstall.sh, perguntando — RN-575). Um default ali faria a api de uma instalação sem broker afirmar que tem um.- O
preflight.mjsRELATA a pasta gerenciada, no molde doDOCKER_GID. SemPROJECT_WORKSPACES_HOST_DIR, api e engine usam o volume gerenciadoproject_workspaces(sem caminho de host), o broker fica semPROJECT_WORKSPACES_HOST_ROOT— o compose de dev a DERIVA daquela —, o stack sobe saudável e ocontainer_startdo modocontainer, o default, termina recusado. O relato tem quatro desfechos que não colapsam:ausente,ok,nao-absoluta(o~: o Compose o expande na origem do bind-mount de api/engine, mas NÃO na variável que o broker recebe — medido comdocker compose config) edivergente(PROJECT_WORKSPACES_HOST_ROOTexplícita diferente da pasta que api e engine montam, inclusive quando elas montam o volume). Cada mensagem diz o sintoma e o conserto. Ele NUNCA recusa a subida e NUNCA grava o.env: a resposta é um caminho no disco de alguém, e trocar o volume pela pasta esconde o que já está no volume (os dados ficam lá, só deixam de ser vistos) — a mensagem diz isso também.
O que fica aberto: o pnpm bootstrap NÃO oferece a pasta. Fazê-lo seria um
segundo consentir-base.mjs (perguntar num TTY, criar a pasta com o dono
certo, gravar as DUAS variáveis juntas, decidir o que fazer com um volume
project_workspaces que já tem dados), e isso é frente própria.
- Código:
docker/docker-compose.yml:266(o default deBROKER_URL),:571(a derivação dePROJECT_WORKSPACES_HOST_ROOT);scripts/dev/pasta-gerenciada.mjs:63(avaliarPastaGerenciada),:89(mensagemDaPastaGerenciada);scripts/dev/preflight.mjs:424(relatarPastaGerenciada) - Teste:
scripts/dev/broker-url-no-dev.spec.ts:42(o default existe),:47(aponta para o serviço e a porta do arquivo),:64(produção e instalação sem default);scripts/dev/pasta-gerenciada.spec.ts:31(ausente),:51(o~),:67(divergente com o volume),:75(a mensagem diz sintoma, conserto e o que não migra),:113(o preflight chama o relato sem sair por ele) - Origem: AT-213 — teste do dono em 2026-09-26