JSON-RPC e schemas versionados num CLI local
Quem já integrou dois sistemas conhece a cena: um lado muda um campo na mensagem, o outro só descobre quando algo quebra em produção. Esse problema costuma ser associado a serviços de times diferentes, mas ele existe em qualquer lugar onde dois programas conversam, até dentro de um CLI rodando na sua máquina. No DevStation, a interface e o engine conversam por JSON-RPC 2.0 com contratos versionados, como dois sistemas independentes, sendo possível até utilizarmos testes de arquitetura para quebrar o build se a interface tentar importar o engine diretamente. Num CLI local isso soa como burocracia. Este artigo é sobre por que fiz assim mesmo, e sobre o que essa fronteira destrava.
No artigo sobre extensibilidade contei que a migração para essa fronteira foi incremental, um contexto por vez, com revisão formal antes de expandir. Aqui quero descer no como: o que significa versionar os schemas, como eventos atravessam a fronteira e para onde essa porta aponta.
Contratos com versão
Se versionamento de contrato é novidade para você, a ideia é menor do que o nome. Um schema é a descrição formal das mensagens que a fronteira aceita: quais operações existem, quais campos entram em cada pedido, quais voltam em cada resposta e o tipo de cada um. Versionar é registrar essa descrição com um número que acompanha as mudanças, e o combinado é simples: campo novo não quebra ninguém e a versão anda pouco, campo removido ou renomeado quebra alguém, e a versão sobe para avisar os clientes antes de o problema virar erro em produção.
No DevStation, os contratos são documentos OpenRPC, um por bounded context, e são a fonte de verdade: nenhum código de runtime vive junto deles. Cada contexto carrega sua própria versão semântica, e a regra de evolução é aditiva: adicionar um method ou um campo é rotina, remover ou renomear é breaking e sobe a versão daquele contexto. O código dos clientes é gerado a partir dos schemas, TypeScript hoje, com o mesmo caminho previsto para outras linguagens, e o CI verifica que a geração é idempotente, ou seja, gerar duas vezes produz exatamente o mesmo arquivo.
A parte que segura tudo na prática são os testes de contrato: o JSON de request e response de cada method fica travado como snapshot, e qualquer drift falha o build. Na prática, o fluxo de mudança é sempre o mesmo: edito o schema, rodo a geração de código, o cliente ganha os tipos novos, e o teste compara o JSON de cada operação com o snapshot gravado no repositório. Um campo renomeado aparece como diff no CI antes de chegar a qualquer cliente. Mudar um contrato vira um ato explícito, visível e revisável, em vez de um efeito colateral que alguém descobre em runtime.
Eventos atravessam a fronteira
Nem tudo é request e response. Provisionar um cluster leva minutos, e a interface precisa acompanhar sem ficar perguntando. Para isso a fronteira usa a segunda metade do JSON-RPC, as notifications: a UI dispara o provisionamento, recebe um id de execução na hora, e o engine empurra o progresso e a conclusão como mensagens que não esperam resposta. É o mesmo padrão que o Language Server Protocol usa há anos dentro dos editores, então não há nada exótico aqui, só um padrão maduro aplicado num lugar pouco usual.
O mesmo canal serve para avisos que não são progresso: quando a sessão expira, é o engine quem avisa, e a interface reage pedindo a senha de novo, sem precisar manter nenhum relógio próprio. O padrão se repete para qualquer trabalho demorado: a chamada volta na hora com um identificador, o trabalho segue em background, e quem interessar acompanha pelos avisos.
O que o isolamento paga hoje
A consequência imediata é que a TUI, a interface do DevStation que roda dentro do terminal (o mesmo estilo de ferramentas como htop ou lazygit), virou um cliente, e não a dona do engine. O MCP é outro cliente da mesma fronteira, com paridade de operações como requisito, então um agente de IA opera o sistema pelos mesmos contratos que a interface humana. E trocar de interface deixou de ser reescrita: cheguei a catalogar no roadmap uma TUI alternativa em Go, para o caso de a aposta na atual não se confirmar. A aposta se confirmou e a ideia está arquivada, mas o que importa é o preço que ela teria: uma reescrita da camada de UI, com o engine intacto.
A porta para o futuro
Está registrado no roadmap, desde a spec da fronteira, o que ela destrava além da troca de UI: um app desktop, uma web, o core headless (o engine sem interface nenhuma) consumido por integrações. O transport de hoje é stdio, a entrada e saída padrão de um subprocesso local, mas a interface de transport foi desenhada para receber outros, e é aí que a visão de longo prazo mora: o mesmo contrato sobre um socket transforma o CLI num serviço backend. Dali, um SaaS de uso interno para um time pequeno vira sobretudo uma questão de transport e autorização, e um multi-tenant público, uma instância única atendendo vários clientes isolados, é o degrau seguinte da mesma escada. A visão do projeto sempre foi começar no homelab e evoluir para times pequenos, e a arquitetura hexagonal é o que mantém esses degraus alcançáveis sem reescrever regra de negócio.
Nada disso é promessa. São opções, e opções podem nunca ser exercidas. O que consigo afirmar é o custo de mantê-las: um envelope, contratos versionados e a disciplina de não deixar um import atravessar a fronteira.
Volto à burocracia do começo. Num CLI local, JSON-RPC com schema versionado parece peso desnecessário, e seria, se o objetivo fosse apenas o CLI de hoje. O que comprei com esse peso foi a liberdade de não decidir agora o formato final do produto. É cedo para dizer qual degrau da escada será exercido, mas subir qualquer um deles não vai começar com uma reescrita.
Referências
- JSON-RPC 2.0 Specification, jsonrpc.org
- OpenRPC Specification, open-rpc.org
- Language Server Protocol, Microsoft
- Model Context Protocol, Anthropic
