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
11 KiB
Dependency Flow
+-------------+ +-----------------+ +-----------+
| apps/web- | | apps/web- | | apps/cms |
| next | | tanstack | | |
+------+------+ +--------+--------+ +-----+-----+
| | |
+------------------+--------------+ | |
| | | | |
+----v-----+ +-----v------+ +-----v----v---+ +-------v------+
| core-api | | core-trpc | | feature | | core-cms |
| | | | | packages | | |
+-----+----+ +-----+------+ +------+-------+ +-------+------+
| | | |
| | | |
+--+-------+------+---------------+----+ +-------------+
| | | |
+----v---+ +-v---------+ +-------v---v---+
| core- | | core-ui | | core-shared |
| shared | | | | |
+--------+ +-----------+ +----------------+
Boundary rules (enforced by ESLint + Turborepo boundaries):
app → app, core, core-composition, feature, tooling
feature → core, tooling
core → core, core-composition, tooling
core-composition → core, core-composition, feature, tooling
tooling → tooling
Composition exceptions:
core-api → @repo/<feature>/api (subpath only)
core-cms → @repo/<feature>/cms (subpath only)
App-side feature subpaths (Plan 9):
@repo/<feature> — contracts (types, errors, schemas, IUseCase aliases, router type, constants)
@repo/<feature>/ui — UI artifacts (query builders, components)
Concrete examples
Allowed:
// in apps/web-next
import { appRouter } from "@repo/core-api";
import { NextTrpcProvider } from "@repo/core-trpc/next";
import { bindProductionBlog } from "@repo/blog/di/bind-production";
import { signInInputSchema, type SignInInput } from "@repo/auth"; // contracts (Plan 9)
import { articleBySlugQuery } from "@repo/blog/ui"; // queries (Plan 9)
// in packages/blog
import { slugifyIfMissing } from "@repo/core-shared/payload";
// in packages/core-api
import { blogRouter } from "@repo/blog/api"; // composition exception
import { router } from "@repo/core-shared/trpc/init"; // core → core fine
// in packages/core-cms
import { articles } from "@repo/blog/cms"; // composition exception
Disallowed:
// in packages/blog (cross-feature)
import { Article } from "@repo/marketing-pages"; // ❌ feature → feature
// in packages/blog (deep import past public exports)
import { articles } from "@repo/blog/src/integrations/cms/collections/articles"; // ❌ no-private
// in packages/core-shared
import { blogRouter } from "@repo/blog/api"; // ❌ core → feature
import { ArticleNotFoundError } from "@repo/blog"; // ❌ core → feature
// (defineErrorMiddleware takes Error
// constructors as args from features —
// core-shared never imports them)
// in packages/core-shared (or any non-composition core package)
import { someBlogThing } from "@repo/blog"; // ❌ core → feature (only core-api/core-cms have exception)
// in apps (using the wrong subpath)
import { articleBySlugQuery } from "@repo/blog"; // ❌ queries live on ./ui
import { Article } from "@repo/blog/ui"; // ❌ types live on the root subpath
Enforcement strategy
Three layers work in tandem:
package.jsondependencies — if you didn't declare it, you can't import itexportsmap — blocks deep imports; only public subpaths are accessible- Two parallel automated checks (both enforcing the same five-tag model):
- ESLint
eslint-plugin-boundariesruns at lint time, catching direct-import violations - Turborepo
boundariesruns at build time, validating the entire workspace graph including transitive dependencies
- ESLint
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 / 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)
│
├─ Rule 0: WEB_NEXT_SENTRY_DSN set?
│ yes → bindOtelInstrumentation(sharedContainer, { dsn, app: "web-next" })
│ → initOtelServerNode(dsn, ...) → OTel SDK + Sentry exporter + PII scrub processors
│ no → bindNoopInstrumentation(sharedContainer)
│ ↓
│ tracer (OtelTracer) + logger (OtelLogger) + metrics (OtelMetrics) instances
│ ↓
├─ resolveEventsAndJobs* → IEventBus + IJobQueue (ADR-015)
│ production → PayloadJobsEventBus + PayloadJobQueue
│ dev-seed → InMemoryEventBus + InMemoryJobQueue
│ ↓
├─ resolveRealtime → IRealtimeBroadcaster + IRealtimeHandlerRegistry (ADR-016)
│ server.ts → SocketIORealtimeBroadcaster + RealtimeHandlerRegistry (passed in from server.ts)
│ page/test → InMemoryRealtimeBroadcaster + RealtimeHandlerRegistry (defaults)
│ ↓
├─ 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, auditLog (guard with ?. when used)
│ ↓
├─ bindProductionBlog(ctx: BindProductionContext)
│ │
│ ├─ blogContainer.bind(TRACER).toConstantValue(tracer)
│ ├─ blogContainer.bind(LOGGER).toConstantValue(logger)
│ ├─ ArticlesRepository(config, tracer, logger) → bound to IArticlesRepository
│ │ (real repo: inline this.tracer.startSpan + this.logger.captureException per method)
│ ├─ withSpan(tracer, ..., withCapture(logger, ..., useCase(deps))) → UseCase symbol
│ ├─ withSpan(tracer, ..., withCapture(logger, ..., controller(uc))) → Controller symbol
│ ├─ // <gen:event-handlers> bus.subscribe(...) (ADR-015, gen event consume)
│ ├─ // <gen:jobs> queue.register(...) in dev-seed; Payload task in prod
│ └─ // <gen:realtime-handlers> realtimeRegistry.register(...) (ADR-016, gen realtime handler)
│
└─ (same for auth, marketing-pages, navigation, media — each receives the same ctx)
BindContext shape (from @repo/core-shared/di):
| Field | Type | Required | Notes |
|---|---|---|---|
tracer |
ITracer |
always | Resolved by Rule 0 (OTel+Sentry vs Noop) |
logger |
ILogger |
always | Resolved by Rule 0 |
metrics |
MetricsProtocol? |
optional | Resolved by Rule 0; per-feature adoption is opportunistic |
config |
SanitizedConfig |
production only | Present in BindProductionContext, absent in BindContext |
bus |
EventBusProtocol? |
optional | IEventBus at the aggregator; protocol surface at binders |
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).
Why per-feature containers also get the binding: lets internal DI-resolved code in a feature pull TRACER/LOGGER without going through the app dispatcher. In practice, only repository classes and feature-internal services would use this — controllers and use cases receive instrumentation via the bind-time wrapper.
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. 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.