From 7c915cb447284759acd76ff8935efae26ad681ae Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Mon, 11 May 2026 17:06:58 +0200 Subject: [PATCH] docs(architecture): surface core-audit + DPA across architecture docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Touches the deeper architecture surfaces the Phase 6 sweep skipped: - overview.md: split must-have (core-shared, core-cms, core-api) from optional (core-trpc, core-ui, core-realtime, core-events, core-audit); add core-audit to the Five tags optional list - dependency-flow.md: extend the bindAll diagram with resolveAudit; add auditLog row to the BindContext table; rename the TRACER/LOGGER/METRICS heading to include AUDIT (ADR-018); note the R52-style boundary rule for @repo/core-audit (consume via protocol) - vertical-feature-spec.md: target-state section now states 3 must-have + 5 optional cores; tag matrix includes the optional cores; bind- production signature destructure includes auditLog - di-explainer.html: §08 instrumentation gains an IAuditLog block + the Wiring path tree shows resolveAudit + auditLog in ctx - testing-strategy.md: RecordingAuditLog reference + reset() guidance --- docs/architecture/dependency-flow.md | 15 +++++++++++---- docs/architecture/di-explainer.html | 14 ++++++++++---- docs/architecture/overview.md | 15 ++++++++++++--- docs/architecture/vertical-feature-spec.md | 10 +++++----- docs/guides/testing-strategy.md | 3 ++- 5 files changed, 40 insertions(+), 17 deletions(-) diff --git a/docs/architecture/dependency-flow.md b/docs/architecture/dependency-flow.md index f87e64c..a5f22f2 100644 --- a/docs/architecture/dependency-flow.md +++ b/docs/architecture/dependency-flow.md @@ -94,12 +94,14 @@ Three layers work in tandem: The two enforcement layers are independent but complementary. ESLint is stricter on per-import context (e.g., file-specific exemptions via `// @boundaries-ignore`), while Turborepo catches transitive issues that lint-time checking misses. Run `pnpm lint` and `pnpm turbo boundaries` in CI to catch all violations. -## TRACER / LOGGER / METRICS (ADR-014, ADR-017) +## TRACER / LOGGER / METRICS / AUDIT (ADR-014, ADR-017, ADR-018) The instrumentation layer is **per-feature container** but **app-wide instance**: each feature container binds `INSTRUMENTATION_SYMBOLS.ITracer`, `INSTRUMENTATION_SYMBOLS.ILogger`, and `INSTRUMENTATION_SYMBOLS.IMetrics` to the SAME instances, constructed once by the app's `bindAll()` dispatcher (Rule 0). **Substrate:** OpenTelemetry SDK. Sentry is the exporter via `@sentry/opentelemetry`. PII scrubbing runs at the OTel processor layer (`PiiScrubSpanProcessor` + `PiiScrubLogRecordProcessor`) before the Sentry exporter sees the data. Feature code is never coupled to the OTel SDK or Sentry SDK directly (ADR-017). +**Audit is a parallel channel** with different durability and redaction contracts (ADR-018). Whereas OTel is sampled and pruned (best-effort observability), audit is append-only and retained for compliance. `auditLog` is bound by `resolveAudit` and added to `ctx` for feature binders that opt in; the binding is wrapped in `TraceIdEnrichingAuditLog` so every `AuditEntry` auto-correlates with the active OTel span via `correlationId`. See `docs/guides/audit-and-compliance.md` and the visual explainer at `docs/architecture/audit-and-compliance-explainer.html`. + ``` apps/web-next/src/server/bind-production.ts (bindAll) │ @@ -118,9 +120,13 @@ apps/web-next/src/server/bind-production.ts (bindAll) │ server.ts → SocketIORealtimeBroadcaster + RealtimeHandlerRegistry (passed in from server.ts) │ page/test → InMemoryRealtimeBroadcaster + RealtimeHandlerRegistry (defaults) │ ↓ - ├─ build ctx: BindProductionContext = { config, tracer, logger, metrics?, bus, queue, realtime, realtimeRegistry } + ├─ resolveAudit → IAuditLog (ADR-018) + │ production → TraceIdEnrichingAuditLog( MultiSinkAuditLog([StdoutJsonAuditLog, PayloadAuditLog]) ) + │ dev-seed → TraceIdEnrichingAuditLog( StdoutJsonAuditLog ) — or Noop when core-audit is absent + │ ↓ + ├─ build ctx: BindProductionContext = { config, tracer, logger, metrics?, bus, queue, realtime, realtimeRegistry, auditLog } │ Required: tracer, logger, config (production only) - │ Optional: metrics, bus, queue, realtime, realtimeRegistry (guard with ?. when used) + │ Optional: metrics, bus, queue, realtime, realtimeRegistry, auditLog (guard with ?. when used) │ ↓ ├─ bindProductionBlog(ctx: BindProductionContext) │ │ @@ -149,6 +155,7 @@ apps/web-next/src/server/bind-production.ts (bindAll) | `queue` | `IJobQueue?` | optional | Present when `core-shared/jobs` is wired | | `realtime` | `RealtimeBroadcasterProtocol?` | optional | `IRealtimeBroadcaster` at the aggregator | | `realtimeRegistry` | `RealtimeRegistryProtocol?` | optional | `IRealtimeHandlerRegistry` at the aggregator | +| `auditLog` | `AuditLogProtocol?` | optional | `IAuditLog` at the aggregator; pre-wrapped in `TraceIdEnrichingAuditLog` so callers don't supply `correlationId` (ADR-018) | Feature binders destructure `ctx` and use optional fields with `?.` or cast to the full interface when the feature unconditionally requires them (e.g. `bus as IEventBus` when a use case always needs the event bus). @@ -156,4 +163,4 @@ Feature binders destructure `ctx` and use optional fields with `?.` or cast to t **Why the shared container exists at all:** isolates Rule 0 resolution from feature containers. Feature containers don't need to know if Sentry is the exporter or not — they just receive an `ITracer` instance. -**Boundary rule:** feature packages MUST NOT import `@sentry/*` or `@opentelemetry/sdk-*` directly (R40 + R52, ESLint-enforced). The OTel bridge (`otel/sentry-bridge.ts`), browser init files (`sentry/init-client*.ts`), and app-level `instrumentation*.{ts,mjs}` / `next.config.{mjs}` / `vite.config.{ts}` entries are the only allowlisted paths. +**Boundary rule:** feature packages MUST NOT import `@sentry/*` or `@opentelemetry/sdk-*` directly (R40 + R52, ESLint-enforced). The OTel bridge (`otel/sentry-bridge.ts`), browser init files (`sentry/init-client*.ts`), and app-level `instrumentation*.{ts,mjs}` / `next.config.{mjs}` / `vite.config.{ts}` entries are the only allowlisted paths. Likewise, features MUST NOT import `@repo/core-audit` directly — they consume `IAuditLog` via the `AuditLogProtocol` type re-exported from `@repo/core-shared/di/bind-protocols`. diff --git a/docs/architecture/di-explainer.html b/docs/architecture/di-explainer.html index 4a04221..56a0df0 100644 --- a/docs/architecture/di-explainer.html +++ b/docs/architecture/di-explainer.html @@ -1170,7 +1170,7 @@ footer .colophon {
§ 08

