Time to build, not just read. In this lab you write a tiny NoteStore from scratch — a bounded, file-backed store with an injected FsPort, validated at the boundary with Zod, that returns Result<T, Error> and never throws. It is the same skeleton the shipped MemoryStore and SkillStore were built on (lessons 5, 7, 12), reduced to the smallest thing that still earns the pattern. By the end you will have written every layer the engine demands of a subsystem — and a "your turn" exercise extends it.
FsPort is from @alembic/etl (packages/etl/src/fs-port.ts:61) and Result/ok/err are from @alembic/contracts. Everything compiles against the actual workspace.The engine's "Rules for safe changes" (CLAUDE.md) say a subsystem must be minimal, testable, and fail-closed. Concretely, our NoteStore must have the four properties every shipped subsystem has:
| Property | How we get it | Why |
|---|---|---|
| IO is injected | FsPort in the constructor — no import 'node:fs' | Testable in-memory; store-agnostic (invariant 2) |
| Validated at boundary | Zod safeParse on every input | Untrusted input can't corrupt state |
| Never throws | returns Result<T, Error>; wraps IO in tryCatchAsync | Failure is a value (ADR-0009) |
| Bounded | a max-entries cap, enforced before write | No unbounded growth — mirrors the memory char budget |
// notes/schema.ts
The schema is the contract for what a valid note is. We validate at the boundary because, in production, this input may come from a model or a network call — it is untrusted until parsed. This mirrors the memory store's memoryActionSchema and the learning loop's reviewProposalSchema.
// A note is a non-empty, trimmed string under 280 chars. import { z } from 'zod'; export const noteSchema = z.string().trim().min(1).max(280); export type Note = z.infer<typeof noteSchema>;
// notes/note-store.ts (top)
A mutating op returns a small terminal outcome reflecting live state — exactly like MemoryOpOutcome (memory-store.ts:71). Naming the success payload (not just returning void) is what lets the caller observe what happened without re-reading the store.
import { ok, err, tryCatchAsync, type Result } from '@alembic/contracts'; import type { FsPort } from '@alembic/etl'; import { noteSchema } from './schema.js'; export const DEFAULT_MAX_NOTES = 50; const FILENAME = 'NOTES.md'; const DELIMITER = '\n---\n'; export interface NoteOutcome { readonly message: string; readonly count: number; // live entry count after the write readonly max: number; }
fs directlyThe constructor takes the FsPort and a base directory. This is the whole trick. The class never imports node:fs; it only knows the shape of a filesystem (readText, writeFileAtomic, stat, joinPath, ensureDir). In tests you pass a Map-backed fake; in production you pass createNodeFsPort() — the class can't tell the difference, which is the point.
export class NoteStore { private entries: string[] = []; constructor( private readonly fs: FsPort, // ← injected. no `import fs`. private readonly baseDir: string, private readonly max: number = DEFAULT_MAX_NOTES, ) {} private path(): string { return this.fs.joinPath(this.baseDir, FILENAME); }
load(): read through the port, fail-closedEvery IO call is wrapped in tryCatchAsync, which turns a thrown exception (ENOENT, permission, decode error) into an err value. A missing file is not an error — it's an empty store — so we check stat first, exactly as MemoryStore.readEntries does (memory-store.ts:306).
async load(): Promise<Result<void, Error>> { const ensured = await tryCatchAsync(() => this.fs.ensureDir(this.baseDir)); if (!ensured.ok) return ensured; // short-circuit on IO failure const meta = await tryCatchAsync(() => this.fs.stat(this.path())); if (!meta.ok) return meta; if (!meta.value) { this.entries = []; return ok(undefined); } // no file ⇒ empty const read = await tryCatchAsync(() => this.fs.readText(this.path())); if (!read.ok) return read; this.entries = read.value.split(DELIMITER).map((s) => s.trim()).filter(Boolean); return ok(undefined); }
add(): validate, bound, mutate, persistThis is the heart. Four guards, in order, each returning err on failure — and only the last step touches disk. Notice the cap is checked before the write, so an over-limit add never half-mutates: state and disk stay consistent.
async add(input: unknown): Promise<Result<NoteOutcome, Error>> { const parsed = noteSchema.safeParse(input); // ① boundary Zod if (!parsed.success) return err(new Error(`Invalid note: ${parsed.error.message}`)); const note = parsed.data; if (this.entries.includes(note)) { // ② dedup (no-op success) return ok({ message: 'exists', count: this.entries.length, max: this.max }); } if (this.entries.length >= this.max) { // ③ bound BEFORE write return err(new Error(`At capacity (${this.max}). Remove a note first.`)); } this.entries.push(note); // ④ mutate, then persist const saved = await tryCatchAsync(() => this.fs.writeFileAtomic(this.path(), this.entries.join(DELIMITER))); if (!saved.ok) { this.entries.pop(); return saved; } // roll back on IO fail return ok({ message: 'added', count: this.entries.length, max: this.max }); } list(): readonly string[] { return this.entries; } }
MemoryStore pushes then saves (memory-store.ts:214-217); if the save fails, the in-memory state would lead disk. For this lab we pop() on failure so memory and disk never diverge — a small hardening you can carry back. Either way, the public method still returns a Result and never throws.FsPortBecause IO is injected, the test needs no temp directory and no real disk — a Map-backed fake satisfies the port. This is invariant 2 paying off directly: the same code is exercised in-memory.
// notes/note-store.test.ts — the fake is ~10 lines const makeFakeFs = (): FsPort => { const store = new Map<string, string>(); return { joinPath: (...p) => p.join('/'), stat: async (p) => (store.has(p) ? { size: 0, mtimeMs: 0, isFile: true, isDirectory: false } : undefined), readText: async (p) => store.get(p) ?? '', writeFileAtomic: async (p, c) => { store.set(p, c); }, ensureDir: async () => {}, // readDir / appendLine / openLineStream: unused here — stub as needed } as FsPort; }; it('rejects an empty note and never throws', async () => { const s = new NoteStore(makeFakeFs(), '/x'); expect((await s.add(' ')).ok).toBe(false); // err, not a throw });
NoteStore take an FsPort in its constructor instead of calling fs.writeFile directly?add() checks the capacity limit before pushing and persisting. Why does the order matter?MemoryStore does the same — it builds a test array and checks the joined length against the limit before committing (memory-store.ts:201-212).tryCatchAsync. What does that buy the public method's signature?tryCatchAsync from @alembic/contracts is the bridge from the throwing world (Node's fs) to the value world (Result). Without it, a disk error would escape as an exception and break the never-throws invariant (ADR-0009).remove(needle)Implement remove(needle: string): Promise<Result<NoteOutcome, Error>> that deletes the single entry containing needle as a substring. Requirements, drawn straight from the shipped MemoryStore.remove + locateUnique (memory-store.ts:261-276, 369-392):
err("No note matched …").err("Multiple notes matched … Be more specific.") — ambiguity is fail-closed, not first-wins.writeFileAtomic, return ok({message:'removed', …}).Result.Then test it with the Map-backed fake: seed two notes, remove one by a unique substring, assert list() has one left; seed two notes sharing a substring and assert the ambiguous remove returns err. That is the same shape as the real test "Multiple entries matched" in memory-store.test.ts.
renderSnapshot() that captures the entries once (frozen) and returns them unchanged even after later add() calls — the prompt-prefix-cache trick from Lesson 7. The shipped store sets this.snapshot in load() and never mutates it mid-session (memory-store.ts:132-136).