API — parte 4 (desde RN-231)

PROGRAMA 28 — Onda 4, frente G2: pipeline de indexação e busca híbrida do Chat RAG (RN-231..238, ADR 0080)

As RNs do auth first-party moram em Regras de negócio — Autenticação.

Saíram daqui por TAMANHO, não por assunto: junto com Custo elas eram metade de uma página de 640 KB. O conteúdo não mudou, e as âncoras #rn-NNN são as mesmas — o que mudou foi o arquivo que as hospeda.

Autenticação

RN-231 — ChunkRepository ganha DELETE e as duas metades da busca híbrida

O port da Onda 3 (RN-226) só cobria escrita/leitura básica. A Onda 4 acrescentou deleteByScope/deleteBySession (para reindexação idempotente por full rebuild — apagar e recriar, nunca UPDATE) e searchByVector/searchByLexicalQuery (as duas metades da busca híbrida, cada uma uma consulta independente, aproveitando o índice feito para ela — HNSW ou GIN). deleteByScope é tipado Exclude<ChunkScope, 'session'>: apagar session por projeto inteiro apagaria sessões que não estão sendo reindexadas agora, então sessão só apaga por deleteBySession.

  • Onde: src/application/ports/chunk-repository.port.ts (linhas 105-146), src/infrastructure/persistence/drizzle/chunk.repository.ts
  • Teste: test/infrastructure/persistence/chunk.repository.spec.ts — describes "Onda 4 (G2)" (delete por escopo/sessão, busca por vetor/léxico, caso feliz e vazio de cada um)
  • ADR: 0080

RN-232 — Origem do texto indexado: docs/adr do repositório do PRÓPRIO projeto, session só de chat.message/agent.response

docs/adr são indexados via ReadProjectCodeUseCase — a mesma superfície da aba Code, com a mesma credencial do owner, o mesmo portão de container (RN-105) e a mesma checagem de caminho (RN-095) — nunca a documentação do Brabo enquanto produto. session indexa só dois tipos de evento (chat.message, agent.response); o resto do event log (tool.call, agent.status, agent.error...) é mecanismo/falha, não conhecimento citável.

  • Onde: src/application/use-cases/rag/index-project-docs.use-case.ts (linhas 16-17, 64), src/application/use-cases/rag/index-session.use-case.ts (linha 29, 45). Desde a AT-764, a leitura de cada .md usa o teto próprio da indexação, não o da aba Code (RN-1036)
  • Teste: test/application/use-cases/rag/index-project-docs.use-case.spec.ts, test/application/use-cases/rag/index-session.use-case.spec.ts (casos felizes + "projeto sem docs/" + "evento sem payload.text é ignorado")
  • ADR: 0080

RN-233 — Provider de embedding indisponível: grava sem vetor e declara a lacuna, nunca finge indexação completa

Quando ollama/nomic-embed-text não responde, RagEmbeddingService.embedMany não lança — devolve available: false e null por entrada pedida. O pipeline grava os chunks mesmo assim (embedding: null; search_vector é GENERATED ALWAYS AS e não depende de provider nenhum), e o relatório de indexação declara a lacuna (embedding: { available, embedded, skipped, reason }). A mesma degradação vale na busca: HybridSearchUseCase roda só com o sinal léxico e vectorAvailable: false avisa.

  • Onde: src/application/use-cases/rag/rag-embedding.service.ts (linhas 53-58)
  • Teste: test/application/use-cases/rag/rag-embedding.service.spec.ts — "CASO DE FALHA: provider sem a capability..." e "CASO DE FALHA: provider que lança no meio do lote..."; index-project-docs.use-case.spec.ts/ index-session.use-case.spec.ts — "CASO DE FALHA: provider de embedding indisponível grava os chunks SEM vetor..."; hybrid-search.use-case.spec.ts — "quando o embedding está indisponível, degrada para busca só léxica"
  • ADR: 0080

RN-234 — Busca híbrida: duas consultas independentes, fusão por peso (0.6/0.4), limiar 0.2, e o contrato de citação

searchByVector/searchByLexicalQuery são consultas SEPARADAS (nunca um JOIN), fundidas em HybridSearchUseCase por soma ponderada (RAG_SEARCH_WEIGHT_VECTOR = 0.6, RAG_SEARCH_WEIGHT_LEXICAL = 0.4 — as escalas de cosseno e ts_rank normalizado não são comparáveis por natureza) e cortadas em RAG_SEARCH_SCORE_THRESHOLD = 0.2. Nenhum dos quatro números vem de calibração com dado real — são ponto de partida documentado. A citação (HybridSearchHit) expõe chunkId/content/scores separados (null, não zero, quando o sinal não achou o chunk)/origin como união discriminada por kind (file com sourcePath/headingPath, session com sessionId/eventId).

  • Onde: src/application/use-cases/rag/hybrid-search.use-case.ts (linhas 44-113), src/domain/rag/rag-search-limits.ts (linhas 60-93), src/domain/rag/rag-citation.ts (linhas 12-71)
  • Teste: test/application/use-cases/rag/hybrid-search.use-case.spec.ts (fusão/ordenação/limiar, query fora da faixa, degradação léxico-only, origin de sessão) e test/infrastructure/persistence/chunk.repository.spec.ts (as consultas SQL de fato, contra pgvector/tsvector real)
  • ADR: 0080

RN-1046 — A busca híbrida devolve no máximo N trechos do mesmo arquivo

Depois da fusão e do limiar (RN-234) e antes do corte em limit, a busca mantém só os maxPerFile melhores trechos de cada arquivo (RAG_SEARCH_MAX_CHUNKS_PER_FILE, hoje 2), na ordem do score. Assim um documento grande e repetitivo (o business-rules.md agregado) não ocupa todas as citações e abafa o documento específico (um ADR). O melhor trecho de cada arquivo nunca é descartado, então o filtro só pode subir a posição do primeiro trecho de um arquivo, nunca baixá-la. Trecho sem arquivo (sessão) não entra na conta. O maxPerFile vigente vai congelado em rag_searches.pesos (RN-479), para a calibração não comparar rodadas com regras diferentes; linhas antigas não o têm. O seed do golden set aceita GOLDEN_SET_RAG_MAX_PER_FILE e, em caso perdido, imprime a posição do arquivo esperado entre os candidatos antes do corte.

  • Onde: src/application/use-cases/rag/hybrid-search.use-case.ts (limitarPorArquivo), src/domain/rag/rag-search-limits.ts, src/domain/rag/rag-telemetry.ts, scripts/seed-golden-set-rag.ts
  • Teste: test/application/use-cases/rag/hybrid-search.use-case.spec.ts
  • ADR: 0080 (a decisão sobre diversidade fica no ADR 0272)

RN-235 — Chunking: 1200 caracteres, 150 de sobreposição, por parágrafo/heading

CHUNK_TARGET_CHARS = 1200 (~300 tokens em português), CHUNK_OVERLAP_CHARS = 150 (12,5%), com corte preferindo quebra de parágrafo, depois de palavra, dentro de uma janela de 200 caracteres. Markdown (docs/adr) é dividido por HEADING antes de ser recortado por tamanho, preservando headingPath. Números documentados como ponto de partida ajustável, não calibrados contra dado real de qualidade de recuperação (não existe, ainda, um corpo de perguntas reais rodado contra este índice).

  • Onde: src/domain/rag/chunking.ts (linhas 30-31, 53 em diante)
  • Teste: test/domain/rag/chunking.spec.ts (texto que cabe/não cabe, texto vazio, sobreposição real, texto patológico sem espaço, headings aninhados, markdown sem heading)
  • ADR: 0080

RN-236 — Reindexação é sempre MANUAL, full rebuild idempotente

Não há watcher por push nem por fechamento de sessão. ReindexProjectUseCase roda docs/adr uma vez e uma IndexSessionUseCase por sessão do projeto, cada indexação apagando o escopo/sessão antes de recriar — rodar duas vezes seguidas não duplica. Disparado por POST /projects/:projectId/rag/reindex, role:maintainer.

  • Onde: src/application/use-cases/rag/reindex-project.use-case.ts (linha 36)
  • Teste: test/application/use-cases/rag/reindex-project.use-case.spec.ts (agrega docs+sessões, embeddingAvailable falso quando qualquer rodada falha, projeto inexistente)
  • ADR: 0080

RN-237 — Cobertura do índice é contagem REAL, nunca "há N minutos" inventado

GetRagCoverageUseCase conta arquivos .md reais no repositório do projeto contra quantos têm chunk, e sessões do projeto contra quantas têm chunk. Não existe coluna de timestamp de indexação por escopo, e a resposta não inclui nenhum "reindexado há Xmin" — um número chutado mentiria (mesma régua do ADR 0042 para nota de modelo).

Desde a AT-764 (RN-1036), cada escopo docs/adr também traz filesCut: quantos desses arquivos têm chunk marcado metadata.sourceCut, isto é, foram lidos só até o teto de indexação. Um arquivo "indexado" que filesCut conta tem parte do texto FORA do índice, e a cobertura não o apresenta mais como inteiro.

  • Onde: src/application/use-cases/rag/get-rag-coverage.use-case.ts (linha 47)
  • Teste: test/application/use-cases/rag/get-rag-coverage.use-case.spec.ts
  • ADR: 0080

