Curso / Lição 28
Lição 28 · Avançado · aprofunda o invariante 3

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.

Leia primeiro (fonte do repo)
packages/swarm/src/ids.ts · packages/vm/src/run-plan.ts · docs/alembic-complete-map.md §2 (invariante 3)

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.

Objetivos desta lição
  • Explicar por que o id de uma run é run-${shortHash(spec)} e não um timestamp ou UUID.
  • Rastrear o que runIdFor faz 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.
run-<16hex>
o formato do id (ids.ts:30)
0
funções proibidas no plano
0 hex
prefixo do SHA-256 (SHORT_HASH_LEN)
0
pilares que sustentam o replay

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)
CONTENT-ADDRESSING · do spec ao endereço da run (runIdFor)
spec goal+tasks+limites canonicalJson chaves ordenadas SHA-256 createHash('sha256') .slice(0,16) 16 hex run-<hex> = o diretório O mesmo spec → as mesmas etapas → o mesmo endereço. É por isso que "a mesma run" é sempre encontrável.
Preveja antes de continuar
Você roda 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.
O id é idêntico: 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:

ID DE TIMESTAMP/UUID (não-encontrável) vs ID DE CONTEÚDO (encontrável) — comparativo
id = Date.now() / UUID mesmo spec, 2ª vez → id NOVO run-1737e8… run-1738f2… ✗ duas runs distintas a de ontem fica órfã — replay impossível id = runIdFor(spec) mesmo spec, 2ª vez → MESMO id run-a1b2c3d4… ✓ uma run, retomável cai no diretório existente → resume/replay Mesmo spec, mesmo conteúdo — só muda o que você escolhe como endereço. Conteúdo é estável; o relógio não.

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.

Em linguagem simples: antes de tirar a impressão digital, o Alembic "arruma a casa" — coloca os campos do spec sempre na mesma ordem. Assim, dois specs com o mesmo conteúdo (mesmo que digitados em ordens diferentes) viram exatamente a mesma impressão digital, e portanto o mesmo endereço de run. O hash não pega a ordem; pega o significado.
No detalhe: 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"
DUAS ESPÉCIES DE ID em ids.ts · conteúdo (endereça a run) vs fresco (correlação) — comparativo
runIdFor(spec) · de conteúdo SHA-256(canonicalJson(spec)).slice(0,16) determinístico · estável entre runs ✓ endereça o diretório da run freshId() · fresco randomUUID() não-determinístico · novo a cada chamada ✗ nunca endereça uma run · só correlação O não-determinismo existe — mas é nomeado (freshId) e isolado do endereço da run. Endereço = sempre conteúdo.
CANONICAL JSON · {b,a} e {a,b} → o MESMO hash → o MESMO diretório (comparativo)
{"b":2,"a":1} {"a":1,"b":2} sortKeys (recursivo) → {"a":1,"b":2} SHA-256 um único hash um dir não duplica Ordem de chaves não altera identidade — só o conteúdo. (Arrays preservam ordem, pois ali a ordem é significado.)

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á
};
sortKeys · três casos recursivos · só o objeto é reordenado (árvore de decisão)
sortKeys(nó) qual o tipo do nó? é array é objeto é primitivo value.map(sortKeys) ordem PRESERVADA (ordem é significado) Object.keys().sort() chaves REORDENADAS + desce em cada valor return value inalterado (string/número/bool/null) recursão desce por toda a árvore string canônica → SHA-256 → mesmo id specs estruturalmente iguais colapsam no mesmo hash

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.

