- ADR-022 codifies the tiered library-evaluation policy: 8 hard auto-reject filters (license, types, maintenance, boundary-fit, shadow-check, EU residency, CVE scan, named consumer), 3 discussion prompts, per-decision trace artifact at docs/library-decisions/, and a 4-layer enforcement stack (Claude PreToolUse/PostToolUse hook -> evaluate-library skill -> pre-commit hook -> sandcastle reviewer prompt). Mirrors the conformance-system latency pattern from ADR-012. - PRD at docs/work/prds/2026-05-14-library-evaluation-policy.prd.md seeds the implementation epic; status: approved, ready for \`pnpm work decompose\`. - Glossary gains "Library trace" + "Pre-shipped trace" entries referenced by both artifacts. Catalyst: the 2026-05-14 grill session nearly adopted trpc-to-openapi + zod-to-json-schema before someone asked who the HTTP consumer was. Honest answer: none -- all callers are TS via createCaller. This policy makes that question structurally unavoidable for any future feature- or core-tier dep.
273 lines
14 KiB
Markdown
273 lines
14 KiB
Markdown
# ADR-022 — Library evaluation policy
|
||
|
||
**Status:** Accepted
|
||
**Date:** 2026-05-14
|
||
**Related:** ADR-006 (vertical feature packages), ADR-010 (turbo boundaries), ADR-014 (Sentry observability), ADR-017 (OpenTelemetry + vendor isolation), ADR-019 (sandcastle agent orchestration), ADR-021 (release-please versioning)
|
||
**Companion guide:** `docs/guides/adding-a-library.md` (human reading-room)
|
||
**Companion skill:** `.claude/skills/evaluate-library/SKILL.md` (authoritative agent runbook)
|
||
|
||
## Context
|
||
|
||
This template ships with a deliberately narrow third-party surface. Every feature
|
||
package today 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 (ADR-006, ADR-010) and the manifest-first ordering
|
||
(ADR-012) silently reward.
|
||
|
||
The discipline is not codified. New dependencies get added by anyone — human or
|
||
agent — running `pnpm add <pkg>`, with no checkpoint between intent and lockfile.
|
||
Three recent signals show the gap:
|
||
|
||
1. An exploratory grill session on **2026-05-14** nearly added `trpc-to-openapi`
|
||
plus `zod-to-json-schema` plus a build-time generator to the repo before
|
||
stopping to ask "who calls this code path?" The honest answer was "nobody —
|
||
all callers are TypeScript via `createCaller`." The library would have shipped
|
||
~30 lines of `.meta({...})` annotations per router and a `superjson`-incompatible
|
||
HTTP handler in exchange for zero downstream consumers. Pure carrying cost,
|
||
caught by a chance question, not by a system.
|
||
2. **Three existing ADRs already record post-hoc library decisions** —
|
||
ADR-002 (Inversify), ADR-014 (Sentry), ADR-017 (OpenTelemetry). Each notes
|
||
"we picked X over Y" but the records were written after adoption. By the time
|
||
the ADR existed the lockfile already held the dep. No mechanism existed to
|
||
catch a _bad_ choice before it became a migration project.
|
||
3. The repo's automation depends on the lockfile staying small and predictable.
|
||
`pnpm fallow` audits for dead code; `pnpm conformance` audits manifest drift;
|
||
`pnpm coverage:diff` audits change coverage. There is no equivalent audit for
|
||
"did we just adopt a library nobody asked for?"
|
||
|
||
A fourth pressure comes from this template being EU-resident and GDPR-bound.
|
||
A library that defaults to a US-only SaaS endpoint (telemetry, analytics, AI,
|
||
log aggregation) silently moves user data out of the EU as soon as it's imported
|
||
and configured with defaults. The current process has no point where that gets
|
||
caught.
|
||
|
||
The decision below codifies the de-facto discipline, formalizes the agent-loop
|
||
hook that prevents drift, and makes rejection records first-class so future
|
||
agents don't re-evaluate libraries that were already rejected for known reasons.
|
||
|
||
## Decision
|
||
|
||
Adopt a **tiered library-evaluation policy** with eight hard auto-reject filters,
|
||
three discussion prompts, a per-decision trace artifact, and a four-layer
|
||
enforcement stack.
|
||
|
||
### 1. Tiered trigger (by boundary tag)
|
||
|
||
| Tier the dep lands in | Process | Companion record |
|
||
| ------------------------------------ | ------------------------ | ---------------- |
|
||
| `apps/<x>` | Author's call; no policy | – |
|
||
| `feature` (e.g. `packages/auth`) | Trace required | – |
|
||
| `core` (e.g. `packages/core-shared`) | Trace required | ADR required |
|
||
| New optional-core category | Trace required | ADR required |
|
||
|
||
The trigger maps onto the workspace-tag boundary already enforced by ESLint
|
||
(`eslint-plugin-boundaries`) and Turborepo (`turbo boundaries`). No new mental
|
||
model — the policy is a corollary of an existing one.
|
||
|
||
### 2. Eight hard auto-reject filters
|
||
|
||
Failing any single filter is an automatic reject. Trace records the failure.
|
||
|
||
1. **License allowlist.** Only `MIT`, `Apache-2.0`, `BSD-*`, `ISC`, `MPL-2.0`.
|
||
Anything else (GPL family, AGPL, CC-BY-NC, custom EULAs) is a no.
|
||
2. **TypeScript-native or `@types/*` available.** The repo is strict TS;
|
||
un-typed JS libraries shift maintenance cost to the integrating feature.
|
||
3. **Not abandoned.** Last release < 18 months **AND** PR/issue activity
|
||
< 12 months. The triple-AND avoids killing finished-but-stable libraries
|
||
(`reflect-metadata`-style).
|
||
4. **Boundary-tag fit.** A dep added to a feature package cannot require
|
||
imports the boundary rules forbid (e.g. a Sentry SDK in a feature —
|
||
ADR-017 §4 forbids this).
|
||
5. **Doesn't shadow an existing must-have.** Proposing `valibot` when `zod`
|
||
is locked, or `tsyringe` when Inversify is locked by ADR-002, is an
|
||
auto-reject; the replacement must be a separate ADR with consequences
|
||
analysis, not a parallel adoption.
|
||
6. **EU data residency for hosted/SaaS components.** If the package transmits
|
||
user data, telemetry, or business state to a vendor-controlled endpoint
|
||
by default, that vendor must offer an EU data region, and the integration
|
||
must be configured to use it. Self-hostable packages, on-device libraries,
|
||
and build-time-only tools are exempt.
|
||
7. **CVE scan clean.** `pnpm audit --audit-level=moderate` clean at adoption
|
||
time. Documented allowlist mechanism for accepted-risk advisories.
|
||
8. **Named consumer exists now, not hypothetically.** The integration must
|
||
answer "who calls this code path today, or who is blocked waiting for it?"
|
||
"Future code that might exist" is not a consumer. This filter is the
|
||
direct response to the 2026-05-14 OpenAPI near-miss.
|
||
|
||
### 3. Three discussion prompts
|
||
|
||
Filters that don't auto-reject but must be answered in the trace, either
|
||
direction acceptable with justification.
|
||
|
||
- **What does it replace?** New-and-old running in parallel is a smell.
|
||
- **Migration cost out.** What does ripping this back out look like 18 months
|
||
from now? Mechanical, hard, or impossible?
|
||
- **Alternatives considered.** Two named alternatives at minimum (or "none
|
||
with explanation"). Required for `feature`-tier; required _and_ duplicated
|
||
into the ADR for `core`-tier.
|
||
|
||
### 4. Trace artifact
|
||
|
||
Every decision — approved or rejected — emits a trace file at
|
||
`docs/library-decisions/<YYYY-MM-DD>-<package-name>.md`. Always written,
|
||
regardless of whether an accompanying ADR exists. Shape:
|
||
|
||
```markdown
|
||
---
|
||
package: <name>
|
||
version: "<semver range>"
|
||
tier: app | feature | core
|
||
decision: approved | rejected
|
||
date: <YYYY-MM-DD>
|
||
deciders: [<author>, ...]
|
||
adr: adr-NNN | null
|
||
filter-results:
|
||
license: <SPDX id>
|
||
types: native | "@types/<x>" | none
|
||
maintenance: active | dormant | abandoned
|
||
boundary-fit: pass | fail
|
||
shadow-check: pass | fail | "shadows <x>"
|
||
eu-residency: ok | n/a | self-hostable | fail
|
||
cve-scan: clean | "<advisory-id>" | fail
|
||
named-consumer: pass | fail
|
||
verification-commands:
|
||
- <literal command that produced each filter result>
|
||
---
|
||
|
||
## Filter: <name>
|
||
|
||
<prose>
|
||
|
||
## Prompt: <name>
|
||
|
||
<prose>
|
||
```
|
||
|
||
Frontmatter is the machine surface (greppable, schema-stable). Headings are
|
||
the human surface. Rejection traces are first-class — the OpenAPI scenario,
|
||
had this policy existed, would have produced a permanent record so the next
|
||
agent considering `trpc-to-openapi` finds the prior reasoning in <1s of
|
||
`ls docs/library-decisions/`.
|
||
|
||
### 5. Four-layer enforcement stack
|
||
|
||
Mirrors the latency-tiered shape of the conformance system (ADR-012).
|
||
|
||
| Layer | Latency | Catches |
|
||
| -------------------------------------- | ---------- | ---------------------------------------------------------------- |
|
||
| Claude `PreToolUse`/`PostToolUse` hook | inline | Agent skipping the skill before `pnpm add` / `package.json` edit |
|
||
| `evaluate-library` skill | seconds | The decision itself + writes the trace |
|
||
| Git pre-commit hook | pre-commit | Humans or agents bypassing the skill |
|
||
| Sandcastle reviewer prompt | per-slice | Bypasses that slipped past pre-commit |
|
||
|
||
The Claude hook injects a `<system-reminder>` directing the agent to the skill
|
||
but does **not** auto-deny (false-positive paths like dev-deps and app-tier
|
||
additions are common). The pre-commit hook is the deterministic gate.
|
||
|
||
### 6. Composition with `pnpm turbo gen core-package`
|
||
|
||
The optional-cores generator emits **pre-shipped traces** — one per direct
|
||
runtime dep of the new core — pre-marked `decision: approved` and cited
|
||
against the relevant ADR (ADR-015 for events, ADR-016 for realtime,
|
||
ADR-018 for audit). Same frozen-snapshot discipline that the optional cores
|
||
already follow (`turbo/generators/__snapshots__/core-package/`). New optional
|
||
cores added later inherit this requirement.
|
||
|
||
### 7. Skill invocation
|
||
|
||
```
|
||
/evaluate-library <package-name> --tier <feature|core|app> --target <package-path>
|
||
```
|
||
|
||
The skill walks the eight filters in **collect-cheap-skip-expensive** order:
|
||
cheap structural filters (license, types, shadow-check, boundary-fit) run to
|
||
completion regardless of failure; expensive filters (CVE scan, EU residency
|
||
probe, maintenance signals) short-circuit after the first reject. The trace
|
||
records which filters ran and which were skipped, so a partial trace is still
|
||
useful evidence.
|
||
|
||
### 8. Backfill at policy adoption
|
||
|
||
Every existing runtime dependency in feature- and core-tier packages
|
||
(~10 deps at this writing — `payload`, `inversify`, `zod`, `@trpc/server`,
|
||
`reflect-metadata`, `superjson`, `@sentry/*`, `@opentelemetry/*` family,
|
||
`socket.io`, etc.) gets a backfilled trace dated 2026-05-14 (adoption day).
|
||
ADR-002, ADR-014, ADR-017 are cited via the `adr:` frontmatter field;
|
||
verification-command output is captured at backfill time.
|
||
|
||
## Alternatives considered
|
||
|
||
- **No policy, keep relying on instinct.** Rejected. The 2026-05-14 OpenAPI
|
||
near-miss demonstrated the gap. Instinct catches some adds, misses others;
|
||
the failure mode is silent.
|
||
- **Every `package.json` change requires a trace, no tier distinction.**
|
||
Rejected. Devdeps in tooling packages and ESLint plugin bumps in apps would
|
||
drown the trace directory in noise. The boundary-tag system already
|
||
partitions blast radius — the policy reuses that partition.
|
||
- **Only "category" decisions require process** (new auth provider, new ORM,
|
||
new queue). Rejected. The OpenAPI scenario was a sub-tool inside an existing
|
||
category, not a category swap. Category-only triggers miss it.
|
||
- **A central library-evaluation service / Linear queue / Slack bot.**
|
||
Rejected. The repo is agent-first and currently single-developer (+ agents).
|
||
Human-in-the-loop services don't fit the dispatch loop; the policy must be
|
||
agent-runnable end-to-end.
|
||
- **No CVE filter — rely on `npm audit` ambient noise.** Rejected. CVE
|
||
status is point-in-time; pinning the result to the trace at adoption is the
|
||
whole value. Re-running the verification commands later detects drift.
|
||
- **Drop the named-consumer filter to a discussion prompt.** Rejected. The
|
||
filter is the one that would have stopped the OpenAPI flirtation; demoting
|
||
it back to a prompt is the same as not adding the filter.
|
||
|
||
## Consequences
|
||
|
||
**Positive**
|
||
|
||
- New dependencies require deliberate intent. The trace artifact + four-layer
|
||
enforcement stack make accidental adds detectable at four latencies, mirroring
|
||
the conformance-system pattern that already works.
|
||
- Rejection records are permanent. Future agents considering a previously
|
||
rejected library find the trace in `docs/library-decisions/` and don't
|
||
re-litigate.
|
||
- EU data residency becomes a binary, machine-readable filter result, not
|
||
an afterthought.
|
||
- Per-decision verification commands give future agents a single source of
|
||
truth they can re-run to verify the trace is still valid.
|
||
- The policy composes with `pnpm turbo gen core-package`: optional cores
|
||
remain a one-command scaffold without bypassing the rule.
|
||
|
||
**Negative**
|
||
|
||
- New feature-tier deps take longer to land. Walking eight filters + writing
|
||
the trace is ~5 minutes of agent work per addition.
|
||
- Backfilling ~10 existing deps is one-time work; expected ~half a day of
|
||
agent dispatch.
|
||
- The Claude `PreToolUse` hook adds latency to every `pnpm add` invocation.
|
||
Mitigated by the hook being a reminder-injector, not a blocker.
|
||
- The pre-commit hook adds a new failure mode ("you added a dep but forgot
|
||
the trace"). Mitigated by the skill being the natural path the hook
|
||
reminders point to.
|
||
- The CVE filter creates a maintenance obligation: when `pnpm audit` finds
|
||
a new advisory in an already-approved dep, the trace becomes stale. Acceptable
|
||
trade-off — staleness detection is exactly what `pnpm audit` already does;
|
||
the trace adds a per-dep anchor for the conversation that follows.
|
||
|
||
**Neutral**
|
||
|
||
- ADR-002, ADR-014, and ADR-017 remain authoritative for their respective
|
||
libraries. Backfilled traces cite them rather than duplicating their reasoning.
|
||
- The policy doesn't constrain transitive dependencies; `pnpm audit` and license
|
||
scanning already handle those recursively. Only direct deps require a trace.
|
||
|
||
## Related
|
||
|
||
- ADR-006 — Vertical feature packages (the tag system the trigger maps to)
|
||
- ADR-010 — Turbo boundaries (the enforcement substrate)
|
||
- ADR-012 — Feature conventions (the conformance-system shape this policy mirrors)
|
||
- ADR-017 — OpenTelemetry migration (vendor-isolation pattern the policy extends)
|
||
- ADR-019 — Sandcastle agent orchestration (reviewer-prompt layer of enforcement)
|
||
- ADR-021 — release-please versioning (where dep additions show up in release notes)
|
||
- Companion guide: `docs/guides/adding-a-library.md`
|
||
- Companion skill: `.claude/skills/evaluate-library/SKILL.md`
|
||
- Glossary: `docs/glossary.md` entries for **Library trace** and **Pre-shipped trace**
|