RN-1036 — A indexação de docs nunca descarta em silêncio o conteúdo de um arquivo

A indexação de docs lê cada .md até o teto próprio de indexação (RAG_INDEX_FILE_MAX_BYTES, 4 MiB), separado do teto da aba Code (GIT_BLOB_MAX_BYTES, 512 KiB, RN-095), que não muda. Um arquivo cortado por esse teto, ou pulado por ser binário (\0 no conteúdo), aparece:

  • em cutFiles no relatório de indexação (POST .../rag/reindex), com { path, bytes, reason }: bytes é o tamanho do arquivo no repositório e reason é too_large (lido só até o teto) ou binary (pulado, sem chunk);
  • no Logger.warn da api, com o caminho e os bytes;
  • no metadado dos chunks de um arquivo cortado (metadata.sourceCut: true);
  • na cobertura (RN-237), em filesCut de cada escopo.

O que passa do teto é cortado no teto, nunca descartado em silêncio. O insert dos chunks é fatiado (RAG_INSERT_BATCH_SIZE, 1000 por comando) porque o Postgres aceita 65 535 parâmetros por comando e um arquivo de 4 MiB já gera ~5 400 chunks. O provider do GitHub lê o conteúdo em formato raw: no formato JSON a API devolve content vazio para arquivo de 1 a 100 MB, e o arquivo sumiria sem aviso.

  • Onde: src/application/use-cases/rag/index-project-docs.use-case.ts, src/application/use-cases/git/read-project-code.use-case.ts (file(..., maxBytes)), src/application/use-cases/rag/get-rag-coverage.use-case.ts, src/domain/rag/rag-search-limits.ts, src/infrastructure/persistence/drizzle/chunk.repository.ts, src/infrastructure/git/github-provider.ts, src/interfaces/http/rag/dto/rag.response.dto.ts.
  • Teste: test/application/use-cases/rag/index-project-docs.use-case.spec.ts (600 KiB indexado até o fim; acima de 4 MiB em cutFiles com sourceCut; .md com \0 pulado e declarado), test/application/use-cases/git/read-project-code.use-case.spec.ts (default de 512 KiB intacto), test/application/use-cases/rag/get-rag-coverage.use-case.spec.ts (filesCut), test/infrastructure/persistence/chunk.repository.spec.ts (9 500 chunks em createMany), test/infrastructure/git/github-provider.contract.spec.ts (arquivo acima de 1 MB em raw).
  • ADR: 0269.
  • Origem: AT-764 (EP-033), decisão do dono de 06/11/2026 (teto de 4 MiB); rodada 223, lane A.

RN-238 — As três rotas HTTP do RAG dividem papel por quem MUDA o que o produto gasta

POST .../rag/search e GET .../rag/coverage são role:viewer (leitura pura); POST .../rag/reindex é role:maintainer, porque dispara N chamadas ao repositório do projeto e ao provider de embedding — mesma régua "muda o que o produto gasta sem perguntar" do teto de paralelismo de área (RN-083).

  • Onde: src/interfaces/http/rag/rag.controller.ts (linhas 50, 57, 81, 98), docs/security-surface.md
  • Teste: test/interfaces/route-surface.spec.ts (classificação de papel, tags fechadas, metadados OpenAPI das três rotas)
  • ADR: 0080

PROGRAMA 28 — Onda 4, frente E2: virtualização de linha e minimapa na aba Code (RN-239..242)

RN-243 — O ciclo de vida do container é TABELA, e nenhuma linha dela chama Docker

project_containers (migração 0046) grava o ESTADO mutável do container de um projeto — distinto de artifact.project_image no event log (ADR 0065), que é a DECISÃO imutável do Arquiteto. Nem RegistrarTransicaoDeContainerUseCase nem ObterCicloDeVidaDoContainerUseCase chamam um daemon Docker: nenhum serviço do produto monta /var/run/docker.sock nem roda privileged hoje, e conceder isso é decisão de segurança fora do escopo desta regra. Um orquestrador real, quando existir, CONSOME esta tabela depois de agir de verdade — nunca o contrário.

  • Onde: src/domain/containers/container-lifecycle.ts, src/application/use-cases/containers/registrar-transicao-de-container.use-case.ts
  • Teste: test/application/use-cases/containers/ciclo-de-vida-do-container.use-case.spec.ts
  • ADR: 0081

RN-244 — A máquina de estados do container: só removed sai reprovisionando

provisioning → running ⇄ stopped, com failed alcançável de provisioning/running/stopped, e removed → provisioning como a ÚNICA saída de removed — nenhum estado é terminal de verdade, porque um projeto pode reprovisionar com uma imagem revisada pelo Arquiteto. Transição fora da tabela lança InvalidContainerTransitionError, que o caso de uso traduz para 409 — mesmo formato de session-state-machine.ts e pr-gate-state-machine.ts.

  • Onde: src/domain/containers/container-lifecycle.ts (ALLOWED_TRANSITIONS)
  • Teste: test/domain/containers/container-lifecycle.spec.ts
  • ADR: 0081

RN-245 — A primeira transição exige a imagem já decidida, e CONGELA versão e recursos

Não existe linha até a primeira chamada com to: 'provisioning', e ela só é aceita se o Arquiteto já tiver decidido a imagem do projeto (RN-105) — o mesmo portão que já protege a aba Code, aplicado na origem em vez de duplicado. A versão de artifact.project_image e os recursos declarados naquele instante são CONGELADOS na linha nova (image_version, cpus, memory_mb, pids_limit): uma revisão posterior do artefato não muda retroativamente o que uma instância já provisionada promete.

  • Onde: src/application/use-cases/containers/registrar-transicao-de-container.use-case.ts
  • Teste: test/application/use-cases/containers/ciclo-de-vida-do-container.use-case.spec.ts — "a primeira transição (provisioning) cria a linha…" e "sem decisão de imagem do Arquiteto, não há o que provisionar"
  • ADR: 0081

RN-246 — Projeto em modo local não tem ciclo de vida de container

Um projeto com workspace_mode: 'local' (ADR 0072, RN-169) roda no container do AGENTE de sempre — não sobe container próprio. Pedir qualquer transição para um projeto local é recusado com 400 ANTES de tocar a tabela, na origem, não filtrado depois na UI.

  • Onde: src/application/use-cases/containers/registrar-transicao-de-container.use-case.ts
  • Teste: test/application/use-cases/containers/ciclo-de-vida-do-container.use-case.spec.ts — "projeto em modo local não tem ciclo de vida de container (ADR 0072)"
  • ADR: 0081

RN-247 — Uma linha por projeto: project_id é único

Só existe UM container vigente por projeto de cada vez — o mesmo desenho de dev_agent_states no engine (ADR 0045). A constraint única mora no BANCO (project_containers_project_id_unique), não só no caso de uso: create chamado duas vezes para o mesmo projeto falha na escrita, nunca produz uma segunda linha silenciosa.

  • Onde: src/db/migrations/0046_chilly_forgotten_one.sql
  • Teste: test/infrastructure/persistence/drizzle/container.repository.spec.ts — "só uma linha por projeto — create duplicado viola a constraint única"
  • ADR: 0081

RN-248 — Teto de recursos DECLARADO, não aplicado — o campo existe para quando houver orquestrador

cpus/memory_mb/pids_limit gravam o que o artefato do Arquiteto prometia no momento do provisionamento, mas nenhum processo hoje faz o kernel respeitar esse teto — não há orquestrador chamando docker run --cpus … --memory … --pids-limit …. O campo nasce mesmo assim porque adiar a coluna para quando o orquestrador existir obrigaria uma migration de correção no dia em que ele chegasse; declarar sem aplicar é honesto enquanto a tabela não mentir sobre APLICAR (nenhuma tela ou resposta de API hoje afirma "o container está limitado a X" — só "a intenção registrada era X").

  • Onde: src/db/schema/containers.ts (projectContainers)
  • Teste: test/infrastructure/persistence/drizzle/container.repository.spec.ts — "create nasce em provisioning, com a versão e os recursos passados"
  • ADR: 0081

PROGRAMA 28 — Onda 5, frente G3: a tela do Chat RAG (RN-252..254, ADR 0082)

RN-267 — GET /projects/:projectId/container/lifecycle é a primeira exposição HTTP do ciclo de vida do container

O ADR 0081 criou ObterCicloDeVidaDoContainerUseCase sem rota, de propósito ("expor uma seria adivinhar contrato" antes de existir um consumidor real). A Onda 5/F2 é esse consumidor (RN-268), e a rota nasce como espelho fiel do caso de uso: null quando o projeto nunca foi provisionado — o resultado esperado hoje, porque nenhum orquestrador real transiciona project_containers em produção (RegistrarTransicaoDeContainerUseCase não tem chamador nenhum fora de teste) — ou o estado registrado (status/imageVersion/resources/failureReason/statusChangedAt), NUNCA confirmado contra um daemon Docker, porque não existe cliente Docker no produto (RN-243). Mesma permissão da rota irmã (GET /projects/:projectId/container): viewer, GET, sem @Post — quem transicionaria o ciclo de vida é um orquestrador que ainda não existe.

  • Onde: src/interfaces/http/containers/containers.controller.ts (cicloDeVida), src/interfaces/http/containers/dto/containers.response.dto.ts (CicloDeVidaDoContainerResponseDto)
  • Teste: test/interfaces/http/containers/containers.controller.spec.ts
  • Borda: id/projectId/containerId da linha interna NÃO vazam na resposta — o contrato HTTP não é a mesma forma que a linha do banco.
  • ADR: 0083 (revisa o 0081)

