ADR-003 — No runtime dependencies in the core
zod costs 23.6 ms to import — 15% of the startup budget to validate a seven-field object.
Date: 2026-09-02 · Status: accepted · Scope: stack
Context
After ADR-001, 160 ms is left for everything the CLI does. The cost of merely importing each candidate was measured (best of 3, Node v25.8.2, unbundled):
| Package | Import | Size |
|---|---|---|
zod 4.5.4 | 23.6 ms | 5.8 MB |
commander 15.0.0 | 17.1 ms | 207 KB |
yaml 2.9.0 | 13.5 ms | 686 KB |
gray-matter 4.0.3 | 13.0 ms | 39 KB |
ulidx 2.4.1 | 11.5 ms | 66 KB |
ulid 3.0.2 | 10.1 ms | 69 KB |
citty 0.2.2 | 8.3 ms | 35 KB |
picocolors 1.1.1 | 7.7 ms | 6 KB |
valibot 1.4.2 | 5.0 ms | 1.8 MB |
cac 7.0.0 | 3.3 ms | 41 KB |
zod alone costs 15% of the entire budget — to validate an object with seven
fields.
So the alternative was measured too:
| Hand-rolled | Import cost | Throughput | Correctness |
|---|---|---|---|
| ULID (26 chars, Crockford base32) | 0 ms | 100k in 132 ms | sorts by time; 50k unique, no collisions |
| Event validation (7 fields, type guards) | 0 ms | 100k in 17.5 ms | catches all 6 classes of error |
Both are about 30 lines.
Decision
The core has no runtime dependency at all. ULID and event validation are hand-rolled and under test.
This is not "not invented here". The argument is measurable and singular: general validation libraries exist for schemas described at runtime that change. Our event shape is fixed in types at compile time, and the check is needed at exactly one boundary — reading a file a person or a merge could have corrupted. For that, a 5.8 MB engine gives nothing a 20-line function does not, and costs a fifth of the startup budget.
ULID was chosen over UUIDv4 for a separate reason: the first 10 characters are a timestamp in Crockford base32, so sorting ids lexicographically matches sorting them chronologically. Event order becomes a property of the id rather than of how far apart machine clocks have drifted.
Consequences
- About 34 ms of budget saved;
npm audithas nothing to report on the core; the supply chain has zero links. - ~60 lines of cryptographically sensitive (ULID uses
node:crypto) and validation code that we must test ourselves — with property tests, not just examples. - What would make us revisit it: if the event schema becomes extensible by
plugins with their own fields, fixed validation stops working, and
valibotbecomes the cheapest engine to fall back to.