Lição 23 · Curso de Fusão · Parte 4 · Método e labs · Lab: o passo de aprendizado
Parte 4 · Método e labs · Lição 23 · Lab prático 2 de 2

Lab: o passo de aprendizado

No Lab 1 (lição 22) você construiu um store. Agora você conecta a pedra angular da fusão (lições 4 e 8): um passo fechado de auto-melhoria onde um revisor propõe escritas duráveis, um gate dispõe, e as escritas aprovadas sedimentam no store. Você vai montar o reviewAndLearn a partir de três ports injetados — um proposer falso, o gate padrão, e o MemoryStore real do Lab 1 — e ver um turno se dividir nos baldes applied / rejected / failed. Esta é a API que de fato existe no repo (@alembic/hermes); você a chama exatamente como a produção chama.

Pacote @alembic/hermes Driver reviewAndLearn(summary, deps) Ports proposer · gate · memory ADR 0018 · gated, não auto-apply Tempo ~25 min
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ê fez o Lab 1 (lição 22) e tem um MemoryStore rodando sobre um FsPort falso. Vamos reusá-lo aqui.
  • Que você viu o loop fechado (lição 4) e o aprendizado profundo (lição 8) — este lab é a versão "mão na massa" deles.
  • TypeScript básico: async/await e checar if (r.ok). Nada além disso — explicamos cada peça do zero.
1

O que vamos construir


Ao terminar este lab você consegue
  • Montar o reviewAndLearn a partir dos seus três ports (proposer, gate, memory).
  • Explicar por que rejected (o gate disse não) é diferente de failed (o gate disse sim, o store recusou).
  • Distinguir um balde de um erro que aborta o passo inteiro (err).
  • Reconhecer por que trocar o gate padrão pelo Validator real não muda uma linha do driver.

O aprendizado no Alembic é gated, não auto-apply (ADR-0018). O modelo propõe; um Validator dispõe. Ao construir o passo a partir de peças falsas, você vê exatamente por que uma proposta rejeitada é diferente de uma escrita que falhou — e por que ambas são diferentes de um erro que aborta o passo.

Pense num editor de uma revista. Um repórter sugere matérias (proposer). O editor aprova ou recusa cada uma (gate). A gráfica imprime as aprovadas — mas pode faltar papel (store cheio). "Recusada pelo editor" e "aprovada, mas não coube na edição" são coisas diferentes, e nenhuma das duas é "a redação pegou fogo" (o erro que para tudo).

a assinatura que você vai chamar

O driver é uma função pura de orquestração que nunca lança através da fronteira: reviewAndLearn(summary, deps): Promise<Result<LearnOutcome, Error>>. Tudo que ele precisa chega em deps — não há nenhum import de adapter concreto nem construção de store concreto (invariante ②, ADR-0009).

packages/hermes/src/learning/review.ts
export const reviewAndLearn = async (
  summary: string,
  deps: ReviewDeps,
): Promise<Result<LearnOutcome, Error>> => { /* … */ };
Infográfico de um passo fechado de aprendizado: à esquerda o resumo de um turno entra num revisor (proposer) que gera propostas; cada proposta passa por um gate de score; as aprovadas sedimentam no MemoryStore. À direita, três baldes de saída — applied (verde), rejected (vermelho-rust, 'gate disse não'), failed (terracota, 'store recusou'). Faixa de rodapé: 'o modelo propõe, o Validator dispõe — ADR-0018'.

O passo de aprendizado: propor → gate → aplicar, e a saída sempre se separa em três baldes nomeados.

2

Os três ports que você conecta


O driver reviewAndLearn(summary, deps) depende só de ports injetados. Seu ReviewDeps tem exatamente três campos — e os três são obrigatórios:

PortTipoNa produçãoNeste lab
proposerReviewProposeruma chamada de ModelAdapter, no formato da cintura estreitaum fake devolvendo propostas fixas
gateReviewGateo Validator do @alembic/coda (ADR-0006)scoreThresholdGate(0.7) (o padrão que vem no pacote)
memoryMemoryStoreo store durável, file-backedum MemoryStore real sobre um FsPort falso
Por que isto importa para o lab. Como os três são ports, você troca o real pelo falso sem reescrever o driver. Você testa a lógica de roteamento de baldes — a parte interessante — sem rede, sem disco e sem gastar um centavo. Essa é a invariante ② (lição 16) em ação.
ReviewDeps — três ports, todos obrigatórios reviewAndLearn (summary, deps) proposer summary → propostas prod: 1 chamada de modelo gate aprova / recusa cada uma prod: Validator coda memory MemoryStore durável reusa o dedup do store
Você fornece os três; o driver não sabe — nem se importa — se são reais ou falsos.
Preveja antes de continuar

