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(groupbr.com.xadm, no registry Forgejo — mesmo mecanismo da 0019) contendo os DTOs@Serdeable+ a interface@Clientdas 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-setupchamandoPOST /api/setup/provision) → não há artefato Java a consumir, então o OpenAPI publicado É o contrato: o receptor publica umopenapi.yamlversionado 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 oopenapi.yamlversionado — como documentação, como contrato da classe (b), e como base de um gateopenapi-diffrecomendado (não obrigatório: nas arestas Java o compile-time já pega o breaking). Ocheca-rotas.pysegue como precedente do gate de contrato. - Módulo deps-light; auth/retry no caller. O
<svc>-apitraz só DTOs + interface (sem infra HTTP pesada). O Bearer<DESTINO>_API_TOKENe o@Retryableficam na config do caller (micronaut.http.services.<id>); o request-id/MDC vem do filtro doxadm-comum-web. Auth não acopla ao shape do contrato. Naming dos artefatos =<slug>-apicom 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.yamlpublicado + gatecheca-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}")viaBeanProvider— o serviço declarado emmicronaut.http.servicesvira 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-generatorgerar@ClientMicronaut 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) comcodede máquina estável (401/403). Essecode(e umtitlehumano 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). Oint-sascarmanté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 doxadm-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
uploadDatado 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.
- Sempre 200 + erro no corpo + Sentry onde PowerSync ou fila exige: o write-back
-
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-webo 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>-apipublicado; rename incompatível não compila (semopenapi-diffnecessá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), oopenapi-diffsobre oopenapi.yamlpinado 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 umopenapi-diffsobre 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.