Pular para o conteúdo principal

Decisões arquiteturais

Um ADR registra por que algo foi decidido, no momento em que foi decidido. Eles são imutáveis: nenhum ADR é editado depois de aceito. Quando uma decisão muda, nasce um ADR novo que substitui o anterior — foi o que aconteceu com o 0018, que registra a substituição do NoopDevAgent decidida no 0011.

Consequência prática: um ADR pode descrever um estado que não é mais o atual. Para o estado atual, use Regras de negócio, Arquitetura e o Runbook. Para o raciocínio, venha aqui.

Dos 61, todos estão aceitos menos os que ainda esperam implementação — hoje o 0053, o 0054 e o 0056, marcados propostos no próprio arquivo. O 0055 foi aceito quando foi implementado na Fase F. O 0154 e o 0155 fizeram o mesmo caminho: aceitos em 2026-09-27, depois que a Fase 30 os exercitou, com uma seção "notas de aceite" nomeando onde o texto e o código divergem. Nenhuma checagem confere esta frase: ela envelheceu dizendo 48 quando já eram 54, e de novo 58 quando já eram 59, e é o argumento do ADR 0029 (gerar > verificar > lembrar) se confirmando contra si mesmo.

As decisões são agrupadas por tema — a frente do produto sobre a qual decidem — e cada ADR aparece uma vez, sob o tema principal dele. O tema mora em docs/adr/temas.yml, ao lado desta página, e não no ADR, porque ADR aceito nunca é editado (ADR 0202); o pnpm docs:check reprova ADR sem tema ou linha na seção errada. A história fase a fase está no histórico de fases.

Git, repositório e política de branches​

O contrato do provider de git, o nascimento e a adoção do repositório, a proteção e a política de branches.