Dos três ports, qual é o único que neste lab será real (não um fake)?

O memory. Usamos o MemoryStore de verdade (sobre um FsPort falso, como no Lab 1) justamente para ver o dedup real e o limite de caracteres real entrarem em jogo. O proposer e o gate são triviais o bastante para serem fakes — e o scoreThresholdGate que usamos como "fake" é, na verdade, o próprio padrão do pacote.
3

O fluxo em três baldes


Antes de codar, fixe o mapa mental. Um resumo de turno entra; cada proposta segue um caminho; a saída sempre cai num de três baldes — ou o passo inteiro aborta com err.

// o caminho de uma proposta
summary do turno proposer → propostas Zod parse forma válida? gate score ≥ 0.7 ? memory.apply dedup reusado applied[] rejected[]gate: não failed[]store: não Zod inválido OU gate erra OU proposer erra → return err — aborta o passo, NÃO é balde
Guarde isto Três baldes, dois abortos. applied/rejected/failed são resultados normais de um passo bem-sucedido. Um err (proposer erra, gate erra, ou proposta com forma inválida) não é um balde — é o passo inteiro falhando fechado, com o store intacto.
4

Passo 1 — construir o store (reuse o Lab 1)


O MemoryStore real do @alembic/hermes precisa de um FsPort. Reusamos o mesmo fake Map-backed do Lab 1, e então chamamos load() uma vez para inicializar o estado vivo.

import {
  MemoryStore, reviewAndLearn, scoreThresholdGate,
} from '@alembic/hermes';
import { ok, err } from '@alembic/contracts';
import type { ReviewProposal, ReviewProposer } from '@alembic/hermes';

const memory = new MemoryStore(makeFakeFs(), '/agent');  // FsPort falso do Lab 1
await memory.load();                                      // inicializa o estado vivo
Por que load() primeiro? O MemoryStore mantém um snapshot congelado capturado no load() (para o cache do prompt) e um estado vivo que o apply() muta. Sem o load() inicial o store não está pronto. Construtor: new MemoryStore(fs, baseDir, options?) — o terceiro argumento (opções) é onde, no Passo 4, vamos apertar o limite de caracteres.
new MemoryStore(fs, baseDir, options?) → load() makeFakeFs() FsPort falso · Map em memória load() lê o disco (vazio) prepara o estado snapshot congelado p/ cache do prompt estado vivo apply() muta este é a MESMA classe do Lab 1 — nada de stub: pipeline Zod → dedup → cap → escrita atômica
O load() deixa o store pronto: um snapshot congelado para o prompt e o estado vivo que cada escrita aprovada muta.
  • Importei MemoryStore, reviewAndLearn e scoreThresholdGate de @alembic/hermes.
  • Construí o store com o FsPort falso e chamei await memory.load().
5

Passo 2 — escrever um proposer falso


Na produção o proposer embrulha uma chamada de modelo e traduz o resultado dela num Result. No lab ele só devolve propostas fixas. Cada proposta é um { target, op, rationale, score } — o score ∈ [0,1] é a confiança do próprio revisor, e o gate decide se ela cruza o piso.

const fakeProposer: ReviewProposer = async (_summary) => {
  const proposals: ReviewProposal[] = [
    { target: 'memory', op: { action: 'add', content: 'Build roda offline por padrão' },
      rationale: 'observado neste run', score: 0.9 },   // forte → deve APLICAR
    { target: 'memory', op: { action: 'add', content: 'talvez preferir tabs?' },
      rationale: 'palpite', score: 0.4 },                // fraco → deve REJEITAR
  ];
  return ok(proposals);
};

o contrato do port

O port ReviewProposer devolve Result<readonly ReviewProposal[], Error> e nunca lança. Uma falha de modelo, na produção, vira err(...) — que faz o passo inteiro falhar fechado. Embrulhar o array em ok diz "o revisor rodou com sucesso e aqui está a saída dele". O score é validado pelo Zod (z.number().min(0).max(1)) antes de qualquer escrita, porque em produção ele vem de saída de modelo — não confiável.