RN-272 — O callback do login social decide em ORDEM: identidade conhecida, depois vínculo por e-mail, depois conta nova

SocialLoginCallbackUseCase resolve a identidade do provider em três passos, nesta ordem, e nunca fora dela: (provider, providerUserId) já vinculada → login direto; sem vínculo mas o e-mail bate com uma conta existente → decide por RN-274/RN-275; nenhuma das duas → provisiona conta nova (RN-278). A chave de busca do primeiro passo é sempre providerUserId, nunca e-mail — evita que trocar o e-mail no provider "perca" o vínculo.

  • Onde: src/application/use-cases/auth/social-login-callback.use-case.ts
  • Teste: test/application/use-cases/auth/social-login-callback.use-case.spec.ts — os sete casos de SocialLoginCallbackUseCase
  • ADR: 0084

RN-273 — O state do login social tem propósito PRÓPRIO, mesmo assinado pela mesma chave do fluxo de conexão de git

domain/auth/social-oauth-state.ts assina com GIT_OAUTH_STATE_SECRET — a MESMA chave HMAC do state de "conectar git ao projeto X" (domain/git/oauth-state.ts) — mas o payload carrega purpose: 'social_login', checado ANTES de qualquer outro campo. Um state do fluxo de conexão de git, mesmo com assinatura válida, é recusado aqui: sem o discriminante, aceitá-lo equivaleria a logar como o userId de quem iniciou aquela conexão — escalação de privilégio.

  • Onde: src/domain/auth/social-oauth-state.ts
  • Teste: test/domain/auth/social-oauth-state.spec.ts — "RN-273: rejeita um state do fluxo de CONEXÃO de git, mesma chave"
  • ADR: 0084

RN-274 — Vincular identidade social a conta existente exige e-mail VERIFICADO pelo provider

Quando o e-mail devolvido pelo provider bate com uma conta já cadastrada, o vínculo só acontece se o provider marca aquele e-mail como verificado (emailVerified: true) — GitHub via GET /user/emails, GitLab via confirmed_at. Um e-mail digitado mas não verificado não é prova de identidade: qualquer um pode registrar um e-mail alheio num provider OAuth. Aceitar o vínculo sem essa checagem abriria account takeover — quem já tem alguem@empresa.com na Brabo não pediu para um GitHub alheio, com aquele endereço só DIGITADO, herdar a conta. A recusa é 403, e nenhum vínculo é gravado.

  • Onde: src/application/use-cases/auth/social-login-callback.use-case.ts (vincularAContaExistente)
  • Teste: test/application/use-cases/auth/social-login-callback.use-case.spec.ts — "RECUSA vincular quando o e-mail bate mas NÃO está verificado pelo provider"
  • ADR: 0084

RN-275 — Provisionar conta NOVA não exige e-mail verificado — só vincular a uma existente exige

A verificação de e-mail da RN-274 protege uma conta que já existe; quando não há conta correspondente, não há nada a proteger, só uma conta nova a nascer. Exigir e-mail verificado nesse caso encareceria o caminho comum (a maioria dos usuários de GitHub tem e-mail verificado, mas nem todos, e recusar o login por isso seria atrito sem ganho de segurança correspondente) sem reduzir risco nenhum — o pior caso é uma conta nova com um e-mail não comprovado, o mesmo risco que o registro por senha já aceita implicitamente até o clique no link de verificação.

  • Onde: src/application/use-cases/auth/social-login-callback.use-case.ts (provisionarContaNova)
  • Teste: test/application/use-cases/auth/social-login-callback.use-case.spec.ts — "provisiona conta nova mesmo com e-mail NÃO verificado"
  • ADR: 0084

RN-276 — providerUserId, nunca e-mail ou login, é a chave de identidade social

social_identities tem índice único em (provider, provider_user_id) — o id NUMÉRICO e estável do provider. E-mail e login (username) podem mudar de dono ou de valor no provider sem aviso; usá-los como chave faria uma troca de e-mail no GitHub "perder" o vínculo de quem já tinha conta, ou pior, herdar silenciosamente o vínculo de outra pessoa que reusou aquele endereço depois.

  • Onde: src/db/migrations/0047_complete_hannibal_king.sql, src/application/ports/social-identity-repository.port.ts
  • Teste: test/application/use-cases/auth/social-login-callback.use-case.spec.ts — "identidade já conhecida: login direto, sem criar segunda linha"
  • ADR: 0084

RN-277 — Login social pede escopo MÍNIMO, nunca o de conexão de repositório

buildLoginAuthorizeUrl pede read:user user:email (GitHub) e read_user (GitLab) — nunca o repo/api que o fluxo de CONEXÃO de git pede. Entrar na conta não deveria conceder acesso a repositório nenhum; os dois fluxos reusam o MESMO app OAuth (RN-281) mas pedem autorizações diferentes, decididas na hora da autorização, não na configuração do app.

  • Onde: src/infrastructure/git/github-oauth-client.ts, src/infrastructure/git/gitlab-oauth-client.ts (buildLoginAuthorizeUrl)
  • Teste: test/application/use-cases/auth/social-login-callback.use-case.spec.ts — "StartSocialLoginUseCase › caminho feliz"
  • ADR: 0084

RN-278 — Conta provisionada por login social nasce SEM senha — mesmo estado "pendente" da migração do Keycloak

Provisionar por login social grava uma linha em users e NENHUMA em auth_credentials — o mesmo par que a migração do Keycloak já deixava para "conta sem senha ainda" (ver RN-032 e migracao-keycloak.spec.ts). LoginUseCase e ResetPasswordUseCase já tratam esse estado; não foi necessário um segundo mecanismo de "senha pendente" para o login social.

  • Onde: src/application/ports/auth-credential-repository.port.ts (criarUsuarioSemCredencial), src/infrastructure/persistence/drizzle/auth-credential.repository.ts
  • Teste: test/application/use-cases/auth/social-login-callback.use-case.spec.ts — "caminho feliz: provisiona um usuário NOVO, sem senha (RN-278)"
  • ADR: 0084

RN-279 — Vincular por e-mail verificado também verifica o e-mail da conta existente

Quando uma conta registrada por senha (e nunca verificada) é vinculada a uma identidade social cujo e-mail o provider marca como verificado, a conta ganha emailVerifiedAt preenchido como efeito colateral. O provider acabou de provar, por um caminho independente, exatamente o que o clique no link de verificação provaria — não faz sentido a conta continuar bloqueada do login por senha (RN-032) depois de provar posse do e-mail por outro caminho igualmente forte.

  • Onde: src/application/use-cases/auth/social-login-callback.use-case.ts (vincularAContaExistente)
  • Teste: test/application/use-cases/auth/social-login-callback.use-case.spec.ts — "vincula a conta existente (…) e marca o e-mail dela como verificado (RN-274/279)"
  • ADR: 0084

RN-280 — Login social recusa conta DESABILITADA, mesma régua do login por senha

Identidade social vinculada a uma conta com auth_credentials.disabled_at preenchido é recusada com 403, tanto no login direto (identidade já conhecida) quanto na tentativa de vincular. Desabilitar uma conta não pode ser contornado trocando de método de entrada.

  • Onde: src/application/use-cases/auth/social-login-callback.use-case.ts (entrarComIdentidadeConhecida, vincularAContaExistente)
  • Teste: test/application/use-cases/auth/social-login-callback.use-case.spec.ts — "recusa login de identidade vinculada a conta DESABILITADA"
  • ADR: 0084

RN-281 — O login social reusa o MESMO app OAuth da conexão de git — sem variável de ambiente nova

GITHUB_OAUTH_CLIENT_ID/_SECRET e GITLAB_OAUTH_CLIENT_ID/_SECRET continuam sendo os únicos client id/secret cadastrados. O que muda por fluxo é o redirect_uri (/auth/oauth/<provider>/callback contra /git/oauth/<provider>/callback) e o scope (RN-277) — os dois decididos em tempo de requisição. Ação do OPERADOR continua necessária (cadastrar o segundo callback URL no app de cada provider), documentada em .env.example — é essa exigência, não uma env var nova, que justifica o branch nascer breaking/.

  • Onde: .env.example, src/application/use-cases/auth/start-social-login.use-case.ts
  • Teste: manual (cadastro no app OAuth) — não há como testar automaticamente uma configuração externa ao produto
  • ADR: 0084

RN-282 — O access token do login social nunca viaja na URL nem no corpo do callback

GET /auth/oauth/:provider/callback grava os cookies de sessão (definirCookiesDeSessao, a MESMA função do login por senha) e redireciona para WEB_ORIGIN/. O boot da web (restaurarSessao(), chamado em toda carga de página) troca o refresh recém-gravado por um access token — nenhum código novo do lado do cliente além dos botões e o alias de rota de erro.

  • Onde: src/interfaces/http/auth/auth.controller.ts (oauthCallback)
  • Teste: test/interfaces/route-surface.spec.ts — cobre a classificação pública e os metadados OpenAPI da rota; o fluxo completo de cookie→boot é E2E, fora do escopo de teste de unidade
  • ADR: 0084

