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 e a maioria governada por uma ADR: ① run() nunca lança, ② agnóstico a adapter/store, ③ IDs content-addressed + run-dir determinístico, ④ o dissenso é preservado pelo Verifier. São as regras que todo pacote mantém — a razão de o sistema ser testável, reproduzível e confiável. Conhecê-las é conhecer a espinha do motor.
Esta lição destila o §2 do mapa como-construído, com cada invariante verificada contra os arquivos citados (packages/*/src + as ADRs). Por que importa: são o filtro que diz se um pacote pertence ao Alembic. Se ele viola uma das quatro, não é Alembic — é só código que mora no mesmo repositório.
docs/alembic-complete-map.md §2) lista exatamente quatro — a contagem abaixo é a autoritativa. Confundir "princípios" extras com as invariantes que sustentam carga é precisamente o tipo de divergência que o método de engenharia reversa (lição 20) existe para pegar.- Nomear as quatro invariantes e apontar o arquivo+linha que afirma cada uma.
- Explicar por que
run()nunca lançar e o ID ser content-addressed se reforçam mutuamente (replay). - Distinguir "agnóstico a adapter/store" de "só uma interface" — o teste é rodar em memória com fakes.
- Mostrar por que o dissenso é estrutural (Verifier read-only + contrarian-last no board-load), não um prompt.
As quatro, em resumo
Comece pelo papel que elas cumprem. As quatro invariantes são o filtro de pertencimento: dado um pacote qualquer do repositório, elas decidem se ele é Alembic ou apenas código que mora no mesmo lugar. Pense num portão de quatro perguntas — basta reprovar em uma e o pacote não passa:
Passado o portão, vale ver como as quatro se organizam. Elas não são uma lista solta: são dois pares que se reforçam. ① e ③ (nunca-lança + content-addressed) tornam a run reproduzível; ② e ④ (agnóstico + dissenso estrutural) tornam a run testável e confiável. Pense numa grade 2×2 — reprodutibilidade no eixo de cima, confiança no de baixo:
E por que "afirmada no código" importa tanto? Porque há uma escala de força. Uma invariante pode viver só num comentário (frágil), num teste (melhor), ou no tipo / no carregamento — onde o compilador ou o motor a recusam antes de rodar. As quatro do Alembic vivem no degrau mais alto: são impostas por construção, não por boa-fé.
runWithGuards (código), ② por injeção de dependência (assinaturas de função), ③ por um hash de conteúdo (runIdFor), e ④ — a mais contraintuitiva — por um erro duro de carregamento (contrarian_not_last) e por um Verifier que é read-only por arquitetura. Se você chutou "uma" ou "duas", a lição inteira é sobre por que a resposta é zero: prompts podem ser ignorados; o código, não.① run() nunca lança; o resultado é uma união discriminada uniforme
Você viu isto na lição 14. É imposto estruturalmente por runWithGuards (adapter-core.ts:118), que envolve validação de input, circuit breaking e retry/backoff e devolve sempre um ModelRunResult discriminado em ok — nunca lança. Verificado em produção: o handoff registra um 429 real surgindo como falha tipada (HANDOFF.md:274). O núcleo de orquestração restabelece a mesma fronteira uma camada acima com runDebateSafe/runSwarmSafe (harness/src/core.ts:313-334), então um throw dentro de um passo de council ou swarm também vira um valor. Governado pela ADR-0009 ("The narrow waist — a model call never throws and returns a discriminated result").
O contraste é o coração do invariante. À esquerda, o mundo "normal": uma exceção sobe pela pilha e quem não a tratou quebra. À direita, o mundo Alembic: o erro é capturado na fonte e desce como um valor que o chamador tem de olhar:
// a forma do resultado: discriminada em `ok` — nunca um throw type ModelRunResult = | { ok: true; value: Completion } | { ok: false; error: ModelError }; // um 429 chega aqui, não como exceção
E não é só o adapter. O invariante se repete em camadas: o adapter garante a fronteira na chamada de modelo; o núcleo (core.ts) re-estabelece a mesma garantia em volta de council e swarm com runDebateSafe/runSwarmSafe; o servidor (lição 17) a fecha de novo em cada handler HTTP. Um throw em qualquer profundidade é capturado pela fronteira mais próxima e desce como valor:
② Os motores são agnósticos a adapter E a store
Os kernels puros recebem apenas vistas readonly e efeitos colaterais injetados. O DebateEngine, o scoring e o verifier recebem um AdapterRegistry injetado (council/src/debate.ts:71-83). A camada ETL roteia todo IO por um FsPort injetável — "toda função aqui é testável em memória" (etl/src/index.ts:11-14). E o funil recebe um registry de adapters injetado, então trocar por um registry offline torna a run inteira $0 e hermética (funnel.ts:79-81).
// o padrão, em todo lugar: dependências chegam como argumentos, nunca imports de concretos runDebate({ board, pack, adapters, requestId }); // adapters injetados → teste com fakes runT0Pipeline(corpusDir, { fs }); // FsPort injetado → roda em memória runFunnel(corpusDir, { adapters: offlineRegistry }); // registry offline → $0, hermético
Esta é a lição 5 (portas & injeção) elevada a lei arquitetural: nenhum motor 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 testar os subsistemas do @alembic/hermes (lições 7–13) sem abrir um único socket.
O ganho é concreto. Compare um motor amarrado a concretos (precisa de gateway no ar, rede, dinheiro, e fica lento e instável) com o motor injetável do Alembic (fake em memória: rápido, determinístico, $0):
③ IDs content-addressed + layout determinístico de run-dir
O id de uma run é o hash de conteúdo (estilo SHA-256) do seu spec: runIdFor(spec) (swarm/src/orchestrator.ts:168). Mude qualquer campo do spec e você obtém um novo diretório de run — runs são reproduzíveis porque inputs idênticos convergem para o mesmo id e o mesmo lugar no disco (swarm/src/types.ts:247-257: "Changing any field yields a new run directory").
A consequência que importa é o replay. Reexecutar o mesmo spec não cria uma run nova: o hash bate, o run-dir é o mesmo, e o motor retoma/re-deriva ali — convergência. Mude um campo e o hash diverge: nasce um diretório novo, e o antigo fica intacto. Os dois caminhos, lado a lado:
// run dirs: <baseDir>/runs/<runId> · events.jsonl append-only + checkpoint.json // stores são content-addressed por SHA-256 sobre JSON canônico (chaves ordenadas) → // re-anexar conteúdo idêntico é no-op, então re-runs convergem em vez de duplicar
Repare na peça que fecha o ciclo: os stores também são content-addressed. canonicalJson ordena as chaves recursivamente e contentHash tira o SHA-256 disso; se o hash já existe no arquivo, appendStoreRecord não escreve de novo — é um no-op (etl/src/stores.ts:79-97,148-166). Conteúdo igual nunca duplica:
É por isso 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 recusa carregar um plano que o contenha (a lição 17 cobre o portão que impõe isso em planos).
Laboratório: o hash decide o run-dir
Sinta o invariante ③ na mão. Ligue/desligue campos do spec abaixo: cada mudança recalcula um hash de demonstração e, com ele, o caminho do run-dir. Repare em duas coisas — o mesmo conjunto sempre dá o mesmo hash (reprodutível), e qualquer alteração dá um hash totalmente diferente (novo diretório, nada sobrescrito).
Selecione um conjunto de campos acima.
runIdFor usa um SHA-256 de 64 caracteres hex sobre o JSON canônico do spec. A lição é a mesma: a identidade da run É o seu conteúdo — não um contador, não um timestamp, não um UUID aleatório.④ O dissenso é preservado/forçado pelo Verifier, não meramente por um prompt
Este é o mais sutil, e o que mais times erram. Muitos "councils" adicionam um prompt que diz "faça advogado do diabo" e chamam isso de adversarial. O Alembic não confia num prompt para produzir dissenso — ele torna o dissenso estrutural por dois mecanismos:
- O Verifier maker-checker é read-only por arquitetura: aceita apenas vistas readonly, não expõe superfície de adapter ou mutação, e prova claims atômicos com oráculos determinísticos sobre a evidência estruturada (votos, scores, spread, quórum), nunca sobre a prosa do maker (
council/src/verifier.ts:19-99). - "Contrarian-last" é imposto no board-load —
contrarian_not_lasté um erro duro de carregamento (council/src/board.ts:142-149) — e realizado como sequenciamento real (fases seriais, membros paralelos;debate.ts:30-44,205-210).
O segundo mecanismo merece um diagrama, porque é onde "contrarian-last" deixa de ser uma frase e vira execução. As fases do debate rodam em série (uma depois da outra), mas os membros dentro de uma fase rodam em paralelo. O contrarian é alocado na última fase — então, por construção, ele só fala depois que todos os outros já se posicionaram. Não há prompt pedindo isso: é a ordem em que o motor executa.
Por que "oráculo determinístico" e não "o Verifier lê o argumento do maker"? Porque um oráculo é uma função pura sobre a evidência estruturada — mesma evidência entra, mesmo veredito sai, sem rede e sem modelo. Ele olha votos, scores, spread e quórum; nunca a prosa. Veja a anatomia de uma prova de claim:
As quatro juntas: rastreie uma run (passo a passo → agora você)
As invariantes não agem isoladas — elas se encadeiam numa run real. Siga uma chamada do começo ao fim e veja cada uma entrar em cena. Depois, um caso é seu. Recuperar o encadeamento (não só ler a lista) é o que fixa de verdade.
runIdFor(spec) tira o SHA-256 e fixa o run-dir <baseDir>/runs/<runId>. Mesmo spec ⇒ mesmo lugar ⇒ a run é reproduzível desde o byte zero.runDebate({ board, pack, adapters, requestId }) — o AdapterRegistry chega como argumento. Em teste seria um fake; em produção, o real. O motor não sabe nem se importa qual.runWithGuards captura e devolve { ok:false, error } — um valor, não uma exceção. O runDebateSafe do núcleo (core.ts:313-334) garante a fronteira mesmo se algo lançasse mais fundo.alembic.plan.ts contém uma linha const seed = Math.random(). Qual invariante isso viola, e o que o motor faz? Pense antes de revelar.
Math.random() torna o spec não-determinístico, então o hash (e o run-dir) mudaria a cada execução, quebrando replay e cache. A VM recusa carregar o plano — ela rejeita Date.now(), new Date() e Math.random() de propósito (lição 17/28). Dica: sempre que algo muda o conteúdo do spec sem o usuário mudar a intenção, suspeite do invariante ③.Quatro, não seis (e nem as três do funil)
Duas armadilhas de contagem rondam estas invariantes. A primeira: o curso antigo dizia "seis" — divergência pura. A segunda é mais sutil e vale fixar: o funil tem suas próprias três invariantes de segurança (docs/alembic-complete-map.md §3) — orçamento que degrada em vez de estourar, escritas append-only/content-addressed/validadas, e leituras read-only na fonte. Essas três são locais ao funil; as quatro desta lição são arquiteturais e valem para todo pacote. Não as some.
O erro de soma fica óbvio quando você desenha os escopos lado a lado: conjuntos diferentes, abrangências diferentes — e um deles simplesmente não existe.
| Conjunto | Quantas | Escopo | Onde está |
|---|---|---|---|
| Invariantes arquiteturais | 4 | Todo pacote do Alembic | alembic-complete-map.md §2 |
| Invariantes de segurança do funil | 3 | Só o funil (L0→L3 ETL) | alembic-complete-map.md §3 |
| "Seis invariantes" | — | não existe (curso antigo, desatualizado) | — |
Confusões comuns
runWithGuards) e some do resto do código. O chamador ramifica num valor (ok: true | false), não embrulha cada chamada — por isso o tipo te obriga a tratar a falha.Fixe os conceitos (flashcards)
Clique pra virar. Tente lembrar a resposta — e o arquivo que a afirma — antes de virar. Recuperação ativa fixa mais que reler.
runWithGuards (adapter-core.ts:118): devolve um ModelRunResult discriminado em ok. Um 429 vira { ok:false }. ADR-0009.AdapterRegistry + FsPort chegam injetados; nenhum import de concreto.runIdFor(spec) = SHA-256 do spec. Input igual → mesmo id → mesmo run-dir → replay. Por isso o plano precisa ser determinístico.contrarian_not_last como erro duro de board-load (board.ts:142-149). ADR-0003.Como isso se encaixa
As quatro invariantes não são uma seção isolada do curso: são o chão sobre o qual toda run do Alembic anda. Onde você está na metodologia? Bem embaixo dela. Cada estágio que você já viu — o spec virar run-dir, o motor chamar um adapter, o council debater, os gates julgarem, o loop de aprendizado registrar — repousa sobre uma destas quatro regras. Tire uma e o estágio correspondente perde a garantia: a run pode quebrar (①), virar não-testável (②), divergir a cada execução (③) ou aceitar um consenso sem pressão (④).
O diagrama abaixo mostra o encaixe literal. Em cima, o fluxo de uma run da esquerda para a direita. Embaixo, as quatro invariantes como uma fundação — cada uma sobe e segura o estágio que governa. Use os botões para acender invariante por invariante e ver o que cada uma sustenta.
Clique numa invariante para ver qual estágio do fluxo ela segura — ou "passo a passo" para percorrer as quatro.
As invariantes conectam-se a quatro outras lições — cada uma aprofunda uma face desta fundação:
- Lição 14 · A cintura estreita — é a casa do invariante ①:
runWithGuardse o tipo discriminado nascem ali. As quatro são "a cintura, vista como leis". - Lição 28 · Determinismo & replay — aprofunda o invariante ③: por que o hash do spec exige um plano sem
Date.now()/Math.random(), e como o replay converge. - Lição 18 · Council & Verifier — desdobra o invariante ④: o Verifier read-only, os oráculos determinísticos e o contrarian-last imposto no board-load.
- Lição 26 · Proveniência & segurança — mostra o invariante ③ no registro: escritas append-only e content-addressed que tornam toda run auditável.
- Lição 17 · A pipeline de gates — o estágio que ① e ④ seguram: é onde o Proof Gate falha fechado e o plano não-determinístico é recusado.
Na prática
Invariante não é teoria de quadro-branco — você consegue tocar duas delas com a CLI, sem custo. O invariante ③ (content-addressed/determinístico) aparece no alembic replay: reexecutar o mesmo run-id retoma o mesmo diretório em vez de criar um novo. E o invariante ① (nunca lança) é exatamente o que a suíte de testes prova — toda falha vira valor tipado, então o teste verifica ramos, não captura exceções.
@alembic/council, @alembic/etl): rodam em memória justamente porque ② vale, e checam o contrarian-last porque ④ é estrutural.① nunca lança — a suíte verde é a prova
O baseline de build/teste do projeto roda mais de 400 testes em memória, $0, sem rede. Eles só conseguem rodar assim porque os erros são valores (①) e as dependências são injetadas (②):
# baseline do repositório — tudo verde a cada mudança (CLAUDE.md) pnpm -r typecheck && pnpm -r build && pnpm -w test # saída esperada (resumida): # ✓ packages/adapters runWithGuards: um 429 vira { ok:false } (não lança) # ✓ packages/council contrarian_not_last = erro duro de board-load # ✓ packages/etl appendStoreRecord: conteúdo idêntico = no-op # Test Files N passed # Tests 400+ passed
Para isolar só um invariante, rode o pacote que o afirma — a saída exata varia com a versão; o que importa é "passed":
# o invariante ① / ② no adapter (a fronteira que não lança) pnpm --filter @alembic/adapters test # o invariante ④ no council (Verifier + contrarian-last) pnpm --filter @alembic/council test # o invariante ③ no etl (append content-addressed / no-op) pnpm --filter @alembic/etl test
③ content-addressed — veja o replay convergir
Aqui o invariante ③ fica visível a olho nu. Uma run nasce de um spec; seu id é o hash do conteúdo. Reexecutar pelo mesmo id não bifurca — retoma o mesmo run-dir a partir do events.jsonl + cache:
# 1) liste as runs existentes (cada id É o hash content-addressed do spec) alembic runs list # 2) reexecute UMA pelo seu id — replay, não uma run nova alembic replay <run-id> # saída esperada (o ponto é a IDENTIDADE do diretório): # resume/re-execute from events.jsonl + cache # → mesmo <run-id> ⇒ MESMO ~/.alembic/runs/<run-id>/ (nada sobrescrito) # conteúdo idêntico re-anexado = no-op (convergência, não duplicação)
cd para a raiz do monorepo Alembic e rode o baseline: pnpm -r typecheck && pnpm -r build && pnpm -w test. Observe a contagem de testes terminar em passed — isso é o invariante ① (nenhuma exceção escapa) e ② (tudo em memória) provados de uma vez.pnpm --filter @alembic/council test e procure o caso de contrarian_not_last: o board inválido nem carrega. É o dissenso como erro de load, não como prompt.alembic runs list para ver os ids (cada um é um hash). Pegue um e rode alembic replay <run-id>. O que procurar: o diretório retomado é ~/.alembic/runs/<run-id>/ — o mesmo id, o mesmo caminho. Reexecutar não criou um diretório novo. Esse é o invariante ③ a olho nu.alembic distill <corpus> --offline (lição 15) — e depois volte ao passo 3. [uncertain] o nome exato do diretório-base pode variar conforme --data-dir/configuração; o invariante a observar é a estabilidade do caminho entre run e replay, não a string literal.Revisão cumulativa — recupere de memória
Antes de clicar: responda de cabeça. As opções têm tamanhos parecidos de propósito — sem pista pela forma.
Date.now() e Math.random()?