packages/hermes/src/learning/types.ts — reviewProposalSchema
export const reviewProposalSchema = z.object({
  target: memoryTargetSchema,                  // 'memory' | 'user'
  op: memoryOpSchema,                          // add | replace | remove
  rationale: z.string(),
  score: z.number().min(0).max(1),         // fora de [0,1] → err
});
Dica O target pode ser 'memory' (notas do agente) ou 'user' (fatos sobre o usuário). Uma proposta com target: 'user' sedimenta no store USER, não no MEMORY — o roteamento por target também é coberto por teste no repo.
Anatomia de uma ReviewProposal target 'memory' | 'user' op add | replace | remove rationale string (justificativa) score ∈ [0,1] autoavaliação do revisor z.number().min(0).max(1) é o gate que decide se passa do piso o score NÃO é o veredito final fora de [0,1] ⇒ safeParse falha ⇒ o passo retorna err (forma inválida, fail-closed)
Quatro campos; o score é só a confiança do revisor — o Zod o valida em [0,1], e quem decide é o gate.
6

Passo 3 — rodar o passo e ler os baldes


Agora monte os três ports e rode um passo. O gate padrão aprova score ≥ 0.7, então a proposta 0.9 é aplicada e a 0.4 é rejeitada.

const result = await reviewAndLearn('terminei uma unidade; testes verdes', {
  proposer: fakeProposer,
  gate: scoreThresholdGate(0.7),   // o padrão conservador do pacote
  memory,
});

if (result.ok) {
  console.log(result.value.applied.length);   // 1  (a proposta 0.9)
  console.log(result.value.rejected.length);  // 1  (a proposta 0.4)
  console.log(result.value.failed.length);    // 0
  console.log(memory.entries('memory'));     // ['Build roda offline por padrão']
}

O store agora guarda exatamente a escrita aprovada. A proposta rejeitada carrega a razão do gate — "score 0.4 < threshold 0.7 (learn only from validated wins)" — então nada é descartado em silêncio.

A razão é sempre legível

Toda rejeição vem com um texto. O gate de score monta, no caso de recusa, exatamente score <n> < threshold <min> (learn only from validated wins) — a codificação mecânica de "só aprenda com vitórias validadas". Um operador lendo o rejected[] entende por que cada proposta não entrou.

O piso é inclusivo: score ≥ 0,7 aprova 0,0 1,0 piso 0,70 < piso → rejected ≥ piso → aprova (vai ao store) 0,4 0,69 ✗ 0,70 ✓ 0,9
O teste do repo fixa exatamente esta borda: 0,69 rejeita, 0,70 aprova. score >= min, não >.
Preveja

Se você trocar o piso para scoreThresholdGate(0.4), onde cai a proposta de score 0.4?

Em applied[]. O piso é inclusivo: score >= min. Com min = 0.4, a proposta de exatamente 0.4 aprova (o teste do repo fixa isso checando que 0.69 rejeita e 0.70 aprova no piso padrão). E, se o store aceitar a escrita, ela vira applied de fato.
7

Passo 4 — forçar um balde failed (gate sim, store não)


A distinção sutil: rejected = o gate disse não; failed = o gate disse sim, mas o store não conseguiu escrever (por exemplo, estourou o orçamento de caracteres). Para vê-lo, encolha o limite do store de modo que uma escrita aprovada transborde. O passo ainda tem sucesso no geral — só aquela escrita cai em failed:

const tiny = new MemoryStore(makeFakeFs(), '/agent', { memoryCharLimit: 12 });
await tiny.load();

const r = await reviewAndLearn('turno', {
  proposer: async () => ok([{ target: 'memory',
    op: { action: 'add', content: 'uma frase bem mais longa que doze caracteres' },
    rationale: 'x', score: 0.95 }]),   // o gate APROVA (0.95 ≥ 0.7)…
  gate: scoreThresholdGate(),
  memory: tiny,
});
// …mas o store recusa a escrita acima do orçamento:
// r.value.applied = []   r.value.rejected = []
// r.value.failed  = [{ proposal, reason: '…would exceed the limit…' }]
Por que três baldes, e não dois

Colapsar failed dentro de rejected diria a um operador "a política recusou esta escrita" quando a verdade é "a política aprovou, mas o store está cheio". As duas situações pedem correções diferentes — relaxar o gate vs. consolidar a memória. O teste que vive no repo, "records a store-rejected approved write in `failed`, not as a throw", fixa exatamente essa separação: ele usa memoryCharLimit: 12, confere que failed tem comprimento 1 e que a razão casa com /exceed the limit/.

