API — parte 7 (desde RN-567)
A chave de dispositivo ganha tela, e a tela diz o alcance de revogar (RN-561)
RN-567 — proposed_action.created diz QUAL regra decidiu: o reason de decide() passa a sobreviver quando a ação passa
decide() sempre devolveu um par — policy e reason —, e a razão é
escrita em todos os pontos de retorno: o IAM insuficiente, o piso do
container (RN-492), agent_autonomy, as linhas do
permissions.json, o comando composto, e cada teto absoluto. Mas
ProposeActionUseCase só a guardava num lugar: rejectionReason, que
initialStatusFor preenche só quando a política nega. Numa
auto-aprovação — o desfecho que mais precisa de auditoria — o event log
dizia "a política decidiu" (RN-049) e
nunca qual regra. status tornou a auto-aprovação distinguível de um
clique; o motivo é o degrau seguinte.
A regra: o payload do evento de SESSÃO proposed_action.created ganha
reason, com a string de decide() como está, nos TRÊS desfechos —
auto_approved, pending e denied. Não nasce segunda régua
"estruturada": a string já é a fonte, e um enum paralelo divergiria dela no
primeiro teto novo. No denied o valor é o MESMO que vira
rejectionReason.
O outbox NÃO ganha o campo, e isso é decisão. A linha de outbox
proposed_action.created é contrato api↔engine, e nenhum consumidor do
engine lê o motivo; alargar contrato sem consumidor é o corte que o
ADR 0153 nomeia. A
linha session_event.appended que AppendSessionEventUseCase grava para o
mesmo evento carrega só eventId/seq/type, então o motivo também não
vaza por ela.
Ausente não é "sem motivo". Evento gravado antes desta regra não tem o
campo, e não há reprocessamento nem coluna nova: quem lê trata reason
ausente como "não registrado". Todo evento novo tem o campo, porque
Decision.reason é string obrigatória no tipo.
Nada em decide() muda — nenhum teto, nenhum veredito, nenhuma string. A
tela também não muda nesta regra: o motivo passa a EXISTIR no log, e
mostrá-lo é outra entrega. (Desde a RN-614 a tela o mostra na
linha do evento no painel de log e no ApprovalCard, pela mesma frase.)
O que o motivo NÃO diz, declarado: ele nomeia a regra, não a RAIZ do
escopo — o ponto 7 do ADR 0055
pedia qual raiz autorizou, e escopo: cd dentro da pasta do projeto não
carrega o caminho. (A raiz passou a ir no evento em campo PRÓPRIO desde a
RN-609, relativa e nunca absoluta.) No comando composto, a razão diz que todos os segmentos
bateram em allow, sem dizer qual padrão casou qual segmento. E as linhas
do permissions.json repetem os TOKENS do comando no texto (o label de
matchAgainstFile) — o mesmo comando que já mora em
proposed_actions.payload e no card de aprovação.
- Código:
src/application/use-cases/actions/propose-action.use-case.ts:277(reasonno payload do evento de sessão),:223(o comentário do outbox sem o campo),:227(o payload do outbox, intacto),:356(orejectionReason, que continua só nodeny);src/domain/actions/decide.ts:294(Decision, a fonte da string) - Teste:
test/application/use-cases/actions/propose-action.use-case.spec.ts:902(caminho feliz: auto-aprovação gravaagent_autonomy: auto_approve),:959(require_approvalpelo default e pelo teto da trava de merge),:1058(denycom o mesmo texto dorejectionReason),:999(o outbox semreason) - ADR: 0048, 0055 (ponto 7, parcialmente)
- Origem: AT-066
RN-569 — O grafo é reconstruído do event log pelo MESMO tradutor do projetor vivo, sem tocar a outbox, e nunca sai como sucesso calado
O ADR 0152 (decisão 4) recusou
backup de neo4j_data apoiado numa premissa — o grafo pode ser descartado
porque pode ser reconstruído — cujo mecanismo não existia: grep por
reprojet/reproject em apps/ e scripts/ voltava vazio, e
application/graph-projection/ só tinha o projetor PARA FRENTE. Enquanto não
existisse, o grafo não era descartável: era perdido.
A regra: src/scripts/reprojetar-grafo.ts
(pnpm --filter api grafo:reprojetar; node scripts/reprojetar-grafo.js na
imagem) reconstrói o grafo varrendo a fonte. Cinco decisões, cada uma com
motivo:
- Um tradutor só. A tradução evento → nó/aresta SAIU do
GraphProjectorparaGraphEventTranslator, e os dois caminhos a chamam — o projetor chega à fonte por uma linha de outbox, a reprojeção por cursor. Dois tradutores divergiriam, e o divergente seria o que roda uma vez por ano. A superfície de injeção do projetor NÃO mudou (ele monta o tradutor com as dependências que já recebia). Um teste reprova tipo novo emGRAPH_PROJECTABLE_EVENT_TYPESsem tradução. - As fontes são duas, e são as de verdade.
handoff.offered,psychologist.hypothesis_proposedeanamnese.profile_updatedsaem desession_events; aInteracaosai desessionsemclosed/closed_abnormally, porque fechar sessão é transição pura e não deixa evento no log.PromptTemplate/PromptVersionNÃO saem do event log e NÃO são reprojetados — voltam porscripts/dev/seed-prompts.ts. - Idempotente, em lotes, e nunca apaga. Toda escrita é
MERGEem chave natural, garantido por uma restrição de unicidade nessa chave (RN-1030), então um projetor vivo concorrente ou uma segunda réplica não consegue criar um segundo nó; rodar de novo é o jeito de retomar.session_eventsé varrida em ordem deid(ULID, a chave primária), 200 por lote, e--after-eventretoma depois do cursor impresso. Com--project, só as sessões daquele projeto. - Não toca a outbox. O projetor vivo guarda o progresso em
outbox_events.processed_at; a reprojeção nem lê nem escreve essa tabela, e por isso roda com a api de pé sem roubar, reabrir ou marcar linha. - Nunca sucesso calado. Grafo desligado é recusado ANTES de ler o log (um escopo vazio "passaria" com zero projetados); Neo4j que cai no meio para com o último evento concluído; projeto inexistente é recusado sem gravar nada; e item com payload incoerente é contado, nomeado, e o processo sai com 1.
O que esta regra NÃO fecha: PerfilAnamnese é snapshot por usuário +
dimensão, SEM projeto — o último a escrever vence —, então uma rodada com
--project pode deixá-lo no valor daquele projeto quando outro escreveu depois;
a rodada total restaura o último global. Não há medição de tempo em event log
grande (a única rodada real foi o compose de dev, 98 eventos, 2,5 s), e a prova
no cluster local que o BRB-018 pede não foi feita — ele segue aberto.
- Código:
src/application/graph-projection/graph-event-translator.ts:15(EVENTOS_DO_LOG_PROJETAVEIS),:31(FECHAMENTOS_DE_SESSAO),:56(o tradutor),:75(projetarEvento),:99(projetarFechamentoDeSessao);src/application/graph-projection/graph-projector.ts:77(o projetor monta o MESMO tradutor),:141(chega à fonte pela outbox e delega);src/scripts/reprojetar-grafo.ts:124(montarTradutor),:145(reprojetarGrafo),:154(a recusa antes de ler),:163e:169(projeto inexistente),:187(a fase dos eventos, por cursor),:213(a interrupção nomeada),:218(a falha contada sem abortar o resto),:230(a fase das sessões fechadas),:301(lerArgumentos),:370(só a invocação direta reprojeta);package.json:44(grafo:reprojetar);Makefile:61(test-reprojecao) - Teste:
test/scripts/reprojetar-grafo.spec.ts:349(projetor para frente → apaga o subgrafo → reprojeta o log inteiro → mesma contagem de nós e arestas e mesma lista de chaves; a segunda rodada idêntica; a outbox intacta),:394(por projeto),:417(projeto inexistente, nada gravado — caso de falha),:432(grafo indisponível é erro nomeado — caso de falha),:441(argumento desconhecido recusado),:452(o tradutor cobre exatamente os tipos projetáveis);test/application/graph-projection/graph-projector.spec.ts(o projetor para frente, inalterado, sobre o tradutor extraído). Os quatro primeiros exigem Neo4j de pé e PULAM sem ele — a CI do api não sobe Neo4j. - Origem: AT-032 (BRB-018) — a premissa do ADR 0152 sem mecanismo
RN-573 — O assistente de criação só pré-seleciona o modo que a instalação executa: sem broker, runner é o sugerido e container/mounted ficam inertes com o motivo em texto
Numa instalação real da v6.1.0 (AT-085), o assistente pré-selecionou mounted
— o install.sh sempre grava BRABO_PROJECTS_BASE, e com base conhecida a
RN-513 pré-selecionava o modo. Mas a instalação por Release não tem
broker de container (a imagem não é publicada, decisão 7 do
ADR 0150), e container/mounted só
sobem container pelo broker (RN-503). Resultado: seis
dev.blocked_by_container e duas container.start_failed — ninguém trabalha
no projeto, para sempre (RN-502).
A regra (ADR 0161):
- A api diz se há broker, na rota que o assistente já lê.
GET workspaces/:workspaceId/projects-basepassa a devolverbrokerConfiguradoao lado deprojectsBase— mesma pergunta ("que modo esta INSTALAÇÃO executa?"), mesmo mínimo (maintainer), nenhuma rota nova. A fonte éContainerBrokerPort.configurado(), a mesma da leitura do estado observado. Diz queBROKER_URLexiste, nunca que o broker responde. - Três estados, que não colapsam. Broker confirmado: a RN-513 intacta.
Ausência confirmada:
containeremountedcontinuam na tela, inertes (o controle sai, a informação fica — RN-102), o motivo é dito UMA vez em texto abaixo dos cards, erunneré o pré-selecionado. Não sei (carregando, consulta que falha, campo ausente): não vira "tem" —mountedNÃO é pré-selecionado, a revisão da RN-513 — e também não vira "não tem": nenhum card trava e nada se afirma em texto;containersegue selecionado, como antes. - A escolha humana não aponta para o que não roda. Quem clicou num modo
antes de a resposta chegar, e a resposta confirma que ele não executa aqui,
cai para
runner— a mesma regra que já derrubavamountedquando a base some.
O que esta regra NÃO fecha: a conversão de modo
(RN-559) continua oferecendo container/mounted numa instalação
sem broker; o instalador não pergunta se liga o broker, porque a imagem dele
não é obtível por uma instalação de Release (medido — ver o ADR 0161); e no
compose de desenvolvimento, que sobe o broker por padrão (RN-512) mas
deixa BROKER_URL vazia até alguém a pôr no .env, o aviso aparece — e está
certo, porque sem a variável a api nunca chama o broker.
- Código:
src/interfaces/http/iam/workspaces.controller.ts:404(getProjectsBase,:256o campo novo),src/interfaces/http/iam/dto/iam.response.dto.ts:554,src/interfaces/http/iam/iam-http.module.ts(o módulo do broker importado só pela perguntaconfigurado());apps/web/src/routes/NewProjectWizard.tsx:257(brokerConfirmado),:260(semBrokerConfirmado),:270(modoExecutavel),:283(o modo vigente),:716(o card inerte),:728(o motivo em texto);apps/web/src/locales/{pt-BR,en}/newProject.json(workspace.noBroker) - Teste:
test/interfaces/http/iam/workspaces-projects-base.controller.spec.ts:59(comBROKER_URL,true),:69(sem,falsemesmo com base — caso de falha),:79(só espaço conta como ausente),:52(o mínimo continuamaintainer);apps/web/src/routes/NewProjectWizard.test.tsx:504(sem broker:runnerpré-selecionado, os dois cards inertes, o motivo em texto),:523(broker desconhecido: não pré-selecionamountednem trava nada),:534(clique emcontainerantes da resposta cai pararunner),:479(consulta que falha não afirma ausência) - Origem: AT-085 — instalação real da v6.1.0 em 2026-09-14, decidido pelo mantenedor em 2026-09-18
RN-574 — A /containers recusa antes do clique a subida de container/mounted numa instalação sem broker, com motivo próprio em texto
A mesma instalação do AT-085 deixou aprovar, pela página de containers
(RN-521), duas container_start que só podiam terminar failed com
BrokerIndisponivelError. A página já recusava localmente o que sabia que ia
falhar — sem imagem, runner sem pasta confirmada, papel abaixo de
maintainer — e não sabia desta.
A regra (ADR 0161):
- A lista diz se há broker, em toda linha.
GET workspaces/:workspaceId/containersganhabrokerConfiguradopor linha, com o MESMO valor em todas, lido UMA vez por carga — é configuração da instalação, e trocar a lista por um envelope quebraria o contrato. - Um quarto motivo de recusa,
sem_broker_na_instalacao, paracontaineremountedquandobrokerConfigurado !== true("não sei" não vira "tem"). Vem logo depois de "já está de pé" e ANTES de "sem imagem decidida": numa instalação sem quem suba o container, mandar decidir a imagem seria apontar a porta errada. O texto diz o quê (esta instalação não sobe container para esses dois modos), por quê (sem broker,BROKER_URLvazia) e o que não é afetado (runner). runnernunca cai nele. Quem depende do broker é decidido pela MESMA ramificação por destino deacaoDeSubidaDoModo(usaBroker), não por uma segunda régua.
O que esta regra NÃO fecha: parar e remover não ganharam a recusa (sem
broker não há container de container/mounted registrado de pé para parar,
e o caso não foi medido), e o Infra Lead continua sem saber de broker ausente
— ele recusa por MODO (RN-566), e propor container_start numa
instalação sem broker continua possível pelo agente.
- Código:
src/application/use-cases/containers/obter-visao-geral-de-containers.use-case.ts:71(o campo),:150(lido uma vez por carga);src/interfaces/http/containers/dto/containers.response.dto.ts:408;apps/web/src/routes/containers-subida.ts:33(o motivo),:94(usaBroker),:122(a recusa);apps/web/src/locales/{pt-BR,en}/containers.json(actions.bloqueio.sem_broker_na_instalacao) - Teste:
test/application/use-cases/containers/obter-visao-geral-de-containers.use-case.spec.ts:252(sem broker,falseem toda linha — caso de falha),:264(com broker,true);apps/web/src/routes/containers-subida.test.ts:54(containeremountedrecusam antes da imagem),:75(runnernão é afetado),:84(campo ausente recusa),:93(já de pé continua dizendo isso);apps/web/src/routes/ContainersPage.test.tsx:562(botão inerte e o motivo em texto, nunca propõe),:726(runnersegue subindo) - Origem: AT-085 — instalação real da v6.1.0 em 2026-09-14, decidido pelo mantenedor em 2026-09-18
O broker de container na instalação, com consentimento (RN-575)
RN-581 — A conversa em curso segura a sessão por até 8h, e a sessão encerrada recusa conversa
No exp001 o heartbeat fechou a sessão 30 segundos depois de a aba parar, com
o Criativo tendo acabado de perguntar — e o event log continuou recebendo
eventos por quatro minutos depois do closed_at. Dois defeitos, um de cada
lado do fechamento: nenhum dos sinais de trabalho pendente da
RN-064 via uma conversa esperando o usuário
(um Criativo que terminou o turno está idle, sem handoff, sem ação, sem
dev.*), e nada — nem o funil de eventos, nem os casos de uso da conversa, nem
o engine — olhava o estado da sessão antes de gravar.
A regra, pelo lado do heartbeat:
- Conversa esperando o usuário é trabalho pendente. É o QUINTO sinal do
pending-work: o evento de fim de turno mais recente da sessão (agent.response,chat.structured_questionouagent.errordo lado do agente;chat.messageouchat.structured_question_answereddo lado do usuário) é do AGENTE, e o ator é um conversacional.agent.errorconta como fim de turno de propósito: pela RN-059 o agente diz no fio o que houve, e a próxima jogada é do usuário. Fala de quem não conversa (Psicólogo, dev agent) não segura. - É o único sinal com TETO: 8 horas, contadas do FIM do turno do agente
(o
created_atdaquele evento), não do último heartbeat. A api devolve o instante (aguardandoUsuarioDesde) e o engine aplica o teto (SESSION_CONVERSATION_IDLE_TIMEOUT_MS, default28800000). Passado ele, a sessão fechaclosedcom causa PRÓPRIA,conversation_idle_timeout— nãoheartbeat_timeout, porque quem lêtermination_reason(o Psicólogo, uma métrica por sessão) precisa separar a aba que sumiu da conversa que ninguém retomou. OTerminationClassifiera lê como:timeout: a api conhece seis causas nas hipóteses, e abrir uma sétima seria mudar aquele contrato. O teto vale com a aba ABERTA também (AT-152): oSessionServertem um relógio próprio (:conversation_idle_check, a cada 5 min — sem variável de ambiente, só o hook de teste:session_conversation_idle_check_ms), independente do heartbeat — o ping da aba não o reseta nem o adia. A cada volta ele lê a pendência e só FECHA por teto (mesma causa,conversation_idle_timeout); api fora do ar, sem pendência ou pendência sem instante apenas reagendam, porque quem encerra por api fora do ar continua sendo o heartbeat. Antes, o teto só era checado quando o heartbeat expirava, e uma aba aberta (ping a cada ~10s) o tornava inalcançável: sessão imortal. - Sinal sem teto vence. Com handoff
offered, açãopending, agente em turno ou dev agent trabalhando, o instante voltanulle a sessão fica sem teto, como sempre ficou. É por isso que o sinal é o ÚLTIMO da lista. - Instante presente e ilegível é ERRO, nunca
nil.nilcompending: trueé pendência sem teto; um formato quebrado viraria sessão imortal. Como erro, cai no caminho da api fora do ar — encerra por heartbeat, dizendo por quê.
A regra, pelo lado da sessão encerrada:
- Sessão
closed/closed_abnormallyrecusa evento de CONVERSA com 409 NOMEADO:{ message, reason: "sessao_encerrada", status, type }.closingainda aceita — a trava é de estado TERMINAL. - "Conversa" tem duas cláusulas, e as duas são necessárias. O TIPO só
existe como conversa (
chat.message,chat.structured_question[_answered],agent.status,agent.activated,handoff.offered/accepted,readiness.confirmed,necessity.validated,architecture.readiness_confirmed), OU o ATOR é um agente conversacional (os seis maisdev-leadeinfra). Uma lista de PERMITIDOS por tipo foi medida e recusada: quem escreve legitimamente numa sessão fechada são os CONSUMIDORES do fechamento — o Psicólogo roda umToolLoopcontra ela e gravatool.call,agent.response,agent.error; a Anamnese grava os próprios desfechos —, e eles falam o MESMO vocabulário genérico do Criativo. O tipo é igual; quem difere é o ator. - A decisão humana sobre ação continua entrando.
action.approved/action.deniede os desfechos de execução não são conversa: a ação é item de uma fila DURÁVEL que sobrevive à sessão, e recusar a decisão deixaria uma pendência impossível de resolver. O caso normal nem chega aqui (açãopendingsegura a sessão, sinal 2 da RN-064); ele só existe quando a sessão morreu por outra porta. - A recusa não deixa rastro. O estado vem do MESMO
UPDATEque reserva oseq(sem consulta a mais no caminho mais quente, sob o mesmo lock de linha da transição), e a recusa lança dentro da transação: o incremento volta, oseqsegue sem buraco. O chat humano, que gravachat.messagesem o funil, tem a mesma trava. Os casos de uso com efeito ANTES do evento recusam antes do efeito:CreateHandoffantes de criar a linha,AcceptHandoffantes de marcaraccepted(que ativaria o agente seguinte),ActivateAgentantes de chamar o engine. - O fechamento PARA os conversacionais.
SessionLifecycleWorker, por onde todo fechamento passa (heartbeat, conversa ociosa, humano, crash), para os conversacionais da sessão em TODOS os nós (Engine.Agents.Conversacionais, por:erpc— o registro é local e o job cai em qualquer réplica). O turno em curso é ABANDONADO noterminate/2dos seis servidores (TurnoAssincrono.abandonar/1): a task morre sem gravar nem transmitir nada, porque gravar seria pedir à api o que ela recusa. Não há resposta a dar: desde a RN-578 (ADR 0163) quem disparou o turno já recebeu:okno aceite. Pelo mesmo motivo ooffer_dev_handoffque o Arquiteto guardou para o fecho do turno NÃO roda quando o fecho é o da sessão — o handoff seria recusado (desde a RN-672 não há mais o que guardar: o handoff ao Dev Lead sai da Infra). O Infra Lead, que roda o turno dentro dohandle_call, é morto se não sair em 5s. - A recusa nunca é calada no engine. Quase todo chamador de
append_event/3descarta o retorno; o cliente registra o 409sessao_encerradacomo aviso no log, e o retorno segue o mesmo. - A tela reconhece o 409 pelo CÓDIGO e diz que a sessão fechou (AT-154).
ehRecusaDeSessaoEncerradaconferestatus === 409Ebody.reason === "sessao_encerrada"— nunca o texto, e nunca só o 409, que também é "turno em andamento". Nas quatro ações de conversa (enviar mensagem, responder pergunta estruturada, confirmar prontidão do Criativo e do Arquiteto) a tela mostra uma frase própria em vez do erro genérico, refaz a leitura da sessão (é ostatusnovo que faz o composer sumir) e NÃO perde o que foi digitado: a mensagem volta ao rascunho e aparece somente leitura no lugar do composer, e as respostas do formulário continuam nos campos. A frase não promete "Reabrir" — a ação não existe. E o botão "Encerrar" fica inerte nos DOIS estados terminais (sessaoEhTerminal, o espelho deisTerminal), não só emclosed(AT-155).
O que esta regra NÃO fecha: o teto só é aferido quando o HEARTBEAT expira —
com a aba aberta e mandando heartbeat, a sessão continua viva sem teto, como
sempre (quem está olhando não é conversa ociosa). A resposta do chat humano
stateless a um turno que COMEÇOU com a sessão aberta entra mesmo que ela feche
no meio (o ator é o modelo, e o gasto já foi medido). deploy/k8s/ não carrega
SESSION_CONVERSATION_IDLE_TIMEOUT_MS, e desde a AT-153 isso é DECISÃO escrita
no Deployment do engine, não pendência: lá nada intercepta o ambiente, a
ausência é o default do runtime.exs, e escrever o número faria uma segunda
cópia sem guarda contra divergir — mudar o teto ali é um item env: no patch do
engine do overlay. Os TRÊS composes o mapeiam com o default do código (o de
instalação só desde a AT-153, e até ali a linha no .env de uma instalação era
inerte), guardados por scripts/ci/teto-ocioso-nos-composes.spec.ts. Desde a AT-154 a tela trata o
409 com frase própria (item 11); as demais ações da tela que gravam em sessão
fechada seguem mostrando a mensagem da api.
-
Código:
apps/web/src/lib/sessao-encerrada.ts(ehRecusaDeSessaoEncerrada,sessaoEhTerminal);src/domain/sessions/conversa-em-sessao-encerrada.ts:56(AGENTES_CONVERSACIONAIS),:67(TIPOS_DA_CONVERSA),:95(ehEventoDeConversa),:117(garantirQueSessaoAceitaEvento);src/application/use-cases/sessions/append-session-event.use-case.ts:45(conflitoDeSessaoEncerrada),:114(o estado no mesmoUPDATEdoseq),:248(garantirQueAceita);src/infrastructure/persistence/drizzle/session.repository.ts:117(incrementSeq);src/application/use-cases/llm/send-chat-message.use-case.ts:78(o chat humano);src/application/use-cases/agents/create-handoff.use-case.ts:67,accept-handoff.use-case.ts:90,activate-agent.use-case.ts:52;src/application/use-cases/sessions/get-session-pending-work.use-case.ts:30(FALA_DO_AGENTE),:238(o quinto sinal);src/infrastructure/persistence/drizzle/session-event.repository.ts:124(findLatestOfTypesInSession);apps/engine/lib/engine/sessions/session_server.ex:115(a pendência com instante),:141(handle_infodo relógio próprio, AT-152),:168(conversa_ociosa),:193(encerrar),:222(conversation_idle_timeout_ms);apps/engine/lib/engine/sessions/monitor.ex:185(classify);apps/engine/lib/engine/psychologist/termination_classifier.ex:46;apps/engine/lib/engine/sessions/engine_api_client.ex:1227(narrar_recusa_de_sessao_encerrada),:1592(pendencia_da_resposta);apps/engine/lib/engine/sessions/engine_api_client.ex:1227(narrar_recusa_de_sessao_encerrada),:1592(pendencia_da_resposta);apps/engine/lib/engine/agents/conversacionais.ex:49(parar_da_sessao),:69(parar_da_sessao_no_cluster);apps/engine/lib/engine/agents/turno_assincrono.ex:471(abandonar);apps/engine/lib/engine/workers/session_lifecycle_worker.ex:88(parar_conversacionais);apps/engine/config/runtime.exs:79 -
Teste:
test/application/use-cases/sessions/conversa-em-sessao-encerrada.spec.ts:144(sessão encerrada recusachat.messagecom 409 nomeado — caso de falha),:167(sem evento e semseqconsumido),:183(Psicólogo e Anamnese continuam entrando),:211(decisão sobre ação continua entrando),:223(sessão ativa aceita — caminho feliz),:244(closingaceita),:258(SendAgentMessagenão chama o engine),:263(nenhum handoff órfão),:276(o aceite não ativa ninguém),:309(a ativação não sobe o agente);test/application/use-cases/sessions/get-session-pending-work.use-case.spec.ts:478(o Criativo respondeu: pendente com o instante),:519(o usuário falou por último),:546(quem não conversa não segura),:563(sinal sem teto vence);apps/engine/test/engine/sessions/session_lifecycle_test.exs:87(dentro do teto, reagenda),:106(acima do teto,conversation_idle_timeout,closed),:124(o default é 8h),:151(pendência sem instante segue sem teto),:152e:194(AT-152: com a aba aberta — pings — a conversa ociosa além do teto fecha, e dentro do teto não fecha);apps/engine/test/engine/sessions/pendencia_de_conversa_test.exs:28(instante inválido vira erro);apps/engine/test/engine/agents/conversacionais_test.exs:60(para só os da sessão),:85(AT-204: pid já morto no Registry não entra na lista de parados),:126(session.closedpara o Criativo vivo);apps/engine/test/engine/agents/turno_assincrono_test.exs:161(abandonar não grava nem transmite, e não responde de novo),:194(oterminate/2abandona);apps/engine/test/engine/psychologist/termination_classifier_test.exs:20 -
Origem: AT-072 —
exp001; decisões do mantenedor em 2026-09-18 (teto de 8h e causa própria; sessão encerrada recusa conversa, nomeado)
O repositório nasce no aceite do handoff ao Arquiteto, e a execução não começa sem ele (RN-582)
RN-582 — O aceite do handoff ao Arquiteto provisiona o repositório; o aceite ao Dev Lead é a segunda porta, idempotente; e execution/activate sem repositório é 409 nomeado
Revisa o GATILHO da RN-522, que cumpria a promessa da
RN-541. O mecanismo não muda — o mesmo ProvisionRepositoryUseCase,
chamado pelo mesmo AcceptHandoffUseCase, sempre local, com a falha virando
repository.provision_failed e nunca derrubando o aceite. Decisão estrutural
no ADR 0165.
O que a instalação real mostrou (AT-092, v6.1.0, 2026-09-14). O Arquiteto
propôs open_adr_pr três vezes antes de o repositório existir; os handoffs a
infra e dev-lead foram oferecidos juntos, só o de infra foi aceito, e
POST /projects/:id/execution/activate respondeu 201. As tabelas
project_repositories, project_git_connections e repo_bootstraps ficaram
vazias — o projeto sem repositório para sempre. Dois defeitos: o gatilho vinha
DEPOIS de quem precisa do repositório (Arquiteto e Infra Lead escrevem nele
antes do Dev Lead), e o único gatilho era PULÁVEL (ativar a execução não exige
o aceite ao Dev Lead).
1. O gatilho é o aceite do handoff ao arquiteto. O Arquiteto só é ativado
por handoff accepted endereçado a ele (canActivateAgent), e esse handoff
nasce do PO (offer_handoff(to_agent: "arquiteto")) ou do usuário (handoff
manual) — as duas portas passam pelo mesmo aceite. O provisionamento roda
ANTES de activateAgent: o Arquiteto acorda num projeto que já tem onde
escrever. Consequência: o Infra Lead, ativado por handoff DO Arquiteto, nunca
mais acorda sem repositório.
2. O aceite ao dev-lead continua provisionando, como SEGUNDA PORTA. É a
saída do projeto que passou pelo Arquiteto antes desta regra (o exp001 da
AT-092: Arquiteto aceito, Dev Lead offered) e a retomada natural de um
provisionamento que falhou no aceite ao Arquiteto. Com o repositório created
já de pé, a segunda chamada não cria nada — nem repositório, nem sessão, nem
linha de bootstrap, nem branch, nem commit —, e isso está provado pelas DUAS
portas em sequência contra o Postgres e o LocalGitProvider de verdade.
3. execution/activate sem repositório é RECUSADO com 409, logo depois da
checagem de module_map e ANTES de qualquer efeito (orçamento persistido,
permissions.json, sessão, engine, evento). A frase é escolhida pelo estado dos
handoffs do projeto, lidos por HandoffRepository.findByProject (de todas as
sessões — o handoff ao Arquiteto mora na do PO): handoff ao Arquiteto
offered → aceite-o; senão ao Dev Lead offered → aceite-o; senão algum dos
dois já accepted → o provisionamento não deixou repositório, veja o
repository.provision_failed e use a página de provisionamento
(/projects/:id/provisioning?provider=local); senão → nenhum handoff ao
Arquiteto foi aceito. Um handoff oferecido a OUTRO agente nunca vira instrução
de aceite (aceitar a Infra não provisiona nada). Repositório ADOTADO conta como
repositório. Recusar e não provisionar aqui é decisão: provisionar dentro da
ativação esconderia um efeito de git, e os agentes anteriores a ela seguiriam
sem repositório.
4. A tela tira o controle e diz o motivo uma vez, em texto (a régua da
RN-102 e do ADR 0064). Na seção de Execução da Visão Geral, com o
GET .../git/repository respondendo null CONFIRMADO, "Ativar execução" fica
inerte no lugar, o texto diz onde o repositório nasce, e há um link para
provisionar. No card do handoff ao Dev Lead, dentro da sessão, o atalho
"Ativar execução" sai e o card diz que aceitar aquele handoff provisiona o
repositório. Carregando ou com erro, nada tranca — "não sei" não vira "não
tem", e o backend recusa de qualquer forma. Aceitar um handoff invalida a
consulta do repositório, porque o aceite pode tê-lo criado.
A sessão técnica não desloca a de trabalho (AT-131). A sessão que o
provisionamento abre (repo_bootstraps.session_id, nome git-bootstrap) nasce
no MEIO da fase do Arquiteto, e escolher "a mais recente" só por createdAt a
punha no lugar da sessão de trabalho até a próxima nascer (ADR 0165 deixou isso
declarado). Agora as duas leituras por createdAt do resumo do workspace
(latestSessionId e o corte das não lidas) ordenam por "é sessão de bootstrap"
ANTES de createdAt, e a Visão Geral (useLatestSession) faz o mesmo. A técnica
só é a mais recente quando é a ÚNICA — no fluxo manual de provisionamento é nela
que o projeto começa. A api lê o marcador por repo_bootstraps; a web só tem o
NOME da sessão, e renomeá-la faz as duas divergirem (a Visão Geral então trata o
resumo como de outra sessão e não o usa). O critério não muda o que o ADR 0165
decidiu (onde o repositório nasce), por isso não há ADR novo.
Revista pela RN-592 (AT-183): a web deixou de ler o NOME — a listagem de sessões devolve o marcador
technical, pelo mesmo vínculo emrepo_bootstraps, e renomear a sessão não faz mais as duas divergirem.
A recusa da ativação não manda aceitar handoff de sessão encerrada
(RN-581, AT-131). Aceitar handoff numa sessão terminal é 409
sessao_encerrada, checado ANTES de o handoff virar accepted — então nada é
provisionado pela metade. A frase da recusa agora consulta o estado da sessão de
cada handoff offered: só o que mora em sessão ABERTA vira "aceite-o"; se o
único offered está em sessão encerrada, a frase diz isso e aponta a página de
provisionamento. Sessão não encontrada conta como aberta ("não sei" não vira
"encerrada"). Os eventos que o provisionamento grava não caem na RN-581: o ator
é system e o destino é a sessão git-bootstrap, recém-criada. O que segue
NÃO coberto: a Visão Geral e o card do handoff na sessão lêem o estado do
repositório, não o de cada sessão, e não mudaram.
- Onde:
src/application/use-cases/agents/accept-handoff.use-case.ts:66(AGENTES_QUE_PROVISIONAM_O_REPOSITORIO, gatilho e segunda porta) e:141(o ramo, antes deactivateAgent);src/application/use-cases/execution/activate-execution.use-case.ts:143(a recusa, antes de qualquer efeito);src/domain/execution/repositorio-para-executar.ts:50(motivoDeExecucaoSemRepositorio, e os dois nomes de agente que o aceite também usa);src/infrastructure/persistence/drizzle/handoff.repository.ts:55(findByProject);apps/web/src/routes/ProjectOverviewTab.tsx:369e:511;apps/web/src/routes/SessionPage.tsx:147;apps/web/src/routes/session-timeline-montagem.tsx:495;apps/web/src/lib/session-acoes-de-handoff.ts:122;apps/engine/lib/engine/projects/project_repository.ex:74(o texto da recusa da RN-577, que nomeava o gatilho antigo e muda junto) - Teste:
test/application/use-cases/agents/accept-handoff.use-case.spec.ts("aceitar para o Arquiteto provisionalocalcom o slug do projeto", "o repositório nasce ANTES de o Arquiteto acordar", "aceitar para o Dev Lead continua provisionandolocal(segunda porta)");test/application/use-cases/agents/accept-handoff-provisiona-uma-vez.spec.ts(as duas portas contra o banco: "UM createRepo, UMA linha, nenhuma falha", e "o exp001: Arquiteto aceito SEM repositório, e a segunda porta provisiona");test/application/use-cases/execution/activate-execution.use-case.spec.ts:725("sem repositório: 409, e NENHUM efeito antes da recusa", "a frase nomeia o handoff ao Dev Lead pendente — a saída do exp001", "repositório ADOTADO conta como repositório");test/domain/execution/repositorio-para-executar.spec.ts;test/infrastructure/persistence/handoff-por-projeto.repository.spec.ts;apps/web/src/routes/ProjectOverviewTab.test.tsx:409,:427e:440;apps/web/src/routes/SessionPage.handoff-devlead-e-colapso.test.tsx:575;apps/engine/test/engine/agents/arquiteto_server_test.exs:134eapps/engine/test/engine/infra/infra_lead_server_test.exs:520(a recusa nomeia o gatilho novo) - Origem: AT-092 — instalação real da v6.1.0 em 2026-09-14, decidido pelo mantenedor em 2026-09-18
RN-583 — O critério de roteamento do hub é do binding, viaja com ele, e congela no metering
Um binding de modelo pode guardar um critério de roteamento —
routing_preference: price, throughput ou latency — que diz ao HUB
como escolher o upstream que serve o modelo. Anulável: null é o
comportamento de sempre, nada vai ao fio e o hub decide sozinho.
- Viaja com o binding que venceu, nunca cascateia sozinho. A cascata continua sendo de binding: o nível vencedor traz o modelo E o critério dele. O nível que a cascata PULA (modelo indisponível, sem tool calling) leva o critério junto — sobreviver só o critério produziria combinação que ninguém escolheu.
- Escrita em três formas. No
PUT .../model-bindingdos cinco escopos,routingPreferenceausente preserva o gravado se o provider do modelo novo declara a capability, e viranullse não declara;nulllimpa; valor para provider sem a capability é 422 (RoutingPreferenceNotSupportedError). Invariante: nenhum binding guarda critério para provider que não o declara. - Congela o que FOI AO FIO.
token_usage.routing_preferenceé gravado por chamada, ao lado doupstream_providerda RN-042: o critério enviado, enullquando o binding não tinha ou quando o provider não declara a capability (o que não foi enviado não é procedência de nada). Mesma régua do preço congelado (ADR 0042). - Capability de PROVIDER, só com prova.
LLMProviderCapabilities.routingPreferenceé obrigatória nos nove, e étrueSÓ no OpenRouter desde 2026-09-29 (AT-158): o smoke com credencial real (openrouter-provider.roteamento.smoke.spec.ts) mostrou o critério MUDANDO o upstream que serviu — emmeta-llama/llama-3.3-70b-instruct,pricepousou na DeepInfra ethroughputna Groq (latencyfoi Groq numa medição e CoreWeave na outra), enquanto sem critério o hub alternou Novita/DeepInfra. A prova é a DIFERENÇA, não o aceite: o modelo do default antigo (~deepseek/deepseek-v4-flash-latest) devolveu o MESMO upstream para os três critérios e sem critério, e isso não distingue "o hub respeitou" de "não havia escolha". Os outros oito seguemfalse, e para eles a feature continua DORMENTE: a rota recusa com 422, a tela diz em texto que o provider não tem a opção provada, e nada muda no fio. O que a prova NÃO afirma: qual upstream cada critério escolhe — isso é decisão do hub e muda com o tempo.
- Onde:
src/domain/llm/routing-preference.ts:56(escrita),src/domain/llm/routing-preference.ts:75(o que vai ao fio),src/domain/llm/binding-resolver.ts:100(viaja com o binding),src/application/use-cases/llm/record-llm-usage.use-case.ts:118(congela no metering),src/infrastructure/llm/openrouter-provider.ts:353(openrouterConfig, a capability provada) - Teste:
test/domain/llm/routing-preference.spec.ts,test/application/use-cases/llm/set-model-binding.use-case.spec.ts,test/application/use-cases/llm/run-llm-turn.use-case.spec.ts,test/infrastructure/llm/openrouter-provider.contract.spec.ts; a prova manual da capability étest/infrastructure/llm/openrouter-provider.roteamento.smoke.spec.ts - Origem: ADR 0166 (AT-090)
RN-590 — A pasta docs/ dos artefatos é reconstruída do event log pelo MESMO tradutor do projetor vivo, sem tocar a outbox, e nunca sai como sucesso calado
O ADR 0148 chamou a pasta docs/
de reconstruível e declarou, no custo 3, que não existia comando de
reprojeção: apagar a pasta não a reconstruía, porque as linhas de outbox já
estavam marcadas. É a mesma lacuna que a RN-569 fechou para o grafo,
num segundo consumidor. Medido por leitura do código: um restore do Postgres, a
perda do volume project_workspaces e a conversão de execution_mode (que não
toca docs/) deixam a pasta ausente ou no lugar antigo, e nada a reescreve.
A regra: src/scripts/reprojetar-artefatos.ts
(pnpm --filter api artefatos:reprojetar; node scripts/reprojetar-artefatos.js
na imagem) varre session_events e reescreve a pasta. Como na RN-569:
- Um tradutor só. A tradução evento → pasta do agente, nome do arquivo e
Markdown SAIU do
ArtifactProjectorparaArtifactEventTranslator; o projetor chega ao evento por uma linha de outbox, a reprojeção por cursor, e os dois o chamam. A superfície do projetor (construtor,drainOnce) não mudou. A lista do que é projetado continua sendoARTIFACT_PROJECTABLE_EVENT_TYPES— tipo novo entra ali e é traduzido no mesmo lugar, nunca num segundo tradutor. - Idempotente e nunca apaga. O nome é função do evento (versionado:
<tipo>.md; append-only: com oseq), então rodar de novo regrava os mesmos arquivos; retomar é rodar de novo, ou--after-eventa partir do cursor impresso. Arquivo que o usuário pôs emdocs/fica. - Não toca a outbox (
aggregate_type = 'artifact_projection'): roda com a api de pé sem roubar, reabrir ou marcar linha. - Nunca sucesso calado. Diferente do grafo, não há destino que pare tudo
(falha de escrita é do ITEM, ADR 0148): cada falha é contada e nomeada com o
id do evento, o resto é gravado, e o processo sai com 1.
--projectinexistente é recusado sem gravar.
O que esta regra NÃO fecha: (a) os versionados (module_map,
module_routing, project_image, c4_diagram) mostram o ÚLTIMO evento em
ordem de id; --after-event começando entre duas versões pode deixar uma
mais antiga — só a rodada completa garante o vigente; (b) escreve na localização
ATUAL do projeto, e precisa enxergar o mesmo disco que a api; (c) não recria
qa_verdict/secops_verdict/task_blocked/infra_delegation_files, que nunca
foram arquivo; (d) sem medição de tempo em event log grande; (e) NÃO entra em
backup — é derivada, o argumento do ADR 0152.
- Código:
src/application/artifact-projection/artifact-event-translator.ts:21(ArtifactEventTranslator),:24(projetarEvento);src/application/artifact-projection/artifact-projector.ts:64(o projetor monta o MESMO tradutor),:131(delega);src/scripts/reprojetar-artefatos.ts:96(reprojetarArtefatos),:115(o tradutor),:137(varredura por cursor sobresession_events),:163(a falha contada sem abortar),:185(lerArgumentos),:241(só a invocação direta reprojeta);package.json:45(artefatos:reprojetar) - Teste:
test/scripts/reprojetar-artefatos.spec.ts:215(projetor para frente → apaga a pasta → reprojeta → mesmos caminhos e conteúdos; segunda rodada idêntica; outbox intacta),:254(nunca apaga),:265(por projeto),:275(projeto inexistente — caso de falha),:289(falha de escrita contada sem abortar o resto — caso de falha),:314(argumento desconhecido);test/application/artifact-projection/artifact-projector.spec.ts(o projetor para frente, inalterado, sobre o tradutor extraído) - Origem: AT-128 — a lacuna que o ADR 0148 declarou (custo 3)
RN-591 — A instalação sem broker é dita na conversão de modo, em parar/remover e na proposta do Infra Lead, pela mesma fonte da RN-574
A RN-573/RN-574 (ADR 0161) ensinaram a criação de
projeto e a subida na /containers a saber que a instalação não tem
broker (BROKER_URL vazia). O fechamento declarou fora o resto: a conversão de
modo, parar/remover e o Infra Lead. Esta regra leva as três portas, com a MESMA
fonte — ContainerBrokerPort.configurado() — e nenhuma rota nova. Ela diz que
a variável EXISTE, nunca que o broker responde (isso segue sendo
naoObservado, RN-486).
- Conversão de modo (
ExecutionModeSection). O campobrokerConfiguradojá vinha emGET .../projects-base, que a seção já lê. Converter paracontainer/mountedcom a ausência CONFIRMADA (false) deixa o botão inerte e diz o motivo em texto; "consultando", consulta que falha e campo ausente NÃO afirmam nada — "não sei" não vira "tem" nem "não tem" — e não bloqueiam. Converter pararunner, ou para o modo que o projeto já tem, nunca é bloqueado. É regra de TELA, como a da criação: oPUT projects/:projectId/execution-modenão a aplica. - Parar/remover na
/containers.container_stop/container_removeemcontainer/mountedtambém passam pelo broker (RN-495), então sem ele só podem terminar em falha: a tela deixa "Parar"/"Remover" inertes com texto próprio (sem_broker_para_parar),!== truecomo na subida.runnernunca cai aqui. - A api recusa ao propor.
ProposeActionUseCaseresponde 409sem_broker_na_instalacao, ANTES de criar a proposta, paracontainer_start/container_stop/container_removeem projeto que não érunnerquandoconfigurado()éfalse. É a decisão de COMO o Infra Lead sabe sem HTTP no laço: o engine não lêBROKER_URL(uma segunda fonte divergiria daconfigurado()), e a chamada que a tool JÁ fazia devolve a recusa.dispatch_container_start/2passou a entregar ao modelo o TEXTO da mensagem da api (container_start recusado: …), como resultado de ferramenta (RN-163), e não a tupla crua. A RN-566 segue como estava: recusa por MODO, local.
O que NÃO fecha: a proposta às cegas por ausência de IMAGEM continua
possível (a RN-566 já declarava); a recusa da api vale também para
quem chama sem ser o agente, o que é o desejado — a aprovação só terminaria
failed; e o PUT execution-mode continua aceitando a conversão por chamada
direta.
- Código:
src/application/use-cases/actions/propose-action.use-case.ts:90(recusa 409),apps/web/src/routes/containers-subida.ts:181(semBrokerParaCicloDeVida) e:192(conversaoSemBroker),apps/web/src/routes/settings/ExecutionModeSection.tsx(botão inerte),apps/engine/lib/engine/infra/infra_lead_server.ex:886(motivo_da_recusa_da_api) - Teste:
test/application/use-cases/actions/propose-action.use-case.spec.ts:207(409 nas três ações, econtainer_stopseguependingcom broker),apps/web/src/routes/containers-subida.test.ts(as duas funções, três estados),apps/web/src/routes/settings/conversao-de-modo.test.tsx,apps/web/src/routes/ContainersPage.test.tsx,apps/engine/test/engine/infra/infra_lead_server_test.exs(o texto do 409 chega ao modelo) - Origem: AT-105 — declarado fora do recorte pelo ADR 0161
RN-592 — A listagem de sessões diz qual é a técnica, pelo vínculo em repo_bootstraps, e a tela lê o marcador em vez do nome
A RN-582 (AT-131) fez a sessão técnica do provisionamento — a de
repo_bootstraps.session_id — deixar de deslocar a sessão de trabalho como "a
mais recente", mas com DOIS critérios: a api a reconhecia pelo vínculo em
repo_bootstraps, e a web pelo NOME (git-bootstrap). O nome é rótulo que o
usuário renomeia (RN-098), então renomear a sessão fazia a Visão Geral
e o resumo do workspace discordarem de qual era a sessão de trabalho.
- A api devolve o marcador. Cada item de
GET /projects/:projectId/sessionscarregatechnical: boolean(SessionListItemResponseDto),truesó para a sessão derepo_bootstraps.session_idDAQUELE projeto (uma por projeto,unique(project_id)). É o MESMO critério do resumo do workspace (sessaoTecnicaDeBootstrap), nunca o nome: renomear não muda o marcador, e uma sessão de trabalho chamadagit-bootstrapnão vira técnica. - Só a listagem o carrega. O marcador não é coluna de
sessionsnem campo da entidadeSession: o GET de uma sessão e as respostas de criar/renomear/transicionar seguem comSessionResponseDto, sem ele. É a listagem que a tela ordena para achar "a mais recente". - A tela lê o marcador.
sessaoMaisRecente(useLatestSession) pula a sessão comtechnical: truee só a devolve quando é a ÚNICA — a regra da RN-582 inalterada, com a fonte trocada. O tipoSessaoListadado web tira o campo do tipo GERADO do OpenAPI.
O que NÃO fecha: a Visão Geral e o card do handoff na sessão continuam sem consultar o estado da sessão de cada handoff (a lacuna que a RN-582 declarou); decidir se passam a consultá-lo é decisão à parte, fora desta regra.
- Código:
src/application/use-cases/sessions/list-sessions-for-project.use-case.ts:30(execute),src/interfaces/http/sessions/dto/sessions.response.dto.ts:110(SessionListItemResponseDto),apps/web/src/lib/hooks.ts:147(sessaoMaisRecente) - Teste:
test/application/use-cases/sessions/list-sessions-for-project.use-case.spec.ts:81(vínculo e não nome; técnica de outro projeto não marca),apps/web/src/lib/sessao-mais-recente.test.ts:28(renomeada continua fora) e:36(trabalho chamadagit-bootstrapnão é técnica) - Origem: AT-183 — achado da AT-131
RN-595 — Toda _PREVIOUS que um serviço lê chega a ele pelos três composes, vazia por padrão
As três rotações sem downtime (AUTH_JWT_SECRET, BRABO_SERVICE_TOKEN,
CREDENTIALS_MASTER_KEY) dependem de o processo ver o valor antigo numa
variável _PREVIOUS durante a janela. O Compose não repassa o ambiente do
host, e nenhuma das três estava no environment: de compose nenhum: definir a
_PREVIOUS no .env não tinha efeito, e quem seguia o runbook fazia, sem
saber, a troca seca que a rotação existe para evitar. Agora a api recebe as
três, e o engine e o broker recebem BRABO_SERVICE_TOKEN_PREVIOUS, nos composes
de dev, de produção e de instalação, sempre com default VAZIO — definida é
rotação em andamento, e um default preenchido deixaria a instalação
eternamente no meio de uma. Vazia é o mesmo que ausente nos três leitores.
- Onde:
docker/docker-compose.yml,docker/docker-compose.prod.ymledocker/docker-compose.install.yml(serviçosapi,engineebroker); os leitores sãosrc/infrastructure/security/auth-key-material.ts:136(passphraseAnterior),src/infrastructure/security/service-token.ts:92(tokenDeServicoAnterior),src/infrastructure/security/envelope-encryption.service.ts:93,apps/engine/config/runtime.exs:70eapps/broker/src/config.ts:85 - Teste:
scripts/ci/previous-nos-composes.spec.ts— DERIVA do código dos três serviços a lista de_PREVIOUSlidas e reprova a que faltar, ou vier com default não vazio, em qualquer um dos três composes; o bloco "ExternalSecret do k8s" reprova oExternalSecretsemdataFrom.extract, uma_PREVIOUSemdata:e o Deployment de api/engine semenvFrom: brabo-secrets - Kubernetes (AT-220, decisão do mantenedor): os Pods leem
envFrom: brabo-secrets, e oExternalSecret(deploy/k8s/base/common/externalsecrets.yaml) puxa o objetobraboINTEIRO do store pordataFrom.extract— a_PREVIOUSentra quando existe e sai quando é retirada, na sincronização seguinte. Não pode ser listada emdata:: o ESO não tem chave opcional, e propriedade ausente (o estado normal) reprova a sincronização do Secret inteiro. Custo declarado: TODA chave do objeto vira variável de api, engine, migrações e backup, e a chave OBRIGATÓRIA ausente deixou de reprovar a sincronização — quem acusa é o consumidor. A sincronização por um ESO real foi exercitada à mão num k3d descartável, não em CI - Origem: AT-201 (achado da AT-196), mesma classe da RN-540; metade Kubernetes na AT-220
RN-597 — Trocar o pepper é logout global
O pepper que chaveia o HMAC dos tokens opacos não tem _PREVIOUS, e trocá-lo
tem consequência, não procedimento: todo refresh token em circulação, todo link
de verificação de e-mail e de redefinição de senha em aberto e todo token de
acesso pessoal (PAT) deixam de ser achados, porque o hash recalculado com o
pepper novo não casa com o gravado. A refresh recusada registra
refresh_unknown (e não refresh_reuse_detected), o link diz "Link inválido
ou expirado", e os baldes de lockout mudam de chave — conta travada destrava. A
SENHA sobrevive (argon2id com salt por registro, sem pepper): trocar o pepper
custa um login, nunca uma conta, e a api sobe normalmente com o valor novo.
Até a RN-613, o pepper sem valor próprio caía no
AUTH_JWT_SECRET, e a rotação do JWT era também a do pepper, com todas as
consequências acima. Desde ela o pepper é obrigatório em produção e nunca
emprestado do JWT.
- Onde:
src/infrastructure/security/auth-key-material.ts:212(pepper),:228(hashDeToken),:247(baldeDeEmail);src/interfaces/http/auth/pat-auth.guard.ts:142 - Teste:
test/application/use-cases/auth/rotacao-dos-segredos.spec.ts, contra o Postgres de teste - Borda: o ALVO de "trocar" inclui o erro de quem separa o pepper do JWT gerando um valor novo em vez de copiar o atual — é a mesma troca, e desloga do mesmo jeito (fixado no mesmo spec, "a ordem errada").
- Origem: ADR 0031, AT-196
RN-598 — O token de serviço ANTERIOR passa pela mesma régua do atual
Durante a rotação, BRABO_SERVICE_TOKEN_PREVIOUS abre /internal/* tanto
quanto o atual. Ele passa por UMA régua, a mesma do atual (exigirTokenDeProducao),
e nenhuma cópia dela. O espaço em volta é descartado, e um valor feito só de
espaço conta como ausente. Em produção, o literal público de desenvolvimento
(dev-service-token-change-me) e um valor com menos de 16 caracteres depois
do trim DERRUBAM o boot da api, com uma mensagem que nomeia
BRABO_SERVICE_TOKEN_PREVIOUS. Antes disso, a variável era comparada crua: um
anterior com o valor de exemplo abria a porta interna a qualquer um que
tivesse lido este repositório, e sem dar erro nenhum. A obrigatoriedade é a
única parte da regra do atual que NÃO é copiada, porque fora da rotação o
anterior ausente é o estado normal. Um anterior igual ao atual (depois do
trim) continua não contando como rotação.
- Onde:
src/infrastructure/security/service-token.ts:63(exigirTokenDeProducao),:92(tokenDeServicoAnterior);src/main.ts:50(tokenDeServicoAnterior), que roda no boot ao lado do atual - Teste:
test/infrastructure/security/service-token.spec.ts(describe "tokenDeServicoAnterior (RN-598)") - Borda: esta RN é o lado da api. O engine e o broker aplicam a mesma régua desde a RN-601.
- Origem: AT-205 (achado da AT-196)
RN-603 — O modo automático libera o escopo de caminho, e só ele
Decisão do dono do produto em 2026-09-26 (AT-226,
ADR 0167): com o
"Modo automático" ligado num agente — a curinga "*" da
RN-153 resolvida como auto_approve —, o agente roda QUALQUER
comando de terminal sem pedir aprovação, INCLUSIVE fora da pasta do projeto.
O teto de escopo de caminho do ADR 0055 deixa de valer para esse agente.
Medido no exp001 (modo mounted, container de pé): 51 comandos pediram
aprovação com o modo ligado, 47 só por citar /work ou /tmp — o dev agent
roda no container e o escopo compara com a raiz do HOST.
decide() passa a receber a ORIGEM da autonomia (ctx.autonomyOrigin:
'especifica' ou 'curinga'), resolvida pelo MESMO repositório que já
resolvia a precedência — findMode virou leitura de resolve, não uma
segunda régua. Só a curinga em auto_approve é modo automático
(modoAutomaticoDoAgente); origem ausente vale como específica. Em modo
automático, dois pedidos deixam de existir: o teto de ESCOPO e o
require_approval que o comando COMPOSTO sintetiza quando um segmento não tem
regra nenhuma em permissions.json (o arquivo sem opinião, que nunca rebaixa
um estágio anterior) — sem o segundo, cd /work && npm test seguiria pedindo.
Continua tudo como estava, em modo automático também: deny (do arquivo, dos
padrões embutidos ou de autonomia específica) vence; um ask ESCRITO no
arquivo pede; o teto de efeito externo/comando privilegiado (push, PR,
deploy, sudo/doas, RN-418) roda antes e não
olha a origem; merge em branch protegida, instruction_patch,
parallelize/raise_max_parallel e container_remove nunca são
auto-aprováveis (RN-154). Regra específica do tipo vence a curinga
no repositório e chega como 'especifica': terminal: require_approval com a
curinga ligada segue pedindo. Desligar (o toggle do card do agente grava a
curinga como require_approval) restaura o teto. Desde a
RN-670 (ADR 0189) a específica auto_approve
sob a curinga auto_approve — o que "Sempre permitir" de dev agent grava —
resolve como a CURINGA, e não desliga mais o modo automático; e, fora dele, o
escopo compara com a pasta REAL de execução (RN-669).
A tela diz, antes do clique, o que o modo libera e o que continua pedindo: a
nota do ApprovalCard aparece nas DUAS variantes (chat e fila de Aprovações),
e o card do agente mostra uma frase sob o toggle — só quando a CURINGA está
ligada, porque o toggle sobre o tipo representativo grava regra específica,
que não libera o escopo.
- Onde:
src/domain/actions/decide.ts:311(modoAutomaticoDoAgente),src/domain/actions/decide.ts:386(o veredito sintetizado do composto ignorado),src/domain/actions/decide.ts:504(o teto de escopo pulado),src/infrastructure/persistence/drizzle/agent-autonomy.repository.ts:25(resolve, com a origem),src/application/use-cases/actions/propose-action.use-case.ts:249(autonomyOrigin),apps/web/src/components/AgentTeamGrid.tsx:114(autonomyHint),apps/web/src/components/ApprovalCard.tsx:313(a nota nas duas variantes) - Teste:
test/domain/actions/decide.spec.ts("modo automático libera o escopo de caminho (RN-603)": fora do escopo e composto auto-aprovam; push/sudo/deny/askescrito seguem; regra específica e curinga desligada não liberam; os outros tetos não mudam),test/application/use-cases/actions/propose-action.use-case.spec.ts(ponta a ponta com o repositório real),test/infrastructure/persistence/drizzle/agent-autonomy.repository.spec.ts(resolvedevolve a origem),apps/web/src/components/AgentTeamGrid.test.tsx,apps/web/src/components/AgentCard.test.tsx,apps/web/src/components/ApprovalCard.test.tsx - Origem: decisão do dono do produto (AT-226) depois do teste no
exp001
RN-604 — A chamada ao broker tem um teto POR OPERAÇÃO, e o teto estourado diz que foi o teto
O cliente HTTP do broker aplicava UM teto, 5 s, às cinco operações. O
docblock o justificava pelo caminho de LEITURA de tela (RN-486), e
ele valia também para exec — cujo pedido CARREGA o próprio timeoutMs — e
para start, que espera o docker run e o pull de imagem que ele faz.
Reproduzido na AT-233: um broker falso que responde um exec em 6 s, com
timeoutMs de 8 s, voltava como BrokerIndisponivelError('sem-resposta') —
"o broker de container não respondeu … The operation was aborted due to
timeout". Ou seja, todo comando de terminal com mais de 5 s num projeto
container/mounted com container running (RN-492) falhava
dizendo que o broker estava fora do ar, enquanto o comando seguia rodando.
E o engine cortava também: a chamada container-exec não passava
receive_timeout e caía no default do Req, 15 s — o MESMO default de
TERMINAL_ACTION_TIMEOUT_MS. Reproduzido com uma api falsa (Bandit) que
responde em 16 s: {:error, %Req.TransportError{reason: :timeout}}. Subir o
teto só na api não consertaria nada.
A regra:
inspectsegue curto (5 s) — é a leitura da/containerse da rota de ciclo de vida, e uma tela que espera o pior caso do broker é pior do que uma que declara "não observado".execDERIVA dotimeoutMs(ou do default de 15 s do broker, quando vem sem ele): contexto do broker (10 s) +ps(30 s) + o maior entre o comando e odocker versionde diagnóstico (30 s) + 5 s de margem. O broker corta o comando emtimeoutMse RESPONDEtimedOut: true; a api tem de estar esperando quando a resposta chega.start/stop/removeesperam o pior caso do PRÓPRIO broker: contexto +ps+ a operação (oruncom o pull; ostopcom os 10 s de graça;rm --force) + odocker versionque ele roda quando ela falha, mais a margem. Esperar isso é esperar a RESPOSTA do broker, inclusive a recusa nomeada dele. A conta nasceu com 105 s e esquecia o SEGUNDOpsdoresolver(o gerenciado e o homônimo): o pior caso real era 130 s. Desde a RN-605 ela conta as seis chamadas de controle dostarte dá 195 s.- O engine espera mais que a api:
timeoutMs+ 90 s. A cadeia é broker < api < engine, e os testes dos dois lados a conferem (a api espelha a folga do engine comoFOLGA_DO_EXEC_NO_ENGINE_MS, e o teste do engine lê esse número do fonte da api). - Teto estourado tem motivo próprio:
BrokerIndisponivelErrorganhateto-excedido, distinto desem-resposta(conexão recusada, DNS), e a mensagem nomeia a operação e o número — e, nas longas, a consequência: o comando pode seguir rodando no container; orun/pull pode ter acontecido, então confira/containersantes de repetir. Na leitura da telateto-excedidocai no MESMOnaoObservado: 'broker-sem-resposta'de sempre, com o texto nodetalhe— nenhum valor novo no DTO.
Os números do outro lado (TIMEOUT_DE_CONTROLE_MS,
TIMEOUT_DE_EXEC_PADRAO_MS, o TIMEOUT_MS do cliente de contexto do broker)
são ESPELHADOS em constantes nomeadas, não importados: a api não consome o
pacote da porta de Docker. Nada muda na contenção do broker (ADR
0130), na DockerPort nem na
RN-502.
Declarado, não fechado:
-
Pull de mais de 30 s continua falhando — no broker. O pull roda sob o
TIMEOUT_DE_CONTROLE_MS(30 s) da porta de Docker, e uma imagem grande não sobe na primeira tentativa. Desde a RN-605 o estouro tem NOME (PullExcedeuTetoError, origeminfra) em vez de "código -1" comorigem: null; o teto em si não subiu, por decisão do mantenedor. -
O
fetchdo Node espera cabeçalhos por no máximo 300 s. UmexeccomtimeoutMsacima de ~255 s é cortado pelo undici antes do teto desta regra; o erro saiteto-excedidonomeando os 300 s. O salto de fora (/internal/actions/execute, api → engine) também não tem teto explícito e cai nos mesmos 300 s, entãoTERMINAL_ACTION_TIMEOUT_MSacima de ~210 s não chega inteiro. -
Broker doente no
ps(odocker pslevando os 30 s inteiros e o diagnóstico outros 30) pode exceder o teto doexeccom comando curto; saiteto-excedido, com o nome certo. -
Código:
src/infrastructure/http-clients/container-broker.client.ts:287(tetoDaOperacao),:252(TETO_DE_MUTACAO_MS),:264(FOLGA_DO_EXEC_NO_ENGINE_MS),:324(erroDeTransporte);src/application/ports/container-broker.port.ts:110(MotivoDeBrokerIndisponivel);apps/engine/lib/engine/sessions/engine_api_client.ex:2120(teto_do_container_exec_ms)apps/engine/lib/engine/sessions/engine_api_client.ex:2120(teto_do_container_exec_ms) -
Teste:
test/infrastructure/http-clients/container-broker.client.spec.ts:286(a reprodução, contra um brokernode:httpque demora 6 s),:182(o teto de cada operação noAbortSignal),:204e:229(teto-excedidonomeando operação e número),:245(o teto do undici),:330(describetetoDaOperacao, incluindo a ordem broker < api < engine);test/application/use-cases/containers/spec-e-observacao-de-container.use-case.spec.ts:298(a tela continua dizendobroker-sem-resposta);test/application/use-cases/containers/executar-comando-no-container.use-case.spec.ts:98;apps/engine/test/engine/sessions/engine_api_client_container_exec_test.exs:72(a reprodução do lado do engine, HTTP real com Bandit),:91(a folga espelhada na api) -
Origem: AT-233
RN-609 — proposed_action.created diz QUAL raiz o escopo comparou, relativa e nunca absoluta
A RN-567 pôs no evento a REGRA que decidiu (reason) e declarou o
que ficava de fora: o ponto 7 do
ADR 0055 pede qual
escopo autorizou, e escopo: cd dentro da pasta do projeto não diz qual
pasta era o escopo naquele dia. A raiz muda — conversão de modo, base
nova —, e o evento é o único lugar que guarda o que valia no instante da
decisão.
A regra: o payload do evento de SESSÃO proposed_action.created de uma
ação terminal ganha scopeRoot, com três campos — executionMode,
ancora e segmento:
| modo | ancora |
segmento |
|---|---|---|
container |
raiz_gerenciada |
o workspace_dir_name — a raiz É <PROJECT_WORKSPACES_ROOT>/<segmento> |
mounted |
base_de_projetos |
o pedaço sob BRABO_PROJECTS_BASE, pela MESMA segmentoSobABaseDeProjetos que o broker usa |
mounted (sem base, fora dela, ou a própria base) |
indisponivel |
null |
runner |
nome_da_pasta |
o workspace_dir_name — identifica a pasta sem ser relativo a nada que o servidor conheça |
Nunca o caminho absoluto, e isso é a decisão (AT-147, do mantenedor). O
event log é lido por todo membro do projeto e evento não se reescreve; em
mounted/runner a raiz é uma pasta da máquina do usuário, com o $HOME
dele no caminho. Por isso indisponivel existe: cair no caminho absoluto
"para não perder informação" seria exatamente o vazamento. No runner a base
local do agente nunca atravessa a rede, então não há segmento relativo que o
servidor possa calcular — a ancora diz que o valor é um NOME e não um
caminho.
Só em terminal. É o único tipo em que decide() consulta o escopo
(terminalNoEscopo); nos outros o campo não existe, em vez de existir
dizendo algo que a decisão não usou. A derivação é a de projectScopeRoot —
raizDoEscopoNoEvento mora ao lado dela, no mesmo arquivo, e não é segunda
régua: o que ela faz é EXPRIMIR a mesma raiz sem a parte absoluta.
Aditivo, e ausente não é "sem raiz". Evento gravado antes desta regra, ou
de outro tipo, não tem o campo; não há reprocessamento nem coluna nova. O
outbox não o carrega, pelo mesmo motivo da RN-567 (sem
consumidor no engine). Nada em decide() muda, nenhum teto, e a tela não
muda — onde ele aparece é outra entrega (AT-148). (Desde a
RN-614 a raiz aparece junto do motivo, na linha do evento no painel
de log e no ApprovalCard, sempre relativa e com texto próprio para
indisponivel.) A web lê o payload como
registro genérico, então evento antigo e novo passam pela mesma leitura.
Declarado: em mounted, o segmento é relativo à base configurada NO
INSTANTE da decisão — se a base mudar depois, o segmento antigo continua
dizendo o que era verdade então, que é o ponto. E a separação entre escopo e
permissions.json (RN-478) não entra no campo: ele descreve a
raiz do ESCOPO, nunca onde o arquivo de política mora.
- Código:
src/infrastructure/filesystem/project-workspaces-root.ts:907(raizDoEscopoNoEvento),:901(RaizDoEscopoNoEvento),:117(segmentoSobABaseDeProjetos, reusada);src/application/use-cases/actions/propose-action.use-case.ts:330(scopeRootsó emterminal);src/domain/actions/decide.ts:648(terminalNoEscopo, o único consumidor do escopo) - Teste:
test/application/use-cases/actions/propose-action.use-case.spec.ts:1154(container),:1134(mounted),:1134(runner),:1123(o caminho absoluto sob/home/usuario/…NUNCA vaza, nos três casos, inclusive fora da base),:1224(indisponivel),:1163(outro tipo e o outbox sem o campo),:1183(evento anterior, sem o campo, pela mesma leitura);test/infrastructure/filesystem/project-workspaces-root.spec.ts:772(a função pura, nos três modos e nos três casos deindisponivel) - ADR: 0055 (ponto 7)
- Origem: AT-147 (EP-025/HS-043)
RN-611 — A chave de MÁQUINA ganha listagem e revogação por CONTA, sem :projectId: só as de máquina, só as do próprio usuário, e a revogação é a MESMA de sempre
A lacuna que a RN-552 e a RN-561 declararam por escrito:
"numa instalação que ainda não tem PROJETO, a listagem e a revogação — as duas
por /projects/:projectId/runner-device-keys — não têm projeto contra o que
responder". A instalação de uma linha (RN-547) cria EXATAMENTE essa
situação: a conta e a chave de máquina nascem antes de qualquer projeto, e a
chave ficava viva e inalcançável. Decisão do mantenedor (AT-118): sim, na
Conta.
Duas rotas novas, e só duas. GET /users/me/machine-device-keys lista e
DELETE /users/me/machine-device-keys/:deviceKeyId revoga (204, idempotente).
Sem @RequireRole, como users/me/credentials e users/me/preferences: o
escopo é a PRÓPRIA pessoa, e não há projeto nem workspace contra o que resolver
um papel. O userId vem do JWT de sessão e vai para o WHERE; nenhum
parâmetro nomeia outra pessoa, e a visão de maintainer sobre a chave alheia
continua fora por decisão da RN-519 — esta rota não a reabre por
outra porta.
Só a espécie de MÁQUINA. A listagem devolve SÓ project_id IS NULL
(especie: "maquina"), revogadas INCLUÍDAS (a régua da RN-519: sumir com a
linha faria a tela afirmar que a chave nunca existiu). As de PROJETO têm casa —
a seção device-keys do projeto delas, onde o alcance de revogá-las é dito — e
trazê-las para a Conta faria a Conta derrubar pareamento de projeto sem nomear
o projeto. Pela mesma razão, a revogação por conta recusa chave de PROJETO com
404, a MESMA resposta de chave que não existe ou que é de outra pessoa: a
pergunta vai à MESMA listagem da tela, filtrada por userId, então não há como
perguntar pela chave alheia e aprender que ela existe.
O que revogar derruba de fato, e o alvo NÃO muda. A revogação por conta não
é uma revogação nova: RevokeMachineDeviceKeyUseCase confere que a chave está
entre as de máquina do chamador e DELEGA a RevokeRunnerDeviceKeyUseCase, byte
a byte. Ele grava a linha e, para a chave de máquina, pede ao engine um
disconnectRunnerOfUser por projeto em modo runner que o dono alcança
(RN-520/RN-543). O alvo continua {projeto, usuário} e
nunca {chave}: outro runner do mesmo usuário nesses projetos cai junto,
mesmo com PAT ou outra chave, e reconecta se a credencial dele ainda valer.
Numa instalação sem projeto, a lista de projetos é vazia e a revogação só
grava a linha — e é o que basta: o agente de máquina que espera o primeiro
projeto (RN-550) não tem conexão nenhuma a derrubar, e o próximo
ticket que ele pedir é recusado. Mudar o alvo continua sendo frente própria,
com ADR (AT-013).
"Registrar substitui a anterior" FICA. A RN-552 tinha duas razões para revogar a chave de máquina anterior a cada registro: (a) o token de serviço não pode fabricar credenciais duradouras em série, e a máquina reinstalada não pode deixar para trás uma chave viva que ninguém lembra de revogar; (b) a órfã seria INALCANÇÁVEL. Esta RN derruba a (b) e deixa a (a) intacta — ela não depende de tela. O que muda é que a substituída passa a APARECER, revogada, na Conta; e como ninguém a revogou pela tela, a seção diz em TEXTO que reinstalar revoga a anterior, para a linha não ser lida como incidente.
A tela mora na Conta (/account), a página que já existia para o que segue a
pessoa entre projetos. Ela lista nome, data de criação, último uso e status, e
herda o vocabulário INTEIRO da RN-561/RN-548:
lastUsedAt é uso REGISTRADO, nunca conexão, e nulo tem texto próprio ("nunca
usada"); "ativa"/"revogada" fala da LINHA e nunca de agente de pé — e "ativa"
não é verde, porque a espécie de máquina nunca ganha verde e ali só há
dela. Não há coluna de espécie: com uma espécie só ela repetiria a mesma
palavra em toda linha, e o que ela carregava na seção de projeto — o ALCANCE — é
dito na legenda e na confirmação ("derruba o agente local em TODOS os seus
projetos em modo runner; sem projeto nenhum, só impede a chave de ser usada de
novo"), junto com o custo colateral da RN-520. Carregando, falhou, vazio,
revogada e nunca usada são cinco estados com cinco textos
(RN-088/RN-470). Sem papel mínimo
no endpoint, a tela não inventa um: roleAtLeast aqui trancaria quem pode, o
defeito pior dos dois.
Revogar por uma lista invalida TODAS. A chave de máquina aparece na lista
da Conta (['machine-device-keys']) e na de TODO projeto do dono
(['runner-device-keys', projectId], lida pelo RunnerOnboardingPanel e pela
seção device-keys). invalidarChavesDeDispositivo invalida a por projeto por
PREFIXO (todo projeto no cache) e a da Conta sempre, e as DUAS telas de
revogação a chamam — antes, revogar pela seção do projeto invalidava só a do
projeto aberto, e as outras listas seguiam anunciando a revogada.
- Código:
src/interfaces/http/runner/machine-device-keys.controller.ts:48(MachineDeviceKeysController),:69(listMachineDeviceKeys),:93(revokeMachineDeviceKey);src/application/use-cases/auth/list-machine-device-keys.use-case.ts:33(ListMachineDeviceKeysUseCase);src/application/use-cases/auth/revoke-machine-device-key.use-case.ts:33(RevokeMachineDeviceKeyUseCase);src/infrastructure/persistence/drizzle/runner-device-key.repository.ts:97(listarDeMaquinaDoUsuario);apps/web/src/routes/MachineDeviceKeysSection.tsx:73(MachineDeviceKeysSection);apps/web/src/lib/chaves-de-dispositivo-queries.ts:31(invalidarChavesDeDispositivo);apps/web/src/lib/api-client.ts:455(listMachineDeviceKeys);apps/web/src/locales/{en,pt-BR}/machineKeys.json - Teste:
test/application/use-cases/auth/machine-device-keys.use-case.spec.ts(a lista só pelo usuário, a revogada incluída; a revogação REAL derrubando{projeto, usuário}em cada projeto; sem projeto, só grava; a chave fora das do chamador é 404 sem revogar nada);test/interfaces/http/runner/machine-device-keys.controller.spec.ts(sem@RequireRole, 204,userIddo JWT, o 404 propagado);test/infrastructure/persistence/drizzle/runner-device-key.repository.spec.ts(contra o Postgres: só as de máquina do usuário, a substituída revogada na lista, a de outro usuário fora, instalação sem projeto);apps/web/src/routes/MachineDeviceKeysSection.test.tsx(lista, os cinco estados, a confirmação com o alcance, a invalidação da Conta E de todo projeto — provada também sobre uma entrada de projeto no cache —, a falha com a frase da api, e os dois idiomas);test/interfaces/route-surface.spec.ts(as duas rotas classificadasjwtemdocs/security-surface.md) - Fica declarado e NÃO fecha: (1) o alvo da revogação continua
{projeto, usuário}(RN-520, AT-013); (2) a visão demaintainercontinua fora (RN-519); (3) a rota que CRIA chave de máquina continua servindo só instalação de UMA pessoa (RN-552) — a Conta lista e revoga, não cria. - Origem: AT-118 (EP-003/HS-007)
RN-613 — AUTH_TOKEN_PEPPER é obrigatório e próprio; quem migra o define com o AUTH_JWT_SECRET atual
O pepper do HMAC dos tokens opacos (refresh, links de conta, PATs, balde de
lockout) deixou de cair no AUTH_JWT_SECRET. O fallback silencioso
(AUTH_TOKEN_PEPPER ?? passphraseAtual()) fazia de toda rotação "sem
downtime" do JWT uma troca do pepper, e era o caminho COMUM: os composes de
produção e de instalação nem repassavam a variável. Em produção a api RECUSA
subir sem ele, e a recusa diz o conserto — "defina com o valor ATUAL de
AUTH_JWT_SECRET para não deslogar ninguém" —, porque a correção óbvia (gerar
um aleatório) é a que desloga todo mundo. Com o pepper igual ao JWT antigo o
hash é byte a byte o de antes, os refresh tokens e os PATs seguem válidos, e
daí em diante o JWT rotaciona sozinho. Pepper igual ao JWT NÃO é recusado: é o
estado legítimo de toda instalação migrada. Valem as réguas do JWT (RN-114):
em branco, os dois literais de exemplo do repositório e menos de 16 caracteres
derrubam o boot. Fora de produção vale um default de desenvolvimento PRÓPRIO
(dev-auth-token-pepper-change-me), nunca o do JWT.
O install.sh faz a migração: instalação NOVA gera um pepper aleatório;
havendo um .env anterior na pasta, ele é LIDO (nunca executado) antes de ser
sobrescrito, e o pepper dele é mantido — ou, sem pepper, nasce com o
AUTH_JWT_SECRET dele. O terminal diz de onde o valor veio, nunca o valor.
- Onde:
src/infrastructure/security/auth-key-material.ts:168(pepperAtual),:212(pepper);src/main.ts:46(a checagem de boot);install.sh:702(valor_no_env_anterior),:734(gerar_segredos),:831(dizer_a_origem_do_pepper); a variável nos três composes (docker/docker-compose.yml,docker-compose.prod.yml,docker-compose.install.yml), nodocker/smoke.she no.env.example - Teste:
test/infrastructure/security/auth-key-material.spec.ts(describe "pepperAtual (RN-613)": recusa sem pepper mesmo com JWT, a mensagem, os exemplos, o piso, igual ao JWT aceito, o default de dev);test/application/use-cases/auth/rotacao-dos-segredos.spec.ts(describe "a migração do RN-613": o refresh gravado com a fórmula ANTIGA segue válido com o pepper igual ao JWT antigo e sobrevive à troca do JWT; o describe doAUTH_JWT_SECRET: sem pepper fora de produção, trocar o JWT não desloga);scripts/dev/install-env.spec.ts(describe "o AUTH_TOKEN_PEPPER do instalador (RN-613)", contradocker compose config, valor inteiro) - Borda: o instalador só enxerga o
.envda pasta em que roda. Instalação cujo.envmora em outro lugar, compose próprio e Kubernetes comExternalSecretfazem a migração à mão, pelo runbook — no Kubernetes a propriedadeAUTH_TOKEN_PEPPERjá existia noExternalSecrete nobootstrap.sh, e um provider que não a tenha já reprovava a sincronização. E a migração do instalador apaga os volumes (ADR 0150): o refresh só sobrevive para quem restaura o dump do Postgres, que é o caso que importa. - Origem: AT-210 (achado da AT-196), decisão do mantenedor: migração com o valor atual
RN-615 — A remoção de membro de WORKSPACE nasce com o teto 2 herdado: remover a si mesmo é 403, e é essa cláusula que segura o último owner, sem contar
A quinta porta da linha dos tetos (RN-472, RN-556,
RN-557), e a rota que o
ADR 0157 recusou
abrir de passagem. Até aqui WorkspacesController não tinha @Delete de
membro, e offboarding era escrita no banco. Decisão do mantenedor
(AT-115/HS-006): a rota nasce, protegida por CLÁUSULA.
A rota. DELETE /workspaces/:workspaceId/members/:userId, owner (o MESMO
mínimo do upsert), 204, idempotente.
O teto é herdado, sem régua nova. remocaoEhAutoRebaixamento recebe o papel
do ator no WORKSPACE como o efetivo de hoje e null como o papel de DEPOIS — no
workspace não há nível acima para segurar a queda, então remover a própria
linha é sempre queda para "nenhum acesso", o ramo que a função já enunciava
para o projeto sem linha de workspace. Remover a SI MESMO é sempre 403, com
frase própria: "Você não pode remover a si mesmo deste workspace: aqui não há
papel acima para segurar a queda, e sem a linha você perde o workspace e todos
os projetos dele. Voltar exige o owner que você estaria abandonando. Peça a
outro owner."
O último owner, pela MESMA cláusula. Só owner chama a rota; ninguém remove
a si mesmo; logo toda remoção bem-sucedida deixa ao menos o CHAMADOR como
owner. Tirar o último exigiria que ele se removesse — o movimento recusado. A
contagem segue recusada (ADR 0157, ponto 2). O mínimo owner da rota é METADE
da prova, e por isso é asserido em teste.
| movimento | desfecho |
|---|---|
| remover a SI MESMO do workspace, havendo ou não outro owner | 403 |
remover OUTRO owner |
passa — é como se revoga propriedade por inteiro |
remover o TITULAR (created_by), por outro owner |
409 criador_do_workspace — RN-616 |
| remover qualquer outra pessoa | passa |
| remover quem não tem linha de workspace | 204 — as linhas de projeto dele no workspace saem igual |
A cascata, numa transação. A linha de workspace_members; as linhas de
project_members do removido nos projetos DESTE workspace (sem elas a
sobreposição projectRole ?? workspaceRole da RN-471 o manteria
dentro de todo projeto em que tivesse linha própria); e as credenciais dele
presas a esses projetos — chaves de dispositivo de PROJETO e PATs —, revogadas
com o motivo workspace_member_removed. Depois do commit, a conexão viva do
runner dele cai em CADA projeto do workspace pelo caminho da
RN-520, em try/catch que só loga: engine fora do ar não derruba a
remoção.
Declarado e NÃO feito: a chave de MÁQUINA (é da conta e serve outros
workspaces; aqui ela deixa de alcançar porque o papel resolve para nenhum); o
socket de SESSÃO já conectado e o terminal :web do removido (seguem até cair;
o ticket novo é recusado); tela (não existe seção de membros de workspace no
apps/web). O TITULAR (workspaces.created_by) não sai por esta rota — é a
RN-616.
- Código:
src/interfaces/http/iam/workspaces.controller.ts:262(removeMember);src/application/use-cases/iam/remove-workspace-member.use-case.ts:79(RemoveWorkspaceMemberUseCase);src/domain/iam/tetos-de-rebaixamento.ts:88(MENSAGEM_TETO_AUTO_REMOCAO_DO_WORKSPACE),:248(remocaoEhAutoRebaixamento);src/infrastructure/persistence/drizzle/workspace.repository.ts:84(removeMember);src/infrastructure/persistence/drizzle/project.repository.ts:167(removeMemberFromWorkspaceProjects);src/infrastructure/persistence/drizzle/runner-device-key.repository.ts:169(revogarChavesDeProjetoNoWorkspace);src/infrastructure/persistence/drizzle/personal-access-token.repository.ts:187(revogarDoUsuarioNoWorkspace) - Teste:
test/application/use-cases/iam/remove-workspace-member.use-case.spec.ts(contra o Postgres: remove outro membro com a cascata inteira e nada fora do workspace; remove outro owner; engine fora do ar não derruba; idempotente; recusa a si mesmo sem escrever nada; frase própria; o owner único não sai; com dois owners, quem remove fica);test/interfaces/http/iam/workspaces-members.controller.spec.ts(ownernas duas rotas de membro, 204, a ordem ator/alvo, o 403 propagado);test/interfaces/route-surface.spec.ts(a rota classificada emdocs/security-surface.md) - ADR: 0173
- Origem: AT-115 (EP-002/HS-006)
RN-616 — O TITULAR do workspace não sai pela remoção de membro: 409 criador_do_workspace até transferir, e a titularidade só vai para outro owner
Decisão do mantenedor sobre a questão que a RN-615 deixou aberta
(AT-115, ADR 0173, ponto 7).
workspaces.created_by não autoriza nada — quem autoriza é o papel
(RN-472) —, mas é por ele que se resolve a credencial de LLM que os
agentes gastam e a de git das ações de agente
(RN-058) e o dono do relatório de gasto
(RN-060). Remover o criador deixaria os
agentes gastando a credencial de quem já não está no workspace. A coluna passa a
ser chamada, na regra, de titular.
A recusa. DELETE /workspaces/:workspaceId/members/:userId com alvo = titular
é 409 com code: "criador_do_workspace": "Não é possível remover o titular
do workspace: a credencial de LLM que os agentes gastam, a de git das ações de
agente e o relatório de gasto são dele (RN-058/RN-060). Transfira antes a
titularidade para outro owner (PUT /workspaces/:workspaceId/owner-of-record) e
remova depois." O teto da RN-615 vem antes: o titular tentando se remover
recebe o 403 de auto-remoção.
A transferência. PUT /workspaces/:workspaceId/owner-of-record, owner,
corpo { userId }, responde o workspace (200). O destino precisa JÁ ser owner
do workspace, senão 409 titular_precisa_ser_owner ("A titularidade só vai
para quem já é owner deste workspace: o titular paga o que os agentes gastam
(RN-058). Promova a pessoa a owner antes, ou escolha outro owner."). Grava
created_by numa transação com a leitura do papel; transferir para o titular
atual é idempotente. Qualquer owner pode transferir, inclusive para OUTRO
owner que não pediu — declarado, é a mesma autoridade que remove donos.
O gasto, a partir do commit. ResolveCredentialOwnerUseCase devolve o novo
titular: o turno de agente procura a credencial DELE para o provider do modelo,
e as ações de git a credencial de git dele. Sem credencial, o desfecho é o que
já existia — o turno termina com "Nenhuma credencial cadastrada para
token_usage não guarda titular, e o relatório inteiro,
histórico incluído, passa a ser do novo titular. Sem evento de domínio: fica
uma linha de log com de/para/quem.
- Código:
src/domain/iam/titularidade-do-workspace.ts:22(CODIGO_CRIADOR_DO_WORKSPACE),:38(removeOTitular);src/application/use-cases/iam/remove-workspace-member.use-case.ts:120(removeOTitular);src/application/use-cases/iam/transfer-workspace-ownership.use-case.ts:39(TransferWorkspaceOwnershipUseCase);src/infrastructure/persistence/drizzle/workspace.repository.ts:98(transferirTitularidade);src/interfaces/http/iam/workspaces.controller.ts:324(transferOwnership) - Teste:
test/application/use-cases/iam/remove-workspace-member.use-case.spec.ts(describe"o TITULAR não sai": o 409 com código e nada escrito; o titular removendo a si mesmo recebe o 403; depois de transferir, remover o antigo criador passa e o novo titular vira o protegido — edescribe"TransferWorkspaceOwnershipUseCase": para owner gravacreated_by; paramaintainere para não-membro é 409titular_precisa_ser_owner; para o titular atual é idempotente);test/interfaces/http/iam/workspaces-members.controller.spec.ts(ownere a delegação da transferência) - ADR: 0173, ponto 7
- Origem: AT-115 (EP-002/HS-006)