Pular para conteúdo

0020 — Contrato app↔app

Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-09-12 · Decidido em: 2026-08-04

Correção 2026-08-25: o mecanismo mudou — de openapi-generator (codegen no consumidor) para módulo de contrato tipado publicado pelo receptor. A casa é Java/Micronaut server-server, o benefício poliglota do codegen vale perto de zero e a capacidade do gerador era risco não provado; o módulo tipado dá enforcement em compile-time reusando o Maven da 0019. Nenhum app tinha implementado o codegen. O princípio ("quem recebe dita") e o resto seguem.

Contexto

A comunicação server-server da casa roda, mas não é segura para evoluir. Cada par de apps redefine os dois lados do fio à mão: zero tipo compartilhado, então um rename de campo quebra em silêncio (compila dos dois lados, falha no wire); sem versionamento de contrato, publicar uma mudança arrisca quebrar quem já consome; sem teste de contrato, ninguém sabe que o produtor e o consumidor divergiram até produção reclamar.

A decisão 0012 já fez metade do caminho: o micronaut-openapi deriva o OpenAPI dos @Controller ao compilar, e o checa-rotas.py confere a prosa contra esse spec no gate. Mas 0012 é explícita sobre o próprio limite — cobre rota e método; o corpo (request/response) fica para revisão de PR. É essa lacuna do corpo que morde quando um app chama outro: a rota confere, o shape do JSON não é contratado por ninguém.

O padrão ARQUIVO-FATO do integrador-server/docs/public/contratos/ (0014/0016) já publica contrato de um repo para o outro consumir — falta elevá-lo a norma de plataforma para todo par app↔app, com um mecanismo de tipo e versão.

Decisão

Esta decisão estende a 0012 para o corpo do contrato: quem recebe o chamado dita o contrato. 0012 continua valendo para rota/método (prosa × OpenAPI no gate); aqui fecha o request/response.

  • O receptor publica um módulo de contrato TIPADO; o caller Java consome por versão. O app que expõe o endpoint publica, do próprio repo, um artefato Maven <svc>-api (group br.com.xadm, no registry Forgejo — mesmo mecanismo da 0019) contendo os DTOs @Serdeable + a interface @Client das operações. O app que chama declara a dependência por versão fixa e injeta a interface — sem client/DTO escrito à mão. O ganho é enforcement em compile-time: o receptor renomeia um campo, bumpa o artefato, e o caller não compila até migrar — o rename passa a falhar no build, não no wire.
  • Duas classes de consumidor. (a) Caller Java da casa → o módulo tipado acima. (b) Caller não-Java / build-time / skill / externo (ex.: a skill /xadm-setup chamando POST /api/setup/provision) → não há artefato Java a consumir, então o OpenAPI publicado É o contrato: o receptor publica um openapi.yaml versionado e o consumidor valida contra ele.
  • O spec continua sendo emitido. O receptor segue gerando o OpenAPI via micronaut-openapi (como por 0012) e publica o openapi.yaml versionado — como documentação, como contrato da classe (b), e como base de um gate openapi-diff recomendado (não obrigatório: nas arestas Java o compile-time já pega o breaking). O checa-rotas.py segue como precedente do gate de contrato.
  • Módulo deps-light; auth/retry no caller. O <svc>-api traz só DTOs + interface (sem infra HTTP pesada). O Bearer <DESTINO>_API_TOKEN e o @Retryable ficam na config do caller (micronaut.http.services.<id>); o request-id/MDC vem do filtro do xadm-comum-web. Auth não acopla ao shape do contrato. Naming dos artefatos = <slug>-api com o slug de registry (0024): ex. integrador-server-api, integrador-sascar-api.
  • Proporcionalidade (simplicidade primeiro). O módulo tipado é o alvo, não um mandato day-1. Uma superfície M2M pequena (poucas arestas, DTOs simples) pode ficar spec-only — openapi.yaml publicado + gate checa-rotas + um teste de contrato no consumidor contra o yaml — até o módulo se pagar (mais arestas, ou um receptor que já publica lib). Montar multi-módulo + publish pipeline para 2 arestas minúsculas é over-engineering; o compile-time entra quando a superfície justifica. Cross-ref Engenharia — "simplicidade primeiro".

Correção 2026-09-12: destino sempre configurado vai em micronaut.http.services.<id>; destino que só existe em alguns deploys vai em @Client("${<app>.url}") via BeanProvider — o serviço declarado em micronaut.http.services vira bean eager e derruba o boot onde o destino não existe.