Cuidado failed não é um erro. O passo continua e devolve ok. Se você tratar failed.length > 0 como "o passo quebrou", você vai parar um loop perfeitamente saudável só porque uma nota não coube.
Gate aprovou, mas o store está cheio → failed gate: aprova score 0,95 ≥ 0,7 MemoryStore · memoryCharLimit: 12 limite (12) 'uma frase bem mais longa…' (44 chars) failed[] = [{ proposal, reason }] reason: '…would exceed the limit…' result.ok permanece true — o passo NÃO aborta; só esta escrita cai em failed
O conteúdo de 44 chars passa do limite de 12: a escrita aprovada vira failed com a razão do store, e o passo segue ok.
8

Passo 5 — os caminhos fail-closed


Três coisas abortam o passo inteiro com err (e não num balde): um erro do proposer, um erro do gate, e uma proposta com forma inválida (o Zod recusa). Tente um proposer que devolve err:

O que ABORTA (err) vs o que CONTINUA (balde) proposer → err forma inválida (Zod safeParse) gate → err return err — passo aborta store INTACTO · sem meio-aprendizado store recusa a escrita push em failed[] · passo continua ok err não é um balde — é um passo que não terminou
Três fontes de err abortam e deixam o store intacto; só a recusa do store continua, registrando failed.
const bad = await reviewAndLearn('turno', {
  proposer: async () => err(new Error('modelo deu timeout')),
  gate: scoreThresholdGate(),
  memory,
});
// bad.ok === false — o passo inteiro falhou fechado; o store fica intacto.

o short-circuit, linha a linha

O driver checa o resultado do proposer antes de qualquer gate ou escrita: const proposed = await deps.proposer(summary); if (!proposed.ok) return proposed;. Um erro do proposer propaga na hora. Dentro do laço, processOne devolve err em dois casos — proposta com forma inválida (safeParse falha) e gate que devolve err — e o laço propaga (if (stepErr) return stepErr;). Já uma recusa do store é gravada em acc.failed e devolve undefined, de modo que o passo continua.

packages/hermes/src/learning/review.ts — reviewAndLearn / processOne
const proposed = await deps.proposer(summary);
if (!proposed.ok) return proposed;            // proposer erra → aborta
if (proposed.value.length === 0) return ok(emptyOutcome());

// dentro de processOne, por proposta:
if (!parsed.success) return err(/* forma inválida */);
const verdict = await deps.gate(proposal);
if (!verdict.ok) return verdict;                // gate erra → aborta
if (!verdict.value.approved) { acc.rejected.push(); return; }
const written = await deps.memory.apply();
if (!written.ok) { acc.failed.push(); return; }  // store recusa → failed, NÃO aborta
acc.applied.push(proposal);

E um resumo vazio ou zero propostas é um no-op válido — ok com os três baldes vazios, espelhando o "Nothing to save." da fonte. (Um resumo só com espaços faz o driver retornar antes mesmo de chamar o proposer.)

Guarde isto Fail-closed. Um revisor quebrado não pode "meio-aprender". Sem propostas confiáveis, o resultado é nenhum aprendizado — nunca um aprendizado parcial. O store nunca é tocado num caminho de err.
Vazio é no-op — ok, não err summary só com espaços OU proposals.length === 0 ok(emptyOutcome()) applied: [] rejected: [] failed: [] "Nothing to save." — o store não é tocado; um resumo só de espaços retorna ANTES de chamar o proposer
Nada a salvar é um sucesso silencioso: ok com três baldes vazios, não um erro.
9

Roteador de baldes — experimente


Mexa nos controles e veja em qual balde a proposta cai. Replica a lógica exata do driver: o gate compara score ao piso (inclusivo); se aprovar, o store tenta escrever; se o conteúdo passar do memoryCharLimit, a escrita vai para failed.

veredito do gate: …
applied0gate sim · store ok
rejected0gate não
failed0gate sim · store não
Leia o roteador como um teste. Cada combinação que você produz corresponde a um caso real do review.test.ts: 0.69 vs 0.70 no piso, a escrita acima do orçamento em failed, e a aprovação limpa em applied. O demo não simula a lógica — ele a repete.
10

Exemplo guiado — um turno de quatro propostas


Vamos rodar, no papel, um único passo com quatro propostas e um store de orçamento apertado. Acompanhe cada proposta até seu balde.

