Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,18 @@ locale/branding writers and whole-value setting audit redaction; both review rou
passed.
Step 3 adds Education writers; both review rounds and a fresh focused fix review
passed. Step 4 completes convergent seed execution after both review rounds.
P02d-2 implementation and final verification are complete; PR review/merge remains
pending. P02d-3 read internals are next; public reads belong to P02d-4.
**P02d-2 is complete and merged** through
[PR #23](https://github.com/HodeTech/LearnStack/pull/23) on 2026-10-02; its
[merge closeout](docs/roadmap/phase-02d-walking-skeleton.md#p02d-2-merge-and-closeout-2026-10-02)
records the accepted head and merge verification. P02d-3 read internals are implemented;
its [decision package](docs/roadmap/phase-02d-walking-skeleton.md#p02d-3-decision-package-2026-10-02)
is Accepted — 2026-10-02. Step 1 implements typed settings and locale resolution;
both review rounds passed. Step 2 implements batched definition reads; both review
rounds passed. Step 3
adds generation caching and scope-safe bypass; both review rounds passed.
P02d-3 is complete and ready for PR review; it remains unmerged.
Public reads
stay with P02d-4.

**Phase 01** shipped the .NET 10 solution scaffold under `backend/`
(core + 7 modules × 4 projects + 4 test projects including the
Expand Down
15 changes: 11 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,15 +73,22 @@ definition/locale readers, text-card metadata validation and seed verification q
Both Step 1 review rounds passed. Step 2 adds locale/branding writers and JSON audit
redaction; both review rounds passed. Step 3 adds Education writers; both review
rounds and the focused fix review passed. Step 4 completes convergent seed
execution after both review rounds. P02d-2 is verified and ready for PR review;
merge remains pending. P02d-3 read internals are next.
execution after both review rounds. **P02d-2 is complete and merged** through
[PR #23](https://github.com/HodeTech/LearnStack/pull/23) on 2026-10-02; the
[merge closeout](docs/roadmap/phase-02d-walking-skeleton.md#p02d-2-merge-and-closeout-2026-10-02)
records verification. P02d-3 read internals are implemented; the
[decision package](docs/roadmap/phase-02d-walking-skeleton.md#p02d-3-decision-package-2026-10-02)
is Accepted — 2026-10-02, with Step 1 implemented and both review rounds passed.
Step 2 implements batched definition reads; both review rounds passed. Step 3 adds
generation caching and scope-safe bypass; both review rounds passed.
P02d-3 is complete and ready for PR review; it remains unmerged.
**P02d-4** owns anonymous public API reads. Browser rendering follows
in P02d-5–7; none of these later packets has started.

| Area | Delivered now | Next milestone |
|---|---|---|
| **Tenancy** | Tenant provisioning, organizations, locales, branding, host resolution and database isolation | User membership and permissions in [Phase 03](docs/roadmap/phase-03-identity-admin.md) |
| **Customization** | Content types, level taxonomies, exact-definition readers, text-card metadata validation and tenant-authored seeds | Remaining authoring capabilities across [Phases 04–08a](docs/roadmap/README.md) |
| **Tenancy** | Tenant provisioning, organizations, locales, typed settings/branding reads, host resolution and database isolation | User membership and permissions in [Phase 03](docs/roadmap/phase-03-identity-admin.md) |
| **Customization** | Content types, level taxonomies, exact-definition and generation-cached batched display readers, text-card metadata validation and tenant-authored seeds | Remaining authoring capabilities across [Phases 04–08a](docs/roadmap/README.md) |
| **Audit** | Classified write path and transactional durability for business changes | Operational hardening in [Phase 11](docs/roadmap/phase-11-production-hardening.md) |
| **Education** | Course and Lesson aggregates, translations, protected-content policy, scoped authoring commands, complete demo seeds and isolation tests | Public reading in [P02d-4](docs/roadmap/phase-02d-walking-skeleton.md) |
| **API foundation** | Error contracts, validation, tenancy, concurrency and observability infrastructure | Authentication and durable event processing in [Phase 02b](docs/roadmap/phase-02b-events-auth.md) |
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using LearnStack.Modules.Customization.Infrastructure;
using LearnStack.Modules.Customization.Application.Contracts.Definitions;
using LearnStack.Modules.Tenancy.Application.Contracts.Locales;
using LearnStack.Modules.Education.Application.Audit;
Expand Down Expand Up @@ -165,7 +166,9 @@ public static IServiceCollection AddLearnStackPersistence(
// which never sees SET LOCAL and reads zero rows from every tenant-owned
// table — silently.
services.AddModuleDbContext<TenancyDbContext>();
services.AddTenantSettingsReads();
services.AddModuleDbContext<CustomizationDbContext>();
services.AddCustomizationProjectionReads();
services.AddModuleDbContext<EducationDbContext>();

// Audit's context is registered for the model, not for a writer. Rows reach
Expand Down Expand Up @@ -219,7 +222,7 @@ public static IServiceCollection AddLearnStackPersistence(
// lets the seeder build the same graph.
services.AddMetrics();

// The only module-facing read. SCOPED, because it reads the scoped ITenantContext
// The feature/entitlement read. SCOPED, because it reads the tenant context
// and answers for one tenant, which is one request. It does NOT take a module
// DbContext: both halves it reads are policy-guarded tables it reaches on
// connections of its own, so resolving it does not require an open unit-of-work
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,10 @@ namespace LearnStack.Infrastructure.Caching;
/// </summary>
/// <remarks>
/// <para>
/// A second process has its own map, so cross-instance freshness requires the
/// Valkey-backed adapter gated by ADR-0035. Correctness remains in the source of
/// truth: this cache may evict at any time and a miss is never an error.
/// A second process has its own map. A family following a freshly read durable
/// generation, such as Customization, retains cross-instance freshness with L1
/// alone. Other families' shared invalidation needs the Valkey-backed adapter on
/// ADR-0035's trigger. This cache may evict at any time; a miss is never an error.
/// </para>
/// <para>
/// Concurrent misses for one key and requested type share one factory flight.
Expand Down Expand Up @@ -634,6 +635,8 @@ private static string CacheName(string key)
("tenancy", "feature-flags") => "tenancy:feature-flags",
("tenancy", "settings") => "tenancy:settings",
("audit", "config") => "audit:config",
("customization", "content-types") => "customization:content-types",
("customization", "taxonomies") => "customization:taxonomies",
_ => "other",
};
}
Expand Down
18 changes: 12 additions & 6 deletions backend/src/LearnStack.SharedKernel/Localization/LocalizedText.cs
Original file line number Diff line number Diff line change
Expand Up @@ -245,20 +245,25 @@ public static LocalizedText FromJson(string json, string parameterName = "json")
/// <remarks>
/// The chain is
/// <see href="../../../../docs/architecture/12-localization.md">§ Fallback Rules</see>:
/// the requested tag, its language subtag, the tenant's default, the platform
/// the requested tag, progressive narrowing, the tenant's exact default, the platform
/// default. <paramref name="fallbackChain"/> carries the third and fourth,
/// because this type knows neither — the tenant's default lives in
/// <c>tenant_locales</c> and is resolved once per request, not once per label.
/// </remarks>
public string Resolve(string requestedLocale, IReadOnlyList<string>? fallbackChain = null)
public string Resolve(string requestedLocale, IReadOnlyList<string>? fallbackChain = null) =>
ResolveWithLocale(requestedLocale, fallbackChain).Value;

/// <summary>The same fallback lookup, retaining the actual canonical locale.</summary>
public ResolvedLocalizedText ResolveWithLocale(
string requestedLocale, IReadOnlyList<string>? fallbackChain = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(requestedLocale);

var requested = LocaleTag.Canonicalize(requestedLocale);

if (_values.TryGetValue(requested, out var direct))
{
return direct;
return new ResolvedLocalizedText(direct, requested);
}

// Narrow one subtag at a time, never widen: `zh-Hant-TW` asks `zh-Hant`
Expand All @@ -272,7 +277,7 @@ public string Resolve(string requestedLocale, IReadOnlyList<string>? fallbackCha
{
if (_values.TryGetValue(requested[..cut], out var narrower))
{
return narrower;
return new ResolvedLocalizedText(narrower, requested[..cut]);
}
}

Expand All @@ -287,13 +292,14 @@ public string Resolve(string requestedLocale, IReadOnlyList<string>? fallbackCha

if (_values.TryGetValue(LocaleTag.Canonicalize(candidate), out var fallback))
{
return fallback;
return new ResolvedLocalizedText(fallback, LocaleTag.Canonicalize(candidate));
}
}
}

// Ordinal-first rather than empty: see the remarks on the type.
return _values.Values.First();
var first = _values.First();
return new ResolvedLocalizedText(first.Value, first.Key);
}

/// <summary>Whether <paramref name="locale"/> was authored, exactly.</summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
namespace LearnStack.SharedKernel.Localization;

/// <summary>An immutable display label and the canonical locale actually authored.</summary>
public sealed record ResolvedLocalizedText(string Value, string Locale);
Original file line number Diff line number Diff line change
Expand Up @@ -216,8 +216,9 @@ Task SetProvisioningTenantContextAsync(
/// </summary>
/// <remarks>
/// <para>
/// The read half of the flag, and it exists for one caller: the audit write path has
/// to tell a <b>refused</b> commit from a <b>faulted</b> one, and only the unit knows
/// The audit write path uses this flag to tell a <b>refused</b> commit from a
/// <b>faulted</b> one; Customization also uses it to refuse cache access/fills in
/// a poisoned scope. Only the unit knows
/// which happened. <c>CompleteAsync</c> throws the same way in both cases — but a
/// refusal issues a real <c>ROLLBACK</c> first, so the server-side outcome is known
/// with certainty, while a fault leaves it genuinely unknown.
Expand Down
5 changes: 4 additions & 1 deletion backend/src/LearnStack.Tools.Seeder/SeedComposition.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using LearnStack.Modules.Customization.Infrastructure;
using LearnStack.Modules.Customization.Application.Contracts.Definitions;
using LearnStack.Modules.Tenancy.Application.Contracts.Locales;
using LearnStack.Modules.Education.Application.Audit;
Expand Down Expand Up @@ -93,6 +94,7 @@ public static ServiceProvider Build(

services.AddScoped<IUnitOfWork, NpgsqlUnitOfWork>();
services.AddModuleDbContext<TenancyDbContext>();
services.AddTenantSettingsReads();
services.AddScoped<ITenantWriteStore, TenantWriteStore>();
services.AddScoped<ITenantSettingWriteStore, TenantSettingWriteStore>();
services.AddScoped<ITenantExistenceReader, TenantExistenceReader>();
Expand All @@ -111,6 +113,7 @@ public static ServiceProvider Build(
services.AddSingleton<IJsonSchemaValidator, JsonSchemaNetValidator>();

services.AddModuleDbContext<CustomizationDbContext>();
services.AddCustomizationProjectionReads();
services.AddModuleDbContext<EducationDbContext>();
services.AddScoped<ITenantContentTypeStore, TenantContentTypeStore>();
services.AddScoped<ITenantLevelTaxonomyStore, TenantLevelTaxonomyStore>();
Expand Down Expand Up @@ -169,7 +172,7 @@ public static ServiceProvider Build(
provider.GetRequiredService<IClock>(),
provider.GetRequiredService<IMeterFactory>()));

// The only module-facing read. SCOPED, because it reads the scoped ITenantContext
// The feature/entitlement read. SCOPED, because it reads the tenant context
// and answers for one tenant, which is one request. It does NOT take a module
// DbContext: both halves it reads are policy-guarded tables it reaches on
// connections of its own, so resolving it does not require an open unit-of-work
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
using System.Collections.Immutable;
using LearnStack.SharedKernel.Localization;
using LearnStack.SharedKernel.Results;

namespace LearnStack.Modules.Customization.Application.Contracts.Definitions;

public readonly record struct DefinitionRevision(string Key, int SchemaVersion);

/// <summary>Display context, not authority to read another tenant or content locale.</summary>
public sealed record DefinitionProjectionRequest(
ImmutableArray<DefinitionRevision> ContentTypes, ImmutableArray<DefinitionRevision> Taxonomies,
string RequestedLocale, string TenantDefaultLocale);

public sealed record TextCardDisplayField(string Name, ResolvedLocalizedText Label);

public sealed record ContentTypeDisplayDefinition(
Guid Id, DefinitionRevision Revision, DefinitionStatus Status, ResolvedLocalizedText DisplayName,
string RendererKey, ImmutableArray<TextCardDisplayField> Fields);

public sealed record TaxonomyDisplayBand(string Key, ResolvedLocalizedText DisplayName, short Sort, string? Metadata);

public sealed record TaxonomyDisplayDefinition(
Guid Id, DefinitionRevision Revision, DefinitionStatus Status, ResolvedLocalizedText DisplayName,
ImmutableArray<TaxonomyDisplayBand> Bands);

public sealed record DefinitionProjection(
long? Generation,
ImmutableDictionary<DefinitionRevision, ContentTypeDisplayDefinition> ContentTypes,
ImmutableDictionary<DefinitionRevision, TaxonomyDisplayDefinition> Taxonomies,
ImmutableHashSet<DefinitionRevision> MissingContentTypes,
ImmutableHashSet<DefinitionRevision> MissingTaxonomies);

/// <summary>
/// Internal batched exact-pin display reads on the announced ambient transaction.
/// Missing members never substitute another revision. No HTTP surface or write eligibility.
/// </summary>
public interface ICustomizationDefinitionProjectionReader
{
Task<Result<DefinitionProjection>> ReadAsync(
DefinitionProjectionRequest request, CancellationToken cancellationToken = default);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
using LearnStack.Modules.Customization.Application.Contracts.Definitions;
using LearnStack.Modules.Customization.Infrastructure.Projections;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;

namespace LearnStack.Modules.Customization.Infrastructure;

public static class CustomizationReadRegistration
{
public static IServiceCollection AddCustomizationProjectionReads(this IServiceCollection services)
{
services.TryAddScoped<CustomizationReadState>();
services.TryAddScoped<DefinitionFamilyCache>();
services.TryAddScoped<DefinitionSnapshotStore>();
services.TryAddScoped<ICustomizationDefinitionProjectionReader, CustomizationDefinitionProjectionReader>();
return services;
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using LearnStack.Modules.Customization.Application.Abstractions;
using LearnStack.SharedKernel.Identifiers;
using LearnStack.Modules.Customization.Infrastructure.Projections;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
using Npgsql;
Expand Down Expand Up @@ -37,7 +38,7 @@ namespace LearnStack.Modules.Customization.Infrastructure.Persistence;
/// to avoid, spelled differently.
/// </para>
/// </remarks>
public sealed class CustomizationGenerationStore(CustomizationDbContext db)
public sealed class CustomizationGenerationStore(CustomizationDbContext db, CustomizationReadState reads)
: ICustomizationGenerationStore
{
private const string BumpSql =
Expand All @@ -52,6 +53,7 @@ RETURNING generation
public async Task<long> BumpAsync(
TenantId tenantId, CancellationToken cancellationToken = default)
{
reads.MarkDirty();
var connection = (NpgsqlConnection)db.Database.GetDbConnection();

await using var command = new NpgsqlCommand(BumpSql, connection)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using LearnStack.Infrastructure.Persistence;
using LearnStack.Modules.Customization.Application.Abstractions;
using LearnStack.Modules.Customization.Domain;
using LearnStack.Modules.Customization.Infrastructure.Projections;
using Microsoft.EntityFrameworkCore;
using static LearnStack.Infrastructure.Persistence.WriteStoreTracking;

Expand Down Expand Up @@ -38,18 +39,20 @@ namespace LearnStack.Modules.Customization.Infrastructure.Persistence;
/// depend on.
/// </para>
/// </remarks>
public sealed class TenantContentTypeStore(CustomizationDbContext db) : ITenantContentTypeStore
public sealed class TenantContentTypeStore(CustomizationDbContext db, CustomizationReadState reads) : ITenantContentTypeStore
{
public Task AddAsync(
TenantContentType aggregate, CancellationToken cancellationToken = default)
{
reads.MarkDirty();
db.TenantContentTypes.Add(aggregate);
return SaveTranslatingConflictsAsync(db, cancellationToken);
}

public Task UpdateAsync(
TenantContentType aggregate, CancellationToken cancellationToken = default)
{
reads.MarkDirty();
EnsureTracked(db, aggregate);
return SaveTranslatingConflictsAsync(db, cancellationToken);
}
Expand All @@ -76,18 +79,20 @@ public Task UpdateAsync(
/// and a publish asks whether it has any — a question a lazy-loading-free context
/// answers with zero for every taxonomy unless the collection is loaded.
/// </remarks>
public sealed class TenantLevelTaxonomyStore(CustomizationDbContext db) : ITenantLevelTaxonomyStore
public sealed class TenantLevelTaxonomyStore(CustomizationDbContext db, CustomizationReadState reads) : ITenantLevelTaxonomyStore
{
public Task AddAsync(
TenantLevelTaxonomy aggregate, CancellationToken cancellationToken = default)
{
reads.MarkDirty();
db.TenantLevelTaxonomies.Add(aggregate);
return SaveTranslatingConflictsAsync(db, cancellationToken);
}

public Task UpdateAsync(
TenantLevelTaxonomy aggregate, CancellationToken cancellationToken = default)
{
reads.MarkDirty();
EnsureTracked(db, aggregate);
return SaveTranslatingConflictsAsync(db, cancellationToken);
}
Expand Down
Loading
Loading