From 0dec43b5314a6da8b5155b876bfc805e9a196c13 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 2 Oct 2026 14:53:21 +0300 Subject: [PATCH 01/15] docs(roadmap): close P02d-2 after merge Record the accepted PR and merge verification, align current delivery status, and identify the remaining P02d-3 decisions before development. Preserve the dated delivery history and the frozen P02d-1 record. --- CLAUDE.md | 7 ++- README.md | 6 ++- docs/modules/education/README.md | 4 +- docs/roadmap/README.md | 6 +-- docs/roadmap/phase-02d-walking-skeleton.md | 62 +++++++++++++++++++++- 5 files changed, 76 insertions(+), 9 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e884be6..fbdc2b1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -79,8 +79,11 @@ 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 next; +its decision pass has not started. Public reads belong to 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 473c158..0f9d36a 100644 --- a/README.md +++ b/README.md @@ -73,8 +73,10 @@ 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 and their decision pass are next. **P02d-4** owns anonymous public API reads. Browser rendering follows in P02d-5–7; none of these later packets has started. diff --git a/docs/modules/education/README.md b/docs/modules/education/README.md index 54fd676..b2ed768 100644 --- a/docs/modules/education/README.md +++ b/docs/modules/education/README.md @@ -11,7 +11,9 @@ 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 next. The diagram includes the access column. ## Overview diff --git a/docs/roadmap/README.md b/docs/roadmap/README.md index 3d537d2..9081918 100644 --- a/docs/roadmap/README.md +++ b/docs/roadmap/README.md @@ -45,9 +45,9 @@ 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 and their decision pass are next - [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 ff21fe2..cf892ba 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -10,7 +10,7 @@ > |---|---|---| > | 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-2 | Writers and seed | ✅ complete and merged — 2026-10-02; [merge closeout](#p02d-2-merge-and-closeout-2026-10-02) | > | P02d-3 | Read internals | not started | > | P02d-4 | Public read API and contract checks | not started | > | P02d-5 | Server-rendering path | not started | @@ -28,6 +28,12 @@ revokes the acceptance-time wait. [Delivery](#p02d-2-implementation-delivery-202 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. +**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. +Phase 02d remains in progress. P02d-3 is next, with its decision pass still open. + ## Goal Put a working education site in a browser — twice, on two hosts, for two tenants in @@ -1046,6 +1052,60 @@ 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-1 decision pass (2026-09-14) **Accepted — 2026-09-14, verified against `6c58343`.** The maintainer approved From 307bbcd807d510af0beff69112933a630b576469 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 2 Oct 2026 16:48:52 +0300 Subject: [PATCH 02/15] docs: accept P02d-3 internal read contracts Record the approved cache, scope and fallback boundaries before readers ship so snapshot consistency and rollback safety are reviewable first. ADR: 0008, 0010, 0040, 0043 Module: Customization, Tenancy --- CLAUDE.md | 4 +- README.md | 4 +- docs/architecture/09-tenant-isolation.md | 9 + docs/architecture/12-localization.md | 28 ++- .../32-tenant-customization-model.md | 70 +++--- docs/glossary.md | 7 +- docs/modules/customization/README.md | 26 +- docs/modules/customization/audit.md | 6 + docs/modules/customization/permissions.md | 5 + docs/modules/tenancy/README.md | 28 ++- docs/modules/tenancy/audit.md | 6 + docs/modules/tenancy/permissions.md | 5 + docs/roadmap/README.md | 4 +- docs/roadmap/phase-02d-walking-skeleton.md | 222 +++++++++++++++++- docs/standards/08-localization.md | 10 +- docs/standards/10-observability.md | 4 +- docs/standards/15-performance.md | 7 + docs/standards/20-infrastructure-stack.md | 40 +++- .../21-architecture-tests-catalogue.md | 11 + 19 files changed, 414 insertions(+), 82 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index fbdc2b1..9c2e926 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -83,7 +83,9 @@ passed. Step 4 completes convergent seed execution after both review rounds. [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 next; -its decision pass has not started. Public reads belong to P02d-4. +its [decision package](docs/roadmap/phase-02d-walking-skeleton.md#p02d-3-decision-package-2026-10-02) +is Accepted — 2026-10-02. Its three implementation steps are next; public reads +belong to 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 0f9d36a..8efea24 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,9 @@ rounds and the focused fix review passed. Step 4 completes convergent seed 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 and their decision pass are next. +records verification. P02d-3 read internals are next; the +[decision package](docs/roadmap/phase-02d-walking-skeleton.md#p02d-3-decision-package-2026-10-02) +is Accepted — 2026-10-02, with three implementation steps to follow. **P02d-4** owns anonymous public API reads. Browser rendering follows in P02d-5–7; none of these later packets has started. diff --git a/docs/architecture/09-tenant-isolation.md b/docs/architecture/09-tenant-isolation.md index 52cd020..3ed76c0 100644 --- a/docs/architecture/09-tenant-isolation.md +++ b/docs/architecture/09-tenant-isolation.md @@ -256,6 +256,15 @@ 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. These are accepted contracts, +not delivered readers yet. + ``` {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 0135fa5..399ed99 100644 --- a/docs/architecture/12-localization.md +++ b/docs/architecture/12-localization.md @@ -203,21 +203,27 @@ 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. Locale-carrying resolution is not implemented yet. 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 b235a85..16d6e2e 100644 --- a/docs/architecture/32-tenant-customization-model.md +++ b/docs/architecture/32-tenant-customization-model.md @@ -525,21 +525,17 @@ 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; implementation pending P02d-3.** +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 +565,34 @@ 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 now, 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 fe03ef7..d1a1165 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 delivered P02d-2 contracts from P02d-3 accepted +contracts awaiting implementation: | 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. Not implemented or 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. | +| **`ITenantSettingsAccessor`** | Accepted P02d-3 typed, uncached ambient settings reader with registered scope/grammar and explicit organization precedence. Not implemented; the [decision package](roadmap/phase-02d-walking-skeleton.md#p02d-3-decision-package-2026-10-02) preserves tenant-wide branding and records acceptance on 2026-10-02. | | **`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 74e45df..4992b27 100644 --- a/docs/modules/customization/README.md +++ b/docs/modules/customization/README.md @@ -190,13 +190,21 @@ 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 contract Accepted — 2026-10-02; implementation pending.** +`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. + +[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 +291,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** p95 target, not yet measured | At most two SELECTs: probe plus coherent generation/rows snapshot; seeded rows/bytes and query plans measured in P02d-3, not a production p95 claim | | 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 7ce92d0..8b10610 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 read contract Accepted — 2026-10-02; implementation pending.** +`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 2d47626..7bc1c3c 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 read contract Accepted — 2026-10-02; implementation pending.** +`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/tenancy/README.md b/docs/modules/tenancy/README.md index 0a4ca1e..293f003 100644 --- a/docs/modules/tenancy/README.md +++ b/docs/modules/tenancy/README.md @@ -162,10 +162,32 @@ 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 + +**Accepted — 2026-10-02; implementation pending.** `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. + +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 diff --git a/docs/modules/tenancy/audit.md b/docs/modules/tenancy/audit.md index 50fe679..dd915d7 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 read contract Accepted — 2026-10-02; implementation pending.** +`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 672f512..9a35756 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 read contract Accepted — 2026-10-02; implementation pending.** +`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 9081918..89a3f37 100644 --- a/docs/roadmap/README.md +++ b/docs/roadmap/README.md @@ -47,7 +47,9 @@ not deferred to the showcase phase. - [Phase 02d: Two-Tenant Walking Skeleton](phase-02d-walking-skeleton.md) — **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 and their decision pass are next + records final verification. P02d-3 read internals are next; the + [decision package](phase-02d-walking-skeleton.md#p02d-3-decision-package-2026-10-02) + is Accepted — 2026-10-02; implementation follows in three steps - [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 cf892ba..ba97597 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -11,7 +11,7 @@ > | 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 | ✅ complete and merged — 2026-10-02; [merge closeout](#p02d-2-merge-and-closeout-2026-10-02) | -> | P02d-3 | Read internals | not started | +> | P02d-3 | Read internals | [decision package](#p02d-3-decision-package-2026-10-02) Accepted — 2026-10-02; implementation next | > | P02d-4 | Public read API and contract checks | not started | > | P02d-5 | Server-rendering path | not started | > | P02d-6 | Public renderer | not started | @@ -323,7 +323,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 | @@ -333,9 +333,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 | @@ -1106,6 +1106,218 @@ settings accessor. The packet table and decision register remain authoritative: 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). + ### P02d-1 decision pass (2026-09-14) **Accepted — 2026-09-14, verified against `6c58343`.** The maintainer approved diff --git a/docs/standards/08-localization.md b/docs/standards/08-localization.md index ca8a290..34306e1 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 diff --git a/docs/standards/10-observability.md b/docs/standards/10-observability.md index 8607315..2df8504 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 not implemented yet. 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 fb47a85..394355d 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 6b2a1b6..ab0d20c 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,29 @@ 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 Accepted, not implemented yet: P02d-3's 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 Registered +`Customization_Projection_Does_Not_Validate_On_Read` guard will enforce that +boundary in P02d-3, with a planted offender. + +**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 diff --git a/docs/standards/21-architecture-tests-catalogue.md b/docs/standards/21-architecture-tests-catalogue.md index 4367330..441b02e 100644 --- a/docs/standards/21-architecture-tests-catalogue.md +++ b/docs/standards/21-architecture-tests-catalogue.md @@ -1276,6 +1276,17 @@ 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 has a planted validator-dependent offender so an empty match is + not treated as evidence. +- **Source:** ADR-0043 and [Standards 20's projection cache contract](20-infrastructure-stack.md#icacheservice-state). +- **Type:** xUnit + Mono.Cecil. **Kind:** structural. +- **Status:** **Registered** (implementation and planted proof in P02d-3). +- **Phase:** 02d (P02d-3). + ### Persistence: concurrency and the unit of work Source: [ADR-0039](../decisions/0039-optimistic-concurrency-token.md), From 929320236ea653fb851a173d0d2c726c9fe1c077 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 2 Oct 2026 17:06:47 +0300 Subject: [PATCH 03/15] feat(tenancy): add typed settings and locale-carrying fallback Keep internal reads scoped and uncached while preserving authored locale metadata and the whole-theme grammar for later public projections. ADR: 0008, 0010, 0017, 0040 Module: Tenancy --- CLAUDE.md | 4 +- README.md | 3 +- .../PersistenceCompositionExtensions.cs | 1 + .../Localization/LocalizedText.cs | 18 +- .../Localization/ResolvedLocalizedText.cs | 4 + .../SeedComposition.cs | 1 + .../Settings/ITenantSettingsAccessor.cs | 26 ++ .../Branding/BrandingThemeRegistry.cs | 17 ++ .../Settings/TenantSettingRegistry.cs | 43 ++++ .../Persistence/TenantSettingsAccessor.cs | 58 +++++ .../TenantSettingsRegistration.cs | 17 ++ .../Database/TenantSettingsAccessorTests.cs | 228 ++++++++++++++++++ .../Modules/Tenancy/TenantSettingsTests.cs | 29 +++ .../Localization/LocalizedTextTests.cs | 24 ++ docs/architecture/09-tenant-isolation.md | 4 +- docs/architecture/12-localization.md | 2 +- docs/glossary.md | 6 +- docs/modules/tenancy/README.md | 7 +- docs/modules/tenancy/audit.md | 2 +- docs/modules/tenancy/permissions.md | 2 +- docs/roadmap/README.md | 3 +- docs/roadmap/phase-02d-walking-skeleton.md | 20 +- 22 files changed, 497 insertions(+), 22 deletions(-) create mode 100644 backend/src/LearnStack.SharedKernel/Localization/ResolvedLocalizedText.cs create mode 100644 backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application.Contracts/Settings/ITenantSettingsAccessor.cs create mode 100644 backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Settings/TenantSettingRegistry.cs create mode 100644 backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/TenantSettingsAccessor.cs create mode 100644 backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/TenantSettingsRegistration.cs create mode 100644 backend/tests/LearnStack.Tests.Integration/Database/TenantSettingsAccessorTests.cs create mode 100644 backend/tests/LearnStack.Tests.Unit/Modules/Tenancy/TenantSettingsTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index 9c2e926..8d97bdf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,8 +84,8 @@ passed. Step 4 completes convergent seed execution after both review rounds. [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 next; its [decision package](docs/roadmap/phase-02d-walking-skeleton.md#p02d-3-decision-package-2026-10-02) -is Accepted — 2026-10-02. Its three implementation steps are next; public reads -belong to P02d-4. +is Accepted — 2026-10-02. Step 1 implements typed settings and locale resolution; +its review is pending. Steps 2/3 and public reads in P02d-4 remain ahead. **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 8efea24..2b113b4 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,8 @@ execution after both review rounds. **P02d-2 is complete and merged** through [merge closeout](docs/roadmap/phase-02d-walking-skeleton.md#p02d-2-merge-and-closeout-2026-10-02) records verification. P02d-3 read internals are next; the [decision package](docs/roadmap/phase-02d-walking-skeleton.md#p02d-3-decision-package-2026-10-02) -is Accepted — 2026-10-02, with three implementation steps to follow. +is Accepted — 2026-10-02, with Step 1 implemented and review pending. +Steps 2/3 remain ahead. **P02d-4** owns anonymous public API reads. Browser rendering follows in P02d-5–7; none of these later packets has started. diff --git a/backend/src/LearnStack.Api/Composition/PersistenceCompositionExtensions.cs b/backend/src/LearnStack.Api/Composition/PersistenceCompositionExtensions.cs index 4d93e51..13de018 100644 --- a/backend/src/LearnStack.Api/Composition/PersistenceCompositionExtensions.cs +++ b/backend/src/LearnStack.Api/Composition/PersistenceCompositionExtensions.cs @@ -165,6 +165,7 @@ 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.AddModuleDbContext(); diff --git a/backend/src/LearnStack.SharedKernel/Localization/LocalizedText.cs b/backend/src/LearnStack.SharedKernel/Localization/LocalizedText.cs index 67c9378..0cc6c91 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 0000000..c2f1640 --- /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.Tools.Seeder/SeedComposition.cs b/backend/src/LearnStack.Tools.Seeder/SeedComposition.cs index cbf011b..ed9794b 100644 --- a/backend/src/LearnStack.Tools.Seeder/SeedComposition.cs +++ b/backend/src/LearnStack.Tools.Seeder/SeedComposition.cs @@ -93,6 +93,7 @@ public static ServiceProvider Build( services.AddScoped(); services.AddModuleDbContext(); + services.AddTenantSettingsReads(); services.AddScoped(); services.AddScoped(); services.AddScoped(); 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 0000000..4ddb392 --- /dev/null +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application.Contracts/Settings/ITenantSettingsAccessor.cs @@ -0,0 +1,26 @@ +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 static TenantSettingKey BrandingTheme { get; } = new("branding.theme"); +} 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 61ebd5f..79d1535 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; @@ -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 0000000..e0853e2 --- /dev/null +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Settings/TenantSettingRegistry.cs @@ -0,0 +1,43 @@ +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 => + _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 0000000..c17ff60 --- /dev/null +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/TenantSettingsAccessor.cs @@ -0,0 +1,58 @@ +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; + +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)) + { + throw new TenantContextMissingException("Settings reads require a resolved, announced 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)); + } + + var parsed = registration.Parse(selected.Value); + 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 0000000..6e0c9dd --- /dev/null +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/TenantSettingsRegistration.cs @@ -0,0 +1,17 @@ +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) + { + services.TryAddSingleton(TenantSettingRegistry.Default); + services.TryAddScoped(); + return services; + } +} 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 0000000..394b3f3 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/Database/TenantSettingsAccessorTests.cs @@ -0,0 +1,228 @@ +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"); + + [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"); + }); + } + + [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 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.Unit/Modules/Tenancy/TenantSettingsTests.cs b/backend/tests/LearnStack.Tests.Unit/Modules/Tenancy/TenantSettingsTests.cs new file mode 100644 index 0000000..f205aaf --- /dev/null +++ b/backend/tests/LearnStack.Tests.Unit/Modules/Tenancy/TenantSettingsTests.cs @@ -0,0 +1,29 @@ +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(); + 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 71792aa..95ccb39 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/09-tenant-isolation.md b/docs/architecture/09-tenant-isolation.md index 3ed76c0..82f701d 100644 --- a/docs/architecture/09-tenant-isolation.md +++ b/docs/architecture/09-tenant-isolation.md @@ -262,8 +262,8 @@ its generation and ambient fill rules live in 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. These are accepted contracts, -not delivered readers yet. +owns whole-value precedence and tenant-wide branding. Step 1 implements the settings +reader; the Customization reader remains pending. ``` {tenant_id}:{org_id}:{module}:{logical-name} ← a value scoped to one organization diff --git a/docs/architecture/12-localization.md b/docs/architecture/12-localization.md index 399ed99..bdfdba2 100644 --- a/docs/architecture/12-localization.md +++ b/docs/architecture/12-localization.md @@ -206,7 +206,7 @@ Pattern B is cheaper for short fields where joining a translation table is overk **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. Locale-carrying resolution is not implemented yet. +records acceptance. Step 1 implements locale-carrying resolution; review is pending. When the requested locale is unavailable: diff --git a/docs/glossary.md b/docs/glossary.md index d1a1165..ee8da4a 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -140,14 +140,14 @@ This glossary defines LearnStack-specific terms. When a term is ambiguous across ## Extension Model -These entries distinguish delivered P02d-2 contracts from P02d-3 accepted -contracts awaiting implementation: +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. Not implemented or 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. | -| **`ITenantSettingsAccessor`** | Accepted P02d-3 typed, uncached ambient settings reader with registered scope/grammar and explicit organization precedence. Not implemented; the [decision package](roadmap/phase-02d-walking-skeleton.md#p02d-3-decision-package-2026-10-02) preserves tenant-wide branding and 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. Review is pending. | | **`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. | diff --git a/docs/modules/tenancy/README.md b/docs/modules/tenancy/README.md index 293f003..a304d08 100644 --- a/docs/modules/tenancy/README.md +++ b/docs/modules/tenancy/README.md @@ -164,7 +164,7 @@ queries are Off. ### P02d-3 accepted typed settings contract -**Accepted — 2026-10-02; implementation pending.** `ITenantSettingsAccessor` +**Step 1 implemented — 2026-10-02; review pending.** `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`, @@ -402,7 +402,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 @@ -426,7 +426,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 dd915d7..213c29b 100644 --- a/docs/modules/tenancy/audit.md +++ b/docs/modules/tenancy/audit.md @@ -3,7 +3,7 @@ Per [Audit Coverage](../../standards/18-audit-coverage.md), which names this file. Part of the [module spec](README.md). -**P02d-3 read contract Accepted — 2026-10-02; implementation pending.** +**P02d-3 Step 1 implemented — 2026-10-02; review pending.** `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 diff --git a/docs/modules/tenancy/permissions.md b/docs/modules/tenancy/permissions.md index 9a35756..53b46fc 100644 --- a/docs/modules/tenancy/permissions.md +++ b/docs/modules/tenancy/permissions.md @@ -3,7 +3,7 @@ Per [Permission Standards](../../standards/19-permissions.md), which names this file. Part of the [module spec](README.md). -**P02d-3 read contract Accepted — 2026-10-02; implementation pending.** +**P02d-3 Step 1 implemented — 2026-10-02; review pending.** `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. diff --git a/docs/roadmap/README.md b/docs/roadmap/README.md index 89a3f37..3352eb6 100644 --- a/docs/roadmap/README.md +++ b/docs/roadmap/README.md @@ -49,7 +49,8 @@ not deferred to the showcase phase. [P02d-2 closeout](phase-02d-walking-skeleton.md#p02d-2-merge-and-closeout-2026-10-02) records final verification. P02d-3 read internals are next; the [decision package](phase-02d-walking-skeleton.md#p02d-3-decision-package-2026-10-02) - is Accepted — 2026-10-02; implementation follows in three steps + is Accepted — 2026-10-02; Step 1 is implemented with review pending. + Steps 2/3 remain ahead - [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 ba97597..a3176dd 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -11,7 +11,7 @@ > | 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 | ✅ 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 — 2026-10-02; implementation next | +> | P02d-3 | Read internals | [decision package](#p02d-3-decision-package-2026-10-02) Accepted; Step 1 implemented, review pending | > | P02d-4 | Public read API and contract checks | not started | > | P02d-5 | Server-rendering path | not started | > | P02d-6 | Public renderer | not started | @@ -1318,6 +1318,24 @@ The three-step plan is part of the accepted package. This decision commit preced 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 + +**In progress — 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 remain pending; +Step 2 has not started. Public consumers/metadata remain P02d-4/6. + ### P02d-1 decision pass (2026-09-14) **Accepted — 2026-09-14, verified against `6c58343`.** The maintainer approved From 58480666dbe90b3ba78c9cd08ee01815279381f0 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 2 Oct 2026 17:15:11 +0300 Subject: [PATCH 04/15] docs: align read guidance after P02d-3 foundation review Remove the second fallback algorithm and stale composition comments so future readers use the accepted resolver and understand ambient scope. ADR: 0008, 0040 Module: Tenancy --- .../PersistenceCompositionExtensions.cs | 2 +- .../LearnStack.Tools.Seeder/SeedComposition.cs | 2 +- docs/architecture/12-localization.md | 16 ++++++---------- docs/roadmap/phase-02d-walking-skeleton.md | 8 ++++++++ 4 files changed, 16 insertions(+), 12 deletions(-) diff --git a/backend/src/LearnStack.Api/Composition/PersistenceCompositionExtensions.cs b/backend/src/LearnStack.Api/Composition/PersistenceCompositionExtensions.cs index 13de018..2f2c089 100644 --- a/backend/src/LearnStack.Api/Composition/PersistenceCompositionExtensions.cs +++ b/backend/src/LearnStack.Api/Composition/PersistenceCompositionExtensions.cs @@ -220,7 +220,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.Tools.Seeder/SeedComposition.cs b/backend/src/LearnStack.Tools.Seeder/SeedComposition.cs index ed9794b..534b451 100644 --- a/backend/src/LearnStack.Tools.Seeder/SeedComposition.cs +++ b/backend/src/LearnStack.Tools.Seeder/SeedComposition.cs @@ -170,7 +170,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/docs/architecture/12-localization.md b/docs/architecture/12-localization.md index bdfdba2..2d660e6 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 diff --git a/docs/roadmap/phase-02d-walking-skeleton.md b/docs/roadmap/phase-02d-walking-skeleton.md index a3176dd..755f135 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -1336,6 +1336,14 @@ All three populations have zero failed/skipped; formatting and document checks p Both independent review rounds remain pending; Step 2 has not started. 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. Round 2 remains pending. + + ### P02d-1 decision pass (2026-09-14) **Accepted — 2026-09-14, verified against `6c58343`.** The maintainer approved From 482941439da7a7cfa4d9c26d5946d49112888bc5 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 2 Oct 2026 17:28:19 +0300 Subject: [PATCH 05/15] docs: close both P02d-3 foundation review rounds Record both independent reviews and align current-state guidance. ADR: 0008, 0040 Module: Tenancy --- CLAUDE.md | 2 +- README.md | 2 +- docs/glossary.md | 2 +- docs/modules/tenancy/README.md | 2 +- docs/modules/tenancy/audit.md | 2 +- docs/modules/tenancy/permissions.md | 2 +- docs/roadmap/README.md | 2 +- docs/roadmap/phase-02d-walking-skeleton.md | 11 ++++++++--- 8 files changed, 15 insertions(+), 10 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 8d97bdf..ec9f948 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -85,7 +85,7 @@ passed. Step 4 completes convergent seed execution after both review rounds. records the accepted head and merge verification. P02d-3 read internals are next; 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; -its review is pending. Steps 2/3 and public reads in P02d-4 remain ahead. +both review rounds passed. Steps 2/3 and public reads in P02d-4 remain ahead. **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 2b113b4..750ecf7 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,7 @@ execution after both review rounds. **P02d-2 is complete and merged** through [merge closeout](docs/roadmap/phase-02d-walking-skeleton.md#p02d-2-merge-and-closeout-2026-10-02) records verification. P02d-3 read internals are next; 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 review pending. +is Accepted — 2026-10-02, with Step 1 implemented and both review rounds passed. Steps 2/3 remain ahead. **P02d-4** owns anonymous public API reads. Browser rendering follows in P02d-5–7; none of these later packets has started. diff --git a/docs/glossary.md b/docs/glossary.md index ee8da4a..8878968 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -147,7 +147,7 @@ These entries distinguish P02d-2 contracts from P02d-3 internal read contracts: | **`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. Not implemented or 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. Review is pending. | +| **`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. | diff --git a/docs/modules/tenancy/README.md b/docs/modules/tenancy/README.md index a304d08..638ff68 100644 --- a/docs/modules/tenancy/README.md +++ b/docs/modules/tenancy/README.md @@ -164,7 +164,7 @@ queries are Off. ### P02d-3 accepted typed settings contract -**Step 1 implemented — 2026-10-02; review pending.** `ITenantSettingsAccessor` +**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`, diff --git a/docs/modules/tenancy/audit.md b/docs/modules/tenancy/audit.md index 213c29b..78ef483 100644 --- a/docs/modules/tenancy/audit.md +++ b/docs/modules/tenancy/audit.md @@ -3,7 +3,7 @@ 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; review pending.** +**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 diff --git a/docs/modules/tenancy/permissions.md b/docs/modules/tenancy/permissions.md index 53b46fc..925ba1f 100644 --- a/docs/modules/tenancy/permissions.md +++ b/docs/modules/tenancy/permissions.md @@ -3,7 +3,7 @@ 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; review pending.** +**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. diff --git a/docs/roadmap/README.md b/docs/roadmap/README.md index 3352eb6..ed6b5e9 100644 --- a/docs/roadmap/README.md +++ b/docs/roadmap/README.md @@ -49,7 +49,7 @@ not deferred to the showcase phase. [P02d-2 closeout](phase-02d-walking-skeleton.md#p02d-2-merge-and-closeout-2026-10-02) records final verification. P02d-3 read internals are next; the [decision package](phase-02d-walking-skeleton.md#p02d-3-decision-package-2026-10-02) - is Accepted — 2026-10-02; Step 1 is implemented with review pending. + is Accepted — 2026-10-02; Step 1 is implemented; both review rounds passed. Steps 2/3 remain ahead - [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) diff --git a/docs/roadmap/phase-02d-walking-skeleton.md b/docs/roadmap/phase-02d-walking-skeleton.md index 755f135..278d9b0 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -1333,15 +1333,20 @@ 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 remain pending; -Step 2 has not started. Public consumers/metadata remain P02d-4/6. +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. Round 2 remains pending. +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. ### P02d-1 decision pass (2026-09-14) From 77197b92f7162ced5dbb6c0709a891b962770eb8 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 2 Oct 2026 17:44:55 +0300 Subject: [PATCH 06/15] feat(customization): add coherent batched definition projections Resolve exact eligible revision pins with actual display locales on the ambient tenant transaction. Keep schema admission on writers and prove snapshot coherence, isolation and the no-validation guard. ADR: 0010, 0040, 0043 Module: Customization --- CLAUDE.md | 3 +- README.md | 7 +- .../PersistenceCompositionExtensions.cs | 2 + .../SeedComposition.cs | 2 + ...CustomizationDefinitionProjectionReader.cs | 41 +++ .../CustomizationReadRegistration.cs | 16 + ...CustomizationDefinitionProjectionReader.cs | 100 ++++++ .../Projections/DefinitionSnapshot.cs | 16 + .../Projections/DefinitionSnapshotStore.cs | 127 +++++++ .../CustomizationProjectionTests.cs | 74 +++++ .../tests/LearnStack.Tests.Architecture/Il.cs | 4 + .../Database/CustomizationProjectionTests.cs | 309 ++++++++++++++++++ .../32-tenant-customization-model.md | 3 +- docs/glossary.md | 2 +- docs/modules/customization/README.md | 5 +- docs/modules/customization/audit.md | 2 +- docs/modules/customization/permissions.md | 2 +- docs/roadmap/README.md | 3 +- docs/roadmap/phase-02d-walking-skeleton.md | 25 +- docs/standards/20-infrastructure-stack.md | 6 +- .../21-architecture-tests-catalogue.md | 8 +- 21 files changed, 738 insertions(+), 19 deletions(-) create mode 100644 backend/src/Modules/Customization/LearnStack.Modules.Customization.Application.Contracts/Definitions/ICustomizationDefinitionProjectionReader.cs create mode 100644 backend/src/Modules/Customization/LearnStack.Modules.Customization.Infrastructure/CustomizationReadRegistration.cs create mode 100644 backend/src/Modules/Customization/LearnStack.Modules.Customization.Infrastructure/Projections/CustomizationDefinitionProjectionReader.cs create mode 100644 backend/src/Modules/Customization/LearnStack.Modules.Customization.Infrastructure/Projections/DefinitionSnapshot.cs create mode 100644 backend/src/Modules/Customization/LearnStack.Modules.Customization.Infrastructure/Projections/DefinitionSnapshotStore.cs create mode 100644 backend/tests/LearnStack.Tests.Architecture/CustomizationProjectionTests.cs create mode 100644 backend/tests/LearnStack.Tests.Integration/Database/CustomizationProjectionTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index ec9f948..21b7938 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -85,7 +85,8 @@ passed. Step 4 completes convergent seed execution after both review rounds. records the accepted head and merge verification. P02d-3 read internals are next; 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. Steps 2/3 and public reads in P02d-4 remain ahead. +both review rounds passed. Step 2 implements uncached batched definition reads; +review and Step 3 caching remain ahead. 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 750ecf7..43b8601 100644 --- a/README.md +++ b/README.md @@ -79,14 +79,15 @@ execution after both review rounds. **P02d-2 is complete and merged** through records verification. P02d-3 read internals are next; 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. -Steps 2/3 remain ahead. +Step 2 implements uncached batched definition reads; review and Step 3 caching +remain ahead. **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 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 2f2c089..5eecc33 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; @@ -167,6 +168,7 @@ public static IServiceCollection AddLearnStackPersistence( services.AddModuleDbContext(); services.AddTenantSettingsReads(); services.AddModuleDbContext(); + services.AddCustomizationProjectionReads(); services.AddModuleDbContext(); // Audit's context is registered for the model, not for a writer. Rows reach diff --git a/backend/src/LearnStack.Tools.Seeder/SeedComposition.cs b/backend/src/LearnStack.Tools.Seeder/SeedComposition.cs index 534b451..c730e1e 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; @@ -112,6 +113,7 @@ public static ServiceProvider Build( services.AddSingleton(); services.AddModuleDbContext(); + services.AddCustomizationProjectionReads(); services.AddModuleDbContext(); services.AddScoped(); services.AddScoped(); 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 0000000..55e9f64 --- /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 0000000..7fe71de --- /dev/null +++ b/backend/src/Modules/Customization/LearnStack.Modules.Customization.Infrastructure/CustomizationReadRegistration.cs @@ -0,0 +1,16 @@ +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(); + return services; + } +} 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 0000000..9f41041 --- /dev/null +++ b/backend/src/Modules/Customization/LearnStack.Modules.Customization.Infrastructure/Projections/CustomizationDefinitionProjectionReader.cs @@ -0,0 +1,100 @@ +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) + : 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(); + } + + // Step 2 deliberately probes and then always loads; Step 3 adds clean-scope caching. + await store.ProbeAsync(context.TenantId, cancellationToken); + var snapshot = await store.LoadAsync(context.TenantId, cancellationToken); + if ((snapshot.Generation is null && snapshot.HasDefinitionRows) || snapshot.Generation is <= 0) + { + return Refused(); + } + + return Result.Ok(Resolve(snapshot, request)); + } + + 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_schema_extension_unresolved")], + })); +} 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 0000000..9163580 --- /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, DefinitionRevision Revision, DefinitionStatus Status, LocalizedText DisplayName, + string RendererKey, ImmutableArray Fields); +internal sealed record UntranslatedTaxonomy( + Guid Id, DefinitionRevision Revision, 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 0000000..a28a80f --- /dev/null +++ b/backend/src/Modules/Customization/LearnStack.Modules.Customization.Infrastructure/Projections/DefinitionSnapshotStore.cs @@ -0,0 +1,127 @@ +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) +{ + // 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); + 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(), revision, + Status(row), Label(row), renderer, presentation.Value)); + } + catch (ArgumentException) + { + // Invalid stored label/profile makes this pin missing, not its neighbors. + } + } + + return new ContentTypeFamily(definitions.ToImmutable()); + } + + private static TaxonomyFamily ReadTaxonomies(string json) + { + using var document = JsonDocument.Parse(json); + 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(), revision, + Status(row), Label(row), bands)); + } + catch (ArgumentException) + { + // Refuse the whole vocabulary if one of its stored labels is malformed. + } + } + + 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/tests/LearnStack.Tests.Architecture/CustomizationProjectionTests.cs b/backend/tests/LearnStack.Tests.Architecture/CustomizationProjectionTests.cs new file mode 100644 index 0000000..88a9ad0 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Architecture/CustomizationProjectionTests.cs @@ -0,0 +1,74 @@ +using FluentAssertions; +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 +{ + /// + /// ADR-0043 + /// and Standards 20. + /// + [Fact] + public void Customization_Projection_Does_Not_Validate_On_Read() + { + using var infrastructure = ModuleDefinition.ReadModule(typeof(CustomizationDefinitionProjectionReader).Assembly.Location); + using var application = ModuleDefinition.ReadModule(typeof(LearnStack.Modules.Customization.Application.Customization.TextCardPresentation).Assembly.Location); + var types = infrastructure.GetTypes().Concat(application.GetTypes()) + .Where(type => type.FullName.StartsWith("LearnStack.Modules.Customization.", StringComparison.Ordinal)) + .ToDictionary(type => type.FullName); + var roots = infrastructure.GetTypes().Where(type => type.Interfaces.Any(contract => + contract.InterfaceType.FullName == typeof(ICustomizationDefinitionProjectionReader).FullName)).ToArray(); + 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 module helpers may reach schema validation"); + } + + [Fact] + public void Projection_validator_guard_detects_direct_and_helper_dependencies() + { + using var module = ModuleDefinition.ReadModule(typeof(CustomizationProjectionTests).Assembly.Location); + var types = module.GetTypes().ToDictionary(type => type.FullName); + var direct = module.GetType(typeof(ValidatorProbe).FullName!.Replace('+', '/')); + var indirect = module.GetType(typeof(HelperProbe).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([clean], types).Should().BeEmpty(); + } + + private static HashSet Offenders(IEnumerable roots, Dictionary types) + { + var visited = new HashSet(StringComparer.Ordinal); + var offenders = new HashSet(StringComparer.Ordinal); + var queue = new Queue(roots); + while (queue.TryDequeue(out var type)) + { + if (!visited.Add(type.FullName)) continue; + foreach (var name in Il.ReferencedTypeNames(type)) + { + if (name == typeof(IJsonSchemaValidator).FullName || name.StartsWith("Json.Schema.", StringComparison.Ordinal)) + offenders.Add(type.FullName); + if (types.TryGetValue(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 CleanProbe + { + public static string Read() => "display"; + } +} diff --git a/backend/tests/LearnStack.Tests.Architecture/Il.cs b/backend/tests/LearnStack.Tests.Architecture/Il.cs index 5633fb4..ac14854 100644 --- a/backend/tests/LearnStack.Tests.Architecture/Il.cs +++ b/backend/tests/LearnStack.Tests.Architecture/Il.cs @@ -20,6 +20,10 @@ 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); + /// /// 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. 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 0000000..e7c13d6 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/Database/CustomizationProjectionTests.cs @@ -0,0 +1,309 @@ +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.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.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +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 CustomizationProjectionTests(SchemaFixture schema) +{ + 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(); + } + + [Fact] + public async Task Exact_batches_keep_deprecated_pins_and_distinguish_draft_deleted_absent_and_malformed() + { + 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 tenant_level_taxonomies SET display_name = '{"en":42}'::jsonb + WHERE key = 'levels' AND schema_version = 2; + UPDATE customization_generations SET generation = generation + 1; + """); + 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 read = await ReadSession.OpenAsync(source, context); + 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_schema_extension_unresolved"); + 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(); + } + + [Fact] + public async Task Publish_between_probe_and_load_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 read = await ReadSession.OpenAsync(source, context); + var probeGeneration = await GenerationAsync(read.Unit); + 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 frame.CompleteAsync(); + }; + var projection = (await read.Reader.ReadAsync(Request([new("profile", 1), new("profile", 2)], + [new("levels", 1), new("levels", 2)]))).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 class CommandObserver : DbCommandInterceptor + { + public int Selects { get; private set; } + public bool Intervened { get; private set; } + public Func? AfterProbe { get; set; } + public override async ValueTask ReaderExecutedAsync(DbCommand command, CommandExecutedEventData eventData, + DbDataReader result, CancellationToken cancellationToken = default) + { + Selects++; + if (AfterProbe is { } action && command.CommandText.Contains("P02d-3 generation probe", StringComparison.Ordinal)) + { + AfterProbe = null; + await action(); + 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) + { + _provider = provider; _scope = scope; Unit = unit; Frame = frame; Db = db; Observer = observer; + Reader = new CustomizationDefinitionProjectionReader(new DefinitionSnapshotStore(db), context, unit); + } + 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) + { + 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); + } + public async ValueTask DisposeAsync() + { + await Frame.DisposeAsync(); + await Db.DisposeAsync(); + await _scope.DisposeAsync(); + await _provider.DisposeAsync(); + } + } +} diff --git a/docs/architecture/32-tenant-customization-model.md b/docs/architecture/32-tenant-customization-model.md index 16d6e2e..2501fc1 100644 --- a/docs/architecture/32-tenant-customization-model.md +++ b/docs/architecture/32-tenant-customization-model.md @@ -529,7 +529,8 @@ per tenant per month. That ratio is the whole design. | `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; implementation pending P02d-3.** +**G12 cache/G22 Accepted — 2026-10-02.** P02d-3 Step 2 implements the +uncached coherent loader; its review and Step 3 caching remain pending. 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, diff --git a/docs/glossary.md b/docs/glossary.md index 8878968..f405468 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -145,7 +145,7 @@ 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. Not implemented or 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. | +| **`ICustomizationDefinitionProjectionReader`** | Accepted P02d-3 internal batched display reader for exact tenant-owned revision pins, with immutable generation-keyed definition families. Step 2 implements the uncached coherent loader; caching and review remain pending. 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). | diff --git a/docs/modules/customization/README.md b/docs/modules/customization/README.md index 4992b27..3a4ac8b 100644 --- a/docs/modules/customization/README.md +++ b/docs/modules/customization/README.md @@ -190,14 +190,15 @@ absence rather than substituting an unrelated diagram for it. ### Primary read flow: resolving a tenant's shapes -**P02d-3 contract Accepted — 2026-10-02; implementation pending.** +**P02d-3 Step 2 implemented — 2026-10-02; review pending.** `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 public response/refusal and page state remain P02d-4/6. The coherent loader +currently runs uncached; generation-keyed cache behavior is Step 3. [Cache strategy § 8.2](../../architecture/32-tenant-customization-model.md#82-cache-strategy) owns family keys, ambient snapshot loading, dirty-scope bypass, fault/cancellation diff --git a/docs/modules/customization/audit.md b/docs/modules/customization/audit.md index 8b10610..5a62a7c 100644 --- a/docs/modules/customization/audit.md +++ b/docs/modules/customization/audit.md @@ -3,7 +3,7 @@ Per [Audit Coverage](../../standards/18-audit-coverage.md), which names this file. Part of the [module spec](README.md). -**P02d-3 read contract Accepted — 2026-10-02; implementation pending.** +**P02d-3 Step 2 implemented — 2026-10-02; review pending.** `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 diff --git a/docs/modules/customization/permissions.md b/docs/modules/customization/permissions.md index 7bc1c3c..e86f420 100644 --- a/docs/modules/customization/permissions.md +++ b/docs/modules/customization/permissions.md @@ -3,7 +3,7 @@ Per [Permission Standards](../../standards/19-permissions.md), which names this file. Part of the [module spec](README.md). -**P02d-3 read contract Accepted — 2026-10-02; implementation pending.** +**P02d-3 Step 2 implemented — 2026-10-02; review pending.** `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. diff --git a/docs/roadmap/README.md b/docs/roadmap/README.md index ed6b5e9..cdbe543 100644 --- a/docs/roadmap/README.md +++ b/docs/roadmap/README.md @@ -50,7 +50,8 @@ not deferred to the showcase phase. records final verification. P02d-3 read internals are next; 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. - Steps 2/3 remain ahead + Step 2 implements uncached batched definition reads; review and Step 3 caching + remain ahead - [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 278d9b0..849f598 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -11,7 +11,7 @@ > | 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 | ✅ 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 implemented, review pending | +> | 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) implemented, review pending; Step 3 ahead | > | P02d-4 | Public read API and contract checks | not started | > | P02d-5 | Server-rendering path | not started | > | P02d-6 | Public renderer | not started | @@ -1333,7 +1333,8 @@ 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. +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 @@ -1349,6 +1350,26 @@ 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 fresh review rounds +remain pending; no cache implementation is claimed. + + ### P02d-1 decision pass (2026-09-14) **Accepted — 2026-09-14, verified against `6c58343`.** The maintainer approved diff --git a/docs/standards/20-infrastructure-stack.md b/docs/standards/20-infrastructure-stack.md index ab0d20c..e1d62b6 100644 --- a/docs/standards/20-infrastructure-stack.md +++ b/docs/standards/20-infrastructure-stack.md @@ -269,9 +269,9 @@ 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 Registered -`Customization_Projection_Does_Not_Validate_On_Read` guard will enforce that -boundary in P02d-3, with a planted offender. +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 direct/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) diff --git a/docs/standards/21-architecture-tests-catalogue.md b/docs/standards/21-architecture-tests-catalogue.md index 441b02e..27ba269 100644 --- a/docs/standards/21-architecture-tests-catalogue.md +++ b/docs/standards/21-architecture-tests-catalogue.md @@ -95,7 +95,7 @@ not implemented is the failure mode this column exists to prevent. ### Implemented today -**140 test methods run in +**142 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, @@ -124,7 +124,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 @@ -1284,7 +1284,9 @@ otherwise). not treated as evidence. - **Source:** ADR-0043 and [Standards 20's projection cache contract](20-infrastructure-stack.md#icacheservice-state). - **Type:** xUnit + Mono.Cecil. **Kind:** structural. -- **Status:** **Registered** (implementation and planted proof in P02d-3). +- **Status:** **Implemented** (`CustomizationProjectionTests`). The companion + `Projection_validator_guard_detects_direct_and_helper_dependencies` proves direct + and module-helper dependencies are detected, with a clean negative control. - **Phase:** 02d (P02d-3). ### Persistence: concurrency and the unit of work From 94a84abb26b0dabbf007859edc2b8205fa60be1a Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 2 Oct 2026 17:53:42 +0300 Subject: [PATCH 07/15] test: strengthen projection guard and align read status Cover concrete validator adapters as well as the interface and module helpers. Reconcile the two verified review documentation findings. ADR: 0008, 0040, 0043 Module: Customization, Tenancy --- .../CustomizationProjectionTests.cs | 13 ++++++++++++- docs/architecture/09-tenant-isolation.md | 5 +++-- docs/architecture/12-localization.md | 3 ++- docs/roadmap/phase-02d-walking-skeleton.md | 12 ++++++++++-- docs/standards/20-infrastructure-stack.md | 2 +- docs/standards/21-architecture-tests-catalogue.md | 2 +- 6 files changed, 29 insertions(+), 8 deletions(-) diff --git a/backend/tests/LearnStack.Tests.Architecture/CustomizationProjectionTests.cs b/backend/tests/LearnStack.Tests.Architecture/CustomizationProjectionTests.cs index 88a9ad0..a24ffcd 100644 --- a/backend/tests/LearnStack.Tests.Architecture/CustomizationProjectionTests.cs +++ b/backend/tests/LearnStack.Tests.Architecture/CustomizationProjectionTests.cs @@ -1,4 +1,5 @@ using FluentAssertions; +using LearnStack.Infrastructure.Validation; using LearnStack.Modules.Customization.Application.Contracts.Definitions; using LearnStack.Modules.Customization.Infrastructure.Projections; using LearnStack.SharedKernel.Validation; @@ -9,6 +10,10 @@ 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. @@ -35,9 +40,11 @@ public void Projection_validator_guard_detects_direct_and_helper_dependencies() var types = module.GetTypes().ToDictionary(type => type.FullName); 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(); } @@ -51,7 +58,7 @@ private static HashSet Offenders(IEnumerable roots, Dict if (!visited.Add(type.FullName)) continue; foreach (var name in Il.ReferencedTypeNames(type)) { - if (name == typeof(IJsonSchemaValidator).FullName || name.StartsWith("Json.Schema.", StringComparison.Ordinal)) + if (ValidatorTypes.Contains(name) || name.StartsWith("Json.Schema.", StringComparison.Ordinal)) offenders.Add(type.FullName); if (types.TryGetValue(name, out var helper)) queue.Enqueue(helper); } @@ -67,6 +74,10 @@ 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"; diff --git a/docs/architecture/09-tenant-isolation.md b/docs/architecture/09-tenant-isolation.md index 82f701d..6dfc8db 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 | @@ -263,7 +263,8 @@ The typed settings accessor is uncached and explicitly selects tenant-wide/curre 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; the Customization reader remains pending. +reader; Step 2 implements the uncached Customization projection. Its review and +Step 3 cache implementation remain pending. ``` {tenant_id}:{org_id}:{module}:{logical-name} ← a value scoped to one organization diff --git a/docs/architecture/12-localization.md b/docs/architecture/12-localization.md index 2d660e6..0014fb5 100644 --- a/docs/architecture/12-localization.md +++ b/docs/architecture/12-localization.md @@ -202,7 +202,8 @@ Pattern B is cheaper for short fields where joining a translation table is overk **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; review is pending. +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: diff --git a/docs/roadmap/phase-02d-walking-skeleton.md b/docs/roadmap/phase-02d-walking-skeleton.md index 849f598..68ea29d 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -1366,8 +1366,16 @@ 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 fresh review rounds -remain pending; no cache implementation is claimed. +restoration. Formatting and documentation checks pass. Round 1 completed; round 2 remains +pending. 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; round 2 remains pending. ### P02d-1 decision pass (2026-09-14) diff --git a/docs/standards/20-infrastructure-stack.md b/docs/standards/20-infrastructure-stack.md index e1d62b6..6d308d2 100644 --- a/docs/standards/20-infrastructure-stack.md +++ b/docs/standards/20-infrastructure-stack.md @@ -271,7 +271,7 @@ dirty-scope bypass under ADR-0040/0043. Stable metrics omit the generation: 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 direct/helper offenders and a clean control. +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) diff --git a/docs/standards/21-architecture-tests-catalogue.md b/docs/standards/21-architecture-tests-catalogue.md index 27ba269..47ede6b 100644 --- a/docs/standards/21-architecture-tests-catalogue.md +++ b/docs/standards/21-architecture-tests-catalogue.md @@ -1285,7 +1285,7 @@ otherwise). - **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 direct + `Projection_validator_guard_detects_direct_and_helper_dependencies` proves interface, concrete-adapter and module-helper dependencies are detected, with a clean negative control. - **Phase:** 02d (P02d-3). From 287318f188c4c4ecd8aa39871172088a7d132ed5 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 2 Oct 2026 18:04:52 +0300 Subject: [PATCH 08/15] docs: close both P02d-3 projection review rounds Record fresh approvals and align current implementation status. ADR: 0010, 0040, 0043 Module: Customization, Tenancy --- CLAUDE.md | 2 +- README.md | 4 ++-- docs/architecture/09-tenant-isolation.md | 4 ++-- docs/architecture/32-tenant-customization-model.md | 2 +- docs/glossary.md | 2 +- docs/modules/customization/README.md | 2 +- docs/modules/customization/audit.md | 2 +- docs/modules/customization/permissions.md | 2 +- docs/modules/tenancy/README.md | 3 ++- docs/roadmap/README.md | 4 ++-- docs/roadmap/phase-02d-walking-skeleton.md | 13 +++++++++---- docs/standards/20-infrastructure-stack.md | 3 ++- docs/standards/21-architecture-tests-catalogue.md | 5 +++-- 13 files changed, 28 insertions(+), 20 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 21b7938..346010e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -86,7 +86,7 @@ records the accepted head and merge verification. P02d-3 read internals are next 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 uncached batched definition reads; -review and Step 3 caching remain ahead. Public reads stay with P02d-4. +both review rounds passed. Step 3 caching remains ahead. 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 43b8601..5d66b36 100644 --- a/README.md +++ b/README.md @@ -79,8 +79,8 @@ execution after both review rounds. **P02d-2 is complete and merged** through records verification. P02d-3 read internals are next; 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 uncached batched definition reads; review and Step 3 caching -remain ahead. +Step 2 implements uncached batched definition reads; both review rounds passed. +Step 3 caching remains ahead. **P02d-4** owns anonymous public API reads. Browser rendering follows in P02d-5–7; none of these later packets has started. diff --git a/docs/architecture/09-tenant-isolation.md b/docs/architecture/09-tenant-isolation.md index 6dfc8db..c53acdc 100644 --- a/docs/architecture/09-tenant-isolation.md +++ b/docs/architecture/09-tenant-isolation.md @@ -263,8 +263,8 @@ The typed settings accessor is uncached and explicitly selects tenant-wide/curre 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 uncached Customization projection. Its review and -Step 3 cache implementation remain pending. +reader; Step 2 implements the uncached Customization projection, with both reviews +passed. Step 3 cache implementation remains pending. ``` {tenant_id}:{org_id}:{module}:{logical-name} ← a value scoped to one organization diff --git a/docs/architecture/32-tenant-customization-model.md b/docs/architecture/32-tenant-customization-model.md index 2501fc1..0fdec34 100644 --- a/docs/architecture/32-tenant-customization-model.md +++ b/docs/architecture/32-tenant-customization-model.md @@ -530,7 +530,7 @@ per tenant per month. That ratio is the whole design. | `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 2 implements the -uncached coherent loader; its review and Step 3 caching remain pending. +uncached coherent loader; both review rounds passed. Step 3 caching remains pending. 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, diff --git a/docs/glossary.md b/docs/glossary.md index f405468..c8b4903 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -145,7 +145,7 @@ 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 2 implements the uncached coherent loader; caching and review remain pending. 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. | +| **`ICustomizationDefinitionProjectionReader`** | Accepted P02d-3 internal batched display reader for exact tenant-owned revision pins, with immutable generation-keyed definition families. Step 2 implements the uncached coherent loader; both review rounds passed; caching remains Step 3. 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). | diff --git a/docs/modules/customization/README.md b/docs/modules/customization/README.md index 3a4ac8b..49b1e9d 100644 --- a/docs/modules/customization/README.md +++ b/docs/modules/customization/README.md @@ -190,7 +190,7 @@ absence rather than substituting an unrelated diagram for it. ### Primary read flow: resolving a tenant's shapes -**P02d-3 Step 2 implemented — 2026-10-02; review pending.** +**P02d-3 Step 2 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 diff --git a/docs/modules/customization/audit.md b/docs/modules/customization/audit.md index 5a62a7c..22e694e 100644 --- a/docs/modules/customization/audit.md +++ b/docs/modules/customization/audit.md @@ -3,7 +3,7 @@ Per [Audit Coverage](../../standards/18-audit-coverage.md), which names this file. Part of the [module spec](README.md). -**P02d-3 Step 2 implemented — 2026-10-02; review pending.** +**P02d-3 Step 2 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 diff --git a/docs/modules/customization/permissions.md b/docs/modules/customization/permissions.md index e86f420..ba15819 100644 --- a/docs/modules/customization/permissions.md +++ b/docs/modules/customization/permissions.md @@ -3,7 +3,7 @@ Per [Permission Standards](../../standards/19-permissions.md), which names this file. Part of the [module spec](README.md). -**P02d-3 Step 2 implemented — 2026-10-02; review pending.** +**P02d-3 Step 2 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. diff --git a/docs/modules/tenancy/README.md b/docs/modules/tenancy/README.md index 638ff68..10495bf 100644 --- a/docs/modules/tenancy/README.md +++ b/docs/modules/tenancy/README.md @@ -164,7 +164,8 @@ queries are Off. ### P02d-3 accepted typed settings contract -**Step 1 implemented — 2026-10-02; both review rounds passed.** `ITenantSettingsAccessor` +**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`, diff --git a/docs/roadmap/README.md b/docs/roadmap/README.md index cdbe543..ff72e76 100644 --- a/docs/roadmap/README.md +++ b/docs/roadmap/README.md @@ -50,8 +50,8 @@ not deferred to the showcase phase. records final verification. P02d-3 read internals are next; 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 uncached batched definition reads; review and Step 3 caching - remain ahead + Step 2 implements uncached batched definition reads; both review rounds passed. + Step 3 caching remains ahead - [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 68ea29d..668ef52 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -11,7 +11,7 @@ > | 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 | ✅ 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) implemented, review pending; Step 3 ahead | +> | 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 ahead | > | P02d-4 | Public read API and contract checks | not started | > | P02d-5 | Server-rendering path | not started | > | P02d-6 | Public renderer | not started | @@ -1366,8 +1366,8 @@ 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. Round 1 completed; round 2 remains -pending. No cache implementation is claimed. +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 @@ -1375,7 +1375,12 @@ 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; round 2 remains pending. +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. ### P02d-1 decision pass (2026-09-14) diff --git a/docs/standards/20-infrastructure-stack.md b/docs/standards/20-infrastructure-stack.md index 6d308d2..f4e160a 100644 --- a/docs/standards/20-infrastructure-stack.md +++ b/docs/standards/20-infrastructure-stack.md @@ -271,7 +271,8 @@ dirty-scope bypass under ADR-0040/0043. Stable metrics omit the generation: 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. +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) diff --git a/docs/standards/21-architecture-tests-catalogue.md b/docs/standards/21-architecture-tests-catalogue.md index 47ede6b..724e8ad 100644 --- a/docs/standards/21-architecture-tests-catalogue.md +++ b/docs/standards/21-architecture-tests-catalogue.md @@ -1285,8 +1285,9 @@ otherwise). - **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 - and module-helper dependencies are detected, with a clean negative control. + `Projection_validator_guard_detects_direct_and_helper_dependencies` proves + interface, concrete-adapter and module-helper dependencies are detected, with + a clean negative control. - **Phase:** 02d (P02d-3). ### Persistence: concurrency and the unit of work From 7ac197f7715963e69453d3c814255dffbfcb48b1 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 2 Oct 2026 19:24:06 +0300 Subject: [PATCH 09/15] feat(customization): cache coherent generation-scoped definitions Reuse immutable untranslated families after a fresh durable generation probe. Bypass caching before supported mutations and in rollback-only scopes so speculative values cannot poison reissued generation keys. Prove lifecycle, races, faults, cancellation, tenant separation and composition parity; record seeded SQL plans, volume and local timings. ADR: 0010, 0038, 0040, 0043 Module: Customization, Tenancy --- CLAUDE.md | 8 +- README.md | 8 +- .../Caching/InMemoryCacheService.cs | 9 +- .../Persistence/IUnitOfWork.cs | 5 +- .../CustomizationReadRegistration.cs | 2 + .../CustomizationGenerationStore.cs | 4 +- .../Persistence/CustomizationWriteStores.cs | 9 +- ...CustomizationDefinitionProjectionReader.cs | 26 +- .../Projections/CustomizationReadState.cs | 11 + .../Projections/DefinitionFamilyCache.cs | 69 ++++ .../Database/CustomizationCatalogTests.cs | 2 + .../CustomizationProjectionCacheTests.cs | 329 ++++++++++++++++++ ...CustomizationProjectionCompositionTests.cs | 198 +++++++++++ .../Database/CustomizationProjectionTests.cs | 47 ++- ...ustomizationPublicationConcurrencyTests.cs | 27 +- .../Database/WriteStoreConflictTests.cs | 2 + .../Caching/InMemoryCacheServiceTests.cs | 4 +- docs/architecture/09-tenant-isolation.md | 4 +- .../32-tenant-customization-model.md | 8 +- docs/glossary.md | 2 +- docs/modules/customization/README.md | 11 +- docs/modules/customization/audit.md | 2 +- docs/modules/customization/permissions.md | 2 +- docs/roadmap/README.md | 6 +- docs/roadmap/phase-02d-walking-skeleton.md | 55 ++- docs/standards/10-observability.md | 2 +- docs/standards/20-infrastructure-stack.md | 2 +- 27 files changed, 795 insertions(+), 59 deletions(-) create mode 100644 backend/src/Modules/Customization/LearnStack.Modules.Customization.Infrastructure/Projections/CustomizationReadState.cs create mode 100644 backend/src/Modules/Customization/LearnStack.Modules.Customization.Infrastructure/Projections/DefinitionFamilyCache.cs create mode 100644 backend/tests/LearnStack.Tests.Integration/Database/CustomizationProjectionCacheTests.cs create mode 100644 backend/tests/LearnStack.Tests.Integration/Database/CustomizationProjectionCompositionTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index 346010e..d95a7ac 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -82,11 +82,13 @@ passed. Step 4 completes convergent seed 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; 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 next; +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 uncached batched definition reads; -both review rounds passed. Step 3 caching remains ahead. Public reads stay with P02d-4. +both review rounds passed. Step 2 implements batched definition reads; both review +rounds passed. Step 3 +adds generation caching and scope-safe bypass; both reviews are pending. 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 5d66b36..b78ae59 100644 --- a/README.md +++ b/README.md @@ -76,18 +76,18 @@ rounds and the focused fix review passed. Step 4 completes convergent seed 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 next; the +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 uncached batched definition reads; both review rounds passed. -Step 3 caching remains ahead. +Step 2 implements batched definition reads; both review rounds passed. Step 3 adds +generation caching and scope-safe bypass; both reviews are pending. **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, 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 batched display readers, text-card metadata validation and tenant-authored seeds | Remaining authoring capabilities across [Phases 04–08a](docs/roadmap/README.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.Infrastructure/Caching/InMemoryCacheService.cs b/backend/src/LearnStack.Infrastructure/Caching/InMemoryCacheService.cs index a4ecedc..6fe5562 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/Persistence/IUnitOfWork.cs b/backend/src/LearnStack.SharedKernel/Persistence/IUnitOfWork.cs index 15ce376..c594daa 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/Modules/Customization/LearnStack.Modules.Customization.Infrastructure/CustomizationReadRegistration.cs b/backend/src/Modules/Customization/LearnStack.Modules.Customization.Infrastructure/CustomizationReadRegistration.cs index 7fe71de..6259ef6 100644 --- a/backend/src/Modules/Customization/LearnStack.Modules.Customization.Infrastructure/CustomizationReadRegistration.cs +++ b/backend/src/Modules/Customization/LearnStack.Modules.Customization.Infrastructure/CustomizationReadRegistration.cs @@ -9,6 +9,8 @@ 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 7fd96d7..ba64b5c 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 60ba612..8014056 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 index 9f41041..61c6daa 100644 --- a/backend/src/Modules/Customization/LearnStack.Modules.Customization.Infrastructure/Projections/CustomizationDefinitionProjectionReader.cs +++ b/backend/src/Modules/Customization/LearnStack.Modules.Customization.Infrastructure/Projections/CustomizationDefinitionProjectionReader.cs @@ -12,7 +12,7 @@ namespace LearnStack.Modules.Customization.Infrastructure.Projections; public sealed class CustomizationDefinitionProjectionReader( - DefinitionSnapshotStore store, ITenantContext context, IUnitOfWork unit) + DefinitionSnapshotStore store, ITenantContext context, IUnitOfWork unit, DefinitionFamilyCache cache) : ICustomizationDefinitionProjectionReader { public async Task> ReadAsync( @@ -32,15 +32,25 @@ public async Task> ReadAsync( return Refused(); } - // Step 2 deliberately probes and then always loads; Step 3 adds clean-scope caching. - await store.ProbeAsync(context.TenantId, cancellationToken); - var snapshot = await store.LoadAsync(context.TenantId, cancellationToken); - if ((snapshot.Generation is null && snapshot.HasDefinitionRows) || snapshot.Generation is <= 0) + // 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) { - return Refused(); - } + snapshot = await store.LoadAsync(context.TenantId, cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + if ((snapshot.Generation is null && snapshot.HasDefinitionRows) || snapshot.Generation is <= 0) + { + return Refused(); + } - return Result.Ok(Resolve(snapshot, request)); + 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) 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 0000000..e191bce --- /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 0000000..3fe8c42 --- /dev/null +++ b/backend/src/Modules/Customization/LearnStack.Modules.Customization.Infrastructure/Projections/DefinitionFamilyCache.cs @@ -0,0 +1,69 @@ +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; + + 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) + { + 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) + { + 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/tests/LearnStack.Tests.Integration/Database/CustomizationCatalogTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/CustomizationCatalogTests.cs index 5f9679a..afc572d 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 0000000..c7c9bdd --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/Database/CustomizationProjectionCacheTests.cs @@ -0,0 +1,329 @@ +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("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 == "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"); + 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(); + } + + 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 0000000..29d2313 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/Database/CustomizationProjectionCompositionTests.cs @@ -0,0 +1,198 @@ +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)); + } + first.Observer.AfterProbe = Pause; + second.Observer.AfterProbe = 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); + first.Unit.Connection.Should().NotBeSameAs(second.Unit.Connection); + first.Observer.Selects.Should().BeInRange(1, 2); + second.Observer.Selects.Should().BeInRange(1, 2); + 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) => new + { + Minimum = samples.Min(), + Median = samples.Order().ElementAt(samples.Count / 2), + Maximum = samples.Max() + }; + 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/CustomizationProjectionTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/CustomizationProjectionTests.cs index e7c13d6..7feacc1 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/CustomizationProjectionTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/CustomizationProjectionTests.cs @@ -6,6 +6,7 @@ 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; @@ -13,18 +14,21 @@ 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 class CustomizationProjectionTests(SchemaFixture schema) +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 = """ @@ -111,7 +115,8 @@ public async Task Missing_counter_distinguishes_empty_from_corrupt_nonempty_conf 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 read = await ReadSession.OpenAsync(source, context); + 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(); @@ -125,6 +130,8 @@ INSERT INTO tenant_content_types 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_schema_extension_unresolved"); + 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) @@ -133,18 +140,23 @@ INSERT INTO customization_generations (tenant_id,generation) 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_between_probe_and_load_returns_the_loaded_generation_and_both_new_families() + 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 read = await ReadSession.OpenAsync(source, context); - var probeGeneration = await GenerationAsync(read.Unit); + 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); @@ -157,10 +169,10 @@ public async Task Publish_between_probe_and_load_returns_the_loaded_generation_a .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([new("profile", 1), new("profile", 2)], - [new("levels", 1), new("levels", 2)]))).Value!; + 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); @@ -250,15 +262,24 @@ private static async Task AssertAppRoleAsync(IUnitOfWork unit) 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 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; @@ -274,17 +295,21 @@ 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) + 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); + 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) + 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(); @@ -296,7 +321,7 @@ public static async Task OpenAsync(NpgsqlDataSource source, ITenant .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); + return new(provider, scope, unit, frame, db, observer, context, cache); } public async ValueTask DisposeAsync() { diff --git a/backend/tests/LearnStack.Tests.Integration/Database/CustomizationPublicationConcurrencyTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/CustomizationPublicationConcurrencyTests.cs index 3c2e4b8..6edd491 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/WriteStoreConflictTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/WriteStoreConflictTests.cs index 232f285..fdef0e3 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 cba814b..74b7c67 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/docs/architecture/09-tenant-isolation.md b/docs/architecture/09-tenant-isolation.md index c53acdc..91cc631 100644 --- a/docs/architecture/09-tenant-isolation.md +++ b/docs/architecture/09-tenant-isolation.md @@ -263,8 +263,8 @@ The typed settings accessor is uncached and explicitly selects tenant-wide/curre 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 uncached Customization projection, with both reviews -passed. Step 3 cache implementation remains pending. +reader; Step 2 implements the Customization projection, with both reviews passed. +Step 3 adds generation caching and scope-safe bypass; both reviews are pending. ``` {tenant_id}:{org_id}:{module}:{logical-name} ← a value scoped to one organization diff --git a/docs/architecture/32-tenant-customization-model.md b/docs/architecture/32-tenant-customization-model.md index 0fdec34..57fb2fa 100644 --- a/docs/architecture/32-tenant-customization-model.md +++ b/docs/architecture/32-tenant-customization-model.md @@ -529,8 +529,8 @@ per tenant per month. That ratio is the whole design. | `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 2 implements the -uncached coherent loader; both review rounds passed. Step 3 caching remains pending. +**G12 cache/G22 Accepted — 2026-10-02.** P02d-3 Step 3 implements the +coherent loader, cache and dirty-scope bypass; both review rounds are pending. 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, @@ -591,7 +591,9 @@ 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 now, and reassess before Phase 04's larger workload. +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. diff --git a/docs/glossary.md b/docs/glossary.md index c8b4903..a81a905 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -145,7 +145,7 @@ 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 2 implements the uncached coherent loader; both review rounds passed; caching remains Step 3. 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. | +| **`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 are pending. 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). | diff --git a/docs/modules/customization/README.md b/docs/modules/customization/README.md index 49b1e9d..ac8cb52 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,15 +190,16 @@ absence rather than substituting an unrelated diagram for it. ### Primary read flow: resolving a tenant's shapes -**P02d-3 Step 2 implemented — 2026-10-02; both review rounds passed.** +**P02d-3 Step 3 implemented — 2026-10-02; both review rounds pending.** `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 -currently runs uncached; generation-keyed cache behavior is Step 3. +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 @@ -293,7 +294,7 @@ In [audit.md](audit.md), the file | Path | Budget | Why this number | |---|---|---| | 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** p95 target, not yet measured | At most two SELECTs: probe plus coherent generation/rows snapshot; seeded rows/bytes and query plans measured in P02d-3, not a production p95 claim | +| 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 22e694e..e72fef8 100644 --- a/docs/modules/customization/audit.md +++ b/docs/modules/customization/audit.md @@ -3,7 +3,7 @@ Per [Audit Coverage](../../standards/18-audit-coverage.md), which names this file. Part of the [module spec](README.md). -**P02d-3 Step 2 implemented — 2026-10-02; both review rounds passed.** +**P02d-3 Step 3 implemented — 2026-10-02; both review rounds pending.** `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 diff --git a/docs/modules/customization/permissions.md b/docs/modules/customization/permissions.md index ba15819..657bd60 100644 --- a/docs/modules/customization/permissions.md +++ b/docs/modules/customization/permissions.md @@ -3,7 +3,7 @@ Per [Permission Standards](../../standards/19-permissions.md), which names this file. Part of the [module spec](README.md). -**P02d-3 Step 2 implemented — 2026-10-02; both review rounds passed.** +**P02d-3 Step 3 implemented — 2026-10-02; both review rounds pending.** `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. diff --git a/docs/roadmap/README.md b/docs/roadmap/README.md index ff72e76..d8c2f42 100644 --- a/docs/roadmap/README.md +++ b/docs/roadmap/README.md @@ -47,11 +47,11 @@ not deferred to the showcase phase. - [Phase 02d: Two-Tenant Walking Skeleton](phase-02d-walking-skeleton.md) — **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 next; the + 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 uncached batched definition reads; both review rounds passed. - Step 3 caching remains ahead + Step 2 implements batched definition reads; both review rounds passed. + Step 3 adds generation caching and scope-safe bypass; both reviews are pending - [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 668ef52..c643c6f 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -11,7 +11,7 @@ > | 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 | ✅ 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 ahead | +> | 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 reviews pending | > | P02d-4 | Public read API and contract checks | not started | > | P02d-5 | Server-rendering path | not started | > | P02d-6 | Public renderer | not started | @@ -1383,6 +1383,59 @@ 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 pass after the fix; the full Docker rerun remains pending. No retry +or weakened +assertion was added. Both independent Step 3 review rounds remain pending. + +**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 | + +`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. + + ### P02d-1 decision pass (2026-09-14) **Accepted — 2026-09-14, verified against `6c58343`.** The maintainer approved diff --git a/docs/standards/10-observability.md b/docs/standards/10-observability.md index 2df8504..4bd22b4 100644 --- a/docs/standards/10-observability.md +++ b/docs/standards/10-observability.md @@ -235,7 +235,7 @@ inventory (`hub:host-map`, `hub:entitlement`, `identity:permissions`, `tenancy:feature-flags`, `tenancy:settings`, `audit:config`, or `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 not implemented yet. An unregistered family is +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/20-infrastructure-stack.md b/docs/standards/20-infrastructure-stack.md index f4e160a..1c10228 100644 --- a/docs/standards/20-infrastructure-stack.md +++ b/docs/standards/20-infrastructure-stack.md @@ -263,7 +263,7 @@ interpolation. The mapping is written down because it is the part that drifts: | `{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 Accepted, not implemented yet: P02d-3's G12/G22 pass +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 From 5b509953eb7381c24ede6d011840ac56cd6f3f8a Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 2 Oct 2026 19:34:05 +0300 Subject: [PATCH 10/15] docs: record P02d-3 cache review and complete backend proof Close the first independent cache review round and record the complete passing Docker rerun and positive TRX populations. Keep Round 2 pending. ADR: 0010, 0038, 0040, 0043 Module: Customization, Tenancy --- CLAUDE.md | 3 ++- README.md | 2 +- docs/architecture/09-tenant-isolation.md | 3 ++- .../32-tenant-customization-model.md | 2 +- docs/glossary.md | 2 +- docs/modules/customization/README.md | 2 +- docs/modules/customization/audit.md | 2 +- docs/modules/customization/permissions.md | 2 +- docs/roadmap/README.md | 2 +- docs/roadmap/phase-02d-walking-skeleton.md | 19 +++++++++++++++++-- 10 files changed, 28 insertions(+), 11 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d95a7ac..0aa0ae8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -87,7 +87,8 @@ its [decision package](docs/roadmap/phase-02d-walking-skeleton.md#p02d-3-decisio 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 reviews are pending. Public reads +adds generation caching and scope-safe bypass; Round 1 passed; Round 2 is pending. +Public reads stay with P02d-4. **Phase 01** shipped the .NET 10 solution scaffold under `backend/` diff --git a/README.md b/README.md index b78ae59..6bcf7cb 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ 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 reviews are pending. +generation caching and scope-safe bypass; Round 1 passed; Round 2 is pending. **P02d-4** owns anonymous public API reads. Browser rendering follows in P02d-5–7; none of these later packets has started. diff --git a/docs/architecture/09-tenant-isolation.md b/docs/architecture/09-tenant-isolation.md index 91cc631..c79e7d0 100644 --- a/docs/architecture/09-tenant-isolation.md +++ b/docs/architecture/09-tenant-isolation.md @@ -264,7 +264,8 @@ 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 reviews are pending. +Step 3 adds generation caching and scope-safe bypass; Round 1 passed; Round 2 is +pending. ``` {tenant_id}:{org_id}:{module}:{logical-name} ← a value scoped to one organization diff --git a/docs/architecture/32-tenant-customization-model.md b/docs/architecture/32-tenant-customization-model.md index 57fb2fa..42fba4a 100644 --- a/docs/architecture/32-tenant-customization-model.md +++ b/docs/architecture/32-tenant-customization-model.md @@ -530,7 +530,7 @@ per tenant per month. That ratio is the whole design. | `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 are pending. +coherent loader, cache and dirty-scope bypass; Round 1 passed; Round 2 is pending. 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, diff --git a/docs/glossary.md b/docs/glossary.md index a81a905..28a374d 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -145,7 +145,7 @@ 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 are pending. 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. | +| **`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; Round 1 passed; Round 2 is pending. 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). | diff --git a/docs/modules/customization/README.md b/docs/modules/customization/README.md index ac8cb52..5a36310 100644 --- a/docs/modules/customization/README.md +++ b/docs/modules/customization/README.md @@ -190,7 +190,7 @@ absence rather than substituting an unrelated diagram for it. ### Primary read flow: resolving a tenant's shapes -**P02d-3 Step 3 implemented — 2026-10-02; both review rounds pending.** +**P02d-3 Step 3 implemented — 2026-10-02; Round 1 passed; Round 2 pending.** `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 diff --git a/docs/modules/customization/audit.md b/docs/modules/customization/audit.md index e72fef8..6337f60 100644 --- a/docs/modules/customization/audit.md +++ b/docs/modules/customization/audit.md @@ -3,7 +3,7 @@ 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 pending.** +**P02d-3 Step 3 implemented — 2026-10-02; Round 1 passed; Round 2 pending.** `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 diff --git a/docs/modules/customization/permissions.md b/docs/modules/customization/permissions.md index 657bd60..bcf882b 100644 --- a/docs/modules/customization/permissions.md +++ b/docs/modules/customization/permissions.md @@ -3,7 +3,7 @@ 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 pending.** +**P02d-3 Step 3 implemented — 2026-10-02; Round 1 passed; Round 2 pending.** `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. diff --git a/docs/roadmap/README.md b/docs/roadmap/README.md index d8c2f42..2409a24 100644 --- a/docs/roadmap/README.md +++ b/docs/roadmap/README.md @@ -51,7 +51,7 @@ not deferred to the showcase phase. [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 reviews are pending + Step 3 adds generation caching and scope-safe bypass; Round 1 passed; Round 2 pending - [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 c643c6f..b301e30 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -11,7 +11,7 @@ > | 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 | ✅ 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 reviews pending | +> | 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; Round 1 passed; Round 2 pending | > | P02d-4 | Public read API and contract checks | not started | > | P02d-5 | Server-rendering path | not started | > | P02d-6 | Public renderer | not started | @@ -1408,7 +1408,7 @@ 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 pass after the fix; the full Docker rerun remains pending. No retry or weakened -assertion was added. Both independent Step 3 review rounds remain pending. +assertion was added. Round 1 is complete; Round 2 remains pending. **Local seeded measurement.** The executable `Seeded_local_measurement_records_statement_plans_payload_volume_and_end_to_end_timings` @@ -1436,6 +1436,21 @@ 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 remains pending. + +**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. + + ### P02d-1 decision pass (2026-09-14) **Accepted — 2026-09-14, verified against `6c58343`.** The maintainer approved From 028dd6e53af2a8239147dbf5ae8e4fbd0fdfacfb Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 2 Oct 2026 19:48:28 +0300 Subject: [PATCH 11/15] docs: close P02d-3 reviews and clarify historical packet status Record both completed cache review rounds and the unmerged packet. Make old P02d-2 milestone descriptions explicitly historical, preserving accepted decisions and directing the next decision pass to P02d-4. ADR: 0010, 0038, 0040, 0043 Module: Customization, Tenancy --- CLAUDE.md | 3 +- README.md | 3 +- docs/architecture/09-tenant-isolation.md | 3 +- .../32-tenant-customization-model.md | 2 +- docs/glossary.md | 2 +- docs/modules/customization/README.md | 2 +- docs/modules/customization/audit.md | 2 +- docs/modules/customization/permissions.md | 2 +- docs/roadmap/README.md | 3 +- docs/roadmap/phase-02d-walking-skeleton.md | 45 ++++++++++++++++--- 10 files changed, 50 insertions(+), 17 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0aa0ae8..dd503be 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -87,7 +87,8 @@ its [decision package](docs/roadmap/phase-02d-walking-skeleton.md#p02d-3-decisio 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; Round 1 passed; Round 2 is pending. +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. diff --git a/README.md b/README.md index 6bcf7cb..d87f464 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,8 @@ 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; Round 1 passed; Round 2 is pending. +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. diff --git a/docs/architecture/09-tenant-isolation.md b/docs/architecture/09-tenant-isolation.md index c79e7d0..295299f 100644 --- a/docs/architecture/09-tenant-isolation.md +++ b/docs/architecture/09-tenant-isolation.md @@ -264,8 +264,7 @@ 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; Round 1 passed; Round 2 is -pending. +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 diff --git a/docs/architecture/32-tenant-customization-model.md b/docs/architecture/32-tenant-customization-model.md index 42fba4a..89030e0 100644 --- a/docs/architecture/32-tenant-customization-model.md +++ b/docs/architecture/32-tenant-customization-model.md @@ -530,7 +530,7 @@ per tenant per month. That ratio is the whole design. | `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; Round 1 passed; Round 2 is pending. +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, diff --git a/docs/glossary.md b/docs/glossary.md index 28a374d..ecb1e8b 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -145,7 +145,7 @@ 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; Round 1 passed; Round 2 is pending. 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. | +| **`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). | diff --git a/docs/modules/customization/README.md b/docs/modules/customization/README.md index 5a36310..cd7bc6e 100644 --- a/docs/modules/customization/README.md +++ b/docs/modules/customization/README.md @@ -190,7 +190,7 @@ absence rather than substituting an unrelated diagram for it. ### Primary read flow: resolving a tenant's shapes -**P02d-3 Step 3 implemented — 2026-10-02; Round 1 passed; Round 2 pending.** +**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 diff --git a/docs/modules/customization/audit.md b/docs/modules/customization/audit.md index 6337f60..d57b3b1 100644 --- a/docs/modules/customization/audit.md +++ b/docs/modules/customization/audit.md @@ -3,7 +3,7 @@ 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; Round 1 passed; Round 2 pending.** +**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 diff --git a/docs/modules/customization/permissions.md b/docs/modules/customization/permissions.md index bcf882b..0d9a5d8 100644 --- a/docs/modules/customization/permissions.md +++ b/docs/modules/customization/permissions.md @@ -3,7 +3,7 @@ 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; Round 1 passed; Round 2 pending.** +**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. diff --git a/docs/roadmap/README.md b/docs/roadmap/README.md index 2409a24..d15f352 100644 --- a/docs/roadmap/README.md +++ b/docs/roadmap/README.md @@ -51,7 +51,8 @@ not deferred to the showcase phase. [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; Round 1 passed; Round 2 pending + 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 b301e30..7d9fcb5 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -11,7 +11,7 @@ > | 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 | ✅ 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; Round 1 passed; Round 2 pending | +> | 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,13 +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. -Phase 02d remains in progress. P02d-3 is next, with its decision pass still open. +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 @@ -1320,7 +1330,7 @@ implementation, as required by the maintainer and ### Delivery record: P02d-3 -**In progress — 2026-10-02.** The accepted decision commit is `307bbcd`. +**Complete, unmerged — 2026-10-02.** The accepted decision commit is `307bbcd`. #### Step 1: typed settings and locale resolution @@ -1406,9 +1416,10 @@ 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 pass after the fix; the full Docker rerun remains pending. No retry +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. Round 1 is complete; Round 2 remains pending. +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` @@ -1439,7 +1450,7 @@ 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 remains pending. +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: @@ -1451,6 +1462,26 @@ 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. + + ### P02d-1 decision pass (2026-09-14) **Accepted — 2026-09-14, verified against `6c58343`.** The maintainer approved From a317d3890dc40b96351e26c5fe8a60152eaf383b Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 2 Oct 2026 20:13:29 +0300 Subject: [PATCH 12/15] docs: align Education closeout and roadmap sentence Fix two verified post-PR documentation findings without changing source: Education now names P02d-3 complete and unmerged, with P02d-4 next. Module: Customization, Tenancy, Education --- docs/modules/education/README.md | 3 ++- docs/roadmap/README.md | 2 +- docs/roadmap/phase-02d-walking-skeleton.md | 9 +++++++++ 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/docs/modules/education/README.md b/docs/modules/education/README.md index b2ed768..2eb7868 100644 --- a/docs/modules/education/README.md +++ b/docs/modules/education/README.md @@ -13,7 +13,8 @@ verification queries, explicitly classified Off. Step 3 implements six writers; review rounds and a focused fix review passed. Step 4 completes the seed after both 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 next. +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/roadmap/README.md b/docs/roadmap/README.md index d15f352..fcb608e 100644 --- a/docs/roadmap/README.md +++ b/docs/roadmap/README.md @@ -51,7 +51,7 @@ not deferred to the showcase phase. [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 + 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) diff --git a/docs/roadmap/phase-02d-walking-skeleton.md b/docs/roadmap/phase-02d-walking-skeleton.md index 7d9fcb5..4eb5899 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -1482,6 +1482,15 @@ 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. + + ### P02d-1 decision pass (2026-09-14) **Accepted — 2026-09-14, verified against `6c58343`.** The maintainer approved From ab2a8b3d410966ed7288a0d94303f791a0c8d86d Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 3 Oct 2026 09:59:40 +0300 Subject: [PATCH 13/15] fix(customization): close P02d-3 review gaps Follow projection helpers across production assemblies and close metadata escapes with planted controls. Refuse malformed setting tokens and detached contexts, preserve recoverable cache fallback, and propagate fatal faults. Keep immutable cached values and branding keys single-sourced. Make concurrent cold-load evidence deterministic and correct the local median calculation. Align editable scope and current-state documentation; preserve accepted answers and historical delivery records. Validated: Release build 0 warnings/errors; 2642 tests pass, no skips; format, links/fragments and deliberate guard mutation checks pass. ADR: 0040, 0043 Module: Customization, Tenancy --- ...CustomizationDefinitionProjectionReader.cs | 2 +- .../Projections/DefinitionFamilyCache.cs | 7 +- .../Projections/DefinitionSnapshot.cs | 4 +- .../Projections/DefinitionSnapshotStore.cs | 9 +- .../Settings/ITenantSettingsAccessor.cs | 3 +- .../Branding/BrandingThemeRegistry.cs | 2 +- .../Settings/TenantSettingRegistry.cs | 7 +- .../Persistence/TenantSettingsAccessor.cs | 7 +- .../TenantSettingsRegistration.cs | 3 + .../CustomizationProjectionTests.cs | 162 ++++++++++++++++-- .../tests/LearnStack.Tests.Architecture/Il.cs | 81 ++++++++- .../CustomizationProjectionCacheTests.cs | 22 +++ ...CustomizationProjectionCompositionTests.cs | 31 +++- .../Database/CustomizationProjectionTests.cs | 22 ++- .../Database/TenantSettingsAccessorTests.cs | 23 +++ .../Modules/Tenancy/TenantSettingsTests.cs | 4 + docs/architecture/02-domain-model.md | 2 +- docs/modules/tenancy/README.md | 5 + docs/roadmap/phase-02d-walking-skeleton.md | 107 ++++++++++-- docs/standards/08-localization.md | 5 +- docs/standards/20-infrastructure-stack.md | 5 +- .../21-architecture-tests-catalogue.md | 18 +- 22 files changed, 449 insertions(+), 82 deletions(-) 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 index 61c6daa..0671eac 100644 --- a/backend/src/Modules/Customization/LearnStack.Modules.Customization.Infrastructure/Projections/CustomizationDefinitionProjectionReader.cs +++ b/backend/src/Modules/Customization/LearnStack.Modules.Customization.Infrastructure/Projections/CustomizationDefinitionProjectionReader.cs @@ -105,6 +105,6 @@ private static Result Refused() => Result>(StringComparer.Ordinal) { - ["Definition"] = [new LocalizedMessage("lockey_schema_extension_unresolved")], + ["Definition"] = [new LocalizedMessage("lockey_invalid_value")], })); } 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 index 3fe8c42..4db768d 100644 --- a/backend/src/Modules/Customization/LearnStack.Modules.Customization.Infrastructure/Projections/DefinitionFamilyCache.cs +++ b/backend/src/Modules/Customization/LearnStack.Modules.Customization.Infrastructure/Projections/DefinitionFamilyCache.cs @@ -13,6 +13,9 @@ public sealed class DefinitionFamilyCache( 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; @@ -27,7 +30,7 @@ public sealed class DefinitionFamilyCache( ? new DefinitionSnapshot(generation, types.Definitions.Count > 0 || taxonomies.Definitions.Count > 0, types, taxonomies) : null; } - catch (Exception) + catch (Exception exception) when (exception is not (OutOfMemoryException or StackOverflowException or AccessViolationException)) { cancellationToken.ThrowIfCancellationRequested(); ProjectionCacheLog.ReadFailed(logger); @@ -47,7 +50,7 @@ internal async Task WriteAsync(TenantId tenant, DefinitionSnapshot snapshot, Can await cache.SetAsync(Key(tenant, "taxonomies", generation), snapshot.Taxonomies, Options, cancellationToken); cancellationToken.ThrowIfCancellationRequested(); } - catch (Exception) + catch (Exception exception) when (exception is not (OutOfMemoryException or StackOverflowException or AccessViolationException)) { cancellationToken.ThrowIfCancellationRequested(); ProjectionCacheLog.WriteFailed(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 index 9163580..d106ef7 100644 --- a/backend/src/Modules/Customization/LearnStack.Modules.Customization.Infrastructure/Projections/DefinitionSnapshot.cs +++ b/backend/src/Modules/Customization/LearnStack.Modules.Customization.Infrastructure/Projections/DefinitionSnapshot.cs @@ -7,10 +7,10 @@ namespace LearnStack.Modules.Customization.Infrastructure.Projections; internal sealed record ContentTypeFamily(ImmutableDictionary Definitions); internal sealed record TaxonomyFamily(ImmutableDictionary Definitions); internal sealed record UntranslatedContentType( - Guid Id, DefinitionRevision Revision, DefinitionStatus Status, LocalizedText DisplayName, + Guid Id, DefinitionStatus Status, LocalizedText DisplayName, string RendererKey, ImmutableArray Fields); internal sealed record UntranslatedTaxonomy( - Guid Id, DefinitionRevision Revision, DefinitionStatus Status, LocalizedText DisplayName, + 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 index a28a80f..9f3d123 100644 --- a/backend/src/Modules/Customization/LearnStack.Modules.Customization.Infrastructure/Projections/DefinitionSnapshotStore.cs +++ b/backend/src/Modules/Customization/LearnStack.Modules.Customization.Infrastructure/Projections/DefinitionSnapshotStore.cs @@ -74,12 +74,12 @@ private static ContentTypeFamily ReadContentTypes(string json) } var revision = Revision(row); - definitions.Add(revision, new UntranslatedContentType(row.GetProperty("id").GetGuid(), revision, + definitions.Add(revision, new UntranslatedContentType(row.GetProperty("id").GetGuid(), Status(row), Label(row), renderer, presentation.Value)); } catch (ArgumentException) { - // Invalid stored label/profile makes this pin missing, not its neighbors. + // An invalid stored label makes this pin missing, not its neighbors. } } @@ -99,12 +99,13 @@ private static TaxonomyFamily ReadTaxonomies(string json) 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(), revision, + definitions.Add(revision, new UntranslatedTaxonomy(row.GetProperty("id").GetGuid(), Status(row), Label(row), bands)); } catch (ArgumentException) { - // Refuse the whole vocabulary if one of its stored labels is malformed. + // Omit this taxonomy revision if its own or a band's label is + // malformed; unrelated revisions remain available. } } 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 index 4ddb392..fd88df8 100644 --- 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 @@ -22,5 +22,6 @@ public sealed record BrandingTheme(string Primary, string Background, string For public static class TenantSettingKeys { - public static TenantSettingKey BrandingTheme { get; } = new("branding.theme"); + 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 79d1535..db2bdb2 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 @@ -15,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), 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 index e0853e2..4a18b18 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Settings/TenantSettingRegistry.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Settings/TenantSettingRegistry.cs @@ -37,7 +37,10 @@ public TenantSettingRegistry(IEnumerable registratio BrandingThemeRegistry.Read), ]); - public TenantSettingRegistration? Find(TenantSettingKey key) where T : class => - _registrations.TryGetValue(key.Value, out var registration) + 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 index c17ff60..dc7a90d 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/TenantSettingsAccessor.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/TenantSettingsAccessor.cs @@ -7,6 +7,7 @@ using LearnStack.SharedKernel.Results; using LearnStack.SharedKernel.Tenancy; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage; namespace LearnStack.Modules.Tenancy.Infrastructure.Persistence; @@ -20,9 +21,11 @@ public async Task>> ReadAsync( ArgumentNullException.ThrowIfNull(key); cancellationToken.ThrowIfCancellationRequested(); if (!tenantContext.IsResolved || tenantContext.TenantId == TenantId.PlatformSentinel - || !unit.HasActiveTransaction || !unit.IsTenantContextIssuedOn(unit.Transaction)) + || !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 ambient tenant transaction."); + throw new TenantContextMissingException("Settings reads require a resolved, announced and enlisted ambient tenant transaction."); } var registration = registry.Find(key); diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/TenantSettingsRegistration.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/TenantSettingsRegistration.cs index 6e0c9dd..48a094c 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/TenantSettingsRegistration.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/TenantSettingsRegistration.cs @@ -10,6 +10,9 @@ 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 index a24ffcd..fa44f83 100644 --- a/backend/tests/LearnStack.Tests.Architecture/CustomizationProjectionTests.cs +++ b/backend/tests/LearnStack.Tests.Architecture/CustomizationProjectionTests.cs @@ -21,23 +21,31 @@ public sealed class CustomizationProjectionTests [Fact] public void Customization_Projection_Does_Not_Validate_On_Read() { - using var infrastructure = ModuleDefinition.ReadModule(typeof(CustomizationDefinitionProjectionReader).Assembly.Location); - using var application = ModuleDefinition.ReadModule(typeof(LearnStack.Modules.Customization.Application.Customization.TextCardPresentation).Assembly.Location); - var types = infrastructure.GetTypes().Concat(application.GetTypes()) - .Where(type => type.FullName.StartsWith("LearnStack.Modules.Customization.", StringComparison.Ordinal)) - .ToDictionary(type => type.FullName); - var roots = infrastructure.GetTypes().Where(type => type.Interfaces.Any(contract => - contract.InterfaceType.FullName == typeof(ICustomizationDefinitionProjectionReader).FullName)).ToArray(); - 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 module helpers may reach schema validation"); + 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 = module.GetTypes().ToDictionary(type => type.FullName); + 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('+', '/')); @@ -46,21 +54,98 @@ public void Projection_validator_guard_detects_direct_and_helper_dependencies() 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 HashSet Offenders(IEnumerable roots, Dictionary types) + 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(StringComparer.Ordinal); + 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.FullName)) continue; - foreach (var name in Il.ReferencedTypeNames(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); - if (types.TryGetValue(name, out var helper)) queue.Enqueue(helper); + 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; @@ -82,4 +167,49 @@ 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 ac14854..30eb07a 100644 --- a/backend/tests/LearnStack.Tests.Architecture/Il.cs +++ b/backend/tests/LearnStack.Tests.Architecture/Il.cs @@ -24,6 +24,10 @@ internal static class Il 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. @@ -114,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; @@ -157,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) { @@ -176,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; @@ -192,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. @@ -242,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. @@ -259,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) => @@ -301,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/CustomizationProjectionCacheTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/CustomizationProjectionCacheTests.cs index c7c9bdd..f438e50 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/CustomizationProjectionCacheTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/CustomizationProjectionCacheTests.cs @@ -48,6 +48,22 @@ public async Task Cold_warm_partial_and_locale_reads_use_two_or_one_select_and_n 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")] @@ -293,6 +309,7 @@ public CacheProbe() 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); } @@ -307,11 +324,16 @@ public async Task SetAsync(string key, T value, CacheOptions? options = null, 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 diff --git a/backend/tests/LearnStack.Tests.Integration/Database/CustomizationProjectionCompositionTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/CustomizationProjectionCompositionTests.cs index 29d2313..241aa87 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/CustomizationProjectionCompositionTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/CustomizationProjectionCompositionTests.cs @@ -101,14 +101,23 @@ async Task Pause() if (Interlocked.Increment(ref arrived) == 2) barrier.TrySetResult(); await barrier.Task.WaitAsync(TimeSpan.FromSeconds(30)); } - first.Observer.AfterProbe = Pause; - second.Observer.AfterProbe = Pause; + // 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().BeInRange(1, 2); - second.Observer.Selects.Should().BeInRange(1, 2); + 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(); @@ -177,12 +186,16 @@ public async Task Seeded_local_measurement_records_statement_plans_payload_volum cache.FactoryCalls.Should().Be(0); } - private static object Summary(List samples) => new + private static object Summary(List samples) { - Minimum = samples.Min(), - Median = samples.Order().ElementAt(samples.Count / 2), - Maximum = samples.Max() - }; + 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!); diff --git a/backend/tests/LearnStack.Tests.Integration/Database/CustomizationProjectionTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/CustomizationProjectionTests.cs index 7feacc1..1d4b70e 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/CustomizationProjectionTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/CustomizationProjectionTests.cs @@ -60,8 +60,10 @@ public async Task Composed_reader_returns_only_its_tenant_and_tracks_no_entities await frame.FailAsync(); } - [Fact] - public async Task Exact_batches_keep_deprecated_pins_and_distinguish_draft_deleted_absent_and_malformed() + [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); @@ -96,9 +98,14 @@ 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; - UPDATE customization_generations SET generation = generation + 1; """); var malformed = (await read.Reader.ReadAsync(request)).Value!; malformed.ContentTypes.Should().ContainSingle().Which.Key.Should().Be(new DefinitionRevision("intact", 1)); @@ -129,7 +136,7 @@ INSERT INTO tenant_content_types """); 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_schema_extension_unresolved"); + 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, """ @@ -269,6 +276,7 @@ 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, @@ -286,6 +294,12 @@ public override async ValueTask ReaderExecutedAsync(DbCommand comm 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; } } diff --git a/backend/tests/LearnStack.Tests.Integration/Database/TenantSettingsAccessorTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/TenantSettingsAccessorTests.cs index 394b3f3..87bd855 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/TenantSettingsAccessorTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/TenantSettingsAccessorTests.cs @@ -146,6 +146,29 @@ await ReadInScopeAsync(context, async (db, unit, services) => }); } + [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() { diff --git a/backend/tests/LearnStack.Tests.Unit/Modules/Tenancy/TenantSettingsTests.cs b/backend/tests/LearnStack.Tests.Unit/Modules/Tenancy/TenantSettingsTests.cs index f205aaf..d17fc3b 100644 --- a/backend/tests/LearnStack.Tests.Unit/Modules/Tenancy/TenantSettingsTests.cs +++ b/backend/tests/LearnStack.Tests.Unit/Modules/Tenancy/TenantSettingsTests.cs @@ -24,6 +24,10 @@ public void Registry_requires_both_registered_key_and_exact_value_type() 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/docs/architecture/02-domain-model.md b/docs/architecture/02-domain-model.md index 591b93e..79006bc 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/modules/tenancy/README.md b/docs/modules/tenancy/README.md index 10495bf..f2ddeff 100644 --- a/docs/modules/tenancy/README.md +++ b/docs/modules/tenancy/README.md @@ -173,6 +173,11 @@ reusing its four-color grammar and contrast validator above. It returns a comple 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 diff --git a/docs/roadmap/phase-02d-walking-skeleton.md b/docs/roadmap/phase-02d-walking-skeleton.md index 4eb5899..5951cff 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -1434,6 +1434,11 @@ coherent statement is 1984 bytes; this measures wire JSON, not managed heap size | 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`. @@ -1491,6 +1496,67 @@ 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. + + ### P02d-1 decision pass (2026-09-14) **Accepted — 2026-09-14, verified against `6c58343`.** The maintainer approved @@ -1774,12 +1840,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 @@ -1884,20 +1951,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 @@ -1958,11 +2028,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 34306e1..b118f25 100644 --- a/docs/standards/08-localization.md +++ b/docs/standards/08-localization.md @@ -233,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/20-infrastructure-stack.md b/docs/standards/20-infrastructure-stack.md index 1c10228..c595337 100644 --- a/docs/standards/20-infrastructure-stack.md +++ b/docs/standards/20-infrastructure-stack.md @@ -347,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' target consistency policy uses L2 and eager invalidation. - 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 724e8ad..12021ca 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 -**142 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 @@ -1280,14 +1281,19 @@ otherwise). - **Asserts:** the internal display projection never depends on `IJsonSchemaValidator`; schema admission/body validation stay on the write path. - The scanner has a planted validator-dependent offender so an empty match is - not treated as evidence. + 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 and module-helper dependencies are detected, with - a clean negative control. + 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 From 825e4f57ebdcbfe7e8b211568cf4547607b0a6b1 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 3 Oct 2026 10:14:02 +0300 Subject: [PATCH 14/15] docs: close P02d-3 correction reviews and clarify cache policy Record both fresh independent correction review rounds. Qualify cache consistency by the canonical per-family policy, preserving L1-only and uncached families instead of generalizing L2 invalidation to all keys. The reviewed backend is unchanged. Local validation remains 2642 passing cases with no skips; all required CI checks and CodeRabbit passed on the reviewed code head. Final documentation-head checks follow this commit. ADR: 0040, 0043 Module: Customization, Tenancy --- docs/roadmap/phase-02d-walking-skeleton.md | 17 +++++++++++++++++ docs/standards/20-infrastructure-stack.md | 2 +- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/docs/roadmap/phase-02d-walking-skeleton.md b/docs/roadmap/phase-02d-walking-skeleton.md index 5951cff..37fea8e 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -1557,6 +1557,23 @@ 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. + + ### P02d-1 decision pass (2026-09-14) **Accepted — 2026-09-14, verified against `6c58343`.** The maintainer approved diff --git a/docs/standards/20-infrastructure-stack.md b/docs/standards/20-infrastructure-stack.md index c595337..bd0f115 100644 --- a/docs/standards/20-infrastructure-stack.md +++ b/docs/standards/20-infrastructure-stack.md @@ -349,7 +349,7 @@ Rules: abandoned keys addressable again. - 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' target consistency policy uses L2 and eager invalidation. + 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" From 79d1539c681e5188ae57fb3af47bdf6f5049990a Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 3 Oct 2026 12:02:44 +0300 Subject: [PATCH 15/15] fix(customization): preserve accepted JSON depth in projections Account for the bounded SQL envelopes around accepted source JSON so cold and partial reads cannot fail on unrelated valid definitions. Refuse selected setting parser shape errors without tenant fallback. Prove both defects and the unchanged source limits through real app-role PostgreSQL; retain cancellation and unrelated parser failures. ADR: 0010, 0040, 0043 Module: Customization, Tenancy --- .../Projections/DefinitionSnapshotStore.cs | 10 +- .../Persistence/TenantSettingsAccessor.cs | 11 +- .../CustomizationProjectionDepthTests.cs | 109 ++++++++++++++++++ .../Database/TenantSettingsAccessorTests.cs | 57 +++++++++ docs/roadmap/phase-02d-walking-skeleton.md | 30 +++++ 5 files changed, 214 insertions(+), 3 deletions(-) create mode 100644 backend/tests/LearnStack.Tests.Integration/Database/CustomizationProjectionDepthTests.cs 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 index 9f3d123..784942e 100644 --- a/backend/src/Modules/Customization/LearnStack.Modules.Customization.Infrastructure/Projections/DefinitionSnapshotStore.cs +++ b/backend/src/Modules/Customization/LearnStack.Modules.Customization.Infrastructure/Projections/DefinitionSnapshotStore.cs @@ -13,6 +13,12 @@ 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 = """ @@ -60,7 +66,7 @@ db.Database.CurrentTransaction is { } current private static ContentTypeFamily ReadContentTypes(string json) { - using var document = JsonDocument.Parse(json); + using var document = JsonDocument.Parse(json, ContentTypeSnapshotOptions); var definitions = ImmutableDictionary.CreateBuilder(); foreach (var row in document.RootElement.EnumerateArray()) { @@ -88,7 +94,7 @@ private static ContentTypeFamily ReadContentTypes(string json) private static TaxonomyFamily ReadTaxonomies(string json) { - using var document = JsonDocument.Parse(json); + using var document = JsonDocument.Parse(json, TaxonomySnapshotOptions); var definitions = ImmutableDictionary.CreateBuilder(); foreach (var row in document.RootElement.EnumerateArray()) { 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 index dc7a90d..1dd88c1 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/TenantSettingsAccessor.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Infrastructure/Persistence/TenantSettingsAccessor.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using LearnStack.Modules.Tenancy.Application.Contracts.Settings; using LearnStack.Modules.Tenancy.Application.Settings; using LearnStack.SharedKernel.Errors; @@ -48,7 +49,15 @@ public async Task>> ReadAsync( return Result.Ok(new TenantSettingRead(null)); } - var parsed = registration.Parse(selected.Value); + 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(); } 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 0000000..6d0775d --- /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/TenantSettingsAccessorTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/TenantSettingsAccessorTests.cs index 87bd855..289fe3e 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/TenantSettingsAccessorTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/TenantSettingsAccessorTests.cs @@ -27,6 +27,7 @@ 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")] @@ -101,6 +102,62 @@ await ReadInScopeAsync(context, async (db, unit, services) => }); } + [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() { diff --git a/docs/roadmap/phase-02d-walking-skeleton.md b/docs/roadmap/phase-02d-walking-skeleton.md index 37fea8e..87eac23 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -1574,6 +1574,36 @@ 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