Entrada: gate padrão (piso 0.7) · store com memoryCharLimit baixo
1
Proposta Ascore 0.9, conteúdo curto. Gate: aprova (0.9 ≥ 0.7). Store: cabe. → applied.
2
Proposta Bscore 0.4. Gate: recusa (0.4 < 0.7), razão "learn only from validated wins". → rejected.
3
Proposta Cscore 0.95, conteúdo longo demais. Gate: aprova. Store: estoura o limite, recusa com "…would exceed the limit…". → failed.
4
Proposta Dscore 0.8, conteúdo idêntico ao da A. Gate: aprova. Store: dedup → no-op de sucesso. → applied (mas o store continua com 1 entrada, não 2).
5
Resultado: applied = [A, D], rejected = [B], failed = [C], e result.ok === true. memory.entries('memory') tem 1 entrada.
Quatro propostas, um passo, três baldes A · 0,9 · curto B · 0,4 C · 0,95 · longo D · 0,8 · = A gate ≥ 0,7 ? store cap + dedup applied = [A, D]store: 1 entrada (dedup) rejected = [B] failed = [C]
A e D aprovam (mas D é dedup → 1 entrada no store); B é recusada pelo gate; C aprova mas estoura o store. result.ok é true.
Agora você

Sem rodar nada: uma Proposta E chega com score: 1.3. Em qual balde ela cai?

Sua resposta
Em nenhum balde. score: 1.3 viola z.number().min(0).max(1), então o safeParse falha e o passo inteiro retorna err — antes de qualquer gate ou escrita. Forma inválida é fail-closed, igual a um erro de proposer. O teste "returns err on a structurally invalid proposal (out-of-range score)" fixa esse caminho.
11

Sua vez — estenda o passo


Exercício 1 — uma asserção "reforce, não duplique"
1
Rode o passo duas vezes com a mesma proposta de score alto (conteúdo 'Build roda offline por padrão', score 0.9), contra a mesma instância de MemoryStore.
2
Confira: ambos os passos reportam a proposta em applied[] (o gate aprova toda vez).
3
Mas memory.entries('memory') tem comprimento 1, não 2 — o dedup do próprio store faz a segunda escrita ser um no-op de sucesso.
Por que isto é importante. Esta é a propriedade "reforce, não duplique" (lição 8): o loop não adiciona dedup próprio — as escritas aprovadas fluem pelo dedup que já existe no store, espelhando o ON CONFLICT DO UPDATE do mini-loop. O teste que prova isso no repo está no bloco "reviewAndLearn — reinforce, do not duplicate" de review.test.ts (re-propor uma entrada existente não cria uma segunda).
Reforce, não duplique — dois passos, 1 entrada passo 1 · score 0,9 → applied passo 2 · MESMO conteúdo → applied (de novo) dedup do store ON CONFLICT DO UPDATE entries('memory').length === 1 a 2ª escrita é no-op de sucesso o loop NÃO tem dedup próprio — ele reaproveita o do store (lição 8)
O gate aprova as duas vezes (ambas em applied), mas o dedup do store mantém uma única entrada.
Exercício 2 (desafio) — um gate de política própria
1
Escreva um ReviewGate que recuse qualquer proposta cujo op.content contenha uma palavra banida (uma política grosseira de "nada de segredos na memória").
2
Devolva ok({ approved: false, reason: 'contém termo banido' }) para a proposta banida — e aprove uma limpa.
3
Confirme que a banida cai em rejected[] com a sua razão, e a limpa ainda aplica.
A lição do desafio Você acabou de mostrar que o gate é o lugar para impor qualquer política de emissão. O Validator do @alembic/coda é só o exemplo mais rico — mas como ele é um port, o seu gate caseiro e o Validator real entram pela mesma porta, sem tocar no reviewAndLearn.
O gate é um port — qualquer política entra pela mesma porta scoreThresholdGate(0,7) gate caseiro: palavra banida Validator real do @alembic/coda ReviewGate (o mesmo tipo) reviewAndLearn — não muda depende do port, não do concreto
Três políticas diferentes, um único tipo ReviewGate: nenhuma delas faz o driver mudar uma linha (ADR-0018 §1).
12

Cartões de memória


Clique para virar. Recupere a resposta de cabeça antes de conferir.