RN-283 — Falha do callback de login social não vaza o motivo na URL

Qualquer falha (state inválido, code rejeitado pelo provider, conta desabilitada, e-mail não verificado) redireciona para WEB_ORIGIN/login?oauth_error=1 — um único sinal genérico, nunca o motivo real. Mesmo padrão do callback de conexão de git (WEB_ORIGIN/git-error).

  • Onde: src/interfaces/http/auth/auth.controller.ts (oauthCallback, bloco catch)
  • Teste: test/application/use-cases/auth/social-login-callback.use-case.spec.ts — cobre os desfechos de falha que o controller mapeia para o mesmo redirect genérico
  • ADR: 0084

Analytics e delivery-metricas: os dois papéis viram RELATÓRIO (RN-320..322, ADR 0089)

Decisão consciente do dono do produto de ANTECIPAR a construção dos papéis analytics e delivery-metricas (packages/contracts/fluxo.yml, status: proposto) sem esperar o gatilho orgânico que cada um já declarava. Os dois viram um SCRIPT só — scripts/analise-funil.ts — no mesmo formato de medir-execucao.ts (Fase 13b): NestFactory.createApplicationContext, --projeto <uuid> obrigatório, leitura pura via Drizzle, sem escrita nenhuma. A forma é a que o próprio fluxo já prescrevia: analytics é "absorvido por medicao" e delivery-metricas "nunca vira agente" — nenhum GenServer, nenhum agente de LLM.

RN-320 — analise-funil.ts é script, nunca agente — mesmo esqueleto de medir-execucao.ts

O comando é pnpm --filter api analise:funil -- --projeto <uuid> [--json]. Só lê proposed_actions (filtrado por actionType IN (git_commit, pr_open, git_merge)) e projects, nunca escreve. A guarda if (process.argv[1]?.endsWith('analise-funil.ts')) void main(); no fim do arquivo é a mesma de medir-execucao.ts: sem ela, importar o módulo no teste subiria o Nest inteiro e derrubaria o processo no process.exit do parser de argumentos.

  • Onde: scripts/analise-funil.ts:64-73 (lerOpcoes), :256-319 (main), :390 (guarda de execução)
  • Teste: test/scripts/analise-funil.spec.ts (só as funções puras — a parte que fala com o banco é exercitada por execução real, mesmo padrão de medir-execucao.spec.ts)
  • ADR: 0089

RN-321 — O funil conta SESSÃO por etapa, só ação executed, e o lead time usa updated_at da execução

calcularFunil conta quantas sessões produziram pelo menos um git_commit, pr_open e git_merge com status: 'executed' — uma sessão com três commits entra uma vez só em cada etapa que alcançou, nunca três. A taxa de conversão de uma etapa é sessões-da-etapa / sessões-da-etapa-anterior, e é null (não 0, não Infinity) quando o denominador é zero — não há "conversão de" nada para medir. calcularLeadTimes usa updated_at da linha de proposed_actions, não created_at: é o instante em que ExecuteGitActionUseCase#record gravou o execution_result de verdade (updateExecutionResult bumba updatedAt), não quando a ação foi PROPOSTA — a mesma distinção que token_usage/preço congelado já fazem em outro contexto (RN-042). Merge cujo updated_at precede o do commit (duas levas na mesma sessão, ou dado incoerente) é descartado, nunca vira lead time negativo.

  • Onde: scripts/analise-funil.ts:115-160 (calcularFunil), :170-193 (calcularLeadTimes)
  • Teste: test/scripts/analise-funil.spec.ts (describe "calcularFunil", "calcularLeadTimes")
  • ADR: 0089

RN-322 — Deployment frequency real filtra merge em branch PROTEGIDA; três métricas ficam DECLARADAS ausentes, nunca aproximadas

deploymentFrequencyPorDia só conta git_merge executed cujo executionResult.targetBranch está em PROTECTED_BRANCHES (src/domain/actions/protected-branches.ts) — merge numa branch de feature não é deploy. Cruza por REFERÊNCIA com o gate backmerge (config/gates.yml): a evidência dele é CI, em .release/gate.json, fora do alcance de um script que só lê o banco, então não há junção de dado, só o mesmo recorte de branch que o gate observa.

Três métricas saem do relatório com uma seção "Não medido, de propósito" em vez de um número aproximado:

  1. Funil de produto completo (ideação → commit). sessions não tem storyId — RN-230 já declara a lacuna na aba Criativo. Fechá-la exige schema novo, fora do escopo desta frente (nenhuma migration).
  2. Evidência de adoção por feature. Não é dado que falta coletar: o Brabo não instrumenta os projetos que ele CONSTRÓI, e não há caminho nenhum para essa telemetria existir hoje.
  3. MTTR e change failure rate. Exigem sinal de INCIDENTE de produção real — a mesma dependência que packages/contracts/fluxo.yml já registra para secops-runtime/platform (status: proposto/planned, ativação sincronizada com DEPLOY_ENABLED).
  • Onde: scripts/analise-funil.ts:220-247 (deploymentFrequencyPorDia), :372-386 (seção "Não medido" impressa)
  • Teste: test/scripts/analise-funil.spec.ts (describe "deploymentFrequencyPorDia")
  • ADR: 0089

O gate implementavel ativa — QA-estratégia como segundo momento do qa-lead (RN-340/341, ADR 0090)

RN-340 — O gate implementavel decide a story ANTES do dev agent escrever código

config/gates.yml declarava o gate implementavel (dono dev-lead) desde a FASE 14d com status: planned — nunca ativado. O ADR 0090 ativa: a ferramenta assess_implementability do Dev Lead propõe o parecer de implementabilidade de uma story (implementavel/inviavel + justificativa) como proposed_action, MESMO padrão de propose_execution_plan (RN-284) — três desfechos ({:ok, texto} | {:pending, action_id} | {:error, texto}), papel mínimo maintainer em decide.ts, e DELIBERADAMENTE fora do bloco de tetos absolutos (é decisão inicial, não ultrapassagem de teto).

O parecer depende do plano de teste, que é um PRÉ-REQUISITO, não um argumento. run_assessment/2 lê o artifact.plano_de_teste mais recente da story no HISTÓRICO da própria sessão do Dev Lead (emitido pela QA-estratégia, RN-341):

  1. Sem plano ainda — dispara Engine.Gates.Dispatcher.run_qa_estrategia/3 (mesma indireção trocável em teste que run_qa/2/run_secops/2 já usam) e devolve {:error, texto} pedindo para tentar de novo em instantes. Erro de ferramenta é ENTRADA do laço, não fim de linha (RN-163): o Dev Lead tem teto de 14 iterações para tentar de novo. A janela de espera é aceita e declarada — a QA-estratégia roda em processo separado (qa-lead), e um run/2 síncrono não pode bloquear esperando o resultado sem acoplar os dois processos.
  2. Com plano — monta o parecer com o plano de teste EMBUTIDO no payload (síntese e critérios executáveis), para o usuário decidir sem precisar abrir dois eventos, e propõe a ação.

Desde a RN-674 (ADR 0192) os dois passos acima NÃO valem mais. O plano de teste nasce DEPOIS da entrega do dev e alimenta o qa-verificada; run_assessment/2 não lê nem dispara plano, propõe o parecer na PRIMEIRA chamada, sobre a história e o module_map que o Dev Lead já tem no contexto, e o payload perdeu planoDeTeste/criteriosExecutaveis. Dispatcher.run_qa_estrategia/3 saiu. O resto desta regra (proposta, três desfechos, papel mínimo, fora dos tetos absolutos) segue igual.

  • Onde: apps/engine/lib/engine/agents/dev_lead_tools.ex (run_assessment/2, spec_assess_implementability/0), dev_lead_server.ex (run_tool/3); src/domain/actions/decide.ts (assess_implementability); config/gates.yml (implementavel, status: active)
  • Teste: apps/engine/test/engine/agents/dev_lead_tools_test.exs (describe "assess_implementability"), dev_lead_server_test.exs (describe "suspensão em aprovação" — os dois testes novos), test/domain/actions/decide.spec.ts (describe "parecer de implementabilidade do Dev Lead")
  • Origem: ADR 0090

secops-runtime — relatório de abuso sobre rate_limit_hits (RN-375..377, ADR 0091)

Decisão consciente do dono do produto de antecipar o papel secops-runtime (packages/contracts/fluxo.yml, camada_seguranca), que nasceu proposto com o critério de separação "produção com tráfego real (pós DEPLOY_ENABLED + platform ativo)". Esse gatilho não disparou — não há tráfego de produção. O que existe hoje é rate_limit_hits (RateLimitGuard, ADR 0027): uma linha por request contado, gravada mesmo sob tráfego de dev/CI. secops-runtime entra como SCRIPT (pnpm --filter api relatorio:seguranca-runtime), não agente LLM nem GenServer — não há decisão a tomar, só dado a agregar.

RN-375 — O relatório declara a janela, nunca finge um histórico maior