Instrumentation symbols.

-

ADR-014 + ADR-017 added instrumentation symbols to the per-feature container — ITracer, ILogger, and IMetrics — bound by a separate Rule 0 in bindAll() that's orthogonal to the repo binding mode. The DSN env var decides OTel+Sentry vs Noop; USE_DEV_SEED / NODE_ENV decide real vs mock repos. Substrate: OpenTelemetry SDK. Sentry is the exporter via @sentry/opentelemetry.

+

ADR-014 + ADR-017 added instrumentation symbols to the per-feature container — ITracer, ILogger, and IMetrics — bound by a separate Rule 0 in bindAll() that's orthogonal to the repo binding mode. The DSN env var decides OTel+Sentry vs Noop; USE_DEV_SEED / NODE_ENV decide real vs mock repos. Substrate: OpenTelemetry SDK. Sentry is the exporter via @sentry/opentelemetry. ADR-018 adds IAuditLog as a parallel channel with different durability and redaction contracts — see audit-and-compliance-explainer.html.

@@ -1184,6 +1184,9 @@ footer .colophon {

INSTRUMENTATION_SYMBOLS.IMetrics

Bound to NoopMetrics or OtelMetrics. OtelMetrics uses the OTel metrics API (counter, histogram, gauge). Per-feature adoption is opportunistic — no feature call sites required.

+ +

IAuditLog (ADR-018, optional)

+

Not a symbol — passed through ctx.auditLog, not bound on the per-feature container. Present only when @repo/core-audit is scaffolded via pnpm turbo gen core-package audit. Pre-wrapped in TraceIdEnrichingAuditLog so callers don't supply correlationId — it's read from the active OTel span at sink time. Parallel channel: OTel is sampled/pruned best-effort observability; audit is append-only and retained for compliance. Same traceId lives in both, which is the debugging bridge.

@@ -1194,12 +1197,15 @@ footer .colophon { OR bindNoopInstrumentation() ← all Noop └─ resolveEventsAndJobs*() ← ADR-015 (env-driven bus + queue) └─ Payload-backed in prod, in-memory in dev-seed - └─ build ctx: BindProductionContext = { config, tracer, logger, metrics?, bus, queue, realtime, realtimeRegistry } - └─ required: tracer, logger, config | optional: metrics, bus, queue, realtime, realtimeRegistry + └─ resolveRealtime() ← ADR-016 (Socket.IO or in-memory) + └─ resolveAudit() ← ADR-018 (TraceIdEnriching( MultiSink([Stdout, Payload]) )) + └─ absent → ctx.auditLog is undefined; record() calls become no-ops via ?. + └─ build ctx: BindProductionContext = { config, tracer, logger, metrics?, bus, queue, realtime, realtimeRegistry, auditLog } + └─ required: tracer, logger, config | optional: metrics, bus, queue, realtime, realtimeRegistry, auditLog └─ bindProductionX(ctx) ← single ctx object passed to each feature binder └─ feature container also binds TRACER + LOGGER └─ withSpan(withCapture(...)) at every use case + controller - └─ // <gen:event-handlers> / // <gen:jobs> injection sites + └─ // <gen:event-handlers> / // <gen:jobs> / // <gen:realtime-handlers> / // <gen:audit-hooks> injection sites

Why per-feature containers also get the binding: repository classes resolve TRACER/LOGGER through the container; controllers and use cases receive instrumentation via the bind-time wrapper instead.

diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index 9099da2..3f13294 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -6,12 +6,19 @@ A vertical-feature monorepo. Business capabilities are top-level packages; non-b ``` packages/ - # Foundation (no business logic) - core-shared/ Generic primitives — Payload field/block helpers, tRPC init/context, lib utilities + # Must-have foundation (no business logic) + core-shared/ Generic primitives — Payload field/block helpers, tRPC init/context, + instrumentation interfaces, audit protocol + truncate-ip + AuditEntry shape, + BindContext + protocol types for optional cross-cutting cores core-cms/ Composition only: assembles feature CMS exports into one Payload config core-api/ Composition only: aggregates feature tRPC routers into one appRouter + + # Optional cross-cutting cores (scaffold on demand via `pnpm turbo gen core-package `) core-trpc/ Frontend tRPC client + per-framework providers (Next.js, TanStack) core-ui/ Design-system primitives (atoms, molecules, generic organisms, templates) + core-realtime/ Socket.IO server + broadcaster + handler registry (ADR-016) + core-events/ In-memory + Payload-backed event bus + job queue (ADR-015) + core-audit/ DPA-compliant audit logging — sinks, hook factories, eraseSubject (ADR-018) # Business capabilities auth/ Users + sign-in/sign-up/sign-out + session/cookie domain @@ -25,6 +32,8 @@ packages/ core-typescript/ Shared tsconfig + vitest base ``` +See `docs/architecture/template-tiers.md` for the must-have/optional split and the scaffold commands. + ## Data flow ``` @@ -95,7 +104,7 @@ The workspace is organized into five mutually exclusive tags: - **app** (4 packages): `apps/cms`, `apps/web-next`, `apps/web-tanstack`, `apps/storybook` - **core-composition** (2 must-have): `packages/core-api`, `packages/core-cms`. Plus `packages/core-trpc` when scaffolded via `pnpm turbo gen core-package trpc` (optional). -- **core** (1 must-have): `packages/core-shared`. Plus `packages/core-ui`, `packages/core-realtime`, `packages/core-events` when scaffolded via `pnpm turbo gen core-package ` (optional). +- **core** (1 must-have): `packages/core-shared`. Plus `packages/core-ui`, `packages/core-realtime`, `packages/core-events`, `packages/core-audit` when scaffolded via `pnpm turbo gen core-package ` (optional). - **feature** (5 packages): `packages/auth`, `packages/blog`, `packages/media`, `packages/marketing-pages`, `packages/navigation` - **tooling** (2 packages): `packages/core-eslint`, `packages/core-typescript` diff --git a/docs/architecture/vertical-feature-spec.md b/docs/architecture/vertical-feature-spec.md index d9febef..a235e2f 100644 --- a/docs/architecture/vertical-feature-spec.md +++ b/docs/architecture/vertical-feature-spec.md @@ -38,7 +38,7 @@ The refactor preserves Clean Architecture layering *inside* each feature (the ex ## 3. Target state (summary) -- Five `core-*` packages: `core-shared`, `core-cms`, `core-api`, `core-trpc`, `core-ui` +- Three must-have `core-*` packages: `core-shared`, `core-cms`, `core-api`. Five optional cross-cutting cores scaffold on demand via `pnpm turbo gen core-package `: `core-trpc`, `core-ui`, `core-realtime` (ADR-016), `core-events` (ADR-015), `core-audit` (ADR-018). See `docs/architecture/template-tiers.md`. - Five feature packages: `auth`, `blog`, `media`, `marketing-pages`, `navigation` - Two tooling packages renamed: `eslint-config` → `core-eslint`, `typescript-config` → `core-typescript` - Three apps unchanged in name: `apps/web-next`, `apps/web-tanstack`, `apps/cms` @@ -428,12 +428,12 @@ Package-level `turbo.json` tags (refined from earlier ADR-006's three-tag model) | Tag | Packages | |---|---| | `app` | `apps/web-next`, `apps/web-tanstack`, `apps/cms`, `apps/storybook` | -| `core-composition` | `packages/core-api`, `core-cms`, `core-trpc` | -| `core` | `packages/core-shared`, `core-ui` | +| `core-composition` | `packages/core-api`, `core-cms`, plus `core-trpc` when scaffolded (optional) | +| `core` | `packages/core-shared`, plus `core-ui`, `core-realtime`, `core-events`, `core-audit` when scaffolded (optional) | | `feature` | `packages/auth`, `blog`, `media`, `marketing-pages`, `navigation` | | `tooling` | `packages/core-eslint`, `core-typescript` | -Note: `core-trpc` is `core-composition` (not plain `core`) because it transitively reaches features through `core-api`'s `AppRouter` type. +Note: `core-trpc` is `core-composition` (not plain `core`) because it transitively reaches features through `core-api`'s `AppRouter` type. The other optional cross-cutting cores stay in plain `core` — they expose protocol types (consumed via `@repo/core-shared/di/bind-protocols`) and never reach into feature packages. ### 9.2 Allowed dependency directions @@ -695,7 +695,7 @@ Invoke the `superpowers:writing-plans` skill to produce a detailed, executable i - `infrastructure/repositories/.repository.ts` — constructor takes `(config, tracer, logger)` with Noop defaults; every public method's body is wrapped in `tracer.startSpan(...)` and any `catch` block calls `logger.captureException(err, { tags: { feature, repo, method } })` before re-throwing. - `infrastructure/repositories/.repository.mock.ts` — same constructor/wrapping shape (no catch — mocks don't originate infra errors). -- `di/bind-production.ts` — signature `(ctx: BindProductionContext)` (from `@repo/core-shared/di`). Destructures `{ config, tracer, logger, metrics?, bus, queue, realtime, realtimeRegistry }` from `ctx`. Binds TRACER + LOGGER to the feature container; constructs the real repo with tracer/logger; wraps every use case + controller via `withSpan(withCapture(factory(deps)))` at bind time. `withSpan` is outermost so an errored span's timing reflects the capture-and-rethrow; `withCapture` honours the `__sentryReported` flag so a bubbled error from the repo isn't re-captured. Optional fields (`metrics`, `bus`, `queue`, `realtime`, `realtimeRegistry`) are guarded with `?.` or cast to the full interface when the feature unconditionally requires them. +- `di/bind-production.ts` — signature `(ctx: BindProductionContext)` (from `@repo/core-shared/di`). Destructures `{ config, tracer, logger, metrics?, bus, queue, realtime, realtimeRegistry, auditLog }` from `ctx`. Binds TRACER + LOGGER to the feature container; constructs the real repo with tracer/logger; wraps every use case + controller via `withSpan(withCapture(factory(deps)))` at bind time. `withSpan` is outermost so an errored span's timing reflects the capture-and-rethrow; `withCapture` honours the `__sentryReported` flag so a bubbled error from the repo isn't re-captured. Optional fields (`metrics`, `bus`, `queue`, `realtime`, `realtimeRegistry`, `auditLog`) are guarded with `?.` or cast to the full interface when the feature unconditionally requires them (e.g. an authoritative action calls `ctx.auditLog?.record({...})`; the `?.` makes it safe whether or not `@repo/core-audit` is scaffolded). - `di/bind-dev-seed.ts` — signature `(ctx: BindContext)` (no `config`). Same wrapping as bind-production but with the populated mock. **Required exports (per feature root):** unchanged. diff --git a/docs/guides/testing-strategy.md b/docs/guides/testing-strategy.md index 64d8fb9..bc83220 100644 --- a/docs/guides/testing-strategy.md +++ b/docs/guides/testing-strategy.md @@ -328,6 +328,7 @@ articlesRepositoryContract.run( - `RecordingLogger.captures` — every `captureException` / `captureMessage` call. - `RecordingLogger.breadcrumbs` — every breadcrumb added. - `RecordingLogger.users` — every `setUser` call (history). +- `RecordingAuditLog.entries` — every `record(entry)` call. Use to assert audit emissions in feature-package tests **without** importing `@repo/core-audit` (the recording double lives in `@repo/core-testing/instrumentation` and mirrors the `AuditEntry` shape inline to avoid the tooling→core boundary). Also exposes `RecordingAuditLog.erasures` for `eraseSubject` history. See ADR-018 and `docs/guides/audit-and-compliance.md` for what to assert. -**Test cleanup:** call `tracer.reset()` and `logger.reset()` in `beforeEach` if the test creates one shared instance across multiple cases. +**Test cleanup:** call `tracer.reset()`, `logger.reset()`, and `auditLog.reset()` in `beforeEach` if the test creates one shared instance across multiple cases. 5. **Mock repos** are the default; only use real Payload in dedicated infrastructure tests