API — parte 3 (desde RN-100)
Painel de projetos
RN-100 — A ordem do sino é do SQL, não do front
A gaveta de notificações mostra os eventos do mais recente para o mais antigo, e quando um projeto tem mais não lidos que o teto de 50 os que aparecem são os mais NOVOS.
A ordenação é do banco por necessidade, não por gosto. A consulta corta em
50 por projeto com uma função de janela
(row_number() OVER (PARTITION BY session_id ORDER BY seq DESC)), e é ela que
decide quais 50 eventos sobrevivem — não só em que ordem eles saem. Com
ASC sobreviviam os 50 mais antigos, e um .sort() no cliente ordenaria
por recência justamente a janela que a consulta já tinha escolhido errado:
num projeto com 300 não lidos, o 251º evento apareceria como "o mais recente".
É por isso que não há ordenação nenhuma no caminho do front, e o comentário no
componente diz que não pode haver.
A consequência, e por que o corte de leitura não mudou. O corte é um seq
por projeto no localStorage, e não existe endpoint de marcar lido, por
decisão registrada na RN-091. Um corte por seq marca um
prefixo; a gaveta agora mostra um sufixo. Marcar como lidas "as 50
exibidas" é inexprimível sem um conjunto de lidos por evento — tabela nova,
fora de escopo. Os dois únicos cortes expressáveis continuam sendo "nada" e
"tudo até agora".
A saída não foi mudar a semântica (ela continua avançando para o último seq,
exatamente como antes), foi parar de esconder o que esse avanço engole:
- cada projeto mostra o total de não lidos, não o tamanho da janela, e
declara quantos ficaram de fora (
+ N mais antigos); - o botão passa a dizer o que faz —
marcar as N como lidas— quando há algo fora da janela; - o número que falta sai de subtração, não de requisição:
latestSeqmenos o corte já vem no resumo do workspace (RN-090).
Como a semântica do corte não mudou, não há ADR nesta regra: o que mudou foi qual janela a consulta escolhe e o que a gaveta declara sobre ela.
- Onde:
src/infrastructure/persistence/drizzle/projects-summary.repository.ts(unreadEventsForWorkspace),src/application/ports/projects-summary-repository.port.ts,src/interfaces/http/iam/dto/iam.response.dto.ts(ProjectUnreadEventsResponseDto),apps/web/src/lib/notifications.ts(useNotificationGroups),apps/web/src/components/NotificationBell.tsx - Teste:
test/infrastructure/persistence/drizzle/projects-summary.repository.spec.ts("com mais não lidos que o teto, voltam os MAIS RECENTES, do novo para o velho" — afirma o CONJUNTO antes da ordem);apps/web/src/components/NotificationBell.test.tsx(renderiza na ordem recebida, sem reordenar; o contador é o total; o botão diz quantas marca);apps/web/src/lib/notifications.test.tsx(o que ficou fora da janela sai por subtração, sem segunda requisição);apps/web/src/routes/Dashboard.fanout.test.tsx(a gaveta continua sendo UMA requisição) - Origem: pedido do usuário na primeira navegação real — "Sino e notificações devem sempre ordenar para última data modificada em descendente para a mais antiga"
RN-101 — O mesmo gasto, duas audiências: a fatura é do owner, o consumo é de quem gastou
O produto responde duas perguntas diferentes sobre token_usage, e nenhuma
é recorte da outra.
A do owner é por CREDENCIAL. GET /workspaces/:id/credential-spend continua
como a RN-060 o deixou — por provider, exigindo owner, respondendo
"quanto saiu da minha chave". Junto dele, GET /workspaces/:id/spend-report
(também owner) quebra o workspace por modelo, provider, projeto, ator e
dia — o eixo de provider entrou pela RN-186. O owner vê os dois
porque é a única pessoa que pode ver os dois.
A do membro é por ATOR. GET /projects/:id/spend/me (papel viewer)
devolve, em tokens e custo estimado, o que quem chamou consumiu naquele
projeto, por sessão e por dia. Ela não quebra por provider nem por
credencial — a chave que rodou é a do owner (RN-058), e uma fatia
da fatura dele não é o que o membro está perguntando.
O ator não é parâmetro: sai do usuário autenticado, e não existe onde escrever o id de outra pessoa. "Membro não vê linha de outro ator" é propriedade da assinatura do caso de uso, não uma checagem que alguém pode esquecer de chamar.
Agente não entra na conta do membro. token_usage registra quem GASTOU, não
quem mandou gastar; atribuir o agente a quem o iniciou seria inventar um dado
que a tabela não tem. Gasto de agente aparece no relatório do owner, de quem é a
chave.
O eixo de provider existe, e o membro não o alcança. Até o
ADR 0076 ele simplesmente
não existia na agregação, e era a ausência que continha a visão do membro. Hoje
sumGroupedBy tem seis dimensões (model, provider, project, actor,
session, day) e a contenção mudou de forma, não de força: quem contém é o
TIPO — ver RN-186 e RN-187. O que não mudou é que dois
providers servindo o mesmo nome de modelo continuam caindo numa linha só na
dimensão model.
- Onde:
src/application/use-cases/llm/get-my-spend.use-case.ts,src/application/use-cases/llm/get-workspace-spend-report.use-case.ts,src/application/ports/token-usage-repository.port.ts,src/interfaces/http/llm/spend.controller.ts,apps/web/src/routes/ProjectSpendTab.tsx - Teste:
test/application/use-cases/llm/spend-audiencias.use-case.spec.ts(o membro não enxerga linha de outro ator, nem de agente, nem do owner; o filtro é pelo par(kind, id); a resposta não carrega provider);apps/web/src/routes/ProjectSpendTab.test.tsx - Origem: ADR 0063 (FASE 22), revisto pelo ADR 0076
RN-102 — O modelo da área é padrão herdável; divergir é decisão do agente, e voltar a herdar apaga a decisão
A cascata de binding ganhou um nível: sessão > agente > **área** > projeto > workspace. area fica ENTRE agente e projeto — é o PADRÃO que o lead e os
subagentes de uma área compartilham (qa/qa-automacao/
qa-performance-seguranca, infra/infra-workflows, dev/dev-<módulo>), e
o binding do próprio agente é a DIVERGÊNCIA que o sobrepõe. Se a área viesse
ACIMA do agente ela venceria sempre, e "padrão herdável" seria, na prática,
"padrão imposto" — nenhum agente conseguiria escolher outro modelo.
O nível novo entra na MESMA revalidação de capability da
RN-041/RN-043: modelo da área que sumiu do provider ou
que não faz tool calling é PULADO e registrado em skipped, exatamente como
agent já era. assertModelFitsBindingScope (RN-040) passou a exigir
supports_tool_calling também no escopo area — ela nunca é lida por chat
humano, só por agente, então deixá-la passar adiaria a mesma falha silenciosa
em um nível.
"Voltar a herdar" apaga o binding, nunca copia o modelo do nível de baixo
para o de cima. Gravar no agente o modelo que a área decidiu pareceria igual
na tela e não é: viraria uma CÓPIA, e a próxima mudança da área deixaria esse
agente para trás sem ninguém notar. Herdar é a AUSÊNCIA de decisão própria, e
desfazer uma divergência é remover a linha — DELETE /projects/:id/agent-bindings/:slug e DELETE /projects/:id/area-bindings/:key, ambos 204, ambos 404 quando o escopo já
herda (idempotência que MENTIRIA se fosse 204 silencioso: apagar o que não
existia e apagar de verdade são respostas diferentes para a mesma tela).
Mudar o modelo da ÁREA exige maintainer, e não developer como o do agente
individual — pelo mesmo motivo do teto de paralelismo
(RN-083): o binding da área alcança o lead e TODOS os subagentes de
uma vez, e escolher modelo é decidir quanto o produto gasta sem perguntar.
A TELA passou a honrar os DOIS mínimos, e são dois. Até aqui
ModelsSection — a tabela de modelo por agente — não checava papel nenhum: o
ModelPicker da coluna MODELO VIGENTE e o "voltar a herdar" da coluna ORIGEM
apareciam clicáveis para todo mundo, viewer incluído, e a api recusava com
403. A saída NÃO foi copiar o gate da seção irmã (owner || maintainer): isso
trocaria o defeito pelo INVERSO, trancando um developer fora de uma ação que
PUT/DELETE .../agent-bindings/:slug aceita — e o inverso é pior, porque
oferecer o que será recusado ao menos termina num toast, enquanto perder
capacidade é invisível para quem a perdeu. O mínimo é do ENDPOINT, não da
seção, e as duas seções são vizinhas na MESMA tela com mínimos diferentes. A
comparação saiu das telas e virou roleAtLeast (apps/web/src/lib/roles.ts),
sobre o ROLE_ORDER que já existia — mesmo nome da função do backend, porque é
a mesma regra dos dois lados do fio; escrever a hierarquia à mão
(role === 'owner' || role === 'maintainer') acerta por acidente enquanto o
mínimo é alto e erra calada quando é baixo. Papel AUSENTE (consulta em voo, ou
que falhou) não alcança nada: errar para o lado de desabilitar se conserta
recarregando.
Some o CONTROLE, nunca a INFORMAÇÃO (ADR 0064). Quem não pode editar
continua vendo o modelo vigente no gatilho e a cadeia de origem inteira
(RN-470), e o "voltar a herdar" continua na linha, inerte — ele é o
que DIZ que aquele agente divergiu, e escondê-lo apagaria um estado que dá para
ler sem poder mudar. O motivo de estarem apagados é dito UMA vez, em texto, na
legenda da seção, e não por title em cada linha: tooltip em elemento
disabled não abre no Chromium (o navegador não despacha evento de mouse em
controle desabilitado), e uma explicação que não aparece é a mesma ausência com
mais código.
Nada disso é fronteira de segurança, e o limite é declarado. Quem recusa
continua sendo o RolesGuard. O papel que a tela lê é o do WORKSPACE
(useCurrentWorkspaceWithRole) e quem autoriza do outro lado é o papel EFETIVO
do PROJETO (ResolveEffectiveRoleUseCase.forProject), que uma linha de
project_members SOBREPÕE nos dois sentidos — então quem tem papel próprio no
projeto pode ver aqui um controle desabilitado que a api aceitaria. Fechar isso
exigiria uma consulta de papel POR PROJETO que o web ainda não tem, e é a mesma
lacuna que a caixa da área (RN-912) também carrega; inventar uma segunda fonte de papel
só numa seção seria pior que a lacuna.
Nota aditiva (RN-471): a lacuna do parágrafo acima é UMA, não uma por seção, e onde a tela JÁ faz a consulta de papel por projeto ela se fecha em vez de se declarar. A seção de Membros busca
listProjectMembers, que éfindMemberRolepara todo mundo de uma vez, e compõeprojectRole ?? workspaceRole— exatamente o queforProjectfaz. Isso não é a segunda fonte de papel que este parágrafo recusa: é a MESMA composição, a partir de dados que aquela seção já tem em mãos. As duas seções de modelo continuam com a aproximação por não terem esses dados. Cuidado ao ler "SOBREPÕE nos dois sentidos" acima: é literal, e três descrições de OpenAPI afirmam o contrário — ver a RN-471.
- Onde:
src/domain/llm/binding-resolver.ts(precedência),src/domain/llm/model-capabilities.ts(capability dearea),src/application/use-cases/llm/resolve-model-binding.use-case.ts(a área do agente sai do catálogoagent-areas.ts, sem round-trip ao banco),src/application/use-cases/llm/clear-model-binding.use-case.ts,src/interfaces/http/llm/model-bindings.controller.ts(area-bindings,DELETEemagent-bindingsearea-bindings),apps/web/src/routes/settings/ModelsSection.tsx(coluna Origem com "voltar a herdar"; desde a RN-912 o apagar da área é desmarcar a caixa do lead, e o gate demaintainersegue sobreroleAtLeastsem mudar de mínimo),src/interfaces/http/llm/model-bindings.controller.ts:273e:301(developernos dois endpoints de agente — estas linhas NÃO mudaram),apps/web/src/lib/roles.ts:49(roleAtLeast— a comparação que faltava),apps/web/src/routes/settings/ModelsSection.tsx:93(podeEditar, e por quedevelopere nãomaintainer),:379(o picker desabilitado),:446(o botão desabilitado, e por que o motivo não vai emtitle),:546(a legenda que diz o motivo) - Teste:
test/domain/llm/binding-resolver.spec.ts,test/domain/llm/model-capabilities.spec.ts,test/application/use-cases/llm/resolve-model-binding.use-case.spec.ts,test/application/use-cases/llm/clear-model-binding.use-case.spec.ts,apps/web/src/routes/ProjectSettingsTab.test.tsx,apps/web/src/routes/settings/papel-na-tabela-de-agentes.test.tsx(viewernão edita e o clique não chega na api;developeredita e o PUT/DELETE chega; papel ausente é inerte; a informação da linha sobrevive ao controle apagado; e o caso que impede "uniformizar" as duas seções — o mesmodevelopervê a tabela de agentes habilitada e a de áreas desabilitada, ao mesmo tempo) - Origem: ADR 0064 (FASE 23)
RN-103 — O binding de agente é POR PROJETO, não mais global
Até a FASE 23, scope = 'agent' guardava um SLUG global
(scope_id = 'qa'), e PUT /projects/:id/agent-bindings/:slug recebia
:projectId na rota e o DESCARTAVA de propósito — escolher o modelo do
Arquiteto na tela de um projeto mudava o modelo dele em TODOS os projetos.
Isso deixou de se sustentar quando a área virou padrão herdável (RN-102): a
área é por projeto, e um binding de agente global ACIMA de um padrão por
projeto faria o mesmo agente resolver modelos diferentes só onde existisse
área — e faria "voltar a herdar" apagar uma decisão que alcançava projetos
que ninguém está olhando.
A saída foi tornar agent por projeto também, e não rebaixar a área para
abaixo do agente: scope_id de agent e de area virou COMPOSTO —
<projectId>:<slug do agente|chave da área> — em vez de inventar uma tabela
nova só para guardar um projeto por binding. UUID de projeto e slug de agente
nunca contêm :, o que torna o primeiro : um separador não ambíguo; a
leitura corta nele, e não em todos, para um slug com : (nenhum existe hoje,
mas nada impede) não virar três pedaços.
scope_id sem o projeto (o formato antigo) é RECUSADO na escrita, não aceito
e ignorado: gravá-lo criaria um binding que a cascata nunca mais encontraria
— invisível, e não um erro. A migração 0040 espalha cada binding de agente
global existente para uma linha por projeto (preservando o que cada projeto
resolvia antes da mudança) e apaga o formato antigo; é ESPALHAR e não
apagar porque a linha global nunca guardou informação de a quem "pertencia" —
inventar um projeto dono seria inventar dado que não existia.
- Onde:
src/domain/llm/binding-scope-id.ts(formato e validação),src/application/use-cases/llm/set-model-binding.use-case.ts(workspaceDoEscopopassou a derivar o workspace deagent/areatambém — a curadoria da RN-043 não era verificável neles antes),src/db/migrations/0040_tearful_night_nurse.sql - Teste:
test/domain/llm/binding-scope-id.spec.ts,test/application/use-cases/llm/set-model-binding.use-case.spec.ts,test/application/use-cases/llm/resolve-model-binding.use-case.spec.ts("o binding de agente é POR PROJETO: o vizinho não o enxerga") - Origem: ADR 0064 (FASE 23)
RN-105 — Sem imagem decidida pelo Arquiteto, o container não sobe e o Code não abre
A aba Code (GET /projects/:id/code/*, ADR 0060)
responde 409 enquanto o projeto estiver em sem_decisao — o estado inicial
de todo projeto. sem_decisao vira decidido só quando o Arquiteto emite
artifact.project_image pela ferramenta choose_project_image, com imagem OCI
de tag explícita (latest recusado), rationale e postura de rede.
A checagem mora no MESMO funil que a contenção de caminho da
RN-095 (ReadProjectCodeUseCase.alvo), e não em cada uma das sete
rotas (árvore, arquivo, busca, diff de PR, blame, lista de
PRs e branches detalhadas, FASE 26b) — checagem
duplicada em sete chamadores é checagem que um dia diverge em um deles
(ADR 0058).
Contagem corrigida aqui: este registro dizia "quatro rotas" desde a FASE 26,
e ficou desatualizado quando a FASE 26b acrescentou as três últimas ao mesmo
funil sem que ninguém revisasse este número.
A fila de PRs prontas do chat e o aviso de PR pronta NÃO passam por este portão (AT-843, decisão do dono de 08/11/2026): eles leem GET /projects/:projectId/git/open-pull-requests, que lista só as PRs abertas, sem conteúdo de arquivo, pelo mesmo funil alvo com o portão desligado. As sete rotas /code/*, inclusive a lista da aba PRs, seguem respondendo 409 em sem_decisao.
O artefato não tem tabela: é o próprio evento no event log, versionado
(version cresce a cada emissão, o vigente é o de maior version), do mesmo
jeito que artifact.module_map. Revisar a imagem é emitir uma versão nova,
nunca sobrescrever a anterior.
- Onde:
src/domain/containers/project-container.ts,src/application/use-cases/containers/decidir-imagem-do-projeto.use-case.ts,src/application/use-cases/containers/obter-container-do-projeto.use-case.ts,src/application/use-cases/git/read-project-code.use-case.ts(métodoportaoDoContainer),apps/engine/lib/engine/harness/tools/choose_project_image.ex - Teste:
test/domain/containers/project-container.spec.ts,test/application/use-cases/containers/container-do-projeto.use-case.spec.ts,test/application/use-cases/git/read-project-code.use-case.spec.ts(bloco "o portão do container") - Borda: 409 e não 403 — nada está errado com quem pediu nem com a permissão dele; o recurso ainda não existe NESTE ESTADO. E não é 404: a aba existe, só não está liberada. A mensagem diz o que falta, para a tela mostrar o motivo em vez de um erro mudo (RN-088).
- Origem: ADR 0065
RN-106 — git push, PR e deploy não saem pelo terminal — mesmo dentro do escopo do projeto
REVISADA pela RN-418 (ADR 0102, decisão GLOBAL do dono do
produto): o deny que este registro descreve virou TETO ABSOLUTO
(require_approval incondicional) — o resto desta entrada é histórico,
fiel ao que valia até a revisão.
Dentro do container do projeto o agente é livre (ADR 0065): o allowlist de verbos do ADR 0055 não converge (achados Z e AD — verbo, forma e invocação são espaços distintos), e a saída é a parede, não uma lista mais longa. Mas três efeitos atravessam a parede e chegam no mundo, e a decisão do usuário foi textual: "agente livre para o que quiser desde que não seja comandos de git ligado ao deploy e ao PR — estas ações ainda devem ser humanas".
decide() reconhece git push, git remote add/set-url, git merge, os CLIs
de provider (gh pr create, gh pr merge, glab mr create/merge, releases e
workflow dispatch) e os comandos de deploy comuns (kubectl apply, helm upgrade, terraform apply, docker push, npm publish, ...) por PREFIXO de
tokens, ignorando flags globais no meio (git -C /tmp push casa). Qualquer
segmento do comando composto que case é deny — não require_approval:
"sempre permitir" grava o padrão em allow, e um clique bastaria para a
segunda porta ficar aberta para sempre. deny vence allow em qualquer
estágio, e é aplicado ANTES de qualquer estágio permissivo em decide().
Negar não tira poder do agente: a mensagem redireciona para a ação TIPADA
(git_push, git_merge, pr_open) — que nasce proposed_action, tem papel
mínimo próprio e registra no event log o que foi empurrado e para onde. É o
caminho que o dev agent já usa (agent_io.ex propõe git_push); o que muda é
que agora está garantido por deny, não só combinado por convenção.
- Onde:
src/domain/actions/external-effect.ts,src/domain/actions/decide.ts(bloco "FRONTEIRA DO CONTAINER") - Teste:
test/domain/actions/external-effect.spec.ts,test/domain/actions/decide.spec.ts(describe "a fronteira do container") - Borda: a fronteira NÃO se sobrepõe à trava de merge em branch protegida (RN-014, sempre manual) nem ao escopo de caminho (RN-095/ADR 0055) — as três regras coexistem, cada uma vetando por um motivo diferente.
- Origem: ADR 0065
RN-109 — O nome de pasta do workspace é congelado na criação, e projeto antigo mantém o UUID
A pasta física de um projeto em PROJECT_WORKSPACES_ROOT era o UUID puro —
ilegível ao abrir no disco. projects.workspace_dir_name (NOT NULL, UNIQUE)
passou a guardar o nome de verdade: <slug>-<8 chars do id> para projeto
NOVO (workspaceDirNameFor em
src/infrastructure/filesystem/project-workspaces-root.ts), gerado
em código — o id nasce de crypto.randomUUID() no
CreateProjectUseCase, não do defaultRandom() do Postgres, porque o nome
da pasta precisa do id ANTES do insert. Os 8 caracteres seguem a mesma
convenção do rótulo de sessão (apps/web/src/lib/session-label.ts).
O nome é CONGELADO no momento da criação e nunca recalculado: UpdateProjectUseCase
permite editar o slug depois, e isso NÃO toca workspace_dir_name — reservar a
pasta física, com working tree e worktrees de agente possivelmente abertos, é
risco real que a decisão evita por construção, não por disciplina de quem
chama.
Projeto criado ANTES desta migração (0042) manteve a pasta física que já
tinha: o backfill grava workspace_dir_name = id para toda linha existente —
o mesmo valor que já era verdade no disco — e NUNCA renomeia diretório
nenhum. Um trigger BEFORE INSERT (projects_workspace_dir_name_default_trg)
aplica o MESMO fallback (id::text) para qualquer insert que chegue sem o
campo — rede de segurança para quem esquecer de gravá-lo (nunca o caminho
principal, que sempre grava explícito), e o que mantém as dezenas de
fixtures de teste existentes, que não conhecem este conceito, funcionando
sem precisar reescrever cada uma.
A derivação de caminho a partir do nome (projectScopeRoot, RN-092/RN-075)
passou a receber workspace_dir_name em vez do projectId cru — mesma
validação de charset, mesma pureza. O engine lê a MESMA coluna
(Engine.Projects.Project.workspace_dir_name/1) para resolver
Engine.Actions.Workspace.workspace_dir/1, nunca recomputando o nome a
partir do id: as duas derivações (api e engine) são, na prática, a mesma
leitura contra o mesmo banco — é o que garante que RN-075 (escopo de
terminal) e RN-092 (leitura de código) continuam apontando para a MESMA
pasta que o engine realmente usa.
- Onde:
src/db/schema/iam.ts(projects.workspaceDirName),src/db/migrations/0042_tough_captain_midlands.sql,src/infrastructure/filesystem/project-workspaces-root.ts(workspaceDirNameFor,projectScopeRoot),src/application/use-cases/iam/create-project.use-case.ts,apps/engine/lib/engine/projects/project.ex(workspace_dir_name/1,all_workspace_dirs/0),apps/engine/lib/engine/actions/workspace.ex(workspace_dir/1,2),apps/engine/lib/engine/dev/worktree_cleanup.ex - Teste:
test/db/workspace-dir-name-migration.spec.ts(trigger, backfill equivalente, unicidade),test/application/use-cases/iam/create-project-semeia-areas.spec.ts(workspaceDirNamenasce<slug>-<8 chars>, dois projetos com o mesmo slug em workspaces diferentes não colidem de pasta),test/infrastructure/filesystem/project-workspaces-root.spec.ts - Borda: o teto de paralelismo e o gate de merge não mudam — RN-109 é só
NOME de pasta, nunca política. Renomear o slug depois da criação não
renomeia a pasta; a pasta só se lê pelo
workspace_dir_namegravado. - Origem: ADR 0066 (revisa o ADR 0055).
RN-114 — Os quatro segredos irmãos do GIT_OAUTH_STATE_SECRET também não sobem em produção com o valor de exemplo
O ADR 0059 fechou o
padrão para GIT_OAUTH_STATE_SECRET e deixou declaradamente aberto que o
mesmo modo de falha valia para quatro segredos irmãos, todos com default de
desenvolvimento no docker-compose.prod.yml: AUTH_JWT_SECRET,
BRABO_SERVICE_TOKEN, CREDENTIALS_MASTER_KEY e SECRET_KEY_BASE. Esta RN
fecha os quatro, replicando exatamente a mesma regra — não é decisão nova,
é a mesma decisão aplicada aos irmãos.
Em produção (NODE_ENV === 'production'), cada resolutor derruba o boot
quando a variável está ausente/só com espaços, é igual ao literal de exemplo
do repositório (que é público — está no .env.example), ou tem menos de 16
caracteres. Fora de produção o default de desenvolvimento continua valendo,
porque docker compose up sem .env tem que funcionar.
AUTH_JWT_SECRET— deriva o par Ed25519 que assina o access token. Com o default público, qualquer um forja um access token válido.BRABO_SERVICE_TOKEN— autentica o tráfego interno api ↔ engine. Com o default público, qualquer um chama as rotas/internal/*sem passar peloEngineServiceGuard.CREDENTIALS_MASTER_KEY— embrulha os DEKs que cifram as credenciais do usuário (chaves de LLM, tokens de git). Com o default público, qualquer um decripta o acervo. Fora de escopo aqui: qualquer mecanismo de rotação — esse já existe (CREDENTIALS_MASTER_KEY_PREVIOUS+src/scripts/rewrap-deks.ts) e não muda; esta é só a checagem de BOOT.SECRET_KEY_BASE(engine) — já derrubava o boot sem a variável (runtime.exs, bloco:prod, boilerplate padrão do Phoenix). O defeito real não era falta de checagem no Elixir: era odocker-compose.prod.ymlsuprir o literal público como fallback, o que fazia a variável chegar sempre DEFINIDA e mascarava oraiseque já existia. A correção aqui foi só remover o fallback do compose — nenhuma linha de Elixir mudou.
Vale a mesma razão do ADR 0059 para rejeitar o literal, e não só o vazio: o
docker-compose.prod.yml supria os quatro literais como fallback, então no
caminho real de erro as variáveis estavam definidas — uma verificação de
"não vazia" passaria por cima do defeito inteiro.
- Onde:
src/infrastructure/security/auth-key-material.ts(passphraseAtual),src/infrastructure/security/service-token.ts(tokenDeServicoAtual) esrc/infrastructure/security/envelope-encryption.service.ts(EnvelopeEncryptionService, checagem no construtor) — os dois primeiros chamados no boot emsrc/main.ts, o terceiro exercitado quando oNestFactory.createmonta o grafo de providers.SECRET_KEY_BASEemapps/engine/config/runtime.exs(inalterado) com o fallback removido dedocker/docker-compose.prod.yml - Teste:
test/infrastructure/security/auth-key-material.spec.ts,test/infrastructure/security/service-token.spec.tse o describevalidação de produçãoemtest/infrastructure/security/envelope-encryption.service.spec.ts - Origem: ADR 0059
RN-127 — ref/path da aba Code recusam chegar como ARRAY, não só como caminho fora do escopo
@Query('ref') ref?: string e @Query('path') path?: string
(code.controller.ts) extraem o valor cru sem DTO/class-validator no
meio, e o ValidationPipe global (main.ts) não ajuda: ele pula tipo
primitivo nativo (String) por desenho do Nest, então nada intercepta
ref/path antes de chegarem como argumento de método. O Express entrega
?ref=a&ref=b como array, não string — a anotação string do
TypeScript só existe em compile-time.
Um array escapava das DUAS checagens que a RN-095 já fazia
tratando o valor como string: ref.includes('..') tem semântica de
ELEMENTO EXATO (não substring) em array, e REF_VALIDO.test(ref) chama
.toString() no array antes de casar — um valor como ['x/../y']
continha .. e ainda assim passaria pelas duas.
garantirQueryEscalar(valor, criarErro) recusa o array ANTES de qualquer
outra checagem, num lugar só, reusado pelos DOIS pontos que tratavam query
como string: caminhoDeRepositorioContido (mesmo arquivo da RN-092/095) e
ReadProjectCodeUseCase.alvo (ref). O erro concreto (CaminhoForaDoEscopoError
ou BadRequestException) é decidido por quem chama, passado como fábrica —
a função central não decide o tipo de erro, só a forma da checagem.
O caminho feliz não muda: todo ref/path legítimo já era string.
- Onde:
src/infrastructure/filesystem/project-workspaces-root.ts(garantirQueryEscalar, usada emcaminhoDeRepositorioContido),src/application/use-cases/git/read-project-code.use-case.ts(alvo,ref) - Teste:
test/infrastructure/filesystem/project-workspaces-root.spec.ts(garantirQueryEscalarisolada ecaminhoDeRepositorioContidorecusando array),test/application/use-cases/git/read-project-code.use-case.spec.ts(ref/pathcomo array são 400 emtree, que todas as outras rotas reusam viaalvo) - Origem: alerta CRÍTICO do CodeQL (confusão de tipo em query param HTTP) bloqueando a promoção qa→main, achado durante a PR #256; estende a RN-095
RN-128 — sessionId/projectId/agent/agentId são validados ANTES de virar segmento de URL da requisição interna ao engine
HttpApiToEngineClient interpola estes valores em template string pra
montar a URL de /internal/* — sem DTO/class-validator no meio, igual
à RN-127: eles chegam de @Param/lookup de sessão sem pipe de
validação em algum ponto da cadeia, e nada garante a forma deles antes da
interpolação. Um valor malicioso poderia injetar segmento de path extra
ou caracteres que quebram a URL montada — o EngineServiceGuard autentica
o CHAMADOR (RN-035), não CONFERE o que o chamador manda na URL.
garantirSegmentoDeUrlInterna reusa a mesma largura de
NOME_DE_PASTA_VALIDO (RN-092/109) — hex, hífen e sublinhado, 1 a 64
chars — e é chamada em DOIS lugares, cobrindo TODOS os métodos que
interpolam id em URL, não só os que o CodeQL reportou:
- dentro de
postCommand, que a maioria dos métodos já usa (startAgent,sendAgentMessage,confirmReadiness,cancelAgentTurn,offerInfraHandoff,invalidateInstructions,startExecution,acceptParallelization,rearmDevAgent,reviseStory) — o chamador lista as tuplas(nome, valor)que já interpolou nopath, epostCommandvalida TODAS antes de montar a requisição; - direto em
reanalyzeSession/runAnamnese, que não passam porpostCommand(precisam distinguir 503 de falha de transporte) e por isso eram os dois únicos pontos que o CodeQL alcançou.
O caminho feliz não muda: sessionId/projectId são sempre UUID vindo do
banco, e agent/agentId são sempre slug curto.
- Onde:
src/infrastructure/http-clients/api-to-engine-client.ts(garantirSegmentoDeUrlInterna,postCommand) - Teste:
test/infrastructure/http-clients/api-to-engine-client.spec.ts(id malformado é recusado ANTES de tocar a rede — provado apontandoENGINE_URLpra uma porta que nada escuta — e o caminho feliz chega a fazer a requisição) - Origem: alerta CRÍTICO do CodeQL (URL de requisição interna montada com valor não validado) bloqueando a promoção qa→main, achado durante a PR #256; mesmo padrão do RN-092
RN-135 — Ativar a execução fecha a sessão de CHAT que originou o pedido
ActivateExecutionUseCase sempre resolvia a sessão de EXECUÇÃO
(findActiveExecutionSession, ou criava uma criativa nova), mas nunca
transicionava a sessão de CHAT de onde veio o clique em "ativar execução" — a
conversa com o Dev Lead/PO, numa sessão separada. Ela ficava active para
sempre, mesmo com a execução já rodando sozinha em outra sessão, e continuava
aparecendo como conversa aberta na lista.
execute() ganhou originSessionId, opcional e por último — um chamador
antigo (hoje só a ativação pela Visão Geral, sem contexto de sessão) continua
funcionando de forma IDÊNTICA, sem fechar nada. Quando passado, no FIM do
método (depois de tudo o mais ter acontecido: module_map, áreas, autonomia,
startExecution, execution.activated):
- nunca fecha a própria sessão de execução — se
originSessionIdfor igual à sessão que acabou de receberexecution.activated, o fechamento é pulado, porque fechá-la destruiria o processo que os dev agents acabaram de ganhar; - só fecha o que está
active— a mesma cautela dedecide-bootstrap-plan.use-case.ts#fecharSessao, nada a fazer se a sessão não existe mais ou já não está aberta; - reaproveita o
GetSessionPendingWorkUseCase(RN-073) — a MESMA guarda que segura o fechamento por heartbeat de inatividade: um handoff oferecido, umaproposed_actionpendente ou um agenteworkingsemidleposterior impedem o fechamento; - passa por
closingantes declosed— a máquina de estados (active -> closing -> closed) não permite o salto direto.
Uma falha ou estado pendente aqui NUNCA se propaga a quem chamou execute(): a
ativação da execução já aconteceu e é o efeito principal; fechar o chat de
origem é um efeito colateral best-effort.
- Onde:
src/application/use-cases/execution/activate-execution.use-case.ts(closeOriginSession),src/interfaces/http/execution/dto/activate-execution.dto.ts(originSessionId),src/interfaces/http/execution/execution.controller.ts - Teste:
test/application/use-cases/execution/activate-execution.use-case.spec.ts, describefecha a sessão de origem (RN-135)— fecha sem pendência, NÃO fecha com pendência, NÃO fecha sessão já não-active, chamador antigo sem o parâmetro não fecha nada, e nunca fecha a própria sessão de execução mesmo seoriginSessionIdcoincidir com ela - Origem: achado de investigação de código — sessão criativa com
execução ativada continuava
activena lista mesmo com 35 eventos de dev agents dentro dela
RN-139 — A aba Executores lê a sessão de execução VIGENTE, nunca a mais recente do projeto
ProjectExecutorsTab buscava os eventos de dev agent/QA pela sessão que
useLatestSession devolvia — a de createdAt mais recente do projeto, sem
filtrar por kind nem exigir execution.activated. Funcionava só por
coincidência: a sessão de execução costuma ser a mais nova. Assim que
qualquer sessão nasce depois dela — uma ideação nova, um chat consultivo — a
aba passa a olhar essa sessão nova, vazia de eventos de execução, em
silêncio: nenhuma pista na tela dizia qual sessão estava sendo exibida.
A leitura correta já existia no backend: findActiveExecutionSession
(SessionRepository) — a sessão active mais recente que carrega
execution.activated — mas só era usada internamente por
ActivateExecutionUseCase para decidir se reativa ou cria. A correção expõe
o MESMO critério por HTTP, em vez de duplicá-lo no front:
-
GET /projects/:projectId/execution/session(role:viewer) devolve a sessão vigente ounull— nunca infere pela mais recente; -
ProjectExecutorsTabtrocauseLatestSessionporuseActiveExecutionSession(novo hook sobre a rota acima) como fonte da sessão que a aba inteira lê; -
o cabeçalho da aba sempre mostra QUAL sessão está sendo exibida — o rótulo dela (hashtag + nome) linkando para
SessionPage, ou "Nenhuma execução ativa" quandonull— nunca mais implícito. Os três estados da RN-088 se aplicam à própria busca da sessão: carregando, erro (comtrace_id) e vazio (null) são três renders distintos, nunca umif (!sessão) return nullque os colapsa. -
Onde:
src/application/use-cases/execution/get-active-execution-session.use-case.ts,src/interfaces/http/execution/execution.controller.ts(getSession),apps/web/src/lib/hooks.ts(useActiveExecutionSession),apps/web/src/routes/ProjectExecutorsTab.tsx -
Teste:
apps/web/src/routes/ProjectExecutorsTab.test.tsx— mostra a sessão de execução mesmo com sessão mais recente existindo no projeto, estado "nenhuma execução ativa" explícito, e erro de rede tratado (não em branco) -
Origem: achado de investigação de código + teste ao vivo — a mesma classe de defeito que a RN-088 fechou para 429, agora para "qual sessão a tela está olhando"
RN-143 — Um subcomando de git somente leitura só é permitido ancorado pela flag que torna a leitura inequívoca, nunca pelo verbo solto
Consultando o banco de uma execução real, os dev agents queimaram dezenas de
aprovações manuais em subcomandos de exploração — git branch -a, git remote -v, git worktree list, git show origin/dev --stat, git log --all --oneline --graph, git for-each-ref, git ls-tree -r origin/dev --name-only, git config user.name — nenhum coberto pela RN-068,
que só permitia git status/diff/log (sem flags extras). Como o casamento
por prefixo de token exige que TODO segmento de um comando composto esteja em
allow, uma longa cadeia de exploração caía inteira em require_approval no
instante em que UM desses subcomandos aparecia no meio.
DEV_TERMINAL_ALLOW_PATTERNS ganhou
git branch -a/-r/-v/--list/--show-current, git remote -v/git remote show, git worktree list, git show, git for-each-ref, git ls-tree, git rev-parse e git config --get. O git log não precisou de
padrão novo: o casamento já é por PREFIXO de token (tokens finais extras são
permitidos), então Terminal(git log) já cobria git log --all --graph --oneline --decorate.
A cautela aqui é a mesma que a RN-068 já mostra para ls/lsof, aplicada
a verbos com um irmão MUTANTE que aceita a mesma forma truncada do padrão.
Terminal(git branch) casaria git branch -D nome (apaga) tanto quanto git branch nome-nova (cria) ou git branch sozinho — o padrão não vê o que vem
DEPOIS do prefixo que conferiu. É por isso que nenhum dos quatro verbos com
forma mutante (branch, remote, worktree, config) foi admitido pelo
verbo solto; cada um foi ANCORADO pela flag que torna a leitura inequívoca
independentemente do que vem depois:
-
git branch— ancorado em-a/-r/-v/--list/--show-current, nunca no verbo sozinho;-D/-d/-m/-M(apagar/renomear) e um nome de branch solto (criar) ficam de fora. -
git remote— ancorado em-veshow(que só aceita um nome de remoto depois, sempre uma leitura);add/remove/set-urlficam de fora. -
git worktree— ancorado emlist;add/remove/pruneficam de fora. -
git config— só--getfoi admitido, porque é a única flag que o próprio git garante ser leitura independentemente do que vem depois (uma chave, ou uma chave + padrão de valor).git config user.name/git config user.emailSEM--getforam deixados de fora deliberadamente: um segundo token depois da chave (git config user.name "novo valor") é uma ESCRITA, e o casamento por prefixo não distingue "nenhum token a mais" de "um token a mais" sem inventar um novo parser de contagem de argumentos — a mesma limitação que já bloqueia umgit branchsolto.--global/--systemnunca foram ancorados. -
Onde:
src/domain/actions/dev-terminal-patterns.ts -
Teste:
test/domain/actions/dev-terminal-patterns.spec.ts(describeread-only git subcommands (found live)— cobre a cadeia composta observada ao vivo sendo auto-aprovada, e cada variante mutante com a MESMA palavra de comando —git branch -D,git remote add,git worktree add,git config --global user.name— permanecendo emrequire_approval) -
Origem: consulta ao banco de uma sessão real, achada durante o uso
RN-145 — O Arquiteto também tem um botão de prontidão, e a MESMA confirmação oferece Infra e Dev Lead
OfferInfraHandoffUseCase (POST .../agents/arquiteto/handoff-infra) já
existia desde a Fase 4a — grava architecture.readiness_confirmed e chama o
engine, que oferece o handoff ao Infra e, na MESMA confirmação, ao Dev Lead
(FASE 14d/ADR 0053). O que faltava era o jeito de chegar até ele: nenhum
lugar do frontend chamava o endpoint. O botão "Confirmar arquitetura pronta"
existe pro Criativo desde sempre ("Estou pronto para produzir",
RN-131/RN-142) — o Arquiteto não tinha equivalente
nenhum, e sem o clique o handoff nunca nascia: a correção de prioridade do
card no fio (RN-125) ficava sem efeito prático, porque não havia o
que mostrar.
arquitetoActive espelha criativoActive (existe um agent.activated pro
Arquiteto nesta sessão) e arquiteturaJaDeclarada espelha
prontidaoJaDeclarada (existe QUALQUER handoff saindo do Arquiteto — a prova
de que a confirmação já aconteceu, já que OfferInfraHandoffUseCase cria
pelo menos o de Infra na mesma chamada). O botão aparece no composer só
quando o primeiro é verdadeiro e o segundo não é — some depois do clique
pelo mesmo motivo que o do Criativo some depois da prontidão.
Ao contrário do Criativo, o Arquiteto NÃO tem guardrail de servidor
bloqueando a confirmação sem module_map — ArquitetoServer.offer_infra_handoff
não recusa nada, diferente de CriativoServer.confirm_readiness
(RN-142). O botão só desabilita durante streaming; não replicar
aqui o disabled={!hasModuleMap} da Visão Geral é decisão deliberada, pelo
mesmo raciocínio que já vale para "Ativar execução" no card do Dev Lead
(RN-137) — quando este card existe, o Arquiteto já decidiu a
arquitetura.
- Onde:
src/interfaces/http/agents/agents.controller.ts(handoffInfra, rota preexistente),apps/web/src/lib/api-client.ts(confirmArchitectureReadiness),apps/web/src/routes/SessionPage.tsx(arquitetoActive,arquiteturaJaDeclarada,handleArchitectureReadiness, botão "Confirmar arquitetura pronta") - Teste:
apps/web/src/routes/SessionPage.arquiteto-modelo-icone.test.tsx, describe "problema 1" — botão ausente sem o Arquiteto ativo, caminho feliz chama o endpoint dedicado, falha mostra toast de erro, e o botão some com a arquitetura já declarada - Origem: investigação de código — o endpoint e a lógica do engine existiam desde a Fase 4a/14d sem NENHUM caminho de UI até eles
RN-146 — agent.response carrega o nome do modelo que gerou a resposta
O nome do modelo só existia em token_usage, sem vínculo com o evento
agent.response específico que ele produziu — SessionPage.tsx mostrava a
string FIXA "modelo" ao lado do nome do agente, nunca o nome real.
A mudança atravessa as três camadas, todas com o MESMO nome de campo
(modelName), para que não seja preciso traduzir entre elas:
- api —
StreamLlmTurnUseCase/RunLlmTurnUseCasejá resolviam o modelo (resolveModelBinding→models.findById) para chamar o provider; o framefinal/RunLlmTurnResultganhammodelName: string | null.nullsó quando o turno falhou ANTES de resolver um modelo (sem binding, ou binding para modelo inexistente) — nos demais casos, inclusive orçamento excedido, o binding já tinha resolvido e o nome viaja mesmo no frame de erro. - engine — os quatro agentes conversacionais (
criativo_server.ex,po_server.ex,arquiteto_server.ex,dev_lead_server.ex) extraemMap.get(frame, "modelName")do framefinale o incluem no payload deemit_response/agent.response(%{content: content, modelName: model_name}). - web —
SessionPage.tsxlêevent.payload.modelName. Evento GRAVADO antes desta mudança não tem a chave (undefined), e um turno cuja api não resolveu modelo nenhum gravanull— os dois degradam para o rótulo genérico"modelo", nunca paraundefined/nullna tela; o mesmo padrão quetext === ''já usa para resposta anterior à RN-059.
- Onde:
src/application/use-cases/llm/stream-llm-turn.use-case.ts(LlmTurnStreamEvent),src/application/use-cases/llm/run-llm-turn.use-case.ts(RunLlmTurnResult),src/interfaces/http/internal/dto/internal.response.dto.ts(LlmTurnResponseDto/LlmTurnStreamEventResponseDto),apps/engine/lib/engine/agents/{criativo,po,arquiteto,dev_lead}_server.ex(emit_response/3),apps/web/src/routes/SessionPage.tsx(blocoagent.responseda timeline) - Teste:
test/application/use-cases/llm/run-llm-turn.use-case.spec.ts,test/application/use-cases/llm/stream-llm-turn.use-case.spec.ts(modelNameno caminho feliz, no erro do provider e sem binding),apps/engine/test/engine/agents/{criativo,po,arquiteto,dev_lead}_server_test.exs(agent.responsecarrega o nome do modelo; borda do frame sem a chave),apps/web/src/routes/SessionPage.arquiteto-modelo-icone.test.tsx, describe "problema 2" — nome real, evento antigo sem a chave,modelName: null - Origem: investigação de código — confirmado que o dado já existia em
token_usage, mas nunca chegava ao payload do evento
RN-149 — O Container level do diagrama C4 é derivado do module_map, nunca redigitado pelo modelo
create_c4_diagram (ferramenta nova do Arquiteto) gera as duas sintaxes
Mermaid do diagrama C4 (Context + Container, modelo de Simon Brown). O tool
call carrega só system_name/system_description/actors — os módulos e
as dependências do nível Container NÃO fazem parte da entrada: o caso de uso
busca o module_map VIGENTE do projeto (ModuleMapRepository.findCurrent,
mesma leitura de GetArchitectureUseCase) e deriva o Container level dele,
com os MESMOS nomes e dependências que create_module_map já validou sem
ciclo.
A alternativa óbvia — deixar o modelo descrever os módulos de novo no tool
call do diagrama, como ele já faz para create_module_map — foi descartada
de propósito: um segundo lugar onde o modelo escreve "os módulos são X, Y,
Z" é um segundo lugar onde essa lista pode divergir da primeira, e a
divergência seria SILENCIOSA — nada recusaria um diagrama com um módulo que
não existe mais no mapa real. Derivar do repositório fecha essa divergência
por construção: o diagrama pode ficar DESATUALIZADO se o module_map mudar
depois (reemitir é gerar de novo, sem trava — ver ADR 0068), mas nunca
MENTE sobre o que existia no momento em que foi gerado.
Sem module_map vigente, create_c4_diagram é recusado com 400 — não há
Container level sem módulos para desenhar, e a mensagem de erro instrui o
Arquiteto a chamar create_module_map primeiro (RN-061: a recusa volta
pelo tool-result, com o motivo inteiro).
O artefato artifact.c4_diagram é versionado no event log sem tabela
própria — mesmo desenho de artifact.project_image (ADR 0065): o vigente é
o de maior version, e revisar é gerar de novo, nunca sobrescrever.
- Onde:
src/domain/architecture/c4-diagram.ts(gerarDiagramaContexto/gerarDiagramaContainer, puras),src/application/use-cases/architecture/create-c4-diagram.use-case.ts,src/application/use-cases/architecture/get-c4-diagram.use-case.ts,apps/engine/lib/engine/harness/tools/create_c4_diagram.ex,apps/web/src/components/C4DiagramView.tsx(renderização, três estados — RN-088),apps/web/src/lib/mermaid-render.ts(omermaidfica isolado aqui,import()dinâmico) - Teste:
test/domain/architecture/c4-diagram.spec.ts(sintaxe Mermaid válida a partir de ummodule_mapde exemplo, aresta pendurada ignorada, ids deduplicados),test/application/use-cases/architecture/create-c4-diagram.use-case.spec.ts(sem module_map recusa com 400 e não grava nada; Container reflete os módulos/dependências reais; versiona ao reemitir),apps/engine/test/engine/harness/tools/create_c4_diagram_test.exs,apps/web/src/components/C4DiagramView.test.tsx(sucesso vira SVG, erro de sintaxe vira Alert legível sem quebrar a tela, diagrama vazio não tenta renderizar) - Origem: pedido do usuário — diagrama C4 do Arquiteto na Visão Geral do projeto (ADR 0068)
RN-151 — O badge de projeto na sidebar é aprovações pendentes, não atividade não lida
O número ao lado do nome de cada projeto em Shell.tsx vinha de
useProjectsUnread — latestSeq (o seq mais recente já gravado na sessão)
menos o cursor de "última vez visto" que o navegador guarda em
read-state.ts. Isso conta QUALQUER evento novo — tool.call,
agent.response, chat — não só decisão pendente. Um projeto de teste
mostrava "392" na sidebar (atividade acumulada de uma execução real) enquanto
a aba Aprovações do MESMO projeto mostrava "8" (a contagem de verdade). Um
número que não corresponde a nada acionável ao clicar é pior que nenhum.
O read model do dashboard (ProjectsSummaryRepository.summarizeForWorkspace,
RN-090) ganhou pendingApprovalsCount: COUNT(*) de proposed_actions com
status = 'pending', agregado por project_id numa consulta a mais no
Promise.all já existente — mesmo formato de storiesAwaitingPromotion
(RN-048), sem crescer o número de idas ao banco por projeto. A soma é do
projeto INTEIRO, todas as sessões — de propósito diferente da aba Aprovações
(ProjectApprovalsTab.tsx), que mostra só as pendências da sessão MAIS
RECENTE: o badge é por PROJETO, não por sessão, e uma pendência numa sessão
antiga continua sendo uma pendência.
Shell.tsx parou de importar useProjectsUnread — o único consumidor dele
ali era este badge. Dashboard.tsx/ProjectCard.tsx ganharam o mesmo fio:
o prop unreadCount de ProjectCard nunca tinha chamador (ProjectCardContainer
não o passava), e virou pendingApprovalsCount com o mesmo valor da sidebar
— duas telas, um número, uma fonte.
- Onde:
src/application/ports/projects-summary-repository.port.ts(ProjectCardSummary.pendingApprovalsCount),src/infrastructure/persistence/drizzle/projects-summary.repository.ts(summarizeForWorkspace, consulta agregada sobreproposed_actions),apps/web/src/routes/Shell.tsx,apps/web/src/routes/Dashboard.tsx,apps/web/src/components/ProjectCard.tsx - Teste:
test/infrastructure/persistence/drizzle/projects-summary.repository.spec.ts(pendingApprovalsCount soma o projeto INTEIRO..., sópendingconta, não vaza entre projetos, número de consultas continua constante),apps/web/src/routes/Shell.test.tsx(badge de aprovações pendentes) - Origem: achado do usuário navegando a app — badge da sidebar mostrando "392" contra "8" de verdade na aba Aprovações do mesmo projeto
RN-153 — "Auto mode": o ApprovalCard liga autonomia pra QUALQUER ação futura de um agente
Revista pela RN-603 (ADR 0167):
decide()passou a receber também a ORIGEM da autonomia, e o modo automático (a curinga emauto_approve) não passa mais pelo teto de escopo de caminho. O que segue abaixo sobredecide()"não mudar" descreve o desenho original.
Antes deste RN, agent_autonomy só sabia conceder autonomia por
(projeto, agente, TIPO de ação) — uma linha por tipo, upsert de UMA regra
por vez (SetAgentAutonomyUseCase,
src/application/use-cases/actions/set-agent-autonomy.use-case.ts).
Confiar amplamente num agente exigia uma linha por tipo — terminal,
write_file, pr_open… — e tipo novo nascia sem regra, de volta a
require_approval.
"Auto mode" é o valor especial actionType: "*" na MESMA tabela e no MESMO
endpoint (PUT /projects/:projectId/agent-autonomy) — não é mecanismo novo,
é a coluna existente (agent_autonomy.action_type, text livre, sem enum
nem FK — src/db/schema/agents.ts) aceitando um valor a mais. A curinga
significa "autonomia pra qualquer tipo de ação DESTE agente" e é resolvida
em DrizzleAgentAutonomyRepository.findMode
(src/infrastructure/persistence/drizzle/agent-autonomy.repository.ts):
busca a regra ESPECÍFICA e a curinga na mesma consulta, e a específica
sempre vence — gravar terminal: deny com "*": auto_approve já ligado
continua negando terminal desse agente, e liberando o resto. decide()
(src/domain/actions/decide.ts) não muda: ele recebe o
PermissionPolicy já resolvido em ctx.autonomyMode, exatamente como antes
da curinga existir — é por isso que os tetos absolutos valem sem precisar
saber que "auto mode" existe (ver RN-154).
O ApprovalCard (apps/web/src/components/ApprovalCard.tsx) ganha o botão
"Modo automático" ao lado de "Sempre permitir", visível só quando: (a) a
ação está pending, (b) quem propôs é um AGENTE (actor.kind === 'agent' —
não há autonomia de agente para conceder a um usuário) e (c) quem chama
(ProjectApprovalsTab.tsx/SessionPage.tsx) já confirmou papel
maintainer/owner no workspace — mesma exigência do endpoint
(@RequireRole('maintainer'), inalterado). O prop onActivateAutoMode é
undefined para quem não tem o papel — o card ESCONDE o botão em vez de
mostrá-lo desabilitado, e a checagem mora em quem chama, não no card
(componente presentational, sem query própria).
Desligar reusa o toggle manual/auto que o card do agente já tinha (Fase
8d) — nenhuma tela nova. AgentTeamGrid.tsx passa a procurar a regra
curinga do agente ANTES da representativa (autonomyActionTypeFor): se
existir, o toggle do card reflete e edita a CURINGA, não mais o tipo
representativo — desligar é gravar a mesma curinga como
require_approval, e o toggle no card do agente é exatamente esse
"desligar".
- Onde:
src/domain/actions/decide.ts(AGENT_AUTONOMY_ALL_ACTIONS,AgentAutonomyActionType),src/infrastructure/persistence/drizzle/agent-autonomy.repository.ts(findModecom precedência específica > curinga),src/interfaces/http/actions/dto/set-agent-autonomy.dto.ts(aceita"*"),apps/web/src/components/ApprovalCard.tsx(botão "Modo automático"),apps/web/src/components/AgentTeamGrid.tsx(toggle passa a priorizar a curinga),apps/web/src/routes/ProjectApprovalsTab.tsxeapps/web/src/routes/SessionPage.tsx(handleActivateAutoMode, gate de papel viauseCurrentWorkspaceWithRole) - Teste:
test/infrastructure/persistence/drizzle/agent-autonomy.repository.spec.ts(precedência específica > curinga; curinga é por agente; desligar é regravar a curinga comorequire_approval),test/application/use-cases/actions/propose-action.use-case.spec.ts(auto mode auto-aprova ação comum SEM bater empermissions.json; regra específica emdenyvence a curinga),test/interfaces/http/actions/agent-autonomy.controller.spec.ts(PUT/GETcontinuam exigindomaintainer; DTO aceita"*"e recusa string fora da lista),apps/web/src/components/ApprovalCard.test.tsx(botão some semonActivateAutoMode; clique chama o callback; nota explica os tetos que continuam pedindo decisão) - Origem: pedido do usuário — "Sempre permitir" só grava um padrão de
comando específico, e
agent_autonomysó cobria um tipo de ação por vez; faltava confiar amplamente num agente com um clique só
RN-154 — Os três tetos absolutos continuam bloqueando MESMO com "auto mode" ligado
Revista pela RN-603 (ADR 0167): os tetos listados aqui seguem valendo em modo automático; o que deixou de valer para ele foi o teto de ESCOPO DE CAMINHO (ADR 0055), que nunca esteve nesta lista.
O desenho do "auto mode" (RN-153) é deliberadamente incapaz de
furar os três tetos que já existiam em decide() — eles são aplicados por
ÚLTIMO, sobre current.policy, sem olhar de onde veio a permissividade
(agent_autonomy com tipo específico, curinga, ou permissions.json — a
função nunca soube distinguir as origens, e continua sem saber):
- Merge em branch protegida (
git_mergecom destino emdev/qa/rc/main, RN-006) — a trava de merge (isProtectedBranch) rebaixaauto_approvepararequire_approvalsempre, mesmo com"*": auto_approveligado pro agente. instruction_patch(RN-007) — mudar a instrução de outro agente exige o humano ver o diff; auto mode não muda isso.parallelize/raise_max_parallel(RN-086) — subir o teto de paralelismo, ou pedir mais agente acima dele, continua decisão do usuário; um agente com auto mode ligado não consegue se auto-conceder mais poder de gasto.
A prova é por CONSTRUÇÃO, não por caso a caso: como os três tetos verificam
só current.policy === 'auto_approve' — nunca a origem —, e "auto mode" só
consegue chegar em current.policy === 'auto_approve' pelo MESMO caminho
que uma regra específica de agent_autonomy já usava
(ctx.autonomyMode), os tetos que já continham agent_autonomy continuam
contendo a curinga sem precisar de código novo. O risco real não era o
teto — era alguém, ao generalizar agent_autonomy pra aceitar "*",
inserir a checagem da curinga ANTES dos tetos e reabrir a porta; por isso
AGENT_AUTONOMY_ALL_ACTIONS foi resolvido inteiramente no REPOSITÓRIO
(antes de decide() rodar), e decide() em si não ganhou nenhuma linha
nova — só o suficiente pra não ter onde a curinga furar.
- Onde:
src/domain/actions/decide.ts(os três blocos de teto, linhas ~207–258, inalterados por esta feature) - Teste:
test/application/use-cases/actions/propose-action.use-case.spec.ts("auto mode NÃO auto-aprova merge em branch protegida", "... instruction_patch", "... parallelize/raise_max_parallel" — os três comagent_autonomy"*" gravado comoauto_approvee o veredito continuandorequire_approval) - Origem: restrição de design confirmada pelo usuário ao pedir o "auto mode" — os três tetos são a garantia que não pode regredir
RN-155 — ordenação da timeline usa o vínculo proposed_action.created, nunca action.seq cru
A timeline de SessionPage.tsx ordena eventos e ações propostas por um
único eixo numérico comparável. Para eventos, é event.seq (gapless, por
sessão). Para ações, é o seq do evento proposed_action.created correlato
(achado por payload.actionId === action.id, gravado por
ProposeActionUseCase na MESMA transação que cria a ação) — nunca
action.seq, que é bigserial único e global de toda a tabela
proposed_actions, compartilhado por todas as sessões e projetos do
sistema, e portanto incomparável com event.seq (contraste deliberado, ver
src/db/schema/actions.ts). Comparar os dois direto produzia ordem
imprevisível toda vez que um ApprovalCard entrava na mistura com eventos
normais. Ações sem esse vínculo (só o bootstrap de Gitflow —
git_repo_create/git_branch_create, que gravam apenas outbox) degradam
para uma posição interpolada por createdAt, ancorada no último evento
anterior.
- Onde:
apps/web/src/routes/SessionPage.tsx(ordemDaAcaoNaTimeline) - Teste:
apps/web/src/routes/SessionPage.ordenacao-e-avisos.test.tsx - Origem: achado de PR #286 — cards de aprovação apareciam fora de ordem na timeline, misturados com eventos normais
RN-169 — O projeto escolhe onde o código mora: Local ou Container
REVISADA pela RN-421 (ADR 0104): workspace_mode/local
viraram execution_mode de TRÊS valores (container/mounted/runner,
migração 0048) — o resto desta entrada é histórico, fiel ao que valia até
a revisão.
Um projeto nasce com um modo de workspace (projects.workspace_mode,
migração 0043), e é ele que decide de onde a raiz de escopo é derivada:
container(DEFAULT, e o comportamento que sempre existiu): a pasta GERENCIADA pelo produto,join(PROJECT_WORKSPACES_ROOT, workspace_dir_name)(RN-109);local: uma pasta DO USUÁRIO, no caminho absoluto deprojects.workspace_path.
O par é amarrado por CHECK no banco
((workspace_mode = 'local') = (workspace_path IS NOT NULL)), e não só pelo
caso de uso: a coluna é lida por DOIS processos (api e engine) e escrita por
scripts de seed/backfill que não passam por ele. local sem caminho seria
escopo de terminal apontando para lugar nenhum; container com caminho seria
uma segunda fonte de verdade esperando divergir da primeira.
A derivação continua única — projectScopeRoot passou a receber a
localização ({workspaceDirName, workspaceMode, workspacePath}) e escolhe o
ramo; nenhum dos quatro consumidores (RN-092) ganhou validação
própria. O engine resolve o mesmo localizador na CONSULTA (nome de pasta no
container, caminho absoluto no local) e distingue os dois pela barra
inicial, que é inequívoca porque o nome de pasta é validado contra
^[A-Za-z0-9_-]{1,64}$.
Duas consequências explícitas do modo local:
- O portão da imagem do Arquiteto (RN-105) NÃO vale.
REVOGADO pela RN-494 (ADR 0135): a
dispensa terminou — os três modos passam a exigir a decisão de imagem
antes de abrir a aba Code. O resto deste item é histórico. Projeto
Local não sobe container, então a aba Code liberava sem esperar decisão
que nunca ia acontecer. A dispensa morava no mesmo funil do portão na
api, e a tela concordava:
ProjectCodeTabnem chegava a perguntar o estado do container (RN-107) quando o projeto era Local — se só a api dispensasse, a aba continuaria bloqueada na tela por uma decisão inexistente. - O
permissions.jsonmora na pasta do usuário, junto com o código — porque a política tem que ser lida da MESMA raiz que o escopo de terminal autoriza.
workspace_mode não confunde com o GitProviderName 'local': um diz onde o
CÓDIGO mora em disco, o outro onde o REPOSITÓRIO git vive, e as duas escolhas
são ortogonais.
- Onde:
src/db/schema/iam.ts(projects),src/infrastructure/filesystem/project-workspaces-root.ts(projectScopeRoot),src/domain/iam/project.entity.ts,src/application/use-cases/git/read-project-code.use-case.ts(portaoDoContainer),apps/web/src/routes/ProjectCodeTab.tsx,apps/engine/lib/engine/projects/project.ex,apps/engine/lib/engine/actions/workspace.ex(workspace_dir/2),apps/engine/lib/engine/dev/worktree_cleanup.ex(que era a segunda derivação escrita à mão, e passou a usar a única),apps/web/src/routes/NewProjectWizard.tsx - Teste:
test/infrastructure/filesystem/project-workspaces-root.spec.ts(describe "projectScopeRoot no modo local"),test/infrastructure/filesystem/fs-permissions-file-store.spec.ts(o permissions.json na pasta do usuário),test/application/use-cases/iam/create-project-modo-de-workspace.spec.ts,test/application/use-cases/git/read-project-code.use-case.spec.ts(projeto Local não passa pelo portão),test/interfaces/http/iam/project-dto-modo-de-workspace.spec.ts(o modo é congelado: PATCH não o muda),apps/web/src/routes/ProjectCodeTab.test.tsx(a tela também dispensa o gate),apps/engine/test/engine/actions/workspace_test.exs(describe "workspace_dir/2 com o localizador já resolvido"),apps/web/src/routes/NewProjectWizard.test.tsx - Decisão arquitetural: ADR 0072
- Origem: pedido do usuário (decisão dele, com a variante de caminho livre escolhida explicitamente)
RN-170 — Caminho Local é validado na CRIAÇÃO, e a recusa ensina
REVISADA pela RN-422 (ADR 0104): a validação de criação passou
a DIVERGIR por modo — mounted (o local renomeado) continua tocando disco
como descrito aqui, runner valida só o LÉXICO, sem I/O — o resto desta
entrada é histórico, fiel ao que valia até a revisão.
Criar um projeto no modo local com um caminho que a api não alcança produz um
projeto que trava depois — na primeira ferramenta do primeiro agente, longe
da tela onde a decisão foi tomada. Por isso a criação recusa com 400, e a
mensagem diz o que falta fazer.
O caminho precisa ser:
- absoluto, sem
..nem.em nenhum segmento (o caminho gravado é o caminho que se lê;/srv/app/../../etcé/etce não parece); - existente e uma pasta dentro do container da api;
- gravável pelo processo (
access(W_OK|X_OK)) — as imagens rodam non-root (ADR 0024), e pasta do host com outro dono chega montada como somente leitura na prática; - fora da raiz e das pastas de sistema (
/,/etc,/usr,/var,/data… e tudo abaixo delas): a raiz do projeto é o escopo que AUTORIZA o terminal do agente (ADR 0055), e um projeto com raiz em/etctransforma "o agente escreve no projeto dele" em "o agente reescreve o container"; - sem sobreposição com o checkout do Brabo, nos DOIS sentidos — a pasta que CONTÉM o monorepo e a pasta DENTRO dele. O segundo caso é o problema que o ADR 0055 relata acontecendo de verdade.
O caminho gravado é o normalizado, não a string crua: validar uma string e
gravar outra é como a validação deixa de valer no dia seguinte. workspacePath
enviado junto com workspace_mode: container é RECUSADO, não ignorado — campo
descartado em silêncio vira "mas eu configurei".
A parte LÉXICA (itens 1, 4 e 5) roda também na leitura, a cada derivação de
projectScopeRoot: o único jeito de burlar a criação é escrever direto no
banco, e o que se ganha ali é escopo de terminal em /. A parte de DISCO
(itens 2 e 3) roda só na criação, onde o usuário ainda pode corrigir.
A recusa por pasta ausente traz a instrução de montagem — o arquivo, os dois
serviços e a linha (- <caminho>:<caminho>), com o ponteiro para
o runbook. Montar só na api produz um projeto que a api aceita e o
engine não enxerga: ela valida o que ela vê, e não tem como saber o que está
montado no outro container.
- Onde:
src/infrastructure/filesystem/project-workspaces-root.ts(validarCaminhoDeWorkspaceLocal,CaminhoLocalInvalidoError),src/application/use-cases/iam/create-project.use-case.ts(caminhoValidado),src/interfaces/http/iam/dto/create-project.dto.ts,apps/web/src/lib/wizard.ts(caminhoLocalParecePlausivel) - Teste:
test/infrastructure/filesystem/project-workspaces-root.spec.ts(describe "validarCaminhoDeWorkspaceLocal"),test/application/use-cases/iam/create-project-modo-de-workspace.spec.ts(describe "a criação RECUSA o caminho que travaria depois"),apps/web/src/lib/wizard.test.ts,apps/web/src/routes/NewProjectWizard.test.tsx - Decisão arquitetural: ADR 0072
- Origem: guarda exigida pela variante de caminho livre (ADR 0072)
RN-186 — provider é dimensão do relatório do owner, e só dele
sumGroupedBy aceita provider como dimensão, e
GET /workspaces/:id/spend-report devolve a lista porProvider ao lado de
modelo, projeto, ator e dia. O ADR 0063
tinha deixado o eixo de fora; o ADR 0076
o devolveu sem revogar o argumento: quebrar gasto por provider continua
sendo quebrar por CREDENCIAL, e por isso o eixo mora numa rota que já exige
owner (RN-060) — a mesma régua de credential-spend, que segue
respondendo a pergunta da FATURA (por mês, com o vínculo à chave que existe
hoje).
GET /projects/:id/spend/me não ganhou nada. A assimetria é o desenho: as
duas respostas continuam não sendo recorte uma da outra (RN-101).
A dimensão model não mudou: dois providers servindo o mesmo nome de modelo
continuam numa linha só. Quem quer a quebra por credencial tem a lista própria,
e cruzar as duas dimensões multiplicaria as linhas do ranking sem responder
pergunta que as duas listas separadas já não respondam.
- Onde:
src/application/ports/token-usage-repository.port.ts:150(SpendDimension),src/infrastructure/persistence/drizzle/token-usage.repository.ts:255(oGROUP BY),src/application/use-cases/llm/get-workspace-spend-report.use-case.ts:112,src/interfaces/http/llm/spend.controller.ts:56 - Teste:
test/application/use-cases/llm/spend-audiencias.use-case.spec.ts("quebra por PROVIDER, e o mesmo nome de modelo em dois providers segue UMA linha") - Origem: ADR 0076
RN-187 — A visão do membro não alcança provider, e quem garante é o TIPO
Enquanto o eixo não existia, o que continha a visão do membro era a ausência
de argumento a passar. Com o eixo de volta, a contenção passou a ser da
assinatura: sumGroupedBy tem duas sobrecargas, e a que aceita um escopo com
actor — o da audiência do membro, e o único que ele tem — só recebe
SpendDimensionDoAtor, que é Exclude<SpendDimension, 'provider'>.
sumGroupedBy('provider', escopoComAtor) não compila.
Nem o repositório nem o caso de uso têm if sobre essa combinação, de
propósito: uma checagem em tempo de execução daria a impressão de que a garantia
é dinâmica, quando quem a sustenta é o compilador — e um if é o tipo de coisa
que a próxima refatoração remove sem que nenhum teste fique vermelho.
A barreira é dupla e as duas metades são independentes: a rota do membro
também não tem parâmetro de dimensão (só projectId e dias), então uma
query inventada como ?dimensao=provider é descartada pelo Nest antes de
chegar ao handler.
Exclude em vez de uma segunda lista escrita à mão é deliberado: dimensão nova
nasce alcançável pelas duas audiências, e tirá-la do alcance do membro vira ato
explícito neste ponto — nunca um esquecimento em outro arquivo.
- Onde:
src/application/ports/token-usage-repository.port.ts:121(as duas sobrecargas),:147(SpendDimensionDoAtor),:163/:173(os dois escopos),src/application/use-cases/llm/get-my-spend.use-case.ts:73,src/interfaces/http/llm/spend.controller.ts:98 - Teste:
test/application/use-cases/llm/spend-audiencias.use-case.spec.ts("não compila pedirprovidercom escopo de ator" — um@ts-expect-errorque otscreprova como diretiva NÃO USADA se a barreira cair; e "só pede as dimensõessessioneday"),test/interfaces/spend.controller.spec.ts(a rota do membro aceitadiase mais nada) - Origem: ADR 0076
RN-188 — Pessoa e agente são partição da lista por ator, sem consulta a mais
O relatório do owner traz porOwner (linhas de actor_kind = 'user') e
porAgente (actor_kind = 'agent') além de porAtor, que continua inteira.
Os dois blocos são derivados de porAtor no caso de uso — actorKind já
vem na linha desde a FASE 22 —, e não duas consultas com where actor_kind.
O motivo é medido: o ADR 0063
mostrou que o custo destas consultas cresce com o tamanho de token_usage e
não com o do pedido, então varrer a janela duas vezes a mais para separar o que
já está separado em memória seria caro pelo motivo errado.
actor_kind que não seja pessoa nem agente (hoje, system) não entra em
nenhum dos dois blocos e continua visível em porAtor e no total. Abrir um
terceiro bloco para ele diria que o produto tem uma audiência que ele não tem.
O rótulo "Por owner" é do handoff de design e vale pela RN-058 — é a
chave do owner que todas essas linhas gastam. Quem é o dono do workspace
continua sendo o campo ownerId, não o actorKind de cada linha.
- Onde:
src/application/use-cases/llm/get-workspace-spend-report.use-case.ts:120 - Teste:
test/application/use-cases/llm/spend-audiencias.use-case.spec.ts("separa PESSOA de AGENTE em dois blocos, sem perder a lista por ator") - Origem: ADR 0076
RN-189 — O embedding devolve um vetor por entrada, ou um erro
embed é uma operação em LOTE: recebe N textos e devolve N vetores, na
mesma ordem. A ordem é o único vínculo entre entrada e vetor, e é por isso
que uma resposta mais curta não serve — o i-ésimo vetor acabaria pertencendo
a outra frase, e o índice erraria em silêncio, com o sintoma aparecendo na
BUSCA, semanas depois e longe da causa.
Assim, o contrato recusa, em vez de degradar, três coisas: um lote
incompleto, um vetor vazio e dimensões diferentes na mesma resposta. Uma
lista de entradas vazia também é recusada antes de sair pela rede —
responder [] a ela faria o chamador gravar um índice vazio achando que
indexou algo.
O resultado traz quatro campos, e cada um responde a uma pergunta que já
custou caro em outro lugar do produto: dimensions é conferido contra o que
VOLTOU (nunca copiado do catálogo); model é o que o provider disse ter
usado, porque um alias resolve para uma versão datada e é esse nome que vai
para o metering, pela mesma razão do preço congelado (RN-044); e
inputTokens vem com estimated, preservando a distinção "o provider disse
zero" × "o provider não disse nada" da RN-041.
O erro é lançado, normalizado por code, em vez de virar um chunk como
no chat. A razão do chunk é preservar o gasto de um turno em andamento;
aqui não há nada a preservar — ou o provider devolveu os vetores e cobrou,
ou não devolveu e não cobrou. É a mesma escolha do listModels, e a
taxonomia é a mesma: nenhum LLMErrorCode novo.
- Onde:
src/application/ports/llm-provider.port.ts:61,src/infrastructure/llm/embedding-result.ts:26 - Teste:
test/contract/llm-provider.contract.ts(cinco casos, rodados contra todo provider que declara a capability) - Origem: ADR 0075
RN-190 — O embedding tem uma capability em duas camadas, e a do modelo é exclusão
Como no tool calling (RN-040), a capability tem duas camadas: o
PROVIDER (capabilities.embeddings, o teto) e o MODELO (supportsEmbeddings
na linha do catálogo). A diferença entre os dois casos é exatamente o que a
regra existe para afirmar:
- tool calling é um gradiente — um modelo que não pede ferramentas ainda conversa, e por isso só o binding de agente é recusado;
- embedding é exclusão — o
nomic-embed-textnão responde a uma pergunta e ollama3.2não devolve um vetor. São conjuntos disjuntos, e o daemon do Ollama prova isso respondendo501("This server does not support embeddings") a um pedido de embedding com um modelo de chat.
assertCanEmbed confere os dois na ordem que falha melhor: primeiro o
provider, porque trocar de modelo não conserta um provider que não faz
embedding. Um modelo sem declaração também é recusado, com mensagem
diferente — ausência significa "o provider não disse"
(ADR 0041),
nunca permissão, e a ação certa para quem lê é sincronizar o catálogo em vez
de trocar de modelo. Deduzir a capability pelo NOME do modelo é proibido:
seria um palpite vestido de dado.
- Onde:
src/domain/llm/embedding-capability.ts:64,src/infrastructure/llm/ollama-provider.ts:319 - Teste:
test/domain/llm/embedding-capability.spec.ts,test/infrastructure/llm/ollama-provider.spec.ts - Origem: ADR 0075
RN-191 — embeddings: true exige execução, não documentação
A regra da casa (ADR 0041/ADR 0043)
aplicada à nova capability: só o ollama declara true, e a prova é o
POST /api/embed rodado contra o daemon real 0.32.1 com nomic-embed-text —
duas entradas, dois vetores de 768 dimensões, prompt_eval_count: 10.
Os outros oito declaram false, por dois motivos distintos:
- falta de prova (sete): não há chave para eles no ambiente, e o único smoke test pago que já rodou (aceite) foi de CHAT — num hub, o embedding roteia para providers diferentes dos do chat, e a prova de um endpoint não é prova do outro;
- operação ausente (Anthropic): não há endpoint dedicado de embedding, e a documentação dele aponta para um terceiro, que é outro provider, com outra chave e outro dialeto.
O DIALETO da base OpenAI-compatível é provado à parte, com a suíte de
contrato rodando uma segunda vez sobre ela com a capability ligada. É isso
que torna barato virar um provider para true no dia em que a chave dele
existir: muda uma linha do literal, e o parsing já está exercitado. Um
provider que declara false e ainda expõe o método recusa a chamada
antes de tocar a rede.
- Onde:
src/infrastructure/llm/ollama-provider.ts:73,src/infrastructure/llm/openai-compatible-provider.ts:325 - Teste:
test/contract/llm-provider.contract.ts,test/infrastructure/llm/openai-compatible-provider.contract.spec.ts,test/infrastructure/llm/ollama-provider.embeddings.smoke.spec.ts(manual, contra o daemon real) - Origem: ADR 0075
RN-198 — Atividades agrupa por agente-base/instância REAL, nunca por contador inventado
A seção Atividades da sidebar é a mesma lógica de agrupamento de
AgentTimelineTree.tsx/timeline-tree.ts (já usada na Visão geral do
projeto), movida para um lugar novo — não reescrita. montarArvore já
agrupa por evento.actor.id; a novidade é agruparPorInstancia
(apps/web/src/lib/timeline-tree.ts), que decide quais ramos formam um
grupo visual de dois níveis.
A "instância" não é um contador renumerado (-01/-02) — é o agent_id
REAL que o produto já escreve: devAgentId/extraDevAgentId
(src/application/use-cases/execution/activate-execution.use-case.ts:27-38)
produzem dev-<modulo> e dev-<modulo>-2 (sufixo sempre exatamente -2,
porque o teto é DOIS por módulo, RN-154). Um ramo só vira
"instância extra" de outro se o agente-base (sem o sufixo) TAMBÉM tiver um
ramo na mesma lista — senão ele é o próprio agente. Agente com uma instância
abre direto nos eventos; com duas, revela um segundo nível, uma linha por
instância, cada uma com sua própria contagem — reaproveitando
getAgentLastSeenSeq/setAgentLastSeenSeq (read-state.ts) que já existia
para o contador de novidade da árvore.
Escopo: só o projeto da ROTA ATUAL (pathname), não todos os projetos do
workspace — agregar todos exigiria uma consulta de eventos POR projeto, a
mesma classe de N+1 que a RN-090/091 fechou no dashboard. Reusa o MESMO par
de hooks que AgentTimelineTree/SessionPage já usam
(useActiveExecutionSession + useSessionEvents, mesma queryKey) — zero
requisição nova quando as duas telas estão montadas juntas.
O que abre/fecha é persistido em brabo.sidebar.agents, com o formato do
handoff adaptado aos ids reais: agenteBase (grupo aberto, ex.:
dev-backend) ou ${agenteBase}/${instancia} (uma instância específica
aberta, ex.: dev-backend/dev-backend-2).
- Onde:
apps/web/src/lib/timeline-tree.ts(agruparPorInstancia,GrupoDeAgente),apps/web/src/routes/Shell.tsx:237-311(GrupoDeAtividade,InstanciaDeAgente),apps/web/src/routes/Shell.tsx:376-399(escopo e persistência) - Teste:
apps/web/src/lib/timeline-tree.test.ts(describe "agruparPorInstancia"),apps/web/src/lib/sidebar-state.test.ts - Origem: PROGRAMA 28, Onda 1/frente B0 (achado, sem código) e Onda 2/frente B (implementação)
RN-213 — Alerta de custo é leitura de lastThresholdNotified, nunca recálculo
alertaDeOrcamento não reimplementa crossedThresholds
(src/domain/llm/budget-threshold.ts): lê o campo que o backend
já grava no momento em que uma chamada real cruza 70/90/100%, e só decide
a cor (warning abaixo de 90, danger a partir de 90) e se o texto deve
avisar bloqueio ativo (policy === 'block' && spentMicros >= limitMicros). Nenhuma regra de negócio nova — puramente apresentação de
um dado que já existe.
- Onde:
apps/web/src/lib/spend.ts(alertaDeOrcamento) - Teste:
apps/web/src/lib/spend.test.ts(describe "alertaDeOrcamento (RN-213)");apps/web/src/routes/ProjectSpendTab.test.tsx(describe "o orçamento do projeto")
RN-219 — Os três escopos do índice de chunks são honestos, e mutuamente exclusivos por CHECK
O índice do Chat RAG cobre só três fontes de texto que o produto já sabe de
onde vieram: docs, adr e session. Código-fonte e Pull Requests ficam
de fora de propósito — indexá-los sem um watcher de reindexação a cada
push faria o índice mentir sobre cobertura, a mesma classe de erro que o
ADR 0042 já recusa para capability de modelo. session_id/source_path
são mutuamente exclusivos por CHECK, não por convenção de aplicação, mesmo
padrão de projects.workspace_mode/workspace_path (ADR 0072): scope = 'session' exige session_id e recusa source_path; docs/adr exigem
source_path e recusam session_id. A trava fica no banco porque quem vai
escrever esta tabela é um pipeline (Onda 4) que não necessariamente passa
pelo mesmo caso de uso toda vez.
- Onde:
src/db/schema/rag.ts(chunkScopeEnume os dois CHECK da tabelachunks) - Teste:
test/infrastructure/persistence/chunk.repository.spec.ts("recusa chunk de docs sem source_path — o CHECK da migração 0045, não validação de aplicação") - ADR: 0079
RN-220 — Vetor e busca léxica vivem na mesma linha, nunca em tabelas separadas
chunks.embedding (pgvector) e chunks.search_vector (tsvector) são
colunas irmãs da MESMA tabela, não duas tabelas ligadas por chunk_id.
Separar exigiria um JOIN em toda busca híbrida (Onda 4) e abriria espaço
para as duas divergirem — um trecho com vetor mas sem entrada léxica, ou
vice-versa — sem nenhum mecanismo do banco impedindo. Uma linha, uma fonte
de verdade para as duas metades da busca.
- Onde:
src/db/schema/rag.ts(tabelachunks) - Teste:
test/infrastructure/persistence/chunk.repository.spec.ts(as três specs escrevem e leem as duas colunas na mesma linha) - ADR: 0079
RN-221 — search_vector nunca é escrita pela aplicação — é coluna GENERATED
search_vector é GENERATED ALWAYS AS (to_tsvector('portuguese', content)) STORED. Nenhum caso de uso, repositório ou script escreve nela — o
Postgres a mantém coerente com content por construção, pronta na mesma
transação do INSERT, sem depender de nenhum provider de LLM responder
(diferente de embedding, que só chega quando um pipeline de indexação
existir).
- Onde:
src/db/schema/rag.ts(colunasearch_vector) - Teste:
test/infrastructure/persistence/chunk.repository.spec.ts("grava um chunk de docs com vetor e devolve o search_vector gerado pela GENERATED ALWAYS AS") - ADR: 0079
RN-222 — A dimensão do vetor é documentada, não adivinhada, e embedding chega depois do chunk
chunks.embedding é vector(768) — a dimensão real do nomic-embed-text
do Ollama, o único provider que hoje declara capabilities.embeddings: true (RN-191, ADR 0075). Um índice vetorial tem dimensão FIXA: trocar de
modelo de embedding no futuro é migração nova, nunca parâmetro de runtime.
A coluna é NULLABLE: esta tabela guarda o CHUNK (texto recortado), e o
VETOR pode chegar depois via um pipeline de indexação assíncrono que ainda
não existe (Onda 4) — sem isso, chunking teria que esperar embedding,
misturando duas falhas de natureza diferente (parsing de documento contra
chamada de rede a um provider) numa escrita atômica só.
- Onde:
src/db/schema/rag.ts(colunaembedding) - Teste:
test/infrastructure/persistence/chunk.repository.spec.ts("grava um chunk de docs com vetor..." grava comembeddingpreenchido; as outras duas specs gravam sem ele, confirmando a nulabilidade) - ADR: 0079; RN-191
RN-223 — O índice vetorial é HNSW, não IVFFlat, porque a tabela nasce vazia
IVFFlat precisa de linhas já carregadas para treinar as listas (lists) e
fica ruim se construído sobre tabela vazia — que é exatamente o estado
desta tabela ao nascer, sem pipeline de indexação ainda (Onda 4). HNSW
constrói o grafo incrementalmente, inserção por inserção, sem etapa de
treino — bom desde a primeira linha. vector_cosine_ops porque é a
métrica que embeddings de texto geralmente esperam (o ranking de
similaridade não deveria mudar com a magnitude do vetor).
- Onde:
src/db/migrations/0045_shallow_randall.sql(chunks_embedding_idx) - ADR: 0079
RN-225 — Migração que pode exigir privilégio de operador nasce em breaking/
Criar uma extensão exige que o role da aplicação tenha CREATEDB (ou que a
extensão esteja marcada "trusted" pelo DBA). Localmente o role é
superusuário, mas nada garante isso em produção — gerenciadores de
Postgres administrado frequentemente não dão superusuário à aplicação. Se
a migration falhar aí, é ação do OPERADOR antes do deploy (rodar CREATE EXTENSION vector; uma vez, como superusuário), não bug do produto — o
critério do CLAUDE.md para nascer em breaking/ em vez de
feature//bugfix/.
- Onde: branch
breaking/tabela-de-chunks,src/db/migrations/0045_shallow_randall.sql(comentário que documenta a decisão dentro da própria migration) - ADR: 0079
RN-226 — ChunkRepository cobre só escrita/leitura básica; createMany é operação de lote
O port só tem create, createMany, findById e listByProject — busca
híbrida (vetor + léxico, pesos, limiar) é da Onda 4 (G2) e deliberadamente
NÃO entra aqui: o port guarda dado, o caso de uso decide o que fazer com
ele. createMany existe porque uma indexação recorta N trechos de um
documento/sessão de uma vez, evitando N round-trips por documento
indexado.
- Onde:
src/application/ports/chunk-repository.port.ts,src/infrastructure/persistence/drizzle/chunk.repository.ts - Teste:
test/infrastructure/persistence/chunk.repository.spec.ts("createMany grava um lote e listByProject filtra por escopo, sem misturar docs e session") - ADR: 0079