DomainGaugesCollector.pruneRateLimit (src/infrastructure/observability/domain-gauges.collector.ts:177-186) apaga hits mais velhos que 2 × RATE_LIMIT_WINDOW_MS (240s com o default de 60s), a cada METRICS_GAUGE_INTERVAL_MS (15s por padrão) — a tabela nunca guarda mais que uns poucos minutos. relatorio-seguranca-runtime.ts imprime DUAS janelas, nunca uma só: a CONFIGURADA (o teto teórico da poda) e a OBSERVADA (o que os dados efetivamente cobrem, do primeiro ao último occurred_at lido). Quando as duas coincidem, o relatório diz explicitamente que é sinal de poda — hits mais antigos podem ter existido e já foram apagados — nunca "não houve mais hits que isso".

RN-376 — O ranking trabalha só com o que bucket_key guarda: balde e quando

RateLimitGuard.registrarEContar (src/interfaces/http/shared/rate-limit.guard.ts:150-170) grava só bucket_key (user:<uuid> ou ip:<endereço>) e occurred_at — NÃO há rota, método HTTP nem motivo do bloqueio. rankingDeBaldes e interpretarBalde (scripts/relatorio-seguranca-runtime.ts) classificam por esses dois campos e só eles; um bucket_key fora do formato user:/ip: cai em desconhecido em vez de estourar, para o script não quebrar se um RateLimitGuard futuro gravar outro formato. Pedir "ranking de IP por rota" seria inventar dimensão que a tabela nunca guardou.

O papel dbre vira dois scripts mecânicos (RN-400..403, ADR 0093)

RN-400 — O parecer de migração é análise ESTÁTICA de SQL, e ignora o padrão de risco quando ele só aparece em comentário

lintarConteudo (o núcleo puro de lint-migracao.ts) varre cada .sql de src/db/migrations/ linha a linha, sem depender de --projeto nem de banco — o risco que ele acha é de SCHEMA, não de carga, e por isso não depende de volume real de dados nenhum (o critério de separação que packages/contracts/fluxo.yml já declarava para o papel). Cinco padrões, cada um com a razão anexada ao achado: DROP TABLE/TRUNCATE (perda total, irreversível), DROP COLUMN (perda da coluna, irreversível), ALTER COLUMN ... TYPE/SET DATA TYPE (pode reescrever a tabela inteira) e ADD COLUMN ... NOT NULL sem DEFAULT (falha contra tabela não-vazia — o padrão que 0042_tough_captain_midlands.sql evitou conscientemente, com nullable-primeiro-depois-backfill-depois-SET NOT NULL).

Linha que começa com -- (comentário) é ignorada de propósito: este repositório explica em prosa, no próprio SQL, por que um padrão foi EVITADO — analisar o texto do comentário acharia o padrão exatamente na frase que descreve por que ele não foi usado.

  • Onde: scripts/lint-migracao.ts (REGRAS, linha 75; lintarConteudo, linha 126)
  • Teste: test/scripts/lint-migracao.spec.ts — cada padrão individualmente, combinação de vários na mesma migration, e "ignora o padrão de risco quando ele aparece só em COMENTÁRIO"
  • ADR: 0093

Auditoria fluxo.yml × código — Onda 2: RN-160 no backend e delegação Dev Lead → dev (RN-404/405, ADR 0094)

Fecha os dois últimos achados da auditoria (seção D, docs/explanation/auditoria-fluxo-vs-codigo.md): B6 (RN-160 garantida só no cliente) e B1 (a delegação Dev Lead → dev-<modulo> declarada pelo ADR 0053 item 5 e nunca implementada).

RN-404 — "Confirmar arquitetura pronta" (RN-160) é revalidada no BACKEND, não só desabilitada na UI

RN-160 garantia a regra ("pelo menos 1 história promovida antes do handoff duplo Arquiteto→Dev Lead/Infra") só desabilitando o botão em SessionPage.tsx — uma chamada HTTP direta a POST /agents/arquiteto/handoff-infra, sem passar pela UI, ignorava a regra por completo. OfferInfraHandoffUseCase.execute agora consulta StoryRepository.findByProject(projectId) e recusa com BadRequestException quando NENHUMA história do projeto tem status !== 'draft' — a checagem vem ANTES de gravar architecture.readiness_confirmed e ANTES de qualquer chamada ao engine (offerInfraHandoff; o offerDevHandoff saiu na RN-672): uma recusa não pode deixar rastro de handoff meio-ofertado no event log, que é imutável. StoryRepository foi escolhido (e não ListBacklogUseCase) por ser mais leve — não precisa montar a árvore épico→história→tarefa para responder "existe alguma não-draft?".

  • Onde: src/application/use-cases/agents/offer-infra-handoff.use-case.ts (linhas 27-34, a checagem; injeção de StoryRepository no construtor)
  • Teste: test/application/use-cases/agents/offer-infra-handoff.use-case.spec.ts — zero história promovida recusa com ZERO chamada ao engine e ZERO evento gravado; com ao menos uma ready/in_progress/done, segue o fluxo normal
  • ADR: 0094

Auditoria fluxo.yml × código — Onda 6 (última): o gate necessidade-validada (RN-406, ADR 0095)

RN-408 — MAIL_TRANSPORT=smtp deriva o boot pelo padrão da RN-114, sem o default público

Fecha o item de backlog "SMTP real no MailSender" (ADR 0096). resolverConfigSmtp() valida SMTP_HOST/SMTP_USER/SMTP_PASSWORD/ SMTP_FROM no MESMO formato da RN-114 — em produção (NODE_ENV === 'production'), cada um derruba o boot se estiver ausente, só com espaços, ou (no caso de SMTP_HOST) igual ao literal de exemplo publicado (comentado) em .env.example. SMTP_FROM tem uma checagem a mais: precisa casar "Nome <email@dominio>" ou só email@dominio. SMTP_PORT inválida (não numérica ou fora de 1–65535) também derruba o boot em produção.

A diferença para a RN-114 original é estrutural, não de rigor: os quatro segredos da RN-114 (AUTH_JWT_SECRET e companhia) TÊM um default de desenvolvimento público, e é a existência desse default que torna "não vazia" uma checagem insuficiente. Aqui não há default nenhum — SMTP_HOST fica em branco se ninguém setar —, então a régua inteira só é aplicada quando NODE_ENV=production E MAIL_TRANSPORT=smtp. Fora de produção, ou com o transporte em log (o default, inclusive em produção), nenhuma das cinco variáveis é exigida: enviar e-mail de verdade é opt-in do operador, e sem esse opt-in explícito o comportamento continua sendo o log-only de sempre — inclusive em produção, para quem já roda o produto hoje não quebrar ao atualizar.

A validação roda dentro do construtor de SmtpMailSender, exercitado pelo useFactory de AuthUseCasesModule na montagem do grafo de providers do Nest (NestFactory.create()) — não por uma chamada eager em main.ts, como os quatro segredos da RN-114 original. A diferença é deliberada: aqueles quatro protegem caminho que QUALQUER requisição pode exercitar a qualquer momento, então falhar antes de subir importa. SmtpMailSender só é instanciado quando o operador optou por smtp, e AuthUseCasesModule é importado incondicionalmente (via AuthHttpModule), então a validação ainda acontece no boot — mesmo desenho que CREDENTIALS_MASTER_KEY já usa (validada no construtor de EnvelopeEncryptionService, exercitado pela mesma montagem de grafo).

  • Onde: src/infrastructure/mail/smtp-config.ts (resolverConfigSmtp, resolverModoDeTransporte), src/infrastructure/mail/smtp-mail-sender.ts (SmtpMailSender, chama resolverConfigSmtp() no construtor), src/application/use-cases/auth/auth-use-cases.module.ts (useFactory do MailSender)
  • Teste: test/infrastructure/mail/smtp-config.spec.ts (mesmo padrão de auth-key-material.spec.ts/service-token.spec.ts), test/infrastructure/mail/smtp-mail-sender.spec.ts
  • Origem: ADR 0096, estendendo o padrão da RN-114 (que por sua vez estende o ADR 0059)

Workspace pessoal automático no cadastro (RN-410)

Achado navegando o produto depois de um reset de banco: o botão "Novo projeto"/"+" do dashboard não fazia NADA, sem erro nenhum. A causa raiz estava em duas camadas. A visível: Dashboard.tsx só abre o NewProjectWizard quando useCurrentWorkspace() acha um workspace (list[0]?.workspace de GET /workspaces); sem nenhum, o clique não tem onde ir. A funda: RegisterUseCase criava usuário e credencial mas NUNCA um workspace, e o mesmo valia para SocialLoginCallbackUseCase no ramo que provisiona conta nova — TODO cadastro novo, por senha ou por login social, caía nessa parede. Só não tinha aparecido antes porque db/seed.ts sempre cria um workspace junto dos dados de demonstração, e nenhuma tela do produto chama createWorkspace (a rota, o caso de uso e o client HTTP existem — só não têm chamador nenhum na UI).

RN-410 — Toda conta NOVA nasce com um workspace pessoal, na MESMA transação da conta

