Superfície exposta da api
Toda rota registrada, com a classificação de autenticação e a justificativa das que ficam abertas. Decisões no ADR 0027.
Este documento é a fonte de verdade de um teste, não a cópia de um.
apps/api/test/interfaces/route-surface.spec.tssobe a aplicação, enumera as rotas registradas em runtime e as compara com a tabela abaixo. Rota nova sem linha aqui reprova o teste; linha cuja classificação difere da anotação real reprova o teste; linha órfã (que não casa com rota nenhuma) reprova o teste. A documentação não fica velha em silêncio.
Classificações
| valor | o que significa |
|---|---|
public | @Public() — sem token. Cada uma justificada abaixo |
engine-service | EngineServiceGuard: X-Brabo-Service-Token igual ao segredo compartilhado. É a superfície interna api↔engine, fora do JWT |
role:<role> | autenticada e restrita pelo RBAC do domínio (@RequireRole) |
jwt | autenticada, sem papel exigido na rota — o escopo vem do próprio recurso |
As quinze rotas públicas
Eram quatro até a Fase 6. A Fase 7a acrescentou oito de uma vez — auth
first-party —, o ADR 0084 mais duas, o login social, e a onda da chave de
dispositivo do runner acrescentou o proxy do binário. Cada uma é
justificada abaixo. Abrir mais alguma continua exigindo mexer na asserção
de route-surface.spec.ts, que lista as públicas literalmente para forçar
a conversa.
Infraestrutura
GET /live — liveness. Não toca o banco de propósito: responde
enquanto o processo estiver vivo. Exigir token aqui faria o kubelet
reiniciar o pod no primeiro soluço do caminho de auth, transformando uma
degradação em queda total.
GET /health — readiness, com select 1. Mesmo raciocínio: quem
chama é o kubelet, e ele não leva token. Revela só se o banco responde.
GET /metrics — scrape do Prometheus, que também não leva token
nenhum. Exposição controlada pela REDE, não por auth: a NetworkPolicy só a
abre para o namespace monitoring, e o Ingress de produção bloqueia o
caminho. Sem @Public() o alvo apareceria down com todo o resto verde.
GET /git/oauth/:provider/callback — o retorno do OAuth do
GitHub/GitLab. O navegador do usuário chega aqui vindo do provedor, sem
sessão da api. Mas não é irrestrita: o parâmetro state é validado por
HMAC (GIT_OAUTH_STATE_SECRET), e sem state válido a chamada é recusada.
Essa garantia vale exatamente o que vale a chave, e é por isso que ela
deixou de ter default: em produção a api recusa subir com a chave de
exemplo do repositório, que é pública (ADR 0059,
RN-093). Com chave conhecida, esta rota
volta a ser irrestrita na prática — qualquer um assina um state para o
projeto que quiser.
GET /runner-releases/binary — faz proxy do binário standalone do
runner a partir das GitHub Releases, para o navegador nunca falar com o
GitHub direto. Pública pelo mesmo motivo de /metrics/JWKS: o binário em
si não é segredo, e exigir sessão para baixar a própria ferramenta que
permite alguém se autenticar seria às avessas. platform é uma lista
fechada de permitidos (linux-x64/linux-arm64/darwin-arm64 — os três
alvos que a matriz de release constrói; darwin-x64 saiu no
ADR 0174 e win32-x64 no
ADR 0187, e os dois são recusados com
mensagem própria apontando para o pacote npm), nunca interpolada crua na URL do GitHub — fechando o
vetor de SSRF/injeção de caminho que um parâmetro aberto deixaria. A URL
resolvida do asset (nunca os bytes do binário) fica em cache na memória
por alguns minutos, só para ficar abaixo do rate limit não autenticado do
GitHub sob downloads concorrentes; desde a sessão 3 da FASE 29 o
checksums.txt parseado — umas centenas de bytes de texto — é memoizado
na mesma entrada, então hash e binário vêm sempre da mesma release.
Desde a RN-525 (ADR 0149)
a rota não transmite mais bytes não verificados: ela confere o sha256 do
que baixou contra o checksums.txt da release e responde 502 com
motivo nomeado quando não consegue — inclusive quando a release não
publica manifesto nenhum, que é recusa, nunca bytes servidos com aviso.
Leia a garantia de forma estreita: é integridade contra o manifesto,
não procedência. A rota não verifica a assinatura cosign do
manifesto (checksums.txt.bundle), então quem consegue reescrever a
Release reescreve os dois arquivos e passa. As duas formas de fechar isso
foram medidas e recusadas por ora — cosign na imagem são 155 MB, e
@sigstore/verify faria uma rota @Public() depender de um segundo host
de terceiro (tuf-repo-cdn.sigstore.dev) para conferir algo que nenhuma
Release ainda carrega. O consumidor que verifica a assinatura é o
install.sh. Como verificar o hash significa ler todos os bytes, o
download vai para um arquivo temporário sob /tmp (o emptyDir do pod,
montado porque o rootfs é somente leitura) com teto de 256 MiB, e só é
devolvido depois de o hash bater — nunca bufferizado no processo de 512Mi.
Auth first-party
As sete rotas /auth/* mais o JWKS. Todas precisam ser públicas pelo
mesmo motivo estrutural: são o caminho pelo qual um access token é
OBTIDO, e o JwtAuthGuard global exige um. Uma rota de auth atrás do
guard pediria a credencial que ela mesma emite. logout é a única que
poderia ser autenticada, e não é, de propósito: ela já carrega a
credencial que importa — o refresh no cookie, com o par de CSRF — e sair
precisa funcionar mesmo com o access token expirado.
O que protege estas rotas não é o rate limit. O
RateLimitGuardisenta as rotas@Public()— de propósito, para não estrangular o/healthaté o kubelet reiniciar o pod. O que segura esta superfície é o bloqueio progressivo por email e por IP, dentro dos casos de uso. Não é reforço opcional: é a única defesa que existe aqui. Ver RN-030 e RN-031.
POST /auth/register — cadastro. Responde 202 tanto para endereço
novo quanto para já cadastrado; no segundo caso nada é criado e o dono do
endereço recebe um aviso. Um 409 Conflict, que é o que o bom senso REST
pediria, entregaria a lista de usuários a qualquer um com uma wordlist.
POST /auth/login — autenticação. Email inexistente, senha errada e
conta bloqueada devolvem todos a mesma resposta 401, e levam o
mesmo tempo (o ramo sem conta confere contra um hash fictício com
parâmetros idênticos).
POST /auth/refresh — rotação. O próprio refresh token é a
credencial, então a rota não pode exigir outra.
POST /auth/logout — revoga a família do token apresentado. Sempre
204, mesmo para token desconhecido: responder 401 aqui seria um oráculo
de validade de token.
POST /auth/verify-email, POST /auth/request-password-reset,
POST /auth/reset-password — fluxos de conta. Quem chega nelas ainda
não tem sessão, por definição. A credencial é o token de uso único que
veio por email; link inválido, expirado ou já usado recebem resposta
idêntica.
GET /.well-known/jwks.json — a metade pública do par Ed25519
que assina os access tokens. Mesmo raciocínio de /metrics: o consumidor
não tem token, e exigir um seria pedir credencial para validar credencial.
Publicar a chave pública é todo o propósito do formato — o que nunca pode
sair daqui é o componente d da JWK, travado por teste.
Login social (ADR 0084)
GET /auth/oauth/:provider/start — redireciona direto para o
provedor (GitHub/GitLab). Pública pelo mesmo motivo estrutural das outras
sete: é o próprio ponto de entrada, antes de existir sessão. O state que
ela gera é assinado por HMAC com propósito PRÓPRIO
(signSocialOauthState/RN-273) — nunca o
mesmo state do GET /git/oauth/:provider/callback acima, embora as duas
rotas assinem com a MESMA chave GIT_OAUTH_STATE_SECRET. O campo
purpose no payload é o que impede um state de um fluxo de ser aceito
pelo verificador do outro.
GET /auth/oauth/:provider/callback — recebe o retorno do provedor.
Mesmo raciocínio do callback da conexão de git: o navegador chega sem
sessão, o state é verificado por HMAC, e a rota nunca devolve JSON — ela
sempre redireciona, para WEB_ORIGIN/ no sucesso (com os cookies de
sessão já gravados) e para WEB_ORIGIN/login?oauth_error=1 na falha, sem
detalhar o motivo na URL.
Notas
-
POST /workspaces/:workspaceId/projectsdecide onde o agente vai escrever, e é por isso que é uma rota de superfície de segurança, não só de cadastro (ADR 0072/ ADR 0104, RN-169/RN-421/ RN-422). O corpo ganhouexecutionMode(container— o padrão e o comportamento de sempre —,mounted, o antigolocal, renomeado, ourunner) eworkspacePath. Emmounted/runnero caminho absoluto informado vira a raiz do escopo do terminal do ADR 0055: o que se digita aqui é o que o agente pode ler e escrever. Nenhuma rota nova, e nenhuma mudança de papel (RequireRole('maintainer'), como já era) — o que mudou é o alcance do que a rota concede.Desde o ADR 0142 (RN-501) os DOIS modos que não são
containervalidam a MESMA coisa na criação: só a forma LÉXICA — absoluto, sem.., nunca a raiz nem uma pasta de sistema, nunca sobreposto ao checkout do Brabo em nenhum dos sentidos (RN-422/RN-423) — sem I/O de disco nenhum.mountedacrescenta uma regra própria: o caminho tem de estar dentro deBRABO_PROJECTS_BASE, a única pasta da máquina que os dois containers enxergam (ADR 0141), e sem base configurada a recusa diz que o MODO não está disponível nesta instalação em vez de culpar o caminho.A parte relevante para a segurança dessa mudança é o que NÃO se moveu. O caminho informado continua sendo a raiz do escopo do terminal, e o predicado léxico continua sendo o que o contém — ele roda em toda derivação da raiz, nas LEITURAS também, porque escrever direto no banco é a única forma de contornar a criação. O que foi adiado é a checagem de DISCO (existe? é diretório? este processo consegue escrever nele?), que nunca conteve nada: ela respondia "isto vai funcionar", não "isto é permitido". Ela roda agora em
materializarWorkspaceMontado, quando a Infra sobe o container (e, como exceção declarada, na conversão de modo), e a regra da base é reaplicada ali ANTES domkdir -p— então um caminho escrito fora do produto não faz a api criar pasta em qualquer lugar que ela alcance.A regra da base fica FORA do predicado léxico de propósito: aquele roda em toda leitura, e um projeto
mountedLEGADO fora da base passaria a falhar só por ser lido.runnercontinua sendo confirmado pelo próprio runner, a única parte com autoridade sobre o host real (RN-423, verPOST /internal/projects/:projectId/workspace-verificationabaixo);mountedpassa a ser confirmado pela materialização, que carimbaworkspace_verified_atdo mesmo jeito. O modo fica congelado depois:UpdateProjectDtoomite os dois campos de propósito, senãoPartialType(CreateProjectDto)os exporia numPATCHsem guarda nenhuma. -
GET /workspaces/:workspaceId/containersagora lista TODO projeto do workspace, e a página que ele alimenta virou o caminho humano para SUBIR um container (RN-521, ADR 0136). O papel não mudou (viewer, como já era) e o escopo também não: ela continua respondendo só pelos projetos do workspace que o chamador enxerga. O que mudou foi o conjunto de linhas — um projeto que nunca provisionou container ficava AUSENTE e agora aparece comregistrado: null, um TERCEIRO estado que não éstoppednem "não foi possível observar". A leitura continua limitada do mesmo jeito: três consultas em lote qualquer que seja o número de projetos, e no máximoTETO_DE_VERIFICACOES_POR_CARGA(20) chamadas ao broker por carga, com as linhas sem container nunca elegíveis e portanto nunca capazes de tirar a vez de uma real.Nada nesta página age diretamente. Parar, remover e subir são todos
proposed_action, sempre com um HUMANO clicando, nunca um agente, e nenhum teto dedecide.tsé tocado: os dois tipos de subida exigemmaintainer, econtainer_removefica no teto absoluto da família de comando privilegiado (RN-418) — nunca auto-aprovável, "sempre permitir" recusado na fonte.container_recreate(remove + sobe de novo, RN-934) entra no mesmo teto. A rede declarada (PUT .../container/network,maintainer, RN-932) só grava artefato — nada muda no Docker até a recriação aprovada. A tela ramifica a ação de subida porexecution_modeexatamente como o backend (RN-497/RN-503):container/mountedpropõemcontainer_start(broker) erunnerpropõecontainer_start_via_runner(agente local), com o payload que o schema de cada um de fato aceita.As recusas da própria tela são honestidade, não imposição: ela não oferece o botão sem imagem decidida (o portão da RN-105, válido nos TRÊS modos desde a RN-494), nem para um projeto
runnercuja pasta nenhum agente local jamais confirmou, nem para papel abaixo demaintainer— e diz qual dos três é, em texto. Quem recusa de verdade continua sendo oRolesGuarde os casos de uso. O papel lido aqui é o de WORKSPACE, não o papel efetivo do projeto (projectRole ?? workspaceRole, RN-471): um membro rebaixado dentro de um projeto ainda vê o botão e recebe 403, um custo declarado de não pagar um N+1 deproject_membersnuma página que atravessa projetos. -
PUT /projects/:projectId/mirror-pathgrava um caminho na máquina do USUÁRIO, e a api nunca vê essa máquina (RN-515, ADR 0147, ponto 4). Ela guarda o destino para onde a capacidadeespelhocopia o trabalho do projeto — uma pasta FORA da base montada, que é justamente por que um bind mount não a alcança e um agente local precisa alcançar.O mínimo é
maintainer, o mesmo deexecution-modeeprojects-baseacima, e pelo mesmo motivo: esta rota trata de um caminho no próprio sistema de arquivos do operador, não de metadado de projeto.O que a api consegue validar aqui é SÓ a forma LÉXICA, e a mensagem de recusa diz isso. Ela reusa
caminhoDeWorkspaceLocalValido— absoluto, sem../., nunca a raiz, nunca uma pasta de sistema, nunca sobreposto ao checkout do próprio Brabo — exatamente como a criação emrunnerfaz (RN-423), e pelo mesmo motivo: não há disco aqui a quem perguntar. Ela NÃO exigeBRABO_PROJECTS_BASE, porque estar fora da base é o objetivo.Duas recusas são próprias desta rota. O destino não pode estar dentro de
workspacePathnem contê-lo — o mesmo laço visto dos dois lados, já que gravar o espelho dentro da própria origem faz o espelho copiar a si mesmo — e a comparação é por SEGMENTO (dentroDoEscopo), então/base-outranão está dentro de/base. E um projeto emexecution_mode: containernão pode ter destino nenhum: a origem dele é um volume gerenciado NO SERVIDOR, e o processo que copiaria roda na máquina do USUÁRIO, que não o enxerga. As duas são 400.A outra metade da guarda — resolver symlinks com
realpath, para que um link em qualquer segmento do destino não aponte de volta para dentro da origem — é do RUNNER, na máquina onde os dois caminhos de fato existem. Esta rota não estabelece essa garantia; ela só descarta o laço ESCRITO. Limpar émirrorPath: null, e a chave é obrigatória: omiti-la seria indistinguível de pedir para limpar, e limpar em silêncio é o defeito. Gravar o espelho nunca éproposed_action— é configuração que o usuário declarou, não um agente pedindo para agir. -
GET /projects/:projectId/mirror-stateéviewer, um degrau abaixo da rota que GRAVA o destino (RN-517, ADR 0147, ponto 7). Ela devolve o que a última rodada do espelho fez: qual dos três estados vale agora (never|synced|failed), a última sincronização bem-sucedida com as contagens dela, o destino congelado daquela rodada e o último erro.A assimetria com
PUT .../mirror-path(maintainer) é deliberada e é a regra da RN-102 aplicada: o mínimo é do ENDPOINT, e este só LÊ. O próprio destino já vai em toda leitura do projeto (GET /projects/:projectId,viewer), então exigirmaintaineraqui trancaria uma informação de quem já a vê no mesmo projeto — o pior dos dois defeitos, porque é invisível para quem perdeu a capacidade.Vale dizer o que ela NÃO expõe: nenhum nome de arquivo, nenhum conteúdo, nenhum caminho além do destino que o chamador já pode ler. As contagens são contagens.
-
GET /workspaces/:workspaceId/projects-baserevela um pedaço da topologia do sistema de arquivos do operador, e é por isso que não éviewer(ADR 0141, RN-500). Ela devolve{ projectsBase: string | null }— a única pasta do host montada por identidade nos containersapieengine, sob a qual vive todo projetomounted.nullé o estado normal de uma instalação que não oferece o modo Montado, e é assim que o assistente de projeto aprende a não oferecer um modo que esta instalação não consegue honrar.O mínimo é
maintainer: o mesmo dePOST /workspaces/:workspaceId/projectsacima, porque decidir o que aquela rota oferece é a única serventia deste valor. Herdarviewerdas leituras vizinhasprojects-*— elas ficam lado a lado no mesmo controller — entregaria um caminho absoluto do host a todo mundo que consegue olhar o workspace, sem capacidade nenhuma em troca.workspaceIdnão entra no cálculo: a base é configuração da instalação, idêntica para todo workspace, e o parâmetro está lá só para dar um escopo aoRolesGuard.Desde o ADR 0161 (RN-573) a resposta também leva
brokerConfigurado: boolean— seBROKER_URLestá definido, lido porContainerBrokerPort.configurado(). Ela revela um bit da topologia da instalação (existe ou não um broker com o socket do Docker), nunca o endereço dele, e o mínimo continuamaintainer. O mesmo bit vai em toda linha deGET /workspaces/:workspaceId/containers(viewer, RN-574), um alargamento deliberado: aquela página precisa dizer ANTES do clique quecontainer/mountednão sobem aqui, e quem consegue ver a página já podia inferir a ausência pornaoObservado: broker-nao-configuradosempre que uma linha estáprovisioning/running. Ela diz que a variável existe, nunca que o broker responde. -
GET /workspaces/:workspaceId/project-foldersserve listagens de diretório a partir de um caminho informado pelo cliente, e toda a segurança dela é UMA contenção (RN-504, ADR 0141). Ela existe porque o assistente de projeto perdeu de uma vez os seus dois mecanismos de "procurar uma pasta":FolderBrowserModalnavegava pelo websocket do RUNNER, eRunnerOnboardingPanelusavashowDirectoryPicker, que devolve um handle do navegador e nunca um caminho absoluto. Com o runner saindo da criação de projeto, o navegador não tem como listar um sistema de arquivos — então a api lista, e a api só enxerga uma pasta: a base de projetos montados.A contenção é essa base e nada mais.
pathé opcional e o padrão éBRABO_PROJECTS_BASE; todopathinformado tem de satisfazerdentroDaBaseDeProjetos— o mesmodentroDoEscopoque o escopo do terminal do ADR 0055 usa, então/home/you/brabo2NÃO está dentro de/home/you/brabomesmo com a string começando igual. Sair da base é 400, não 403: 403 sugeriria que algum outro papel a veria, e nenhum vê. O pedido está malformado, não sub-autorizado...e.são recusados de saída em vez de resolvidos, pelo mesmo motivo que o predicado da criação os recusa — resolver é aceitar que o caminho lido não é o caminho pedido.Os tetos fazem parte do contrato, não são detalhe de implementação: só diretórios, no máximo 500 (ordenados ANTES do corte), nunca recursivo, entradas com prefixo de ponto excluídas, e symlinks informados mas nunca percorridos — então um link apontando para fora da base não é uma saída dela. O que é excluído é CONTADO (
arquivos,simbolicos,truncado), então uma pasta cheia de código nunca volta parecendo vazia (RN-180). Não há POST companheiro: criar pasta é da materialização do workspace montado, nunca do seletor.O mínimo é
maintainer, o mesmo dePOST /workspaces/:workspaceId/projectseprojects-baseacima, e pelo mesmo motivo levado um passo adiante:projects-baserevela UM caminho na máquina do operador, esta rota revela a TOPOLOGIA abaixo dele. Quem não pode criar projeto não tem nada a fazer com a lista de pastas onde um projeto poderia ser criado. -
GET /é o "Hello World!" do scaffold do NestJS (src/app.controller.ts). Está atrás da guarda e não vaza nada, mas não serve para nada — candidata a remoção. Ficou registrada aqui em vez de removida, porque isso é decisão de produto, fora do escopo desta sessão. -
GET /internal/projects/:projectId/git-remoteé a única rota do produto que devolve um segredo DECIFRADO — o token de git do owner do workspace (ADR 0056). Ela existe porque o engine trabalha no sistema de arquivos e não tem a chave mestra; replicá-la no engine dobraria o raio de impacto do segredo mais sensível do produto. Duas propriedades a mantêm defensável: ooriginque ela devolve é limpo (a credencial vem num campo separado, nunca embutida na URL), e o consumidor é obrigado a injetá-la por invocação, nunca em arquivo — verEngine.Actions.GitAuthe o raciocínio disso na RN-076. Se esta rota um dia passar a devolver a URL já autenticada, o token acabaria em.git/config, dentro da pasta onde o dev agent tem leituras auto-aprovadas.No modo
runneressa injeção por invocação viaja um salto a mais — o campoenvda mensagemexec(RN-507) — e ela para na fronteira do container: a porta de Docker do produto não tem campoenv, de propósito (ADR 0130), então um comando roteado para dentro do container pordocker execnunca consegue levar o token. Essa contenção se manteve; o que não se manteve foi o relato. Até a RN-558 o runner rodava esse comando mesmo assim, com o credential helper instalado e as variáveis vazias, e a falha de autenticação resultante era indistinguível de um token ruim ou de uma rede quebrada — no caminho comum, já que o containerrunningregistrado que a RN-507 exige só existe porque esse mesmo runner o subiu. O runner agora recusa o par (credencial presente, container ativo) antes de executar qualquer coisa, e o engine classifica a recusa com origempoliticaem vez decodigo. O texto da recusa não nomeia as variáveis nem os valores delas, só quantas eram. A credencial continua sem atravessar a fronteira do container, e desde o ADR 0193 (RN-676) ela não precisa: o engine marca ofetchautenticado (gitCredenciado: true, posto de um lugar só,RunnerRouter.exec_git_credenciado/5) e o runner roda ESSE comando no host, onde oenvchega ao processo filho; os comandos do dev agent continuam indo paradocker exec. A fronteira de confiança é a marca, não oenv: um comando levandoenvSEM a marca continua recusado enquanto há container ativo, então o campo de credencial nunca vira uma saída do container. Nenhuma porta do ADR 0130 mudou. -
As três rotas de leitura do PO —
GET /internal/projects/:projectId/business-rules,GET /internal/projects/:projectId/backlog(RN-164) eGET /internal/projects/:projectId/product-metrics(RN-407) — não devolvem segredo nenhum e não aceitam nada além do id do projeto: nenhum termo de busca, nenhuma paginação, nenhum filtro. É de propósito. Uma rota de leitura para agente é uma superfície que o modelo escolhe chamar, e um parâmetro é onde o modelo escreve o que quiser; aqui não há onde escrever. O escopo é fechado ao projeto pelo caminho, e o custo por chamada é constante (três leituras no backlog, duas nas regras, uma consulta aproposed_actionsfiltrada por índice nas métricas de produto). -
A leitura de contratos do dev agent —
GET /internal/projects/:projectId/module-contracts(RN-684, ADR 0200) — segue a mesma regra: nenhum segredo, nada além do id do projeto, uma entrada por módulo domodule_mapvigente. A gêmea de escrita,POST /internal/sessions/:sessionId/module-contracts, recebe só a declaração do Arquiteto (o que cada módulo expõe) e valida todo nome contra omodule_mapvigente; nenhuma das duas rotas toca um worktree. -
POST /internal/projects/:projectId/workspace-verification(RN-423, ADR 0104) é chamada só pelo engine, depois que um runner conecta e mandaworkspace_confirmpelo canal — nunca diretamente pelo runner, que não tem o service token. O runner é a FONTE DE VERDADE do caminho (ele SOBRESCREVEworkspacePath, sem exigir que bata com o que foi digitado na criação), mas o caminho informado ainda passa pela MESMA checagem léxica da criação (caminhoDeWorkspaceLocalValido) — raiz do sistema e sobreposição com o checkout do Brabo continuam proibidas mesmo vindo do runner.400se o projeto não estiver no modorunner.Desde o ADR 0151 (RN-529) o runner pode TAMBÉM nascer com uma base — uma pasta daquela máquina sob a qual cada projeto é uma subpasta. Duas coisas sobre ela cabem nesta página. Primeiro, a base é local e nunca chega pela rede: vem de
--baseou de$XDG_CONFIG_HOME/brabo/runner.json, nunca de um campo do servidor, então o invariante do ADR 0130/ ADR 0144 vale deste lado também — quem é dono da raiz é quem executa, e só o segmento relativo viaja.resolverPastaDoProjetoNaBaserecusa um segmento ABSOLUTO lexicamente em vez de reinterpretá-lo. Segundo, a base não entra na validação de--dir, exatamente como a regra da base fica fora do predicado léxico da api: um projeto cuja pasta é anterior à base continua funcionando. A guarda é uma TERCEIRA irmã deguard.ts, reusandodentroDoEscopo/realpathMaisProximo/semBarraFinale a mesma passada dupla léxica-depois-realpath— e herda por escrito a mesma ressalva de TOCTOU: melhor esforço, nunca a fronteira de segurança.Desde a RN-532 (mesmo ADR, pontos 3 a 6) essa base tem um CONSUMIDOR: o par
workspace_create/workspace_create_result. Três coisas sobre ele cabem nesta página. Primeiro, nenhuma rota de escrita nova nasceu: tendo criado a pasta, o runner empurra oworkspace_confirmque já existia, e é ele — por este mesmo endpoint — que carimbaworkspace_verified_at. O engine continua sem escrever a tabela, e o caminho único que carimba continua único. Segundo, o que viaja é o segmento relativo, nunca um caminho absoluto, e o runner recusa um absoluto lexicamente. Terceiro, a capacidadeworkspaceé a única das quatro cuja declaração depende do ESTADO do runner e não da versão dele — ela só é declarada quando uma base foi consentida —, então é por essa declaração, e por nada mais, que o servidor fica sabendo que existe uma base. Ninguém a EXIGE no join: um runner sem base conecta e atende o projeto dele como sempre, e sóworkspace_createé recusado, com uma resposta NOMEADA. Criar essa pasta é configuração consentida, não um agente pedindo para agir: não éproposed_action, e nenhum teto dedecide.tsganha exceção. -
POST /internal/projects/:projectId/container-exec(RN-492, ADR 0134) é chamada só pelo engine, quandoEngine.Actions.TerminalExecutordecidiu que um comando de terminal pertence ao container real do projeto. Ela repassa aContainerBrokerPort.exec— a api nunca roda o comando ela mesma. Diferente decontainer-specabaixo, aqui é o ENGINE chamando a api, não o broker; o corpo da resposta nunca lança por recusa do broker nem por broker inalcançável ({ sucesso: false, motivo }é a forma normal, pela RN-486 — uma linharunningnunca garante que o container está de pé agora), então um container morto é um comando falho comum, não um 5xx. -
POST /internal/projects/:projectId/mirror-sync-result(RN-517, ADR 0147, ponto 7) é chamada só pelo engine, depois que o runner empurramirror_sync_resultpelo canal — nunca diretamente pelo runner, que não tem o service token. A mesma forma e o mesmo caminho deworkspace-verificationacima, e pelo mesmo motivo: o runner é a única parte que sabe o que aconteceu na máquina do usuário, e o engine repassa em vez de escrever a tabela ele mesmo.Ela grava TELEMETRIA e nada mais: uma linha em
project_mirror_states, nunca o event log (uma rodada de espelho não tem sessão esession_events.session_idéNOT NULL) e nunca umproposed_action. O corpo leva um desfecho, três contagens e uma mensagem de erro — nenhum caminho sobre o qual a api aja, nada executado, nada concedido. Gravar nunca derruba o que mede: a cópia já terminou quando isto é chamado, e uma recusa aqui só é registrada em log pelo engine. -
POST /internal/projects/:projectId/qa-environments/:taskId/start,POST /internal/projects/:projectId/qa-environments/:taskId/removeePOST /internal/qa-environments/sweep(RN-935, ADR 0229) são chamadas só pelo engine (engine-service,BRABO_SERVICE_TOKEN). A api chama o broker e gravaproject_qa_environments; o corpo dosweepé só a lista de ids de tarefa em voo, e a remoção só alcança o que o broker rotulabrabo.papel=qa. Oalvode QA docontainer-execvem do engine, nunca do modelo. Desde a RN-941 o serviço de QA levauser(<uid>:<gid>, nunca 0) ecommand(argumentos depois da imagem, sem quebra de linha) noqadocontainer-spec, os dois validados pela api e de novo pelo broker antes de virardocker run. -
GET /internal/projects/:projectId/container-spec(ADR 0130, RN-485) é a única rotaengine-servicecujo chamador NÃO é o engine — é o broker de containers, o único processo do produto com acesso a um daemon Docker. A classificação nomeia o MECANISMO (BRABO_SERVICE_TOKENno cabeçalho próprio, comparado em tempo constante), não o remetente, e o segredo é o mesmo de propósito: os três serviços rodam no mesmo cluster e leem o mesmo Secret, então um segundo segredo daria a impressão de compartimentar sem compartimentar nada (o raciocínio completo está emservice-token.ts). O que faz a rota valer a existência é a direção da chamada: o broker não RECEBE uma spec de container, ele vem aqui LER a identidade do projeto, o modo de execução e a decisão de imagem vigente do Arquiteto, e compõe a spec ele mesmo. Uma spec viajando num corpo de requisição faria a contenção de um processo equivalente a root depender de o chamador estar certo. Ela não devolve caminho absoluto nenhum — a origem do bind é resolvida 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 que viaja no lugar élocalizacao(RN-503, ADR 0144): um localizador discriminado dizendo qual das DUAS raízes do broker resolve a pasta (gerenciada→PROJECT_WORKSPACES_HOST_ROOT,montada→BRABO_PROJECTS_HOST_BASE, ouindisponivelcom um motivo) mais o segmento RELATIVO que essa raiz não cobre. A api nunca fica sabendo onde essas raízes estão no host, e o broker nunca fica sabendo nada do projeto — nenhum dos lados sozinho consegue escrever um caminho arbitrário, e essa é a contenção. O broker recusa ostartnomeando a raiz que falta, nunca caindo na outra, e revalida o segmento (.., absoluto, vazio,NUL, todos recusados) mais o resultado concatenado antes de ele virar um-v. Ele também descartarationale, que existe para um humano revisar a decisão e não tem consumidor numdocker run. -
GET /internal/projects/:projectId/container-specganhouusuarioDaPasta, e a classificação não mudou — continuaengine-service, continua chamada só pelo broker (ADR 0180, RN-627). O campo é o{ uid, gid }da pasta do projeto como a api a vê pelostat, ounull(modo runner, pasta inalcançável, pasta de root). O broker o revalida (inteiros em 1..2^31-1;0é recusado pelo nome) e o usa como--user; ele nunca lê um usuário de corpo de requisição, então continua não havendo campo em que um chamador escrevaprivileged,cap_addou uma montagem. Ele roda o container com MENOS poder que antes (non-root em vez de root), com--cap-drop ALLintacto. -
POST /projects/:projectId/runner-ticketé classificadarole:developercomo qualquer outra rota, mas NÃO aceita JWT de sessão (ADR 0105, RN-424) — só um Personal Access Token (brb_…) OU uma chave de dispositivo do runner (Ed25519, ver abaixo). A classificação automática (route-surface.spec.ts) não distingue os mecanismos, porque a EXIGÊNCIA de papel é a mesma; o que muda é só comorequest.useré estabelecido.PatAuthGuardroda no lugar deJwtAuthGuardnesta rota (@RequirePatAuth(), o mesmo padrão estrutural de@ServiceRoute()/EngineServiceGuard— desvio por metadado, nunca umifque uma rota nova possa esquecer), e é o ÚNICO lugar da api que aceita qualquer um desses formatos de credencial: em qualquer outra rota umbrb_...falha na verificação de JWT normalmente, e um JWT de chave de dispositivo também (okiddele nunca resolve contra as chaves do próprio emissor de tokens de sessão). As cinco rotas sob/projects/:projectId/personal-access-tokens(emitir/listar/revogar o próprio PAT, mais as duas demaintainer— RN-427, listar/revogar de QUALQUER usuário do projeto) continuam JWT de sessão comum — só a rota que o PRÓPRIO TOKEN autentica muda de mecanismo. -
GET /runner/projectsé classificadajwte NÃO aceita JWT de sessão nenhum (RN-543, ADR 0154). É a rota pela qual um agente de MÁQUINA descobre os projetos que atende, e é a primeira rota@RequirePatAuth()sem:projectIdno caminho — que é exatamente o que ela existe para resolver: quem chama ainda não sabe quais projetos existem. Duas consequências, ambas fixadas por teste:- Só uma credencial de MÁQUINA entra (
runner_device_keys.project_idNULL). Uma credencial amarrada a um projeto — um PAT, ou uma chave de dispositivo do fluxo do ADR 0118 — descreve um projeto e não tem o que descobrir; oPatAuthGuarda recusa com 403 e uma mensagem PRÓPRIA, nunca a de "projeto errado", que mentiria sobre o motivo. A comparação que recusa chave-de-A-em-B NÃO foi removida: ela some só paranull, e os dois sentidos estão fixados. - Não há
@RequireRole, porque não há projeto contra o qual resolver um papel. O mínimodeveloper— o MESMO dorunner-ticket— é aplicado POR LINHA dentro deListRunnerProjectsUseCase, com a régua única do produto (ResolveEffectiveRoleUseCase.forProject). Nada é afrouxado: o que sai é exatamente o conjunto de projetos para os quais orunner-ticketjá autorizaria este usuário. O classificador automático lê@RequireRolee não vê mecanismo, então cai emjwt— a mesma aproximação que este documento já corrige em prosa para orunner-ticketacima. A rota nunca toca um JWT de sessão: oJwtAuthGuardse abstém nas rotas@RequirePatAuth(), e a chave pública que oPatAuthGuardverifica vem derunner_device_keys, nunca do emissor de sessão.
- Só uma credencial de MÁQUINA entra (
-
POST /runner/llm-credentialsé classificadajwte só aceita chave de dispositivo de MÁQUINA (RN-927, ADR 0230). É a PRIMEIRA rota em que uma credencial de máquina ESCREVE segredo de usuário: obrabo-runner credentials importgrava a chave de API de LLM que a pessoa escolheu importar, pelo mesmo upsert com envelope do cadastro pela tela. ComoGET /runner/projects, não tem:projectId, e oPatAuthGuardrecusa com 403 toda credencial amarrada a projeto; todo PAT é de projeto, então na prática PAT não chama esta rota (o guard não distingue "PAT de conta" porque ele não existe). Credencial existente exigesubstituir: true(409credencial_existente), e o valor nunca vai a log, evento nem resposta (o pino já redige*.apiKey; a auditoria leva provider eorigem). O teto é a revogação da chave de máquina.Ela devolve o id do projeto, o nome, o
workspaceDirNamee o estado de verificação — nunca um caminho absoluto. O que atravessa o fio é o SEGMENTO, e a raiz é de quem executa, o mesmo invariante do broker (ADR 0144) e da base do runner (ADR 0151).Desde a RN-544 a rota tem o seu CONSUMIDOR: rodado sem
--project, obrabo-runnera chama no início e abre uma conexão por projeto listado — e, desde a RN-550, quando essa lista volta vazia ele continua chamando, numa cadência declarada (15s, 30s, depois 60s repetindo), até o primeiro projeto aparecer. Essa espera tem teto do lado da falha, não do lado da espera: dez chamadas seguidas com falha e o processo sai com 1 nomeando a contagem, qualquer resposta (vazia inclusive) zera o contador, e no momento em que uma conexão está viva a espera para — com conexão viva a lista continua lida só no início, pelo motivo abaixo. Cada conexão tem raiz em<base>/<workspaceDirName>pelas mesmas guardas (resolverPastaDoProjetoNaBase, depois RN-434/RN-435). Três coisas sobre isso pertencem a esta página. Primeiro, nada no engine mudou: o tópicoterminal:<projectId>, o id do socket e o ticket descrevem uma CONEXÃO, e N conexões os satisfazem byte a byte — a recusa de um segundo runner no mesmo projeto não foi tocada, nem o espelho (RN-516) e oworkspace_create(RN-532), que já viajavam na concessão do join DAQUELA conexão. Segundo, o JWT de descoberta não leva claimprojectId— a guarda comparapayload.projectId !== request.params.projectId, e numa rota sem projeto no caminho os dois têm de serundefined; assinar ali um id de projeto inventado seria recusado com um 403 que diz a coisa errada. Terceiro, o runner nunca adivinha a espécie da própria chave: em disco uma chave de máquina e uma de projeto são o mesmo arquivo (uma JWK privada comkid), quem sabe é o servidor, e o 403 nomeado é repassado com a própria mensagem e a própria correção (--project) em vez de achatado numa falha de conexão genérica. O modo novo exige AMBAS, uma credencial de máquina e uma base consentida — sem base não há de onde derivar a pasta de cada projeto, e inventar uma gravaria no disco do usuário um caminho que ele nunca consentiu; sem as duas, rodar sem--projectcontinua imprimindo o uso. -
Um cliente do socket
/runnerNUNCA pode deixar ophoenix.jsreconectar sozinho (RN-108). O ticket é de uso único, e o auto-reconnect embutido repete os MESMOSparams— então um socket construído semreconnectAfterMstenta de novo um ticket morto para sempre. Isto não é hipotético:apps/runner/src/channel.tssaiu assim enquanto o próprio docblock afirmava o contrário, e foi medido em uso real — o mesmo ticket recusado a cada ~5,13s (o teto do backoff interno da biblioteca), 61REFUSED CONNECTION TO EngineWeb.RunnerSocketem poucas horas e, somado à política PRÓPRIA de retentativa do runner, 530 requisições em um minuto contra o teto de 300 req/min doRATE_LIMIT_USER. O limite é por usuário, então a negação de serviço caiu no NAVEGADOR do dono da conta, como 429 — não há terceiro não autenticado envolvido, mas um cliente mal construído basta para trancar o próprio usuário do lado de fora. A reconexão é sempre a política própria de quem chama, com ticket novo a cada tentativa; a opção agora é OBRIGATÓRIA no tipo (OpcoesDoSocket) e asserida por um teste sobre a opção passada ao construtor, já que um teste que só conferia "conecta" passou o tempo todo. -
As duas rotas
/projects/:projectId/runner-device-keysSÃO JWT de sessão comum, ao contrário dorunner-ticketacima — quem chama é uma tela (a seção de chaves de dispositivo das Configurações, e oRunnerOnboardingPanelreconhecendo uma máquina já pareada). Até o ADR 0203 (RN-687) havia uma terceira, umPOSTpelo qual o navegador registrava a chave pública Ed25519 que acabara de gerar (ADR 0118). Esse fluxo foi aposentado e a rota saiu junto, porque não tinha outro chamador: as chaves agora nascem na máquina —brabo-runner device-key create(RN-551) gera o par e grava a metade privada em disco, modo 600, em$XDG_CONFIG_HOME/brabo/(senão~/.config/brabo/) — e são registradas pelo instalador pela rota interna abaixo. A metade privada continua nunca atravessando o fio; só a JWK pública e oiddo registro atravessam. O que esse CLI deliberadamente não tem é uma credencial com que registrar — o registro fica com quem detém o service token (o instalador, ADR 0155 ponto 1), então cada lado detém exatamente um segredo e nenhum vê o do outro. O arquivo que o runner lê nunca existe sem o seukid:creategrava um nome.parcialque o leitor ignora, efinish --id <id>carimba e renomeia. Chaves de projeto registradas pelo navegador ANTES do ADR 0203 continuam valendo: oPatAuthGuardainda as aceita, oGETas lista e oDELETEas revoga. OGETlista as chaves do próprio chamador, revogadas inclusive (RN-519) — é o que torna a revogação alcançável, e até ele existir uma chave órfã (aba fechada no meio do fluxo de configuração automática) era invisível e permanente. Ele nunca devolve a JWK pública: a lista existe para revogar, elastUsedAt: nullé o sinal da órfã. ODELETErevoga a chave do próprio chamador, de forma idempotente, no mesmo formato da revogação de PAT por autosserviço. OPatAuthGuardé quem DEPOIS aceita um JWT assinado pela metade privada dessa chave norunner-ticket, buscado pelo cabeçalhokidigual aoiddesta tabela; a guarda confere que a chave não foi revogada, mas nunca uma expiração — a chave em si não expira, só o JWT de TTL curto (≤60s,exp - iat) que o runner assina com ela a cada vez. Desde a RN-543 oGETdevolve DUAS espécies e DIZ qual é qual (especie): uma chave de MÁQUINA (projectId: null) serve todo projeto do seu dono, então aparece na listagem de todo projeto — sem isso ela seria invisível e permanente em toda tela, o mesmo defeito que a RN-519 fechou, renascido na espécie nova. A rota que cria chaves de MÁQUINA éPOST /internal/machine-device-keys, abaixo (RN-552) — outra rota, outra credencial e outro chamador. O MATERIAL da chave que ela registra é produzido na máquina:brabo-runner device-key create(RN-551) gera o par Ed25519 localmente e imprime só a JWK pública, então a metade privada nunca viaja. Desde a RN-547 oinstall.shencadeia as duas no fim de uma instalação, e instala a unit de máquina com a chave resultante (ADR 0155) — que é o que torna real, e não hipotético, o custo declarado desta rota. Revogar uma chave de MÁQUINA pede ao engine que derrube o runner vivo em CADA projeto em modo runner que o dono alcança — uma chamada{project, user}por projeto, sem tocar o engine. -
A chave de MÁQUINA também é alcançável por CONTA, sem projeto no caminho (RN-611):
GET /users/me/machine-device-keyseDELETE /users/me/machine-device-keys/:deviceKeyId, classificadasjwtporque o escopo é o próprio chamador, como o resto de/users/me/*. Elas existem porque a instalação de uma linha cria a conta e a chave de máquina ANTES de qualquer projeto, e toda outra listagem precisa de um:projectId: ali, a chave estava viva e inalcançável. São estreitas de propósito — só chaves de MÁQUINA (uma chave de projeto responde 404, o mesmo que uma chave inexistente ou de outra pessoa, então nada vaza) e só as do CHAMADOR (ouserIdvem do JWT de sessão e entra noWHERE; nenhum parâmetro nomeia outro usuário, e a visão demaintainerfica de fora, como a RN-519 decidiu). ODELETEé a mesma revogação da rota por projeto, delegada ao mesmo caso de uso: derruba as conexões vivas abertas COM aquela chave, em todo projeto (desde a RN-685 o alvo é a chave, não{project, user}); sem projeto ainda, ela só registra a revogação, que é tudo o que há para parar — o agente de máquina que espera (RN-550) não tem conexão, e o próximo ticket dele é recusado. -
Revogar uma chave de dispositivo agora alcança a conexão VIVA, e o alvo é
{project, user}— nunca{key}(RN-520, ADR 0147 ponto 6). ODELETEsó barrava o PRÓXIMO ticket: umbrabo-runnerjá conectado mantinha vivo o canalterminal:<projectId>, rodando comandos aprovados com a chave revogada, até cair sozinho. A api agora pede ao engine (POST /internal/projects/:projectId/runner/disconnect) que o derrube, e o engine alcança o pid do canal — a api nunca fala com o canal, e o engine nunca lê a tabela de chaves. A precisão que NÃO existe é por credencial, e isso é propriedade do caminho do ticket, não preferência:runner_socket_ticketsguardaproject_id/user_id/kinde nada mais, então qual PAT ou qualkidabriu aquele socket morre noPatAuthGuarde nunca chega ao engine. Custo declarado: um runner do MESMO usuário conectado com um PAT, ou com outra chave do mesmo projeto, também cai — e reconecta sozinho, porque a rodada seguinte pede ticket novo e uma credencial que continua válida o obtém. Um runner de outro usuário no mesmo projeto fica de pé. Derrubar a conexão é EFEITO COLATERAL: engine fora do ar, nenhum runner conectado ou um timeout nunca podem fazer oDELETE(204, idempotente) falhar ou virar 5xx — a mesma régua derag_searches(RN-479) emirror_sync_result(RN-517). Revisado pela RN-685 (ADR 0201): o alvo agora é a CHAVE. O ticket registra qual credencial o pediu (okid, ou o id do PAT), e a revogação pede ao engine (POST /internal/runner/disconnect-credential) que anule os tickets pendentes da credencial e derrube só os runners nascidos dela — o custo declarado acima sumiu: um runner do mesmo usuário com PAT ou outra chave fica de pé. A revogação de PAT (própria, ou comomaintainer) agora derruba as conexões dele também. Uma conexão aberta com ticket anterior ao deploy (sem credencial) continua caindo pelo par, e um engine que não conhece a rota faz a api voltar arunner/disconnect; a rota do par também fica como alvo da remoção de membro. A visão demaintainerque o PAT tem (RN-427, listar/revogar de QUALQUER usuário) fica DE FORA para chaves de dispositivo — agora por decisão, não omissão: aquele par nasceu de resposta a incidente com um segredo COMPARTILHADO circulando, e a metade privada de uma chave de dispositivo nunca sai da máquina que a fez. Essa frase dizia "o navegador que a fez", e a RN-551 alargou o fabricante sem enfraquecer a afirmação: o terminal é agora um segundo gerador, e a metade privada continua nunca viajando — o que sai é a JWK pública. O que MUDOU é onde ela repousa: uma chave feita no navegador pousa numa pasta de projeto que o usuário escolheu, uma feita pelo CLI pousa em$XDG_CONFIG_HOME/brabo/com modo 600. Nenhuma das duas é alcançável pela api, e é disso que a decisão acima depende. -
As rotas
engine-servicenão são "internas" por convenção de nome. O que as protege é oEngineServiceGuardcomparandoX-Brabo-Service-Tokencom o segredo compartilhado em tempo constante, mais a NetworkPolicy. O prefixo/internalé sinalização para humanos. Elas ficam fora do JWT via@ServiceRoute(): o token de usuário não funciona aqui e o service token não funciona em nenhuma outra rota — os dois mecanismos nunca se sobrepõem (RN-035). -
POST /internal/first-accounté um segundo caminho que cria USUÁRIO, e funciona em produção (RN-546, ADR 0155). Declarado aqui em vez de deixado para ser descoberto. O registro normal é o primeiro caminho e não foi tocado; este existe porque uma instalação nova de uma linha não deixa ninguém entrar — o.envgerado não tem variável de e-mail,MAIL_TRANSPORTcai emlog, e o registro espera um e-mail que ninguém envia.Ele é estreito por construção, por três motivos independentes. É
engine-service, então o que o abre é oBRABO_SERVICE_TOKEN— que o próprioinstall.shgerou e gravou com modo600, tornando-o prova de controle sobre a MÁQUINA, não sobre uma caixa de e-mail. Recusa com409quando a instalação tem QUALQUER usuário, condição sobre a instalação e não sobre o e-mail pedido, então não pode ser chamado repetidamente com endereços diferentes, e fica calado para sempre assim que alguém existe (inclusive depois de uma restauração de migração, RN-530). E nenhuma rota pública nasceu: um "criar o primeiro owner" público é uma corrida entre quem instalou e quem varreu a porta, eownerdo primeiro workspace não é papel que alguém recupere por HTTP.A conta nasce com o e-mail já verificado, e essa é a única coisa que esta rota relaxa — para um caso nomeado, nunca para o outro caminho. O que a verificação prova é "esta pessoa controla esta caixa de e-mail"; quem roda o instalador já provou a máquina, o
.enve o daemon do Docker. A senha é digitada no TTY, lida sem eco, passada por hash com argon2id e descartada: nunca no.env, nunca no marcador, nunca num log, e nunca gerada por código nenhum.Uma janela é declarada em vez de fechada: a checagem "existe algum usuário?" roda dentro da transação, mas
READ COMMITTEDnão impede que duas chamadas concorrentes com e-mails diferentes passem as duas. Fechá-la exigiria um advisory lock sobre a ausência de linhas, e não é ali que mora a contenção — quem alcança esta rota já detém o service token, ou seja, já controla a instalação. -
POST /internal/machine-device-keysCUNHA UMA CREDENCIAL DURÁVEL DE USUÁRIO a partir de um segredo de máquina (RN-552, ADR 0155 ponto 4). Esta é a borda mais afiada que o service token tem, e é declarada aqui pelo mesmo motivo que a irmã acima: o próximo passo do instalador depois de criar a primeira conta é parear o agente local, e parear exige uma credencial — a única que existia era amarrada a um PROJETO, numa instalação que ainda não tem nenhum.O que custa, dito com todas as letras: um
BRABO_SERVICE_TOKENvazado agora pode FABRICAR uma chave de dispositivo que age como usuário contra rotas com checagem de papel (POST .../runner-ticket, e por ela o canal do runner), em vez de só falar com as rotas internas. A credencial alternativa — a do próprio usuário recém-criado — foi considerada e recusada porque contradiz o próprio ADR 0155: a senha é lida no TTY, usada e descartada, e o ponto 5 afirma que o instalador "não faz login por ninguém", e é por isso que a resposta da primeira conta deliberadamente não leva token de sessão. Exigi-la aqui forçaria o instalador a criar, na máquina, exatamente a sessão viva que aquele ADR recusou criar.Três contenções moram na ROTA, não neste parágrafo. Não há
userIdno corpo: o dono é o ÚNICO usuário da instalação, resolvido pela api, então o token nunca compra a escolha de qual credencial cunhar — com zero usuários ou mais de um ela recusa com409e não grava nada, o mesmo formato de condição da primeira conta (sobre a instalação, nunca sobre o argumento), e fica calada de vez assim que a instalação tem um time. Registrar SUBSTITUI: as chaves de máquina ativas do dono são revogadas na mesma transação, então uma máquina reinstalada é caso legítimo e mil chaves de máquina são impossíveis — teto por cláusula, não por um número que envelhece. Uma JWK privada é recusada pelo nome:dpresente responde400dizendo o que chegou, pela mesma regra de domínio que o registro do navegador usa, porque guardar uma metade privada é o pior desfecho que esta rota tem.O que a chave concede não é alargado: ela dá ao agente local exatamente os projetos que o dono já alcança como
developer, resolvidos contra o projeto pedido (RN-543). Duas lacunas são declaradas em vez de escondidas: não háGETnemDELETEaqui (listar publicaria o inventário de credenciais de uma pessoa a quem detém só o segredo da máquina; revogar já existe onde tem um dono humano), e numa instalação que ainda não tem projeto nenhuma tela alcança uma chave de máquina — que é exatamente por que registrar substitui em vez de deixar órfãs para trás. -
/docse/docs-jsonNÃO estão na tabela, e essa é uma lacuna conhecida. A Swagger UI é montada porSwaggerModule.setup()no nível do Express, não como controller, e o teste enumera viaDiscoveryService— ele estruturalmente não as vê. As duas só existem comNODE_ENV !== 'production'(main.ts), são públicas, e servem o mesmo documento que a referência gerada publica. Registrado aqui em vez de deixado de fora: o que o teste não alcança precisa estar na prosa. -
GET /projects/:projectId/agent-areaspassou a devolver dados reais, e a classificação não mudou — continuarole:developer, enquanto oPATCHdo teto seguerole:maintainer. Até a FASE 18 a tabelaagent_areasnunca era escrita e a rota respondia[]a todos, o que fazia a classificação parecer permissiva por acidente, não por decisão. Com a área nascendo junto com o projeto (RN-094), a divisão volta ao que a FASE 14d pretendia: ler o teto é trabalho de quem executa; mudá-lo é decidir quanto o produto gasta sem perguntar, e é por isso que exige o mesmo papel que ativa a execução. -
As cinco rotas
/projects/:projectId/rag/*dividem o papel pelo mesmo critério do teto de paralelismo da área (RN-083) (PROGRAMA 28, Onda 4 — RN-231..234, ADR 0080):searchecoveragesãorole:viewer(leitura pura sobre o que já está indexado), ereindexérole:maintainer— dispara N chamadas ao repositório do projeto e ao provider de embedding, o mesmo "muda o que o produto gasta sem perguntar" que já justifica o papel mais alto em outras rotas de disparo caro.local(RN-455, ADR 0113) érole:maintainertambém, pelo mesmo raciocínio: chama o provider de embedding e substitui o que o projeto tem indexado para aquele escopo. O corpo é TEXTO lido pelo navegador, nunca um caminho do host.feedback(RN-480) érole:viewer, o mesmo papel desearch, e o critério acima é o porquê: votar não gasta nada nem configura nada — é observação, e é o único sinal de verdade que a medição do RAG tem (medir:rag). Subi-lo paramaintaineresvaziaria esse sinal para não proteger nada. O voto continua contido no servidor: umsearchIdde outro projeto, ou umchunkIdque não estava entre os resultados daquela busca, é 400 — um voto sem posição não mede nada. -
As quatro rotas
/projects/:projectId/code/*sãorole:viewere SOMENTE LEITURA (FASE 26b). Ver o código de um projeto é a mesma permissão que ver o projeto — o mesmo corte deGET /projects/:id/git/repository. Três coisas fazem dessa aparente permissividade uma decisão e não um descuido:- não há verbo de escrita no controller, e não pode haver: a aba Código
é para ler, e escrever é efeito externo, que nasce
proposed_actione pertence a uma fase posterior. Um@Postneste arquivo é mudança de fase, não de rota; - o caminho é contido num lugar SÓ (RN-095),
pela mesma checagem central da RN-092 —
e a contenção importa aqui mais que o papel, porque nos providers remotos o
caminho vira segmento de URL da API do provider e um
../troca o endpoint, não o arquivo; - a credencial gasta é a do owner do workspace
(RN-058/RN-082),
como na escrita. Ler custa o rate limit do provider, e é por isso que a
busca tem orçamento: sem teto, um
viewerpoderia inflar a conta do owner à vontade.
- não há verbo de escrita no controller, e não pode haver: a aba Código
é para ler, e escrever é efeito externo, que nasce
-
Três rotas de gasto/curadoria nasceram na AT-632 (RN-952..RN-954, ADR 0233).
GET /projects/:projectId/spend/team(role:viewer) devolve o total do projeto e o custo por agente/área a QUALQUER membro, e nunca provider nem credencial: a rota não tem parâmetro de dimensão e o caso de uso só pedeactor.GET /workspaces/:workspaceId/spend/me(role:viewer) é o consumo PRÓPRIO somado por projeto: o ator vem do token e o escopo de ator impedeproviderpor tipo (RN-187).PATCH /workspaces/:workspaceId/models/:modelId/billing(role:owner, o nível de…/pricing) grava a marca curada. O?projectId=dospend-reportsegueownere responde 404 para projeto de outro workspace. -
GET /workspaces/:workspaceId/spend-reportpassou a devolver o recorte por provider, que é um recorte por CREDENCIAL (ADR 0076, RN-186/RN-187). Nenhuma rota nova e nenhuma mudança de papel — continuarole:owner, como já era —, mas o que ela CONCEDE mudou, e é por isso que esta nota existe. O ADR 0063 tinha recusado esse eixo exatamente por este motivo; o 0076 o revisa por decisão do dono do produto. O que agora segura a fronteira são DUAS barreiras independentes:GET /projects/:projectId/spend/me(role:viewer) não tem parâmetro de dimensão nenhum, e o TIPO recusa a combinação — um escopo comactorsó aceitaExclude<SpendDimension, 'provider'>, então pedir provider na visão do membro não compila. A segunda é mais fraca que a garantia anterior ("a dimensão não existia"), e é por isso que são duas. -
POST /projects/:projectId/sessions/:sessionId/socket-ticketérole:viewerna tabela, mas isso é o PISO, não o teto (RN-108). O@RequireRole('viewer')cobrescope: "heartbeat"— o socket de heartbeat/eventos ao vivo que já existe;scope: "terminal"exigedeveloper, conferido DENTRO deCreateSocketTicketUseCasecontrarequest.effectiveRole(o mesmo que oRolesGuardjá resolveu), porque o papel mínimo depende do CORPO da requisição, não só da rota — o mesmo padrão deMIN_ROLE_FOR_ACTION_TYPE.terminalemdomain/actions/decide.ts. Hoje nenhum caminho real pedescope: "terminal"(o socket do terminal interativo é a FASE 25); o valor já nasce certo para quando ele existir. -
POST /projects/:projectId/membersérole:maintainerna tabela, e esse papel é NECESSÁRIO mas não SUFICIENTE (ADR 0127, RN-472). É o espelho da nota dosocket-ticketacima: lá o papel da tabela é o piso e o corpo pode elevá-lo; aqui o papel da tabela é o portão inteiro que oRolesGuardaplica, e o caso de uso então recusa dois movimentos com 403 mesmo para quem o tem — rebaixar alguém que éownerdo WORKSPACE, e rebaixar a SI MESMO. Nenhuma rota nova e nenhuma troca de papel (RequireRole('maintainer'), como já era); o que mudou é que a classificação deixou de ser a resposta completa para "quem pode fazer o quê aqui". O motivo de isso não poder morar noRolesGuardé o mesmo que a nota dosocket-ticketdá — a decisão depende do CORPO da requisição (dto.role) e do ALVO dela (dto.userId), que o guard não vê —, então a regra é uma função pura emdomain/iam/tetos-de-rebaixamento.tsaplicada porAddProjectMemberUseCase, na FORMA dos tetos absolutos dedomain/actions/decide.ts(RN-418): nenhuma chave de configuração, nada que possa ligá-los. Oownerprotegido éworkspace_members.role, nuncaworkspaces.created_by. O que os tetos NÃO cobrem está escrito no ADR e na RN. -
DELETE /projects/:projectId/members/:userIdtambém érole:maintainere também não basta — a segunda porta do teto de auto-rebaixamento (ADR 0156, RN-556). O ADR 0127 deixou esta rota sem teto por uma premissa que se mostrou falsa — "remoção é benigna". Apagar a linha deproject_membersnão apaga um papel: ele troca o efetivo, porqueforProjectéprojectRole ?? workspaceRole(RN-471). Ummaintainerpor linha de projeto que éviewerno workspace se rebaixava, de forma irreversível pela tela — repor a linha éPOST :projectId/members, que exige omaintainerrecém-abandonado; sem papel nenhum no workspace, a queda é para nenhum acesso. O teto agora é aplicado porRemoveProjectMemberUseCase, que teve de passar a receber o ATOR (a rota não o passava, então nenhum teto poderia ter sido aplicado), e a regra REUSAehAutoRebaixamento—remocaoEhAutoRebaixamentodelega a ela com o papel de workspace no lugar do pedido, nunca uma segunda régua. O teto 1 não tem par aqui, por decisão fixada em teste:owneré o topo doROLE_ORDER, então remover uma linha de projeto só pode ELEVAR o papel efetivo desse usuário — e é justamente assim que se desfaz a restrição que o teto 1 proíbe criar. Preço declarado, já que o teto 2 não tem limiar: a auto-remoção deownerde projeto paramaintainerde workspace é reversível e também é recusada — o único movimento benigno que muda desfecho, ainda alcançável por outromaintainer. -
POST /workspaces/:workspaceId/membersérole:ownerna tabela, e isso também deixou de ser a resposta inteira — a terceira rota desta família (ADR 0157, RN-557). O ADR 0127 nomeou esta rota como a mesma classe de defeito um escopo acima, e o ADR 0156 a deixou como decisão à parte; ela era um passa-adiante de doze linhas que nunca recebia o ator, então nenhum teto poderia ter sido aplicado. Aqui é pior que no projeto por dois motivos que lá não existem: nenhum nível acima segura a queda (no projeto, rebaixar a si mesmo derruba o papel efetivo para o do workspace, que muitas vezes segura), e não há@Deletede membro nenhum neste controller (medido) — desfazer é esta mesma rota, exigindo oownerrecém- abandonado. Por issoAddWorkspaceMemberUseCaseagora recebe o ator e recusa mudar o SEU PRÓPRIO papel com 403, nos dois sentidos. O teto não conta owners, de propósito: a cláusula não tem número para envelhecer (critério do ADR 0127), e ela já produz o invariante que uma contagem existiria para garantir — um workspace nunca chega a zero owners, já que remover o último exigiria que esse owner o fizesse. O teto 1 não tem par neste escopo, considerado e não espelhado: é uma regra sobre inversão de hierarquia, e o@RequireRole('owner')já torna a inversão impossível — quem pode chamar nunca está abaixo de quem toca. Acrescentá-lo, somado à rota de remoção ausente, faria deownerum estado absorvente do qual ninguém sai por HTTP, que é a classe de estado que o ADR 0127 nasceu para eliminar. Rebaixar outro owner, portanto, continua permitido — a única forma de a propriedade ser revogada, e reversível pela mesma rota por qualquer owner que reste. -
DELETE /workspaces/:workspaceId/members/:userIdérole:owner, e é a quinta porta da mesma família (ADR 0173, RN-615). Ela não existia quando o ADR 0157 foi escrito — a frase acima sobre "nenhum@Deletede membro" descreve aquele momento. Ela carrega o mesmo teto sem regra nova:remocaoEhAutoRebaixamentocom o papel de workspace como o efetivo de hoje e nenhum como o papel de depois, já que nenhum nível acima segura a queda — então remover a SI MESMO é sempre 403. Essa mesma cláusula é o que protege o último owner, sem contar: só umownerchama a rota, ninguém remove a si mesmo, então toda chamada bem-sucedida deixa ao menos o chamador comoowner. Remover outro owner é permitido (é como a propriedade é revogada por inteiro), e o teto 1 continua sem par aqui — o@RequireRole('owner')segue impedindo a inversão de hierarquia. A remoção também cascateia, numa transação: as linhas deproject_membersdo alvo neste workspace (sem isso a regra de projeto-sobrepõe-workspace o manteria dentro de todo projeto onde ele tinha linha própria), as chaves de dispositivo de PROJETO dele e os PATs dele aqui (todo PAT pertence a um projeto); depois do commit a conexão viva do runner dele em cada projeto é derrubada, em melhor esforço, pelo caminho da RN-520. Declarado e não feito: chaves de máquina (da conta inteira; elas deixam de alcançar este workspace porque o papel resolve para nenhum), sockets de sessão já conectados. O titular (workspaces.created_by, cujas credenciais de LLM e de git os agentes gastam, RN-058) não pode ser removido: 409criador_do_workspaceaté quePUT /workspaces/:workspaceId/owner-of-record(role:owner, o destino já tem de serowner, senão 409titular_precisa_ser_owner) a transfira — RN-616. Qualquer owner pode transferi-la, inclusive para outro owner que não pediu. -
GET /workspaces/:workspaceId/membersérole:viewer, não oownerdas três rotas de escrita ao lado (RN-652, AT-335). O mínimo é o do endpoint (RN-102): ler a lista não é mantê-la.vieweré o que as leituras vizinhas usam (GET /workspaces/:workspaceId,/projects) e o queGET /projects/:projectId/membersusa — a leitura que esta completa, já que quem entra num projeto só pelo papel de workspace não tem linha de projeto. Quem pode abrir uma sessão pode ver quem falou nela, e umviewerde workspace já vê todo projeto do workspace. O corpo tem a mesma forma da leitura de projeto —userId,name,email,role— e nada mais (semcreatedAt, sem estado da conta). -
A auto-PROMOÇÃO agora é recusada nas duas rotas de associação, o que muda também
POST /projects/:projectId/members. O ADR 0127 a tinha registrado como capacidade que ficava ("os tetos são sobre descer"); o ADR 0157 revisa isso. As duas metades são um movimento só — uma pessoa decidindo sozinha que autoridade tem — e a metade de subida é a única que escala privilégio, justamente o que o ADR 0127 podia afirmar que seus tetos nunca faziam. Reescrever o MESMO papel continua passando: um upsert idempotente não é movimento. A comparação ficou num lugar só:autoMovimentoDoProprioPapeldevolve o SENTIDO em vez de um booleano (o chamador precisa dele para escolher a mensagem), eehAutoRebaixamentosobrevive como leitura dele para que a porta da REMOÇÃO continue vendo só a metade de baixo, como o ADR 0156 decidiu. -
jwtsem papel não quer dizer sem autorização. Em/users/me/*o escopo é o próprio usuário; emGET /workspacesa listagem já é filtrada pela associação de quem chama. -
X-Brabo-Service-Tokenpassou a ser redigido no log (ADR 0035). Ele é o portador de todo o tráfego api↔engine e não estava na listaredactdo pino: se caísse num corpo de erro logado, iria para o Loki em texto puro e com retenção.serviceToken,privateKey,encryptedDekedekentraram junto. A lista completa está emapps/api/src/infrastructure/observability/logger.config.ts, e há um teste afirmando cada caminho — a lista é contrato, não conveniência. -
O
allowedHeadersdo CORS é explícito, e a lista precisa conter todo cabeçalho que o web envia:Content-Type,Authorization,X-CSRF-Tokenetraceparent. Faltar um não quebra teste nenhum (nenhum teste faz preflight) e quebra o navegador. -
O engine tem CORS só nas rotas de saúde (ADR 0037).
/health,/livee/readydevolvemAccess-Control-Allow-Originpara as origens deWEB_ORIGIN;/internal/*e/metricsnão, e a exclusão é o ponto. As 13 rotas internas são servidor-a-servidor com um segredo compartilhado (RN-035); CORS ali não habilitaria nada — o cliente HTTP da api ignora esses cabeçalhos —, mas anunciaria a um navegador que ele é cliente esperado daquele canal. Há um teste afirmando a ausência, e um afirmando que a lista de caminhos tem exatamente três entradas, para que mover a fronteira apareça no diff. -
Origem desconhecida recebe resposta, não
403. Nos dois serviços, a requisição é atendida e sai sem o cabeçalho; quem bloqueia a leitura é o navegador, de quem é a decisão. Responder403quebraria todo cliente que não mandaOrigin— a probe do kubelet,curl,docker/smoke.sh. -
POST .../delegationsé engine-service como as outras rotas internas (QA da Fase 8b, Infra da Fase 8c — ADR 0038) — o lead de cada área registra o desfecho de cada delegado (completed/failed/dispensed) SEPARADO da chamada que a área usa para reportar o resultado consolidado para fora (gates/verdictno QA,open_infra_prna Infra). Escopo de sessão, não de tarefa —taskIdé opcional no corpo. Delegação nunca é visível como handoff. -
POST /projects/:projectId/execution/activateganhou umoriginSessionIdopcional no corpo, e a classificação não mudou — continuarole:maintainer(RN-135). O campo deixa quem já tem esse papel fechar a sessão de CHAT que originou o pedido, mas com duas contenções que impedem usá-lo para fechar a sessão de outra pessoa:findInProject(projectId, originSessionId)recusa em silêncio um id que não pertence ao PRÓPRIO projeto do caminho, e o fechamento só acontece seGetSessionPendingWorkUseCase(a mesma guarda do heartbeat de inatividade, RN-073) confirmar que não há handoff, ação ou turno pendurado ali. Nunca fecha a sessão de execução que a própria chamada acabou de ativar. -
GET /internal/sessions/:sessionId/pending-workganhouaguardandoUsuarioDesde, e nenhuma rota mudou de classificação (RN-581). Continuaengine-service: o campo é o instante em que o turno de um agente conversacional terminou, lido do mesmo event log que o engine já escreve, e só decide por quanto tempo o engine mantém uma sessão aberta. A mesma RN fez as rotas de sessão que acrescentam conversa (POST .../agents/:agent/message, o chat, handoffs,POST .../agents/:agent/start,POST /internal/sessions/:sessionId/events) responderem 409sessao_encerradaem sessão terminal — uma recusa por ESTADO, depois da checagem de papel, nunca um papel novo.terminationReasonnas respostas de sessão ganhou um valor documentado,conversation_idle_timeout. -
POST /projects/:projectId/execution/activaterecusa com409quando o projeto não tem repositório, e a classificação não mudou — continuarole:maintainer(RN-582, ADR 0165). A recusa vem antes de qualquer efeito. A mensagem é escolhida a partir dos handoffs do projeto, lidos em TODAS as sessões dele — por isso ela pode dizer "aceite o handoff ao Arquiteto/Dev Lead". O que ela revela é só que esse handoff existe e o status dele, que qualquerviewerdo projeto já lê porGET .../sessions/:sessionId/handoffs; ela nunca nomeia sessão nem usuário. -
Oferecer handoff a um agente já ativo no projeto é
409agente_ja_ativo, e a classificação não mudou — a rota humanaPOST .../sessions/:sessionId/handoffscontinuarole:developere a interna continuaengine-service(RN-635, ADR 0182). Ao contrário da recusa acima, esta NOMEIA uma sessão: a mensagem diz em que sessão do mesmo projeto o destino está ativo, porque é o texto que o agente lê como resultado de ferramenta e o ponteiro de que uma pessoa precisa para ir falar com ele. Qualquerviewerdo projeto já lista as sessões dele e lêagent.activatedem cada uma, então nada atravessa fronteira de projeto. As mesmas rotas agora respondem com a oferta CORRENTE em vez de sempre uma linha nova (desfecho), e a interna aceitaseAusente— uma chave que só pode fazer a chamada escrever MENOS. -
O
POST /internal/sessions/:sessionId/handoffsinterno agora pode ACEITAR a oferta Criativo→PO que ele cria, e a classificação não mudou — continuaengine-service(RN-658, ADR 0185). O engine não ganha poder de ativar agente: quem decide é a api, a partir doreadiness.confirmedque uma PESSOA gravou porPOST .../readiness(role:developer, o mesmo mínimo de aceitar pelo card), e ela grava essa pessoa como o ator. O engine não consegue forjar a marca — ele nunca escrevereadiness.confirmed— e a oferta precisa carregar oproduct_briefnascido depois daquele clique, na mesma sessão.POST .../agents/criativo/validate-necessitymantém a rota e o papel, sem chamador no web (RN-657). -
GET /projects/:projectId/execution/sessionérole:viewer, o mesmo papel deGET /sessions/:sessionId(RN-139). Devolve a sessão de execução CORRENTE do projeto —activecomexecution.activatedgravado — ounull; nunca a sessão mais recente do projeto, que era o que a aba Executores lia e que trocava de sessão em silêncio no instante em que outra sessão nascia depois dela. -
GET /workspaces/:workspaceId/projects-summaryganhouroster.activatedAgents, e a classificação não mudou — continuarole:viewer. O campo é a lista de ids de agente com umagent.activatedna sessão mais recente do projeto (RN-630), calculada na mesma varredura por sessão que já produzexecutionActivatedegatesEverOpened(RN-568). Não nomeia usuário, sessão nem credencial, e não acrescenta consulta. -
POST .../llm-turnePOST .../llm-turn-streamganharammodelNameno corpo da resposta/quadro final, e a classificação não mudou — continuaengine-servicecomo sempre (RN-146). O nome do modelo já era resolvido para chamar o provider; ele só passou a viajar de volta ao engine, que o inclui no payload deagent.response. Nenhum dado novo é lido, nenhuma credencial nova é exposta — é o mesmo nome que já aparece emtoken_usage. -
PUT /projects/:projectId/agent-autonomypassou a aceitaractionType: "*"— o "modo automático" (RN-153) — e a classificação não mudou: continuarole:maintainer, o mesmo doGETao lado. A diferença é o que o corpo agora AUTORIZA, não quem pode chamar: a curinga concede autonomia para QUALQUER tipo de ação do agente de uma vez, em vez de um tipo por vez como antes. A resolução (regra ESPECÍFICA sempre vence a curinga) mora inteira no repositório (DrizzleAgentAutonomyRepository.findMode), nunca emdecide()— que continua recebendo só aPermissionPolicyjá resolvida, exatamente como antes de a curinga existir. É por isso que os três tetos absolutos — merge em branch protegida,instruction_patch,parallelize/raise_max_parallel— continuam bloqueando mesmo com a curinga emauto_approve(RN-154): eles reagem acurrent.policy === 'auto_approve', nunca a de onde ela veio, e nenhuma exceção precisou entrar emdecide()para isso continuar valendo. OApprovalCard.tsxsó oferece o botão que grava a curinga a um cliente que já sabe termaintainer/owner— mas o que de fato garante o papel é este mesmo@RequireRole('maintainer'), inalterado. -
O roteador de ferramentas Jev é uma chamada de SAÍDA nova levando contexto de agente (ADR 0179, RN-625). Antes de uma chamada de chat a um modelo do OpenRouter, a api faz POST em
https://openrouter.ai/api/alpha/decisions(a Decisions API, alfa) com a chave do OpenRouter do owner do workspace — a MESMA chave que o chat já gasta, decifrada no mesmo caso de uso, nunca logada e nunca devolvida. O que viaja é ostate: o id do agente, a última mensagem do usuário (cortada em 6.000 caracteres), o começo da mensagem de sistema (1.500) e até seis chamadas de ferramenta recentes com argumentos e resultados cortados em 500 caracteres cada — mais os NOMES e as DESCRIÇÕES das ferramentas, nunca osparametersdelas. É conteúdo que a chamada de chat já manda para a mesma conta do OpenRouter, numa fatia menor, mas que agora chega a um segundo modelo (typesafe/jev-1.13) por trás dela. A chave é por workspace (PUT /workspaces/:workspaceId/tool-router,role:owner, ligada por padrão) e só dispara com modelo de chat do OpenRouter. O roteador estreita o menu de ferramentas e nada mais: não consegue aprovar, negar nem alargar nada, e uma chamada que o modelo faça fora do menu continua virando Proposed Action sob a mesma política. Qualquer falha da chamada cai no catálogo inteiro. -
POST .../actions/:actionId/approve_alwaysganhoudesfechoepadraoGravadona resposta, e a classificação não mudou — continuarole:developer(RN-642). A aprovação e o padrão gravado agora dividem uma transação; clicar numa ação que já estava APROVADA responde201comdesfecho: "ja_aprovada"em vez de409, e uma ação RECUSADA continua respondendo409(acao_ja_recusada) sem nada gravado. Os tetos da RN-418 econtainer_removecontinuam recusados com400antes de qualquer estado ser lido, então o caminho idempotente nunca grava um padrão que o teto proíbe. -
Um erro de git ganhou resposta nomeada nova:
409 merge_conflict— nenhuma rota, papel ou guard mudou (RN-704).GitProviderErrorFiltermapeiaGitMergeConflictError(o merge do repositório local achou conflito) para409com os arquivos em conflito na mensagem; a ref de destino fica intacta e a pull request continua aberta. Pelo pipeline de aprovação a açãogit_mergeterminafailedcom essa mensagem, o mesmo caminho deGitPullRequestAlreadyMergedError. -
GET /projects/:projectId/actionsaceitastatus=failed— mesma rota, mesmo papeldeveloper(RN-705). Até aqui sópendingera aceito;failedlista as ações do projeto cuja execução falhou, e EXIGEactionType(400sem ele), para que a leitura seja de um tipo e nunca o histórico inteiro. Devolve a mesma formaProposedActionque a leitura de pendentes devolve; a ação de git falhada agora carrega o própriokind, a mensagem de erro e, num conflito de merge, os nomes dos arquivos em conflito — caminhos do repositório que o mesmo papel já lê no diff. -
Duas respostas ganharam campos na Rodada 135 — nenhuma rota, papel ou guard mudou.
GET /workspaces/:workspaceId/models/catalog(role:maintainer) devolvesugestaopor modelo (RN-965): a faixa de cobrança e o preço publicado pelo fabricante, com link e data da fonte — dado curado no repositório, não dado do usuário nem segredo, e nada é gravado pela leitura (quem aplica é oowner, pelas rotas de curadoria de sempre).GET /projects/:projectId/sessions/:sessionId/token-usage(role:developer) devolvelastCallpor agente (RN-969): tokens de entrada, nome do modelo e janela da última chamada, do mesmo escopo de sessão que a soma já expunha, sem provider nem credencial. -
A resposta do catálogo de modelos ganhou
reasoningBudget— nenhuma rota, papel ou guard mudou (RN-914, ADR 0223). O campo (inteiro ounull) sai no DTO de modelo das leituras do catálogo que já existiam, com os mesmos papéis. É curadoria do produto (o orçamento de raciocínio provado por smoke), não dado do usuário nem segredo; a escrita é só por migration, sem rota nova.
Tabela
| método | caminho | classificação |
|---|---|---|
| GET | /.well-known/jwks.json | public |
| POST | /auth/login | public |
| POST | /auth/logout | public |
| GET | /auth/oauth/:provider/callback | public |
| GET | /auth/oauth/:provider/start | public |
| POST | /auth/refresh | public |
| POST | /auth/register | public |
| POST | /auth/request-password-reset | public |
| POST | /auth/reset-password | public |
| POST | /auth/verify-email | public |
| GET | /gates | jwt |
| GET | /git/oauth/:provider/callback | public |
| GET | /health | public |
| GET | /live | public |
| GET | /metrics | public |
| GET | /runner-releases/binary | public |
| POST | /internal/sessions/:sessionId/actions | engine-service |
| GET | /internal/sessions/:sessionId/anamnese-context | engine-service |
| POST | /internal/sessions/:sessionId/anamnese-window | engine-service |
| POST | /internal/sessions/:sessionId/delegations | engine-service |
| GET | /internal/sessions/:sessionId/dev-context | engine-service |
| POST | /internal/sessions/:sessionId/epics | engine-service |
| GET | /internal/sessions/:sessionId/events | engine-service |
| GET | /internal/sessions/:sessionId/pending-work | engine-service |
| POST | /internal/sessions/:sessionId/events | engine-service |
| POST | /internal/sessions/:sessionId/gates/verdict | engine-service |
| POST | /internal/sessions/:sessionId/handoffs | engine-service |
| POST | /internal/sessions/:sessionId/hypotheses | engine-service |
| GET | /internal/sessions/:sessionId/infra-artifacts/:prActionId/files | engine-service |
| GET | /internal/sessions/:sessionId/infra-context | engine-service |
| POST | /internal/sessions/:sessionId/infra-gates/verdict | engine-service |
| POST | /internal/sessions/:sessionId/instruction-patches | engine-service |
| POST | /internal/sessions/:sessionId/max-parallel-proposals | engine-service |
| POST | /internal/sessions/:sessionId/llm-turn | engine-service |
| POST | /internal/sessions/:sessionId/llm-turn-stream | engine-service |
| POST | /internal/sessions/:sessionId/c4-diagram | engine-service |
| POST | /internal/sessions/:sessionId/module-map | engine-service |
| POST | /internal/sessions/:sessionId/module-routing | engine-service |
| POST | /internal/sessions/:sessionId/module-contracts | engine-service |
| POST | /internal/sessions/:sessionId/project-image | engine-service |
| POST | /internal/sessions/:sessionId/proficiency | engine-service |
| POST | /internal/models/sync | engine-service |
| GET | /internal/graph/prompt-templates/:name | engine-service |
| POST | /internal/graph/prompt-templates | engine-service |
| POST | /internal/rag/search | engine-service |
| POST | /internal/rag/feedback | engine-service |
| GET | /internal/gates | engine-service |
| GET | /internal/projects/:projectId/git-remote | engine-service |
| GET | /internal/projects/:projectId/business-rules | engine-service |
| GET | /internal/projects/:projectId/backlog | engine-service |
| GET | /internal/projects/:projectId/product-metrics | engine-service |
| GET | /internal/projects/:projectId/module-contracts | engine-service |
| POST | /internal/projects/:projectId/workspace-verification | engine-service |
| POST | /internal/projects/:projectId/container-exec | engine-service |
| POST | /internal/projects/:projectId/mirror-sync-result | engine-service |
| GET | /internal/projects/:projectId/container-spec | engine-service |
| POST | /internal/projects/:projectId/qa-environments/:taskId/start | engine-service |
| POST | /internal/projects/:projectId/qa-environments/:taskId/remove | engine-service |
| POST | /internal/qa-environments/sweep | engine-service |
| POST | /internal/first-account | engine-service |
| POST | /internal/machine-device-keys | engine-service |
| GET | /internal/sessions/:sessionId/psychologist-context | engine-service |
| POST | /internal/sessions/:sessionId/stories | engine-service |
| POST | /internal/sessions/:sessionId/stories/:storyId/complete | engine-service |
| POST | /internal/sessions/:sessionId/stories/:storyId/update | engine-service |
| POST | /internal/sessions/:sessionId/stories/:storyId/archive | engine-service |
| POST | /internal/sessions/:sessionId/semantic-duplicate-check | engine-service |
| POST | /internal/sessions/:sessionId/story-modules | engine-service |
| POST | /internal/sessions/:sessionId/tasks | engine-service |
| POST | /internal/sessions/:sessionId/tasks/:taskId/block | engine-service |
| POST | /internal/sessions/:sessionId/tasks/:taskId/unblock | engine-service |
| POST | /internal/sessions/:sessionId/tasks/:taskId/gate/open | engine-service |
| POST | /internal/sessions/:sessionId/tasks/:taskId/status | engine-service |
| POST | /internal/sessions/:sessionId/tasks/claim | engine-service |
| GET | /internal/sessions/:sessionId/tasks/in-progress-by-dev | engine-service |
| POST | /internal/sessions/:sessionId/termination | engine-service |
| GET | / | jwt |
| GET | /runner/projects | jwt |
| POST | /runner/llm-credentials | jwt |
| GET | /llm/provider-capabilities | jwt |
| GET | /users/me/credentials | jwt |
| POST | /users/me/credentials | jwt |
| POST | /users/me/credentials/:provider/test | jwt |
| DELETE | /users/me/credentials/:provider | jwt |
| POST | /users/me/git-credentials | jwt |
| GET | /users/me/machine-device-keys | jwt |
| DELETE | /users/me/machine-device-keys/:deviceKeyId | jwt |
| GET | /users/me/preferences | jwt |
| PATCH | /users/me/preferences | jwt |
| POST | /users/me/preferences/detected-language | jwt |
| GET | /workspaces | jwt |
| POST | /workspaces | jwt |
| DELETE | /projects/:projectId | role:maintainer |
| GET | /projects/:projectId | role:viewer |
| PATCH | /projects/:projectId | role:maintainer |
| PUT | /projects/:projectId/execution-mode | role:maintainer |
| PUT | /projects/:projectId/mirror-path | role:maintainer |
| PUT | /projects/:projectId/container/network | role:maintainer |
| GET | /projects/:projectId/mirror-state | role:viewer |
| GET | /projects/:projectId/models | role:viewer |
| GET | /projects/:projectId/actions | role:developer |
| GET | /projects/:projectId/agent-autonomy | role:maintainer |
| PUT | /projects/:projectId/agent-autonomy | role:maintainer |
| DELETE | /projects/:projectId/agent-bindings/:agentSlug | role:developer |
| GET | /projects/:projectId/agent-bindings/:agentSlug | role:viewer |
| PUT | /projects/:projectId/agent-bindings/:agentSlug | role:developer |
| DELETE | /projects/:projectId/area-bindings/:areaKey | role:maintainer |
| GET | /projects/:projectId/area-bindings/:areaKey | role:viewer |
| PUT | /projects/:projectId/area-bindings/:areaKey | role:maintainer |
| GET | /projects/:projectId/agent-costs | role:developer |
| GET | /projects/:projectId/agents/:agent/instruction-versions | role:viewer |
| POST | /projects/:projectId/agents/:agent/instruction-versions/:version/rollback | role:maintainer |
| POST | /projects/:projectId/anamnese/run | role:maintainer |
| GET | /projects/:projectId/architecture | role:viewer |
| GET | /projects/:projectId/backlog | role:viewer |
| GET | /projects/:projectId/budget | role:maintainer |
| PUT | /projects/:projectId/budget | role:maintainer |
| GET | /projects/:projectId/agent-areas | role:developer |
| PATCH | /projects/:projectId/agent-areas/:key/max-parallel | role:maintainer |
| PUT | /projects/:projectId/agent-areas/:key/budget | role:maintainer |
| GET | /projects/:projectId/code/blame | role:viewer |
| GET | /projects/:projectId/code/branches | role:viewer |
| GET | /projects/:projectId/code/file | role:viewer |
| GET | /projects/:projectId/code/pull-requests | role:viewer |
| GET | /projects/:projectId/code/pull-requests/:pullRequestId/diff | role:viewer |
| GET | /projects/:projectId/code/search | role:viewer |
| GET | /projects/:projectId/code/tree | role:viewer |
| POST | /projects/:projectId/rag/search | role:viewer |
| POST | /projects/:projectId/rag/reindex | role:maintainer |
| POST | /projects/:projectId/rag/local | role:maintainer |
| POST | /projects/:projectId/rag/feedback | role:viewer |
| GET | /projects/:projectId/rag/coverage | role:viewer |
| GET | /projects/:projectId/container | role:viewer |
| GET | /projects/:projectId/container/lifecycle | role:viewer |
| GET | /projects/:projectId/container/network | role:viewer |
| GET | /projects/:projectId/coverage | role:viewer |
| GET | /projects/:projectId/events/:eventId | role:viewer |
| POST | /projects/:projectId/execution/activate | role:maintainer |
| GET | /projects/:projectId/execution/session | role:viewer |
| GET | /projects/:projectId/git/:provider/connect | role:maintainer |
| POST | /projects/:projectId/git/:provider/repository | role:maintainer |
| POST | /projects/:projectId/git/:provider/repository/adopt | role:maintainer |
| GET | /projects/:projectId/git/bootstrap | role:viewer |
| GET | /projects/:projectId/git/bootstrap/plan | role:viewer |
| POST | /projects/:projectId/git/bootstrap/plan/approve | role:maintainer |
| POST | /projects/:projectId/git/bootstrap/plan/skip | role:maintainer |
| POST | /projects/:projectId/git/bootstrap/acknowledge-protection-failure | role:maintainer |
| GET | /projects/:projectId/git/repository | role:viewer |
| GET | /projects/:projectId/hypotheses | role:viewer |
| POST | /projects/:projectId/hypotheses/:hypothesisId/accept | role:developer |
| POST | /projects/:projectId/hypotheses/:hypothesisId/dismiss | role:developer |
| GET | /projects/:projectId/infra-artifacts | role:viewer |
| GET | /projects/:projectId/instruction-versions | role:viewer |
| GET | /projects/:projectId/members | role:viewer |
| POST | /projects/:projectId/members | role:maintainer |
| DELETE | /projects/:projectId/members/:userId | role:maintainer |
| GET | /projects/:projectId/model-binding | role:viewer |
| PUT | /projects/:projectId/model-binding | role:maintainer |
| GET | /projects/:projectId/model-bindings/resolved | role:viewer |
| GET | /projects/:projectId/permissions | role:maintainer |
| PUT | /projects/:projectId/permissions | role:maintainer |
| POST | /projects/:projectId/personal-access-tokens | role:developer |
| GET | /projects/:projectId/personal-access-tokens | role:developer |
| GET | /projects/:projectId/personal-access-tokens/all | role:maintainer |
| DELETE | /projects/:projectId/personal-access-tokens/:tokenId | role:developer |
| DELETE | /projects/:projectId/personal-access-tokens/:tokenId/admin | role:maintainer |
| GET | /projects/:projectId/open-questions | role:viewer |
| POST | /projects/:projectId/open-questions/:questionId/answer | role:viewer |
| GET | /projects/:projectId/run-instructions | role:viewer |
| GET | /projects/:projectId/runner-device-keys | role:developer |
| DELETE | /projects/:projectId/runner-device-keys/:deviceKeyId | role:developer |
| GET | /projects/:projectId/proficiency | role:viewer |
| DELETE | /projects/:projectId/proficiency/me | role:viewer |
| POST | /projects/:projectId/proficiency/me/opt-in | role:viewer |
| GET | /projects/:projectId/psychologist/analyses | role:viewer |
| GET | /projects/:projectId/psychologist/status | role:viewer |
| POST | /projects/:projectId/runner-ticket | role:developer |
| POST | /projects/:projectId/terminal-ticket | role:viewer |
| GET | /projects/:projectId/sessions | role:viewer |
| POST | /projects/:projectId/sessions | role:developer |
| GET | /projects/:projectId/sessions/:sessionId | role:viewer |
| PATCH | /projects/:projectId/sessions/:sessionId | role:developer |
| GET | /projects/:projectId/sessions/:sessionId/actions | role:developer |
| POST | /projects/:projectId/sessions/:sessionId/actions | role:developer |
| POST | /projects/:projectId/sessions/:sessionId/actions/:actionId/approve | role:developer |
| POST | /projects/:projectId/sessions/:sessionId/actions/:actionId/approve_always | role:developer |
| POST | /projects/:projectId/sessions/:sessionId/actions/:actionId/deny | role:developer |
| POST | /projects/:projectId/sessions/:sessionId/agents/:agent/cancel | role:developer |
| POST | /projects/:projectId/sessions/:sessionId/agents/:agent/message | role:developer |
| POST | /projects/:projectId/sessions/:sessionId/agents/:agent/messages/:messageId/cancel | role:developer |
| POST | /projects/:projectId/sessions/:sessionId/agents/:agent/start | role:developer |
| POST | /projects/:projectId/sessions/:sessionId/agents/:agent/structured-question/:questionSetId/answer | role:developer |
| POST | /projects/:projectId/sessions/:sessionId/agents/:agentId/rearm | role:developer |
| POST | /projects/:projectId/sessions/:sessionId/agents/arquiteto/handoff-infra | role:developer |
| POST | /projects/:projectId/sessions/:sessionId/agents/criativo/validate-necessity | role:developer |
| GET | /projects/:projectId/sessions/:sessionId/budget | role:developer |
| PUT | /projects/:projectId/sessions/:sessionId/budget | role:developer |
| POST | /projects/:projectId/sessions/:sessionId/chat | role:developer |
| GET | /projects/:projectId/sessions/:sessionId/events | role:viewer |
| POST | /projects/:projectId/sessions/:sessionId/events | role:developer |
| GET | /projects/:projectId/sessions/:sessionId/events/:eventId | role:viewer |
| GET | /projects/:projectId/sessions/:sessionId/context-summary | role:viewer |
| POST | /projects/:projectId/sessions/:sessionId/execution/parallelize | role:developer |
| GET | /projects/:projectId/sessions/:sessionId/handoffs | role:viewer |
| POST | /projects/:projectId/sessions/:sessionId/handoffs | role:developer |
| POST | /projects/:projectId/sessions/:sessionId/handoffs/:handoffId/accept | role:developer |
| GET | /projects/:projectId/sessions/:sessionId/model-binding | role:viewer |
| PUT | /projects/:projectId/sessions/:sessionId/model-binding | role:developer |
| POST | /projects/:projectId/sessions/:sessionId/psychologist/reanalyze | role:maintainer |
| POST | /projects/:projectId/sessions/:sessionId/readiness | role:developer |
| GET | /projects/:projectId/sessions/:sessionId/response-language | role:viewer |
| PUT | /projects/:projectId/sessions/:sessionId/response-language | role:developer |
| POST | /projects/:projectId/sessions/:sessionId/socket-ticket | role:viewer |
| POST | /projects/:projectId/sessions/:sessionId/tasks/:taskId/unblock | role:developer |
| GET | /projects/:projectId/sessions/:sessionId/token-usage | role:developer |
| POST | /projects/:projectId/sessions/:sessionId/transition | role:developer |
| POST | /projects/:projectId/sessions/:sessionId/reopen | role:developer |
| POST | /projects/:projectId/sessions/:sessionId/agents/:agent/resume | role:developer |
| POST | /projects/:projectId/tasks/:taskId/gates/:gate/resume | role:developer |
| GET | /projects/:projectId/spend/me | role:viewer |
| GET | /projects/:projectId/spend/team | role:viewer |
| POST | /projects/:projectId/stories/:storyId/return | role:developer |
| PATCH | /projects/:projectId/stories/:storyId/title | role:developer |
| POST | /projects/:projectId/stories/:storyId/archive | role:developer |
| POST | /projects/:projectId/stories/promote | role:developer |
| DELETE | /workspaces/:workspaceId | role:owner |
| GET | /workspaces/:workspaceId | role:viewer |
| PATCH | /workspaces/:workspaceId | role:maintainer |
| GET | /workspaces/:workspaceId/members | role:viewer |
| POST | /workspaces/:workspaceId/members | role:owner |
| DELETE | /workspaces/:workspaceId/members/:userId | role:owner |
| PUT | /workspaces/:workspaceId/owner-of-record | role:owner |
| PUT | /workspaces/:workspaceId/tool-router | role:owner |
| GET | /workspaces/:workspaceId/model-binding | role:viewer |
| PUT | /workspaces/:workspaceId/model-binding | role:maintainer |
| GET | /workspaces/:workspaceId/credential-spend | role:owner |
| GET | /workspaces/:workspaceId/spend-report | role:owner |
| GET | /workspaces/:workspaceId/spend/me | role:viewer |
| GET | /workspaces/:workspaceId/huggingface/models | role:maintainer |
| POST | /workspaces/:workspaceId/huggingface/pull-requests | role:maintainer |
| POST | /workspaces/:workspaceId/huggingface/pull-requests/:id/confirm | role:maintainer |
| GET | /workspaces/:workspaceId/huggingface/pull-requests/:id | role:maintainer |
| GET | /workspaces/:workspaceId/llm-providers/credentialed | role:viewer |
| POST | /workspaces/:workspaceId/models/activate | role:owner |
| GET | /workspaces/:workspaceId/models/catalog | role:maintainer |
| POST | /workspaces/:workspaceId/models/sync | role:owner |
| POST | /workspaces/:workspaceId/models/uses | role:owner |
| GET | /workspaces/:workspaceId/models/:modelId/price-changes | role:maintainer |
| PATCH | /workspaces/:workspaceId/models/:modelId/pricing | role:owner |
| PATCH | /workspaces/:workspaceId/models/:modelId/billing | role:owner |
| GET | /workspaces/:workspaceId/projects | role:viewer |
| POST | /workspaces/:workspaceId/projects | role:maintainer |
| GET | /workspaces/:workspaceId/projects-base | role:maintainer |
| GET | /workspaces/:workspaceId/project-folders | role:maintainer |
| GET | /workspaces/:workspaceId/projects-status | role:viewer |
| GET | /workspaces/:workspaceId/projects-summary | role:viewer |
| GET | /workspaces/:workspaceId/summary | role:viewer |
| POST | /workspaces/:workspaceId/unread-events | role:viewer |
| GET | /workspaces/:workspaceId/containers | role:viewer |