Curso / Lição 14
Lição 14 · Motor & método

A cintura estreita: um run() que nunca lança

Toda invocação de modelo no Alembic — todo adapter, todo membro de council, todo worker do swarm, toda camada do funil — passa por uma única forma de função: uma chamada assíncrona que nunca lança exceção e devolve um valor discriminado por ok. É o invariante mais importante do código. O pacote de fusão das lições 7–13 se pluga nesta cintura; entendê-la é entender por que uma rede instável ou um 429 não conseguem derrubar uma run.

Leia primeiro (fonte primária — no repositório)
packages/contracts/src/model.ts + packages/adapters/src/adapter-core.ts — governado pela ADR-0009

Esta lição destila o contrato (model.ts) e a espinha que o impõe (adapter-core.ts), lidos linha a linha. Por que importa pra missão: é a costura por onde flui cada chamada de modelo da fusão — se ela rejeitar, qualquer lane derruba a run inteira; se nunca rejeitar, uma falha vira só um valor a tratar.

Objetivos desta lição
  • Distinguir lançar de nunca-lançar e por que a falha vira um valor, não um salto de fluxo.
  • Ler o ModelRunResult como união discriminada por ok e como o compilador estreita o tipo.
  • Seguir as cinco etapas de runWithGuards (Zod → try/catch → breaker → retry → rede final).
  • Decidir se um erro é retryable pelo catálogo de ErrorCode — e por que isso fica na origem.
0
braços da união · ok:true / ok:false
0
etapas em runWithGuards
0
estados do circuit-breaker
0
throw no tipo de run()

01 · Por que uma chamada de modelo nunca lança

Comece pela intuição. Uma exceção é como um alarme de incêndio: ela interrompe tudo e sobe pela pilha procurando alguém que a apague (try/catch). Quem chama uma função não consegue ver, pelo tipo da função, quais alarmes ela pode disparar — então ou super-captura tudo "por via das dúvidas", ou esquece e o alarme derruba o prédio inteiro. Um valor de falha é o oposto: em vez de soar um alarme, a função te entrega um bilhete dizendo "falhei, eis o porquê". Você lê o bilhete na hora que quiser, e o tipo da função te obriga a olhar.

DUAS FORMAS DE FALHAR · "lança" (alarme invisível) vs "nunca-lança" (bilhete tipado)
lança (exceção) attempt() encontra um 429 throw — sobe pela pilha… ✗ ninguém capturou rejeição não tratada → a run cai nunca-lança (valor) attempt() encontra um 429 return { ok:false, error:{…retryable} } ✓ quem chama ramifica if (result.ok) — fallback é só transformar valor A informação do erro é a MESMA nos dois — muda só se ela viaja como salto invisível ou como dado visível.
A lei, escrita na própria interface. O contrato não confia na disciplina de quem escreve adapter — ele declara a regra no tipo. Em packages/contracts/src/model.ts, a interface diz, textual: /** Run a single completion. NEVER throws — see invariant above. */. E o bloco acima do método: "INVARIANT: ModelAdapter.run NEVER throws. Any error — network, timeout, quota, adapter bug — MUST be returned as a ModelRunFailure." Quem chama, portanto, nunca precisa de try/catch em volta de run: só ramifica em result.ok.

Há uma segunda metade nessa promessa que é fácil de ignorar: run é assíncrona, e toda Promise tem dois desfechos — resolve (entrega um valor) ou rejeita (estoura como uma exceção, que quem chama precisa pegar com .catch / try/await). O invariante elimina o segundo desfecho de propósito: a promessa de run sempre resolve, e o valor resolvido carrega o sucesso ou a falha. Ela nunca rejeita.

UMA PROMISE, DOIS DESFECHOS POSSÍVEIS · o invariante apaga o ramo "rejeita"
await run(input) Promise<ModelRunResult> resolve ✓ rejeita ✗ SEMPRE este caminho → ModelRunResult { ok:true, text… } | { ok:false, error… } — os dois são valores resolvidos caminho apagado pelo invariante nenhuma exceção escapa — logo, nenhum try/await é necessário Numa Promise normal você trata os dois ramos; aqui o contrato garante que só o ramo verde acontece.

