diff --git a/backend/Directory.Build.props b/backend/Directory.Build.props
index dc58aed..3349137 100644
--- a/backend/Directory.Build.props
+++ b/backend/Directory.Build.props
@@ -6,6 +6,16 @@
enable
enable
true
+
+ $(WarningsNotAsErrors);LS0001
true
latest
- $(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 3b950e5..061491d 100644
--- a/backend/Directory.Packages.props
+++ b/backend/Directory.Packages.props
@@ -54,22 +54,66 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
+
+
+
+
+
+
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..8312234
--- /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
+--------|----------|----------|------------
+LS0001 | Design | Warning | Rule "LearnStackException-DomainExceptionThrow" (ADR-0032 § Sub-decision 4 + Amendment 1). DomainException is reserved for programmer errors; expected business-rule violations return Result.Fail(business_rule_violation, ...). Roslyn diagnostic ids must be valid identifiers, so the wire id is LS0001; the hyphenated string is the human-readable rule name. 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..86fedd3
--- /dev/null
+++ b/backend/analyzers/LearnStack.Analyzers/DomainExceptionThrowAnalyzer.cs
@@ -0,0 +1,126 @@
+using System.Collections.Immutable;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+using Microsoft.CodeAnalysis.Diagnostics;
+
+namespace LearnStack.Analyzers;
+
+///
+/// Rule "LearnStackException-DomainExceptionThrow" (Roslyn diagnostic id
+/// = LS0001) — 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.
+///
+///
+///
+/// The Roslyn diagnostic id MUST be a valid identifier (letters/digits, no
+/// hyphens) — Roslyn throws AD0001 at report time otherwise. The
+/// human-readable rule name LearnStackException-DomainExceptionThrow
+/// (ADR-0032 / Standards 21 naming convention) is carried in the title and
+/// help text; the wire-level id is LS0001. See ADR-0032 Amendment 1.
+///
+///
+/// Severity: in Phase 02a. Per
+/// ADR-0032 the severity escalates to
+/// after Phase 03 exit when every existing call site has been migrated.
+/// Until then LS0001 is listed in WarningsNotAsErrors
+/// (Directory.Build.props) so a legitimate aggregate-invariant throw does
+/// not break CI under TreatWarningsAsErrors before the documented
+/// escalation point.
+///
+///
+[DiagnosticAnalyzer(LanguageNames.CSharp)]
+public sealed class DomainExceptionThrowAnalyzer : DiagnosticAnalyzer
+{
+ public const string DiagnosticId = "LS0001";
+
+ /// The human-readable rule name (ADR-0032 / Standards 21).
+ public const string RuleName = "LearnStackException-DomainExceptionThrow";
+
+ private static readonly LocalizableString Title =
+ "Avoid throwing DomainException for expected business-rule violations (LearnStackException-DomainExceptionThrow)";
+
+ 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..4e21986
--- /dev/null
+++ b/backend/src/LearnStack.Api/Common/HttpStatusMap.cs
@@ -0,0 +1,78 @@
+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
+ {
+ // 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,
+
+ // Every LearnStackException carries a structured Error; the HTTP
+ // status is derived from that Error.Code so the response status
+ // and the Problem Details `code` field can NEVER disagree. In
+ // particular `ProviderException.IsClientError` is an
+ // observability concern (it gates Sentry capture in
+ // ShouldCapture), NOT an HTTP-status concern: a bare provider
+ // failure carries `dependency_unavailable` → 503, and an adapter
+ // that wants to surface a provider 4xx as a client-actionable
+ // status passes an explicit Error (e.g. validation_failed → 400).
+ // Deriving from the code keeps body+status consistent for both.
+ 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..c637a03
--- /dev/null
+++ b/backend/src/LearnStack.Api/Common/LearnStackExceptionHandler.cs
@@ -0,0 +1,163 @@
+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).
+///
+///
+/// internal sealed — modules do not (and per ADR-0032 § Sub-decision 1
+/// must not) instantiate it; the framework's
+/// services.AddExceptionHandler<T>() is the only entry. Tests
+/// reach the type through InternalsVisibleTo.
+///
+internal 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);
+ var isProviderClientError = exception is ProviderException { IsClientError: true };
+ var isCancellation = exception is OperationCanceledException;
+
+ // Span semantics per Standards 09 § Sentry vs OpenTelemetry table:
+ // OperationCanceled → leave span Unset, no RecordException
+ // Provider 4xx → SetStatus(Error), no RecordException
+ // everything else → RecordException + SetStatus(Error)
+ // Activity.AddException is the .NET 9+ replacement for the legacy
+ // Activity.RecordException — the ADR's Implementation Notes still
+ // reference the older name; both add the same exception.* tags.
+ if (!isCancellation)
+ {
+ if (!isProviderClientError)
+ {
+ Activity.Current?.AddException(exception);
+ }
+
+ Activity.Current?.SetStatus(ActivityStatusCode.Error, exception.GetType().Name);
+ }
+
+ if (capture)
+ {
+ // 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
+ {
+ LogSkipped(logger, exception.GetType().FullName ?? "", null);
+ }
+
+ // OperationCanceled means the client has already disconnected. The
+ // outbound flush would throw on the closed socket anyway and the
+ // body would not reach a reader. Set the status for completeness
+ // and skip the body.
+ httpContext.Response.StatusCode = problem.Status ?? StatusCodes.Status500InternalServerError;
+ if (isCancellation || cancellationToken.IsCancellationRequested)
+ {
+ return true;
+ }
+
+ 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;
+
+ // 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: 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.");
+
+ 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/ProblemDetailsActionResult.cs b/backend/src/LearnStack.Api/Common/ProblemDetailsActionResult.cs
new file mode 100644
index 0000000..e82d1da
--- /dev/null
+++ b/backend/src/LearnStack.Api/Common/ProblemDetailsActionResult.cs
@@ -0,0 +1,39 @@
+using LearnStack.SharedKernel.Results;
+using Microsoft.AspNetCore.Mvc;
+
+namespace LearnStack.Api.Common;
+
+///
+/// subtype that defers the
+/// body construction until ASP.NET invokes
+/// . The deferred build is what lets the
+/// sanctioned controller shape per ADR-0032 § Sub-decision 6
+/// ((await _mediator.Send(cmd, ct)).ToActionResult()) populate
+/// and the correlationId
+/// extension from the current request without the caller having to thread
+/// explicitly.
+///
+public sealed class ProblemDetailsActionResult : ObjectResult
+{
+ public ProblemDetailsActionResult(Error error)
+ : base(value: null)
+ {
+ Error = error ?? throw new ArgumentNullException(nameof(error));
+ StatusCode = HttpStatusMap.For(error);
+ }
+
+ /// The carried failure — kept around for tests + extension points.
+ public Error Error { get; }
+
+ public override Task ExecuteResultAsync(ActionContext context)
+ {
+ ArgumentNullException.ThrowIfNull(context);
+
+ var problem = ProblemDetailsFactory.For(Error, context.HttpContext);
+ Value = problem;
+ StatusCode = problem.Status;
+ ContentTypes.Clear();
+ ContentTypes.Add("application/problem+json");
+ return base.ExecuteResultAsync(context);
+ }
+}
diff --git a/backend/src/LearnStack.Api/Common/ProblemDetailsFactory.cs b/backend/src/LearnStack.Api/Common/ProblemDetailsFactory.cs
new file mode 100644
index 0000000..0978712
--- /dev/null
+++ b/backend/src/LearnStack.Api/Common/ProblemDetailsFactory.cs
@@ -0,0 +1,173 @@
+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.
+///
+///
+/// intentionally carries the
+/// lockey_* localization key (matches the Standards 09 § API Surface
+/// example). The frontend resolves the key against its i18n catalogue; the
+/// wire value is stable across locales so support staff debugging in
+/// Insomnia / curl can match the lockey back to the catalogue entry. A
+/// future LocalizedMessage → text projector (Phase 02b, Accept-Language
+/// binding) may compose a human-readable Title alongside.
+///
+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);
+
+ // Status MUST come from HttpStatusMap.For(Exception) so
+ // ProviderException(IsClientError = true) returns 400 instead of
+ // falling through to the carried Error.Code's 503 default. Code +
+ // messageKey still come from the carried Error so the wire shape
+ // stays consistent. Standards 09 § Provider Failures + ADR-0032
+ // § Sub-decision 7.
+ var status = HttpStatusMap.For(exception);
+
+ if (exception is LearnStackException known)
+ {
+ var problem = BuildBase(known.Error.Code, known.Error.Message.Key, status, context);
+ if (known.Error.Details is { Count: > 0 })
+ {
+ problem.Extensions["errors"] = ProjectDetails(known.Error.Details);
+ }
+ return problem;
+ }
+
+ // Unhandled / unknown — surface a stable generic shape.
+ return BuildBase(
+ code: "internal_error",
+ messageKey: "lockey_internal_error",
+ status: status,
+ context: context);
+ }
+
+ private static ProblemDetails BuildBase(string code, string messageKey, int status, HttpContext? context)
+ {
+ var problem = new ProblemDetails
+ {
+ // Standards 09 § API Surface example uses the short slug
+ // (e.g. /validation, not /validation_failed). Strip the
+ // trailing _failed when present so the URL stays clean across
+ // codes; other codes ride through unchanged.
+ Type = ProblemTypePrefix + TrimFailedSuffix(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 TrimFailedSuffix(string code) =>
+ code.EndsWith("_failed", StringComparison.Ordinal)
+ ? code[..^"_failed".Length]
+ : code;
+
+ private static string? ResolveCorrelationId(HttpContext? context)
+ {
+ // 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 traceParent;
+ }
+
+ return context?.TraceIdentifier;
+ }
+
+ private static Dictionary> ProjectDetails(
+ IReadOnlyDictionary> details)
+ {
+ // Two distinct source keys can normalize to the same camelCase key
+ // (e.g. "UserId" and "UserID" both project to "userId") — merge
+ // their messages instead of letting the later key win and silently
+ // drop the earlier one's entries.
+ var merged = new Dictionary>(StringComparer.Ordinal);
+ foreach (var (key, list) in details)
+ {
+ var camelKey = ToCamelCase(key);
+ if (!merged.TryGetValue(camelKey, out var messages))
+ {
+ messages = [];
+ merged[camelKey] = messages;
+ }
+
+ messages.AddRange(list.Select(m => (object)new
+ {
+ key = m.Key,
+ @params = m.Params,
+ }));
+ }
+
+ return merged.ToDictionary(
+ kv => kv.Key,
+ kv => (IReadOnlyList
+
+
+
diff --git a/backend/src/LearnStack.Application/Pipeline/AuditLogBehavior.cs b/backend/src/LearnStack.Application/Pipeline/AuditLogBehavior.cs
new file mode 100644
index 0000000..3752842
--- /dev/null
+++ b/backend/src/LearnStack.Application/Pipeline/AuditLogBehavior.cs
@@ -0,0 +1,90 @@
+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.
+ // Cancellation = client disconnect = noise per Standards 09 §
+ // Sentry vs OpenTelemetry table; the L1 handler already swallows
+ // it, and an audit entry for "user pressed Stop" is not useful.
+ // Skip the catch and rethrow naturally.
+ catch (Exception ex) when (ex is not OperationCanceledException)
+#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..8547bfa
--- /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..eae817f
--- /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 mediatr.<RequestName>
+/// on the learnstack.mediatr , 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..f40e6a9
--- /dev/null
+++ b/backend/src/LearnStack.Application/Pipeline/MediatRPipelineRegistration.cs
@@ -0,0 +1,69 @@
+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 7 pipeline behaviors in canonical order. ADR-0032
+ /// § Sub-decision 2 describes the chain as "eight steps" — these 7
+ /// behaviors plus the handler at the innermost position make up that
+ /// sequence. MediatR resolves the handler after the behavior chain
+ /// unwinds; it is not registered here. Architecture tests reflect on
+ /// this list to assert the runtime DI order 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<,>),
+ // Step 8 (the handler) is resolved by MediatR itself.
+ ];
+
+ ///
+ /// 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..db873c0
--- /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..3ba3a1a
--- /dev/null
+++ b/backend/src/LearnStack.Application/Pipeline/TenantContextBehavior.cs
@@ -0,0 +1,73 @@
+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.
+ ///
+ ///
+ /// 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
+ /// attribute lives only on the narrow command-set that legitimately
+ /// runs before any tenant is resolved (e.g. ProvisionTenantCommand,
+ /// EnterPlatformAdminScopeCommand). Documenting the seam now
+ /// so Packet 7 doesn't reinvent it.
+ ///
+ 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..e878038
--- /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..82efb29
--- /dev/null
+++ b/backend/src/LearnStack.Infrastructure.ErrorTracking/ErrorTrackingRegistration.cs
@@ -0,0 +1,112 @@
+using LearnStack.SharedKernel.Hosting;
+using LearnStack.SharedKernel.Observability;
+using LearnStack.SharedKernel.Secrets;
+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.
+///
+///
+/// Sentry DSN reads through per ADR-0032
+/// § Sub-decision 9. Phase 02a Packet 3 ships the
+/// ConfigurationSecretProvider default — Vault-equipped deployments
+/// pick up the Dapr-backed implementation in Packet 5 without changing
+/// this code path.
+///
+public static class ErrorTrackingRegistration
+{
+ public static IServiceCollection AddLearnStackErrorTracking(
+ this IServiceCollection services,
+ ISecretProvider secretProvider,
+ IConfiguration configuration,
+ DeploymentMode deploymentMode)
+ {
+ ArgumentNullException.ThrowIfNull(services);
+ ArgumentNullException.ThrowIfNull(secretProvider);
+ ArgumentNullException.ThrowIfNull(configuration);
+
+ services.Configure(
+ configuration.GetSection(ErrorTrackingOptions.SectionName));
+
+ var options = configuration.GetSection(ErrorTrackingOptions.SectionName)
+ .Get() ?? new ErrorTrackingOptions();
+
+ // ADR-0032 § Sub-decision 9 binds DSN lookup to the secret provider.
+ // ConfigurationSecretProvider falls through to IConfiguration so
+ // dev / CI keep working with appsettings or env vars; Packet 5's
+ // DaprSecretProvider reads from Vault in production.
+ var resolvedDsn = secretProvider.GetSecret("ErrorTracking:Sentry:Dsn");
+
+ switch (deploymentMode)
+ {
+ case DeploymentMode.Development:
+ services.AddSingleton();
+ break;
+
+ case DeploymentMode.SaaS:
+ case DeploymentMode.Dedicated:
+ InitSentry(resolvedDsn, options.Sentry);
+ services.AddSingleton();
+ break;
+
+ case DeploymentMode.SelfHostedOnline:
+ if (!string.IsNullOrWhiteSpace(resolvedDsn))
+ {
+ InitSentry(resolvedDsn, 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(string? dsn, SentrySettings options)
+ {
+ if (string.IsNullOrWhiteSpace(dsn))
+ {
+ throw new InvalidOperationException(
+ "DeploymentMode requires a Sentry DSN but ISecretProvider returned null for "
+ + "'ErrorTracking:Sentry:Dsn'. Provide the DSN via the secret provider — Vault "
+ + "in production, env var or appsettings in dev (the ConfigurationSecretProvider "
+ + "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 = sampleRate;
+ });
+ }
+}
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..e16c281
--- /dev/null
+++ b/backend/src/LearnStack.Infrastructure.ErrorTracking/LocalFileErrorTracker.cs
@@ -0,0 +1,167 @@
+using System.Text.Json;
+using LearnStack.SharedKernel.Observability;
+using LearnStack.SharedKernel.Secrets;
+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
+{
+ ///
+ /// W3C traceparent is 55 chars; defensive cap above that absorbs any
+ /// inbound oddity without blowing the stack via stackalloc.
+ ///
+ private const int MaxFileNameSegmentLength = 128;
+
+ // 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;
+ 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 = RedactSensitiveTags(context.AdditionalTags),
+ };
+
+ var safeCorrelation = SanitiseForFileName(context.CorrelationId);
+ // Guid suffix guarantees uniqueness — millisecond precision +
+ // correlation id are not enough when two captures land in the same
+ // tick for the same trace (a multi-failure burst on a single
+ // request).
+ var fileName =
+ $"{DateTimeOffset.UtcNow:yyyyMMdd-HHmmss-fff}-{safeCorrelation}-{Guid.NewGuid():N}.json";
+ var path = Path.Combine(_directory, fileName);
+ // Serialize into a sibling .tmp file and move it into place only
+ // once the write succeeds, so a crash or cancellation mid-write
+ // never leaves a truncated/corrupt envelope at the final path.
+ var tempPath = path + ".tmp";
+
+ try
+ {
+ await using (var stream = File.Create(tempPath))
+ {
+ await JsonSerializer.SerializeAsync(stream, envelope, SerializerOptions, cancellationToken)
+ .ConfigureAwait(false);
+ }
+
+ File.Move(tempPath, path);
+ }
+#pragma warning disable CA1031 // Air-gapped capture is best-effort; swallow the write failure but log it.
+ catch (Exception writeFailure)
+#pragma warning restore CA1031
+ {
+ DeleteBestEffort(tempPath);
+ LogWriteFailure(_logger, path, writeFailure);
+ }
+ }
+
+ private static void DeleteBestEffort(string tempPath)
+ {
+ try
+ {
+ File.Delete(tempPath);
+ }
+#pragma warning disable CA1031 // Cleanup itself is best-effort; a failed delete just leaves an orphaned .tmp file.
+ catch (Exception)
+#pragma warning restore CA1031
+ {
+ }
+ }
+
+ private static IReadOnlyDictionary? RedactSensitiveTags(
+ IReadOnlyDictionary? tags)
+ {
+ if (tags is null or { Count: 0 })
+ {
+ return tags;
+ }
+
+ var sanitised = new Dictionary(tags.Count, StringComparer.Ordinal);
+ foreach (var (key, value) in tags)
+ {
+ sanitised[key] = SensitiveTokenCatalog.IsSensitive(key) ? RedactedValue : value;
+ }
+
+ return sanitised;
+ }
+
+ private static string SanitiseForFileName(string? correlationId)
+ {
+ if (string.IsNullOrWhiteSpace(correlationId))
+ {
+ return "noid";
+ }
+
+ // Defensive cap before the stackalloc: a multi-KB traceparent
+ // header from a misbehaving client must not blow the call stack.
+ var length = Math.Min(correlationId.Length, MaxFileNameSegmentLength);
+ Span buffer = stackalloc char[length];
+ for (var i = 0; i < 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..e7e8aa0
--- /dev/null
+++ b/backend/src/LearnStack.Infrastructure.ErrorTracking/NoOpErrorTracker.cs
@@ -0,0 +1,27 @@
+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)
+ {
+ // 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/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..9713cd9
--- /dev/null
+++ b/backend/src/LearnStack.Infrastructure.ErrorTracking/SentryErrorTracker.cs
@@ -0,0 +1,86 @@
+using LearnStack.SharedKernel.Observability;
+using LearnStack.SharedKernel.Secrets;
+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)
+ {
+ // 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);
+ }
+ }
+ });
+
+ 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..fe4f67f
--- /dev/null
+++ b/backend/src/LearnStack.Infrastructure.Observability/LearnStack.Infrastructure.Observability.csproj
@@ -0,0 +1,28 @@
+
+
+
+ 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..6774f90
--- /dev/null
+++ b/backend/src/LearnStack.Infrastructure.Observability/ObservabilityRegistration.cs
@@ -0,0 +1,30 @@
+using LearnStack.Infrastructure.Observability.Serilog;
+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
+/// , and the Serilog enrichers
+/// ( +
+/// ) so the Serilog and OTel
+/// pipelines wired by LearnStack.Api can resolve them as singletons.
+///
+public static class ObservabilityRegistration
+{
+ public static IServiceCollection AddLearnStackObservabilityServices(
+ this IServiceCollection services)
+ {
+ ArgumentNullException.ThrowIfNull(services);
+
+ 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..27478bf
--- /dev/null
+++ b/backend/src/LearnStack.Infrastructure.Observability/Serilog/RedactSensitiveFieldsEnricher.cs
@@ -0,0 +1,178 @@
+using LearnStack.SharedKernel.Secrets;
+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 / corporate identifiers,
+/// authorization headers, full payment payloads must never reach the
+/// console or the OTLP sink.
+///
+///
+///
+/// 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.
+///
+///
+/// Redaction is recursive: destructured objects
+/// ({@User}), dictionaries, and sequences are walked so a sensitive
+/// field nested inside a non-sensitive top-level property
+/// (e.g. User.Password) is scrubbed too. Reconstruction is lazy —
+/// a value with no sensitive descendant is returned by reference, so the
+/// common (clean) event allocates nothing.
+///
+///
+/// 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 stays honest.
+///
+///
+/// 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
+/// discipline; promoting it to a compile-time check closes the last
+/// gap the runtime redactor cannot.
+///
+///
+public sealed class RedactSensitiveFieldsEnricher : ILogEventEnricher
+{
+ /// Substituted in place of any matched property value.
+ public const string RedactedValue = SensitiveTokenCatalog.RedactedValue;
+
+ private static readonly ScalarValue RedactedScalar = new(RedactedValue);
+
+ public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory)
+ {
+ ArgumentNullException.ThrowIfNull(logEvent);
+ ArgumentNullException.ThrowIfNull(propertyFactory);
+
+ // Collect first, mutate after — never mutate logEvent.Properties
+ // while enumerating it. The list materialises only when a top-level
+ // property needs rewriting (its name is sensitive, or a sensitive
+ // value lives nested inside it), so a clean event allocates nothing.
+ List? rewrites = null;
+
+ foreach (var (name, value) in logEvent.Properties)
+ {
+ if (SensitiveTokenCatalog.IsSensitive(name))
+ {
+ (rewrites ??= []).Add(new LogEventProperty(name, RedactedScalar));
+ continue;
+ }
+
+ var redacted = Redact(value);
+ if (!ReferenceEquals(redacted, value))
+ {
+ (rewrites ??= []).Add(new LogEventProperty(name, redacted));
+ }
+ }
+
+ if (rewrites is null)
+ {
+ return;
+ }
+
+ foreach (var property in rewrites)
+ {
+ logEvent.AddOrUpdateProperty(property);
+ }
+ }
+
+ ///
+ /// Returns a copy of with every sensitively-named
+ /// nested property / dictionary key redacted, or the same instance when
+ /// nothing changed (so clean values cost no allocation).
+ ///
+ private static LogEventPropertyValue Redact(LogEventPropertyValue value)
+ {
+ switch (value)
+ {
+ case StructureValue structure:
+ {
+ List? newProps = null;
+ for (var i = 0; i < structure.Properties.Count; i++)
+ {
+ var prop = structure.Properties[i];
+ var newValue = SensitiveTokenCatalog.IsSensitive(prop.Name)
+ ? RedactedScalar
+ : Redact(prop.Value);
+
+ if (newProps is null && ReferenceEquals(newValue, prop.Value))
+ {
+ continue;
+ }
+
+ newProps ??= [.. structure.Properties.Take(i)];
+ newProps.Add(new LogEventProperty(prop.Name, newValue));
+ }
+
+ return newProps is null
+ ? structure
+ : new StructureValue(newProps, structure.TypeTag);
+ }
+
+ case DictionaryValue dictionary:
+ {
+ // DictionaryValue.Elements is keyed by ScalarValue (not an
+ // indexable list). On the first change, copy the whole map
+ // then overwrite the changed keys; a clean dictionary returns
+ // by reference.
+ Dictionary? newElements = null;
+ foreach (var element in dictionary.Elements)
+ {
+ var keyName = element.Key.Value?.ToString();
+ var newValue = keyName is not null && SensitiveTokenCatalog.IsSensitive(keyName)
+ ? RedactedScalar
+ : Redact(element.Value);
+
+ if (ReferenceEquals(newValue, element.Value))
+ {
+ continue;
+ }
+
+ newElements ??= new Dictionary(dictionary.Elements);
+ newElements[element.Key] = newValue;
+ }
+
+ return newElements is null
+ ? dictionary
+ : new DictionaryValue(newElements);
+ }
+
+ case SequenceValue sequence:
+ {
+ List? newItems = null;
+ for (var i = 0; i < sequence.Elements.Count; i++)
+ {
+ var item = sequence.Elements[i];
+ var newItem = Redact(item);
+
+ if (newItems is null && ReferenceEquals(newItem, item))
+ {
+ continue;
+ }
+
+ newItems ??= [.. sequence.Elements.Take(i)];
+ newItems.Add(newItem);
+ }
+
+ return newItems is null ? sequence : new SequenceValue(newItems);
+ }
+
+ default:
+ return value; // ScalarValue and unknown value kinds pass through.
+ }
+ }
+}
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..42f4302
--- /dev/null
+++ b/backend/src/LearnStack.Infrastructure.Observability/TenantContextSpanProcessor.cs
@@ -0,0 +1,69 @@
+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)
+ {
+ // 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.ToString());
+ }
+
+ if (context.UserId is { } userId)
+ {
+ data.SetTag("user.id", userId.Value.ToString());
+ }
+ }
+
+ 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..4a3dcf3
--- /dev/null
+++ b/backend/src/LearnStack.Infrastructure.Resilience/LearnStack.Infrastructure.Resilience.csproj
@@ -0,0 +1,22 @@
+
+
+
+ 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..8821aac
--- /dev/null
+++ b/backend/src/LearnStack.Infrastructure.Resilience/ProviderResilience.cs
@@ -0,0 +1,111 @@
+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;
+
+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 and the circuit breaker
+/// apply when the exception is a non-client ,
+/// a transient , or a
+/// raised by this pipeline's own
+/// timeout strategy (Standards 09 § Retry vs. Don't Retry lists timeouts as
+/// retryable). Polly's timeout strategy signals its own elapsed timeout via
+/// , not ,
+/// so genuine caller-initiated cancellation is never mistaken for a
+/// retryable timeout.
+///
+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)
+ .Handle(),
+ 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)
+ .Handle(),
+ 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 — 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.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..43b3a9a
--- /dev/null
+++ b/backend/src/LearnStack.SharedKernel/Errors/DomainException.cs
@@ -0,0 +1,41 @@
+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
+{
+ // 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_internal_error"));
+
+ 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..d7a72d2
--- /dev/null
+++ b/backend/src/LearnStack.SharedKernel/Errors/ProviderException.cs
@@ -0,0 +1,80 @@
+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).
+/// It does not drive the HTTP status returned to the
+/// client.
+///
+///
+/// The HTTP status comes from the carried 's code (see
+/// HttpStatusMap.For(Exception)), so the response status and the
+/// Problem Details code field can never disagree. The convenience
+/// ctor defaults to dependency_unavailable (→ 503), which is the
+/// right shape for an unspecified provider failure. When an adapter wants to
+/// surface a provider 4xx as a client-actionable status, it passes an
+/// explicit (e.g. validation_failed → 400) via
+/// the error-carrying ctor — that error then drives both the body code and
+/// the status consistently.
+///
+///
+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..a580827 100644
--- a/backend/src/LearnStack.SharedKernel/LearnStack.SharedKernel.csproj
+++ b/backend/src/LearnStack.SharedKernel/LearnStack.SharedKernel.csproj
@@ -26,6 +26,19 @@
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/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/LearnStack.SharedKernel/Secrets/SensitiveTokenCatalog.cs b/backend/src/LearnStack.SharedKernel/Secrets/SensitiveTokenCatalog.cs
new file mode 100644
index 0000000..b8d8fad
--- /dev/null
+++ b/backend/src/LearnStack.SharedKernel/Secrets/SensitiveTokenCatalog.cs
@@ -0,0 +1,163 @@
+namespace LearnStack.SharedKernel.Secrets;
+
+///
+/// The canonical list of word-tokens whose presence as a segment of
+/// 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 on word boundaries, not raw substrings, to
+/// avoid over-redaction: a property named ClassName tokenises to
+/// ["class", "name"] and is NOT flagged for the ssn token (raw
+/// Contains("ssn") would wrongly match "classname"), while
+/// UserPassword → ["user", "password"] still matches
+/// password. Two-word tokens (api_key, card_number) match
+/// either the joined form (apikey) or adjacent segments
+/// (Api+Key). 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, 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***";
+
+ ///
+ /// Single-word tokens matched against a whole name-segment (or the
+ /// separator-stripped full name). Sorted alphabetically.
+ ///
+ private static readonly HashSet SingleWordTokens =
+ new(StringComparer.OrdinalIgnoreCase)
+ {
+ "apikey",
+ "authorization",
+ "cardnumber",
+ "credential",
+ "cvc",
+ "cvv",
+ "dsn",
+ "iban",
+ "jwt",
+ "passwd",
+ "password",
+ "secret",
+ "ssn", // national ID (US SSN / TR shorthand)
+ "tckn", // Turkish national ID
+ "token",
+ "vkn", // Turkish corporate tax number (Vergi Kimlik Numarası)
+ };
+
+ ///
+ /// Joined forms of two-word tokens, matched against adjacent
+ /// camelCase / separated segments (Api+Key → apikey).
+ ///
+ private static readonly HashSet TwoWordTokens =
+ new(StringComparer.OrdinalIgnoreCase)
+ {
+ "apikey", // api_key / ApiKey
+ "cardnumber", // card_number / CardNumber
+ "authheader", // auth_header / AuthHeader
+ };
+
+ ///
+ /// The canonical token list (for docs / tests). Returns a snapshot, not
+ /// the backing set, so a caller casting the
+ /// result cannot mutate the catalogue.
+ ///
+ public static IReadOnlyCollection DefaultTokens =>
+ Array.AsReadOnly(SingleWordTokens.ToArray());
+
+ ///
+ /// Returns true when any whole segment of
+ /// (split on camelCase boundaries and _ . - separators) matches a
+ /// sensitive token. Word-boundary matching prevents the substring
+ /// false-positives that a naive Contains would produce (e.g.
+ /// ssn inside className).
+ ///
+ public static bool IsSensitive(string propertyName)
+ {
+ if (string.IsNullOrEmpty(propertyName))
+ {
+ return false;
+ }
+
+ var segments = Tokenize(propertyName);
+
+ // Whole-name fallback: a property literally named "apikey" (no
+ // separators / case transitions) tokenises to a single segment that
+ // the single-word set already covers, so this is implicit.
+ for (var i = 0; i < segments.Count; i++)
+ {
+ if (SingleWordTokens.Contains(segments[i]) || TwoWordTokens.Contains(segments[i]))
+ {
+ return true;
+ }
+
+ if (i + 1 < segments.Count
+ && TwoWordTokens.Contains(segments[i] + segments[i + 1]))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ ///
+ /// Splits a property name into lowercase word segments on camelCase
+ /// transitions and _ . - (and any non-alphanumeric) separators.
+ ///
+ private static List Tokenize(string name)
+ {
+ var segments = new List();
+ var start = 0;
+
+ for (var i = 0; i < name.Length; i++)
+ {
+ var c = name[i];
+ var isSeparator = !char.IsLetterOrDigit(c);
+
+ // camelCase boundaries:
+ // case 1 aA / 1A — lower/digit → Upper (userToken → user|Token)
+ // case 2 ABc — Upper → Upper-then-lower (SSNToken → ssn|Token,
+ // APIKey → api|Key) so trailing acronym letters
+ // start the next word.
+ var isCamelBoundary = i > start && char.IsUpper(c) &&
+ ((char.IsLower(name[i - 1]) || char.IsDigit(name[i - 1]))
+ || (char.IsUpper(name[i - 1])
+ && i + 1 < name.Length
+ && char.IsLower(name[i + 1])));
+
+ if (isSeparator)
+ {
+ if (i > start)
+ {
+ segments.Add(name[start..i].ToLowerInvariant());
+ }
+
+ start = i + 1;
+ }
+ else if (isCamelBoundary)
+ {
+ segments.Add(name[start..i].ToLowerInvariant());
+ start = i;
+ }
+ }
+
+ if (start < name.Length)
+ {
+ segments.Add(name[start..].ToLowerInvariant());
+ }
+
+ return segments;
+ }
+}
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/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
new file mode 100644
index 0000000..3ce2e18
--- /dev/null
+++ b/backend/tests/LearnStack.Tests.Architecture/CrossCuttingFoundationTests.cs
@@ -0,0 +1,369 @@
+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 LearnStack.SharedKernel.Results;
+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 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();
+
+ 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(
+ 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]
+ 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 Handlers_Return_Result()
+ {
+ // The 8-step MediatR pipeline behaviors are constrained
+ // `where TResponse : IResultBase`; MediatR only instantiates an
+ // open-generic behavior for requests whose response satisfies the
+ // constraint. A handler declared IRequestHandler
+ // would therefore run with NO behaviors — no validation, no audit,
+ // and (once Packet 7 lands) no TenantContextBehavior (where RLS GUCs
+ // get set). This test locks the "handlers return Result" invariant
+ // now, while the pipeline contract is fresh. Vacuous today (no
+ // handlers yet); active the moment they land. Standards 02 § MediatR
+ // Use Cases (review-4 M1).
+ var applicationAssemblies = ModuleAssemblyShapes
+ .Where(n => n.EndsWith(".Application", StringComparison.Ordinal))
+ .Append("LearnStack.Application")
+ .Select(TryLoadAssembly)
+ .Where(a => a is not null)
+ .ToArray();
+
+ foreach (var assembly in applicationAssemblies)
+ {
+ foreach (var type in assembly!.GetTypes())
+ {
+ if (type.IsAbstract || type.IsInterface)
+ {
+ continue;
+ }
+
+ foreach (var contract in type.GetInterfaces())
+ {
+ if (!contract.IsGenericType
+ || contract.GetGenericTypeDefinition() != typeof(IRequestHandler<,>))
+ {
+ continue;
+ }
+
+ var responseType = contract.GetGenericArguments()[1];
+ typeof(IResultBase).IsAssignableFrom(responseType).Should().BeTrue(
+ $"{type.FullName} handles a request whose response ({responseType.Name}) "
+ + "does not implement IResultBase. Handlers must return Result so the "
+ + "MediatR pipeline (validation / audit / tenant-context + RLS) applies "
+ + "— a raw-DTO response silently bypasses every behavior. "
+ + "Standards 02 § MediatR Use Cases.");
+ }
+ }
+ }
+ }
+
+ [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()
+ {
+ // 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).");
+
+ // 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()
+ {
+ 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.Integration/CrossCuttingFoundationHttpTests.cs b/backend/tests/LearnStack.Tests.Integration/CrossCuttingFoundationHttpTests.cs
new file mode 100644
index 0000000..8e7895b
--- /dev/null
+++ b/backend/tests/LearnStack.Tests.Integration/CrossCuttingFoundationHttpTests.cs
@@ -0,0 +1,204 @@
+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.Localization;
+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_Consistent_Body_And_Status_For_Client_Side_ProviderException()
+ {
+ var response = await _client.GetAsync(new Uri("/test/throw-provider-4xx", UriKind.Relative));
+
+ // An adapter surfacing a provider 4xx as client-actionable passes an
+ // explicit Error (here validation_failed). HTTP status is derived
+ // from that code, so body code and status agree — 400 + validation_failed,
+ // NOT a 400 carrying dependency_unavailable. IsClientError only gates
+ // Sentry capture, not the status.
+ response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
+ var problem = await response.Content.ReadFromJsonAsync();
+ problem.GetProperty("code").GetString().Should().Be("validation_failed");
+ problem.GetProperty("messageKey").GetString().Should().Be("lockey_validation_failed");
+ }
+
+ [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(
+ error: new Error(new LocalizedMessage("lockey_validation_failed")),
+ providerName: "test-provider",
+ message: "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/Analyzers/DomainExceptionThrowAnalyzerTests.cs b/backend/tests/LearnStack.Tests.Unit/Analyzers/DomainExceptionThrowAnalyzerTests.cs
new file mode 100644
index 0000000..54010b5
--- /dev/null
+++ b/backend/tests/LearnStack.Tests.Unit/Analyzers/DomainExceptionThrowAnalyzerTests.cs
@@ -0,0 +1,93 @@
+using System.Collections.Immutable;
+using FluentAssertions;
+using LearnStack.Analyzers;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.Diagnostics;
+using Xunit;
+
+namespace LearnStack.Tests.Unit.Analyzers;
+
+///
+/// Locks the review-4 H1 regression: the analyzer's Roslyn diagnostic id
+/// must be a valid identifier (LS0001) so reporting succeeds instead
+/// of crashing with AD0001 ("not a valid identifier"). Runs the real
+/// over synthetic compilations.
+///
+public sealed class DomainExceptionThrowAnalyzerTests
+{
+ private const string DomainExceptionShim = """
+ namespace LearnStack.SharedKernel.Errors
+ {
+ public class DomainException : System.Exception
+ {
+ public DomainException(string message) : base(message) { }
+ }
+ }
+ """;
+
+ [Fact]
+ public void DiagnosticId_Is_A_Valid_Roslyn_Identifier()
+ {
+ // Roslyn requires ids to be valid identifiers (no hyphens). The
+ // human-readable rule name keeps the hyphenated form.
+ DomainExceptionThrowAnalyzer.DiagnosticId.Should().Be("LS0001");
+ DomainExceptionThrowAnalyzer.DiagnosticId.Should().MatchRegex("^[A-Za-z][A-Za-z0-9]*$");
+ DomainExceptionThrowAnalyzer.RuleName.Should().Be("LearnStackException-DomainExceptionThrow");
+ }
+
+ [Fact]
+ public async Task Reports_LS0001_On_Throw_New_DomainException()
+ {
+ const string source = DomainExceptionShim + """
+ namespace Sample
+ {
+ public sealed class Aggregate
+ {
+ public void Mutate() =>
+ throw new LearnStack.SharedKernel.Errors.DomainException("boom");
+ }
+ }
+ """;
+
+ var diagnostics = await RunAnalyzerAsync(source);
+
+ // The key assertion: a real diagnostic with the valid id is produced —
+ // NOT an AD0001 analyzer crash.
+ diagnostics.Should().ContainSingle(d => d.Id == "LS0001");
+ diagnostics.Should().NotContain(d => d.Id == "AD0001");
+ }
+
+ [Fact]
+ public async Task Does_Not_Report_When_No_DomainException_Is_Thrown()
+ {
+ const string source = DomainExceptionShim + """
+ namespace Sample
+ {
+ public sealed class Handler
+ {
+ public string Handle() => "ok"; // returns a value, throws nothing
+ }
+ }
+ """;
+
+ var diagnostics = await RunAnalyzerAsync(source);
+
+ diagnostics.Should().NotContain(d => d.Id == "LS0001");
+ diagnostics.Should().NotContain(d => d.Id == "AD0001");
+ }
+
+ private static async Task> RunAnalyzerAsync(string source)
+ {
+ var compilation = CSharpCompilation.Create(
+ assemblyName: "AnalyzerSample",
+ syntaxTrees: [CSharpSyntaxTree.ParseText(source)],
+ references: [MetadataReference.CreateFromFile(typeof(object).Assembly.Location)],
+ options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
+
+ var withAnalyzers = compilation.WithAnalyzers(
+ ImmutableArray.Create(new DomainExceptionThrowAnalyzer()));
+
+ return await withAnalyzers.GetAnalyzerDiagnosticsAsync();
+ }
+}
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..b2ee25a
--- /dev/null
+++ b/backend/tests/LearnStack.Tests.Unit/Api/Common/HttpStatusMapTests.cs
@@ -0,0 +1,70 @@
+using FluentAssertions;
+using LearnStack.Api.Common;
+using LearnStack.SharedKernel.Errors;
+using LearnStack.SharedKernel.Localization;
+using LearnStack.SharedKernel.Results;
+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_Default_Derives_503_From_DependencyUnavailable_Code()
+ {
+ // IsClientError gates Sentry capture only — it does NOT drive the
+ // HTTP status. A bare provider failure carries the default
+ // dependency_unavailable code → 503, regardless of IsClientError,
+ // so the body code and status stay consistent.
+ var clientError = new ProviderException("test", "bad input", isClientError: true);
+ var serverError = new ProviderException("test", "upstream down", isClientError: false);
+
+ HttpStatusMap.For(clientError).Should().Be(503);
+ HttpStatusMap.For(serverError).Should().Be(503);
+ }
+
+ [Fact]
+ public void For_ProviderException_With_Explicit_Error_Derives_Status_From_That_Code()
+ {
+ // An adapter surfacing a provider 4xx as client-actionable passes an
+ // explicit Error; the status follows that code so body+status agree.
+ var ex = new ProviderException(
+ error: new Error(new LocalizedMessage("lockey_validation_failed")),
+ providerName: "test",
+ message: "provider rejected the request",
+ isClientError: true);
+
+ HttpStatusMap.For(ex).Should().Be(400);
+ }
+
+ [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..9618b8f
--- /dev/null
+++ b/backend/tests/LearnStack.Tests.Unit/Api/Common/ResultExtensionsTests.cs
@@ -0,0 +1,106 @@
+using FluentAssertions;
+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 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
+{
+ [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_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 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/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/RedactSensitiveFieldsEnricherTests.cs b/backend/tests/LearnStack.Tests.Unit/Infrastructure/Observability/RedactSensitiveFieldsEnricherTests.cs
new file mode 100644
index 0000000..8e2ba18
--- /dev/null
+++ b/backend/tests/LearnStack.Tests.Unit/Infrastructure/Observability/RedactSensitiveFieldsEnricherTests.cs
@@ -0,0 +1,108 @@
+using FluentAssertions;
+using LearnStack.Infrastructure.Observability.Serilog;
+using LearnStack.SharedKernel.Secrets;
+using Serilog.Core;
+using Serilog.Events;
+using Serilog.Parsing;
+using Xunit;
+
+namespace LearnStack.Tests.Unit.Infrastructure.Observability;
+
+///
+/// RedactSensitiveFieldsEnricher behaviour (review-4): redacts sensitive
+/// top-level AND nested properties, while leaving ordinary fields (and
+/// substring-collision names like ClassName) untouched.
+///
+public sealed class RedactSensitiveFieldsEnricherTests
+{
+ private const string Redacted = SensitiveTokenCatalog.RedactedValue;
+
+ [Fact]
+ public void Redacts_Top_Level_Sensitive_Property()
+ {
+ var logEvent = CreateEvent(
+ new LogEventProperty("Password", new ScalarValue("hunter2")),
+ new LogEventProperty("UserName", new ScalarValue("alice")));
+
+ Enrich(logEvent);
+
+ Scalar(logEvent, "Password").Should().Be(Redacted);
+ Scalar(logEvent, "UserName").Should().Be("alice");
+ }
+
+ [Fact]
+ public void Does_Not_Redact_Substring_Collision_Names()
+ {
+ var logEvent = CreateEvent(
+ new LogEventProperty("ClassName", new ScalarValue("OrderService")),
+ new LogEventProperty("BusinessName", new ScalarValue("Acme")));
+
+ Enrich(logEvent);
+
+ Scalar(logEvent, "ClassName").Should().Be("OrderService");
+ Scalar(logEvent, "BusinessName").Should().Be("Acme");
+ }
+
+ [Fact]
+ public void Redacts_Nested_Sensitive_Property_In_Destructured_Object()
+ {
+ var user = new StructureValue(
+ [
+ new LogEventProperty("Id", new ScalarValue("u-1")),
+ new LogEventProperty("Password", new ScalarValue("hunter2")),
+ new LogEventProperty("Tckn", new ScalarValue("12345678901")),
+ ]);
+ var logEvent = CreateEvent(new LogEventProperty("User", user));
+
+ Enrich(logEvent);
+
+ var redactedUser = (StructureValue)logEvent.Properties["User"];
+ ScalarOf(redactedUser, "Id").Should().Be("u-1");
+ ScalarOf(redactedUser, "Password").Should().Be(Redacted);
+ ScalarOf(redactedUser, "Tckn").Should().Be(Redacted);
+ }
+
+ [Fact]
+ public void Leaves_Clean_Event_Unchanged_By_Reference()
+ {
+ var inner = new ScalarValue("plain");
+ var logEvent = CreateEvent(new LogEventProperty("UserName", inner));
+
+ Enrich(logEvent);
+
+ // No sensitive data anywhere → the original value instance is retained.
+ logEvent.Properties["UserName"].Should().BeSameAs(inner);
+ }
+
+ private static void Enrich(LogEvent logEvent) =>
+ new RedactSensitiveFieldsEnricher().Enrich(logEvent, new SimplePropertyFactory());
+
+ private static LogEvent CreateEvent(params LogEventProperty[] properties)
+ {
+ var logEvent = new LogEvent(
+ DateTimeOffset.UtcNow,
+ LogEventLevel.Information,
+ exception: null,
+ new MessageTemplate("test", []),
+ []);
+
+ foreach (var property in properties)
+ {
+ logEvent.AddOrUpdateProperty(property);
+ }
+
+ return logEvent;
+ }
+
+ private static string? Scalar(LogEvent logEvent, string name) =>
+ ((ScalarValue)logEvent.Properties[name]).Value?.ToString();
+
+ private static string? ScalarOf(StructureValue structure, string name) =>
+ ((ScalarValue)structure.Properties.Single(p => p.Name == name).Value).Value?.ToString();
+
+ private sealed class SimplePropertyFactory : ILogEventPropertyFactory
+ {
+ public LogEventProperty CreateProperty(string name, object? value, bool destructureObjects = false) =>
+ new(name, value as LogEventPropertyValue ?? new ScalarValue(value));
+ }
+}
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..f5219dc
--- /dev/null
+++ b/backend/tests/LearnStack.Tests.Unit/Infrastructure/Observability/TenantContextSpanProcessorTests.cs
@@ -0,0 +1,91 @@
+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);
+
+ // 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");
+ }
+
+ [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..99415cc
--- /dev/null
+++ b/backend/tests/LearnStack.Tests.Unit/Infrastructure/Resilience/ProviderResilienceTests.cs
@@ -0,0 +1,138 @@
+using FluentAssertions;
+using LearnStack.Infrastructure.Resilience;
+using LearnStack.SharedKernel.Errors;
+using LearnStack.SharedKernel.Resilience;
+using Polly.Timeout;
+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");
+ }
+
+ [Fact]
+ public async Task Retry_Activates_On_Pipeline_Timeout()
+ {
+ var options = new ResilienceOptions
+ {
+ Retry = new RetryOptions { MaxAttempts = 2, DelaySeconds = 0, UseJitter = false, Enabled = true },
+ CircuitBreaker = new CircuitBreakerOptions { Enabled = false },
+ Timeout = new TimeoutOptions { Enabled = true, TotalSeconds = 0.05 },
+ };
+
+ var sut = new ProviderResilience("test", options);
+ var attempts = 0;
+
+ var act = async () => await sut.Pipeline.ExecuteAsync(async ct =>
+ {
+ attempts++;
+ // Observes the token the Timeout strategy cancels — mirrors how
+ // a real provider call (e.g. HttpClient) respects the ambient
+ // cancellation token instead of blocking past it.
+ await Task.Delay(TimeSpan.FromSeconds(5), ct);
+ });
+
+ await act.Should().ThrowAsync();
+ attempts.Should().Be(3, "MaxAttempts = 2 retries means 1 initial + 2 retries = 3 invocations, each timing out");
+ }
+
+ [Fact]
+ public async Task Caller_Cancellation_Is_Not_Retried_As_A_Timeout()
+ {
+ // Same enabled retry as Retry_Activates_On_Pipeline_Timeout, but the
+ // callback cancels the caller's own token mid-execution instead of
+ // hitting the pipeline's Timeout strategy — retry's ShouldHandle
+ // must tell the two apart and let real cancellation through.
+ 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;
+ using var cts = new CancellationTokenSource();
+
+ var act = async () => await sut.Pipeline.ExecuteAsync(async (ct) =>
+ {
+ attempts++;
+ cts.Cancel();
+ ct.ThrowIfCancellationRequested();
+ await Task.CompletedTask;
+ }, cts.Token);
+
+ await act.Should().ThrowAsync();
+ attempts.Should().Be(1, "caller-initiated cancellation must propagate immediately, not be retried as a timeout");
+ }
+}
diff --git a/backend/tests/LearnStack.Tests.Unit/LearnStack.Tests.Unit.csproj b/backend/tests/LearnStack.Tests.Unit/LearnStack.Tests.Unit.csproj
index 14c940c..a305a66 100644
--- a/backend/tests/LearnStack.Tests.Unit/LearnStack.Tests.Unit.csproj
+++ b/backend/tests/LearnStack.Tests.Unit/LearnStack.Tests.Unit.csproj
@@ -10,6 +10,14 @@
+
+
+
+
+
+
@@ -19,6 +27,9 @@
+
+