A disciplina de ports-and-injection
Por que todos os sete subsistemas da fusão se parecem? Porque cada um obedece a uma única disciplina: depender de portas injetadas (FsPort, Clock, adapter, idFactory), retornar Result em toda fronteira, validar entrada não confiável com Zod e nunca lançar exceção. A disciplina não é decoração — é o que torna o motor testável, determinístico e agnóstico de store (ADR-0009).
Result<T, Error> … avoid throwing in library code" + ADR-0009
Esta lição destila a regra do projeto e a vê encarnada, linha a linha, em packages/contracts/src/result.ts e em quatro kernels de packages/hermes/src. Cada número/trecho tem fonte (rodapé). Por que importa pra missão: é a disciplina que faz os subsistemas do Hermes plugarem no motor do Alembic sem reescrever nada.
- Distinguir um
Result<T, Error>de uma exceção — e explicar por que a falha vira valor no tipo. - Reconhecer uma porta injetada (
FsPort/Clock/adapter/idFactory) e por que o kernel nunca a constrói. - Aplicar a regra "Zod entra na fronteira →
Resultsai" a qualquer entrada não confiável. - Justificar a disciplina pelos três retornos: testabilidade, determinismo e agnosticismo de store (ADR-0009).
01 · O contrato: um Result que nunca lança
Antes das portas, antes da injeção, existe uma peça da qual tudo depende. Um Result é um valor que é ou sucesso ou falha — nunca uma exceção. Pense numa encomenda dos correios: ou ela chega com o pacote, ou chega com um aviso explicando por que não veio. Em nenhum caso o carteiro some no meio do caminho. É de @alembic/contracts:
// packages/contracts/src/result.ts:10-26 export interface Ok<T> { readonly ok: true; readonly value: T; } export interface Err<E> { readonly ok: false; readonly error: E; } export type Result<T, E = Error> = Ok<T> | Err<E>; export const ok = <T>(value: T): Ok<T> => ({ ok: true, value }); export const err = <E>(error: E): Err<E> => ({ ok: false, error });
O booleano ok é uma tag de união discriminada: depois de if (!r.ok) return r; o compilador sabe que o resto é r.value. Não há terceiro estado nem fluxo de controle oculto — uma falha é um valor que você tem de tratar, não uma exceção que desenrola a pilha.
O único lugar onde um try/catch cabe é bem na borda, encapsulando uma chamada que lança de volta no contrato:
// packages/contracts/src/result.ts:57-69 — "Never rejects." /** Wrap an async throwing function as a Result. Never rejects. */ export const tryCatchAsync = async <T>( fn: () => Promise<T>, onError: (cause: unknown) => Error = toError, ): Promise<Result<T, Error>> => { try { return ok(await fn()); } catch (cause) { return err(onError(cause)); } };
ok) e segue por um de dois caminhos. Como a encomenda: pacote ou aviso, nunca um sumiço.Ok<T> e Err<E> são interfaces readonly com a discriminante literal ok: true | false. A união Result<T, E = Error> faz o TypeScript estreitar (narrow) o tipo após o guard !r.ok: dentro do ramo, r.value é T; fora, é inacessível. tryCatchAsync é a única fronteira com try/catch — converte qualquer throw de fn() em err(onError(cause)), então a Promise resolve sempre, "Never rejects".Result<T, Error> … avoid throwing in library code". Se o código de biblioteca não pode lançar, um chamador nunca pode ser surpreendido por uma exceção — todo caminho de falha está no tipo. Os sete subsistemas da fusão honram isso sem exceção (no duplo sentido).try/catch a "metade pública" de um subsistema da fusão deveria ter, se ele segue a disciplina à risca? Chute antes de revelar.
try/catch de todo o stack vive dentro de tryCatchAsync, bem na borda que encapsula uma chamada que lança. Tudo acima disso devolve Result e propaga a falha como valor (if (!r.ok) return r;). Se você chutou "um por função que pode falhar", caiu na armadilha que o Result existe para eliminar: a falha não precisa de catch — ela já é parte do tipo de retorno.02 · O padrão: dependa de uma porta, não de uma concretização
Com o contrato firme, vem a segunda metade da disciplina. Uma porta é uma interface injetada — o subsistema declara do que precisa e o chamador fornece. Nenhum subsistema constrói seu próprio filesystem, relógio, modelo ou cliente de rede. É como uma tomada na parede: o aparelho não tem um gerador embutido; ele declara um plugue e você liga onde quiser — na rede elétrica de verdade, ou numa bateria de teste. Quatro costuras recorrem na fusão:
| Porta | O que abstrai | Prod vs teste |
|---|---|---|
FsPort | IO de filesystem (read/write/escrita-atômica) | impl real node:fs · fake em memória |
Clock | o tempo atual | relógio do sistema · relógio fixo/avançável |
| backend / adapter | uma chamada de modelo ou provedor de rede | fetch/ModelAdapter · um fake enlatado |
idFactory | cunhar identificadores | contador monotônico (determinístico em todo lugar) |
import fs, não chama Date.now(), não instancia um SDK); o mundo é entregue a ele, já embrulhado numa interface. Trocar o mundo (real → fake) não toca uma linha do kernel.03 · A mesma forma, quatro vezes
A prova de que isto é disciplina, e não acaso, é repetição. O mesmo formato aparece em quatro kernels diferentes do Hermes — cada um injeta o que precisa e devolve Result.
FsPort + Clock — o usage store do curador
O construtor recebe seu filesystem, o caminho do arquivo e seu relógio. Não constrói nenhum deles:
// packages/hermes/src/curator/usage-store.ts:58-63 export class UsageStore { constructor( private readonly fs: FsPort, // IO injetado — sem node:fs private readonly sidecarPath: string, // o caminho é argumento, sem home global private readonly clock: Clock, // tempo injetado — sem Date.now() ) {}
Escritas atômicas passam por FsPort.writeFileAtomic para que um crash nunca deixe um sidecar meio-escrito; leituras são best-effort (um arquivo corrompido é tratado como vazio, retornando ok, então uma chamada de telemetria de hot-path não pode quebrar o host). A assimetria é deliberada: leitura corrompida → ok(vazio), escrita falha → err.
Uma porta de backend — busca/extração web
O kernel web não importa nenhum SDK e nenhum backend concreto. Ele declara duas costuras injetadas — o provedor e um compressor opcional — ambas retornando Result:
// packages/hermes/src/web/types.ts:120-140 export interface WebBackend { search(query: WebSearchQuery): Promise<Result<readonly WebSearchResult[], Error>>; extract(url: string): Promise<Result<WebExtractResult, Error>>; } // Costura opcional de compressão LLM — encapsula UMA chamada de ModelAdapter // em prod; ausente ⇒ conteúdo bruto é retornado sem alteração. export type Compressor = ( text: string, instruction: string, ) => Promise<Result<string, Error>>;
Uma falha de rede é err; uma busca vazia é ok([]) — a diferença é preservada no tipo. Esta é exatamente a costura que o ciclo de aprendizado usa no seu ReviewProposer: "uma chamada de modelo em prod, um fake em testes".
Um idFactory — o gateway de clarify
A primitiva bloqueante de humano-no-loop precisa de ids. Ela recebe um id factory injetado, com padrão de contador monotônico — nunca Math.random() ou Date.now(), que a VM de plano do motor rejeita e que quebraria o replay:
// packages/hermes/src/clarify/gateway.ts:72-74 + 176-182 constructor(options: ClarifyGatewayOptions = {}) { this.mintId = options.idFactory ?? monotonicIdFactory(); // injetado, padrão determinístico } export const monotonicIdFactory = (prefix = 'clarify'): (() => ClarifyId) => { let n = 0; return () => { n += 1; return `${prefix}-${n}`; }; };
Node não tem thread bloqueante, então o gateway é uma promise + registro de resolvers + timeout: ask() registra uma entrada pendente sob um id cunhado, arma um setTimeout, e retorna a promise aguardada. No timeout a entrada é descartada e a promise resolve para err — nunca trava e nunca lança.
Clock e idFactory são o mesmo padrão. Ambos substituem um global não-determinístico proibido — Date.now() e Math.random() — por uma costura injetada, pelas mesmas duas razões: testabilidade e replay (a VM de plano rejeita ambos os globais). A fonte torna o elo explícito: o doc-comment da própria porta Clock (curator/types.ts:147-154) referencia monotonicIdFactory. Então a metade de determinismo desta disciplina é uma ideia com duas instâncias — injete a coisa que de outro modo tornaria uma run irrepetível.04 · A fronteira: Zod entra → Result sai
Há um terceiro fio na disciplina, e é onde as duas metades se encontram. Qualquer coisa de fora do programa — a proposta de um modelo, a resposta de clarify de uma plataforma, o JSON de um backend — é não confiável. Trate-a como a fila da imigração: ninguém entra sem ter o documento conferido. Então ela é validada com Zod na fronteira, e uma falha vira err, não uma exceção lançada:
// packages/hermes/src/clarify/gateway.ts:86-89 const parsed = clarifyQuestionSchema.safeParse(question); if (!parsed.success) { return err(new Error(`Invalid clarify question: ${parsed.error.message}`)); } // …e em learning/review.ts:83-85, a saída do proposer (modelo não confiável) é // reviewProposalSchema.safeParse'd antes de qualquer escrita. Mesma forma, toda fronteira.
err e o dado que passa já está tipado: nada não confiável circula lá dentro.05 · Refatore na mão (passo a passo → agora você)
Você viu a disciplina em quatro kernels prontos. Agora aplique-a você: pegue uma função "ingênua" (que constrói o mundo e lança) e leve-a até a forma da fusão. Recuperar o procedimento fixa mais que reler o resultado.
import fs from 'node:fs' e chama Date.now() lá dentro. São dois "mundos" embutidos — exatamente o que a porta remove.fs vira private readonly fs: FsPort; o relógio vira private readonly clock: Clock; o caminho vira um argumento sidecarPath: string — sem home global. Igual ao UsageStore (linhas 58-63).const parsed = schema.safeParse(input); if (!parsed.success) return err(...). A fila da imigração da seção 04.ok(value) ou err(error). A leitura best-effort retorna ok(vazio) num arquivo corrompido; a escrita que falha retorna err (a assimetria deliberada da seção 03).FsPort em memória + um Clock fixo. Zero IO, zero Date.now() — a transição vira reproduzível.fetch de uma busca web e throw em erro de rede. Quais portas você injeta e o que ela passa a retornar? Pense antes de revelar.
WebBackend (e, se comprimir, um Compressor opcional) — nada de fetch nem SDK no kernel. A assinatura passa a ser Promise<Result<readonly WebSearchResult[], Error>>: erro de rede → err, busca vazia → ok([]). É exatamente a porta WebBackend de web/types.ts:120-140. Dica: o procedimento é sempre o mesmo — promova os mundos a portas, valide a entrada, devolva Result.06 · Injetado vs hardcoded — o trade-off lado a lado
Toda a disciplina gira em torno de uma escolha: o kernel recebe o mundo (injetado) ou o constrói (hardcoded)? Veja o mesmo kernel nas duas formas — a tabela diz os números, o diagrama torna a diferença visível.
| Dimensão | Hardcoded (import fs, Date.now()) | Injetado (FsPort, Clock) |
|---|---|---|
| Testar sem IO/rede | ✗ impossível isolar — toca disco e relógio reais | ✓ injeta fake + clock fixo, zero IO |
| Determinismo / replay | ✗ Date.now()/Math.random() — a VM de plano rejeita | ✓ mesmas entradas → mesmas saídas |
| Trocar de backend | ✗ reescrever o kernel | ✓ trocar o argumento (ADR-0009) |
| Falha de rede | ✗ exceção que surpreende o chamador | ✓ err no tipo, falha tratável |
07 · O raio de explosão de uma troca de backend
Aqui a disciplina vira número. Quando você troca o provedor (frontier → local → fake offline), quantos arquivos mudam? Com portas, só o ponto de composição (quem injeta) muda; o kernel fica intacto. Sem portas, cada arquivo que importou o SDK direto precisa ser reescrito. Arraste para sentir o trade-off:
08 · O retorno — por que dar todo esse trabalho (ADR-0009)
As três disciplinas — Result, portas, Zod-na-fronteira — não são três regras soltas. São uma só ideia vista de três ângulos, e juntas pagam três dividendos:
- Testabilidade: injete um
FsPortfake + umClockfixo + um backend enlatado e o kernel roda com zero IO, zero rede, zero instabilidade — e zero não-determinismo deDate.now(). - Determinismo: as mesmas entradas sempre produzem as mesmas saídas (a regra de plano do motor proíbe
Date.now()/Math.random()), então as runs são reproduzíveis. - Agnosticismo de store/adapter (ADR-0009): o mesmo kernel dirige uma API de fronteira, um modelo local ou um fake offline — e o Validador real do coda pode substituir o portão padrão por injeção, sem mudar o kernel.
Confusões comuns
Result é só exceção com passos extras." A diferença é o sistema de tipos. Uma exceção é invisível na assinatura de uma função; um Result<T,Error> está bem ali, e o compilador não te deixa ler .value até você ter tratado !r.ok. A falha se torna impossível de esquecer.09 · Como isso se encaixa
Esta lição não descreve um subsistema — descreve o padrão que está debaixo de todos eles. Ports-and-injection não é uma peça da pipeline; é a regra que cada peça da pipeline obedece. Por isso ela não aparece como uma caixa no fluxo: ela é o chão sobre o qual o fluxo inteiro anda. Veja onde a disciplina toca a máquina:
Por que conecta: as portas tornam cada kernel agnóstico; a cintura é o contrato fino por onde ele pluga no motor — o complemento direto desta disciplina. Lição 09 · UsageStore + curador
Por que conecta: é o
FsPort + Clock da seção 03 visto inteiro — a assimetria ler-tolera / escrever-fail-closed em um subsistema real.
Lição 11 · webSearch / webExtractPor que conecta: a porta
WebBackend da seção 03 em ação — "uma chamada de modelo em prod, um fake em teste", err ≠ ok([]).
Lição 10 · ClarifyGatewayPor que conecta: o
idFactory injetado + Zod-na-fronteira desta lição, no humano-no-loop — promise+resolver+timeout que nunca trava nem lança.
Lição 28 · Determinismo & replayPor que conecta: colhe o dividendo —
Clock/idFactory injetados são o que torna uma run reproduzível e o replay possível.
10 · Na prática
A disciplina não é teoria: é exatamente o que deixa você provar um subsistema sem rede, sem disco e sem relógio real. O teste injeta um FsPort fake, um Clock fixo e um backend enlatado — e o kernel roda determinístico. Rode você mesmo o pacote onde os quatro kernels desta lição vivem:
# roda só o pacote @alembic/hermes — onde vivem usage-store, web, clarify, learning pnpm --filter @alembic/hermes test # … # ✓ src/curator/curator.test.ts (FsPort fake + Clock fixo → zero IO) # ✓ src/web/web.test.ts (WebBackend fake → err ≠ ok([])) # ✓ src/clarify/clarify.test.ts (idFactory monotônico → ids determinísticos) # ✓ src/learning/review.test.ts (proposta safeParse'd na fronteira) # Test Files N passed # Tests N passed ← todos com portas FALSAS, nada toca o mundo
A contagem exata de casos varia conforme o pacote evolui — o que importa é que passa sem tocar disco, rede ou relógio. É a disciplina virando prova. [uncertain] o número (N) de casos depende da versão do repo; o conjunto real aparece quando você roda o comando acima.
E a mesma disciplina é o que mantém o monorepo inteiro verde — a baseline que todo change precisa passar (CLAUDE.md):
# a baseline de build/test do projeto inteiro (CLAUDE.md) pnpm -r typecheck && pnpm -r build && pnpm -w test # typecheck: o compilador exige que você trate !r.ok antes de ler r.value # test: kernels rodam contra portas fake — determinístico, sem rede
cd na raiz do monorepo e rode pnpm --filter @alembic/hermes test. Repare: ele termina em segundos, sem abrir rede nem escrever no seu disco real — porque os testes injetam portas falsas.packages/hermes/src/curator/curator.test.ts (que exercita o UsageStore de usage-store.ts), procure onde o store é construído: ele recebe um FsPort em memória e um Clock fixo — não node:fs, não Date.now(). É a injeção da seção 02, no teste.Clock fixo por outro fixo num instante diferente, ou o FsPort fake por um que devolve um sidecar corrompido. Pergunte: a saída muda de forma previsível? O kernel ainda devolve Result (e nunca lança)? Se sim, a porta está fazendo seu trabalho.pnpm -r typecheck && pnpm -r build && pnpm -w test na raiz. O typecheck é a metade silenciosa da disciplina: ele recusa compilar se você ler r.value sem ter tratado !r.ok antes.alembic run --goal GOAL.md --plan alembic.plan.ts --yes executa uma missão cujos kernels seguem esta forma, e alembic run --goal GOAL.md --plan alembic.plan.ts --coordinated --yes liga o Validador adicional por injeção (offline, só registra) — a "troca do portão padrão" da seção 06, na linha de comando. Para fechar o ciclo determinístico, alembic replay <run-id> re-executa do events.jsonl + cache: só repete porque o Clock/idFactory são injetados (Lição 28). [uncertain] esses comandos exercitam o motor inteiro, não isolam este kernel; para isolar a disciplina, o caminho direto é o pnpm --filter @alembic/hermes test acima.Fixe os conceitos (flashcards)
Clique pra virar. Tente lembrar a resposta antes de virar — recuperação ativa fixa mais que reler.
Result<T, Error>?Ok<T> | Err<E>. A falha é um valor (err), nunca uma exceção. Após !r.ok o compilador libera r.value.FsPort/Clock em vez de construir?schema.safeParse(input) na fronteira; forma inválida → err, fail-closed. Nada não confiável circula tipado lá dentro.idFactory e Clock são o mesmo padrão?Math.random()/Date.now(), rejeitados pela VM de plano) por uma costura injetada — testabilidade + replay.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.
Result e nunca lança; o chamador trata a falha como um valor que o tipo o força a considerar. a reintroduz a exceção que o Result existe para eliminar — lançar é justo o que a regra do CLAUDE.md proíbe. c apaga a informação do erro e ainda mente no tipo (ok(null) diz "deu certo"). d trava o host num laço infinito. O único try/catch legítimo vive em tryCatchAsync, na borda, convertendo um throw em err.UsageStore recebe um Clock no construtor em vez de chamar Date.now()?reviewProposalSchema.safeParse na fronteira antes de qualquer escrita; uma proposta malformada vira err. a inverte a ordem: validar na leitura já gravou lixo no disco. b confia na origem — mas "veio do modelo" é precisamente o que torna a entrada não confiável. d usa as, um cast que mente para o compilador sem checar nada em runtime — o oposto de fail-closed.WebBackend isola a mudança no chamador que injeta; o kernel só vê a interface e não muda. b descreve o mundo hardcoded (cada import de SDK reescrito) — exatamente o raio de explosão que a porta elimina. c é mágica: a troca é uma decisão de composição, não acontece sozinha. d erra ao reescrever o kernel — se o kernel muda numa troca de backend, é sinal de que a porta não estava lá.ok e escrita falha vira err?", "Como injeto um Clock fixo num teste?", "Onde fica o ponto de composição que injeta as portas?". É só dizer.