Por que "cintura": muitos chamadores, uma forma só

O nome vem do desenho de uma ampulheta. Em cima, muitos chamadores diferentes (adapters de cada provedor, membros de council, workers do swarm, camadas do funil). Embaixo, muitos backends. No meio, uma única forma estreita por onde tudo passa — run(input): Promise<ModelRunResult>. Aperte a cintura num único contrato e o sistema inteiro herda a mesma garantia de uma vez.

A AMPULHETA · todo chamador → o mesmo run() → todo backend (a cintura estreita)
council (debate) swarm (workers) funil (tiers) roteador/fallback run(input): Promise<ModelRunResult> a cintura — NEVER throws cliproxyapi local / local-cli offline anthropic / codex

02 · A forma: ModelRunResult

O contrato é uma união discriminada por um campo literal ok. "União" porque o valor é um de dois formatos; "discriminada" porque há um campo (ok) cujo valor literal — true ou false — diz qual dos dois você tem. Não existe throw no tipo: a falha é um braço da união, não um evento de fluxo de controle.

ModelRunResult · uma união, dois braços, discriminada por ok
ModelRunResult z.discriminatedUnion('ok', […]) ok = true ok = false ModelRunSuccess ok: true · adapterId · modelId requestId · durationMs · text usage? · costUsd? · raw? modelVersionUsed? o resultado normal: texto + telemetria ModelRunFailure ok: false · adapterId · modelId requestId · durationMs · raw? error: { code, message, retryable: boolean } a falha tipada: por que, e se vale tentar de novo
// packages/contracts/src/model.ts — os dois braços (condensado)
type ModelRunSuccess = {                  // ok: literal(true) é o discriminante
  ok: true; adapterId: string; durationMs: number; modelId: string;
  text: string; usage?: TokenUsage; costUsd?: number; raw?: unknown;
};
type ModelRunFailure = {                  // ok: literal(false)
  ok: false; adapterId: string; durationMs: number; modelId: string;
  error: { code: string; message: string; retryable: boolean };
};
type ModelRunResult = z.discriminatedUnion('ok', [Success, Failure]);

Snippet condensado do real (no arquivo, ambos os braços também carregam requestId e o Success ainda tem modelVersionUsed? — ver o diagrama acima). O ponto que importa: o braço de falha carrega um booleano retryable — quem chama não precisa adivinhar se um 429 vale nova tentativa; o resultado já diz. A interface documenta a lei: run(input): Promise<ModelRunResult> // NEVER throws (invariant).

Preveja antes de continuar
Quantas vezes a palavra throw aparece no tipo de retorno de run()? E o que isso implica para quem chama?
Zero. O tipo é Promise<ModelRunResult> — e ModelRunResult é só Success | Failure. Como nenhuma exceção aparece no tipo, quem chama nunca precisa de try/catch; ele ramifica em result.ok. Se você imaginou "depende de cada adapter", caiu na armadilha que o contrato existe para matar: a exceção seria invisível no tipo — por isso a falha foi movida para dentro do valor, onde o compilador a enxerga.

03 · O compilador estreita o tipo por você

A grande vantagem prática da união discriminada: ao testar if (result.ok), o TypeScript estreita automaticamente o tipo dentro de cada ramo. No then, result é só ModelRunSuccess (tem .text); no else, é só ModelRunFailure (tem .error). Acessar .error no ramo de sucesso é erro de compilação — o tipo te impede de ler o campo errado.

A RAMIFICAÇÃO · um teste em ok abre dois caminhos, cada um com seus campos
result: ModelRunResult if (result.ok) true ModelRunSuccess usa result.text ✓ false ModelRunFailure · result.error.retryable result.error aqui = erro de compilação ✗
Por que isso vale ouro num sistema de fan-out. O harness distribui um pedido a dezenas de modelos em paralelo (council, swarm, funil). Com exceções, uma cair sem catch contamina a faixa toda; com a união, cada lane devolve seu Success ou Failure e o agregador apenas transforma valores. É a diferença entre "o 500 de um provedor degrada aquela faixa" e "o 500 de um provedor derruba a run".
FAN-OUT · uma lane falha — com exceção (contamina tudo) vs com a união (degrada só ela)
com exceção lane A ok lane B throw lane C ok agregador (Promise.all) ✗ rejeita inteiro o throw de B descarta A e C também com a união lane A ok lane B !ok lane C ok agregador (transforma valores) ✓ entrega A + C (B vira fallback) o Failure de B é só um valor a tratar

