# 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 `, 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/` | 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/-.md`. Always written, regardless of whether an accompanying ADR exists. Shape: ```markdown --- package: version: "" tier: app | feature | core decision: approved | rejected date: deciders: [, ...] adr: adr-NNN | null filter-results: license: types: native | "@types/" | none maintenance: active | dormant | abandoned boundary-fit: pass | fail shadow-check: pass | fail | "shadows " eu-residency: ok | n/a | self-hostable | fail cve-scan: clean | "" | fail named-consumer: pass | fail verification-commands: - --- ## Filter: ## Prompt: ``` 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 `` 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 --tier --target ``` 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. ### 9. Sub-processor discriminated union (amendment: 2026-05-18) Every trace carries two top-level frontmatter fields classifying the library from a GDPR sub-processor perspective: ```yaml is-sub-processor: false # boolean — true when the vendor receives personal data on the operator's behalf processes-pii: false # boolean — true when the library processes PII in-process (even without transmitting it) ``` When `is-sub-processor: true`, five additional fields are **required**: ```yaml data-sent: "" region: "" dpa-signed: true | false sccs-required: true | false contact: "" ``` **Discriminated-union rules:** | `is-sub-processor` | `processes-pii` | Conditional fields required? | | ------------------ | --------------- | --------------------------------------------------------------------------------- | | `false` | `false` | No — pure library, no data involvement | | `false` | `true` | No — in-process only, no vendor data flow | | `true` | `true` | Yes — all five conditional fields required | | `true` | `false` | Technically possible but very unusual; still requires all five conditional fields | **Baseline for backfill:** pure in-process libraries (no network calls to vendor-controlled endpoints) get `is-sub-processor: false` + `processes-pii: false`. Self-hosted software that stores PII but transmits nothing to the vendor (e.g. `payload`) gets `is-sub-processor: false` + `processes-pii: true`. These fields are the machine surface consumed by `scripts/emit-sub-processors.mjs` (see Story 06 of the compliance-manifests-pii-retention-subprocessors epic). A trace missing `is-sub-processor` is treated as `false` by the generator for backward-compatibility; all new traces authored after this amendment must include both fields. The `evaluate-library` skill (§7) prompts for these fields unconditionally and writes the conditional block only when `is-sub-processor: true`. The weekly `dpa-signed` staleness check in CI (ADR-023 cross-reference) should flag any `dpa-signed: true` traces where the DPA has not been revalidated within the prior 365 days. Implementation of that cron is deferred to the CI security hardening work. ## 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**