RegisterUseCase (registro por e-mail/senha) e SocialLoginCallbackUseCase (provisionamento de conta nova via login social) criam o workspace e adicionam o usuário como owner — mesmo par create/addMember que CreateWorkspaceUseCase já usa — dentro da MESMA transação que já cria usuário e credencial. Não é uma segunda chamada ao use case: ele abre a PRÓPRIA transação, e o ponto é nunca existir usuário sem workspace se algo falhar no meio.

Nome e slug saem de UMA função pura, nomeESlugDoWorkspacePessoal(nome, email, userId) (src/domain/auth/personal-workspace.ts), reusada pelos dois pontos de criação — para a regra não divergir em dois arquivos, o mesmo motivo por trás de normalizarEmail/exigirSenhaValida morarem no domínio. O nome usa o nome informado (login social usa o login do provider), com o local-part do e-mail como fallback quando não há nome — nunca inventa nenhum dos dois. O slug é kebab-case do mesmo texto, SEMPRE sufixado com userId.slice(0, 8) (mesmo padrão de extraDevAgentId/workspaceDirName/rótulo de sessão): workspaces.slug é UNIQUE no banco e nada no produto faz retry-on-conflict, então dois cadastros com nome ou local-part iguais colidiriam sem o sufixo. Nome ou e-mail sem NENHUM caractere alfanumérico degradam para o literal workspace-<8 chars>, nunca para uma string vazia.

  • Onde: src/domain/auth/personal-workspace.ts (nomeESlugDoWorkspacePessoal), src/application/use-cases/auth/register.use-case.ts, src/application/use-cases/auth/social-login-callback.use-case.ts (método provisionarContaNova)
  • Teste: test/domain/auth/personal-workspace.spec.ts (nome com/sem nome, fallback para o local-part do e-mail, acento/maiúscula virando kebab-case ASCII, fallback workspace-<id> sem caractere alfanumérico nenhum, slug sempre único por conta do sufixo); test/application/use-cases/auth/register.use-case.spec.ts (conta nova ganha workspace com o usuário como owner, e-mail duplicado não cria um segundo); test/application/use-cases/auth/ social-login-callback.use-case.spec.ts (mesma prova no ramo que provisiona conta nova via login social)
  • ADR: nenhum — correção de um caminho que devia ter feito isso desde o início, não decisão estrutural nova
  • Origem: achado por uso real, navegando o produto — não item de backlog planejado

"N agentes online" no dashboard — status AO VIVO, nunca presença histórica (RN-409, ADR 0097)

RN-412 — dev.awaiting_gate/dev.awaiting_approval seguram a sessão; a janela efetiva de compactação é coerente com o transporte

Achado por USO real ("nas PRs sempre está estourando entity too large"), com dois defeitos que se encadeiam. O gate de QA/SecOps morria com 413 request entity too large → o dev agent que esperava o veredito ficava preso em dev.awaiting_gate indefinidamente → esse estado NÃO era um dos três que a RN-411 segurava → o heartbeat de 30s fechava a sessão por baixo → a aba Executores exigia sessão ativa (findActiveExecutionSession) e apagava o roster inteiro, mesmo com trabalho real pendurado.

A causa do 413 era da própria api do Brabo, nunca do provider de LLM — ver ADR 0098. Duas correções, as duas necessárias (uma sem a outra só adia o estouro):

  1. src/main.ts nunca configurou limite de body do Express — valia o default de 100 KB, o gargalo mais estreito no sentido engine→api (o Phoenix aceita até 8 MB). POST /internal/sessions/:sessionId/llm-turn reenvia o histórico INTEIRO da conversa a cada iteração do ToolLoop, e 3-4 tool results de 32 KiB (teto individual da RN-150) já somavam mais que 100 KB. API_JSON_BODY_LIMIT (default 10mb) fecha essa ponta.
  2. Engine.Harness.ContextManager.Default tinha dois defeitos que deixavam a compactação inalcançável antes do corpo estourar: estimate/1 contava só content, então mensagens de assistant com toolCalls pesados custavam ~zero tokens na estimativa; e a janela de compactação usava só context_window (128.000 tokens nos agentes de gate — qa_automacao_agent.ex, qa_performance_seguranca_agent.ex, qa_estrategia_agent.ex, appsec_agent.ex, dev_agent_server.ex), dando ~350 KB antes de compactar (threshold 0.7 × janela). A janela EFETIVA agora é min(context_window, teto_de_transporte) (transport_max_body_bytes, config única, default 8 MiB), e o corte sempre acontece em FRONTEIRA DE ITERAÇÃO do ToolLoop (group_by_iteration/1) — nunca separando uma mensagem assistant com toolCalls dos role: "tool" que a respondem, que quebraria o protocolo de tool-use do provider.

A régua de trabalho pendente (DEV_PENDING_TYPES, get-session-pending-work.use-case.ts) ganhou dois tipos: dev.awaiting_gate (o argumento original — o gate agora não deveria mais morrer, mas travar a sessão por causa dele nunca foi correto, é defesa em profundidade) e dev.awaiting_approval, por um argumento DIFERENTE do que a RN-411 tinha descartado: a decisão de aprovação/negação grava proposed_actions.status de forma SÍNCRONA na transação do ApproveActionUseCase (o segundo sinal já não vê mais pending ali), mas a retomada do dev agent é ASSÍNCRONA — só depois de avisarQuemEsperava() gravar task.action_settled/task.pr_settled na outbox é que Engine.Outbox.Drain enfileira o job do Oban que acorda DevAgentServer. Nessa janela, nada segurava a sessão: o mesmo defeito da RN-411, um nível mais fundo.

A aba Executores/Visão Geral tinham o defeito irmão do lado web: executionActivated era derivado de events.some((e) => e.type === 'execution.activated') sobre a janela de só 200 eventos de useSessionEvents ({ limit: 200, latest: true }) — execution.activated é dos PRIMEIROS eventos de uma sessão de execução e saía da janela em qualquer sessão real. O valor correto já existia, agregado sobre TODOS os eventos (bool_or(...), RN-090), no resumo do workspace (ProjectCardSummary.roster.executionActivated) — ProjectExecutorsTab.tsx e ProjectOverviewTab.tsx passaram a consumi-lo em vez de derivar da janela. gatesEverOpened sofre da MESMA classe de defeito e ficou DECLARADO como limitação conhecida, não corrigido aqui — corrigi-lo exigiria mudar a assinatura de deriveAgentRoster/rosterFactsFromEvents, fora do escopo desta correção.

  • Onde: src/main.ts, apps/engine/lib/engine/harness/context_manager.ex, apps/engine/lib/engine/harness/tokenizer.ex, src/application/use-cases/sessions/get-session-pending-work.use-case.ts, apps/web/src/routes/ProjectExecutorsTab.tsx, apps/web/src/routes/ProjectOverviewTab.tsx
  • Teste: test/main.spec.ts (body de 1 MB aceito; acima do limite → 413); apps/engine/test/engine/harness/context_manager_test.exs (toolCalls pesados disparam compactação; :pinned sobrevive; teto de transporte força compactação mesmo com janela de modelo grande); get-session-pending-work.use-case.spec.ts (último evento dev.awaiting_gate/dev.awaiting_approval → pending: true); ProjectExecutorsTab.test.tsx/ProjectOverviewTab.test.tsx (sessão com >200 eventos ainda mostra o roster via resumo agregado)
  • Origem: achado por uso real — "nas PRs sempre está estourando entity too large", investigado em conjunto com o dono do produto

RN-416 — O grafo é memória DERIVADA, reconstruível por projeção da outbox

A alternativa de o engine escrever no Neo4j direto foi RECUSADA — abriria um segundo caminho de escrita além do event log, quebrando a garantia de fonte única de verdade. GraphProjector (api) drena uma SEGUNDA linha de outbox, mesma transação de sempre, aggregateType: 'graph_projection' — valor que o Engine.Outbox.Drain do lado engine nunca casa (o filtro dele é aggregate_type IN ('session', 'task')), evitando a corrida que existiria se reusasse 'session' (o engine já drena e marca esse tipo em ~2s). Mesmo padrão de deny-action.use-case.ts, que já grava em dois aggregateType na mesma transação.

Instrumentado em dois pontos: AppendSessionEventUseCase (para handoff.offered, psychologist.hypothesis_proposed, anamnese.profile_updated — payload só {eventId}, o projector RELÊ o envelope completo do event log na hora de projetar, nunca confia numa cópia potencialmente velha) e TransitionSessionUseCase (para session.closed/session.closed_abnormally, que não passam por session_events). GraphProjector é um poller (~2s, mesmo formato do DomainGaugesCollector) que chama os casos de uso de gravação já existentes da fundação anterior — a idempotência mora NELES (chave natural por tipo: Hipotese.id, Handoff(sessionId,seq), PerfilAnamnese(userId,dimensao), Interacao.sessionId com extensão de faixa). GraphUnavailableError no meio de um lote PARA o ciclo inteiro (o resto falharia pelo mesmo motivo) — a linha fica não-processada e tenta de novo sozinha no próximo ciclo, sem intervenção.

  • Onde: src/application/graph-projection/graph-projector.ts; src/domain/graph/graph-projection-events.ts; src/application/use-cases/sessions/append-session-event.use-case.ts, transition-session.use-case.ts
  • Teste: graph-projector.spec.ts (caminho feliz dos quatro tipos; resolução evidência→seq de hipótese; GraphUnavailableError deixa a linha sem marcar e para o ciclo; ciclo seguinte reprocessa com sucesso; reprocessar a MESMA linha duas vezes não duplica no grafo); testes novos em append-session-event.use-case.spec.ts/ transition-session.use-case.spec.ts (tipo projetável grava a segunda linha; tipo não-projetável não grava nada extra)
  • Origem: ver ADR 0101

