Determinismo & replay: a mesma entrada, a mesma run
O invariante 3 (Lição 16) diz que uma run é content-addressed e replayável. Esta lição é o mecanismo, peça por peça: o id de uma run é um hash do seu spec, então o mesmo spec sempre cai no mesmo diretório; esse diretório é um log append-only mais um checkpoint, então uma run que crashou retoma de onde parou; e a checagem de plano proíbe as três funções que quebrariam tudo isso — Date.now(), new Date() e Math.random(). Onde tempo e aleatoriedade reais são genuinamente necessários, eles entram por uma costura injetada — um Clock e uma fábrica de ids — nunca um global. Essa única disciplina é o que torna "fazer replay desta run exata" possível.
Cada número e cada linha de código desta lição saem do próprio repositório (rodapé). Por que importa pra missão: sem determinismo, "retomar a run de ontem" e "provar que a fusão Alembic × Hermes é reproduzível" seriam impossíveis — replay é o que transforma uma run num artefato auditável, não num evento que aconteceu uma vez.
- Explicar por que o id de uma run é
run-${shortHash(spec)}e não um timestamp ou UUID. - Rastrear o que
runIdForfaz por dentro: canonical-JSON → SHA-256 → prefixo de 16 hex. - Ler o fluxograma da checagem de determinismo (
run-plan.ts) e prever quando ela rejeita um plano. - Reconhecer a costura injetada (
Clock+ fábrica de ids) como a única porta por onde o não-determinismo entra.
01 · A identidade de uma run é o hash do seu spec
Imagine que cada run é uma carta, e o "endereço" da carta tem que ser o mesmo toda vez que você escrever exatamente a mesma carta. Se o endereço fosse a hora em que você escreveu, a mesma carta iria para um endereço diferente a cada vez — e você nunca acharia a anterior. A solução do Alembic é fazer o endereço ser uma impressão digital do conteúdo: a identidade de uma run não é um timestamp nem um UUID — é o hash de conteúdo do seu spec.
runIdFor(spec) faz o hash da especificação da run, então mudar qualquer campo do spec gera um novo id e, portanto, um novo diretório:
// packages/swarm/src/ids.ts:30 — o id da run é o hash do seu spec export const runIdFor = (spec: unknown): string => `run-${shortHash(spec)}`; // packages/swarm/src/orchestrator.ts:168 — usado na entrada do orquestrador const runId = runIdFor(spec); // muda qualquer campo de `spec` ⇒ um runId diferente ⇒ um diretório de run diferente
O retorno: identidade é conteúdo. Duas runs do mesmo spec (goal + tasks + maxDepth + maxConcurrency) compartilham um diretório e podem retomar uma à outra; um spec ajustado é inequivocamente uma run diferente. Não há id derivado do relógio que tornaria "a mesma run" não-encontrável amanhã. O próprio comentário do schema cravam isso:
The immutable spec that defines a run and seeds its content-addressed id. Changing any field yields a new run directory.— packages/swarm/src/types.ts (doc de runSpecSchema)
alembic run com um spec; o id sai run-a1b2c3d4e5f60718. No dia seguinte você roda o mesmíssimo spec de novo. Que id sai — e o que acontece com o diretório da primeira run? Chute antes de revelar.
run-a1b2c3d4e5f60718. Como o id é o hash do conteúdo do spec (e nada mais — nenhum timestamp entra no cálculo), o mesmo spec sempre produz o mesmo endereço. A segunda invocação cai exatamente no diretório existente e pode retomar/replay a partir do log de ontem, em vez de criar uma run nova. Se você tivesse chutado "um id novo a cada dia", você estaria descrevendo um id derivado de relógio — justamente o que content-addressing existe para evitar.Timestamp vs hash de conteúdo — o veredito é estrutural
Por que não um Date.now() ou um randomUUID() como id? Porque os dois quebram a propriedade central — "o mesmo input acha a mesma run". Veja os dois esquemas lado a lado:
02 · Dentro do runIdFor: como o hash é estável
"Hash do spec" parece simples, mas esconde uma sutileza que faz toda a diferença: e se você montar o mesmo spec com os campos em ordem diferente? Em JSON, {"a":1,"b":2} e {"b":2,"a":1} são o mesmo objeto, mas têm bytes diferentes — e um hash ingênuo dos bytes daria dois ids. O Alembic resolve isso com canonical JSON: antes de hashear, ele ordena as chaves recursivamente, então objetos estruturalmente iguais sempre colapsam no mesmo hash.
shortHash(value) = contentHash(value).slice(0, 16), e contentHash = createHash('sha256').update(canonicalJson(value)).digest('hex'). O canonicalJson faz sortKeys recursivo: arrays mantêm a ordem (ordem é semântica), objetos têm as chaves ordenadas. SHORT_HASH_LEN = 16 dá um prefixo hex curto e seguro como nome de diretório. (packages/swarm/src/ids.ts:13-41)// packages/swarm/src/ids.ts:19-24 — SHA-256 real sobre JSON canônico, prefixo de 16 export const contentHash = (value: unknown): string => createHash('sha256').update(canonicalJson(value)).digest('hex'); export const shortHash = (value: unknown): string => contentHash(value).slice(0, SHORT_HASH_LEN); // SHORT_HASH_LEN = 16
O mesmo módulo ids.ts exporta duas espécies de id — e a diferença é o coração desta lição. runIdFor é um id de conteúdo (determinístico, endereça a run); freshId = randomUUID() é um id fresco (não-determinístico de propósito, p/ correlacionar requisições). A não-determinação não é proibida — ela é rotulada e mantida longe do que endereça uma run:
// packages/swarm/src/ids.ts:30,32-33 — duas espécies, propósitos opostos export const runIdFor = (spec: unknown) => `run-${shortHash(spec)}`; // determinístico export const freshId = (): string => randomUUID(); // "non-deterministic. Used for request ids"
Por dentro do sortKeys: a regra dos três casos
O canonicalJson não é mágica — é uma recursão de três casos sobre cada nó do spec. É essa regra que garante que "estruturalmente igual" sempre vire "byte-a-byte igual" antes do SHA-256:
// packages/swarm/src/ids.ts:43-54 — sortKeys recursivo (o pré-imagem do hash) const sortKeys = (value: unknown): unknown => { if (Array.isArray(value)) return value.map(sortKeys); // array → preserva ordem if (value !== null && typeof value === 'object') { // objeto → ordena as chaves const sorted = {}; for (const key of Object.keys(value).sort()) sorted[key] = sortKeys(value[key]); return sorted; } return value; // primitivo → como está };
Experimente: mude um campo, veja o endereço mudar
Esta é a propriedade inteira em forma de brinquedo. Digite um goal e mexa em maxConcurrency: o id recalcula ao vivo (um hash determinístico de demonstração) e o diretório muda. Repita o mesmo valor e o id volta a ser igual — é content-addressing em ação.
03 · O diretório de run determinístico
O id resolve "onde" a run mora. Agora, "o quê" mora lá: cada run vive num caminho fixo e previsível — resolveRunDir = join(baseDir, 'runs', runId) — com um log append-only e um checkpoint. Esse é o substrato concreto que torna retomar e replay possíveis (Lição 16):
// packages/etl/src/run-directory.ts — o layout sob <baseDir>/runs/<runId>/ <baseDir>/runs/<runId>/ ├── events.jsonl // append-only; todo evento em ordem ├── checkpoint.json // último estado retomável └── meta.json // fingerprint de goal/plan/contract (validado no --resume)
O caminho em si é montado por uma única função — três pedaços, sem nada derivado do relógio entre eles. baseDir é configurável (o ~ é expandido), 'runs' é constante, e runId é o hash de conteúdo da seção 01. Como nenhum dos três muda entre execuções do mesmo spec, o caminho é estável:
// packages/etl/src/run-directory.ts:58-59 — o caminho é só uma composição export const resolveRunDir = (baseDir: string, runId: string): string => join(expandTilde(baseDir), 'runs', runId);
E os stores content-addressed reforçam a propriedade: resultados são gravados por SHA-256 sobre JSON canônico (chaves ordenadas, undefined filtrado), então re-anexar conteúdo idêntico é um no-op e re-runs convergem. A idempotência é estrutural — rodar duas vezes não pode duplicar:
// packages/etl/src/stores.ts — canonical JSON p/ hashing, depois SHA-256 // "keys sorted recursively so two structurally equal records // collapse to the same hash regardless of key order." export const contentHash = (payload: unknown): string => sha256Hex(canonicalJson(payload));
04 · A proibição: Date.now() / new Date() / Math.random() num plano
Um módulo de plano (alembic.plan.ts) é a descrição determinística do que rodar. Se um plano pudesse ler o relógio de parede ou jogar dados, duas avaliações do mesmo plano divergiriam — e o replay seria uma mentira. Então, antes de importar o plano, o motor faz uma varredura em busca das três construções proibidas e rejeita o plano se encontrar qualquer uma. Aqui está a engenharia real, com uma honestidade que o resumo costuma esconder: para planos .ts é uma varredura por regex (não um sandbox completo), porque o parser usado para JS (Acorn) não entende sintaxe TypeScript.
// packages/vm/src/run-plan.ts — a checagem de determinismo p/ planos .ts const forbidden = [ { pattern: /\bDate\.now\s*\(/, name: 'Date.now()' }, { pattern: /\bnew\s+Date\s*\(\s*\)/, name: 'new Date()' }, { pattern: /\bMath\.random\s*\(/, name: 'Math.random()' }, ]; // achou? → { ok: false, reason: `Non-deterministic construct detected: ${name}` }
A intenção é declarada no próprio comentário do código: a varredura "é deliberadamente conservadora: pode sinalizar ocorrências dentro de comentários/strings, mas um falso positivo é mais seguro que permitir não-determinismo num plano retomável". A checagem não confia que você lembre; ela impõe. (Nota: essa proibição é no módulo de plano, não no código de aplicação — um comando de CLI pode ler o relógio à vontade; o plano descrevendo uma run não pode.)
05 · Disseque o erro real (passo a passo → agora você)
A melhor forma de internalizar a proibição é ler o teste que a garante. O vm.test.ts escreve um plano com Date.now() e prova que a run morre antes de executar. Vamos seguir o caminho do erro, passo a passo — depois um exercício é seu.
non-deterministic.plan.js com export const run = async () => ({ now: Date.now() }); — um plano que tenta ler o relógio de parede..js, então toma o ramo do parser: checkDeterminism(source) — o walk de AST do Acorn (packages/mission/src/determinism.ts). (Para um plano .ts, o ramo seria checkDeterminismTs, a varredura por regex — mesma lista proibida, mesmo veredito.)Date.now() e devolve { ok: false, reason: …'Date.now()'… }.throw new Error(`plan module ${path} is non-deterministic: ${reason}`) — capturado e retornado como { ok: false, error }. O import() do plano nunca acontece.expect(result.ok).toBe(false), depois expect(result.error).toContain('non-deterministic') e .toContain('Date.now') — verde (vm.test.ts:148-151). A run foi barrada na porta; nada foi executado, nada foi journaled..ts com const r = Math.random();. A run executa? Qual a reason exata? Responda antes de revelar.
.ts, a terceira regex de checkDeterminismTs, /\bMath\.random\s*\(/, casa; a reason é Non-deterministic construct detected: Math.random(); runPlan lança plan module … is non-deterministic: … e devolve { ok:false } — sem nunca importar o plano. Dica: o procedimento é sempre o mesmo, só muda qual ramo (regex p/ .ts, AST p/ .js) e qual das três construções dispara. É por isso que a regra "remova-os do módulo de plano" é confiável: ela é mecânica, não opinião.06 · A saída de emergência: injete o clock e a fábrica de ids
Sistemas reais precisam de tempo real (um curador decidindo "obsoleto após 30 dias") e de ids únicos (uma pergunta clarify precisa de um handle). A resposta não é proibi-los — é torná-los uma costura injetada, para que a produção passe o real e um teste passe um fake. Você já viu os dois nas lições anteriores:
Clock (Lição 9 · curador): o curador recebe tempo como um Clock injetado (epoch ms). O próprio doc avisa: "TIME is an injected Clock (epoch ms), never Date.now()/new Date() — the engine's plan VM forbids them and they break deterministic replay." Por isso os thresholds (DEFAULT_STALE_AFTER_DAYS = 30, DEFAULT_ARCHIVE_AFTER_DAYS = 90) são guardados em ms: o Clock injetado é a única unidade de tempo.ClarifyGateway recebe uma idFactory em vez de chamar um global: this.mintId = options.idFactory ?? monotonicIdFactory();. Um teste passa monotonicIdFactory('q') (um contador), a produção passa o monotônico padrão. Mesma forma, determinística no teste.Tempo e aleatoriedade são efeitos colaterais, exatamente como o filesystem (Lição 5, FsPort). O segundo invariante do motor — kernel puro, efeitos colaterais injetados — se aplica a eles também. Proibir os globais na checagem de plano e passar um Clock/fábrica de ids em todo o resto é uma ideia usando dois chapéus: o único não-determinismo numa run entra por uma costura que você controla. Controle a costura e você controla o replay.
07 · 1ª run vs replay: por que isso tudo é a rocha do replay
Junte as quatro peças e veja a diferença entre executar pela primeira vez e fazer replay. Na primeira run, o motor calcula o id, executa o plano e escreve o log + checkpoint. No replay, o motor recalcula o mesmo id (mesmo spec), encontra o diretório existente e relê o log — sem re-executar o que já está registrado. O id idêntico é o que faz as duas colunas se encontrarem.
IDs content-addressed significam que "a mesma run" é encontrável. O log append-only + checkpoint significam que uma run pode ser relida e retomada. A proibição na checagem de plano significa que re-avaliar o plano gera a estrutura idêntica. O Clock/fábrica de ids injetados significam que o não-determinismo residual é capturado e replayável. A disciplina é holística — por isso a checagem impõe a parte fácil de esquecer automaticamente.
08 · Onde o tempo entra (e onde não pode entrar)
A confusão mais comum é achar que o Alembic "não pode usar o tempo". Pode — só não no lugar errado. Há uma fronteira nítida entre o módulo de plano (onde o relógio é proibido) e todo o resto (onde o tempo entra por um Clock injetado). Esta tabela e o mapa abaixo deixam a fronteira explícita:
| Local | Como obtém tempo / id | Permitido? | Por quê |
|---|---|---|---|
módulo de plano (alembic.plan.ts) | Date.now() / new Date() / Math.random() | ✗ rejeitado | re-avaliar o plano divergiria → quebra o replay content-addressed |
| curador (Lição 9) | Clock injetado (epoch ms) | ✓ sim | o teste fixa o clock → transições active→stale→archived determinísticas |
| clarify (Lição 10) | idFactory injetada (monotonicIdFactory) | ✓ sim | o teste passa um contador → ids previsíveis e estáveis |
| comando de CLI | pode ler o relógio livremente | ✓ sim | não é a descrição de uma run; não entra no hash do spec |
Clock injetado — totalmente usável, só controlável.Como isso se encaixa
Determinismo não é um truque local desta lição — é uma propriedade que três peças distantes do motor combinam para sustentar, e que uma quarta colhe. Leia o fluxo da esquerda (o que garante o determinismo) para a direita (o que ele torna possível), com o trio de mecanismos destacado no meio: o id endereçado por conteúdo (runIdFor), o Clock/fábrica de ids injetados e a proibição do plano-VM. Os três juntos é que fazem o replay ser honesto em vez de uma esperança.
Cada caixa acesa acima é uma lição inteira. Estes são os encaixes diretos — siga o link e veja a peça por dentro:
runIdFor(spec) da seção 01 nasce na entrada do orchestrator (orchestrator.ts:168), e o log append-only é o que deixa uma run que crashou retomar de onde parou (crash-safe resume).Clock injetado da seção 06 é o mesmíssimo padrão do FsPort — tempo é um efeito colateral, então é injetado, não chamado. Determinismo é a disciplina de ports aplicada ao relógio.Date.now()/new Date()/Math.random()"). A trilha de ADRs é onde o porquê dela fica auditável.Onde você está na metodologia: o determinismo é o chão sobre o qual o resto da máquina pisa. Sem ele, "retomar a run de ontem", "provar que a fusão é reproduzível" e até o cache (a chave é o SHA-256 de {prompt, opts}) ruiriam. Abra a metodologia interativa para ver onde este chão se encaixa no fluxo completo do motor, ou volte ao hub do curso para o mapa dos 30 passos.
Na prática
A teoria toda existe para um par de comandos: você roda uma vez e depois refaz o replay — e o segundo cai exatamente no diretório do primeiro. Primeiro, execute um escopo (o --yes dispensa a confirmação interativa); o run-id impresso é o runIdFor(spec) da seção 01, ao vivo:
# 1 · roda o escopo — o run-id é o SHA-256 do spec (endereçado por conteúdo) alembic run --goal GOAL.md --plan alembic.plan.ts --yes # saída esperada (resumida): # run-id: run-a1b2c3d4e5f60718 ← runIdFor(spec), não um timestamp # ▸ Scope Gate ✓ copia GOAL/plan/contrato p/ o run-dir # ▸ Proof Gate ✓ roda os unit.proof[] (exit 0) # resultado em <baseDir>/runs/run-a1b2c3d4e5f60718/ # events.jsonl · checkpoint.json · meta.json · units/<id>/proof-results.jsonl
Agora refaça o replay daquela run exata pelo seu id. O replay resolve o mesmo runs/<run-id>/ (a seção 03), relê o events.jsonl + cache e re-executa a partir dali — convergindo ao mesmo estado, sem duplicar nada:
# 2 · refaz o replay da run pelo seu id content-addressed alembic replay run-a1b2c3d4e5f60718 # saída esperada (do runReplay em commands.ts): # replay run-a1b2c3d4e5f60718 — <goal do spec> # done: 3 | failed: 0 | parked: 0 # cai no run-dir EXISTENTE — re-anexar conteúdo idêntico é no-op (idempotência estrutural)
Comandos canônicos do CLAUDE.md ("Run orchestration / observability"). A linha de saída do replay (replay <run-id> — <goal> seguida de done | failed | parked) vem literalmente de runReplay em apps/cli/src/commands.ts. [uncertain] os contadores exatos (done/failed/parked) dependem do seu spec; o formato da linha é fixo, os números não.
E o outro lado da moeda — a proibição em ação. Se o seu alembic.plan.ts tentar ler o relógio, o motor barra a run antes de executar (a seção 04, ao vivo). Você não precisa de um comando especial: qualquer alembic run sobre um plano ofensor falha fechado:
// alembic.plan.ts — um plano que tenta ler o relógio de parede export const run = async () => ({ now: Date.now() }); // ✗ proibido # alembic run --goal GOAL.md --plan alembic.plan.ts --yes # → plan module alembic.plan.ts is non-deterministic: Date.now() # a run para na porta — nada executado, nada journaled (vm.test.ts:125)
pnpm -r typecheck && pnpm -r build && pnpm -w test a partir da raiz — é o mesmo Proof que o motor roda. Tem que ficar verde antes de qualquer coisa.alembic run --goal GOAL.md --plan alembic.plan.ts --yes e copie o run-id impresso — ele é o SHA-256 do spec (a invariante ③ da seção 06).alembic runs list para achar o caminho real, depois abra a pasta do id: procure events.jsonl (a trilha), checkpoint.json e units/<id>/proof-results.jsonl (cada proof que passou).alembic replay <run-id> com o id do passo 2. Confira que a linha replay <run-id> — <goal> sai e que nenhum diretório novo aparece — ele caiu no existente.alembic.plan.ts, insira um Math.random() em qualquer lugar e rode alembic run … de novo. A run deve falhar com non-deterministic construct detected: Math.random() — a proibição da seção 04 te barrando na porta. Remova-o e a run volta a passar.Por que isto é a prova do determinismo. Rodar e refazer o replay caindo no mesmo diretório é a invariante ③ se provando sozinha — e a recusa do plano-VM ao Math.random() é o motor te impedindo de quebrá-la. [uncertain] o caminho exato do run-dir (<baseDir>/runs/<id>/) depende do --data-dir configurado; use alembic runs list para o caminho real da sua máquina.
Fixe os conceitos (flashcards)
Clique pra virar. Tente lembrar a resposta antes de virar — recuperação ativa fixa mais que reler.
run-${shortHash(spec)} — prefixo de 16 hex (SHORT_HASH_LEN) do SHA-256 sobre o JSON canônico do spec. (ids.ts:30)run-plan.ts faz com um plano .ts contendo Date.now()?{ok:false, reason} e runPlan lança antes de importar — a run para sem executar.Clock injetado (epoch ms); o teste passa um clock fixo. Thresholds (30d/90d) ficam em ms. Nunca Date.now().Revisão cumulativa — recupere de memória
Antes de clicar: responda de cabeça. As quatro opções têm o mesmo tamanho de propósito — sem pista pela forma.
runIdFor(spec) content-addressa a run, então re-submeter o mesmo spec cai no estado existente. a confunde benefício cosmético (o prefixo de 16 hex é curto) com a razão — o ponto não é tamanho. b erra o objetivo: o hash não esconde nada (o spec fica em meta.json); ele endereça. d inverte a verdade: timestamps são até "únicos demais" — mudam a cada invocação, o que justamente torna "a mesma run" não-encontrável amanhã.Date.now(), new Date() e Math.random() num alembic.plan.ts. A razão central é:Clock. a é exatamente o anti-padrão que o doc proíbe ("never Date.now()"). b tornaria "obsoleto" uma constante errada — não é tempo, é um número fixo. c reintroduz não-determinismo: o mtime muda com o sistema de arquivos e não é controlável pelo teste, então o replay divergiria.spec que vira o hash?", "Como o --resume valida o meta.json?". É só dizer.