Decisões de arquitetura
Por que o Oren é do jeito que é. Cada decisão aqui tem uma alternativa que foi considerada e rejeitada por um motivo específico — o motivo importa mais que a decisão, porque é ele que diz quando revisitar.
Task e Implementation são documentos separados
Seção intitulada “Task e Implementation são documentos separados”Decisão. Um contrato (Task) declara o que entra, o que sai e de que recursos precisa. Uma implementação (Implementation) declara qual imagem faz o trabalho e onde cada recurso é montado.
Por quê. A v0 tinha um TaskBuilder que misturava os dois lados:
builder.addVolumeMap("source", "/source")"source" é nome de contrato; "/source" é caminho de implementação. Como estavam juntos, as definições de task só podiam viver dentro da CLI — e por isso o projeto não conseguia ter um catálogo.
A linha exata. “Preciso de um repositório git” é da task. “Monto em /source” é da implementação. “Preciso de uma credencial GCP” é da task. “Ela vai em /keys/gcp-credential.json” é da implementação.
Consequência que compensa. Duas implementações do mesmo contrato são intercambiáveis por uma linha no pipeline. Verificado com analyze-commits: as versões shell e Node produzem saída byte a byte idêntica.
Dependências são uma lista única e tipada
Seção intitulada “Dependências são uma lista única e tipada”Decisão. Diretórios, credenciais e engines ficam em dependencies, cada uma com um type de um vocabulário fechado.
Alternativa rejeitada. Campos separados (workspaces, secrets, capabilities), que foi a primeira versão do spec.
Por quê. Como três campos, não existe a pergunta “qual implementação pede menos”. Como uma lista tipada, existe — e é a base do ranking no portal. Foi o próprio usuário quem apontou isso, e a proposta original era pior.
Dois eixos por tipo.
- Semântica — o que é (
secret/gcp-service-account). O autor da task declara isso. - Forma — como chega (
file,socket,string,directory). Derivada do tipo pelo registry.
Derivar a forma da semântica evita declarar duas vezes e é o que permite o gerador de CI traduzir credenciais corretamente.
Piso e delta. A Task declara o mínimo que qualquer implementação vai precisar. A Implementation declara o que ela adiciona. Duas implementações só são comparáveis porque o piso é idêntico — o que sobra é escolha de engenharia.
Demonstrado na prática: nenhum contrato exige engine/docker. A implementação anterior de build usava o socket do host (privilégio critical); a nova usa kaniko e não pede nada além do piso.
effects tem três valores
Seção intitulada “effects tem três valores”Decisão. none, reads-external, external.
| cacheável | repetir é seguro | |
|---|---|---|
none |
sim | — |
reads-external |
não | sim |
external |
não | não |
Por quê. O motor de execução cacheia agressivamente. Para uma task que publica imagem, servir do cache significa que o push não aconteceu — com o pipeline reportando sucesso. Demonstrado: um npm-publish executado duas vezes recebeu 409 Conflict do registry na segunda, provando que reexecutou de verdade.
reads-external surgiu do terraform-plan: não altera nada, mas lê estado remoto e infraestrutura real. Cachear devolveria um plano calculado sobre um mundo que já mudou. A distinção não muda o cache — nenhum dos dois é cacheável — mas muda o risco, e é o que permite ao portal mostrar que planejar é seguro repetir e aplicar não.
Critério. Se eu rodar de novo com as mesmas entradas e nada acontecer, o pipeline ainda está correto? Se não, é external.
Assimetria dos erros. Marcar external a mais custa cache. A menos produz um deploy silenciosamente ausente. Na dúvida, external.
O protocolo trafega por arquivo, não por argv
Seção intitulada “O protocolo trafega por arquivo, não por argv”Decisão. /oren/input.json e /oren/output.json. stdout e stderr são log livre.
Alternativa rejeitada. A v0 passava JSON em argv e lia a saída de stdout delimitada por marcadores.
Três problemas que isso resolveu de uma vez.
- Escaping. JSON em argv atravessa o shell do entrypoint;
$, aspas e quebras de linha exigiam escaping que nenhum lado fazia direito. - Colisão. stdout era canal de log e de dados ao mesmo tempo. Um worker que imprimisse o delimitador corrompia a saída.
- Vazamento. Tokens em argv aparecem em
pse em qualquer eco do comando.
Ganho não previsto. O protocolo ficou agnóstico de runtime, o que tornou a geração de CI possível sem mudar nada. Um worker que lê $OREN_INPUT_PATH roda igual pela CLI ou por um job do GitLab.
Uma imagem OCI é o único runtime
Seção intitulada “Uma imagem OCI é o único runtime”Decisão. Todo worker executa como imagem OCI. A linguagem é escolha de build-time, resolvida por SDK e template.
Alternativa rejeitada. Runtimes por linguagem (container, node, python…), que chegou a estar no spec.
Por quê. Foi o usuário quem notou: os “runtimes” que eu tinha modelado não eram runtimes — eram linguagens. Com um caminho de execução só, some a árvore transitiva do npm no lockfile, some a segunda superfície de supply chain, e adicionar uma linguagem deixa de ser mudança de spec.
A distinção é temporal.
| Build-time | Execution-time | |
|---|---|---|
| O que é | SDK / template | Imagem OCI + protocolo v1 |
| Quem vê | quem escreve o worker | CLI, portal, lockfile |
O SDK se distribui pela imagem base, não por gerenciador de pacotes. Um “SDK shell” são quatro funções copiadas para a imagem — não precisa de npm. Isso generaliza para qualquer linguagem, inclusive as sem registry conveniente.
Uma imagem pode servir várias tasks
Seção intitulada “Uma imagem pode servir várias tasks”Decisão. Implementações compartilham imagem e se distinguem pelo runtime.entrypoint.
Alternativa rejeitada. Um input comando no contrato, para uma task fazer plan ou apply.
Por quê. terraform-plan e terraform-apply têm effects diferentes, o plano é output de um e input do outro, e o diretório de artefatos é mutável só no primeiro. Um contrato único teria que declarar o pior caso em effects e perderia a informação de que planejar é seguro repetir.
Mas contratos separados não exigem imagens separadas: as duas apontam para oren-ci-terraform-hashicorp e escolhem /worker/plan.sh ou /worker/apply.sh.
Formulação. Subcomando é entrypoint, e quem escolhe é a implementação — não um input do contrato.
Nada pode depender de estado vivo em memória
Seção intitulada “Nada pode depender de estado vivo em memória”Decisão. Outputs são gravados em .oren/runs/<id>/ e relidos dali, mesmo na execução local.
Por quê. É o que faz o modo local ser um caso particular do modo gerado, onde cada step vira um job separado e nada sobrevive entre eles além do que foi materializado. Sem isso, a classe de bug “funciona na minha máquina, quebra no CI” existiria por construção.
O que a invariante proíbe. Passar referências vivas entre steps (handle de container, socket, objeto do SDK), outputs não serializáveis, e qualquer cache da CLI que não esteja no lockfile ou em caminho declarado.
Exceção que precisou de tratamento. Outputs marcados secret não são persistidos — gravar credencial em texto no .oren/ desfaria a garantia de que material sensível não toca o disco. O resume trata steps dependentes de valor secreto como não-retomáveis.
O motor de execução é dedicado ao Oren
Seção intitulada “O motor de execução é dedicado ao Oren”Decisão. Container (oren-engine-<versão>) e volume (oren-cache-<versão>) próprios, com a imagem oficial do Dagger.
Por quê. O Dagger roda um engine compartilhado por máquina. Sem isolamento, oren cache prune apagaria o cache de qualquer outro uso de Dagger do usuário, e oren cache limit reescreveria a configuração global dele. O Dagger é detalhe de implementação do Oren e não deveria ter consequências fora dele.
Não é um fork. Mesma imagem, mesmo digest. O que muda é o --name e o volume, escolhidos pelo SDK via _EXPERIMENTAL_DAGGER_RUNNER_HOST.
Riscos aceitos. A variável tem prefixo _EXPERIMENTAL_ e pode mudar sem aviso. E o cache não é compartilhado com outros usos de Dagger — layers comuns são baixadas duas vezes.
Versionamento. Container e volume carregam a versão porque o formato do cache do BuildKit não garante compatibilidade entre versões do engine. Versões antigas são removidas ao subir a nova, senão cada atualização deixaria um volume órfão.
A identidade do worker é imutável
Seção intitulada “A identidade do worker é imutável”Decisão. O oren.lock guarda referência com digest (repo@sha256:...) ou o digest do conteúdo do build.
Por quê. O consentimento é ancorado na identidade do worker. Antes do lockfile, essa identidade era a tag — e tags são mutáveis. Republicar alpine:3.20 com outro conteúdo mantinha o fingerprint intacto, e a autorização concedida a uma imagem valia para outra que ninguém revisou. O mecanismo apresentado como proteção contra escalada de privilégio tinha exatamente esse furo.
Para build local, o digest é do conteúdo do diretório: mover o worker não muda a identidade, alterar o conteúdo muda.
O consentimento é gravado e commitável
Seção intitulada “O consentimento é gravado e commitável”Decisão. .oren/consent.json, com fingerprint sobre identidade do worker + conjunto de dependências.
Por quê ser um arquivo. Um prompt a cada execução vira um “sim” automático. Gravado, o time herda a decisão e uma escalada de privilégio aparece como diff em code review — revisão de privilégio deixa de ser um prompt e vira parte do fluxo que já existe.
Por que o fingerprint cobre as dependências. Imagem nova, dependência nova, ou diretório que passou a ser mutável produzem chave diferente e obrigam nova autorização.
Fadiga. Dependência de privilégio low não pergunta. Ler um diretório não merece cerimônia, e cerimônia demais é o que faz o mecanismo deixar de ser lido.
Em CI, a decisão é falhar. Sem terminal e sem autorização gravada, o pipeline para. --yes existe mas é opt-in explícito. Privilégio novo não deve ser concedido sozinho.
O registry é aberto entre organizações
Seção intitulada “O registry é aberto entre organizações”Decisão. Uma organização publica um contrato, qualquer outra pode implementá-lo.
Por quê. É o que evita o problema original voltar em outra escala: se cada empresa escrevesse o próprio contrato, teríamos dez versões incompatíveis de build-docker-image.
Três regras que decorrem.
- Namespace obrigatório para publicar. Sem ele, o segundo
analyze-commitsjá colide. - Versão publicada é imutável. Republicar quebra quem depende, sem sinal.
- Não existe deleção, só depreciação. Já sabemos como termina a alternativa.
Consequência sobre implementações oficiais. Elas são de referência, não recomendadas. Ser “oficial” não pode virar peso no ranking — do contrário o ranking deixa de medir privilégio e passa a medir procedência, e o incentivo de terceiros publicarem desaparece.
O portal guarda o documento, não uma normalização dele
Seção intitulada “O portal guarda o documento, não uma normalização dele”Decisão. O task.yaml é armazenado íntegro. Índices para busca e listagem
são derivados dele.
Alternativa rejeitada. Normalizar em tabelas — uma para inputs, outra para dependências, e assim por diante.
Por quê. Normalizar é reimplementar o spec dentro do banco, e aí existem duas definições do que é uma task. Elas divergem na primeira mudança de schema, e a divergência aparece como bug de quem consome. Com o documento íntegro, o spec continua sendo a única fonte, portal e CLI validam contra o mesmo JSON Schema, e os índices são cache recriável.
A interface renderiza o documento — como as páginas do catálogo já fazem, geradas
do task.yaml.
O portal serve resolução, nunca execução
Seção intitulada “O portal serve resolução, nunca execução”Decisão. A API responde três perguntas: resolver namespace/nome@range para
um contrato, listar as implementações de um contrato com o delta de dependências,
e devolver imagem e digest de uma implementação.
O que isso proíbe. Endpoints que executem pipeline, agendem, ou guardem
estado de execução como fonte da verdade. Depois do oren install, um build não
pode depender do portal — é o que o lockfile garante, e o que mantém a promessa
de que o pipeline não pertence a nenhuma plataforma.
Consequência de ordem. Desenhar essa API antes do modelo de dados. O caminho inverso produz uma API que espelha o banco em vez de servir o cliente.
O portal confia no que a implementação declara
Seção intitulada “O portal confia no que a implementação declara”Decisão. Publicar uma implementação não exige verificação de que as dependências declaradas correspondem ao que a imagem faz.
Por quê. A defesa já existe e é melhor: a CLI concede exatamente o
declarado. Uma implementação que use o socket do Docker sem declarar
engine/docker falha ao executar. Sub-declarar não rende vantagem no ranking —
rende um worker quebrado.
Verificar no portal exigiria executar a imagem em ambiente controlado, o que é caro e ainda assim burlável (basta o comportamento depender de input). Não compensa antes de haver abuso real.
Contratos e implementações num repositório só
Seção intitulada “Contratos e implementações num repositório só”Decisão. workers/ é um repositório único.
Por quê. Eles mudam juntos. Alterar um tipo no registry ou adicionar um campo ao spec exige revisar todos os contratos no mesmo movimento — separados, viraria onze mudanças coordenadas.
Critério de saída. Quando um worker tiver ritmo de release próprio, mantenedor diferente, ou virar dependência de terceiros, ele sai. Monorepo → polirepo é barato nessa direção; o contrário não é.
implementation é obrigatório em todo step
Seção intitulada “implementation é obrigatório em todo step”Decisão. O schema de pipeline exige implementation. Não há default — nem quando o contrato tem uma única implementação registrada.
Por quê. Omitir era uma quebra à distância. Com uma candidata só, o step funcionava; no dia em que alguém publicasse uma segunda — outra pessoa, outra organização, outro repositório — o pipeline passava a falhar sem que ninguém tivesse tocado nele. É o mesmo não-determinismo que o oren.lock existe para eliminar, sobrevivendo num lugar onde não estávamos olhando.
O que roda tem que ser função do que está escrito no repositório, não do que existe publicado no momento da execução.
Alternativa considerada e recusada. Default para a implementação da mesma organização que publicou o contrato. Falha em dois pontos: colide com a decisão de que procedência não pesa no ranking (seria o peso máximo — não muda a ordem, decide), e não resolve o caso real, porque a mesma organização pode ter várias. techlite/analyze-commits tem duas, ambas low, ambas com zero dependências adicionais: nenhum desempate disponível significa alguma coisa. E um desempate arbitrário é pior que nenhum — parece intencional.
Consequência sobre o oren add. Ele passou a escrever o step no oren.yaml em vez de imprimir um snippet para copiar. A escolha continua sendo humana e feita uma vez só; o que muda é que ela fica registrada no arquivo em vez de depender de alguém ter lido a saída do comando. Com mais de uma candidata ele pergunta, mostrando o custo de cada uma; fora de TTY exige --impl.
Custo aceito. Verbosidade. Todo step ganha uma linha que, no caso de implementação única, não carrega escolha nenhuma — só a torna explícita.
O portal valida o documento na publicação
Seção intitulada “O portal valida o documento na publicação”Decisão. ValidadorDeDocumentos recusa qualquer documento que não satisfaça o schema do spec, antes de ele entrar no catálogo.
Por quê. O portal serve o documento íntegro e toda CLI o valida do outro lado contra o mesmo schema. Aceitar um documento inválido é publicar uma bomba: quem resolver aquela versão recebe algo que a própria ferramenta dele recusa, e o erro aparece na máquina de quem consome — longe de quem publicou, e sem que ele fique sabendo.
O Javadoc já afirmava que essa validação existia. Não existia: só o kind era conferido, e os schemas ficavam sincronizados nos resources sem uso.
Custo aceito. A lib (networknt/json-schema-validator) traz Jackson 2 junto, e o projeto usa Jackson 3. Os dois convivem — pacotes distintos — e a travessia acontece num único ponto, dentro do validador. A alternativa era reimplementar JSON Schema.
O que isso NÃO resolve. O caso inverso: documento válido para um portal novo e inválido para uma CLI antiga, porque os schemas do spec são estritos. Ver Compatibilidade.
Uma vez público, sempre público
Seção intitulada “Uma vez público, sempre público”Decisão. Uma task ou implementação publicada como pública nunca pode virar privada. A transição existe só no sentido inverso: privada → pública.
Por quê. Tornar privado o que já era público quebra todo pipeline que o referencia, em máquinas que ninguém tocou, e sem sinal — o oren install de amanhã falha resolvendo algo que resolvia ontem. É a mesma classe de problema que a imutabilidade de versão evita, com um agravante: a versão continua existindo, só deixou de ser alcançável.
Isso vale mesmo quando quem publicou tem todo o direito de mudar de ideia. O dano não é dele.
Consequência para o modelo pago. Contratos e implementações privados são o adicional que faz sentido cobrar — uma organização publica no registry sem expor ao mundo. Mas a privacidade é uma escolha do momento da publicação, não um interruptor. Uma conta que deixe de pagar não pode ter o que era público retirado do ar; o que se perde é a capacidade de publicar coisas novas em privado.
O que isso impõe ao schema. A visibilidade é do documento publicado, não da organização. Se fosse um campo da organização, mudar o plano dela mudaria a visibilidade de tudo que ela já publicou de uma vez — exatamente o que esta decisão proíbe.
Adiado, não descartado. Cobrança, planos e faturamento fazem parte do plano — o que não faz sentido é encará-los agora. Sozinhos, dão um projeto do tamanho do registry inteiro (meios de pagamento, ciclo de assinatura, inadimplência, fiscal), e começá-los antes de o registry ser usável adiaria o que ainda falta aqui.
O que esta decisão garante é que adiar não custa caro: a regra “uma vez público, sempre público” e a visibilidade morando no documento são o que impede que o modelo de cobrança, quando existir, precise mexer no que já foi publicado.
Namespace é plano; times separam permissão, não nome
Seção intitulada “Namespace é plano; times separam permissão, não nome”Decisão. Não haverá sub-namespaces (acme/plataforma/build). Uma organização com vários times ou tem uma organização por time (acme-plataforma, acme-dados), ou usa times internos — que controlam quem publica o quê, sem aparecer no identificador.
Por quê. O namespace aparece em toda referência de todo pipeline que usa a task. Um nível a mais o transforma num reflexo do organograma — e organogramas mudam. Um time que se reestrutura mudaria a referência de tudo que já publicou, o que colide de frente com uma vez público, sempre público: a versão continuaria existindo, com outro nome, e quem a referenciava pararia de encontrá-la.
A estrutura interna de uma empresa não deve vazar para um identificador que terceiros escrevem nos próprios arquivos.
Precedente. npm e GitHub separam as duas coisas: times existem e controlam permissão, mas o nome do pacote continua plano (@acme/x). O GitLab misturou — subgrupos mudam o caminho — e é a fonte recorrente de “onde está X?” e de migrações caras quando um time se move.
O que fica em aberto, e é a necessidade real. Hoje o papel é da organização inteira: quem pode publicar, publica tudo. “Só o time de plataforma publica acme/build” é permissão por recurso, e não precisa de pasta nenhuma para existir. É o que falta no modelo — não um nível a mais no nome.
Agrupar para navegar — 200 tasks numa organização grande — é apresentação. Resolve-se com tags e filtros na interface, sem tocar no identificador.
Custo aceito. Uma organização que queira namespaces separados por time precisa criar organizações separadas, e membros se repetem entre elas. É mais cadastro; em troca, nenhuma reorganização interna quebra a referência de ninguém.
Mantenedores restringem por task; ausência deles não restringe nada
Seção intitulada “Mantenedores restringem por task; ausência deles não restringe nada”Decisão. Uma task pode ter mantenedores. Se tiver, só eles publicam versões novas dela. Se não tiver, vale o papel na organização.
Por quê. É a necessidade real por trás da pergunta sobre times — “só o time de plataforma publica acme/build” — resolvida sem tocar no identificador. Ver namespace é plano.
Restringe, nunca concede. O papel na organização continua sendo o piso: um mantenedor que não seja publicador não publica nada. A restrição por task tira de quem já podia, e não dá a quem não podia. Sem isso, atribuir mantenedor viraria uma forma lateral de conceder acesso, contornando o admin.
Ausência de mantenedor é ausência de restrição, e não “ninguém pode”. O contrário trancaria, na primeira execução da migration, todas as tasks que já existem — e ninguém publicaria nada até atribuir mantenedor a cada uma. Pelo mesmo motivo, remover o último mantenedor devolve a task à organização: uma task cujo único mantenedor saiu da empresa não pode ficar trancada para sempre.
Por nome da task, não por versão. Mantenedor é de uma task ao longo do tempo. Amarrar a uma versão exigiria reatribuir a cada publicação — justo no momento em que a permissão precisa já existir.
A task não precisa existir. Reservar quem cuida de build antes da primeira versão é o caso normal: é assim que um time garante que ninguém publique a v1 no lugar dele.
Custo aceito. A gestão é pessoa a pessoa. Para um time de cinquenta, cansa. Times como agrupamento entram depois — e entram como açúcar sobre isto, sem mudar a semântica: um time se expande nas contas dele no momento da checagem.