#decisão
0001Formato do contrato normalizado do GitProvider: nove operações e capabilities declaradas, validadas por uma suite de contrato única
0002Normalização de erros: seis classes específicas, sem classe-base comum — a abstração foi considerada e rejeitada por não pagar o próprio custo ainda
0003Retry automático só em leituras, nunca em mutações, com Full Jitter e 4 tentativas
0004Cadastro de credenciais de git: enum dedicado no banco, união com os providers de LLM apenas no tipo
0005Bootstrap de Gitflow em seis passos idempotentes e retomáveis; skip é sucesso
0028Proteção de branch diverge entre GitHub e GitLab — e por isso não é o portão; o portão é o domínio
0030Política de branches mecanizada: regra → mecanismo, lógica em script testável, gate de retropropagação, e o custo de reintroduzir cada corte
0044Adoção de repositório existente com origin (created|adopted) nas duas tabelas, e o PLANO como portão: o dry-run é o check() do ADR 0005 sem run(), o plano vive no cursor (snapshot, não log), e nada é alterado enquanto a decisão for nula — o runner da Fase 2 sai verbatim para um colaborador e nunca é filtrado, só não é chamado; "proteção divergente" fica no nível booleano porque é só isso que o contrato expõe (ADR 0028)
0056O engine trabalha em repositório remoto. get_local_repo_path/1 devolve unsupported_provider para tudo que não é local, e quatro consumidores param junto — worktree do dev, executor de terminal, o diff que QA e SecOps julgam, e o contexto de projeto. A assimetria é a raiz: a api fala GitHub por HTTP (criou o repo, commitou, criou as branches) e o engine trabalha no sistema de arquivos, só conhecendo bare repo local; projeto remoto faz a metade conversacional e para na de construção. É o que impede a FASE 13b de existir como escrita. O encanamento de git já é genérico — init + remote add + fetch + checkout não se importa se a origem é caminho ou URL —, então o que falta é CREDENCIAL. O engine não recebe a chave mestra: pede o remoto de trabalho à api pelo canal /internal/* que já existe, no momento em que precisa. E o token NUNCA entra no .git/config, decisão que é consequência direta do 0055: escrever https://x-access-token:TOKEN@… no origin põe o segredo em texto puro DENTRO da pasta onde a RN-075 acabou de dar leitura auto-aprovada ao dev agent — um cat .git/config devolveria o token, que viajaria ao provider de LLM no turno seguinte. O escopo protege contra ler para fora, não contra um segredo que o produto colocou dentro. origin fica com a URL limpa e a autenticação é injetada por invocação. Credencial é a do OWNER (RN-058), local segue idêntico, e falha de credencial reprova com origem infra
0139A promoção é --no-ff, e essa regra foi quebrada TRÊS vezes (#367, #394, #464) — a terceira sendo o próprio PR que corrigiu as duas primeiras. A investigação achou o contrário de uma lacuna de detecção: a checagem existe desde 2026-07-27 e disparou corretamente nas duas promoções por squash; o log de 2026-08-25 diz o merge em qa não é merge commit (1 pai) literalmente. O que faltava era um DESTINATÁRIO — um workflow disparado por push que falha numa branch permanente não tem PR para ficar vermelho (o merge já aconteceu), então deixa uma execução na aba Actions para a qual nada aponta, e o repositório tinha zero issues em toda a sua história. Por isso o tag-release ganha um job avisar que abre uma issue (e comenta na existente em vez de abrir uma segunda para a mesma branch, porque uma enxurrada de issues idênticas é o jeito mais rápido de ensinar alguém a ignorá-las), autenticando com github.token e nunca o PAT — um BRABO_BOT_TOKEN inválido é ele mesmo uma das falhas que o alarme precisa relatar, e um alarme que depende do que pode estar quebrado fica mudo exatamente quando é preciso. A regra se estende também à dev, de forma ESTREITA: a dev recebe trabalho por squash de propósito, então "um pai é defeito" dispararia em todo PR; o defeito ali é só o PR que carregou uma ARESTA NOVA (o head dele era um merge cujo segundo pai ainda não estava na base) — o #464, cuja entrega inteira era essa aresta. O gêmeo benigno, um PR de trabalho que puxou a dev para dentro de si, também tem um merge como head, mas o segundo pai dele já está na base. Identificar o PR produtor filtra por merge_commit_sha, não por .[0]: commits/{sha}/pulls devolve todo PR associado, e como a dev é o head do PR de promoção que fica aberto o ciclo inteiro, .[0].head.ref leria dev em todo squash de PR de trabalho e reprovaria o repositório inteiro — um erro cometido e pego contra dados reais antes de entrar. Desligar o squash no repositório inteiro foi medido e RECUSADO: os seis merges mais recentes na dev são squashes de um pai só e o changelog.mjs roda git log --no-merges, então a correção cobraria de todo PR de trabalho e mudaria a geração do changelog. A prevenção fica aberta e é decisão de produto: o promote.yml fazendo ele mesmo o merge --no-ff preservaria a autoridade mecanizando o método, mas exige decidir se um disparo humano conta como "merge manual"
0165O repositório do projeto é criado quando o handoff ao Arquiteto é aceito, e a execução se recusa a começar sem um. Uma instalação real da v6.1.0 (AT-092) mostrou o Arquiteto propondo open_adr_pr três vezes antes de existir qualquer repositório, o handoff ao Dev Lead deixado em offered e POST .../execution/activate respondendo 201 num projeto cujas três tabelas de git ficaram vazias para sempre. A RN-522 tinha posto o gatilho no handoff ao Dev Lead — DEPOIS dos dois agentes que escrevem no repositório (o Arquiteto e o Infra Lead que ele ativa) — e esse gatilho era pulável, já que ativar a execução nunca o exigiu. O Arquiteto só pode ser ativado por um handoff aceito endereçado a ele (vindo do PO ou manual), então o gatilho vai para o mesmo AcceptHandoffUseCase, antes de activateAgent, com o mesmo mecanismo (local, falha como evento). O aceite do Dev Lead fica como segunda porta, idempotente — a saída para projetos que passaram pelo Arquiteto sob a regra antiga, e a nova tentativa natural de um provisionamento que falhou —, e a idempotência é provada pelas duas portas contra o Postgres e o provider local real. Ativar sem repositório vira um 409 lançado antes de qualquer efeito, cuja mensagem é escolhida a partir dos handoffs do projeto (HandoffRepository.findByProject); a Visão geral deixa o botão inerte e diz por quê uma vez, em texto, e o card do handoff ao Dev Lead perde o atalho de ativação. Declarado: a sessão git-bootstrap passa a ser a sessão mais recente do projeto durante toda a fase do Arquiteto
0217Enquanto o QA está em fase de teste (QA_FASE_DE_TESTE=true), todo merge na dev abre e mergeia sozinho a promoção dev → qa, depois que todos os checks terminam verdes; a escada de aprovação dispensa só esse par aberto pelo bot, e qa → main continua manual
0235A qa é aposentada por hora no repositório do Brabo (AT-660): esteira dev → main, âncora de versão -dev.N, qa arquivada em refs/archive/qa e receita da volta; o Gitflow do produto não muda. Revisa o 0030, revoga o mecanismo do 0217.

Harness de agente, turnos e sessões​

Como um agente roda — contexto, o laço de ferramentas, o turno conversacional, a sessão e seus estados.

#decisão
0006Montagem determinística de contexto: camadas ordenadas, orçamento por camada, corte previsível
0007ToolLoop, catálogo de ferramentas e ContextManager — o laço com LLM de verdade
0061O tipo da sessão é dado da criação, e a execução continua sendo evento. Toda sessão nascia igual, e chegar ao Criativo exigia abri-la primeiro e DESCOBRIR depois um botão na barra de topo — foi assim que o usuário relatou o problema. O risco não é o campo novo: é que o produto JÁ sabia distinguir uma sessão que executa, por derivação, e findActiveExecutionSession procura a sessão active que carrega execution.activated (é o que faz reativar cair na sessão onde os dev agents já estão, achado #11 do primeiro dogfooding). Gravar um tipo cria uma SEGUNDA fonte, e duas fontes divergem — aqui, sobre onde os dev agents escrevem. Derivar tudo do log não serve, e o motivo é temporal: a intenção existe ANTES de qualquer evento, no clique que abre a sessão, e a sessão criativa que ninguém ativou não tem evento nenhum a derivar. A decisão é que as duas coexistam respondendo perguntas diferentes — kind é INTENÇÃO de criação e imutável, o evento é ESTADO de execução, e findActiveExecutionSession NÃO passa a olhar kind —, sob a regra que as impede de brigar: execution.activated em sessão consultiva é 409, nunca conversão silenciosa. A trava mora no FUNIL (AppendSessionEventUseCase), porque a rota do usuário e a /internal/* do engine passam por ele e travar no caso de uso deixaria o outro aberto; e roda antes do incrementSeq, para tentativa recusada não consumir seq. O campo é obrigatório em todo ponto de criação, inclusive nos quatro internos: opcional faria os chamadores herdarem o default calados. O DEFAULT da coluna é consultiva (o tipo que pode menos) e o BACKFILL é criativa, na direção oposta e pelo argumento do 0046 — toda linha existente é anterior à distinção, e algumas são as sessões em que os dev agents trabalham agora. Junto vêm o nome amigável, que se SOMA à hashtag e nunca a substitui (a hashtag é o que se cola numa URL; nome de pessoa não é único), e a saída da tela de sessão, que não tinha nenhuma. O preço aceito: a pergunta "esta sessão executa?" passa a ter dois lugares onde procurar
0098O 413 request entity too large nas PRs tinha causa na própria api do Brabo, nunca no provider de LLM (comentários que atribuíam a culpa ao provider estavam errados). Duas pontas fecham juntas (RN-412): apps/api/src/main.ts nunca configurava limite de body do Express (valia o default de 100 KB, contra os 8 MB que o Phoenix aceita no sentido engine→api mais pesado, POST .../llm-turn) — ganhou API_JSON_BODY_LIMIT (default 10 MB); e Engine.Harness.ContextManager.Default tinha compactação estruturalmente inalcançável antes do estouro — estimate/1 não contava toolCalls (custavam ~zero tokens) e a janela usava só context_window (128k, ~350 KB antes de compactar). A janela EFETIVA vira min(context_window, teto_de_transporte), com corte sempre em fronteira de iteração do ToolLoop para nunca separar um assistant com toolCalls dos tool que o respondem
0163O clique que inicia um turno de agente responde no ACEITE, não quando o turno termina, e a recusa deixa de ser calada. Medido numa instalação real da v6.1.0 (AT-089): responder o formulário de perguntas do Criativo levou 97,3 s, confirmar a prontidão 97,3 s e confirmar a arquitetura 51,8 s — cada clique preso pelo turno INTEIRO, porque o handle_call do agente conversacional só respondia ao GenServer.call síncrono do controller do engine quando a Task do turno terminava, e um turno acima do teto de 120/180 s virava 500 num comando que tinha dado certo. A mesma instalação mostra a outra metade: um "Continue" digitado durante o kickoff do Arquiteto recebeu {:error, :turno_em_andamento}, que o controller descartava antes de responder 202 — aceito, nunca lido, sem rastro. Decisão: TurnoAssincrono.iniciar/3 responde :ok assim que a Task sobe (depois de persistir agent.status: working, nunca antes), generalizando o que o 0086 fazia só para o Dev Lead suspenso — um mecanismo para os seis agentes conversacionais, sem segundo caminho. O que o agente recusa ANTES de começar continua síncrono e ganha status próprio (409 turno_em_andamento/aguardando_aprovacao, 422 sem_regra_de_negocio), repassado pela api com a frase do engine. O contrato de sucesso da api NÃO muda (201 { ok: true } sempre quis dizer "aceito"). A tela para de ler "a chamada resolveu" como "o turno acabou" e acompanha o fim pelo canal mais a cauda do log. O handoff ao Dev Lead é SEGURADO enquanto o turno de fecho do Arquiteto roda, para continuar chegando depois do da Infra. Declarado: o chat.message gravado antes de o engine recusar fica no log (agora explicado), e um restart do engine no meio do turno deixa a faixa de pé até "Parar".
0175O Infra Lead conversa pelo composer e vira o SÉTIMO agente conversacional (AT-141, decisão do mantenedor). A RN-584 tinha deixado a pergunta aberta e precificado um "sim": o turno inteiro do Infra Lead rodava DENTRO do handle_call/handle_cast — sem resposta no aceite, sem "Parar", sem fechamento de turno órfão, com teto de chamada de 180 s abaixo dos 225 s de um propose_action de container (RN-605), e uma mensagem enviada durante o kickoff esperava na fila do processo pelo turno inteiro. Por isso o turno mudou PRIMEIRO: kickoff, correção de gate e mensagem começam todos por Engine.Agents.TurnoAssincrono, o mesmo módulo dos outros seis — aceite com working persistido antes dele, 409 turno_em_andamento, via_for("infra") para o "Parar", TurnoOrfao e toolloop.limit_reached no teto próprio de 14. A consolidação do Workflows roda dentro da Task; uma correção de gate que chega no meio do turno entra numa FILA e começa quando o turno fecha, porque o TurnoAssincrono sem from a descarta. Só então message/2 ganhou a cláusula própria de infra (a recusa nomeada saiu) e o web pôs infra em AGENTES_DE_CHAT, para que ele seja o destinatário quando for o agente ativado mais recentemente; o handoff dele mantém o card próprio (RN-499) e é excluído pelo nome do card do fio. Nenhum efeito externo novo: as mesmas quatro ferramentas, e tudo o que tem efeito continua sendo proposed_action. SOLO_CONVERSATIONAL_AGENTS não muda — o Infra Lead é lead de área
0177O idioma em que os agentes RESPONDEM mora em três lugares — conta, sessão e projeto — como lista ABERTA de códigos BCP-47, nunca em users.locale (AT-162, decisões do mantenedor na AT-168/AT-169). Sem enum: todo lugar guarda text canônico validado por normalizarIdiomaBcp47 (forma por Intl.getCanonicalLocales, subtag de idioma conhecida pela ICU da api, nunca und nem uso privado, no máximo 35 caracteres), porque abrir o idioma da resposta não precisa de arquivo de recurso e um enum precisaria de migration por idioma. Conta: users.response_language (NULL é automático, como toda conta começa) e o par detected_language/detected_language_confirmed_at amarrado por CHECK — a detecção só conta depois de confirmada (gravada pela AT-163). Sessão: session_language_overrides, chaveada pelo par {session, user}, configuração e não evento. Projeto: projects.language (AT-243, RN-619). Precedência para turno com autor humano, por PESSOA: override da sessão > escolha da conta > detecção confirmada > users.locale, então a cadeia sempre termina num idioma; ResolverIdiomaDaRespostaUseCase devolve o vencedor, a origem e a cadeia. Declarado: nenhum turno muda ainda — o transporte até o modelo é a AT-164
0179O laço do agente pergunta ao Jev qual ferramenta cabe em cada passo, e o modelo de chat do usuário recebe só o menu que sobra (AT-238, HS-063, decisões do mantenedor na AT-236 e de 2026-09-29). Um roteador PRÓPRIO, ToolRouter, fora do contrato de provider do ADR 0041 e fora do catálogo models: só age com modelo de chat do OpenRouter e duas ou mais ferramentas, e só RESTRINGE (política P3: {a escolha do Jev, a ferramenta anterior da mesma execução}; responder_sem_ferramenta ou nenhuma ferramenta anterior deixa o catálogo inteiro). O critério de adoção foi baixado de 90% para 80% pelo dono, com os números na frente dele (cobertura do menu 91%, 85,1–94%; top-1 do Jev 73%); a validação real é a AT-239. Qualquer falha — HTTP, timeout (2.000 ms), JSON inesperado, escolha fora das opções — cai no catálogo inteiro com motivo nomeado, e o turno nunca falha por causa do Jev. O Jev não aprova, não nega e não escolhe modelo. O custo dele vai para token_usage como o usage.cost REAL com preço IMPLÍCITO (cost ÷ tokens, price_implicit), no ator do próprio agente para entrar no orçamento da área; a chave de desligar é por workspace (migration 0066); um menu errado se escapa repetindo o passo uma vez com o catálogo inteiro.
0183Uma sessão ENCERRADA pode ser REABERTA (AT-071): uma saída dos estados terminais, só para active, por rota própria (POST .../sessions/:id/reopen) e checagem própria da máquina de estados (canReopen) — a transição genérica continua recusando closed → active, e closing → active segue proibido. A linha volta a active com closed_at/termination_reason limpos, e um evento NOVO session.reopened, no mesmo seq, guarda o fechamento anterior; nenhum evento é editado, kind não é tocado. Três padrões PROVISÓRIOS aguardam o dono: mínimo maintainer, sem prazo, e sessão com execution.activated recusada com 409 sessao_com_execucao para que findActiveExecutionSession não seja desviado. O SessionLifecycleWorker do engine ignora um fechamento que a reabertura já desfez; a segunda análise automática do Psicólogo continua bloqueada por alreadyAnalyzed
0184O dono responde aos três padrões PROVISÓRIOS do ADR 0183 (AT-337): reabrir uma sessão encerrada exige developer — o mesmo papel que a encerra pela transição genérica — em vez de maintainer, com viewer continuando 403; o sem prazo e a recusa 409 sessao_com_execucao para sessão com execution.activated são CONFIRMADOS. O ADR 0183 não é editado
0191Uma mensagem que chega enquanto um agente conversacional está no meio de um turno deixa de ser recusada com 409 turno_em_andamento (AT-267, decisão do dono de 01/10, revisando o ADR 0163 só para MENSAGENS): ela entra na FILA do agente em TurnoAssincrono — uma fila para os sete, os servidores só dizem como uma mensagem vira turno —, e quando o turno termina as mensagens pendentes são lidas JUNTAS, em ordem, em UM turno. O estado mora no event log (chat.message_queued/_delivered/_cancelled, todos levando o id do chat.message), nunca numa tabela: o init/1 reconstrói a fila e o boot acorda quem tem pendente. Teto de 10 por agente e sessão (409 fila_de_mensagens_cheia); quem ENVIOU uma mensagem pendente pode cancelá-la. O Dev Lead suspenso para aprovação continua recusando; comandos que não são mensagem mantêm turno_em_andamento
0226O agente escreve o plano (registrar_plano, agent.plan) e o diário o projeta do log; o turno que o reinício matou é retomado UMA vez, no boot, se recente (900 s), e depois só pelo botão "Retomar". Revisa o "nunca refeito" da RN-586/RN-778; dev retoma do commit, gate tem teto 1× no boot e no tick, correção do Infra Lead é durável. Referencia 0163, 0191, 0207 e 0221.

O time de agentes — papéis, áreas e handoffs​

Quais agentes existem, o que cada um entrega a quem, a delegação dentro de uma área e o handoff entre eles.

#decisão
0008Criativo conversacional (o chat deixa de ser echo stateless) e a fundação de handoffs
0009PO, backlog em três níveis e rastreabilidade regra → história → task; a prontidão vira validação de domínio
0010Arquiteto: ADRs por PR real, module_map e validação cruzada
0014InfraAgent propositivo (propõe por PR, nunca aplica) e painel do time ao vivo por canais Phoenix
0015Psicólogo real: hipóteses com evidência apontando para eventos reais, e triagem leve/pesada por custo
0016Anamnese: catálogo fechado de competências, patches de instrução versionados com rollback, e o teto de que patch nunca é auto-aprovável
0022Evidência que chega, custo que aparece, análise que sobrevive ao kill; causa de término como classificação determinística
0023Catálogo que funciona, dedup que distingue quem negou, e fila que sobrevive à rodada
0038Área, lead e delegação: handoff externo só endereça lead ou agente sem área, consolidação num artefato só sem mudar o contrato externo dos gates, orçamento em cascata com sub-teto por delegação, origem da falha tipada e retrofitada em todo o sistema que já classificava falha informalmente
0046Promoção de história volta a ser do usuário: proposed_ready é booleano e não valor novo no enum porque o enum É o portão do claimNext; promover reusa o TransitionStoryUseCase (código morto do achado #13) e herda de graça o task.became_claimable da 12b; a validação é unificada em assertPromotable ANTES de o gatilho virar configurável, senão os dois modos seriam regras distintas com o mesmo nome; backfill DIRIGIDO (projeto existente vai para auto) porque o comportamento estava em uso; a recusa grava antes de falar com o engine — inverso do rearm, porque o evento afirma algo sobre o USUÁRIO, não sobre o engine
0053Dev Lead como área, e paralelismo autorizado pelo usuário. Hoje sobe um dev por módulo mais um extra por aceite de um clique, sem teto de sessão nenhum: N módulos × 2 agentes, e o gasto sobe sem que ninguém autorize. A 14d decide que quem avalia quantos agentes valem a pena é o LEAD, e que passar de dois na sessão vira proposed_action pelo mesmo pipeline de toda ação com efeito externo. O Dev Lead não existia e era proibido, junto com as áreas dinâmicas via module_map e o aparato genérico de áreas — os três cortes caem juntos porque os membros da área de dev são um por módulo do module_map, decididos pelo Arquiteto e diferentes em cada projeto: não há como hardcodá-los como qa e infra. agent_areas/agent_area_members entram por projeto, com max_parallel default 2 configurável por lead; o contrato externo dos gates não muda e a suite da Fase 4 verde sem modificação é o critério de aceite. A Anamnese propõe subir o teto quando a autorização vira recorrente, e nunca sobe sozinha — automatizar seria o produto elevando o próprio teto de gasto
0068Diagrama C4 (Context + Container, Simon Brown) do Arquiteto, renderizado na Visão Geral. artifact.c4_diagram sem tabela, mesmo desenho do artifact.project_image (ADR 0065): o Container level é DERIVADO do module_map vigente pelo caso de uso (nunca redigitado pelo modelo — evita divergência silenciosa entre diagrama e mapa real), e só o Context (nome do sistema + atores externos) vem do tool call create_c4_diagram. mermaid entra como dependência de RUNTIME nova do apps/web (decisão do usuário) — primeira do tipo; isolada atrás de lib/mermaid-render.ts com import() dinâmico, confirmado por vite build que só o entrypoint carrega eager e os chunks pesados do Mermaid (cytoscape.esm 435 KB, katex 258 KB, usados só por OUTROS tipos de diagrama) ficam sob demanda. CSP fechado do ADR 0058 confirmado intacto: nenhum chunk do Mermaid usa eval/new Function, e style-src 'unsafe-inline' já cobria o <style> que o SVG pode embutir
0069Fusão condicional do aceite do handoff com a ativação de execução. O card do Dev Lead tinha dois cliques porque são duas autorizações deliberadamente distintas (RN-137): aceitar exige developer, ativar exige maintainer porque quem ativa vira session.createdBy e resolve o papel EFETIVO das PRs que os dev agents abrem depois. O pedido não baixa essa exigência — para quem já é maintainer/owner (lido do mesmo useCurrentWorkspaceWithRole() que já autoriza o "Auto mode" e Aprovações/Configurações), o segundo clique não protegia nada, só repetia uma decisão que o papel já autorizava; handleAcceptHandoff passa a encadear a MESMA handleActivateExecution que o botão "Ativar execução" já chama (RN-137), reusando o tratamento de erro dela para não confundir "aceite falhou" com "ativação falhou depois de aceitar com sucesso". Backend intocado — RequireRole('maintainer') continua igual, é decisão só de apps/web; developer mantém o fluxo de hoje, com os dois botões separados
0085docs/fluxo.yml registra as RELAÇÕES entre papéis do time de agentes — quem entrega o quê a quem — a terceira peça ao lado do catálogo (agent-areas.ts, FASE 18) e do controle (docs/gates.yml, ADR 0054), que até aqui só existiam implícitas em prompts e casos de uso. Contratos por papel (missão, entradas/saídas tipadas, gate de saída referenciando gates.yml) com status active/planned/proposto/em-refinamento; papel proposto DECLARA quem o absorve hoje e o critério objetivo de separação, para o organograma-alvo virar sequência de ativação e não aspiração. docs/.docmap.yml ganha a regra modelo-de-time em warn — falta o teste de cruzamento fluxo.yml × gates.yml × agent-areas.ts para promover a block (backlog)
0086O plano de execução do Dev Lead vira proposed_action de verdade, revisando parte do 0053 — a auditoria de docs/fluxo.yml × código achou que o fluxo já declarava via: proposed_action para esta saída e o código nunca foi ajustado. Precedente direto: 0052 e 0057, mas os dois são disparados por cast e nunca esperavam resposta síncrona — o Dev Lead é o PRIMEIRO agente conversacional (GenServer.call de até 180s) a suspender. TurnoAssincrono.tratar_resultado/2 ganha um ramo: state com a chave :aguardando_aprovacao (valor não-nulo, não só presente — o Dev Lead a carrega desde o init/1) responde ao from do mesmo jeito e na mesma hora, mas emite só agent.status: awaiting_approval em vez de agent.done. LiveBroadcast.agent_status/4 ampliou a guarda para aceitar o status novo. DevLeadServer assina Engine.Dev.Wake pelo próprio id (mesmo módulo que QaLeadServer já reusa para subagentes de QA) e retoma via {:action_settled, ...}; uma segunda user_message durante a espera é recusada com agent.error (origem politica), sem novo turno. propose_execution_plan foi CONSIDERADO e deixado de fora do bloco de tetos absolutos de decide.ts — ao contrário de parallelize/raise_max_parallel, pode ser configurado para auto-aprovar. Lacuna aceita e declarada: sem tabela de estado, restart durante a suspensão perde a inscrição no Wake — a decisão fica registrada e visível em Aprovações, mas o Dev Lead não narra o desfecho sozinho
0087O UX Designer entra como o quinto agente conversacional (Criativo, PO, Arquiteto, Dev Lead e agora ele), SOLO e sem área, ANTECIPANDO o gatilho de separação que docs/fluxo.yml (ADR 0085) sempre declarou para o papel ("quando o projeto GERENCIADO tiver interface própria a desenhar") — decisão consciente do dono do produto, não erro de leitura do critério. UxDesignerServer espelha o DevLeadServer (teto 14, laço bounded); o kickoff lê a artifact.product_brief do Criativo, e o sistema de design (design/tokens.css/COMPONENTS.md) é DESCRITO na identidade — os conversacionais não têm ferramenta de leitura de repositório. A única ferramenta, propose_prototype, grava artifact.prototipo_navegavel SEM caso de uso dedicado na api (ao contrário de project_image/c4_diagram, que precisam dele por terem conteúdo DERIVADO ou recusa de domínio compartilhada — nenhum dos dois se aplica aqui): validação de forma no engine (ArtifactSchemas) e gravação pelo append_event_returning genérico. Um artefato só, dois handoffs sobre ele (para "po" e "dev-lead") — nunca um segundo artefato para "spec-visual", que É o mesmo protótipo. Reusa a metade do desenho do ADR 0086 que sobrevive sem a suspensão dele: sucesso encerra o turno, para não duplicar propostas
0088O Staff (docs/fluxo.yml, camada_decisao_tecnica, status: planned desde o 0085) ganha CÓDIGO antes do gatilho automático dele existir — decisão consciente do dono do produto, sabendo que a Anamnese (que dispararia o handoff por "problema sistêmico recorrente") está pausada (ANAMNESE_ENABLED=false). Sexto agente conversacional solo, espelhando o Arquiteto (GenServer por sessão, laço bounded teto 14), com duas diferenças: SEM kickoff/1 (não há artefato de sessão para resumir — fica ocioso até a primeira user_message) e ativação pelo caminho GENÉRICO de canActivateAgent (handoff accepted endereçado a "staff"), sem entrar em USER_STARTED_AGENTS — investigação confirmou que nenhuma mudança de domínio na api era necessária, porque assertHandoffTargetAllowed só recusa subagente de área, e o Staff não tem área. A única ferramenta, propose_rfc (problema, opções com trade-offs, recomendação, PoC descartável), grava artifact.rfc_staff DIRETO via append_event_returning — mesmo padrão sem tabela de emit_insight, não o de artifact.c4_diagram (0068), porque o RFC não deriva nada do lado da api — e devolve o handoff ao Arquiteto no MESMO tool call, sem proposed_action (registrar um documento não é efeito externo). Declarado, não escondido: SessionPage.tsx/AGENTES_DE_CHAT não foram tocados — mesmo padrão já aceito para infra, um lead real que também fica fora dessa lista
0089analytics e delivery-metricas (docs/fluxo.yml, status: proposto) viram status: active como SCRIPT de relatório — decisão do dono do produto de antecipar os dois papéis sem esperar o gatilho orgânico que o próprio fluxo já declarava. apps/api/scripts/analise-funil.ts é o único artefato novo, no MESMO formato de medir-execucao.ts (Fase 13b): leitura pura via Drizzle, zero escrita, sem GenServer, sem agente de LLM — a forma que docs/fluxo.yml já prescrevia ("nunca vira agente" para delivery-metricas). Mede funil real (sessão → commit → PR → merge, contando SESSÃO por etapa, só ação executed), lead time real (primeiro commit ao primeiro merge, por updated_at da execução, não da proposta) e deployment frequency real (merge em branch PROTECTED_BRANCHES, cruzando por referência com o gate backmerge). Três métricas ficam DECLARADAS ausentes, de propósito: funil de produto completo ideação → commit (sessions sem storyId, RN-230), evidência de adoção por feature (o Brabo não instrumenta o que constrói — sem caminho hoje, não só sem dado) e MTTR/change failure rate (dependem de sinal de incidente real, mesma dependência de secops-runtime/platform)
0092platform (docs/fluxo.yml, status: planned, ativação sincronizada com DEPLOY_ENABLED — que não existe) nasce como SCRIPT de relatório sob demanda, não agente LLM nem GenServer: pnpm --filter api relatorio:telemetria [--projeto <uuid>] lê as MESMAS perguntas que o DomainGaugesCollector já coleta a cada METRICS_GAUGE_INTERVAL_MS para o scrape do Prometheus — sessões ativas/closing por projeto, tasks bloqueadas por projeto, estado do último backup (sempre GLOBAL, nunca por projeto) — mas como leitura PONTUAL, avulsa, que termina depois de imprimir. As consultas SQL são replicadas, não importadas do coletor: os métodos dele são privados e terminam escrevendo num gauge Prometheus, sem metade pura de "só a query" para reusar sem acoplar um script de CLI ao ciclo de vida de um @Injectable. A saída sempre linka para o que já existe versionado (dashboards, alertas, runbook, pnpm dev:obs) em vez de duplicar, e declara — nunca inventa — o que NÃO mede: SLO numérico formal (nenhum definido), postmortem (sem incidente real) e telemetria de volta ao produto em loop fechado (isso é o que tornaria platform active, e o gatilho continua ausente). status: planned e gate_saida: operavel do papel não mudam; docs/fluxo.yml só ganha uma nota na saída telemetria-consolidada dizendo que a versão manual já é real
0093O papel dbre (docs/fluxo.yml, ADR 0085) vira dois scripts mecânicos, antecipados por decisão do dono do produto sem esperar o gatilho ("volume real de dados") — nem agente LLM (não há julgamento de linguagem natural a fazer) nem GenServer (não há laço nem estado de longa duração), porque nenhum dos dois entregáveis exige isso: lint-migracao.ts é análise ESTÁTICA de texto SQL (DROP TABLE/TRUNCATE/DROP COLUMN/ALTER COLUMN ... TYPE/ADD COLUMN ... NOT NULL sem DEFAULT, lógica pura separada do adaptador de I/O, mesmo desenho de pr-police.ts) e relatorio-backup.ts só LÊ backup_runs com a mesma lógica de DomainGaugesCollector.collectBackup(), formatada como relatório sob demanda, citando o restore já testado (docs/runbook.md#restore) em vez de reexecutá-lo. O linter varre o REPOSITÓRIO inteiro (não o diff de uma PR) e por isso NÃO entrou em CI — rodá-lo hoje acha três ocorrências em migrations já mergeadas e aceitas (0006, 0007, 0034), e um gate que reprova o repositório inteiro reprovaria toda PR para sempre; virar gate de verdade exige escopar ao diff, como pr-police.ts já faz, para quando o dbre precisar BLOQUEAR e não só opinar. plano-de-capacidade/tuning continuam LACUNA, declarados: dependem de volume real de dados, que simular sem carga seria inventar número (mesma régua do 0042/0077)
0094A delegação Dev Lead → dev-<modulo> vira DADO em delegations (area: 'dev'), fechando o achado B1 da auditoria fluxo.yml × código e revogando o corte declarado no 0053 item 5. Gravada do lado API, dentro de AcceptParallelizationUseCase.execute (cobre os dois caminhos — direto e aprovado — de graça, porque os dois já convergem ali), diferente de QA/Infra que gravam do lado engine porque é lá que o subagente produz um parecer. status: 'completed' é REDEFINIDO para esta área: significa "a delegação foi EFETIVADA" (o agente subiu), não "parecer emitido" — e parecerArtifactId aponta para o id do evento artifact.module_map mais recente do projeto (via SessionEventRepository.listByTypeForProject, método já existente, reusado sem generalizar), o artefato que justificou a decisão de delegar. Sem module_map no projeto: NÃO grava com id falso, só loga o estado inesperado (RN-059) e segue — e falha em RecordDelegationUseCase nunca derruba a ativação, que já é sucesso quando a gravação é tentada
0109Handoff manual para um agente à escolha, fechando o item de backlog que o ADR 0088 tinha nomeado com precisão ("a UI genérica fica no backlog"). Sem terceiro estado de handoff: CreateHandoffUseCase (o único escritor de toAgent, ADR 0038) ganha um actor?: Actor opcional para que um handoff.offered iniciado por humano grave {kind:'user'} em vez do padrão {kind:'agent'} — o caminho de aceite/ativação existente o pega sem mudança. addressableAgents() (leads ∪ SOLO_CONVERSATIONAL_AGENTS) é mais estrito que assertHandoffTargetAllowed: um humano escolhendo num dropdown recebe um catálogo fechado, não só "não é subagente". A investigação antes de codar achou o ux-designer (ADR 0087) exatamente na mesma situação de alcançável só por rota interna que o Staff — os dois entraram juntos em AGENTES_DE_CHAT, sem mudança no engine para nenhum dos dois
0182A OFERTA de handoff ganha ciclo de vida: status superseded (migration 0064) com um evento imutável handoff.superseded, e no máximo UMA offered por (projeto, destino) — a mesma sessão sem artefato novo devolve a oferta existente, um artefato novo ou outra sessão a substitui (AT-291/292). Concorrência por advisory lock com escopo de transação por destino, não por índice único parcial, para que as duplicatas já existentes na tabela convirjam COM evento em vez de serem reescritas pela migration. Ativar um agente por qualquer caminho substitui as ofertas a ele; uma oferta a agente já ativo no projeto é 409 agente_ja_ativo, cuja mensagem é o que o modelo lê; "arquitetura pronta" e o AppSec (seAusente, RN-636) param de duplicar ofertas
0185UM clique — "Estou pronto — a necessidade está validada" — fecha a prontidão, o gate necessidade-validada e o aceite do PO (AT-311/312), revisando o clique SEPARADO do ADR 0095 por decisão do dono. O mesmo POST grava readiness.confirmed (marcado) e, quando o engine aceita o turno, necessity.validated com productBriefId: null (o brief vem do turno que o clique inicia); o gate continua warn. A oferta Criativo→PO que leva o brief nascido depois desse clique é aceita na rota interna que a cria, pelo MESMO AcceptHandoffUseCase: os mesmos eventos, a pessoa como ator e uma marca implicito em handoff.accepted/agent.activated. Um aceite implícito que falha vira agent.error durável; o engine não muda
0186O handoff do PO ao Arquiteto é aceito pelo SISTEMA, sem clique, quando o backlog está COBERTO (pelo menos uma regra de negócio e nenhuma sem história que a cite — a mesma computeCoverage da aba Backlog), o repositório que o aceite toca é local sem credencial de git no projeto, e quem abriu a sessão ainda tem developer (AT-314). Ele se apoia no ADR 0165 sem editá-lo: o provisionamento continua em AcceptHandoffUseCase, antes de activateAgent; o caso de uso novo só lê e decide, e grava handoff.accepted com o ator de sistema handoff-auto-accept e o critério no payload. Uma falha depois de decidir vira handoff.auto_accept_failed; repositório remoto ou credencial de git continuam pedindo o clique
0194APROVAR o propose_execution_plan do Dev Lead é o que ativa a execução, e aceitar o handoff ao Dev Lead só o traz para PLANEJAR (AT-263, decisão do dono de 01/10, revisando a fusão do ADR 0069, RN-161). O plano ganha um executor na api que o relê contra o module_map vigente, grava o MÓDULO de cada tarefa e chama o MESMO ActivateExecutionUseCase do botão, então o 409 sem repositório, o 409 de sessão consultiva e findActiveExecutionSession ficam como estavam. As tarefas ganham a coluna module (migration 0068), atribuída pelo Dev Lead no plano (tarefas: [{ taskId, modulo }], AT-274); tarefa sem módulo ou com módulo fora do module_map é recusada com 400 nomeado na proposta. Só o dev daquele módulo a pega; tarefa sem módulo só é pegável quando a história dela tem exatamente um módulo
0212Uma história pode ser ARQUIVADA, e isso é coluna — stories.archived_at/archived_reason (migration 0069) — e não um valor novo de story_status, porque o status é o portão do claim e arquivar não é passo de progresso (AT-406, decisão do dono de 03/10 "PO e usuário"). Toda leitura que DECIDE (backlog, cobertura, fila de promoção, o plano do Dev Lead, claim) filtra archived_at IS NULL, e as tarefas saem junto com a história; nada é apagado, e os eventos imutáveis novos backlog.story_archived/backlog.story_updated registram isso. Uma régua para a ferramenta do PO e a aba Backlog: só draft sem tarefa em execução, 409 nomeado no resto
0213O entregável roda na máquina do usuário pelo compose da Infra, nunca por uma porta publicada pelo broker (AT-517, decisão do dono de 11/10; ADR 0130 intocado). O compose constrói o APP a partir do Dockerfile do módulo com FROM = a imagem decidida, na porta do host ${APP_PORT:-8080}, com um .env.example commitado; propose_infra_pr ganha um payload run obrigatório (command, 1–5 urls de localhost, envExample) que a api transforma no artifact.run_instructions versionado. Os devs servem /openapi.json e /docs (Swagger de um CDN sob rede none), o QA os prova DENTRO do container, e o card da Visão geral diz "declarado pela Infra, não verificado na sua máquina" — o docker compose up real fica como lacuna declarada

Gates, QA e revisão de segurança​

O registro de gates, os vereditos de QA e SecOps e os golden-sets que os medem.

#decisão
0013Gates de QA e SecOps com máquina de estados de ordem imutável
0020Destrava dos gates: gitleaks na árvore de trabalho (não só no diff), pareceres validados como artefato, recuperação de tool call em texto
0021Fechamento da 4a: gate de infra que valida de verdade, e painel que diz a verdade
0054Gates como registro declarativo em docs/gates.yml. Os gates do fluxo existiam e tinham sido validados por execução real, mas estavam implícitos — espalhados entre prompt de agente, regra pura, use case, teste e CI —, e a lição da Fase 10/13 (métrica extraída por script, nunca anotada) não tem como ser aplicada ao que não é enumerável. Este ADR é a metade de cima do 0048: aquele tornou a decisão OBSERVÁVEL no event log, este torna o conjunto ENUMERÁVEL. Cada gate declara `evidencia: event_log
0057O agente de gate espera a aprovação, como o dev espera. O ADR 0052 fez isso para o dev agent; os agentes de GATE ficaram de fora, e ninguém percebeu porque nenhuma execução chegava até eles — a FASE 13b chegou. Na 6ª execução real o qa-automacao rodou `ls -la && find …
0067O gate sobrevive ao restart. O 0057 já declarava "restart no meio da espera perde o laço" como limite conhecido, mas investigar de novo achou uma SEGUNDA janela, distinta da suspensão em {:awaiting, ...}: DevAgentServer.correct/3 e Dispatcher.run_secops/2 são chamada direta em memória, feitas DEPOIS de record_gate_verdict já ter avançado o gate_status de forma durável na api — um crash entre as duas prende a PR pra sempre, e é a janela mais FÁCIL de acontecer, não a mais rara. gate_states (schema engine, chave {project_id, task_id, gate}) grava dois step: in_progress (nenhum veredito gravado ainda — nenhuma retomada cirúrgica do ctx, que não sobrevive a restart, mas reiniciar a área do zero é seguro porque a api rejeita record_gate_verdict pro gate que já não é mais dono do gate_status) e dispatch_pending (veredito já gravado, só falta a chamada em processo). Engine.Gates.GateRescuer varre linhas paradas há mais de 15 min (generoso de propósito — o ToolLoop de QA roda legitimamente até 60 iterações, e resgatar cedo demais duplicaria um ciclo só lento), chamado no boot e por tick Oban auto-reagendado, reusando o mesmo mecanismo de fila que o resto do engine já usa. Duas guardas contra duplicar trabalho: Registry.lookup local antes de qualquer resgate, e o próprio limiar de staleness como segunda linha pro que a primeira não cobre (réplica remota, Registry local ao nó, mesma ressalva do Engine.Dev.Wake desde o ADR 0045) — o pior desfecho de uma corrida residual é trabalho duplicado e barato, nunca dado inconsistente
0090Dois "segundos momentos" do mesmo desenho conceitual, cada um decidido pelo dono do produto para antecipar um gatilho que docs/fluxo.yml previa. O gate implementavel sai de planned para active: QA-estratégia deixa de ser papel proposto e vira o PRÓPRIO qa-lead num segundo MOMENTO, mesmo processo (Engine.Gates.QaLeadServer.run_design/3, aditivo ao run/2 de sempre), entregável separado — Engine.Gates.QaEstrategiaAgent (sem estado, sem Terminal, contexto LEVE reusando list_backlog/get_infra_context sem rota nova) produz artifact.plano_de_teste PRE-DEV. O Dev Lead ganha assess_implementability: sem plano ainda, dispara a avaliação e devolve erro pedindo retentativa (RN-163); com plano, propõe o parecer como proposed_action, MESMO padrão de propose_execution_plan (0086). O appsec (RN-360/361) segue o MESMO padrão sobre o secops: Engine.Gates.SecOpsAgentServer.run_design/2, no mesmo processo do secops de PR, produz artifact.threat_model (checklist STRIDE-lite sobre o DESENHO da story) e cria handoff para arquiteto, dev-lead e infra. Nenhum dos dois agentes NUNCA suspende — nenhuma ferramenta passa pelo ActionPipeline — e o teto de iterações fica em 8 (conversacional) de propósito, mesma razão de infra-workflows: sem token_budget_micros por baixo, subir o teto multiplicaria o pior caso sem nada para conter. Ambos ACIONÁVEIS, mas o gatilho automático (assess_implementability chamando run_design do appsec) ainda não existe — fora do escopo desta entrega (RN-340/341/360/361)
0091secops-runtime (docs/fluxo.yml, papel proposto da camada de segurança) antecipado como SCRIPT de relatório, não agente LLM nem GenServer — decisão consciente do dono do produto, mesmo com o gatilho declarado ("produção com tráfego real, pós DEPLOY_ENABLED + platform ativo") ainda não tendo disparado. pnpm --filter api relatorio:seguranca-runtime lê rate_limit_hits (o dado que o RateLimitGuard do ADR 0027 já grava, inclusive sob tráfego de dev/CI) e produz ranking de baldes (user:<uuid>/ip:<endereço>, os dois únicos formatos gravados — sem rota nem motivo, que a tabela nunca guardou) e distribuição temporal em fatias fixas. Detecção automática de incidente, resposta a incidente e postmortem seguem FORA — dependem do mesmo gatilho não disparado — e o relatório os lista numa seção "não medido" permanente, sem simular incidente de exemplo nem inventar número (mesmo princípio dos ADRs 0041/0042/0077). A janela retida é curta (DomainGaugesCollector.pruneRateLimit apaga hits com mais de 2 × RATE_LIMIT_WINDOW_MS, 240s por padrão) e o relatório declara as duas janelas — CONFIGURADA e OBSERVADA — nunca deixando a segunda passar por um histórico maior do que é
0095O gate necessidade-validada (docs/fluxo.yml, papel criativo, proposto desde o ADR 0085) fecha a ÚLTIMA onda da auditoria fluxo.yml × código (achado B2), com confirmação humana SEPARADA — nunca o Criativo se autovalidando, o anti-padrão já registrado em modelo-de-time.md. Botão dedicado em SessionPage.tsx (mesmo padrão de "Confirmar arquitetura pronta", RN-160), rota POST .../agents/criativo/validate-necessity, ValidateNecessityUseCase gravando necessity.validated — habilita só DEPOIS de confirm_readiness já ter consolidado o artifact.product_brief (não dá pra validar o que ainda não existe), e não sinaliza o engine: o handoff Criativo→PO já aconteceu dentro do próprio confirm_readiness, então este evento é só o registro do mérito, não um gatilho de ativação. docs/gates.yml ganha o gate active/warn (nada hoje CONSULTA a passagem dele antes de deixar o PO seguir)
0123Fecha o achado aberto do próprio ADR 0020 — o passo semântico do gate de QA (casar regra de negócio com teste) é não determinístico com modelo local, fechando na 10ª de 11 execuções — com um golden-set de seis casos de regressão rodado deliberadamente (mix golden_set.qa) contra um LLM local REAL (Ollama), nunca em CI (bloqueio confirmado: nenhum ollama nos workflows, nenhum segredo de LLM, e a própria cultura do CI rejeita allow-failure silencioso). Engine.Gates.QaAutomacaoAgent.run/5 é chamado direto, como em qa_automacao_agent_test.exs, mas contra o EngineApiClient.Live REAL — que precisa da api e do engine rodando de fato, e de um System.cmd NOVO entre linguagens (Elixir chamando pnpm/ts-node) para semear linhas reais de projeto/sessão/binding de modelo que o banco isolado engine_test do teste do engine não alcança. apps/api/scripts/seed-golden-set-qa.ts faz o PRÓPRIO checkout de git (o worktree precisa morar dentro de projectScopeRoot para o teto de escopo de caminho de decide() auto-aprovar npm test algum dia — achado empiricamente) e semeia agent_areas (sem ela, o metering obrigatório do primeiro turno de LLM dá 404 — também achado empiricamente). A tag ExUnit :golden_set_qa é excluída PERMANENTEMENTE, nunca por detecção de binário/serviço, porque esta máquina já roda Ollama continuamente e a inclusão automática dispararia a suíte dentro de qualquer mix test. Uma catraca floor.json (espelhando coverage-floor.ts, >= nunca >, editada só por humano) substitui um passa/reprova binário: as taxas de acerto reais medidas foram qwen2.5-coder:latest 1–4/6 em quatro execuções (falha dominante: argumentos de comando de terminal vazios — a mesma falta de confiabilidade em tool calling que o ADR 0020 já achara nessa família de modelos) contra gpt-oss:20b 4–5/6 em duas (4x mais lento por caso) — gpt-oss:20b é o padrão recomendado para qualquer fluxo que dependa de esse julgamento ser confiável. A ligação com o CI fica como TODO(humano) declarado
0168O golden-set do QA Automação (ADR 0123) passa a rodar em CI em .github/workflows/golden-set-qa.yml, no molde do ADR 0138 — AGENDADO e SEMANAL (sábado 01:17 UTC, mais workflow_dispatch), nunca em pull_request. Duas medições o tornaram possível: a AT-067 mostrou que o relógio cabe num ubuntu-latest sem GPU (refutando a metade "infraestrutura nova" da frase do ADR 0123), mas pontuou 0/6 por construção sob a RN-502; a AT-076 fez o seed subir o container de cada caso pelo broker pelo caminho de produção, pontuando 3/6 duas vezes contra o piso de 1/6, em 52 e 86 min. Semanal e não noturno porque uma execução custa 52–86 min e a pontuação VARIA entre execuções (mesmo total, casos diferentes) — uma execução por semana lê a tendência que o piso da catraca guarda; timeout-minutes: 150 cobre os 86 min medidos mais a variância do modelo. O veredito é o piso do próprio teste; o workflow acrescenta só duas guardas de instrumento (a linha de pontuação tem de existir, e nenhuma recusa da RN-502 pode aparecer no event log) e, na falha, imprime logs exatamente como o job do RAG — sem issue. api, engine e broker rodam nativos no runner e só o broker fala com o Docker; a imagem a pré-baixar é lida da própria constante do seed, para que o job nunca baixe uma imagem enquanto o broker pede outra dentro do teto de controle de 30 s (RN-604). Declarado: o broker do CI não tem as cinco camadas de contenção do compose (o que resta é o que ele aceita), e um PR que edita o workflow não o roda
0192O plano de teste é escrito DEPOIS da entrega do dev agent, a partir do código entregue, não na hora do desenho (AT-269, decisão do dono de 01/10, a saída "(b)" que contradiz o ADR 0090). Ele vira o PRIMEIRO passo do ciclo qa-verificada: QaLeadServer.run_area/3 roda QaEstrategiaAgent.run/6 no worktree do dev com os arquivos que a entrega tocou (git diff dev...HEAD), uma vez por TAREFA (a rodada de correção reusa o plano por taskId), e o plano chega à Automação como ENTRADA — a regra do veredito e o contrato de um veredito não mudam, e um plano que falha nunca segura a revisão. O gate implementavel fica (mesmo dono, warn, aprovação humana) e é julgado pelo Dev Lead sobre a história e o module_map que já estão no contexto dele: o veredito sai na PRIMEIRA chamada, sem campos de plano. run_design/3, run_qa_estrategia/3 e QaEstrategiaContext saem; artifact.plano_de_teste exige taskId; o teto de iterações FICA em 8, agora com o orçamento da tarefa por baixo; o appsec continua na hora do desenho (RN-674)
0207Um ciclo de gate parado há mais de 2 horas desde a última atividade é estacionado com um evento gate.rescue_parked e só um gesto humano o retoma; o resgatador pergunta ao lead sobre a TAREFA, não sobre o projeto, e deixa em paz um ciclo com decisão humana pendente (AT-391, revisa o 0067).
0214Uma pergunta em aberto ao usuário vira pendência com estado no log (artifact.open_question + open_question.answered + um decision_record), declarada só pela ferramenta declare_open_question e respondida só pelo autor da sessão; o QA ganha o veredito needs_user_decision, que não consome o ciclo de correção e estaciona o gate pelo mecanismo do ADR 0207 até a resposta; a decisão chega ao dev-context e ao corte do QA, e o PO pode corrigir uma história ready a partir de uma pergunta respondida (AT-521, estende o 0207, revisa parte do 0212).
0215O openQuestionId de um veredito de gate tem de existir no projeto e estar em aberto (desconhecido é 404, já respondido é 409 com a decisão), afeta.regras aceita o título exato da regra e guarda o id, e o dev-context devolve também as outras decisões do projeto como decisoesDoProjeto dentro do mesmo teto de 10 (AT-526, revisa parte do 0214).

Critério de aceite ainda aberto. O 0020 registra explicitamente que a demo dos gates está "FECHADA, mas NÃO DETERMINÍSTICA" — depende do julgamento de um modelo local de 7B e não serve como teste de regressão. O 0021 tem uma seção "Estado do critério de aceite: NÃO FECHADO". Os dois são honestos de propósito; não os leia como concluídos.

Execução, política de terminal e aprovações​

O dev agent trabalhando, proposed_action, a política de terminal, o escopo de caminho, o modo automático e os tetos absolutos.

#decisão
0011Worktrees por dev agent, executores git e a trava de merge: branch protegida nunca é auto-aprovável
0012DevAgent real: ToolLoop no worktree, orçamento por task, ReportDone exigindo suite verde, bloqueio com diagnóstico
0017Lock de inicialização do workspace, Monitor de dev agents e herança do teto de correções pelo subagente
0018O NoopDevAgent deixa de ser andaime e vira modo de execução permanente
0019Destrava do DevAgent real: escrita, terminal, ADR por módulo e orçamento por projeto
0045Reagendamento do dev agent por evento: outbox (não chamada em processo) porque sobrevive a um restart entre o veredito do gate e o wake; awaiting_gate retém o worktree por AGENTE (não por task) até o gate terminar; entrega por PubSub (não Registry, local ao nó) com o limite at-most-once aceito conscientemente; circuit breaker (RN-047) mora no engine porque actor.id de backlog.task_blocked não identifica a sequência de um agente
0047Fechamento da Fase 12: a validação é um SCRIPT que sai != 0, não um roteiro em prosa, e a evidência é extraída do banco em vez de transcrita; roda local e sem LLM por dois motivos verificados (o fork nunca foi nomeado; o julgamento dos gates com modelo local não é determinístico desde o ADR 0020), e isso é declarado no primeiro parágrafo e não no rodapé; o Noop entrou na máquina de estados da 12b — o achado #10 sobrevivia dentro do próprio instrumento de medida —, movendo-a para o AgentIo em vez de copiá-la; a colheita da Fase 10 foi escrita com os buracos como não medido
0048Dois achados revisitados: a DECISÃO de uma ação (created/approved/denied) vira evento de domínio com o ator real — o outbox é transporte, não memória, e events.md documentava os três desde sempre sem que existissem; status no payload do created é o que distingue clique humano de auto-aprovação por política, a métrica que a Fase 10 não conseguiu colher. E o D5: a causa não era o worktree ser reciclado, era propose/3 descartar o status e o gate abrir sem PR — o QA varria o worktree, aprovava, e a task fechava sem uma linha commitada. Corrigida a ordem, o D5 morre por consequência, e worktree-por-task (previsto pelo ADR 0045) deixa de ser necessário
0052O dev agent espera a aprovação no meio do laço, em vez de queimar iterações. Ferramenta :pipeline pendente devolvia a string proposed_action <id> status pending como RESULTADO, e o ToolLoop seguia: o agente não aprendia nada sobre o comando, tentava outra coisa, e morria em toolloop.limit_reached {iteration: 8, max_iterations: 8} sem escrever uma linha — as aprovações concedidas chegavam depois do laço esgotado e eram inúteis. A allowlist de terminal não resolve porque é lista de comandos previstos e o modelo inventa comandos: liberados os de leitura, o mesmo agente travou em git branch -a. :awaiting_approval JÁ existia (Fase 12e, para o pr_open do fim da task) com todo o encanamento pronto — evento de outbox, worker, Wake.deliver, retenção de worktree —, então o que falta é aplicá-lo ao meio do laço. NÃO se espera bloqueando: o ToolLoop roda dentro do callback do GenServer, e bloquear ali trava a caixa de mensagens que traria a própria decisão
0055Escopo de caminho na política de terminal. A escada de aprovação de terminal foi MEDIDA como inviável: 15 turnos, 209.031 tokens de entrada contra 4.546 de saída, 14 ações e ZERO linha escrita para a task, porque quase todo turno terminou esperando um clique. Dois defeitos independentes explicam o "pergunta sempre": o agente emite cd <caminho> && <verbo> e cd não está em allow nenhum, e comando composto exige que TODOS os segmentos casem (decide.ts:201), então o allow semeado quase nunca é alcançado; e "Sempre permitir" grava o comando LITERAL inteiro, que nunca volta a casar porque o casamento é por prefixo de token — o escape hatch não escapa. O que o usuário pede, "sempre desde que na pasta do projeto", é hoje inexpressável: só write_file tem noção de caminho, terminal não. A execução expôs o agravante: /workspace dentro do executor é o monorepo do PRÓPRIO Brabo, e o dev agent do hello-limpo chegou a propor ler git_executor.ex e context_builder.ex — não existe fronteira de projeto, e o alcance é maior que o projeto. Escopo resolve por REALPATH e não por prefixo de string (um .. escaparia), PERMITE sem isentar (rede e instalação continuam pedindo; estar na pasta não torna curl piped para shell seguro), deny continua vencendo primeiro, e fora do escopo é require_approval e não deny. É política, não isolamento: enquanto o monorepo estiver montado no container que executa, a fronteira depende de a política acertar
0102Revisão do ADR 0065, decisão GLOBAL do dono do produto (confirmada explicitamente após aviso automático de segurança): git push/PR/deploy (RN-106) e sudo/doas (novo) saem de deny incondicional e viram TETO ABSOLUTO — require_approval incondicional, mesmo padrão de código dos outros tetos, nunca auto-aprovável por auto mode nem permissions.json. A fresta do "sempre permitir" foi fechada NA FONTE (ApproveAlwaysActionUseCase recusa gravar padrão pra esses comandos) — sem isso o teto seria decorativo
0167O modo automático libera o teto de escopo de caminho, e só ele. Medido na instalação do dono (AT-226, projeto exp001, mounted com container rodando): com a curinga "*": auto_approve ligada, 51 comandos de terminal ainda pediram aprovação, 47 só porque citavam /work ou /tmp — o dev agent roda dentro do container e o escopo do ADR 0055 compara com a raiz do HOST. Decidido pelo dono do produto: um agente em modo automático roda qualquer comando de terminal sem aprovação, mesmo fora da pasta do projeto. decide() passa a receber do repositório a ORIGEM da autonomia (regra específica ou curinga), e o repositório continua dono da precedência da específica sobre a curinga; só a curinga resolvida como auto_approve é modo automático. Em modo automático o teto de escopo de caminho e o require_approval que um comando composto sintetiza para um segmento não coberto deixam de valer; um ask escrito em permissions.json, todo deny, o teto de efeito externo/privilegiado (RN-418), o merge em branch protegida, instruction_patch, o paralelismo e container_remove ficam exatamente como estavam. O card de aprovação e o card do agente dizem, antes do clique, o que o modo automático libera e o que ainda pergunta. Recusa: traduzir /work antes da checagem de escopo, e liberar o escopo para qualquer auto_approve específico
0189O modo automático vira o PILOTO AUTOMÁTICO, e o escopo de caminho compara com a pasta onde o comando RODA (AT-259/255/258, decisão do dono de 01/10). O ADR 0167 NÃO é revogado: a curinga *: auto_approve aprova tudo — comandos compostos sintetizados, caminhos fora da pasta, git commit e criação de branch LOCAL — exceto os tetos absolutos (push/PR/merge/deploy, sudo/doas, merge em branch protegida, container_remove, instruction_patch, paralelismo); deny continua vencendo. Um auto_approve específico gravado por "Sempre permitir" sob a curinga resolve COMO a curinga, então não desliga mais o piloto automático. Com um container running registrado (container/mounted), a raiz de escopo do ADR 0055 vira /work mais o /tmp do container, com o cwd do host traduzido como o engine traduz; sem ele, fica a pasta do host, com /tmp de fora. A UI mostra uma lista do que o piloto automático libera e do que não libera, nos três lugares onde ele é ligado. E "Sempre permitir" grava VERBO + SUBCOMANDO, um padrão por segmento de comando (AT-257, fechando a AT-170 e o ponto 6 do ADR 0055): verbo + flag fica exato, e uma unidade que seria prefixo de um teto da RN-418 (git remote, gh pr) também fica exata. A contenção que sustenta a decisão é provada de ponta a ponta no broker, e o que ela NÃO cobre é declarado
0204O modo automático libera git_push e pr_open (tipados, e o mesmo push/PR pelo comando de terminal), por decisão do dono de 02/10 (AT-385, RN-713), revisando o ADR 0102 e a RN-689 só para push/PR. SÓ a curinga *: auto_approve os libera — regra específica, "Sempre permitir" e permissions.json continuam não liberando, e a ativação segue semeando só git_commit. Merge em branch protegida, deploy, sudo/doas, container_remove, instruction_patch e paralelismo continuam tetos absolutos; voltar o toggle para manual restaura a aprovação

Onde o código mora — modos de execução​

Os três modos de execução, a pasta do usuário, a base de projetos e a conversão de um projeto entre modos.

#decisão
0066Nome de pasta legível do workspace. Revisa o 0055: projectScopeRoot passa a receber workspace_dir_name, não mais o projectId cru, preservando a garantia de que api e engine concordam sobre a raiz do projeto. Pedido do usuário: pasta física legível (<slug>-<8 chars do id>), com id único — PROJECT_WORKSPACES_ROOT é UMA raiz para a instância inteira, e slug sozinho não é globalmente único. Entre as duas opções apresentadas (symlink de menor risco vs. renomear a pasta de verdade), o usuário escolheu explicitamente a segunda. O id nasce em CÓDIGO (crypto.randomUUID() no CreateProjectUseCase, não mais defaultRandom() do Postgres), porque o nome da pasta precisa dele ANTES do INSERT. Projeto criado antes desta migração manteve a pasta física que já tinha — o backfill grava workspace_dir_name = id e NUNCA renomeia diretório nenhum, e o nome é CONGELADO na criação: editar o slug depois não recalcula a pasta. Um trigger BEFORE INSERT (o primeiro do histórico de migrations do produto) aplica o mesmo fallback id::text para insert que chegue sem o campo — rede de segurança, não o caminho principal, e o que evita reescrever mais de cinquenta specs de teste que inserem projects sem conhecer o conceito. O engine lê a MESMA coluna (Engine.Projects.Project.workspace_dir_name/1) para resolver Workspace.workspace_dir/1, nunca recomputando o nome — as duas derivações são, na prática, a mesma leitura contra o mesmo banco. Fora do hot path do laço de ferramentas (que já recebe ctx[:workspace_root] resolvido uma vez, na criação do worktree do agente); WorktreeCleanup trocou de varrer o disco para consultar Project.all_workspace_dirs/0 numa query só, porque a pasta deixou de ser o project_id e não há como voltar um para o outro sem consultar
0072O projeto escolhe onde o código mora: a pasta do usuário (Local) ou uma pasta gerenciada (Container). Revisa parte do 0065 e pisa no terreno do 0055 — os dois tinham decidido a direção OPOSTA, rumo à parede do container, e é por isso que a seção de consequências é a parte mais importante deste ADR. Pedido do usuário, com a variante escolhida explicitamente: caminho digitado livremente, ciente do aviso de que só funciona montado no container. projects ganha o par (workspace_mode, workspace_path) com um CHECK amarrando os dois — local sem caminho seria um escopo de terminal apontando para lugar nenhum, container com caminho seria uma segunda fonte de verdade —, e a trava mora no BANCO porque a coluna é lida por dois processos e escrita por scripts que não passam pelo caso de uso. container é o padrão: projeto existente não muda de lugar. projectScopeRoot passa a receber o LOCAL em vez do nome da pasta e continua sendo a derivação ÚNICA — nenhum chamador ganhou validação própria, pelo mesmo motivo de a função existir. A guarda da criação (RN-170) RECUSA com 400 e ensina: caminho absoluto, sem .., que existe, é pasta e tem escrita pelo processo (`W_OK
0104Os ADRs 0072 e 0103 nunca se falaram: RN-170 exige bind-mount na criação, RN-420 roteia pro runner sob a MESMA flag workspace_mode == 'local' sem bind-mount nenhum — usar o runner de verdade continuava obrigado a passar pela validação de pasta montada. workspace_mode (2 valores) vira execution_mode (3: container, mounted — o antigo local, RN-170 agora condicional a ele — e runner, sem bind-mount). Projeto runner nasce com caminho DECLARADO e estado workspace: unverified, promovido a verified só quando um runner conecta e confirma no HOST — mesmo espírito de gate com evidência por evento que docs/gates.yml já usa. Qualquer conversão entre os três valores passa a ser permitida, sem recriar o projeto. Backlog priorizado do dono do produto, registrado nesta ordem: distribuição do runner via npm publish (@brabo/runner, tsup), token de conta de longa duração (PAT) substituindo o replay de login em apps/runner/src/auth.ts — ANTES da distribuição —, exclusividade por {project_id, machine_id} ADIADA até segundo dev simultâneo real, e guard.ts REAFIRMADO como best-effort — a fronteira de segurança real é autenticação + aprovação + consentimento, nunca sandboxing
0107Navegação de pasta local, revisando (sem editar) o ADR 0072: o seletor de pasta que o 0072 tinha recusado por exigir a api enumerar o filesystem do CONTAINER passa a existir pelo RUNNER (ADR 0103), que já tem a visão certa — a máquina real do usuário. Dois eventos novos no MESMO canal terminal:<projectId> (fs_list_dir/fs_home_dir), mesmo desenho de relay puro do PTY, correlacionados por ref. A api continua sem enumerar filesystem nenhum — nenhuma rota nova. A fronteira de segurança continua a do 0103 (autenticação + leitura sem efeito + consentimento), não uma allowlist de caminho — guard.ts (que restringe cwd de comando aprovado) de propósito NÃO se aplica aqui, porque o objetivo é navegar LIVRE. Gap declarado: na tela de CRIAÇÃO de projeto o ticket é por-projeto e o projeto só nasce na confirmação, então "Procurar pasta..." ali mostra o estado declarado em vez de fingir uma conexão impossível — funciona de ponta a ponta onde o projeto já existe
0108Fecha a lacuna que o próprio ADR 0107 tinha declarado: no modo runner, o NewProjectWizard.tsx passa a criar o projeto quando "Navegar pasta..." é clicado, não só na confirmação — usando o caminho já digitado, ou um marcador provisório lexicamente válido (/workspace-a-confirmar, sobrescrito quando o runner real conecta, RN-423) quando o campo ainda está vazio. Reusado por um retrato de IDENTIDADE (name/externalId/adotando — nunca o caminho, cujo refinamento é justamente o motivo de navegar): identidade inalterada reusa o mesmo projeto, identidade alterada cria outro. O modo mounted não muda — a validação do caminho toca o disco na criação, então continua esperando a confirmação. Efeito colateral aceito: um projeto órfão "não provisionado" se o wizard for fechado antes de terminar, o MESMO estado que qualquer criação interrompida já produz hoje
0111Converter o execution_mode de um projeto EXISTENTE, corrigindo a afirmação do item 4 do ADR 0104 de que isso já era possível "sem recriar o projeto" — não era; o UpdateProjectDto continuava excluindo os dois campos de propósito. Uma rota dedicada (PUT :projectId/execution-mode, role:maintainer) orquestra o que uma escrita crua da coluna não consegue: recusa (409) enquanto algum dev agent do projeto não estiver ocioso em QUALQUER sessão (engine.dev_agent_states, idle_tripped conta como ativo aqui, ao contrário da contagem de online da RN-409, porque o agente ainda segura um workspace_root capturado) — nunca drenando nem migrando um agente vivo; realoca o permissions.json para a nova raiz de escopo por um PermissionsFileStore.move() novo (o conteúdo nunca muda, só o caminho físico); aposenta a linha de ciclo de vida de project_containers para removed (passando antes por stopped se estiver running) ANTES de a coluna virar, ao sair de container, e nunca provisiona sozinho ao entrar nele; zera workspaceVerifiedAt a cada conversão real. Declarado, não escondido: a conversão nunca copia código entre raízes de escopo — o engine rederiva o worktree a partir do repositório bare do projeto sob demanda (Engine.Actions.Workspace.ensure!/4), então o trabalho commitado está seguro, mas um diff NÃO COMMITADO no worktree antigo abandonado fica órfão no lugar, nem migrado nem apagado
0141Criar um projeto mounted exigia editar à mão uma linha de bind-mount no docker-compose.yml para api E engine e reiniciá-los — o que mata todo turno de agente em voo, socket de terminal e chamada de LLM da instalação inteira, para acolher UM projeto. Tolerável enquanto mounted era uma rota de fuga; desqualificante agora que vira escolha de primeira classe com caminho sugerido. Então: UMA base, BRABO_PROJECTS_BASE, configurada uma vez pelo operador e montada por IDENTIDADE ($X:$X) em api e engine, com todo projeto montado morando debaixo dela e nada editado por projeto, nunca. Identidade e não um mountpoint fixo porque projects.workspace_path é uma string que o usuário DIGITA e a tela mostra de volta: com host ≠ container o produto teria de escolher qual dos dois guardar, e qualquer escolha faz a tela mentir para alguém — e identidade é exatamente o que deixa projectScopeRoot (api) e Engine.Actions.Workspace.workspace_dir/2 (engine) continuarem certos com zero código novo, com a barra inicial ainda decidindo. Uma variável NOVA, nunca PROJECT_WORKSPACES_HOST_DIR, por três motivos, o primeiro com consequência em dados: um workspace gerenciado é nomeado por workspace_dir_name (UNIQUE) e um projeto montado é nomeado pelo USUÁRIO, então <base>/loja e um projeto container cujo nome de pasta é loja cairiam na MESMA pasta física e init_from_bare! daria git init dentro do projeto de outra pessoa — nada no schema impede isso, já que a unicidade é entre valores de workspace_dir_name e nunca contra o nome final de um workspace_path. Ausente é estado NORMAL: baseDeProjetos() devolve null e nunca lança, GET /workspaces/:workspaceId/projects-base (maintainer, o mesmo mínimo de POST .../projects, já que decidir o que essa rota oferece é a única serventia do valor) leva o null ao cliente, e o wizard simplesmente não oferece o modo Montado — nunca ofereça um modo que a instalação não pode honrar. E o ponto cego do /workspace finalmente ganha uma guarda onde uma é possível: scripts/dev/preflight.mjs RECUSA subir a stack quando a base se sobrepõe ao checkout do Brabo em qualquer sentido, porque ele roda no HOST — a api compara contra process.cwd(), /workspace dentro do próprio container, então quem clona o Brabo em $HOME/brabo e aponta a base para lá passa por toda validação existente e tem dev agents executando dentro da árvore do próprio produto, a falha do ADR 0055 entrando por uma porta que ele não vigia. Dois custos declarados: um symlink sob a base resolve diferente dos dois lados (o navegador de pastas se recusa a descer em symlinks e os conta; a checagem de disco na materialização é o que pega a divergência, como falha NOMEADA), e código fora da base exige mover a pasta — a v1 suporta uma base, e a mensagem de recusa diz isso em vez de insinuar uma configuração escondida
0142O ADR 0141 decidiu ONDE um projeto mounted mora e deixou explicitamente a outra metade para o PR seguinte: QUANDO essa pasta passa a existir. O requisito do dono do produto é literal — "se for Pasta montada, o bind-mount deve ser criado DEPOIS da decisão do arquiteto" — e o código de hoje o torna impossível: validarCaminhoDeWorkspaceLocal roda na CRIAÇÃO do projeto e faz três perguntas de disco (existe? é diretório? este processo escreve nele?), recusando com 400 quando alguma falha. A criação é a PRIMEIRA tela do fluxo; a decisão do arquiteto acontece muitas sessões depois, então exigir a pasta pronta na criação é exigi-la antes de existir qualquer decisão — e é o que impede mounted de ser escolha de primeira classe, já que um caminho que o wizard SUGERE é, por construção, um caminho que ainda não existe. Então mounted adota a disciplina que o runner já tinha (RN-423): só o predicado LÉXICO na criação, mais uma regra nova própria — o caminho tem de estar dentro de BRABO_PROJECTS_BASE, a única pasta da máquina que os containers da api e do engine enxergam, e sem base configurada o MODO não está disponível nesta instalação, com a recusa dizendo isso. A verificação adiada volta em materializarWorkspaceMontado (mkdir -p mais as mesmas três perguntas de disco, com a recusa de fora da base ANTES do mkdir para que um caminho escrito fora do produto não faça a api criar pastas onde quer que alcance), chamada de dois lugares: ExecuteContainerStartUseCase, o normal, onde a Infra subindo o container cria a pasta e carimba workspace_verified_at — a falha ali é um failed NOMEADO, nunca um throw, e o ciclo de vida NÃO é marcado provisioning, porque marcá-lo e só então descobrir que não se pode escrever deixaria a linha afirmando um estado que nunca existiu; e ConvertProjectExecutionModeUseCase, a exceção declarada, que não tem passo de container onde pendurar o trabalho e move o permissions.json para dentro dessa mesma pasta logo depois. O que deliberadamente NÃO muda é caminhoDeWorkspaceLocalValido: a regra da base fica fora dele porque esse predicado roda em TODA LEITURA via projectScopeRoot, e um projeto mounted LEGADO fora da base começaria a explodir só de ser lido. Sem migration, e o CHECK (execution_mode <> 'container') = (workspace_path IS NOT NULL) fica intocado — adiar a VERIFICAÇÃO nunca toca o invariante de PAREAMENTO
0146O modo Montado estava pronto e inalcançável: o ADR 0141 construiu a base única e o ADR 0142 tornou possível sugerir um caminho que ainda não existe, mas nada no produto chega a pedir ao operador que configure BRABO_PROJECTS_BASE — então GET /workspaces/:workspaceId/projects-base não tem chamador no web e o modo simplesmente nunca é oferecido. Por isso o pnpm bootstrap ganha um passo de consentimento: ele propõe $HOME/projetos-brabo (sob $HOME porque é o que o Docker Desktop compartilha POR PADRÃO — um padrão que exige antes abrir as configurações do Docker é uma pergunta disfarçada), e NÃO $HOME/brabo-projetos, porque o .env.example já usa esse nome exato como exemplo comentado de PROJECT_WORKSPACES_HOST_DIR, a raiz que o PRODUTO gerencia — exatamente a confusão que o ADR 0141 recusou, já que <base>/loja colidiria com um projeto container cujo workspace_dir_name também é loja e o bootstrap daria git init dentro do projeto de outra pessoa. O nome é só o desvio; a proteção é MECANISMO: o script recusa qualquer base igual a essa raiz, que a contenha ou que esteja contida nela, reusando o dentroDe que já sustenta baseSobrepoeOCheckout. Escrito por scripts/dev/consentir-base.mjs em Node e nunca em bash, por dois motivos medidos: o bootstrap.sh não tem helper que escreva variável (garantir_env só copia o exemplo, e o único escritor no lugar é escreverEnv em preflight.mjs), e — decisivo — um item de menu NÃO CONSEGUE PERGUNTAR, já que todo comando de menu roda em segundo plano com stdin de /dev/null para que nada roube as setas do usuário; um passo de consentimento escrito ali seria, por construção, um passo que não consegue consentir. O compartilhamento de arquivos do Docker Desktop é PROVADO MONTANDO (docker run --rm -v <base>:/sonda ... ls /sonda), nunca lendo settings.json: esse arquivo não é documentado, difere entre macOS e Windows, muda entre versões e descreve o que o usuário configurou em vez do que o daemon vai fazer — a mesma regra que o produto já aplica a providers de LLM, onde uma capability só é declarada quando provada por execução (ADR 0041/0042). Isso vira a SEGUNDA guarda de plataforma do produto, ao lado de validarDirDentroDoHomeNoLinux, e as duas são simétricas em princípio: cada uma restringe só onde a restrição significa algo. E o broker sai de profiles: ["container-broker"] só no compose LOCAL — o ADR 0130 escolheu esse profile porque "nada o chama para escrever ainda... em troca de nada", e essa premissa morreu: os ADRs 0133/0136 lhe deram quatro chamadores reais e o ADR 0144 fez mounted subir o container PELO broker, então com mounted como padrão local o broker é o que separa o modo padrão de BrokerIndisponivelError. A assimetria é o que sustenta isso: quem roda pnpm dev já tem o socket do Docker — está rodando docker compose —, então o broker recebê-lo não concede nada que falte ao operador, enquanto em produção o socket é uma fronteira real de privilégio e o operador não é o desenvolvedor. As cinco camadas de contenção ficam intocadas: isto muda QUANDO o broker roda, nunca O QUE ele aceita

Container do projeto e broker​

O ciclo de vida do container do projeto, a decisão de imagem, a porta de Docker e o broker que fala com o daemon.

#decisão
0065Container por projeto: a fronteira deixa de ser política. Revisa o 0055, que declarara de si mesmo a pendência: escopo de caminho é POLÍTICA, não isolamento — enquanto o monorepo do Brabo estiver montado no mesmo container que executa comandos, a fronteira depende da política acertar, e symlink de dentro apontando para fora não é detectado. Os achados Z e AD (allowlist de verbo não converge — verbo, forma e invocação são espaços distintos) reforçam a mesma conclusão da FASE 13: o caminho para autonomia não passa por afrouxar política, passa por trocar a fronteira. Decisão do usuário, textual: cada projeto ganha container próprio, dentro dele o agente é LIVRE (ler, escrever, instalar, buildar, testar, rodar), e o Code só libera depois que o ARQUITETO decide qual imagem sobe — ele é quem sabe qual runtime a stack pede. A imagem nasce ARTEFATO (artifact.project_image no event log, versionado, sem tabela — revisar é emitir de novo), com tag OCI explícita (latest recusado) e rationale obrigatório; o portão (RN-105) faz a aba Code responder 409 enquanto sem_decisao, na MESMA função que já contém o caminho (RN-092), nunca duplicada nas quatro rotas. A fronteira que sobra é FORA: git push, PR e deploy não saem pelo terminal — nem dentro do escopo do projeto —, e a regra é deny, não require_approval, porque "sempre permitir" grava o padrão em allow e um clique reabriria a porta para sempre; a mensagem redireciona para a ação TIPADA (git_push/git_merge/pr_open), que já é o caminho real do dev agent. Rede é decidida UMA VEZ no artefato (network: none default, egress autorizado pelo usuário) e não comando a comando — repetir o allowlist para egresso seria repetir o erro que Z e AD provaram não convergir; gasto (cpus/memória/pids) tem teto que RECUSA em vez de rebaixar em silêncio. Corte HONESTO e declarado: o ciclo de vida do container (provisionar, reciclar, limpar, o que sobrevive a restart) fica para a fase com slot de migration — estado de container é mutável e pede tabela, e um container que sobe sem reciclar é pior que nenhum. Consequência: a metade "dentro é livre" da política de terminal AINDA NÃO mudou; o 0055 continua valendo como está até a parede subir de verdade
0081Ciclo de vida do container: tabela de estado, sem orquestrador. Fecha o corte que o 0065 declarou de si mesmo ("O que este ADR NÃO faz") — project_containers (migração 0046) grava o ESTADO mutável (status, image_version apontando para a decisão do Arquiteto sem duplicá-la, container_id, teto de recursos DECLARADO, failure_reason), uma linha por projeto como dev_agent_states no engine (0045), com máquina de estados pura (provisioning → running ⇄ stopped, failed alcançável dos três, removed só sai reprovisionando) no mesmo formato de session-state-machine.ts. Investigação prévia confirmou que NENHUM serviço do produto monta /var/run/docker.sock nem roda privileged — e por isso os dois casos de uso (Registrar/ObterCicloDeVidaDoContainer) só GRAVAM e LEEM, nenhuma chamada real a Docker: montar o socket para "fazer funcionar de verdade" seria tomar uma decisão de segurança (vetor de escalação a root) que ninguém pediu nesta fatia. A primeira transição (provisioning) exige a imagem já decidida (RN-105, mesmo portão da aba Code) e CONGELA versão/recursos na linha; projeto em workspace_mode: 'local' (0072) é recusado, porque não sobe container próprio. Nenhuma rota HTTP nova — sem consumidor real ainda, expor uma seria adivinhar contrato
0083A aba Terminal ganha o consumidor real do ciclo de vida, não o terminal. Revisa a parte do 0081 que adiara a rota HTTP até existir um consumidor real — esta frente (PROGRAMA 28, Onda 5/F2) era esse consumidor. O plano original era o terminal interativo completo, mas a FASE 25b continua cortada: nenhum serviço monta /var/run/docker.sock, e nada em produção transiciona project_containers (o ADR 0081 criou a tabela, não um orquestrador). Implementar um terminal fingido — ou rodando no mesmo container do monorepo do Brabo, a dívida que o 0055 já descreve como política — inventaria capacidade, o mesmo erro que os ADRs 0041/0042 recusam para provider e modelo. A entrega real: GET /projects/:projectId/container/lifecycle (RN-267), espelho fiel de ObterCicloDeVidaDoContainerUseCase, e CodeBottomPanel.tsx mostrando esse estado (badge traduzido, motivo de falha) SOB o texto explicativo que já existia — nunca no lugar dele —, buscado só enquanto a aba Terminal está aberta (RN-268). null (nunca provisionado) é o resultado honesto e mais comum hoje
0128O acesso ao Docker no runner vai para trás de uma PORTA com exatamente cinco operações (start/stop/remove/inspect/exec), e a implementação foi escolhida por uma PROVA que reprovou. O dockerode foi instalado, importado, instanciado e exercitado (ping()) por uma flag --self-test-docker rodada contra os artefatos reais — nunca um import que um bundler pudesse eliminar por tree-shaking. O tsup passou (ele deixa dependencies como require externo, então o dockerode nunca entrou no bundle); o bun build --compile (ADR 0112, o binário standalone) REPROVOU, com o erro registrado literalmente: Could not resolve: "../build/Release/cpufeatures.node". A cadeia é obrigatória, não acidental — docker-modem/lib/modem.js faz require de ./ssh no topo do módulo, ssh2 faz require de cpu-features, e esse binding nativo opcional está embrulhado num try/catch de runtime que o BUNDLER não tem. Mesma classe de problema que o ADR 0112 documentou para o node-pty, com a diferença que decide: lá o binding é ESSENCIAL, aqui ele acelera um transporte SSH que este runner nunca usa (ele fala com o socket unix local). Medido e NÃO adotado, para ninguém refazer a investigação: --external cpu-features compila (280 módulos contra 79, +1,7 MB por binário × 5 plataformas) e o dockerode funciona dentro do binário — é exatamente o contorno criativo que o dono do produto excluiu de antemão. Então o runner usa execFile('docker', …), o dockerode sai do lockfile, e o BROKER (PR 1.2) não herda a decisão: ele é entregue numa imagem que nós construímos e nunca vira binário standalone. A contenção é o TIPO, não a disciplina do chamador: não há campo para privileged/cap_add, RedeDoContainer é `'none'
0130Nasce o BROKER de container — um serviço Node/TS novo (apps/broker), o único processo do produto que fala com um daemon Docker no SERVIDOR, e a api nunca recebe o socket. Três decisões inseparáveis. Primeira, a porta MUDA de lugar: docker-port.ts e docker-cli.ts (e os dois specs) saem de apps/runner/src/ para um pacote de workspace novo, packages/docker-port, consumido por runner e broker — exatamente o que o ADR 0128 escreveu que faria, nunca copiado, porque "um segundo arquivo com as mesmas cinco operações e uma sexta 'só do broker' é o começo do fim da contenção". packages/shared foi recusado pelo invariante que declara e que packages-shared-so-tipos.spec.ts mantém honesto (100% tipo, e a imagem de produção da api morre com ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING se um valor de runtime sobreviver ao tsc); o pacote novo NÃO tem passo de build e é EMPACOTADO pelos dois consumidores (tsup, bun build --compile), e é por isso que a api não pode consumi-lo e que validarDecisaoDeImagem NÃO muda de lugar. O binário standalone sobreviveu à mudança, medido: build, build:bin, smoke e smoke:bin verdes, bundle do Bun de 79 → 83 módulos, npm pack --dry-run inalterado. Segunda, e esse é o propósito do serviço: o broker não aceita spec de container. Ele recebe um projectId mais uma das cinco operações, LÊ a decisão do Arquiteto na própria api (GET /internal/projects/:projectId/container-spec, RN-485) e COMPÕE imagem, rede, recursos e a montagem única — não há campo onde escrever privileged, cap_add, network: host ou um -v livre, porque não há campo. Ele revalida o que a api devolveu, já que contenção que depende de o chamador estar certo não é contenção, e as duas validações respondem perguntas diferentes (a da api é sobre o ARTEFATO ser revisável; esta é o parse de JSON não confiável para o tipo fechado). Uma checagem existe só aqui e é nova: uma referência de imagem que começa com - seria lida pelo CLI como FLAG — execFile sem shell resolve injeção de comando, não injeção de ARGUMENTO. Terceira, a api não manda caminho NENHUM: o -v é resolvido pelo daemon contra o sistema de arquivos do HOST, então um caminho de dentro do container da api montaria em silêncio uma pasta vazia; o broker junta workspaceDirName (RN-109) com o próprio PROJECT_WORKSPACES_HOST_ROOT e recusa o start quando ele falta em vez de adivinhar. pidsLimit entra na porta por um motivo que vale nomear — o artefato sempre levou três números, e largar um faria ele prometer um teto que o container não recebe. Contido por cinco camadas independentes: nenhuma porta publicada, uma rede internal: true na qual só a api entra, o service token compartilhado comparado em tempo constante, cinco operações e a spec calculada. Ele é entregue sob profiles: ["container-broker"] nos dois composes e por isso fica DESLIGADO por padrão, e a imagem dele NÃO é publicada no GHCR — docker-bake.hcl e ALVOS mantêm as quatro imagens do ADR 0119. A api ganha uma porta e um cliente HTTP cujo único CHAMADOR é o inspect: a rota de ciclo de vida do container passa a devolver o estado OBSERVADO ao lado do REGISTRADO, nunca fundidos, com naoObservado nomeando o motivo quando não houve como olhar (RN-486) — então a tabela agora pode mentir, e a leitura diz isso. Nada sobe container: nenhum laço, nenhuma fila, nenhuma proposed_action container_start, e RegistrarTransicaoDeContainerUseCase fica byte a byte igual, guarda por modo incluída
0131O Arquiteto ganha uma sétima ferramenta, route_modules_to_infra: um item {modulo, imagemCandidata, porque} por módulo do module_map VIGENTE, gravado como artifact.module_routing — sem tabela, mesmo desenho de artifact.module_map/artifact.project_image/artifact.c4_diagram, o evento É o artefato. O Arquiteto só CANDIDATA; eleger entre as candidatas (ou recusar todas) é trabalho da Infra, num PR posterior — este só produz a lista auditável. A imagem de cada item passa pela MESMA regra que choose_project_image já aplica (validarDecisaoDeImagem: tag/digest explícito, latest recusado, um rationale de verdade), reusada em vez de reimplementada por item; o domínio novo (module-routing.ts) só acrescenta o que é próprio da LISTA — vazia é recusada (não é decisão), módulo repetido é recusado (ambíguo: qual das duas imagens vence?), e cada modulo tem de nomear um que exista no module_map vigente, recusado nomeando os VÁLIDOS, mesma regra de assign_story_modules. :direct, nunca proposed_action: rotear não tem efeito externo. build_kickoff/1 — a única fonte de ORDEM de ferramentas do Arquiteto — lista a ferramenta nova logo depois de create_module_map
0133O ADR 0131 deixou meia frase pendurada — "o Arquiteto só candidata, a Infra elege, num PR separado" — e este é esse PR, ligado à outra frase pendurada, no próprio comentário da porta do broker (ADR 0130): "subir é efeito externo e não acontece sem uma proposed_action; a Infra a propõe, com a autoridade final do usuário — outro PR." A junção das duas expõe uma pergunta que nenhum dos dois ADRs respondeu: o broker não recebe spec, ele lê GET .../container-spec, que lê artifact.project_image (RN-105) — não artifact.module_routing. Então uma eleição que não vira nova versão de artifact.project_image é auditável e INERTE — o container que sobe é o que o choose_project_image disse, sem relação com o que a Infra acabou de eleger. Decisão: a eleição REUSA DecidirImagemDoProjetoUseCase inteiro, chamado agora com decidedBy: 'infra-lead' em vez do padrão 'arquiteto' — mesma validação (validarDecisaoDeImagem, mesmo teto de recursos), mesmo artefato "vigente" único, mesmo precedente que o próprio comentário de module-routing.ts já tinha fixado para reusar em vez de reimplementar. A imagem eleita TEM de ser uma das imagemCandidata do roteamento — nunca inventada —, checada antes de tocar o artefato, recusada pelo nome com as candidatas válidas listadas quando não é. A proposed_action nova container_start (maintainer, deliberadamente fora do bloco de teto absoluto em decide.ts, mesmo raciocínio já registrado ali para propose_execution_plan/assess_implementability — primeira eleição, não aumento de teto) é o PRIMEIRO chamador real de ContainerBrokerPort.start — das cinco operações que os ADRs 0128/0130 declararam, só o inspect tinha um antes. RegistrarTransicaoDeContainerUseCase também ganha o primeiro chamador fora de teste, transicionando provisioning → running (ou direto para running a partir de stopped, já que a máquina de estados do ADR 0081 recusa stopped → provisioning) só depois que o broker confirma. Os dev agents ainda não rodam dentro do container que sobe — isso é um PR posterior
0134Fecha o que o ADR 0133 deixou declarado: "os dev agents ainda não rodam dentro do container que sobe — isso é um PR posterior." Engine.Actions.TerminalExecutor.decisao_de_execucao/1 ganha um QUINTO desfecho, :executar_no_container — execution_mode: container com uma linha de project_containers REGISTRADA running (Engine.Containers.ProjectContainerLifecycle.running?/1, um schema Ecto novo somente leitura no padrão de Engine.Projects.Project) — e o comando de terminal atravessa engine → api (POST internal/projects/:projectId/container-exec, rota nova em InternalProjectsController, não no InternalContainersController voltado ao broker, já que o chamador aqui é o engine e nada é ESCRITO na tabela de ciclo de vida) → broker (ContainerBrokerPort.exec, a última das cinco operações dos ADRs 0128/0130 a ganhar chamador real), rodando por docker exec em vez de System.cmd dentro do processo do engine. O cwd, quando presente, é traduzido do caminho do HOST (dentro de PROJECT_WORKSPACES_ROOT) para /work pelo engine antes da chamada — verificado no código, não presumido: o raizDoProjeto do broker (PROJECT_WORKSPACES_HOST_ROOT + workspaceDirName) e o PROJECT_WORKSPACES_ROOT + workspace_dir_name do engine resolvem o MESMO diretório físico, então Workspace.ensure!/4 precisou de zero mudança — o container é só um segundo observador do worktree que o dev agent já tinha. Uma linha running é sempre só o estado REGISTRADO (RN-486: registrado e observado nunca se fundem); um container morto ou removido por fora transforma a chamada ao broker num failed_result comum — nunca um crash, nunca uma queda silenciosa de volta para fora do container, que reabriria a lacuna de isolamento que este PR fecha. Ações de terminal no escopo do container real do projeto ganham também um PISO de auto-aprovação — containerExecutionActive em DecideContext, posto por ProposeActionUseCase só para terminal em execution_mode: container — que muda o valor INICIAL de decide() de require_approval para auto_approve; é piso, não teto novo: agent_autonomy/permissions.json ainda podem baixá-lo exatamente como já baixam o padrão antigo, e os cinco tetos absolutos (escopo de caminho, git de efeito externo/comando privilegiado, merge em branch protegida, instruction_patch, paralelismo) seguem rodando por cima, sem mudança. O argumento de segurança é uma segunda fronteira, mais forte, não a ausência da primeira: decide() nunca vê um caminho /work (essa tradução acontece depois, no engine), então o teto léxico de escopo de caminho continua operando sobre os mesmos caminhos de host de sempre, redundante com — não substituído por — o namespace de montagem do Docker e a revalidação de /work do próprio broker
0135Executa a decisão nº 5 do plano de "execução real em container", já aceita antes de este PR existir: o portão de imagem da RN-105 — 409 na aba Código até alguém decidir artifact.project_image — deixa de ser dispensado para mounted/runner (ADR 0072/RN-169, revisado pelo ADR 0104/RN-421). ReadProjectCodeUseCase.portaoDoContainer perde o retorno antecipado executionMode !== 'container'; o portão vira incondicional nos três modos, e o ProjectCodeTab (web) para de tratar mounted/runner como caso especial — mesmo useQuery, mesmos quatro estados, ramo modoLocal removido, o que SIMPLIFICA o componente em vez de acrescentar um caso. Custo aceito, dito com todas as letras: todo projeto mounted/runner EXISTENTE sem imagem decidida — inclusive os projetos de dogfooding deste repositório, exp001/exp002 — perde o acesso à aba Código no instante em que isto entra, até o Arquiteto (ou a eleição da Infra, ADR 0133) decidir uma; isso é ação do operador exigida DEPOIS do deploy, então a branch é breaking/, não bugfix/, mesmo a mudança parecendo correção, e o salto de versão para MAJOR é mecânico, não manual. RegistrarTransicaoDeContainerUseCase também perde a recusa 400 por modo — project_containers pode registrar linha para os três modos agora —, mas foi investigado e confirmado inalterado: chegar a running de verdade continua impossível para mounted/runner, imposto num lugar só, o ModoDeExecucaoNaoSuportadoError de ContainerBrokerPort.start() (o broker roda no SERVIDOR e não enxerga a pasta do usuário), que ExecuteContainerStartUseCase já transforma num container.start_failed comum — nunca um crash, nunca uma queda silenciosa — ANTES de qualquer transição de ciclo de vida ser sequer tentada, então nenhuma linha nasce para esses modos pelo caminho real de aprovação apesar de o 400 ter saído. Nenhuma segunda checagem de modo foi acrescentada na camada de api/proposta de propósito: duplicar o portão que seria do broker cria um segundo lugar para divergir do primeiro, exatamente a lição que o próprio comentário do broker já registrava apontando para este ADR. Também investigado, e deixado declarado em vez de corrigido aqui (fora do escopo de API/domínio deste PR): nem propose_container_start (a ferramenta do Infra Lead) nem GetInfraContextUseCase restringem por executionMode hoje, então o Infra Lead ainda pode propor container_start para um projeto mounted/runner — lacuna preexistente, gasta um ciclo de aprovação, falha alto e nomeada em vez de calada
0136A página global de containers (/containers, entre projetos) fecha a outra lacuna declarada do comentário da porta do ADR 0130 — "stop/remove ainda não têm chamador" — com duas decisões que o plano não especificou. Primeira: a página lê o estado REGISTRADO em três consultas constantes (ContainersOverviewRepository, mesma forma sem N+1 de ProjectsSummaryRepository), mas perguntar ao broker o estado OBSERVADO é uma chamada de rede por projeto — então só linhas provisioning/running são elegíveis, com teto de TETO_DE_VERIFICACOES_POR_CARGA (20) por carga, em paralelo; o que cai fora de qualquer das regras leva o PRÓPRIO motivo naoVerificado, nunca confundido com o naoObservado do broker (a disciplina da RN-468/486 estendida a uma tela nova). Segunda: container_stop ganha exatamente o calibre de container_start (maintainer, auto-aprovação configurável, nunca semeada); container_remove — o único dos três que descarta o container e força reprovisionar tudo — entra no MESMO teto absoluto de git de efeito externo/comandos privilegiados (RN-418): nunca auto-aprovável, "sempre permitir" recusado na fonte. ContainerBrokerPort.remove já era docker rm --force (remove até running, numa chamada); em vez de alargar a máquina de estados com um atalho running -> removed, ExecuteContainerRemoveUseCase registra os dois saltos reais (stopped, depois removed). "Subir de novo" reusa container_start literalmente — zero mudança no backend — montando o payload a partir da decisão de imagem vigente do projeto em vez de um quarto tipo de ação
0137Fecha a outra metade que o ADR 0135 deixou declarada: mounted/runner agora exigem imagem decidida (portão da RN-105, nos três modos) mas ainda não tinham como subir um container — o broker do servidor recusa esses dois modos de saída, porque o código deles mora numa pasta que só a máquina do USUÁRIO enxerga. container_start/container_stop/container_remove ganham um SEGUNDO caminho de execução, ramificado por executionMode só na hora da EXECUÇÃO (nunca na proposta nem na aprovação, as duas continuam agnósticas de modo): container continua passando pelo broker sem mudança; mounted/runner agora pedem ao ENGINE que repasse ao RUNNER conectado, por três pares de eventos novos no mesmo canal Phoenix — container_start/_result, container_stop/_result, container_remove/_result — replicando exatamente o padrão exec/exec_result em vez de inventar um novo. O runner responde com DockerViaCli (@brabo/docker-port), a MESMA porta que o broker usa no servidor, provada até ali só por --self-test-docker e agora com chamador real; a imagem que ele sobe é LIDA da decisão vigente (ObterSpecDeContainerUseCase, o mesmo caso de uso que a rota de spec do broker já chama, invocado direto já que os dois rodam no processo da api), nunca reeleita. Engine.Actions.TerminalExecutor NÃO ganhou desfecho novo: ele já roteava todo comando de um projeto runner conectado para o canal incondicionalmente (RN-423) — host-ou-container é uma decisão que o RUNNER toma internamente (EstadoDoRunner.containerAtivo, tratarExec trocando para docker exec com o cwd traduzido por troca de prefixo, cwdParaContainer). A contenção em $HOME do guard.ts (RN-434) precisou de zero código novo para cobrir a montagem nova — verificado lendo o arquivo inteiro: o bind É o estado.dir, a mesma raiz já validada na partida do CLI, antes de qualquer canal conectar. "Nenhum runner conectado"/timeout (RunnerNaoConectadoError) e "o runner tentou e recusou" (RunnerRecusouContainerError) são ambos desfechos failed comuns, nunca exceção não tratada — mesma disciplina de BrokerIndisponivelError/BrokerRecusouError
0143Uma execução real do exp001 teve dez tarefas de dev travando de uma vez, e o achado não foi a pasta específica que falhou — foi que nada ordenava container antes de dev agent: nenhum container_start chegou a ser proposto, e dez dev agents começaram mesmo assim. Então Engine.Dev.AgentIo.try_claim/2 — o ÚNICO ponto de claim — passa a perguntar ProjectContainerLifecycle.running?/1 (o predicado que o ADR 0134 já introduziu, sem maquinaria nova) ANTES de chamar a api, e sem uma linha running REGISTRADA o agente cai para :idle, persiste e emite dev.blocked_by_container. No ENGINE e não só no portão activate-execution da api, porque o claim tem um caminho que nenhuma rota cobre: a REIDRATAÇÃO não faz cast de :work — DevAgentServer.init/1 -> finish_restart_recovery/1 -> try_claim/2 faz o claim sozinho, então um portão só de HTTP deixaria todo agente reidratado voltar a trabalhar sem container, exatamente na situação em que ninguém está olhando. :idle e não um status novo, pelo motivo que o próprio docblock de try_claim/2 registrara lá na Fase 12b: é o único estado que um wake ainda resgata, e toda guarda de handle_info/2 é chaveada nele — um :blocked_by_container seria um estado que nada resgata. O que distingue "a fila esvaziou" de "não há container" é o EVENTO, nunca o status. O despertar é por outbox: RegistrarTransicaoDeContainerUseCase acrescenta container.running na MESMA transação que grava a chegada, sob um agregado container (o terceiro que Engine.Outbox.Drain lê, ao lado de session/task) em vez de pegar task emprestado para poupar uma linha de consulta, com o PROJETO como aggregateId; a mensagem entregue é a {:wake, :became_claimable} EXISTENTE, cuja semântica já é "pode haver trabalho agora" — uma nova precisaria de uma cláusula handle_info/2 nova nos dois servidores com guarda idêntica e nenhuma decisão diferente a tomar. Segunda metade, a mesma regra vista do outro lado: TerminalExecutor.decisao_de_execucao/1 para de degradar em silêncio — container sem container rodando caía no System.cmd DENTRO do processo do engine, então o isolamento do ADR 0134 só valia no caminho feliz. Agora ele recusa (:recusar_container_ausente), espelhando as duas recusas que o modo runner já tinha, com mounted no mesmo ramo, e o catch-all encolhe para o que sempre deveria ter coberto sozinho: projeto inexistente ou id malformado. Custo declarado, e o motivo de isto só poder entrar DEPOIS de mounted ganhar container: um projeto sem um para de funcionar — alto, que é o objetivo
0144Um projeto no modo Montado não podia ter container nenhum, bloqueado duas vezes: ExecuteContainerStartUseCase mandava todo modo que não fosse container para o RUNNER (que exige um brabo-runner conectado), e o broker recusava na fonte todo modo que não fosse container. As duas recusas eram sobre GEOMETRIA, e não sobre o nome do modo — a pasta de um projeto montado ficava em algum lugar arbitrário do disco do operador que o daemon Docker do servidor não tinha por que ver — e o ADR 0141 mudou exatamente essa geometria ao pôr todo projeto montado sob UMA base montada por identidade. Então o broker ganha uma SEGUNDA raiz, BRABO_PROJECTS_HOST_BASE, e a api passa a mandar um localizador DISCRIMINADO que diz a qual raiz um segmento pertence: gerenciada (a pasta gerenciada, nomeada por workspace_dir_name), montada (o caminho RELATIVO sob a base) ou indisponivel (nenhuma raiz daqui o alcança, com o motivo). O invariante do ADR 0130 fica intocado e é a razão da forma: o que atravessa o fio continua sendo só a metade que a raiz do broker não cobre, a api nunca sabe onde essas raízes estão no host, e o broker nunca sabe nada do projeto — nenhum dos lados sozinho consegue escrever um caminho arbitrário, e mandar um absoluto devolveria às mãos do chamador a contenção de um processo equivalente a root. Três variantes e não duas porque indisponivel tem duas correções DIFERENTES — um projeto runner (correção: o runner, do outro lado) e um projeto mounted LEGADO criado fora da base (correção: mover a pasta) — e um null mandaria quem opera para o lugar errado metade das vezes; uma pasta que É a base cai ali também, já que <root>/ montaria todo projeto montado dentro do container de um projeto. Nenhuma raiz substitui a outra: a que falta é nomeada na recusa (BaseDeProjetosNaoConfiguradaError, espelhando RaizDeWorkspacesNaoConfiguradaError), porque a raiz gerenciada é nomeada por workspace_dir_name e a base é nomeada pelo usuário, então cair de uma na outra montaria o código de outra pessoa sem nada indicando por quê. Três barreiras ficam sobre a concatenação, não uma — a api recusa o que está fora da base, packages/docker-port recusa um segmento que não é relativo (.., absoluto, vazio, NUL), e raizDeProjetoValidada ainda roda sobre root + segment antes de virar um -v. Do lado da api a ramificação vira de DESTINO: container e mounted vão para o broker, só runner para o runner, e container_stop/container_remove mudam junto com container_start necessariamente — subir um container no servidor e depois pedir à máquina do usuário que o pare deixaria de pé, sem forma de parar, o que está de pé. E mounted ELEGE a imagem como container em vez de ler a vigente como o caminho do runner: o broker compõe a partir de artifact.project_image, que ele BUSCA na api, então uma eleição não gravada nesse artefato seria inerte — o argumento do ADR 0133 aplicado a um segundo modo
0180O container do projeto roda como o DONO DA PASTA (--user uid:gid, medido pela api, revalidado pelo broker, nunca vindo de corpo de requisição), para que os dev agents possam escrever em /work sob --cap-drop ALL (AT-247). Acrescenta um campo à spec, não abre porta de contenção nenhuma
0190A Infra sobe o container SOZINHA quando o handoff a ela é aceito (AT-260, decisão do dono de 01/10), revisando o "nunca semeado" do ADR 0133 sem editá-lo: o aceite semeia container_start: auto_approve para o agente de Infra, e o SERVIDOR do Infra Lead — não o modelo, não uma ferramenta nova — elege a candidata do roteamento que cobre mais módulos (a primeira no empate) e propõe container_start antes da primeira chamada ao modelo, pelo MESMO caminho da ferramenta, com as recusas por modo/estado (RN-566/610) e o 409 sem broker intactos. runner mantém container_start_via_runner com aprovação, container_remove continua no teto absoluto, e o fechamento da RN-668 continua verdadeiro. Mesma decisão do dono (AT-262): o Dev Lead é oferecido PELA INFRA, só com o container do projeto running — o handoff duplo do Arquiteto sai, e o "o Dev Lead vem do Arquiteto" dos ADRs 0053/0085 muda sem ser editado
0199Os recursos MÍNIMOS do container são DERIVADOS do module_map (AT-261, decisão do dono de 01/10): cada módulo pode declarar resources (cpus, memoryMb, pidsLimit — os três ou nenhum), e o container_start com recursos omitidos — sempre o caso da subida pelo servidor no aceite do handoff (ADR 0190) — sobe com a SOMA dos módulos, porque todos compartilham UM container. Módulo sem declaração põe o piso do container no padrão de hoje em vez de inventar um número; mapa sem declaração nenhuma dá exatamente o padrão de hoje. Soma acima do teto é 400 para o Arquiteto quando o mapa é criado; pedido abaixo do mínimo é um failed nomeado
0208A Infra HERDA a rede que o Arquiteto declarou em artifact.project_image (AT-392, decisão do dono de 02/10): a subida pelo servidor no aceite do handoff e a ferramenta propose_container_start sem network usam o egress/none do Arquiteto em vez de um none literal, e continuam subindo sem clique; revisa o ponto 3 do ADR 0190, se afasta do §4 do ADR 0065 e declara o preço (o egress alcança as portas publicadas do host, ADR 0189).
0228O container do projeto sobe o app pelo comando declarado pela Infra (supervisor constante, comando como argumento) e publica porta SÓ no loopback, com DNS e extra_hosts validados; revisa o 0130 e o 0213 sem editá-los
0229O QA testa numa instância efêmera (brabo-qa-<workspace>-<chave>, sem --restart, rótulo brabo.papel=qa) com os serviços que a Infra declarou, por digest, no mesmo namespace de rede; compartilhado vive em rede privada do projeto

O runner local​

O CLI na máquina do usuário — o canal, as credenciais, as chaves de dispositivo, a distribuição, a base e o serviço.

#decisão
0103apps/runner (workspace novo, Node/TS): CLI que roda na máquina do usuário, conecta ao engine via canal Phoenix novo (/runner, ticket de uso único EMITIDO PELO ENGINE) pra executar comando de agente JÁ APROVADO no $SHELL do usuário, mais terminal PTY interativo pra aba Code (engine faz relay puro dos bytes). Roteamento sempre DEPOIS da aprovação — nunca um segundo caminho de execução. Sem runner conectado, cai no System.cmd de sempre (ADR 0072 vira fallback). A fronteira de segurança NÃO é sandboxing: é autenticação + pipeline de aprovação + consentimento do usuário — guard.ts é best-effort, declarado. Achado real: sem mecanismo de token de conta de longa duração no produto, o runner replica o login do browser até um token de automação de verdade existir
0105Personal Access Token (brb_…) fecha o item de backlog do ADR 0104: apps/runner/src/auth.ts troca login replicado (senha+cookie em disco) por um token de longa duração, escopado a um projeto. Tabela nova personal_access_tokens, hash HMAC-SHA256+pepper via hashDeToken() (correção do backlog, que dizia argon2 — errado pra segredo de ALTA entropia). Decisão central: o PAT nunca autentica fora de POST .../runner-ticket, por CONSTRUÇÃO — IS_PAT_ROUTE_KEY/@RequirePatAuth() (terceiro branch em JwtAuthGuard, mesmo padrão de IS_SERVICE_ROUTE_KEY) + PatAuthGuard de rota, nunca um branch no guard global que deixaria RolesGuard autorizar o PAT pra qualquer rota do papel do usuário. last_used_at é UPDATE incondicional na mesma query de validação — sem throttle, que rejeitaria com 401 uma reconexão legítima em menos de 5 minutos
0106Fecha o último item do backlog do ADR 0104: tsup empacota apps/runner num dist/index.cjs único (node-pty como external obrigatório — binding nativo, não pode ser embutido), publicado como @brabo/runner por um workflow PRÓPRIO disparado pela mesma tag final de release.yml. Achado real testado empiricamente: a correção óbvia da guarda de auto-run (import.meta.url === pathToFileURL(argv[1]).href) estava QUEBRADA no caso que a onda existe pra habilitar — process.argv[1] nunca é resolvido por realpath, import.meta.url sempre é, e npm install -g cria exatamente essa assimetria via symlink; a correção final aplica realpathSync em argv[1] antes de comparar. Versão injetada só no checkout descartável de CI (npm pkg set), nunca commitada. Mitiga o latest do npm retroceder ao republicar uma das seis tags órfãs já existentes no repo — publica com dist-tag própria quando a versão-alvo é anterior ao latest atual. breaking/ — ação de operador (criar o Automation Token do npm) exigida antes da publicação funcionar de ponta a ponta
0112Binário standalone do @brabo/runner via bun build --compile — companheiro do ADR 0106, fechando o item de backlog do ADR 0104 explicitamente adiado lá. O node-pty resolve o próprio addon nativo .node por um caminho CALCULADO em runtime, que o Bun não consegue embutir estaticamente (provado empiricamente: o build ingênuo compila limpo e lança em runtime); a correção embute todo arquivo de que o node-pty precisa via with { type: 'file' } (o único mecanismo do Bun que aceita um especificador literal) e os reextrai para um diretório temporário REAL na partida, preservando o layout relativo original para que os próprios require() relativos do node-pty resolvam sem modificação. Dois bugs achados só rodando o binário compilado: --external node-pty é obrigatório no próprio comando bun build (senão o Bun empacota em silêncio o await import('node-pty') do fallback não compilado, lançando um erro enganoso em runtime em vez de reprovar o build), e a guarda de auto-execução do ADR 0106 precisou de um segundo ramo para process.argv[1] ser um caminho virtual /$bunfs/root/... dentro de um binário compilado. Matriz de cinco alvos (linux-x64/linux-arm64/darwin-x64/darwin-arm64/win32-x64), cada um construído no próprio runner nativo; só o linux-x64 foi validado por execução real nesta investigação — os outros quatro, o Windows em especial, são raciocinados pelo código-fonte do node-pty, e a primeira execução real deles é a próxima tag de versão
0118Configuração do runner pelo navegador: um par de chaves Ed25519 gerado no navegador (Web Crypto) é registrado como uma linha nova de runner_device_keys, e POST .../runner-ticket aceita o JWT EdDSA autoassinado e de vida curta (≤60s) dela como SEGUNDA credencial ao lado do PAT — aditiva, nunca substituta, e explicitamente não a autenticação dupla com JWT de sessão que a RN-439 já proíbe. Uma rota @Public() nova faz proxy do binário já publicado nas GitHub Releases (cache em memória de 5 minutos da resolução do asset, nunca dos bytes) em vez de construir ou guardar uma segunda cópia. A File System Access API escreve a pasta configurada direto no Chromium (binário + config + chave privada do dispositivo, três arquivos, um clique); em qualquer outro lugar, dois downloads simples são o plano B. Limitação aceita: o navegador não consegue pôr o bit de execução do Unix, então o chmod +x continua manual. --project/--dir/--token viram opcionais no CLI quando há config local. Rejeitado explicitamente na mesma sessão: o runner descobrir sozinho um container Docker/Kubernetes local e conectar por SSH — reabriria a Fase 25b, cortada de propósito
0145O runner roteava todo comando de terminal aprovado para o CLI conectado assim que o workspace estava verificado e o runner conectado — sem nunca checar um container running REGISTRADO, o mesmo predicado que container/mounted já exigiam desde o ADR 0143. O runner decidia host-ou-container internamente (ADR 0137), e sem container de pé caía no HOST puro, em silêncio, exatamente a queda que o ADR 0143 tinha fechado para os outros dois modos. Um segundo defeito, declarado aberto desde a RN-478: Workspace.ensure!/4 tentava File.mkdir_p!/git init LOCAIS no caminho só-do-HOST de um projeto runner, para o qual o processo do engine nunca teve bind-mount — a correção anterior só melhorou a MENSAGEM da falha, o dev agent continuava sem onde trabalhar. Então as três pré-condições (verificado, conectado, container running) vão para Engine.Runners.RunnerReadiness, compartilhado por TerminalExecutor e pela materialização do worktree, que agora roda na máquina REAL do usuário pelo MESMO canal exec/exec_result que já roda terminal aprovado (Engine.Actions.Workspace.RunnerGit, novo) em vez de dentro do processo do engine — as quatro operações de Engine.Dev.WorktreeManager ramificam do mesmo jeito, e o job periódico de limpeza (Engine.Dev.WorktreeCleanup) PULA um projeto runner que não está pronto agora em vez de quebrar ou tratá-lo como órfão. O protocolo exec ganha um campo env opcional (RN-507) para que a credencial do git fetch (ADR 0056) possa viajar no ambiente do processo filho na máquina do usuário — mesclada sobre process.env, nunca substituindo, nunca mandada ao docker exec (sem suporte a env em packages/docker-port, por desenho) e nunca logada. Terceiro achado: container_start também servia runner, mas o payload dele (imagem/network/resources/rationale — a Infra ELEGE uma candidata) nunca se aplicou ali — não há contra o que eleger, já que o broker nunca alcança a pasta de um projeto runner. Então container_start vira exclusivo de container/mounted (os dois pelo broker, RN-503), e uma ação nova, container_start_via_runner (schema: só rationale opcional), cobre o runner — a ferramenta dela no Infra Lead checa execution_mode e a conectividade do runner LOCALMENTE (sem HTTP) antes de propor, recusando pelo nome em vez de propor às cegas, ao contrário da lacuna preexistente, declarada e ainda aberta de propose_container_start (RN-494)
0147Escrever numa pasta que o usuário escolheu EM QUALQUER LUGAR do host tem exatamente três mecanismos possíveis — rodar api/engine nativos, manter um processo vivo no host, ou fazer o navegador escrever pela File System Access API — e só o segundo é contínuo. O brabo-runner já é esse processo, então em vez de entregar um segundo binário de "espelho" ele vira UM agente local que DECLARA CAPACIDADES (espelho, exec, pty) na conexão, com o servidor concedendo só o que o execution_mode do projeto exige: uma instalação, uma autenticação, uma revogação. Declaradas nos params do join, porque hoje o join é MUDO dos dois lados — o runner manda {} e a assinatura do canal literalmente ignora _params, com o papel vindo do kind do ticket — e concedidas por interseção, guardadas em socket.assigns como estado da CONEXÃO e nunca numa tabela, o mesmo raciocínio que mantém containerAtivo do lado do cliente e que já deixa a entrada :global do Registry morrer com o pid. Uma capacidade exigida que não foi declarada RECUSA O JOIN, que é a forma certa porque o runner já trata JoinRecusadoError como fatal sem nova tentativa: um runner incapaz não deve ficar num laço de backoff. A capacidade vai no join e não num push pós-join como workspace_confirm porque aquele descreve o WORKSPACE, que muda, enquanto a capacidade descreve o BINÁRIO, fixo durante a conexão. O espelho ganha guarda de escopo própria, irmã do guard.ts, reusando a passada dupla léxico+realpath que já pega um symlink no meio do caminho, e recusa três coisas: um symlink escapando do destino, um destino que CONTÉM o workspace e um destino DENTRO dele — os dois últimos sendo o mesmo laço com o bind do ADR 0141, em sentidos opostos. Uma direção, e NUNCA --delete: um arquivo removido no workspace fica no destino, e o acúmulo é o custo declarado. NÃO exige Docker, e ganha um predicado SEPARADO em vez de um RunnerReadiness com flag — uma função compartilhada com um parâmetro "pula o container" é exatamente como a terceira pré-condição cai por acidente para o exec depois; o espelho não precisa dela porque essa pré-condição existe para impedir um comando de cair no HOST puro (ADR 0145), e uma cópia de arquivo na máquina do usuário não tem host de onde cair. A revogação se revela cega de um jeito que o próprio docblock do código subestima: ele declara o corte como "sem visão de maintainer", mas os PATs têm cinco rotas e as chaves de dispositivo têm duas — um developer não consegue listar nem as PRÓPRIAS chaves, e não se revoga o que não se vê. Então a listagem é acrescentada, e revogar deixa de significar só "sem tickets novos": agora derruba o canal VIVO, que Registry.whereis/1 já torna alcançável. A sincronização é disparada POR EVENTO (fim de turno, commit) e nunca por fs.watch, que seria trabalho ilimitado disparado por qualquer coisa — inclusive pelas escritas do próprio espelho, o que é um laço. E a linha que dá forma a tudo isso: consentir uma configuração não é aprovar uma ação. A escrita do espelho não passa por proposed_action porque não é um agente pedindo para agir; é o sistema escrevendo num caminho que o usuário declarou. Fazê-la como comando de terminal seria o oposto — cairia no escopo de caminho do ADR 0055, viraria uma fila de aprovações de rotina e corroeria o próprio teto que dá sentido ao clique
0151O runner ganha uma BASE consentida, e as pastas passam a ser criadas pelo canal — uma fatia deliberadamente estreita, com o runner por MÁQUINA empurrado para a fase seguinte. Hoje o runner não tem base: a única raiz é o --dir, e sem ele a raiz É o cwd (index.ts:242-250); estado.dir é um caminho singular que alimenta a guarda de cwd, o pty, o cwdParaContainer e o workspace_confirm. A base só existe na api, e esse mesmo arquivo proíbe por escrito (project-workspaces-root.ts:421-438) que a regra da base entre em caminhoDeWorkspaceLocalValido, que roda em TODA leitura. Decisão: o instalador consente UMA base, guardada localmente; cada projeto é uma subpasta; a base nunca é recebida pelo fio — mesma forma do broker (ADR 0144), onde a raiz é de quem executa e só o SEGMENTO viaja. A guarda REUSA dentroDoEscopo e a recusa de laço por SEGMENTO de espelho-guard.ts:208-233 (/base-outra NÃO está dentro de /base), nunca uma quarta cópia da regra — o precedente é a RN-515. Par novo workspace_create/workspace_create_result no molde de exec/exec_result, e então o runner manda o workspace_confirm que já existe, então nenhum caminho de escrita novo nasce, o engine continua sem escrever a tabela, e uma única rota continua carimbando workspace_verified_at. Registra uma precisão de nome: workspace.verified não existe em lugar nenhum — o evento é project.workspace_verified — e workspace_confirm é UNIDIRECIONAL (terminal_channel.ex:356-373 responde {:noreply, socket}), então um par que precisa de resposta não pode ser modelado nele. A capacidade só entra no vocabulário na sessão em que o código entra (RN-514). Predicado próprio com DUAS pré-condições, adotando literalmente espelho.ex:19-24 sobre por que RunnerReadiness nunca ganha uma flag "pula container". Não é proposed_action. Declara os CINCO acoplamentos independentes que o runner por máquina precisa atravessar, um deles uma migration e um deles breaking/
0154A identidade do agente local passa a ser a da MÁQUINA, não a do projeto. O instalador já chega perto — desde a RN-531 ele baixa o binário, confere contra o checksums.txt assinado e o instala com install -m 0755, sem chmod manual — e então PARA, dizendo isso na própria última linha: ele não pareia o agente, e a chave de dispositivo ainda vem da tela do projeto. O que bloqueia não é o binário, é a IDENTIDADE: service install precisa de um projectId e de uma chave, e na hora da instalação não existe projeto — nem conta, nem workspace. A FASE 29 mediu "um runner por projeto" como imposto em cinco lugares independentes; medir de novo com arquivo:linha mostra que três deles não precisam de mudança nenhuma, e esse é o achado deste ADR. RunnerSocket.connect/3 autentica por TICKET, o ticket é pedido por projeto, e o consumo atômico acontece no join contra o project_id do tópico — então o tópico (terminal_channel.ex:204), o id do socket (runner_socket.ex:73) e o ticket descrevem uma CONEXÃO, não um processo. Um processo abrindo N conexões, uma por projeto que ele atende, satisfaz os três byte a byte e preserva a recusa de um segundo runner no mesmo projeto, que é uma garantia real e não um acaso. O que resta é o que pertence de verdade ao processo: a credencial que pede esses N tickets e a unit que o mantém vivo. Então runner_device_keys.project_id vira nulável, e NULL SIGNIFICA máquina — mesma tabela, mesmo kid dentro da JWK privada (RN-475), mesma revogação; uma tabela irmã foi considerada e recusada porque o PatAuthGuard acha a chave pública pelo kid e não precisa saber a espécie dela, e duas tabelas significariam duas buscas, a segunda esquecida exatamente uma vez. Uma chave de máquina não concede nada que o usuário dono dela já não tivesse: runner-ticket continua por projeto e a autorização continua resolvida contra o projeto pedido. O runner DESCOBRE quais projetos atende por um GET runner/projects novo em vez de varrer a base — adivinhar pelo nome da pasta é a classe de erro que o ADR 0141 recusou. A unit passa a ser uma por máquina, e as de projeto continuam funcionando, com o status DIZENDO que existem em vez de deixar seis processos disputando três projetos em silêncio. Recusa: fundir tópicos num runner:<userId>, uma chave atravessando usuários, descobrir projetos pelo disco, e qualquer exceção em decide.ts — este ADR move identidade, nunca autoridade. Declara o custo que a FASE 29 previu: revogar agora derruba o agente para todo projeto daquela máquina
0174O binário standalone do runner deixa de prometer o Mac Intel: quatro alvos em vez dos cinco do ADR 0112. Medido (AT-065): macos-13 não tem runner (imagem aposentada pelo GitHub, 24h00m01s na fila e cancelado nas três tags), e o substituto macos-15-intel agenda e constrói mas reprova o --self-test-pty porque sob o Bun o onData do node-pty nunca dispara (oven-sh/bun#25822, aberto) — a mesma prova passa sob o Node. Decidido pelo mantenedor: darwin-x64 sai da matriz e de ALVOS_ESPERADOS, o proxy GET /runner-releases/binary o recusa com 400 próprio apontando para o pacote npm, o install.sh em darwin-amd64 não baixa e manda rodar npm install -g @brabo/runner, e o navegador ainda o detecta mas pula o download. scripts/ci/alvos-do-runner.spec.ts amarra os quatro lugares que enumeram alvos. Caminho de volta: um ADR novo quando o Bun entregar a correção
0187O binário standalone do runner deixa de prometer o Windows: três alvos em vez dos quatro do ADR 0174, no mesmo molde. Medido em quatro ensaios por workflow_dispatch (AT-343, execuções 36775746724, 36779817686, 36780804339, 36781729045): o binário constrói, o uso roda e o node-pty carrega quando o caminho virtual do Windows é reconhecido, mas sob o Bun o net.Socket sobre o named pipe de saída do ConPTY termina depois do primeiro pedaço — até cmd.exe /c echo morre com 0xC000013A (família do oven-sh/bun#25822). Decidido pelo mantenedor: win32-x64 sai da matriz e de ALVOS_ESPERADOS, o proxy GET /runner-releases/binary o recusa com 400 próprio apontando para npm install -g @brabo/runner, a recusa do Windows no install.sh nomeia o caminho do npm, e o navegador ainda o detecta mas pula o download. Caminho de volta: um ADR novo quando o Bun entregar a correção
0193O git CREDENCIADO roda no HOST do runner, o código roda no container (AT-116, decisão do dono de 01/10): o git fetch autenticado do RunnerGit vai MARCADO (gitCredenciado: true, posto só por RunnerRouter.exec_git_credenciado/5) e o runner o executa no host mesmo com container ativo, onde o env chega ao processo filho — a mesma pasta, já que estado.dir é o bind-mount de /work. Nenhuma porta do ADR 0130 muda (DockerPort.exec continua sem env), RunnerReadiness fica byte a byte, e a recusa da RN-558 encolhe para env SEM a marca (na prática, um runner anterior a este ADR)
0201A revogação mira a CHAVE, não o par {project, user} (AT-013, decisão do dono de 01/10, revisando a RN-520 sobre o ADR 0154 e o ADR 0155). O ticket do socket do runner grava QUAL credencial o pediu (credential_kind/credential_id, nuláveis, migration Ecto no schema do próprio engine), o PatAuthGuard a repassa, o socket a guarda em assigns e no id dele, e POST /internal/runner/disconnect-credential anula os tickets pendentes da credencial e pergunta a TODO runner registrado se nasceu dela — então outro runner do mesmo usuário, com PAT ou outra chave, fica de pé, e uma chave de MÁQUINA é uma requisição em vez do par aplicado N vezes. A revogação de PAT (RN-426/427) agora derruba as conexões dele também. Duas peças de transição mantêm caindo o que caía: uma conexão LEGADA (ticket sem credencial) cai pelo par nos projetos que a api manda, e se o engine não conhece a rota a revogação de chave volta para disconnectRunnerOfUser. A rota do par fica para a remoção de membro (RN-615). Os diálogos de confirmação mudam só o texto (RN-561); o runner não muda
0203A configuração do runner pelo navegador do ADR 0118 é aposentada (AT-014, decisão do dono de 01/10, fecha o BRB-031): o navegador não gera mais a chave, não baixa o binário nem escreve a pasta, e o RunnerOnboardingPanel mostra no lugar o comando do install.sh — o instalador já baixa o binário verificado, o instala executável (install -m 0755, então sem chmod +x manual em lugar nenhum) e pareia a máquina pelo terminal. apps/web/src/lib/runner-bootstrap.ts e POST /projects/:projectId/runner-device-keys são removidos (sem outro chamador; BREAKING), enquanto GET/DELETE e a rota interna de chave de máquina ficam. Chaves de projeto registradas antes continuam funcionando e revogáveis. Declarado: instalações com time perdem o jeito sem PAT de parear uma segunda máquina, e GET /runner-releases/binary fica sem consumidor de produção, mantido por decisão à parte
0230O brabo-runner credentials import lê, na máquina do usuário, chaves de API de LLM já configuradas em outras ferramentas e as grava por POST /runner/llm-credentials (só chave de dispositivo de MÁQUINA; todo PAT é de projeto e fica de fora), pelo MESMO upsert com envelope (key_id). Só chave de API — login por assinatura não é importado (termos dos fornecedores); a api nunca lê o disco; credencial existente exige substituir: true (409 credencial_existente); o valor nunca vai a log nem resposta. Referencia 0050, 0154 e 0158.

Autenticação, acesso e segredos​

Autenticação first-party, papéis e seus tetos, associação ao workspace, endurecimento entre origens e envelopes de segredo.

#decisão
0031Auth no domínio da api: argon2id com parâmetros fixos, access token EdDSA de chave derivada, rotação de refresh com revogação de família por reuso, lockout com chave no e-mail (e não no id) e a invariante única que fecha a enumeração
0032O corte atômico do Keycloak: emissor próprio no guard sem tocar no RBAC, /internal/* fora do JWT com segredo compartilhado, sessão da web em cookie httpOnly com CSRF, e o usuário migrado indistinguível de um e-mail que não existe
0037O CORS que o engine não tinha (/health respondia 200 e o navegador descartava), o plug próprio em vez do Corsica com /internal e /metrics fora da allowlist, WEB_ORIGIN lido num lugar só, e strictPort porque a porta faz parte do contrato de CORS
0050Credencial sempre cifrada e gravada; verificar vira ação à parte. O portão do ADR 0004 (testar ANTES de persistir) parecia prudente e produzia o pior desfecho: campo write-only + recusa = usuário sem credencial E sem o texto para corrigir, porque a tela nunca reexibe o que ele colou. Guardar e verificar são perguntas diferentes, e amarrá-las fazia a segunda depender da primeira ter resposta agora. Vale para LLM e git juntos — mesma tabela, mesma promessa, e tratá-las diferente daria duas telas com regras distintas para o mesmo objeto. O veredito tem TRÊS estados porque o tester é NO-OP em ollama/anthropic/openai: num binário eles voltariam ok e a tela afirmaria uma verificação que ninguém fez (a regra de capability do ADR 0041 aplicada aqui). Chave ruim deixa de ser exceção HTTP e vira resultado 200, então os dois @Catch que a mapeavam saíram — filtro que não pode disparar é regra morta
0058CSP fechado na api, e o escopo de projeto contido na raiz. Revisa o item 7 do 0027, que decidira contentSecurityPolicy: false porque "a api serve JSON e o CSP é da web" — verdade que não é o ponto: a alternativa a um CSP GENÉRICO não é cabeçalho nenhum, é um CSP ESPECÍFICO, e para quem só serve JSON o específico é default-src 'none', que custa zero comportamento. Dois caminhos põem a resposta da api fora do alcance do CSP da web: navegação DIRETA a uma rota (link colado, redirect), renderizada na origem da api, e frame-ancestors, que só vale no documento emoldurado. Fora de produção o perfil afrouxa só o que o Swagger UI de /docs exige, sob a MESMA condição que monta o Swagger — se um dia ele subir em produção, o CSP acompanha em vez de barrá-lo calado. As opções saem do literal do main.ts para security-headers.ts pelo motivo que já movera o cors-origins.ts: no boot elas não eram testáveis, e a prova tem que ser uma requisição de verdade, porque false e um objeto de diretivas são os dois "configuração válida" e só diferem na resposta HTTP. Junto vem a contenção do projectId: ele chega do parâmetro de rota com o percent-encoding já decodificado pelo Express, então ..%2F..%2Fetc virava ../../etc e escapava da raiz — atingindo não só a leitura e ESCRITA do permissions.json como o escopo que AUTORIZA terminal pelo 0055, isto é, a política apontando para o lugar errado. A validação fica onde a raiz é derivada, e não em cada chamador, pelo mesmo motivo que fez a função existir: checagem duplicada é checagem que um dia diverge. Registra também as sete dispensas do CodeQL com a razão de cada uma — entre elas o js/insufficient-password-hash sobre um HMAC de state de OAuth, onde não há senha e hash lento seria erro
0059A chave que assina o state do OAuth de git não tem default. Nasceu de uma das dispensas do 0058: dispensar js/insufficient-password-hash estava certo — não há senha ali, a "senha" é GIT_OAUTH_STATE_SECRET, uma chave HMAC do servidor, e HMAC-SHA256 é a primitiva certa. O que o argumento não considerou é que uma chave de servidor com valor default conhecido não assina nada, e era o caso: os dois pontos de leitura faziam process.env.GIT_OAUTH_STATE_SECRET ?? 'dev-oauth-state-secret-change-me'. O mesmo modo de falha que o WEB_ORIGIN já tinha tido fechado em cors-origins.ts, e pior por dois motivos — o valor é PÚBLICO (está no .env.example de um repositório open source; não é um default fraco, é um segredo publicado), e o que ele protege é o state, o único obstáculo a forjar o callback de uma rota que precisa ser pública: com a chave conhecida, qualquer um assina o {projectId, userId, provider} que quiser e faz o callback gravar, no projeto desse payload, o token de git obtido do provider. O detalhe que decide a forma da correção é que o docker-compose.prod.yml fornecia o literal como fallback, então no caminho de erro REAL a variável ESTAVA definida — exigir só "não vazia" não teria pegado nada, e por isso o literal do exemplo é recusado explicitamente, junto com um piso de 16 caracteres que transforma o sempre presente "fraca = CSRF" da tabela de configuração numa checagem de fato. A checagem roda no BOOT, não no primeiro uso: uma api que só falhasse quando alguém tentasse conectar o git ficaria semanas de pé aceitando um state assinado com uma chave pública. Resolvida numa função única pelo motivo da RN-092 — havia duas cópias do mesmo literal, e divergi-las faria o callback recusar todo state legítimo. Quebra deliberada: produção sem a variável definida para de subir, o que é o objetivo e não efeito colateral. Os quatro segredos irmãos do mesmo compose (AUTH_JWT_SECRET, BRABO_SERVICE_TOKEN, CREDENTIALS_MASTER_KEY, SECRET_KEY_BASE) ficaram declaradamente de fora, no backlog — fechados depois pela mesma regra, sem ADR novo, na RN-114
0084Login social (GitHub/GitLab), revisando o backlog dos ADRs 0031/0032 — proibição revogada só para esta frente, por decisão explícita do dono do produto. SocialLoginCallbackUseCase termina chamando o MESMO EmitirSessaoUseCase do login por senha — nenhum segundo formato de sessão. O cliente OAuth é reusado (GithubOauthClient/GitlabOauthClient ganham buildLoginAuthorizeUrl/fetchIdentity), mas o state NÃO: domain/auth/social-oauth-state.ts assina com a MESMA chave GIT_OAUTH_STATE_SECRET só porque o payload é estruturalmente incompatível — um discriminante purpose: 'social_login' checado ANTES de qualquer outro campo é o que impede um state do fluxo de CONEXÃO de git ser aceito aqui (RN-273), o que seria escalação de privilégio. Tabela nova social_identities (migração 0047), chave (provider, provider_user_id) — nunca e-mail/login, que podem trocar de dono (RN-276). O callback decide em três passos: identidade já conhecida → login; e-mail bate com conta existente E verificado pelo provider → vincula, e de quebra verifica o e-mail da conta se ainda não estava (RN-279); e-mail bate mas NÃO verificado → recusa, account takeover (RN-274); sem conta correspondente → provisiona usuário NOVO sem senha, reusando o mesmo estado "pendente" da migração do Keycloak (RN-278) — aqui o e-mail não precisa estar verificado, porque não há conta a proteger (RN-275). Reusa o MESMO app OAuth do fluxo de conexão de git — zero variável de ambiente nova —, mudando só redirect_uri e scope (mínimo, sem repo/api) por requisição; a ação do OPERADOR de cadastrar o segundo callback no provider é o que justifica o branch breaking/. O access token nunca viaja na URL: cookies de sessão gravados no callback, e o boot da web já reconstrói a sessão sozinho
0096SMTP real no MailSender, fechando o item de backlog aberto desde o 0032 ("SMTP real continua sendo config futura"). nodemailer sobre transporte SMTP puro — protocolo de LINHA com estado/MIME/STARTTLS/AUTH, diferente das APIs JSON HTTP que o resto do produto integra — atrás de MAIL_TRANSPORT (log, default inclusive em produção, ou smtp), selecionado por useFactory em AuthUseCasesModule sem tocar nenhum caso de uso. SMTP_HOST/SMTP_USER/SMTP_PASSWORD/SMTP_FROM seguem o padrão RN-114 em produção quando o modo é smtp, mas SEM o default público que as quatro RN-114 originais tinham — a régua só entra quando o operador optou por smtp, validada no construtor de SmtpMailSender (exercitado pelo useFactory na montagem do grafo de providers, mesmo desenho de CREDENTIALS_MASTER_KEY), não por chamada eager em main.ts. Corpo em texto puro, nunca HTML — a porta não carrega estrutura para corpo rico. Investigação achou uma lacuna real antes de implementar: email_verification não tinha rota web nenhuma (verificarEmail existia em apps/web/src/lib/auth.ts sem chamador) — /verificar-email (VerifyEmailPage.tsx) fecha isso, espelhando SetPasswordPage.tsx
0127ResolveEffectiveRoleUseCase.forProject continua projectRole ?? workspaceRole — a linha do projeto SOBREPÕE a do workspace nos DOIS sentidos, e restringir alguém num projeto sensível (developer no workspace → viewer no projeto) é uma capacidade deliberada que "o maior dos dois" apagaria. O que entra no lugar são DOIS tetos no caminho de escrita, recusados com 403 e configuráveis em lugar nenhum: ninguém rebaixa um owner do workspace (então um maintainer não consegue mais trancar o dono fora do próprio projeto), e ninguém rebaixa A SI MESMO (desfazer exige o próprio papel de que se abre mão). O owner é lido de workspace_members.role, nunca de workspaces.created_by — created_by é um fato histórico que não aparece em caminho de autorização nenhum, e um teto que o lesse protegeria um criador que já transferiu a propriedade deixando exposto o dono corrente. O teto 2 é enunciado SEM limiar ("você mesmo", não "abaixo de maintainer"): a versão com limiar copia um número do @RequireRole de um controller para o domínio e envelhece em silêncio se o mínimo da rota mudar; o preço declarado é que um owner se rebaixando a maintainer — reversível — também cai. Eles moram no CASO DE USO com a regra em domain/iam/tetos-de-rebaixamento.ts, não no RolesGuard, que autoriza o CHAMADOR contra a rota e não vê nem corpo (dto.role) nem alvo; a FORMA segue domain/actions/decide.ts (RN-154/RN-418) sem o desfecho dele, já que uma chamada humana síncrona não tem fila de aprovação onde cair. As três descrições de OpenAPI que a RN-471 declarou falsas ("o maior entre este e o que a pessoa já tem", "inclui quem herda acesso do workspace") são corrigidas aqui. Declarado como ainda possível: rebaixar outro maintainer, a auto-PROMOÇÃO (inalterada, não aberta aqui), o POST no nível do workspace, que não tem teto nenhum, e o auto-rebaixamento pela REMOÇÃO — RemoveProjectMemberUseCase não ganhou teto, e a premissa de que remover é sempre benigno é FALSA (é, quando o papel de workspace segura a queda; é auto-rebaixamento irreversível quando não segura). Os dois casos de remoção estão fixados em testes, o aberto inclusive. O web fica de fora de propósito e por um motivo estrutural: a tela consegue calcular o teto 2 mas NÃO o teto 1, já que listProjectMembers devolve o papel da linha do projeto e o papel de workspace do alvo está fora do alcance do cliente — meio portão seria a segunda fonte de papel contra a qual a RN-471 alerta; enquanto isso, a recusa aparece, pelo toast mensagemDaApi que o #444 acrescentou
0156O teto de auto-rebaixamento alcança a OUTRA porta: remover a própria linha de project_members. O ADR 0127 pôs teto só no caminho de escrita e deixou este movimento de fora por escrito, fixando-o num teste cujo nome documentava o buraco — "a remoção AINDA consegue rebaixar quem a chamou" — e prevendo o custo de fechá-lo (a assinatura de RemoveProjectMemberUseCase.execute). Esta é essa próxima pessoa. Remover não apaga um papel, TROCA o efetivo: projectRole ?? workspaceRole (RN-471) passa a resolver pelo segundo termo, então um maintainer cuja autoridade vinha da linha do projeto, viewer no workspace, caía para viewer sem volta — restaurar é POST :projectId/members, que exige o próprio maintainer recém-abandonado; sem papel de workspace nenhum, a queda é para acesso NENHUM. A regra é o teto 2, não um teto novo: ehAutoRebaixamento compara o efetivo de hoje com o efetivo de depois e nunca precisou saber por qual rota o segundo chegou, então remocaoEhAutoRebaixamento DELEGA a ela passando o papel de workspace como o pedido, sem reimplementar comparação nenhuma. Ela existe como função própria pelo único caso que a assinatura da outra não consegue enunciar — um ator sem papel de workspace, onde o "depois" é nada, que não é um Role. O teto 1 não ganha par, de propósito: owner é o topo do ROLE_ORDER, então remover a linha de projeto de um dono de workspace só pode ELEVAR o papel efetivo dele — é na verdade a única forma de desfazer a restrição que o teto 1 proíbe de criar, e um teto ali recusaria só movimentos benignos tornando permanente o estado que o ADR 0127 nasceu para eliminar. Enunciado sem limiar, como o teto 2, e o preço declarado vai junto: a auto-remoção de owner do projeto para maintainer do workspace é reversível e cai também — o único movimento benigno que passava e agora recusa. A mensagem é PRÓPRIA, porque quem clicou em "remover" não pediu mudança de papel e merece o mecanismo, não a frase do add. Recusa: teto de dono na remoção, uma segunda regra de comparação, tocar forProject, estender ao escopo do workspace (cujo POST ainda não tem teto e cujo @Delete de membro não existe), bloquear a remoção de OUTRAS pessoas que caem, e um portão no cliente — MembersSection.handleRemove já mostra mensagemDaApi num toast, e calcular só a metade que a tela consegue calcular é o meio portão que o ADR 0127 recusou. Fecha o BRB-001
0157O teto de auto-movimento alcança o upsert de WORKSPACE, e a auto-PROMOÇÃO — que o ADR 0127 deixou de pé por escrito — vira brecha e fecha. AddWorkspaceMemberUseCase era um repasse de doze linhas que nunca recebia o ator, numa rota que exige owner: a mesma classe de defeito de project_members, um escopo acima e pior, porque aqui não há nível acima para segurar a queda e não existe @Delete de membro nenhum (medido; o ADR 0156 tinha usado o mesmo fato para adiar isto). Um owner se gravando viewer perdia o workspace inteiro — todo projeto, o gasto de LLM que é dele pela RN-060, e a própria capacidade de convidar quem o restauraria —, e desfazer era esta mesma rota, exigindo o owner recém-abandonado. O teto não conta owners, pelo critério literal do ADR 0127 ("enunciado como cláusula, não tem número para envelhecer"): uma contagem acrescenta uma leitura que pode sair de compasso com a escrita, precisa julgar se um segundo owner que saiu da empresa ainda conta, e faz a mesma requisição com o mesmo corpo passar hoje e recusar amanhã. O argumento mais forte é que a cláusula já produz o invariante que uma contagem existiria para garantir — um workspace nunca fica com zero owners, porque remover o último exigiria que esse owner o fizesse, que é exatamente o movimento recusado. O teto 1 não ganha par aqui, e isso foi considerado e não espelhado: ao contrário da remoção (ADR 0156), o movimento EXISTE — um owner pode rebaixar outro owner. Ele é recusado porque o teto 1 é uma regra sobre inversão de hierarquia, e o @RequireRole('owner') já torna a inversão impossível neste escopo: quem pode chamar nunca está abaixo de quem toca. Acrescentá-lo, somado à rota de remoção que falta, faria de owner um estado absorvente do qual ninguém sai por HTTP — desligamento, rotação de propriedade e correção de convite errado precisariam de escrita direta no banco, que é a classe de estado que o ADR 0127 nasceu para eliminar, reconstruída com o sinal trocado. Então rebaixar outro owner FICA, reversível pela mesma rota por qualquer owner restante. A auto-promoção fecha nas duas rotas de associação. O ADR 0127 a registrara como capacidade que ficava ("os tetos são sobre descer"); reaberta aqui, a resposta é que as duas metades são um movimento só — uma pessoa decidindo sozinha que autoridade tem — e a metade de cima é a única que ESCALA privilégio, que é justamente o que aquele ADR podia afirmar que os tetos dele nunca faziam. Reescrever o MESMO papel continua passando: upsert idempotente não é movimento, e recusá-lo quebraria o seed.ts. A comparação não é copiada: vira um classificador, autoMovimentoDoProprioPapel, que devolve o SENTIDO em vez de um booleano, porque o chamador precisa dele para escolher a mensagem. ehAutoRebaixamento sobrevive como leitura desse classificador — de propósito, não por compatibilidade: remocaoEhAutoRebaixamento delega a ela, e alargá-la faria a REMOÇÃO recusar a auto-promoção também, quebrando o movimento benigno que o ADR 0156 protegeu pelo nome. Quatro mensagens, uma por sentido e por escopo — ninguém que tentou se promover lê a frase de rebaixamento, e ninguém barrado no workspace é mandado pedir a um maintainer. Recusa: contar owners, teto de dono neste escopo, criar @Delete de membro de workspace (uma porta nova precisaria deste mesmo teto mais o julgamento do último owner), alargar a porta de remoção, tocar forProject pelo terceiro ADR seguido, e um portão no cliente — não há o que construir, já que não existe chamador desta rota em apps/web. Custo declarado: um maintainer legítimo que precisa de owner agora depende de outra pessoa. Fecha o BRB-002
0158O envelope passa a dizer QUAL chave mestra o embrulhou, e esse rótulo nunca decide se ele abre. A própria página do runbook enuncia o buraco ("O wrapped_dek guardado no banco não identifica qual chave o embrulhou"), e dois defeitos decorrem dele: o passo do meio da rotação não tem progresso — quem o interrompe não sabe onde parou, e "ainda há algo na chave antiga?" não tem resposta em SQL, só rodar o script de novo pelo contador impresso — e chave errada e blob corrompido caem no mesmo catch, dois diagnósticos com ações opostas vestindo uma falha genérica. Então as duas tabelas de envelope ganham um key_id nulável, sempre juntas, já que compartilham o envelope e o script e mudar uma deixaria um acervo onde a consulta responde metade. O valor é a impressão digital da chave, HMAC-SHA256(derived key, label) truncada — que responde a pergunta aberta que a atividade deixou ("não um hash da chave, isso dá a quem lê o banco um oráculo de verificação offline") medindo em vez de presumir: o envelope é AES-256-GCM, que AUTENTICA, então testar uma passphrase candidata contra o wrapped_dek JÁ era um oráculo perfeito custando um scrypt; testá-la contra a impressão custa o mesmo scrypt. Nenhum oráculo é criado, e nenhuma variável é acrescentada — o operador não digita a impressão em lugar nenhum, ela é função pura da chave já publicada, que é o que torna a velha objeção "mais um metadado para manter em sincronia" verdadeira para um id arbitrário e falsa para uma impressão digital. O rótulo é observabilidade, nunca autoridade, e é aí que esta decisão se afasta da óbvia: decrypt fica byte a byte, e rewrap continua decidindo "já está na chave atual" por TENTATIVA. Usar o rótulo para pular a tentativa foi considerado e recusado por um modo de falha próprio — uma linha rotulada "atual" cujo envelope está na chave velha seria pulada em silêncio, ficaria fora da contagem de re-embrulhadas, e o passo 3 a tornaria ilegível para sempre, transformando uma inconsistência de metadado em perda de segredo. Então o rótulo serve a três coisas fora da decisão criptográfica: a consulta de progresso (IS DISTINCT FROM, nunca <>, para que NULL conte como pendente), o diagnóstico quando NENHUMA chave abre, e o carimbo que o rewrap grava ao lado do envelope novo. Sem preenchimento retroativo: o key_id nasce na próxima escrita, as linhas existentes ficam NULL significando "gravada antes desta coluna" e nunca "na chave atual" — com o custo declarado de que numa instalação existente a consulta só passa a significar algo depois da primeira rotação. A api imprime a impressão atual no boot, porque uma coluna que ninguém consegue comparar com nada é inútil. Recusa: KMS/HSM/chaves por tenant (o ADR 0027 continua valendo), um índice em key_id, e levar o diagnóstico para o caminho quente de leitura
0173Nasce a remoção de membro de workspace, com teto pela MESMA cláusula. DELETE /workspaces/:workspaceId/members/:userId (owner, 204) acaba com o desligamento por escrita no banco, que o ADR 0157 se recusou a abrir de passagem. O teto 2 é herdado sem regra nova — remocaoEhAutoRebaixamento com o papel de workspace como o efetivo de hoje e nenhum como o papel de depois, já que nada acima segura a queda —, então remover A SI MESMO é sempre 403, e essa mesma cláusula protege o último owner sem contar: só um owner chama a rota e ninguém se remove, então toda remoção deixa ao menos o chamador. Remover OUTRO owner é permitido; o teto 1 continua sem par. A remoção cascateia numa transação (as linhas de project_members do alvo no workspace, as chaves de dispositivo de projeto e os PATs dele ali) e depois derruba o runner vivo dele por projeto pelo caminho da RN-520. Declarado em aberto: workspaces.created_by não muda, então remover o criador deixa os agentes gastando a chave de LLM do criador (RN-058).

Providers de LLM, modelos e gasto​

O contrato de provider, o catálogo de modelos e a curadoria, os bindings de modelo, o metering e os orçamentos.

#decisão
0040O card do dashboard e a linha de resumo mostram só USD (a moeda de origem dos preços, sem conversão nenhuma envolvida) — divergência isolada a essa superfície, TokenMeter default/live continuam R$+US$; preferência de moeda por workspace com taxa manual fica registrada e adiada
0041Base OpenAI-compatível sobre node:http (não sobre o SDK, para ter timeout de INATIVIDADE), erro normalizado com code obrigatório no chunk, capabilities em duas camadas com binding de agente recusado sem tool calling nativo, e suite de contrato onde o contrato é dono das asserções e o harness do dialeto
0042Sync de catálogo por capability, com modelo descoberto entrando desativado e modelo sumido marcado em vez de apagado; dois eixos independentes de disponibilidade (curadoria do owner × realidade remota); cascata que revalida capability ao cair de nível; e preço congelado em token_usage para o custo antigo ficar reproduzível, com auditoria append-only fora do outbox
0043Os seis providers da 9b (NVIDIA NIM, Together, DeepInfra, Bitdeer, Vultr, OpenRouter) entram como config sobre a base, cada um investigado do zero contra a doc oficial — capability só declarada quando provada, com dois casos reais de decisão revertida ao vivo durante a implementação; único hook novo na base é o parseErrorFrame que só o OpenRouter (o hub) precisou
0049Curadoria de modelo por workspace: models.is_active era uma coluna para a instalação INTEIRA — um owner do workspace A ligando um modelo o ligava para o B, com o gasto caindo no orçamento de quem não decidiu. O catálogo segue global porque nome, preço e capabilities são fato do provider, e duplicá-lo por workspace criaria N verdades sobre o mesmo modelo além de partir token_usage.model_id; só a decisão migra, para workspace_models. Ausência de linha É o desligado — não há terceiro estado, e é assim que a RN-043 sobrevive sem coluna nenhuma que o sync possa atropelar; desligar é UPDATE e não DELETE, para não apagar quem decidiu junto. A rota do seletor pende do PROJETO (as três telas que a consomem já tinham projectId, nenhuma tinha workspace), e isActive sai da entidade Model: quem precisa dele usa um tipo que só existe com workspace na mão
0051Facetas de capability lidas do provider, e "para que serve" como curadoria do workspace. O sync nunca consultava o remoto para modalidade (supportsVision: local?.supportsVision ?? false), então a coluna nascia false e morria false — os 338 modelos do primeiro sync real do OpenRouter ficaram todos sem vision, incluindo os 181 que o provider declara multimodais. Entrada e saída de imagem viram eixos DISTINTOS: quem lê diagrama e quem desenha resolvem problemas diferentes. A outra metade do pedido — "melhor para código/documentação" — não existe em catálogo nenhum, e derivá-la do nome do modelo seria palpite vestido de dado (ADR 0041); então ela é opinião de quem opera e mora em workspace_models, como toda curadoria desde o ADR 0049. Os dois eixos não se misturam: marcar uso não liga o modelo (a coluna is_active tem DEFAULT true, e a linha nova nasce com false explícito), e a tela nunca escreve "não lê imagem", porque false ali quer dizer "o provider não declarou". Vídeo ficou de fora: zero modelos em nove providers, um filtro que nunca casaria nada
0063Duas audiências para o mesmo gasto: a fatura do owner e o consumo do membro. O pedido era uma aba de resumo de gasto no espírito da activity do OpenRouter, e os dados nunca foram o problema — token_usage tem provider, modelo, ator, tokens e preço CONGELADO desde o 0042; o que faltava eram as AGREGAÇÕES (não havia nenhuma por modelo, por projeto dentro do workspace, por sessão ou por pessoa). A dificuldade é a colisão entre duas regras existentes: pela RN-058 a chave que o agente gasta é a do OWNER, e pela RN-060 a fatura dessa chave é dele e só dele — então "quanto eu gastei" é, literalmente, pedir um pedaço da fatura de outra pessoa. As duas saídas fáceis estão erradas: abrir o relatório de credencial filtrado revoga a RN-060 pela porta dos fundos (a resposta ainda falaria de provider e chave, e somar as linhas reconstrói a fatura), e não mostrar nada deixa justamente a pessoa proibida de ver o consumo ser exatamente quem consome. A decisão separa as PERGUNTAS: a do owner é por CREDENCIAL (provider, chave, fatura) e continua exigindo owner, reusada inteira em vez de reescrita; a do membro é por ATOR, em tokens e custo estimado, SEM quebrar por provider ou credencial. O ator vem do token autenticado e não há parâmetro onde escrever o id de outra pessoa — "um membro não vê a linha de outro ator" vira propriedade da assinatura, não uma checagem que alguém pode esquecer; provado por teste, e a mutação que remove o filtro o mata. O eixo de provider NÃO EXISTE na agregação nova (sumGroupedBy tem model/project/actor/session/day e nada mais): a ausência é o que impede a visão do membro de recebê-lo por acidente, e pelo mesmo motivo dois providers servindo um modelo de mesmo nome caem numa linha só. Agente não entra na conta do membro porque token_usage registra quem gastou, não quem mandou gastar. Sem biblioteca de gráficos — duas formas, uma série cada, um <rect> inline resolve —, e a série diária vem DENSA da api, senão três chamadas espalhadas por três semanas viram três barras grudadas. Sem migration: medido com 525 mil linhas, as consultas saem em 55 ms e 38 ms por seq scan, e um índice em created_at as levaria a 32 ms e 19 ms — o ganho fica registrado e o índice vem depois
0064Escopo de área na cascata de modelo, e o binding de agente global. O pedido: leads de área e seu subgrupo compartilhando um modelo vigente, com o agente podendo divergir — padrão herdável, decisão já tomada pelo usuário. A cascata (sessão > agente > projeto > workspace) ganhou area entre agente e projeto, e responder ONDE levantou uma incoerência que precisou de decisão antes de codar: o binding de agent era GLOBAL (scope_id = slug puro, :projectId da rota IGNORADO de propósito desde a Fase 9a) e area é POR PROJETO desde o ADR 0053 — um escopo por projeto ACIMA de um escopo global faria o mesmo agente resolver modelos diferentes só onde existisse área, comportamento dependente de acidente de dados. Duas saídas mutuamente exclusivas: o binding de agente virar por projeto (muda comportamento existente), ou a área ficar ABAIXO do agente (contradiz "padrão herdável", porque quase todo binding hoje É de agente). Escolhida a primeira: scope_id de agent e area virou COMPOSTO — `:<slug
0075Embeddings no contrato de LLMProvider. O RAG de Chat que vem aí precisa de vetores, e o produto não sabia fazê-los: a palavra "embedding" aparecia em UM comentário, na prosa de bitdeer-provider.ts. A operação entra como embed?, opcional sob o mesmo contrato de dois lados de listModels (quem declara a capability a implementa; quem não declara não a expõe), em LOTE porque um índice recebe N chunks de uma vez — e daí vem a garantia mais importante: um vetor por entrada ou um erro, nunca uma lista mais curta, porque a ordem é o único vínculo entre entrada e vetor e uma resposta parcial é indetectável depois. O erro LANÇA normalizado por code em vez de virar chunk, com a taxonomia do 0041 intocada: o motivo do chunk é preservar o gasto de um turno em andamento, e aqui não há nada a preservar. O retorno declara dimensions conferidas contra o que VOLTOU (não copiadas do catálogo — um índice vetorial tem dimensão fixa), o model que o provider disse ter usado (um alias resolve para uma versão datada, e é isso que vai para o metering pelo motivo do preço congelado, RN-044) e inputTokens/estimated com a distinção da RN-041. A capability é em DUAS camadas como o 0041 exigiu, mas com uma diferença que precisou ser nomeada: tool calling é gradiente (modelo sem ferramenta ainda conversa) e embedding é EXCLUSÃO — nomic-embed-text não responde perguntas e llama3.2 não devolve vetores, dois conjuntos disjuntos, e é por isso que a pergunta não cabia em nenhuma coluna supports_* existente. Provider: capabilities.embeddings, OBRIGATÓRIA pelo mesmo argumento que tornou code obrigatório. Modelo: supportsEmbeddings/embeddingDimensions no catálogo, opcionais porque a ausência quer dizer "o provider não disse" — o Ollama é o único dos nove que publica isso por modelo (capabilities: ["embedding"] em /api/tags). Só o ollama declara true, provado por EXECUÇÃO contra o daemon real 0.32.1 (2 entradas → 2 vetores de 768, prompt_eval_count: 10), uma execução que também produziu o teste do 501 que um modelo de chat devolve. Os outros oito ficam false: sete por falta de prova (nenhuma chave no ambiente, e o único smoke pago que já rodou foi de CHAT — num hub, embedding roteia para um provider diferente), e a Anthropic por não ter a operação (a documentação dela manda usar um terceiro). O dialeto da base é provado à parte, com a suíte de contrato rodando uma segunda vez contra ele com a capability ligada, e é isso que torna barato ligar um provider quando a chave existir. Dois cortes declarados: o metering de embedding ainda não existe porque token_usage.session_id é NOT NULL e indexar um repositório não acontece dentro de sessão, e a coluna models.supports_embeddings esperou a vaga de migration de outra onda
0076provider volta a ser dimensão de gasto, e a contenção passa a ser do TIPO. Revisa o 0063, que excluíra o eixo com uma frase sem meio-termo ("a ausência é estrutural… não há argumento a passar") — o argumento continua correto, e é por ele que a reabertura, decidida pelo dono do produto, vem com a contenção escrita. O que o 0063 não separava e o uso pediu: o owner não conseguia ver, DENTRO da janela deslizante, por onde o dinheiro saiu (credential-spend responde por provider, mas em meses-calendário e amarrado à chave que existe hoje), e pessoa e agente vinham no mesmo ranking. sumGroupedBy ganha provider, o relatório do owner ganha porProvider (RN-186), e o membro não ganha campo nenhum. A contenção muda de FORMA, não de força: são duas sobrecargas, e o escopo que carrega actor — o único que a visão do membro tem — só aceita SpendDimensionDoAtor, que é Exclude<SpendDimension, 'provider'>; pedir provider com ator NÃO COMPILA (RN-187). Nenhum if sobre a combinação, de propósito, pelo mesmo motivo que a resolução do auto mode mora no repositório e não em decide.ts (RN-153/154): checagem dinâmica sugere garantia dinâmica, e é o que a próxima refatoração remove sem deixar teste vermelho. Exclude em vez de uma segunda lista escrita à mão, porque lista restrita paralela envelhece calada. Pessoa e agente viram porOwner/porAgente, PARTIÇÃO de porAtor no caso de uso e não duas consultas (RN-188) — o 0063 mediu que o custo cresce com o tamanho de token_usage, não com o do pedido. O índice em token_usage(created_at) que o 0063 mediu e adiou entra aqui (migração 0044): 55 ms → 32 ms e 38 ms → 19 ms a 525 mil linhas. Consequência declarada sem atenuar: a garantia passou de "impossível de expressar" para "impossível de compilar", que é mais fraca — o que segura o preço são duas barreiras independentes, e a outra é a rota do membro não ter parâmetro de dimensão nenhum, com @ts-expect-error cobrindo a primeira (se a barreira cair, o tsc reprova a diretiva NÃO USADA, em vez de o teste passar a testar nada). A TELA fica de fora, declarada
0077Ranking de modelos por capacidade, sem nota inventada. O handoff pedia uma tabela "Melhores modelos por capacidade" com uma NOTA por capacidade (código 9.4, imagem 9.1…) e um badge ideal no ModelPicker "quando o modelo cobre todas as capacidades exigidas pelo agente". A investigação achou que os dois pedem o mesmo dado que o produto não tem: a nota é FICTÍCIA do mock (nenhum provider publica "qualidade de código", nenhuma métrica do produto mede isso), e "capacidades exigidas por agente" não existe no domínio — o próprio ModelsSection já tinha renomeado a coluna "Agente · capacidades" para só "Agente" por essa razão, e prometer de novo o que já fora recusado reabriria a lacuna. Uma segunda barreira, estrutural, fecha o badge por conta própria: o ModelPicker de binding de agente lê GET /projects/:id/models (papel viewer), e a curadoria (uses, ADR 0051) só existe em ModelComCuradoria, de uma rota maintainer-only — ligar as duas seria alargar RBAC, decisão de produto à parte. O badge NÃO é construído (documentado no próprio ModelPicker.tsx); a tabela de ranking É construída, com dois sinais REAIS: "recomendado"/"alternativa" saem da curadoria (uses) que o workspace já marcou, ordenados por quantos agentes DESTE projeto já resolvem para aquele modelo pela cascata (o sinal mais honesto sem inventar nota), custo como desempate; capacidade sem cobertura curada mostra a lacuna, nunca esconde a linha
0110Fecha o item de backlog "orçamento por área" aberto desde a Fase 8. agent_areas ganha budget_micros/spent_micros, espelhando max_parallel na MESMA linha — sem tabela nova, e não a tabela genérica budgets (o CHECK dela impõe exatamente um entre projeto/sessão, construída para uma exclusão de dois lados, não para um terceiro escopo). É um TERCEIRO teto ADITIVO aos orçamentos de projeto/sessão, checado e incrementado de forma independente por CheckBudgetGateUseCase/RecordLlmUsageUseCase — explicitamente NÃO a cascata de binding de modelo do ADR 0064, com a qual os dois mecanismos só compartilham o nome. spent_micros acumula sempre que o ator pertence a uma área (resolvida pelo areaDo puro, sem ida ao banco para ator sem área), com ou sem teto definido; SetAreaBudgetUseCase exige maintainer e aceita null para limpar o teto
0114Detecção de Ollama nativo no bootstrap do pnpm dev: o preflight.mjs confirma por HTTP que quem ocupa a OLLAMA_PORT (padrão 11434, o mesmo padrão de uma instalação nativa) é mesmo um daemon Ollama, pergunta UMA vez se deve reusá-lo (com padrão sim sem TTY, já que o menu roda comandos com stdin de /dev/null de propósito), e persiste a resposta no .env (OLLAMA_MODE=host|container) via escreverEnv — a primeira coisa do repositório que ESCREVE o .env no lugar em vez de só lê-lo. ollama/ollama-model-loader vão para trás de profiles: ["local-llm"], alternados por comando por scripts/dev/perfil-ollama.sh; um serviço com profile invisível ao docker compose config por padrão forçou detectarOllamaNativo a ganhar caminho próprio de relatar conflito (o laço genérico por porta deixaria de ver um ocupante genuíno que não é Ollama) e forçou a remoção do depends_on: ollama da api (o Compose recusa o arquivo inteiro quando um serviço fora de um profile inativo depende de um de dentro). Item de menu novo "Docker › Reconfigurar Ollama" esquece a decisão, sob demanda
0115O pull de modelo do Hugging Face ganha uma tabela PEQUENA PRÓPRIA, huggingface_model_pull_requests, nunca proposed_actions — rastreado e rejeitado porque o session_id NOT NULL desse pipeline, a resolução de política decide()/agent_autonomy e o vocabulário fechado de ActionType são todos construídos em torno de um AGENTE supervisionado por um humano, e aqui o ator já É o humano, com portão de papel na rota, sem sessão e sem agente em jogo. A confirmação em dois passos É a transição pending_confirmation → confirmed, não uma flag extra — ConfirmModelPullUseCase se recusa (409) a rodá-la duas vezes, e só uma confirmação bem-sucedida chama OllamaProvider.pullModel de forma SÍNCRONA dentro da requisição (a api não tem executor de job em segundo plano próprio; GET .../pull-requests/:id existe para quem não quer segurar essa conexão aberta). O repoId viaja no corpo da requisição, não na rota, porque a forma <publisher>/<model> do Hub quebra o casamento de caminho de segmento único (a mesma correção já usada para caminhos de arquivo em code.controller.ts). Uma allowlist curada à mão, sensível a maiúsculas, de publishers oficiais do Hub (o "curadoria manual sempre" do ADR 0042, aplicado a um selo novo) filtra os resultados padrão da busca; resultados da comunidade exigem um opt-in explícito, desligado por padrão, acompanhado de um aviso de segurança persistente
0166A preferência de roteamento do hub mora no binding de modelo, viaja com ele e é congelada no metering. Medido numa instalação da v6.1.0 (AT-090): 23 de 24 chamadas de um modelo pelo OpenRouter foram servidas por um upstream umas quatro vezes mais lento, porque o corpo da requisição nunca dizia como escolher (provider.sort). Decidido pelo mantenedor: model_bindings.routing_preference (`price
0188O custo REAL que o provider devolve vira o número do metering (AT-270, decisão do dono de 30/09): quando a resposta o traz (o usage.cost do OpenRouter), ele é o token_usage.cost_micros, o que o turno devolve ao engine, o que os orçamentos somam e o que a aba Gasto mostra; o preço congelado do catálogo (ADR 0042) continua sendo o número só onde a resposta não diz nada. O preço da linha passa a ser IMPLÍCITO (cost ÷ tokens, marcado price_implicit, a mesma coluna e função do Jev do ADR 0179), o que o catálogo teria cobrado vai ao lado em catalog_cost_micros, e o modelo resolvido e o id gen-… são gravados. estimated continua significando os TOKENS; BYOK cai no catálogo. A mesma rodada grava as PARTES que o dialeto informa — cached_input_tokens e reasoning_tokens (AT-272), nunca somadas aos totais, null significando "não disse" —, e medir-execucao.ts informa quanto da entrada foi leitura de cache
0198A duplicata SEMÂNTICA de uma história ou regra de negócio é AVISADA por embedding com limiar, nunca recusada (AT-171, decisão do dono de 01/10): depois que o item é gravado, o título dele é comparado com os títulos do projeto do mesmo tipo pelo RagEmbeddingService do RAG, e um cosseno igual ou acima de 0,80 — um PONTO DE PARTIDA, não calibrado, porque o ambiente não conseguiu gravar vetores reais — devolve um aviso ao agente no resultado da ferramenta e backlog.semantic_duplicate_warned no log. Sem provider de embedding a checagem é PULADA e diz por quê. O gasto dela é medido como linha própria de token_usage (ator system/duplicata-semantica) — UMA exceção ao corte do ADR 0075, que continua valendo para a indexação e a busca. A duplicata exata continua recusada (RN-080/081)
0221O hub prende a conversa de cada agente ao upstream que a atendeu: o Brabo manda session_id = brabo:<sessão>:<agente> ao OpenRouter (capability stickySession, provada por smoke), e provider.order/allow_fallbacks seguem fora. Referencia o 0166.
0222O 9Router (gateway local do dono, fora do compose) entra como o 10º provider: URL por instalação (NINE_ROUTER_BASE_URL, extra_hosts nos composes de instalação e produção), credencial obrigatória, preço de CURADORIA com o preço público da API equivalente (decisão do dono de 22/10), capabilities false até o smoke e Token Saver desligado. Referencia o 0043.
0223O orçamento de raciocínio é capability por modelo, curada à mão: reasoning.max_tokens só para o qwen/qwen3.7-flash (provado, N = 2048); o deepseek fica de fora por medição e effort nunca é usado.
0224O raciocínio é contado no stream e abortado no limite do reasoning_budget (truncated com motivo); o gasto abortado é estimado na hora (price_implicit) e reconciliado depois pelo /generation; o deepseek ganha orçamento 2048.
0225O OpenRouter recebe um teto de preço por chamada: provider.max_price = preço de catálogo × 1,5, fixo no código e ligado por padrão (capability priceCeiling, provada por smoke), mesclado com o sort; o 404 por nenhum upstream no teto vira erro nomeado e a chamada repete UMA vez sem teto (fator nulo no metering). Referencia o 0166 e o 0221.
0227O OpenCode Zen entra como o 11º provider, só no dialeto OpenAI (/chat/completions): listModels: false (a rota de modelos é outra e pública), sem teste de conexão da credencial, seed só com os dois grátis de retenção zero (deepseek-v4.1-flash-free, glm-5.3-flash-free) e capabilities false até o smoke. Referencia o 0043 e o 0222.
0233O membro vê o gasto do PROJETO (total e custo por agente/área, GET /projects/:projectId/spend/team) e o próprio consumo por projeto (GET /workspaces/:workspaceId/spend/me), nunca provider nem credencial. Revisa o 0063; o relatório do owner ganha ?projectId= (404 entre workspaces) e a dimensão session, e a marca curada billing separa assinatura do US$ pago.

RAG, grafo de conhecimento e artefatos​

Chunks e busca híbrida, a medição dela, o grafo Neo4j e os artefatos projetados do event log.

#decisão
0079Tabela chunks para o Chat RAG: vector(768) e tsvector na MESMA linha (não duas tabelas com JOIN), índice HNSW (vector_cosine_ops, não IVFFlat — a tabela nasce vazia) e GIN, três escopos honestos (docs/adr/session) mutuamente exclusivos por CHECK. A migração 0045 carrega CREATE EXTENSION IF NOT EXISTS vector ela mesma, porque docker/postgres/init.sql só roda na primeira inicialização do volume — e por isso nasce em branch breaking/: criar a extensão pode exigir privilégio que o role da aplicação não tenha em produção
0080Busca híbrida: pesos, limiar e o que é citação. docs/adr são indexados do repositório GIT do PRÓPRIO projeto (via ReadProjectCodeUseCase, mesma credencial/portão/checagem de caminho da aba Code); session indexa só chat.message/agent.response (o resto do event log é mecanismo, não conhecimento citável). Chunking por parágrafo/heading, 1200 caracteres com 150 de sobreposição — ponto de partida documentado, não calibrado com dado real. Quando o provider de embedding (fixo: ollama/nomic-embed-text, mesma dimensão de RN-222) não responde, o pipeline grava os chunks SEM vetor em vez de falhar por completo — search_vector é GENERATED ALWAYS AS e não depende de provider nenhum — e declara a lacuna no relatório, nunca finge indexação completa. Busca híbrida é DUAS consultas independentes (searchByVector/searchByLexicalQuery, cada uma no índice feito para ela), fundidas por soma ponderada (0.6 vetorial, 0.4 léxico — as escalas de ts_rank normalizado e cosseno não são comparáveis por natureza) e cortadas num limiar (0.2); pesos e limiar são ponto de partida ajustável, não ciência. Citação é chunkId/content/scores separados (null, não zero, quando o sinal não achou)/origin como união discriminada por kind (file com sourcePath/headingPath, ou session com sessionId/eventId). Reindexação é sempre MANUAL (POST .../rag/reindex, full rebuild idempotente) — sem watcher por push nem por fechamento de sessão
0082Chat RAG vira aba PRÓPRIA (key: 'rag'), sem renomear sessions. O 0080 deixou o pipeline e as três rotas prontas; o 0078/RN-202 tinha adiado o rótulo "Chat RAG" porque a capacidade não existia — agora existe, e a pergunta era só ONDE ela mora. sessions é conversa com agente ATIVADO (gasta a chave do owner por turno, RN-058); RAG é busca sobre um índice já construído, sem agente algum no meio e read-only por natureza — fundir as duas obrigaria o mesmo fio a expressar coisas estruturalmente diferentes, então a resposta foi uma aba nova (ordem: 28, entre Código e Backlog) em vez de renomear a existente. A tela consome os três contratos do 0080 sem adivinhar forma (searchRag/getRagCoverage/reindexRag, tipos espelhados 1:1) e mostra as duas degradações honestas que o backend já declarava e mais nenhuma tela lia: vectorAvailable: false vira aviso visível (RN-252) e a cobertura nunca inventa "reindexado há Xmin" — a resposta não carrega esse dado. Citação de origem session navega até o EVENTO exato reusando useNavigate + highlightEvent, o MESMO mecanismo que os chips de evidência do Psicólogo já usam (RN-253); origem file mostra caminho/heading como texto, sem link — a aba Código não tem deep-link por caminho hoje, e construir um é fora do escopo desta frente. O botão de reindexar só aparece para owner/maintainer no cliente, espelhando a régua de maintainer que a rota já aplica (RN-238/RN-254) — quem garante continua sendo a api
0099Neo4j entra como grafo de CONHECIMENTO — memória DERIVADA do event log, nunca fonte de verdade — para templates de prompt versionados (PromptTemplate/PromptVersion) e memória relacional (interações, hipóteses do Psicólogo com evidência, perfis da Anamnese, handoffs). pgvector CONTINUA sendo o índice vetorial dos chunks — o grafo não guarda embedding. Driver neo4j-driver na API (não no engine): a API já é dona de toda persistência e do RAG existente, o engine consome por HTTP interno com service token como sempre. GraphStore.onModuleInit nunca lança — as três env vars são obrigatórias em produção e opcionais fora dela, indisponibilidade vira GraphUnavailableError/503, nunca 500 cru. Padrão inspirado explicitamente no repositório ErickWendel/neo4j-ai-experiments (grafo como memória de agentes + prompts como arquivos separados do código)
0100Tool nova rag_search para os agentes do engine — fecha o maior vão do RAG existente (pgvector, busca híbrida): nenhum agente jamais o consultava, só a aba web "Chat RAG". Chama POST /internal/rag/search (reusa HybridSearchUseCase sem duplicar lógica), formata hits com citação (path + trecho), degradação (degraded: true) sempre visível no INÍCIO do texto. Tetos próprios (RN-150): top_k clampado a 10, 16 KiB de teto de bytes. ollama-model-loader (serviço one-shot novo, dev e prod) fecha um bug real separado: nomic-embed-text nunca era puxado automaticamente, e o RAG degradava para léxico-only em silêncio em qualquer ambiente limpo
0101Consumo da fundação do grafo: InstructionFiles ganha fonte :graph (precedência db > graph > dir > root, cache ETS reusado), sem mudar o contrato de nenhum GenServer — ux-designer, Psicólogo e Anamnese passam a TENTAR resolver template do grafo antes do texto inline, que vira fallback. Duas flags separadas (graph_templates_enabled?, graph_instruction_templates_enabled?) de propósito — uma chave só colidiria com defaults CONTRÁRIOS entre frentes paralelas. Psicólogo/Anamnese compõem "recentes"/"janela temporal" com trechos RELEVANTES via rag_search (nunca substituição, sempre aditivo — sem hit, comportamento idêntico ao de antes). O grafo se escreve sozinho: GraphProjector drena uma SEGUNDA linha de outbox (aggregateType: 'graph_projection', nunca casado pelo filtro do engine) e chama os casos de uso de gravação já existentes — o engine NUNCA escreve no grafo direto, preservando o event log como única fonte de verdade
0113Anexar uma pasta local da PRÓPRIA máquina do usuário como material de referência somente leitura — SEM o CLI brabo-runner, explicitamente distinto de execution_mode: runner (ADR 0104), que precisa de um caminho real do host. O navegador lê o CONTEÚDO dos arquivos (File.text()) e os caminhos RELATIVOS (File.webkitRelativePath) — nunca um caminho absoluto do host, porque a File API não expõe um a site nenhum; não há caminho a rotear pelo Runner (ADR 0107) nem a validar como a guarda do ADR 0072, porque não há caminho nenhum. chunks.scope ganha um quarto valor, 'local' (migration 0052, ALTER TYPE aditivo, zero CHECK novo — as restrições existentes já se dividem em "é session ou não"), reusando o pipeline inteiro do RAG (chunking, embedding, busca híbrida, citação) em vez de um mecanismo paralelo. Upload de uma vez: tetos agregados (número de arquivos, bytes somados) RECUSAM (400) o lote inteiro em vez de truncar em silêncio, um arquivo individual grande demais ou não reconhecido só é pulado; reenviar é o ÚNICO mecanismo de ressincronização, deliberadamente nunca ligado ao "Reindexar agora" genérico (que relê de uma fonte que o servidor pode revisitar — este escopo não tem nenhuma)
0129A busca híbrida do RAG finalmente deixa rastro — mas como TABELAS (rag_searches/rag_feedback), não eventos de sessão: session_events.session_id é NOT NULL e uma busca vinda da aba RAG é uma busca de projeto sem sessão, então o event log descartaria em silêncio exatamente as buscas que um humano julgou (mesma classe de problema que cortou o metering de embedding no ADR 0075). Os pesos ficam CONGELADOS em cada linha, como o preço congelado no metering (ADR 0042), para que uma calibração posterior não reescreva em silêncio o que toda medição anterior significava. rag.search/rag.feedback só existem quando HÁ sessão, e são narração, nunca a fonte da medição. Gravar telemetria nunca derruba a busca — e também nunca falha calado: origem infra no log e um searchId: null explícito. Nada é calibrado aqui; este passo só instrumenta.
0132A vez do RAG ganhar um golden-set: mesma forma do do agente de QA Automação (ADR 0123) — uma tag ExUnit excluída permanentemente (:golden_set_rag), um piso de catraca chaveado por MODELO, um script de seed chamado por System.cmd — copiada de propósito em vez de rederivada, com a única adaptação que o domínio força: a busca híbrida do RAG roda inteira dentro da api (HybridSearchUseCase), sem julgamento de LLM de chat em ponto nenhum do caminho, então o seed-golden-set-rag.ts tanto provisiona QUANTO roda a própria busca, e o lado Elixir só o invoca e aplica o piso — reimplementar a busca em Elixir testaria uma segunda implementação, nunca a real. As 17 perguntas são COMPOSTAS a partir de RNs/ADRs reais, não extraídas — conferido antes: gh issue list neste repositório de dev solo devolve "No Issues", então não há corpus genuíno de perguntas para minerar, e isso é dito em vez de forjado. O corpus indexado é real, porém: 22 arquivos curados do próprio docs/ deste repositório, copiados literalmente e indexados pelo mesmo IndexProjectDocsUseCase que qualquer projeto real usa — curado, não a árvore inteira de 130+ ADRs, por custo de embedding numa execução manual. O critério de acerto — caminho do arquivo, nunca o chunk exato, dentro do top 5, nunca a posição 1 — é uma função pura e testada (golden-set-criterio.ts), porque o chunking é exatamente o parâmetro que este programa inteiro existe para ter liberdade de revisar, e o RAG de Chat cita vários chunks por resposta, não só o primeiro. Gate novo rag-acertivo em docs/gates.yml, warn (não há CI rodando LLM real), evidência tipo: teste — o que prova este gate é o próprio golden-set, não um evento. Medido de verdade, duas vezes, de forma determinística: 17/17 perguntas acharam o arquivo dentro do top 5, nas duas execuções, contra um Ollama real com nomic-embed-text e um Postgres isolado
0138Etapa 3 do programa de RAG mensurável: o golden-set do RAG (ADR 0132) passa a rodar de verdade em CI, num .github/workflows/golden-set-rag.yml novo — mas AGENDADO (cron noturno + workflow_dispatch), nunca em pull_request, uma cadência que o dono do produto escolheu dados os ~3-6min que uma execução custa (baixar o nomic-embed-text mais indexar e 17 buscas). A severidade do rag-acertivo CONTINUA warn — deliberadamente, agora por motivo de cadência em vez de técnico, já que um job agendado não bloqueia merge. O que torna isto viável onde o golden-set do QA (ADR 0123) ainda não é: este só chama o modelo de EMBEDDING (CPU, determinístico, sem amostragem), nunca um modelo de chat julgando — ubuntu-latest sem GPU basta. Um achado que simplifica o job inteiro: o seed-golden-set-rag.ts nunca define NODE_ENV, então os quatro segredos que a api recusaria em produção (RN-114) pegam o fallback literal de dev sem reclamar — ao contrário do docker/smoke.sh, nada aqui precisa de openssl rand. O health-check do serviço Ollama roda ollama list, não curl (a imagem garante o primeiro, não o segundo), reusando exatamente o padrão de espera que docker/ollama/pull-models.sh já validou com um download real. O golden-set do QA recebe zero mudança deste ADR — continua inteiramente manual, continua um TODO(humano) declarado
0148Todo artefato que os agentes produzem — o brief do Criativo, as regras de negócio do PO, o diagrama C4 e o roteamento de módulos do Arquiteto, as decisões dos seis agentes conversacionais — mora EXCLUSIVAMENTE no event log; o emit_artifact.ex diz isso com todas as letras, "não há tabela de artefatos". Essa é a decisão certa para a FONTE e não é tocada aqui. O problema é que essa memória não chega a lugar nenhum que uma pessoa abra: ela é legível uma linha de cada vez dentro da timeline de sessão do produto, e quem abre a pasta do projeto num editor — onde o código vai estar — não acha nada do raciocínio que o produziu. Adiar o git (o provisionamento adiado (PR próprio)) alargou o buraco: entre a criação e o handoff ao Arquiteto o projeto não tem repositório nenhum, então tudo o que o produto acumulou fica em linhas de banco enquanto a pasta do usuário está vazia. Então docs/ vira uma PROJEÇÃO do event log no sentido estrito que o ADR 0101 deu ao grafo — a fonte continua sendo o evento, a pasta pode ser apagada inteira e reconstruída, e NADA no produto lê dela para decidir coisa alguma; ela existe para ser aberta por uma pessoa, e todo o resto decorre disso. O mecanismo copia o GraphProjector peça por peça (poller próprio com .unref(), drainOnce() público para testes, flag draining, lote de 50, markProcessed só depois do sucesso) — não reuso de código, mas reuso de DESENHO, já que os dois escrevem em destinos sem nada em comum, e copiar a forma é o que deixa quem conhece um entender o outro sem reler. aggregate_type próprio (artifact_projection) pelo motivo que o do grafo já documenta: o Engine.Outbox.Drain só drena session/task/container e marca a linha 'session' a cada ~2s, então compartilhá-lo seria correr contra o engine e perder quase sempre — com uma diferença deliberada, o aggregateId é o PROJETO e não a sessão, poupando ao projetor uma consulta por artefato por algo que o caso de uso já tinha em mãos. Uma lista de PERMITIDOS, não de proibidos, para que um tipo novo de artefato nasça fora da projeção em vez de aparecer sem aviso numa pasta que o usuário lê; qa_verdict, secops_verdict, task_blocked e infra_delegation_files ficam de fora como DESFECHOS operacionais e não documentos de produto — um veredito de gate por tarefa encheria docs/qa-lead/ de arquivos que ninguém abre e custaria à pasta a própria propriedade que a torna útil, caber numa olhada. O layout é docs/<agent>/, então navegar responde "o que o Arquiteto produziu?" sem abrir nada; os quatro tipos VERSIONADOS sobrescrevem um arquivo (a pasta mostra o VIGENTE, o histórico fica inteiro no event log — projetá-los por versão produziria c4-diagram-v1.md, c4-diagram-v2.md e faria o leitor descobrir qual vale, exatamente o que a redução por version existe para poupar), enquanto os tipos append-only ganham cada um o próprio arquivo levando o seq do evento, porque sem ele dois artefatos do mesmo tipo e título colidiriam e o segundo apagaria o primeiro — o único jeito de esta projeção perder informação que a fonte tem. Duas barreiras ficam entre um título escrito por LLM e o sistema de arquivos, e a segunda não é redundante: slugDeArquivo DERIVA um nome de um alfabeto fechado (nenhum caminho de retorno deixa um separador sobreviver, e vazio dá null, já que um nome derivado de nada colide com todos os outros), e o FsArtifactFileStore confere de novo com relative() antes de abrir — as duas respondem perguntas diferentes, "que nome eu derivo" e "o que estou prestes a abrir", e confiar só na primeira faria a segurança desta escrita depender de nenhuma mudança futura do slug jamais deixar passar um separador, justamente a contenção que o ADR 0130 recusa por princípio. No modo runner a pasta é desviada para a raiz gerenciada exatamente como o permissions.json e pelo mesmo motivo físico (a api escreve de dentro do container dela, um projeto runner fica deliberadamente sem bind-mount, e o canal do runner leva COMANDOS, sem primitiva para escrever conteúdo de arquivo) — custo declarado: ali, docs/ não fica ao lado do código. E a escrita NÃO passa por proposed_action, o que não é omissão: esse pipeline é a origem de todo efeito externo de um AGENTE PEDINDO PARA AGIR, e aqui nenhum agente pede nada — o artefato já foi emitido e já está no event log; rotear isto por aprovação encheria a fila com uma decisão de rotina por artefato e corroeria o próprio teto que dá sentido ao clique nas ações que são de fato efeito externo
0196A Anamnese é RELIGADA com correções (AT-277, decisão do dono de 01/10). Uma rodada não roda sem SUJEITO elegível — membro EFETIVO do projeto (projectRole ?? workspaceRole, RN-471; criar projeto não grava linha em project_members, e é por isso que toda rodada do uso real de 29/09 pagou o modelo para concluir "nenhum membro elegível"), fora do opt-out, com interação PRÓPRIA na janela: nenhuma chamada ao LLM, nenhuma busca no RAG, o motivo nomeado no log do engine, e anamnese.run_skipped só na rodada pedida à mão. Uma hipótese do Psicólogo ACEITA pela pessoa a quem a sessão pertence vira um FATO DO PERFIL no grafo — traduzida de psychologist.hypothesis_accepted pelo mesmo GraphEventTranslator, então o grafo:reprojetar a reconstrói — e chega aos agentes que conversam com essa pessoa: a api a lê por QueryUserContextUseCase (escopada ao projeto, as 5 mais recentes, total ao lado) e o engine a acrescenta como mensagem de sistema efêmera no turno do autor, pelo mesmo caminho do idioma da resposta. O aceite de terceiro e a recusa ficam só registrados. ANAMNESE_ENABLED volta ao padrão true; o Psicólogo continua pausado
0200O contrato entre módulos vira ARTEFATO versionado do Arquiteto, e os dev agents o leem em vez do worktree de outro módulo (AT-276, decisão do dono de 01/10). artifact.module_contracts, sem tabela, a versão vigente substitui a anterior: por módulo, só o que ele EXPÕE (funcao/rota/evento/dado + assinatura, 1–40 itens); o que ele consome é derivado na leitura do dependsOn do module_map vigente. Um artefato SEPARADO, nunca campo do module_map. O Arquiteto ganha declare_module_contracts; os dev agents ganham listar_contratos_de_modulos (sem parâmetro, escopada ao projeto, o próprio módulo e as dependências dele por inteiro, teto de 120 itens) e o kickoff deles diz para ler as interfaces ali; sem contrato, report_blocked nomeando o que falta
0205O que a Anamnese vê como desvio do AGENTE — um laço, um turno cancelado pelo usuário, uma mensagem do usuário reparando uma falha do agente — vira sinal de melhoria do PRODUTO (AT-356 parte 2, decisão do dono de 02/10): o evento durável anamnese.agent_deviation, escrito pela ferramenta nova report_agent_deviation, separado do perfil do usuário e SEM dado pessoal (só agente, tipo de desvio de vocabulário fechado e evidência por eventId; sem texto livre). Não é projetado no grafo do usuário; é lido do event log, sem tela nova

Interface web e design system​

Telas, navegação, temas e a estrutura dos maiores arquivos do app web.

#decisão
0036As telas de auth fiéis ao mock aprovado: as três fontes do design system auto-hospedadas (a CSP bloqueava o CDN e a tipografia caía em fonte de sistema em produção), a versão da tag assada no artefato, o contraste calculado dos tokens em vez de medido pelo axe, e as seis divergências deliberadas do mock
0060A superfície de leitura de código da aba Código, contida e com orçamento. A 26a pôs listTree e getPullRequestDiff no contrato e instalou a checagem que reprova operação sem consumidor; este ADR é o consumidor, e decidir escrevê-lo forçou resolver três coisas que nenhuma rota anterior tinha enfrentado, porque nenhuma recebia um CAMINHO DE ARQUIVO do cliente. Onde a busca mora: fora do contrato. Nenhum dos três providers a tem — GitHub e GitLab têm busca de código no nível da plataforma com semântica própria, e o LocalGitProvider é um repositório bare —, então declará-la seria ou uma 13ª operação com capability false no local (uma aba que some num provider) ou vazar o vocabulário de uma plataforma para o contrato normalizado que existe justamente para impedir isso; ela fica composta no nível da aplicação, sobre listTree + getFileContent. Quanto ela pode gastar: três orçamentos (diretórios percorridos, arquivos abertos, ocorrências devolvidas) mais um cache de TTL curto, porque é a única leitura cujo custo cresce com o TAMANHO do repositório e não com o da requisição, e quem paga é a credencial do OWNER (RN-058/RN-082) — sem teto, é a mesma família de falha dos 3.824 req/min do dashboard, com o 429 virando tela em branco. Varredura em largura: cortada no meio, ela entrega os arquivos rasos, que é onde se costuma procurar. Como o caminho é contido: uma função única, no arquivo da RN-092, reusando as primitivas de escopo do 0055 — e devolvendo o caminho NORMALIZADO, porque devolver o original permitiria checar uma string e mandar outra. O vetor não é ler o arquivo errado: num provider remoto o caminho vira segmento de URL da API e um .. TROCA DE ENDPOINT com o token do owner na mão. O CodeQL vai continuar apontando, e o preço é o já aceito no PÓS-FASE 15 — duplicar a checagem calaria o alerta e criaria quatro cópias que um dia divergem. As quatro rotas são GET/role:viewer e o controller NÃO tem verbo de escrita: ler não é efeito externo e não vira proposed_action (encheria a fila de ruído), e quando a escrita chegar ela nasce como uma — a ausência de verbo é o que torna esta fronteira verificável em vez de uma intenção
0074Tema alcançável, e o boot sob CSP. O tema claro existia em design/tokens.css, era medido por teste de paridade e citado no design/README.md — e nada em apps/web escrevia data-theme: o único jeito de vê-lo era digitar o atributo no DevTools. Os dois testes de contraste sabiam disso e estavam certos enquanto durou (um afirmava por expect que três pares do claro REPROVAM, "registro do que se herda, não garantia do que se renderiza"); com o botão de tema, medir só o primário passaria a esconder metade da superfície visível. O boot é um ARQUIVO (public/theme-boot.js), não script inline: a imagem serve sob script-src 'self' (ADR 0058), e inline funcionaria em pnpm dev:web e seria bloqueado em produção — a falha do 0036 com outro sujeito, já que o handoff pede exatamente o inline. Síncrono e antes do bundle, senão o usuário do claro vê flash escuro a cada carga; ES5 puro porque o public/ do vite não passa por build, travado por teste; localStorage que lança degrada para "pinta e não persiste", e valor desconhecido cai no default em vez de virar um data-theme que o CSS não conhece. Seis tokens do tema claro mudaram de valor porque o fundo mais exigente do tema é o --code-bg de papel: --accent 3,56 → 4,81, --warning 3,15 → 4,98, --success 3,89 → 5,12, --violet 4,16 → 4,95, --text-muted 2,76 → 5,17 (a 2,40:1 sobre --surface-2 ele reprovava até o piso de 3:1 — era defeito, não dívida), e --accent-hover seguiu um degrau abaixo. Efeito colateral registrado: os cinco pares que no ESCURO são dívida conhecida passam os 4,5:1 no claro, e a "exceção conhecida do design system" (--on-accent sobre --accent, 3,20:1) some no claro porque o conserto que o comentário do teste descrevia — escurecer até --terracota-500 — é o que o claro passou a fazer. O escuro não mudou um valor. A paleta de sintaxe vira os OITO papéis do handoff com prefixo --syntax-*, e cinco dos oito valores do handoff foram recusados por medição (--syn-cm 2,32:1 no claro, --syn-kw 4,34, --syn-str 4,20, --syn-fn 4,14, --syn-op 4,00): onde o handoff reprova vale o número medido, mesma régua do 0036. --font-display/--shadow-modal e --r-md/--r-lg/--r-pill entram como ALIAS, nunca renomeação — e --r-sm (7px) NÃO é sinônimo de --radius-sm (4px)
0078Moldura de tela, e o registro de abas diverge do handoff de propósito. O checklist "moldura de tela" do handoff (header 60px, régua de abas com box-shadow: inset 0 -2px 0 var(--accent), rolagem horizontal, container de conteúdo com largura máxima 960–1040px) tinha os quatro defeitos em ProjectPage.tsx, e os quatro foram corrigidos — --header-h virou min-height, não height, porque o cabeçalho do PROJETO (identidade + TokenMeter compacto) é mais rico que o cabeçalho genérico das 6 telas internas do handoff e forçar 60px cortaria o alerta de orçamento, a mesma classe de erro que a RN-088 proíbe. A parte que precisava de decisão, não só CSS: o handoff lista 7 abas e o registro (project-tabs.ts) tem 10. executores (RN-121), backlog (RN-048) e insights nasceram DEPOIS do handoff, com dado real e RN própria, e FICAM — o handoff é referência de fidelidade VISUAL, não teto de quantas abas o produto tem (RN-203). E a chave sessions continua rotulada "Chat", nunca "Chat RAG" como o handoff mais recente pede (RN-202): "Chat RAG" descreve uma funcionalidade que não existe — sem pipeline de indexação, sem UI de citação, só a fundação do contrato (embeddings, 0075) —, e renomear a aba hoje anunciaria uma capacidade antes dela existir, o mesmo erro que o 0042 já recusa para modelo de catálogo. Os valores de espaçamento da régua que viviam como override de CSS de descendente em ProjectPage.module.css — pendência declarada desde a FASE 16 — migraram para Tabs.module.css, que passou a ter o mesmo dono. Nenhuma FORMA de export mudou (AbaDoProjeto, ABAS_DO_PROJETO); só o rótulo de code foi de "Code" para "Código"
0097O "N agentes online" do dashboard (RN-409) lê engine.dev_agent_states DIRETO — SQL cru batelado por workspace, dentro do MESMO Promise.all que já soma onze consultas do read model de RN-090 —, em vez de expor uma rota HTTP interna nova no engine. api e engine compartilham o MESMO banco físico, separados só por schema Postgres ("engine" vs public), e já há precedente de leitura cross-schema (apps/api/scripts/medir-execucao.ts já lê engine.oban_peers) — esta decisão eleva o padrão de script manual para código de produção TESTADO, com fixture mínima e declarada da tabela do engine em test/support/global-setup.ts. Consequência aceita: a consulta pressupõe que quem opera o produto migra os dois lados juntos (db:migrate E engine:migrate), e falha alto/visível se não — sem try/catch escondendo o erro
0116O ActionType do apps/web deixa de ser copiado à mão — agora é gerado pelo openapi-typescript a partir de docs/reference/openapi.json (já um artefato com checagem de drift), reusando esse mesmo portão de frescor em vez de duplicá-lo. Corrige uma cópia que já divergiu de apps/api/src/domain/actions/decide.ts DUAS vezes em produção (os três tipos do bootstrap de Gitflow quebrando o ApprovalCard; depois parallelize/raise_max_parallel), as duas sem o compilador notar porque apps/api não é dependência de apps/web. Só o ActionType migra — as outras ~1600 linhas de api-types.ts continuam escritas à mão, um tipo de cada vez, não uma regeneração em massa. O aprovacoes.test.ts (FASE 19) fica: a exaustividade agora é conferida também pelo compilador via Record<ActionType, ...>, mas as asserções de conteúdo do teste (qualidade da frase, não só existência) não são propriedade de tipo
0122apps/web/src/routes/SessionPage.tsx — 3 807 linhas, 169 KiB, 25 arquivos de teste o importando, sob mudança ativa (50 commits, zero tentativas anteriores de extração) — declara de antemão a decomposição INTEIRA em 5 PRs para que cada PR a execute em vez de redecidi-la: helpers puros de timeline/turno → lib/session-timeline.ts (PR 1), StorySlide (PR 2), StructuredQuestionCard + permiteOutra (PR 3), helpers da árvore de backlog + ItemDeBacklog + ContextAside (PR 4), um hook useSessionReadiness — o único que não é mudança de arquivo, ordenado por último (PR 5). O SessionPage.module.css tem exatamente um importador hoje, então os arquivos irmãos novos dos PRs 2–4 podem importá-lo direto com segurança — nenhuma divisão de CSS forçada. Explicitamente FORA dos cinco PRs: o conjunto de estado do canal de turno (turnoViaCanal/statusAgent/pensandoVisivel/atividadeDoTurno, lido e escrito por quatro handlers de entrada de turno diferentes, o "UM lugar que termina um turno" do comentário no código) e o ProjectSettingsTab.tsx (escopo separado). A régua de aceite de todo PR é os MESMOS 25 arquivos SessionPage.*.test.tsx passando sem edição
0124O conjunto de estado do canal de turno que o ADR 0122 adiou ("um ADR próprio, depois, com numeração separada") vai para useTurnoDoAgente (apps/web/src/lib/session-turno.ts): streaming/streamingText/streamingAgent/turnoViaCanal/statusAgent/pensandoVisivel/atividadeDoTurno/optimisticUser, o efeito de canal connectSessionHeartbeat (verificado 100% maquinaria de ciclo de vida do turno), e iniciarTurnoDoAgente/finalizarTurnoDoAgente/cancelarTurnoOtimista. Uma releitura linha a linha achou a própria contagem do ADR 0122 desatualizada — quatro handlers de entrada de turno nomeados, sete pontos reais de entrada de escrita existem, inclusive handleReturnStory/RN-174, nunca mencionado ali — e se apoiou num PR anterior de deduplicação mecânica (ele mesmo sem ADR) que primeiro fez três braços inline duplicados chamarem o iniciarTurnoDoAgente preexistente (que ganhou { comStatus } ali) e introduziu cancelarTurnoOtimista. Essa função é construída para cobrir só dois dos cinco blocos de reset-na-falha de formatos diferentes achados no arquivo (os blocos idênticos de 4 campos de handleReadiness/handleArchitectureReadiness) — NÃO o bloco de 2 campos de handleAcceptHandoff (o kickoff dele é um GenServer.cast assíncrono, então ele nunca armou os outros dois campos; acoplá-lo a cancelarTurnoOtimista repetiria exatamente a armadilha de acoplamento silencioso que o ADR 0122 já nomeou) nem as duas formas distintas de handleSend (optimisticUser pertence ao próprio ciclo de vida de handleSend, não ao armar). SessionPage.tsx vai de 2 681 para 2 479 linhas; o retorno do hook em forma de objeto (13 campos, desestruturação posicional rejeitada por ilegível) mantém inalterado o nome de variável de todo ponto de chamada. Fecha especificamente o item do canal de turno da seção "Explicitamente fora de escopo" do ADR 0122 — o ProjectSettingsTab.tsx, o outro arquivo dessa linha de dívida, fica intocado e com escopo separado
0125apps/web/src/routes/ProjectSettingsTab.tsx — 2 532 linhas, 89,9 KiB, a ÚLTIMA metade aberta da linha de dívida técnica que o ADR 0122 e o ADR 0124 nomearam e deliberadamente adiaram — se divide num arquivo por seção sob apps/web/src/routes/settings/: 17 seções mais um settings/shared.ts de dois símbolos. UM PR, não cinco como o 0122, e o motivo é estrutural e não de gosto: o pai não segurava NADA (17 filhos JSX, nenhum hook, nenhuma query, nenhum t, nenhum queryClient, nenhuma checagem de papel), nenhuma seção recebia mais que {projectId} (MatrixSection/CredentialsSection não recebem prop nenhuma), e 11 das 17 já eram exportadas — então todo arquivo é um bloco contíguo, idêntico byte a byte, e o argumento de mudança ativa que forçou a forma de cinco PRs do 0122 não se aplica. O ProjectSettingsTab.tsx FICA no caminho dele como entrada + barrel (2 532 → 77 linhas), e isso sustenta peso, não é cosmético: ProjectSettingsTab.test.tsx importa 11 nomes do caminho, ProjectPage.test.tsx e project-tabs.test.tsx o mockam com vi.mock PELO CAMINHO, e ProficiencySection.test.tsx renderiza o próprio barrel — o único teste que monta as 17 de uma vez. Mesma forma de barrel do ADR 0121, mas com os exports NOMEADOS em vez de export *: uma seção é um componente com um formato de prop, e uma lista explícita é o que deixa o compilador pegar uma que deixe de casar com o ponto de chamada. O lugar dos helpers é mecânico e verificado por grep — um chamador quer dizer o arquivo do chamador (dez de doze), dois ou mais quer dizer shared.ts (ORIGIN_TONE, formatarCustoMicros) — o que corrigiu uma expectativa de passagem: iniciaisDe/gradienteDe pertencem só a MembersSection, não a ProficiencySection. As 6 seções privadas ganham export no próprio módulo (mecânico; o barrel precisa importá-las) mas NÃO são reexportadas pelo barrel, então a superfície pública fica com forma idêntica byte a byte — alargá-la seria mudança observável num PR que afirma não ter nenhuma. O ProjectSettingsTab.module.css continua UMA folha de estilo compartilhada, agora com 15 importadores, a mesma resposta que o 0122 deu para o SessionPage.module.css. Deliberadamente preservados: useTranslation('settings') ~14×, useQueryClient() 11×, useToast() 10×, useQuery(['project', projectId]) 7× — o React Query os deduplica, e içá-los para um pai ou um contexto seria mudança de desenho disfarçada de limpeza. Fecha o ÚLTIMO item de dívida aberta declarado em docs/architecture.md; as quatro melhorias de UI que caem nesses mesmos arquivos são PRs posteriores do mesmo plano, explicitamente fora deste
0126A faixa horizontal de abas de dois níveis da página do projeto (components/ui/GroupedTabs.tsx) vira um TRILHO VERTICAL (apps/web/src/routes/ProjectRail.tsx), com os três grupos abertos de uma vez. Só um dos três argumentos da revisão de design sobreviveu à verificação, e é a única justificativa registrada: 12 abas numa barra feita para ~6 não escala. Os outros dois eram falsos e ficam escritos para ninguém relitigá-los — o segundo nível era uma linha inline, nunca uma sobreposição (GroupedTabs.tsx:143), e um grupo fechado SOMAVA sim os contadores dos filhos (somarContagens, :33, testado em :141). A geometria é emprestada de routes/code/CodeShell.module.css — o único trilho do repositório com estado ativo desenhado (faixa --surface-1, divisória à direita, item de 32px em --radius-sm, ativo tingido color-mix(… 12%)) — nunca da .trilha da sidebar, que não tem item ativo nem teclado. Uma divergência declarada: 180px de largura em vez de 48px, porque este trilho leva rótulos e CABEÇALHOS de grupo, que é o mecanismo que mantém três grupos abertos. Os cinco contadores continuam SEPARADOS e nenhum grupo os soma; o selo continua sólido --accent/--on-accent contra o tingido de 18% do handoff, pela dívida de contraste do ADR 0074. O contrato de teclado testado de onKeyDownDaLinha é PORTADO, com o eixo trocado (ArrowDown/ArrowUp/Home/End, com volta) e melhorado — refs por chave em vez de consultas posicionais [role="tab"] ao DOM, que só existiam porque a primitiva Tabs não expõe nenhuma. O GroupedTabs é apagado com o teste dele; o Tabs FICA apesar de perder o único chamador, porque ds-bundle/_ds_sync.json o publica como components/primitivas/Tabs e uma primitiva do DS sem consumidor é estoque, não código morto. Revisa a RN-201: o auto-recolhimento da aba Código (AutoCollapseContext/useAutoCollapseSidebar) é REMOVIDO, porque um trilho de projeto permanente ao lado do trilho de ícones do Shell seriam dois trilhos verticais adjacentes na aba mais pesada. O preço aceito, medido: 492px de moldura antes do primeiro caractere de código (264 sidebar + 180 trilho + 48 trilho do código) contra ~110px antes. O que se compra é que recolher volta a ser decisão do usuário — ainda manual, ainda persistido; o trilho duplo sobrevive sob recolhimento manual, o que é dito em voz alta em vez de fingido inexistente
0176apps/web/src/routes/SessionPage.tsx vai para abaixo de 1.000 linhas em dez PRs empilhados e MECÂNICOS, e só então ganha um teto no CI (AT-138, BRB-015, decisão do mantenedor: "sim, e trava depois"). A linha de dívida fechada pelos ADRs 0122/0124 em 2.479 linhas fechou por um NÚMERO que ninguém remediu: na origin/dev em 018b8cd24c o arquivo tinha 2.637 linhas, de volta ao tamanho por features legítimas. O programa move, sem editar nenhum dos 28 arquivos SessionPage.*.test.tsx: as passagens do fio (session-fio.tsx), o corpo de 762 linhas do useMemo da timeline (session-timeline-montagem.tsx, chamado de dentro do MESMO useMemo com a MESMA lista de dependências, byte a byte), as três faixas de JSX (SessionTopbar, SessionFio, SessionComposer), as derivações de handoff (lib/session-handoffs.ts), as refs e efeitos de rolagem (useRolagemDoFio), a promoção de histórias (usePromocaoDeHistorias + DevolverHistoriaModal) e as ações de handoff/execução que não são de turno (useAcoesDeHandoff) — 2.637 → 834. Os handlers de entrada de turno ficam: são a porta do conjunto do ADR 0124. O último PR acrescenta SessionPage.teto.test.ts, reprovando o arquivo com 1.000 linhas ou mais; subir o teto exige ADR novo
0181O tema escuro vira PRETO NEUTRO com acento terracota SUAVE, e o claro vira o neutro da mesma família (AT-283/AT-284). Os VALORES semânticos mudam, os nomes não; entra uma escala crua --neutro-*. Os números finais vieram de MEDIÇÃO, não do rascunho (--text-muted #86868f no escuro / #696972 no claro, o --on-accent do escuro vira preto neutro), e os cinco pares de dívida do tema escuro que o 0074 travou por número agora passam 4,5:1 e são afirmados como PISOS; a exceção do botão primário saiu sem mover a matiz da marca. Cor fora dos tokens vai para os tokens: uma cópia conferida dos fallbacks para Mermaid/xterm/minimapa, três tokens de agente por tema, --overlay, e um teste que reprova qualquer var(--x) que ninguém declara
0210Animação de texto com motion (AT-401): o Shimmer do AI Elements, adaptado a CSS Modules e tokens (base --text-secondary, varredura --text-primary), mora em components/ui/Shimmer.tsx — o único importador de motion, carregado por LazyMotion + domAnimation num chunk próprio vendor-motion (26,5 kB gzip, fora da carga inicial); movimento reduzido mantém o texto estático.
0211Uma navegação só (AT-404): o trilho vertical do projeto (ADR 0126, ProjectRail.tsx) é removido e a lista por projeto da sidebar (AbasDoProjeto) absorve tudo o que ele tinha — grupos, aba ativa, os cinco contadores SEPARADOS (o mesmo hook useContagensDoProjeto da moldura, sem requisição nova), teclado, um flyout quando recolhida e a gaveta no celular. A moldura da aba Código cai de 492px para 312px (110px recolhida).
0232shadcn/ui com Base UI (base-nova) e Tailwind 4 no apps/web (AT-645): tema = tokens do Brabo, sem preflight até a limpeza, alias @/, components/shadcn/, grupo vendor-ui; nenhuma tela muda.
0234shiki no Code Block e recharts no Chart dos Gastos (AT-657, AT-658), liberados pelo dono em 31/10: versão exata, sempre por import()/lazy, fora do JS inicial e de codeSplitting.groups; cor por token; o realce por regex e o SVG de hoje ficam como queda.

Operação, deploy e instalação​

Imagens de produção, Kubernetes, observabilidade, backup, o compose de dev e o instalador de uma linha.

#decisão
0024Imagens de produção multi-stage e non-root, compose.prod, CI com scan e teste de fumaça
0025Kustomize base+overlays (não Helm), HPA do engine por profundidade da fila do Oban, overlay local
0026Graceful shutdown com drain, registro de sessão em :global, OpenTelemetry com trace por sessão, métricas e dashboards como código
0027Backup/restore testado, rate limit em Postgres (sem Redis), superfície HTTP classificada e release versionado
0035Instrumentar e exportar separados (trace com trace_id real sem coletor), o caminho entre camadas por AsyncLocalStorage + @Traced, log legível em dev e uma linha de JSON em produção, e os três furos de correlação que estavam mortos
0070Observabilidade no Compose local: métrica e log, sem trace. O 0026 montou Prometheus, Loki, Tempo, Alloy, Collector e Grafana só para Kubernetes — o critério de aceite dizia "no Grafana local" e "local" ali era o cluster k3d. A consequência prática é que quem desenvolve com pnpm dev tem a instrumentação inteira funcionando (a api expõe 102 séries, o engine expõe oban_queue_depth, os três carimbam trace_id no log) e NADA disso é observável, porque ver um painel exigia subir um cluster que por desenho não coexiste com o pnpm dev (mesmas portas, ADR 0025). O overlay docker/docker-compose.observability.yml é opt-in, não parte do pnpm dev: são quatro containers a mais numa máquina que já roda Postgres, Ollama e três apps, e quem não está investigando não deve pagar por eles. Entrega as duas telemetrias que NÃO precisam de coletor — métrica é scrape, log é lido do stdout, exatamente a separação da decisão 9 do 0026 — e deixa trace de fora, sem definir OTEL_EXPORTER_OTLP_ENDPOINT, porque o 0035 já estabeleceu que não exportar é o estado normal em desenvolvimento e apontar para endereço inexistente só produz erro de exportação a cada turno. Os artefatos são os MESMOS do cluster e não uma cópia: o Compose monta deploy/k8s/observability/dashboards/ direto e os UIDs de datasource são idênticos, porque dashboard referencia datasource por UID e cópia de dashboard diverge no primeiro painel corrigido de um lado só; pelo mesmo motivo o scrape emite os rótulos app/pod que os painéis agrupam e o coletor emite o app que o Alloy do cluster deriva de app.kubernetes.io/name. O parsing do log é por regex e não stage.json porque em dev o pino-pretty desenha a árvore legível e o engine usa PrettyLogFormatter — decisão boa que não se troca para agradar o coletor; o coletor é que se adapta. Só api, engine e web vão para o Loki: Postgres e Ollama são ruído, e o próprio stack de observabilidade fica de fora porque roda no MESMO projeto do Compose e ingeriria o próprio log num laço que se alimenta sozinho. trace_id é metadado ESTRUTURADO e nunca rótulo (cardinalidade), o que exigiu allow_structured_metadata e schema v13 numa config própria do Loki. Registra três modos de falha silenciosa que custaram tempo, todos com a mesma assinatura — config válida, zero erro no log, dado errado no painel: stage.replace substitui GRUPOS DE CAPTURA e sem parêntese não substitui nada; escape de \x1b em string do River é processado antes de a regex compilar, e [[:cntrl:]] não depende dessa camada; e template do Go com chave ausente renderiza o literal <NO VALUE>, que virou um valor de nível no seletor
0150O instalador de uma linha, e os três fatos de disco que lhe dão forma. docker-compose.prod.yml não é manifesto de instalação: todo serviço aponta para brabo-*:prod com um bloco build: local, não há uma única referência a ghcr.io sob docker/, e o arquivo diz de si mesmo que é o "compose de VALIDAÇÃO" e "o que este arquivo NÃO é: um manifesto de produção endurecido". O único consumidor de .release/images.json é o overlay do kustomize. Decisão: a invocação é sh -c "$(curl -fsSL …)" e **nunca `curl
0152Backup e restauração que rodam contra o compose — nascidos porque a FASE 29 precisa migrar uma instalação existente e o caminho documentado não serve: make test-restore é um caminho de Kubernetes (precisa de kubectl, um cluster e o CronJob brabo-backup), o docker-compose.prod.yml não tem serviço backup, e o backup.sh cobre só o Postgres exigindo cinco variáveis de S3. A decisão começa RECUSANDO o movimento fácil ("fazer backup de todo volume") e classificando os sete: pgdata e git_local_repos são fonte de verdade; neo4j_data e project_workspaces são derivados; ollama_data é reobtenível; brabo_projects_base pertence ao usuário. git_local_repos é o buraco real e o achado deste ADR — um projeto de provider local guarda o repositório bare ali, perder esse volume perde o código que os agentes produziram, nenhum backup o cobre hoje, e ele NÃO é reconstruível a partir do Postgres: o event log guarda a narrativa, não os objetos do git. O Neo4j ganha reprojeção, não backup — restaurar uma projeção possivelmente defasada ao lado de um Postgres restaurado noutro instante gera dois estados derivados de momentos diferentes sem nada que os reconcilie; esta fase declara o caminho e aceita que uma instalação migrada começa com o grafo VAZIO, o que é degradação conhecida (o RAG mora no pgvector, dentro do dump), não perda de dados. O disco vira destino válido para que migrar não exija bucket. A restauração do compose reusa as MESMAS três validações de restore.sh:139-224 — o invólucro muda, o julgamento não — e deploy/k8s/test-restore.sh fica intocado. O instalador só ganha o direito de apagar volumes porque provou que consegue trazê-los de volta
0153DEPLOY_ENABLED é citado como gatilho de ativação por dez documentos — os ADRs 0089/0091/0092 no próprio campo Status, fluxo.yml em três lugares (platform.ativacao, area-infra.gate_saida, e as DUAS entregas de lacuna de secops-runtime), gates.yml no gate operavel, mais business-rules.md, backlog.md, modelo-de-time.md, auditoria-fluxo-vs-codigo.md, historico-de-fases.md e CLAUDE.md — e não existe em lugar nenhum do código: as duas únicas ocorrências são COMENTÁRIOS (external-effect.ts:144, listar_metricas_de_produto.ex:185). Dez documentos apontam para um interruptor sem parede atrás. Um achado que não é sobre deploy: o gate deployavel é declarado como gate_saida da area-infra em fluxo.yml:247 e está ausente do registro gates.yml, que é exatamente a inconsistência que o cruzamento (ainda no backlog) entre as três peças do modelo de time pegaria — o segundo achado desse tipo. A decisão se divide em duas com uma fronteira rígida: a Etapa A, decidível hoje, acrescenta deployavel ao registro como planned/warn (as RN-070/071 proíbem promover a block sem script de verificação), deixa intocados os dois scripts antecipados, e faz a prosa parar de tratar DEPLOY_ENABLED como variável, nomeando no lugar o MARCO que ele sempre quis dizer; a Etapa B é bloqueada por um operador, não por código — um ambiente de produção com tráfego real, depois do qual platform sai de planned e as lacunas ficam possíveis. A infraestrutura NÃO é o gargalo (overlays, imagens no GHCR por digest desde o 0119, scripts de smoke/rollout/restore, tudo existe) — o que falta é alguém rodar e manter. A alternativa CONSIDERADA E RECUSADA foi criar a variável agora com padrão false: uma variável que nenhum código lê é a mesma promessa vazia que este ADR denuncia, agora com uma linha de configuração a escorando; a flag nasce no PR que tiver o primeiro consumidor real. Recusa explicitamente: criar um agente platform/secops-runtime no engine (repetindo a recusa dos 0091/0092, que continua valendo), e marcar MTTR/change failure rate como permanentemente imensuráveis quando só dependem de um ambiente
0155O instalador cria a primeira conta, no terminal, e sobe o agente local já pareado. O achado que força isso: uma instalação nova de uma linha não deixa ninguém entrar. O .env gerado não leva variável de e-mail nenhuma, MAIL_TRANSPORT cai em log (docker-compose.install.yml:200, cujo próprio comentário diz que o comportamento "continua só-log até aqui"), e o registro exige verificar um e-mail — que, como o docblock de provisionar-usuario.ts já registrava, "com o MailSender só-log não fecha sozinho". Então a instalação termina dizendo "Pronto" e o único caminho adiante é pescar um link de verificação em docker compose logs api. O provisionarUsuario existe exatamente para esse buraco — cria um usuário com a senha JÁ verificada — e os dois chamadores dele são o seed de demonstração e o smoke, nenhum deles uma instalação real. Decisão: o passo roda só quando não há usuário nenhum, por uma rota interna autenticada pelo BRABO_SERVICE_TOKEN que o instalador acabou de gerar e gravar em modo 600; não nasce rota pública de "criar o primeiro owner", porque uma rota pública que cria owner é uma corrida entre quem instalou e quem varreu a porta. A conta nasce verificada, e o motivo é o que a autoriza: o que a verificação de e-mail prova é "esta pessoa controla esta caixa de correio", enquanto quem roda o instalador já provou algo mais forte — controla a MÁQUINA, o .env com os cinco segredos e o daemon Docker. Exigir a prova mais fraca, por um canal que a instalação sabe estar desligado, é teatro — e o instalador DIZ isso na saída em vez de deixar a pessoa descobrir que a conta dela difere da de todo mundo. O consentimento é explícito e sem TTY o passo não acontece (a regra da RN-511, a forma do consentir-base.mjs): não existe caminho onde uma conta nasce com senha escolhida pelo script, nem gerada nem padrão. A senha é lida sem eco, confirmada, usada e descartada — nunca no .env, nunca no marcador, nunca num log; o marcador ganha o e-mail do owner, que é identificação, não segredo. Com a conta feita, uma chave de dispositivo de máquina é registrada e a unit por máquina instalada; o par é gerado NA máquina e a metade privada nunca viaja, mantendo verdadeira a frase com que a RN-519 recusou a visão de maintainer. O agente então sobe SEM projeto, e esse é o estado normal — ele espera, não emite falha, e o status diz "de pé, nenhum projeto em modo runner", um dos quatro estados que a RN-518 já distingue em vez de um quinto. Recusa: criar conta quando já existe uma, rota pública de primeiro owner, senha escolhida pelo script, afrouxar o registro normal, ligar o SMTP sozinho, e pedir credencial de LLM
0160O compose do instalador viaja com ele, como asset assinado da Release. A primeira execução do instalador numa máquina limpa (RN-549) achou que o install.sh publicado não subia nada sozinho: ele rodava docker compose -f docker/docker-compose.install.yml, um caminho relativo a onde quer que rodasse, e esse arquivo não era asset da Release, não estava no checksums.txt assinado e não era baixado em lugar nenhum — então quem seguia a linha única do runbook morria com "no such file or directory" depois de o .env com cinco segredos já estar escrito. Decidido pelo mantenedor: quatro arquivos viram assets no mesmo manifesto assinado — o compose, os dois arquivos que ele monta por bind-mount (postgres/init.sql, ollama/pull-models.sh) e, medido durante a implementação, o backup/test-restore-compose.sh que o caminho de migração executa, que o contorno manual do E2E nunca levou porque uma máquina limpa não tem nada a migrar. Os nomes levam o prefixo brabo-install- porque uma Release é plana e nomes genéricos colidiriam. A tabela mora em dois lados — scripts/ci/assets-do-instalador.ts para quem publica, um case no install.sh para quem baixa (bash 3.2, sem Node) — e um spec reprova quando eles divergem e deriva todo bind-mount relativo do compose, que é exatamente como o defeito nasceu. O job checksums os publica a partir do checkout da tag e agora roda sha256sum -c --strict no manifesto antes de anexar. O instalador baixa e confere os quatro logo depois de se conferir, contra o MESMO manifesto, por nome exato, com três recusas nomeadas distintas, todas com a máquina intocada; COMPOSE_DE_INSTALACAO nasce vazio e só vira caminho absoluto quando as cópias conferidas são postas sob a pasta de instalação, substituindo — nunca lendo — o que estava lá. O E2E larga o passo manual e afirma a ausência dele. Recusa: clonar o repositório na tag (troca a cadeia de assinatura por confiança no transporte do git, mais uma dependência que uma máquina limpa pode não ter), um tarball (uma linha de manifesto cobrindo quatro arquivos sem nome), embuti-los no install.sh, uma flag apontando para a URL de outra Release, e rodar o E2E em pull requests. Declarado: provado de ponta a ponta só na próxima tag final; nenhuma Release já publicada ganha os assets; e, medido e não corrigido, o test-restore-compose.sh chama o Compose sem --env-file, então a prova de restauração da migração tende a falhar de forma segura
0161As telas só oferecem o modo de execução que esta instalação consegue rodar, e uma instalação a partir de Release não tem broker de container. Uma instalação real da v6.1.0 (AT-085) criou um projeto em mounted — PRÉ-selecionado, porque o install.sh sempre grava BRABO_PROJECTS_BASE e a RN-513 pré-seleciona esse modo quando a base é conhecida — e recebeu seis dev.blocked_by_container mais dois container.start_failed: container e mounted só sobem container pelo broker (ADR 0144), o compose de instalação não tem serviço broker nenhum (não um profile desligado — decisão 7 do ADR 0150), e sem container rodando nenhum dev agent trabalha (ADR 0143). O mantenedor decidiu "os dois": o instalador pergunta se habilita o broker, e as telas param de oferecer o que não roda. A metade do instalador foi medida e parou: a imagem do broker não é obtenível por uma instalação a partir de Release — quatro alvos de bake, nenhum broker em images-manifest.ts nem no images.json da v6.1.0, docker manifest inspect ghcr.io/daneiel/brabo-broker:6.1.0 responde denied enquanto a mesma chamada anônima para brabo-api resolve, e a linha única roda numa pasta sem código-fonte de onde construir. Uma pergunta cujo "sim" não tem imagem é pior que nenhuma; publicar o broker como quinta imagem vem antes, como frente própria. A metade da tela entra: brokerConfigurado (de ContainerBrokerPort.configurado(), a mesma fonte que a leitura de estado observado usa) entra em GET .../projects-base e em toda linha de GET .../containers — sem rota nova. O wizard tem três estados que nunca colapsam: broker confirmado mantém a RN-513; ausência confirmada mantém Container e Montado na tela mas inertes, diz por quê uma vez em texto, e pré-seleciona Runner; desconhecido nem pré-seleciona Montado nem trava nada. A /containers ganha uma quarta recusa local, sem_broker_na_instalacao, para container/mounted, antes da checagem de imagem. A RN-512 e as cinco camadas de contenção ficam intocadas. Fora de escopo, declarado: a conversão de modo, parar/remover, o Infra Lead não saber do broker ausente, e a sugestão de paralelização que ignora agentes bloqueados
0162O broker de container vira a quinta imagem publicada, e o instalador pergunta se o liga. A metade do instalador do "os dois" do ADR 0161: numa instalação a partir de Release, projetos container e mounted nunca rodaram, porque quem sobe os containers deles é o broker (ADR 0144) e a instalação não tinha serviço de broker — medido, ghcr.io/daneiel/brabo-broker respondia denied, o bake tinha quatro alvos. O Dockerfile.prod já existia e passava no hadolint; nada o construía. Agora o alvo de bake broker passa por todo portão que os outros quatro passam — construído, varrido pelo Trivy e recusado como root em todo PR, provado saudável somente leitura e sem rede, publicado, gravado no images.json, assinado e verificado por digest em toda tag. O compose de instalação ganha o serviço desligado, sob o profile container-broker, com as cinco camadas do ADR 0130 intactas; o overlay do Kubernetes não o conhece. O instalador pergunta depois da base, diz em texto que ligá-lo entrega o socket do Docker da máquina ao serviço, só o liga com um "s" digitado, mede o gid do socket de dentro de um container (recusa nomeada em caso contrário, nunca um 999 adivinhado), grava COMPOSE_PROFILES/BROKER_URL/DOCKER_GID/a raiz gerenciada juntos, e confere depois da subida que a api alcança o broker e que a raiz calculada é a do volume. O argumento da RN-512 (quem instala na própria máquina já tem o socket) é por que ele pode ser oferecido; o leitor do instalador nunca ter aberto este repositório é por que ele é perguntado, não presumido. Preço, declarado: uma imagem equivalente a root é pública, e uma instalação de usuário monta o socket do Docker quando consente
0169O destino S3 de backup do overlay local deixa de ser o MinIO e vira o SeaweedFS (docker.io/chrislusf/seaweedfs 4.47, pinado por digest de ÍNDICE, server -s3 -s3.port=9000). O MinIO parou de publicar a imagem da comunidade — o digest pinado responde 401 no quay.io e o repositório do Docker Hub 404 —, então o bootstrap morria no rollout status e nenhuma prova de propriedade rodava (issue #633). Medido contra os critérios do mantenedor, em ordem: o SeaweedFS passa nos quatro (oficial, anônimo, índice amd64+arm64; o fluxo real do lib.sh pelo aws-cli — autenticação, mb, upload multipart de 30 MiB por stdin, head-object, download conferido por sha256, cópia do lado do servidor, list-objects-v2, rm; só o servidor muda; Apache-2.0); o Garage é descartado no terceiro (arquivo de config mais layout pós-boot e importação de chave); o Bitnami, o espelho legado congelado dele e o MinIO só :latest da Chainguard, no primeiro. Os recursos ganham um nome neutro (s3-local); o cliente, os scripts de backup, o Secret e a porta não mudam. Declarado: um digest garante imutabilidade, não disponibilidade; a compatibilidade S3 é por operação, então uma operação nova no lib.sh é medida contra ele primeiro
0170O compose de dev vira o projeto Docker brabo-dev, e o dev se recusa a subir ao lado de uma instalação. Medido em 2026-09-19: o compose de dev e o compose de instalação eram os dois name: brabo — UM projeto Docker numa máquina com os dois, compartilhando containers, rede e volumes (brabo_pgdata…); o banco de uma instalação recebeu migration do dev, e o reset-total.sh teria apagado os schemas dele. A instalação continua brabo (renomeá-la desligaria toda instalação existente dos próprios volumes); o dev é que muda de nome, e o overlay de observabilidade repete o nome porque o último -f vence. O preflight.mjs e o reset-total.sh RECUSAM, nomeando os containers e o down (sem -v) de cada arquivo, enquanto existir qualquer container cujo rótulo config_files aponte para o compose de instalação (em qualquer estado, sob qualquer -p), ou enquanto o compose de dev resolveria para brabo; a guarda do reset roda antes de tudo. Os volumes brabo_* antigos do dev só geram AVISO — chaves que só o dev tem, derivadas dos dois arquivos de compose, os provam, chaves compartilhadas continuam ambíguas e o aviso diz isso — e o runbook leva o procedimento de copiar ou recomeçar. Preço declarado: desenvolver numa máquina com uma instalação exige derrubar a instalação antes
0209O engine passa para Elixir 1.20.4 / OTP 29.1.1 sobre Alpine 3.24.2 em dev, produção e CI (AT-398): o OTP 27 abortava no boot com sys_sigaltstack(): Internal error sob a musl 1.2.6 do Alpine 3.24 em hosts com AMX (erlang/otp#11349), corrigido só a partir do OTP 29.1; o preço é reconstruir os volumes _build/deps de dev.

CI, release e cadeia de suprimentos​

O pipeline, as imagens publicadas, a assinatura, a varredura, o pin, as camadas de teste e a estrutura do próprio repositório.

#decisão
0039actionlint pinado no Dockerfile do engine (mesmo padrão do gitleaks/hadolint), validação do pipeline de CI na GERAÇÃO em vez de num gate pós-PR novo, e o gap documentado de .gitlab-ci.yml sem linter estático offline equivalente
0119O release.yml para de construir as quatro imagens de produção só para provar que a tag é construível e as PUBLICA no GHCR a cada tag final, públicas, fazendo login com o próprio GITHUB_TOKEN do job (packages: write) — nenhum segredo novo. O prefixo de registry e a saída do build viram variáveis do bake (REGISTRY/OUTPUT), as duas vazias por padrão para que o ci.yml continue construindo os nomes locais curtos que ele varre; type=registry não é otimização, é o único modo em que o bake devolve containerimage.digest, porque o digest nasce do push. O que cada tag publicou é gravado POR DIGEST em .release/images.json — um asset da Release, que entra no repositório no PR do CHANGELOG, SEM terceira exceção de push. O overlay mantém REPLACE_WITH_DIGEST como marcador e make imagens-do-release aplica o digest com kustomize edit set image; o repositório nunca declara qual release está em produção. Achado no caminho: os overlays listavam três imagens, não quatro — o CronJob de backup herdava brabo-backup:prod, um nome que não resolve em registry nenhum
0120Uma camada de E2E de navegador (e2e/, Playwright, só chromium) fecha o único buraco que restava no topo da pirâmide: nada exercitava um navegador de verdade, e os mecanismos que carregam a sessão são mecanismos de navegador — cookie de refresh httpOnly pareado com um brabo_csrf legível, chamadas entre origens de :8088 para :3000 (preflight, SameSite, credentials: 'include'), e o ticket de socket de uso único da RN-108 fazendo handshake contra o engine numa TERCEIRA origem. O jsdom não impõe nenhum deles, então um teste que os afirmasse ali passaria sem provar nada (o main.ts já diz: "teste não faz preflight"). Ele roda contra o compose de PRODUÇÃO que o docker/smoke.sh já sobe (SMOKE_KEEP_UP=1) e no MESMO job images, porque as quatro imagens são a maior parte do tempo desse job e um job separado as reconstruiria para chegar à stack idêntica. e2e/ NÃO é membro do workspace — pnpm-workspace.yaml e lockfile próprios, mesma forma do website/ (ADR 0117), pelo mesmo motivo: a árvore do Playwright não chega a imagem nenhuma e não tem o que fazer no pnpm audit do produto. Os specs afirmam MECANISMO (a flag httpOnly como o navegador a informa, document.cookie como a página o vê, a sobrevivência a um reload, ticket= na URL do WebSocket) com seletores estruturais, nunca texto — o idioma da interface é decisão do servidor. O seed vai por HTTP, espelhando o smoke. Declarado como não coberto: diferenças entre navegadores, aprovação inline e streaming
0121apps/api/src/db/schema.ts — 2 452 linhas, 51 tabelas, 34 enums, o arquivo mais alterado do repositório — se divide num arquivo por AGREGADO DE DOMÍNIO sob db/schema/, espelhando src/domain/* em vez de inventar uma taxonomia que o arquivo já mantinha à mão em comentários // --- section ---. O caminho antigo vira um barrel de 25 linhas de export *, então os 144 módulos que importam …/db/schema não mudam nada, o import * as schema de drizzle-client.ts vê o conjunto idêntico de exports, e o drizzle.config.ts continua apontando para ele (verificado: o drizzle-kit segue a cadeia de export * e informa "No schema changes, nothing to migrate"). Puramente mecânico — nenhuma tabela, coluna, valor de enum, nome de índice ou expressão de constraint mudou, e a régua de aceite é a migration de diff ZERO, mais estrita que a suíte porque o Drizzle compara o SQL inferido. Enums moram com a tabela que os chama, não com o assunto: uma FK entre arquivos é segura num ciclo (.references() é callback preguiçoso), um ENUM entre arquivos não é (roda na avaliação do módulo), então project_execution_mode/story_promotion ficam em iam.ts com o único consumidor e failure_origin em backlog.ts, mantendo o grafo de imports um DAG. O backup.ts é o único arquivo sem par em domain/ — infra sem regra de negócio, e uma fronteira errada custaria mais que um arquivo pequeno. O docs/.docmap.yml ganha schema/** ao lado do barrel: sem isso a regra ficaria cega às constraints, que é o que ela existe para vigiar
0140Em 2026-09-04 o endpoint de advisories do npm (POST .../security/advisories/bulk) ficou intermitente por horas e reprovou o job de auditoria de dependências em QUATRO PRs separados com assinatura idêntica (error (503) e depois TimeoutError) — reproduzido fora do CI, de outra máquina, onde um GET simples ao registry respondia em 130ms e o POST em lote nunca respondia. O custo não foram os minutos perdidos, mas o hábito que isso ensina: todo vermelho desse tipo é limpo rodando de novo até ficar verde, e quem aprende isso roda de novo também por cima de um vermelho real. Decisão do dono do produto: três tentativas sem resposta do registry é RISCO ASSUMIDO — o job fica verde com um aviso alto que diz que a árvore NÃO foi auditada, nunca que está limpa. O que a decisão não afrouxa é o que a torna defensável: uma vulnerabilidade informada continua reprovando na PRIMEIRA tentativa sem nova tentativa, e uma falha não reconhecida reprova também (fail closed). A precedência é o coração disso — classificarAudit testa as assinaturas de ACHADO antes das de rede, então um relatório que por acaso menciona timeout (um pacote com esse nome, uma CVE de timeout) nunca é perdoado como indisponibilidade, e há um teste exatamente para isso. A lógica mora num script testável (11 casos, usando a saída REAL da execução 33838507158 em vez de paráfrases), não em bash dentro de YAML. Só o lado do pnpm muda; mix hex.audit/mix deps.audit ficam como estavam, já que alargar a exceção a quem não a pediu transformaria uma decisão pontual em política geral. Declarado e não resolvido: nada alerta quando o risco é assumido repetidamente — três timeouts num PR é ruído de terceiro, três em todo PR por uma semana é o portão desligado na prática, e hoje nada distingue os dois
0149O produto não assina nada do que publica: procurar cosign, sigstore, slsa e attestation em .github/, scripts/, docker/ e Makefile devolve uma ocorrência real, e ela não cobre nem imagem nem binário (npm publish --provenance para o pacote npm, publish-runner.yml:188). O release.yml constrói e empurra as quatro imagens (:286-303) e grava os digests delas em .release/images.json (:308-319) declarando só três permissões (:40-43) — sem id-token: write, que a assinatura keyless exige. O build-runner-binaries.yml sobe cinco binários com gh release upload (:227) e nenhum checksums.txt — enquanto as chamadas sha256sum -c que EXISTEM (ci.yml:201, Dockerfile.prod:95) verificam binários de terceiros, exatamente o padrão que o repositório conhece e nunca aplicou a si mesmo. E o GET /runner-releases/binary é @Public() e repassa os bytes do GitHub direto, sem checagem de integridade nenhuma. Esse é o BRB-005, e é o que bloqueou o instalador de uma linha na FASE 28. Decisão: cosign keyless (OIDC do Actions) assinando as quatro imagens por digest — nunca por tag, um ponteiro móvel — mais UM checksums.txt assinado cobrindo os cinco binários em vez de cinco assinaturas separadas (cinco artefatos a verificar são cinco jeitos de a verificação ficar pela metade). Três consumidores nomeados, cada um com uma recusa, nunca um aviso: o instalador, o proxy de download e o build local. Keyless e não um par de chaves gerenciado pelo motivo que o repositório já aprendeu do jeito difícil — uma chave privada quer dizer custódia, rotação e MAIS um segredo de CI, e um PAT expirando sem ninguém notar já reprovou duas execuções do tag-release (0139). O binário cosign que verifica é ele mesmo pinado e conferido com sha256sum -c contra um hash escrito no script versionado. Recusa: chaves gerenciadas, "assina agora, verifica depois", assinar por tag, e atestação SLSA/SBOM completa como eixo separado. Declara que o broker continua sem assinatura porque continua sem publicação — o bake tem quatro alvos. Emendado na sessão 3, que dividiu em duas a linha do proxy de download: ele verifica integridade (sha256 contra o checksums.txt da release, recusando — nunca avisando — quando o manifesto falta) e não procedência, porque os dois jeitos de a api conferir a assinatura foram medidos e recusados — o cosign na imagem tem 155 MB contra um runtime Alpine+Node, e o @sigstore/verify é barato (2,5 MB) mas faria uma rota @Public() depender de um segundo host de terceiro para verificar o que nenhuma Release leva ainda, o que os ADRs 0041/0042 proíbem declarar sem prova. Então o BRB-005 fecha quanto a produzir assinaturas e continua aberto para essa metade do proxy (RN-525)
0159Imagens de terceiros são pinadas por digest, com a tag num comentário ao final, e um lint reprova a próxima regressão. Isto revoga uma decisão que este repositório tinha escrito: a página de cadeia de suprimentos chamava o pin por tag de "uma parada deliberada, não um descuido", comprando reprodutibilidade no dia a dia "sem o custo de manutenção de digests em imagens que não publicamos". As duas metades dessa frase caem diante da medição. São 37 referências de terceiros, e três dos lugares onde elas rodam não são o dia a dia: treze são linhas FROM de Dockerfile, que são a base das quatro imagens que publicamos no GHCR e assinamos com cosign (RN-524); quatro estão no docker-compose.install.yml, que roda ao lado do Postgres de quem instalou o produto, no mesmo arquivo onde as quatro imagens próprias já chegam por digest via variável; e quatro são services: em ci.yml/golden-set-rag.yml, que é literalmente o runner que a regra das actions existe para proteger, alcançado pela outra porta — nenhum dos três estava na própria evidência do BRB-004. O custo de manutenção é real e é o MESMO já aceito para os SHAs das actions. A checagem é irmã do actions-pinadas.ts, não uma extensão: uses: é uma sintaxe no YAML de workflow, imagens são outras três entre composes, manifestos do kustomize e Dockerfiles, e uma função respondendo às duas perguntas responde mal às duas. Ela reprova uma referência mutável, um digest sem comentário de tag, e a mesma tag levando dois digests diferentes — esta terceira transforma uma promessa escrita num comentário (o golden-set-rag.yml afirmando rodar a "MESMA versão pinada" do compose de dev, porque o piso do golden-set é chaveado por modelo, não por ambiente) em algo verificável. O digest é o do índice, nunca o de uma plataforma: pinar linux/amd64 quebraria o linux-arm64 sem aviso. Num Dockerfile o comentário de tag vai na linha de cima, e isso foi aprendido errando: o parser do Docker só aceita # no começo da linha, então um ao final é um FROM com três argumentos e o build morre — enquanto o hadolint passava o mesmo arquivo, porque o linter concordar não é o build concordar. A checagem reconhece a forma ao final só para recusá-la, já que um padrão que simplesmente não casasse faria a referência sumir da varredura em vez de ficar vermelha — trocando um erro por silêncio, que é o defeito que ela existe para impedir. O comentário é um token só nas duas formas, que é o que separa uma tag da prosa que já fica acima de quase todo FROM. A checagem deliberadamente não cobre as quatro imagens que o produto publica, por três motivos independentes — nenhum terceiro as pode mover; brabo-api:prod é uma tag local cujo digest não existe antes do build e muda a cada build; e onde elas atravessam um registry, o ADR 0119 já as resolve por digest, com o overlay deliberadamente guardando o MARCADOR em vez de uma release congelada, então exigir um literal ali reprovaria o estado correto. O mesmo para referências interpoladas e estágios de multi-stage. A lista de exceções é por nome e falha fechada. O compose de observabilidade era um TODO(humano) aberto e entra: excluí-lo custaria uma isenção nomeada dentro de docker/, que é exatamente a forma de buraco que o docblock da irmã diagnostica ("um workflow novo é escrito copiando um vizinho, e o vizinho tinha uma tag"). Declarado e não corrigido: um digest não recebe atualização de segurança até alguém subi-lo à mão — a dívida dos SHAs de action de novo — e o ecossistema docker do Dependabot não está habilitado, uma decisão à parte; as bases de dev (node:24-alpine) congelam mais que as de produção, que é a consequência menos confortável e é aceita; e o otel-collector-values.yaml, onde image: abre um mapa cuja tag o chart escolhe, fica sem cobertura. Nenhuma RN nasce: a regra irmã também não tem, e as duas moram no CLAUDE.md e na página de cadeia de suprimentos. O BRB-004 deixa de reproduzir
0172O release roda o Trivy no que PUBLICA, por digest e antes de assinar (AT-179): até então só o ci.yml varria, e ele varre um build LOCAL de PR, enquanto uma tag constrói a frio noutra execução (AT-110) — a imagem que o instalador baixa era a que ninguém varreu. O portão fica entre gravar .release/images.json e cosign sign, então uma imagem que reprova nunca é assinada; ele usa as flags do ci.yml byte a byte (--severity HIGH,CRITICAL --ignore-unfixed --exit-code 1, o mesmo .trivyignore.yaml, nenhuma allowlist nova) e o código de saída do Trivy é o veredito. HIGH/CRITICAL sem correção é INFORMADO — resumo do job e um asset trivy-sem-correcao.md da Release montado por scripts/ci/trivy-do-release.ts — e não bloqueia. Preço declarado: o push já aconteceu, então uma tag reprovada deixa imagens sem assinatura no GHCR sem Release; e versão, hash e flags moram em dois workflows, guardados por um spec. Medido contra os digests da v6.1.0: api e web reprovariam hoje na CVE-2026-45447 (openssl, corrigida em 3.3.7-r1).
0178Uma referência de imagem de terceiro vira image:tag@sha256:<index> — a tag sai de um comentário e vai PARA DENTRO da referência, antes do digest (AT-139, sobre o ADR 0159; a decisão do mantenedor o chamou de "ADR 0171", um número que nunca foi escrito e fica vago). Medido em 2026-09-27: o Dependabot não lê comentário de tag em formato nenhum e move um pin só de digest para o digest do latest, o que teria levado pgvector, neo4j e node para outra major com o comentário ainda mentindo e o lint verde. As 39 referências migram com os MESMOS digests (29 em docker/, 4 em deploy/k8s/, 6 em workflows), comentários removidos; o imagens-pinadas.ts deriva a tag da referência e reprova um digest sem tag inline (a forma do ADR 0159 inclusive), um comentário de tag que discorda, um comentário ao final de um FROM e a mesma tag inline com dois digests — cada regra provada por mutação. NÃO feito, e declarado: habilitar o docker/docker-compose do Dependabot, porque o passo decidido que alinha os services: dos workflows no PR do bot teria de empurrar para .github/workflows/, o que o GITHUB_TOKEN nunca consegue — a saída é do mantenedor. O imageName do CNPG e a imagem do golden-set do QA continuam manuais de qualquer jeito
0197Os services: dos workflows leem a imagem do compose de dev, e os ecossistemas docker/docker-compose do Dependabot são ligados (AT-246, decisão do dono de 01/10, sobre o ADR 0159 e o ADR 0178). Os seis literais (pgvector e ollama no ci.yml e nos dois golden-sets) dão lugar a ${{ needs.imagens.outputs.<image> }} vindo de um imagens-do-compose.yml reutilizável que roda scripts/ci/imagens-do-compose.ts — uma fonte de digest, então o PR do bot nunca toca .github/workflows/, e o "mesmo Ollama do dev" do golden-set-rag.yml passa de promessa a construção. O imagens-pinadas.ts agora reprova, num workflow, qualquer literal de imagem, qualquer expressão que não seja exatamente essa (um literal, `

Documentação​

Como a documentação é gerada, verificada e publicada.

#decisão
0029Sincronização contínua da documentação: gerar > verificar > lembrar, mapa de responsabilidade validado no CI, e escape hatch obrigatório
0033A referência de API sai do código: DTOs de resposta que espelham a entidade por tipo, o teste de tabela cobrando summary e resposta de verdade (a sintetizada pelo Swagger não conta), e um manifesto de hashes no lugar de regerar no --check
0034Documentação publicada por degrau em subdiretório do Pages: baseUrl por variável, noIndex fora da main com forceIgnoreNoIndex na busca local, e a terceira exceção de push direto
0071Publicação simétrica por degrau, e uma raiz que escolhe. O 0034 pôs cada permanente publicando a própria documentação, mas deixou o ENDEREÇO assimétrico: main na raiz, qa e dev em subdiretório. A assimetria cobrava três preços — os três sites não se enxergavam (quem caía em /brabo/ não tinha como chegar na qa), o caso especial da raiz obrigava um passo do workflow que rodava só num terço das publicações, e não havia onde declarar maturidade. Os três passam a /brabo/<branch>/ e a raiz vira um ÍNDICE gerado, não um redirecionamento: redirecionar seria mais simples e engoliria a pergunta que leva alguém a olhar mais de um degrau ("a qa já tem o que eu preciso?"). A página oferece o que EXISTE na árvore, nunca uma lista fixa, e mostra a versão carimbada lida das tags. Um 404.html na raiz segura todo link profundo antigo (/brabo/architecture → /brabo/main/architecture), com guarda contra laço para o 404 que acontece DENTRO de um degrau. A mudança que mais importa é invisível: o site sabia se era produção comparando BASE_URL === '/brabo/', e com os três em subdiretório isso vira falso para a main também — noIndex na documentação REAL, fora do Google, em silêncio e com o CI verde, porque nada no build reprova por indexar de menos; o degrau passa a ser DECLARADO em DOCS_BRANCH. A árvore é montada para os três (não keep_files: true, que nunca remove órfão), o que traz a migração de graça: o lixo que a main deixou na raiz não está na árvore montada e sai no primeiro push. E a transição semeia /main/ da raiz antiga — sem isso, o primeiro push de dev deixaria /brabo/main/ inexistente justamente enquanto o 404.html reencaminha para lá
0073O caminho publicado nomeia o AMBIENTE, não a branch. Revisa o 0071: ele pôs os três degraus em /brabo/<branch>/ por interpolação de $GITHUB_REF_NAME, e com isso a documentação estável ficou em /brabo/main/ — main é palavra de quem commita, e quem lê está escolhendo maturidade, não branch. main passa a publicar em /brabo/prd/; qa e dev coincidem por acaso, não por regra. O mapa branch→caminho passa a EXISTIR num ponto por processo (o passo do workflow, a tabela DEGRAUS do docusaurus.config.ts e a do landing.mjs, esta última separando o diretório da árvore da branch de onde sai a TAG da versão). E_PRODUCAO continua sendo DOCS_BRANCH === 'main' — o item 4 do 0071 existe justamente para o dia em que o caminho muda, e este é o dia. Duas armadilhas tratadas: /brabo/main/ está publicado e some com o keep_files: false, então o 404.html REESCREVE o prefixo (caso próprio, não o reencaminhamento genérico, que produziria /brabo/prd/main/…) e a guarda anti-laço passa a cobrir só os caminhos que existem; e a transição semeia /prd/ do gh-pages:main, senão /brabo/prd/ responderia 404 por dias entre o primeiro push de dev e a próxima promoção
0117O website/ sai do pnpm-workspace.yaml da raiz e ganha pnpm-lock.yaml próprio, para que o pnpm audit da raiz pare de relatar a árvore de dependências do Docusaurus, que nunca é entregue. Auditar os 13 overrides de segurança da raiz com pnpm why <pkg> -r (não relendo os comentários deles) achou que a maioria dos atribuídos só ao website/ na verdade é MISTA — mermaid/dompurify/uuid também resolvem pelo mermaid de runtime do próprio apps/web (ADR 0068), postcss/nanoid pelo vite do apps/web, js-yaml/fast-uri/lodash pelas cadeias eslint/ajv/@nestjs/swagger do apps/api — então esses ficam na raiz E são duplicados num bloco pnpm.overrides novo em website/package.json; só serialize-javascript e a faixa yaml@1.x eram de fato exclusivos do website e saíram de vez; o esbuild ficou só na raiz, ausente da árvore do website/. pnpm --filter website (só da raiz, exige pertencer ao workspace) vira pnpm --dir website em todo script docs:* e em docs-deploy.yml/docs-check.yml, que ganham cada um um segundo passo pnpm install, separado, com escopo em website/
0202O índice de ADR é agrupado por TEMA, com o tema FORA do ADR (AT-137, decisão do dono de 01/10, achado BRB-026): docs/adr/temas.yml guarda a lista fechada de 15 temas e um tema principal por ADR, para que nenhum ADR aceito seja editado, nem para ganhar frontmatter. O índice é CONFERIDO, não gerado — as descrições de uma linha continuam curadas — e o docs:check (scripts/docs/temas-de-adr.mjs, cada regra provada por mutação) reprova ADR sem tema, tema fora da lista, tema sem ADR, e linha do índice na seção errada, fora de qualquer seção ou fora da ordem numérica. A taxonomia vem das frentes do produto, não das fases nem das pastas do código, e é a proposta da lane para o dono revisar; o autor de um ADR novo escolhe o tema dele no mesmo PR, e passar de 15 temas exige um ADR
0206A referência da API vira UMA página Redoc renderizada no servidor (redocusaurus) em /reference/api/, revisando a metade de publicação do 0033: o plugin e o tema de OpenAPI arrastavam postman-collection → @faker-js/faker@5.5.3 (alerta High #43) sem caminho de correção. As páginas por operação, a sidebar por tag, as URLs por operação, o playground e os trechos de cURL saem por decisão do dono; o api-render-check.mjs agora afirma o título e uma seção SSR por operação
0216Quatro idiomas para a documentação e a interface — o pt-BR vira a FONTE e o padrão, com inglês, russo e chinês simplificado como traduções em fases, paridade medida e um aviso por página para páginas não traduzidas ou defasadas (AT-545, decisões do dono de 15/10)
0218Todo ADR se chama "ADR NNNN — título": o H1 ganha o prefixo ADR, número de 4 dígitos e travessão; o nome do arquivo (a URL) não muda, e normalizar o rótulo do título não é editar a decisão (AT-559, decisão do dono de 15/10)
0219A referência entra na tradução: docs/reference/** passa a ser fonte pt-BR traduzida e medida na paridade, inclusive o bloco gerado, menos o próprio relatório de paridade e o openapi.json; a página Redoc fica para a AT-565 (AT-564, decisões do dono de 15/10)
0220A página da API sai traduzida por catálogo: a fonte segue sendo o texto dos decorators da api (exceção à fonte pt-BR), um catálogo por locale chaveado pelo hash do texto, spec derivada no build e fora do git, órfã reprova e faltando avisa, rótulos fixos do Redoc em inglês (AT-565, decisões do dono de 16/10)

A convenção​

  • Um arquivo por decisão, em docs/adr/NNNN-short-title.md, com numeração sequencial de 4 dígitos, e título # ADR NNNN — <título> (0218). Sem reuso de número, nem quando um ADR é substituído — o próximo é 0236.
  • Um tema por decisão, escolhido por quem escreve o ADR, no mesmo PR: uma linha em docs/adr/temas.yml e a linha do índice sob a seção desse tema, em ordem numérica. Quem revisa confirma. Um tema novo é uma mudança na lista em temas.yml, justificada no PR que o acrescenta; passar de 15 exige um ADR que revise o 0202.
  • Três seções, só elas: Contexto (o problema ou a força que o motivou), Decisão (o que foi decidido), Consequências (os trade-offs aceitos e o que ficou para depois).
  • Sem template de terceiros — nada de Nygard, MADR ou variantes. As três seções bastam.
  • Em português, como o resto do projeto.
  • ADR aceito não é editado. Um substituído por decisão posterior fica como está; o ADR novo referencia o antigo e explica o que mudou.

O que dá valor a estes ADRs é registrar o que não funcionou: o 0020 documenta nove execuções fracassadas seguidas, e o 0027 registra uma justificativa que foi revista dentro da mesma sessão que a produziu. Uma consequência ruim omitida é a parte que faz falta seis meses depois.