04 · runWithGuards: a espinha que impõe o invariante

Um comentário dizendo "nunca lança" é inútil se um adapter esquecer. Então o invariante é estrutural: cada adapter implementa apenas um attempt() interno — uma única tentativa de rede/subprocesso — e uma espinha compartilhada, runWithGuards, o envolve. O comentário no topo do arquivo é explícito: "tudo transversal vive aqui para que todos os seis adapters se comportem de forma idêntica". São cinco etapas, em ordem:

FLUXOGRAMA · runWithGuards — da entrada ao ModelRunResult (5 etapas, 2 losangos de decisão)
run(input) chamado ① validate(adapterId, input) — Zod validation.ok? NÃO return Failure nunca chega ao attempt SIM ④ try { … } catch → failureFromThrown (rede de segurança final) ③ withRetry — re-executa enquanto retry:true, com backoff ② guardedAttempt(…) → ⑤ attempt(input) a única coisa que cada adapter escreve ModelRunResult (sempre resolve)
// packages/adapters/src/adapter-core.ts:118-147 — a espinha canônica (condensado)
export const runWithGuards = async (adapterId, attempt, input, runtime = {}) => {
  const validation = validate(adapterId, input);          // ① Zod na fronteira
  if (!validation.ok) return validation.result;         //    input ruim ⇒ Failure, não throw
  try {
    return await withRetry(                                // ③ backoff em resultados retryable
      () => guardedAttempt(adapterId, attempt, input, runtime.breaker, logger), // ②
      policy, { clock, logger, random: runtime.random, signal: input.signal },
    );
  } catch (cause) {
    // withRetry só rejeita se guardedAttempt rejeitar, o que nunca acontece;
    // esta é uma rede de segurança final para preservar o invariante.
    return failureFromThrown({ adapterId, input, durationMs: 0 }, cause); // ④
  }
};

As quatro guardas em prosa: validação Zod do input na fronteira (input ruim já vira Failure, sem nunca tocar o attempt); o guardedAttempt com try/catch + feedback do circuit-breaker; o withRetry que re-executa enquanto o resultado for retryable; o try/catch externo — a "rede de segurança final". E a 5ª etapa é o attempt(input) em si, a única que cada adapter escreve.

Em uma frase: imagine um adapter como um estagiário que só sabe "tentar uma vez" (attempt). O runWithGuards é o supervisor experiente que cerca essa tentativa: confere o pedido antes (Zod), segura a mão num provedor doente (breaker), tenta de novo quando faz sentido (retry) e, se tudo der errado de um jeito impensado, ainda transforma o tropeço num bilhete de falha em vez de deixar o alarme tocar. O estagiário muda; o supervisor é sempre o mesmo.
Por dentro: a camada ④ é logicamente inalcançávelwithRetry só rejeita se guardedAttempt rejeitar, e guardedAttempt já captura qualquer throw internamente e o converte via failureFromThrown. O catch externo permanece como defesa em profundidade: custa um try e protege a promessa mais crítica do sistema contra uma refatoração futura que quebre uma suposição interna. O comentário no código diz isto literalmente (linhas 143–144).

A mesma espinha, vista como camadas concêntricas

Outra forma de ler: cada guarda envolve a anterior. Um throw em qualquer profundidade é convertido num ModelRunFailure tipado antes de escapar — a união é total.

DEFESA EM PROFUNDIDADE · cada camada envolve o attempt; nada escapa como exceção
① validação Zod do input na fronteira — input ruim ⇒ ModelRunFailure (nunca chega ao attempt) ④ try/catch externo ao redor de withRetry — "rede de segurança final para preservar o invariante" ③ withRetry — re-executa enquanto result.error.retryable, com backoff ② guardedAttempt — try/catch + feedback do circuit-breaker ⑤ attempt(input) — a única coisa que cada adapter escreve um throw em QUALQUER profundidade vira um ModelRunFailure tipado via failureFromThrown — a união é total

