Initial commit
This commit is contained in:
26
docs/work/README.md
Normal file
26
docs/work/README.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# docs/work — the local task system
|
||||
|
||||
Filesystem-backed Epic/Story/Task hierarchy used by AI agents and humans alike.
|
||||
See `docs/architecture/agent-first-workflow-and-conformance.md` for the full
|
||||
design. Until the `work-system-v1` epic ships orchestration tooling, this
|
||||
folder is human-driven — agents read the files for context, humans flip
|
||||
checkboxes.
|
||||
|
||||
## Layout
|
||||
|
||||
- `prds/<date>-<slug>.prd.md` — source PRDs
|
||||
- `<epic-slug>/_epic.md` — one folder per epic
|
||||
- `<epic-slug>/<story-slug>/_story.md` — one folder per story
|
||||
- `<epic-slug>/<story-slug>/<task-slug>.task.md` — one file per task
|
||||
- `_templates/` — copy-paste templates (added in work-system-v1)
|
||||
- `_state.json` — derived index (added in work-system-v1)
|
||||
|
||||
## PRD lifecycle
|
||||
|
||||
PRD `status` frontmatter field: `draft → in-review → approved → shipped`.
|
||||
|
||||
- **draft → in-review** — author flips when ready for review (manual)
|
||||
- **in-review → approved** — human reviewer flips on acceptance (manual)
|
||||
- **approved → shipped** — auto-flipped by `pnpm work prd-ship <prd-id>` when the seed epic finishes. The state-builder surfaces this signal under `_state.json` → `needs_prd_ship[]` so the orchestrator (or a reviewer running the sandcastle workflow) can act on it.
|
||||
|
||||
The decomposer refuses to run on `draft` PRDs. Once approved, the seed epic is generated; once the epic completes, the PRD is automatically flipped to `shipped` along with its commit list.
|
||||
764
docs/work/_system/_state.json
Normal file
764
docs/work/_system/_state.json
Normal file
@@ -0,0 +1,764 @@
|
||||
{
|
||||
"updated_at": "2026-05-20T12:57:04.572Z",
|
||||
"epics": {
|
||||
"binder-wrap-helper": {
|
||||
"status": "done",
|
||||
"title": "Collapse binder duplication via wireUseCase helper",
|
||||
"prd": "docs/work/prds/binder-wrap-helper.prd.md",
|
||||
"stories": {
|
||||
"01-wire-use-case-helper": {
|
||||
"status": "done",
|
||||
"title": "Introduce wireUseCase helper in core-shared",
|
||||
"ac_total": 1,
|
||||
"ac_completed": 1,
|
||||
"depends_on": [],
|
||||
"blocks": [
|
||||
"02-migrate-feature-binders",
|
||||
"03-update-generator-templates"
|
||||
]
|
||||
},
|
||||
"02-migrate-feature-binders": {
|
||||
"status": "done",
|
||||
"title": "Migrate all five feature binders to wireUseCase",
|
||||
"ac_total": 5,
|
||||
"ac_completed": 5,
|
||||
"depends_on": [
|
||||
"01-wire-use-case-helper"
|
||||
],
|
||||
"blocks": []
|
||||
},
|
||||
"03-update-generator-templates": {
|
||||
"status": "done",
|
||||
"title": "Update feature generator templates to emit wireUseCase call shape",
|
||||
"ac_total": 1,
|
||||
"ac_completed": 1,
|
||||
"depends_on": [
|
||||
"01-wire-use-case-helper"
|
||||
],
|
||||
"blocks": []
|
||||
}
|
||||
}
|
||||
},
|
||||
"ci-security-and-supply-chain": {
|
||||
"status": "done",
|
||||
"title": "CI security + supply-chain enforcement stack",
|
||||
"prd": "docs/work/prds/ci-security-and-supply-chain.prd.md",
|
||||
"stories": {
|
||||
"01-trace-schema-extensions": {
|
||||
"status": "done",
|
||||
"title": "Trace schema extensions (socketRisk + lastRevalidated)",
|
||||
"ac_total": 1,
|
||||
"ac_completed": 1,
|
||||
"depends_on": [],
|
||||
"blocks": []
|
||||
},
|
||||
"02-socket-integration": {
|
||||
"status": "done",
|
||||
"title": "Socket integration (skill + CI)",
|
||||
"ac_total": 2,
|
||||
"ac_completed": 2,
|
||||
"depends_on": [
|
||||
"01-trace-schema-extensions"
|
||||
],
|
||||
"blocks": [
|
||||
"08-reviewer-prompt-update"
|
||||
]
|
||||
},
|
||||
"03-renovate-adoption": {
|
||||
"status": "done",
|
||||
"title": "Renovate adoption",
|
||||
"ac_total": 1,
|
||||
"ac_completed": 1,
|
||||
"depends_on": [],
|
||||
"blocks": [
|
||||
"09-ci-security-guide-and-docs"
|
||||
]
|
||||
},
|
||||
"04-major-bump-reevaluation": {
|
||||
"status": "done",
|
||||
"title": "Major-bump re-evaluation flow",
|
||||
"ac_total": 1,
|
||||
"ac_completed": 1,
|
||||
"depends_on": [
|
||||
"01-trace-schema-extensions"
|
||||
],
|
||||
"blocks": [
|
||||
"05-trace-revalidation-workflow"
|
||||
]
|
||||
},
|
||||
"05-trace-revalidation-workflow": {
|
||||
"status": "done",
|
||||
"title": "Trace revalidation workflow",
|
||||
"ac_total": 2,
|
||||
"ac_completed": 2,
|
||||
"depends_on": [
|
||||
"01-trace-schema-extensions",
|
||||
"04-major-bump-reevaluation"
|
||||
],
|
||||
"blocks": [
|
||||
"09-ci-security-guide-and-docs"
|
||||
]
|
||||
},
|
||||
"06-codeql-and-audit-signatures": {
|
||||
"status": "done",
|
||||
"title": "CodeQL workflow + pnpm audit signatures",
|
||||
"ac_total": 2,
|
||||
"ac_completed": 2,
|
||||
"depends_on": [],
|
||||
"blocks": [
|
||||
"08-reviewer-prompt-update"
|
||||
]
|
||||
},
|
||||
"07-gitleaks-precommit": {
|
||||
"status": "done",
|
||||
"title": "Gitleaks pre-commit hook",
|
||||
"ac_total": 1,
|
||||
"ac_completed": 1,
|
||||
"depends_on": [],
|
||||
"blocks": [
|
||||
"09-ci-security-guide-and-docs"
|
||||
]
|
||||
},
|
||||
"08-reviewer-prompt-update": {
|
||||
"status": "done",
|
||||
"title": "Sandcastle reviewer prompt update",
|
||||
"ac_total": 1,
|
||||
"ac_completed": 1,
|
||||
"depends_on": [
|
||||
"02-socket-integration",
|
||||
"06-codeql-and-audit-signatures"
|
||||
],
|
||||
"blocks": [
|
||||
"09-ci-security-guide-and-docs"
|
||||
]
|
||||
},
|
||||
"09-ci-security-guide-and-docs": {
|
||||
"status": "done",
|
||||
"title": "CI security guide + CLAUDE.md",
|
||||
"ac_total": 2,
|
||||
"ac_completed": 2,
|
||||
"depends_on": [],
|
||||
"blocks": []
|
||||
}
|
||||
}
|
||||
},
|
||||
"compliance-docs-scaffolds": {
|
||||
"status": "done",
|
||||
"title": "Compliance docs scaffolds — Epic D of ADR-025",
|
||||
"prd": "docs/work/prds/compliance-docs-scaffolds.prd.md",
|
||||
"stories": {
|
||||
"01-land-operator-checklist": {
|
||||
"status": "done",
|
||||
"title": "Land operator-checklist.md verbatim",
|
||||
"ac_total": 1,
|
||||
"ac_completed": 1,
|
||||
"depends_on": [],
|
||||
"blocks": [
|
||||
"02-refresh-operator-checklist"
|
||||
]
|
||||
},
|
||||
"02-refresh-operator-checklist": {
|
||||
"status": "done",
|
||||
"title": "Refresh operator-checklist.md for ADR-024 and ADR-025",
|
||||
"ac_total": 1,
|
||||
"ac_completed": 1,
|
||||
"depends_on": [
|
||||
"01-land-operator-checklist"
|
||||
],
|
||||
"blocks": []
|
||||
},
|
||||
"03-policy-templates": {
|
||||
"status": "done",
|
||||
"title": "Write seven policy templates in docs/compliance/templates/",
|
||||
"ac_total": 2,
|
||||
"ac_completed": 2,
|
||||
"depends_on": [],
|
||||
"blocks": [
|
||||
"04-pre-launch-compliance-checklist",
|
||||
"05-compliance-overview"
|
||||
]
|
||||
},
|
||||
"04-pre-launch-compliance-checklist": {
|
||||
"status": "done",
|
||||
"title": "Write pre-launch compliance checklist",
|
||||
"ac_total": 1,
|
||||
"ac_completed": 1,
|
||||
"depends_on": [
|
||||
"03-policy-templates"
|
||||
],
|
||||
"blocks": [
|
||||
"05-compliance-overview"
|
||||
]
|
||||
},
|
||||
"05-compliance-overview": {
|
||||
"status": "done",
|
||||
"title": "Write compliance-overview.md hub",
|
||||
"ac_total": 1,
|
||||
"ac_completed": 1,
|
||||
"depends_on": [
|
||||
"03-policy-templates",
|
||||
"04-pre-launch-compliance-checklist"
|
||||
],
|
||||
"blocks": [
|
||||
"06-doc-wiring"
|
||||
]
|
||||
},
|
||||
"06-doc-wiring": {
|
||||
"status": "done",
|
||||
"title": "Wire compliance docs into CLAUDE.md, README, and glossary",
|
||||
"ac_total": 2,
|
||||
"ac_completed": 2,
|
||||
"depends_on": [
|
||||
"05-compliance-overview"
|
||||
],
|
||||
"blocks": []
|
||||
}
|
||||
}
|
||||
},
|
||||
"compliance-manifests-pii-retention-subprocessors": {
|
||||
"status": "done",
|
||||
"title": "Declarative compliance manifests (PII + retention + sub-processors) — Epic A of ADR-025",
|
||||
"prd": "docs/work/prds/compliance-manifests-pii-retention-subprocessors.prd.md",
|
||||
"stories": {
|
||||
"01-pii-retention-type-primitives": {
|
||||
"status": "done",
|
||||
"title": "PII and retention type primitives in core-shared",
|
||||
"ac_total": 1,
|
||||
"ac_completed": 1,
|
||||
"depends_on": [],
|
||||
"blocks": []
|
||||
},
|
||||
"02-eslint-rule-pii-declaration-complete": {
|
||||
"status": "done",
|
||||
"title": "Add pii-declaration-must-be-complete ESLint rule to core-eslint",
|
||||
"ac_total": 1,
|
||||
"ac_completed": 1,
|
||||
"depends_on": [
|
||||
"01-pii-retention-type-primitives"
|
||||
],
|
||||
"blocks": [
|
||||
"05-backfill-template-collections"
|
||||
]
|
||||
},
|
||||
"03-adr-022-amendment-and-evaluate-library-skill": {
|
||||
"status": "done",
|
||||
"title": "ADR-022 amendment for sub-processor fields and evaluate-library skill update",
|
||||
"ac_total": 1,
|
||||
"ac_completed": 1,
|
||||
"depends_on": [
|
||||
"01-pii-retention-type-primitives"
|
||||
],
|
||||
"blocks": []
|
||||
},
|
||||
"04-retention-purge-job": {
|
||||
"status": "done",
|
||||
"title": "Background retention purge job in core-shared",
|
||||
"ac_total": 1,
|
||||
"ac_completed": 1,
|
||||
"depends_on": [
|
||||
"01-pii-retention-type-primitives"
|
||||
],
|
||||
"blocks": []
|
||||
},
|
||||
"05-backfill-template-collections": {
|
||||
"status": "done",
|
||||
"title": "Backfill existing template Payload collections with PII and retention metadata",
|
||||
"ac_total": 5,
|
||||
"ac_completed": 5,
|
||||
"depends_on": [],
|
||||
"blocks": [
|
||||
"06-compliance-generator-scripts"
|
||||
]
|
||||
},
|
||||
"06-compliance-generator-scripts": {
|
||||
"status": "done",
|
||||
"title": "Compliance generator scripts (emit-data-map, emit-retention-policy, emit-sub-processors, emit-all)",
|
||||
"ac_total": 4,
|
||||
"ac_completed": 4,
|
||||
"depends_on": [
|
||||
"01-pii-retention-type-primitives",
|
||||
"05-backfill-template-collections"
|
||||
],
|
||||
"blocks": [
|
||||
"07-pre-commit-and-ci-integration",
|
||||
"08-docs-compliance-reference-files"
|
||||
]
|
||||
},
|
||||
"07-pre-commit-and-ci-integration": {
|
||||
"status": "done",
|
||||
"title": "Pre-commit hook and CI integration for compliance drift detection",
|
||||
"ac_total": 1,
|
||||
"ac_completed": 1,
|
||||
"depends_on": [
|
||||
"06-compliance-generator-scripts"
|
||||
],
|
||||
"blocks": []
|
||||
},
|
||||
"08-docs-compliance-reference-files": {
|
||||
"status": "done",
|
||||
"title": "docs/compliance reference example files and README",
|
||||
"ac_total": 1,
|
||||
"ac_completed": 1,
|
||||
"depends_on": [
|
||||
"06-compliance-generator-scripts"
|
||||
],
|
||||
"blocks": []
|
||||
}
|
||||
}
|
||||
},
|
||||
"dsr-consent-and-cookie-banner": {
|
||||
"status": "done",
|
||||
"title": "DSR + consent abstraction + cookie consent banner — Epic B of ADR-025",
|
||||
"prd": "docs/work/prds/dsr-consent-and-cookie-banner.prd.md",
|
||||
"stories": {
|
||||
"01-subject-linkage-types": {
|
||||
"status": "done",
|
||||
"title": "Subject-linkage types in core-shared",
|
||||
"ac_total": 1,
|
||||
"ac_completed": 1,
|
||||
"depends_on": [],
|
||||
"blocks": [
|
||||
"02-audit-enum-amendment"
|
||||
]
|
||||
},
|
||||
"02-audit-enum-amendment": {
|
||||
"status": "done",
|
||||
"title": "Audit action enum amendment (ADR-018)",
|
||||
"ac_total": 1,
|
||||
"ac_completed": 1,
|
||||
"depends_on": [
|
||||
"01-subject-linkage-types"
|
||||
],
|
||||
"blocks": [
|
||||
"03-core-consent-foundation",
|
||||
"06-core-dsr"
|
||||
]
|
||||
},
|
||||
"03-core-consent-foundation": {
|
||||
"status": "done",
|
||||
"title": "core-consent foundation — types, brand, withConsent wrapper, conformance extension, ESLint rule",
|
||||
"ac_total": 2,
|
||||
"ac_completed": 2,
|
||||
"depends_on": [
|
||||
"02-audit-enum-amendment"
|
||||
],
|
||||
"blocks": [
|
||||
"04-core-consent-implementation"
|
||||
]
|
||||
},
|
||||
"04-core-consent-implementation": {
|
||||
"status": "done",
|
||||
"title": "core-consent implementation — Payload impl, DI binders, migration helpers, tRPC router",
|
||||
"ac_total": 3,
|
||||
"ac_completed": 3,
|
||||
"depends_on": [
|
||||
"03-core-consent-foundation"
|
||||
],
|
||||
"blocks": []
|
||||
},
|
||||
"05-core-consent-react": {
|
||||
"status": "done",
|
||||
"title": "core-consent React subpath — ConsentProvider + useConsent()",
|
||||
"ac_total": 1,
|
||||
"ac_completed": 1,
|
||||
"depends_on": [
|
||||
"04-core-consent-implementation"
|
||||
],
|
||||
"blocks": [
|
||||
"09-cookie-consent-banner"
|
||||
]
|
||||
},
|
||||
"06-core-dsr": {
|
||||
"status": "done",
|
||||
"title": "core-dsr — scaffold, interfaces, Payload impls, recording doubles, handlers, dsrRouter",
|
||||
"ac_total": 3,
|
||||
"ac_completed": 3,
|
||||
"depends_on": [
|
||||
"02-audit-enum-amendment"
|
||||
],
|
||||
"blocks": [
|
||||
"07-core-api-router-composition"
|
||||
]
|
||||
},
|
||||
"07-core-api-router-composition": {
|
||||
"status": "done",
|
||||
"title": "core-api router composition — dsrRouter + consentRouter into appRouter",
|
||||
"ac_total": 1,
|
||||
"ac_completed": 1,
|
||||
"depends_on": [
|
||||
"04-core-consent-implementation",
|
||||
"06-core-dsr"
|
||||
],
|
||||
"blocks": [
|
||||
"11-documentation"
|
||||
]
|
||||
},
|
||||
"08-core-ui-scaffold": {
|
||||
"status": "done",
|
||||
"title": "core-ui scaffold",
|
||||
"ac_total": 1,
|
||||
"ac_completed": 1,
|
||||
"depends_on": [],
|
||||
"blocks": [
|
||||
"09-cookie-consent-banner"
|
||||
]
|
||||
},
|
||||
"09-cookie-consent-banner": {
|
||||
"status": "done",
|
||||
"title": "CookieConsentBanner component in core-ui",
|
||||
"ac_total": 1,
|
||||
"ac_completed": 1,
|
||||
"depends_on": [
|
||||
"05-core-consent-react",
|
||||
"08-core-ui-scaffold"
|
||||
],
|
||||
"blocks": [
|
||||
"11-documentation"
|
||||
]
|
||||
},
|
||||
"10-auth-signup-migration": {
|
||||
"status": "done",
|
||||
"title": "auth signUp anonymous consent migration",
|
||||
"ac_total": 1,
|
||||
"ac_completed": 1,
|
||||
"depends_on": [
|
||||
"04-core-consent-implementation"
|
||||
],
|
||||
"blocks": [
|
||||
"11-documentation"
|
||||
]
|
||||
},
|
||||
"11-documentation": {
|
||||
"status": "done",
|
||||
"title": "Documentation — DSR guide, consent guide, glossary, CLAUDE.md",
|
||||
"ac_total": 1,
|
||||
"ac_completed": 1,
|
||||
"depends_on": [],
|
||||
"blocks": []
|
||||
}
|
||||
}
|
||||
},
|
||||
"library-evaluation-policy": {
|
||||
"status": "done",
|
||||
"title": "Library evaluation policy — skill, traces, enforcement stack",
|
||||
"prd": "docs/work/prds/library-evaluation-policy.prd.md",
|
||||
"stories": {
|
||||
"01-trace-schema-foundation": {
|
||||
"status": "done",
|
||||
"title": "Trace schema module + docs/library-decisions/ foundation",
|
||||
"ac_total": 1,
|
||||
"ac_completed": 1,
|
||||
"depends_on": [],
|
||||
"blocks": []
|
||||
},
|
||||
"02-pre-commit-check-script": {
|
||||
"status": "done",
|
||||
"title": "Pre-commit check script for library trace presence",
|
||||
"ac_total": 1,
|
||||
"ac_completed": 1,
|
||||
"depends_on": [
|
||||
"01-trace-schema-foundation"
|
||||
],
|
||||
"blocks": [
|
||||
"06-sandcastle-reviewer-prompt"
|
||||
]
|
||||
},
|
||||
"03-claude-hooks": {
|
||||
"status": "done",
|
||||
"title": "Claude PreToolUse / PostToolUse hooks for library-policy nudge",
|
||||
"ac_total": 1,
|
||||
"ac_completed": 1,
|
||||
"depends_on": [],
|
||||
"blocks": []
|
||||
},
|
||||
"04-evaluate-library-skill": {
|
||||
"status": "done",
|
||||
"title": "evaluate-library skill (SKILL.md + supporting files)",
|
||||
"ac_total": 1,
|
||||
"ac_completed": 1,
|
||||
"depends_on": [
|
||||
"01-trace-schema-foundation"
|
||||
],
|
||||
"blocks": [
|
||||
"05-human-guide"
|
||||
]
|
||||
},
|
||||
"05-human-guide": {
|
||||
"status": "done",
|
||||
"title": "Human reading-room guide — docs/guides/adding-a-library.md",
|
||||
"ac_total": 1,
|
||||
"ac_completed": 1,
|
||||
"depends_on": [
|
||||
"04-evaluate-library-skill"
|
||||
],
|
||||
"blocks": [
|
||||
"09-claude-md-update"
|
||||
]
|
||||
},
|
||||
"06-sandcastle-reviewer-prompt": {
|
||||
"status": "done",
|
||||
"title": "Sandcastle reviewer prompt — Library-trace check section",
|
||||
"ac_total": 1,
|
||||
"ac_completed": 1,
|
||||
"depends_on": [
|
||||
"02-pre-commit-check-script"
|
||||
],
|
||||
"blocks": []
|
||||
},
|
||||
"07-generator-pre-shipped-traces": {
|
||||
"status": "done",
|
||||
"title": "Generator templates — pre-shipped traces for optional core packages",
|
||||
"ac_total": 1,
|
||||
"ac_completed": 1,
|
||||
"depends_on": [
|
||||
"01-trace-schema-foundation"
|
||||
],
|
||||
"blocks": []
|
||||
},
|
||||
"08-backfill-traces": {
|
||||
"status": "done",
|
||||
"title": "Backfill library traces for existing feature- and core-tier runtime deps",
|
||||
"ac_total": 4,
|
||||
"ac_completed": 4,
|
||||
"depends_on": [
|
||||
"01-trace-schema-foundation"
|
||||
],
|
||||
"blocks": []
|
||||
},
|
||||
"09-claude-md-update": {
|
||||
"status": "done",
|
||||
"title": "CLAUDE.md Key Conventions — library policy bullet",
|
||||
"ac_total": 1,
|
||||
"ac_completed": 1,
|
||||
"depends_on": [
|
||||
"05-human-guide"
|
||||
],
|
||||
"blocks": []
|
||||
}
|
||||
}
|
||||
},
|
||||
"product-analytics-channel": {
|
||||
"status": "done",
|
||||
"title": "Product analytics as a fourth capture channel (ADR-024 implementation)",
|
||||
"prd": "docs/work/prds/product-analytics-channel.prd.md",
|
||||
"stories": {
|
||||
"01-scaffold-core-analytics-package": {
|
||||
"status": "done",
|
||||
"title": "Scaffold @repo/core-analytics package with IAnalytics and NoopAnalytics",
|
||||
"ac_total": 2,
|
||||
"ac_completed": 2,
|
||||
"depends_on": [],
|
||||
"blocks": []
|
||||
},
|
||||
"02-recording-analytics": {
|
||||
"status": "done",
|
||||
"title": "Add RecordingAnalytics to @repo/core-testing",
|
||||
"ac_total": 1,
|
||||
"ac_completed": 1,
|
||||
"depends_on": [
|
||||
"01-scaffold-core-analytics-package"
|
||||
],
|
||||
"blocks": [
|
||||
"08-react-provider"
|
||||
]
|
||||
},
|
||||
"03-analyzed-brand-and-with-analytics-wrapper": {
|
||||
"status": "done",
|
||||
"title": "Add Analyzed brand and withAnalytics wrapper",
|
||||
"ac_total": 2,
|
||||
"ac_completed": 2,
|
||||
"depends_on": [
|
||||
"01-scaffold-core-analytics-package"
|
||||
],
|
||||
"blocks": [
|
||||
"04-manifest-schema-and-wire-use-case"
|
||||
]
|
||||
},
|
||||
"04-manifest-schema-and-wire-use-case": {
|
||||
"status": "done",
|
||||
"title": "Extend manifest schema and wireUseCase with analyticsEvents",
|
||||
"ac_total": 2,
|
||||
"ac_completed": 2,
|
||||
"depends_on": [
|
||||
"03-analyzed-brand-and-with-analytics-wrapper"
|
||||
],
|
||||
"blocks": []
|
||||
},
|
||||
"05-assert-feature-conformance-analyzed": {
|
||||
"status": "done",
|
||||
"title": "Extend assertFeatureConformance to check Analyzed brand",
|
||||
"ac_total": 1,
|
||||
"ac_completed": 1,
|
||||
"depends_on": [
|
||||
"04-manifest-schema-and-wire-use-case"
|
||||
],
|
||||
"blocks": []
|
||||
},
|
||||
"06-analytics-protocol-bind-context": {
|
||||
"status": "done",
|
||||
"title": "Add AnalyticsProtocol and BindContext.analytics",
|
||||
"ac_total": 1,
|
||||
"ac_completed": 1,
|
||||
"depends_on": [
|
||||
"05-assert-feature-conformance-analyzed"
|
||||
],
|
||||
"blocks": [
|
||||
"09-documentation"
|
||||
]
|
||||
},
|
||||
"07-eslint-rule-no-undeclared-analytics-event": {
|
||||
"status": "done",
|
||||
"title": "Add no-undeclared-analytics-event ESLint rule to @repo/core-eslint",
|
||||
"ac_total": 1,
|
||||
"ac_completed": 1,
|
||||
"depends_on": [
|
||||
"05-assert-feature-conformance-analyzed"
|
||||
],
|
||||
"blocks": [
|
||||
"09-documentation"
|
||||
]
|
||||
},
|
||||
"08-react-provider": {
|
||||
"status": "done",
|
||||
"title": "Add React provider to @repo/core-analytics",
|
||||
"ac_total": 1,
|
||||
"ac_completed": 1,
|
||||
"depends_on": [
|
||||
"02-recording-analytics"
|
||||
],
|
||||
"blocks": [
|
||||
"09-documentation"
|
||||
]
|
||||
},
|
||||
"09-documentation": {
|
||||
"status": "done",
|
||||
"title": "Documentation — analytics.md, conformance-quickref, CLAUDE.md, template-tiers",
|
||||
"ac_total": 2,
|
||||
"ac_completed": 2,
|
||||
"depends_on": [],
|
||||
"blocks": []
|
||||
}
|
||||
}
|
||||
},
|
||||
"security-headers-rate-limit-sbom": {
|
||||
"status": "done",
|
||||
"title": "Security headers + rate-limit primitive + SBOM in CI — Epic C of ADR-025",
|
||||
"prd": "docs/work/prds/security-headers-rate-limit-sbom.prd.md",
|
||||
"stories": {
|
||||
"01-rate-limit-type-primitives": {
|
||||
"status": "done",
|
||||
"title": "Rate-limit type primitives and manifest field",
|
||||
"ac_total": 1,
|
||||
"ac_completed": 1,
|
||||
"depends_on": [],
|
||||
"blocks": []
|
||||
},
|
||||
"02-rate-limit-implementations": {
|
||||
"status": "done",
|
||||
"title": "Rate-limit implementations — Noop, InMemory, Recording",
|
||||
"ac_total": 2,
|
||||
"ac_completed": 2,
|
||||
"depends_on": [
|
||||
"01-rate-limit-type-primitives"
|
||||
],
|
||||
"blocks": []
|
||||
},
|
||||
"03-no-undeclared-rate-limit-eslint-rule": {
|
||||
"status": "done",
|
||||
"title": "`no-undeclared-rate-limit` ESLint rule",
|
||||
"ac_total": 1,
|
||||
"ac_completed": 1,
|
||||
"depends_on": [
|
||||
"01-rate-limit-type-primitives"
|
||||
],
|
||||
"blocks": [
|
||||
"05-auth-signin-rate-limit-backfill"
|
||||
]
|
||||
},
|
||||
"04-with-rate-limit-wrapper-and-conformance": {
|
||||
"status": "done",
|
||||
"title": "`withRateLimit` wrapper and conformance extensions",
|
||||
"ac_total": 1,
|
||||
"ac_completed": 1,
|
||||
"depends_on": [
|
||||
"01-rate-limit-type-primitives",
|
||||
"02-rate-limit-implementations"
|
||||
],
|
||||
"blocks": [
|
||||
"05-auth-signin-rate-limit-backfill"
|
||||
]
|
||||
},
|
||||
"05-auth-signin-rate-limit-backfill": {
|
||||
"status": "done",
|
||||
"title": "auth.signIn rate-limit backfill",
|
||||
"ac_total": 1,
|
||||
"ac_completed": 1,
|
||||
"depends_on": [],
|
||||
"blocks": []
|
||||
},
|
||||
"06-security-headers-core-module": {
|
||||
"status": "done",
|
||||
"title": "Security headers core module",
|
||||
"ac_total": 1,
|
||||
"ac_completed": 1,
|
||||
"depends_on": [],
|
||||
"blocks": [
|
||||
"07-security-header-adapters"
|
||||
]
|
||||
},
|
||||
"07-security-header-adapters": {
|
||||
"status": "done",
|
||||
"title": "Per-framework security header adapters",
|
||||
"ac_total": 2,
|
||||
"ac_completed": 2,
|
||||
"depends_on": [
|
||||
"06-security-headers-core-module"
|
||||
],
|
||||
"blocks": [
|
||||
"08-app-wiring-web-next",
|
||||
"09-app-wiring-web-tanstack-and-cms"
|
||||
]
|
||||
},
|
||||
"08-app-wiring-web-next": {
|
||||
"status": "done",
|
||||
"title": "App wiring: web-next",
|
||||
"ac_total": 1,
|
||||
"ac_completed": 1,
|
||||
"depends_on": [
|
||||
"07-security-header-adapters"
|
||||
],
|
||||
"blocks": []
|
||||
},
|
||||
"09-app-wiring-web-tanstack-and-cms": {
|
||||
"status": "done",
|
||||
"title": "App wiring: web-tanstack and cms",
|
||||
"ac_total": 2,
|
||||
"ac_completed": 2,
|
||||
"depends_on": [
|
||||
"07-security-header-adapters"
|
||||
],
|
||||
"blocks": []
|
||||
},
|
||||
"10-sbom-ci-workflow": {
|
||||
"status": "done",
|
||||
"title": "SBOM CI workflow and ADR-023 amendment",
|
||||
"ac_total": 1,
|
||||
"ac_completed": 1,
|
||||
"depends_on": [],
|
||||
"blocks": []
|
||||
},
|
||||
"11-documentation": {
|
||||
"status": "done",
|
||||
"title": "Documentation and conformance reference updates",
|
||||
"ac_total": 2,
|
||||
"ac_completed": 2,
|
||||
"depends_on": [],
|
||||
"blocks": []
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"ready": [],
|
||||
"blocked": [],
|
||||
"needs_prd_ship": []
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
id: 01-wire-use-case-helper
|
||||
epic: binder-wrap-helper
|
||||
title: Introduce wireUseCase helper in core-shared
|
||||
type: technical-story
|
||||
status: done
|
||||
feature: core-shared
|
||||
depends-on: []
|
||||
blocks: [02-migrate-feature-binders, 03-update-generator-templates]
|
||||
created: 2026-05-13T19:17:10+02:00
|
||||
updated: 2026-05-14T19:21:52.308Z
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Add `wire-use-case.ts` + `wire-use-case.test.ts` to `packages/core-shared/src/conformance/` and export the helper from the `@repo/core-shared/conformance` barrel. The helper encapsulates `withSpan(withCapture(withAudit?(factory(deps))))` composition and performs the container bind step, so callers pass options and get back a brand-stacked wired value.
|
||||
|
||||
## Why
|
||||
|
||||
Every feature binder currently inlines the same `withSpan + withCapture` wrapping per use case — 30–79 lines of mechanical boilerplate per binder pair. A single helper in core-shared eliminates the structural clone groups and makes adjusting the wrapping shape a one-file change.
|
||||
|
||||
## Done when
|
||||
|
||||
- `packages/core-shared/src/conformance/wire-use-case.ts` exists and is exported from `packages/core-shared/src/conformance/index.ts`.
|
||||
- `wire-use-case.test.ts` covers: no-audit path (Instrumented + Captured brands present), audit path (Instrumented + Captured + Audited brands present), span-name derivation (`<feature>.<name>`), capture-tag structure (`{ feature, layer, name }`), container binding (symbol resolves to wired value), idempotent re-bind (unbind + bind when symbol already bound).
|
||||
- `pnpm typecheck && pnpm test && pnpm lint && pnpm conformance && pnpm fallow:audit && pnpm coverage:diff` all pass.
|
||||
- `wire-use-case.ts` reaches 100% statement/branch coverage (file is small; exhaustive test coverage is achievable).
|
||||
|
||||
## In scope
|
||||
|
||||
- `packages/core-shared/src/conformance/wire-use-case.ts` — the helper and its exported types.
|
||||
- `packages/core-shared/src/conformance/wire-use-case.test.ts` — unit tests using `RecordingTracer` / `RecordingLogger` / `RecordingAuditLog` (or equivalent mocks from core-shared/core-audit).
|
||||
- Export line added to `packages/core-shared/src/conformance/index.ts`.
|
||||
- No changes to `withSpan`, `withCapture`, or `withAudit` internals — the helper composes them as-is.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Feature binder changes (Story 02).
|
||||
- Generator template changes (Story 03).
|
||||
- `wireController` peer — the `layer` discriminator in `wireUseCase` covers controllers too if the shapes converge; a separate peer is a follow-up if needed.
|
||||
- Repository or service binding patterns.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] Add `wire-use-case.ts` + `wire-use-case.test.ts` to `packages/core-shared/src/conformance/` and export from the conformance index — implement the helper (options object: `container`, `symbol`, `factory`, `deps`, `feature`, `layer`, `name`, `tracer`, `logger`, optional `auditLog`) with full unit tests covering both audit and no-audit paths, brand presence assertions via `isInstrumented` / `isCaptured` / `isAudited`, container binding, and idempotent re-bind; all gates pass on this single commit.
|
||||
@@ -0,0 +1,50 @@
|
||||
---
|
||||
id: 02-migrate-feature-binders
|
||||
epic: binder-wrap-helper
|
||||
title: Migrate all five feature binders to wireUseCase
|
||||
type: technical-story
|
||||
status: done
|
||||
feature: auth, blog, media, marketing-pages, navigation
|
||||
depends-on: [01-wire-use-case-helper]
|
||||
blocks: []
|
||||
created: 2026-05-13T19:17:10+02:00
|
||||
updated: 2026-05-14T19:21:52.308Z
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Replace every inline `withSpan + withCapture (+ withAudit)` block in each feature's `bind-production.ts` and `bind-dev-seed.ts` with a call to `wireUseCase`. Existing binder-level and integration tests must continue to pass unchanged. Each feature migrates in its own commit.
|
||||
|
||||
## Why
|
||||
|
||||
The five inline wrapping clone groups are the top source of duplication reported by `pnpm fallow`. Moving to `wireUseCase` eliminates all five clone groups and leaves each binder as decision content only (which adapter, which mode, which symbol).
|
||||
|
||||
## Done when
|
||||
|
||||
- All 10 binder files (`auth ×2`, `blog ×2`, `media ×2`, `marketing-pages ×2`, `navigation ×2`) call `wireUseCase` for every use case binding; no inline `withSpan + withCapture` blocks remain for use cases.
|
||||
- `assertFeatureConformance` at the tail of each `bind-production.ts` continues to accept all wired use cases (brands present, manifest entries match).
|
||||
- `pnpm typecheck && pnpm test && pnpm lint && pnpm conformance && pnpm fallow:audit && pnpm coverage:diff` pass after each per-feature commit.
|
||||
- `pnpm fallow dupes` no longer shows the five binder-pair clone groups in its output.
|
||||
|
||||
## In scope
|
||||
|
||||
- `packages/auth/src/di/bind-production.ts` + `bind-dev-seed.ts` — 3 use cases (signIn, signUp, signOut).
|
||||
- `packages/blog/src/di/bind-production.ts` + `bind-dev-seed.ts` — 3 use cases (getArticles, getArticleBySlug, createArticle).
|
||||
- `packages/media/src/di/bind-production.ts` + `bind-dev-seed.ts` — 3 use cases (getMedia, listMedia, deleteMedia).
|
||||
- `packages/marketing-pages/src/di/bind-production.ts` + `bind-dev-seed.ts` — 2 use cases (getPageBySlug, getSiteSettings).
|
||||
- `packages/navigation/src/di/bind-production.ts` + `bind-dev-seed.ts` — 1 use case (getHeader).
|
||||
- If any binder's existing tests assert on internal wrapping shape rather than observable behaviour, refactor those assertions to test through the helper's contract (same commit as the binder change).
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Controller bindings — covered by the helper per the PRD but deferred to a follow-up if scope is a concern.
|
||||
- Repository and service bindings — stay as direct `.toConstantValue()` calls.
|
||||
- Apps' `bindAll()` dispatcher — untouched.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] Migrate `auth` binders (`bind-production.ts` + `bind-dev-seed.ts`) to `wireUseCase` for all 3 use cases (signIn, signUp, signOut); all gates pass on this commit.
|
||||
- [x] Migrate `blog` binders to `wireUseCase` for all 3 use cases (getArticles, getArticleBySlug, createArticle); all gates pass on this commit.
|
||||
- [x] Migrate `media` binders to `wireUseCase` for all 3 use cases (getMedia, listMedia, deleteMedia); all gates pass on this commit.
|
||||
- [x] Migrate `marketing-pages` binders to `wireUseCase` for both use cases (getPageBySlug, getSiteSettings); all gates pass on this commit.
|
||||
- [x] Migrate `navigation` binders to `wireUseCase` for the single use case (getHeader); all gates pass on this commit; verify `pnpm fallow dupes` no longer surfaces the five binder-pair clone groups.
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
id: 03-update-generator-templates
|
||||
epic: binder-wrap-helper
|
||||
title: Update feature generator templates to emit wireUseCase call shape
|
||||
type: technical-story
|
||||
status: done
|
||||
feature: core-shared
|
||||
depends-on: [01-wire-use-case-helper]
|
||||
blocks: []
|
||||
created: 2026-05-13T19:17:10+02:00
|
||||
updated: 2026-05-14T19:21:52.308Z
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Update `turbo/generators/templates/feature/src/di/bind-production.ts.hbs` and `bind-dev-seed.ts.hbs` so that `pnpm turbo gen feature` scaffolds new features with `wireUseCase` calls instead of the longhand inline `withSpan + withCapture` form.
|
||||
|
||||
## Why
|
||||
|
||||
New features scaffolded after the migration would otherwise drift back to the inline form, re-introducing the clone group. The generator is the canonical source for scaffolded binder shape; updating it closes the loop.
|
||||
|
||||
## Done when
|
||||
|
||||
- `turbo/generators/templates/feature/src/di/bind-production.ts.hbs` emits `wireUseCase(...)` calls (not inline `withSpan + withCapture`) for the placeholder use case.
|
||||
- `turbo/generators/templates/feature/src/di/bind-dev-seed.ts.hbs` emits `wireUseCase(...)` calls for the placeholder use case.
|
||||
- Running `pnpm turbo gen feature testfeature` produces binder files that call `wireUseCase` and pass `pnpm typecheck && pnpm lint && pnpm test && pnpm conformance && pnpm fallow:audit && pnpm coverage:diff` (or the generated feature is cleaned up after verification).
|
||||
|
||||
## In scope
|
||||
|
||||
- `turbo/generators/templates/feature/src/di/bind-production.ts.hbs`
|
||||
- `turbo/generators/templates/feature/src/di/bind-dev-seed.ts.hbs`
|
||||
- Any supporting type imports in the templates that change as a result (e.g. dropping `ProductionUseCase` intermediate type if the helper absorbs it).
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Generator templates for events, jobs, realtime, or core-packages — those do not produce binder files with use-case wrapping.
|
||||
- Runtime behaviour changes — the generated output must be semantically equivalent to the old inline form.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] Update `bind-production.ts.hbs` and `bind-dev-seed.ts.hbs` in `turbo/generators/templates/feature/src/di/` to emit `wireUseCase` calls; verify a test-scaffold of a new feature produces correct binder output and all gates pass on this commit.
|
||||
24
docs/work/epics/binder-wrap-helper/_epic.md
Normal file
24
docs/work/epics/binder-wrap-helper/_epic.md
Normal file
@@ -0,0 +1,24 @@
|
||||
---
|
||||
id: binder-wrap-helper
|
||||
prd: docs/work/prds/binder-wrap-helper.prd.md
|
||||
title: Collapse binder duplication via wireUseCase helper
|
||||
type: epic
|
||||
status: done
|
||||
features: [core-shared, auth, blog, media, marketing-pages, navigation]
|
||||
created: 2026-05-13T00:00:00Z
|
||||
updated: 2026-05-14T19:21:52.308Z
|
||||
---
|
||||
|
||||
## 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. Update the feature generator templates to emit the new call shape by default.
|
||||
|
||||
## Why
|
||||
|
||||
Five of `pnpm fallow`'s top-ten clone groups come from binder pairs across features. The inline wrapping runs 30–79 duplicated lines per binder pair. The helper becomes the single source of truth for the wrapping shape; per-feature binders shrink to their decision content plus a list of `wireUseCase` calls.
|
||||
|
||||
## Stories
|
||||
|
||||
- [x] [01 — Introduce `wireUseCase` helper in core-shared](01-wire-use-case-helper/_story.md)
|
||||
- [x] [02 — Migrate all five feature binders to `wireUseCase`](02-migrate-feature-binders/_story.md)
|
||||
- [x] [03 — Update generator templates to emit `wireUseCase` call shape](03-update-generator-templates/_story.md)
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
id: 01-trace-schema-extensions
|
||||
epic: ci-security-and-supply-chain
|
||||
title: Trace schema extensions (socketRisk + lastRevalidated)
|
||||
type: technical-story
|
||||
status: done
|
||||
feature: scripts
|
||||
depends-on: []
|
||||
blocks:
|
||||
[
|
||||
02-socket-integration,
|
||||
04-major-bump-reevaluation,
|
||||
05-trace-revalidation-workflow,
|
||||
]
|
||||
created: 2026-05-14T18:59:12+02:00
|
||||
updated: 2026-05-14T19:21:52.308Z
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Extend `scripts/library-decisions/schema.mjs` with two new fields — `socketRisk` (the 9th filter result) and `lastRevalidated` (ISO-date or null) — and update `docs/library-decisions/_template.md` to reflect the expanded schema.
|
||||
|
||||
## Why
|
||||
|
||||
Every downstream enforcement layer (the evaluate-library skill's 9th filter, the check.mjs major-bump mode, the revalidate.mjs weekly cron) depends on these two fields being present and validated at the schema layer first. Landing them here in one green commit before any consumer touches them prevents schema-drift between layers.
|
||||
|
||||
**External dependency:** library-evaluation epic story 01 (trace schema foundation) must be complete before this story — `scripts/library-decisions/schema.mjs` and `docs/library-decisions/_template.md` must exist. That epic is marked done.
|
||||
|
||||
## Done when
|
||||
|
||||
- `scripts/library-decisions/schema.mjs` exports an updated Zod schema where `filter-results` includes `socketRisk: z.union([z.literal("clean"), z.literal("flagged"), z.string()])` and the trace frontmatter includes `lastRevalidated: z.string().nullable()` (ISO date or null).
|
||||
- `docs/library-decisions/_template.md` mirrors both new fields with inline documentation of their allowed values.
|
||||
- `schema.test.mjs` covers: `socketRisk` round-trips for all three variants (`clean`, `flagged`, arbitrary string); `lastRevalidated` accepts ISO date strings and `null`; a trace missing `socketRisk` in `filter-results` fails validation; `lastRevalidated: null` is valid (default for fresh adoptions).
|
||||
- `pnpm typecheck && pnpm lint && pnpm test && pnpm conformance && pnpm fallow:audit && pnpm coverage:diff` all pass.
|
||||
|
||||
## In scope
|
||||
|
||||
- `scripts/library-decisions/schema.mjs` — schema additions only (no new exports, no breaking changes to existing field shapes).
|
||||
- `scripts/library-decisions/schema.test.mjs` — new test cases for the two new fields.
|
||||
- `docs/library-decisions/_template.md` — field documentation additions.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- `socket-cli` invocation or Socket CI step — Story 02.
|
||||
- `check.mjs` major-bump mode — Story 04.
|
||||
- `revalidate.mjs` script — Story 05.
|
||||
- Backfilling existing traces with the new fields — Story 05 (the revalidation cron handles this on its first run).
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] Extend `scripts/library-decisions/schema.mjs` adding `socketRisk` (union: `"clean" | "flagged" | string`) to the `filter-results` Zod object and `lastRevalidated` (nullable ISO-date string) to the trace frontmatter schema; update `docs/library-decisions/_template.md` to document both fields with their enum values; add test cases to `schema.test.mjs` covering `socketRisk` round-trips (all three variants), `lastRevalidated` ISO + null acceptance, and missing-`socketRisk` rejection; all gates pass on this single commit.
|
||||
@@ -0,0 +1,47 @@
|
||||
---
|
||||
id: 02-socket-integration
|
||||
epic: ci-security-and-supply-chain
|
||||
title: Socket integration (skill + CI)
|
||||
type: technical-story
|
||||
status: done
|
||||
feature: tooling
|
||||
depends-on: [01-trace-schema-extensions]
|
||||
blocks: [08-reviewer-prompt-update]
|
||||
created: 2026-05-14T18:59:12+02:00
|
||||
updated: 2026-05-14T19:21:52.308Z
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Wire Socket.dev into two enforcement layers: (1) the `evaluate-library` skill gains Filter 9 (supply-chain behavior) using `socket-cli`, and (2) `ci.yml` gains a `socket-cli scan` step that fails on `critical` severity findings.
|
||||
|
||||
## Why
|
||||
|
||||
CVE databases are lagging indicators — `event-stream`, `ua-parser-js`, and `tj-actions/changed-files` all shipped malware before any CVE existed. Socket detects behavioral signals (new network calls, new post-install scripts, maintainer-account changes) in real time. Placing it as the 9th filter in `evaluate-library` + as a CI gate closes the behavior-compromise surface that CVE scanning misses.
|
||||
|
||||
**External dependency:** library-evaluation epic story 04 (evaluate-library skill) must be complete — the skill's SKILL.md must exist and have the 8-filter structure. That epic is marked done.
|
||||
|
||||
## Done when
|
||||
|
||||
- `.claude/skills/evaluate-library/SKILL.md` has a "Filter 9 — Supply-chain behavior (Socket)" section. The skill's fail-fast logic positions Socket as expensive (network call), running it after the cheap structural filters. The section documents the `socket-cli` verification command and the JSON output fields used to classify `clean` / `flagged` / `<finding-summary>`. The trace's `socket-risk` field in `filter-results` is set from this output.
|
||||
- `.socket.json` exists at repo root: `{ "issueRules": { "critical": "error", "high": "warn", "medium": "ignore", "low": "ignore" } }`.
|
||||
- `ci.yml`'s `validate` job has a step that runs `socket-cli scan` against the lockfile, filtered to PRs that touch `package.json` or `pnpm-lock.yaml` (via `paths:` condition). The step exits non-zero on any `critical` finding.
|
||||
- `docs/guides/ci-security.md` Socket App install instructions are deferred to Story 09 (the human guide). This story ships only the machine-enforced layers.
|
||||
- `pnpm typecheck && pnpm lint && pnpm test && pnpm conformance && pnpm fallow:audit && pnpm coverage:diff` all pass.
|
||||
|
||||
## In scope
|
||||
|
||||
- `.claude/skills/evaluate-library/SKILL.md` — Filter 9 section addition.
|
||||
- `.socket.json` — repo-root config file.
|
||||
- `.github/workflows/ci.yml` — one new step in the `validate` job (with `paths:` filter).
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Paid Socket Team plan or server-side PR-block enforcement — explicitly out of PRD scope.
|
||||
- Socket GitHub App install — consumer-facing instructions live in Story 09's guide.
|
||||
- Backfilling existing traces with `socket-risk` — Story 05 (revalidation cron handles this).
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] Add `.socket.json` at repo root and extend `.claude/skills/evaluate-library/SKILL.md` with a "Filter 9 — Supply-chain behavior (Socket)" section: position Socket after cheap filters, document `socket-cli` as the verification command, specify how `clean`/`flagged`/`<finding-summary>` maps to the trace's `socket-risk` field; one commit, all gates pass.
|
||||
- [x] Add a `socket-cli scan` step to `ci.yml`'s `validate` job, scoped to PRs touching `package.json` or `pnpm-lock.yaml` via a `paths:` condition; step exits non-zero on any `critical` finding; one commit, all gates pass.
|
||||
@@ -0,0 +1,42 @@
|
||||
---
|
||||
id: 03-renovate-adoption
|
||||
epic: ci-security-and-supply-chain
|
||||
title: Renovate adoption
|
||||
type: technical-story
|
||||
status: done
|
||||
feature: tooling
|
||||
depends-on: []
|
||||
blocks: [09-ci-security-guide-and-docs]
|
||||
created: 2026-05-14T18:59:12+02:00
|
||||
updated: 2026-05-14T19:21:52.308Z
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Ship `.github/renovate.json` configuring automated dependency management: grouped per-ecosystem npm bumps, Dockerfile base-image tracking, GitHub Actions SHA pinning, automerge for green minor+patch PRs, and a single dependency dashboard issue.
|
||||
|
||||
## Why
|
||||
|
||||
Major-tag pinning for GitHub Actions is documented insecure — the 2025 `tj-actions/changed-files` incident proved it. Renovate's `pinGitHubActionDigests` preset automates the one-time SHA-pin sweep and keeps SHAs current thereafter. Grouping ecosystem clusters (Sentry, OTel, tRPC, Payload, Inversify) into weekly PRs prevents noise while ensuring drift is surfaced. Automerge on green minor+patch PRs removes human toil for routine bumps.
|
||||
|
||||
## Done when
|
||||
|
||||
- `.github/renovate.json` exists and is valid JSON, extending presets: `config:base`, `helpers:pinGitHubActionDigests`, `:separateMajorReleases`, `:automergeMinor`, `:automergePatch`.
|
||||
- `packageRules` groups `@sentry/*`, `@opentelemetry/*`, `@trpc/*`, `payload*`, and `inversify*` into per-cluster weekly PRs.
|
||||
- Dockerfile manager is enabled for `.sandcastle/Dockerfile`.
|
||||
- `dependencyDashboard: true` is set (opens a single Renovate-managed issue summarising open + queued PRs, labeled `renovate/dashboard` per PRD Q6).
|
||||
- Renovate's bump commits use `chore(deps):` (minor/patch) and `chore(deps-major):` (major) commit-message prefixes so release-please's per-package bump rules apply cleanly.
|
||||
- `pnpm typecheck && pnpm lint && pnpm test && pnpm conformance && pnpm fallow:audit && pnpm coverage:diff` all pass (no executable code change; JSON config only).
|
||||
|
||||
## In scope
|
||||
|
||||
- `.github/renovate.json` — full Renovate configuration.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Verifying the first Renovate PR (SHA-pin sweep) — that happens when the GitHub App runs, not at commit time. The success criterion (all `@v<N>` pins rewritten to SHAs) is verified when Renovate's first PR merges.
|
||||
- Renovate Dependency Dashboard → `docs/work/` task integration — explicitly out of scope in the PRD.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] Create `.github/renovate.json` extending presets `config:base`, `helpers:pinGitHubActionDigests`, `:separateMajorReleases`, `:automergeMinor`, `:automergePatch`; add `packageRules` grouping `@sentry/*`, `@opentelemetry/*`, `@trpc/*`, `payload*`, `inversify*` into weekly per-cluster PRs; enable Dockerfile manager for `.sandcastle/Dockerfile`; set `dependencyDashboard: true`; set `commitMessagePrefix` to enforce `chore(deps):` / `chore(deps-major):` per Conventional Commits; one commit, all gates pass.
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
id: 04-major-bump-reevaluation
|
||||
epic: ci-security-and-supply-chain
|
||||
title: Major-bump re-evaluation flow
|
||||
type: technical-story
|
||||
status: done
|
||||
feature: scripts
|
||||
depends-on: [01-trace-schema-extensions]
|
||||
blocks: [05-trace-revalidation-workflow]
|
||||
created: 2026-05-14T18:59:12+02:00
|
||||
updated: 2026-05-14T19:21:52.308Z
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Extend `scripts/library-decisions/check.mjs` with a major-bump detection mode: when invoked on a Renovate PR, parse the lockfile diff to find bumped deps, classify each as major/minor/patch, and for any feature- or core-tier major bump require the trace's `lastRevalidated` field to be fresh (set today); exit non-zero with a pointer to the `evaluate-library` skill if not.
|
||||
|
||||
## Why
|
||||
|
||||
ADR-022 closes the adoption-time gate; ADR-023 closes the drift gate for major-version changes. A Renovate PR that bumps `@sentry/node` from `7.x → 8.x` is effectively a new adoption decision — the original trace may have evaluated a very different API surface and risk profile. Requiring a fresh `lastRevalidated` ensures the trace is re-walked before the bump merges. Minor and patch bumps don't trigger re-evaluation (backwards-compatible by semver contract).
|
||||
|
||||
**External dependency:** library-evaluation epic story 02 (pre-commit check script) must be complete — `scripts/library-decisions/check.mjs` must exist. That epic is marked done.
|
||||
|
||||
## Done when
|
||||
|
||||
- `check.mjs` has a new mode (invocable as `node scripts/library-decisions/check.mjs --renovate-pr`) that:
|
||||
- Detects Renovate PRs via `renovate/` branch prefix (from `GITHUB_HEAD_REF` or `--branch` arg).
|
||||
- Parses the lockfile diff (from `git diff origin/main -- pnpm-lock.yaml` or a `--diff` arg) to extract bumped deps with from/to versions.
|
||||
- Classifies each bump as major / minor / patch using semver comparison.
|
||||
- For each feature- or core-tier major bump: reads the corresponding trace from `docs/library-decisions/`, checks `lastRevalidated` equals today's ISO date. If not fresh, exits non-zero with a message referencing the `evaluate-library` skill and the trace path.
|
||||
- App-tier deps: pass unconditionally (ADR-022 exemption).
|
||||
- Non-Renovate branch: pass unconditionally (the rule is scoped to Renovate PRs only).
|
||||
- Multiple bumps in one PR: validated per-dep independently (PRD Q3).
|
||||
- Integration tests in `check.test.mjs` (or equivalent) cover: minor bump on feature-tier dep → pass; major bump + fresh `lastRevalidated` → pass; major bump + stale `lastRevalidated` → fail with clear pointer; major bump on app-tier dep → pass; patch bump in Renovate branch → pass; non-Renovate branch with major bump → pass.
|
||||
- `pnpm typecheck && pnpm lint && pnpm test && pnpm conformance && pnpm fallow:audit && pnpm coverage:diff` all pass.
|
||||
|
||||
## In scope
|
||||
|
||||
- `scripts/library-decisions/check.mjs` — new `--renovate-pr` mode only; existing modes unchanged.
|
||||
- `scripts/library-decisions/check.test.mjs` — integration tests for the new mode (use fixture trace files + fixture lockfile diffs; no network calls).
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Auto-closing `library-policy/re-evaluation` issues when `lastRevalidated` is refreshed — Story 05 (revalidation cron) handles this.
|
||||
- The CI step that invokes this check on Renovate PRs — the script is the unit; wiring it into CI workflows is part of Story 09's guide and/or the reviewer prompt in Story 08.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] Extend `scripts/library-decisions/check.mjs` with a `--renovate-pr` mode: detect Renovate branch prefix, parse lockfile diff for bumped deps, classify semver deltas, require fresh `lastRevalidated` on feature/core-tier major bumps (fail with `evaluate-library` pointer if stale), pass app-tier + non-Renovate + minor/patch unconditionally; write integration tests in `check.test.mjs` with fixture trace files and lockfile diffs covering all six cases; one commit, all gates pass.
|
||||
@@ -0,0 +1,46 @@
|
||||
---
|
||||
id: 05-trace-revalidation-workflow
|
||||
epic: ci-security-and-supply-chain
|
||||
title: Trace revalidation workflow
|
||||
type: technical-story
|
||||
status: done
|
||||
feature: scripts
|
||||
depends-on: [01-trace-schema-extensions, 04-major-bump-reevaluation]
|
||||
blocks: [09-ci-security-guide-and-docs]
|
||||
created: 2026-05-14T18:59:12+02:00
|
||||
updated: 2026-05-14T19:21:52.308Z
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Write `scripts/library-decisions/revalidate.mjs` — a script that walks every approved + pre-shipped trace, re-runs each trace's `verification-commands`, classifies divergence as soft or hard, and opens/updates/closes GitHub issues accordingly — then wire it into `.github/workflows/trace-revalidation-weekly.yml` (weekly cron + `workflow_dispatch`).
|
||||
|
||||
## Why
|
||||
|
||||
ADR-022 traces go stale silently when new CVEs drop or Socket picks up behavioral changes in a package post-adoption. A weekly automated revalidation creates a feedback loop: soft divergence (minor drift) surfaces as a rolling dashboard issue; hard divergence (a re-evaluation is warranted) surfaces as a per-dep `library-policy/re-evaluation` issue with the trace path + finding + re-walk handoff. No auto-edit of traces and no CI gating on main — the workflow runs in parallel, not on the critical path.
|
||||
|
||||
**External dependency:** library-evaluation epic story 02 (pre-commit check script) must be complete — `check.mjs` and the `docs/library-decisions/` fixture patterns are the prior art this script mirrors.
|
||||
|
||||
## Done when
|
||||
|
||||
- `scripts/library-decisions/revalidate.mjs` walks all traces in `docs/library-decisions/` whose `decision` field is `accepted` or `pre-shipped`; for each trace, re-runs its `verification-commands`; classifies divergence (soft: minor discrepancy from expected output; hard: finding that would change the evaluation decision); opens a rolling `library-policy/dashboard`-labeled issue for soft divergence (creates or updates a single issue); opens a `library-policy/re-evaluation`-labeled per-dep issue for hard divergence with title `re-evaluate: <package>@<version> — <finding>`, trace path, and `evaluate-library` re-walk pointer; closes open `library-policy/re-evaluation` issues whose dep has since had `lastRevalidated` refreshed; skips rejected traces entirely.
|
||||
- Integration tests use a fixture trace directory (no real `gh` CLI / no network): no-drift trace → no issue; soft-drift trace → dashboard issue created; hard-drift trace → per-dep issue with correct labels + title format; open per-dep issue already exists → no duplicate opened; `lastRevalidated` refreshed → open issue closed with comment.
|
||||
- `.github/workflows/trace-revalidation-weekly.yml` triggers on `schedule: - cron: "30 6 * * 1"` and `workflow_dispatch`; job steps: checkout, `pnpm install --frozen-lockfile`, `node scripts/library-decisions/revalidate.mjs`; permissions: `issues: write`, `contents: read` (NO `contents: write`).
|
||||
- `pnpm typecheck && pnpm lint && pnpm test && pnpm conformance && pnpm fallow:audit && pnpm coverage:diff` all pass.
|
||||
|
||||
## In scope
|
||||
|
||||
- `scripts/library-decisions/revalidate.mjs` — the revalidation script.
|
||||
- `scripts/library-decisions/revalidate.test.mjs` — integration tests with fixture directory and mocked `gh` CLI surface.
|
||||
- `.github/workflows/trace-revalidation-weekly.yml` — the workflow file.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Auto-editing trace files — explicitly forbidden (no `contents: write`).
|
||||
- Auto-dispatching on `library-policy/re-evaluation` issues — human triage required (PRD out of scope).
|
||||
- CI gating on main from this workflow — main keeps deploying; revalidation runs in parallel.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] Write `scripts/library-decisions/revalidate.mjs` (walk approved+pre-shipped traces, re-run `verification-commands`, classify soft/hard divergence, open/update/close issues via `gh` CLI; mock-friendly `gh` surface for tests); write `revalidate.test.mjs` integration tests with fixture traces covering: no-drift, soft-drift (dashboard issue), hard-drift (per-dep issue with correct labels+title), duplicate-issue guard, stale-issue close on refreshed `lastRevalidated`, rejected-trace skip; one commit, all gates pass.
|
||||
- [x] Create `.github/workflows/trace-revalidation-weekly.yml` (trigger: `schedule: cron: "30 6 * * 1"` + `workflow_dispatch`; steps: checkout, `pnpm install --frozen-lockfile`, `node scripts/library-decisions/revalidate.mjs`; permissions: `issues: write`, `contents: read`); one commit, all gates pass.
|
||||
@@ -0,0 +1,42 @@
|
||||
---
|
||||
id: 06-codeql-and-audit-signatures
|
||||
epic: ci-security-and-supply-chain
|
||||
title: CodeQL workflow + pnpm audit signatures
|
||||
type: technical-story
|
||||
status: done
|
||||
feature: tooling
|
||||
depends-on: []
|
||||
blocks: [08-reviewer-prompt-update]
|
||||
created: 2026-05-14T18:59:12+02:00
|
||||
updated: 2026-05-14T19:21:52.308Z
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Add two baseline GitHub-native gates: (1) a `pnpm audit signatures --audit-level=high` step in `ci.yml`'s validate job, and (2) a `.github/workflows/codeql.yml` workflow running javascript-typescript static analysis on push/PR/weekly schedule.
|
||||
|
||||
## Why
|
||||
|
||||
`pnpm audit signatures` catches tampered package signatures before they reach production — a post-install script from a compromised maintainer account would fail this check. CodeQL's javascript-typescript analysis catches common vulnerability patterns (XSS, injection, prototype pollution) that are invisible to dependency-scanning tools. Both are zero-cost on public repos and the GitHub Free plan; CodeQL's template includes a clear no-op on plans that don't support it.
|
||||
|
||||
## Done when
|
||||
|
||||
- `ci.yml`'s `validate` job includes a `pnpm audit signatures --audit-level=high` step. The step fails the job on `high` or `critical` severity signature failures.
|
||||
- `.github/workflows/codeql.yml` exists; triggers: `push: branches: [main]`, `pull_request`, and `schedule: - cron: "0 2 * * 3"` (Wednesday 02:00 UTC, staggered from the trace-revalidation cron); language: `javascript-typescript`; uses default queries. Includes a comment noting that CodeQL on private repos requires GitHub Advanced Security (consumer-toggleable per PRD constraint).
|
||||
- `pnpm typecheck && pnpm lint && pnpm test && pnpm conformance && pnpm fallow:audit && pnpm coverage:diff` all pass (no executable code change; CI config only).
|
||||
|
||||
## In scope
|
||||
|
||||
- `.github/workflows/ci.yml` — one new `pnpm audit signatures` step in `validate` job.
|
||||
- `.github/workflows/codeql.yml` — new workflow file.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Configuring GitHub branch protection to require CodeQL as a status check — consumer-facing instruction deferred to Story 09's guide.
|
||||
- OSSF Scorecard — explicitly out of PRD scope.
|
||||
- StepSecurity Harden Runner — explicitly out of PRD scope.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] Add `pnpm audit signatures --audit-level=high` as a step in `ci.yml`'s `validate` job; one commit, all gates pass.
|
||||
- [x] Create `.github/workflows/codeql.yml` (language: `javascript-typescript`; triggers: push to main, pull_request, weekly schedule Wednesday 02:00 UTC; default queries; consumer note about GitHub Advanced Security requirement for private repos); one commit, all gates pass.
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
id: 07-gitleaks-precommit
|
||||
epic: ci-security-and-supply-chain
|
||||
title: Gitleaks pre-commit hook
|
||||
type: technical-story
|
||||
status: done
|
||||
feature: tooling
|
||||
depends-on: []
|
||||
blocks: [09-ci-security-guide-and-docs]
|
||||
created: 2026-05-14T18:59:12+02:00
|
||||
updated: 2026-05-14T19:21:52.308Z
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Add `gitleaks protect --staged --redact` as a step in `.husky/pre-commit` and ship a `.gitleaks.toml` allowlist that covers test-fixture patterns in `__seeds__/**`, so a commit containing a known secret pattern is blocked locally before it reaches the remote.
|
||||
|
||||
## Why
|
||||
|
||||
Developer accidents (pasting tokens into config, seeding test fixtures with real-looking keys) are the most common secret-leak vector. A pre-commit hook stops the leak at the earliest possible point — before the secret is ever pushed. GitHub native push protection is the second line of defense (documented in Story 09's guide); the hook is the first. The `__seeds__/**` allowlist prevents false positives from test fixtures that deliberately use token-shaped strings as dummy data.
|
||||
|
||||
## Done when
|
||||
|
||||
- `.husky/pre-commit` has a `gitleaks protect --staged --redact` step that runs before the existing state-sync guard (or after — order between guards doesn't matter, both must run).
|
||||
- `.gitleaks.toml` exists at repo root with at minimum one allowlist rule scoping `__seeds__/**` test fixtures (using `paths` or `allowlist.paths` depending on the gitleaks version).
|
||||
- A smoke test (bash script or vitest) pipes a staged commit containing a Stripe-style test key (`sk_test_...`) through the hook and asserts non-zero exit code. The smoke test is documented in the story's Done-when but may live as a manual verification step given gitleaks requires a binary; include instructions in `docs/guides/ci-security.md` (Story 09) for consumers to verify locally.
|
||||
- `pnpm typecheck && pnpm lint && pnpm test && pnpm conformance && pnpm fallow:audit && pnpm coverage:diff` all pass.
|
||||
|
||||
## In scope
|
||||
|
||||
- `.husky/pre-commit` — new `gitleaks` step.
|
||||
- `.gitleaks.toml` — allowlist config.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Installing `gitleaks` as a project devDependency — consumers install it via their OS package manager or `brew`; the hook exits gracefully with a warning if `gitleaks` is not found in `$PATH` (to avoid blocking developers who haven't installed it yet, while still enforcing for those who have).
|
||||
- GitHub native push protection configuration — consumer-facing instruction deferred to Story 09's guide.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] Add `gitleaks protect --staged --redact` step to `.husky/pre-commit` (exit-gracefully if `gitleaks` not in `$PATH`); create `.gitleaks.toml` at repo root with `__seeds__/**` allowlist for test-fixture patterns; one commit, all gates pass.
|
||||
@@ -0,0 +1,42 @@
|
||||
---
|
||||
id: 08-reviewer-prompt-update
|
||||
epic: ci-security-and-supply-chain
|
||||
title: Sandcastle reviewer prompt update
|
||||
type: technical-story
|
||||
status: done
|
||||
feature: tooling
|
||||
depends-on: [02-socket-integration, 06-codeql-and-audit-signatures]
|
||||
blocks: [09-ci-security-guide-and-docs]
|
||||
created: 2026-05-14T18:59:12+02:00
|
||||
updated: 2026-05-14T19:21:52.308Z
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Extend `.sandcastle/reviewer.prompt.md` with a "CI security checks" section that instructs the reviewer agent to read Socket CI output and CodeQL findings via `gh run view`, and reject the slice with specific notes if any `critical` Socket finding or `error`-severity CodeQL finding is present.
|
||||
|
||||
## Why
|
||||
|
||||
The sandcastle reviewer is the single composable gate for agent PRs (ADR-019 constraint). Without an explicit section, an agent reviewer has no machine-readable instruction to check Socket + CodeQL outputs and may approve a slice that introduced a flagged dependency or a static-analysis error. Landing this after Stories 02 + 06 ensures the reviewer references gates that actually exist in CI.
|
||||
|
||||
**External dependency:** library-evaluation epic story 06 (sandcastle reviewer prompt) must be complete — the reviewer prompt must exist and have the library-trace check section that this story composes with. That epic is marked done.
|
||||
|
||||
## Done when
|
||||
|
||||
- `.sandcastle/reviewer.prompt.md` has a "CI security checks" section added after the existing library-trace check section.
|
||||
- The section instructs the reviewer to: (a) run `gh run view <run-id> --log` (or equivalent) for the PR's check suite; (b) scan the output for Socket findings of severity `critical` — if found, reject with notes naming the finding and referencing the failure-mode hierarchy in `docs/guides/ci-security.md`; (c) scan the output for CodeQL findings of severity `error` — same rejection pattern.
|
||||
- The reviewer composes these checks with the existing library-trace presence check (both must pass for approval).
|
||||
- `pnpm typecheck && pnpm lint && pnpm test && pnpm conformance && pnpm fallow:audit && pnpm coverage:diff` all pass (prose-only change; no executable code).
|
||||
|
||||
## In scope
|
||||
|
||||
- `.sandcastle/reviewer.prompt.md` — new "CI security checks" section only; existing sections unchanged.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Automated tests for the reviewer prompt — it's a prose runbook for an agent; success is verified manually (PRD testing decisions).
|
||||
- Extending the reviewer for `pnpm audit signatures` step failures — those surface as standard CI job failures, already handled by the reviewer's existing "all CI checks must pass" instruction.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] Extend `.sandcastle/reviewer.prompt.md` with a "CI security checks" section after the library-trace check: instruct the reviewer to read `gh run view` output for Socket `critical` findings and CodeQL `error` findings, reject on either with notes naming the finding and citing `docs/guides/ci-security.md` failure-mode hierarchy; one commit, all gates pass.
|
||||
@@ -0,0 +1,60 @@
|
||||
---
|
||||
id: 09-ci-security-guide-and-docs
|
||||
epic: ci-security-and-supply-chain
|
||||
title: CI security guide + CLAUDE.md
|
||||
type: technical-story
|
||||
status: done
|
||||
feature: docs
|
||||
depends-on:
|
||||
[
|
||||
01-trace-schema-extensions,
|
||||
02-socket-integration,
|
||||
03-renovate-adoption,
|
||||
04-major-bump-reevaluation,
|
||||
05-trace-revalidation-workflow,
|
||||
06-codeql-and-audit-signatures,
|
||||
07-gitleaks-precommit,
|
||||
08-reviewer-prompt-update,
|
||||
]
|
||||
blocks: []
|
||||
created: 2026-05-14T18:59:12+02:00
|
||||
updated: 2026-05-14T19:21:52.308Z
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Write `docs/guides/ci-security.md` — the human reading-room for the four-pillar stack — and add a Key Conventions bullet to `CLAUDE.md` pointing agents and developers to ADR-023 + the guide.
|
||||
|
||||
## Why
|
||||
|
||||
Each prior story lands a machine-enforced layer, but no single document explains the composed system to a developer or consumer who hasn't read all nine stories. `docs/guides/ci-security.md` fills that gap: it explains the four pillars, the failure-mode hierarchy, what settings are consumer-toggleable, and gives two worked examples so the mental model is concrete. The CLAUDE.md bullet ensures the enforcement stack is discoverable during every agent session via the startup context.
|
||||
|
||||
## Done when
|
||||
|
||||
- `docs/guides/ci-security.md` exists and covers:
|
||||
- Overview of the four pillars (Renovate + Action SHA pinning, Socket, trace revalidation, GitHub-native gates).
|
||||
- Per-pillar section with what the gate does, what it catches, and how to toggle it in a downstream consumer repo.
|
||||
- Failure-mode hierarchy table (mirroring ADR-023 §5): pillar, trigger condition, action, label, who resolves.
|
||||
- Consumer-toggleable settings list: GitHub native push protection, Socket GitHub App install, branch protection rules requiring `library-policy/*`-labeled checks before merge.
|
||||
- Two worked examples: (a) a passing Renovate minor-bump PR (gates pass, auto-merges); (b) a blocked major-bump PR (Renovate opens PR → `check.mjs` requires `lastRevalidated` refresh → agent re-walks `evaluate-library` → trace updated → PR unblocked) + a hard-divergence revalidation issue (weekly cron finds Socket-flagged dep → per-dep `library-policy/re-evaluation` issue opened → agent closes issue after re-walk).
|
||||
- Socket GitHub App install instructions for consumers.
|
||||
- `gitleaks` installation instructions for developers (OS package manager / brew; hook exits gracefully if binary absent).
|
||||
- Note that CodeQL requires GitHub Advanced Security on private repos.
|
||||
- `CLAUDE.md` Key Conventions section has a bullet: _"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`."_
|
||||
- `pnpm typecheck && pnpm lint && pnpm test && pnpm conformance && pnpm fallow:audit && pnpm coverage:diff` all pass (docs + CLAUDE.md; no executable code).
|
||||
|
||||
## In scope
|
||||
|
||||
- `docs/guides/ci-security.md` — new guide file.
|
||||
- `CLAUDE.md` — one bullet addition to Key Conventions.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Configuring GitHub branch protection rules — documented as consumer action, not a tracked file change.
|
||||
- Installing the Socket GitHub App — documented as consumer action in the guide; no config file change.
|
||||
- Backfilling existing traces with `last-revalidated` — handled by the first revalidation cron run (Story 05).
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] Write `docs/guides/ci-security.md` with: four-pillar overview, per-pillar sections, failure-mode hierarchy table (ADR-023 §5), consumer-toggleable settings list, Socket GitHub App + gitleaks install instructions, CodeQL note for private repos, two worked examples (passing minor-bump PR; blocked major-bump PR + hard-divergence revalidation issue); one commit, all gates pass.
|
||||
- [x] Add CI security Key Conventions bullet to `CLAUDE.md` referencing ADR-023 + `docs/guides/ci-security.md`; one commit, all gates pass.
|
||||
30
docs/work/epics/ci-security-and-supply-chain/_epic.md
Normal file
30
docs/work/epics/ci-security-and-supply-chain/_epic.md
Normal file
@@ -0,0 +1,30 @@
|
||||
---
|
||||
id: ci-security-and-supply-chain
|
||||
prd: docs/work/prds/ci-security-and-supply-chain.prd.md
|
||||
title: CI security + supply-chain enforcement stack
|
||||
type: epic
|
||||
status: done
|
||||
features: [scripts, tooling, docs]
|
||||
created: 2026-05-14T00:00:00Z
|
||||
updated: 2026-05-14T19:21:52.308Z
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Implement 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. Codifies ADR-023.
|
||||
|
||||
## Why
|
||||
|
||||
The repo's security posture has zero security tooling. ADR-022 + the library-evaluation epic close the adoption-time gate for new dependencies but not the drift gate. Six post-adoption threats remain uncovered: CVE disclosures, supply-chain behavior compromise, maintainer-account compromise, GitHub Actions supply-chain (major-tag pinning), license drift, and EU-residency drift. This epic closes all six via the four-pillar stack.
|
||||
|
||||
## Stories
|
||||
|
||||
- [x] [01 — Trace schema extensions (socketRisk + lastRevalidated)](01-trace-schema-extensions/_story.md)
|
||||
- [x] [02 — Socket integration (skill + CI)](02-socket-integration/_story.md)
|
||||
- [x] [03 — Renovate adoption](03-renovate-adoption/_story.md)
|
||||
- [x] [04 — Major-bump re-evaluation flow](04-major-bump-reevaluation/_story.md)
|
||||
- [x] [05 — Trace revalidation workflow](05-trace-revalidation-workflow/_story.md)
|
||||
- [x] [06 — CodeQL workflow + pnpm audit signatures](06-codeql-and-audit-signatures/_story.md)
|
||||
- [x] [07 — Gitleaks pre-commit hook](07-gitleaks-precommit/_story.md)
|
||||
- [x] [08 — Sandcastle reviewer prompt update](08-reviewer-prompt-update/_story.md)
|
||||
- [x] [09 — CI security guide + CLAUDE.md](09-ci-security-guide-and-docs/_story.md)
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
id: 01-land-operator-checklist
|
||||
epic: compliance-docs-scaffolds
|
||||
title: Land operator-checklist.md verbatim
|
||||
type: technical-story
|
||||
status: done
|
||||
feature: ~
|
||||
depends-on: []
|
||||
blocks: [02-refresh-operator-checklist]
|
||||
created: 2026-05-20T12:00:00Z
|
||||
updated: 2026-05-20T12:57:04.290Z
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Track the existing untracked `docs/guides/operator-checklist.md` in git without altering its content. Establishes it as the baseline before the refresh story rewrites sections.
|
||||
|
||||
## Why
|
||||
|
||||
`docs/guides/operator-checklist.md` has been untracked since the ADR-022/023 work. The PRD (Q5) calls for landing it verbatim as a separate commit so the "what existed" diff is legible for review, distinct from the "what changed" diff that story 02 produces.
|
||||
|
||||
## Done when
|
||||
|
||||
- `git status` shows `docs/guides/operator-checklist.md` as tracked (no longer `??`).
|
||||
- File content is identical to its untracked state — no edits.
|
||||
- Commit type is `chore(docs)`.
|
||||
- `pnpm lint && pnpm typecheck && pnpm test && pnpm conformance && pnpm fallow:audit` all pass.
|
||||
|
||||
## In scope
|
||||
|
||||
- Staging and committing `docs/guides/operator-checklist.md` verbatim.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Any content changes to the file (story 02).
|
||||
- Any other files.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] Stage and commit `docs/guides/operator-checklist.md` verbatim as `chore(docs): track operator-checklist.md verbatim`; no content edits — the file is committed exactly as found in the working tree.
|
||||
@@ -0,0 +1,42 @@
|
||||
---
|
||||
id: 02-refresh-operator-checklist
|
||||
epic: compliance-docs-scaffolds
|
||||
title: Refresh operator-checklist.md for ADR-024 and ADR-025
|
||||
type: technical-story
|
||||
status: done
|
||||
feature: ~
|
||||
depends-on: [01-land-operator-checklist]
|
||||
blocks: []
|
||||
created: 2026-05-20T12:00:00Z
|
||||
updated: 2026-05-20T12:08:20.429Z
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Add two new sections to `docs/guides/operator-checklist.md` covering ADR-024 (analytics backend wiring) and ADR-025 (compliance directory, drift gate, retention purge scheduling, and sub-processors file). Preserve all existing ADR-022/023 content.
|
||||
|
||||
## Why
|
||||
|
||||
The operator checklist predates ADR-024 (analytics) and ADR-025 (the compliance epics). A template operator following the checklist today would miss compliance directory setup, the drift CI gate, and the retention purge job — all of which require a deliberate operator action.
|
||||
|
||||
## Done when
|
||||
|
||||
- `docs/guides/operator-checklist.md` contains a section for ADR-024 operator actions: note that the analytics backend is consumer-chosen + consumer-wired, and that the operator should decide whether to wire a vendor via `/evaluate-library`.
|
||||
- The file contains a section for ADR-025 operator actions: `compliance/*.yml` as committed audit evidence; compliance drift gate running in pre-commit + CI; retention purge job scheduling per `custom.retention`; `compliance/sub-processors.manual.yml` hand-authored for non-npm vendors.
|
||||
- All existing ADR-022/023 content is preserved unchanged.
|
||||
- Commit type is `docs(compliance)`.
|
||||
- `pnpm lint && pnpm typecheck && pnpm test && pnpm conformance && pnpm fallow:audit` all pass.
|
||||
|
||||
## In scope
|
||||
|
||||
- Adding ADR-024 operator actions section to `docs/guides/operator-checklist.md`.
|
||||
- Adding ADR-025 operator actions section to `docs/guides/operator-checklist.md`.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Rewriting or restructuring the existing ADR-022/023 sections.
|
||||
- Any other files.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] Add ADR-024 (analytics backend is consumer-chosen; note `/evaluate-library` decision process) and ADR-025 (commit `compliance/*.yml` as audit evidence, verify compliance drift gate in pre-commit + CI, schedule retention purge job per `custom.retention`, hand-author `compliance/sub-processors.manual.yml` for non-npm vendors) operator action sections to `docs/guides/operator-checklist.md`, preserving all existing content; commit as `docs(compliance): refresh operator-checklist with ADR-024 and ADR-025 actions`.
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
id: 03-policy-templates
|
||||
epic: compliance-docs-scaffolds
|
||||
title: Write seven policy templates in docs/compliance/templates/
|
||||
type: user-story
|
||||
status: done
|
||||
feature: ~
|
||||
depends-on: []
|
||||
blocks: [04-pre-launch-compliance-checklist, 05-compliance-overview]
|
||||
created: 2026-05-20T12:00:00Z
|
||||
updated: 2026-05-20T12:21:53.062Z
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Create `docs/compliance/templates/` and populate it with seven `*.template.md` files: two anchored templates (`incident-runbook`, `dsr-procedure`) cross-referencing real shipped features, and five skeleton templates (`backup-policy`, `password-policy`, `device-policy`, `onboarding`, `offboarding`) with "not code-enforced" banners. All templates use the `[FILL IN: <description>]` marker convention throughout.
|
||||
|
||||
## Why
|
||||
|
||||
A downstream consumer preparing for a DPA audit currently writes organizational policy documents from a blank page. These templates reduce that to copy-and-fill work. The anchored templates are substantive because the template ships the relevant code (DSR endpoints, audit channel, Sentry alerting, security headers, rate-limit) — they reference real ADR numbers and `pnpm` commands so the procedure matches the shipped system. The skeleton templates provide the document structure; the consumer supplies the organization-specific content.
|
||||
|
||||
## Done when
|
||||
|
||||
- `docs/compliance/templates/` directory exists with exactly 7 files: `incident-runbook.template.md`, `dsr-procedure.template.md`, `backup-policy.template.md`, `password-policy.template.md`, `device-policy.template.md`, `onboarding.template.md`, `offboarding.template.md`.
|
||||
- Each template has YAML frontmatter with `status: template` and `playbook-section: <n>`.
|
||||
- Anchored templates (`incident-runbook`, `dsr-procedure`) each reference at least one ADR number and one `pnpm` command or shipped interface/endpoint; each still contains `[FILL IN:]` markers for org-specific values (contacts, SLA targets, etc.).
|
||||
- Skeleton templates all open with the "not code-enforced" banner; `password-policy.template.md`'s banner cites ADR-025's explicit deferral of MFA + password policy + lockout by ADR number.
|
||||
- `grep -rn '\[FILL IN:' docs/compliance/templates/` returns hits in every template.
|
||||
- Every relative Markdown link in the new files resolves to an existing file.
|
||||
- `pnpm lint && pnpm typecheck && pnpm test && pnpm conformance && pnpm fallow:audit` all pass.
|
||||
|
||||
## In scope
|
||||
|
||||
- `docs/compliance/templates/incident-runbook.template.md` — breach detection → triage → containment → notification (GDPR Art. 33 72h / DPA 24h) → post-mortem; cross-references ADR-018 (audit channel), ADR-014 (Sentry alerting), Epic C (security-headers + rate-limit surfaces).
|
||||
- `docs/compliance/templates/dsr-procedure.template.md` — DSR receipt → validation → fulfilment → recording; cross-references Epic B DSR endpoints (`/api/gdpr/*`), `core-dsr` interfaces, audit `CONSENT_*`/`RESTRICT` actions, `compliance/data-map.yml` (Epic A).
|
||||
- `docs/compliance/templates/backup-policy.template.md` — skeleton with banner.
|
||||
- `docs/compliance/templates/password-policy.template.md` — skeleton with banner; banner cites ADR-025 MFA/password/lockout deferral.
|
||||
- `docs/compliance/templates/device-policy.template.md` — skeleton with banner.
|
||||
- `docs/compliance/templates/onboarding.template.md` — skeleton with banner.
|
||||
- `docs/compliance/templates/offboarding.template.md` — skeleton with banner.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Filling in org-specific values — the `[FILL IN:]` markers are intentionally left for the consumer.
|
||||
- Any changes to existing compliance guides (`dsr.md`, `consent.md`, etc.).
|
||||
- A CI gate enforcing no `[FILL IN:]` in `compliance/` (deferred).
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] Create `docs/compliance/templates/` and write `incident-runbook.template.md` + `dsr-procedure.template.md`: both anchored with YAML frontmatter (`status: template`, `playbook-section`), procedure skeleton cross-referencing real ADRs/commands/interfaces/endpoints, and `[FILL IN:]` markers for org-specific values; commit as `docs(compliance): add anchored policy templates (incident-runbook, dsr-procedure)`.
|
||||
- [x] Write `backup-policy.template.md`, `password-policy.template.md`, `device-policy.template.md`, `onboarding.template.md`, and `offboarding.template.md` in `docs/compliance/templates/`: each with YAML frontmatter, the "not code-enforced" banner (password-policy's banner cites ADR-025 MFA/lockout deferral by number), and `[FILL IN:]` markers throughout; commit as `docs(compliance): add skeleton policy templates (backup, password, device, onboarding, offboarding)`.
|
||||
@@ -0,0 +1,46 @@
|
||||
---
|
||||
id: 04-pre-launch-compliance-checklist
|
||||
epic: compliance-docs-scaffolds
|
||||
title: Write pre-launch compliance checklist
|
||||
type: user-story
|
||||
status: done
|
||||
feature: ~
|
||||
depends-on: [03-policy-templates]
|
||||
blocks: [05-compliance-overview]
|
||||
created: 2026-05-20T12:00:00Z
|
||||
updated: 2026-05-20T12:37:24.995Z
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Create `docs/guides/pre-launch-compliance-checklist.md` as a two-column Markdown table mapping every playbook obligation (drawn from playbook §19 + the 22 sections) to its coverage status: "Shipped by template" (with verification command), "Consumer responsibility", or "Infra responsibility". The table operationalizes ADR-025's three-way coverage split into a checkable launch gate.
|
||||
|
||||
## Why
|
||||
|
||||
A launching team currently has no single answer to "are we compliant enough to ship to a paying customer?" The playbook §19 is generic; nothing maps it to this template's concrete features (`pnpm compliance:emit-all --check`, `core-audit`, DSR endpoints) or explicitly flags which obligations are on the consumer vs the infra vs already handled. This checklist provides that clarity and produces verification evidence on demand for compliance officers.
|
||||
|
||||
## Done when
|
||||
|
||||
- `docs/guides/pre-launch-compliance-checklist.md` exists and contains a two-column table.
|
||||
- Rows are grouped by playbook section (Infrastructure, Data, Application, Secrets, Sub-Processors, Logging, Breach, DSR, Backup, SDLC, Workforce, Legal, Documentation).
|
||||
- Every "Shipped by template" row names a runnable verification command (e.g. `pnpm compliance:emit-all --check`, `pnpm conformance`, securityheaders.com scan).
|
||||
- Every "Consumer responsibility" and "Infra responsibility" row is explicitly labelled as such.
|
||||
- The file links to `docs/guides/compliance-overview.md` and to relevant templates in `docs/compliance/templates/`.
|
||||
- All 22 playbook sections are represented.
|
||||
- Every relative Markdown link resolves to an existing file.
|
||||
- Commit type is `docs(compliance)`.
|
||||
- `pnpm lint && pnpm typecheck && pnpm test && pnpm conformance && pnpm fallow:audit` all pass.
|
||||
|
||||
## In scope
|
||||
|
||||
- `docs/guides/pre-launch-compliance-checklist.md` — the two-column launch gate table.
|
||||
- Links outward to `compliance-overview.md` and `docs/compliance/templates/*.template.md`.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Machine-enforceable CI gate for this checklist (deferred).
|
||||
- Changes to any existing guide files.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] Write `docs/guides/pre-launch-compliance-checklist.md` as a two-column table covering all 22 playbook sections (Infrastructure, Data, Application, Secrets, Sub-Processors, Logging, Breach, DSR, Backup, SDLC, Workforce, Legal, Documentation), labelling each obligation as "Shipped by template" (with inline verification command), "Consumer responsibility", or "Infra responsibility", and linking to `compliance-overview.md` + relevant templates; commit as `docs(compliance): add pre-launch compliance checklist`.
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
id: 05-compliance-overview
|
||||
epic: compliance-docs-scaffolds
|
||||
title: Write compliance-overview.md hub
|
||||
type: user-story
|
||||
status: done
|
||||
feature: ~
|
||||
depends-on: [03-policy-templates, 04-pre-launch-compliance-checklist]
|
||||
blocks: [06-doc-wiring]
|
||||
created: 2026-05-20T12:00:00Z
|
||||
updated: 2026-05-20T12:47:06.204Z
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Create `docs/guides/compliance-overview.md` as the single navigational entry point for compliance in this template. It maps each of the 22 playbook sections to the ADR / guide / template / epic that covers it, names ADR-025's deferrals and consumer/infra-scope items explicitly, and links outward to every compliance guide, ADR, and template.
|
||||
|
||||
## Why
|
||||
|
||||
After four epics, compliance documentation sprawls across `docs/compliance/`, `docs/guides/` (8+ files), `docs/decisions/` (6 ADRs), and root `compliance/`. An auditor, new engineer, or AI agent asked "is feature X compliant?" has no entry point and must reverse-engineer the structure from grep. ADR-025 designated this hub as the solution; it must be written last so it can link to finished templates + checklist.
|
||||
|
||||
## Done when
|
||||
|
||||
- `docs/guides/compliance-overview.md` exists.
|
||||
- The file maps all 22 playbook sections; every row points at a real ADR, guide path, template path, or epic that covers it.
|
||||
- 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).
|
||||
- The file links one-directionally to `docs/guides/pre-launch-compliance-checklist.md`, `docs/compliance/templates/*.template.md`, all relevant ADRs (`docs/decisions/`), and all relevant guides.
|
||||
- Every relative Markdown link resolves to an existing file.
|
||||
- Commit type is `docs(compliance)`.
|
||||
- `pnpm lint && pnpm typecheck && pnpm test && pnpm conformance && pnpm fallow:audit` all pass.
|
||||
|
||||
## In scope
|
||||
|
||||
- `docs/guides/compliance-overview.md` — the 22-section hub with outbound links and deferrals summary.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Editing any existing ADR, guide, or template (one-directional only).
|
||||
- Duplicating ADR-025's rationale — the overview is the navigational map, not the decision record.
|
||||
- Replacing `docs/compliance/README.md` as the hub — README stays scoped to generator schema reference.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] Write `docs/guides/compliance-overview.md` mapping all 22 playbook sections to their covering ADR/guide/template/epic, with a closing deferrals summary (RBAC, MFA, breach-detection, GDPR Art. 22, EU region, TLS, MDM, legal instruments) and outbound links to all referenced files; verify every relative link resolves; commit as `docs(compliance): add compliance-overview hub`.
|
||||
@@ -0,0 +1,46 @@
|
||||
---
|
||||
id: 06-doc-wiring
|
||||
epic: compliance-docs-scaffolds
|
||||
title: Wire compliance docs into CLAUDE.md, README, and glossary
|
||||
type: technical-story
|
||||
status: done
|
||||
feature: ~
|
||||
depends-on: [05-compliance-overview]
|
||||
blocks: []
|
||||
created: 2026-05-20T12:00:00Z
|
||||
updated: 2026-05-20T12:52:53.917Z
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Complete the one-directional wiring of Epic D's new documents into the three shared navigation surfaces: add a "Policy templates" section to `docs/compliance/README.md`, add four new glossary entries to `docs/glossary.md`, and add a `compliance-overview.md` pointer to CLAUDE.md's "Read First" section.
|
||||
|
||||
## Why
|
||||
|
||||
Without these changes, `compliance-overview.md` is orphaned from the primary AI navigation entry point (CLAUDE.md), the policy templates have no discovery path in `docs/compliance/`, and the `[FILL IN:]` marker convention has no canonical glossary definition. The PRD specifies these as the final wiring step, after all new documents exist so links resolve.
|
||||
|
||||
## Done when
|
||||
|
||||
- `docs/compliance/README.md` has a "Policy templates" section that explains: the `docs/compliance/templates/` directory, the copy-to-`compliance/` workflow, the `[FILL IN:]` convention, and the `grep -rn '\[FILL IN:' compliance/` verification one-liner.
|
||||
- `docs/glossary.md` has four new entries: `pre-launch compliance checklist`, `compliance overview`, `fill-in template`, `[FILL IN:] marker`.
|
||||
- CLAUDE.md "Read First" lists `compliance-overview.md` (with path and one-line description matching the existing entries' style).
|
||||
- No existing content in any of the three files is removed or modified.
|
||||
- Every relative Markdown link added resolves to an existing file.
|
||||
- `pnpm lint && pnpm typecheck && pnpm test && pnpm conformance && pnpm fallow:audit` all pass.
|
||||
|
||||
## In scope
|
||||
|
||||
- `docs/compliance/README.md` — add "Policy templates" section.
|
||||
- `docs/glossary.md` — add 4 entries.
|
||||
- `CLAUDE.md` — add `compliance-overview.md` pointer to "Read First".
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Any changes to `docs/guides/compliance-overview.md` or the templates (they are complete by the time this story runs).
|
||||
- Any changes to the existing compliance guides (`dsr.md`, `consent.md`, etc.).
|
||||
- Broadening `docs/compliance/README.md` beyond the generator schema + policy template scope.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] Add a "Policy templates" section to `docs/compliance/README.md` (explaining the `docs/compliance/templates/` directory, copy-to-`compliance/` workflow, `[FILL IN:]` convention, and the `grep -rn '\[FILL IN:' compliance/` one-liner) and add four glossary entries (`pre-launch compliance checklist`, `compliance overview`, `fill-in template`, `[FILL IN:] marker`) to `docs/glossary.md`; commit as `docs(compliance): document policy template convention in README and glossary`.
|
||||
- [x] Add `docs/guides/compliance-overview.md` to the "Read First" section of `CLAUDE.md` with a one-line description matching the existing entries' style; commit as `docs(core): add compliance-overview.md to CLAUDE.md Read First`.
|
||||
27
docs/work/epics/compliance-docs-scaffolds/_epic.md
Normal file
27
docs/work/epics/compliance-docs-scaffolds/_epic.md
Normal file
@@ -0,0 +1,27 @@
|
||||
---
|
||||
id: compliance-docs-scaffolds
|
||||
prd: docs/work/prds/compliance-docs-scaffolds.prd.md
|
||||
title: Compliance docs scaffolds — Epic D of ADR-025
|
||||
type: epic
|
||||
status: done
|
||||
features: []
|
||||
created: 2026-05-20T12:00:00Z
|
||||
updated: 2026-05-20T12:52:53.917Z
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Ship the compliance documentation layer: seven copy-and-fill policy templates, a template-tailored pre-launch compliance checklist, a single `compliance-overview.md` hub mapping the 22 playbook sections, and a refreshed `operator-checklist.md`. Pure documentation — no code, no manifests, no conformance rules.
|
||||
|
||||
## Why
|
||||
|
||||
Epics A–C shipped the compliance machinery (PII manifests + retention + sub-processor generators, DSR + consent + cookie banner, security headers + rate-limit + SBOM). Epic D fills three remaining documentation gaps: no human-authored policy artifacts, no single launch gate, and no compliance map. A downstream consumer currently writes these from a blank page. ADR-025 settled the strategy; this epic is the implementation.
|
||||
|
||||
## Stories
|
||||
|
||||
- [x] [01 — Land operator-checklist.md verbatim](01-land-operator-checklist/_story.md)
|
||||
- [x] [02 — Refresh operator-checklist.md for ADR-024 and ADR-025](02-refresh-operator-checklist/_story.md)
|
||||
- [x] [03 — Write seven policy templates](03-policy-templates/_story.md)
|
||||
- [x] [04 — Write pre-launch compliance checklist](04-pre-launch-compliance-checklist/_story.md)
|
||||
- [x] [05 — Write compliance-overview.md hub](05-compliance-overview/_story.md)
|
||||
- [x] [06 — Wire compliance docs into CLAUDE.md, README, and glossary](06-doc-wiring/_story.md)
|
||||
@@ -0,0 +1,55 @@
|
||||
---
|
||||
id: 01-pii-retention-type-primitives
|
||||
epic: compliance-manifests-pii-retention-subprocessors
|
||||
title: PII and retention type primitives in core-shared
|
||||
type: technical-story
|
||||
status: done
|
||||
feature: core-shared
|
||||
depends-on: []
|
||||
blocks:
|
||||
[
|
||||
02-eslint-rule-pii-declaration-complete,
|
||||
03-adr-022-amendment-and-evaluate-library-skill,
|
||||
04-retention-purge-job,
|
||||
05-backfill-template-collections,
|
||||
06-compliance-generator-scripts,
|
||||
]
|
||||
created: 2026-05-18T17:52:09Z
|
||||
updated: 2026-05-18T18:29:19.373Z
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Produce the TypeScript type primitives (`PiiCategory`, `DataProcessingPurpose`, `RetentionAction`, `RetentionTrigger`, `FieldRetention`, `FieldPii`, `CollectionRetention`, `PurgeSchedule`, `PAYLOAD_AUTH_PII_DEFAULTS`) in `core-shared/payload/` plus the ambient module declaration that extends Payload's `Field` and `CollectionConfig` custom fields to be typed. These are the "manifest" for the whole epic — everything else depends on them existing and compiling.
|
||||
|
||||
## Why
|
||||
|
||||
All downstream stories need the type contracts before they can compile. Landing these first means every subsequent story gets full TypeScript coverage on Payload config files, and the ESLint rule has a known schema to validate against.
|
||||
|
||||
## Done when
|
||||
|
||||
- `packages/core-shared/src/payload/pii-types.ts` exports `PiiCategory`, `DataProcessingPurpose`, `RetentionTrigger`, `RetentionAction`, `FieldRetention`, `FieldPii`, and `PAYLOAD_AUTH_PII_DEFAULTS` (with `null` for credential fields: `password`, `salt`, `hash`, `resetPasswordToken`, `resetPasswordExpiration`, `loginAttempts`, `lockUntil`, `apiKey`, `apiKeyIndex`).
|
||||
- `packages/core-shared/src/payload/retention-types.ts` exports `PurgeSchedule` and `CollectionRetention`.
|
||||
- `packages/core-shared/src/payload/payload-custom-ambient.d.ts` augments the `payload` module to type `Field.custom.pii?: FieldPii` and `CollectionConfig.custom.retention?: CollectionRetention` / `CollectionConfig.custom.authPii?: Record<string, FieldPii | null>`.
|
||||
- Both modules are re-exported from the `core-shared` barrel (or a `payload` sub-barrel).
|
||||
- Vitest tests cover: `@ts-expect-error` on malformed `FieldPii` (missing required fields), `PAYLOAD_AUTH_PII_DEFAULTS` structure (credential fields are `null`, `email` is non-null with correct shape).
|
||||
- `pnpm typecheck && pnpm lint && pnpm test && pnpm conformance && pnpm fallow:audit && pnpm coverage:diff` all pass.
|
||||
|
||||
## In scope
|
||||
|
||||
- `packages/core-shared/src/payload/pii-types.ts`
|
||||
- `packages/core-shared/src/payload/retention-types.ts`
|
||||
- `packages/core-shared/src/payload/payload-custom-ambient.d.ts`
|
||||
- Vitest tests for both type files.
|
||||
- Barrel export additions.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- ESLint rule (Story 02).
|
||||
- Retention purge job (Story 04).
|
||||
- Collection backfill (Story 05).
|
||||
- Generator scripts (Story 06).
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] Add `pii-types.ts`, `retention-types.ts`, and `payload-custom-ambient.d.ts` to `packages/core-shared/src/payload/` — complete type contracts per the PRD, ambient Payload module augmentation, `PAYLOAD_AUTH_PII_DEFAULTS` constant with `null` for credential fields, vitest tests verifying shape and defaults, barrel exports — all gates pass on this commit.
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
id: 02-eslint-rule-pii-declaration-complete
|
||||
epic: compliance-manifests-pii-retention-subprocessors
|
||||
title: Add pii-declaration-must-be-complete ESLint rule to core-eslint
|
||||
type: technical-story
|
||||
status: done
|
||||
feature: core-eslint
|
||||
depends-on: [01-pii-retention-type-primitives]
|
||||
blocks: [05-backfill-template-collections]
|
||||
created: 2026-05-18T17:52:09Z
|
||||
updated: 2026-05-18T18:35:43.840Z
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Add the `conformance/pii-declaration-must-be-complete` rule to `@repo/core-eslint` at warn severity. The rule detects `custom: { pii: { ... } }` blocks in Payload collection/field TypeScript files and warns when any required sub-field (`category`, `purpose`, `exportable`, `restrictable`) is missing.
|
||||
|
||||
## Why
|
||||
|
||||
Provides sub-second editor and CI feedback when a developer partially declares a `custom.pii` block. Without this rule, a missing `exportable: false` could silently survive into `compliance/data-map.yml` and appear incorrectly in an audit report.
|
||||
|
||||
## Done when
|
||||
|
||||
- `packages/core-eslint/rules/pii-declaration-must-be-complete.js` exists and passes RuleTester fixtures.
|
||||
- Fixtures cover: complete `custom.pii` → no warning; `category` missing → warn; `purpose` missing → warn; `exportable` missing → warn; `restrictable` missing → warn; non-pii `custom` block → no-op; malformed/non-object `custom.pii` → no-op.
|
||||
- Rule registered in `plugin.js` and `base.js` at `"warn"` severity.
|
||||
- ESLint rule count in `docs/guides/conformance-quickref.md` and `CLAUDE.md` updated from 7 to 8.
|
||||
- `pnpm lint` exercises the rule; all gates pass.
|
||||
|
||||
## In scope
|
||||
|
||||
- `packages/core-eslint/rules/pii-declaration-must-be-complete.js` + RuleTester test file.
|
||||
- `packages/core-eslint/plugin.js` + `packages/core-eslint/base.js` — rule registration at warn.
|
||||
- `docs/guides/conformance-quickref.md` + `CLAUDE.md` conformance rule count bump.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Auto-fix path — warn only, no `--fix`.
|
||||
- Migration of existing collection files — that is Story 05's job.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] Add `pii-declaration-must-be-complete.js` rule to `@repo/core-eslint` — AST rule detecting incomplete `custom.pii` blocks in Payload config files, RuleTester fixtures (complete passes, each missing required field warns, non-pii custom block is no-op), register in `plugin.js` + `base.js` at `"warn"`, update conformance rule count in `conformance-quickref.md` and `CLAUDE.md` (7 → 8) — all gates pass on this commit.
|
||||
@@ -0,0 +1,42 @@
|
||||
---
|
||||
id: 03-adr-022-amendment-and-evaluate-library-skill
|
||||
epic: compliance-manifests-pii-retention-subprocessors
|
||||
title: ADR-022 amendment for sub-processor fields and evaluate-library skill update
|
||||
type: technical-story
|
||||
status: done
|
||||
feature: tooling
|
||||
depends-on: [01-pii-retention-type-primitives]
|
||||
blocks: []
|
||||
created: 2026-05-18T17:52:09Z
|
||||
updated: 2026-05-18T18:46:05.473Z
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Amend ADR-022 to add the discriminated-union sub-processor fields to every library trace frontmatter (`is-sub-processor`, `processes-pii`, and conditionally `data-sent`, `region`, `dpa-signed`, `sccs-required`, `contact`). Update `.claude/skills/evaluate-library/SKILL.md` to prompt for these new fields during trace authoring. Backfill existing `docs/library-decisions/*.md` traces so they comply with the amended schema.
|
||||
|
||||
## Why
|
||||
|
||||
Without the skill update, a developer running `/evaluate-library` today produces a trace missing the sub-processor fields, causing `emit-sub-processors.mjs` (Story 06) to silently skip it. Making the skill ask the questions in one pass prevents traces that require a later backfill amendment.
|
||||
|
||||
## Done when
|
||||
|
||||
- `docs/decisions/ADR-022.md` documents the discriminated-union sub-processor frontmatter specification: `is-sub-processor: boolean`, `processes-pii: boolean`, and the 5 conditional required fields when `is-sub-processor: true`.
|
||||
- `.claude/skills/evaluate-library/SKILL.md` updated with two new prompts ("is this library a sub-processor?" / "does it process PII in-process?"), conditional prompt for the 5 required sub-processor fields, and an updated trace frontmatter template.
|
||||
- All existing `docs/library-decisions/*.md` traces gain `is-sub-processor` + `processes-pii` fields (with `false` / `false` as the baseline for non-sub-processors, or correct values where applicable).
|
||||
- `pnpm typecheck && pnpm lint && pnpm test && pnpm conformance && pnpm fallow:audit && pnpm coverage:diff` all pass.
|
||||
|
||||
## In scope
|
||||
|
||||
- `docs/decisions/ADR-022.md` — sub-processor discriminated union specification.
|
||||
- `.claude/skills/evaluate-library/SKILL.md` — new prompts + updated trace template.
|
||||
- All existing `docs/library-decisions/*.md` traces — `is-sub-processor` + `processes-pii` field additions.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- `emit-sub-processors.mjs` generator implementation (Story 06).
|
||||
- Weekly `dpa-signed` staleness check in CI — that is an ADR-023 cross-reference; document it, but do not implement the cron here.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] Amend `docs/decisions/ADR-022.md` with the sub-processor discriminated union frontmatter spec, update `.claude/skills/evaluate-library/SKILL.md` with new prompts and trace template, and backfill all existing `docs/library-decisions/*.md` traces with `is-sub-processor` + `processes-pii` fields — all gates pass on this commit.
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
id: 04-retention-purge-job
|
||||
epic: compliance-manifests-pii-retention-subprocessors
|
||||
title: Background retention purge job in core-shared
|
||||
type: technical-story
|
||||
status: done
|
||||
feature: core-shared
|
||||
depends-on: [01-pii-retention-type-primitives]
|
||||
blocks: []
|
||||
created: 2026-05-18T17:52:09Z
|
||||
updated: 2026-05-18T18:58:23.920Z
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Add `core-shared/payload/retention-purge/retention-purge.job.ts` — a module that walks every Payload collection's `custom.retention.purgeSchedule`, registers a per-collection scheduled job via `IJobQueue`, queries rows whose active-retention period has elapsed, then either pseudonymizes or hard-deletes each row while emitting one `IAuditLog.record(...)` entry per row. Optional `auditLog` is skipped gracefully when `core-audit` is not wired.
|
||||
|
||||
## Why
|
||||
|
||||
Retention without automated purge is a compliance statement with no enforcement. The job makes `custom.retention` actionable: declarations in code become real deletes on schedule.
|
||||
|
||||
## Done when
|
||||
|
||||
- `packages/core-shared/src/payload/retention-purge/retention-purge.job.ts` exists; receives `IJobQueue` + `SanitizedConfig` (+ optional `IAuditLog`) via constructor/factory.
|
||||
- At registration time, one scheduled job is created per collection that declares `custom.retention.purgeSchedule`.
|
||||
- Job body queries by `createdAt` (trigger `from-creation`) or `updatedAt` (trigger `from-last-access`), applies `pseudonymize` (NULL PII fields) or `hard-delete` (Payload cascade delete) per `postDeletion.action`.
|
||||
- Each processed row emits `auditLog.record({ action: "DELETE", subject: row.id, actor: "system", reason: "retention-policy" })`; when `auditLog` is undefined the emission is silently skipped.
|
||||
- Unit tests with an in-memory Payload mock cover: schedule registration per collection, row matching per trigger type, audit emission, pseudonymize vs hard-delete branches, and graceful auditLog skip.
|
||||
- `pnpm typecheck && pnpm lint && pnpm test && pnpm conformance && pnpm fallow:audit && pnpm coverage:diff` all pass.
|
||||
|
||||
## In scope
|
||||
|
||||
- `packages/core-shared/src/payload/retention-purge/retention-purge.job.ts` + sibling unit test.
|
||||
- Uses existing `IJobQueue` from `core-shared/jobs` and `IAuditLog` from `core-audit` (optional injection).
|
||||
|
||||
## Out of scope
|
||||
|
||||
- App-side boot wiring — the module is the deliverable; consumers integrate it into their `bindAll()`.
|
||||
- `lastAccessedAt` field hook for true "from-last-access" tracking (deferred; see PRD Q2).
|
||||
- Advisory lock for concurrent purge guard (deferred; see PRD Q4).
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] Add `retention-purge.job.ts` + unit tests to `packages/core-shared/src/payload/retention-purge/` — walks `custom.retention.purgeSchedule` per collection, registers scheduled jobs via `IJobQueue`, executes pseudonymize or hard-delete per `postDeletion.action`, emits optional audit entry per row, unit tests cover all branches including graceful auditLog skip — all gates pass on this commit.
|
||||
@@ -0,0 +1,47 @@
|
||||
---
|
||||
id: 05-backfill-template-collections
|
||||
epic: compliance-manifests-pii-retention-subprocessors
|
||||
title: Backfill existing template Payload collections with PII and retention metadata
|
||||
type: technical-story
|
||||
status: done
|
||||
feature: auth
|
||||
depends-on:
|
||||
[01-pii-retention-type-primitives, 02-eslint-rule-pii-declaration-complete]
|
||||
blocks: [06-compliance-generator-scripts]
|
||||
created: 2026-05-18T17:52:09Z
|
||||
updated: 2026-05-18T19:21:32.358Z
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Add `custom.pii` + `custom.retention` to `auth.users` (full PII tagging), and `custom.retention` to the five remaining template collections (`blog.articles`, `marketing-pages.site-settings`, `marketing-pages.pages`, `media.media`, `navigation.header`), plus `custom.pii` on `media.media.uploadedBy` if the field exists. Each feature's collection file lands as its own commit.
|
||||
|
||||
## Why
|
||||
|
||||
The generators (Story 06) walk Payload configs to produce `compliance/*.yml`. Without backfill, the retention-policy generator warns every collection is missing `purgeSchedule`, and `data-map.yml` has no entries. The backfill also validates that the type primitives from Story 01 compile correctly in real collection files.
|
||||
|
||||
## Done when
|
||||
|
||||
- `auth.users`: `displayName` tagged `{ category: "identification-username", purpose: ["service-delivery"], exportable: true, restrictable: true }`. `custom.retention`: `postDeletion: { duration: "P30D", trigger: "after-deletion", action: "hard-delete" }`, `purgeSchedule: "daily"`. `PAYLOAD_AUTH_PII_DEFAULTS` covers `email`/`password`/`salt`/`hash` automatically — no `custom.authPii` override needed.
|
||||
- `blog.articles`: `custom.retention` with `purgeSchedule` declared.
|
||||
- `marketing-pages.site-settings` + `marketing-pages.pages`: `custom.retention` with `purgeSchedule` declared.
|
||||
- `media.media`: `custom.retention` with `purgeSchedule`; `uploadedBy` tagged `{ category: "identification-username", ... }` if the field exists in the collection.
|
||||
- `navigation.header`: `custom.retention` with `purgeSchedule` declared.
|
||||
- `pnpm typecheck && pnpm lint && pnpm test && pnpm conformance && pnpm fallow:audit && pnpm coverage:diff` all pass after each individual commit.
|
||||
|
||||
## In scope
|
||||
|
||||
- Payload collection config files within: `packages/auth/`, `packages/blog/`, `packages/marketing-pages/`, `packages/media/`, `packages/navigation/`.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- PII tags on fields not clearly identified as PII in the PRD (template default is conservative).
|
||||
- Custom `authPii` overrides — not needed unless non-default auth fields are present.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] Add `custom.pii` (`displayName` as `identification-username`) + `custom.retention` (daily purge, 30-day post-deletion hard-delete) to `auth` feature's Payload users collection — `PAYLOAD_AUTH_PII_DEFAULTS` covers email/credentials — all gates pass on this commit.
|
||||
- [x] Add `custom.retention` to `blog` feature's articles collection — all gates pass on this commit.
|
||||
- [x] Add `custom.retention` to `marketing-pages` feature's site-settings and pages collections — all gates pass on this commit.
|
||||
- [x] Add `custom.retention` (and `custom.pii` on `uploadedBy` if the field exists) to `media` feature's media collection — all gates pass on this commit.
|
||||
- [x] Add `custom.retention` to `navigation` feature's header collection — all gates pass on this commit.
|
||||
@@ -0,0 +1,52 @@
|
||||
---
|
||||
id: 06-compliance-generator-scripts
|
||||
epic: compliance-manifests-pii-retention-subprocessors
|
||||
title: Compliance generator scripts (emit-data-map, emit-retention-policy, emit-sub-processors, emit-all)
|
||||
type: technical-story
|
||||
status: done
|
||||
feature: tooling
|
||||
depends-on: [01-pii-retention-type-primitives, 05-backfill-template-collections]
|
||||
blocks: [07-pre-commit-and-ci-integration, 08-docs-compliance-reference-files]
|
||||
created: 2026-05-18T17:52:09Z
|
||||
updated: 2026-05-18T20:12:13.181Z
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Add four ESM scripts under `scripts/compliance/` and wire them as `pnpm compliance:*` package scripts in root `package.json`. Each emitter walks the relevant source (Payload collections or library traces), produces deterministic YAML, supports `--check` (diff against committed file, exit non-zero on mismatch) and `--print` (stdout) modes, and ships with unit tests. The final task commits the initial generated `compliance/*.yml` artifacts.
|
||||
|
||||
## Why
|
||||
|
||||
The generators are the runtime bridge between source declarations (Payload configs, ADR-022 library traces) and audit evidence (`compliance/*.yml`). Without them, the pre-commit hook and CI integration (Story 07) have nothing to invoke, and `compliance/` stays empty.
|
||||
|
||||
## Done when
|
||||
|
||||
- `scripts/compliance/emit-data-map.mjs`: walks Payload collections, applies `PAYLOAD_AUTH_PII_DEFAULTS` + `custom.authPii` overrides, emits deterministic `compliance/data-map.yml`, supports `--check` / `--print`; unit tests cover happy path, `--check` match, `--check` mismatch (readable diff), empty-collections, auth-defaults applied, and `authPii` override applied.
|
||||
- `scripts/compliance/emit-retention-policy.mjs`: walks collections, validates `purgeSchedule` present per collection (exit non-zero + hint if missing), emits `compliance/retention-policy.yml`, supports `--check` / `--print`; unit tests cover required fields validation and diff modes.
|
||||
- `scripts/compliance/emit-sub-processors.mjs`: walks `docs/library-decisions/*.md`, filters `is-sub-processor: true`, merges `compliance/sub-processors.manual.yml` (if present, with `source: manual` flag), emits sorted `compliance/sub-processors.yml`, supports `--check` / `--print`; unit tests cover discriminated-union parsing, absent manual file graceful skip, and merge.
|
||||
- `scripts/compliance/emit-all.mjs`: orchestrates all three in `--check` mode, exits non-zero if any generator fails.
|
||||
- Root `package.json` gains scripts: `compliance:data-map`, `compliance:retention-policy`, `compliance:sub-processors`, `compliance:emit-all`.
|
||||
- Initial `compliance/data-map.yml`, `compliance/retention-policy.yml`, `compliance/sub-processors.yml` generated and committed.
|
||||
- `pnpm typecheck && pnpm lint && pnpm test && pnpm conformance && pnpm fallow:audit && pnpm coverage:diff` all pass.
|
||||
|
||||
## In scope
|
||||
|
||||
- `scripts/compliance/emit-data-map.mjs` + tests.
|
||||
- `scripts/compliance/emit-retention-policy.mjs` + tests.
|
||||
- `scripts/compliance/emit-sub-processors.mjs` + tests.
|
||||
- `scripts/compliance/emit-all.mjs`.
|
||||
- Root `package.json` script entries.
|
||||
- Initial `compliance/*.yml` files committed.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Pre-commit hook wiring (Story 07).
|
||||
- CI integration (Story 07).
|
||||
- `compliance/sub-processors.manual.yml` — consumer-authored; generator handles its absence gracefully.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] Add `scripts/compliance/emit-data-map.mjs` + unit tests + `compliance:data-map` root package script — walks Payload collections, applies auth PII defaults and `authPii` overrides, deterministic YAML output, `--check` and `--print` modes — all gates pass on this commit.
|
||||
- [x] Add `scripts/compliance/emit-retention-policy.mjs` + unit tests + `compliance:retention-policy` root package script — validates `purgeSchedule` on every collection, deterministic YAML output, `--check` and `--print` modes — all gates pass on this commit.
|
||||
- [x] Add `scripts/compliance/emit-sub-processors.mjs` + unit tests + `compliance:sub-processors` root package script — parses `is-sub-processor` discriminated union from library traces, merges manual entries with `source: manual` flag, sorted deterministic YAML output, `--check` and `--print` modes — all gates pass on this commit.
|
||||
- [x] Add `scripts/compliance/emit-all.mjs` orchestrator + `compliance:emit-all` root package script, run `pnpm compliance:emit-all` to produce and commit initial `compliance/data-map.yml`, `compliance/retention-policy.yml`, and `compliance/sub-processors.yml` — all gates pass on this commit.
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
id: 07-pre-commit-and-ci-integration
|
||||
epic: compliance-manifests-pii-retention-subprocessors
|
||||
title: Pre-commit hook and CI integration for compliance drift detection
|
||||
type: technical-story
|
||||
status: done
|
||||
feature: tooling
|
||||
depends-on: [06-compliance-generator-scripts]
|
||||
blocks: []
|
||||
created: 2026-05-18T17:52:09Z
|
||||
updated: 2026-05-18T20:16:22.912Z
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Wire `pnpm compliance:emit-all` into the pre-commit hook (conditional — only when staged files are Payload configs or library traces) and add `pnpm compliance:emit-all --check` as a hard-fail step in the CI validate job, positioned after `pnpm conformance` and before `pnpm coverage:diff`.
|
||||
|
||||
## Why
|
||||
|
||||
The generators alone don't prevent drift — a developer could edit a Payload config and never regenerate. The pre-commit hook auto-regenerates and auto-stages `compliance/*.yml`; CI catches any slip-through on the PR. Together they form the E3 and E5 latency layers for compliance drift.
|
||||
|
||||
## Done when
|
||||
|
||||
- `.husky/pre-commit` gains a conditional step that: checks whether any staged file matches `packages/*/src/integrations/cms/**/*.ts`, `docs/library-decisions/*.md`, or `compliance/*.yml`; if so, runs `pnpm compliance:emit-all` and `git add compliance/`. Non-matching commits incur only the ~10ms detection cost.
|
||||
- `.github/workflows/ci.yml` validate job gains a `Compliance manifest drift check` step running `pnpm compliance:emit-all --check`, positioned after `pnpm conformance`. CI failure message includes the fix command (`pnpm compliance:emit-all`).
|
||||
- `pnpm typecheck && pnpm lint && pnpm test && pnpm conformance && pnpm fallow:audit && pnpm coverage:diff` all pass.
|
||||
|
||||
## In scope
|
||||
|
||||
- `.husky/pre-commit` — conditional compliance regeneration step.
|
||||
- `.github/workflows/ci.yml` — validate job `compliance:emit-all --check` step.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Generator scripts themselves (Story 06).
|
||||
- The `--no-verify` bypass — repo policy already prohibits it; CI re-checks provide the safety net.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] Add conditional `pnpm compliance:emit-all` step to `.husky/pre-commit` (staged-file pattern guard matching Payload configs, library traces, and `compliance/*.yml`; auto-stages generated files via `git add compliance/`) and add `pnpm compliance:emit-all --check` hard-fail step to `.github/workflows/ci.yml` validate job with failure message pointing to the fix command — all gates pass on this commit.
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
id: 08-docs-compliance-reference-files
|
||||
epic: compliance-manifests-pii-retention-subprocessors
|
||||
title: docs/compliance reference example files and README
|
||||
type: technical-story
|
||||
status: done
|
||||
feature: docs
|
||||
depends-on: [06-compliance-generator-scripts]
|
||||
blocks: []
|
||||
created: 2026-05-18T17:52:09Z
|
||||
updated: 2026-05-18T20:23:30.582Z
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Add `docs/compliance/data-map.example.yml`, `docs/compliance/retention-policy.example.yml`, `docs/compliance/sub-processors.example.yml`, and `docs/compliance/README.md` explaining the `docs/compliance/` (templates / examples) vs root `compliance/` (live generated artifacts) split and how to operate the generators.
|
||||
|
||||
## Why
|
||||
|
||||
Without documentation, a downstream consumer editing `compliance/*.yml` manually won't understand which files are generated vs hand-authored, what fields each entry requires, or how to run the generators. The `docs/compliance/` folder becomes the canonical onboarding reference for the compliance module.
|
||||
|
||||
## Done when
|
||||
|
||||
- `docs/compliance/data-map.example.yml` shows a complete data-map entry with every field (`category`, `purpose`, `exportable`, `restrictable`, optional `retention`) populated and annotated.
|
||||
- `docs/compliance/retention-policy.example.yml` shows a complete retention-policy entry (`activeRetention`, `postDeletion`, `purgeSchedule`, optional `coldArchive`).
|
||||
- `docs/compliance/sub-processors.example.yml` shows both a trace-backed entry (`is-sub-processor: true` with all conditional fields) and a `source: manual` hand-authored entry.
|
||||
- `docs/compliance/README.md` explains: what each YAML file contains, how they are generated, the `docs/compliance/` (examples) vs `compliance/` (live) split, when and how to author `compliance/sub-processors.manual.yml`, and how to run `pnpm compliance:emit-all`.
|
||||
- `pnpm typecheck && pnpm lint && pnpm test && pnpm conformance && pnpm fallow:audit && pnpm coverage:diff` all pass.
|
||||
|
||||
## In scope
|
||||
|
||||
- `docs/compliance/data-map.example.yml`
|
||||
- `docs/compliance/retention-policy.example.yml`
|
||||
- `docs/compliance/sub-processors.example.yml`
|
||||
- `docs/compliance/README.md`
|
||||
|
||||
## Out of scope
|
||||
|
||||
- `compliance/sub-processors.manual.yml` — consumer-authored artifact; not shipped by the template.
|
||||
- Runbooks, privacy policies, pre-launch checklist — Epic D territory.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] Add `docs/compliance/data-map.example.yml`, `docs/compliance/retention-policy.example.yml`, `docs/compliance/sub-processors.example.yml`, and `docs/compliance/README.md` explaining the docs/compliance (templates) vs compliance/ (live artifacts) split, generator usage, and manual sub-processor entry authoring — all gates pass on this commit.
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
id: compliance-manifests-pii-retention-subprocessors
|
||||
prd: docs/work/prds/compliance-manifests-pii-retention-subprocessors.prd.md
|
||||
title: Declarative compliance manifests (PII + retention + sub-processors) — Epic A of ADR-025
|
||||
type: epic
|
||||
status: done
|
||||
features:
|
||||
[core-shared, core-eslint, auth, blog, media, marketing-pages, navigation]
|
||||
created: 2026-05-18T17:52:09Z
|
||||
updated: 2026-05-18T20:23:30.582Z
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
## Stories
|
||||
|
||||
- [x] [01 — PII and retention type primitives in core-shared](01-pii-retention-type-primitives/_story.md)
|
||||
- [x] [02 — ESLint rule pii-declaration-must-be-complete](02-eslint-rule-pii-declaration-complete/_story.md)
|
||||
- [x] [03 — ADR-022 amendment and evaluate-library skill update](03-adr-022-amendment-and-evaluate-library-skill/_story.md)
|
||||
- [x] [04 — Background retention purge job in core-shared](04-retention-purge-job/_story.md)
|
||||
- [x] [05 — Backfill existing template collections](05-backfill-template-collections/_story.md)
|
||||
- [x] [06 — Compliance generator scripts](06-compliance-generator-scripts/_story.md)
|
||||
- [x] [07 — Pre-commit hook and CI integration](07-pre-commit-and-ci-integration/_story.md)
|
||||
- [x] [08 — docs/compliance reference files and README](08-docs-compliance-reference-files/_story.md)
|
||||
@@ -0,0 +1,46 @@
|
||||
---
|
||||
id: 01-subject-linkage-types
|
||||
epic: dsr-consent-and-cookie-banner
|
||||
title: Subject-linkage types in core-shared
|
||||
type: technical-story
|
||||
status: done
|
||||
feature: core-shared
|
||||
depends-on: []
|
||||
blocks: [02-audit-enum-amendment]
|
||||
created: 2026-05-19T12:00:00Z
|
||||
updated: 2026-05-19T10:13:17.502Z
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Add the subject-linkage type definitions to `@repo/core-shared` and apply the first instance of the `custom.subject` declaration to the `auth.users` collection, establishing the pattern every downstream consumer will follow.
|
||||
|
||||
## Why
|
||||
|
||||
The DSR cascade (Epic B's `core-dsr`) walks `custom.subject` fields to discover which rows belong to a given subject. Before any DSR or consent implementation can begin, the TypeScript types and Payload ambient declaration must exist in `core-shared` (must-have package) so all other packages can reference them without an optional-core dependency.
|
||||
|
||||
## Done when
|
||||
|
||||
- `packages/core-shared/src/payload/subject-linkage-types.ts` exports `SubjectLinkKind`, `SubjectLink`, and `CollectionSubject`.
|
||||
- An ambient module declaration extends Payload's `CollectionConfig.custom?` with `subject?: CollectionSubject | CollectionSubject[]`.
|
||||
- `PAYLOAD_AUTH_PII_DEFAULTS` (Epic A's auth-managed exclusions list) gains `processingRestrictedAt` and `consentState` as excluded fields.
|
||||
- `packages/auth/` sets `custom.subject = { kind: "self", field: "id" }` on the `users` collection config (canonical usage example).
|
||||
- `pnpm typecheck && pnpm lint && pnpm test && pnpm conformance && pnpm fallow:audit && pnpm coverage:diff` all pass.
|
||||
|
||||
## In scope
|
||||
|
||||
- `packages/core-shared/src/payload/subject-linkage-types.ts` — `SubjectLinkKind` (`"self" | "owner" | "reference"`), `SubjectLink` (field, kind, optional target + role), `CollectionSubject` (single or array form).
|
||||
- Ambient declaration extending `CollectionConfig.custom?` in `core-shared` (parallel to `custom.pii` from Epic A).
|
||||
- `PAYLOAD_AUTH_PII_DEFAULTS` extension with `processingRestrictedAt` and `consentState`.
|
||||
- Export of new types from the `core-shared` payload barrel.
|
||||
- `auth.users` collection: explicit `custom.subject = { kind: "self", field: "id" }` declaration for documentation clarity.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Payload impl walking `custom.subject` (Story 06 — core-dsr).
|
||||
- All other existing collections (`blog.articles`, `media.media`, etc.) — no subject linkage needed per Epic A's PII backfill.
|
||||
- `docs/compliance/subject-linkage.example.md` — Story 11.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] Add `SubjectLink`, `SubjectLinkKind`, `CollectionSubject` types in `packages/core-shared/src/payload/subject-linkage-types.ts` + ambient declaration extending Payload `CollectionConfig.custom?` with `subject?: CollectionSubject | CollectionSubject[]` + extend `PAYLOAD_AUTH_PII_DEFAULTS` with `processingRestrictedAt` and `consentState` as excluded fields + export from `core-shared` payload barrel + add `custom.subject = { kind: "self", field: "id" }` to `packages/auth/`'s `users` collection config; all gates pass.
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
id: 02-audit-enum-amendment
|
||||
epic: dsr-consent-and-cookie-banner
|
||||
title: Audit action enum amendment (ADR-018)
|
||||
type: technical-story
|
||||
status: done
|
||||
feature: core-shared
|
||||
depends-on: [01-subject-linkage-types]
|
||||
blocks: [03-core-consent-foundation, 06-core-dsr]
|
||||
created: 2026-05-19T12:00:00Z
|
||||
updated: 2026-05-19T10:29:21.283Z
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Extend the audit action enum with four new action types needed by consent and DSR, and amend ADR-018 to document the addition.
|
||||
|
||||
## Why
|
||||
|
||||
`core-consent`'s `IConsent.grant` / `IConsent.withdraw` emit `CONSENT_GRANT` / `CONSENT_WITHDRAW` for Art. 7 legal proof. `core-dsr`'s `IProcessingRestriction` emits `RESTRICT` / `UNRESTRICT` for Art. 18. Both optional cores must emit via `core-audit`'s existing channel; the action types must exist in `core-shared`'s enum before either optional core can be implemented.
|
||||
|
||||
## Done when
|
||||
|
||||
- The audit action enum in `core-shared/audit/` gains `CONSENT_GRANT`, `CONSENT_WITHDRAW`, `RESTRICT`, `UNRESTRICT`.
|
||||
- `core-audit`'s `IAuditLog.record` accepts the new action types without type errors.
|
||||
- `docs/guides/audit-and-compliance.md` is updated to list the new action types.
|
||||
- `docs/decisions/ADR-018.md` gains an `## Amendments` section recording the date and the reason (consent + restriction events added for Epic B).
|
||||
- `pnpm typecheck && pnpm lint && pnpm test && pnpm conformance && pnpm fallow:audit && pnpm coverage:diff` all pass.
|
||||
|
||||
## In scope
|
||||
|
||||
- Audit action enum extension (four new values) in `core-shared/audit/`.
|
||||
- `core-audit` type update so `IAuditLog.record` is compatible with the new values (no new interface methods).
|
||||
- `docs/guides/audit-and-compliance.md` update — "Six action types" wording amended to reflect the new count.
|
||||
- ADR-018 amendment section.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- New audit emission call sites (Stories 04 and 06 — they live in the optional-core implementations).
|
||||
- `eraseSubject` flow changes — existing post-DSR-delete pseudonymization is unchanged.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] Add `CONSENT_GRANT`, `CONSENT_WITHDRAW`, `RESTRICT`, `UNRESTRICT` to the audit action enum in `packages/core-shared/src/audit/` + update `core-audit`'s `IAuditLog` type to accept the new values + update `docs/guides/audit-and-compliance.md` with the new action types + add `## Amendments` section to `docs/decisions/ADR-018.md` recording the date and reason; all gates pass.
|
||||
@@ -0,0 +1,57 @@
|
||||
---
|
||||
id: 03-core-consent-foundation
|
||||
epic: dsr-consent-and-cookie-banner
|
||||
title: core-consent foundation — types, brand, withConsent wrapper, conformance extension, ESLint rule
|
||||
type: technical-story
|
||||
status: done
|
||||
feature: core-consent
|
||||
depends-on: [02-audit-enum-amendment]
|
||||
blocks: [04-core-consent-implementation]
|
||||
created: 2026-05-19T12:00:00Z
|
||||
updated: 2026-05-19T11:54:41.015Z
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Scaffold `@repo/core-consent`, define the public type surface (`ConsentCategory`, `IConsent`), establish the `ConsentChecked` brand + `withConsent` wrapper, wire the `requiresConsent` manifest field into `assertFeatureConformance`, and add the `no-undeclared-consent-check` ESLint rule — the full conformance-layer foundation that all subsequent consent implementation and use-case integration depends on.
|
||||
|
||||
## Why
|
||||
|
||||
Manifest-first ordering requires that the consent type surface and structural enforcement land before any runtime implementation. The `ConsentChecked` brand must exist in `core-shared/conformance/brands.ts` before `assertFeatureConformance` can check it; the `requiresConsent` manifest field must be schema-valid before any feature can declare it; the ESLint rule must exist before use cases can be lint-gated. Landing both tasks in this story keeps the conformance layer coherent before any optional-core impl lands.
|
||||
|
||||
## Done when
|
||||
|
||||
- `pnpm turbo gen core-package consent` produces a green package shell.
|
||||
- `core-consent/consent-types.ts` exports `ConsentCategory`, `ConsentState`, `UserConsentState`.
|
||||
- `core-consent/consent.interface.ts` exports `IConsent`.
|
||||
- `core-shared/conformance/brands.ts` exports `ConsentChecked` brand + `isConsentChecked` helper.
|
||||
- `core-consent/with-consent.ts` exports `withConsent` wrapper attaching `ConsentChecked` brand at bind time; unit tests assert brand is attached and factory passthrough is preserved.
|
||||
- Feature manifest schema gains `requiresConsent: ConsentCategory[]` (default `[]`); existing features declare it as empty array without errors.
|
||||
- `assertFeatureConformance` boot check requires `ConsentChecked` brand when `requiresConsent.length > 0`; synthetic fixture test asserts failure when brand is absent.
|
||||
- `core-eslint/rules/no-undeclared-consent-check.js` is registered at warn severity; `_manifest-ast.js` extracts `requiresConsent` field; RuleTester fixtures cover: matching call (pass), undeclared category in call site (warn), unused manifest declaration (warn), non-use-case file (no-op).
|
||||
- Conformance ESLint rule count in CLAUDE.md and `docs/guides/conformance-quickref.md` advances from 11 → 12.
|
||||
- `pnpm typecheck && pnpm lint && pnpm test && pnpm conformance && pnpm fallow:audit && pnpm coverage:diff` all pass after each task.
|
||||
|
||||
## In scope
|
||||
|
||||
- `pnpm turbo gen core-package consent` scaffold.
|
||||
- `ConsentCategory` (string-literal union with escape hatch), `ConsentState`, `UserConsentState` types.
|
||||
- `IConsent` interface (`isGranted`, `grant`, `withdraw`, `getCategories`).
|
||||
- `ConsentChecked` brand in `core-shared/conformance/brands.ts`.
|
||||
- `withConsent` wrapper (composes innermost — order: `withSpan → withCapture → withAudit → withAnalytics → withConsent → factory(deps)`).
|
||||
- `requiresConsent: ConsentCategory[]` manifest field (schema + default).
|
||||
- `assertFeatureConformance` extension for `ConsentChecked` brand check.
|
||||
- `no-undeclared-consent-check` ESLint rule + `_manifest-ast.js` `requiresConsent` extraction.
|
||||
- CLAUDE.md + `docs/guides/conformance-quickref.md` rule-count bump (11 → 12).
|
||||
|
||||
## Out of scope
|
||||
|
||||
- `PayloadConsent` Payload-backed implementation (Story 04).
|
||||
- DI binders, handlers, tRPC router (Story 04).
|
||||
- React subpath (Story 05).
|
||||
- Anonymous migration helpers (Story 04).
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] Run `pnpm turbo gen core-package consent` + add `ConsentCategory`, `ConsentState`, `UserConsentState` types in `core-consent/consent-types.ts` + `IConsent` interface in `core-consent/consent.interface.ts` + `ConsentChecked` brand + `isConsentChecked` helper in `core-shared/conformance/brands.ts` + `withConsent` wrapper in `core-consent/with-consent.ts` with unit tests asserting brand attachment and factory passthrough + `requiresConsent: ConsentCategory[]` field in the feature manifest schema (default `[]`) + extend `assertFeatureConformance` to require `ConsentChecked` brand when `requiresConsent.length > 0` with a synthetic fixture test asserting the boot failure; all gates pass.
|
||||
- [x] Add `no-undeclared-consent-check` ESLint rule (warn severity) in `packages/core-eslint/rules/` + extend `_manifest-ast.js` to extract `requiresConsent` + add RuleTester fixtures; update CLAUDE.md and `docs/guides/conformance-quickref.md` rule count 11 → 12; all gates pass.
|
||||
@@ -0,0 +1,57 @@
|
||||
---
|
||||
id: 04-core-consent-implementation
|
||||
epic: dsr-consent-and-cookie-banner
|
||||
title: core-consent implementation — Payload impl, DI binders, migration helpers, tRPC router
|
||||
type: technical-story
|
||||
status: done
|
||||
feature: core-consent
|
||||
depends-on: [03-core-consent-foundation]
|
||||
blocks:
|
||||
[
|
||||
05-core-consent-react,
|
||||
07-core-api-router-composition,
|
||||
10-auth-signup-migration,
|
||||
]
|
||||
created: 2026-05-19T12:00:00Z
|
||||
updated: 2026-05-19T13:27:09.005Z
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Implement the runtime layer of `@repo/core-consent`: the Payload-backed `PayloadConsent` impl, the `RecordingConsent` test double, DI binders, the anonymous → authenticated consent migration helpers, protocol-agnostic handlers, and the `consentRouter` tRPC router.
|
||||
|
||||
## Why
|
||||
|
||||
Story 03 established the type surface and structural enforcement. This story delivers the working machinery: a Payload impl that reads/writes `users.consentState` and emits `CONSENT_GRANT`/`CONSENT_WITHDRAW` audit entries, migration helpers that consumers call in `signUp`, and a tRPC router that `core-api` can compose. Without this story, no downstream use case can actually check or record consent.
|
||||
|
||||
## Done when
|
||||
|
||||
- `PayloadConsent` in `core-consent/` reads `users.consentState` for fast `isGranted` reads and writes both the cache field and a `CONSENT_GRANT`/`CONSENT_WITHDRAW` audit entry via injected `core-audit` on grant/withdraw.
|
||||
- `RecordingConsent` test double in `core-testing` records calls and payloads; unit tests assert captured calls match invocations.
|
||||
- DI binders `core-consent/di/bind-production.ts` + `core-consent/di/bind-dev-seed.ts` exist; `assertFeatureConformance` passes at boot.
|
||||
- Contract tests cover: `isGranted` returns `false` before grant, `true` after; `grant` writes both cache + audit entry with correct shape; `withdraw` clears cache + emits `CONSENT_WITHDRAW`; `getCategories` returns all categories with `granted: true`; consent state round-trip including `bannerVersion`, `policyVersion`, `method`.
|
||||
- `extractAnonymousConsent(cookieHeader: string)` + `migrateAnonymousConsent({ userId, cookieState, bannerVersion, policyVersion })` exist in `core-consent/` with tests covering the happy path and absent-cookie no-op.
|
||||
- Protocol-agnostic handlers in `core-consent/handlers/` return normalized `{ status, body, headers }`.
|
||||
- `consentRouter` in `core-consent/consent.router.ts` exports `grant`, `withdraw`, `isGranted`, `getCategories` procedures with auth checks via `defineErrorMiddleware` pattern; integration tests assert response shapes and auth error passthrough.
|
||||
- `pnpm typecheck && pnpm lint && pnpm test && pnpm conformance && pnpm fallow:audit && pnpm coverage:diff` all pass after each task.
|
||||
|
||||
## In scope
|
||||
|
||||
- `PayloadConsent` Payload-backed implementation (reads/writes `users.consentState`, emits consent audit entries via injected `core-audit`).
|
||||
- `RecordingConsent` test double in `packages/core-testing/`.
|
||||
- DI binders (`bind-production`, `bind-dev-seed`) in `core-consent/di/`.
|
||||
- `extractAnonymousConsent` + `migrateAnonymousConsent` migration helpers in `core-consent/`.
|
||||
- Protocol-agnostic handlers in `core-consent/handlers/`.
|
||||
- `consentRouter` tRPC router in `core-consent/consent.router.ts`.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- React subpath `<ConsentProvider>` + `useConsent()` (Story 05).
|
||||
- `core-api` appRouter composition (Story 07).
|
||||
- `auth.signUp` integration (Story 10).
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] Add `PayloadConsent` Payload-backed implementation in `core-consent/` reading/writing `users.consentState` and emitting `CONSENT_GRANT`/`CONSENT_WITHDRAW` audit entries via injected `core-audit` + `RecordingConsent` test double in `packages/core-testing/` + DI binders `core-consent/di/bind-production.ts` and `core-consent/di/bind-dev-seed.ts`; contract tests covering grant/withdraw/isGranted round-trip, audit entry shape, and `getCategories`; all gates pass.
|
||||
- [x] Add `extractAnonymousConsent(cookieHeader: string)` + `migrateAnonymousConsent({ userId, cookieState, bannerVersion, policyVersion })` helpers in `core-consent/` with tests covering the happy path (cookie present → calls `IConsent.grant` with `method: "signup-migration"`) and the absent-cookie no-op; all gates pass.
|
||||
- [x] Add protocol-agnostic handlers in `core-consent/handlers/` + `consentRouter` tRPC router in `core-consent/consent.router.ts` exporting `grant`, `withdraw`, `isGranted`, `getCategories` procedures with auth checks; integration tests asserting response shapes and auth error passthrough; all gates pass.
|
||||
@@ -0,0 +1,46 @@
|
||||
---
|
||||
id: 05-core-consent-react
|
||||
epic: dsr-consent-and-cookie-banner
|
||||
title: core-consent React subpath — ConsentProvider + useConsent()
|
||||
type: technical-story
|
||||
status: done
|
||||
feature: core-consent
|
||||
depends-on: [04-core-consent-implementation]
|
||||
blocks: [09-cookie-consent-banner]
|
||||
created: 2026-05-19T12:00:00Z
|
||||
updated: 2026-05-19T19:21:30.198Z
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Add the React subpath `core-consent/react` exporting `<ConsentProvider>` and `useConsent()`, following the pattern established by `@repo/core-analytics/react` (`<AnalyticsProvider>` + `useAnalytics()`).
|
||||
|
||||
## Why
|
||||
|
||||
The `<CookieConsentBanner>` component (Story 09) reads consent state and dispatches grant/withdraw via `useConsent()`. The React subpath must exist and be stable before the banner can be built.
|
||||
|
||||
## Done when
|
||||
|
||||
- `packages/core-consent/src/react/index.ts` exports `<ConsentProvider>` + `useConsent()`.
|
||||
- `<ConsentProvider>` accepts an `IConsent` instance as a prop and makes it available via context.
|
||||
- `useConsent()` returns `{ isGranted, grant, withdraw, getCategories }` bound to the context instance.
|
||||
- The subpath is exposed via `package.json` exports as `"./react"`.
|
||||
- Tests using React Testing Library assert: `useConsent()` returns the methods; `grant()` and `withdraw()` propagate to the injected `IConsent`; `isGranted()` reflects the mock instance's state; missing `<ConsentProvider>` ancestor throws a descriptive error.
|
||||
- `pnpm typecheck && pnpm lint && pnpm test && pnpm conformance && pnpm fallow:audit && pnpm coverage:diff` all pass.
|
||||
|
||||
## In scope
|
||||
|
||||
- `packages/core-consent/src/react/consent-provider.tsx` + `use-consent.ts`.
|
||||
- `packages/core-consent/src/react/index.ts` barrel.
|
||||
- `package.json` exports `"./react"` subpath entry.
|
||||
- RTL + vitest unit tests.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- `<CookieConsentBanner>` component (Story 09).
|
||||
- Any cookie read/write logic (lives in the banner, not the hook).
|
||||
- SSR placeholder / dynamic-import loader (documented in consent.md, Story 11; the hook itself is client-only by design — no SSR surface here).
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] Add `<ConsentProvider>` + `useConsent()` hook in `packages/core-consent/src/react/` + barrel export + `"./react"` subpath in `package.json`; RTL tests asserting context propagation, method delegation, and missing-provider error; all gates pass.
|
||||
@@ -0,0 +1,63 @@
|
||||
---
|
||||
id: 06-core-dsr
|
||||
epic: dsr-consent-and-cookie-banner
|
||||
title: core-dsr — scaffold, interfaces, Payload impls, recording doubles, handlers, dsrRouter
|
||||
type: technical-story
|
||||
status: done
|
||||
feature: core-dsr
|
||||
depends-on: [02-audit-enum-amendment]
|
||||
blocks: [07-core-api-router-composition]
|
||||
created: 2026-05-19T12:00:00Z
|
||||
updated: 2026-05-19T20:39:14.972Z
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Scaffold `@repo/core-dsr` and deliver the complete DSR capability: four GDPR interfaces (`IDataExport`, `IDataDelete`, `IDataRectify`, `IProcessingRestriction`), their Payload-backed implementations walking Epic A's `custom.pii` tags and Story 01's `custom.subject` linkage, four recording doubles in `core-testing`, DI binders, protocol-agnostic handlers, and the `dsrRouter` tRPC router.
|
||||
|
||||
## Why
|
||||
|
||||
Every EU-bound consumer needs endpoints to satisfy GDPR Arts. 15–18 + 20. The DSR interfaces encapsulate the cascade walk over `custom.subject`-linked collections so consumers never reinvent it. The Payload impls drive the reference implementation; the recording doubles let feature tests assert DSR behaviour without Payload. The tRPC router surfaces the capability to `core-api` (Story 07).
|
||||
|
||||
## Done when
|
||||
|
||||
- `pnpm turbo gen core-package dsr` produces a green package shell.
|
||||
- `IDataExport`, `IDataDelete`, `IDataRectify`, `IProcessingRestriction` interfaces exist in `core-dsr/<interface>.interface.ts`.
|
||||
- `core-dsr/contexts/user-data.jsonld` ships the schema.org JSON-LD `@context`.
|
||||
- `PayloadDataExport.exportSubjectData("alice", "json")` walks `users` + any `custom.subject`-linked collections, returning a `UserDataBundle` with `asSelf` for `kind: "self" | "owner"` rows and `asReference` for `kind: "reference"` rows.
|
||||
- `PayloadDataDelete.deleteSubjectData("alice", "soft")` flips `processingRestrictedAt`, NULLs exportable PII, redacts `reference`-role linked fields to `null`, emits one audit entry per affected collection, returns a `DeletionCertificate`.
|
||||
- `PayloadDataDelete.deleteSubjectData("alice", "cascade-hard")` hard-deletes `self`/`owner` rows and redacts `reference` fields immediately (admin-only; auth check at procedure level).
|
||||
- `PayloadDataRectify.updateSubjectField` updates the specified field and emits a `RESTRICT` audit entry with `reason: "art-16-request"`.
|
||||
- `PayloadProcessingRestriction.{setRestriction, isRestricted}` toggles and reads `processingRestrictedAt`; emits `RESTRICT`/`UNRESTRICT` audit entries.
|
||||
- `RecordingDataExport`, `RecordingDataDelete`, `RecordingDataRectify`, `RecordingProcessingRestriction` test doubles in `core-testing` record calls and payloads; unit tests assert shape.
|
||||
- DI binders `core-dsr/di/bind-production.ts` + `core-dsr/di/bind-dev-seed.ts` wire all four interfaces; `assertFeatureConformance` passes at boot.
|
||||
- Contract tests cover: happy path per role/mode, multi-subject row redaction (only the requesting subject's link is redacted, row preserved), JSON-LD `@context` correctness (parsed by `jsonld` library in test only), audit emission shape per operation, restriction flag honored by `isRestricted`.
|
||||
- Protocol-agnostic handlers in `core-dsr/handlers/{export,delete,rectify,restrict}-handler.ts` return normalized `{ status, body, headers }`.
|
||||
- `dsrRouter` in `core-dsr/dsr.router.ts` exports `export`, `delete`, `rectify`, `restrict` procedures with auth checks; integration tests assert response shapes and error passthrough.
|
||||
- `pnpm typecheck && pnpm lint && pnpm test && pnpm conformance && pnpm fallow:audit && pnpm coverage:diff` all pass after each task.
|
||||
|
||||
## In scope
|
||||
|
||||
- `pnpm turbo gen core-package dsr` scaffold.
|
||||
- Four GDPR interfaces + `UserDataBundle` + `DeletionCertificate` types.
|
||||
- `core-dsr/contexts/user-data.jsonld` (schema.org `@context`; consumer-overridable).
|
||||
- `PayloadDataExport`, `PayloadDataDelete`, `PayloadDataRectify`, `PayloadProcessingRestriction` Payload-backed implementations.
|
||||
- `RecordingData*` test doubles in `packages/core-testing/`.
|
||||
- DI binders in `core-dsr/di/`.
|
||||
- Protocol-agnostic handlers in `core-dsr/handlers/`.
|
||||
- `dsrRouter` tRPC router in `core-dsr/dsr.router.ts`.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- `core-api` appRouter composition (Story 07).
|
||||
- Streaming `IDataExport` — in-memory `UserDataBundle` only (streaming v2 deferred).
|
||||
- `dsr_rectifications` separate audit collection — main audit log via `reason: "art-16-request"` tag is sufficient.
|
||||
- `withRestriction` brand-treatment — consumer calls `isRestricted` where needed; no wrapper.
|
||||
- 30-day grace period hard-delete — handled by Epic A's existing retention purge job (no new code here).
|
||||
- Consent checks on DSR use cases — DSR operations are subject-rights fulfilment, not consent-gated.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] Run `pnpm turbo gen core-package dsr` + define `IDataExport`, `IDataDelete`, `IDataRectify`, `IProcessingRestriction` interfaces in `core-dsr/<interface>.interface.ts` + add `UserDataBundle` + `DeletionCertificate` types + ship `core-dsr/contexts/user-data.jsonld`; all gates pass.
|
||||
- [x] Add `PayloadDataExport`, `PayloadDataDelete`, `PayloadDataRectify`, `PayloadProcessingRestriction` Payload-backed implementations walking `custom.pii` tags and `custom.subject` linkage (cascade semantics per role: self/owner/reference) + `RecordingDataExport`, `RecordingDataDelete`, `RecordingDataRectify`, `RecordingProcessingRestriction` test doubles in `packages/core-testing/` + DI binders `core-dsr/di/bind-production.ts` and `core-dsr/di/bind-dev-seed.ts`; contract tests covering happy path per role/mode, multi-subject row redaction, JSON-LD `@context` correctness, audit emission shape, restriction flag; all gates pass.
|
||||
- [x] Add protocol-agnostic handlers in `core-dsr/handlers/{export,delete,rectify,restrict}-handler.ts` + `dsrRouter` tRPC router in `core-dsr/dsr.router.ts` with auth checks; integration tests asserting procedure response shapes and error passthrough; all gates pass.
|
||||
@@ -0,0 +1,42 @@
|
||||
---
|
||||
id: 07-core-api-router-composition
|
||||
epic: dsr-consent-and-cookie-banner
|
||||
title: core-api router composition — dsrRouter + consentRouter into appRouter
|
||||
type: technical-story
|
||||
status: done
|
||||
feature: core-api
|
||||
depends-on: [04-core-consent-implementation, 06-core-dsr]
|
||||
blocks: [11-documentation]
|
||||
created: 2026-05-19T12:00:00Z
|
||||
updated: 2026-05-19T20:52:13.004Z
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Compose `dsrRouter` and `consentRouter` into `core-api`'s `appRouter` via the existing `<gen:*>` anchor + barrel pattern, making the DSR and consent tRPC procedures available to consuming apps without manual wiring.
|
||||
|
||||
## Why
|
||||
|
||||
The two optional-core routers exist after Stories 04 and 06 but are unreachable until `core-api` composes them. The `<gen:*>` anchor means no hand-wired boilerplate — just adding the imports and router entries. Completing this story closes the API surface loop.
|
||||
|
||||
## Done when
|
||||
|
||||
- `core-api`'s `appRouter` includes `dsr: dsrRouter` and `consent: consentRouter`.
|
||||
- Import and composition follow the existing `<gen:*>` anchor pattern.
|
||||
- tRPC integration tests assert `trpc.dsr.export`, `trpc.dsr.delete`, `trpc.dsr.rectify`, `trpc.dsr.restrict`, `trpc.consent.grant`, `trpc.consent.withdraw`, `trpc.consent.isGranted`, `trpc.consent.getCategories` all resolve (auth + response shape checks).
|
||||
- `pnpm typecheck && pnpm lint && pnpm test && pnpm conformance && pnpm fallow:audit && pnpm coverage:diff` all pass.
|
||||
|
||||
## In scope
|
||||
|
||||
- `core-api/src/router.ts` (or equivalent) — add `dsr: dsrRouter` + `consent: consentRouter` entries.
|
||||
- Import of `dsrRouter` from `@repo/core-dsr` and `consentRouter` from `@repo/core-consent`.
|
||||
- tRPC integration tests for all eight new procedures.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- HTTP endpoint scaffolding — tRPC only per established pattern; REST wrapping documented for regulators in Epic D.
|
||||
- Per-framework router auto-wiring for cookie banner pageview reset — banner emits `onConsentChange`; consumer wires their router.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] Import `dsrRouter` from `@repo/core-dsr` and `consentRouter` from `@repo/core-consent` and compose them into `core-api`'s `appRouter` via the `<gen:*>` anchor pattern; add tRPC integration tests asserting all eight procedures resolve with correct auth and response shapes; all gates pass.
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
id: 08-core-ui-scaffold
|
||||
epic: dsr-consent-and-cookie-banner
|
||||
title: core-ui scaffold
|
||||
type: technical-story
|
||||
status: done
|
||||
feature: core-ui
|
||||
depends-on: []
|
||||
blocks: [09-cookie-consent-banner]
|
||||
created: 2026-05-19T12:00:00Z
|
||||
updated: 2026-05-19T21:03:59.344Z
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Scaffold `@repo/core-ui` via the generator, producing a green package shell that Story 09 will populate with the `<CookieConsentBanner>` component.
|
||||
|
||||
## Why
|
||||
|
||||
The PRD notes that `core-ui`'s directory exists but is empty. Running the generator is the required first step before any component work can land — it wires the package into the Turborepo graph, establishes the package.json and tsconfig.json, and creates the Storybook entry point. Story 09 cannot land without a valid package shell.
|
||||
|
||||
## Done when
|
||||
|
||||
- `packages/core-ui/` is a valid Turborepo package with `package.json`, `tsconfig.json`, `vitest.config.ts`, and an empty `src/index.ts` barrel.
|
||||
- The package appears in `pnpm turbo boundaries` output.
|
||||
- `pnpm typecheck && pnpm lint && pnpm test && pnpm conformance && pnpm fallow:audit && pnpm coverage:diff` all pass.
|
||||
|
||||
## In scope
|
||||
|
||||
- `pnpm turbo gen core-package ui` invocation and any post-scaffold fixes required to make the shell green.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- `<CookieConsentBanner>` component (Story 09).
|
||||
- Any UI component other than the banner — this epic ships only what the PRD requires.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] Run `pnpm turbo gen core-package ui` and apply any post-scaffold fixes needed to produce a green package shell (`pnpm typecheck && pnpm lint && pnpm test && pnpm conformance && pnpm fallow:audit && pnpm coverage:diff` pass).
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
id: 09-cookie-consent-banner
|
||||
epic: dsr-consent-and-cookie-banner
|
||||
title: CookieConsentBanner component in core-ui
|
||||
type: user-story
|
||||
status: done
|
||||
feature: core-ui
|
||||
depends-on: [05-core-consent-react, 08-core-ui-scaffold]
|
||||
blocks: [11-documentation]
|
||||
created: 2026-05-19T12:00:00Z
|
||||
updated: 2026-05-19T21:38:48.887Z
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Ship `<CookieConsentBanner>` in `@repo/core-ui` — a headless component with a EU-compliant default UI (equal-prominence Reject All / Accept All), granular category toggles, render-prop overrides for branding, `__consent_state` cookie management for the anonymous pre-signup flow, and `useConsent()` integration for authenticated state.
|
||||
|
||||
## Why
|
||||
|
||||
Downstream consumers need a drop-in cookie consent banner that satisfies CNIL guidance and EDPB Art. 7 interpretation out of the box (equal visual weight for Reject All / Accept All, no pre-ticked boxes, equal tab order). Building the component as a headless default with render-prop overrides lets consumers brand the visuals without forking the compliance logic. The Storybook story doubles as the human-readable compliance review surface.
|
||||
|
||||
## Done when
|
||||
|
||||
- `<CookieConsentBanner variant="modal">` renders with Reject All / Accept All as equal-size, equal-weight side-by-side buttons; tab order treats them equally; ARIA labels mirror; focus-trapped inside modal; ESC = "Reject All" (explicit legal choice, not silent dismiss).
|
||||
- `<CookieConsentBanner variant="banner">` renders fixed to the bottom of the viewport, full-width.
|
||||
- Default categories: `essential` (always enabled, non-toggleable), `functional`, `analytics`, `marketing`.
|
||||
- Render-prop overrides: `renderHeader`, `renderCategoryRow`, `renderActions` — default UI works out-of-box; consumer surgically overrides.
|
||||
- `__consent_state` cookie: SameSite=Lax, Secure, 1-year max-age, versioned with `_v: 1`; component reads/writes/clears.
|
||||
- `onConsentChange` callback fires with updated `UserConsentState`.
|
||||
- When `<ConsentProvider>` is present (authenticated context), the banner reads and writes via `useConsent()` from `@repo/core-consent/react`; when absent, the banner manages state via the cookie only (anonymous flow).
|
||||
- Storybook story covers: modal variant, banner variant, render-prop override example, a11y tab-order demo.
|
||||
- axe-core a11y test in Storybook passes (WCAG 2.2 AA color contrast, no violations).
|
||||
- RTL behavioral tests assert: click "Reject All" → `onConsentChange` fires with all non-essential categories `granted: false`; toggle analytics → click "Save Selected" → `onConsentChange` fires with `analytics.granted: true`; ESC in modal → Reject All semantics; tab order visits Reject All before Accept All.
|
||||
- Modal focus-trap test: focus does not escape the modal while it is open.
|
||||
- `pnpm typecheck && pnpm lint && pnpm test && pnpm conformance && pnpm fallow:audit && pnpm coverage:diff` all pass.
|
||||
|
||||
## In scope
|
||||
|
||||
- `packages/core-ui/src/cookie-consent-banner/` — component, types, cookie helpers.
|
||||
- `__consent_state` cookie read/write/clear (versioned `_v: 1` shape; migrate older versions on read).
|
||||
- Storybook story in `packages/core-ui/src/cookie-consent-banner/cookie-consent-banner.stories.tsx`.
|
||||
- axe-core a11y integration in Storybook + RTL behavioral tests.
|
||||
- Export from `packages/core-ui/src/index.ts` barrel.
|
||||
- SSR-safe pattern: component is client-only; ship a `<CookieConsentBannerLoader>` SSR placeholder that dynamic-imports the actual banner client-side (see consent.md, Q3 of grill).
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Per-framework router auto-wiring on consent toggle — banner emits `onConsentChange`; consumer wires (e.g., re-initialize analytics SDK).
|
||||
- Cookie versioning migration policy documentation (Story 11).
|
||||
- Strict-mode `ConsentCategory` declaration merging — string-literal-union escape hatch is sufficient.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] Add `<CookieConsentBanner>` headless component in `packages/core-ui/src/cookie-consent-banner/` with `variant: "modal" | "banner"` prop, granular category toggles (essential non-toggleable), equal-prominence Reject All / Accept All buttons (CNIL compliance baked into default visual treatment), render-prop overrides (`renderHeader`, `renderCategoryRow`, `renderActions`), `__consent_state` cookie management (SameSite=Lax, Secure, 1-year, versioned `_v: 1`), `onConsentChange` callback, `useConsent()` integration for authenticated context, `<CookieConsentBannerLoader>` SSR-safe wrapper; Storybook story covering both variants + render-prop example; axe-core a11y pass; RTL behavioral tests (Reject All, Save Selected with toggle, ESC = Reject All, tab order, modal focus-trap); export from `core-ui` index; all gates pass.
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
id: 10-auth-signup-migration
|
||||
epic: dsr-consent-and-cookie-banner
|
||||
title: auth signUp anonymous consent migration
|
||||
type: technical-story
|
||||
status: done
|
||||
feature: auth
|
||||
depends-on: [04-core-consent-implementation]
|
||||
blocks: [11-documentation]
|
||||
created: 2026-05-19T12:00:00Z
|
||||
updated: 2026-05-19T21:56:58.992Z
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Extend the template's `auth.signUp` use case to call `extractAnonymousConsent` + `migrateAnonymousConsent` when a `__consent_state` cookie is present, so a user's pre-signup banner choices persist into their account automatically.
|
||||
|
||||
## Why
|
||||
|
||||
Without this integration, anonymous users who consented via the banner before signing up would lose their consent state at account creation — forcing them to re-consent or leaving analytics gated incorrectly. The migration call is the canonical example for downstream consumers implementing the same pattern in their own `signUp` use cases.
|
||||
|
||||
## Done when
|
||||
|
||||
- `auth.signUp` use case calls `extractAnonymousConsent(cookieHeader)` after user-record creation; if a `__consent_state` cookie is present, calls `migrateAnonymousConsent({ userId, cookieState, bannerVersion, policyVersion })`.
|
||||
- Response includes `Set-Cookie: __consent_state=; Max-Age=0` to clear the anonymous cookie after migration.
|
||||
- `auth.signUp.use-case.test.ts` covers: mock cookie header present → `migrateAnonymousConsent` called with correct args → audit entry has `method: "signup-migration"` → response cookie cleared; no cookie present → `migrateAnonymousConsent` not called.
|
||||
- `RecordingConsent` from `core-testing` is used for assertions (not a raw mock).
|
||||
- `pnpm typecheck && pnpm lint && pnpm test && pnpm conformance && pnpm fallow:audit && pnpm coverage:diff` all pass.
|
||||
|
||||
## In scope
|
||||
|
||||
- `packages/auth/src/use-cases/sign-up.use-case.ts` — add `extractAnonymousConsent` + `migrateAnonymousConsent` calls.
|
||||
- `packages/auth/src/use-cases/sign-up.use-case.test.ts` — extend with migration scenarios.
|
||||
- Auth feature's DI binders — inject `IConsent` dependency when present (guard with `?.` for consumers who haven't installed `core-consent`).
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Anonymous consent storage in `users.consentState` directly from the banner — anonymous state lives in the cookie until this migration.
|
||||
- Post-migration analytics re-initialization — consumer's responsibility via `onConsentChange` callback.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] Extend `auth.signUp` use case to call `extractAnonymousConsent(cookieHeader)` + `migrateAnonymousConsent({ userId, cookieState, bannerVersion, policyVersion })` when a `__consent_state` cookie is present, set `Set-Cookie: __consent_state=; Max-Age=0` in the response, and inject `IConsent` into the use case deps (optional, guarded with `?.`); extend `sign-up.use-case.test.ts` with `RecordingConsent` to assert migration call shape, audit entry `method: "signup-migration"`, and cookie-clear; all gates pass.
|
||||
@@ -0,0 +1,55 @@
|
||||
---
|
||||
id: 11-documentation
|
||||
epic: dsr-consent-and-cookie-banner
|
||||
title: Documentation — DSR guide, consent guide, glossary, CLAUDE.md
|
||||
type: technical-story
|
||||
status: done
|
||||
feature: core-shared
|
||||
depends-on:
|
||||
[
|
||||
07-core-api-router-composition,
|
||||
09-cookie-consent-banner,
|
||||
10-auth-signup-migration,
|
||||
]
|
||||
blocks: []
|
||||
created: 2026-05-19T12:00:00Z
|
||||
updated: 2026-05-19T22:09:30.310Z
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Write the consumer-facing documentation for DSR and consent, update the glossary with new terms, and update CLAUDE.md and conformance-quickref with the new manifest field and rule count.
|
||||
|
||||
## Why
|
||||
|
||||
Story 15 in the PRD: a DPO should be able to answer "what data do we hold + how does a subject act on it" by reading `compliance/data-map.yml` (Epic A) and the DSR endpoint mapping (this epic) without reading code. The documentation also serves AI agents scaffolding new features that need consent gates or DSR wiring.
|
||||
|
||||
## Done when
|
||||
|
||||
- `docs/guides/dsr.md` exists and covers: interfaces, tRPC procedure → GDPR article mapping, route wiring for consumers, multi-subject handling, soft vs cascade-hard delete semantics, `DeletionCertificate` format, Art. 15/16/17/18/20 compliance notes.
|
||||
- `docs/guides/consent.md` exists and covers: `requiresConsent` manifest field + brand + runtime check pattern, `IConsent.grant` + audit trail, anonymous → authenticated migration flow, cookie versioning policy (`_v` field, migration-on-read), SSR-safe banner loading pattern, CNIL/EDPB equal-prominence requirement.
|
||||
- `docs/compliance/subject-linkage.example.md` documents the `custom.subject` declaration pattern with a worked example of a multi-subject collection (e.g., a support ticket with submitter + assignee), providing the anchor for downstream consumers adding PII-holding collections.
|
||||
- `docs/glossary.md` gains entries for: `SubjectLink`, `DeletionCertificate`, `UserConsentState`, `ConsentChecked` (brand).
|
||||
- `CLAUDE.md` reflects: conformance rule count 11 → 12, new manifest field `requiresConsent: ConsentCategory[]` in the Key Conventions section, updated brand composition order (`withSpan → withCapture → withAudit → withAnalytics → withConsent → factory(deps)`).
|
||||
- `docs/guides/conformance-quickref.md` reflects the new rule + manifest field.
|
||||
- `pnpm typecheck && pnpm lint && pnpm test && pnpm conformance && pnpm fallow:audit && pnpm coverage:diff` all pass.
|
||||
|
||||
## In scope
|
||||
|
||||
- `docs/guides/dsr.md` (new file).
|
||||
- `docs/guides/consent.md` (new file).
|
||||
- `docs/compliance/subject-linkage.example.md` (new file).
|
||||
- `docs/glossary.md` — four new entries.
|
||||
- `CLAUDE.md` — rule count + manifest field + brand composition order.
|
||||
- `docs/guides/conformance-quickref.md` — rule + manifest field.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Pre-launch compliance checklist + fill-in templates (Epic D).
|
||||
- REST endpoint documentation (Epic D).
|
||||
- Cross-region transfer documentation / Schrems II / TIA (Epic D).
|
||||
- Per-framework router auto-wiring docs (out of scope per PRD).
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] Write `docs/guides/dsr.md` + `docs/guides/consent.md` + `docs/compliance/subject-linkage.example.md` + update `docs/glossary.md` with `SubjectLink`, `DeletionCertificate`, `UserConsentState`, `ConsentChecked` entries + update `CLAUDE.md` (rule count 11 → 12, `requiresConsent` manifest field, updated brand composition order) + update `docs/guides/conformance-quickref.md`; all gates pass.
|
||||
42
docs/work/epics/dsr-consent-and-cookie-banner/_epic.md
Normal file
42
docs/work/epics/dsr-consent-and-cookie-banner/_epic.md
Normal file
@@ -0,0 +1,42 @@
|
||||
---
|
||||
id: dsr-consent-and-cookie-banner
|
||||
prd: docs/work/prds/dsr-consent-and-cookie-banner.prd.md
|
||||
title: DSR + consent abstraction + cookie consent banner — Epic B of ADR-025
|
||||
type: epic
|
||||
status: done
|
||||
features:
|
||||
[
|
||||
core-shared,
|
||||
core-consent,
|
||||
core-dsr,
|
||||
core-ui,
|
||||
core-eslint,
|
||||
core-testing,
|
||||
core-api,
|
||||
auth,
|
||||
]
|
||||
created: 2026-05-19T12:00:00Z
|
||||
updated: 2026-05-19T22:09:30.310Z
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Ship the user-rights surface end-to-end: DSR endpoints that walk Epic A's PII tags to export/delete/rectify/restrict any subject's data, per-use-case consent gates with audit-logged proof, and a compliant cookie consent banner with EU-prominence defaults.
|
||||
|
||||
## Why
|
||||
|
||||
Epic A delivered declarative PII inventory + retention + sub-processors. Epic B closes the remaining gaps: GDPR Arts. 15–18 + 20 DSR endpoints, Art. 7 demonstrable consent with structural lint enforcement, and a CNIL-compliant cookie consent banner that downstream consumers can drop in without forking legal-compliance logic.
|
||||
|
||||
## Stories
|
||||
|
||||
- [x] [01 — Subject-linkage types in core-shared](01-subject-linkage-types/_story.md)
|
||||
- [x] [02 — Audit action enum amendment (ADR-018)](02-audit-enum-amendment/_story.md)
|
||||
- [x] [03 — core-consent foundation: types + brand + withConsent + conformance + ESLint rule](03-core-consent-foundation/_story.md)
|
||||
- [x] [04 — core-consent implementation: Payload impl + DI + migration helpers + tRPC router](04-core-consent-implementation/_story.md)
|
||||
- [x] [05 — core-consent React subpath: ConsentProvider + useConsent()](05-core-consent-react/_story.md)
|
||||
- [x] [06 — core-dsr: scaffold + interfaces + Payload impls + handlers + dsrRouter](06-core-dsr/_story.md)
|
||||
- [x] [07 — core-api router composition: dsrRouter + consentRouter into appRouter](07-core-api-router-composition/_story.md)
|
||||
- [x] [08 — core-ui scaffold](08-core-ui-scaffold/_story.md)
|
||||
- [x] [09 — CookieConsentBanner component in core-ui](09-cookie-consent-banner/_story.md)
|
||||
- [x] [10 — auth signUp anonymous consent migration](10-auth-signup-migration/_story.md)
|
||||
- [x] [11 — Documentation: DSR guide + consent guide + glossary + CLAUDE.md](11-documentation/_story.md)
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
id: 01-trace-schema-foundation
|
||||
epic: library-evaluation-policy
|
||||
title: Trace schema module + docs/library-decisions/ foundation
|
||||
type: technical-story
|
||||
status: done
|
||||
feature: scripts
|
||||
depends-on: []
|
||||
blocks:
|
||||
[
|
||||
02-pre-commit-check-script,
|
||||
04-evaluate-library-skill,
|
||||
07-generator-pre-shipped-traces,
|
||||
08-backfill-traces,
|
||||
]
|
||||
created: 2026-05-14T06:52:02+02:00
|
||||
updated: 2026-05-14T19:21:52.308Z
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Create the shared Zod-validated trace-schema module (`scripts/library-decisions/schema.mjs`) that every enforcement layer imports, and establish `docs/library-decisions/` with a `_template.md` schema reference that documents the required frontmatter + heading shape for all future traces.
|
||||
|
||||
## Why
|
||||
|
||||
All four enforcement layers (skill, pre-commit check, generator templates, sandcastle reviewer) need a single authoritative definition of what a valid library trace looks like. Without a shared module, each layer would re-implement the parse/validate logic independently and drift. The `_template.md` gives human contributors and agents a copy-pasteable starting point.
|
||||
|
||||
## Done when
|
||||
|
||||
- `scripts/library-decisions/schema.mjs` exists and exports: (1) a Zod schema validating the full trace frontmatter (all fields from ADR-022 §4 including nested `filter-results` object), (2) a `parseTrace(filePath)` function that reads + validates a `.md` file's frontmatter, (3) a `validateTrace(raw)` function for validating already-parsed objects.
|
||||
- `scripts/library-decisions/schema.test.mjs` covers: valid trace round-trips without error; missing required field throws; unknown filter key rejected; invalid enum value rejected; `accepted-cves` optional field accepted.
|
||||
- `docs/library-decisions/_template.md` exists with the complete frontmatter schema (all fields, all enums documented) and all required section headings (`## Filter: <name>` × 8 + `## Prompt: <name>` × 3) in the machine-checkable order from ADR-022.
|
||||
- `docs/library-decisions/` directory is committed (can be just `_template.md` + `.gitkeep` if no traces yet).
|
||||
- `pnpm typecheck && pnpm lint && pnpm test && pnpm conformance && pnpm fallow:audit && pnpm coverage:diff` all pass.
|
||||
|
||||
## In scope
|
||||
|
||||
- `scripts/library-decisions/schema.mjs` — Zod schema + parse/validate exports.
|
||||
- `scripts/library-decisions/schema.test.mjs` — unit tests (vitest or node:test; match the pattern used by `scripts/work/` tests).
|
||||
- `docs/library-decisions/_template.md` — schema reference document.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- The pre-commit check script (`check.mjs`) — Story 02.
|
||||
- The skill itself — Story 04.
|
||||
- Actual trace files (backfill) — Story 08.
|
||||
- Generator template changes — Story 07.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] Create `scripts/library-decisions/schema.mjs` with Zod frontmatter schema (all fields from ADR-022 §4: `package`, `version`, `tier`, `decision`, `date`, `deciders`, `adr`, `filter-results` nested object with all 8 filter keys and their enum values, `verification-commands`, `accepted-cves` optional), plus `parseTrace(filePath)` and `validateTrace(raw)` exports; write `schema.test.mjs` covering valid round-trip, missing-field rejection, invalid-enum rejection, and optional `accepted-cves`; create `docs/library-decisions/_template.md` with full frontmatter schema + all 11 required headings in ADR-022 order; all gates pass on this single commit.
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
id: 02-pre-commit-check-script
|
||||
epic: library-evaluation-policy
|
||||
title: Pre-commit check script for library trace presence
|
||||
type: technical-story
|
||||
status: done
|
||||
feature: scripts
|
||||
depends-on: [01-trace-schema-foundation]
|
||||
blocks: [06-sandcastle-reviewer-prompt]
|
||||
created: 2026-05-14T06:52:02+02:00
|
||||
updated: 2026-05-14T19:21:52.308Z
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Write `scripts/library-decisions/check.mjs` — the script that walks staged `package.json` diffs, derives the tier of each affected package, and fails the commit when a new runtime dependency in a feature- or core-tier package has no sibling approved trace staged. Wire it into `.husky/pre-commit` as step 4.
|
||||
|
||||
## Why
|
||||
|
||||
Human and agent reviewers cannot reliably check trace presence during code review. The pre-commit hook is the last mechanical gate before a dep reaches the repo; it runs unconditionally, composes with `--no-verify` protection already in the bash-guard hook, and gives the committer immediate actionable feedback.
|
||||
|
||||
## Done when
|
||||
|
||||
- `scripts/library-decisions/check.mjs` exists and: (1) reads `git diff --cached --name-only -- '**/package.json'`; (2) for each staged `package.json`, derives tier from path (`apps/*` → app, `packages/core-*` → core, `packages/*` → feature); (3) for each newly added line in `dependencies` (not `devDependencies` / `peerDependencies`), checks that `docs/library-decisions/*-<name>.md` is also staged with `decision: approved`; (4) exits 1 with a per-package error report + pointer to the skill when any check fails; (5) app-tier and devdep additions exit 0 silently.
|
||||
- `.husky/pre-commit` invokes `node scripts/library-decisions/check.mjs` after the existing state-sync guard.
|
||||
- `scripts/library-decisions/check.test.mjs` covers (using a temp git repo fixture): new feature-tier dep without trace → exit 1; new feature-tier dep with approved trace staged → exit 0; new feature-tier dep with rejected-decision trace staged → exit 1; new app-tier dep → exit 0; new devdep → exit 0; multi-file diff with mixed pass/fail → exit 1 with per-package report; `peerDependencies`-only change → exit 0.
|
||||
- `pnpm typecheck && pnpm lint && pnpm test && pnpm conformance && pnpm fallow:audit && pnpm coverage:diff` all pass.
|
||||
|
||||
## In scope
|
||||
|
||||
- `scripts/library-decisions/check.mjs` — the check script (imports `schema.mjs` from Story 01 for trace validation).
|
||||
- `scripts/library-decisions/check.test.mjs` — integration tests using a temp git repo fixture (mirror pattern from `scripts/work/state-sync-guard.mjs` tests).
|
||||
- `.husky/pre-commit` — one added line.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Sandcastle reviewer prompt integration — Story 06.
|
||||
- `--staged-against <base>` flag for CI/sandcastle use — added in Story 06 when the reviewer prompt is written.
|
||||
- `pnpm libs check` ergonomic wrapper — deferred per PRD.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] Write `scripts/library-decisions/check.mjs` (imports schema from Story 01; parses `git diff --cached` output; tier derivation from path; staged-trace presence + `decision: approved` check; exit-1 report with skill pointer); wire into `.husky/pre-commit`; write `check.test.mjs` integration tests with temp git repo fixture covering all 7 cases from Done when; all gates pass on this single commit.
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
id: 03-claude-hooks
|
||||
epic: library-evaluation-policy
|
||||
title: Claude PreToolUse / PostToolUse hooks for library-policy nudge
|
||||
type: technical-story
|
||||
status: done
|
||||
feature: tooling
|
||||
depends-on: []
|
||||
blocks: []
|
||||
created: 2026-05-14T06:52:02+02:00
|
||||
updated: 2026-05-14T19:21:52.308Z
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Write `.claude/hooks/library-policy-nudge.sh` — a single hook script that dispatches on `tool_use_type` to handle both `PreToolUse` (Bash invocations matching `pnpm add` / `pnpm i <pkg>`) and `PostToolUse` (Edit/Write on any `**/package.json`). On match, emit a non-blocking system-reminder pointing the agent at the `evaluate-library` skill with the exact invocation pattern. Register the hook in `.claude/settings.json` following the same pattern as `generator-first-nudge.sh`.
|
||||
|
||||
## Why
|
||||
|
||||
Agents (and developers) running `pnpm add` by reflex bypass the policy before the pre-commit gate fires. The PreToolUse hook injects the skill reminder _before_ the install runs — the cheapest possible intervention point. The PostToolUse hook catches the rarer case where an agent edits `package.json` directly without running the install command.
|
||||
|
||||
## Done when
|
||||
|
||||
- `.claude/hooks/library-policy-nudge.sh` exists, is executable, dispatches on `CLAUDE_TOOL_USE_TYPE` (or equivalent hook env var), and emits the reminder to stdout when a `pnpm add`/`pnpm i <pkg>` bash command is detected or when an Edit/Write tool targets a path matching `**/package.json`.
|
||||
- The hook is registered in `.claude/settings.json` (or `.claude/settings.local.json` if per-repo convention) under both `PreToolUse` and `PostToolUse` event types, matching the registration pattern of `generator-first-nudge.sh`.
|
||||
- The reminder text includes the literal string `/evaluate-library` and the argument template `<name> --tier <feature|core|app> --target <package-path>` so the agent sees the exact invocation.
|
||||
- Bash smoke tests (same style as any existing hook tests): pipe a mocked Claude hook payload `{ "tool_input": { "command": "pnpm add foo" } }` into the script and assert stdout contains the skill-reminder marker; pipe a payload for `pnpm add -D foo` (devdep) and assert no reminder emitted; pipe an Edit payload on `src/feature.manifest.ts` and assert no reminder emitted.
|
||||
- `pnpm lint && pnpm fallow:audit` pass.
|
||||
|
||||
## In scope
|
||||
|
||||
- `.claude/hooks/library-policy-nudge.sh` — the hook script.
|
||||
- `.claude/settings.json` (or equivalent) — hook registration lines.
|
||||
- Bash smoke tests for the hook script.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Hook auto-deny or blocking behavior — the hook is advisory only (non-blocking system-reminder).
|
||||
- Hooks for `npm install` / `yarn add` — the repo is pnpm-only.
|
||||
- Integration with the skill implementation (Story 04) — the hook emits a text reminder; the skill is a separate file.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] Investigate how `generator-first-nudge.sh` is registered (read `.claude/settings.json` and the existing hook script) to confirm the env-var names, payload shape, and registration keys; then write `.claude/hooks/library-policy-nudge.sh` + register it in the settings file + write bash smoke tests covering `pnpm add <pkg>` (reminder), `pnpm add -D <pkg>` (no reminder), Edit on non-package.json (no reminder), Edit on `package.json` (reminder); all gates pass on this single commit.
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
id: 04-evaluate-library-skill
|
||||
epic: library-evaluation-policy
|
||||
title: evaluate-library skill (SKILL.md + supporting files)
|
||||
type: technical-story
|
||||
status: done
|
||||
feature: tooling
|
||||
depends-on: [01-trace-schema-foundation]
|
||||
blocks: [05-human-guide]
|
||||
created: 2026-05-14T06:52:02+02:00
|
||||
updated: 2026-05-14T19:21:52.308Z
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Create the `.claude/skills/evaluate-library/` directory with `SKILL.md` (the authoritative agent runbook), `POLICY.md` (ADR-022 mirror for quick reference), `TRACE-TEMPLATE.md` (showing the YAML frontmatter + heading shape the skill must emit), and an `EXAMPLES/` directory with two worked cases (one approved, one rejected). The skill must be invocable as `/evaluate-library <name> --tier <feature|core|app> --target <package-path>`.
|
||||
|
||||
## Why
|
||||
|
||||
Without a deterministic skill runbook, every agent evaluating a library does so ad hoc — different filters, different order, inconsistent trace format. The skill is the single source of truth for the 8-filter + 3-prompt sequence, the collect-cheap-skip-expensive ordering, and the trace write step. It also provides the canonical invocation the Claude hook emits.
|
||||
|
||||
## Done when
|
||||
|
||||
- `.claude/skills/evaluate-library/SKILL.md` exists and covers: invocation signature (`/evaluate-library <name> --tier <tier> --target <pkg-path>`), the 8 filters in collect-cheap-skip-expensive order (license, types, shadow-check, boundary-fit run to completion; maintenance, CVE scan, EU residency, named-consumer short-circuit after first reject), the 3 prompts, trace-write step (unconditional at evaluation end, including rejections), and the fail/skip sentinel for skipped expensive filters.
|
||||
- `.claude/skills/evaluate-library/POLICY.md` summarises ADR-022 in ≤2 pages — the filters, the tier trigger, the trace schema fields, the four enforcement layers.
|
||||
- `.claude/skills/evaluate-library/TRACE-TEMPLATE.md` shows the complete YAML frontmatter (all fields, real sentinel values for skipped filters) + all 11 required headings in order.
|
||||
- `.claude/skills/evaluate-library/EXAMPLES/` contains at least two worked trace files: one `decision: approved` trace and one `decision: rejected` trace (use `trpc-to-openapi` as the rejected example per the PRD, with `named-consumer: fail` and prose citing the grill-session conversation as provenance).
|
||||
- The skill is listed in `.claude/settings.json` (or wherever skills are registered) so `/evaluate-library` resolves to the SKILL.md via the Skill tool.
|
||||
- `pnpm lint && pnpm fallow:audit` pass.
|
||||
|
||||
## In scope
|
||||
|
||||
- `.claude/skills/evaluate-library/SKILL.md`
|
||||
- `.claude/skills/evaluate-library/POLICY.md`
|
||||
- `.claude/skills/evaluate-library/TRACE-TEMPLATE.md`
|
||||
- `.claude/skills/evaluate-library/EXAMPLES/approved-example.md`
|
||||
- `.claude/skills/evaluate-library/EXAMPLES/rejected-trpc-to-openapi.md`
|
||||
- Skill registration in `.claude/settings.json` (match existing skill registration pattern).
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Automated tests for the skill (it is a prose runbook; correctness is verified by the success criterion in the PRD — running it against `trpc-to-openapi` produces the documented trace).
|
||||
- The schema module (Story 01) — already landed.
|
||||
- The human guide with worked examples for non-agent readers (Story 05).
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] Investigate the existing skill registration pattern (read `.claude/settings.json` and one existing skill's SKILL.md to confirm format and registration key); then write all five skill files (`SKILL.md`, `POLICY.md`, `TRACE-TEMPLATE.md`, `EXAMPLES/approved-example.md`, `EXAMPLES/rejected-trpc-to-openapi.md`) and register the skill; all gates pass on this single commit.
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
id: 05-human-guide
|
||||
epic: library-evaluation-policy
|
||||
title: Human reading-room guide — docs/guides/adding-a-library.md
|
||||
type: technical-story
|
||||
status: done
|
||||
feature: docs
|
||||
depends-on: [04-evaluate-library-skill]
|
||||
blocks: [09-claude-md-update]
|
||||
created: 2026-05-14T06:52:02+02:00
|
||||
updated: 2026-05-14T19:21:52.308Z
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Write `docs/guides/adding-a-library.md` — the human-readable guide explaining the library evaluation policy with worked examples (one approved, one rejected), the tier trigger, the four enforcement layers, and how to invoke the skill. The guide targets a maintainer reading the repo for the first time.
|
||||
|
||||
## Why
|
||||
|
||||
ADR-022 is the source of truth but is written for decision-record density, not onboarding. The guide translates the policy into a narrative that answers "why does this exist?" and "what do I actually do?" before a developer encounters the pre-commit gate for the first time. Worked examples anchor the abstract filter list to concrete outcomes.
|
||||
|
||||
## Done when
|
||||
|
||||
- `docs/guides/adding-a-library.md` exists with: (1) a "Why this exists" section explaining the uncodified-surface problem and the three signals from the PRD; (2) the tier trigger (feature/core packages require traces; app-tier does not; devdeps exempt); (3) the four enforcement layers (Claude hook → skill → pre-commit → sandcastle) in latency order; (4) step-by-step "how to add a library" walkthrough pointing at the `/evaluate-library` skill; (5) a worked approved example (brief — the full trace lives in `EXAMPLES/` from Story 04); (6) a worked rejected example (`trpc-to-openapi`, `named-consumer: fail`); (7) a link to ADR-022 and `docs/library-decisions/_template.md`.
|
||||
- `pnpm lint && pnpm fallow:audit` pass.
|
||||
|
||||
## In scope
|
||||
|
||||
- `docs/guides/adding-a-library.md` — the guide document.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- The skill itself (Story 04) — already landed.
|
||||
- CLAUDE.md update (Story 09) — that bullet points here and to ADR-022 but lands separately.
|
||||
- Changing any existing guide or ADR.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] Write `docs/guides/adding-a-library.md` with all seven sections from Done when (why, tier trigger, four layers, how-to walkthrough, worked approved + rejected examples, cross-links); all gates pass on this single commit.
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
id: 06-sandcastle-reviewer-prompt
|
||||
epic: library-evaluation-policy
|
||||
title: Sandcastle reviewer prompt — Library-trace check section
|
||||
type: technical-story
|
||||
status: done
|
||||
feature: tooling
|
||||
depends-on: [02-pre-commit-check-script]
|
||||
blocks: []
|
||||
created: 2026-05-14T06:52:02+02:00
|
||||
updated: 2026-05-14T19:21:52.308Z
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Append a "Library-trace check" section to `.sandcastle/reviewer.prompt.md` instructing the reviewer agent to run `node scripts/library-decisions/check.mjs --staged-against <base>` before issuing its verdict, and add the `--staged-against <base>` flag to `check.mjs` so it can compare against a given base ref rather than only the git index.
|
||||
|
||||
## Why
|
||||
|
||||
The sandcastle reviewer runs in a clean sandbox where `git diff --cached` may not reflect the full branch diff. The `--staged-against <base>` flag allows the reviewer to pass the PR's base branch as the comparison point, giving the same check a CI-compatible code path. Without this, the fourth enforcement layer is advisory only — it has no mechanical check to back it up.
|
||||
|
||||
## Done when
|
||||
|
||||
- `scripts/library-decisions/check.mjs` accepts a `--staged-against <base>` flag; when present, compares `git diff <base>...HEAD -- '**/package.json'` instead of `git diff --cached`.
|
||||
- `check.test.mjs` has a new test case: `--staged-against main` mode with a new feature-tier dep and no trace → exit 1.
|
||||
- `.sandcastle/reviewer.prompt.md` contains a "Library-trace check" section (appended, not replacing existing content) instructing the reviewer to run `node scripts/library-decisions/check.mjs --staged-against <base-branch>` and reject the slice if it exits non-zero.
|
||||
- `pnpm lint && pnpm test && pnpm fallow:audit && pnpm coverage:diff` all pass.
|
||||
|
||||
## In scope
|
||||
|
||||
- `scripts/library-decisions/check.mjs` — add `--staged-against` flag.
|
||||
- `scripts/library-decisions/check.test.mjs` — add test for the new flag.
|
||||
- `.sandcastle/reviewer.prompt.md` — append the Library-trace check section.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Changing the reviewer prompt's existing sections.
|
||||
- The pre-commit (index-mode) check behavior — already in Story 02.
|
||||
- CI integration (GitHub Actions) — deferred.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] Add `--staged-against <base>` flag to `check.mjs` (switches from `git diff --cached` to `git diff <base>...HEAD`); add a test covering `--staged-against` mode (temp git repo fixture, new feature-tier dep, no trace → exit 1); append "Library-trace check" section to `.sandcastle/reviewer.prompt.md` with the `node scripts/library-decisions/check.mjs --staged-against <base>` invocation and reject instruction; all gates pass on this single commit.
|
||||
@@ -0,0 +1,46 @@
|
||||
---
|
||||
id: 07-generator-pre-shipped-traces
|
||||
epic: library-evaluation-policy
|
||||
title: Generator templates — pre-shipped traces for optional core packages
|
||||
type: technical-story
|
||||
status: done
|
||||
feature: tooling
|
||||
depends-on: [01-trace-schema-foundation]
|
||||
blocks: []
|
||||
created: 2026-05-14T06:52:02+02:00
|
||||
updated: 2026-05-14T19:21:52.308Z
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Update the five optional-core generator templates (`events`, `realtime`, `audit`, `trpc`, `ui`) so that when `pnpm turbo gen core-package <name>` runs, it copies pre-written `decision: approved` library traces into `docs/library-decisions/` for every direct runtime dependency of that core package. Update the corresponding `__snapshots__` files so the snapshot tests cover the new trace files.
|
||||
|
||||
## Why
|
||||
|
||||
A developer who scaffolds an optional core via the generator should not immediately face a pre-commit failure for its bundled deps — the generator is the policy-compliant path, so the traces should land by construction. Without pre-shipped traces, the very act of using the generator would trigger the enforcement gate it's supposed to clear.
|
||||
|
||||
## Done when
|
||||
|
||||
- Each of the five generator templates (`turbo/generators/templates/core-package/{events,realtime,audit,trpc,ui}/`) contains a `docs/library-decisions/` subtree with one `.md` trace file per direct runtime dependency of that core, dated at generation time (use the template variable for date, or freeze to the scaffold date with a comment), `decision: approved`, and citing the relevant ADR (`events` → ADR-015, `realtime` → ADR-016, `audit` → ADR-018; `trpc` and `ui` cite their closest ADR or `null` if none).
|
||||
- The generator copies these files into the workspace when run.
|
||||
- The `turbo/generators/__snapshots__/core-package/<name>.snapshot.json` files are updated to include the new trace files; `pnpm turbo gen core-package events` (or any other optional core) passes the existing snapshot test.
|
||||
- `pnpm lint && pnpm fallow:audit` pass.
|
||||
|
||||
## In scope
|
||||
|
||||
- `turbo/generators/templates/core-package/events/` — pre-shipped trace(s).
|
||||
- `turbo/generators/templates/core-package/realtime/` — pre-shipped trace(s).
|
||||
- `turbo/generators/templates/core-package/audit/` — pre-shipped trace(s).
|
||||
- `turbo/generators/templates/core-package/trpc/` — pre-shipped trace(s).
|
||||
- `turbo/generators/templates/core-package/ui/` — pre-shipped trace(s).
|
||||
- Snapshot JSON updates for all five cores.
|
||||
- Generator copy logic (if not already handled by the template mechanism).
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Backfill traces for already-installed optional cores in the live workspace — Story 08.
|
||||
- Generator templates for non-core-package generators (feature, event, job, realtime, component) — no runtime deps are emitted by those generators.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] Inventory the direct runtime deps of each of the five optional core generator templates (read each template's `package.json`); write one approved trace file per dep in the correct generator template subtree with ADR citation; update the five snapshot JSON files to include the new trace file entries; verify `pnpm turbo gen core-package events` (or equivalent dry-run) matches the updated snapshot; all gates pass on this single commit.
|
||||
@@ -0,0 +1,50 @@
|
||||
---
|
||||
id: 08-backfill-traces
|
||||
epic: library-evaluation-policy
|
||||
title: Backfill library traces for existing feature- and core-tier runtime deps
|
||||
type: technical-story
|
||||
status: done
|
||||
feature: docs
|
||||
depends-on: [01-trace-schema-foundation]
|
||||
blocks: []
|
||||
created: 2026-05-14T06:52:02+02:00
|
||||
updated: 2026-05-14T19:21:52.308Z
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Write approved library trace files dated 2026-05-14 in `docs/library-decisions/` for every existing runtime dependency in feature- and core-tier packages, grouped by ADR provenance into four commits. No package.json is changed; these commits are pure trace-file additions.
|
||||
|
||||
## Why
|
||||
|
||||
Without backfill, every existing dep becomes a pre-commit-hook failure the first time someone touches a `package.json` — the enforcement gate would fire retroactively. Backfill establishes the baseline so the gate is additive (new deps require traces) rather than disruptive (old deps fail immediately). Grouping by ADR cluster makes the commit history readable and keeps each commit focused on a coherent rationale.
|
||||
|
||||
## Done when
|
||||
|
||||
- All runtime deps in `packages/` (feature- and core-tier) have a corresponding `docs/library-decisions/YYYY-MM-DD-<name>.md` with `decision: approved`, `date: 2026-05-14`, and the relevant `adr` citation (or `null` for un-cited deps).
|
||||
- Four commits land, one per cluster:
|
||||
- **ADR-002 cluster**: `inversify`, `reflect-metadata`.
|
||||
- **ADR-014 cluster**: `@sentry/node`, `@sentry/nextjs`, `@sentry/react`, and any other Sentry packages present.
|
||||
- **ADR-017 cluster**: `@opentelemetry/api`, `@opentelemetry/sdk-node`, and any other OTel packages present.
|
||||
- **Un-cited cluster**: `payload`, `@trpc/server`, `zod`, `superjson`, and any remaining runtime deps not covered by an ADR.
|
||||
- All trace files pass `validateTrace()` from `schema.mjs` (Story 01).
|
||||
- `pnpm lint && pnpm fallow:audit` pass after all four commits.
|
||||
|
||||
## In scope
|
||||
|
||||
- `docs/library-decisions/<date>-<name>.md` trace files — one per dep.
|
||||
- Four conventional commits: `chore(deps): backfill library traces for <cluster>`.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Deps in `apps/*` — app-tier is out of scope per the PRD.
|
||||
- devDeps in any tier — exempt from traces.
|
||||
- Changing any `package.json` — backfill is trace-only.
|
||||
- Optional core packages not yet installed in the workspace — covered by Story 07 (generator pre-shipped traces) when they are scaffolded.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] Inventory all runtime deps in `packages/` (run `jq '.dependencies // {} | keys' packages/*/package.json packages/core-*/package.json` or equivalent); write approved trace files for the ADR-002 cluster (`inversify`, `reflect-metadata`) in `docs/library-decisions/`; commit as `chore(deps): backfill library traces for ADR-002 cluster`.
|
||||
- [x] Write approved trace files for the ADR-014 cluster (Sentry packages); commit as `chore(deps): backfill library traces for ADR-014 cluster`.
|
||||
- [x] Write approved trace files for the ADR-017 cluster (OpenTelemetry packages); commit as `chore(deps): backfill library traces for ADR-017 cluster`.
|
||||
- [x] Write approved trace files for the un-cited cluster (`payload`, `@trpc/server`, `zod`, `superjson`, and any remaining runtime deps); commit as `chore(deps): backfill library traces for un-cited cluster`; all gates pass after this final commit.
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
id: 09-claude-md-update
|
||||
epic: library-evaluation-policy
|
||||
title: CLAUDE.md Key Conventions — library policy bullet
|
||||
type: technical-story
|
||||
status: done
|
||||
feature: docs
|
||||
depends-on: [05-human-guide]
|
||||
blocks: []
|
||||
created: 2026-05-14T06:52:02+02:00
|
||||
updated: 2026-05-14T19:21:52.308Z
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Add one bullet to the "Key Conventions" section of `CLAUDE.md` pointing agents and developers at ADR-022, the `evaluate-library` skill, and `docs/guides/adding-a-library.md`. No other changes to `CLAUDE.md`.
|
||||
|
||||
## Why
|
||||
|
||||
`CLAUDE.md` is the first file agents and developers read. Without a Key Conventions entry, the library evaluation policy is invisible to any agent starting a fresh session — it may run `pnpm add` without knowing the policy exists, and only hit the hook or pre-commit gate after the fact. The bullet closes the discoverability gap.
|
||||
|
||||
## Done when
|
||||
|
||||
- `CLAUDE.md` Key Conventions section contains a bullet: _"New runtime dependencies in feature- or core-tier packages require a library trace at `docs/library-decisions/<date>-<name>.md` produced by the `/evaluate-library` skill — see ADR-022 and `docs/guides/adding-a-library.md`."_ (exact wording may vary; substance must include ADR-022, the skill, and the guide path).
|
||||
- No other changes to `CLAUDE.md`.
|
||||
- `pnpm lint && pnpm fallow:audit` pass.
|
||||
|
||||
## In scope
|
||||
|
||||
- `CLAUDE.md` — one bullet in Key Conventions.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- `docs/glossary.md` — the glossary entries for **Library trace** and **Pre-shipped trace** landed during the 2026-05-14 grill session and are already present.
|
||||
- Any other documentation changes.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] Add the one-line library-policy bullet to CLAUDE.md Key Conventions (after confirming glossary entries are already present); all gates pass on this single commit.
|
||||
30
docs/work/epics/library-evaluation-policy/_epic.md
Normal file
30
docs/work/epics/library-evaluation-policy/_epic.md
Normal file
@@ -0,0 +1,30 @@
|
||||
---
|
||||
id: library-evaluation-policy
|
||||
prd: docs/work/prds/library-evaluation-policy.prd.md
|
||||
title: Library evaluation policy — skill, traces, enforcement stack
|
||||
type: epic
|
||||
status: done
|
||||
features: [scripts, tooling, docs]
|
||||
created: 2026-05-14T00:00:00Z
|
||||
updated: 2026-05-14T19:21:52.308Z
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Implement 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`. Rejection traces are first-class records. Codifies ADR-022.
|
||||
|
||||
## Why
|
||||
|
||||
The repo's narrow third-party surface is uncodified. New dependencies enter via `pnpm add` with no checkpoint. Three signals exposed the gap: a near-miss adding a build-time-only library, post-hoc ADR records (002/014/017), and a silent EU-data-residency risk from US-only SaaS defaults. The enforcement stack mirrors the 5-gate conformance pattern — same vocabulary, same agent feedback loop.
|
||||
|
||||
## Stories
|
||||
|
||||
- [x] [01 — Trace schema module + docs/library-decisions/ foundation](01-trace-schema-foundation/_story.md)
|
||||
- [x] [02 — Pre-commit check script](02-pre-commit-check-script/_story.md)
|
||||
- [x] [03 — Claude PreToolUse / PostToolUse hooks](03-claude-hooks/_story.md)
|
||||
- [x] [04 — evaluate-library skill](04-evaluate-library-skill/_story.md)
|
||||
- [x] [05 — Human guide: docs/guides/adding-a-library.md](05-human-guide/_story.md)
|
||||
- [x] [06 — Sandcastle reviewer prompt update](06-sandcastle-reviewer-prompt/_story.md)
|
||||
- [x] [07 — Generator pre-shipped traces for optional cores](07-generator-pre-shipped-traces/_story.md)
|
||||
- [x] [08 — Backfill traces for existing runtime deps](08-backfill-traces/_story.md)
|
||||
- [x] [09 — CLAUDE.md Key Conventions bullet](09-claude-md-update/_story.md)
|
||||
@@ -0,0 +1,52 @@
|
||||
---
|
||||
id: 01-scaffold-core-analytics-package
|
||||
epic: product-analytics-channel
|
||||
title: Scaffold @repo/core-analytics package with IAnalytics and NoopAnalytics
|
||||
type: technical-story
|
||||
status: done
|
||||
feature: core-analytics
|
||||
depends-on: []
|
||||
blocks:
|
||||
[
|
||||
02-recording-analytics,
|
||||
03-analyzed-brand-and-with-analytics-wrapper,
|
||||
06-analytics-protocol-bind-context,
|
||||
08-react-provider,
|
||||
]
|
||||
created: 2026-05-18T12:00:00Z
|
||||
updated: 2026-05-18T11:33:06.880Z
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Produce a green `@repo/core-analytics` package — generator-scaffolded skeleton, then fleshed out with `IAnalytics` interface (`track`, `identify`, `pageView`, `flush`), `NoopAnalytics` default implementation, supporting types (`AnalyticsAttributeValue`, `AnalyticsUser`), and full method-level test coverage. The package is vendor-neutral: no third-party analytics SDK is bundled.
|
||||
|
||||
## Why
|
||||
|
||||
All downstream stories depend on `IAnalytics` existing as the contract surface. Landing this first means every subsequent story compiles and imports from a real package rather than a placeholder.
|
||||
|
||||
## Done when
|
||||
|
||||
- `packages/core-analytics/` exists as a workspace package `@repo/core-analytics`.
|
||||
- `IAnalytics`, `AnalyticsAttributeValue`, `AnalyticsUser` are exported from the package root.
|
||||
- `NoopAnalytics` implements `IAnalytics`; `flush()` resolves with a microtask (`Promise.resolve()`).
|
||||
- Sibling tests cover all four interface methods on `NoopAnalytics`.
|
||||
- `pnpm typecheck && pnpm lint && pnpm test && pnpm conformance && pnpm fallow:audit && pnpm coverage:diff` all pass.
|
||||
|
||||
## In scope
|
||||
|
||||
- `packages/core-analytics/` — package scaffold, `IAnalytics` interface, `AnalyticsAttributeValue` + `AnalyticsUser` types, `NoopAnalytics` impl, sibling tests, root barrel export.
|
||||
- No subpath exports in this story (`./react` lands in Story 08).
|
||||
- No `withAnalytics` wrapper in this story (Story 03).
|
||||
|
||||
## Out of scope
|
||||
|
||||
- `RecordingAnalytics` (Story 02).
|
||||
- `Analyzed` brand / `withAnalytics` wrapper (Story 03).
|
||||
- React provider (Story 08).
|
||||
- Backend vendor integration — template ships Noop only.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] Run `pnpm turbo gen core-package analytics` to scaffold `@repo/core-analytics` — verify the generator output compiles and all gates pass on this commit alone.
|
||||
- [x] Replace generator placeholder content with `IAnalytics` interface (`track`, `identify`, `pageView`, `flush`), `AnalyticsAttributeValue` + `AnalyticsUser` types, and `NoopAnalytics` implementation (`flush()` returns `Promise.resolve()`); add sibling tests covering all four methods; export everything from the package root barrel — all gates pass on this commit.
|
||||
@@ -0,0 +1,42 @@
|
||||
---
|
||||
id: 02-recording-analytics
|
||||
epic: product-analytics-channel
|
||||
title: Add RecordingAnalytics to @repo/core-testing
|
||||
type: technical-story
|
||||
status: done
|
||||
feature: core-testing
|
||||
depends-on: [01-scaffold-core-analytics-package]
|
||||
blocks: [08-react-provider]
|
||||
created: 2026-05-18T12:01:00Z
|
||||
updated: 2026-05-18T11:38:17.680Z
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Add `RecordingAnalytics` to `@repo/core-testing` as an in-memory test double that implements `IAnalytics`, parallel to `RecordingAuditLog`. Consumers use it in unit tests and the React provider test.
|
||||
|
||||
## Why
|
||||
|
||||
Every story that needs to assert analytics behaviour (including the React provider test in Story 08) requires a deterministic implementation. `RecordingAnalytics` provides the same recording double pattern already established by `RecordingAuditLog`.
|
||||
|
||||
## Done when
|
||||
|
||||
- `RecordingAnalytics` implements `IAnalytics` and records calls to `tracked`, `identified`, `pageViewed` arrays.
|
||||
- `flush()` resolves with a microtask and clears or drains the in-memory buffer.
|
||||
- Exported from `@repo/core-testing`'s barrel.
|
||||
- Sibling test covers all four methods and the flush drain behaviour.
|
||||
- All gates pass.
|
||||
|
||||
## In scope
|
||||
|
||||
- `packages/core-testing/src/recording-analytics.ts` + sibling test.
|
||||
- Export line in `packages/core-testing/src/index.ts`.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- React provider wiring (Story 08).
|
||||
- Any vendor-specific recording behaviour.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] Add `RecordingAnalytics` implementing `IAnalytics` to `@repo/core-testing` — recorded arrays for `track`, `identify`, `pageView`; `flush()` returns `Promise.resolve()`; sibling test covers all methods and flush; export added to barrel — all gates pass on this commit.
|
||||
@@ -0,0 +1,46 @@
|
||||
---
|
||||
id: 03-analyzed-brand-and-with-analytics-wrapper
|
||||
epic: product-analytics-channel
|
||||
title: Add Analyzed brand and withAnalytics wrapper
|
||||
type: technical-story
|
||||
status: done
|
||||
feature: core-shared
|
||||
depends-on: [01-scaffold-core-analytics-package]
|
||||
blocks: [04-manifest-schema-and-wire-use-case]
|
||||
created: 2026-05-18T12:02:00Z
|
||||
updated: 2026-05-18T11:55:08.463Z
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Add the `Analyzed<F>` brand to `core-shared/conformance/` and the `withAnalytics(analytics, factory)` higher-order wrapper to `@repo/core-analytics`. Together they enable brand-based conformance detection at DI bind time, mirroring the `Audited` / `withAudit` pattern.
|
||||
|
||||
## Why
|
||||
|
||||
`wireUseCase` (Story 04) and `assertFeatureConformance` (Story 05) both key off the `Analyzed` brand at runtime. The brand must exist in `core-shared` (so the assertion layer can import it without a circular dep) while the wrapper lives in `core-analytics` (analytics is optional; can't pollute core-shared with the implementation).
|
||||
|
||||
## Done when
|
||||
|
||||
- `Analyzed<F> = F & { readonly __analyzed: true }` exists in `packages/core-shared/src/conformance/brands.ts` and `isAnalyzed` guard exists in `packages/core-shared/src/conformance/brand-runtime.ts`.
|
||||
- `withAnalytics(analytics, factory)` lives in `packages/core-analytics/src/with-analytics.ts`, attaches `Analyzed` via `attachBrand` from `core-shared/conformance/brand-runtime`, and is exported from `@repo/core-analytics`.
|
||||
- `with-analytics.test.ts` asserts brand is present after wrapping and absent before.
|
||||
- All gates pass on each commit independently.
|
||||
|
||||
## In scope
|
||||
|
||||
- `packages/core-shared/src/conformance/brands.ts` — `Analyzed<F>` type addition.
|
||||
- `packages/core-shared/src/conformance/brand-runtime.ts` — `isAnalyzed` guard.
|
||||
- `packages/core-core-shared/src/conformance/index.ts` — export additions.
|
||||
- `packages/core-analytics/src/with-analytics.ts` + sibling test.
|
||||
- `packages/core-analytics/src/index.ts` — export addition.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- `wireUseCase` composition (Story 04).
|
||||
- `assertFeatureConformance` extension (Story 05).
|
||||
- `analyticsEvents` manifest field (Story 04).
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] Add `Analyzed<F>` brand type to `packages/core-shared/src/conformance/brands.ts` and `isAnalyzed(f): f is Analyzed<F>` guard to `brand-runtime.ts`; export both from the conformance index — all gates pass on this commit.
|
||||
- [x] Add `withAnalytics(analytics, factory)` wrapper to `packages/core-analytics/src/with-analytics.ts` using `attachBrand` from `core-shared/conformance/brand-runtime` + `with-analytics.test.ts` asserting brand attached after wrapping and absent before; export from `@repo/core-analytics` root barrel — all gates pass on this commit.
|
||||
@@ -0,0 +1,50 @@
|
||||
---
|
||||
id: 04-manifest-schema-and-wire-use-case
|
||||
epic: product-analytics-channel
|
||||
title: Extend manifest schema and wireUseCase with analyticsEvents
|
||||
type: technical-story
|
||||
status: done
|
||||
feature: core-shared
|
||||
depends-on: [03-analyzed-brand-and-with-analytics-wrapper]
|
||||
blocks:
|
||||
[
|
||||
05-assert-feature-conformance-analyzed,
|
||||
07-eslint-rule-no-undeclared-analytics-event,
|
||||
]
|
||||
created: 2026-05-18T12:03:00Z
|
||||
updated: 2026-05-18T15:15:39.064Z
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Add `analyticsEvents: string[]` (default `[]`) to the per-use-case manifest schema in `define-feature.ts`, update the feature generator template to emit the field, and extend `wireUseCase` to compose `withAnalytics` when an `analytics` arg is provided and the use case declares at least one analytics event.
|
||||
|
||||
## Why
|
||||
|
||||
The manifest field is the declaration surface the ESLint rule (Story 07) and boot assertion (Story 05) both read from. `wireUseCase` is the composition site that attaches the `Analyzed` brand; it must compose analytics in the right position (`factory → withAnalytics → withAudit → withCapture → withSpan`).
|
||||
|
||||
## Done when
|
||||
|
||||
- `useCases.<name>.analyticsEvents: string[]` is valid in every feature manifest; existing manifests stay green with an absent or empty field.
|
||||
- The feature generator template emits `analyticsEvents: []` for each scaffolded use case.
|
||||
- `wireUseCase({ ..., analytics? })` composes `withAnalytics` when `analytics` is provided; existing callers without `analytics` are unaffected.
|
||||
- `wire-use-case.test.ts` covers the analytics path (brand present) and the no-analytics path (brand absent).
|
||||
- All gates pass on each commit independently.
|
||||
|
||||
## In scope
|
||||
|
||||
- `packages/core-shared/src/conformance/define-feature.ts` — schema extension.
|
||||
- Feature generator template (`turbo/generators/`) — `analyticsEvents: []` added to use-case scaffold output.
|
||||
- `packages/core-shared/src/conformance/wire-use-case.ts` — `analytics?` arg + `withAnalytics` composition.
|
||||
- `packages/core-shared/src/conformance/wire-use-case.test.ts` — analytics path coverage.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- `assertFeatureConformance` extension (Story 05).
|
||||
- ESLint rule (Story 07).
|
||||
- Migrating existing feature manifests (out of scope per PRD).
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] Add `analyticsEvents: string[]` (default `[]`) to the use-case schema in `define-feature.ts` and update the feature generator template to emit the field — existing manifests remain valid; all gates pass on this commit.
|
||||
- [x] Extend `wireUseCase` to accept optional `analytics` arg and compose `withAnalytics` (innermost, before `withAudit`) when provided + update `wire-use-case.test.ts` with analytics path (Analyzed brand present) and no-analytics path (Analyzed brand absent) — all gates pass on this commit.
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
id: 05-assert-feature-conformance-analyzed
|
||||
epic: product-analytics-channel
|
||||
title: Extend assertFeatureConformance to check Analyzed brand
|
||||
type: technical-story
|
||||
status: done
|
||||
feature: core-shared
|
||||
depends-on: [04-manifest-schema-and-wire-use-case]
|
||||
blocks:
|
||||
[
|
||||
06-analytics-protocol-bind-context,
|
||||
07-eslint-rule-no-undeclared-analytics-event,
|
||||
]
|
||||
created: 2026-05-18T12:04:00Z
|
||||
updated: 2026-05-18T15:36:52.400Z
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Extend `assertFeatureConformance` to reject a binding at boot time when the manifest's use case declares `analyticsEvents.length > 0` but the bound function does not carry the `Analyzed` brand. Error message names the use case and the missing brand.
|
||||
|
||||
## Why
|
||||
|
||||
This is the boot-time gate that prevents analytics events from being declared in the manifest but silently skipped at runtime. Mirrors the existing `Audited` check for the same guarantee at the same latency.
|
||||
|
||||
## Done when
|
||||
|
||||
- `assertFeatureConformance` throws `ConformanceError` naming the missing `Analyzed` brand when `analyticsEvents.length > 0` and the bound function is not `isAnalyzed`.
|
||||
- A synthetic conformance test (parallel to `assert-bindings.test.ts`) covers: passes when Analyzed present + events declared, throws with message naming `Analyzed` when events declared + brand missing, passes when events empty + brand absent.
|
||||
- All gates pass.
|
||||
|
||||
## In scope
|
||||
|
||||
- `packages/core-shared/src/conformance/assert-bindings.ts` — Analyzed check addition.
|
||||
- Conformance test file for the new assertion path.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- `BindContext.analytics` field (Story 06).
|
||||
- ESLint rule (Story 07).
|
||||
- Any template feature wiring analytics (out of scope per PRD).
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] Add `Analyzed` brand check to `assertFeatureConformance` when `manifest.useCases[name].analyticsEvents.length > 0` + synthetic conformance test asserting `ConformanceError` with message naming `Analyzed` for unwrapped bindings — all gates pass on this commit.
|
||||
@@ -0,0 +1,42 @@
|
||||
---
|
||||
id: 06-analytics-protocol-bind-context
|
||||
epic: product-analytics-channel
|
||||
title: Add AnalyticsProtocol and BindContext.analytics
|
||||
type: technical-story
|
||||
status: done
|
||||
feature: core-shared
|
||||
depends-on: [05-assert-feature-conformance-analyzed]
|
||||
blocks: [09-documentation]
|
||||
created: 2026-05-18T12:05:00Z
|
||||
updated: 2026-05-18T15:40:20.464Z
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Add `AnalyticsProtocol` to `core-shared/di/bind-protocols.ts` and an optional `analytics?: AnalyticsProtocol` field to `BindContext`. This is the DI seam that lets feature binders receive an analytics implementation at bind time without importing the concrete class.
|
||||
|
||||
## Why
|
||||
|
||||
Feature binders that need analytics accept `ctx.analytics` from the aggregator's `BindContext`. Without the protocol type and context field, there's no typed path for passing analytics through the DI layer. Mirrors how `IEventBus`, `IAuditLog`, and `IJobQueue` are threaded through `BindContext`.
|
||||
|
||||
## Done when
|
||||
|
||||
- `AnalyticsProtocol` structural type exists in `packages/core-shared/src/di/bind-protocols.ts` and is exported.
|
||||
- `BindContext` (and `BindProductionContext`) gain `analytics?: AnalyticsProtocol`.
|
||||
- Existing binder call-sites compile without changes (field is optional).
|
||||
- All gates pass.
|
||||
|
||||
## In scope
|
||||
|
||||
- `packages/core-shared/src/di/bind-protocols.ts` — `AnalyticsProtocol` type.
|
||||
- `packages/core-shared/src/di/bind-context.ts` — `analytics?:` field on both context shapes.
|
||||
- Exports from `@repo/core-shared/di` barrel.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Wiring analytics in any actual feature binder (no template feature adopts analytics per PRD).
|
||||
- `IAnalytics` class methods — `AnalyticsProtocol` is the structural type used for DI; it mirrors the `IAnalytics` shape but lives in `core-shared` to avoid a circular dependency.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] Add `AnalyticsProtocol` structural type to `packages/core-shared/src/di/bind-protocols.ts` and `analytics?: AnalyticsProtocol` to `BindContext` + `BindProductionContext`; export from the `@repo/core-shared/di` barrel — all gates pass on this commit.
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
id: 07-eslint-rule-no-undeclared-analytics-event
|
||||
epic: product-analytics-channel
|
||||
title: Add no-undeclared-analytics-event ESLint rule to @repo/core-eslint
|
||||
type: technical-story
|
||||
status: done
|
||||
feature: core-eslint
|
||||
depends-on: [05-assert-feature-conformance-analyzed]
|
||||
blocks: [09-documentation]
|
||||
created: 2026-05-18T12:06:00Z
|
||||
updated: 2026-05-18T15:44:55.963Z
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Add the `conformance/no-undeclared-analytics-event` rule to `@repo/core-eslint` at warn severity. The rule finds `analytics.track("X", ...)` string-literal calls in `*.use-case.ts` files and warns when `"X"` is not declared in the file's feature manifest under `analyticsEvents`. Mirrors `no-undeclared-audit` and `no-undeclared-event-publish`.
|
||||
|
||||
## Why
|
||||
|
||||
Boot-time conformance (Story 05) catches missing brands at process start, but it can't catch event-slug typos or new undeclared slugs introduced during development before the app is ever booted. The ESLint rule provides sub-second feedback in the editor and in CI, closing the latency gap.
|
||||
|
||||
## Done when
|
||||
|
||||
- `packages/core-eslint/rules/no-undeclared-analytics-event.js` exists and passes RuleTester fixtures.
|
||||
- Fixtures cover: declared slug → no warning, undeclared slug → warn, non-use-case file → no-op, manifest with no use cases → no-op.
|
||||
- `_manifest-ast.js` parses `analyticsEvents` arrays (extends existing AST helper).
|
||||
- Rule registered in `plugin.js` and `base.js` at `"warn"` severity.
|
||||
- `pnpm lint` runs the rule; all gates pass.
|
||||
|
||||
## In scope
|
||||
|
||||
- `packages/core-eslint/rules/no-undeclared-analytics-event.js` + RuleTester test file.
|
||||
- `packages/core-eslint/rules/_manifest-ast.js` — `analyticsEvents` parsing addition.
|
||||
- `packages/core-eslint/plugin.js` + `packages/core-eslint/base.js` — rule registration at warn.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Migrating existing use cases (no template feature declares analytics events per PRD).
|
||||
- Auto-fix — warn only, no `--fix` path in this slice.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] Add `no-undeclared-analytics-event.js` rule to `@repo/core-eslint` — rule implementation cross-checking `analytics.track("X", ...)` literal slug against manifest `analyticsEvents`, extend `_manifest-ast.js` to parse the field, RuleTester fixtures (declared passes, undeclared warns, non-use-case no-op, no-manifest no-op), register in `plugin.js` + `base.js` at `"warn"` — all gates pass on this commit.
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
id: 08-react-provider
|
||||
epic: product-analytics-channel
|
||||
title: Add React provider to @repo/core-analytics
|
||||
type: technical-story
|
||||
status: done
|
||||
feature: core-analytics
|
||||
depends-on: [02-recording-analytics]
|
||||
blocks: [09-documentation]
|
||||
created: 2026-05-18T12:07:00Z
|
||||
updated: 2026-05-18T15:56:54.277Z
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Add a `./react` subpath export to `@repo/core-analytics` containing `<AnalyticsProvider value={IAnalytics}>` and `useAnalytics(): IAnalytics`. `useAnalytics()` throws a clear error when called outside a provider. No auto-wired router events.
|
||||
|
||||
## Why
|
||||
|
||||
Server-side use cases receive analytics through `BindContext.analytics`. Client-side components need an equivalent contract surface so `analytics.track(...)` reads the same whether called from a use case or a React component. The provider bridges `IAnalytics` from DI land to React's context tree without coupling consumers to a specific vendor.
|
||||
|
||||
## Done when
|
||||
|
||||
- `@repo/core-analytics/react` subpath exports `AnalyticsProvider` and `useAnalytics`.
|
||||
- `useAnalytics()` throws a named error (`AnalyticsContextError` or similar) when called outside `<AnalyticsProvider>`.
|
||||
- React Testing Library test: render a child inside `<AnalyticsProvider value={recordingAnalytics}>`, child calls `useAnalytics().track("test.event")`, assert `recordingAnalytics.tracked` contains the event.
|
||||
- All gates pass.
|
||||
|
||||
## In scope
|
||||
|
||||
- `packages/core-analytics/src/react/analytics-provider.tsx` + `useAnalytics.ts` (or colocated).
|
||||
- `packages/core-analytics/src/react/index.ts` — subpath barrel.
|
||||
- `package.json` `exports` map: `"./react": "./dist/react/index.js"` (or equivalent for the build config).
|
||||
- React Testing Library test.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Auto-wiring Next App Router or TanStack Router route-change hooks (deferred per PRD).
|
||||
- SSR hydration concerns — provider is a pure context wrapper; consumers handle hydration.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] Add `AnalyticsProvider` + `useAnalytics()` to `packages/core-analytics/src/react/`, wire `./react` subpath export in `package.json`, write React Testing Library test using `RecordingAnalytics` asserting `track` flows through context — all gates pass on this commit.
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
id: 09-documentation
|
||||
epic: product-analytics-channel
|
||||
title: Documentation — analytics.md, conformance-quickref, CLAUDE.md, template-tiers
|
||||
type: technical-story
|
||||
status: done
|
||||
feature: docs
|
||||
depends-on:
|
||||
[
|
||||
06-analytics-protocol-bind-context,
|
||||
07-eslint-rule-no-undeclared-analytics-event,
|
||||
08-react-provider,
|
||||
]
|
||||
blocks: []
|
||||
created: 2026-05-18T12:08:00Z
|
||||
updated: 2026-05-18T16:05:30.519Z
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Write `docs/guides/analytics.md` and update `docs/guides/conformance-quickref.md`, `CLAUDE.md`, and `docs/architecture/template-tiers.md` to reflect the completed channel. All docs reference the final shipped shape.
|
||||
|
||||
## Why
|
||||
|
||||
Docs land last (per PRD sequencing) so they can reference the real API rather than draft shapes. The conformance-quickref and CLAUDE.md rule-count bump are load-bearing for agents that use those files as context pointers.
|
||||
|
||||
## Done when
|
||||
|
||||
- `docs/guides/analytics.md` exists and documents: server-side wiring path (`BindContext.analytics` → feature binder), client-side wiring path (`<AnalyticsProvider>` + `useAnalytics()`), consumer vendor evaluation step (ADR-022 `/evaluate-library` gate), and PII boundary deferral (ADR-024 §"PII boundary").
|
||||
- `docs/guides/conformance-quickref.md` rule table shows 7 ESLint rules (adds `no-undeclared-analytics-event`) and drift patterns include analytics-event sprawl.
|
||||
- `CLAUDE.md` rule count updated from 6 to 7 in the conformance ESLint rules list.
|
||||
- `docs/architecture/template-tiers.md` lists `core-analytics` in the optional-cores table.
|
||||
- All gates pass on each commit independently.
|
||||
|
||||
## In scope
|
||||
|
||||
- `docs/guides/analytics.md` — new guide.
|
||||
- `docs/guides/conformance-quickref.md` — rule table + drift patterns update.
|
||||
- `CLAUDE.md` — conformance rule count line update.
|
||||
- `docs/architecture/template-tiers.md` — optional-cores list update.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Glossary update (already done in the ADR-024 commit per PRD).
|
||||
- ADR-024 itself (already exists).
|
||||
- Storybook component or demo app (deferred per PRD).
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] Write `docs/guides/analytics.md` covering server-side wiring (`BindContext.analytics`), client-side wiring (`<AnalyticsProvider>` + `useAnalytics()`), vendor evaluation step (ADR-022), and PII boundary note — all gates pass on this commit.
|
||||
- [x] Update `docs/guides/conformance-quickref.md` (seventh rule + analytics-event drift pattern), `CLAUDE.md` (6 → 7 conformance ESLint rules), and `docs/architecture/template-tiers.md` (add `core-analytics` to optional-cores list) — all gates pass on this commit.
|
||||
30
docs/work/epics/product-analytics-channel/_epic.md
Normal file
30
docs/work/epics/product-analytics-channel/_epic.md
Normal file
@@ -0,0 +1,30 @@
|
||||
---
|
||||
id: product-analytics-channel
|
||||
prd: docs/work/prds/product-analytics-channel.prd.md
|
||||
title: Product analytics as a fourth capture channel (ADR-024 implementation)
|
||||
type: epic
|
||||
status: done
|
||||
features: [core-analytics, core-shared, core-testing, core-eslint]
|
||||
created: 2026-05-18T12:00:00Z
|
||||
updated: 2026-05-18T16:05:30.519Z
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Ship `@repo/core-analytics` as an optional core package that codifies product analytics as the fourth capture channel alongside `ITracer`, `ILogger`, and `IAuditLog`. Adds `IAnalytics` interface, `NoopAnalytics` + `RecordingAnalytics` implementations, `Analyzed` brand, `withAnalytics` wrapper, `analyticsEvents` manifest field, `assertFeatureConformance` extension, `AnalyticsProtocol` in `BindContext`, `no-undeclared-analytics-event` ESLint rule, and a React provider scaffold — all mirroring the audit channel shape with ADR-024's three deliberate divergences.
|
||||
|
||||
## Why
|
||||
|
||||
Consumers routinely bolt analytics SDKs on at the React component layer, bypassing manifests, brands, and the five-gate conformance system. Codifying analytics as a channel with the same structural shape as `core-audit` means the conformance gates extend to a fourth signal at zero new gate count.
|
||||
|
||||
## Stories
|
||||
|
||||
- [x] [01 — Scaffold @repo/core-analytics package](01-scaffold-core-analytics-package/_story.md)
|
||||
- [x] [02 — Add RecordingAnalytics to @repo/core-testing](02-recording-analytics/_story.md)
|
||||
- [x] [03 — Add Analyzed brand and withAnalytics wrapper](03-analyzed-brand-and-with-analytics-wrapper/_story.md)
|
||||
- [x] [04 — Extend manifest schema and wireUseCase with analyticsEvents](04-manifest-schema-and-wire-use-case/_story.md)
|
||||
- [x] [05 — Extend assertFeatureConformance to check Analyzed brand](05-assert-feature-conformance-analyzed/_story.md)
|
||||
- [x] [06 — Add AnalyticsProtocol and BindContext.analytics](06-analytics-protocol-bind-context/_story.md)
|
||||
- [x] [07 — Add no-undeclared-analytics-event ESLint rule](07-eslint-rule-no-undeclared-analytics-event/_story.md)
|
||||
- [x] [08 — Add React provider to @repo/core-analytics](08-react-provider/_story.md)
|
||||
- [x] [09 — Documentation](09-documentation/_story.md)
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
id: 01-rate-limit-type-primitives
|
||||
epic: security-headers-rate-limit-sbom
|
||||
title: Rate-limit type primitives and manifest field
|
||||
type: technical-story
|
||||
status: done
|
||||
feature: core-shared
|
||||
depends-on: []
|
||||
blocks:
|
||||
[
|
||||
02-rate-limit-implementations,
|
||||
03-no-undeclared-rate-limit-eslint-rule,
|
||||
04-with-rate-limit-wrapper-and-conformance,
|
||||
]
|
||||
created: 2026-05-20T00:00:00Z
|
||||
updated: 2026-05-20T08:24:33.292Z
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Add the foundational TypeScript types for the rate-limit primitive — `IRateLimit`, `RateLimitBudget`, `RateLimitDecision`, and the `RateLimited` brand — to `core-shared`, and extend the feature manifest schema with `rateLimit?: RateLimitBudget[]` so every subsequent story has a stable, schema-valid type surface to build on.
|
||||
|
||||
## Why
|
||||
|
||||
Manifest-first ordering requires types before implementations and lint rules. The `RateLimited` brand must exist in `core-shared/conformance/brands.ts` before `assertFeatureConformance` can enforce it; `RateLimitBudget` must be a valid manifest field before any feature can declare rate-limit gates; `IRateLimit` must be exported before any implementation or wrapper can reference it. All three land together because they are mutually referential and individually incomplete.
|
||||
|
||||
## Done when
|
||||
|
||||
- `packages/core-shared/src/rate-limit/rate-limit.interface.ts` exports `IRateLimit`, `RateLimitBudget`, `RateLimitDecision`.
|
||||
- `packages/core-shared/src/conformance/brands.ts` exports `RateLimited<F>` brand and `isRateLimited(fn): boolean` helper.
|
||||
- `UseCaseManifest` in `packages/core-shared/src/conformance/define-feature.ts` gains `rateLimit?: RateLimitBudget[]` (absent defaults to `[]`).
|
||||
- New types exported from the `core-shared` barrel.
|
||||
- `pnpm typecheck && pnpm lint && pnpm test && pnpm conformance && pnpm fallow:audit && pnpm coverage:diff` all pass.
|
||||
|
||||
## In scope
|
||||
|
||||
- `IRateLimit` interface: `consume(budgetName, key, weight?): Promise<RateLimitDecision>` and `reset(budgetName, key): Promise<void>`.
|
||||
- `RateLimitBudget` type: `{ name: string; window: string; budget: number }`.
|
||||
- `RateLimitDecision` type: `{ allowed: boolean; remaining: number; resetAt: Date }`.
|
||||
- `RateLimited<F>` brand in `conformance/brands.ts` following the `Captured`, `ConsentChecked` pattern.
|
||||
- `isRateLimited(fn): boolean` brand-check helper.
|
||||
- `rateLimit?: RateLimitBudget[]` field added to `UseCaseManifest` in `define-feature.ts`.
|
||||
- Barrel export from `core-shared`.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Implementations (`NoopRateLimit`, `InMemoryRateLimit`, `RecordingRateLimit`) — Story 02.
|
||||
- `withRateLimit` wrapper, `assertFeatureConformance` extension, `wireUseCase` extension — Story 04.
|
||||
- ESLint rule — Story 03.
|
||||
- Auth backfill — Story 05.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] Add `IRateLimit`, `RateLimitBudget`, `RateLimitDecision` types in `packages/core-shared/src/rate-limit/rate-limit.interface.ts`; add `RateLimited<F>` brand + `isRateLimited` helper in `packages/core-shared/src/conformance/brands.ts`; extend `UseCaseManifest` in `packages/core-shared/src/conformance/define-feature.ts` with `rateLimit?: RateLimitBudget[]`; export new types from the `core-shared` barrel; all gates pass.
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
id: 02-rate-limit-implementations
|
||||
epic: security-headers-rate-limit-sbom
|
||||
title: Rate-limit implementations — Noop, InMemory, Recording
|
||||
type: technical-story
|
||||
status: done
|
||||
feature: core-shared
|
||||
depends-on: [01-rate-limit-type-primitives]
|
||||
blocks:
|
||||
[
|
||||
04-with-rate-limit-wrapper-and-conformance,
|
||||
05-auth-signin-rate-limit-backfill,
|
||||
]
|
||||
created: 2026-05-20T00:00:00Z
|
||||
updated: 2026-05-20T08:38:25.271Z
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Provide three working `IRateLimit` implementations that cover dev, test, and production-delegate scenarios: `NoopRateLimit` (always-allow, zero overhead), `InMemoryRateLimit` (per-process fixed-window with check-at-read expiry), and `RecordingRateLimit` (test helper in `core-testing` that captures all calls for assertions).
|
||||
|
||||
## Why
|
||||
|
||||
The `withRateLimit` wrapper (Story 04) and the `auth.signIn` backfill (Story 05) both need concrete classes to wire. `NoopRateLimit` is also the required default in `BindContext` so apps without a wired rate-limit implementation boot safely. `RecordingRateLimit` is the testing primitive every future rate-limited use-case test will use; landing it here alongside the production impls keeps the testing toolkit coherent.
|
||||
|
||||
## Done when
|
||||
|
||||
- `packages/core-shared/src/rate-limit/noop-rate-limit.ts` exports `NoopRateLimit` with unit test: `consume` always resolves `{ allowed: true, remaining: Infinity, resetAt: epoch }`, `reset` is a no-op.
|
||||
- `packages/core-shared/src/rate-limit/in-memory-rate-limit.ts` exports `InMemoryRateLimit` using check-at-read fixed-window expiry (no `setTimeout`); unit test: per-bucket tracking, decrement on `consume`, reset at window boundary (synthetic clock), explicit `reset` call restores budget to declared value.
|
||||
- `packages/core-testing/src/rate-limit/recording-rate-limit.ts` exports `RecordingRateLimit` capturing `consume` + `reset` invocation arguments; sibling test verifies capture correctness.
|
||||
- `pnpm typecheck && pnpm lint && pnpm test && pnpm conformance && pnpm fallow:audit && pnpm coverage:diff` all pass after each task.
|
||||
|
||||
## In scope
|
||||
|
||||
- `NoopRateLimit` — always-allow impl, no state, no timers.
|
||||
- `InMemoryRateLimit` — `Map`-backed, per-bucket `{ count, resetAt }`, check-at-read expiry, clock injection via constructor arg for testability.
|
||||
- `RecordingRateLimit` — in `packages/core-testing`; exposes `consumeCalls` + `resetCalls` accessors for test assertions.
|
||||
- Barrel exports from `core-shared` and `core-testing`.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Redis-backed or any distributed impl — deferred, consumer wires via ADR-022 library evaluation.
|
||||
- Token-bucket algorithm — InMemoryRateLimit uses fixed-window for simplicity; documented as dev-only.
|
||||
- `withRateLimit` wrapper — Story 04.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] Implement `NoopRateLimit` in `packages/core-shared/src/rate-limit/noop-rate-limit.ts` with sibling unit test (consume always allowed, reset no-op); implement `InMemoryRateLimit` in `packages/core-shared/src/rate-limit/in-memory-rate-limit.ts` with sibling unit test (per-bucket tracking, check-at-read expiry via injected clock, explicit reset restores budget); export both from `core-shared` barrel; all gates pass.
|
||||
- [x] Implement `RecordingRateLimit` in `packages/core-testing/src/rate-limit/recording-rate-limit.ts` capturing `consume` + `reset` call arguments verbatim with `consumeCalls` + `resetCalls` accessors; sibling test verifies capture correctness; export from `core-testing` barrel; all gates pass.
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
id: 03-no-undeclared-rate-limit-eslint-rule
|
||||
epic: security-headers-rate-limit-sbom
|
||||
title: "`no-undeclared-rate-limit` ESLint rule"
|
||||
type: technical-story
|
||||
status: done
|
||||
feature: core-eslint
|
||||
depends-on: [01-rate-limit-type-primitives]
|
||||
blocks: [05-auth-signin-rate-limit-backfill]
|
||||
created: 2026-05-20T00:00:00Z
|
||||
updated: 2026-05-20T08:45:03.170Z
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Add the `no-undeclared-rate-limit` ESLint rule (warn severity) that catches `rateLimit.consume("X", _)` calls where `"X"` is not declared in the manifest's `rateLimit` array, and declared budget names that are never consumed in the use-case body — giving AI agents and developers lint-time enforcement of rate-limit drift.
|
||||
|
||||
## Why
|
||||
|
||||
Without a lint rule, an agent could add a `consume("foo", ...)` call that has no corresponding manifest declaration, or declare a budget that silently goes unused. The ESLint rule closes this gap at the same latency as the existing `no-undeclared-audit` and `no-undeclared-consent-check` rules, making the rate-limit channel structurally consistent with the rest of the conformance system.
|
||||
|
||||
## Done when
|
||||
|
||||
- `packages/core-eslint/rules/no-undeclared-rate-limit.js` is registered at warn severity in `plugin.js` + `base.js`.
|
||||
- `packages/core-eslint/rules/_manifest-ast.js` parser extracts the `rateLimit` field from a feature manifest.
|
||||
- RuleTester fixtures cover: matching `budgetName` in call and manifest (pass), `budgetName` in call absent from manifest (warn), declared budget never consumed in use-case body (warn), non-use-case file (no-op).
|
||||
- Prior-art shape mirrors `no-undeclared-audit.js` and `no-undeclared-consent-check.js`.
|
||||
- `pnpm typecheck && pnpm lint && pnpm test && pnpm conformance && pnpm fallow:audit && pnpm coverage:diff` all pass.
|
||||
|
||||
## In scope
|
||||
|
||||
- `no-undeclared-rate-limit.js` rule implementation.
|
||||
- `_manifest-ast.js` extension for `rateLimit` field extraction.
|
||||
- Rule registration in `plugin.js` + `base.js` at warn severity.
|
||||
- RuleTester fixtures (declared/undeclared/unused/non-use-case).
|
||||
|
||||
## Out of scope
|
||||
|
||||
- CLAUDE.md + `conformance-quickref.md` rule-count bump (12 → 13) — Story 11.
|
||||
- `withRateLimit` wrapper — Story 04.
|
||||
- Auth backfill applying the rule — Story 05.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] Add `no-undeclared-rate-limit` rule in `packages/core-eslint/rules/no-undeclared-rate-limit.js` (warn severity); extend `packages/core-eslint/rules/_manifest-ast.js` to extract the `rateLimit` field; register the rule in `plugin.js` + `base.js`; add RuleTester fixtures: declared budget + matching call (pass), undeclared budget name in call (warn), declared budget never consumed (warn), non-use-case file (no-op); all gates pass.
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
id: 04-with-rate-limit-wrapper-and-conformance
|
||||
epic: security-headers-rate-limit-sbom
|
||||
title: "`withRateLimit` wrapper and conformance extensions"
|
||||
type: technical-story
|
||||
status: done
|
||||
feature: core-shared
|
||||
depends-on: [01-rate-limit-type-primitives, 02-rate-limit-implementations]
|
||||
blocks: [05-auth-signin-rate-limit-backfill]
|
||||
created: 2026-05-20T00:00:00Z
|
||||
updated: 2026-05-20T09:05:31.838Z
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Ship the `withRateLimit` wrapper (attaches `RateLimited` brand at DI bind time), extend `wireUseCase` to compose it innermost when `rateLimit.length > 0`, extend `assertFeatureConformance` to require the brand when a manifest declares rate-limit budgets, and add `rateLimit?: IRateLimit` to `BindContext` defaulting to `NoopRateLimit` — completing the full conformance enforcement loop for rate-limit.
|
||||
|
||||
## Why
|
||||
|
||||
The brand enforcement loop must be complete before any feature can declare `rateLimit` in its manifest and claim conformance. A partial landing (e.g. wrapper without `assertFeatureConformance`, or `BindContext` without a default) leaves the conformance layer in an undefined state: either boot assertions silently skip the brand check, or apps fail to boot because `ctx.rateLimit` is undefined. All four changes land together as a single coherent slice.
|
||||
|
||||
## Done when
|
||||
|
||||
- `packages/core-shared/src/rate-limit/with-rate-limit.ts` exports `withRateLimit(rateLimit, factory)` attaching the `RateLimited` brand; unit tests assert brand is attached, factory passthrough is preserved, and wrapper composes correctly with other wrappers in the canonical order.
|
||||
- `wireUseCase` in `packages/core-shared/src/conformance/wire-use-case.ts` accepts optional `rateLimit?: IRateLimit` and composes `withRateLimit` innermost (after `withConsent`) when `manifest.rateLimit.length > 0`; existing `wireUseCase` tests remain green.
|
||||
- `assertFeatureConformance` in `packages/core-shared/src/conformance/assert-bindings.ts` requires the `RateLimited` brand when `manifest.useCases[name].rateLimit.length > 0`; synthetic fixture test asserts `ConformanceError` is thrown when the brand is absent.
|
||||
- `packages/core-shared/src/di/bind-context.ts` gains `rateLimit?: IRateLimit`; the app aggregator in `apps/web-next/src/server/bind-production.ts` (and equivalents) defaults to `new NoopRateLimit()` when the consumer does not wire a backend.
|
||||
- Canonical wrapper composition order confirmed: `withSpan → withCapture → withAudit → withAnalytics → withConsent → withRateLimit → factory(deps)`.
|
||||
- `pnpm typecheck && pnpm lint && pnpm test && pnpm conformance && pnpm fallow:audit && pnpm coverage:diff` all pass.
|
||||
|
||||
## In scope
|
||||
|
||||
- `withRateLimit(rateLimit, factory)` wrapper in `core-shared/rate-limit/with-rate-limit.ts`.
|
||||
- `wireUseCase` extension accepting `rateLimit?: IRateLimit` and composing `withRateLimit` innermost.
|
||||
- `assertFeatureConformance` extension: require `RateLimited` brand when `rateLimit.length > 0`.
|
||||
- Synthetic fixture test for the boot assertion failure case.
|
||||
- `BindContext.rateLimit?: IRateLimit` — app aggregators default to `new NoopRateLimit()`.
|
||||
- Mirror prior art: `with-capture.ts` / `with-consent.ts` wrapper pattern; `wire-use-case.ts` + `assert-bindings.ts` extension pattern.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- `NoopRateLimit` implementation — Story 02 (required as dependency).
|
||||
- Auth backfill — Story 05.
|
||||
- ESLint rule — Story 03.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] Implement `withRateLimit(rateLimit, factory)` in `packages/core-shared/src/rate-limit/with-rate-limit.ts` attaching the `RateLimited` brand; extend `wireUseCase` to accept optional `rateLimit?: IRateLimit` and compose `withRateLimit` innermost when `manifest.rateLimit.length > 0`; extend `assertFeatureConformance` to require `RateLimited` brand when `rateLimit.length > 0` with a synthetic fixture test asserting `ConformanceError` on absent brand; add `rateLimit?: IRateLimit` to `BindContext` and default it to `new NoopRateLimit()` in app aggregators; unit tests for wrapper brand attachment and factory passthrough; all gates pass.
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
id: 05-auth-signin-rate-limit-backfill
|
||||
epic: security-headers-rate-limit-sbom
|
||||
title: auth.signIn rate-limit backfill
|
||||
type: technical-story
|
||||
status: done
|
||||
feature: auth
|
||||
depends-on:
|
||||
[
|
||||
01-rate-limit-type-primitives,
|
||||
02-rate-limit-implementations,
|
||||
03-no-undeclared-rate-limit-eslint-rule,
|
||||
04-with-rate-limit-wrapper-and-conformance,
|
||||
]
|
||||
blocks: []
|
||||
created: 2026-05-20T00:00:00Z
|
||||
updated: 2026-05-20T09:27:20.152Z
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Apply the rate-limit primitive to `auth.signIn` as the canonical reference example that every downstream consumer will copy — manifest declaration, dual `consume` calls in the use-case body with `TooManyRequestsError` throws, binder wiring, and extended tests — demonstrating end-to-end credential-stuffing defence that passes lint and conformance.
|
||||
|
||||
## Why
|
||||
|
||||
`auth.signIn` is the highest-risk write path in the template. Without rate-limit gates, credential-stuffing and account-enumeration windows stay open until a real consumer notices their auth logs. Backfilling this use case with the complete pattern (manifest → use-case body → binders → tests) also validates that all four preceding stories form a coherent system: if any type, impl, ESLint rule, or wrapper is mis-shaped, this story's conformance gate will surface it.
|
||||
|
||||
## Done when
|
||||
|
||||
- `packages/auth/src/feature.manifest.ts` `signIn` entry declares `rateLimit: [{ name: "ip", window: "1m", budget: 5 }, { name: "account", window: "1h", budget: 10 }]`.
|
||||
- `signInUseCase` deps include `rateLimit: IRateLimit`; body calls `rateLimit.consume("ip", \`signIn:ip:${input.clientIp}\`)` and `rateLimit.consume("account", \`signIn:account:${input.email}\`)`, throwing `TooManyRequestsError`on`!allowed`.
|
||||
- `packages/auth/src/di/bind-production.ts` + `bind-dev-seed.ts` pass `ctx.rateLimit ?? new NoopRateLimit()` into signIn's `wireUseCase`.
|
||||
- Existing signIn unit tests extended: `RecordingRateLimit` asserts both `consume` calls captured; `InMemoryRateLimit` at budget 1 asserts second call throws `TooManyRequestsError`.
|
||||
- `no-undeclared-rate-limit` ESLint rule passes (no warnings) on both `"ip"` and `"account"` call sites.
|
||||
- `assertFeatureConformance` boot assertion passes (signIn is `RateLimited` branded).
|
||||
- `pnpm typecheck && pnpm lint && pnpm test && pnpm conformance && pnpm fallow:audit && pnpm coverage:diff` all pass.
|
||||
|
||||
## In scope
|
||||
|
||||
- `feature.manifest.ts` — `rateLimit` declaration for `signIn`.
|
||||
- `sign-in.use-case.ts` — `rateLimit: IRateLimit` in deps, dual `consume` + `TooManyRequestsError` throw.
|
||||
- `TooManyRequestsError` class (if not already present in `auth/entities/errors/`) — add alongside other auth errors.
|
||||
- `bind-production.ts` + `bind-dev-seed.ts` — wire `ctx.rateLimit ?? new NoopRateLimit()` into signIn.
|
||||
- Unit tests: `RecordingRateLimit` dual-consume assertion; `InMemoryRateLimit` budget-1 rejection.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- `auth.signUp` rate-limit — not declared in the PRD; consumer adds when needed.
|
||||
- Rate-limit for any other auth use case — beyond the canonical example scope.
|
||||
- Redis-backed wiring — consumer adds via ADR-022 library evaluation.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] Add `rateLimit: [{ name: "ip", window: "1m", budget: 5 }, { name: "account", window: "1h", budget: 10 }]` to the `signIn` entry in `packages/auth/src/feature.manifest.ts`; add `rateLimit: IRateLimit` to `signInUseCase` deps; add `TooManyRequestsError` to auth error types if absent; insert dual `rateLimit.consume` calls with `TooManyRequestsError` throws in the use-case body; update `bind-production.ts` + `bind-dev-seed.ts` to pass `ctx.rateLimit ?? new NoopRateLimit()` into signIn's `wireUseCase`; extend signIn unit tests with `RecordingRateLimit` dual-consume assertion and `InMemoryRateLimit` budget-1 rejection; all gates pass.
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
id: 06-security-headers-core-module
|
||||
epic: security-headers-rate-limit-sbom
|
||||
title: Security headers core module
|
||||
type: technical-story
|
||||
status: done
|
||||
feature: core-shared
|
||||
depends-on: []
|
||||
blocks: [07-security-header-adapters]
|
||||
created: 2026-05-20T00:00:00Z
|
||||
updated: 2026-05-20T09:35:46.924Z
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Add the framework-agnostic security headers module to `core-shared/security` — `SecurityHeadersConfig` types, `generateNonce()` cryptographic helper, and `buildSecurityHeaders()` pure builder that returns all six headers with mode-aware CSP and URL validation for allowlisted origins.
|
||||
|
||||
## Why
|
||||
|
||||
The per-framework adapters (Story 07) and app-wiring stories (08, 09) all depend on this pure builder. Extracting the builder into `core-shared` (must-have) ensures all three template apps and any future framework adapters share a single, tested implementation of the header set, without duplicating CSP string construction or nonce generation logic.
|
||||
|
||||
## Done when
|
||||
|
||||
- `packages/core-shared/src/security/security-types.ts` exports `SecurityHeadersConfig` and `CspMode`.
|
||||
- `packages/core-shared/src/security/nonce.ts` exports `generateNonce()` returning a cryptographically random base64-encoded 16-byte string.
|
||||
- `packages/core-shared/src/security/build-security-headers.ts` exports `buildSecurityHeaders(opts: SecurityHeadersConfig): Record<string, string>` emitting all six headers (HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy, Content-Security-Policy); prod CSP includes nonce in `script-src 'strict-dynamic' 'nonce-{NONCE}'`; dev CSP is permissive (`'unsafe-inline' 'unsafe-eval' ws: localhost:* 127.0.0.1:*`); `allowedConnectOrigins` entries validated via `URL` constructor, throwing `InvalidSecurityHeadersConfig` on malformed input.
|
||||
- Unit tests: expected header set per mode, nonce threading into CSP `script-src`, dev vs prod CSP shape, `allowedConnectOrigins` / `allowedImgOrigins` / `allowedFontOrigins` applied to correct CSP directives, URL validation error on malformed origin, `generateNonce` randomness (two calls differ).
|
||||
- Types exported from `core-shared` barrel.
|
||||
- `pnpm typecheck && pnpm lint && pnpm test && pnpm conformance && pnpm fallow:audit && pnpm coverage:diff` all pass.
|
||||
|
||||
## In scope
|
||||
|
||||
- `security-types.ts` — `SecurityHeadersConfig`, `CspMode` types.
|
||||
- `nonce.ts` — `generateNonce()` using `crypto.randomBytes(16).toString("base64")`.
|
||||
- `build-security-headers.ts` — pure builder, six headers, prod/dev CSP, `allowedConnectOrigins` / `allowedImgOrigins` / `allowedFontOrigins` applied to CSP directives, URL validation with `InvalidSecurityHeadersConfig` error.
|
||||
- Unit tests for all three files.
|
||||
- Barrel export from `core-shared`.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Framework-specific adapter subpaths (`core-shared/security/next`, `core-shared/security/tanstack`) — Story 07.
|
||||
- App middleware wiring — Stories 08 and 09.
|
||||
- CSP report-uri collector endpoint — deferred, documented in guide.
|
||||
- Storybook CSP — explicitly out of Epic C scope.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] Create `packages/core-shared/src/security/security-types.ts` with `SecurityHeadersConfig` + `CspMode` types; implement `packages/core-shared/src/security/nonce.ts` with `generateNonce()` using `crypto.randomBytes`; implement `packages/core-shared/src/security/build-security-headers.ts` emitting all six headers with prod/dev CSP mode, nonce threading into `script-src`, `allowedConnectOrigins` / `allowedImgOrigins` / `allowedFontOrigins` applied to CSP, URL validation throwing `InvalidSecurityHeadersConfig` on malformed origins; unit tests covering header set, CSP variants, nonce threading, allowlist CSP directives, URL validation error, nonce randomness; export from `core-shared` barrel; all gates pass.
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
id: 07-security-header-adapters
|
||||
epic: security-headers-rate-limit-sbom
|
||||
title: Per-framework security header adapters
|
||||
type: technical-story
|
||||
status: done
|
||||
feature: core-shared
|
||||
depends-on: [06-security-headers-core-module]
|
||||
blocks: [08-app-wiring-web-next, 09-app-wiring-web-tanstack-and-cms]
|
||||
created: 2026-05-20T00:00:00Z
|
||||
updated: 2026-05-20T09:58:23.659Z
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Ship the two framework-specific adapter subpaths — `core-shared/security/next` (Next.js middleware + `getNonce()` Server Component helper) and `core-shared/security/tanstack` (TanStack Start server middleware + request-context nonce extractor) — so the app-wiring stories can wire them end-to-end without touching the underlying header builder.
|
||||
|
||||
## Why
|
||||
|
||||
Adapters follow the `core-analytics/react` subpath pattern established in the codebase: framework-specific code lives in a subpath export so the core module remains importable without dragging in framework dependencies. Each adapter generates a per-request nonce, calls `buildSecurityHeaders`, sets all six headers on the response, and forwards the nonce via `x-nonce` for downstream Server Component access. Landing the adapters before app wiring keeps the integration commits thin.
|
||||
|
||||
## Done when
|
||||
|
||||
- `packages/core-shared/src/security/next/index.ts` exports a Next.js middleware function and `getNonce()` helper; adapter tests assert all six headers set on response, `x-nonce` present, `getNonce()` reads the value from `headers()`.
|
||||
- `packages/core-shared/src/security/tanstack/index.ts` exports a TanStack Start server middleware and a nonce extractor for server context; adapter tests assert equivalent header + nonce behaviour.
|
||||
- Both subpaths declared in `packages/core-shared/package.json` `exports` map.
|
||||
- `pnpm typecheck && pnpm lint && pnpm test && pnpm conformance && pnpm fallow:audit && pnpm coverage:diff` all pass after each task.
|
||||
|
||||
## In scope
|
||||
|
||||
- `core-shared/security/next` — middleware calling `generateNonce()` + `buildSecurityHeaders({ mode: process.env.NODE_ENV === "production" ? "production" : "development", nonce })`, setting headers on the response, writing nonce to `x-nonce` response header; `getNonce()` reading `x-nonce` from `headers()` for use in Server Components.
|
||||
- `core-shared/security/tanstack` — equivalent using TanStack Start's server middleware API; nonce extractor for TanStack Server context.
|
||||
- `package.json` subpath exports for both adapters.
|
||||
- Adapter unit tests for each.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- App middleware wiring, Sentry nonce init, layout nonce threading — Stories 08 and 09.
|
||||
- CMS adapter — `apps/cms` uses the framework-agnostic `buildSecurityHeaders` directly (Story 09).
|
||||
- Storybook CSP — explicitly out of Epic C scope.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] Implement `packages/core-shared/src/security/next/index.ts`: Next.js middleware calling `generateNonce()` + `buildSecurityHeaders`, setting all six headers and `x-nonce` on the response, plus `getNonce()` helper reading `x-nonce` from Next.js `headers()`; add subpath to `package.json` exports; adapter tests asserting all headers present, nonce in response headers, `getNonce()` reads it; all gates pass.
|
||||
- [x] Implement `packages/core-shared/src/security/tanstack/index.ts`: TanStack Start server middleware equivalent (generate nonce, set headers + `x-nonce`) plus request-context nonce extractor; add subpath to `package.json` exports; adapter tests asserting equivalent header + nonce behaviour; all gates pass.
|
||||
@@ -0,0 +1,47 @@
|
||||
---
|
||||
id: 08-app-wiring-web-next
|
||||
epic: security-headers-rate-limit-sbom
|
||||
title: "App wiring: web-next"
|
||||
type: technical-story
|
||||
status: done
|
||||
feature: web-next
|
||||
depends-on: [07-security-header-adapters]
|
||||
blocks: []
|
||||
created: 2026-05-20T00:00:00Z
|
||||
updated: 2026-05-20T10:14:20.860Z
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Wire the security headers middleware end-to-end in `apps/web-next` — middleware chain, nonce-aware Sentry browser SDK init, and nonce threaded into the document head — producing a Next.js app that emits all six headers with per-request CSP nonces and no CSP violations in the browser console.
|
||||
|
||||
## Why
|
||||
|
||||
`apps/web-next` is the primary template app; getting it wired first validates the Next.js adapter in a real app context, including the Sentry nonce contract and the `<Script nonce={nonce}>` threading pattern that consumers will copy. The Sentry init integration is non-trivial enough (ADR-014 nonce contract, `replayIntegration` + `feedbackIntegration` both need the nonce) that it justifies its own story to get right before the parallel web-tanstack story begins.
|
||||
|
||||
## Done when
|
||||
|
||||
- `apps/web-next/middleware.ts` invokes the `core-shared/security/next` middleware and chains it with existing auth checks (security headers apply before auth redirects).
|
||||
- `apps/web-next/instrumentation-client.ts` reads nonce via `getNonce()` and passes it to `Sentry.init({ integrations: [replayIntegration({ nonce }), feedbackIntegration({ nonce })] })`.
|
||||
- `apps/web-next/app/layout.tsx` threads nonce from `getNonce()` into `<Script nonce={nonce}>` for any inline scripts in the document head.
|
||||
- Middleware test asserts: all six headers present in response, CSP shape matches prod template for `NODE_ENV=production`, CSP shape is permissive for `NODE_ENV=development`, `x-nonce` header present.
|
||||
- No CSP violations appear in browser console when running `pnpm dev` against `localhost:3000`.
|
||||
- `pnpm typecheck && pnpm lint && pnpm test && pnpm conformance && pnpm fallow:audit && pnpm coverage:diff` all pass.
|
||||
|
||||
## In scope
|
||||
|
||||
- `apps/web-next/middleware.ts` — security headers middleware chained with existing auth middleware.
|
||||
- `apps/web-next/instrumentation-client.ts` — nonce-aware `Sentry.init`.
|
||||
- `apps/web-next/app/layout.tsx` — nonce threaded into `<Script>` tags.
|
||||
- Middleware test: six headers + CSP shape per mode + `x-nonce` presence.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- TanStack Start and CMS wiring — Story 09.
|
||||
- CSP report-uri collector — deferred.
|
||||
- Storybook CSP — explicitly out of Epic C scope.
|
||||
- HSTS preload list submission — consumer/legal action.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] Wire `core-shared/security/next` middleware in `apps/web-next/middleware.ts` chained before existing auth checks; update `apps/web-next/instrumentation-client.ts` to read nonce via `getNonce()` and pass to `Sentry.init` replay + feedback integrations; thread nonce from `getNonce()` into `<Script nonce={nonce}>` in `apps/web-next/app/layout.tsx`; add middleware test asserting all six headers, prod/dev CSP shape, and `x-nonce` present in response; all gates pass.
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
id: 09-app-wiring-web-tanstack-and-cms
|
||||
epic: security-headers-rate-limit-sbom
|
||||
title: "App wiring: web-tanstack and cms"
|
||||
type: technical-story
|
||||
status: done
|
||||
feature: web-tanstack
|
||||
depends-on: [07-security-header-adapters]
|
||||
blocks: []
|
||||
created: 2026-05-20T00:00:00Z
|
||||
updated: 2026-05-20T11:22:09.324Z
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Wire the security headers middleware in `apps/web-tanstack` (TanStack Start server middleware + nonce-aware Sentry init) and `apps/cms` (Payload Express middleware) so all three template apps emit the six security headers by default.
|
||||
|
||||
## Why
|
||||
|
||||
Completing the three-app wiring fulfils the PRD success criterion that a consumer picking any template app gets compliant default headers without writing middleware. The two tasks are independent and can land in either order; grouping them in one story reflects that they share the same depends-on (Story 07) and both close the "all apps wired" milestone together.
|
||||
|
||||
## Done when
|
||||
|
||||
- `apps/web-tanstack/app.config.ts` registers the `core-shared/security/tanstack` server middleware.
|
||||
- The web-tanstack client init file reads nonce from request context and passes it to `Sentry.init` replay + feedback integrations (mirroring the web-next pattern).
|
||||
- `apps/cms` Payload config wires the `core-shared/security` Express middleware; CMS responses emit all six headers (no nonce needed — server-side only app).
|
||||
- `pnpm typecheck && pnpm lint && pnpm test && pnpm conformance && pnpm fallow:audit && pnpm coverage:diff` all pass after each task.
|
||||
|
||||
## In scope
|
||||
|
||||
- `apps/web-tanstack/app.config.ts` — `core-shared/security/tanstack` server middleware registration.
|
||||
- web-tanstack client init file — nonce-aware Sentry init (nonce from request context via tanstack adapter's extractor).
|
||||
- `apps/cms` Payload config — Express middleware from `core-shared/security` (framework-agnostic builder; no nonce needed for CMS).
|
||||
|
||||
## Out of scope
|
||||
|
||||
- web-next wiring — Story 08.
|
||||
- CSP report-uri collector — deferred.
|
||||
- Storybook CSP — explicitly out of Epic C scope.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] Register `core-shared/security/tanstack` server middleware in `apps/web-tanstack/app.config.ts`; update the web-tanstack client init file with nonce-aware Sentry init reading nonce from request context via the tanstack adapter's nonce extractor; all gates pass.
|
||||
- [x] Wire `core-shared/security` Express middleware in `apps/cms` Payload config (no nonce needed — CMS is server-side only); all gates pass.
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
id: 10-sbom-ci-workflow
|
||||
epic: security-headers-rate-limit-sbom
|
||||
title: SBOM CI workflow and ADR-023 amendment
|
||||
type: technical-story
|
||||
status: done
|
||||
feature: ci
|
||||
depends-on: []
|
||||
blocks: []
|
||||
created: 2026-05-20T00:00:00Z
|
||||
updated: 2026-05-20T11:33:07.860Z
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Amend `.github/workflows/release-please.yml` to generate a CycloneDX SBOM and upload it as a GitHub release asset whenever release-please cuts a release, and capture the concrete workflow step as an amendment to ADR-023 §10.
|
||||
|
||||
## Why
|
||||
|
||||
Consumers pursuing SOC 2 / ISO 27001 / FedRAMP / EU CRA must answer "what's in version X" without inventory inspection. A CycloneDX SBOM attached to every GitHub release gives auditors a machine-readable, per-release artifact. `pnpm dlx` avoids adding `@cyclonedx/cyclonedx-npm` to the lockfile (CI-only tool per ADR-022). The Renovate-pinned SHA on `softprops/action-gh-release` follows the established ADR-023 pattern.
|
||||
|
||||
## Done when
|
||||
|
||||
- `.github/workflows/release-please.yml` has a conditional step that runs `pnpm dlx @cyclonedx/cyclonedx-npm --output-file sbom-<tag>.cdx.json --output-format json` when `steps.release.outputs.releases_created == 'true'`.
|
||||
- A `softprops/action-gh-release@<SHA>` step (Renovate-managed SHA per ADR-023) uploads the SBOM JSON file as a release asset with `tag_name: ${{ steps.release.outputs.tag_name }}`.
|
||||
- `docs/decisions/adr-023-ci-security-and-supply-chain.md` contains a new amendment subsection capturing the SBOM step's concrete shape and rationale.
|
||||
- Local validation: `pnpm dlx @cyclonedx/cyclonedx-npm --output-file sbom-test.cdx.json` succeeds and produces valid CycloneDX JSON.
|
||||
- `pnpm typecheck && pnpm lint && pnpm test && pnpm conformance && pnpm fallow:audit && pnpm coverage:diff` all pass.
|
||||
|
||||
## In scope
|
||||
|
||||
- `.github/workflows/release-please.yml` — conditional SBOM generation + upload steps.
|
||||
- `softprops/action-gh-release@<SHA>` with Renovate-managed SHA (choose a recent stable release; Renovate will keep it current).
|
||||
- `docs/decisions/adr-023-ci-security-and-supply-chain.md` — amendment subsection §10 SBOM.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Per-PR SBOM generation — release-only per PRD.
|
||||
- SBOM signing / SLSA provenance attestation — bare CycloneDX only; attestation is a future PRD.
|
||||
- Per-package SBOMs — root SBOM covers all workspace packages; industry practice for monorepos.
|
||||
- `@cyclonedx/cyclonedx-npm` added to `package.json` — invoked via `pnpm dlx` only.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] Add conditional SBOM generation step (`pnpm dlx @cyclonedx/cyclonedx-npm --output-file sbom-${{ steps.release.outputs.tag_name }}.cdx.json --output-format json`) and upload step (`softprops/action-gh-release@<SHA>` with `files:` pointing to the SBOM and `tag_name:` from release-please output) to `.github/workflows/release-please.yml`; add amendment subsection to `docs/decisions/adr-023-ci-security-and-supply-chain.md` documenting the concrete step shape and rationale; all gates pass.
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
id: 11-documentation
|
||||
epic: security-headers-rate-limit-sbom
|
||||
title: Documentation and conformance reference updates
|
||||
type: technical-story
|
||||
status: done
|
||||
feature: core-shared
|
||||
depends-on:
|
||||
[
|
||||
05-auth-signin-rate-limit-backfill,
|
||||
08-app-wiring-web-next,
|
||||
09-app-wiring-web-tanstack-and-cms,
|
||||
10-sbom-ci-workflow,
|
||||
]
|
||||
blocks: []
|
||||
created: 2026-05-20T00:00:00Z
|
||||
updated: 2026-05-20T11:49:44.873Z
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Write the consumer-facing cookbooks (`security-headers.md`, `rate-limiting.md`) and update the cross-cutting reference docs (glossary, CLAUDE.md, conformance quickref) so downstream consumers, AI agents, and compliance officers can discover and apply security headers, rate-limiting, and SBOM from a single reading session.
|
||||
|
||||
## Why
|
||||
|
||||
Implementations are complete after Story 10, but the institutional knowledge of how to use them, customize them, and understand their conformance contracts lives only in code. The cookbooks close that gap by walking concrete wiring steps, key-naming conventions, and verification workflows. The CLAUDE.md and conformance-quickref updates keep the conformance rule count accurate (12 → 13) and surface the `rateLimit` manifest field in the authoritative reference every agent reads at session start.
|
||||
|
||||
## Done when
|
||||
|
||||
- `docs/guides/security-headers.md` covers: per-framework middleware wiring (Next.js, TanStack, CMS), nonce threading for consumer-added inline scripts, CSP allowlist customization (`allowedConnectOrigins` etc.), Sentry nonce integration steps, securityheaders.com verification workflow.
|
||||
- `docs/guides/rate-limiting.md` covers: manifest `rateLimit` field declaration, canonical key-naming convention `<feature>:<scope>:<key>`, multi-budget patterns, `InMemoryRateLimit` for dev/test, guidance on wiring a production backend via `BindContext.rateLimit`.
|
||||
- `docs/glossary.md` has entries for: `IRateLimit`, `RateLimited` (brand), `withRateLimit`, `SecurityHeadersConfig`, `buildSecurityHeaders`, `SBOM` (CycloneDX context), `nonce` (CSP context).
|
||||
- `CLAUDE.md` conformance rule count updated 12 → 13; `rateLimit?: RateLimitBudget[]` documented in the manifest field table.
|
||||
- `docs/guides/conformance-quickref.md` updated to list `no-undeclared-rate-limit` as the 13th rule.
|
||||
- `pnpm typecheck && pnpm lint && pnpm test && pnpm conformance && pnpm fallow:audit && pnpm coverage:diff` all pass after each task.
|
||||
|
||||
## In scope
|
||||
|
||||
- `docs/guides/security-headers.md` (new file).
|
||||
- `docs/guides/rate-limiting.md` (new file).
|
||||
- `docs/glossary.md` — new entries only; no existing entry edits.
|
||||
- `CLAUDE.md` — conformance rule count bump (12 → 13) + `rateLimit` manifest field row.
|
||||
- `docs/guides/conformance-quickref.md` — add `no-undeclared-rate-limit` entry.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- ADR-023 amendment — Story 10.
|
||||
- Any new code changes — docs only; all implementation is done.
|
||||
- Compliance fill-in docs (incident runbook, password policy) — Epic D.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] Write `docs/guides/security-headers.md` (per-framework wiring, nonce threading for consumer inline scripts, CSP allowlist customization, Sentry nonce integration, securityheaders.com verification) and `docs/guides/rate-limiting.md` (manifest field, key-naming convention `<feature>:<scope>:<key>`, multi-budget patterns, dev/staging/prod backend wiring); all gates pass.
|
||||
- [x] Add entries for `IRateLimit`, `RateLimited` brand, `withRateLimit`, `SecurityHeadersConfig`, `buildSecurityHeaders`, `SBOM`, `nonce` (CSP context) to `docs/glossary.md`; update `CLAUDE.md` conformance rule count 12 → 13 and add `rateLimit?: RateLimitBudget[]` to the manifest field documentation; add `no-undeclared-rate-limit` as the 13th rule in `docs/guides/conformance-quickref.md`; all gates pass.
|
||||
33
docs/work/epics/security-headers-rate-limit-sbom/_epic.md
Normal file
33
docs/work/epics/security-headers-rate-limit-sbom/_epic.md
Normal file
@@ -0,0 +1,33 @@
|
||||
---
|
||||
id: security-headers-rate-limit-sbom
|
||||
prd: docs/work/prds/security-headers-rate-limit-sbom.prd.md
|
||||
title: Security headers + rate-limit primitive + SBOM in CI — Epic C of ADR-025
|
||||
type: epic
|
||||
status: done
|
||||
features:
|
||||
[core-shared, core-testing, core-eslint, auth, web-next, web-tanstack, cms]
|
||||
created: 2026-05-20T00:00:00Z
|
||||
updated: 2026-05-20T11:49:44.873Z
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Ship three hardening primitives — framework-agnostic security header middleware, a manifest-declared rate-limit conformance channel, and per-release SBOM evidence — so downstream consumers get compliant default headers, lint-enforced rate-limit gates, and CycloneDX audit artifacts without inventing any of them.
|
||||
|
||||
## Why
|
||||
|
||||
Security scanners flag the absence of HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy, and CSP on every template app response. Auth endpoints (signIn, signUp) ship without rate-limit declarations, leaving credential-stuffing windows open until a consumer notices their auth logs. Consumers pursuing SOC 2 / ISO 27001 / FedRAMP must invent SBOM tooling and bolt it into their release flow. ADR-025 settled the strategy; this epic is the implementation.
|
||||
|
||||
## Stories
|
||||
|
||||
- [x] [01 — Rate-limit type primitives and manifest field](01-rate-limit-type-primitives/_story.md)
|
||||
- [x] [02 — Rate-limit implementations: Noop, InMemory, Recording](02-rate-limit-implementations/_story.md)
|
||||
- [x] [03 — `no-undeclared-rate-limit` ESLint rule](03-no-undeclared-rate-limit-eslint-rule/_story.md)
|
||||
- [x] [04 — `withRateLimit` wrapper and conformance extensions](04-with-rate-limit-wrapper-and-conformance/_story.md)
|
||||
- [x] [05 — auth.signIn rate-limit backfill](05-auth-signin-rate-limit-backfill/_story.md)
|
||||
- [x] [06 — Security headers core module](06-security-headers-core-module/_story.md)
|
||||
- [x] [07 — Per-framework security header adapters](07-security-header-adapters/_story.md)
|
||||
- [x] [08 — App wiring: web-next](08-app-wiring-web-next/_story.md)
|
||||
- [x] [09 — App wiring: web-tanstack and cms](09-app-wiring-web-tanstack-and-cms/_story.md)
|
||||
- [x] [10 — SBOM CI workflow and ADR-023 amendment](10-sbom-ci-workflow/_story.md)
|
||||
- [x] [11 — Documentation and conformance reference updates](11-documentation/_story.md)
|
||||
207
docs/work/prds/binder-wrap-helper.prd.md
Normal file
207
docs/work/prds/binder-wrap-helper.prd.md
Normal file
@@ -0,0 +1,207 @@
|
||||
---
|
||||
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 30–79 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.
|
||||
492
docs/work/prds/ci-security-and-supply-chain.prd.md
Normal file
492
docs/work/prds/ci-security-and-supply-chain.prd.md
Normal file
@@ -0,0 +1,492 @@
|
||||
---
|
||||
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 01–02 (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.
|
||||
223
docs/work/prds/compliance-docs-scaffolds.prd.md
Normal file
223
docs/work/prds/compliance-docs-scaffolds.prd.md
Normal file
@@ -0,0 +1,223 @@
|
||||
---
|
||||
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 A–C 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 A–C). 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 A–C 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.
|
||||
@@ -0,0 +1,385 @@
|
||||
---
|
||||
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).
|
||||
280
docs/work/prds/coverage-architecture.prd.md
Normal file
280
docs/work/prds/coverage-architecture.prd.md
Normal file
@@ -0,0 +1,280 @@
|
||||
---
|
||||
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%) | ~5–30s 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, 6–8 stories.
|
||||
- This is the canonical example of "agent-first observability": every layer optimized for machine consumption first, human consumption second.
|
||||
488
docs/work/prds/dsr-consent-and-cookie-banner.prd.md
Normal file
488
docs/work/prds/dsr-consent-and-cookie-banner.prd.md
Normal file
@@ -0,0 +1,488 @@
|
||||
---
|
||||
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.
|
||||
334
docs/work/prds/library-evaluation-policy.prd.md
Normal file
334
docs/work/prds/library-evaluation-policy.prd.md
Normal file
@@ -0,0 +1,334 @@
|
||||
---
|
||||
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.
|
||||
179
docs/work/prds/product-analytics-channel.prd.md
Normal file
179
docs/work/prds/product-analytics-channel.prd.md
Normal file
@@ -0,0 +1,179 @@
|
||||
---
|
||||
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.
|
||||
419
docs/work/prds/security-headers-rate-limit-sbom.prd.md
Normal file
419
docs/work/prds/security-headers-rate-limit-sbom.prd.md
Normal file
@@ -0,0 +1,419 @@
|
||||
---
|
||||
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).
|
||||
Reference in New Issue
Block a user