Translates ADR-022 from decision-record density into an onboarding narrative: why the policy exists, the tier trigger, the four enforcement layers, a step-by-step walkthrough, and worked approved/rejected examples (clsx pass, trpc-to-openapi named-consumer fail). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
12 KiB
Adding a library
Human reading-room guide for the library evaluation policy. For decision-record density, see ADR-022. For the agent runbook, see .claude/skills/evaluate-library/SKILL.md.
Why this exists
This repo ships with a deliberately narrow third-party surface. Every feature package carries the same six runtime dependencies — @repo/core-shared, @trpc/server, inversify, payload, reflect-metadata, zod — and nothing else. That uniformity isn't an accident; it's the result of unstated discipline that the boundary-tag system and the manifest-first ordering silently reward.
The discipline was not codified. Before ADR-022, anyone — human or agent — could run pnpm add <pkg> with no checkpoint between intent and lockfile. Three signals made the gap undeniable:
-
The 2026-05-14 OpenAPI near-miss. An exploratory session nearly added
trpc-to-openapi,zod-to-json-schema, and a build-time generator before someone asked "who calls this code path?" The honest answer was nobody — all callers were TypeScript usingcreateCaller. The library would have shipped ~30 lines of.meta({...})annotations per router and asuperjson-incompatible HTTP handler in exchange for zero consumers. Pure carrying cost, caught by a chance question, not by a system. -
Post-hoc ADRs don't prevent bad choices. ADR-002 (Inversify), ADR-014 (Sentry), and ADR-017 (OpenTelemetry) were all written after adoption. By the time the record existed, the lockfile already held the dep. No mechanism existed to catch a bad choice before it became a migration project.
-
No audit for unintended adoption.
pnpm fallowaudits dead code;pnpm conformanceaudits manifest drift;pnpm coverage:diffaudits change coverage. There was no equivalent for "did we just adopt a library nobody asked for?"
A fourth pressure: this template is EU-resident and GDPR-bound. A library that defaults to a US-only SaaS endpoint silently moves user data out of the EU the moment it's imported and configured with defaults. The old process had no point where that was caught.
ADR-022 codifies the de-facto discipline, formalises the enforcement stack, and makes rejection records first-class so future agents don't re-evaluate libraries that were already rejected for known reasons.
When the policy applies (tier trigger)
The policy maps onto the workspace boundary-tag system already enforced by ESLint and Turborepo. No new mental model required.
| Where the dep lands | Process required | Companion record |
|---|---|---|
apps/<x> (any app) |
Author's call; none | – |
devDependencies in any package |
Exempt | – |
feature package (e.g. packages/auth) |
Trace required | – |
core package (e.g. packages/core-shared) |
Trace required | ADR required |
| New optional-core category | Trace required | ADR required |
App-tier additions (Next.js, Payload CMS, TanStack Start) are at the author's discretion. Apps have clear blast-radius bounds; the conformance system doesn't govern their node_modules.
Dev-deps (linters, test runners, type-only packages) are exempt. They never run in production, never move user data, and are already bounded by the tooling packages (core-eslint, core-typescript).
Feature- and core-tier runtime deps require a trace — this is where the boundary-tag rules govern what can be imported, and where a bad choice propagates across all apps that consume the feature.
Four enforcement layers
The policy enforces at four latencies, mirroring the shape of the conformance system (ADR-012):
| Layer | Latency | What it catches |
|---|---|---|
Claude PreToolUse/PostToolUse hook |
Inline (before pnpm add runs) |
Agent skipping the skill before editing package.json or running pnpm add. Injects a <system-reminder> directing the agent to the skill — does not auto-deny, because app-tier and devdep additions are common and exempt. |
/evaluate-library skill |
Seconds | The decision itself: walks the eight filters, writes the trace file to docs/library-decisions/, and returns pass/fail. |
| Git pre-commit hook | Pre-commit | Humans or agents who ran pnpm add without invoking the skill. The hook detects a new runtime dep in a feature/core package.json without a corresponding trace file and blocks the commit. |
| Sandcastle reviewer prompt | Per-slice | Bypasses that slipped past pre-commit (e.g. direct lockfile edits). The reviewer checks for unevaluated deps before approving a slice. |
The hook is a reminder-injector, not a blocker — false positives (app-tier, devdeps) are common. The pre-commit hook is the deterministic gate for humans. The skill is the natural path both layers point to.
How to add a library
1. Check whether the policy applies
Is the dep going into a feature or core package as a runtime dependency? If yes, continue. If it's going into an apps/<x> directory or as a devDependency, you can skip the evaluation (see tier trigger above).
2. Check for an existing trace
ls docs/library-decisions/ | grep <package-name>
If a trace already exists and is marked decision: rejected, read it before proceeding. The rejection reasoning is the permanent record — if circumstances have changed (e.g. a named consumer now exists), you can re-evaluate and write a new trace. Otherwise, the existing rejection stands.
3. Invoke the evaluate-library skill
/evaluate-library <package-name> --tier <feature|core> --target <package-path>
The skill walks the eight filters in collect-cheap-skip-expensive order:
- Cheap filters (always complete, even on failure):
license,types,shadow-check,boundary-fit - Expensive filters (short-circuit on first failure):
maintenance,cve-scan,eu-residency,named-consumer
It then answers the three discussion prompts and writes a trace file to docs/library-decisions/<YYYY-MM-DD>-<package-name>.md.
4. Read the result
The skill returns approved or rejected with the filter that caused rejection (if any). The trace file is the permanent record either way.
- Approved → the trace is written; proceed with
pnpm add. - Rejected → the trace records the failure; do not add the library. If you believe the rejection was wrong, re-evaluate with new evidence rather than bypassing.
5. Add the library and commit
After an approved trace:
pnpm add <package-name> --filter <target-package>
Include the trace file in the same commit as the package.json and lockfile changes. The pre-commit hook verifies this pairing.
6. For core-tier additions: write the ADR
If the dep lands in a core package or new optional-core, write an ADR before or alongside the trace. The alternatives-considered section of the trace is duplicated into the ADR. Cite the ADR in the trace's adr: frontmatter field.
Worked example: approved (clsx)
Scenario: Adding clsx to packages/navigation for conditional class-name composition in UI components.
Tier: feature (packages/navigation is a feature package).
Evaluation summary:
| Filter | Result |
|---|---|
| license | MIT — on allowlist |
| types | Native .d.ts — pass |
| maintenance | Last release 15 months ago, active PR triage — pass |
| boundary-fit | Pure string utility, zero transitive deps, no vendor SDK — pass |
| shadow-check | No existing class-composition utility in workspace — pass |
| eu-residency | No network calls, no vendor endpoint — n/a |
| cve-scan | 0 vulnerabilities — clean |
| named-consumer | navigation-menu.tsx + mobile-nav.tsx both blocked on this — pass |
Decision: approved. Named consumer exists today (not hypothetically), migration cost is mechanical (swap back to ternaries), alternatives evaluated (classnames, inline ternaries, tailwind-merge).
Full trace: .claude/skills/evaluate-library/EXAMPLES/approved-example.md
Worked example: rejected (trpc-to-openapi)
Scenario: Exposing the tRPC router surface as a REST API for potential external consumers.
Tier: core (would land alongside the tRPC router configuration in a core package).
Evaluation summary:
| Filter | Result |
|---|---|
| license | MIT — pass |
| types | Native — pass |
| maintenance | Active — pass |
| boundary-fit | Imports @trpc/server and zod (both workspace must-haves) — pass |
| shadow-check | No existing OpenAPI generator in workspace — pass |
| eu-residency | Pure in-process, no vendor endpoint — n/a |
| cve-scan | Clean — pass |
| named-consumer | No named consumer — FAIL |
Decision: rejected. Seven of eight filters passed. The failure was named-consumer. All current API callers are TypeScript using createCaller; no HTTP REST clients exist, and no external consumers are blocked waiting for an OpenAPI spec. Speculative future partners do not count as consumers.
This trace is the permanent record. The next agent considering trpc-to-openapi finds it in ls docs/library-decisions/ in under a second and does not re-litigate. If a concrete integration is later planned, re-open the evaluation with the integration as the named consumer.
Full trace: .claude/skills/evaluate-library/EXAMPLES/rejected-trpc-to-openapi.md
Cross-links
- ADR-022 — Library evaluation policy — the authoritative decision record with full rationale, alternatives considered, and consequences
docs/library-decisions/_template.md— trace file template; the skill uses this shape automatically, but it's useful when writing or reviewing a trace manually.claude/skills/evaluate-library/SKILL.md— agent runbook; the authoritative guide for agents running the evaluation- ADR-006 — vertical feature packages (the boundary-tag system the tier trigger maps to)
- ADR-010 — Turborepo boundaries (enforcement substrate)
- ADR-017 — vendor isolation pattern (motivates the boundary-fit and EU-residency filters)
docs/glossary.md— entries for Library trace and Pre-shipped trace