05 · guardedAttempt por dentro — onde o breaker aprende

A camada ② é o coração operacional. Ela faz quatro coisas, nesta ordem: (1) consulta o circuit-breaker — se ele estiver aberto, devolve uma rejeição retryable sem nem tentar; (2) executa o attempt dentro de um try/catch (qualquer throw vira failureFromThrown — defesa em profundidade); (3) alimenta o breaker (recordSuccess num ok, recordFailure caso contrário); (4) só pede retry: true quando result.error.retryable.

FLUXOGRAMA · guardedAttempt — dois losangos: breaker aberto? e erro retryable?
guardedAttempt(…) breaker.canExecute()? (circuito fechado?) NÃO (aberto) retry:true · circuit_open espera o cooldown SIM try { result = await attempt(input) } catch → failureFromThrown (defesa em profundidade) result.ok? SIM recordSuccess() retry:false · value NÃO → recordFailure() error.retryable? sim → retry:true não → retry:false (terminal)

Direto do adapter-core.ts: guardedAttempt chama breaker?.recordSuccess() num resultado ok e breaker?.recordFailure() caso contrário, e reporta retry: true apenas quando result.error.retryable (linhas 102–110). O comentário no catch interno é literal: "Defense in depth: an attempt should not throw, but if it does we honor the never-throws invariant here." (linhas 98–99).

O laço de realimentação: cada tentativa ensina o breaker

Repare que a etapa ② não só consulta o breaker — ela o alimenta de volta. canExecute() decide se a chamada passa; depois, recordSuccess()/recordFailure() atualizam o contador interno que, na próxima chamada, muda a resposta de canExecute(). É um ciclo fechado: o resultado de hoje é a porta de amanhã.

LAÇO DE REALIMENTAÇÃO · canExecute → attempt → result.ok? → record* → contador → (de volta a canExecute)
canExecute() porta: passa? curto-circuita? admite attempt(input) a única chamada de rede result.ok? SIM recordSuccess() zera o contador NÃO recordFailure() +1 consecutiveFailures contador interno do breaker consecutiveFailures · openedAt · estado realimenta a porta (5 falhas seguidas ⇒ abre) O ciclo fechado é o que faz o breaker "aprender": sem ele, cada chamada bateria num provedor doente como se fosse a primeira.

06 · O catálogo de erros — quem decide retryable, e por quê

De onde vem o booleano retryable? Não de um chute em cada ponto de chamada — de um catálogo único, ErrorCode, em packages/adapters/src/errors.ts. Cada código nasce classificado como retryable ou não; o makeError deriva a flag do código (a menos que seja sobrescrita). O adapter, que conhece o provedor, classifica uma vez na origem — e toda a política fica uniforme.

Comparativo direto: os mesmos erros, dois grupos. Verde = transitório, vale repetir; laranja = definitivo, repetir só queima cota.

ErrorCode · RETRYABLE (transitório) vs NÃO-RETRYABLE (definitivo) — comparativo lado a lado
retryable: true withRetry faz backoff e tenta de novo • rate_limitedHTTP 429 • server_errorHTTP 5xx • network_errorsocket/DNS reset • timeoutdeadline/abort • circuit_opencurto-circuito a falha provavelmente passa se esperar um pouco retryable: false repetir não ajuda — falha terminal • client_errorHTTP 4xx (≠429) • parse_errorcorpo inválido • subprocess_errorCLI saiu ≠0 • auth_errortoken ausente • not_implemented · unknown o pedido está errado, não o momento
SituaçãoErrorCoderetryablePor quê
HTTP 429 (cota)rate_limitedsimTransitório — esperar e repetir costuma passar
HTTP ≥ 500server_errorsimFalha do upstream, não do pedido
HTTP ≥ 400 (≠ 429)client_errornãoPedido malformado/auth — repetir não muda nada
Corpo ilegívelparse_errornãoA resposta não casa a forma esperada
Token ausente/expiradoauth_errornãoCredencial é problema de configuração

