Lição 16 · Curso de Fusão·Parte 3 · A arquitetura · As quatro invariantes
Parte 3 · A arquitetura · Lição 16
As quatro invariantes
A arquitetura do Alembic repousa sobre exatamente quatro propriedades — não seis, não "boas práticas", mas quatro invariantes nomeadas, cada uma afirmada no código-fonte e a maioria governada por um ADR. São as regras que todo pacote cumpre, e a razão de o sistema ser testável, replayável e confiável. Conhecê-las é conhecer a espinha do motor. Fonte: docs/alembic-complete-map.md §2, conferida contra os arquivos citados.
Cada bloco tem uma versão Simples e uma Técnica. Abra a técnica quando quiser o código real.
O que presumimos de você
Que você já viu a cintura estreita (lição 14) e o funil (lição 15) — vamos nos apoiar nelas.
Que "invariante" pode soar sofisticado. Não é: é só uma regra que nunca pode ser quebrada. Explicamos cada uma do zero.
Nada além disso. Não é preciso saber TypeScript a fundo para entender por que essas quatro regras seguram o motor.
1
A grande ideia
Ao terminar esta lição você consegue
Nomear as quatro invariantes e dizer qual ADR governa cada uma.
Explicar por que cada uma torna o motor testável, replayável ou confiável.
Reconhecer a diferença entre dissenso de prompt (frágil) e dissenso estrutural (garantido).
Ligar a invariante ③ à regra "planos não podem usar Date.now()".
A maioria dos sistemas se descreve por uma lista longa de "princípios". O Alembic faz o contrário: ele se reduz a quatro propriedades que nunca são violadas. Tudo o mais — convenções, padrões de pasta, estilo de código — é consequência dessas quatro, não um quinto princípio.
Pense num prédio: ele não tem "cem boas ideias de engenharia". Ele tem fundação, estrutura, vedação e instalações — e o resto decorre disso. As quatro invariantes são os pilares; a casa inteira (os 19+ pacotes) fica de pé porque eles ficam.
As três propriedades do topo não são vagas — cada uma é comprada por uma invariante concreta.
Uma nota sobre "seis". Uma versão antiga deste curso dizia "seis invariantes". Estava desatualizada. O mapa as-built lista exatamente quatro — a contagem abaixo é a autoritativa. Confundir "princípios" extras com as invariantes que de fato sustentam o peso é precisamente o tipo de deriva que o método de reverse-engineering (lição 20) existe para pegar.
As quatro invariantes como quatro pilares — cada uma afirmada no código-fonte, a maioria governada por um ADR.
2
As quatro, num relance
Quatro pilares — leia da esquerda (①) para a direita (④). O resto da arquitetura repousa sobre eles.
Preveja antes de continuar
Das quatro, qual você acha que a maioria das equipes acha que tem, mas não tem de verdade?
A ④ — dissenso. Quase todo "council" adiciona um prompt "seja o advogado do diabo" e se diz adversarial. O Alembic não confia num prompt para isso: ele torna o dissenso estrutural. Voltamos a essa diferença na seção ④.
#
Invariante
Por que importa
Onde se prova
①
run() nunca lança
todo resultado é um valor tipado — erros não escapam como exceções
adapter-core.ts:118 · ADR-0009
②
agnóstico a adapter & store
todo engine roda em memória, com fakes, sem rede
debate.ts:71-83 · funnel.ts:79-81
③
IDs por conteúdo + run-dir determinístico
entradas iguais convergem no mesmo id e lugar — runs são replayáveis
orchestrator.ts:168
④
dissenso preservado pelo Verifier
a pressão adversarial é propriedade do sistema, não um pedido ignorável
verifier.ts:19-99 · ADR-0003
3
① run() nunca lança; o resultado é uma união discriminada uniforme
Você já viu isto na lição 14. Quando você manda o motor rodar algo, ele nunca "estoura" uma exceção na sua cara. Ele sempre devolve um valor que diz, de forma uniforme, "deu certo, eis o resultado" ou "deu errado, eis o erro tipado". Um erro nunca é uma surpresa que escapa — é um dado que você inspeciona.
É a diferença entre um cano que estoura molhando a casa e um painel com luzinhas: a falha aparece num mostrador previsível, no mesmo formato de sempre, em vez de inundar tudo.
como é imposto
É imposto estruturalmente por runWithGuards (packages/adapters/src/adapter-core.ts:118) e verificado ao vivo — o handoff registra um 429 real aparecendo como uma falha tipada. O núcleo de orquestração restabelece a mesma fronteira uma camada acima com runDebateSafe / runSwarmSafe (packages/harness/src/core.ts:314-334), de modo que um throw dentro de um passo de council ou swarm também vira um valor. Governado por ADR-0009.
a forma da união discriminada
type Result<T, E> =
| { ok: true; value: T } // caminho feliz
| { ok: false; error: E }; // caminho de erro — tipado, nunca lançado// quem chama SEMPRE checa o discriminante .ok antes de usar .valueconst r = await runWithGuards(/* … */);
if (!r.ok) return r; // a falha é um valor que você propaga
use(r.value);
A fronteira é restabelecida em cada camada — nada escapa como exceção não tratada.
Guarde istoFalha é dado, não evento. Você nunca embrulha uma chamada do motor em try/catch esperando uma surpresa — você checa .ok.
O discriminante .ok torna impossível "esquecer" de tratar a falha — ela está no tipo.
4
② Engines são agnósticos a adapter E a store
Os kernels puros recebem apenas views readonly e efeitos colaterais injetados. Nenhum engine sai por conta própria para falar com um modelo de IA, com o disco ou com um banco. Tudo o que ele precisa chega como argumento. Troque o argumento por um fake e o engine inteiro roda em memória, de graça, sem rede.
É como uma cozinha profissional que não tem fornecedor fixo: os ingredientes chegam pela porta. Você pode entregar ingredientes de verdade (produção) ou de brinquedo (teste) — a receita é exatamente a mesma.
o padrão, em todo lugar: dependências chegam como argumentos, nunca como import de concretos
O DebateEngine, o scoring e o verifier recebem um AdapterRegistry injetado (packages/council/src/debate.ts:71-83). A camada ETL roteia todo o IO por um FsPort injetável — "every function here is testable in-memory" (packages/etl/src/index.ts:12). E o funil recebe um registry de adapters injetado, então trocar por um registry offline torna o run inteiro $0 e hermético (packages/harness/src/funnel.ts:79-81).
// dependências chegam como argumentos → teste com fakes
runDebate({ board, pack, adapters, requestId }); // adapters injetados
runT0Pipeline(corpusDir, { fs }); // FsPort injetado → em memória
runFunnel(corpusDir, { adapters: offlineRegistry }); // registry offline → $0, hermético
Isto é a lição 5 (ports & injeção) elevada a lei arquitetural: nenhum engine alcança um adapter, filesystem ou store concreto. É exatamente o que faz a suíte de 400+ testes rodar rápido, em memória, sem rede — e o que permitiu que os subsistemas de @alembic/hermes (lições 7–13) fossem testados sem abrir um único socket.
"Agnóstico" não é só "tem uma interface" — é que o engine nunca importa o concreto.
Cartão · invariante ②
Qual é o teste prático de "agnóstico a adapter e store"?
clique para virar
resposta
Você consegue rodar qualquer engine inteiramente em memória, com fakes — sem rede e sem disco. Se precisa de um socket aberto para testar, a invariante foi quebrada.
5
③ IDs endereçados por conteúdo + layout determinístico de run-dir
O id de um run é o hash do conteúdo do seu spec (estilo SHA-256). Mude qualquer campo do spec e você ganha um diretório de run novo. Runs são replayáveis porque entradas idênticas convergem no mesmo id e no mesmo lugar no disco — rodar de novo retoma ou re-deriva o mesmo resultado em vez de criar um run paralelo.
É como a impressão digital de um documento: dois documentos idênticos têm a mesma digital e vão para a mesma gaveta. Trocou uma vírgula? Digital nova, gaveta nova. Nada se mistura por acaso.
content-addressed por construção
O id é resolvido por runIdFor(spec) (packages/swarm/src/orchestrator.ts:168) — um hash sobre o JSON canônico (chaves ordenadas) do spec. Os stores são endereçados por conteúdo por SHA-256 sobre esse mesmo JSON canônico, então re-anexar conteúdo idêntico é um no-op: re-runs convergem em vez de duplicar.
// run dirs: <baseDir>/runs/<runId> · events.jsonl append-only + checkpoint.json// stores endereçados por SHA-256 sobre JSON canônico (chaves ordenadas) →// re-anexar conteúdo idêntico é no-op → re-runs convergem, não duplicamconst runId = runIdFor(spec); // muda 1 campo do spec ⇒ outro id ⇒ outro run-dir
Por isso planos têm que ser determinísticos
É por isto que módulos de plano (alembic.plan.ts) precisam ser determinísticos — Date.now(), new Date() e Math.random() são rejeitados pela VM. Não-determinismo mudaria o hash do spec a cada run e quebraria o replay, então o motor se recusa a carregar um plano que os contenha (a lição 17 cobre o gate que impõe isso nos planos).
O hash do conteúdo é o que liga "entrada idêntica" a "mesmo lugar no disco" — a base do replay.
Cuidado Um new Date() escondido num plano não dá erro "de lógica" — ele quebra o replay silenciosamente ao mudar o hash. Por isso a VM prefere recusar o plano a deixá-lo rodar.
6
④ O dissenso é preservado/forçado pelo Verifier, não por um mero prompt
Esta é a mais sutil — e a que mais equipes erram. Muitos "councils" adicionam um prompt dizendo "faça o advogado do diabo" e chamam isso de adversarial. O Alembic não confia num prompt para produzir dissenso — ele torna o dissenso estrutural, garantido pela arquitetura, não pela boa-vontade do modelo.
Pedir a alguém "por favor, discorde de você mesmo" é frágil — a pessoa pode ignorar. Ter um auditor independente que só pode checar fatos, sem poder mexer em nada, é garantido. O Alembic escolhe o auditor.
dissenso como propriedade do sistema
O Verifier maker-checker é read-only por arquitetura: ele aceita apenas views readonly, não expõe nenhuma superfície de adapter ou mutação, e prova claims atômicos com oráculos determinísticos sobre a evidência estruturada — nunca sobre a prosa do maker (packages/council/src/verifier.ts:19-99).
"Contrarian-last" é imposto no board-load — contrarian_not_last é um erro DURO de carregamento (packages/council/src/board.ts:142-149) — e realizado como sequenciamento real pelo engine (fases seriais, membros paralelos).
Read-only + oráculos determinísticos = um auditor que não pode ser convencido pela retórica.
Dissenso de prompt (ignorável) vs. dissenso estrutural (garantido pela arquitetura). ADR-0003.
Um bug que foi corrigido, registrado com honestidade. O handoff já listou "contrarian-last é ficção (o prompt diz, o código roda em paralelo)" entre os bugs a não carregar adiante. No código atual isso não é mais ficção: é imposto no board-load e realizado por execução serial de fases. O mapa anota a divergência e diz "o bug foi corrigido" — proveniência acima de polimento. Governado por ADR-0003: não existe um Role "contrarian" privilegiado; a pressão adversarial é o trabalho do Verifier.
7
Explorador das invariantes
Clique em cada pilar para ver, lado a lado, o que ele garante, como é imposto e onde se prova. As quatro juntas são a espinha; remova uma e o motor deixa de ser testável, replayável ou confiável.
garante
① run() nunca lança
Todo resultado é um valor tipado: {ok:true,value} ou {ok:false,error}. Erros não escapam como exceções.
Imposto por:runWithGuards, mais runDebateSafe/runSwarmSafe uma camada acima.
Uma "boa prática" é um conselho: você deveria seguir. Uma invariante é uma lei: o sistema não te deixa quebrar. Essas quatro foram escolhidas porque cada uma compra uma propriedade que você consegue verificar — não admirar. Testável (②), replayável (③), confiável (① e ④). Se uma quinta "regra" não comprar uma dessas, ela é consequência, não pilar.
Cada invariante é afirmada no código, não só na prosa: ① no tipo de retorno e nos guards; ② na assinatura dos engines (dependências como parâmetros); ③ no runIdFor + na rejeição da VM; ④ no Verifier read-only + no erro de board-load. É isso que permite testá-las: uma invariante que não pode ser checada por um teste não é uma invariante, é uma intenção.
Se uma "regra" decorre de um pilar, ela é consequência — não um quinto pilar.
9
Exemplo guiado — classifique a violação
Um colega abre um PR com três mudanças. Para cada uma, qual invariante ela ameaça?
Diagnóstico passo a passo
1
Um engine novo faz import { realSlackAdapter } e o chama direto, sem recebê-lo por parâmetro. → Viola ②. O engine deixou de ser agnóstico; agora não dá para testá-lo em memória.
2
Um alembic.plan.ts calcula um nome de arquivo com Date.now(). → Viola ③. O hash do spec mudaria a cada run; a VM recusa o plano antes de rodar.
3
Uma função do council faz throw new Error('quota') em vez de devolver {ok:false,error}. → Viola ①. Quem chama esperava um valor; runDebateSafe existe justamente para reconverter isso, mas o código novo não deveria depender disso para "consertar" um throw evitável.
Treine o olho: cada cheiro de código aponta para exatamente uma invariante.
Agora você: imagine uma quarta mudança — "adicionei um prompt pedindo ao revisor para discordar mais". Qual invariante ela parece reforçar, mas na verdade não satisfaz? (Resposta: a ④. Um prompt é ignorável; só o Verifier read-only + o erro de board-load tornam o dissenso real.)
10
Confusões comuns
"Agnóstico a adapter é só ter uma interface." É mais: engines nunca importam um adapter, filesystem ou store concreto — eles chegam como argumentos injetados. O teste da invariante é conseguir rodar qualquer engine inteiramente em memória com fakes, o que a suíte faz o tempo todo.
"Endereçamento por conteúdo é para deduplicar." Dedupe é um benefício colateral. O propósito mais profundo é replay e convergência: entradas idênticas produzem o mesmo id e o mesmo lugar no disco, então um re-run retoma ou re-deriva o mesmo resultado em vez de bifurcar um novo.
"São quatro entre muitas boas práticas." Não. São as quatro. Outras regras (como o determinismo de plano) existem para servir uma destas — o determinismo é precondição da ③, não um quinto pilar.
O critério é operacional: se você não consegue injetar um fake, a invariante não está lá.
Afirmação
Frágil?
Estrutural?
"Prompt: seja o advogado do diabo"
sim
não
Verifier read-only com oráculos determinísticos
não
sim
"Por favor, não use Date.now()"
sim
não
VM rejeita o plano não-determinístico
não
sim
11
Recapitulando
Invariante ①
run() nunca lança
Todo resultado é uma união discriminada uniforme — ok:true/value ou ok:false/error. Falha é dado, não exceção. ADR-0009.
1
Invariante ②
Agnóstico a adapter & store
Kernels puros recebem views readonly + efeitos injetados. Por isso 400+ testes rodam em memória, sem socket.
2
Invariante ③
IDs por conteúdo
O id do run é o hash do spec. Entradas iguais convergem; por isso planos têm que ser determinísticos.
3
Invariante ④
Dissenso estrutural
A pressão adversarial é garantida pela arquitetura (Verifier read-only + board-load), não por um prompt. ADR-0003.
4
A espinha
Quatro, não seis
Conhecer as quatro é conhecer o motor. Tudo o mais é consequência — não um pilar a mais.
5
Slide 1 / 5use ←→
12
Verifique
Revisão acumulada
Três perguntas. A pontuação corre conforme você responde — acerte as três e a revisão fecha.
1. Quantas invariantes arquiteturais o mapa as-built nomeia?
Correto: c. O §2 do mapa lista quatro. Uma versão antiga dizia "seis" — estava desatualizada. As quatro são as propriedades que sustentam o peso; todo o resto é consequência ou convenção.
2. Por que um módulo de plano precisa evitar Date.now() e Math.random()?
Correto: b. O id do run é um hash do spec; não-determinismo geraria um hash (e um run-dir) diferente toda vez, derrotando replay e cache. O determinismo é precondição da invariante ③, então o motor recusa planos não-determinísticos.
3. O que torna a invariante de "dissenso" do Alembic mais forte que um prompt "seja contrarian"?
Correto: d. Um prompt pode ser ignorado ou racionalizado. O Alembic impõe o dissenso na arquitetura: um Verifier independente, sem mutação, com oráculos determinísticos, mais a ordenação do contrarian checada no load. O ADR-0003 faz da pressão adversarial uma propriedade do sistema, não um Role.
Acertos: 0/3
Você é o aluno e também o auditor: olhe um pacote qualquer do monorepo e pergunte — "esse engine importa algum concreto, ou recebe tudo por parâmetro?". Se importa, a invariante ② foi ferida. A seguir (lição 17): saímos das invariantes e entramos no pipeline de gates — Scope, Council, Proof, Validator e Publish — o maquinário que impõe essas regras em cada run.