API — parte 6 (desde RN-503)
Projeto montado sobe container pelo BROKER (RN-503)
RN-503 — Projeto mounted sobe container pelo BROKER, e o que atravessa a rede é um localizador discriminado — nunca um caminho absoluto
Um projeto no modo Pasta montada não conseguia container nenhum até aqui, e
por dois bloqueios independentes: ExecuteContainerStartUseCase mandava todo
modo diferente de container para o RUNNER (que exige um brabo-runner
conectado), e o broker recusava na fonte qualquer modo que não fosse
container, com ModoDeExecucaoNaoSuportadoError.
Os dois existiam pela mesma razão de GEOMETRIA, não pelo nome do modo: a pasta de um projeto montado ficava num lugar arbitrário do disco do operador, que o daemon Docker do servidor não tinha por que enxergar. O ADR 0141 (RN-500) mudou essa geometria — todo projeto montado passa a morar sob uma base montada por identidade —, e é isso, e só isso, que esta regra colhe.
A ramificação passa a ser por DESTINO, não por modo. container e
mounted vão ao BROKER; só runner vai ao runner, porque a pasta dele
continua numa máquina que este servidor não alcança (ADR 0137). Vale igual para
as três ações de ciclo de vida — container_start, container_stop e
container_remove: elas TÊM de mudar juntas, senão o container de um projeto
montado sobe no servidor e o pedido de parar vai procurá-lo na máquina do
usuário, deixando de pé, sem forma de parar, o que está de pé.
O invariante do ADR 0130 não se mexe: nenhum caminho absoluto atravessa a
rede. O broker é root-equivalente no host e COMPÕE o -v a partir das raízes
DELE; se a api mandasse /home/voce/brabo/loja, a contenção do bind-mount
passaria a depender de a api estar correta, que é exatamente a dependência que
o broker existe para não ter. O que muda é que agora existem DUAS raízes do
lado de lá, então a spec precisa DIZER contra qual delas o pedaço relativo
vale — e diz, num localizador discriminado:
localizacao.tipo |
segmento | raiz do broker |
|---|---|---|
gerenciada |
workspace_dir_name (RN-109) |
PROJECT_WORKSPACES_HOST_ROOT |
montada |
o caminho RELATIVO sob a base | BRABO_PROJECTS_HOST_BASE |
indisponivel |
— (há motivo) |
nenhuma |
Três estados e não dois, porque o terceiro existe de verdade e tem dois
consertos diferentes: projeto runner (a pasta está noutra máquina — o
conserto é o runner, do lado de lá) e projeto mounted LEGADO criado fora da
base (o conserto é mover a pasta). Colapsá-los num null faria a mesma
ausência mandar quem opera para o lugar errado. A pasta que É a própria base
também cai aqui, e não vira segmento vazio: <raiz>/ montaria a base inteira
— a pasta de TODOS os projetos montados — dentro do container de um só.
A falta de uma raiz nunca é suprida pela outra. Sem
BRABO_PROJECTS_HOST_BASE, um start de projeto montado recusa nomeando a
variável (BaseDeProjetosNaoConfiguradaError, origem infra, 503) e não
toca container nenhum — mesmo molde de RaizDeWorkspacesNaoConfiguradaError.
Cair na outra raiz por omissão seria o pior desfecho possível: a raiz
gerenciada é nomeada por workspace_dir_name e a base é nomeada pelo usuário,
então o mesmo nome aponta para pastas diferentes e o container subiria com a
pasta de OUTRO projeto, sem nada indicando por quê. Pelo mesmo motivo, a lista
de modos que o broker atende é de PERMITIDOS: um modo novo no enum nasce
recusado, com mensagem, em vez de aceito por omissão.
A composição continua passando por três barreiras, não uma. A api recusa o
que não está sob a base (segmentoSobABaseDeProjetos); o broker recusa o
segmento que não é relativo (segmentoDeProjetoValidado — .., absoluto,
vazio, barra dupla, NUL); e o resultado da concatenação ainda passa por
raizDeProjetoValidada antes de virar -v. Validar o segmento e não validar a
concatenação seria confiar na aritmética de strings.
mounted ELEGE a imagem, como container — não lê a vigente como o runner.
Não é simetria estética: é o único desenho que funciona. O broker compõe a
partir de artifact.project_image, indo BUSCÁ-LO na api. Uma eleição da Infra
que não fosse gravada nesse artefato seria inerte — o container subiria com a
imagem que o Arquiteto decidiu, o payload que o humano aprovou diria outra, e
nada no registro denunciaria a diferença. O caminho do runner pode ler a
vigente justamente porque ali a api MANDA os campos da spec pelo canal; lá o
artefato não é a fonte que o outro lado consulta.
O que esta regra NÃO faz: não materializa pasta nenhuma — quem cria a pasta
do projeto montado é a RN-501, no MESMO caso de uso e antes desta
ramificação, justamente porque o daemon do servidor precisa da pasta existindo
para montá-la. Também não toca o portão da imagem (RN-105 já vale para os três
modos desde a RN-494) e não muda quem PROPÕE container_start — o Infra Lead
segue podendo propor para um projeto sem runner conectado e sem imagem
decidida, a lacuna declarada desde a RN-494.
- Onde:
src/application/use-cases/containers/obter-spec-de-container.use-case.ts(LocalizacaoDoProjeto,localizacaoDoProjeto);src/infrastructure/filesystem/project-workspaces-root.ts(segmentoSobABaseDeProjetos);src/interfaces/http/internal/dto/container-spec-internal.response.dto.ts;src/application/use-cases/actions/execute-container-start.use-case.ts,…/execute-container-stop.use-case.ts,…/execute-container-remove.use-case.ts(a ramificação!== 'runner');packages/docker-port/src/docker-port.ts(segmentoDeProjetoValidado);apps/broker/src/config.ts(baseDeProjetosNoHost) eapps/broker/src/operacoes.ts(raizDoProjetoNoHost,garantirModoSuportado,BaseDeProjetosNaoConfiguradaError,LocalizacaoIndisponivelError) - Teste:
test/application/use-cases/containers/spec-e-observacao-de-container.use-case.spec.ts(describe('… o localizador discriminado (RN-503)'));test/application/use-cases/actions/execute-container-{start,stop,remove}.use-case.spec.ts;packages/docker-port/src/docker-port.spec.ts(describe('segmentoDeProjetoValidado'));apps/broker/src/operacoes.spec.tseapps/broker/src/config.spec.ts - ADR: 0130, 0141
- Origem: plano do dono do produto, PR 3
O artefato de decisão dos conversacionais (RN-505)
RN-508 — container_start_via_runner: segundo tipo de ação para subir o container, exclusivo de runner, e o Infra Lead recusa às cegas ANTES de propor
container_start (ADR 0130/0133) exige imagem/network/resources/
rationale — a Infra ELEGE uma candidata do roteamento do Arquiteto. Até
esta entrega, o mesmo tipo de ação também atendia execution_mode: runner
(ExecuteContainerStartUseCase.executeViaRunner), mas o caminho do runner
NUNCA lia nenhum desses quatro campos: a imagem que sobe é a que já estiver
DECIDIDA (não há roteamento contra o qual eleger, porque o broker nunca
alcança a pasta de um projeto runner). Pedir 4 campos que o dispatch sempre
descartava é o defeito que motiva nascer uma AÇÃO separada em vez de uma
container_start "inteligente" ramificando por modo.
container_start_via_runner só tem rationale (opcional). Mesmo calibre
de container_start (maintainer, require_approval por padrão, nunca
semeado em auto-aprovação, configurável em permissions.json como as
demais três ações de container — não entra no teto absoluto de
container_remove). ExecuteContainerStartUseCase perde o ramo runner
inteiro (executeViaRunner, ApiToEngineClient/ObterSpecDeContainerUseCase
saem do construtor) — fica exclusivo de container/mounted, os dois pelo
BROKER (RN-503). O código extraído vira
ExecuteContainerStartViaRunnerUseCase, mesmo arquivo-irmão. A dança de
ciclo de vida (provisioning -> running) que os dois caminhos compartilhavam
por cópia vira SubirCicloDeVidaDoContainerUseCase
(.../containers/) — extraída para não duplicar o racional da
máquina de estados nos dois arquivos.
O Infra Lead consulta LOCALMENTE, sem HTTP, antes de propor. A tool nova
(Engine.Infra.Tools.ProposeContainerStartViaRunner) é interceptada por
Engine.Infra.InfraLeadServer.dispatch_calls/2, que lê Project.get/1
(execution_mode) e Engine.Runners.Registry.connected?/1 (runner conectado)
— as DUAS leituras já são locais, no mesmo processo BEAM — ANTES de chamar
propose_action. Recusa com motivo NOMEADO (nunca propõe às cegas) quando o
projeto está em container (usa propose_container_start), em mounted
(idem, desde a RN-503) ou em runner sem runner conectado agora. Esta
lacuna — "propor sem saber se dá certo" — é a MESMA que propose_container_start
tem desde a RN-494, declarada e aceita para aquela tool (tocar o prompt/
instrução do Infra Lead ficaria fora do escopo de API/domínio); a tool NOVA
nasce sem ela, porque nasce sabendo negar.
A especificação que a api compõe é a que o runner VALIDA, e isso passou a
ser provado. EspecificacaoDeContainerParaRunner são os campos de
EntradaDeEspecificacao (packages/docker-port) menos raizDoProjeto, que
só o runner conhece — DEZ, não nove. Ela nasceu com nove, sem projectId, e
a consequência foi que este caminho NUNCA subiu container nenhum desde que
existe: o engine repassa o mapa opacamente, o runner o entrega a
especificacaoValidada, que exige projectId como texto não vazio, e a ação
terminava failed com "especificação de container recusada em projectId:
esperava texto não vazio, recebi undefined" — e daí o bloqueio CORRETO da
RN-507/RN-502 fazia o resto, sem nenhum dev agent
reivindicando tarefa. O dado sempre existiu (SpecDeContainer.projectId, o
mesmo que o BROKER recebe por GET .../container-spec, e é por isso que só o
modo runner quebrou); só não era copiado. Três suítes verdes não pegaram
porque cada uma montava o próprio fixture: o teste do caso de uso afirmava o
que ele mesmo esperava, o do runner montava um fixture COM projectId, e o
engine não olha o mapa. O que passa a ser garantido é a CORRENTE — o payload
que ExecuteContainerStartViaRunnerUseCase compõe, atravessando o
especificacaoValidada de verdade —, e o teste também trava o conjunto de
campos: raizDoProjeto é o único que a api não manda, então campo novo de um
lado sem o outro reprova.
- Onde:
src/domain/actions/decide.ts(container_start_via_runner);src/application/use-cases/actions/ execute-container-start-via-runner.use-case.ts(novo);src/application/use-cases/containers/ subir-ciclo-de-vida-do-container.use-case.ts(novo);src/application/use-cases/actions/propose-action.use-case.ts;src/application/use-cases/actions/approve-action.use-case.ts;src/application/ports/api-to-engine-client.port.ts(EspecificacaoDeContainerParaRunner, os dez campos);apps/web/src/lib/aprovacoes.ts;apps/engine/lib/engine/infra/tools/propose_container_start_via_runner.ex(novo);apps/engine/lib/engine/infra/infra_lead_server.ex - Teste:
test/application/use-cases/actions/execute-container-start.use-case.spec.ts,test/application/use-cases/actions/ execute-container-start-via-runner.use-case.spec.ts(novo),test/contract/ especificacao-de-container-para-runner.contract.spec.ts(a CORRENTE — o payload composto pela api contra oespecificacaoValidadade verdade),apps/web/src/lib/aprovacoes.test.ts,apps/engine/test/engine/infra/tools/ propose_container_start_via_runner_test.exs(novo),apps/engine/test/engine/infra/infra_lead_server_test.exs - ADR: 0145
- Origem: decisão do dono do produto — Docker vira pré-requisito real do
modo
runner, sem fallback pro host
O navegador de pastas servido pela api (RN-504)
RN-509 — "Sempre permitir" de um Dev Agent de módulo escopa a agent_autonomy, POR AGENTE — não mais o permissions.json de projeto inteiro
ApproveAlwaysActionUseCase sempre gravou em permissions.json/allow —
escopo de PROJETO INTEIRO, compartilhado por qualquer ator. Isso
significava que "sempre permitir" um comando pro dev-checkout liberava o
MESMO comando pro dev-auth, pro dev-lead, e pra qualquer agente futuro
do projeto: a intenção de quem clicou ("confio NESTE agente com ISTO") não
tinha como ser expressa — só existia "confio em qualquer um com isto".
A gravação passa a se ramificar pelo ATOR, dentro do MESMO use case (não
um novo — duplicar as duas guardas de teto absoluto de terminal fora de
escopo/git_push/sudo e de container_remove em dois lugares seria o
mesmo defeito que RN-418/RN-495 já existem para evitar):
- Ator
agentcujo id édev-<modulo>(ADR 0053/FASE 14d — a área DINÂMICA dedev, um agente por módulo domodule_map): grava emagent_autonomy(projectId, agentId, actionType) = auto_approve, pelo MESMOAgentAutonomyRepository.upsertqueactivate-execution.use-case.tsjá chama pra semear as três ações git por módulo.permissions.jsonfica intocado. - Qualquer outro ator —
user,system, agenteagentque não é dev-de-módulo, e odev-lead— continua indo propermissions.json/allowde sempre, escopo de projeto inteiro, exatamente como antes.
dev-lead é a exceção que precisa de nome próprio. ehDevDeModulo
(agent-areas.ts) é agentId.startsWith('dev-') PURO, de propósito — e
isso classifica dev-lead como true. Quem exclui o lead da própria área
é ehMembroDe, não ehDevDeModulo sozinho ("o lead não é membro da própria
área" vale pra qualquer área — duplicar a exclusão dentro do predicado
deixava as duas cópias inalcançáveis por teste, achado registrado no
comentário do próprio agent-areas.ts). Por isso o branch usa
ehDevDeModulo(actor.id) && actor.id !== DEV_LEAD: sem o segundo termo,
"sempre permitir" clicado numa ação do dev-lead gravaria autonomia de
módulo sob o agentId do LEAD por acidente — um agente que não é membro de
dev, que não tem módulo, e que a área nunca olha via agent_area_members.
Os dois tetos absolutos de decide.ts continuam rodando ANTES do branch,
sem mudar de ordem. Terminal com efeito externo git (git push, gh pr create) ou privilegiado (sudo/doas) — RN-106/RN-418 — e
container_remove — RN-495 — seguem recusando o clique INTEIRO (400, ação
nem aprovada), pra QUALQUER ator, antes mesmo do projeto ser buscado.
Escopar a gravação por agente não é uma segunda porta pro mesmo teto: os
dois if de recusa ficam onde estavam, e o branch novo entra depois.
O evento permission.granted carrega um payload OU outro, nunca um
fingindo ser o outro. { pattern } continua sendo o formato do caminho de
permissions.json (o padrão de texto gravado no arquivo); o caminho novo
emite { agentId, actionType } — não há "padrão" nenhum pra mostrar quando
a gravação é uma linha de tabela chaveada por agente.
Sem migração de dados. Entradas antigas de "sempre permitir" gravadas
para um dev-de-módulo em permissions.json, de antes desta regra existir,
continuam lá exatamente como estavam — só não recebem MAIS entradas desse
tipo dali pra frente. Decisão consciente do dono do produto, não lacuna
esquecida.
- Onde:
src/application/use-cases/actions/approve-always-action.use-case.ts(o branchehAgenteDeModulo),src/domain/agents/agent-areas.ts(ehDevDeModulo,DEV_LEAD— consumidos, não alterados),src/application/ports/agent-autonomy-repository.port.ts(upsert, já existente, sem método novo) - Teste:
test/application/use-cases/actions/approve-always-action.use-case.spec.ts(descreveescopo por Dev Agent de módulo (RN-509): caminho felizdev-checkoutgravaagent_autonomye não vaza pradev-auth; os DOIS tetos absolutos recusando o clique inteiro pra um ator dev-de-módulo;dev-leade atoruser— inclusive com id começando emdev-— continuam no caminho antigo) - Origem: plano do dono do produto, Frente 2
O destino do espelho é por projeto (RN-515)
RN-515 — O destino do espelho é declarado POR PROJETO e nunca é global; null é o estado normal; e a api valida SÓ o léxico, dizendo que é só o léxico
A capacidade espelho (RN-514, ADR 0147)
copia o trabalho dos agentes para uma pasta do usuário fora da base
montada — que é a razão de ela existir, porque bind mount não atravessa rede
nem alcança caminho fora do que foi montado. Esta regra decide onde esse
destino mora e como o usuário o declara; ela não copia arquivo nenhum.
Por PROJETO, nunca global. O destino é a coluna projects.mirror_path e o
campo mirrorPath do projeto, escrito por
PUT /projects/:projectId/mirror-path (maintainer, o mesmo mínimo de
execution-mode e de projects-base: a rota fala de um caminho do computador
do operador). Nunca configuração global no runner, nunca variável de ambiente:
um destino global faria o artefato do projeto B aterrissar na pasta do projeto
A, e o usuário descobriria isso pelo conteúdo, não por um erro (ADR 0147,
ponto 4).
null é o estado NORMAL, não uma pendência. A coluna é nullable e nasce
nula; projeto sem espelho é a maioria, e o produto nunca escolhe um destino
sozinho. Limpar é {"mirrorPath": null} — a chave é obrigatória, e
omiti-la é 400: um corpo que omite o campo seria indistinguível de "limpe",
e limpar em silêncio é justamente o defeito. Limpar funciona sempre,
inclusive num projeto container, senão uma linha convertida ficaria presa no
estado que a regra proíbe.
Só o LÉXICO, e a api DIZ que é só o léxico. A api não enxerga a máquina
onde o destino vai existir — é a situação do modo runner
(RN-423), não a do mounted, onde a pasta acaba dentro da base que
a api alcança de verdade. Ela reusa caminhoDeWorkspaceLocalValido, o mesmo
predicado da criação e da conversão (absoluto, sem ../., nunca a raiz nem
pasta de sistema, nunca sobreposto ao checkout do Brabo), em vez de uma quarta
cópia da mesma régua — e não exige BRABO_PROJECTS_BASE, porque o destino
do espelho é por definição a pasta que a base não cobre. A mensagem de recusa
declara que o disco não foi verificado e quem o verifica.
Os dois sentidos do mesmo laço. Destino dentro do workspacePath e
destino que contém o workspacePath são a mesma recusa vista de dois lados
(ADR 0147, ponto 2): escrever o espelho dentro da origem faz o espelho copiar o
próprio espelho, e uma origem dentro do destino aninha a cópia a cada rodada.
Recusar um e permitir o outro seria fechar a porta e deixar a janela. A
comparação é por SEGMENTO (dentroDoEscopo), nunca startsWith cru:
/base-outra não está dentro de /base, e recusá-lo taxaria uma pasta
irmã perfeitamente legítima.
Esta é a metade LÉXICA da guarda, e só ela. A outra é realpath, e é do
RUNNER (espelho-guard.ts, ADR 0147 ponto 2), na máquina onde os dois caminhos
existem de verdade. Um symlink em qualquer segmento do destino apontando de
volta para a origem passa por aqui sem ser visto — a api não tem disco onde
resolvê-lo. A api não garantiu o laço; ela impediu o laço ESCRITO.
container é recusado, e a recusa é NOMEADA. A origem de um projeto
container é um volume gerenciado no servidor, e quem copiaria é um
processo na máquina do usuário, que não a enxerga. Aceitar e nunca copiar
seria pior que recusar, porque pareceria configurado. mounted e runner
podem. Pelo mesmo motivo, converter o modo ZERA o destino
(RN-447..450): em container ele não poderia existir, e mesmo entre
mounted e runner a conversão MOVE a origem contra a qual os dois sentidos
do laço foram validados.
O valor volta em toda leitura de projeto (ProjectResponseDto.mirrorPath),
para a implementação do espelho e a tela de estado não precisarem de rota nova.
Não há endpoint de leitura dedicado.
E ele NÃO é proposed_action. A escrita do espelho é configuração que o
usuário declarou, não um agente pedindo para agir — o ADR 0147 é explícito
disso, e sincronizar por comando de terminal cairia no escopo do
ADR 0055 e viraria
fila de aprovações rotineiras.
- Onde:
src/db/schema/iam.ts(colunamirror_path, nullable, sem CHECK — a recusa decontainermora no caso de uso porque o modo é CONVERSÍVEL);src/db/migrations/0056_lovely_firelord.sql;src/domain/iam/project.entity.ts(mirrorPath);src/application/services/workspace-location.ts(validarDestinoDeEspelho— o léxico reusado, a recusa decontainere os dois sentidos do laço);src/application/use-cases/iam/set-project-mirror-path.use-case.ts;src/application/use-cases/iam/convert-project-execution-mode.use-case.ts(a conversão zerando o destino);src/interfaces/http/iam/dto/set-mirror-path.dto.ts(@IsDefined@ValidateIf, nunca@IsOptional, que colapsarianulleundefined);src/interfaces/http/iam/projects.controller.ts(PUT :projectId/mirror-path,maintainer);src/interfaces/http/iam/dto/iam.response.dto.ts
- Teste:
test/application/use-cases/iam/set-project-mirror-path.use-case.spec.ts(grava, normaliza, limpa, limpa mesmo emcontainer, recusa não-absoluto/../raiz/pasta de sistema/checkout do Brabo, os dois sentidos do laço,/base-outravs/baseNÃO sendo dentro,containerrecusado, 404);test/interfaces/http/iam/projects-execution-mode.controller.spec.ts(describe "destino do espelho (RN-515)"—maintainere o repasse denull);test/application/use-cases/iam/convert-project-execution-mode.use-case.spec.ts(a conversão zerando o destino);test/interfaces/route-surface.spec.ts(a rota classificada emdocs/security-surface.md) - ADR: 0147, ponto 4
- Origem: FASE 28, sessão 6 (metade A — onde o destino mora; a cópia, o
mirror_synce a segunda passada derealpathsão a metade B, no engine e no runner)
O estado do espelho é visível, e os três estados não colapsam (RN-517)
RN-517 — O desfecho de cada rodada do espelho volta pelo canal e vira TELEMETRIA numa tabela própria; "nunca sincronizou", "sincronizou e não copiou nada" e "falhou" são TRÊS respostas, e sucesso e erro convivem
A sessão 8 da FASE 28 e o ponto 7 do
ADR 0147. A
RN-516 fez o espelho copiar de verdade e deixou o mirror_sync
fire-and-forget — sem mirror_sync_result no protocolo, de propósito,
porque o formato da telemetria era decisão desta entrega. É ela que fecha o
laço.
O caminho é o de workspace_confirm, e nenhum outro. O runner empurra
mirror_sync_result no canal terminal:<projectId> depois de a rodada
terminar; o engine trata em handle_in/3 e REPASSA à api pelo HTTP interno
(POST /internal/projects/:projectId/mirror-sync-result), que grava pelo caso
de uso. O engine não escreve a tabela — um segundo caminho de escrita
seria a segunda fonte da mesma verdade, e a primeira coisa a divergir. É o
único precedente de o runner contar algo sobre si mesmo, e foi por isso que o
ADR o nomeou.
Nunca um "ok" otimista. O desfecho reportado é o REAL: as três contagens
que sincronizarEspelho devolveu (copiados, pulados, recusados — nunca
recontadas) ou o erro nomeado. As TRÊS saídas de tratarMirrorSync reportam,
e com mensagens diferentes: a recusa por destino não concedido, a falha da
cópia e o sucesso. ok NUNCA é deduzido de haver contagem ou mensagem — nem
no runner, nem na tradução do engine, nem no caso de uso da api: deduzir
tornaria uma rodada que copiou 0 arquivos (perfeitamente normal)
indistinguível de uma que nem chegou a rodar.
TABELA, e não event log. project_mirror_states, uma linha por projeto
(project_id único, mesmo desenho de project_containers). O motivo é o
mesmo que fez rag_searches virar tabela (RN-479):
session_events.session_id é NOT NULL e uma rodada de espelho não tem
sessão — o event log perderia a coisa inteira, não uma parte dela. E não é
proposed_action em hipótese nenhuma: a escrita do espelho é configuração que
o usuário declarou, não um agente pedindo para agir.
Os TRÊS estados da RN-088, e eles não colapsam. "Nunca
sincronizou" é a linha ausente. "Sincronizou e não copiou nada" é linha
presente com files_copied = 0 — um desfecho, não um vazio. "Falhou" é
last_error_at mais recente que last_synced_at. São três respostas
diferentes, a pessoa age diferente em cada uma, e um traço servindo às três
seria a tela recusando nomear o que sabe (RN-470).
Sucesso e erro CONVIVEM na mesma linha. As duas escritas mencionam colunas
disjuntas: a de sucesso não toca last_error/last_error_at, a de falha
não toca nenhuma coluna de sucesso, e nenhuma das duas apaga a outra. Quem
decide qual está VIGENTE é a comparação dos dois carimbos
(deriveMirrorSyncStatus, função pura do domínio), e é isso que permite a tela
dizer "a última rodada falhou — última cópia boa em ontem, com 412 arquivos".
Uma escrita destrutiva de um lado sobre o outro jogaria fora justamente a
informação mais útil que a tela tem enquanto o espelho está quebrado. Duas
escolhas menores na mesma direção: falha SEM destino não apaga o destino da
última cópia que funcionou, e empate exato de carimbo resolve para failed —
entre afirmar que está tudo bem e afirmar que algo falhou, a afirmação segura
é a que faz a pessoa olhar.
destination é CONGELADO, como image_version em project_containers e
os pesos em rag_searches (RN-479): é o destino REAL daquela
rodada, resolvido por realpath na máquina do usuário depois do mkdir -p.
Ler projects.mirror_path na hora de mostrar diria o destino de AGORA sobre
uma cópia de ontem — e os dois divergem de verdade, porque a concessão viaja no
join e só muda quando o runner reconecta (RN-516). Quando divergem,
a tela DIZ isso.
Gravar telemetria jamais derruba o que ela mede. A cópia já terminou
quando o reporte sai; o handle_in do engine só LOGA a recusa da api e devolve
{:noreply, socket} nos dois ramos, e o canal do runner segue vivo. Sem canal
(a conexão caiu entre o pedido e o fim da cópia) o runner não reporta e não
lança — a rodada que aconteceu não é desfeita por isso. E um projeto cujo
destino foi LIMPO no meio ainda registra: a rodada aconteceu, e o erro dela é
o que costuma explicar o que houve.
Na tela, a linha entra em AmbienteDoProjeto (a coluna lateral da Visão
geral, ao lado das de modo de execução e runner) e só existe em projeto que
tem mirrorPath — num projeto sem espelho, "nunca sincronizou" seria uma
ausência inventada, o mesmo argumento que já valia para a linha do runner. Data
ABSOLUTA com ressalva e nunca bolinha verde de "está de pé", pelo mesmo
precedente de workspaceVerifiedAt: o carimbo diz que uma rodada aconteceu,
não que a pasta esteja em dia com o que mudou depois (o gatilho é um momento
nomeado, o commit — nunca um watcher). O status chega RESOLVIDO da api: a
regra que decide qual carimbo vale tem uma fonte só, e é ela que decide qual
das três frases a pessoa lê.
Papel viewer para LER, maintainer para escrever o destino — a régua da
RN-102 aplicada: o mínimo é do ENDPOINT, e este só lê. O destino já
viaja em toda leitura de projeto (GET /projects/:projectId, viewer), então
exigir maintainer aqui trancaria informação para quem já a vê no mesmo
projeto, que é o pior dos dois defeitos porque é invisível para quem perdeu a
capacidade.
- Onde:
src/db/schema/iam.ts(project_mirror_states, no agregadoiamporque é lá que o destino mora — não hádomain/runners);src/db/migrations/0057_brave_silver_centurion.sql;src/domain/iam/mirror-state.ts(deriveMirrorSyncStatus, a regra dos três estados, pura);src/application/ports/mirror-state-repository.port.ts(DOIS métodos de escrita, não um upsert com tudo opcional — é o que torna a disjunção um invariante do tipo, não uma convenção do chamador);src/infrastructure/persistence/drizzle/mirror-state.repository.ts;src/application/use-cases/iam/record-mirror-sync.use-case.tse.../get-project-mirror-state.use-case.ts;src/interfaces/http/internal/internal-projects.controller.ts(POST .../mirror-sync-result);src/interfaces/http/iam/projects.controller.ts(GET .../mirror-state,viewer);apps/engine/lib/engine/sessions/engine_api_client.ex(report_mirror_sync/2);apps/engine/lib/engine_web/channels/terminal_channel.ex(handle_in("mirror_sync_result", …)e a tradução do vocabulário);apps/runner/src/channel.ts(MirrorSyncResultMessage,enviarMirrorSyncResult);apps/runner/src/index.ts(tratarMirrorSyncreporta as três saídas);apps/web/src/components/AmbienteDoProjeto.tsx - Teste:
test/application/use-cases/iam/mirror-sync-telemetry.use-case.spec.ts(os três estados na função pura e na leitura; erro que não apaga o sucesso; destino trocado; contagem 0; mensagem sem texto e mensagem longa; projeto sem destino que ainda registra);test/infrastructure/persistence/drizzle/mirror-state.repository.spec.ts(o invariante das colunas disjuntas contra o banco de verdade — um fake provaria o fake);apps/engine/test/engine_web/channels/terminal_channel_test.exs(describe "mirror_sync_result"— o repasse com tradução, ofilesCopied: 0que não some, campo ausente que NÃO viranil,sucessofaltando que viraok: false,:webignorado, e a api que recusa sem derrubar o canal);apps/runner/src/index-handlers.spec.ts(describe "o desfecho é reportado em mirror_sync_result"— contagem real, repositório vazio como sucesso, falha nomeada, recusa sem destino, e a conexão caída que não reporta nem lança);apps/web/src/components/AmbienteDoProjeto.test.tsx(describe "linha do espelho"— semmirrorPatha linha some e nem consulta; os três estados com três frases; nenhum vocabulário de liveness; destino trocado dito) - ADR: 0147, ponto 7
- Origem: FASE 28, sessão 8. Fica declarado e NÃO feito: nenhum alarme, nenhuma notificação e nenhuma retentativa automática de rodada que falhou — a próxima rodada acontece no próximo momento nomeado, como sempre, e a tela é o único lugar onde a falha aparece hoje
A revogação de chave de dispositivo deixa de ser cega, e alcança a conexão viva (RN-519..520)
RN-519 — Ninguém revoga o que não consegue ver: a chave de dispositivo ganha listagem do PRÓPRIO dono, e a visão de maintainer fica fora por DECISÃO
A sessão 7 da FASE 28 e o ponto 6 do
ADR 0147. A revogação existia desde
o ADR 0118
(DELETE /projects/:projectId/runner-device-keys/:deviceKeyId, developer,
204, idempotente) — e não havia como enxergar o que se revoga.
O corte real era maior do que o docblock dizia. Ele declarava a ausência
como "sem a visão de maintainer (listar/revogar de qualquer usuário) que o
PAT tem". Mas o PersonalAccessTokensController tem cinco rotas — @Post(),
@Get() e @Delete(':tokenId') para o próprio usuário, mais @Get('all') e
@Delete(':tokenId/admin') para maintainer (RN-427) — e o de chave
de dispositivo tinha duas. Não faltava só a visão de maintainer: faltava a
listagem do PRÓPRIO dono. Uma chave órfã (aba fechada no meio do fluxo da
RN-473) era inerte, invisível e permanente, sem tela nenhuma onde
revogá-la — a lacuna que o CLAUDE.md já declarava.
Entra @Get(), developer, no mesmo formato que o PAT já tem e escopado ao
próprio usuário no WHERE do repositório, nunca num filtro do caso de uso.
A revogada CONTINUA na lista. Mesma escolha de listarDoUsuarioNoProjeto
do PAT: quem revogou precisa ver que revogou, e sumir com a linha faria a tela
afirmar que a chave nunca existiu. revokedAt é o que separa as duas, e
lastUsedAt nulo é o sinal da ÓRFÃ — registrada e nunca usada por runner
nenhum.
Nunca a JWK pública. Ela não é segredo, mas não serve a nada aqui: a lista
existe para revogar, e para isso bastam id, nome e as três datas. A metade
privada a api nunca viu.
A visão de maintainer continua FORA — agora por decisão, não por omissão.
As duas rotas de admin do PAT nasceram de resposta a incidente (RN-427):
dev desligado com um segredo COMPARTILHADO circulando. Chave de dispositivo não
é esse bicho — a privada nunca sai do navegador que a gerou, então não há
segredo na mão de outro a conter. O que a DELETE daqui ganhou em troca foi
ALCANCE (RN-520), não amplitude.
- Onde:
src/interfaces/http/runner/runner-device-keys.controller.ts:99(@Get(),listDeviceKeys,developer);src/application/use-cases/auth/list-runner-device-keys.use-case.ts:24;src/application/ports/runner-device-key-repository.port.ts:90(listarDoUsuarioNoProjeto);src/infrastructure/persistence/drizzle/runner-device-key.repository.ts:69;src/interfaces/http/runner/dto/runner-device-key-list.response.dto.ts:19(as DUAS travas de tipo,Wire/MesmasChaves, ao contrário do DTO do registro) - Teste:
test/application/use-cases/auth/list-runner-device-keys.use-case.spec.ts(delegação comuserId/projectIdna ordem certa; a revogada que fica na lista; a órfã comlastUsedAtnulo; repositório que rejeita e propaga);test/interfaces/http/runner/runner-device-keys.controller.spec.ts(as TRÊS rotas emdeveloper; a delegação; a rota que não devolve lista vazia por engano) - ADR: 0147, ponto 6
- Origem: FASE 28, sessão 7. Fica declarado e NÃO feito: tela nenhuma.
Esta entrega é a metade server-side; a lacuna do
CLAUDE.mdfecha pela metade — a api passa a poder listar e revogar, eapps/webainda não tem onde fazê-lo - A outra metade FECHOU na RN-561: a aba Configurações ganhou a
seção que lista e revoga, marcando a ESPÉCIE de cada chave. O que segue
aberto daqui é a visão de
maintainer(acima, por decisão) e o alvo{projeto, usuário}da desconexão (RN-520)
RN-520 — Revogar deixa de só impedir ticket NOVO e passa a derrubar a conexão VIVA; o alvo é {projeto, usuário} e nunca {chave}, e derrubar nunca derruba a revogação
Revisada pela RN-685 (ADR 0201): o ticket do socket passou a guardar QUAL credencial o pediu, e a revogação de chave (e a de PAT) derruba só as conexões abertas COM ELA — outro runner do mesmo usuário, com outra chave ou com PAT, fica de pé. O "custo declarado" abaixo deixou de valer. O par
{projeto, usuário}sobrevive como o alvo da remoção de membro (RN-615), como o da conexão LEGADA (ticket sem credencial) durante o rollout, e como o plano B quando o engine não conhece a rota por credencial.
A outra metade do ponto 6 do
ADR 0147. Revogar uma chave de
dispositivo impedia o ticket SEGUINTE e nada mais: um brabo-runner já
conectado mantinha o canal terminal:<projectId> de pé — executando comando
aprovado, atendendo PTY, subindo container — com a chave revogada, até cair
sozinho.
Quem manda o comando é a api; quem alcança o pid é o engine. A api chama
POST /internal/projects/:projectId/runner/disconnect (o mesmo HTTP interno com
service token de sempre, nunca um segundo mecanismo), e o engine acha o canal
por Engine.Runners.Registry.whereis/1 — o pid que ele já entregava. O engine
não consulta a tabela de chaves e a api não fala com o canal; nenhum dos
dois faz o trabalho do outro, a mesma divisão de EngineWeb.ContainerCommandController
(RN-497).
A precisão que existe é {projeto, usuário}, e a que não existe é
{chave} — por construção, não por gosto. Engine.Runners.SocketTicket
guarda project_id, user_id e kind, e só; a api pede o ticket com
{userId, kind}. A identidade da CREDENCIAL que originou o ticket (o PAT, ou o
kid da chave de dispositivo — RN-475) morre no PatAuthGuard e
nunca chega ao socket. Levá-la até lá exigiria coluna nova em
runner_socket_tickets, campo novo no pedido interno e um assign novo no
connect/3: mudança de contrato de auth, fora desta entrega.
Custo DECLARADO, e nomeado no código, não só aqui: um runner do MESMO
usuário conectado com PAT, ou com outra chave do mesmo projeto, também cai. Ele
reconecta sozinho — a rodada seguinte pede um ticket novo, e a credencial que
ainda vale ganha um. Quem foi revogado não ganha, e é aí que a revogação morde.
Runner de OUTRO usuário no mesmo projeto não cai: a comparação é
socket.assigns.user_id, dentro do processo do canal, que é o único que pode
lê-lo — e o desfecho de_outro_dono volta ao pedinte em vez de um :ok que não
descreve o que aconteceu.
Derruba o TRANSPORTE, não só o processo do canal. Parar apenas o canal
deixaria o socket vivo e o cliente Phoenix reentrando no tópico para sempre com
um ticket já consumido — degradação silenciosa, exatamente o defeito que o ADR
0147 existe para não repetir. EngineWeb.RunnerSocket.id/1 deixa de ser nil e
passa a nomear a conexão (runner_socket:<kind>:<projectId>:<userId>), e o
canal usa o mecanismo documentado do Phoenix, Endpoint.broadcast(id, "disconnect", %{}). É por ele que o runner PERCEBE a queda: onDisconnected
resolve a rodada, o laço pede ticket novo e a api recusa.
Efeito colateral NUNCA derruba o efeito principal. O DELETE continua 204 e
idempotente: engine fora do ar, nenhum runner conectado ou timeout não podem
fazer a revogação falhar nem virar 5xx. A chamada ao engine mora num
try/catch que só LOGA, dentro do caso de uso — a MESMA régua de
rag_searches (RN-479) e do mirror_sync_result
(RN-517). E os quatro desfechos são informação, não erro: sem_runner
é o caso normal de quem revoga uma chave órfã (RN-519), e desfecho
DESCONHECIDO vindo do engine vira timeout e nunca derrubado — entre afirmar
que a conexão caiu e afirmar que talvez não, a afirmação segura é a que não
promete o que não se sabe.
Revoga PRIMEIRO, derruba depois, e o projeto sai da LINHA revogada, nunca da
URL: o projectId da rota já era ignorado no DELETE, e lê-lo aqui faria um
projectId divergente derrubar o runner de um projeto que não tem nada com esta
chave.
- Onde:
src/application/use-cases/auth/revoke-runner-device-key.use-case.ts:63(a ordem, o projeto da linha e otry/catchque só loga);src/application/ports/api-to-engine-client.port.ts:433(disconnectRunnerOfUser,DesfechoDeDesconexaoDeRunner);src/infrastructure/http-clients/api-to-engine-client.ts:477;apps/engine/lib/engine/runners/revogacao.ex:92(derrubar/3);apps/engine/lib/engine_web/channels/terminal_channel.ex:697(handle_info({:derrubar_por_revogacao, …}));apps/engine/lib/engine_web/channels/runner_socket.ex:79(id/1, que deixou de sernil);apps/engine/lib/engine_web/controllers/runner_connection_command_controller.ex:27;apps/engine/lib/engine_web/router.ex:86 - Teste:
test/application/use-cases/auth/revoke-runner-device-key.use-case.spec.ts(describe "a revogação alcança a conexão viva"— o projeto da linha; a ordem revoga→derruba; engine fora do ar que não derruba a revogação; 404 que não derruba ninguém);test/infrastructure/http-clients/api-to-engine-client.spec.ts(a rota e o corpo; desfecho desconhecido virandotimeout; 500 que LANÇA, para o caso de uso tratar);apps/engine/test/engine/runners/revogacao_test.exs(sem runner; o dono; o outro dono; o teto; argumento fora de forma que não levanta);apps/engine/test/engine_web/channels/terminal_channel_test.exs(describe "revogação alcança a conexão viva"— o canal REAL que para e libera a presença noRegistry, o de outro dono que fica de pé, o:webque nunca é alvo);apps/engine/test/engine_web/controllers/runner_connection_command_controller_test.exs(os 200 com desfecho, e o 400 de pedido malformado) - ADR: 0147, ponto 6
- Origem: FASE 28, sessão 7. Fica declarado e NÃO feito: a revogação não
alcança um runner conectado em OUTRO projeto com a mesma chave — não existe
esse caso, porque
runner_device_keys.project_idamarra a chave a um projeto só; e nenhuma tela dispara isto ainda (a metade web da RN-519)
RN-521 — A página /containers lista TODO projeto do workspace e vira o caminho HUMANO de subir o container, ramificado por execution_mode
Estendida pela RN-574 (ADR 0161): um quarto motivo de recusa local,
sem_broker_na_instalacao, paracontainer/mountednuma instalação sem broker de container — depois de "já está de pé" e antes de "sem imagem decidida".Estendida pela RN-590 (AT-105): a mesma leitura vale na conversão de modo, em parar/remover e na proposta do Infra Lead (recusa 409 da api ao propor).
Subir o container de um projeto tinha um caminho: o Infra Lead, dentro de
uma sessão, chamando a ferramenta dele. Numa execução real do exp004 (modo
runner) a container_start_via_runner falhou pelo defeito que a
RN-508 fechou — e não havia como TENTAR DE NOVO. Cinco dev agents
ficaram horas emitindo dev.blocked_by_container
(RN-502), e a única saída era abrir sessão nova e conversar com um
agente para reexecutar uma operação de máquina.
A página global de containers (ADR 0136,
RN-495) já era o lugar onde um HUMANO decide ciclo de vida de
container — mas ela nem mostrava o projeto: a régua era "uma linha por projeto
que já tem project_containers", e uma subida que falha ANTES de registrar não
deixa linha nenhuma. A tela escondia exatamente o projeto para o qual ela
precisava existir.
O TERCEIRO estado tem nome próprio, e não é stopped. O read model passa a
LEFT JOIN (era INNER) e registrado vira null para quem nunca provisionou —
não é um valor novo de ContainerLifecycleStatus (stopped é um container que
EXISTIU e parou), e não é "não observado" (que é sobre o daemon ter sido
perguntado). A tela imprime "nunca provisionado" e a coluna Observado diz
sem_container_registrado, o terceiro motivo de naoVerificado — os três não
colapsam (RN-088/RN-486).
A lista alargou; o ORÇAMENTO não. Perguntar o observado ao broker é chamada
de rede por projeto, com teto de 20 por carregamento
(TETO_DE_VERIFICACOES_POR_CARGA). Projeto sem project_containers não tem o
que observar, então nunca é elegível e nunca ocupa uma vaga: num workspace
com centenas de projetos vazios, quem tem container de verdade continua
verificado exatamente como antes. E continuam sendo TRÊS consultas em lote,
quantos projetos forem — nenhuma dentro de laço.
A ação ramifica por execution_mode, com a mesma régua do backend
(RN-497/RN-503): container e mounted propõem
container_start (broker, no servidor) e runner propõe
container_start_via_runner (agente local, na máquina do usuário). Os DOIS
payloads são diferentes de propósito e não se copiam: container_start carrega
a ELEIÇÃO de imagem (imagem, rede, recursos), lida da decisão vigente do projeto
no clique; container_start_via_runner tem schema só com rationale
(RN-508), porque ela sobe a imagem JÁ decidida e não elege nada.
A tela NÃO propõe o que já se sabe que vai falhar — e diz o motivo no lugar
do botão. Três recusas, cada uma com texto próprio, na ordem "estado do MUNDO
antes da capacidade de quem olha" (assim um viewer ainda lê "falta decidir a
imagem", e não só "você não pode"):
- sem imagem decidida — o portão da RN-105, que vale nos TRÊS
modos desde a RN-494; sem
artifact.project_imagenão há o que subir; runnerque nunca teve pasta confirmada (workspaceVerifiedAtnulo, RN-423) — nenhum agente local jamais conectou, então a ação falharia com certeza. Mesma honestidade que a ferramenta do Infra Lead já pratica ao recusar LOCALMENTE antes de propor (RN-508);- papel abaixo de
maintainer— o mínimo é o do ENDPOINT (RN-102): os dois tipos pedemmaintaineremdecide.ts, e a comparação sai deroleAtLeast, nunca de uma lista de papéis à mão. O que se tira é o CONTROLE, nunca a INFORMAÇÃO (ADR 0064): quem não pode continua vendo estado, imagem e recursos, e o motivo é dito UMA vez em TEXTO —titleem elementodisablednão abre no Chromium.
E o que a tela NÃO sabe, ela declara em vez de prometer.
workspaceVerifiedAt é registro de UMA confirmação e nunca batimento
(RN-468): não-nulo não prova agente local conectado AGORA, e a api
não tem sinal de presença (quem sabe do agora é o canal Phoenix, no engine).
Então a linha do runner oferece o botão com ressalva escrita — "se ele não
estiver rodando agora, a ação falha ao ser aprovada" — em vez de uma bolinha
verde que mentiria.
Nada de teto se mexeu. Toda proposta desta tela é proposed_action,
sempre com um HUMANO clicando, nunca um agente; container_remove continua no
teto absoluto (RN-418) e decide.ts fica byte a byte. O papel lido
é o do WORKSPACE, e a lacuna é declarada: o efetivo é
projectRole ?? workspaceRole (RN-471), e buscar project_members
por linha seria o N+1 de rede pelo qual esta página existe para não pagar —
quem foi REBAIXADO num projeto ainda vê o botão e recebe 403, que é o defeito
reparável (termina em toast), não o invisível.
- Onde:
src/infrastructure/persistence/drizzle/containers-overview.repository.ts:96(o LEFT JOIN, oORDER BYetemImagemDecidida);src/application/ports/containers-overview-repository.port.ts:20(executionMode/lifecyclenulo/temImagemDecidida/workspaceVerifiedAt);src/application/use-cases/containers/obter-visao-geral-de-containers.use-case.ts:33(sem_container_registrado) e:101(elegivelParaVerificacao, que é o que mantém o orçamento intacto);src/interfaces/http/containers/containers-overview.controller.ts:60;apps/web/src/routes/containers-subida.ts:114(decidirSubida, a regra inteira, pura);apps/web/src/routes/ContainersPage.tsx:119(os dois payloads) - Teste:
test/application/use-cases/containers/obter-visao-geral-de-containers.use-case.spec.ts(o terceiro estado com motivo próprio; 25 projetos vazios que NÃO empurram para fora da verificação os 20 com container real);test/infrastructure/persistence/drizzle/containers-overview.repository.spec.ts(projeto semproject_containersna lista;temImagemDecidida;container_start_via_runnerpendente; as TRÊS consultas que não crescem);test/interfaces/http/containers/containers-overview.controller.spec.ts(oregistradoaninhado e onullque sobrevive à conversão);apps/web/src/routes/containers-subida.test.ts(a ramificação por modo, as três recusas, a ressalva, a hierarquia de papel);apps/web/src/routes/ContainersPage.test.tsx(o cenário REAL doexp004: projetorunner, sem linha, imagem decidida, pasta confirmada → a ação proposta écontainer_start_via_runnercom payload só derationale, eGET .../containernem é chamado) - ADR: 0136 (a página), 0145 (o tipo de ação), 0133 (o outro)
- Origem: execução real do
exp004, decisão do dono do produto. Fica declarado e NÃO feito: a tela não sabe se há agente local conectado AGORA (não existe sinal de presença na api), e o papel lido é o do workspace, não o efetivo do projeto
O repositório nasce no handoff para o Dev Lead (RN-522)
RN-522 — O repositório é provisionado ao ACEITAR o handoff Arquiteto → Dev Lead, sempre local, e a falha nunca derruba o aceite
Revisada pela RN-582 (ADR 0165): o GATILHO passou a ser o aceite do handoff ao Arquiteto. O aceite ao Dev Lead descrito abaixo continua provisionando, como segunda porta idempotente, e o resto desta regra (sempre
local, falha como evento, adotado não é falha) segue valendo. O texto abaixo é o da decisão original.
A RN-541 tirou o provisionamento da criação e declarou que o gatilho
viria em seguida. Este é o gatilho — e sem ele um projeto criado ficaria sem
repositório para sempre, porque ProvisioningPage era o único chamador de
provisionRepository no web e a criação deixou de navegar para lá.
O momento é o aceite do handoff do Arquiteto para o Dev Lead, e não a ativação da execução. São instantes DISTINTOS, nesta ordem: aceitar o handoff ativa o Dev Lead; ativar a execução é quando os dev agents começam a reivindicar task. É no primeiro que "o desenvolvimento começou" passa a ser verdade, e é o que o dono do produto pediu — "quando o arquiteto passar o handoff para o desenvolvedor iniciar o desenvolvimento".
O ramo mora em AcceptHandoffUseCase, ao lado do ramo 'infra' que já existia
e pela mesma razão de ordem: o efeito acontece antes de activateAgent, de
modo que o agente acorde num projeto que já tem onde trabalhar.
Sempre local. É o único provider que não pede credencial nenhuma — o
if (providerName !== 'local') de ProvisionRepositoryUseCase nunca chega a
resolver segredo, e o LocalGitProvider.createRepo sequer lê o accessToken
que a assinatura aceita. É isso que torna um provisionamento automático
possível: ninguém escolheu hospedagem no meio do aceite, então não há credencial
a buscar nem pergunta a fazer. Publicar num provider remoto continua sendo
caminho separado (adoção, ou conversão em Configurações) — declarado, não
esquecido. Efeito colateral aceito: protectBranch: false no provider local faz
o último passo do bootstrap degradar (step_degraded), nunca falhar.
A falha vira EVENTO, nunca exceção. Este caso de uso não tem transação:
quando o provisionamento roda, updateStatus('accepted') e o evento
handoff.accepted já estão COMMITADOS. Um throw devolveria 500 ao usuário sobre
um handoff que, no banco, foi aceito — e ainda impediria o activateAgent logo
abaixo, deixando o Dev Lead sem acordar por causa de uma falha de git. Então o
desfecho vai para o event log como repository.provision_failed, com origem e
a mensagem real, e o aceite segue. É a régua de sempre: falha registra a origem
e nunca vira resposta vazia.
O que NÃO é falha: projeto que ADOTOU um repositório. ProvisionRepository
recusa esse caso com ConflictException de propósito — bootstrap de repositório
de terceiro só roda por aprovação de plano (RN-045) —, mas o projeto TEM
repositório, então não há o que provisionar e registrar isso como falha mentiria
sobre o estado dele. O ramo consulta findByProjectId e sai antes. Repositório
created que já existe não é pulado: cai no caso de uso normal, que é
idempotente por desenho ("rodar de novo não falha"), e é assim que um bootstrap
interrompido se completa.
- Onde:
src/application/use-cases/agents/accept-handoff.use-case.ts— o ramodev-leadnoexecutee o método privadoprovisionarRepositorio, com otry/catche a saída antecipada do repositório adotado;src/application/use-cases/agents/agents-use-cases.module.ts(o import deGitUseCasesModule— a seta éagents → git, egitnão importaagents, então não há ciclo) - Teste:
test/application/use-cases/agents/accept-handoff.use-case.spec.ts— caminho feliz ("aceitar para o Dev Lead provisionalocalcom o slug do projeto"), os dois casos de falha ("provisionamento que falha NÃO derruba o aceite nem a ativação" e "a falha vira evento nomeado, com origem e a mensagem real"), e os limites ("aceitar para OUTRO agente não provisiona nada", "projeto que ADOTOU repositório não provisiona, e isso não é falha", "repositório já CRIADO cai no caso de uso, que é idempotente por desenho") - Origem: pedido do dono do produto — "o gatilho deve vir quando o arquiteto passar o handoff para o desenvolvedor iniciar o desenvolvimento"
A chave de MÁQUINA que o instalador registra (RN-552)
RN-552 — Rota interna registra a chave de dispositivo de MÁQUINA, para o usuário ÚNICO da instalação, e registrar SUBSTITUI
Sessão 7 da FASE 30, fechando o
ponto 4 do ADR 0155 sobre a
espécie de chave que o ADR 0154
criou. É a lacuna que a RN-543 declarou por escrito e deixou aberta
de propósito, pela régua do DEPLOY_ENABLED: "nenhuma rota da api cria chave
de máquina ainda; isso é o install.sh, e nasce com o primeiro chamador". O
chamador chegou — é o instalador da sessão 6 —, então a rota nasce.
A rota. POST /internal/machine-device-keys, engine-service. Corpo
{ name, publicKeyJwk }, resposta 201 com
{ id, userId, name, createdAt, replacedKeyIds }. Sem GET e sem DELETE.
A credencial é o SERVICE TOKEN, e a alternativa foi recusada por motivo
escrito. É o mesmo instalador, no mesmo minuto, no passo seguinte ao da
RN-546: o BRABO_SERVICE_TOKEN foi gerado pelo próprio install.sh
e escrito no .env com modo 600, então apresentá-lo prova controle da
máquina — que é exatamente o que este passo quer provar. A candidata real
do outro lado era a credencial do usuário recém-criado, e ela contraria o
próprio ADR 0155: a senha é lida no TTY, "usada e descartada", e o ponto 5
declara que "o instalador não faz login por ninguém", razão pela qual a
resposta da primeira conta não devolve token de sessão. Exigi-la aqui obrigaria
o instalador a criar, na máquina, a sessão viva que aquele ADR recusou criar.
O que essa escolha custa, declarado e não escondido. Um
BRABO_SERVICE_TOKEN vazado passa a poder fabricar uma credencial de
acesso duradoura de um usuário — não só falar com as rotas internas. Está dito
aqui, no security-surface.md e no docblock do
controller, ao lado da declaração irmã da primeira conta. O que a chave concede
não aumentou: ela dá ao agente local exatamente os projetos que o dono dela já
alcança em developer, resolvidos contra o projeto pedido (RN-543).
A rota não sabe escolher de quem é a chave. Não há userId no corpo. O
dono é o usuário ÚNICO da instalação, resolvido por
UserRepository.usuarioUnicoDaInstalacao() (LIMIT 2, nunca LIMIT 1: com
uma linha seria impossível distinguir "há exatamente um" de "há muitos"); com
zero ou mais de um, 409 e nada é escrito. É o análogo exato da condição
da RN-546 — sobre a INSTALAÇÃO, nunca sobre o argumento pedido —, e
é o que impede que quem tenha o token escolha a vítima. Consequência declarada:
numa instalação com duas pessoas a rota não funciona mais, para ninguém. Ela se
cala como a da primeira conta se cala, e pelo mesmo motivo — pertence ao
MOMENTO da instalação, não à vida do produto.
Registrar SUBSTITUI, nunca acumula. Toda chave de MÁQUINA ativa do dono é
revogada na MESMA transação, e os ids voltam em replacedKeyIds. Uma máquina
reinstalada é caso legítimo e continua passando; mil chaves de máquina viram
impossíveis — e não por um número que envelhece, mas porque o conjunto de
chaves de máquina vivas de um usuário nunca passa de uma. A revogação vem
ANTES do registro: revogar depois deixaria uma janela com as duas vivas, e
falhar no meio deixaria a instalação com duas credenciais de máquina e nenhuma
forma de saber qual é a nova. Só as de MÁQUINA caem (project_id IS NULL): as
de PROJETO vieram do navegador (ADR 0118),
num fluxo que esta rota não conhece, e derrubá-las apagaria o pareamento de
quem já usa o produto — o erro que a RN-545 nomeou ao recusar
converter instalação alheia em silêncio.
A resposta devolve o id porque ele vira o kid. É a lição da
RN-475, a mais cara desta área: o id do registro vai gravado DENTRO
da JWK privada, no kid, e a cadeia inteira só o REPASSA — o agente local lê
jwk.kid, o JWT de ticket o leva no header, o PatAuthGuard acha a pública
por ele. Ninguém deriva esse id de outra coisa, e foi por ele faltar que o modo
automático do navegador nunca autenticou. O userId também volta, RESOLVIDO
pela api: quem chama não o manda, e devolvê-lo deixa o instalador conferir
contra a conta que acabou de criar em vez de supor.
A privada nunca viaja, e uma que chegue é RECUSADA. O par é gerado na
máquina e só a metade pública sobe — mesmo desenho do navegador, pelo mesmo
motivo, mantendo verdadeira a frase que a RN-519 usa. A validação de
forma virou régua ÚNICA no domínio (exigirJwkPublicaEd25519), chamada pelos
DOIS registradores: duas cópias divergiriam no primeiro dia em que uma delas
mudasse, como a política de senha da RN-546 já resolve. E ela ganhou
um caso que nenhuma das duas tinha: JWK com d responde 400 dizendo o
que chegou. d é a metade privada de uma JWK OKP (RFC 8037 §2) e só chega por
engano de quem serializou o par inteiro — antes, ela passava na checagem (tem
kty, crv e x como qualquer pública) e era gravada. Recusar é a
resposta; a mensagem nomeia o que veio, porque um "JWK inválida" genérico faria
a pessoa tentar de novo com o mesmo arquivo.
Kind próprio na trilha de auth. machine_device_key_registered: esta é a
única credencial duradoura de um usuário que nasce sem esse usuário autenticar
nada — quem prova é o controle da máquina. Numa suspeita de vazamento do
service token, é essa linha que diz quando e para quem ela foi criada, e o
metadata carrega os ids que ela substituiu, nunca a JWK nem o nome.
Sem GET e sem DELETE, e o que isso custa. Listar por aqui publicaria,
para quem só tem o token da máquina, o inventário de credenciais de uma pessoa;
revogar já existe onde tem dono humano
(DELETE /projects/:projectId/runner-device-keys/:deviceKeyId, que casa por
{id, usuário} e nunca por projeto). A assimetria tem preço, e ele está
declarado: numa instalação que ainda não tem projeto, tela nenhuma alcança
uma chave de máquina — e é justamente por isso que registrar SUBSTITUI em vez
de deixar órfãs para trás, que seriam vivas e inalcançáveis.
O que NÃO entrou: rota pública (mesma corrida da RN-546);
userId no corpo; chave de máquina para instalação com time (declarado acima
como lacuna, não como acaso); tabela nova (uma chave de máquina continua sendo
uma linha de runner_device_keys com project_id NULL, ADR 0154
ponto 1) — e nenhuma migration, porque a coluna já é nullable desde a
RN-543.
- Código:
src/interfaces/http/internal/internal-machine-device-keys.controller.ts:87(o@Post()da rota, e o docblock com a escolha de credencial),src/application/use-cases/auth/registrar-chave-de-maquina.use-case.ts:122(o dono resolvido),:124(o409),:136(a substituição antes do registro) e:146(projectId: null, o que faz dela uma chave de máquina),src/domain/auth/jwk-de-dispositivo.ts:33(a régua única) e:59(a recusa da privada),src/infrastructure/persistence/drizzle/user.repository.ts:31(usuarioUnicoDaInstalacao,LIMIT 2),src/infrastructure/persistence/drizzle/runner-device-key.repository.ts:148(revogarChavesDeMaquina, os doisIS NULL),src/domain/auth/auth-event.ts(machine_device_key_registered),src/interfaces/http/internal/dto/machine-device-key-internal.dto.tse…/machine-device-key-internal.response.dto.ts - Teste:
test/application/use-cases/auth/registrar-chave-de-maquina.use-case.spec.ts(caminho feliz comprojectIdnulo e dono resolvido; o409sem usuário único, sem escrever nada; a substituição ANTES do registro; a privada recusada antes de abrir transação; a trilha sem a JWK);test/domain/auth/jwk-de-dispositivo.spec.ts(a régua única, com ode a precedência da curva);test/interfaces/http/internal/internal-machine-device-keys.controller.spec.ts(o contrato da resposta, o corpo SEM dono, e as três do guard);test/infrastructure/persistence/drizzle/user.repository.spec.ts(um usuário devolve; dois devolvemnull, nunca "o primeiro");test/infrastructure/persistence/drizzle/runner-device-key.repository.spec.ts(a substituição contra o Postgres de verdade, e o que ela NÃO derruba);test/interfaces/route-surface.spec.ts(a rota classificada) - ADR: 0155 ponto 4, sobre a espécie criada pelo 0154
O teto de auto-rebaixamento também na REMOÇÃO (RN-556)
RN-556 — Remover a própria linha de project_members é recusado com 403 quando o efeito líquido é rebaixamento
Sobre o número. O salto de RN-542 para 556 é deliberado: a faixa 543–555 está reservada para a FASE 30, que já a alocou em branches em voo. Alocar aqui produziria a colisão que este repositório já observou três vezes no mesmo instante (ADR 0148 ×2, RN-522 ×2, RN-523 ×2), resolvida depois pela DATA. Nada foi pulado por engano.
O ADR 0127 pôs os dois
tetos da RN-472 só no caminho de ESCRITA e declarou o terceiro
movimento aberto, por escrito e com o custo estimado: "RemoveProjectMemberUseCase
NÃO ganhou teto… A premissa de que 'a remoção é sempre benigna' é falsa, e fica
escrita aqui para não ser redescoberta". Esta RN fecha essa porta — é o
BRB-001, P1 de segurança.
Remover não apaga um papel, TROCA o papel efetivo. ResolveEffectiveRoleUseCase.forProject
é projectRole ?? workspaceRole (RN-471), e a sobreposição vale nos
DOIS sentidos. Some a linha de projeto, o efetivo passa a resolver pelo segundo
termo — que pode ser MENOR. Um maintainer cuja autoridade vinha da linha de
projeto, viewer no workspace, removia a própria linha e caía para viewer
sem volta pela tela: repor é POST :projectId/members, que pede o maintainer
recém-abandonado. Sem papel de workspace nenhum, a queda é para acesso NENHUM.
É o teto 2, não um teto novo. ehAutoRebaixamento compara o papel efetivo
de HOJE com o de DEPOIS, e nunca precisou saber por qual rota o segundo chegou:
no add o papel-depois vem do corpo, na remoção vem do workspace.
remocaoEhAutoRebaixamento é essa aplicação, e DELEGA — não reimplementa
comparação nenhuma. Ela existe como função própria por UM caso que a assinatura
da outra não sabe enunciar: ator sem papel de workspace, onde o papel-depois é
"nenhum", que não é um Role. Esse caso é o rebaixamento máximo e recusa.
| movimento | desfecho |
|---|---|
| remover a própria linha, workspace com o MESMO papel | passa — é como alguém sai da lista |
| remover a própria linha, workspace com papel MENOR | 403 |
| remover a própria linha, SEM papel de workspace | 403 — a queda é para acesso nenhum |
| remover a linha de OUTRA pessoa, mesmo que ela caia | passa — capacidade legítima de sempre |
remover a linha que restringia o owner do WORKSPACE |
passa — só pode ELEVAR |
O teto 1 não ganha par, e a ausência é decisão. owner é o topo do
ROLE_ORDER: remover a linha de projeto de quem é owner no workspace só pode
elevar o efetivo dele, ou mantê-lo. Mais que inofensivo, é a ÚNICA forma de
desfazer uma restrição que o teto 1 impede de criar. Um teto ali recusaria só
movimentos benignos e tornaria permanente o estado que o ADR 0127 nasceu para
eliminar. Está escrito ao lado da função e fixado em teste.
Sem limiar, como o teto 2 — e o preço vem junto. A recusa é "a si mesmo",
não "abaixo de maintainer", pelo mesmo motivo da RN-472: um limiar copiaria um
número do @RequireRole do controller para dentro do domínio e envelheceria
calado se a rota mudasse de mínimo. O preço declarado é a auto-remoção de quem
tem owner no projeto e maintainer no workspace: reversível, e recusada
também. Era o único movimento benigno que passava antes e passa a recusar; segue
alcançável por outro maintainer.
A mensagem é PRÓPRIA. Quem clicou "remover" não pediu para mudar papel
nenhum, então MENSAGEM_TETO_AUTO_REBAIXAMENTO_POR_REMOCAO diz o MECANISMO —
sem a linha de projeto o papel cai para o do workspace, e voltar exige o papel
abandonado — em vez de reusar a frase do add, que falaria de um movimento que
a pessoa não fez.
A tela não muda, e a recusa aparece. MembersSection.handleRemove já
mostra mensagemDaApi(erro, …) num toast (RN-471), então a frase da
api chega sem uma linha de web. Diferente do teto 1, este gate É calculável no
cliente — mas calcular só ele produziria o meio-gate que o ADR 0127 recusou.
- Código:
src/domain/iam/tetos-de-rebaixamento.ts(remocaoEhAutoRebaixamentoeMENSAGEM_TETO_AUTO_REBAIXAMENTO_POR_REMOCAO, mais o comentário que diz por que o teto 1 não tem par),src/application/use-cases/iam/remove-project-member.use-case.ts(a assinatura comatorIde as duas leituras de papel),src/interfaces/http/iam/projects.controller.ts(@CurrentUser()no@Delete(':projectId/members/:userId'), a descrição corrigida e o@ApiForbiddenResponse),src/application/use-cases/iam/resolve-effective-role.use-case.ts(INTOCADO —projectRole ?? workspaceRolesegue como está) - Teste:
test/application/use-cases/iam/tetos-de-rebaixamento.use-case.spec.ts, bloco "Teto 2 pela outra porta — a remoção da própria linha" (as três recusas; a auto-remoção benigna; remover outra pessoa; e a remoção que devolve o projeto aoownerdo workspace). O primeiro deles é o teste de lacuna do ADR 0127 INVERTIDO, com o nome guardando a origem - ADR: 0156, que referencia o 0127 e fecha a lacuna que ele declarou
- Origem:
BRB-001, P1 de Segurança do registro do mantenedor — a lacuna que o ADR 0127 escreveu com a frase "quem for fechá-lo vê o teste falhar"
A rotação da chave mestra deixa de ser tentativa e erro (RN-562, RN-563)
RN-562 — A rotação da chave mestra é provada ponta a ponta, nas duas tabelas
O procedimento com o pior desfecho do Runbook
passa a ter verificação NOMEADA: um teste percorre a rotação inteira —
cifra com K1, publica K2, reenvelopa, descarta K1 e ainda decifra — contra
user_credentials e project_git_connections, com Postgres de verdade.
Por que de integração, e não mais um caso unitário. O que já existia
(envelope-encryption.service.spec.ts, o describe rotação da chave mestra)
exercita encrypt/decrypt/rewrap em memória, e isso não prova a única coisa
que o passo 3 do runbook depende: que o script percorre as duas tabelas.
Convertendo metade do acervo e reportando sucesso, descartar a chave velha
tornaria a outra metade ilegível para sempre. A prova disso é a asserção sobre
as duas linhas — e ela reprova quando a lista de tabelas do script encolhe.
A idempotência entra no mesmo teste, porque é a propriedade que o runbook
promete a quem interrompe o script: a segunda rodada reporta
re-embrulhados=0 e já na chave atual=1 nas duas tabelas.
Uma linha ilegível não aborta as outras. Registro embrulhado por uma TERCEIRA chave — o caso que o runbook chama de "veio de outro ambiente" — é contado, identificado por tabela e id, e deixa as demais seguirem; o envelope dele fica intocado.
O que o teste exigiu do código: rewrap-deks.ts era um main() disparado
na CARGA do módulo, então importá-lo de um spec rodaria a rotação. O núcleo
virou reenvelopar(db, cofre, reportarFalha), exportado, e main() — que lê
ambiente, abre o pool, imprime e escolhe o código de saída — passou a rodar só
sob require.main === module. O nome do script e a invocação do runbook
(node scripts/rewrap-deks.js) NÃO mudaram.
Nenhuma chave e nenhum segredo é literal no teste: os dois nascem de
randomBytes a cada rodada. Passphrase constante em fixture é string com cara
de segredo indo para o histórico, e o gitleaks varre branches.
- Código:
src/scripts/rewrap-deks.ts:93(reenvelopar, a função exportada),:100(a lista das duas tabelas, que é o que o teste fixa),:209(orequire.main === moduleque impede a importação de rotacionar) - Teste:
test/scripts/rewrap-deks.spec.ts— o caminho feliz (a sequência K1 → K1+K2 → reenvelopa → só K2), a idempotência, e o caso de falha da linha de outro ambiente, que também assere que o relato NÃO carrega segredo nenhum - Origem: AT-033 (EP-015/HS-022), finding
BRB-010
RN-563 — O envelope diz qual chave o embrulhou, e esse rótulo nunca decide se ele abre
user_credentials e project_git_connections ganham key_id text anulável
— a impressão digital da chave mestra que embrulhou aquele DEK. Com ela, a
pergunta que decide o passo 3 da rotação ("ainda há credencial na chave
velha?") vira uma consulta:
select count(*) from user_credentials where key_id is distinct from '<atual>'
union all
select count(*) from project_git_connections where key_id is distinct from '<atual>';
IS DISTINCT FROM, e nunca <>: com <>, a linha de key_id NULO — o acervo
anterior a esta coluna — sumiria da contagem, e "não sei qual chave" viraria
"já está na atual", que é a leitura que faz alguém descartar a chave velha
cedo demais.
O valor é HMAC-SHA256(chave derivada, 'brabo-master-key-id') truncado a 8
bytes, e não um contador no .env nem um hash da passphrase. Não há oráculo
novo, e isso foi MEDIDO, não suposto: quem lê o banco já tem um oráculo de
verificação perfeito — o próprio envelope é AES-256-GCM, que autentica, então
testar uma passphrase candidata já custava um scrypt e um decipher. Testá-la
contra a impressão custa o MESMO scrypt. E não há variável nova para o
operador errar: o valor é função pura da chave que ele já publicou.
O rótulo é OBSERVABILIDADE, nunca AUTORIDADE. decrypt fica byte a byte
como estava — tenta a atual, cai para a anterior, o GCM decide — e rewrap
continua decidindo "já está na chave atual" pela TENTATIVA. Confiar no rótulo
faria uma linha que MENTE (rótulo "atual", envelope na chave velha) ser pulada
em silêncio, ficar fora da contagem de re-embrulhados, e o passo 3 a tornaria
ilegível para sempre. Metadado incoerente vira perda de segredo; é por isso que
o teste que fixa esta regra é o de uma linha mentirosa sendo re-embrulhada
assim mesmo.
O que o rótulo compra na falha: quando NENHUMA das duas chaves abre, a mensagem deixa de ser genérica e nomeia o caso — "veio de outro ambiente" (rótulo desconhecido), "rótulo incoerente ou registro adulterado" (rótulo diz atual e não abre) e "não tem key_id" (linha anterior a esta coluna) são diagnósticos diferentes com ações diferentes. Nenhuma dessas mensagens carrega passphrase, chave derivada ou conteúdo do segredo — só impressões digitais.
Sem migração do acervo. O key_id nasce na próxima escrita (encrypt e
rewrap o gravam). As linhas de hoje ficam NULL, e numa instalação existente
a consulta só passa a valer depois da primeira rotação. Forçar re-embrulho de
tudo só para preencher a coluna transformaria observabilidade em operação de
risco sobre todas as credenciais.
O operador precisa da impressão corrente, senão a coluna não se compara contra nada: a api a registra no boot em uma linha, e a advertência de rotação que já existia passa a nomear as DUAS.
- Código:
src/infrastructure/security/envelope-encryption.service.ts:130(impressaoDigital),:107(a linha de boot),:113(a advertência com as duas),:226(o diagnóstico derewrap),:251(diagnosticoDaChave);src/db/schema/llm.ts:259esrc/db/schema/git.ts:76(a coluna nas duas tabelas); migrationsrc/db/migrations/0059_key_id_no_envelope.sql - Teste: o describe
key_id da chave mestraemtest/infrastructure/security/envelope-encryption.service.spec.ts— o caminho feliz (impressão estável e determinística, gravada porencrypte porrewrap), a prova de que o rótulo não decide (linha mentirosa é re-embrulhada), a linha legada sem rótulo, e os dois casos de falha (mensagem que nomeia a chave sem vazar segredo; "sem rótulo" dito como tal). A consulta em SQL de verdade está emtest/scripts/rewrap-deks.spec.ts, incluindo o caso da linhaNULLcontando como pendente - ADR: 0158
- Origem: AT-034 (EP-015/HS-022), finding
BRB-016