A classificação de HTTP é uma função pura: classifyHttpStatus429 → rate_limited, ≥500 → server_error, ≥400 → client_error (errors.ts:64–69). O conjunto retryable é fixado por política em RETRYABLE_CODES (errors.ts:48–54).

07 · withRetry: backoff exponencial com jitter (faça a conta)

Quando o resultado é retryable, a camada ③ espera antes de tentar de novo — e a espera cresce a cada tentativa, para não martelar um provedor já doente. A fórmula é min(base × 2^tentativa, max), e então aplica-se full jitter (um fator aleatório em [0,1]) para espalhar os clientes no tempo. A política padrão: maxAttempts: 3, baseDelayMs: 200, maxDelayMs: 2000.

Exemplo resolvido · o teto de backoff de cada tentativa (jitter no máximo, rand = 1)
1
Pegue a política padrão. base = 200 ms, max = 2000 ms, maxAttempts = 3 (logo, até 2 esperas entre 3 tentativas).
2
Tentativa 0 → 1. min(200 × 2^0, 2000) = min(200, 2000) = 200 ms (teto).
3
Tentativa 1 → 2. min(200 × 2^1, 2000) = min(400, 2000) = 400 ms (teto).
4
O jitter. Com full jitter, a espera real é floor(teto × rand), rand ∈ [0,1) — então cada espera fica entre 0 e o teto. Espalhar evita que 1000 clientes repitam no mesmo milissegundo.
Agora você: com base = 200 e max = 2000, qual seria o teto da espera na tentativa 4 → 5 (índice 4)? Faça antes de revelar.
min(200 × 2^4, 2000) = min(3200, 2000) = 2000 ms — o maxDelayMs entra em ação: a curva exponencial é aparada em 2 s. (Na política padrão você nem chega à tentativa 4, pois maxAttempts = 3; mas a fórmula é a mesma para qualquer política.)

Antes do gráfico de tetos, veja a sequência no tempo: a política padrão dá maxAttempts: 3, logo até 2 esperas entre 3 tentativas. O loop só repete enquanto o resultado pede retry:true; quando o orçamento acaba, withRetry devolve o último valor produzido — mesmo que ele ainda pedisse retry (linhas 88–100).

LINHA DO TEMPO · 3 tentativas, 2 backoffs entre elas (o orçamento de maxAttempts se esgota)
t = 0 tempo → attempt 0 falha · retry:true sleep ≤ 200 ms backoff 1 (× jitter) attempt 1 falha · retry:true sleep ≤ 400 ms backoff 2 (× jitter) attempt 2 (última) orçamento esgotado: devolve este valor ↑ Se qualquer tentativa der ok (ou retry:false), o loop para ali — não chega às seguintes. Só erros retryable (429/5xx/network/timeout/circuit_open) consomem o orçamento; um 4xx terminal sai na tentativa 0.

Mexa os sliders e veja a curva de backoff (o teto por tentativa) crescer e ser aparada pelo maxDelayMs:

soma dos tetos
600 ms
1ª espera aparada em
tentativa
esperas (n−1)
2

As barras mostram o teto de cada espera (min(base×2^i, max)); a espera real é esse teto × um jitter em [0,1). A linha tracejada é o maxDelayMs — onde a exponencial para de crescer.

Por que a espera tem de realmente acontecer. O withRetry aguarda o clock.sleep injetado — um relógio falso pode adiantá-lo nos testes, mas em produção o backoff genuinamente decorre. E ele nunca rejeita: se o sleep for abortado no meio, o erro é engolido e a próxima tentativa observa o mesmo signal, registrando o abort como uma falha tipada (retry.ts:108–118). Preservar o invariante nunca-lança vale até para a pausa entre tentativas.

08 · O circuit-breaker — três estados que poupam um upstream doente

A camada ② consulta um disjuntor (circuit-breaker). A analogia é o disjuntor elétrico: depois de muitas faíscas seguidas, ele desarma e corta a corrente por um tempo, em vez de deixar a casa pegar fogo a cada tentativa. Aqui: depois de failureThreshold falhas consecutivas (padrão 5), ele abre e curto-circuita as chamadas por um cooldownMs (padrão 30 s); depois admite uma tentativa de teste.

