From 603104ca9731e8cad0f7f56f7a35720d07fc5e67 Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Thu, 14 May 2026 05:52:08 +0000 Subject: [PATCH] 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 --- docs/guides/adding-a-library.md | 170 ++++++++++++++++++++++++++++++++ 1 file changed, 170 insertions(+) create mode 100644 docs/guides/adding-a-library.md diff --git a/docs/guides/adding-a-library.md b/docs/guides/adding-a-library.md new file mode 100644 index 0000000..61b7124 --- /dev/null +++ b/docs/guides/adding-a-library.md @@ -0,0 +1,170 @@ +# 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 ` 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/` (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 `` 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/` 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 +``` + +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 --tier --target +``` + +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/-.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 --filter +``` + +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**