Curso / Lição 5  ·  EN
Lição 05 · A disciplina sob tudo

Portas & Injeção

Por que todos os sete subsistemas da fusão se parecem? Porque cada um obedece a uma disciplina: depender de portas injetadas, 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).

O contrato: um Result que nunca lança

Tudo se apoia num tipo minúsculo de @alembic/contracts. Um Result é um valor que é ou sucesso ou falha — nunca uma exceção:

// 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: após 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ê deve 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."
/** Encapsula uma função async que lança como Result. Nunca rejeita. */
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)); }
};
Por que isso importa. A regra do projeto (CLAUDE.md): "fail-closed Result<T, Error> … evitar lançar em código de biblioteca". 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 subsistemas da fusão honram isso sem exceção.

O padrão: dependa de uma porta, não de uma concretização

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. Quatro costuras recorrem na fusão:

PortaO que abstraiProd vs teste
FsPortIO de filesystem (read/write/escrita-atômica)impl real node:fs · fake em memória
Clocko tempo atualrelógio do sistema · relógio fixo/avançável
backend / adapteruma chamada de modelo ou provedor de redefetch/ModelAdapter · um fake enlatado
idFactorycunhar identificadorescontador monotônico (determinístico em todo lugar)
kernel do subsistema lógica pura · retorna Result FsPort Clock idFactory WebBackend Compressor o kernel não importa fs, nem clock, nem SDK — só as portas acima

A mesma forma, cinco vezes

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.

Zod na fronteira — toda entrada não confiável

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, então é 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.
O retorno — por que dar todo esse trabalho
1. Uma função de biblioteca encontra um erro de rede. O que o contrato nunca-lança exige que ela faça?
Correto: b. Código de biblioteca retorna Result e nunca lança. O único try/catch está dentro de tryCatchAsync bem na borda, que converte um throw em err. O chamador trata a falha como um valor que o tipo o força a considerar.
2. Por que o UsageStore recebe um Clock no construtor em vez de chamar Date.now()?
Correto: d. Injetar o tempo significa que um teste pode fixar o "agora", avançá-lo além de um corte e afirmar a transição exata — sem dormir, sem instabilidade. O curador e o usage store compartilham o mesmo relógio para que "registrado agora" e "decidido agora" concordem.
3. Uma proposta de modelo chega ao ciclo de aprendizado. Antes de qualquer escrita, o que acontece com ela?
Correto: c. Em produção uma proposta é saída de modelo não confiável, então é validada com Zod na fronteira (reviewProposalSchema.safeParse) antes de qualquer escrita. Uma proposta malformada vira err — nunca uma exceção lançada, nunca um cast não verificado.

Confusões comuns

"Portas são só interfaces — over-engineering." Aqui elas carregam peso: o mesmo kernel deve rodar contra uma API real em produção e um fake num teste sem rede. Sem a costura você não consegue testar a lógica isolada, e não consegue trocar o portão padrão pelo Validador do coda sem reescrever o kernel.
"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.