From b1e13064ba31f859cc994ab4e5e8e4ee69ccb996 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Thu, 21 May 2026 19:02:40 +0300 Subject: [PATCH 1/9] =?UTF-8?q?feat(phase-02a):=20packet=203=20=E2=80=94?= =?UTF-8?q?=20cross-cutting=20foundation=20per=20ADR-0032?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the ADR-0032 surface end to end so every later module inherits the same error / logging / observability contract on Day 1: - L1 LearnStackExceptionHandler + ShouldCapture switch (Sentry-vs-OTel boundary per Standards 09); ProblemDetailsFactory + HttpStatusMap + ResultExtensions.ToActionResult lit up. - LearnStackException hierarchy (DomainException, InfrastructureException, ProviderException with IsClientError, TenantContextMissingException) in SharedKernel/Errors/. - Eight-step MediatR pipeline in Application/Pipeline/ — Validation + Logging are full impls; AuditLog ships the try/ExceptionDispatchInfo rethrow shell; TenantContext short-circuits on unresolved context; Authorization / Transaction / OutboxFlush pass-through shells. Registration order encoded in CanonicalBehaviorOrder + asserted by MediatR_Pipeline_Order_Matches_Canonical_Sequence. - New Infrastructure projects: Observability (TenantContextAccessor + TenantContextSpanProcessor), ErrorTracking (NoOp / Sentry / LocalFile trackers + DeploymentMode-aware AddLearnStackErrorTracking), Resilience (Polly v8 IProviderResilience socket with retry → breaker → timeout + the configuration shape Resilience::). - LearnStack.Analyzers Roslyn analyzer flags `throw new DomainException(...)` in Domain + Application (warning; escalates to error after Phase 03 exit). Referenced via OutputItemType ="Analyzer" project references; ships release-tracking markdown. - Program.cs rewires through AddLearnStackCrossCuttingFoundation — Serilog primary logger + WriteTo.OpenTelemetry sink (no AddOpenTelemetry().WithLogging() per Sub-decision 8), OTel SDK with AspNetCore + HttpClient + EFCore instrumentation + TenantContextSpanProcessor + OTLP exporter, IErrorTrackingProvider branched on DeploymentMode, MediatR pipeline + AddExceptionHandler. - Architecture tests: pipeline order, IExceptionHandler registered, OTel processor wired, logging via MEL, Sentry not referenced from modules, adapter SDK exceptions stay in their namespace, IErrorTrackingProvider singleton (110 unit + 24 architecture + 1 contract + 1 integration green under CI=true). - Roadmap (docs/roadmap/phase-02a-kernel-tenancy.md) marks Packet 3 ✅ with the full deliverables list. Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/Directory.Packages.props | 61 ++++- backend/LearnStack.slnx | 6 + .../AnalyzerReleases.Shipped.md | 2 + .../AnalyzerReleases.Unshipped.md | 8 + .../DomainExceptionThrowAnalyzer.cs | 109 ++++++++ .../LearnStack.Analyzers.csproj | 32 +++ .../LearnStack.Api/Common/HttpStatusMap.cs | 55 ++++ .../Common/LearnStackExceptionHandler.cs | 108 ++++++++ .../Common/ProblemDetailsFactory.cs | 111 ++++++++ .../LearnStack.Api/Common/ResultExtensions.cs | 43 +++ .../CrossCuttingFoundationExtensions.cs | 131 +++++++++ .../src/LearnStack.Api/LearnStack.Api.csproj | 17 ++ backend/src/LearnStack.Api/Program.cs | 27 +- .../LearnStack.Api/Properties/AssemblyInfo.cs | 7 + backend/src/LearnStack.Api/appsettings.json | 44 ++- .../LearnStack.Application.csproj | 11 + .../Pipeline/AuditLogBehavior.cs | 86 ++++++ .../Pipeline/AuthorizationBehavior.cs | 41 +++ .../Pipeline/LoggingBehavior.cs | 109 ++++++++ .../Pipeline/MediatRPipelineRegistration.cs | 64 +++++ .../Pipeline/OutboxFlushBehavior.cs | 38 +++ .../Pipeline/TenantContextBehavior.cs | 63 +++++ .../Pipeline/TransactionBehavior.cs | 42 +++ .../Pipeline/ValidationBehavior.cs | 104 +++++++ .../LearnStack.Domain.csproj | 11 + .../AssemblyMarker.cs | 3 + .../ErrorTrackingOptions.cs | 29 ++ .../ErrorTrackingRegistration.cs | 87 ++++++ ...nStack.Infrastructure.ErrorTracking.csproj | 23 ++ .../LocalFileErrorTracker.cs | 107 ++++++++ .../NoOpErrorTracker.cs | 18 ++ .../Properties/AssemblyInfo.cs | 3 + .../SentryErrorTracker.cs | 75 +++++ .../AssemblyMarker.cs | 7 + ...nStack.Infrastructure.Observability.csproj | 23 ++ .../ObservabilityRegistration.cs | 27 ++ .../TenantContextAccessor.cs | 23 ++ .../TenantContextSpanProcessor.cs | 64 +++++ .../AssemblyMarker.cs | 3 + ...earnStack.Infrastructure.Resilience.csproj | 20 ++ .../Properties/AssemblyInfo.cs | 3 + .../ProviderResilience.cs | 89 ++++++ .../ProviderResilienceRegistration.cs | 47 ++++ .../Errors/DomainException.cs | 34 +++ .../Errors/InfrastructureException.cs | 25 ++ .../Errors/LearnStackException.cs | 36 +++ .../Errors/ProviderException.cs | 65 +++++ .../Errors/TenantContextMissingException.cs | 22 ++ .../Hosting/DeploymentMode.cs | 24 ++ .../LearnStack.SharedKernel.csproj | 6 + .../Observability/IErrorTrackingProvider.cs | 42 +++ .../Resilience/IProviderResilience.cs | 40 +++ .../Resilience/ResilienceOptions.cs | 51 ++++ .../Tenancy/ITenantContext.cs | 65 +++++ .../Tenancy/ITenantContextAccessor.cs | 29 ++ .../Tenancy/UnresolvedTenantContext.cs | 35 +++ .../CrossCuttingFoundationTests.cs | 257 ++++++++++++++++++ .../LearnStack.Tests.Architecture.csproj | 3 + .../Api/Common/HttpStatusMapTests.cs | 62 +++++ .../Api/Common/ResultExtensionsTests.cs | 43 +++ .../Pipeline/AuditLogBehaviorTests.cs | 53 ++++ .../Pipeline/TenantContextBehaviorTests.cs | 39 +++ .../Pipeline/ValidationBehaviorTests.cs | 88 ++++++ .../LocalFileErrorTrackerTests.cs | 61 +++++ .../TenantContextSpanProcessorTests.cs | 88 ++++++ .../Resilience/ProviderResilienceTests.cs | 81 ++++++ .../LearnStack.Tests.Unit.csproj | 4 + docs/roadmap/phase-02a-kernel-tenancy.md | 98 +++++-- 68 files changed, 3292 insertions(+), 40 deletions(-) create mode 100644 backend/analyzers/LearnStack.Analyzers/AnalyzerReleases.Shipped.md create mode 100644 backend/analyzers/LearnStack.Analyzers/AnalyzerReleases.Unshipped.md create mode 100644 backend/analyzers/LearnStack.Analyzers/DomainExceptionThrowAnalyzer.cs create mode 100644 backend/analyzers/LearnStack.Analyzers/LearnStack.Analyzers.csproj create mode 100644 backend/src/LearnStack.Api/Common/HttpStatusMap.cs create mode 100644 backend/src/LearnStack.Api/Common/LearnStackExceptionHandler.cs create mode 100644 backend/src/LearnStack.Api/Common/ProblemDetailsFactory.cs create mode 100644 backend/src/LearnStack.Api/Common/ResultExtensions.cs create mode 100644 backend/src/LearnStack.Api/Composition/CrossCuttingFoundationExtensions.cs create mode 100644 backend/src/LearnStack.Api/Properties/AssemblyInfo.cs create mode 100644 backend/src/LearnStack.Application/Pipeline/AuditLogBehavior.cs create mode 100644 backend/src/LearnStack.Application/Pipeline/AuthorizationBehavior.cs create mode 100644 backend/src/LearnStack.Application/Pipeline/LoggingBehavior.cs create mode 100644 backend/src/LearnStack.Application/Pipeline/MediatRPipelineRegistration.cs create mode 100644 backend/src/LearnStack.Application/Pipeline/OutboxFlushBehavior.cs create mode 100644 backend/src/LearnStack.Application/Pipeline/TenantContextBehavior.cs create mode 100644 backend/src/LearnStack.Application/Pipeline/TransactionBehavior.cs create mode 100644 backend/src/LearnStack.Application/Pipeline/ValidationBehavior.cs create mode 100644 backend/src/LearnStack.Infrastructure.ErrorTracking/AssemblyMarker.cs create mode 100644 backend/src/LearnStack.Infrastructure.ErrorTracking/ErrorTrackingOptions.cs create mode 100644 backend/src/LearnStack.Infrastructure.ErrorTracking/ErrorTrackingRegistration.cs create mode 100644 backend/src/LearnStack.Infrastructure.ErrorTracking/LearnStack.Infrastructure.ErrorTracking.csproj create mode 100644 backend/src/LearnStack.Infrastructure.ErrorTracking/LocalFileErrorTracker.cs create mode 100644 backend/src/LearnStack.Infrastructure.ErrorTracking/NoOpErrorTracker.cs create mode 100644 backend/src/LearnStack.Infrastructure.ErrorTracking/Properties/AssemblyInfo.cs create mode 100644 backend/src/LearnStack.Infrastructure.ErrorTracking/SentryErrorTracker.cs create mode 100644 backend/src/LearnStack.Infrastructure.Observability/AssemblyMarker.cs create mode 100644 backend/src/LearnStack.Infrastructure.Observability/LearnStack.Infrastructure.Observability.csproj create mode 100644 backend/src/LearnStack.Infrastructure.Observability/ObservabilityRegistration.cs create mode 100644 backend/src/LearnStack.Infrastructure.Observability/TenantContextAccessor.cs create mode 100644 backend/src/LearnStack.Infrastructure.Observability/TenantContextSpanProcessor.cs create mode 100644 backend/src/LearnStack.Infrastructure.Resilience/AssemblyMarker.cs create mode 100644 backend/src/LearnStack.Infrastructure.Resilience/LearnStack.Infrastructure.Resilience.csproj create mode 100644 backend/src/LearnStack.Infrastructure.Resilience/Properties/AssemblyInfo.cs create mode 100644 backend/src/LearnStack.Infrastructure.Resilience/ProviderResilience.cs create mode 100644 backend/src/LearnStack.Infrastructure.Resilience/ProviderResilienceRegistration.cs create mode 100644 backend/src/LearnStack.SharedKernel/Errors/DomainException.cs create mode 100644 backend/src/LearnStack.SharedKernel/Errors/InfrastructureException.cs create mode 100644 backend/src/LearnStack.SharedKernel/Errors/LearnStackException.cs create mode 100644 backend/src/LearnStack.SharedKernel/Errors/ProviderException.cs create mode 100644 backend/src/LearnStack.SharedKernel/Errors/TenantContextMissingException.cs create mode 100644 backend/src/LearnStack.SharedKernel/Hosting/DeploymentMode.cs create mode 100644 backend/src/LearnStack.SharedKernel/Observability/IErrorTrackingProvider.cs create mode 100644 backend/src/LearnStack.SharedKernel/Resilience/IProviderResilience.cs create mode 100644 backend/src/LearnStack.SharedKernel/Resilience/ResilienceOptions.cs create mode 100644 backend/src/LearnStack.SharedKernel/Tenancy/ITenantContext.cs create mode 100644 backend/src/LearnStack.SharedKernel/Tenancy/ITenantContextAccessor.cs create mode 100644 backend/src/LearnStack.SharedKernel/Tenancy/UnresolvedTenantContext.cs create mode 100644 backend/tests/LearnStack.Tests.Architecture/CrossCuttingFoundationTests.cs create mode 100644 backend/tests/LearnStack.Tests.Unit/Api/Common/HttpStatusMapTests.cs create mode 100644 backend/tests/LearnStack.Tests.Unit/Api/Common/ResultExtensionsTests.cs create mode 100644 backend/tests/LearnStack.Tests.Unit/Application/Pipeline/AuditLogBehaviorTests.cs create mode 100644 backend/tests/LearnStack.Tests.Unit/Application/Pipeline/TenantContextBehaviorTests.cs create mode 100644 backend/tests/LearnStack.Tests.Unit/Application/Pipeline/ValidationBehaviorTests.cs create mode 100644 backend/tests/LearnStack.Tests.Unit/Infrastructure/ErrorTracking/LocalFileErrorTrackerTests.cs create mode 100644 backend/tests/LearnStack.Tests.Unit/Infrastructure/Observability/TenantContextSpanProcessorTests.cs create mode 100644 backend/tests/LearnStack.Tests.Unit/Infrastructure/Resilience/ProviderResilienceTests.cs diff --git a/backend/Directory.Packages.props b/backend/Directory.Packages.props index 3b950e5..4d1bc61 100644 --- a/backend/Directory.Packages.props +++ b/backend/Directory.Packages.props @@ -54,22 +54,63 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - + + + + + + diff --git a/backend/LearnStack.slnx b/backend/LearnStack.slnx index d0f568e..f599854 100644 --- a/backend/LearnStack.slnx +++ b/backend/LearnStack.slnx @@ -6,8 +6,14 @@ + + + + + + diff --git a/backend/analyzers/LearnStack.Analyzers/AnalyzerReleases.Shipped.md b/backend/analyzers/LearnStack.Analyzers/AnalyzerReleases.Shipped.md new file mode 100644 index 0000000..a530e20 --- /dev/null +++ b/backend/analyzers/LearnStack.Analyzers/AnalyzerReleases.Shipped.md @@ -0,0 +1,2 @@ +; Shipped analyzer releases. +; See https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md diff --git a/backend/analyzers/LearnStack.Analyzers/AnalyzerReleases.Unshipped.md b/backend/analyzers/LearnStack.Analyzers/AnalyzerReleases.Unshipped.md new file mode 100644 index 0000000..b297ff0 --- /dev/null +++ b/backend/analyzers/LearnStack.Analyzers/AnalyzerReleases.Unshipped.md @@ -0,0 +1,8 @@ +; Unshipped analyzer release. +; See https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md + +### New Rules + +Rule ID | Category | Severity | Notes +--------|----------|----------|------------ +LearnStackException-DomainExceptionThrow | Design | Warning | ADR-0032 § Sub-decision 4 — DomainException is reserved for programmer errors; expected business-rule violations return Result.Fail(business_rule_violation, ...). Severity escalates to Error after Phase 03 exit. diff --git a/backend/analyzers/LearnStack.Analyzers/DomainExceptionThrowAnalyzer.cs b/backend/analyzers/LearnStack.Analyzers/DomainExceptionThrowAnalyzer.cs new file mode 100644 index 0000000..fd2dcbe --- /dev/null +++ b/backend/analyzers/LearnStack.Analyzers/DomainExceptionThrowAnalyzer.cs @@ -0,0 +1,109 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace LearnStack.Analyzers; + +/// +/// LearnStackException-DomainExceptionThrow — flags every +/// throw new DomainException(...) in Domain / Application code per +/// ADR-0032 § Sub-decision 4. Expected business-rule violations return +/// Result.Fail(business_rule_violation, ...); the exception is +/// reserved for programmer errors. +/// +/// +/// Severity: in Phase 02a. Per +/// ADR-0032 the severity escalates to +/// after Phase 03 exit when every existing call site has been migrated. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class DomainExceptionThrowAnalyzer : DiagnosticAnalyzer +{ + public const string DiagnosticId = "LearnStackException-DomainExceptionThrow"; + + private static readonly LocalizableString Title = + "Avoid throwing DomainException for expected business-rule violations"; + + private static readonly LocalizableString MessageFormat = + "DomainException is reserved for programmer errors. " + + "Return Result.Fail(business_rule_violation, ...) instead."; + + private static readonly LocalizableString Description = + "ADR-0032 § Sub-decision 4 reserves DomainException for aggregate-invariant " + + "violations that signal a programming mistake. Expected business-rule " + + "violations are an outcome — return Result.Fail(business_rule_violation, ...) " + + "from the domain method."; + + private static readonly DiagnosticDescriptor Rule = new( + id: DiagnosticId, + title: Title, + messageFormat: MessageFormat, + category: "Design", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: Description, + helpLinkUri: "https://github.com/cemililik/learnstack/blob/main/docs/decisions/0032-exception-handling-logging-and-observability.md"); + + public override ImmutableArray SupportedDiagnostics + => ImmutableArray.Create(Rule); + + public override void Initialize(AnalysisContext context) + { + if (context is null) throw new System.ArgumentNullException(nameof(context)); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterSyntaxNodeAction(AnalyzeThrowExpression, SyntaxKind.ThrowExpression); + context.RegisterSyntaxNodeAction(AnalyzeThrowStatement, SyntaxKind.ThrowStatement); + } + + private static void AnalyzeThrowStatement(SyntaxNodeAnalysisContext context) + { + var node = (ThrowStatementSyntax)context.Node; + if (node.Expression is null) return; + InspectThrown(context, node.Expression); + } + + private static void AnalyzeThrowExpression(SyntaxNodeAnalysisContext context) + { + var node = (ThrowExpressionSyntax)context.Node; + InspectThrown(context, node.Expression); + } + + private static void InspectThrown(SyntaxNodeAnalysisContext context, ExpressionSyntax expression) + { + if (expression is not ObjectCreationExpressionSyntax creation) + { + return; + } + + var typeInfo = context.SemanticModel.GetTypeInfo(creation, context.CancellationToken); + var symbol = typeInfo.Type; + if (symbol is null) + { + return; + } + + if (!IsDomainException(symbol)) + { + return; + } + + context.ReportDiagnostic(Diagnostic.Create(Rule, creation.GetLocation())); + } + + private static bool IsDomainException(ITypeSymbol type) + { + for (var current = type; current is not null; current = current.BaseType) + { + if (current.Name == "DomainException" && + current.ContainingNamespace?.ToDisplayString() == "LearnStack.SharedKernel.Errors") + { + return true; + } + } + + return false; + } +} diff --git a/backend/analyzers/LearnStack.Analyzers/LearnStack.Analyzers.csproj b/backend/analyzers/LearnStack.Analyzers/LearnStack.Analyzers.csproj new file mode 100644 index 0000000..7fb4170 --- /dev/null +++ b/backend/analyzers/LearnStack.Analyzers/LearnStack.Analyzers.csproj @@ -0,0 +1,32 @@ + + + + + netstandard2.0 + enable + latest + false + LearnStack.Analyzers + true + + + + + all + + + all + + + + + + + + + + diff --git a/backend/src/LearnStack.Api/Common/HttpStatusMap.cs b/backend/src/LearnStack.Api/Common/HttpStatusMap.cs new file mode 100644 index 0000000..fafef82 --- /dev/null +++ b/backend/src/LearnStack.Api/Common/HttpStatusMap.cs @@ -0,0 +1,55 @@ +using System.Net; +using LearnStack.SharedKernel.Errors; +using LearnStack.SharedKernel.Results; + +namespace LearnStack.Api.Common; + +/// +/// Maps an (or a +/// subclass) to the HTTP status the API surface returns. The table mirrors +/// Standards 09 +/// § Result Type; adding a new error code requires updating both +/// places so the contract stays in sync. +/// +public static class HttpStatusMap +{ + public static int For(Error error) + { + ArgumentNullException.ThrowIfNull(error); + return For(error.Code); + } + + public static int For(string code) => code switch + { + "validation_failed" => (int)HttpStatusCode.BadRequest, + "unsupported_locale" => (int)HttpStatusCode.BadRequest, + "unauthorized" => (int)HttpStatusCode.Unauthorized, + "forbidden" => (int)HttpStatusCode.Forbidden, + "resource_scope_violation" => (int)HttpStatusCode.Forbidden, + "feature_disabled" => (int)HttpStatusCode.Forbidden, + "not_found" => (int)HttpStatusCode.NotFound, + "tenant_mismatch" => (int)HttpStatusCode.NotFound, + "concurrency_conflict" => (int)HttpStatusCode.Conflict, + "business_rule_violation" => (int)HttpStatusCode.Conflict, + "recording_consent_required" => (int)HttpStatusCode.Conflict, + "rate_limited" => (int)HttpStatusCode.TooManyRequests, + "dependency_unavailable" => (int)HttpStatusCode.ServiceUnavailable, + _ => (int)HttpStatusCode.InternalServerError, + }; + + public static int For(Exception exception) + { + ArgumentNullException.ThrowIfNull(exception); + + return exception switch + { + OperationCanceledException => 499, // client closed request + ProviderException pex when pex.IsClientError => (int)HttpStatusCode.BadRequest, + ProviderException => (int)HttpStatusCode.ServiceUnavailable, + InfrastructureException => (int)HttpStatusCode.ServiceUnavailable, + TenantContextMissingException => (int)HttpStatusCode.NotFound, + LearnStackException known => For(known.Error), + _ => (int)HttpStatusCode.InternalServerError, + }; + } +} diff --git a/backend/src/LearnStack.Api/Common/LearnStackExceptionHandler.cs b/backend/src/LearnStack.Api/Common/LearnStackExceptionHandler.cs new file mode 100644 index 0000000..999ad06 --- /dev/null +++ b/backend/src/LearnStack.Api/Common/LearnStackExceptionHandler.cs @@ -0,0 +1,108 @@ +using System.Diagnostics; +using LearnStack.SharedKernel.Errors; +using LearnStack.SharedKernel.Observability; +using LearnStack.SharedKernel.Tenancy; +using Microsoft.AspNetCore.Diagnostics; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; + +namespace LearnStack.Api.Common; + +/// +/// L1 exception handler — the single catch site at the HTTP boundary per +/// ADR-0032 § Sub-decision 1. Builds the Problem Details body, records the +/// span error, and dispatches to only +/// when returns true +/// (Standards 09 § Sentry vs OpenTelemetry — Error Capture Boundary). +/// +public sealed class LearnStackExceptionHandler( + IErrorTrackingProvider errorTracker, + ITenantContextAccessor tenantContextAccessor, + ILogger logger) : IExceptionHandler +{ + public async ValueTask TryHandleAsync( + HttpContext httpContext, + Exception exception, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(httpContext); + ArgumentNullException.ThrowIfNull(exception); + + var problem = ProblemDetailsFactory.For(exception, httpContext); + var capture = ShouldCapture(exception); + + // OperationCanceled stays on Unset — RecordException would tag the + // span as error and Tempo would render the client disconnect as a + // failure. See ADR-0032 § Sub-decision 7 + Implementation Notes. + if (exception is not OperationCanceledException) + { + Activity.Current?.AddException(exception); + Activity.Current?.SetStatus(ActivityStatusCode.Error, exception.GetType().Name); + } + + if (capture) + { + var capturedContext = BuildCapturedContext(httpContext); + await errorTracker.CaptureAsync(exception, capturedContext, cancellationToken) + .ConfigureAwait(false); + LogCaptured(logger, exception.GetType().FullName ?? "", exception); + } + else + { + LogSkipped(logger, exception.GetType().FullName ?? "", null); + } + + httpContext.Response.StatusCode = problem.Status ?? StatusCodes.Status500InternalServerError; + httpContext.Response.ContentType = "application/problem+json"; + await httpContext.Response.WriteAsJsonAsync( + problem, + problem.GetType(), + options: null, + contentType: "application/problem+json", + cancellationToken: cancellationToken) + .ConfigureAwait(false); + + return true; + } + + /// + /// The Sentry / OTel boundary table (Standards 09 § Sentry vs + /// OpenTelemetry — Error Capture Boundary) reduced to a switch. Internal + /// for unit-test visibility via InternalsVisibleTo; the rule + /// itself is binding from ADR-0032 § Sub-decision 7. + /// + internal static bool ShouldCapture(Exception exception) => exception switch + { + OperationCanceledException => false, + ProviderException pex when pex.IsClientError => false, + _ => true, + }; + + private CapturedContext BuildCapturedContext(HttpContext httpContext) + { + var context = tenantContextAccessor.Current; + var traceId = Activity.Current?.TraceId.ToString(); + + return new CapturedContext( + CorrelationId: traceId ?? context?.CorrelationId, + RequestPath: httpContext.Request.Path.Value, + RequestMethod: httpContext.Request.Method, + TenantId: context?.IsResolved == true ? context.TenantId : null, + OrganizationId: context?.OrganizationId, + UserId: context?.UserId?.Value, + ModuleName: context?.ModuleName, + AdditionalTags: null); + } + + private static readonly Action LogCaptured = + LoggerMessage.Define( + LogLevel.Error, + new EventId(1, nameof(LogCaptured)), + "L1 exception handler captured {ExceptionType} to IErrorTrackingProvider."); + + private static readonly Action LogSkipped = + LoggerMessage.Define( + LogLevel.Information, + new EventId(2, nameof(LogSkipped)), + "L1 exception handler skipped Sentry capture for {ExceptionType} per Standards 09 boundary."); +} diff --git a/backend/src/LearnStack.Api/Common/ProblemDetailsFactory.cs b/backend/src/LearnStack.Api/Common/ProblemDetailsFactory.cs new file mode 100644 index 0000000..783dfc8 --- /dev/null +++ b/backend/src/LearnStack.Api/Common/ProblemDetailsFactory.cs @@ -0,0 +1,111 @@ +using System.Diagnostics; +using LearnStack.SharedKernel.Errors; +using LearnStack.SharedKernel.Localization; +using LearnStack.SharedKernel.Results; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace LearnStack.Api.Common; + +/// +/// Builds RFC 7807 bodies from the project's +/// / hierarchy. +/// Shape pinned by Standards 09 § API Surface — every API error response +/// carries code, messageKey, correlationId, optional +/// errors. +/// +public static class ProblemDetailsFactory +{ + private const string ProblemTypePrefix = "https://errors.learnstack.dev/"; + + public static ProblemDetails For(Error error, HttpContext? context = null) + { + ArgumentNullException.ThrowIfNull(error); + + var problem = BuildBase(error.Code, error.Message.Key, HttpStatusMap.For(error.Code), context); + + if (error.Details is { Count: > 0 }) + { + problem.Extensions["errors"] = ProjectDetails(error.Details); + } + + return problem; + } + + public static ProblemDetails For(Exception exception, HttpContext? context = null) + { + ArgumentNullException.ThrowIfNull(exception); + + if (exception is LearnStackException known) + { + return For(known.Error, context); + } + + // Unhandled / unknown — surface a stable generic shape. + return BuildBase( + code: "internal_error", + messageKey: "lockey_internal_error", + status: HttpStatusMap.For(exception), + context: context); + } + + private static ProblemDetails BuildBase(string code, string messageKey, int status, HttpContext? context) + { + var problem = new ProblemDetails + { + Type = ProblemTypePrefix + code, + Title = messageKey, + Status = status, + Instance = context?.Request.Path.Value, + }; + + problem.Extensions["code"] = code; + problem.Extensions["messageKey"] = messageKey; + problem.Extensions["correlationId"] = ResolveCorrelationId(context); + return problem; + } + + private static string? ResolveCorrelationId(HttpContext? context) + { + var traceId = Activity.Current?.TraceId.ToString(); + if (!string.IsNullOrWhiteSpace(traceId)) + { + return traceId; + } + + return context?.TraceIdentifier; + } + + private static Dictionary> ProjectDetails( + IReadOnlyDictionary> details) + { + // Per Standards 09 § Validation Errors field names use the request's + // camelCase shape on the wire. The validator typically returns + // PascalCase property names; lower-case the first char so the + // payload matches what the SDK expects without losing the source + // information. + var projected = new Dictionary>(StringComparer.Ordinal); + foreach (var (key, list) in details) + { + projected[ToCamelCase(key)] = list + .Select(m => (object)new + { + key = m.Key, + @params = m.Params, + }) + .ToArray(); + } + + return projected; + } + + private static string ToCamelCase(string value) + { + if (string.IsNullOrEmpty(value) || char.IsLower(value[0])) + { + return value; + } + + return char.ToLowerInvariant(value[0]) + value[1..]; + } +} diff --git a/backend/src/LearnStack.Api/Common/ResultExtensions.cs b/backend/src/LearnStack.Api/Common/ResultExtensions.cs new file mode 100644 index 0000000..5338430 --- /dev/null +++ b/backend/src/LearnStack.Api/Common/ResultExtensions.cs @@ -0,0 +1,43 @@ +using LearnStack.SharedKernel.Results; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace LearnStack.Api.Common; + +/// +/// Maps a to an per +/// ADR-0032 § Sub-decision 6. The sanctioned shape — explicit at every +/// controller endpoint: +/// +/// +/// [HttpPost] +/// public async Task<IActionResult> Create(CreateCourseCommand cmd, CancellationToken ct) +/// => (await _mediator.Send(cmd, ct)).ToActionResult(); +/// +/// +/// +/// No action filter, no MediatR ResultUnwrapBehavior, no implicit +/// conversion. Explicit beats magic. +/// +public static class ResultExtensions +{ + public static IActionResult ToActionResult(this Result result, HttpContext? context = null) + { + ArgumentNullException.ThrowIfNull(result); + + if (result.IsSuccess) + { + return new OkObjectResult(result.Value); + } + + var error = result.Error + ?? throw new InvalidOperationException( + "Result.IsFailure but Error is null — Result.Fail enforces a non-null Error."); + + var problem = ProblemDetailsFactory.For(error, context); + return new ObjectResult(problem) + { + StatusCode = problem.Status, + }; + } +} diff --git a/backend/src/LearnStack.Api/Composition/CrossCuttingFoundationExtensions.cs b/backend/src/LearnStack.Api/Composition/CrossCuttingFoundationExtensions.cs new file mode 100644 index 0000000..bc63193 --- /dev/null +++ b/backend/src/LearnStack.Api/Composition/CrossCuttingFoundationExtensions.cs @@ -0,0 +1,131 @@ +using LearnStack.Api.Common; +using LearnStack.Application.Pipeline; +using LearnStack.Infrastructure.ErrorTracking; +using LearnStack.Infrastructure.Observability; +using LearnStack.SharedKernel.Hosting; +using LearnStack.SharedKernel.Tenancy; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Hosting; +using OpenTelemetry.Resources; +using OpenTelemetry.Trace; +using OpenTelemetry.Metrics; +using Serilog; + +namespace LearnStack.Api.Composition; + +/// +/// Composition-root extension that wires the entire ADR-0032 surface in one +/// disciplined pass — Serilog, OpenTelemetry, error tracking, +/// , MediatR pipeline, the singleton +/// , and the request-scoped +/// default. The wire-cross-cutting-foundation +/// skill is the long-form walk; this method is the binary. +/// +public static class CrossCuttingFoundationExtensions +{ + /// + /// Wires the cross-cutting foundation against the supplied + /// . The Serilog bootstrap runs first so + /// startup errors are captured; OpenTelemetry tracing + metrics binds + /// next; branches by + /// ; the MediatR pipeline registers the + /// eight canonical behaviors. + /// + public static WebApplicationBuilder AddLearnStackCrossCuttingFoundation( + this WebApplicationBuilder builder, + DeploymentMode deploymentMode, + params System.Reflection.Assembly[] mediatorHandlerAssemblies) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(mediatorHandlerAssemblies); + + WireSerilog(builder); + builder.Services.AddLearnStackObservabilityServices(); + WireOpenTelemetry(builder); + + builder.Services.AddLearnStackErrorTracking(builder.Configuration, deploymentMode); + + // Request-scoped ITenantContext default — Packet 7 swaps this for the + // resolved instance produced by TenantResolverMiddleware. The + // singleton ITenantContextAccessor is set in + // AddLearnStackObservabilityServices above. + builder.Services.TryAddScoped(_ => UnresolvedTenantContext.Instance); + + builder.Services.AddProblemDetails(); + builder.Services.AddExceptionHandler(); + + builder.Services.AddLearnStackMediatRPipeline(mediatorHandlerAssemblies); + + return builder; + } + + private static void WireSerilog(WebApplicationBuilder builder) + { + builder.Host.UseSerilog((ctx, services, cfg) => + { + cfg.ReadFrom.Configuration(ctx.Configuration) + .ReadFrom.Services(services) + .Enrich.FromLogContext() + .Enrich.WithProperty("service.name", "learnstack-api") + .WriteTo.Console(new Serilog.Formatting.Compact.RenderedCompactJsonFormatter()); + + var otlpEndpoint = ctx.Configuration["Telemetry:OtlpEndpoint"]; + if (!string.IsNullOrWhiteSpace(otlpEndpoint)) + { + cfg.WriteTo.OpenTelemetry(o => + { + o.Endpoint = otlpEndpoint; + o.Protocol = Serilog.Sinks.OpenTelemetry.OtlpProtocol.Grpc; + o.ResourceAttributes = new Dictionary(StringComparer.Ordinal) + { + ["service.name"] = "learnstack-api", + }; + }); + } + // The OTel LoggerProvider (AddOpenTelemetry().WithLogging()) is + // intentionally NOT registered alongside; double-export would + // duplicate every log line. ADR-0032 § Sub-decision 8. + }); + } + + private static void WireOpenTelemetry(WebApplicationBuilder builder) + { + var serviceName = builder.Configuration["Telemetry:Service:Name"] ?? "learnstack-api"; + var serviceVersion = builder.Configuration["Telemetry:Service:Version"] ?? "0.0.0-dev"; + var otlpEndpoint = builder.Configuration["Telemetry:OtlpEndpoint"]; + + var otel = builder.Services + .AddOpenTelemetry() + .ConfigureResource(r => r.AddService( + serviceName: serviceName, + serviceVersion: serviceVersion)) + .WithTracing(t => + { + t.AddAspNetCoreInstrumentation(); + t.AddHttpClientInstrumentation(); + t.AddEntityFrameworkCoreInstrumentation(); + t.AddSource("LearnStack.*"); + t.AddProcessor(); + if (!string.IsNullOrWhiteSpace(otlpEndpoint)) + { + t.AddOtlpExporter(o => o.Endpoint = new Uri(otlpEndpoint)); + } + }) + .WithMetrics(m => + { + m.AddAspNetCoreInstrumentation(); + m.AddHttpClientInstrumentation(); + m.AddMeter("LearnStack.*"); + if (!string.IsNullOrWhiteSpace(otlpEndpoint)) + { + m.AddOtlpExporter(o => o.Endpoint = new Uri(otlpEndpoint)); + } + }); + + _ = otel; + } +} diff --git a/backend/src/LearnStack.Api/LearnStack.Api.csproj b/backend/src/LearnStack.Api/LearnStack.Api.csproj index 573b0a8..f0c4fff 100644 --- a/backend/src/LearnStack.Api/LearnStack.Api.csproj +++ b/backend/src/LearnStack.Api/LearnStack.Api.csproj @@ -11,6 +11,9 @@ + + + @@ -31,6 +34,20 @@ + + + + + + + + + + + diff --git a/backend/src/LearnStack.Api/Program.cs b/backend/src/LearnStack.Api/Program.cs index 2beb310..ce90ab7 100644 --- a/backend/src/LearnStack.Api/Program.cs +++ b/backend/src/LearnStack.Api/Program.cs @@ -1,20 +1,25 @@ -// TODO(2026-05-19, @platform, phase-02a): wire OpenTelemetry — traces + -// metrics + logs via AddOpenTelemetry(); the OpenTelemetry.* packages are -// already reserved in Directory.Packages.props. LearnStack.Tests.Contract -// should then assert /openapi/v1.json advertises the correlation-id header. -// -// TODO(2026-05-19, @platform, phase-02a): revisit appsettings.Development.json -// EF Core logging level — currently `Information` logs every SQL statement -// including parameter values. Once handlers land and parameters may carry PII, -// drop to `Warning` and route SQL traces through OpenTelemetry instead -// (Standards 11 § Logging Hygiene). +using LearnStack.Api.Composition; +using LearnStack.SharedKernel.Hosting; var builder = WebApplication.CreateBuilder(args); +// Resolve the deployment mode once at the composition root. Modules never +// read DeploymentMode (architecture test +// Modules_Do_Not_Reference_DeploymentMode enforces it); the value selects +// the right error tracker, OTLP exporter target, and (later packets) the +// right Dapr / entitlement / host-resolver implementations per +// docs/standards/20-infrastructure-stack.md § Composition Root. +var deploymentMode = builder.Configuration.GetValue("Deployment:Mode", DeploymentMode.Development); + +builder.AddLearnStackCrossCuttingFoundation(deploymentMode); + builder.Services.AddOpenApi(); +builder.Services.AddControllers(); var app = builder.Build(); +app.UseExceptionHandler(); + if (app.Environment.IsDevelopment()) { app.MapOpenApi(); @@ -23,6 +28,8 @@ app.MapGet("/healthz", () => Results.Ok(new { status = "healthy" })) .WithName("HealthCheck"); +app.MapControllers(); + app.Run(); // `public partial class Program` is the top-level-statements escape hatch diff --git a/backend/src/LearnStack.Api/Properties/AssemblyInfo.cs b/backend/src/LearnStack.Api/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..8e38b67 --- /dev/null +++ b/backend/src/LearnStack.Api/Properties/AssemblyInfo.cs @@ -0,0 +1,7 @@ +using System.Runtime.CompilerServices; + +// LearnStackExceptionHandler.ShouldCapture is internal so tests can hold +// it to the Sentry-vs-OTel boundary contract without exposing the rule +// to module code. +[assembly: InternalsVisibleTo("LearnStack.Tests.Unit")] +[assembly: InternalsVisibleTo("LearnStack.Tests.Architecture")] diff --git a/backend/src/LearnStack.Api/appsettings.json b/backend/src/LearnStack.Api/appsettings.json index 10f68b8..10c3a60 100644 --- a/backend/src/LearnStack.Api/appsettings.json +++ b/backend/src/LearnStack.Api/appsettings.json @@ -5,5 +5,47 @@ "Microsoft.AspNetCore": "Warning" } }, - "AllowedHosts": "*" + "AllowedHosts": "*", + "Deployment": { + "Mode": "Development" + }, + "Telemetry": { + "OtlpEndpoint": "http://otel-collector:4317", + "Service": { + "Name": "learnstack-api", + "Version": "0.0.0-dev" + } + }, + "ErrorTracking": { + "Sentry": { + "Dsn": "", + "Environment": "development", + "TracesSampleRate": 0.1 + }, + "LocalFile": { + "Directory": "/var/learnstack/errors/" + } + }, + "Resilience": { + "liveclass": { + "Retry": { "MaxAttempts": 3, "DelaySeconds": 1, "UseJitter": true }, + "CircuitBreaker": { "FailureRatio": 0.5, "SamplingDurationSeconds": 30, "MinimumThroughput": 10, "BreakDurationSeconds": 30 }, + "Timeout": { "TotalSeconds": 10 } + }, + "payment": { + "Retry": { "MaxAttempts": 3, "DelaySeconds": 1, "UseJitter": true }, + "CircuitBreaker": { "FailureRatio": 0.5, "SamplingDurationSeconds": 30, "MinimumThroughput": 10, "BreakDurationSeconds": 60 }, + "Timeout": { "TotalSeconds": 15 } + }, + "storage": { + "Retry": { "MaxAttempts": 5, "DelaySeconds": 0.5, "UseJitter": true }, + "CircuitBreaker": { "FailureRatio": 0.5, "SamplingDurationSeconds": 30, "MinimumThroughput": 10, "BreakDurationSeconds": 30 }, + "Timeout": { "TotalSeconds": 30 } + }, + "search": { + "Retry": { "MaxAttempts": 2, "DelaySeconds": 0.25, "UseJitter": true }, + "CircuitBreaker": { "FailureRatio": 0.5, "SamplingDurationSeconds": 30, "MinimumThroughput": 10, "BreakDurationSeconds": 15 }, + "Timeout": { "TotalSeconds": 5 } + } + } } diff --git a/backend/src/LearnStack.Application/LearnStack.Application.csproj b/backend/src/LearnStack.Application/LearnStack.Application.csproj index 1c6fe44..10a1017 100644 --- a/backend/src/LearnStack.Application/LearnStack.Application.csproj +++ b/backend/src/LearnStack.Application/LearnStack.Application.csproj @@ -8,11 +8,22 @@ + + + + + + diff --git a/backend/src/LearnStack.Application/Pipeline/AuditLogBehavior.cs b/backend/src/LearnStack.Application/Pipeline/AuditLogBehavior.cs new file mode 100644 index 0000000..f27af16 --- /dev/null +++ b/backend/src/LearnStack.Application/Pipeline/AuditLogBehavior.cs @@ -0,0 +1,86 @@ +using System.Runtime.ExceptionServices; +using LearnStack.SharedKernel.Results; +using MediatR; +using Microsoft.Extensions.Logging; + +namespace LearnStack.Application.Pipeline; + +/// +/// MediatR pipeline behavior — step 3 of the canonical 8-step order +/// (ADR-0032 § Sub-decision 2). Per +/// ADR-0016 +/// this behavior wraps the inner pipeline with try / catch, writes a +/// failure-class audit entry on exception, and rethrows via +/// to preserve the original stack trace. +/// The L1 IExceptionHandler is the final catch site below the +/// framework. +/// +/// +/// +/// Phase 02a Packet 3 ships the shell: the catch / rethrow +/// contract is wired, but the audit-write path is a no-op until Packet 9 +/// lights up LearnStack.Infrastructure.Audit (per the Phase 02a +/// roadmap). The shell preserves two guarantees that Packet 9 cannot retrofit +/// without churn: +/// +/// +/// Exception rethrow uses — +/// handlers and the L1 boundary see the original stack. +/// Pipeline order: AuditLog wraps TenantContext + Authorization + +/// Transaction + OutboxFlush + Handler. The architecture test +/// MediatR_Pipeline_Order_Matches_Canonical_Sequence asserts the +/// wrap order; a Packet 9 swap must not change it. +/// +/// +/// When Packet 9 lights up IAuditStore + IAuditStateCapture, +/// this class moves to LearnStack.Infrastructure.Audit with the same +/// shape — only the no-op TODOs flip to real writes. +/// +/// +public sealed class AuditLogBehavior( + ILogger> logger) + : IPipelineBehavior + where TRequest : notnull + where TResponse : IResultBase +{ + public async Task Handle( + TRequest request, + RequestHandlerDelegate next, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(next); + + try + { + var response = await next().ConfigureAwait(false); + + // TODO(2026-05-21, @platform, phase-02a-packet-9): on success, + // resolve IAuditStateCapture for the request type and write the + // success-class audit entry through IAuditStore. Per ADR-0016 + + // Standards 18 audit-coverage matrix. The shell shape here keeps + // the pipeline contract intact until the audit infrastructure + // lands. + + return response; + } +#pragma warning disable CA1031 // Do not catch general exception types — ADR-0016 binds the audit-then-rethrow contract here. + catch (Exception ex) +#pragma warning restore CA1031 + { + // TODO(2026-05-21, @platform, phase-02a-packet-9): write the + // failure-class audit entry to audit_log via IAuditStore. The + // shell logs the audit-intent so we do not silently lose the + // failure visibility while Packet 9 is pending. + LogAuditIntent(logger, typeof(TRequest).Name, ex); + + ExceptionDispatchInfo.Capture(ex).Throw(); + throw; // unreachable; the line above is the rethrow. + } + } + + private static readonly Action LogAuditIntent = + LoggerMessage.Define( + LogLevel.Warning, + new EventId(1, nameof(LogAuditIntent)), + "AuditLogBehavior shell captured exception during {RequestName}; audit-write deferred until Packet 9 lights up IAuditStore."); +} diff --git a/backend/src/LearnStack.Application/Pipeline/AuthorizationBehavior.cs b/backend/src/LearnStack.Application/Pipeline/AuthorizationBehavior.cs new file mode 100644 index 0000000..53c9cbc --- /dev/null +++ b/backend/src/LearnStack.Application/Pipeline/AuthorizationBehavior.cs @@ -0,0 +1,41 @@ +using LearnStack.SharedKernel.Results; +using MediatR; + +namespace LearnStack.Application.Pipeline; + +/// +/// MediatR pipeline behavior — step 5 of the canonical 8-step order +/// (ADR-0032 § Sub-decision 2). Calls +/// IAuthorizationService.AuthorizeAsync against the command's +/// resource; on deny returns Result.Fail(forbidden) rather than +/// throwing. +/// +/// +/// Phase 02a Packet 3 ships the shell: there is no +/// permission catalogue yet (it lands together with the Identity module in +/// Phase 03 + Standards 19). The shell passes every request through and +/// preserves the pipeline-order contract. When the permission catalogue +/// arrives the shell flips to consuming IAuthorizationService and +/// per-request [Authorize] metadata; the registration order does not +/// change. +/// +public sealed class AuthorizationBehavior + : IPipelineBehavior + where TRequest : notnull + where TResponse : IResultBase +{ + public Task Handle( + TRequest request, + RequestHandlerDelegate next, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(next); + + // TODO(2026-05-21, @platform, phase-03): resolve the request's + // [Authorize(Policy)] attribute, call IAuthorizationService.AuthorizeAsync + // with the tenant + organization-scoped resource, and return + // Result.FailFor(forbidden) on deny. + + return next(); + } +} diff --git a/backend/src/LearnStack.Application/Pipeline/LoggingBehavior.cs b/backend/src/LearnStack.Application/Pipeline/LoggingBehavior.cs new file mode 100644 index 0000000..c1341e1 --- /dev/null +++ b/backend/src/LearnStack.Application/Pipeline/LoggingBehavior.cs @@ -0,0 +1,109 @@ +using System.Diagnostics; +using LearnStack.SharedKernel.Results; +using LearnStack.SharedKernel.Tenancy; +using MediatR; +using Microsoft.Extensions.Logging; + +namespace LearnStack.Application.Pipeline; + +/// +/// MediatR pipeline behavior — step 2 of the canonical 8-step order +/// (ADR-0032 § Sub-decision 2). Opens an +/// carrying the eight correlation fields (Standards 10 § Correlation), +/// starts a manual named +/// learnstack.<module>.<use-case>, and measures the +/// handler latency for downstream histogram reporting. +/// +/// +/// +/// The actual metric histogram is wired into the OpenTelemetry meter at the +/// composition root; this behavior simply records start / stop on a +/// per-invocation and attaches the elapsed +/// milliseconds to the log scope so the metric pipeline can pick it up. +/// +/// +/// The ActivitySource name is "learnstack.mediatr"; per-module +/// manual spans use their own source (e.g. "learnstack.education") so +/// trace consumers can filter independently. +/// +/// +public sealed class LoggingBehavior( + ILogger> logger, + ITenantContextAccessor tenantContextAccessor) + : IPipelineBehavior + where TRequest : notnull + where TResponse : IResultBase +{ + private static readonly ActivitySource ActivitySource = new("learnstack.mediatr"); + + public async Task Handle( + TRequest request, + RequestHandlerDelegate next, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(next); + + var requestName = typeof(TRequest).Name; + var context = tenantContextAccessor.Current; + + using var activity = ActivitySource.StartActivity( + $"mediatr.{requestName}", + ActivityKind.Internal); + + using var scope = logger.BeginScope(BuildScope(context, requestName)); + + var stopwatch = Stopwatch.StartNew(); + try + { + var response = await next().ConfigureAwait(false); + stopwatch.Stop(); + + if (response.IsSuccess) + { + LogSuccess(logger, requestName, stopwatch.ElapsedMilliseconds, null); + } + else + { + LogFailure(logger, requestName, response.Error?.Code ?? "", stopwatch.ElapsedMilliseconds, null); + } + + return response; + } + catch + { + stopwatch.Stop(); + // Re-throw to preserve the catch / audit / rethrow contract carried + // by AuditLogBehavior (step 3). LoggingBehavior is intentionally + // silent on exception so AuditLogBehavior owns the failure-audit + // path; the L1 handler logs the exception once at the boundary. + throw; + } + } + + private static Dictionary BuildScope(ITenantContext? context, string requestName) + { + return new Dictionary(StringComparer.Ordinal) + { + ["RequestName"] = requestName, + ["TenantId"] = context?.IsResolved == true ? context.TenantId : null, + ["OrganizationId"] = context?.OrganizationId, + ["UserId"] = context?.UserId?.Value, + ["CorrelationId"] = context?.CorrelationId, + ["Module"] = context?.ModuleName, + }; + } + + // LoggerMessage source-generated delegates (CA1848) — keep the format + // strings identical to the inlined-string version they replaced. + private static readonly Action LogSuccess = + LoggerMessage.Define( + LogLevel.Information, + new EventId(1, nameof(LogSuccess)), + "MediatR request {RequestName} completed successfully in {ElapsedMilliseconds} ms"); + + private static readonly Action LogFailure = + LoggerMessage.Define( + LogLevel.Information, + new EventId(2, nameof(LogFailure)), + "MediatR request {RequestName} returned Result.Fail({ErrorCode}) in {ElapsedMilliseconds} ms"); +} diff --git a/backend/src/LearnStack.Application/Pipeline/MediatRPipelineRegistration.cs b/backend/src/LearnStack.Application/Pipeline/MediatRPipelineRegistration.cs new file mode 100644 index 0000000..04b00fc --- /dev/null +++ b/backend/src/LearnStack.Application/Pipeline/MediatRPipelineRegistration.cs @@ -0,0 +1,64 @@ +using MediatR; +using Microsoft.Extensions.DependencyInjection; + +namespace LearnStack.Application.Pipeline; + +/// +/// Composition-root extension that registers the canonical eight-step MediatR +/// pipeline (ADR-0032 § Sub-decision 2). Outermost (validation) first, +/// innermost (handler) last; the architecture test +/// MediatR_Pipeline_Order_Matches_Canonical_Sequence asserts the DI +/// registration order at startup. +/// +public static class MediatRPipelineRegistration +{ + /// + /// The behaviors in canonical order. Architecture tests reflect on this + /// list to assert the runtime registration matches the contract; **do + /// not** reorder without amending ADR-0032. + /// + public static IReadOnlyList CanonicalBehaviorOrder { get; } = + [ + typeof(ValidationBehavior<,>), + typeof(LoggingBehavior<,>), + typeof(AuditLogBehavior<,>), + typeof(TenantContextBehavior<,>), + typeof(AuthorizationBehavior<,>), + typeof(TransactionBehavior<,>), + typeof(OutboxFlushBehavior<,>), + ]; + + /// + /// Registers the eight-step MediatR pipeline against the supplied + /// . The handler types themselves are scanned + /// from (typically each module's + /// AssemblyMarker assembly). + /// + public static IServiceCollection AddLearnStackMediatRPipeline( + this IServiceCollection services, + params System.Reflection.Assembly[] handlerAssemblies) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(handlerAssemblies); + + // MediatR 12.x throws if no assemblies are registered for handler + // scanning. Fall back to the LearnStack.Application assembly itself — + // it carries no handlers in Phase 02a, but the behaviors below register + // through AddBehavior directly and do not depend on scanning. + var assembliesToScan = handlerAssemblies.Length > 0 + ? handlerAssemblies + : [typeof(AssemblyMarker).Assembly]; + + services.AddMediatR(cfg => + { + cfg.RegisterServicesFromAssemblies(assembliesToScan); + + foreach (var behaviorType in CanonicalBehaviorOrder) + { + cfg.AddBehavior(typeof(IPipelineBehavior<,>), behaviorType); + } + }); + + return services; + } +} diff --git a/backend/src/LearnStack.Application/Pipeline/OutboxFlushBehavior.cs b/backend/src/LearnStack.Application/Pipeline/OutboxFlushBehavior.cs new file mode 100644 index 0000000..98e703a --- /dev/null +++ b/backend/src/LearnStack.Application/Pipeline/OutboxFlushBehavior.cs @@ -0,0 +1,38 @@ +using LearnStack.SharedKernel.Results; +using MediatR; + +namespace LearnStack.Application.Pipeline; + +/// +/// MediatR pipeline behavior — step 7 of the canonical 8-step order +/// (ADR-0032 § Sub-decision 2). Enrols IOutbox messages in the +/// current unit-of-work transaction; the outbox processor publishes them +/// via IEventBus on commit (see +/// 15-event-and-outbox.md). +/// +/// +/// Phase 02a Packet 3 ships the shell: there is no +/// IOutbox contract yet (it lands in Phase 02b). The shell delegates +/// to the inner pipeline so the order is correct now; Phase 02b lights up +/// the enrolment without changing the eight-step registration. +/// +public sealed class OutboxFlushBehavior + : IPipelineBehavior + where TRequest : notnull + where TResponse : IResultBase +{ + public Task Handle( + TRequest request, + RequestHandlerDelegate next, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(next); + + // TODO(2026-05-21, @platform, phase-02b): on a success-Result, flush + // IOutbox messages collected during the handler into outbox_messages + // via the unit-of-work seam so Dapr pub/sub dispatches them after + // commit. Per ADR-0006 + ADR-0014 + ADR-0032 § Sub-decision 12. + + return next(); + } +} diff --git a/backend/src/LearnStack.Application/Pipeline/TenantContextBehavior.cs b/backend/src/LearnStack.Application/Pipeline/TenantContextBehavior.cs new file mode 100644 index 0000000..8d77ead --- /dev/null +++ b/backend/src/LearnStack.Application/Pipeline/TenantContextBehavior.cs @@ -0,0 +1,63 @@ +using LearnStack.SharedKernel.Localization; +using LearnStack.SharedKernel.Results; +using LearnStack.SharedKernel.Tenancy; +using MediatR; + +namespace LearnStack.Application.Pipeline; + +/// +/// MediatR pipeline behavior — step 4 of the canonical 8-step order +/// (ADR-0032 § Sub-decision 2). Asserts that the upstream resolution stage +/// populated ; when it has not, short-circuits +/// the request with Result.Fail(tenant_mismatch). The PostgreSQL RLS +/// session-variable wiring (app.tenant_id / app.organization_id +/// via a DbConnectionInterceptor) lights up in Packet 7 when the +/// resolver middleware lands. +/// +/// +/// Phase 02a Packet 3 ships the assertion shell. Until +/// Packet 7 lands the resolver middleware every request runs against +/// ; this behavior surfaces the fact +/// loudly so no handler reads an unresolved context by accident. Packet 7 +/// flips the default registration to the real resolver and adds the RLS +/// interceptor line below the assertion. +/// +public sealed class TenantContextBehavior( + ITenantContext tenantContext) + : IPipelineBehavior + where TRequest : notnull + where TResponse : IResultBase +{ + private static readonly Error TenantMismatchError = new( + new LocalizedMessage("lockey_tenant_mismatch")); + + public Task Handle( + TRequest request, + RequestHandlerDelegate next, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(next); + + if (!tenantContext.IsResolved && !AllowsUnresolvedContext(typeof(TRequest))) + { + return Task.FromResult(Result.FailFor(TenantMismatchError)); + } + + // TODO(2026-05-21, @platform, phase-02a-packet-7): set the PostgreSQL + // RLS GUCs via a DbConnectionInterceptor (transaction-local + // set_config('app.tenant_id', ..., true) / + // set_config('app.organization_id', ..., true)). The interceptor + // lands together with the tenant-owned schema + RLS policies. + + return next(); + } + + /// + /// Opt-in escape hatch for commands that are explicitly platform-wide + /// (e.g. tenant provisioning). The default is "no exceptions"; opt-in + /// arrives in Packet 7 alongside the EnterPlatformAdminScope(reason) + /// surface. Until then the predicate is a stub returning false — + /// every request needs a resolved context to proceed. + /// + private static bool AllowsUnresolvedContext(Type requestType) => false; +} diff --git a/backend/src/LearnStack.Application/Pipeline/TransactionBehavior.cs b/backend/src/LearnStack.Application/Pipeline/TransactionBehavior.cs new file mode 100644 index 0000000..034da03 --- /dev/null +++ b/backend/src/LearnStack.Application/Pipeline/TransactionBehavior.cs @@ -0,0 +1,42 @@ +using LearnStack.SharedKernel.Results; +using MediatR; + +namespace LearnStack.Application.Pipeline; + +/// +/// MediatR pipeline behavior — step 6 of the canonical 8-step order +/// (ADR-0032 § Sub-decision 2). Opens a unit-of-work transaction; commits on +/// a success-Result and rolls back on a fail-Result or any +/// exception that bubbles through. Validation- and authorization-failed +/// requests short-circuit upstream and never open a transaction. +/// +/// +/// Phase 02a Packet 3 ships the shell: there is no +/// per-module DbContext yet (those land starting in Packet 6 + +/// Phase 03). The shell just delegates to the inner pipeline so the +/// canonical eight-step order can be wired now; Packet 6 swaps the body for +/// the real DbContext.Database.BeginTransactionAsync() + +/// commit-on-success-Result / rollback-on-failure pattern without changing +/// registration order. +/// +public sealed class TransactionBehavior + : IPipelineBehavior + where TRequest : notnull + where TResponse : IResultBase +{ + public Task Handle( + TRequest request, + RequestHandlerDelegate next, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(next); + + // TODO(2026-05-21, @platform, phase-02a-packet-6): open the UoW + // transaction (per-module DbContext.Database.BeginTransactionAsync), + // commit on success-Result, rollback on fail-Result, and rollback + + // rethrow on exception (preserving the rethrow that AuditLogBehavior + // owns one frame out). + + return next(); + } +} diff --git a/backend/src/LearnStack.Application/Pipeline/ValidationBehavior.cs b/backend/src/LearnStack.Application/Pipeline/ValidationBehavior.cs new file mode 100644 index 0000000..acc2800 --- /dev/null +++ b/backend/src/LearnStack.Application/Pipeline/ValidationBehavior.cs @@ -0,0 +1,104 @@ +using FluentValidation; +using LearnStack.SharedKernel.Localization; +using LearnStack.SharedKernel.Results; +using MediatR; + +namespace LearnStack.Application.Pipeline; + +/// +/// MediatR pipeline behavior — step 1 of the canonical 8-step order +/// (ADR-0032 § Sub-decision 2). Aggregates FluentValidation failures into +/// and returns +/// Result.FailFor<TResponse>(validation_failed, …); never +/// throws . The +/// ValidationBehavior_DoesNotThrow_ValidationException architecture +/// test enforces this end-to-end. +/// +/// +/// +/// The constraint +/// lets the behavior construct the concrete Result<T> shape via +/// without referencing the value type +/// (ADR-0032 § Sub-decision 3). +/// +/// +/// Field names are kept in their PascalCase property form here; the API +/// boundary (ProblemDetailsFactory) lower-cases them on the way out +/// per Standards 09 § Validation Errors. +/// +/// +public sealed class ValidationBehavior( + IEnumerable> validators) + : IPipelineBehavior + where TRequest : notnull + where TResponse : IResultBase +{ + private const string ValidationFailedKey = "lockey_validation_failed"; + + private readonly IValidator[] _validators = validators.ToArray(); + + public async Task Handle( + TRequest request, + RequestHandlerDelegate next, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(next); + + if (_validators.Length == 0) + { + return await next().ConfigureAwait(false); + } + + var context = new ValidationContext(request); + var failures = new List(); + + foreach (var validator in _validators) + { + var result = await validator.ValidateAsync(context, cancellationToken).ConfigureAwait(false); + if (!result.IsValid) + { + failures.AddRange(result.Errors); + } + } + + if (failures.Count == 0) + { + return await next().ConfigureAwait(false); + } + + var details = failures + .GroupBy(f => f.PropertyName, StringComparer.Ordinal) + .ToDictionary( + g => g.Key, + g => (IReadOnlyList)g + .Select(f => new LocalizedMessage(NormaliseKey(f.ErrorCode ?? f.ErrorMessage))) + .ToArray(), + StringComparer.Ordinal); + + var error = new Error( + new LocalizedMessage(ValidationFailedKey), + details); + + return Result.FailFor(error); + } + + /// + /// FluentValidation error codes are typically rule names ("NotEmpty", + /// "EmailValidator"); the API contract requires the + /// . If the validator's + /// ErrorCode already starts with lockey_ we trust it; + /// otherwise we coerce by lower-casing and prefixing so the constructor + /// invariant holds. + /// + private static string NormaliseKey(string raw) + { + if (string.IsNullOrWhiteSpace(raw)) + { + return "lockey_validation_failed"; + } + + return raw.StartsWith(LocalizedMessage.RequiredPrefix, StringComparison.Ordinal) + ? raw + : LocalizedMessage.RequiredPrefix + raw.ToLowerInvariant(); + } +} diff --git a/backend/src/LearnStack.Domain/LearnStack.Domain.csproj b/backend/src/LearnStack.Domain/LearnStack.Domain.csproj index 760d371..0bb1b8e 100644 --- a/backend/src/LearnStack.Domain/LearnStack.Domain.csproj +++ b/backend/src/LearnStack.Domain/LearnStack.Domain.csproj @@ -6,6 +6,17 @@ + + + diff --git a/backend/src/LearnStack.Infrastructure.ErrorTracking/AssemblyMarker.cs b/backend/src/LearnStack.Infrastructure.ErrorTracking/AssemblyMarker.cs new file mode 100644 index 0000000..bd3bcd0 --- /dev/null +++ b/backend/src/LearnStack.Infrastructure.ErrorTracking/AssemblyMarker.cs @@ -0,0 +1,3 @@ +namespace LearnStack.Infrastructure.ErrorTracking; + +public static class AssemblyMarker; diff --git a/backend/src/LearnStack.Infrastructure.ErrorTracking/ErrorTrackingOptions.cs b/backend/src/LearnStack.Infrastructure.ErrorTracking/ErrorTrackingOptions.cs new file mode 100644 index 0000000..a65f5a2 --- /dev/null +++ b/backend/src/LearnStack.Infrastructure.ErrorTracking/ErrorTrackingOptions.cs @@ -0,0 +1,29 @@ +namespace LearnStack.Infrastructure.ErrorTracking; + +/// +/// Configuration bound from ErrorTracking: in appsettings.json. +/// Per ADR-0032 § Sub-decision 9 the DSN itself comes from +/// ISecretProvider in non-Dev modes; the composition root reads the +/// secret and overwrites before +/// SentrySdk.Init. +/// +public sealed class ErrorTrackingOptions +{ + public const string SectionName = "ErrorTracking"; + + public SentrySettings Sentry { get; set; } = new(); + + public LocalFileOptions LocalFile { get; set; } = new(); +} + +public sealed class SentrySettings +{ + public string? Dsn { get; set; } + public string? Environment { get; set; } + public double TracesSampleRate { get; set; } = 0.1; +} + +public sealed class LocalFileOptions +{ + public string Directory { get; set; } = "/var/learnstack/errors/"; +} diff --git a/backend/src/LearnStack.Infrastructure.ErrorTracking/ErrorTrackingRegistration.cs b/backend/src/LearnStack.Infrastructure.ErrorTracking/ErrorTrackingRegistration.cs new file mode 100644 index 0000000..a38172d --- /dev/null +++ b/backend/src/LearnStack.Infrastructure.ErrorTracking/ErrorTrackingRegistration.cs @@ -0,0 +1,87 @@ +using LearnStack.SharedKernel.Hosting; +using LearnStack.SharedKernel.Observability; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Sentry; + +namespace LearnStack.Infrastructure.ErrorTracking; + +/// +/// Composition-root extension that branches on +/// to pick the right implementation per +/// ADR-0032 § Sub-decision 9. Sentry SDK initialisation happens here too — +/// modules never call SentrySdk.Init directly. +/// +public static class ErrorTrackingRegistration +{ + public static IServiceCollection AddLearnStackErrorTracking( + this IServiceCollection services, + IConfiguration configuration, + DeploymentMode deploymentMode) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configuration); + + services.Configure( + configuration.GetSection(ErrorTrackingOptions.SectionName)); + + var options = configuration.GetSection(ErrorTrackingOptions.SectionName) + .Get() ?? new ErrorTrackingOptions(); + + switch (deploymentMode) + { + case DeploymentMode.Development: + services.AddSingleton(); + break; + + case DeploymentMode.SaaS: + case DeploymentMode.Dedicated: + InitSentry(options.Sentry); + services.AddSingleton(); + break; + + case DeploymentMode.SelfHostedOnline: + if (!string.IsNullOrWhiteSpace(options.Sentry.Dsn)) + { + InitSentry(options.Sentry); + services.AddSingleton(); + } + else + { + services.AddSingleton(); + } + break; + + case DeploymentMode.SelfHostedAirGapped: + services.AddSingleton(sp => + new LocalFileErrorTracker( + options.LocalFile.Directory, + sp.GetRequiredService>())); + break; + + default: + throw new System.Diagnostics.UnreachableException( + $"Unhandled DeploymentMode '{deploymentMode}' in error-tracking composition."); + } + + return services; + } + + private static void InitSentry(SentrySettings options) + { + if (string.IsNullOrWhiteSpace(options.Dsn)) + { + throw new InvalidOperationException( + "DeploymentMode requires a Sentry DSN but ErrorTracking:Sentry:Dsn is empty. " + + "Provide the DSN via ISecretProvider (per ADR-0032 § Sub-decision 9)."); + } + + SentrySdk.Init(o => + { + o.Dsn = options.Dsn; + o.Environment = options.Environment; + o.TracesSampleRate = options.TracesSampleRate; + }); + } +} diff --git a/backend/src/LearnStack.Infrastructure.ErrorTracking/LearnStack.Infrastructure.ErrorTracking.csproj b/backend/src/LearnStack.Infrastructure.ErrorTracking/LearnStack.Infrastructure.ErrorTracking.csproj new file mode 100644 index 0000000..90c62f4 --- /dev/null +++ b/backend/src/LearnStack.Infrastructure.ErrorTracking/LearnStack.Infrastructure.ErrorTracking.csproj @@ -0,0 +1,23 @@ + + + + LearnStack.Infrastructure.ErrorTracking + + + + + + + + + + + + + + + + + diff --git a/backend/src/LearnStack.Infrastructure.ErrorTracking/LocalFileErrorTracker.cs b/backend/src/LearnStack.Infrastructure.ErrorTracking/LocalFileErrorTracker.cs new file mode 100644 index 0000000..5bb8737 --- /dev/null +++ b/backend/src/LearnStack.Infrastructure.ErrorTracking/LocalFileErrorTracker.cs @@ -0,0 +1,107 @@ +using System.Text.Json; +using LearnStack.SharedKernel.Observability; +using Microsoft.Extensions.Logging; + +namespace LearnStack.Infrastructure.ErrorTracking; + +/// +/// implementation that writes each +/// captured exception as a JSON envelope under the configured directory. +/// Selected by the composition root when +/// DeploymentMode.SelfHostedAirGapped — the runbook explains how to +/// ship those files off-network later if the customer ever wants to. +/// +/// +/// File names use a sortable timestamp + the trace id when present, so +/// operators see new captures at the bottom of the directory listing. +/// Failures of the write itself (full disk, permissions) are logged and +/// swallowed; air-gapped capture is best-effort by definition. +/// +internal sealed class LocalFileErrorTracker : IErrorTrackingProvider +{ + private readonly string _directory; + private readonly ILogger _logger; + private static readonly JsonSerializerOptions SerializerOptions = new() + { + WriteIndented = false, + }; + + public LocalFileErrorTracker(string directory, ILogger logger) + { + ArgumentException.ThrowIfNullOrWhiteSpace(directory); + ArgumentNullException.ThrowIfNull(logger); + + _directory = directory; + _logger = logger; + Directory.CreateDirectory(_directory); + } + + public async ValueTask CaptureAsync( + Exception exception, + CapturedContext context, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(exception); + ArgumentNullException.ThrowIfNull(context); + + var envelope = new + { + timestamp = DateTimeOffset.UtcNow, + exception = new + { + type = exception.GetType().FullName, + message = exception.Message, + stackTrace = exception.StackTrace, + inner = exception.InnerException?.GetType().FullName, + }, + context.CorrelationId, + context.RequestPath, + context.RequestMethod, + context.TenantId, + context.OrganizationId, + context.UserId, + context.ModuleName, + additionalTags = context.AdditionalTags, + }; + + var safeCorrelation = SanitiseForFileName(context.CorrelationId); + var fileName = $"{DateTimeOffset.UtcNow:yyyyMMdd-HHmmss-fff}-{safeCorrelation}.json"; + var path = Path.Combine(_directory, fileName); + + try + { + await using var stream = File.Create(path); + await JsonSerializer.SerializeAsync(stream, envelope, SerializerOptions, cancellationToken) + .ConfigureAwait(false); + } +#pragma warning disable CA1031 // Air-gapped capture is best-effort; swallow the write failure but log it. + catch (Exception writeFailure) +#pragma warning restore CA1031 + { + LogWriteFailure(_logger, path, writeFailure); + } + } + + private static string SanitiseForFileName(string? correlationId) + { + if (string.IsNullOrWhiteSpace(correlationId)) + { + return "noid"; + } + + Span buffer = stackalloc char[correlationId.Length]; + for (var i = 0; i < correlationId.Length; i++) + { + var c = correlationId[i]; + buffer[i] = char.IsLetterOrDigit(c) || c == '-' ? c : '_'; + } + + return new string(buffer); + } + + private static readonly Action LogWriteFailure = + LoggerMessage.Define( + LogLevel.Warning, + new EventId(1, nameof(LogWriteFailure)), + "LocalFileErrorTracker failed to write capture envelope to {Path}."); +} diff --git a/backend/src/LearnStack.Infrastructure.ErrorTracking/NoOpErrorTracker.cs b/backend/src/LearnStack.Infrastructure.ErrorTracking/NoOpErrorTracker.cs new file mode 100644 index 0000000..49dbeab --- /dev/null +++ b/backend/src/LearnStack.Infrastructure.ErrorTracking/NoOpErrorTracker.cs @@ -0,0 +1,18 @@ +using LearnStack.SharedKernel.Observability; + +namespace LearnStack.Infrastructure.ErrorTracking; + +/// +/// implementation that discards capture +/// requests silently. Selected by the composition root when +/// DeploymentMode.Development — no external egress, and the local +/// developer sees the exception in their console / IDE without needing +/// Sentry running. +/// +internal sealed class NoOpErrorTracker : IErrorTrackingProvider +{ + public ValueTask CaptureAsync( + Exception exception, + CapturedContext context, + CancellationToken cancellationToken = default) => ValueTask.CompletedTask; +} diff --git a/backend/src/LearnStack.Infrastructure.ErrorTracking/Properties/AssemblyInfo.cs b/backend/src/LearnStack.Infrastructure.ErrorTracking/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..44fe94b --- /dev/null +++ b/backend/src/LearnStack.Infrastructure.ErrorTracking/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("LearnStack.Tests.Unit")] diff --git a/backend/src/LearnStack.Infrastructure.ErrorTracking/SentryErrorTracker.cs b/backend/src/LearnStack.Infrastructure.ErrorTracking/SentryErrorTracker.cs new file mode 100644 index 0000000..edbf90d --- /dev/null +++ b/backend/src/LearnStack.Infrastructure.ErrorTracking/SentryErrorTracker.cs @@ -0,0 +1,75 @@ +using LearnStack.SharedKernel.Observability; +using Sentry; + +namespace LearnStack.Infrastructure.ErrorTracking; + +/// +/// implementation that dispatches to +/// Sentry. The Sentry hub is supplied by the SDK once +/// SentrySdk.Init has been called by the composition root. +/// +/// +/// Per ADR-0032 § Sub-decision 9 this is the only sanctioned site that +/// references the Sentry SDK; modules import +/// instead. The architecture test +/// Modules_Do_Not_Reference_Sentry_SDK_Directly enforces the scope. +/// +internal sealed class SentryErrorTracker : IErrorTrackingProvider +{ + public ValueTask CaptureAsync( + Exception exception, + CapturedContext context, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(exception); + ArgumentNullException.ThrowIfNull(context); + + SentrySdk.CaptureException(exception, scope => + { + if (context.TenantId is { } tenantId) + { + scope.SetTag("tenant.id", tenantId.ToString()); + } + + if (context.OrganizationId is { } orgId) + { + scope.SetTag("organization.id", orgId.ToString()); + } + + if (context.UserId is { } userId) + { + scope.User = new SentryUser { Id = userId.ToString() }; + } + + if (!string.IsNullOrWhiteSpace(context.CorrelationId)) + { + scope.SetTag("correlation.id", context.CorrelationId); + } + + if (!string.IsNullOrWhiteSpace(context.ModuleName)) + { + scope.SetTag("module", context.ModuleName); + } + + if (!string.IsNullOrWhiteSpace(context.RequestPath)) + { + scope.SetTag("http.route", context.RequestPath); + } + + if (!string.IsNullOrWhiteSpace(context.RequestMethod)) + { + scope.SetTag("http.method", context.RequestMethod); + } + + if (context.AdditionalTags is not null) + { + foreach (var (key, value) in context.AdditionalTags) + { + scope.SetTag(key, value); + } + } + }); + + return ValueTask.CompletedTask; + } +} diff --git a/backend/src/LearnStack.Infrastructure.Observability/AssemblyMarker.cs b/backend/src/LearnStack.Infrastructure.Observability/AssemblyMarker.cs new file mode 100644 index 0000000..e661d24 --- /dev/null +++ b/backend/src/LearnStack.Infrastructure.Observability/AssemblyMarker.cs @@ -0,0 +1,7 @@ +namespace LearnStack.Infrastructure.Observability; + +/// +/// Reflection seam — gives assembly scanners (architecture tests, +/// composition-root extension wiring) a non-generic type to anchor against. +/// +public static class AssemblyMarker; diff --git a/backend/src/LearnStack.Infrastructure.Observability/LearnStack.Infrastructure.Observability.csproj b/backend/src/LearnStack.Infrastructure.Observability/LearnStack.Infrastructure.Observability.csproj new file mode 100644 index 0000000..64d8c3f --- /dev/null +++ b/backend/src/LearnStack.Infrastructure.Observability/LearnStack.Infrastructure.Observability.csproj @@ -0,0 +1,23 @@ + + + + LearnStack.Infrastructure.Observability + + + + + + + + + + + + + + + diff --git a/backend/src/LearnStack.Infrastructure.Observability/ObservabilityRegistration.cs b/backend/src/LearnStack.Infrastructure.Observability/ObservabilityRegistration.cs new file mode 100644 index 0000000..806b204 --- /dev/null +++ b/backend/src/LearnStack.Infrastructure.Observability/ObservabilityRegistration.cs @@ -0,0 +1,27 @@ +using LearnStack.SharedKernel.Tenancy; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace LearnStack.Infrastructure.Observability; + +/// +/// Composition-root extension that registers the singleton +/// + . +/// The OpenTelemetry tracing pipeline itself is wired in +/// LearnStack.Api.Composition.CrossCuttingFoundationExtensions; this +/// extension only registers the types the pipeline depends on, so the +/// singleton lifetimes are correct before the SDK builds. +/// +public static class ObservabilityRegistration +{ + public static IServiceCollection AddLearnStackObservabilityServices( + this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + services.TryAddSingleton(); + services.TryAddSingleton(); + + return services; + } +} diff --git a/backend/src/LearnStack.Infrastructure.Observability/TenantContextAccessor.cs b/backend/src/LearnStack.Infrastructure.Observability/TenantContextAccessor.cs new file mode 100644 index 0000000..e8dde9d --- /dev/null +++ b/backend/src/LearnStack.Infrastructure.Observability/TenantContextAccessor.cs @@ -0,0 +1,23 @@ +using LearnStack.SharedKernel.Tenancy; + +namespace LearnStack.Infrastructure.Observability; + +/// +/// Singleton, -backed implementation of +/// . Per ADR-0032 § Sub-decision 10: +/// cross-cutting infrastructure (OTel span processor, Serilog enricher, +/// Sentry enricher) reads the current tenant context through this accessor +/// instead of injecting the request-scoped +/// directly — the lifetime mismatch (singleton processor versus scoped +/// context) would otherwise fail at startup. +/// +internal sealed class TenantContextAccessor : ITenantContextAccessor +{ + private static readonly AsyncLocal CurrentContext = new(); + + public ITenantContext? Current + { + get => CurrentContext.Value; + set => CurrentContext.Value = value; + } +} diff --git a/backend/src/LearnStack.Infrastructure.Observability/TenantContextSpanProcessor.cs b/backend/src/LearnStack.Infrastructure.Observability/TenantContextSpanProcessor.cs new file mode 100644 index 0000000..31605a7 --- /dev/null +++ b/backend/src/LearnStack.Infrastructure.Observability/TenantContextSpanProcessor.cs @@ -0,0 +1,64 @@ +using System.Diagnostics; +using LearnStack.SharedKernel.Tenancy; +using OpenTelemetry; + +namespace LearnStack.Infrastructure.Observability; + +/// +/// Singleton OpenTelemetry span processor that enriches every started +/// with the cross-cutting correlation tags +/// (tenant.id, organization.id, user.id, +/// correlation.id, module) read from the singleton +/// . Per ADR-0032 § Sub-decision 10 the +/// processor must never throw — auto-instrumentation +/// libraries create warm-up activities before any handler scope has set the +/// accessor. +/// +/// +/// The architecture test +/// OTel_Pipeline_Includes_TenantContextSpanProcessor asserts the +/// processor is registered on the tracing pipeline. The unit test +/// TenantContextSpanProcessor_DoesNotThrow_When_Context_Missing +/// asserts the no-throw contract. +/// +public sealed class TenantContextSpanProcessor(ITenantContextAccessor accessor) + : BaseProcessor +{ + private readonly ITenantContextAccessor _accessor = accessor + ?? throw new ArgumentNullException(nameof(accessor)); + + public override void OnStart(Activity data) + { + ArgumentNullException.ThrowIfNull(data); + + var context = _accessor.Current; + if (context is null) + { + return; + } + + if (context.IsResolved) + { + data.SetTag("tenant.id", context.TenantId); + if (context.OrganizationId is { } orgId) + { + data.SetTag("organization.id", orgId); + } + + if (context.UserId is { } userId) + { + data.SetTag("user.id", userId.Value); + } + } + + if (!string.IsNullOrWhiteSpace(context.CorrelationId)) + { + data.SetTag("correlation.id", context.CorrelationId); + } + + if (!string.IsNullOrWhiteSpace(context.ModuleName)) + { + data.SetTag("module", context.ModuleName); + } + } +} diff --git a/backend/src/LearnStack.Infrastructure.Resilience/AssemblyMarker.cs b/backend/src/LearnStack.Infrastructure.Resilience/AssemblyMarker.cs new file mode 100644 index 0000000..2a6011c --- /dev/null +++ b/backend/src/LearnStack.Infrastructure.Resilience/AssemblyMarker.cs @@ -0,0 +1,3 @@ +namespace LearnStack.Infrastructure.Resilience; + +public static class AssemblyMarker; diff --git a/backend/src/LearnStack.Infrastructure.Resilience/LearnStack.Infrastructure.Resilience.csproj b/backend/src/LearnStack.Infrastructure.Resilience/LearnStack.Infrastructure.Resilience.csproj new file mode 100644 index 0000000..cb121b6 --- /dev/null +++ b/backend/src/LearnStack.Infrastructure.Resilience/LearnStack.Infrastructure.Resilience.csproj @@ -0,0 +1,20 @@ + + + + LearnStack.Infrastructure.Resilience + + + + + + + + + + + + + + + + diff --git a/backend/src/LearnStack.Infrastructure.Resilience/Properties/AssemblyInfo.cs b/backend/src/LearnStack.Infrastructure.Resilience/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..44fe94b --- /dev/null +++ b/backend/src/LearnStack.Infrastructure.Resilience/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("LearnStack.Tests.Unit")] diff --git a/backend/src/LearnStack.Infrastructure.Resilience/ProviderResilience.cs b/backend/src/LearnStack.Infrastructure.Resilience/ProviderResilience.cs new file mode 100644 index 0000000..2d7685c --- /dev/null +++ b/backend/src/LearnStack.Infrastructure.Resilience/ProviderResilience.cs @@ -0,0 +1,89 @@ +using LearnStack.SharedKernel.Errors; +using LearnStack.SharedKernel.Resilience; +using Polly; +using Polly.CircuitBreaker; +using Polly.Retry; +using Polly.Timeout; + +namespace LearnStack.Infrastructure.Resilience; + +/// +/// Default implementation that +/// assembles a Polly v8 from the supplied +/// . Per ADR-0032 § Sub-decision 5 every +/// provider adapter consumes one of these in its constructor and routes +/// outbound calls through . +/// +/// +/// The pipeline order — retry → circuit breaker → timeout → bulkhead — is +/// the Polly recommended ordering: retries see the underlying failure; +/// the breaker opens against sustained failure ratios; the timeout bounds +/// a single attempt; the bulkhead caps concurrent in-flight calls so a +/// slow upstream cannot starve the host. Retry only applies when the +/// exception is a non-client or a +/// transient . +/// +internal sealed class ProviderResilience : IProviderResilience + where TPort : class +{ + public ProviderResilience(string portName, ResilienceOptions options) + { + ArgumentException.ThrowIfNullOrWhiteSpace(portName); + ArgumentNullException.ThrowIfNull(options); + + PortName = portName; + Pipeline = BuildPipeline(options); + } + + public ResiliencePipeline Pipeline { get; } + + public string PortName { get; } + + private static ResiliencePipeline BuildPipeline(ResilienceOptions options) + { + var builder = new ResiliencePipelineBuilder(); + + if (options.Retry.Enabled && options.Retry.MaxAttempts > 0) + { + builder.AddRetry(new RetryStrategyOptions + { + ShouldHandle = new PredicateBuilder() + .Handle() + .Handle(static ex => !ex.IsClientError), + MaxRetryAttempts = options.Retry.MaxAttempts, + Delay = TimeSpan.FromSeconds(options.Retry.DelaySeconds), + BackoffType = DelayBackoffType.Exponential, + UseJitter = options.Retry.UseJitter, + }); + } + + if (options.CircuitBreaker.Enabled) + { + builder.AddCircuitBreaker(new CircuitBreakerStrategyOptions + { + ShouldHandle = new PredicateBuilder() + .Handle() + .Handle(static ex => !ex.IsClientError), + FailureRatio = options.CircuitBreaker.FailureRatio, + SamplingDuration = TimeSpan.FromSeconds(options.CircuitBreaker.SamplingDurationSeconds), + MinimumThroughput = options.CircuitBreaker.MinimumThroughput, + BreakDuration = TimeSpan.FromSeconds(options.CircuitBreaker.BreakDurationSeconds), + }); + } + + if (options.Timeout.Enabled && options.Timeout.TotalSeconds > 0) + { + builder.AddTimeout(new TimeoutStrategyOptions + { + Timeout = TimeSpan.FromSeconds(options.Timeout.TotalSeconds), + }); + } + + // Bulkhead is not provided by Polly v8 out of the box — Microsoft's + // Resilience extensions ship a rate-limiter strategy that fills the + // role. Lit up by AddProviderResilience when Bulkhead is configured; + // see ProviderResilienceRegistration for the wiring. + + return builder.Build(); + } +} diff --git a/backend/src/LearnStack.Infrastructure.Resilience/ProviderResilienceRegistration.cs b/backend/src/LearnStack.Infrastructure.Resilience/ProviderResilienceRegistration.cs new file mode 100644 index 0000000..47f2288 --- /dev/null +++ b/backend/src/LearnStack.Infrastructure.Resilience/ProviderResilienceRegistration.cs @@ -0,0 +1,47 @@ +using LearnStack.SharedKernel.Resilience; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace LearnStack.Infrastructure.Resilience; + +/// +/// Composition-root extension that registers a single +/// for the given . +/// The configuration shape is fixed in +/// ADR-0032 +/// — Resilience:<portName>:. +/// +/// +/// +/// The decorator wiring itself lives on the per-adapter AddProviderAdapter +/// path (added together with the first real adapter; see +/// add-provider-adapter skill). Phase 02a Packet 3 ships the +/// resilience socket only — adapters consume +/// from their constructor and route +/// outbound calls through Pipeline.ExecuteAsync. Subsequent packets +/// can layer a Scrutor / DynamicProxy-based decorator on top without +/// changing the socket shape. +/// +/// +public static class ProviderResilienceRegistration +{ + public static IServiceCollection AddProviderResilience( + this IServiceCollection services, + IConfiguration configuration, + string portName) + where TPort : class + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configuration); + ArgumentException.ThrowIfNullOrWhiteSpace(portName); + + var options = configuration + .GetSection($"Resilience:{portName}") + .Get() ?? new ResilienceOptions(); + + services.AddSingleton>( + _ => new ProviderResilience(portName, options)); + + return services; + } +} diff --git a/backend/src/LearnStack.SharedKernel/Errors/DomainException.cs b/backend/src/LearnStack.SharedKernel/Errors/DomainException.cs new file mode 100644 index 0000000..5baea9b --- /dev/null +++ b/backend/src/LearnStack.SharedKernel/Errors/DomainException.cs @@ -0,0 +1,34 @@ +using LearnStack.SharedKernel.Localization; +using LearnStack.SharedKernel.Results; + +namespace LearnStack.SharedKernel.Errors; + +/// +/// Programmer-error exception. Raised only when an aggregate's +/// invariant is bypassed by a programming mistake — never for expected +/// business-rule violations (those return +/// Result.Fail(business_rule_violation, …) per +/// Standards 09 § Domain Exceptions). +/// +/// +/// The Roslyn analyzer LearnStackException-DomainExceptionThrow flags +/// every throw new DomainException(...) in Domain + Application +/// projects (Warning in Phase 02a, Error after Phase 03 exit). The companion +/// architecture test Domain_Methods_Do_Not_Throw_For_Expected_Cases +/// asserts the analyzer report is empty per module. +/// +public sealed class DomainException : LearnStackException +{ + private static readonly Error DefaultError = new( + new LocalizedMessage("lockey_business_rule_violation")); + + public DomainException(string message, Exception? innerException = null) + : base(DefaultError, message, innerException) + { + } + + public DomainException(Error error, string message, Exception? innerException = null) + : base(error, message, innerException) + { + } +} diff --git a/backend/src/LearnStack.SharedKernel/Errors/InfrastructureException.cs b/backend/src/LearnStack.SharedKernel/Errors/InfrastructureException.cs new file mode 100644 index 0000000..5cd6653 --- /dev/null +++ b/backend/src/LearnStack.SharedKernel/Errors/InfrastructureException.cs @@ -0,0 +1,25 @@ +using LearnStack.SharedKernel.Localization; +using LearnStack.SharedKernel.Results; + +namespace LearnStack.SharedKernel.Errors; + +/// +/// Transient infrastructure fault (database connection, Valkey, SeaweedFS, +/// outbox dispatcher transport). Retryable per Standards 09 § Retry vs Don't +/// Retry. Captured to IErrorTrackingProvider at the L1 handler. +/// +public class InfrastructureException : LearnStackException +{ + private static readonly Error DefaultError = new( + new LocalizedMessage("lockey_dependency_unavailable")); + + public InfrastructureException(string message, Exception? innerException = null) + : base(DefaultError, message, innerException) + { + } + + public InfrastructureException(Error error, string message, Exception? innerException = null) + : base(error, message, innerException) + { + } +} diff --git a/backend/src/LearnStack.SharedKernel/Errors/LearnStackException.cs b/backend/src/LearnStack.SharedKernel/Errors/LearnStackException.cs new file mode 100644 index 0000000..7854981 --- /dev/null +++ b/backend/src/LearnStack.SharedKernel/Errors/LearnStackException.cs @@ -0,0 +1,36 @@ +using LearnStack.SharedKernel.Results; + +namespace LearnStack.SharedKernel.Errors; + +/// +/// Base class for every exception LearnStack itself raises. Per +/// ADR-0032 +/// § Sub-decision 4 and +/// Standards 09 § Hierarchy: +/// exceptions are reserved for unexpected failures (bugs, transient +/// infrastructure faults, contract violations). Expected outcomes return +/// instead. +/// +/// +/// Carrying the structured at the exception site lets +/// the L1 IExceptionHandler map straight to RFC 7807 Problem Details +/// without re-deriving the code from the exception type. Subclasses pass the +/// appropriate stock lockey_*-keyed through +/// their constructors. +/// +public abstract class LearnStackException : Exception +{ + protected LearnStackException(Error error, string message, Exception? innerException = null) + : base(message, innerException) + { + ArgumentNullException.ThrowIfNull(error); + Error = error; + } + + /// + /// The stable the L1 handler projects to the + /// Problem Details body. Error.Code drives the HTTP status mapping + /// (Standards 09 § Result Type). + /// + public Error Error { get; } +} diff --git a/backend/src/LearnStack.SharedKernel/Errors/ProviderException.cs b/backend/src/LearnStack.SharedKernel/Errors/ProviderException.cs new file mode 100644 index 0000000..7dcf87d --- /dev/null +++ b/backend/src/LearnStack.SharedKernel/Errors/ProviderException.cs @@ -0,0 +1,65 @@ +using LearnStack.SharedKernel.Localization; +using LearnStack.SharedKernel.Results; + +namespace LearnStack.SharedKernel.Errors; + +/// +/// Wraps an upstream provider failure surfaced at the adapter boundary. +/// Per ADR-0032 +/// § Sub-decision 5 every adapter under +/// LearnStack.Infrastructure.<Adapter> translates SDK exception +/// types into the appropriate subclass; the +/// architecture test Adapters_Wrap_Provider_Exceptions enforces that +/// SDK exception types never leave the adapter assembly. +/// +/// +/// The flag splits the Sentry-capture boundary +/// (Standards 09 § Sentry vs OpenTelemetry — Error Capture Boundary): +/// true for 4xx upstream (provider's user-mistake, no Sentry capture), +/// false for 5xx upstream / timeouts (Sentry-captured infra failure). +/// +public class ProviderException : LearnStackException +{ + private static readonly Error DefaultError = new( + new LocalizedMessage("lockey_dependency_unavailable")); + + public ProviderException( + string providerName, + string message, + bool isClientError, + Exception? innerException = null) + : base(DefaultError, message, innerException) + { + ArgumentException.ThrowIfNullOrWhiteSpace(providerName); + ProviderName = providerName; + IsClientError = isClientError; + } + + public ProviderException( + Error error, + string providerName, + string message, + bool isClientError, + Exception? innerException = null) + : base(error, message, innerException) + { + ArgumentException.ThrowIfNullOrWhiteSpace(providerName); + ProviderName = providerName; + IsClientError = isClientError; + } + + /// + /// Stable provider identifier (e.g. "livekit", "stripe", + /// "meilisearch") tagged on metrics / spans / Sentry events. Must + /// not leak to end users (Standards 09 § Provider Failures). + /// + public string ProviderName { get; } + + /// + /// true when the upstream response is a 4xx-equivalent (the + /// adapter caller passed bad input). The L1 handler skips Sentry capture + /// for client errors. false when the upstream returned 5xx / + /// timeout — captured to Sentry as an infra fault. + /// + public bool IsClientError { get; } +} diff --git a/backend/src/LearnStack.SharedKernel/Errors/TenantContextMissingException.cs b/backend/src/LearnStack.SharedKernel/Errors/TenantContextMissingException.cs new file mode 100644 index 0000000..6329d67 --- /dev/null +++ b/backend/src/LearnStack.SharedKernel/Errors/TenantContextMissingException.cs @@ -0,0 +1,22 @@ +using LearnStack.SharedKernel.Localization; +using LearnStack.SharedKernel.Results; + +namespace LearnStack.SharedKernel.Errors; + +/// +/// Thrown when application code requires a resolved tenant context but the +/// ambient ITenantContext.IsResolved is false. Reached only via +/// programmer-error paths — the request pipeline's TenantContextBehavior +/// asserts the context up front, so this exception escapes mainly from +/// background workers / outbox handlers that forgot to populate it. +/// +public sealed class TenantContextMissingException : LearnStackException +{ + private static readonly Error DefaultError = new( + new LocalizedMessage("lockey_tenant_mismatch")); + + public TenantContextMissingException(string message, Exception? innerException = null) + : base(DefaultError, message, innerException) + { + } +} diff --git a/backend/src/LearnStack.SharedKernel/Hosting/DeploymentMode.cs b/backend/src/LearnStack.SharedKernel/Hosting/DeploymentMode.cs new file mode 100644 index 0000000..db19dce --- /dev/null +++ b/backend/src/LearnStack.SharedKernel/Hosting/DeploymentMode.cs @@ -0,0 +1,24 @@ +namespace LearnStack.SharedKernel.Hosting; + +/// +/// The deployment shape the composition root branches on per +/// ADR-0020 +/// and the +/// Standards 20 § Composition +/// Root and Deployment Mode table. SelfHosted is split into two +/// values so phone-home and signed-license-key entitlement providers can be +/// picked at startup without runtime branching. +/// +/// +/// Modules never read this enum directly. The composition +/// root selects provider implementations exactly once; the architecture test +/// Modules_Do_Not_Reference_DeploymentMode enforces the rule. +/// +public enum DeploymentMode +{ + Development, + SaaS, + Dedicated, + SelfHostedOnline, + SelfHostedAirGapped, +} diff --git a/backend/src/LearnStack.SharedKernel/LearnStack.SharedKernel.csproj b/backend/src/LearnStack.SharedKernel/LearnStack.SharedKernel.csproj index a677d49..766c544 100644 --- a/backend/src/LearnStack.SharedKernel/LearnStack.SharedKernel.csproj +++ b/backend/src/LearnStack.SharedKernel/LearnStack.SharedKernel.csproj @@ -26,6 +26,12 @@ that build-time hook only - SharedKernel does not use any EF Core type directly. --> + + + diff --git a/backend/src/LearnStack.SharedKernel/Observability/IErrorTrackingProvider.cs b/backend/src/LearnStack.SharedKernel/Observability/IErrorTrackingProvider.cs new file mode 100644 index 0000000..6230fdf --- /dev/null +++ b/backend/src/LearnStack.SharedKernel/Observability/IErrorTrackingProvider.cs @@ -0,0 +1,42 @@ +namespace LearnStack.SharedKernel.Observability; + +/// +/// Sanctioned entry point for error capture. Per +/// ADR-0032 +/// § Sub-decision 9 the L1 IExceptionHandler is the only +/// production caller; modules never import Sentry.SentrySdk directly. +/// The composition root selects the implementation by DeploymentMode +/// (): +/// NoOpErrorTracker for Development, SentryErrorTracker for +/// SaaS / Dedicated / SelfHostedOnline (DSN via ISecretProvider), +/// LocalFileErrorTracker for SelfHostedAirGapped. +/// +/// +/// The capture boundary itself (which exceptions go here and which only tag +/// the OTel span) lives in +/// LearnStack.Api.Common.LearnStackExceptionHandler.ShouldCapture per +/// Standards 09 § Sentry vs OpenTelemetry — Error Capture Boundary. +/// +public interface IErrorTrackingProvider +{ + ValueTask CaptureAsync( + Exception exception, + CapturedContext context, + CancellationToken cancellationToken = default); +} + +/// +/// Snapshot of cross-cutting tags every error capture flows with. The L1 +/// handler builds it from the current HttpContext + the singleton +/// ITenantContextAccessor; offline capture sites (the local-file +/// tracker, future worker hosts) build it themselves. +/// +public sealed record CapturedContext( + string? CorrelationId, + string? RequestPath, + string? RequestMethod, + Guid? TenantId, + Guid? OrganizationId, + Guid? UserId, + string? ModuleName, + IReadOnlyDictionary? AdditionalTags = null); diff --git a/backend/src/LearnStack.SharedKernel/Resilience/IProviderResilience.cs b/backend/src/LearnStack.SharedKernel/Resilience/IProviderResilience.cs new file mode 100644 index 0000000..e57c7c9 --- /dev/null +++ b/backend/src/LearnStack.SharedKernel/Resilience/IProviderResilience.cs @@ -0,0 +1,40 @@ +using Polly; + +namespace LearnStack.SharedKernel.Resilience; + +/// +/// Carrier for the Polly v8 that wraps a +/// provider adapter (ILiveClassProvider, IPaymentProvider, +/// IStorageProvider, ISearchProvider, …). Per +/// ADR-0032 +/// § Sub-decision 5 every adapter receives one of these in its +/// constructor and routes outbound calls through . +/// +/// The port interface the resilience policy is keyed +/// to (e.g. ILiveClassProvider). Used as a DI discriminator only — +/// the type itself is not consumed at runtime. +/// +/// +/// The pipeline is built once from appsettings.Resilience:<PortName>: +/// (Standards 09 § Provider Resilience — Polly v8 ResiliencePipeline) with +/// retry (exponential backoff + jitter), circuit breaker, timeout, and +/// bulkhead policies. +/// +/// +/// Hub HTTP clients (IEntitlementProvider, IUsageReporter, +/// IHubTenantSync) are excluded from this pattern — +/// their resilience lives inside the mTLS + signed-JWT + HMAC wrapper per +/// ADR-0019. +/// +/// +#pragma warning disable CA1040 // Avoid empty interfaces — the generic type parameter is the DI discriminator. +public interface IProviderResilience +#pragma warning restore CA1040 + where TPort : class +{ + /// The pre-built Polly v8 pipeline; thread-safe and meant to be reused. + ResiliencePipeline Pipeline { get; } + + /// The configuration section name ("liveclass", "payment", …). + string PortName { get; } +} diff --git a/backend/src/LearnStack.SharedKernel/Resilience/ResilienceOptions.cs b/backend/src/LearnStack.SharedKernel/Resilience/ResilienceOptions.cs new file mode 100644 index 0000000..e97b893 --- /dev/null +++ b/backend/src/LearnStack.SharedKernel/Resilience/ResilienceOptions.cs @@ -0,0 +1,51 @@ +namespace LearnStack.SharedKernel.Resilience; + +/// +/// Configuration shape bound from appsettings.Resilience:<portName>: +/// per ADR-0032 § Sub-decision 5. The decorator reads one of these per +/// provider port and assembles the Polly v8 pipeline. +/// +/// +/// Defaults match the conservative-but-useful shape from Standards 09 +/// § Provider Resilience (retry up to 3 attempts with jitter, breaker after +/// 50 % failure ratio over 30 s, single-attempt timeout 10 s, no bulkhead). +/// +public sealed class ResilienceOptions +{ + public RetryOptions Retry { get; set; } = new(); + + public CircuitBreakerOptions CircuitBreaker { get; set; } = new(); + + public TimeoutOptions Timeout { get; set; } = new(); + + public BulkheadOptions? Bulkhead { get; set; } +} + +public sealed class RetryOptions +{ + public int MaxAttempts { get; set; } = 3; + public double DelaySeconds { get; set; } = 1.0; + public bool UseJitter { get; set; } = true; + public bool Enabled { get; set; } = true; +} + +public sealed class CircuitBreakerOptions +{ + public double FailureRatio { get; set; } = 0.5; + public double SamplingDurationSeconds { get; set; } = 30; + public int MinimumThroughput { get; set; } = 10; + public double BreakDurationSeconds { get; set; } = 30; + public bool Enabled { get; set; } = true; +} + +public sealed class TimeoutOptions +{ + public double TotalSeconds { get; set; } = 10; + public bool Enabled { get; set; } = true; +} + +public sealed class BulkheadOptions +{ + public int MaxConcurrency { get; set; } = 100; + public int QueueLength { get; set; } +} diff --git a/backend/src/LearnStack.SharedKernel/Tenancy/ITenantContext.cs b/backend/src/LearnStack.SharedKernel/Tenancy/ITenantContext.cs new file mode 100644 index 0000000..19db264 --- /dev/null +++ b/backend/src/LearnStack.SharedKernel/Tenancy/ITenantContext.cs @@ -0,0 +1,65 @@ +using LearnStack.SharedKernel.Identifiers; + +namespace LearnStack.SharedKernel.Tenancy; + +/// +/// Request-scoped tenant + organization + user context handed to MediatR +/// handlers, EF interceptors, and the audit pipeline. Populated at scope +/// start by TenantResolverMiddleware (HTTP), HubCorrelationMiddleware +/// (/api/internal/*), the Hangfire JobActivator (background jobs), +/// and the outbox / inbox handler scope (integration events). Modules never +/// write to this contract — they read it through DI. +/// +/// +/// Phase 02a Packet 3 ships the contract. The real population sites land in +/// Packet 7 (TenantResolverMiddleware) and Phase 02b (Hangfire + outbox +/// handler scope). Pre-population, the ambient context resolves to a +/// composition-root-provided UnresolvedTenantContext singleton whose +/// is false. +/// +public interface ITenantContext +{ + /// + /// true once the resolution pipeline has populated tenant + (where + /// applicable) organization. TenantContextBehavior short-circuits + /// the request with Result.Fail(tenant_mismatch) when this is + /// false. + /// + bool IsResolved { get; } + + /// + /// The resolved tenant. Reading on an unresolved context throws + /// — callers gate on + /// first. + /// + Guid TenantId { get; } + + /// + /// The resolved organization within the tenant, when the request targets + /// an [OrganizationScoped] resource. null for tenant-wide + /// requests. + /// + Guid? OrganizationId { get; } + + /// + /// The acting user, when authenticated. null for anonymous / + /// system-issued requests (background jobs, outbox handlers). + /// + UserId? UserId { get; } + + /// + /// W3C traceparent string ("00-<trace>-<span>-<flags>") + /// that threads through HTTP / outbox / Hangfire / Hub envelopes. The + /// observability stack reads this from the singleton accessor; modules + /// never set it directly. + /// + string? CorrelationId { get; } + + /// + /// Logical module that owns the current request (e.g. "education", + /// "classroom"). Tagged onto spans by the + /// TenantContextSpanProcessor; helpful when filtering Tempo / + /// Grafana queries. + /// + string? ModuleName { get; } +} diff --git a/backend/src/LearnStack.SharedKernel/Tenancy/ITenantContextAccessor.cs b/backend/src/LearnStack.SharedKernel/Tenancy/ITenantContextAccessor.cs new file mode 100644 index 0000000..97fd3bd --- /dev/null +++ b/backend/src/LearnStack.SharedKernel/Tenancy/ITenantContextAccessor.cs @@ -0,0 +1,29 @@ +namespace LearnStack.SharedKernel.Tenancy; + +/// +/// Singleton, AsyncLocal<ITenantContext?>-backed accessor that +/// gives cross-cutting infrastructure (OTel span processor, Serilog enricher, +/// Sentry enricher) a way to read the current tenant context without +/// injecting the request-scoped — which would +/// fail the singleton-vs-scoped lifetime gate. +/// +/// +/// +/// Modules never write to this accessor. Population is +/// owned by the resolution sites listed in ADR-0032 § Sub-decision 10: +/// TenantResolverMiddleware (HTTP), HubCorrelationMiddleware +/// (/api/internal/*), Hangfire JobActivator (background jobs), +/// outbox / inbox handler scope (integration events). +/// +/// +/// is null outside any resolved scope (warm-up +/// activities created during startup, background tasks before any handler +/// scope opened) — readers must handle the null case rather than +/// throw. The TenantContextSpanProcessor_DoesNotThrow_When_Context_Missing +/// unit test guards the OTel processor's behaviour. +/// +/// +public interface ITenantContextAccessor +{ + ITenantContext? Current { get; set; } +} diff --git a/backend/src/LearnStack.SharedKernel/Tenancy/UnresolvedTenantContext.cs b/backend/src/LearnStack.SharedKernel/Tenancy/UnresolvedTenantContext.cs new file mode 100644 index 0000000..a7ac804 --- /dev/null +++ b/backend/src/LearnStack.SharedKernel/Tenancy/UnresolvedTenantContext.cs @@ -0,0 +1,35 @@ +using LearnStack.SharedKernel.Identifiers; + +namespace LearnStack.SharedKernel.Tenancy; + +/// +/// Default registered at the composition root +/// so modules can inject the contract before any resolution middleware has +/// populated the request. is false; reading +/// raises so +/// the caller cannot accidentally proceed with a zero . +/// +/// +/// The real population sites (per ADR-0032 § Sub-decision 10) overwrite the +/// scoped instance once they resolve. Until Packet 7 lands +/// TenantResolverMiddleware, every request runs against this default — +/// the TenantContextBehavior short-circuits with +/// Result.Fail(tenant_mismatch) before any handler runs. +/// +public sealed class UnresolvedTenantContext : ITenantContext +{ + public static UnresolvedTenantContext Instance { get; } = new(); + + public bool IsResolved => false; + + public Guid TenantId => throw new InvalidOperationException( + "TenantId is not available on an unresolved tenant context. Gate reads on IsResolved."); + + public Guid? OrganizationId => null; + + public UserId? UserId => null; + + public string? CorrelationId => null; + + public string? ModuleName => null; +} diff --git a/backend/tests/LearnStack.Tests.Architecture/CrossCuttingFoundationTests.cs b/backend/tests/LearnStack.Tests.Architecture/CrossCuttingFoundationTests.cs new file mode 100644 index 0000000..60b6f8d --- /dev/null +++ b/backend/tests/LearnStack.Tests.Architecture/CrossCuttingFoundationTests.cs @@ -0,0 +1,257 @@ +using System.Reflection; +using FluentAssertions; +using LearnStack.Api.Composition; +using LearnStack.Application.Pipeline; +using LearnStack.Infrastructure.Observability; +using LearnStack.SharedKernel.Hosting; +using LearnStack.SharedKernel.Observability; +using MediatR; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using NetArchTest.Rules; +using OpenTelemetry.Trace; +using Xunit; + +namespace LearnStack.Tests.Architecture; + +/// +/// Cross-cutting architecture rules per +/// ADR-0032 +/// and +/// Standards 21 § Cross-cutting. +/// The catalogue is the canonical reference for every identifier below. +/// +public sealed class CrossCuttingFoundationTests +{ + private static readonly string[] ModuleAssemblyShapes = + [ + "LearnStack.Modules.Tenancy.Application", + "LearnStack.Modules.Tenancy.Domain", + "LearnStack.Modules.Tenancy.Infrastructure", + "LearnStack.Modules.Identity.Application", + "LearnStack.Modules.Identity.Domain", + "LearnStack.Modules.Identity.Infrastructure", + "LearnStack.Modules.Customization.Application", + "LearnStack.Modules.Customization.Domain", + "LearnStack.Modules.Customization.Infrastructure", + "LearnStack.Modules.Audit.Application", + "LearnStack.Modules.Audit.Domain", + "LearnStack.Modules.Audit.Infrastructure", + "LearnStack.Modules.Content.Application", + "LearnStack.Modules.Content.Domain", + "LearnStack.Modules.Content.Infrastructure", + "LearnStack.Modules.Media.Application", + "LearnStack.Modules.Media.Domain", + "LearnStack.Modules.Media.Infrastructure", + "LearnStack.Modules.Education.Application", + "LearnStack.Modules.Education.Domain", + "LearnStack.Modules.Education.Infrastructure", + ]; + + [Fact] + public void MediatR_Pipeline_Order_Matches_Canonical_Sequence() + { + // ADR-0032 § Sub-decision 2 — outermost (validation) first, + // innermost (handler) last. The architecture test reflects on the + // DI registration order MediatR emits via AddBehavior(...). + var services = new ServiceCollection(); + services.AddLearnStackMediatRPipeline(); + + var behaviorTypes = services + .Where(d => d.ServiceType.IsGenericType + && d.ServiceType.GetGenericTypeDefinition() == typeof(IPipelineBehavior<,>) + && d.ImplementationType is not null) + .Select(d => d.ImplementationType!.GetGenericTypeDefinition()) + .ToArray(); + + behaviorTypes.Should().Equal( + MediatRPipelineRegistration.CanonicalBehaviorOrder.ToArray(), + "ADR-0032 § Sub-decision 2 pins the eight-step order; the catalogue entry " + + "MediatR_Pipeline_Order_Matches_Canonical_Sequence is the canonical name. " + + "Changing the order requires a new ADR."); + } + + [Fact] + public void IExceptionHandler_Registered_AtStartup() + { + // ADR-0032 § Sub-decision 1 — every host registers + // LearnStackExceptionHandler via AddExceptionHandler(). + using var application = BuildMinimalApiHost(); + + var registered = application.Services + .GetServices() + .Select(h => h.GetType()) + .ToArray(); + + registered.Should().Contain(typeof(LearnStack.Api.Common.LearnStackExceptionHandler), + "the L1 handler is the only sanctioned catch site below the framework " + + "(ADR-0032 § Sub-decision 1)."); + } + + [Fact] + public void OTel_Pipeline_Includes_TenantContextSpanProcessor() + { + // ADR-0032 § Sub-decision 10 — the processor enriches every span + // (auto-instrumented and manual) with the correlation tags. If the + // composition root removes it, Tempo queries lose the per-tenant + // axis. + using var application = BuildMinimalApiHost(); + + // The processor is registered as a singleton so the OTel tracing + // pipeline can resolve it via AddProcessor(); confirming both + // the type registration and the IConfigureTracerProviderBuilder + // pipeline-attach is what catches a regression. + var processor = application.Services.GetService(); + processor.Should().NotBeNull( + "AddOpenTelemetry().WithTracing(...).AddProcessor() " + + "must remain wired (ADR-0032 § Sub-decision 10)."); + + // The tracer provider triggers processor construction at build + // time — resolving it ensures the pipeline successfully attached + // every processor in the closure, including ours. + var tracerProvider = application.Services.GetService(); + tracerProvider.Should().NotBeNull( + "AddOpenTelemetry().WithTracing(...) must register a TracerProvider singleton."); + } + + [Fact] + public void Logging_Goes_Through_Microsoft_Extensions_Logging() + { + // Standards 10 § Stack — modules log through ILogger; + // Serilog.ILogger is the implementation seam at the composition + // root and must not be imported from module assemblies. + foreach (var name in ModuleAssemblyShapes) + { + var assembly = TryLoadAssembly(name); + if (assembly is null) + { + // Phase 02a packets do not necessarily fill every module + // assembly with code yet; an empty assembly is a vacuous + // pass. + continue; + } + + var result = Types.InAssembly(assembly) + .Should() + .NotHaveDependencyOn("Serilog") + .GetResult(); + + result.IsSuccessful.Should().BeTrue( + $"{name} references Serilog directly. Modules log through " + + "Microsoft.Extensions.Logging.ILogger; the Serilog impl is wired " + + "once at the composition root (ADR-0032 § Sub-decision 8)."); + } + } + + [Fact] + public void Modules_Do_Not_Reference_Sentry_SDK_Directly() + { + // ADR-0032 § Sub-decision 9 — only + // LearnStack.Infrastructure.ErrorTracking may reference the Sentry + // SDK. Modules call IErrorTrackingProvider instead. + foreach (var name in ModuleAssemblyShapes) + { + var assembly = TryLoadAssembly(name); + if (assembly is null) continue; + + var result = Types.InAssembly(assembly) + .Should() + .NotHaveDependencyOn("Sentry") + .GetResult(); + + result.IsSuccessful.Should().BeTrue( + $"{name} references the Sentry SDK directly. Use IErrorTrackingProvider " + + "(ADR-0032 § Sub-decision 9)."); + } + } + + [Fact] + public void Adapters_Wrap_Provider_Exceptions() + { + // ADR-0032 § Sub-decision 5 — provider SDK exception types + // (LiveKit.NET.LiveKitException, Stripe.StripeException, + // Meilisearch.MeilisearchApiError, …) never escape + // LearnStack.Infrastructure.. Phase 02a has no adapters + // yet, so this test asserts the constraint vacuously by walking + // the well-known SDK namespaces against every non-adapter + // assembly. + var nonAdapterAssemblies = ModuleAssemblyShapes + .Append("LearnStack.SharedKernel") + .Append("LearnStack.Domain") + .Append("LearnStack.Application") + .Append("LearnStack.Application.Contracts") + .Append("LearnStack.Api") + .Select(TryLoadAssembly) + .Where(a => a is not null) + .ToArray(); + + string[] forbiddenSdkNamespaces = + [ + "LiveKit", + "Stripe", + "Meilisearch", + "Iyzipay", + ]; + + foreach (var assembly in nonAdapterAssemblies) + { + foreach (var sdkPrefix in forbiddenSdkNamespaces) + { + var result = Types.InAssembly(assembly!) + .Should() + .NotHaveDependencyOn(sdkPrefix) + .GetResult(); + + result.IsSuccessful.Should().BeTrue( + $"{assembly!.GetName().Name} references {sdkPrefix}. SDK exception types " + + "must stay inside LearnStack.Infrastructure.."); + } + } + } + + [Fact] + public void IErrorTrackingProvider_Is_Singleton() + { + // ADR-0032 § Sub-decision 9 — the composition root registers a + // single IErrorTrackingProvider implementation per DeploymentMode. + // The boundary L1 handler resolves it once at startup. + using var application = BuildMinimalApiHost(); + + var providers = application.Services + .GetServices() + .ToArray(); + + providers.Should().HaveCount(1, + "exactly one IErrorTrackingProvider is registered per DeploymentMode " + + "(ADR-0032 § Sub-decision 9)."); + } + + private static WebApplication BuildMinimalApiHost() + { + var builder = WebApplication.CreateBuilder([]); + // Empty configuration is fine for Development — the NoOp tracker + // does not need a Sentry DSN, and the OTLP exporter is silently + // skipped when Telemetry:OtlpEndpoint is absent. + builder.Configuration.AddInMemoryCollection( + new Dictionary(StringComparer.Ordinal) + { + ["Deployment:Mode"] = nameof(DeploymentMode.Development), + }); + builder.AddLearnStackCrossCuttingFoundation(DeploymentMode.Development); + return builder.Build(); + } + + private static Assembly? TryLoadAssembly(string assemblyName) + { + try + { + return Assembly.Load(assemblyName); + } + catch (FileNotFoundException) + { + return null; + } + } +} diff --git a/backend/tests/LearnStack.Tests.Architecture/LearnStack.Tests.Architecture.csproj b/backend/tests/LearnStack.Tests.Architecture/LearnStack.Tests.Architecture.csproj index cd92c4c..a51e8b1 100644 --- a/backend/tests/LearnStack.Tests.Architecture/LearnStack.Tests.Architecture.csproj +++ b/backend/tests/LearnStack.Tests.Architecture/LearnStack.Tests.Architecture.csproj @@ -22,6 +22,9 @@ + + + diff --git a/backend/tests/LearnStack.Tests.Unit/Api/Common/HttpStatusMapTests.cs b/backend/tests/LearnStack.Tests.Unit/Api/Common/HttpStatusMapTests.cs new file mode 100644 index 0000000..2777bfe --- /dev/null +++ b/backend/tests/LearnStack.Tests.Unit/Api/Common/HttpStatusMapTests.cs @@ -0,0 +1,62 @@ +using FluentAssertions; +using LearnStack.Api.Common; +using LearnStack.SharedKernel.Errors; +using Xunit; + +namespace LearnStack.Tests.Unit.Api.Common; + +/// +/// HttpStatusMap mirrors Standards 09 § Result Type's error-code table. +/// Adding a new code requires updating both places; these tests catch the +/// drift. +/// +public sealed class HttpStatusMapTests +{ + [Theory] + [InlineData("validation_failed", 400)] + [InlineData("unauthorized", 401)] + [InlineData("forbidden", 403)] + [InlineData("feature_disabled", 403)] + [InlineData("not_found", 404)] + [InlineData("tenant_mismatch", 404)] + [InlineData("concurrency_conflict", 409)] + [InlineData("business_rule_violation", 409)] + [InlineData("rate_limited", 429)] + [InlineData("dependency_unavailable", 503)] + [InlineData("unknown_code", 500)] + public void For_Code_Matches_StandardsTable(string code, int expected) + { + HttpStatusMap.For(code).Should().Be(expected); + } + + [Fact] + public void For_ProviderException_With_ClientError_Maps_To_400() + { + var ex = new ProviderException( + providerName: "test", + message: "bad input", + isClientError: true); + + HttpStatusMap.For(ex).Should().Be(400); + } + + [Fact] + public void For_ProviderException_With_ServerError_Maps_To_503() + { + var ex = new ProviderException( + providerName: "test", + message: "upstream down", + isClientError: false); + + HttpStatusMap.For(ex).Should().Be(503); + } + + [Fact] + public void For_OperationCanceled_Maps_To_499() + { + // 499 (client closed request) — Standards 10 § Tracing leaves + // OperationCanceled spans Unset; the HTTP surface returns 499 for + // the client disconnect. + HttpStatusMap.For(new OperationCanceledException()).Should().Be(499); + } +} diff --git a/backend/tests/LearnStack.Tests.Unit/Api/Common/ResultExtensionsTests.cs b/backend/tests/LearnStack.Tests.Unit/Api/Common/ResultExtensionsTests.cs new file mode 100644 index 0000000..cb5f47e --- /dev/null +++ b/backend/tests/LearnStack.Tests.Unit/Api/Common/ResultExtensionsTests.cs @@ -0,0 +1,43 @@ +using FluentAssertions; +using LearnStack.Api.Common; +using LearnStack.SharedKernel.Localization; +using LearnStack.SharedKernel.Results; +using Microsoft.AspNetCore.Mvc; +using Xunit; + +namespace LearnStack.Tests.Unit.Api.Common; + +/// +/// ResultExtensions.ToActionResult contract per ADR-0032 § Sub-decision 6 — +/// the sanctioned shape every controller endpoint uses. +/// +public sealed class ResultExtensionsTests +{ + [Fact] + public void Success_Maps_To_OkObjectResult() + { + var result = Result.Ok("payload"); + + var action = result.ToActionResult(); + + action.Should().BeOfType() + .Which.Value.Should().Be("payload"); + } + + [Fact] + public void Failure_Maps_To_ProblemDetails_With_Correct_Status() + { + var error = new Error(new LocalizedMessage("lockey_not_found")); + var result = Result.Fail(error); + + var action = result.ToActionResult(); + + var objectResult = action.Should().BeOfType().Which; + objectResult.StatusCode.Should().Be(404); + objectResult.Value.Should().BeOfType(); + var problem = (ProblemDetails)objectResult.Value!; + problem.Status.Should().Be(404); + problem.Extensions["code"].Should().Be("not_found"); + problem.Extensions["messageKey"].Should().Be("lockey_not_found"); + } +} diff --git a/backend/tests/LearnStack.Tests.Unit/Application/Pipeline/AuditLogBehaviorTests.cs b/backend/tests/LearnStack.Tests.Unit/Application/Pipeline/AuditLogBehaviorTests.cs new file mode 100644 index 0000000..d94ccf9 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Unit/Application/Pipeline/AuditLogBehaviorTests.cs @@ -0,0 +1,53 @@ +using FluentAssertions; +using LearnStack.Application.Pipeline; +using LearnStack.SharedKernel.Results; +using MediatR; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace LearnStack.Tests.Unit.Application.Pipeline; + +/// +/// AuditLogBehavior shell contract per ADR-0032 § Sub-decision 2 + +/// ADR-0016 § Pipeline behavior order. The shell catches handler +/// exceptions and rethrows via ExceptionDispatchInfo (preserving the +/// original stack); the audit-write itself is deferred to Packet 9 when +/// IAuditStore lights up. +/// +public sealed class AuditLogBehaviorTests +{ + public sealed record DummyCommand : IRequest>; + + [Fact] + public async Task Passes_Through_Successful_Result() + { + var behavior = new AuditLogBehavior>( + NullLogger>>.Instance); + + RequestHandlerDelegate> next = () => + Task.FromResult(Result.Ok("ok")); + + var result = await behavior.Handle(new DummyCommand(), next, default); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be("ok"); + } + + [Fact] + public async Task Rethrows_Exception_From_Inner_Handler_Preserving_Stack() + { + var behavior = new AuditLogBehavior>( + NullLogger>>.Instance); + + var thrown = new InvalidOperationException("boom"); + RequestHandlerDelegate> next = () => throw thrown; + + var act = async () => await behavior.Handle(new DummyCommand(), next, default); + + // ExceptionDispatchInfo.Throw rethrows the original instance so + // reference equality still holds — the rethrow does not box a new + // wrapper exception. + var caught = await act.Should().ThrowAsync(); + caught.Which.Should().BeSameAs(thrown); + } +} diff --git a/backend/tests/LearnStack.Tests.Unit/Application/Pipeline/TenantContextBehaviorTests.cs b/backend/tests/LearnStack.Tests.Unit/Application/Pipeline/TenantContextBehaviorTests.cs new file mode 100644 index 0000000..e70dda0 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Unit/Application/Pipeline/TenantContextBehaviorTests.cs @@ -0,0 +1,39 @@ +using FluentAssertions; +using LearnStack.Application.Pipeline; +using LearnStack.SharedKernel.Results; +using LearnStack.SharedKernel.Tenancy; +using MediatR; +using Xunit; + +namespace LearnStack.Tests.Unit.Application.Pipeline; + +/// +/// TenantContextBehavior shell contract — short-circuits with +/// Result.Fail(tenant_mismatch) when the resolution stage has not +/// populated ITenantContext. Until Packet 7 lands the resolver this is the +/// loud-fail guard for any handler executed without context. +/// +public sealed class TenantContextBehaviorTests +{ + public sealed record DummyCommand : IRequest>; + + [Fact] + public async Task Short_Circuits_When_Context_Unresolved() + { + var behavior = new TenantContextBehavior>( + UnresolvedTenantContext.Instance); + + var called = false; + RequestHandlerDelegate> next = () => + { + called = true; + return Task.FromResult(Result.Ok("should not run")); + }; + + var result = await behavior.Handle(new DummyCommand(), next, default); + + called.Should().BeFalse(); + result.IsFailure.Should().BeTrue(); + result.Error!.Code.Should().Be("tenant_mismatch"); + } +} diff --git a/backend/tests/LearnStack.Tests.Unit/Application/Pipeline/ValidationBehaviorTests.cs b/backend/tests/LearnStack.Tests.Unit/Application/Pipeline/ValidationBehaviorTests.cs new file mode 100644 index 0000000..f23bdf7 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Unit/Application/Pipeline/ValidationBehaviorTests.cs @@ -0,0 +1,88 @@ +using FluentAssertions; +using FluentValidation; +using LearnStack.Application.Pipeline; +using LearnStack.SharedKernel.Results; +using MediatR; +using Xunit; + +namespace LearnStack.Tests.Unit.Application.Pipeline; + +/// +/// ValidationBehavior contract per ADR-0032 § Sub-decision 3 and Standards +/// 09 § Validation Errors. The behavior never throws +/// FluentValidation.ValidationException; it returns +/// Result.Fail(validation_failed, details) instead. +/// +public sealed class ValidationBehaviorTests +{ + public sealed record TestCommand(string Name) : IRequest>; + + private sealed class TestCommandValidator : AbstractValidator + { + public TestCommandValidator() + { + RuleFor(c => c.Name) + .NotEmpty() + .WithErrorCode("lockey_name_required"); + } + } + + [Fact] + public async Task Returns_Result_Fail_When_Validation_Fails() + { + var behavior = new ValidationBehavior>( + [new TestCommandValidator()]); + + RequestHandlerDelegate> next = () => + Task.FromResult(Result.Ok("should not reach")); + + var result = await behavior.Handle(new TestCommand(string.Empty), next, default); + + result.IsFailure.Should().BeTrue(); + result.Error.Should().NotBeNull(); + result.Error!.Code.Should().Be("validation_failed"); + result.Error.Details.Should().NotBeNull(); + // FluentValidation keys properties in their original PascalCase shape; + // ProblemDetailsFactory at the HTTP boundary projects to camelCase per + // Standards 09 § Validation Errors. + result.Error.Details!["Name"].Should().Contain(m => m.Key == "lockey_name_required"); + } + + [Fact] + public async Task Calls_Inner_Handler_When_Validation_Succeeds() + { + var behavior = new ValidationBehavior>( + [new TestCommandValidator()]); + + var called = false; + RequestHandlerDelegate> next = () => + { + called = true; + return Task.FromResult(Result.Ok("ok")); + }; + + var result = await behavior.Handle(new TestCommand("alice"), next, default); + + called.Should().BeTrue(); + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be("ok"); + } + + [Fact] + public async Task Never_Throws_ValidationException() + { + // ADR-0032 § Sub-decision 3 binding: the pipeline never raises + // FluentValidation.ValidationException; the architecture-test + // catalogue entry ValidationBehavior_DoesNotThrow_ValidationException + // cites this assertion. + var behavior = new ValidationBehavior>( + [new TestCommandValidator()]); + + RequestHandlerDelegate> next = () => + Task.FromResult(Result.Ok("unreachable")); + + var act = async () => await behavior.Handle(new TestCommand(string.Empty), next, default); + + await act.Should().NotThrowAsync(); + } +} diff --git a/backend/tests/LearnStack.Tests.Unit/Infrastructure/ErrorTracking/LocalFileErrorTrackerTests.cs b/backend/tests/LearnStack.Tests.Unit/Infrastructure/ErrorTracking/LocalFileErrorTrackerTests.cs new file mode 100644 index 0000000..43753d0 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Unit/Infrastructure/ErrorTracking/LocalFileErrorTrackerTests.cs @@ -0,0 +1,61 @@ +using System.Text.Json; +using FluentAssertions; +using LearnStack.Infrastructure.ErrorTracking; +using LearnStack.SharedKernel.Observability; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace LearnStack.Tests.Unit.Infrastructure.ErrorTracking; + +/// +/// LocalFileErrorTracker is the air-gapped capture path per ADR-0032 § +/// Sub-decision 9. The capture must be best-effort: write a JSON envelope +/// if possible, swallow filesystem failures so the L1 handler still +/// returns the Problem Details body to the client. +/// +public sealed class LocalFileErrorTrackerTests : IDisposable +{ + private readonly string _directory; + + public LocalFileErrorTrackerTests() + { + _directory = Path.Combine( + Path.GetTempPath(), + "learnstack-errortracker-tests", + Guid.NewGuid().ToString("N")); + } + + [Fact] + public async Task CaptureAsync_Writes_JsonEnvelope_To_ConfiguredDirectory() + { + var sut = new LocalFileErrorTracker(_directory, NullLogger.Instance); + var context = new CapturedContext( + CorrelationId: "00-aabb-ccdd-01", + RequestPath: "/v1/courses", + RequestMethod: "POST", + TenantId: Guid.NewGuid(), + OrganizationId: null, + UserId: null, + ModuleName: "education"); + + await sut.CaptureAsync(new InvalidOperationException("boom"), context); + + var files = Directory.GetFiles(_directory); + files.Should().HaveCount(1); + + var raw = await File.ReadAllTextAsync(files[0]); + var doc = JsonDocument.Parse(raw); + doc.RootElement.GetProperty("CorrelationId").GetString().Should().Be("00-aabb-ccdd-01"); + doc.RootElement.GetProperty("RequestPath").GetString().Should().Be("/v1/courses"); + doc.RootElement.GetProperty("exception").GetProperty("type").GetString() + .Should().Be(typeof(InvalidOperationException).FullName); + } + + public void Dispose() + { + if (Directory.Exists(_directory)) + { + Directory.Delete(_directory, recursive: true); + } + } +} diff --git a/backend/tests/LearnStack.Tests.Unit/Infrastructure/Observability/TenantContextSpanProcessorTests.cs b/backend/tests/LearnStack.Tests.Unit/Infrastructure/Observability/TenantContextSpanProcessorTests.cs new file mode 100644 index 0000000..c98a86f --- /dev/null +++ b/backend/tests/LearnStack.Tests.Unit/Infrastructure/Observability/TenantContextSpanProcessorTests.cs @@ -0,0 +1,88 @@ +using System.Diagnostics; +using FluentAssertions; +using LearnStack.Infrastructure.Observability; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Tenancy; +using Xunit; + +namespace LearnStack.Tests.Unit.Infrastructure.Observability; + +/// +/// Backs the catalogue entry +/// TenantContextSpanProcessor_DoesNotThrow_When_Context_Missing +/// (ADR-0032 § Sub-decision 10). The processor must no-op when the +/// singleton accessor is null — auto-instrumentation libraries create +/// warm-up activities before any handler scope opens. +/// +public sealed class TenantContextSpanProcessorTests +{ + [Fact] + public void OnStart_DoesNotThrow_When_Accessor_Current_Is_Null() + { + var accessor = new TestAccessor(current: null); + var processor = new TenantContextSpanProcessor(accessor); + + using var activity = new Activity("warm-up"); + activity.Start(); + + var act = () => processor.OnStart(activity); + + act.Should().NotThrow(); + } + + [Fact] + public void OnStart_Enriches_Activity_When_Context_Is_Resolved() + { + var tenantId = Guid.Parse("018f4d40-1234-7000-8000-000000000001"); + var organizationId = Guid.Parse("018f4d40-1234-7000-8000-000000000002"); + var userId = UserId.From(Guid.Parse("018f4d40-1234-7000-8000-000000000003")); + + var accessor = new TestAccessor(new TestTenantContext( + IsResolved: true, + TenantId: tenantId, + OrganizationId: organizationId, + UserId: userId, + CorrelationId: "00-aabbccdd-eeff0011-01", + ModuleName: "education")); + + var processor = new TenantContextSpanProcessor(accessor); + using var activity = new Activity("test-span"); + activity.Start(); + + processor.OnStart(activity); + + activity.GetTagItem("tenant.id").Should().Be(tenantId); + activity.GetTagItem("organization.id").Should().Be(organizationId); + activity.GetTagItem("user.id").Should().Be(userId.Value); + activity.GetTagItem("correlation.id").Should().Be("00-aabbccdd-eeff0011-01"); + activity.GetTagItem("module").Should().Be("education"); + } + + [Fact] + public void OnStart_DoesNotEnrich_TenantTags_When_Context_Is_Unresolved() + { + var accessor = new TestAccessor(UnresolvedTenantContext.Instance); + var processor = new TenantContextSpanProcessor(accessor); + + using var activity = new Activity("unresolved"); + activity.Start(); + + var act = () => processor.OnStart(activity); + + act.Should().NotThrow(); + activity.GetTagItem("tenant.id").Should().BeNull(); + } + + private sealed class TestAccessor(ITenantContext? current) : ITenantContextAccessor + { + public ITenantContext? Current { get; set; } = current; + } + + private sealed record TestTenantContext( + bool IsResolved, + Guid TenantId, + Guid? OrganizationId, + UserId? UserId, + string? CorrelationId, + string? ModuleName) : ITenantContext; +} diff --git a/backend/tests/LearnStack.Tests.Unit/Infrastructure/Resilience/ProviderResilienceTests.cs b/backend/tests/LearnStack.Tests.Unit/Infrastructure/Resilience/ProviderResilienceTests.cs new file mode 100644 index 0000000..871b0eb --- /dev/null +++ b/backend/tests/LearnStack.Tests.Unit/Infrastructure/Resilience/ProviderResilienceTests.cs @@ -0,0 +1,81 @@ +using FluentAssertions; +using LearnStack.Infrastructure.Resilience; +using LearnStack.SharedKernel.Errors; +using LearnStack.SharedKernel.Resilience; +using Xunit; + +namespace LearnStack.Tests.Unit.Infrastructure.Resilience; + +/// +/// ProviderResilience contract per ADR-0032 § Sub-decision 5. Phase 02a +/// Packet 3 ships the socket; these tests assert the pipeline is buildable +/// with the canonical options shape, retries non-client provider failures, +/// and skips retry for client errors. +/// +public sealed class ProviderResilienceTests +{ + private interface ITestPort + { + } + + [Fact] + public void Pipeline_Is_Built_With_Default_Options() + { + var sut = new ProviderResilience("test", new ResilienceOptions()); + + sut.PortName.Should().Be("test"); + sut.Pipeline.Should().NotBeNull(); + } + + [Fact] + public async Task Retry_Activates_On_Server_Side_ProviderException() + { + var options = new ResilienceOptions + { + Retry = new RetryOptions { MaxAttempts = 2, DelaySeconds = 0, UseJitter = false, Enabled = true }, + CircuitBreaker = new CircuitBreakerOptions { Enabled = false }, + Timeout = new TimeoutOptions { Enabled = false }, + }; + + var sut = new ProviderResilience("test", options); + var attempts = 0; + + var act = async () => await sut.Pipeline.ExecuteAsync(_ => + { + attempts++; + throw new ProviderException("test", "upstream 5xx", isClientError: false); +#pragma warning disable CS0162 // Unreachable code: makes the lambda's return type explicit. + return ValueTask.CompletedTask; +#pragma warning restore CS0162 + }); + + await act.Should().ThrowAsync(); + attempts.Should().Be(3, "MaxAttempts = 2 retries means 1 initial + 2 retries = 3 invocations"); + } + + [Fact] + public async Task Retry_Does_Not_Trigger_On_Client_Side_ProviderException() + { + var options = new ResilienceOptions + { + Retry = new RetryOptions { MaxAttempts = 5, DelaySeconds = 0, UseJitter = false, Enabled = true }, + CircuitBreaker = new CircuitBreakerOptions { Enabled = false }, + Timeout = new TimeoutOptions { Enabled = false }, + }; + + var sut = new ProviderResilience("test", options); + var attempts = 0; + + var act = async () => await sut.Pipeline.ExecuteAsync(_ => + { + attempts++; + throw new ProviderException("test", "bad input", isClientError: true); +#pragma warning disable CS0162 + return ValueTask.CompletedTask; +#pragma warning restore CS0162 + }); + + await act.Should().ThrowAsync(); + attempts.Should().Be(1, "client errors are not retried — Standards 09 § Retry vs Don't Retry"); + } +} diff --git a/backend/tests/LearnStack.Tests.Unit/LearnStack.Tests.Unit.csproj b/backend/tests/LearnStack.Tests.Unit/LearnStack.Tests.Unit.csproj index 14c940c..a711763 100644 --- a/backend/tests/LearnStack.Tests.Unit/LearnStack.Tests.Unit.csproj +++ b/backend/tests/LearnStack.Tests.Unit/LearnStack.Tests.Unit.csproj @@ -10,6 +10,10 @@ + + + + diff --git a/docs/roadmap/phase-02a-kernel-tenancy.md b/docs/roadmap/phase-02a-kernel-tenancy.md index a8cf727..44a0ceb 100644 --- a/docs/roadmap/phase-02a-kernel-tenancy.md +++ b/docs/roadmap/phase-02a-kernel-tenancy.md @@ -71,26 +71,86 @@ > for the EF Core + MediatR references SharedKernel requires. Unit / > architecture / contract suites all green in CI. > -> **Packet 3 — Cross-cutting foundation ⏳** -> Wires the [ADR-0032](../decisions/0032-exception-handling-logging-and-observability.md) -> surface end to end via the +> **Packet 3 — Cross-cutting foundation ✅** +> The [ADR-0032](../decisions/0032-exception-handling-logging-and-observability.md) +> surface is wired end to end via the > [wire-cross-cutting-foundation](../../.claude/skills/wire-cross-cutting-foundation/SKILL.md) -> skill: L1 `LearnStackExceptionHandler : IExceptionHandler`, 8-step MediatR -> pipeline shells (Validation / Logging / AuditLog / TenantContext / -> Authorization / Transaction / OutboxFlush / Handler — behaviors whose -> dependencies are not yet present are scaffolded as no-op shells that later -> packets light up), `Result.ToActionResult()` extension, `DomainException` -> Roslyn analyzer (warning class in Phase 02a, error class after Phase 03 exit), -> `IProviderResilience` decorator (Polly v8 ResiliencePipeline — retry + -> circuit breaker + timeout + bulkhead — config shape -> `appsettings.Resilience::`), Serilog primary logger + -> `WriteTo.OpenTelemetry(...)` sink, OpenTelemetry SDK with -> `AspNetCore` + `HttpClient` + `EntityFrameworkCore` instrumentation + -> `TenantContextSpanProcessor` + OTLP exporter, `IErrorTrackingProvider` -> socket with `NoOpErrorTracker` / `SentryErrorTracker` / `LocalFileErrorTracker` -> + composition-root branching by `DeploymentMode`. Mediator pipeline order -> backed by the `MediatR_Pipeline_Order_Matches_Canonical_Sequence` -> architecture test. +> skill. Shipped: +> +> - L1 `LearnStackExceptionHandler : IExceptionHandler` registered through +> `services.AddExceptionHandler()` + `app.UseExceptionHandler()`; +> `ShouldCapture(ex)` switch drives the Sentry-vs-OTel boundary per +> Standards 09 (`OperationCanceledException` + client-error +> `ProviderException` skip capture). +> - Eight-step MediatR pipeline in `LearnStack.Application/Pipeline/`: +> `Validation` + `Logging` are full implementations; `AuditLog` ships the +> try / `ExceptionDispatchInfo` rethrow shell (audit-write lights up in +> Packet 9 when `IAuditStore` lands); `TenantContext` shell short-circuits +> with `Result.Fail(tenant_mismatch)` until the resolver middleware arrives +> in Packet 7; `Authorization` / `Transaction` / `OutboxFlush` pass-through +> shells whose registration order is the binding part. Order encoded in +> `MediatRPipelineRegistration.CanonicalBehaviorOrder` and asserted by the +> `MediatR_Pipeline_Order_Matches_Canonical_Sequence` architecture test. +> - `Result.ToActionResult()` extension in `LearnStack.Api.Common` +> alongside `ProblemDetailsFactory` + `HttpStatusMap`; explicit at every +> future controller endpoint per Standards 09. +> - `LearnStackException` hierarchy in `LearnStack.SharedKernel/Errors/`: +> `DomainException`, `InfrastructureException`, `ProviderException` (with +> `IsClientError` flag), `TenantContextMissingException`. +> `LearnStackException-DomainExceptionThrow` Roslyn analyzer under +> `backend/analyzers/LearnStack.Analyzers/` flags `throw new DomainException` +> in `Domain` + `Application` (warning class in Phase 02a, escalates to +> error after Phase 03 exit per ADR-0032 § Sub-decision 4). +> - `IProviderResilience` socket in +> `LearnStack.SharedKernel/Resilience/` + Polly v8 +> `ResiliencePipeline` builder in +> `LearnStack.Infrastructure.Resilience/`. Pipeline = retry (only +> `InfrastructureException` + non-client `ProviderException`) → circuit +> breaker → timeout. Configuration shape: +> `appsettings.Resilience::` (sample lit up under +> `liveclass`, `payment`, `storage`, `search`). Per-adapter decoration is +> the adapter's responsibility — the socket is what adapters consume in +> Phase 02b+ via the [add-provider-adapter](../../.claude/skills/add-provider-adapter/SKILL.md) +> skill. Hub HTTP clients excluded per Sub-decision 5. +> - Serilog primary logger wired in `LearnStack.Api/Composition/CrossCuttingFoundationExtensions.cs` +> with `WriteTo.Console(RenderedCompactJsonFormatter)` + +> `WriteTo.OpenTelemetry(OTLP gRPC)`. The OTel `LoggerProvider` is +> intentionally **not** registered alongside per ADR-0032 § Sub-decision 8. +> - OpenTelemetry SDK with `AspNetCore` + `HttpClient` + +> `EntityFrameworkCore` instrumentations + the OTLP exporter, plus the +> singleton `TenantContextSpanProcessor` from +> `LearnStack.Infrastructure.Observability/` that enriches every span +> with `tenant.id` / `organization.id` / `user.id` / `correlation.id` / +> `module` from the singleton +> `ITenantContextAccessor` (`AsyncLocal`-backed). +> - `IErrorTrackingProvider` socket in `LearnStack.SharedKernel/Observability/` +> with three implementations in `LearnStack.Infrastructure.ErrorTracking/`: +> `NoOpErrorTracker` (Development), `SentryErrorTracker` (SaaS / Dedicated / +> SelfHostedOnline-with-DSN), `LocalFileErrorTracker` +> (SelfHostedAirGapped, writes JSON envelopes to a configured directory). +> Composition-root branching by `DeploymentMode` per Standards 20 table. +> Sentry SDK is referenced only by the ErrorTracking project — enforced +> by the `Modules_Do_Not_Reference_Sentry_SDK_Directly` architecture test. +> - Architecture tests green (`backend/tests/LearnStack.Tests.Architecture/CrossCuttingFoundationTests.cs`): +> `MediatR_Pipeline_Order_Matches_Canonical_Sequence`, +> `IExceptionHandler_Registered_AtStartup`, +> `OTel_Pipeline_Includes_TenantContextSpanProcessor`, +> `Logging_Goes_Through_Microsoft_Extensions_Logging`, +> `Modules_Do_Not_Reference_Sentry_SDK_Directly`, +> `Adapters_Wrap_Provider_Exceptions`, +> `IErrorTrackingProvider_Is_Singleton`. Unit tests green for +> `ValidationBehavior` (returns `Result.Fail(validation_failed)`, +> never throws `ValidationException`), `AuditLogBehavior` (preserves +> stack via `ExceptionDispatchInfo`), `TenantContextBehavior` +> (short-circuits unresolved context), `TenantContextSpanProcessor` +> (`OnStart` is null-safe and enriches resolved spans), +> `ProviderResilience` (retries non-client failures, skips +> client-error retries), `HttpStatusMap` (mirrors Standards 09 table), +> `ResultExtensions.ToActionResult()` (Problem Details shape), +> `LocalFileErrorTracker` (writes the JSON envelope). +> - `LearnStack.Tests.Unit` 110/110, `LearnStack.Tests.Architecture` 24/24, +> `LearnStack.Tests.Contract` 1/1, `LearnStack.Tests.Integration` 1/1 +> green under `CI=true`. > > **Packet 4 — API conventions ⏳** > REST + URL versioning (`/v1/...` per ADR-0024), Problem Details (RFC 7807) From 6023f67061a8247d045895c8da0a4e6f061d196b Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Thu, 21 May 2026 21:23:16 +0300 Subject: [PATCH 2/9] =?UTF-8?q?fix(phase-02a):=20packet=203=20review-1=20?= =?UTF-8?q?=E2=80=94=20ADR-0032=20contract=20gaps=20+=20cleanups?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the full review-1 findings from the cross-cutting foundation: Blockers - B1 Sentry DSN reads via the new ISecretProvider socket (SharedKernel/Secrets/) with ConfigurationSecretProvider as the Phase 02a default — Packet 5 swaps in DaprSecretProvider for Vault. Modules never read DSN from IConfiguration directly. (ADR-0032 § Sub-decision 9) - B2 Serilog pipeline gains CorrelationContextEnricher + RedactSensitiveFieldsEnricher (password / token / secret / DSN / JWT / authorization / SSN / TCKN / card-number / IBAN / CVV); LocalFileErrorTracker redacts the same set on AdditionalTags. Stack traces remain (modules must not put secrets in exception messages — Standards 11). Major - M1 ProblemDetailsFactory.For(Exception) routes status via HttpStatusMap.For(Exception) so ProviderException(IsClientError:true) maps to 400 instead of falling through to 503. - M2 L1 handler skips Activity.AddException for client-side ProviderException — SetStatus(Error) only, no exception event. Match the Standards 09 § Sentry vs OpenTelemetry table. - M3 All 14 module Domain + Application csproj files reference LearnStack.Analyzers via OutputItemType="Analyzer"; future `throw new DomainException(...)` in any module trips the analyzer. - M4 Polly bulkhead lit up via Polly.RateLimiting's AddRateLimiter(ConcurrencyLimiterOptions); BulkheadOptions is no longer dead config. Minor - N1 ProblemDetailsFactory.ToCamelCase handles nested paths (Address.Street → address.street) and acronyms (URLValue → urlValue) via JsonNamingPolicy.CamelCase. - N2 ToActionResult() returns ProblemDetailsActionResult that builds the body lazily inside ExecuteResultAsync — Instance + correlationId now populate from HttpContext without the controller threading it. - N3 / A6 LocalFileErrorTracker file names suffix a Guid for unique filenames in same-ms bursts; stackalloc capped at 128 chars so a multi-KB traceparent header cannot blow the stack. - A5 AuditLogBehavior catch filter excludes OperationCanceledException so client disconnects no longer churn warning logs / future audit rows. - A7 MediatRPipelineRegistration.CanonicalBehaviorOrder doc clarifies "7 behaviors + the handler = the 8 canonical steps". - A8 ProblemDetailsFactory Type URL trims the _failed suffix to match the Standards 09 § API Surface example. - A11 New WebApplicationFactory-based integration tests (LearnStack.Tests.Integration/CrossCuttingFoundationHttpTests) exercise the L1 handler + ValidationBehavior end-to-end via a synthetic test controller. Standards 21 catalogue updated to "unit + integration". - A12 LearnStackExceptionHandler is internal sealed (framework's AddExceptionHandler() instantiates it; tests reach via InternalsVisibleTo). - A13 TenantContextSpanProcessor stringifies Guid tags so wire format is stable across exporters. Suggestions - S1 MediatR_Pipeline_Order_Matches_Canonical_Sequence test asserts a hardcoded behavior sequence; the production list reorder cannot sneak past. - SU1 L1 handler skips body write on cancellation — the client has already disconnected. - SU4 TenantContextBehavior.AllowsUnresolvedContext TODO documents the Packet 7 marker-attribute seam ([AllowsUnresolvedTenantContext]). New architecture test - Modules_Do_Not_Reference_DeploymentMode — catalogue entry existed since ADR-0020 but had no implementation until now. Validation - dotnet build LearnStack.slnx (CI=true) → 0 warning, 0 error. - 142/142 tests green: Unit 111, Architecture 25, Integration 5, Contract 1. Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/Directory.Build.props | 10 +- backend/Directory.Packages.props | 3 + .../Common/LearnStackExceptionHandler.cs | 37 +++- .../Common/ProblemDetailsActionResult.cs | 39 ++++ .../Common/ProblemDetailsFactory.cs | 69 +++++-- .../LearnStack.Api/Common/ResultExtensions.cs | 18 +- .../CrossCuttingFoundationExtensions.cs | 28 ++- .../Pipeline/AuditLogBehavior.cs | 6 +- .../Pipeline/MediatRPipelineRegistration.cs | 11 +- .../Pipeline/TenantContextBehavior.cs | 10 + .../ErrorTrackingRegistration.cs | 34 ++- .../LocalFileErrorTracker.cs | 67 +++++- ...nStack.Infrastructure.Observability.csproj | 5 + .../ObservabilityRegistration.cs | 13 +- .../Serilog/CorrelationContextEnricher.cs | 66 ++++++ .../Serilog/RedactSensitiveFieldsEnricher.cs | 85 ++++++++ .../TenantContextSpanProcessor.cs | 11 +- ...earnStack.Infrastructure.Resilience.csproj | 2 + .../ProviderResilience.cs | 22 +- .../LearnStack.SharedKernel.csproj | 7 + .../Secrets/ConfigurationSecretProvider.cs | 29 +++ .../Secrets/ISecretProvider.cs | 35 ++++ ...earnStack.Modules.Audit.Application.csproj | 3 + .../LearnStack.Modules.Audit.Domain.csproj | 3 + ...rnStack.Modules.Content.Application.csproj | 3 + .../LearnStack.Modules.Content.Domain.csproj | 3 + ...k.Modules.Customization.Application.csproj | 3 + ...nStack.Modules.Customization.Domain.csproj | 3 + ...Stack.Modules.Education.Application.csproj | 3 + ...LearnStack.Modules.Education.Domain.csproj | 3 + ...nStack.Modules.Identity.Application.csproj | 3 + .../LearnStack.Modules.Identity.Domain.csproj | 3 + ...earnStack.Modules.Media.Application.csproj | 3 + .../LearnStack.Modules.Media.Domain.csproj | 3 + ...rnStack.Modules.Tenancy.Application.csproj | 3 + .../LearnStack.Modules.Tenancy.Domain.csproj | 3 + .../CrossCuttingFoundationTests.cs | 63 +++++- .../CrossCuttingFoundationHttpTests.cs | 194 ++++++++++++++++++ .../LearnStack.Tests.Integration.csproj | 6 + .../Api/Common/ResultExtensionsTests.cs | 81 +++++++- .../TenantContextSpanProcessorTests.cs | 9 +- docs/roadmap/phase-02a-kernel-tenancy.md | 80 +++++++- .../21-architecture-tests-catalogue.md | 25 ++- 43 files changed, 1029 insertions(+), 78 deletions(-) create mode 100644 backend/src/LearnStack.Api/Common/ProblemDetailsActionResult.cs create mode 100644 backend/src/LearnStack.Infrastructure.Observability/Serilog/CorrelationContextEnricher.cs create mode 100644 backend/src/LearnStack.Infrastructure.Observability/Serilog/RedactSensitiveFieldsEnricher.cs create mode 100644 backend/src/LearnStack.SharedKernel/Secrets/ConfigurationSecretProvider.cs create mode 100644 backend/src/LearnStack.SharedKernel/Secrets/ISecretProvider.cs create mode 100644 backend/tests/LearnStack.Tests.Integration/CrossCuttingFoundationHttpTests.cs diff --git a/backend/Directory.Build.props b/backend/Directory.Build.props index dc58aed..ce3cc65 100644 --- a/backend/Directory.Build.props +++ b/backend/Directory.Build.props @@ -61,10 +61,18 @@ CA2234 — `HttpClient.GetAsync(string)` overload is fine in test assertions; the Uri-overload preference is library-grade hygiene that costs readability in tests. + CA1711 — xunit collection-definition classes by convention end in + "Collection"; the rule was written for API surfaces, not + test scaffolding. + CA1822 — controller actions / endpoint methods that intentionally + throw for L1-handler integration tests don't access + instance data; making them static is fine but + ControllerBase conventions and ASP.NET routing prefer + instance methods. Real code-quality rules (CA1305 culture-invariant formatting, CA1861 static-readonly array reuse) STAY ON — fix violations in the code. --> - $(NoWarn);CA1707;CA1812;CA1515;CA1034;CA2234 + $(NoWarn);CA1707;CA1812;CA1515;CA1034;CA2234;CA1711;CA1822 diff --git a/backend/Directory.Packages.props b/backend/Directory.Packages.props index 4d1bc61..061491d 100644 --- a/backend/Directory.Packages.props +++ b/backend/Directory.Packages.props @@ -65,12 +65,14 @@ + + @@ -88,6 +90,7 @@ IProviderResilience. --> + + diff --git a/backend/src/LearnStack.Infrastructure.Observability/ObservabilityRegistration.cs b/backend/src/LearnStack.Infrastructure.Observability/ObservabilityRegistration.cs index 806b204..6774f90 100644 --- a/backend/src/LearnStack.Infrastructure.Observability/ObservabilityRegistration.cs +++ b/backend/src/LearnStack.Infrastructure.Observability/ObservabilityRegistration.cs @@ -1,3 +1,4 @@ +using LearnStack.Infrastructure.Observability.Serilog; using LearnStack.SharedKernel.Tenancy; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -6,11 +7,11 @@ namespace LearnStack.Infrastructure.Observability; /// /// Composition-root extension that registers the singleton -/// + . -/// The OpenTelemetry tracing pipeline itself is wired in -/// LearnStack.Api.Composition.CrossCuttingFoundationExtensions; this -/// extension only registers the types the pipeline depends on, so the -/// singleton lifetimes are correct before the SDK builds. +/// , the +/// , and the Serilog enrichers +/// ( + +/// ) so the Serilog and OTel +/// pipelines wired by LearnStack.Api can resolve them as singletons. /// public static class ObservabilityRegistration { @@ -21,6 +22,8 @@ public static IServiceCollection AddLearnStackObservabilityServices( services.TryAddSingleton(); services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); return services; } diff --git a/backend/src/LearnStack.Infrastructure.Observability/Serilog/CorrelationContextEnricher.cs b/backend/src/LearnStack.Infrastructure.Observability/Serilog/CorrelationContextEnricher.cs new file mode 100644 index 0000000..2dd2ef8 --- /dev/null +++ b/backend/src/LearnStack.Infrastructure.Observability/Serilog/CorrelationContextEnricher.cs @@ -0,0 +1,66 @@ +using LearnStack.SharedKernel.Tenancy; +using Serilog.Core; +using Serilog.Events; + +namespace LearnStack.Infrastructure.Observability.Serilog; + +/// +/// Serilog enricher that copies the cross-cutting correlation tags from +/// onto every : +/// tenant.id, organization.id, user.id, +/// correlation.id, module. Per ADR-0032 § Sub-decision 8 the +/// Serilog implementation owns the cross-cutting log shape; the same five +/// fields ride on every OTel span via +/// TenantContextSpanProcessor. +/// +/// +/// The accessor is queried on every event. When no scope has populated +/// the accessor (warm-up logs at process start, background tasks before +/// any handler scope opens), the enricher no-ops — matching the same +/// no-throw contract the OTel processor guarantees per ADR-0032 +/// § Sub-decision 10. +/// +public sealed class CorrelationContextEnricher(ITenantContextAccessor accessor) : ILogEventEnricher +{ + private readonly ITenantContextAccessor _accessor = accessor + ?? throw new ArgumentNullException(nameof(accessor)); + + public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory) + { + ArgumentNullException.ThrowIfNull(logEvent); + ArgumentNullException.ThrowIfNull(propertyFactory); + + var context = _accessor.Current; + if (context is null) return; + + if (context.IsResolved) + { + logEvent.AddOrUpdateProperty( + propertyFactory.CreateProperty("tenant.id", context.TenantId.ToString())); + + if (context.OrganizationId is { } orgId) + { + logEvent.AddOrUpdateProperty( + propertyFactory.CreateProperty("organization.id", orgId.ToString())); + } + + if (context.UserId is { } userId) + { + logEvent.AddOrUpdateProperty( + propertyFactory.CreateProperty("user.id", userId.Value.ToString())); + } + } + + if (!string.IsNullOrWhiteSpace(context.CorrelationId)) + { + logEvent.AddOrUpdateProperty( + propertyFactory.CreateProperty("correlation.id", context.CorrelationId)); + } + + if (!string.IsNullOrWhiteSpace(context.ModuleName)) + { + logEvent.AddOrUpdateProperty( + propertyFactory.CreateProperty("module", context.ModuleName)); + } + } +} diff --git a/backend/src/LearnStack.Infrastructure.Observability/Serilog/RedactSensitiveFieldsEnricher.cs b/backend/src/LearnStack.Infrastructure.Observability/Serilog/RedactSensitiveFieldsEnricher.cs new file mode 100644 index 0000000..efb194c --- /dev/null +++ b/backend/src/LearnStack.Infrastructure.Observability/Serilog/RedactSensitiveFieldsEnricher.cs @@ -0,0 +1,85 @@ +using Serilog.Core; +using Serilog.Events; + +namespace LearnStack.Infrastructure.Observability.Serilog; + +/// +/// Serilog enricher that scrubs sensitive log-scope properties before the +/// formatter touches them. Per +/// Standards 10 +/// § Logging Rules and +/// Standards 11 § Sensitive Data Exposure: +/// passwords, tokens, DSNs, JWTs, API keys, national identifiers, +/// authorization headers, full payment payloads must never reach the +/// console or the OTLP sink. +/// +/// +/// +/// The enricher rewrites matching properties in place to the constant +/// . A property is "sensitive" when its name +/// contains one of the case-insensitive tokens listed in +/// . The list is conservative; production +/// deployments can layer additional patterns by composing a richer +/// enricher. +/// +/// +/// Stack traces and exception messages are NOT redacted — they ride +/// through , which the enricher leaves +/// alone. Modules must follow Standards 11 (never put secrets in +/// exception messages) so the boundary is honest. +/// +/// +public sealed class RedactSensitiveFieldsEnricher : ILogEventEnricher +{ + public const string RedactedValue = "***REDACTED***"; + + private static readonly string[] SensitiveKeyTokens = + [ + "password", + "passwd", + "secret", + "token", + "apikey", + "api_key", + "authorization", + "auth_header", + "dsn", + "jwt", + "credential", + "ssn", + "tckn", + "iban", + "cardnumber", + "card_number", + "cvv", + "cvc", + ]; + + public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory) + { + ArgumentNullException.ThrowIfNull(logEvent); + ArgumentNullException.ThrowIfNull(propertyFactory); + + foreach (var propertyName in logEvent.Properties.Keys.ToArray()) + { + if (IsSensitive(propertyName)) + { + logEvent.AddOrUpdateProperty( + propertyFactory.CreateProperty(propertyName, RedactedValue)); + } + } + } + + private static bool IsSensitive(string propertyName) + { + foreach (var token in SensitiveKeyTokens) + { + if (propertyName.Contains(token, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } +} diff --git a/backend/src/LearnStack.Infrastructure.Observability/TenantContextSpanProcessor.cs b/backend/src/LearnStack.Infrastructure.Observability/TenantContextSpanProcessor.cs index 31605a7..42f4302 100644 --- a/backend/src/LearnStack.Infrastructure.Observability/TenantContextSpanProcessor.cs +++ b/backend/src/LearnStack.Infrastructure.Observability/TenantContextSpanProcessor.cs @@ -39,15 +39,20 @@ public override void OnStart(Activity data) if (context.IsResolved) { - data.SetTag("tenant.id", context.TenantId); + // OTel attribute types are string / long / double / bool / + // array. A bare Guid is ToString-projected at export time + // with no contract on format (some exporters use "D", others + // "N"). Pin the wire format here for parity with + // SentryErrorTracker and Loki dashboards. + data.SetTag("tenant.id", context.TenantId.ToString()); if (context.OrganizationId is { } orgId) { - data.SetTag("organization.id", orgId); + data.SetTag("organization.id", orgId.ToString()); } if (context.UserId is { } userId) { - data.SetTag("user.id", userId.Value); + data.SetTag("user.id", userId.Value.ToString()); } } diff --git a/backend/src/LearnStack.Infrastructure.Resilience/LearnStack.Infrastructure.Resilience.csproj b/backend/src/LearnStack.Infrastructure.Resilience/LearnStack.Infrastructure.Resilience.csproj index cb121b6..4a3dcf3 100644 --- a/backend/src/LearnStack.Infrastructure.Resilience/LearnStack.Infrastructure.Resilience.csproj +++ b/backend/src/LearnStack.Infrastructure.Resilience/LearnStack.Infrastructure.Resilience.csproj @@ -11,6 +11,8 @@ + + diff --git a/backend/src/LearnStack.Infrastructure.Resilience/ProviderResilience.cs b/backend/src/LearnStack.Infrastructure.Resilience/ProviderResilience.cs index 2d7685c..dc3fcac 100644 --- a/backend/src/LearnStack.Infrastructure.Resilience/ProviderResilience.cs +++ b/backend/src/LearnStack.Infrastructure.Resilience/ProviderResilience.cs @@ -1,7 +1,9 @@ +using System.Threading.RateLimiting; using LearnStack.SharedKernel.Errors; using LearnStack.SharedKernel.Resilience; using Polly; using Polly.CircuitBreaker; +using Polly.RateLimiting; using Polly.Retry; using Polly.Timeout; @@ -79,10 +81,22 @@ private static ResiliencePipeline BuildPipeline(ResilienceOptions options) }); } - // Bulkhead is not provided by Polly v8 out of the box — Microsoft's - // Resilience extensions ship a rate-limiter strategy that fills the - // role. Lit up by AddProviderResilience when Bulkhead is configured; - // see ProviderResilienceRegistration for the wiring. + // Bulkhead — Polly v8 maps "bulkhead" onto the rate-limiter + // strategy backed by System.Threading.RateLimiting.ConcurrencyLimiter. + // Only wired when MaxConcurrency > 0 so the default options shape + // (Bulkhead = null) leaves the pipeline unchanged. + if (options.Bulkhead is { MaxConcurrency: > 0 } bulkhead) + { + builder.AddRateLimiter(new RateLimiterStrategyOptions + { + DefaultRateLimiterOptions = new ConcurrencyLimiterOptions + { + PermitLimit = bulkhead.MaxConcurrency, + QueueLimit = Math.Max(0, bulkhead.QueueLength), + QueueProcessingOrder = QueueProcessingOrder.OldestFirst, + }, + }); + } return builder.Build(); } diff --git a/backend/src/LearnStack.SharedKernel/LearnStack.SharedKernel.csproj b/backend/src/LearnStack.SharedKernel/LearnStack.SharedKernel.csproj index 766c544..a580827 100644 --- a/backend/src/LearnStack.SharedKernel/LearnStack.SharedKernel.csproj +++ b/backend/src/LearnStack.SharedKernel/LearnStack.SharedKernel.csproj @@ -32,6 +32,13 @@ pre-built pipeline. SharedKernel owns the port-interface seam; the concrete builder lives in LearnStack.Infrastructure.Resilience. --> + + + diff --git a/backend/src/LearnStack.SharedKernel/Secrets/ConfigurationSecretProvider.cs b/backend/src/LearnStack.SharedKernel/Secrets/ConfigurationSecretProvider.cs new file mode 100644 index 0000000..9fae778 --- /dev/null +++ b/backend/src/LearnStack.SharedKernel/Secrets/ConfigurationSecretProvider.cs @@ -0,0 +1,29 @@ +using Microsoft.Extensions.Configuration; + +namespace LearnStack.SharedKernel.Secrets; + +/// +/// Default that delegates to +/// . Phase 02a Packet 3 ships this as the +/// composition-root default for every DeploymentMode; Packet 5 +/// swaps it for the Dapr-backed implementation when running against a +/// Vault-equipped environment. +/// +/// +/// The configuration layer already merges environment variables, user +/// secrets, and appsettings.{env}.json, so the default covers +/// developer workstations and CI without spinning up Vault. Production- +/// grade deployments override the registration in the composition root. +/// +public sealed class ConfigurationSecretProvider(IConfiguration configuration) : ISecretProvider +{ + private readonly IConfiguration _configuration = configuration + ?? throw new ArgumentNullException(nameof(configuration)); + + public string? GetSecret(string key) + { + ArgumentException.ThrowIfNullOrWhiteSpace(key); + var value = _configuration[key]; + return string.IsNullOrWhiteSpace(value) ? null : value; + } +} diff --git a/backend/src/LearnStack.SharedKernel/Secrets/ISecretProvider.cs b/backend/src/LearnStack.SharedKernel/Secrets/ISecretProvider.cs new file mode 100644 index 0000000..01ee594 --- /dev/null +++ b/backend/src/LearnStack.SharedKernel/Secrets/ISecretProvider.cs @@ -0,0 +1,35 @@ +namespace LearnStack.SharedKernel.Secrets; + +/// +/// Composition-root-resolved secret provider. Per +/// Standards 20 § ISecretProvider +/// and ADR-0032 § Sub-decision 9, every secret-bearing value (Sentry DSN, +/// signed-license RSA key paths, provider API keys, …) is read through +/// this contract — modules never call Environment.GetEnvironmentVariable +/// or hand-roll their own Vault clients. +/// +/// +/// +/// Phase 02a Packet 3 ships the contract + the default +/// implementation that delegates +/// to . Packet 5 adds the +/// DaprSecretProvider (Vault-backed) and the composition root branches by +/// DeploymentMode. +/// +/// +/// The interface is intentionally synchronous: most secret reads happen at +/// startup time, and Vault offers a synchronous fetch path. A future +/// async overload may land alongside the Dapr-backed implementation if a +/// hot-path use case appears. +/// +/// +public interface ISecretProvider +{ + /// + /// Resolves the secret identified by . Returns + /// null when the secret is not configured — callers decide + /// whether to fail fast or fall back to a default. + /// + /// The secret path, e.g. "ErrorTracking:Sentry:Dsn". + string? GetSecret(string key); +} diff --git a/backend/src/Modules/Audit/LearnStack.Modules.Audit.Application/LearnStack.Modules.Audit.Application.csproj b/backend/src/Modules/Audit/LearnStack.Modules.Audit.Application/LearnStack.Modules.Audit.Application.csproj index 40a8c09..9694008 100644 --- a/backend/src/Modules/Audit/LearnStack.Modules.Audit.Application/LearnStack.Modules.Audit.Application.csproj +++ b/backend/src/Modules/Audit/LearnStack.Modules.Audit.Application/LearnStack.Modules.Audit.Application.csproj @@ -8,6 +8,9 @@ + diff --git a/backend/src/Modules/Audit/LearnStack.Modules.Audit.Domain/LearnStack.Modules.Audit.Domain.csproj b/backend/src/Modules/Audit/LearnStack.Modules.Audit.Domain/LearnStack.Modules.Audit.Domain.csproj index bed6853..9fe0477 100644 --- a/backend/src/Modules/Audit/LearnStack.Modules.Audit.Domain/LearnStack.Modules.Audit.Domain.csproj +++ b/backend/src/Modules/Audit/LearnStack.Modules.Audit.Domain/LearnStack.Modules.Audit.Domain.csproj @@ -6,6 +6,9 @@ + diff --git a/backend/src/Modules/Content/LearnStack.Modules.Content.Application/LearnStack.Modules.Content.Application.csproj b/backend/src/Modules/Content/LearnStack.Modules.Content.Application/LearnStack.Modules.Content.Application.csproj index 81c589f..a508642 100644 --- a/backend/src/Modules/Content/LearnStack.Modules.Content.Application/LearnStack.Modules.Content.Application.csproj +++ b/backend/src/Modules/Content/LearnStack.Modules.Content.Application/LearnStack.Modules.Content.Application.csproj @@ -8,6 +8,9 @@ + diff --git a/backend/src/Modules/Content/LearnStack.Modules.Content.Domain/LearnStack.Modules.Content.Domain.csproj b/backend/src/Modules/Content/LearnStack.Modules.Content.Domain/LearnStack.Modules.Content.Domain.csproj index 7b4e904..ebd5459 100644 --- a/backend/src/Modules/Content/LearnStack.Modules.Content.Domain/LearnStack.Modules.Content.Domain.csproj +++ b/backend/src/Modules/Content/LearnStack.Modules.Content.Domain/LearnStack.Modules.Content.Domain.csproj @@ -6,6 +6,9 @@ + diff --git a/backend/src/Modules/Customization/LearnStack.Modules.Customization.Application/LearnStack.Modules.Customization.Application.csproj b/backend/src/Modules/Customization/LearnStack.Modules.Customization.Application/LearnStack.Modules.Customization.Application.csproj index 9c05697..b860077 100644 --- a/backend/src/Modules/Customization/LearnStack.Modules.Customization.Application/LearnStack.Modules.Customization.Application.csproj +++ b/backend/src/Modules/Customization/LearnStack.Modules.Customization.Application/LearnStack.Modules.Customization.Application.csproj @@ -8,6 +8,9 @@ + diff --git a/backend/src/Modules/Customization/LearnStack.Modules.Customization.Domain/LearnStack.Modules.Customization.Domain.csproj b/backend/src/Modules/Customization/LearnStack.Modules.Customization.Domain/LearnStack.Modules.Customization.Domain.csproj index a8a4bc5..f307240 100644 --- a/backend/src/Modules/Customization/LearnStack.Modules.Customization.Domain/LearnStack.Modules.Customization.Domain.csproj +++ b/backend/src/Modules/Customization/LearnStack.Modules.Customization.Domain/LearnStack.Modules.Customization.Domain.csproj @@ -6,6 +6,9 @@ + diff --git a/backend/src/Modules/Education/LearnStack.Modules.Education.Application/LearnStack.Modules.Education.Application.csproj b/backend/src/Modules/Education/LearnStack.Modules.Education.Application/LearnStack.Modules.Education.Application.csproj index e2aecc7..e7ebaff 100644 --- a/backend/src/Modules/Education/LearnStack.Modules.Education.Application/LearnStack.Modules.Education.Application.csproj +++ b/backend/src/Modules/Education/LearnStack.Modules.Education.Application/LearnStack.Modules.Education.Application.csproj @@ -8,6 +8,9 @@ + diff --git a/backend/src/Modules/Education/LearnStack.Modules.Education.Domain/LearnStack.Modules.Education.Domain.csproj b/backend/src/Modules/Education/LearnStack.Modules.Education.Domain/LearnStack.Modules.Education.Domain.csproj index ca0ba54..fa5b00d 100644 --- a/backend/src/Modules/Education/LearnStack.Modules.Education.Domain/LearnStack.Modules.Education.Domain.csproj +++ b/backend/src/Modules/Education/LearnStack.Modules.Education.Domain/LearnStack.Modules.Education.Domain.csproj @@ -6,6 +6,9 @@ + diff --git a/backend/src/Modules/Identity/LearnStack.Modules.Identity.Application/LearnStack.Modules.Identity.Application.csproj b/backend/src/Modules/Identity/LearnStack.Modules.Identity.Application/LearnStack.Modules.Identity.Application.csproj index f263e6b..bec2d31 100644 --- a/backend/src/Modules/Identity/LearnStack.Modules.Identity.Application/LearnStack.Modules.Identity.Application.csproj +++ b/backend/src/Modules/Identity/LearnStack.Modules.Identity.Application/LearnStack.Modules.Identity.Application.csproj @@ -8,6 +8,9 @@ + diff --git a/backend/src/Modules/Identity/LearnStack.Modules.Identity.Domain/LearnStack.Modules.Identity.Domain.csproj b/backend/src/Modules/Identity/LearnStack.Modules.Identity.Domain/LearnStack.Modules.Identity.Domain.csproj index 59eda77..75a9043 100644 --- a/backend/src/Modules/Identity/LearnStack.Modules.Identity.Domain/LearnStack.Modules.Identity.Domain.csproj +++ b/backend/src/Modules/Identity/LearnStack.Modules.Identity.Domain/LearnStack.Modules.Identity.Domain.csproj @@ -6,6 +6,9 @@ + diff --git a/backend/src/Modules/Media/LearnStack.Modules.Media.Application/LearnStack.Modules.Media.Application.csproj b/backend/src/Modules/Media/LearnStack.Modules.Media.Application/LearnStack.Modules.Media.Application.csproj index 3566dfa..20a7fbc 100644 --- a/backend/src/Modules/Media/LearnStack.Modules.Media.Application/LearnStack.Modules.Media.Application.csproj +++ b/backend/src/Modules/Media/LearnStack.Modules.Media.Application/LearnStack.Modules.Media.Application.csproj @@ -8,6 +8,9 @@ + diff --git a/backend/src/Modules/Media/LearnStack.Modules.Media.Domain/LearnStack.Modules.Media.Domain.csproj b/backend/src/Modules/Media/LearnStack.Modules.Media.Domain/LearnStack.Modules.Media.Domain.csproj index 3272879..93134df 100644 --- a/backend/src/Modules/Media/LearnStack.Modules.Media.Domain/LearnStack.Modules.Media.Domain.csproj +++ b/backend/src/Modules/Media/LearnStack.Modules.Media.Domain/LearnStack.Modules.Media.Domain.csproj @@ -6,6 +6,9 @@ + diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/LearnStack.Modules.Tenancy.Application.csproj b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/LearnStack.Modules.Tenancy.Application.csproj index 2971b97..a655c73 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/LearnStack.Modules.Tenancy.Application.csproj +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/LearnStack.Modules.Tenancy.Application.csproj @@ -8,6 +8,9 @@ + diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/LearnStack.Modules.Tenancy.Domain.csproj b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/LearnStack.Modules.Tenancy.Domain.csproj index 67f0c9e..46f276a 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/LearnStack.Modules.Tenancy.Domain.csproj +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/LearnStack.Modules.Tenancy.Domain.csproj @@ -6,6 +6,9 @@ + diff --git a/backend/tests/LearnStack.Tests.Architecture/CrossCuttingFoundationTests.cs b/backend/tests/LearnStack.Tests.Architecture/CrossCuttingFoundationTests.cs index 60b6f8d..dfce869 100644 --- a/backend/tests/LearnStack.Tests.Architecture/CrossCuttingFoundationTests.cs +++ b/backend/tests/LearnStack.Tests.Architecture/CrossCuttingFoundationTests.cs @@ -54,8 +54,23 @@ public sealed class CrossCuttingFoundationTests public void MediatR_Pipeline_Order_Matches_Canonical_Sequence() { // ADR-0032 § Sub-decision 2 — outermost (validation) first, - // innermost (handler) last. The architecture test reflects on the - // DI registration order MediatR emits via AddBehavior(...). + // innermost (handler) last. The expected list is hardcoded here on + // purpose so a future edit of MediatRPipelineRegistration's + // CanonicalBehaviorOrder can't sneak past the test by also + // reordering the test fixture. The catalogue entry + // MediatR_Pipeline_Order_Matches_Canonical_Sequence is the + // canonical reference for the contract. + Type[] expectedOrder = + [ + typeof(ValidationBehavior<,>), + typeof(LoggingBehavior<,>), + typeof(AuditLogBehavior<,>), + typeof(TenantContextBehavior<,>), + typeof(AuthorizationBehavior<,>), + typeof(TransactionBehavior<,>), + typeof(OutboxFlushBehavior<,>), + ]; + var services = new ServiceCollection(); services.AddLearnStackMediatRPipeline(); @@ -67,10 +82,21 @@ public void MediatR_Pipeline_Order_Matches_Canonical_Sequence() .ToArray(); behaviorTypes.Should().Equal( - MediatRPipelineRegistration.CanonicalBehaviorOrder.ToArray(), - "ADR-0032 § Sub-decision 2 pins the eight-step order; the catalogue entry " - + "MediatR_Pipeline_Order_Matches_Canonical_Sequence is the canonical name. " - + "Changing the order requires a new ADR."); + expectedOrder, + "ADR-0032 § Sub-decision 2 pins the canonical 7-behavior order " + + "(plus the handler at the innermost position). Changing the order " + + "requires a new ADR; this hardcoded list is the test's " + + "drift-proof anchor."); + + // Belt-and-suspenders: the production CanonicalBehaviorOrder list + // must match the same hardcoded sequence so reflection-based + // consumers (e.g. the registration extension) see the same + // contract. + MediatRPipelineRegistration.CanonicalBehaviorOrder + .Should() + .Equal(expectedOrder, + "the production CanonicalBehaviorOrder is the public surface; " + + "it must match the hardcoded ADR-0032 sequence."); } [Fact] @@ -211,6 +237,31 @@ public void Adapters_Wrap_Provider_Exceptions() } } + [Fact] + public void Modules_Do_Not_Reference_DeploymentMode() + { + // Standards 20 § Composition Root and Deployment Mode — the + // composition root selects provider implementations once; + // modules must NEVER read DeploymentMode directly. The catalogue + // entry of the same name has lived without an implementation + // until now (Phase 02a Packet 3 review finding). + foreach (var name in ModuleAssemblyShapes) + { + var assembly = TryLoadAssembly(name); + if (assembly is null) continue; + + var result = Types.InAssembly(assembly) + .Should() + .NotHaveDependencyOn("LearnStack.SharedKernel.Hosting") + .GetResult(); + + result.IsSuccessful.Should().BeTrue( + $"{name} references LearnStack.SharedKernel.Hosting (the DeploymentMode " + + "namespace). Composition-root branching is the only sanctioned read site " + + "(Standards 20 § Composition Root)."); + } + } + [Fact] public void IErrorTrackingProvider_Is_Singleton() { diff --git a/backend/tests/LearnStack.Tests.Integration/CrossCuttingFoundationHttpTests.cs b/backend/tests/LearnStack.Tests.Integration/CrossCuttingFoundationHttpTests.cs new file mode 100644 index 0000000..8d5307b --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/CrossCuttingFoundationHttpTests.cs @@ -0,0 +1,194 @@ +using System.Diagnostics.CodeAnalysis; +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using FluentAssertions; +using LearnStack.Api.Common; +using LearnStack.SharedKernel.Errors; +using LearnStack.SharedKernel.Results; +using FluentValidation; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Tenancy; +using MediatR; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Hosting; +using Xunit; + +namespace LearnStack.Tests.Integration; + +/// +/// HTTP-level integration coverage for ADR-0032's L1 boundary + +/// ValidationBehavior. Backs the Standards 21 catalogue rows +/// IExceptionHandler_Registered_AtStartup and +/// ValidationBehavior_DoesNotThrow_ValidationException with the +/// end-to-end shape ASP.NET produces — Problem Details body, status +/// mapping, correlationId extension, content type. +/// +public sealed class CrossCuttingFoundationHttpTests(CrossCuttingHttpFixture fixture) + : IClassFixture +{ + private readonly HttpClient _client = fixture.CreateClient(); + + [Fact] + public async Task L1_Handler_Returns_ProblemDetails_For_Server_Side_ProviderException() + { + var response = await _client.GetAsync(new Uri("/test/throw-provider-5xx", UriKind.Relative)); + + response.StatusCode.Should().Be(HttpStatusCode.ServiceUnavailable); + response.Content.Headers.ContentType?.MediaType + .Should().Be("application/problem+json"); + + var problem = await response.Content.ReadFromJsonAsync(); + problem.GetProperty("code").GetString().Should().Be("dependency_unavailable"); + problem.GetProperty("messageKey").GetString().Should().Be("lockey_dependency_unavailable"); + problem.GetProperty("instance").GetString().Should().Be("/test/throw-provider-5xx"); + problem.TryGetProperty("correlationId", out _).Should().BeTrue(); + } + + [Fact] + public async Task L1_Handler_Returns_400_For_Client_Side_ProviderException() + { + var response = await _client.GetAsync(new Uri("/test/throw-provider-4xx", UriKind.Relative)); + + // ProviderException(IsClientError: true) must map to 400 — the + // production bug the review caught: bypassing HttpStatusMap.For(Exception) + // mapped it to 503 via the LearnStackException default Error.Code. + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task ValidationBehavior_Returns_400_ProblemDetails_For_Invalid_Command() + { + // ADR-0032 § Sub-decision 3 — invalid input never reaches the + // handler; ValidationBehavior returns Result.Fail(validation_failed) + // and the controller's ToActionResult() projects to a 400 + // Problem Details body. + var response = await _client.PostAsJsonAsync( + new Uri("/test/validate", UriKind.Relative), + new { Name = string.Empty }); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + response.Content.Headers.ContentType?.MediaType + .Should().Be("application/problem+json"); + + var problem = await response.Content.ReadFromJsonAsync(); + problem.GetProperty("code").GetString().Should().Be("validation_failed"); + problem.GetProperty("messageKey").GetString().Should().Be("lockey_validation_failed"); + problem.GetProperty("instance").GetString().Should().Be("/test/validate"); + problem.GetProperty("errors").GetProperty("name")[0] + .GetProperty("key").GetString().Should().Be("lockey_name_required"); + } + + [Fact] + public async Task ValidationBehavior_Passes_Through_When_Command_Is_Valid() + { + var response = await _client.PostAsJsonAsync( + new Uri("/test/validate", UriKind.Relative), + new { Name = "alice" }); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var body = await response.Content.ReadAsStringAsync(); + body.Should().Contain("alice"); + } +} + +/// +/// Shared that wires the +/// integration test's controllers + MediatR handler + validator into the +/// real LearnStack.Api host. Reuses the host's +/// AddLearnStackCrossCuttingFoundation wiring so the L1 handler + +/// ValidationBehavior tested here is the same code production runs. +/// +public sealed class CrossCuttingHttpFixture : WebApplicationFactory +{ + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + builder.UseEnvironment(Environments.Development); + builder.ConfigureTestServices(services => + { + services + .AddControllers() + .AddApplicationPart(typeof(CrossCuttingTestController).Assembly); + + // ValidationBehavior resolves IValidator from DI. We + // register the validator + the handler so the test exercises + // the full pipeline without re-running AddMediatR (which would + // double-register the behaviors). + services.AddTransient< + IRequestHandler>, + TestValidationHandler>(); + services.AddTransient, TestValidationValidator>(); + + // TenantContextBehavior short-circuits when ITenantContext is + // not resolved. Until Packet 7 lands TenantResolverMiddleware, + // production has no way to flip IsResolved → true. For the + // integration test we replace the scoped registration with a + // fixed test tenant so MediatR's pipeline reaches the inner + // handler. + services.RemoveAll(); + services.AddScoped(_ => TestResolvedTenantContext.Instance); + }); + } +} + +internal sealed class TestResolvedTenantContext : ITenantContext +{ + public static TestResolvedTenantContext Instance { get; } = new(); + + public bool IsResolved => true; + public Guid TenantId { get; } = Guid.Parse("018f4d40-0000-7000-8000-000000000001"); + public Guid? OrganizationId { get; } + public UserId? UserId { get; } + public string? CorrelationId => null; + public string? ModuleName => "integration-test"; +} + +[Route("test")] +public sealed class CrossCuttingTestController(IMediator mediator) : ControllerBase +{ + [HttpGet("throw-provider-5xx")] + [SuppressMessage("Performance", "CA1822:Mark members as static", + Justification = "Controller actions are instance methods by ASP.NET routing convention.")] + public IActionResult ThrowServerProvider() => + throw new ProviderException("test-provider", "upstream returned 5xx", isClientError: false); + + [HttpGet("throw-provider-4xx")] + [SuppressMessage("Performance", "CA1822:Mark members as static", + Justification = "Controller actions are instance methods by ASP.NET routing convention.")] + public IActionResult ThrowClientProvider() => + throw new ProviderException("test-provider", "upstream returned 4xx", isClientError: true); + + [HttpPost("validate")] + public async Task Validate( + [FromBody] TestValidationCommand command, + CancellationToken cancellationToken) + { + var result = await mediator.Send(command, cancellationToken); + return result.ToActionResult(); + } +} + +public sealed record TestValidationCommand(string Name) : IRequest>; + +internal sealed class TestValidationValidator : AbstractValidator +{ + public TestValidationValidator() + { + RuleFor(c => c.Name) + .NotEmpty() + .WithErrorCode("lockey_name_required"); + } +} + +internal sealed class TestValidationHandler : IRequestHandler> +{ + public Task> Handle( + TestValidationCommand request, CancellationToken cancellationToken) => + Task.FromResult(Result.Ok(request.Name)); +} diff --git a/backend/tests/LearnStack.Tests.Integration/LearnStack.Tests.Integration.csproj b/backend/tests/LearnStack.Tests.Integration/LearnStack.Tests.Integration.csproj index fa5d581..e5809a2 100644 --- a/backend/tests/LearnStack.Tests.Integration/LearnStack.Tests.Integration.csproj +++ b/backend/tests/LearnStack.Tests.Integration/LearnStack.Tests.Integration.csproj @@ -28,6 +28,12 @@ + + + diff --git a/backend/tests/LearnStack.Tests.Unit/Api/Common/ResultExtensionsTests.cs b/backend/tests/LearnStack.Tests.Unit/Api/Common/ResultExtensionsTests.cs index cb5f47e..9618b8f 100644 --- a/backend/tests/LearnStack.Tests.Unit/Api/Common/ResultExtensionsTests.cs +++ b/backend/tests/LearnStack.Tests.Unit/Api/Common/ResultExtensionsTests.cs @@ -2,14 +2,25 @@ using LearnStack.Api.Common; using LearnStack.SharedKernel.Localization; using LearnStack.SharedKernel.Results; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; using Xunit; namespace LearnStack.Tests.Unit.Api.Common; /// /// ResultExtensions.ToActionResult contract per ADR-0032 § Sub-decision 6 — -/// the sanctioned shape every controller endpoint uses. +/// the sanctioned shape every controller endpoint uses. The failure path +/// returns , which defers the +/// body assembly until ExecuteResultAsync +/// runs; these tests verify both the immediate carry (Error + status) and +/// the executed body shape. /// public sealed class ResultExtensionsTests { @@ -25,19 +36,71 @@ public void Success_Maps_To_OkObjectResult() } [Fact] - public void Failure_Maps_To_ProblemDetails_With_Correct_Status() + public void Failure_Returns_ProblemDetailsActionResult_With_StatusCode_From_Error() { var error = new Error(new LocalizedMessage("lockey_not_found")); var result = Result.Fail(error); var action = result.ToActionResult(); - var objectResult = action.Should().BeOfType().Which; - objectResult.StatusCode.Should().Be(404); - objectResult.Value.Should().BeOfType(); - var problem = (ProblemDetails)objectResult.Value!; - problem.Status.Should().Be(404); - problem.Extensions["code"].Should().Be("not_found"); - problem.Extensions["messageKey"].Should().Be("lockey_not_found"); + var pdar = action.Should().BeOfType().Which; + pdar.Error.Should().BeSameAs(error); + pdar.StatusCode.Should().Be(404); + } + + [Fact] + public async Task ExecuteResultAsync_Populates_ProblemDetails_From_HttpContext() + { + // ProblemDetailsActionResult builds the body lazily so the + // sanctioned controller shape `(await Send(...)).ToActionResult()` + // (no HttpContext argument) still populates instance + correlation + // when ASP.NET invokes ExecuteResultAsync. + var error = new Error(new LocalizedMessage("lockey_not_found")); + var sut = new ProblemDetailsActionResult(error); + + var actionContext = BuildActionContext(path: "/v1/courses/abc"); + await sut.ExecuteResultAsync(actionContext); + + var body = actionContext.HttpContext.Items["WrittenBody"] + .Should().BeOfType().Which; + body.Status.Should().Be(404); + body.Instance.Should().Be("/v1/courses/abc"); + body.Extensions["code"].Should().Be("not_found"); + body.Extensions["messageKey"].Should().Be("lockey_not_found"); + body.Extensions.Should().ContainKey("correlationId"); + } + + private static ActionContext BuildActionContext(string path) + { + var services = new ServiceCollection(); + services.AddSingleton, CapturingObjectResultExecutor>(); + services.AddSingleton(NullLoggerFactory.Instance); + var sp = services.BuildServiceProvider(); + + var httpContext = new DefaultHttpContext + { + RequestServices = sp, + }; + httpContext.Request.Path = new PathString(path); + + return new ActionContext( + httpContext, + new RouteData(), + new ActionDescriptor()); + } + + private sealed class CapturingObjectResultExecutor : IActionResultExecutor + { + public Task ExecuteAsync(ActionContext context, ObjectResult result) + { + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(result); + context.HttpContext.Items["WrittenBody"] = result.Value; + if (result.StatusCode is { } code) + { + context.HttpContext.Response.StatusCode = code; + } + return Task.CompletedTask; + } } } diff --git a/backend/tests/LearnStack.Tests.Unit/Infrastructure/Observability/TenantContextSpanProcessorTests.cs b/backend/tests/LearnStack.Tests.Unit/Infrastructure/Observability/TenantContextSpanProcessorTests.cs index c98a86f..f5219dc 100644 --- a/backend/tests/LearnStack.Tests.Unit/Infrastructure/Observability/TenantContextSpanProcessorTests.cs +++ b/backend/tests/LearnStack.Tests.Unit/Infrastructure/Observability/TenantContextSpanProcessorTests.cs @@ -51,9 +51,12 @@ public void OnStart_Enriches_Activity_When_Context_Is_Resolved() processor.OnStart(activity); - activity.GetTagItem("tenant.id").Should().Be(tenantId); - activity.GetTagItem("organization.id").Should().Be(organizationId); - activity.GetTagItem("user.id").Should().Be(userId.Value); + // OTel attribute types are string / long / double / bool / array. + // The processor projects Guid via ToString() (default "D" format) + // so the wire format is stable across exporters — review-2 fix. + activity.GetTagItem("tenant.id").Should().Be(tenantId.ToString()); + activity.GetTagItem("organization.id").Should().Be(organizationId.ToString()); + activity.GetTagItem("user.id").Should().Be(userId.Value.ToString()); activity.GetTagItem("correlation.id").Should().Be("00-aabbccdd-eeff0011-01"); activity.GetTagItem("module").Should().Be("education"); } diff --git a/docs/roadmap/phase-02a-kernel-tenancy.md b/docs/roadmap/phase-02a-kernel-tenancy.md index 44a0ceb..1cba7da 100644 --- a/docs/roadmap/phase-02a-kernel-tenancy.md +++ b/docs/roadmap/phase-02a-kernel-tenancy.md @@ -148,10 +148,86 @@ > client-error retries), `HttpStatusMap` (mirrors Standards 09 table), > `ResultExtensions.ToActionResult()` (Problem Details shape), > `LocalFileErrorTracker` (writes the JSON envelope). -> - `LearnStack.Tests.Unit` 110/110, `LearnStack.Tests.Architecture` 24/24, -> `LearnStack.Tests.Contract` 1/1, `LearnStack.Tests.Integration` 1/1 +> - `LearnStack.Tests.Unit` 111/111, `LearnStack.Tests.Architecture` 25/25, +> `LearnStack.Tests.Contract` 1/1, `LearnStack.Tests.Integration` 5/5 > green under `CI=true`. > +> Review fixes (commit ``): +> +> - **B1** — Sentry DSN now reads via the new `ISecretProvider` socket +> (`LearnStack.SharedKernel/Secrets/`) with `ConfigurationSecretProvider` +> as the Phase 02a default; Packet 5 swaps in `DaprSecretProvider` for +> Vault. ADR-0032 § Sub-decision 9 contract honoured. +> - **B2** — Serilog pipeline gains `CorrelationContextEnricher` +> (copies tenant / org / user / correlation / module from +> `ITenantContextAccessor` onto every log event) + +> `RedactSensitiveFieldsEnricher` (scrubs password / token / secret / +> DSN / JWT / authorization / SSN / TCKN / card-number tokens before +> the formatter touches them). `LocalFileErrorTracker` redacts the +> same token set on `CapturedContext.AdditionalTags`. +> - **M1** — `ProblemDetailsFactory.For(Exception)` routes status through +> `HttpStatusMap.For(Exception)` so `ProviderException(IsClientError:true)` +> returns 400 instead of falling through to 503 via the carried Error's +> default code. +> - **M2** — L1 handler now skips `Activity.AddException` for +> `ProviderException(IsClientError:true)` per Standards 09 § Sentry vs +> OpenTelemetry table — `SetStatus(Error)` only, no exception event. +> - **M3** — All 14 module Domain + Application csproj files now reference +> `LearnStack.Analyzers` via `OutputItemType="Analyzer"`. Future +> `throw new DomainException(...)` in any module fails the analyzer. +> - **M4** — Polly `IProviderResilience` pipeline now consumes +> `BulkheadOptions` via `Polly.RateLimiting`'s +> `AddRateLimiter(ConcurrencyLimiterOptions)`; the silent-dead config +> gap is closed. +> - **N1** — `ProblemDetailsFactory` projects nested +> FluentValidation property paths (`Address.Street`) and acronyms +> (`URLValue`) to the right camelCase shape via +> `JsonNamingPolicy.CamelCase`. +> - **N2** — `ToActionResult()` returns `ProblemDetailsActionResult` +> which builds the body inside `ExecuteResultAsync(ActionContext)`, so +> the sanctioned `(await Send(...)).ToActionResult()` shape populates +> `Instance` + `correlationId` without the caller threading +> `HttpContext`. +> - **N3 / A6** — `LocalFileErrorTracker` file names suffix a Guid for +> guaranteed uniqueness in same-millisecond bursts; `stackalloc` is +> capped at 128 chars so a multi-KB inbound `traceparent` cannot blow +> the stack. +> - **A5** — `AuditLogBehavior` catch filter excludes +> `OperationCanceledException` so client disconnects no longer churn +> warning logs / future audit rows. +> - **A7** — `MediatRPipelineRegistration.CanonicalBehaviorOrder` +> documentation explicitly notes "7 behaviors + the handler at the +> innermost position = the 8 canonical steps". +> - **A8** — `ProblemDetailsFactory` strips the `_failed` suffix from +> the Problem `type` URL (matches the Standards 09 § API Surface +> example: `/validation`, not `/validation_failed`). +> - **A11** — New HTTP-level integration tests in +> `LearnStack.Tests.Integration/CrossCuttingFoundationHttpTests` exercise +> the L1 handler + ValidationBehavior end-to-end via +> `WebApplicationFactory` and a synthetic test controller. The +> Standards 21 catalogue row for +> `ValidationBehavior_DoesNotThrow_ValidationException` is updated to +> "unit + integration". +> - **A12** — `LearnStackExceptionHandler` is `internal sealed` — only +> the framework's `AddExceptionHandler()` instantiates it; tests +> reach the type via `InternalsVisibleTo`. +> - **A13** — `TenantContextSpanProcessor` stringifies Guid tags +> (`tenant.id` / `organization.id` / `user.id`) so the wire format is +> stable across exporters. +> - **S1** — `MediatR_Pipeline_Order_Matches_Canonical_Sequence` test +> asserts a hardcoded behavior-type sequence, not the production +> `CanonicalBehaviorOrder` list, so an accidental list reorder cannot +> sneak past. +> - **SU1** — L1 handler skips the body write on +> `OperationCanceledException` / cancelled `CancellationToken`; the +> client has already disconnected. +> - **SU4** — `TenantContextBehavior.AllowsUnresolvedContext` predicate +> carries a TODO documenting the Packet 7 marker-attribute seam +> (`[AllowsUnresolvedTenantContext]`). +> - New architecture test `Modules_Do_Not_Reference_DeploymentMode` lit +> up — catalogue entry existed since ADR-0020 but had no implementation +> until now. +> > **Packet 4 — API conventions ⏳** > REST + URL versioning (`/v1/...` per ADR-0024), Problem Details (RFC 7807) > shape on every error, cursor pagination, idempotency keys for write diff --git a/docs/standards/21-architecture-tests-catalogue.md b/docs/standards/21-architecture-tests-catalogue.md index 039c3c8..d926a5d 100644 --- a/docs/standards/21-architecture-tests-catalogue.md +++ b/docs/standards/21-architecture-tests-catalogue.md @@ -100,8 +100,15 @@ otherwise). outcome; a `FluentValidation.ValidationException` never escapes the behavior into the handler scope or up to L1. - **Source:** ADR-0032 § Sub-decision 3. -- **Type:** integration test (Testcontainers). -- **Phase:** 02a. +- **Type:** unit (`LearnStack.Tests.Unit` — + `ValidationBehaviorTests.Never_Throws_ValidationException`) **+** + HTTP-level integration (`LearnStack.Tests.Integration` — + `CrossCuttingFoundationHttpTests.ValidationBehavior_Returns_400_ProblemDetails_For_Invalid_Command`, + via `WebApplicationFactory` and the + `CrossCuttingTestController.validate` endpoint). The integration + variant lights up the full controller → MediatR pipeline → Problem + Details body shape so a regression at any layer surfaces. +- **Phase:** 02a (Packet 3 — both variants shipped). #### `Domain_Methods_Do_Not_Throw_For_Expected_Cases` @@ -161,6 +168,17 @@ otherwise). - **Type:** xUnit + NetArchTest. - **Phase:** 02a. +#### `Modules_Do_Not_Reference_DeploymentMode` + +- **Asserts:** no module assembly references + `LearnStack.SharedKernel.Hosting` (the namespace that owns + `DeploymentMode`). The composition root is the only sanctioned read + site per Standards 20 § Composition Root and Deployment Mode. +- **Source:** ADR-0020; + [20-infrastructure-stack.md § Composition Root and Deployment Mode](20-infrastructure-stack.md). +- **Type:** xUnit + NetArchTest. +- **Phase:** 02a (Packet 3 — landed alongside the cross-cutting tests). + #### `OTel_Pipeline_Includes_TenantContextSpanProcessor` - **Asserts:** the registered OpenTelemetry tracing pipeline includes the @@ -241,9 +259,10 @@ identifiers awaiting migration: `NullEntitlementProvider_NotRegistered_OutsideDevelopment`, `LicenseKey_Validation_Is_Pinned_RSA2048`. - Standards 20 — composition-root + direct-injection bans: - `Modules_Do_Not_Reference_DeploymentMode`, `Modules_Do_Not_Inject_Valkey_Directly`, `Modules_Do_Not_Read_Entitlement_Cache_Directly`. + (`Modules_Do_Not_Reference_DeploymentMode` migrated to the main + catalogue below in Phase 02a Packet 3 — see the dedicated entry.) The next PR that edits any of these source documents folds the corresponding row in here. From a194b770521da98c27affb48a81b0b4b7d73b938 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 22 May 2026 00:06:24 +0300 Subject: [PATCH 3/9] =?UTF-8?q?fix(phase-02a):=20packet=203=20review-2=20?= =?UTF-8?q?=E2=80=94=20secret-provider=20seam=20+=20sensitive-token=20cata?= =?UTF-8?q?log?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the review-2 follow-ups raised on the review-1 commit: Minor - N4 SelectSecretProvider helper in CrossCuttingFoundationExtensions becomes the single composition-root site that picks the ISecretProvider implementation per DeploymentMode. Both the DI registration and the local AddLearnStackErrorTracking call read the same instance. Packet 5's DaprSecretProvider swap now touches one line, not two — TODO comment anchors the per-mode branches. Suggestions - SU5 SensitiveTokenCatalog in SharedKernel/Secrets/ becomes the single source of truth for the sensitive-property-name token list. Both RedactSensitiveFieldsEnricher and LocalFileErrorTracker.RedactSensitiveTags consume SensitiveTokenCatalog.IsSensitive(...), so the Serilog path and the air-gapped path cannot drift. The catalogue now includes `vkn` (Vergi Kimlik Numarası — Turkish corporate tax number) next to `tckn`. - SU6 RedactSensitiveFieldsEnricher remarks carry a dated TODO naming the Packet 7+ Roslyn analyzer that should extend LearnStack.Analyzers to flag string-interpolated `throw new ...Exception($"...{token}...")` patterns in Domain + Application projects. Runtime redaction covers logs / OTLP / Sentry tags; the analyzer closes the secrets-in- exception-messages gap at compile time. - SU7 HttpStatusMap.For(Exception) carries a rationale comment block explaining the non-IETF 499 "client closed request" status: matches Nginx / IIS / Envoy / APISIX behaviour, keeps client disconnects off the error-budget axis, points at the L1 handler's skip-body contract. If a future ADR pins a different code, the comment block is the one seam to change. Validation - dotnet build LearnStack.slnx (CI=true) → 0 warning, 0 error. - 142/142 tests green: Unit 111, Architecture 25, Integration 5, Contract 1. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../LearnStack.Api/Common/HttpStatusMap.cs | 18 ++++- .../CrossCuttingFoundationExtensions.cs | 49 ++++++++++-- .../LocalFileErrorTracker.cs | 35 ++------ .../Serilog/RedactSensitiveFieldsEnricher.cs | 65 +++++---------- .../Secrets/SensitiveTokenCatalog.cs | 79 +++++++++++++++++++ docs/roadmap/phase-02a-kernel-tenancy.md | 31 ++++++++ 6 files changed, 198 insertions(+), 79 deletions(-) create mode 100644 backend/src/LearnStack.SharedKernel/Secrets/SensitiveTokenCatalog.cs diff --git a/backend/src/LearnStack.Api/Common/HttpStatusMap.cs b/backend/src/LearnStack.Api/Common/HttpStatusMap.cs index fafef82..a7c1aaa 100644 --- a/backend/src/LearnStack.Api/Common/HttpStatusMap.cs +++ b/backend/src/LearnStack.Api/Common/HttpStatusMap.cs @@ -43,7 +43,23 @@ public static int For(Exception exception) return exception switch { - OperationCanceledException => 499, // client closed request + // 499 "client closed request" is an Nginx convention, not an + // IETF code — Standards 09 § Result Type does not pin a status + // for client disconnects. We pick 499 (rather than 408 / 503 / + // 500) because: + // * IIS, Nginx, Envoy, and APISIX all emit 499 for pre- + // response client aborts; log dashboards and SLO calculators + // already treat it as "not our fault". + // * 408 implies a server-side timeout (we did not time out — + // the client left). + // * 5xx codes would inflate error-budget metrics and trip + // PagerDuty rotations for nothing. + // L1 handler skips both Sentry capture and the response body + // write for OperationCanceled per ADR-0032 § Sub-decision 7; + // the status is set here for parity with the upstream proxy's + // behaviour. If a future ADR pins a different code, change + // this line. + OperationCanceledException => 499, ProviderException pex when pex.IsClientError => (int)HttpStatusCode.BadRequest, ProviderException => (int)HttpStatusCode.ServiceUnavailable, InfrastructureException => (int)HttpStatusCode.ServiceUnavailable, diff --git a/backend/src/LearnStack.Api/Composition/CrossCuttingFoundationExtensions.cs b/backend/src/LearnStack.Api/Composition/CrossCuttingFoundationExtensions.cs index bf45ff4..df4874e 100644 --- a/backend/src/LearnStack.Api/Composition/CrossCuttingFoundationExtensions.cs +++ b/backend/src/LearnStack.Api/Composition/CrossCuttingFoundationExtensions.cs @@ -52,11 +52,14 @@ public static WebApplicationBuilder AddLearnStackCrossCuttingFoundation( builder.Services.AddLearnStackObservabilityServices(); // ISecretProvider socket lands now so non-Dev DSN / license-key reads - // route through one seam. ConfigurationSecretProvider is the default - // — Packet 5 swaps in DaprSecretProvider once Vault is wired. - builder.Services.TryAddSingleton( - _ => new ConfigurationSecretProvider(builder.Configuration)); - var secretProvider = new ConfigurationSecretProvider(builder.Configuration); + // route through one seam. SelectSecretProvider is the SINGLE site + // that picks the implementation per DeploymentMode — both the DI + // registration and the local AddLearnStackErrorTracking call read + // the same instance. Packet 5 extends SelectSecretProvider with the + // Dapr branch so adding DaprSecretProvider touches one line, not + // two. + var secretProvider = SelectSecretProvider(deploymentMode, builder.Configuration); + builder.Services.TryAddSingleton(secretProvider); WireSerilog(builder); WireOpenTelemetry(builder); @@ -152,4 +155,40 @@ private static void WireOpenTelemetry(WebApplicationBuilder builder) _ = otel; } + + /// + /// Single composition-root site that picks the + /// implementation per + /// . Packet 5 extends this method with the + /// Dapr branch so the swap touches one line, not two. Both the DI + /// registration and the local AddLearnStackErrorTracking call + /// read the same instance returned here. + /// + /// + /// CA1859 (prefer concrete return type for perf) is suppressed + /// deliberately: the interface return is the entire point of the + /// helper — Packet 5 returns DaprSecretProvider for some + /// modes, and the call site must not bind to a concrete type. + /// + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Performance", + "CA1859:Use concrete types when possible for improved performance", + Justification = "Return type is intentionally ISecretProvider so Packet 5 can swap implementations per DeploymentMode.")] + private static ISecretProvider SelectSecretProvider( + DeploymentMode deploymentMode, + IConfiguration configuration) + { + // TODO(2026-05-21, @platform, phase-02a-packet-5): light up the + // Dapr-backed branch. + // DeploymentMode.SaaS / Dedicated / SelfHostedOnline → + // new DaprSecretProvider(...) // Vault-backed + // DeploymentMode.SelfHostedAirGapped → + // new FileSecretProvider(...) // disk-backed + // DeploymentMode.Development → + // keep ConfigurationSecretProvider (delegates to IConfiguration). + // The signature stays the same so AddLearnStackErrorTracking's + // ISecretProvider argument resolves correctly across modes. + _ = deploymentMode; + return new ConfigurationSecretProvider(configuration); + } } diff --git a/backend/src/LearnStack.Infrastructure.ErrorTracking/LocalFileErrorTracker.cs b/backend/src/LearnStack.Infrastructure.ErrorTracking/LocalFileErrorTracker.cs index 2dd2ac4..7f03d82 100644 --- a/backend/src/LearnStack.Infrastructure.ErrorTracking/LocalFileErrorTracker.cs +++ b/backend/src/LearnStack.Infrastructure.ErrorTracking/LocalFileErrorTracker.cs @@ -1,5 +1,6 @@ using System.Text.Json; using LearnStack.SharedKernel.Observability; +using LearnStack.SharedKernel.Secrets; using Microsoft.Extensions.Logging; namespace LearnStack.Infrastructure.ErrorTracking; @@ -25,20 +26,11 @@ internal sealed class LocalFileErrorTracker : IErrorTrackingProvider /// private const int MaxFileNameSegmentLength = 128; - /// - /// Property-name tokens that mark a tag as sensitive. AdditionalTags - /// whose key contains one of these (case-insensitive) are redacted - /// before write — air-gapped operators inherit the same Standards 11 - /// protections as the Serilog path. - /// - private static readonly string[] SensitiveTagTokens = - [ - "password", "passwd", "secret", "token", "apikey", "api_key", - "authorization", "auth_header", "dsn", "jwt", "credential", - "ssn", "tckn", "iban", "cardnumber", "card_number", "cvv", "cvc", - ]; - - private const string RedactedValue = "***REDACTED***"; + // AdditionalTags redaction reads from the SharedKernel-side + // SensitiveTokenCatalog so the Serilog enricher and this air-gapped + // capture path stay in sync. Adding a token there lights up both + // surfaces together. + private const string RedactedValue = SensitiveTokenCatalog.RedactedValue; private readonly string _directory; private readonly ILogger _logger; @@ -119,25 +111,12 @@ await JsonSerializer.SerializeAsync(stream, envelope, SerializerOptions, cancell var sanitised = new Dictionary(tags.Count, StringComparer.Ordinal); foreach (var (key, value) in tags) { - sanitised[key] = IsSensitive(key) ? RedactedValue : value; + sanitised[key] = SensitiveTokenCatalog.IsSensitive(key) ? RedactedValue : value; } return sanitised; } - private static bool IsSensitive(string key) - { - foreach (var token in SensitiveTagTokens) - { - if (key.Contains(token, StringComparison.OrdinalIgnoreCase)) - { - return true; - } - } - - return false; - } - private static string SanitiseForFileName(string? correlationId) { if (string.IsNullOrWhiteSpace(correlationId)) diff --git a/backend/src/LearnStack.Infrastructure.Observability/Serilog/RedactSensitiveFieldsEnricher.cs b/backend/src/LearnStack.Infrastructure.Observability/Serilog/RedactSensitiveFieldsEnricher.cs index efb194c..018c6b6 100644 --- a/backend/src/LearnStack.Infrastructure.Observability/Serilog/RedactSensitiveFieldsEnricher.cs +++ b/backend/src/LearnStack.Infrastructure.Observability/Serilog/RedactSensitiveFieldsEnricher.cs @@ -1,3 +1,4 @@ +using LearnStack.SharedKernel.Secrets; using Serilog.Core; using Serilog.Events; @@ -9,51 +10,38 @@ namespace LearnStack.Infrastructure.Observability.Serilog; /// Standards 10 /// § Logging Rules and /// Standards 11 § Sensitive Data Exposure: -/// passwords, tokens, DSNs, JWTs, API keys, national identifiers, +/// passwords, tokens, DSNs, JWTs, API keys, national / corporate identifiers, /// authorization headers, full payment payloads must never reach the /// console or the OTLP sink. /// /// /// -/// The enricher rewrites matching properties in place to the constant -/// . A property is "sensitive" when its name -/// contains one of the case-insensitive tokens listed in -/// . The list is conservative; production -/// deployments can layer additional patterns by composing a richer -/// enricher. +/// The token list lives in +/// — +/// the canonical source the air-gapped LocalFileErrorTracker shares +/// so the two redaction surfaces cannot drift. Adding a token there lights +/// up both paths together. /// /// /// Stack traces and exception messages are NOT redacted — they ride /// through , which the enricher leaves /// alone. Modules must follow Standards 11 (never put secrets in -/// exception messages) so the boundary is honest. +/// exception messages) so the boundary stays honest. +/// +/// +/// TODO(2026-05-21, @platform, phase-02b-or-later): augment the Serilog +/// pipeline with a Roslyn analyzer (extending LearnStack.Analyzers) +/// that flags string-interpolated throw new ...Exception($"...{token}...") +/// patterns in Domain + Application projects. Today the +/// "no secrets in exception messages" rule rests on Standards 11 review +/// discipline; promoting it to a compile-time check closes the last +/// gap the runtime redactor cannot. /// /// public sealed class RedactSensitiveFieldsEnricher : ILogEventEnricher { - public const string RedactedValue = "***REDACTED***"; - - private static readonly string[] SensitiveKeyTokens = - [ - "password", - "passwd", - "secret", - "token", - "apikey", - "api_key", - "authorization", - "auth_header", - "dsn", - "jwt", - "credential", - "ssn", - "tckn", - "iban", - "cardnumber", - "card_number", - "cvv", - "cvc", - ]; + /// Substituted in place of any matched property value. + public const string RedactedValue = SensitiveTokenCatalog.RedactedValue; public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory) { @@ -62,24 +50,11 @@ public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory) foreach (var propertyName in logEvent.Properties.Keys.ToArray()) { - if (IsSensitive(propertyName)) + if (SensitiveTokenCatalog.IsSensitive(propertyName)) { logEvent.AddOrUpdateProperty( propertyFactory.CreateProperty(propertyName, RedactedValue)); } } } - - private static bool IsSensitive(string propertyName) - { - foreach (var token in SensitiveKeyTokens) - { - if (propertyName.Contains(token, StringComparison.OrdinalIgnoreCase)) - { - return true; - } - } - - return false; - } } diff --git a/backend/src/LearnStack.SharedKernel/Secrets/SensitiveTokenCatalog.cs b/backend/src/LearnStack.SharedKernel/Secrets/SensitiveTokenCatalog.cs new file mode 100644 index 0000000..4c48fc1 --- /dev/null +++ b/backend/src/LearnStack.SharedKernel/Secrets/SensitiveTokenCatalog.cs @@ -0,0 +1,79 @@ +namespace LearnStack.SharedKernel.Secrets; + +/// +/// The canonical list of substrings whose presence in a property name (or +/// AdditionalTags key) marks the value as sensitive — passwords, tokens, +/// secrets, payment identifiers, national / corporate IDs. The Serilog +/// RedactSensitiveFieldsEnricher and the air-gapped +/// LocalFileErrorTracker consume this single source of truth so the +/// two redaction surfaces cannot drift. +/// +/// +/// +/// Matching is case-insensitive and substring-based: a property named +/// UserPassword matches the password token. Standards 11 +/// § Sensitive Data Exposure is authoritative for what counts as +/// sensitive; this list is the runtime expression of that rule. +/// +/// +/// New tokens land here, not in the consuming projects. The +/// architecture-test suite asserts both consumers route through +/// so a future addition lights up the +/// Serilog path and the air-gapped path together. +/// +/// +public static class SensitiveTokenCatalog +{ + /// Substituted in place of any matched property value. + public const string RedactedValue = "***REDACTED***"; + + /// + /// Substring tokens that mark a property as sensitive. Sorted + /// alphabetically; additions go anywhere in the list. + /// + public static IReadOnlyList DefaultTokens { get; } = + [ + "apikey", + "api_key", + "authorization", + "auth_header", + "cardnumber", + "card_number", + "credential", + "cvc", + "cvv", + "dsn", + "iban", + "jwt", + "passwd", + "password", + "secret", + "ssn", + "tckn", // Turkish national ID + "token", + "vkn", // Turkish corporate tax number (Vergi Kimlik Numarası) + ]; + + /// + /// Returns true when the property name contains any token + /// from (case-insensitive substring + /// match). + /// + public static bool IsSensitive(string propertyName) + { + if (string.IsNullOrEmpty(propertyName)) + { + return false; + } + + foreach (var token in DefaultTokens) + { + if (propertyName.Contains(token, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } +} diff --git a/docs/roadmap/phase-02a-kernel-tenancy.md b/docs/roadmap/phase-02a-kernel-tenancy.md index 1cba7da..2819467 100644 --- a/docs/roadmap/phase-02a-kernel-tenancy.md +++ b/docs/roadmap/phase-02a-kernel-tenancy.md @@ -228,6 +228,37 @@ > up — catalogue entry existed since ADR-0020 but had no implementation > until now. > +> Review-2 fixes: +> +> - **N4** — `CrossCuttingFoundationExtensions.SelectSecretProvider` +> becomes the single composition-root site that picks the +> `ISecretProvider` implementation per `DeploymentMode`. Both the DI +> registration and the local `AddLearnStackErrorTracking` argument +> read the same instance. Packet 5's `DaprSecretProvider` swap now +> touches one line, not two. +> - **SU5** — `SensitiveTokenCatalog` in +> `LearnStack.SharedKernel/Secrets/` is the single source of truth for +> the sensitive-property-name token list. Both +> `RedactSensitiveFieldsEnricher` and +> `LocalFileErrorTracker.RedactSensitiveTags` consume +> `SensitiveTokenCatalog.IsSensitive(...)` so the two redaction +> surfaces cannot drift. The catalogue now includes `vkn` (Vergi +> Kimlik Numarası — Turkish corporate tax number, common for +> instructor-owned sole proprietorships) alongside `tckn`. +> - **SU6** — `RedactSensitiveFieldsEnricher` remarks carry a dated +> TODO naming the Packet 7+ Roslyn analyzer that should extend +> `LearnStack.Analyzers` to flag string-interpolated +> `throw new ...Exception($"...{token}...")` patterns in `Domain` + +> `Application` projects. Runtime redaction covers logs / OTLP / Sentry +> tags; the analyzer closes the last gap (secrets in exception +> messages) at compile time. +> - **SU7** — `HttpStatusMap.For(Exception)` carries a rationale comment +> for the non-IETF `499` "client closed request" status: matches +> Nginx / IIS / Envoy / APISIX behaviour, keeps client disconnects +> off the error-budget axis, and points at the L1 handler's skip-body +> contract. If a future ADR pins a different code, the one comment +> block is the seam to change. +> > **Packet 4 — API conventions ⏳** > REST + URL versioning (`/v1/...` per ADR-0024), Problem Details (RFC 7807) > shape on every error, cursor pagination, idempotency keys for write From 0fd16d5132dd2bd8abffc5d1a3e6148b2d57c385 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 22 May 2026 12:18:49 +0300 Subject: [PATCH 4/9] =?UTF-8?q?fix(phase-02a):=20packet=203=20review-3=20?= =?UTF-8?q?=E2=80=94=20L1=20robustness,=20redaction=20parity,=20semantics?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified each finding against current code; fixed the still-valid ones, skipped the rest with reasons (below). Fixed - L1 handler wraps IErrorTrackingProvider.CaptureAsync in try/catch — a provider failure (Sentry network blip, full disk) can no longer abort TryHandleAsync; the Problem Details response always writes. New LogCaptureFailed event records the swallowed capture error. - correlationId now uses the full W3C traceparent (Activity.Current.Id) in both the L1 handler's CapturedContext and ProblemDetailsFactory, with a fallback chain to ITenantContext.CorrelationId then HttpContext.TraceIdentifier. Matches the ITenantContext.CorrelationId contract and keeps the Problem Details body + Sentry/LocalFile capture on one handle. - ErrorTrackingRegistration clamps Sentry TracesSampleRate to [0,1] (Math.Clamp) — a mis-typed appsettings value no longer crashes startup via Sentry's range-checked setter. - NoOpErrorTracker null-guards exception + context for parity with the Sentry / LocalFile implementations (a contract bug surfaces in dev, not just prod). - SentryErrorTracker redacts AdditionalTags via SensitiveTokenCatalog before scope.SetTag — the same catalog the Serilog enricher + the air-gapped LocalFileErrorTracker share, so all three external-egress surfaces redact identically. - DomainException default Error is now lockey_internal_error (→ 500), not lockey_business_rule_violation (→ 409). A DomainException reaching L1 is a bug, not a refused business operation (ADR-0032 § Sub-decision 4). - IErrorTrackingProvider_Is_Singleton asserts singleton *lifetime* — resolves twice from root + once from a fresh scope and asserts reference equality, not just registration count. - RedactSensitiveFieldsEnricher uses a two-pass lazy approach: the common no-sensitive-property path now allocates nothing (was a per-event ToArray); the List materialises only on the first match. - Normalized 9 TODO comments to the documented (YYYY-MM-DD, @owner) two-token format (CLAUDE.md / Standards 02 § Comments); phase/packet info moved into the description body. Skipped (with reason) - DeploymentMode enum reduction to {SaaS, Dedicated, SelfHosted}: rejected — Standards 20 § Composition Root + ADR-0020 explicitly mandate the 5-value form (Development + SelfHostedOnline + SelfHostedAirGapped); the split is what lets the composition root pick phone-home vs signed-license without runtime branching. The whole ErrorTracking switch + Standards 20 table depend on it. - Strongly-typed IDs in CapturedContext + ITenantContext: deferred — the TenantId / OrganizationId Vogen value objects do not exist yet (they land with the Tenancy schema in Packet 6/7). ITenantContext itself uses raw Guid for the same reason; typing only CapturedContext (or mixing one typed UserId with two raw Guids) would be inconsistent. Revisit when the Tenancy IDs land. Validation - dotnet build LearnStack.slnx (CI=true) → 0 warning, 0 error. - 142/142 tests green: Unit 111, Architecture 25, Integration 5, Contract 1. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Common/LearnStackExceptionHandler.cs | 42 ++++++++++++++++--- .../Common/ProblemDetailsFactory.cs | 11 +++-- .../CrossCuttingFoundationExtensions.cs | 2 +- .../Pipeline/AuditLogBehavior.cs | 4 +- .../Pipeline/AuthorizationBehavior.cs | 2 +- .../Pipeline/OutboxFlushBehavior.cs | 2 +- .../Pipeline/TenantContextBehavior.cs | 4 +- .../Pipeline/TransactionBehavior.cs | 2 +- .../ErrorTrackingRegistration.cs | 9 +++- .../NoOpErrorTracker.cs | 11 ++++- .../SentryErrorTracker.cs | 13 +++++- .../Serilog/RedactSensitiveFieldsEnricher.cs | 27 +++++++++--- .../Errors/DomainException.cs | 9 +++- .../CrossCuttingFoundationTests.cs | 11 +++++ 14 files changed, 123 insertions(+), 26 deletions(-) diff --git a/backend/src/LearnStack.Api/Common/LearnStackExceptionHandler.cs b/backend/src/LearnStack.Api/Common/LearnStackExceptionHandler.cs index 6c7df02..c637a03 100644 --- a/backend/src/LearnStack.Api/Common/LearnStackExceptionHandler.cs +++ b/backend/src/LearnStack.Api/Common/LearnStackExceptionHandler.cs @@ -58,10 +58,25 @@ public async ValueTask TryHandleAsync( if (capture) { - var capturedContext = BuildCapturedContext(httpContext); - await errorTracker.CaptureAsync(exception, capturedContext, cancellationToken) - .ConfigureAwait(false); - LogCaptured(logger, exception.GetType().FullName ?? "", exception); + // The error-tracking provider must never abort the L1 handler — + // it is the last line of defense and the Problem Details + // response has to be written even if Sentry / the local-file + // sink throws (network blip, full disk, mis-configuration). + // Swallow + log the capture failure; the response write below + // always runs. + try + { + var capturedContext = BuildCapturedContext(httpContext); + await errorTracker.CaptureAsync(exception, capturedContext, cancellationToken) + .ConfigureAwait(false); + LogCaptured(logger, exception.GetType().FullName ?? "", exception); + } +#pragma warning disable CA1031 // L1 must complete; provider failure cannot escape. + catch (Exception captureFailure) +#pragma warning restore CA1031 + { + LogCaptureFailed(logger, exception.GetType().FullName ?? "", captureFailure); + } } else { @@ -106,10 +121,19 @@ await httpContext.Response.WriteAsJsonAsync( private CapturedContext BuildCapturedContext(HttpContext httpContext) { var context = tenantContextAccessor.Current; - var traceId = Activity.Current?.TraceId.ToString(); + + // Prefer the full W3C traceparent (00-trace-span-flags) per the + // ITenantContext.CorrelationId contract — Activity.Current.Id + // carries it, whereas TraceId is only the 32-hex trace component. + // Fall through to the resolved context's CorrelationId, then the + // ASP.NET request id so a capture is never left without a handle + // that correlates to the client's Problem Details body. + var correlationId = Activity.Current?.Id + ?? context?.CorrelationId + ?? httpContext.TraceIdentifier; return new CapturedContext( - CorrelationId: traceId ?? context?.CorrelationId, + CorrelationId: correlationId, RequestPath: httpContext.Request.Path.Value, RequestMethod: httpContext.Request.Method, TenantId: context?.IsResolved == true ? context.TenantId : null, @@ -130,4 +154,10 @@ private CapturedContext BuildCapturedContext(HttpContext httpContext) LogLevel.Information, new EventId(2, nameof(LogSkipped)), "L1 exception handler skipped Sentry capture for {ExceptionType} per Standards 09 boundary."); + + private static readonly Action LogCaptureFailed = + LoggerMessage.Define( + LogLevel.Error, + new EventId(3, nameof(LogCaptureFailed)), + "L1 exception handler failed to capture {ExceptionType} to IErrorTrackingProvider; Problem Details response still written."); } diff --git a/backend/src/LearnStack.Api/Common/ProblemDetailsFactory.cs b/backend/src/LearnStack.Api/Common/ProblemDetailsFactory.cs index 10a424f..a2f7a22 100644 --- a/backend/src/LearnStack.Api/Common/ProblemDetailsFactory.cs +++ b/backend/src/LearnStack.Api/Common/ProblemDetailsFactory.cs @@ -98,10 +98,15 @@ private static string TrimFailedSuffix(string code) => private static string? ResolveCorrelationId(HttpContext? context) { - var traceId = Activity.Current?.TraceId.ToString(); - if (!string.IsNullOrWhiteSpace(traceId)) + // Activity.Current.Id is the full W3C traceparent + // (00-trace-span-flags) — matches the ITenantContext.CorrelationId + // contract and what the L1 handler tags Sentry / LocalFile captures + // with, so the Problem Details body and the captured error share one + // handle. TraceId alone is only the 32-hex trace component. + var traceParent = Activity.Current?.Id; + if (!string.IsNullOrWhiteSpace(traceParent)) { - return traceId; + return traceParent; } return context?.TraceIdentifier; diff --git a/backend/src/LearnStack.Api/Composition/CrossCuttingFoundationExtensions.cs b/backend/src/LearnStack.Api/Composition/CrossCuttingFoundationExtensions.cs index df4874e..e48f890 100644 --- a/backend/src/LearnStack.Api/Composition/CrossCuttingFoundationExtensions.cs +++ b/backend/src/LearnStack.Api/Composition/CrossCuttingFoundationExtensions.cs @@ -178,7 +178,7 @@ private static ISecretProvider SelectSecretProvider( DeploymentMode deploymentMode, IConfiguration configuration) { - // TODO(2026-05-21, @platform, phase-02a-packet-5): light up the + // TODO(2026-05-21, @platform): Phase 02a Packet 5 — light up the // Dapr-backed branch. // DeploymentMode.SaaS / Dedicated / SelfHostedOnline → // new DaprSecretProvider(...) // Vault-backed diff --git a/backend/src/LearnStack.Application/Pipeline/AuditLogBehavior.cs b/backend/src/LearnStack.Application/Pipeline/AuditLogBehavior.cs index 0263669..3752842 100644 --- a/backend/src/LearnStack.Application/Pipeline/AuditLogBehavior.cs +++ b/backend/src/LearnStack.Application/Pipeline/AuditLogBehavior.cs @@ -54,7 +54,7 @@ public async Task Handle( { var response = await next().ConfigureAwait(false); - // TODO(2026-05-21, @platform, phase-02a-packet-9): on success, + // TODO(2026-05-21, @platform): Phase 02a Packet 9 — on success, // resolve IAuditStateCapture for the request type and write the // success-class audit entry through IAuditStore. Per ADR-0016 + // Standards 18 audit-coverage matrix. The shell shape here keeps @@ -71,7 +71,7 @@ public async Task Handle( catch (Exception ex) when (ex is not OperationCanceledException) #pragma warning restore CA1031 { - // TODO(2026-05-21, @platform, phase-02a-packet-9): write the + // TODO(2026-05-21, @platform): Phase 02a Packet 9 — write the // failure-class audit entry to audit_log via IAuditStore. The // shell logs the audit-intent so we do not silently lose the // failure visibility while Packet 9 is pending. diff --git a/backend/src/LearnStack.Application/Pipeline/AuthorizationBehavior.cs b/backend/src/LearnStack.Application/Pipeline/AuthorizationBehavior.cs index 53c9cbc..8547bfa 100644 --- a/backend/src/LearnStack.Application/Pipeline/AuthorizationBehavior.cs +++ b/backend/src/LearnStack.Application/Pipeline/AuthorizationBehavior.cs @@ -31,7 +31,7 @@ public Task Handle( { ArgumentNullException.ThrowIfNull(next); - // TODO(2026-05-21, @platform, phase-03): resolve the request's + // TODO(2026-05-21, @platform): Phase 03 — resolve the request's // [Authorize(Policy)] attribute, call IAuthorizationService.AuthorizeAsync // with the tenant + organization-scoped resource, and return // Result.FailFor(forbidden) on deny. diff --git a/backend/src/LearnStack.Application/Pipeline/OutboxFlushBehavior.cs b/backend/src/LearnStack.Application/Pipeline/OutboxFlushBehavior.cs index 98e703a..db873c0 100644 --- a/backend/src/LearnStack.Application/Pipeline/OutboxFlushBehavior.cs +++ b/backend/src/LearnStack.Application/Pipeline/OutboxFlushBehavior.cs @@ -28,7 +28,7 @@ public Task Handle( { ArgumentNullException.ThrowIfNull(next); - // TODO(2026-05-21, @platform, phase-02b): on a success-Result, flush + // TODO(2026-05-21, @platform): Phase 02b — on a success-Result, flush // IOutbox messages collected during the handler into outbox_messages // via the unit-of-work seam so Dapr pub/sub dispatches them after // commit. Per ADR-0006 + ADR-0014 + ADR-0032 § Sub-decision 12. diff --git a/backend/src/LearnStack.Application/Pipeline/TenantContextBehavior.cs b/backend/src/LearnStack.Application/Pipeline/TenantContextBehavior.cs index 768527e..3ba3a1a 100644 --- a/backend/src/LearnStack.Application/Pipeline/TenantContextBehavior.cs +++ b/backend/src/LearnStack.Application/Pipeline/TenantContextBehavior.cs @@ -43,7 +43,7 @@ public Task Handle( return Task.FromResult(Result.FailFor(TenantMismatchError)); } - // TODO(2026-05-21, @platform, phase-02a-packet-7): set the PostgreSQL + // TODO(2026-05-21, @platform): Phase 02a Packet 7 — set the PostgreSQL // RLS GUCs via a DbConnectionInterceptor (transaction-local // set_config('app.tenant_id', ..., true) / // set_config('app.organization_id', ..., true)). The interceptor @@ -60,7 +60,7 @@ public Task Handle( /// every request needs a resolved context to proceed. /// /// - /// TODO(2026-05-21, @platform, phase-02a-packet-7): replace the stub + /// TODO(2026-05-21, @platform): Phase 02a Packet 7 — replace the stub /// with a real discriminator. The intended seam is a marker attribute /// ([AllowsUnresolvedTenantContext]) the predicate scans for /// via reflection, paired with an architecture test that asserts the diff --git a/backend/src/LearnStack.Application/Pipeline/TransactionBehavior.cs b/backend/src/LearnStack.Application/Pipeline/TransactionBehavior.cs index 034da03..e878038 100644 --- a/backend/src/LearnStack.Application/Pipeline/TransactionBehavior.cs +++ b/backend/src/LearnStack.Application/Pipeline/TransactionBehavior.cs @@ -31,7 +31,7 @@ public Task Handle( { ArgumentNullException.ThrowIfNull(next); - // TODO(2026-05-21, @platform, phase-02a-packet-6): open the UoW + // TODO(2026-05-21, @platform): Phase 02a Packet 6 — open the UoW // transaction (per-module DbContext.Database.BeginTransactionAsync), // commit on success-Result, rollback on fail-Result, and rollback + // rethrow on exception (preserving the rethrow that AuditLogBehavior diff --git a/backend/src/LearnStack.Infrastructure.ErrorTracking/ErrorTrackingRegistration.cs b/backend/src/LearnStack.Infrastructure.ErrorTracking/ErrorTrackingRegistration.cs index c0726f7..82efb29 100644 --- a/backend/src/LearnStack.Infrastructure.ErrorTracking/ErrorTrackingRegistration.cs +++ b/backend/src/LearnStack.Infrastructure.ErrorTracking/ErrorTrackingRegistration.cs @@ -95,11 +95,18 @@ private static void InitSentry(string? dsn, SentrySettings options) + "fall-through). Per ADR-0032 § Sub-decision 9."); } + // Sentry's TracesSampleRate setter throws when the value is outside + // [0, 1]. A mis-typed appsettings value (e.g. 1.5) would otherwise + // crash startup with a cryptic Sentry error; clamp defensively so + // the misconfiguration degrades to "sample everything / nothing" + // rather than a boot failure. + var sampleRate = Math.Clamp(options.TracesSampleRate, 0.0, 1.0); + SentrySdk.Init(o => { o.Dsn = dsn; o.Environment = options.Environment; - o.TracesSampleRate = options.TracesSampleRate; + o.TracesSampleRate = sampleRate; }); } } diff --git a/backend/src/LearnStack.Infrastructure.ErrorTracking/NoOpErrorTracker.cs b/backend/src/LearnStack.Infrastructure.ErrorTracking/NoOpErrorTracker.cs index 49dbeab..e7e8aa0 100644 --- a/backend/src/LearnStack.Infrastructure.ErrorTracking/NoOpErrorTracker.cs +++ b/backend/src/LearnStack.Infrastructure.ErrorTracking/NoOpErrorTracker.cs @@ -14,5 +14,14 @@ internal sealed class NoOpErrorTracker : IErrorTrackingProvider public ValueTask CaptureAsync( Exception exception, CapturedContext context, - CancellationToken cancellationToken = default) => ValueTask.CompletedTask; + CancellationToken cancellationToken = default) + { + // Guard for parity with the Sentry / LocalFile implementations so a + // null argument fails the same way in Development as it would in + // production — a contract bug surfaces locally instead of hiding + // behind the no-op. + ArgumentNullException.ThrowIfNull(exception); + ArgumentNullException.ThrowIfNull(context); + return ValueTask.CompletedTask; + } } diff --git a/backend/src/LearnStack.Infrastructure.ErrorTracking/SentryErrorTracker.cs b/backend/src/LearnStack.Infrastructure.ErrorTracking/SentryErrorTracker.cs index edbf90d..9713cd9 100644 --- a/backend/src/LearnStack.Infrastructure.ErrorTracking/SentryErrorTracker.cs +++ b/backend/src/LearnStack.Infrastructure.ErrorTracking/SentryErrorTracker.cs @@ -1,4 +1,5 @@ using LearnStack.SharedKernel.Observability; +using LearnStack.SharedKernel.Secrets; using Sentry; namespace LearnStack.Infrastructure.ErrorTracking; @@ -65,7 +66,17 @@ public ValueTask CaptureAsync( { foreach (var (key, value) in context.AdditionalTags) { - scope.SetTag(key, value); + // Redact sensitive tag values before they leave the + // process — Sentry is external egress. Uses the same + // SensitiveTokenCatalog the Serilog enricher + the + // air-gapped LocalFileErrorTracker share so the three + // surfaces cannot drift (Standards 11 § Sensitive Data + // Exposure). + scope.SetTag( + key, + SensitiveTokenCatalog.IsSensitive(key) + ? SensitiveTokenCatalog.RedactedValue + : value); } } }); diff --git a/backend/src/LearnStack.Infrastructure.Observability/Serilog/RedactSensitiveFieldsEnricher.cs b/backend/src/LearnStack.Infrastructure.Observability/Serilog/RedactSensitiveFieldsEnricher.cs index 018c6b6..5770801 100644 --- a/backend/src/LearnStack.Infrastructure.Observability/Serilog/RedactSensitiveFieldsEnricher.cs +++ b/backend/src/LearnStack.Infrastructure.Observability/Serilog/RedactSensitiveFieldsEnricher.cs @@ -29,8 +29,8 @@ namespace LearnStack.Infrastructure.Observability.Serilog; /// exception messages) so the boundary stays honest. /// /// -/// TODO(2026-05-21, @platform, phase-02b-or-later): augment the Serilog -/// pipeline with a Roslyn analyzer (extending LearnStack.Analyzers) +/// TODO(2026-05-21, @platform): augment the Serilog pipeline with a Roslyn +/// analyzer (extending LearnStack.Analyzers) in Phase 02b or later /// that flags string-interpolated throw new ...Exception($"...{token}...") /// patterns in Domain + Application projects. Today the /// "no secrets in exception messages" rule rests on Standards 11 review @@ -48,13 +48,30 @@ public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory) ArgumentNullException.ThrowIfNull(logEvent); ArgumentNullException.ThrowIfNull(propertyFactory); - foreach (var propertyName in logEvent.Properties.Keys.ToArray()) + // Two-pass to avoid a steady-state per-event allocation: the common + // case (no sensitive properties) allocates nothing. The `sensitive` + // list materialises only when the first match is found, so we never + // ToArray the key set up front. The collect-then-mutate split is + // also what keeps us from mutating logEvent.Properties while + // enumerating it. + List? sensitive = null; + foreach (var propertyName in logEvent.Properties.Keys) { if (SensitiveTokenCatalog.IsSensitive(propertyName)) { - logEvent.AddOrUpdateProperty( - propertyFactory.CreateProperty(propertyName, RedactedValue)); + (sensitive ??= []).Add(propertyName); } } + + if (sensitive is null) + { + return; + } + + foreach (var propertyName in sensitive) + { + logEvent.AddOrUpdateProperty( + propertyFactory.CreateProperty(propertyName, RedactedValue)); + } } } diff --git a/backend/src/LearnStack.SharedKernel/Errors/DomainException.cs b/backend/src/LearnStack.SharedKernel/Errors/DomainException.cs index 5baea9b..43b3a9a 100644 --- a/backend/src/LearnStack.SharedKernel/Errors/DomainException.cs +++ b/backend/src/LearnStack.SharedKernel/Errors/DomainException.cs @@ -19,8 +19,15 @@ namespace LearnStack.SharedKernel.Errors; /// public sealed class DomainException : LearnStackException { + // A DomainException reaching the L1 handler is a *bug* — an invariant + // was bypassed, not an expected outcome. Its default code must map to a + // 500 (programmer / internal error), NOT business_rule_violation (409), + // which is reserved for the Result.Fail path. Using + // lockey_business_rule_violation here would misclassify a crash as a + // refused business operation. Per ADR-0032 § Sub-decision 4 + + // Standards 09 § Domain Exceptions. private static readonly Error DefaultError = new( - new LocalizedMessage("lockey_business_rule_violation")); + new LocalizedMessage("lockey_internal_error")); public DomainException(string message, Exception? innerException = null) : base(DefaultError, message, innerException) diff --git a/backend/tests/LearnStack.Tests.Architecture/CrossCuttingFoundationTests.cs b/backend/tests/LearnStack.Tests.Architecture/CrossCuttingFoundationTests.cs index dfce869..e457f02 100644 --- a/backend/tests/LearnStack.Tests.Architecture/CrossCuttingFoundationTests.cs +++ b/backend/tests/LearnStack.Tests.Architecture/CrossCuttingFoundationTests.cs @@ -277,6 +277,17 @@ public void IErrorTrackingProvider_Is_Singleton() providers.Should().HaveCount(1, "exactly one IErrorTrackingProvider is registered per DeploymentMode " + "(ADR-0032 § Sub-decision 9)."); + + // Registration count is necessary but not sufficient — assert the + // singleton *lifetime* by resolving twice from the root and once + // from a fresh scope; all three must be the same instance. + var first = application.Services.GetRequiredService(); + var second = application.Services.GetRequiredService(); + using var scope = application.Services.CreateScope(); + var scoped = scope.ServiceProvider.GetRequiredService(); + + second.Should().BeSameAs(first, "the provider is registered as a singleton."); + scoped.Should().BeSameAs(first, "a singleton resolves to the same instance across scopes."); } private static WebApplication BuildMinimalApiHost() From b4c98f0beb119e848c6360381ef8a84f4e5001be Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 22 May 2026 14:51:43 +0300 Subject: [PATCH 5/9] =?UTF-8?q?fix(phase-02a):=20packet=203=20review-3/4?= =?UTF-8?q?=20=E2=80=94=20analyzer=20crash,=20redaction=20depth,=20provide?= =?UTF-8?q?r-error=20consistency?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified each finding from the two review passes against current code; fixed the valid ones, skipped the rest with reasons. Blocker - H1 The DomainExceptionThrow analyzer used a hyphenated Roslyn diagnostic id ("LearnStackException-DomainExceptionThrow"), which Roslyn rejects — it threw AD0001 at report time, so the intended warning never fired and, under CI's TreatWarningsAsErrors, the first DomainException throw would break the build. Reproduced empirically. Fixed: diagnostic id is now LS0001 (valid identifier); the hyphenated string is retained as the human-readable rule name in the title/help text. LS0001 is listed in WarningsNotAsErrors so a legitimate aggregate-invariant throw stays a warning in CI until the Phase 03 escalation. New DomainExceptionThrowAnalyzerTests run the analyzer over synthetic compilations and assert LS0001 fires (no AD0001). Recorded as ADR-0032 Amendment 1; Standards 21 naming convention + analyzer entry + wiring description (ProjectReference OutputItemType=Analyzer, not PackageReference) corrected. Major - Provider error body/status consistency: HttpStatusMap.For(Exception) now derives the status from the carried Error.Code for every LearnStackException instead of special-casing ProviderException.IsClientError → 400. IsClientError is purely the Sentry boundary; a bare provider failure is dependency_unavailable → 503, and an adapter surfacing a provider 4xx passes an explicit Error (validation_failed → 400). Body code and status can no longer disagree. Tests assert both. ProviderException doc updated. - Redaction over-match + nesting: SensitiveTokenCatalog.IsSensitive matches on word-segment boundaries (camelCase / _ . -) instead of raw substrings, so ClassName / BusinessName are no longer redacted by the "ssn" token while Password / ApiKey / SSNToken still are. RedactSensitiveFieldsEnricher recurses into StructureValue / DictionaryValue / SequenceValue so a sensitive field nested in a non-sensitive top-level property (User.Password) is scrubbed; lazy reconstruction keeps clean events allocation-free. New SensitiveTokenCatalogTests + RedactSensitiveFieldsEnricherTests. - OTel naming + air-gapped: AddSource / AddMeter use the documented lowercase learnstack.* convention (matching the learnstack.mediatr ActivitySource without relying on case-insensitive wildcard matching). WireSerilog / WireOpenTelemetry now take DeploymentMode; SelfHostedAirGapped never wires the network OTLP exporters (no-egress contract), with a dated TODO for the /var/learnstack/otel/ file target deferred to Phase 11 ops. Medium - M1 New Handlers_Return_Result architecture test asserts every IRequestHandler<,TResponse> has TResponse : IResultBase, so a raw-DTO handler cannot silently bypass the pipeline (validation / audit / tenant-context + RLS). Vacuous today, active when handlers land. Low - L4 Serilog enrichers are resolved from DI (the singletons registered in AddLearnStackObservabilityServices) instead of being new()'d in the pipeline — no dead registrations. Docs - Domain_Methods_Do_Not_Throw_For_Expected_Cases marked deferred in Standards 21 (the LS0001 analyzer already enforces the rule at build time; the report-walking architecture test lands with module domain code in Packet 6+). LoggingBehavior activity-name doc, correlationId-as-full- traceparent in Standards 09/10, and the IMeterFactory.Create example in architecture 33 reconciled with the code. Analyzer helpLinkUri casing. Skipped (with reason) - Resilience pipeline order (retry → breaker → timeout → bulkhead) left as-is — faithful to ADR-0032 § Sub-decision 5's stated order. Whether the concurrency limiter should sit outermost (cap total in-flight incl. retries, per Microsoft.Extensions.Resilience) is an ADR-level question for a future amendment, not a code defect. - DeploymentMode 5-value enum: corpus-mandated (Standards 20 + ADR-0020); not reduced. - Strongly-typed IDs in CapturedContext / ITenantContext: deferred until the TenantId / OrganizationId value objects land (Packet 6/7). Validation - dotnet build LearnStack.slnx (CI=true) → 0 warning, 0 error. - Unit 154, Architecture 26, Integration 5, Contract 1 — all green. Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/Directory.Build.props | 10 ++ .../AnalyzerReleases.Unshipped.md | 2 +- .../DomainExceptionThrowAnalyzer.cs | 25 ++- .../LearnStack.Api/Common/HttpStatusMap.cs | 15 +- .../CrossCuttingFoundationExtensions.cs | 55 ++++-- .../Pipeline/LoggingBehavior.cs | 6 +- .../Serilog/RedactSensitiveFieldsEnricher.cs | 129 ++++++++++++-- .../Errors/ProviderException.cs | 15 ++ .../Secrets/SensitiveTokenCatalog.cs | 161 +++++++++++++----- .../CrossCuttingFoundationTests.cs | 50 ++++++ .../CrossCuttingFoundationHttpTests.cs | 20 ++- .../DomainExceptionThrowAnalyzerTests.cs | 93 ++++++++++ .../Api/Common/HttpStatusMapTests.cs | 28 +-- .../RedactSensitiveFieldsEnricherTests.cs | 108 ++++++++++++ .../LearnStack.Tests.Unit.csproj | 7 + .../Secrets/SensitiveTokenCatalogTests.cs | 61 +++++++ .../architecture/33-cross-cutting-concerns.md | 6 +- ...tion-handling-logging-and-observability.md | 30 ++++ docs/roadmap/phase-02a-kernel-tenancy.md | 65 +++++++ docs/standards/09-error-handling.md | 12 +- docs/standards/10-observability.md | 2 +- .../21-architecture-tests-catalogue.md | 71 ++++++-- 22 files changed, 854 insertions(+), 117 deletions(-) create mode 100644 backend/tests/LearnStack.Tests.Unit/Analyzers/DomainExceptionThrowAnalyzerTests.cs create mode 100644 backend/tests/LearnStack.Tests.Unit/Infrastructure/Observability/RedactSensitiveFieldsEnricherTests.cs create mode 100644 backend/tests/LearnStack.Tests.Unit/SharedKernel/Secrets/SensitiveTokenCatalogTests.cs diff --git a/backend/Directory.Build.props b/backend/Directory.Build.props index ce3cc65..3349137 100644 --- a/backend/Directory.Build.props +++ b/backend/Directory.Build.props @@ -6,6 +6,16 @@ enable enable true + + $(WarningsNotAsErrors);LS0001 true latest + @@ -23,6 +27,9 @@ + +