Files
agentic-dev-template/docs/guides/adding-a-library.md
Danijel Martinek 603104ca97 docs(guides): add adding-a-library.md human reading-room guide
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>
2026-05-14 05:52:08 +00:00

171 lines
12 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Adding a library
Human reading-room guide for the library evaluation policy. For decision-record density, see [ADR-022](../decisions/adr-022-library-evaluation-policy.md). For the agent runbook, see [`.claude/skills/evaluate-library/SKILL.md`](../../.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:
1. **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 using `createCaller`. The library would have shipped ~30 lines of `.meta({...})` annotations per router and a `superjson`-incompatible HTTP handler in exchange for zero consumers. Pure carrying cost, caught by a chance question, not by a system.
2. **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.
3. **No audit for unintended adoption.** `pnpm fallow` audits dead code; `pnpm conformance` audits manifest drift; `pnpm coverage:diff` audits 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](#when-the-policy-applies-tier-trigger) above).
### 2. Check for an existing trace
```bash
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:
```bash
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`](../../.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`](../../.claude/skills/evaluate-library/EXAMPLES/rejected-trpc-to-openapi.md)
---
## Cross-links
- [ADR-022 — Library evaluation policy](../decisions/adr-022-library-evaluation-policy.md) — the authoritative decision record with full rationale, alternatives considered, and consequences
- [`docs/library-decisions/_template.md`](../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`](../../.claude/skills/evaluate-library/SKILL.md) — agent runbook; the authoritative guide for agents running the evaluation
- [ADR-006](../decisions/adr-006-vertical-feature-packages.md) — vertical feature packages (the boundary-tag system the tier trigger maps to)
- [ADR-010](../decisions/adr-010-turbo-boundaries.md) — Turborepo boundaries (enforcement substrate)
- [ADR-017](../decisions/adr-017-opentelemetry-vendor-isolation.md) — vendor isolation pattern (motivates the boundary-fit and EU-residency filters)
- [`docs/glossary.md`](../glossary.md) — entries for **Library trace** and **Pre-shipped trace**