Files
agentic-dev-template/docs/decisions/adr-022-library-evaluation-policy.md
Danijel Martinek 98d96d2e19 docs(tooling): add sub-processor discriminated union to ADR-022 and traces
Amends ADR-022 §9 with the `is-sub-processor` / `processes-pii` discriminated
union spec, including the five conditional fields required when a library is a
true GDPR sub-processor. Updates the evaluate-library skill to prompt for these
fields during every trace authoring pass and adds the updated frontmatter
template. Backfills all nine existing library-decision traces with the new
fields; payload gets `processes-pii: true` (self-hosted CMS that stores user
data); all pure in-process libraries get `false / false`.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 18:44:09 +00:00

16 KiB
Raw Permalink Blame History

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:

---
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.

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:

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:

data-sent: "<what personal data the library transmits to the vendor>"
region: "<vendor data region, e.g. eu-west-1>"
dpa-signed: true | false
sccs-required: true | false
contact: "<vendor DPO or privacy contact email/URL>"

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.
  • 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