RN-418 — Efeito externo git e comando privilegiado (sudo/doas) viram teto absoluto, nunca deny

Revisada pela RN-713 (AT-385, 02/10): com o modo automático (a curinga em auto_approve), push e PR são auto-aprovados.

Revisada pela RN-689 (AT-347, decisão do dono de 01/10): o teto abaixo vale também para as ações TIPADAS git_push e pr_open, que até ali só tinham o papel mínimo e nasciam auto_approved pelo curinga, por regra específica ou por permissions.json. O resto desta entrada continua valendo.

Revisa a RN-106 por decisão GLOBAL e explícita do dono do produto: git push, abertura de PR, deploy (a mesma detecção por prefixo que a RN-106 já tinha) e sudo/doas (novo — casados por VERBO em comandoPrivilegiadoNoComando, varrendo todos os segmentos do comando) deixam de ser deny incondicional e viram TETO ABSOLUTO — require_approval incondicional, no MESMO bloco final e MESMO padrão de código dos outros tetos (merge protegida, instruction_patch, parallelize/raise_max_parallel, escopo de caminho): current.policy === 'auto_approve' → sobrescrito. Nunca auto-aprovável por agent_autonomy (inclusive o curinga "*" do modo automático) nem por permissions.json.

A condição que torna isto seguro — sem a qual o teto seria decorativo — é a metade que fecha "sempre permitir" NA FONTE: ApproveAlwaysActionUseCase/patternsForAction recusam gravar padrão em allow pra ação de terminal com efeito externo git ou comando privilegiado. A instância específica ainda pode ser aprovada pelo fluxo normal (ApproveActionUseCase); só o clique que gravaria um padrão pra sempre é recusado, com mensagem clara. É o mesmo argumento que a RN-106 original usava pra justificar deny — resolvido na origem, não mais bloqueando o sintoma.

Um aviso automático de segurança sinalizou esta mudança durante a implementação (reescrever uma regra que o produto documentava como deny absoluto merece escrutínio) — o dono do produto confirmou explicitamente, depois de revisar, que a decisão era essa.

  • Onde: src/domain/actions/decide.ts (bloco de tetos absolutos), src/domain/actions/external-effect.ts (comandoPrivilegiadoNoComando, mensagemDeComandoPrivilegiado), src/application/use-cases/actions/approve-always-action.use-case.ts
  • Teste: decide.spec.ts (git push com auto mode "*" E com allow casando no permissions.json resolvem pra require_approval; sudo idem; os tetos anteriores continuam intactos — regressão completa); approve-always-action.use-case.spec.ts ("sempre permitir" sobre git com efeito externo/sudo não grava padrão; comando comum continua gravando normalmente — regressão); external-effect.spec.ts
  • Origem: ver ADR 0102

Onda 1 do ADR 0104 — execution_mode em três valores, workspace verificado pelo runner

O ADR 0072 (RN-169/RN-170) e o ADR 0103 (RN-419/RN-420) nunca se falaram: o runner reusava a mesma flag workspace_mode == 'local' sem bind-mount nenhum, então usar o runner de verdade continuava exigindo passar pela validação de PASTA MONTADA. O ADR 0104 reconcilia os dois. Migração 0048 recria o enum (project_workspace_mode → project_execution_mode, local → mounted) numa transação só — ALTER TYPE ... ADD VALUE não pode ser referenciado na mesma transação em que foi adicionado — e soma workspace_verified_at (nullable).

RN-421 — execution_mode tem TRÊS valores: container/mounted/runner

projects.workspace_mode (dois valores) virou projects.execution_mode (três): container (DEFAULT, inalterado), mounted (o antigo local, RENOMEADO — mesmo comportamento, mesma validação de disco na criação) e runner (NOVO: uma pasta do usuário sem bind-mount, confirmada pelo CLI brabo-runner). O CHECK do banco passou a ser (execution_mode <> 'container') = (workspace_path IS NOT NULL) — cobre os dois modos não- container com a MESMA condição, sem OR explícito por valor.

projectScopeRoot/Project.workspace_dir_name (api e engine) derivam a raiz pela MESMA regra nos dois modos não-container: o caminho do usuário é a raiz, distinguido do nome de pasta gerenciada pela barra inicial. O que muda entre mounted e runner não é ONDE a raiz fica — é QUANDO/QUEM confirma que ela existe de verdade (RN-422/RN-423).

  • Onde: src/db/migrations/0048_quiet_iron_fist.sql, src/db/schema/iam.ts (projectExecutionModeEnum), src/domain/iam/project.entity.ts (PROJECT_EXECUTION_MODES), src/infrastructure/filesystem/project-workspaces-root.ts (projectScopeRoot), apps/engine/lib/engine/projects/project.ex
  • Teste: test/infrastructure/filesystem/project-workspaces-root.spec.ts (describe "projectScopeRoot nos modos mounted/runner"), apps/engine/test/engine/projects/project_test.exs
  • Decisão arquitetural: ADR 0104
  • Origem: reconciliação pedida pelo dono do produto, entre os ADRs 0072 e 0103

RN-422 — A validação de criação DIVERGE por modo: mounted toca disco, runner só o léxico

CreateProjectUseCase.caminhoValidado ganhou um terceiro ramo. mounted continua exatamente como local era (RN-170, histórico): recusa com 400 o caminho que não existe, não é pasta ou não é gravável dentro do container, com a instrução de montagem. runner valida só o LÉXICO — mesma lista de proibições (absoluto, sem .., fora de raiz/pasta de sistema, sem sobreposição com o checkout do Brabo) — sem tocar disco: só o runner, rodando no HOST de verdade, tem autoridade para confirmar que a pasta existe. O projeto nasce com workspace_verified_at: null.

caminhoDeWorkspaceLocalValido (o predicado léxico) foi EXPORTADO de project-workspaces-root.ts para ser reusado nos dois lados que precisam dele sem tocar disco: a criação de projeto runner e a confirmação do runner (RN-423) — a mesma função, nunca duas cópias que um dia divergem.

  • Onde: src/application/use-cases/iam/create-project.use-case.ts (caminhoValidado), src/infrastructure/filesystem/project-workspaces-root.ts (caminhoDeWorkspaceLocalValido, exportada), src/interfaces/http/iam/dto/create-project.dto.ts
  • Teste: test/application/use-cases/iam/create-project-modo-de-workspace.spec.ts (bloco runner), test/interfaces/http/iam/project-dto-modo-de-workspace.spec.ts, apps/web/src/lib/wizard.test.ts (canAdvanceFromWorkspace para runner)
  • Decisão arquitetural: ADR 0104
  • Origem: reconciliação do ADR 0104

RN-424 — PAT autentica SÓ runner-ticket, por construção — nunca dual-auth com JWT nessa rota

Um Personal Access Token (brb_…) nunca autoriza nenhuma rota além de POST /projects/:projectId/runner-ticket. A garantia não é um if que uma rota nova poderia esquecer de checar: é estrutural. @RequirePatAuth() marca o handler com IS_PAT_ROUTE_KEY; JwtAuthGuard (o APP_GUARD global) checa esse metadado ANTES de tentar verify() de JWT e devolve true sem validar nada — o mesmo formato de bypass que IS_PUBLIC_KEY e IS_SERVICE_ROUTE_KEY já usam. PatAuthGuard, aplicado só nesse handler via @UseGuards(), é quem de fato autentica: extrai o bearer, recusa (401) qualquer valor que não comece com brb_ sem consultar o repositório — nunca tenta validar como JWT nessa rota, e nenhuma outra rota aceita brb_… como bearer.

Depois que o PatAuthGuard popula request.user, RolesGuard/ ResolveEffectiveRoleUseCase continuam rodando, inalterados — cinto e suspensório: se o dono do PAT perder papel suficiente no projeto pela via normal (ProjectMember/workspace), o token para de autorizar mesmo sem ter sido revogado explicitamente.

A alternativa considerada e recusada: o JwtAuthGuard global reconhecer o prefixo brb_ e popular request.user direto, pra QUALQUER rota. Isso autorizaria o PAT a tudo que o papel do usuário permite no resto da api — o escopo "só pede ticket de runner" viraria decorativo.

  • Onde: src/interfaces/http/auth/pat-route.decorator.ts (IS_PAT_ROUTE_KEY/@RequirePatAuth()), src/interfaces/http/auth/jwt-auth.guard.ts (o terceiro early-out), src/interfaces/http/auth/pat-auth.guard.ts (PatAuthGuard), src/interfaces/http/runner/runner-tickets.controller.ts (runnerTicket, o único handler marcado)
  • Teste: test/interfaces/pat-auth.guard.spec.ts, test/interfaces/jwt-auth.guard.spec.ts (@RequirePatAuth() passa sem tentar verify()), test/interfaces/http/runner/runner-tickets.controller.spec.ts (runnerTicket tem o decorator, terminalTicket não)
  • Decisão arquitetural: ADR 0105

