docs(work): archive shipped template epics and PRDs

Move the 8 shipped template epics and their 9 PRDs (incl.
coverage-architecture) to docs/work/archive/{epics,prds}/ so dispatch
context and prioritization only see live Veect work. The state builder
already walks docs/work/epics/ + docs/work/prds/ only; the one work-CLI
script that matched archive paths (bump-updated-timestamps.mjs, staged
docs/work/**/*.md) now excludes docs/work/archive/ so archived content
stays byte-identical.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016j8z4VHjedXDTjEDNg7qHK
This commit is contained in:
2026-07-12 17:28:21 +02:00
parent 2126fda6f8
commit 2b39ae8c0a
86 changed files with 5 additions and 757 deletions

View File

@@ -1,207 +0,0 @@
---
id: binder-wrap-helper
title: Collapse binder duplication via wireUseCase helper
type: prd
status: approved
author: danijel
elicitation-session: improve-codebase-architecture-2026-05-13
created: 2026-05-13T00:00:00Z
updated: 2026-05-14T19:16:52.691Z
---
## Problem
Every feature's `bind-production.ts` and `bind-dev-seed.ts` independently inline the same `withSpan(tracer, opts, withCapture(logger, tags, factory(deps)))` wrapping for each use case. Concretely:
- Five of `pnpm fallow`'s top-ten clone groups come from binder pairs (auth, blog, media, marketing-pages, navigation).
- Per binder pair the duplication runs 3079 lines.
- Adding a use case means editing two parallel blocks (production + dev-seed); forgetting one is silent drift not caught until `pnpm dev` boots (`assertFeatureConformance` fires) or coverage gates fail.
- The wrapping shape is structurally identical across features and modes: span options, capture tags, and the factory call are the only things that vary per use case.
The cost is paid at three sites:
1. **Authoring** — every new use case requires writing ~12 lines of mechanical boilerplate, twice.
2. **Reading** — every diff that touches a binder is dominated by repetitive shape; signal-to-noise is poor.
3. **Refactoring** — changes to the wrapping shape (e.g., adding a new brand, adjusting span attributes) require N × 2 simultaneous edits across the workspace.
The wrapping itself is correct and stays at DI bind time per ADR-014 / ADR-017. What's missing is the abstraction: a single point of composition that the binders call instead of inline.
## Goal
Introduce a `wireUseCase(...)` helper in `@repo/core-shared/conformance/` that encapsulates the `withSpan + withCapture (+ optional withAudit)` composition. Refactor all five features' binders to call the helper. The wrapping shape becomes a single source of truth; per-feature binders shrink to their decision content (which adapter to bind for which mode) plus a list of `wireUseCase` calls.
## In scope
- New module: `@repo/core-shared/conformance/wire-use-case.ts` exporting the helper + its types.
- Tests: a colocated `wire-use-case.test.ts` covering the helper's behaviour (span composition, capture composition, audit composition when applicable, brand attachment).
- Migration: all five features' `bind-production.ts` and `bind-dev-seed.ts` files (10 files) updated to call `wireUseCase` instead of inlining the wrappers.
- Existing binder-level tests must continue to pass (smoke-level coverage for the per-feature wiring).
- The `assertFeatureConformance` boot-time assertion (already at the tail of each binder) continues to fire on drift — the helper does not bypass it.
- Generator update: `pnpm turbo gen feature` template emits `wireUseCase` calls in the scaffolded binders, not the longhand inline form.
## Out of scope
- **Sub-shape (b)** (pre-wired factory exports at the use-case file level) — explicitly rejected during the architecture grilling that produced this PRD. Helper-inside-binder (sub-shape (a)) keeps the wrapping a binder concern, which preserves ADR-008's per-feature DI isolation.
- **Hoisting brand attachment out of `withSpan` / `withCapture` / `withAudit`** — the helper composes these existing wrappers; it does not replace them.
- **Changing the manifest schema** — manifests stay as they are. The helper reads what the binder passes; it does not consult the manifest directly.
- **Apps' `bindAll()` dispatcher** — the `apps/web-next/src/server/bind-production.ts` (and equivalent for tanstack/cms) stays untouched. It calls per-feature binders, which is unchanged.
- **Repository/service binding patterns** — only use-case bindings (the wrapped factories) move through the helper. Repository and service bindings stay as direct `.toConstantValue()` calls.
- **Controller bindings** — covered by the helper too (controllers are also wrapped the same way per ADR-013), but if scope creep is a concern, controllers can land in a follow-up.
## Constraints
- **ADR-008** — per-feature DI containers. The helper takes the container as a parameter; it does NOT introduce a global registry.
- **ADR-012, ADR-013** — factory-function use cases + controllers. The helper consumes factories of shape `(deps) => async (input) => output`; it does NOT change the factory shape.
- **ADR-014, ADR-017** — instrumentation interfaces (`ITracer`, `ILogger`) + OTel substrate. The helper composes the existing `withSpan` / `withCapture` wrappers; it does NOT import `@opentelemetry/sdk-*` or `@sentry/*` directly (rule R52 enforces this; the helper lives in `core-shared/conformance/`, not `core-shared/instrumentation/otel/`).
- **ADR-018** — audit logging. The helper composes `withAudit` when an audit emitter is provided in the call.
- **TS brand-slot enforcement** — `Instrumented` / `Captured` / `Audited` brands must remain present at the bind-time return value. The helper's return type carries the full brand stack so `assertFeatureConformance` continues to find the brands at boot.
- **Conformance ESLint rules** — the five rules in `core-eslint/rules/` (manifest must have a file, use case must have a test, etc.) keep firing unchanged. The helper does not interact with ESLint.
- **`pnpm fallow` clone-group baseline** — after the migration, the five top-ten clone groups should disappear. The PR's coverage check + fallow audit verify this.
- **L0 coverage** — `core-shared/conformance/wire-use-case.ts` lives in `core-shared`, not a feature, so its L0 band is `core-shared`'s vitest config's defaults (80/75/80/80 baseline). The new file is small enough that 100% is achievable; aim for 100%.
- **Generator-first** — the feature generator template must emit the new call shape; the legacy inline form is no longer emitted.
- **Hybrid versioning (ADR-021)** — this PR touches `core-shared` (bumps the root template version) and all five feature packages (bumps each feature's package version). The release-please rolling PR will reflect that.
## Success criteria
- `wireUseCase` lives at `packages/core-shared/src/conformance/wire-use-case.ts` and is exported from `@repo/core-shared/conformance`.
- A colocated `wire-use-case.test.ts` covers: span composition (Instrumented brand attached), capture composition (Captured brand attached), audit composition (Audited brand attached when audit emitter passed), and the no-audit branch.
- All ten binder files (auth × 2, blog × 2, media × 2, marketing-pages × 2, navigation × 2) call `wireUseCase` for their use cases (and controllers, if covered in this PRD).
- `pnpm typecheck && pnpm test && pnpm lint && pnpm conformance && pnpm fallow:audit` green.
- `pnpm test -- --coverage` green; per-feature L0 bands hold (100% on entities/use-cases/controllers).
- `pnpm coverage:diff` reports `pass` against `origin/main`.
- `pnpm fallow dupes` shows the five binder-pair clone groups have disappeared (down from the current 5-in-top-10 footprint).
- `pnpm dev` boots — `assertFeatureConformance` accepts every wired use case (brands present, manifest entries match).
## User stories
1. As a **feature author**, I want adding a use case to be a single-line binder edit (one `wireUseCase` call per mode) instead of a ~12-line inline wrapper block, so that I don't pay boilerplate cost for mechanical work.
2. As a **future architect**, I want the wrapping shape (`withSpan` + `withCapture` + optional `withAudit`) to live in one place, so that adjusting it across the workspace is a single-file change.
3. As an **AI implementer agent**, I want the generator to emit the helper-based shape, so that scaffolded features ship with the consolidated wrapping by default and don't drift back to inline form.
4. As an **AI reviewer agent**, I want the binders to read as decision content (which adapter, which mode) rather than mechanical boilerplate, so that diff review surfaces real changes.
5. As a **maintainer of `core-shared`**, I want the helper's behaviour fully unit-tested in isolation, so that changes to the wrapping composition are caught at `pnpm test` rather than at every feature's downstream binder test.
6. As a **CI consumer**, I want `pnpm fallow dupes` to no longer flag the five binder-pair clone groups, so that fallow output's signal-to-noise improves.
7. As a **template adopter**, I want `pnpm turbo gen feature` to scaffold binders that use the helper, so that new features participate in the consolidated wrapping pattern from the first commit.
8. As an **on-call engineer**, I want `assertFeatureConformance` to keep its current "missing brand fails boot" semantics, so that the refactor doesn't weaken the boot-time conformance gate.
## Implementation decisions
### The helper's call shape
The helper takes an options object covering everything the inline form expresses today:
- the DI container (so the helper performs the `bind(...).toConstantValue(...)` step)
- the DI symbol
- the use-case factory function + its deps tuple
- the feature name, layer, and use-case name (used to derive both `span.name` + capture tags)
- the tracer + logger (always required)
- the auditLog + audit input schema (optional — only when the manifest declares audits for this use case)
A second variant (or a `wireController` peer) covers controllers, which share the wrapping shape but have a different brand identity per ADR-013.
The helper returns the brand-stacked wired value so callers can hold a reference (e.g., for tests that bypass the container).
Exact API surface (signatures, generic parameters, the deps-tuple typing strategy, whether one entry point covers both use cases and controllers or two peer helpers do) lands during the implementation TDD cycle. The architecture grilling fixed the _shape_, not the exact signature.
### Where the helper lives
`packages/core-shared/src/conformance/wire-use-case.ts`, alongside the existing `define-feature.ts`, `coverage.ts`, `assert-bindings.ts`. Exported through `@repo/core-shared/conformance`. NOT in `core-shared/instrumentation/` (the helper composes interfaces from there but doesn't depend on the OTel SDK; placing it in `instrumentation/` would muddy the vendor-isolation boundary).
### Brand attachment
Brands continue to attach inside `withSpan` / `withCapture` / `withAudit` (the existing wrappers). The helper just orders the composition correctly (`withSpan` outermost per ADR-014). No brand-attachment logic moves into the helper.
### Migration shape per binder file
Inline blocks like:
```ts
const wrappedSignIn: ProductionUseCase<
SignInInput,
SignInOutput,
AuthManifest["useCases"]["signIn"]
> = withSpan(
tracer,
{ name: "auth.signIn", op: "use-case" },
withCapture(
logger,
{ feature: "auth", layer: "use-case", name: "auth.signIn" },
signInUseCase(repo, authService),
),
);
authContainer
.bind<ISignInUseCase>(AUTH_SYMBOLS.ISignInUseCase)
.toConstantValue(wrappedSignIn);
```
become:
```ts
wireUseCase({
container: authContainer,
symbol: AUTH_SYMBOLS.ISignInUseCase,
factory: signInUseCase,
deps: [repo, authService],
feature: "auth",
layer: "use-case",
name: "signIn",
tracer,
logger,
});
```
Span name (`auth.signIn`) is derived from `feature + "." + name`. Capture tags are derived from `feature + layer + name`. The container `unbind` + `bind` pattern (already idempotent per ADR-008) is encapsulated.
### Audit-bearing use cases
When the manifest declares `audits: [...]` for a use case, the binder passes `auditLog` + the audit schema into `wireUseCase`. The helper composes `withAudit` inside `withCapture`. When `audits: []`, the audit composition is skipped (no Audited brand attached, no audit emission).
### Generator template
`turbo/generators/templates/feature/src/di/bind-production.ts.hbs` and `bind-dev-seed.ts.hbs` (assuming they exist; if not, equivalent files) get updated to emit the `wireUseCase` call shape. The anchor `<gen:use-cases>` continues to mark the injection point.
### Per-feature impact
| Feature | Use cases to migrate | Audit-bearing? |
| --------------- | ------------------------------------------------ | -------------- |
| auth | 3 (signIn, signUp, signOut) | check manifest |
| blog | 3 (getArticles, getArticleBySlug, createArticle) | check manifest |
| media | 3 (getMedia, listMedia, deleteMedia) | check manifest |
| marketing-pages | 2 (getPageBySlug, getSiteSettings) | check manifest |
| navigation | 1 (getHeader) | check manifest |
Total: 12 use cases × 2 modes = 24 inline wrap sites to migrate.
## Testing decisions
- **What "good test" means here**: tests assert on observable outcomes at the helper's interface (the wired value's behaviour when called, the brands attached, the container's binding shape) — not on internal composition order.
- **Helper-level unit tests** in `wire-use-case.test.ts`:
- Wiring without audit composes `withSpan(withCapture(factory))`; both Instrumented + Captured brands present.
- Wiring with audit composes `withSpan(withCapture(withAudit(factory)))`; all three brands present.
- Span name derivation: `<feature>.<name>` for use cases; check the convention matches existing behaviour.
- Capture tags: `{ feature, layer, name }` correctly applied.
- Container binding: symbol → wired value; idempotent re-bind (unbind + bind) when called twice.
- Brand presence asserted via the existing `isInstrumented` / `isCaptured` / `isAudited` runtime helpers from `core-shared/conformance/brand-runtime.ts`.
- **Per-feature binder tests**: should continue to pass without modification. If they assert exact shape of the wrapping internals (rather than observable behaviour), refactor them to assert through the helper's contract.
- **Integration tests**: existing `feature.test.ts` and `*-flow.feature.test.ts` files (e.g., `packages/auth/tests/sign-in-flow.feature.test.ts`) must continue to pass — they exercise the wired chain end-to-end.
- **Coverage**: `wire-use-case.ts` should hit 100% on entities/use-cases/controllers band equivalents (it lives in core-shared, baseline 80/75/80/80, but the file is small and a single helper should be exhaustively tested).
- **Prior art**: the existing wrappers' tests in `core-shared/instrumentation/` and `core-audit/with-audit.test.ts` are the closest pattern to mirror for the new helper's test file.
## Open questions
- **Q1: One helper or two (use cases + controllers)?** Use cases and controllers share the wrapping shape but the brand identity differs. **Recommended:** start with one `wireUseCase` that takes a `layer: "use-case" | "controller"` discriminator; if the controller path diverges meaningfully during implementation, split into `wireController` then.
- **Q2: Should the helper consult the manifest directly to decide whether to apply `withAudit`?** The manifest declares `audits: [...]` per use case. The binder could pass this in, OR the helper could read the manifest. **Recommended:** binder passes — keeps the helper a pure composition concern; avoids the helper depending on the manifest schema.
- **Q3: Does the helper handle the `unbind + bind` idempotency, or do callers?** **Recommended:** helper handles it. Every existing binder already does `if (container.isBound(sym)) container.unbind(sym); container.bind(sym).toConstantValue(...);` — consolidate.
- **Q4: Where do tracer + logger come from inside the helper?** Passed in by the caller (the binder already has them via `ctx`). The helper takes them as parameters; it does not reach into a global.
## Out of scope (deferred)
- Pre-wired factory exports (sub-shape (b) from the architecture grilling) — rejected for this iteration; revisit only if the helper-based approach fails to deliver the locality benefit.
- A `wireRepository` / `wireService` peer for repositories and services. Those bindings are direct `.toConstantValue(new RealOrMock(...))` calls and aren't structurally duplicated like the use cases; deepening them is a separate ADR conversation.
- The dupes that fallow surfaces outside the binders (e.g., the 117-line clone between `init-client.ts` and `init-client-react.ts` in `core-shared/instrumentation/sentry/`) — own initiative, not part of this PRD.
- Identity-presenter cleanup (Candidate 2 from the architecture skill's exploration) — separate PRD.
## Further notes
- This PRD is the output of the `improve-codebase-architecture` skill's grilling loop on Candidate 1, sub-shape (a). The companion candidates (2 / 3 / 4 / 5 housekeeping) remain as future deepening targets.
- After the implementation epic ships, `pnpm fallow dupes` will become a cleaner gate — losing five top-ten clone groups should let it surface remaining duplication signal more clearly.
- The release-please rolling PR will bump both `template-vertical` (root, for the generator + core-shared changes) and all five feature packages (for their binder changes). Per ADR-021's commit-path bump targeting, this is expected.

View File

@@ -1,492 +0,0 @@
---
id: ci-security-and-supply-chain
title: CI security + supply-chain enforcement stack
type: prd
status: approved
author: danijel
created: 2026-05-14T00:00:00Z
updated: 2026-05-14T19:16:52.691Z
adr: adr-023
builds-on: library-evaluation-policy
---
## Problem
The repo's current security posture, audited 2026-05-14: **zero security tooling**.
No Dependabot, no Renovate, no CodeQL, no Snyk, no Trivy, no OSV-Scanner, no
Socket, no `gitleaks`, no `pnpm audit signatures` step. GitHub Actions are
pinned to **major-version tags** (`actions/checkout@v4`, `pnpm/action-setup@v4`,
`googleapis/release-please-action@v4`), which the 2025 `tj-actions/changed-files`
incident proved unsafe.
ADR-022 + the in-flight library-evaluation epic close the **adoption-time** gate
for new dependencies. They do not close the **drift** gate. Six post-adoption
threats remain uncovered:
1. **CVE disclosures against pinned versions.** The trace's `verification-commands`
snapshot goes stale silently when new advisories drop.
2. **Supply-chain _behavior_ compromise**`event-stream`, `ua-parser-js`,
`tj-actions/changed-files`, `xz-utils`. CVE scanning is a lagging indicator;
these shipped malware that no CVE database had seen at the moment of compromise.
3. **Maintainer-account compromise.** A trusted upstream maintainer's npm
account gets phished; the next patch publishes a malicious post-install
script; everyone on `^1.2.0` inherits it.
4. **GitHub Actions supply chain.** Major-tag pinning is documented insecure.
5. **License drift** (e.g. Sentry going BSL on a major; Elasticsearch going SSPL).
6. **EU-residency drift** when a vendor announces US-only changes mid-flight.
ADR-023 codifies the four-pillar enforcement stack that closes these gaps. This
PRD implements it.
## Goal
A four-pillar CI security stack — Renovate-managed bumps + Action SHA pinning,
Socket-based supply-chain-behavior detection, continuous trace revalidation
extending ADR-022, and baseline GitHub-native gates — composed via a single
failure-mode hierarchy that the sandcastle reviewer prompt enforces machine-readably
for agent-driven PRs.
## In scope
- **Renovate adoption** — `.github/renovate.json` configuring per-workspace
npm bumps (grouped by ecosystem cluster), Dockerfile bumps for
`.sandcastle/Dockerfile`, GitHub Actions SHA pinning via
`pinGitHubActionDigests`, major/minor split with automerge for green
minor+patch PRs.
- **One-time Action SHA-pin sweep.** Renovate's first run rewrites the 6
existing `uses:` references in `.github/workflows/*.yml` from
major-version tags to full 40-char SHAs.
- **Socket.dev integration as the 9th hard filter in `evaluate-library`.**
Trace schema gains `socket-risk: clean | flagged | "<finding-summary>"`
in `filter-results:`. Verification-commands gains the Socket scan command.
`.socket.json` configures issue-rules with named severity thresholds
(default: `critical → error`).
- **Socket CI step** in `ci.yml`'s `validate` job — runs `socket-cli scan`
against the lockfile, fails on `critical`.
- **Socket GitHub App install instructions** in the human guide for consumers.
- **Trace revalidation workflow** at
`.github/workflows/trace-revalidation-weekly.yml` — weekly cron +
`workflow_dispatch`. Scope: every approved + pre-shipped trace.
Two-tier divergence action: soft → rolling dashboard issue
(`library-policy/dashboard` label); hard → per-dep issue
(`library-policy/re-evaluation` label). No auto-edit of traces, no
auto-dispatch, no main-CI gating.
- **Trace schema extensions** in `scripts/library-decisions/schema.mjs`
`last-revalidated: <ISO date>` frontmatter field; `socket-risk` filter
result; `verification-commands` array gains the Socket entry.
- **Major-bump re-evaluation flow** — `scripts/library-decisions/check.mjs`
detects when a Renovate PR bumps a runtime dep across a semver-major
boundary in a feature/core package and requires the trace's
`last-revalidated` to be refreshed. Minor + patch bumps do NOT require
re-evaluation.
- **CodeQL workflow** at `.github/workflows/codeql.yml` for
`javascript-typescript`; runs on push to main + PRs + weekly schedule.
- **`pnpm audit signatures --audit-level=high`** added as one step in
`ci.yml`'s `validate` job.
- **`gitleaks` pre-commit hook** in `.husky/pre-commit` as a step
alongside the existing state-sync guard. Custom-pattern allowlist via
`.gitleaks.toml`.
- **Sandcastle reviewer prompt update** — extend `.sandcastle/reviewer.prompt.md`
to read Socket CI output (via `gh run view`) and reject on `critical`,
and to read CodeQL findings and reject on `error` severity.
- **Failure-mode hierarchy table** ships in
`docs/guides/ci-security.md` and is referenced from the reviewer prompt.
- **`docs/guides/ci-security.md`** human reading-room — covers each
gate, consumer-toggleable settings (GitHub native push protection,
Socket App install, branch protection for `library-policy/*` labels),
the failure-mode hierarchy table, and worked examples (a passing
Renovate minor-bump PR, a blocked major-bump PR, a hard-divergence
revalidation issue).
- **CLAUDE.md "Key Conventions"** gains a one-line bullet pointing to
ADR-023 + the guide.
- **Glossary** already includes **Trace revalidation** and
**Major-bump re-evaluation** (landed inline during the grill session
that produced ADR-023).
## Out of scope
- **Paid Socket Team plan / server-side PR-block enforcement.** The free
App + self-hosted CLI achieves equivalent enforcement at $0; paid
upgrade is a per-consumer decision.
- **Snyk, Trivy, OSV-Scanner.** Free GitHub-native (CodeQL + Dependabot
alerts + push protection) + Socket + Renovate cover the surface at $0.
- **Container scanning for the sandcastle Dockerfile.** Renovate handles
base-image bumps; the sandbox is short-lived and host-isolated.
- **Auto-removal of approved-then-unused deps** — `pnpm fallow` territory.
- **License auto-enforcement at the lockfile layer** (license-checker
plugins). Defer until the policy has run for some time.
- **Anything app-tier.** ADR-022 exempts app tier from traces; this PRD
inherits that exemption.
- **Devdeps in any tier.** Only `dependencies` (runtime) participate in
the policy.
- **Splitting ADR-023 from ADR-022 amendments into two ADRs.** Decided
in the grill (Q7d): one ADR, ADR-022 unedited but cited.
- **Auto-dispatch on `library-policy/re-evaluation` issues.** Human
triage required; the dispatch loop drains the queue on demand.
- **CI gating on `library-policy/re-evaluation` (block main).** Main
keeps deploying; trace re-walks happen in parallel.
## Constraints
- **ADR-023** is the source of truth. This PRD implements but does not
extend it.
- **ADR-022** stays unedited. The amendments in ADR-023 §6 are what the
implementation honors. Both ADRs must be readable as a composed policy.
- **ADR-019** — the sandcastle reviewer prompt is one of four enforcement
layers. Reviewer-prompt extensions compose with the existing prompt
shape and with the library-evaluation epic's story 06 (whose
reviewer-prompt updates land first).
- **ADR-021** — release-please picks up dep changes from commit history.
Renovate's bump commits must use Conventional Commits (`chore(deps):`,
`chore(deps-major):`) so release-please's per-package bump rules apply
cleanly.
- **Template-vs-consumer framing** — every artifact ships as a
consumer-inheritable default. Plan-gated tools (CodeQL on private
repos) include clear error messages when the consumer's GitHub plan
doesn't cover them.
- **Conformance system parity** — the failure-mode hierarchy mirrors
ADR-012's latency-tiered shape. Same vocabulary, same agent feedback
loop.
- **Conventional Commits** — every commit produced by the implementation
follows `<type>(<scope>): <subject>`.
- **`--no-verify` is forbidden** — the bash-guard hook enforces this; new
pre-commit checks inherit the protection.
- **Reviewer prompt is the single composable gate for agent PRs** — the
sandcastle reviewer must be able to derive "approve/reject" from CI
outputs (Socket findings, CodeQL severity) without needing a separate
judgment surface.
## Success criteria
- `pnpm typecheck && pnpm test && pnpm lint && pnpm conformance && pnpm fallow:audit`
pass green at the end of the epic.
- `pnpm coverage:diff` covers every changed executable line introduced
by the implementation slices.
- Renovate's first run (on this repo and on any consumer's downstream
fork) opens a PR that SHA-pins every `uses:` reference in
`.github/workflows/*.yml`. After merge, no `@v<N>`-style tag pin
remains in any workflow.
- Running `evaluate-library` against any new package now collects and
records a `socket-risk` filter result and includes the `socket-cli`
invocation in `verification-commands`. Existing backfilled traces
(from the library-evaluation epic) get `socket-risk` added via the
Renovate-bump flow or via an explicit backfill task.
- A Renovate PR that bumps `@sentry/node` from `7.x → 8.x` against
`packages/marketing-pages` is blocked from auto-merge until the
trace's `last-revalidated` is refreshed by running the
`evaluate-library` skill.
- A Renovate PR that bumps `@sentry/node` from `7.5.0 → 7.6.0` against
the same package auto-merges if all gates pass — no trace refresh
required.
- `.github/workflows/trace-revalidation-weekly.yml` runs successfully
on its first weekly cron, against the existing ~10 backfilled traces,
and produces either zero divergence or a `library-policy/dashboard`
issue with a comparison diff.
- A simulated hard-divergence trigger (manually mutating a trace's
expected `cve-scan` value vs. what `pnpm audit` returns) opens a
`library-policy/re-evaluation` issue with the correct title format
and body.
- A simulated `critical` Socket finding in CI causes the sandcastle
reviewer to reject the slice with notes referencing the Socket finding.
- A simulated `error`-severity CodeQL finding causes the same reviewer
rejection.
- `pnpm audit signatures` runs as a step in CI and fails the job when
a deliberately-tampered package signature is staged.
- `gitleaks` pre-commit hook blocks a commit that adds a known token
pattern (Stripe-style test key) to any tracked file.
- `docs/guides/ci-security.md` includes the failure-mode hierarchy
table, the consumer-toggleable settings list, and at least two
worked examples (one approved flow, one blocked flow).
- `CLAUDE.md` Key Conventions includes the ADR-023 bullet.
- All existing backfilled traces (from the library-evaluation epic)
carry a `last-revalidated` field after the first weekly cron run.
## User stories
1. **As a developer running `pnpm add` against a feature package**, I want
Socket's risk score to be one of the filter results the
`evaluate-library` skill collects and records, so I get a single
composed answer instead of having to remember to check Socket
separately.
2. **As an agent dispatched against a slice that bumps a runtime dep**,
I want Renovate's bump PR to either auto-merge (minor/patch) or
require me to walk `evaluate-library` (major) so the policy gate is
automatic, not remembered.
3. **As a reviewer (human or agent) of a Renovate major-bump PR**, I
want the trace's `last-revalidated` field refreshed by the
`evaluate-library` re-run so I can see at a glance "this dep was
re-validated today" before approving.
4. **As an agent reviewing a slice in sandcastle**, I want the reviewer
prompt to read Socket CI output + CodeQL findings + library-trace
presence in one composed check and reject on any `critical` /
`error`, so I have a single composable gate.
5. **As a maintainer who hasn't touched the repo for a week**, I want
the weekly trace revalidation cron to produce at most one rolling
dashboard issue (soft divergence) and zero per-dep issues (hard
divergence) unless something actually drifted, so my notification
surface stays clean.
6. **As a future agent considering a previously-approved library that's
now Socket-flagged**, I want the trace revalidation cron to open a
`library-policy/re-evaluation` issue with the trace path + the
Socket finding + a clean re-walk handoff, so I can drive the
re-evaluation without re-discovering the prior context.
7. **As a maintainer reviewing CI output for a PR that touches
`package.json`**, I want Socket's comment + the `socket-cli scan`
step's result + the library-trace presence check to all be visible
in one place (the PR's checks panel), so the decision is one glance,
not three.
8. **As a security-conscious maintainer**, I want every `uses:`
reference in every workflow pinned to a 40-char SHA + a trailing
`# v<N>` comment, so the `tj-actions/changed-files` class of attack
is closed and Renovate keeps the SHAs current.
9. **As a maintainer who accidentally pastes a token into a commit**,
I want the `gitleaks` pre-commit hook to refuse the commit + GitHub
native push protection to be a second line of defense, so a leaked
secret never reaches the remote.
10. **As a code reviewer looking at a PR with a CodeQL `error` finding**,
I want the finding to appear in PR checks as a hard-block, so the
pattern doesn't merge.
11. **As a maintainer reading the repo for the first time**, I want
`docs/guides/ci-security.md` to walk me through the four pillars
- the failure-mode hierarchy + the consumer-toggleable settings,
so I understand what to enable in a downstream repo without
spelunking workflows.
## Implementation decisions
**Module sketch** — what lands where, by concern (no specific file paths
where prose suffices):
- **Renovate config** — single `.github/renovate.json` extending a small
set of presets: `config:base`, `helpers:pinGitHubActionDigests`,
`:separateMajorReleases`, `:automergeMinor`, `:automergePatch`. Custom
`packageRules:` group `@sentry/*`, `@opentelemetry/*`, `@trpc/*`,
`payload*`, and `inversify*` into per-cluster weekly PRs. Dockerfile
manager enabled for `.sandcastle/Dockerfile`. `dependencyDashboard:
true` opens a single issue that summarizes open + queued PRs.
- **Socket integration — schema layer.** `scripts/library-decisions/schema.mjs`
gains `socketRisk: z.union([z.literal("clean"), z.literal("flagged"),
z.string()])` in the `filter-results` Zod schema. The `verification-commands`
array gains the Socket entry. The trace template (`_template.md`)
mirrors the new field.
- **Socket integration — skill layer.** `.claude/skills/evaluate-library/SKILL.md`
gains a "9 — Supply-chain behavior (Socket)" section. The skill's
fail-fast logic (collect-cheap-skip-expensive) treats Socket as
expensive (network call) and runs it after the cheap structural
filters. `socket-cli` is the verification command; output parsing
follows Socket's JSON schema.
- **Socket integration — CI layer.** One step in `ci.yml`'s `validate`
job: `socket-cli scan --json | jq <severity-filter>`. Fail on `critical`.
`.socket.json` lives at repo root: `{ "issueRules": { "critical":
"error", "high": "warn", "medium": "ignore", "low": "ignore" } }`.
- **Trace revalidation workflow.** New file
`.github/workflows/trace-revalidation-weekly.yml`. Triggers:
`schedule: - cron: "30 6 * * 1"` (Monday 06:30 UTC, avoiding the
Sunday→Monday CI peak), plus `workflow_dispatch`. Job: checkout,
install, run a new script `scripts/library-decisions/revalidate.mjs`
that walks every approved + pre-shipped trace, re-runs each trace's
`verification-commands`, classifies divergence, opens or updates
issues via `gh` CLI. Permissions: `issues: write`, `contents: read`
(NO `contents: write` — no auto-edit).
- **Major-bump re-evaluation flow.** `scripts/library-decisions/check.mjs`
gains a new mode: when invoked on a Renovate-generated PR (detected
via branch prefix `renovate/`), it parses the lockfile diff to extract
bumped deps + their from/to versions, classifies each as major /
minor / patch, and for any feature/core-tier major bump checks that
the corresponding trace's `last-revalidated` field is fresh (set
today). If not fresh, exit non-zero with a pointer to the
`evaluate-library` skill.
- **CodeQL workflow.** Standard GitHub-issued template: `language: javascript-typescript`,
triggers `push: branches: [main]`, `pull_request`, and weekly
`schedule`. Default queries.
- **Pre-commit `gitleaks`.** `.husky/pre-commit` gains step:
`gitleaks protect --staged --redact`. `.gitleaks.toml` ships with
the repo's allowlist patterns (e.g. test fixtures in `__seeds__/`
that look like tokens but aren't).
- **Reviewer-prompt update.** `.sandcastle/reviewer.prompt.md` gains
a "CI security checks" section after the existing library-trace
check (from the library-evaluation epic's story 06). The reviewer
reads `gh run view` output for the PR's check suite, looks for
Socket findings of severity `critical` and CodeQL findings of
severity `error`, and rejects the slice if either is present with
notes referencing the specific finding.
- **Human guide** — `docs/guides/ci-security.md` follows the same
shape as `docs/guides/coverage.md`: overview, per-pillar section,
failure-mode hierarchy table, consumer settings list, two worked
examples.
- **CLAUDE.md update** — one bullet in Key Conventions:
_"CI security + supply-chain enforcement: Renovate for bumps + Action
SHA pinning, Socket for supply-chain behavior, weekly trace
revalidation, CodeQL + audit signatures + gitleaks. See ADR-023 +
`docs/guides/ci-security.md`."_
**Trace schema extension (Zod, lifted from ADR-023 §6.3):**
```ts
filterResults: z.object({
// ... existing 8 fields ...
socketRisk: z.union([
z.literal("clean"),
z.literal("flagged"),
z.string(), // human-readable finding summary
]),
});
lastRevalidated: z.string().nullable(); // ISO date or null on a fresh adoption
```
The `date` field stays mandatory (adoption-provenance); `last-revalidated`
is set on major-bump re-eval (Q3) and on a successful trace revalidation
run (J).
**Failure-mode hierarchy (lifted from ADR-023 §5):** the table is the
source of truth referenced by both the reviewer prompt and
`docs/guides/ci-security.md`. Changes to the hierarchy require an ADR
amendment.
**Sequencing — depends on the library-evaluation epic.** This PRD's
implementation depends on the in-flight library-evaluation epic:
- Story 01 of library-evaluation (trace schema foundation) **must land
first** — this PRD extends that schema.
- Story 02 of library-evaluation (pre-commit check script) **must land
first** — this PRD extends that script with the major-bump-detection
mode.
- Story 04 of library-evaluation (evaluate-library skill) **must land
first** — this PRD adds the Socket filter to that skill.
- Story 06 of library-evaluation (reviewer-prompt update) **must land
first** — this PRD extends the reviewer prompt added there.
Sandcastle dispatch should order this PRD's epic _after_ the
library-evaluation epic completes.
**Conformance system composition** — no new use cases, controllers,
manifest entries, audits, events, jobs, or realtime channels. This PRD
is workflow/policy implementation, not feature-domain change. The
conformance gates apply only to the new TypeScript/JS modules (Zod
schema extensions, the revalidate.mjs script, the check.mjs major-bump
mode) — they get standard vitest coverage.
## Testing decisions
- **`scripts/library-decisions/schema.mjs` extensions** — unit tests
covering: `socketRisk` field round-trips for all three variants
(`clean` / `flagged` / `<string>`); `lastRevalidated` accepts ISO
dates and `null`; missing `socketRisk` on a trace fails validation;
`lastRevalidated: null` is the default for fresh traces.
- **`scripts/library-decisions/check.mjs` major-bump mode** — integration
tests covering: minor bump on a feature-tier dep → pass without trace
refresh; major bump on a feature-tier dep with fresh `last-revalidated`
→ pass; major bump on a feature-tier dep with stale `last-revalidated`
→ fail with a clear pointer; major bump on an app-tier dep → pass
(app tier exempt); patch bump in a Renovate branch → pass; non-Renovate
branch with a major bump → pass (the rule is Renovate-PR-scoped).
- **`scripts/library-decisions/revalidate.mjs`** — integration tests
using a fixture trace directory: trace with no drift → no issue opened;
trace with soft drift → dashboard issue created/updated; trace with
hard drift → per-dep issue opened with correct labels + title format;
trace already covered by an open per-dep issue → no duplicate issue;
rejection trace → skipped entirely. Use `gh` CLI mocks or a fake
GitHub API surface for the issue-write side.
- **Renovate config** — no automated test; verified by Renovate
Dependency Dashboard preview run + manual review of the first PR
(the SHA-pin sweep).
- **Socket CI step** — smoke test by adding a known-flagged package
fixture to a test branch and asserting CI fails. Captures the
`socket-cli` output format we depend on for parsing.
- **CodeQL workflow** — no test; the workflow file IS the test
(GitHub validates the YAML; CodeQL action either runs or no-ops per
consumer plan).
- **`pnpm audit signatures` step** — verified by the existence of the
step in `ci.yml` + a smoke test where a deliberately-corrupt signature
fails CI.
- **`gitleaks` pre-commit hook** — bash smoke test that pipes a staged
commit containing a known token pattern through the hook and asserts
exit code non-zero. Use a Stripe-style test key as the fixture.
- **Reviewer-prompt extension** — no automated test in the conformance
sense (it's a prose runbook for an agent). Success criterion is
manual: dispatch an agent against a PR with a simulated Socket
`critical` finding, verify the agent rejects with the expected notes.
- **Prior art** — mirror the test patterns from the library-evaluation
epic's stories 0102 (trace schema + check script). The fixture and
assertion shape carries over directly.
- **Coverage bands** — new scripts under `scripts/library-decisions/`
aren't feature packages, so no per-layer thresholds. Default expectation:
100% statement coverage on the new branches (the scripts are small).
## Open questions
- **Q1:** Should the Renovate config use a `branchPrefix` other than
the default `renovate/` to make the check.mjs Renovate-PR-detection
more robust against future Renovate refactors? — **Recommended:**
no — Renovate's `renovate/` prefix has been stable for years; use
the default and detect via that prefix. Future-proofing here is
premature.
- **Q2:** Should `socket-cli scan` run on **every** CI PR or only on
PRs that touch `package.json` / `pnpm-lock.yaml`? — **Recommended:**
only on PRs that touch those files. Use a `paths:` filter on the
step. Cheaper CI; same coverage (Socket can't catch behavior changes
in a PR that doesn't change deps).
- **Q3:** Should the major-bump re-evaluation rule apply when Renovate
groups multiple deps in one PR? — **Recommended:** the rule applies
per-dep, not per-PR. If a grouped PR contains 3 minor bumps + 1 major
bump, the trace for the major-bump dep needs `last-revalidated`
refreshed; the 3 minor bumps don't trigger. The check.mjs script
walks the lockfile diff and validates each bumped dep independently.
- **Q4:** Should `library-policy/re-evaluation` issues auto-close when
the trace's `last-revalidated` is refreshed in a subsequent commit?
**Recommended:** yes, the trace revalidation workflow checks for
open issues whose dep names appear in newly-refreshed traces and
closes them with a comment citing the refresh commit.
- **Q5:** Where do the gitleaks allowlist patterns live? — **Recommended:**
ship a minimal `.gitleaks.toml` at repo root with one explicit
allowlist for `__seeds__/**` test fixtures. Document that consumers
extend it for their own custom patterns.
- **Q6:** Should the `dependencyDashboard` issue Renovate opens be
labeled identically to the trace revalidation `library-policy/dashboard`
issue? — **Recommended:** no, keep them separate. Renovate's
dependency dashboard is about _pending bumps_; the trace revalidation
dashboard is about _post-adoption drift_. Different queues, different
labels (`renovate/dashboard` vs `library-policy/dashboard`).
## Out of scope (deferred)
- **Branch protection rules.** Configuring GitHub branch protection to
require Socket + CodeQL + audit-signatures + library-trace-check
checks before merge is a per-repo settings change, not a tracked
file. Document the recommended ruleset in
`docs/guides/ci-security.md` and leave application to consumers.
- **Auto-dispatch on `library-policy/re-evaluation` issues.** Decided
out of scope in the grill (Q7c); revisit if human triage becomes a
bottleneck in practice.
- **Renovate Dependency Dashboard → docs/work/ task integration.**
Surfacing pending bumps as `pnpm work` tasks would let agents pick
them up via dispatch. Interesting but separate.
- **OSSF Scorecard integration.** Complementary to Socket but
duplicates several signals; defer until the four-pillar stack has
matured.
- **StepSecurity Harden Runner.** Adds runtime egress detection on top
of Action SHA pinning. Defer; the SHA pins close the primary attack
surface.
- **Socket Team plan upgrade.** Free tier is documented as adequate
for this template; consumers upgrade per their own threat model.
- **License-checker lockfile-layer enforcement.** Defer until the
policy has run for some time.
## Further notes
- **Anchored by ADR-023** — CI security + supply-chain enforcement
stack. Read that first.
- **Builds on ADR-022** — Library evaluation policy. ADR-022 stays
unedited; ADR-023 §6 amends it with major-bump trigger,
`last-revalidated` field, and Socket as the 9th hard filter.
- **Builds on PRD** `library-evaluation-policy` — many of
this PRD's modules extend artifacts being built by that PRD's epic.
The sequencing constraint in §Implementation Decisions is
load-bearing.
- **Glossary entries** for **Trace revalidation** and **Major-bump
re-evaluation** landed during the 2026-05-14 grill session that
produced ADR-023.
- **Conversation provenance** — the 2026-05-14 grill-with-docs session
that produced this PRD is captured in the session transcript;
ADR-023 cites the audit of zero security tooling + the
`tj-actions/changed-files` incident as concrete catalysts.

View File

@@ -1,223 +0,0 @@
---
id: compliance-docs-scaffolds
title: Compliance docs scaffolds — Epic D of ADR-025
type: prd
status: approved
author: danijel
created: 2026-05-20T09:18:39Z
updated: 2026-05-20T09:34:30.184Z
---
## Problem
Epics AC shipped the compliance _machinery_: PII manifests + retention + sub-processor generators (A), DSR + consent + cookie banner (B), security headers + rate-limit + SBOM (C). A consumer adopting the template now has the code-enforced ~80% of the DPA/GDPR playbook.
But three documentation gaps remain, each of which hurts a real reader:
- **No human-authored policy artifacts.** A DPA auditor or a customer doing GDPR due diligence expects `incident-runbook.md`, `dsr-procedure.md`, `backup-policy.md`, `password-policy.md`, `device-policy.md`, `onboarding.md`, `offboarding.md` — organizational policy documents the code can't generate. Every consumer writes these from a blank page, usually badly, usually late.
- **No single launch gate.** There's no answer to "are we compliant enough to ship to a paying customer?" The playbook §19 is a generic checklist; nothing maps it to _this template's_ concrete features (`pnpm compliance:emit-all --check`, `core-audit`, DSR endpoints) or flags which obligations are the consumer's own.
- **No compliance map.** After 4 epics, compliance docs sprawl across `docs/compliance/`, `docs/guides/` (8+ files), `docs/decisions/` (6 ADRs), and root `compliance/`. A new agent, auditor, or engineer has no entry point answering "how is compliance structured here?"
A fourth, smaller gap: `docs/guides/operator-checklist.md` has sat untracked since the ADR-022/023 work and is now stale — it predates ADR-024 (analytics) and ADR-025 (the compliance epics). The pre-launch checklist needs to cross-reference it, so it must be landed and refreshed.
ADR-025 settled the strategy: fill-in templates under `docs/compliance/`, a pre-launch checklist, the `docs/compliance/` vs root `compliance/` split documented. This PRD is the implementation seed for Epic D — the final epic of ADR-025.
## Goal
Ship the compliance documentation layer so a downstream consumer gets: (1) seven copy-and-fill policy templates, (2) a template-tailored pre-launch compliance checklist that maps every playbook obligation to its template mechanism or flags it consumer/infra-scope, (3) a single `compliance-overview.md` hub mapping the 22 playbook sections to their covering ADR/guide/template. Pure documentation — no code, no manifests, no conformance rules.
## In scope
### Fill-in policy templates — `docs/compliance/templates/`
Seven Markdown templates, copy-to-`compliance/`-and-fill. Two tiers:
- **Anchored** (reference real Epic A/B/C features):
- `incident-runbook.template.md` — breach detection → triage → containment → notification (GDPR Art. 33 72h / typical DPA 24h) → post-mortem. Cross-references the audit channel (ADR-018), Sentry alerting (ADR-014), the security-headers + rate-limit surfaces (Epic C).
- `dsr-procedure.template.md` — how a data subject request is received, validated, fulfilled, and recorded. Cross-references the DSR endpoints + `core-dsr` interfaces (Epic B), the audit `CONSENT_*`/`RESTRICT` actions, the `compliance/data-map.yml` artifact (Epic A).
- **Skeleton** (org-policy structure, heavy `[FILL IN:]`, "policy not code-enforced by the template" banner):
- `backup-policy.template.md`
- `password-policy.template.md` — banner explicitly cross-references ADR-025's deferral of MFA + password policy + lockout (product-shaped, not built)
- `device-policy.template.md`
- `onboarding.template.md`
- `offboarding.template.md`
All templates use the `[FILL IN: <description>]` marker convention — self-documenting, greppable, collision-free.
### Pre-launch compliance checklist — `docs/guides/pre-launch-compliance-checklist.md`
A two-column status table. Column 1 = playbook obligation (drawn from playbook §19 + the 22 sections). Column 2 = one of:
- **Shipped by template** — with the concrete verification command (e.g. `pnpm compliance:emit-all --check`, `pnpm conformance`, securityheaders.com scan)
- **Consumer responsibility** — the consumer must do this (fill a policy template, wire a vendor)
- **Infra responsibility** — deploy/infra-layer (EU region pinning, TLS at edge, backups)
The table operationalizes ADR-025's three-way coverage split (template-shaped / deferred / consumer-scope) into a checkable launch gate.
### Compliance overview hub — `docs/guides/compliance-overview.md`
The single entry point. Maps each of the 22 playbook sections to the ADR / guide / template / epic that covers it. Includes the "what's deferred / what's consumer-scope" summary. Links one-directionally out to every compliance guide, ADR, and template. Added to CLAUDE.md "Read First".
### Operator checklist — land + refresh `docs/guides/operator-checklist.md`
The existing untracked `operator-checklist.md` is committed and refreshed to current state:
- Add ADR-024 operator actions (analytics backend wiring is consumer-scope; note it)
- Add ADR-025 operator actions: the `compliance/` directory as committed audit evidence, the compliance drift CI gate + pre-commit hook, retention purge job scheduling, the `compliance/sub-processors.manual.yml` hand-authored file
- Keep the existing ADR-022/023 content (push to remote, GitHub Apps, branch protection, weekly cadence)
### Doc wiring (one-directional)
- `CLAUDE.md` "Read First" — add `compliance-overview.md` pointer
- `docs/compliance/README.md` — add a "Policy templates" section pointing at `templates/` and explaining the copy-to-`compliance/` workflow + the `[FILL IN:]` convention + the `grep -rn '\[FILL IN:' compliance/` verification one-liner
- `docs/glossary.md` — new entries: `pre-launch compliance checklist`, `compliance overview`, `fill-in template`, `[FILL IN:] marker`
- Existing compliance guides (`dsr.md`, `consent.md`, `security-headers.md`, `rate-limiting.md`, `audit-and-compliance.md`, `ci-security.md`) — **not touched**; the overview links to them one-directionally
## Out of scope
- **Any code, manifest field, ESLint rule, brand, generator, or conformance change** — Epic D is pure documentation
- **Rewriting the existing compliance guides** — `dsr.md`, `consent.md`, etc. stay as-is; the overview links to them, they don't link back
- **A machine-checkable "no `[FILL IN:]` left in `compliance/`" lint/CI gate** — the marker convention is chosen to _allow_ a future check, but Epic D ships docs only; the gate is a deferred follow-up
- **Generating `compliance/*.md` filled artifacts** — the template ships `docs/compliance/templates/*.template.md`; the consumer copies and fills. The template never ships filled policy docs (it has no real org data)
- **DPIA / RoPA / SCC / Privacy Policy / ToS / DPA document templates** — legal-instrument artifacts; consumer + legal counsel author them. The overview _names_ them as consumer-scope but Epic D ships no template for them
- **Enforcing the password policy the `password-policy.template.md` documents** — MFA + password rules + lockout are ADR-025 deferrals; the template documents the policy shape, not the enforcement
- **Broadening `docs/compliance/README.md` into the hub** — the README stays scoped to generator schema reference; the hub is the separate `compliance-overview.md`
- **Multi-language / localized policy templates** — English only
- **The Epic D docs covering Epic D itself** — no meta-documentation
## Constraints
- **ADR-025** — Epic D is the fourth and final epic; strategy settled there. The `docs/compliance/` vs root `compliance/` split is canonical.
- **ADR-018, ADR-014, ADR-017, ADR-022, ADR-023, ADR-024** — the overview + anchored templates cross-reference these; references must be accurate to shipped state.
- **No conformance surface** — Epic D adds no manifest field, no ESLint rule, no brand. Conformance ESLint rule count stays **13** (`pii-declaration-must-be-complete`, `no-undeclared-consent-check`, `no-undeclared-rate-limit` landed in Epics AC). Any rule-count reference in Epic D docs must say 13.
- **Sequencing** — Epic D lands after Epics A, B, C so the anchored templates + checklist + overview reference shipped features, not planned ones. If a referenced feature is still in flight at decompose time, the story for that doc waits.
- **`[FILL IN:]` convention** — every placeholder uses `[FILL IN: <description>]`. No bare `{{ }}`, no italic-only markers.
- **Skeleton banner** — the 5 skeleton templates carry a banner stating the policy is organizational and not code-enforced by the template. `password-policy.template.md`'s banner cross-references ADR-025's MFA/password deferral by ADR number.
- **One-directional links** — Epic D touches `CLAUDE.md`, `docs/compliance/README.md`, `docs/glossary.md` + new files only. Existing guides are not edited.
- **Conventional Commits** — every story = one `docs:` or `chore(docs):` commit.
- **No `--no-verify`** — pre-commit gates run; the compliance drift gate (`emit-all --check`) must still pass (Epic D changes no collections, so it will).
## Success criteria
- `docs/compliance/templates/` contains 7 `*.template.md` files; each anchored one cross-references at least one real ADR + one real `pnpm` command or shipped interface; each skeleton one carries the "not code-enforced" banner.
- `grep -rn '\[FILL IN:' docs/compliance/templates/` returns matches in every template (proves the convention is used); the same grep against a hypothetical filled copy in `compliance/` would return empty.
- `docs/guides/pre-launch-compliance-checklist.md` is a two-column table; every "Shipped by template" row names a runnable verification command; every consumer/infra row is explicitly labelled.
- `docs/guides/compliance-overview.md` maps all 22 playbook sections; every row points at a real ADR / guide / template / epic; the file is listed in CLAUDE.md "Read First".
- `docs/guides/operator-checklist.md` is tracked in git (no longer untracked) and contains sections for ADR-024 + ADR-025 operator actions.
- `docs/compliance/README.md` has a "Policy templates" section.
- `docs/glossary.md` has the 4 new entries.
- `pnpm lint && pnpm typecheck && pnpm test && pnpm conformance && pnpm fallow:audit && pnpm compliance:emit-all --check` all green (Epic D touches no code, so these are unaffected — the criterion is "didn't break anything").
- No broken internal doc links — every `[text](path)` in the new files resolves to an existing file.
## User stories
1. As a **downstream consumer preparing for a DPA audit**, I want seven copy-and-fill policy templates so I produce the required organizational documents without starting from a blank page.
2. As a **downstream consumer**, I want every placeholder marked `[FILL IN: <description>]` so I (or an agent) can `grep` for unfilled spots and never ship a half-filled runbook.
3. As a **downstream consumer filling `password-policy.template.md`**, I want a banner telling me the template doesn't enforce these rules in code so I don't assume MFA/lockout is wired.
4. As a **launching team**, I want a pre-launch checklist that tells me, per obligation, whether the template handled it (and how to verify) or whether it's on me.
5. As a **compliance officer**, I want the checklist's "Shipped by template" rows to name a runnable command so I can produce verification evidence on demand.
6. As an **auditor or new engineer**, I want a single `compliance-overview.md` mapping the 22 playbook sections to where each is covered so I don't reverse-engineer the structure from scattered files.
7. As an **AI agent** asked "is feature X compliant?", I want `compliance-overview.md` in CLAUDE.md "Read First" so I start from the map, not from grep.
8. As a **template operator**, I want `operator-checklist.md` committed and current so the steps I follow reflect ADR-024 + ADR-025, not just the older ADR-022/023 state.
9. As a **downstream consumer reading the incident runbook**, I want it to reference the actual audit channel + Sentry alerting so the breach procedure uses the template's real observability surface.
10. As a **downstream consumer reading the DSR procedure**, I want it to reference the actual `/api/gdpr/*` endpoints + `core-dsr` interfaces so the procedure matches the shipped code.
11. As a **template author**, I want the existing `docs/compliance/README.md` to stay scoped to generator schema and the new `compliance-overview.md` to be the hub, so neither doc tries to be both.
12. As a **template maintainer**, I want Epic D to touch only CLAUDE.md + README + glossary + new files so the change has a small, reviewable blast radius.
## Implementation decisions
### Module surface
Epic D creates **no packages and modifies no code**. It is entirely under `docs/`.
**New files:**
- `docs/guides/compliance-overview.md` — the hub
- `docs/guides/pre-launch-compliance-checklist.md` — the two-column launch gate
- `docs/compliance/templates/incident-runbook.template.md` — anchored
- `docs/compliance/templates/dsr-procedure.template.md` — anchored
- `docs/compliance/templates/backup-policy.template.md` — skeleton
- `docs/compliance/templates/password-policy.template.md` — skeleton
- `docs/compliance/templates/device-policy.template.md` — skeleton
- `docs/compliance/templates/onboarding.template.md` — skeleton
- `docs/compliance/templates/offboarding.template.md` — skeleton
**Modified files:**
- `docs/guides/operator-checklist.md` — landed from untracked + refreshed for ADR-024/025
- `CLAUDE.md` — "Read First" gains `compliance-overview.md`
- `docs/compliance/README.md` — gains a "Policy templates" section
- `docs/glossary.md` — 4 new entries
### Marker convention
`[FILL IN: <description>]` — fixed `[FILL IN:` prefix (greppable), free-text description inside (self-documenting), `]` close. Example: `Notify [FILL IN: data protection officer name + email] within [FILL IN: hours — GDPR Art. 33 caps at 72] of a confirmed breach.`
The README documents the convention and the verification one-liner `grep -rn '\[FILL IN:' compliance/`.
### Template tiers
**Anchored** (`incident-runbook`, `dsr-procedure`): structured around real template features. Each names specific ADRs, `pnpm` commands, interfaces, endpoints. They still contain `[FILL IN:]` markers for genuinely org-specific values (on-call contacts, escalation names, SLA targets) but the _procedure skeleton_ is template-substantive.
**Skeleton** (`backup-policy`, `password-policy`, `device-policy`, `onboarding`, `offboarding`): mostly `[FILL IN:]`. Each opens with a banner:
> **This is an organizational policy template.** The template scaffolds the document structure; it does **not** enforce these rules in code. Fill every `[FILL IN:]` marker and have the policy reviewed by whoever owns compliance.
`password-policy.template.md`'s banner adds: a sentence pointing at ADR-025's explicit deferral of MFA + password policy + lockout, so a reader understands the template has no code backing the password rules they're about to write.
### Pre-launch checklist shape
Two-column Markdown table. Rows grouped by playbook section (Infrastructure, Data, Application, Secrets, Sub-Processors, Logging, Breach, DSR, Backup, SDLC, Workforce, Legal, Documentation). Column 2 values are one of three labelled kinds with the verification command inline for the "Shipped by template" kind. The checklist links to `compliance-overview.md` (the map) and to the relevant templates (the consumer-action artifacts).
### Compliance overview shape
A section-by-section map of the 22 playbook sections. For each: a one-line statement of the obligation, then the covering artifact(s) — ADR number, guide path, template path, or epic. A closing summary restates ADR-025's deferrals (RBAC, MFA, breach-detection, GDPR Art. 22) and the consumer/infra-scope items (EU region, TLS, MDM, legal instruments).
### Operator checklist refresh
Landing the existing untracked file as the starting point, then adding two sections:
- **ADR-024 (analytics)** — analytics backend is consumer-chosen + consumer-wired; operator action is "decide whether to wire an analytics vendor; if so, run it through `/evaluate-library`."
- **ADR-025 (compliance)** — `compliance/*.yml` are committed audit evidence; the compliance drift gate runs in pre-commit + CI; the retention purge job schedules per `custom.retention`; `compliance/sub-processors.manual.yml` is hand-authored for non-npm vendors.
The existing ADR-022/023 content (remote push, GitHub Apps, branch protection, weekly Renovate/Socket cadence) is preserved.
### No conformance surface
Epic D adds no manifest field, no ESLint rule, no brand, no generator. The conformance rule count stays 13. The compliance drift CI gate is unaffected (no collections change).
## Testing decisions
Epic D is documentation — "testing" means verification, not unit tests:
- **Link integrity** — every relative Markdown link in the new files resolves to an existing file. Verified by a link-check pass during the documentation story (manual `grep` of `](` targets, or a one-off script; no permanent CI gate added).
- **Marker presence** — `grep -rn '\[FILL IN:' docs/compliance/templates/` returns hits in all 7 templates.
- **ADR/command accuracy** — every ADR number, `pnpm` command, interface name, and endpoint path referenced in the anchored templates + overview + checklist is cross-checked against shipped state at authoring time (Epics AC are landed by the time Epic D dispatches).
- **Gate pass-through** — `pnpm lint && pnpm typecheck && pnpm test && pnpm conformance && pnpm compliance:emit-all --check` stay green; Epic D touches no code, so the criterion is "no regression."
- **No new test files** — there is no code to test. No repository contract suite, no use-case unit tests, no Playwright specs.
- **Prior art to mirror** — `docs/compliance/README.md` (existing, Epic A) for the annotated-doc voice; `docs/guides/ci-security.md` + `docs/guides/audit-and-compliance.md` for guide structure + the "What X requires / How the template handles it" framing; `docs/guides/operator-checklist.md` (the untracked file itself) for the checklist voice.
## Open questions
- **Q1: Should the pre-launch checklist live in `docs/guides/` or `docs/compliance/`?** — Recommended: `docs/guides/`. It's a how-to/runbook (the consumer _runs_ it), and `docs/compliance/` is now reserved for generator schema + `templates/`. Keeps the "guides = how-to" / "compliance = schema + templates" split clean.
- **Q2: Should the 7 templates carry a `status: template` frontmatter field so a future lint can distinguish unfilled from filled?** — Recommended: yes, minimal frontmatter (`status: template` + `playbook-section: <n>`). Cheap, and it enables the deferred "no unfilled template in `compliance/`" check without committing to building that check now.
- **Q3: Does `compliance-overview.md` duplicate ADR-025's content?** — Recommended: no — ADR-025 is the _decision record_ (why this strategy); the overview is the _navigational map_ (where each thing lives). The overview links to ADR-025 rather than restating its rationale.
- **Q4: Should Epic D add a CI link-check for docs?** — Recommended: no, deferred. A docs link-checker is a reasonable future addition but it's a CI/tooling change, not a docs deliverable; bundling it into a pure-docs epic widens scope. Note it in "Out of scope (deferred)."
- **Q5: Refresh `operator-checklist.md` content fully, or land-then-refresh in two commits?** — Recommended: two stories — story 1 lands the file verbatim as `chore(docs)`, story 2 refreshes it as `docs(compliance)`. Keeps the "what existed" vs "what changed" diff legible for review.
## Out of scope (deferred)
- **CI link-checker for docs** — reasonable future tooling PRD; not a pure-docs deliverable (see Q4)
- **A `no-unfilled-template` check** — verifying `compliance/*.md` has no `[FILL IN:]` left; the marker convention + Q2 frontmatter enable it, but building the check is deferred
- **DPIA / RoPA / SCC / Privacy Policy / ToS / DPA templates** — legal instruments; consumer + counsel author them
- **Localized (non-English) policy templates**
- **Auto-generating filled policy docs from template config** — the template has no org data; filling is inherently a consumer action
- **A compliance dashboard / status page** — the pre-launch checklist is static Markdown; a live dashboard is out of scope
## Further notes
- **Builds on:** ADR-025 (strategy umbrella — Epic D is its fourth epic), Epic A PRD (`compliance-manifests-pii-retention-subprocessors` — the overview + checklist reference its generators + `compliance/*.yml`), Epic B PRD (`dsr-consent-and-cookie-banner` — the `dsr-procedure` template references its endpoints + interfaces), Epic C PRD (`security-headers-rate-limit-sbom` — the `incident-runbook` + checklist reference its headers + rate-limit + SBOM).
- **Closes:** ADR-025. With Epic D, all four epics of the EU compliance baseline are decomposed; the template's compliance coverage reaches the ~80% ADR-025 targeted, with the remaining ~20% explicitly documented as deferred or consumer/infra-scope in `compliance-overview.md`.
- **Sequencing:** Epic D dispatches last. Within Epic D: (1) land `operator-checklist.md` verbatim, (2) refresh it, (3) write the 7 templates (anchored first — they need the most cross-referencing), (4) write the pre-launch checklist, (5) write `compliance-overview.md`, (6) wire CLAUDE.md + README + glossary. The overview is written near-last so it can link to the finished templates + checklist.
- **Stakeholders:** downstream consumers preparing for audits (primary beneficiary — get policy templates + launch gate), auditors + compliance officers (get the overview map + verifiable checklist), template operators (get a current operator checklist), AI agents (get `compliance-overview.md` as a Read-First entry point), template authors (small reviewable blast radius — docs only).
- **PII note:** Epic D ships no code and stores no data — no PII surface, no `custom.pii` changes, no DSR interaction. The `password-policy` deferral note is the only place Epic D references unbuilt behavior, and it does so explicitly to prevent a false enforcement assumption.

View File

@@ -1,385 +0,0 @@
---
id: compliance-manifests-pii-retention-subprocessors
title: Declarative compliance manifests (PII + retention + sub-processors) — Epic A of ADR-025
type: prd
status: approved
author: danijel
created: 2026-05-18T17:52:09Z
updated: 2026-05-18T17:55:38.523Z
---
## Problem
A consumer adopting this template today gets the audit channel (ADR-018), observability PII boundary (ADR-017 §7), EU library residency filter (ADR-022), and supply-chain hardening (ADR-023) — about half the playbook's surface. But three load-bearing compliance artifacts that a DPA auditor (or a partner doing GDPR due diligence) expects are missing:
- **Data map** — no structured inventory of "what personal data does this system hold, where, with what retention, exportable by whom"
- **Retention policy** — no declarative source of truth for how long data lives per collection, no scheduled purge mechanism
- **Sub-processor inventory** — no record of "which third-party services receive personal data, under what DPA, in what region"
Every downstream EU-bound consumer currently has to invent these three artifacts from scratch, and the cost of drift between code (what's actually PII) and documentation (what we _say_ is PII) is high enough that most teams ship with both stale.
ADR-025 settled the strategy: tag at the Payload collection/field level + generators emit `compliance/*.yml` + extend ADR-022 traces for sub-processors. This PRD is the implementation seed for Epic A.
## Goal
Ship the declarative compliance manifests + generators so downstream consumers get a complete, automatically-validated PII inventory, retention policy, and sub-processor record by editing source-of-truth Payload configs and ADR-022 library traces. Drift detection runs in pre-commit + CI; the consumer's `compliance/` directory becomes audit evidence.
## In scope
- Type primitives in `core-shared/payload/`: `PiiCategory`, `DataProcessingPurpose`, `RetentionAction`, `RetentionTrigger`, `FieldPii`, `CollectionRetention`, `AuthPiiDefaults`
- TypeScript ambient module declaration extending Payload's `custom: Record<string, unknown>` to type `pii` (per field) and `retention` (per collection)
- Three generators under `scripts/compliance/`:
- `emit-data-map.mjs``compliance/data-map.yml`
- `emit-retention-policy.mjs``compliance/retention-policy.yml`
- `emit-sub-processors.mjs``compliance/sub-processors.yml`
- Orchestrator `emit-all.mjs` + `pnpm compliance:*` package scripts
- `--check` mode on every generator for drift detection
- Pre-commit hook integration: conditional (runs only when staged files match Payload configs or library traces) auto-regenerate + auto-stage
- CI integration: `pnpm compliance:emit-all --check` step in `ci.yml`'s validate job, hard-fail on drift
- New conformance ESLint rule `pii-declaration-must-be-complete` (warn): flags `custom.pii: {...}` blocks missing required sub-fields
- ADR-022 amendment: trace frontmatter gains discriminated-union sub-processor fields (`is-sub-processor`, `processes-pii`, conditional `data-sent`/`region`/`dpa-signed`/`sccs-required`/`contact`)
- `/evaluate-library` skill update: prompts for the new fields during trace authoring
- Background purge job in `core-shared/payload/retention-purge/` using existing `IJobQueue` infrastructure; emits an audit entry per row purged
- Backfill of existing template collections per Q6 of the Epic A grill:
- `auth.users`: full PII tagging on `displayName` + `custom.retention` + `custom.authPii` overrides (if non-default)
- All 6 existing collections: `custom.retention` declared
- Other collections: PII tags only where unambiguous (e.g., `media.media.uploadedBy` if tracked)
- `docs/compliance/` reference files: `data-map.example.yml`, `retention-policy.example.yml`, `sub-processors.example.yml`, `README.md` explaining the `docs/compliance/` (templates) vs root `compliance/` (live artifacts) split
## Out of scope
- DSR scaffold (`@repo/core-dsr`, `IDataExport`/`IDataDelete`/`IDataRectify`/`IProcessingRestriction`) — Epic B
- Consent abstraction (`@repo/core-consent`, `IConsent`, `ConsentChecked` brand, `requiresConsent` manifest field) — Epic B
- Cookie consent UI component — Epic B
- Security headers middleware — Epic C
- Rate-limit primitive (`IRateLimit`, `RateLimited` brand) — Epic C
- SBOM generation in CI — Epic C
- Compliance fill-in docs (runbooks, policies, pre-launch checklist) — Epic D
- Per-feature PII migration beyond what Q6 specifies — consumers ship their own
- Pure-HTTP sub-processors with no library trace — allowed as hand-authored `compliance/sub-processors.yml` entries, generator handles "manual entry, no trace" with a CI-visible flag, but no scaffold for editing them
- Retention enforcement for non-Payload data stores (Redis, S3, log aggregators) — out of scope; the template doesn't ship those abstractions yet
- Cross-region transfer assessment (Schrems II / TIA artifacts) — partially addressed via `region` field in sub-processor records, but DPIA-style transfer-impact-assessment docs are Epic D's territory
## Constraints
- **ADR-025** — Epic A's strategy is settled there. Implementation may surface details ADR-025 didn't anticipate; flag those for amendment before proceeding.
- **ADR-022** — sub-processor trace fields extend ADR-022's frontmatter. The discriminated-union shape and the `/evaluate-library` skill prompts are amendments captured in this PRD.
- **ADR-018** — purge job emits `IAuditLog.record({ action: "DELETE", reason: "retention-policy" })` per row purged. Uses the existing `core-audit` audit channel; doesn't introduce new audit semantics.
- **ADR-023** — pre-commit + CI integration follows the existing pattern (`.husky/pre-commit` already runs `bump-updated-timestamps.mjs`; ci.yml already has a `validate` job).
- **Manifest-first ordering** — the PII type primitives + Payload ambient declaration are the "manifest" for this work; they land first.
- **Generator-first** — Payload collections do NOT get hand-rolled scaffolding; existing collection files are modified in place per Q6.
- **`core-shared` must-have boundary** — purge job lives in `core-shared/payload/` because every template consumer uses Payload. Doesn't create a new optional core (per Q5).
- **No `--no-verify`** — pre-commit hook auto-regenerates compliance YAMLs; developers cannot bypass with `--no-verify` per repo policy. CI re-checks anyway.
- **Conventional Commits** — every slice lands as one green commit per the established session convention.
## Success criteria
- `pnpm compliance:emit-all` produces three deterministic YAMLs at `compliance/data-map.yml`, `compliance/retention-policy.yml`, `compliance/sub-processors.yml`.
- `pnpm compliance:emit-all --check` exits 0 when committed YAMLs match source declarations; exits non-zero with a readable diff otherwise.
- Pre-commit hook auto-regenerates conditionally — staging only a non-Payload-config / non-trace file does NOT trigger the generators.
- CI workflow (`ci.yml`'s validate job) blocks merges with mismatched compliance YAMLs.
- `pii-declaration-must-be-complete` ESLint rule fires on a `custom.pii: { category: "contact-email" }` (missing required sub-fields) in a synthetic Payload collection fixture.
- `auth.users` has a complete `custom.pii` tag set: `displayName` tagged, `email` covered by `PAYLOAD_AUTH_PII_DEFAULTS`, `password`/`salt`/`hash` excluded by the same default.
- All 6 existing template collections declare `custom.retention`.
- A library trace authored via `/evaluate-library` with `is-sub-processor: true` triggers the conditional fields prompt; the trace fails validation if any are missing.
- The retention purge job, when run via `pnpm work dispatch` or via local `pnpm dev` boot, schedules per-collection deletes; deleted rows produce audit entries with `action: "DELETE"` and `reason: "retention-policy"`.
- `pnpm typecheck && pnpm lint && pnpm test && pnpm conformance && pnpm fallow:audit && pnpm compliance:emit-all --check` all pass green at every commit boundary.
## User stories
1. As a **template author**, I want declarative PII + retention + sub-processor inventories so downstream consumers get audit evidence by editing source-of-truth configs.
2. As a **downstream consumer**, I want to add `custom.pii: { category: "contact-email", ... }` to a Payload field and have it appear in `compliance/data-map.yml` after `pnpm compliance:emit-all`.
3. As a **downstream consumer**, I want collection-level retention with cron-schedulable purge so old data gets deleted automatically without my writing a custom cron job.
4. As a **downstream consumer running a serverless deployment**, I want `purgeSchedule` to be runnable by either a process-local scheduler or an external cron — the interface accepts both.
5. As a **downstream consumer**, I want a CI gate that fails my PR if I add a new Payload field with `pii: true` but forget to regenerate `compliance/data-map.yml` so my audit evidence stays in sync.
6. As a **downstream consumer**, I want `is-sub-processor: true` traces to drive `compliance/sub-processors.yml` automatically so I don't maintain a separate inventory.
7. As an **AI agent** scaffolding a new Payload collection, I want the TypeScript types for `custom.pii`/`custom.retention` to be enforced by the compiler so I can't ship an invalid declaration.
8. As an **AI agent** evaluating a new library via `/evaluate-library`, I want the skill to ask "is this a sub-processor?" upfront so I author a complete trace in one pass.
9. As a **compliance reviewer**, I want `compliance/sub-processors.yml` to match DPA Section D so a regulator review surfaces zero discrepancies.
10. As a **template author**, I want the existing `auth.users` collection backfilled with PII tags so the template has a working reference example (not just empty schema).
11. As a **template author**, I want `password`/`salt`/`hash`/`resetPasswordToken` excluded from the data map by default so downstream consumers can't accidentally ship them as "exportable user data."
## Implementation decisions
### Module surface
- **`@repo/core-shared` modifications** (must-have package):
- New module `core-shared/payload/pii-types.ts` exporting `PiiCategory`, `DataProcessingPurpose`, `RetentionAction`, `RetentionTrigger`, `FieldPii`, `AuthPiiDefaults`, `PAYLOAD_AUTH_PII_DEFAULTS`
- New module `core-shared/payload/retention-types.ts` exporting `CollectionRetention`, `ISO8601Duration` (helper)
- New module `core-shared/payload/retention-purge/` containing `retention-purge.job.ts` + unit test
- Ambient TypeScript declaration extending Payload's `Field` and `CollectionConfig` `custom?: {}` to type `pii?: FieldPii` and `retention?: CollectionRetention` respectively
- **No new optional core packages** — Epic A doesn't introduce `core-retention` or similar (per Q5)
- **`@repo/core-eslint`**: new rule `pii-declaration-must-be-complete` (warn). Extends `_manifest-ast.js` with a Payload-collection field-parser
- **`scripts/compliance/`**: 4 new mjs scripts (3 emitters + 1 orchestrator) following the established `scripts/<topic>/` pattern
- **Existing feature packages** (auth, blog, media, marketing-pages, navigation): Payload collection files modified in place to add `custom.retention` (all) and `custom.pii` (auth fully, others sparingly per Q6)
- **`.claude/skills/evaluate-library/SKILL.md`**: updated with the two new prompt questions and the discriminated-union trace template
- **`.husky/pre-commit`**: new conditional step for `pnpm compliance:emit-all`
- **`.github/workflows/ci.yml`**: new step `pnpm compliance:emit-all --check` in `validate` job
- **`package.json`** (root): new scripts `compliance:data-map`, `compliance:retention-policy`, `compliance:sub-processors`, `compliance:emit-all`
### Type primitive contracts (decision-encoding inlined)
```ts
// core-shared/payload/pii-types.ts
export type PiiCategory =
| "contact-email"
| "contact-phone"
| "contact-address"
| "identification-name"
| "identification-username"
| "identification-government-id"
| "auth-credential"
| "auth-token"
| "network-ip"
| "network-user-agent"
| "financial-info"
| "behavioral-engagement"
| "document-content"
| "derived-metric"
| (string & Record<never, never>); // declaration-merge escape hatch for consumer extension
export type DataProcessingPurpose =
| "account-authentication"
| "transactional-notifications"
| "marketing-communications"
| "analytics-aggregation"
| "legal-compliance"
| "service-delivery"
| (string & Record<never, never>);
export type RetentionTrigger =
| "from-creation"
| "from-last-access"
| "after-deletion";
export type RetentionAction = "hard-delete" | "pseudonymize";
export type FieldRetention = {
duration: string; // ISO 8601 duration, e.g. "P30D"
trigger: RetentionTrigger;
action: RetentionAction;
};
export type FieldPii = {
category: PiiCategory;
purpose: DataProcessingPurpose[];
retention?: FieldRetention; // optional; falls back to collection-level when omitted
exportable: boolean;
restrictable: boolean;
};
// PAYLOAD_AUTH_PII_DEFAULTS — applied automatically when `auth: true`
// `null` = excluded from data-map (security material, never PII-export)
// Consumer overrides via `custom.authPii: { email: { ...override }, totpSecret: null }`
export const PAYLOAD_AUTH_PII_DEFAULTS: Record<string, FieldPii | null> = {
email: {
category: "contact-email",
purpose: ["account-authentication", "transactional-notifications"],
exportable: true,
restrictable: true,
},
password: null,
salt: null,
hash: null,
resetPasswordToken: null,
resetPasswordExpiration: null,
loginAttempts: null,
lockUntil: null,
apiKey: null,
apiKeyIndex: null,
};
```
```ts
// core-shared/payload/retention-types.ts
export type PurgeSchedule = "daily" | "weekly" | "monthly" | string; // cron expression
export type CollectionRetention = {
activeRetention?: {
duration: string;
trigger: "from-creation" | "from-last-access";
};
postDeletion?: {
duration: string;
trigger: "after-deletion";
action: RetentionAction;
};
purgeSchedule: PurgeSchedule;
coldArchive?: { duration: string; trigger: "from-creation" };
};
```
Ambient module declaration:
```ts
// core-shared/payload/payload-custom-ambient.d.ts
declare module "payload" {
interface Field {
custom?: {
pii?: FieldPii;
[key: string]: unknown;
};
}
interface CollectionConfig {
custom?: {
retention?: CollectionRetention;
authPii?: Record<string, FieldPii | null>;
[key: string]: unknown;
};
}
}
```
### Generator contracts
Each generator runs in one of three modes:
- Default: emit YAML to `compliance/<artifact>.yml`, overwriting
- `--check`: regenerate in-memory, diff against existing file, exit 0 on match, non-zero with diff on mismatch
- `--print`: emit to stdout (for debugging)
YAML output is deterministic (sorted keys, normalized formatting, trailing newline) so byte-identical runs produce byte-identical output.
`emit-data-map.mjs`:
- Walks every Payload collection across packages (uses `@repo/cms` to load all configs)
- For each field in `fields[]`, reads `custom.pii` if present, emits an entry
- For collections with `auth: true`, applies `PAYLOAD_AUTH_PII_DEFAULTS` then overlays `custom.authPii` overrides; emits entries for non-null defaults
- Output structure: per-collection block listing fields + their PII metadata
- Excluded fields (e.g., `password: null`) are documented in a separate `excluded:` section per collection for audit transparency
`emit-retention-policy.mjs`:
- Walks every Payload collection
- Emits `custom.retention` block per collection
- Validates: every collection MUST have `purgeSchedule` declared (failure: print collection name + hint)
- Output structure: per-collection retention block with purge cadence + activeRetention + postDeletion + coldArchive
`emit-sub-processors.mjs`:
- Walks `docs/library-decisions/*.md`, parses frontmatter
- Filters to traces with `is-sub-processor: true`
- Emits each as a sub-processor entry with conditional fields (`data-sent`, `region`, `dpa-signed`, `sccs-required`, `contact`)
- Also reads `compliance/sub-processors.manual.yml` (if exists) for pure-HTTP entries with no backing trace; merges into output with `source: manual` flag
- Output structure: array of sub-processor records, sorted by name
`emit-all.mjs`:
- Orchestrates the three; supports `--check` mode for all at once
- Single failure exit code per failed generator
### ADR-022 amendment — trace frontmatter discriminated union
Every library trace at `docs/library-decisions/<date>-<pkg>.md` MUST declare two boolean fields after the existing ADR-022 fields:
- `is-sub-processor: boolean` (does this library send data to an external server it owns/operates?)
- `processes-pii: boolean` (does this library process personal data inside the calling process?)
When `is-sub-processor: true`, the following fields become REQUIRED:
- `data-sent: string[]` (references `PiiCategory` values)
- `region: "EU" | "EEA" | "US" | "UK" | "CH" | "OTHER"`
- `dpa-signed: ISO-date | null` (null = pending)
- `sccs-required: boolean`
- `contact: string` (email or URL)
When `is-sub-processor: false`, these fields MUST be absent. Validator enforces.
The `/evaluate-library` skill prompts the user for both binary fields during evaluation; when `is-sub-processor: true`, also prompts for the 5 conditional fields. Trace template in `.claude/skills/evaluate-library/SKILL.md` updated accordingly.
The weekly trace revalidation cron (ADR-023) checks `dpa-signed` for staleness: DPA dates older than 2 years trigger a re-confirmation issue.
### Pre-commit hook integration
`.husky/pre-commit` gains a step that runs `pnpm compliance:emit-all` if and only if any staged file matches:
- `packages/*/src/integrations/cms/**/*.ts` (Payload configs)
- `docs/library-decisions/*.md` (library traces)
- `compliance/*.yml` (the artifacts themselves — protects against manual edits)
Output is auto-staged via `git add compliance/`. The conditional check keeps unrelated commits fast (~10ms detection cost).
### CI integration
`.github/workflows/ci.yml`'s `validate` job gains a step:
```yaml
- name: Compliance manifest drift check
run: pnpm compliance:emit-all --check
```
Position: after `pnpm conformance`, before `pnpm coverage:diff`. Same severity (hard error). Failure message includes the fix command.
### Background retention purge job
Lives at `core-shared/payload/retention-purge/retention-purge.job.ts`. Receives `ctx.queue` (`IJobQueue` from `core-shared/jobs`) + `ctx.config` (Payload SanitizedConfig).
At app boot, the binder walks every collection, reads `custom.retention.purgeSchedule`, registers a scheduled job per collection with the corresponding cadence. The job body:
1. Queries the collection for rows whose `activeRetention.duration` has elapsed (from `createdAt` for `from-creation`, from `updatedAt` for `from-last-access`)
2. For each row:
- If `postDeletion.action === "pseudonymize"`: NULL the PII fields, set `processing_restricted: true` (per Epic B's IProcessingRestriction)
- If `postDeletion.action === "hard-delete"`: cascade delete via Payload's delete operation
3. Emits one audit entry per processed row: `IAuditLog.record({ action: "DELETE", subject: row.id, actor: "system", reason: "retention-policy" })`
When `auditLog` isn't wired (consumer hasn't scaffolded `core-audit`), the audit emission is skipped without throwing.
### Backfill scope (per Q6)
Stories cover backfill of existing template collections:
- `auth.users`: `displayName` tagged as `identification-username` (exportable, restrictable); `role` tagged as null (not PII per template default). Collection retention: `activeRetention: indefinite, postDeletion: 30d hard-delete, purgeSchedule: daily`. `PAYLOAD_AUTH_PII_DEFAULTS` covers `email`/`password`/`salt`/`hash` automatically; no `custom.authPii` override needed unless future custom auth fields are added.
- `blog.articles`: collection retention only (no PII fields by default — author refs not modeled as PII per template).
- `marketing-pages.site-settings`, `pages`: collection retention only (no PII).
- `media.media`: collection retention; if `uploadedBy` exists, tag it as `identification-username`.
- `navigation.header`: collection retention only.
Each backfill is one slice = one commit.
### Conformance impact
- ESLint rule count: 10 → 11 (adds `pii-declaration-must-be-complete`)
- Manifest fields: unchanged (no per-use-case manifest fields added by Epic A — declarations are on Payload configs, not feature manifests)
- New brand: none in Epic A (brands are Epic B/C territory)
- Boot assertion: extended to validate `custom.retention.purgeSchedule` is parseable when the binder boots in production
## Testing decisions
- **Type primitives**: vitest tests on `core-shared/payload/pii-types.ts` and `retention-types.ts` — verify TS shape via `@ts-expect-error` on malformed declarations; verify defaults exports.
- **Generators**: each script gets unit tests covering: happy path, `--check` matches, `--check` mismatch with readable diff, empty input (no collections), auth-managed defaults applied, `custom.authPii` overrides applied, sub-processor frontmatter discriminated union parsing.
- **ESLint rule**: RuleTester-based fixture suite mirroring `no-undeclared-audit.test.js`. Cover: complete `custom.pii` passes, missing `category` fires, missing `purpose` fires, missing `exportable` fires, non-PII collection no-op, malformed YAML in trace no-op.
- **Retention purge job**: unit test with in-memory Payload mock; verify schedule registration, row matching, audit emission, pseudonymize vs hard-delete branches, optional `auditLog` graceful skip.
- **Integration**: e2e test using the existing dev-seed setup — declare a `custom.retention` on a test collection, run `pnpm compliance:emit-retention-policy --check`, assert output. Same for `--check` mismatch with a forced manual edit.
- **No repository contract suite** — Epic A doesn't introduce a new `IXRepository`.
- **Coverage**: Epic A's modules join the L0 vitest thresholds (per `coverage.bands` in the affected packages' manifests); L1 `pnpm coverage:diff` gates the slices in dispatch.
- **Prior art to mirror**:
- Generator + `--check` pattern: `scripts/coverage/diff.mjs` (similar diff-against-committed-output pattern)
- ESLint rule shape: `packages/core-eslint/rules/no-undeclared-audit.{js,test.js}`
- Background job in `core-shared`: existing `packages/core-shared/src/jobs/payload-job-queue.{ts,test.ts}`
- Ambient TypeScript module augmentation: existing patterns in `node_modules/@types/*` (search for `declare module`)
## Open questions
- **Q1: Should `retention-must-be-declared` ESLint rule join `pii-declaration-must-be-complete`?** — Recommended: **No, defer.** Q6 already establishes that every existing template collection gets `custom.retention`; making it ESLint-enforced for _all_ Payload collections in _all_ downstream consumers is stricter than the strategy ADR. Add the rule in a follow-up PRD if a consumer feels the pain.
- **Q2: How does `from-last-access` retention trigger interact with Payload — does Payload track `updatedAt` natively?** — Payload sets `createdAt` and `updatedAt` by default. `from-last-access` reads `updatedAt`. If a consumer needs true "last read" tracking (rare), they add a custom hook updating `lastAccessedAt`; the purge job uses that field via `custom.retention.lastAccessFieldOverride` (deferred — not in Epic A).
- **Q3: Should the generator emit `compliance/manifest.lock.yml` containing a hash of source declarations, for fast `--check` mode?** — Recommended: **No, defer.** Full regenerate-and-diff is fast enough (~50ms for the template's 6 collections). Reconsider if generators grow slow on a real consumer with hundreds of collections.
- **Q4: How does the purge job handle race conditions between concurrent purge runs (e.g., misconfigured cron firing daily and weekly simultaneously)?** — Recommended: per-job advisory lock via Payload's job system (existing `IJobQueue` should support this); test for it in the job's unit suite. If not supported, document as a known limitation in the consumer's runbook.
- **Q5: Does the pre-commit hook trigger on `compliance/*.yml` edits themselves (the auto-stage behavior could feedback-loop)?** — Recommended: **Yes, conditionally.** The hook runs `emit-all` when `compliance/*.yml` is staged because that's the manual-edit case; the generator regenerates the YAML, auto-stages the regenerated version, replacing the developer's manual edit. The developer sees this in their `git status` post-commit and can re-edit if they had intent. Avoids drift via manual edit silently surviving.
## Out of scope (deferred)
- DSR scaffold and consent abstraction (Epic B)
- Security headers + rate-limit + SBOM (Epic C)
- Compliance fill-in docs (Epic D)
- `retention-must-be-declared` ESLint rule (see Q1 above)
- `lastAccessedAt` field hook + true "from-last-access" retention (see Q2)
- `compliance/manifest.lock.yml` for faster `--check` (see Q3)
- Backfill of any Payload `auth: true` collection beyond `users` (none exist yet)
- Cross-region transfer documentation (DPIA / TIA) — Epic D's territory
- Migration tooling for downstream consumers upgrading from a pre-ADR-025 version of the template (the template hasn't been versioned with consumers yet; no migration needed)
## Further notes
- **Builds on:** ADR-018 (audit channel — purge emits audit entries), ADR-022 (library evaluation policy — extended for sub-processor fields), ADR-023 (CI security + supply chain — pre-commit + CI integration follows the established pattern), ADR-025 (strategy umbrella).
- **Pairs with:** Epic B PRD `dsr-consent-and-cookie-banner.prd.md` (consumes A's PII tags for DSR cascade); Epic D PRD `compliance-docs-scaffolds.prd.md` (references A's generator output formats in `.example.yml` files).
- **Sequencing:** Epic A is the dependency-graph root for Epic B. Epic B's PRD will be authored once Epic A's stories are at least partially dispatched (decomposer needs A's type primitives to settle B's interfaces).
- **Stakeholders:** template authors (most affected — three new generators, ESLint rule, ADR-022 amendment), downstream consumers (positively affected — gain three compliance artifacts), AI agents operating in feature code (positively affected — typed Payload custom config catches invalid declarations at compile time), compliance reviewers (positively affected — `compliance/` directory becomes audit evidence).

View File

@@ -1,280 +0,0 @@
---
id: coverage-architecture
title: Agent-first coverage architecture (4 layers + manifest-driven thresholds)
type: prd
status: shipped
author: danijel
elicitation-session: brainstorm-2026-05-13
created: 2026-05-13T00:00:00Z
updated: 2026-05-14T19:16:52.691Z
shipped: 2026-05-13
shipping-commits:
- 7eb783a (PRD)
- 4dce1df (ADR-020 + glossary + hook)
- f7baa8b (manifest schema + helper + auth)
- 412d994 (L1 coverage:diff)
- bd5a077 (L2 coverage:aggregate)
- 39e33eb (CI integration)
- 15db9c4 (helper rollout blog + marketing-pages)
- f4254aa (cookbook guide + generator)
- 6428f10 (L3 Stryker mutation)
- bf0b049 (L0 unification — all 5 features green)
---
## Problem
The template enforces "every use case has a test file" via the `usecase-must-have-test-file` ESLint rule (structural) and declares per-layer coverage thresholds in each feature's `vitest.config.ts` (100% on entities/use-cases/controllers; 80/75/80/80 baseline). But:
- **Agents can ship slices that don't actually exercise the new code.** The ESLint rule only checks file presence; coverage % checks what executed. There's no gate that says "this PR's diff was tested."
- **The declared 100%-on-critical-layers thresholds may be aspirational, not enforced.** Their actual green/red state is unverified. CI uploads `**/coverage/lcov.info` as an artifact but doesn't gate on it.
- **There's no aggregate visibility.** No merged report, no trend, no "is the codebase covered well right now?" answer.
- **100% coverage with weak assertions is invisible.** Tests that import the SUT but barely assert anything pass coverage. The third dimension of test quality — "would my test catch a real regression?" — isn't measured.
- **Coverage expectations live in 5 separate vitest configs.** Drift across features is easy and hard to spot.
For an agent-first template where most code is authored by AI agents in vertical slices, this is the single biggest gap between "feature shipped" and "feature shipped safely."
## Goal
Establish a 4-layer coverage architecture that mirrors the existing 5-gate conformance philosophy (multi-latency, machine-readable, agent-first) and makes coverage a first-class conformance signal driven from each feature's `feature.manifest.ts`.
## In scope
- A `coverage:` section in `feature.manifest.ts` as the single source of truth for per-layer expectations
- Vitest config auto-derives test-time thresholds from the manifest
- `pnpm coverage:diff` script — cover-the-diff gate (changed lines must be exercised), machine-readable output for the dispatch loop
- `pnpm coverage:aggregate` script — merges per-package lcov into a root `coverage/lcov.info` + grep-able `coverage/summary.json`
- `coverage/summary.json` committed per merge; trend readable from `git log`
- `pnpm mutate` — Stryker on `entities/` + `application/use-cases/` only, on-demand (not part of `pnpm test`)
- `assertFeatureConformance` reads the manifest's coverage band and the package's lcov at boot; fails if drift
- CI gate: `pnpm test --coverage` (existing) + `pnpm coverage:diff` (new) + `pnpm coverage:aggregate` (new)
- ADR-020 capturing the architecture
- `docs/guides/coverage.md` cookbook
- Glossary entries for new vocabulary
- Generator update — `pnpm turbo gen feature` scaffolds the `coverage:` manifest section
- `.claude/hooks/prompt-context.sh` detects coverage-related prompts and injects pointers
## Out of scope
- **Codecov / SaaS dashboards.** Aggregate trend ships as committed `coverage/summary.json` only. SaaS can be a later ADR.
- **Coverage badges or PR comments.** Optional follow-up if humans want them; agents don't.
- **Mutation testing on infrastructure / repositories / controllers** — entities + use-cases only for v1. Wider scope is a future epic.
- **Branch coverage on `__seeds__/` / `__factories__/` / `__contracts__/`** — already excluded from coverage; stays out.
- **Coverage for `apps/`** — they have their own existing thresholds; not part of this initiative.
- **Test quality beyond coverage + mutation** — property-based tests, fuzzing, etc. are separate concerns.
## Constraints
- ADR-014, ADR-017 (instrumentation): coverage instrumentation must not interfere with span/log collection.
- ADR-011 (TDD foundation): the declared per-layer thresholds (entities/use-cases/controllers at 100%; baseline 80/75/80/80) are existing decisions; we honor them and centralize their declaration.
- The conformance ESLint rules are AST-time + filesystem; coverage assertions are runtime data — they must remain in separate gates.
- Generator-first is non-negotiable: any new file added to a feature must come from `pnpm turbo gen feature` or a sibling generator.
- `pnpm conformance` and `pnpm test` already take ~120s and ~90s respectively; new gates must not add more than ~30s wall time to the default loop.
- Agent dispatch (`pnpm work dispatch`) must be able to read coverage results as JSON without a network call.
## Success criteria
- `pnpm test --coverage` passes green across all five feature packages (verifies L0 baseline is real, not aspirational).
- `pnpm coverage:diff` exits non-zero when a changed line is uncovered; outputs JSON to stdout listing each uncovered hunk with file + line range.
- `pnpm coverage:aggregate` produces `coverage/lcov.info` + `coverage/summary.json` at the repo root; both readable in <1s.
- `coverage/summary.json` is committed and `git log -- coverage/summary.json` shows trend over time.
- `pnpm mutate --filter <feature>` runs Stryker on entities + use-cases; produces a per-feature mutation score.
- `feature.manifest.ts` includes `coverage: { ... }`; removing or weakening a band fails `pnpm dev` boot via `assertFeatureConformance`.
- CI fails when (a) a per-layer threshold breach happens, (b) any changed line is uncovered, (c) the aggregate report can't be produced.
- ADR-020 + `docs/guides/coverage.md` + glossary entries land alongside implementation.
- `pnpm turbo gen feature` emits a manifest with the `coverage:` section pre-populated to defaults.
## User stories
1. As an **AI implementer agent**, I want `pnpm coverage:diff` to exit with a precise JSON list of uncovered hunks, so that I can immediately add the missing test without searching.
2. As an **AI implementer agent**, I want `pnpm dev` to refuse to boot if I've authored a manifest entry without backing tests, so that I cannot ship an untested slice.
3. As an **AI reviewer agent**, I want the dispatch loop to surface coverage drift as part of its post-task verification, so that I can flag the slice for revision.
4. As a **human reviewer**, I want `coverage/summary.json` committed on merge, so that I can see coverage trend via `git log` without leaving the repo.
5. As a **template maintainer**, I want each feature's `feature.manifest.ts` to declare its coverage band, so that I have one place to read or change expectations.
6. As a **template maintainer**, I want `pnpm mutate` to surface tests that don't actually assert, so that 100% line coverage can't paper over weak tests.
7. As a **template adopter**, I want `pnpm turbo gen feature` to scaffold the `coverage:` manifest section with sensible defaults, so that I don't have to remember the shape.
8. As a **future agent in a session**, I want the prompt-context hook to inject coverage pointers when I mention "coverage" or "uncovered" in a prompt, so that the relevant ADR + guide load automatically.
9. As an **on-call engineer**, I want `coverage/summary.json` to include a timestamp + commit SHA, so that I can correlate coverage state with deploys.
10. As an **AI agent receiving a handoff**, I want the coverage state of the in-flight slice to be readable from a known path, so that I can continue without re-running the suite.
## Implementation decisions
### Architecture: 4 layers, mirroring the 5-gate conformance philosophy
| Layer | What it catches | Latency | Runs in |
| ---------------------------------- | ----------------------------------------------------- | ------------------ | ------------------------------------------------------------ |
| **L0** Per-layer vitest thresholds | Drift below declared bands (e.g., entities < 100%) | ~530s per package | `pnpm test --coverage` (existing) |
| **L1** Diff coverage | "Changed line was not exercised" | ~5s after L0 | `pnpm coverage:diff`; CI gate; dispatch post-task |
| **L2** Aggregate trend | "Codebase coverage drifted over time" | ~10s | `pnpm coverage:aggregate`; committed `coverage/summary.json` |
| **L3** Mutation testing | "Test exists, executes the code, but asserts nothing" | Minutes | `pnpm mutate`; on-demand; nightly GH Action |
### Manifest-driven coverage band (the keystone)
A `coverage:` section in `feature.manifest.ts` is the single source of truth. Default scaffolded shape:
- `entities`: 100/100/100/100
- `use-cases`: 100/95/100/100
- `controllers`: 100/95/100/100
- `baseline`: 80/75/80/80
- `mutationTargets`: `["entities", "use-cases"]`
Three readers:
1. **Vitest** `vitest.config.ts` imports the manifest and emits its `coverage.thresholds`. Eliminates the duplication today.
2. **`assertFeatureConformance`** reads `coverage/lcov.info` for the package at boot, asserts each band. Fails to boot on drift (matching the existing brand-assertion shape).
3. **`pnpm coverage:diff`** uses `baseline` as the default expectation for non-layer-tagged files; stricter bands override per-path.
### Diff coverage algorithm
`pnpm coverage:diff [<base-ref>]` (default `origin/main...HEAD`):
1. Run `git diff --unified=0 --no-color <base>...HEAD` list of `(file, hunk-start, hunk-end)` for changed/added lines.
2. Read merged `coverage/lcov.info` executable-line + execution-count per file.
3. For each changed line that is _executable_ (skip comments, types, exports, declarations): assert execution count > 0.
4. Output stdout JSON `{ status: "pass" | "fail", uncovered: [{ file, line, kind }] }`.
5. Output stderr human-readable summary.
6. Exit 0 on pass, 1 on fail.
Filename allowlist (don't gate on diff coverage): `*.test.ts`, `*.test.tsx`, `*.config.*`, `*.md`, `*.json`, `*.mjs`, `*.cjs`. Same exclude list as L0's existing per-package coverage excludes (DI bootstrap, interfaces, CMS, UI, factories, contracts).
### Aggregate report
`pnpm coverage:aggregate`:
1. Collect every `packages/**/coverage/lcov.info` + `apps/**/coverage/lcov.info`.
2. Merge into `coverage/lcov.info` via `lcov-result-merger` or `monocart-coverage-reports`.
3. Emit `coverage/summary.json`:
```json
{
"generatedAt": "2026-05-13T12:34:56Z",
"commit": "abc1234",
"repo": { "statements": 87.4, "branches": 81.2, "functions": 89.0, "lines": 87.4 },
"byPackage": {
"@repo/auth": { "statements": 96.1, ... },
...
}
}
```
4. Re-render HTML at `coverage/html/index.html` for human drill-down (gitignored).
`coverage/summary.json` is committed; `coverage/lcov.info` + `coverage/html/` are gitignored.
### Mutation testing
`pnpm mutate [--filter @repo/<feature>]`:
- Uses Stryker with `@stryker-mutator/vitest-runner`.
- Per-feature `stryker.config.json` scaffolded by the generator.
- Mutation scope honors the manifest's `mutationTargets` (entities + use-cases by default).
- Output: `reports/mutation/<feature>/` (HTML + JSON; gitignored).
- Mutation score threshold: 80% per feature (tunable in manifest); not enforced in default `pnpm test`.
- Optional nightly GH Action: `mutation-nightly.yml` — runs across all features, opens an issue on score drop > 5%.
### Boot-time assertion
`assertFeatureConformance` (in `core-shared/conformance/`) gains a new check:
1. Look for `coverage/lcov.info` at the feature package root.
2. If absent: skip with a `logger.debug(...)` (dev mode is allowed to lack coverage data).
3. If present: parse, compute per-layer % for `entities/`, `application/use-cases/`, `interface-adapters/controllers/`.
4. Compare against the manifest's `coverage:` bands. Fail boot on breach with a `CoverageDriftError`.
Graceful degradation in dev (`USE_DEV_SEED=true`): assertion logs warning instead of throwing, so `pnpm dev` boots without a fresh coverage run.
### CI integration
`.github/workflows/ci.yml` gains three steps after the existing test step:
1. `pnpm coverage:aggregate` (always)
2. `pnpm coverage:diff` (always; fails build on uncovered diff)
3. Upload `coverage/lcov.info` + `coverage/summary.json` as artifact (existing flow)
4. On merge to main only: commit `coverage/summary.json` back to the repo (separate workflow with `permissions: contents: write`)
### Generator integration
`pnpm turbo gen feature` template emits:
- Manifest with `coverage: { ... defaults ... }` section at a `<gen:coverage>` anchor
- `vitest.config.ts` that imports the manifest and derives `coverage.thresholds` from it
- `stryker.config.json` at the package root, scoped to `entities/` + `application/use-cases/`
The CI guard at `packages/core-eslint/anchors.test.js` adds the new anchor.
### Hook integration
`.claude/hooks/prompt-context.sh` gains a new keyword group:
```
if echo "$prompt" | grep -qE 'coverage|uncovered|mutation|stryker|lcov'; then
inject+=('Coverage: ADR-020 + docs/guides/coverage.md. 4 layers: L0 vitest thresholds, L1 pnpm coverage:diff, L2 coverage/summary.json, L3 pnpm mutate. Manifest-driven via feature.manifest.ts coverage section.')
fi
```
## Testing decisions
A good test for this initiative covers behavior through the public surface:
- The diff-coverage script gets tested by feeding it a synthetic `git diff` + `lcov.info` and asserting JSON output shape and exit code. Fixtures, not e2e.
- The aggregate script gets unit-tested by feeding it N synthetic per-package lcov files and asserting the merged output structure.
- `assertFeatureConformance` gets a new test in `core-shared/conformance/` that constructs a manifest + a `coverage/lcov.info` fixture and asserts pass/fail behavior. No real test run.
- Stryker integration gets an integration-test style verification: run `pnpm mutate --filter @repo/auth` in CI nightly and assert mutation score > 80% on a known-good commit.
- The manifest schema gets a Zod schema test: invalid `coverage:` shapes fail parse with a specific error message.
**Modules to add test coverage to:**
- `scripts/coverage/diff.mjs` (the diff coverage runner)
- `scripts/coverage/aggregate.mjs` (the merger)
- `packages/core-shared/src/conformance/assert-coverage.ts` (the boot-time reader + comparator)
- `packages/core-shared/src/conformance/manifest-schema.ts` (extended Zod schema)
**Prior art to mirror:**
- `scripts/work/state-builder.mjs` — for the diff-coverage script's shape (Node ESM, no deps, fixture-based test)
- `packages/core-eslint/anchors.test.js` — for the new anchor's CI guard pattern
- Existing `assertFeatureConformance` brand-assertion code — for the boot-time check shape
## Open questions
- **Q1: Mutation score threshold per feature** — start at 80% across the board, or tune per-feature in the manifest? **Recommended:** start at 80% baseline, allow per-feature override via `coverage.mutationThreshold`.
- **Q2: When `coverage/lcov.info` is stale at boot** — fail loudly or warn? **Recommended:** warn in dev, fail in `NODE_ENV=production` boot.
- **Q3: Should `pnpm coverage:diff` run automatically as a Git pre-push hook?** **Recommended:** no — pre-commit already runs; pre-push adds latency without proportional value. Keep it CI-only + dispatch-loop.
- **Q4: How is `coverage/summary.json` committed safely on merge to main?** **Recommended:** separate workflow with a github-actions[bot] identity, `permissions: contents: write`, skipped if no diff in summary.
- **Q5: `monocart-coverage-reports` vs. `lcov-result-merger`?** **Recommended:** monocart — actively maintained, single-binary, handles V8 + Istanbul, no Python dep.
## Out of scope (deferred to future PRDs)
- **Codecov / SaaS dashboards.** Aggregate is committed; SaaS is gold-plating.
- **Mutation testing on `infrastructure/`, `interface-adapters/`, `integrations/`.** Bigger surface, slower runs; revisit after L3 v1 is stable.
- **Coverage-aware Storybook screenshot diffing.** Visual regression is its own concern.
- **Code review heuristics from coverage** (e.g., "this PR touched a 100%-covered file and dropped to 95% — needs review tag"). Possible follow-up via fallow audit.
## L0 verification findings (2026-05-13, during brainstorm)
Ran `pnpm --filter @repo/<x> test -- --coverage --run` per feature. State of the declared 100%-on-entities/use-cases/controllers:
| Feature | State | Detail |
| ----------------------- | -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `@repo/auth` | ✅ green | 21 tests, 93.7% overall, all per-layer bands hit 100% |
| `@repo/blog` | ✅ green | passed (no threshold errors surfaced) |
| `@repo/marketing-pages` | ✅ green | passed |
| `@repo/navigation` | ❌ real gap | `entities/`: 86.36% lines / 50% functions; `controllers/`: 86.66% lines / 80% branches |
| `@repo/media` | ❌ config + real gap | (1) Missing `@vitest/coverage-v8` dev dep — `--coverage` crashed. (2) `vitest.config.ts` had NO coverage block at all (no per-layer thresholds, no excludes). When the standard block was applied, real gaps surfaced in `controllers/` (one controller at 86.66% lines / 75% branches, lines 19-20 uncovered). |
**Conclusions reinforcing the PRD:**
- The L0 layer is real (auth/blog/marketing-pages prove the 100%/100%/95%/100% bar is achievable).
- The duplication problem is real (5 features, 4 different vitest configs, one entirely absent the coverage block — exactly the drift the manifest-driven keystone eliminates).
- Real test gaps exist in 2 of 5 features (navigation, media). Fixing them is part of the implementation epic, not a blocker for this design landing.
**Patches landed alongside the PRD (non-blocking for CI):**
- `packages/media/package.json` — added `@vitest/coverage-v8` dev dep so `pnpm test -- --coverage` no longer crashes.
- `packages/media/vitest.config.ts` — left intentionally minimal (no coverage block); commented to point at the L0 unification story.
The full per-layer block for media + the missing tests in navigation + media land in the **L0 unification** story of the implementation epic, as the first work item after the manifest schema lands.
## Further notes
- Builds on: ADR-011 (TDD foundation), ADR-006 (vertical-feature-packages), the existing 5-gate conformance system.
- ADR-020 is the durable record of the architecture decisions; this PRD is the implementation seed.
- Each layer should land as a separate story (L0 unification, manifest schema + auto-derive, L1 diff, L2 aggregate, L3 mutation, ADR + docs). Estimated effort: one mid-sized epic, 68 stories.
- This is the canonical example of "agent-first observability": every layer optimized for machine consumption first, human consumption second.

View File

@@ -1,488 +0,0 @@
---
id: dsr-consent-and-cookie-banner
title: DSR + consent abstraction + cookie consent banner — Epic B of ADR-025
type: prd
status: approved
author: danijel
created: 2026-05-19T09:36:42Z
updated: 2026-05-19T09:41:27.097Z
---
## Problem
Epic A (compliance manifests) shipped declarative PII inventory + retention + sub-processors. A consumer can now answer "what personal data does this system hold?" via `compliance/data-map.yml`. They cannot yet answer "how does a user exercise their GDPR rights against that data?" or "did this user consent to marketing emails?" or "where's the cookie banner that asks?"
Concretely, three load-bearing surfaces remain missing:
- **Data Subject Rights (DSR) endpoints** — every EU-bound consumer needs `/api/gdpr/{export,delete,rectify,restrict}` to satisfy GDPR Arts. 15, 16, 17, 18, 20. Without them, the consumer either reinvents the cascade walk over their Payload collections (high failure rate — easy to miss a collection or fail to redact one subject in a multi-subject row) or admin-mediates every request manually (slow, doesn't scale, audit-fragile).
- **Consent abstraction** — analytics gating (`analytics.track` from ADR-024) currently has no consent check. Every consumer who ships analytics ends up adding a homegrown consent flag, with no audit trail proving consent was given. Art. 7 requires demonstrable consent; today's template can't demonstrate it.
- **Cookie consent UI** — no template surface for first-visit consent collection. Every consumer reinvents the banner, and getting Reject All / Accept All equally prominent (CNIL guidance, EDPB Art. 7 interpretation) is non-obvious. Compliance failures here are the most visible regulator-facing surface.
ADR-025 settled the strategy: two new optional cores (`@repo/core-dsr`, `@repo/core-consent`) + cookie banner in `core-ui`. This PRD is the implementation seed for Epic B.
## Goal
Ship the user-rights surface end-to-end so a downstream consumer can: (1) expose DSR endpoints that walk Epic A's PII tags to export/delete/rectify/restrict any subject's data, (2) declare per-use-case consent requirements that gate analytics + marketing emission with audit-logged proof, (3) drop in a compliant cookie consent banner with EU-prominence defaults. Epic A's PII + retention machinery powers DSR cascade; Epic B's consent gates close the analytics PII loop.
## In scope
### `@repo/core-dsr` (new optional core)
- Scaffolded via `pnpm turbo gen core-package dsr`
- Four interfaces:
- `IDataExport.exportSubjectData(subjectId, format: "json" | "json-ld"): Promise<UserDataBundle>` — covers Arts. 15 + 20
- `IDataDelete.deleteSubjectData(subjectId, mode: "soft" | "cascade-hard"): Promise<DeletionCertificate>` — Art. 17
- `IDataRectify.updateSubjectField(subjectId, collection, field, value): Promise<void>` — Art. 16
- `IProcessingRestriction.{setRestriction, isRestricted}` — Art. 18
- Payload-backed reference impls walking Epic A's `custom.pii` tags and the new collection-level `custom.subject` linkage
- Protocol-agnostic handlers in `core-dsr/handlers/` returning normalized `{ status, body, headers }`
- tRPC router `core-dsr/dsr.router.ts` consumed by `core-api`'s appRouter
- Subject-linkage TypeScript types in `core-shared/payload/`: `SubjectLink`, `SubjectLinkRole`, `CollectionSubject`
- Ambient declaration extending Payload's `CollectionConfig.custom?` with `subject?: CollectionSubject | CollectionSubject[]`
- Default schema.org JSON-LD `@context` shipped at `core-dsr/contexts/user-data.jsonld`; consumer-overridable
- `DeletionCertificate` type derived from audit entry; returned to caller, not separately persisted
### `@repo/core-consent` (new optional core)
- Scaffolded via `pnpm turbo gen core-package consent`
- Interface:
- `IConsent.{isGranted, grant, withdraw, getCategories}` per the ADR-025 shape
- `ConsentCategory` string-literal union with escape hatch (matches `PiiCategory` pattern): `"essential" | "functional" | "analytics" | "marketing" | (string & Record<never, never>)`
- `withConsent` wrapper attaching `ConsentChecked` brand at DI bind time (passive — runtime checks live in use case body)
- `requiresConsent: ConsentCategory[]` per-use-case manifest field; cross-checked by new ESLint rule `no-undeclared-consent-check`
- `assertFeatureConformance` extended to require `ConsentChecked` brand when `requiresConsent.length > 0`
- Hybrid storage: `users.consentState: UserConsentState` field (fast `isGranted` reads) + `core-audit` `CONSENT_GRANT`/`CONSENT_WITHDRAW` action entries (legal proof history)
- Anonymous → authenticated migration helper `extractAnonymousConsent(cookieHeader)` + `migrateAnonymousConsent({ userId, cookieState })`
- Protocol-agnostic handlers in `core-consent/handlers/`
- tRPC router `core-consent/consent.router.ts` consumed by `core-api`'s appRouter
- React hook `useConsent()` + `<ConsentProvider>` in `core-consent/react`
### Cookie consent banner in `@repo/core-ui`
- **Precondition**: `pnpm turbo gen core-package ui` (core-ui not yet scaffolded — directory exists but empty)
- `<CookieConsentBanner>` headless component with default UI:
- `variant: "modal" | "banner"` prop, default `"modal"`
- Granular category toggles (essential/functional/analytics/marketing by default; consumer extends)
- Equal-prominence Reject All / Accept All buttons (CNIL + EDPB compliance baked into default visual treatment)
- Render-prop overrides for `renderCategoryRow` / `renderActions` / `renderHeader` (default UI works out-of-box; consumer surgically overrides for branding/legal text)
- Reads `IConsent` via `useConsent()` hook from `core-consent/react`
- Manages pre-signup state in `__consent_state` cookie; emits state-changed callback for analytics
- Storybook story doubles as the human-reading-room for compliant cookie UX
### Conformance + manifest changes
- New manifest field per use case: `requiresConsent: ConsentCategory[]`
- New ESLint rule `no-undeclared-consent-check` at warn severity
- `withConsent` wrapper composes innermost — order: `withSpan ⟶ withCapture ⟶ withAudit ⟶ withAnalytics ⟶ withConsent ⟶ factory(deps)`
- Conformance ESLint rule count: 11 → 12
### Subject linkage on existing collections
- `auth.users`: `custom.subject` defaults to `{ kind: "self", field: "id" }` (template ships explicit declaration for documentation clarity)
- `blog.articles`, `marketing-pages.{site-settings,pages}`, `media.media`, `navigation.header`: no subject linkage needed (no PII fields per Epic A backfill)
- Anchor for future per-feature collections that hold PII about users: documented pattern + example in `docs/compliance/subject-linkage.example.md`
### ADR amendments captured
- **ADR-018**: audit action enum gains `CONSENT_GRANT`, `CONSENT_WITHDRAW`, `RESTRICT`, `UNRESTRICT`
- **ADR-024**: `analytics.track` call sites in feature use cases gain a "check consent first" idiom; existing analytics manifest stays unchanged (no `analyticsEvents` re-declaration)
- **`PAYLOAD_AUTH_PII_DEFAULTS`** (from Epic A) gains two excluded fields: `processingRestrictedAt`, `consentState`
### Documentation
- `docs/guides/dsr.md` — full DSR cookbook (interfaces, route wiring, multi-subject handling, soft vs hard delete, certificate format)
- `docs/guides/consent.md` — consent flow cookbook (manifest field, brand, runtime check pattern, anonymous → authenticated migration)
- `docs/glossary.md` — new entries for `SubjectLink`, `DeletionCertificate`, `UserConsentState`, `ConsentChecked` brand
- `CLAUDE.md` + `conformance-quickref.md` — rule count bump (11 → 12) + new manifest field documentation
## Out of scope
- **Pre-launch compliance checklist + fill-in templates** — Epic D
- **Security headers middleware + rate-limit primitive + SBOM** — Epic C
- **Streaming `IDataExport`** — in-memory only for first pass; streaming v2 when a consumer hits OOM
- **REST endpoints** — Epic B exposes only tRPC (matches established pattern); REST wrapping documented for regulators in Epic D
- **Per-framework router auto-wiring for cookie banner pageview reset** — banner emits a `consentChanged` callback; consumer wires their router
- **Anonymous (pre-signup) consent storage in `users.consentState`** — anonymous lives in the cookie until signup migration
- **Strict-mode `ConsentCategory` declaration merging** — string-literal-union escape hatch is sufficient (Q10 of grill)
- **`dsr_rectifications` separate audit collection** — rectifications recorded in main audit log via `reason: "art-16-request"` tag (Q4 of grill)
- **Brand-treatment of `IProcessingRestriction`** — restriction is binary + rare; consumer calls `isRestricted` where needed without a wrapper (Q5 of grill)
- **GDPR Art. 22 (automated decision-making)** — deferred per ADR-025 (no template ML; revisit when consumer adds automated decisions)
- **Cross-region transfer documentation (Schrems II / TIA)** — Epic D's territory
- **Audit-log full-text export for DSR** — included as `auditLog: AuditEntry[]` in `UserDataBundle` but filtered to subject's own events only (other-subject events stay private)
## Constraints
- **ADR-025** — Epic B strategy settled there. Surface deviations require amendment before proceeding.
- **ADR-018** — audit action enum amendment captured in this PRD; reuses existing `core-audit` channel for proof-of-consent + restriction events.
- **ADR-022** — no library-evaluation traces needed for Epic B itself (no new third-party runtime deps); `@repo/core-dsr` and `@repo/core-consent` are workspace packages.
- **ADR-024** — consent gates analytics emission via use case body checks (passive brand pattern); `analytics.track` interface itself unchanged.
- **Epic A's deliverables are dependencies**:
- `custom.pii` tags drive DSR's PII-walking cascade
- `PAYLOAD_AUTH_PII_DEFAULTS` extension pattern is reused for `processingRestrictedAt` + `consentState`
- `compliance/data-map.yml` generator output documents what DSR exports
- **Generator-first** — both new optional cores scaffolded via `pnpm turbo gen core-package <name>`. `core-ui` also requires scaffold (precondition).
- **Manifest-first ordering** — `ConsentCategory` types + `requiresConsent` manifest schema land first; ESLint rule second; runtime wrappers third; use case body migrations last.
- **`core-shared` boundary** — subject-linkage types live in `core-shared/payload/` (must-have, every consumer needs the type to read others' collection configs). DSR + consent interfaces stay in their optional cores.
- **Optional cores composition** — `core-api`'s appRouter composes the new tRPC routers via the existing `<gen:*>` anchor pattern.
- **Consent is template-policy-neutral** — template ships 4 default categories; consumer adds whatever else they need. No template-imposed legal opinion about what requires consent (consumer's DPO decides).
- **No `--no-verify`** — every commit passes pre-commit gates; Conventional Commits non-negotiable.
## Success criteria
- `pnpm turbo gen core-package dsr` and `pnpm turbo gen core-package consent` produce green packages with the documented interfaces.
- `pnpm turbo gen core-package ui` produces a green `core-ui` package; `<CookieConsentBanner>` lands in it.
- `IDataExport.exportSubjectData("alice", "json")` walks the `users` collection + any `custom.subject`-linked collections, returning a `UserDataBundle` with `data.users.asSelf` containing Alice's row and `data.<other>.asReference` for any rows that linked-field her.
- `IDataDelete.deleteSubjectData("alice", "soft")` flips `processingRestrictedAt`, NULLs exportable PII on Alice's `users` row, redacts `assignedTo`-style reference rows to NULL, emits one audit entry per affected collection, returns a `DeletionCertificate`.
- 30 days after a soft-delete, Epic A's retention purge job hard-deletes the row via its existing schedule (no new code in Epic B for this — verifies Epic A's interaction).
- `IConsent.grant("alice", ["analytics"], { method: "banner", bannerVersion: "v1" })` writes a `CONSENT_GRANT` audit entry AND updates `users.consentState.analytics.granted = true`.
- `IConsent.isGranted("alice", "analytics")` returns `true` after the above (reads the cache field).
- A use case declaring `requiresConsent: ["analytics"]` but missing the `withConsent` wrapper at bind time fails `assertFeatureConformance` boot check + the `no-undeclared-consent-check` ESLint rule.
- A `consent.isGranted(_, "marketing")` call site in a use case whose manifest doesn't declare `"marketing"` in `requiresConsent` fires the ESLint rule.
- `<CookieConsentBanner variant="modal">` renders with Reject All / Accept All as equally-sized side-by-side buttons; tab order treats them equally; ARIA labels mirror.
- Anonymous user grants consent via banner → cookie stored → signs up → consumer's `signUp` use case calls `migrateAnonymousConsent` → cookie state lands in `users.consentState` + audit emits `CONSENT_GRANT` with `method: "signup-migration"`.
- tRPC routers `dsrRouter` + `consentRouter` compose into `core-api`'s appRouter without manual wiring (anchor + barrel pattern).
- `pnpm typecheck && pnpm lint && pnpm test && pnpm conformance && pnpm fallow:audit && pnpm coverage:diff && pnpm compliance:emit-all --check` all green at every commit boundary.
- `docs/guides/dsr.md` + `docs/guides/consent.md` cover the consumer wiring paths end-to-end including the anonymous → authenticated migration.
## User stories
1. As a **downstream consumer**, I want `/api/gdpr/export` to walk every PII-tagged collection and return my user's data in JSON so I satisfy Art. 15 without writing custom collection traversal.
2. As a **downstream consumer**, I want `/api/gdpr/delete` to soft-delete a user (immediate flag, 30-day grace, hard-delete via Epic A's purge) so Art. 17 fulfillment is automated.
3. As a **downstream consumer**, I want multi-subject rows (e.g. a support ticket with submitter + assignee) to have their linked PII redacted but the row preserved when one subject requests deletion, so the other subject's data remains intact.
4. As a **downstream consumer running multi-tenant**, I want `custom.subject` to be declarable per-collection so I model my domain's subject relationships explicitly, not assume one `userId` column.
5. As a **downstream consumer**, I want JSON-LD export with schema.org `@context` so a user can port their data to another service (Art. 20) without me writing a portability mapping.
6. As a **downstream consumer**, I want `processing_restricted` to be honored across all my normal use cases via `IProcessingRestriction.isRestricted` checks so Art. 18 enforcement is mechanical.
7. As a **downstream consumer shipping analytics**, I want `requiresConsent: ["analytics"]` on my manifest to enforce that the use case can't bind without consent-checking wrapper, so Art. 7 demonstrable consent is structurally enforced.
8. As a **downstream consumer**, I want `IConsent.grant` to write both the fast-read cache and an audit entry so I have legal proof of consent without slow audit-log queries on every analytics call.
9. As a **downstream consumer**, I want a cookie consent banner with EU-compliant default visual treatment (equal-prominence reject/accept) so I don't accidentally ship a CNIL-violating UX.
10. As a **downstream consumer**, I want the banner to be a headless component with render-prop overrides so I brand the visuals without forking the legal-compliance logic.
11. As an **anonymous user**, I want my consent choices to persist into my account at signup so I don't have to re-consent.
12. As a **compliance reviewer**, I want every consent grant/withdrawal to leave an immutable audit entry recording timestamp, banner version, policy version, and method so I can prove demonstrable consent to a regulator.
13. As an **AI agent** modifying a use case that calls `analytics.track`, I want `no-undeclared-consent-check` to fire when I call `consent.isGranted("X")` without declaring `"X"` in `requiresConsent`, so consent-event drift is caught at lint time.
14. As an **AI agent** scaffolding a new feature, I want `pnpm turbo gen feature` to emit `requiresConsent: []` by default so I declare consent requirements during manifest-first ordering, not as an afterthought.
15. As a **DPO doing internal audit**, I want `compliance/data-map.yml` (from Epic A) and the DSR endpoint mapping (from this Epic) to together answer "what data do we hold + how does a subject act on it" without me reading code.
## Implementation decisions
### Module surface
- **`@repo/core-shared` modifications** (must-have package):
- New module `core-shared/payload/subject-linkage-types.ts` exporting `SubjectLinkKind` (`"self" | "owner" | "reference"`), `SubjectLink`, `CollectionSubject` (single or array form)
- Ambient declaration extending Payload `CollectionConfig.custom?` with `subject?: CollectionSubject | CollectionSubject[]`
- Extension to `PAYLOAD_AUTH_PII_DEFAULTS` (Epic A's auth-managed exclusions): add `processingRestrictedAt` + `consentState` as excluded (security/control material, not PII-export)
- Audit action enum (in `core-shared/audit`, used by `core-audit`) gains: `CONSENT_GRANT`, `CONSENT_WITHDRAW`, `RESTRICT`, `UNRESTRICT`
- **`@repo/core-audit` modifications**:
- `IAuditLog.record` accepts the new action types via the extended enum
- No new interface methods; existing `eraseSubject` flow handles post-DSR-delete pseudonymization
- **`@repo/core-dsr` (new optional core)**:
- Four interfaces in `core-dsr/<interface>.interface.ts`
- Payload-backed reference impls: `PayloadDataExport`, `PayloadDataDelete`, `PayloadDataRectify`, `PayloadProcessingRestriction`
- Recording test doubles in `core-testing`
- Protocol-agnostic handlers in `core-dsr/handlers/{export,delete,rectify,restrict}-handler.ts`
- tRPC router `core-dsr/dsr.router.ts` exporting `dsrRouter`
- JSON-LD context at `core-dsr/contexts/user-data.jsonld` (schema.org-derived)
- DI binders `core-dsr/di/{bind-production,bind-dev-seed}.ts`
- **`@repo/core-consent` (new optional core)**:
- `IConsent` interface in `core-consent/consent.interface.ts`
- `ConsentCategory` + `ConsentState` + `UserConsentState` types in `core-consent/consent-types.ts`
- `withConsent` wrapper attaching `ConsentChecked` brand at bind time
- `ConsentChecked` brand definition added to `core-shared/conformance/brands.ts`
- Payload-backed reference impl `PayloadConsent` (reads/writes `users.consentState` + emits `CONSENT_*` audit entries via injected `core-audit`)
- Recording test double in `core-testing`
- Anonymous migration helpers `extractAnonymousConsent` + `migrateAnonymousConsent`
- Protocol-agnostic handlers in `core-consent/handlers/`
- tRPC router `core-consent/consent.router.ts` exporting `consentRouter`
- React subpath `core-consent/react` with `<ConsentProvider>` + `useConsent()` hook
- DI binders `core-consent/di/{bind-production,bind-dev-seed}.ts`
- **`@repo/core-ui` (new optional core — precondition scaffold)**:
- `pnpm turbo gen core-package ui` lands the package shell first
- `<CookieConsentBanner>` component
- Default render-prop slots: `renderHeader`, `renderCategoryRow`, `renderActions`
- `__consent_state` cookie management (read/write/clear) inside the component for anonymous flow
- Storybook story + accessibility tests (axe-core)
- **`@repo/core-api` modifications**:
- appRouter composes `dsrRouter` + `consentRouter` via existing `<gen:*>` anchor pattern
- **`@repo/core-eslint`**:
- New rule `no-undeclared-consent-check` (warn severity)
- `_manifest-ast.js` parser gains `requiresConsent` field extraction (parallel to `requiresCores`)
- **`@repo/core-testing`**:
- `RecordingDataExport`, `RecordingDataDelete`, `RecordingDataRectify`, `RecordingProcessingRestriction`, `RecordingConsent` test doubles
- **`packages/auth/` modifications**:
- `users` collection: explicit `custom.subject = { kind: "self", field: "id" }` declaration
- Optional `signUp` use case extension (in template's own auth feature): call `migrateAnonymousConsent` when a cookie is present
- **Conformance ESLint rules**: count 11 → 12 (`no-undeclared-consent-check`)
- **Brand composition**: order extended to `withSpan ⟶ withCapture ⟶ withAudit ⟶ withAnalytics ⟶ withConsent ⟶ factory(deps)`
- **`assertFeatureConformance`**: extended brand check for `ConsentChecked` when `requiresConsent.length > 0`
### Subject linkage contract
```ts
// core-shared/payload/subject-linkage-types.ts
export type SubjectLinkKind = "self" | "owner" | "reference";
export type SubjectLink = {
field: string; // field name (or "id" for self)
kind: SubjectLinkKind;
target?: string; // target collection (required when kind !== "self")
role?: string; // e.g. "submitter", "assignee" — surfaces in DeletionCertificate
};
export type CollectionSubject = SubjectLink | SubjectLink[];
```
DSR cascade semantics by role:
| Role | `IDataExport` | `IDataDelete` mode=`soft` | `IDataDelete` mode=`cascade-hard` |
| ----------- | -------------------------------------------------------------- | ----------------------------------------------------- | -------------------------------------------- |
| `self` | include row in `asSelf` | NULL exportable fields + set `processingRestrictedAt` | hard-delete row |
| `owner` | include row in `asSelf` | NULL exportable fields | hard-delete row |
| `reference` | include `{rowId, linkedField, linkedThrough}` in `asReference` | redact the linked field only | redact the linked field only (row preserved) |
### Interface contracts
```ts
// core-dsr/data-export.interface.ts
export type UserDataBundle = {
subjectId: string;
exportedAt: string; // ISO 8601
format: "json" | "json-ld";
data: {
[collection: string]: {
asSelf?: Array<Record<string, unknown>>;
asReference?: Array<{
rowId: string;
linkedField: string;
linkedThrough: string;
}>;
};
};
auditLog?: AuditEntry[]; // filtered to subject's own events
"@context"?: string | Record<string, unknown>;
};
export interface IDataExport {
exportSubjectData(
subjectId: string,
format: "json" | "json-ld",
): Promise<UserDataBundle>;
}
// core-dsr/data-delete.interface.ts
export type DeletionCertificate = {
subjectId: string; // or "erased-{hash}" if already scrubbed
mode: "soft" | "cascade-hard";
timestamp: string;
reason: "art-17-request" | "admin-expunge" | "retention-policy";
affected: Array<{
collection: string;
rowsAffected: number;
action: "deleted" | "redacted" | "pseudonymized";
fields?: string[]; // when action === "redacted"
}>;
auditEntryId: string;
};
export interface IDataDelete {
deleteSubjectData(
subjectId: string,
mode: "soft" | "cascade-hard",
): Promise<DeletionCertificate>;
}
// core-dsr/data-rectify.interface.ts
export interface IDataRectify {
updateSubjectField(
subjectId: string,
collection: string,
field: string,
value: unknown,
): Promise<void>;
}
// core-dsr/processing-restriction.interface.ts
export interface IProcessingRestriction {
setRestriction(subjectId: string, granted: boolean): Promise<void>;
isRestricted(subjectId: string): Promise<boolean>;
}
```
```ts
// core-consent/consent-types.ts
export type ConsentCategory =
| "essential"
| "functional"
| "analytics"
| "marketing"
| (string & Record<never, never>);
export type ConsentState = {
granted: boolean;
grantedAt?: string; // ISO 8601
withdrawnAt?: string;
bannerVersion?: string;
policyVersion?: string;
method?: "banner" | "settings" | "api" | "signup-migration";
};
export type UserConsentState = Record<ConsentCategory, ConsentState>;
// core-consent/consent.interface.ts
export interface IConsent {
isGranted(subjectId: string, category: ConsentCategory): Promise<boolean>;
grant(
subjectId: string,
categories: ConsentCategory[],
record: Omit<ConsentState, "granted" | "grantedAt">,
): Promise<void>;
withdraw(subjectId: string, categories: ConsentCategory[]): Promise<void>;
getCategories(subjectId: string): Promise<ConsentCategory[]>;
}
```
### Cookie banner contract
```tsx
type CookieConsentBannerProps = {
variant?: "modal" | "banner"; // default "modal"
categories?: ConsentCategory[]; // default ["essential", "functional", "analytics", "marketing"]
defaultEnabled?: ConsentCategory[]; // default ["essential"]
privacyPolicyHref?: string;
bannerVersion?: string;
policyVersion?: string;
onConsentChange?: (state: UserConsentState) => void;
renderHeader?: (ctx: { variant: "modal" | "banner" }) => ReactNode;
renderCategoryRow?: (ctx: {
category: ConsentCategory;
granted: boolean;
toggle: () => void;
}) => ReactNode;
renderActions?: (ctx: {
acceptAll: () => void;
rejectAll: () => void;
saveSelected: () => void;
}) => ReactNode;
};
```
Default UI ships:
- Modal variant: centered, focus-trapped, ESC = "reject all" (legally explicit choice — not silent dismiss)
- Banner variant: bottom-of-viewport, position fixed, full-width
- Reject All + Accept All: side-by-side, equal size, equal visual weight (no "Accept All" emphasis via color/size)
- Save Selected: secondary action
a11y baseline:
- WCAG 2.2 AA color contrast
- Keyboard navigable (tab/shift-tab/escape)
- Screen-reader announcements via ARIA live region
- Focus-trap inside modal
- axe-core test in Storybook
### Anonymous → authenticated consent migration
Flow:
1. Banner saves anonymous consent to `__consent_state` cookie (SameSite=Lax, Secure, 1-year max-age).
2. Consumer's `signUp` use case extracts the cookie via `extractAnonymousConsent(request.headers.cookie)`.
3. After user record creation, calls `migrateAnonymousConsent({ userId, cookieState, banneredVersion, policyVersion })`.
4. Helper calls `IConsent.grant(userId, cookieState.categories, { method: "signup-migration", bannerVersion, policyVersion })`.
5. Audit emits `CONSENT_GRANT` with `method: "signup-migration"`.
6. Response sets `Set-Cookie: __consent_state=; Max-Age=0` (clear cookie).
Helper signatures live in `core-consent`; the template's existing `auth.signUp` use case is the canonical example consumer.
### tRPC router shapes
- `dsrRouter.export``{ subjectId, format }``UserDataBundle`
- `dsrRouter.delete``{ subjectId, mode }``DeletionCertificate`
- `dsrRouter.rectify``{ subjectId, collection, field, value }``void`
- `dsrRouter.restrict``{ subjectId, granted }``void`
- `consentRouter.grant``{ subjectId, categories, record }``void`
- `consentRouter.withdraw``{ subjectId, categories }``void`
- `consentRouter.isGranted``{ subjectId, category }``boolean`
- `consentRouter.getCategories``{ subjectId }``ConsentCategory[]`
Auth check happens at the procedure level via existing per-feature error middleware pattern (defineErrorMiddleware). Subject-driven calls: `subjectId === ctx.session.user.id`. Admin calls: role check via existing auth feature mechanisms.
### ADR amendments captured
- **ADR-018 amendment**: audit action enum gains `CONSENT_GRANT`, `CONSENT_WITHDRAW`, `RESTRICT`, `UNRESTRICT`. The PRD landing this change adds the lines to `core-shared/audit/audit-action.ts` (or wherever the enum lives) and updates `docs/guides/audit-and-compliance.md`. ADR-018's own §"Six action types" wording becomes "ten action types"; an `## Amendments` section at the foot of ADR-018 captures the date + reason.
### Conformance + manifest impact
- New per-use-case field: `requiresConsent: ConsentCategory[]` (default `[]`)
- New ESLint rule: `no-undeclared-consent-check` (warn)
- Brand composition order extended (innermost-to-outermost): `withConsent``withAnalytics``withAudit``withCapture``withSpan`
- Boot assertion: requires `ConsentChecked` brand when `requiresConsent.length > 0`
- New optional cores in `requiredCores` vocabulary: `dsr`, `consent`
- `required-cores-installed` ESLint rule auto-detects new optional cores via existing pnpm-workspace.yaml mechanism
### Endpoint mapping (informative — not code)
| GDPR Article | tRPC procedure | HTTP path (consumer-mapped) |
| ------------------------ | ----------------------------------------- | --------------------------------- |
| Art. 15 (access) | `dsrRouter.export({ format: "json" })` | `/api/gdpr/export` |
| Art. 16 (rectification) | `dsrRouter.rectify` | `/api/gdpr/rectify` |
| Art. 17 (erasure) | `dsrRouter.delete` | `/api/gdpr/delete` |
| Art. 18 (restriction) | `dsrRouter.restrict` | `/api/gdpr/restrict` |
| Art. 20 (portability) | `dsrRouter.export({ format: "json-ld" })` | `/api/gdpr/export?format=json-ld` |
| Art. 21 (objection) | `consentRouter.withdraw` | `/api/consent/withdraw` |
| Art. 22 (auto decisions) | _deferred_ per ADR-025 | _deferred_ |
| Art. 7 (consent grant) | `consentRouter.grant` | `/api/consent/grant` |
The mapping is documentation; consumer composes their own HTTP namespace from the tRPC procedures.
## Testing decisions
- **Interface impls** (`PayloadDataExport`, `PayloadDataDelete`, `PayloadDataRectify`, `PayloadProcessingRestriction`, `PayloadConsent`): each gets a contract test suite covering: happy path per role/mode, multi-subject row redaction, JSON-LD `@context` correctness, audit emission shape, restriction flag honored on reads, consent state read/write round-trip including audit entry, signup-migration helper.
- **Recording doubles**: vitest unit tests asserting captured calls match invocations + payload shape.
- **`withConsent` wrapper**: tests asserting `ConsentChecked` brand attached at bind time, brand inspectable via `isConsentChecked` helper, factory invocation passthrough preserved.
- **ESLint rule `no-undeclared-consent-check`**: RuleTester fixtures parallel to `no-undeclared-audit.test.js` — passes when call matches manifest, fires on undeclared category, fires on unused declaration (warn), no-op on non-use-case files.
- **`assertFeatureConformance` brand check**: synthetic manifest fixture with `requiresConsent: ["analytics"]` but no `withConsent` wrapper at bind site fails with a `ConformanceError` naming the missing `ConsentChecked` brand.
- **Cookie banner**:
- Storybook story with axe-core a11y pass
- React Testing Library: render → click "Reject All" → assert callback fires with all categories `granted: false` except `essential`
- Render → toggle analytics → click "Save Selected" → assert callback fires with `analytics.granted: true`
- Modal variant focus-trap test
- Keyboard nav test (tab order, escape behavior)
- **Signup migration**: in `auth.signUp.use-case.test.ts` — mock cookie header → call use case → assert `migrateAnonymousConsent` invoked → assert audit entry shape via `RecordingAuditLog`
- **tRPC routers**: integration tests using the existing tRPC test pattern (defineErrorMiddleware passthrough, auth check, response shape)
- **Multi-subject scenario**: synthetic Payload collection fixture (`support_tickets`) declared in a unit test with `custom.subject = [{ field: "submittedBy", role: "submitter", kind: "reference" }, { field: "assignedTo", role: "assignee", kind: "reference" }]`. Export for one user returns the linked-field reference; delete redacts only that user's link.
- **JSON-LD `@context` correctness**: test asserts emitted JSON-LD parses with a standard schema.org validator (use `jsonld` library in test only, not runtime).
- **Coverage**: all new modules join L0 vitest thresholds via `coverage.bands` in their new feature manifests. L1 `pnpm coverage:diff` gates every slice.
- **Prior art to mirror**:
- `core-audit` interface + DI binder + recording double pattern: `packages/core-audit/src/{audit-log.interface.ts,di/bind-audit.ts}` + `packages/core-testing/src/instrumentation/recording-audit-log.ts`
- ESLint rule: `packages/core-eslint/rules/no-undeclared-audit.{js,test.js}`
- Brand attachment: `packages/core-shared/src/conformance/{brands.ts,brand-runtime.ts}` + `packages/core-shared/src/instrumentation/with-capture.ts`
- tRPC router pattern: any feature's `integrations/api/router.ts`
- React subpath: ADR-024's `@repo/core-analytics/react` (`<AnalyticsProvider>` + `useAnalytics()` hook) — shipped this session
## Open questions
- **Q1: Multi-subject `IDataExport` JSON-LD `@context` — separate context per `role` or one context?** — Recommended: **one context**. schema.org's `Role` vocabulary handles role-naming via `roleName`. Simpler than per-role context selection.
- **Q2: Should `IDataDelete` mode=`cascade-hard` skip the 30-day grace and execute immediately?** — Recommended: **yes**. Admin-only path (auth check at endpoint level). Soft-delete handles the user-driven 30-day flow; cascade-hard is for "expunge now" scenarios (legal hold release, court order). Document in `docs/guides/dsr.md`.
- **Q3: How does `<CookieConsentBanner>` handle SSR — render server-side or client-only?** — Recommended: **client-only with SSR-safe placeholder**. Component reads/writes cookies; server can't reliably synthesize the right initial state. Ship `<CookieConsentBannerLoader>` for SSR placeholder + dynamic-import the actual banner client-side. Document in `docs/guides/consent.md`.
- **Q4: Should `IConsent.grant` accept an opaque `ipTruncated` field for audit (matches `core-audit`'s from-where requirement)?** — Recommended: **yes**, but threaded via the handler layer (extracts from request), not via `IConsent`'s own signature. Keep the interface PII-clean; the handler enriches the audit entry with truncated IP from the request context.
- **Q5: Does the cookie banner's `__consent_state` cookie shape need to be versioned for forward-compat?** — Recommended: **yes, include `_v: 1` field**. Component reads versioned shape; migrates older versions on read. Documents the migration policy in `docs/guides/consent.md`.
## Out of scope (deferred)
- **REST endpoint scaffolds** — exposed via tRPC only per Q8 of grill; consumer wraps for REST if needed; Epic D docs the wrapping pattern.
- **Streaming `IDataExport`** — in-memory `UserDataBundle` for first pass; v2 if a consumer hits OOM (see Q3 of grill).
- **`dsr_rectifications` collection for rectification history** — main audit log suffices via `reason: "art-16-request"` tag (Q4 of grill).
- **`withRestriction` brand-treatment** — restriction is binary + rare; consumer calls `isRestricted` where needed (Q5 of grill).
- **Strict-mode `ConsentCategory` declaration merging** — escape-hatch union is sufficient (Q10 of grill).
- **GDPR Art. 22 (automated decision-making)** — deferred per ADR-025; revisit when consumer adds automated decisions.
- **Cross-region transfer documentation (Schrems II / TIA)** — Epic D's territory.
- **Audit-log full export across subjects (admin-mediated forensics)** — out of DSR's subject-scoped surface.
- **Migration tooling for downstream consumers upgrading from a pre-ADR-025 template version** — template hasn't been versioned with consumers; no migration story yet.
- **Per-framework router auto-wiring for analytics on consent toggle** — banner emits `onConsentChange` callback; consumer wires (e.g., re-initialize analytics SDK after consent granted).
## Further notes
- **Builds on**: ADR-018 (audit channel — amends action enum), ADR-022 (library evaluation policy — no new traces needed for workspace packages), ADR-024 (analytics — gates emission via consent), ADR-025 (strategy umbrella — Epic B implementation seed), Epic A PRD (`compliance-manifests-pii-retention-subprocessors.prd.md` — provides PII tags + retention purge + `PAYLOAD_AUTH_PII_DEFAULTS` extension pattern).
- **Pairs with**: Epic C PRD `security-headers-rate-limit-sbom.prd.md` (independent; dispatcher can interleave); Epic D PRD `compliance-docs-scaffolds.prd.md` (lands after B; consumes DSR + consent route mappings in documentation).
- **Sequencing within Epic B**: subject-linkage types + ambient declaration → audit enum amendment → `core-consent` interface + brand + ESLint rule → `core-dsr` interfaces + handlers → tRPC routers + core-api composition → cookie banner (after core-ui scaffolded) → signup-migration helper + auth feature integration → docs.
- **Stakeholders**: template authors (most affected — adds 2 optional cores, manifest field, brand, ESLint rule, ADR-018 amendment), downstream EU-bound consumers (positively affected — gain DSR + consent + cookie banner for free), AI agents operating in feature code (positively affected — declarative consent gates with lint-time enforcement), compliance reviewers + DPOs (positively affected — demonstrable consent with audit-logged proof).
- **PII boundary clarification**: this PRD does NOT relax ADR-017 §7. Observability surface stays id-only. Consent surface explicitly permits traits within `UserConsentState` (banner version, policy version, method, IP-truncated via handler enrichment) — consumer-policy boundary distinct from observability boundary, documented per ADR-024's PII boundary precedent.

View File

@@ -1,334 +0,0 @@
---
id: library-evaluation-policy
title: Library evaluation policy — skill, traces, enforcement stack
type: prd
status: approved
author: danijel
created: 2026-05-14T00:00:00Z
updated: 2026-05-14T19:16:52.691Z
adr: adr-022
---
## Problem
This template ships with a deliberately narrow third-party surface — every
feature package today holds the same 6 runtime deps and nothing else. That
discipline is uncodified. New dependencies enter via `pnpm add <pkg>` with no
checkpoint between intent and lockfile, and three recent signals show the gap:
1. The 2026-05-14 grill session nearly added `trpc-to-openapi` + `zod-to-json-schema`
- a build-time generator before someone asked "who calls this code path?"
The honest answer was "nobody — all callers are TypeScript via `createCaller`."
2. ADR-002 (Inversify), ADR-014 (Sentry), ADR-017 (OpenTelemetry) each record
library decisions, but every record was written _after_ adoption. No mechanism
exists to catch a bad choice before it becomes a migration project.
3. The repo 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 with defaults. Nothing currently flags this.
ADR-022 codifies the policy. This PRD implements it.
## Goal
A four-layer enforcement stack — Claude hook, skill, pre-commit hook, sandcastle
reviewer prompt — that makes every new runtime dependency in a feature- or
core-tier package produce a permanent **library trace** at
`docs/library-decisions/<YYYY-MM-DD>-<package-name>.md`, with rejection traces
treated as first-class records.
## In scope
- The `evaluate-library` skill at `.claude/skills/evaluate-library/SKILL.md`
authoritative agent runbook; walks 8 hard filters + 3 prompts; writes the trace.
- The human reading-room guide at `docs/guides/adding-a-library.md` with worked
examples (approved + rejected).
- The `docs/library-decisions/` directory + `_template.md` schema reference.
- A Zod-validated trace-schema module (`scripts/library-decisions/schema.mjs`)
shared by the skill, the pre-commit checker, and the generator.
- Claude `PreToolUse` hook (`.claude/hooks/library-policy-nudge.sh`) — matches
`pnpm add` / `pnpm i <pkg>` in Bash invocations; emits skill reminder.
- Claude `PostToolUse` hook (also in `library-policy-nudge.sh`, dispatching by
event type) — matches `Edit`/`Write` against any `**/package.json`.
- Pre-commit hook check script (`scripts/library-decisions/check.mjs`) wired
into `.husky/pre-commit`. Blocks the commit when a new runtime dep is staged
in a feature/core package and no sibling trace file is staged.
- Sandcastle reviewer prompt update (`.sandcastle/reviewer.prompt.md`) — the
reviewer agent runs the same check before issuing approve/reject.
- Optional-cores generator templates (`turbo/generators/templates/core-package/`)
emit pre-shipped traces per direct dep, dated at generation time, marked
`decision: approved`, citing the relevant ADR (015/016/018). Five generators
updated: `events`, `realtime`, `audit`, `trpc`, `ui`.
- Backfill traces for every existing runtime dependency in feature- and core-
tier packages, dated 2026-05-14, citing existing ADRs (002/014/017) where
applicable. Approx 10 traces.
- `CLAUDE.md` "Key Conventions" gets a one-line bullet pointing to ADR-022 +
the guide.
## Out of scope
- Transitive dependency tracing — `pnpm audit` already handles recursive scanning.
- Bundle-size analysis — Vercel / Vite build output already reports this.
- Auto-removal of approved-then-unused deps — `pnpm fallow` territory.
- License auto-enforcement at the lockfile layer (license-checker plugins) —
defer until the policy has run for some time and we know where it leaks.
- Anything app-tier. Deps in `apps/*` are author's call per the tier model.
- Devdeps in any tier. Only `dependencies` (runtime) require traces.
## Constraints
- **ADR-022** is the source of truth. This PRD implements but does not extend it.
- **ADR-006 + ADR-010** — the tier trigger maps onto the existing boundary-tag
system. No new mental model; ESLint already partitions blast radius.
- **ADR-019** — the sandcastle reviewer prompt is one of four enforcement
layers. Whatever the agent loop does must compose with the existing prompt
shape at `.sandcastle/reviewer.prompt.md`.
- **ADR-021** — release-please picks up dependency changes from commit history.
The trace file landing in the **same commit** as the `package.json` change
is required so release notes correlate cleanly with policy records.
- **Conformance system parity** — the enforcement stack mirrors the 5-gate
latency pattern from ADR-012. Same vocabulary, same agent feedback loop.
- **Conventional Commits** — every commit produced by the implementation
follows `<type>(<scope>): <subject>`.
- **`--no-verify` is forbidden** — the bash-guard hook already enforces this;
the new pre-commit check inherits that protection.
- **Skill must be deterministic from explicit args** — `/evaluate-library <name>
--tier <feature|core|app> --target <package-path>`. The Claude hook produces
exactly this invocation from a `pnpm add` command line.
## Success criteria
- `pnpm typecheck && pnpm test && pnpm lint && pnpm conformance && pnpm fallow:audit`
pass green at the end of the epic.
- `pnpm coverage:diff` covers every changed executable line introduced by
the implementation slices.
- Attempting to commit a new feature-tier runtime dep without a sibling trace
file is blocked by the pre-commit hook with a clear error pointing to the skill.
- Running the `evaluate-library` skill against `trpc-to-openapi` (the rejected
library from the grill session) produces a `decision: rejected` trace with
`named-consumer: fail` and prose citing the conversation, in <90 seconds of
agent work.
- Running `pnpm turbo gen core-package events` (or any other optional core)
emits pre-shipped traces for every direct dep of that core into
`docs/library-decisions/`, all `decision: approved` and ADR-cited.
- The Claude `PreToolUse` hook fires on `pnpm add <anything>` and emits the
skill-reminder system-reminder; does **not** auto-deny.
- All existing runtime deps in feature- and core-tier packages have backfilled
trace files dated 2026-05-14 in `docs/library-decisions/`.
- `CLAUDE.md` Key Conventions includes the one-line policy bullet.
- `docs/glossary.md` already includes **Library trace** and **Pre-shipped
trace** entries (landed inline during the grill session).
## User stories
1. **As a developer adding a new feature dependency**, I want a deterministic
skill that walks me through the 8 filters and 3 prompts in collect-cheap-
skip-expensive order, so I don't forget any check and the trace file is
written automatically with my answers.
2. **As an agent dispatched against a slice that needs a new dep**, I want the
Claude `PreToolUse` hook to inject a system-reminder pointing me at the
skill the moment I'm about to run `pnpm add`, so I don't bypass the policy
by reflex.
3. **As an agent editing a `package.json` by hand**, I want the Claude
`PostToolUse` hook to inject the same reminder, so the policy isn't
sidestepped by paste-then-install.
4. **As a reviewer (human or agent)**, I want the pre-commit hook to refuse a
commit that adds a runtime dep to a feature/core package without a sibling
trace file, so I don't have to remember to check during review.
5. **As a future agent considering a previously-rejected library**, I want
to find the rejection trace in `docs/library-decisions/` in <1s of
`ls`/`grep`, so I don't re-litigate a decision that has already been made.
6. **As an EU-resident maintainer**, I want the EU-data-residency filter to
reject US-only SaaS components by default and force a `self-hostable` or
`EU-region-configured` justification in the trace, so user data doesn't
leave the EU silently.
7. **As a maintainer scaffolding an optional core via
`pnpm turbo gen core-package <name>`**, I want pre-shipped traces emitted
for every direct dep of the new core, so the policy is satisfied by
construction.
8. **As a security-conscious maintainer**, I want the CVE-scan filter to run
`pnpm audit --audit-level=moderate` at evaluation time and snapshot the
result + commands into the trace, so I can re-run them later to detect drift.
9. **As an agent reviewing a slice in sandcastle**, I want the reviewer prompt
to check for trace presence + correctness, so I can reject incompliant
slices without needing a separate workflow.
10. **As a maintainer reading the repo for the first time**, I want
`docs/guides/adding-a-library.md` to explain the policy with worked
examples (one approved, one rejected), so I understand the why and how
before I face the gate myself.
## Implementation decisions
**Module sketch** — what lands where, by package and concern (no file paths
where prose suffices):
- **The skill itself** — `.claude/skills/evaluate-library/` follows the same
shape as `to-prd`, `grill-with-docs`, `improve-codebase-architecture`.
SKILL.md is authoritative; supporting files (`POLICY.md` mirroring ADR-022,
`TRACE-TEMPLATE.md` showing the YAML+headings shape, `EXAMPLES/` worked
cases) flesh it out. The skill is invocable via slash command
`/evaluate-library`.
- **Trace schema module** — a small shared module at
`scripts/library-decisions/schema.mjs` exporting (1) a Zod schema for the
trace's frontmatter, (2) a parser that reads a trace file and returns the
validated frontmatter, (3) a serializer that takes filter results + prose
blocks and emits a trace file. Both the skill and the pre-commit checker
import this module. Deep module — small interface (parse/serialize/validate),
high leverage across the four enforcement layers.
- **Pre-commit check script** — `scripts/library-decisions/check.mjs`. Walks
`git diff --cached --name-only -- '**/package.json'`, for each file extracts
newly-added dep lines via `git diff --cached <file>`, derives the tier from
the path, and for each new runtime dep checks that
`docs/library-decisions/*-<name>.md` is also staged with `decision: approved`.
Exits non-zero with a pointer to the skill if any check fails. Invoked from
`.husky/pre-commit` as step 4 (after the existing state-sync guard).
- **Claude hooks** — a single `.claude/hooks/library-policy-nudge.sh` that
dispatches on `tool_use_type` to handle both `PreToolUse` (Bash with
`pnpm add` / `pnpm i <pkg>` pattern) and `PostToolUse` (Edit/Write on
`**/package.json`). Same style as the existing `generator-first-nudge.sh`.
Emits a non-blocking system-reminder to stdout that the harness threads
into the agent's next turn.
- **Sandcastle reviewer prompt** — append a "Library-trace check" section
to `.sandcastle/reviewer.prompt.md`. The reviewer runs
`node scripts/library-decisions/check.mjs --staged-against <base>` in the
sandbox before issuing its verdict.
- **Generator templates** — each `turbo/generators/templates/core-package/<name>/`
gets a `docs/library-decisions/` subtree with one `.md` per direct dep of
that core. The generator copies these alongside the core's package.json
into the workspace. Frozen via the existing
`turbo/generators/__snapshots__/core-package/<name>.snapshot.json` mechanism.
- **Backfill traces** — write one trace per existing runtime dep in feature/
core tier. The deps cluster naturally by ADR provenance: ADR-002 cluster
(Inversify + reflect-metadata), ADR-014 cluster (Sentry family), ADR-017
cluster (OpenTelemetry family), and the un-cited cluster (`payload`,
`@trpc/server`, `zod`, `superjson`, plus any others surfaced by inventory).
All traces dated 2026-05-14, `decision: approved`, ADR citation where the
cluster maps to one.
- **`CLAUDE.md` update** — one bullet in Key Conventions: _"New runtime
dependencies in feature- or core-tier packages require a trace at
`docs/library-decisions/<date>-<name>.md` produced by the `evaluate-library`
skill — see ADR-022."_
**Trace schema (frontmatter)** — Zod schema (lifted from ADR-022 §4):
```
package: string
version: string // semver range as written in package.json
tier: "app" | "feature" | "core"
decision: "approved" | "rejected"
date: ISO date string
deciders: string[]
adr: string | null // "adr-NNN" slug or null
filter-results: {
license: SPDX-id-string
types: "native" | `@types/${string}` | "none"
maintenance: "active" | "dormant" | "abandoned"
boundary-fit: "pass" | "fail"
shadow-check: "pass" | "fail" | `shadows ${string}`
eu-residency: "ok" | "n/a" | "self-hostable" | "fail"
cve-scan: "clean" | `${cve-id}` | "fail"
named-consumer: "pass" | "fail"
}
verification-commands: string[]
accepted-cves?: string[] // optional per-trace allowlist
```
Headings (machine-checkable order): one `## Filter: <name>` per filter +
one `## Prompt: <name>` per prompt, in the order listed in ADR-022.
**Skill fail behavior** — collect-cheap-skip-expensive. The four cheap
structural filters (license, types, shadow-check, boundary-fit) always run
to completion. The four expensive filters (maintenance, CVE scan, EU residency,
named-consumer) short-circuit after the first reject. The trace records which
filters ran and which were skipped, with a "skipped because earlier filter
already rejected" sentinel value.
**Pre-commit hook decision-state check** — beyond presence, the script also
verifies that the trace's `decision` matches the dep status: a dep listed in
`package.json` requires `decision: approved`; a trace with `decision: rejected`
that names a dep that's also in the package.json is a hard fail (rejected
libraries cannot ship).
**Conformance system composition** — no new use cases, controllers, manifest
entries, audits, events, or jobs. This PRD is a workflow/policy implementation,
not a feature-domain change. The conformance gates apply only to the new
TypeScript/JS modules (Zod schema module + check script) — they get standard
vitest coverage.
## Testing decisions
- **`scripts/library-decisions/schema.mjs`** — unit tests covering: valid
trace parses round-trip; missing required field rejected; unknown filter
rejected; invalid enum value rejected.
- **`scripts/library-decisions/check.mjs`** — integration tests covering: new
feature-tier dep without trace → fail with exit 1; new feature-tier dep with
approved trace → pass; new feature-tier dep with rejected trace listed in
package.json → fail; new app-tier dep (no trace required) → pass; new devdep
→ pass (devdeps exempt); multi-file staged diff with mixed pass/fail → fail
with per-package report; non-runtime dep (`peerDependencies` only) → pass.
Use a temp git repo as the test fixture.
- **The skill** — no automated test in the conformance sense (it's a prose
runbook for an agent). The success criterion is that running it against
`trpc-to-openapi` produces the documented rejection trace; verified manually
during the epic.
- **Generator pre-shipped traces** — the existing
`turbo/generators/__snapshots__/core-package/<name>.snapshot.json` snapshot
test extends to cover the new trace files. A failing snapshot is the gate.
- **Claude hook scripts** — bash smoke tests that pipe a mocked Claude
hook payload (`{ "tool_input": { "command": "pnpm add foo" } }`) into the
script and assert stderr contains the skill-reminder marker. Same style as
`generator-first-nudge.sh`'s existing tests if any (check during impl).
- **Prior art** — mirror the test patterns from `scripts/work/state-sync-guard.mjs`
(the pre-commit `_state.json` check) for the new check script; the
fixture/assertion shape carries over directly.
- **Coverage bands** — the new scripts under `scripts/library-decisions/` are
not feature packages, so they don't have a `feature.manifest.ts` and aren't
bound by per-layer coverage thresholds. Add them to the diff-coverage
exception list **only if** the diff-coverage gate is too strict on script
files; default is they should hit 100% statement coverage because they're
small.
## Open questions
- **Q1:** CVE-accepted-risk mechanism — per-trace `accepted-cves: ["CVE-XXXX-YYYY"]`
frontmatter array vs central `docs/library-decisions/_cve-allowlist.md`? —
**Recommended:** per-trace. Acceptance is library-scoped, not global; a
central file becomes a god-object that no agent reads in full.
- **Q2:** Should the policy also gate `peerDependencies` additions, or only
`dependencies`? — **Recommended:** only `dependencies`. Peer deps are a
contract, not a runtime addition; if a feature declares a peer, the actual
runtime adopter (an app or another package) is the one whose `dependencies`
the policy catches.
- **Q3:** Should the backfill be one commit per trace or one batch commit per
ADR cluster? — **Recommended:** one commit per cluster (4 commits total),
each with conventional `chore(deps): backfill library traces for <cluster>`.
Avoids both extremes (1 mega-commit and 10 noisy single-trace commits).
- **Q4:** Should the skill be permitted to **write the trace** before the
user/agent approves the final decision, or must the final write be a
separate explicit step? — **Recommended:** skill writes the trace
unconditionally at the end of evaluation; the trace IS the record, including
for rejections. No "draft" state.
- **Q5:** Where do the Claude hooks register themselves? — **Investigate:**
the existing `.claude/hooks/*.sh` are referenced by some kind of settings
file or auto-discovered. Confirm during the first slice that adds the new
hook script and matches the existing registration pattern.
## Out of scope (deferred)
- **Periodic re-verification.** Running each trace's `verification-commands`
on a schedule (nightly?) to detect drift — new CVEs, license changes,
upstream abandonment. Deserves its own PRD; would compose with `pnpm fallow`
as a sixth gate.
- **Auto-generated PR comments** that summarize the trace for human reviewers.
Nice-to-have once the policy has lived for a quarter.
- **`pnpm libs <subcommand>` ergonomic CLI** — wrapping `check.mjs` as
`pnpm libs check`, plus `pnpm libs list`, `pnpm libs orphans`, etc. Defer
until the raw script proves the workflow.
## Further notes
- **Anchored by ADR-022** — Library evaluation policy. Read that first.
- **Glossary entries** for **Library trace** and **Pre-shipped trace** landed
during the 2026-05-14 grill session that produced ADR-022.
- **Conversation provenance** — the 2026-05-14 grill-with-docs session that
produced this PRD is captured in the session transcript; ADR-022 cites the
OpenAPI near-miss as concrete catalyst.

View File

@@ -1,179 +0,0 @@
---
id: product-analytics-channel
title: Product analytics as a fourth capture channel (ADR-024 implementation)
type: prd
status: approved
author: danijel
created: 2026-05-18T10:30:46Z
updated: 2026-05-18T10:35:01.727Z
---
## Problem
Every consumer of this template who builds a user-facing product eventually has the same conversation: "how do I add PostHog / Segment / Mixpanel?" They end up bolting an analytics SDK on at the React component layer, bypassing the conformance system entirely. Use cases never know they're emitting analytics events. Manifests never record them. The ESLint rules that catch `audits` / `publishes` drift have no analog. Five gates of conformance protection collapse to zero the moment the SDK is dropped in.
The repo already has four capture channels (`ITracer`, `ILogger`, `IMetrics`, `IAuditLog`), and none of them is product analytics — Sentry is intentionally PII-stripped per ADR-017 §7, audit is compliance-driven, metrics are pre-aggregated. There is no path for funnel analysis, cohort tracking, conversion measurement, or any other identified-user event stream the typical product needs.
ADR-024 settled the architectural decision. This PRD is the implementation seed for the epic.
## Goal
Ship `@repo/core-analytics` as an optional core package with `IAnalytics` interface, brand-based conformance integration, manifest field, ESLint rule, and React provider — mirroring the audit channel's structural shape with the three deliberate divergences ADR-024 specifies. Template stays vendor-neutral; consumers wire their backend (PostHog / Segment / etc.) through `/evaluate-library` per ADR-022.
## In scope
- New optional core package `@repo/core-analytics`, scaffolded via `pnpm turbo gen core-package analytics`
- `IAnalytics` interface with four methods: `track`, `identify`, `pageView`, `flush`
- `NoopAnalytics` default impl
- `RecordingAnalytics` test double in `@repo/core-testing`
- `Analyzed` brand + `withAnalytics` wrapper composed into `wireUseCase`
- Manifest schema extension: `analyticsEvents: string[]` per use case
- `assertFeatureConformance` extended to check `Analyzed` when `analyticsEvents.length > 0`
- `BindContext.analytics?: AnalyticsProtocol` + `AnalyticsProtocol` in `bind-protocols`
- ESLint rule `no-undeclared-analytics-event` at warn severity
- React subpath `@repo/core-analytics/react` with `<AnalyticsProvider>` + `useAnalytics()` hook
- Documentation: `docs/guides/analytics.md`, glossary already updated, CLAUDE.md + `conformance-quickref.md` rule-count bumps, `template-tiers.md` optional-cores list update
## Out of scope
- **Picking a backend vendor.** Vendor choice (PostHog / Segment / Mixpanel / etc.) is per-consumer and routes through ADR-022's `/evaluate-library` gate. Template ships only Noop + Recording impls.
- **Framework-specific router auto-wiring.** `<AnalyticsProvider>` exposes `pageView(path, ...)` but does NOT auto-call it on Next App Router / TanStack Router events. Consumers wire their router's route-changed hook themselves. A future `pnpm turbo gen` may scaffold per-framework adapters.
- **Migrating existing features to declare `analyticsEvents`.** This PRD adds the channel; no current feature (auth, blog, media, marketing-pages, navigation) gains an analytics event in this epic. Empty `analyticsEvents: []` everywhere is acceptable.
- **Unifying `audits` + `publishes` + `analyticsEvents` into a single `events: [...]` field.** ADR-024 alternative B rejected this as out-of-proportion scope. Three orthogonal manifest fields.
- **CI guardrail beyond ADR-022/023.** No new grep gate, no PII allowlist enforcement, no CodeQL-style scan specific to analytics. Consumer owns consent + retention.
- **Server-side smoke test for `Analyzed` brand wiring.** Smoke tests parallel to `bind-production.smoke.test.ts` are added by consumers who actually wire analytics. Template features won't.
## Constraints
- **ADR-024** — every decision in this PRD must match. If implementation finds a constraint that conflicts with ADR-024, surface it and amend the ADR before proceeding.
- **ADR-017 §7** — observability PII policy (`sendDefaultPii: false`, `setUser({ id })`) stays untouched. Analytics PII boundary is structurally distinct, documented per ADR-024.
- **ADR-022** — backend vendor choice (when a consumer makes one) goes through `/evaluate-library` and produces a trace at `docs/library-decisions/<date>-<vendor>.md`.
- **ADR-018** — `IAuditLog` precedent. Analytics mirrors its package layout, manifest field shape, brand pattern, and ESLint rule structure. Three deliberate divergences (no `mutates` gate, `flush()` on interface, React provider scaffold) are spelled out in ADR-024.
- **Generator-first** — `pnpm turbo gen core-package analytics` is the entry point for the package scaffold. Hand-rolling the directory is forbidden per CLAUDE.md / repo-wide rule.
- **Conformance ordering** — manifest entry → contracts → tests → impl. Brand attachment composes at DI bind time only. No moving emission into the use case body unless the use case explicitly declares `analyticsEvents`.
- **Conventional Commits** — every commit follows the spec. Slice = task = PR = commit.
## Success criteria
- `pnpm turbo gen core-package analytics` produces a green `@repo/core-analytics` package containing `IAnalytics` + `NoopAnalytics`.
- `pnpm typecheck && pnpm lint && pnpm test && pnpm conformance && pnpm fallow:audit` all pass at every commit boundary.
- A test feature (synthetic in a unit test, not a real feature in this repo) that declares `analyticsEvents: ["X"]` in its manifest but skips `wireUseCase` with analytics fails the boot-time assertion AND the `no-undeclared-analytics-event` ESLint rule.
- `assertFeatureConformance` rejects an unwrapped binding when `analyticsEvents.length > 0`, with a message naming the missing `Analyzed` brand.
- `<AnalyticsProvider>` + `useAnalytics()` round-trip a `track` call through `RecordingAnalytics` in a React testing-library test.
- `docs/guides/analytics.md` documents the consumer wiring path for both server (`BindContext.analytics`) and client (`<AnalyticsProvider>`), plus the PII boundary deferral.
- CLAUDE.md + `conformance-quickref.md` reflect 7 conformance ESLint rules.
## User stories
1. As a **template author**, I want analytics codified as a fourth capture channel so future agents and humans extend the conformance pattern rather than bolting SDKs on at the wrong layer.
2. As a **downstream consumer** building a product on this template, I want an `IAnalytics` interface I can implement against my chosen vendor (PostHog / Segment / etc.) so my use case bodies don't depend on a specific SDK.
3. As a **downstream consumer**, I want my server-side use cases to declare their analytics events in the manifest so I can audit "what does this feature emit" without reading every use case body.
4. As a **downstream consumer**, I want an ESLint rule that catches calls to `analytics.track("undeclared.event", ...)` so analytics-event sprawl doesn't accumulate silently.
5. As a **downstream consumer**, I want a React provider scaffold so server and client share the same `IAnalytics` contract and `analytics.track(...)` reads the same way in both.
6. As a **downstream consumer running in serverless**, I want `flush()` on the interface so I can drain the in-memory batch in my response-finish hook and stop losing events.
7. As an **AI agent** scaffolding a new feature, I want the manifest schema to include `analyticsEvents: []` by default so I can declare events without rediscovering the channel each time.
8. As an **AI agent** modifying a use case, I want `assertFeatureConformance` to refuse to boot when I've declared `analyticsEvents` but forgotten to wire `withAnalytics` — same fast feedback that exists for tracing + capture + audit.
9. As a **compliance reviewer** auditing the codebase, I want the PII boundary divergence (observability id-only vs analytics traits-allowed) documented in code + ADR so the deliberate distinction isn't mistaken for drift.
## Implementation decisions
### Module layout
- **New package** `@repo/core-analytics` — scaffolded via `pnpm turbo gen core-package analytics`. Mirrors `@repo/core-audit` layout. Subpath export `./react` for the provider. No vendor backend bundled.
- **Modified package** `@repo/core-testing` — adds `RecordingAnalytics` parallel to `RecordingAuditLog`.
- **Modified package** `@repo/core-shared``conformance/brands.ts` (`Analyzed` type), `conformance/brand-runtime.ts` (`isAnalyzed`), `conformance/assert-bindings.ts` (brand check when manifest declares events), `conformance/define-feature.ts` (`analyticsEvents` field), `conformance/wire-use-case.ts` (compose `withAnalytics`), `di/bind-protocols.ts` (`AnalyticsProtocol`), `di/bind-context.ts` (`analytics?:` field).
- **Modified package** `@repo/core-eslint` — new rule `no-undeclared-analytics-event`, `_manifest-ast.js` extended to parse `analyticsEvents`, `plugin.js` + `base.js` register at warn.
- **Modified docs** — `docs/guides/analytics.md` (new), `docs/guides/conformance-quickref.md` (rule table + drift patterns), `CLAUDE.md` (rule count: 6 → 7), `docs/architecture/template-tiers.md` (optional-cores list).
- **Glossary** — already updated in the same commit as ADR-024 (`IAnalytics`, `withAnalytics`).
### Interface contract (informative — authoritative copy lives in `analytics.interface.ts`)
```ts
export type AnalyticsAttributeValue = string | number | boolean;
export type AnalyticsUser = {
id: string;
traits?: Record<string, AnalyticsAttributeValue>;
};
export interface IAnalytics {
track(
event: string,
properties?: Record<string, AnalyticsAttributeValue>,
user?: AnalyticsUser,
): void;
identify(user: AnalyticsUser): void;
pageView(
path: string,
properties?: Record<string, AnalyticsAttributeValue>,
): void;
flush(): Promise<void>;
}
```
`IAnalytics` extends `AnalyticsProtocol` (in `core-shared/di/bind-protocols.ts`) for the structural type pattern used by `IEventBus`/`IAuditLog`/etc.
### Manifest field
`useCases.<name>.analyticsEvents: string[]` — array of event slug literals. Default `[]`. Same syntax as `audits` / `publishes` / `consumes`. Cross-checked by `no-undeclared-analytics-event` against `analytics.track("<slug>", ...)` literal call sites in the use case body.
### Brand + wrapper
- `Analyzed<F> = F & { readonly __analyzed: true }` in `conformance/brands.ts`
- `withAnalytics(analytics, factory)` in `core-analytics` (NOT in `core-shared/instrumentation` — analytics is optional core, can't pollute core-shared). The wrapper attaches the brand via the existing `attachBrand` helper from `core-shared/conformance/brand-runtime`.
- Composition order (innermost → outermost): `factory(deps)``withAnalytics``withAudit``withCapture``withSpan`.
- `wireUseCase({ ... analytics })` — new optional arg. When provided AND the manifest declares `analyticsEvents.length > 0`, composes `withAnalytics` into the wrapper chain.
### `assertFeatureConformance`
When `manifest.useCases[name].analyticsEvents.length > 0`, the bound function MUST carry the `Analyzed` brand. Same shape as the existing `Audited` check, minus the `mutates` gate.
### ESLint rule
`conformance/no-undeclared-analytics-event` — warn severity. Applies to `*.use-case.ts`. Finds `analytics.track("X", ...)` with string-literal first argument, cross-checks `X` against manifest. Implementation mirrors `no-undeclared-audit` and `no-undeclared-event-publish`.
### React provider
`@repo/core-analytics/react` exports:
- `<AnalyticsProvider value={IAnalytics}>` — React context provider
- `useAnalytics(): IAnalytics` — context consumer hook
No auto-wired router events. Consumer wires their router's route-changed hook to call `useAnalytics().pageView(path)`.
### Optional-core requirements
Features that emit analytics declare `requiredCores: ["analytics"]` in their manifest. The existing `required-cores-installed` ESLint rule enforces the `@repo/core-analytics` package is present in `pnpm-workspace.yaml`.
## Testing decisions
- **Repository contract suite:** none — analytics has no repository surface (it's a sink, not a store).
- **Unit tests:** every interface method on `NoopAnalytics` + `RecordingAnalytics` has a sibling `.test.ts` (per conformance rule `usecase-must-have-test-file` extended in spirit). `withAnalytics` gets a brand-attachment test parallel to `withAudit.test.ts`.
- **Conformance test for `assertFeatureConformance`:** synthetic manifest + binder pair that omits `withAnalytics` when `analyticsEvents > 0` should throw `ConformanceError` with a message naming `Analyzed`. Mirror the existing `assert-bindings.test.ts` shape.
- **ESLint rule unit tests:** RuleTester-based, fixture-driven, parallel to `no-undeclared-audit.test.js`. Cover: passes when call slug declared, fires when undeclared, no-op on non-use-case files, no-op when manifest has no use cases.
- **React provider test:** React Testing Library — render with `<AnalyticsProvider value={recordingAnalytics}>`, child component calls `useAnalytics().track(...)`, assert `recordingAnalytics.tracked` contains the event.
- **No integration / e2e:** no real feature in this template wires analytics. Consumers add their own e2e.
- **Prior art:** `packages/core-audit/src/` is the closest parallel. `packages/core-shared/src/conformance/wire-use-case.test.ts` shows the brand-test shape. `packages/core-eslint/rules/no-undeclared-audit.test.js` is the ESLint-rule template.
## Open questions
- **Q1: Should `wireUseCase`'s analytics arg be required or optional when the manifest declares `analyticsEvents`?** — Optional. If omitted, the boot-time `assertFeatureConformance` will throw on the missing brand; the helper itself shouldn't enforce. This mirrors how `auditLog` is handled. Defer enforcement to the assertion layer.
- **Q2: Should `NoopAnalytics.flush()` resolve synchronously or with a microtask?** — Microtask (`Promise.resolve()`). Mirrors `RecordingAuditLog` async patterns and avoids consumers writing `await analytics.flush()` expecting a tick that doesn't happen with sync-resolved promises.
- **Q3: Should the React provider hook throw if used outside `<AnalyticsProvider>`, or fall back to Noop?** — Throw with a clear error. Same shape as `useContext` on a context with no default; fall-back-to-Noop hides wiring bugs.
- **Q4: Should we add a server-side smoke test parallel to the bind-production.smoke.test.ts pattern for analytics?** — No. No template feature wires analytics, so the test would have nothing to assert. Consumers who adopt analytics add their own per-feature smoke tests; the pattern is documented in `docs/guides/analytics.md`.
## Out of scope (deferred)
- **Per-framework router adapters** (Next App Router, TanStack Router) — separate future PRD if a consumer needs them.
- **Backend vendor evaluation** — runs through `/evaluate-library` skill per ADR-022 when a real consumer needs analytics.
- **Migration of existing manifests to add `analyticsEvents: []`** — handled by the conformance system's tolerance for missing optional fields; existing manifests stay untouched.
- **Unified `events: [...]` manifest field** — explicit alternative rejected in ADR-024 §B. Future ADR if real pain emerges.
- **PII allowlist enforcement at the interface level** — explicit alternative rejected in ADR-024 §"PII boundary". Consumer policy.
- **Storybook component for an analytics-instrumented button** — useful demo but not load-bearing for the channel itself.
## Further notes
- **Builds on:** ADR-018 (audit channel — closest structural precedent), ADR-022 (library evaluation policy — vendor choice gate), ADR-024 (the architectural decision this PRD implements).
- **Pairs with:** future "client-side observability harmonization" PRD if/when `ITracer` / `ILogger` grow client-side abstractions matching the `<AnalyticsProvider>` shape.
- **Stakeholders:** template authors (most affected — adding a channel changes conformance count), downstream consumers (positively affected — gain a contract surface), AI agents operating in feature code (positively affected — manifest gates extend to a fourth signal).
- **Sequencing:** decomposer should chain stories so brand + wrapper land before manifest schema + wireUseCase, which lands before `assertFeatureConformance` extension, which lands before BindContext + ESLint rule, which lands before React provider, which lands before docs. The React subpath can technically land at any point after the interface ships, but docs should be last so they reference the final shape.

View File

@@ -1,419 +0,0 @@
---
id: security-headers-rate-limit-sbom
title: Security headers + rate-limit primitive + SBOM in CI — Epic C of ADR-025
type: prd
status: approved
author: danijel
created: 2026-05-19T11:05:39Z
updated: 2026-05-19T11:09:21.438Z
---
## Problem
Three load-bearing security primitives the playbook §5, §6, §13 require are missing from the template:
- **Security headers** — no app in the template ships HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy, or CSP by default. The playbook calls these "mandatory"; security scanners (securityheaders.com, Mozilla Observatory) flag the absence on first scan. Every consumer reinvents the middleware, and getting CSP right with nonces + Sentry replay + Tailwind inline styles is non-trivial enough that most consumers ship `'unsafe-inline'` and call it done.
- **Rate-limit primitive** — no template surface for "this endpoint is rate-limited." Auth use cases (signIn, signUp) ship without rate-limit declarations; credential-stuffing and account-enumeration windows stay open until a real consumer notices their auth logs. The playbook §5 says "all authentication endpoints rate-limited per IP and per account" — declaratively impossible today.
- **SBOM** — no Software Bill of Materials generated on release. Consumers pursuing SOC 2 / ISO 27001 / FedRAMP / EU CRA evidence have to invent SBOM tooling and bolt it into their release flow. Audit-prep tax compounds across every consumer.
ADR-025 settled the strategy: framework-agnostic header middleware in `core-shared/security`, rate-limit as fourth conformance channel in `core-shared/rate-limit`, SBOM via `cyclonedx-npm` in `release-please.yml`. This PRD is the implementation seed for Epic C.
## Goal
Ship the three hardening primitives so a downstream consumer gets compliant default headers, manifest-declared rate-limit gates, and per-release SBOM evidence without inventing any of them. Template apps (`web-next`, `web-tanstack`, `cms`) wire the middleware end-to-end; auth feature ships rate-limit as the canonical reference example; release-please attaches a CycloneDX SBOM to every tagged release.
## In scope
### Security headers middleware in `core-shared/security`
- New module `core-shared/security/` exporting framework-agnostic header builder:
- `buildSecurityHeaders(opts: SecurityHeadersConfig): Record<string, string>` — returns header name → value map
- `SecurityHeadersConfig` type: `{ mode: "production" | "development", nonce?: string, allowedConnectOrigins?, allowedImgOrigins?, allowedFontOrigins?, csp?: { reportUri?: string, reportOnly?: boolean } }`
- `generateNonce(): string` — cryptographically random 16-byte base64-encoded
- Six headers emitted: HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy, Content-Security-Policy
- Per-framework adapter subpaths (matches `core-analytics/react` pattern):
- `core-shared/security/next` — Next.js middleware function + `getNonce()` helper reading from `headers()`
- `core-shared/security/tanstack` — TanStack Start server middleware analog + request-context nonce extractor
- CSP defaults by mode (auto-switched from `NODE_ENV`):
- **Production** — strict CSP with per-request nonce: `default-src 'self'; script-src 'self' 'strict-dynamic' 'nonce-{NONCE}'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self' {ALLOWED_ORIGINS}; frame-ancestors 'none'; form-action 'self'; base-uri 'self'; upgrade-insecure-requests;`
- **Development** — permissive: `default-src 'self' 'unsafe-inline' 'unsafe-eval' ws: localhost:* 127.0.0.1:*;`
- App wiring across all three template apps:
- `apps/web-next``middleware.ts` at app root invoking `core-shared/security/next`
- `apps/web-tanstack``app.config.ts` server middleware
- `apps/cms` — Payload `express` config middleware
- Sentry browser SDK nonce-aware init in `apps/web-next/instrumentation-client.ts` + `apps/web-tanstack` equivalent (reads `getNonce()` and threads it into `Sentry.init({ ... })` + replay/feedback integrations)
### Rate-limit primitive in `core-shared/rate-limit`
- Scaffolded inline in `core-shared` (small surface; doesn't justify a new optional core)
- `IRateLimit` interface — `consume(budgetName, key, weight?): Promise<RateLimitDecision>` + `reset(budgetName, key): Promise<void>` + `RateLimitDecision = { allowed, remaining, resetAt }`
- Three impls:
- `NoopRateLimit` — always-allow default
- `InMemoryRateLimit` — per-process Map-backed; dev/single-replica enforcement
- `RecordingRateLimit` (lives in `core-testing`) — captures all calls for assertions
- `withRateLimit(rateLimit, factory)` wrapper attaching `RateLimited` brand at DI bind time
- `RateLimited` brand definition added to `core-shared/conformance/brands.ts`
- New per-use-case manifest field `rateLimit: RateLimitBudget[]` where `RateLimitBudget = { name: string, window: string, budget: number }`
- New ESLint rule `no-undeclared-rate-limit` (warn severity):
- Warn on `rateLimit.consume("X", _)` literal `budgetName` not declared in manifest
- Warn on declared budget never consumed
- `assertFeatureConformance` extended: require `RateLimited` brand when `rateLimit.length > 0`
- Brand composition order updated to innermost: `withSpan → withCapture → withAudit → withAnalytics → withConsent → withRateLimit → factory(deps)`
- `BindContext` gains `rateLimit?: IRateLimit` (defaults to `NoopRateLimit` when consumer doesn't wire one)
- `auth.signIn` backfilled as canonical reference example:
- Manifest: `rateLimit: [{ name: "ip", window: "1m", budget: 5 }, { name: "account", window: "1h", budget: 10 }]`
- Use case body: dual `consume("ip", ...)` + `consume("account", ...)` calls
### SBOM generation in CI
- Amendment to ADR-023 §10 (SBOM bullet) — adds the concrete workflow step
- Step added to `.github/workflows/release-please.yml`:
- Runs only when release-please cuts a release (`steps.release.outputs.releases_created == 'true'`)
- Invokes `pnpm dlx @cyclonedx/cyclonedx-npm` to generate `sbom-<tag>.cdx.json` covering the entire workspace via `pnpm-lock.yaml`
- Attaches SBOM as a GitHub release asset via `softprops/action-gh-release` (Renovate-pinned SHA per ADR-023)
### Documentation
- `docs/guides/security-headers.md` — full cookbook (per-framework wiring, nonce threading for consumer-added inline scripts, CSP allowlist customization, Sentry nonce integration, securityheaders.com verification)
- `docs/guides/rate-limiting.md` — cookbook (manifest field, naming convention `<feature>:<scope>:<key>`, multi-budget patterns, dev/staging/prod backend wiring guidance)
- `docs/glossary.md` — entries for `RateLimited` brand, `IRateLimit`, `SecurityHeadersConfig`, `buildSecurityHeaders`, `SBOM` (CycloneDX terminology)
- `CLAUDE.md` + `conformance-quickref.md` — rule count bump (12 → 13) + new manifest field documentation
- `docs/decisions/adr-023-ci-security-and-supply-chain.md` — amendment subsection capturing the SBOM workflow step
## Out of scope
- **Compliance fill-in docs (incident runbook, password policy templates, etc.)** — Epic D
- **Vendor-specific rate-limit backend** (Redis/Upstash/Cloudflare adapters) — consumer wires via ADR-022 library evaluation
- **Tracking IPs for rate-limit keys via request context** — caller-provided keys per Q5 of grill; consumer threads request context into use case input or builds key from controller
- **Distributed/multi-replica InMemoryRateLimit semantics** — per-process Map is dev-only; documented as such
- **Per-PR SBOM generation** — SBOM is a release artifact, per-release only (Q8 of grill)
- **SBOM signing / attestation** — bare SBOM only; SLSA-style provenance attestation is a future PRD if a consumer asks
- **CSP report-uri endpoint scaffolding** — CSP supports `report-uri` / `report-to` for violation collection but the template doesn't ship a collector. Config exposes the option; consumer wires their own collector
- **Nonce-aware policy for consumer-added inline scripts** — template documents the contract (`getNonce()` helper available; consumer threads via `<Script nonce={...}>`) but doesn't enforce
- **CSP for the Storybook app** — Storybook's renderer needs `'unsafe-eval'` for some addons; out of scope
- **Frame-ancestors override for embeddable widgets** — default `'none'`; consumer overrides when they ship embeddable surfaces
- **HSTS preload list submission** — `preload` directive emitted but submission to hstspreload.org is consumer/legal action
## Constraints
- **ADR-025** — Epic C strategy settled there.
- **ADR-014** — Sentry browser SDK init must respect new nonce contract; can't break existing instrumentation flow.
- **ADR-017** — observability PII boundary stays untouched; CSP changes don't affect Sentry's PII scrubbing.
- **ADR-021** — release-please's workflow shape is canonical for the SBOM step.
- **ADR-022** — no new third-party runtime dependencies for security headers, rate-limit, or SBOM generation. `@cyclonedx/cyclonedx-npm` invoked via `pnpm dlx` (no install). Rate-limit reference impls (`NoopRateLimit`, `InMemoryRateLimit`) use no external libs.
- **ADR-023** — SBOM step is an amendment to §10; CI workflow changes follow the established SHA-pin pattern.
- **ADR-024** + Epic B `requiresConsent`\*\* — rate-limit wrapper composes innermost (after consent); the order is canonical and tested.
- **Generator-first** — `withRateLimit` wrapper, `RateLimited` brand, ESLint rule additions follow the established conformance scaffold pattern.
- **Manifest-first ordering** — manifest schema + types land first; ESLint rule second; wrapper third; backfill (`auth.signIn`) fourth; app wiring last.
- **`core-shared` boundary** — rate-limit + security live in `core-shared` (must-have, available to every consumer). No new optional cores.
- **Conventional Commits** — every slice = one green commit.
## Success criteria
- `pnpm dev` on any app emits all six security headers with mode=development CSP; browser dev tools show `Content-Security-Policy` and the other 5 headers on every response.
- `pnpm build && pnpm start` (production mode) on any app emits mode=production CSP with a unique nonce per request, and Sentry browser SDK initializes successfully (no CSP violations in browser console).
- `securityheaders.com` scan of a `pnpm start`-served app scores A or A+.
- `core-shared/security/next` exports a working Next.js middleware that integrates with the app's existing middleware chain (auth checks etc.).
- `core-shared/security/tanstack` exports a working TanStack Start adapter.
- `IRateLimit` interface has three working impls (Noop, InMemory, Recording) with passing tests.
- `auth.signIn` manifest declares `rateLimit: [{ name: "ip", ... }, { name: "account", ... }]`; use case body invokes both `consume` calls; binding fails `assertFeatureConformance` if `withRateLimit` is omitted.
- `no-undeclared-rate-limit` ESLint rule fires on `rateLimit.consume("foo", ...)` where `"foo"` is not declared in the manifest; passes on matching calls.
- A merged release-please PR cuts a tag AND the release-please workflow uploads a CycloneDX SBOM as a release asset; the asset opens in any CycloneDX viewer.
- `pnpm typecheck && pnpm lint && pnpm test && pnpm conformance && pnpm fallow:audit && pnpm coverage:diff && pnpm compliance:emit-all --check` all green at every commit boundary.
- `docs/guides/security-headers.md` + `docs/guides/rate-limiting.md` cover consumer wiring end-to-end including the Sentry nonce thread and the canonical key-naming convention.
- CLAUDE.md + `conformance-quickref.md` reflect 13 conformance ESLint rules.
## User stories
1. As a **downstream consumer**, I want six security headers shipped by default in all three template apps so a securityheaders.com scan immediately scores A/A+ without my writing middleware.
2. As a **downstream consumer running Next.js**, I want a per-request CSP nonce so I can use Next.js's `<Script>` component without falling back to `'unsafe-inline'`.
3. As a **downstream consumer running TanStack Start**, I want the same nonce contract as Next.js so my dual-framework deployment behaves consistently.
4. As a **downstream consumer**, I want CSP to auto-switch between strict (production) and permissive (development) so my dev tooling (HMR, inline-style libraries) keeps working without manual config.
5. As a **downstream consumer customizing CSP**, I want `allowedConnectOrigins` + `allowedImgOrigins` config so I can allowlist my Sentry DSN host, analytics backend, CDN, etc. without forking the builder.
6. As a **downstream consumer shipping auth**, I want `rateLimit: [{ name: "ip" }, { name: "account" }]` on signIn so credential stuffing fails fast at lint time if I forget to wire it.
7. As a **downstream consumer**, I want `IRateLimit` to support multiple named budgets per use case so I can express "5/min per IP AND 10/hour per account" without homegrown key concatenation.
8. As an **AI agent** modifying signIn's use case body, I want `no-undeclared-rate-limit` to fire when I call `rateLimit.consume("foo", ...)` without declaring `"foo"` in manifest, so rate-limit drift is caught at lint time.
9. As an **AI agent** scaffolding a new auth/write/export use case, I want manifest to default to `rateLimit: []` so I'm prompted to think about rate-limit during manifest-first ordering.
10. As a **downstream consumer running InMemoryRateLimit in dev**, I want predictable single-process enforcement so I can test 429 responses locally without spinning up Redis.
11. As an **SRE rolling out production**, I want a Redis-backed `IRateLimit` impl I can swap in via `BindContext.rateLimit` so my multi-replica deployment shares state.
12. As a **compliance officer pursuing SOC 2**, I want a CycloneDX SBOM attached to every GitHub release so my auditor can answer "what's in version X" without inventory inspection.
13. As a **downstream consumer running serverless**, I want SBOM generation to happen at release time only (not per-PR) so my CI minutes don't balloon.
14. As an **AI agent** sequencing the dispatch, I want Epic C stories independent of Epic A/B so I can interleave with the dependency-ordered chain.
## Implementation decisions
### Module surface
- **`@repo/core-shared` additions** (must-have package, no new optional core):
- New module `core-shared/security/` containing:
- `security-types.ts``SecurityHeadersConfig`, `CspMode`, `CspDirective` types
- `build-security-headers.ts` — pure builder function
- `nonce.ts``generateNonce()` cryptographic helper
- `next/index.ts` — Next.js middleware adapter + `getNonce()` from `headers()`
- `tanstack/index.ts` — TanStack Start adapter
- New module `core-shared/rate-limit/` containing:
- `rate-limit.interface.ts``IRateLimit`, `RateLimitBudget`, `RateLimitDecision` types
- `noop-rate-limit.ts` + sibling test
- `in-memory-rate-limit.ts` + sibling test
- `with-rate-limit.ts` — wrapper attaching `RateLimited` brand
- Extension to `core-shared/conformance/brands.ts`:
- `RateLimited<F> = F & { readonly __rateLimited: true }` type
- Helper `isRateLimited(fn): boolean`
- Extension to `core-shared/conformance/wire-use-case.ts`:
- Accept optional `rateLimit: IRateLimit` arg; compose `withRateLimit` innermost when manifest declares `rateLimit.length > 0`
- Extension to `core-shared/conformance/assert-bindings.ts`:
- Require `RateLimited` brand when `manifest.useCases[name].rateLimit.length > 0`
- Extension to `core-shared/conformance/define-feature.ts`:
- Type `UseCaseManifest.rateLimit?: RateLimitBudget[]`
- Extension to `core-shared/di/bind-context.ts`:
- `BindContext.rateLimit?: IRateLimit` (defaults to `NoopRateLimit` at app aggregator level)
- **`@repo/core-testing` additions**:
- `recording-rate-limit.ts` — captures `consume` + `reset` invocations
- **`@repo/core-eslint` additions**:
- New rule `no-undeclared-rate-limit.js` (warn severity)
- `_manifest-ast.js` parser gains `rateLimit` field extraction
- `plugin.js` + `base.js` register the rule at warn
- **`packages/auth/` modifications**:
- `feature.manifest.ts` — add `rateLimit: [{ name: "ip", window: "1m", budget: 5 }, { name: "account", window: "1h", budget: 10 }]` to `signIn`
- `application/use-cases/sign-in.use-case.ts` — body invokes both `rateLimit.consume` calls; deps signature gains `rateLimit: IRateLimit`
- `di/bind-production.ts` + `bind-dev-seed.ts` — pass `ctx.rateLimit` (or `new NoopRateLimit()` fallback) into `wireUseCase` for signIn
- **`apps/web-next` modifications**:
- `middleware.ts` — invokes `core-shared/security/next` middleware; chains with existing auth checks
- `instrumentation-client.ts` — reads nonce via `getNonce()`, passes to `Sentry.init({ ..., transportOptions, integrations })`
- `app/layout.tsx` — threads nonce into `<Script>` tags (Sentry, analytics, any inline scripts)
- **`apps/web-tanstack` modifications**:
- `app.config.ts` — register server middleware from `core-shared/security/tanstack`
- Equivalent client init file (`src/client.tsx` or framework convention) — nonce-aware Sentry init
- **`apps/cms` modifications**:
- Payload config — wire Express middleware from `core-shared/security` (cms is server-side only; no nonce concern)
- **`.github/workflows/release-please.yml` modifications**:
- Conditional SBOM generation step after release-creation
- SBOM upload to GitHub release via `softprops/action-gh-release`
- **`docs/decisions/adr-023-ci-security-and-supply-chain.md`**:
- Amendment subsection capturing the SBOM workflow step concrete shape
- **`docs/guides/`**:
- New: `security-headers.md`
- New: `rate-limiting.md`
- **`docs/glossary.md`**:
- Entries: `IRateLimit`, `RateLimited` brand, `withRateLimit`, `SecurityHeadersConfig`, `buildSecurityHeaders`, `SBOM`, `nonce` (CSP context)
- **`CLAUDE.md` + `docs/guides/conformance-quickref.md`**:
- Rule count bump: 12 → 13
- New manifest field `rateLimit` documented in conformance table
### Type primitive contracts (inlined where decision-encoding tight)
```ts
// core-shared/security/security-types.ts
export type CspMode = "production" | "development";
export type SecurityHeadersConfig = {
mode: CspMode;
nonce?: string;
allowedConnectOrigins?: string[];
allowedImgOrigins?: string[];
allowedFontOrigins?: string[];
csp?: {
reportUri?: string;
reportOnly?: boolean;
};
};
// core-shared/rate-limit/rate-limit.interface.ts
export type RateLimitBudget = {
name: string;
window: string; // ISO 8601 duration, e.g. "P1M" or shorthand "1m" / "1h"
budget: number;
};
export type RateLimitDecision = {
allowed: boolean;
remaining: number;
resetAt: Date;
};
export interface IRateLimit {
consume(
budgetName: string,
key: string,
weight?: number,
): Promise<RateLimitDecision>;
reset(budgetName: string, key: string): Promise<void>;
}
```
```ts
// auth.signIn — canonical example
// feature.manifest.ts entry:
signIn: {
mutates: false,
audits: [],
publishes: [],
consumes: [],
analyticsEvents: [],
requiresConsent: ["essential"],
rateLimit: [
{ name: "ip", window: "1m", budget: 5 },
{ name: "account", window: "1h", budget: 10 },
],
}
// use case body:
export const signInUseCase = (deps) => async (input) => {
const ipDecision = await deps.rateLimit.consume("ip", `signIn:ip:${input.clientIp}`);
if (!ipDecision.allowed) throw new TooManyRequestsError("ip");
const accountDecision = await deps.rateLimit.consume("account", `signIn:account:${input.email}`);
if (!accountDecision.allowed) throw new TooManyRequestsError("account");
// ... rest of business logic
};
```
### CSP defaults
**Production CSP (template emits with nonce):**
```
default-src 'self';
script-src 'self' 'strict-dynamic' 'nonce-{NONCE}';
style-src 'self' 'unsafe-inline';
img-src 'self' data: https:;
font-src 'self';
connect-src 'self' {ALLOWED_CONNECT_ORIGINS};
frame-ancestors 'none';
form-action 'self';
base-uri 'self';
upgrade-insecure-requests;
```
`'strict-dynamic'` + nonce: nonce-tagged scripts can load further scripts dynamically without enumeration. Required for Sentry's lazy-loaded modules. `'unsafe-inline'` in `style-src` is a known compromise (Tailwind, CSS-in-JS, framework runtime inject inline styles); production CSP eliminates `'unsafe-inline'` for `script-src` (the actually-dangerous one).
**Development CSP:**
```
default-src 'self' 'unsafe-inline' 'unsafe-eval' ws: localhost:* 127.0.0.1:*;
```
Allows Next.js HMR (websocket), React Refresh (unsafe-eval), and inline scripts. Auto-emitted when `mode === "development"`.
### Sentry nonce integration
Per-framework integration:
- **Next.js** (`apps/web-next/instrumentation-client.ts`):
```ts
const nonce = (await headers()).get("x-nonce") ?? undefined;
Sentry.init({
dsn: ...,
integrations: [
Sentry.replayIntegration({ ..., nonce }),
Sentry.feedbackIntegration({ ..., nonce }),
],
});
```
- **TanStack Start** — equivalent via request-context extraction
- **Layout / Document head** — `<Script nonce={nonce}>` for any inline scripts the app ships
The `getNonce()` helper exported from `core-shared/security/next` abstracts the framework-specific extraction (`headers().get("x-nonce")` in Next.js Server Components).
### Rate-limit wrapper composition
Composition order (innermost → outermost):
```
withSpan ⟶ withCapture ⟶ withAudit ⟶ withAnalytics ⟶ withConsent ⟶ withRateLimit ⟶ factory(deps)
```
Rate-limit innermost because:
- Rate-limit fires after every other gate (audit, span, capture). The body running rate-limit `consume` calls is the actual work; outer layers wrap the work, including the rate-limit check.
- Spans should include rate-limit decision latency for observability — if `withSpan` were inside `withRateLimit`, the span would miss the rate-limit decision.
### CI workflow amendment (ADR-023 §10)
`.github/workflows/release-please.yml` gains a step after the release-please action emits `releases_created`:
```yaml
- name: Generate SBOM
if: ${{ steps.release.outputs.releases_created == 'true' }}
run: pnpm dlx @cyclonedx/cyclonedx-npm --output-file sbom-${{ steps.release.outputs.tag_name }}.cdx.json --output-format json
- name: Upload SBOM to GitHub release
if: ${{ steps.release.outputs.releases_created == 'true' }}
uses: softprops/action-gh-release@<SHA>
with:
files: sbom-${{ steps.release.outputs.tag_name }}.cdx.json
tag_name: ${{ steps.release.outputs.tag_name }}
```
`<SHA>` is Renovate-managed per ADR-023. `pnpm dlx` avoids adding cyclonedx-npm to the lockfile (it's a CI-only tool, not a runtime dep).
Per-package SBOMs aren't generated — release-please cuts multiple per-package tags simultaneously, but the root SBOM covers all workspace packages and is the canonical evidence per industry practice.
### Conformance impact
- ESLint rule count: 12 → 13 (`no-undeclared-rate-limit`)
- Manifest field: `rateLimit?: RateLimitBudget[]` (optional, defaults to `[]`)
- New brand: `RateLimited`
- Boot assertion: extended to require `RateLimited` brand when `rateLimit.length > 0`
- New manifest channel doesn't require a new optional core (`core-shared/rate-limit` is must-have)
## Testing decisions
- **`core-shared/security`**:
- Unit tests on `buildSecurityHeaders`: returns expected header set per mode, nonce threaded into CSP when provided, optional config fields applied to CSP allowlists, dev vs prod CSP shape
- Unit test on `generateNonce`: returns base64-encoded 16-byte strings, two calls return different values
- Adapter tests for `core-shared/security/next`: middleware sets headers + injects nonce into response headers + makes nonce available to `getNonce()` helper
- Adapter tests for `core-shared/security/tanstack`: equivalent
- **`core-shared/rate-limit`**:
- `NoopRateLimit` test: `consume` always returns `{ allowed: true }`, `reset` is no-op
- `InMemoryRateLimit` test: tracks per-bucket counts correctly, decrements on consume, resets at window boundary, resets to budget on explicit `reset` call, concurrent consumes are safe (single-threaded JS — straightforward)
- `RecordingRateLimit` test: captures invocation arguments verbatim
- `withRateLimit` wrapper test: `RateLimited` brand attached, factory invocation passthrough preserved, composes with other wrappers
- **`assertFeatureConformance` extension**:
- Synthetic manifest fixture with `rateLimit: [{ name: "ip", ... }]` but no `withRateLimit` at bind: fails with `ConformanceError` naming the missing brand
- **`no-undeclared-rate-limit` ESLint rule**:
- RuleTester fixtures parallel to `no-undeclared-audit.test.js`
- Passes when call's `budgetName` matches declared
- Fires on undeclared `budgetName`
- Fires on declared-but-unused (warn)
- No-op on non-use-case files
- **`auth.signIn` integration**:
- Existing signIn test extended: with `RecordingRateLimit`, assert dual-consume captured; with `InMemoryRateLimit` set to budget 1, second call fails with `TooManyRequestsError`
- **App integration**:
- `apps/web-next/middleware.test.ts` (or equivalent): assertion that all six headers present in response, CSP shape correct for `NODE_ENV=production` + `NODE_ENV=development`, nonce header present in response
- Manual verification: `pnpm build && pnpm start`, hit `localhost:3000`, browser dev tools shows all headers + functioning Sentry replay (no CSP violations)
- **SBOM workflow**:
- Verify locally: `pnpm dlx @cyclonedx/cyclonedx-npm --output-file sbom-test.cdx.json` succeeds and produces valid CycloneDX JSON
- End-to-end verification: merge a release-please PR, confirm SBOM asset appears on the resulting GitHub release
- **Coverage**: all new modules join L0 vitest thresholds per their `coverage.bands`. L1 `pnpm coverage:diff` gates every slice.
- **Prior art to mirror**:
- Wrapper + brand pattern: `core-shared/instrumentation/with-capture.{ts,test.ts}` + `core-shared/conformance/wire-use-case.{ts,test.ts}`
- ESLint rule shape: `packages/core-eslint/rules/no-undeclared-audit.{js,test.js}`
- Three-impl interface pattern: `core-shared/jobs/{job-queue.interface.ts, payload-job-queue.ts, in-memory-job-queue.ts}` + `core-testing/instrumentation/recording-job-queue.ts`
- Per-framework adapter subpath: `core-analytics/react` from ADR-024 (ships React provider as subpath)
## Open questions
- **Q1: Should `InMemoryRateLimit` honor TTL via `setTimeout` or check-at-read for bucket expiry?** — Recommended: **check-at-read**. setTimeout floods the event loop with timers in busy apps. Check-at-read on every `consume` call evaluates `now() > resetAt` and resets if expired. Single test case: assert bucket resets after `window` elapses with synthetic clock injection.
- **Q2: Does `withRateLimit` short-circuit the use case body on `!allowed`, or does the body decide?** — Recommended: **body decides**. The wrapper attaches the brand only; the use case body calls `consume(...)` explicitly and decides whether to throw `TooManyRequestsError` or degrade gracefully. Mirrors the audit pattern (`auditLog.record` doesn't decide flow control).
- **Q3: Should the security headers builder validate that `allowedConnectOrigins` entries are well-formed URLs?** — Recommended: **yes, with `URL` constructor parse**. Throws `InvalidSecurityHeadersConfig` at app boot if an allowlisted origin is malformed. Catches typos early. Documented in `docs/guides/security-headers.md`.
- **Q4: For TanStack Start nonce: where does the app store/retrieve the nonce — request context, async-local-storage, or response header?** — Recommended: **request context via TanStack's existing mechanisms**. Use `useRouterState()` or context provider for client-side access; server middleware reads from `event.node.req.headers["x-nonce"]` after setting it. Document the framework-specific extraction in `docs/guides/security-headers.md`.
- **Q5: How does CSP interact with `apps/storybook`?** — Recommended: **out of Epic C scope** (called out in "Out of scope"). Storybook's renderer needs `'unsafe-eval'`; shipping strict CSP there breaks docs. Document the omission.
## Out of scope (deferred)
- **Compliance fill-in docs (incident runbook, password policy, etc.)** — Epic D
- **Redis-backed `IRateLimit` reference impl** — consumer wires via ADR-022 library evaluation (`@upstash/ratelimit` or similar would need a trace)
- **CSP report-uri collector endpoint** — consumer-side; could be added later
- **SBOM signing / SLSA attestation** — bare CycloneDX SBOM only; signed attestations are a future PRD
- **Per-PR SBOM generation** — release-only per Q8 of grill
- **`apps/storybook` CSP** — Storybook tooling requires `'unsafe-eval'`; explicit deferral
- **`IRateLimit` v2: token-bucket algorithm vs fixed-window** — InMemoryRateLimit uses fixed-window for simplicity; consumer's production backend likely uses token-bucket; documented as such
- **CSP nonce reuse strategy** — every request gets a fresh nonce; no reuse across requests. Don't optimize prematurely.
- **HSTS preload list submission** — `preload` directive emitted but hstspreload.org submission is consumer/legal action
## Further notes
- **Builds on**: ADR-014 (Sentry observability — nonce integration), ADR-017 (PII boundary — CSP shouldn't affect server-side scrubbing), ADR-021 (release-please — SBOM step extends), ADR-022 (library evaluation — no new traces needed), ADR-023 (CI security — SBOM amendment), ADR-024 (analytics — confirms wrapper composition order), ADR-025 (strategy umbrella).
- **Pairs with**: Epic A PRD (`compliance-manifests-pii-retention-subprocessors.prd.md` — no direct dependency but Epic C's `auth.signIn` rate-limit backfill follows Epic A's backfill pattern), Epic B PRD (`dsr-consent-and-cookie-banner.prd.md` — wrapper composition order extended consistently), Epic D PRD (`compliance-docs-scaffolds.prd.md` — references Epic C's middleware + rate-limit + SBOM in security checklist documents).
- **Sequencing within Epic C**: type primitives (`SecurityHeadersConfig`, `RateLimitBudget`, `IRateLimit`) → manifest schema + ESLint rule → wrapper + brand + `wireUseCase` extension → `auth.signIn` backfill → security headers builder + per-framework adapters → app integration (web-next middleware + Sentry nonce, web-tanstack same, cms middleware) → SBOM workflow step + ADR-023 amendment → docs.
- **Dispatch independence**: Epic C has no dependencies on Epic A's deliverables or Epic B's; the dispatch loop can interleave Epic C stories with Epic B's whenever an Epic B story is blocked.
- **Stakeholders**: template authors (most affected — adds manifest field + brand + ESLint rule + ADR-023 amendment), downstream consumers (positively affected — gain compliant headers + rate-limit + SBOM out of box), AI agents operating in feature code (positively affected — declarative rate-limit gates with lint-time enforcement), compliance officers (positively affected — SBOM evidence per release; audit-grade headers), SREs (positively affected — IRateLimit interface for production wiring).