diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 3f64281..83d2c54 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -93,13 +93,48 @@ jobs:
path: artifacts/backend-tests
if-no-files-found: warn
- # ─── Backend integration (deferred — Testcontainers harness lights up P02c-2) ─
+ # ─── Backend integration (Testcontainers — Postgres) ─────────────────────
backend-integration:
- name: backend integration (Testcontainers — deferred)
+ name: backend integration (Testcontainers)
runs-on: ubuntu-latest
- if: false # activate when LearnStack.Hub.Tests.Integration has its first test
+ timeout-minutes: 25
steps:
- - run: echo "Placeholder — P02c-2 wires the first Testcontainers integration test."
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ persist-credentials: false
+
+ - name: Set up .NET SDK
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: ${{ env.DOTNET_SDK_VERSION }}
+
+ - name: Cache NuGet packages
+ uses: actions/cache@v4
+ with:
+ path: ~/.nuget/packages
+ key: nuget-${{ runner.os }}-${{ hashFiles('backend/**/*.csproj', 'backend/Directory.Packages.props') }}
+ restore-keys: |
+ nuget-${{ runner.os }}-
+
+ - name: Restore
+ working-directory: backend
+ run: dotnet restore tests/LearnStack.Hub.Tests.Integration/LearnStack.Hub.Tests.Integration.csproj
+
+ - name: Test (integration — Testcontainers Postgres)
+ working-directory: backend
+ run: |
+ dotnet test tests/LearnStack.Hub.Tests.Integration/LearnStack.Hub.Tests.Integration.csproj \
+ --logger "trx;LogFileName=integration-results.trx" \
+ --results-directory ../artifacts/backend-integration-tests
+
+ - name: Upload test results
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: backend-integration-test-results
+ path: artifacts/backend-integration-tests
+ if-no-files-found: warn
# ─── Frontend ───────────────────────────────────────────────────────────
frontend:
diff --git a/CLAUDE.md b/CLAUDE.md
index 506a50a..aa1b8dd 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -10,18 +10,18 @@ Hub **never** stores tenant content. Hub holds tenant _metadata_ (plan, subscrip
## What state this is in
-**Phase 02c — Repository Bootstrap (P02c-0)** ✅. Solution scaffold + frontend monorepo + compose stack + CI + docs skeleton are in place. No Hub domain code yet — that lands in P02c-1 (Hub Domain Core).
-
-| Packet | State |
-| ---------------------------------------------------------------------------------------------------------------------------------------- | -------------- |
-| P02c-0 — Repository bootstrap | ✅ this commit |
-| P02c-1 — Hub Domain Core (`LearnStackTenant`, `Plan`, `HubSubscription`, `Entitlement`) | ⏳ next |
-| P02c-2 — Hub-side internal API + outbound `LearnStackApiClient` | ⏳ |
-| P02c-3 — LearnStack core PR (`HubEntitlementProvider`, `IUsageReporter`, internal-API handlers) — **blocked on LearnStack P02a-5/6/7/9** | ⏳ |
-| P02c-4 — Operator portal MVP | ⏳ |
-| P02c-5 — Custom domain lifecycle | ⏳ |
-| P02c-6 — License key skeleton | ⏳ |
-| P02c-7 — End-to-end exit gate | ⏳ |
+**Phase 02c — Hub Domain Core (P02c-1)** ✅. The SharedKernel, the 6-step cross-cutting foundation, and the four domain modules (`TenantLifecycle`, `Plans`, `Subscriptions`, `Entitlements`) with their DbContexts, migrations, and the entitlement projection are in place. Next is P02c-2 (Hub-side internal API + outbound `LearnStackApiClient`).
+
+| Packet | State |
+| ---------------------------------------------------------------------------------------------------------------------------------------- | ------- |
+| P02c-0 — Repository bootstrap | ✅ |
+| P02c-1 — Hub Domain Core (`LearnStackTenant`, `Plan`, `HubSubscription`, `Entitlement`) | ✅ |
+| P02c-2 — Hub-side internal API + outbound `LearnStackApiClient` | ⏳ next |
+| P02c-3 — LearnStack core PR (`HubEntitlementProvider`, `IUsageReporter`, internal-API handlers) — **blocked on LearnStack P02a-5/6/7/9** | ⏳ |
+| P02c-4 — Operator portal MVP | ⏳ |
+| P02c-5 — Custom domain lifecycle | ⏳ |
+| P02c-6 — License key skeleton | ⏳ |
+| P02c-7 — End-to-end exit gate | ⏳ |
## Where to start
diff --git a/backend/.editorconfig b/backend/.editorconfig
index bfa3dbe..9e862ac 100644
--- a/backend/.editorconfig
+++ b/backend/.editorconfig
@@ -21,3 +21,8 @@ dotnet_diagnostic.CS8601.severity = error
dotnet_diagnostic.CS8602.severity = error
dotnet_diagnostic.CS8603.severity = error
dotnet_diagnostic.CS8604.severity = error
+
+# EF Core migrations are tool-generated; treat them as generated code so the
+# style analyzers (e.g. IDE0161 file-scoped namespace) do not gate the build.
+[**/Migrations/*.cs]
+generated_code = true
diff --git a/backend/Directory.Build.props b/backend/Directory.Build.props
index 298fab6..cdcb478 100644
--- a/backend/Directory.Build.props
+++ b/backend/Directory.Build.props
@@ -25,8 +25,9 @@
false
-
- $(NoWarn);CA1707;CA1812;CA1515;CA1034;CA2234
+
+ $(NoWarn);CA1707;CA1812;CA1515;CA1034;CA2234;CA1711
diff --git a/backend/Directory.Packages.props b/backend/Directory.Packages.props
index b89eaae..2494ec7 100644
--- a/backend/Directory.Packages.props
+++ b/backend/Directory.Packages.props
@@ -52,17 +52,47 @@
P02c-2 outbound HTTP client; P02c-5 cert provisioning). Declared centrally
so the version matrix is consistent the moment they are pulled in.
-->
-
+
+
+
+
+
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -16,6 +31,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/backend/src/Core/LearnStack.Hub.Api/Program.cs b/backend/src/Core/LearnStack.Hub.Api/Program.cs
index 083f889..323993f 100644
--- a/backend/src/Core/LearnStack.Hub.Api/Program.cs
+++ b/backend/src/Core/LearnStack.Hub.Api/Program.cs
@@ -1,25 +1,70 @@
-// TODO(2026-05-21, @platform, phase-02c-1): wire the cross-cutting foundation
-// (mirror of LearnStack core P02a-3) — IExceptionHandler, 8-step MediatR
-// pipeline, Result.ToActionResult(), Serilog + OTLP, IErrorTrackingProvider,
-// IProviderResilience. The OpenTelemetry.* packages are reserved in
-// Directory.Packages.props.
-//
-// TODO(2026-05-21, @platform, phase-02c-2): wire the four-endpoint Hub HTTPS
-// contract surface — `POST /api/v1/internal/license/verify` and
-// `POST /api/v1/usage/report` are HOSTED here; the outbound
-// `POST /api/internal/tenants` + `PUT /api/internal/tenants/{id}/entitlements`
-// calls live in LearnStack.Hub.Infrastructure.LearnStackApiClient.
-//
-// TODO(2026-05-21, @platform, phase-02c-2): bind /api/internal/* endpoints
-// only to the internal listener (separate Kestrel endpoint). Architecture
-// test `Internal_API_Endpoints_AreNot_Public` will fail otherwise.
+using LearnStack.Hub.Api.Common;
+using LearnStack.Hub.Api.Composition;
+using LearnStack.Hub.Application.Pipeline;
+using LearnStack.Hub.Infrastructure.Composition;
+using LearnStack.Hub.Modules.Entitlements.Infrastructure;
+using LearnStack.Hub.Modules.Plans.Infrastructure;
+using LearnStack.Hub.Modules.Subscriptions.Infrastructure;
+using LearnStack.Hub.Modules.TenantLifecycle.Infrastructure;
+using LearnStack.Hub.SharedKernel.Hosting;
+
+// TODO(P02c-2): wire the four-endpoint Hub HTTPS contract surface —
+// POST /api/v1/internal/license/verify and POST /api/v1/usage/report are HOSTED
+// here; the outbound POST /api/internal/tenants + PUT .../entitlements calls
+// live in LearnStack.Hub.Infrastructure.LearnStackApiClient. Bind /api/internal/*
+// to the internal listener only (Internal_API_Endpoints_AreNot_Public).
var builder = WebApplication.CreateBuilder(args);
+// DeploymentMode is read exactly once, here at the composition root; modules
+// never read it (Modules_Do_Not_Reference_DeploymentMode).
+var deploymentMode = Enum.TryParse(
+ builder.Configuration["Hub:DeploymentMode"],
+ ignoreCase: true,
+ out var parsed)
+ ? parsed
+ : DeploymentMode.Development;
+
+builder.AddHubSerilog();
+builder.AddHubOpenTelemetry();
+
builder.Services.AddOpenApi();
+// L1 exception handling → RFC 7807 Problem Details.
+builder.Services.AddExceptionHandler();
+builder.Services.AddProblemDetails();
+
+// Cross-cutting foundation: clock / guid / random / secrets, the
+// DeploymentMode-branched IErrorTrackingProvider, and the shared-connection
+// unit of work the live TransactionBehavior drives.
+builder.Services.AddHubFoundation(builder.Configuration, deploymentMode);
+
+// The 6-step MediatR pipeline + every module's handler assembly.
+builder.Services.AddHubMediatRPipeline(
+ typeof(LearnStack.Hub.Application.AssemblyMarker).Assembly,
+ typeof(LearnStack.Hub.Modules.TenantLifecycle.Application.AssemblyMarker).Assembly,
+ typeof(LearnStack.Hub.Modules.Plans.Application.AssemblyMarker).Assembly,
+ typeof(LearnStack.Hub.Modules.Subscriptions.Application.AssemblyMarker).Assembly,
+ typeof(LearnStack.Hub.Modules.Entitlements.Application.AssemblyMarker).Assembly);
+
+// The four domain modules.
+builder.Services.AddTenantLifecycleModule(builder.Configuration);
+builder.Services.AddPlansModule(builder.Configuration);
+builder.Services.AddSubscriptionsModule(builder.Configuration);
+builder.Services.AddEntitlementsModule(builder.Configuration);
+
var app = builder.Build();
+// `dotnet run -- --seed` (make seed): apply migrations + seed the plan catalogue
+// and a demo tenant idempotently, then exit without starting the web host.
+if (args.Contains("--seed", StringComparer.Ordinal))
+{
+ await LearnStack.Hub.Api.Composition.HubSeeder.SeedAsync(app.Services);
+ return;
+}
+
+app.UseExceptionHandler();
+
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
@@ -30,13 +75,7 @@
app.Run();
-// `public partial class Program` serves two purposes:
-// 1. Entry-point escape hatch: WebApplicationFactory in the test
-// assemblies resolves the entry-point type via this declaration.
-// 2. Assembly marker: this is the intentional public type that pins the
-// LearnStack.Hub.Api assembly's IL TypeRef surface for NetArchTest
-// scanning. The other six core projects ship a separate `AssemblyMarker`
-// class because they have no entry point; the Api project does not need
-// a duplicate marker — `Program` already plays that role. Same posture
-// as LearnStack core's `LearnStack.Api/Program.cs`.
+// `public partial class Program` exposes the entry-point type for
+// WebApplicationFactory in the test assemblies and pins the
+// LearnStack.Hub.Api assembly's IL TypeRef surface for NetArchTest scanning.
public partial class Program;
diff --git a/backend/src/Core/LearnStack.Hub.Application/LearnStack.Hub.Application.csproj b/backend/src/Core/LearnStack.Hub.Application/LearnStack.Hub.Application.csproj
index 21aaa5a..8e2ac26 100644
--- a/backend/src/Core/LearnStack.Hub.Application/LearnStack.Hub.Application.csproj
+++ b/backend/src/Core/LearnStack.Hub.Application/LearnStack.Hub.Application.csproj
@@ -1,8 +1,10 @@
@@ -15,4 +17,11 @@
+
+
+
+
+
+
+
diff --git a/backend/src/Core/LearnStack.Hub.Application/Pipeline/AuditLogBehavior.cs b/backend/src/Core/LearnStack.Hub.Application/Pipeline/AuditLogBehavior.cs
new file mode 100644
index 0000000..383c1d6
--- /dev/null
+++ b/backend/src/Core/LearnStack.Hub.Application/Pipeline/AuditLogBehavior.cs
@@ -0,0 +1,58 @@
+using System.Runtime.ExceptionServices;
+using LearnStack.Hub.SharedKernel.Results;
+using MediatR;
+using Microsoft.Extensions.Logging;
+
+namespace LearnStack.Hub.Application.Pipeline;
+
+///
+/// Step 3 of the 6-step Hub pipeline — shell. Wraps the inner
+/// pipeline with try/catch and rethrows via
+/// to preserve the original stack trace. The operator-audit write lands in
+/// P02c-4 (the Audit module + Operators module); the shell preserves the
+/// catch/rethrow contract + pipeline order so P02c-4 can light up the write
+/// without churn.
+///
+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(P02c-4): on success, resolve the audit-state capture for the
+ // request type and write the success-class operator-audit entry
+ // through the Audit module's writer.
+
+ return response;
+ }
+#pragma warning disable CA1031 // Audit-then-rethrow contract binds the broad catch here.
+ catch (Exception ex) when (ex is not OperationCanceledException)
+#pragma warning restore CA1031
+ {
+ // TODO(P02c-4): write the failure-class operator-audit entry. The
+ // shell logs the audit intent so failure visibility is not silently
+ // lost while the Audit module 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}; operator-audit write deferred until P02c-4.");
+}
diff --git a/backend/src/Core/LearnStack.Hub.Application/Pipeline/AuthorizationBehavior.cs b/backend/src/Core/LearnStack.Hub.Application/Pipeline/AuthorizationBehavior.cs
new file mode 100644
index 0000000..e439992
--- /dev/null
+++ b/backend/src/Core/LearnStack.Hub.Application/Pipeline/AuthorizationBehavior.cs
@@ -0,0 +1,30 @@
+using LearnStack.Hub.SharedKernel.Results;
+using MediatR;
+
+namespace LearnStack.Hub.Application.Pipeline;
+
+///
+/// Step 4 of the 6-step Hub pipeline — shell. The operator
+/// permission check ({module}.{resource}.{action} keys, operator scope
+/// only) lands in P02c-4 with the Operators module. The shell passes every
+/// request through and preserves the pipeline-order contract.
+///
+public sealed class AuthorizationBehavior
+ : IPipelineBehavior
+ where TRequest : notnull
+ where TResponse : IResultBase
+{
+ public Task Handle(
+ TRequest request,
+ RequestHandlerDelegate next,
+ CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(next);
+
+ // TODO(P02c-4): resolve the request's required operator permission,
+ // check it against the resolved OperatorContext, and return
+ // Result.FailFor(forbidden) on deny.
+
+ return next();
+ }
+}
diff --git a/backend/src/Core/LearnStack.Hub.Application/Pipeline/LoggingBehavior.cs b/backend/src/Core/LearnStack.Hub.Application/Pipeline/LoggingBehavior.cs
new file mode 100644
index 0000000..efe90fd
--- /dev/null
+++ b/backend/src/Core/LearnStack.Hub.Application/Pipeline/LoggingBehavior.cs
@@ -0,0 +1,89 @@
+using System.Diagnostics;
+using LearnStack.Hub.SharedKernel.Results;
+using MediatR;
+using Microsoft.Extensions.Logging;
+
+namespace LearnStack.Hub.Application.Pipeline;
+
+///
+/// Step 2 of the 6-step Hub pipeline. Opens an
+/// carrying the request name + correlation id, starts a manual
+/// on the learnstack.hub.mediatr
+/// , and measures handler latency.
+///
+///
+/// Hub is operator-scoped, not tenant-scoped, so the correlation scope carries
+/// operator.id rather than tenant.id. The operator-context
+/// accessor lands with the Operators module (P02c-4); until then the scope
+/// carries the request name + the W3C correlation id from the ambient activity.
+///
+public sealed class LoggingBehavior(
+ ILogger> logger)
+ : IPipelineBehavior
+ where TRequest : notnull
+ where TResponse : IResultBase
+{
+ private static readonly ActivitySource ActivitySource = new("learnstack.hub.mediatr");
+
+ public async Task Handle(
+ TRequest request,
+ RequestHandlerDelegate next,
+ CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(next);
+
+ var requestName = typeof(TRequest).Name;
+
+ using var activity = ActivitySource.StartActivity(
+ $"mediatr.{requestName}",
+ ActivityKind.Internal);
+
+ using var scope = logger.BeginScope(BuildScope(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 so the AuditLogBehavior (step 3) owns the failure-audit
+ // path and the L1 HubExceptionHandler logs the exception once.
+ throw;
+ }
+ }
+
+ private static Dictionary BuildScope(string requestName)
+ {
+ return new Dictionary(StringComparer.Ordinal)
+ {
+ ["RequestName"] = requestName,
+ ["CorrelationId"] = Activity.Current?.Id,
+ };
+ }
+
+ 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/Core/LearnStack.Hub.Application/Pipeline/MediatRPipelineRegistration.cs b/backend/src/Core/LearnStack.Hub.Application/Pipeline/MediatRPipelineRegistration.cs
new file mode 100644
index 0000000..38aba5e
--- /dev/null
+++ b/backend/src/Core/LearnStack.Hub.Application/Pipeline/MediatRPipelineRegistration.cs
@@ -0,0 +1,62 @@
+using System.Reflection;
+using MediatR;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace LearnStack.Hub.Application.Pipeline;
+
+///
+/// Composition-root extension that registers the canonical 6-step
+/// Hub MediatR pipeline. Hub drops the two tenant-isolation steps LearnStack
+/// core's 8-step pipeline carries (TenantContextBehavior and the
+/// tenant-scoped concerns) because Hub is operator-administered, not
+/// tenant-isolated. Outermost (validation) first, innermost (handler) last; the
+/// MediatR_Pipeline_Order_Matches_Canonical_Sequence architecture test
+/// asserts this DI registration order.
+///
+public static class MediatRPipelineRegistration
+{
+ ///
+ /// The 6 pipeline behaviors in canonical order. The handler is the seventh
+ /// (innermost) step, resolved by MediatR itself. Do not reorder without
+ /// amending docs/architecture/cross-cutting-foundation.md § 2.
+ ///
+ public static IReadOnlyList CanonicalBehaviorOrder { get; } =
+ [
+ typeof(ValidationBehavior<,>),
+ typeof(LoggingBehavior<,>),
+ typeof(AuditLogBehavior<,>),
+ typeof(AuthorizationBehavior<,>),
+ typeof(TransactionBehavior<,>),
+ typeof(OutboxFlushBehavior<,>),
+ // Step 7 (the handler) is resolved by MediatR itself.
+ ];
+
+ ///
+ /// Registers the 6-step MediatR pipeline against .
+ /// Handler types are scanned from
+ /// (typically each module's AssemblyMarker assembly).
+ ///
+ public static IServiceCollection AddHubMediatRPipeline(
+ this IServiceCollection services,
+ params Assembly[] handlerAssemblies)
+ {
+ ArgumentNullException.ThrowIfNull(services);
+ ArgumentNullException.ThrowIfNull(handlerAssemblies);
+
+ 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/Core/LearnStack.Hub.Application/Pipeline/OutboxFlushBehavior.cs b/backend/src/Core/LearnStack.Hub.Application/Pipeline/OutboxFlushBehavior.cs
new file mode 100644
index 0000000..b2c8463
--- /dev/null
+++ b/backend/src/Core/LearnStack.Hub.Application/Pipeline/OutboxFlushBehavior.cs
@@ -0,0 +1,32 @@
+using LearnStack.Hub.SharedKernel.Results;
+using MediatR;
+
+namespace LearnStack.Hub.Application.Pipeline;
+
+///
+/// Step 6 of the 6-step Hub pipeline — shell. Enrols
+/// IOutbox messages in the current unit-of-work transaction; the outbox
+/// processor publishes them via Dapr pub/sub on commit. The IOutbox
+/// contract + the learnstack.hub.entitlement publish land in P02c-2; the
+/// shell delegates to the inner pipeline so the order is correct now.
+///
+public sealed class OutboxFlushBehavior
+ : IPipelineBehavior
+ where TRequest : notnull
+ where TResponse : IResultBase
+{
+ public Task Handle(
+ TRequest request,
+ RequestHandlerDelegate next,
+ CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(next);
+
+ // TODO(P02c-2): on a success-Result, flush IOutbox messages collected
+ // during the handler into outbox_messages within the active unit-of-work
+ // transaction so Dapr pub/sub dispatches learnstack.hub.entitlement
+ // after commit.
+
+ return next();
+ }
+}
diff --git a/backend/src/Core/LearnStack.Hub.Application/Pipeline/TransactionBehavior.cs b/backend/src/Core/LearnStack.Hub.Application/Pipeline/TransactionBehavior.cs
new file mode 100644
index 0000000..385cec7
--- /dev/null
+++ b/backend/src/Core/LearnStack.Hub.Application/Pipeline/TransactionBehavior.cs
@@ -0,0 +1,64 @@
+using LearnStack.Hub.SharedKernel.Persistence;
+using LearnStack.Hub.SharedKernel.Results;
+using MediatR;
+
+namespace LearnStack.Hub.Application.Pipeline;
+
+///
+/// Step 5 of the 6-step Hub pipeline — live. Opens the
+/// shared-connection unit-of-work transaction for the outermost command,
+/// commits on a success-, and rolls back on a
+/// fail-result or any exception. Validation- and authorization-failed requests
+/// short-circuit upstream and never reach here.
+///
+///
+/// Unlike LearnStack core's Packet-3 shell (which waited for per-module
+/// DbContexts), Hub has real DbContexts in P02c-1 so this behavior is live.
+/// Nested MediatR sends (e.g. the Subscriptions handler invoking the
+/// Entitlements recompute) join the outer transaction: when a transaction is
+/// already active the behavior delegates straight to the inner pipeline so only
+/// the outermost command owns commit / rollback. Every module DbContext rides
+/// the one transaction via .
+///
+public sealed class TransactionBehavior(IUnitOfWork unitOfWork)
+ : IPipelineBehavior
+ where TRequest : notnull
+ where TResponse : IResultBase
+{
+ public async Task Handle(
+ TRequest request,
+ RequestHandlerDelegate next,
+ CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(next);
+
+ // Nested send: a transaction is already open one frame out. Join it —
+ // the outermost behavior owns commit / rollback.
+ if (unitOfWork.HasActiveTransaction)
+ {
+ return await next().ConfigureAwait(false);
+ }
+
+ await unitOfWork.BeginAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ var response = await next().ConfigureAwait(false);
+
+ if (response.IsSuccess)
+ {
+ await unitOfWork.CommitAsync(cancellationToken).ConfigureAwait(false);
+ }
+ else
+ {
+ await unitOfWork.RollbackAsync(cancellationToken).ConfigureAwait(false);
+ }
+
+ return response;
+ }
+ catch
+ {
+ await unitOfWork.RollbackAsync(cancellationToken).ConfigureAwait(false);
+ throw;
+ }
+ }
+}
diff --git a/backend/src/Core/LearnStack.Hub.Application/Pipeline/ValidationBehavior.cs b/backend/src/Core/LearnStack.Hub.Application/Pipeline/ValidationBehavior.cs
new file mode 100644
index 0000000..8eb055f
--- /dev/null
+++ b/backend/src/Core/LearnStack.Hub.Application/Pipeline/ValidationBehavior.cs
@@ -0,0 +1,86 @@
+using FluentValidation;
+using LearnStack.Hub.SharedKernel.Localization;
+using LearnStack.Hub.SharedKernel.Results;
+using MediatR;
+
+namespace LearnStack.Hub.Application.Pipeline;
+
+///
+/// Step 1 of the 6-step Hub pipeline. Aggregates FluentValidation failures into
+/// and returns
+/// Result.FailFor<TResponse>(validation_failed, …); never
+/// throws .
+///
+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"); 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.
+ ///
+ 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/Core/LearnStack.Hub.Infrastructure/Composition/HubFoundationRegistration.cs b/backend/src/Core/LearnStack.Hub.Infrastructure/Composition/HubFoundationRegistration.cs
new file mode 100644
index 0000000..33611a0
--- /dev/null
+++ b/backend/src/Core/LearnStack.Hub.Infrastructure/Composition/HubFoundationRegistration.cs
@@ -0,0 +1,92 @@
+using LearnStack.Hub.Infrastructure.ErrorTracking;
+using LearnStack.Hub.Infrastructure.Persistence;
+using LearnStack.Hub.SharedKernel.Hosting;
+using LearnStack.Hub.SharedKernel.Identifiers;
+using LearnStack.Hub.SharedKernel.Observability;
+using LearnStack.Hub.SharedKernel.Persistence;
+using LearnStack.Hub.SharedKernel.Random;
+using LearnStack.Hub.SharedKernel.Secrets;
+using LearnStack.Hub.SharedKernel.Time;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Npgsql;
+
+namespace LearnStack.Hub.Infrastructure.Composition;
+
+///
+/// Composition-root extension that wires the Hub cross-cutting foundation:
+/// clock / guid / random / secrets, the DeploymentMode-branched
+/// , and the shared-connection unit of work
+/// every module DbContext rides. DeploymentMode is read exactly once
+/// here — modules never read it.
+///
+public static class HubFoundationRegistration
+{
+ public static IServiceCollection AddHubFoundation(
+ this IServiceCollection services,
+ IConfiguration configuration,
+ DeploymentMode deploymentMode)
+ {
+ ArgumentNullException.ThrowIfNull(services);
+ ArgumentNullException.ThrowIfNull(configuration);
+
+ // Deterministic-abstraction singletons.
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+
+ // Error tracking — branched once by DeploymentMode.
+ services.AddSingleton(sp => deploymentMode switch
+ {
+ DeploymentMode.Development => new NoOpErrorTracker(),
+ DeploymentMode.SelfHostedAirGapped => new LocalFileErrorTracker(
+ configuration["ErrorTracking:LocalFile:Path"] ?? "logs/hub-errors.ndjson",
+ sp.GetRequiredService>()),
+ _ => new SentryErrorTracker(
+ sp.GetRequiredService(),
+ sp.GetRequiredService>()),
+ });
+
+ // Shared Npgsql connection + the unit of work the live TransactionBehavior drives.
+ var connectionString = ResolveConnectionString(configuration);
+ services.AddSingleton(_ => NpgsqlDataSource.Create(connectionString));
+ services.AddScoped(sp => sp.GetRequiredService().CreateConnection());
+ services.AddScoped();
+
+ return services;
+ }
+
+ ///
+ /// Resolves the learnstack_hub connection string. Precedence:
+ /// the explicit ConnectionStrings:HubDatabase value (set by the
+ /// integration-test harness and production config), otherwise assembled
+ /// from the POSTGRES_* environment / config keys the dev .env
+ /// supplies. No credential literal is embedded in source — the password
+ /// rides on POSTGRES_PASSWORD at runtime (empty falls back to the
+ /// passwordless local-trust shape).
+ ///
+ public static string ResolveConnectionString(IConfiguration configuration)
+ {
+ ArgumentNullException.ThrowIfNull(configuration);
+
+ var explicitConnection = configuration["ConnectionStrings:HubDatabase"];
+ if (!string.IsNullOrWhiteSpace(explicitConnection))
+ {
+ return explicitConnection;
+ }
+
+ var host = configuration["POSTGRES_HOST"] ?? "localhost";
+ var port = configuration["POSTGRES_PORT"] ?? "5432";
+ var database = configuration["POSTGRES_DB_HUB"] ?? "learnstack_hub";
+ var username = configuration["POSTGRES_USER"] ?? "learnstack";
+ var password = configuration["POSTGRES_PASSWORD"];
+
+ var passwordPart = string.IsNullOrEmpty(password)
+ ? string.Empty
+ : $"Password={password};";
+
+ return $"Host={host};Port={port};Database={database};Username={username};{passwordPart}";
+ }
+}
diff --git a/backend/src/Core/LearnStack.Hub.Infrastructure/ErrorTracking/LocalFileErrorTracker.cs b/backend/src/Core/LearnStack.Hub.Infrastructure/ErrorTracking/LocalFileErrorTracker.cs
new file mode 100644
index 0000000..c224b27
--- /dev/null
+++ b/backend/src/Core/LearnStack.Hub.Infrastructure/ErrorTracking/LocalFileErrorTracker.cs
@@ -0,0 +1,103 @@
+using System.Text.Json;
+using LearnStack.Hub.SharedKernel.Observability;
+using LearnStack.Hub.SharedKernel.Secrets;
+using Microsoft.Extensions.Logging;
+
+namespace LearnStack.Hub.Infrastructure.ErrorTracking;
+
+///
+/// for DeploymentMode.SelfHostedAirGapped:
+/// writes a redacted JSON capture line to a local newline-delimited file (no
+/// network egress). Tag values whose key matches
+/// are redacted before serialisation so credentials never reach the file.
+///
+public sealed class LocalFileErrorTracker : IErrorTrackingProvider, IDisposable
+{
+ private static readonly JsonSerializerOptions SerializerOptions = new() { WriteIndented = false };
+
+ private readonly string _filePath;
+ private readonly ILogger _logger;
+ private readonly SemaphoreSlim _writeLock = new(1, 1);
+
+ public LocalFileErrorTracker(string filePath, ILogger logger)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(filePath);
+ _filePath = filePath;
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ public async ValueTask CaptureAsync(
+ Exception exception,
+ CapturedContext context,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(exception);
+ ArgumentNullException.ThrowIfNull(context);
+
+ var record = new
+ {
+ timestamp = DateTimeOffset.UtcNow,
+ exceptionType = exception.GetType().FullName,
+ message = exception.Message,
+ stackTrace = exception.StackTrace,
+ correlationId = context.CorrelationId,
+ requestPath = context.RequestPath,
+ requestMethod = context.RequestMethod,
+ operatorId = context.OperatorId,
+ tenantId = context.TenantId,
+ moduleName = context.ModuleName,
+ tags = Redact(context.AdditionalTags),
+ };
+
+ var line = JsonSerializer.Serialize(record, SerializerOptions);
+
+ await _writeLock.WaitAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ var directory = Path.GetDirectoryName(_filePath);
+ if (!string.IsNullOrEmpty(directory))
+ {
+ Directory.CreateDirectory(directory);
+ }
+
+ await File.AppendAllTextAsync(_filePath, line + Environment.NewLine, cancellationToken)
+ .ConfigureAwait(false);
+ }
+#pragma warning disable CA1031 // The tracker is the last line of defense; an I/O failure must not escape.
+ catch (Exception ioFailure)
+#pragma warning restore CA1031
+ {
+ LogWriteFailed(_logger, _filePath, ioFailure);
+ }
+ finally
+ {
+ _writeLock.Release();
+ }
+ }
+
+ private static readonly Action LogWriteFailed =
+ LoggerMessage.Define(
+ LogLevel.Error,
+ new EventId(1, nameof(LogWriteFailed)),
+ "LocalFileErrorTracker failed to append a capture to {FilePath}.");
+
+ private static Dictionary? Redact(IReadOnlyDictionary? tags)
+ {
+ if (tags is not { Count: > 0 })
+ {
+ return null;
+ }
+
+ var redacted = new Dictionary(tags.Count, StringComparer.Ordinal);
+ foreach (var (key, value) in tags)
+ {
+ redacted[key] = SensitiveTokenCatalog.IsSensitive(key)
+ ? SensitiveTokenCatalog.RedactedValue
+ : value;
+ }
+
+ return redacted;
+ }
+
+ public void Dispose() => _writeLock.Dispose();
+}
diff --git a/backend/src/Core/LearnStack.Hub.Infrastructure/ErrorTracking/NoOpErrorTracker.cs b/backend/src/Core/LearnStack.Hub.Infrastructure/ErrorTracking/NoOpErrorTracker.cs
new file mode 100644
index 0000000..d5ec38c
--- /dev/null
+++ b/backend/src/Core/LearnStack.Hub.Infrastructure/ErrorTracking/NoOpErrorTracker.cs
@@ -0,0 +1,16 @@
+using LearnStack.Hub.SharedKernel.Observability;
+
+namespace LearnStack.Hub.Infrastructure.ErrorTracking;
+
+///
+/// that discards captures. Composition-root
+/// default for DeploymentMode.Development — exceptions still surface via
+/// Serilog + the OTel span; no external sink is involved.
+///
+public sealed class NoOpErrorTracker : IErrorTrackingProvider
+{
+ public ValueTask CaptureAsync(
+ Exception exception,
+ CapturedContext context,
+ CancellationToken cancellationToken = default) => ValueTask.CompletedTask;
+}
diff --git a/backend/src/Core/LearnStack.Hub.Infrastructure/ErrorTracking/SentryErrorTracker.cs b/backend/src/Core/LearnStack.Hub.Infrastructure/ErrorTracking/SentryErrorTracker.cs
new file mode 100644
index 0000000..581876c
--- /dev/null
+++ b/backend/src/Core/LearnStack.Hub.Infrastructure/ErrorTracking/SentryErrorTracker.cs
@@ -0,0 +1,51 @@
+using LearnStack.Hub.SharedKernel.Observability;
+using LearnStack.Hub.SharedKernel.Secrets;
+using Microsoft.Extensions.Logging;
+
+namespace LearnStack.Hub.Infrastructure.ErrorTracking;
+
+///
+/// for the online deployment modes
+/// (SaaS / Dedicated / SelfHostedOnline). Composition-root default when a DSN
+/// is configured.
+///
+///
+/// P02c-1 ships the shell: the Sentry SDK is not yet a Hub
+/// dependency (it lands in a later packet inside this Infrastructure assembly so
+/// modules never reference Sentry.SentrySdk directly). The shell reads
+/// the DSN through and logs the capture intent so
+/// the composition-root branch + the secret seam are exercised now; the real
+/// SentrySdk.CaptureException call replaces the log line when the SDK is
+/// wired.
+///
+public sealed class SentryErrorTracker : IErrorTrackingProvider
+{
+ private readonly ILogger _logger;
+ private readonly bool _dsnConfigured;
+
+ public SentryErrorTracker(ISecretProvider secrets, ILogger logger)
+ {
+ ArgumentNullException.ThrowIfNull(secrets);
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ _dsnConfigured = !string.IsNullOrWhiteSpace(secrets.GetSecret("ErrorTracking:Sentry:Dsn"));
+ }
+
+ public ValueTask CaptureAsync(
+ Exception exception,
+ CapturedContext context,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(exception);
+ ArgumentNullException.ThrowIfNull(context);
+
+ // TODO(later packet): SentrySdk.CaptureException(exception, scope => { ... tags from context ... });
+ LogCaptureIntent(_logger, exception.GetType().FullName ?? "", _dsnConfigured, exception);
+ return ValueTask.CompletedTask;
+ }
+
+ private static readonly Action LogCaptureIntent =
+ LoggerMessage.Define(
+ LogLevel.Error,
+ new EventId(1, nameof(LogCaptureIntent)),
+ "SentryErrorTracker shell would capture {ExceptionType} (dsnConfigured={DsnConfigured}); real SDK wiring deferred.");
+}
diff --git a/backend/src/Core/LearnStack.Hub.Infrastructure/LearnStack.Hub.Infrastructure.csproj b/backend/src/Core/LearnStack.Hub.Infrastructure/LearnStack.Hub.Infrastructure.csproj
index 4781d57..003045c 100644
--- a/backend/src/Core/LearnStack.Hub.Infrastructure/LearnStack.Hub.Infrastructure.csproj
+++ b/backend/src/Core/LearnStack.Hub.Infrastructure/LearnStack.Hub.Infrastructure.csproj
@@ -1,9 +1,12 @@
@@ -17,4 +20,14 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/backend/src/Core/LearnStack.Hub.Infrastructure/Persistence/NpgsqlUnitOfWork.cs b/backend/src/Core/LearnStack.Hub.Infrastructure/Persistence/NpgsqlUnitOfWork.cs
new file mode 100644
index 0000000..f752abd
--- /dev/null
+++ b/backend/src/Core/LearnStack.Hub.Infrastructure/Persistence/NpgsqlUnitOfWork.cs
@@ -0,0 +1,102 @@
+using LearnStack.Hub.SharedKernel.Persistence;
+using Microsoft.EntityFrameworkCore;
+using Npgsql;
+
+namespace LearnStack.Hub.Infrastructure.Persistence;
+
+///
+/// Shared-connection . Every Hub module DbContext is
+/// constructed against the same scoped and
+/// enlists here on construction; this unit of work owns the single
+/// all enlisted contexts ride on, so a command
+/// that writes across modules (e.g. tenant create → subscription → entitlement)
+/// is atomic without a distributed transaction.
+///
+///
+/// Scoped lifetime: one instance per request / per DI scope. The shared
+/// connection is disposed by the DI container at scope end.
+///
+public sealed class NpgsqlUnitOfWork(NpgsqlConnection connection) : IUnitOfWork, IAsyncDisposable
+{
+ private readonly NpgsqlConnection _connection = connection
+ ?? throw new ArgumentNullException(nameof(connection));
+
+ private readonly List _enlisted = [];
+ private NpgsqlTransaction? _transaction;
+
+ public bool HasActiveTransaction => _transaction is not null;
+
+ public void Enlist(DbContext context)
+ {
+ ArgumentNullException.ThrowIfNull(context);
+
+ if (!_enlisted.Contains(context))
+ {
+ _enlisted.Add(context);
+ }
+
+ // A context resolved after BeginAsync joins the active transaction now.
+ if (_transaction is not null)
+ {
+ context.Database.UseTransaction(_transaction);
+ }
+ }
+
+ public async Task BeginAsync(CancellationToken cancellationToken = default)
+ {
+ if (_transaction is not null)
+ {
+ return;
+ }
+
+ if (_connection.State != System.Data.ConnectionState.Open)
+ {
+ await _connection.OpenAsync(cancellationToken).ConfigureAwait(false);
+ }
+
+ _transaction = await _connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
+
+ // Contexts enlisted before the transaction opened (e.g. via the handler's
+ // constructor) join it now.
+ foreach (var context in _enlisted)
+ {
+ await context.Database.UseTransactionAsync(_transaction, cancellationToken).ConfigureAwait(false);
+ }
+ }
+
+ public async Task CommitAsync(CancellationToken cancellationToken = default)
+ {
+ if (_transaction is null)
+ {
+ return;
+ }
+
+ await _transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
+ await DisposeTransactionAsync().ConfigureAwait(false);
+ }
+
+ public async Task RollbackAsync(CancellationToken cancellationToken = default)
+ {
+ if (_transaction is null)
+ {
+ return;
+ }
+
+ await _transaction.RollbackAsync(cancellationToken).ConfigureAwait(false);
+ await DisposeTransactionAsync().ConfigureAwait(false);
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ await DisposeTransactionAsync().ConfigureAwait(false);
+ }
+
+ private async Task DisposeTransactionAsync()
+ {
+ if (_transaction is not null)
+ {
+ await _transaction.DisposeAsync().ConfigureAwait(false);
+ _transaction = null;
+ }
+ }
+}
diff --git a/backend/src/Core/LearnStack.Hub.Infrastructure/Resilience/PollyProviderResilience.cs b/backend/src/Core/LearnStack.Hub.Infrastructure/Resilience/PollyProviderResilience.cs
new file mode 100644
index 0000000..f8b99e4
--- /dev/null
+++ b/backend/src/Core/LearnStack.Hub.Infrastructure/Resilience/PollyProviderResilience.cs
@@ -0,0 +1,68 @@
+using LearnStack.Hub.SharedKernel.Resilience;
+using Polly;
+using Polly.CircuitBreaker;
+using Polly.Retry;
+using Polly.Timeout;
+
+namespace LearnStack.Hub.Infrastructure.Resilience;
+
+///
+/// Polly v8 implementation of . Builds
+/// the once from the supplied
+/// (retry → circuit breaker → timeout, optional
+/// bulkhead). No adapter consumes it in P02c-1 — the surface ships so P02c-2 /
+/// Phase 09b adapters can take an instance in their constructor.
+///
+public sealed class PollyProviderResilience : IProviderResilience
+ where TPort : class
+{
+ public PollyProviderResilience(string portName, ResilienceOptions options)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(portName);
+ ArgumentNullException.ThrowIfNull(options);
+
+ PortName = portName;
+ Pipeline = Build(options);
+ }
+
+ public ResiliencePipeline Pipeline { get; }
+
+ public string PortName { get; }
+
+ private static ResiliencePipeline Build(ResilienceOptions options)
+ {
+ var builder = new ResiliencePipelineBuilder();
+
+ if (options.Retry.Enabled)
+ {
+ builder.AddRetry(new RetryStrategyOptions
+ {
+ 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
+ {
+ FailureRatio = options.CircuitBreaker.FailureRatio,
+ SamplingDuration = TimeSpan.FromSeconds(options.CircuitBreaker.SamplingDurationSeconds),
+ MinimumThroughput = options.CircuitBreaker.MinimumThroughput,
+ BreakDuration = TimeSpan.FromSeconds(options.CircuitBreaker.BreakDurationSeconds),
+ });
+ }
+
+ if (options.Timeout.Enabled)
+ {
+ builder.AddTimeout(new TimeoutStrategyOptions
+ {
+ Timeout = TimeSpan.FromSeconds(options.Timeout.TotalSeconds),
+ });
+ }
+
+ return builder.Build();
+ }
+}
diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/Compliance/ComplianceCap.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/Compliance/ComplianceCap.cs
new file mode 100644
index 0000000..686d51a
--- /dev/null
+++ b/backend/src/Core/LearnStack.Hub.SharedKernel/Compliance/ComplianceCap.cs
@@ -0,0 +1,18 @@
+namespace LearnStack.Hub.SharedKernel.Compliance;
+
+///
+/// A single compliance cap: whether a capability is ,
+/// whether it is (operator-mandated, tenant cannot opt
+/// out), and an optional (e.g. an audit-retention day count
+/// or a data-residency region).
+///
+///
+/// The cross-cutting cap shape, shared by Plan.compliance_defaults and
+/// the entitlement projection's compliance.caps, so the wire contract is
+/// defined in one place. Empty in P02c-1 — no plan compliance
+/// defaults are wired and the projection's caps map is {}; the
+/// CompliancePolicy module (P02c-5) populates it. is
+/// modelled as a string for now; P02c-5 may refine it to a typed value when the
+/// caps map is actually populated.
+///
+public sealed record ComplianceCap(bool Allowed, bool Forced, string? Value = null);
diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/Domain/AuditableEntity.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/Domain/AuditableEntity.cs
new file mode 100644
index 0000000..1b80005
--- /dev/null
+++ b/backend/src/Core/LearnStack.Hub.SharedKernel/Domain/AuditableEntity.cs
@@ -0,0 +1,115 @@
+using LearnStack.Hub.SharedKernel.Identifiers;
+using LearnStack.Hub.SharedKernel.Persistence;
+
+namespace LearnStack.Hub.SharedKernel.Domain;
+
+///
+/// Mutable aggregate base. Carries the audit columns every Hub aggregate
+/// mirrors (CreatedAt / CreatedBy / UpdatedAt /
+/// UpdatedBy / DeletedAt / DeletedBy / Version) and
+/// implements + .
+///
+///
+/// The load-bearing Hub adjustment: the *By audit columns are typed on
+/// (a Hub operator), NOT a tenant UserId — Hub
+/// actions are performed by operators, never tenant users.
+///
+public abstract class AuditableEntity
+ : Entity, ISoftDelete, IOptimisticConcurrency
+ where TId : struct, IStronglyTypedId
+{
+ protected AuditableEntity(TId id)
+ : base(id)
+ {
+ }
+
+ // EF Core / ORM materialization ctor.
+ protected AuditableEntity()
+ {
+ }
+
+ public DateTimeOffset CreatedAt { get; protected set; }
+
+ public OperatorId CreatedBy { get; protected set; }
+
+ public DateTimeOffset? UpdatedAt { get; protected set; }
+
+ public OperatorId? UpdatedBy { get; protected set; }
+
+ public DateTimeOffset? DeletedAt { get; protected set; }
+
+ public OperatorId? DeletedBy { get; protected set; }
+
+ public uint Version { get; protected set; }
+
+ ///
+ /// Convenience projection of for in-process
+ /// callers. EF global query filters should gate on
+ /// directly — but note Hub does NOT register soft-delete query filters
+ /// (operator-administered, cross-tenant by design); this property is for
+ /// CLR-side reads only.
+ ///
+ public bool IsDeleted => DeletedAt.HasValue;
+
+ ///
+ /// Stamps / on first
+ /// persist. Throws when already stamped — audit-trail integrity rules out
+ /// silent overwrites.
+ ///
+ public void MarkCreated(DateTimeOffset at, OperatorId by)
+ {
+ EnsureValidAuditInput(at, by);
+
+ if (CreatedAt != default)
+ {
+ throw new InvalidOperationException(
+ "MarkCreated has already been called on this aggregate; the created-at / created-by columns are immutable after first stamp.");
+ }
+
+ CreatedAt = at;
+ CreatedBy = by;
+ }
+
+ /// Stamps / .
+ public void MarkUpdated(DateTimeOffset at, OperatorId by)
+ {
+ EnsureValidAuditInput(at, by);
+
+ UpdatedAt = at;
+ UpdatedBy = by;
+ }
+
+ ///
+ /// Marks the entity soft-deleted and bumps /
+ /// so the last-touched timestamp stays monotonic.
+ ///
+ public void SoftDelete(DateTimeOffset at, OperatorId by)
+ {
+ EnsureValidAuditInput(at, by);
+
+ DeletedAt = at;
+ DeletedBy = by;
+ UpdatedAt = at;
+ UpdatedBy = by;
+ }
+
+ // Audit metadata must always be meaningful: the default timestamp and the
+ // default OperatorId (Guid.Empty) are programmer-error sentinels. Fail loud
+ // at the call site rather than persisting them.
+ private static void EnsureValidAuditInput(DateTimeOffset at, OperatorId by)
+ {
+ if (at == default)
+ {
+ throw new ArgumentException(
+ "Audit timestamp must be a meaningful instant, not default(DateTimeOffset). Pass the value from IClock.UtcNow.",
+ nameof(at));
+ }
+
+ if (by.Value == Guid.Empty)
+ {
+ throw new ArgumentException(
+ "Audit actor must be a real OperatorId, not default(OperatorId). Pass the resolved operator id.",
+ nameof(by));
+ }
+ }
+}
diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/Domain/DomainEvent.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/Domain/DomainEvent.cs
new file mode 100644
index 0000000..a0a7734
--- /dev/null
+++ b/backend/src/Core/LearnStack.Hub.SharedKernel/Domain/DomainEvent.cs
@@ -0,0 +1,14 @@
+namespace LearnStack.Hub.SharedKernel.Domain;
+
+///
+/// Base record for in-process domain events. and
+/// are required init: every event MUST be
+/// stamped from the aggregate's injected IGuidFactory / IClock
+/// so the deterministic-test abstractions are never bypassed.
+///
+public abstract record DomainEvent : IDomainEvent
+{
+ public required Guid EventId { get; init; }
+
+ public required DateTimeOffset OccurredAt { get; init; }
+}
diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/Domain/Entity.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/Domain/Entity.cs
new file mode 100644
index 0000000..f5259b9
--- /dev/null
+++ b/backend/src/Core/LearnStack.Hub.SharedKernel/Domain/Entity.cs
@@ -0,0 +1,85 @@
+using System.Collections.ObjectModel;
+using LearnStack.Hub.SharedKernel.Identifiers;
+
+namespace LearnStack.Hub.SharedKernel.Domain;
+
+///
+/// Aggregate base. Carries identity and raises in-process
+/// s; does not carry the audit columns —
+/// those belong to mutable aggregates and live on
+/// . inherits this
+/// base directly (one row per tenant, replaced wholesale on recompute, no audit
+/// columns).
+///
+///
+/// Identity-based equality with three guards: transient entities
+/// ( equal to default(TId)) match only by reference,
+/// runtime-type mismatches never match even when the ID matches, and the hash
+/// code partitions transient instances apart so EF Core's change tracker and
+/// any HashSet navigation behave correctly. Domain-event collection
+/// state is lazily allocated.
+///
+public abstract class Entity : IHasId, IHasDomainEvents
+ where TId : struct, IStronglyTypedId
+{
+ private List? _domainEvents;
+ private ReadOnlyCollection? _domainEventsView;
+
+ protected Entity(TId id)
+ {
+ Id = id;
+ }
+
+ // EF Core / ORM materialization ctor.
+ protected Entity()
+ {
+ }
+
+ public TId Id { get; protected init; }
+
+ ///
+ /// In-process domain events raised since the last
+ /// . Returns a cached read-only wrapper so
+ /// callers cannot mutate the collection out from under the aggregate.
+ ///
+ public IReadOnlyCollection DomainEvents =>
+ _domainEventsView ??= (_domainEvents ??= []).AsReadOnly();
+
+ protected void RaiseDomainEvent(IDomainEvent domainEvent)
+ {
+ ArgumentNullException.ThrowIfNull(domainEvent);
+ (_domainEvents ??= []).Add(domainEvent);
+ }
+
+ public void ClearDomainEvents() => _domainEvents?.Clear();
+
+ public override bool Equals(object? obj)
+ {
+ if (obj is not Entity other)
+ {
+ return false;
+ }
+
+ if (ReferenceEquals(this, other))
+ {
+ return true;
+ }
+
+ if (GetType() != other.GetType())
+ {
+ return false;
+ }
+
+ if (Id.Equals(default(TId)) || other.Id.Equals(default(TId)))
+ {
+ return false;
+ }
+
+ return Id.Equals(other.Id);
+ }
+
+ public override int GetHashCode() =>
+ Id.Equals(default(TId))
+ ? base.GetHashCode()
+ : HashCode.Combine(GetType(), Id);
+}
diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/Domain/IDomainEvent.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/Domain/IDomainEvent.cs
new file mode 100644
index 0000000..abd7248
--- /dev/null
+++ b/backend/src/Core/LearnStack.Hub.SharedKernel/Domain/IDomainEvent.cs
@@ -0,0 +1,17 @@
+using MediatR;
+
+namespace LearnStack.Hub.SharedKernel.Domain;
+
+///
+/// In-process domain event raised by an aggregate method. Dispatched in-process
+/// by MediatR — the cross-module integration-event path (outbox + Dapr pub/sub)
+/// is a different mechanism per ADR-0010.
+///
+public interface IDomainEvent : INotification
+{
+ /// Unique event identifier. UUIDv7 so insertion order matches occurrence order.
+ Guid EventId { get; }
+
+ /// UTC instant the event was raised.
+ DateTimeOffset OccurredAt { get; }
+}
diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/Domain/IHasDomainEvents.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/Domain/IHasDomainEvents.cs
new file mode 100644
index 0000000..30e13fc
--- /dev/null
+++ b/backend/src/Core/LearnStack.Hub.SharedKernel/Domain/IHasDomainEvents.cs
@@ -0,0 +1,13 @@
+namespace LearnStack.Hub.SharedKernel.Domain;
+
+///
+/// Marker every entity that raises implements. The
+/// unit-of-work walks tracked entities, drains the events, and hands them to
+/// MediatR's in-process publisher on commit.
+///
+public interface IHasDomainEvents
+{
+ IReadOnlyCollection DomainEvents { get; }
+
+ void ClearDomainEvents();
+}
diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/Errors/DomainException.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/Errors/DomainException.cs
new file mode 100644
index 0000000..5a06a12
--- /dev/null
+++ b/backend/src/Core/LearnStack.Hub.SharedKernel/Errors/DomainException.cs
@@ -0,0 +1,31 @@
+using LearnStack.Hub.SharedKernel.Localization;
+using LearnStack.Hub.SharedKernel.Results;
+
+namespace LearnStack.Hub.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, …), mirror of LearnStack core
+/// ADR-0032 § Sub-decision 4).
+///
+public sealed class DomainException : HubException
+{
+ // A DomainException reaching the L1 handler is a *bug* — an invariant was
+ // bypassed, not an expected outcome. Its default code maps to a 500
+ // (internal error), NOT business_rule_violation (409) which is reserved
+ // for the Result.Fail path.
+ 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/Core/LearnStack.Hub.SharedKernel/Errors/HubException.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/Errors/HubException.cs
new file mode 100644
index 0000000..ed3884a
--- /dev/null
+++ b/backend/src/Core/LearnStack.Hub.SharedKernel/Errors/HubException.cs
@@ -0,0 +1,25 @@
+using LearnStack.Hub.SharedKernel.Results;
+
+namespace LearnStack.Hub.SharedKernel.Errors;
+
+///
+/// Base class for every exception Hub itself raises (the Hub analogue of
+/// LearnStack core's LearnStackException). Exceptions are reserved for
+/// unexpected failures (bugs, transient infrastructure faults,
+/// contract violations); expected outcomes return .
+/// Carrying the structured at the exception site
+/// lets the L1 HubExceptionHandler map straight to RFC 7807 Problem
+/// Details.
+///
+public abstract class HubException : Exception
+{
+ protected HubException(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.
+ public Error Error { get; }
+}
diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/Errors/InfrastructureException.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/Errors/InfrastructureException.cs
new file mode 100644
index 0000000..b9035c6
--- /dev/null
+++ b/backend/src/Core/LearnStack.Hub.SharedKernel/Errors/InfrastructureException.cs
@@ -0,0 +1,25 @@
+using LearnStack.Hub.SharedKernel.Localization;
+using LearnStack.Hub.SharedKernel.Results;
+
+namespace LearnStack.Hub.SharedKernel.Errors;
+
+///
+/// Transient infrastructure fault (database connection, cache, outbox
+/// dispatcher transport). Retryable. Captured to IErrorTrackingProvider
+/// at the L1 handler.
+///
+public class InfrastructureException : HubException
+{
+ 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/Core/LearnStack.Hub.SharedKernel/Errors/ProviderException.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/Errors/ProviderException.cs
new file mode 100644
index 0000000..929323a
--- /dev/null
+++ b/backend/src/Core/LearnStack.Hub.SharedKernel/Errors/ProviderException.cs
@@ -0,0 +1,53 @@
+using LearnStack.Hub.SharedKernel.Localization;
+using LearnStack.Hub.SharedKernel.Results;
+
+namespace LearnStack.Hub.SharedKernel.Errors;
+
+///
+/// Wraps an upstream provider failure surfaced at an adapter boundary
+/// (Stripe / Iyzico / Let's Encrypt — adapters land in later packets). Each
+/// adapter translates SDK exception types into a
+/// so SDK types never leave the adapter assembly.
+///
+///
+/// splits the error-capture boundary: true
+/// for 4xx upstream (no capture), false for 5xx / timeouts (captured).
+/// It does not drive the HTTP status returned to the client — that comes from
+/// the carried 's code.
+///
+public class ProviderException : HubException
+{
+ 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. "stripe"). Must not leak to end users.
+ public string ProviderName { get; }
+
+ /// true when the upstream response is a 4xx-equivalent; the L1 handler skips capture for client errors.
+ public bool IsClientError { get; }
+}
diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/FeatureFlags/FeatureKey.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/FeatureFlags/FeatureKey.cs
new file mode 100644
index 0000000..20a7ddb
--- /dev/null
+++ b/backend/src/Core/LearnStack.Hub.SharedKernel/FeatureFlags/FeatureKey.cs
@@ -0,0 +1,14 @@
+namespace LearnStack.Hub.SharedKernel.FeatureFlags;
+
+///
+/// Typed feature-flag key (mirror of LearnStack core's FeatureKey per
+/// ADR-0021 Amendment 1). The wire string is dotted snake_case with no
+/// .enabled suffix — every feature is implicitly boolean. The
+/// projection serialises Plan.features as Dictionary<string, bool>
+/// keyed on these strings; a drift from LearnStack core's registry breaks the
+/// entitlement projection LearnStack consumes.
+///
+public readonly record struct FeatureKey(string Value)
+{
+ public override string ToString() => Value;
+}
diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/FeatureFlags/FeatureKeys.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/FeatureFlags/FeatureKeys.cs
new file mode 100644
index 0000000..410c2f7
--- /dev/null
+++ b/backend/src/Core/LearnStack.Hub.SharedKernel/FeatureFlags/FeatureKeys.cs
@@ -0,0 +1,68 @@
+using System.Collections.Immutable;
+
+namespace LearnStack.Hub.SharedKernel.FeatureFlags;
+
+///
+/// The Hub-side registry of known s. Hub is the
+/// authoring side — the plan editor (P02c-4) writes these keys into
+/// Plan.features, and the Plan validator rejects any key not in
+/// .
+///
+///
+///
+/// Seeded from the entitlement projection wire-shape (Architecture 24 § 4 +
+/// docs/architecture/entitlement-projection.md) — the projection JSON
+/// is the load-bearing contract surface LearnStack core consumes — together
+/// with the broader feature set in ADR-0021 Amendment 1.
+///
+///
+/// Registry sync. Hub and LearnStack core each keep their own
+/// copy of this registry, so they can drift. Where ADR-0021 Amendment 1 and
+/// the projection example disagree (e.g. the limit-key prefix), the projection
+/// wire-shape wins because it is the contract the projection serialiser emits.
+/// A future cross-repo reconciliation (or a shared contracts package, Phase 11)
+/// is the durable fix — tracked in docs/roadmap/README.md.
+///
+///
+public static class FeatureKeys
+{
+ public static readonly FeatureKey ClassroomRecording = new("classroom.recording");
+ public static readonly FeatureKey ClassroomBreakoutRooms = new("classroom.breakout_rooms");
+ public static readonly FeatureKey CustomDomain = new("tenancy.custom_domain");
+ public static readonly FeatureKey WhiteLabelBranding = new("tenancy.white_label_branding");
+ public static readonly FeatureKey UnlimitedContentTypes = new("customization.unlimited_content_types");
+ public static readonly FeatureKey SsoSaml = new("identity.sso.saml");
+ public static readonly FeatureKey SsoOidc = new("identity.sso.oidc");
+ public static readonly FeatureKey Scim = new("identity.scim");
+ public static readonly FeatureKey AdvancedReporting = new("analytics.advanced_reporting");
+ public static readonly FeatureKey BulkImport = new("admin.bulk_import");
+ public static readonly FeatureKey ApiAccess = new("integrations.api_access");
+ public static readonly FeatureKey Webhooks = new("integrations.webhooks");
+ public static readonly FeatureKey AuditExport = new("audit.export");
+ public static readonly FeatureKey DataResidencySelection = new("compliance.data_residency");
+
+ /// Every known feature key.
+ public static ImmutableArray All { get; } =
+ [
+ ClassroomRecording,
+ ClassroomBreakoutRooms,
+ CustomDomain,
+ WhiteLabelBranding,
+ UnlimitedContentTypes,
+ SsoSaml,
+ SsoOidc,
+ Scim,
+ AdvancedReporting,
+ BulkImport,
+ ApiAccess,
+ Webhooks,
+ AuditExport,
+ DataResidencySelection,
+ ];
+
+ private static readonly ImmutableHashSet KnownValues =
+ All.Select(k => k.Value).ToImmutableHashSet(StringComparer.Ordinal);
+
+ /// Returns true when is a registered feature-key wire string.
+ public static bool IsKnown(string value) => KnownValues.Contains(value);
+}
diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/FeatureFlags/LimitKey.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/FeatureFlags/LimitKey.cs
new file mode 100644
index 0000000..bce2bfc
--- /dev/null
+++ b/backend/src/Core/LearnStack.Hub.SharedKernel/FeatureFlags/LimitKey.cs
@@ -0,0 +1,12 @@
+namespace LearnStack.Hub.SharedKernel.FeatureFlags;
+
+///
+/// Typed numeric-limit key (mirror of LearnStack core's LimitKey per
+/// ADR-0021 Amendment 1). The wire string carries the limits. prefix;
+/// the projected value is a long where -1 = unlimited and
+/// 0 = not available.
+///
+public readonly record struct LimitKey(string Value)
+{
+ public override string ToString() => Value;
+}
diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/FeatureFlags/LimitKeys.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/FeatureFlags/LimitKeys.cs
new file mode 100644
index 0000000..bb1de93
--- /dev/null
+++ b/backend/src/Core/LearnStack.Hub.SharedKernel/FeatureFlags/LimitKeys.cs
@@ -0,0 +1,44 @@
+using System.Collections.Immutable;
+
+namespace LearnStack.Hub.SharedKernel.FeatureFlags;
+
+///
+/// The Hub-side registry of known s. Keys carry the
+/// limits. prefix per the entitlement projection wire-shape
+/// (Architecture 24 § 4 + docs/architecture/entitlement-projection.md),
+/// which is the contract LearnStack core consumes. Values project as
+/// long: -1 = unlimited, 0 = not available. See
+/// for the registry-sync caveat.
+///
+public static class LimitKeys
+{
+ public static readonly LimitKey MaxUsers = new("limits.max_users");
+ public static readonly LimitKey MaxOrganizations = new("limits.max_organizations");
+ public static readonly LimitKey ClassroomMinutesPerMonth = new("limits.classroom_minutes_per_month");
+ public static readonly LimitKey RecordingStorageGb = new("limits.recording_storage_gb");
+ public static readonly LimitKey MediaStorageGb = new("limits.media_storage_gb");
+ public static readonly LimitKey MediaBandwidthGbPerMonth = new("limits.media_bandwidth_gb_per_month");
+ public static readonly LimitKey ApiRatePerMinute = new("limits.api_rate_per_minute");
+ public static readonly LimitKey MaxCustomContentTypes = new("limits.max_custom_content_types");
+ public static readonly LimitKey MaxPageBlockDefinitions = new("limits.max_page_block_definitions");
+
+ /// Every known limit key.
+ public static ImmutableArray All { get; } =
+ [
+ MaxUsers,
+ MaxOrganizations,
+ ClassroomMinutesPerMonth,
+ RecordingStorageGb,
+ MediaStorageGb,
+ MediaBandwidthGbPerMonth,
+ ApiRatePerMinute,
+ MaxCustomContentTypes,
+ MaxPageBlockDefinitions,
+ ];
+
+ private static readonly ImmutableHashSet KnownValues =
+ All.Select(k => k.Value).ToImmutableHashSet(StringComparer.Ordinal);
+
+ /// Returns true when is a registered limit-key wire string.
+ public static bool IsKnown(string value) => KnownValues.Contains(value);
+}
diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/Hosting/DeploymentMode.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/Hosting/DeploymentMode.cs
new file mode 100644
index 0000000..dd2bffb
--- /dev/null
+++ b/backend/src/Core/LearnStack.Hub.SharedKernel/Hosting/DeploymentMode.cs
@@ -0,0 +1,17 @@
+namespace LearnStack.Hub.SharedKernel.Hosting;
+
+///
+/// The deployment shape the composition root branches on (mirror of LearnStack
+/// core per ADR-0020). Read exactly once at the composition root to select
+/// provider implementations; modules never read this enum —
+/// the architecture test Modules_Do_Not_Reference_DeploymentMode
+/// enforces the rule.
+///
+public enum DeploymentMode
+{
+ Development,
+ SaaS,
+ Dedicated,
+ SelfHostedOnline,
+ SelfHostedAirGapped,
+}
diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/Identifiers/FixedGuidFactory.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/Identifiers/FixedGuidFactory.cs
new file mode 100644
index 0000000..b80bb5f
--- /dev/null
+++ b/backend/src/Core/LearnStack.Hub.SharedKernel/Identifiers/FixedGuidFactory.cs
@@ -0,0 +1,34 @@
+namespace LearnStack.Hub.SharedKernel.Identifiers;
+
+///
+/// Deterministic for tests. Returns the supplied
+/// sequence in order; throws once the sequence is exhausted so tests fail loud
+/// rather than silently reusing a default value. Both
+/// and draw from the same queue.
+///
+public sealed class FixedGuidFactory : IGuidFactory
+{
+ private readonly Queue _sequence;
+
+ public FixedGuidFactory(params Guid[] sequence)
+ {
+ ArgumentNullException.ThrowIfNull(sequence);
+ _sequence = new Queue(sequence);
+ }
+
+ public Guid NewUuidV7() => Dequeue();
+
+ public Guid NewUuidV4() => Dequeue();
+
+ private Guid Dequeue()
+ {
+ if (_sequence.Count == 0)
+ {
+ throw new InvalidOperationException(
+ "FixedGuidFactory sequence exhausted. Construct with enough GUIDs for the test, " +
+ "or switch to a different fixture.");
+ }
+
+ return _sequence.Dequeue();
+ }
+}
diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/Identifiers/HubSystemActors.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/Identifiers/HubSystemActors.cs
new file mode 100644
index 0000000..f350f3d
--- /dev/null
+++ b/backend/src/Core/LearnStack.Hub.SharedKernel/Identifiers/HubSystemActors.cs
@@ -0,0 +1,16 @@
+namespace LearnStack.Hub.SharedKernel.Identifiers;
+
+///
+/// Well-known synthetic actors used for system-initiated writes before a real
+/// operator context exists. The resolved OperatorContext (from the
+/// learnstack-hub Keycloak realm) lands in P02c-4; until then P02c-1
+/// command handlers and the seeder stamp audit columns with
+/// (a fixed, non-empty id so the
+/// AuditableEntity audit-input guard is satisfied).
+///
+public static class HubSystemActors
+{
+ /// The synthetic system operator for P02c-1 writes (replaced by the resolved operator in P02c-4).
+ public static OperatorId SystemOperator { get; } =
+ OperatorId.From(new Guid("00000000-0000-0000-0000-000000000001"));
+}
diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/Identifiers/IAggregateRoot.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/Identifiers/IAggregateRoot.cs
new file mode 100644
index 0000000..550d4ec
--- /dev/null
+++ b/backend/src/Core/LearnStack.Hub.SharedKernel/Identifiers/IAggregateRoot.cs
@@ -0,0 +1,12 @@
+namespace LearnStack.Hub.SharedKernel.Identifiers;
+
+///
+/// Marker for the root entity of an aggregate. Repositories accept and return
+/// only aggregate roots; entities inside an aggregate are reached through the
+/// root. Per ADR-0023 every aggregate root carries an
+/// -shaped Vogen-emitted ID over .
+///
+public interface IAggregateRoot : IHasId
+ where TId : struct, IStronglyTypedId
+{
+}
diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/Identifiers/IGuidFactory.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/Identifiers/IGuidFactory.cs
new file mode 100644
index 0000000..14570ca
--- /dev/null
+++ b/backend/src/Core/LearnStack.Hub.SharedKernel/Identifiers/IGuidFactory.cs
@@ -0,0 +1,18 @@
+namespace LearnStack.Hub.SharedKernel.Identifiers;
+
+///
+/// GUID minting abstraction. Application-side GUIDs flow through this interface
+/// so tests can pin the sequence deterministically.
+///
+public interface IGuidFactory
+{
+ ///
+ /// Mints a UUIDv7 (Guid.CreateVersion7). Preferred for every new
+ /// aggregate root identifier because the timestamp prefix keeps DB-side
+ /// indexes sorted by insertion order.
+ ///
+ Guid NewUuidV7();
+
+ /// Mints a UUIDv4. Reserved for identifiers that should not leak insertion order.
+ Guid NewUuidV4();
+}
diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/Identifiers/IHasId.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/Identifiers/IHasId.cs
new file mode 100644
index 0000000..c056ce1
--- /dev/null
+++ b/backend/src/Core/LearnStack.Hub.SharedKernel/Identifiers/IHasId.cs
@@ -0,0 +1,13 @@
+namespace LearnStack.Hub.SharedKernel.Identifiers;
+
+///
+/// Tagging interface for any entity that exposes an identifier of type
+/// . Kept separate from
+/// so child entities inside an aggregate can
+/// share the Id shape without claiming aggregate-root status.
+///
+public interface IHasId
+ where TId : struct, IStronglyTypedId
+{
+ TId Id { get; }
+}
diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/Identifiers/IStronglyTypedId.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/Identifiers/IStronglyTypedId.cs
new file mode 100644
index 0000000..52586cf
--- /dev/null
+++ b/backend/src/Core/LearnStack.Hub.SharedKernel/Identifiers/IStronglyTypedId.cs
@@ -0,0 +1,7 @@
+namespace LearnStack.Hub.SharedKernel.Identifiers;
+
+public interface IStronglyTypedId
+ where TKey : notnull
+{
+ TKey Value { get; }
+}
diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/Identifiers/LearnStackTenantId.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/Identifiers/LearnStackTenantId.cs
new file mode 100644
index 0000000..7a486a8
--- /dev/null
+++ b/backend/src/Core/LearnStack.Hub.SharedKernel/Identifiers/LearnStackTenantId.cs
@@ -0,0 +1,17 @@
+using Vogen;
+
+namespace LearnStack.Hub.SharedKernel.Identifiers;
+
+///
+/// Strongly-typed identifier for a LearnStack tenant — the Hub-side mirror of
+/// the LearnStack-core tenant.id (same UUID on both sides). It lives in
+/// the SharedKernel (like ) because it is referenced
+/// across modules: it is the TenantLifecycle aggregate's id, the Subscriptions
+/// foreign key, and the Entitlement projection's primary key. Cross-module
+/// references to a tenant use this id; other cross-module FKs (e.g. a plan id)
+/// stay plain columns per the module-boundary rules.
+///
+[ValueObject(LearnStackHubVogenDefaults.IdMask)]
+public readonly partial record struct LearnStackTenantId : IStronglyTypedId
+{
+}
diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/Identifiers/OperatorId.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/Identifiers/OperatorId.cs
new file mode 100644
index 0000000..a004260
--- /dev/null
+++ b/backend/src/Core/LearnStack.Hub.SharedKernel/Identifiers/OperatorId.cs
@@ -0,0 +1,22 @@
+using Vogen;
+
+namespace LearnStack.Hub.SharedKernel.Identifiers;
+
+///
+/// Cross-cutting strongly-typed identifier for a Hub operator — a
+/// LearnStack staff member acting in the learnstack-hub Keycloak realm.
+/// This is the load-bearing Hub adjustment versus LearnStack core: Hub's
+/// cross-cutting actor id is , NOT a tenant UserId.
+///
+///
+/// There is deliberately no tenant UserId type anywhere in Hub. Hub
+/// references tenants by LearnStackTenantId, never tenant users by id —
+/// a tenant UserId appearing in Hub would be a
+/// Hub_NeverStores_TenantData-adjacent smell. Audit columns
+/// (CreatedBy / UpdatedBy / DeletedBy) and
+/// CapturedContext are all typed on .
+///
+[ValueObject(LearnStackHubVogenDefaults.IdMask)]
+public readonly partial record struct OperatorId : IStronglyTypedId
+{
+}
diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/Identifiers/SystemGuidFactory.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/Identifiers/SystemGuidFactory.cs
new file mode 100644
index 0000000..ebfd64f
--- /dev/null
+++ b/backend/src/Core/LearnStack.Hub.SharedKernel/Identifiers/SystemGuidFactory.cs
@@ -0,0 +1,12 @@
+namespace LearnStack.Hub.SharedKernel.Identifiers;
+
+///
+/// Production backed by the BCL. Registered as a
+/// singleton at the composition root.
+///
+public sealed class SystemGuidFactory : IGuidFactory
+{
+ public Guid NewUuidV7() => Guid.CreateVersion7();
+
+ public Guid NewUuidV4() => Guid.NewGuid();
+}
diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/LearnStack.Hub.SharedKernel.csproj b/backend/src/Core/LearnStack.Hub.SharedKernel/LearnStack.Hub.SharedKernel.csproj
index 8690cb0..825fba0 100644
--- a/backend/src/Core/LearnStack.Hub.SharedKernel/LearnStack.Hub.SharedKernel.csproj
+++ b/backend/src/Core/LearnStack.Hub.SharedKernel/LearnStack.Hub.SharedKernel.csproj
@@ -1,10 +1,16 @@
@@ -12,9 +18,27 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/LearnStackHubVogenDefaults.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/LearnStackHubVogenDefaults.cs
new file mode 100644
index 0000000..3a1929b
--- /dev/null
+++ b/backend/src/Core/LearnStack.Hub.SharedKernel/LearnStackHubVogenDefaults.cs
@@ -0,0 +1,24 @@
+using Vogen;
+
+namespace LearnStack.Hub.SharedKernel;
+
+///
+/// Canonical Conversions mask for every Hub-declared
+/// [ValueObject<T>] (mirror of LearnStack core's
+/// LearnStackVogenDefaults per ADR-0023). Lives at the SharedKernel
+/// root namespace because the mask covers both aggregate-root IDs
+/// (LearnStackTenantId, PlanId, …) and the cross-cutting
+/// OperatorId.
+///
+public static class LearnStackHubVogenDefaults
+{
+ ///
+ /// Conversion set every aggregate-root ID opts into: EF Core value
+ /// converter, System.Text.Json converter, and the TypeConverter (which
+ /// carries ASP.NET Core route-parameter binding).
+ ///
+ public const Conversions IdMask =
+ Conversions.EfCoreValueConverter
+ | Conversions.SystemTextJson
+ | Conversions.TypeConverter;
+}
diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/Localization/LocalizedMessage.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/Localization/LocalizedMessage.cs
new file mode 100644
index 0000000..4d01449
--- /dev/null
+++ b/backend/src/Core/LearnStack.Hub.SharedKernel/Localization/LocalizedMessage.cs
@@ -0,0 +1,108 @@
+using System.Collections.ObjectModel;
+
+namespace LearnStack.Hub.SharedKernel.Localization;
+
+///
+/// Localization-key carrier for every user-facing message Hub returns to the
+/// operator portal. The backend never returns raw English text; it returns a
+/// whose resolves to a
+/// translation on the client. The lockey_ prefix invariant is enforced
+/// at the constructor — mis-prefixed keys fail loud at construction.
+///
+public sealed record LocalizedMessage
+{
+ /// The required prefix for every localization key.
+ public const string RequiredPrefix = "lockey_";
+
+ public LocalizedMessage(string key, IReadOnlyDictionary? @params = null)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(key);
+
+ if (!key.StartsWith(RequiredPrefix, StringComparison.Ordinal))
+ {
+ throw new ArgumentException(
+ $"Localization key must start with '{RequiredPrefix}'. Got: '{key}'.",
+ nameof(key));
+ }
+
+ Key = key;
+ Params = @params is { Count: > 0 }
+ ? new ReadOnlyDictionary(new Dictionary(@params))
+ : null;
+ }
+
+ ///
+ /// The localization key (always begins with ).
+ /// Error.Code projects from this key with the prefix stripped.
+ ///
+ public string Key { get; }
+
+ ///
+ /// Optional ICU MessageFormat parameter set the frontend interpolates as
+ /// plain text. null when the message takes no parameters.
+ ///
+ public IReadOnlyDictionary? Params { get; }
+
+ /// Convenience factory equivalent to new LocalizedMessage(key, params).
+ public static LocalizedMessage Of(
+ string key,
+ IReadOnlyDictionary? @params = null) =>
+ new(key, @params);
+
+ public bool Equals(LocalizedMessage? other)
+ {
+ if (other is null)
+ {
+ return false;
+ }
+
+ if (ReferenceEquals(this, other))
+ {
+ return true;
+ }
+
+ if (!string.Equals(Key, other.Key, StringComparison.Ordinal))
+ {
+ return false;
+ }
+
+ if (Params is null && other.Params is null)
+ {
+ return true;
+ }
+
+ if (Params is null || other.Params is null || Params.Count != other.Params.Count)
+ {
+ return false;
+ }
+
+ foreach (var (k, v) in Params)
+ {
+ if (!other.Params.TryGetValue(k, out var otherValue) ||
+ !string.Equals(v, otherValue, StringComparison.Ordinal))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ public override int GetHashCode()
+ {
+ var hash = new HashCode();
+ hash.Add(Key, StringComparer.Ordinal);
+ if (Params is not null)
+ {
+ var paramsHash = 0;
+ foreach (var (k, v) in Params)
+ {
+ paramsHash ^= HashCode.Combine(k, v);
+ }
+
+ hash.Add(paramsHash);
+ }
+
+ return hash.ToHashCode();
+ }
+}
diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/Observability/IErrorTrackingProvider.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/Observability/IErrorTrackingProvider.cs
new file mode 100644
index 0000000..4fd195d
--- /dev/null
+++ b/backend/src/Core/LearnStack.Hub.SharedKernel/Observability/IErrorTrackingProvider.cs
@@ -0,0 +1,34 @@
+namespace LearnStack.Hub.SharedKernel.Observability;
+
+///
+/// Sanctioned entry point for error capture. The L1 HubExceptionHandler
+/// 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,
+/// LocalFileErrorTracker for SelfHostedAirGapped.
+///
+public interface IErrorTrackingProvider
+{
+ ValueTask CaptureAsync(
+ Exception exception,
+ CapturedContext context,
+ CancellationToken cancellationToken = default);
+}
+
+///
+/// Snapshot of cross-cutting tags every error capture flows with. Hub is
+/// operator-scoped, not tenant-scoped: the actor is an OperatorId, and
+/// there is no tenant UserId / organization context (the operator-id
+/// span enricher itself lands with the Operators module in P02c-4). When a
+/// capture concerns a specific tenant the optional
+/// carries it (Hub legitimately administers tenants by id — that is not RLS).
+///
+public sealed record CapturedContext(
+ string? CorrelationId,
+ string? RequestPath,
+ string? RequestMethod,
+ Guid? OperatorId,
+ Guid? TenantId,
+ string? ModuleName,
+ IReadOnlyDictionary? AdditionalTags = null);
diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/Pagination/CursorCodec.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/Pagination/CursorCodec.cs
new file mode 100644
index 0000000..3e05d15
--- /dev/null
+++ b/backend/src/Core/LearnStack.Hub.SharedKernel/Pagination/CursorCodec.cs
@@ -0,0 +1,37 @@
+using System.Buffers.Text;
+
+namespace LearnStack.Hub.SharedKernel.Pagination;
+
+///
+/// Encodes / decodes the opaque keyset cursor used by Hub list endpoints. The
+/// cursor wraps the last-seen aggregate as URL-safe base64;
+/// the client treats it as opaque and never parses it.
+///
+public static class CursorCodec
+{
+ /// Encodes a keyset id as an opaque cursor token.
+ public static string Encode(Guid lastId) =>
+ Base64Url.EncodeToString(lastId.ToByteArray());
+
+ ///
+ /// Decodes a cursor token to its keyset id. Returns null when the
+ /// token is null/blank or malformed — callers treat that as "from the start".
+ ///
+ public static Guid? Decode(string? cursor)
+ {
+ if (string.IsNullOrWhiteSpace(cursor))
+ {
+ return null;
+ }
+
+ try
+ {
+ var bytes = Base64Url.DecodeFromChars(cursor);
+ return bytes.Length == 16 ? new Guid(bytes) : null;
+ }
+ catch (FormatException)
+ {
+ return null;
+ }
+ }
+}
diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/Pagination/CursorPagination.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/Pagination/CursorPagination.cs
new file mode 100644
index 0000000..cf548cd
--- /dev/null
+++ b/backend/src/Core/LearnStack.Hub.SharedKernel/Pagination/CursorPagination.cs
@@ -0,0 +1,41 @@
+namespace LearnStack.Hub.SharedKernel.Pagination;
+
+///
+/// Cursor-pagination request. Cursor is the default for every list endpoint.
+/// The is an opaque token the server minted on a previous
+/// response; the client never parses it. defaults to
+/// and is capped at .
+///
+public sealed record CursorPagination
+{
+ public const int DefaultLimit = 20;
+
+ public const int MaxLimit = 100;
+
+ private readonly int _limit = DefaultLimit;
+
+ public CursorPagination(string? Cursor = null, int Limit = DefaultLimit)
+ {
+ this.Cursor = Cursor;
+ this.Limit = Limit;
+ }
+
+ public string? Cursor { get; init; }
+
+ public int Limit
+ {
+ get => _limit;
+ init
+ {
+ if (value <= 0)
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(value),
+ value,
+ $"Limit must be > 0. Got: {value}.");
+ }
+
+ _limit = value > MaxLimit ? MaxLimit : value;
+ }
+ }
+}
diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/Pagination/Page.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/Pagination/Page.cs
new file mode 100644
index 0000000..e0e5b13
--- /dev/null
+++ b/backend/src/Core/LearnStack.Hub.SharedKernel/Pagination/Page.cs
@@ -0,0 +1,19 @@
+using System.Diagnostics.CodeAnalysis;
+
+namespace LearnStack.Hub.SharedKernel.Pagination;
+
+///
+/// Cursor-paginated response. are the page payload;
+/// carries the next/previous opaque cursors plus the
+/// boolean hints the client uses to render pagination controls.
+///
+[SuppressMessage(
+ "Design",
+ "CA1000:Do not declare static members on generic types",
+ Justification = "Canonical empty-instance pattern (mirrors Array.Empty).")]
+public sealed record Page(IReadOnlyList Items, PageInfo PageInfo)
+{
+ public static Page Empty { get; } = new(
+ Array.Empty(),
+ new PageInfo(null, null, HasNext: false, HasPrevious: false));
+}
diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/Pagination/PageInfo.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/Pagination/PageInfo.cs
new file mode 100644
index 0000000..29688de
--- /dev/null
+++ b/backend/src/Core/LearnStack.Hub.SharedKernel/Pagination/PageInfo.cs
@@ -0,0 +1,11 @@
+namespace LearnStack.Hub.SharedKernel.Pagination;
+
+///
+/// Cursor-pagination response envelope. Uniform shape across every list
+/// endpoint so OpenAPI generation produces a consistent surface.
+///
+public sealed record PageInfo(
+ string? NextCursor,
+ string? PreviousCursor,
+ bool HasNext,
+ bool HasPrevious);
diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/Persistence/IOptimisticConcurrency.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/Persistence/IOptimisticConcurrency.cs
new file mode 100644
index 0000000..8b2a381
--- /dev/null
+++ b/backend/src/Core/LearnStack.Hub.SharedKernel/Persistence/IOptimisticConcurrency.cs
@@ -0,0 +1,16 @@
+namespace LearnStack.Hub.SharedKernel.Persistence;
+
+///
+/// Marker for any entity whose updates use optimistic concurrency. EF Core
+/// configures as the row version token so concurrent
+/// updates fail with DbUpdateConcurrencyException — translated to a
+/// Result.Fail(concurrency_conflict).
+///
+public interface IOptimisticConcurrency
+{
+ ///
+ /// Monotonically-increasing version counter. EF Core bumps this on every
+ /// SaveChangesAsync; aggregate code does not mutate it directly.
+ ///
+ uint Version { get; }
+}
diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/Persistence/ISoftDelete.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/Persistence/ISoftDelete.cs
new file mode 100644
index 0000000..2492e86
--- /dev/null
+++ b/backend/src/Core/LearnStack.Hub.SharedKernel/Persistence/ISoftDelete.cs
@@ -0,0 +1,16 @@
+using LearnStack.Hub.SharedKernel.Identifiers;
+
+namespace LearnStack.Hub.SharedKernel.Persistence;
+
+///
+/// Marker for any entity that participates in soft deletion. Callers never set
+/// / directly — use the
+/// aggregate's SoftDelete method. The actor is an
+/// (Hub operators perform deletions), not a tenant user.
+///
+public interface ISoftDelete
+{
+ DateTimeOffset? DeletedAt { get; }
+
+ OperatorId? DeletedBy { get; }
+}
diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/Persistence/IUnitOfWork.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/Persistence/IUnitOfWork.cs
new file mode 100644
index 0000000..f3e9e9e
--- /dev/null
+++ b/backend/src/Core/LearnStack.Hub.SharedKernel/Persistence/IUnitOfWork.cs
@@ -0,0 +1,41 @@
+using Microsoft.EntityFrameworkCore;
+
+namespace LearnStack.Hub.SharedKernel.Persistence;
+
+///
+/// The shared-connection transaction seam the 6-step MediatR pipeline's live
+/// TransactionBehavior commits / rolls back. Hub keeps one DbContext per
+/// module (no global DbContext), but a single command — e.g.
+/// CreateTenantCommand — legitimately writes across the TenantLifecycle,
+/// Subscriptions, and Entitlements contexts. To make that atomic without a
+/// distributed transaction, every module DbContext is constructed against the
+/// same scoped NpgsqlConnection and enlists itself here; the
+/// unit of work owns the one transaction all enlisted contexts ride on.
+///
+///
+/// Nested MediatR sends (e.g. the Subscriptions handler invoking the
+/// Entitlements recompute command) join the outer transaction: the behavior
+/// checks and short-circuits to the inner
+/// pipeline rather than opening a second transaction.
+///
+public interface IUnitOfWork
+{
+ /// true once has opened a transaction not yet committed / rolled back.
+ bool HasActiveTransaction { get; }
+
+ ///
+ /// Registers a module DbContext with the unit of work. If a transaction is
+ /// already active the context is immediately associated with it; otherwise
+ /// it is associated when runs. Idempotent per context.
+ ///
+ void Enlist(DbContext context);
+
+ /// Opens the shared connection (if needed) and begins the transaction. No-op if already active.
+ Task BeginAsync(CancellationToken cancellationToken = default);
+
+ /// Commits the active transaction. No-op if none is active.
+ Task CommitAsync(CancellationToken cancellationToken = default);
+
+ /// Rolls back the active transaction. No-op if none is active.
+ Task RollbackAsync(CancellationToken cancellationToken = default);
+}
diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/Persistence/JsonbConversions.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/Persistence/JsonbConversions.cs
new file mode 100644
index 0000000..81e13c2
--- /dev/null
+++ b/backend/src/Core/LearnStack.Hub.SharedKernel/Persistence/JsonbConversions.cs
@@ -0,0 +1,35 @@
+using System.Text.Json;
+using Microsoft.EntityFrameworkCore.ChangeTracking;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+namespace LearnStack.Hub.SharedKernel.Persistence;
+
+///
+/// EF Core value-converter + comparer factories for the JSONB dictionary
+/// columns Hub stores (Plan.features / .limits,
+/// Entitlement.features / .limits / .compliance_caps). The
+/// converter serialises a Dictionary<string, TValue> to a JSON
+/// string mapped to a jsonb column; the comparer gives EF correct
+/// change-tracking semantics for the mutable dictionary.
+///
+public static class JsonbConversions
+{
+ private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web);
+
+ /// Value converter for a string-keyed dictionary <-> JSON text (jsonb column).
+ public static ValueConverter, string> DictionaryConverter() =>
+ new(
+ model => JsonSerializer.Serialize(model, Options),
+ provider => JsonSerializer.Deserialize>(provider, Options)
+ ?? new Dictionary(StringComparer.Ordinal));
+
+ /// Change-tracking comparer for a string-keyed dictionary (snapshot + deep equality).
+ public static ValueComparer> DictionaryComparer() =>
+ new(
+ (left, right) => Serialize(left) == Serialize(right),
+ value => Serialize(value).GetHashCode(StringComparison.Ordinal),
+ value => new Dictionary(value, StringComparer.Ordinal));
+
+ private static string Serialize(Dictionary? value) =>
+ value is null ? string.Empty : JsonSerializer.Serialize(value, Options);
+}
diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/Random/FixedRandom.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/Random/FixedRandom.cs
new file mode 100644
index 0000000..4b59e89
--- /dev/null
+++ b/backend/src/Core/LearnStack.Hub.SharedKernel/Random/FixedRandom.cs
@@ -0,0 +1,24 @@
+namespace LearnStack.Hub.SharedKernel.Random;
+
+///
+/// Deterministic for tests. Seeded
+/// reproduces the same sequence per run.
+///
+public sealed class FixedRandom : IRandom
+{
+ private readonly System.Random _random;
+
+ public FixedRandom(int seed)
+ {
+ _random = new System.Random(seed);
+ }
+
+ public int Next(int maxExclusive) => _random.Next(maxExclusive);
+
+ public int Next(int minInclusive, int maxExclusive) =>
+ _random.Next(minInclusive, maxExclusive);
+
+ public double NextDouble() => _random.NextDouble();
+
+ public void NextBytes(Span destination) => _random.NextBytes(destination);
+}
diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/Random/IRandom.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/Random/IRandom.cs
new file mode 100644
index 0000000..f78133c
--- /dev/null
+++ b/backend/src/Core/LearnStack.Hub.SharedKernel/Random/IRandom.cs
@@ -0,0 +1,27 @@
+using System.Diagnostics.CodeAnalysis;
+
+namespace LearnStack.Hub.SharedKernel.Random;
+
+///
+/// Randomness abstraction for domain and application code. Production code
+/// never instantiates directly so tests can pin
+/// the sequence deterministically. Cryptographic randomness is out of scope.
+///
+[SuppressMessage(
+ "Naming",
+ "CA1716:Identifiers should not match keywords",
+ Justification = "Mirrors System.Random.Next; C#-only codebase; no VB consumer affected.")]
+public interface IRandom
+{
+ /// Returns a non-negative random integer less than .
+ int Next(int maxExclusive);
+
+ /// Returns a random integer in [minInclusive, maxExclusive).
+ int Next(int minInclusive, int maxExclusive);
+
+ /// Returns a random double in [0.0, 1.0).
+ double NextDouble();
+
+ /// Fills the destination span with random bytes.
+ void NextBytes(Span destination);
+}
diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/Random/SystemRandom.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/Random/SystemRandom.cs
new file mode 100644
index 0000000..9499991
--- /dev/null
+++ b/backend/src/Core/LearnStack.Hub.SharedKernel/Random/SystemRandom.cs
@@ -0,0 +1,17 @@
+namespace LearnStack.Hub.SharedKernel.Random;
+
+///
+/// Production backed by .
+/// Thread-safe; registered as a singleton at the composition root.
+///
+public sealed class SystemRandom : IRandom
+{
+ public int Next(int maxExclusive) => System.Random.Shared.Next(maxExclusive);
+
+ public int Next(int minInclusive, int maxExclusive) =>
+ System.Random.Shared.Next(minInclusive, maxExclusive);
+
+ public double NextDouble() => System.Random.Shared.NextDouble();
+
+ public void NextBytes(Span destination) => System.Random.Shared.NextBytes(destination);
+}
diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/Resilience/IProviderResilience.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/Resilience/IProviderResilience.cs
new file mode 100644
index 0000000..c88bc90
--- /dev/null
+++ b/backend/src/Core/LearnStack.Hub.SharedKernel/Resilience/IProviderResilience.cs
@@ -0,0 +1,23 @@
+using Polly;
+
+namespace LearnStack.Hub.SharedKernel.Resilience;
+
+///
+/// Carrier for the Polly v8 that wraps a
+/// provider adapter (IPaymentProvider, certificate provider, …). Every
+/// such adapter receives one of these in its constructor and routes outbound
+/// calls through . No adapter consumes it until P02c-2 /
+/// Phase 09b, but the surface ships now for parity.
+///
+/// The port interface the resilience policy is keyed to (DI discriminator only).
+#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 ("payment", …).
+ string PortName { get; }
+}
diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/Resilience/ResilienceOptions.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/Resilience/ResilienceOptions.cs
new file mode 100644
index 0000000..388f0ad
--- /dev/null
+++ b/backend/src/Core/LearnStack.Hub.SharedKernel/Resilience/ResilienceOptions.cs
@@ -0,0 +1,46 @@
+namespace LearnStack.Hub.SharedKernel.Resilience;
+
+///
+/// Configuration shape bound from appsettings.Resilience:<portName>:.
+/// The decorator reads one of these per provider port and assembles the Polly
+/// v8 pipeline. Defaults match a conservative-but-useful shape.
+///
+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/Core/LearnStack.Hub.SharedKernel/Results/Error.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/Results/Error.cs
new file mode 100644
index 0000000..0aaa1c5
--- /dev/null
+++ b/backend/src/Core/LearnStack.Hub.SharedKernel/Results/Error.cs
@@ -0,0 +1,140 @@
+using System.Collections.ObjectModel;
+using System.Diagnostics.CodeAnalysis;
+using LearnStack.Hub.SharedKernel.Localization;
+
+namespace LearnStack.Hub.SharedKernel.Results;
+
+///
+/// Result-pattern error payload used by .
+/// is the localised payload the frontend resolves;
+/// is the stable machine-readable identifier API consumers
+/// route on. is derived from Message.Key by
+/// stripping the invariant , so
+/// the two contracts stay in sync by construction.
+///
+[SuppressMessage(
+ "Naming",
+ "CA1716:Identifiers should not match keywords",
+ Justification = "Result+Error pattern — C#-only codebase; no VB consumer affected.")]
+public sealed record Error
+{
+ public Error(
+ LocalizedMessage message,
+ IReadOnlyDictionary>? details = null)
+ {
+ ArgumentNullException.ThrowIfNull(message);
+ Message = message;
+ Details = SnapshotDetails(details);
+ }
+
+ public LocalizedMessage Message { get; }
+
+ public IReadOnlyDictionary>? Details { get; }
+
+ ///
+ /// Stable machine-readable code derived from 's
+ /// Key with the
+ /// stripped. Used as the RFC 7807 code field — never localized.
+ ///
+ public string Code => Message.Key[LocalizedMessage.RequiredPrefix.Length..];
+
+ public bool Equals(Error? other)
+ {
+ if (other is null)
+ {
+ return false;
+ }
+
+ if (ReferenceEquals(this, other))
+ {
+ return true;
+ }
+
+ return Message.Equals(other.Message) && DetailsEqual(Details, other.Details);
+ }
+
+ public override int GetHashCode()
+ {
+ var hash = new HashCode();
+ hash.Add(Message);
+
+ if (Details is not null)
+ {
+ var detailsHash = 0;
+ foreach (var (key, list) in Details)
+ {
+ var listHash = 0;
+ foreach (var msg in list)
+ {
+ listHash ^= msg.GetHashCode();
+ }
+
+ detailsHash ^= HashCode.Combine(key, listHash);
+ }
+
+ hash.Add(detailsHash);
+ }
+
+ return hash.ToHashCode();
+ }
+
+ private static ReadOnlyDictionary>? SnapshotDetails(
+ IReadOnlyDictionary>? source)
+ {
+ if (source is not { Count: > 0 })
+ {
+ return null;
+ }
+
+ var snapshot = new Dictionary>(source.Count);
+ foreach (var (key, list) in source)
+ {
+ ArgumentNullException.ThrowIfNull(list);
+
+ var copy = new LocalizedMessage[list.Count];
+ for (var i = 0; i < list.Count; i++)
+ {
+ copy[i] = list[i] ?? throw new ArgumentException(
+ $"Error.Details['{key}'][{i}] is null. Every field-level entry must be a non-null LocalizedMessage.",
+ nameof(source));
+ }
+
+ snapshot[key] = new ReadOnlyCollection(copy);
+ }
+
+ return new ReadOnlyDictionary>(snapshot);
+ }
+
+ private static bool DetailsEqual(
+ IReadOnlyDictionary>? a,
+ IReadOnlyDictionary>? b)
+ {
+ if (a is null && b is null)
+ {
+ return true;
+ }
+
+ if (a is null || b is null || a.Count != b.Count)
+ {
+ return false;
+ }
+
+ foreach (var (key, listA) in a)
+ {
+ if (!b.TryGetValue(key, out var listB) || listA.Count != listB.Count)
+ {
+ return false;
+ }
+
+ for (var i = 0; i < listA.Count; i++)
+ {
+ if (!listA[i].Equals(listB[i]))
+ {
+ return false;
+ }
+ }
+ }
+
+ return true;
+ }
+}
diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/Results/IResultBase.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/Results/IResultBase.cs
new file mode 100644
index 0000000..d2fa1ad
--- /dev/null
+++ b/backend/src/Core/LearnStack.Hub.SharedKernel/Results/IResultBase.cs
@@ -0,0 +1,25 @@
+using System.Diagnostics.CodeAnalysis;
+using LearnStack.Hub.SharedKernel.Localization;
+
+namespace LearnStack.Hub.SharedKernel.Results;
+
+///
+/// Non-generic surface every exposes. Pipeline
+/// behaviors operate on this contract so they can construct the correct
+/// concrete Result<TResponse> shape via
+/// .
+///
+[SuppressMessage(
+ "Naming",
+ "CA1716:Identifiers should not match keywords",
+ Justification = "Result+Error pattern — C#-only codebase; no VB consumer affected.")]
+public interface IResultBase
+{
+ bool IsSuccess { get; }
+
+ bool IsFailure { get; }
+
+ LocalizedMessage? SuccessMessage { get; }
+
+ Error? Error { get; }
+}
diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/Results/Result.Helpers.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/Results/Result.Helpers.cs
new file mode 100644
index 0000000..1a23aff
--- /dev/null
+++ b/backend/src/Core/LearnStack.Hub.SharedKernel/Results/Result.Helpers.cs
@@ -0,0 +1,61 @@
+using System.Reflection;
+using LearnStack.Hub.SharedKernel.Localization;
+
+namespace LearnStack.Hub.SharedKernel.Results;
+
+///
+/// Static helpers for constructing instances when the
+/// concrete generic parameter is known only at runtime (e.g. inside the
+/// MediatR ValidationBehavior that short-circuits a handler without
+/// referencing the value type). Mirror of LearnStack core ADR-0032 § Sub-decision 3.
+///
+public static class Result
+{
+ public static Result Ok(T value, LocalizedMessage? message = null) =>
+ Result.Ok(value, message);
+
+ public static Result Fail(Error error) => Result.Fail(error);
+
+ ///
+ /// Reflection-friendly failure factory used by MediatR pipeline behaviors
+ /// whose TResponse is itself a Result<TValue>. Returns
+ /// an instance of by reflecting over the
+ /// closed generic to invoke Result<TValue>.Fail(error). The
+ /// reflected is cached per closed
+ /// — initialised once, zero per-call cost.
+ ///
+ public static TResponse FailFor(Error error)
+ where TResponse : IResultBase
+ {
+ ArgumentNullException.ThrowIfNull(error);
+
+ var fail = FailForCache.FailMethod
+ ?? throw new InvalidOperationException(
+ $"Result.FailFor requires TResponse to be a closed Result; got {typeof(TResponse).FullName}.");
+
+ return (TResponse)fail.Invoke(obj: null, parameters: [error])!;
+ }
+
+ private static class FailForCache
+ where TResponse : IResultBase
+ {
+ public static readonly MethodInfo? FailMethod = ResolveFailMethod();
+
+ private static MethodInfo? ResolveFailMethod()
+ {
+ var responseType = typeof(TResponse);
+ if (!responseType.IsGenericType
+ || responseType.GetGenericTypeDefinition() != typeof(Result<>))
+ {
+ return null;
+ }
+
+ return responseType.GetMethod(
+ nameof(Result.Fail),
+ BindingFlags.Public | BindingFlags.Static,
+ binder: null,
+ types: [typeof(Error)],
+ modifiers: null);
+ }
+ }
+}
diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/Results/Result.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/Results/Result.cs
new file mode 100644
index 0000000..36c053c
--- /dev/null
+++ b/backend/src/Core/LearnStack.Hub.SharedKernel/Results/Result.cs
@@ -0,0 +1,59 @@
+using System.Diagnostics.CodeAnalysis;
+using LearnStack.Hub.SharedKernel.Localization;
+
+namespace LearnStack.Hub.SharedKernel.Results;
+
+///
+/// Result-pattern wrapper. Returned by every MediatR command/query handler
+/// (mirror of LearnStack core ADR-0032 § Error Model). Construction is
+/// funnelled through the / factories;
+/// the primary constructor is internal so callers cannot bypass the
+/// success-must-carry-value rule via positional record syntax.
+///
+[SuppressMessage(
+ "Design",
+ "CA1000:Do not declare static members on generic types",
+ Justification = "Result+Error factory pattern — canonical shape across FluentResults / Ardalis.Result lineage.")]
+public sealed record Result : IResultBase
+{
+ internal Result(bool isSuccess, T? value, Error? error, LocalizedMessage? successMessage = null)
+ {
+ IsSuccess = isSuccess;
+ Value = value;
+ Error = error;
+ SuccessMessage = successMessage;
+ }
+
+ public bool IsSuccess { get; }
+
+ public bool IsFailure => !IsSuccess;
+
+ public T? Value { get; }
+
+ public Error? Error { get; }
+
+ public LocalizedMessage? SuccessMessage { get; }
+
+ ///
+ /// Constructs a success result. Throws when is
+ /// null: a success result must carry a value — if a payload-less
+ /// success shape is needed, model it as Result<Unit>.
+ ///
+ public static Result Ok(T value, LocalizedMessage? message = null)
+ {
+ if (value is null)
+ {
+ throw new ArgumentNullException(
+ nameof(value),
+ "Result.Ok cannot wrap a null value. Use Result for payload-less success.");
+ }
+
+ return new Result(isSuccess: true, value: value, error: null, successMessage: message);
+ }
+
+ public static Result Fail(Error error)
+ {
+ ArgumentNullException.ThrowIfNull(error);
+ return new Result(isSuccess: false, value: default, error: error);
+ }
+}
diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/Results/Unit.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/Results/Unit.cs
new file mode 100644
index 0000000..46e9599
--- /dev/null
+++ b/backend/src/Core/LearnStack.Hub.SharedKernel/Results/Unit.cs
@@ -0,0 +1,16 @@
+namespace LearnStack.Hub.SharedKernel.Results;
+
+///
+/// Empty value used as the payload of Result<Unit> when a
+/// command/query succeeds without returning data. Use this rather than
+/// Result<object?> with a null value.
+///
+public readonly record struct Unit
+{
+ ///
+ /// The single canonical value. All
+ /// instances are equal by definition; is just the
+ /// idiomatic spelling at call sites.
+ ///
+ public static Unit Value => default;
+}
diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/Secrets/ConfigurationSecretProvider.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/Secrets/ConfigurationSecretProvider.cs
new file mode 100644
index 0000000..5474f4e
--- /dev/null
+++ b/backend/src/Core/LearnStack.Hub.SharedKernel/Secrets/ConfigurationSecretProvider.cs
@@ -0,0 +1,23 @@
+using Microsoft.Extensions.Configuration;
+
+namespace LearnStack.Hub.SharedKernel.Secrets;
+
+///
+/// Default that delegates to
+/// (which already merges environment variables,
+/// user secrets, and appsettings.{env}.json). A Vault-backed
+/// implementation lands in a later packet; the composition root branches by
+/// DeploymentMode.
+///
+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/Core/LearnStack.Hub.SharedKernel/Secrets/ISecretProvider.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/Secrets/ISecretProvider.cs
new file mode 100644
index 0000000..050e8f9
--- /dev/null
+++ b/backend/src/Core/LearnStack.Hub.SharedKernel/Secrets/ISecretProvider.cs
@@ -0,0 +1,17 @@
+namespace LearnStack.Hub.SharedKernel.Secrets;
+
+///
+/// Composition-root-resolved secret provider. Every secret-bearing value
+/// (Sentry DSN, provider API keys, HMAC shared secrets) is read through this
+/// contract — modules never call Environment.GetEnvironmentVariable or
+/// hand-roll their own Vault clients.
+///
+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.
+ ///
+ string? GetSecret(string key);
+}
diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/Secrets/SensitiveTokenCatalog.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/Secrets/SensitiveTokenCatalog.cs
new file mode 100644
index 0000000..4c02bf2
--- /dev/null
+++ b/backend/src/Core/LearnStack.Hub.SharedKernel/Secrets/SensitiveTokenCatalog.cs
@@ -0,0 +1,118 @@
+namespace LearnStack.Hub.SharedKernel.Secrets;
+
+///
+/// The canonical list of word-tokens whose presence as a segment of a
+/// property name marks the value as sensitive. The air-gapped
+/// LocalFileErrorTracker (and any future Serilog redaction enricher)
+/// consume this single source of truth so redaction surfaces cannot drift.
+/// Matching is on word boundaries, not raw substrings, to avoid over-redaction.
+///
+public static class SensitiveTokenCatalog
+{
+ /// Substituted in place of any matched property value.
+ public const string RedactedValue = "***REDACTED***";
+
+ private static readonly HashSet SingleWordTokens =
+ new(StringComparer.OrdinalIgnoreCase)
+ {
+ "apikey",
+ "authorization",
+ "cardnumber",
+ "credential",
+ "cvc",
+ "cvv",
+ "dsn",
+ "hmac",
+ "iban",
+ "jwt",
+ "passwd",
+ "password",
+ "secret",
+ "ssn",
+ "tckn",
+ "token",
+ "vkn",
+ };
+
+ private static readonly HashSet TwoWordTokens =
+ new(StringComparer.OrdinalIgnoreCase)
+ {
+ "apikey",
+ "cardnumber",
+ "authheader",
+ };
+
+ /// The canonical token list (for docs / tests).
+ public static IReadOnlyCollection DefaultTokens => SingleWordTokens;
+
+ ///
+ /// Returns true when any whole segment of
+ /// (split on camelCase boundaries and _ . - separators) matches a
+ /// sensitive token.
+ ///
+ public static bool IsSensitive(string propertyName)
+ {
+ if (string.IsNullOrEmpty(propertyName))
+ {
+ return false;
+ }
+
+ var segments = Tokenize(propertyName);
+
+ for (var i = 0; i < segments.Count; i++)
+ {
+ if (SingleWordTokens.Contains(segments[i]))
+ {
+ return true;
+ }
+
+ if (i + 1 < segments.Count
+ && TwoWordTokens.Contains(segments[i] + segments[i + 1]))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ 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);
+
+ 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/Core/LearnStack.Hub.SharedKernel/Time/FixedClock.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/Time/FixedClock.cs
new file mode 100644
index 0000000..6892778
--- /dev/null
+++ b/backend/src/Core/LearnStack.Hub.SharedKernel/Time/FixedClock.cs
@@ -0,0 +1,22 @@
+namespace LearnStack.Hub.SharedKernel.Time;
+
+///
+/// Deterministic for tests. Time advances only through
+/// / — never spontaneously. All
+/// stored instants are normalised to UTC offset.
+///
+public sealed class FixedClock : IClock
+{
+ private DateTimeOffset _utcNow;
+
+ public FixedClock(DateTimeOffset utcNow)
+ {
+ _utcNow = utcNow.ToUniversalTime();
+ }
+
+ public DateTimeOffset UtcNow => _utcNow;
+
+ public void SetUtcNow(DateTimeOffset utcNow) => _utcNow = utcNow.ToUniversalTime();
+
+ public void Advance(TimeSpan delta) => _utcNow = _utcNow.Add(delta).ToUniversalTime();
+}
diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/Time/IClock.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/Time/IClock.cs
new file mode 100644
index 0000000..d8964b2
--- /dev/null
+++ b/backend/src/Core/LearnStack.Hub.SharedKernel/Time/IClock.cs
@@ -0,0 +1,12 @@
+namespace LearnStack.Hub.SharedKernel.Time;
+
+///
+/// Wall-clock abstraction for domain and application code. No production code
+/// reads DateTimeOffset.UtcNow directly — every timestamp flows through
+/// so tests can pin time deterministically.
+///
+public interface IClock
+{
+ /// Current UTC instant. Persisted timestamps are always UTC.
+ DateTimeOffset UtcNow { get; }
+}
diff --git a/backend/src/Core/LearnStack.Hub.SharedKernel/Time/SystemClock.cs b/backend/src/Core/LearnStack.Hub.SharedKernel/Time/SystemClock.cs
new file mode 100644
index 0000000..f965560
--- /dev/null
+++ b/backend/src/Core/LearnStack.Hub.SharedKernel/Time/SystemClock.cs
@@ -0,0 +1,10 @@
+namespace LearnStack.Hub.SharedKernel.Time;
+
+///
+/// Production backed by the BCL system clock. Registered
+/// as a singleton at the composition root.
+///
+public sealed class SystemClock : IClock
+{
+ public DateTimeOffset UtcNow => DateTimeOffset.UtcNow;
+}
diff --git a/backend/src/Modules/Directory.Build.props b/backend/src/Modules/Directory.Build.props
new file mode 100644
index 0000000..ce85d9b
--- /dev/null
+++ b/backend/src/Modules/Directory.Build.props
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Application.Contracts/AssemblyMarker.cs b/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Application.Contracts/AssemblyMarker.cs
new file mode 100644
index 0000000..0558b54
--- /dev/null
+++ b/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Application.Contracts/AssemblyMarker.cs
@@ -0,0 +1,4 @@
+namespace LearnStack.Hub.Modules.Entitlements.Application.Contracts;
+
+/// Anchor type for assembly scanning (MediatR handler discovery + NetArchTest IL TypeRef pinning).
+public sealed class AssemblyMarker;
diff --git a/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Application.Contracts/EntitlementCommands.cs b/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Application.Contracts/EntitlementCommands.cs
new file mode 100644
index 0000000..6eacbaa
--- /dev/null
+++ b/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Application.Contracts/EntitlementCommands.cs
@@ -0,0 +1,14 @@
+using LearnStack.Hub.SharedKernel.Results;
+using MediatR;
+
+namespace LearnStack.Hub.Modules.Entitlements.Application.Contracts;
+
+///
+/// Rebuilds the tenant's entitlement projection from Plan +
+/// HubSubscription and bumps the monotonic generation. The public entry
+/// the Subscriptions / Plans / TenantLifecycle handlers call after a state change.
+///
+public sealed record RecomputeEntitlementCommand(Guid TenantId) : IRequest>;
+
+/// Reads the current entitlement projection for a tenant.
+public sealed record GetEntitlementQuery(Guid TenantId) : IRequest>;
diff --git a/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Application.Contracts/EntitlementProjectionDto.cs b/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Application.Contracts/EntitlementProjectionDto.cs
new file mode 100644
index 0000000..828edd2
--- /dev/null
+++ b/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Application.Contracts/EntitlementProjectionDto.cs
@@ -0,0 +1,59 @@
+using System.Text.Json.Serialization;
+
+namespace LearnStack.Hub.Modules.Entitlements.Application.Contracts;
+
+///
+/// The entitlement projection wire contract (entitlement-projection.md §
+/// Projection shape + Architecture 24 § 4). Its System.Text.Json shape is
+/// the contract LearnStack core consumes — treat changes as contract changes
+/// (the EntitlementProjection_Shape_IsStable contract test snapshots it
+/// against entitlement-v1.schema.json). Property names are pinned via
+/// , independent of any serializer policy.
+///
+public sealed record EntitlementProjectionDto
+{
+ [JsonPropertyName("tenant_id")]
+ public required Guid TenantId { get; init; }
+
+ [JsonPropertyName("tier")]
+ public required string Tier { get; init; }
+
+ [JsonPropertyName("features")]
+ public required IReadOnlyDictionary Features { get; init; }
+
+ [JsonPropertyName("limits")]
+ public required IReadOnlyDictionary Limits { get; init; }
+
+ [JsonPropertyName("compliance")]
+ public required ComplianceSectionDto Compliance { get; init; }
+
+ [JsonPropertyName("expires_at")]
+ public DateTimeOffset? ExpiresAt { get; init; }
+
+ [JsonPropertyName("grace_until")]
+ public DateTimeOffset? GraceUntil { get; init; }
+
+ [JsonPropertyName("generation")]
+ public required long Generation { get; init; }
+}
+
+/// The compliance envelope: a single caps map (empty {} in P02c-1).
+public sealed record ComplianceSectionDto
+{
+ [JsonPropertyName("caps")]
+ public required IReadOnlyDictionary Caps { get; init; }
+}
+
+/// A single compliance cap on the wire: { allowed, forced, value? }.
+public sealed record ComplianceCapDto
+{
+ [JsonPropertyName("allowed")]
+ public required bool Allowed { get; init; }
+
+ [JsonPropertyName("forced")]
+ public required bool Forced { get; init; }
+
+ [JsonPropertyName("value")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string? Value { get; init; }
+}
diff --git a/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Application.Contracts/LearnStack.Hub.Modules.Entitlements.Application.Contracts.csproj b/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Application.Contracts/LearnStack.Hub.Modules.Entitlements.Application.Contracts.csproj
new file mode 100644
index 0000000..9473809
--- /dev/null
+++ b/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Application.Contracts/LearnStack.Hub.Modules.Entitlements.Application.Contracts.csproj
@@ -0,0 +1,15 @@
+
+
+
+ LearnStack.Hub.Modules.Entitlements.Application.Contracts
+
+
+
+
+
+
+
+
+
+
+
diff --git a/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Application/Abstractions/IEntitlementRepository.cs b/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Application/Abstractions/IEntitlementRepository.cs
new file mode 100644
index 0000000..aac36ec
--- /dev/null
+++ b/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Application/Abstractions/IEntitlementRepository.cs
@@ -0,0 +1,14 @@
+using LearnStack.Hub.Modules.Entitlements.Domain;
+using LearnStack.Hub.SharedKernel.Identifiers;
+
+namespace LearnStack.Hub.Modules.Entitlements.Application.Abstractions;
+
+/// Persistence port for the projection (1:1 with tenant; PK = tenant id).
+public interface IEntitlementRepository
+{
+ Task GetByTenantAsync(LearnStackTenantId tenantId, CancellationToken cancellationToken);
+
+ Task AddAsync(Entitlement entitlement, CancellationToken cancellationToken);
+
+ Task SaveChangesAsync(CancellationToken cancellationToken);
+}
diff --git a/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Application/AssemblyMarker.cs b/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Application/AssemblyMarker.cs
new file mode 100644
index 0000000..c069472
--- /dev/null
+++ b/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Application/AssemblyMarker.cs
@@ -0,0 +1,4 @@
+namespace LearnStack.Hub.Modules.Entitlements.Application;
+
+/// Anchor type for assembly scanning (MediatR handler discovery + NetArchTest IL TypeRef pinning).
+public sealed class AssemblyMarker;
diff --git a/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Application/EntitlementMappings.cs b/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Application/EntitlementMappings.cs
new file mode 100644
index 0000000..95f3d12
--- /dev/null
+++ b/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Application/EntitlementMappings.cs
@@ -0,0 +1,31 @@
+using LearnStack.Hub.Modules.Entitlements.Application.Contracts;
+using LearnStack.Hub.Modules.Entitlements.Domain;
+
+namespace LearnStack.Hub.Modules.Entitlements.Application;
+
+/// Maps the aggregate to the wire-contract .
+internal static class EntitlementMappings
+{
+ public static EntitlementProjectionDto ToProjectionDto(this Entitlement e) => new()
+ {
+ TenantId = e.Id.Value,
+ Tier = e.Tier,
+ Features = new Dictionary(e.Features, StringComparer.Ordinal),
+ Limits = new Dictionary(e.Limits, StringComparer.Ordinal),
+ Compliance = new ComplianceSectionDto
+ {
+ Caps = e.ComplianceCaps.ToDictionary(
+ kvp => kvp.Key,
+ kvp => new ComplianceCapDto
+ {
+ Allowed = kvp.Value.Allowed,
+ Forced = kvp.Value.Forced,
+ Value = kvp.Value.Value,
+ },
+ StringComparer.Ordinal),
+ },
+ ExpiresAt = e.ExpiresAt,
+ GraceUntil = e.GraceUntil,
+ Generation = e.Generation,
+ };
+}
diff --git a/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Application/EntitlementProjectionService.cs b/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Application/EntitlementProjectionService.cs
new file mode 100644
index 0000000..d447e37
--- /dev/null
+++ b/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Application/EntitlementProjectionService.cs
@@ -0,0 +1,84 @@
+using LearnStack.Hub.Modules.Entitlements.Application.Abstractions;
+using LearnStack.Hub.Modules.Entitlements.Application.Contracts;
+using LearnStack.Hub.Modules.Entitlements.Domain;
+using LearnStack.Hub.Modules.Plans.Application.Contracts;
+using LearnStack.Hub.Modules.Subscriptions.Application.Contracts;
+using LearnStack.Hub.SharedKernel.Compliance;
+using LearnStack.Hub.SharedKernel.Identifiers;
+using LearnStack.Hub.SharedKernel.Results;
+using LearnStack.Hub.SharedKernel.Time;
+using MediatR;
+
+namespace LearnStack.Hub.Modules.Entitlements.Application;
+
+///
+/// Rebuilds the projection from Plan +
+/// HubSubscription (+ CompliancePolicy from P02c-5). Reads both
+/// inputs via Application.Contracts queries (never their Domain), upserts the
+/// projection in its own DbContext within the active transaction, and bumps the
+/// monotonic generation. In P02c-1 the learnstack.hub.entitlement Dapr
+/// publish is a no-op shell — the in-process recompute + persist is the deliverable.
+///
+public sealed class EntitlementProjectionService(
+ IEntitlementRepository repository,
+ IMediator mediator,
+ IClock clock)
+{
+ public async Task> RecomputeAsync(Guid tenantId, CancellationToken cancellationToken)
+ {
+ var subscription = await mediator.Send(new GetSubscriptionQuery(tenantId), cancellationToken).ConfigureAwait(false);
+ if (subscription.IsFailure)
+ {
+ return Result.Fail(subscription.Error!);
+ }
+
+ var plan = await mediator.Send(new GetPlanQuery(subscription.Value!.PlanId), cancellationToken).ConfigureAwait(false);
+ if (plan.IsFailure)
+ {
+ return Result.Fail(plan.Error!);
+ }
+
+ var sub = subscription.Value!;
+ var p = plan.Value!;
+
+ // Compose. compliance.caps is empty in P02c-1 (CompliancePolicy is P02c-5);
+ // grace_until is null until license/dunning (P02c-6 / Phase 09b).
+ var caps = new Dictionary(StringComparer.Ordinal);
+ var tenantStronglyTyped = LearnStackTenantId.From(tenantId);
+
+ var existing = await repository.GetByTenantAsync(tenantStronglyTyped, cancellationToken).ConfigureAwait(false);
+ Entitlement entitlement;
+ if (existing is null)
+ {
+ entitlement = Entitlement.CreateInitial(
+ tenantStronglyTyped,
+ p.Tier,
+ p.Features,
+ p.Limits,
+ caps,
+ sub.CurrentPeriodEnd,
+ graceUntil: null,
+ clock);
+ await repository.AddAsync(entitlement, cancellationToken).ConfigureAwait(false);
+ }
+ else
+ {
+ existing.Recompute(
+ p.Tier,
+ p.Features,
+ p.Limits,
+ caps,
+ sub.CurrentPeriodEnd,
+ graceUntil: null,
+ clock);
+ entitlement = existing;
+ }
+
+ await repository.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+
+ // TODO(P02c-2): enqueue the learnstack.hub.entitlement integration event
+ // via IOutbox carrying { tenant_id, generation, expires_at }.
+
+ return Result.Ok(entitlement.ToProjectionDto());
+ }
+}
diff --git a/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Application/Handlers/EntitlementHandlers.cs b/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Application/Handlers/EntitlementHandlers.cs
new file mode 100644
index 0000000..9fc7eaa
--- /dev/null
+++ b/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Application/Handlers/EntitlementHandlers.cs
@@ -0,0 +1,34 @@
+using LearnStack.Hub.Modules.Entitlements.Application.Abstractions;
+using LearnStack.Hub.Modules.Entitlements.Application.Contracts;
+using LearnStack.Hub.SharedKernel.Identifiers;
+using LearnStack.Hub.SharedKernel.Localization;
+using LearnStack.Hub.SharedKernel.Results;
+using MediatR;
+
+namespace LearnStack.Hub.Modules.Entitlements.Application.Handlers;
+
+public sealed class RecomputeEntitlementCommandHandler(EntitlementProjectionService projectionService)
+ : IRequestHandler>
+{
+ public Task> Handle(RecomputeEntitlementCommand request, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+ return projectionService.RecomputeAsync(request.TenantId, cancellationToken);
+ }
+}
+
+public sealed class GetEntitlementQueryHandler(IEntitlementRepository repository)
+ : IRequestHandler>
+{
+ public async Task> Handle(GetEntitlementQuery request, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+
+ var entitlement = await repository.GetByTenantAsync(LearnStackTenantId.From(request.TenantId), cancellationToken)
+ .ConfigureAwait(false);
+
+ return entitlement is null
+ ? Result.Fail(new Error(new LocalizedMessage("lockey_not_found")))
+ : Result.Ok(entitlement.ToProjectionDto());
+ }
+}
diff --git a/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Application/LearnStack.Hub.Modules.Entitlements.Application.csproj b/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Application/LearnStack.Hub.Modules.Entitlements.Application.csproj
new file mode 100644
index 0000000..f8536fc
--- /dev/null
+++ b/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Application/LearnStack.Hub.Modules.Entitlements.Application.csproj
@@ -0,0 +1,21 @@
+
+
+
+ LearnStack.Hub.Modules.Entitlements.Application
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Domain/AssemblyMarker.cs b/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Domain/AssemblyMarker.cs
new file mode 100644
index 0000000..f22133b
--- /dev/null
+++ b/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Domain/AssemblyMarker.cs
@@ -0,0 +1,4 @@
+namespace LearnStack.Hub.Modules.Entitlements.Domain;
+
+/// Anchor type for assembly scanning (MediatR handler discovery + NetArchTest IL TypeRef pinning).
+public sealed class AssemblyMarker;
diff --git a/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Domain/Entitlement.cs b/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Domain/Entitlement.cs
new file mode 100644
index 0000000..883a18e
--- /dev/null
+++ b/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Domain/Entitlement.cs
@@ -0,0 +1,112 @@
+using LearnStack.Hub.SharedKernel.Compliance;
+using LearnStack.Hub.SharedKernel.Domain;
+using LearnStack.Hub.SharedKernel.Identifiers;
+using LearnStack.Hub.SharedKernel.Time;
+
+namespace LearnStack.Hub.Modules.Entitlements.Domain;
+
+///
+/// The flattened, denormalised projection of Plan + HubSubscription
+/// (+ CompliancePolicy, P02c-5) per tenant — the single shape LearnStack
+/// core consumes across the Hub HTTPS contract surface. Inherits
+/// , NOT AuditableEntity: exactly one row per
+/// tenant (PK = tenant id), replaced wholesale on recompute; its audit trail is
+/// the monotonic + .
+///
+public sealed class Entitlement : Entity
+{
+ // Non-readonly: EF replaces these field instances on materialization.
+ private Dictionary _features = new(StringComparer.Ordinal);
+ private Dictionary _limits = new(StringComparer.Ordinal);
+ private Dictionary _complianceCaps = new(StringComparer.Ordinal);
+
+ private Entitlement(LearnStackTenantId tenantId)
+ : base(tenantId)
+ {
+ }
+
+ // EF Core materialization ctor.
+ private Entitlement()
+ {
+ }
+
+ public string Tier { get; private set; } = string.Empty;
+
+ public IReadOnlyDictionary Features => _features;
+
+ public IReadOnlyDictionary Limits => _limits;
+
+ /// Compliance caps — empty in P02c-1; populated by the Compliance module (P02c-5).
+ public IReadOnlyDictionary ComplianceCaps => _complianceCaps;
+
+ public DateTimeOffset? ExpiresAt { get; private set; }
+
+ public DateTimeOffset? GraceUntil { get; private set; }
+
+ /// Monotonic cache-coherency counter: starts at 1, only ever +1, never resets or decrements.
+ public long Generation { get; private set; }
+
+ public DateTimeOffset UpdatedAt { get; private set; }
+
+ /// Creates the first entitlement for a tenant with = 1.
+ public static Entitlement CreateInitial(
+ LearnStackTenantId tenantId,
+ string tier,
+ IReadOnlyDictionary features,
+ IReadOnlyDictionary limits,
+ IReadOnlyDictionary complianceCaps,
+ DateTimeOffset? expiresAt,
+ DateTimeOffset? graceUntil,
+ IClock clock)
+ {
+ ArgumentNullException.ThrowIfNull(clock);
+
+ var entitlement = new Entitlement(tenantId);
+ entitlement.Apply(tier, features, limits, complianceCaps, expiresAt, graceUntil);
+ entitlement.Generation = 1;
+ entitlement.UpdatedAt = clock.UtcNow;
+ return entitlement;
+ }
+
+ ///
+ /// Replaces the projection fields and bumps by
+ /// exactly 1. The increment + field replacement happen in the same
+ /// transaction (the live TransactionBehavior covers commit/rollback).
+ ///
+ public void Recompute(
+ string tier,
+ IReadOnlyDictionary features,
+ IReadOnlyDictionary limits,
+ IReadOnlyDictionary complianceCaps,
+ DateTimeOffset? expiresAt,
+ DateTimeOffset? graceUntil,
+ IClock clock)
+ {
+ ArgumentNullException.ThrowIfNull(clock);
+
+ Apply(tier, features, limits, complianceCaps, expiresAt, graceUntil);
+ Generation++;
+ UpdatedAt = clock.UtcNow;
+ }
+
+ private void Apply(
+ string tier,
+ IReadOnlyDictionary features,
+ IReadOnlyDictionary limits,
+ IReadOnlyDictionary complianceCaps,
+ DateTimeOffset? expiresAt,
+ DateTimeOffset? graceUntil)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(tier);
+ ArgumentNullException.ThrowIfNull(features);
+ ArgumentNullException.ThrowIfNull(limits);
+ ArgumentNullException.ThrowIfNull(complianceCaps);
+
+ Tier = tier;
+ _features = new Dictionary(features, StringComparer.Ordinal);
+ _limits = new Dictionary(limits, StringComparer.Ordinal);
+ _complianceCaps = new Dictionary(complianceCaps, StringComparer.Ordinal);
+ ExpiresAt = expiresAt;
+ GraceUntil = graceUntil;
+ }
+}
diff --git a/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Domain/LearnStack.Hub.Modules.Entitlements.Domain.csproj b/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Domain/LearnStack.Hub.Modules.Entitlements.Domain.csproj
new file mode 100644
index 0000000..d634a01
--- /dev/null
+++ b/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Domain/LearnStack.Hub.Modules.Entitlements.Domain.csproj
@@ -0,0 +1,11 @@
+
+
+
+ LearnStack.Hub.Modules.Entitlements.Domain
+
+
+
+
+
+
+
diff --git a/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Infrastructure/AssemblyMarker.cs b/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Infrastructure/AssemblyMarker.cs
new file mode 100644
index 0000000..2296355
--- /dev/null
+++ b/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Infrastructure/AssemblyMarker.cs
@@ -0,0 +1,4 @@
+namespace LearnStack.Hub.Modules.Entitlements.Infrastructure;
+
+/// Anchor type for assembly scanning (MediatR handler discovery + NetArchTest IL TypeRef pinning).
+public sealed class AssemblyMarker;
diff --git a/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Infrastructure/EntitlementsModuleRegistration.cs b/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Infrastructure/EntitlementsModuleRegistration.cs
new file mode 100644
index 0000000..c6778bb
--- /dev/null
+++ b/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Infrastructure/EntitlementsModuleRegistration.cs
@@ -0,0 +1,34 @@
+using LearnStack.Hub.Modules.Entitlements.Application;
+using LearnStack.Hub.Modules.Entitlements.Application.Abstractions;
+using LearnStack.Hub.Modules.Entitlements.Infrastructure.Persistence;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Npgsql;
+
+namespace LearnStack.Hub.Modules.Entitlements.Infrastructure;
+
+/// Composition-root registration for the Entitlements module (DbContext, repository, projection service).
+public static class EntitlementsModuleRegistration
+{
+ /// Per-module migrations history table (in the hub schema).
+ public const string MigrationsHistoryTable = "__ef_migrations_history_entitlements";
+
+ public static IServiceCollection AddEntitlementsModule(
+ this IServiceCollection services,
+ IConfiguration configuration)
+ {
+ ArgumentNullException.ThrowIfNull(services);
+ ArgumentNullException.ThrowIfNull(configuration);
+
+ services.AddDbContext((sp, options) =>
+ options.UseNpgsql(
+ sp.GetRequiredService(),
+ npg => npg.MigrationsHistoryTable(MigrationsHistoryTable, "hub")));
+
+ services.AddScoped();
+ services.AddScoped();
+
+ return services;
+ }
+}
diff --git a/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Infrastructure/LearnStack.Hub.Modules.Entitlements.Infrastructure.csproj b/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Infrastructure/LearnStack.Hub.Modules.Entitlements.Infrastructure.csproj
new file mode 100644
index 0000000..03cb3c0
--- /dev/null
+++ b/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Infrastructure/LearnStack.Hub.Modules.Entitlements.Infrastructure.csproj
@@ -0,0 +1,21 @@
+
+
+
+ LearnStack.Hub.Modules.Entitlements.Infrastructure
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Infrastructure/Migrations/20260522133639_InitialCreate.Designer.cs b/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Infrastructure/Migrations/20260522133639_InitialCreate.Designer.cs
new file mode 100644
index 0000000..74ddb71
--- /dev/null
+++ b/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Infrastructure/Migrations/20260522133639_InitialCreate.Designer.cs
@@ -0,0 +1,78 @@
+//
+using System;
+using LearnStack.Hub.Modules.Entitlements.Infrastructure.Persistence;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace LearnStack.Hub.Modules.Entitlements.Infrastructure.Migrations
+{
+ [DbContext(typeof(EntitlementsDbContext))]
+ [Migration("20260522133639_InitialCreate")]
+ partial class InitialCreate
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasDefaultSchema("hub")
+ .HasAnnotation("ProductVersion", "10.0.0")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("LearnStack.Hub.Modules.Entitlements.Domain.Entitlement", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id");
+
+ b.Property("ExpiresAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("expires_at");
+
+ b.Property("Generation")
+ .HasColumnType("bigint")
+ .HasColumnName("generation");
+
+ b.Property("GraceUntil")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("grace_until");
+
+ b.Property("Tier")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("tier");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at");
+
+ b.Property("_complianceCaps")
+ .IsRequired()
+ .HasColumnType("jsonb")
+ .HasColumnName("compliance_caps");
+
+ b.Property("_features")
+ .IsRequired()
+ .HasColumnType("jsonb")
+ .HasColumnName("features");
+
+ b.Property("_limits")
+ .IsRequired()
+ .HasColumnType("jsonb")
+ .HasColumnName("limits");
+
+ b.HasKey("Id");
+
+ b.ToTable("entitlements", "hub");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Infrastructure/Migrations/20260522133639_InitialCreate.cs b/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Infrastructure/Migrations/20260522133639_InitialCreate.cs
new file mode 100644
index 0000000..0ddb249
--- /dev/null
+++ b/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Infrastructure/Migrations/20260522133639_InitialCreate.cs
@@ -0,0 +1,45 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace LearnStack.Hub.Modules.Entitlements.Infrastructure.Migrations;
+
+///
+public partial class InitialCreate : Migration
+{
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.EnsureSchema(
+ name: "hub");
+
+ migrationBuilder.CreateTable(
+ name: "entitlements",
+ schema: "hub",
+ columns: table => new
+ {
+ tenant_id = table.Column(type: "uuid", nullable: false),
+ tier = table.Column(type: "text", nullable: false),
+ expires_at = table.Column(type: "timestamp with time zone", nullable: true),
+ grace_until = table.Column(type: "timestamp with time zone", nullable: true),
+ generation = table.Column(type: "bigint", nullable: false),
+ updated_at = table.Column(type: "timestamp with time zone", nullable: false),
+ compliance_caps = table.Column(type: "jsonb", nullable: false),
+ features = table.Column(type: "jsonb", nullable: false),
+ limits = table.Column(type: "jsonb", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_entitlements", x => x.tenant_id);
+ });
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "entitlements",
+ schema: "hub");
+ }
+}
diff --git a/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Infrastructure/Migrations/EntitlementsDbContextModelSnapshot.cs b/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Infrastructure/Migrations/EntitlementsDbContextModelSnapshot.cs
new file mode 100644
index 0000000..2f0d389
--- /dev/null
+++ b/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Infrastructure/Migrations/EntitlementsDbContextModelSnapshot.cs
@@ -0,0 +1,75 @@
+//
+using System;
+using LearnStack.Hub.Modules.Entitlements.Infrastructure.Persistence;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace LearnStack.Hub.Modules.Entitlements.Infrastructure.Migrations
+{
+ [DbContext(typeof(EntitlementsDbContext))]
+ partial class EntitlementsDbContextModelSnapshot : ModelSnapshot
+ {
+ protected override void BuildModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasDefaultSchema("hub")
+ .HasAnnotation("ProductVersion", "10.0.0")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("LearnStack.Hub.Modules.Entitlements.Domain.Entitlement", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id");
+
+ b.Property("ExpiresAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("expires_at");
+
+ b.Property("Generation")
+ .HasColumnType("bigint")
+ .HasColumnName("generation");
+
+ b.Property("GraceUntil")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("grace_until");
+
+ b.Property("Tier")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("tier");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at");
+
+ b.Property("_complianceCaps")
+ .IsRequired()
+ .HasColumnType("jsonb")
+ .HasColumnName("compliance_caps");
+
+ b.Property("_features")
+ .IsRequired()
+ .HasColumnType("jsonb")
+ .HasColumnName("features");
+
+ b.Property("_limits")
+ .IsRequired()
+ .HasColumnType("jsonb")
+ .HasColumnName("limits");
+
+ b.HasKey("Id");
+
+ b.ToTable("entitlements", "hub");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Infrastructure/Persistence/EntitlementConfiguration.cs b/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Infrastructure/Persistence/EntitlementConfiguration.cs
new file mode 100644
index 0000000..c21ac1e
--- /dev/null
+++ b/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Infrastructure/Persistence/EntitlementConfiguration.cs
@@ -0,0 +1,46 @@
+using LearnStack.Hub.Modules.Entitlements.Domain;
+using LearnStack.Hub.SharedKernel.Compliance;
+using LearnStack.Hub.SharedKernel.Identifiers;
+using LearnStack.Hub.SharedKernel.Persistence;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace LearnStack.Hub.Modules.Entitlements.Infrastructure.Persistence;
+
+internal sealed class EntitlementConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.ToTable("entitlements");
+
+ // PK is the tenant id (no surrogate); Entity.
+ builder.HasKey(e => e.Id);
+ builder.Property(e => e.Id)
+ .HasColumnName("tenant_id")
+ .HasConversion();
+
+ builder.Property(e => e.Tier).HasColumnName("tier").IsRequired();
+
+ builder.Property>("_features")
+ .HasColumnName("features")
+ .HasColumnType("jsonb")
+ .HasConversion(JsonbConversions.DictionaryConverter(), JsonbConversions.DictionaryComparer());
+
+ builder.Property>("_limits")
+ .HasColumnName("limits")
+ .HasColumnType("jsonb")
+ .HasConversion(JsonbConversions.DictionaryConverter(), JsonbConversions.DictionaryComparer());
+
+ builder.Property>("_complianceCaps")
+ .HasColumnName("compliance_caps")
+ .HasColumnType("jsonb")
+ .HasConversion(
+ JsonbConversions.DictionaryConverter(),
+ JsonbConversions.DictionaryComparer());
+
+ builder.Property(e => e.ExpiresAt).HasColumnName("expires_at");
+ builder.Property(e => e.GraceUntil).HasColumnName("grace_until");
+ builder.Property(e => e.Generation).HasColumnName("generation");
+ builder.Property(e => e.UpdatedAt).HasColumnName("updated_at");
+ }
+}
diff --git a/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Infrastructure/Persistence/EntitlementRepository.cs b/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Infrastructure/Persistence/EntitlementRepository.cs
new file mode 100644
index 0000000..bca087a
--- /dev/null
+++ b/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Infrastructure/Persistence/EntitlementRepository.cs
@@ -0,0 +1,18 @@
+using LearnStack.Hub.Modules.Entitlements.Application.Abstractions;
+using LearnStack.Hub.Modules.Entitlements.Domain;
+using LearnStack.Hub.SharedKernel.Identifiers;
+using Microsoft.EntityFrameworkCore;
+
+namespace LearnStack.Hub.Modules.Entitlements.Infrastructure.Persistence;
+
+internal sealed class EntitlementRepository(EntitlementsDbContext db) : IEntitlementRepository
+{
+ public async Task GetByTenantAsync(LearnStackTenantId tenantId, CancellationToken cancellationToken) =>
+ await db.Entitlements.FirstOrDefaultAsync(e => e.Id == tenantId, cancellationToken).ConfigureAwait(false);
+
+ public async Task AddAsync(Entitlement entitlement, CancellationToken cancellationToken) =>
+ await db.Entitlements.AddAsync(entitlement, cancellationToken).ConfigureAwait(false);
+
+ public async Task SaveChangesAsync(CancellationToken cancellationToken) =>
+ await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+}
diff --git a/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Infrastructure/Persistence/EntitlementsDbContext.cs b/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Infrastructure/Persistence/EntitlementsDbContext.cs
new file mode 100644
index 0000000..c8fe42c
--- /dev/null
+++ b/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Infrastructure/Persistence/EntitlementsDbContext.cs
@@ -0,0 +1,29 @@
+using LearnStack.Hub.Modules.Entitlements.Domain;
+using LearnStack.Hub.SharedKernel.Persistence;
+using Microsoft.EntityFrameworkCore;
+
+namespace LearnStack.Hub.Modules.Entitlements.Infrastructure.Persistence;
+
+///
+/// EF Core context for the Entitlements module. hub default schema,
+/// jsonb columns for features / limits / compliance_caps, PK = tenant id, no
+/// RLS. Enlists with the shared-connection unit of work.
+///
+public sealed class EntitlementsDbContext : DbContext
+{
+ public EntitlementsDbContext(DbContextOptions options, IUnitOfWork? unitOfWork = null)
+ : base(options)
+ {
+ unitOfWork?.Enlist(this);
+ }
+
+ public DbSet Entitlements => Set();
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ ArgumentNullException.ThrowIfNull(modelBuilder);
+
+ modelBuilder.HasDefaultSchema("hub");
+ modelBuilder.ApplyConfiguration(new EntitlementConfiguration());
+ }
+}
diff --git a/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Infrastructure/Persistence/EntitlementsDesignTimeDbContextFactory.cs b/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Infrastructure/Persistence/EntitlementsDesignTimeDbContextFactory.cs
new file mode 100644
index 0000000..fb23475
--- /dev/null
+++ b/backend/src/Modules/Entitlements/LearnStack.Hub.Modules.Entitlements.Infrastructure/Persistence/EntitlementsDesignTimeDbContextFactory.cs
@@ -0,0 +1,21 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Design;
+
+namespace LearnStack.Hub.Modules.Entitlements.Infrastructure.Persistence;
+
+/// Design-time factory for dotnet ef migrations (env-overridable, passwordless default).
+public sealed class EntitlementsDesignTimeDbContextFactory : IDesignTimeDbContextFactory
+{
+ public EntitlementsDbContext CreateDbContext(string[] args)
+ {
+ var connectionString = Environment.GetEnvironmentVariable("ConnectionStrings__HubDatabase")
+ ?? "Host=localhost;Port=5432;Database=learnstack_hub;Username=learnstack";
+
+ var options = new DbContextOptionsBuilder()
+ .UseNpgsql(connectionString, npg =>
+ npg.MigrationsHistoryTable(EntitlementsModuleRegistration.MigrationsHistoryTable, "hub"))
+ .Options;
+
+ return new EntitlementsDbContext(options);
+ }
+}
diff --git a/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Application.Contracts/AssemblyMarker.cs b/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Application.Contracts/AssemblyMarker.cs
new file mode 100644
index 0000000..c10cb66
--- /dev/null
+++ b/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Application.Contracts/AssemblyMarker.cs
@@ -0,0 +1,4 @@
+namespace LearnStack.Hub.Modules.Plans.Application.Contracts;
+
+/// Anchor type for assembly scanning (MediatR handler discovery + NetArchTest IL TypeRef pinning).
+public sealed class AssemblyMarker;
diff --git a/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Application.Contracts/LearnStack.Hub.Modules.Plans.Application.Contracts.csproj b/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Application.Contracts/LearnStack.Hub.Modules.Plans.Application.Contracts.csproj
new file mode 100644
index 0000000..ecbc01b
--- /dev/null
+++ b/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Application.Contracts/LearnStack.Hub.Modules.Plans.Application.Contracts.csproj
@@ -0,0 +1,15 @@
+
+
+
+ LearnStack.Hub.Modules.Plans.Application.Contracts
+
+
+
+
+
+
+
+
+
+
+
diff --git a/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Application.Contracts/PlanCommands.cs b/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Application.Contracts/PlanCommands.cs
new file mode 100644
index 0000000..1220920
--- /dev/null
+++ b/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Application.Contracts/PlanCommands.cs
@@ -0,0 +1,27 @@
+using LearnStack.Hub.SharedKernel.Results;
+using MediatR;
+
+namespace LearnStack.Hub.Modules.Plans.Application.Contracts;
+
+/// Creates a plan. Tier / BillingCycle are wire strings the validator + handler parse.
+public sealed record CreatePlanCommand(
+ string Name,
+ string Tier,
+ IReadOnlyDictionary Features,
+ IReadOnlyDictionary Limits,
+ decimal BasePriceUsd,
+ string BillingCycle,
+ string Currency) : IRequest>;
+
+/// Updates a plan's mutable definition; triggers the entitlement recompute fan-out.
+public sealed record UpdatePlanCommand(
+ Guid PlanId,
+ string Name,
+ IReadOnlyDictionary Features,
+ IReadOnlyDictionary Limits,
+ decimal BasePriceUsd,
+ string BillingCycle,
+ string Currency) : IRequest>;
+
+/// Deactivates a plan so it can no longer be newly subscribed.
+public sealed record DeactivatePlanCommand(Guid PlanId) : IRequest>;
diff --git a/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Application.Contracts/PlanDtos.cs b/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Application.Contracts/PlanDtos.cs
new file mode 100644
index 0000000..5a5f8c8
--- /dev/null
+++ b/backend/src/Modules/Plans/LearnStack.Hub.Modules.Plans.Application.Contracts/PlanDtos.cs
@@ -0,0 +1,22 @@
+namespace LearnStack.Hub.Modules.Plans.Application.Contracts;
+
+/// Full plan projection returned by