Course / Lesson 29  ·  PT-BR
Lesson 29 · Advanced · the recipe

Extending the fusion: the recipe for a new subsystem

You've read seven shipped subsystems and built two of your own in the labs. Now distill it into a checklist a future agent can follow to add an eighth @alembic/hermes subsystem without rediscovering the conventions. The recipe is not arbitrary taste — every step traces to an invariant or an ADR you've already met. Follow it and your subsystem will look like it was always there; skip a step and CI or a reviewer will catch you, because the rules are enforced, not suggested.

The shape every subsystem shares. A folder under packages/hermes/src/<name>/ with three files: types.ts (Zod schemas + port interfaces), the implementation (a class or functions over injected ports), and <name>.test.ts (fakes for every port). All exports are named and re-exported from src/index.ts. That's the whole template — memory, learning, clarify, web, skills, curator, and media all match it.

The recipe at a glance

StepDoBecause (invariant / ADR)
1Define ports — pass IO/time/randomness as injected interfaces (FsPort, a backend, Clock, an id factory)Invariant 2: pure kernel, injected side-effects (ADR-0009)
2Schema-validate every input with Zod safeParse at the boundaryInputs may be untrusted model/network output (ADR-0011)
3Return Result<T, Error> from every fallible function; never throw across the public boundaryThe narrow waist / never-throws (ADR-0009)
4No Date.now()/new Date()/Math.random() — inject a Clock/id factory insteadDeterminism & replay, invariant 3
5Add no new runtime dependency without justification"Rules for safe changes" (CLAUDE.md)
6Add the per-package vitest.config.ts hardening + run via test:safeAnti-orphan test safety (Lesson 25)
7Export named symbols from src/index.ts; document the CLONE/ADAPT provenance + sourcesDiscoverability + clean-room (ADR-0011 §4)
8Keep the suite green; the total test count must increase or stay flatEvery feature needs tests (CLAUDE.md)
packages/hermes/src/<name>/ types.ts — Zod + port interfaces <name>.ts — over injected ports <name>.test.ts — fakes per port the 8-step recipe (each step enforced, not suggested) ① ports ② Zod boundary ③ Result ④ no globals ⑤ no new dep ⑥ test:safe ⑦ export + docs ⑧ suite green · count flat-or-up Each step traces to an invariant or ADR — skip one and CI, the plan-VM, or a reviewer catches it. pnpm -r typecheck && pnpm -r build && pnpm -w test → the baseline that must stay green

Step 1–2 — ports and schemas first

Start with types.ts. Define what a valid input is (Zod) and what the subsystem depends on (port interfaces). The shipped subsystems do exactly this — e.g. the web subsystem's WebBackend + Compressor ports, the learning loop's ReviewProposer + ReviewGate ports. Ports are types, never concrete classes:

// packages/hermes/src/<name>/types.ts
import { z } from 'zod';

export const requestSchema = z.object({ /* … bounded fields … */ });
export type Request = z.infer<typeof requestSchema>;

// A port = an injected capability, never a concrete import:
export type Backend = (req: Request) =>
  Promise<import('@alembic/contracts').Result<Response, Error>>;   // never throws

Step 3–4 — Result everywhere, no globals

The implementation file is functions or a class over those ports. Every fallible path returns Result; IO is wrapped in tryCatchAsync; time/randomness come from an injected Clock/id factory, never a global (Lesson 28). This is the exact skeleton from Lab 1 — it's not a toy, it's the production pattern at minimum size.

// packages/hermes/src/<name>/<name>.ts
import { ok, err, tryCatchAsync, type Result } from '@alembic/contracts';
import { requestSchema, type Backend } from './types.js';

export const doThing = async (
  input: unknown, deps: { backend: Backend; now: () => number },
): Promise<Result<Response, Error>> => {
  const parsed = requestSchema.safeParse(input);          // ② boundary Zod
  if (!parsed.success) return err(new Error(parsed.error.message));
  return deps.backend(parsed.data);                        // ③ Result through, ④ no globals
};

Step 5 — the dependency question

Before adding a package, ask: can a global already in the runtime do it? The web and media subsystems answered yes — they use global fetch via a thin createFetchBackend rather than adding an HTTP client (Lesson 11). The skills subsystem wrote its own dependency-free scalar frontmatter parser rather than pulling YAML (Lesson 12). The rule: "Do not add new dependencies without justification." A new dep is a supply-chain surface and a clean-room risk — earn it or avoid it.

Step 6 — wire the test safety

Each package carries its own vitest.config.ts (the hermes package has one). Match the hardened root config — bounded timeouts and pool:'forks' — and always run the suite through the process-group wrapper, never bare vitest:

# run the whole suite safely (Lesson 25):
pnpm test:safe            # bounded run in its own group → kill the group + sweep
# or one package:
pnpm --filter @alembic/hermes test
Why per-package config matters. A subsystem that opens a socket or starts a timer in a test must fail on a bounded timeout, not hang a worker forever. Inheriting the testTimeout/teardownTimeout/forks hardening (Lesson 25) is part of the contract — the test-safety wrapper is the floor, the config is the first line.

Step 7–8 — export, document, verify

Re-export every public symbol from src/index.ts with a header comment naming the CLONE/ADAPT/IGNORE provenance and the source map sections (read any subsystem's block in index.ts — they all do this). Then run the baseline and confirm the count moved the right way:

# the build/test baseline every change must keep green (CLAUDE.md):
pnpm -r typecheck && pnpm -r build && pnpm -w test

The discipline closes here: "Every new feature needs tests; total test count should increase or stay flat." A subsystem with no tests, or one that drops the count, is not done.

The copy-paste checklist
1. You need an HTTP call in a new subsystem. What does the recipe say to do first?
Correct: b. The web and media subsystems use global fetch via a thin backend — no HTTP dependency. And the backend is injected, so tests pass a fake instead of hitting the network. "Do not add new dependencies without justification" (CLAUDE.md).
2. Your subsystem's lifecycle decision depends on the current time. The recipe requires:
Correct: d. Step 4 mirrors the curator: time is a side effect, injected like any other. A Clock port makes the lifecycle deterministic in test and replay-safe in production (Lesson 28), and keeps the code uniform with the rest of the package.
3. Why does the recipe insist the total test count "increase or stay flat" and the suite run via test:safe?
Correct: c. The count rule enforces that every new behavior is covered; test:safe (Lesson 25) guarantees a hung test can't pin CPU for hours. The recipe bakes both into the definition of "done," so a future agent can't quietly skip either.

Common confusions

"Ports are over-engineering for a small subsystem." The labs proved the opposite — the port skeleton is the minimum that earns testability, determinism, and store-agnosticism at once. A direct node:fs call saves three lines and costs you in-memory tests, deterministic replay, and a swappable backend. The pattern scales down cleanly.
"The recipe is just convention; I can deviate." Most of it is enforced: the plan VM rejects Date.now(), CI runs the never-throws-shaped types, the test wrapper kills orphans, and a reviewer checks provenance. Deviation isn't a style debate — it's a failing build or a rejected change.