MÁQUINA DE ESTADOS · CLOSED → OPEN → HALF_OPEN → (CLOSED | OPEN)
CLOSED chamadas passam OPEN curto-circuita (canExecute=false) HALF_OPEN admite tentativa de teste 5 falhas consecutivas cooldown 30s sucesso (≥ successThreshold=1) → fecha qualquer falha → reabre (reinicia cooldown) qualquer sucesso zera o contador de falhas
Por que existe o estado HALF_OPEN: reabrir direto para CLOSED seria ingênuo — o upstream pode ainda estar doente. O HALF_OPEN admite só um punhado de chamadas de teste (halfOpenMaxCalls, padrão 1); o primeiro sucesso fecha, qualquer falha reabre na hora e reinicia o cooldown. Assim uma rajada não atropela um provedor que mal voltou.
Como isso fecha o loop com o retry: quando o breaker está OPEN, o guardedAttempt devolve circuit_open com retry: true — então o withRetry espera o backoff, que dá tempo do cooldown passar. O breaker e o retry são duas peças que se conversam: uma corta, a outra espera.
Pureza pra testar. O breaker é puro em relação ao tempo via um Clock injetado; não faz IO e nunca lança (circuit-breaker.ts:20–22). É a mesma disciplina da cintura: o tempo entra por uma porta (o Clock), então um relógio falso testa transições de 30 s em microssegundos.

09 · O irmão leve: Result<T, E>

Existe uma segunda união discriminada, mais leve — também por ok, mas com braços value/error — para trabalho falível não-modelo: IO de arquivo, parsing, wrapper de subprocesso. Ela espelha de propósito a cintura de modelo, então ambas se leem igual nos pontos de chamada.

// packages/contracts/src/result.ts — o irmão leve
type Result<T, E = Error> =
  | { readonly ok: true; readonly value: T }
  | { readonly ok: false; readonly error: E };
// helpers: ok(v), err(e), isOk, isErr, mapResult, tryCatch, tryCatchAsync
DUAS UNIÕES, UM DISCRIMINANTE · contrato rico de modelo vs contrato mínimo falível
discriminante compartilhado: ok ModelRunResult (rico) braço ok: text, usage, costUsd, durationMs, adapterId, modelId… braço !ok: error{code,message,retryable} contrato de chamada de modelo Result<T, E> (mínimo) braço ok: value: T braço !ok: error: E helpers: ok/err/isOk/mapResult… IO/parsing/subprocesso

Este é o Result<T, Error> que você viu retornado por todo subsistema do @alembic/hermes nas lições 7–13 — load(), transcribe(), webSearch() todos o retornam. A regra do repositório é explícita: "código de biblioteca retorna Result em vez de lançar" (CLAUDE.md). Duas uniões, uma filosofia: falibilidade é um valor, validado na fronteira, nunca uma surpresa.

10 · Como isso se encaixa

Você acabou de ler a cintura de perto. Agora afaste a câmera e veja onde ela mora no aparelho inteiro. A cintura não é um subsistema isolado — é a junta por onde passa toda chamada de modelo do Alembic. Acima dela, os adapters (um por provedor) só escrevem o attempt(); abaixo, o motor — council, swarm, funil, gates, ciclo de aprendizado — consome o ModelRunResult como um valor a ramificar, nunca como um alarme a apagar.

A CINTURA NO PIPELINE · adapters (acima) → run(): ModelRunResult (a cintura) → o motor (abaixo)
ACIMA · cada adapter escreve só um attempt() (Lição 05) adapter cliproxyapi adapter local / cli adapter offline run(input): Promise<ModelRunResult> a cintura · runWithGuards impõe o invariante NEVER throws um valor { ok } — nunca uma exceção ABAIXO · o motor consome o ModelRunResult — ramifica em ok, nunca captura exceção council & verifierLição 18 o swarmLição 19 o funilLição 15 pipeline de gatesLição 17 tiers · custo · orçamentoLição 27 tudo isto repousa na invariante nº 1 — "uma chamada nunca lança" (Lição 16)
passo 0/4

