Course / Lesson 5  ·  PT-BR
Lesson 05 · The discipline beneath everything

Ports & Injection

Why do all seven fusion subsystems look the same? Because each obeys one discipline: depend on injected ports, return Result at every boundary, validate untrusted input with Zod, and never throw. The discipline isn't decoration — it's what makes the engine testable, deterministic, and store-agnostic (ADR-0009).

The contract: a Result that never throws

Everything rests on a tiny type from @alembic/contracts. A Result is a value that is either a success or a failure — never an exception:

// 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 });

The ok boolean is a discriminated-union tag: after if (!r.ok) return r; the compiler knows the rest is r.value. There is no third state and no hidden control flow — a failure is a value you must handle, not an exception that unwinds the stack.

The only place a try/catch belongs is at the very edge, wrapping a throwing call back into the contract:

// 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)); }
};
Why this matters. The project rule (CLAUDE.md): "fail-closed Result<T, Error> … avoid throwing in library code." If library code can't throw, a caller can never be surprised by an exception — every failure path is in the type. The fusion subsystems honor this without exception.

The pattern: depend on a port, not a concretion

A port is an injected interface — the subsystem declares what it needs and the caller supplies it. No subsystem constructs its own filesystem, clock, model, or network client. Four seams recur across the fusion:

PortWhat it abstractsProd vs test
FsPortfilesystem IO (read/write/atomic-write)real node:fs impl · in-memory fake
Clockthe current timesystem clock · a fixed/advanceable clock
backend / adaptera model call or network providerfetch/ModelAdapter · a canned fake
idFactoryminting identifiersmonotonic counter (deterministic everywhere)
subsystem kernel pure logic · returns Result FsPort Clock idFactory WebBackend Compressor the kernel imports no fs, no clock, no SDK — only the ports above

The same shape, five times

FsPort + Clock — the curator's usage store

The constructor takes its filesystem, its file path, and its clock. It builds none of them:

// packages/hermes/src/curator/usage-store.ts:58-63
export class UsageStore {
  constructor(
    private readonly fs: FsPort,          // injected IO — no node:fs
    private readonly sidecarPath: string,  // path is an argument, no global home
    private readonly clock: Clock,        // injected time — no Date.now()
  ) {}

Atomic writes go through FsPort.writeFileAtomic so a crash never leaves a half-written sidecar; reads are best-effort (a corrupt file is treated as empty, returning ok, so a hot-path telemetry call can't break the host). The asymmetry is deliberate: corrupt read → ok(empty), failed write → err.

A backend port — web search/extract

The web kernel imports no SDK and no concrete backend. It declares two injected seams — the provider and an optional compressor — both returning 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>>;
}
// Optional LLM-compression seam — wraps ONE ModelAdapter call in prod;
// absent ⇒ raw content is returned unchanged.
export type Compressor = (
  text: string, instruction: string,
) => Promise<Result<string, Error>>;

A network failure is err; an empty search is ok([]) — the difference is preserved in the type. This is exactly the seam the learning loop uses for its ReviewProposer: "a model call in prod, a fake in tests."

An idFactory — the clarify gateway

The blocking human-in-the-loop primitive needs ids. It takes an injected id factory, defaulting to a monotonic counter — never Math.random() or Date.now(), which the engine's plan VM rejects and which would break replay:

// packages/hermes/src/clarify/gateway.ts:72-74 + 176-182
constructor(options: ClarifyGatewayOptions = {}) {
  this.mintId = options.idFactory ?? monotonicIdFactory();  // injected, deterministic default
}
export const monotonicIdFactory = (prefix = 'clarify'): (() => ClarifyId) => {
  let n = 0;
  return () => { n += 1; return `${prefix}-${n}`; };
};

Node has no blocking thread, so the gateway is a promise + resolver registry + timeout: ask() registers a pending entry under a minted id, arms a setTimeout, and returns the awaited promise. On timeout the entry is dropped and the promise resolves to err — it never hangs and never throws.

Clock and idFactory are the same pattern. Both replace a forbidden non-deterministic global — Date.now() and Math.random() — with an injected seam, for the same two reasons: testability and replay (the plan VM rejects both globals). The source makes the link explicit: the Clock port's own doc-comment (curator/types.ts:147-154) cross-references monotonicIdFactory. So the determinism half of this discipline is one idea with two instances — inject the thing that would otherwise make a run unrepeatable.

Zod at the boundary — every untrusted input

Anything from outside the program — a model's proposal, a platform's clarify response, a backend's JSON — is untrusted, so it's validated with Zod at the boundary and a failure becomes err, not a thrown exception:

// 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}`));
}
// …and in learning/review.ts:83-85, proposer output (untrusted model output) is
// reviewProposalSchema.safeParse'd before any write. Same shape, every boundary.
The payoff — why bother with all this
1. A library function hits a network error. What does the never-throws contract require it to do?
Correct: b. Library code returns Result and never throws. The only try/catch is inside tryCatchAsync at the very edge, which converts a throw into err. The caller handles the failure as a value the type forces them to consider.
2. Why does UsageStore take a Clock in its constructor instead of calling Date.now()?
Correct: d. Injecting time means a test can pin "now," advance it past a cutoff, and assert the exact transition — no sleeping, no flakiness. The curator and the usage store share the same clock so "recorded now" and "decided now" agree.
3. A model proposal arrives at the learning loop. Before any write, what happens to it?
Correct: c. In production a proposal is untrusted model output, so it's validated with Zod at the boundary (reviewProposalSchema.safeParse) before any write. A malformed proposal becomes err — never a thrown exception, never an unchecked cast.

Common confusions

"Ports are just interfaces — over-engineering." They're load-bearing here: the same kernel must run against a real API in production and a fake in a test with no network. Without the seam you can't test the logic in isolation, and you can't swap the default gate for the coda Validator without rewriting the kernel.
"Result is just exceptions with extra steps." The difference is the type system. An exception is invisible in a function's signature; a Result<T,Error> is right there, and the compiler won't let you read .value until you've handled !r.ok. Failure becomes impossible to forget.