API — parte 1 (desde RN-001)
Sessão
O Brabo executa trabalho de engenharia por agentes de IA. Duas forças moldam praticamente toda regra daqui:
O agente não é confiável por construção. É um modelo de linguagem: pode alucinar, entrar em loop ou pedir algo destrutivo. As regras existem para que o dano possível seja limitado pela estrutura, não pela qualidade do prompt.
O gasto é real e contínuo. Todo turno consome token pago. Orçamento, teto e medição não são conveniências administrativas — são o que impede um loop de agente de virar prejuízo.
Atores
| ator | quem é | autoridade |
|---|---|---|
| Usuário | a pessoa. Papéis: viewer, developer, maintainer, owner |
decisão final sobre qualquer ação com efeito externo; o merge é exclusivamente dele |
| Agente | processo de longa duração com um papel (Criativo, PO, Arquiteto, Dev, Infra, QA, SecOps, Psicólogo, Anamnese) | propõe; nunca decide sozinho o que a política não permite |
| Sistema | api e engine | aplica a política, registra o evento, mede o custo |
O vocabulário está no glossário.
Contexto de negócio
RN-001 — A sessão tem cinco estados e transições fechadas
created → active → closing → closed, com closed_abnormally alcançável
de qualquer estado não terminal. De closing nunca há volta para
active, e os estados terminais não têm saída.
stateDiagram-v2 [*] --> created created --> active created --> closed_abnormally: falha no provisionamento active --> closing active --> closed_abnormally closing --> closed closing --> closed_abnormally closed --> [*] closed_abnormally --> [*]
- Onde:
src/domain/sessions/session-state-machine.ts:29 - Teste:
test/domain/sessions/session-state-machine.spec.ts - Caso de borda:
closingé um estado transitório. Uma sessão presa nele significa que o dreno começou e não terminou — há alerta para isso, ver o runbook.
RN-002 — Todo evento de sessão é imutável e o seq é denso
Nunca há UPDATE em session_events. O seq é único por sessão
(unique(session_id, seq)) e não tem buracos: começa em 1 e é contíguo.
- Onde:
src/db/schema/sessions.ts(tabelasession_events) - Teste: conferido no restore —
docker/backup/restore.shfalha secount(*) ≠ max(seq) − min(seq) + 1oumin(seq) ≠ 1 - Por quê: é o que torna rastreável a evidência do Psicólogo e verificável o backup. Estado que precisa mudar mora em tabela própria, ao lado.
Aprovação de ações
O coração do sistema. Toda ação com efeito externo nasce como
proposed_action.
RN-003 — A ação tem seis estados, e negada é terminal
stateDiagram-v2 [*] --> pending [*] --> auto_approved: a política permitiu pending --> approved: usuário aprova pending --> denied: usuário nega approved --> executed approved --> failed auto_approved --> executed auto_approved --> failed denied --> [*] executed --> [*] failed --> [*]
- Onde:
src/domain/actions/action-state-machine.ts:36 - Teste:
test/domain/actions/action-state-machine.spec.ts - Caso de borda: uma ação aprovada que executou vira
executed, não continuaapproved. Contar aprovações porstatus = 'approved'dá o número errado — o critério correto édecided_by IS NOT NULL.
RN-004 — A decisão é avaliada em três estágios, e deny vence na hora
Ordem: (a) IAM → (b) agent_autonomy → (c) permissions.json. Cada
estágio só pode subir a permissividade; um estágio calado nunca rebaixa o
anterior. deny em qualquer estágio retorna na hora.
- Onde:
src/domain/actions/decide.ts:120 - Teste:
test/domain/actions/decide.spec.ts(10 KB — o maior do domínio)
RN-005 — Papel mínimo por tipo de ação
Antes de qualquer política, o IAM: cada ActionType exige um papel efetivo
mínimo. Sem ele, deny com motivo explícito.
- Onde:
src/domain/actions/decide.ts:117(MIN_ROLE_FOR_ACTION_TYPE) - Teste:
test/domain/actions/decide.spec.ts
RN-006 — Teto: merge em branch protegida nunca é auto-aprovável
dev, qa, rc e main são protegidas. Um git_merge que mira uma delas
é rebaixado de auto_approve para require_approval depois que toda a
política rodou. Nem agent_autonomy nem permissions.json conseguem
promovê-lo.
rc continua na lista mesmo depois que a etapa saiu da política
(ADR 0030) e o bootstrap
deixou de criá-la (RN-029). Esta lista decide o que a trava
recusa, e repositórios criados por versões anteriores ainda têm a
branch: proteger uma que não existe não custa nada; desproteger uma que
existe custa caro.
- Onde:
src/domain/actions/decide.ts:523(isProtectedBranch, o teto da trava de merge) +protected-branches.ts:4 - Teste:
test/domain/actions/decide.spec.ts - Origem: ADR 0011 §1
- Nota: a proteção equivalente na plataforma (GitHub/GitLab) diverge entre os providers e não é o portão — ver ADR 0028.
RN-007 — Teto: patch de instrução nunca é auto-aprovável
Mudar a instrução de um agente sempre exige decisão humana. O valor da funcionalidade está no humano ver o diff; auto-aprovar seria o agente se reescrevendo.
- Onde:
src/domain/actions/decide.ts:170 - Teste:
test/domain/actions/decide.spec.ts - Origem: ADR 0016 §8
RN-008 — O casamento de comando é por padrão, não por substring
O permissions.json casa um comando de terminal por padrão estruturado, com
o comando devidamente tokenizado — não por includes().
- Onde:
src/domain/actions/command-matcher.ts - Teste:
test/domain/actions/command-matcher.spec.ts
Backlog
RN-009 — A história tem quatro estados, e done é terminal
draft → ready → in_progress → done. Retrabalho é permitido: ready → draft e
in_progress → ready.
stateDiagram-v2 [*] --> draft draft --> ready: passa na checagem de prontidão (RN-010) ready --> in_progress ready --> draft: retrabalho in_progress --> done in_progress --> ready: devolvida done --> [*]
- Onde:
src/domain/backlog/story-state-machine.ts:27 - Teste:
test/domain/backlog/story-state-machine.spec.ts
RN-010 — draft → ready exige quatro coisas, validadas no domínio
Para uma história virar ready ela precisa, de todas:
dodnão vazio — Definition of Donedornão vazio — Definition of Ready- pelo menos 1 requisito funcional (
rf) - pelo menos 1 regra de negócio ligada (
businessRuleIds)
O erro nomeia exatamente o que falta, não um "inválido" genérico.
- Onde:
src/domain/backlog/story-readiness.ts:39 - Teste:
test/domain/backlog/story-readiness.spec.ts - Origem: ADR 0009 §3
- Por quê: é o que impede o PO de despejar uma história vaga na fila do dev.
RN-011 — Regra de negócio sem história é achado, não erro
A cobertura regra→história é calculada, e o que não tem história aparece como pendente — informação, não falha.
- Onde:
src/domain/backlog/coverage.ts - Teste:
test/domain/backlog/coverage.spec.ts
RN-013 — O grafo de módulos não pode ter ciclo
- Onde:
src/domain/architecture/module-graph.ts - Teste:
test/domain/architecture/module-graph.spec.ts
Gates de PR
RN-014 — A ordem dos gates é imutável e awaiting_user é terminal
awaiting_qa → awaiting_secops → awaiting_user. Aprovar o QA nunca
pula direto para o usuário. changes_requested devolve ao dev na mesma
branch, sem PR nova.
stateDiagram-v2 [*] --> awaiting_qa awaiting_qa --> awaiting_secops: QA aprova awaiting_qa --> awaiting_qa: changes_requested (volta ao dev) awaiting_secops --> awaiting_user: SecOps aprova awaiting_secops --> awaiting_secops: changes_requested awaiting_user --> [*]: o merge é do humano
- Onde:
src/domain/execution/pr-gate-state-machine.ts:24 - Teste:
test/domain/execution/pr-gate-state-machine.spec.ts - Origem: ADR 0013 §1
- Caso de borda:
awaiting_useré terminal por desenho — o sistema nunca faz merge (RN-006).
RN-015 — Ciclo K: o teto de correção é finito e herdado
Toda volta de gate consome uma rodada. Esgotado o teto, a tarefa fica bloqueada com motivo, em vez de girar para sempre. Um subagente criado pela paralelização herda o teto do agente-base.
Desde a Fase 8b o teto vale também para a ÁREA de QA, sem mudança de
código: o QaLeadServer é o único chamador de record_gate_verdict, então
uma rodada de gate — não importa quantas especializações ela consultou por
baixo — continua consumindo exatamente UMA rodada. Ver
RN-036.
- Onde:
DEFAULT_MAX_GATE_CORRECTIONS = 3emsrc/application/use-cases/execution/record-gate-verdict.use-case.ts:21; aplicado emactivate-execution.use-case.ts:89e, no engine, emqa_lead_server.ex:268esecops_agent_server.ex:142 - Teste:
apps/engine/test/engine/gates/qa_automacao_agent_test.exs(a especialização devolve{:blocked, ...}sem chamar a api) eqa_lead_server_test.exs(o Lead NUNCA chamarecord_gate_verdictnesse caso — é isso que impede a correção de ser queimada) - Origem: ADR 0017 §4
RN-017 — Orçamento tem escopo exclusivo: projeto ou sessão
Um budget referencia um projeto ou uma sessão, nunca os dois — garantido
por um check no banco de dados, não só no código.
- Onde:
src/db/schema/llm.ts(budgets_scope_check) - Teste: a constraint é a garantia
RN-018 — Notificação de orçamento em 70%, 90% e 100%, sem repetir
Cada limiar dispara uma vez; o último notificado fica persistido em
budgets.last_threshold_notified.
- Onde:
src/domain/llm/budget-threshold.ts:1 - Teste:
test/domain/llm/budget-threshold.spec.ts
RN-019 — policy = 'block' recusa a chamada; 'allow' só registra
- Onde:
src/domain/llm/budget-threshold.ts:4 - Caso de borda: um projeto em
allownão se detém no teto. É a causa mais comum de "o orçamento não segurou" — veja o runbook.
RN-020 — O modelo é resolvido em cascata, do mais específico ao mais geral
session > agent > area > project > workspace. Vence o primeiro que
existir. area entrou na FASE 23 — veja a RN-102 para a posição
dela e o que muda para quem já leu esta cascata.
- Onde:
src/domain/llm/binding-resolver.ts - Teste:
test/domain/llm/binding-resolver.spec.ts
Psicólogo e Anamnese
RN-021 — Hipótese sem evidência válida não é gravada
Os evidenceEventIds precisam apontar para eventos que existem e pertencem à
sessão analisada. A validação é atômica por lote: um id inválido reprova o
lote inteiro.
- Onde:
src/domain/psychologist/hypothesis-evidence.ts - Teste:
test/domain/psychologist/hypothesis-evidence.spec.ts - Origem: ADR 0015 §3
RN-022 — O ciclo de vida da hipótese é compare-and-swap
proposed → accepted | dismissed. Duas decisões concorrentes sobre a mesma
hipótese: uma vence, a outra recebe conflito — não 500, não silêncio.
- Onde:
src/domain/psychologist/hypothesis-lifecycle.ts - Teste:
test/domain/psychologist/hypothesis-lifecycle.spec.ts - Origem: ADR 0022 §7
RN-024 — A Anamnese só perfila competência do catálogo — guarda-corpo estrutural
Competências de processo são seis, fechadas: git, agile, arquitetura,
testes, seguranca, infra. Mais as stacks técnicas derivadas do
module_map. Qualquer outra coisa — "ansiedade", "saúde mental" — não tem
caminho de escrita: é erro no domínio, não instrução de prompt.
- Onde:
src/domain/anamnese/competency-catalog.ts:16 - Teste:
test/domain/anamnese/competency-catalog.spec.ts - Origem: ADR 0016 §1
- Por quê: a Anamnese perfila competência técnica, não pessoa. O limite é estrutural porque uma instrução de prompt não é garantia.
RN-025 — Apagar o perfil apaga de verdade, e o opt-out impede a re-derivação
- Onde:
src/domain/anamnese/proficiency-profile.entity.ts+ tabelaanamnese_opt_outs - Origem: ADR 0016 §2
RN-026 — Patch de instrução negado não é reproposto
A comparação é sobre o conteúdo normalizado, não sobre o texto literal:
reindentar o mesmo patch não o transforma em proposta nova. Não há tabela de
dedup — a fonte é o próprio histórico de proposed_action com actionType = instruction_patch e status = denied.
- Onde:
src/domain/instructions/patch-dedup.ts:22 - Teste:
test/domain/instructions/patch-dedup.spec.ts
RN-027 — Rollback de instrução é operação para frente
Reverter cria uma versão nova com o conteúdo antigo; não apaga histórico. A tabela de versões é append-only.
- Onde:
src/domain/instructions/ - Origem: ADR 0016 §4
Git
RN-029 — O bootstrap de Gitflow é idempotente e retomável
Cinco passos; cada um verifica antes de agir e pode ser retomado do ponto que
falhou. skip é sucesso, não erro.
Eram seis. O sexto criava a branch rc, degrau que o
ADR 0030 removeu da política —
o bootstrap continuou criando, protegendo e documentando no repositório do
usuário uma escada que o produto já tinha abandonado (achado #3 do primeiro
dogfooding). O valor create_rc_branch continua no enum bootstrap_step:
linhas antigas o referenciam, e passo que aconteceu de verdade não se apaga do
histórico.
- Onde:
src/application/use-cases/git/bootstrap-steps.ts+domain/git/repo-bootstrap.entity.ts(RETIRED_BOOTSTRAP_STEPS) - Teste:
test/domain/git/repo-bootstrap-status.spec.ts - Origem: ADR 0005
RN-030 — Reapresentar um refresh já usado revoga a família inteira
Cada refresh consome o token apresentado e emite um filho com o mesmo
family_id e o mesmo family_started_at. Apresentar um token que já foi
consumido é a assinatura de um roubo — alguém está usando uma cópia — e a
resposta é revogar todos os tokens vivos daquela família, com evento de
segurança.
O usuário legítimo é deslogado junto. Isso é o comportamento correto, não um defeito: do lado do servidor, um duplo-submit do cliente e um replay de ladrão são idênticos.
- Onde:
src/domain/auth/refresh-token.ts:50+application/use-cases/auth/refresh.use-case.ts:98 - Teste:
test/application/use-cases/auth/rotacao-e-reuso.spec.ts - Borda: quem apresenta um token de família já revogada é vítima a
jusante, não novo roubo: registra
refresh_revokede não dispara segunda cascata. Sem essa distinção, cada aba do usuário legítimo geraria um alarme falso durante o incidente. - Origem: ADR 0031
RN-031 — Falha de login é contada por e-mail e por IP, e o bloqueio escala
Janela deslizante de 15 minutos no Postgres, sem Redis. Dois baldes por tentativa e o mais restritivo vence: e-mail (5 falhas → 30s, 8 → 5min, 12 → 15min) e IP (20 → 30s, 30 → 2min). Um login bem-sucedido limpa o balde do e-mail; o de IP drena só por tempo.
A chave do balde é o e-mail normalizado, nunca o id do usuário. Com id, o balde só existiria depois de encontrar a conta, e o próprio lockout viraria oráculo de existência.
- Onde:
src/domain/auth/lockout-policy.ts:97+infrastructure/persistence/drizzle/drizzle-login-throttle.ts:74 - Teste:
test/application/use-cases/auth/lockout.spec.ts - Borda: enquanto bloqueado, a tentativa não é registrada. Se fosse, um atacante manteria a conta da vítima travada para sempre só continuando a tentar — o lockout viraria negação de serviço contra quem ele protege.
- Por quê: o balde de IP não pode ser limpo no sucesso; quem tem uma conta válida zeraria a janela à vontade e pulverizaria palpites sem limite.
- Origem: ADR 0031
RN-032 — Nenhuma resposta distingue conta existente de inexistente
Qualquer resposta diferente da falha uniforme só é alcançável depois de uma verificação de senha bem-sucedida. No login, e-mail inexistente, senha errada e conta bloqueada devolvem o mesmo 401 e gastam o mesmo tempo — o ramo sem conta verifica contra um hash dummy gerado com os mesmos parâmetros do real. No registro e no pedido de reset, endereço conhecido e desconhecido devolvem 202.
- Onde:
src/application/use-cases/auth/login.use-case.ts:79+register.use-case.ts:74 - Teste:
test/application/use-cases/auth/enumeracao.spec.ts - Borda: a checagem de bloqueio por e-mail roda depois do argon2, não antes. Sair mais cedo é a otimização que qualquer revisor sugeriria, e é exatamente o vazamento — o teste fica vermelho se alguém a introduzir.
- Borda: o usuário MIGRADO do Keycloak (existe em
users, sem linha emauth_credentials) também recebe o 401 uniforme — e o link de "definir senha" é disparado em silêncio. Responderpassword_pendingconfirmaria que o endereço existe e que é conta legada. Por issofindByEmailé um LEFT JOIN numa consulta só: duas consultas encadeadas fariam esse ramo pagar uma ida a mais ao banco, e o relógio revelaria o que o corpo esconde. - Por quê: o que se afirma é "nenhum ramo pula o trabalho caro e nenhum produz resposta distinguível", não tempo constante. Ver as consequências no ADR.
- Origem: ADR 0031, borda do migrado em ADR 0032
RN-033 — Token de verificação e de reset vale uma vez só
Consumo por UPDATE condicional com returning: o próprio UPDATE é a guarda.
Zero linhas cobre inexistente, de outro propósito, já consumido, invalidado e
expirado — todos com a mesma resposta. Pedir um link novo invalida o anterior.
Concluir um reset revoga todas as sessões do usuário e não emite tokens.
- Onde:
src/infrastructure/persistence/drizzle/account-token.repository.ts:76 - Teste:
test/application/use-cases/auth/tokens-de-conta.spec.ts - Borda: dois envios simultâneos não passam os dois. A corrida é o caso normal, não a exceção: scanner de e-mail corporativo abre todo link de toda mensagem, então o robô costuma consumir o token antes do humano clicar.
- Por quê: o reset não emite sessão de propósito — logar direto a partir de um link recebido por e-mail faria comprometer o e-mail equivaler a tomar a conta, sem segundo passo.
- Origem: ADR 0031
RN-034 — A sessão da web vive em cookie httpOnly, com CSRF
O refresh token vai num cookie brabo_refresh (httpOnly, SameSite=Strict,
Path=/auth, Secure em produção) e não aparece no corpo de nenhuma
resposta. O access token, de 15 minutos, fica em memória no cliente e viaja no
Authorization: Bearer.
/auth/refresh e /auth/logout exigem X-CSRF-Token igual ao cookie
brabo_csrf, comparado em tempo constante.
- Onde:
src/interfaces/http/auth/session-cookies.ts:53+interfaces/http/auth/auth.controller.ts - Teste:
test/interfaces/session-cookies.spec.ts - Borda: falha de CSRF é 403, não 401. Com 401 o cliente tentaria renovar a sessão e entraria em laço — a credencial está boa, quem está errada é a requisição.
- Por quê: devolver o refresh também no corpo anularia o
httpOnly— bastaria um XSS ler a resposta do login, e levaria a sessão longa em vez dos 15 minutos do access. - Origem: ADR 0032
RN-035 — O tráfego interno engine ↔ api exige o segredo de serviço
As 32 rotas /internal/* são @ServiceRoute(): ficam fora do JWT de usuário e
fora do rate limit. Quem autentica é o EngineServiceGuard, comparando
X-Brabo-Service-Token com BRABO_SERVICE_TOKEN em tempo constante. O mesmo
segredo vale nos dois sentidos, e BRABO_SERVICE_TOKEN_PREVIOUS é aceito só na
verificação, para a rotação não ter janela de indisponibilidade.
- Onde:
src/interfaces/http/auth/engine-service.guard.ts:44+infrastructure/security/service-token.ts+apps/engine/lib/engine_web/plugs/verify_service_token.ex - Teste:
apps/engine/test/engine_web/plugs/verify_service_token_test.exs,test/interfaces/route-surface.spec.tse, para a rotação do lado da api (anterior aceito só na verificação, quem chama manda o atual, removido o anterior o velho cai),test/infrastructure/security/service-token.spec.ts(describe "rotação do BRABO_SERVICE_TOKEN") etest/interfaces/engine-service.guard.spec.ts(RN-597) - Borda: a isenção de rate limit vem do METADADO da rota, não do guard. O
RateLimitGuardéAPP_GUARDe roda antes de qualquer guard de controller — quando ele decide, oEngineServiceGuardainda não rodou. - Origem: ADR 0032
RN-038 — Agente contado no resumo do workspace = gastou tokens este mês
O resumo do dashboard de projetos ("N projetos ativos · M agentes · gasto
este mês") conta como "agente" quem tem pelo menos uma linha em
token_usage com actor_kind = 'agent' no mês corrente, somando todos os
projetos do workspace. Sem o filtro de actor_kind, um user mandando
chat ou um system registrando uso inflaria a contagem — token_usage
grava para qualquer tipo de ator, não só agente. O corte por mês usa
created_at >= date_trunc('month', now()); um agente que trabalhou só no
mês anterior não conta, mesmo que ainda apareça no roster de alguma
sessão. A contagem naturalmente inclui as subespecialidades de área da
Fase 8 (qa-automacao, qa-performance-seguranca, infra-workflows):
cada uma tem seu próprio actor_id quando gasta tokens.
- Onde:
src/infrastructure/persistence/drizzle/token-usage.repository.ts(summarizeForWorkspaceThisMonth) - Teste:
test/application/use-cases/iam/get-workspace-summary.use-case.spec.ts
RN-040 — Binding de agente exige tool calling nativo
Vincular um modelo a um agente (scope = 'agent') só é permitido se o
modelo tiver supports_tool_calling. Um agente só existe dentro do
ToolLoop, e o ToolLoop só funciona se o modelo souber pedir ferramentas;
sem isso, a falha apareceria adiante como "o agente parou sem terminar",
que é exatamente o diagnóstico por eliminação que o
ADR 0020 proibiu. A recusa é um
422 e a mensagem aponta para o filtro "aptos para agentes" — sem esse
apontamento a regra vira um beco sem saída.
O ToolCallRecovery do engine recupera chamadas que o modelo escreveu em
prosa, mas é um resgate, não uma licença: depende de o modelo acertar o
formato por acaso.
Só agent valida. workspace e project são o fallback do chat humano e
session é conversa — nenhum deles roda um ToolLoop, e travá-los proibiria
modelos só de chat no produto. O agente context-manager está coberto por
construção: é um slug dentro do escopo agent, não um escopo próprio.
Nota aditiva — o filtro passou a vir MARCADO, e quem o liga é o ESCOPO. O
parágrafo acima diz que a recusa aponta para o filtro "aptos para agentes",
"senão a regra vira beco sem saída" — e por muito tempo ela foi um: o prop que
abre o seletor com o filtro marcado existia desde a Fase 9c e nenhuma tela de
produção o passava. O seletor de um agente abria oferecendo modelo chat-only
cujo clique só existia para ser recusado. As duas telas que gravam num escopo
validado passam a abrir filtradas — ModelsSection (agente e a caixa
da área na linha do lead, RN-912) —, e a régua de quem liga não é a tela e sim
assertModelFitsBindingScope: onde a api RECUSA, o filtro vem marcado; onde ela
não recusa, quem escolhe é a pessoa. Por isso o seletor de SESSÃO continua sem
filtro — marcar ali esconderia modelo que a api aceita. Isto não elimina o
422: o filtro cobre uma das três causas, e as outras duas
(RN-043 — desativado no workspace, sumido do provider) seguem
alcançáveis, com o indisponível ainda LISTADO e marcado; o desfecho de recusa da
RN-470 continua sendo o que conta o que houve. E é estado INICIAL de
um checkbox, não trava: desmarcar volta a listar tudo.
A consequência: o vigente que o filtro esconde é DITO. workspace e
project nunca exigiram tool calling, e as rotas que resolvem o binding de um
agente ou de uma área chamam ResolveModelBindingUseCase sem
exigeToolCalling — então um padrão chat-only desses níveis é resolvido
normalmente e vira o vigente da linha. Com o filtro marcado, o gatilho mostrava
um nome que a lista aberta não continha, sem nenhuma opção marcada. O picker
agora nomeia o modelo e a causa; ele PODE nomear a causa porque só há um filtro
ali. Quando a lista inteira fica vazia, quem fala continua sendo o texto de
lista vazia, que já manda desmarcar — dois avisos sobre o mesmo checkbox seriam
ruído.
Correção de fato, não do texto acima: "Only agent validates" descreve a
Fase 9a. Desde o ADR 0064
area entra na MESMA validação — ela não é fallback genérico, o único consumidor
do modelo de uma área é um agente dela (RN-102) —, e é por isso que
são DUAS as telas que ligam o filtro.
- Onde:
src/domain/llm/model-capabilities.ts:38;apps/web/src/components/ModelPicker.tsx(o filtro e o aviso de vigente escondido),apps/web/src/routes/settings/ModelsSection.tsx(quem o liga, no seletor do agente e no do lead que segue a área) - Teste:
test/domain/llm/model-capabilities.spec.ts,test/application/use-cases/llm/set-model-binding.use-case.spec.ts;apps/web/src/routes/settings/filtro-de-aptos.test.tsxeapps/web/src/routes/SessionPage.filtro-de-aptos.test.tsx(as duas telas que ligam, e a que não liga) - Origem: ADR 0041
RN-041 — Contagem de tokens que o provider não deu é marcada como estimada
Quando a resposta do provider não traz usage, a base compatível com
OpenAI conta localmente com o tokenizer e emite o chunk com
estimated: true. O número ainda serve para cobrança, mas a marca preserva
a diferença entre "o provider disse zero" e "o provider não disse
nada" — e é o que permite à UI qualificar o custo em vez de mostrar um
valor sem procedência.
Os outros dois providers divergem, e a divergência é normalizada, não
escondida: o Ollama simplesmente não emite usage sem a linha done; o
Anthropic não pode omitir a contagem, porque usage é obrigatório no
message_start do protocolo dele. As três respostas estão em
docs/reference/llm-providers.md.
- Onde:
src/infrastructure/llm/openai-compatible-provider.ts:167 - Teste:
test/contract/llm-provider.contract.ts(cenáriosem_usage, rodado contra os três providers) - Origem: ADR 0041
RN-042 — O metering registra quem ATENDEU a chamada, não só por onde ela entrou
Quando a chamada passa por um hub que informa o provider real,
token_usage registra esse provider em upstream_provider, além do
provider de entrada. Sem hub — ou com um hub que não informou — o campo é
null, nunca string vazia: a consulta de custo por provider precisa
distinguir "não passou por hub" de "passou por um e o hub não disse".
Nas métricas, o rótulo upstream_provider repete o próprio provider quando
não há hub, de modo que sum by (upstream_provider) continua somando o
custo inteiro.
- Onde:
src/application/use-cases/llm/record-llm-usage.use-case.ts:73 - Teste:
test/application/use-cases/llm/record-llm-usage.use-case.spec.ts - Origem: ADR 0041
RN-043 — Um modelo descoberto entra desabilitado; um modelo que desaparece é marcado, nunca apagado
A sincronização do catálogo tem três desfechos, e nenhum é destrutivo:
- Um modelo novo entra sem linha de curadoria em nenhum workspace, e a ausência de linha É o estado desabilitado. O catálogo de um provider tem centenas de linhas — despejá-las como ativas tornaria a escolha impossível e ligaria um modelo caro sem que ninguém decidisse. Ativar é a curadoria do owner, e vale só no workspace dele (RN-052).
- Um modelo que sumiu do catálogo remoto recebe
availability = 'unavailable'e permanece na tabela:model_bindingsetoken_usageapontam para a linha, e apagá-la levaria junto o histórico de custo. - Um modelo que voltou retorna a
availablecom a curadoria intacta — a escolha do owner sobrevive a uma ausência temporária do provider.
Os dois eixos são deliberadamente independentes: a curadoria é decisão de
uma pessoa, availability é uma observação do provider. Nenhum escreve no
outro — e desde o ADR 0049
eles nem moram na mesma tabela, então a sincronização não tem campo de
curadoria para atropelar, mesmo que quisesse.
Três consequências no resto do sistema:
-
um binding NOVO para um modelo inativo ou indisponível é recusado no domínio (
ModelNotBindableError, 422). Os bindings que já existem permanecem; -
a cascata do
resolveBindingpula o candidato indisponível, registra o que pulou emskippede — quando o turno leva ferramentas — revalidasupports_tool_callingem TODOS os níveis. Sem isso, o fallback pousaria um agente num modelo só de chat e violaria a RN-040 em silêncio; -
um provider que falhou não torna nada indisponível: um 401 é "não sei o que há ali", não "não há nada ali". O provider é pulado, com a ORIGEM da falha (
infra|modelo) no relatório — nunca diagnóstico por eliminação. -
Onde:
src/application/use-cases/llm/sync-model-catalog.use-case.ts:160,src/domain/llm/binding-resolver.ts:63,src/domain/llm/model-capabilities.ts:49 -
Teste:
test/application/use-cases/llm/sync-model-catalog.use-case.spec.ts,test/domain/llm/binding-resolver.spec.ts,test/application/use-cases/llm/set-model-binding.use-case.spec.ts -
Origem: ADR 0042
RN-044 — O preço vale daqui para a frente, e o custo antigo continua conferindo
Toda linha de token_usage registra o preço que produziu o seu
cost_micros. Mudar o preço de um modelo não reprecifica o consumo
passado — e mais que isso: o custo antigo continua reproduzível,
porque tokens × preço registrado bate com o custo registrado mesmo depois
de três correções na tabela models.
Toda mudança de preço grava uma linha em model_price_changes, append-only,
com o par antes/depois e a origem (manual | sync). O par é gravado junto
de propósito: reconstruir o "antes" a partir da linha anterior dependeria de
nenhuma escrita ter escapado do caminho auditado, que é exatamente o que a
auditoria existe para provar. Um preço igual ao atual é no-op — uma linha
dizendo "mudou de 10 para 10" transformaria o log em ruído.
A regra vale para todo caminho que muda preço, não só o da tela. Duas
escritas costumavam escapar dela: a sincronização do catálogo (que mudava o
preço via upsert, nunca produzindo a origem sync que o domínio declarava
desde a Fase 9c) e o seed.ts (que roda num banco já semeado —
BRABO_FORCE_SEED=1 no bootstrap.sh do k8s — e portanto corrigia o preço
em silêncio). Ambos agora auditam, com o seed reaproveitando o próprio
UpdateModelPricingUseCase.
- Onde:
src/application/use-cases/llm/update-model-pricing.use-case.ts:44,src/application/use-cases/llm/sync-model-catalog.use-case.ts:213,src/db/seed.ts:376 - Teste:
test/application/use-cases/llm/update-model-pricing.use-case.spec.ts,test/application/use-cases/llm/sync-model-catalog.use-case.spec.ts - Origem: ADR 0042
RN-045 — Um repositório adotado só é alterado por um plano aprovado
Adotar um repositório existente diagnostica sem agir. A adoção valida o
acesso (getRepo), grava as linhas do projeto e produz um plano: a lista
serializada do que o bootstrap faria, obtida chamando o check() de cada
passo — o mesmo que dá idempotência desde a RN-029
— sem nunca executar a mutação correspondente.
Enquanto repo_bootstraps.plan_decision é nulo, nenhuma mutação roda. O
portão fica antes do executor, não dentro dele: o runner do bootstrap é o
mesmo da Fase 2, sem filtro, e simplesmente não é chamado. Somado à guarda
que já pulava branches protegidas, não há caminho de código que toque uma
branch fora de um plano aprovado.
Os dois desfechos:
- aprovar é tudo ou nada (aprovar passos soltos quebraria a cascata
dev←main, qa←dev). O que executa é o plano rederivado no momento da execução: igual ou menor que o mostrado, nunca maior — uma branch que virou protegida nesse meio-tempo é pulada; - adotar como está dispensa o bootstrap, registra a decisão e não mexe no cursor para fingir convergência. O plano é guardado como evidência do que foi deliberadamente não aplicado.
Decidir sobre um plano regenerado é recusado (409): a decisão carrega o
planGeneratedAt que o usuário viu, e um "sim" dado sobre outra coisa não
vale.
O provisionamento normal recusa (409) rodar num repositório adotado — sem essa guarda, o caminho de retomada rodaria o bootstrap no repositório de terceiros sem plano nenhum.
Limite conhecido: "proteção divergente" aqui é presença × ausência,
porque é só isso que o contrato expõe (GitBranch.protected é um booleano, e
o ADR 0028
adiou uma ProtectionPolicy normalizada). Uma branch com proteção PARCIAL
conta como "desprotegida" e pode ser sobrescrita — mas só dentro de um plano
aprovado.
- Onde:
src/application/use-cases/git/decide-bootstrap-plan.use-case.ts,src/application/use-cases/git/bootstrap-plan.ts,src/application/use-cases/git/bootstrap-steps.ts:74 - Teste:
test/application/use-cases/git/decide-bootstrap-plan.use-case.spec.ts,test/application/use-cases/git/bootstrap-plan.spec.ts - Origem: ADR 0044
RN-046 — Todo repositório de projeto declara a sua origem
project_repositories.origin e repo_bootstraps.origin dizem se o Brabo
criou o repositório (created) ou adotou um que já existia
(adopted). A origem é escrita explicitamente por quem grava a linha — não
pelo default da coluna — e não muda depois.
Não é decoração: é o que faz o produto tratar como caso legítimo o que a
Fase 10 precisou fazer à mão (inserir linhas em project_repositories e
repo_bootstraps para apontar um projeto para um fork). Um repositório
adopted tem política de branches própria, não passa pelo provisionamento e
só é alterado conforme a RN-045.
O backfill da migration 0031 marca tudo que já existia como created, e
pode ser cego quanto a isso: a adoção não existia antes dela, então não há
linha adotada para classificar errado.
- Onde:
src/db/schema/git.ts,src/db/migrations/0031_special_winter_soldier.sql,src/domain/git/repo-bootstrap.entity.ts - Teste:
test/application/use-cases/git/adopt-repository.use-case.spec.ts - Origem: ADR 0044
RN-048 — A promoção de story é do usuário por padrão; o modo muda quem dispara, nunca o que se valida
projects.story_promotion escolhe QUEM promove uma story de draft para
ready:
manual: o PO deixa a story completa e ela ficadraftcomstories.proposed_ready = true. Nenhuma de suas tarefas é pegável — oclaimNextexigestory.status = 'ready'— e é o usuário quem a promove, individualmente ou em lote, a partir do Backlog.auto(default de projeto novo desde a RN-659): o PO promove por conta própria ao terminar uma story completa. Esse era o comportamento anterior à Fase 12c;manualfoi o default da 12c até a RN-659 (migration0065), que mudou só o default da coluna — projetos criados no meio do caminho continuammanual.
O modo muda o gatilho, não o critério. Os dois caminhos passam por
assertPromotable — prontidão (RF/DoD/DoR/regra) e módulos resolvidos contra
o module_map vigente — e é isso que o teste de simetria em
story-promotion.spec.ts trava: para toda story, isPromotable concorda com
o que assertPromotable levanta. Antes da fase, a validação era duplicada e
assimétrica (a criação chamava canBecomeReady, a transição chamava
assertReady + assertModulesResolved): duas portas para o mesmo estado,
com fechaduras diferentes. Tornar o gatilho configurável exigiu unificá-las
primeiro, senão "promover pela UI" e "promover na criação" seriam regras
distintas com o mesmo nome.
Uma story incompleta nunca é proposta. proposed_ready só liga quando a
story já passaria na validação — propor o que o domínio recusaria empurraria
o trabalho do PO para o usuário disfarçado de decisão.
A recusa devolve a story ao PO: grava returned_reason/returned_at,
desliga proposed_ready, emite backlog.story_promotion_returned e injeta o
motivo como mensagem FIXADA na sessão do PO, com a mesma redação de
precedência de um gate devolvido ao dev (lição do ADR 0020). A recusa é
gravada antes de falar com o engine, e o engine falhar não a desfaz — é o
inverso da ordem do rearme na RN-047, e por um motivo: lá o evento
afirma algo SOBRE o engine, aqui ele afirma algo sobre o usuário, o que é
verdade haja ou não um PO de plantão para ouvir.
A promoção em lote não é tudo ou nada: cada story é a sua própria
transação, e uma que perdeu a prontidão entre a proposta e a decisão volta
failed com o motivo, sem derrubar as outras que o usuário acabou de
revisar.
O evento backlog.story_transitioned registra o ator real — user na
promoção manual, agent/po na automática. O event log é imutável e é o que a
auditoria lê: registrar o PO numa decisão do usuário apagaria exatamente a
etapa humana que a regra existe para devolver.
A migration 0033 faz um backfill dirigido, não cego: a coluna nasce
manual e todo projeto que já existia é movido para auto. O novo default
vale para quem vier depois; um projeto em andamento não pode parar de
produzir por causa de um deploy.
- Onde:
src/domain/backlog/story-promotion.ts,src/db/migrations/0033_absurd_domino.sql,src/application/use-cases/backlog/promote-stories.use-case.ts,src/application/use-cases/backlog/return-story.use-case.ts,apps/engine/lib/engine/agents/po_server.ex(revision_message/1) - Teste:
test/domain/backlog/story-promotion.spec.ts(simetria),test/db/story-promotion-migration.spec.ts(backfill dirigido),test/application/use-cases/backlog/promote-stories.use-case.spec.ts,test/application/use-cases/backlog/return-story.use-case.spec.ts,apps/engine/test/engine/agents/po_server_test.exs(describerevise/2) - Origem: ADR 0046
RN-049 — Toda decisão sobre uma ação proposta fica no event log, com quem decidiu
proposed_action.created, .approved e .denied são eventos de domínio em
session_events, além das linhas do outbox que os levam ao engine. O outbox
não é memória: ele é drenado, marcado com processed_at e podado.
actor é quem realmente decidiu — o usuário em .approved/.denied, o
agente que propôs em .created. E created.payload.status diz como a
ação nasceu (pending, auto_approved, denied).
Isso rende a distinção que dá a métrica: decisão humana = evento
proposed_action.approved; a política decidindo sozinha aparece apenas em
.created com status: auto_approved e ator agente, e nunca se confunde com
um clique. Essa contagem — "cliques de aprovação" — era exatamente o que a
Fase 10 queria medir e não conseguiu, porque a decisão não existia em lugar
consultável (achado #17). approve_always conta como aprovação porque
delega ao mesmo caso de uso, e emite permission.granted por cima.
Ficou de fora, por decisão: o proposed_action.created que o bootstrap do
repositório emite direto para o outbox. Essas mutações já são narradas por
bootstrap.step_* na mesma sessão, e duplicá-las contaria o mesmo fato duas
vezes numa métrica de aprovação.
- Onde:
src/application/use-cases/actions/propose-action.use-case.ts,.../approve-action.use-case.ts,.../deny-action.use-case.ts - Teste:
test/application/use-cases/actions/approve-deny-action.use-case.spec.ts(describethe decision in the event log) - Origem: ADR 0048
RN-051 — Um preço digitado à mão vence o catálogo do provider
Uma linha de models com manual_pricing = true tem um número que alguém
digitou depois de ler a documentação do provider. A sincronização do catálogo
não toca nele — nem quando o catálogo remoto traz um preço próprio. Era
isso que o schema sempre dizia ("quem sincroniza preço NÃO PODE sobrescrever
uma linha marcada aqui sem decisão explícita") e o que o código não fazia: o
remoto sempre vencia quando trazia um preço, e a sincronização seguinte
desfazia a correção de quem tinha acertado um número errado.
A regra existe porque, para vários providers, o número digitado é o único que existe: NVIDIA NIM e Bitdeer não publicam preço por token em documentação nenhuma (referência de providers), e o valor semeado é uma aproximação de mercado. Deixar o catálogo remoto sobrescrevê-lo trocaria uma aproximação conhecida por outra, sem ninguém decidir.
Um modelo NOVO nasce com a flag vinda do catálogo, não de um default fixo:
descoberto com preço, manual_pricing = false (a fonte é a sincronização,
e é ela quem mantém a linha em dia); descoberto sem preço, true — a
linha está esperando alguém digitar um, e marcá-la já protege esse número do
primeiro catálogo que resolver informar um preço.
- Onde:
src/application/use-cases/llm/sync-model-catalog.use-case.ts(resolverPreco),src/db/schema/llm.ts:137 - Teste:
test/application/use-cases/llm/sync-model-catalog.use-case.spec.ts(manually typed price beats a catalog that REPORTS a price) - Origem: ADR 0042
RN-052 — A curadoria de modelos vale só no workspace que a decidiu
Ligar ou desligar um modelo no seletor é uma decisão daquele workspace, e
não alcança o vizinho. O catálogo em si continua global — nome, preço,
janela e capabilities são fatos do provider, iguais para todos, e duplicá-los
por workspace criaria N verdades sobre o mesmo modelo, além de partir
token_usage.model_id ao meio.
Antes disso, models.is_active era uma coluna para a instalação inteira:
quem clicava em "ativar" decidia por todos os workspaces, e a tela não dava
sinal nenhum disso. O efeito prático era um workspace ligando um modelo caro
no seletor de outro — e o gasto aparecendo no orçamento de quem não decidiu
nada.
Três regras derivadas:
- A ausência de linha é o estado desabilitado. Não há um estado separado de "nunca decidido"; um modelo que a sincronização descobriu não tem linha e não aparece no seletor (RN-043).
- Desabilitar é
UPDATE, nãoDELETE. Apagar a linha apagaria também quem decidiu e quando. As leituras tratam os dois casos como inativos; o registro existe para quem audita. - Os escopos
agentesessionnão conferem a curadoria. Nenhum dos dois tem âncora de workspace — o binding de agente é por slug global. A checagem recebenulle confere só a disponibilidade, deixando a lacuna explícita em vez de adivinhar um workspace.
- Onde:
src/db/schema/llm.ts(workspace_models),src/application/use-cases/llm/set-models-active.use-case.ts,src/application/use-cases/llm/set-model-binding.use-case.ts(workspaceDoEscopo) - Teste:
test/application/use-cases/llm/set-models-active.use-case.spec.ts(activating in one workspace does NOT turn on the model in its neighbor) - Origem: ADR 0049
RN-053 — Reativar a execução acorda quem está parado, dentro da sessão que já existe
Ativar a execução de um projeto que já está executando é reativação, não um começo do zero: ela cai na sessão de execução atual e acorda os agentes que estavam parados. Duas partes, uma de cada lado do sistema.
A sessão é reaproveitada. A ativação usa a sessão active do projeto
que já carrega um execution.activated; só cria uma quando não há nenhuma.
Não existe coluna dizendo "esta sessão é de execução" — o que distingue uma é
o evento que ela guarda, e é isso que se consulta. Fechar a sessão continua
sendo o jeito de recomeçar: uma fechada não é candidata, e a próxima
ativação abre uma nova.
Antes, o create era incondicional, e o engine descarta o novo
session_id quando o agente já está vivo. Cada clique em "ativar" deixava
para trás uma sessão ativa que recebia o execution.activated e mais nada —
os eventos dos agentes continuavam indo para a sessão da ativação anterior.
O agente é acordado por wake, não por work. Um começo do zero dispara o
ciclo (:work — emite dev.started e pega tarefa). Um agente que já estava
vivo recebe {:wake, :became_claimable}, e quem decide é a guarda de estado
do servidor:
| estado do agente | o que a reativação faz |
|---|---|
idle |
pega a próxima tarefa |
working, awaiting_gate, awaiting_approval |
nada — a tarefa em andamento não é abandonada |
idle_tripped |
nada — só o rearme explícito o destrava (RN-047) |
Disparar :work para todos seria pior que o defeito: ele pega tarefa
incondicionalmente, e para um agente em awaiting_gate significaria largar o
worktree que o gate está varrendo — além de contornar o circuit breaker com
um clique.
- Onde:
src/application/use-cases/execution/activate-execution.use-case.ts,src/infrastructure/persistence/drizzle/session.repository.ts(findActiveExecutionSession),apps/engine/lib/engine_web/controllers/execution_command_controller.ex(acordar/4) - Teste:
test/application/use-cases/execution/activate-execution.use-case.spec.ts(describereactivation doesn't open an orphan session),test/infrastructure/persistence/session-execution.repository.spec.ts,apps/engine/test/engine_web/controllers/execution_command_controller_test.exs(describereactivation) - Origem: achado #11 do primeiro dogfooding
Gates de PR
RN-054 — O handoff externo se dirige ao lead da área ou a um agente sem área — nunca a um subagente
Quem fala com uma área de fora fala com o lead. Um handoff dirigido a um
subagente (qa-automacao, qa-performance-seguranca, infra-workflows) é
recusado com um erro tipado que nomeia o lead a quem o chamador deveria
ter se dirigido — recusar sem dizer o caminho certo só trocaria uma quebra de
hierarquia por um agente travado.
A recusa acontece antes do INSERT. Recusar depois deixaria um handoff
fantasma na tabela e um handoff.offered — evento imutável — afirmando uma
oferta que a política não permite.
A delegação interna (lead → subagente) não passa por aqui: ela é privada
da área, tem tabela própria (delegations) e caminho próprio
(RecordDelegationUseCase). A regra é sobre a fronteira externa da área, não
sobre o que acontece dentro dela.
O ADR 0038 pedia esta validação, nomeando o lugar — CreateHandoffUseCase é o
único do sistema que grava toAgent — e ela nunca tinha sido implementada
(achado #12 do primeiro dogfooding). O offer_handoff do engine repassava
to_agent como string livre, então até agora nada impedia um agente de
quebrar a hierarquia.
Área, lead e membros têm UMA fonte desde a FASE 18:
src/domain/agents/agent-areas.ts. A lista era escrita à mão em três lugares
(api, web, engine) e o teste só comparava api com web — o engine divergia em
silêncio. Agora apps/web/src/lib/agent-areas.generated.ts e
apps/engine/lib/engine/agents/areas.ex são produzidos por
pnpm --filter api gerar:areas, e o teste reprova o que estiver velho no
disco. Desde a AT-046 o mesmo gerador também escreve
SOLO_CONVERSATIONAL_AGENTS (RN-440) no arquivo da web — e só lá, porque o
engine não tem leitor para ele.
A lista continua sendo o CATÁLOGO; agent_areas é o ESTADO por projeto
(RN-094), e os dois respondem perguntas diferentes.
- Onde:
src/domain/agents/agent-areas.ts(assertHandoffTargetAllowed,HandoffToSubagentError),src/application/use-cases/agents/create-handoff.use-case.ts - Teste:
test/domain/agents/agent-areas.spec.ts(a regra + a checagem de que as cópias derivadas não divergem da fonte),test/application/use-cases/agents/create-handoff.use-case.spec.ts(recusa sem linha e sem evento) - Origem: ADR 0038, fechado a partir do achado #12 do primeiro dogfooding