balde
O que distingue rejected de failed?
clique para virar
rejected = o gate disse não. failed = o gate disse sim, mas o store recusou a escrita (ex.: estourou o limite de chars).
controle de fluxo
Um err do proposer é um quarto balde?
clique para virar
Não. Ele aborta o passo inteiro (return proposed) com o store intacto. Baldes só existem num passo bem-sucedido.
gate
O piso do scoreThresholdGate é inclusivo?
clique para virar
Sim: score >= min. Com o padrão 0.7, 0.70 aprova e 0.69 rejeita.
arquitetura
Quanto do driver muda ao trocar o gate pelo Validator real?
clique para virar
Nada. O gate é um port; você injeta um ReviewGate diferente e o reviewAndLearn nem percebe (ADR-0018).
13

Recapitulando


Os cinco pontos do lab, em sequência. Use ou os botões.

1 · A montagem

Três ports, um driver

Você montou reviewAndLearn a partir de proposer, gate e memory — todos injetados. O driver não importa nenhum concreto (invariante ②).

proposergatememoryreviewAndLearn
1

2 · Os três baldes

applied · rejected · failed

Um passo bem-sucedido sempre separa a saída em três: aprovada e escrita, recusada pelo gate, e aprovada mas recusada pelo store. Cada rejeição vem com razão.

appliedgate sim · store gravourejectedgate disse nãofailedgate sim · store não
2

3 · A distinção crítica

rejected ≠ failed

"O gate disse não" pede relaxar a política. "O store está cheio" pede consolidar a memória. Colapsá-los esconderia qual conserto fazer.

rejectedconserto: relaxar a política do gatefailedconserto: consolidar a memória
3

4 · Fail-closed

Erro aborta, não vira balde

Proposer erra, gate erra, ou forma inválida → o passo retorna err e o store fica intacto. Um revisor quebrado não meio-aprende.

proposer errforma inválida (Zod)gate errreturn errstore INTACTO
4

5 · O payoff

O gate é um port

O scoreThresholdGate(0.7) é o padrão conservador. O Validator real do @alembic/coda entra por injeção, sem mudar o reviewAndLearn. Esse é o ganho de depender de ports.

scoreThresholdGateValidator (coda)driver não mudamesma porta: ReviewGate
5
Slide 1 / 5 use
Infográfico comparativo de três baldes de saída do passo de aprendizado, lado a lado: applied (verde-oliva, 'gate aprovou e store escreveu'); rejected (vermelho-rust, 'gate recusou — score abaixo do piso'); failed (terracota, 'gate aprovou mas store recusou — acima do orçamento'). Abaixo, uma faixa separada marcada err mostrando 'proposer erra · gate erra · forma inválida → aborta o passo, store intacto'. Cada balde com seu conserto sugerido: relaxar o gate vs consolidar a memória.

Os três baldes (resultado normal) versus o caminho err (aborto fail-closed) — cada um pede um conserto diferente.

14

Verifique


Revisão acumulada

Três perguntas. A pontuação corre conforme você responde — acerte as três e a revisão fecha.

1. Uma proposta tira score 0.95, o gate aprova, mas o MemoryStore está acima do orçamento de chars e recusa a escrita. Onde ela cai?
Correto: c. rejected é recusa do gate; failed é "gate sim, store não". Uma escrita aprovada acima do orçamento é gravada em failed com a razão do store, nunca lançada e nunca confundida com recusa de política. O teste "records a store-rejected approved write in `failed`, not as a throw" fixa isso.
2. Você injeta scoreThresholdGate(0.7). Depois a equipe entrega o Validator real do coda. Quanto do reviewAndLearn muda?
Correto: b. O gate é um port. O padrão conservador é opt-in por injeção, e o Validator real "wires in later by supplying its own ReviewGate" — sem tocar no driver. Esse é o payoff de depender de ports, não de concretos.
3. Seu proposer falso devolve err(new Error('modelo deu timeout')). O que acontece com o passo e o store?
Correto: d. reviewAndLearn faz if (!proposed.ok) return proposed — um erro do proposer faz curto-circuito do passo inteiro antes de qualquer gate ou escrita. Fail-closed é a regra: sem propostas, nenhum aprendizado, não aprendizado parcial.
Acertos: 0/3
Você é o aluno e também o auditor: abra packages/hermes/src/learning/review.test.ts e ache os três casos que você acabou de viver — store-rejected → failed, reinforce-not-duplicate e fail-closed on port errors. A seguir (lição 24): saímos dos labs e seguimos a trilha de ADRs — as decisões (como a ADR-0018 que rege este passo) que explicam por que o motor é assim.