Risco original resolvido (2026-08-25): a versão anterior desta decisão dependia do openapi-generator gerar @Client Micronaut idiomático (auth/@Retryable) — capacidade não-provada (§1.2). O módulo tipado elimina essa dependência: o @Client é escrito uma vez pelo receptor na interface do <svc>-api, sem codegen. Risco moot.

  • Versionamento com compatibilidade. Publicar uma versão nova de um contrato não pode quebrar um caller pinado na anterior — a versão anterior continua servível durante a janela de deprecação, e o caller migra quando puder. Mudança incompatível = versão nova (path versionado /api/v2/... ou versão do artefato de contrato), não edição in-place da que está no ar.

  • Envelope de auth M2M. Chamada máquina-a-máquina entre apps carrega Bearer, com a env nomeada pelo destino — <APP>_URL + <APP>_API_TOKEN, um token por callee, mesmo valor no validador e em todo caller (a convenção de nome é o piso, Segurança §Segredos). Erro de auth responde RFC-7807 (application/problem+json) com code de máquina estável (401/403). Esse code (e um title humano de tipo) vêm da exceção levantada, via seam do processor da casa (xadm-comum-web) — não derivados do status; ver Corpo de erro. O token rota por rotação; é 1 token por instância (não compartilhado entre clientes — há uma instância por cliente). O int-sascar mantém token-como-tenant (o token identifica o cliente na entrada) — exceção existente, não se força a mudar.

  • Dois eixos de auth, não confundir. O contrato cobre só o M2M (envelope acima). A user-auth (Firebase / xadm_session / central, para quem tem usuário de verdade) é problema do xadm-seguranca, resolvido por lib, fora deste contrato. Um endpoint app↔app é M2M; uma tela com usuário é user-auth.

  • Status HTTP por caso — não há um número universal.

    • Sempre 200 + erro no corpo + Sentry onde PowerSync ou fila exige: o write-back uploadData do PowerSync e o push legado do X-Adm travam a fila do cliente se recebem não-2xx para erro de negócio (PowerSync §Config). Aí o 200-sempre é obrigatório e não muda — o erro vai no corpo, o Sentry registra.
    • Par app↔app novo, SEM PowerSync no meio, usa status HTTP real (4xx/5xx). Não se propaga o 200-sempre para contrato novo só por hábito: onde não há fila que trave, o status real é o contrato honesto.
  • request-id propagado entre hops. Um header de correlação (request-id) atravessa o envelope M2M em todo hop (integrador ↔ sascar ↔ plataforma), populando o MDC do log de cada app. O filtro que gera/propaga vive no xadm-comum-web (0019, Fase 2). Requisição rastreável ponta-a-ponta no multi-hop — hoje não existe em nenhum app (gap declarado em Java/Micronaut), vira norma aqui.

Correção 2026-09-12: o filtro de request-id/MDC foi prescrito e não publicado — nenhuma versão da xadm-comum-web o traz, e a norma derivada não o trata como existente.

  • Contract test = o compilador (Java) + compat (spec). Nas arestas Java o gate é o próprio compilador: o caller compila contra o <svc>-api publicado; rename incompatível não compila (sem openapi-diff necessário). Some-se um teste que prova que a versão N-1 do artefato segue servível depois de publicar a N (janela de deprecação). Na classe (b) (não-Java), o openapi-diff sobre o openapi.yaml pinado faz esse papel. É o que fecha "o corpo diverge em silêncio" que 0012 deixou aberto.

Alternativas descartadas

  • Deixar como 0012 (só rota/método) e cobrir o corpo por revisão de PR. É o estado atual — e o estado atual é o problema: rename de campo quebra no wire sem gate. Revisão humana não é oráculo de shape.
  • Codegen do cliente (openapi-generator) — mecanismo ANTERIOR, descartado (2026-08-25). Gerar @Client+DTOs do OpenAPI no consumidor tem valor real num consumidor poliglota — que a casa (100% Java/Micronaut server-server) não tem —, enquanto cobra o custo todo: capacidade do gerador não-provada (§1.2), spec parcial vira DTO frouxo, e detectar breaking exige um openapi-diff sobre spec pinado (o "contract test" que só regenerar-do-HEAD não pega). O módulo tipado dá o mesmo "quem recebe dita" com enforcement em compile-time e zero infra nova (reusa a 0019).

    Sobre "lib de contrato a quatro mãos". A objeção clássica — "DTOs num módulo acoplam os dois lados e o receptor perde a posse" — vale para a forma fraca (os dois lados mantêm o DTO à mão). A forma forte adotada aqui não incorre nisso: o <svc>-api é publicado pelo receptor, do próprio repo, e o caller só consome por versão — o receptor segue dono único do contrato, exatamente como no modelo OpenAPI, só que tipado e checado pelo compilador. - Forçar status HTTP real em todo endpoint. Quebraria o write-back do PowerSync e o push legado (fila trava). O status-por-caso preserva o 200-sempre onde a fila exige e adota o status real só onde é seguro. - Versionar in-place (editar o contrato no ar). Quebra caller pinado. Compat com janela de deprecação é o custo aceito para não quebrar ninguém.

Consequências

  • Todo par app↔app novo casa num contrato tipado e versionado, com teste bindando produtor↔consumidor. Rename de campo passa a falhar no gate, não no wire.
  • O receptor é dono do contrato. Muda o contrato criando versão nova; o consumidor migra na janela. Ninguém edita o shape do outro.
  • Generaliza o ARQUIVO-FATO para a plataforma — o docs/public/contratos/ de um repo deixa de ser padrão de um app e vira o mecanismo de publicação de contrato de qualquer receptor.
  • Janela de deprecação — número em aberto. O quanto uma versão anterior do contrato segue servível é decisão operável (SemVer do artefato + pin do caller já dão a compat); o número concreto fica para a spec de quem implementar a aresta. (A antiga 2ª confirmação de fase — a capacidade do openapi-generator — saiu com o codegen.)
  • request-id e o filtro de correlação nascem no xadm-comum-web — este contrato é um dos motivos de a Fase 2 existir.