diff --git a/CLAUDE.md b/CLAUDE.md index e884be60..dd503be2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/README.md b/README.md index 473c1587..d87f4640 100644 --- a/README.md +++ b/README.md @@ -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) | diff --git a/backend/src/LearnStack.Api/Composition/PersistenceCompositionExtensions.cs b/backend/src/LearnStack.Api/Composition/PersistenceCompositionExtensions.cs index 4d93e51d..5eecc33b 100644 --- a/backend/src/LearnStack.Api/Composition/PersistenceCompositionExtensions.cs +++ b/backend/src/LearnStack.Api/Composition/PersistenceCompositionExtensions.cs @@ -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; @@ -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(); + services.AddTenantSettingsReads(); services.AddModuleDbContext(); + services.AddCustomizationProjectionReads(); services.AddModuleDbContext(); // Audit's context is registered for the model, not for a writer. Rows reach @@ -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 diff --git a/backend/src/LearnStack.Infrastructure/Caching/InMemoryCacheService.cs b/backend/src/LearnStack.Infrastructure/Caching/InMemoryCacheService.cs index a4ecedc8..6fe55628 100644 --- a/backend/src/LearnStack.Infrastructure/Caching/InMemoryCacheService.cs +++ b/backend/src/LearnStack.Infrastructure/Caching/InMemoryCacheService.cs @@ -11,9 +11,10 @@ namespace LearnStack.Infrastructure.Caching; /// /// /// -/// 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. /// /// /// Concurrent misses for one key and requested type share one factory flight. @@ -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", }; } diff --git a/backend/src/LearnStack.SharedKernel/Localization/LocalizedText.cs b/backend/src/LearnStack.SharedKernel/Localization/LocalizedText.cs index 67c9378a..0cc6c910 100644 --- a/backend/src/LearnStack.SharedKernel/Localization/LocalizedText.cs +++ b/backend/src/LearnStack.SharedKernel/Localization/LocalizedText.cs @@ -245,12 +245,17 @@ public static LocalizedText FromJson(string json, string parameterName = "json") /// /// The chain is /// § Fallback Rules: - /// 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. carries the third and fourth, /// because this type knows neither — the tenant's default lives in /// tenant_locales and is resolved once per request, not once per label. /// - public string Resolve(string requestedLocale, IReadOnlyList? fallbackChain = null) + public string Resolve(string requestedLocale, IReadOnlyList? fallbackChain = null) => + ResolveWithLocale(requestedLocale, fallbackChain).Value; + + /// The same fallback lookup, retaining the actual canonical locale. + public ResolvedLocalizedText ResolveWithLocale( + string requestedLocale, IReadOnlyList? fallbackChain = null) { ArgumentException.ThrowIfNullOrWhiteSpace(requestedLocale); @@ -258,7 +263,7 @@ public string Resolve(string requestedLocale, IReadOnlyList? fallbackCha 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` @@ -272,7 +277,7 @@ public string Resolve(string requestedLocale, IReadOnlyList? fallbackCha { if (_values.TryGetValue(requested[..cut], out var narrower)) { - return narrower; + return new ResolvedLocalizedText(narrower, requested[..cut]); } } @@ -287,13 +292,14 @@ public string Resolve(string requestedLocale, IReadOnlyList? 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); } /// Whether was authored, exactly. diff --git a/backend/src/LearnStack.SharedKernel/Localization/ResolvedLocalizedText.cs b/backend/src/LearnStack.SharedKernel/Localization/ResolvedLocalizedText.cs new file mode 100644 index 00000000..c2f16400 --- /dev/null +++ b/backend/src/LearnStack.SharedKernel/Localization/ResolvedLocalizedText.cs @@ -0,0 +1,4 @@ +namespace LearnStack.SharedKernel.Localization; + +/// An immutable display label and the canonical locale actually authored. +public sealed record ResolvedLocalizedText(string Value, string Locale); diff --git a/backend/src/LearnStack.SharedKernel/Persistence/IUnitOfWork.cs b/backend/src/LearnStack.SharedKernel/Persistence/IUnitOfWork.cs index 15ce3769..c594daaf 100644 --- a/backend/src/LearnStack.SharedKernel/Persistence/IUnitOfWork.cs +++ b/backend/src/LearnStack.SharedKernel/Persistence/IUnitOfWork.cs @@ -216,8 +216,9 @@ Task SetProvisioningTenantContextAsync( /// /// /// - /// The read half of the flag, and it exists for one caller: the audit write path has - /// to tell a refused commit from a faulted one, and only the unit knows + /// The audit write path uses this flag to tell a refused commit from a + /// faulted one; Customization also uses it to refuse cache access/fills in + /// a poisoned scope. Only the unit knows /// which happened. CompleteAsync throws the same way in both cases — but a /// refusal issues a real ROLLBACK first, so the server-side outcome is known /// with certainty, while a fault leaves it genuinely unknown. diff --git a/backend/src/LearnStack.Tools.Seeder/SeedComposition.cs b/backend/src/LearnStack.Tools.Seeder/SeedComposition.cs index cbf011b6..c730e1e1 100644 --- a/backend/src/LearnStack.Tools.Seeder/SeedComposition.cs +++ b/backend/src/LearnStack.Tools.Seeder/SeedComposition.cs @@ -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; @@ -93,6 +94,7 @@ public static ServiceProvider Build( services.AddScoped(); services.AddModuleDbContext(); + services.AddTenantSettingsReads(); services.AddScoped(); services.AddScoped(); services.AddScoped(); @@ -111,6 +113,7 @@ public static ServiceProvider Build( services.AddSingleton(); services.AddModuleDbContext(); + services.AddCustomizationProjectionReads(); services.AddModuleDbContext(); services.AddScoped(); services.AddScoped(); @@ -169,7 +172,7 @@ public static ServiceProvider Build( provider.GetRequiredService(), provider.GetRequiredService())); - // 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 diff --git a/backend/src/Modules/Customization/LearnStack.Modules.Customization.Application.Contracts/Definitions/ICustomizationDefinitionProjectionReader.cs b/backend/src/Modules/Customization/LearnStack.Modules.Customization.Application.Contracts/Definitions/ICustomizationDefinitionProjectionReader.cs new file mode 100644 index 00000000..55e9f643 --- /dev/null +++ b/backend/src/Modules/Customization/LearnStack.Modules.Customization.Application.Contracts/Definitions/ICustomizationDefinitionProjectionReader.cs @@ -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); + +/// Display context, not authority to read another tenant or content locale. +public sealed record DefinitionProjectionRequest( + ImmutableArray ContentTypes, ImmutableArray 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 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 Bands); + +public sealed record DefinitionProjection( + long? Generation, + ImmutableDictionary ContentTypes, + ImmutableDictionary Taxonomies, + ImmutableHashSet MissingContentTypes, + ImmutableHashSet MissingTaxonomies); + +/// +/// Internal batched exact-pin display reads on the announced ambient transaction. +/// Missing members never substitute another revision. No HTTP surface or write eligibility. +/// +public interface ICustomizationDefinitionProjectionReader +{ + Task> ReadAsync( + DefinitionProjectionRequest request, CancellationToken cancellationToken = default); +} diff --git a/backend/src/Modules/Customization/LearnStack.Modules.Customization.Infrastructure/CustomizationReadRegistration.cs b/backend/src/Modules/Customization/LearnStack.Modules.Customization.Infrastructure/CustomizationReadRegistration.cs new file mode 100644 index 00000000..6259ef6f --- /dev/null +++ b/backend/src/Modules/Customization/LearnStack.Modules.Customization.Infrastructure/CustomizationReadRegistration.cs @@ -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(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + return services; + } +} diff --git a/backend/src/Modules/Customization/LearnStack.Modules.Customization.Infrastructure/Persistence/CustomizationGenerationStore.cs b/backend/src/Modules/Customization/LearnStack.Modules.Customization.Infrastructure/Persistence/CustomizationGenerationStore.cs index 7fd96d7c..ba64b5c3 100644 --- a/backend/src/Modules/Customization/LearnStack.Modules.Customization.Infrastructure/Persistence/CustomizationGenerationStore.cs +++ b/backend/src/Modules/Customization/LearnStack.Modules.Customization.Infrastructure/Persistence/CustomizationGenerationStore.cs @@ -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; @@ -37,7 +38,7 @@ namespace LearnStack.Modules.Customization.Infrastructure.Persistence; /// to avoid, spelled differently. /// /// -public sealed class CustomizationGenerationStore(CustomizationDbContext db) +public sealed class CustomizationGenerationStore(CustomizationDbContext db, CustomizationReadState reads) : ICustomizationGenerationStore { private const string BumpSql = @@ -52,6 +53,7 @@ RETURNING generation public async Task BumpAsync( TenantId tenantId, CancellationToken cancellationToken = default) { + reads.MarkDirty(); var connection = (NpgsqlConnection)db.Database.GetDbConnection(); await using var command = new NpgsqlCommand(BumpSql, connection) diff --git a/backend/src/Modules/Customization/LearnStack.Modules.Customization.Infrastructure/Persistence/CustomizationWriteStores.cs b/backend/src/Modules/Customization/LearnStack.Modules.Customization.Infrastructure/Persistence/CustomizationWriteStores.cs index 60ba6126..80140565 100644 --- a/backend/src/Modules/Customization/LearnStack.Modules.Customization.Infrastructure/Persistence/CustomizationWriteStores.cs +++ b/backend/src/Modules/Customization/LearnStack.Modules.Customization.Infrastructure/Persistence/CustomizationWriteStores.cs @@ -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; @@ -38,11 +39,12 @@ namespace LearnStack.Modules.Customization.Infrastructure.Persistence; /// depend on. /// /// -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); } @@ -50,6 +52,7 @@ public Task AddAsync( public Task UpdateAsync( TenantContentType aggregate, CancellationToken cancellationToken = default) { + reads.MarkDirty(); EnsureTracked(db, aggregate); return SaveTranslatingConflictsAsync(db, cancellationToken); } @@ -76,11 +79,12 @@ 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. /// -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); } @@ -88,6 +92,7 @@ public Task AddAsync( public Task UpdateAsync( TenantLevelTaxonomy aggregate, CancellationToken cancellationToken = default) { + reads.MarkDirty(); EnsureTracked(db, aggregate); return SaveTranslatingConflictsAsync(db, cancellationToken); } diff --git a/backend/src/Modules/Customization/LearnStack.Modules.Customization.Infrastructure/Projections/CustomizationDefinitionProjectionReader.cs b/backend/src/Modules/Customization/LearnStack.Modules.Customization.Infrastructure/Projections/CustomizationDefinitionProjectionReader.cs new file mode 100644 index 00000000..0671eacd --- /dev/null +++ b/backend/src/Modules/Customization/LearnStack.Modules.Customization.Infrastructure/Projections/CustomizationDefinitionProjectionReader.cs @@ -0,0 +1,110 @@ +using System.Collections.Immutable; +using LearnStack.Modules.Customization.Application.Contracts.Definitions; +using LearnStack.Modules.Customization.Domain; +using LearnStack.SharedKernel.Domain; +using LearnStack.SharedKernel.Errors; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Localization; +using LearnStack.SharedKernel.Persistence; +using LearnStack.SharedKernel.Results; +using LearnStack.SharedKernel.Tenancy; + +namespace LearnStack.Modules.Customization.Infrastructure.Projections; + +public sealed class CustomizationDefinitionProjectionReader( + DefinitionSnapshotStore store, ITenantContext context, IUnitOfWork unit, DefinitionFamilyCache cache) + : ICustomizationDefinitionProjectionReader +{ + public async Task> ReadAsync( + DefinitionProjectionRequest request, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + cancellationToken.ThrowIfCancellationRequested(); + if (!context.IsResolved || context.TenantId == TenantId.PlatformSentinel + || !unit.HasActiveTransaction || !unit.IsTenantContextIssuedOn(unit.Transaction) + || !store.IsEnlistedOn(unit.Transaction)) + { + throw new TenantContextMissingException("Definition reads require a resolved, announced and enlisted ambient transaction."); + } + + if (!Valid(request)) + { + return Refused(); + } + + // Every batch probes afresh. A miss loads BOTH families and their generation + // together; earlier partial hits are discarded even if a writer intervened. + var generation = await store.ProbeAsync(context.TenantId, cancellationToken); + var snapshot = generation is > 0 + ? await cache.ReadAsync(context.TenantId, generation.Value, cancellationToken) : null; + if (snapshot is null) + { + snapshot = await store.LoadAsync(context.TenantId, cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + if ((snapshot.Generation is null && snapshot.HasDefinitionRows) || snapshot.Generation is <= 0) + { + return Refused(); + } + + await cache.WriteAsync(context.TenantId, snapshot, cancellationToken); + } + var projection = Resolve(snapshot, request); + cancellationToken.ThrowIfCancellationRequested(); + return Result.Ok(projection); + } + + private static DefinitionProjection Resolve(DefinitionSnapshot snapshot, DefinitionProjectionRequest request) + { + string[] fallback = [request.TenantDefaultLocale, "en"]; + ResolvedLocalizedText Label(LocalizedText value) => value.ResolveWithLocale(request.RequestedLocale, fallback); + var types = request.ContentTypes.Distinct().Where(snapshot.ContentTypes.Definitions.ContainsKey) + .ToImmutableDictionary(pin => pin, pin => + { + var definition = snapshot.ContentTypes.Definitions[pin]; + return new ContentTypeDisplayDefinition(definition.Id, pin, definition.Status, Label(definition.DisplayName), + definition.RendererKey, definition.Fields.Select(field => new TextCardDisplayField(field.Name, Label(field.Label))) + .ToImmutableArray()); + }); + var taxonomies = request.Taxonomies.Distinct().Where(snapshot.Taxonomies.Definitions.ContainsKey) + .ToImmutableDictionary(pin => pin, pin => + { + var definition = snapshot.Taxonomies.Definitions[pin]; + return new TaxonomyDisplayDefinition(definition.Id, pin, definition.Status, Label(definition.DisplayName), + definition.Bands.Select(band => new TaxonomyDisplayBand(band.Key, Label(band.DisplayName), band.Sort, band.Metadata)) + .ToImmutableArray()); + }); + return new DefinitionProjection(snapshot.Generation, types, taxonomies, + request.ContentTypes.Except(types.Keys).ToImmutableHashSet(), request.Taxonomies.Except(taxonomies.Keys).ToImmutableHashSet()); + } + + private static bool Valid(DefinitionProjectionRequest request) + { + if (request.ContentTypes.IsDefault || request.Taxonomies.IsDefault + || request.ContentTypes.Concat(request.Taxonomies).Any(pin => + string.IsNullOrEmpty(pin.Key) || pin.Key.Length > CustomizationKey.MaxLength + || !UrlSlug.IsUrlSafe(pin.Key) || pin.SchemaVersion <= 0) + || string.IsNullOrEmpty(request.RequestedLocale) || string.IsNullOrEmpty(request.TenantDefaultLocale) + || request.RequestedLocale.Length > LocaleTag.MaxLength || request.TenantDefaultLocale.Length > LocaleTag.MaxLength) + { + return false; + } + + try + { + LocaleTag.EnsureWellFormed(request.RequestedLocale, nameof(request.RequestedLocale)); + LocaleTag.EnsureWellFormed(request.TenantDefaultLocale, nameof(request.TenantDefaultLocale)); + return true; + } + catch (ArgumentException) + { + return false; + } + } + + private static Result Refused() => Result.Fail(new Error( + new LocalizedMessage("lockey_validation_failed"), + new Dictionary>(StringComparer.Ordinal) + { + ["Definition"] = [new LocalizedMessage("lockey_invalid_value")], + })); +} diff --git a/backend/src/Modules/Customization/LearnStack.Modules.Customization.Infrastructure/Projections/CustomizationReadState.cs b/backend/src/Modules/Customization/LearnStack.Modules.Customization.Infrastructure/Projections/CustomizationReadState.cs new file mode 100644 index 00000000..e191bce5 --- /dev/null +++ b/backend/src/Modules/Customization/LearnStack.Modules.Customization.Infrastructure/Projections/CustomizationReadState.cs @@ -0,0 +1,11 @@ +namespace LearnStack.Modules.Customization.Infrastructure.Projections; + +/// +/// Sticky for the DI scope, not a transaction object: a rolled-back generation can +/// be reissued and Npgsql can reuse transaction instances. No reset or tenant setter. +/// +public sealed class CustomizationReadState +{ + public bool IsDirty { get; private set; } + public void MarkDirty() => IsDirty = true; +} diff --git a/backend/src/Modules/Customization/LearnStack.Modules.Customization.Infrastructure/Projections/DefinitionFamilyCache.cs b/backend/src/Modules/Customization/LearnStack.Modules.Customization.Infrastructure/Projections/DefinitionFamilyCache.cs new file mode 100644 index 00000000..4db768d2 --- /dev/null +++ b/backend/src/Modules/Customization/LearnStack.Modules.Customization.Infrastructure/Projections/DefinitionFamilyCache.cs @@ -0,0 +1,72 @@ +using System.Globalization; +using LearnStack.SharedKernel.Caching; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Persistence; +using Microsoft.Extensions.Logging; + +namespace LearnStack.Modules.Customization.Infrastructure.Projections; + +/// Awaited get/set only; an ambient loader never enters a shared factory flight. +public sealed class DefinitionFamilyCache( + ICacheService cache, CustomizationReadState state, IUnitOfWork unit, ILogger logger) +{ + private static readonly CacheOptions Options = new(TimeSpan.FromSeconds(60), TimeSpan.FromMinutes(15)); + private bool CanUse => !state.IsDirty && !unit.IsRollbackOnly; + + // The cache port has no provider exception taxonomy. Recover from cache + // faults, but never treat a fatal process failure as an ordinary miss. + + internal async Task ReadAsync(TenantId tenant, long generation, CancellationToken cancellationToken) + { + if (!CanUse) return null; + try + { + var types = await cache.GetAsync(Key(tenant, "content-types", generation), cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + if (!CanUse) return null; + var taxonomies = await cache.GetAsync(Key(tenant, "taxonomies", generation), cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + return CanUse && types is not null && taxonomies is not null + ? new DefinitionSnapshot(generation, types.Definitions.Count > 0 || taxonomies.Definitions.Count > 0, types, taxonomies) + : null; + } + catch (Exception exception) when (exception is not (OutOfMemoryException or StackOverflowException or AccessViolationException)) + { + cancellationToken.ThrowIfCancellationRequested(); + ProjectionCacheLog.ReadFailed(logger); + return null; + } + } + + internal async Task WriteAsync(TenantId tenant, DefinitionSnapshot snapshot, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!CanUse || snapshot.Generation is not { } generation) return; + try + { + await cache.SetAsync(Key(tenant, "content-types", generation), snapshot.ContentTypes, Options, cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + if (!CanUse) return; + await cache.SetAsync(Key(tenant, "taxonomies", generation), snapshot.Taxonomies, Options, cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + } + catch (Exception exception) when (exception is not (OutOfMemoryException or StackOverflowException or AccessViolationException)) + { + cancellationToken.ThrowIfCancellationRequested(); + ProjectionCacheLog.WriteFailed(logger); + } + } + + private static string Key(TenantId tenant, string family, long generation) => + CacheKey.ForTenant(tenant.Value, "customization", family, "v" + generation.ToString(CultureInfo.InvariantCulture)); +} + +internal static partial class ProjectionCacheLog +{ + // Deliberately no exception/message/key payload: faults may carry private provider data. + [LoggerMessage(EventId = 7401, Level = LogLevel.Warning, Message = "Customization cache read failed; loading the database snapshot.")] + internal static partial void ReadFailed(ILogger logger); + + [LoggerMessage(EventId = 7402, Level = LogLevel.Warning, Message = "Customization cache write failed; returning the database snapshot.")] + internal static partial void WriteFailed(ILogger logger); +} diff --git a/backend/src/Modules/Customization/LearnStack.Modules.Customization.Infrastructure/Projections/DefinitionSnapshot.cs b/backend/src/Modules/Customization/LearnStack.Modules.Customization.Infrastructure/Projections/DefinitionSnapshot.cs new file mode 100644 index 00000000..d106ef72 --- /dev/null +++ b/backend/src/Modules/Customization/LearnStack.Modules.Customization.Infrastructure/Projections/DefinitionSnapshot.cs @@ -0,0 +1,16 @@ +using System.Collections.Immutable; +using LearnStack.Modules.Customization.Application.Contracts.Definitions; +using LearnStack.SharedKernel.Localization; + +namespace LearnStack.Modules.Customization.Infrastructure.Projections; + +internal sealed record ContentTypeFamily(ImmutableDictionary Definitions); +internal sealed record TaxonomyFamily(ImmutableDictionary Definitions); +internal sealed record UntranslatedContentType( + Guid Id, DefinitionStatus Status, LocalizedText DisplayName, + string RendererKey, ImmutableArray Fields); +internal sealed record UntranslatedTaxonomy( + Guid Id, DefinitionStatus Status, LocalizedText DisplayName, + ImmutableArray Bands); +internal sealed record DefinitionSnapshot( + long? Generation, bool HasDefinitionRows, ContentTypeFamily ContentTypes, TaxonomyFamily Taxonomies); diff --git a/backend/src/Modules/Customization/LearnStack.Modules.Customization.Infrastructure/Projections/DefinitionSnapshotStore.cs b/backend/src/Modules/Customization/LearnStack.Modules.Customization.Infrastructure/Projections/DefinitionSnapshotStore.cs new file mode 100644 index 00000000..784942ef --- /dev/null +++ b/backend/src/Modules/Customization/LearnStack.Modules.Customization.Infrastructure/Projections/DefinitionSnapshotStore.cs @@ -0,0 +1,134 @@ +using System.Collections.Immutable; +using System.Text.Json; +using LearnStack.Modules.Customization.Application.Contracts.Definitions; +using LearnStack.Modules.Customization.Application.Customization; +using LearnStack.Modules.Customization.Infrastructure.Persistence; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Localization; +using Microsoft.EntityFrameworkCore; +using Npgsql; + +namespace LearnStack.Modules.Customization.Infrastructure.Projections; + +/// Generation and both families share one READ COMMITTED statement snapshot. +public sealed class DefinitionSnapshotStore(CustomizationDbContext db) +{ + // Source writers use JsonDocument's default 64-level limit. SnapshotSql adds + // array + row containers; taxonomy metadata also sits inside bands + band. + private const int AcceptedSourceJsonMaxDepth = 64; + private static readonly JsonDocumentOptions ContentTypeSnapshotOptions = new() { MaxDepth = AcceptedSourceJsonMaxDepth + 2 }; + private static readonly JsonDocumentOptions TaxonomySnapshotOptions = new() { MaxDepth = AcceptedSourceJsonMaxDepth + 4 }; + + // Explicit tenant predicates are required for this unmapped, read-only projection; + // execution still passes the ambient EF command guard and PostgreSQL RLS. + private const string SnapshotSql = """ + SELECT + (SELECT generation FROM customization_generations WHERE tenant_id = @tenant_id) AS "Generation", + (EXISTS(SELECT 1 FROM tenant_content_types WHERE tenant_id = @tenant_id) + OR EXISTS(SELECT 1 FROM tenant_level_taxonomies WHERE tenant_id = @tenant_id)) AS "HasDefinitionRows", + COALESCE((SELECT jsonb_agg(jsonb_build_object( + 'id', c.id, 'key', c.key, 'version', c.schema_version, 'status', c.status, + 'label', c.display_name, 'schema', c.json_schema, 'renderer', c.renderer_key) + ORDER BY c.key, c.schema_version) + FROM tenant_content_types c + WHERE c.tenant_id = @tenant_id AND c.deleted_at IS NULL AND c.status IN ('Active', 'Deprecated')), + '[]'::jsonb)::text AS "ContentTypes", + COALESCE((SELECT jsonb_agg(jsonb_build_object( + 'id', t.id, 'key', t.key, 'version', t.schema_version, 'status', t.status, 'label', t.display_name, + 'bands', COALESCE((SELECT jsonb_agg(jsonb_build_object( + 'key', i.key, 'label', i.display_name, 'sort', i.sort, 'metadata', i.metadata) ORDER BY i.sort) + FROM tenant_level_taxonomy_items i + WHERE i.tenant_id = @tenant_id AND i.tenant_id = t.tenant_id + AND i.taxonomy_key = t.key AND i.schema_version = t.schema_version), '[]'::jsonb)) + ORDER BY t.key, t.schema_version) + FROM tenant_level_taxonomies t + WHERE t.tenant_id = @tenant_id AND t.deleted_at IS NULL AND t.status IN ('Active', 'Deprecated')), + '[]'::jsonb)::text AS "Taxonomies" + """; + + internal Task ProbeAsync(TenantId tenant, CancellationToken cancellationToken) => + db.CustomizationGenerations.AsNoTracking().TagWith("P02d-3 generation probe") + .Where(row => row.TenantId == tenant).Select(row => (long?)row.Generation) + .SingleOrDefaultAsync(cancellationToken); + + internal async Task LoadAsync(TenantId tenant, CancellationToken cancellationToken) + { + var row = await db.Database.SqlQueryRaw(SnapshotSql, + new NpgsqlParameter("tenant_id", tenant.Value)) + .TagWith("P02d-3 definition snapshot").SingleAsync(cancellationToken); + return new DefinitionSnapshot(row.Generation, row.HasDefinitionRows, + ReadContentTypes(row.ContentTypes), ReadTaxonomies(row.Taxonomies)); + } + + internal bool IsEnlistedOn(System.Data.Common.DbTransaction? transaction) => + db.Database.CurrentTransaction is { } current + && ReferenceEquals(Microsoft.EntityFrameworkCore.Storage.DbContextTransactionExtensions.GetDbTransaction(current), transaction); + + private static ContentTypeFamily ReadContentTypes(string json) + { + using var document = JsonDocument.Parse(json, ContentTypeSnapshotOptions); + var definitions = ImmutableDictionary.CreateBuilder(); + foreach (var row in document.RootElement.EnumerateArray()) + { + try + { + var renderer = row.GetProperty("renderer").GetString()!; + var presentation = TextCardPresentation.Resolve(row.GetProperty("schema").GetRawText(), renderer); + if (presentation.IsFailure) + { + continue; + } + + var revision = Revision(row); + definitions.Add(revision, new UntranslatedContentType(row.GetProperty("id").GetGuid(), + Status(row), Label(row), renderer, presentation.Value)); + } + catch (ArgumentException) + { + // An invalid stored label makes this pin missing, not its neighbors. + } + } + + return new ContentTypeFamily(definitions.ToImmutable()); + } + + private static TaxonomyFamily ReadTaxonomies(string json) + { + using var document = JsonDocument.Parse(json, TaxonomySnapshotOptions); + var definitions = ImmutableDictionary.CreateBuilder(); + foreach (var row in document.RootElement.EnumerateArray()) + { + try + { + var revision = Revision(row); + var bands = row.GetProperty("bands").EnumerateArray().Select(band => new TaxonomyBandDto( + band.GetProperty("key").GetString()!, Label(band), band.GetProperty("sort").GetInt16(), + band.GetProperty("metadata").ValueKind == JsonValueKind.Null + ? null : band.GetProperty("metadata").GetRawText())).ToImmutableArray(); + definitions.Add(revision, new UntranslatedTaxonomy(row.GetProperty("id").GetGuid(), + Status(row), Label(row), bands)); + } + catch (ArgumentException) + { + // Omit this taxonomy revision if its own or a band's label is + // malformed; unrelated revisions remain available. + } + } + + return new TaxonomyFamily(definitions.ToImmutable()); + } + + private static DefinitionRevision Revision(JsonElement row) => + new(row.GetProperty("key").GetString()!, row.GetProperty("version").GetInt32()); + private static LocalizedText Label(JsonElement row) => LocalizedText.FromJson(row.GetProperty("label").GetRawText()); + private static DefinitionStatus Status(JsonElement row) => row.GetProperty("status").GetString() == "Active" + ? DefinitionStatus.Active : DefinitionStatus.Deprecated; + + private sealed class SnapshotRow + { + public long? Generation { get; set; } + public bool HasDefinitionRows { get; set; } + public string ContentTypes { get; set; } = ""; + public string Taxonomies { get; set; } = ""; + } +} diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application.Contracts/Settings/ITenantSettingsAccessor.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application.Contracts/Settings/ITenantSettingsAccessor.cs new file mode 100644 index 00000000..fd88df84 --- /dev/null +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application.Contracts/Settings/ITenantSettingsAccessor.cs @@ -0,0 +1,27 @@ +using LearnStack.SharedKernel.Results; + +namespace LearnStack.Modules.Tenancy.Application.Contracts.Settings; + +/// Typed registered settings; uncached and scoped to the trusted ambient context. +public interface ITenantSettingsAccessor +{ + Task>> ReadAsync( + TenantSettingKey key, CancellationToken cancellationToken = default) where T : class; +} + +/// A typed lookup token, admitted only by the server's registration. +public sealed record TenantSettingKey(string Value) where T : class; + +/// An absent setting is a successful empty value, never raw configuration. +public sealed record TenantSettingRead(T? Value) where T : class +{ + public bool IsPresent => Value is not null; +} + +public sealed record BrandingTheme(string Primary, string Background, string Foreground, string Muted); + +public static class TenantSettingKeys +{ + public const string BrandingThemeName = "branding.theme"; + public static TenantSettingKey BrandingTheme { get; } = new(BrandingThemeName); +} diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Branding/BrandingThemeRegistry.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Branding/BrandingThemeRegistry.cs index 61ebd5f9..db2bdb2d 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Branding/BrandingThemeRegistry.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Branding/BrandingThemeRegistry.cs @@ -3,6 +3,7 @@ using System.Globalization; using System.Text.Json; using LearnStack.Modules.Tenancy.Application.Tenant; +using LearnStack.Modules.Tenancy.Application.Contracts.Settings; using LearnStack.SharedKernel.Domain; using LearnStack.SharedKernel.Results; @@ -14,7 +15,7 @@ public sealed record BrandingColorDescriptor(string JsonName, string CssVariable public static class BrandingThemeRegistry { private static readonly SearchValues HexDigits = SearchValues.Create("0123456789abcdefABCDEF"); - public const string SettingKey = "branding.theme"; + public const string SettingKey = TenantSettingKeys.BrandingThemeName; public static ImmutableArray Colors { get; } = [ new("primary", "--ls-primary", 3), @@ -77,6 +78,22 @@ public static Result ValidateAndCanonicalize(string theme) } } + /// Uses the authoring grammar and contrast policy for a complete typed read. + public static Result Read(string theme) + { + var validated = ValidateAndCanonicalize(theme); + if (validated.IsFailure) + { + return Result.Fail(validated.Error); + } + + using var document = JsonDocument.Parse(validated.Value); + var root = document.RootElement; + return Result.Ok(new BrandingTheme(root.GetProperty("primary").GetString()!, + root.GetProperty("background").GetString()!, root.GetProperty("foreground").GetString()!, + root.GetProperty("muted").GetString()!)); + } + private static Result Invalid(string reason) => TenantWriteFailures.Field("lockey_validation_failed", "Theme", reason); diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Settings/TenantSettingRegistry.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Settings/TenantSettingRegistry.cs new file mode 100644 index 00000000..4a18b186 --- /dev/null +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Settings/TenantSettingRegistry.cs @@ -0,0 +1,46 @@ +using System.Collections.Frozen; +using LearnStack.Modules.Tenancy.Application.Branding; +using LearnStack.Modules.Tenancy.Application.Contracts.Settings; +using LearnStack.SharedKernel.Results; + +namespace LearnStack.Modules.Tenancy.Application.Settings; + +public interface ITenantSettingRegistration +{ + string Key { get; } + Type ValueType { get; } + bool AllowsOrganizationScope { get; } +} + +/// Server-owned scope and grammar; a lookup caller cannot supply these. +public sealed record TenantSettingRegistration( + TenantSettingKey Token, bool AllowsOrganizationScope, Func> Parse) + : ITenantSettingRegistration where T : class +{ + public string Key => Token.Value; + public Type ValueType => typeof(T); +} + +public sealed class TenantSettingRegistry +{ + private readonly FrozenDictionary _registrations; + + public TenantSettingRegistry(IEnumerable registrations) + { + ArgumentNullException.ThrowIfNull(registrations); + _registrations = registrations.ToFrozenDictionary(row => row.Key, StringComparer.Ordinal); + } + + public static TenantSettingRegistry Default { get; } = new( + [ + new TenantSettingRegistration(TenantSettingKeys.BrandingTheme, false, + BrandingThemeRegistry.Read), + ]); + + public TenantSettingRegistration? Find(TenantSettingKey key) where T : class + { + ArgumentNullException.ThrowIfNull(key); + return !string.IsNullOrEmpty(key.Value) && _registrations.TryGetValue(key.Value, out var registration) + ? registration as TenantSettingRegistration : null; + } +} diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/TenantSettingsAccessor.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/TenantSettingsAccessor.cs new file mode 100644 index 00000000..1dd88c13 --- /dev/null +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/TenantSettingsAccessor.cs @@ -0,0 +1,70 @@ +using System.Text.Json; +using LearnStack.Modules.Tenancy.Application.Contracts.Settings; +using LearnStack.Modules.Tenancy.Application.Settings; +using LearnStack.SharedKernel.Errors; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Localization; +using LearnStack.SharedKernel.Persistence; +using LearnStack.SharedKernel.Results; +using LearnStack.SharedKernel.Tenancy; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage; + +namespace LearnStack.Modules.Tenancy.Infrastructure.Persistence; + +public sealed class TenantSettingsAccessor( + TenancyDbContext context, ITenantContext tenantContext, IUnitOfWork unit, TenantSettingRegistry registry) + : ITenantSettingsAccessor +{ + public async Task>> ReadAsync( + TenantSettingKey key, CancellationToken cancellationToken = default) where T : class + { + ArgumentNullException.ThrowIfNull(key); + cancellationToken.ThrowIfCancellationRequested(); + if (!tenantContext.IsResolved || tenantContext.TenantId == TenantId.PlatformSentinel + || !unit.HasActiveTransaction || !unit.IsTenantContextIssuedOn(unit.Transaction) + || context.Database.CurrentTransaction is not { } transaction + || !ReferenceEquals(transaction.GetDbTransaction(), unit.Transaction)) + { + throw new TenantContextMissingException("Settings reads require a resolved, announced and enlisted ambient tenant transaction."); + } + + var registration = registry.Find(key); + if (registration is null) + { + return Invalid(); + } + + var organization = registration.AllowsOrganizationScope ? tenantContext.OrganizationId : null; + var rows = await context.TenantSettings.AsNoTracking() + .Where(row => row.TenantId == tenantContext.TenantId && row.Key == registration.Key + && row.DeletedAt == null + && (row.OrganizationId == null || (organization != null && row.OrganizationId == organization))) + .Select(row => new { row.OrganizationId, row.Value }) + .ToListAsync(cancellationToken); + var selected = rows.SingleOrDefault(row => organization != null && row.OrganizationId == organization) + ?? rows.SingleOrDefault(row => row.OrganizationId == null); + if (selected is null) + { + return Result.Ok(new TenantSettingRead(null)); + } + + Result parsed; + try + { + parsed = registration.Parse(selected.Value); + } + catch (Exception exception) when (exception is JsonException or InvalidOperationException) + { + return Invalid(); + } + return parsed.IsSuccess ? Result.Ok(new TenantSettingRead(parsed.Value)) : Invalid(); + } + + private static Result> Invalid() where T : class => + Result>.Fail(new Error(new LocalizedMessage("lockey_validation_failed"), + new Dictionary>(StringComparer.Ordinal) + { + ["Setting"] = [new LocalizedMessage("lockey_invalid_value")], + })); +} diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/TenantSettingsRegistration.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/TenantSettingsRegistration.cs new file mode 100644 index 00000000..48a094c8 --- /dev/null +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/TenantSettingsRegistration.cs @@ -0,0 +1,20 @@ +using LearnStack.Modules.Tenancy.Application.Contracts.Settings; +using LearnStack.Modules.Tenancy.Application.Settings; +using LearnStack.Modules.Tenancy.Infrastructure.Persistence; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace LearnStack.Modules.Tenancy.Infrastructure; + +public static class TenantSettingsRegistration +{ + public static IServiceCollection AddTenantSettingsReads(this IServiceCollection services) + { + // Registrations are values in an explicit server registry, not individual + // DI services. Register a complete TenantSettingRegistry before this call + // to replace the default; the constructor also supports isolated tests. + services.TryAddSingleton(TenantSettingRegistry.Default); + services.TryAddScoped(); + return services; + } +} diff --git a/backend/tests/LearnStack.Tests.Architecture/CustomizationProjectionTests.cs b/backend/tests/LearnStack.Tests.Architecture/CustomizationProjectionTests.cs new file mode 100644 index 00000000..fa44f83b --- /dev/null +++ b/backend/tests/LearnStack.Tests.Architecture/CustomizationProjectionTests.cs @@ -0,0 +1,215 @@ +using FluentAssertions; +using LearnStack.Infrastructure.Validation; +using LearnStack.Modules.Customization.Application.Contracts.Definitions; +using LearnStack.Modules.Customization.Infrastructure.Projections; +using LearnStack.SharedKernel.Validation; +using Mono.Cecil; +using Xunit; + +namespace LearnStack.Tests.Architecture; + +public sealed class CustomizationProjectionTests +{ + private static readonly HashSet ValidatorTypes = typeof(JsonSchemaNetValidator).Assembly.GetTypes() + .Where(type => typeof(IJsonSchemaValidator).IsAssignableFrom(type)).Select(type => type.FullName!) + .Append(typeof(IJsonSchemaValidator).FullName!).ToHashSet(StringComparer.Ordinal); + + /// + /// ADR-0043 + /// and Standards 20. + /// + [Fact] + public void Customization_Projection_Does_Not_Validate_On_Read() + { + var modules = ReadProductionModules(); + try + { + modules.Where(module => module.Name.StartsWith("LearnStack.Modules.Customization.", StringComparison.Ordinal)) + .Select(module => Path.GetFileNameWithoutExtension(module.Name)).Should().BeEquivalentTo( + ["LearnStack.Modules.Customization.Domain", "LearnStack.Modules.Customization.Application.Contracts", + "LearnStack.Modules.Customization.Application", "LearnStack.Modules.Customization.Infrastructure"], + "Fix: scan every Customization layer, including Domain and Contracts helpers"); + var types = TypeMap(modules); + var roots = Roots(types); + roots.Should().NotBeEmpty("the registered display reader must be classified by its contract"); + Offenders(roots, types).Should().BeEmpty( + "Fix: admit schemas on the write path; neither the projection nor its production helpers may reach schema validation"); + } + finally + { + foreach (var module in modules) module.Dispose(); + } + } + + [Fact] + public void Projection_validator_guard_detects_direct_and_helper_dependencies() + { + using var module = ModuleDefinition.ReadModule(typeof(CustomizationProjectionTests).Assembly.Location); + var types = TypeMap([module]); + var direct = module.GetType(typeof(ValidatorProbe).FullName!.Replace('+', '/')); + var indirect = module.GetType(typeof(HelperProbe).FullName!.Replace('+', '/')); + var concrete = module.GetType(typeof(ConcreteProbe).FullName!.Replace('+', '/')); + var clean = module.GetType(typeof(CleanProbe).FullName!.Replace('+', '/')); + Offenders([direct], types).Should().ContainSingle().Which.Should().Be(direct.FullName); + Offenders([indirect], types).Should().ContainSingle().Which.Should().Be(direct.FullName); + Offenders([concrete], types).Should().ContainSingle().Which.Should().Be(concrete.FullName); + Offenders([clean], types).Should().BeEmpty(); + + foreach (var probe in new[] { typeof(ArrayProbe), typeof(ConstraintProbe<>), typeof(ParameterAttributeProbe), + typeof(ReturnAttributeProbe), typeof(GenericAttributeProbe<>), typeof(EventAttributeProbe), + typeof(LambdaProbe), typeof(AsyncProbe), typeof(CatchProbe) }) + { + var planted = module.GetType(probe.FullName!.Replace('+', '/')); + Offenders([planted], types).Should().Contain(planted.FullName, + $"Fix: the validator guard must see the dependency carried by {probe.Name}"); + Il.NamesNamespace(planted, probe == typeof(CatchProbe) ? "Json.Schema" : "LearnStack.SharedKernel.Validation") + .Should().BeTrue("the shared namespace guards must see the same metadata references"); + } + + var callback = new FunctionPointerType { ReturnType = module.TypeSystem.Void }; + callback.Parameters.Add(new ParameterDefinition(module.ImportReference(typeof(IJsonSchemaValidator)))); + var pointerProbe = new TypeDefinition("ReviewProbe", "Callback", TypeAttributes.Class); + pointerProbe.Fields.Add(new FieldDefinition("Callback", FieldAttributes.Public, callback)); + module.Types.Add(pointerProbe); + Offenders([pointerProbe], TypeMap([module])).Should().ContainSingle().Which.Should().Be(pointerProbe.FullName); + Il.NamesNamespace(pointerProbe, "LearnStack.SharedKernel.Validation").Should().BeTrue(); + } + + [Fact] + public void Projection_validator_guard_follows_domain_contracts_and_external_production_helpers() + { + var modules = ReadProductionModules(); + try + { + var infrastructure = modules.Single(module => module.Name == "LearnStack.Modules.Customization.Infrastructure.dll"); + var reader = infrastructure.GetType(typeof(CustomizationDefinitionProjectionReader).FullName!); + // Mutate only the in-memory Cecil models. The real production census and + // root predicate must reach each planted helper; no source file is changed. + foreach (var name in new[] { "LearnStack.Modules.Customization.Domain.dll", + "LearnStack.Modules.Customization.Application.Contracts.dll", "LearnStack.Infrastructure.dll" }) + { + var owner = modules.Single(module => module.Name == name); + var helper = new TypeDefinition("ReviewProbe", "Helper", TypeAttributes.Class | TypeAttributes.Public); + owner.Types.Add(helper); + helper.Fields.Add(new FieldDefinition("Validator", FieldAttributes.Public, + owner.ImportReference(typeof(IJsonSchemaValidator)))); + var dependency = new FieldDefinition("ReviewDependency", FieldAttributes.Private, infrastructure.ImportReference(helper)); + reader.Fields.Add(dependency); + var types = TypeMap(modules); + Offenders(Roots(types), types).Should().Contain(helper.FullName, + $"Fix: a helper in {name} must not hide projection validation"); + reader.Fields.Remove(dependency); + owner.Types.Remove(helper); + } + } + finally + { + foreach (var module in modules) module.Dispose(); + } + } + + private static List ReadProductionModules() + { + var modules = new List(); + try + { + foreach (var assembly in ProductionAssemblies.All()) modules.Add(ModuleDefinition.ReadModule(assembly.Location)); + return modules; + } + catch + { + foreach (var module in modules) module.Dispose(); + throw; + } + } + + private static Dictionary<(string Assembly, string Type), TypeDefinition> TypeMap(IEnumerable modules) => + modules.SelectMany(module => module.GetTypes()).ToDictionary(type => (type.Module.Assembly.Name.Name, type.FullName)); + + private static TypeDefinition[] Roots(Dictionary<(string Assembly, string Type), TypeDefinition> types) => + types.Values.Where(type => type.Interfaces.Any(contract => + contract.InterfaceType.FullName == typeof(ICustomizationDefinitionProjectionReader).FullName)).ToArray(); + + private static HashSet Offenders(IEnumerable roots, Dictionary<(string Assembly, string Type), TypeDefinition> types) + { + var visited = new HashSet<(string Assembly, string Type)>(); + var offenders = new HashSet(StringComparer.Ordinal); + var queue = new Queue(roots); + while (queue.TryDequeue(out var type)) + { + if (!visited.Add((type.Module.Assembly.FullName, type.FullName))) continue; + foreach (var reference in Il.ReferencedTypeReferences(type)) + { + var name = reference.FullName; + if (ValidatorTypes.Contains(name) || name.StartsWith("Json.Schema.", StringComparison.Ordinal)) + offenders.Add(type.FullName); + var assembly = reference.Scope is AssemblyNameReference scope ? scope.Name + : reference.Scope is ModuleDefinition module ? module.Assembly.Name.Name : reference.Scope.Name; + if (types.TryGetValue((assembly, name), out var helper)) queue.Enqueue(helper); + } + } + return offenders; + } + + private sealed class ValidatorProbe(IJsonSchemaValidator validator) + { + public IJsonSchemaValidator Value => validator; + } + private sealed class HelperProbe + { + public static ValidatorProbe? Value => null; + } + private sealed class ConcreteProbe + { + public static JsonSchemaNetValidator? Value => null; + } + private sealed class CleanProbe + { + public static string Read() => "display"; + } + private sealed class ArrayProbe + { + public Dictionary[]? Value { get; } + } + private sealed class ConstraintProbe where T : IJsonSchemaValidator; + [AttributeUsage(AttributeTargets.All)] + private sealed class TypeProbeAttribute(Type type) : Attribute + { + public Type Value => type; + } + private sealed class ParameterAttributeProbe + { + public static void Read([TypeProbe(typeof(IJsonSchemaValidator))] string value) { } + } + private sealed class ReturnAttributeProbe + { + [return: TypeProbe(typeof(IJsonSchemaValidator))] + public static string Read() => "display"; + } + private sealed class GenericAttributeProbe<[TypeProbe(typeof(IJsonSchemaValidator))] T>; + private sealed class EventAttributeProbe + { + [TypeProbe(typeof(IJsonSchemaValidator))] + public static event Action Changed { add { } remove { } } + } + private sealed class LambdaProbe + { + public static Func Read() => () => typeof(IJsonSchemaValidator); + } + private sealed class AsyncProbe + { + public static async Task Read() + { + await Task.Yield(); + return typeof(IJsonSchemaValidator); + } + } + private sealed class CatchProbe + { + public static void Read() + { + try { CleanProbe.Read(); } + catch (Json.Schema.JsonSchemaException) { } + } + } +} diff --git a/backend/tests/LearnStack.Tests.Architecture/Il.cs b/backend/tests/LearnStack.Tests.Architecture/Il.cs index 5633fb4c..30eb07a8 100644 --- a/backend/tests/LearnStack.Tests.Architecture/Il.cs +++ b/backend/tests/LearnStack.Tests.Architecture/Il.cs @@ -20,6 +20,14 @@ namespace LearnStack.Tests.Architecture; /// internal static class Il { + /// Names reachable from signatures, bodies and generated state machines. + internal static IEnumerable ReferencedTypeNames(TypeDefinition type) => + WithGenerated(type).SelectMany(ReferencedTypes).SelectMany(NamesOf).Distinct(StringComparer.Ordinal); + + /// Expanded references retain their assembly scope for transitive helper walks. + internal static IEnumerable ReferencedTypeReferences(TypeDefinition type) => + WithGenerated(type).SelectMany(ReferencedTypes).SelectMany(ReferencesOf); + /// /// Whether a type names a namespace anywhere the IL can carry it: its base type and /// interfaces, its attributes, its members' signatures, and its method bodies. @@ -110,6 +118,11 @@ private static IEnumerable MethodReferences(MethodDefinition meth { yield return method.ReturnType; + foreach (var reference in AttributeReferences(MethodAttributes(method))) + { + yield return reference; + } + foreach (var parameter in method.Parameters) { yield return parameter.ParameterType; @@ -153,6 +166,10 @@ private static IEnumerable MethodReferences(MethodDefinition meth } yield return generic.ReturnType; + foreach (var parameter in generic.Parameters) + { + yield return parameter.ParameterType; + } if (generic.DeclaringType is { } genericOwner) { @@ -172,6 +189,10 @@ private static IEnumerable MethodReferences(MethodDefinition meth yield return declaring; } + break; + case FieldReference field: + yield return field.FieldType; + yield return field.DeclaringType; break; case MemberReference member when member.DeclaringType is { } owner: yield return owner; @@ -188,21 +209,40 @@ private static IEnumerable MethodReferences(MethodDefinition meth /// The constructed type's own name is included too, so a member typed /// Guarded<T> is found by its element name. /// - private static IEnumerable NamesOf(TypeReference reference) + private static IEnumerable NamesOf(TypeReference reference) => ReferencesOf(reference).Select(type => type.FullName); + + private static IEnumerable ReferencesOf(TypeReference reference) { + // Function pointers are specifications with no element type; their + // return/parameter signatures carry the references instead. + if (reference is FunctionPointerType function) + { + foreach (var type in ReferencesOf(function.ReturnType)) yield return type; + foreach (var type in function.Parameters.SelectMany(parameter => ReferencesOf(parameter.ParameterType))) yield return type; + yield break; + } + if (reference is GenericInstanceType generic) { - yield return generic.ElementType.GetElementType().FullName; + yield return generic.ElementType; - foreach (var name in generic.GenericArguments.SelectMany(NamesOf)) + foreach (var argument in generic.GenericArguments.SelectMany(ReferencesOf)) { - yield return name; + yield return argument; } yield break; } - yield return reference.GetElementType().FullName; + // Arrays, byrefs and pointers may wrap a constructed generic. Removing + // all specifications at once loses the wrapped generic's arguments. + if (reference is TypeSpecification specification) + { + foreach (var element in ReferencesOf(specification.ElementType)) yield return element; + yield break; + } + + yield return reference; } /// Every type reference one type carries. @@ -238,6 +278,11 @@ private static IEnumerable ReferencedTypes(TypeDefinition type) yield return property.PropertyType; } + foreach (var @event in type.Events) + { + yield return @event.EventType; + } + // One walker, not two: this loop used to carry its own copy, and the copy saw less — // a called method's return and parameter types were invisible to it, so a namespace // named only there was never reported. @@ -255,11 +300,25 @@ private static IEnumerable ReferencedTypes(TypeDefinition type) /// Arrays are walked, because an attribute argument can be one. /// private static IEnumerable Attributes(TypeDefinition type) => - type.CustomAttributes + AttributeReferences(type.CustomAttributes + .Concat(type.GenericParameters.SelectMany(GenericAttributes)) + .Concat(type.Interfaces.SelectMany(contract => contract.CustomAttributes)) .Concat(type.Fields.SelectMany(field => field.CustomAttributes)) .Concat(type.Properties.SelectMany(property => property.CustomAttributes)) - .Concat(type.Methods.SelectMany(method => method.CustomAttributes)) - .SelectMany(attribute => new[] { attribute.AttributeType }.Concat(Named(attribute))); + .Concat(type.Properties.SelectMany(property => property.Parameters).SelectMany(parameter => parameter.CustomAttributes)) + .Concat(type.Events.SelectMany(@event => @event.CustomAttributes)) + .Concat(type.Methods.SelectMany(MethodAttributes))); + + private static IEnumerable GenericAttributes(GenericParameter parameter) => + parameter.CustomAttributes.Concat(parameter.Constraints.SelectMany(constraint => constraint.CustomAttributes)); + + private static IEnumerable MethodAttributes(MethodDefinition method) => + method.CustomAttributes.Concat(method.MethodReturnType.CustomAttributes) + .Concat(method.Parameters.SelectMany(parameter => parameter.CustomAttributes)) + .Concat(method.GenericParameters.SelectMany(GenericAttributes)); + + private static IEnumerable AttributeReferences(IEnumerable attributes) => + attributes.SelectMany(attribute => new[] { attribute.AttributeType }.Concat(Named(attribute))); /// The types one attribute's arguments name. private static IEnumerable Named(CustomAttribute attribute) => @@ -297,12 +356,22 @@ private static IEnumerable NamedBy(CustomAttributeArgument argume /// private static bool InNamespace(TypeReference reference, string namespacePrefix) { + if (reference is FunctionPointerType function) + { + return ReferencesOf(function).Any(type => InNamespace(type, namespacePrefix)); + } + if (reference is GenericInstanceType generic && generic.GenericArguments.Any(argument => InNamespace(argument, namespacePrefix))) { return true; } + if (reference is TypeSpecification specification) + { + return InNamespace(specification.ElementType, namespacePrefix); + } + if (reference.IsNested && reference.DeclaringType is { } declaring) { return InNamespace(declaring, namespacePrefix); diff --git a/backend/tests/LearnStack.Tests.Integration/Database/CustomizationCatalogTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/CustomizationCatalogTests.cs index 5f9679a5..afc572d3 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/CustomizationCatalogTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/CustomizationCatalogTests.cs @@ -3,6 +3,7 @@ using LearnStack.Modules.Customization.Application.Abstractions; using LearnStack.Modules.Customization.Domain; using LearnStack.Modules.Customization.Infrastructure.Persistence; +using LearnStack.Modules.Customization.Infrastructure.Projections; using LearnStack.SharedKernel.Identifiers; using LearnStack.SharedKernel.Localization; using LearnStack.SharedKernel.Persistence; @@ -221,6 +222,7 @@ private ServiceProvider BuildProvider() ?? UnresolvedTenantContext.Instance); services.AddScoped(); services.AddModuleDbContext(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/backend/tests/LearnStack.Tests.Integration/Database/CustomizationProjectionCacheTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/CustomizationProjectionCacheTests.cs new file mode 100644 index 00000000..f438e50d --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/Database/CustomizationProjectionCacheTests.cs @@ -0,0 +1,351 @@ +using System.Collections.Concurrent; +using System.Diagnostics.Metrics; +using FluentAssertions; +using LearnStack.Infrastructure.Caching; +using LearnStack.Modules.Customization.Application.Abstractions; +using LearnStack.Modules.Customization.Application.Contracts.Customization; +using LearnStack.Modules.Customization.Application.Contracts.Definitions; +using LearnStack.Modules.Customization.Domain; +using LearnStack.Modules.Customization.Infrastructure.Projections; +using LearnStack.SharedKernel.Caching; +using LearnStack.SharedKernel.Errors; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Time; +using LearnStack.Tools.Seeder; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Npgsql; +using Xunit; + +namespace LearnStack.Tests.Integration.Database; + +[Trait(RequiresDocker.Key, RequiresDocker.Value)] +public sealed partial class CustomizationProjectionTests +{ + [Fact] + public async Task Cold_warm_partial_and_locale_reads_use_two_or_one_select_and_never_a_shared_factory() + { + await using var source = NpgsqlDataSource.Create(schema.Postgres.AppConnectionString); + await using var cache = new CacheProbe(); + var context = new SeedTenantContext(TenantId.From(SchemaFixture.TenantA), null); + await using var read = await ReadSession.OpenAsync(source, context, cache: cache); + var request = Request([new("announcement", 1)], [new("proficiency", 1)]); + var first = (await read.Reader.ReadAsync(request)).Value!; + read.Observer.Selects.Should().Be(2); + cache.GetCalls.Should().Be(2); + cache.SetCalls.Should().Be(2); + cache.Options.Should().OnlyContain(options => options == new CacheOptions(TimeSpan.FromSeconds(60), TimeSpan.FromMinutes(15))); + var generation = first.Generation!.Value; + cache.Keys.Should().BeEquivalentTo([FamilyKey(context.TenantId, "content-types", generation), FamilyKey(context.TenantId, "taxonomies", generation)]); + var warm = (await read.Reader.ReadAsync(request with { RequestedLocale = "EN-us" })).Value!; + warm.ContentTypes[new("announcement", 1)].DisplayName.Locale.Should().Be("en"); + read.Observer.Selects.Should().Be(3, "warm read still probes the durable generation, but never definitions"); + cache.SetCalls.Should().Be(2, "a hit does not extend the original TTL"); + await cache.RemoveAsync(FamilyKey(context.TenantId, "taxonomies", generation)); + (await read.Reader.ReadAsync(request)).Value!.Taxonomies.Should().ContainSingle(); + read.Observer.Selects.Should().Be(5, "a partial hit loads a coherent pair instead of merging generations"); + cache.FactoryCalls.Should().Be(0); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Fatal_cache_failures_propagate_instead_of_attempting_recovery(bool duringSet) + { + await using var source = NpgsqlDataSource.Create(schema.Postgres.AppConnectionString); + // Throw a synthetic exception; this test does not exhaust process memory. + await using var cache = new CacheProbe { Fault = duringSet ? "set-oom" : "get-oom" }; + var context = new SeedTenantContext(TenantId.From(SchemaFixture.TenantA), null); + await using var read = await ReadSession.OpenAsync(source, context, cache: cache); + var invoke = () => read.Reader.ReadAsync(Request([new("announcement", 1)], [])); + await invoke.Should().ThrowAsync(); + cache.Logger.Messages.Should().BeEmpty(); + read.Observer.Selects.Should().Be(duringSet ? 2 : 1); + } + + [Theory] + [InlineData("get")] + [InlineData("set")] + [InlineData("timeout")] + public async Task Cache_faults_degrade_to_complete_database_results_with_bounded_diagnostics(string fault) + { + await using var source = NpgsqlDataSource.Create(schema.Postgres.AppConnectionString); + await using var cache = new CacheProbe { Fault = fault }; + var context = new SeedTenantContext(TenantId.From(SchemaFixture.TenantA), null); + await using var read = await ReadSession.OpenAsync(source, context, cache: cache); + var result = await read.Reader.ReadAsync(Request([new("announcement", 1)], [new("proficiency", 1)])); + result.IsSuccess.Should().BeTrue(); + result.Value!.ContentTypes.Should().ContainSingle(); + result.Value.Taxonomies.Should().ContainSingle(); + result.Value.MissingContentTypes.Should().BeEmpty(); + read.Observer.Selects.Should().Be(2); + cache.Logger.Messages.Should().ContainSingle().Which.Should().NotContain("private-cache-data"); + cache.Logger.Exceptions.Should().BeEmpty(); + cache.FactoryCalls.Should().Be(0); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Caller_cancellation_during_cache_get_or_set_propagates_without_factory_work(bool duringSet) + { + await using var source = NpgsqlDataSource.Create(schema.Postgres.AppConnectionString); + using var cancellation = new CancellationTokenSource(); + await using var cache = new CacheProbe { Cancel = cancellation, CancelDuringSet = duringSet }; + var context = new SeedTenantContext(TenantId.From(SchemaFixture.TenantA), null); + await using var read = await ReadSession.OpenAsync(source, context, cache: cache); + var invoke = () => read.Reader.ReadAsync(Request([new("announcement", 1)], []), cancellation.Token); + await invoke.Should().ThrowAsync(); + cache.Tokens.Should().OnlyContain(token => token == cancellation.Token); + cache.Logger.Messages.Should().BeEmpty(); + cache.FactoryCalls.Should().Be(0); + read.Observer.Selects.Should().Be(duringSet ? 2 : 1); + } + + [Fact] + public async Task A_database_failure_is_not_converted_to_a_cache_miss_or_empty_projection() + { + await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); + await using var source = NpgsqlDataSource.Create(database.AppConnectionString); + var context = await ProvisionAsync(source); + await CreateRevisionAsync(source, context, 1); + await using (var owner = await PostgresFixture.OpenAsync(database.MigrationConnectionString)) + await using (var revoke = new NpgsqlCommand("REVOKE SELECT ON tenant_level_taxonomies FROM learnstack_app", (NpgsqlConnection)owner)) + await revoke.ExecuteNonQueryAsync(); + await using var cache = new CacheProbe(); + await using var read = await ReadSession.OpenAsync(source, context, cache: cache); + var invoke = () => read.Reader.ReadAsync(Request([new("profile", 1)], [])); + (await invoke.Should().ThrowAsync()).Which.SqlState.Should().Be(PostgresErrorCodes.InsufficientPrivilege); + cache.SetCalls.Should().Be(0); + } + + [Fact] + public async Task Warm_cache_never_admits_closed_or_unannounced_transactions() + { + await using var source = NpgsqlDataSource.Create(schema.Postgres.AppConnectionString); + await using var cache = new CacheProbe(); + var context = new SeedTenantContext(TenantId.From(SchemaFixture.TenantA), null); + await using var read = await ReadSession.OpenAsync(source, context, cache: cache); + var request = Request([new("announcement", 1)], []); + (await read.Reader.ReadAsync(request)).IsSuccess.Should().BeTrue(); + cache.Keys.Should().NotBeEmpty(); + var gets = cache.GetCalls; + await read.Frame.FailAsync(); + var closed = () => read.Reader.ReadAsync(request); + await closed.Should().ThrowAsync(); + await using var unannounced = await ReadSession.OpenAsync(source, context, announce: false, cache: cache); + await ((Func>>)(() => unannounced.Reader.ReadAsync(request))) + .Should().ThrowAsync(); + cache.GetCalls.Should().Be(gets); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Store_mutation_bypasses_warm_cache_before_bump_and_rollback_reissued_generation_is_clean(bool taxonomy) + { + await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); + await using var source = NpgsqlDataSource.Create(database.AppConnectionString); + var context = await ProvisionAsync(source); + await CreateRevisionAsync(source, context, 1); + await CreateRevisionAsync(source, context, 2, publish: false); + await using var cache = new CacheProbe(); + var request = Request([new("profile", 1), new("profile", 2)], [new("levels", 1), new("levels", 2)]); + long speculative; + await using (var read = await ReadSession.OpenAsync(source, context, cache: cache)) + { + var before = (await read.Reader.ReadAsync(request)).Value!; + before.MissingContentTypes.Should().Contain(new DefinitionRevision("profile", 2)); + var gets = cache.GetCalls; var sets = cache.SetCalls; + if (taxonomy) + { + var store = read.Services.GetRequiredService(); + var current = (await store.FindActiveAsync("levels"))!; + var successor = (await store.FindAsync(TenantLevelTaxonomyId.From(await IdAsync(read.Unit, "tenant_level_taxonomies", "levels", 2))))!; + current.Deprecate(new SystemClock(), UserId.SystemActor); + await store.UpdateAsync(current); + successor.Publish(new SystemClock(), UserId.SystemActor); + await store.UpdateAsync(successor); + } + else + { + var store = read.Services.GetRequiredService(); + var current = (await store.FindActiveAsync("profile"))!; + var successor = (await store.FindAsync(TenantContentTypeId.From(await IdAsync(read.Unit, "tenant_content_types", "profile", 2))))!; + current.Deprecate(new SystemClock(), UserId.SystemActor); + await store.UpdateAsync(current); + successor.Publish(new SystemClock(), UserId.SystemActor); + await store.UpdateAsync(successor); + } + read.State.IsDirty.Should().BeTrue(); + var between = (await read.Reader.ReadAsync(request)).Value!; + between.Generation.Should().Be(before.Generation, "the supported store saved, but the generation has not advanced yet"); + if (taxonomy) between.Taxonomies[new("levels", 2)].Status.Should().Be(DefinitionStatus.Active); + else between.ContentTypes[new("profile", 2)].Status.Should().Be(DefinitionStatus.Active); + speculative = await read.Services.GetRequiredService().BumpAsync(context.TenantId); + (await read.Reader.ReadAsync(request)).Value!.Generation.Should().Be(speculative); + cache.GetCalls.Should().Be(gets); + cache.SetCalls.Should().Be(sets, "uncommitted definitions must never fill the reissuable generation key"); + await read.Frame.FailAsync(); + read.State.IsDirty.Should().BeTrue("the DI-scope flag is never reset after rollback"); + await ((Func)(() => read.Unit.BeginTransactionAsync())).Should().ThrowAsync(); + cache.GetCalls.Should().Be(gets); + cache.SetCalls.Should().Be(sets); + } + // A different committed Draft reuses the speculative number without granting access to v2. + if (taxonomy) + (await SendAsync(source, context, new RegisterTenantLevelTaxonomyCommand(Guid.CreateVersion7(), "levels", 3, Label, + [new("only", Label, 0)]))).IsSuccess.Should().BeTrue(); + else + (await SendAsync(source, context, new RegisterTenantContentTypeCommand(Guid.CreateVersion7(), "profile", 3, Label, Profile, "default-card"))) + .IsSuccess.Should().BeTrue(); + await using var fresh = await ReadSession.OpenAsync(source, context, cache: cache); + var committed = (await fresh.Reader.ReadAsync(request)).Value!; + committed.Generation.Should().Be(speculative); + committed.ContentTypes[new("profile", 1)].Status.Should().Be(DefinitionStatus.Active); + committed.Taxonomies[new("levels", 1)].Status.Should().Be(DefinitionStatus.Active); + committed.MissingContentTypes.Should().Contain(new DefinitionRevision("profile", 2)); + committed.MissingTaxonomies.Should().Contain(new DefinitionRevision("levels", 2)); + fresh.Observer.Selects.Should().Be(2); + fresh.State.IsDirty.Should().BeFalse(); + } + + [Fact] + public async Task Rollback_only_without_a_customization_write_also_bypasses_warm_cache() + { + await using var source = NpgsqlDataSource.Create(schema.Postgres.AppConnectionString); + await using var cache = new CacheProbe(); + var context = new SeedTenantContext(TenantId.From(SchemaFixture.TenantA), null); + await using var read = await ReadSession.OpenAsync(source, context, cache: cache); + var request = Request([new("announcement", 1)], []); + (await read.Reader.ReadAsync(request)).IsSuccess.Should().BeTrue(); + var gets = cache.GetCalls; var sets = cache.SetCalls; + read.State.IsDirty.Should().BeFalse(); + read.Unit.MarkRollbackOnly(); + (await read.Reader.ReadAsync(request)).IsSuccess.Should().BeTrue(); + read.Observer.Selects.Should().Be(4); + cache.GetCalls.Should().Be(gets); + cache.SetCalls.Should().Be(sets); + } + + [Fact] + public async Task Shared_warm_cache_alternates_tenants_without_crossing_definition_or_band_values() + { + await using var source = NpgsqlDataSource.Create(schema.Postgres.AppConnectionString); + await using var cache = new CacheProbe(); + for (var index = 0; index < 4; index++) + { + var foreign = index % 2 == 1; + var context = new SeedTenantContext(TenantId.From(foreign ? SchemaFixture.TenantB : SchemaFixture.TenantA), null); + await using var read = await ReadSession.OpenAsync(source, context, cache: cache); + await AssertAppRoleAsync(read.Unit); + var result = (await read.Reader.ReadAsync(Request([new("announcement", 1)], [new("proficiency", 1)]))).Value!; + result.Taxonomies[new("proficiency", 1)].Bands.Should().ContainSingle().Which.Key.Should().Be(foreign ? "starter" : "beginner"); + read.Observer.Selects.Should().Be(index < 2 ? 2 : 1); + } + cache.Keys.Should().HaveCount(4); + cache.FactoryCalls.Should().Be(0); + } + + [Fact] + public async Task Independent_process_L1_maps_follow_the_same_durable_generation_without_events() + { + await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); + await using var source = NpgsqlDataSource.Create(database.AppConnectionString); + var context = await ProvisionAsync(source); + await CreateRevisionAsync(source, context, 1); + await using var firstCache = new CacheProbe(); + await using var secondCache = new CacheProbe(); + var request = Request([new("profile", 1), new("profile", 2)], [new("levels", 1), new("levels", 2)]); + foreach (var cache in new[] { firstCache, secondCache }) + { + await using var read = await ReadSession.OpenAsync(source, context, cache: cache); + (await read.Reader.ReadAsync(request)).Value!.MissingContentTypes.Should().Contain(new DefinitionRevision("profile", 2)); + var localized = (await read.Reader.ReadAsync(request with { RequestedLocale = "EN-us" })).Value!; + localized.ContentTypes[new("profile", 1)].DisplayName.Should().Be(new LearnStack.SharedKernel.Localization.ResolvedLocalizedText("Label", "en")); + read.Observer.Selects.Should().Be(3); + } + await CreateRevisionAsync(source, context, 2); + foreach (var cache in new[] { firstCache, secondCache }) + { + await using var read = await ReadSession.OpenAsync(source, context, cache: cache); + var changed = (await read.Reader.ReadAsync(request)).Value!; + changed.ContentTypes[new("profile", 2)].Status.Should().Be(DefinitionStatus.Active); + changed.Taxonomies[new("levels", 2)].Status.Should().Be(DefinitionStatus.Active); + changed.ContentTypes[new("profile", 1)].Status.Should().Be(DefinitionStatus.Deprecated); + (await read.Reader.ReadAsync(request)).IsSuccess.Should().BeTrue(); + read.Observer.Selects.Should().Be(3); + cache.Keys.Should().HaveCount(4, "old committed keys remain stranded until TTL reclamation"); + } + } + + private static string FamilyKey(TenantId tenant, string family, long generation) => + CacheKey.ForTenant(tenant.Value, "customization", family, "v" + generation); + + internal sealed class CacheProbe : ICacheService, IAsyncDisposable + { + private readonly ServiceProvider _owner; + private readonly InMemoryCacheService _inner; + private int _gets; private int _sets; + public CacheProbe() + { + var services = new ServiceCollection(); services.AddMetrics(); + _owner = services.BuildServiceProvider(); + _inner = new InMemoryCacheService(new SystemClock(), _owner.GetRequiredService()); + } + public int GetCalls => _gets; + public int SetCalls => _sets; + public int FactoryCalls { get; private set; } + public string? Fault { get; set; } + public CancellationTokenSource? Cancel { get; set; } + public bool CancelDuringSet { get; set; } + public ConcurrentBag Keys { get; } = []; + public ConcurrentBag Options { get; } = []; + public ConcurrentBag Tokens { get; } = []; + public CacheLogger Logger { get; } = new(); + public async Task GetAsync(string key, CancellationToken cancellationToken = default) + { + Interlocked.Increment(ref _gets); Tokens.Add(cancellationToken); + if (!CancelDuringSet) Cancel?.Cancel(); + if (Fault == "get") throw new IOException("private-cache-data"); + if (Fault == "get-oom") ThrowSyntheticMemoryFailure(); + if (Fault == "timeout") throw new OperationCanceledException("private-cache-data"); + return await _inner.GetAsync(key, cancellationToken); + } + public Task GetOrSetAsync(string key, Func> factory, CacheOptions? options = null, + CancellationToken cancellationToken = default) + { + FactoryCalls++; + throw new InvalidOperationException("An ambient loader must never enter a shared factory flight."); + } + public async Task SetAsync(string key, T value, CacheOptions? options = null, CancellationToken cancellationToken = default) + { + Interlocked.Increment(ref _sets); Tokens.Add(cancellationToken); Options.Add(options); + if (CancelDuringSet) Cancel?.Cancel(); + if (Fault == "set") throw new IOException("private-cache-data"); + if (Fault == "set-oom") ThrowSyntheticMemoryFailure(); + await _inner.SetAsync(key, value, options, cancellationToken); + if (!Keys.Contains(key)) Keys.Add(key); + } + public Task RemoveAsync(string key, CancellationToken cancellationToken = default) => _inner.RemoveAsync(key, cancellationToken); + public ValueTask DisposeAsync() => _owner.DisposeAsync(); + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "CA2201:Do not raise reserved exception types", + Justification = "A test double simulates a fatal cache fault without exhausting process memory.")] + private static void ThrowSyntheticMemoryFailure() => throw new OutOfMemoryException(); + } + + internal sealed class CacheLogger : ILogger + { + public List Messages { get; } = []; + public List Exceptions { get; } = []; + public IDisposable? BeginScope(TState state) where TState : notnull => null; + public bool IsEnabled(LogLevel logLevel) => true; + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + Messages.Add(formatter(state, exception)); + if (exception is not null) Exceptions.Add(exception); + } + } +} diff --git a/backend/tests/LearnStack.Tests.Integration/Database/CustomizationProjectionCompositionTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/CustomizationProjectionCompositionTests.cs new file mode 100644 index 00000000..241aa877 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/Database/CustomizationProjectionCompositionTests.cs @@ -0,0 +1,211 @@ +using System.Diagnostics; +using System.Text; +using System.Text.Json; +using FluentAssertions; +using LearnStack.Modules.Customization.Application.Contracts.Customization; +using LearnStack.Modules.Customization.Application.Contracts.Definitions; +using LearnStack.Modules.Customization.Infrastructure.Persistence; +using LearnStack.Modules.Customization.Infrastructure.Projections; +using LearnStack.Modules.Tenancy.Application.Contracts.Settings; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Persistence; +using LearnStack.SharedKernel.Tenancy; +using LearnStack.Tools.Seeder; +using MediatR; +using Microsoft.AspNetCore.Hosting; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Npgsql; +using Xunit; + +namespace LearnStack.Tests.Integration.Database; + +[Trait(RequiresDocker.Key, RequiresDocker.Value)] +public sealed partial class CustomizationProjectionTests +{ + [Fact] + public async Task Nested_commands_share_the_dirty_reader_scope_and_cannot_fill_speculative_generation_keys() + { + await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); + await using var source = NpgsqlDataSource.Create(database.AppConnectionString); + var context = await ProvisionAsync(source); + await using var cache = new CacheProbe(); + await using var read = await ReadSession.OpenAsync(source, context, cache: cache); + (await read.Reader.ReadAsync(Request([], []))).Value!.Generation.Should().BeNull(); + var id = Guid.CreateVersion7(); + var sender = read.Services.GetRequiredService(); + (await sender.Send(new RegisterTenantContentTypeCommand(id, "nested", 1, Label, Profile, "default-card"))).IsSuccess.Should().BeTrue(); + (await sender.Send(new PublishTenantContentTypeCommand(id))).IsSuccess.Should().BeTrue(); + var saved = (await read.Reader.ReadAsync(Request([new("nested", 1)], []))).Value!; + saved.Generation.Should().Be(2); + saved.ContentTypes[new("nested", 1)].Status.Should().Be(DefinitionStatus.Active); + cache.GetCalls.Should().Be(0); + cache.SetCalls.Should().Be(0); + await read.Frame.FailAsync(); + await using var fresh = await ReadSession.OpenAsync(source, context, cache: cache); + (await fresh.Reader.ReadAsync(Request([new("nested", 1)], []))).Value!.MissingContentTypes.Should().ContainSingle(); + fresh.State.IsDirty.Should().BeFalse(); + cache.Keys.Should().BeEmpty(); + } + + [Fact] + public async Task Api_and_seeder_roots_supply_the_same_scoped_guarded_contracts() + { + await using var source = NpgsqlDataSource.Create(schema.Postgres.AppConnectionString); + var context = new SeedTenantContext(TenantId.From(SchemaFixture.TenantA), null); + await using var seed = SeedComposition.Build(source, context, NullLoggerFactory.Instance); + await using var api = factory.WithWebHostBuilder(builder => builder.UseSetting("ConnectionStrings:Default", schema.Postgres.AppConnectionString)); + foreach (var root in new[] { seed, api.Services }) + { + CustomizationReadState? previous = null; + for (var index = 0; index < 2; index++) + { + await using var scope = root.CreateAsyncScope(); + var services = scope.ServiceProvider; + services.GetRequiredService().Current = context; + var unit = services.GetRequiredService(); + await using var frame = await unit.BeginTransactionAsync(); + await unit.SetTenantContextAsync(context); + await AssertAppRoleAsync(unit); + var state = services.GetRequiredService(); + state.Should().NotBeSameAs(previous); + state.Should().BeSameAs(services.GetRequiredService()); + state.IsDirty.Should().BeFalse(); + var reader = services.GetRequiredService(); + var result = (await reader.ReadAsync(Request([new("announcement", 1)], [new("proficiency", 1)]))).Value!; + result.Taxonomies[new("proficiency", 1)].Bands.Should().ContainSingle().Which.Key.Should().Be("beginner"); + var settings = services.GetRequiredService(); + (await settings.ReadAsync(TenantSettingKeys.BrandingTheme)).IsSuccess.Should().BeTrue(); + services.GetRequiredService().ChangeTracker.Entries().Should().BeEmpty(); + await frame.FailAsync(); + await ((Func>>)(() => reader.ReadAsync(Request([], [])))) + .Should().ThrowAsync(); + previous = state; + } + } + } + + [Fact] + public async Task Concurrent_cold_callers_keep_their_own_transaction_and_awaited_loader() + { + await using var source = NpgsqlDataSource.Create(schema.Postgres.AppConnectionString); + await using var cache = new CacheProbe(); + var context = new SeedTenantContext(TenantId.From(SchemaFixture.TenantA), null); + await using var first = await ReadSession.OpenAsync(source, context, cache: cache); + await using var second = await ReadSession.OpenAsync(source, context, cache: cache); + var arrived = 0; + var barrier = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + async Task Pause() + { + if (Interlocked.Increment(ref arrived) == 2) barrier.TrySetResult(); + await barrier.Task.WaitAsync(TimeSpan.FromSeconds(30)); + } + // Neither loader may return/fill until both have executed their own + // snapshot. A probe-only barrier still permits one caller to warm the other. + first.Observer.AfterSnapshot = Pause; + second.Observer.AfterSnapshot = Pause; + var request = Request([new("announcement", 1)], [new("proficiency", 1)]); + var results = await Task.WhenAll(first.Reader.ReadAsync(request), second.Reader.ReadAsync(request)); + results.Should().OnlyContain(result => result.IsSuccess); + foreach (var result in results) + { + result.Value!.ContentTypes.Should().ContainSingle().Which.Key.Should().Be(new DefinitionRevision("announcement", 1)); + result.Value.Taxonomies[new("proficiency", 1)].Bands.Should().ContainSingle().Which.Key.Should().Be("beginner"); + } + first.Unit.Connection.Should().NotBeSameAs(second.Unit.Connection); + first.Observer.Selects.Should().Be(2); + second.Observer.Selects.Should().Be(2); + first.Observer.Snapshot.Should().NotBeNull(); + second.Observer.Snapshot.Should().NotBeNull(); + cache.FactoryCalls.Should().Be(0); + await first.Frame.FailAsync(); + (await second.Reader.ReadAsync(request)).IsSuccess.Should().BeTrue(); + } + + [Fact] + public async Task Seeded_local_measurement_records_statement_plans_payload_volume_and_end_to_end_timings() + { + await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); + await using var source = NpgsqlDataSource.Create(database.AppConnectionString); + var runner = new SeedRunner(context => SeedComposition.Build(source, context, NullLoggerFactory.Instance), NullLogger.Instance); + (await runner.RunAsync(CancellationToken.None)).Should().Be(0); + await using var cache = new CacheProbe(); + var tenant = SeedData.English; + var context = new SeedTenantContext(tenant.TenantId, null); + await using var read = await ReadSession.OpenAsync(source, context, cache: cache); + await AssertAppRoleAsync(read.Unit); + var request = Request([.. SeedData.ContentTypes(tenant).Select(type => new DefinitionRevision(type.Key, type.SchemaVersion))], + [.. SeedData.Taxonomies(tenant).Select(taxonomy => new DefinitionRevision(taxonomy.Key, taxonomy.SchemaVersion))]); + var initial = (await read.Reader.ReadAsync(request)).Value!; + initial.ContentTypes.Should().HaveCount(2); + initial.Taxonomies.Should().HaveCount(2); + var cold = new List(); var warm = new List(); var settings = new List(); + var accessor = read.Services.GetRequiredService(); + (await accessor.ReadAsync(TenantSettingKeys.BrandingTheme)).Value!.IsPresent.Should().BeTrue(); + for (var iteration = 0; iteration < 20; iteration++) + { + await cache.RemoveAsync(FamilyKey(context.TenantId, "content-types", initial.Generation!.Value)); + await cache.RemoveAsync(FamilyKey(context.TenantId, "taxonomies", initial.Generation.Value)); + read.Observer.Reset(); + var started = Stopwatch.GetTimestamp(); + (await read.Reader.ReadAsync(request)).IsSuccess.Should().BeTrue(); + cold.Add(Stopwatch.GetElapsedTime(started).TotalMilliseconds); + read.Observer.Selects.Should().Be(2); + started = Stopwatch.GetTimestamp(); + (await read.Reader.ReadAsync(request)).IsSuccess.Should().BeTrue(); + warm.Add(Stopwatch.GetElapsedTime(started).TotalMilliseconds); + read.Observer.Selects.Should().Be(3); + started = Stopwatch.GetTimestamp(); + (await accessor.ReadAsync(TenantSettingKeys.BrandingTheme)).Value!.IsPresent.Should().BeTrue(); + settings.Add(Stopwatch.GetElapsedTime(started).TotalMilliseconds); + } + var probe = await ExplainAsync(read.Unit, read.Observer.Probe!); + var snapshot = await ExplainAsync(read.Unit, read.Observer.Snapshot!); + await using var payload = SampleCommand(read.Unit, read.Observer.Snapshot!); + await using var rows = await payload.ExecuteReaderAsync(); + (await rows.ReadAsync()).Should().BeTrue(); + var types = rows.GetString(rows.GetOrdinal("ContentTypes")); + var taxonomies = rows.GetString(rows.GetOrdinal("Taxonomies")); + var bytes = Encoding.UTF8.GetByteCount(types) + Encoding.UTF8.GetByteCount(taxonomies); + output.WriteLine(JsonSerializer.Serialize(new + { + Sample = "local Docker PostgreSQL, app role, 20 observations after warmup; not production p95", + Tenant = tenant.Slug, + Generation = initial.Generation, + ContentTypes = initial.ContentTypes.Count, + Taxonomies = initial.Taxonomies.Count, + Bands = initial.Taxonomies.Values.Sum(taxonomy => taxonomy.Bands.Length), + SnapshotJsonUtf8Bytes = bytes, + ColdMs = Summary(cold), + WarmMs = Summary(warm), + SettingsMs = Summary(settings), + ProbePlan = probe, + SnapshotPlan = snapshot + })); + cache.FactoryCalls.Should().Be(0); + } + + private static object Summary(List samples) + { + var ordered = samples.Order().ToArray(); + return new + { + Minimum = ordered[0], + Median = (ordered[(ordered.Length - 1) / 2] + ordered[ordered.Length / 2]) / 2, + Maximum = ordered[^1] + }; + } + private static NpgsqlCommand SampleCommand(IUnitOfWork unit, CommandSample sample) + { + var command = new NpgsqlCommand(sample.Sql, (NpgsqlConnection)unit.Connection, (NpgsqlTransaction)unit.Transaction!); + foreach (var (name, value) in sample.Parameters) command.Parameters.AddWithValue(name, value); + return command; + } + private static async Task ExplainAsync(IUnitOfWork unit, CommandSample sample) + { + await using var command = SampleCommand(unit, sample); + command.CommandText = "EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) " + command.CommandText; + return (string)(await command.ExecuteScalarAsync())!; + } +} diff --git a/backend/tests/LearnStack.Tests.Integration/Database/CustomizationProjectionDepthTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/CustomizationProjectionDepthTests.cs new file mode 100644 index 00000000..6d0775da --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/Database/CustomizationProjectionDepthTests.cs @@ -0,0 +1,109 @@ +using System.Text.Json; +using FluentAssertions; +using LearnStack.Modules.Customization.Application.Contracts.Customization; +using LearnStack.Modules.Customization.Application.Contracts.Definitions; +using Npgsql; +using Xunit; + +namespace LearnStack.Tests.Integration.Database; + +[Trait(RequiresDocker.Key, RequiresDocker.Value)] +public sealed partial class CustomizationProjectionTests +{ + [Theory] + [InlineData(false, 63)] + [InlineData(false, 64)] + [InlineData(true, 61)] + [InlineData(true, 64)] + public async Task Accepted_source_depth_survives_snapshot_envelopes_for_unrelated_cold_and_partial_reads( + bool taxonomy, int sourceDepth) + { + await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); + await using var source = NpgsqlDataSource.Create(database.AppConnectionString); + var context = await ProvisionAsync(source); + await CreateRevisionAsync(source, context, 1); + var id = Guid.CreateVersion7(); + if (taxonomy) + { + (await SendAsync(source, context, new RegisterTenantLevelTaxonomyCommand(id, "deep", 1, Label, + [new("only", Label, 0, NestedArrays(65))]))).IsFailure.Should().BeTrue("the source limit remains 64"); + (await SendAsync(source, context, new RegisterTenantLevelTaxonomyCommand(id, "deep", 1, Label, + [new("only", Label, 0, NestedArrays(sourceDepth))]))).IsSuccess.Should().BeTrue(); + (await SendAsync(source, context, new PublishTenantLevelTaxonomyCommand(id))).IsSuccess.Should().BeTrue(); + } + else + { + (await SendAsync(source, context, new RegisterTenantContentTypeCommand(id, "deep", 1, Label, + DeepDefaultSchema(65), "default-card"))).IsFailure.Should().BeTrue("the source limit remains 64"); + var registered = await SendAsync(source, context, new RegisterTenantContentTypeCommand(id, "deep", 1, Label, + DeepDefaultSchema(sourceDepth), "default-card")); + registered.IsSuccess.Should().BeTrue("{0}", JsonSerializer.Serialize(registered.Error)); + (await SendAsync(source, context, new PublishTenantContentTypeCommand(id))).IsSuccess.Should().BeTrue(); + } + + await using var cache = new CacheProbe(); + await using var read = await ReadSession.OpenAsync(source, context, cache: cache); + await AssertAppRoleAsync(read.Unit); + // Loading either family parses both whole families, even for unrelated pins. + var unrelated = await read.Reader.ReadAsync(Request([new("profile", 1)], [new("levels", 1)])); + unrelated.IsSuccess.Should().BeTrue(); + unrelated.Value!.ContentTypes.Should().ContainSingle(); + unrelated.Value.Taxonomies.Should().ContainSingle(); + unrelated.Value.MissingContentTypes.Should().BeEmpty(); + unrelated.Value.MissingTaxonomies.Should().BeEmpty(); + read.Observer.Selects.Should().Be(2); + var generation = unrelated.Value.Generation!.Value; + var request = Request( + taxonomy ? [new("profile", 1)] : [new("profile", 1), new("deep", 1)], + taxonomy ? [new("levels", 1), new("deep", 1)] : [new("levels", 1)]); + + // Warm and either direction of a partial hit retain the accepted boundary pin. + foreach (var missingFamily in new[] { "", "content-types", "taxonomies" }) + { + if (missingFamily.Length > 0) + { + await cache.RemoveAsync(FamilyKey(context.TenantId, missingFamily, generation)); + } + read.Observer.Reset(); + var result = await read.Reader.ReadAsync(request); + result.IsSuccess.Should().BeTrue(); + result.Value!.Generation.Should().Be(generation); + result.Value.ContentTypes.Keys.Should().BeEquivalentTo(request.ContentTypes); + result.Value.Taxonomies.Keys.Should().BeEquivalentTo(request.Taxonomies); + result.Value.MissingContentTypes.Should().BeEmpty(); + result.Value.MissingTaxonomies.Should().BeEmpty(); + if (taxonomy) + { + var definition = result.Value.Taxonomies[new("deep", 1)]; + definition.Id.Should().Be(id); + definition.Status.Should().Be(DefinitionStatus.Active); + var metadata = definition.Bands.Should().ContainSingle().Which.Metadata; + using var document = JsonDocument.Parse(metadata!); + var value = document.RootElement; + for (var level = 0; level < sourceDepth; level++) + { + value.GetArrayLength().Should().Be(1); + value = value[0]; + } + value.GetInt32().Should().Be(0); + } + else + { + result.Value.ContentTypes[new("deep", 1)].Id.Should().Be(id); + result.Value.ContentTypes[new("deep", 1)].Status.Should().Be(DefinitionStatus.Active); + result.Value.ContentTypes[new("deep", 1)].Fields.Should().BeEmpty(); + } + read.Observer.Selects.Should().Be(missingFamily.Length == 0 ? 1 : 2); + } + cache.FactoryCalls.Should().Be(0); + read.Db.ChangeTracker.Entries().Should().BeEmpty(); + } + + private static string NestedArrays(int depth) => new string('[', depth) + "0" + new string(']', depth); + + // The root object spends one raw JSON level; default is literal instance data. + private static string DeepDefaultSchema(int sourceDepth) => + "{\"$schema\":\"https://json-schema.org/draft/2020-12/schema\",\"type\":\"object\"," + + "\"properties\":{\"a\":{\"type\":\"string\"}},\"default\":" + + NestedArrays(sourceDepth - 1) + "}"; +} diff --git a/backend/tests/LearnStack.Tests.Integration/Database/CustomizationProjectionTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/CustomizationProjectionTests.cs new file mode 100644 index 00000000..1d4b70e2 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/Database/CustomizationProjectionTests.cs @@ -0,0 +1,348 @@ +using System.Data.Common; +using FluentAssertions; +using LearnStack.Infrastructure.Persistence; +using LearnStack.Modules.Customization.Application.Contracts.Customization; +using LearnStack.Modules.Customization.Application.Contracts.Definitions; +using LearnStack.Modules.Customization.Infrastructure.Persistence; +using LearnStack.Modules.Customization.Infrastructure.Projections; +using LearnStack.Modules.Tenancy.Application.Contracts.Tenant; +using LearnStack.SharedKernel.Audit; +using LearnStack.SharedKernel.Errors; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Persistence; +using LearnStack.SharedKernel.Results; +using LearnStack.SharedKernel.Tenancy; +using LearnStack.Tools.Seeder; +using MediatR; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Npgsql; +using Xunit; +using Xunit.Abstractions; + +namespace LearnStack.Tests.Integration.Database; + +[Trait(RequiresDocker.Key, RequiresDocker.Value)] +[Collection(SharedSchema.Name)] +public sealed partial class CustomizationProjectionTests(SchemaFixture schema, WebApplicationFactory factory, ITestOutputHelper output) + : IClassFixture> +{ + private static readonly Dictionary Label = new() { ["en"] = "Label", ["tr"] = "Etiket" }; + private const string Profile = """ + {"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object", + "properties":{"a":{"type":"string"},"b":{"type":"string"}},"additionalProperties":false, + "x-fields":[{"name":"b","label":{"zh-Hant":"Second"}},{"name":"a","label":{"en":"First"}}]} + """; + + [Theory] + [InlineData(false, "beginner")] + [InlineData(true, "starter")] + public async Task Composed_reader_returns_only_its_tenant_and_tracks_no_entities(bool foreign, string band) + { + await using var source = NpgsqlDataSource.Create(schema.Postgres.AppConnectionString); + var context = new SeedTenantContext(TenantId.From(foreign ? SchemaFixture.TenantB : SchemaFixture.TenantA), null); + await using var provider = SeedComposition.Build(source, context, NullLoggerFactory.Instance); + await using var scope = provider.CreateAsyncScope(); + var unit = scope.ServiceProvider.GetRequiredService(); + await using var frame = await unit.BeginTransactionAsync(); + await unit.SetTenantContextAsync(context); + await AssertAppRoleAsync(unit); + var reader = scope.ServiceProvider.GetRequiredService(); + var result = await reader.ReadAsync(Request([new("announcement", 1), new("announcement", 9)], [new("proficiency", 1)])); + result.IsSuccess.Should().BeTrue(); + result.Value!.Taxonomies[new("proficiency", 1)].Bands.Should().ContainSingle().Which.Key.Should().Be(band); + result.Value.ContentTypes.Should().ContainSingle(); + result.Value.MissingContentTypes.Should().BeEquivalentTo([new DefinitionRevision("announcement", 9)]); + scope.ServiceProvider.GetRequiredService().ChangeTracker.Entries().Should().BeEmpty(); + await frame.FailAsync(); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Exact_batches_keep_deprecated_pins_and_distinguish_draft_deleted_absent_and_malformed(bool malformedBand) + { + await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); + await using var source = NpgsqlDataSource.Create(database.AppConnectionString); + var context = await ProvisionAsync(source); + await CreateRevisionAsync(source, context, 1); + await CreateRevisionAsync(source, context, 2); + await CreateRevisionAsync(source, context, 3, publish: false); + var intactType = Guid.CreateVersion7(); + var intactTaxonomy = Guid.CreateVersion7(); + (await SendAsync(source, context, new RegisterTenantContentTypeCommand(intactType, "intact", 1, Label, Profile, "default-card"))) + .IsSuccess.Should().BeTrue(); + (await SendAsync(source, context, new PublishTenantContentTypeCommand(intactType))).IsSuccess.Should().BeTrue(); + (await SendAsync(source, context, new RegisterTenantLevelTaxonomyCommand(intactTaxonomy, "intact", 1, Label, + [new("only", Label, 0)]))).IsSuccess.Should().BeTrue(); + (await SendAsync(source, context, new PublishTenantLevelTaxonomyCommand(intactTaxonomy))).IsSuccess.Should().BeTrue(); + await using var read = await ReadSession.OpenAsync(source, context); + var request = Request([new("profile", 1), new("profile", 2), new("profile", 3), new("profile", 9), new("intact", 1)], + [new("levels", 1), new("levels", 2), new("levels", 3), new("levels", 9), new("intact", 1)]); + var result = (await read.Reader.ReadAsync(request)).Value!; + result.ContentTypes[new("profile", 1)].Status.Should().Be(DefinitionStatus.Deprecated); + result.ContentTypes[new("profile", 2)].Status.Should().Be(DefinitionStatus.Active); + result.Taxonomies[new("levels", 1)].Bands.Select(item => item.Key).Should().Equal("later", "first"); + result.MissingContentTypes.Should().BeEquivalentTo([new DefinitionRevision("profile", 3), new DefinitionRevision("profile", 9)]); + result.MissingTaxonomies.Should().BeEquivalentTo([new DefinitionRevision("levels", 3), new DefinitionRevision("levels", 9)]); + result.ContentTypes[new("profile", 1)].DisplayName.Locale.Should().Be("tr"); + result.ContentTypes[new("profile", 1)].Fields.Select(field => field.Name).Should().Equal("b", "a"); + result.ContentTypes[new("profile", 1)].Fields[0].Label.Locale.Should().Be("zh-Hant"); + result.ContentTypes[new("profile", 1)].Fields[1].Label.Locale.Should().Be("en"); + read.Observer.Selects.Should().Be(2, "one generation probe and one coherent batch regardless of pin count"); + + await ExecuteAsync(read.Unit, """ + UPDATE tenant_content_types SET deleted_at = now() WHERE key = 'profile' AND schema_version = 1; + UPDATE tenant_level_taxonomies SET deleted_at = now() WHERE key = 'levels' AND schema_version = 1; + UPDATE tenant_content_types SET json_schema = '{"x-fields":42}'::jsonb WHERE key = 'profile' AND schema_version = 2; + UPDATE customization_generations SET generation = generation + 1; + """); + await ExecuteAsync(read.Unit, malformedBand ? """ + UPDATE tenant_level_taxonomy_items SET display_name = '{"en":42}'::jsonb + WHERE taxonomy_key = 'levels' AND schema_version = 2 AND key = 'first'; + """ : """ + UPDATE tenant_level_taxonomies SET display_name = '{"en":42}'::jsonb + WHERE key = 'levels' AND schema_version = 2; + """); + var malformed = (await read.Reader.ReadAsync(request)).Value!; + malformed.ContentTypes.Should().ContainSingle().Which.Key.Should().Be(new DefinitionRevision("intact", 1)); + malformed.Taxonomies.Should().ContainSingle().Which.Key.Should().Be(new DefinitionRevision("intact", 1)); + malformed.MissingContentTypes.Should().BeEquivalentTo(request.ContentTypes.Where(pin => pin.Key != "intact")); + malformed.MissingTaxonomies.Should().BeEquivalentTo(request.Taxonomies.Where(pin => pin.Key != "intact")); + read.Observer.Selects.Should().Be(4); + read.Db.ChangeTracker.Entries().Should().BeEmpty(); + } + + [Fact] + public async Task Missing_counter_distinguishes_empty_from_corrupt_nonempty_configuration() + { + await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); + await using var source = NpgsqlDataSource.Create(database.AppConnectionString); + var context = await ProvisionAsync(source); + await using var cache = new CacheProbe(); + await using var read = await ReadSession.OpenAsync(source, context, cache: cache); + var empty = (await read.Reader.ReadAsync(Request([], []))).Value!; + empty.Generation.Should().BeNull(); + empty.ContentTypes.Should().BeEmpty(); + empty.Taxonomies.Should().BeEmpty(); + await ExecuteAsync(read.Unit, """ + INSERT INTO tenant_content_types + (id,tenant_id,key,schema_version,schema_revision,status,display_name,json_schema,renderer_key,created_at,created_by,row_version) + VALUES (uuidv7(),NULLIF(current_setting('app.tenant_id',true),'')::uuid,'orphan',1,0,'Draft', + '{"en":"Orphan"}','{"type":"object"}','default-card',now(),'00000000-0000-7000-8000-000000000001',0) + """); + var refusal = await read.Reader.ReadAsync(Request([new("orphan", 1)], [])); + refusal.Error!.Code.Should().Be("validation_failed"); + refusal.Error.Details!["Definition"].Single().Key.Should().Be("lockey_invalid_value"); + cache.GetCalls.Should().Be(0); + cache.SetCalls.Should().Be(0); + await ExecuteAsync(read.Unit, """ + INSERT INTO customization_generations (tenant_id,generation) + VALUES (NULLIF(current_setting('app.tenant_id',true),'')::uuid,1) + """); + var present = (await read.Reader.ReadAsync(Request([new("orphan", 1)], []))).Value!; + present.Generation.Should().Be(1); + present.ContentTypes.Should().BeEmpty(); + present.MissingContentTypes.Should().ContainSingle(); + cache.SetCalls.Should().Be(2, "a present counter with empty eligible families is cacheable"); + } + + [Fact] + public async Task Publish_after_probe_with_a_partial_warm_hit_returns_the_loaded_generation_and_both_new_families() + { + await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); + await using var source = NpgsqlDataSource.Create(database.AppConnectionString); + var context = await ProvisionAsync(source); + await CreateRevisionAsync(source, context, 1); + await CreateRevisionAsync(source, context, 2, publish: false); + await using var cache = new CacheProbe(); + await using var read = await ReadSession.OpenAsync(source, context, cache: cache); + var request = Request([new("profile", 1), new("profile", 2)], [new("levels", 1), new("levels", 2)]); + var probeGeneration = (await read.Reader.ReadAsync(request)).Value!.Generation!.Value; + await cache.RemoveAsync(FamilyKey(context.TenantId, "taxonomies", probeGeneration)); + read.Observer.Reset(); + read.Observer.AfterProbe = async () => + { + await using var provider = SeedComposition.Build(source, context, NullLoggerFactory.Instance); + await using var scope = provider.CreateAsyncScope(); + var unit = scope.ServiceProvider.GetRequiredService(); + await using var frame = await unit.BeginTransactionAsync(); + await unit.SetTenantContextAsync(context); + var sender = scope.ServiceProvider.GetRequiredService(); + (await sender.Send(new PublishTenantContentTypeCommand(await IdAsync(unit, "tenant_content_types", "profile", 2)))) + .IsSuccess.Should().BeTrue(); + (await sender.Send(new PublishTenantLevelTaxonomyCommand(await IdAsync(unit, "tenant_level_taxonomies", "levels", 2)))) + .IsSuccess.Should().BeTrue(); + await scope.ServiceProvider.GetRequiredService().WritePendingAsync(unit); + await frame.CompleteAsync(); + }; + var projection = (await read.Reader.ReadAsync(request)).Value!; + read.Observer.Intervened.Should().BeTrue(); + projection.Generation.Should().Be(probeGeneration + 2); + projection.ContentTypes[new("profile", 1)].Status.Should().Be(DefinitionStatus.Deprecated); + projection.ContentTypes[new("profile", 2)].Status.Should().Be(DefinitionStatus.Active); + projection.Taxonomies[new("levels", 1)].Status.Should().Be(DefinitionStatus.Deprecated); + projection.Taxonomies[new("levels", 2)].Status.Should().Be(DefinitionStatus.Active); + read.Observer.Selects.Should().Be(2); + } + + [Fact] + public async Task Admission_rejects_unannounced_frames_bad_pins_and_bad_locales_and_propagates_cancellation() + { + await using var source = NpgsqlDataSource.Create(schema.Postgres.AppConnectionString); + var context = new SeedTenantContext(TenantId.From(SchemaFixture.TenantA), null); + await using var read = await ReadSession.OpenAsync(source, context, announce: false); + var invoke = () => read.Reader.ReadAsync(Request([], [])); + await invoke.Should().ThrowAsync(); + await read.Unit.SetTenantContextAsync(context); + (await read.Reader.ReadAsync(Request([new("profile", 0)], []))).IsFailure.Should().BeTrue(); + (await read.Reader.ReadAsync(Request([], []) with { RequestedLocale = "tr_TR" })).IsFailure.Should().BeTrue(); + (await read.Reader.ReadAsync(Request([], []) with { ContentTypes = default })).IsFailure.Should().BeTrue(); + using var canceled = new CancellationTokenSource(); + await canceled.CancelAsync(); + var cancel = () => read.Reader.ReadAsync(Request([], []), canceled.Token); + await cancel.Should().ThrowAsync(); + read.Observer.Selects.Should().Be(0); + await read.Frame.FailAsync(); + await invoke.Should().ThrowAsync(); + } + + private static DefinitionProjectionRequest Request( + System.Collections.Immutable.ImmutableArray types, + System.Collections.Immutable.ImmutableArray taxonomies) => new(types, taxonomies, "zh-Hant-TW", "tr"); + + private static async Task ProvisionAsync(NpgsqlDataSource source) + { + var tenant = TenantId.From(Guid.CreateVersion7()); + (await SendAsync(source, null, new ProvisionTenantCommand(tenant, "projection-proof", "Projection proof", + OrganizationId.From(Guid.CreateVersion7()), "main", "Main"))).IsSuccess.Should().BeTrue(); + return new(tenant, null); + } + private static async Task> SendAsync(NpgsqlDataSource source, ITenantContext? context, IRequest> command) + { + await using var provider = SeedComposition.Build(source, context, NullLoggerFactory.Instance); + await using var scope = provider.CreateAsyncScope(); + return await scope.ServiceProvider.GetRequiredService().Send(command); + } + private static async Task CreateRevisionAsync(NpgsqlDataSource source, ITenantContext context, int version, bool publish = true) + { + var type = Guid.CreateVersion7(); + var taxonomy = Guid.CreateVersion7(); + (await SendAsync(source, context, new RegisterTenantContentTypeCommand(type, "profile", version, Label, Profile, "default-card"))) + .IsSuccess.Should().BeTrue(); + (await SendAsync(source, context, new RegisterTenantLevelTaxonomyCommand(taxonomy, "levels", version, Label, + [new("first", Label, 2), new("later", Label, 1)]))).IsSuccess.Should().BeTrue(); + if (!publish) return; + (await SendAsync(source, context, new PublishTenantContentTypeCommand(type))).IsSuccess.Should().BeTrue(); + (await SendAsync(source, context, new PublishTenantLevelTaxonomyCommand(taxonomy))).IsSuccess.Should().BeTrue(); + } + private static async Task ExecuteAsync(IUnitOfWork unit, string sql) + { + await using var command = new NpgsqlCommand(sql, (NpgsqlConnection)unit.Connection, (NpgsqlTransaction)unit.Transaction!); + await command.ExecuteNonQueryAsync(); + } + private static async Task GenerationAsync(IUnitOfWork unit) + { + await using var command = new NpgsqlCommand("SELECT generation FROM customization_generations", + (NpgsqlConnection)unit.Connection, (NpgsqlTransaction)unit.Transaction!); + return (long)(await command.ExecuteScalarAsync())!; + } + private static async Task IdAsync(IUnitOfWork unit, string table, string key, int version) + { + // Closed test-only table names; data remain parameters. + await using var command = new NpgsqlCommand($"SELECT id FROM {table} WHERE key = @key AND schema_version = @version", + (NpgsqlConnection)unit.Connection, (NpgsqlTransaction)unit.Transaction!); + command.Parameters.AddWithValue("key", key); + command.Parameters.AddWithValue("version", version); + return (Guid)(await command.ExecuteScalarAsync())!; + } + private static async Task AssertAppRoleAsync(IUnitOfWork unit) + { + await using var command = new NpgsqlCommand("SELECT current_user, rolsuper OR rolbypassrls FROM pg_roles WHERE rolname = current_user", + (NpgsqlConnection)unit.Connection, (NpgsqlTransaction)unit.Transaction!); + await using var reader = await command.ExecuteReaderAsync(); + (await reader.ReadAsync()).Should().BeTrue(); + reader.GetString(0).Should().Be("learnstack_app"); + reader.GetBoolean(1).Should().BeFalse(); + } + + internal sealed record CommandSample(string Sql, (string Name, object Value)[] Parameters); + + internal sealed class CommandObserver : DbCommandInterceptor + { + public int Selects { get; private set; } + public bool Intervened { get; private set; } + public Func? AfterProbe { get; set; } + public Func? AfterSnapshot { get; set; } + public CommandSample? Probe { get; private set; } + public CommandSample? Snapshot { get; private set; } + private static CommandSample Sample(DbCommand command) => new(command.CommandText, + command.Parameters.Cast().Select(parameter => (parameter.ParameterName, parameter.Value!)).ToArray()); + public void Reset() => Selects = 0; + public override async ValueTask ReaderExecutedAsync(DbCommand command, CommandExecutedEventData eventData, + DbDataReader result, CancellationToken cancellationToken = default) + { + Selects++; + if (command.CommandText.Contains("P02d-3 generation probe", StringComparison.Ordinal)) Probe = Sample(command); + if (command.CommandText.Contains("P02d-3 definition snapshot", StringComparison.Ordinal)) Snapshot = Sample(command); + if (AfterProbe is { } action && command.CommandText.Contains("P02d-3 generation probe", StringComparison.Ordinal)) + { + AfterProbe = null; + await action(); + Intervened = true; + } + if (AfterSnapshot is { } snapshotAction && command.CommandText.Contains("P02d-3 definition snapshot", StringComparison.Ordinal)) + { + AfterSnapshot = null; + await snapshotAction(); + Intervened = true; + } + return result; + } + } + + internal sealed class ReadSession : IAsyncDisposable + { + private readonly ServiceProvider _provider; + private readonly AsyncServiceScope _scope; + private ReadSession(ServiceProvider provider, AsyncServiceScope scope, IUnitOfWork unit, + IUnitOfWorkScope frame, CustomizationDbContext db, CommandObserver observer, ITenantContext context, CacheProbe? cache) + { + _provider = provider; _scope = scope; Unit = unit; Frame = frame; Db = db; Observer = observer; + Reader = new CustomizationDefinitionProjectionReader(new DefinitionSnapshotStore(db), context, unit, + cache is null ? scope.ServiceProvider.GetRequiredService() + : new DefinitionFamilyCache(cache, State, unit, cache.Logger)); + } + public IServiceProvider Services => _scope.ServiceProvider; + public CustomizationReadState State => Services.GetRequiredService(); + public IUnitOfWork Unit { get; } + public IUnitOfWorkScope Frame { get; } + public CustomizationDbContext Db { get; } + public CommandObserver Observer { get; } + public ICustomizationDefinitionProjectionReader Reader { get; } + public static async Task OpenAsync(NpgsqlDataSource source, ITenantContext context, bool announce = true, CacheProbe? cache = null) + { + var provider = SeedComposition.Build(source, context, NullLoggerFactory.Instance); + var scope = provider.CreateAsyncScope(); + var unit = scope.ServiceProvider.GetRequiredService(); + var frame = await unit.BeginTransactionAsync(); + if (announce) await unit.SetTenantContextAsync(context); + var observer = new CommandObserver(); + var db = new CustomizationDbContext(new DbContextOptionsBuilder() + .UseNpgsql(unit.Connection).AddInterceptors(new TenantContextGuardInterceptor(unit), observer).Options, + scope.ServiceProvider.GetRequiredService()); + await db.Database.UseTransactionAsync(unit.Transaction); + return new(provider, scope, unit, frame, db, observer, context, cache); + } + public async ValueTask DisposeAsync() + { + await Frame.DisposeAsync(); + await Db.DisposeAsync(); + await _scope.DisposeAsync(); + await _provider.DisposeAsync(); + } + } +} diff --git a/backend/tests/LearnStack.Tests.Integration/Database/CustomizationPublicationConcurrencyTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/CustomizationPublicationConcurrencyTests.cs index 3c2e4b8c..6edd491a 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/CustomizationPublicationConcurrencyTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/CustomizationPublicationConcurrencyTests.cs @@ -1,9 +1,12 @@ using FluentAssertions; using LearnStack.Modules.Customization.Application.Abstractions; using LearnStack.Modules.Customization.Application.Contracts.Customization; +using LearnStack.Modules.Customization.Application.Contracts.Definitions; +using LearnStack.SharedKernel.Caching; using LearnStack.Modules.Customization.Application.Contracts.Seeding; using LearnStack.Modules.Customization.Domain; using LearnStack.Modules.Customization.Infrastructure.Persistence; +using LearnStack.Modules.Customization.Infrastructure.Projections; using LearnStack.Modules.Tenancy.Application.Contracts.Tenant; using LearnStack.SharedKernel.Audit; using LearnStack.SharedKernel.Identifiers; @@ -45,9 +48,9 @@ public async Task A_competitor_publishing_between_reads_is_refused_without_self_ .ConfigureServices(services => { services.AddScoped(provider => new ControlledContentStore( - new TenantContentTypeStore(provider.GetRequiredService()), gate)); + new TenantContentTypeStore(provider.GetRequiredService(), provider.GetRequiredService()), gate)); services.AddScoped(provider => new ControlledTaxonomyStore( - new TenantLevelTaxonomyStore(provider.GetRequiredService()), gate)); + new TenantLevelTaxonomyStore(provider.GetRequiredService(), provider.GetRequiredService()), gate)); })); async Task LosingAttempt() { @@ -100,14 +103,16 @@ public async Task An_absorbed_first_publication_save_failure_cannot_commit_the_d var later = Guid.CreateVersion7(); await RegisterAsync(source, context, id, taxonomy, "first-definition"); var initialVersion = await VersionAsync(source, context, id, taxonomy); + await using var cache = new CustomizationProjectionTests.CacheProbe(); await using var host = factory.WithWebHostBuilder(builder => builder .UseSetting("ConnectionStrings:Default", database.AppConnectionString) .ConfigureServices(services => { services.AddScoped(provider => new ControlledContentStore( - new TenantContentTypeStore(provider.GetRequiredService()), failure: concurrency)); + new TenantContentTypeStore(provider.GetRequiredService(), provider.GetRequiredService()), failure: concurrency)); services.AddScoped(provider => new ControlledTaxonomyStore( - new TenantLevelTaxonomyStore(provider.GetRequiredService()), failure: concurrency)); + new TenantLevelTaxonomyStore(provider.GetRequiredService(), provider.GetRequiredService()), failure: concurrency)); + services.AddSingleton(cache); services.AddTransient>, AbsorbingPublicationHandler>(); services.AddSingleton(); })); @@ -122,6 +127,9 @@ async Task> Outer() if (taxonomy) (await SendAsync(source, context, new GetTaxonomySeedStateQuery(later))).Value!.State.Should().BeNull(); else (await SendAsync(source, context, new GetContentTypeSeedStateQuery(later))).Value!.State.Should().BeNull(); (await GenerationAsync(database, context.TenantId)).Should().Be(1); + cache.GetCalls.Should().Be(2); + cache.SetCalls.Should().Be(2, "only the clean pre-write snapshot may fill; absorbed post-save refusal poisons the scope"); + cache.FactoryCalls.Should().Be(0); } [Theory] @@ -257,10 +265,14 @@ public async Task UpdateAsync(TenantLevelTaxonomy root, CancellationToken ct = d } } public sealed record AbsorbingPublicationCommand(Guid Id, Guid LaterId, bool Taxonomy) : IRequest>; - private sealed class AbsorbingPublicationHandler(ISender sender) : IRequestHandler> + private sealed class AbsorbingPublicationHandler(ISender sender, ICustomizationDefinitionProjectionReader reader, + CustomizationReadState state, IUnitOfWork unit) : IRequestHandler> { public async Task> Handle(AbsorbingPublicationCommand request, CancellationToken ct) { + var projection = new DefinitionProjectionRequest([new("first-definition", 1)], [new("first-definition", 1)], "en", "en"); + (await reader.ReadAsync(projection, ct)).IsSuccess.Should().BeTrue(); + state.IsDirty.Should().BeFalse(); if (request.Taxonomy) { (await sender.Send(new PublishTenantLevelTaxonomyCommand(request.Id), ct)).IsFailure.Should().BeTrue(); @@ -271,6 +283,11 @@ public async Task> Handle(AbsorbingPublicationCommand request, Canc (await sender.Send(new PublishTenantContentTypeCommand(request.Id), ct)).IsFailure.Should().BeTrue(); (await sender.Send(ContentType(request.LaterId, "later-definition"), ct)).IsSuccess.Should().BeTrue(); } + unit.IsRollbackOnly.Should().BeTrue(); + state.IsDirty.Should().BeTrue(); + var saved = (await reader.ReadAsync(projection, ct)).Value!; + if (request.Taxonomy) saved.Taxonomies[new("first-definition", 1)].Status.Should().Be(DefinitionStatus.Active); + else saved.ContentTypes[new("first-definition", 1)].Status.Should().Be(DefinitionStatus.Active); return Result.Ok(None.Value); } } diff --git a/backend/tests/LearnStack.Tests.Integration/Database/TenantSettingsAccessorTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/TenantSettingsAccessorTests.cs new file mode 100644 index 00000000..289fe3e8 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/Database/TenantSettingsAccessorTests.cs @@ -0,0 +1,308 @@ +using System.Text.Json; +using FluentAssertions; +using LearnStack.Modules.Tenancy.Application.Branding; +using LearnStack.Modules.Tenancy.Application.Contracts.Settings; +using LearnStack.Modules.Tenancy.Application.Settings; +using LearnStack.Modules.Tenancy.Domain; +using LearnStack.Modules.Tenancy.Infrastructure.Persistence; +using LearnStack.SharedKernel.Errors; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Localization; +using LearnStack.SharedKernel.Persistence; +using LearnStack.SharedKernel.Results; +using LearnStack.SharedKernel.Tenancy; +using LearnStack.SharedKernel.Time; +using LearnStack.Tools.Seeder; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Npgsql; +using Xunit; + +namespace LearnStack.Tests.Integration.Database; + +[Trait(RequiresDocker.Key, RequiresDocker.Value)] +[Collection(SharedSchema.Name)] +public sealed class TenantSettingsAccessorTests(SchemaFixture schema) +{ + private const string Theme = """{"primary":"#2345aa","background":"#ffffff","foreground":"#111111","muted":"#555555"}"""; + private static readonly TenantSettingKey PairKey = new("test.read-pair"); + private static readonly JsonSerializerOptions PairOptions = new() { PropertyNameCaseInsensitive = true }; + + [Theory] + [InlineData(false, "Europe/Istanbul")] + [InlineData(true, null)] + public async Task Registered_reads_keep_tenant_data_separate(bool foreign, string? expected) + { + var context = new Context(TenantId.From(foreign ? SchemaFixture.TenantB : SchemaFixture.TenantA)); + await ReadInScopeAsync(context, async (db, unit, services) => + { + await AssertAppRoleAsync(unit); + var token = new TenantSettingKey("tz"); + var other = new TenantSettingKey("beta-only"); + var reader = new TenantSettingsAccessor(db, context, unit, new TenantSettingRegistry( + [new TenantSettingRegistration(token, true, ReadText), + new TenantSettingRegistration(other, true, ReadText)])); + (await reader.ReadAsync(token)).Value!.Value.Should().Be(expected is null ? null : new Text(expected)); + (await reader.ReadAsync(other)).Value!.Value.Should().Be(foreign ? new Text("visible to beta alone") : null); + }); + } + + [Theory] + [InlineData(0, null)] + [InlineData(1, "main")] + [InlineData(2, "branch")] + public async Task Organization_scope_selects_only_its_own_override_even_with_the_tenant_hatch( + int organization, string? expected) + { + var org = organization switch + { + 1 => OrganizationId.From(SchemaFixture.OrgA1), + 2 => OrganizationId.From(SchemaFixture.OrgA2), + _ => (OrganizationId?)null + }; + var context = new Context(TenantId.From(SchemaFixture.TenantA), org); + await ReadInScopeAsync(context, async (db, unit, services) => + { + await using var hatch = new NpgsqlCommand("SELECT set_config('app.scope', 'tenant', true)", + (NpgsqlConnection)unit.Connection, (NpgsqlTransaction)unit.Transaction!); + await hatch.ExecuteNonQueryAsync(); + // Positive control: RLS now admits both sibling rows, still as learnstack_app. + await using var control = new NpgsqlCommand("SELECT count(*) FROM tenant_settings WHERE key = 'theme'", + (NpgsqlConnection)unit.Connection, (NpgsqlTransaction)unit.Transaction!); + ((long)(await control.ExecuteScalarAsync())!).Should().Be(2); + var token = new TenantSettingKey("theme"); + var reader = new TenantSettingsAccessor(db, context, unit, new TenantSettingRegistry( + [new TenantSettingRegistration(token, true, ReadText)])); + (await reader.ReadAsync(token)).Value!.Value.Should().Be(expected is null ? null : new Text(expected)); + }); + } + + [Fact] + public async Task Override_is_a_whole_value_and_invalid_selected_data_is_not_tenant_fallback() + { + var context = new Context(TenantId.From(SchemaFixture.TenantA), OrganizationId.From(SchemaFixture.OrgA1)); + await ReadInScopeAsync(context, async (db, unit, services) => + { + // Fixture preparation obeys exact write scope; the read still runs as its org. + await SchemaQueries.SetSettingAsync(unit.Connection, unit.Transaction!, "app.organization_id", ""); + var tenant = Add(db, context, PairKey.Value, """{"left":"tenant","right":"base"}""", null); + await db.SaveChangesAsync(); + await SchemaQueries.SetSettingAsync(unit.Connection, unit.Transaction!, "app.organization_id", context.OrganizationId!.Value.Value.ToString()); + var organization = Add(db, context, PairKey.Value, """{"left":"organization","right":"own"}""", context.OrganizationId); + await db.SaveChangesAsync(); + var reader = PairReader(db, context, unit); + (await reader.ReadAsync(PairKey)).Value!.Value.Should().Be(new Pair("organization", "own")); + organization.SetValue("""{"left":"partial"}""", new SystemClock(), UserId.SystemActor); + await db.SaveChangesAsync(); + var invalid = await reader.ReadAsync(PairKey); + invalid.Error!.Code.Should().Be("validation_failed"); + invalid.Error.Details!["Setting"].Single().Key.Should().Be("lockey_invalid_value"); + tenant.Value.Should().Contain("base"); + }); + } + + [Theory] + [InlineData(false, "[]")] + [InlineData(false, "{\"left\":42,\"right\":\"invalid\"}")] + [InlineData(true, "[]")] + public async Task Shape_exceptions_refuse_the_selected_override_without_tenant_fallback( + bool deserialize, string invalidValue) + { + var context = new Context(TenantId.From(SchemaFixture.TenantA), OrganizationId.From(SchemaFixture.OrgA1)); + await ReadInScopeAsync(context, async (db, unit, services) => + { + await AssertAppRoleAsync(unit); + await SchemaQueries.SetSettingAsync(unit.Connection, unit.Transaction!, "app.organization_id", ""); + Add(db, context, PairKey.Value, """{"left":"tenant","right":"base"}""", null); + await db.SaveChangesAsync(); + await SchemaQueries.SetSettingAsync(unit.Connection, unit.Transaction!, "app.organization_id", context.OrganizationId!.Value.Value.ToString()); + var organization = Add(db, context, PairKey.Value, invalidValue, context.OrganizationId); + await db.SaveChangesAsync(); + Func> parse = deserialize + ? value => Result.Ok(JsonSerializer.Deserialize(value, PairOptions)!) : ReadPair; + var reader = new TenantSettingsAccessor(db, context, unit, new TenantSettingRegistry( + [new TenantSettingRegistration(PairKey, true, parse)])); + + var invalid = await reader.ReadAsync(PairKey); + invalid.Error!.Code.Should().Be("validation_failed"); + invalid.Error.Details!["Setting"].Single().Key.Should().Be("lockey_invalid_value"); + organization.SetValue("""{"left":"organization","right":"own"}""", new SystemClock(), UserId.SystemActor); + await db.SaveChangesAsync(); + (await reader.ReadAsync(PairKey)).Value!.Value.Should().Be(new Pair("organization", "own")); + }); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Non_shape_parser_failures_remain_loud(bool canceled) + { + var context = new Context(TenantId.From(SchemaFixture.TenantA)); + await ReadInScopeAsync(context, async (db, unit, services) => + { + Add(db, context, PairKey.Value, "{}", null); + await db.SaveChangesAsync(); + var reader = new TenantSettingsAccessor(db, context, unit, new TenantSettingRegistry( + [new TenantSettingRegistration(PairKey, true, + _ => throw (canceled ? new OperationCanceledException() : new IOException()))])); + Func invoke = () => reader.ReadAsync(PairKey); + if (canceled) + { + await invoke.Should().ThrowAsync(); + } + else + { + await invoke.Should().ThrowAsync(); + } + }); + } + + [Fact] + public async Task Reads_are_uncached_observe_saved_changes_and_exclude_soft_deleted_rows() + { + var context = new Context(TenantId.From(SchemaFixture.TenantA)); + await ReadInScopeAsync(context, async (db, unit, services) => + { + var reader = PairReader(db, context, unit); + (await reader.ReadAsync(PairKey)).Value!.IsPresent.Should().BeFalse(); + var setting = Add(db, context, PairKey.Value, """{"left":"first","right":"whole"}""", null); + await db.SaveChangesAsync(); + (await reader.ReadAsync(PairKey)).Value!.Value.Should().Be(new Pair("first", "whole")); + setting.SetValue("""{"left":"second","right":"changed"}""", new SystemClock(), UserId.SystemActor); + await db.SaveChangesAsync(); + (await reader.ReadAsync(PairKey)).Value!.Value.Should().Be(new Pair("second", "changed")); + setting.SoftDelete(new SystemClock().UtcNow, UserId.SystemActor); + await db.SaveChangesAsync(); + (await reader.ReadAsync(PairKey)).Value!.IsPresent.Should().BeFalse(); + (await reader.ReadAsync(new TenantSettingKey("tz"))).IsFailure.Should().BeTrue(); + }); + } + + [Fact] + public async Task Branding_registration_stays_tenant_wide_and_returns_a_complete_validated_palette() + { + var context = new Context(TenantId.From(SchemaFixture.TenantA), OrganizationId.From(SchemaFixture.OrgA1)); + await ReadInScopeAsync(context, async (db, unit, services) => + { + await SchemaQueries.SetSettingAsync(unit.Connection, unit.Transaction!, "app.organization_id", ""); + var tenant = Add(db, context, BrandingThemeRegistry.SettingKey, Theme, null); + await db.SaveChangesAsync(); + await SchemaQueries.SetSettingAsync(unit.Connection, unit.Transaction!, "app.organization_id", context.OrganizationId!.Value.Value.ToString()); + Add(db, context, BrandingThemeRegistry.SettingKey, "{}", context.OrganizationId); + await db.SaveChangesAsync(); + // Resolve the actual Seeder composition registration, not a second test graph. + var reader = services.GetRequiredService(); + (await reader.ReadAsync(TenantSettingKeys.BrandingTheme)).Value!.Value.Should() + .Be(new BrandingTheme("#2345aa", "#ffffff", "#111111", "#555555")); + await SchemaQueries.SetSettingAsync(unit.Connection, unit.Transaction!, "app.organization_id", ""); + tenant.SetValue("{}", new SystemClock(), UserId.SystemActor); + await db.SaveChangesAsync(); + await SchemaQueries.SetSettingAsync(unit.Connection, unit.Transaction!, "app.organization_id", context.OrganizationId!.Value.Value.ToString()); + (await reader.ReadAsync(TenantSettingKeys.BrandingTheme)).IsFailure.Should().BeTrue(); + }); + } + + [Fact] + public async Task Malformed_key_values_are_refused_and_a_detached_context_is_not_admitted() + { + var context = new Context(TenantId.From(SchemaFixture.TenantA)); + await ReadInScopeAsync(context, async (db, unit, services) => + { + var reader = services.GetRequiredService(); + foreach (var value in new[] { null, "" }) + { + var refused = await reader.ReadAsync(new TenantSettingKey(value!)); + refused.Error!.Code.Should().Be("validation_failed"); + refused.Error.Details!["Setting"].Single().Key.Should().Be("lockey_invalid_value"); + } + await using var detached = new TenancyDbContext(new DbContextOptionsBuilder() + .UseNpgsql(unit.Connection).Options, new StaticTenantContextAccessor(context)); + var detachedReader = new TenantSettingsAccessor(detached, context, unit, TenantSettingRegistry.Default); + Func read = () => detachedReader.ReadAsync(TenantSettingKeys.BrandingTheme); + await read.Should().ThrowAsync(); + // Positive control: the same announced unit admits its enlisted context. + (await reader.ReadAsync(TenantSettingKeys.BrandingTheme)).IsSuccess.Should().BeTrue(); + }); + } + + [Fact] + public async Task Missing_announcement_and_cancellation_are_loud_before_a_settings_query() + { + await using var source = NpgsqlDataSource.Create(schema.Postgres.AppConnectionString); + var context = new Context(TenantId.From(SchemaFixture.TenantA)); + await using var provider = SeedComposition.Build(source, context, NullLoggerFactory.Instance); + await using var scope = provider.CreateAsyncScope(); + var unit = scope.ServiceProvider.GetRequiredService(); + await using var frame = await unit.BeginTransactionAsync(); + var reader = scope.ServiceProvider.GetRequiredService(); + Func read = () => reader.ReadAsync(TenantSettingKeys.BrandingTheme); + await read.Should().ThrowAsync(); + await unit.SetTenantContextAsync(context); + using var cancelled = new CancellationTokenSource(); + cancelled.Cancel(); + read = () => reader.ReadAsync(TenantSettingKeys.BrandingTheme, cancelled.Token); + await read.Should().ThrowAsync(); + await frame.FailAsync(); + read = () => reader.ReadAsync(TenantSettingKeys.BrandingTheme); + await read.Should().ThrowAsync(); + } + + private async Task ReadInScopeAsync(Context context, Func act) + { + await using var source = NpgsqlDataSource.Create(schema.Postgres.AppConnectionString); + await using var provider = SeedComposition.Build(source, context, NullLoggerFactory.Instance); + await using var scope = provider.CreateAsyncScope(); + var unit = scope.ServiceProvider.GetRequiredService(); + await using var frame = await unit.BeginTransactionAsync(); + await unit.SetTenantContextAsync(context); + var db = scope.ServiceProvider.GetRequiredService(); + await act(db, unit, scope.ServiceProvider); + await frame.FailAsync(); + } + + private static TenantSetting Add(TenancyDbContext db, Context context, string key, string value, OrganizationId? org) + { + var setting = TenantSetting.Create(TenantSettingId.From(Guid.CreateVersion7()), context.TenantId, org, + key, value, new SystemClock(), UserId.SystemActor); + db.TenantSettings.Add(setting); + return setting; + } + + private static TenantSettingsAccessor PairReader(TenancyDbContext db, Context context, IUnitOfWork unit) => + new(db, context, unit, new TenantSettingRegistry( + [new TenantSettingRegistration(PairKey, true, ReadPair)])); + + private static Result ReadPair(string value) + { + using var document = JsonDocument.Parse(value); + var root = document.RootElement; + return root.TryGetProperty("left", out var left) && root.TryGetProperty("right", out var right) + ? Result.Ok(new Pair(left.GetString()!, right.GetString()!)) + : Result.Fail(new Error(new LocalizedMessage("lockey_validation_failed"))); + } + + private static Result ReadText(string value) => Result.Ok(new Text(JsonSerializer.Deserialize(value)!)); + + private static async Task AssertAppRoleAsync(IUnitOfWork unit) + { + await using var command = new NpgsqlCommand("SELECT current_user, rolsuper, rolbypassrls FROM pg_roles WHERE rolname = current_user", + (NpgsqlConnection)unit.Connection, (NpgsqlTransaction)unit.Transaction!); + await using var row = await command.ExecuteReaderAsync(); + (await row.ReadAsync()).Should().BeTrue(); + row.GetString(0).Should().Be("learnstack_app"); + row.GetBoolean(1).Should().BeFalse(); + row.GetBoolean(2).Should().BeFalse(); + } + + private sealed record Pair(string Left, string Right); + private sealed record Text(string Value); + private sealed record Context(TenantId TenantId, OrganizationId? OrganizationId = null) : ITenantContext + { + public bool IsResolved => true; + public UserId? UserId => null; + public TenantContextOrigin? Origin => TenantContextOrigin.Ambient; + public string? CorrelationId => null; + public string? ModuleName => "tenancy"; + } +} diff --git a/backend/tests/LearnStack.Tests.Integration/Database/WriteStoreConflictTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/WriteStoreConflictTests.cs index 232f2856..fdef0e36 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/WriteStoreConflictTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/WriteStoreConflictTests.cs @@ -3,6 +3,7 @@ using LearnStack.Modules.Customization.Application.Abstractions; using LearnStack.Modules.Customization.Domain; using LearnStack.Modules.Customization.Infrastructure.Persistence; +using LearnStack.Modules.Customization.Infrastructure.Projections; using LearnStack.SharedKernel.Identifiers; using LearnStack.SharedKernel.Localization; using LearnStack.SharedKernel.Persistence; @@ -215,6 +216,7 @@ private ServiceProvider BuildProvider() ?? UnresolvedTenantContext.Instance); services.AddScoped(); services.AddModuleDbContext(); + services.AddScoped(); services.AddScoped(); return services.BuildServiceProvider(); } diff --git a/backend/tests/LearnStack.Tests.Unit/Infrastructure/Caching/InMemoryCacheServiceTests.cs b/backend/tests/LearnStack.Tests.Unit/Infrastructure/Caching/InMemoryCacheServiceTests.cs index cba814b6..74b7c67d 100644 --- a/backend/tests/LearnStack.Tests.Unit/Infrastructure/Caching/InMemoryCacheServiceTests.cs +++ b/backend/tests/LearnStack.Tests.Unit/Infrastructure/Caching/InMemoryCacheServiceTests.cs @@ -294,7 +294,9 @@ public async Task The_Flight_Owners_Ttl_Is_The_One_Stored() [Theory] [InlineData("platform:hub:host-map:school.example.com", "hub:host-map")] [InlineData("platform:tenancy:killswitch", "tenancy:killswitch")] - public async Task The_Two_Platform_Families_Report_As_Themselves(string key, string expected) + [InlineData("11111111-1111-7111-8111-111111111111:customization:content-types:v123", "customization:content-types")] + [InlineData("11111111-1111-7111-8111-111111111111:customization:taxonomies:v456", "customization:taxonomies")] + public async Task Platform_And_Customization_Families_Report_As_Themselves(string key, string expected) { // This returned "hub:host-map" for ANY key under the sentinel, without looking at // segments 1 and 2 — right while the host map was the only platform family, and diff --git a/backend/tests/LearnStack.Tests.Unit/Modules/Tenancy/TenantSettingsTests.cs b/backend/tests/LearnStack.Tests.Unit/Modules/Tenancy/TenantSettingsTests.cs new file mode 100644 index 00000000..d17fc3b2 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Unit/Modules/Tenancy/TenantSettingsTests.cs @@ -0,0 +1,33 @@ +using FluentAssertions; +using LearnStack.Modules.Tenancy.Application.Branding; +using LearnStack.Modules.Tenancy.Application.Contracts.Settings; +using LearnStack.Modules.Tenancy.Application.Settings; +using Xunit; + +namespace LearnStack.Tests.Unit.Modules.Tenancy; + +public sealed class TenantSettingsTests +{ + [Fact] + public void Branding_read_uses_the_same_whole_theme_contrast_and_grammar_as_authoring() + { + const string valid = """{"primary":"#2345AA","background":"#FFFFFF","foreground":"#111111","muted":"#555555"}"""; + BrandingThemeRegistry.Read(valid).Value.Should().Be(new BrandingTheme("#2345aa", "#ffffff", "#111111", "#555555")); + BrandingThemeRegistry.Read(valid.Replace("#111111", "#FFFFFF", StringComparison.Ordinal)).IsFailure.Should().BeTrue(); + BrandingThemeRegistry.Read("{}").IsFailure.Should().BeTrue(); + } + + [Fact] + public void Registry_requires_both_registered_key_and_exact_value_type() + { + var registry = TenantSettingRegistry.Default; + registry.Find(TenantSettingKeys.BrandingTheme)!.AllowsOrganizationScope.Should().BeFalse(); + registry.Find(new TenantSettingKey(TenantSettingKeys.BrandingTheme.Value)).Should().BeNull(); + registry.Find(new TenantSettingKey("private.unknown")).Should().BeNull(); + registry.Find(new TenantSettingKey(null!)).Should().BeNull(); + registry.Find(new TenantSettingKey("")).Should().BeNull(); + ((Action)(() => registry.Find(null!))).Should().Throw(); + BrandingThemeRegistry.SettingKey.Should().Be(TenantSettingKeys.BrandingTheme.Value); + new TenantSettingRead(null).IsPresent.Should().BeFalse(); + } +} diff --git a/backend/tests/LearnStack.Tests.Unit/SharedKernel/Localization/LocalizedTextTests.cs b/backend/tests/LearnStack.Tests.Unit/SharedKernel/Localization/LocalizedTextTests.cs index 71792aa1..95ccb394 100644 --- a/backend/tests/LearnStack.Tests.Unit/SharedKernel/Localization/LocalizedTextTests.cs +++ b/backend/tests/LearnStack.Tests.Unit/SharedKernel/Localization/LocalizedTextTests.cs @@ -17,6 +17,30 @@ namespace LearnStack.Tests.Unit.SharedKernel.Localization; /// public sealed class LocalizedTextTests { + [Theory] + [InlineData("TR-tr", "tr-TR", "regional")] + [InlineData("zh-Hant-TW", "zh-Hant", "script")] + [InlineData("zh-Hans-CN", "zh", "language")] + [InlineData("de", "fr", "default")] + public void Locale_carrying_resolution_preserves_exact_narrowing_and_default_order( + string requested, string locale, string value) + { + var text = LocalizedText.From(("tr-TR", "regional"), ("zh-Hant", "script"), + ("zh", "language"), ("fr", "default"), ("en", "platform")); + text.ResolveWithLocale(requested, ["fr", "en"]).Should().Be(new ResolvedLocalizedText(value, locale)); + text.Resolve(requested, ["fr", "en"]).Should().Be(value); + } + + [Fact] + public void Display_fallback_keeps_default_candidates_exact_and_terminal_order_ordinal() + { + var text = LocalizedText.From(("tr-TR", "regional"), ("fr", "French"), ("de", "German")); + // Neither the requested language nor the exact regional default widens/narrows. + text.ResolveWithLocale("tr", ["fr-FR", "en"]).Should().Be(new ResolvedLocalizedText("German", "de")); + var platform = LocalizedText.From(("fr", "French"), ("en", "English")); + platform.ResolveWithLocale("de", ["tr-TR", "EN"]).Should().Be(new ResolvedLocalizedText("English", "en")); + } + [Fact] public void Locales_are_canonicalized_so_two_spellings_cannot_be_two_entries() { diff --git a/docs/architecture/02-domain-model.md b/docs/architecture/02-domain-model.md index 591b93e9..79006bc8 100644 --- a/docs/architecture/02-domain-model.md +++ b/docs/architecture/02-domain-model.md @@ -220,7 +220,7 @@ flowchart LR | `Organization` | Yes | Sub-unit within a tenant (branch, studio, campus, department, cohort). Two-level hierarchy strict (ADR-0017). Every tenant has at least one default org. | | `TenantDomain` | Yes | Subdomain on `{slug}.learnstack.app` (always available) or custom domain (Hub-managed; see [27-custom-domain-tls.md](27-custom-domain-tls.md)). | | `TenantBranding` | No — not an entity; the values are `TenantSetting` rows ([Frontend Architecture Standards § Tenant Branding](../standards/07-frontend-architecture.md#tenant-branding)) | Logo, colors, typography tokens. May be overridden per-organization via `OrganizationBranding`. | -| `OrganizationBranding` | Inside Organization | Optional partial design-token override (logo / colors / typography) merged on top of `TenantBranding` at render time. When the resolved request carries an organization id and a row exists, the merged token set is injected as CSS variables on the SSR'd HTML root; missing fields fall through to the tenant default. See [Glossary § Branding](../glossary.md). | +| `OrganizationBranding` | Inside Organization | **Phase 06 target; not implemented by P02d-2/3.** Optional partial design-token override (logo / colors / typography) merged on top of `TenantBranding` at render time. When the resolved request carries an organization id and a row exists, the merged token set is injected as CSS variables on the SSR'd HTML root; missing fields fall through to the tenant default. See [Glossary § Branding](../glossary.md). | | `TenantFeatureFlag` | Inside Tenant | Experimental / gradual-rollout flags. Plan-level features are surfaced via the entitlement projection (ADR-0021), not stored here. See [21-feature-flags.md](21-feature-flags.md). | | `TenantLocale` | Inside Tenant | The locales a tenant publishes in ([ADR-0008](../decisions/0008-localization-schema.md)). Composite key `(tenant_id, locale)`, no surrogate id; exactly one row is the default. | | `TenantSetting` | Yes | Timezone, default notification sender, content settings. | diff --git a/docs/architecture/09-tenant-isolation.md b/docs/architecture/09-tenant-isolation.md index 52cd0202..295299f7 100644 --- a/docs/architecture/09-tenant-isolation.md +++ b/docs/architecture/09-tenant-isolation.md @@ -37,7 +37,7 @@ two scopes (tenant + organization): | EF Core | Global query filter `e.TenantId == currentTenantId` | Global query filter `e.OrganizationId == null OR e.OrganizationId == currentOrgId` | | PostgreSQL | The tenant term of the single policy: `tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid` | The organization term `AND`-ed into that **same** policy, plus the restrictive `UPDATE` / `DELETE` write guards. Canonical SQL in [Database Standards](../standards/05-database.md) | | Identity | Single-realm `learnstack` with `tenant_id` JWT claim (default per [ADR-0004](../decisions/0004-authentication-strategy.md); realm-per-tenant is an opt-in for enterprise isolation only) | `organization_id` JWT claim populated from active org membership | -| Cache | The caller composes `{tenant_id}:{module}:{logical-name}` with `CacheKey.ForTenant`; every `ICacheService` implementation validates the key and prefixes nothing | `{tenant_id}:{org_id}:{module}:{logical-name}` with `CacheKey.ForOrganization`, for a value scoped to one organization. How the settings accessor keys a read whose rows depend on the session's organization is G23 in [Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register); the decision pass that closes it edits this row if its answer changes it | +| Cache | The caller composes `{tenant_id}:{module}:{logical-name}` with `CacheKey.ForTenant`; every `ICacheService` implementation validates the key and prefixes nothing | `{tenant_id}:{org_id}:{module}:{logical-name}` with `CacheKey.ForOrganization`, for a value scoped to one organization. Settings reads are uncached in P02d-2/3 under [Accepted G23](../roadmap/phase-02d-walking-skeleton.md#p02d-3-decision-package-2026-10-02); no settings cache key is active | | Files (SeaweedFS) | Object key prefix `tenants/{tenant_id}/...` | `tenants/{tenant_id}/organizations/{org_id}/...` for org-scoped assets | | Search | `tenant_id` as a mandatory filter composed **inside** `ITenantSearch` — callers pass criteria, never filter strings. Until Meilisearch's demand gate fires ([ADR-0035](../decisions/0035-demand-gated-infrastructure.md)), search runs on PostgreSQL full-text over tenant-owned tables and inherits Row Level Security; the engine-enforced per-request tenant token arrives with the Meilisearch adapter in [Phase 09](../roadmap/phase-09-billing-integrations-analytics.md) | `organization_id = X OR organization_id IS NULL` clause when org context | | Jobs (Hangfire) | `JobParams.TenantId` mandatory | `JobParams.OrganizationId` nullable | @@ -256,6 +256,16 @@ tenants/{tenant_id}/brand/... ← tenant- ### Cache (Dapr State Store / Valkey) +P02d-3's Accepted internal Customization cache is tenant-wide and untranslated; +its generation and ambient fill rules live in +[Cache strategy](32-tenant-customization-model.md#82-cache-strategy). +The typed settings accessor is uncached and explicitly selects tenant-wide/current +organization rows even if a future tenant-scope hatch widens RLS reads. The +[Tenancy contract](../modules/tenancy/README.md#p02d-3-accepted-typed-settings-contract) +owns whole-value precedence and tenant-wide branding. Step 1 implements the settings +reader; Step 2 implements the Customization projection, with both reviews passed. +Step 3 adds generation caching and scope-safe bypass; both review rounds passed. + ``` {tenant_id}:{org_id}:{module}:{logical-name} ← a value scoped to one organization {tenant_id}:{module}:{logical-name} ← a tenant-wide value diff --git a/docs/architecture/12-localization.md b/docs/architecture/12-localization.md index 0135fa54..0014fb5c 100644 --- a/docs/architecture/12-localization.md +++ b/docs/architecture/12-localization.md @@ -176,20 +176,16 @@ CREATE TABLE levels ( ); ``` -The application reads with a helper that performs fallback: +Stored JSON materializes as `LocalizedText`; use its locale-carrying resolver, +not a second fallback implementation: ```csharp -public static string Resolve(JsonElement localizedField, string requestedLocale, IReadOnlyList fallbackChain) -{ - if (localizedField.TryGetProperty(requestedLocale, out var direct) && direct.ValueKind == JsonValueKind.String) - return direct.GetString()!; - foreach (var fb in fallbackChain) - if (localizedField.TryGetProperty(fb, out var fbVal) && fbVal.ValueKind == JsonValueKind.String) - return fbVal.GetString()!; - return string.Empty; -} +ResolvedLocalizedText resolved = label.ResolveWithLocale( + requestedLocale, [tenantDefaultLocale, "en"]); ``` +[Fallback Rules](#fallback-rules) owns the chain and terminal behavior. + Pattern B is cheaper for short fields where joining a translation table is overkill, and avoids N+1 issues when listing many rows. Use it for short, atomic, mostly-required strings. ### Choosing Between Patterns @@ -203,21 +199,28 @@ Pattern B is cheaper for short fields where joining a translation table is overk ## Fallback Rules -> **Open in Phase 02d.** This chain and the one in -> [Localization Standards § Locale Model](../standards/08-localization.md#locale-model) -> differ. Which is canonical, and the terminal state of a missing field or label, are -> G24 in -> [Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register). +**G24 Accepted — 2026-10-02.** This section owns display fallback under +[ADR-0008](../decisions/0008-localization-schema.md); the standard links here. +[P02d-3's decision package](../roadmap/phase-02d-walking-skeleton.md#p02d-3-decision-package-2026-10-02) +records acceptance. Step 1 implements locale-carrying resolution; both review +rounds passed. Public response fields and language attributes remain P02d-4/6. When the requested locale is unavailable: 1. Try the requested locale (e.g. `tr-TR`). -2. Try the language part of the requested locale (`tr`). -3. Try the tenant's default locale. -4. Try the platform default (`en`). -5. Return an empty string or a marked placeholder (e.g. `[Untranslated]` in development, empty in production). - -The fallback chain is computed once per request and reused. +2. Narrow one subtag at a time (`zh-Hant-TW` → `zh-Hant` → `zh`). Never widen. +3. Try the exact tenant default; do not implicitly narrow that candidate. +4. Try the exact platform default (`en`). +5. Pattern B labels end at the first authored canonical locale key in ordinal + order, matching `LocalizedText`. Return the actual resolved locale with its value. + Nullable Pattern A display fields end absent instead; required fields retain + their exact authored translation. + +Supply the tenant default once per batch/request. The string-returning +`LocalizedText.Resolve` remains a compatible wrapper. This chain never locates a +different URL, slug or lesson body; content-locale admission is independent. +P02d-4 owns field-level public response applicability and locale metadata; +P02d-6 owns language attributes and page states. ## Slugs and URLs diff --git a/docs/architecture/32-tenant-customization-model.md b/docs/architecture/32-tenant-customization-model.md index b235a85a..89030e00 100644 --- a/docs/architecture/32-tenant-customization-model.md +++ b/docs/architecture/32-tenant-customization-model.md @@ -525,21 +525,18 @@ per tenant per month. That ratio is the whole design. | What | Layer | Key | TTL | Invalidated by | |---|---|---|---|---| -| `TenantContentType` set for a tenant | L1 + L2 | `{tenant_id}:customization:content-types-v{generation}` | L1 60s, L2 15 min | Generation bump | -| `TenantLevelTaxonomy` by key | L1 + L2 | `{tenant_id}:customization:taxonomy-{key}-v{generation}` | same | Generation bump | -| `TenantPageBlock` set | L1 + L2 | `{tenant_id}:customization:blocks-v{generation}` | same | Generation bump | - -These are composed with `CacheKey.ForTenant(tenantId, "customization", logicalName)`, and the -shape is not cosmetic: the tenant segment comes **first**, per -[Standards 20 § `ICacheService`](../standards/20-infrastructure-stack.md), and -`CacheKey.EnsureValid` throws on anything else. An earlier version of this table led -each key with `cust:` — module first — which would have thrown at the first call. - -The generation is folded into the *logical-name* segment rather than added as a fourth -one, because `CacheKey` forbids a `:` inside any single component: a separator that can -appear inside a component makes two different key tuples collide. The same rule applies -to `{key}`, which is tenant-supplied — the caller validates or encodes it before -composing, and a `:` in it is rejected rather than silently widening the key space. +| `TenantContentType` eligible revision set | L1; L2 on Phase 11's trigger | `{tenant_id}:customization:content-types:v{generation}` | L1 60s, future L2 15 min | Fresh generation probe | +| `TenantLevelTaxonomy` eligible revision set, including bands | same | `{tenant_id}:customization:taxonomies:v{generation}` | same | Fresh generation probe | +| `TenantPageBlock` set (Phase 04; not implemented) | L1 + L2 target | `{tenant_id}:customization:blocks-v{generation}` | same | Generation bump | + +**G12 cache/G22 Accepted — 2026-10-02.** P02d-3 Step 3 implements the +coherent loader, cache and dirty-scope bypass; both review rounds passed. +The first two keys use `CacheKey.ForTenant(tenantId, "customization", family, +$"v{generation}")`: generation is a separate component, never a `:` inside one. +Both cache immutable, untranslated Active and Deprecated nondeleted revisions, +indexed by exact `(key, schema_version)`. Drafts are excluded; missing pins never +adopt Active. Stable metric families are `customization:content-types` and +`customization:taxonomies`. The writer reader remains uncached and purpose-aware. Two rules make this safe: @@ -569,22 +566,36 @@ Two rules make this safe: [ADR-0043 § 6](../decisions/0043-customization-payload-validation.md) records the measurements and deletes it. The adapter compiles per call. -Cache misses cost one indexed query per tenant per definition set. A cold pod serving its -first request for a tenant performs at most three such queries, not one per entry. - -> **Open in Phase 02d.** This section owns the families, their keys and the generation -> rule, and Phase 02d builds the first loader against them. What a key carries for a -> lesson's bound revision is G12. The rest is G22: whether the loader runs in the -> request's transaction; how a request learns the generation, and in what order it reads -> it and the rows; what an absent row means, since a tenant that has never had a -> customization has none; what keeps an entry filled inside a transaction that bumped -> and rolled back unreachable; what the TTLs bound; how the adapter's `cache.name` -> mapping matches a generation-embedded name; and how many statements a public read -> issues, which the count above states without a generation read. Both are in -> [Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register), -> and the pass that closes each edits this section with its answer. The -> `TenantPageBlock` family is [Phase 04](../roadmap/phase-04-cms-media-pages.md)'s, with -> its aggregate. +The internal batched projection runs on the caller's announced ambient transaction, +without a new setter or transaction mode. Probe the durable generation on every +batch: a fully warm read issues one SELECT. A cold/partial/fault read issues at +most two SELECTs: the probe and one statement loading generation plus both sets +from the same PostgreSQL snapshot. Use the latter generation for the entire +result/fill; discard earlier hits if it changed. Explicit tenant predicates and +RLS both apply. No generation memo or out-of-band loader is permitted. + +An absent counter and empty definitions return an uncached empty projection. +Definitions without a counter are a configuration refusal, never a cache fill. +Present-counter empty sets are valid. A scoped dirty flag is set before every +supported store mutation/generation bump. Dirty or rollback-only scopes bypass +both cache reads and fills; the flag stays set for the DI scope. Reads do not +flush pending tracked mutations. This prevents speculative values becoming +reachable when a rolled-back generation is reissued. + +Await cache get/load/set in the caller's lifetime; never capture the ambient loader +in the shared `GetOrSetAsync` factory. Cold callers may load independently. Cache +faults fall back to the bounded database load, cancellation propagates, and DB +failures are not cache misses. TTLs reclaim stranded keys; fresh generation probes +provide invalidation across independent L1 instances without events or L2. + +The module spec owns result/missing-member semantics and measured read budgets. +[P02d-3's decision package](../roadmap/phase-02d-walking-skeleton.md#p02d-3-decision-package-2026-10-02) +records proof obligations. Family-wide volume includes retained revisions; measure +rows/bytes and query plans as recorded in the +[delivery measurements](../roadmap/phase-02d-walking-skeleton.md#step-3-generation-cache-and-read-safety), +and reassess before Phase 04's larger workload. +The `TenantPageBlock` family remains +[Phase 04](../roadmap/phase-04-cms-media-pages.md) work. ### 8.3 The N+1 problem, and the limits that bound it diff --git a/docs/glossary.md b/docs/glossary.md index fe03ef7c..ecb1e8b8 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -140,11 +140,14 @@ This glossary defines LearnStack-specific terms. When a term is ambiguous across ## Extension Model -P02d-2 Step 1 implements these contextual contracts and metadata validation: +These entries distinguish P02d-2 contracts from P02d-3 internal read contracts: | Term | Definition | |---|---| | **`IExactCustomizationDefinitionReader`** | Contextual uncached application reader for exact content-type/taxonomy revision values, with NewBinding versus ExistingPin eligibility; [Customization spec](modules/customization/README.md#p02d-2-accepted-exact-write-contract). | +| **`ICustomizationDefinitionProjectionReader`** | Accepted P02d-3 internal batched display reader for exact tenant-owned revision pins, with immutable generation-keyed definition families. Step 3 implements coherent loading, generation caching and scope-safe bypass; both review rounds passed. Not a public API; the [decision package](roadmap/phase-02d-walking-skeleton.md#p02d-3-decision-package-2026-10-02) records acceptance on 2026-10-02. | +| **`ResolvedLocalizedText`** | An immutable display label paired with its actual authored canonical locale, following [Localization § Fallback Rules](architecture/12-localization.md#fallback-rules). P02d-3 adds this internal metadata; public response fields and page language attributes remain P02d-4/6. | +| **`ITenantSettingsAccessor`** | Accepted P02d-3 typed, uncached ambient settings reader with registered scope/grammar and explicit organization precedence. Step 1 implements the reader; the [Tenancy contract](modules/tenancy/README.md#p02d-3-accepted-typed-settings-contract) owns its scope. Both review rounds passed. | | **`ITenantLocaleEligibilityReader`** | Contextual uncached Tenancy contract for canonical enabled locale membership and valid locale configuration; [Tenancy spec](modules/tenancy/README.md#p02d-2-accepted-locale-and-branding-contract). | | **`x-fields`** | Optional root JSON Schema array of ordered property names and Pattern-B labels for the bounded text-card profile; [ADR-0051](decisions/0051-ordered-text-card-presentation.md). It is metadata, not a schema or a new renderer primitive. | @@ -196,7 +199,7 @@ P02d-2 Step 1 implements these contextual contracts and metadata validation: |------|------------| | **TenantBranding** | The tenant's presentation tokens. Not an aggregate of its own: the values are tenant settings held in `tenant_settings` ([Frontend Architecture Standards § Tenant Branding](standards/07-frontend-architecture.md#tenant-branding)). G16(a–e) is Accepted and delivered in P02d-2; the [Tenancy contract](modules/tenancy/README.md#p02d-2-accepted-locale-and-branding-contract) owns the keys and value grammar. Anonymous projection and entitlement/attribution remain G16(f/g), and server-rendered injection remains G42, in [Phase 02d's decision register](roadmap/phase-02d-walking-skeleton.md#the-decision-register). | | **`branding.theme`** | P02d-2 delivers the writer for a single tenant-wide TenantSetting document with four validated color fields; one root/version protects contrast during concurrent replacement. The [Tenancy spec](modules/tenancy/README.md#whole-theme-setting-and-public-boundary) owns the command-local registry; other generic setting keys remain legal. Anonymous projection and rendering remain P02d-4 and P02d-6. | -| **OrganizationBranding** | An optional override row attached to an `Organization` that supplies a partial design-token set. When the resolved request carries an organization id, the runtime merges `OrganizationBranding` on top of `TenantBranding` before injecting tokens; missing fields fall through to the tenant default. | +| **OrganizationBranding** | A planned per-organization design-token override owned by [Phase 06](roadmap/phase-06-renderer-admin-studio.md). Its partial token merge is not implemented by P02d-2/3; the generic settings scope model does not authorize an organization override of `branding.theme`. | ## Module-Loading Contracts diff --git a/docs/modules/customization/README.md b/docs/modules/customization/README.md index 74e45dfb..cd7bc6ed 100644 --- a/docs/modules/customization/README.md +++ b/docs/modules/customization/README.md @@ -3,7 +3,7 @@ **Status:** Design stable, partially implemented (Phase 02a Packet 8 shipped the two aggregates, the schema and its isolation, the payload gate, and the write path; P02d-2 Step 1 adds contextual exact-definition reads and metadata validation. -Public projections and their generation-keyed cache follow in P02d-3/4 +P02d-3 adds internal generation-cached display reads; public consumers follow in P02d-4 in [Phase 02d](../../roadmap/phase-02d-walking-skeleton.md), and the Admin Studio editors with the phases that consume them). @@ -190,13 +190,23 @@ absence rather than substituting an unrelated diagram for it. ### Primary read flow: resolving a tenant's shapes -The public read projection is not implemented. It is keyed on -`customization_generations.generation`, so every write strands every stale key at -once across every pod without enumerating anything — -[§ 8.2](../../architecture/32-tenant-customization-model.md) has the design and -[ADR-0043 § 6](../../decisions/0043-customization-payload-validation.md) deletes -the compiled-validator cache that used to sit beside it. It lands with its first -consumer in [Phase 02d](../../roadmap/phase-02d-walking-skeleton.md). +**P02d-3 Step 3 implemented — 2026-10-02; both review rounds passed.** +`ICustomizationDefinitionProjectionReader` resolves batched exact revision pins +through ADR-0010's application-contract mechanism. Values are immutable; no public +table, schema validation, HTTP endpoint or write is introduced. Active/Deprecated +nondeleted definitions are eligible; missing individual pins remain distinguishable +without failing unrelated members or substituting another revision. Labels resolve +per call with actual locale metadata from the caller's display-locale context. +The public response/refusal and page state remain P02d-4/6. The coherent loader supplies +generation-keyed families; dirty or rollback-only +scopes bypass their cache. The writer reader remains uncached. + +[Cache strategy § 8.2](../../architecture/32-tenant-customization-model.md#82-cache-strategy) +owns family keys, ambient snapshot loading, dirty-scope bypass, fault/cancellation +behavior and freshness. The two families include eligible retained revisions and +immutable bands; the writer's exact-purpose reader below stays uncached. +[P02d-3's accepted package](../../roadmap/phase-02d-walking-skeleton.md#p02d-3-decision-package-2026-10-02) +owns implementation steps and proof obligations. Public consumers arrive P02d-4. ## P02d-2 accepted exact write contract @@ -283,8 +293,8 @@ In [audit.md](audit.md), the file | Path | Budget | Why this number | |---|---|---| -| Resolve a tenant's live definitions (cache hit) | **< 1 ms** | On every render of every page | -| Resolve a tenant's live definitions (cache miss) | **< 20 ms** p95 | Two indexed reads on partial unique indexes. How a request learns the generation, in what order the loader reads it and the rows, and how many statements a read issues are G22 in [Phase 02d's decision register](../../roadmap/phase-02d-walking-skeleton.md#the-decision-register); the pass that closes it edits this row and the cache-hit row with its answer | +| Resolve batched definitions (warm) | **< 1 ms** for in-memory resolution, excluding SQL probe | One fresh generation SELECT; no definition query. End-to-end timing measured separately in P02d-3 | +| Resolve batched definitions (cold/partial/fault) | **< 20 ms** production p95 target, not proven by the local sample | At most two SELECTs: probe plus coherent generation/rows snapshot; [seeded measurements](../../roadmap/phase-02d-walking-skeleton.md#step-3-generation-cache-and-read-safety) report rows/bytes and query plans, not production p95 | | Admit a tenant-authored schema (four gates) | **< 50 ms** p95 | Interactive, on save, and rare | | Validate one instance at the § 8.4 caps | **742 ms, 1.6 GB** | Measured worst case, not a budget — see below | | Publish a successor (2 reads, 2 updates, 1 upsert) | **< 100 ms** p95 | Interactive but rare | diff --git a/docs/modules/customization/audit.md b/docs/modules/customization/audit.md index 7ce92d0d..d57b3b1d 100644 --- a/docs/modules/customization/audit.md +++ b/docs/modules/customization/audit.md @@ -3,6 +3,12 @@ Per [Audit Coverage](../../standards/18-audit-coverage.md), which names this file. Part of the [module spec](README.md). +**P02d-3 Step 3 implemented — 2026-10-02; both review rounds passed.** +`ICustomizationDefinitionProjectionReader` is an internal application interface, not a +MediatR request or audited write. It creates no intent or business-state mutation. +Any production request introduced for it must be audit Off; test-only requests +stay test-only. [The module contract](README.md#primary-read-flow-resolving-a-tenants-shapes) owns the read. + Four of the operations below now exist, all written by Phase 02a Packet 8's handlers: `ContentType` register and publish, and `LevelTaxonomy` register and publish. Eight more rows are classification ahead of code and carry `(planned)` in the diff --git a/docs/modules/customization/permissions.md b/docs/modules/customization/permissions.md index 2d476265..0d9a5d89 100644 --- a/docs/modules/customization/permissions.md +++ b/docs/modules/customization/permissions.md @@ -3,6 +3,11 @@ Per [Permission Standards](../../standards/19-permissions.md), which names this file. Part of the [module spec](README.md). +**P02d-3 Step 3 implemented — 2026-10-02; both review rounds passed.** +`ICustomizationDefinitionProjectionReader` is internal and unrouted, uses trusted +ambient scope and adds no HTTP endpoint or permission key. Public admission belongs to +P02d-4. [The module contract](README.md#primary-read-flow-resolving-a-tenants-shapes) owns its scope. + **No permission keys yet.** The matrix below is a forward declaration in the `{module}.{resource}.{action}` form with the closed action set of [Permission Standards](../../standards/19-permissions.md). Registration runs diff --git a/docs/modules/education/README.md b/docs/modules/education/README.md index 54fd6768..2eb78688 100644 --- a/docs/modules/education/README.md +++ b/docs/modules/education/README.md @@ -11,7 +11,10 @@ The [P02d-2 package](../../roadmap/phase-02d-walking-skeleton.md#p02d-2-decision is Accepted on 2026-10-02. Step 1 implements the course access column and contextual verification queries, explicitly classified Off. Step 3 implements six writers; both review rounds and a focused fix review passed. Step 4 completes the seed after both -review rounds. P02d-2 is verified and ready for PR review; merge remains pending. +review rounds. P02d-2 is complete and merged — 2026-10-02; its +[merge closeout](../../roadmap/phase-02d-walking-skeleton.md#p02d-2-merge-and-closeout-2026-10-02) +records final verification. P02d-3 read internals are complete and unmerged; P02d-4 +public reads are next. The diagram includes the access column. ## Overview diff --git a/docs/modules/tenancy/README.md b/docs/modules/tenancy/README.md index 0a4ca1e6..f2ddeff3 100644 --- a/docs/modules/tenancy/README.md +++ b/docs/modules/tenancy/README.md @@ -162,10 +162,38 @@ settings disclosure. The setting write is MUST; locale writes are SHOULD over th owning Tenant root, including contained locale changes. Contextual seed verification queries are Off. +### P02d-3 accepted typed settings contract + +**Step 1 implemented — 2026-10-02; both review rounds passed.** +`ITenantSettingsAccessor` +exposes registered typed settings under ADR-0010's application-contract mechanism. +No raw string-key/JSON export, settings HTTP surface or caller-supplied scope is +admitted. The first production registration is tenant-wide `branding.theme`, +reusing its four-color grammar and contrast validator above. It returns a complete +typed palette or bounded absent/invalid outcome; public defaults and allowlisting +remain P02d-4, CSS injection P02d-6. + +Registrations are values in an explicit server-owned `TenantSettingRegistry`. +The composition extension installs its default only when no registry was already +registered; a complete replacement registry must be registered before that call. +Individual `ITenantSettingRegistration` DI services are not collected implicitly. + +For a registration permitting organization scope, explicitly select the current +tenant and `(organization_id IS NULL OR organization_id = current organization)`, +excluding soft-deleted rows. No organization selects tenant-wide only. Organization +values replace the whole tenant value, not individual JSON fields; an invalid +selected override returns a configuration refusal. The explicit predicate excludes +siblings even under the future tenant-scope RLS hatch. Synthetic test registrations +prove this generic behavior; `branding.theme` stays tenant-wide in every context. +Organization branding and token merging remain Phase 06. + No settings cache in P02d-2/3: no generation migration, TTL or cross-process stale -entry. P02d-3 implements the typed ambient accessor; it explicitly reads tenant-wide -rows and exact organization rows, then merges in memory. Performance is measured -there, not claimed satisfied by this decision. +entry. The accessor uses the existing announced ambient transaction, without +opening/announcing a new one or retaining a per-scope value snapshot. A subsequent +read observes supported writes under the ambient isolation. Measure the indexed +read/selection budget in P02d-3. The +[accepted package](../../roadmap/phase-02d-walking-skeleton.md#p02d-3-decision-package-2026-10-02) +records the tests and step plan. ## Entity-relationship diagram @@ -380,7 +408,7 @@ resolver reads `platform_host_to_tenant` and nothing else. flowchart LR subgraph Tenancy DOM[Domain
4 aggregate roots] - CON[Application.Contracts
6 write commands, 4 seed queries,
locale eligibility contract] + CON[Application.Contracts
6 write commands, 4 seed queries,
locale eligibility and typed settings] APP[Application
Handlers and validators,
write and read ports] INF[Infrastructure
TenancyDbContext,
4 write stores and filtered readers] end @@ -404,7 +432,8 @@ flowchart LR Text fallback — **components**: Tenancy is four assemblies — `Domain` (the `Tenant`, `Organization`, `TenantDomain` and `TenantSetting` roots), `Application.Contracts` (six write commands, four contextual seed queries and locale -eligibility), `Application` (handlers, validators and module-owned persistence ports) +eligibility and typed settings), `Application` (handlers, validators and module-owned +persistence ports) and `Infrastructure` (`TenancyDbContext`, four write stores and filtered readers). The stores implement `ITenantWriteStore`, `IOrganizationWriteStore`, `ITenantSettingWriteStore` and `IPlatformHostMappingStore`; the context maps nine tables. diff --git a/docs/modules/tenancy/audit.md b/docs/modules/tenancy/audit.md index 50fe6794..78ef483e 100644 --- a/docs/modules/tenancy/audit.md +++ b/docs/modules/tenancy/audit.md @@ -3,6 +3,12 @@ Per [Audit Coverage](../../standards/18-audit-coverage.md), which names this file. Part of the [module spec](README.md). +**P02d-3 Step 1 implemented — 2026-10-02; both review rounds passed.** +`ITenantSettingsAccessor` is an internal application interface, not a +MediatR request or audited write. It creates no intent or business-state mutation. +Any production request introduced for it must be audit Off; test-only requests +stay test-only. [The module contract](README.md#p02d-3-accepted-typed-settings-contract) owns the read. + **P02d-2 Step 2 implemented; both review rounds passed — 2026-10-02.** The [accepted writer contract](README.md#p02d-2-accepted-locale-and-branding-contract) implements the locale diff --git a/docs/modules/tenancy/permissions.md b/docs/modules/tenancy/permissions.md index 672f5126..925ba1f8 100644 --- a/docs/modules/tenancy/permissions.md +++ b/docs/modules/tenancy/permissions.md @@ -3,6 +3,11 @@ Per [Permission Standards](../../standards/19-permissions.md), which names this file. Part of the [module spec](README.md). +**P02d-3 Step 1 implemented — 2026-10-02; both review rounds passed.** +`ITenantSettingsAccessor` is internal and unrouted, uses trusted ambient +scope and adds no HTTP endpoint or permission key. Public admission belongs to +P02d-4. [The module contract](README.md#p02d-3-accepted-typed-settings-contract) owns its scope. + **P02d-2 Step 2 writers — 2026-10-02.** The [locale and branding commands](README.md#p02d-2-accepted-locale-and-branding-contract) are implemented unrouted tenant-wide operations, with no registered permission or diff --git a/docs/roadmap/README.md b/docs/roadmap/README.md index 3d537d24..fcb608ee 100644 --- a/docs/roadmap/README.md +++ b/docs/roadmap/README.md @@ -45,9 +45,14 @@ not deferred to the showcase phase. - [Phase 01: Repository, Tooling, and Local Infrastructure](phase-01-repository-tooling.md) — **complete** - [Phase 02a: Platform Kernel, Multi-Tenancy, Organization, and Foundation Sockets](phase-02a-kernel-tenancy.md) — **complete** (packets 0–3, 3b and 4–10 shipped) - [Phase 02d: Two-Tenant Walking Skeleton](phase-02d-walking-skeleton.md) — - **in progress**; P02d-1 merged, P02d-2 decision pass Accepted 2026-10-02; - all four P02d-2 steps complete after both review rounds and final verification; - PR review/merge pending, P02d-3 read internals next + **in progress**; P02d-1 and P02d-2 complete and merged; the + [P02d-2 closeout](phase-02d-walking-skeleton.md#p02d-2-merge-and-closeout-2026-10-02) + records final verification. P02d-3 read internals are implemented; the + [decision package](phase-02d-walking-skeleton.md#p02d-3-decision-package-2026-10-02) + is Accepted — 2026-10-02; Step 1 is implemented; 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, unmerged and ready for PR review. - [Phase 02b: Events, Background Jobs, Identity, and Session](phase-02b-events-auth.md) - [Phase 03: Identity Domain, Authorization, and Admin Foundation](phase-03-identity-admin.md) - [Phase 04: Headless CMS, Page Builder, and Media Library](phase-04-cms-media-pages.md) diff --git a/docs/roadmap/phase-02d-walking-skeleton.md b/docs/roadmap/phase-02d-walking-skeleton.md index ff21fe2a..87eac235 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -10,8 +10,8 @@ > |---|---|---| > | P02d-0 | Kickoff | ✅ this plan | > | P02d-1 | Education schema and database-level isolation | ✅ complete and merged — 2026-09-14; [merge closeout](#merge-and-closeout-2026-09-14) | -> | P02d-2 | Writers and seed | ✅ implementation complete — 2026-10-02; all four steps reviewed and verified; PR review/merge pending | -> | P02d-3 | Read internals | not started | +> | P02d-2 | Writers and seed | ✅ complete and merged — 2026-10-02; [merge closeout](#p02d-2-merge-and-closeout-2026-10-02) | +> | P02d-3 | Read internals | [decision package](#p02d-3-decision-package-2026-10-02) Accepted; Step 1 reviews passed; [Step 2](#step-2-batched-coherent-definition-reads) reviews passed; [Step 3](#step-3-generation-cache-and-read-safety) implemented; both review rounds passed; ready for PR review, unmerged | > | P02d-4 | Public read API and contract checks | not started | > | P02d-5 | Server-rendering path | not started | > | P02d-6 | Public renderer | not started | @@ -26,7 +26,23 @@ explicit request. ADR-0049 and Phase 09a remain Proposed. **Implementation resumed — 2026-10-02.** The maintainer's implementation request revokes the acceptance-time wait. [Delivery](#p02d-2-implementation-delivery-2026-10-02) records all four completed implementation steps and their two independent review -rounds. P02d-2 is ready for PR review; merge closeout remains pending. P02d-3 is next. +rounds. At that pre-merge milestone, P02d-2 was ready for PR review, its merge +closeout was pending, and P02d-3 was next. + +**Merge complete — 2026-10-02.** The acceptance and implementation notes above +record the pre-merge milestones. P02d-2 is now closed through +[PR #23](https://github.com/HodeTech/LearnStack/pull/23); its +[merge closeout](#p02d-2-merge-and-closeout-2026-10-02) records verification. +At that closeout, Phase 02d remained in progress and P02d-3 was next, with its +decision pass still open. + + +**P02d-3 complete — 2026-10-02, unmerged.** The preceding notes record earlier +milestones. The [decision package](#p02d-3-decision-package-2026-10-02) is Accepted; +all three implementation steps and both fresh review rounds per step are complete. +The [delivery record](#delivery-record-p02d-3) records code, verified fixes and +2637 passing tests. The packet is ready for maintainer PR review. P02d-4 is next: +its public-read decision pass and contracts are not started. ## Goal @@ -317,7 +333,7 @@ premise a row cites is re-verified at that pass rather than trusted. | G9 | Education schema detail: the content slug's character shape, normalization, width and database backstop — including whether a GUID-shaped slug is refused, which G26's shared-slot path needs; whether an Education table holds a foreign key into `tenants`, `organizations` or `tenant_locales`; and each runtime role's privileges on the four tables | `UrlSlug`'s shape with its own width constant and a `ck__slug_format` backstop, since restrictive now is the reversible choice (ASCII-only slugs exclude native-script URLs, a product choice); no foreign key into Tenancy; `learnstack_app` `SELECT, INSERT` plus exactly what G11's commands need, `learnstack_platform` `SELECT` | Detail: Localization Standards § Pattern A for the shape; the Database Standards satellite fence and [§ GRANT matrix](../standards/05-database.md#grant-matrix); § Migrations only if a cross-chain key is chosen | P02d-1 (the creating migration writes the `CHECK` and the grants; the grants couple with G11) | [Accepted — 2026-09-14](#p02d-1-accepted-answers): G9 | | G10 | What is the catalog's default order and tie-breaker, and what is the cursor it mints: its payload and version; what it binds (tenant, organization, locale, sort, filters, endpoint); its integrity (none, a MAC with a key version, or server-side state); its direction; what happens when a row changes between pages; which list parameters the endpoint binds; where it is decoded; whether the codec is this endpoint's or the kernel's; and which cursor classes answer `400`? | The reviews split between a keyless versioned payload with a binding fingerprint, decoded at binding so a garbage cursor opens no transaction, and an HMAC-authenticated cursor with key rotation. Both keep tenant and organization out of the cursor, and bind `CursorPaginationRequest` rather than `ListRequest`, whose `q` is Phase 04's search | Contract: a phase-doc statement if the codec is endpoint-local and keyless; a new ADR if it becomes a kernel rule later lists follow, or a MAC adds a secret and a rotation posture. Detail: [API Standards § Pagination](../standards/04-api-design.md#pagination), which drops "Nothing validates its *shape* yet"; Standards 21 rows | P02d-1 (the order part: an ordering column, publication timestamp or collation), P02d-4 (the codec part) | [Accepted — 2026-09-14](#p02d-1-accepted-answers): order; P02d-4 codec remains open | | G11 | The write surface the seed needs. Which Education commands write courses, lessons and their translations; is a translation written separately from create; is publishing its own command; which command reports a slug collision as `business_rule_violation` rather than a raw unique violation, and does Localization Standards' "from the publish command" still hold? What shape do the Tenancy commands raising `tenancy.locale.write` and `tenancy.setting.write` take? How are the non-baseline writes classified, and how does a re-run converge? | Create course, write course translation, add lesson, write lesson translation, publish course (MUST); one locale command over `Tenant.AddLocale` and `SetDefaultLocale`; a create-or-update setting command keyed on context scope and key; ordering taxonomy → content type → course → lessons; idempotent by conflict, with an ownership check per act and a second-run test. None has a route | Contract: a phase-doc statement plus the Education spec (README write sequence, `audit.md`, `permissions.md` as a forward declaration on [the Tenancy precedent](../modules/tenancy/permissions.md)). Detail: catalogue sources, the Tenancy `audit.md` and `permissions.md`, Localization Standards § Pattern A if the collision sentence changes. An ADR only if a handler must write two roots | P02d-2 (commands, handlers, catalogue sources, seeder acts) | [Accepted — 2026-10-02](#p02d-2-accepted-answers): commands, failures, audit classifications and convergence | -| G12 | Through which `Customization.Application.Contracts` surface does an Education write obtain the schema a body is validated against — exact `(key, schema_version)` including Deprecated revisions, or a key that binds the Active one — and is it an interface or a MediatR query, classified how? Which revisions may a writer bind, and what refusal answers an absent, cross-tenant or ineligible one? On the read side: what the cache keys on, whether the lesson response carries the binding or resolved field descriptors, and what the API and the page show when a binding cannot be resolved | One exact-revision query, Deprecated included, never falling back to Active; only Active revisions bindable for new writes, since a Draft's body can still change; absent and cross-tenant refused indistinguishably as `validation_failed` naming the binding; resolved descriptors in the response; an unresolvable binding shows a bounded placeholder with a warning log, never a `500` and never another revision's fields ([ADR-0013](../decisions/0013-page-block-schema-versioning.md)'s placeholder rule) | Detail: the Customization spec's contract and § Primary read flow, the Education spec's invariants, a phase-doc statement. No ADR: ADR-0010 settles the mechanism. A dated ADR-0013 amendment only if the unresolvable outcome departs from the placeholder rule | P02d-2 (the contract and write eligibility: the lesson writer is its first caller), P02d-3 (the cache key), P02d-4 (descriptors, the unresolvable outcome), P02d-6 (the page state) | [Accepted — 2026-10-02](#p02d-2-accepted-answers): contract and write eligibility. Cache, public response and page behavior remain open for P02d-3/4/6 | +| G12 | Through which `Customization.Application.Contracts` surface does an Education write obtain the schema a body is validated against — exact `(key, schema_version)` including Deprecated revisions, or a key that binds the Active one — and is it an interface or a MediatR query, classified how? Which revisions may a writer bind, and what refusal answers an absent, cross-tenant or ineligible one? On the read side: what the cache keys on, whether the lesson response carries the binding or resolved field descriptors, and what the API and the page show when a binding cannot be resolved | One exact-revision query, Deprecated included, never falling back to Active; only Active revisions bindable for new writes, since a Draft's body can still change; absent and cross-tenant refused indistinguishably as `validation_failed` naming the binding; resolved descriptors in the response; an unresolvable binding shows a bounded placeholder with a warning log, never a `500` and never another revision's fields ([ADR-0013](../decisions/0013-page-block-schema-versioning.md)'s placeholder rule) | Detail: the Customization spec's contract and § Primary read flow, the Education spec's invariants, a phase-doc statement. No ADR: ADR-0010 settles the mechanism. A dated ADR-0013 amendment only if the unresolvable outcome departs from the placeholder rule | P02d-2 (the contract and write eligibility: the lesson writer is its first caller), P02d-3 (the cache key), P02d-4 (descriptors, the unresolvable outcome), P02d-6 (the page state) | [Accepted — 2026-10-02](#p02d-2-accepted-answers): contract and write eligibility. [Accepted — 2026-10-02](#p02d-3-decision-package-2026-10-02): cache key. Public response and page behavior remain P02d-4/6 | | G13 | May an Education translation be written for a locale absent from, or disabled in, `tenant_locales`, and how is membership checked across the module boundary? Does a read resolve under a disabled locale? What does a tenant with no locale rows serve — [Localization § Tenant Locale Configuration](../architecture/12-localization.md#tenant-locale-configuration) promises platform `en`, and nothing implements it? Does a platform registry bound the enabled set, as Localization Standards names one in a namespace that does not exist? What happens to translations when `RemoveLocale` runs? | A Tenancy application contract checks membership on write; a read resolves only an enabled locale, checked once per request; no cross-chain foreign key; no platform registry in this phase; a tenant with no locale rows serves nothing until it has one | Contract: a phase-doc statement over ADR-0010's application-contract mechanism. Detail: the Tenancy and Education specs; Localization architecture and Localization Standards § Locale Model reconciled in the same diff; Database Standards § Migrations only if a key is chosen | P02d-2 (the translation command's check and the locale command the seed uses; the read half is written to the same answer in P02d-4) | [Accepted — 2026-10-02](#p02d-2-accepted-answers): write eligibility, no platform registry and no implicit no-row locale; public reader implementation remains P02d-4 | | G14 | Seed inventory. At what scope is each seeded row class written — courses, lessons, translations, branding settings — and from what seeder context, given that `SeedTenantContext` requires an organization? Where do the rows the criteria need live — a sibling-organization course, an organization-scoped course on the tenant host, a `(locale, slug)` held in both tenants, draft and wrong-course rows, more courses than one catalog page, a disabled locale holding translations — `make seed` or test-owned data? Which key the yoga taxonomy uses, which tenant is bilingual, what state do the built-in `card` / `plain` keep, which record holds it all, and how do the Packet 7 fixture's raw settings rows coexist with seeded ones? | English content tenant-wide; the yoga studio gets a tenant-wide, a Studio One and a Studio Two course; a seed context that announces no organization; branding tenant-wide; rows in the seed with `SeedData` as the record; built-ins stay Active and are never selected implicitly; expectations recomputed as enumerated sets. An English organization-scoped row is still needed for the tenant-host criterion, seeded or test-owned — the demo database's contents are the owner's preference | Detail: a phase-doc statement, the `SeedData` remarks, the `seed-tenant` skill, the writers delivery record. No ADR: [Security Standards § Forbidden](../standards/11-security.md#forbidden) already makes scope come from context | P02d-2 (seeder steps, the seed-context constructor, `SeedData`, `SeederTests`; moving placement later rewrites the seed and every request-level case) | [Accepted — 2026-10-02](#p02d-2-accepted-answers): inventory, ownership and test-owned controls | | G15 | `SeedRunner` calls `IUnitOfWork.SetTenantContextAsync` on its own transaction, and neither [ADR-0040](../decisions/0040-ambient-unit-of-work.md)'s closed setter set nor [Security Standards § The out-of-band setters](../standards/11-security.md#the-out-of-band-setters) lists it. Is that method's caller set mechanically closed, and is the seeder's call reconciled by routing its ownership check through `ISender`, or by admitting the seeder? | Route the ownership check through `ISender`, and add a source scan that admits `TransactionBehavior` (and Phase 02b's transport) with a planted offender | Contract: a dated ADR-0040 amendment plus a setters-table row only if the seeder is admitted. Detail: a Standards 21 source-scan row with its companion | P02d-2 (the Education seed acts reach the ownership check's refusal arm today) | [Accepted — 2026-10-02](#p02d-2-accepted-answers): contextual verification and Registered caller fence | @@ -327,9 +343,9 @@ premise a row cites is re-verified at that pass rather than trusted. | G19 | URL and markup policy for tenant-authored values on an anonymous page: which schemes (`https` only, or `http` too), credentials and `target`, which media origins, whether the rule is enforced on write — in the Education command, or as a validation gate Phase 04's entries share — whether the public API filters too, and whether URLs inside markdown fall under it. The write-time check constrains structure, not schemes: `format: uri` admits `javascript:` and `data:` | The reviews split on `http`; all refuse `javascript:`, dangerous `data:` and credentials; checked on write by a LearnStack rule and again on render; no third-party media in the seed | Detail: one home for the scheme list — [Security Standards § XSS & Output Encoding](../standards/11-security.md#xss--output-encoding) or [Frontend Architecture Standards § Security](../standards/07-frontend-architecture.md#security), not both; the Education spec's write rules; Tenant Customization Model § 8.1 if checked on write. Contract: a dated ADR-0043 amendment if it becomes a shared validation gate | P02d-2 (the lesson command's validation and the seed values; the render-time check reuses the answer) | [Accepted — 2026-10-02](#p02d-2-accepted-answers): no active sink in the seeded text profile; future URL/markup contracts precede Phase 04/05 sinks | | G20 | What mechanically backs "no production code branches on which tenant it serves"? The shipped domain-term scan strips literals and exempts seed data. (a) The mechanism and its literal source; (b) its subjects, matching and the platform built-ins; (c) its exemptions, including development hosts in frontend or infrastructure configuration; (d) whether a ban on production references to `LearnStack.Tools.Seeder` and a behavioural same-code, different-data test accompany it | A Standards 21 sibling row scanning production backend and `frontend/` sources, comments stripped, for exact identity literals read from `SeedData` (slugs, ids, hosts, display names, customization keys), built-ins excluded, with planted offenders; plus the behavioural test. The exemption policy is the owner's judgement | Detail: a Standards 21 row Registered in the first pass that uses it and Implemented before exit; a phase-doc statement in § Genericity proof. No ADR | P02d-2 (a: every seed literal lives where the source reads it), P02d-5 (c: the first host outside `SeedData`), P02d-6 (b: frontend subjects), P02d-7 (Implemented and required) | [Accepted — 2026-10-02](#p02d-2-accepted-answers): (a) SeedData literal source and Registered guard. Subjects, exemptions and behavioral proof remain open for P02d-5/6/7 | | G21 | Does the anonymous public path set any cookie — the [Frontend Architecture Standards § Tenant Resolution](../standards/07-frontend-architecture.md#tenant-resolution) flowchart sets them — and may a public page load any cross-origin subresource, such as the CDN-hosted logo and font assets Frontend Architecture describes? | No cookies, since the locale is already in the path and a locale-less request redirects ([Localization Standards § URL Strategy](../standards/08-localization.md#url-strategy)); same-origin subresources only; both asserted by a check. Whether tenant branding may point visitors' browsers at third-party hosts is a data-protection choice for the owner | Detail: a phase-doc statement; the Standards 07 flowchart and Frontend Architecture § Theming reconciled in the deciding pass | P02d-2 (subresources, if G16 admits a URL-valued token), P02d-5 (cookies: the middleware replacement is the first code that could set one) | [Accepted — 2026-10-02](#p02d-2-accepted-answers): subresources. Cookies remain open for P02d-5 | -| G22 | How does the customization definition projection load and stay correct? In the request's ambient transaction, or as a ninth out-of-band tenant-context setter (ADR-0040's set is closed at eight)? In what order are the generation and the rows read; what does an absent generation row mean; how is a cache filled inside a transaction that bumped and rolled back kept unreachable, when the bump is an upsert increment that can reissue a number; what does an absent definition set return; which families are registered, and how does the adapter's exact-tuple `cache.name` mapping match generation-embedded names; what do the TTLs bound; and is the contract batched so a public read issues a bounded number of statements? | Load in the ambient transaction; read the generation first, then the rows; fill only from non-bumping transactions; treat cache faults as misses; restate the module's cache-hit budget; a batched contract, with statement-count assertions cold and warm | Contract: the Customization spec § Primary read flow and a [Tenant Customization Model § 8.2](../architecture/32-tenant-customization-model.md#82-cache-strategy) statement on how a request learns the generation; a dated ADR-0040 amendment and a setters row only if the loader is out-of-band. Detail: the [Infrastructure Stack Standards](../standards/20-infrastructure-stack.md) cache table, the `cache.name` mapping, the Observability Standards metrics family list | P02d-3 | Open | -| G23 | The typed settings accessor and its freshness. With no `learnstack.tenancy.settings` event until Phase 02b and the seed writing from its own process, what bounds staleness: a TTL with a stated bound, a writer-coupled Tenancy settings generation counter, or no settings cache here? What are the accessor's name and glossary headword; how is a cached read keyed so tenant-wide and organization rows never cross organizations — a settings read depends on `app.organization_id` today, and the policy's tenant-scope read gains a carrier in Phase 03; and does its loader run in the ambient transaction? | The reviews split on freshness — a TTL bound until 02b, a counter, or no cache. For keys: tenant-wide rows loaded with an explicit `organization_id IS NULL` predicate under `CacheKey.ForTenant`, each organization's overrides under `CacheKey.ForOrganization`, merged in memory; an ambient loader. The documented tenant-only key is rejected, because it would serve one organization's overrides to another | Detail: if settings are cached, the Infrastructure Stack Standards cheat-sheet rows and `cache.name` mapping; the Tenancy spec's event row and budget; a glossary headword. Contract only for a counter (the Tenancy spec, Database Standards § Table classes and § GRANT matrix) or an out-of-band loader (an ADR-0040 amendment) | P02d-2 (a counter is bumped inside the setting command's transaction), P02d-3 (name, keys, loader) | [Accepted — 2026-10-02](#p02d-2-accepted-answers): no settings cache in P02d-2/3. Typed ambient accessor/scoped merge remains P02d-3 | -| G24 | Display fallback. Which document owns the chain — [Localization § Fallback Rules](../architecture/12-localization.md#fallback-rules) or [Localization Standards § Locale Model](../standards/08-localization.md#locale-model), which state different chains, while the shipped `LocalizedText.Resolve` narrows one subtag at a time and ends at the first authored value? What is the terminal state of a nullable Pattern A field and of a Pattern B label? Does a response say which locale a fallback value resolved in, so the page can mark its language (WCAG 3.1.2)? | Localization architecture owns the chain and Localization Standards links it, both recording the shipped narrowing and the first-authored terminal for labels; a nullable Pattern A field renders absent; each fallback-capable field reports its resolved locale | Detail: Localization Standards § Locale Model linking its owner, reconciled with `LocalizedText` in the same diff; the Customization contract's signature; the response schema under G26. No ADR | P02d-3 (the first caller that passes a fallback chain), P02d-4 (response fields) | Open | +| G22 | How does the customization definition projection load and stay correct? In the request's ambient transaction, or as a ninth out-of-band tenant-context setter (ADR-0040's set is closed at eight)? In what order are the generation and the rows read; what does an absent generation row mean; how is a cache filled inside a transaction that bumped and rolled back kept unreachable, when the bump is an upsert increment that can reissue a number; what does an absent definition set return; which families are registered, and how does the adapter's exact-tuple `cache.name` mapping match generation-embedded names; what do the TTLs bound; and is the contract batched so a public read issues a bounded number of statements? | Load in the ambient transaction; read the generation first, then the rows; fill only from non-bumping transactions; treat cache faults as misses; restate the module's cache-hit budget; a batched contract, with statement-count assertions cold and warm | Contract: the Customization spec § Primary read flow and a [Tenant Customization Model § 8.2](../architecture/32-tenant-customization-model.md#82-cache-strategy) statement on how a request learns the generation; a dated ADR-0040 amendment and a setters row only if the loader is out-of-band. Detail: the [Infrastructure Stack Standards](../standards/20-infrastructure-stack.md) cache table, the `cache.name` mapping, the Observability Standards metrics family list | P02d-3 | [Accepted — 2026-10-02](#p02d-3-decision-package-2026-10-02) | +| G23 | The typed settings accessor and its freshness. With no `learnstack.tenancy.settings` event until Phase 02b and the seed writing from its own process, what bounds staleness: a TTL with a stated bound, a writer-coupled Tenancy settings generation counter, or no settings cache here? What are the accessor's name and glossary headword; how is a cached read keyed so tenant-wide and organization rows never cross organizations — a settings read depends on `app.organization_id` today, and the policy's tenant-scope read gains a carrier in Phase 03; and does its loader run in the ambient transaction? | The reviews split on freshness — a TTL bound until 02b, a counter, or no cache. For keys: tenant-wide rows loaded with an explicit `organization_id IS NULL` predicate under `CacheKey.ForTenant`, each organization's overrides under `CacheKey.ForOrganization`, merged in memory; an ambient loader. The documented tenant-only key is rejected, because it would serve one organization's overrides to another | Detail: if settings are cached, the Infrastructure Stack Standards cheat-sheet rows and `cache.name` mapping; the Tenancy spec's event row and budget; a glossary headword. Contract only for a counter (the Tenancy spec, Database Standards § Table classes and § GRANT matrix) or an out-of-band loader (an ADR-0040 amendment) | P02d-2 (a counter is bumped inside the setting command's transaction), P02d-3 (name, keys, loader) | [Accepted — 2026-10-02](#p02d-2-accepted-answers): no settings cache in P02d-2/3. [Accepted — 2026-10-02](#p02d-3-decision-package-2026-10-02): typed ambient accessor/scoped merge | +| G24 | Display fallback. Which document owns the chain — [Localization § Fallback Rules](../architecture/12-localization.md#fallback-rules) or [Localization Standards § Locale Model](../standards/08-localization.md#locale-model), which state different chains, while the shipped `LocalizedText.Resolve` narrows one subtag at a time and ends at the first authored value? What is the terminal state of a nullable Pattern A field and of a Pattern B label? Does a response say which locale a fallback value resolved in, so the page can mark its language (WCAG 3.1.2)? | Localization architecture owns the chain and Localization Standards links it, both recording the shipped narrowing and the first-authored terminal for labels; a nullable Pattern A field renders absent; each fallback-capable field reports its resolved locale | Detail: Localization Standards § Locale Model linking its owner, reconciled with `LocalizedText` in the same diff; the Customization contract's signature; the response schema under G26. No ADR | P02d-3 (the first caller that passes a fallback chain), P02d-4 (response fields) | [Accepted — 2026-10-02](#p02d-3-decision-package-2026-10-02): internal fallback. Public response fields remain P02d-4 | | G25 | Site data and the page set. How does the renderer get the per-host data none of the Education reads returns — enabled and default locales, branding tokens, taxonomy display values, content-type field lists: fields embedded in the course reads (which cannot supply a default locale before a locale is known), a separate `[PublicSurface]` read resolved from the effective host, or the edge host lookup [Frontend Architecture Standards § Tenant Resolution](../standards/07-frontend-architecture.md#tenant-resolution) and [Infrastructure Stack Standards § Host → Tenant Resolution](../standards/20-infrastructure-stack.md#host--tenant-resolution) prescribe today, which must then state the effective host over the hop? Does the frontend ever hold a tenant or organization id? And which `(public)` pages ship — catalog, course with ordered lesson links and lesson, or two pages with bounded lesson links in the catalog response? | One `[PublicSurface]` site-data read with no host parameter, returning a closed projection and no ids, and three pages, which gives the course-detail read a consumer; one review keeps two pages with an explicit catalog outline. The first two options change what two Active standards prescribe | Contract: a phase-doc statement in § Read API and § Public renderer; for the first two options, edits to the two standards named, with an ADR if the pass judges the change non-trivial (no ADR carries the edge-lookup rule). Detail: the API Standards § Public surface rows; the Frontend Architecture sketch, sequence diagram and cache rows; the Localization architecture's edge locale sentence; the glossary; Phase 06 § What Phase 02d already shipped; Phase 05's inherited row if the course-detail read changes | P02d-4 (the endpoint set and DTOs the OpenAPI baseline freezes; a two-page answer changes the catalog response) | Open | | G26 | The v1 public read contract. The path shape beside Phase 05's authoring `/courses/{id}` — a shared slot, a distinct public prefix, or `/courses/by-slug/{slug}`; each response as an allow-list and what it never carries; the embedded lesson list's fields, order and bound, and whether an empty list is valid; per-locale alternates; how enums and envelopes stay additive; and which Problem Details responses each operation documents, given that no non-idempotent operation documents any today and a baseline of `200`s cannot see a status change | Fields limited to what the pages render; object envelopes, extensible enums, a deny-list contract test (`tenantId`, `organizationId`, `createdBy`, `updatedBy`, `deletedAt`, `rowVersion`, `slugKey`); the embedded list carries title, slug and order under a cap; `alternates` for enabled, translated locales; one shared transformer declaring each operation's statuses as `application/problem+json`. No review settled the path | Contract: a phase-doc statement recorded before the breaking-change check stores its baseline. Detail: the OpenAPI snapshot; [API Standards § URL Structure](../standards/04-api-design.md#url-structure) for a prefix class, § Pagination for an embedded list, § OpenAPI; the gateway's public-band row. [ADR-0024](../decisions/0024-api-versioning-policy.md) settles that later additions are non-breaking | P02d-1 (whether the slug grammar must refuse GUID shapes, with G9), P02d-4 (route templates, records, snapshot) | [Accepted — 2026-09-14](#p02d-1-accepted-answers): slug grammar only; P02d-4 route and response contracts remain open | | G27 | The cache posture of public reads. What directive do anonymous responses carry — the `200`s, the Problem Details `400`s and `404`s, the tenancy edge's unmapped-host `404` — what freshness do a newly published or unpublished course and a not-found have, and do anonymous reads emit an `ETag` and honour `If-None-Match`? [API Standards § Optimistic Concurrency](../standards/04-api-design.md#optimistic-concurrency) says mutable resources expose an `ETag`, and [ADR-0039](../decisions/0039-optimistic-concurrency-token.md) fixes one derivation, which a composite read cannot use without publishing `row_version` | An explicit `Cache-Control: no-store`, asserted by a test, and no `ETag` on anonymous reads — a response without explicit freshness may be cached heuristically by a shared cache. One review proposed no directive, stated | Contract: a phase-doc statement. Detail: API Standards — the directive, and a § Optimistic Concurrency sentence on anonymous read contracts, owed under either answer. A dated ADR-0039 amendment if a body-hash validator ships; [Performance Standards § Caching](../standards/15-performance.md#caching) if the answer caches | P02d-4 (the header-setting code and the headers the snapshot documents) | Open | @@ -1046,6 +1062,548 @@ and the positive build/TRX evidence. No further production change is required; this documentation-only closeout records the completed rounds. PR #23 remains open for maintainer review and merge. +### P02d-2 merge and closeout (2026-10-02) + +[PR #23](https://github.com/HodeTech/LearnStack/pull/23) merged into `main` at +**11:41:47 UTC**, with final PR head `161314313eeb0d87758fb38c20af5e4c4c1b5766` +and merge commit `8edbb032b81313aae7e635b2782af511a9fe02cc`. Their trees are +identical. `development` was fast-forwarded to the merge commit without switching +branches or rewriting history. This closeout changes documentation only. + +- [x] Accepted P02d-2 gate parts and all four implementation steps are complete; + each step and the subsequent verified PR corrections completed both review rounds. +- [x] ADR-0050's policy, restricted backfill and Education writers are delivered; + ADR-0051's profile parsing and resolution are delivered. Public-read enforcement + remains P02d-4, rendering P02d-6 and course access grants Phase 07. +- [x] Three Tenancy and six Education writers, exact-definition/locale validation + and convergent two-tenant seed execution are delivered and registered. +- [x] The final tenant-existence correction refuses both branding write intents + before setting access; live Trial tenants remain supported. +- [x] The final PR head passed all five required checks; CodeRabbit also succeeded. +- [x] The merge commit passed the same five required checks. + +| Verified revision | CI evidence | Result | +|---|---|---| +| Final PR head `1613143` | [Run 36986675925](https://github.com/HodeTech/LearnStack/actions/runs/36986675925) | All five required jobs succeeded | +| `main` merge commit `8edbb03` | [Run 37002423112](https://github.com/HodeTech/LearnStack/actions/runs/37002423112) | All five required jobs succeeded | + +Final implementation verification records **2,594 passing backend cases**: 1,577 +unit, 184 architecture, one contract, 171 Docker-free integration and 661 Docker +integration, with zero failures or skips. Release build has zero warnings/errors; +format and link/fragment checks pass. The historical P02d-1 record remains unchanged. + +The live required-check list still contains the five recorded contexts with +`strict: true`. The documentation closeout also passes 184 architecture cases, +execution/zero-skip guards, added-prose wrapping and relative-link/anchor checks. + +**P02d-2 is closed. Phase 02d remains in progress.** P02d-3 through P02d-7 have +not started. No public business endpoint or browser demo is delivered by this merge. +ADR-0049 and the Course Marketplace pilot, Phase 09a, remain Proposed. + +#### P02d-3 entry readiness + +P02d-2's merged definitions, settings and seed satisfy the implementation dependency. +The next action is P02d-3's decision pass, followed by read internals with no HTTP: +generation-keyed Customization projections/cache families and a typed Tenancy +settings accessor. The packet table and decision register remain authoritative: + +- G12's cache-key part, G22's ambient loader/generation/rollback/cache contract and + G24's display fallback remain to be accepted. +- G23's no-settings-cache bound is already Accepted. The accessor's name, ambient + loading and tenant/organization scope contract remain P02d-3's decision work; + organization branding overrides and their token merge remain Phase 06. +- Cold/warm statement-count, cache-fault and rollback safety proofs belong with + these readers. Public contracts/eligibility remain P02d-4; transport, rendering + and the final browser/CI demo remain P02d-5, P02d-6 and P02d-7 respectively. + +### P02d-3 decision package (2026-10-02) + +**Accepted — 2026-10-02, verified against `0dec43b`.** The maintainer approved +the three decisions and implementation steps below before source changes. This +closes G12's cache-key part, G22, G23's accessor part and G24's internal fallback +part; it claims no implementation. Detail owners are synchronized in this first +commit. Original questions and shipped delivery records remain intact. Public +response and page-state decisions remain with P02d-4/6. + +#### Verified premises and document review + +The review baseline is `0dec43b` on `development`, following merged PR #23. +The required context reading, relevant module specs, localization/cache/isolation +architecture, ADR-0008/0010/0013/0038/0040/0043/0050/0051 and governing standards +were checked against the current adapters and composition roots. Two fresh +read-only review sessions independently examined cache/transaction safety and +settings/localization boundaries. A third reviewed the completed proposal and +verified no blocker or major finding. These sessions ran no tests and changed +no source. + +- The four Customization writers bump the durable tenant generation inside their + business transaction. A rollback discards that increment; a later commit can + reuse its value. An uncommitted cache fill would then become reachable. +- The ambient unit opens the default PostgreSQL isolation level. Under + [READ COMMITTED](https://www.postgresql.org/docs/18/transaction-iso.html#XACT-READ-COMMITTED), + successive queries may see different committed states; a generation-first + query alone does not prove a coherent definition snapshot. +- `ICacheService.GetOrSetAsync` has a shared factory lifetime. An ambient + connection must not be captured by a factory that may outlive its request or + be shared with another request. This contract uses awaited get/load/set calls. +- P02d-2's exact writer reader is uncached and purpose-aware. It remains so; + display reads must not weaken NewBinding eligibility or change stored pins. +- G23 already rejects settings caching in P02d-2/3. Standards 20's stale TTL rows + are corrected to a reserved, unused family; its metric spelling remains intact. +- The localization architecture, standard and `LocalizedText.Resolve` describe + different fallback chains. G24 below reconciles them before the first caller. +- Organization branding/token merge remains Phase 06. The glossary now states + that ownership explicitly; generic setting scope is not branding authorization. + +The selected vehicles remain the ones assigned by the register: module specs, +architecture/standard details and this dated package under existing ADRs. No new +ADR is required by the accepted ambient design. A different setter, transaction +mode, access policy or cross-module mechanism requires its decision record and +maintainer approval before implementation. + +#### Accepted gate answers + +| Gate part | Accepted answer | Detail owner | +|---|---|---| +| G12: cache key | Cache immutable, untranslated definition families per tenant/generation; indexes inside each family use exact `(key, schema_version)`, including Active and Deprecated, excluding Draft/deleted. Never substitute Active for an unresolved pin. The writer reader stays uncached | Customization spec, Education pin invariant; architecture 32 § 8.2 | +| G22: loader and correctness | Ambient caller transaction only; fresh generation probe per batch, coherent generation/rows snapshot on a miss, mutation-scope cache bypass, bounded statements, cache-fault fallback and generation-driven freshness as specified below | Customization spec § Primary read flow; architecture 32 § 8.2; standards 10/20 | +| G23: accessor | `ITenantSettingsAccessor`, uncached and ambient; typed registered settings, explicit tenant/current-organization selection and whole-value precedence. `branding.theme` remains tenant-wide. No caller-supplied tenant/organization authority | Tenancy spec and glossary; standards 20's existing no-cache answer | +| G24: display fallback | Localization architecture owns the chain; the standard links it. Exact requested tag, progressive narrowing, exact tenant default, platform `en`, then deterministic first-authored Pattern B label. Every resolved label carries its actual locale. Nullable Pattern A display fields end absent; URL/body lookup never falls back | Localization architecture § Fallback Rules; standard 08; SharedKernel and Customization contract; public fields remain P02d-4 | + +#### Customization projection and cache contract + +`ICustomizationDefinitionProjectionReader` is an application +interface returning immutable values through mechanism 1 of ADR-0010. This is an +in-memory projection, not a new `public_*` table or an integration-event consumer. +It resolves a batch of exact content-type and taxonomy revision pins with one +caller-provided display-locale context. No foreign Domain/Infrastructure type, +public marker, HTTP endpoint, schema validator or compiled-validator cache is added. + +Unresolved/ineligible individual pins remain identifiable as missing members of +the batch, without substituting another revision or failing unrelated members. +The API's placeholder/refusal contract, warning attribution and protected-content +eligibility remain G12/G5/G26 in P02d-4. Stored labels stay immutable in the cache; +resolved labels are produced per call, so a locale is not needed in these keys. + +Two families contain all eligible revisions, including taxonomy bands. Loading +both sets together prevents N+1 work for lists and gives one coherent snapshot: + +| Family | Key via `CacheKey.ForTenant` | Stable `cache.name` | +|---|---|---| +| Content types | `{tenant_id}:customization:content-types:v{generation}` | `customization:content-types` | +| Taxonomies | `{tenant_id}:customization:taxonomies:v{generation}` | `customization:taxonomies` | + +The generation is a separate logical-name component passed to the existing +multi-part factory, never a hand-built separator. These templates replace +architecture 32's generation-embedded names and per-key taxonomy example in +this decision pass; the registry, metrics list and adapter mapping change together. +`TenantPageBlock` and its cache family remain Phase 04 work. + +- Require a resolved real tenant and an active, correctly announced/enlisted + transaction before every read, including cache hits. Keys come from that + trusted context, never from a request tenant id. RLS remains effective. +- Read the durable generation afresh for every batch; do not memoize or cache it. + A fully warm read uses one generation SELECT and no definition query. +- On any miss/fault, load generation and both eligible sets in one read-only SQL + statement snapshot on the same connection. Use parameterized, explicit tenant + predicates over Customization-owned tables only, following + [Database Standards § Raw SQL](../standards/05-database.md#raw-sql). + Use that statement's generation for both returned sets and cache fills; + discard earlier cache hits if the probe's generation changed. Cold/partial-hit + reads use at most two SELECT statements, independent of the number of pins. +- An absent counter with no definitions yields an empty projection; no built-in + values are synthesized. Do not cache the absent-counter result. A nonempty + definition set with no counter is a bounded configuration refusal and is not + cached. A present counter with empty sets is valid. +- Before a supported Customization store mutation or generation bump, mark this + scoped reader state dirty. Dirty or rollback-only scopes bypass cache get and + set entirely; they can read their saved database changes through the ambient + snapshot. The flag is sticky for the DI scope, avoiding transaction-object + reuse and nested-frame resets. Pending tracked changes are not auto-flushed by + the reader. A fresh scope regains normal caching. +- A clean scope can fill before its own read transaction commits because its + snapshot contains committed Customization data only. Rolling that read back + cannot publish speculative definitions. Fill safety must be proved for reads + before and after nested writers and for rollback/reissued generation values. +- Await each cache operation in the caller's lifetime; do not pass the ambient + loader to `GetOrSetAsync`, spawn work or parallelize module queries. Concurrent + cold callers may each load the bounded snapshot; this trades coalescing for + explicit transaction ownership without changing the cache port. +- Cache read/write faults degrade to database results with a bounded diagnostic; + caller cancellation propagates. Database errors are not cache misses. No + partial cache result may make a batch look complete. +- L1 TTL is 60 seconds; future L2 remains 15 minutes on Phase 11's trigger. + Generation reads bound definition freshness, while TTLs reclaim stranded keys. + Each instance's L1 follows the same durable counter; no event or L2 is required + for this family's invalidation correctness. + +The warm/cold statement counts are acceptance proofs, not measured latency. +The existing `< 1 ms` hit target must distinguish in-memory resolution from the +mandatory generation database probe. Record end-to-end timings and query plans +for the seeded fixture; do not claim a production p95 from a small local sample. +The family-wide load has a data-volume cost: it includes retained revisions, not +just the bounded requested pin list. Report rows/bytes with the measurements; +Phase 04's larger authoring workload re-evaluates that cost before expanding it. + +#### Typed settings and display fallback contract + +`ITenantSettingsAccessor` exposes typed registered reads through Tenancy contracts, +without a raw string-key/JSON export or a generic settings HTTP surface. The +production registration initially admits only the delivered `branding.theme` +palette. Reuse its grammar/contrast policy; return a typed four-color value and a +bounded absent/invalid outcome, never a partial palette or raw JSON. P02d-4 owns +safe public defaults/allowlisting; P02d-6 owns CSS injection. + +For a registered setting that permits organization scope, select tenant-wide and +exact current-organization rows explicitly, excluding soft-deleted rows; no +organization context selects tenant-wide only. Merge the organization value over +the tenant value as one whole value, without deep JSON/token merge. Invalid selected +values return a typed configuration refusal, without silently adopting another +scope. Synthetic test registrations prove this generic precedence; no speculative +production setting key or organization-branded palette is added. Even a future +`app.scope = 'tenant'` hatch cannot introduce sibling overrides into this selection. +The branding registration selects tenant-wide only in every organization context. + +The accessor opens no transaction, announces no context and caches neither values +nor per-scope snapshots; a supported write is visible to the next read under the +ambient database isolation. Audit and permissions matrices identify this internal +interface as unrouted, not as a new audited write. Any request used only by tests +stays test-only; any production MediatR query added must be classified Off. + +For G24, add a locale-carrying resolution result to `LocalizedText` and retain the +existing string-returning API as a compatible wrapper over the same algorithm. +First-authored means canonical locale keys ordered ordinally, as the shipped +`ImmutableSortedDictionary` implements; it does not mean JSON insertion order. +Do not widen `tr` to `tr-TR` or narrow the tenant/platform default candidates +implicitly. The platform `en` candidate is for display labels only; it authorizes +no content locale. The caller supplies the tenant default once for the batch. + +Pattern A rules concern optional display fields after an exact routable translation +has been found. They never locate another slug/translation/body. Required content +fields keep the authored translation; a nullable field with no permitted display +fallback remains absent. P02d-4 specifies field-level response applicability and +resolved-locale fields; P02d-6 emits the corresponding language attributes. + +#### Implementation steps and required review loop + +| Step | Scope and evidence | +|---|---| +| 1 | Locale-carrying fallback and uncached typed settings accessor. Unit fallback/grammar tests; app-role tenant/org/no-org selection, invalid/missing/soft-deleted settings, future tenant-scope hatch, read-after-write and no-cache proofs; register both API and Seeder roots | +| 2 | Batched exact-pin display contract and coherent ambient snapshot loader, initially uncached. Active/Deprecated versus Draft/deleted, mixed missing pins, tenant separation, empty/missing generation, no validation on read, resolved locales and coordinated generation/row race proofs | +| 3 | Generation cache families, scoped mutation bypass and metrics. Cold/warm counts, partial/cache-fault/cancellation behavior, cross-tenant warm alternation, nested write/read, rollback/reissue and independent-process freshness proofs; measured budgets, composition parity, full validation and packet closeout | + +Each step: implementation and focused validation, commit on `development`, fresh +independent security/correctness/contracts/documentation review, verify findings, +fix and commit; then a second fresh review round and verified fix commits before +the next step. Models/effort follow the complexity of each review surface. No +branch change. Packet completion updates the current-state docs and delivery +record; the PR is opened for maintainer review after all three steps pass. + +Relevant workflow skills are `add-integration-test`, `add-architecture-test`, +`update-glossary`, `standards-check`, `code-review`, `run-tests-locally` and +`commit-and-pr`. No new handler, entity or migration is planned; if implementation +requires one, dispatch its matching skill before the change. + +At acceptance, synchronize the Customization/Tenancy specs and matrices, glossary, +architecture 09/12/32, standards 08/10/15/20 and any catalogue rows for new guards. +Preserve existing Accepted ADR bodies and dated P02d-1/P02d-2 delivery history. +README, CLAUDE and roadmap index follow the actual decision/implementation state. +The full backend validation includes Release build, formatting, positive unit, +architecture, contract and both integration populations, with zero failed/skipped +cases. Documentation checks cover links/anchors, prose width and frozen delivery +history. No frontend behavior or public endpoint changes in this packet. + +#### Maintainer approval + +The maintainer approved the package together: + +1. G12/G22's ambient coherent-snapshot cache contract, batched exact pins, + two family keys, dirty-scope bypass and bounded database statement counts. +2. G23's uncached typed accessor, whole-value generic scope precedence + and explicitly tenant-wide branding registration. +3. G24's architecture-owned fallback, ordinal-first label terminal and + actual resolved-locale metadata while retaining exact content/URL admission. + +The three-step plan is part of the accepted package. This decision commit precedes +implementation, as required by the maintainer and +[implement-task Step 1](../../.claude/skills/implement-task/SKILL.md#step-1--scope-and-alignment). + +### Delivery record: P02d-3 + +**Complete, unmerged — 2026-10-02.** The accepted decision commit is `307bbcd`. + +#### Step 1: typed settings and locale resolution + +Implemented `ResolvedLocalizedText` and the compatible string wrapper, plus the +registered typed settings accessor in both composition roots. The sole production +registration is tenant-wide branding; synthetic tests prove generic whole-value +organization precedence. Explicit predicates, soft deletion and ambient admission +protect reads without a cache or raw configuration export. + +Release build: zero warnings/errors. Unit: 1584 passed; architecture: 184 passed; +integration: 241 passed (Docker/settings, writer/seed and Docker-free cases). +All three populations have zero failed/skipped; formatting and document checks pass. +Both independent review rounds passed; Step 2 follows. Public consumers/metadata +remain P02d-4/6. + +**Step 1 review round 1.** Two fresh GPT-5.5 high sessions reviewed +`307bbcd..9293202`. No verified Blocker/Major. Two verified Minor findings were +fixed: the localization illustration used a second stale fallback helper, and +both composition roots overstated feature flags as the only module-facing read. +The illustration now calls the shipped resolver; comments describe their own +read. No behavior changed. + +**Step 1 review round 2.** Two fresh GPT-5.5 xhigh sessions reviewed +`307bbcd..5848066`. Both approved the code; no verified Blocker/Major. +They independently identified the same stale delivery-status sentence above, +which is corrected in this closeout. Related current-state carriers now record +both rounds as passed. Documentation link/fragment and diff checks pass. + + +#### Step 2: batched coherent definition reads + +Implemented the immutable exact-pin display contract and ambient loader in both +composition roots. One SQL statement returns the generation, both eligible +families and taxonomy bands using their composite revision key. Individual +invalid/ineligible pins remain missing without revision substitution or dropping +valid neighbors. Actual display locales and authored descriptor order survive; +raw schemas stay inside the loader. The writer's purpose-aware reader is unchanged. + +The reader is deliberately uncached in this step. Every batch probes the durable +counter and loads the coherent statement snapshot; cache, dirty-scope behavior +and warm counts remain Step 3. The no-validation-on-read architecture guard walks +module helper dependencies and has planted direct/helper and clean controls. +Release build has zero warnings/errors; 1584 unit, 186 architecture and 21 focused +Docker integration cases pass with zero failures/skips. The real guard rejects a +planted validator dependency in the production snapshot helper, then passes after +restoration. Formatting and documentation checks pass. Both review rounds passed. +No cache implementation is claimed. + +**Step 2 review round 1.** Two fresh GPT-5.5 high sessions reviewed +`4829414..77197b9`. No verified code or SQL finding; two Minor document carriers +still treated G23 or Step 1 review as pending. Both are synchronized. The root's +additional guard check demonstrated a concrete validator adapter escaped the +interface-only ban; a planted concrete probe failed before the fix and passes +with the adapter census. The full architecture suite passes after the fix. +Round 1 is complete. + +**Step 2 review round 2.** Two fresh GPT-5.5 xhigh sessions reviewed +`4829414..94a84ab`. Both approved, with no verified findings. Current-state +carriers record both rounds as passed; link/fragment and wrapping checks pass. +Step 3 follows. No public consumer or cache implementation is claimed here. + + +#### Step 3: generation cache and read safety + +Implemented the two untranslated immutable definition families, awaited cache +get/load/set and stable metric names. Every read probes the durable generation; +a miss loads both families and generation from the same statement snapshot. +Supported stores and generation bumps mark a sticky scoped dirty flag before +mutation. Dirty and rollback-only reads bypass cache get/set entirely. Cache +fault diagnostics omit private exception/key payloads; cancellation and database +failures propagate with their own meaning. L1 is 60 seconds; L2 remains Phase 11. + +Release build: zero warnings/errors. Unit: 1586 passed; architecture: 186 passed; +focused Docker integration: 33 passed, including 23 projection/cache/composition +cases and ten publication-failure/concurrency cases. All have zero failures/skips. +The tests prove cold/warm statement counts, partial-hit publication coherence, +locale-neutral families, tenant alternation, independent L1 freshness, nested +write/read, store-save-before-bump, absorbed post-save refusal, rollback/reissued +keys, faults/cancellation, missing-counter admission and API/Seeder root parity. +An attempted test-only transaction reopen after rollback was correctly refused; +the proof uses a fresh scope, preserving ADR-0040's irreversible rollback-only rule. +The first full run passed unit, architecture, contract and Docker-free suites. +It exposed four missing scoped-state registrations in two legacy test fixtures; +those fixtures now supply the new state. A separate Seeder case encountered a +connection-open timeout before reaching its seeded race. All six focused fixture +and seed-race cases passed after the fix; the full Docker rerun was pending at +the implementation commit. No retry +or weakened +assertion was added. The review closeout below records completion of both rounds. + +**Local seeded measurement.** The executable +`Seeded_local_measurement_records_statement_plans_payload_volume_and_end_to_end_timings` +case uses a disposable PostgreSQL database through `learnstack_app`, the full +P02d-2 seed and 20 observations after warmup. Tenant `demo-english`, generation 8: +two content types, two taxonomies and nine bands. UTF-8 JSON payload from the +coherent statement is 1984 bytes; this measures wire JSON, not managed heap size. + +| End-to-end path | Minimum / median / maximum, ms | SELECT statements | +|---|---|---| +| Cold | 0.736 / 0.781 / 0.975 | 2 | +| Warm | 0.235 / 0.259 / 0.304 | 1 | +| Typed branding setting | 0.259 / 0.300 / 0.352 | 1; uncached | + +**Measurement erratum — 2026-10-03.** The historical “median” column above +reported upper medians, not the average of both middle observations. The routine +is corrected; the new sample and its limits are recorded in +[PR #24 review remediation](#pr-24-review-remediation-2026-10-03). + +`EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)` on the actual parameterized statements +uses `pk_customization_generations` for the probe and composite tenant/key/version +indexes for both families; bands use `ux_tenant_level_taxonomy_items_taxonomy_sort`. +Probe execution: 0.007 ms, two shared-buffer hits. Snapshot execution: 0.117 ms, +12 shared-buffer hits; both have zero shared-buffer reads. The snapshot's nested +band aggregate remains one SQL statement. These small local observations are not +production p95 evidence, do not isolate the in-memory `< 1 ms` target and do not +prove the cold `< 20 ms` or settings `< 5 ms` production budgets. Retained revision +volume, concurrent load and Phase 04's larger authoring workload require renewed +measurement before extending the families. No latency threshold is hard-coded +into the test. + + +**Step 3 review round 1.** Two fresh GPT-5.5 xhigh read-only sessions reviewed +`287318f..7ac197f`, covering transaction/cache safety and contracts/test evidence, +performance and corpus consistency. Both approved with no verified findings. +No source fix was necessary; Round 2 was pending at that milestone. + +**Complete backend verification after fixture repair.** Release build: zero +warnings/errors; formatting passes. All 2637 cases passed with zero failures/skips: +1586 unit, 186 architecture, one contract, 171 Docker-free integration and 693 +Docker integration. Positive TRX counters confirm `passed = executed = total` in +each suite; the full Docker rerun includes the earlier seed timeout case and +legacy fixture cases. Markdown links/fragments, diff checks and frozen P02d-1 +suffix checks pass. This evidence precedes the second independent review round; +it does not mark the packet merged. + + +**Step 3 review round 2 and packet closeout.** Two fresh GPT-5.5 xhigh read-only +sessions independently reviewed `287318f..5b50995`. No verified code, security, +transaction, cache, performance or test finding. One reviewer identified a Minor +status ambiguity in the phase's opening P02d-2 milestones, which still described +P02d-3 as next/open. Those sentences now explicitly describe their historical +moment; the current completion note and packet table name the accepted, delivered +P02d-3 state. Current-state carriers record both rounds as passed. The verified +fix changes documentation only; the complete backend execution evidence above +still applies to the unchanged source. Final links/fragments, formatting, diff +and architecture metadata checks pass. + +All three implementation steps and both review rounds per step are complete. +No new ADR, migration, HTTP endpoint, context setter or transaction mode was +required. P02d-3 is ready for PR review and remains unmerged. Phase 02d remains +in progress; P02d-4 owns the next decision pass for public eligibility, response +contracts, locale/cursor/cache rules, Off classification and read-only controls, +OpenAPI/SDK drift gates and request-level Education isolation. P02d-5/6 provide +SSR and rendering; P02d-7 provides the full-stack demonstration and exit proof. + + +**PR documentation correction — 2026-10-02.** After PR #24 opened, two CodeRabbit +Minor findings were verified against the current files: the Education spec still +named P02d-3 as next, and the roadmap index lacked a sentence terminator. Both +are corrected. Education now records P02d-3 complete/unmerged and P02d-4 next. +The fix changes no backend source or test; the 2637-case execution evidence +remains applicable. Final Markdown link/fragment and diff checks pass; required +CI is rechecked against the final documentation head before handoff. + + +#### PR #24 review remediation (2026-10-03) + +The maintainer supplied two independent reviews of `8edbb032..a317d389`. +Findings were verified against current source before changes; two additional +read-only agents checked documentation scope and runtime contract claims. +No new ADR, endpoint, migration or accepted decision is introduced. + +- **B1/M1/M14:** the validator guard now follows assembly-scoped references across + every production project, including Customization Domain/Contracts and core + helpers. Planted controls cover parameter/return/generic/event attributes, + wrapped generics, constraints, catches, lambdas and state machines. A narrowed + production census mutant fails on the planted Domain helper; the wrapped + generic control failed before the IL correction. Restored source passes. +- **M2:** the old probe barrier legitimately allowed a caller to warm the other. + The test now parks both independent snapshot loaders before either fills, + proving exactly two SELECTs and complete values per caller without flakiness. +- **M3–M8:** recoverable cache faults still fall back with bounded diagnostics; + fatal process exceptions propagate. Invalid setting-token values return a + bounded failure, settings admission checks its context's enlistment, branding + uses one canonical key, redundant cached revision fields are removed and + malformed-pin comments describe the actual per-revision behavior. Internal + definition refusals use neutral `lockey_invalid_value`, not a schema-extension + message. Malformed band labels omit their entire pin and preserve neighbors. +- **D1/D2/M10–M12:** editable Scope and current-state carriers now identify the + delivered locale/accessor/fallback work, planned organization branding and + generation-driven L1 consistency. Frozen P02d-1 accepted answers and delivery + record, and dated P02d-2 closeout/readiness, remain unchanged. + +**Disposition of remaining suggestions.** The accepted registry is an explicit +server-owned value, not an additive DI-registration API; its replacement rule is +now documented rather than inventing a new extension mechanism. The Phase 04 +`blocks-v{generation}` example is a legal, explicitly unimplemented target. +Public surface enforcement remains P02d-4's G30, not a delivered P02d-3 gate. +L2/serialization, cache-fault metrics and cold-load coalescing are not claimed by +this packet. Missing-counter detection intentionally includes Draft/deleted roots; +nonpositive generations remain invalid. Test-only non-null assertions fail loudly +if required measurement commands are absent; they do not hide a skipped proof. + +**Measurement correction.** The earlier table's historical “median” values were +upper medians (the eleventh of twenty observations). The routine now averages +both middle observations. A new twenty-observation sample after warmup, from +`learnstack_app` and the same 1984-byte fixture, records: + +| End-to-end path | Minimum / median / maximum, ms | SELECT statements | +|---|---|---| +| Cold | 0.598 / 0.738 / 0.973 | 2 | +| Warm | 0.184 / 0.209 / 0.275 | 1 | +| Typed branding setting | 0.190 / 0.235 / 0.292 | 1; uncached | + +Probe/snapshot execution is 0.008/0.146 ms with 2/12 shared-buffer hits and zero +reads. These observations still prove no production percentile or latency budget. +The measurement case asserts statement/data invariants, not unstable timings. + +**Local validation:** Release build has zero warnings/errors. Unit 1586, +architecture 187, contract 1, Docker-free integration 171 and Docker integration +697 pass: **2642 cases, zero failed or skipped**, with positive TRX execution +counters checked. Full format, Markdown links/fragments and diff checks pass. +Fresh correction review rounds follow the implementation commit; PR #24 remains +unmerged. + + +**Correction review round 1 — 2026-10-03.** Two fresh read-only reviewers, +GPT-6-astra and GPT-6.1-sol at xhigh effort, reviewed `a317d38..ab2a8b3` across +architecture/test proof and runtime/documentation. Both approved with no verified +findings. They ran no builds or tests; the primary executed the checks above. + +**Correction review round 2 — 2026-10-03.** Two new read-only reviewers using +the same models/effort independently reviewed that range with the lenses +exchanged. One Minor was identified and confirmed by both: Standards 20's new +“other families” sentence still generalized L2 to no-L2/uncached families. The verified +fix now links the +canonical per-family policy instead. No further finding or backend change. +Required CI and CodeRabbit passed on reviewed code head `ab2a8b3`; there are no +unresolved review threads. Links/fragments, metadata and diff checks pass after +the wording fix. Final documentation-head CI is rechecked before handoff. +PR #24 remains unmerged. + + +#### PR #24 depth and settings correction (2026-10-03) + +The maintainer's review of `8edbb032..825e4f57` identified two valid reader +defects. Both were verified against current source and reproduced through real +PostgreSQL as `learnstack_app` before the production correction. + +- **Snapshot depth:** the accepted raw JSON limit remains 64. The SQL snapshot + adds two containers around content-type schemas and four around taxonomy band + metadata. Explicit, bounded reader limits of 66 and 68 preserve that source + contract. No schema admission, publication, tenant predicate or cache policy + changes. +- **Settings parsing:** `JsonException` and `InvalidOperationException` from the + selected registration's parser return the existing bounded `validation_failed` + outcome. An invalid organization override never falls back to the tenant value. + Existing unsuccessful results remain refusals; cancellation and unrelated I/O + failures still propagate. + +Nine new database cases cover the two reported inputs, both raw-64 boundaries, +raw-65 write refusal, unrelated cold pins, warm reads, either partial-cache +direction, malformed setting roots/fields and non-shape parser failures. Before +the correction, the four depth and three shape cases fail at the expected parser; +the two non-shape controls pass. No Accepted ADR, migration or public API changes. + +**Local validation:** Release build has zero warnings/errors. Unit 1586, +architecture 187, contract 1, Docker-free integration 171 and Docker integration +706 pass: **2651 cases, zero failed or skipped**. Positive TRX execution counters +and all nine new regression outcomes are verified. Full format, Markdown +links/fragments and diff checks pass. Two fresh independent review rounds follow +the correction commit. PR #24 remains unmerged. + ### P02d-1 decision pass (2026-09-14) **Accepted — 2026-09-14, verified against `6c58343`.** The maintainer approved @@ -1329,12 +1887,13 @@ the moment it is inserted, published or not; which command reports the collision `tenant_locales` already exists — [Phase 02a Packet 6](phase-02a-kernel-tenancy.md#delivery-record-packet-6) ships it and already states it is required before any tenant-owned content table ships. The table -ships; the configuration does not. Neither seed tenant holds a row, and no command -writes one — `Tenant.AddLocale` and `SetDefaultLocale` have no caller outside tests. +shipped before this phase. At phase entry, neither seed tenant held a locale row +and `Tenant.AddLocale` and `SetDefaultLocale` had no caller outside tests. [ADR-0042](../decisions/0042-tenant-provisioning-cross-aggregate-transaction.md) requires locale rows to be written by their own command in their own transaction: the -one raising `tenancy.locale.write`, `(planned)` in -[the Tenancy audit matrix](../modules/tenancy/audit.md). This phase ships it (**G11**). +one raising `tenancy.locale.write` in +[the Tenancy audit matrix](../modules/tenancy/audit.md). P02d-2 delivered those +commands and seeded locale configuration; see its [delivery record](#p02d-2-implementation-delivery-2026-10-02). Case variants of one tag are one locale ([ADR-0018](../decisions/0018-tenant-driven-customization-model.md)'s 2026-09-04 amendment), and how the shipped table spells a locale is in @@ -1439,20 +1998,23 @@ Obligations already imposed: - The read path does not validate ([Tenant Customization Model § 8.1](../architecture/32-tenant-customization-model.md)). -Open: the contract (**G12**), how the projection loads and stays correct (**G22**), and -the display fallback it applies (**G24**). +P02d-3 delivered **G12**'s cache contract, the loader/correctness contract (**G22**) +and internal display fallback (**G24**) under the +[accepted gate answers](#accepted-gate-answers). Public response and page-state +parts remain P02d-4/6. **The typed settings accessor** over `tenant_settings`, which Phase 02a left to its -first reader, lands here too. Under Row Level Security a `tenant_settings` read returns -tenant-wide rows plus the caller's organization's rows, so its result depends on -`app.organization_id`, and the policy's tenant-scope read has no carrier until +first reader, is delivered in P02d-3. Under Row Level Security a `tenant_settings` +read admits tenant-wide rows plus the caller's organization's rows, so its result +depends on `app.organization_id`, and the policy's tenant-scope read has no carrier +until [Phase 03](phase-03-identity-admin.md) ([Security Standards § Tenant Context](../standards/11-security.md#tenant-context)); -resolution follows the organization-over-tenant fallback. The declared eager -invalidation, `learnstack.tenancy.settings`, is booked to Phase 02b in the Tenancy spec, -and this phase's settings writes come from the seed, which runs as its own process, so -nothing it writes reaches a cache inside the API process. The accessor's name, keys, -loader and staleness bound, if any, are **G23**. +the accessor selects organization-over-tenant whole-value precedence only for +registrations that permit it; `branding.theme` stays tenant-wide. Seed writes run +in their own process, so **G23** selects the uncached, ambient `ITenantSettingsAccessor` +under the +[accepted typed settings contract](#typed-settings-and-display-fallback-contract). ### Read API @@ -1513,11 +2075,10 @@ The display fallback chain, computed once per request the entity is resolved and **never** to the slug lookup: a course with no `en` translation has no `en` URL, and requesting one is a `404`. For the same reason a lesson with no translation in the requested locale is omitted from the course's lesson list -rather than rendered as a link that cannot resolve. Localization Standards § Locale -Model and Localization § Fallback Rules state different chains, and the shipped -`LocalizedText.Resolve` narrows one subtag at a time and ends at the first authored -value; which chain is the record is **G24**, and a per-tenant fallback configuration is -Phase 04's. +rather than rendered as a link that cannot resolve. P02d-3 reconciled the internal +fallback under **G24**: [Localization § Fallback Rules](../architecture/12-localization.md#fallback-rules) +owns the chain and actual resolved locale. Public response locale fields remain +P02d-4; per-tenant fallback configuration remains Phase 04's. **Publication is not a Row Level Security term.** The canonical policy filters on tenant and organization only, so the database does not keep an unpublished course or lesson off diff --git a/docs/standards/08-localization.md b/docs/standards/08-localization.md index ca8a2903..b118f259 100644 --- a/docs/standards/08-localization.md +++ b/docs/standards/08-localization.md @@ -29,12 +29,14 @@ Localization covers: ([Education data model](../modules/education/README.md#data-model-and-invariants)). - Slug is unique per `(tenant_id, locale)`, enforced on the translation table and flat across organizations — see [§ Pattern A](#pattern-a--side-translation-table-default-for-content-shaped-entities). -- Fallback chain: requested → tenant default → field-level fallback (if allowed) → render-safe missing-content state. +- Display fallback follows the single owner, + [Localization § Fallback Rules](../architecture/12-localization.md#fallback-rules), + under ADR-0008 and P02d-3's Accepted G24 answer. It never authorizes URL/body + fallback or enabled-locale membership. Resolved values carry their actual locale. > **Remaining Phase 02d decision.** G13 denies disabled or absent locale membership; -> enforcement belongs to P02d-2/4. Display fallback remains G24: this list and -> [Localization § Fallback Rules](../architecture/12-localization.md#fallback-rules) -> still need one reconciled owner in +> writers ship in P02d-2 and public enforcement belongs to P02d-4. G24's internal +> display fallback is Accepted; public response fields remain P02d-4 in > [Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register). ## URL Strategy @@ -231,8 +233,9 @@ var msg = _stringLocalizer["course.publish.success"]; [Tenancy contract](../modules/tenancy/README.md#locale-guarantees-and-read-contract) selects no platform registry: use LocaleTag's existing grammar, canonicalization and 35-character bound, then the tenant's enabled membership. No locale rows authorize -no content locale, rather than an implicit `en`. Request G6(b) and display G24 remain -their later packet parts. P02d-2 Step 2 implements locale command admission and the +no content locale, rather than an implicit `en`. Request G6(b) and G24's public +response fields remain P02d-4; P02d-3 implements the internal display fallback. +P02d-2 Step 2 implements locale command admission and the default-enabled CHECK; request-language negotiation is not part of those writers. ## Right-to-Left diff --git a/docs/standards/10-observability.md b/docs/standards/10-observability.md index 86073158..4bd22b46 100644 --- a/docs/standards/10-observability.md +++ b/docs/standards/10-observability.md @@ -233,7 +233,9 @@ backend treat business rejections as system failures. Cache `cache.name` is a governed, low-cardinality family from the Standards 20 inventory (`hub:host-map`, `hub:entitlement`, `identity:permissions`, `tenancy:feature-flags`, `tenancy:settings`, `audit:config`, or -`tenancy:killswitch`). An unregistered family is +`tenancy:killswitch`, `customization:content-types`, or `customization:taxonomies`). +The Customization families are Accepted by P02d-3's G22 pass under ADR-0040/0043; +their adapter mapping is implemented in Step 3. An unregistered family is reported as `other`; adapters never derive a label from a full cache key, tenant or organization id, host, session id, or entity id. `reason` is one of `explicit`, `expired`, or `capacity`; `outcome` is one of `success`, `faulted`, or `cancelled`. diff --git a/docs/standards/15-performance.md b/docs/standards/15-performance.md index fb47a857..394355da 100644 --- a/docs/standards/15-performance.md +++ b/docs/standards/15-performance.md @@ -43,6 +43,13 @@ Budgets are reviewed quarterly against measured production metrics. - TTL chosen per content type; default 5 minutes for catalog, 1 minute for course detail. - Cache hit ratio per cache name surfaced as a metric. +Customization's internal untranslated definition cache is a generation-driven +exception to event/locale invalidation under ADR-0043 and P02d-3's Accepted G22 +answer. Its [architecture owner](../architecture/32-tenant-customization-model.md#82-cache-strategy) +requires a fresh SQL generation probe; `< 1 ms` measures in-memory resolution +only, not end-to-end latency. Cold/partial/fault reads use at most two SELECTs. +Seeded timings and plans do not establish a production p95. + > **Open in Phase 02d.** That phase ships the first course-catalog reads and the first > pages rendered from them, and no Education publish event to invalidate a cache with. > Whether those reads are cached at all, with what directive and what freshness, is G27; diff --git a/docs/standards/20-infrastructure-stack.md b/docs/standards/20-infrastructure-stack.md index 6b2a1b6d..bd0f1152 100644 --- a/docs/standards/20-infrastructure-stack.md +++ b/docs/standards/20-infrastructure-stack.md @@ -243,12 +243,13 @@ different decisions: | `{tenant_id}:hub:entitlement` (plan projection) | 60 s | 15 min (upper bound; Hub-push refresh resets it) | `learnstack.hub.entitlement` | | `{tenant_id}:tenancy:feature-flags` | 60 s | 15 min | none yet — see the note below | | `{tenant_id}:identity:permissions:{session_id}` | 60 s | session-scoped (no L2) | `learnstack.identity.role` / `.membership` events | -| `{tenant_id}:tenancy:settings` (low-churn) | 5 min | 1 h | `learnstack.tenancy.settings` | +| `{tenant_id}:tenancy:settings` (reserved; unused in P02d-2/3) | No settings cache | No settings cache | Phase 02b owns the declared settings event; it is not implemented | | `{tenant_id}:audit:config` (per-tenant audit overrides) | 5 min | 1 h | none yet — see the note below | +| `{tenant_id}:customization:content-types:v{generation}` | 60 s | 15 min on Phase 11's trigger | Fresh durable generation, no event | +| `{tenant_id}:customization:taxonomies:v{generation}` | 60 s | 15 min on Phase 11's trigger | Fresh durable generation, no event | -Each of these is produced by a `CacheKey` factory, never by string -interpolation, and the mapping is written down because it is the part that -drifts: +When a family is used, its key is produced by a `CacheKey` factory, never by string +interpolation. The mapping is written down because it is the part that drifts: | Family | Composed by | |---|---| @@ -257,16 +258,30 @@ drifts: | `{tenant_id}:hub:entitlement` | `CacheKey.ForTenant(tenantId, "hub", "entitlement")` | | `{tenant_id}:tenancy:feature-flags` | `CacheKey.ForTenant(tenantId, "tenancy", "feature-flags")` | | `{tenant_id}:identity:permissions:{session_id}` | `CacheKey.ForTenant(tenantId, "identity", "permissions", sessionId)` | -| `{tenant_id}:tenancy:settings` | `CacheKey.ForTenant(tenantId, "tenancy", "settings")` | +| `{tenant_id}:tenancy:settings` (reserved; unused in P02d-2/3) | No accessor uses this key; the existing adapter metric spelling remains reserved | | `{tenant_id}:audit:config` | `CacheKey.ForTenant(tenantId, "audit", "config")` | - -**`{tenant_id}:tenancy:settings` has a key and no reader yet.** The family and its -`cache.name` mapping are shipped; nothing caches a settings read. Whether settings are -cached at all, how a cached read keeps one organization's overrides from reaching -another, and what bounds staleness before the `learnstack.tenancy.settings` event -exists, are G23 in -[Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register). -The pass that closes it edits both `tenancy:settings` rows above with its answer. +| `{tenant_id}:customization:content-types:v{generation}` | `CacheKey.ForTenant(tenantId, "customization", "content-types", $"v{generation}")` | +| `{tenant_id}:customization:taxonomies:v{generation}` | `CacheKey.ForTenant(tenantId, "customization", "taxonomies", $"v{generation}")` | + +The Customization rows are implemented in P02d-3 Step 3. The Accepted G12/G22 pass +selects generation-keyed untranslated families, ambient coherent loading and +dirty-scope bypass under ADR-0040/0043. Stable metrics omit the generation: +`customization:content-types`, `customization:taxonomies`. The +[architecture owner](../architecture/32-tenant-customization-model.md#82-cache-strategy) +states the snapshot/fill contract; TTL is reclamation, not its freshness bound. Schema +validation remains write-only under ADR-0043; the Implemented +`Customization_Projection_Does_Not_Validate_On_Read` guard enforces that +boundary in P02d-3, with planted interface/concrete/helper offenders and a clean +control. + +**Settings are uncached in P02d-2/3.** The +[Accepted G23 freshness answer](../roadmap/phase-02d-walking-skeleton.md#p02d-2-accepted-answers) +selects no settings cache. The adapter's existing `tenancy:settings` metric spelling +is reserved, not an active reader or a TTL promise. P02d-3 owns the typed ambient +accessor and explicit tenant/organization scope; it adds no settings generation +counter or out-of-band loader. Phase 02b owns the declared +`learnstack.tenancy.settings` event. Adding a cached settings consumer requires a +new scope/key/freshness decision before that consumer ships. **`{tenant_id}:audit:config` has no eager invalidation, and that is a stated gap rather than an omission.** The projection is read by `IAuditConfigService` on the classification @@ -332,8 +347,9 @@ Rules: a write makes every stale key unreachable at once without deleting any of them. The counter is domain state, never a cache entry — an evicted counter would make abandoned keys addressable again. -- L1 protects per-pod hot path; cross-pod consistency relies on L2 + eager - invalidation. +- L1 protects the per-pod hot path. P02d-3's definition families use a fresh + durable generation probe for cross-pod consistency, without L2 or events. + Other families follow the per-family consistency policy in the table above. - The 15-min L2 figure is an **upper bound**, not the typical refresh window — eager invalidation via Dapr is the typical path; the TTL is the safety net. - A "60s cache" reference in any other document refers to L1; a "15-min TTL" diff --git a/docs/standards/21-architecture-tests-catalogue.md b/docs/standards/21-architecture-tests-catalogue.md index 43673308..12021cab 100644 --- a/docs/standards/21-architecture-tests-catalogue.md +++ b/docs/standards/21-architecture-tests-catalogue.md @@ -95,11 +95,12 @@ not implemented is the failure mode this column exists to prevent. ### Implemented today -**140 test methods run in +**143 test methods run in [`backend/tests/LearnStack.Tests.Architecture`](../../backend/tests/LearnStack.Tests.Architecture),** shipped by [Phase 01](../roadmap/phase-01-repository-tooling.md), [Phase 02a Packets 2–3](../roadmap/phase-02a-kernel-tenancy.md), Packet 4, Packet 6, Packet 7, -Packet 8, Packet 9, Packet 10 and P02d-1. Methods are not rows: a `[Theory]` is one row and many cases, +Packet 8, Packet 9, Packet 10, P02d-1, P02d-2 and P02d-3. Methods are not rows: +a `[Theory]` is one row and many cases, and most rows pair a rule with the companion assertion that stops it passing vacuously. **Every number in this section is recomputed by `The_Catalogue_Counts_Its_Own_Rules`.** They @@ -124,7 +125,7 @@ two fifths of its subject is the defect this section is about. It also refuses a test class that exists nowhere, because otherwise a renamed or deleted file drops its entries out of the subject instead of failing. -**148 rules in this catalogue are Implemented, and 101 of them are in that assembly.** +**149 rules in this catalogue are Implemented, and 102 of them are in that assembly.** The other 47 are no less binding, and most could not live there. The table says where and why, and deliberately carries no per-row count: those are the numbers nothing recomputes, and the first version of this table claimed "three rules" for a suite @@ -1276,6 +1277,25 @@ otherwise). - **Mutation-checked.** A second handler taking two write ports, the sanctioned handler renamed, and the two ports fused into one — each turns the rule red. +#### `Customization_Projection_Does_Not_Validate_On_Read` + +- **Asserts:** the internal display projection never depends on + `IJsonSchemaValidator`; schema admission/body validation stay on the write path. + The scanner follows helpers across all production assemblies, including all + four Customization layers. Its module census and planted validator-dependent + offenders prevent an empty or narrowed scan from passing silently. +- **Source:** ADR-0043 and [Standards 20's projection cache contract](20-infrastructure-stack.md#icacheservice-state). +- **Type:** xUnit + Mono.Cecil. **Kind:** structural. +- **Status:** **Implemented** (`CustomizationProjectionTests`). The companion + `Projection_validator_guard_detects_direct_and_helper_dependencies` proves + interface, concrete-adapter, constraint, catch, attribute, wrapped generic, + lambda and state-machine dependencies are detected, with a clean negative + control. +`Projection_validator_guard_follows_domain_contracts_and_external_production_helpers` + plants dependencies in the production Cecil models and uses the real root + predicate and helper census to prove Domain, Contracts and core helper coverage. +- **Phase:** 02d (P02d-3). + ### Persistence: concurrency and the unit of work Source: [ADR-0039](../decisions/0039-optimistic-concurrency-token.md),