Clique Percorrer o fluxo: acenda cada estágio na ordem em que uma chamada o atravessa — dos adapters, pela cintura, até o motor e a invariante que sustenta tudo.

As peças que esta cintura conecta

Onde você está na metodologia. Se o curso fosse um mapa do motor, esta lição seria o cano-tronco no centro: adapters à montante, todo o resto à jusante. Domine a cintura e as Lições 15–19 e 27 deixam de ser subsistemas soltos — viram consumidores da mesma garantia. Para ver esse encaixe de forma interativa, com o pipeline inteiro clicável, abra o mapa interativo da metodologia e procure o nó "cintura estreita / run()".

11 · Na prática

A cintura é um contrato de tipo — você não a "roda" com um comando próprio. O jeito honesto de exercitá-la é rodar a suíte que prova o invariante (a adapter-core.test.ts verifica, no boundary real, que um throw, uma rejeição assíncrona e um input inválido viram todos um ModelRunResult com ok:false — nunca uma exceção) e, depois, lembrar que todo alembic run roteia cada chamada de modelo por ela.

# 1 · a suíte que prova o invariante da cintura (boundary real)
#    o tipo ModelRunResult vive em @alembic/contracts/src/model.ts;
#    a espinha runWithGuards que o impõe é exercitada em @alembic/adapters.
#    vitest aceita um filtro por nome de arquivo — rode só a suíte da cintura:
pnpm -w test adapter-core

# saída esperada (resumida):
#  ✓ src/adapter-core.test.ts (N)
#    ✓ runWithGuards never-throws invariant
#      ✓ a sync throw inside attempt() resolves to a Failure
#      ✓ an async rejection resolves to a Failure
#      ✓ invalid input fails Zod and never reaches attempt()
#  Test Files  1 passed

# 2 · o baseline completo do repositório (o que todo PR precisa manter verde)
pnpm -r typecheck && pnpm -r build && pnpm -w test

# 3 · ver a cintura "viva": qualquer run roteia CADA chamada de modelo por ela.
#    offline = hermético, $0 — o adapter offline também devolve ModelRunResult.
alembic run --goal GOAL.md --plan alembic.plan.ts --yes

Nota honesta sobre o comando: @alembic/contracts (onde ModelRunResult é declarado) é um pacote só-de-tipos — não tem script test próprio, então pnpm --filter @alembic/contracts test não roda. Quem exercita o invariante é a adapter-core.test.ts em @alembic/adapters; por isso usamos o filtro por nome de arquivo do vitest na raiz (pnpm -w test adapter-core) — ele casa o arquivo onde quer que esteja. [uncertain] o nome exato e a contagem de cada caso de teste podem variar entre versões; rode o comando para ver a lista corrente — a estrutura ("never-throws invariant") é estável.

Experimente · prove o "nunca lança" com as próprias mãos
1
Entre no repositório. cd /Users/acf/Documents/Projects/appfy/alembic (ou o caminho do seu clone) e instale uma vez: pnpm install.
2
Rode só a suíte da cintura. pnpm -w test adapter-core. Procure a linha runWithGuards never-throws invariant — é a prova de que um throw vira um ModelRunResult, não um crash.
3
Leia o contrato na origem. Abra packages/contracts/src/model.ts e ache o comentário NEVER throws — see invariant above no método run; depois packages/adapters/src/adapter-core.ts (a função runWithGuards, ~linha 118) para ver as cinco etapas que você estudou.
4
Veja a cintura num run real (offline, $0). Num escopo com GOAL.md + alembic.plan.ts, rode alembic run --goal GOAL.md --plan alembic.plan.ts --yes. Cada chamada de modelo que aparecer nos eventos passou por run() — inspecione o run-dir em <dataDir>/runs/<run-id> (alembic runs list lista os ids).
Agora você: qual desses passos falharia se você trocasse o comando por pnpm --filter @alembic/contracts test? Pense antes de revelar.
O passo 2. @alembic/contracts não define um script test — o pnpm erra com "No projects matched ... a script named test". O contrato é provado de fora, em @alembic/adapters: o tipo declara a lei, e a suíte do adapter a verifica no boundary. Por isso o comando certo é o filtro por arquivo na raiz.
Da teoria à fronteira. O valor de toda esta lição não está no diagrama — está naquela linha verde do passo 2. "Nunca lança" deixa de ser uma promessa em prosa e vira um teste que falharia se alguém quebrasse o invariante. É a disciplina do Proof Gate (Lição 17) aplicada ao alicerce do motor: nenhuma garantia conta como verdadeira até um comando real a provar.