RN-425 — Validação de PAT colapsa inexistente/revogado/expirado numa resposta só; escopo errado é 403; last_used_at nunca throttla

PersonalAccessTokenRepository.validarEUsar(hash) é UMA query: UPDATE personal_access_tokens SET last_used_at = now() WHERE token_hash = $1 AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > now()) RETURNING id, user_id, project_id. Zero linhas devolve null, e o guard responde 401 pra "não existe", "revogado" e "expirado" com a MESMA mensagem — quem apresenta um token roubado ou expirado não descobre qual dos três é o motivo (mesmo padrão de AccountTokenRepository.consumir()).

Escopo de projeto incorreto é uma categoria DIFERENTE: se project_id devolvido pela query não bate com :projectId da rota, é 403, não 401 — o token autenticou de verdade, só não tem direito a ESTE projeto.

last_used_at é atualizado de forma INCONDICIONAL, na MESMA query de validação — nunca com um throttle (ex.: "só se last_used_at for NULL ou tiver mais de 5 minutos") no mesmo WHERE. Um throttle ali é um bug real: um PAT reapresentado duas vezes em menos de 5 minutos cairia fora do WHERE na segunda vez (porque last_used_at estaria "fresco demais"), e a query devolveria zero linhas pra um token válido — rejeitando com 401 uma reconexão legítima. O laço de retry do runner reconecta em segundos, não minutos. O custo de não throttlar é um UPDATE de uma linha por índice único a cada chamada — irrelevante no pior caso real (até 10 tentativas seguidas, teto do runner).

  • Onde: src/infrastructure/persistence/drizzle/personal-access-token.repository.ts (validarEUsar), src/interfaces/http/auth/pat-auth.guard.ts
  • Teste: test/infrastructure/persistence/personal-access-token.repository.spec.ts (inexistente/revogado/expirado → null; "toca last_used_at sempre que válido, sem throttle" — regressão do bug acima), test/interfaces/pat-auth.guard.spec.ts (escopo errado → 403, não 401)
  • Decisão arquitetural: ADR 0105

RN-426 — Listar/revogar PAT é escopado ao PRÓPRIO usuário, no WHERE da query — sem admin cross-user nesta onda

ListPersonalAccessTokensUseCase/RevokePersonalAccessTokenUseCase filtram por userId dentro da consulta SQL (WHERE user_id = $1 AND ...), nunca trazendo tudo e filtrando depois em memória. Cada usuário só enxerga e só revoga os PRÓPRIOS tokens — inclusive dentro de um projeto onde ele é maintainer.

revogar(id, userId, motivo) é IDEMPOTENTE: se o UPDATE (com o mesmo WHERE user_id = $1) não acha linha porque já estava revogado, uma segunda consulta de desempate devolve a linha (revogar de novo não é erro); se não acha porque o token não existe OU não é do usuário chamador, devolve null — a MESMA resposta (404 no controller) pros dois casos, pra não vazar a existência de um token alheio pelo código de status.

Fechado pela RN-427: um maintainer revogar o PAT de outro usuário — o caso de resposta a incidente (dev desligado com token vazando) — que esta seção declarava fora de escopo saiu do backlog e está implementado. Esta régua (autorevogação, escopo por userId) não mudou em nada.

  • Onde: src/application/use-cases/auth/list-personal-access-tokens.use-case.ts (ListPersonalAccessTokensUseCase), src/application/use-cases/auth/revoke-personal-access-token.use-case.ts, src/infrastructure/persistence/drizzle/personal-access-token.repository.ts (listarDoUsuarioNoProjeto, revogar)
  • Teste: test/application/use-cases/auth/list-personal-access-tokens.use-case.spec.ts, test/application/use-cases/auth/revoke-personal-access-token.use-case.spec.ts, test/infrastructure/persistence/personal-access-token.repository.spec.ts (revogar token de outro usuário → null)
  • Decisão arquitetural: ADR 0105

RN-427 — maintainer revoga PAT de QUALQUER usuário do projeto, escopado por project_id — resposta a incidente

Extensão do modelo da RN-426, não uma decisão de arquitetura nova: mesmo padrão já usado para fechar a RN-407 (PO lendo métricas de produto) sobre um modelo já decidido, sem ADR próprio. Fecha o corte que o ADR 0105 declarava "fora desta onda" — resposta a incidente real (dev desligado com token vazando), onde esperar o próprio usuário revogar não é opção.

ListPersonalAccessTokensAsMaintainerUseCase/ RevokePersonalAccessTokenAsMaintainerUseCase são casos de uso NOVOS, em rotas SEPARADAS (GET .../personal-access-tokens/all, DELETE .../personal-access-tokens/:tokenId/admin, ambas @RequireRole('maintainer')) — nunca um if dentro dos handlers de self-service, mesmo princípio já usado no resto do produto para autorização por nível (OfferInfraHandoffUseCase). A rota de autorevogação (RN-426) não muda em nada.

listarDoProjeto traz TODOS os tokens do projeto — sem filtro de userId — com um innerJoin em users pro e-mail do dono, mesmo padrão que a listagem de membros do projeto já usa: sem o e-mail, um maintainer vendo "revogar token de quem?" só teria um UUID cru, e o próprio motivo do item (resposta a incidente) pede saber QUEM é o dono.

revogarComoMaintainer(id, projectId, motivo) é o mesmo desenho idempotente de revogar() — UPDATE condicional seguido de SELECT de desempate quando o UPDATE não acha linha —, mas o WHERE compara project_id, nunca user_id: um maintainer revoga qualquer dono DENTRO do projeto dele, nunca um token de outro projeto. Sem linha nenhuma (token não existe OU é de outro projeto), devolve null — a MESMA resposta (404) pros dois casos, mesma disciplina de não vazar existência que a RN-426 já aplicava.

  • Onde: src/application/use-cases/auth/list-personal-access-tokens-as-maintainer.use-case.ts, src/application/use-cases/auth/revoke-personal-access-token-as-maintainer.use-case.ts, src/infrastructure/persistence/drizzle/personal-access-token.repository.ts (listarDoProjeto, revogarComoMaintainer), src/interfaces/http/runner/personal-access-tokens.controller.ts (listAllPats, revokePatAsMaintainer), apps/web/src/routes/settings/PersonalAccessTokensSection.tsx (sub-lista visível só para owner/maintainer)
  • Teste: test/application/use-cases/auth/list-personal-access-tokens-as-maintainer.use-case.spec.ts, test/application/use-cases/auth/revoke-personal-access-token-as-maintainer.use-case.spec.ts, test/infrastructure/persistence/personal-access-token.repository.spec.ts (listarDoProjeto/revogarComoMaintainer), test/interfaces/http/runner/personal-access-tokens.controller.spec.ts, apps/web/src/routes/ProjectSettingsTab.test.tsx
  • Decisão arquitetural: ADR 0105 (extensão do modelo existente, sem ADR novo)

RN-430 — PRs são project-wide; a decisão usa o sessionId da própria ação, nunca a mais recente

A aba prs resolve o defeito de ProjectApprovalsTab.tsx, que escopava a seção "PRs em revisão" a usePendingActions(projectId, latestSession?.id) — a revisão pendente de uma sessão anterior desaparecia assim que uma sessão nova nascia. A listagem vem direto do provider (GET /projects/:id/code/pull-requests, já project-wide por desenho); o cruzamento com a proposed_action correspondente (ex.: git_merge) usa ProposedActionRepository.findPendingByProject(projectId, actionType?), novo, ao lado do já existente escopado por sessão. A decisão (aprovar/negar/sempre permitir) usa o sessionId que a própria ProposedAction carrega, nunca latestSession — a ação pode ter nascido numa sessão diferente da atual.

A aba PRs é a primeira produtora real de git_merge pela UI (actor.kind: 'user'); a trava de branch protegida em decide.ts (RN-154) segue absoluta independente de quem propõe. git_merge ganhou corpo próprio no card de aprovação em vez do despejo de JSON cru (mesmo defeito que a RN-096 já corrigiu para outros tipos).

  • Onde: src/application/ports/proposed-action-repository.port.ts, src/infrastructure/persistence/drizzle/proposed-action.repository.ts, src/application/use-cases/actions/list-project-pending-actions.use-case.ts, src/interfaces/http/actions/project-actions.controller.ts (GET /projects/:projectId/actions?status=pending&actionType=), apps/web/src/routes/ProjectPrsTab.tsx, apps/web/src/components/ApprovalCard.tsx
  • Teste: list-project-pending-actions.use-case.spec.ts (ação pendente de sessão antiga encontrada project-wide; filtro por actionType; nunca devolve ação decidida); propose-action.use-case.spec.ts (git_merge com payload real da aba PRs segue pending mesmo com GitMerge() em allow, e com agent_autonomy curinga ligado); ProjectPrsTab.test.tsx (PRs de múltiplas sessões aparecem juntas; decisão usa o sessionId da ação, não o da sessão mais recente); ApprovalCard.test.tsx (corpo próprio de git_merge)
  • ADR: nenhum — extensão de um caminho de leitura já existente, sem mudar o pipeline de decisão
  • Origem: pedido do dono do produto — "não há maneiras de gerir PRs"