spec = {…} run-… diretório: <baseDir>/runs/run-…/ hash de demonstração (não o SHA-256 real) — ilustra só a propriedade "campo muda ⇒ id muda".

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);
resolveRunDir · três pedaços estáveis viram um caminho estável (arquitetura)
expandTilde(baseDir) configurável · ~ expandido 'runs' constante runId = run-<hex> hash do spec (seção 01) join() o diretório …/runs/run-<hex>/ Nenhum dos três pedaços lê o relógio. Mesmo spec ⇒ mesmo runId ⇒ mesmo caminho — toda vez. É o mesmo princípio do id, um nível acima: a estabilidade do conteúdo se propaga do hash para o caminho no disco.
DIRETÓRIO DE RUN · o id endereça; o log + checkpoint guardam o estado replayável
runIdFor(spec) run-<hex> runs/<runId>/ events.jsonl — append-only checkpoint.json — último estado meta.json — fingerprint (validado no --resume) replay / resume relê o log O log é a verdade; o checkpoint é o atalho. Uma run retomada pega exatamente onde o log parou.

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}` }
FLUXOGRAMA · runPlan: ler fonte → varrer construções proibidas → importar ou lançar
readFile(planModulePath) a fonte do plano termina em .ts? (Acorn não lê TS) SIM varredura por regex checkDeterminismTs NÃO parser Acorn (.js) checkDeterminism achou Date.now / new Date / Math.random? SIM ✗ throw — non-deterministic a run para antes de executar NÃO ✓ import executa o plano

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.)

DUAS VIAS DE CHECAGEM · plano .ts (regex) vs plano .js (Acorn) — mesmo veredito (comparativo)
plano .ts checkDeterminismTs — varredura por regex plano .js checkDeterminism — parser Acorn (AST) mesma lista proibida → mesmo veredito

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.

Exemplo resolvido · "rejects a non-deterministic plan before execution" (vm.test.ts:125)
1
O plano ofensor. O teste grava o módulo non-deterministic.plan.js com export const run = async () => ({ now: Date.now() }); — um plano que tenta ler o relógio de parede.
2
runPlan lê a fonte. O caminho termina em .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.)
3
O check casa. O walk encontra a chamada a Date.now() e devolve { ok: false, reason: …'Date.now()'… }.
4
runPlan lança. throw new Error(`plan module ${path} is non-deterministic: ${reason}`) — capturado e retornado como { ok: false, error }. O import() do plano nunca acontece.
5
O teste prova. 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.
Agora você: um plano .ts com const r = Math.random();. A run executa? Qual a reason exata? Responda antes de revelar.
Não executa. Num plano .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.
fábrica de ids (Lição 10 · clarify): o 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.
A COSTURA INJETADA · produção passa o real, o teste passa o fake — mesmo código (comparativo)
PRODUÇÃO Clock real idFactory real TESTE Clock fixo curador · clarify o MESMO código nos dois mundos saída determinística no teste same telemetry + same clock → same decision Tempo e aleatoriedade são efeitos colaterais — exatamente como o filesystem. Injete-os, não os chame.
O princípio: não-determinismo é uma dependência, então injete-o

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.

GLOBAL (proibido) vs COSTURA (injetada) · as duas formas de obter "agora" (comparativo)
global direto const t = Date.now(); ✗ rejeitado no plano · não-replayável muda a cada execução costura injetada const t = clock.now(); ✓ permitido · replayável o teste fixa o clock

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.

1ª RUN (escreve) vs REPLAY (relê) · o mesmo id é a ponte entre as duas (comparativo)
1ª RUN · escreve runIdFor(spec) = run-a1b2… executa o plano (determinístico) grava events.jsonl + checkpoint o estado nasce no disco REPLAY · relê runIdFor(spec) = run-a1b2… (idêntico) encontra o diretório existente relê o log — não re-executa converge ao mesmo estado mesmo id Tire qualquer pilar e a ponte rompe: id de relógio não-encontra; ramo aleatório re-roda diferente; Date.now() no curador muda o "obsoleto".

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.

OS QUATRO PILARES DO REPLAY · remova um e o replay quebra
1 · id de conteúdo acha a mesma run 2 · log + checkpoint relê e retoma 3 · proibição plano re-avalia igual 4 · Clock/id injetados resíduo capturado replay reproduzível ✓

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:

LocalComo obtém tempo / idPermitido?Por quê
módulo de plano (alembic.plan.ts)Date.now() / new Date() / Math.random()✗ rejeitadore-avaliar o plano divergiria → quebra o replay content-addressed
curador (Lição 9)Clock injetado (epoch ms)✓ simo teste fixa o clock → transições active→stale→archived determinísticas
clarify (Lição 10)idFactory injetada (monotonicIdFactory)✓ simo teste passa um contador → ids previsíveis e estáveis
comando de CLIpode ler o relógio livremente✓ simnão é a descrição de uma run; não entra no hash do spec
MAPA DA FRONTEIRA · zona proibida (plano) vs zona injetada (curador/clarify/CLI)
ZONA PROIBIDA · o módulo de plano Date.now() · new Date() · Math.random() a varredura rejeita antes de executar nada que altere a re-avaliação do plano ZONA INJETADA · curador · clarify · CLI clock.now() · idFactory() tempo real entra por uma costura controlada o teste passa um fake → determinístico
"A proibição significa que o Alembic nunca pode usar o tempo atual." Ela proíbe os globais no módulo de plano, onde o não-determinismo quebraria o replay. Em todo o resto, tempo entra por um Clock injetado — totalmente usável, só controlável.
"IDs content-addressed são só para dedup." Dedup é um benefício (re-anexar conteúdo idêntico é um no-op), mas o propósito mais profundo é replay: o id é o fingerprint do spec, então resume/replay encontra e re-roda exatamente a mesma run. Identidade e idempotência caem do mesmo hash.

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.

ONDE O DETERMINISMO ENTRA · spec + invariantes → os três mecanismos → run-dir estável que o replay relê
spec imutável goal + tasks + limites + invariante 3 (L16) content-addressed OS TRÊS MECANISMOS (esta lição) 1 · id endereçado por conteúdo runIdFor(spec) = run-${shortHash} (seções 01–02) o mesmo spec → o mesmo endereço, sempre 2 · Clock + fábrica de ids INJETADOS o resíduo de tempo/aleatório entra por uma costura (seção 06) produção passa o real · o teste passa o fake 3 · a proibição do plano-VM rejeita Date.now() · new Date() · Math.random() (seção 04) re-avaliar o plano gera a estrutura idêntica tire um e a ponte rompe (seção 07) — os três são uma ideia só run-dir estável events.jsonl (append) + checkpoint (seção 03) alembic replay relê o log → mesmo run-dir byte-a-byte crash-safe resume (L19) o não-determinismo só entra por costuras que você controla — então o replay reproduz a run, não a esperança dela
Clique em "Percorrer" para acender uma etapa por vez — do spec aos três mecanismos ao replay.

Cada caixa acesa acima é uma lição inteira. Estes são os encaixes diretos — siga o link e veja a peça por dentro:

Lição 16 · As quatro invariantesporque conecta: esta lição é o mecanismo da invariante ③ ("content-addressed e replayável"). Lá você vê a propriedade declarada; aqui, a engenharia que a torna verdadeira, peça por peça.
Lição 19 · O swarmporque conecta: é quem colhe o determinismo. O 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).
Lição 05 · Ports & injeçãoporque conecta: o 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.
Lição 24 · A trilha de ADRsporque conecta: a proibição da seção 04 não é um capricho — é uma decisão registrada (a regra "plan modules must not use 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)
Experimente · prove o replay com as próprias mãos (5 passos)
1
Entre no repositório e prove o baseline. Rode 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.
2
Rode um escopo e anote o id. Rode 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).
3
Liste e inspecione o run-dir. Rode 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).
4
Refaça o replay. Rode 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.
Quebre o determinismo de propósito. Edite o 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.

runIdFor
Qual o formato do id de uma run?
clique pra virar ↻
Resposta
run-${shortHash(spec)} — prefixo de 16 hex (SHORT_HASH_LEN) do SHA-256 sobre o JSON canônico do spec. (ids.ts:30)
Canonical JSON
Por que ordenar as chaves antes de hashear?
clique pra virar ↻
Resposta
Para que specs estruturalmente iguais (campos em ordens diferentes) colapsem no mesmo hash → mesmo diretório. Arrays mantêm a ordem.
A proibição
O que run-plan.ts faz com um plano .ts contendo Date.now()?
clique pra virar ↻
Resposta
A varredura por regex casa, devolve {ok:false, reason} e runPlan lança antes de importar — a run para sem executar.
Costura
Como o curador obtém "agora" sem quebrar o replay?
clique pra virar ↻
Resposta
Recebe um 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.

1. Por que o id de uma run é derivado de um hash do seu spec em vez de um timestamp ou UUID?
Correto: c. 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ã.
2. A checagem de plano rejeita Date.now(), new Date() e Math.random() num alembic.plan.ts. A razão central é:
Correto: b. Um plano é a descrição determinística de uma run; valores de relógio/aleatórios gerariam estruturas diferentes na re-avaliação, quebrando o replay content-addressed. a é falso: as três funções são padrão e não depreciadas — o problema é o não-determinismo, não a obsolescência. c e d citam custos que não existem aqui: a checagem é sobre determinismo, não velocidade nem memória. Por isso a varredura conservadora "um falso positivo é mais seguro" — ela barra a categoria inteira.
3. O curador genuinamente precisa do "agora" para decidir obsolescência. Como ele obtém tempo sem quebrar o determinismo?
Correto: d. Tempo é efeito colateral, então é injetado como qualquer outro; thresholds (30d/90d) ficam em ms e o "agora" vem do 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.
4. Você submete o mesmo spec duas vezes, em dias diferentes. O que acontece?
Correto: a. O id é o hash do conteúdo do spec — nenhum timestamp entra no cálculo —, então o mesmo spec produz o mesmo endereço e a 2ª invocação reusa o diretório. b descreve um id derivado de relógio, o oposto de content-addressing. c erra o comportamento: re-submeter não é "rejeitado", é convergente — cai no estado existente. d inventa uma "fusão automática" que não existe; há um único diretório porque há um único id.
💬 Travou em algo? Eu sou seu professor neste curso — pergunte. "Por que 16 hex e não o SHA-256 inteiro?", "O que vai dentro do spec que vira o hash?", "Como o --resume valida o meta.json?". É só dizer.