Fixe os conceitos (flashcards)

Clique pra virar. Tente lembrar a resposta antes de virar — recuperação ativa fixa mais que reler.

Contrato
O que é ModelRunResult?
clique pra virar ↻
Resposta
Uma união discriminada por ok: ModelRunSuccess (ok:true, text…) ou ModelRunFailure (ok:false, error{code,message,retryable}). Sem throw no tipo.
Espinha
As 5 etapas de runWithGuards?
clique pra virar ↻
Resposta
① Zod na fronteira → ② guardedAttempt (try/catch + breaker) → ③ withRetry (backoff) → ④ try/catch externo (rede final) → ⑤ attempt(input).
Retry
Quem decide se um erro é retryable?
clique pra virar ↻
Resposta
O catálogo ErrorCode na origem. 429/5xx/network/timeout/circuit_open = retryable; 4xx/parse/auth/subprocess = não. makeError deriva a flag do código.
Breaker
Os 3 estados do circuit-breaker?
clique pra virar ↻
Resposta
CLOSED (passa; abre após 5 falhas) → OPEN (curto-circuita por 30 s) → HALF_OPEN (tentativa de teste; sucesso fecha, falha reabre).

Revisão cumulativa — recupere de memória

Antes de clicar: responda de cabeça. As opções têm tamanho parecido de propósito — sem pista pela forma.

1. O attempt() interno de um adapter lança um TypeError cru no fundo do corpo. O que quem chamou run() observa?
Correto: b. O throw é capturado na camada ② e convertido num failure tipado; até o catch externo logicamente inalcançável em ④ o converteria. a é exatamente o que o invariante existe para impedir — run() resolve, nunca rejeita. c inventa um sucesso: um throw vira falha, não sucesso vazio. d ignora o orçamento finito: maxAttempts limita as tentativas, e só erros retryable repetem.
2. Por que ModelRunFailure carrega um retryable: boolean em vez de deixar quem chama decidir?
Correto: c. guardedAttempt retorna retry: true exatamente quando result.error.retryable vale, e withRetry faz backoff por essa flag. a confunde propósito com tamanho — o campo carrega decisão, não volume. b é falso: a flag dirige o retry, não só o log. d inverte a causa: o Zod valida a forma porque ela existe; ela existe para classificar a retryability na origem e manter a política uniforme.
3. O try/catch externo em runWithGuards é descrito como "uma rede de segurança final". Por que manter código logicamente inalcançável?
Correto: d. withRetry só rejeita se guardedAttempt rejeitar, o que nunca acontece — então hoje o catch externo não dispara. a erra a ordem: a validação Zod é a etapa ① e já retorna Failure antes do try. b não faz sentido — um try não acelera nada. c é falso: o retry acontece dentro do withRetry, não pelo catch externo. O comentário no código diz exatamente d.
4. Um adapter recebe um HTTP 401 Unauthorized (token expirado). O withRetry vai tentar de novo?
Correto: a. classifyHttpStatus manda 4xx (≠429) para client_error, e credenciais ausentes/expiradas são auth_error — ambos não-retryable, então guardedAttempt devolve retry: false e o resultado é terminal. b generaliza errado: só 429/5xx/network/timeout/circuit_open são retryable. c ignora o orçamento finito do withRetry. d confunde papéis: o breaker conta falhas, mas é a flag retryable (não o breaker) que decide repetir.
💬 Travou em algo? Eu sou seu professor neste curso — pergunte. "Por que o catch externo se ele nunca dispara?", "Como o breaker e o retry se coordenam no caso 429?", "Por que duas uniões em vez de uma só?". É só dizer.