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.mdusa 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) etest/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
cutFilesno relatório de indexação (POST .../rag/reindex), com{ path, bytes, reason }:bytesé o tamanho do arquivo no repositório ereasonétoo_large(lido só até o teto) oubinary(pulado, sem chunk); - no
Logger.warnda api, com o caminho e os bytes; - no metadado dos chunks de um arquivo cortado (
metadata.sourceCut: true); - na cobertura (RN-237), em
filesCutde 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 emcutFilescomsourceCut;.mdcom\0pulado 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 emcreateMany),test/infrastructure/git/github-provider.contract.spec.ts(arquivo acima de 1 MB emraw). - 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 modolocalnã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 emprovisioning, 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/containerIdda 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 deSocialLoginCallbackUseCase - 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, blococatch) - 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 demedir-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:
- Funil de produto completo (ideação → commit).
sessionsnão temstoryId— RN-230 já declara a lacuna na aba Criativo. Fechá-la exige schema novo, fora do escopo desta frente (nenhuma migration). - 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.
- MTTR e change failure rate. Exigem sinal de INCIDENTE de produção
real — a mesma dependência que
packages/contracts/fluxo.ymljá registra parasecops-runtime/platform(status: proposto/planned, ativação sincronizada comDEPLOY_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):
- Sem plano ainda — dispara
Engine.Gates.Dispatcher.run_qa_estrategia/3(mesma indireção trocável em teste querun_qa/2/run_secops/2já 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 umrun/2síncrono não pode bloquear esperando o resultado sem acoplar os dois processos. - 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 deStoryRepositoryno 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 umaready/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, chamaresolverConfigSmtp()no construtor),src/application/use-cases/auth/auth-use-cases.module.ts(useFactorydoMailSender) - Teste:
test/infrastructure/mail/smtp-config.spec.ts(mesmo padrão deauth-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étodoprovisionarContaNova) - Teste:
test/domain/auth/personal-workspace.spec.ts(nome com/semnome, fallback para o local-part do e-mail, acento/maiúscula virando kebab-case ASCII, fallbackworkspace-<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 comoowner, 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):
src/main.tsnunca 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-turnreenvia o histórico INTEIRO da conversa a cada iteração doToolLoop, e 3-4 tool results de 32 KiB (teto individual da RN-150) já somavam mais que 100 KB.API_JSON_BODY_LIMIT(default10mb) fecha essa ponta.Engine.Harness.ContextManager.Defaulttinha dois defeitos que deixavam a compactação inalcançável antes do corpo estourar:estimate/1contava sócontent, então mensagens deassistantcomtoolCallspesados 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 (threshold0.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 doToolLoop(group_by_iteration/1) — nunca separando uma mensagemassistantcomtoolCallsdosrole: "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;:pinnedsobrevive; teto de transporte força compactação mesmo com janela de modelo grande);get-session-pending-work.use-case.spec.ts(último eventodev.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;GraphUnavailableErrordeixa 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 emappend-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 pushcom auto mode"*"E comallowcasando nopermissions.jsonresolvem prarequire_approval;sudoidem; 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(blocorunner),test/interfaces/http/iam/project-dto-modo-de-workspace.spec.ts,apps/web/src/lib/wizard.test.ts(canAdvanceFromWorkspacepararunner) - 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 tentarverify()),test/interfaces/http/runner/runner-tickets.controller.spec.ts(runnerTickettem o decorator,terminalTicketnã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; "tocalast_used_atsempre 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ó paraowner/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 comGitMerge()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 degit_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"