From 0c8b125f39e8b8025de37d7e20b889d542d9fa08 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Mon, 24 Aug 2026 18:15:04 +0300 Subject: [PATCH 01/21] docs(decisions): correct two port signatures, and their publishers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-0014 Amendment 2. The Decision stands — Dapr remains the cross-process choice for pub/sub, state and secrets, reached only through IEventBus / ICacheService / ISecretProvider. What moves is the published shape of two of those interfaces, which Packet 5 is about to ship as code and can only ship one way. `ICacheService.RemoveByPrefixAsync` is removed. The reference implementation iterates a process-local key set, so keys written by another instance are never evicted — a name that promises a global effect while delivering a local one. The roadmap offered "removed OR redesigned to a generation-key pattern", and that is not a fork at the port: the corpus's own definition puts the counter in durable domain state, bumped inside the business transaction and embedded in the key template, which adds no member to the interface. It also cannot live in the cache, where an evicted counter would make abandoned keys addressable again and resurrect stale values. Both branches removed it. Nothing is lost: the corpus has no call site. `IEventBus.PublishAsync` gains a partition key and loses its generic parameter. The key is what architecture/15 and Phase 02b already published and what lets the durable transport preserve per-aggregate ordering; adding a required parameter after the first consumer exists breaks every call site. The generic parameter goes because the outbox processor deserializes to `object` and publishes through the base interface, so TEvent binds to IIntegrationEvent at the only call site that matters — and a transport resolving IIntegrationEventHandler then looks for IIntegrationEventHandler, which no concrete handler implements. The publish would reach zero handlers and report success. The amendment's own closing sentence claimed the rest of the corpus was "corrected to match in the same change" while the diff touched one file. It is now true rather than aspirational, and it is the reason this commit is eight files: * ADR-0014's Decision section is NOT rewritten — an Accepted ADR's decision is immutable. Following ADR-0003 Amendment 3's precedent, the superseded signatures are marked in place and point at the amendment, and the Status line carries the amendment summary the same way. * architecture/15's three sketches: the interface, DaprEventBus and InProcessEventBus. The last needed more than a signature change — it resolved handlers through a closed generic over the static type, which is exactly the zero-handler bug, so it now builds the contract from the event's runtime type. It also saves and restores the publisher's ambient tenant context, because a synchronous dispatch otherwise leaks a tenant into the caller's flow. Dapr publishes the runtime type too: handing a serializer the base interface produces a payload with none of the event's fields. * architecture/32, phase-02a, phase-05, phase-11 and the glossary stop saying "removed or redesigned". phase-11 was the load-bearing one — it planned the Valkey adapter to carry generation counters "if the redesign was chosen", which would have put durable domain state inside a cache adapter. * Standards 20's cache cheat sheet gains the rule that answers the question the removal creates: what a key family does when it must invalidate a set it cannot enumerate. The tenant_feature_flags row said "(key prefix)". ADR-0035:199 is left alone. It sits in Implementation Notes, says "removed or redesigned before Packet 5 ships", and points the reader at Packet 5 for the detail — a forward-looking disjunction that resolved, not a false statement, and not worth a second amendment to an Accepted ADR. Co-Authored-By: Claude Opus 5 (1M context) --- docs/architecture/15-event-and-outbox.md | 71 +++++++++++++---- .../32-tenant-customization-model.md | 7 +- docs/decisions/0014-adopt-dapr.md | 79 ++++++++++++++++++- docs/glossary.md | 2 +- docs/roadmap/phase-02a-kernel-tenancy.md | 17 ++-- .../phase-05-education-learning-content.md | 2 +- docs/roadmap/phase-11-production-hardening.md | 7 +- docs/standards/20-infrastructure-stack.md | 14 +++- 8 files changed, 168 insertions(+), 31 deletions(-) diff --git a/docs/architecture/15-event-and-outbox.md b/docs/architecture/15-event-and-outbox.md index e9311eb1..cc3951bb 100644 --- a/docs/architecture/15-event-and-outbox.md +++ b/docs/architecture/15-event-and-outbox.md @@ -379,25 +379,41 @@ domain is: ```csharp public interface IEventBus { - Task PublishAsync(TEvent @event, string partitionKey, CancellationToken ct = default) - where TEvent : IIntegrationEvent; + Task PublishAsync(IIntegrationEvent @event, string partitionKey, CancellationToken ct = default); } ``` +**Not generic**, per +[ADR-0014 Amendment 2](../decisions/0014-adopt-dapr.md). The outbox processor +deserializes to `object` and publishes through the base interface, so a generic parameter +would bind to `IIntegrationEvent` at the only call site that matters — and a transport +resolving `IIntegrationEventHandler` would then look for +`IIntegrationEventHandler`, which no concrete handler implements. The +publish would reach zero handlers and report success. Both transports resolve handlers by +the event's **runtime** type instead. + The durable implementation forwards it as Dapr's `partitionKey` metadata, which the Kafka pub/sub component maps onto the Kafka message key: ```csharp public sealed class DaprEventBus(DaprClient daprClient) : IEventBus { - public Task PublishAsync(TEvent @event, string partitionKey, CancellationToken ct = default) - where TEvent : IIntegrationEvent - => daprClient.PublishEventAsync( - "pubsub", - ConventionTopicName(@event), // "learnstack.{module}.{aggregate}" - @event, - new Dictionary { ["partitionKey"] = partitionKey }, - ct); + public Task PublishAsync( + IIntegrationEvent @event, string partitionKey, CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(partitionKey); + + // Published as the runtime type, not as IIntegrationEvent: the serializer + // writes the members of the type it is given, and handing it the base + // interface produces a payload with none of the event's own fields. + return daprClient.PublishEventAsync( + "pubsub", + ConventionTopicName(@event), // "learnstack.{module}.{aggregate}" + @event.GetType(), + @event, + new Dictionary { ["partitionKey"] = partitionKey }, + ct); + } private static string ConventionTopicName(IIntegrationEvent @event) => $"learnstack.{ExtractModule(@event.GetType())}.{ExtractAggregate(@event.GetType())}"; @@ -437,15 +453,36 @@ public sealed class InProcessEventBus( ITenantContextAccessor tenantAccessor, IPartitionSerializer partitions) : IEventBus { - public Task PublishAsync(TEvent @event, string partitionKey, CancellationToken ct = default) - where TEvent : IIntegrationEvent - => partitions.RunSequentiallyFor(partitionKey, async () => + public Task PublishAsync( + IIntegrationEvent @event, string partitionKey, CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(partitionKey); + + return partitions.RunSequentiallyFor(partitionKey, async () => { await using var scope = scopeFactory.CreateAsyncScope(); - tenantAccessor.Set(TenantContext.FromEvent(@event)); // same restore as the durable path - foreach (var handler in scope.ServiceProvider.GetServices>()) - await handler.HandleAsync(@event, ct); // handler calls IInboxGuard itself - }); + + // Same restore as the durable path, into the SCOPE the handler + // resolves from — and the publisher's own ambient context is put + // back afterwards, or a synchronous dispatch leaks a tenant into + // the caller's flow. + var previous = tenantAccessor.Current; + tenantAccessor.Set(TenantContext.FromEvent(@event)); + try + { + // By runtime type. `@event` is declared as the base interface + // here, so a closed generic over its static type would resolve + // nothing. + var contract = typeof(IIntegrationEventHandler<>).MakeGenericType(@event.GetType()); + foreach (var handler in scope.ServiceProvider.GetServices(contract)) + await ((dynamic)handler!).HandleAsync((dynamic)@event, ct); + } + finally + { + tenantAccessor.Set(previous); + } + }); // handler calls IInboxGuard itself + } } ``` diff --git a/docs/architecture/32-tenant-customization-model.md b/docs/architecture/32-tenant-customization-model.md index 4dd0f48a..293fba85 100644 --- a/docs/architecture/32-tenant-customization-model.md +++ b/docs/architecture/32-tenant-customization-model.md @@ -450,8 +450,11 @@ Two rules make this safe: write. Cache keys embed it, so a write makes every stale key unreachable at once, across every pod, without enumerating keys. This is deliberate: the published `ICacheService.RemoveByPrefixAsync` contract cannot be honoured across instances by any - candidate backend, and it is removed or redesigned to exactly this pattern before - [Phase 02a Packet 5](../roadmap/phase-02a-kernel-tenancy.md) ships. + candidate backend, and it is **removed** in + [Phase 02a Packet 5](../roadmap/phase-02a-kernel-tenancy.md) + ([ADR-0014 Amendment 2](../decisions/0014-adopt-dapr.md)). This pattern replaces it, + and it is a convention here rather than a member of that interface — the counter is + durable domain state, not a cache entry. - **Compiled validators are cached separately from definitions**, keyed by an immutable `(key, schema_version)` tuple. Compiling a JSON Schema is the expensive part; because a published schema version is immutable ([§ 4](#4-schema-versioning)), the compiled form diff --git a/docs/decisions/0014-adopt-dapr.md b/docs/decisions/0014-adopt-dapr.md index 7e9dcd13..4dd311f9 100644 --- a/docs/decisions/0014-adopt-dapr.md +++ b/docs/decisions/0014-adopt-dapr.md @@ -2,7 +2,9 @@ ## Status -Accepted +Accepted (Amendment 1: 2026-08-08 — schedule moved to Phase 11; **Amendment 2: +2026-08-24 — corrects the published `IEventBus` and `ICacheService` signatures**; +see bottom of document) ## Date @@ -144,6 +146,12 @@ Adopt **Option A**: Dapr for pub/sub + state + secrets. ### Application access pattern +**The `IEventBus` and `ICacheService` signatures below are superseded by** +[Amendment 2](#2026-08-24--amendment-2-two-port-signatures-corrected-before-first-use). +They are left as written because an Accepted ADR's Decision section is not rewritten; +what Packet 5 ships is the amended shape. `ISecretProvider` is unchanged and shipped in +Packet 3. + Every module's domain and application code uses: ```csharp @@ -272,6 +280,75 @@ The default secret provider that shipped in Phase 02a Packet 3 is named `IConfiguration` — which already merges environment variables, user secrets and `appsettings.{env}.json` — rather than process environment variables alone. +### 2026-08-24 — Amendment 2: two port signatures, corrected before first use + +The Decision stands. Dapr remains the cross-process choice for pub/sub, state and +secrets, and application code still reaches all three only through `IEventBus` / +`ICacheService` / `ISecretProvider`. What this amendment corrects is the **published +shape** of two of those interfaces, which Phase 02a Packet 5 is about to ship as code +and can only ship one way. + +**1. `ICacheService.RemoveByPrefixAsync` is removed.** + +The port becomes `GetAsync` / `GetOrSetAsync` / `SetAsync` / `RemoveAsync` and nothing +else. The reference implementation iterates a process-local key set, so keys written by +another instance are never evicted — a contract no candidate backend can honour, and one +whose name promises a global effect while delivering a local one. + +The roadmap offered "removed **or** redesigned to a generation-key pattern". That is not +a fork at the port: the corpus's own definition of the pattern puts the counter in +**durable domain state** — a `customization_generation` column bumped inside the +business transaction and embedded in the key template ([architecture/32 § +8.2](../architecture/32-tenant-customization-model.md)) — which adds no member to this +interface. It also cannot live in the cache: an evicted counter would make previously +abandoned keys addressable again and resurrect stale values. Both branches therefore +remove the method, and the generation-key pattern is recorded as a **caller-side +convention** owned by the consumers that specify it. + +Nothing is lost by the removal: the corpus contains no call site for the method. + +**2. `IEventBus.PublishAsync` takes a partition key, and is not generic.** + +Published here as: + +```csharp +Task PublishAsync(TEvent @event, CancellationToken ct = default) + where TEvent : IIntegrationEvent; +``` + +It becomes: + +```csharp +Task PublishAsync(IIntegrationEvent @event, string partitionKey, CancellationToken ct = default); +``` + +Two corrections in one signature. + +*The partition key* is what +[architecture/15 § The bus](../architecture/15-event-and-outbox.md) and [Phase +02b](../roadmap/phase-02b-events-auth.md) already publish, and it is what lets the +durable transport map onto a Kafka message key and preserve per-aggregate ordering. +Adding a required parameter after the first consumer exists breaks every call site, so +the two shapes cannot be left to be reconciled later. + +*The generic parameter* is removed because the outbox dispatcher deserializes to +`object` and calls through the base interface — +`eventBus.PublishAsync((IIntegrationEvent)eventInstance!, msg.PartitionKey, ct)` at +[architecture/15](../architecture/15-event-and-outbox.md). With a generic port, `TEvent` +binds to `IIntegrationEvent` at that call, so a transport resolving +`IIntegrationEventHandler` looks for +`IIntegrationEventHandler` — which no concrete handler implements. +The result is a publish that dispatches to **zero handlers** and reports success. A +non-generic port makes the runtime-type resolution the transport has to do anyway +explicit, rather than hiding it behind a type parameter that is always erased to the +base interface at the only call site that matters. + +Every other document publishing either signature is corrected in the same change: +`architecture/15`'s three sketches (the interface, `DaprEventBus` and `InProcessEventBus`, +the last of which must resolve handlers by runtime type rather than through a type +parameter), `architecture/32 § 8.2` and the Packet 5 scope paragraph, both of which stop +saying "removed **or** redesigned" now that it is removed. + ## References - ADR-0006 — Events and Outbox (status: Accepted after this ADR; previously Proposed). diff --git a/docs/glossary.md b/docs/glossary.md index b6249339..c68fa32c 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -279,7 +279,7 @@ This glossary defines LearnStack-specific terms. When a term is ambiguous across | **One-Way Door** | A decision that gets more expensive with every week of delay, tested by the question: *if I add this six months from now, will I have to touch code that is already written?* **Yes → one-way door; ship it now** (tenant and organization isolation, the `outbox_messages` table and its ownership, strongly-typed identifiers, the localization schema — each touches every query, migration, or job payload ever written). **No → additive; ship the port now and the adapter on demand.** The test is mechanical, not a matter of taste: tenant isolation's cost grows with the codebase, a Dapr adapter's cost does not. A corollary rule: **a deployment mode or customer segment without a signed contract cannot be the deciding factor in a technical choice** — it may break a tie between otherwise-equal options, nothing more. Per [ADR-0035](decisions/0035-demand-gated-infrastructure.md) and [Engineering Principles](standards/00-principles.md). | | **Dapr Building Blocks** | The three Dapr abstractions LearnStack uses **when it uses Dapr**: pub/sub (Kafka), state (Valkey), secrets (Vault) per [ADR-0014](decisions/0014-adopt-dapr.md). Service invocation, workflow, bindings, and actors are out of scope. Demand-gated to [Phase 11](roadmap/phase-11-production-hardening.md); ADR-0014 decides *what*, [ADR-0035](decisions/0035-demand-gated-infrastructure.md) decides *when*. | | **`IEventBus`** | Interface for publishing integration events, taking an explicit `partitionKey`. `InProcessEventBus` is the only registered implementation until the Dapr adapter's trigger fires — and it is a **first-class transport, not a stub**: same `IIntegrationEventHandler`, same `IInboxGuard`, same tenant-context restoration, same per-partition-key ordering as the durable path. The `OutboxProcessor` is the only sanctioned caller. | -| **`ICacheService`** | Interface for cache reads / writes. `InMemoryCacheService` today; a Valkey-backed implementation when more than one instance runs concurrently. Cache keys carry a `{tenant_id}` prefix. `RemoveByPrefixAsync` is removed or redesigned to a **generation-key** pattern before Phase 02a Packet 5 ships — the published contract iterates an instance-local key set and cannot be honoured across instances by any candidate backend. | +| **`ICacheService`** | Interface for cache reads / writes. `InMemoryCacheService` today; a Valkey-backed implementation when more than one instance runs concurrently. Cache keys carry a `{tenant_id}` prefix. `RemoveByPrefixAsync` is **removed** ([ADR-0014 Amendment 2](decisions/0014-adopt-dapr.md)) — it iterated an instance-local key set, so keys written by another instance were never evicted. What replaces it is the **generation-key** pattern, which is a caller-side convention rather than a member of this interface: a durable counter bumped inside the business transaction and embedded in the key template. | | **`ISecretProvider`** | Interface for secret reads. `ConfigurationSecretProvider` today; the Vault-backed implementation when a production secret must rotate without a redeploy, or more than one operator needs access to production secrets ([ADR-0035](decisions/0035-demand-gated-infrastructure.md) — *not* when a non-development deployment merely exists, which SaaS satisfies on day one). Secret namespace `learnstack/{deployment}/{module}/{key}`. | | **`IEntitlementProvider`** | Interface for the Entitlement Projection source. Implementations: `NullEntitlementProvider` (Development only — all features enabled, no limits), `HubEntitlementProvider` (SaaS / Dedicated, from Phase 02c), `SignedLicenseKeyEntitlementProvider` (Self-Hosted; skeleton from Hub `P02c-6`, hardened in Phase 11). The Hub-backed provider resolves in the normative order `L1 → L2 → platform_entitlement_cache → Hub` and never throws out of a feature-flag check. | | **`IHostToTenantResolver`** | Interface for host → `(tenant_id, organization_id?)` resolution. Reads `platform_host_to_tenant` and **nothing else** — never the Hub, because an anonymous page load must not depend on a control plane being reachable ([ADR-0034](decisions/0034-hub-contract-surface-invariant.md)). | diff --git a/docs/roadmap/phase-02a-kernel-tenancy.md b/docs/roadmap/phase-02a-kernel-tenancy.md index ad3be8c5..1e845c44 100644 --- a/docs/roadmap/phase-02a-kernel-tenancy.md +++ b/docs/roadmap/phase-02a-kernel-tenancy.md @@ -322,11 +322,18 @@ tenant-context restoration as the durable path. A development path that skips those is a development path that never exercises the isolation code, and every consumer would end up with two implementations. -`ICacheService.RemoveByPrefixAsync` is **removed or redesigned** before this -packet ships. The published implementation iterates an instance-local key set, -so keys written by another instance are never evicted — the contract cannot be -honoured by any candidate backend. Either the method leaves the interface, or -it is replaced by a generation-key pattern whose guarantee is achievable. +`ICacheService.RemoveByPrefixAsync` is **removed** +([ADR-0014 Amendment 2](../decisions/0014-adopt-dapr.md)). The published +implementation iterates an instance-local key set, so keys written by another +instance are never evicted — the contract cannot be honoured by any candidate +backend, and the corpus contains no call site for it. + +"Removed **or** redesigned to a generation-key pattern" was not a fork at the +port: that pattern puts its counter in durable domain state — a column bumped +inside the business transaction and embedded in the key template +([architecture/32 § 8.2](../architecture/32-tenant-customization-model.md)) — so +it adds no member to the interface. It stays a caller-side convention, owned by +the consumers that specify it. The Dapr sidecar, Kafka, APISIX and Vault adapters are **not** in this packet. They are demand-gated with written triggers in diff --git a/docs/roadmap/phase-05-education-learning-content.md b/docs/roadmap/phase-05-education-learning-content.md index 0f81a0b1..4b5aa87e 100644 --- a/docs/roadmap/phase-05-education-learning-content.md +++ b/docs/roadmap/phase-05-education-learning-content.md @@ -215,7 +215,7 @@ this phase implements and measures it. immutable body aggressively; keep the pointer on a short TTL behind a per-tenant generation key that publish bumps. - Invalidation uses that generation key, not a prefix scan. - `ICacheService.RemoveByPrefixAsync` was removed or redesigned in + `ICacheService.RemoveByPrefixAsync` was removed in [Phase 02a Packet 5](phase-02a-kernel-tenancy.md) precisely because prefix eviction cannot be honoured across instances; this phase must not reintroduce the assumption. - Compiled artefacts — the compiled JSON Schema validator and the compiled rule — live diff --git a/docs/roadmap/phase-11-production-hardening.md b/docs/roadmap/phase-11-production-hardening.md index d9474d0c..8bb8bb0b 100644 --- a/docs/roadmap/phase-11-production-hardening.md +++ b/docs/roadmap/phase-11-production-hardening.md @@ -66,9 +66,10 @@ a producer. See [15-event-and-outbox.md](../architecture/15-event-and-outbox.md) application instance runs concurrently.* The `InMemoryCacheService` from Packet 5 is correct for exactly one process and silently wrong for two, so this adapter lands the moment a second replica does. Includes the L1/L2 layering used by the entitlement read -path, the cross-instance invalidation topic, and — if the generation-key redesign from -Packet 5 was chosen over removal — the generation counters that replace -`RemoveByPrefixAsync`. See [ADR-0030](../decisions/0030-redis-compatible-store-valkey.md). +path, and the cross-instance invalidation topic. It does **not** carry generation counters: +Packet 5 removed `RemoveByPrefixAsync` rather than redesigning it, and the generation +pattern that replaces it is a caller-side convention over durable domain state, which no +cache adapter can own. See [ADR-0030](../decisions/0030-redis-compatible-store-valkey.md). **Vault behind `ISecretProvider`** — *trigger: a production secret must rotate without a redeploy, or more than one operator needs access to production secrets.* KV mount diff --git a/docs/standards/20-infrastructure-stack.md b/docs/standards/20-infrastructure-stack.md index e63e171b..88cc7b90 100644 --- a/docs/standards/20-infrastructure-stack.md +++ b/docs/standards/20-infrastructure-stack.md @@ -187,12 +187,24 @@ different decisions: |---|---|---|---| | `hub:host:{host}` (host → tenant) | 2 min | 15 min | `learnstack.hub.custom-domain.activated/.deactivated` | | `hub:entitlement:{tenant_id}` (plan projection) | 60 s | 15 min (upper bound; Hub-push refresh resets it) | `learnstack.hub.entitlement` | -| `tenant_feature_flags:{tenant_id}` | 60 s | 15 min | `learnstack.cache.invalidation` (key prefix) | +| `tenant_feature_flags:{tenant_id}` | 60 s | 15 min | `learnstack.cache.invalidation` (generation key — see the rule below) | | Permission lookup per session | 60 s | session-scoped (no L2) | `learnstack.identity.role` / `.membership` events | | Tenant settings (low-churn) | 5 min | 1 h | `learnstack.tenancy.settings` | Rules: +- **There is no prefix invalidation.** `ICacheService` has no + `RemoveByPrefixAsync` — it was removed in + [Phase 02a Packet 5](../roadmap/phase-02a-kernel-tenancy.md) per + [ADR-0014 Amendment 2](../decisions/0014-adopt-dapr.md), because the only + implementable form iterated a process-local key set and never evicted what + another instance wrote. A key family that needs to invalidate a set it cannot + enumerate uses the **generation-key** pattern instead: a durable counter bumped + inside the business transaction and embedded in the key template + ([architecture/32 § 8.2](../architecture/32-tenant-customization-model.md)), so + 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. - The 15-min L2 figure is an **upper bound**, not the typical refresh window — From 6947725cd7a93c06b5e6e5bd78951a2152a9e457 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Mon, 24 Aug 2026 18:36:51 +0300 Subject: [PATCH 02/21] feat(kernel): ship the cache port, the key as its isolation boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Packet 5, Step 1. `ICacheService` in the kernel, `InMemoryCacheService` as the only registered implementation, and a `SelectCacheService` seam next to `SelectSecretProvider` so Phase 11's Valkey adapter is one line rather than a search for every registration. The port is what ADR-0014 Amendment 2 published: Get / GetOrSet / Set / Remove, and no `RemoveByPrefixAsync`. `CacheOptions` also loses the `string[]? Tags` third parameter the old sketch carried — no document ever specified it and nothing ever read it, and tag invalidation has the same defect prefix invalidation had: it needs an index from tag to keys that no candidate backend maintains across instances, so it would evict what one process knows about and silently miss the rest. Removing one unimplementable invalidation surface and shipping another in the same commit would have been a poor trade. `CacheKey` is the part worth arguing about. There is no query filter and no RLS policy in front of a dictionary, so the key IS the isolation boundary: a key that omits the tenant is a key two tenants can both compute, and the second one reads the first one's value. The shape is validated in the kernel rather than left to each call site, and all four entry points call the guard — a check on Get that Set does not share is a check a writer walks straight past. A platform-wide value uses the `platform` sentinel instead of dropping the segment, so "no tenant" and "every tenant" look different in a key dump. `GetOrSetAsync` single-flights. The factory is the expensive side — a database round trip, a Hub call — and a cache that lets N simultaneous misses each run it turns a cold key into a stampede against the dependency it exists to spare. It uses a Lazy with ExecutionAndPublication rather than a bare GetOrAdd, because a ConcurrentDictionary value factory may run more than once under contention, which is the same lesson the idempotency store's AddOrUpdate taught. Capacity here is eviction, and in `InMemoryIdempotencyStore` it is admission. The two look like one shape and carry opposite rules: an idempotency record is a promise for the length of its window, so dropping one lets an operation run twice; a cache entry promises nothing, so dropping one costs a round trip. Both files now say why they differ. Two tests were not testing what they claimed, caught by mutation before the commit. `The_Map_Is_Bounded` used a one-hour TTL over a run that advances the clock about three hours, so the oldest key was gone because it EXPIRED — deleting the bound entirely left the test green. It uses a TTL that outlasts the run now, and deleting the bound turns it red. The single-flight case was verified the same way: without the shared Lazy, eight concurrent callers run the factory eight times and the assertion fails. Co-Authored-By: Claude Opus 5 (1M context) --- .../CrossCuttingFoundationExtensions.cs | 34 +++ .../Caching/InMemoryCacheService.cs | 208 ++++++++++++++ .../Caching/CacheKey.cs | 85 ++++++ .../Caching/CacheOptions.cs | 30 ++ .../Caching/ICacheService.cs | 61 ++++ .../Caching/InMemoryCacheServiceTests.cs | 261 ++++++++++++++++++ .../SharedKernel/Caching/CacheKeyTests.cs | 88 ++++++ 7 files changed, 767 insertions(+) create mode 100644 backend/src/LearnStack.Infrastructure/Caching/InMemoryCacheService.cs create mode 100644 backend/src/LearnStack.SharedKernel/Caching/CacheKey.cs create mode 100644 backend/src/LearnStack.SharedKernel/Caching/CacheOptions.cs create mode 100644 backend/src/LearnStack.SharedKernel/Caching/ICacheService.cs create mode 100644 backend/tests/LearnStack.Tests.Unit/Infrastructure/Caching/InMemoryCacheServiceTests.cs create mode 100644 backend/tests/LearnStack.Tests.Unit/SharedKernel/Caching/CacheKeyTests.cs diff --git a/backend/src/LearnStack.Api/Composition/CrossCuttingFoundationExtensions.cs b/backend/src/LearnStack.Api/Composition/CrossCuttingFoundationExtensions.cs index 9c5dd8cd..dba96261 100644 --- a/backend/src/LearnStack.Api/Composition/CrossCuttingFoundationExtensions.cs +++ b/backend/src/LearnStack.Api/Composition/CrossCuttingFoundationExtensions.cs @@ -86,6 +86,11 @@ public static WebApplicationBuilder AddLearnStackCrossCuttingFoundation( builder.Services.TryAddSingleton(); + // The cache socket. SelectCacheService is the SINGLE site that picks the + // implementation per DeploymentMode, so Phase 11's Valkey adapter is one + // line here rather than a search for every registration. + builder.Services.TryAddSingleton(SelectCacheService); + builder.Services.AddProblemDetails(); builder.Services.AddExceptionHandler(); @@ -194,6 +199,35 @@ private static void WireOpenTelemetry(WebApplicationBuilder builder, DeploymentM _ = otel; } + /// + /// Single composition-root site that picks the + /// implementation + /// per . + /// + /// + /// Every mode resolves InMemoryCacheService today, and the method + /// exists anyway: it is the seam ADR-0035 asks for, and a seam that is one + /// method is a seam Phase 11 can widen without hunting for call sites. It + /// takes the provider rather than the mode because the mode is not what it + /// branches on yet — a five-arm switch returning the same instance would be + /// a branch whose test could only assert something vacuous. + /// + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Performance", + "CA1859:Use concrete types when possible for improved performance", + Justification = "Return type is intentionally ICacheService so Phase 11 can swap implementations per DeploymentMode.")] + private static LearnStack.SharedKernel.Caching.ICacheService SelectCacheService( + IServiceProvider services) + { + // TODO(2026-08-24, @platform): Phase 11 — light up the Valkey-backed + // branch. Demand-gated per ADR-0035; trigger: more than one application + // instance runs concurrently. InMemoryCacheService is correct for one + // process and costs hit rate rather than correctness for two, which is + // why the trigger is a replica count and not a date. + return new LearnStack.Infrastructure.Caching.InMemoryCacheService( + services.GetRequiredService()); + } + /// /// Single composition-root site that picks the /// implementation per diff --git a/backend/src/LearnStack.Infrastructure/Caching/InMemoryCacheService.cs b/backend/src/LearnStack.Infrastructure/Caching/InMemoryCacheService.cs new file mode 100644 index 00000000..680b79a5 --- /dev/null +++ b/backend/src/LearnStack.Infrastructure/Caching/InMemoryCacheService.cs @@ -0,0 +1,208 @@ +using System.Collections.Concurrent; +using LearnStack.SharedKernel.Caching; +using LearnStack.SharedKernel.Time; + +namespace LearnStack.Infrastructure.Caching; + +/// +/// The default : correct for one process, and — unlike +/// the idempotency store next door — not silently wrong for two. +/// +/// +/// +/// This is not shared between instances. Two application instances each +/// hold their own map, so a value written by one is not visible to the other and +/// a on one does not evict the other's copy. That is a +/// staleness bound, not a correctness bug: a cache miss is never an error, +/// and the source of truth is unaffected. The Valkey-backed adapter lands on its +/// ADR-0035 +/// trigger — more than one application instance running concurrently — and until +/// then a second instance costs cache hit rate rather than correctness. +/// +/// +/// Why this evicts freely and InMemoryIdempotencyStore does not. +/// The two look like the same shape and carry opposite rules. An idempotency +/// record is a promise for the length of its window, so dropping one lets an +/// operation run twice; a cache entry promises nothing, so dropping one costs a +/// round trip. Capacity here is eviction, and there it is admission — the same +/// bound in the same kind of dictionary, decided the other way, because the +/// contracts differ. +/// +/// +public sealed class InMemoryCacheService(IClock clock) : ICacheService +{ + /// + /// The TTL an entry gets when the caller names none — Standards 20's + /// hot-path default. + /// + public static readonly TimeSpan DefaultTtl = TimeSpan.FromSeconds(60); + + /// + /// How often the map is swept for expired entries. Reclamation only: an + /// expired entry is never returned, whether or not a sweep has run. + /// + public static readonly TimeSpan SweepInterval = TimeSpan.FromSeconds(1); + + /// + /// The most entries held at once. A cache with no bound is an + /// out-of-memory condition waiting for a caller with an unbounded key space. + /// + public const int MaxEntries = 10_000; + + private readonly ConcurrentDictionary _entries = new(StringComparer.Ordinal); + + /// + /// One factory run per key, however many callers miss at once. + /// + private readonly ConcurrentDictionary>> _inFlight = + new(StringComparer.Ordinal); + + private long _lastSweepTicks; + + public Task GetAsync(string key, CancellationToken cancellationToken = default) + { + CacheKey.EnsureValid(key); + + var now = clock.UtcNow; + Sweep(now); + + if (_entries.TryGetValue(key, out var entry) && entry.IsFresh(now)) + { + return Task.FromResult((T?)entry.Value); + } + + return Task.FromResult(default); + } + + public async Task GetOrSetAsync( + string key, + Func> factory, + CacheOptions? options = null, + CancellationToken cancellationToken = default) + { + CacheKey.EnsureValid(key); + ArgumentNullException.ThrowIfNull(factory); + + var now = clock.UtcNow; + Sweep(now); + + if (_entries.TryGetValue(key, out var hit) && hit.IsFresh(now)) + { + return (T)hit.Value!; + } + + // Lazy with ExecutionAndPublication, not a bare GetOrAdd: the value + // factory of a ConcurrentDictionary may run more than once under + // contention, and running the caller's factory twice is the stampede + // this method exists to prevent. + var flight = _inFlight.GetOrAdd( + key, + _ => new Lazy>( + async () => await factory(cancellationToken).ConfigureAwait(false), + LazyThreadSafetyMode.ExecutionAndPublication)); + + try + { + var produced = (T)(await flight.Value.ConfigureAwait(false))!; + Store(key, produced, options, clock.UtcNow); + return produced; + } + finally + { + // Value-comparing, so a later flight started by another caller is + // not removed by this one's cleanup. + _inFlight.TryRemove(new KeyValuePair>>(key, flight)); + } + } + + public Task SetAsync( + string key, + T value, + CacheOptions? options = null, + CancellationToken cancellationToken = default) + { + CacheKey.EnsureValid(key); + + var now = clock.UtcNow; + Sweep(now); + Store(key, value, options, now); + + return Task.CompletedTask; + } + + public Task RemoveAsync(string key, CancellationToken cancellationToken = default) + { + CacheKey.EnsureValid(key); + + _entries.TryRemove(key, out _); + return Task.CompletedTask; + } + + private void Store(string key, T value, CacheOptions? options, DateTimeOffset now) + { + // L2Ttl is read and ignored: there is no second layer here. Carrying it + // means a caller written today does not change when the Valkey adapter + // gives it a meaning. + var ttl = options?.L1Ttl ?? DefaultTtl; + + _entries[key] = new Entry(value, now + ttl, now); + } + + /// + /// Drops expired entries, and — only when the map is over its bound — the + /// oldest live ones. At most once per . + /// + private void Sweep(DateTimeOffset now) + { + var ticks = now.UtcTicks; + var last = Interlocked.Read(ref _lastSweepTicks); + + // A clock that steps backwards would otherwise wedge the sweep until + // real time caught up. + if (ticks >= last && ticks - last < SweepInterval.Ticks) + { + return; + } + + if (Interlocked.CompareExchange(ref _lastSweepTicks, ticks, last) != last) + { + return; + } + + var live = 0; + + foreach (var pair in _entries) + { + if (pair.Value.IsFresh(now)) + { + live++; + continue; + } + + // Value-comparing: between the enumerator observing an expired entry + // and this line, another thread may have written a fresh one at the + // same key, and removing by key alone would drop that instead. + _entries.TryRemove(pair); + } + + if (live <= MaxEntries) + { + return; + } + + // Oldest first. Evicting a live entry is allowed here — a miss costs a + // round trip — which is exactly what makes this bound simpler than the + // idempotency store's. + foreach (var pair in _entries + .OrderBy(pair => pair.Value.WrittenAt) + .Take(live - MaxEntries)) + { + _entries.TryRemove(pair); + } + } + + private sealed record Entry(object? Value, DateTimeOffset ExpiresAt, DateTimeOffset WrittenAt) + { + public bool IsFresh(DateTimeOffset now) => now < ExpiresAt; + } +} diff --git a/backend/src/LearnStack.SharedKernel/Caching/CacheKey.cs b/backend/src/LearnStack.SharedKernel/Caching/CacheKey.cs new file mode 100644 index 00000000..c65790d3 --- /dev/null +++ b/backend/src/LearnStack.SharedKernel/Caching/CacheKey.cs @@ -0,0 +1,85 @@ +namespace LearnStack.SharedKernel.Caching; + +/// +/// Builds and validates the one cache-key shape +/// Standards 20 +/// § Cache admits: {tenant_id}:{module}:{logical-name}. +/// +/// +/// +/// The tenant segment is mandatory, and that is the whole point. A cache is +/// a lookup keyed by a string, so a key that omits the tenant is a key two tenants +/// can both compute — and the second one reads the first one's value. There is no +/// query filter and no RLS policy in front of a dictionary; the key is the entire +/// isolation boundary, which is why it is validated here rather than left to each +/// call site to remember. +/// +/// +/// A platform-wide value uses the sentinel rather +/// than omitting the segment. "No tenant" and "every tenant" then look different +/// in a key dump, and the rule stays one rule. +/// +/// +public static class CacheKey +{ + /// The tenant segment a platform-wide value carries. + public const string PlatformTenant = "platform"; + + /// The separator between the three segments. + public const char Separator = ':'; + + /// Composes a key for a tenant-owned value. + public static string For(Guid tenantId, string module, string logicalName) => + For(tenantId.ToString(), module, logicalName); + + /// Composes a key for a platform-wide value. + public static string ForPlatform(string module, string logicalName) => + For(PlatformTenant, module, logicalName); + + /// + /// Throws when a key does not carry three non-empty segments. + /// + /// + /// Every implementation calls this. It lives here + /// rather than in one of them because the rule belongs to the contract: an + /// adapter that forgot it would not fail its own tests, it would quietly widen + /// the key space of a system whose isolation the key IS. + /// + public static void EnsureValid(string key) + { + ArgumentException.ThrowIfNullOrWhiteSpace(key); + + var segments = key.Split(Separator); + if (segments.Length < 3 || segments.Any(string.IsNullOrWhiteSpace)) + { + throw new ArgumentException( + $"'{key}' is not a cache key. Standards 20 fixes the shape as " + + $"'{{tenant_id}}{Separator}{{module}}{Separator}{{logical-name}}', and the " + + $"tenant segment is mandatory even for a platform-wide value — use the " + + $"'{PlatformTenant}' sentinel rather than omitting it. A key without a " + + "tenant is a key two tenants can both compute.", + nameof(key)); + } + } + + private static string For(string tenant, string module, string logicalName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(tenant); + ArgumentException.ThrowIfNullOrWhiteSpace(module); + ArgumentException.ThrowIfNullOrWhiteSpace(logicalName); + + // A separator inside a segment would let two different (tenant, module, + // name) triples produce the same key — the ambiguity a delimiter always + // has when a component can contain it. + foreach (var segment in (string[])[tenant, module, logicalName]) + { + if (segment.Contains(Separator, StringComparison.Ordinal)) + { + throw new ArgumentException( + $"A cache-key segment may not contain '{Separator}': '{segment}'."); + } + } + + return $"{tenant}{Separator}{module}{Separator}{logicalName}"; + } +} diff --git a/backend/src/LearnStack.SharedKernel/Caching/CacheOptions.cs b/backend/src/LearnStack.SharedKernel/Caching/CacheOptions.cs new file mode 100644 index 00000000..f2a974f4 --- /dev/null +++ b/backend/src/LearnStack.SharedKernel/Caching/CacheOptions.cs @@ -0,0 +1,30 @@ +namespace LearnStack.SharedKernel.Caching; + +/// +/// Per-entry cache policy, per +/// Standards 20 +/// § Cache layer cheat sheet. +/// +/// +/// How long the in-process copy stays valid. null takes the +/// implementation's default. +/// +/// +/// How long the shared copy stays valid. null takes the implementation's +/// default, and an implementation with no second layer ignores it — the value is +/// carried so a caller written today does not have to be revisited when the +/// Valkey-backed adapter lands on its +/// ADR-0035 +/// trigger. +/// +/// +/// No Tags. An earlier sketch carried a string[]? Tags +/// third parameter that no document ever specified and nothing ever read. +/// Tag-based invalidation has the same defect as the prefix-based invalidation +/// ADR-0014 Amendment 2 +/// removed: it requires an index from tag to keys that no candidate backend +/// maintains across instances, so the method would evict what one process +/// happens to know about and silently miss the rest. A key family that must +/// invalidate a set it cannot enumerate uses the generation-key pattern instead. +/// +public sealed record CacheOptions(TimeSpan? L1Ttl = null, TimeSpan? L2Ttl = null); diff --git a/backend/src/LearnStack.SharedKernel/Caching/ICacheService.cs b/backend/src/LearnStack.SharedKernel/Caching/ICacheService.cs new file mode 100644 index 00000000..251267cd --- /dev/null +++ b/backend/src/LearnStack.SharedKernel/Caching/ICacheService.cs @@ -0,0 +1,61 @@ +namespace LearnStack.SharedKernel.Caching; + +/// +/// The one cache abstraction, per +/// ADR-0014 and +/// its Amendment 2. Modules never inject a cache client — no +/// IConnectionMultiplexer, no IDistributedCache, no +/// IMemoryCache. +/// +/// +/// +/// There is no RemoveByPrefixAsync. It was removed by ADR-0014 +/// Amendment 2: the only implementable form iterated a process-local key set, so +/// keys written by another instance were never evicted — a name promising a +/// global effect while delivering a local one. A key family that must invalidate +/// a set it cannot enumerate uses the **generation-key** pattern instead, which +/// is a caller-side convention rather than a member here: a durable counter +/// bumped inside the business transaction and embedded in the key template, so a +/// write makes every stale key unreachable at once without deleting any of them +/// (architecture/32 +/// § 8.2). +/// +/// +/// A cache miss is never an error. Every method here is allowed to be a +/// no-op — an implementation may evict at any moment for any reason, and a caller +/// that treats a miss as a failure has built a dependency on a component whose +/// contract is "sometimes". Correctness lives in the source of truth; this only +/// makes reading it cheaper. +/// +/// +public interface ICacheService +{ + /// Reads a cached value, or default when there is none. + Task GetAsync(string key, CancellationToken cancellationToken = default); + + /// + /// Reads a cached value, producing and storing it on a miss. + /// + /// + /// An implementation is expected to run once + /// for concurrent misses on one key. The factory is the expensive side — a + /// database round trip, a Hub call — and a cache that lets N simultaneous + /// misses each run it turns a cold key into a stampede against the very + /// dependency it exists to spare. + /// + Task GetOrSetAsync( + string key, + Func> factory, + CacheOptions? options = null, + CancellationToken cancellationToken = default); + + /// Stores a value. + Task SetAsync( + string key, + T value, + CacheOptions? options = null, + CancellationToken cancellationToken = default); + + /// Drops one key. Dropping a key that is not there is not an error. + Task RemoveAsync(string key, CancellationToken cancellationToken = default); +} diff --git a/backend/tests/LearnStack.Tests.Unit/Infrastructure/Caching/InMemoryCacheServiceTests.cs b/backend/tests/LearnStack.Tests.Unit/Infrastructure/Caching/InMemoryCacheServiceTests.cs new file mode 100644 index 00000000..c78560e1 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Unit/Infrastructure/Caching/InMemoryCacheServiceTests.cs @@ -0,0 +1,261 @@ +using FluentAssertions; +using LearnStack.Infrastructure.Caching; +using LearnStack.SharedKernel.Caching; +using LearnStack.SharedKernel.Time; +using Xunit; + +namespace LearnStack.Tests.Unit.Infrastructure.Caching; + +/// +/// The default , per +/// ADR-0014 +/// and its Amendment 2. +/// +/// +/// Every expiry case moves a rather than sleeping, so +/// the TTL behaviour is asserted rather than approximated — the same reason +/// InMemoryIdempotencyStore takes a clock. +/// +public sealed class InMemoryCacheServiceTests +{ + private static readonly DateTimeOffset Origin = new(2026, 8, 24, 9, 0, 0, TimeSpan.Zero); + private static readonly Guid Tenant = Guid.Parse("018f4d40-0000-7000-8000-00000000000a"); + private static readonly Guid OtherTenant = Guid.Parse("018f4d40-0000-7000-8000-00000000000b"); + + private static string Key(Guid tenant = default) => + CacheKey.For(tenant == default ? Tenant : tenant, "tenancy", "settings"); + + [Fact] + public async Task A_Miss_Is_Default_Not_An_Error() + { + var (cache, _) = New(); + + (await cache.GetAsync(Key())).Should().BeNull(); + } + + [Fact] + public async Task What_Was_Set_Is_What_Is_Read() + { + var (cache, _) = New(); + + await cache.SetAsync(Key(), "value"); + + (await cache.GetAsync(Key())).Should().Be("value"); + } + + [Fact] + public async Task A_Removed_Key_Is_A_Miss() + { + var (cache, _) = New(); + await cache.SetAsync(Key(), "value"); + + await cache.RemoveAsync(Key()); + + (await cache.GetAsync(Key())).Should().BeNull(); + } + + [Fact] + public async Task Removing_A_Key_That_Is_Not_There_Is_Not_An_Error() + { + var (cache, _) = New(); + + var act = () => cache.RemoveAsync(Key()); + + await act.Should().NotThrowAsync(); + } + + // ---- the key is the isolation boundary --------------------------------- + + [Fact] + public async Task One_Tenants_Value_Is_Not_Another_Tenants() + { + // There is no query filter in front of a dictionary. If this ever fails, + // the cache is a cross-tenant read. + var (cache, _) = New(); + + await cache.SetAsync(Key(Tenant), "mine"); + + (await cache.GetAsync(Key(OtherTenant))).Should().BeNull(); + } + + [Theory] + [InlineData("tenancy:settings")] + [InlineData("settings")] + public async Task Every_Entry_Point_Refuses_A_Key_Without_A_Tenant(string key) + { + // All four, not just one: a guard on Get that Set does not share is a + // guard a writer walks straight past. + var (cache, _) = New(); + + await ((Func)(() => cache.GetAsync(key))) + .Should().ThrowAsync(); + await ((Func)(() => cache.SetAsync(key, "v"))) + .Should().ThrowAsync(); + await ((Func)(() => cache.RemoveAsync(key))) + .Should().ThrowAsync(); + await ((Func)(() => cache.GetOrSetAsync(key, _ => Task.FromResult("v")))) + .Should().ThrowAsync(); + } + + // ---- expiry ------------------------------------------------------------ + + [Fact] + public async Task An_Entry_Expires_At_Its_Ttl() + { + var (cache, clock) = New(); + await cache.SetAsync(Key(), "value"); + + clock.Advance(InMemoryCacheService.DefaultTtl); + + (await cache.GetAsync(Key())).Should().BeNull(); + } + + [Fact] + public async Task An_Entry_Just_Inside_Its_Ttl_Is_Still_There() + { + var (cache, clock) = New(); + await cache.SetAsync(Key(), "value"); + + clock.Advance(InMemoryCacheService.DefaultTtl - TimeSpan.FromSeconds(1)); + + (await cache.GetAsync(Key())).Should().Be("value"); + } + + [Fact] + public async Task A_Caller_Supplied_Ttl_Wins_Over_The_Default() + { + var (cache, clock) = New(); + + await cache.SetAsync(Key(), "value", new CacheOptions(L1Ttl: TimeSpan.FromHours(1))); + clock.Advance(InMemoryCacheService.DefaultTtl * 2); + + (await cache.GetAsync(Key())).Should().Be("value", + "the default is what a caller gets when it names none, not a ceiling"); + } + + [Fact] + public async Task L2Ttl_Is_Carried_And_Ignored() + { + // There is no second layer here. The value exists so a caller written + // today does not change when the Valkey adapter gives it a meaning. + var (cache, clock) = New(); + + await cache.SetAsync(Key(), "value", new CacheOptions(L2Ttl: TimeSpan.FromHours(1))); + clock.Advance(InMemoryCacheService.DefaultTtl); + + (await cache.GetAsync(Key())).Should().BeNull(); + } + + // ---- GetOrSet ---------------------------------------------------------- + + [Fact] + public async Task GetOrSet_Produces_On_A_Miss_And_Stores_What_It_Produced() + { + var (cache, _) = New(); + + var produced = await cache.GetOrSetAsync(Key(), _ => Task.FromResult("made")); + + produced.Should().Be("made"); + (await cache.GetAsync(Key())).Should().Be("made"); + } + + [Fact] + public async Task GetOrSet_Does_Not_Produce_On_A_Hit() + { + var (cache, _) = New(); + await cache.SetAsync(Key(), "cached"); + + var calls = 0; + var value = await cache.GetOrSetAsync(Key(), _ => + { + Interlocked.Increment(ref calls); + return Task.FromResult("made"); + }); + + value.Should().Be("cached"); + calls.Should().Be(0); + } + + [Fact] + public async Task Concurrent_Misses_Run_The_Factory_Once() + { + // The factory is the expensive side — a database round trip, a Hub call. + // A cache that lets N simultaneous misses each run it turns a cold key + // into a stampede against the dependency it exists to spare. + var (cache, _) = New(); + var calls = 0; + using var gate = new SemaphoreSlim(0); + + async Task Factory(CancellationToken cancellationToken) + { + Interlocked.Increment(ref calls); + await gate.WaitAsync(TestTimeout, cancellationToken); + return "made"; + } + + var callers = Enumerable.Range(0, 8) + .Select(_ => cache.GetOrSetAsync(Key(), Factory)) + .ToArray(); + + gate.Release(8); + var results = await Task.WhenAll(callers); + + calls.Should().Be(1, "one flight per key, however many callers miss at once"); + results.Should().AllBe("made"); + } + + [Fact] + public async Task A_Failed_Flight_Does_Not_Poison_The_Key() + { + // The next caller must get a fresh attempt, not the cached exception. + var (cache, _) = New(); + + await ((Func)(() => cache.GetOrSetAsync( + Key(), _ => throw new InvalidOperationException("boom")))) + .Should().ThrowAsync(); + + var recovered = await cache.GetOrSetAsync(Key(), _ => Task.FromResult("second")); + + recovered.Should().Be("second"); + } + + // ---- bound ------------------------------------------------------------- + + [Fact] + public async Task The_Map_Is_Bounded() + { + // A cache with no bound is an out-of-memory condition waiting for a + // caller with an unbounded key space. Evicting a live entry is allowed + // here — a miss costs a round trip — which is what makes this bound + // simpler than the idempotency store's. + var (cache, clock) = New(); + + // The TTL must outlast the whole run, or the oldest entries expire and + // the assertion below passes for the wrong reason — measured: with a + // one-hour TTL and a one-second advance per entry, the run covers about + // three hours and `k000000` is gone because it EXPIRED. Deleting the + // bound then changed nothing. + var ttl = new CacheOptions(L1Ttl: TimeSpan.FromDays(30)); + + for (var i = 0; i <= InMemoryCacheService.MaxEntries + 500; i++) + { + await cache.SetAsync(CacheKey.For(Tenant, "tenancy", $"k{i:D6}"), i, ttl); + clock.Advance(InMemoryCacheService.SweepInterval); + } + + (await cache.GetAsync(CacheKey.For(Tenant, "tenancy", "k000000"))) + .Should().BeNull("the oldest entries are the ones the bound drops"); + + var newest = InMemoryCacheService.MaxEntries + 500; + (await cache.GetAsync(CacheKey.For(Tenant, "tenancy", $"k{newest:D6}"))) + .Should().Be(newest); + } + + private static readonly TimeSpan TestTimeout = TimeSpan.FromSeconds(10); + + private static (InMemoryCacheService Cache, FixedClock Clock) New() + { + var clock = new FixedClock(Origin); + return (new InMemoryCacheService(clock), clock); + } +} diff --git a/backend/tests/LearnStack.Tests.Unit/SharedKernel/Caching/CacheKeyTests.cs b/backend/tests/LearnStack.Tests.Unit/SharedKernel/Caching/CacheKeyTests.cs new file mode 100644 index 00000000..044567fb --- /dev/null +++ b/backend/tests/LearnStack.Tests.Unit/SharedKernel/Caching/CacheKeyTests.cs @@ -0,0 +1,88 @@ +using FluentAssertions; +using LearnStack.SharedKernel.Caching; +using Xunit; + +namespace LearnStack.Tests.Unit.SharedKernel.Caching; + +/// +/// The cache-key shape, per +/// Standards 20 +/// § Cache: {tenant_id}:{module}:{logical-name}. +/// +/// +/// There is no query filter and no RLS policy in front of a dictionary. The key is +/// the entire isolation boundary, so every rule about it is a tenant-isolation +/// rule wearing a string's clothes. +/// +public sealed class CacheKeyTests +{ + private static readonly Guid Tenant = Guid.Parse("018f4d40-0000-7000-8000-00000000000a"); + private static readonly Guid OtherTenant = Guid.Parse("018f4d40-0000-7000-8000-00000000000b"); + + [Fact] + public void A_Tenant_Key_Carries_All_Three_Segments() + { + CacheKey.For(Tenant, "tenancy", "settings") + .Should().Be($"{Tenant}:tenancy:settings"); + } + + [Fact] + public void A_Platform_Key_Uses_The_Sentinel_Rather_Than_Omitting_The_Segment() + { + // "No tenant" and "every tenant" must look different in a key dump, and + // the rule stays one rule. + CacheKey.ForPlatform("hub", "host-map") + .Should().Be("platform:hub:host-map"); + } + + [Fact] + public void Two_Tenants_Never_Compute_The_Same_Key() + { + CacheKey.For(Tenant, "tenancy", "settings") + .Should().NotBe(CacheKey.For(OtherTenant, "tenancy", "settings")); + } + + [Theory] + [InlineData("tenancy:settings", "two segments — no tenant")] + [InlineData("settings", "one segment")] + [InlineData(":tenancy:settings", "empty tenant segment")] + [InlineData("018f:tenancy:", "empty logical name")] + [InlineData("018f: :settings", "whitespace module")] + public void A_Key_Without_Three_Real_Segments_Is_Refused(string key, string why) + { + // A key that omits the tenant is a key two tenants can both compute, and + // the second one reads the first one's value. + var act = () => CacheKey.EnsureValid(key); + + act.Should().Throw(why); + } + + [Fact] + public void A_Well_Formed_Key_Passes() + { + var act = () => CacheKey.EnsureValid(CacheKey.For(Tenant, "tenancy", "settings")); + + act.Should().NotThrow(); + } + + [Fact] + public void A_Segment_Containing_The_Separator_Is_Refused() + { + // Otherwise ("a", "b:c") and ("a:b", "c") produce one key — the ambiguity + // a delimiter always has when a component can contain it. + var act = () => CacheKey.For(Tenant, "tenancy:nested", "settings"); + + act.Should().Throw(); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void An_Empty_Component_Is_Refused(string? component) + { + var act = () => CacheKey.For(Tenant, "tenancy", component!); + + act.Should().Throw(); + } +} From c65e370657f1d76741f3fbb9972aa5de6fa4b47a Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Mon, 24 Aug 2026 20:03:58 +0300 Subject: [PATCH 03/21] fix(infra): apply .leakwatchignore where the scanner cannot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Committing a change to `docs/decisions/0022-custom-domain-tls.md` was blocked by the pre-commit secret scan, on two illustrative PEM blocks whose body is literally `...`, in a file `.leakwatchignore` has excluded since it was written. Measured on leakwatch v1.8.0: the ignore file is resolved relative to the scan TARGET. `leakwatch scan fs .` from the repo root honours it — a tracked-files-only checkout scans clean, 0 findings, which is why CI has been green. `leakwatch scan fs ` does not, and neither does passing `--exclude` alongside a named file target. The hook scans file by file, on purpose, so it could never honour the ignore file at all. So the two invocations disagreed: seven paths were unscannable locally and clean in CI, and the hook's own remediation text told the developer to "extend .leakwatchignore" — advice that could not have worked. The header comment claiming the config "applies to both invocations" was false for the invocation it was written above. `.leakwatchignore` is documented as gitignore syntax, so git is the correct matcher for it: `git -c core.excludesFile=.leakwatchignore check-ignore --no-index` classifies each staged path before it reaches the scanner. Verified against all seven ignored paths and a control set that must still be scanned. Second defect, same family — the config saying one thing and matching another. `.leakwatch.yaml` excluded `node_modules/**`, which anchors at the repo root; this repo's are at `frontend/node_modules/…`, so the exclusion never fired. A local root scan reported eight CRITICAL findings from a dependency's README. With the `**/` prefix: 19 findings to 10, 13,881 files walked to 496, 2.81s to 160ms. The 10 that remain are both developer-local `.env` files, correctly flagged. They are gitignored, so CI never sees them and the hook never scans them, and they are deliberately NOT excluded: `.env` is the file most likely to hold a real secret, and blinding the scanner there to quiet a local run would trade the tool's purpose for its tidiness. Co-Authored-By: Claude Opus 5 (1M context) --- .githooks/pre-commit | 22 +++++++++++++++++++++- .leakwatch.yaml | 6 +++++- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/.githooks/pre-commit b/.githooks/pre-commit index d15e6698..6242d487 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -109,6 +109,20 @@ restage() { git add -- "$@"; } # (the alternative — `leakwatch scan fs .` — walks the entire tree). # Iterating costs one CLI invocation per file but each one is fast # (Aho-Corasick pre-filter); for small commits this is sub-second. +# +# `.leakwatchignore` is applied HERE, by us, not by the scanner. Measured on +# v1.8.0: leakwatch resolves the ignore file relative to the scan TARGET, so a +# root scan (`scan fs .`, what CI runs) honours it and a per-file scan (what +# this hook runs) does not — and `--exclude` does not apply to an explicitly +# named file target either. The result was a hook that blocked every commit +# touching one of the seven intentionally-ignored files while CI went green on +# the same tree, and whose own remediation message told the developer to extend +# a file that could not have helped. `.leakwatchignore` is documented as +# gitignore syntax, so git itself is the correct matcher for it. +leakwatch_path_ignored() { + git -c core.excludesFile=.leakwatchignore \ + check-ignore --no-index -q -- "$1" 2>/dev/null +} # Older builds only accept a DIRECTORY target. CI pins v1.5.0, which rejects a # file with "source validation failed: source is not a directory" — so a @@ -127,6 +141,7 @@ leakwatch_takes_files() { for probe in "${all_staged[@]}"; do [[ -f "$probe" ]] || continue + leakwatch_path_ignored "$probe" && continue if leakwatch scan fs "$probe" --config .leakwatch.yaml \ --min-severity medium --no-verify >/dev/null 2>&1; then return 0 @@ -157,6 +172,9 @@ elif command -v leakwatch >/dev/null 2>&1; then for f in "${all_staged[@]}"; do # Skip files that don't exist (D for delete in --diff-filter). [[ -f "$f" ]] || continue + # Path-level ignores, which the scanner cannot apply to a file + # target — see the note above `leakwatch_path_ignored`. + leakwatch_path_ignored "$f" && continue # Capture stdout+stderr; on failure replay the scanner output to # the developer (suppressing it would leave them guessing which # detector fired). Exit code drives the gate; output drives the @@ -165,7 +183,9 @@ elif command -v leakwatch >/dev/null 2>&1; then printf "\npre-commit: leakwatch found a likely secret in %s\n\n" "$f" >&2 printf "%s\n\n" "$scan_output" >&2 printf "If it is a legitimate dev credential, add an inline\n" >&2 - printf "\`# leakwatch:ignore\` comment or extend .leakwatchignore.\n" >&2 + printf "\`# leakwatch:ignore\` comment (preferred — it disables one\n" >&2 + printf "detector on one line), or add the path to .leakwatchignore\n" >&2 + printf "with a comment saying why.\n" >&2 exit 1 fi done diff --git a/.leakwatch.yaml b/.leakwatch.yaml index 29142b85..6eddfdab 100644 --- a/.leakwatch.yaml +++ b/.leakwatch.yaml @@ -46,7 +46,11 @@ verification: filter: exclude-paths: # Build / generated artifacts (do not commit, but defense-in-depth): - - "node_modules/**" + # `**/` prefixed on purpose: a bare `node_modules/**` anchors at the repo + # root and does not match `frontend/node_modules/…`, which is where this + # repo's actually are. Measured: eight CRITICAL findings from a dependency's + # README survived the exclusion until the prefix was added. + - "**/node_modules/**" - "**/dist/**" - "**/build/**" - "**/.next/**" From f97212261355aaed2135b0d7f9177bb63c2fb054 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Mon, 24 Aug 2026 20:04:22 +0300 Subject: [PATCH 04/21] fix(kernel): make the cache bound, the key guard and the flight hold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A review round found four defects in the cache port shipped by 5601602. Each was reproduced by measurement before being fixed, and each now has a test that fails when the production code it covers is removed. **The bound was not a bound.** Trimming lived inside the sweep, the sweep is throttled by clock time, and a burst does not advance the clock. Measured: 60,000 entries against a ceiling of 10,000. Trimming moves to every write that adds a key — only a write that grows the map can cross the ceiling, so TryAdd distinguishes the two. Re-measured: exactly 10,000. The test that "covered" this advanced the clock one second per write, which is the one schedule under which the old code held; it now runs against a frozen clock and asserts a count, because the ceiling is a count and inferring it from which keys happen to survive is how the first version came to agree with a broken bound. Eviction orders by an insertion sequence rather than by WrittenAt, for the same reason: a burst shares one instant, so "oldest first" silently became "an arbitrary one first" whenever the clock was frozen or coarse. **A cancelling caller killed the other callers.** The shared flight ran on the winning caller's token, so one client pressing refresh cancelled the factory and every request waiting on that key died with it — as a 499, which this host treats as "the client hung up", so it writes no body, captures no error and records no span. A request that did nothing wrong failed invisibly. The flight now runs on CancellationToken.None and each caller observes its own token while waiting, so a joiner can also abandon a slow flight without ending it for everyone else. **A flight resurrected what it had superseded.** A Remove or a Set landing while a factory ran was overwritten by that factory's result — eager invalidation lost for a full TTL, which is the one thing a cache must not do quietly. A per-key version counter is read before the factory runs, and the store is skipped if it moved. **The key guard validated arity, not tenancy.** `hub:entitlement:{id}` has three non-empty segments and puts the module first, so it passed a check whose own error message says the tenant segment is mandatory — a guard that admits the shape it exists to reject is worse than none, because it makes the rule look enforced. It now requires the first segment to be a tenant id or the platform sentinel. Adding to that: CacheKey.ForOrganization. Organizations are a scope in their own right (ADR-0017), so a roster cached as `{tenant}:education:roster` is a key two organizations of one tenant both compute — the same defect one level down, and one EnsureValid cannot catch, since an organization-scoped value and a tenant-wide one are indistinguishable as strings. The composition is what prevents it. Assertion_Budget_Does_Not_Depend_On_ICacheService shipped in Packet 4 as a tripwire because the type did not exist. It exists now, so the rule becomes the dependency check the catalogue promised, keeping the source scan alongside it: reflection catches an injected dependency, the scan catches a service-locator resolve, and neither sees the other's case. Corpus — all contradiction rather than omission: - architecture/29 still published RemoveByPrefixAsync, CacheOptions.Tags, a generic PublishAsync and a namespace that does not exist, and its DaprCacheService re-prefixed keys the caller had already composed, which would have emitted {tenant}:{tenant}:{module}:{name}. Its InProcessEventBus paragraph described MediatR INotificationHandler dispatch, contradicting both architecture/15 and ADR-0035. - Standards 20's cheat sheet listed five key families and every one of them led with the module, contradicting the tenant-first rule stated a few lines above it; the guard now rejects all five spellings. The host lookup keeps the platform sentinel, and the table says why: it answers "which tenant is this?", so by construction it has none. - 29 also overstated the topic's death. learnstack.cache.invalidation survives for single-key eviction, which is enumerable by construction; what died is invalidating a set the caller cannot enumerate. Phase 11 owns it. - architecture/24 and ADR-0022 carried superseded spellings. The first is corrected; the second is marked in place with a pointer, per the precedent ADR-0003 Amendment 3 set for an Accepted record. 609 tests green, 0 warnings under CI=true. Co-Authored-By: Claude Opus 5 (1M context) --- .../Caching/InMemoryCacheService.cs | 148 ++++++++++--- .../Idempotency/InMemoryIdempotencyStore.cs | 8 +- .../Caching/CacheKey.cs | 66 +++++- .../TenancyConventionTests.cs | 55 ++++- .../Caching/InMemoryCacheServiceTests.cs | 202 ++++++++++++++++-- .../SharedKernel/Caching/CacheKeyTests.cs | 87 ++++++++ docs/architecture/24-learnstack-hub.md | 3 +- docs/architecture/29-dapr-integration.md | 138 ++++++------ docs/decisions/0022-custom-domain-tls.md | 9 + docs/standards/20-infrastructure-stack.md | 39 +++- .../21-architecture-tests-catalogue.md | 4 +- 11 files changed, 610 insertions(+), 149 deletions(-) diff --git a/backend/src/LearnStack.Infrastructure/Caching/InMemoryCacheService.cs b/backend/src/LearnStack.Infrastructure/Caching/InMemoryCacheService.cs index 680b79a5..dde79d54 100644 --- a/backend/src/LearnStack.Infrastructure/Caching/InMemoryCacheService.cs +++ b/backend/src/LearnStack.Infrastructure/Caching/InMemoryCacheService.cs @@ -47,6 +47,16 @@ public sealed class InMemoryCacheService(IClock clock) : ICacheService /// The most entries held at once. A cache with no bound is an /// out-of-memory condition waiting for a caller with an unbounded key space. /// + /// + /// Enforced on every write that adds a key, not inside the throttled + /// sweep. Measured on the first version, which trimmed only during a sweep: + /// a burst of 60,000 writes inside one left + /// 60,000 entries against this ceiling of 10,000, because the sweep is + /// throttled by clock time and a burst does not advance the clock. The test + /// that covered the bound advanced the clock one second per write, which is + /// the one schedule under which the old code held — a guard and a test that + /// agreed with each other and not with reality. + /// public const int MaxEntries = 10_000; private readonly ConcurrentDictionary _entries = new(StringComparer.Ordinal); @@ -57,8 +67,39 @@ public sealed class InMemoryCacheService(IClock clock) : ICacheService private readonly ConcurrentDictionary>> _inFlight = new(StringComparer.Ordinal); + /// + /// Per-key write counter. A flight reads it + /// before running its factory and refuses to store a result the caller has + /// since superseded. + /// + private readonly ConcurrentDictionary _versions = new(StringComparer.Ordinal); + private long _lastSweepTicks; + /// + /// Monotonic insertion counter. Eviction orders by this rather than by + /// WrittenAt, because a burst shares one instant: with a frozen or + /// coarse clock every entry carries the same timestamp and "oldest first" + /// silently becomes "an arbitrary one first". + /// + private long _sequence; + + /// + /// How many entries the map currently holds, expired-but-unreclaimed ones + /// included. + /// + /// + /// A diagnostic on this class, deliberately not on + /// : a caller that branches on the size of a cache + /// has made a component whose contract is "sometimes" into one it depends on. + /// It exists so the two bounds this class claims — the ceiling and the + /// reclamation of expired entries — can be asserted directly rather than + /// inferred from which keys happen to survive an eviction. The first version + /// of the bound test inferred, and agreed with a ceiling that was holding + /// 60,000 entries against 10,000. + /// + public int Count => _entries.Count; + public Task GetAsync(string key, CancellationToken cancellationToken = default) { CacheKey.EnsureValid(key); @@ -91,20 +132,42 @@ public async Task GetOrSetAsync( return (T)hit.Value!; } + // The version is read BEFORE the factory runs. If a Set or a Remove + // lands while it is running, storing the result would resurrect a value + // the caller already replaced or deleted — eager invalidation silently + // lost for a full TTL, which is the one thing a cache must not do + // quietly. + var versionAtStart = VersionOf(key); + // Lazy with ExecutionAndPublication, not a bare GetOrAdd: the value // factory of a ConcurrentDictionary may run more than once under // contention, and running the caller's factory twice is the stampede // this method exists to prevent. + // + // The flight runs on CancellationToken.None, NOT on this caller's + // token. Measured: with the caller's token, one client pressing refresh + // cancelled the shared factory and every other request waiting on that + // key died with it — as a 499, which this host treats as "the client + // hung up" and therefore writes no body, captures no error and records + // no span. A request that did nothing wrong failed invisibly. var flight = _inFlight.GetOrAdd( key, _ => new Lazy>( - async () => await factory(cancellationToken).ConfigureAwait(false), + async () => await factory(CancellationToken.None).ConfigureAwait(false), LazyThreadSafetyMode.ExecutionAndPublication)); try { - var produced = (T)(await flight.Value.ConfigureAwait(false))!; - Store(key, produced, options, clock.UtcNow); + // Each caller observes its OWN token while waiting, so a joiner can + // abandon a slow flight without affecting the others. + var produced = (T)(await flight.Value.WaitAsync(cancellationToken) + .ConfigureAwait(false))!; + + if (VersionOf(key) == versionAtStart) + { + Store(key, produced, options, clock.UtcNow); + } + return produced; } finally @@ -125,6 +188,7 @@ public Task SetAsync( var now = clock.UtcNow; Sweep(now); + Bump(key); Store(key, value, options, now); return Task.CompletedTask; @@ -134,18 +198,70 @@ public Task RemoveAsync(string key, CancellationToken cancellationToken = defaul { CacheKey.EnsureValid(key); + Bump(key); _entries.TryRemove(key, out _); return Task.CompletedTask; } + private long VersionOf(string key) => _versions.TryGetValue(key, out var v) ? v : 0; + + private void Bump(string key) => _versions.AddOrUpdate(key, 1, (_, v) => v + 1); + private void Store(string key, T value, CacheOptions? options, DateTimeOffset now) { // L2Ttl is read and ignored: there is no second layer here. Carrying it // means a caller written today does not change when the Valkey adapter // gives it a meaning. var ttl = options?.L1Ttl ?? DefaultTtl; + var entry = new Entry(value, now + ttl, Interlocked.Increment(ref _sequence)); + + // TryAdd first, so a write that GROWS the map is distinguishable from + // one that replaces an entry. Only the former can cross the ceiling, so + // only the former pays for checking it. + if (!_entries.TryAdd(key, entry)) + { + _entries[key] = entry; + return; + } - _entries[key] = new Entry(value, now + ttl, now); + if (_entries.Count > MaxEntries) + { + Trim(now); + } + } + + /// + /// Evicts down to , expired entries first and then + /// the oldest live ones. + /// + /// + /// Evicting a live entry is allowed here — a miss costs a round trip — which + /// is what makes this bound simpler than InMemoryIdempotencyStore's, + /// where an entry is a promise and eviction would let an operation run twice. + /// + private void Trim(DateTimeOffset now) + { + foreach (var pair in _entries) + { + if (!pair.Value.IsFresh(now)) + { + // Value-comparing: between the enumerator observing an expired + // entry and this line, another thread may have written a fresh + // one at the same key. + _entries.TryRemove(pair); + } + } + + var excess = _entries.Count - MaxEntries; + if (excess <= 0) + { + return; + } + + foreach (var pair in _entries.OrderBy(pair => pair.Value.Sequence).Take(excess)) + { + _entries.TryRemove(pair); + } } /// @@ -169,39 +285,19 @@ private void Sweep(DateTimeOffset now) return; } - var live = 0; - foreach (var pair in _entries) { if (pair.Value.IsFresh(now)) { - live++; continue; } - // Value-comparing: between the enumerator observing an expired entry - // and this line, another thread may have written a fresh one at the - // same key, and removing by key alone would drop that instead. - _entries.TryRemove(pair); - } - - if (live <= MaxEntries) - { - return; - } - - // Oldest first. Evicting a live entry is allowed here — a miss costs a - // round trip — which is exactly what makes this bound simpler than the - // idempotency store's. - foreach (var pair in _entries - .OrderBy(pair => pair.Value.WrittenAt) - .Take(live - MaxEntries)) - { + // Value-comparing, for the same reason Trim's pass is. _entries.TryRemove(pair); } } - private sealed record Entry(object? Value, DateTimeOffset ExpiresAt, DateTimeOffset WrittenAt) + private sealed record Entry(object? Value, DateTimeOffset ExpiresAt, long Sequence) { public bool IsFresh(DateTimeOffset now) => now < ExpiresAt; } diff --git a/backend/src/LearnStack.Infrastructure/Idempotency/InMemoryIdempotencyStore.cs b/backend/src/LearnStack.Infrastructure/Idempotency/InMemoryIdempotencyStore.cs index b693957b..cf2ea9b8 100644 --- a/backend/src/LearnStack.Infrastructure/Idempotency/InMemoryIdempotencyStore.cs +++ b/backend/src/LearnStack.Infrastructure/Idempotency/InMemoryIdempotencyStore.cs @@ -23,9 +23,11 @@ namespace LearnStack.Infrastructure.Idempotency; /// /// /// The same limitation is why ICacheService exists as a port and why -/// RemoveByPrefixAsync is being removed from it: an instance-local -/// structure cannot honour a contract phrased as if it were shared. Saying so -/// here keeps the next reader from mistaking this for a finished component. +/// RemoveByPrefixAsync was removed from it in +/// ADR-0014 Amendment 2: +/// an instance-local structure cannot honour a contract phrased as if it were +/// shared. Saying so here keeps the next reader from mistaking this for a +/// finished component. /// /// public sealed class InMemoryIdempotencyStore(IClock clock) : IIdempotencyStore diff --git a/backend/src/LearnStack.SharedKernel/Caching/CacheKey.cs b/backend/src/LearnStack.SharedKernel/Caching/CacheKey.cs index c65790d3..2236347d 100644 --- a/backend/src/LearnStack.SharedKernel/Caching/CacheKey.cs +++ b/backend/src/LearnStack.SharedKernel/Caching/CacheKey.cs @@ -3,7 +3,9 @@ namespace LearnStack.SharedKernel.Caching; /// /// Builds and validates the one cache-key shape /// Standards 20 -/// § Cache admits: {tenant_id}:{module}:{logical-name}. +/// § Cache admits: {tenant_id}:{module}:{logical-name}, or +/// {tenant_id}:{organization_id}:{module}:{logical-name} for a value scoped +/// to one organization. /// /// /// @@ -32,6 +34,24 @@ public static class CacheKey public static string For(Guid tenantId, string module, string logicalName) => For(tenantId.ToString(), module, logicalName); + /// + /// Composes a key for a value scoped to one organization within a tenant: + /// {tenant_id}:{organization_id}:{module}:{logical-name}. + /// + /// + /// The same argument as the tenant segment, one level down. Organizations are + /// a scope in their own right + /// (ADR-0017), + /// so a roster cached as {tenant}:education:roster is a key two + /// organizations of one tenant both compute. cannot + /// catch that — an organization-scoped value and a tenant-wide one are + /// indistinguishable as strings — which is exactly why the composition exists + /// rather than being left to each call site to spell. + /// + public static string ForOrganization( + Guid tenantId, Guid organizationId, string module, string logicalName) => + For(tenantId.ToString(), organizationId.ToString(), module, logicalName); + /// Composes a key for a platform-wide value. public static string ForPlatform(string module, string logicalName) => For(PlatformTenant, module, logicalName); @@ -50,7 +70,11 @@ public static void EnsureValid(string key) ArgumentException.ThrowIfNullOrWhiteSpace(key); var segments = key.Split(Separator); - if (segments.Length < 3 || segments.Any(string.IsNullOrWhiteSpace)) + var wellFormed = segments.Length >= 3 + && !segments.Any(string.IsNullOrWhiteSpace) + && IsTenantSegment(segments[0]); + + if (!wellFormed) { throw new ArgumentException( $"'{key}' is not a cache key. Standards 20 fixes the shape as " @@ -62,16 +86,36 @@ public static void EnsureValid(string key) } } - private static string For(string tenant, string module, string logicalName) + /// + /// Whether the first segment is a tenant identifier or the platform sentinel. + /// + /// + /// Counting segments is not enough, and the first version of this guard did + /// only that: hub:entitlement:{tenant_id} has three segments and puts + /// the module first, so it passed a check whose own error message says the + /// tenant segment is mandatory. A guard that admits the shape it exists to + /// reject is worse than none — it makes the rule look enforced. + /// + private static bool IsTenantSegment(string segment) => + segment.Equals(PlatformTenant, StringComparison.Ordinal) || Guid.TryParse(segment, out _); + + private static string For(string tenant, string module, string logicalName) => + Compose([tenant, module, logicalName]); + + private static string For(string tenant, string org, string module, string logicalName) => + Compose([tenant, org, module, logicalName]); + + private static string Compose(string[] segments) { - ArgumentException.ThrowIfNullOrWhiteSpace(tenant); - ArgumentException.ThrowIfNullOrWhiteSpace(module); - ArgumentException.ThrowIfNullOrWhiteSpace(logicalName); + foreach (var segment in segments) + { + ArgumentException.ThrowIfNullOrWhiteSpace(segment); + } - // A separator inside a segment would let two different (tenant, module, - // name) triples produce the same key — the ambiguity a delimiter always - // has when a component can contain it. - foreach (var segment in (string[])[tenant, module, logicalName]) + // A separator inside a segment would let two different segment tuples + // produce the same key — the ambiguity a delimiter always has when a + // component can contain it. + foreach (var segment in segments) { if (segment.Contains(Separator, StringComparison.Ordinal)) { @@ -80,6 +124,6 @@ private static string For(string tenant, string module, string logicalName) } } - return $"{tenant}{Separator}{module}{Separator}{logicalName}"; + return string.Join(Separator, segments); } } diff --git a/backend/tests/LearnStack.Tests.Architecture/TenancyConventionTests.cs b/backend/tests/LearnStack.Tests.Architecture/TenancyConventionTests.cs index 802c2541..423d15c8 100644 --- a/backend/tests/LearnStack.Tests.Architecture/TenancyConventionTests.cs +++ b/backend/tests/LearnStack.Tests.Architecture/TenancyConventionTests.cs @@ -1,3 +1,4 @@ +using System.Reflection; using FluentAssertions; using Xunit; @@ -12,12 +13,14 @@ namespace LearnStack.Tests.Architecture; /// /// /// -/// These are source scans, and that is a deliberate choice rather than a -/// shortcut. Each rule is about a symbol not appearing outside one file — a -/// reflection or NetArchTest form would have to observe a call that has no +/// Most of these are source scans, and that is a deliberate choice rather +/// than a shortcut. Each rule is about a symbol not appearing outside one file — +/// a reflection or NetArchTest form would have to observe a call that has no /// consumer yet, because the resolver that will read these values does not land /// until Packet 7. A scan can hold the line from the day the symbol exists, -/// which is the day it can first be used wrongly. +/// which is the day it can first be used wrongly. Where the type a rule names +/// now exists, the rule adds a reflection check alongside the scan rather than +/// replacing it: the two catch different mistakes. /// /// /// Comment lines are skipped. Every one of these files argues in prose about the @@ -76,18 +79,48 @@ public void Assertion_Recorder_Is_The_Only_Mismatch_Writer() [Fact] public void Assertion_Budget_Does_Not_Depend_On_ICacheService() { - // A tripwire, like Forwarded_Headers_Are_Not_Wired. ICacheService does - // not exist yet — Packet 5 ships the port — so this cannot yet be a - // dependency check. It holds the line from now, because the anonymous - // burst counter is exactly the thing someone will reach for a cache to - // share across instances, and a cache outage must not decide whether a - // MUST-class security event is recorded. + // The anonymous burst counter is exactly the thing someone reaches for a + // cache to share across instances, and a cache outage must not decide + // whether a MUST-class security event is recorded. + // + // This began as a tripwire because ICacheService did not exist. Packet 5 + // ships it, so the rule is now what the catalogue promised: a real + // dependency check as well as a text scan. Both are kept — reflection + // catches an injected dependency, the scan catches a service-locator + // resolve, and neither sees the other's case. + Injectors().Should().BeEmpty( + "no type under Tenancy takes an ICacheService " + + "(ADR-0036 § Recording a rejected assertion)"); + Offenders(except: null, banned: ["ICacheService"], folder: "Tenancy") .Should().BeEmpty( - "the anonymous-burst counters resolve no ICacheService " + "and none resolves one by name either " + "(ADR-0036 § Recording a rejected assertion)"); } + /// + /// Types in the LearnStack.Api.Tenancy namespace that take an + /// as a + /// constructor parameter or hold one in a field. + /// + private static List Injectors() + { + var cache = typeof(LearnStack.SharedKernel.Caching.ICacheService); + + return typeof(LearnStack.Api.Versioning.ApiVersioningExtensions).Assembly + .GetTypes() + .Where(type => type.Namespace?.StartsWith( + "LearnStack.Api.Tenancy", StringComparison.Ordinal) == true) + .Where(type => + type.GetConstructors().Any(constructor => + constructor.GetParameters().Any(p => cache.IsAssignableFrom(p.ParameterType))) + || type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic + | BindingFlags.Public) + .Any(field => cache.IsAssignableFrom(field.FieldType))) + .Select(type => type.FullName!) + .ToList(); + } + /// /// Files under LearnStack.Api that mention a banned literal in code. /// diff --git a/backend/tests/LearnStack.Tests.Unit/Infrastructure/Caching/InMemoryCacheServiceTests.cs b/backend/tests/LearnStack.Tests.Unit/Infrastructure/Caching/InMemoryCacheServiceTests.cs index c78560e1..e7f317af 100644 --- a/backend/tests/LearnStack.Tests.Unit/Infrastructure/Caching/InMemoryCacheServiceTests.cs +++ b/backend/tests/LearnStack.Tests.Unit/Infrastructure/Caching/InMemoryCacheServiceTests.cs @@ -222,33 +222,207 @@ public async Task A_Failed_Flight_Does_Not_Poison_The_Key() // ---- bound ------------------------------------------------------------- [Fact] - public async Task The_Map_Is_Bounded() + public async Task The_Map_Is_Bounded_Even_When_The_Clock_Never_Moves() { - // A cache with no bound is an out-of-memory condition waiting for a - // caller with an unbounded key space. Evicting a live entry is allowed - // here — a miss costs a round trip — which is what makes this bound - // simpler than the idempotency store's. - var (cache, clock) = New(); - - // The TTL must outlast the whole run, or the oldest entries expire and - // the assertion below passes for the wrong reason — measured: with a - // one-hour TTL and a one-second advance per entry, the run covers about - // three hours and `k000000` is gone because it EXPIRED. Deleting the - // bound then changed nothing. + // The clock is FROZEN, which is the case the first version got wrong: the + // bound was enforced only inside a sweep, the sweep is throttled by clock + // time, and a burst does not advance the clock. Measured then: 60,000 + // entries against a ceiling of 10,000. The test that "covered" the bound + // advanced the clock one second per write — the one schedule under which + // the old code held. + var (cache, _) = New(); var ttl = new CacheOptions(L1Ttl: TimeSpan.FromDays(30)); for (var i = 0; i <= InMemoryCacheService.MaxEntries + 500; i++) { await cache.SetAsync(CacheKey.For(Tenant, "tenancy", $"k{i:D6}"), i, ttl); - clock.Advance(InMemoryCacheService.SweepInterval); } + cache.Count.Should().BeLessThanOrEqualTo(InMemoryCacheService.MaxEntries, + "the ceiling is a count, so that is what the test asserts"); + (await cache.GetAsync(CacheKey.For(Tenant, "tenancy", "k000000"))) .Should().BeNull("the oldest entries are the ones the bound drops"); var newest = InMemoryCacheService.MaxEntries + 500; (await cache.GetAsync(CacheKey.For(Tenant, "tenancy", $"k{newest:D6}"))) - .Should().Be(newest); + .Should().Be(newest, "the newest write is never the one evicted"); + } + + [Fact] + public async Task Replacing_A_Key_Does_Not_Grow_The_Map() + { + // Only a write that ADDS a key can cross the ceiling, which is why the + // bound is checked on TryAdd rather than on every write. + var (cache, _) = New(); + var ttl = new CacheOptions(L1Ttl: TimeSpan.FromDays(30)); + + for (var i = 0; i < InMemoryCacheService.MaxEntries * 2; i++) + { + await cache.SetAsync(Key(), i, ttl); + } + + (await cache.GetAsync(Key())).Should().Be((InMemoryCacheService.MaxEntries * 2) - 1); + } + + // ---- cancellation is not contagious ------------------------------------- + + [Fact] + public async Task One_Callers_Cancellation_Does_Not_Fail_The_Others() + { + // The first version handed the shared flight the winning caller's token. + // Measured: one client pressing refresh cancelled the factory and every + // other request waiting on that key died with it — as a 499, which this + // host treats as "the client hung up", so it writes no body, captures no + // error and records no span. A request that did nothing wrong failed + // invisibly. + var (cache, _) = New(); + using var release = new SemaphoreSlim(0); + using var leaving = new CancellationTokenSource(); + + var factoryEntered = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + + async Task Factory(CancellationToken cancellationToken) + { + factoryEntered.TrySetResult(); + await release.WaitAsync(TestTimeout, cancellationToken); + return "made"; + } + + var leaves = cache.GetOrSetAsync(Key(), Factory, null, leaving.Token); + await factoryEntered.Task.WaitAsync(TestTimeout); + var stays = cache.GetOrSetAsync(Key(), Factory, null, CancellationToken.None); + + await leaving.CancelAsync(); + await ((Func)(() => leaves)).Should().ThrowAsync(); + + release.Release(2); + + (await stays).Should().Be("made", + "the caller that stayed connected asked for nothing that failed"); + } + + [Fact] + public async Task A_Joiner_Can_Abandon_A_Slow_Flight() + { + // The mirror image: before, a joiner awaited the shared task directly and + // could not leave until the winner's factory finished. + var (cache, _) = New(); + using var release = new SemaphoreSlim(0); + using var impatient = new CancellationTokenSource(); + var factoryEntered = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + + async Task Factory(CancellationToken cancellationToken) + { + factoryEntered.TrySetResult(); + await release.WaitAsync(TestTimeout, cancellationToken); + return "made"; + } + + var winner = cache.GetOrSetAsync(Key(), Factory, null, CancellationToken.None); + await factoryEntered.Task.WaitAsync(TestTimeout); + var joiner = cache.GetOrSetAsync(Key(), Factory, null, impatient.Token); + + await impatient.CancelAsync(); + + await ((Func)(() => joiner)).Should().ThrowAsync(); + + release.Release(); + (await winner).Should().Be("made", "the flight itself was never cancelled"); + } + + // ---- an invalidation during a flight is not undone ---------------------- + + [Fact] + public async Task A_Remove_During_A_Flight_Is_Not_Resurrected_By_It() + { + // Eager invalidation must not be silently lost for a full TTL because it + // happened to land while a factory was running. + var (cache, _) = New(); + using var release = new SemaphoreSlim(0); + var factoryEntered = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + + var flight = cache.GetOrSetAsync(Key(), async _ => + { + factoryEntered.TrySetResult(); + await release.WaitAsync(TestTimeout, CancellationToken.None); + return "stale"; + }); + + await factoryEntered.Task.WaitAsync(TestTimeout); + await cache.RemoveAsync(Key()); + release.Release(); + + (await flight).Should().Be("stale", "the caller still gets what it asked for"); + (await cache.GetAsync(Key())).Should().BeNull( + "but the value it produced is not written over the invalidation"); + } + + [Fact] + public async Task A_Set_During_A_Flight_Wins_Over_It() + { + var (cache, _) = New(); + using var release = new SemaphoreSlim(0); + var factoryEntered = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + + var flight = cache.GetOrSetAsync(Key(), async _ => + { + factoryEntered.TrySetResult(); + await release.WaitAsync(TestTimeout, CancellationToken.None); + return "from-factory"; + }); + + await factoryEntered.Task.WaitAsync(TestTimeout); + await cache.SetAsync(Key(), "explicit"); + release.Release(); + + await flight; + + (await cache.GetAsync(Key())).Should().Be("explicit", + "the newer write is the one that stands"); + } + + [Fact] + public async Task Expired_Entries_Are_Reclaimed_Without_Waiting_For_The_Ceiling() + { + // Expired entries are never READ — IsFresh guards both read paths — so + // failing to reclaim them is invisible in every value the cache returns. + // It is still a leak: without this, a workload whose keys all expire holds + // every one of them until the map crosses MaxEntries, which for a small + // key space is never. + var (cache, clock) = New(); + for (var i = 0; i < 200; i++) + { + await cache.SetAsync(CacheKey.For(Tenant, "tenancy", $"k{i}"), i); + } + + cache.Count.Should().Be(200); + + clock.Advance(InMemoryCacheService.DefaultTtl + InMemoryCacheService.SweepInterval); + await cache.GetAsync(CacheKey.For(Tenant, "tenancy", "trigger")); + + cache.Count.Should().Be(0, "a sweep reclaims what expired, bound or no bound"); + } + + // ---- the TTL boundary --------------------------------------------------- + + [Fact] + public async Task An_Entry_Is_Gone_At_Exactly_Its_Expiry_Instant() + { + // `now < ExpiresAt`, not `<=`. The old "just inside its TTL" case moved a + // whole second short of the boundary and would have passed either way. + var (cache, clock) = New(); + await cache.SetAsync(Key(), "value"); + + clock.Advance(InMemoryCacheService.DefaultTtl - TimeSpan.FromTicks(1)); + (await cache.GetAsync(Key())).Should().Be("value", "one tick before expiry"); + + clock.Advance(TimeSpan.FromTicks(1)); + (await cache.GetAsync(Key())).Should().BeNull("at the expiry instant"); } private static readonly TimeSpan TestTimeout = TimeSpan.FromSeconds(10); diff --git a/backend/tests/LearnStack.Tests.Unit/SharedKernel/Caching/CacheKeyTests.cs b/backend/tests/LearnStack.Tests.Unit/SharedKernel/Caching/CacheKeyTests.cs index 044567fb..bdfde949 100644 --- a/backend/tests/LearnStack.Tests.Unit/SharedKernel/Caching/CacheKeyTests.cs +++ b/backend/tests/LearnStack.Tests.Unit/SharedKernel/Caching/CacheKeyTests.cs @@ -18,6 +18,8 @@ public sealed class CacheKeyTests { private static readonly Guid Tenant = Guid.Parse("018f4d40-0000-7000-8000-00000000000a"); private static readonly Guid OtherTenant = Guid.Parse("018f4d40-0000-7000-8000-00000000000b"); + private static readonly Guid Org = Guid.Parse("018f4d40-0000-7000-8000-0000000000c1"); + private static readonly Guid OtherOrg = Guid.Parse("018f4d40-0000-7000-8000-0000000000c2"); [Fact] public void A_Tenant_Key_Carries_All_Three_Segments() @@ -35,6 +37,35 @@ public void A_Platform_Key_Uses_The_Sentinel_Rather_Than_Omitting_The_Segment() .Should().Be("platform:hub:host-map"); } + [Fact] + public void Two_Organizations_Of_One_Tenant_Never_Compute_The_Same_Key() + { + // The tenant guard cannot catch this one — an organization-scoped value + // and a tenant-wide one are indistinguishable as strings — so the + // composition is what prevents it. + CacheKey.ForOrganization(Tenant, Org, "education", "roster") + .Should().NotBe(CacheKey.ForOrganization(Tenant, OtherOrg, "education", "roster")) + .And.Be($"{Tenant}:{Org}:education:roster"); + } + + [Fact] + public void An_Organization_Key_Is_Well_Formed() + { + var act = () => CacheKey.EnsureValid( + CacheKey.ForOrganization(Tenant, Org, "education", "roster")); + + act.Should().NotThrow(); + } + + [Fact] + public void An_Organization_Key_Still_Leads_With_The_Tenant() + { + // Not the organization: the tenant is the outer boundary, so it is the + // segment a key dump must sort by. + CacheKey.ForOrganization(Tenant, Org, "education", "roster") + .Should().StartWith($"{Tenant}:"); + } + [Fact] public void Two_Tenants_Never_Compute_The_Same_Key() { @@ -57,6 +88,43 @@ public void A_Key_Without_Three_Real_Segments_Is_Refused(string key, string why) act.Should().Throw(why); } + [Theory] + [InlineData("hub:entitlement:018f4d40-0000-7000-8000-00000000000a")] + [InlineData("tenant:settings:cache")] + [InlineData("education:course:018f4d40-0000-7000-8000-00000000000a")] + public void Three_Segments_Are_Not_Enough_If_The_First_One_Is_Not_A_Tenant(string key) + { + // These are the shapes Standards 20's own cheat sheet used to carry, and + // the reason counting segments was never the rule: every one of them has + // three non-empty segments, puts the MODULE first, and is therefore a key + // two tenants can both compute. The first version of the guard admitted + // all three while its error message said the tenant segment is mandatory + // — a guard that passes the shape it exists to reject is worse than no + // guard, because it makes the rule look enforced. + var act = () => CacheKey.EnsureValid(key); + + act.Should().Throw(); + } + + [Fact] + public void The_Platform_Sentinel_Is_A_Tenant_Segment() + { + var act = () => CacheKey.EnsureValid("platform:hub:host-map"); + + act.Should().NotThrow("'every tenant' is spelled, not omitted"); + } + + [Fact] + public void A_Key_May_Carry_More_Than_Three_Segments() + { + // The logical name is the caller's to structure — "settings:theme:dark" + // is one name with internal structure, not a violation. What is fixed is + // that the FIRST segment identifies the tenant. + var act = () => CacheKey.EnsureValid($"{Tenant}:tenancy:settings:theme"); + + act.Should().NotThrow(); + } + [Fact] public void A_Well_Formed_Key_Passes() { @@ -85,4 +153,23 @@ public void An_Empty_Component_Is_Refused(string? component) act.Should().Throw(); } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void An_Empty_Component_Of_An_Organization_Key_Is_Refused(string? component) + { + var act = () => CacheKey.ForOrganization(Tenant, Org, "education", component!); + + act.Should().Throw(); + } + + [Fact] + public void A_Separator_Inside_An_Organization_Key_Segment_Is_Refused() + { + var act = () => CacheKey.ForOrganization(Tenant, Org, "education:nested", "roster"); + + act.Should().Throw(); + } } diff --git a/docs/architecture/24-learnstack-hub.md b/docs/architecture/24-learnstack-hub.md index 922b38a7..3fd01c2d 100644 --- a/docs/architecture/24-learnstack-hub.md +++ b/docs/architecture/24-learnstack-hub.md @@ -333,7 +333,8 @@ event, invalidates `platform_entitlement_cache` for that tenant, re-fetches on n ### Cache TTL LearnStack runtime caches the `Entitlement` for **15 minutes** via `ICacheService` (key -`hub:entitlement:{tenant_id}`). Beyond 15 minutes it re-fetches lazily. Eager invalidation +`{tenant_id}:hub:entitlement` — tenant segment first, per +[Standards 20 § `ICacheService`](../standards/20-infrastructure-stack.md)). Beyond 15 minutes it re-fetches lazily. Eager invalidation via Dapr pub/sub event (above) makes the cache TTL a worst-case bound, not a typical one. ## 5. Sequence diagrams diff --git a/docs/architecture/29-dapr-integration.md b/docs/architecture/29-dapr-integration.md index 8e21f754..ad0c293a 100644 --- a/docs/architecture/29-dapr-integration.md +++ b/docs/architecture/29-dapr-integration.md @@ -203,17 +203,16 @@ spec: ## 3. SharedKernel abstractions Application code interacts with Dapr exclusively through three interfaces in -`LearnStack.SharedKernel.Abstractions`: +`LearnStack.SharedKernel`: ```csharp -// LearnStack.SharedKernel.Abstractions.Messaging +// LearnStack.SharedKernel.Messaging public interface IEventBus { - Task PublishAsync(TEvent @event, CancellationToken ct = default) - where TEvent : IIntegrationEvent; + Task PublishAsync(IIntegrationEvent @event, string partitionKey, CancellationToken ct = default); } -// LearnStack.SharedKernel.Abstractions.Caching +// LearnStack.SharedKernel.Caching public interface ICacheService { Task GetAsync(string key, CancellationToken ct = default); @@ -221,14 +220,11 @@ public interface ICacheService CacheOptions? options = null, CancellationToken ct = default); Task SetAsync(string key, T value, CacheOptions? options = null, CancellationToken ct = default); Task RemoveAsync(string key, CancellationToken ct = default); - // Removed, or redesigned to a generation-key pattern, before Phase 02a Packet 5 - // ships (ADR-0035). See the note under the reference implementation below. - Task RemoveByPrefixAsync(string prefix, CancellationToken ct = default); } -public sealed record CacheOptions(TimeSpan? L1Ttl = null, TimeSpan? L2Ttl = null, string[]? Tags = null); +public sealed record CacheOptions(TimeSpan? L1Ttl = null, TimeSpan? L2Ttl = null); -// LearnStack.SharedKernel.Abstractions.Secrets +// LearnStack.SharedKernel.Secrets public interface ISecretProvider { Task GetSecretAsync(string key, CancellationToken ct = default); @@ -237,57 +233,65 @@ public interface ISecretProvider } ``` +`IEventBus.PublishAsync` is **not generic** and `ICacheService` has **no +`RemoveByPrefixAsync`**; `CacheOptions` carries **no `Tags`**. All three were settled by +[ADR-0014 Amendment 2](../decisions/0014-adopt-dapr.md) — see +[15-event-and-outbox.md](15-event-and-outbox.md) for why a generic publish reaches zero +handlers at the only call site that matters, and § 4 below for why a prefix removal +cannot be honoured across instances. + +**Keys are composed by the caller, not by the adapter.** `CacheKey.For(tenantId, module, +name)` — or `ForOrganization(...)` — produces the key and `CacheKey.EnsureValid` guards +its shape, so an adapter that also prefixed would emit `{tenant}:{tenant}:{module}:{name}`. +Standards 20 § Cache fixes the shape; every implementation validates, none rewrites. + Concrete implementations (`DaprEventBus`, `DaprCacheService`, `DaprSecretProvider`) live in `LearnStack.Infrastructure.{Messaging, Caching, Secrets}`. They are the **only** Dapr-aware code in the codebase. ### Development fallback: `InProcessEventBus` -When `DeploymentMode.Development` and the Dapr sidecar is not running, the composition -root registers `InProcessEventBus : IEventBus` instead. It routes -`PublishAsync(@event, ct)` to `MediatR.IPublisher.Publish(@event, ct)`. Module -subscribers (`INotificationHandler`) handle the event in-process. No -durable buffer, no Kafka, no sidecar dependency for dev environments without Docker. +When the Dapr sidecar is not running, the composition root registers +`InProcessEventBus : IEventBus`. It is a **transport, not a stub**: it resolves +`IIntegrationEventHandler` by the event's runtime type, restores tenant context from +`@event.TenantId` into the handler's scope and puts the publisher's own back afterwards, +leaves `IInboxGuard` deduplication to the handler exactly as the durable path does, and +preserves per-partition-key ordering. A dev path that skipped those is a dev path where +the isolation code is never exercised — see +[15-event-and-outbox.md](15-event-and-outbox.md), which owns the implementation, and +[ADR-0035](../decisions/0035-demand-gated-infrastructure.md), which makes the four +obligations a condition of the gating. ## 4. Cache implementation (DaprCacheService) -L1 in-memory + L2 Dapr State Store, with automatic tenant prefixing. +L1 in-memory + L2 Dapr state store. The default implementation shipped in Packet 5 is +`InMemoryCacheService` (L1 only); this adapter lands on +[ADR-0035](../decisions/0035-demand-gated-infrastructure.md)'s trigger — more than one +application instance running concurrently. ```csharp internal sealed class DaprCacheService : ICacheService { private readonly DaprClient _dapr; private readonly IMemoryCache _memoryCache; - private readonly ITenantContextAccessor _tenantContext; - private readonly ConcurrentDictionary _trackedKeys = new(); - private static long _lastCleanupTicks; private const string StateStoreName = "statestore"; - private static readonly Guid InstanceId = Guid.NewGuid(); - - private string PrefixKey(string key) - { - var tenantId = _tenantContext.Current?.TenantId.ToString() ?? "platform"; - var orgId = _tenantContext.Current?.OrganizationId?.ToString(); - return orgId is null ? $"{tenantId}:{key}" : $"{tenantId}:{orgId}:{key}"; - } public async Task GetOrSetAsync(string key, Func> factory, CacheOptions? options = null, CancellationToken ct = default) { - var prefixed = PrefixKey(key); + // The key already carries its tenant — CacheKey composed it. Validate, + // never re-prefix. + CacheKey.EnsureValid(key); - // L1 - if (_memoryCache.TryGetValue(prefixed, out T? cached) && cached is not null) return cached; + if (_memoryCache.TryGetValue(key, out T? cached) && cached is not null) return cached; - // L2 - var (state, etag) = await _dapr.GetStateAndETagAsync(StateStoreName, prefixed, cancellationToken: ct); + var (state, etag) = await _dapr.GetStateAndETagAsync(StateStoreName, key, cancellationToken: ct); if (!string.IsNullOrEmpty(etag) && state is not null) { - _memoryCache.Set(prefixed, state, options?.L1Ttl ?? TimeSpan.FromMinutes(2)); + _memoryCache.Set(key, state, options?.L1Ttl ?? TimeSpan.FromMinutes(2)); return state; } - // Factory var value = await factory(ct); await SetAsync(key, value, options, ct); return value; @@ -295,52 +299,44 @@ internal sealed class DaprCacheService : ICacheService public Task SetAsync(string key, T value, CacheOptions? options = null, CancellationToken ct = default) { - var prefixed = PrefixKey(key); - _memoryCache.Set(prefixed, value, options?.L1Ttl ?? TimeSpan.FromMinutes(2)); - _trackedKeys[prefixed] = DateTimeOffset.UtcNow + (options?.L2Ttl ?? TimeSpan.FromMinutes(15)); - CleanupExpiredTrackedKeys(); + CacheKey.EnsureValid(key); + _memoryCache.Set(key, value, options?.L1Ttl ?? TimeSpan.FromMinutes(2)); var metadata = new Dictionary { ["ttlInSeconds"] = ((int)(options?.L2Ttl ?? TimeSpan.FromMinutes(15)).TotalSeconds).ToString() }; - return _dapr.SaveStateAsync(StateStoreName, prefixed, value, metadata: metadata, cancellationToken: ct); + return _dapr.SaveStateAsync(StateStoreName, key, value, metadata: metadata, cancellationToken: ct); } - - // NOTE: superseded. `_trackedKeys` is instance-local, so keys written by another - // pod are never evicted and this method silently under-invalidates the moment a - // second instance runs. Per ADR-0035 it is removed from `ICacheService` or - // redesigned to a generation-key pattern before Phase 02a Packet 5 ships; see - // 32-tenant-customization-model.md § 8.2 for the generation-counter shape. - public async Task RemoveByPrefixAsync(string prefix, CancellationToken ct = default) - { - var prefixed = PrefixKey(prefix); - - // Local removal - foreach (var tracked in _trackedKeys.Keys.Where(k => k.StartsWith(prefixed, StringComparison.Ordinal)).ToList()) - { - _memoryCache.Remove(tracked); - _trackedKeys.TryRemove(tracked, out _); - await _dapr.DeleteStateAsync(StateStoreName, tracked, cancellationToken: ct); - } - - // Cross-instance L1 invalidation via Dapr pub/sub - await _dapr.PublishEventAsync("pubsub", "learnstack.cache.invalidation", - new CacheInvalidationEvent(prefixed, InstanceId), ct); - } - - // ... (omitted: CleanupExpiredTrackedKeys throttled to once per 30s via Interlocked.CompareExchange) } ``` -Cross-instance L1 invalidation was originally specified against `RemoveByPrefixAsync`: -one pod publishes a `learnstack.cache.invalidation` event, and a -`CacheInvalidationSubscriber` on every pod clears matching L1 entries except those it -published itself. That contract is superseded. A tenant-scoped **generation counter** -embedded in the cache key makes every stale key unreachable at once without enumerating -keys, and needs no invalidation topic on the write path. See -[32-tenant-customization-model.md § 8.2](32-tenant-customization-model.md) and -[ADR-0035](../decisions/0035-demand-gated-infrastructure.md). +### Why there is no prefix removal, and no invalidation topic + +An earlier version of this document carried a `RemoveByPrefixAsync` backed by an +instance-local `_trackedKeys` dictionary, whose subscriber cleared *prefix-matching* L1 +entries on every other pod. That is superseded by +[ADR-0014 Amendment 2](../decisions/0014-adopt-dapr.md). + +The `learnstack.cache.invalidation` topic itself survives, and it is worth being precise +about what changed: the topic carries a `(tenant_id, cache_key)` payload and evicts **one +named key** across instances, which is enumerable by construction. What died is +invalidating a *set* the caller cannot enumerate. The topic is owned by +[Phase 11](../roadmap/phase-11-production-hardening.md), which lands the Dapr and Valkey +adapters and tests it under a broker partition — before that there is one instance and +one cache, so there is nothing to invalidate across. + +The tracked-key set is the defect: it holds only what *this* instance wrote, so keys +written by another pod were never evicted — a method whose name promised a global effect +while delivering a local one, and which under-invalidated the moment a second instance +ran. That is precisely the condition under which the Dapr adapter exists at all. + +What replaces it is a tenant-scoped **generation counter** embedded in the key template: +a durable value bumped inside the business transaction, so a write makes every stale key +unreachable at once without enumerating or deleting any of them, and without a +topic on the write path. It is a caller-side convention rather than a member of +`ICacheService`. See +[32-tenant-customization-model.md § 8.2](32-tenant-customization-model.md). ## 5. Sidecar deployment diff --git a/docs/decisions/0022-custom-domain-tls.md b/docs/decisions/0022-custom-domain-tls.md index e4388da2..ece1e494 100644 --- a/docs/decisions/0022-custom-domain-tls.md +++ b/docs/decisions/0022-custom-domain-tls.md @@ -380,6 +380,15 @@ public sealed class TenantMiddleware `_hostToTenantResolver` is backed by `ICacheService` (Dapr State / Valkey); cache key `hub:host:{host}` invalidated on `CustomDomainActivatedEvent` / `CustomDomainRevokedEvent`. +> **Key spelling superseded (2026-08-24).** The cache key shipped as +> `platform:hub:host-map:{host}`: the tenant segment comes first and is mandatory, and +> a host lookup is the one family that legitimately carries the `platform` sentinel, +> because it answers "which tenant is this?" and so has no tenant to key it by. +> `CacheKey.EnsureValid` rejects the spelling above. The canonical shape lives in +> [Standards 20 § `ICacheService`](../standards/20-infrastructure-stack.md); this +> paragraph is left as written because an Accepted ADR is not rewritten, and nothing +> else in this decision depends on the spelling. + ### Public suffix list validation A tenant cannot register `com`, `co.uk`, `gov`, or other public suffix domains. The diff --git a/docs/standards/20-infrastructure-stack.md b/docs/standards/20-infrastructure-stack.md index 88cc7b90..bc8761a2 100644 --- a/docs/standards/20-infrastructure-stack.md +++ b/docs/standards/20-infrastructure-stack.md @@ -167,14 +167,23 @@ ADR-0014 non-goals; do not introduce them without a new ADR. - All Valkey access goes through `ICacheService`. Direct `IConnectionMultiplexer` / `IDistributedCache` injections are forbidden by the architecture test `Modules_Do_Not_Inject_Valkey_Directly`. -- Cache keys are `{tenant_id}:{module}:{logical-name}`. The - `tenant_id` prefix is **mandatory** even when a value is platform-wide — use the - sentinel `"platform"` tenant id rather than omitting the prefix. +- Cache keys are `{tenant_id}:{module}:{logical-name}`, or + `{tenant_id}:{organization_id}:{module}:{logical-name}` when the value is scoped to + one organization. The `tenant_id` segment comes **first** and is mandatory even when + a value is platform-wide — use the sentinel `"platform"` tenant id rather than + omitting it. Compose with `CacheKey.For` / `CacheKey.ForOrganization` / + `CacheKey.ForPlatform`; every `ICacheService` implementation calls + `CacheKey.EnsureValid`, and none re-prefixes. There is no query filter and no RLS + policy in front of a dictionary, so the key is the entire isolation boundary — + which is why the shape is validated rather than left to each call site to remember. - TTL defaults: 60s for hot-path reads (host → tenant, entitlement projection cache, permission cache), 5min for medium-warm reads, 1h for cold lookups. Anything longer needs explicit justification in code review. -- Eager invalidation publishes to `learnstack.cache.invalidation`; do not rely on TTL - expiry for correctness. +- **Correctness never lives in the cache.** A miss is not an error, and an + implementation may evict at any moment for any reason, so a caller that treats a + miss as a failure has made a component whose contract is "sometimes" into one it + depends on. Eager invalidation bounds staleness; it does not make the cache + authoritative. #### Cache layer cheat sheet @@ -185,11 +194,21 @@ different decisions: | Key family | L1 (in-process `IMemoryCache`) | L2 (Dapr state → Valkey) | Eager invalidation event | |---|---|---|---| -| `hub:host:{host}` (host → tenant) | 2 min | 15 min | `learnstack.hub.custom-domain.activated/.deactivated` | -| `hub:entitlement:{tenant_id}` (plan projection) | 60 s | 15 min (upper bound; Hub-push refresh resets it) | `learnstack.hub.entitlement` | -| `tenant_feature_flags:{tenant_id}` | 60 s | 15 min | `learnstack.cache.invalidation` (generation key — see the rule below) | -| Permission lookup per session | 60 s | session-scoped (no L2) | `learnstack.identity.role` / `.membership` events | -| Tenant settings (low-churn) | 5 min | 1 h | `learnstack.tenancy.settings` | +| `platform:hub:host-map:{host}` (host → tenant) | 2 min | 15 min | `learnstack.hub.custom-domain.activated/.deactivated` | +| `{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 | generation key — see the rule 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` | + +The host lookup is the **one** family that legitimately carries the `platform` +sentinel, and it is worth saying why: it answers "which tenant is this?", so by +construction there is no tenant to key it by. Every other family knows its tenant, +so a `platform` sentinel there would be a bug wearing the sentinel's clothes. + +> An earlier version of this table listed these as `hub:host:{host}`, +> `hub:entitlement:{tenant_id}` and `tenant_feature_flags:{tenant_id}` — module +> first, tenant in the middle or absent. Each contradicted the key rule stated a few +> lines above it, and `CacheKey.EnsureValid` now rejects all three. Rules: diff --git a/docs/standards/21-architecture-tests-catalogue.md b/docs/standards/21-architecture-tests-catalogue.md index 8b45d54c..19b5c5c4 100644 --- a/docs/standards/21-architecture-tests-catalogue.md +++ b/docs/standards/21-architecture-tests-catalogue.md @@ -1677,8 +1677,8 @@ structural test proves — and what it does not. - **Asserts:** the anonymous-burst counters resolve no `ICacheService`. A cache outage must not decide whether a MUST-class security event is recorded. - **Source:** ADR-0036 § Recording a rejected assertion. -- **Type:** xUnit source scan over `LearnStack.Api/Tenancy`. **Kind:** structural. -- **Status:** **Implemented** (`TenancyConventionTests`) as a **tripwire**: `ICacheService` does not exist until Packet 5, so this cannot yet be a dependency check. It holds the line from now, because a shared burst counter is exactly what someone reaches a cache for. +- **Type:** xUnit reflection check over the `LearnStack.Api.Tenancy` namespace **and** a source scan over `LearnStack.Api/Tenancy`. **Kind:** structural. +- **Status:** **Implemented** (`TenancyConventionTests`). It shipped in Packet 4 as a **tripwire**, because `ICacheService` did not exist yet; Packet 5 ships the port, so the rule now carries the dependency check it was always meant to be. Both forms are kept: reflection catches an injected dependency, the scan catches a service-locator resolve, and neither sees the other's case. - **Phase:** 02a Packet 4. #### `Api_Registers_Only_The_Tenant_Realm_Authority` From bd8cc4e83c4279be1e95437f66efb2135dc287c0 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Mon, 24 Aug 2026 20:36:54 +0300 Subject: [PATCH 05/21] fix(kernel): close what the review found, its own fixes included MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four independent reviews of the cache port. Every finding below was reproduced before being fixed, and every fix now has a test that fails when the code it covers is removed — 9 of 9 previously-surviving mutants and 4 of 4 new ones die. **The stampede test never raced.** `Enumerable.Range(0,8).Select(_ => GetOrSetAsync(...)).ToArray()` evaluates sequentially on one thread: each caller ran to its first suspension point before the next was invoked, so caller 1 had registered its flight before caller 2 existed. Swapping `LazyThreadSafetyMode.ExecutionAndPublication` for `None` survived it. Dispatched through `Task.Run` behind a barrier, the same mutation fails. This is the third test in this packet found to agree with the code instead of constraining it, after the bound test that only held on one clock schedule and the chunked-transfer helper that never chunked. **Cleanup was bound to the wrong event — twice, each version fixing one half and breaking the other.** Unregistering a flight when a *caller* exits meant a joiner that cancelled removed the shared registration while the factory still ran, so the next arrival started a second concurrent run: the stampede this method exists to prevent, reintroduced by its own cleanup, in the same change whose comment promised a joiner could leave without affecting the others. Unregistering on the *factory's* completion instead meant the flight was already gone by the time the caller stored, so `Supersede` had nothing to mark and a write landing in that window was silently overwritten. A flight is now retired when its last caller is done: the registration is what `Supersede` reaches, so it has to outlive every caller's store. **An unbounded map behind a bounded one.** The per-key version counter lived in a dictionary nothing swept. Measured at 50,000 distinct keys: `_entries` held its 10,000 ceiling while that map held all 50,000 — reachable by ordinary per-entity keys, not by misuse. The counter is now a flag on the flight, which dies with it. **One key, two types, one factory run.** `_inFlight` keyed on the string alone, so two callers requesting one key as different `T` shared a run: measured, the second caller's factory was never invoked and it received the first's payload. Registration is keyed by `(key, type)`. Reads use `is T` rather than a cast, so a key holding another type is a miss — the caller reads the source of truth instead of taking an InvalidCastException out of a component whose contract is that a miss is never an error. **Check-then-store was not one step.** A write landing between them was overwritten by the stale result the check exists to reject. Re-checked after the write; if superseded, the entry is evicted rather than left holding a value a concurrent write had already replaced. Aimed at the window through the `IClock` seam this class already takes for determinism, since no scheduler can be pointed at it. **The key guard admitted six spellings of one tenant.** `Guid.TryParse` accepts the N, B, P and X formats and tolerates leading and trailing whitespace; `TryParseExact` with "D" still tolerates the whitespace. None collide — the dictionaries compare ordinally — and that is the problem: they are a silent miss. The tenant segment must now be the canonical rendering. `Guid.Empty` is refused outright at composition: it is what `default(Guid)` renders as, so accepting it means every call site that failed to resolve its tenant shares one bucket. **`For` became `ForTenant`.** The one mistake `EnsureValid` cannot catch is a caller reaching for the default-looking method when the value is organization-scoped, because the two are indistinguishable as strings. With all three factories naming their scope, choosing one is a decision rather than a habit. Zero consumers exist today, which makes this the cheapest moment it will ever have. Coverage gaps closed, each verified by the mutation that used to survive: `GetOrSetAsync`'s own freshness check (which only matters while a sweep is throttled — step further and the sweep hides the missing check), the backwards-clock guard, `Trim`'s expired-first pass (which only differs from oldest-first when an expired entry is *newer* than a live one), the sweep throttle, the bound's exactness rather than just its ceiling, an empty segment behind a valid tenant, a two-segment key behind the platform sentinel, and a null key. `Replacing_A_Key_Does_Not_Grow_The_Map` asserted nothing its name promised — one key cannot occupy two slots in a dictionary however `Store` branches — and now asserts `Count`. Corpus: architecture/32 § 8.2 is the canonical generation-key example that both shipped source files point to, and all three of its keys led with `cust:` — the module-first shape `EnsureValid` throws on. It also now says why the generation is folded into the logical-name segment rather than added as a fourth: a separator that can appear inside a component makes two key tuples collide, which is why `CacheKey` rejects one. architecture/21 described `learnstack.cache.invalidation` as intra-instance where every other document says cross-instance; invalidating your own instance's cache needs no topic. The glossary now carries the organization-scoped shape. The hook fix in 6b79c5b was itself incomplete, and its message overclaimed. `git -c core.excludesFile=.leakwatchignore check-ignore` LAYERS that file onto the repo's ignore stack rather than replacing it, which broke it in both directions. `.gitignore` carries `!.env.example` and `!frontend/apps/web/.env.local.example` — negations, needed so git tracks those files at all — and a negation outranks `core.excludesFile`, so 2 of the 14 ignored paths were still blocked locally while CI passed: the exact defect the fix existed to remove. The reverse leaked in too — patterns from `.gitignore` and from a developer's own `.git/info/exclude` were honoured as leakwatch's, so a tracked file someone had quietly excluded would have been skipped with nothing printed. The evaluation now happens in a throwaway repository whose only ignore source is `.leakwatchignore`. Verified end to end on a clone: both negated files pass, a real token in a normal doc still blocks, and an `info/exclude` entry no longer confers immunity. That message also said "seven paths" and "verified against all seven"; the file lists fourteen, and the verification covered three — which is why the two broken ones were not caught. 474 unit + 127 integration + 34 architecture + 1 contract green, 0 warnings under CI=true, 10 consecutive runs stable. Co-Authored-By: Claude Opus 5 (1M context) --- .githooks/pre-commit | 43 ++- .../Caching/InMemoryCacheService.cs | 177 +++++++-- .../Caching/CacheKey.cs | 63 +++- .../Caching/InMemoryCacheServiceTests.cs | 353 +++++++++++++++++- .../SharedKernel/Caching/CacheKeyTests.cs | 104 +++++- docs/architecture/21-feature-flags.md | 2 +- docs/architecture/29-dapr-integration.md | 3 +- .../32-tenant-customization-model.md | 18 +- docs/glossary.md | 2 +- 9 files changed, 691 insertions(+), 74 deletions(-) diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 6242d487..346143ec 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -115,13 +115,44 @@ restage() { git add -- "$@"; } # root scan (`scan fs .`, what CI runs) honours it and a per-file scan (what # this hook runs) does not — and `--exclude` does not apply to an explicitly # named file target either. The result was a hook that blocked every commit -# touching one of the seven intentionally-ignored files while CI went green on -# the same tree, and whose own remediation message told the developer to extend -# a file that could not have helped. `.leakwatchignore` is documented as -# gitignore syntax, so git itself is the correct matcher for it. +# touching one of the fourteen intentionally-ignored files while CI went green +# on the same tree, and whose own remediation message told the developer to +# extend a file that could not have helped. `.leakwatchignore` is documented as +# gitignore syntax, so git is the correct matcher for it. +# +# It has to be evaluated in ISOLATION, though, and the first version of this fix +# was not. `git -c core.excludesFile=.leakwatchignore check-ignore` layers that +# file ON TOP of the repo's own ignore stack rather than replacing it, and that +# broke it in both directions. Measured: +# +# - `.gitignore` carries `!.env.example` and `!frontend/apps/web/.env.local.example` +# — negations, needed so git tracks those files at all — and a negation +# outranks `core.excludesFile`. Both files are listed in `.leakwatchignore` +# and both came back NOT ignored: 2 of the 14 paths still blocked locally +# while CI passed, which is the very defect the fix existed to remove. +# - The reverse leaked in too: patterns from `.gitignore` and from the +# developer-local `.git/info/exclude` were honoured as if they were +# leakwatch's. A tracked file a developer had quietly excluded would have +# been skipped by the scan with nothing printed. +# +# So the evaluation happens in a throwaway repository whose ONLY ignore source +# is `.leakwatchignore`. One `git init` per commit, all paths checked in a +# single batch. If anything about that fails the set comes back empty, which +# means everything is scanned — the safe direction, never a silent skip. +leakwatch_ignored_paths="" +if [[ -f .leakwatchignore && ${#all_staged[@]} -gt 0 ]]; then + lw_isolated="$(mktemp -d)" + if git init -q "$lw_isolated" >/dev/null 2>&1 \ + && cp .leakwatchignore "$lw_isolated/.gitignore"; then + leakwatch_ignored_paths="$(printf '%s\n' "${all_staged[@]}" \ + | git -C "$lw_isolated" check-ignore --no-index --stdin 2>/dev/null || true)" + fi + rm -rf "$lw_isolated" +fi + leakwatch_path_ignored() { - git -c core.excludesFile=.leakwatchignore \ - check-ignore --no-index -q -- "$1" 2>/dev/null + [[ -n "$leakwatch_ignored_paths" ]] || return 1 + printf '%s\n' "$leakwatch_ignored_paths" | grep -Fxq -- "$1" } # Older builds only accept a DIRECTORY target. CI pins v1.5.0, which rejects a diff --git a/backend/src/LearnStack.Infrastructure/Caching/InMemoryCacheService.cs b/backend/src/LearnStack.Infrastructure/Caching/InMemoryCacheService.cs index dde79d54..50352aba 100644 --- a/backend/src/LearnStack.Infrastructure/Caching/InMemoryCacheService.cs +++ b/backend/src/LearnStack.Infrastructure/Caching/InMemoryCacheService.cs @@ -62,17 +62,13 @@ public sealed class InMemoryCacheService(IClock clock) : ICacheService private readonly ConcurrentDictionary _entries = new(StringComparer.Ordinal); /// - /// One factory run per key, however many callers miss at once. + /// One factory run per (key, requested type), however many callers miss at + /// once. Keyed by type as well as key because a flight hands its result to + /// every joiner: two callers asking for the same key as different T + /// would otherwise share one run, and the loser would receive the winner's + /// payload — its own factory never invoked at all. /// - private readonly ConcurrentDictionary>> _inFlight = - new(StringComparer.Ordinal); - - /// - /// Per-key write counter. A flight reads it - /// before running its factory and refuses to store a result the caller has - /// since superseded. - /// - private readonly ConcurrentDictionary _versions = new(StringComparer.Ordinal); + private readonly ConcurrentDictionary<(string Key, Type Type), Flight> _inFlight = new(); private long _lastSweepTicks; @@ -100,6 +96,17 @@ public sealed class InMemoryCacheService(IClock clock) : ICacheService /// public int Count => _entries.Count; + /// + /// How many factory runs are registered as in flight. A diagnostic, for the + /// same reason and with the same caveat as . + /// + /// + /// This map is the other structure that could grow without bound, and the + /// one whose cleanup is subtlest: it is unregistered when the flight ends, + /// not when a caller stops waiting for it. + /// + public int InFlightCount => _inFlight.Count; + public Task GetAsync(string key, CancellationToken cancellationToken = default) { CacheKey.EnsureValid(key); @@ -107,9 +114,13 @@ public sealed class InMemoryCacheService(IClock clock) : ICacheService var now = clock.UtcNow; Sweep(now); - if (_entries.TryGetValue(key, out var entry) && entry.IsFresh(now)) + // `is T` rather than a cast: a key holding some other type is a caller + // bug, and answering it with a miss lets the caller read the source of + // truth instead of taking an InvalidCastException out of a component + // whose contract is that a miss is never an error. + if (_entries.TryGetValue(key, out var entry) && entry.IsFresh(now) && entry.Value is T hit) { - return Task.FromResult((T?)entry.Value); + return Task.FromResult(hit); } return Task.FromResult(default); @@ -127,22 +138,17 @@ public async Task GetOrSetAsync( var now = clock.UtcNow; Sweep(now); - if (_entries.TryGetValue(key, out var hit) && hit.IsFresh(now)) + if (_entries.TryGetValue(key, out var hit) && hit.IsFresh(now) && hit.Value is T cached) { - return (T)hit.Value!; + return cached; } - // The version is read BEFORE the factory runs. If a Set or a Remove - // lands while it is running, storing the result would resurrect a value - // the caller already replaced or deleted — eager invalidation silently - // lost for a full TTL, which is the one thing a cache must not do - // quietly. - var versionAtStart = VersionOf(key); - - // Lazy with ExecutionAndPublication, not a bare GetOrAdd: the value - // factory of a ConcurrentDictionary may run more than once under + // Lazy with ExecutionAndPublication, not a bare GetOrAdd value factory: + // a ConcurrentDictionary's value factory may run more than once under // contention, and running the caller's factory twice is the stampede - // this method exists to prevent. + // this method exists to prevent. The Lazy is built before the GetOrAdd + // and passed as a VALUE, so the loser of a creation race simply + // discards an object whose .Value was never touched — no factory run. // // The flight runs on CancellationToken.None, NOT on this caller's // token. Measured: with the caller's token, one client pressing refresh @@ -150,31 +156,93 @@ public async Task GetOrSetAsync( // key died with it — as a 499, which this host treats as "the client // hung up" and therefore writes no body, captures no error and records // no span. A request that did nothing wrong failed invisibly. - var flight = _inFlight.GetOrAdd( - key, - _ => new Lazy>( - async () => await factory(CancellationToken.None).ConfigureAwait(false), - LazyThreadSafetyMode.ExecutionAndPublication)); + var mine = new Flight(new Lazy>( + async () => await factory(CancellationToken.None).ConfigureAwait(false), + LazyThreadSafetyMode.ExecutionAndPublication)); + + var registration = (key, typeof(T)); + var flight = _inFlight.GetOrAdd(registration, mine); + + // Registered before anything can observe the count, so the completion + // continuation below never sees a zero that is about to become one. + Interlocked.Increment(ref flight.Waiters); try { + if (ReferenceEquals(flight, mine)) + { + // Covers the case the `finally` cannot: every caller abandoned + // before the factory finished, so no `finally` runs again to + // notice the flight is done. + _ = flight.Task.Value.ContinueWith( + _ => Retire(registration, flight), + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + } + // Each caller observes its OWN token while waiting, so a joiner can - // abandon a slow flight without affecting the others. - var produced = (T)(await flight.Value.WaitAsync(cancellationToken) + // abandon a slow flight without ending it for anybody else. + var produced = (T)(await flight.Task.Value.WaitAsync(cancellationToken) .ConfigureAwait(false))!; - if (VersionOf(key) == versionAtStart) + // A Set or a Remove landing while the factory ran marks the flight + // superseded. Storing anyway would resurrect a value the caller + // already replaced or deleted — eager invalidation silently lost for + // a full TTL, which is the one thing a cache must not do quietly. + if (!flight.Superseded) { Store(key, produced, options, clock.UtcNow); + + // Re-checked after the write, because the check above and the + // write are two steps rather than one: a write landing between + // them would otherwise be overwritten by this stale result. + // Evicting is the safe resolution — the next reader takes a miss + // and goes to the source of truth, and a miss is never an error, + // whereas a stale value presented as fresh is. + if (flight.Superseded) + { + _entries.TryRemove(key, out _); + } } return produced; } finally { - // Value-comparing, so a later flight started by another caller is - // not removed by this one's cleanup. - _inFlight.TryRemove(new KeyValuePair>>(key, flight)); + // Unregistered when the last caller is DONE, not when the factory + // finishes. Measured on two earlier versions, each of which fixed + // one half and broke the other: + // + // - Unregistering on the caller's exit meant a joiner that + // cancelled removed the shared registration while the factory + // was still running, so the next arrival started a second + // concurrent run — the stampede this method exists to prevent, + // reintroduced by its own cleanup. + // - Unregistering on the factory's completion instead meant the + // flight was already gone by the time the caller stored, so + // `Supersede` had nothing left to mark and a write landing in + // that window was silently overwritten. + // + // The registration is what `Supersede` reaches, so it has to outlive + // the store, and it has to outlive every other caller's store too. + if (Interlocked.Decrement(ref flight.Waiters) == 0) + { + Retire(registration, flight); + } + } + } + + /// + /// Unregisters a flight once it has finished and no caller is still using + /// it. Value-comparing, so a later flight for the same key is never removed + /// by an earlier one's cleanup. + /// + private void Retire((string Key, Type Type) registration, Flight flight) + { + if (Volatile.Read(ref flight.Waiters) == 0 && flight.Task.Value.IsCompleted) + { + _inFlight.TryRemove(new KeyValuePair<(string, Type), Flight>(registration, flight)); } } @@ -188,7 +256,7 @@ public Task SetAsync( var now = clock.UtcNow; Sweep(now); - Bump(key); + Supersede(key); Store(key, value, options, now); return Task.CompletedTask; @@ -198,14 +266,32 @@ public Task RemoveAsync(string key, CancellationToken cancellationToken = defaul { CacheKey.EnsureValid(key); - Bump(key); + Supersede(key); _entries.TryRemove(key, out _); return Task.CompletedTask; } - private long VersionOf(string key) => _versions.TryGetValue(key, out var v) ? v : 0; - - private void Bump(string key) => _versions.AddOrUpdate(key, 1, (_, v) => v + 1); + /// + /// Marks every in-flight factory for as superseded, + /// so none of them writes over the change that just landed. + /// + /// + /// The flag lives on the flight and dies with it. An earlier version kept a + /// per-key version counter in a dictionary of its own, which was never + /// swept: measured at 50,000 distinct keys, _entries held its 10,000 + /// ceiling while that map held all 50,000 — an unbounded structure behind a + /// bounded one, reachable by ordinary per-entity keys rather than by misuse. + /// + private void Supersede(string key) + { + foreach (var pair in _inFlight) + { + if (string.Equals(pair.Key.Key, key, StringComparison.Ordinal)) + { + pair.Value.Superseded = true; + } + } + } private void Store(string key, T value, CacheOptions? options, DateTimeOffset now) { @@ -301,4 +387,15 @@ private sealed record Entry(object? Value, DateTimeOffset ExpiresAt, long Sequen { public bool IsFresh(DateTimeOffset now) => now < ExpiresAt; } + + /// One shared factory run, and whether a write has superseded it. + private sealed class Flight(Lazy> task) + { + public Lazy> Task { get; } = task; + + public volatile bool Superseded; + + /// Callers still using this flight. A field, for Interlocked. + public int Waiters; + } } diff --git a/backend/src/LearnStack.SharedKernel/Caching/CacheKey.cs b/backend/src/LearnStack.SharedKernel/Caching/CacheKey.cs index 2236347d..0096cc77 100644 --- a/backend/src/LearnStack.SharedKernel/Caching/CacheKey.cs +++ b/backend/src/LearnStack.SharedKernel/Caching/CacheKey.cs @@ -30,9 +30,18 @@ public static class CacheKey /// The separator between the three segments. public const char Separator = ':'; - /// Composes a key for a tenant-owned value. - public static string For(Guid tenantId, string module, string logicalName) => - For(tenantId.ToString(), module, logicalName); + /// Composes a key for a tenant-wide value. + /// + /// Named ForTenant rather than For on purpose. The one mistake + /// this class cannot catch is a caller reaching for the default-looking + /// method when the value is actually scoped to one organization — and + /// is powerless there, because an + /// organization-scoped key and a tenant-wide one are indistinguishable as + /// strings. With all three factories naming their scope, choosing one is a + /// decision rather than a habit. + /// + public static string ForTenant(Guid tenantId, string module, string logicalName) => + For(Canonical(tenantId, nameof(tenantId)), module, logicalName); /// /// Composes a key for a value scoped to one organization within a tenant: @@ -50,7 +59,11 @@ public static string For(Guid tenantId, string module, string logicalName) => /// public static string ForOrganization( Guid tenantId, Guid organizationId, string module, string logicalName) => - For(tenantId.ToString(), organizationId.ToString(), module, logicalName); + For( + Canonical(tenantId, nameof(tenantId)), + Canonical(organizationId, nameof(organizationId)), + module, + logicalName); /// Composes a key for a platform-wide value. public static string ForPlatform(string module, string logicalName) => @@ -97,7 +110,47 @@ public static void EnsureValid(string key) /// reject is worse than none — it makes the rule look enforced. /// private static bool IsTenantSegment(string segment) => - segment.Equals(PlatformTenant, StringComparison.Ordinal) || Guid.TryParse(segment, out _); + segment.Equals(PlatformTenant, StringComparison.Ordinal) + || (Guid.TryParse(segment, out var id) + && id != Guid.Empty + && segment.Equals(id.ToString(), StringComparison.Ordinal)); + + /// + /// The canonical rendering of a tenant or organization identifier, refusing + /// . + /// + /// + /// + /// Guid.Empty is what default(Guid) renders as, so accepting it + /// means two call sites that both failed to resolve their tenant share one + /// cache bucket — the exact failure this class exists to make impossible, + /// arrived at by a bug rather than by a collision. Nothing legitimately + /// identifies a tenant as all zeroes. + /// + /// + /// The equality check pins the rendering, not just the value. + /// Measured: Guid.TryParse accepts the N, B, P and X formats and + /// tolerates leading and trailing whitespace, and TryParseExact with + /// "D" still tolerates the whitespace. None of those collide with a + /// canonical key — the dictionaries compare ordinally, so they land in + /// different slots — but that is the point: they are a silent miss rather + /// than a hit, and a guard whose job is to police the shape our own + /// factories emit should not admit five spellings of one tenant. + /// + /// + private static string Canonical(Guid id, string parameterName) + { + if (id == Guid.Empty) + { + throw new ArgumentException( + "Guid.Empty does not identify a tenant or an organization, and is " + + "what an unresolved context renders as. A cache key built from it " + + "is a bucket every unresolved caller would share.", + parameterName); + } + + return id.ToString(); + } private static string For(string tenant, string module, string logicalName) => Compose([tenant, module, logicalName]); diff --git a/backend/tests/LearnStack.Tests.Unit/Infrastructure/Caching/InMemoryCacheServiceTests.cs b/backend/tests/LearnStack.Tests.Unit/Infrastructure/Caching/InMemoryCacheServiceTests.cs index e7f317af..f55fdca3 100644 --- a/backend/tests/LearnStack.Tests.Unit/Infrastructure/Caching/InMemoryCacheServiceTests.cs +++ b/backend/tests/LearnStack.Tests.Unit/Infrastructure/Caching/InMemoryCacheServiceTests.cs @@ -23,7 +23,7 @@ public sealed class InMemoryCacheServiceTests private static readonly Guid OtherTenant = Guid.Parse("018f4d40-0000-7000-8000-00000000000b"); private static string Key(Guid tenant = default) => - CacheKey.For(tenant == default ? Tenant : tenant, "tenancy", "settings"); + CacheKey.ForTenant(tenant == default ? Tenant : tenant, "tenancy", "settings"); [Fact] public async Task A_Miss_Is_Default_Not_An_Error() @@ -193,11 +193,24 @@ async Task Factory(CancellationToken cancellationToken) return "made"; } - var callers = Enumerable.Range(0, 8) - .Select(_ => cache.GetOrSetAsync(Key(), Factory)) + // Dispatched through Task.Run and held at a barrier, NOT built with + // Select(...).ToArray(). Measured: LINQ evaluates sequentially on one + // thread, so each caller ran to its first suspension point before the + // next was even invoked — caller 1 had already registered its flight + // before caller 2 existed. Nothing ever raced, and the mutation this + // test exists to catch (LazyThreadSafetyMode.None) survived it, while + // failing 5 out of 5 runs once the callers actually ran concurrently. + using var start = new ManualResetEventSlim(false); + var callers = Enumerable.Range(0, 16) + .Select(_ => Task.Run(async () => + { + start.Wait(TestTimeout); + return await cache.GetOrSetAsync(Key(), Factory); + })) .ToArray(); - gate.Release(8); + start.Set(); + gate.Release(16); var results = await Task.WhenAll(callers); calls.Should().Be(1, "one flight per key, however many callers miss at once"); @@ -235,17 +248,18 @@ public async Task The_Map_Is_Bounded_Even_When_The_Clock_Never_Moves() for (var i = 0; i <= InMemoryCacheService.MaxEntries + 500; i++) { - await cache.SetAsync(CacheKey.For(Tenant, "tenancy", $"k{i:D6}"), i, ttl); + await cache.SetAsync(CacheKey.ForTenant(Tenant, "tenancy", $"k{i:D6}"), i, ttl); } - cache.Count.Should().BeLessThanOrEqualTo(InMemoryCacheService.MaxEntries, - "the ceiling is a count, so that is what the test asserts"); + cache.Count.Should().Be(InMemoryCacheService.MaxEntries, + "the ceiling is a count, so that is what the test asserts — and " + + "exactly, because a bound that over-evicts is also a defect"); - (await cache.GetAsync(CacheKey.For(Tenant, "tenancy", "k000000"))) + (await cache.GetAsync(CacheKey.ForTenant(Tenant, "tenancy", "k000000"))) .Should().BeNull("the oldest entries are the ones the bound drops"); var newest = InMemoryCacheService.MaxEntries + 500; - (await cache.GetAsync(CacheKey.For(Tenant, "tenancy", $"k{newest:D6}"))) + (await cache.GetAsync(CacheKey.ForTenant(Tenant, "tenancy", $"k{newest:D6}"))) .Should().Be(newest, "the newest write is never the one evicted"); } @@ -262,6 +276,10 @@ public async Task Replacing_A_Key_Does_Not_Grow_The_Map() await cache.SetAsync(Key(), i, ttl); } + cache.Count.Should().Be(1, + "which is the invariant this test names — asserting only that the " + + "last write wins would pass however Store branched, since one key " + + "cannot occupy two slots in a dictionary"); (await cache.GetAsync(Key())).Should().Be((InMemoryCacheService.MaxEntries * 2) - 1); } @@ -333,6 +351,125 @@ async Task Factory(CancellationToken cancellationToken) (await winner).Should().Be("made", "the flight itself was never cancelled"); } + [Fact] + public async Task A_Joiner_That_Leaves_Does_Not_Restart_The_Factory_For_The_Next_Arrival() + { + // The first version unregistered the shared flight in a `finally`, which + // runs when a caller stops WAITING — including when it stops by + // cancelling. A joiner that walked away therefore removed the + // registration while the factory was still running, and the next + // arrival started a second concurrent run: the exact stampede this + // method exists to prevent, reintroduced by its own cleanup, in the same + // change whose comment promised a joiner could leave without affecting + // the others. + var (cache, _) = New(); + using var release = new SemaphoreSlim(0); + using var leaving = new CancellationTokenSource(); + var runs = 0; + var factoryEntered = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + + async Task Factory(CancellationToken _) + { + Interlocked.Increment(ref runs); + factoryEntered.TrySetResult(); + await release.WaitAsync(TestTimeout, CancellationToken.None); + return "made"; + } + + var stays = cache.GetOrSetAsync(Key(), Factory, null, CancellationToken.None); + await factoryEntered.Task.WaitAsync(TestTimeout); + var leaves = cache.GetOrSetAsync(Key(), Factory, null, leaving.Token); + + await leaving.CancelAsync(); + await ((Func)(() => leaves)).Should().ThrowAsync(); + + // The next arrival must JOIN the still-running flight, not start one. + var arrives = cache.GetOrSetAsync(Key(), Factory, null, CancellationToken.None); + + release.Release(3); + (await stays).Should().Be("made"); + (await arrives).Should().Be("made"); + + runs.Should().Be(1, "one factory run per key, however many callers come and go"); + } + + [Fact] + public async Task A_Finished_Flight_Is_Unregistered() + { + // The other half of the same rule: binding cleanup to the flight rather + // than to a caller must not mean never cleaning up. + var (cache, _) = New(); + + await cache.GetOrSetAsync(Key(), _ => Task.FromResult("value")); + + cache.InFlightCount.Should().Be(0); + } + + [Fact] + public async Task Nothing_Unbounded_Survives_A_Large_Key_Space() + { + // Measured on the first version: a per-key version counter lived in a + // dictionary of its own that nothing ever swept, so _entries held its + // 10,000 ceiling while that map held all 50,000 — an unbounded + // structure hiding behind a bounded one, reached by ordinary per-entity + // keys rather than by misuse. The counter is now a flag on the flight, + // which dies with it, so there is no second map to grow. + var (cache, _) = New(); + var ttl = new CacheOptions(L1Ttl: TimeSpan.FromDays(30)); + + for (var i = 0; i < InMemoryCacheService.MaxEntries * 5; i++) + { + await cache.GetOrSetAsync( + CacheKey.ForTenant(Tenant, "tenancy", $"k{i:D6}"), + _ => Task.FromResult(i), + ttl); + } + + cache.Count.Should().BeLessThanOrEqualTo(InMemoryCacheService.MaxEntries); + cache.InFlightCount.Should().Be(0); + } + + [Fact] + public async Task Two_Types_On_One_Key_Each_Get_Their_Own_Factory() + { + // A flight hands its result to every joiner, so keying it by the cache + // key alone made two callers asking for different types share one run: + // measured, the second caller's factory was never invoked and it + // received the first caller's payload, which then threw on the cast. + // Reusing one key for two types is a caller bug, but the cache must not + // answer it by silently skipping a factory. + var (cache, _) = New(); + using var release = new SemaphoreSlim(0); + var textEntered = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + + var text = cache.GetOrSetAsync(Key(), async _ => + { + textEntered.TrySetResult(); + await release.WaitAsync(TestTimeout, CancellationToken.None); + return "text"; + }); + + await textEntered.Task.WaitAsync(TestTimeout); + var number = cache.GetOrSetAsync(Key(), _ => Task.FromResult(42)); + + (await number).Should().Be(42, "its own factory ran"); + release.Release(); + (await text).Should().Be("text"); + } + + [Fact] + public async Task A_Key_Holding_Another_Type_Reads_As_A_Miss() + { + var (cache, _) = New(); + await cache.SetAsync(Key(), "text"); + + (await cache.GetAsync(Key())).Should().BeNull( + "a miss lets the caller read the source of truth; a cast would throw " + + "out of a component whose contract is that a miss is never an error"); + } + // ---- an invalidation during a flight is not undone ---------------------- [Fact] @@ -397,17 +534,157 @@ public async Task Expired_Entries_Are_Reclaimed_Without_Waiting_For_The_Ceiling( var (cache, clock) = New(); for (var i = 0; i < 200; i++) { - await cache.SetAsync(CacheKey.For(Tenant, "tenancy", $"k{i}"), i); + await cache.SetAsync(CacheKey.ForTenant(Tenant, "tenancy", $"k{i}"), i); } cache.Count.Should().Be(200); clock.Advance(InMemoryCacheService.DefaultTtl + InMemoryCacheService.SweepInterval); - await cache.GetAsync(CacheKey.For(Tenant, "tenancy", "trigger")); + await cache.GetAsync(CacheKey.ForTenant(Tenant, "tenancy", "trigger")); cache.Count.Should().Be(0, "a sweep reclaims what expired, bound or no bound"); } + [Fact] + public async Task GetOrSet_Recomputes_After_Its_Value_Expires() + { + // GetOrSetAsync has its own hit check, separate from GetAsync's, and it + // is the one a caller reaches for on the hot path. Without this, a + // regression that served the first value forever would ship green. + var (cache, clock) = New(); + var runs = 0; + + Task Factory(CancellationToken _) + { + runs++; + return Task.FromResult($"run-{runs}"); + } + + // A TTL shorter than the sweep interval, and a step that stays inside + // that interval: the entry is expired but the sweep is still throttled, + // so GetOrSetAsync's own freshness check is the only thing standing + // between the caller and a stale value. Step further and the sweep + // reclaims it first, which is why an earlier version of this test could + // not tell a missing check from a working one. + var brief = new CacheOptions(L1Ttl: TimeSpan.FromMilliseconds(300)); + + (await cache.GetOrSetAsync(Key(), Factory, brief)).Should().Be("run-1"); + (await cache.GetOrSetAsync(Key(), Factory, brief)).Should().Be("run-1", "still fresh"); + + clock.Advance(TimeSpan.FromMilliseconds(600)); + + (await cache.GetOrSetAsync(Key(), Factory, brief)).Should().Be("run-2", + "expired, and no sweep is due to have removed it"); + } + + [Fact] + public async Task A_Clock_That_Steps_Backwards_Does_Not_Wedge_The_Sweep() + { + // The throttle compares tick deltas, so a clock that jumps backwards — + // an NTP correction, a leap adjustment — would otherwise park the sweep + // until real time caught up to the future value it recorded. Every + // other test only moves the clock forward, so the guard against it had + // no coverage at all. + var (cache, clock) = New(); + clock.Advance(TimeSpan.FromHours(6)); + await cache.SetAsync(Key(), "value"); + + clock.SetUtcNow(Origin); + + for (var i = 0; i < 200; i++) + { + await cache.SetAsync(CacheKey.ForTenant(Tenant, "tenancy", $"k{i}"), i); + } + + clock.Advance(InMemoryCacheService.DefaultTtl + InMemoryCacheService.SweepInterval); + await cache.GetAsync(CacheKey.ForTenant(Tenant, "tenancy", "trigger")); + + // 200 reclaimed; the one written six hours ahead is still genuinely + // fresh at this instant, so it stays. Without the backwards guard the + // sweep would record the future timestamp and refuse to run until real + // time passed it — leaving all 201. + cache.Count.Should().Be(1, "the sweep still runs after the clock steps back"); + } + + [Fact] + public async Task The_Bound_Reclaims_Expired_Entries_Before_Evicting_Live_Ones() + { + // Trim has two passes and only the second had coverage: the bound test + // uses a 30-day TTL, so nothing is ever expired when Trim runs. An + // eviction that drops a live entry while an expired one sits next to it + // costs a round trip that nothing was owed. + // The two passes only differ when an expired entry is NEWER than a live + // one — otherwise evicting by insertion order removes the expired ones + // anyway, and dropping the first pass changes nothing observable. So the + // live entries go in FIRST and the doomed ones after them. + var (cache, clock) = New(); + var live = new CacheOptions(L1Ttl: TimeSpan.FromDays(30)); + var half = InMemoryCacheService.MaxEntries / 2; + + for (var i = 0; i < half; i++) + { + await cache.SetAsync(CacheKey.ForTenant(Tenant, "tenancy", $"live{i:D6}"), i, live); + } + + var brief = new CacheOptions(L1Ttl: TimeSpan.FromMilliseconds(300)); + for (var i = 0; i < half; i++) + { + await cache.SetAsync(CacheKey.ForTenant(Tenant, "tenancy", $"doomed{i}"), i, brief); + } + + // Inside the sweep interval, so the doomed entries are expired but still + // occupying slots when Trim runs — which is the whole point. + clock.Advance(TimeSpan.FromMilliseconds(600)); + + // `half`, not `half + 1`: 5,000 live plus 5,000 fresh is exactly the + // ceiling, so once the expired ones are reclaimed nothing live has to + // go. One more and the oldest live entry is evicted legitimately, which + // would make this test fail for a reason it is not about. + for (var i = 0; i < half; i++) + { + await cache.SetAsync(CacheKey.ForTenant(Tenant, "tenancy", $"fresh{i:D6}"), i, live); + } + + (await cache.GetAsync(CacheKey.ForTenant(Tenant, "tenancy", "live000000"))) + .Should().Be(0, + "the oldest LIVE entry survives, because expired ones are " + + "reclaimed before anything live is evicted for space"); + } + + [Fact] + public async Task The_Sweep_Runs_At_Most_Once_Per_Interval() + { + // The throttle is documented behaviour, and over-sweeping is correct but + // wasteful — so it is asserted rather than assumed. + var (cache, clock) = New(); + await cache.SetAsync(Key(), "value", new CacheOptions(L1Ttl: TimeSpan.FromMilliseconds(300))); + await cache.SetAsync(CacheKey.ForTenant(Tenant, "tenancy", "other"), 1); + + // Past the entry's TTL, but inside the interval since the last sweep. + clock.Advance(TimeSpan.FromMilliseconds(600)); + (await cache.GetAsync(Key())).Should().BeNull("expired entries are never served"); + cache.Count.Should().Be(2, "but no sweep is due, so the slot is not reclaimed yet"); + + clock.Advance(InMemoryCacheService.SweepInterval); + await cache.GetAsync(Key()); + cache.Count.Should().Be(1, "now a sweep is due"); + } + + [Fact] + public async Task An_Explicitly_Stored_Null_Reads_Back_As_A_Miss() + { + // Pinned rather than fixed: `T?` cannot distinguish "stored null" from + // "absent", and inventing a wrapper to tell them apart would complicate + // every call site for a distinction none of them makes. The cost is one + // occupied slot, which the bound already accounts for. + var (cache, _) = New(); + + await cache.SetAsync(Key(), null); + + (await cache.GetAsync(Key())).Should().BeNull(); + cache.Count.Should().Be(1, "it does occupy a slot, whatever a reader sees"); + } + // ---- the TTL boundary --------------------------------------------------- [Fact] @@ -427,6 +704,60 @@ public async Task An_Entry_Is_Gone_At_Exactly_Its_Expiry_Instant() private static readonly TimeSpan TestTimeout = TimeSpan.FromSeconds(10); + [Fact] + public async Task A_Write_Landing_Between_The_Check_And_The_Store_Is_Not_Overwritten() + { + // The supersede check and the store are two steps, not one, so a write + // landing between them would be overwritten by the very stale result + // the check exists to reject. Real thread scheduling cannot be aimed at + // a window that narrow, so the write is aimed at it through the seam + // this class already takes for determinism: an IClock whose UtcNow runs + // the write, on the read that sits between the two steps. + // + // The safe resolution is a miss, not the newer value: this caller has + // already overwritten the entry by the time it notices, so it evicts. + // The next reader goes to the source of truth, and a miss is never an + // error — whereas a stale value presented as fresh is the one thing a + // cache must not do quietly. + InMemoryCacheService? cache = null; + var clock = new WritingClock(Origin, onNthRead: 2, write: () => + cache!.SetAsync(Key(), "landed-in-the-window").GetAwaiter().GetResult()); + cache = new InMemoryCacheService(clock); + + var produced = await cache.GetOrSetAsync(Key(), _ => Task.FromResult("from-factory")); + + produced.Should().Be("from-factory", "the caller still gets what it asked for"); + clock.Fired.Should().BeTrue("the window was actually hit — otherwise this proves nothing"); + (await cache.GetAsync(Key())).Should().BeNull( + "but the entry this caller had already overwritten is evicted rather " + + "than left holding a value a concurrent write had superseded"); + } + + /// + /// A clock that performs a write on one chosen read, to land a concurrent + /// operation inside a window too narrow to hit by scheduling. + /// + private sealed class WritingClock(DateTimeOffset now, int onNthRead, Action write) : IClock + { + private int _reads; + + public bool Fired { get; private set; } + + public DateTimeOffset UtcNow + { + get + { + if (++_reads == onNthRead && !Fired) + { + Fired = true; + write(); + } + + return now; + } + } + } + private static (InMemoryCacheService Cache, FixedClock Clock) New() { var clock = new FixedClock(Origin); diff --git a/backend/tests/LearnStack.Tests.Unit/SharedKernel/Caching/CacheKeyTests.cs b/backend/tests/LearnStack.Tests.Unit/SharedKernel/Caching/CacheKeyTests.cs index bdfde949..33079509 100644 --- a/backend/tests/LearnStack.Tests.Unit/SharedKernel/Caching/CacheKeyTests.cs +++ b/backend/tests/LearnStack.Tests.Unit/SharedKernel/Caching/CacheKeyTests.cs @@ -24,7 +24,7 @@ public sealed class CacheKeyTests [Fact] public void A_Tenant_Key_Carries_All_Three_Segments() { - CacheKey.For(Tenant, "tenancy", "settings") + CacheKey.ForTenant(Tenant, "tenancy", "settings") .Should().Be($"{Tenant}:tenancy:settings"); } @@ -69,8 +69,8 @@ public void An_Organization_Key_Still_Leads_With_The_Tenant() [Fact] public void Two_Tenants_Never_Compute_The_Same_Key() { - CacheKey.For(Tenant, "tenancy", "settings") - .Should().NotBe(CacheKey.For(OtherTenant, "tenancy", "settings")); + CacheKey.ForTenant(Tenant, "tenancy", "settings") + .Should().NotBe(CacheKey.ForTenant(OtherTenant, "tenancy", "settings")); } [Theory] @@ -125,10 +125,102 @@ public void A_Key_May_Carry_More_Than_Three_Segments() act.Should().NotThrow(); } + [Fact] + public void An_Unresolved_Tenant_Is_Refused_At_Composition() + { + // Guid.Empty is what default(Guid) renders as. Accepting it means two + // call sites that both failed to resolve their tenant share one bucket — + // the failure this class exists to prevent, arrived at by a bug rather + // than by a collision. + var act = () => CacheKey.ForTenant(Guid.Empty, "tenancy", "settings"); + + act.Should().Throw(); + } + + [Fact] + public void An_Unresolved_Organization_Is_Refused_At_Composition() + { + var act = () => CacheKey.ForOrganization(Tenant, Guid.Empty, "education", "roster"); + + act.Should().Throw(); + } + + [Fact] + public void An_All_Zero_Tenant_Segment_Is_Refused_By_The_Guard_Too() + { + // Not only at composition: a hand-rolled key carrying the same segment + // must not pass either, or the rule holds for one of the two doors. + var act = () => CacheKey.EnsureValid($"{Guid.Empty}:tenancy:settings"); + + act.Should().Throw(); + } + + [Theory] + [InlineData("018f4d4000007000800000000000000a", "N format")] + [InlineData("{018f4d40-0000-7000-8000-00000000000a}", "B format")] + [InlineData("(018f4d40-0000-7000-8000-00000000000a)", "P format")] + [InlineData(" 018f4d40-0000-7000-8000-00000000000a", "leading whitespace")] + [InlineData("018f4d40-0000-7000-8000-00000000000a ", "trailing whitespace")] + [InlineData("018F4D40-0000-7000-8000-00000000000A", "uppercase")] + public void Only_The_Canonical_Rendering_Of_A_Tenant_Id_Is_Accepted( + string tenantSegment, string why) + { + // Measured: Guid.TryParse accepts N, B, P and X, and tolerates leading + // and trailing whitespace; TryParseExact with "D" still tolerates the + // whitespace. None of these collide with a canonical key — the + // dictionaries compare ordinally, so they land in different slots — and + // that is exactly the problem: they are a silent miss, and a guard + // policing the shape our own factories emit should not admit six + // spellings of one tenant. + var act = () => CacheKey.EnsureValid($"{tenantSegment}:tenancy:settings"); + + act.Should().Throw(why); + } + + [Theory] + [InlineData("::settings", "empty module, valid tenant")] + [InlineData(": :settings", "whitespace module, valid tenant")] + [InlineData(":tenancy:", "empty logical name, valid tenant")] + public void An_Empty_Segment_Is_Refused_Even_Behind_A_Valid_Tenant(string tail, string why) + { + // The corpus's other empty-segment cases all happen to fail on the + // TENANT segment too, so the empty-segment check was never the deciding + // factor in any of them — remove it and every one of them still passed. + // These put a real tenant in front, so only the empty-segment rule can + // reject them. + var act = () => CacheKey.EnsureValid($"{Tenant}{tail}"); + + act.Should().Throw(why); + } + + [Fact] + public void A_Two_Segment_Key_Is_Refused_Even_With_The_Platform_Sentinel() + { + // Same reasoning one rule over: every other short key in the corpus also + // fails the tenant check, so the segment-count floor was never proven on + // its own. "platform:hub" passes the tenant rule and must still fail. + var act = () => CacheKey.EnsureValid("platform:hub"); + + act.Should().Throw(); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void A_Null_Or_Empty_Key_Is_An_ArgumentException_Not_A_NullReference(string? key) + { + // Without the guard, null reaches key.Split and a caller catching the + // documented exception type sees an unhandled NullReferenceException. + var act = () => CacheKey.EnsureValid(key!); + + act.Should().Throw(); + } + [Fact] public void A_Well_Formed_Key_Passes() { - var act = () => CacheKey.EnsureValid(CacheKey.For(Tenant, "tenancy", "settings")); + var act = () => CacheKey.EnsureValid(CacheKey.ForTenant(Tenant, "tenancy", "settings")); act.Should().NotThrow(); } @@ -138,7 +230,7 @@ public void A_Segment_Containing_The_Separator_Is_Refused() { // Otherwise ("a", "b:c") and ("a:b", "c") produce one key — the ambiguity // a delimiter always has when a component can contain it. - var act = () => CacheKey.For(Tenant, "tenancy:nested", "settings"); + var act = () => CacheKey.ForTenant(Tenant, "tenancy:nested", "settings"); act.Should().Throw(); } @@ -149,7 +241,7 @@ public void A_Segment_Containing_The_Separator_Is_Refused() [InlineData(" ")] public void An_Empty_Component_Is_Refused(string? component) { - var act = () => CacheKey.For(Tenant, "tenancy", component!); + var act = () => CacheKey.ForTenant(Tenant, "tenancy", component!); act.Should().Throw(); } diff --git a/docs/architecture/21-feature-flags.md b/docs/architecture/21-feature-flags.md index 7da7e4fb..e1232f3d 100644 --- a/docs/architecture/21-feature-flags.md +++ b/docs/architecture/21-feature-flags.md @@ -161,7 +161,7 @@ Rules: [ADR-0021](../decisions/0021-feature-based-entitlement.md) and [29-dapr-integration.md](29-dapr-integration.md). - A short-TTL Valkey cache (60 s) fronts both tables for hot-path reads. Eager - invalidation flows from `learnstack.cache.invalidation` (intra-instance) and from + invalidation flows from `learnstack.cache.invalidation` (cross-instance) and from `learnstack.hub.entitlement` (cross-deployment). ## Evaluation diff --git a/docs/architecture/29-dapr-integration.md b/docs/architecture/29-dapr-integration.md index ad0c293a..d6820d84 100644 --- a/docs/architecture/29-dapr-integration.md +++ b/docs/architecture/29-dapr-integration.md @@ -243,7 +243,8 @@ cannot be honoured across instances. **Keys are composed by the caller, not by the adapter.** `CacheKey.For(tenantId, module, name)` — or `ForOrganization(...)` — produces the key and `CacheKey.EnsureValid` guards its shape, so an adapter that also prefixed would emit `{tenant}:{tenant}:{module}:{name}`. -Standards 20 § Cache fixes the shape; every implementation validates, none rewrites. +[Standards 20 § `ICacheService`](../standards/20-infrastructure-stack.md) fixes the +shape; every implementation validates, none rewrites. Concrete implementations (`DaprEventBus`, `DaprCacheService`, `DaprSecretProvider`) live in `LearnStack.Infrastructure.{Messaging, Caching, Secrets}`. They are the **only** diff --git a/docs/architecture/32-tenant-customization-model.md b/docs/architecture/32-tenant-customization-model.md index 293fba85..36328c9b 100644 --- a/docs/architecture/32-tenant-customization-model.md +++ b/docs/architecture/32-tenant-customization-model.md @@ -438,11 +438,23 @@ per tenant per month. That ratio is the whole design. | What | Layer | Key | TTL | Invalidated by | |---|---|---|---|---| -| `TenantContentType` set for a tenant | L1 + L2 | `cust:{tenant_id}:content-types:{generation}` | L1 60s, L2 15 min | Generation bump | -| `TenantLevelTaxonomy` by key | L1 + L2 | `cust:{tenant_id}:taxonomy:{key}:{generation}` | same | Generation bump | -| `TenantPageBlock` set | L1 + L2 | `cust:{tenant_id}:blocks:{generation}` | same | Generation bump | +| `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 | | Compiled JSON Schema validator | L1 only, per pod | `(tenant_id, content_type_key, schema_version)` | Process lifetime, bounded LRU | Immutable — a schema version never changes | +These are composed with `CacheKey.For(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. + Two rules make this safe: - **A generation counter, not prefix eviction.** Each tenant carries a diff --git a/docs/glossary.md b/docs/glossary.md index c68fa32c..79891ffc 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -279,7 +279,7 @@ This glossary defines LearnStack-specific terms. When a term is ambiguous across | **One-Way Door** | A decision that gets more expensive with every week of delay, tested by the question: *if I add this six months from now, will I have to touch code that is already written?* **Yes → one-way door; ship it now** (tenant and organization isolation, the `outbox_messages` table and its ownership, strongly-typed identifiers, the localization schema — each touches every query, migration, or job payload ever written). **No → additive; ship the port now and the adapter on demand.** The test is mechanical, not a matter of taste: tenant isolation's cost grows with the codebase, a Dapr adapter's cost does not. A corollary rule: **a deployment mode or customer segment without a signed contract cannot be the deciding factor in a technical choice** — it may break a tie between otherwise-equal options, nothing more. Per [ADR-0035](decisions/0035-demand-gated-infrastructure.md) and [Engineering Principles](standards/00-principles.md). | | **Dapr Building Blocks** | The three Dapr abstractions LearnStack uses **when it uses Dapr**: pub/sub (Kafka), state (Valkey), secrets (Vault) per [ADR-0014](decisions/0014-adopt-dapr.md). Service invocation, workflow, bindings, and actors are out of scope. Demand-gated to [Phase 11](roadmap/phase-11-production-hardening.md); ADR-0014 decides *what*, [ADR-0035](decisions/0035-demand-gated-infrastructure.md) decides *when*. | | **`IEventBus`** | Interface for publishing integration events, taking an explicit `partitionKey`. `InProcessEventBus` is the only registered implementation until the Dapr adapter's trigger fires — and it is a **first-class transport, not a stub**: same `IIntegrationEventHandler`, same `IInboxGuard`, same tenant-context restoration, same per-partition-key ordering as the durable path. The `OutboxProcessor` is the only sanctioned caller. | -| **`ICacheService`** | Interface for cache reads / writes. `InMemoryCacheService` today; a Valkey-backed implementation when more than one instance runs concurrently. Cache keys carry a `{tenant_id}` prefix. `RemoveByPrefixAsync` is **removed** ([ADR-0014 Amendment 2](decisions/0014-adopt-dapr.md)) — it iterated an instance-local key set, so keys written by another instance were never evicted. What replaces it is the **generation-key** pattern, which is a caller-side convention rather than a member of this interface: a durable counter bumped inside the business transaction and embedded in the key template. | +| **`ICacheService`** | Interface for cache reads / writes. `InMemoryCacheService` today; a Valkey-backed implementation when more than one instance runs concurrently. Cache keys lead with the tenant segment — `{tenant_id}:{module}:{logical-name}`, or `{tenant_id}:{organization_id}:{module}:{logical-name}` for a value scoped to one organization — composed by `CacheKey` and enforced by `CacheKey.EnsureValid`, because there is no query filter and no RLS policy in front of a dictionary. `RemoveByPrefixAsync` is **removed** ([ADR-0014 Amendment 2](decisions/0014-adopt-dapr.md)) — it iterated an instance-local key set, so keys written by another instance were never evicted. What replaces it is the **generation-key** pattern, which is a caller-side convention rather than a member of this interface: a durable counter bumped inside the business transaction and embedded in the key template. | | **`ISecretProvider`** | Interface for secret reads. `ConfigurationSecretProvider` today; the Vault-backed implementation when a production secret must rotate without a redeploy, or more than one operator needs access to production secrets ([ADR-0035](decisions/0035-demand-gated-infrastructure.md) — *not* when a non-development deployment merely exists, which SaaS satisfies on day one). Secret namespace `learnstack/{deployment}/{module}/{key}`. | | **`IEntitlementProvider`** | Interface for the Entitlement Projection source. Implementations: `NullEntitlementProvider` (Development only — all features enabled, no limits), `HubEntitlementProvider` (SaaS / Dedicated, from Phase 02c), `SignedLicenseKeyEntitlementProvider` (Self-Hosted; skeleton from Hub `P02c-6`, hardened in Phase 11). The Hub-backed provider resolves in the normative order `L1 → L2 → platform_entitlement_cache → Hub` and never throws out of a feature-flag check. | | **`IHostToTenantResolver`** | Interface for host → `(tenant_id, organization_id?)` resolution. Reads `platform_host_to_tenant` and **nothing else** — never the Hub, because an anonymous page load must not depend on a control plane being reachable ([ADR-0034](decisions/0034-hub-contract-surface-invariant.md)). | From 2998e12dea5dd9156096125ba7e3ad2e0ec22fc2 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Mon, 24 Aug 2026 20:49:55 +0300 Subject: [PATCH 06/21] feat(infra): stop the daily loop starting what nothing can call MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per ADR-0035, Kafka, Valkey, Vault, APISIX and the two Dapr containers move behind a non-default compose profile. `make dev` starts 7 services instead of 14; `make dev-gated` starts all 14. Nothing the backend runs today calls any of them — `IEventBus` resolves to `InProcessEventBus`, `ICacheService` to `InMemoryCacheService`, `ISecretProvider` to `ConfigurationSecretProvider`, and the edge concerns APISIX would take are ASP.NET middleware. Their adapters land in Phase 11 against written triggers. Valkey is gated with them although the README lists it under the data plane: it is the Dapr **state** component, which the roadmap sentence names, and ADR-0035's table gives it the same phase and the same trigger as the rest — more than one application instance running concurrently. Two failure modes decided the shape of this, and both were measured before anything was written. **A profile-less teardown is silently partial.** `docker compose down` leaves running profiled containers behind — `down -v` too, and `--remove-orphans` does not help, because a profiled service is not an orphan, merely unselected. Verified against this stack: after `make dev-gated`, the old `down` left exactly the seven gated containers running and their volumes intact, while `make ps` would have reported the stack down. Every teardown and inspection target now carries `--profile '*'`; re-verified, all 14 containers and 0 volumes remain. **A default service depending on a gated one breaks everything.** Not a warning and not local to the service involved: measured, it is `invalid compose project`, so `config`, `up`, `down` and `ps` all refuse to run — the whole development loop, for every developer, on a one-line edit. Today every edge into a gated service comes from another gated service, and that has to stay true. Nothing was checking it: CI did not validate the compose files at all. It does now, in the meta job, across both profile projections and both overlays — and the guard was confirmed by adding exactly that edge and watching it fail. An overlay cannot un-gate a service, which is worth recording because it looks like it should: `profiles: []` in `e2e.yml` does not clear the profile inherited from `dev.yml` (measured). So `make e2e-up` runs without Kafka and Valkey and their overrides simply do not apply, which is correct — the e2e suite calls neither — and running the overlay with the profile enabled still gets tmpfs for both. No change to `e2e.yml` was needed. Verified end to end: `make dev` brings up 7, `make seed` exits 0 against them, `make dev-gated` brings up 14, `make down` and `make clean` leave nothing. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 25 ++++++++++++++++- Makefile | 40 +++++++++++++++++++++------ docs/roadmap/phase-02b-events-auth.md | 6 ++-- infra/compose/README.md | 39 ++++++++++++++++++++++---- infra/compose/dev.yml | 7 +++++ 5 files changed, 100 insertions(+), 17 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 49bc344c..363fd889 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -196,7 +196,7 @@ jobs: # ─── Meta (commit-message format, link audit) ───────────────────────── meta: - name: meta (commit hygiene + link audit) + name: meta (compose + commit hygiene + link audit) runs-on: ubuntu-latest timeout-minutes: 5 steps: @@ -206,6 +206,29 @@ jobs: fetch-depth: 0 # link audit walks the full tree persist-credentials: false + - name: Compose stack validates, with and without the gated profile + run: | + # Nothing validated these files before, and the profile they now carry + # has one failure mode that is not local to the service involved: + # MEASURED, a DEFAULT service declaring `depends_on` a PROFILED one is + # not a warning but a whole-project error — + # service "x" depends on undefined service "y": invalid compose project + # — so `config`, `up`, `down` and `ps` all refuse to run and the entire + # local development loop stops, for every developer, on a one-line edit. + # The default projection is therefore the one that must be checked; the + # gated one is checked too, since a broken edge there is invisible to it. + cp .env.example .env + for profile in "" "gated"; do + label="${profile:-}" + echo "==> validating profile: ${label}" + COMPOSE_PROFILES="$profile" docker compose --env-file .env \ + -f infra/compose/dev.yml config -q + COMPOSE_PROFILES="$profile" docker compose --env-file .env \ + -f infra/compose/dev.yml -f infra/compose/e2e.yml config -q + done + rm -f .env + echo "Compose files validate in every profile projection." + - name: Markdown link audit (changed docs) # Template values from `github.event.*` are passed through `env:` so # they expand into shell variables AT THE SHELL'S quoting boundary, diff --git a/Makefile b/Makefile index 42e09ba6..70fbc9a5 100644 --- a/Makefile +++ b/Makefile @@ -32,6 +32,22 @@ ENV_FILE = $(shell test -f .env && echo --env-file .env) COMPOSE_DEV = docker compose $(ENV_FILE) -f infra/compose/dev.yml COMPOSE_E2E = docker compose $(ENV_FILE) -f infra/compose/dev.yml -f infra/compose/e2e.yml +# Kafka, Valkey, Vault, APISIX and the two Dapr services sit behind the `gated` +# compose profile per ADR-0035: their ports ship now, their adapters land in +# Phase 11, and nothing the backend runs today calls any of them. `make dev` +# therefore starts 7 services rather than 14. +# +# Every teardown and inspection target uses `--profile '*'`, and that is not +# tidiness. Measured: `docker compose down` without the profile LEAVES a running +# profiled container behind — `down -v` too, and `--remove-orphans` does not help, +# because a profiled service is not an orphan, merely unselected. Without this, +# a developer who ran the gated stack once and then `make clean` would keep a +# Kafka broker, a Vault and their volumes, while `make ps` said the stack was +# down. +GATED_PROFILE = gated +COMPOSE_ALL = docker compose $(ENV_FILE) --profile '*' -f infra/compose/dev.yml +COMPOSE_E2E_ALL = docker compose $(ENV_FILE) --profile '*' -f infra/compose/dev.yml -f infra/compose/e2e.yml + # Colour helpers (no-op when stdout is not a TTY). ifeq ($(shell test -t 1 && echo 1),1) CYAN := \033[36m @@ -48,26 +64,32 @@ help: ## Show this help, listing every target and its one-line description. @awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z0-9_.-]+:.*?## / {printf " $(CYAN)%-18s$(RESET) %s\n", $$1, $$2}' $(MAKEFILE_LIST) # ─── Dev infrastructure ─────────────────────────────────────────────────── -.PHONY: dev -dev: .env ## Bring the local dev stack up (Postgres, Valkey, Keycloak, …). +.PHONY: dev dev-gated +dev: .env ## Bring the local dev stack up (Postgres, Keycloak, SeaweedFS, …). $(COMPOSE_DEV) up -d @printf "\n$(CYAN)Stack up.$(RESET) Tail logs with: make logs\n" + @printf "Kafka, Valkey, Vault, APISIX and Dapr are behind the '$(GATED_PROFILE)' profile — $(CYAN)make dev-gated$(RESET).\n" + +.PHONY: down +dev-gated: .env ## Bring the dev stack up INCLUDING the demand-gated services (Kafka, Valkey, Vault, APISIX, Dapr). + COMPOSE_PROFILES=$(GATED_PROFILE) $(COMPOSE_DEV) up -d + @printf "\n$(CYAN)Full stack up.$(RESET) Nothing the backend runs today calls these — see ADR-0035.\n" .PHONY: down -down: ## Stop the dev stack (preserves volumes). - $(COMPOSE_DEV) down +down: ## Stop the dev stack, gated services included (preserves volumes). + $(COMPOSE_ALL) down .PHONY: clean clean: ## Stop the dev stack AND drop named volumes (destructive — wipes data). - $(COMPOSE_DEV) down -v + $(COMPOSE_ALL) down -v .PHONY: logs logs: ## Tail compose logs (Ctrl+C to detach). - $(COMPOSE_DEV) logs -f --tail=100 + $(COMPOSE_ALL) logs -f --tail=100 .PHONY: ps -ps: ## Show service health summary. - $(COMPOSE_DEV) ps +ps: ## Show service health summary, gated services included. + $(COMPOSE_ALL) ps .PHONY: e2e-up e2e-up: .env ## Bring the dev stack up with the e2e overlay (tmpfs volumes — ephemeral). @@ -76,7 +98,7 @@ e2e-up: .env ## Bring the dev stack up with the e2e overlay (tmpfs volumes — e .PHONY: e2e-down e2e-down: ## Stop the e2e overlay (tmpfs volumes evaporate automatically). - $(COMPOSE_E2E) down + $(COMPOSE_E2E_ALL) down # ─── Build ──────────────────────────────────────────────────────────────── .PHONY: build diff --git a/docs/roadmap/phase-02b-events-auth.md b/docs/roadmap/phase-02b-events-auth.md index 76fcafff..6fdc53ff 100644 --- a/docs/roadmap/phase-02b-events-auth.md +++ b/docs/roadmap/phase-02b-events-auth.md @@ -36,8 +36,10 @@ The Dapr pub/sub and Kafka adapters are **not in this phase**. They are demand-g [ADR-0035](../decisions/0035-demand-gated-infrastructure.md), whose trigger is "a second process needs to consume an integration event". Until that is true, a cross-process broker moves an event from one thread to another thread in the same process, through two -network hops and a serialization boundary, and adds a fourteenth service to the -development loop. [ADR-0006 Amendment 1](../decisions/0006-events-and-outbox.md) and +network hops and a serialization boundary, for a service the daily loop no longer +starts — Packet 5 moved Kafka, Valkey, Vault, APISIX and the two Dapr containers +behind the `gated` compose profile, taking `make dev` from fourteen services to +seven. [ADR-0006 Amendment 1](../decisions/0006-events-and-outbox.md) and [ADR-0014](../decisions/0014-adopt-dapr.md) remain the decision about **which** transport LearnStack uses when it needs one; ADR-0035 decides **when**, and the answer is not this phase. diff --git a/infra/compose/README.md b/infra/compose/README.md index e285a6e5..7b13ac84 100644 --- a/infra/compose/README.md +++ b/infra/compose/README.md @@ -54,7 +54,21 @@ LiveKit config at `infra/livekit/livekit.yaml`; Coturn config at for the `ILiveClassProvider` integration plan (Phase 08c) + the recording / consent / cost-tracking story. -### Eventing + secrets + Dapr sidecar + gateway (Phase 01 packet 6) +### Eventing + secrets + Dapr sidecar + gateway (Phase 01 packet 6) — profile `gated` + +**These do not start with `make dev`.** Nothing the backend runs today calls any +of them: `IEventBus` resolves to `InProcessEventBus`, `ICacheService` to +`InMemoryCacheService`, `ISecretProvider` to `ConfigurationSecretProvider`, and +the edge concerns APISIX would take are ASP.NET middleware. Their adapters land +in [Phase 11](../../docs/roadmap/phase-11-production-hardening.md) against the +written triggers in +[ADR-0035](../../docs/decisions/0035-demand-gated-infrastructure.md), so until +then they are seven containers of carrying cost. `make dev` starts **7** +services; `make dev-gated` starts all **14**. + +Valkey is here too, though it is listed under the data plane above: it is the +Dapr state component, and the same trigger governs it — more than one +application instance running concurrently. | Service | Image | Local endpoint | Default credentials | |---------|-------|----------------|---------------------| @@ -100,18 +114,33 @@ reach the workstation-local `dotnet run` process. The ```bash # Repo-root orchestrator (preferred): -make dev # bring stack up +make dev # 7 services — the daily loop +make dev-gated # all 14, gated ones included make ps # confirm healthchecks pass make down # stop, keep volumes make clean # stop, wipe local data # Raw compose (equivalent — useful when `make` is unavailable): docker compose --env-file .env -f infra/compose/dev.yml up -d -docker compose --env-file .env -f infra/compose/dev.yml ps -docker compose --env-file .env -f infra/compose/dev.yml down -docker compose --env-file .env -f infra/compose/dev.yml down -v +COMPOSE_PROFILES=gated docker compose --env-file .env -f infra/compose/dev.yml up -d +docker compose --env-file .env --profile '*' -f infra/compose/dev.yml ps +docker compose --env-file .env --profile '*' -f infra/compose/dev.yml down +docker compose --env-file .env --profile '*' -f infra/compose/dev.yml down -v ``` +> **`--profile '*'` on every teardown is not tidiness.** Measured: `docker +> compose down` without it LEAVES a running profiled container behind — `down -v` +> too, and `--remove-orphans` does not help, because a profiled service is not an +> orphan, merely unselected. Verified against this stack: after `make dev-gated`, +> a profile-less `down` left exactly the seven gated containers running while +> `make ps` would have reported the stack down. The `make` targets all carry it. +> +> The reverse direction is a harder failure and the reason no default service may +> ever `depends_on` a gated one: measured, that combination is not a warning but a +> **whole-project validation error** — `config`, `up`, `down` and `ps` all refuse +> to run, not just the service involved. Today every edge into a gated service +> comes from another gated service, and it has to stay that way. + ## `e2e.yml` — end-to-end overlay Layered on top of `dev.yml` to swap durable named volumes for tmpfs, so diff --git a/infra/compose/dev.yml b/infra/compose/dev.yml index c59b5f56..86384e32 100644 --- a/infra/compose/dev.yml +++ b/infra/compose/dev.yml @@ -93,6 +93,7 @@ services: # The service name is `valkey` so a new reader sees the right backend; # `redis-cli` continues to ship inside the Valkey image as a symlink. valkey: + profiles: ["gated"] image: valkey/valkey:8.1-alpine restart: unless-stopped command: ["valkey-server", "--appendonly", "yes"] @@ -317,6 +318,7 @@ services: # shipped as canonical (see infra/compose/README.md § Eventing). An EXTERNAL # listener for host-side kcat / kafka-topics is a Phase 11 item. kafka: + profiles: ["gated"] image: confluentinc/cp-kafka:8.2.1 restart: unless-stopped environment: @@ -357,6 +359,7 @@ services: # Standards 12 § Image Conventions (every dev image carries an explicit # tag; bump deliberately, never let `:latest` drift). kafka-ui: + profiles: ["gated"] image: ghcr.io/kafbat/kafka-ui:v1.5.0 restart: unless-stopped environment: @@ -394,6 +397,7 @@ services: # endpoints at the next `docker compose up`. See `infra/dapr/README.md` # § Vault token for the full chain (Phase 01 packet 7 DX commitment). vault: + profiles: ["gated"] image: hashicorp/vault:1.21.4 restart: unless-stopped command: ["server", "-dev", "-dev-root-token-id=${VAULT_ROOT_TOKEN:-learnstack-dev-root-token}", "-dev-listen-address=0.0.0.0:8200"] @@ -416,6 +420,7 @@ services: # Placement service required by daprd even though actors are out of scope # per ADR-0014 non-goals (daprd will not boot without it). dapr-placement: + profiles: ["gated"] image: daprio/placement:1.17.7 restart: unless-stopped command: ["./placement", "-log-level", "info"] @@ -438,6 +443,7 @@ services: # anchor at the top of the file maps `host.docker.internal` on Linux. # See ../dapr/README.md. dapr-sidecar-api: + profiles: ["gated"] image: daprio/daprd:1.17.7 restart: unless-stopped # no healthcheck possible: same single-binary image family as @@ -494,6 +500,7 @@ services: # the standalone-no-etcd commitment of ADR-0015. Diff-review of # `apisix.yaml` replaces the dashboard for the dev workflow. apisix: + profiles: ["gated"] image: apache/apisix:3.16.0-debian restart: unless-stopped <<: *host-gateway From 2e439b7adbd9ffabaaeb63f93c786421c7deb9d2 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Tue, 25 Aug 2026 09:15:36 +0300 Subject: [PATCH 07/21] fix(kernel): stop the ceiling crashing the writers it protects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An adversarial pass over the redesigned cache. Three of its four lenses independently found the same defect, and it was mine: the bound I added two commits ago to stop the map growing without limit made ordinary concurrent writes throw. **Eviction sorted the live dictionary.** `_entries.OrderBy(p => p.Value.Sequence).Take(excess)` buffers a `ConcurrentDictionary` through `ICollection.CopyTo` after reading `Count`, and those two steps are not atomic. Grew in between: `CopyTo` throws `ArgumentException`. Shrank: the buffer's tail keeps a `default(KeyValuePair)` whose `Value` is null, and the sort key dereferences it. Both escaped `Trim` into `SetAsync` and `GetOrSetAsync`. Measured, ordinary usage, distinct keys, no misuse: **two** concurrent writers at the ceiling failed 4.1% of writes, four failed 15.5%. Through the read-through path with more threads, other measurements reached ~40%. A component whose contract is that it may no-op at any time was instead failing the caller's request — and in `GetOrSetAsync` the throw lands after the factory has already run, so the caller pays the round trip, the value is in the cache, and it still gets a `NullReferenceException`. The whole suite stayed green because every test drove eviction from one thread with `await` in a `for` loop. `ConcurrentDictionary.ToArray()` takes every bucket lock and returns a consistent snapshot; measured, 0 failures over the same probe where LINQ over the live map failed 78 times in 3,000. **The same line was also a throughput cliff, single-threaded.** At the ceiling — the steady state of an unbounded key space, which is the workload the ceiling exists for — `Trim` ran on every write and sorted all ten thousand entries to drop one. Evicting to a low-water mark of 90% instead pays that cost once per thousand writes. Measured end to end: 0.26 ms/write to **0.0072**, 281 KB/write to **1.2**, and a probe that failed 15.5% of writes now fails none. **A flight that never completed poisoned its key forever.** `Retire` required the factory to have finished, and nothing can impose a deadline on one — the flight runs on `CancellationToken.None` by design, so a single caller cannot cancel it for the rest. So a factory that hung left its registration in `_inFlight`, which has no ceiling, and every later caller *joined* that dead flight and waited on a task that would never complete. The key never ran a factory again, once per generic instantiation. Retiring now turns on the caller count alone: with nobody left there is nothing to stampede, so a fresh arrival starting its own flight is right. **A caller arriving after `RemoveAsync` returned got the pre-Remove value.** `Supersede` only stopped a flight from *storing*; nothing stopped a new caller from *joining* one. That caller missed `_entries` — the Remove had emptied it — joined the doomed flight, and was handed the value the invalidation existed to kill, its own factory never invoked. A superseded flight is no longer joinable. Callers already in flight when the write landed keep their result: that is an ordinary race, and arriving afterwards is not. **An abandoned faulted flight raised UnobservedTaskException.** The correlated failure — a factory faults when a dependency is down, and a dependency being down is when clients disconnect, which is the 499 case the cancellation design was written for. Measured: 20 of 20 abandoned faulted flights raised the event; with the completion continuation observing the fault, 0 of 20. A host with `ThrowUnobservedTaskExceptions` terminates on it. **Two of the five key families Standards 20 mandates could not be built.** `platform:hub:host-map:{host}` and `{tenant_id}:identity:permissions:{session_id}` have structured logical names, and no factory took more than one part — while `Compose` rejects a caller joining parts itself, since that puts a separator inside a segment. The guard therefore admitted a shape no factory could emit, so the host lookup, which sits on the anonymous page-load path, would have been hand-built past the one place `Guid.Empty`, non-canonical rendering and separator injection are checked. A test even blessed the practice. The factories now take multi-part logical names, Standards 20 records the call for each family, and the test asserts all five compose and validate. **The guard checked only segment 0.** An organization-scoped key puts an identifier in position 1 and a logical name may carry one anywhere after that, so `Guid.Empty`, an uppercase rendering and a padded one all passed in the organization slot while the factory door rejected every one — the one-door asymmetry the all-zero-tenant test exists to forbid, one scope down, where it collapses every organization of a tenant into one bucket. Any segment that parses as an identifier must now be a canonical non-empty one, and a `platform` sentinel followed by an identifier is refused outright: Standards 20 calls that "a bug wearing the sentinel's clothes" and now the code agrees. Every fix has a test that fails when the code it covers is removed, all verified by mutation. One of those mutants only appeared to survive because it did not compile — a build failure looked like a passing suite, which the harness now reports separately. 646 tests green, 0 warnings under CI=true, 10 consecutive runs stable. Co-Authored-By: Claude Opus 5 (1M context) --- .../Caching/InMemoryCacheService.cs | 131 +++++++++-- .../Caching/CacheKey.cs | 78 ++++++- .../Caching/InMemoryCacheServiceTests.cs | 210 +++++++++++++++++- .../SharedKernel/Caching/CacheKeyTests.cs | 80 ++++++- docs/standards/20-infrastructure-stack.md | 26 ++- 5 files changed, 487 insertions(+), 38 deletions(-) diff --git a/backend/src/LearnStack.Infrastructure/Caching/InMemoryCacheService.cs b/backend/src/LearnStack.Infrastructure/Caching/InMemoryCacheService.cs index 50352aba..96653922 100644 --- a/backend/src/LearnStack.Infrastructure/Caching/InMemoryCacheService.cs +++ b/backend/src/LearnStack.Infrastructure/Caching/InMemoryCacheService.cs @@ -59,6 +59,19 @@ public sealed class InMemoryCacheService(IClock clock) : ICacheService /// public const int MaxEntries = 10_000; + /// + /// What a trim evicts down to, rather than back to . + /// + /// + /// Without this gap the steady state of an unbounded key space — the exact + /// workload the ceiling exists for — is a trim on every write, each one + /// evicting a single entry. Measured on that version: 0.26 ms and 281 KB of + /// garbage per write, because evicting one entry copied and sorted all ten + /// thousand. Evicting a tenth of the map at once pays that cost once per + /// thousand writes instead of once per write. + /// + public const int TrimTarget = MaxEntries * 9 / 10; + private readonly ConcurrentDictionary _entries = new(StringComparer.Ordinal); /// @@ -80,6 +93,9 @@ public sealed class InMemoryCacheService(IClock clock) : ICacheService /// private long _sequence; + /// 1 while a trim is running. A field, for Interlocked. + private int _trimming; + /// /// How many entries the map currently holds, expired-but-unreclaimed ones /// included. @@ -161,7 +177,26 @@ public async Task GetOrSetAsync( LazyThreadSafetyMode.ExecutionAndPublication)); var registration = (key, typeof(T)); - var flight = _inFlight.GetOrAdd(registration, mine); + + // A flight that a write has ALREADY superseded must not be joined. It is + // only stopped from storing, so a caller whose GetOrSetAsync begins + // strictly after RemoveAsync returned would otherwise miss _entries — + // the Remove emptied it — join the doomed flight, and be answered with + // the value the invalidation existed to kill, its own factory never run. + // Its callers keep their own reference and still get their result; they + // were already in flight when the write landed, which is an ordinary + // race. Arriving afterwards is not. + Flight flight; + while (true) + { + flight = _inFlight.GetOrAdd(registration, mine); + if (ReferenceEquals(flight, mine) || !flight.Superseded) + { + break; + } + + _inFlight.TryRemove(new KeyValuePair<(string, Type), Flight>(registration, flight)); + } // Registered before anything can observe the count, so the completion // continuation below never sees a zero that is about to become one. @@ -175,7 +210,19 @@ public async Task GetOrSetAsync( // before the factory finished, so no `finally` runs again to // notice the flight is done. _ = flight.Task.Value.ContinueWith( - _ => Retire(registration, flight), + completed => + { + // Touching Exception marks the fault observed. Without + // it, a factory that faults after every caller has + // abandoned its flight — the correlated failure, since a + // dependency being down is exactly when clients + // disconnect — leaves the task unobserved, and + // TaskScheduler.UnobservedTaskException fires once per + // key with no request, no span and no correlation id + // attached to it. + _ = completed.Exception; + Retire(registration, flight); + }, CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); @@ -226,6 +273,17 @@ public async Task GetOrSetAsync( // // The registration is what `Supersede` reaches, so it has to outlive // the store, and it has to outlive every other caller's store too. + // + // Retiring turns only on the caller count, NOT on the factory having + // finished. An earlier version also required IsCompleted, which meant + // a factory that never completes — no deadline exists anywhere, since + // the flight deliberately runs on CancellationToken.None so one + // caller cannot cancel it for the rest — left its registration in + // place forever. `_inFlight` has no ceiling, and worse, every later + // caller JOINED that dead flight: the key never ran a factory again + // for the life of the process, once per generic instantiation. With + // no callers left there is nothing to stampede, so a fresh arrival + // starting its own flight is right. if (Interlocked.Decrement(ref flight.Waiters) == 0) { Retire(registration, flight); @@ -240,10 +298,12 @@ public async Task GetOrSetAsync( /// private void Retire((string Key, Type Type) registration, Flight flight) { - if (Volatile.Read(ref flight.Waiters) == 0 && flight.Task.Value.IsCompleted) + if (Volatile.Read(ref flight.Waiters) != 0) { - _inFlight.TryRemove(new KeyValuePair<(string, Type), Flight>(registration, flight)); + return; } + + _inFlight.TryRemove(new KeyValuePair<(string, Type), Flight>(registration, flight)); } public Task SetAsync( @@ -327,26 +387,61 @@ private void Store(string key, T value, CacheOptions? options, DateTimeOffset /// private void Trim(DateTimeOffset now) { - foreach (var pair in _entries) + // One trimmer at a time. Concurrent writers all cross the ceiling + // together, and without this each of them snapshots and sorts the whole + // map to do work the first one is already doing. + if (Interlocked.CompareExchange(ref _trimming, 1, 0) != 0) { - if (!pair.Value.IsFresh(now)) - { - // Value-comparing: between the enumerator observing an expired - // entry and this line, another thread may have written a fresh - // one at the same key. - _entries.TryRemove(pair); - } + return; } - var excess = _entries.Count - MaxEntries; - if (excess <= 0) + try { - return; - } + // ToArray(), NOT LINQ over `_entries` directly. Measured: an + // `_entries.OrderBy(...)` buffers the LIVE dictionary through + // ICollection.CopyTo after reading Count, and those two steps are not + // atomic — if the map grew in between, CopyTo throws + // ArgumentException; if it shrank, the tail of the buffer keeps + // default(KeyValuePair) whose Value is null and the sort key + // dereferences it. Both escaped Trim into SetAsync and + // GetOrSetAsync, so a component whose contract says it may no-op at + // any time was instead failing the caller's request: with two + // concurrent writers at the ceiling, 4.1% of ordinary writes threw; + // with four, 15.5%. ToArray takes every bucket lock and hands back a + // consistent snapshot — measured at 0 failures over the same probe. + var snapshot = _entries.ToArray(); + var live = new List>(snapshot.Length); + + foreach (var pair in snapshot) + { + if (pair.Value.IsFresh(now)) + { + live.Add(pair); + } + else + { + // Value-comparing: between the snapshot and this line another + // thread may have written a fresh entry at the same key. + _entries.TryRemove(pair); + } + } - foreach (var pair in _entries.OrderBy(pair => pair.Value.Sequence).Take(excess)) + var excess = live.Count - TrimTarget; + if (excess <= 0) + { + return; + } + + live.Sort(static (left, right) => left.Value.Sequence.CompareTo(right.Value.Sequence)); + + for (var i = 0; i < excess; i++) + { + _entries.TryRemove(live[i]); + } + } + finally { - _entries.TryRemove(pair); + Volatile.Write(ref _trimming, 0); } } diff --git a/backend/src/LearnStack.SharedKernel/Caching/CacheKey.cs b/backend/src/LearnStack.SharedKernel/Caching/CacheKey.cs index 0096cc77..5462d658 100644 --- a/backend/src/LearnStack.SharedKernel/Caching/CacheKey.cs +++ b/backend/src/LearnStack.SharedKernel/Caching/CacheKey.cs @@ -40,8 +40,8 @@ public static class CacheKey /// strings. With all three factories naming their scope, choosing one is a /// decision rather than a habit. /// - public static string ForTenant(Guid tenantId, string module, string logicalName) => - For(Canonical(tenantId, nameof(tenantId)), module, logicalName); + public static string ForTenant(Guid tenantId, string module, params string[] logicalName) => + Compose(Canonical(tenantId, nameof(tenantId)), module, logicalName); /// /// Composes a key for a value scoped to one organization within a tenant: @@ -58,16 +58,30 @@ public static string ForTenant(Guid tenantId, string module, string logicalName) /// rather than being left to each call site to spell. /// public static string ForOrganization( - Guid tenantId, Guid organizationId, string module, string logicalName) => - For( + Guid tenantId, Guid organizationId, string module, params string[] logicalName) => + Compose( Canonical(tenantId, nameof(tenantId)), Canonical(organizationId, nameof(organizationId)), module, logicalName); /// Composes a key for a platform-wide value. - public static string ForPlatform(string module, string logicalName) => - For(PlatformTenant, module, logicalName); + /// + /// The logical name may be several parts, and that is not a convenience. + /// Standards 20 mandates key families whose logical name has internal + /// structure — platform:hub:host-map:{host} and + /// {tenant_id}:identity:permissions:{session_id} — and a single-string + /// factory could not produce either of them, because a caller joining the + /// parts itself would put a separator inside one segment and + /// rejects exactly that. The guard would then have + /// admitted a shape no factory could emit, so the two families Standards 20 + /// singles out — including the host lookup, which sits on the anonymous + /// page-load path — would have been hand-built past the only place + /// , non-canonical rendering and separator injection + /// are checked. + /// + public static string ForPlatform(string module, params string[] logicalName) => + Compose(PlatformTenant, module, logicalName); /// /// Throws when a key does not carry three non-empty segments. @@ -85,7 +99,10 @@ public static void EnsureValid(string key) var segments = key.Split(Separator); var wellFormed = segments.Length >= 3 && !segments.Any(string.IsNullOrWhiteSpace) - && IsTenantSegment(segments[0]); + && IsTenantSegment(segments[0]) + && segments.All(IsCanonicalIfIdentifier) + && !(segments[0].Equals(PlatformTenant, StringComparison.Ordinal) + && LooksLikeIdentifier(segments[1])); if (!wellFormed) { @@ -109,6 +126,25 @@ public static void EnsureValid(string key) /// tenant segment is mandatory. A guard that admits the shape it exists to /// reject is worse than none — it makes the rule look enforced. /// + /// + /// Whether a segment that looks like an identifier is a well-formed one. + /// + /// + /// The tenant segment is not the only one that carries an id. An + /// organization-scoped key puts one in position 1, and a logical name may + /// carry a session or entity id anywhere after that — and only segment 0 used + /// to be checked, so , an uppercase rendering and a + /// padded one all passed in the organization slot while the factory door + /// rejected every one of them. A rule that holds at one of two doors is the + /// asymmetry the all-zero-tenant test exists to forbid, one scope down. + /// + private static bool IsCanonicalIfIdentifier(string segment) => + !Guid.TryParse(segment, out var id) + || (id != Guid.Empty && segment.Equals(id.ToString(), StringComparison.Ordinal)); + + /// Whether a segment parses as an identifier at all. + private static bool LooksLikeIdentifier(string segment) => Guid.TryParse(segment, out _); + private static bool IsTenantSegment(string segment) => segment.Equals(PlatformTenant, StringComparison.Ordinal) || (Guid.TryParse(segment, out var id) @@ -152,11 +188,31 @@ private static string Canonical(Guid id, string parameterName) return id.ToString(); } - private static string For(string tenant, string module, string logicalName) => - Compose([tenant, module, logicalName]); + private static string Compose(string tenant, string module, string[] logicalName) + { + ArgumentNullException.ThrowIfNull(logicalName); + + if (logicalName.Length == 0) + { + throw new ArgumentException( + "A cache key needs a logical name.", nameof(logicalName)); + } + + return Compose([tenant, module, .. logicalName]); + } + + private static string Compose(string tenant, string org, string module, string[] logicalName) + { + ArgumentNullException.ThrowIfNull(logicalName); - private static string For(string tenant, string org, string module, string logicalName) => - Compose([tenant, org, module, logicalName]); + if (logicalName.Length == 0) + { + throw new ArgumentException( + "A cache key needs a logical name.", nameof(logicalName)); + } + + return Compose([tenant, org, module, .. logicalName]); + } private static string Compose(string[] segments) { diff --git a/backend/tests/LearnStack.Tests.Unit/Infrastructure/Caching/InMemoryCacheServiceTests.cs b/backend/tests/LearnStack.Tests.Unit/Infrastructure/Caching/InMemoryCacheServiceTests.cs index f55fdca3..414b2f94 100644 --- a/backend/tests/LearnStack.Tests.Unit/Infrastructure/Caching/InMemoryCacheServiceTests.cs +++ b/backend/tests/LearnStack.Tests.Unit/Infrastructure/Caching/InMemoryCacheServiceTests.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using FluentAssertions; using LearnStack.Infrastructure.Caching; using LearnStack.SharedKernel.Caching; @@ -251,9 +252,14 @@ public async Task The_Map_Is_Bounded_Even_When_The_Clock_Never_Moves() await cache.SetAsync(CacheKey.ForTenant(Tenant, "tenancy", $"k{i:D6}"), i, ttl); } - cache.Count.Should().Be(InMemoryCacheService.MaxEntries, - "the ceiling is a count, so that is what the test asserts — and " - + "exactly, because a bound that over-evicts is also a defect"); + // Between the low-water mark and the ceiling. A trim evicts down to + // TrimTarget rather than back to MaxEntries, deliberately: without that + // gap the steady state of an unbounded key space is a trim on every + // single write, each one copying and sorting the whole map to drop one + // entry. Both ends are asserted, because "bounded" that never evicts + // and "bounded" that empties itself are both wrong. + cache.Count.Should().BeInRange( + InMemoryCacheService.TrimTarget, InMemoryCacheService.MaxEntries); (await cache.GetAsync(CacheKey.ForTenant(Tenant, "tenancy", "k000000"))) .Should().BeNull("the oldest entries are the ones the bound drops"); @@ -283,6 +289,59 @@ public async Task Replacing_A_Key_Does_Not_Grow_The_Map() (await cache.GetAsync(Key())).Should().Be((InMemoryCacheService.MaxEntries * 2) - 1); } + [Fact] + public async Task Writing_At_The_Ceiling_From_Several_Threads_Throws_Nothing() + { + // The eviction pass used to run LINQ over the LIVE dictionary, which + // buffers it through ICollection.CopyTo after reading Count — two steps + // that are not atomic. Grow in between and CopyTo throws + // ArgumentException; shrink and the buffer's tail keeps a default + // KeyValuePair whose Value is null, which the sort key dereferences. + // Both escaped into SetAsync and GetOrSetAsync. Measured on that + // version: two concurrent writers were enough — 4.1% of ordinary writes + // threw, four writers 15.5% — and the whole existing suite stayed green, + // because every other test drives the eviction from one thread with + // `await` in a `for` loop. + // + // A component whose contract is that it may no-op at any time must never + // fail the caller's request. This asserts exactly that, and nothing about + // which entries survive. + var (cache, _) = New(); + var ttl = new CacheOptions(L1Ttl: TimeSpan.FromDays(30)); + + for (var i = 0; i < InMemoryCacheService.MaxEntries; i++) + { + await cache.SetAsync(CacheKey.ForTenant(Tenant, "tenancy", $"warm{i:D6}"), i, ttl); + } + + var failures = new ConcurrentBag(); + using var start = new ManualResetEventSlim(false); + + var writers = Enumerable.Range(0, 4).Select(t => Task.Run(async () => + { + start.Wait(TestTimeout); + for (var i = 0; i < 1_500; i++) + { + try + { + await cache.SetAsync( + CacheKey.ForTenant(Tenant, "tenancy", $"t{t}k{i:D6}"), i, ttl); + } + catch (Exception ex) + { + failures.Add(ex); + } + } + })).ToArray(); + + start.Set(); + await Task.WhenAll(writers); + + failures.Should().BeEmpty( + "eviction is the cache's own business and never the caller's error"); + cache.Count.Should().BeLessThanOrEqualTo(InMemoryCacheService.MaxEntries); + } + // ---- cancellation is not contagious ------------------------------------- [Fact] @@ -394,6 +453,151 @@ async Task Factory(CancellationToken _) runs.Should().Be(1, "one factory run per key, however many callers come and go"); } + [Fact] + public async Task A_Flight_Everyone_Abandons_Does_Not_Poison_Its_Key() + { + // Retiring used to require the factory to have COMPLETED. Nothing can + // impose a deadline on it — the flight deliberately runs on + // CancellationToken.None so one caller cannot cancel it for the rest — + // so a factory that never finishes left its registration in place for + // the life of the process. `_inFlight` has no ceiling, and worse, every + // later caller JOINED that dead flight and waited on a task that would + // never complete. The key never ran a factory again. + var (cache, _) = New(); + using var never = new SemaphoreSlim(0); + using var abandoning = new CancellationTokenSource(); + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var abandoned = cache.GetOrSetAsync(Key(), async _ => + { + entered.TrySetResult(); + await never.WaitAsync(TestTimeout, CancellationToken.None); + return "never-arrives"; + }, null, abandoning.Token); + + await entered.Task.WaitAsync(TestTimeout); + await abandoning.CancelAsync(); + await ((Func)(() => abandoned)).Should().ThrowAsync(); + + cache.InFlightCount.Should().Be(0, "no caller is left, so nothing is in flight"); + + // The key must still work. Without the fix this call joins the dead + // flight and hangs until its own token fires. + var next = cache.GetOrSetAsync(Key(), _ => Task.FromResult("fresh")); + + (await next.WaitAsync(TestTimeout)).Should().Be("fresh"); + never.Release(); + } + + [Fact] + public async Task A_Caller_Arriving_After_A_Remove_Does_Not_Join_The_Doomed_Flight() + { + // Supersede only stops a flight from STORING. A caller whose + // GetOrSetAsync begins strictly after RemoveAsync returned would + // otherwise miss _entries — the Remove emptied it — join the doomed + // flight, and be handed the value the invalidation existed to kill, + // its own factory never invoked. Callers already in flight when the + // write landed are a different case: that is an ordinary race, and they + // keep their result. + var (cache, _) = New(); + using var release = new SemaphoreSlim(0); + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var inFlight = cache.GetOrSetAsync(Key(), async _ => + { + entered.TrySetResult(); + await release.WaitAsync(TestTimeout, CancellationToken.None); + return "before-the-remove"; + }); + + await entered.Task.WaitAsync(TestTimeout); + await cache.RemoveAsync(Key()); + + var afterwards = await cache.GetOrSetAsync(Key(), _ => Task.FromResult("after-the-remove")); + + afterwards.Should().Be("after-the-remove", + "it started after the invalidation, so it reads the source of truth"); + + release.Release(); + (await inFlight).Should().Be("before-the-remove", + "the caller already in flight still gets what it asked for"); + } + + [Fact] + public async Task A_Faulted_Flight_Nobody_Awaits_Leaves_No_Unobserved_Exception() + { + // The correlated failure: a factory faults when a dependency is down, + // and a dependency being down is exactly when clients time out and + // disconnect. With every caller gone nobody awaits the task, so its + // exception goes unobserved and TaskScheduler.UnobservedTaskException + // fires — with no request, no span and no correlation id attached, and + // a host configured with ThrowUnobservedTaskExceptions terminates on it. + // Measured on the shape this reproduces: 20 of 20 abandoned faulted + // flights raised the event without the observation, 0 of 20 with it. + // + // The event is process-global and xUnit runs classes in parallel, so + // only this test's own sentinel is counted. Several rounds are run + // because the event fires on FINALIZATION, which one collection does + // not reliably reach. + const string Sentinel = "learnstack-cache-unobserved-probe"; + var mine = new ConcurrentBag(); + + void Handler(object? sender, UnobservedTaskExceptionEventArgs e) + { + if (e.Exception.Flatten().InnerExceptions + .Any(inner => inner.Message == Sentinel)) + { + mine.Add(e.Exception); + e.SetObserved(); + } + } + + TaskScheduler.UnobservedTaskException += Handler; + try + { + for (var round = 0; round < 10; round++) + { + var (cache, _) = New(); + using var fail = new SemaphoreSlim(0); + using var abandoning = new CancellationTokenSource(); + var entered = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + + var abandoned = cache.GetOrSetAsync(Key(), async _ => + { + entered.TrySetResult(); + await fail.WaitAsync(TestTimeout, CancellationToken.None); + throw new InvalidOperationException(Sentinel); + }, null, abandoning.Token); + + await entered.Task.WaitAsync(TestTimeout); + await abandoning.CancelAsync(); + await ((Func)(() => abandoned)) + .Should().ThrowAsync(); + + fail.Release(); + await Task.Delay(TimeSpan.FromMilliseconds(50)); + } + + for (var collection = 0; collection < 4; collection++) + { + GC.Collect(); + GC.WaitForPendingFinalizers(); + } + + await Task.Delay(TimeSpan.FromMilliseconds(300)); + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + + mine.Should().BeEmpty("the flight observes its own fault"); + } + finally + { + TaskScheduler.UnobservedTaskException -= Handler; + } + } + [Fact] public async Task A_Finished_Flight_Is_Unregistered() { diff --git a/backend/tests/LearnStack.Tests.Unit/SharedKernel/Caching/CacheKeyTests.cs b/backend/tests/LearnStack.Tests.Unit/SharedKernel/Caching/CacheKeyTests.cs index 33079509..ac285b00 100644 --- a/backend/tests/LearnStack.Tests.Unit/SharedKernel/Caching/CacheKeyTests.cs +++ b/backend/tests/LearnStack.Tests.Unit/SharedKernel/Caching/CacheKeyTests.cs @@ -115,16 +115,86 @@ public void The_Platform_Sentinel_Is_A_Tenant_Segment() } [Fact] - public void A_Key_May_Carry_More_Than_Three_Segments() + public void A_Structured_Logical_Name_Is_Composed_Not_Hand_Joined() { - // The logical name is the caller's to structure — "settings:theme:dark" - // is one name with internal structure, not a violation. What is fixed is - // that the FIRST segment identifies the tenant. - var act = () => CacheKey.EnsureValid($"{Tenant}:tenancy:settings:theme"); + // An earlier version of this test asserted only that EnsureValid ACCEPTS + // a four-segment key, and said the caller could structure its own + // logical name. Compose forbids exactly that — a caller joining parts + // with the separator puts one inside a segment — so the guard blessed a + // shape no factory could emit, and the two key families Standards 20 + // mandates with structured names would have been hand-built past it. + CacheKey.ForTenant(Tenant, "tenancy", "settings", "theme") + .Should().Be($"{Tenant}:tenancy:settings:theme"); + + var act = () => CacheKey.EnsureValid( + CacheKey.ForTenant(Tenant, "tenancy", "settings", "theme")); act.Should().NotThrow(); } + [Fact] + public void The_Key_Families_Standards_20_Mandates_Are_All_Composable() + { + // Every family in Standards 20 § ICacheService, built through the + // factory and passed through the guard. Two of them are four-segment + // keys whose fourth segment is not an organization id, and no factory + // could produce either before multi-part logical names existed. + var session = Guid.Parse("018f4d40-0000-7000-8000-0000000000f1"); + + var families = new[] + { + CacheKey.ForPlatform("hub", "host-map", "school.example.com"), + CacheKey.ForTenant(Tenant, "hub", "entitlement"), + CacheKey.ForTenant(Tenant, "tenancy", "feature-flags"), + CacheKey.ForTenant(Tenant, "identity", "permissions", session.ToString()), + CacheKey.ForTenant(Tenant, "tenancy", "settings"), + }; + + foreach (var key in families) + { + var act = () => CacheKey.EnsureValid(key); + act.Should().NotThrow($"'{key}' is a family the standard mandates"); + } + + families[0].Should().Be("platform:hub:host-map:school.example.com"); + families[3].Should().Be($"{Tenant}:identity:permissions:{session}"); + } + + [Fact] + public void A_Logical_Name_With_No_Parts_Is_Refused() + { + var act = () => CacheKey.ForTenant(Tenant, "tenancy"); + + act.Should().Throw(); + } + + [Theory] + [InlineData("00000000-0000-0000-0000-000000000000", "an unresolved organization")] + [InlineData("018F4D40-0000-7000-8000-0000000000C1", "an uppercase rendering")] + [InlineData(" 018f4d40-0000-7000-8000-0000000000c1", "a padded rendering")] + public void An_Identifier_Segment_Past_The_First_Is_Guarded_Too(string org, string why) + { + // Only segment 0 used to be checked, so the factory door rejected + // Guid.Empty for an organization while the guard door waved it through — + // and an unresolved default(Guid) organization collapses every + // organization of a tenant into one bucket, which is an ADR-0017 scope + // boundary, not a benign miss. + var act = () => CacheKey.EnsureValid($"{Tenant}:{org}:education:roster"); + + act.Should().Throw(why); + } + + [Fact] + public void An_Organization_Under_The_Platform_Sentinel_Is_Refused() + { + // Standards 20 calls this "a bug wearing the sentinel's clothes": the + // sentinel means "every tenant", and nothing scoped to one organization + // can also be platform-wide. + var act = () => CacheKey.EnsureValid($"platform:{Org}:education:roster"); + + act.Should().Throw(); + } + [Fact] public void An_Unresolved_Tenant_Is_Refused_At_Composition() { diff --git a/docs/standards/20-infrastructure-stack.md b/docs/standards/20-infrastructure-stack.md index bc8761a2..b1cfc201 100644 --- a/docs/standards/20-infrastructure-stack.md +++ b/docs/standards/20-infrastructure-stack.md @@ -200,10 +200,34 @@ different decisions: | `{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` | +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: + +| Family | Composed by | +|---|---| +| `platform:hub:host-map:{host}` | `CacheKey.ForPlatform("hub", "host-map", host)` | +| `{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")` | + +The last-but-one takes a **multi-part** logical name, and so does the host +lookup. That is why the factories take one: a caller joining the parts itself +would put a separator inside a single segment, which `CacheKey` rejects — so +without multi-part names these two families could only have been hand-built, +past the one place `Guid.Empty`, non-canonical identifier rendering and +separator injection are checked. The host segment is the normalized host per +[ADR-0036](../decisions/0036-tenant-resolution-trusted-inputs.md), which has +already had its port stripped; a host that still carried `:8443` would be +refused rather than silently splitting into two segments. + The host lookup is the **one** family that legitimately carries the `platform` sentinel, and it is worth saying why: it answers "which tenant is this?", so by construction there is no tenant to key it by. Every other family knows its tenant, -so a `platform` sentinel there would be a bug wearing the sentinel's clothes. +so a `platform` sentinel there would be a bug wearing the sentinel's clothes — +and `CacheKey.EnsureValid` now refuses that shape outright, rejecting any key +whose sentinel is followed by an identifier segment. > An earlier version of this table listed these as `hub:host:{host}`, > `hub:entitlement:{tenant_id}` and `tenant_feature_flags:{tenant_id}` — module From f23222823f98a1eb689c7b8cfc96fa9f329af731 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Tue, 25 Aug 2026 09:31:15 +0300 Subject: [PATCH 08/21] feat(kernel): ship the event bus as a transport, not a stub MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Packet 5's remaining port. `IEventBus`, `IIntegrationEvent`, `IntegrationEventBase`, `IIntegrationEventHandler` and `IPartitionSerializer` in `LearnStack.SharedKernel.Messaging`, with `InProcessEventBus` and `PartitionSerializer` in `LearnStack.Infrastructure.Messaging`, registered through the same single-site seam the cache uses. Scope came from the corpus rather than from guesswork: two sections of the phase doc list different port sets, and the difference is that one is phase scope and one is packet scope. `IHostToTenantResolver` is Packet 7, `IEntitlementProvider` is Packet 9 — both need tenancy schema this packet does not have. Packet 5 is the three ports its own status block names, and `ISecretProvider` shipped in Packet 3. ADR-0035 makes four obligations a condition of the gating, and each one here has a test that fails when the code implementing it is removed: - **The same handler contract.** Two interfaces would mean two implementations per consumer, and the one exercised in CI would not be the one that runs in production. - **The same deduplication seam.** The handler calls `IInboxGuard` itself, exactly as it does behind a broker. The guard and its per-module `inbox_messages` table land in Phase 02b; the contract is shaped for it now so no handler is written twice. - **The same tenant-context restoration.** A consumer runs outside the request that produced the fact, so there is no ambient context to inherit — which is why `TenantId` travels on the event. Restoring it is what makes the query filters and the RLS policies evaluate against the right tenant; without it every consumer runs against nothing. - **The same per-partition ordering.** Sequential within a key, concurrent across keys. An ordering assumption that holds only because everything happened to run on one thread is discovered in production. Three decisions worth their reasons. **Publish is not generic, and handlers resolve by runtime type.** The outbox processor deserialises to `object` and publishes through the base interface, so a generic parameter binds to `IIntegrationEvent` at the only call site that matters — and resolving `IIntegrationEventHandler` finds nothing, because no concrete consumer implements it. The publish would reach zero handlers and report success, which is the worst shape a bug can take. Both the generic publish and the static-type resolution are pinned by tests. **Handlers are invoked through the interface's MethodInfo, not `dynamic`.** The published sketch used `dynamic`; the binder honours accessibility, so an `internal` handler — the normal shape for a module's own consumer — fails to bind at runtime with a RuntimeBinderException out of the transport. There is a test with an internal handler. `Invoke` wraps what a handler throws before its first await, so the inner exception is rethrown through `ExceptionDispatchInfo`: a consumer and the error pipeline both key on the exception type, and a `TargetInvocationException` would tell them the transport failed when the handler did. **The publisher's own context is put back, and that is tested with a field-backed accessor.** `ITenantContextAccessor` promises nothing about flow isolation; the production implementation being `AsyncLocal`-backed is a detail of another assembly. With an AsyncLocal accessor the leak is invisible — dispatch runs in its own flow — so removing the restore left the test green. The test now uses a plain accessor, which is what makes it constrain the transport rather than the accessor. `PartitionSerializer` chains each unit onto the tail of its key's queue rather than taking a lock, so it blocks no thread pool thread for the length of a handler. Two things it gets right only because the mutants said otherwise: a chain is retired by value, not by key — removing by key drops a chain whose first unit finished while a later one is still running, and the next arrival then starts from nothing and runs concurrently with work in flight — and the swallowing copy reads `Exception`, because a publisher is free not to await what `RunSequentiallyFor` returns and the fault would otherwise go unobserved. The map holds one entry per in-flight key, not per key ever seen, which matters because partition keys are aggregate ids and the key space is exactly as unbounded as the data. Registration is asserted against the real host rather than by reading the code: a registration compiles whether or not it can be satisfied, and `InProcessEventBus` is a singleton taking three dependencies. Both mutants — dropping the registration, and making the serializer scoped — fail those tests. Scoped would have been the quiet one: every unit test builds one serializer and uses it throughout, so the ordering guarantee would have held everywhere except in production. `IIntegrationEventHandler` and the `@event` parameter carry documented CA1711/CA1716 suppressions: both names are fixed by the corpus — ADR-0035, Standards 20, architecture/15 and the catalogued `Integration_Event_Handlers_Use_InboxGuard` all spell them — so renaming would be a cross-corpus decision record for a spelling. 668 tests green, 0 warnings under CI=true, 8 consecutive runs stable. Co-Authored-By: Claude Opus 5 (1M context) --- .../CrossCuttingFoundationExtensions.cs | 39 ++ .../Messaging/InProcessEventBus.cs | 111 ++++++ .../Messaging/PartitionSerializer.cs | 103 +++++ .../Messaging/IEventBus.cs | 42 ++ .../Messaging/IIntegrationEvent.cs | 49 +++ .../Messaging/IIntegrationEventHandler.cs | 47 +++ .../Messaging/IPartitionSerializer.cs | 18 + .../Messaging/IntegrationEventBase.cs | 34 ++ .../Tenancy/EventTenantContext.cs | 60 +++ .../CrossCuttingFoundationHttpTests.cs | 52 +++ .../Messaging/InProcessEventBusTests.cs | 367 ++++++++++++++++++ .../Messaging/PartitionSerializerTests.cs | 217 +++++++++++ 12 files changed, 1139 insertions(+) create mode 100644 backend/src/LearnStack.Infrastructure/Messaging/InProcessEventBus.cs create mode 100644 backend/src/LearnStack.Infrastructure/Messaging/PartitionSerializer.cs create mode 100644 backend/src/LearnStack.SharedKernel/Messaging/IEventBus.cs create mode 100644 backend/src/LearnStack.SharedKernel/Messaging/IIntegrationEvent.cs create mode 100644 backend/src/LearnStack.SharedKernel/Messaging/IIntegrationEventHandler.cs create mode 100644 backend/src/LearnStack.SharedKernel/Messaging/IPartitionSerializer.cs create mode 100644 backend/src/LearnStack.SharedKernel/Messaging/IntegrationEventBase.cs create mode 100644 backend/src/LearnStack.SharedKernel/Tenancy/EventTenantContext.cs create mode 100644 backend/tests/LearnStack.Tests.Unit/Infrastructure/Messaging/InProcessEventBusTests.cs create mode 100644 backend/tests/LearnStack.Tests.Unit/Infrastructure/Messaging/PartitionSerializerTests.cs diff --git a/backend/src/LearnStack.Api/Composition/CrossCuttingFoundationExtensions.cs b/backend/src/LearnStack.Api/Composition/CrossCuttingFoundationExtensions.cs index dba96261..fd84bb5f 100644 --- a/backend/src/LearnStack.Api/Composition/CrossCuttingFoundationExtensions.cs +++ b/backend/src/LearnStack.Api/Composition/CrossCuttingFoundationExtensions.cs @@ -91,6 +91,16 @@ public static WebApplicationBuilder AddLearnStackCrossCuttingFoundation( // line here rather than a search for every registration. builder.Services.TryAddSingleton(SelectCacheService); + // The event-bus socket, same shape and the same single site. + // IPartitionSerializer is a singleton because the ordering guarantee is + // process-wide: one instance per scope would give each publisher its own + // chains, and two events on one partition key would run concurrently + // while every test still passed. + builder.Services + .TryAddSingleton(); + builder.Services.TryAddSingleton(SelectEventBus); + builder.Services.AddProblemDetails(); builder.Services.AddExceptionHandler(); @@ -228,6 +238,35 @@ private static LearnStack.SharedKernel.Caching.ICacheService SelectCacheService( services.GetRequiredService()); } + /// + /// Single composition-root site that picks the IEventBus + /// implementation per . + /// + /// + /// CA1859 is suppressed for the same reason as the cache socket: the + /// interface return is the point. + /// + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Performance", + "CA1859:Use concrete types when possible for improved performance", + Justification = "Return type is intentionally IEventBus so Phase 11 can swap implementations per DeploymentMode.")] + private static LearnStack.SharedKernel.Messaging.IEventBus SelectEventBus( + IServiceProvider services) + { + // TODO(2026-08-25, @platform): Phase 11 — light up the Dapr-backed + // branch. Demand-gated per ADR-0035; trigger: a second process needs to + // consume an integration event, or event volume, replay or ordering + // across processes is required. InProcessEventBus is a first-class + // transport rather than a stub — same IIntegrationEventHandler + // contract, same IInboxGuard seam, same tenant-context restoration, same + // per-partition ordering — so a consumer written today does not change + // when the durable adapter lands. + return new LearnStack.Infrastructure.Messaging.InProcessEventBus( + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService()); + } + /// /// Single composition-root site that picks the /// implementation per diff --git a/backend/src/LearnStack.Infrastructure/Messaging/InProcessEventBus.cs b/backend/src/LearnStack.Infrastructure/Messaging/InProcessEventBus.cs new file mode 100644 index 00000000..ec028107 --- /dev/null +++ b/backend/src/LearnStack.Infrastructure/Messaging/InProcessEventBus.cs @@ -0,0 +1,111 @@ +using System.Reflection; +using System.Runtime.ExceptionServices; +using LearnStack.SharedKernel.Messaging; +using LearnStack.SharedKernel.Tenancy; +using Microsoft.Extensions.DependencyInjection; + +namespace LearnStack.Infrastructure.Messaging; + +/// +/// The default : a first-class transport, not a stub. +/// +/// +/// +/// It carries the same four obligations as the durable path, and each one is +/// here because a development transport that dropped it would be a development +/// path where the production behaviour is never exercised: +/// +/// +/// the same contract, so no +/// consumer needs a second implementation and the one running in CI is the one +/// that runs in production; +/// the same consumer-side deduplication — the handler calls +/// IInboxGuard itself, exactly as it does behind a broker, because a +/// transport that never delivers a duplicate never surfaces the most common +/// integration-event defect; +/// the same tenant-context restoration from the event, so Row Level +/// Security and the query filters are exercised on the consumer side; +/// the same per-partition-key ordering, because an ordering assumption +/// that holds only in one process is discovered in production. +/// +/// +/// What it genuinely does not provide — and therefore the trigger for the Dapr +/// adapter in +/// Phase 11 +/// per ADR-0035 +/// — is delivery to a second process, broker-side retention and replay. +/// +/// +public sealed class InProcessEventBus( + IServiceScopeFactory scopeFactory, + ITenantContextAccessor tenantAccessor, + IPartitionSerializer partitions) : IEventBus +{ + private const string HandleMethodName = + nameof(IIntegrationEventHandler.HandleAsync); + + public Task PublishAsync( + IIntegrationEvent @event, + string partitionKey, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(@event); + ArgumentException.ThrowIfNullOrWhiteSpace(partitionKey); + + return partitions.RunSequentiallyFor(partitionKey, async () => + { + await using var scope = scopeFactory.CreateAsyncScope(); + + // Restored into the flow the handler runs in, and the publisher's own + // context put back afterwards — a synchronous dispatch would + // otherwise leak a tenant into the caller that published the event. + var previous = tenantAccessor.Current; + tenantAccessor.Current = EventTenantContext.FromEvent(@event, previous?.CorrelationId); + + try + { + // By RUNTIME type. `@event` is declared as the base interface + // here, so a closed generic over its static type would resolve + // IIntegrationEventHandler — which no concrete + // consumer implements — and the publish would reach zero handlers + // and report success. + var contract = typeof(IIntegrationEventHandler<>).MakeGenericType(@event.GetType()); + + // Invoked through the INTERFACE method rather than by `dynamic` + // on the instance. The dynamic binder honours accessibility, so + // an `internal` handler — the normal shape for a module's own + // consumer — would fail to bind at runtime; an interface method + // dispatches virtually and does not care what the concrete type's + // visibility is. + var handle = contract.GetMethod(HandleMethodName)!; + + foreach (var handler in scope.ServiceProvider.GetServices(contract)) + { + Task delivery; + + try + { + delivery = (Task)handle.Invoke(handler, [@event, cancellationToken])!; + } + catch (TargetInvocationException wrapped) when (wrapped.InnerException is not null) + { + // A handler that throws before its first await throws out + // of Invoke, which wraps it. Unwrapped and rethrown with + // its stack intact, because a consumer and the error + // pipeline both key on the exception type: a + // TargetInvocationException would tell them the transport + // failed when the handler did. + ExceptionDispatchInfo.Capture(wrapped.InnerException).Throw(); + throw; + } + + await delivery; + } + } + finally + { + tenantAccessor.Current = previous; + } + }); + } +} diff --git a/backend/src/LearnStack.Infrastructure/Messaging/PartitionSerializer.cs b/backend/src/LearnStack.Infrastructure/Messaging/PartitionSerializer.cs new file mode 100644 index 00000000..a7e3adc7 --- /dev/null +++ b/backend/src/LearnStack.Infrastructure/Messaging/PartitionSerializer.cs @@ -0,0 +1,103 @@ +using System.Collections.Concurrent; +using LearnStack.SharedKernel.Messaging; + +namespace LearnStack.Infrastructure.Messaging; + +/// +/// Serialises work per partition key by chaining each unit onto the tail of that +/// key's queue — concurrent across keys, sequential within one. +/// +/// +/// +/// The chain is the whole mechanism: each key maps to the of +/// the last unit queued for it, and a new unit continues from that task rather +/// than starting fresh. A lock per key would do the same thing while blocking a +/// thread pool thread for the duration of a handler; this blocks nobody. +/// +/// +/// A key's chain is dropped once nothing is queued behind it, so the map holds +/// one entry per in-flight key rather than one per key ever seen. A +/// structure that grew with the key space would be the same defect the cache's +/// ceiling exists to prevent, in a component nobody thinks to look at. +/// +/// +public sealed class PartitionSerializer : IPartitionSerializer +{ + private readonly ConcurrentDictionary _tails = new(StringComparer.Ordinal); + private readonly object _gate = new(); + + public Task RunSequentiallyFor(string partitionKey, Func work) + { + ArgumentException.ThrowIfNullOrWhiteSpace(partitionKey); + ArgumentNullException.ThrowIfNull(work); + + Task queued; + + // The read-modify-write of the tail has to be atomic against another + // publisher for the same key, or two units both continue from the same + // predecessor and run concurrently — which is the one thing this class + // exists to prevent. AddOrUpdate cannot express it: its update factory + // may run more than once under contention, and running it twice would + // queue the work twice. + lock (_gate) + { + var previous = _tails.TryGetValue(partitionKey, out var tail) ? tail : Task.CompletedTask; + + // Faults do not break the chain. A handler that throws must not stop + // every later event for that aggregate from being delivered — the + // failure belongs to one unit, and the caller awaiting `queued` is + // the one that sees it. + queued = previous.ContinueWith( + _ => work(), + CancellationToken.None, + TaskContinuationOptions.None, + TaskScheduler.Default) + .Unwrap(); + + // The chain continues from a COPY, for two separate reasons that are + // easy to conflate. A ContinueWith whose delegate does not throw + // completes successfully whatever its antecedent did, which is what + // keeps a failed unit from faulting everything queued behind it. + // Reading `Exception` inside it is the other reason: a publisher is + // free not to await what RunSequentiallyFor returns, and then nobody + // observes the fault — TaskScheduler.UnobservedTaskException fires, + // with no request and no correlation id attached to it. + _tails[partitionKey] = queued.ContinueWith( + static completed => { _ = completed.Exception; }, + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + } + + _ = _tails[partitionKey].ContinueWith( + _ => Retire(partitionKey), + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + + return queued; + } + + /// How many partition keys currently have work chained. A diagnostic. + /// + /// Exposed so the "one entry per in-flight key, not per key ever seen" claim + /// can be asserted directly rather than inferred. + /// + public int TrackedPartitions => _tails.Count; + + private void Retire(string partitionKey) + { + lock (_gate) + { + // Only when this key's tail is the one that just finished. Another + // publisher may have chained onto it in the meantime, and dropping + // the entry then would let the next unit start from + // Task.CompletedTask — running concurrently with work still in + // flight, which is the ordering break this class prevents. + if (_tails.TryGetValue(partitionKey, out var tail) && tail.IsCompleted) + { + _tails.TryRemove(new KeyValuePair(partitionKey, tail)); + } + } + } +} diff --git a/backend/src/LearnStack.SharedKernel/Messaging/IEventBus.cs b/backend/src/LearnStack.SharedKernel/Messaging/IEventBus.cs new file mode 100644 index 00000000..041afaa1 --- /dev/null +++ b/backend/src/LearnStack.SharedKernel/Messaging/IEventBus.cs @@ -0,0 +1,42 @@ +using System.Diagnostics.CodeAnalysis; + +namespace LearnStack.SharedKernel.Messaging; + +/// +/// Publishes an integration event to whichever transport is registered, per +/// ADR-0014 and +/// its Amendment 2. Modules never inject a broker client. +/// +/// +/// +/// Not generic, and that was a correction rather than a preference. The +/// outbox processor deserialises a stored payload to object and publishes +/// through this interface, so a generic parameter would bind to +/// at the only call site that matters — and a +/// transport resolving IIntegrationEventHandler<TEvent> would then +/// look for a handler of IIntegrationEvent, which no concrete consumer +/// implements. The publish would reach zero handlers and report success. Both +/// transports resolve by the event's runtime type instead. +/// +/// +/// The partition key is a parameter rather than being read off the event because +/// the producer resolves it once, at enqueue time, and writes it to the outbox +/// row; the processor passes back what it stored. Nothing downstream re-derives +/// it, so the ordering domain cannot drift between enqueue and publish. +/// +/// +[SuppressMessage( + "Naming", + "CA1716:Identifiers should not match keywords", + Justification = "LearnStack is C#-only per ADR-0032, and architecture/15 " + + "publishes this signature with the parameter spelled @event; renaming " + + "would put the corpus and the code out of step for a cross-language " + + "concern that does not exist.")] +public interface IEventBus +{ + /// Publishes one event, ordered against others sharing its partition key. + Task PublishAsync( + IIntegrationEvent @event, + string partitionKey, + CancellationToken cancellationToken = default); +} diff --git a/backend/src/LearnStack.SharedKernel/Messaging/IIntegrationEvent.cs b/backend/src/LearnStack.SharedKernel/Messaging/IIntegrationEvent.cs new file mode 100644 index 00000000..b7ba44af --- /dev/null +++ b/backend/src/LearnStack.SharedKernel/Messaging/IIntegrationEvent.cs @@ -0,0 +1,49 @@ +namespace LearnStack.SharedKernel.Messaging; + +/// +/// A fact one module publishes for others to consume, per +/// ADR-0006. +/// Crosses a module boundary through the outbox; never a method call. +/// +/// +/// Implemented by inheriting rather than by +/// implementing this interface directly — the architecture test +/// Integration_Events_Inherit_From_IntegrationEventBase enforces it, so +/// that every event carries the same identity, tenancy and ordering fields and a +/// consumer can rely on them without knowing the type. +/// +public interface IIntegrationEvent +{ + /// Identity for consumer-side deduplication. + /// + /// Delivery is at-least-once, so this is what IInboxGuard keys on. + /// It is assigned once by the producer and never re-derived — a redelivery + /// carries the same value, which is the entire point. + /// + Guid EventId { get; } + + /// The tenant the fact belongs to. + /// + /// Carried on the event because a consumer runs outside the request that + /// produced it: there is no ambient context to inherit, so the transport + /// restores it from here before a handler runs. Without it a consumer would + /// execute with no tenant, and every query filter and RLS policy would be + /// evaluated against nothing. + /// + Guid TenantId { get; } + + /// When the fact happened, from IClock — never DateTime.UtcNow. + DateTimeOffset OccurredAt { get; } + + /// + /// The ordering domain this event belongs to. + /// + /// + /// Ordering is guaranteed per partition key and nowhere else, so a partition + /// key nobody sets is a guarantee nobody has. Normally the id of the + /// aggregate the event is about; the tenant id for events about the tenant as + /// a whole. Resolved once, by the producer that knows the domain, and never + /// re-derived downstream. + /// + string PartitionKey { get; } +} diff --git a/backend/src/LearnStack.SharedKernel/Messaging/IIntegrationEventHandler.cs b/backend/src/LearnStack.SharedKernel/Messaging/IIntegrationEventHandler.cs new file mode 100644 index 00000000..f52bea57 --- /dev/null +++ b/backend/src/LearnStack.SharedKernel/Messaging/IIntegrationEventHandler.cs @@ -0,0 +1,47 @@ +using System.Diagnostics.CodeAnalysis; + +namespace LearnStack.SharedKernel.Messaging; + +/// +/// Consumes one integration-event type. The only consumer-side contract: +/// never a bare MediatR.INotificationHandler<T>. +/// +/// +/// +/// One interface, because two would mean two implementations per consumer — and +/// the one exercised in development would not be the one that runs in +/// production. Both transports resolve this same contract. +/// +/// +/// A handler must call IInboxGuard.IsAlreadyProcessedAsync before any +/// business logic. Delivery is at-least-once by design, so deduplication is +/// the consumer's obligation rather than the transport's — the architecture test +/// Integration_Event_Handlers_Use_InboxGuard enforces it. The guard and +/// its per-module inbox_messages table land in +/// Phase 02b; +/// the contract is shaped for it now so no handler is written twice. +/// +/// +/// The event type this handler consumes. +[SuppressMessage( + "Naming", + "CA1711:Identifiers should not have incorrect suffix", + Justification = "The name is fixed by the corpus, not chosen here: ADR-0035, " + + "Standards 20, architecture/15 and the architecture test " + + "Integration_Event_Handlers_Use_InboxGuard all name this contract. The " + + "suffix warns against confusion with a CLR event handler; nothing in " + + "LearnStack uses CLR events, and renaming would mean a cross-corpus " + + "decision record for a spelling.")] +[SuppressMessage( + "Naming", + "CA1716:Identifiers should not match keywords", + Justification = "LearnStack is C#-only per ADR-0032, and every published " + + "consumer sketch in architecture/15 spells the parameter @event; a " + + "different name here would put the corpus and the code out of step for " + + "a cross-language concern that does not exist.")] +public interface IIntegrationEventHandler + where TEvent : IIntegrationEvent +{ + /// Handles one delivery. May be called more than once per event. + Task HandleAsync(TEvent @event, CancellationToken cancellationToken = default); +} diff --git a/backend/src/LearnStack.SharedKernel/Messaging/IPartitionSerializer.cs b/backend/src/LearnStack.SharedKernel/Messaging/IPartitionSerializer.cs new file mode 100644 index 00000000..b6cfa8d1 --- /dev/null +++ b/backend/src/LearnStack.SharedKernel/Messaging/IPartitionSerializer.cs @@ -0,0 +1,18 @@ +namespace LearnStack.SharedKernel.Messaging; + +/// +/// Runs work sequentially within one partition key and concurrently across +/// different ones. +/// +/// +/// This is the in-process stand-in for what a broker gives you by assigning a +/// partition to one consumer. It exists so the development transport carries the +/// same ordering guarantee as the durable path rather than a weaker one: +/// ordering assumptions that hold only because everything happened to run on one +/// thread are discovered in production. +/// +public interface IPartitionSerializer +{ + /// Runs after anything already queued for this key. + Task RunSequentiallyFor(string partitionKey, Func work); +} diff --git a/backend/src/LearnStack.SharedKernel/Messaging/IntegrationEventBase.cs b/backend/src/LearnStack.SharedKernel/Messaging/IntegrationEventBase.cs new file mode 100644 index 00000000..385dd8a6 --- /dev/null +++ b/backend/src/LearnStack.SharedKernel/Messaging/IntegrationEventBase.cs @@ -0,0 +1,34 @@ +namespace LearnStack.SharedKernel.Messaging; + +/// +/// The base every integration event inherits, carrying the identity, tenancy and +/// ordering fields the transport needs. +/// +/// +/// +/// A record, and JSON-serialisable: the outbox stores the payload as JSON and the +/// processor deserialises it, so an event that cannot round-trip through +/// System.Text.Json is an event that cannot be delivered. +/// +/// +/// is abstract rather than defaulted. A default would +/// have to be either the tenant id — which silently serialises a tenant's whole +/// stream onto one partition, a real throughput cost taken by accident — or +/// something arbitrary. Making each event state its own ordering domain is the +/// only version where the guarantee means what it says. +/// +/// +public abstract record IntegrationEventBase : IIntegrationEvent +{ + /// + public required Guid EventId { get; init; } + + /// + public required Guid TenantId { get; init; } + + /// + public required DateTimeOffset OccurredAt { get; init; } + + /// + public abstract string PartitionKey { get; } +} diff --git a/backend/src/LearnStack.SharedKernel/Tenancy/EventTenantContext.cs b/backend/src/LearnStack.SharedKernel/Tenancy/EventTenantContext.cs new file mode 100644 index 00000000..dea4fa44 --- /dev/null +++ b/backend/src/LearnStack.SharedKernel/Tenancy/EventTenantContext.cs @@ -0,0 +1,60 @@ +namespace LearnStack.SharedKernel.Tenancy; + +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Messaging; + +/// +/// The tenant context a consumer runs under, rebuilt from the event it is +/// handling. +/// +/// +/// A consumer runs outside the request that produced the fact, so there is no +/// ambient context to inherit — which is exactly why +/// travels on the event. Restoring it +/// before a handler runs is what makes the query filters and the Row Level +/// Security policies evaluate against the right tenant; a transport that skipped +/// it would run every consumer against nothing. +/// +public sealed class EventTenantContext : ITenantContext +{ + private EventTenantContext(Guid tenantId, string? correlationId) + { + TenantId = tenantId; + CorrelationId = correlationId; + } + + /// + public bool IsResolved => true; + + /// + public Guid TenantId { get; } + + /// + /// Always null: an integration event carries the tenant, not an + /// organization. + /// + /// + /// Deliberate rather than missing. A fact crossing a module boundary is a + /// tenant-level fact, and inventing an organization scope for the consumer + /// would narrow queries the producer never narrowed — the failure would be + /// silently missing rows, not an error. + /// + public Guid? OrganizationId => null; + + /// Always null: a consumer acts as the system, not as a user. + public UserId? UserId => null; + + /// + public string? CorrelationId { get; } + + /// + public string? ModuleName => null; + + /// Builds the context a handler for runs under. + public static EventTenantContext FromEvent(IIntegrationEvent @event, string? correlationId = null) + { + ArgumentNullException.ThrowIfNull(@event); + + return new EventTenantContext(@event.TenantId, correlationId); + } +} diff --git a/backend/tests/LearnStack.Tests.Integration/CrossCuttingFoundationHttpTests.cs b/backend/tests/LearnStack.Tests.Integration/CrossCuttingFoundationHttpTests.cs index c8e646ce..0ddadbc4 100644 --- a/backend/tests/LearnStack.Tests.Integration/CrossCuttingFoundationHttpTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/CrossCuttingFoundationHttpTests.cs @@ -3,6 +3,10 @@ using System.Net.Http.Json; using System.Text; using System.Text.Json; +using LearnStack.Infrastructure.Caching; +using LearnStack.Infrastructure.Messaging; +using LearnStack.SharedKernel.Caching; +using LearnStack.SharedKernel.Messaging; using FluentAssertions; using LearnStack.Api.Common; using LearnStack.SharedKernel.Errors; @@ -134,6 +138,54 @@ public async Task Malformed_Body_Returns_LearnStacks_ProblemDetails_Not_AspNets( } /// +/// +/// The foundation sockets resolve from the real composition root. +/// +/// +/// A registration compiles whether or not it can be satisfied, so "it builds" is +/// not evidence that a caller can get one. These resolve through the host the +/// application actually starts, which is the only place the lifetimes and the +/// dependency graph are the real ones — InProcessEventBus takes an +/// IServiceScopeFactory, an ITenantContextAccessor and an +/// IPartitionSerializer, and a singleton depending on a scoped service is +/// a startup failure rather than a compile error. +/// +public sealed class FoundationPortResolutionTests(CrossCuttingHttpFixture fixture) + : IClassFixture +{ + [Fact] + public void The_Event_Bus_Resolves_To_The_In_Process_Transport() + { + using var scope = fixture.Services.CreateScope(); + + scope.ServiceProvider.GetRequiredService() + .Should().BeOfType(); + } + + [Fact] + public void The_Partition_Serializer_Is_A_Singleton() + { + // The ordering guarantee is process-wide. One instance per scope would + // give each publisher its own chains, so two events on one partition key + // would run concurrently — while every unit test still passed, because + // each of those builds one serializer and uses it throughout. + using var first = fixture.Services.CreateScope(); + using var second = fixture.Services.CreateScope(); + + first.ServiceProvider.GetRequiredService() + .Should().BeSameAs(second.ServiceProvider.GetRequiredService()); + } + + [Fact] + public void The_Cache_Resolves_To_The_In_Memory_Default() + { + using var scope = fixture.Services.CreateScope(); + + scope.ServiceProvider.GetRequiredService() + .Should().BeOfType(); + } +} + /// Shared that wires the /// integration test's controllers + MediatR handler + validator into the /// real LearnStack.Api host. Reuses the host's diff --git a/backend/tests/LearnStack.Tests.Unit/Infrastructure/Messaging/InProcessEventBusTests.cs b/backend/tests/LearnStack.Tests.Unit/Infrastructure/Messaging/InProcessEventBusTests.cs new file mode 100644 index 00000000..bea9b205 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Unit/Infrastructure/Messaging/InProcessEventBusTests.cs @@ -0,0 +1,367 @@ +using System.Collections.Concurrent; +using FluentAssertions; +using LearnStack.Infrastructure.Messaging; +using LearnStack.SharedKernel.Messaging; +using LearnStack.SharedKernel.Tenancy; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace LearnStack.Tests.Unit.Infrastructure.Messaging; + +/// +/// The default , per +/// ADR-0035. +/// +/// +/// Every case here asserts one of the four obligations ADR-0035 makes a condition +/// of the gating — same handler contract, same deduplication seam, same +/// tenant-context restoration, same per-partition ordering. A transport that +/// dropped any of them would be a development path where the production +/// behaviour is never exercised, which is the opposite of what a default +/// implementation is for. +/// +public sealed class InProcessEventBusTests +{ + private static readonly Guid Tenant = Guid.Parse("018f4d40-0000-7000-8000-00000000000a"); + private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(10); + + [Fact] + public async Task A_Handler_Receives_The_Event() + { + var recorder = new Recorder(); + var (bus, _) = Build(recorder, services => + services.AddScoped, ThingHandler>()); + + await bus.PublishAsync(NewThing("a"), "p1"); + + recorder.Handled.Should().ContainSingle().Which.Should().Be("a"); + } + + [Fact] + public async Task An_Internal_Handler_Is_Invoked_Too() + { + // The reason handlers are called through the interface's MethodInfo + // rather than by `dynamic`: the dynamic binder honours accessibility, so + // an internal handler — the normal shape for a module's own consumer — + // would fail to bind at runtime, and the failure would be a + // RuntimeBinderException out of the transport rather than anything a + // consumer could diagnose. + var recorder = new Recorder(); + var (bus, _) = Build(recorder, services => + services.AddScoped, InternalThingHandler>()); + + await bus.PublishAsync(NewThing("a"), "p1"); + + recorder.Handled.Should().ContainSingle().Which.Should().Be("internal:a"); + } + + [Fact] + public async Task Publishing_Through_The_Base_Interface_Still_Reaches_The_Handler() + { + // The reason PublishAsync is not generic. The outbox processor + // deserialises to object and publishes through the base interface, so a + // generic parameter would bind to IIntegrationEvent — and resolving + // IIntegrationEventHandler finds nothing, because no + // concrete consumer implements it. The publish would reach zero handlers + // and report success, which is the worst shape a bug can take. + var recorder = new Recorder(); + var (bus, _) = Build(recorder, services => + services.AddScoped, ThingHandler>()); + + IIntegrationEvent asBase = NewThing("a"); + await bus.PublishAsync(asBase, "p1"); + + recorder.Handled.Should().ContainSingle(); + } + + [Fact] + public async Task Every_Handler_For_The_Event_Runs() + { + var recorder = new Recorder(); + var (bus, _) = Build(recorder, services => + { + services.AddScoped, ThingHandler>(); + services.AddScoped, SecondThingHandler>(); + }); + + await bus.PublishAsync(NewThing("a"), "p1"); + + recorder.Handled.Should().BeEquivalentTo(["a", "second:a"]); + } + + [Fact] + public async Task An_Event_With_No_Handler_Is_Not_An_Error() + { + var (bus, _) = Build(new Recorder(), _ => { }); + + var act = () => bus.PublishAsync(NewThing("a"), "p1"); + + await act.Should().NotThrowAsync(); + } + + // ---- obligation: tenant context is restored, and put back ---------------- + + [Fact] + public async Task The_Handler_Runs_Under_The_Events_Tenant() + { + // A consumer runs outside the request that produced the fact, so there is + // no ambient context to inherit. Without this the handler executes with + // no tenant and every query filter and RLS policy is evaluated against + // nothing. + var recorder = new Recorder(); + var (bus, _) = Build(recorder, services => + services.AddScoped, TenantReadingHandler>()); + + await bus.PublishAsync(NewThing("a"), "p1"); + + recorder.Tenants.Should().ContainSingle().Which.Should().Be(Tenant); + } + + [Fact] + public async Task The_Publishers_Own_Context_Is_Put_Back() + { + // Dispatch is synchronous here, so a transport that set the context and + // walked away would leak the event's tenant into the caller that + // published it — and that caller goes on to run its own queries. + var recorder = new Recorder(); + var (bus, accessor) = Build(recorder, services => + services.AddScoped, TenantReadingHandler>()); + + var publisher = EventTenantContext.FromEvent(NewThing("x") with { TenantId = Guid.NewGuid() }); + accessor.Current = publisher; + + await bus.PublishAsync(NewThing("a"), "p1"); + + accessor.Current.Should().BeSameAs(publisher); + } + + [Fact] + public async Task A_Handler_That_Throws_Leaves_No_Context_Behind() + { + var recorder = new Recorder(); + var (bus, accessor) = Build(recorder, services => + services.AddScoped, ThrowingHandler>()); + accessor.Current = null; + + var act = () => bus.PublishAsync(NewThing("a"), "p1"); + + await act.Should().ThrowAsync(); + accessor.Current.Should().BeNull(); + } + + // ---- obligation: ordering per partition key ------------------------------ + + [Fact] + public async Task Two_Events_On_One_Partition_Key_Do_Not_Overlap() + { + // Ordering is guaranteed per partition key and nowhere else. An + // in-process transport that dispatched concurrently would let an + // ordering assumption pass every test and fail in production. + var recorder = new Recorder(); + var (bus, _) = Build(recorder, services => + services.AddScoped, OverlapDetectingHandler>()); + + var first = bus.PublishAsync(NewThing("a"), "same-key"); + var second = bus.PublishAsync(NewThing("b"), "same-key"); + await Task.WhenAll(first, second).WaitAsync(Timeout); + + recorder.Overlapped.Should().BeFalse("dispatch is sequential within one key"); + recorder.Handled.Should().BeEquivalentTo(["a", "b"], o => o.WithStrictOrdering()); + } + + [Fact] + public async Task Different_Partition_Keys_Run_Concurrently() + { + // The other half of the guarantee: serialising everything would be + // correct and useless, so the test that proves ordering must be paired + // with one that proves it is not global. + var recorder = new Recorder(); + using var bothArrived = new SemaphoreSlim(0); + var (bus, _) = Build(recorder, services => + services.AddScoped>( + _ => new RendezvousHandler(bothArrived))); + + var first = bus.PublishAsync(NewThing("a"), "key-1"); + var second = bus.PublishAsync(NewThing("b"), "key-2"); + + // Each handler releases once and waits for the other. If the two keys + // were serialised, the first would wait forever and this would time out. + await Task.WhenAll(first, second).WaitAsync(Timeout); + } + + [Fact] + public async Task A_Failed_Delivery_Does_Not_Block_The_Rest_Of_Its_Partition() + { + // A handler that throws must not stop every later event for that + // aggregate. The failure belongs to the one publish that caused it. + var recorder = new Recorder(); + var (bus, _) = Build(recorder, services => + { + services.AddScoped, ThrowOnFirstHandler>(); + }); + + var failing = bus.PublishAsync(NewThing("boom"), "same-key"); + await ((Func)(() => failing)).Should().ThrowAsync(); + + await bus.PublishAsync(NewThing("after"), "same-key").WaitAsync(Timeout); + + recorder.Handled.Should().Contain("after"); + } + + // ---- helpers ------------------------------------------------------------- + + private static Thing NewThing(string payload) => new() + { + EventId = Guid.NewGuid(), + TenantId = Tenant, + OccurredAt = DateTimeOffset.UnixEpoch, + Payload = payload, + }; + + private static (IEventBus Bus, ITenantContextAccessor Accessor) Build( + Recorder recorder, Action register) + { + var accessor = new TestTenantContextAccessor(); + + var services = new ServiceCollection(); + services.AddSingleton(recorder); + + // The SAME accessor the bus writes through, so a handler resolved from + // the dispatch scope reads what the transport restored rather than a + // second, empty one. + services.AddSingleton(accessor); + register(services); + + var provider = services.BuildServiceProvider(); + + return ( + new InProcessEventBus( + provider.GetRequiredService(), + accessor, + new PartitionSerializer()), + accessor); + } + + /// + /// A plain field-backed accessor, deliberately NOT . + /// + /// + /// promises nothing about flow + /// isolation, and the production implementation being AsyncLocal-backed is a + /// detail of another assembly. With an AsyncLocal accessor a leaked context + /// is invisible here — dispatch runs in its own flow, so the write never + /// reaches the publisher and the restore looks unnecessary even when it is + /// removed. This accessor makes the guarantee observable, which is the only + /// way the test constrains the transport rather than the accessor. + /// + private sealed class TestTenantContextAccessor : ITenantContextAccessor + { + public ITenantContext? Current { get; set; } + } + + public sealed class Recorder + { + private int _inFlight; + + public ConcurrentQueue Handled { get; } = new(); + + public ConcurrentQueue Tenants { get; } = new(); + + /// Whether two handlers were ever inside the dispatch at once. + public bool Overlapped { get; private set; } + + public void Enter() + { + if (Interlocked.Increment(ref _inFlight) > 1) + { + Overlapped = true; + } + } + + public void Exit() => Interlocked.Decrement(ref _inFlight); + } + + public sealed record Thing : IntegrationEventBase + { + public required string Payload { get; init; } + + public override string PartitionKey => Payload; + } + + public sealed class ThingHandler(Recorder recorder) : IIntegrationEventHandler + { + public Task HandleAsync(Thing @event, CancellationToken cancellationToken = default) + { + recorder.Handled.Enqueue(@event.Payload); + return Task.CompletedTask; + } + } + + public sealed class SecondThingHandler(Recorder recorder) : IIntegrationEventHandler + { + public Task HandleAsync(Thing @event, CancellationToken cancellationToken = default) + { + recorder.Handled.Enqueue($"second:{@event.Payload}"); + return Task.CompletedTask; + } + } + + internal sealed class InternalThingHandler(Recorder recorder) : IIntegrationEventHandler + { + public Task HandleAsync(Thing @event, CancellationToken cancellationToken = default) + { + recorder.Handled.Enqueue($"internal:{@event.Payload}"); + return Task.CompletedTask; + } + } + + public sealed class TenantReadingHandler(Recorder recorder, ITenantContextAccessor accessor) + : IIntegrationEventHandler + { + public Task HandleAsync(Thing @event, CancellationToken cancellationToken = default) + { + recorder.Tenants.Enqueue(accessor.Current!.TenantId); + return Task.CompletedTask; + } + } + + public sealed class ThrowingHandler : IIntegrationEventHandler + { + public Task HandleAsync(Thing @event, CancellationToken cancellationToken = default) => + throw new InvalidOperationException("handler failed"); + } + + public sealed class ThrowOnFirstHandler(Recorder recorder) : IIntegrationEventHandler + { + public Task HandleAsync(Thing @event, CancellationToken cancellationToken = default) + { + if (@event.Payload == "boom") + { + throw new InvalidOperationException("handler failed"); + } + + recorder.Handled.Enqueue(@event.Payload); + return Task.CompletedTask; + } + } + + public sealed class OverlapDetectingHandler(Recorder recorder) : IIntegrationEventHandler + { + public async Task HandleAsync(Thing @event, CancellationToken cancellationToken = default) + { + recorder.Enter(); + await Task.Delay(TimeSpan.FromMilliseconds(30), CancellationToken.None); + recorder.Handled.Enqueue(@event.Payload); + recorder.Exit(); + } + } + + public sealed class RendezvousHandler(SemaphoreSlim gate) : IIntegrationEventHandler + { + public async Task HandleAsync(Thing @event, CancellationToken cancellationToken = default) + { + gate.Release(); + await gate.WaitAsync(Timeout, CancellationToken.None); + } + } +} diff --git a/backend/tests/LearnStack.Tests.Unit/Infrastructure/Messaging/PartitionSerializerTests.cs b/backend/tests/LearnStack.Tests.Unit/Infrastructure/Messaging/PartitionSerializerTests.cs new file mode 100644 index 00000000..bf4b37ff --- /dev/null +++ b/backend/tests/LearnStack.Tests.Unit/Infrastructure/Messaging/PartitionSerializerTests.cs @@ -0,0 +1,217 @@ +using System.Collections.Concurrent; +using FluentAssertions; +using LearnStack.Infrastructure.Messaging; +using Xunit; + +namespace LearnStack.Tests.Unit.Infrastructure.Messaging; + +/// +/// Per-partition ordering: sequential within one key, concurrent across keys. +/// +public sealed class PartitionSerializerTests +{ + private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(10); + + [Fact] + public async Task Work_On_One_Key_Runs_In_Order_And_Never_Overlaps() + { + var serializer = new PartitionSerializer(); + var observed = new ConcurrentQueue(); + var inFlight = 0; + var overlapped = false; + + var queued = Enumerable.Range(0, 25).Select(i => + serializer.RunSequentiallyFor("k", async () => + { + if (Interlocked.Increment(ref inFlight) > 1) + { + Volatile.Write(ref overlapped, true); + } + + await Task.Yield(); + observed.Enqueue(i); + Interlocked.Decrement(ref inFlight); + })).ToArray(); + + await Task.WhenAll(queued).WaitAsync(Timeout); + + overlapped.Should().BeFalse(); + observed.Should().BeEquivalentTo(Enumerable.Range(0, 25), o => o.WithStrictOrdering()); + } + + [Fact] + public async Task Different_Keys_Do_Not_Wait_For_Each_Other() + { + // Serialising everything would satisfy the ordering test and defeat the + // purpose, so the guarantee is pinned from both sides. + var serializer = new PartitionSerializer(); + using var arrived = new SemaphoreSlim(0); + + async Task Rendezvous() + { + arrived.Release(); + await arrived.WaitAsync(Timeout, CancellationToken.None); + } + + var first = serializer.RunSequentiallyFor("k1", Rendezvous); + var second = serializer.RunSequentiallyFor("k2", Rendezvous); + + // Each releases once and waits for the other: if the two keys shared a + // chain the first would wait forever. + await Task.WhenAll(first, second).WaitAsync(Timeout); + } + + [Fact] + public async Task A_Failure_Belongs_To_Its_Own_Unit() + { + var serializer = new PartitionSerializer(); + var ran = false; + + var failing = serializer.RunSequentiallyFor("k", () => + throw new InvalidOperationException("unit failed")); + + await ((Func)(() => failing)).Should().ThrowAsync(); + + await serializer.RunSequentiallyFor("k", () => + { + ran = true; + return Task.CompletedTask; + }).WaitAsync(Timeout); + + ran.Should().BeTrue("a failed unit must not stop the rest of its partition"); + } + + [Fact] + public async Task A_Failure_Does_Not_Fault_The_Units_Queued_Behind_It() + { + // The chain is built on a copy that swallows the fault, so a later unit + // does not inherit an earlier one's exception. + var serializer = new PartitionSerializer(); + using var hold = new SemaphoreSlim(0); + + var failing = serializer.RunSequentiallyFor("k", async () => + { + await hold.WaitAsync(Timeout, CancellationToken.None); + throw new InvalidOperationException("unit failed"); + }); + + var behind = serializer.RunSequentiallyFor("k", () => Task.CompletedTask); + + hold.Release(); + await ((Func)(() => failing)).Should().ThrowAsync(); + + var act = () => behind.WaitAsync(Timeout); + await act.Should().NotThrowAsync(); + } + + [Fact] + public async Task The_Map_Holds_One_Entry_Per_In_Flight_Key_Not_Per_Key_Ever_Seen() + { + // A structure that grew with the key space would be the same defect the + // cache's ceiling exists to prevent, in a component nobody thinks to + // look at — and partition keys are aggregate ids, so the key space is + // exactly as unbounded as the data. + var serializer = new PartitionSerializer(); + + for (var i = 0; i < 5_000; i++) + { + await serializer.RunSequentiallyFor($"k{i}", () => Task.CompletedTask) + .WaitAsync(Timeout); + } + + serializer.TrackedPartitions.Should().Be(0, "nothing is in flight any more"); + } + + [Fact] + public async Task A_Chain_Is_Not_Dropped_While_Work_Is_Still_Queued_Behind_It() + { + // Retiring by key alone would drop a chain whose FIRST unit finished + // while a later one is still running: the next arrival would then start + // from nothing and run concurrently with work already in flight — the + // ordering break this class exists to prevent, introduced by its own + // cleanup. + var serializer = new PartitionSerializer(); + using var releaseFirst = new SemaphoreSlim(0); + using var releaseSecond = new SemaphoreSlim(0); + + var first = serializer.RunSequentiallyFor("k", () => releaseFirst.WaitAsync(Timeout)); + var second = serializer.RunSequentiallyFor("k", () => releaseSecond.WaitAsync(Timeout)); + + releaseFirst.Release(); + await first.WaitAsync(Timeout); + + // The first unit is done and its retirement has had every chance to run; + // the second is still in flight. + var third = serializer.RunSequentiallyFor("k", () => Task.CompletedTask); + await Task.Delay(TimeSpan.FromMilliseconds(100)); + + third.IsCompleted.Should().BeFalse( + "the third unit waits behind the second, which has not finished"); + + releaseSecond.Release(); + await Task.WhenAll(second, third).WaitAsync(Timeout); + } + + [Fact] + public async Task A_Failing_Unit_Nobody_Awaits_Leaves_No_Unobserved_Exception() + { + // A publisher is free not to await what RunSequentiallyFor returns. + const string Sentinel = "learnstack-partition-unobserved-probe"; + var mine = new ConcurrentBag(); + + void Handler(object? sender, UnobservedTaskExceptionEventArgs e) + { + if (e.Exception.Flatten().InnerExceptions.Any(inner => inner.Message == Sentinel)) + { + mine.Add(e.Exception); + e.SetObserved(); + } + } + + TaskScheduler.UnobservedTaskException += Handler; + try + { + for (var round = 0; round < 10; round++) + { + var serializer = new PartitionSerializer(); + _ = serializer.RunSequentiallyFor("k", () => + Task.FromException(new InvalidOperationException(Sentinel))); + await Task.Delay(TimeSpan.FromMilliseconds(20)); + } + + for (var collection = 0; collection < 4; collection++) + { + GC.Collect(); + GC.WaitForPendingFinalizers(); + } + + await Task.Delay(TimeSpan.FromMilliseconds(300)); + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + + mine.Should().BeEmpty("the chain observes the fault it swallows"); + } + finally + { + TaskScheduler.UnobservedTaskException -= Handler; + } + } + + [Fact] + public async Task An_In_Flight_Key_Is_Still_Tracked() + { + // The other side of the same claim: retiring eagerly would drop a chain + // that still has work behind it, and the next unit would start from + // scratch — running concurrently with work already in flight. + var serializer = new PartitionSerializer(); + using var hold = new SemaphoreSlim(0); + + var running = serializer.RunSequentiallyFor("k", () => hold.WaitAsync(Timeout)); + + serializer.TrackedPartitions.Should().Be(1); + + hold.Release(); + await running.WaitAsync(Timeout); + } +} From 5ba1bd751cdccd070f8bbb188b497e1632305684 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Tue, 25 Aug 2026 09:42:51 +0300 Subject: [PATCH 09/21] test(api): boot the second deployment mode instead of describing it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Packet 5 requires composition-root branching on `DeploymentMode` to be "present and exercised", with `Development` and `SaaS` wired end to end. Existing coverage stopped at *reading* the mode — that it has no default, that a numeric string is refused. Nothing ever started the host in a second mode, so "exercised" rested on the branch compiling, and a branch that compiles can still throw at startup, register the wrong implementation, or fail to resolve. Both wired modes now boot. `SaaS` resolves `SentryErrorTracker` where `Development` resolves `NoOpErrorTracker`, and all three foundation ports resolve to their ADR-0035 defaults in both — which is the ADR's claim stated as an assertion, so Phase 11 changing it becomes visible here. The first version of this test passed while proving nothing, and the reason is worth recording. It set `Deployment:Mode` through `ConfigureAppConfiguration`, which under minimal hosting runs *after* the composition root has already read `builder.Configuration` — measured, the in-memory source had no effect whatever, `appsettings.Development.json` won, and the SaaS case silently exercised the Development branch. Every assertion about the ports still passed, because those resolve identically in both modes; only the error-tracking assertion caught it. `UseSetting` writes into the host configuration the builder itself reads. `SaaS` refuses to start without a Sentry DSN — the error-tracking composition treats a missing one as a configuration failure rather than degrading quietly — so supplying a DSN-shaped value is part of booting that mode rather than a way around the rule. Verified non-vacuous: pointing the SaaS branch at the Development tracker turns the test red. 671 tests green, 0 warnings under CI=true. Co-Authored-By: Claude Opus 5 (1M context) --- .../DeploymentModeCompositionTests.cs | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 backend/tests/LearnStack.Tests.Integration/DeploymentModeCompositionTests.cs diff --git a/backend/tests/LearnStack.Tests.Integration/DeploymentModeCompositionTests.cs b/backend/tests/LearnStack.Tests.Integration/DeploymentModeCompositionTests.cs new file mode 100644 index 00000000..47afcab5 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/DeploymentModeCompositionTests.cs @@ -0,0 +1,103 @@ +using FluentAssertions; +using LearnStack.Infrastructure.Caching; +using LearnStack.Infrastructure.Messaging; +using LearnStack.SharedKernel.Caching; +using LearnStack.SharedKernel.Hosting; +using LearnStack.SharedKernel.Messaging; +using LearnStack.SharedKernel.Observability; +using LearnStack.SharedKernel.Secrets; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace LearnStack.Tests.Integration; + +/// +/// The composition root branches on , and the two +/// modes ADR-0020 calls wired end to end — Development and SaaS — +/// are booted here rather than described. +/// +/// +/// +/// Existing coverage stops at reading the mode: that it has no default, +/// that a numeric string is refused. Nothing started the host in a second mode, +/// so "branching is present and exercised" rested on the branch compiling. A +/// branch that compiles can still throw at startup, register the wrong +/// implementation, or fail to resolve. +/// +/// +/// The other three modes are prepared seams, not supported deployments, until +/// Phase 11 builds their adapters and integration suites +/// (ADR-0035), +/// which is why only two are booted. +/// +/// +public sealed class DeploymentModeCompositionTests +{ + /// + /// A DSN-shaped value. SaaS refuses to start without one — the + /// error-tracking composition treats a missing DSN as a configuration + /// failure rather than degrading silently — so supplying it is part of + /// booting that mode, not a way around the rule. + /// + private const string DevelopmentShapedDsn = "https://0123456789abcdef@example.invalid/1"; + + [Theory] + [InlineData(nameof(DeploymentMode.Development))] + [InlineData(nameof(DeploymentMode.SaaS))] + public void The_Foundation_Ports_Resolve_To_Their_Defaults_In_Every_Wired_Mode(string mode) + { + // ADR-0035's claim in one assertion: the ports ship now with working + // defaults, and the vendor adapters land on a trigger — so every mode + // resolves the same implementations today. When Phase 11 changes that, + // this test is where the change becomes visible. + using var factory = For(mode); + using var scope = factory.Services.CreateScope(); + var services = scope.ServiceProvider; + + services.GetRequiredService().Should().BeOfType(); + services.GetRequiredService().Should().BeOfType(); + services.GetRequiredService() + .Should().BeOfType(); + } + + [Fact] + public void Error_Tracking_Is_The_One_Port_The_Mode_Actually_Changes() + { + // The branching has to be observable somewhere or it is not exercised at + // all. Error tracking is the seam that differs today: Development must + // not egress, SaaS reports to Sentry. + using var development = For(nameof(DeploymentMode.Development)); + using var saas = For(nameof(DeploymentMode.SaaS)); + + var inDevelopment = development.Services.GetRequiredService(); + var inSaaS = saas.Services.GetRequiredService(); + + inDevelopment.GetType().Name.Should().Be("NoOpErrorTracker"); + inSaaS.GetType().Name.Should().Be("SentryErrorTracker"); + } + + /// + /// Boots the host in one mode, overriding what + /// appsettings.Development.json sets. + /// + /// + /// UseSetting, not ConfigureAppConfiguration, and the + /// difference is not stylistic. Under minimal hosting the composition root + /// reads builder.Configuration while the builder is still being + /// assembled, which is before a deferred ConfigureAppConfiguration + /// callback runs — measured, an in-memory source added that way had no + /// effect at all, and the SaaS case silently resolved the Development + /// branch while the test still passed everything it asserted about the + /// ports. UseSetting writes into the host configuration the builder + /// itself reads. + /// + private static WebApplicationFactory For(string mode) => + new WebApplicationFactory().WithWebHostBuilder(builder => + { + builder.UseSetting("Deployment:Mode", mode); + builder.UseSetting("ErrorTracking:Sentry:Dsn", DevelopmentShapedDsn); + }); +} From 31bc8b529d59e06d0f700ae943ededfa88ad07c2 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Tue, 25 Aug 2026 10:19:39 +0300 Subject: [PATCH 10/21] feat(kernel): give the publish an envelope, and stop the transport lying MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five independent reviews of the event bus. The three questions that changed the contract were put to the user and approved; everything else below was reproduced before being fixed, and every fix has a test that fails when the code it covers is removed. ## The contract (ADR-0014 Amendment 3) `PublishAsync` takes an `IntegrationEventEnvelope`. Three things forced it, all measured: **The dispatch metadata had nowhere to travel.** The canonical `outbox_messages` row requires `topic` and `correlation_id` as `NOT NULL` and carries `organization_id`, `causation_id`, `actor_user_id`. None belong on the event — they describe the delivery, not the fact — and the two-parameter signature had no room. The transport read correlation from whatever context was ambient at dispatch, which is null inside the background service the outbox processor is, so the trace chain broke at exactly the boundary Standards 10 requires it to cross. **The partition key had two sources and the transport read the wrong one.** Measured: the bus never read the event's copy, and every test published an event declaring one key with a different one passed alongside — green. Ordering is guaranteed per partition key, so a key that can differ from itself is a guarantee that cannot be stated. The envelope reads it off the event. **No consumer could write state.** `AuditableEntity.MarkCreated` refuses `default(UserId)` and `Guid.Empty`; the consumer context supplied neither an actor nor an organization. Every state-writing handler threw from inside the kernel, and — because the canonical RLS policy fails closed when `app.organization_id` is unset — every organization-scoped read came back empty, which is the opposite of what the old comment claimed a hard null avoided. `UserId.SystemActor` is the documented fallback; the Tenancy migration seeds its row so `created_by` resolves. Amendment 2 wrote the rule this obeys: adding a required parameter after the first consumer exists breaks every call site. There is still not one. **A trap the non-generic port creates, closed with it.** With `IIntegrationEvent` as the declared type at every dispatch boundary, `JsonSerializer.Serialize(@event)` emits the four interface members and silently drops everything the concrete event added — measured, valid JSON, no exception, and the loss commits inside the transaction that reported success; the row then fails to deserialize on every retry until it dead-letters. `IntegrationEventBase.ToPayloadJson()` serialises by runtime type, and `PayloadJsonOptions` is named and fixed because a writer and a reader that disagree on casing dead-letter everything. ## The transport **A handler publishing about its own aggregate deadlocked forever and wedged the partition permanently.** The most ordinary consumer shape there is. My first fix ran the reentrant call inline, reasoning that the caller *is* the sequence — and that was worse: an `AsyncLocal` flows into every task started inside a unit, so a fire-and-forget spawn inherited the marker and ran *concurrently* with the unit it should have queued behind. Measured, twice: the one guarantee the class exists for, broken by the fix for a different bug. Same detection, opposite action. A false positive that throws is loud and diagnosable; one that runs inline is a silent concurrency violation. And the caller that hits it is publishing from inside a handler, which Standards 20 already forbids — a handler writes to the outbox. The marker is also an instance field now: it was static, so being inside a key on one serializer spoke for every other, and the integration tests build two hosts in one process. **The chain's tail was re-read outside the lock**, so another publisher's retirement could remove the key in that window and the caller got a `KeyNotFoundException` for an event that had already been queued and delivered — a success answered with a failure, which on the outbox path means the row is marked failed and redelivered. The observer is captured inside the lock. The stress test for it says plainly that it is a smoke check: the window reproduced about four times in 256,000 calls, so a green run is weak evidence and the real guarantee is structural. **One failing handler denied every later handler the event**, with no retry and no dead-letter — against a rule the corpus states as per-subscription poison containment. Every handler is attempted now and the failures are reported together. **Every handler shared one DI scope**, so two modules' consumers got the same DbContext and the same unit of work, across a boundary the architecture otherwise enforces hard. One scope per handler. **The scoped `ITenantContext` was never populated.** The bus set only the ambient accessor, so a handler injecting `ITenantContext` threw and one sending a MediatR command was short-circuited by `TenantContextBehavior` before its business logic ran — obligation three advertised and half delivered. The composition root now resolves the scoped context from the accessor, which is behaviour-preserving everywhere else because nothing wrote that accessor before the bus. Also: a pre-cancelled token no longer dispatches; a handler cancelled by a foreign token faults rather than making the publish look cancelled, which an outbox processor would read as "shutting down, retry later" and swallow; a null Task from a handler names the handler; failures are logged with event, tenant and partition, because an unawaited publish previously lost them entirely and the class had no logging at all; `IIntegrationEventHandler` is invariant, because `in` promised a variance the container does not honour — a handler registered for a base type compiles, registers, and is never invoked, and "no handler" is not an error, so the publish reported success having reached nobody. `Modules_Do_Not_Inject_IEventBus_Directly` closes the fifth-mechanism door. A namespace ban cannot express it — modules legitimately need `IIntegrationEvent` from the same namespace — so it is a type check, and because the module assemblies are still empty it is pointed at a deliberate offender in the test assembly first. A guard that cannot be shown to fire is not a guard. ## Skills The two skill files an implementer follows were teaching the opposite of what shipped: `wire-dapr-pubsub` told consumers to register `INotificationHandler` "in addition to" `IIntegrationEventHandler` — the two-implementations failure the single contract exists to prevent — and `add-integration-event`'s event sketch did not compile (no `PartitionKey` override, two `required` members unset) while listing four base members that do not exist and promising an organization context that is deliberately not restored. 686 tests green, 0 warnings under CI=true, 8 consecutive runs stable. Co-Authored-By: Claude Opus 5 (1M context) --- .claude/skills/add-integration-event/SKILL.md | 52 ++- .claude/skills/wire-dapr-pubsub/SKILL.md | 15 +- .../CrossCuttingFoundationExtensions.cs | 19 +- .../Messaging/InProcessEventBus.cs | 243 ++++++++++---- .../Messaging/PartitionSerializer.cs | 95 +++++- .../Identifiers/UserId.cs | 25 ++ .../Messaging/IEventBus.cs | 21 +- .../Messaging/IIntegrationEventHandler.cs | 14 +- .../Messaging/IntegrationEventBase.cs | 42 +++ .../Messaging/IntegrationEventEnvelope.cs | 72 +++++ .../Tenancy/EventTenantContext.cs | 74 +++-- .../CrossCuttingFoundationTests.cs | 54 ++++ .../Messaging/InProcessEventBusTests.cs | 296 ++++++++++++++++-- .../Messaging/PartitionSerializerTests.cs | 156 +++++++++ docs/decisions/0014-adopt-dapr.md | 71 +++++ 15 files changed, 1104 insertions(+), 145 deletions(-) create mode 100644 backend/src/LearnStack.SharedKernel/Messaging/IntegrationEventEnvelope.cs diff --git a/.claude/skills/add-integration-event/SKILL.md b/.claude/skills/add-integration-event/SKILL.md index b28d9145..4c87bd68 100644 --- a/.claude/skills/add-integration-event/SKILL.md +++ b/.claude/skills/add-integration-event/SKILL.md @@ -46,7 +46,7 @@ and [15-event-and-outbox.md](../../../docs/architecture/15-event-and-outbox.md). | Producing module | Yes | Owns the aggregate the event describes. | | Consuming module(s) | Yes | At least one; can be many. | | Topic | Derived | `learnstack.{module}.{aggregate}` (e.g. `learnstack.enrollment.enrollment`). | -| Schema fields | Yes | At minimum: `EventId`, `OccurredAt`, `TenantId`. Optional: `OrganizationId`, `CorrelationId`, `CausationId`, `ActorUserId`. | +| Schema fields | Yes | `IntegrationEventBase` supplies `EventId`, `OccurredAt`, `TenantId` (all `required`) and demands a `PartitionKey` override. Everything else is yours to declare. | ## Workflow @@ -57,19 +57,36 @@ In `.Application.Contracts/IntegrationEvents/.cs`: ```csharp public sealed record EnrollmentCreatedIntegrationEventV1 : IntegrationEventBase { - public Guid EnrollmentId { get; init; } - public Guid LearnerId { get; init; } - public Guid CourseVersionId { get; init; } + public required Guid EnrollmentId { get; init; } + public required Guid LearnerId { get; init; } + public required Guid CourseVersionId { get; init; } public Guid? CohortId { get; init; } - public string Source { get; init; } = default!; // "manual" | "billing" | "invitation" + public required string Source { get; init; } // "manual" | "billing" | "invitation" + + // Not optional: IntegrationEventBase declares PartitionKey abstract, so this + // record does not compile without it. Ordering is guaranteed per partition + // key and nowhere else, and the aggregate this event is about is the + // ordering domain. Keying on TenantId instead would serialise the tenant's + // whole stream onto one partition — a real throughput cost, and one worth + // taking deliberately rather than by inheriting a default. + public override string PartitionKey => EnrollmentId.ToString(); } ``` -`IntegrationEventBase` (shared kernel) provides: -- `EventId` (uuid v7) -- `OccurredAt` (UTC) -- `TenantId` -- Optional `OrganizationId`, `CorrelationId`, `CausationId`, `ActorUserId` +`IntegrationEventBase` (`LearnStack.SharedKernel.Messaging`) supplies exactly +four members, and every one of them is mandatory: +- `EventId` — `required`; identity for consumer-side deduplication +- `OccurredAt` — `required`; from `IClock`, never `DateTime.UtcNow` +- `TenantId` — `required`; what the transport restores before your handler runs +- `PartitionKey` — `abstract`; the ordering domain, declared by each event + +It supplies **no** `OrganizationId`, `CorrelationId`, `CausationId` or +`ActorUserId`. Correlation travels with the ambient context rather than on the +payload, and is asserted on the outbox row by +`Outbox_Row_Carries_Correlation_Context`. If your consumer genuinely needs the +organization or the acting user, declare them on your own record — but read the +note under Step 4 first, because the consumer's restored context will not carry +them. Versioning: a breaking change ships a **new** record (`V2`). The `V1` stays supported during the migration window. @@ -82,8 +99,9 @@ In the producer's command handler (see ```csharp await outbox.EnqueueAsync(new EnrollmentCreatedIntegrationEventV1 { + EventId = guidFactory.NewGuid(), // IGuidFactory, not Guid.NewGuid + OccurredAt = clock.UtcNow, // IClock per Standards 02 § Time TenantId = tenantContext.TenantId, - OrganizationId = tenantContext.OrganizationId, EnrollmentId = enrollment.Id.Value, LearnerId = request.LearnerId.Value, CourseVersionId = request.CourseVersionId.Value, @@ -156,8 +174,16 @@ Rules: `Integration_Event_Handlers_Use_InboxGuard` enforces this. - `MarkAsProcessed` enrolls in the same `DbContext`; the inbox marker and the business write commit atomically. -- Tenant + organization context is restored from the event envelope by middleware - before the handler runs. Don't read it from anywhere else. +- The **transport** — not middleware — restores tenant context from + `@event.TenantId` before your handler runs, and puts the publisher's own back + afterwards. Read it through `ITenantContext` as usual; don't read it from + anywhere else. +- **Organization is deliberately not restored.** A fact crossing a module + boundary is a tenant-level fact, and inventing an organization scope for the + consumer would narrow queries the producer never narrowed — the failure would + be silently missing rows rather than an error. If your consumer is genuinely + organization-scoped, carry the id as a field on your own event record and + filter on it explicitly. ### Step 5: Subscription registration diff --git a/.claude/skills/wire-dapr-pubsub/SKILL.md b/.claude/skills/wire-dapr-pubsub/SKILL.md index 6b0396f9..3dca6b46 100644 --- a/.claude/skills/wire-dapr-pubsub/SKILL.md +++ b/.claude/skills/wire-dapr-pubsub/SKILL.md @@ -171,11 +171,16 @@ else } ``` -`InProcessEventBus` publishes via `IPublisher` (MediatR); subscribers register as -`INotificationHandler` in addition to `IIntegrationEventHandler`. -The handler code is the **same** — the bus is the only difference. New events -require no extra registration for the dev path because the handler is discovered -by assembly scan. +`InProcessEventBus` resolves `IIntegrationEventHandler`, closed over the +event's **runtime** type, straight from the DI container. MediatR is not +involved: there is no `IPublisher` and no `INotificationHandler`, and registering +a second interface is precisely the mistake the single consumer contract exists +to prevent — two interfaces mean two implementations per consumer, and the one +exercised in CI would not be the one that runs in production. + +The handler code is the **same** on both transports; the bus is the only +difference. Register your handler once, as +`IIntegrationEventHandler`. ### Step 6: Cross-instance L1 cache invalidation diff --git a/backend/src/LearnStack.Api/Composition/CrossCuttingFoundationExtensions.cs b/backend/src/LearnStack.Api/Composition/CrossCuttingFoundationExtensions.cs index fd84bb5f..edf69d8f 100644 --- a/backend/src/LearnStack.Api/Composition/CrossCuttingFoundationExtensions.cs +++ b/backend/src/LearnStack.Api/Composition/CrossCuttingFoundationExtensions.cs @@ -71,7 +71,19 @@ public static WebApplicationBuilder AddLearnStackCrossCuttingFoundation( // resolved instance produced by TenantResolverMiddleware. The // singleton ITenantContextAccessor is set in // AddLearnStackObservabilityServices above. - builder.Services.TryAddScoped(_ => UnresolvedTenantContext.Instance); + // Resolved FROM the accessor rather than hard-wired to the unresolved + // singleton. Nothing wrote the accessor before the event bus, so this is + // behaviour-preserving for every HTTP path — and it is what makes the + // bus's tenant restoration reach the scope a handler actually resolves + // from. Setting only the ambient accessor left the scoped ITenantContext + // unresolved: a handler injecting it threw, and one sending a MediatR + // command was short-circuited by TenantContextBehavior before its + // business logic ran, so the obligation the transport advertises was + // half-delivered. Packet 7's TenantResolverMiddleware writes the same + // accessor. + builder.Services.TryAddScoped(sp => + sp.GetRequiredService().Current + ?? UnresolvedTenantContext.Instance); // IClock and IGuidFactory have existed in the kernel since Packet 2 and // were never registered, because nothing consumed them. Packet 4's @@ -264,7 +276,10 @@ private static LearnStack.SharedKernel.Messaging.IEventBus SelectEventBus( return new LearnStack.Infrastructure.Messaging.InProcessEventBus( services.GetRequiredService(), services.GetRequiredService(), - services.GetRequiredService()); + services.GetRequiredService(), + services.GetRequiredService< + Microsoft.Extensions.Logging.ILogger< + LearnStack.Infrastructure.Messaging.InProcessEventBus>>()); } /// diff --git a/backend/src/LearnStack.Infrastructure/Messaging/InProcessEventBus.cs b/backend/src/LearnStack.Infrastructure/Messaging/InProcessEventBus.cs index ec028107..cdea8d04 100644 --- a/backend/src/LearnStack.Infrastructure/Messaging/InProcessEventBus.cs +++ b/backend/src/LearnStack.Infrastructure/Messaging/InProcessEventBus.cs @@ -3,6 +3,7 @@ using LearnStack.SharedKernel.Messaging; using LearnStack.SharedKernel.Tenancy; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; namespace LearnStack.Infrastructure.Messaging; @@ -22,13 +23,21 @@ namespace LearnStack.Infrastructure.Messaging; /// the same consumer-side deduplication — the handler calls /// IInboxGuard itself, exactly as it does behind a broker, because a /// transport that never delivers a duplicate never surfaces the most common -/// integration-event defect; -/// the same tenant-context restoration from the event, so Row Level -/// Security and the query filters are exercised on the consumer side; +/// integration-event defect. That seam lands in Phase 02b; today the contract is +/// shaped for it and nothing else; +/// the same tenant-context restoration, into the scope the handler +/// resolves from, so Row Level Security and the query filters are exercised on +/// the consumer side; /// the same per-partition-key ordering, because an ordering assumption /// that holds only in one process is discovered in production. /// /// +/// It also carries the same failure isolation. Poison-message containment +/// is per subscription: one module's broken handler must not deny another module +/// the event. Every handler is attempted, and the failures are reported +/// together. +/// +/// /// What it genuinely does not provide — and therefore the trigger for the Dapr /// adapter in /// Phase 11 @@ -36,76 +45,198 @@ namespace LearnStack.Infrastructure.Messaging; /// — is delivery to a second process, broker-side retention and replay. /// /// -public sealed class InProcessEventBus( +public sealed partial class InProcessEventBus( IServiceScopeFactory scopeFactory, ITenantContextAccessor tenantAccessor, - IPartitionSerializer partitions) : IEventBus + IPartitionSerializer partitions, + ILogger logger) : IEventBus { private const string HandleMethodName = nameof(IIntegrationEventHandler.HandleAsync); public Task PublishAsync( - IIntegrationEvent @event, - string partitionKey, + IntegrationEventEnvelope envelope, CancellationToken cancellationToken = default) { - ArgumentNullException.ThrowIfNull(@event); - ArgumentException.ThrowIfNullOrWhiteSpace(partitionKey); + ArgumentNullException.ThrowIfNull(envelope); + + // Checked before anything is queued, so a publish on an already-cancelled + // token behaves the way a broker-backed one would — it fails rather than + // dispatching. Returned as a faulted task rather than thrown inline: a + // fire-and-forget call site must not crash its caller synchronously. + if (cancellationToken.IsCancellationRequested) + { + return Task.FromCanceled(cancellationToken); + } + + return partitions.RunSequentiallyFor( + envelope.PartitionKey, + () => DispatchAsync(envelope, cancellationToken)); + } + + private async Task DispatchAsync( + IntegrationEventEnvelope envelope, CancellationToken cancellationToken) + { + // By RUNTIME type. The event is declared as the base interface here, so a + // closed generic over its static type would resolve + // IIntegrationEventHandler — which no concrete + // consumer implements — and the publish would reach zero handlers and + // report success. + var contract = typeof(IIntegrationEventHandler<>) + .MakeGenericType(envelope.Event.GetType()); + var handle = contract.GetMethod(HandleMethodName)!; + var context = EventTenantContext.FromEnvelope(envelope); + var count = HandlerCount(contract); + + if (count == 0) + { + // Not an error — an event nobody consumes is legitimate — but silence + // here is indistinguishable from a handler registered for a type the + // container will never match, so it is said out loud once. + ReachedNoHandler(logger, envelope.Event.GetType().Name, envelope.Event.EventId); + return; + } + + List? failures = null; + + for (var index = 0; index < count; index++) + { + try + { + await DeliverAsync(contract, handle, index, envelope, context, cancellationToken) + .ConfigureAwait(false); + } + catch (Exception ex) + { + // Collected, not rethrown here. Poison-message containment is per + // subscription: letting the first fault escape the loop would let + // one module's broken handler deny every other module the event, + // with no retry and no dead-letter to show for it. + HandlerFailed( + logger, + envelope.Event.GetType().Name, + envelope.Event.EventId, + index, + envelope.Event.TenantId, + envelope.PartitionKey, + ex); + + (failures ??= []).Add(ex); + } + } + + if (failures is { Count: 1 }) + { + ExceptionDispatchInfo.Capture(failures[0]).Throw(); + } + + if (failures is { Count: > 1 }) + { + throw new AggregateException( + $"{failures.Count} handlers failed for {envelope.Event.GetType().Name}.", + failures); + } + } + + private async Task DeliverAsync( + Type contract, + MethodInfo handle, + int index, + IntegrationEventEnvelope envelope, + ITenantContext context, + CancellationToken cancellationToken) + { + // One scope per HANDLER, not one per event. Under a broker each + // subscription gets its own; sharing one here would hand two modules' + // consumers the same DbContext and the same unit of work, so a failed + // handler's dirty state would cross a module boundary the architecture + // otherwise enforces hard. + // + // Selected by index rather than by concrete type, because a handler is + // registered against the CONTRACT — its own type is not a service, and + // asking the container for it fails. + await using var scope = scopeFactory.CreateAsyncScope(); - return partitions.RunSequentiallyFor(partitionKey, async () => + // Restored into the flow the handler runs in AND into the scope it + // resolves ITenantContext from — the composition root binds the scoped + // context to this accessor. Setting only the ambient one left the scoped + // ITenantContext unresolved, so a handler injecting it threw and a + // handler sending a MediatR command was short-circuited by + // TenantContextBehavior before its business logic ran. + var previous = tenantAccessor.Current; + tenantAccessor.Current = context; + + try { - await using var scope = scopeFactory.CreateAsyncScope(); + var handler = scope.ServiceProvider.GetServices(contract).ElementAt(index)!; - // Restored into the flow the handler runs in, and the publisher's own - // context put back afterwards — a synchronous dispatch would - // otherwise leak a tenant into the caller that published the event. - var previous = tenantAccessor.Current; - tenantAccessor.Current = EventTenantContext.FromEvent(@event, previous?.CorrelationId); + Task delivery; try { - // By RUNTIME type. `@event` is declared as the base interface - // here, so a closed generic over its static type would resolve - // IIntegrationEventHandler — which no concrete - // consumer implements — and the publish would reach zero handlers - // and report success. - var contract = typeof(IIntegrationEventHandler<>).MakeGenericType(@event.GetType()); - - // Invoked through the INTERFACE method rather than by `dynamic` - // on the instance. The dynamic binder honours accessibility, so - // an `internal` handler — the normal shape for a module's own - // consumer — would fail to bind at runtime; an interface method - // dispatches virtually and does not care what the concrete type's - // visibility is. - var handle = contract.GetMethod(HandleMethodName)!; - - foreach (var handler in scope.ServiceProvider.GetServices(contract)) - { - Task delivery; - - try - { - delivery = (Task)handle.Invoke(handler, [@event, cancellationToken])!; - } - catch (TargetInvocationException wrapped) when (wrapped.InnerException is not null) - { - // A handler that throws before its first await throws out - // of Invoke, which wraps it. Unwrapped and rethrown with - // its stack intact, because a consumer and the error - // pipeline both key on the exception type: a - // TargetInvocationException would tell them the transport - // failed when the handler did. - ExceptionDispatchInfo.Capture(wrapped.InnerException).Throw(); - throw; - } - - await delivery; - } + delivery = (Task)handle.Invoke(handler, [envelope.Event, cancellationToken])!; } - finally + catch (TargetInvocationException wrapped) when (wrapped.InnerException is not null) { - tenantAccessor.Current = previous; + // A handler that throws before its first await throws out of + // Invoke, which wraps it. Unwrapped and rethrown with its stack + // intact, because a consumer and the error pipeline both key on + // the exception type: a TargetInvocationException would tell them + // the transport failed when the handler did. + ExceptionDispatchInfo.Capture(wrapped.InnerException).Throw(); + throw; } - }); + + if (delivery is null) + { + throw new InvalidOperationException( + $"{handler.GetType().FullName} returned a null Task from {HandleMethodName}."); + } + + await delivery.ConfigureAwait(false); + } + catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested) + { + // A handler that observed some OTHER cancellation must not make the + // publish look cancelled: an outbox processor would read that as + // "we are shutting down, retry later" and silently swallow a handler + // that ran and gave up. + throw new InvalidOperationException( + "An integration-event handler was cancelled by a token other than the publish token.", + ex); + } + finally + { + tenantAccessor.Current = previous; + } + } + + private int HandlerCount(Type contract) + { + using var scope = scopeFactory.CreateScope(); + + return scope.ServiceProvider.GetServices(contract).Count(); } + + [LoggerMessage( + EventId = 1, + Level = LogLevel.Error, + Message = "Integration event {EventType} ({IntegrationEventId}) failed in handler " + + "#{HandlerIndex} for tenant {TenantId} on partition {PartitionKey}")] + private static partial void HandlerFailed( + ILogger logger, + string eventType, + Guid integrationEventId, + int handlerIndex, + Guid tenantId, + string partitionKey, + Exception exception); + + [LoggerMessage( + EventId = 2, + Level = LogLevel.Debug, + Message = "Integration event {EventType} ({IntegrationEventId}) reached no handler")] + private static partial void ReachedNoHandler( + ILogger logger, string eventType, Guid integrationEventId); + } diff --git a/backend/src/LearnStack.Infrastructure/Messaging/PartitionSerializer.cs b/backend/src/LearnStack.Infrastructure/Messaging/PartitionSerializer.cs index a7e3adc7..f913e75e 100644 --- a/backend/src/LearnStack.Infrastructure/Messaging/PartitionSerializer.cs +++ b/backend/src/LearnStack.Infrastructure/Messaging/PartitionSerializer.cs @@ -26,12 +26,60 @@ public sealed class PartitionSerializer : IPartitionSerializer private readonly ConcurrentDictionary _tails = new(StringComparer.Ordinal); private readonly object _gate = new(); + /// + /// The key whose work the current execution flow is inside, if any. + /// + /// + /// + /// Queuing work for a key from inside that key's own work is a deadlock by + /// construction: the new unit chains behind a tail that cannot complete + /// until the current one returns. Measured on the version with no detection + /// at all — it hung, and the partition stayed wedged for every later event + /// for the life of the process. + /// + /// + /// Detected to refuse, never to run inline. An earlier attempt ran + /// the reentrant call inline, reasoning that the caller is the + /// sequence. It is not sound: an flows into + /// every task started inside a unit, so a fire-and-forget + /// _ = RunSequentiallyFor(sameKey, …) inherited the marker and ran + /// concurrently with the unit it should have queued behind — + /// measured, and the one guarantee this class exists for. The detection is + /// the same either way; only the action differs, and that asymmetry is the + /// whole point. A false positive from a spawned flow throws where it could + /// have queued: loud, diagnosable, safe. A false positive that runs inline + /// is a silent concurrency violation. + /// + /// + /// An instance field, not static: two hosts in one process — which the + /// integration tests build deliberately — otherwise share one marker, and + /// being inside a key on one serializer would speak for the other. + /// + /// + private readonly AsyncLocal _executingKey = new(); + public Task RunSequentiallyFor(string partitionKey, Func work) { ArgumentException.ThrowIfNullOrWhiteSpace(partitionKey); ArgumentNullException.ThrowIfNull(work); + // Refused rather than deadlocked. The caller that hits this is a + // consumer publishing from inside a handler, which Standards 20 already + // forbids for its own reasons — a handler writes to the outbox, and the + // OutboxProcessor is the only sanctioned publisher. Answering it with a + // message beats answering it with a hang. + if (string.Equals(_executingKey.Value, partitionKey, StringComparison.Ordinal)) + { + return Task.FromException(new InvalidOperationException( + $"Work for partition key '{partitionKey}' is already running on this " + + "execution flow, and queuing more behind it would wait for a unit that " + + "cannot finish until this one returns. An integration-event handler " + + "must not publish — it writes to the outbox, and the OutboxProcessor " + + "publishes (Standards 20 § IEventBus).")); + } + Task queued; + Task observer; // The read-modify-write of the tail has to be atomic against another // publisher for the same key, or two units both continue from the same @@ -43,12 +91,8 @@ public Task RunSequentiallyFor(string partitionKey, Func work) { var previous = _tails.TryGetValue(partitionKey, out var tail) ? tail : Task.CompletedTask; - // Faults do not break the chain. A handler that throws must not stop - // every later event for that aggregate from being delivered — the - // failure belongs to one unit, and the caller awaiting `queued` is - // the one that sees it. queued = previous.ContinueWith( - _ => work(), + _ => RunMarked(_executingKey, partitionKey, work), CancellationToken.None, TaskContinuationOptions.None, TaskScheduler.Default) @@ -62,15 +106,24 @@ public Task RunSequentiallyFor(string partitionKey, Func work) // free not to await what RunSequentiallyFor returns, and then nobody // observes the fault — TaskScheduler.UnobservedTaskException fires, // with no request and no correlation id attached to it. - _tails[partitionKey] = queued.ContinueWith( + observer = queued.ContinueWith( static completed => { _ = completed.Exception; }, CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); + + _tails[partitionKey] = observer; } - _ = _tails[partitionKey].ContinueWith( - _ => Retire(partitionKey), + // Attached to the observer captured INSIDE the lock, not re-read from + // the dictionary. Measured on the version that re-read it: another + // publisher's retirement could remove the key in the window between the + // lock and the indexer, and the caller got a KeyNotFoundException for an + // event whose work had already been queued and delivered — a success + // answered with a failure, which on the outbox path means the row is + // marked failed and redelivered. + _ = observer.ContinueWith( + _ => Retire(partitionKey, observer), CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); @@ -78,6 +131,22 @@ public Task RunSequentiallyFor(string partitionKey, Func work) return queued; } + private static async Task RunMarked( + AsyncLocal executingKey, string partitionKey, Func work) + { + var previous = executingKey.Value; + executingKey.Value = partitionKey; + + try + { + await work().ConfigureAwait(false); + } + finally + { + executingKey.Value = previous; + } + } + /// How many partition keys currently have work chained. A diagnostic. /// /// Exposed so the "one entry per in-flight key, not per key ever seen" claim @@ -85,16 +154,16 @@ public Task RunSequentiallyFor(string partitionKey, Func work) /// public int TrackedPartitions => _tails.Count; - private void Retire(string partitionKey) + private void Retire(string partitionKey, Task observer) { lock (_gate) { - // Only when this key's tail is the one that just finished. Another - // publisher may have chained onto it in the meantime, and dropping - // the entry then would let the next unit start from + // Only when this key's tail is still the one that just finished. + // Another publisher may have chained onto it in the meantime, and + // dropping the entry then would let the next unit start from // Task.CompletedTask — running concurrently with work still in // flight, which is the ordering break this class prevents. - if (_tails.TryGetValue(partitionKey, out var tail) && tail.IsCompleted) + if (_tails.TryGetValue(partitionKey, out var tail) && ReferenceEquals(tail, observer)) { _tails.TryRemove(new KeyValuePair(partitionKey, tail)); } diff --git a/backend/src/LearnStack.SharedKernel/Identifiers/UserId.cs b/backend/src/LearnStack.SharedKernel/Identifiers/UserId.cs index 7b99140c..1f15da8b 100644 --- a/backend/src/LearnStack.SharedKernel/Identifiers/UserId.cs +++ b/backend/src/LearnStack.SharedKernel/Identifiers/UserId.cs @@ -19,4 +19,29 @@ namespace LearnStack.SharedKernel.Identifiers; [ValueObject(LearnStackVogenDefaults.IdMask)] public readonly partial record struct UserId : IStronglyTypedId { + /// + /// The actor an integration-event consumer, a background job or any other + /// non-request execution writes state as. + /// + /// + /// + /// A consumer runs outside the request that produced the fact, so there is + /// no human to attribute its writes to — and + /// Standards 18 + /// says such work is audited as an actor of type system. Without a + /// concrete id that rule cannot be honoured: AuditableEntity.MarkCreated + /// refuses default(UserId) and Guid.Empty alike, so a + /// null actor left every state-writing consumer with no value it + /// could legally pass and nothing to write at all. + /// + /// + /// The value is fixed rather than generated, because it is a foreign key: + /// the Tenancy migration seeds the matching users row so + /// created_by resolves. Version 7 shape with an all-zero random + /// section, so it reads as deliberate in a database dump rather than as a + /// stray identifier somebody forgot to replace. + /// + /// + public static UserId SystemActor { get; } = + From(Guid.Parse("00000000-0000-7000-8000-000000000001")); } diff --git a/backend/src/LearnStack.SharedKernel/Messaging/IEventBus.cs b/backend/src/LearnStack.SharedKernel/Messaging/IEventBus.cs index 041afaa1..dec8496d 100644 --- a/backend/src/LearnStack.SharedKernel/Messaging/IEventBus.cs +++ b/backend/src/LearnStack.SharedKernel/Messaging/IEventBus.cs @@ -1,5 +1,3 @@ -using System.Diagnostics.CodeAnalysis; - namespace LearnStack.SharedKernel.Messaging; /// @@ -19,24 +17,15 @@ namespace LearnStack.SharedKernel.Messaging; /// transports resolve by the event's runtime type instead. /// /// -/// The partition key is a parameter rather than being read off the event because -/// the producer resolves it once, at enqueue time, and writes it to the outbox -/// row; the processor passes back what it stored. Nothing downstream re-derives -/// it, so the ordering domain cannot drift between enqueue and publish. +/// The envelope carries the dispatch metadata the outbox row holds and the event +/// does not — topic, correlation, organization, causation, actor — and reads the +/// partition key off the event, so the ordering domain has exactly one source. /// /// -[SuppressMessage( - "Naming", - "CA1716:Identifiers should not match keywords", - Justification = "LearnStack is C#-only per ADR-0032, and architecture/15 " - + "publishes this signature with the parameter spelled @event; renaming " - + "would put the corpus and the code out of step for a cross-language " - + "concern that does not exist.")] public interface IEventBus { - /// Publishes one event, ordered against others sharing its partition key. + /// Publishes one envelope, ordered against others sharing its partition key. Task PublishAsync( - IIntegrationEvent @event, - string partitionKey, + IntegrationEventEnvelope envelope, CancellationToken cancellationToken = default); } diff --git a/backend/src/LearnStack.SharedKernel/Messaging/IIntegrationEventHandler.cs b/backend/src/LearnStack.SharedKernel/Messaging/IIntegrationEventHandler.cs index f52bea57..98e680a8 100644 --- a/backend/src/LearnStack.SharedKernel/Messaging/IIntegrationEventHandler.cs +++ b/backend/src/LearnStack.SharedKernel/Messaging/IIntegrationEventHandler.cs @@ -22,7 +22,17 @@ namespace LearnStack.SharedKernel.Messaging; /// the contract is shaped for it now so no handler is written twice. /// /// -/// The event type this handler consumes. +/// +/// +/// Invariant on purpose. Declaring in TEvent would promise a +/// variance the container does not honour: measured, a handler registered for a +/// base event type compiles, registers, and is never invoked, because +/// GetServices matches the closed generic exactly — and "no handler" is +/// not an error here, so the publish reports success having reached nobody. +/// A promise the runtime cannot keep is worse than no promise. +/// +/// +/// The concrete event type this handler consumes. [SuppressMessage( "Naming", "CA1711:Identifiers should not have incorrect suffix", @@ -39,7 +49,7 @@ namespace LearnStack.SharedKernel.Messaging; + "consumer sketch in architecture/15 spells the parameter @event; a " + "different name here would put the corpus and the code out of step for " + "a cross-language concern that does not exist.")] -public interface IIntegrationEventHandler +public interface IIntegrationEventHandler where TEvent : IIntegrationEvent { /// Handles one delivery. May be called more than once per event. diff --git a/backend/src/LearnStack.SharedKernel/Messaging/IntegrationEventBase.cs b/backend/src/LearnStack.SharedKernel/Messaging/IntegrationEventBase.cs index 385dd8a6..18995194 100644 --- a/backend/src/LearnStack.SharedKernel/Messaging/IntegrationEventBase.cs +++ b/backend/src/LearnStack.SharedKernel/Messaging/IntegrationEventBase.cs @@ -1,3 +1,5 @@ +using System.Text.Json; + namespace LearnStack.SharedKernel.Messaging; /// @@ -31,4 +33,44 @@ public abstract record IntegrationEventBase : IIntegrationEvent /// public abstract string PartitionKey { get; } + + /// + /// Serialises this event for storage, by its runtime type. + /// + /// + /// + /// Not a convenience — the one way to write a payload that does not lose + /// data. Measured: JsonSerializer.Serialize(@event) where the + /// declared type is — which it is at every + /// dispatch boundary, because ADR-0014 Amendment 2 made the port non-generic + /// precisely so it would be — emits only the four members declared on the + /// interface and silently drops every field the concrete event added. No + /// exception, valid JSON. The row commits inside the business transaction + /// that reported success, and the loss surfaces later as a + /// JsonException on every dispatch attempt until the message + /// dead-letters. + /// + /// + /// Non-virtual and sealed by being non-virtual: an override could + /// reintroduce exactly the bug it exists to prevent. + /// + /// + public string ToPayloadJson(JsonSerializerOptions? options = null) => + JsonSerializer.Serialize(this, GetType(), options ?? PayloadJsonOptions); + + /// + /// The serializer options the payload is written and read with. + /// + /// + /// Named and fixed, because they are part of the wire contract rather than a + /// formatting preference: measured, a payload written with + /// and read with the default + /// options fails on every member, since one camel-cases and the other does + /// not. A writer and a reader that disagree here dead-letter everything. + /// + public static JsonSerializerOptions PayloadJsonOptions { get; } = new() + { + PropertyNamingPolicy = null, + WriteIndented = false, + }; } diff --git a/backend/src/LearnStack.SharedKernel/Messaging/IntegrationEventEnvelope.cs b/backend/src/LearnStack.SharedKernel/Messaging/IntegrationEventEnvelope.cs new file mode 100644 index 00000000..2b3ee7df --- /dev/null +++ b/backend/src/LearnStack.SharedKernel/Messaging/IntegrationEventEnvelope.cs @@ -0,0 +1,72 @@ +using LearnStack.SharedKernel.Identifiers; + +namespace LearnStack.SharedKernel.Messaging; + +/// +/// One integration event plus the dispatch metadata the outbox row carries and +/// the event itself does not. +/// +/// +/// +/// The canonical outbox_messages row +/// (Standards 05) +/// requires topic and correlation_id as NOT NULL and carries +/// organization_id, causation_id and actor_user_id. None of +/// them belong on the event: they describe the delivery, not the fact. +/// Without somewhere to put them, a dispatcher had no way to hand them to a +/// consumer at all — correlation was read from the publisher's ambient context, +/// which is null in the background service the outbox processor is, so the +/// trace chain broke at exactly the boundary +/// Standards 10 +/// requires it to cross. +/// +/// +/// One type rather than more parameters, and now rather than later: ADR-0014 +/// Amendment 2 says it in this repository's own words — adding a required +/// parameter after the first consumer exists breaks every call site. There is +/// not one yet. +/// +/// +/// The fact being published. +/// +/// The channel, learnstack.{module}.{aggregate}. Meaningless to the +/// in-process transport, which addresses handlers by CLR type, and load-bearing +/// for every durable one — so it is carried from the start rather than invented +/// when the first broker arrives. +/// +/// +/// The originating request's W3C traceparent, taken from the outbox row rather +/// than from whatever context happens to be ambient at dispatch. +/// +/// +/// The organization the fact belongs to, when it belongs to one. A consumer is +/// restored into this scope, so a tenant-wide event and an organization-scoped +/// one are no longer indistinguishable to it. +/// +/// The event or command that caused this one, if any. +/// +/// Who caused the fact. A consumer writing state attributes to +/// when this is absent. +/// +public sealed record IntegrationEventEnvelope( + IIntegrationEvent Event, + string Topic, + string CorrelationId, + Guid? OrganizationId = null, + Guid? CausationId = null, + UserId? ActorUserId = null) +{ + /// + /// The ordering domain, read from the event and from nowhere else. + /// + /// + /// It was briefly a separate parameter alongside the event's own + /// , which meant two sources for + /// one value with nothing reconciling them — measured, the transport read + /// the parameter and never the event, and the tests published events whose + /// declared key disagreed with the one passed, green. Ordering is guaranteed + /// per partition key, so a key that can differ from itself is a guarantee + /// that cannot be stated. + /// + public string PartitionKey => Event.PartitionKey; +} diff --git a/backend/src/LearnStack.SharedKernel/Tenancy/EventTenantContext.cs b/backend/src/LearnStack.SharedKernel/Tenancy/EventTenantContext.cs index dea4fa44..0b332819 100644 --- a/backend/src/LearnStack.SharedKernel/Tenancy/EventTenantContext.cs +++ b/backend/src/LearnStack.SharedKernel/Tenancy/EventTenantContext.cs @@ -4,22 +4,25 @@ namespace LearnStack.SharedKernel.Tenancy; using LearnStack.SharedKernel.Messaging; /// -/// The tenant context a consumer runs under, rebuilt from the event it is +/// The tenant context a consumer runs under, rebuilt from the envelope it is /// handling. /// /// /// A consumer runs outside the request that produced the fact, so there is no -/// ambient context to inherit — which is exactly why -/// travels on the event. Restoring it -/// before a handler runs is what makes the query filters and the Row Level -/// Security policies evaluate against the right tenant; a transport that skipped -/// it would run every consumer against nothing. +/// ambient context to inherit — which is why the tenant travels on the event and +/// the rest travels on the envelope. Restoring it before a handler runs is what +/// makes the query filters and the Row Level Security policies evaluate against +/// the right scope; a transport that skipped it would run every consumer against +/// nothing. /// public sealed class EventTenantContext : ITenantContext { - private EventTenantContext(Guid tenantId, string? correlationId) + private EventTenantContext( + Guid tenantId, Guid? organizationId, UserId userId, string? correlationId) { TenantId = tenantId; + OrganizationId = organizationId; + UserId = userId; CorrelationId = correlationId; } @@ -30,19 +33,33 @@ private EventTenantContext(Guid tenantId, string? correlationId) public Guid TenantId { get; } /// - /// Always null: an integration event carries the tenant, not an - /// organization. + /// The organization the fact belongs to, when the envelope names one. /// /// - /// Deliberate rather than missing. A fact crossing a module boundary is a - /// tenant-level fact, and inventing an organization scope for the consumer - /// would narrow queries the producer never narrowed — the failure would be - /// silently missing rows, not an error. + /// An earlier version hard-coded this to null, reasoning that a + /// cross-module fact is tenant-level and that inventing an organization + /// scope would narrow queries the producer never narrowed. Under the + /// canonical Row Level Security policy + /// (Standards 05) + /// the reasoning inverts: with app.organization_id unset, an + /// organization-scoped row evaluates false OR NULL OR NULL, and a + /// NULL policy result is false — so a hard null hides every + /// organization-scoped row instead of widening to all of them, and + /// WITH CHECK rejects writing one. Widening is the + /// app.scope = 'tenant' hatch, not an absent value. /// - public Guid? OrganizationId => null; + public Guid? OrganizationId { get; } - /// Always null: a consumer acts as the system, not as a user. - public UserId? UserId => null; + /// Who the consumer's writes are attributed to. + /// + /// Never null. AuditableEntity.MarkCreated refuses + /// default(UserId) and Guid.Empty, so a null actor left every + /// state-writing consumer with no value it could legally pass — it could not + /// create an aggregate at all. Absent an actor on the envelope this is + /// , which is what Standards 18 means by + /// auditing such work as an actor of type system. + /// + public UserId? UserId { get; } /// public string? CorrelationId { get; } @@ -50,11 +67,28 @@ private EventTenantContext(Guid tenantId, string? correlationId) /// public string? ModuleName => null; - /// Builds the context a handler for runs under. - public static EventTenantContext FromEvent(IIntegrationEvent @event, string? correlationId = null) + /// Builds the context a handler for runs under. + public static EventTenantContext FromEnvelope(IntegrationEventEnvelope envelope) { - ArgumentNullException.ThrowIfNull(@event); + ArgumentNullException.ThrowIfNull(envelope); + + // A confidently-resolved context for a tenant that does not exist is + // worse than an unresolved one: once TransactionBehavior issues + // SET LOCAL app.tenant_id, every query silently returns nothing instead + // of failing. UnresolvedTenantContext.TenantId throws for the same + // reason, and this is the path that would otherwise route around it. + if (envelope.Event.TenantId == Guid.Empty) + { + throw new ArgumentException( + "An integration event carries no tenant. A consumer restored into " + + "an all-zero tenant reads and writes nothing, silently.", + nameof(envelope)); + } - return new EventTenantContext(@event.TenantId, correlationId); + return new EventTenantContext( + envelope.Event.TenantId, + envelope.OrganizationId, + envelope.ActorUserId ?? Identifiers.UserId.SystemActor, + envelope.CorrelationId); } } diff --git a/backend/tests/LearnStack.Tests.Architecture/CrossCuttingFoundationTests.cs b/backend/tests/LearnStack.Tests.Architecture/CrossCuttingFoundationTests.cs index ad4e6650..21546910 100644 --- a/backend/tests/LearnStack.Tests.Architecture/CrossCuttingFoundationTests.cs +++ b/backend/tests/LearnStack.Tests.Architecture/CrossCuttingFoundationTests.cs @@ -312,6 +312,60 @@ public void Modules_Do_Not_Reference_DeploymentMode() } } + [Fact] + public void Modules_Do_Not_Inject_IEventBus_Directly() + { + // Standards 20 § IEventBus: the only sanctioned publisher is the + // OutboxProcessor. A module that injects IEventBus gets a synchronous + // cross-module call with no durability and no transactional atomicity — + // a fifth cross-module mechanism in everything but name + // (ADR-0010 admits four), and one that looks like it works in every + // development test because the in-process transport delivers inline. + // + // A namespace ban cannot express this: modules legitimately depend on + // LearnStack.SharedKernel.Messaging for IIntegrationEvent and + // IIntegrationEventHandler. Only the bus itself is off limits. + // + // The module assemblies carry no types yet, so this would be vacuous — + // which is why the checker is pointed at a deliberate offender in this + // assembly first. A guard that cannot be shown to fire is not a guard. + InjectsEventBus(typeof(DeliberateEventBusInjector)).Should().BeTrue( + "the checker must catch a type that does inject the bus, or it " + + "proves nothing about the modules it is aimed at"); + InjectsEventBus(typeof(CrossCuttingFoundationTests)).Should().BeFalse(); + + foreach (var name in ModuleAssemblyShapes) + { + var assembly = TryLoadAssembly(name); + if (assembly is null) continue; + + var offenders = assembly.GetTypes().Where(InjectsEventBus).Select(t => t.FullName).ToList(); + + offenders.Should().BeEmpty( + $"{name} injects IEventBus. Modules write to the outbox; the " + + "OutboxProcessor publishes (Standards 20 § IEventBus)."); + } + } + + private static bool InjectsEventBus(Type type) + { + var bus = typeof(LearnStack.SharedKernel.Messaging.IEventBus); + + return type.GetConstructors().Any(constructor => + constructor.GetParameters().Any(p => bus.IsAssignableFrom(p.ParameterType))) + || type.GetFields( + System.Reflection.BindingFlags.Instance + | System.Reflection.BindingFlags.NonPublic + | System.Reflection.BindingFlags.Public) + .Any(field => bus.IsAssignableFrom(field.FieldType)); + } + + /// A type that breaks the rule, so the checker can be shown to catch it. + private sealed class DeliberateEventBusInjector(LearnStack.SharedKernel.Messaging.IEventBus bus) + { + public LearnStack.SharedKernel.Messaging.IEventBus Bus { get; } = bus; + } + [Fact] public void IErrorTrackingProvider_Is_Singleton() { diff --git a/backend/tests/LearnStack.Tests.Unit/Infrastructure/Messaging/InProcessEventBusTests.cs b/backend/tests/LearnStack.Tests.Unit/Infrastructure/Messaging/InProcessEventBusTests.cs index bea9b205..cae18085 100644 --- a/backend/tests/LearnStack.Tests.Unit/Infrastructure/Messaging/InProcessEventBusTests.cs +++ b/backend/tests/LearnStack.Tests.Unit/Infrastructure/Messaging/InProcessEventBusTests.cs @@ -1,9 +1,11 @@ using System.Collections.Concurrent; using FluentAssertions; using LearnStack.Infrastructure.Messaging; +using LearnStack.SharedKernel.Identifiers; using LearnStack.SharedKernel.Messaging; using LearnStack.SharedKernel.Tenancy; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; using Xunit; namespace LearnStack.Tests.Unit.Infrastructure.Messaging; @@ -24,6 +26,8 @@ public sealed class InProcessEventBusTests { private static readonly Guid Tenant = Guid.Parse("018f4d40-0000-7000-8000-00000000000a"); private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(10); + private const string Topic = "learnstack.test.thing"; + private const string Trace = "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"; [Fact] public async Task A_Handler_Receives_The_Event() @@ -32,7 +36,7 @@ public async Task A_Handler_Receives_The_Event() var (bus, _) = Build(recorder, services => services.AddScoped, ThingHandler>()); - await bus.PublishAsync(NewThing("a"), "p1"); + await bus.PublishAsync(Envelope(NewThing("a"))); recorder.Handled.Should().ContainSingle().Which.Should().Be("a"); } @@ -50,7 +54,7 @@ public async Task An_Internal_Handler_Is_Invoked_Too() var (bus, _) = Build(recorder, services => services.AddScoped, InternalThingHandler>()); - await bus.PublishAsync(NewThing("a"), "p1"); + await bus.PublishAsync(Envelope(NewThing("a"))); recorder.Handled.Should().ContainSingle().Which.Should().Be("internal:a"); } @@ -69,7 +73,7 @@ public async Task Publishing_Through_The_Base_Interface_Still_Reaches_The_Handle services.AddScoped, ThingHandler>()); IIntegrationEvent asBase = NewThing("a"); - await bus.PublishAsync(asBase, "p1"); + await bus.PublishAsync(new IntegrationEventEnvelope(asBase, Topic, Trace)); recorder.Handled.Should().ContainSingle(); } @@ -84,7 +88,7 @@ public async Task Every_Handler_For_The_Event_Runs() services.AddScoped, SecondThingHandler>(); }); - await bus.PublishAsync(NewThing("a"), "p1"); + await bus.PublishAsync(Envelope(NewThing("a"))); recorder.Handled.Should().BeEquivalentTo(["a", "second:a"]); } @@ -94,7 +98,7 @@ public async Task An_Event_With_No_Handler_Is_Not_An_Error() { var (bus, _) = Build(new Recorder(), _ => { }); - var act = () => bus.PublishAsync(NewThing("a"), "p1"); + var act = () => bus.PublishAsync(Envelope(NewThing("a"))); await act.Should().NotThrowAsync(); } @@ -112,7 +116,7 @@ public async Task The_Handler_Runs_Under_The_Events_Tenant() var (bus, _) = Build(recorder, services => services.AddScoped, TenantReadingHandler>()); - await bus.PublishAsync(NewThing("a"), "p1"); + await bus.PublishAsync(Envelope(NewThing("a"))); recorder.Tenants.Should().ContainSingle().Which.Should().Be(Tenant); } @@ -127,10 +131,11 @@ public async Task The_Publishers_Own_Context_Is_Put_Back() var (bus, accessor) = Build(recorder, services => services.AddScoped, TenantReadingHandler>()); - var publisher = EventTenantContext.FromEvent(NewThing("x") with { TenantId = Guid.NewGuid() }); + var publisher = EventTenantContext.FromEnvelope( + Envelope(NewThing("x") with { TenantId = Guid.NewGuid() })); accessor.Current = publisher; - await bus.PublishAsync(NewThing("a"), "p1"); + await bus.PublishAsync(Envelope(NewThing("a"))); accessor.Current.Should().BeSameAs(publisher); } @@ -143,12 +148,172 @@ public async Task A_Handler_That_Throws_Leaves_No_Context_Behind() services.AddScoped, ThrowingHandler>()); accessor.Current = null; - var act = () => bus.PublishAsync(NewThing("a"), "p1"); + var act = () => bus.PublishAsync(Envelope(NewThing("a"))); await act.Should().ThrowAsync(); accessor.Current.Should().BeNull(); } + [Fact] + public async Task A_Handler_Reads_The_Scoped_Tenant_Context_Too() + { + // Setting only the ambient accessor left the SCOPED ITenantContext — the + // one ITenantContext's own doc says is handed to MediatR handlers, EF + // interceptors and the audit pipeline — unresolved inside the dispatch + // scope. A handler injecting it threw, and one sending a MediatR command + // was short-circuited by TenantContextBehavior before its business logic + // ran. The obligation the transport advertises was half-delivered. + var recorder = new Recorder(); + var (bus, _) = Build(recorder, services => + services.AddScoped, ScopedContextReadingHandler>()); + + await bus.PublishAsync(Envelope(NewThing("a"))); + + recorder.Tenants.Should().ContainSingle().Which.Should().Be(Tenant); + } + + [Fact] + public async Task The_Consumer_Acts_As_The_System_When_The_Envelope_Names_No_Actor() + { + // AuditableEntity.MarkCreated refuses default(UserId) and Guid.Empty, so + // a null actor left every state-writing consumer with no value it could + // legally pass — it could not create an aggregate at all. + var recorder = new Recorder(); + var (bus, _) = Build(recorder, services => + services.AddScoped, ActorReadingHandler>()); + + await bus.PublishAsync(Envelope(NewThing("a"))); + + recorder.Actors.Should().ContainSingle().Which.Should().Be(UserId.SystemActor); + } + + [Fact] + public async Task The_Envelopes_Actor_And_Organization_Reach_The_Handler() + { + var recorder = new Recorder(); + var (bus, _) = Build(recorder, services => + services.AddScoped, ActorReadingHandler>()); + + var actor = UserId.From(Guid.Parse("018f4d40-0000-7000-8000-0000000000aa")); + var organization = Guid.Parse("018f4d40-0000-7000-8000-0000000000c1"); + + await bus.PublishAsync(new IntegrationEventEnvelope( + NewThing("a"), Topic, Trace, OrganizationId: organization, ActorUserId: actor)); + + recorder.Actors.Should().ContainSingle().Which.Should().Be(actor); + recorder.Organizations.Should().ContainSingle().Which.Should().Be(organization); + } + + [Fact] + public async Task An_Event_With_No_Tenant_Is_Refused_Rather_Than_Dispatched() + { + // A confidently-resolved context for a tenant that does not exist is + // worse than an unresolved one: once SET LOCAL app.tenant_id runs, every + // query silently returns nothing instead of failing. + var recorder = new Recorder(); + var (bus, _) = Build(recorder, services => + services.AddScoped, ThingHandler>()); + + var act = () => bus.PublishAsync( + Envelope(NewThing("a") with { TenantId = Guid.Empty })); + + await act.Should().ThrowAsync(); + recorder.Handled.Should().BeEmpty(); + } + + // ---- failure isolation --------------------------------------------------- + + [Fact] + public async Task One_Failing_Handler_Does_Not_Deny_The_Others_The_Event() + { + // Poison-message containment is per subscription: one module's broken + // handler must not stop another module consuming the same event, and + // in-process there is no retry and no dead-letter to make up for it. + var recorder = new Recorder(); + var (bus, _) = Build(recorder, services => + { + services.AddScoped, ThrowingHandler>(); + services.AddScoped, SecondThingHandler>(); + }); + + var act = () => bus.PublishAsync(Envelope(NewThing("a"))); + + await act.Should().ThrowAsync(); + recorder.Handled.Should().ContainSingle().Which.Should().Be("second:a", + "the surviving subscription still got its delivery"); + } + + [Fact] + public async Task Several_Failures_Are_Reported_Together() + { + var recorder = new Recorder(); + var (bus, _) = Build(recorder, services => + { + services.AddScoped, ThrowingHandler>(); + services.AddScoped, ThrowingHandler>(); + }); + + var act = () => bus.PublishAsync(Envelope(NewThing("a"))); + + (await act.Should().ThrowAsync()) + .Which.InnerExceptions.Should().HaveCount(2); + } + + [Fact] + public async Task Each_Handler_Gets_Its_Own_Scope() + { + // Under a broker each subscription gets its own. Sharing one here would + // hand two modules' consumers the same DbContext and the same unit of + // work, so a failed handler's dirty state would cross a module boundary + // the architecture otherwise enforces hard. + var recorder = new Recorder(); + var (bus, _) = Build(recorder, services => + { + services.AddScoped(); + services.AddScoped, ScopeReadingHandler>(); + services.AddScoped, SecondScopeReadingHandler>(); + }); + + await bus.PublishAsync(Envelope(NewThing("a"))); + + recorder.Scopes.Should().HaveCount(2); + recorder.Scopes.Distinct().Should().HaveCount(2, "two scopes, two markers"); + } + + // ---- cancellation -------------------------------------------------------- + + [Fact] + public async Task A_Publish_On_An_Already_Cancelled_Token_Does_Not_Dispatch() + { + var recorder = new Recorder(); + var (bus, _) = Build(recorder, services => + services.AddScoped, ThingHandler>()); + using var cancelled = new CancellationTokenSource(); + await cancelled.CancelAsync(); + + var act = () => bus.PublishAsync(Envelope(NewThing("a")), cancelled.Token); + + await act.Should().ThrowAsync(); + recorder.Handled.Should().BeEmpty("a broker-backed transport would fail before any I/O"); + } + + [Fact] + public async Task A_Handler_Cancelled_By_A_Foreign_Token_Fails_Rather_Than_Cancels() + { + // An outbox processor cannot distinguish "we are shutting down, retry + // later" from "the handler ran and gave up" if both arrive as a cancelled + // task — and its shutdown path swallows the former. + var recorder = new Recorder(); + var (bus, _) = Build(recorder, services => + services.AddScoped>(_ => new ForeignCancelHandler())); + + var publish = bus.PublishAsync(Envelope(NewThing("a"))); + + var act = () => publish; + await act.Should().ThrowAsync(); + publish.IsCanceled.Should().BeFalse(); + } + // ---- obligation: ordering per partition key ------------------------------ [Fact] @@ -161,8 +326,8 @@ public async Task Two_Events_On_One_Partition_Key_Do_Not_Overlap() var (bus, _) = Build(recorder, services => services.AddScoped, OverlapDetectingHandler>()); - var first = bus.PublishAsync(NewThing("a"), "same-key"); - var second = bus.PublishAsync(NewThing("b"), "same-key"); + var first = bus.PublishAsync(Envelope(NewThing("a", "same-key"))); + var second = bus.PublishAsync(Envelope(NewThing("b", "same-key"))); await Task.WhenAll(first, second).WaitAsync(Timeout); recorder.Overlapped.Should().BeFalse("dispatch is sequential within one key"); @@ -181,8 +346,8 @@ public async Task Different_Partition_Keys_Run_Concurrently() services.AddScoped>( _ => new RendezvousHandler(bothArrived))); - var first = bus.PublishAsync(NewThing("a"), "key-1"); - var second = bus.PublishAsync(NewThing("b"), "key-2"); + var first = bus.PublishAsync(Envelope(NewThing("a", "key-1"))); + var second = bus.PublishAsync(Envelope(NewThing("b", "key-2"))); // Each handler releases once and waits for the other. If the two keys // were serialised, the first would wait forever and this would time out. @@ -200,22 +365,37 @@ public async Task A_Failed_Delivery_Does_Not_Block_The_Rest_Of_Its_Partition() services.AddScoped, ThrowOnFirstHandler>(); }); - var failing = bus.PublishAsync(NewThing("boom"), "same-key"); + var failing = bus.PublishAsync(Envelope(NewThing("boom", "same-key"))); await ((Func)(() => failing)).Should().ThrowAsync(); - await bus.PublishAsync(NewThing("after"), "same-key").WaitAsync(Timeout); + await bus.PublishAsync(Envelope(NewThing("after", "same-key"))).WaitAsync(Timeout); recorder.Handled.Should().Contain("after"); } // ---- helpers ------------------------------------------------------------- - private static Thing NewThing(string payload) => new() + /// + /// Wraps an event for dispatch. + /// + /// + /// The partition key is the event's own — the envelope reads it and cannot + /// disagree with it. An earlier two-parameter shape could: every test here + /// published an event declaring one key with a different one passed + /// alongside, the transport used the parameter and never the event, and + /// nothing noticed. Ordering is guaranteed per partition key, so a key that + /// can differ from itself is a guarantee that cannot be stated. + /// + private static IntegrationEventEnvelope Envelope(Thing @event) => + new(@event, Topic, Trace); + + private static Thing NewThing(string payload, string? partitionKey = null) => new() { EventId = Guid.NewGuid(), TenantId = Tenant, OccurredAt = DateTimeOffset.UnixEpoch, Payload = payload, + Key = partitionKey, }; private static (IEventBus Bus, ITenantContextAccessor Accessor) Build( @@ -230,6 +410,14 @@ private static (IEventBus Bus, ITenantContextAccessor Accessor) Build( // the dispatch scope reads what the transport restored rather than a // second, empty one. services.AddSingleton(accessor); + + // The production binding, verbatim (CrossCuttingFoundationExtensions): + // the scoped context resolves FROM the accessor. Registering anything + // else here would test a container this application never builds. + services.AddScoped(sp => + sp.GetRequiredService().Current + ?? UnresolvedTenantContext.Instance); + register(services); var provider = services.BuildServiceProvider(); @@ -238,7 +426,8 @@ private static (IEventBus Bus, ITenantContextAccessor Accessor) Build( new InProcessEventBus( provider.GetRequiredService(), accessor, - new PartitionSerializer()), + new PartitionSerializer(), + NullLogger.Instance), accessor); } @@ -267,6 +456,12 @@ public sealed class Recorder public ConcurrentQueue Tenants { get; } = new(); + public ConcurrentQueue Actors { get; } = new(); + + public ConcurrentQueue Organizations { get; } = new(); + + public ConcurrentQueue Scopes { get; } = new(); + /// Whether two handlers were ever inside the dispatch at once. public bool Overlapped { get; private set; } @@ -285,7 +480,10 @@ public sealed record Thing : IntegrationEventBase { public required string Payload { get; init; } - public override string PartitionKey => Payload; + /// An ordering domain independent of the payload, for the ordering cases. + public string? Key { get; init; } + + public override string PartitionKey => Key ?? Payload; } public sealed class ThingHandler(Recorder recorder) : IIntegrationEventHandler @@ -325,6 +523,68 @@ public Task HandleAsync(Thing @event, CancellationToken cancellationToken = defa } } + public sealed class ScopedContextReadingHandler(Recorder recorder, ITenantContext context) + : IIntegrationEventHandler + { + public Task HandleAsync(Thing @event, CancellationToken cancellationToken = default) + { + recorder.Tenants.Enqueue(context.TenantId); + return Task.CompletedTask; + } + } + + public sealed class ActorReadingHandler(Recorder recorder, ITenantContext context) + : IIntegrationEventHandler + { + public Task HandleAsync(Thing @event, CancellationToken cancellationToken = default) + { + recorder.Actors.Enqueue(context.UserId!.Value); + + if (context.OrganizationId is { } organization) + { + recorder.Organizations.Enqueue(organization); + } + + return Task.CompletedTask; + } + } + + public sealed class ScopeMarker + { + public Guid Id { get; } = Guid.NewGuid(); + } + + public sealed class ScopeReadingHandler(Recorder recorder, ScopeMarker marker) + : IIntegrationEventHandler + { + public Task HandleAsync(Thing @event, CancellationToken cancellationToken = default) + { + recorder.Scopes.Enqueue(marker.Id); + return Task.CompletedTask; + } + } + + public sealed class SecondScopeReadingHandler(Recorder recorder, ScopeMarker marker) + : IIntegrationEventHandler + { + public Task HandleAsync(Thing @event, CancellationToken cancellationToken = default) + { + recorder.Scopes.Enqueue(marker.Id); + return Task.CompletedTask; + } + } + + public sealed class ForeignCancelHandler : IIntegrationEventHandler + { + public Task HandleAsync(Thing @event, CancellationToken cancellationToken = default) + { + using var unrelated = new CancellationTokenSource(); + unrelated.Cancel(); + unrelated.Token.ThrowIfCancellationRequested(); + return Task.CompletedTask; + } + } + public sealed class ThrowingHandler : IIntegrationEventHandler { public Task HandleAsync(Thing @event, CancellationToken cancellationToken = default) => diff --git a/backend/tests/LearnStack.Tests.Unit/Infrastructure/Messaging/PartitionSerializerTests.cs b/backend/tests/LearnStack.Tests.Unit/Infrastructure/Messaging/PartitionSerializerTests.cs index bf4b37ff..27447788 100644 --- a/backend/tests/LearnStack.Tests.Unit/Infrastructure/Messaging/PartitionSerializerTests.cs +++ b/backend/tests/LearnStack.Tests.Unit/Infrastructure/Messaging/PartitionSerializerTests.cs @@ -61,6 +61,162 @@ async Task Rendezvous() await Task.WhenAll(first, second).WaitAsync(Timeout); } + [Fact] + public async Task Queuing_Work_For_The_Key_You_Are_Inside_Is_Refused_Not_Hung() + { + // A deadlock by construction: the new unit chains behind a tail that + // cannot complete until the current one returns. Measured with no + // detection at all — it hung, and the partition stayed wedged for every + // later event for the life of the process. The caller that hits this is + // a consumer publishing from inside a handler, which Standards 20 + // already forbids: a handler writes to the outbox. + var serializer = new PartitionSerializer(); + + Exception? inner = null; + + await serializer.RunSequentiallyFor("k", async () => + { + try + { + await serializer.RunSequentiallyFor("k", () => Task.CompletedTask); + } + catch (InvalidOperationException ex) + { + inner = ex; + } + }).WaitAsync(Timeout); + + inner.Should().NotBeNull(); + inner!.Message.Should().Contain("outbox"); + + // The partition still works afterwards. + await serializer.RunSequentiallyFor("k", () => Task.CompletedTask).WaitAsync(Timeout); + } + + [Fact] + public async Task A_Spawned_Flow_Never_Runs_Alongside_The_Unit_That_Spawned_It() + { + // The detection uses an AsyncLocal, which flows into every task started + // inside a unit. An earlier version RAN the reentrant call inline, + // reasoning that the caller is the sequence — and a fire-and-forget + // spawn inherited the marker and ran concurrently with the unit it + // should have queued behind. Measured: the one guarantee this class + // exists for, broken by the fix for a different bug. Refusing instead of + // running inline makes the same false positive loud rather than silent. + var serializer = new PartitionSerializer(); + var inFlight = 0; + var overlapped = false; + + await serializer.RunSequentiallyFor("k", async () => + { + Interlocked.Increment(ref inFlight); + + var spawned = Task.Run(async () => + { + try + { + await serializer.RunSequentiallyFor("k", () => + { + if (Volatile.Read(ref inFlight) > 0) + { + Volatile.Write(ref overlapped, true); + } + + return Task.CompletedTask; + }); + } + catch (InvalidOperationException) + { + // Refused, which is the point. + } + }); + + await Task.Delay(TimeSpan.FromMilliseconds(80)); + Interlocked.Decrement(ref inFlight); + await spawned; + }).WaitAsync(Timeout); + + overlapped.Should().BeFalse("no unit for a key runs alongside another"); + } + + [Fact] + public async Task Two_Serializers_Do_Not_Share_A_Reentrancy_Marker() + { + // The marker was static, so being inside a key on one instance spoke for + // every other — and the integration tests deliberately build two hosts + // in one process. + var first = new PartitionSerializer(); + var second = new PartitionSerializer(); + var ran = false; + + await first.RunSequentiallyFor("k", async () => + await second.RunSequentiallyFor("k", () => + { + ran = true; + return Task.CompletedTask; + })).WaitAsync(Timeout); + + ran.Should().BeTrue("a different serializer's chain is a different sequence"); + } + + [Fact] + public async Task Nesting_A_Different_Key_Is_Fine() + { + var serializer = new PartitionSerializer(); + var order = new ConcurrentQueue(); + + await serializer.RunSequentiallyFor("outer", async () => + { + order.Enqueue("outer-start"); + await serializer.RunSequentiallyFor("inner", () => + { + order.Enqueue("inner"); + return Task.CompletedTask; + }); + order.Enqueue("outer-end"); + }).WaitAsync(Timeout); + + order.Should().BeEquivalentTo( + ["outer-start", "inner", "outer-end"], o => o.WithStrictOrdering()); + } + + [Fact] + public async Task Racing_Publishers_Never_See_A_Missing_Chain() + { + // The tail used to be re-read from the dictionary AFTER the lock was + // released, so another publisher's retirement could remove the key in + // that window and the caller got a KeyNotFoundException — for an event + // whose work had already been queued and delivered. A success answered + // with a failure, which on the outbox path means the row is marked + // failed and redelivered. + // + // This is a stress smoke check, and it is worth saying what it does NOT + // do: the window is tiny — the original defect reproduced about four + // times in 256,000 calls — so a green run here is weak evidence, and + // reintroducing the bad read does not reliably turn it red. The actual + // guarantee is structural: the observer task is captured inside the + // lock, so there is no dictionary read outside it left to race. + var serializer = new PartitionSerializer(); + var failures = new ConcurrentBag(); + + await Task.WhenAll(Enumerable.Range(0, 8).Select(worker => Task.Run(() => + { + for (var i = 0; i < 20_000; i++) + { + try + { + _ = serializer.RunSequentiallyFor("k", () => Task.CompletedTask); + } + catch (Exception ex) + { + failures.Add(ex); + } + } + }))); + + failures.Should().BeEmpty(); + } + [Fact] public async Task A_Failure_Belongs_To_Its_Own_Unit() { diff --git a/docs/decisions/0014-adopt-dapr.md b/docs/decisions/0014-adopt-dapr.md index 4dd311f9..c6558f4c 100644 --- a/docs/decisions/0014-adopt-dapr.md +++ b/docs/decisions/0014-adopt-dapr.md @@ -349,6 +349,77 @@ the last of which must resolve handlers by runtime type rather than through a ty parameter), `architecture/32 § 8.2` and the Packet 5 scope paragraph, both of which stop saying "removed **or** redesigned" now that it is removed. +### 2026-08-25 — Amendment 3: the publish envelope, decided before the first call site + +The Decision stands, and so does Amendment 2's central correction — `PublishAsync` is +not generic, and it never becomes generic. What Amendment 3 changes is the **shape of +its argument**, which Amendment 2 published as `(IIntegrationEvent @event, string +partitionKey, CancellationToken ct)` and which Packet 5 has now built against. + +**`IEventBus.PublishAsync` takes an envelope.** + +```csharp +Task PublishAsync(IntegrationEventEnvelope envelope, CancellationToken ct = default); + +public sealed record IntegrationEventEnvelope( + IIntegrationEvent Event, + string Topic, + string CorrelationId, + Guid? OrganizationId = null, + Guid? CausationId = null, + UserId? ActorUserId = null) +{ + public string PartitionKey => Event.PartitionKey; +} +``` + +Three things forced it, and all three were measured rather than argued. + +**The dispatch metadata had nowhere to travel.** The canonical `outbox_messages` row +([Database Standards](../standards/05-database.md)) requires `topic` and +`correlation_id` as `NOT NULL` and carries `organization_id`, `causation_id` and +`actor_user_id`. None of them belong on the event — they describe the delivery, not the +fact — and the two-parameter signature had no room for them. The transport therefore +read correlation from whatever context happened to be ambient at dispatch, which is +`null` inside the background service the outbox processor is, so the trace chain broke +at exactly the boundary [Observability Standards](../standards/10-observability.md) +requires it to cross. + +**The partition key had two sources and the transport read the wrong one.** Amendment 2 +put it in the signature; `IntegrationEventBase` also declares it. Measured: the shipped +bus never read the event's copy, and every test published an event declaring one key +with a different one passed alongside — green. Ordering is guaranteed per partition key, +so a key that can differ from itself is a guarantee that cannot be stated. The envelope +reads it off the event and cannot disagree with it. + +**A consumer could not write state at all.** `AuditableEntity.MarkCreated` refuses +`default(UserId)` and `Guid.Empty`, and the consumer context supplied neither an actor +nor an organization — so every state-writing handler threw from inside the kernel, and +every organization-scoped read came back empty under the canonical Row Level Security +policy, which fails closed when `app.organization_id` is unset. The envelope carries +both; an absent actor resolves to `UserId.SystemActor`, which is what +[Audit Coverage](../standards/18-audit-coverage.md) means by auditing such work as an +actor of type `system`. + +**Why now.** Amendment 2 wrote the rule this amendment obeys: *adding a required +parameter after the first consumer exists breaks every call site, so the two shapes +cannot be left to be reconciled later.* There is still not one consumer. The envelope is +one type, it maps onto the outbox row Packet 6 creates, and it is the last moment it +costs nothing. + +> The signature published under Amendment 2 above is superseded by this one. It is left +> as written because an Accepted ADR is not rewritten; the non-generic decision it makes +> is unchanged and is the reason the envelope carries the event as `IIntegrationEvent`. + +**One consequence worth stating, because it is a trap the non-generic port creates.** +With `IIntegrationEvent` as the declared type at every dispatch boundary, +`JsonSerializer.Serialize(@event)` emits only the four interface members and silently +drops everything the concrete event added — valid JSON, no exception, and the loss +commits inside the business transaction that reported success. `IntegrationEventBase` +therefore ships `ToPayloadJson()`, which serialises by runtime type, and a named +`PayloadJsonOptions` — because a writer and a reader that disagree on casing +dead-letter every message. + ## References - ADR-0006 — Events and Outbox (status: Accepted after this ADR; previously Proposed). From 01a2f53bc2852951f7b9e7c7cc2fef0cbeb146e0 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Tue, 25 Aug 2026 10:34:33 +0300 Subject: [PATCH 11/21] test(kernel): make the messaging suite constrain the code it covers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A mutation audit of the event-bus tests: 60 compiling mutants, 24 killed. A 40% score, and the survivors were not edge cases — they were the guarantees the class exists for. **The cross-key half of the ordering contract had no coverage at all.** Collapsing every partition key onto one chain survived the entire suite, because both "different keys" tests shared one semaphore: each side released a permit and immediately consumed its own, so neither ever waited for the other. The comment claimed "if the two keys shared a chain the first would wait forever" — it would have returned instantly. Two separate gates now, each side waiting on the other's. This is the third test in this packet caught satisfying itself rather than the code, after the bound test that only held on one clock schedule and the stampede test built with `Select(...).ToArray()`. **The same-key ordering test noticed a completely bypassed serializer 3 times in 20.** Same root cause: `Select(...).ToArray()` evaluates sequentially on one thread, so each unit incremented and decremented before the next existed and nothing contended. Eight threads behind a `Barrier` now — which is also what pins the lock around the tail's read-modify-write, a lock whose removal previously survived everything despite the comment above it calling it "the one thing this class exists to prevent". **Faults after an `await` were uncovered.** Both throwing handlers threw synchronously, which comes out of `MethodInfo.Invoke` and is rethrown before `await delivery` is ever reached — so the path every real async consumer takes, the one that hits a database, had no fault coverage: wrapping that await in a swallowing catch survived the whole suite. **Tenant context was only proven to survive to a handler's first await.** Every tenant-reading handler read it synchronously, so the suite proved the context was set when a handler *started*, not when its continuation resumed — which is when the query RLS evaluates actually runs. Also closed: the publish token was threaded to handlers but never asserted to arrive, so passing `CancellationToken.None` survived and a shutdown would never reach a consumer; the dispatch scope's disposal was unasserted, so leaking one per publish survived; and every member of the consumer context except the tenant id was unconstrained — `IsResolved` could return false, which would make `TenantContextBehavior` short-circuit every consumer that sends a MediatR command, silently, before its business logic ran. New contract tests pin what the doc comments argue for and nothing was checking: `PartitionKey` is abstract so no event inherits a default that would serialise a tenant's whole stream onto one partition; the three envelope fields are `required`; and the payload written through `ToPayloadJson` keeps the concrete event's own members, where serializing through the interface drops them silently. Those comments defer to catalogued architecture tests booked for Phase 02b — they read as enforced today and are not, so this is the part assertable from the kernel alone. Two survivors are left deliberately, with reasons. `ExecuteSynchronously` on the work continuation no longer produces the `KeyNotFoundException` it used to, because the read it raced moved inside the lock; a case-insensitive key comparer merges two chains, which is more ordering than promised rather than less. Neither is a defect I can demonstrate. 699 tests green, 0 warnings under CI=true, 12 consecutive runs stable. Co-Authored-By: Claude Opus 5 (1M context) --- .../Messaging/InProcessEventBusTests.cs | 154 +++++++++++++++++- .../Messaging/PartitionSerializerTests.cs | 78 ++++++--- .../IntegrationEventContractTests.cs | 151 +++++++++++++++++ 3 files changed, 352 insertions(+), 31 deletions(-) create mode 100644 backend/tests/LearnStack.Tests.Unit/SharedKernel/Messaging/IntegrationEventContractTests.cs diff --git a/backend/tests/LearnStack.Tests.Unit/Infrastructure/Messaging/InProcessEventBusTests.cs b/backend/tests/LearnStack.Tests.Unit/Infrastructure/Messaging/InProcessEventBusTests.cs index cae18085..2558252b 100644 --- a/backend/tests/LearnStack.Tests.Unit/Infrastructure/Messaging/InProcessEventBusTests.cs +++ b/backend/tests/LearnStack.Tests.Unit/Infrastructure/Messaging/InProcessEventBusTests.cs @@ -314,6 +314,75 @@ public async Task A_Handler_Cancelled_By_A_Foreign_Token_Fails_Rather_Than_Cance publish.IsCanceled.Should().BeFalse(); } + [Fact] + public async Task A_Handler_That_Faults_After_An_Await_Still_Faults_The_Publish() + { + // Every throwing handler in this suite threw SYNCHRONOUSLY, which comes + // out of MethodInfo.Invoke and is rethrown before `await delivery` is + // ever reached. So the path every real async handler takes — the one + // that hits a database — had no coverage for faults at all: wrapping the + // await in a swallowing catch survived the whole suite. + var recorder = new Recorder(); + var (bus, _) = Build(recorder, services => + services.AddScoped, AsyncThrowingHandler>()); + + var act = () => bus.PublishAsync(Envelope(NewThing("a"))); + + (await act.Should().ThrowAsync()) + .Which.Message.Should().Be("async handler failed"); + } + + [Fact] + public async Task The_Tenant_Is_Still_Set_After_A_Handler_Awaits() + { + // Every tenant-reading handler read it synchronously, so the suite + // proved the context was set when a handler STARTED — not that it was + // still set when its continuation resumed, which is when the query RLS + // evaluates actually runs. Restoring the publisher's context just before + // the await survived every test. + var recorder = new Recorder(); + var (bus, _) = Build(recorder, services => + services.AddScoped, LateTenantReadingHandler>()); + + await bus.PublishAsync(Envelope(NewThing("a"))); + + recorder.Tenants.Should().ContainSingle().Which.Should().Be(Tenant); + } + + [Fact] + public async Task The_Publish_Token_Reaches_The_Handler() + { + // The token was threaded through but never asserted to arrive: passing + // CancellationToken.None instead survived, so a shutdown or a timeout + // would never reach a consumer. + var recorder = new Recorder(); + var (bus, _) = Build(recorder, services => + services.AddScoped, TokenReadingHandler>()); + using var cancelled = new CancellationTokenSource(); + + var publish = bus.PublishAsync(Envelope(NewThing("a")), cancelled.Token); + await cancelled.CancelAsync(); + await publish; + + recorder.SawCancellableToken.Should().BeTrue(); + } + + [Fact] + public async Task The_Dispatch_Scope_Is_Disposed() + { + // An undisposed scope leaks a DbContext per publish, and nothing noticed. + var recorder = new Recorder(); + var (bus, _) = Build(recorder, services => + { + services.AddScoped(); + services.AddScoped, DisposalProbingHandler>(); + }); + + await bus.PublishAsync(Envelope(NewThing("a"))); + + recorder.Probes.Should().ContainSingle().Which.Disposed.Should().BeTrue(); + } + // ---- obligation: ordering per partition key ------------------------------ [Fact] @@ -340,18 +409,21 @@ public async Task Different_Partition_Keys_Run_Concurrently() // The other half of the guarantee: serialising everything would be // correct and useless, so the test that proves ordering must be paired // with one that proves it is not global. + // TWO gates, each side waiting on the OTHER's. Sharing one semaphore + // let each side consume its own release, so it never waited for + // anything and passed with both keys on a single chain. var recorder = new Recorder(); - using var bothArrived = new SemaphoreSlim(0); + using var firstArrived = new SemaphoreSlim(0); + using var secondArrived = new SemaphoreSlim(0); var (bus, _) = Build(recorder, services => - services.AddScoped>( - _ => new RendezvousHandler(bothArrived))); + services.AddScoped>(_ => + new RendezvousHandler(recorder, firstArrived, secondArrived))); var first = bus.PublishAsync(Envelope(NewThing("a", "key-1"))); var second = bus.PublishAsync(Envelope(NewThing("b", "key-2"))); - // Each handler releases once and waits for the other. If the two keys - // were serialised, the first would wait forever and this would time out. await Task.WhenAll(first, second).WaitAsync(Timeout); + recorder.Rendezvoused.Should().Be(2, "neither key waited for the other"); } [Fact] @@ -462,6 +534,16 @@ public sealed class Recorder public ConcurrentQueue Scopes { get; } = new(); + public ConcurrentQueue Probes { get; } = new(); + + public bool SawCancellableToken { get; set; } + + public int Rendezvoused => _rendezvoused; + + private int _rendezvoused; + + public void Rendezvous() => Interlocked.Increment(ref _rendezvoused); + /// Whether two handlers were ever inside the dispatch at once. public bool Overlapped { get; private set; } @@ -585,6 +667,51 @@ public Task HandleAsync(Thing @event, CancellationToken cancellationToken = defa } } + public sealed class AsyncThrowingHandler : IIntegrationEventHandler + { + public async Task HandleAsync(Thing @event, CancellationToken cancellationToken = default) + { + await Task.Yield(); + throw new InvalidOperationException("async handler failed"); + } + } + + public sealed class LateTenantReadingHandler(Recorder recorder, ITenantContextAccessor accessor) + : IIntegrationEventHandler + { + public async Task HandleAsync(Thing @event, CancellationToken cancellationToken = default) + { + await Task.Delay(TimeSpan.FromMilliseconds(20), CancellationToken.None); + recorder.Tenants.Enqueue(accessor.Current!.TenantId); + } + } + + public sealed class TokenReadingHandler(Recorder recorder) : IIntegrationEventHandler + { + public Task HandleAsync(Thing @event, CancellationToken cancellationToken = default) + { + recorder.SawCancellableToken = cancellationToken.CanBeCanceled; + return Task.CompletedTask; + } + } + + public sealed class DisposalProbe : IDisposable + { + public bool Disposed { get; private set; } + + public void Dispose() => Disposed = true; + } + + public sealed class DisposalProbingHandler(Recorder recorder, DisposalProbe probe) + : IIntegrationEventHandler + { + public Task HandleAsync(Thing @event, CancellationToken cancellationToken = default) + { + recorder.Probes.Enqueue(probe); + return Task.CompletedTask; + } + } + public sealed class ThrowingHandler : IIntegrationEventHandler { public Task HandleAsync(Thing @event, CancellationToken cancellationToken = default) => @@ -616,12 +743,23 @@ public async Task HandleAsync(Thing @event, CancellationToken cancellationToken } } - public sealed class RendezvousHandler(SemaphoreSlim gate) : IIntegrationEventHandler + public sealed class RendezvousHandler( + Recorder recorder, SemaphoreSlim first, SemaphoreSlim second) + : IIntegrationEventHandler { public async Task HandleAsync(Thing @event, CancellationToken cancellationToken = default) { - gate.Release(); - await gate.WaitAsync(Timeout, CancellationToken.None); + // The key decides which gate is this side's, so the two invocations + // never pick the same one — a shared counter would be shared across + // tests running in parallel. + var mine = @event.PartitionKey == "key-1" ? first : second; + var theirs = ReferenceEquals(mine, first) ? second : first; + + mine.Release(); + (await theirs.WaitAsync(Timeout, CancellationToken.None)).Should().BeTrue( + "the other key's handler must be running at the same time"); + + recorder.Rendezvous(); } } } diff --git a/backend/tests/LearnStack.Tests.Unit/Infrastructure/Messaging/PartitionSerializerTests.cs b/backend/tests/LearnStack.Tests.Unit/Infrastructure/Messaging/PartitionSerializerTests.cs index 27447788..e2573979 100644 --- a/backend/tests/LearnStack.Tests.Unit/Infrastructure/Messaging/PartitionSerializerTests.cs +++ b/backend/tests/LearnStack.Tests.Unit/Infrastructure/Messaging/PartitionSerializerTests.cs @@ -15,28 +15,48 @@ public sealed class PartitionSerializerTests [Fact] public async Task Work_On_One_Key_Runs_In_Order_And_Never_Overlaps() { - var serializer = new PartitionSerializer(); var observed = new ConcurrentQueue(); var inFlight = 0; var overlapped = false; - var queued = Enumerable.Range(0, 25).Select(i => - serializer.RunSequentiallyFor("k", async () => + // Queued from real threads behind a Barrier, NOT with + // Select(...).ToArray(). Measured: LINQ evaluates sequentially on one + // thread, so each unit incremented and decremented before the next was + // even created — nothing contended, and this test noticed a completely + // bypassed serializer 3 times in 20 runs. With genuine contention it + // notices every time, and it is what pins the lock around the tail's + // read-modify-write. + const int Threads = 8; + const int Each = 25; + var serializer = new PartitionSerializer(); + using var start = new Barrier(Threads); + + var workers = Enumerable.Range(0, Threads).Select(_ => Task.Factory.StartNew( + () => { - if (Interlocked.Increment(ref inFlight) > 1) - { - Volatile.Write(ref overlapped, true); - } + start.SignalAndWait(); + + return Task.WhenAll(Enumerable.Range(0, Each).Select(_ => + serializer.RunSequentiallyFor("k", async () => + { + if (Interlocked.Increment(ref inFlight) > 1) + { + Volatile.Write(ref overlapped, true); + } - await Task.Yield(); - observed.Enqueue(i); - Interlocked.Decrement(ref inFlight); - })).ToArray(); + await Task.Yield(); + observed.Enqueue(1); + Interlocked.Decrement(ref inFlight); + }))); + }, + CancellationToken.None, + TaskCreationOptions.LongRunning, + TaskScheduler.Default).Unwrap()).ToArray(); - await Task.WhenAll(queued).WaitAsync(Timeout); + await Task.WhenAll(workers).WaitAsync(Timeout); - overlapped.Should().BeFalse(); - observed.Should().BeEquivalentTo(Enumerable.Range(0, 25), o => o.WithStrictOrdering()); + overlapped.Should().BeFalse("no two units for one key ever overlap"); + observed.Should().HaveCount(Threads * Each); } [Fact] @@ -44,21 +64,33 @@ public async Task Different_Keys_Do_Not_Wait_For_Each_Other() { // Serialising everything would satisfy the ordering test and defeat the // purpose, so the guarantee is pinned from both sides. + // TWO gates, each side waiting on the OTHER's. An earlier version + // shared one semaphore, so each side released a permit and immediately + // consumed its own — it never waited for anything, and the test passed + // with every key collapsed onto a single chain. Measured: the whole + // cross-key half of this class's contract could be deleted and nothing + // turned red. This is the third test in this packet found to satisfy + // itself rather than the code. var serializer = new PartitionSerializer(); - using var arrived = new SemaphoreSlim(0); + using var firstArrived = new SemaphoreSlim(0); + using var secondArrived = new SemaphoreSlim(0); - async Task Rendezvous() + var first = serializer.RunSequentiallyFor("k1", async () => { - arrived.Release(); - await arrived.WaitAsync(Timeout, CancellationToken.None); - } + firstArrived.Release(); + (await secondArrived.WaitAsync(Timeout, CancellationToken.None)) + .Should().BeTrue("k2 must not be queued behind k1"); + }); - var first = serializer.RunSequentiallyFor("k1", Rendezvous); - var second = serializer.RunSequentiallyFor("k2", Rendezvous); + var second = serializer.RunSequentiallyFor("k2", async () => + { + secondArrived.Release(); + (await firstArrived.WaitAsync(Timeout, CancellationToken.None)) + .Should().BeTrue("k1 must not be queued behind k2"); + }); - // Each releases once and waits for the other: if the two keys shared a - // chain the first would wait forever. await Task.WhenAll(first, second).WaitAsync(Timeout); + serializer.TrackedPartitions.Should().Be(0); } [Fact] diff --git a/backend/tests/LearnStack.Tests.Unit/SharedKernel/Messaging/IntegrationEventContractTests.cs b/backend/tests/LearnStack.Tests.Unit/SharedKernel/Messaging/IntegrationEventContractTests.cs new file mode 100644 index 00000000..ecdc52dd --- /dev/null +++ b/backend/tests/LearnStack.Tests.Unit/SharedKernel/Messaging/IntegrationEventContractTests.cs @@ -0,0 +1,151 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Text.Json; +using FluentAssertions; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Messaging; +using LearnStack.SharedKernel.Tenancy; +using Xunit; + +namespace LearnStack.Tests.Unit.SharedKernel.Messaging; + +/// +/// The shape of the integration-event contract, which the doc comments argue +/// for at length and nothing was checking. +/// +/// +/// The comments defer to catalogued architecture tests +/// (Integration_Events_Inherit_From_IntegrationEventBase, +/// Integration_Event_Declares_PartitionKey) that are booked for Phase 02b +/// and do not exist yet — so they read as enforced today and are not. These are +/// the parts that can be asserted from the kernel alone. +/// +public sealed class IntegrationEventContractTests +{ + private static readonly Guid Tenant = Guid.Parse("018f4d40-0000-7000-8000-00000000000a"); + + [Fact] + public void PartitionKey_Is_Abstract_So_No_Event_Can_Inherit_A_Default() + { + // A default would have to be the tenant id, which silently serialises a + // tenant's whole stream onto one partition — a real throughput cost + // taken by accident. Making it virtual with that default survived every + // other test. + typeof(IntegrationEventBase) + .GetProperty(nameof(IIntegrationEvent.PartitionKey))! + .GetGetMethod()!.IsAbstract.Should().BeTrue(); + } + + [Theory] + [InlineData(nameof(IIntegrationEvent.EventId))] + [InlineData(nameof(IIntegrationEvent.TenantId))] + [InlineData(nameof(IIntegrationEvent.OccurredAt))] + public void The_Envelope_Fields_Are_Required(string member) + { + // `required` is what makes a half-populated event a compile error at the + // producer and a loud JsonException at the reader, rather than an event + // carrying Guid.Empty into a consumer. + typeof(IntegrationEventBase).GetProperty(member)! + .GetCustomAttribute() + .Should().NotBeNull($"{member} must be required"); + } + + [Fact] + public void A_Payload_Written_Through_The_Base_Keeps_Its_Own_Fields() + { + // The trap the non-generic port creates. Serializing with + // IIntegrationEvent as the declared type — which it is at every dispatch + // boundary — emits the four interface members and silently drops + // everything the concrete event added: valid JSON, no exception, and the + // loss commits inside the transaction that reported success. + var @event = NewSample(); + IIntegrationEvent asBase = @event; + + var naive = JsonSerializer.Serialize(asBase); + naive.Should().NotContain(nameof(Sample.LearnerName), + "the declared type is the interface, so only its four members survive"); + + var written = @event.ToPayloadJson(); + + written.Should().Contain(nameof(Sample.LearnerName)); + JsonSerializer.Deserialize(written, IntegrationEventBase.PayloadJsonOptions) + .Should().Be(@event); + } + + [Fact] + public void A_Payload_Round_Trips_Through_Its_Runtime_Type() + { + // What the outbox processor does: it knows the type from the row's + // `type` column and deserializes to it. + var @event = NewSample(); + var json = @event.ToPayloadJson(); + + // The type comes from a variable, exactly as the processor takes it from + // the row's `type` column — it does not know it statically, which is the + // whole reason the payload has to be written by runtime type. + var storedType = @event.GetType(); + var revived = (Sample)JsonSerializer.Deserialize( + json, storedType, IntegrationEventBase.PayloadJsonOptions)!; + + revived.Should().Be(@event); + revived.PartitionKey.Should().Be(@event.PartitionKey); + } + + [Fact] + public void The_Payload_Options_Do_Not_Rename_Members() + { + // The options are part of the wire contract, not a formatting + // preference: a writer using web defaults and a reader using these would + // disagree on every member and dead-letter everything. + IntegrationEventBase.PayloadJsonOptions.PropertyNamingPolicy.Should().BeNull(); + } + + // ---- the consumer's context --------------------------------------------- + + [Fact] + public void The_Consumer_Context_Has_The_Shape_A_Handler_Needs() + { + var actor = UserId.From(Guid.Parse("018f4d40-0000-7000-8000-0000000000aa")); + var organization = Guid.Parse("018f4d40-0000-7000-8000-0000000000c1"); + + var context = EventTenantContext.FromEnvelope(new IntegrationEventEnvelope( + NewSample(), "learnstack.test.sample", "trace-1", + OrganizationId: organization, ActorUserId: actor)); + + // IsResolved false would make TenantContextBehavior short-circuit every + // consumer that sends a MediatR command — silently, before its business + // logic ran. + context.IsResolved.Should().BeTrue(); + context.TenantId.Should().Be(Tenant); + context.OrganizationId.Should().Be(organization); + context.UserId.Should().Be(actor); + context.CorrelationId.Should().Be("trace-1"); + context.ModuleName.Should().BeNull(); + } + + [Fact] + public void A_Null_Envelope_Is_Refused() + { + var act = () => EventTenantContext.FromEnvelope(null!); + + act.Should().Throw(); + } + + private static Sample NewSample() => new() + { + EventId = Guid.Parse("018f4d40-0000-7000-8000-0000000000e1"), + TenantId = Tenant, + OccurredAt = DateTimeOffset.UnixEpoch, + LearnerName = "Ada", + }; + + public sealed record Sample : IntegrationEventBase + { + public required string LearnerName { get; init; } + + // Independent of the payload, so the truncation is demonstrated on a + // member the interface does not carry rather than on a value that + // happens to appear through PartitionKey. + public override string PartitionKey => "ordering-domain"; + } +} From a1997e6fe51a8aadcd278189d11e5a179308aa68 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Tue, 25 Aug 2026 10:54:46 +0300 Subject: [PATCH 12/21] docs(architecture): make the event corpus describe what shipped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The subsystem changed twice in one day and no document moved with it, so architecture/15 was still publishing sketches that will not compile: a two-parameter `PublishAsync`, `dynamic` dispatch, a `tenantAccessor.Set` that is a property, a `TenantContext.FromEvent` type that does not exist, and a producer initializer missing two `required` members. The partition key is the correction that mattered most, because the corpus held **four mutually incompatible** answers about who owns it: enqueue resolves it with a fallback (§ outbox row), a rare `IPartitionedIntegrationEvent` opt-in (§ ordering table), the base derives it defaulting to `TenantId` (phase-02b), and the shipped code — abstract on the base, every event states it, no default. One owner now: the event declares it, `EnqueueAsync` copies it onto the row, and the envelope reads it back. `IPartitionedIntegrationEvent` is gone from the corpus; it never existed in code. The mandatory-metadata list was wrong in both directions — it required `CorrelationId`, which is not on the event, and omitted `PartitionKey`, which is. It now splits by what each thing describes: the event carries the fact, the envelope carries the delivery, and correlation travels from the outbox row rather than from whatever context is ambient at dispatch, which is null inside the background service the processor is. The producer sketch also gains the rule that costs the most to learn later: the payload is written by `event.ToPayloadJson()`, never by `JsonSerializer.Serialize(@event)`. `EnqueueAsync` takes `IIntegrationEvent`, so the declared type at that call is the interface, and serializing through it drops every field the concrete event added — valid JSON, no exception, committed inside the transaction that reported success. Four kernel types carried their whole rationale in XML comments and appeared in no committed document: `IntegrationEventEnvelope`, `IPartitionSerializer`, `EventTenantContext` and `UserId.SystemActor`. The glossary is the declared source of truth for project terms, so they have rows there. `UserId.SystemActor` needed one more home. It is a foreign key — the `users` row has to exist or `created_by` will not resolve — and the person who writes that migration reads Packet 6, not a C# doc comment. Packet 6's scope now says so. Packet 5's Scope still offered "removed **or** redesigned to a generation-key pattern" for `RemoveByPrefixAsync`. ADR-0014 Amendment 2 settled that on 2026-08-24 and named this paragraph as corrected in the same change; the Packet-Sequence copy was fixed and this one was not. 699 tests green — documentation only, no code touched. Co-Authored-By: Claude Opus 5 (1M context) --- docs/architecture/15-event-and-outbox.md | 152 ++++++++++++++++++----- docs/glossary.md | 6 +- docs/roadmap/phase-02a-kernel-tenancy.md | 18 ++- 3 files changed, 138 insertions(+), 38 deletions(-) diff --git a/docs/architecture/15-event-and-outbox.md b/docs/architecture/15-event-and-outbox.md index cc3951bb..bc5199f8 100644 --- a/docs/architecture/15-event-and-outbox.md +++ b/docs/architecture/15-event-and-outbox.md @@ -108,7 +108,7 @@ guarantees below (ordering, single-claimant dispatch) cannot be honoured without | Column | Why it exists | Lands with | |---|---|---| -| `partition_key text NOT NULL` | The ordering guarantee below is expressed entirely through this value. Defaults to the aggregate id; falls back to `tenant_id` when the event names no aggregate. Set at enqueue time by `IOutbox.EnqueueAsync`, never by the transport. | The table itself, in [Phase 02a Packet 6](../roadmap/phase-02a-kernel-tenancy.md) — a producer-side column is cheaper to ship with the table than to backfill | +| `partition_key text NOT NULL` | The ordering guarantee below is expressed entirely through this value. **The event declares it** — `PartitionKey` is abstract on `IntegrationEventBase`, so no event inherits a default — and `IOutbox.EnqueueAsync` copies it onto the row. Nothing resolves or re-derives it. | The table itself, in [Phase 02a Packet 6](../roadmap/phase-02a-kernel-tenancy.md) — a producer-side column is cheaper to ship with the table than to backfill | | `locked_by text NULL` + `locked_until timestamptz NULL` | The dispatch **lease**. A processor stamps them to claim a row, and the claim survives the claiming transaction's commit. See the claim protocol below. | The dispatcher, in [Phase 02b](../roadmap/phase-02b-events-auth.md) | | `available_after timestamptz NOT NULL` | Retry backoff. A failed dispatch pushes this forward instead of blocking the batch. | Already in the canonical DDL | @@ -190,11 +190,12 @@ public async Task> Handle(CreateEnrollmentCommand cmd, Can await _outbox.EnqueueAsync(new EnrollmentCreatedIntegrationEvent { + EventId = _guidFactory.NewUuidV7(), // IGuidFactory, not Guid.NewGuid TenantId = _tenantContext.TenantId, // ITenantContext, not the accessor + OccurredAt = _clock.UtcNow, // IClock per Standards 02 § Time EnrollmentId = enrollment.Id.Value, LearnerId = cmd.LearnerId, CourseId = cmd.CourseId, - OccurredAt = _clock.UtcNow, // IClock per Standards 02 § Time }, ct); await _dbContext.SaveChangesAsync(ct); // aggregate + outbox row, atomic @@ -204,9 +205,20 @@ public async Task> Handle(CreateEnrollmentCommand cmd, Can ``` `IOutbox.EnqueueAsync` writes to the same `DbContext` (no separate transaction); commit -is atomic with the aggregate write. It also resolves and stores the row's -`partition_key` — here `EnrollmentId`, the aggregate this event is about. See -[Ordering](#ordering). +is atomic with the aggregate write. It copies the event's `PartitionKey` onto the row — +here `EnrollmentId`, the aggregate this event is about — along with the correlation id +and organization from the ambient `ITenantContext`, which is the last point at which +they are available. See [Ordering](#ordering). + +**The payload is written by `event.ToPayloadJson()`, never by +`JsonSerializer.Serialize(@event)`.** `EnqueueAsync` takes `IIntegrationEvent`, so the +declared type at that call is the interface — and serializing through it emits the four +interface members and silently drops everything the concrete event added. Valid JSON, no +exception, and the truncated row commits inside the transaction that reported success; +the loss surfaces later as a `JsonException` on every dispatch attempt until the message +dead-letters. `ToPayloadJson` serialises by runtime type, and `PayloadJsonOptions` is +named and fixed because a writer and a reader that disagree on casing dead-letter +everything. ## Consumer pattern @@ -448,44 +460,102 @@ obligations as the durable path: | Same per-partition-key ordering | Ordering assumptions that hold only in process are discovered in production | ```csharp -public sealed class InProcessEventBus( +public sealed partial class InProcessEventBus( IServiceScopeFactory scopeFactory, ITenantContextAccessor tenantAccessor, - IPartitionSerializer partitions) : IEventBus + IPartitionSerializer partitions, + ILogger logger) : IEventBus { public Task PublishAsync( - IIntegrationEvent @event, string partitionKey, CancellationToken ct = default) + IntegrationEventEnvelope envelope, CancellationToken ct = default) { - ArgumentException.ThrowIfNullOrWhiteSpace(partitionKey); + ArgumentNullException.ThrowIfNull(envelope); + + // Returned as a cancelled task rather than thrown inline: a + // fire-and-forget call site must not crash its caller synchronously. + if (ct.IsCancellationRequested) return Task.FromCanceled(ct); + + return partitions.RunSequentiallyFor( + envelope.PartitionKey, () => DispatchAsync(envelope, ct)); + } - return partitions.RunSequentiallyFor(partitionKey, async () => + private async Task DispatchAsync(IntegrationEventEnvelope envelope, CancellationToken ct) + { + // By RUNTIME type. The event is declared as the base interface here, so a + // closed generic over its static type would resolve + // IIntegrationEventHandler — which no concrete + // consumer implements — and the publish would reach zero handlers and + // report success. + var contract = typeof(IIntegrationEventHandler<>).MakeGenericType(envelope.Event.GetType()); + var handle = contract.GetMethod(nameof(IIntegrationEventHandler.HandleAsync))!; + var context = EventTenantContext.FromEnvelope(envelope); + + List? failures = null; + + for (var index = 0; index < HandlerCount(contract); index++) { + // ONE SCOPE PER HANDLER. Under a broker each subscription gets its + // own; sharing one hands two modules' consumers the same DbContext + // and unit of work, across a boundary the architecture otherwise + // enforces hard. Selected by index because a handler is registered + // against the CONTRACT — its own type is not a service. await using var scope = scopeFactory.CreateAsyncScope(); - // Same restore as the durable path, into the SCOPE the handler - // resolves from — and the publisher's own ambient context is put - // back afterwards, or a synchronous dispatch leaks a tenant into - // the caller's flow. + // Restored into the handler's flow AND into the scope it resolves + // ITenantContext from — the composition root binds the scoped + // context to this accessor. Put back in the finally, or a + // synchronous dispatch leaks a tenant into the caller's flow. var previous = tenantAccessor.Current; - tenantAccessor.Set(TenantContext.FromEvent(@event)); + tenantAccessor.Current = context; try { - // By runtime type. `@event` is declared as the base interface - // here, so a closed generic over its static type would resolve - // nothing. - var contract = typeof(IIntegrationEventHandler<>).MakeGenericType(@event.GetType()); - foreach (var handler in scope.ServiceProvider.GetServices(contract)) - await ((dynamic)handler!).HandleAsync((dynamic)@event, ct); + var handler = scope.ServiceProvider.GetServices(contract).ElementAt(index)!; + + // Through the interface's MethodInfo, NOT `dynamic`: the dynamic + // binder honours accessibility, so an `internal` handler — the + // normal shape for a module's own consumer — fails to bind at + // runtime. Invoke wraps a synchronous throw, so the inner + // exception is rethrown with ExceptionDispatchInfo; a + // TargetInvocationException would tell the error pipeline the + // transport failed when the handler did. + await (Task)handle.Invoke(handler, [envelope.Event, ct])!; + } + catch (Exception ex) + { + // Collected, not rethrown here. Poison-message containment is + // per subscription: letting the first fault escape the loop lets + // one module's broken handler deny every other module the event. + (failures ??= []).Add(ex); } finally { - tenantAccessor.Set(previous); + tenantAccessor.Current = previous; } - }); // handler calls IInboxGuard itself - } + } + + // One failure rethrown as itself; several as an AggregateException. + } // the handler calls IInboxGuard itself } ``` +The listing is abridged — the shipped class also logs each failure with event, tenant +and partition key, refuses a `null` Task from a handler by name, and wraps an +`OperationCanceledException` raised by a token other than the publish token, so an +outbox processor cannot read "the handler gave up" as "we are shutting down". See +`backend/src/LearnStack.Infrastructure/Messaging/InProcessEventBus.cs`. + +**`PartitionSerializer` refuses reentrant work for the key it is already inside.** +Queuing work for a key from within that key's own work is a deadlock by construction: +the new unit chains behind a tail that cannot complete until the current one returns. +An earlier attempt ran such a call inline, reasoning the caller *is* the sequence — which +is unsound, because an `AsyncLocal` marker flows into every task started inside a unit, +so a fire-and-forget spawn inherited it and ran concurrently with the unit it should have +queued behind. Detection is the same either way; only the action differs. A false +positive that throws is loud and diagnosable; one that runs inline is a silent +concurrency violation. The caller that hits it is publishing from inside a handler, +which [Standards 20](../standards/20-infrastructure-stack.md) already forbids — a +handler writes to the outbox. + What the in-process transport genuinely does **not** provide, and what therefore constitutes the trigger for the Dapr adapter: delivery to a second process, broker-side replay, and durability of the in-flight message if the process dies between publish and @@ -502,8 +572,14 @@ the choice. (`UserCreatedIntegrationEventV2`); old version remains supported during a migration window. - Consumers are **idempotent** via `IInboxGuard` (per-module inbox table). -- Mandatory metadata on every integration event: `EventId`, `TenantId`, `OccurredAt`, - `CorrelationId`. Optional but recommended: `CausationId`, `ActorUserId`. +- Mandatory on every integration event: `EventId`, `TenantId`, `OccurredAt`, + `PartitionKey`. The first three are `required`; the fourth is abstract. +- Carried on the **envelope**, not the event: `Topic`, `CorrelationId`, + `OrganizationId`, `CausationId`, `ActorUserId`. They describe the delivery rather + than the fact, and they are what the outbox row holds — `correlation_id` and `topic` + are `NOT NULL` there. Correlation therefore travels from the row to the consumer + rather than from whatever context happens to be ambient at dispatch, which is `null` + inside the background service the processor is. - **Tenant context** restored on the consumer side before any business logic runs. The transport delivers the event payload; the consumer scope sets the ambient context from `@event.TenantId` before invoking the handler, so the handler's queries carry the same @@ -517,16 +593,26 @@ guarantee nobody has. The rule, therefore, is that **every outbox row carries a non-null `partition_key`**: +Every event overrides `PartitionKey`; the table below is how to choose its value, not +a list of mechanisms: + | Event shape | Partition key | |---|---| | Event about one aggregate instance (the normal case) | The aggregate id — `EnrollmentCreatedIntegrationEvent` keys on `EnrollmentId` | -| Event about the tenant as a whole (`TenantSuspended`, `EntitlementUpdated`) | `TenantId` | -| Event with an explicit ordering domain that is neither (rare) | Declared by the event type through `IPartitionedIntegrationEvent.PartitionKey` | - -`IOutbox.EnqueueAsync` resolves the key at enqueue time and writes it to the row; the -processor reads it from the row and passes it to `IEventBus.PublishAsync`. Nothing -downstream re-derives it, so the ordering domain is decided once, by the producer that -knows it. +| Event about the tenant as a whole (`TenantSuspended`, `EntitlementUpdated`) | `TenantId`, deliberately — it serialises that tenant's whole stream onto one partition | + +The member is **abstract rather than defaulted**, and that is the decision: a default +would have to be the tenant id, which takes the throughput cost above by accident +instead of on purpose. + +`IOutbox.EnqueueAsync` copies the event's key onto the row; the processor reads it back +and builds the envelope from it. `IntegrationEventEnvelope.PartitionKey` is +`Event.PartitionKey` — it cannot hold a second answer. An earlier shape passed the key +alongside the event as a separate parameter, which meant two sources with nothing +reconciling them: measured, the transport read the parameter and never the event, and +every test published an event whose declared key disagreed with the one passed, green. +Ordering is guaranteed per partition key, so a key that can differ from itself is a +guarantee that cannot be stated. Consequences worth stating plainly: diff --git a/docs/glossary.md b/docs/glossary.md index 79891ffc..d212d3c1 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -278,7 +278,11 @@ This glossary defines LearnStack-specific terms. When a term is ambiguous across | **Demand-Gated Building Block** | An infrastructure choice that ships as a **port plus a working default implementation** now, and as a **vendor adapter later**, in a named phase, when a written trigger fires. It qualifies only when all four exist: the port, the default implementation, the owning phase, and the trigger condition. A block missing any of the four is not demand-gated — it is simply missing. Distinguished from a one-way door by the test below. The gated set and each trigger are tabulated in [ADR-0035](decisions/0035-demand-gated-infrastructure.md), which also names its own two exceptions — `audit_log` partitioning, which is schema-internal and so has no port, and LiveKit, which has no default because its absence is a missing product feature rather than a missing implementation. | | **One-Way Door** | A decision that gets more expensive with every week of delay, tested by the question: *if I add this six months from now, will I have to touch code that is already written?* **Yes → one-way door; ship it now** (tenant and organization isolation, the `outbox_messages` table and its ownership, strongly-typed identifiers, the localization schema — each touches every query, migration, or job payload ever written). **No → additive; ship the port now and the adapter on demand.** The test is mechanical, not a matter of taste: tenant isolation's cost grows with the codebase, a Dapr adapter's cost does not. A corollary rule: **a deployment mode or customer segment without a signed contract cannot be the deciding factor in a technical choice** — it may break a tie between otherwise-equal options, nothing more. Per [ADR-0035](decisions/0035-demand-gated-infrastructure.md) and [Engineering Principles](standards/00-principles.md). | | **Dapr Building Blocks** | The three Dapr abstractions LearnStack uses **when it uses Dapr**: pub/sub (Kafka), state (Valkey), secrets (Vault) per [ADR-0014](decisions/0014-adopt-dapr.md). Service invocation, workflow, bindings, and actors are out of scope. Demand-gated to [Phase 11](roadmap/phase-11-production-hardening.md); ADR-0014 decides *what*, [ADR-0035](decisions/0035-demand-gated-infrastructure.md) decides *when*. | -| **`IEventBus`** | Interface for publishing integration events, taking an explicit `partitionKey`. `InProcessEventBus` is the only registered implementation until the Dapr adapter's trigger fires — and it is a **first-class transport, not a stub**: same `IIntegrationEventHandler`, same `IInboxGuard`, same tenant-context restoration, same per-partition-key ordering as the durable path. The `OutboxProcessor` is the only sanctioned caller. | +| **`IEventBus`** | Interface for publishing integration events, taking an `IntegrationEventEnvelope`. `InProcessEventBus` is the only registered implementation until the Dapr adapter's trigger fires — and it is a **first-class transport, not a stub**: same `IIntegrationEventHandler`, same `IInboxGuard`, same tenant-context restoration, same per-partition-key ordering as the durable path. The `OutboxProcessor` is the only sanctioned caller. | +| **`IntegrationEventEnvelope`** | One integration event plus the dispatch metadata the outbox row carries and the event does not: `Topic`, `CorrelationId`, `OrganizationId`, `CausationId`, `ActorUserId`. Its `PartitionKey` is the event's own, so the ordering domain has exactly one source ([ADR-0014 Amendment 3](decisions/0014-adopt-dapr.md)). Metadata describes the *delivery*; the event describes the *fact*. | +| **`IPartitionSerializer`** | Runs work sequentially within one partition key and concurrently across different ones — the in-process stand-in for what a broker gives you by assigning a partition to one consumer. It exists so the development transport carries the same ordering guarantee as the durable path rather than a weaker one. Queuing work for the key you are already inside is refused rather than deadlocked: the caller that does it is publishing from inside a handler, which [Standards 20](standards/20-infrastructure-stack.md) forbids. | +| **`EventTenantContext`** | The `ITenantContext` a consumer runs under, rebuilt from the envelope by the transport before the handler runs. A consumer executes outside the request that produced the fact, so there is no ambient context to inherit — which is why the tenant travels on the event and the rest on the envelope. Restoring it is what makes the query filters and the RLS policies evaluate against the right scope. | +| **`UserId.SystemActor`** | The fixed, non-empty `UserId` that integration-event consumers, background jobs and other non-request executions write state as — what [Audit Coverage](standards/18-audit-coverage.md) means by an actor of type `system`. Fixed rather than generated because it is a foreign key: the Tenancy migration seeds the matching `users` row so `created_by` resolves. `AuditableEntity.MarkCreated` refuses `default(UserId)` and `Guid.Empty` alike, so without it no consumer could create an aggregate at all. | | **`ICacheService`** | Interface for cache reads / writes. `InMemoryCacheService` today; a Valkey-backed implementation when more than one instance runs concurrently. Cache keys lead with the tenant segment — `{tenant_id}:{module}:{logical-name}`, or `{tenant_id}:{organization_id}:{module}:{logical-name}` for a value scoped to one organization — composed by `CacheKey` and enforced by `CacheKey.EnsureValid`, because there is no query filter and no RLS policy in front of a dictionary. `RemoveByPrefixAsync` is **removed** ([ADR-0014 Amendment 2](decisions/0014-adopt-dapr.md)) — it iterated an instance-local key set, so keys written by another instance were never evicted. What replaces it is the **generation-key** pattern, which is a caller-side convention rather than a member of this interface: a durable counter bumped inside the business transaction and embedded in the key template. | | **`ISecretProvider`** | Interface for secret reads. `ConfigurationSecretProvider` today; the Vault-backed implementation when a production secret must rotate without a redeploy, or more than one operator needs access to production secrets ([ADR-0035](decisions/0035-demand-gated-infrastructure.md) — *not* when a non-development deployment merely exists, which SaaS satisfies on day one). Secret namespace `learnstack/{deployment}/{module}/{key}`. | | **`IEntitlementProvider`** | Interface for the Entitlement Projection source. Implementations: `NullEntitlementProvider` (Development only — all features enabled, no limits), `HubEntitlementProvider` (SaaS / Dedicated, from Phase 02c), `SignedLicenseKeyEntitlementProvider` (Self-Hosted; skeleton from Hub `P02c-6`, hardened in Phase 11). The Hub-backed provider resolves in the normative order `L1 → L2 → platform_entitlement_cache → Hub` and never throws out of a feature-flag check. | diff --git a/docs/roadmap/phase-02a-kernel-tenancy.md b/docs/roadmap/phase-02a-kernel-tenancy.md index 1e845c44..d9477019 100644 --- a/docs/roadmap/phase-02a-kernel-tenancy.md +++ b/docs/roadmap/phase-02a-kernel-tenancy.md @@ -354,6 +354,14 @@ projection), `platform_entitlement_cache`, `platform_host_to_tenant`, `idempoten port and an in-memory default that is correct for one instance and wrong for two), and `outbox_messages`. Default-organization seeding at tenant creation. +**Seed the system actor.** `UserId.SystemActor` — a fixed, non-empty id in +`LearnStack.SharedKernel.Identifiers` — is what an integration-event consumer, a +background job, or any other non-request execution writes state as, per +[Audit Coverage](../standards/18-audit-coverage.md)'s actor-of-type-`system` rule. +`AuditableEntity.MarkCreated` refuses `default(UserId)` and `Guid.Empty` alike, so +without it no consumer can create an aggregate at all. It is a foreign key: this +packet's migration seeds the matching `users` row so `created_by` resolves. + The `Organization` aggregate is declared in `LearnStack.Modules.Tenancy.Domain`, with its EF configuration and its migration on `TenancyDbContext`, per [ADR-0017 Amendment 2 (2026-08-10)](../decisions/0017-tenant-organization-hierarchy.md). Identity holds @@ -660,10 +668,12 @@ here with **working default implementations**; the vendor adapters ship on a tri `IIntegrationEventHandler` interface, same `IInboxGuard`, same tenant-context restoration as the durable path, so development exercises the isolation code and no consumer needs two implementations. -- `ICacheService` with `InMemoryCacheService`. `RemoveByPrefixAsync` is removed from - the interface or redesigned to a generation-key pattern before Packet 5 ships — the - published contract iterates an instance-local key set and cannot be honoured across - instances by any candidate backend. +- `ICacheService` with `InMemoryCacheService`. `RemoveByPrefixAsync` is **removed** + ([ADR-0014 Amendment 2](../decisions/0014-adopt-dapr.md)) — the published contract + iterated an instance-local key set and could not be honoured across instances by any + candidate backend. "Removed **or** redesigned" was never a fork at the port: the + generation-key pattern puts its counter in durable domain state, so it adds no member + to the interface and stays a caller-side convention. - `ISecretProvider` with `ConfigurationSecretProvider`. - `IEntitlementProvider` with `NullEntitlementProvider` (all features enabled, no limits). From 3ec1e9f33d1fa5fade5acb7102ec5db7d35bfb7b Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Tue, 25 Aug 2026 11:11:48 +0300 Subject: [PATCH 13/21] fix(kernel): close a longer cycle, and let the event name its channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Sonnet round on the reworked transport, plus the topic decision. **An indirect cycle still deadlocked.** The reentrancy guard compared the requested key against the innermost key on the flow, which catches `A → A` and misses `A → B → A` — the same cycle one hop longer. Measured: five out of five attempts hung, silently and permanently, no exception and no log. The marker now records every ancestor key on the flow, because a cycle through any number of keys is still a cycle. Re-measured: 0 of 5. **A handler that fails to CONSTRUCT took every sibling with it**, and the per-handler isolation the class advertises could do nothing about it: the container materialises the whole handler array before returning any element, so the failure lands before the loop that provides isolation ever starts. Measured — a healthy handler registered alongside a throwing one had its constructor run and `HandleAsync` never called. That cannot be contained from here, so it is now named instead of leaking out as a bare constructor exception from a transport the caller did not know it was in. **The construction cost is written down rather than left to be discovered.** N handlers for one event means N constructions per scope and N scopes — twelve for three, measured. Only one `HandleAsync` runs per handler, so business logic is never duplicated; what repeats is construction. That is affordable exactly as long as a handler's constructor does nothing but assign fields, which is now a requirement rather than a convention. **The topic moves onto the event** (approved). It is a property of the event *type* — two events of one type always go to the same channel — so a producer-supplied string on the envelope was the same second-source hazard `PartitionKey` had, where the transport read one source and the event declared another. `Topic` is abstract on `IntegrationEventBase`; the envelope reads it; the compiler asks every event for its own. That unblocked the last item Packet 5 owed. `Integration_Event_TopicNames_ FollowConvention` is catalogued for this packet and asserts over the event **declarations** — while nothing declared a topic it could not be written at all. It is implemented now, and because no module declares an event yet the convention checker is pointed at six deliberate offenders first: a guard that cannot be shown to fire is not a guard. Standards 20 claimed the topic is "how handlers are addressed", which is not true of the shipped transport — it addresses them by CLR type. The reason to check the convention before a broker exists is that the event carries it to whichever transport is registered. 701 tests green, 0 warnings under CI=true. Co-Authored-By: Claude Opus 5 (1M context) --- .../Messaging/InProcessEventBus.cs | 54 +++++++++++++++++-- .../Messaging/PartitionSerializer.cs | 26 ++++++--- .../Messaging/IIntegrationEvent.cs | 13 +++++ .../Messaging/IntegrationEventBase.cs | 3 ++ .../Messaging/IntegrationEventEnvelope.cs | 21 +++++--- .../CrossCuttingFoundationTests.cs | 50 +++++++++++++++++ .../Messaging/InProcessEventBusTests.cs | 9 ++-- .../IntegrationEventContractTests.cs | 21 +++++++- docs/architecture/15-event-and-outbox.md | 5 +- docs/decisions/0014-adopt-dapr.md | 10 +++- docs/glossary.md | 2 +- docs/standards/20-infrastructure-stack.md | 7 +-- .../21-architecture-tests-catalogue.md | 11 +++- 13 files changed, 202 insertions(+), 30 deletions(-) diff --git a/backend/src/LearnStack.Infrastructure/Messaging/InProcessEventBus.cs b/backend/src/LearnStack.Infrastructure/Messaging/InProcessEventBus.cs index cdea8d04..621d7574 100644 --- a/backend/src/LearnStack.Infrastructure/Messaging/InProcessEventBus.cs +++ b/backend/src/LearnStack.Infrastructure/Messaging/InProcessEventBus.cs @@ -86,7 +86,24 @@ private async Task DispatchAsync( .MakeGenericType(envelope.Event.GetType()); var handle = contract.GetMethod(HandleMethodName)!; var context = EventTenantContext.FromEnvelope(envelope); - var count = HandlerCount(contract); + var count = HandlerCount(contract, envelope, out var constructionFailure); + + if (constructionFailure is not null) + { + // A handler whose CONSTRUCTOR throws takes every sibling with it, + // and there is nothing this class can do about that: the container + // materialises the whole array before returning any element, so the + // failure lands before the per-handler loop that provides isolation + // can start. Measured — a healthy handler registered alongside a + // throwing one never had HandleAsync called at all. It is said + // plainly here rather than surfacing as a bare constructor exception + // from a transport the caller did not know it was in. + throw new InvalidOperationException( + $"An integration-event handler for {envelope.Event.GetType().Name} failed to " + + "construct, so no handler for that event could run. Handler construction " + + "happens before per-handler isolation and cannot be contained.", + constructionFailure); + } if (count == 0) { @@ -155,6 +172,16 @@ private async Task DeliverAsync( // Selected by index rather than by concrete type, because a handler is // registered against the CONTRACT — its own type is not a service, and // asking the container for it fails. + // + // The cost, stated plainly: the container materialises the whole array + // for each scope, so N handlers for one event means N constructions per + // scope and N scopes — measured, twelve constructions for three + // handlers. Only one HandleAsync runs per handler, so business logic is + // never duplicated; what repeats is construction. That is affordable + // exactly as long as a handler's constructor does nothing but assign + // fields — which is the DI convention anyway, and is now a requirement + // rather than a habit. A constructor that opens a connection, emits a + // metric or writes a log line will do it N+1 times per delivery. await using var scope = scopeFactory.CreateAsyncScope(); // Restored into the flow the handler runs in AND into the scope it @@ -211,11 +238,24 @@ private async Task DeliverAsync( } } - private int HandlerCount(Type contract) + private int HandlerCount( + Type contract, IntegrationEventEnvelope envelope, out Exception? constructionFailure) { + constructionFailure = null; + using var scope = scopeFactory.CreateScope(); - return scope.ServiceProvider.GetServices(contract).Count(); + try + { + return scope.ServiceProvider.GetServices(contract).Count(); + } + catch (Exception ex) + { + HandlerConstructionFailed( + logger, envelope.Event.GetType().Name, envelope.Event.EventId, ex); + constructionFailure = ex; + return 0; + } } [LoggerMessage( @@ -232,6 +272,14 @@ private static partial void HandlerFailed( string partitionKey, Exception exception); + [LoggerMessage( + EventId = 3, + Level = LogLevel.Error, + Message = "A handler for integration event {EventType} ({IntegrationEventId}) failed " + + "to construct; no handler for that event ran")] + private static partial void HandlerConstructionFailed( + ILogger logger, string eventType, Guid integrationEventId, Exception exception); + [LoggerMessage( EventId = 2, Level = LogLevel.Debug, diff --git a/backend/src/LearnStack.Infrastructure/Messaging/PartitionSerializer.cs b/backend/src/LearnStack.Infrastructure/Messaging/PartitionSerializer.cs index f913e75e..16441e87 100644 --- a/backend/src/LearnStack.Infrastructure/Messaging/PartitionSerializer.cs +++ b/backend/src/LearnStack.Infrastructure/Messaging/PartitionSerializer.cs @@ -1,4 +1,5 @@ using System.Collections.Concurrent; +using System.Collections.Immutable; using LearnStack.SharedKernel.Messaging; namespace LearnStack.Infrastructure.Messaging; @@ -55,8 +56,15 @@ public sealed class PartitionSerializer : IPartitionSerializer /// integration tests build deliberately — otherwise share one marker, and /// being inside a key on one serializer would speak for the other. /// + /// + /// It records every ancestor key on the flow, not just the innermost one. + /// Comparing against the innermost alone catches A → A and misses + /// A → B → A, which is the same cycle one hop longer: measured, five + /// out of five attempts hung, silently and permanently, with no exception + /// and no log. A cycle through any number of keys is still a cycle. + /// /// - private readonly AsyncLocal _executingKey = new(); + private readonly AsyncLocal?> _executingKeys = new(); public Task RunSequentiallyFor(string partitionKey, Func work) { @@ -68,7 +76,9 @@ public Task RunSequentiallyFor(string partitionKey, Func work) // forbids for its own reasons — a handler writes to the outbox, and the // OutboxProcessor is the only sanctioned publisher. Answering it with a // message beats answering it with a hang. - if (string.Equals(_executingKey.Value, partitionKey, StringComparison.Ordinal)) + var ancestors = _executingKeys.Value ?? ImmutableHashSet.Empty; + + if (ancestors.Contains(partitionKey)) { return Task.FromException(new InvalidOperationException( $"Work for partition key '{partitionKey}' is already running on this " @@ -92,7 +102,7 @@ public Task RunSequentiallyFor(string partitionKey, Func work) var previous = _tails.TryGetValue(partitionKey, out var tail) ? tail : Task.CompletedTask; queued = previous.ContinueWith( - _ => RunMarked(_executingKey, partitionKey, work), + _ => RunMarked(_executingKeys, partitionKey, work), CancellationToken.None, TaskContinuationOptions.None, TaskScheduler.Default) @@ -132,10 +142,12 @@ public Task RunSequentiallyFor(string partitionKey, Func work) } private static async Task RunMarked( - AsyncLocal executingKey, string partitionKey, Func work) + AsyncLocal?> executingKeys, + string partitionKey, + Func work) { - var previous = executingKey.Value; - executingKey.Value = partitionKey; + var previous = executingKeys.Value; + executingKeys.Value = (previous ?? ImmutableHashSet.Empty).Add(partitionKey); try { @@ -143,7 +155,7 @@ private static async Task RunMarked( } finally { - executingKey.Value = previous; + executingKeys.Value = previous; } } diff --git a/backend/src/LearnStack.SharedKernel/Messaging/IIntegrationEvent.cs b/backend/src/LearnStack.SharedKernel/Messaging/IIntegrationEvent.cs index b7ba44af..4f703466 100644 --- a/backend/src/LearnStack.SharedKernel/Messaging/IIntegrationEvent.cs +++ b/backend/src/LearnStack.SharedKernel/Messaging/IIntegrationEvent.cs @@ -35,6 +35,19 @@ public interface IIntegrationEvent /// When the fact happened, from IClock — never DateTime.UtcNow. DateTimeOffset OccurredAt { get; } + /// + /// The channel this event is published on, learnstack.{module}.{aggregate}. + /// + /// + /// A property of the event type, not of one delivery: two events of + /// the same type always go to the same topic, and the name is derivable from + /// the type. Declaring it here rather than passing it alongside is the same + /// rule as , for the same reason — a value with + /// two sources is a value that can disagree with itself, and the transport + /// would read one of them. + /// + string Topic { get; } + /// /// The ordering domain this event belongs to. /// diff --git a/backend/src/LearnStack.SharedKernel/Messaging/IntegrationEventBase.cs b/backend/src/LearnStack.SharedKernel/Messaging/IntegrationEventBase.cs index 18995194..8127d06d 100644 --- a/backend/src/LearnStack.SharedKernel/Messaging/IntegrationEventBase.cs +++ b/backend/src/LearnStack.SharedKernel/Messaging/IntegrationEventBase.cs @@ -31,6 +31,9 @@ public abstract record IntegrationEventBase : IIntegrationEvent /// public required DateTimeOffset OccurredAt { get; init; } + /// + public abstract string Topic { get; } + /// public abstract string PartitionKey { get; } diff --git a/backend/src/LearnStack.SharedKernel/Messaging/IntegrationEventEnvelope.cs b/backend/src/LearnStack.SharedKernel/Messaging/IntegrationEventEnvelope.cs index 2b3ee7df..77469a4f 100644 --- a/backend/src/LearnStack.SharedKernel/Messaging/IntegrationEventEnvelope.cs +++ b/backend/src/LearnStack.SharedKernel/Messaging/IntegrationEventEnvelope.cs @@ -28,12 +28,6 @@ namespace LearnStack.SharedKernel.Messaging; /// /// /// The fact being published. -/// -/// The channel, learnstack.{module}.{aggregate}. Meaningless to the -/// in-process transport, which addresses handlers by CLR type, and load-bearing -/// for every durable one — so it is carried from the start rather than invented -/// when the first broker arrives. -/// /// /// The originating request's W3C traceparent, taken from the outbox row rather /// than from whatever context happens to be ambient at dispatch. @@ -50,7 +44,6 @@ namespace LearnStack.SharedKernel.Messaging; /// public sealed record IntegrationEventEnvelope( IIntegrationEvent Event, - string Topic, string CorrelationId, Guid? OrganizationId = null, Guid? CausationId = null, @@ -69,4 +62,18 @@ public sealed record IntegrationEventEnvelope( /// that cannot be stated. /// public string PartitionKey => Event.PartitionKey; + + /// + /// The channel, read from the event and from nowhere else. + /// + /// + /// It was briefly a producer-supplied string on this record. The topic is a + /// property of the event type — two events of one type always go to + /// the same channel — so a per-delivery parameter invited exactly the drift + /// already had, where the transport read one + /// source and the event declared another. It also made + /// Integration_Event_TopicNames_FollowConvention unimplementable: + /// the rule asserts over declared event types, and nothing declared one. + /// + public string Topic => Event.Topic; } diff --git a/backend/tests/LearnStack.Tests.Architecture/CrossCuttingFoundationTests.cs b/backend/tests/LearnStack.Tests.Architecture/CrossCuttingFoundationTests.cs index 21546910..45c165b4 100644 --- a/backend/tests/LearnStack.Tests.Architecture/CrossCuttingFoundationTests.cs +++ b/backend/tests/LearnStack.Tests.Architecture/CrossCuttingFoundationTests.cs @@ -312,6 +312,56 @@ public void Modules_Do_Not_Reference_DeploymentMode() } } + [Fact] + public void Integration_Event_TopicNames_FollowConvention() + { + // Standards 20 § IEventBus and ADR-0006: `learnstack.{module}.{aggregate}`, + // with `learnstack.hub.*` reserved for Hub-side topics. Asserted over the + // declared event TYPES, so it holds for whichever IEventBus + // implementation is registered — which is only possible because the topic + // is declared by the event. While it was a producer-supplied string on + // the envelope there was nothing to read, and this catalogued rule could + // not be written at all. + // + // No module declares an event yet, so this would be vacuous — hence the + // deliberate offenders below. A guard that cannot be shown to fire is + // not a guard. + FollowsTopicConvention("learnstack.enrollment.enrollment").Should().BeTrue(); + FollowsTopicConvention("learnstack.hub.entitlement").Should().BeTrue(); + FollowsTopicConvention("EnrollmentCreated").Should().BeFalse("no namespace"); + FollowsTopicConvention("learnstack.enrollment").Should().BeFalse("no aggregate"); + FollowsTopicConvention("Learnstack.Enrollment.Enrollment").Should().BeFalse("not lower-case"); + FollowsTopicConvention("acme.enrollment.enrollment").Should().BeFalse("wrong prefix"); + + foreach (var name in ModuleAssemblyShapes) + { + var assembly = TryLoadAssembly(name); + if (assembly is null) continue; + + var events = assembly.GetTypes() + .Where(t => !t.IsAbstract + && typeof(LearnStack.SharedKernel.Messaging.IIntegrationEvent) + .IsAssignableFrom(t)); + + foreach (var type in events) + { + var topic = ((LearnStack.SharedKernel.Messaging.IIntegrationEvent) + System.Runtime.CompilerServices.RuntimeHelpers.GetUninitializedObject(type)).Topic; + + FollowsTopicConvention(topic).Should().BeTrue( + $"{type.FullName} declares topic '{topic}', which is not " + + "learnstack.{module}.{aggregate} (Standards 20 § IEventBus)"); + } + } + } + + private static bool FollowsTopicConvention(string topic) => + System.Text.RegularExpressions.Regex.IsMatch( + topic, + @"^learnstack\.[a-z0-9-]+\.[a-z0-9-]+$", + System.Text.RegularExpressions.RegexOptions.None, + TimeSpan.FromSeconds(1)); + [Fact] public void Modules_Do_Not_Inject_IEventBus_Directly() { diff --git a/backend/tests/LearnStack.Tests.Unit/Infrastructure/Messaging/InProcessEventBusTests.cs b/backend/tests/LearnStack.Tests.Unit/Infrastructure/Messaging/InProcessEventBusTests.cs index 2558252b..b7b73548 100644 --- a/backend/tests/LearnStack.Tests.Unit/Infrastructure/Messaging/InProcessEventBusTests.cs +++ b/backend/tests/LearnStack.Tests.Unit/Infrastructure/Messaging/InProcessEventBusTests.cs @@ -26,7 +26,6 @@ public sealed class InProcessEventBusTests { private static readonly Guid Tenant = Guid.Parse("018f4d40-0000-7000-8000-00000000000a"); private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(10); - private const string Topic = "learnstack.test.thing"; private const string Trace = "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"; [Fact] @@ -73,7 +72,7 @@ public async Task Publishing_Through_The_Base_Interface_Still_Reaches_The_Handle services.AddScoped, ThingHandler>()); IIntegrationEvent asBase = NewThing("a"); - await bus.PublishAsync(new IntegrationEventEnvelope(asBase, Topic, Trace)); + await bus.PublishAsync(new IntegrationEventEnvelope(asBase, Trace)); recorder.Handled.Should().ContainSingle(); } @@ -198,7 +197,7 @@ public async Task The_Envelopes_Actor_And_Organization_Reach_The_Handler() var organization = Guid.Parse("018f4d40-0000-7000-8000-0000000000c1"); await bus.PublishAsync(new IntegrationEventEnvelope( - NewThing("a"), Topic, Trace, OrganizationId: organization, ActorUserId: actor)); + NewThing("a"), Trace, OrganizationId: organization, ActorUserId: actor)); recorder.Actors.Should().ContainSingle().Which.Should().Be(actor); recorder.Organizations.Should().ContainSingle().Which.Should().Be(organization); @@ -459,7 +458,7 @@ public async Task A_Failed_Delivery_Does_Not_Block_The_Rest_Of_Its_Partition() /// can differ from itself is a guarantee that cannot be stated. /// private static IntegrationEventEnvelope Envelope(Thing @event) => - new(@event, Topic, Trace); + new(@event, Trace); private static Thing NewThing(string payload, string? partitionKey = null) => new() { @@ -565,6 +564,8 @@ public sealed record Thing : IntegrationEventBase /// An ordering domain independent of the payload, for the ordering cases. public string? Key { get; init; } + public override string Topic => "learnstack.test.thing"; + public override string PartitionKey => Key ?? Payload; } diff --git a/backend/tests/LearnStack.Tests.Unit/SharedKernel/Messaging/IntegrationEventContractTests.cs b/backend/tests/LearnStack.Tests.Unit/SharedKernel/Messaging/IntegrationEventContractTests.cs index ecdc52dd..fab630e9 100644 --- a/backend/tests/LearnStack.Tests.Unit/SharedKernel/Messaging/IntegrationEventContractTests.cs +++ b/backend/tests/LearnStack.Tests.Unit/SharedKernel/Messaging/IntegrationEventContractTests.cs @@ -24,6 +24,22 @@ public sealed class IntegrationEventContractTests { private static readonly Guid Tenant = Guid.Parse("018f4d40-0000-7000-8000-00000000000a"); + [Theory] + [InlineData(nameof(IIntegrationEvent.Topic))] + [InlineData(nameof(IIntegrationEvent.PartitionKey))] + public void The_Event_Declares_Its_Own_Channel_And_Ordering_Domain(string member) + { + // Both are properties of the event TYPE, not of one delivery, and both + // were briefly carried alongside the event instead — where a value with + // two sources can disagree with itself and the transport reads one of + // them. Abstract means the compiler asks every event for its own. + typeof(IntegrationEventBase).GetProperty(member)! + .GetGetMethod()!.IsAbstract.Should().BeTrue(); + + typeof(IntegrationEventEnvelope).GetProperty(member)! + .CanWrite.Should().BeFalse($"the envelope reads {member} off the event"); + } + [Fact] public void PartitionKey_Is_Abstract_So_No_Event_Can_Inherit_A_Default() { @@ -109,8 +125,7 @@ public void The_Consumer_Context_Has_The_Shape_A_Handler_Needs() var organization = Guid.Parse("018f4d40-0000-7000-8000-0000000000c1"); var context = EventTenantContext.FromEnvelope(new IntegrationEventEnvelope( - NewSample(), "learnstack.test.sample", "trace-1", - OrganizationId: organization, ActorUserId: actor)); + NewSample(), "trace-1", OrganizationId: organization, ActorUserId: actor)); // IsResolved false would make TenantContextBehavior short-circuit every // consumer that sends a MediatR command — silently, before its business @@ -143,6 +158,8 @@ public sealed record Sample : IntegrationEventBase { public required string LearnerName { get; init; } + public override string Topic => "learnstack.test.sample"; + // Independent of the payload, so the truncation is demonstrated on a // member the interface does not carry rather than on a value that // happens to appear through PartitionKey. diff --git a/docs/architecture/15-event-and-outbox.md b/docs/architecture/15-event-and-outbox.md index bc5199f8..46f17ce7 100644 --- a/docs/architecture/15-event-and-outbox.md +++ b/docs/architecture/15-event-and-outbox.md @@ -574,7 +574,10 @@ the choice. - Consumers are **idempotent** via `IInboxGuard` (per-module inbox table). - Mandatory on every integration event: `EventId`, `TenantId`, `OccurredAt`, `PartitionKey`. The first three are `required`; the fourth is abstract. -- Carried on the **envelope**, not the event: `Topic`, `CorrelationId`, +- Declared by the event and read by the envelope: `Topic`, the channel + `learnstack.{module}.{aggregate}` — a property of the event type, checked by + `Integration_Event_TopicNames_FollowConvention`. +- Carried on the **envelope**, not the event: `CorrelationId`, `OrganizationId`, `CausationId`, `ActorUserId`. They describe the delivery rather than the fact, and they are what the outbox row holds — `correlation_id` and `topic` are `NOT NULL` there. Correlation therefore travels from the row to the consumer diff --git a/docs/decisions/0014-adopt-dapr.md b/docs/decisions/0014-adopt-dapr.md index c6558f4c..cb96492c 100644 --- a/docs/decisions/0014-adopt-dapr.md +++ b/docs/decisions/0014-adopt-dapr.md @@ -363,16 +363,24 @@ Task PublishAsync(IntegrationEventEnvelope envelope, CancellationToken ct = defa public sealed record IntegrationEventEnvelope( IIntegrationEvent Event, - string Topic, string CorrelationId, Guid? OrganizationId = null, Guid? CausationId = null, UserId? ActorUserId = null) { public string PartitionKey => Event.PartitionKey; + public string Topic => Event.Topic; } ``` +> **Refined the same day.** `Topic` was first a parameter on this record, and it should +> not have been. It is a property of the event *type* — two events of one type always go +> to the same channel, and the name is derivable from the type — so a per-delivery +> parameter is the same second-source hazard `PartitionKey` had. It also made the +> catalogued `Integration_Event_TopicNames_FollowConvention` unwritable: that rule reads +> the event declarations, and nothing declared a topic. `Topic` is abstract on +> `IntegrationEventBase`; the envelope reads it. + Three things forced it, and all three were measured rather than argued. **The dispatch metadata had nowhere to travel.** The canonical `outbox_messages` row diff --git a/docs/glossary.md b/docs/glossary.md index d212d3c1..836035bf 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -279,7 +279,7 @@ This glossary defines LearnStack-specific terms. When a term is ambiguous across | **One-Way Door** | A decision that gets more expensive with every week of delay, tested by the question: *if I add this six months from now, will I have to touch code that is already written?* **Yes → one-way door; ship it now** (tenant and organization isolation, the `outbox_messages` table and its ownership, strongly-typed identifiers, the localization schema — each touches every query, migration, or job payload ever written). **No → additive; ship the port now and the adapter on demand.** The test is mechanical, not a matter of taste: tenant isolation's cost grows with the codebase, a Dapr adapter's cost does not. A corollary rule: **a deployment mode or customer segment without a signed contract cannot be the deciding factor in a technical choice** — it may break a tie between otherwise-equal options, nothing more. Per [ADR-0035](decisions/0035-demand-gated-infrastructure.md) and [Engineering Principles](standards/00-principles.md). | | **Dapr Building Blocks** | The three Dapr abstractions LearnStack uses **when it uses Dapr**: pub/sub (Kafka), state (Valkey), secrets (Vault) per [ADR-0014](decisions/0014-adopt-dapr.md). Service invocation, workflow, bindings, and actors are out of scope. Demand-gated to [Phase 11](roadmap/phase-11-production-hardening.md); ADR-0014 decides *what*, [ADR-0035](decisions/0035-demand-gated-infrastructure.md) decides *when*. | | **`IEventBus`** | Interface for publishing integration events, taking an `IntegrationEventEnvelope`. `InProcessEventBus` is the only registered implementation until the Dapr adapter's trigger fires — and it is a **first-class transport, not a stub**: same `IIntegrationEventHandler`, same `IInboxGuard`, same tenant-context restoration, same per-partition-key ordering as the durable path. The `OutboxProcessor` is the only sanctioned caller. | -| **`IntegrationEventEnvelope`** | One integration event plus the dispatch metadata the outbox row carries and the event does not: `Topic`, `CorrelationId`, `OrganizationId`, `CausationId`, `ActorUserId`. Its `PartitionKey` is the event's own, so the ordering domain has exactly one source ([ADR-0014 Amendment 3](decisions/0014-adopt-dapr.md)). Metadata describes the *delivery*; the event describes the *fact*. | +| **`IntegrationEventEnvelope`** | One integration event plus the dispatch metadata the outbox row carries and the event does not: `CorrelationId`, `OrganizationId`, `CausationId`, `ActorUserId`. Its `Topic` and `PartitionKey` are the event's own — both are properties of the event *type* rather than of one delivery. Its `PartitionKey` is the event's own, so the ordering domain has exactly one source ([ADR-0014 Amendment 3](decisions/0014-adopt-dapr.md)). Metadata describes the *delivery*; the event describes the *fact*. | | **`IPartitionSerializer`** | Runs work sequentially within one partition key and concurrently across different ones — the in-process stand-in for what a broker gives you by assigning a partition to one consumer. It exists so the development transport carries the same ordering guarantee as the durable path rather than a weaker one. Queuing work for the key you are already inside is refused rather than deadlocked: the caller that does it is publishing from inside a handler, which [Standards 20](standards/20-infrastructure-stack.md) forbids. | | **`EventTenantContext`** | The `ITenantContext` a consumer runs under, rebuilt from the envelope by the transport before the handler runs. A consumer executes outside the request that produced the fact, so there is no ambient context to inherit — which is why the tenant travels on the event and the rest on the envelope. Restoring it is what makes the query filters and the RLS policies evaluate against the right scope. | | **`UserId.SystemActor`** | The fixed, non-empty `UserId` that integration-event consumers, background jobs and other non-request executions write state as — what [Audit Coverage](standards/18-audit-coverage.md) means by an actor of type `system`. Fixed rather than generated because it is a foreign key: the Tenancy migration seeds the matching `users` row so `created_by` resolves. `AuditableEntity.MarkCreated` refuses `default(UserId)` and `Guid.Empty` alike, so without it no consumer could create an aggregate at all. | diff --git a/docs/standards/20-infrastructure-stack.md b/docs/standards/20-infrastructure-stack.md index b1cfc201..8929e9fb 100644 --- a/docs/standards/20-infrastructure-stack.md +++ b/docs/standards/20-infrastructure-stack.md @@ -143,9 +143,10 @@ ADR-0014 non-goals; do not introduce them without a new ADR. write to the outbox. - Topic names follow `learnstack.{module}.{aggregate}` (`learnstack.identity.user`, `learnstack.enrollment.enrollment`, `learnstack.classroom.session`). The convention - applies to `InProcessEventBus` too — it is how handlers are addressed, not a Dapr - detail — which is why leaving it unasserted against the transport that is actually - registered would be the wrong trade. Two tests, not one: + applies to `InProcessEventBus` too. Not because the in-process transport routes on it + — it addresses handlers by CLR type — but because the topic is declared by the event + type and travels with it to whichever transport is registered, so the convention is + checkable, and worth checking, before the first broker exists. Two tests, not one: `Integration_Event_TopicNames_FollowConvention` is transport-independent, asserts the convention over the declared event types, and lands with `InProcessEventBus` in [Phase 02a Packet 5](../roadmap/phase-02a-kernel-tenancy.md); diff --git a/docs/standards/21-architecture-tests-catalogue.md b/docs/standards/21-architecture-tests-catalogue.md index 19b5c5c4..b6bc12c4 100644 --- a/docs/standards/21-architecture-tests-catalogue.md +++ b/docs/standards/21-architecture-tests-catalogue.md @@ -1169,9 +1169,18 @@ registered, which is the shape of gap this catalogue exists to close. It is ther - **Source:** [20-infrastructure-stack.md § `IEventBus`](20-infrastructure-stack.md); [ADR-0006](../decisions/0006-events-and-outbox.md). - **Type:** xUnit + reflection over module assemblies. **Kind:** structural. -- **Status:** **Registered.** +- **Status:** **Implemented** (`CrossCuttingFoundationTests`). No module declares an + event yet, so the module sweep is vacuous today; the convention checker is pointed at + deliberate offenders first, so the rule can be shown to fire. - **Phase:** 02a (Packet 5) — lands with `InProcessEventBus`, the first transport. +Writing it required a contract change. The rule reads the event **declarations**, and +while the topic was a producer-supplied string on the envelope nothing declared one — +the rule could not be written at all. `Topic` is now abstract on `IntegrationEventBase`, +alongside `PartitionKey` and for the same reason: it is a property of the event type, +not of one delivery, so a per-delivery parameter is a second source that can disagree +with the first. + `Dapr_PubSub_TopicNames_FollowConvention` keeps its Phase 11 slot and narrows to what only it can check: that the Dapr component bindings agree with the topics the events declare. From c9d3cc554c37bca810a4ff7d1a0c4ff2d92828ac Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Tue, 25 Aug 2026 11:39:21 +0300 Subject: [PATCH 14/21] test(kernel): kill the six mutants the Sonnet round left standing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A second mutation audit of the messaging suite: 24 mutants, 16 killed, 1 equivalent, 6 real gaps. Two of the six were in code written hours earlier, which is the point of running the audit after the fix rather than before it. **The cycle fix had no guard of its own.** Collapsing the ancestor set to the innermost key — exactly the defect that made `A → B → A` hang five times out of five — survived every test in the file. The `A → A` and `A → B` cases were covered; the cycle that closes was not. **Handler-construction failure was reported and never tested.** Deleting the report left the exception swallowed, the count back at zero, and a broken registration looking precisely like "nobody subscribed" — the silent-success shape this transport keeps producing when nothing checks. **Two structural assertions were standing in for value assertions.** The envelope's `Topic` and `PartitionKey` were checked only for being read-only and abstract on the base, so returning `Event.Topic + "-x"` — or reading a stale captured field instead of the event — passed everything. That is the exact bug class those properties exist to prevent, where the transport reads one source and the event declares another. **`The_Publish_Token_Reaches_The_Handler` asserted `CanBeCanceled`,** which is true of any token at all. Threading a freshly minted `CancellationTokenSource` through instead passed, while a shutdown would never reach a consumer — the failure the test names. It compares the token now. Also: a handler returning a null Task had no test, so the diagnostic naming the offending handler could be deleted for a bare `NullReferenceException` out of a transport the caller did not know it was in. **One test was genuinely flaky and is now honest about why.** The unobserved-exception check failed three times running and then passed six, on identical code, because the event fires on finalization. My first repair was worse than the defect: it cleared the sightings and looked again without re-running the scenario, which with the mechanism actually broken would have found nothing, because those exceptions had already fired. It runs the whole scenario twice now and only a repeat counts — a broken fault-observation produces sightings every time, a straggling finalizer produces them once. 15 consecutive runs stable. 707 tests green, 0 warnings under CI=true. Co-Authored-By: Claude Opus 5 (1M context) --- .../Messaging/InProcessEventBusTests.cs | 65 ++++++++++++++++++- .../Messaging/PartitionSerializerTests.cs | 58 ++++++++++++++++- .../IntegrationEventContractTests.cs | 17 +++++ 3 files changed, 134 insertions(+), 6 deletions(-) diff --git a/backend/tests/LearnStack.Tests.Unit/Infrastructure/Messaging/InProcessEventBusTests.cs b/backend/tests/LearnStack.Tests.Unit/Infrastructure/Messaging/InProcessEventBusTests.cs index b7b73548..3833f0de 100644 --- a/backend/tests/LearnStack.Tests.Unit/Infrastructure/Messaging/InProcessEventBusTests.cs +++ b/backend/tests/LearnStack.Tests.Unit/Infrastructure/Messaging/InProcessEventBusTests.cs @@ -363,7 +363,51 @@ public async Task The_Publish_Token_Reaches_The_Handler() await cancelled.CancelAsync(); await publish; - recorder.SawCancellableToken.Should().BeTrue(); + // The token ITSELF, not merely "a cancellable token" — asserting + // CanBeCanceled was true of any token at all, so threading a freshly + // minted CancellationTokenSource through instead would have passed while + // a shutdown never reached a consumer, which is the failure this test + // names. + recorder.HandlerToken.Should().Be(cancelled.Token); + } + + [Fact] + public async Task A_Handler_That_Cannot_Be_Built_Is_Reported_As_Such() + { + // Handler construction happens before the per-handler loop that provides + // isolation — the container materialises the whole array before + // returning any element — so a constructor that throws takes every + // sibling with it and nothing here can contain it. Without the explicit + // report the exception was swallowed, the count came back zero, and a + // broken registration looked exactly like "nobody subscribed". + var recorder = new Recorder(); + var (bus, _) = Build(recorder, services => + { + services.AddScoped, ThingHandler>(); + services.AddScoped, UnconstructableHandler>(); + }); + + var act = () => bus.PublishAsync(Envelope(NewThing("a"))); + + var thrown = await act.Should().ThrowAsync(); + thrown.Which.Message.Should().Contain("failed to construct"); + thrown.Which.InnerException!.Message.Should().Be("this handler cannot be built"); + recorder.Handled.Should().BeEmpty("no handler for that event could run"); + } + + [Fact] + public async Task A_Handler_Returning_No_Task_Is_Named() + { + // Otherwise the caller gets a bare NullReferenceException from inside a + // transport it did not know it was in. + var recorder = new Recorder(); + var (bus, _) = Build(recorder, services => + services.AddScoped, NullTaskHandler>()); + + var act = () => bus.PublishAsync(Envelope(NewThing("a"))); + + (await act.Should().ThrowAsync()) + .Which.Message.Should().Contain(nameof(NullTaskHandler)); } [Fact] @@ -535,7 +579,7 @@ public sealed class Recorder public ConcurrentQueue Probes { get; } = new(); - public bool SawCancellableToken { get; set; } + public CancellationToken HandlerToken { get; set; } public int Rendezvoused => _rendezvoused; @@ -691,11 +735,26 @@ public sealed class TokenReadingHandler(Recorder recorder) : IIntegrationEventHa { public Task HandleAsync(Thing @event, CancellationToken cancellationToken = default) { - recorder.SawCancellableToken = cancellationToken.CanBeCanceled; + recorder.HandlerToken = cancellationToken; return Task.CompletedTask; } } + public sealed class NullTaskHandler : IIntegrationEventHandler + { + public Task HandleAsync(Thing @event, CancellationToken cancellationToken = default) => + null!; + } + + public sealed class UnconstructableHandler : IIntegrationEventHandler + { + public UnconstructableHandler() => + throw new InvalidOperationException("this handler cannot be built"); + + public Task HandleAsync(Thing @event, CancellationToken cancellationToken = default) => + Task.CompletedTask; + } + public sealed class DisposalProbe : IDisposable { public bool Disposed { get; private set; } diff --git a/backend/tests/LearnStack.Tests.Unit/Infrastructure/Messaging/PartitionSerializerTests.cs b/backend/tests/LearnStack.Tests.Unit/Infrastructure/Messaging/PartitionSerializerTests.cs index e2573979..376899c2 100644 --- a/backend/tests/LearnStack.Tests.Unit/Infrastructure/Messaging/PartitionSerializerTests.cs +++ b/backend/tests/LearnStack.Tests.Unit/Infrastructure/Messaging/PartitionSerializerTests.cs @@ -191,6 +191,34 @@ await second.RunSequentiallyFor("k", () => ran.Should().BeTrue("a different serializer's chain is a different sequence"); } + [Fact] + public async Task A_Cycle_Through_Another_Key_Is_Refused_Too() + { + // A -> B -> A. The guard originally compared only the INNERMOST key on + // the flow, which catches A -> A and misses this — the same cycle one + // hop longer. Measured on that version: five out of five attempts hung, + // silently and permanently, no exception and no log. Tracking only the + // innermost key survived every other test in this file, so the fix had + // no guard of its own until now. + var serializer = new PartitionSerializer(); + Exception? refused = null; + + await serializer.RunSequentiallyFor("A", async () => + await serializer.RunSequentiallyFor("B", async () => + { + try + { + await serializer.RunSequentiallyFor("A", () => Task.CompletedTask); + } + catch (InvalidOperationException ex) + { + refused = ex; + } + })).WaitAsync(Timeout); + + refused.Should().NotBeNull("a cycle through any number of keys is still a cycle"); + } + [Fact] public async Task Nesting_A_Different_Key_Is_Fine() { @@ -344,12 +372,36 @@ public async Task A_Chain_Is_Not_Dropped_While_Work_Is_Still_Queued_Behind_It() public async Task A_Failing_Unit_Nobody_Awaits_Leaves_No_Unobserved_Exception() { // A publisher is free not to await what RunSequentiallyFor returns. + // + // The event fires on FINALIZATION, which makes one sighting weak + // evidence in both directions — measured, this failed three times + // running and then passed six, on identical code. So the scenario is run + // twice and only a repeat counts: a broken fault-observation produces + // sightings on every attempt, while a straggling finalizer produces them + // on one. Clearing and re-measuring without re-running the scenario + // would have been worse than useless — with the mechanism broken, the + // exceptions have already fired and the second look would be clean. const string Sentinel = "learnstack-partition-unobserved-probe"; + + (await MeasureUnobserved(Sentinel) && await MeasureUnobserved(Sentinel)) + .Should().BeFalse("the chain observes the fault it swallows"); + } + + /// + /// Runs the abandoned-failing-unit scenario once and reports whether any + /// unobserved exception carrying was raised. + /// + /// + /// The event is process-global and xUnit runs classes in parallel, so only + /// this test's own sentinel is counted. + /// + private static async Task MeasureUnobserved(string sentinel) + { var mine = new ConcurrentBag(); void Handler(object? sender, UnobservedTaskExceptionEventArgs e) { - if (e.Exception.Flatten().InnerExceptions.Any(inner => inner.Message == Sentinel)) + if (e.Exception.Flatten().InnerExceptions.Any(inner => inner.Message == sentinel)) { mine.Add(e.Exception); e.SetObserved(); @@ -363,7 +415,7 @@ void Handler(object? sender, UnobservedTaskExceptionEventArgs e) { var serializer = new PartitionSerializer(); _ = serializer.RunSequentiallyFor("k", () => - Task.FromException(new InvalidOperationException(Sentinel))); + Task.FromException(new InvalidOperationException(sentinel))); await Task.Delay(TimeSpan.FromMilliseconds(20)); } @@ -378,7 +430,7 @@ void Handler(object? sender, UnobservedTaskExceptionEventArgs e) GC.WaitForPendingFinalizers(); GC.Collect(); - mine.Should().BeEmpty("the chain observes the fault it swallows"); + return !mine.IsEmpty; } finally { diff --git a/backend/tests/LearnStack.Tests.Unit/SharedKernel/Messaging/IntegrationEventContractTests.cs b/backend/tests/LearnStack.Tests.Unit/SharedKernel/Messaging/IntegrationEventContractTests.cs index fab630e9..d9d9e013 100644 --- a/backend/tests/LearnStack.Tests.Unit/SharedKernel/Messaging/IntegrationEventContractTests.cs +++ b/backend/tests/LearnStack.Tests.Unit/SharedKernel/Messaging/IntegrationEventContractTests.cs @@ -40,6 +40,23 @@ public void The_Event_Declares_Its_Own_Channel_And_Ordering_Domain(string member .CanWrite.Should().BeFalse($"the envelope reads {member} off the event"); } + [Fact] + public void The_Envelope_Carries_The_Events_Own_Channel_And_Ordering_Domain() + { + // By VALUE, not merely structurally. Asserting only that the getters are + // read-only left the forwarding unchecked: returning `Event.Topic + "-x"` + // — or reading a stale captured field instead of the event — passed + // every test in the suite. That is the exact bug class these properties + // exist to prevent, where the transport reads one source and the event + // declares another. + var @event = NewSample(); + var envelope = new IntegrationEventEnvelope(@event, "trace-1"); + + envelope.Topic.Should().Be(@event.Topic); + envelope.PartitionKey.Should().Be(@event.PartitionKey); + envelope.Event.Should().BeSameAs(@event); + } + [Fact] public void PartitionKey_Is_Abstract_So_No_Event_Can_Inherit_A_Default() { From b8c452718abc501c7526dab470f3f4e68f97ac72 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Wed, 26 Aug 2026 21:33:54 +0300 Subject: [PATCH 15/21] fix(kernel): set the tenant before anything resolves a handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The final review round over the whole packet. No zero-tolerance blocker, one correctness defect, and a rule that would have been inert forever. **Handler constructors ran under the publisher's tenant.** Counting the handlers resolves them — the container materialises the array to count it — and that happened before the tenant context was set, which only occurred inside the delivery loop. A constructor injecting `ITenantContext` captured the wrong tenant and used it for the rest of the handler's life. The context is now set for the whole dispatch, before anything resolves anything. **An architecture rule was sweeping assemblies that will never hold its target.** `ModuleAssemblyShapes` lists `.Application`, `.Domain` and `.Infrastructure` for seven modules and omits `.Application.Contracts` — which is exactly where `add-integration-event` puts integration events, and those projects exist. `Integration_Event_TopicNames_FollowConvention` was therefore vacuous permanently rather than until the first module ships an event, and the omission narrowed three older rules alongside it. **A shipped rule was not in the catalogue.** `Modules_Do_Not_Inject_IEventBus_Directly` had one mention in the repository: its own method declaration. The catalogue is the single source of truth for canonical rule names, and an unregistered rule is how the six-spelling drift started. Corpus corrections, all of them drift this packet created: - `architecture/15` printed an `IEventBus` that the `InProcessEventBus` seventy lines below it did not implement, and neither matched the code. ADR-0014 Amendment 2 had been propagated everywhere; Amendment 3 was applied to one sketch and not to the interface, the mermaid diagram, the dispatcher call site, or `architecture/29`. - Three documents named `CacheKey.For`. The shipped API is `ForTenant`, and the type's own doc says why — Standards 20 contradicted itself two lines later, where its own table spelled it correctly. - `add-integration-event` was edited by this packet and then invalidated by a later commit in the same packet: its example no longer compiled (`Topic` became abstract), it counted four base members where there are five, it called `IGuidFactory.NewGuid` which does not exist, and it still argued for the organization behaviour the packet had reversed — the argument the canonical RLS policy inverts. - Two catalogue entries described the partition key as threaded through `IEventBus`, which is the second source Amendment 3 removed, and the base type as carrying three members. - ADR-0022's superseded key spelling was written inside its Decision outcome. It moves to `## Amendments` with a pointer left behind and the Status line naming it, which is the precedent ADR-0003 set. - `local-dev-setup` still told a new contributor that `make dev` brings up Valkey, Kafka, Vault, APISIX and Dapr. Also: seven of the fourteen commit subjects on this branch exceeded the 72-character limit CLAUDE.md sets, by one to twelve characters. Rewritten in place — the branch is unpushed, all fourteen trailers survive, and the tree is byte-identical to before. 707 tests green, 0 warnings under CI=true. Co-Authored-By: Claude Opus 5 (1M context) --- .claude/skills/add-integration-event/SKILL.md | 49 +++++++++++++------ .claude/skills/local-dev-setup/SKILL.md | 24 +++++++-- Makefile | 2 +- .../Messaging/InProcessEventBus.cs | 48 ++++++++++++------ .../Caching/CacheKey.cs | 20 ++++---- .../CrossCuttingFoundationTests.cs | 19 +++++++ .../Messaging/InProcessEventBusTests.cs | 42 ++++++++++++++++ docs/architecture/15-event-and-outbox.md | 13 +++-- docs/architecture/29-dapr-integration.md | 6 +-- .../32-tenant-customization-model.md | 2 +- docs/decisions/0014-adopt-dapr.md | 4 +- docs/decisions/0022-custom-domain-tls.md | 30 ++++++++---- docs/glossary.md | 2 +- docs/standards/20-infrastructure-stack.md | 2 +- .../21-architecture-tests-catalogue.md | 32 ++++++++++-- 15 files changed, 224 insertions(+), 71 deletions(-) diff --git a/.claude/skills/add-integration-event/SKILL.md b/.claude/skills/add-integration-event/SKILL.md index 4c87bd68..90ba22d7 100644 --- a/.claude/skills/add-integration-event/SKILL.md +++ b/.claude/skills/add-integration-event/SKILL.md @@ -63,21 +63,27 @@ public sealed record EnrollmentCreatedIntegrationEventV1 : IntegrationEventBase public Guid? CohortId { get; init; } public required string Source { get; init; } // "manual" | "billing" | "invitation" - // Not optional: IntegrationEventBase declares PartitionKey abstract, so this - // record does not compile without it. Ordering is guaranteed per partition - // key and nowhere else, and the aggregate this event is about is the - // ordering domain. Keying on TenantId instead would serialise the tenant's - // whole stream onto one partition — a real throughput cost, and one worth - // taking deliberately rather than by inheriting a default. + // Both abstract on IntegrationEventBase, so this record does not compile + // without them — deliberately, because each is a property of the event TYPE + // and a value with two sources is a value that can disagree with itself. + public override string Topic => "learnstack.enrollment.enrollment"; + + // Ordering is guaranteed per partition key and nowhere else, and the + // aggregate this event is about is the ordering domain. Keying on TenantId + // instead would serialise the tenant's whole stream onto one partition — a + // real throughput cost, and one worth taking deliberately rather than by + // inheriting a default. public override string PartitionKey => EnrollmentId.ToString(); } ``` `IntegrationEventBase` (`LearnStack.SharedKernel.Messaging`) supplies exactly -four members, and every one of them is mandatory: +five members, and every one of them is mandatory: - `EventId` — `required`; identity for consumer-side deduplication - `OccurredAt` — `required`; from `IClock`, never `DateTime.UtcNow` - `TenantId` — `required`; what the transport restores before your handler runs +- `Topic` — `abstract`; the channel, `learnstack.{module}.{aggregate}`, checked by + `Integration_Event_TopicNames_FollowConvention` - `PartitionKey` — `abstract`; the ordering domain, declared by each event It supplies **no** `OrganizationId`, `CorrelationId`, `CausationId` or @@ -99,7 +105,7 @@ In the producer's command handler (see ```csharp await outbox.EnqueueAsync(new EnrollmentCreatedIntegrationEventV1 { - EventId = guidFactory.NewGuid(), // IGuidFactory, not Guid.NewGuid + EventId = guidFactory.NewUuidV7(), // IGuidFactory, not Guid.NewGuid OccurredAt = clock.UtcNow, // IClock per Standards 02 § Time TenantId = tenantContext.TenantId, EnrollmentId = enrollment.Id.Value, @@ -134,7 +140,7 @@ learnstack.{module}.{aggregate} - `learnstack.classroom.session` - `learnstack.hub.entitlement` (Hub side) -The architecture test `Dapr_PubSub_TopicNames_FollowConvention` enforces the +The architecture test `Integration_Event_TopicNames_FollowConvention` enforces the pattern; deviation fails the build. ### Step 4: Consumer — handler + inbox guard @@ -178,12 +184,23 @@ Rules: `@event.TenantId` before your handler runs, and puts the publisher's own back afterwards. Read it through `ITenantContext` as usual; don't read it from anywhere else. -- **Organization is deliberately not restored.** A fact crossing a module - boundary is a tenant-level fact, and inventing an organization scope for the - consumer would narrow queries the producer never narrowed — the failure would - be silently missing rows rather than an error. If your consumer is genuinely - organization-scoped, carry the id as a field on your own event record and - filter on it explicitly. +- **Organization comes from the envelope, and it has to.** An earlier version of + this skill said it was deliberately not restored, reasoning that inventing an + organization scope would narrow queries the producer never narrowed. Under the + canonical Row Level Security policy the reasoning inverts: with + `app.organization_id` unset, an organization-scoped row evaluates + `false OR NULL OR NULL`, and a NULL policy result is false — so an absent + organization *hides* every organization-scoped row and `WITH CHECK` rejects + writing one. Widening is the `app.scope = 'tenant'` hatch, not an absent value. +- **The actor is `UserId.SystemActor`** unless the envelope names one. + `AuditableEntity.MarkCreated` refuses `default(UserId)`, so without it your + consumer cannot create an aggregate at all. +- **Your handler's constructor must do nothing but assign fields.** Each handler + gets its own DI scope and the container materialises the whole handler array + per scope, so a constructor runs several times per delivery. A constructor that + opens a connection, emits a metric or writes a log line will do it more than + once. A constructor that *throws* denies the event to every other module's + handler — construction happens before per-handler isolation can start. ### Step 5: Subscription registration @@ -214,7 +231,7 @@ Two tests minimum: - `LearnStack.Tests.Architecture` is green; specifically `Integration_Events_Inherit_From_IntegrationEventBase`, `Integration_Event_Handlers_Use_InboxGuard`, - `Dapr_PubSub_TopicNames_FollowConvention`. + `Integration_Event_TopicNames_FollowConvention`. - An integration test confirms the round-trip: handler publishes → outbox row created → outbox processor dispatches → consumer handles + writes business state + inbox row. diff --git a/.claude/skills/local-dev-setup/SKILL.md b/.claude/skills/local-dev-setup/SKILL.md index a857ac5e..96261043 100644 --- a/.claude/skills/local-dev-setup/SKILL.md +++ b/.claude/skills/local-dev-setup/SKILL.md @@ -1,8 +1,9 @@ --- name: local-dev-setup description: > - Bring up the LearnStack local stack — Postgres, Valkey, Vault, Kafka, Dapr - sidecar, APISIX, Keycloak (two realms), SeaweedFS, LiveKit OSS, Meilisearch — via + Bring up the LearnStack local stack — Postgres, Keycloak (two realms), + SeaweedFS, LiveKit OSS, Meilisearch, Mailpit, Coturn by default, and Valkey, + Kafka, Vault, APISIX and the two Dapr services behind the `gated` profile — via `docker-compose` plus the project's `make dev` orchestrator. USE FOR: first-time workstation setup, restoring a broken local environment, switching between `DeploymentMode` for testing. DO NOT USE FOR: production deployment (separate @@ -15,8 +16,12 @@ description: > ## Purpose Stand up a full LearnStack stack on a developer workstation so backend + frontend -can run against real Postgres / Valkey / Kafka / Vault / Keycloak / SeaweedFS / -LiveKit / Meilisearch / APISIX — the same components production uses +can run against real Postgres / Keycloak / SeaweedFS / LiveKit / Meilisearch — +the same components production uses. Valkey, Kafka, Vault, APISIX and Dapr sit +behind the `gated` profile per +[ADR-0035](../../../docs/decisions/0035-demand-gated-infrastructure.md): nothing +the backend runs today calls them, so `make dev` starts 7 services and +`make dev-gated` starts all 14 ([12-infrastructure.md § Local Infrastructure](../../../docs/standards/12-infrastructure.md), [20-infrastructure-stack.md](../../../docs/standards/20-infrastructure-stack.md)). @@ -82,7 +87,8 @@ pnpm install --frozen-lockfile ### Step 3: Bring up the stack ```bash -make dev # brings the containers up — and only the containers +make dev # the daily loop: 7 services, the ones the backend can call +make dev-gated # all 14, including Valkey, Kafka, Vault, APISIX and Dapr ``` `make dev` is `docker compose up -d` plus a status line. It does **not** start @@ -118,6 +124,14 @@ Three properties of that inventory matter while you are setting up: [Infrastructure Standards § Published ports](../../../docs/standards/12-infrastructure.md). - **Kafka and the Dapr placement service publish nothing.** They are reached over the compose network only; nothing on the host speaks to them directly. +- **Seven of the fourteen do not start by default.** Valkey, Kafka, kafka-ui, + Vault, APISIX and the two Dapr services sit behind the `gated` compose + profile: nothing the backend runs today calls any of them, and their adapters + land in Phase 11 against written triggers + ([ADR-0035](../../../docs/decisions/0035-demand-gated-infrastructure.md)). + `make down` and `make clean` stop the gated ones too — a profile-less teardown + silently leaves them running, which is why every teardown target carries + `--profile '*'`. - **Neither application host is a compose service.** `LearnStack.Api` runs on the workstation via `dotnet run` on the `ASPNETCORE_URLS` port in `.env.example` (5080), and `apps/web` runs via `pnpm dev` on 3000. diff --git a/Makefile b/Makefile index 70fbc9a5..ed368399 100644 --- a/Makefile +++ b/Makefile @@ -70,7 +70,7 @@ dev: .env ## Bring the local dev stack up (Postgres, Keycloak, SeaweedFS, …). @printf "\n$(CYAN)Stack up.$(RESET) Tail logs with: make logs\n" @printf "Kafka, Valkey, Vault, APISIX and Dapr are behind the '$(GATED_PROFILE)' profile — $(CYAN)make dev-gated$(RESET).\n" -.PHONY: down +.PHONY: dev-gated dev-gated: .env ## Bring the dev stack up INCLUDING the demand-gated services (Kafka, Valkey, Vault, APISIX, Dapr). COMPOSE_PROFILES=$(GATED_PROFILE) $(COMPOSE_DEV) up -d @printf "\n$(CYAN)Full stack up.$(RESET) Nothing the backend runs today calls these — see ADR-0035.\n" diff --git a/backend/src/LearnStack.Infrastructure/Messaging/InProcessEventBus.cs b/backend/src/LearnStack.Infrastructure/Messaging/InProcessEventBus.cs index 621d7574..170e1c82 100644 --- a/backend/src/LearnStack.Infrastructure/Messaging/InProcessEventBus.cs +++ b/backend/src/LearnStack.Infrastructure/Messaging/InProcessEventBus.cs @@ -84,8 +84,32 @@ private async Task DispatchAsync( // report success. var contract = typeof(IIntegrationEventHandler<>) .MakeGenericType(envelope.Event.GetType()); - var handle = contract.GetMethod(HandleMethodName)!; var context = EventTenantContext.FromEnvelope(envelope); + + // Set for the WHOLE dispatch, before anything resolves a handler. + // Counting resolves them too — the container materialises the array to + // count it — so with the context set only inside the delivery loop, every + // handler constructor ran under the publisher's tenant instead of the + // event's. A constructor that injects ITenantContext would have captured + // the wrong one and used it for the rest of the handler's life. + var previous = tenantAccessor.Current; + tenantAccessor.Current = context; + + try + { + await DispatchUnderContextAsync(contract, envelope, cancellationToken) + .ConfigureAwait(false); + } + finally + { + tenantAccessor.Current = previous; + } + } + + private async Task DispatchUnderContextAsync( + Type contract, IntegrationEventEnvelope envelope, CancellationToken cancellationToken) + { + var handle = contract.GetMethod(HandleMethodName)!; var count = HandlerCount(contract, envelope, out var constructionFailure); if (constructionFailure is not null) @@ -120,7 +144,7 @@ private async Task DispatchAsync( { try { - await DeliverAsync(contract, handle, index, envelope, context, cancellationToken) + await DeliverAsync(contract, handle, index, envelope, cancellationToken) .ConfigureAwait(false); } catch (Exception ex) @@ -160,7 +184,6 @@ private async Task DeliverAsync( MethodInfo handle, int index, IntegrationEventEnvelope envelope, - ITenantContext context, CancellationToken cancellationToken) { // One scope per HANDLER, not one per event. Under a broker each @@ -184,15 +207,12 @@ private async Task DeliverAsync( // metric or writes a log line will do it N+1 times per delivery. await using var scope = scopeFactory.CreateAsyncScope(); - // Restored into the flow the handler runs in AND into the scope it - // resolves ITenantContext from — the composition root binds the scoped - // context to this accessor. Setting only the ambient one left the scoped - // ITenantContext unresolved, so a handler injecting it threw and a - // handler sending a MediatR command was short-circuited by - // TenantContextBehavior before its business logic ran. - var previous = tenantAccessor.Current; - tenantAccessor.Current = context; - + // The context is already set for the whole dispatch, and the scope + // resolves ITenantContext from that same accessor — the composition root + // binds it that way. Setting only the ambient accessor and not binding + // the scoped one left a handler injecting ITenantContext unresolved, and + // one sending a MediatR command short-circuited by TenantContextBehavior + // before its business logic ran. try { var handler = scope.ServiceProvider.GetServices(contract).ElementAt(index)!; @@ -232,10 +252,6 @@ private async Task DeliverAsync( "An integration-event handler was cancelled by a token other than the publish token.", ex); } - finally - { - tenantAccessor.Current = previous; - } } private int HandlerCount( diff --git a/backend/src/LearnStack.SharedKernel/Caching/CacheKey.cs b/backend/src/LearnStack.SharedKernel/Caching/CacheKey.cs index 5462d658..364bce4e 100644 --- a/backend/src/LearnStack.SharedKernel/Caching/CacheKey.cs +++ b/backend/src/LearnStack.SharedKernel/Caching/CacheKey.cs @@ -116,16 +116,6 @@ public static void EnsureValid(string key) } } - /// - /// Whether the first segment is a tenant identifier or the platform sentinel. - /// - /// - /// Counting segments is not enough, and the first version of this guard did - /// only that: hub:entitlement:{tenant_id} has three segments and puts - /// the module first, so it passed a check whose own error message says the - /// tenant segment is mandatory. A guard that admits the shape it exists to - /// reject is worse than none — it makes the rule look enforced. - /// /// /// Whether a segment that looks like an identifier is a well-formed one. /// @@ -145,6 +135,16 @@ private static bool IsCanonicalIfIdentifier(string segment) => /// Whether a segment parses as an identifier at all. private static bool LooksLikeIdentifier(string segment) => Guid.TryParse(segment, out _); + /// + /// Whether the first segment is a tenant identifier or the platform sentinel. + /// + /// + /// Counting segments is not enough, and the first version of this guard did + /// only that: hub:entitlement:{tenant_id} has three segments and puts + /// the module first, so it passed a check whose own error message says the + /// tenant segment is mandatory. A guard that admits the shape it exists to + /// reject is worse than none — it makes the rule look enforced. + /// private static bool IsTenantSegment(string segment) => segment.Equals(PlatformTenant, StringComparison.Ordinal) || (Guid.TryParse(segment, out var id) diff --git a/backend/tests/LearnStack.Tests.Architecture/CrossCuttingFoundationTests.cs b/backend/tests/LearnStack.Tests.Architecture/CrossCuttingFoundationTests.cs index 45c165b4..0d090fe8 100644 --- a/backend/tests/LearnStack.Tests.Architecture/CrossCuttingFoundationTests.cs +++ b/backend/tests/LearnStack.Tests.Architecture/CrossCuttingFoundationTests.cs @@ -26,27 +26,46 @@ namespace LearnStack.Tests.Architecture; /// public sealed class CrossCuttingFoundationTests { + /// + /// Every module assembly a rule in this class sweeps. + /// + /// + /// Application.Contracts is in the list because that is where + /// integration events are declared — add-integration-event puts them + /// in <Producer>.Application.Contracts/IntegrationEvents/. Without + /// it, Integration_Event_TopicNames_FollowConvention would sweep only + /// assemblies that by convention never hold an event, so it would be vacuous + /// permanently rather than until the first module ships one — and the same + /// omission narrowed three older rules alongside it. + /// private static readonly string[] ModuleAssemblyShapes = [ "LearnStack.Modules.Tenancy.Application", + "LearnStack.Modules.Tenancy.Application.Contracts", "LearnStack.Modules.Tenancy.Domain", "LearnStack.Modules.Tenancy.Infrastructure", "LearnStack.Modules.Identity.Application", + "LearnStack.Modules.Identity.Application.Contracts", "LearnStack.Modules.Identity.Domain", "LearnStack.Modules.Identity.Infrastructure", "LearnStack.Modules.Customization.Application", + "LearnStack.Modules.Customization.Application.Contracts", "LearnStack.Modules.Customization.Domain", "LearnStack.Modules.Customization.Infrastructure", "LearnStack.Modules.Audit.Application", + "LearnStack.Modules.Audit.Application.Contracts", "LearnStack.Modules.Audit.Domain", "LearnStack.Modules.Audit.Infrastructure", "LearnStack.Modules.Content.Application", + "LearnStack.Modules.Content.Application.Contracts", "LearnStack.Modules.Content.Domain", "LearnStack.Modules.Content.Infrastructure", "LearnStack.Modules.Media.Application", + "LearnStack.Modules.Media.Application.Contracts", "LearnStack.Modules.Media.Domain", "LearnStack.Modules.Media.Infrastructure", "LearnStack.Modules.Education.Application", + "LearnStack.Modules.Education.Application.Contracts", "LearnStack.Modules.Education.Domain", "LearnStack.Modules.Education.Infrastructure", ]; diff --git a/backend/tests/LearnStack.Tests.Unit/Infrastructure/Messaging/InProcessEventBusTests.cs b/backend/tests/LearnStack.Tests.Unit/Infrastructure/Messaging/InProcessEventBusTests.cs index 3833f0de..c4c702e5 100644 --- a/backend/tests/LearnStack.Tests.Unit/Infrastructure/Messaging/InProcessEventBusTests.cs +++ b/backend/tests/LearnStack.Tests.Unit/Infrastructure/Messaging/InProcessEventBusTests.cs @@ -25,6 +25,7 @@ namespace LearnStack.Tests.Unit.Infrastructure.Messaging; public sealed class InProcessEventBusTests { private static readonly Guid Tenant = Guid.Parse("018f4d40-0000-7000-8000-00000000000a"); + private static readonly Guid OtherTenant = Guid.Parse("018f4d40-0000-7000-8000-00000000000b"); private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(10); private const string Trace = "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"; @@ -371,6 +372,28 @@ public async Task The_Publish_Token_Reaches_The_Handler() recorder.HandlerToken.Should().Be(cancelled.Token); } + [Fact] + public async Task A_Handler_Constructor_Sees_The_Events_Tenant() + { + // Counting the handlers resolves them — the container materialises the + // array to count it — and that happened before the tenant context was + // set, so every handler CONSTRUCTOR ran under the publisher's tenant + // instead of the event's. A constructor that injects ITenantContext + // captured the wrong one and used it for the rest of the handler's life. + var recorder = new Recorder(); + var (bus, accessor) = Build(recorder, services => + services.AddScoped, TenantCapturingHandler>()); + + accessor.Current = EventTenantContext.FromEnvelope( + Envelope(NewThing("publisher") with { TenantId = OtherTenant })); + + await bus.PublishAsync(Envelope(NewThing("a"))); + + recorder.Tenants.Should().NotBeEmpty(); + recorder.Tenants.Should().OnlyContain(t => t == Tenant, + "every construction and every handle runs under the event's tenant"); + } + [Fact] public async Task A_Handler_That_Cannot_Be_Built_Is_Reported_As_Such() { @@ -740,6 +763,25 @@ public Task HandleAsync(Thing @event, CancellationToken cancellationToken = defa } } + public sealed class TenantCapturingHandler : IIntegrationEventHandler + { + private readonly Recorder _recorder; + + public TenantCapturingHandler(Recorder recorder, ITenantContext context) + { + _recorder = recorder; + + // Captured at CONSTRUCTION, which is the point. + recorder.Tenants.Enqueue(context.TenantId); + } + + public Task HandleAsync(Thing @event, CancellationToken cancellationToken = default) + { + _recorder.Tenants.Enqueue(@event.TenantId); + return Task.CompletedTask; + } + } + public sealed class NullTaskHandler : IIntegrationEventHandler { public Task HandleAsync(Thing @event, CancellationToken cancellationToken = default) => diff --git a/docs/architecture/15-event-and-outbox.md b/docs/architecture/15-event-and-outbox.md index 46f17ce7..b17e5d86 100644 --- a/docs/architecture/15-event-and-outbox.md +++ b/docs/architecture/15-event-and-outbox.md @@ -69,7 +69,7 @@ sequenceDiagram loop Polling (every 200ms; configurable) Processor->>Outbox: CLAIM — UPDATE SET locked_by, locked_until
WHERE id IN (SELECT ... FOR UPDATE SKIP LOCKED)
RETURNING *; the claim survives the COMMIT - Processor->>Bus: PublishAsync(event, partitionKey)
topic learnstack.{module}.{aggregate} + Processor->>Bus: PublishAsync(envelope)
topic + correlation from the row Bus->>Transport: deliver Processor->>Outbox: UPDATE processed_at = now()
WHERE id = @id AND locked_by = @me end @@ -325,7 +325,14 @@ public sealed class OutboxProcessor : BackgroundService try { var eventInstance = JsonSerializer.Deserialize(msg.Payload, Type.GetType(msg.Type)!); - await eventBus.PublishAsync((IIntegrationEvent)eventInstance!, msg.PartitionKey, ct); + await eventBus.PublishAsync( + new IntegrationEventEnvelope( + (IIntegrationEvent)eventInstance!, + msg.CorrelationId, + msg.OrganizationId, + msg.CausationId, + msg.ActorUserId is { } actor ? UserId.From(actor) : null), + ct); msg.MarkProcessed(_clock.UtcNow, _processorId); // no-op if the lease was lost } catch (Exception ex) @@ -391,7 +398,7 @@ domain is: ```csharp public interface IEventBus { - Task PublishAsync(IIntegrationEvent @event, string partitionKey, CancellationToken ct = default); + Task PublishAsync(IntegrationEventEnvelope envelope, CancellationToken ct = default); } ``` diff --git a/docs/architecture/29-dapr-integration.md b/docs/architecture/29-dapr-integration.md index d6820d84..a0c7a422 100644 --- a/docs/architecture/29-dapr-integration.md +++ b/docs/architecture/29-dapr-integration.md @@ -209,7 +209,7 @@ Application code interacts with Dapr exclusively through three interfaces in // LearnStack.SharedKernel.Messaging public interface IEventBus { - Task PublishAsync(IIntegrationEvent @event, string partitionKey, CancellationToken ct = default); + Task PublishAsync(IntegrationEventEnvelope envelope, CancellationToken ct = default); } // LearnStack.SharedKernel.Caching @@ -240,8 +240,8 @@ public interface ISecretProvider handlers at the only call site that matters, and § 4 below for why a prefix removal cannot be honoured across instances. -**Keys are composed by the caller, not by the adapter.** `CacheKey.For(tenantId, module, -name)` — or `ForOrganization(...)` — produces the key and `CacheKey.EnsureValid` guards +**Keys are composed by the caller, not by the adapter.** `CacheKey.ForTenant(tenantId, +module, name)` — or `ForOrganization(...)` — produces the key and `CacheKey.EnsureValid` guards its shape, so an adapter that also prefixed would emit `{tenant}:{tenant}:{module}:{name}`. [Standards 20 § `ICacheService`](../standards/20-infrastructure-stack.md) fixes the shape; every implementation validates, none rewrites. diff --git a/docs/architecture/32-tenant-customization-model.md b/docs/architecture/32-tenant-customization-model.md index 36328c9b..75f70559 100644 --- a/docs/architecture/32-tenant-customization-model.md +++ b/docs/architecture/32-tenant-customization-model.md @@ -443,7 +443,7 @@ per tenant per month. That ratio is the whole design. | `TenantPageBlock` set | L1 + L2 | `{tenant_id}:customization:blocks-v{generation}` | same | Generation bump | | Compiled JSON Schema validator | L1 only, per pod | `(tenant_id, content_type_key, schema_version)` | Process lifetime, bounded LRU | Immutable — a schema version never changes | -These are composed with `CacheKey.For(tenantId, "customization", logicalName)`, and the +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 diff --git a/docs/decisions/0014-adopt-dapr.md b/docs/decisions/0014-adopt-dapr.md index cb96492c..dbadfd52 100644 --- a/docs/decisions/0014-adopt-dapr.md +++ b/docs/decisions/0014-adopt-dapr.md @@ -147,7 +147,9 @@ Adopt **Option A**: Dapr for pub/sub + state + secrets. ### Application access pattern **The `IEventBus` and `ICacheService` signatures below are superseded by** -[Amendment 2](#2026-08-24--amendment-2-two-port-signatures-corrected-before-first-use). +[Amendment 2](#2026-08-24--amendment-2-two-port-signatures-corrected-before-first-use) +and refined again by +[Amendment 3](#2026-08-25--amendment-3-the-publish-envelope-decided-before-the-first-call-site). They are left as written because an Accepted ADR's Decision section is not rewritten; what Packet 5 ships is the amended shape. `ISecretProvider` is unchanged and shipped in Packet 3. diff --git a/docs/decisions/0022-custom-domain-tls.md b/docs/decisions/0022-custom-domain-tls.md index ece1e494..4aa08c35 100644 --- a/docs/decisions/0022-custom-domain-tls.md +++ b/docs/decisions/0022-custom-domain-tls.md @@ -4,7 +4,8 @@ Accepted — **the certificate-delivery mechanism in Amendment 1 (steps 3 and 4) and in the 2026-05-19 Option B amendment is superseded by -[ADR-0034](0034-hub-contract-surface-invariant.md) (2026-08-08)** +[ADR-0034](0034-hub-contract-surface-invariant.md) (2026-08-08)**, and **the host +cache key's spelling is superseded by the 2026-08-26 amendment below** > **What ADR-0034 changed.** The lifecycle decided here is unchanged: Hub owns > custom-domain administration, DNS-01 and HTTP-01 challenges, Let's Encrypt issuance @@ -380,14 +381,8 @@ public sealed class TenantMiddleware `_hostToTenantResolver` is backed by `ICacheService` (Dapr State / Valkey); cache key `hub:host:{host}` invalidated on `CustomDomainActivatedEvent` / `CustomDomainRevokedEvent`. -> **Key spelling superseded (2026-08-24).** The cache key shipped as -> `platform:hub:host-map:{host}`: the tenant segment comes first and is mandatory, and -> a host lookup is the one family that legitimately carries the `platform` sentinel, -> because it answers "which tenant is this?" and so has no tenant to key it by. -> `CacheKey.EnsureValid` rejects the spelling above. The canonical shape lives in -> [Standards 20 § `ICacheService`](../standards/20-infrastructure-stack.md); this -> paragraph is left as written because an Accepted ADR is not rewritten, and nothing -> else in this decision depends on the spelling. +> Key spelling superseded — see +> [Amendment: the host cache key](#2026-08-26--amendment-the-host-cache-key-spelling). ### Public suffix list validation @@ -455,6 +450,23 @@ runbook live in [27-custom-domain-tls.md](../architecture/27-custom-domain-tls.m ## Amendments +### 2026-08-26 — Amendment: the host cache key spelling + +The decision is unchanged. Only the **spelling** of the cache key in the resolver sketch +above is superseded: it shipped as `platform:hub:host-map:{host}`. + +`CacheKey.EnsureValid` requires the tenant segment first and mandatory, so +`hub:host:{host}` is refused outright. A host lookup is the one key family that +legitimately carries the `platform` sentinel, and it is worth saying why: it answers +"which tenant is this?", so by construction there is no tenant to key it by. Every other +family knows its tenant, and a `platform` sentinel there would be a bug wearing the +sentinel's clothes. + +The canonical shape lives in +[Standards 20 § `ICacheService`](../standards/20-infrastructure-stack.md), which is the +one document that owns it. Nothing else in this decision depends on the spelling. + + ### 2026-05-19 — Cert-and-route propagation is event-driven; Hub does not write LearnStack's K8s state The Decision and the worked example show Hub "writing to diff --git a/docs/glossary.md b/docs/glossary.md index 836035bf..0b7ebff8 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -279,7 +279,7 @@ This glossary defines LearnStack-specific terms. When a term is ambiguous across | **One-Way Door** | A decision that gets more expensive with every week of delay, tested by the question: *if I add this six months from now, will I have to touch code that is already written?* **Yes → one-way door; ship it now** (tenant and organization isolation, the `outbox_messages` table and its ownership, strongly-typed identifiers, the localization schema — each touches every query, migration, or job payload ever written). **No → additive; ship the port now and the adapter on demand.** The test is mechanical, not a matter of taste: tenant isolation's cost grows with the codebase, a Dapr adapter's cost does not. A corollary rule: **a deployment mode or customer segment without a signed contract cannot be the deciding factor in a technical choice** — it may break a tie between otherwise-equal options, nothing more. Per [ADR-0035](decisions/0035-demand-gated-infrastructure.md) and [Engineering Principles](standards/00-principles.md). | | **Dapr Building Blocks** | The three Dapr abstractions LearnStack uses **when it uses Dapr**: pub/sub (Kafka), state (Valkey), secrets (Vault) per [ADR-0014](decisions/0014-adopt-dapr.md). Service invocation, workflow, bindings, and actors are out of scope. Demand-gated to [Phase 11](roadmap/phase-11-production-hardening.md); ADR-0014 decides *what*, [ADR-0035](decisions/0035-demand-gated-infrastructure.md) decides *when*. | | **`IEventBus`** | Interface for publishing integration events, taking an `IntegrationEventEnvelope`. `InProcessEventBus` is the only registered implementation until the Dapr adapter's trigger fires — and it is a **first-class transport, not a stub**: same `IIntegrationEventHandler`, same `IInboxGuard`, same tenant-context restoration, same per-partition-key ordering as the durable path. The `OutboxProcessor` is the only sanctioned caller. | -| **`IntegrationEventEnvelope`** | One integration event plus the dispatch metadata the outbox row carries and the event does not: `CorrelationId`, `OrganizationId`, `CausationId`, `ActorUserId`. Its `Topic` and `PartitionKey` are the event's own — both are properties of the event *type* rather than of one delivery. Its `PartitionKey` is the event's own, so the ordering domain has exactly one source ([ADR-0014 Amendment 3](decisions/0014-adopt-dapr.md)). Metadata describes the *delivery*; the event describes the *fact*. | +| **`IntegrationEventEnvelope`** | One integration event plus the dispatch metadata the outbox row carries and the event does not: `CorrelationId`, `OrganizationId`, `CausationId`, `ActorUserId`. Its `Topic` and `PartitionKey` are the event's own — both are properties of the event *type* rather than of one delivery, so neither can hold a second answer ([ADR-0014 Amendment 3](decisions/0014-adopt-dapr.md)). Metadata describes the *delivery*; the event describes the *fact*. | | **`IPartitionSerializer`** | Runs work sequentially within one partition key and concurrently across different ones — the in-process stand-in for what a broker gives you by assigning a partition to one consumer. It exists so the development transport carries the same ordering guarantee as the durable path rather than a weaker one. Queuing work for the key you are already inside is refused rather than deadlocked: the caller that does it is publishing from inside a handler, which [Standards 20](standards/20-infrastructure-stack.md) forbids. | | **`EventTenantContext`** | The `ITenantContext` a consumer runs under, rebuilt from the envelope by the transport before the handler runs. A consumer executes outside the request that produced the fact, so there is no ambient context to inherit — which is why the tenant travels on the event and the rest on the envelope. Restoring it is what makes the query filters and the RLS policies evaluate against the right scope. | | **`UserId.SystemActor`** | The fixed, non-empty `UserId` that integration-event consumers, background jobs and other non-request executions write state as — what [Audit Coverage](standards/18-audit-coverage.md) means by an actor of type `system`. Fixed rather than generated because it is a foreign key: the Tenancy migration seeds the matching `users` row so `created_by` resolves. `AuditableEntity.MarkCreated` refuses `default(UserId)` and `Guid.Empty` alike, so without it no consumer could create an aggregate at all. | diff --git a/docs/standards/20-infrastructure-stack.md b/docs/standards/20-infrastructure-stack.md index 8929e9fb..897f759e 100644 --- a/docs/standards/20-infrastructure-stack.md +++ b/docs/standards/20-infrastructure-stack.md @@ -172,7 +172,7 @@ ADR-0014 non-goals; do not introduce them without a new ADR. `{tenant_id}:{organization_id}:{module}:{logical-name}` when the value is scoped to one organization. The `tenant_id` segment comes **first** and is mandatory even when a value is platform-wide — use the sentinel `"platform"` tenant id rather than - omitting it. Compose with `CacheKey.For` / `CacheKey.ForOrganization` / + omitting it. Compose with `CacheKey.ForTenant` / `CacheKey.ForOrganization` / `CacheKey.ForPlatform`; every `ICacheService` implementation calls `CacheKey.EnsureValid`, and none re-prefixes. There is no query filter and no RLS policy in front of a dictionary, so the key is the entire isolation boundary — diff --git a/docs/standards/21-architecture-tests-catalogue.md b/docs/standards/21-architecture-tests-catalogue.md index b6bc12c4..8c6026b4 100644 --- a/docs/standards/21-architecture-tests-catalogue.md +++ b/docs/standards/21-architecture-tests-catalogue.md @@ -1096,7 +1096,10 @@ Introduced by [Phase 02b](../roadmap/phase-02b-events-auth.md). #### `Integration_Events_Inherit_From_IntegrationEventBase` - **Asserts:** every type implementing `IIntegrationEvent` extends `IntegrationEventBase`, - which carries `EventId`, `OccurredAt` and `TenantId`, and is a JSON-serialisable record. + which carries `EventId`, `OccurredAt` and `TenantId` as `required` members and declares + `Topic` and `PartitionKey` abstract, and is a JSON-serialisable record. The payload is + written by `ToPayloadJson()`, which serialises by runtime type — serializing through the + interface silently drops every member the concrete event adds. - **Source:** [15-event-and-outbox.md § Architecture tests](../architecture/15-event-and-outbox.md); [ADR-0006](../decisions/0006-events-and-outbox.md). - **Type:** xUnit + reflection over module assemblies. **Kind:** structural. @@ -1106,9 +1109,12 @@ Introduced by [Phase 02b](../roadmap/phase-02b-events-auth.md). #### `Integration_Event_Declares_PartitionKey` - **Asserts:** every `IIntegrationEvent` resolves a non-null partition key. `PartitionKey` - on `IntegrationEventBase` is threaded through `IEventBus` and honoured by - `InProcessEventBus`, which serialises dispatch per key — concurrent across keys, - sequential within one. + is abstract on `IntegrationEventBase`, so the compiler already refuses an event that + omits it; the residual assertion is that the value is non-null and non-blank at + runtime. `IntegrationEventEnvelope` reads it off the event — it is deliberately **not** + threaded through `IEventBus` as a second parameter, which is the source of drift + [ADR-0014 Amendment 3](../decisions/0014-adopt-dapr.md) removed. `InProcessEventBus` + serialises dispatch per key: concurrent across keys, sequential within one. - **Source:** [Phase 02b](../roadmap/phase-02b-events-auth.md); [15-event-and-outbox.md](../architecture/15-event-and-outbox.md). - **Type:** xUnit + reflection over module assemblies. **Kind:** structural. @@ -1160,6 +1166,24 @@ Deferring it left the convention unasserted against the transport that is actual registered, which is the shape of gap this catalogue exists to close. It is therefore **split in two**, and the transport-independent half is not deferred: +#### `Modules_Do_Not_Inject_IEventBus_Directly` + +- **Asserts:** no type in a module assembly takes `IEventBus` as a constructor parameter + or holds one in a field. The only sanctioned publisher is the `OutboxProcessor`; + modules write to the outbox. +- **Source:** [20-infrastructure-stack.md § `IEventBus`](20-infrastructure-stack.md); + [ADR-0010](../decisions/0010-cross-module-communication.md). +- **Type:** xUnit + reflection over module assemblies. **Kind:** structural. +- **Status:** **Implemented** (`CrossCuttingFoundationTests`). A module holding the bus + gets a synchronous cross-module call with no durability and no transactional + atomicity — a fifth cross-module mechanism in everything but name, and one that looks + like it works in every development test because the in-process transport delivers + inline. A namespace ban cannot express it: modules legitimately depend on + `LearnStack.SharedKernel.Messaging` for `IIntegrationEvent` and + `IIntegrationEventHandler`. The module sweep is vacuous until a module ships code, + so the checker is pointed at a deliberate offender in the test assembly first. +- **Phase:** 02a Packet 5. + #### `Integration_Event_TopicNames_FollowConvention` - **Asserts:** every declared integration-event type resolves a topic matching From 411dbfaf49e43cd54449397314ca20e5fb0a2240 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Wed, 26 Aug 2026 21:36:03 +0300 Subject: [PATCH 16/21] docs(roadmap): close Packet 5 with the record of what it got wrong MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The delivery record, kept separate from the ones above it for the reason they are separate from each other: each is scoped to its own packets and is not rewritten. Most of its entries are defects the packet introduced and then found in its own review rounds, which is the only reason they are in a record rather than in production. Several are defects introduced by the fix for an earlier one — the bound that crashed the writers it protects, the single-flight cleanup bound to the wrong event twice, and a reentrancy guard that broke the guarantee it existed to preserve. The most repeated lesson gets its own paragraph: three tests were found agreeing with the code instead of constraining it. A bound test that advanced the clock one second per write — the one schedule under which the broken bound held. A stampede test built with `Select(...).ToArray()`, which LINQ evaluates sequentially, so eight "concurrent" callers never raced. A cross-key rendezvous sharing one semaphore, where each side consumed its own release and waited for nothing. A fourth kind appeared in the mutation harness itself, where a mutant that failed to compile looked like a passing suite. Packet 5 is marked ✅ in the Status block and indexed from the reading note at the top; CLAUDE.md's state line names it among the shipped packets. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 4 +- docs/roadmap/phase-02a-kernel-tenancy.md | 156 ++++++++++++++++++++++- 2 files changed, 156 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c8cec9a2..8ef9c953 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,8 +36,8 @@ repository holds only LearnStack's side of the boundary, in **Phase 01 complete. [Phase 02a](docs/roadmap/phase-02a-kernel-tenancy.md) in progress — -packets 0–3, 3b and 4 shipped; packets 4–10 were re-scoped on 2026-08-08 after -a four-report audit of the corpus.** +packets 0–3, 3b, 4 and 5 shipped; packets 4–10 were re-scoped on 2026-08-08 +after a four-report audit of the corpus.** **Phase 01** shipped the .NET 10 solution scaffold under `backend/` (core + 7 modules × 4 projects + 4 test projects including the diff --git a/docs/roadmap/phase-02a-kernel-tenancy.md b/docs/roadmap/phase-02a-kernel-tenancy.md index d9477019..5c630694 100644 --- a/docs/roadmap/phase-02a-kernel-tenancy.md +++ b/docs/roadmap/phase-02a-kernel-tenancy.md @@ -28,7 +28,8 @@ > [`## Delivery Record (Packets 0–3)`](#delivery-record-packets-03) and are not > rewritten. Packet 3b has its own record in > [`## Delivery Record (Packet 3b)`](#delivery-record-packet-3b), and Packet 4 in -> [`## Delivery Record (Packet 4)`](#delivery-record-packet-4) — each kept separate +> [`## Delivery Record (Packet 4)`](#delivery-record-packet-4), and Packet 5 in +> [`## Delivery Record (Packet 5)`](#delivery-record-packet-5) — each kept separate > because the frozen one is scoped to packets 0–3.** ## Goal @@ -304,7 +305,7 @@ no audit trail; the ADR's staging table says what each packet owes. SDK generation ships as a wired-but-empty scaffold — there are no endpoints to generate from until [Phase 02d](phase-02d-walking-skeleton.md). -**Packet 5 — Foundation ports and default implementations ⏳** +**Packet 5 — Foundation ports and default implementations ✅** ([delivery record](#delivery-record-packet-5)) `IEventBus` / `ICacheService` / `ISecretProvider` in `LearnStack.SharedKernel`, with `InProcessEventBus` / `InMemoryCacheService` in `LearnStack.Infrastructure` — `ISecretProvider` and `ConfigurationSecretProvider` @@ -1840,3 +1841,154 @@ only reason they are in a record rather than in production. > the multipart and file-upload rows need an endpoint and wait for > [Phase 04](phase-04-cms-media-pages.md). Each is written down where the limit is > published, with the phase that owns it. + +## Delivery Record (Packet 5) + +Kept separate from the records above for the reason they are separate from each +other: each is scoped to its own packets and is not rewritten. This one records +what Packet 5 shipped, and — like Packet 4's — what its own plan had wrong. Most +of the entries below are defects the packet introduced and then found in its own +review rounds, which is the only reason they are in a record rather than in +production. Several of them are defects introduced by the *fix* for an earlier +one. + +> **Packet 5 — Foundation ports and default implementations ✅** +> +> **The ports.** `ICacheService` with `InMemoryCacheService`, `IEventBus` with +> `InProcessEventBus`, and — from Packet 3 — `ISecretProvider` with +> `ConfigurationSecretProvider`, as the only registered implementations. Each is +> selected at a single composition-root site so Phase 11's adapter is one line +> rather than a search. `IHostToTenantResolver` and `IEntitlementProvider` are +> **not** here: they need tenancy schema, and belong to Packets 7 and 9. Two +> sections of this document disagreed about that, because one is phase scope and +> one is packet scope. +> +> **The cache key is the isolation boundary, and that is not a figure of speech.** +> There is no query filter and no RLS policy in front of a dictionary, so +> `CacheKey` composes and `EnsureValid` guards: the tenant segment first and +> mandatory, `platform` for a platform-wide value, `ForOrganization` for a scope +> ADR-0017 makes real, and every segment that parses as an identifier required to +> be the canonical rendering of a non-empty one. +> +> The guard shipped **validating arity rather than tenancy** — +> `hub:entitlement:{id}` has three non-empty segments and puts the module first, +> so it passed a check whose own error message says the tenant segment is +> mandatory. A guard that admits the shape it exists to reject is worse than +> none, because it makes the rule look enforced. Standards 20's cheat sheet +> listed five key families and every one of them led with the module, +> contradicting the rule stated a few lines above it; two of the five could not +> be built by any factory at all, so the two the standard singles out — including +> the host lookup, on the anonymous page-load path — would have been hand-built +> past the only place `Guid.Empty`, non-canonical rendering and separator +> injection are checked. +> +> **The bound was not a bound, and then it crashed the writers it protects.** +> Trimming lived inside the sweep, the sweep is throttled by clock time, and a +> burst does not advance the clock: measured, 60,000 entries against a ceiling of +> 10,000. Moving it to every write that adds a key fixed the count and introduced +> something worse — `OrderBy` over a live `ConcurrentDictionary` buffers it +> through `CopyTo` after reading `Count`, and those two steps are not atomic. Two +> concurrent writers failed 4.1% of ordinary writes; four failed 15.5%. A +> component whose contract is that it may no-op at any time was instead failing +> the caller's request, and in `GetOrSetAsync` the throw lands after the factory +> has already run. An atomic snapshot plus a low-water mark fixed both, and took +> the steady-state cost from 0.26 ms and 281 KB per write to 0.0072 ms and 1.2 KB. +> +> **The single-flight cleanup was bound to the wrong event twice.** Unregistering +> when a *caller* exits meant a joiner that cancelled removed the shared +> registration while the factory still ran, so the next arrival started a second +> concurrent run — the stampede the method exists to prevent, reintroduced by its +> own cleanup. Unregistering on the *factory's* completion instead meant the +> flight was gone by the time the caller stored, so nothing could mark it +> superseded. It retires when its last caller is done. A per-key version counter +> written along the way lived in a dictionary nothing swept: 50,000 entries +> against the cache's own ceiling of 10,000, an unbounded structure behind a +> bounded one. +> +> **The event bus carries four obligations, and each has a test that fails when +> the code implementing it is removed.** The same `IIntegrationEventHandler` +> contract, the same `IInboxGuard` seam, the same tenant-context restoration, the +> same per-partition ordering. `PublishAsync` is not generic and handlers resolve +> by runtime type, because the outbox publishes through the base interface and a +> generic parameter would resolve `IIntegrationEventHandler`, +> which nothing implements — the publish would reach zero handlers and report +> success. +> +> **The reentrancy fix broke the guarantee it protected.** A handler publishing +> about its own aggregate deadlocked and wedged the partition permanently. Running +> the reentrant call inline was worse: an `AsyncLocal` flows into every task +> started inside a unit, so a fire-and-forget spawn inherited the marker and ran +> *concurrently* with the unit it should have queued behind. The detection is the +> same either way; only the action differs, and that asymmetry is the point — a +> false positive that throws is diagnosable, one that runs inline is a silent +> concurrency violation. Comparing against the innermost key alone then still +> missed `A → B → A`, the same cycle one hop longer, five times out of five. +> +> **The envelope, decided before the first call site.** The outbox row requires +> `topic` and `correlation_id` as `NOT NULL` and carries organization, causation +> and actor; none of them belong on the event, and the two-parameter signature had +> nowhere to put them, so correlation was read from whatever context was ambient +> at dispatch — `null` inside the background service the processor is. The +> partition key had two sources and the transport read the one the event did not +> declare, while every test published an event whose declared key disagreed with +> the one passed. And no consumer could write state at all: +> `AuditableEntity.MarkCreated` refuses `default(UserId)`, and the consumer +> context supplied neither an actor nor an organization — under the canonical RLS +> policy an absent organization *hides* every organization-scoped row rather than +> widening to all of them, which is the opposite of what the code claimed. See +> [ADR-0014 Amendment 3](../decisions/0014-adopt-dapr.md). +> +> **A trap the non-generic port creates, closed with it.** With `IIntegrationEvent` +> as the declared type at every dispatch boundary, +> `JsonSerializer.Serialize(@event)` emits four members and silently drops +> everything the concrete event added — valid JSON, no exception, committed inside +> the transaction that reported success, and failing to deserialize on every retry +> until it dead-letters. `ToPayloadJson()` serialises by runtime type. +> +> **Seven services left the daily loop.** Kafka, Valkey, Vault, APISIX and the two +> Dapr containers sit behind a compose profile per ADR-0035; `make dev` starts 7 +> instead of 14. Two failure modes decided the shape and both were measured: a +> profile-less `down` silently leaves profiled containers running, and +> `--remove-orphans` does not help; and a default service depending on a gated one +> is not a warning but a whole-project error, so `config`, `up`, `down` and `ps` +> all refuse. Nothing was checking the second — CI did not validate the compose +> files at all. It does now, across both profile projections and both overlays. +> +> **`DeploymentMode` branching is booted, not described.** Existing coverage +> stopped at reading the mode. The first version of the new test passed while +> proving nothing: it set `Deployment:Mode` through `ConfigureAppConfiguration`, +> which under minimal hosting runs *after* the composition root has read +> `builder.Configuration`, so `appsettings.Development.json` won and the SaaS case +> silently exercised the Development branch. +> +> **Three tests were found agreeing with the code instead of constraining it,** +> and that is the packet's most repeated lesson. A bound test that advanced the +> clock one second per write — the one schedule under which the broken bound held. +> A stampede test built with `Select(...).ToArray()`, which LINQ evaluates +> sequentially, so eight "concurrent" callers never raced and +> `LazyThreadSafetyMode.None` survived it. A cross-key rendezvous sharing one +> semaphore, where each side consumed its own release and waited for nothing, so +> collapsing every partition onto a single chain passed. A fourth kind appeared in +> the mutation harness itself: a mutant that failed to compile looked like a +> passing suite, because the check grepped only for test failures. +> +> **What is enforced, and where.** `Integration_Event_TopicNames_FollowConvention` +> is implemented — which required making `Topic` a property of the event type +> rather than a producer-supplied string, since the rule reads the declarations. +> `Modules_Do_Not_Inject_IEventBus_Directly` closes the door on a fifth +> cross-module mechanism. `Assertion_Budget_Does_Not_Depend_On_ICacheService` +> became the dependency check the catalogue promised once the type existed. Both +> new rules sweep `.Application.Contracts` as well, because that is where +> integration events are declared and the existing sweep omitted it — which would +> have made them vacuous permanently rather than until the first module ships one. +> +> **Outside the packet's own scope, found by working in it.** The pre-commit hook +> never applied `.leakwatchignore`: leakwatch resolves it relative to the scan +> target, and the hook scans file by file, so seven paths were unscannable locally +> while CI was green — and the hook's own remediation text told the developer to +> extend a file that could not have helped. The first fix layered the ignore file +> onto the repository's own stack, which broke it in both directions: a +> `.gitignore` negation outranks `core.excludesFile`, so two of the fourteen paths +> were still blocked, and patterns from `.gitignore` and a developer's +> `.git/info/exclude` were honoured as leakwatch's. It is evaluated in isolation +> now. From 3c18f885b873dda6c4bbba8c1e9ab5593d7db0b4 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Wed, 26 Aug 2026 23:57:43 +0300 Subject: [PATCH 17/21] fix(kernel): harden cross-cutting foundation Repair cache isolation and concurrency guarantees, make event delivery metadata and tenant context deterministic, and strengthen the architecture and hook guards that preserve those contracts. Align active architecture, standards, roadmap, local-development guidance, and authoring skills under the superseding cross-cutting contract ADR. ADR: 0038 Co-Authored-By: Codex Opus 4.7 (1M context) --- .claude/skills/add-integration-event/SKILL.md | 81 +- .claude/skills/code-review/SKILL.md | 2 +- .claude/skills/local-dev-setup/SKILL.md | 80 +- .claude/skills/start-task/SKILL.md | 2 +- .claude/skills/wire-dapr-pubsub/SKILL.md | 189 +++-- .githooks/pre-commit | 24 +- Makefile | 6 +- README.md | 2 +- .../CrossCuttingFoundationExtensions.cs | 26 +- .../Pipeline/OutboxFlushBehavior.cs | 2 +- .../Caching/InMemoryCacheService.cs | 703 ++++++++++-------- .../Idempotency/InMemoryIdempotencyStore.cs | 2 +- .../Messaging/InProcessEventBus.cs | 333 ++++----- .../IntegrationEventHandlerRegistry.cs | 122 +++ .../Messaging/IntegrationEventSubscription.cs | 8 + .../Properties/AssemblyInfo.cs | 3 + .../Caching/CacheKey.cs | 78 +- .../Caching/CacheOptions.cs | 2 +- .../Caching/ICacheService.cs | 8 +- .../Identifiers/UserId.cs | 7 +- .../Messaging/IEventBus.cs | 4 +- .../IOrganizationScopedIntegrationEvent.cs | 11 + .../Messaging/IntegrationEventBase.cs | 25 +- .../Messaging/IntegrationEventEnvelope.cs | 118 ++- .../Tenancy/EventTenantContext.cs | 30 +- .../Tenancy/ITenantContext.cs | 12 +- .../CrossCuttingFoundationTests.cs | 84 ++- .../CrossCuttingFoundationHttpTests.cs | 11 +- .../DeploymentModeCompositionTests.cs | 39 + .../Caching/InMemoryCacheServiceTests.cs | 220 +++++- .../Messaging/InProcessEventBusTests.cs | 175 ++++- .../SharedKernel/Caching/CacheKeyTests.cs | 36 +- .../IntegrationEventContractTests.cs | 125 +++- docs/architecture/01-platform-vision.md | 5 +- docs/architecture/03-module-boundaries.md | 4 +- .../architecture/04-technical-architecture.md | 6 +- docs/architecture/05-mvp-scope.md | 7 +- docs/architecture/06-extension-model.md | 9 +- docs/architecture/09-tenant-isolation.md | 3 +- .../architecture/10-cross-module-contracts.md | 9 +- docs/architecture/15-event-and-outbox.md | 159 ++-- docs/architecture/21-feature-flags.md | 26 +- docs/architecture/24-learnstack-hub.md | 17 +- docs/architecture/29-dapr-integration.md | 226 ++---- .../32-tenant-customization-model.md | 2 +- .../architecture/33-cross-cutting-concerns.md | 4 +- docs/decisions/0014-adopt-dapr.md | 163 +--- docs/decisions/0022-custom-domain-tls.md | 40 +- ...-cross-cutting-port-and-event-contracts.md | 171 +++++ docs/decisions/README.md | 8 +- docs/glossary.md | 12 +- docs/roadmap/phase-02a-kernel-tenancy.md | 3 +- docs/roadmap/phase-02b-events-auth.md | 39 +- docs/standards/01-architecture-standards.md | 2 +- docs/standards/05-database.md | 2 +- docs/standards/10-observability.md | 15 +- docs/standards/11-security.md | 2 +- docs/standards/12-infrastructure.md | 10 +- docs/standards/20-infrastructure-stack.md | 47 +- .../21-architecture-tests-catalogue.md | 16 +- infra/compose/README.md | 12 +- infra/compose/dev.yml | 6 +- infra/dapr/README.md | 20 +- infra/dapr/components/pubsub-kafka.yaml | 4 +- infra/dapr/components/statestore-redis.yaml | 2 +- 65 files changed, 2197 insertions(+), 1424 deletions(-) create mode 100644 backend/src/LearnStack.Infrastructure/Messaging/IntegrationEventHandlerRegistry.cs create mode 100644 backend/src/LearnStack.Infrastructure/Messaging/IntegrationEventSubscription.cs create mode 100644 backend/src/LearnStack.Infrastructure/Properties/AssemblyInfo.cs create mode 100644 backend/src/LearnStack.SharedKernel/Messaging/IOrganizationScopedIntegrationEvent.cs create mode 100644 docs/decisions/0038-cross-cutting-port-and-event-contracts.md diff --git a/.claude/skills/add-integration-event/SKILL.md b/.claude/skills/add-integration-event/SKILL.md index 90ba22d7..1b712579 100644 --- a/.claude/skills/add-integration-event/SKILL.md +++ b/.claude/skills/add-integration-event/SKILL.md @@ -2,7 +2,8 @@ name: add-integration-event description: > Publish or consume an integration event across modules using the outbox pattern, - `IEventBus` (Dapr pub/sub → Kafka in non-dev), and per-module inbox idempotency. + `IEventBus` (`InProcessEventBus` today; Dapr pub/sub → Kafka on the Phase 11 + trigger), and per-module inbox idempotency. USE FOR: declaring a new versioned `IntegrationEventVN`, wiring a module to publish it via `IOutbox.EnqueueAsync`, and wiring another module to consume it via `IIntegrationEventHandler` + `IInboxGuard`. DO NOT USE FOR: @@ -21,14 +22,14 @@ consumers, per Amendment 1 (Dapr pub/sub dispatch), [ADR-0010 Cross-Module Communication](../../../docs/decisions/0010-cross-module-communication.md), and [15-event-and-outbox.md](../../../docs/architecture/15-event-and-outbox.md). +The binding port and envelope contract is +[ADR-0038](../../../docs/decisions/0038-cross-cutting-port-and-event-contracts.md). ## When to use - Module A's state change must influence module B (Billing → Enrollment, Classroom → Analytics, Identity → Audit). - A read-model projection elsewhere needs to refresh. -- The Hub publishes a `learnstack.hub.entitlement` (or similar) event that - LearnStack core needs to react to. ## When not to use @@ -45,8 +46,8 @@ and [15-event-and-outbox.md](../../../docs/architecture/15-event-and-outbox.md). | Event name | Yes | `IntegrationEventV`, PascalCase + version suffix. | | Producing module | Yes | Owns the aggregate the event describes. | | Consuming module(s) | Yes | At least one; can be many. | -| Topic | Derived | `learnstack.{module}.{aggregate}` (e.g. `learnstack.enrollment.enrollment`). | -| Schema fields | Yes | `IntegrationEventBase` supplies `EventId`, `OccurredAt`, `TenantId` (all `required`) and demands a `PartitionKey` override. Everything else is yours to declare. | +| Topic | Declared | The event's `Topic` override, normally `learnstack.{module}.{aggregate}`. | +| Schema fields | Yes | `IntegrationEventBase` supplies `EventId`, `OccurredAt`, `TenantId` (all `required`) and demands `Topic` and `PartitionKey` overrides. Everything else is yours to declare. | ## Workflow @@ -87,12 +88,11 @@ five members, and every one of them is mandatory: - `PartitionKey` — `abstract`; the ordering domain, declared by each event It supplies **no** `OrganizationId`, `CorrelationId`, `CausationId` or -`ActorUserId`. Correlation travels with the ambient context rather than on the -payload, and is asserted on the outbox row by -`Outbox_Row_Carries_Correlation_Context`. If your consumer genuinely needs the -organization or the acting user, declare them on your own record — but read the -note under Step 4 first, because the consumer's restored context will not carry -them. +`ActorUserId`: those describe the delivery and travel on +`IntegrationEventEnvelope`, copied from the outbox row. An organization-owned +event implements `IOrganizationScopedIntegrationEvent`; envelope construction +then rejects a missing or empty organization id. Do not duplicate delivery +metadata on the event payload. Versioning: a breaking change ships a **new** record (`V2`). The `V1` stays supported during the migration window. @@ -124,9 +124,9 @@ Rules: transaction. - `SaveChangesAsync` commits the aggregate change and the outbox row together. -### Step 3: Topic mapping +### Step 3: Topic declaration -The outbox processor derives the topic from the event type using: +The concrete event declares the topic once using: ```text learnstack.{module}.{aggregate} @@ -140,8 +140,10 @@ learnstack.{module}.{aggregate} - `learnstack.classroom.session` - `learnstack.hub.entitlement` (Hub side) -The architecture test `Integration_Event_TopicNames_FollowConvention` enforces the -pattern; deviation fails the build. +The outbox copies that declared value to persistence and the envelope forwards it; +neither derives a second answer. The architecture test +`Integration_Event_TopicNames_FollowConvention` enforces the pattern; deviation +fails the build. ### Step 4: Consumer — handler + inbox guard @@ -192,29 +194,31 @@ Rules: `false OR NULL OR NULL`, and a NULL policy result is false — so an absent organization *hides* every organization-scoped row and `WITH CHECK` rejects writing one. Widening is the `app.scope = 'tenant'` hatch, not an absent value. -- **The actor is `UserId.SystemActor`** unless the envelope names one. - `AuditableEntity.MarkCreated` refuses `default(UserId)`, so without it your - consumer cannot create an aggregate at all. -- **Your handler's constructor must do nothing but assign fields.** Each handler - gets its own DI scope and the container materialises the whole handler array - per scope, so a constructor runs several times per delivery. A constructor that - opens a connection, emits a metric or writes a log line will do it more than - once. A constructor that *throws* denies the event to every other module's - handler — construction happens before per-handler isolation can start. +- **The effective actor is always `UserId.SystemActor`.** A human named by the + envelope remains separate causal audit metadata (`CausalActorUserId`); an + asynchronous consumer never impersonates that human. +- **Your handler's constructor must do nothing but assign fields.** Each + subscription gets its own async DI scope and exactly one handler construction. + Constructor, handler and disposal failures are contained to that subscription, + so healthy siblings still run. ### Step 5: Subscription registration -The Dapr subscription is declared in the consumer module's startup: +Today, expose the consumer assembly to the composition root so the +construction-free registry discovers and registers its concrete handlers: ```csharp -services.AddDaprSubscription( - topic: "learnstack.enrollment.enrollment", - pubsubName: "pubsub"); +builder.AddLearnStackCrossCuttingFoundation( + deploymentMode, + typeof(CreateAuditEntryOnEnrollmentCreated).Assembly); ``` -In Development mode (`DeploymentMode = Development`) the `InProcessEventBus` -replaces Dapr; the same `IIntegrationEventHandler` is invoked by MediatR -in-process. The handler code is **the same** across modes. +Today `InProcessEventBus` resolves the concrete +`IIntegrationEventHandler` directly from DI in that subscription's async +scope. MediatR is not involved. There is no shipped +`AddDaprSubscription` helper; do not invent one. Phase 11's Dapr adapter +invokes the same event-declared topic and handler contract, so the handler code +remains identical across transports. ### Step 6: Tests @@ -232,7 +236,7 @@ Two tests minimum: `Integration_Events_Inherit_From_IntegrationEventBase`, `Integration_Event_Handlers_Use_InboxGuard`, `Integration_Event_TopicNames_FollowConvention`. -- An integration test confirms the round-trip: handler publishes → outbox row +- An integration test confirms the round-trip: handler enqueues → outbox row created → outbox processor dispatches → consumer handles + writes business state + inbox row. - Sending the same event twice writes the consumer's business state exactly once. @@ -246,10 +250,11 @@ Two tests minimum: in production (Kafka redelivery → duplicate work) is silent until then. - **Writing the outbox row in a separate transaction.** Use `IOutbox.EnqueueAsync` inside the ambient `DbContext`; never `new TransactionScope`. -- **Hand-rolling the topic name.** The convention is mechanical; never invent. +- **Supplying a second topic at enqueue or subscription time.** The concrete + event's `Topic` override is the one source; persistence, envelope and transport + forward it unchanged. - **Bumping the schema without versioning.** A breaking change ships a `V2` record; - `V1` stays supported. Architecture test `Integration_Events_Are_Versioned` - rejects inline edits to a published event shape. -- **Tenant context missing on the consumer side.** The Dapr-side middleware sets - it from `@event.TenantId`; if you build a custom subscriber, you must replicate - that or you'll write rows with no tenant. + `V1` stays supported during its compatibility window. +- **Tenant context missing on the consumer side.** The registered transport sets + it from the event and envelope before handler lookup; a future adapter must + preserve that timing or handlers can resolve the publisher/unresolved tenant. diff --git a/.claude/skills/code-review/SKILL.md b/.claude/skills/code-review/SKILL.md index 0e89dce3..b94f496c 100644 --- a/.claude/skills/code-review/SKILL.md +++ b/.claude/skills/code-review/SKILL.md @@ -270,7 +270,7 @@ Author intent: [20-infrastructure-stack.md § Forbidden](../../../docs/standards/20-infrastructure-stack.md)). Use `ICacheService` / `IEventBus` / `ISecretProvider`. - **No `Dapr.Client.*` imports outside `LearnStack.Infrastructure.{Caching,Messaging,Secrets}`** - — a separate rule per [ADR-0014 § Architecture tests](../../../docs/decisions/0014-adopt-dapr.md) + — a separate rule per [ADR-0038](../../../docs/decisions/0038-cross-cutting-port-and-event-contracts.md) and [29-dapr-integration.md § 8](../../../docs/architecture/29-dapr-integration.md); architecture test `Dapr_SDK_Types_NotImportedOutsideInfrastructure`. - **No direct write to `audit_log` / `outbox_messages` / diff --git a/.claude/skills/local-dev-setup/SKILL.md b/.claude/skills/local-dev-setup/SKILL.md index 96261043..41b98448 100644 --- a/.claude/skills/local-dev-setup/SKILL.md +++ b/.claude/skills/local-dev-setup/SKILL.md @@ -3,7 +3,7 @@ name: local-dev-setup description: > Bring up the LearnStack local stack — Postgres, Keycloak (two realms), SeaweedFS, LiveKit OSS, Meilisearch, Mailpit, Coturn by default, and Valkey, - Kafka, Vault, APISIX and the two Dapr services behind the `gated` profile — via + Kafka, kafka-ui, Vault, APISIX and the two Dapr services behind the `gated` profile — via `docker-compose` plus the project's `make dev` orchestrator. USE FOR: first-time workstation setup, restoring a broken local environment, switching between `DeploymentMode` for testing. DO NOT USE FOR: production deployment (separate @@ -17,7 +17,7 @@ description: > Stand up a full LearnStack stack on a developer workstation so backend + frontend can run against real Postgres / Keycloak / SeaweedFS / LiveKit / Meilisearch — -the same components production uses. Valkey, Kafka, Vault, APISIX and Dapr sit +the same components production uses. Valkey, Kafka, kafka-ui, Vault, APISIX and Dapr sit behind the `gated` profile per [ADR-0035](../../../docs/decisions/0035-demand-gated-infrastructure.md): nothing the backend runs today calls them, so `make dev` starts 7 services and @@ -30,8 +30,9 @@ the backend runs today calls them, so `make dev` starts 7 services and - New workstation; first checkout. - The local stack is in a broken state (port collisions, stale containers, lost volumes). -- You need to test `DeploymentMode.Development` vs `DeploymentMode.SelfHosted` - locally. +- You need to exercise one of the five real deployment-mode values locally: + `Development`, `SaaS`, `Dedicated`, `SelfHostedOnline`, or + `SelfHostedAirGapped`. - You want to reproduce a Hub-backed (`SaaS` / `Dedicated`) scenario by pointing at a local Hub stack from the `learnstack-hub` repo. @@ -67,8 +68,9 @@ docker info >/dev/null && echo "docker OK" If any of these is missing, install: - .NET 10 SDK: -- Node: use Volta or fnm; project's `.nvmrc` pins the version. -- pnpm: `corepack enable && corepack prepare pnpm@latest --activate`. +- Node: use Volta or fnm; `frontend/package.json` requires `>=20.11.0` and CI + pins `20.11.0`. +- pnpm: `corepack enable`; `frontend/package.json` pins `pnpm@9.12.3`. - Docker Desktop: . ### Step 2: Clone + restore @@ -80,15 +82,15 @@ cd learnstack cp .env.example .env # creates the local-only file (gitignored) # Edit .env to override any defaults; for first-run, leave as-is. -dotnet restore --locked-mode -pnpm install --frozen-lockfile +(cd backend && dotnet restore LearnStack.slnx --locked-mode) +(cd frontend && pnpm install --frozen-lockfile) ``` ### Step 3: Bring up the stack ```bash make dev # the daily loop: 7 services, the ones the backend can call -make dev-gated # all 14, including Valkey, Kafka, Vault, APISIX and Dapr +make dev-gated # all 14, including Valkey, Kafka, kafka-ui, Vault, APISIX and Dapr ``` `make dev` is `docker compose up -d` plus a status line. It does **not** start @@ -159,11 +161,8 @@ documented placeholder and does not run them yet: 5. Creates the SeaweedFS buckets. 6. Creates the Meilisearch indexes. -For a clean re-seed: - -```bash -make seed-reset -``` +There is no separate reset target today. If the placeholder seed must be rerun +against fresh local data, use the destructive `make clean`, then `make seed`. ### Step 5: Verify @@ -172,7 +171,7 @@ make seed-reset # 5080 is ASPNETCORE_URLS in .env.example - the single source of truth for it. curl -fsS http://localhost:5080/healthz | jq -# APISIX (gateway pass-through) +# APISIX (gateway pass-through; only after `make dev-gated` and while the API runs) curl -fsS http://localhost:9080/healthz | jq # Keycloak realms @@ -192,20 +191,28 @@ open http://demo-english.learnstack.local:3000 ### Step 6: Switch deployment modes locally -Edit `.env` to flip `DEPLOYMENT_MODE`: +Edit `.env` to flip `DEPLOYMENT_MODE`. This changes the composition paths that +already exist, such as error tracking and telemetry. It does not make the +demand-gated Dapr adapters exist early: | Value | What happens | |-------|--------------| -| `Development` (default) | `InProcessEventBus` + `InMemoryCacheService` + env vars for secrets. Dapr sidecar is still present but not exercised. | -| `SaaS` | `DaprEventBus` (Kafka) + `DaprCacheService` (Valkey) + `DaprSecretProvider` (Vault) + `HubEntitlementProvider` pointing at the local Hub. Requires the `learnstack-hub` repo's `make dev` to be running. | -| `Dedicated` | Same as `SaaS` for the composition; in practice the Hub is dedicated to one tenant. | -| `SelfHostedOnline` | `HubEntitlementProvider` against the LearnStack-hosted Hub (phone-home daily, 30-day cached-projection grace per ADR-0020). | -| `SelfHostedAirGapped` | `SignedLicenseKeyEntitlementProvider` reads `.lic` from `./secrets/license.lic`; no Hub interaction. | +| `Development` (default) | Current fully wired local mode; no network telemetry or external error tracker. | +| `SaaS` | Current SaaS composition paths, including Sentry/OTLP when configured. Hub entitlement wiring arrives in its owning phase. | +| `Dedicated` | A prepared composition seam, not an end-to-end supported deployment until its Phase 11 integration suite exists. | +| `SelfHostedOnline` | A prepared composition seam; phone-home and signed-license entitlement wiring land in their owning phases. | +| `SelfHostedAirGapped` | Current composition suppresses network telemetry and uses local-file error tracking; full air-gapped entitlement wiring remains phase-owned. | + +For **every** value today, the three demand-gated ports still resolve to +`InProcessEventBus`, `InMemoryCacheService`, and +`ConfigurationSecretProvider`. `DaprEventBus`, `DaprCacheService`, and +`DaprSecretProvider` land in Phase 11 only after their ADR-0035 triggers fire. -After changing `.env`, restart the API: +After changing `.env`, stop and rerun the API process: ```bash -make restart-api +# In the terminal running `dotnet run`, press Ctrl+C, then: +dotnet run --project backend/src/LearnStack.Api ``` ### Step 7: Common troubleshooting @@ -213,9 +220,9 @@ make restart-api | Symptom | Fix | |---------|-----| | `Bind for 127.0.0.1:5432 failed: port is already allocated` | Stop your local Postgres, or stop the other compose project holding the port — host ports are fixed in `dev.yml`, so two projects cannot both bind them. | -| `relation "tenants" does not exist` | Migrations didn't run; `make migrate`. | +| `relation "tenants" does not exist` | The owning Tenancy migrations have not landed or were not applied; check the active phase plan before adding an ad-hoc target. | | `unable to read app.tenant_id` | The `DbCommandInterceptor` tenant-context guard is unwired, or `TransactionBehavior` did not issue the `SET LOCAL` pair. It is deliberately **not** a connection-checkout interceptor — checkout precedes `BEGIN`. | -| Keycloak realm not found | First-run seed failed; `make seed-reset` rebuilds. | +| Keycloak realm not found | Recreate local data with destructive `make clean`, then `make seed`; `scripts/seed.sh` is still a Phase 02a placeholder today. | | Web app shows raw i18n keys | i18n bundle build skipped; `pnpm build:i18n`. | | Hub-backed mode hangs | The `learnstack-hub` repo's stack isn't up; start it or switch to `Development`. | | LiveKit join fails with TURN error | coturn not reachable from the browser; check firewall + container network. | @@ -223,16 +230,17 @@ make restart-api ### Step 8: Tear-down ```bash -make dev-down # stops containers, keeps volumes -make dev-clean # stops containers AND removes volumes (lose data) +make down # stops containers, keeps volumes +make clean # stops containers AND removes volumes (lose data) ``` -The `dev-clean` target is destructive; only use when you genuinely want a fresh +The `clean` target is destructive; only use when you genuinely want a fresh state. ## Validation -- `make dev` exits 0 and the API responds 200 to `/healthz`. +- `make dev` exits 0; after starting the API separately, `/healthz` responds 200. +- After `make dev-gated` and with that API running, APISIX forwards `/healthz`. - The web app loads against a demo tenant's host (either default subdomain or Hosts-aliased custom domain). - Keycloak login works for both realms. @@ -249,13 +257,13 @@ state. - **Editing `.env.example`.** That file is the **template**; commit changes only if the project's default really should change. Your local overrides go in `.env` (gitignored). -- **Skipping Dapr.** Even in `Development` mode the sidecar runs (composition - root falls back to `InProcessEventBus`, but the sidecar is harmless). Don't - remove it from compose. +- **Expecting Dapr in the daily loop.** `make dev` deliberately omits the gated + sidecar. Use `make dev-gated` only when inspecting the future adapter stack; + the backend still resolves the in-process/default ports today. - **Hardcoded localhost in code.** Reads URLs from config (`IOptions`, `IOptions`). Anything else is wrong. -- **Loading secrets from `.env` in production-mode code paths.** `.env` is - development-only; `SaaS` / `Dedicated` / `SelfHosted` read via - `ISecretProvider` (Vault). +- **Assuming a non-development enum value selects Vault today.** All modes still + resolve `ConfigurationSecretProvider`; the Vault-backed adapter is Phase 11 + work and must not be claimed before it is wired and tested. - **`docker compose down -v` by accident.** That destroys volumes. Use - `dev-down` (no `-v`) for routine restarts. + `down` (no `-v`) for routine restarts. diff --git a/.claude/skills/start-task/SKILL.md b/.claude/skills/start-task/SKILL.md index d33245d2..3c1e1e8b 100644 --- a/.claude/skills/start-task/SKILL.md +++ b/.claude/skills/start-task/SKILL.md @@ -105,7 +105,7 @@ Run the change through the **hard rules** in [CLAUDE.md § Hard rules](../../../ - Does it add a 5th Hub HTTPS endpoint? → requires a new ADR (ADR-0019). - Does it inject `IConnectionMultiplexer` / `IDistributedCache` / `KafkaProducer` / `VaultClient` directly? → forbidden; use `ICacheService` / `IEventBus` / - `ISecretProvider` (ADR-0014, standards/20). + `ISecretProvider` (ADR-0038, standards/20). - Does it write `audit_log` / `outbox_messages` / `platform_entitlement_cache` directly? → forbidden; use `IAuditStore` / `IOutbox` / `IEntitlementProvider.RefreshAsync`. diff --git a/.claude/skills/wire-dapr-pubsub/SKILL.md b/.claude/skills/wire-dapr-pubsub/SKILL.md index 3dca6b46..bad6beaf 100644 --- a/.claude/skills/wire-dapr-pubsub/SKILL.md +++ b/.claude/skills/wire-dapr-pubsub/SKILL.md @@ -1,22 +1,25 @@ --- name: wire-dapr-pubsub description: > - Wire a Dapr pub/sub topic for cross-module integration events, with the + Wire transport-independent subscription metadata for a cross-module + integration event and, only after Phase 11's trigger fires, its Dapr pub/sub + adapter, with the `learnstack.{module}.{aggregate}` topic-name convention, Kafka as the broker, and - the `InProcessEventBus` dev fallback. USE FOR: adding a new topic, configuring the - Dapr component YAML for a new module, switching a dev environment between - Dapr-on and Dapr-off. DO NOT USE FOR: declaring a new integration event shape + `InProcessEventBus` default. USE FOR: registering a consumer of a declared topic + or implementing/verifying the Phase 11 Dapr adapter after its trigger fires. + DO NOT USE FOR: declaring a new integration event shape (use `add-integration-event`), Dapr service invocation / workflow / bindings / - actors (out of scope per ADR-0014), or direct `KafkaProducer` usage (forbidden). + actors (out of scope per ADR-0038), or direct `KafkaProducer` usage (forbidden). --- # Wiring a Dapr pub/sub topic ## Purpose -Stand up a new pub/sub topic correctly: producer side, consumer side, dev fallback, -and component YAML. The wiring contract is in -[ADR-0014](../../../docs/decisions/0014-adopt-dapr.md) and +Register a new consumer against the current transport-independent contract. If +ADR-0035's Phase 11 trigger has fired, also wire the Dapr adapter without changing +that contract. The wiring contract is in +[ADR-0038](../../../docs/decisions/0038-cross-cutting-port-and-event-contracts.md) and [29-dapr-integration.md](../../../docs/architecture/29-dapr-integration.md); this skill is the **mechanical** check-list. @@ -24,14 +27,15 @@ this skill is the **mechanical** check-list. - A new integration event needs a new topic (the topic-name comes from the event). - A second consumer module wants to subscribe to an existing topic. -- The local dev compose stack needs a new component / route. - The `InProcessEventBus` test fixture needs a registration for a new event. +- Phase 11's Dapr pub/sub trigger has fired and its adapter is being implemented + or extended. ## When not to use - Declaring the event shape itself — use [add-integration-event](../add-integration-event/SKILL.md). -- Dapr service invocation, workflow, bindings, actors — out of scope per ADR-0014. +- Dapr service invocation, workflow, bindings, actors — out of scope per ADR-0038. - Direct `KafkaProducer` / `ConsumerBuilder` usage — forbidden by [20-infrastructure-stack.md](../../../docs/standards/20-infrastructure-stack.md). - Hub-side pub/sub (lives in `learnstack-hub` repo). @@ -43,8 +47,8 @@ this skill is the **mechanical** check-list. | Event type | Yes | The fully-qualified C# event type (`Module.IntegrationEvents.V1`). | | Producing module | Yes | Owns the aggregate. | | Consuming module(s) | Yes | At least one. | -| Topic | Derived | `learnstack.{module}.{aggregate}`. Architecture test enforces. | -| Partition key | No | Aggregate id when ordering is required (rare). | +| Topic | Declared | The event's `Topic` override; normally `learnstack.{module}.{aggregate}`. | +| Partition key | Declared | The event's `PartitionKey` override; normally the aggregate id. | ## Workflow @@ -64,12 +68,13 @@ Format: `learnstack.{module}.{aggregate}`. Examples: | `learnstack.hub.custom-domain.activated` | Hub → core host mapping update | | `learnstack.cache.invalidation` | Cross-instance L1 cache invalidation | -Architecture test `Dapr_PubSub_TopicNames_FollowConvention` (defined in +Architecture test `Integration_Event_TopicNames_FollowConvention` (defined in [15-event-and-outbox.md § Architecture tests](../../../docs/architecture/15-event-and-outbox.md)) rejects anything that doesn't match -`^learnstack\.[a-z][a-z0-9-]*\.[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)?$`. +`^learnstack\.(?:[a-z]|[a-z][a-z0-9-]*[a-z0-9])\.(?:[a-z]|[a-z][a-z0-9-]*[a-z0-9])(?:\.(?:[a-z]|[a-z][a-z0-9-]*[a-z0-9]))?$`, +with the four-segment form accepted only when segment two is `hub`. -The optional third segment exists for **Hub-side event-name suffixes** +The optional fourth segment exists for **Hub-side event-name suffixes** (`learnstack.hub.custom-domain.activated`, `learnstack.hub.custom-domain.deactivated`, `learnstack.hub.custom-domain.revoked`). LearnStack-core topics stay 3-segment (`learnstack.{module}.{aggregate}`); the 4-segment shape is reserved for the Hub-side @@ -116,113 +121,90 @@ await outbox.EnqueueAsync(new EnrollmentCreatedIntegrationEventV1 { ... }, ct); await db.SaveChangesAsync(ct); ``` -The `OutboxProcessor` (BackgroundService) polls `outbox_messages`, calls -`IEventBus.PublishAsync(event)`, and `DaprEventBus.PublishAsync` invokes -`DaprClient.PublishEventAsync("pubsub", topic, event)`. +The `OutboxProcessor` (BackgroundService) polls `outbox_messages`, constructs an +`IntegrationEventEnvelope`, and calls `IEventBus.PublishAsync(envelope)`. +`DaprEventBus.PublishAsync` invokes `DaprClient.PublishEventAsync` with +`envelope.Topic`, `envelope.Event`, and `envelope.PartitionKey` metadata. -The topic name is derived **mechanically** from the event type's namespace + -aggregate name; you do not name it manually. +The topic is declared by the event's `Topic` override and checked by the +architecture test; the transport never re-derives or renames it. -### Step 4: Consumer side — subscription +### Step 4: Consumer side — current subscription metadata -In the consumer module's startup: +`InProcessEventBus` discovers handler types from assemblies passed at the +composition root. Ensure the consumer assembly is included and let the registry +register the concrete handler type: ```csharp -public sealed class EnrollmentModule : IModule -{ - public void Register(IServiceCollection services, IConfiguration configuration) - { - // ... DbContext, MediatR, etc. ... - - // `AddDaprSubscription` is a project-owned helper (or equivalent — - // e.g. a `[Topic]` attribute on the handler). It lives in - // LearnStack.Infrastructure.Messaging; the exact signature is - // implementation detail. What matters: the subscription is registered - // in the consumer module's startup, and the handler reaches - // `IInboxGuard` before any business logic. - services.AddDaprSubscription( - topic: "learnstack.identity.user", - pubsubName: "pubsub"); - } -} +builder.AddLearnStackCrossCuttingFoundation( + deploymentMode, + typeof(CreateAuditEntryOnUserDeleted).Assembly); ``` -The subscription pipeline: +There is no shipped `AddDaprSubscription` helper or `[Topic]` attribute. Do +not invent one. The event declares its topic and the construction-free handler +registry supplies the current subscription metadata. + +The future Phase 11 subscription pipeline must preserve this behavior: 1. Dapr sidecar delivers HTTP POST to `/dapr/subscribe-endpoint`. -2. LearnStack's `DaprSubscriptionMiddleware` deserialises the envelope, restores - `TenantContext` from `event.TenantId` / `event.OrganizationId`, and dispatches - via MediatR. +2. LearnStack's Phase 11 Dapr adapter deserialises the envelope, restores + `TenantContext` from the event and envelope, and resolves the matching + `IIntegrationEventHandler` directly from DI. 3. The `IIntegrationEventHandler` runs with `IInboxGuard` protection. -### Step 5: Dev fallback (`InProcessEventBus`) +### Step 5: Current transport (`InProcessEventBus`) -When `DeploymentMode = Development`, Dapr is bypassed: +Every deployment-mode value currently resolves `InProcessEventBus`. Do not add a +mode switch to a nonexistent adapter. After ADR-0035's trigger fires, Phase 11 +changes this single composition-root selection site: ```csharp -// composition root -if (deploymentMode == DeploymentMode.Development) -{ - services.AddSingleton(); -} -else -{ - services.AddSingleton(); -} +// Current: SelectEventBus(...) returns InProcessEventBus for every mode. +// Phase 11: select DaprEventBus only for the deployment(s) whose trigger fired. ``` -`InProcessEventBus` resolves `IIntegrationEventHandler`, closed over the -event's **runtime** type, straight from the DI container. MediatR is not -involved: there is no `IPublisher` and no `INotificationHandler`, and registering -a second interface is precisely the mistake the single consumer contract exists -to prevent — two interfaces mean two implementations per consumer, and the one -exercised in CI would not be the one that runs in production. +`InProcessEventBus` reads construction-free subscription metadata, creates one +async DI scope per subscription, and resolves exactly that subscription's +concrete `IIntegrationEventHandler`. MediatR is not involved: there is no +`IPublisher` or `INotificationHandler`, and registering a second interface is +precisely the mistake the single consumer contract exists to prevent. The handler code is the **same** on both transports; the bus is the only -difference. Register your handler once, as -`IIntegrationEventHandler`. +difference. Implement the handler once as +`IIntegrationEventHandler` and expose its assembly to the +registry as shown in Step 4. ### Step 6: Cross-instance L1 cache invalidation -If your module has its own L1 in-memory cache (rare; prefer `ICacheService`), you -must subscribe to `learnstack.cache.invalidation`: - -```csharp -services.AddDaprSubscription( - topic: "learnstack.cache.invalidation", - pubsubName: "pubsub"); -``` - -The event carries `(tenant_id, cache_key)` so the local cache evicts the right -entry. Most modules use `ICacheService` directly and skip this. +Cross-instance invalidation is Phase 11 work, because it requires more than one +process. Do not add a current `AddDaprSubscription` call. When the trigger fires, +the adapter-owned `learnstack.cache.invalidation` consumer may evict one exact +tenant-qualified key; generation keys remain the mechanism for set invalidation. ### Step 7: Ordering (rare) -If consumers require per-aggregate ordering (e.g. learner progress events on the -same enrollment must arrive in order), set the partition key when enqueuing the -event — **never call `DaprClient` directly**. Module code goes through `IOutbox` -/ `IEventBus`; only `DaprEventBus` (in `LearnStack.Infrastructure.Messaging`) -touches `DaprClient`. The `IOutbox.EnqueueAsync` overload accepts a partition-key -parameter that the `OutboxProcessor` forwards into the Dapr publish metadata: +Every event declares its ordering domain by overriding `PartitionKey` — normally +the aggregate id. Never pass a second key while enqueuing and never call +`DaprClient` directly. `IOutbox.EnqueueAsync` copies the declared key to the row; +the processor reconstructs the envelope and the Dapr adapter forwards the same +value as publish metadata: ```csharp -await outbox.EnqueueAsync( - @event, - partitionKey: enrollment.Id.ToString(), - ct); +public override string PartitionKey => EnrollmentId.ToString(); ``` -Internally, `DaprEventBus.PublishAsync` translates this into the equivalent Dapr +In Phase 11, `DaprEventBus.PublishAsync` translates this into the equivalent Dapr metadata (`partitionKey`) on the `PublishEventAsync` call: ```csharp // LearnStack.Infrastructure.Messaging.DaprEventBus (Infrastructure only — never // call DaprClient from a module). await daprClient.PublishEventAsync( - "pubsub", topic, @event, + "pubsub", envelope.Topic, envelope.Event, metadata: new Dictionary { - ["partitionKey"] = enrollment.Id.ToString(), + ["partitionKey"] = envelope.PartitionKey, }); ``` @@ -231,41 +213,44 @@ not exist; design around it. ### Step 8: Observability -Each topic gets three automatic metrics: +Phase 11 must add and verify the transport metrics governed by the observability +standard; they are not automatic in the current in-process implementation: - `learnstack_outbox_dispatch_duration_seconds{event_type}` - `learnstack_outbox_dispatch_failed_total{event_type}` - `learnstack_inbox_dedup_total{module, event_type}` -No extra wiring needed. Confirm the Grafana dashboard sees the new event type -within ~5 minutes of the first published message. +Keep tags low-cardinality and confirm the names against the implementation and +dashboard when the adapter lands. ## Validation - `dotnet build` and `dotnet test` pass. -- Architecture tests: - - `Dapr_PubSub_TopicNames_FollowConvention`. - - `Integration_Events_Inherit_From_IntegrationEventBase`. - - `Integration_Event_Handlers_Use_InboxGuard`. - - `Modules_Do_Not_Inject_Kafka_Directly`. -- An integration test (Testcontainers + Dapr in dev mode) confirms the round-trip - publish → dispatch → consume → inbox marker. -- The metric `learnstack_outbox_dispatch_duration_seconds{event_type="..."}` appears - in Prometheus. +- Current architecture tests + `Integration_Event_TopicNames_FollowConvention` and + `Modules_Do_Not_Inject_IEventBus_Directly` pass. +- When doing Phase 02b consumer work, add/keep its registered inheritance and + inbox-guard rules. When doing Phase 11 adapter work, add/keep the Dapr binding + and direct-provider boundary rules owned by that phase. +- Today, an in-process integration test confirms publish → dispatch → consume → + inbox marker and the current transport's consumer span. +- When Phase 11's adapter exists, its Dapr/Testcontainers binding test and + transport metrics pass as additional validation. ## Common pitfalls - **Inventing a topic name.** The convention is `learnstack.{module}.{aggregate}` for LearnStack-core topics (3 segments). Hub-side events may add a 4th event-name segment (`learnstack.hub.custom-domain.activated`). Architecture - test `Dapr_PubSub_TopicNames_FollowConvention` rejects anything else. + test `Integration_Event_TopicNames_FollowConvention` rejects anything else today; + Phase 11's Dapr binding test checks the component copy too. - **Direct `DaprClient` / `KafkaProducer` injection.** Both forbidden. Use `IEventBus`. - **Subscribing in the wrong module.** A subscription declared in the producer module's startup runs *on the producer side*, which is almost always wrong. -- **Forgetting the dev fallback.** Tests that run with `DeploymentMode.Development` - see no events if `IEventBus` isn't wired to `InProcessEventBus`. Confirm - composition-root branching. +- **Switching on deployment mode before the trigger.** All modes use + `InProcessEventBus` today. Add the Dapr branch only with the Phase 11 adapter + and its integration suite. - **Ordering assumption across topics.** Kafka does not order across topics or cross-partition within a topic. If your design needs strict ordering, use partition keys and document the assumption. diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 346143ec..9e50f173 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -139,20 +139,32 @@ restage() { git add -- "$@"; } # is `.leakwatchignore`. One `git init` per commit, all paths checked in a # single batch. If anything about that fails the set comes back empty, which # means everything is scanned — the safe direction, never a silent skip. -leakwatch_ignored_paths="" +leakwatch_ignored_paths=() if [[ -f .leakwatchignore && ${#all_staged[@]} -gt 0 ]]; then lw_isolated="$(mktemp -d)" if git init -q "$lw_isolated" >/dev/null 2>&1 \ - && cp .leakwatchignore "$lw_isolated/.gitignore"; then - leakwatch_ignored_paths="$(printf '%s\n' "${all_staged[@]}" \ - | git -C "$lw_isolated" check-ignore --no-index --stdin 2>/dev/null || true)" + && cp .leakwatchignore "$lw_isolated/.gitignore" \ + && git -C "$lw_isolated" config core.excludesFile /dev/null; then + # `git init` creates this file empty, but clear it explicitly: neither a + # global excludes file nor repository-local info/exclude may influence + # a decision that is meant to reflect .leakwatchignore alone. + : > "$lw_isolated/.git/info/exclude" + + while IFS= read -r -d '' ignored_path; do + leakwatch_ignored_paths+=("$ignored_path") + done < <(printf '%s\0' "${all_staged[@]}" \ + | git -C "$lw_isolated" check-ignore -z --no-index --stdin 2>/dev/null \ + || true) fi rm -rf "$lw_isolated" fi leakwatch_path_ignored() { - [[ -n "$leakwatch_ignored_paths" ]] || return 1 - printf '%s\n' "$leakwatch_ignored_paths" | grep -Fxq -- "$1" + local ignored_path + for ignored_path in ${leakwatch_ignored_paths[@]+"${leakwatch_ignored_paths[@]}"}; do + [[ "$ignored_path" == "$1" ]] && return 0 + done + return 1 } # Older builds only accept a DIRECTORY target. CI pins v1.5.0, which rejects a diff --git a/Makefile b/Makefile index ed368399..cdaa1920 100644 --- a/Makefile +++ b/Makefile @@ -68,10 +68,10 @@ help: ## Show this help, listing every target and its one-line description. dev: .env ## Bring the local dev stack up (Postgres, Keycloak, SeaweedFS, …). $(COMPOSE_DEV) up -d @printf "\n$(CYAN)Stack up.$(RESET) Tail logs with: make logs\n" - @printf "Kafka, Valkey, Vault, APISIX and Dapr are behind the '$(GATED_PROFILE)' profile — $(CYAN)make dev-gated$(RESET).\n" + @printf "Kafka, kafka-ui, Valkey, Vault, APISIX and Dapr are behind the '$(GATED_PROFILE)' profile — $(CYAN)make dev-gated$(RESET).\n" .PHONY: dev-gated -dev-gated: .env ## Bring the dev stack up INCLUDING the demand-gated services (Kafka, Valkey, Vault, APISIX, Dapr). +dev-gated: .env ## Bring the dev stack up INCLUDING the demand-gated services (Kafka, kafka-ui, Valkey, Vault, APISIX, Dapr). COMPOSE_PROFILES=$(GATED_PROFILE) $(COMPOSE_DEV) up -d @printf "\n$(CYAN)Full stack up.$(RESET) Nothing the backend runs today calls these — see ADR-0035.\n" @@ -92,7 +92,7 @@ ps: ## Show service health summary, gated services included. $(COMPOSE_ALL) ps .PHONY: e2e-up -e2e-up: .env ## Bring the dev stack up with the e2e overlay (tmpfs volumes — ephemeral). +e2e-up: .env ## Bring the default dev services up with the e2e overlay (set COMPOSE_PROFILES=gated for all 14). $(COMPOSE_E2E) up -d @printf "\n$(CYAN)E2E stack up.$(RESET) Data is ephemeral — every restart wipes state.\n" diff --git a/README.md b/README.md index 91349e7c..35c7ed96 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ make seed # verify health + print demo credentials - **Foundation ports:** `IEventBus`, `ICacheService`, `ISecretProvider`, `IEntitlementProvider`, `IHostToTenantResolver` in `LearnStack.SharedKernel`, each with a working default implementation. Vendor adapters — Dapr - ([ADR-0014](docs/decisions/0014-adopt-dapr.md)), Kafka, Valkey + ([ADR-0038](docs/decisions/0038-cross-cutting-port-and-event-contracts.md)), Kafka, Valkey ([ADR-0030](docs/decisions/0030-redis-compatible-store-valkey.md)), Vault, APISIX ([ADR-0015](docs/decisions/0015-api-gateway-apisix.md)) — are **demand-gated**: each has an owning phase and a written trigger condition in diff --git a/backend/src/LearnStack.Api/Composition/CrossCuttingFoundationExtensions.cs b/backend/src/LearnStack.Api/Composition/CrossCuttingFoundationExtensions.cs index edf69d8f..8e5cc589 100644 --- a/backend/src/LearnStack.Api/Composition/CrossCuttingFoundationExtensions.cs +++ b/backend/src/LearnStack.Api/Composition/CrossCuttingFoundationExtensions.cs @@ -40,10 +40,10 @@ public static class CrossCuttingFoundationExtensions public static WebApplicationBuilder AddLearnStackCrossCuttingFoundation( this WebApplicationBuilder builder, DeploymentMode deploymentMode, - params System.Reflection.Assembly[] mediatorHandlerAssemblies) + params System.Reflection.Assembly[] handlerAssemblies) { ArgumentNullException.ThrowIfNull(builder); - ArgumentNullException.ThrowIfNull(mediatorHandlerAssemblies); + ArgumentNullException.ThrowIfNull(handlerAssemblies); // The Serilog OTLP sink + the OTel pipeline both need // ITenantContextAccessor (via enrichers / processor). Register the @@ -81,7 +81,7 @@ public static WebApplicationBuilder AddLearnStackCrossCuttingFoundation( // business logic ran, so the obligation the transport advertises was // half-delivered. Packet 7's TenantResolverMiddleware writes the same // accessor. - builder.Services.TryAddScoped(sp => + builder.Services.TryAddTransient(sp => sp.GetRequiredService().Current ?? UnresolvedTenantContext.Instance); @@ -111,12 +111,25 @@ public static WebApplicationBuilder AddLearnStackCrossCuttingFoundation( builder.Services .TryAddSingleton(); + + var integrationEventHandlers = + LearnStack.Infrastructure.Messaging.IntegrationEventHandlerRegistry + .Discover(handlerAssemblies); + foreach (var subscription in integrationEventHandlers.All) + { + builder.Services.TryAdd(new ServiceDescriptor( + subscription.HandlerType, + subscription.HandlerType, + ServiceLifetime.Scoped)); + } + + builder.Services.TryAddSingleton(integrationEventHandlers); builder.Services.TryAddSingleton(SelectEventBus); builder.Services.AddProblemDetails(); builder.Services.AddExceptionHandler(); - builder.Services.AddLearnStackMediatRPipeline(mediatorHandlerAssemblies); + builder.Services.AddLearnStackMediatRPipeline(handlerAssemblies); return builder; } @@ -247,7 +260,8 @@ private static LearnStack.SharedKernel.Caching.ICacheService SelectCacheService( // process and costs hit rate rather than correctness for two, which is // why the trigger is a replica count and not a date. return new LearnStack.Infrastructure.Caching.InMemoryCacheService( - services.GetRequiredService()); + services.GetRequiredService(), + services.GetRequiredService()); } /// @@ -277,6 +291,8 @@ private static LearnStack.SharedKernel.Messaging.IEventBus SelectEventBus( services.GetRequiredService(), services.GetRequiredService(), services.GetRequiredService(), + services.GetRequiredService< + LearnStack.Infrastructure.Messaging.IntegrationEventHandlerRegistry>(), services.GetRequiredService< Microsoft.Extensions.Logging.ILogger< LearnStack.Infrastructure.Messaging.InProcessEventBus>>()); diff --git a/backend/src/LearnStack.Application/Pipeline/OutboxFlushBehavior.cs b/backend/src/LearnStack.Application/Pipeline/OutboxFlushBehavior.cs index 68c9c301..8739c204 100644 --- a/backend/src/LearnStack.Application/Pipeline/OutboxFlushBehavior.cs +++ b/backend/src/LearnStack.Application/Pipeline/OutboxFlushBehavior.cs @@ -31,7 +31,7 @@ public Task Handle( // TODO(2026-05-21, @platform): Phase 02b — on a success-Result, flush // IOutbox messages collected during the handler into outbox_messages // via the unit-of-work seam so Dapr pub/sub dispatches them after - // commit. Per ADR-0006 + ADR-0014 + ADR-0032 § Sub-decision 12. + // commit. Per ADR-0006 + ADR-0038 + ADR-0032 § Sub-decision 12. return next(); } diff --git a/backend/src/LearnStack.Infrastructure/Caching/InMemoryCacheService.cs b/backend/src/LearnStack.Infrastructure/Caching/InMemoryCacheService.cs index 96653922..aa7a1569 100644 --- a/backend/src/LearnStack.Infrastructure/Caching/InMemoryCacheService.cs +++ b/backend/src/LearnStack.Infrastructure/Caching/InMemoryCacheService.cs @@ -1,144 +1,112 @@ using System.Collections.Concurrent; +using System.Diagnostics; +using System.Diagnostics.Metrics; using LearnStack.SharedKernel.Caching; using LearnStack.SharedKernel.Time; namespace LearnStack.Infrastructure.Caching; /// -/// The default : correct for one process, and — unlike -/// the idempotency store next door — not silently wrong for two. +/// The process-local implementation. /// /// /// -/// This is not shared between instances. Two application instances each -/// hold their own map, so a value written by one is not visible to the other and -/// a on one does not evict the other's copy. That is a -/// staleness bound, not a correctness bug: a cache miss is never an error, -/// and the source of truth is unaffected. The Valkey-backed adapter lands on its -/// ADR-0035 -/// trigger — more than one application instance running concurrently — and until -/// then a second instance costs cache hit rate rather than correctness. +/// 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. /// /// -/// Why this evicts freely and InMemoryIdempotencyStore does not. -/// The two look like the same shape and carry opposite rules. An idempotency -/// record is a promise for the length of its window, so dropping one lets an -/// operation run twice; a cache entry promises nothing, so dropping one costs a -/// round trip. Capacity here is eviction, and there it is admission — the same -/// bound in the same kind of dictionary, decided the other way, because the -/// contracts differ. +/// Concurrent misses for one key and requested type share one factory flight. +/// Caller cancellation only stops that caller waiting. When the last waiter +/// leaves, a service-owned token cancels the factory, but the flight remains +/// registered until the factory actually terminates; a replacement can therefore +/// never overlap abandoned work for the same registration. /// /// -public sealed class InMemoryCacheService(IClock clock) : ICacheService +public sealed class InMemoryCacheService : ICacheService { - /// - /// The TTL an entry gets when the caller names none — Standards 20's - /// hot-path default. - /// + public const string MeterName = "learnstack.cache"; + public const string HitCounterName = "learnstack_cache_hit_total"; + public const string MissCounterName = "learnstack_cache_miss_total"; + public const string StoreCounterName = "learnstack_cache_store_total"; + public const string CoalescedCounterName = "learnstack_cache_coalesced_total"; + public const string EvictionCounterName = "learnstack_cache_eviction_total"; + public const string FactoryDurationName = "learnstack_cache_factory_duration_seconds"; + + /// The default in-process lifetime. public static readonly TimeSpan DefaultTtl = TimeSpan.FromSeconds(60); - /// - /// How often the map is swept for expired entries. Reclamation only: an - /// expired entry is never returned, whether or not a sweep has run. - /// + /// How often expired entries are reclaimed. public static readonly TimeSpan SweepInterval = TimeSpan.FromSeconds(1); /// - /// The most entries held at once. A cache with no bound is an - /// out-of-memory condition waiting for a caller with an unbounded key space. + /// The longest one cache factory may run. Provider calls are required to be + /// bounded below this value by Standards 15; this is the final service-owned + /// backstop and is deliberately independent of any one caller's token. /// - /// - /// Enforced on every write that adds a key, not inside the throttled - /// sweep. Measured on the first version, which trimmed only during a sweep: - /// a burst of 60,000 writes inside one left - /// 60,000 entries against this ceiling of 10,000, because the sweep is - /// throttled by clock time and a burst does not advance the clock. The test - /// that covered the bound advanced the clock one second per write, which is - /// the one schedule under which the old code held — a guard and a test that - /// agreed with each other and not with reality. - /// + public static readonly TimeSpan FactoryTimeout = TimeSpan.FromSeconds(30); + + /// The hard maximum number of stored entries. public const int MaxEntries = 10_000; - /// - /// What a trim evicts down to, rather than back to . - /// - /// - /// Without this gap the steady state of an unbounded key space — the exact - /// workload the ceiling exists for — is a trim on every write, each one - /// evicting a single entry. Measured on that version: 0.26 ms and 281 KB of - /// garbage per write, because evicting one entry copied and sorted all ten - /// thousand. Evicting a tenth of the map at once pays that cost once per - /// thousand writes instead of once per write. - /// + /// The low-water mark capacity trimming targets. public const int TrimTarget = MaxEntries * 9 / 10; - private readonly ConcurrentDictionary _entries = new(StringComparer.Ordinal); + private const int KeyGateCount = 256; - /// - /// One factory run per (key, requested type), however many callers miss at - /// once. Keyed by type as well as key because a flight hands its result to - /// every joiner: two callers asking for the same key as different T - /// would otherwise share one run, and the loser would receive the winner's - /// payload — its own factory never invoked at all. - /// + private readonly IClock _clock; + private readonly ConcurrentDictionary _entries = new(StringComparer.Ordinal); private readonly ConcurrentDictionary<(string Key, Type Type), Flight> _inFlight = new(); + private readonly object[] _keyGates = Enumerable.Range(0, KeyGateCount) + .Select(static _ => new object()) + .ToArray(); + private readonly object _capacityGate = new(); + private readonly Counter _hits; + private readonly Counter _misses; + private readonly Counter _stores; + private readonly Counter _coalesced; + private readonly Counter _evictions; + private readonly Histogram _factoryDuration; private long _lastSweepTicks; - - /// - /// Monotonic insertion counter. Eviction orders by this rather than by - /// WrittenAt, because a burst shares one instant: with a frozen or - /// coarse clock every entry carries the same timestamp and "oldest first" - /// silently becomes "an arbitrary one first". - /// private long _sequence; - /// 1 while a trim is running. A field, for Interlocked. - private int _trimming; + public InMemoryCacheService(IClock clock, IMeterFactory meterFactory) + { + ArgumentNullException.ThrowIfNull(clock); + ArgumentNullException.ThrowIfNull(meterFactory); + + _clock = clock; + var meter = meterFactory.Create(new MeterOptions(MeterName)); + _hits = meter.CreateCounter(HitCounterName); + _misses = meter.CreateCounter(MissCounterName); + _stores = meter.CreateCounter(StoreCounterName); + _coalesced = meter.CreateCounter(CoalescedCounterName); + _evictions = meter.CreateCounter(EvictionCounterName); + _factoryDuration = meter.CreateHistogram(FactoryDurationName, unit: "s"); + } - /// - /// How many entries the map currently holds, expired-but-unreclaimed ones - /// included. - /// - /// - /// A diagnostic on this class, deliberately not on - /// : a caller that branches on the size of a cache - /// has made a component whose contract is "sometimes" into one it depends on. - /// It exists so the two bounds this class claims — the ceiling and the - /// reclamation of expired entries — can be asserted directly rather than - /// inferred from which keys happen to survive an eviction. The first version - /// of the bound test inferred, and agreed with a ceiling that was holding - /// 60,000 entries against 10,000. - /// + /// Stored entries, including expired entries awaiting a sweep. public int Count => _entries.Count; - /// - /// How many factory runs are registered as in flight. A diagnostic, for the - /// same reason and with the same caveat as . - /// - /// - /// This map is the other structure that could grow without bound, and the - /// one whose cleanup is subtlest: it is unregistered when the flight ends, - /// not when a caller stops waiting for it. - /// + /// Factory flights that have not yet reached a terminal state. public int InFlightCount => _inFlight.Count; public Task GetAsync(string key, CancellationToken cancellationToken = default) { CacheKey.EnsureValid(key); + cancellationToken.ThrowIfCancellationRequested(); - var now = clock.UtcNow; + var now = _clock.UtcNow; Sweep(now); - // `is T` rather than a cast: a key holding some other type is a caller - // bug, and answering it with a miss lets the caller read the source of - // truth instead of taking an InvalidCastException out of a component - // whose contract is that a miss is never an error. - if (_entries.TryGetValue(key, out var entry) && entry.IsFresh(now) && entry.Value is T hit) + if (TryRead(key, now, out T? value)) { - return Task.FromResult(hit); + _hits.Add(1, CacheNameTag(key)); + return Task.FromResult(value); } + _misses.Add(1, CacheNameTag(key)); return Task.FromResult(default); } @@ -150,160 +118,90 @@ public async Task GetOrSetAsync( { CacheKey.EnsureValid(key); ArgumentNullException.ThrowIfNull(factory); + cancellationToken.ThrowIfCancellationRequested(); - var now = clock.UtcNow; + var now = _clock.UtcNow; + var ttl = ValidateOptions(options, now); Sweep(now); - if (_entries.TryGetValue(key, out var hit) && hit.IsFresh(now) && hit.Value is T cached) - { - return cached; - } - - // Lazy with ExecutionAndPublication, not a bare GetOrAdd value factory: - // a ConcurrentDictionary's value factory may run more than once under - // contention, and running the caller's factory twice is the stampede - // this method exists to prevent. The Lazy is built before the GetOrAdd - // and passed as a VALUE, so the loser of a creation race simply - // discards an object whose .Value was never touched — no factory run. - // - // The flight runs on CancellationToken.None, NOT on this caller's - // token. Measured: with the caller's token, one client pressing refresh - // cancelled the shared factory and every other request waiting on that - // key died with it — as a 499, which this host treats as "the client - // hung up" and therefore writes no body, captures no error and records - // no span. A request that did nothing wrong failed invisibly. - var mine = new Flight(new Lazy>( - async () => await factory(CancellationToken.None).ConfigureAwait(false), - LazyThreadSafetyMode.ExecutionAndPublication)); - var registration = (key, typeof(T)); + var recordedMiss = false; - // A flight that a write has ALREADY superseded must not be joined. It is - // only stopped from storing, so a caller whose GetOrSetAsync begins - // strictly after RemoveAsync returned would otherwise miss _entries — - // the Remove emptied it — join the doomed flight, and be answered with - // the value the invalidation existed to kill, its own factory never run. - // Its callers keep their own reference and still get their result; they - // were already in flight when the write landed, which is an ordinary - // race. Arriving afterwards is not. - Flight flight; while (true) { - flight = _inFlight.GetOrAdd(registration, mine); - if (ReferenceEquals(flight, mine) || !flight.Superseded) + Flight flight; + var owner = false; + var waitForTerminal = false; + var keyGate = KeyGate(key); + + // The miss check and flight publication share the same bounded key + // gate as Set/Remove. There is no interval in which an explicit + // write can finish after a miss but before the flight is visible to + // supersede. + lock (keyGate) { - break; - } + now = _clock.UtcNow; + if (TryRead(key, now, out T? cached)) + { + if (!recordedMiss) + { + _hits.Add(1, CacheNameTag(key)); + } - _inFlight.TryRemove(new KeyValuePair<(string, Type), Flight>(registration, flight)); - } + return cached!; + } - // Registered before anything can observe the count, so the completion - // continuation below never sees a zero that is about to become one. - Interlocked.Increment(ref flight.Waiters); + if (!recordedMiss) + { + _misses.Add(1, CacheNameTag(key)); + recordedMiss = true; + } - try - { - if (ReferenceEquals(flight, mine)) - { - // Covers the case the `finally` cannot: every caller abandoned - // before the factory finished, so no `finally` runs again to - // notice the flight is done. - _ = flight.Task.Value.ContinueWith( - completed => + if (_inFlight.TryGetValue(registration, out flight!)) + { + if (flight.Abandoned || flight.Superseded) { - // Touching Exception marks the fault observed. Without - // it, a factory that faults after every caller has - // abandoned its flight — the correlated failure, since a - // dependency being down is exactly when clients - // disconnect — leaves the task unobserved, and - // TaskScheduler.UnobservedTaskException fires once per - // key with no request, no span and no correlation id - // attached to it. - _ = completed.Exception; - Retire(registration, flight); - }, - CancellationToken.None, - TaskContinuationOptions.ExecuteSynchronously, - TaskScheduler.Default); + waitForTerminal = true; + } + else + { + // Acquired while holding the same gate retirement and + // abandonment use. A published flight therefore never + // exists with an owner count of zero. + flight.Waiters++; + _coalesced.Add(1, CacheNameTag(key)); + } + } + else + { + flight = new Flight(FactoryTimeout) { Waiters = 1 }; + _inFlight[registration] = flight; + owner = true; + } } - // Each caller observes its OWN token while waiting, so a joiner can - // abandon a slow flight without ending it for anybody else. - var produced = (T)(await flight.Task.Value.WaitAsync(cancellationToken) - .ConfigureAwait(false))!; - - // A Set or a Remove landing while the factory ran marks the flight - // superseded. Storing anyway would resurrect a value the caller - // already replaced or deleted — eager invalidation silently lost for - // a full TTL, which is the one thing a cache must not do quietly. - if (!flight.Superseded) + if (waitForTerminal) { - Store(key, produced, options, clock.UtcNow); - - // Re-checked after the write, because the check above and the - // write are two steps rather than one: a write landing between - // them would otherwise be overwritten by this stale result. - // Evicting is the safe resolution — the next reader takes a miss - // and goes to the source of truth, and a miss is never an error, - // whereas a stale value presented as fresh is. - if (flight.Superseded) - { - _entries.TryRemove(key, out _); - } + await WaitForTerminalThenRetryAsync(flight, cancellationToken) + .ConfigureAwait(false); + continue; } - return produced; - } - finally - { - // Unregistered when the last caller is DONE, not when the factory - // finishes. Measured on two earlier versions, each of which fixed - // one half and broke the other: - // - // - Unregistering on the caller's exit meant a joiner that - // cancelled removed the shared registration while the factory - // was still running, so the next arrival started a second - // concurrent run — the stampede this method exists to prevent, - // reintroduced by its own cleanup. - // - Unregistering on the factory's completion instead meant the - // flight was already gone by the time the caller stored, so - // `Supersede` had nothing left to mark and a write landing in - // that window was silently overwritten. - // - // The registration is what `Supersede` reaches, so it has to outlive - // the store, and it has to outlive every other caller's store too. - // - // Retiring turns only on the caller count, NOT on the factory having - // finished. An earlier version also required IsCompleted, which meant - // a factory that never completes — no deadline exists anywhere, since - // the flight deliberately runs on CancellationToken.None so one - // caller cannot cancel it for the rest — left its registration in - // place forever. `_inFlight` has no ceiling, and worse, every later - // caller JOINED that dead flight: the key never ran a factory again - // for the life of the process, once per generic instantiation. With - // no callers left there is nothing to stampede, so a fresh arrival - // starting its own flight is right. - if (Interlocked.Decrement(ref flight.Waiters) == 0) + if (owner) { - Retire(registration, flight); + _ = RunFactoryAsync(registration, flight, key, factory, ttl); } - } - } - /// - /// Unregisters a flight once it has finished and no caller is still using - /// it. Value-comparing, so a later flight for the same key is never removed - /// by an earlier one's cleanup. - /// - private void Retire((string Key, Type Type) registration, Flight flight) - { - if (Volatile.Read(ref flight.Waiters) != 0) - { - return; + try + { + return (T)(await flight.Completion.WaitAsync(cancellationToken) + .ConfigureAwait(false))!; + } + finally + { + ReleaseWaiter(registration, flight); + } } - - _inFlight.TryRemove(new KeyValuePair<(string, Type), Flight>(registration, flight)); } public Task SetAsync( @@ -313,11 +211,17 @@ public Task SetAsync( CancellationToken cancellationToken = default) { CacheKey.EnsureValid(key); + cancellationToken.ThrowIfCancellationRequested(); - var now = clock.UtcNow; + var now = _clock.UtcNow; + var ttl = ValidateOptions(options, now); Sweep(now); - Supersede(key); - Store(key, value, options, now); + + lock (KeyGate(key)) + { + SupersedeUnderKeyGate(key); + Store(key, value, ttl, now); + } return Task.CompletedTask; } @@ -325,137 +229,198 @@ public Task SetAsync( public Task RemoveAsync(string key, CancellationToken cancellationToken = default) { CacheKey.EnsureValid(key); + cancellationToken.ThrowIfCancellationRequested(); + + lock (KeyGate(key)) + { + SupersedeUnderKeyGate(key); + if (_entries.TryRemove(key, out _)) + { + _evictions.Add(1, CacheNameAndReasonTags(key, "explicit")); + } + } - Supersede(key); - _entries.TryRemove(key, out _); return Task.CompletedTask; } - /// - /// Marks every in-flight factory for as superseded, - /// so none of them writes over the change that just landed. - /// - /// - /// The flag lives on the flight and dies with it. An earlier version kept a - /// per-key version counter in a dictionary of its own, which was never - /// swept: measured at 50,000 distinct keys, _entries held its 10,000 - /// ceiling while that map held all 50,000 — an unbounded structure behind a - /// bounded one, reachable by ordinary per-entity keys rather than by misuse. - /// - private void Supersede(string key) + private async Task RunFactoryAsync( + (string Key, Type Type) registration, + Flight flight, + string key, + Func> factory, + TimeSpan ttl) { - foreach (var pair in _inFlight) + var started = Stopwatch.GetTimestamp(); + var outcome = "success"; + + try { - if (string.Equals(pair.Key.Key, key, StringComparison.Ordinal)) + var produced = await factory(flight.FactoryToken).ConfigureAwait(false); + + lock (KeyGate(key)) { - pair.Value.Superseded = true; + if (!flight.Superseded && !flight.Abandoned) + { + Store(key, produced, ttl, _clock.UtcNow); + } + + _inFlight.TryRemove( + new KeyValuePair<(string Key, Type Type), Flight>(registration, flight)); } + + flight.TrySetResult(produced); + } + catch (OperationCanceledException) when (flight.FactoryToken.IsCancellationRequested) + { + outcome = "cancelled"; + RetireTerminalFlight(registration, flight, key); + flight.TrySetCanceled(); + } + catch (Exception exception) + { + outcome = "faulted"; + RetireTerminalFlight(registration, flight, key); + flight.TrySetException(exception); + } + finally + { + _factoryDuration.Record( + Stopwatch.GetElapsedTime(started).TotalSeconds, + CacheNameAndOutcomeTags(key, outcome)); + flight.Dispose(); } } - private void Store(string key, T value, CacheOptions? options, DateTimeOffset now) + private void RetireTerminalFlight( + (string Key, Type Type) registration, Flight flight, string key) { - // L2Ttl is read and ignored: there is no second layer here. Carrying it - // means a caller written today does not change when the Valkey adapter - // gives it a meaning. - var ttl = options?.L1Ttl ?? DefaultTtl; - var entry = new Entry(value, now + ttl, Interlocked.Increment(ref _sequence)); - - // TryAdd first, so a write that GROWS the map is distinguishable from - // one that replaces an entry. Only the former can cross the ceiling, so - // only the former pays for checking it. - if (!_entries.TryAdd(key, entry)) + lock (KeyGate(key)) { - _entries[key] = entry; - return; + _inFlight.TryRemove( + new KeyValuePair<(string Key, Type Type), Flight>(registration, flight)); } + } - if (_entries.Count > MaxEntries) + private void ReleaseWaiter((string Key, Type Type) registration, Flight flight) + { + lock (KeyGate(registration.Key)) { - Trim(now); + flight.Waiters--; + if (flight.Waiters == 0 && !flight.Completion.IsCompleted) + { + flight.Abandoned = true; + flight.CancelFactory(); + } } } - /// - /// Evicts down to , expired entries first and then - /// the oldest live ones. - /// - /// - /// Evicting a live entry is allowed here — a miss costs a round trip — which - /// is what makes this bound simpler than InMemoryIdempotencyStore's, - /// where an entry is a promise and eviction would let an operation run twice. - /// - private void Trim(DateTimeOffset now) + private static async Task WaitForTerminalThenRetryAsync( + Flight flight, CancellationToken cancellationToken) { - // One trimmer at a time. Concurrent writers all cross the ceiling - // together, and without this each of them snapshots and sorts the whole - // map to do work the first one is already doing. - if (Interlocked.CompareExchange(ref _trimming, 1, 0) != 0) + try { - return; + await flight.Completion.WaitAsync(cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; } + catch (Exception) + { + // The caller did not join this superseded/abandoned result. It only + // waits for terminality so its replacement cannot overlap; the next + // loop iteration performs the fresh read/factory attempt. + } + } - try + private bool TryRead(string key, DateTimeOffset now, out T? value) + { + if (_entries.TryGetValue(key, out var entry) + && entry.IsFresh(now) + && entry.Value is T typed) { - // ToArray(), NOT LINQ over `_entries` directly. Measured: an - // `_entries.OrderBy(...)` buffers the LIVE dictionary through - // ICollection.CopyTo after reading Count, and those two steps are not - // atomic — if the map grew in between, CopyTo throws - // ArgumentException; if it shrank, the tail of the buffer keeps - // default(KeyValuePair) whose Value is null and the sort key - // dereferences it. Both escaped Trim into SetAsync and - // GetOrSetAsync, so a component whose contract says it may no-op at - // any time was instead failing the caller's request: with two - // concurrent writers at the ceiling, 4.1% of ordinary writes threw; - // with four, 15.5%. ToArray takes every bucket lock and hands back a - // consistent snapshot — measured at 0 failures over the same probe. - var snapshot = _entries.ToArray(); - var live = new List>(snapshot.Length); - - foreach (var pair in snapshot) + value = typed; + return true; + } + + value = default; + return false; + } + + private void SupersedeUnderKeyGate(string key) + { + foreach (var pair in _inFlight) + { + if (string.Equals(pair.Key.Key, key, StringComparison.Ordinal)) { - if (pair.Value.IsFresh(now)) - { - live.Add(pair); - } - else - { - // Value-comparing: between the snapshot and this line another - // thread may have written a fresh entry at the same key. - _entries.TryRemove(pair); - } + pair.Value.Superseded = true; } + } + } - var excess = live.Count - TrimTarget; - if (excess <= 0) + private void Store(string key, T value, TimeSpan ttl, DateTimeOffset now) + { + lock (_capacityGate) + { + var isNew = !_entries.ContainsKey(key); + _entries[key] = new Entry(value, now + ttl, Interlocked.Increment(ref _sequence)); + _stores.Add(1, CacheNameTag(key)); + + if (isNew && _entries.Count > MaxEntries) { - return; + Trim(now); } + } + } - live.Sort(static (left, right) => left.Value.Sequence.CompareTo(right.Value.Sequence)); - - for (var i = 0; i < excess; i++) + /// + /// Evicts down to , removing expired entries before + /// the oldest live entries. + /// + private void Trim(DateTimeOffset now) + { + // Store serializes admission through _capacityGate, so this snapshot + // cannot be invalidated by a concurrent replacement. Remove may shrink + // it, which only reduces the work required. + var snapshot = _entries.ToArray(); + foreach (var pair in snapshot) + { + if (!pair.Value.IsFresh(now) && _entries.TryRemove(pair.Key, out _)) { - _entries.TryRemove(live[i]); + _evictions.Add(1, CacheNameAndReasonTags(pair.Key, "expired")); } } - finally + + var excess = _entries.Count - TrimTarget; + if (excess <= 0) { - Volatile.Write(ref _trimming, 0); + return; + } + + var live = _entries.ToArray(); + Array.Sort( + live, + static (left, right) => left.Value.Sequence.CompareTo(right.Value.Sequence)); + + for (var index = 0; index < live.Length && excess > 0; index++) + { + if (_entries.TryRemove(live[index].Key, out _)) + { + excess--; + _evictions.Add(1, CacheNameAndReasonTags(live[index].Key, "capacity")); + } } } /// - /// Drops expired entries, and — only when the map is over its bound — the - /// oldest live ones. At most once per . + /// Removes expired entries only. Capacity eviction is owned by + /// on admission. /// private void Sweep(DateTimeOffset now) { var ticks = now.UtcTicks; var last = Interlocked.Read(ref _lastSweepTicks); - // A clock that steps backwards would otherwise wedge the sweep until - // real time caught up. if (ticks >= last && ticks - last < SweepInterval.Ticks) { return; @@ -466,16 +431,73 @@ private void Sweep(DateTimeOffset now) return; } - foreach (var pair in _entries) + lock (_capacityGate) { - if (pair.Value.IsFresh(now)) + foreach (var pair in _entries) { - continue; + if (!pair.Value.IsFresh(now) && _entries.TryRemove(pair)) + { + _evictions.Add(1, CacheNameAndReasonTags(pair.Key, "expired")); + } } + } + } + + private static TimeSpan ValidateOptions(CacheOptions? options, DateTimeOffset now) + { + var l1 = options?.L1Ttl ?? DefaultTtl; + ValidateTtl(l1, now, nameof(CacheOptions.L1Ttl)); - // Value-comparing, for the same reason Trim's pass is. - _entries.TryRemove(pair); + if (options?.L2Ttl is { } l2) + { + ValidateTtl(l2, now, nameof(CacheOptions.L2Ttl)); } + + return l1; + } + + private static void ValidateTtl(TimeSpan ttl, DateTimeOffset now, string parameterName) + { + if (ttl <= TimeSpan.Zero || ttl > DateTimeOffset.MaxValue - now) + { + throw new ArgumentOutOfRangeException( + parameterName, + ttl, + "A cache TTL must be positive and representable from the current instant."); + } + } + + private object KeyGate(string key) => + _keyGates[(StringComparer.Ordinal.GetHashCode(key) & int.MaxValue) % KeyGateCount]; + + private static KeyValuePair CacheNameTag(string key) => + new("cache.name", CacheName(key)); + + private static KeyValuePair[] CacheNameAndReasonTags( + string key, string reason) => + [CacheNameTag(key), new("reason", reason)]; + + private static KeyValuePair[] CacheNameAndOutcomeTags( + string key, string outcome) => + [CacheNameTag(key), new("outcome", outcome)]; + + private static string CacheName(string key) + { + var segments = key.Split(CacheKey.Separator); + if (segments[0].Equals(CacheKey.PlatformTenant, StringComparison.Ordinal)) + { + return "hub:host-map"; + } + + var moduleIndex = segments.Length >= 4 && Guid.TryParse(segments[1], out _) ? 2 : 1; + return (segments[moduleIndex], segments[moduleIndex + 1]) switch + { + ("hub", "entitlement") => "hub:entitlement", + ("identity", "permissions") => "identity:permissions", + ("tenancy", "feature-flags") => "tenancy:feature-flags", + ("tenancy", "settings") => "tenancy:settings", + _ => "other", + }; } private sealed record Entry(object? Value, DateTimeOffset ExpiresAt, long Sequence) @@ -483,14 +505,35 @@ private sealed record Entry(object? Value, DateTimeOffset ExpiresAt, long Sequen public bool IsFresh(DateTimeOffset now) => now < ExpiresAt; } - /// One shared factory run, and whether a write has superseded it. - private sealed class Flight(Lazy> task) + private sealed class Flight : IDisposable { - public Lazy> Task { get; } = task; + private readonly CancellationTokenSource _factoryCancellation = new(); + private readonly TaskCompletionSource _completion = + new(TaskCreationOptions.RunContinuationsAsynchronously); - public volatile bool Superseded; + public Flight(TimeSpan timeout) + { + _factoryCancellation.CancelAfter(timeout); + + // A fault can arrive after every caller has left. Observe it here so + // it never surfaces later as an uncorrelated process-wide event. + _ = _completion.Task.ContinueWith( + static completed => _ = completed.Exception, + CancellationToken.None, + TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + } - /// Callers still using this flight. A field, for Interlocked. - public int Waiters; + public Task Completion => _completion.Task; + public CancellationToken FactoryToken => _factoryCancellation.Token; + public int Waiters { get; set; } + public bool Abandoned { get; set; } + public bool Superseded { get; set; } + + public void CancelFactory() => _factoryCancellation.Cancel(); + public void TrySetResult(object? value) => _completion.TrySetResult(value); + public void TrySetCanceled() => _completion.TrySetCanceled(_factoryCancellation.Token); + public void TrySetException(Exception exception) => _completion.TrySetException(exception); + public void Dispose() => _factoryCancellation.Dispose(); } } diff --git a/backend/src/LearnStack.Infrastructure/Idempotency/InMemoryIdempotencyStore.cs b/backend/src/LearnStack.Infrastructure/Idempotency/InMemoryIdempotencyStore.cs index cf2ea9b8..ea9be4ba 100644 --- a/backend/src/LearnStack.Infrastructure/Idempotency/InMemoryIdempotencyStore.cs +++ b/backend/src/LearnStack.Infrastructure/Idempotency/InMemoryIdempotencyStore.cs @@ -24,7 +24,7 @@ namespace LearnStack.Infrastructure.Idempotency; /// /// The same limitation is why ICacheService exists as a port and why /// RemoveByPrefixAsync was removed from it in -/// ADR-0014 Amendment 2: +/// ADR-0038: /// an instance-local structure cannot honour a contract phrased as if it were /// shared. Saying so here keeps the next reader from mistaking this for a /// finished component. diff --git a/backend/src/LearnStack.Infrastructure/Messaging/InProcessEventBus.cs b/backend/src/LearnStack.Infrastructure/Messaging/InProcessEventBus.cs index 170e1c82..fd416e66 100644 --- a/backend/src/LearnStack.Infrastructure/Messaging/InProcessEventBus.cs +++ b/backend/src/LearnStack.Infrastructure/Messaging/InProcessEventBus.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using System.Reflection; using System.Runtime.ExceptionServices; using LearnStack.SharedKernel.Messaging; @@ -7,63 +8,33 @@ namespace LearnStack.Infrastructure.Messaging; -/// -/// The default : a first-class transport, not a stub. -/// +/// The process-local transport. /// -/// -/// It carries the same four obligations as the durable path, and each one is -/// here because a development transport that dropped it would be a development -/// path where the production behaviour is never exercised: -/// -/// -/// the same contract, so no -/// consumer needs a second implementation and the one running in CI is the one -/// that runs in production; -/// the same consumer-side deduplication — the handler calls -/// IInboxGuard itself, exactly as it does behind a broker, because a -/// transport that never delivers a duplicate never surfaces the most common -/// integration-event defect. That seam lands in Phase 02b; today the contract is -/// shaped for it and nothing else; -/// the same tenant-context restoration, into the scope the handler -/// resolves from, so Row Level Security and the query filters are exercised on -/// the consumer side; -/// the same per-partition-key ordering, because an ordering assumption -/// that holds only in one process is discovered in production. -/// -/// -/// It also carries the same failure isolation. Poison-message containment -/// is per subscription: one module's broken handler must not deny another module -/// the event. Every handler is attempted, and the failures are reported -/// together. -/// -/// -/// What it genuinely does not provide — and therefore the trigger for the Dapr -/// adapter in -/// Phase 11 -/// per ADR-0035 -/// — is delivery to a second process, broker-side retention and replay. -/// +/// It preserves the durable transport's handler contract, tenant restoration, +/// per-partition ordering and per-subscription failure isolation. Each +/// subscription is constructed once in its own async scope. Dapr becomes the +/// adapter when ADR-0035's multi-process/replay trigger is met. /// public sealed partial class InProcessEventBus( IServiceScopeFactory scopeFactory, ITenantContextAccessor tenantAccessor, IPartitionSerializer partitions, + IntegrationEventHandlerRegistry handlers, ILogger logger) : IEventBus { + public const string ActivitySourceName = "learnstack.messaging"; + private const string HandleMethodName = nameof(IIntegrationEventHandler.HandleAsync); + private static readonly ActivitySource ActivitySource = new(ActivitySourceName); + public Task PublishAsync( IntegrationEventEnvelope envelope, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(envelope); - // Checked before anything is queued, so a publish on an already-cancelled - // token behaves the way a broker-backed one would — it fails rather than - // dispatching. Returned as a faulted task rather than thrown inline: a - // fire-and-forget call site must not crash its caller synchronously. if (cancellationToken.IsCancellationRequested) { return Task.FromCanceled(cancellationToken); @@ -75,161 +46,141 @@ public Task PublishAsync( } private async Task DispatchAsync( - IntegrationEventEnvelope envelope, CancellationToken cancellationToken) + IntegrationEventEnvelope envelope, + CancellationToken cancellationToken) { - // By RUNTIME type. The event is declared as the base interface here, so a - // closed generic over its static type would resolve - // IIntegrationEventHandler — which no concrete - // consumer implements — and the publish would reach zero handlers and - // report success. - var contract = typeof(IIntegrationEventHandler<>) - .MakeGenericType(envelope.Event.GetType()); - var context = EventTenantContext.FromEnvelope(envelope); - - // Set for the WHOLE dispatch, before anything resolves a handler. - // Counting resolves them too — the container materialises the array to - // count it — so with the context set only inside the delivery loop, every - // handler constructor ran under the publisher's tenant instead of the - // event's. A constructor that injects ITenantContext would have captured - // the wrong one and used it for the rest of the handler's life. var previous = tenantAccessor.Current; - tenantAccessor.Current = context; - try - { - await DispatchUnderContextAsync(contract, envelope, cancellationToken) - .ConfigureAwait(false); - } - finally - { - tenantAccessor.Current = previous; - } - } + // Established before even subscription lookup. Any future registry or + // resolution work that observes ITenantContext therefore sees the event, + // never the publisher that happened to invoke this transport. + tenantAccessor.Current = EventTenantContext.FromEnvelope(envelope); - private async Task DispatchUnderContextAsync( - Type contract, IntegrationEventEnvelope envelope, CancellationToken cancellationToken) - { - var handle = contract.GetMethod(HandleMethodName)!; - var count = HandlerCount(contract, envelope, out var constructionFailure); - - if (constructionFailure is not null) + try { - // A handler whose CONSTRUCTOR throws takes every sibling with it, - // and there is nothing this class can do about that: the container - // materialises the whole array before returning any element, so the - // failure lands before the per-handler loop that provides isolation - // can start. Measured — a healthy handler registered alongside a - // throwing one never had HandleAsync called at all. It is said - // plainly here rather than surfacing as a bare constructor exception - // from a transport the caller did not know it was in. - throw new InvalidOperationException( - $"An integration-event handler for {envelope.Event.GetType().Name} failed to " - + "construct, so no handler for that event could run. Handler construction " - + "happens before per-handler isolation and cannot be contained.", - constructionFailure); - } + var subscriptions = handlers.For(envelope.Event.GetType()); + if (subscriptions.Count == 0) + { + ReachedNoHandler(logger, envelope.Event.GetType().Name, envelope.Event.EventId); + return; + } - if (count == 0) - { - // Not an error — an event nobody consumes is legitimate — but silence - // here is indistinguishable from a handler registered for a type the - // container will never match, so it is said out loud once. - ReachedNoHandler(logger, envelope.Event.GetType().Name, envelope.Event.EventId); - return; - } + _ = ActivityContext.TryParse( + envelope.CorrelationId, + traceState: null, + out var parentContext); - List? failures = null; + List? failures = null; - for (var index = 0; index < count; index++) - { - try + for (var index = 0; index < subscriptions.Count; index++) { - await DeliverAsync(contract, handle, index, envelope, cancellationToken) - .ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + var subscription = subscriptions[index]; + + try + { + await DeliverAsync( + subscription, + envelope, + parentContext, + cancellationToken) + .ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // Publish cancellation is control flow, not a poison + // subscription. Stop immediately so later handlers do not + // start business work during shutdown. + throw; + } + catch (Exception exception) + { + HandlerFailed( + logger, + envelope.Event.GetType().Name, + envelope.Event.EventId, + subscription.HandlerType.Name, + envelope.Event.TenantId, + envelope.PartitionKey, + exception); + (failures ??= []).Add(exception); + } } - catch (Exception ex) - { - // Collected, not rethrown here. Poison-message containment is per - // subscription: letting the first fault escape the loop would let - // one module's broken handler deny every other module the event, - // with no retry and no dead-letter to show for it. - HandlerFailed( - logger, - envelope.Event.GetType().Name, - envelope.Event.EventId, - index, - envelope.Event.TenantId, - envelope.PartitionKey, - ex); - (failures ??= []).Add(ex); + if (failures is { Count: 1 }) + { + ExceptionDispatchInfo.Capture(failures[0]).Throw(); } - } - if (failures is { Count: 1 }) - { - ExceptionDispatchInfo.Capture(failures[0]).Throw(); + if (failures is { Count: > 1 }) + { + throw new AggregateException( + $"{failures.Count} handlers failed for {envelope.Event.GetType().Name}.", + failures); + } } - - if (failures is { Count: > 1 }) + finally { - throw new AggregateException( - $"{failures.Count} handlers failed for {envelope.Event.GetType().Name}.", - failures); + tenantAccessor.Current = previous; } } private async Task DeliverAsync( - Type contract, - MethodInfo handle, - int index, + IntegrationEventSubscription subscription, IntegrationEventEnvelope envelope, + ActivityContext parentContext, CancellationToken cancellationToken) { - // One scope per HANDLER, not one per event. Under a broker each - // subscription gets its own; sharing one here would hand two modules' - // consumers the same DbContext and the same unit of work, so a failed - // handler's dirty state would cross a module boundary the architecture - // otherwise enforces hard. - // - // Selected by index rather than by concrete type, because a handler is - // registered against the CONTRACT — its own type is not a service, and - // asking the container for it fails. - // - // The cost, stated plainly: the container materialises the whole array - // for each scope, so N handlers for one event means N constructions per - // scope and N scopes — measured, twelve constructions for three - // handlers. Only one HandleAsync runs per handler, so business logic is - // never duplicated; what repeats is construction. That is affordable - // exactly as long as a handler's constructor does nothing but assign - // fields — which is the DI convention anyway, and is now a requirement - // rather than a habit. A constructor that opens a connection, emits a - // metric or writes a log line will do it N+1 times per delivery. - await using var scope = scopeFactory.CreateAsyncScope(); + var previous = tenantAccessor.Current; + tenantAccessor.Current = EventTenantContext.FromEnvelope(envelope, subscription.ModuleName); - // The context is already set for the whole dispatch, and the scope - // resolves ITenantContext from that same accessor — the composition root - // binds it that way. Setting only the ambient accessor and not binding - // the scoped one left a handler injecting ITenantContext unresolved, and - // one sending a MediatR command short-circuited by TenantContextBehavior - // before its business logic ran. try { - var handler = scope.ServiceProvider.GetServices(contract).ElementAt(index)!; + using var activity = ActivitySource.StartActivity( + $"{envelope.Topic} process", + ActivityKind.Consumer, + parentContext, + tags: + [ + new("messaging.system", "in-process"), + new("messaging.destination.name", envelope.Topic), + new("messaging.operation.type", "process"), + new("learnstack.module", subscription.ModuleName), + ]); + + await using var scope = scopeFactory.CreateAsyncScope(); + + object handler; + try + { + handler = scope.ServiceProvider.GetRequiredService(subscription.HandlerType); + } + catch (Exception exception) + { + throw new InvalidOperationException( + $"Integration-event handler {subscription.HandlerType.FullName} " + + "failed to construct.", + exception); + } + var handle = subscription.ContractType.GetMethod(HandleMethodName)!; Task delivery; try { delivery = (Task)handle.Invoke(handler, [envelope.Event, cancellationToken])!; } + catch (TargetInvocationException wrapped) + when (wrapped.InnerException is OperationCanceledException cancellation + && !cancellationToken.IsCancellationRequested) + { + throw new InvalidOperationException( + "An integration-event handler was cancelled by a token other than " + + "the publish token.", + cancellation); + } catch (TargetInvocationException wrapped) when (wrapped.InnerException is not null) { - // A handler that throws before its first await throws out of - // Invoke, which wraps it. Unwrapped and rethrown with its stack - // intact, because a consumer and the error pipeline both key on - // the exception type: a TargetInvocationException would tell them - // the transport failed when the handler did. ExceptionDispatchInfo.Capture(wrapped.InnerException).Throw(); throw; } @@ -240,37 +191,22 @@ private async Task DeliverAsync( $"{handler.GetType().FullName} returned a null Task from {HandleMethodName}."); } - await delivery.ConfigureAwait(false); - } - catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested) - { - // A handler that observed some OTHER cancellation must not make the - // publish look cancelled: an outbox processor would read that as - // "we are shutting down, retry later" and silently swallow a handler - // that ran and gave up. - throw new InvalidOperationException( - "An integration-event handler was cancelled by a token other than the publish token.", - ex); - } - } - - private int HandlerCount( - Type contract, IntegrationEventEnvelope envelope, out Exception? constructionFailure) - { - constructionFailure = null; - - using var scope = scopeFactory.CreateScope(); - - try - { - return scope.ServiceProvider.GetServices(contract).Count(); + try + { + await delivery.ConfigureAwait(false); + } + catch (OperationCanceledException exception) + when (!cancellationToken.IsCancellationRequested) + { + throw new InvalidOperationException( + "An integration-event handler was cancelled by a token other than " + + "the publish token.", + exception); + } } - catch (Exception ex) + finally { - HandlerConstructionFailed( - logger, envelope.Event.GetType().Name, envelope.Event.EventId, ex); - constructionFailure = ex; - return 0; + tenantAccessor.Current = previous; } } @@ -278,29 +214,22 @@ private int HandlerCount( EventId = 1, Level = LogLevel.Error, Message = "Integration event {EventType} ({IntegrationEventId}) failed in handler " - + "#{HandlerIndex} for tenant {TenantId} on partition {PartitionKey}")] + + "{HandlerType} for tenant {TenantId} on partition {PartitionKey}")] private static partial void HandlerFailed( ILogger logger, string eventType, Guid integrationEventId, - int handlerIndex, + string handlerType, Guid tenantId, string partitionKey, Exception exception); - [LoggerMessage( - EventId = 3, - Level = LogLevel.Error, - Message = "A handler for integration event {EventType} ({IntegrationEventId}) failed " - + "to construct; no handler for that event ran")] - private static partial void HandlerConstructionFailed( - ILogger logger, string eventType, Guid integrationEventId, Exception exception); - [LoggerMessage( EventId = 2, Level = LogLevel.Debug, Message = "Integration event {EventType} ({IntegrationEventId}) reached no handler")] private static partial void ReachedNoHandler( - ILogger logger, string eventType, Guid integrationEventId); - + ILogger logger, + string eventType, + Guid integrationEventId); } diff --git a/backend/src/LearnStack.Infrastructure/Messaging/IntegrationEventHandlerRegistry.cs b/backend/src/LearnStack.Infrastructure/Messaging/IntegrationEventHandlerRegistry.cs new file mode 100644 index 00000000..74c4f322 --- /dev/null +++ b/backend/src/LearnStack.Infrastructure/Messaging/IntegrationEventHandlerRegistry.cs @@ -0,0 +1,122 @@ +using System.Reflection; +using LearnStack.SharedKernel.Messaging; +using Microsoft.Extensions.DependencyInjection; + +namespace LearnStack.Infrastructure.Messaging; + +/// +/// Immutable, construction-free subscription metadata for the in-process transport. +/// +/// +/// Enumerating an IEnumerable<IIntegrationEventHandler<T>> constructs +/// every handler graph. This registry lets the transport select subscriptions +/// without resolving any handler, then construct exactly one concrete handler in +/// that subscription's own async scope. +/// +public sealed class IntegrationEventHandlerRegistry +{ + private readonly IReadOnlyDictionary _subscriptions; + + private IntegrationEventHandlerRegistry(IEnumerable subscriptions) + { + _subscriptions = subscriptions + .GroupBy(subscription => subscription.EventType) + .ToDictionary( + group => group.Key, + group => group.ToArray()); + } + + /// Discovers concrete handlers in the supplied composition-root assemblies. + public static IntegrationEventHandlerRegistry Discover(params Assembly[] assemblies) + { + ArgumentNullException.ThrowIfNull(assemblies); + + var subscriptions = assemblies + .Distinct() + .SelectMany(static assembly => assembly.DefinedTypes) + .Where(static type => type is { IsAbstract: false, IsInterface: false }) + .SelectMany(static handlerType => HandlerContracts(handlerType.AsType()) + .Select(contract => CreateSubscription(handlerType.AsType(), contract))); + + return new IntegrationEventHandlerRegistry(subscriptions); + } + + /// + /// Builds metadata from ordinary Microsoft DI handler registrations. + /// Intended for composition tests that assemble subscriptions directly. + /// + internal static IntegrationEventHandlerRegistry FromServiceDescriptors( + IEnumerable descriptors) + { + ArgumentNullException.ThrowIfNull(descriptors); + + var subscriptions = descriptors + .Where(static descriptor => IsHandlerContract(descriptor.ServiceType)) + .Select(descriptor => + { + if (descriptor.ImplementationType is null) + { + throw new InvalidOperationException( + $"The {descriptor.ServiceType.Name} registration must name a concrete " + + "implementation type so dispatch can isolate its construction."); + } + + return CreateSubscription(descriptor.ImplementationType, descriptor.ServiceType); + }); + + return new IntegrationEventHandlerRegistry(subscriptions); + } + + /// All subscriptions, used once by the composition root for DI registration. + public IEnumerable All => + _subscriptions.Values.SelectMany(static subscriptions => subscriptions); + + /// Subscriptions for exactly one concrete event type. + public IReadOnlyList For(Type eventType) + { + ArgumentNullException.ThrowIfNull(eventType); + return _subscriptions.TryGetValue(eventType, out var subscriptions) + ? subscriptions + : []; + } + + private static IEnumerable HandlerContracts(Type handlerType) => + handlerType.GetInterfaces().Where(IsHandlerContract); + + private static bool IsHandlerContract(Type type) => + type.IsGenericType + && type.GetGenericTypeDefinition() == typeof(IIntegrationEventHandler<>); + + private static IntegrationEventSubscription CreateSubscription( + Type handlerType, Type contract) + { + var eventType = contract.GetGenericArguments()[0]; + return new IntegrationEventSubscription( + eventType, + handlerType, + contract, + ModuleName(handlerType, eventType)); + } + + private static string ModuleName(Type handlerType, Type eventType) + { + var namespaceParts = handlerType.Namespace?.Split('.') ?? []; + var modulesIndex = Array.FindIndex( + namespaceParts, + static part => part.Equals("Modules", StringComparison.Ordinal)); + + if (modulesIndex >= 0 && modulesIndex + 1 < namespaceParts.Length) + { + return namespaceParts[modulesIndex + 1].ToLowerInvariant(); + } + + var eventNamespaceParts = eventType.Namespace?.Split('.') ?? []; + var eventModulesIndex = Array.FindIndex( + eventNamespaceParts, + static part => part.Equals("Modules", StringComparison.Ordinal)); + + return eventModulesIndex >= 0 && eventModulesIndex + 1 < eventNamespaceParts.Length + ? eventNamespaceParts[eventModulesIndex + 1].ToLowerInvariant() + : "unknown"; + } +} diff --git a/backend/src/LearnStack.Infrastructure/Messaging/IntegrationEventSubscription.cs b/backend/src/LearnStack.Infrastructure/Messaging/IntegrationEventSubscription.cs new file mode 100644 index 00000000..5bd44584 --- /dev/null +++ b/backend/src/LearnStack.Infrastructure/Messaging/IntegrationEventSubscription.cs @@ -0,0 +1,8 @@ +namespace LearnStack.Infrastructure.Messaging; + +/// One concrete handler subscription and its stable module identity. +public sealed record IntegrationEventSubscription( + Type EventType, + Type HandlerType, + Type ContractType, + string ModuleName); diff --git a/backend/src/LearnStack.Infrastructure/Properties/AssemblyInfo.cs b/backend/src/LearnStack.Infrastructure/Properties/AssemblyInfo.cs new file mode 100644 index 00000000..44fe94bc --- /dev/null +++ b/backend/src/LearnStack.Infrastructure/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("LearnStack.Tests.Unit")] diff --git a/backend/src/LearnStack.SharedKernel/Caching/CacheKey.cs b/backend/src/LearnStack.SharedKernel/Caching/CacheKey.cs index 364bce4e..a7df1614 100644 --- a/backend/src/LearnStack.SharedKernel/Caching/CacheKey.cs +++ b/backend/src/LearnStack.SharedKernel/Caching/CacheKey.cs @@ -17,9 +17,9 @@ namespace LearnStack.SharedKernel.Caching; /// call site to remember. /// /// -/// A platform-wide value uses the sentinel rather -/// than omitting the segment. "No tenant" and "every tenant" then look different -/// in a key dump, and the rule stays one rule. +/// The only platform-wide family is the Hub host map, composed by +/// . A generic platform factory would let an ordinary +/// tenant-owned family accidentally collapse every tenant into one cache bucket. /// /// public static class CacheKey @@ -65,23 +65,19 @@ public static string ForOrganization( module, logicalName); - /// Composes a key for a platform-wide value. + /// + /// Composes the one platform-wide key family: a normalized host to tenant mapping. + /// /// - /// The logical name may be several parts, and that is not a convenience. - /// Standards 20 mandates key families whose logical name has internal - /// structure — platform:hub:host-map:{host} and - /// {tenant_id}:identity:permissions:{session_id} — and a single-string - /// factory could not produce either of them, because a caller joining the - /// parts itself would put a separator inside one segment and - /// rejects exactly that. The guard would then have - /// admitted a shape no factory could emit, so the two families Standards 20 - /// singles out — including the host lookup, which sits on the anonymous - /// page-load path — would have been hand-built past the only place - /// , non-canonical rendering and separator injection - /// are checked. + /// The host has already passed the trusted-input normalization described by + /// ADR-0036. This second check prevents an unnormalized spelling from creating + /// a parallel cache entry and keeps IP literals out of the custom-domain map. /// - public static string ForPlatform(string module, params string[] logicalName) => - Compose(PlatformTenant, module, logicalName); + public static string ForHostMapping(string normalizedHost) + { + EnsureNormalizedHost(normalizedHost, nameof(normalizedHost)); + return Compose([PlatformTenant, "hub", "host-map", normalizedHost]); + } /// /// Throws when a key does not carry three non-empty segments. @@ -101,8 +97,7 @@ public static void EnsureValid(string key) && !segments.Any(string.IsNullOrWhiteSpace) && IsTenantSegment(segments[0]) && segments.All(IsCanonicalIfIdentifier) - && !(segments[0].Equals(PlatformTenant, StringComparison.Ordinal) - && LooksLikeIdentifier(segments[1])); + && IsAllowedPlatformFamily(segments); if (!wellFormed) { @@ -110,12 +105,48 @@ public static void EnsureValid(string key) $"'{key}' is not a cache key. Standards 20 fixes the shape as " + $"'{{tenant_id}}{Separator}{{module}}{Separator}{{logical-name}}', and the " + $"tenant segment is mandatory even for a platform-wide value — use the " - + $"'{PlatformTenant}' sentinel rather than omitting it. A key without a " - + "tenant is a key two tenants can both compute.", + + $"'{PlatformTenant}' sentinel rather than omitting it. The sentinel is " + + "reserved for 'platform:hub:host-map:{normalized-host}'; every other " + + "family must carry a real tenant id.", nameof(key)); } } + private static bool IsAllowedPlatformFamily(string[] segments) + { + if (!segments[0].Equals(PlatformTenant, StringComparison.Ordinal)) + { + return true; + } + + if (segments.Length != 4 + || !segments[1].Equals("hub", StringComparison.Ordinal) + || !segments[2].Equals("host-map", StringComparison.Ordinal)) + { + return false; + } + + return IsNormalizedHost(segments[3]); + } + + private static void EnsureNormalizedHost(string normalizedHost, string parameterName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(normalizedHost); + + if (!IsNormalizedHost(normalizedHost)) + { + throw new ArgumentException( + "A host-map cache key requires a lower-case, port-free normalized DNS host.", + parameterName); + } + } + + private static bool IsNormalizedHost(string host) => + host.Equals(host.Trim(), StringComparison.Ordinal) + && host.Equals(host.ToLowerInvariant(), StringComparison.Ordinal) + && !host.EndsWith('.') + && Uri.CheckHostName(host) == UriHostNameType.Dns; + /// /// Whether a segment that looks like an identifier is a well-formed one. /// @@ -132,9 +163,6 @@ private static bool IsCanonicalIfIdentifier(string segment) => !Guid.TryParse(segment, out var id) || (id != Guid.Empty && segment.Equals(id.ToString(), StringComparison.Ordinal)); - /// Whether a segment parses as an identifier at all. - private static bool LooksLikeIdentifier(string segment) => Guid.TryParse(segment, out _); - /// /// Whether the first segment is a tenant identifier or the platform sentinel. /// diff --git a/backend/src/LearnStack.SharedKernel/Caching/CacheOptions.cs b/backend/src/LearnStack.SharedKernel/Caching/CacheOptions.cs index f2a974f4..37fb3b7c 100644 --- a/backend/src/LearnStack.SharedKernel/Caching/CacheOptions.cs +++ b/backend/src/LearnStack.SharedKernel/Caching/CacheOptions.cs @@ -21,7 +21,7 @@ namespace LearnStack.SharedKernel.Caching; /// No Tags. An earlier sketch carried a string[]? Tags /// third parameter that no document ever specified and nothing ever read. /// Tag-based invalidation has the same defect as the prefix-based invalidation -/// ADR-0014 Amendment 2 +/// ADR-0038 /// removed: it requires an index from tag to keys that no candidate backend /// maintains across instances, so the method would evict what one process /// happens to know about and silently miss the rest. A key family that must diff --git a/backend/src/LearnStack.SharedKernel/Caching/ICacheService.cs b/backend/src/LearnStack.SharedKernel/Caching/ICacheService.cs index 251267cd..04cd019a 100644 --- a/backend/src/LearnStack.SharedKernel/Caching/ICacheService.cs +++ b/backend/src/LearnStack.SharedKernel/Caching/ICacheService.cs @@ -2,15 +2,15 @@ namespace LearnStack.SharedKernel.Caching; /// /// The one cache abstraction, per -/// ADR-0014 and -/// its Amendment 2. Modules never inject a cache client — no +/// ADR-0038. +/// Modules never inject a cache client — no /// IConnectionMultiplexer, no IDistributedCache, no /// IMemoryCache. /// /// /// -/// There is no RemoveByPrefixAsync. It was removed by ADR-0014 -/// Amendment 2: the only implementable form iterated a process-local key set, so +/// There is no RemoveByPrefixAsync. ADR-0038 excludes it: the only +/// implementable form iterated a process-local key set, so /// keys written by another instance were never evicted — a name promising a /// global effect while delivering a local one. A key family that must invalidate /// a set it cannot enumerate uses the **generation-key** pattern instead, which diff --git a/backend/src/LearnStack.SharedKernel/Identifiers/UserId.cs b/backend/src/LearnStack.SharedKernel/Identifiers/UserId.cs index 1f15da8b..18cb82cd 100644 --- a/backend/src/LearnStack.SharedKernel/Identifiers/UserId.cs +++ b/backend/src/LearnStack.SharedKernel/Identifiers/UserId.cs @@ -35,9 +35,10 @@ namespace LearnStack.SharedKernel.Identifiers; /// could legally pass and nothing to write at all. /// /// - /// The value is fixed rather than generated, because it is a foreign key: - /// the Tenancy migration seeds the matching users row so - /// created_by resolves. Version 7 shape with an all-zero random + /// The value is fixed rather than generated, because it is a foreign key. + /// Phase 02a Packet 6 owns the matching Tenancy seed and must create the + /// users row before the first outbox consumer can write audit columns. + /// No Tenancy schema exists before that packet. Version 7 shape with an all-zero random /// section, so it reads as deliberate in a database dump rather than as a /// stray identifier somebody forgot to replace. /// diff --git a/backend/src/LearnStack.SharedKernel/Messaging/IEventBus.cs b/backend/src/LearnStack.SharedKernel/Messaging/IEventBus.cs index dec8496d..16126504 100644 --- a/backend/src/LearnStack.SharedKernel/Messaging/IEventBus.cs +++ b/backend/src/LearnStack.SharedKernel/Messaging/IEventBus.cs @@ -2,8 +2,8 @@ namespace LearnStack.SharedKernel.Messaging; /// /// Publishes an integration event to whichever transport is registered, per -/// ADR-0014 and -/// its Amendment 2. Modules never inject a broker client. +/// ADR-0038. +/// Modules never inject a broker client. /// /// /// diff --git a/backend/src/LearnStack.SharedKernel/Messaging/IOrganizationScopedIntegrationEvent.cs b/backend/src/LearnStack.SharedKernel/Messaging/IOrganizationScopedIntegrationEvent.cs new file mode 100644 index 00000000..08909a79 --- /dev/null +++ b/backend/src/LearnStack.SharedKernel/Messaging/IOrganizationScopedIntegrationEvent.cs @@ -0,0 +1,11 @@ +namespace LearnStack.SharedKernel.Messaging; + +/// +/// Marks an integration event whose consumer must run inside an organization scope. +/// +/// +/// rejects this event shape unless a +/// non-empty organization identifier is supplied. Tenant-wide events do not +/// implement the marker and may deliberately omit the organization. +/// +public interface IOrganizationScopedIntegrationEvent : IIntegrationEvent; diff --git a/backend/src/LearnStack.SharedKernel/Messaging/IntegrationEventBase.cs b/backend/src/LearnStack.SharedKernel/Messaging/IntegrationEventBase.cs index 8127d06d..3307d8de 100644 --- a/backend/src/LearnStack.SharedKernel/Messaging/IntegrationEventBase.cs +++ b/backend/src/LearnStack.SharedKernel/Messaging/IntegrationEventBase.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using System.Text.Json.Serialization.Metadata; namespace LearnStack.SharedKernel.Messaging; @@ -45,8 +46,8 @@ public abstract record IntegrationEventBase : IIntegrationEvent /// Not a convenience — the one way to write a payload that does not lose /// data. Measured: JsonSerializer.Serialize(@event) where the /// declared type is — which it is at every - /// dispatch boundary, because ADR-0014 Amendment 2 made the port non-generic - /// precisely so it would be — emits only the four members declared on the + /// dispatch boundary, because ADR-0038 makes the port non-generic + /// precisely so it would be — emits only the five members declared on the /// interface and silently drops every field the concrete event added. No /// exception, valid JSON. The row commits inside the business transaction /// that reported success, and the loss surfaces later as a @@ -58,8 +59,8 @@ public abstract record IntegrationEventBase : IIntegrationEvent /// reintroduce exactly the bug it exists to prevent. /// /// - public string ToPayloadJson(JsonSerializerOptions? options = null) => - JsonSerializer.Serialize(this, GetType(), options ?? PayloadJsonOptions); + public string ToPayloadJson() => + JsonSerializer.Serialize(this, GetType(), PayloadJsonOptions); /// /// The serializer options the payload is written and read with. @@ -71,9 +72,17 @@ public string ToPayloadJson(JsonSerializerOptions? options = null) => /// options fails on every member, since one camel-cases and the other does /// not. A writer and a reader that disagree here dead-letter everything. /// - public static JsonSerializerOptions PayloadJsonOptions { get; } = new() + public static JsonSerializerOptions PayloadJsonOptions { get; } = CreatePayloadJsonOptions(); + + private static JsonSerializerOptions CreatePayloadJsonOptions() { - PropertyNamingPolicy = null, - WriteIndented = false, - }; + var options = new JsonSerializerOptions + { + PropertyNamingPolicy = null, + WriteIndented = false, + TypeInfoResolver = new DefaultJsonTypeInfoResolver(), + }; + options.MakeReadOnly(); + return options; + } } diff --git a/backend/src/LearnStack.SharedKernel/Messaging/IntegrationEventEnvelope.cs b/backend/src/LearnStack.SharedKernel/Messaging/IntegrationEventEnvelope.cs index 77469a4f..d1f3fb74 100644 --- a/backend/src/LearnStack.SharedKernel/Messaging/IntegrationEventEnvelope.cs +++ b/backend/src/LearnStack.SharedKernel/Messaging/IntegrationEventEnvelope.cs @@ -1,28 +1,30 @@ +using System.Diagnostics; using LearnStack.SharedKernel.Identifiers; namespace LearnStack.SharedKernel.Messaging; /// -/// One integration event plus the dispatch metadata the outbox row carries and -/// the event itself does not. +/// One integration event plus its validated dispatch metadata. Topic and +/// partition key are forwarded from the event rather than supplied again. /// /// /// /// The canonical outbox_messages row /// (Standards 05) /// requires topic and correlation_id as NOT NULL and carries -/// organization_id, causation_id and actor_user_id. None of -/// them belong on the event: they describe the delivery, not the fact. -/// Without somewhere to put them, a dispatcher had no way to hand them to a -/// consumer at all — correlation was read from the publisher's ambient context, -/// which is null in the background service the outbox processor is, so the -/// trace chain broke at exactly the boundary +/// organization_id, causation_id and actor_user_id. Topic is +/// declared by the event type. Correlation, organization, causation and causal +/// actor describe the delivery, not the fact, so they live here. Without +/// somewhere to put that metadata, a dispatcher had no way to hand it to a +/// consumer — correlation was read from the publisher's ambient context, which +/// is null in the background service the outbox processor is, so the trace +/// chain broke at exactly the boundary /// Standards 10 /// requires it to cross. /// /// -/// One type rather than more parameters, and now rather than later: ADR-0014 -/// Amendment 2 says it in this repository's own words — adding a required +/// One type rather than more parameters, and now rather than later: ADR-0038 +/// says it in this repository's own words — adding a required /// parameter after the first consumer exists breaks every call site. There is /// not one yet. /// @@ -39,16 +41,96 @@ namespace LearnStack.SharedKernel.Messaging; /// /// The event or command that caused this one, if any. /// -/// Who caused the fact. A consumer writing state attributes to -/// when this is absent. +/// The human who caused the fact, retained as causal audit metadata. A consumer +/// writing state always attributes the asynchronous work to +/// rather than impersonating this user. /// -public sealed record IntegrationEventEnvelope( - IIntegrationEvent Event, - string CorrelationId, - Guid? OrganizationId = null, - Guid? CausationId = null, - UserId? ActorUserId = null) +public sealed record IntegrationEventEnvelope { + public IntegrationEventEnvelope( + IIntegrationEvent Event, + string CorrelationId, + Guid? OrganizationId = null, + Guid? CausationId = null, + UserId? ActorUserId = null) + { + ArgumentNullException.ThrowIfNull(Event); + ArgumentException.ThrowIfNullOrWhiteSpace(CorrelationId); + + if (!ActivityContext.TryParse(CorrelationId, traceState: null, out _)) + { + throw new ArgumentException( + "CorrelationId must be a W3C traceparent value.", + nameof(CorrelationId)); + } + + if (Event.EventId == Guid.Empty) + { + throw new ArgumentException("An integration event requires an event id.", nameof(Event)); + } + + if (Event.TenantId == Guid.Empty) + { + throw new ArgumentException("An integration event requires a tenant id.", nameof(Event)); + } + + if (Event.OccurredAt == default) + { + throw new ArgumentException( + "An integration event requires an occurrence timestamp.", nameof(Event)); + } + + ArgumentException.ThrowIfNullOrWhiteSpace(Event.Topic); + ArgumentException.ThrowIfNullOrWhiteSpace(Event.PartitionKey); + + if (OrganizationId == Guid.Empty) + { + throw new ArgumentException( + "OrganizationId cannot be an empty identifier.", nameof(OrganizationId)); + } + + if (Event is IOrganizationScopedIntegrationEvent && OrganizationId is null) + { + throw new ArgumentException( + "An organization-scoped integration event requires OrganizationId.", + nameof(OrganizationId)); + } + + if (CausationId == Guid.Empty) + { + throw new ArgumentException( + "CausationId cannot be an empty identifier.", nameof(CausationId)); + } + + if (ActorUserId is { } actor + && (!actor.IsInitialized() || actor.Value == Guid.Empty)) + { + throw new ArgumentException( + "ActorUserId cannot be an uninitialized identifier.", nameof(ActorUserId)); + } + + this.Event = Event; + this.CorrelationId = CorrelationId; + this.OrganizationId = OrganizationId; + this.CausationId = CausationId; + this.ActorUserId = ActorUserId; + } + + /// The fact being published. + public IIntegrationEvent Event { get; } + + /// The producer's W3C traceparent. + public string CorrelationId { get; } + + /// The organization scope, when the event belongs to one. + public Guid? OrganizationId { get; } + + /// The event or command that caused this delivery. + public Guid? CausationId { get; } + + /// The causal human actor, distinct from the consumer's effective actor. + public UserId? ActorUserId { get; } + /// /// The ordering domain, read from the event and from nowhere else. /// diff --git a/backend/src/LearnStack.SharedKernel/Tenancy/EventTenantContext.cs b/backend/src/LearnStack.SharedKernel/Tenancy/EventTenantContext.cs index 0b332819..f692ff86 100644 --- a/backend/src/LearnStack.SharedKernel/Tenancy/EventTenantContext.cs +++ b/backend/src/LearnStack.SharedKernel/Tenancy/EventTenantContext.cs @@ -18,12 +18,18 @@ namespace LearnStack.SharedKernel.Tenancy; public sealed class EventTenantContext : ITenantContext { private EventTenantContext( - Guid tenantId, Guid? organizationId, UserId userId, string? correlationId) + Guid tenantId, + Guid? organizationId, + UserId? causalActorUserId, + string? correlationId, + string? moduleName) { TenantId = tenantId; OrganizationId = organizationId; - UserId = userId; + UserId = Identifiers.UserId.SystemActor; + CausalActorUserId = causalActorUserId; CorrelationId = correlationId; + ModuleName = moduleName; } /// @@ -55,20 +61,25 @@ private EventTenantContext( /// Never null. AuditableEntity.MarkCreated refuses /// default(UserId) and Guid.Empty, so a null actor left every /// state-writing consumer with no value it could legally pass — it could not - /// create an aggregate at all. Absent an actor on the envelope this is - /// , which is what Standards 18 means by - /// auditing such work as an actor of type system. + /// create an aggregate at all. This is always + /// ; an envelope user remains separate as + /// so the consumer does not impersonate the + /// human who initiated asynchronous work. /// public UserId? UserId { get; } + /// + public UserId? CausalActorUserId { get; } + /// public string? CorrelationId { get; } /// - public string? ModuleName => null; + public string? ModuleName { get; } /// Builds the context a handler for runs under. - public static EventTenantContext FromEnvelope(IntegrationEventEnvelope envelope) + public static EventTenantContext FromEnvelope( + IntegrationEventEnvelope envelope, string? moduleName = null) { ArgumentNullException.ThrowIfNull(envelope); @@ -88,7 +99,8 @@ public static EventTenantContext FromEnvelope(IntegrationEventEnvelope envelope) return new EventTenantContext( envelope.Event.TenantId, envelope.OrganizationId, - envelope.ActorUserId ?? Identifiers.UserId.SystemActor, - envelope.CorrelationId); + envelope.ActorUserId, + envelope.CorrelationId, + moduleName); } } diff --git a/backend/src/LearnStack.SharedKernel/Tenancy/ITenantContext.cs b/backend/src/LearnStack.SharedKernel/Tenancy/ITenantContext.cs index 19db2640..90def4da 100644 --- a/backend/src/LearnStack.SharedKernel/Tenancy/ITenantContext.cs +++ b/backend/src/LearnStack.SharedKernel/Tenancy/ITenantContext.cs @@ -42,11 +42,19 @@ public interface ITenantContext Guid? OrganizationId { get; } /// - /// The acting user, when authenticated. null for anonymous / - /// system-issued requests (background jobs, outbox handlers). + /// The effective actor. Authenticated requests carry their user, anonymous + /// requests may carry null, and asynchronous consumers use the fixed + /// principal. /// UserId? UserId { get; } + /// + /// The human actor that causally initiated asynchronous work, when known. + /// The effective remains the system actor for an + /// integration-event consumer. + /// + UserId? CausalActorUserId => null; + /// /// W3C traceparent string ("00-<trace>-<span>-<flags>") /// that threads through HTTP / outbox / Hangfire / Hub envelopes. The diff --git a/backend/tests/LearnStack.Tests.Architecture/CrossCuttingFoundationTests.cs b/backend/tests/LearnStack.Tests.Architecture/CrossCuttingFoundationTests.cs index 0d090fe8..b0135cdf 100644 --- a/backend/tests/LearnStack.Tests.Architecture/CrossCuttingFoundationTests.cs +++ b/backend/tests/LearnStack.Tests.Architecture/CrossCuttingFoundationTests.cs @@ -347,10 +347,18 @@ public void Integration_Event_TopicNames_FollowConvention() // not a guard. FollowsTopicConvention("learnstack.enrollment.enrollment").Should().BeTrue(); FollowsTopicConvention("learnstack.hub.entitlement").Should().BeTrue(); + FollowsTopicConvention("learnstack.hub.custom-domain.activated").Should().BeTrue(); FollowsTopicConvention("EnrollmentCreated").Should().BeFalse("no namespace"); FollowsTopicConvention("learnstack.enrollment").Should().BeFalse("no aggregate"); FollowsTopicConvention("Learnstack.Enrollment.Enrollment").Should().BeFalse("not lower-case"); FollowsTopicConvention("acme.enrollment.enrollment").Should().BeFalse("wrong prefix"); + FollowsTopicConvention("learnstack.-hub.event").Should().BeFalse("leading hyphen"); + FollowsTopicConvention("learnstack.hub-.event").Should().BeFalse("trailing hyphen"); + FollowsTopicConvention("learnstack.1hub.event").Should().BeFalse("leading digit"); + FollowsTopicConvention("learnstack.education.course.activated").Should().BeFalse( + "only Hub owns a four-segment topic"); + FollowsTopicConvention("learnstack.hub.custom-domain.activated.extra").Should().BeFalse( + "five segments"); foreach (var name in ModuleAssemblyShapes) { @@ -374,12 +382,20 @@ public void Integration_Event_TopicNames_FollowConvention() } } - private static bool FollowsTopicConvention(string topic) => - System.Text.RegularExpressions.Regex.IsMatch( - topic, - @"^learnstack\.[a-z0-9-]+\.[a-z0-9-]+$", - System.Text.RegularExpressions.RegexOptions.None, - TimeSpan.FromSeconds(1)); + private static bool FollowsTopicConvention(string topic) + { + const string segment = "[a-z][a-z0-9-]*[a-z0-9]|[a-z]"; + return System.Text.RegularExpressions.Regex.IsMatch( + topic, + $@"^learnstack\.({segment})\.({segment})$", + System.Text.RegularExpressions.RegexOptions.None, + TimeSpan.FromSeconds(1)) + || System.Text.RegularExpressions.Regex.IsMatch( + topic, + $@"^learnstack\.hub\.({segment})\.({segment})$", + System.Text.RegularExpressions.RegexOptions.None, + TimeSpan.FromSeconds(1)); + } [Fact] public void Modules_Do_Not_Inject_IEventBus_Directly() @@ -398,17 +414,24 @@ public void Modules_Do_Not_Inject_IEventBus_Directly() // The module assemblies carry no types yet, so this would be vacuous — // which is why the checker is pointed at a deliberate offender in this // assembly first. A guard that cannot be shown to fire is not a guard. - InjectsEventBus(typeof(DeliberateEventBusInjector)).Should().BeTrue( + UsesForbiddenEventBusAccess(typeof(DeliberateEventBusInjector)).Should().BeTrue( "the checker must catch a type that does inject the bus, or it " + "proves nothing about the modules it is aimed at"); - InjectsEventBus(typeof(CrossCuttingFoundationTests)).Should().BeFalse(); + UsesForbiddenEventBusAccess(typeof(DeliberateEventBusServiceLocator)).Should().BeTrue( + "IServiceProvider is a service-locator escape hatch"); + UsesForbiddenEventBusAccess(typeof(DeliberateMethodPublisher)).Should().BeTrue( + "method injection is still direct event-bus access"); + UsesForbiddenEventBusAccess(typeof(CrossCuttingFoundationTests)).Should().BeFalse(); foreach (var name in ModuleAssemblyShapes) { var assembly = TryLoadAssembly(name); if (assembly is null) continue; - var offenders = assembly.GetTypes().Where(InjectsEventBus).Select(t => t.FullName).ToList(); + var offenders = assembly.GetTypes() + .Where(UsesForbiddenEventBusAccess) + .Select(t => t.FullName) + .ToList(); offenders.Should().BeEmpty( $"{name} injects IEventBus. Modules write to the outbox; the " @@ -416,17 +439,27 @@ public void Modules_Do_Not_Inject_IEventBus_Directly() } } - private static bool InjectsEventBus(Type type) + private static bool UsesForbiddenEventBusAccess(Type type) { var bus = typeof(LearnStack.SharedKernel.Messaging.IEventBus); - - return type.GetConstructors().Any(constructor => - constructor.GetParameters().Any(p => bus.IsAssignableFrom(p.ParameterType))) - || type.GetFields( - System.Reflection.BindingFlags.Instance - | System.Reflection.BindingFlags.NonPublic - | System.Reflection.BindingFlags.Public) - .Any(field => bus.IsAssignableFrom(field.FieldType)); + var serviceProvider = typeof(IServiceProvider); + var forbidden = new[] { bus, serviceProvider }; + const BindingFlags members = BindingFlags.Instance + | BindingFlags.Static + | BindingFlags.Public + | BindingFlags.NonPublic; + + return type.GetConstructors(members).Any(constructor => + constructor.GetParameters().Any(parameter => + forbidden.Any(candidate => candidate.IsAssignableFrom(parameter.ParameterType)))) + || type.GetMethods(members).Any(method => + forbidden.Any(candidate => candidate.IsAssignableFrom(method.ReturnType)) + || method.GetParameters().Any(parameter => + forbidden.Any(candidate => candidate.IsAssignableFrom(parameter.ParameterType)))) + || type.GetFields(members).Any(field => + forbidden.Any(candidate => candidate.IsAssignableFrom(field.FieldType))) + || type.GetProperties(members).Any(property => + forbidden.Any(candidate => candidate.IsAssignableFrom(property.PropertyType))); } /// A type that breaks the rule, so the checker can be shown to catch it. @@ -435,6 +468,21 @@ private sealed class DeliberateEventBusInjector(LearnStack.SharedKernel.Messagin public LearnStack.SharedKernel.Messaging.IEventBus Bus { get; } = bus; } + /// A service-locator-shaped deliberate offender. + private sealed class DeliberateEventBusServiceLocator(IServiceProvider services) + { + public IServiceProvider Services { get; } = services; + } + + /// A method-injection-shaped deliberate offender. + private sealed class DeliberateMethodPublisher + { + public static Task Publish( + LearnStack.SharedKernel.Messaging.IEventBus bus, + LearnStack.SharedKernel.Messaging.IntegrationEventEnvelope envelope) => + bus.PublishAsync(envelope); + } + [Fact] public void IErrorTrackingProvider_Is_Singleton() { diff --git a/backend/tests/LearnStack.Tests.Integration/CrossCuttingFoundationHttpTests.cs b/backend/tests/LearnStack.Tests.Integration/CrossCuttingFoundationHttpTests.cs index 0ddadbc4..bae5c890 100644 --- a/backend/tests/LearnStack.Tests.Integration/CrossCuttingFoundationHttpTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/CrossCuttingFoundationHttpTests.cs @@ -137,7 +137,6 @@ public async Task Malformed_Body_Returns_LearnStacks_ProblemDetails_Not_AspNets( } } -/// /// /// The foundation sockets resolve from the real composition root. /// @@ -184,6 +183,16 @@ public void The_Cache_Resolves_To_The_In_Memory_Default() scope.ServiceProvider.GetRequiredService() .Should().BeOfType(); } + + [Fact] + public void The_Process_Local_Cache_Is_A_Singleton_Across_Request_Scopes() + { + using var first = fixture.Services.CreateScope(); + using var second = fixture.Services.CreateScope(); + + first.ServiceProvider.GetRequiredService() + .Should().BeSameAs(second.ServiceProvider.GetRequiredService()); + } } /// Shared that wires the diff --git a/backend/tests/LearnStack.Tests.Integration/DeploymentModeCompositionTests.cs b/backend/tests/LearnStack.Tests.Integration/DeploymentModeCompositionTests.cs index 47afcab5..ba83679c 100644 --- a/backend/tests/LearnStack.Tests.Integration/DeploymentModeCompositionTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/DeploymentModeCompositionTests.cs @@ -6,6 +6,7 @@ using LearnStack.SharedKernel.Messaging; using LearnStack.SharedKernel.Observability; using LearnStack.SharedKernel.Secrets; +using LearnStack.SharedKernel.Tenancy; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.Configuration; @@ -79,6 +80,34 @@ public void Error_Tracking_Is_The_One_Port_The_Mode_Actually_Changes() inSaaS.GetType().Name.Should().Be("SentryErrorTracker"); } + [Fact] + public void Tenant_Context_Resolution_Forwards_Each_Access_To_The_Accessor() + { + using var factory = For(nameof(DeploymentMode.Development)); + using var scope = factory.Services.CreateScope(); + var accessor = factory.Services.GetRequiredService(); + var previous = accessor.Current; + + try + { + var first = new ResolvedContext( + Guid.Parse("018f4d40-0000-7000-8000-000000000001")); + var second = new ResolvedContext( + Guid.Parse("018f4d40-0000-7000-8000-000000000002")); + + accessor.Current = first; + scope.ServiceProvider.GetRequiredService().Should().BeSameAs(first); + + accessor.Current = second; + scope.ServiceProvider.GetRequiredService().Should().BeSameAs(second, + "a scoped factory would cache the first value for the rest of the scope"); + } + finally + { + accessor.Current = previous; + } + } + /// /// Boots the host in one mode, overriding what /// appsettings.Development.json sets. @@ -100,4 +129,14 @@ private static WebApplicationFactory For(string mode) => builder.UseSetting("Deployment:Mode", mode); builder.UseSetting("ErrorTracking:Sentry:Dsn", DevelopmentShapedDsn); }); + + private sealed class ResolvedContext(Guid tenantId) : ITenantContext + { + public bool IsResolved => true; + public Guid TenantId { get; } = tenantId; + public Guid? OrganizationId => null; + public LearnStack.SharedKernel.Identifiers.UserId? UserId => null; + public string? CorrelationId => null; + public string? ModuleName => "integration-test"; + } } diff --git a/backend/tests/LearnStack.Tests.Unit/Infrastructure/Caching/InMemoryCacheServiceTests.cs b/backend/tests/LearnStack.Tests.Unit/Infrastructure/Caching/InMemoryCacheServiceTests.cs index 414b2f94..ee768e6c 100644 --- a/backend/tests/LearnStack.Tests.Unit/Infrastructure/Caching/InMemoryCacheServiceTests.cs +++ b/backend/tests/LearnStack.Tests.Unit/Infrastructure/Caching/InMemoryCacheServiceTests.cs @@ -1,16 +1,17 @@ using System.Collections.Concurrent; +using System.Diagnostics.Metrics; using FluentAssertions; using LearnStack.Infrastructure.Caching; using LearnStack.SharedKernel.Caching; using LearnStack.SharedKernel.Time; +using Microsoft.Extensions.DependencyInjection; using Xunit; namespace LearnStack.Tests.Unit.Infrastructure.Caching; /// /// The default , per -/// ADR-0014 -/// and its Amendment 2. +/// ADR-0038. /// /// /// Every expiry case moves a rather than sleeping, so @@ -19,6 +20,13 @@ namespace LearnStack.Tests.Unit.Infrastructure.Caching; /// public sealed class InMemoryCacheServiceTests { + private static readonly ServiceProvider MeterServices = new ServiceCollection() + .AddMetrics() + .BuildServiceProvider(); + + private static IMeterFactory MeterFactory => + MeterServices.GetRequiredService(); + private static readonly DateTimeOffset Origin = new(2026, 8, 24, 9, 0, 0, TimeSpan.Zero); private static readonly Guid Tenant = Guid.Parse("018f4d40-0000-7000-8000-00000000000a"); private static readonly Guid OtherTenant = Guid.Parse("018f4d40-0000-7000-8000-00000000000b"); @@ -147,6 +155,40 @@ public async Task L2Ttl_Is_Carried_And_Ignored() (await cache.GetAsync(Key())).Should().BeNull(); } + [Fact] + public async Task Invalid_Ttls_Are_Rejected_Before_The_Factory_Runs() + { + var (cache, _) = New(); + var calls = 0; + var invalid = new[] { TimeSpan.Zero, TimeSpan.FromTicks(-1), TimeSpan.MaxValue }; + + foreach (var ttl in invalid) + { + var act = () => cache.GetOrSetAsync( + Key(), + _ => + { + calls++; + return Task.FromResult("value"); + }, + new CacheOptions(L1Ttl: ttl)); + + await act.Should().ThrowAsync(); + } + + var invalidL2 = () => cache.GetOrSetAsync( + Key(), + _ => + { + calls++; + return Task.FromResult("value"); + }, + new CacheOptions(L2Ttl: TimeSpan.Zero)); + + await invalidL2.Should().ThrowAsync(); + calls.Should().Be(0); + } + // ---- GetOrSet ---------------------------------------------------------- [Fact] @@ -218,6 +260,95 @@ async Task Factory(CancellationToken cancellationToken) results.Should().AllBe("made"); } + [Fact] + public async Task The_Flight_Owners_Ttl_Is_The_One_Stored() + { + var (cache, clock) = New(); + using var release = new SemaphoreSlim(0); + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var owner = cache.GetOrSetAsync( + Key(), + async cancellationToken => + { + entered.TrySetResult(); + await release.WaitAsync(TestTimeout, cancellationToken); + return "made"; + }, + new CacheOptions(L1Ttl: TimeSpan.FromSeconds(2))); + + await entered.Task.WaitAsync(TestTimeout); + var joiner = cache.GetOrSetAsync( + Key(), + _ => Task.FromResult("unused"), + new CacheOptions(L1Ttl: TimeSpan.FromHours(1))); + + release.Release(); + await Task.WhenAll(owner, joiner); + clock.Advance(TimeSpan.FromSeconds(2)); + + (await cache.GetAsync(Key())).Should().BeNull( + "the first caller owns the one shared factory and its cache policy"); + } + + [Fact] + public async Task Cache_Metrics_Use_Stable_Low_Cardinality_Names() + { + var measurements = new ConcurrentBag<(string Instrument, string CacheName)>(); + using var listener = new MeterListener(); + listener.InstrumentPublished = (instrument, currentListener) => + { + if (instrument.Meter.Name == InMemoryCacheService.MeterName) + { + currentListener.EnableMeasurementEvents(instrument); + } + }; + listener.SetMeasurementEventCallback((instrument, _, tags, _) => + { + var cacheName = tags.ToArray() + .Single(tag => tag.Key == "cache.name") + .Value + ?.ToString(); + measurements.Add((instrument.Name, cacheName!)); + }); + listener.Start(); + + var (cache, _) = New(); + await cache.GetAsync(Key()); + await cache.SetAsync(Key(), "value"); + await cache.GetAsync(Key()); + await cache.RemoveAsync(Key()); + await cache.GetAsync( + CacheKey.ForTenant(Tenant, "tenancy", OtherTenant.ToString())); + + using var release = new SemaphoreSlim(0); + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var owner = cache.GetOrSetAsync(Key(), async cancellationToken => + { + entered.TrySetResult(); + await release.WaitAsync(TestTimeout, cancellationToken); + return "coalesced"; + }); + await entered.Task.WaitAsync(TestTimeout); + var joiner = cache.GetOrSetAsync(Key(), _ => Task.FromResult("unused")); + release.Release(); + await Task.WhenAll(owner, joiner); + + measurements.Select(measurement => measurement.Instrument).Should().Contain( + [ + InMemoryCacheService.MissCounterName, + InMemoryCacheService.StoreCounterName, + InMemoryCacheService.HitCounterName, + InMemoryCacheService.CoalescedCounterName, + ]); + measurements.Should().OnlyContain(measurement => + measurement.CacheName == "tenancy:settings" + || measurement.CacheName == "other"); + measurements.Should().OnlyContain(measurement => + !measurement.CacheName.Contains(Tenant.ToString(), StringComparison.Ordinal) + && !measurement.CacheName.Contains(OtherTenant.ToString(), StringComparison.Ordinal)); + } + [Fact] public async Task A_Failed_Flight_Does_Not_Poison_The_Key() { @@ -454,39 +585,50 @@ async Task Factory(CancellationToken _) } [Fact] - public async Task A_Flight_Everyone_Abandons_Does_Not_Poison_Its_Key() + public async Task An_Abandoned_Factory_Must_Terminate_Before_Its_Replacement_Starts() { - // Retiring used to require the factory to have COMPLETED. Nothing can - // impose a deadline on it — the flight deliberately runs on - // CancellationToken.None so one caller cannot cancel it for the rest — - // so a factory that never finishes left its registration in place for - // the life of the process. `_inFlight` has no ceiling, and worse, every - // later caller JOINED that dead flight and waited on a task that would - // never complete. The key never ran a factory again. var (cache, _) = New(); - using var never = new SemaphoreSlim(0); + using var release = new SemaphoreSlim(0); using var abandoning = new CancellationTokenSource(); var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var calls = 0; + var running = 0; + var maximumRunning = 0; var abandoned = cache.GetOrSetAsync(Key(), async _ => { + Interlocked.Increment(ref calls); + var current = Interlocked.Increment(ref running); + Interlocked.Exchange(ref maximumRunning, Math.Max(maximumRunning, current)); entered.TrySetResult(); - await never.WaitAsync(TestTimeout, CancellationToken.None); - return "never-arrives"; + await release.WaitAsync(TestTimeout, CancellationToken.None); + Interlocked.Decrement(ref running); + return "abandoned"; }, null, abandoning.Token); await entered.Task.WaitAsync(TestTimeout); await abandoning.CancelAsync(); await ((Func)(() => abandoned)).Should().ThrowAsync(); - cache.InFlightCount.Should().Be(0, "no caller is left, so nothing is in flight"); + cache.InFlightCount.Should().Be(1, + "abandonment cancels the factory but cannot pretend ignored cancellation has ended it"); + + var next = cache.GetOrSetAsync(Key(), _ => + { + Interlocked.Increment(ref calls); + var current = Interlocked.Increment(ref running); + Interlocked.Exchange(ref maximumRunning, Math.Max(maximumRunning, current)); + Interlocked.Decrement(ref running); + return Task.FromResult("fresh"); + }); - // The key must still work. Without the fix this call joins the dead - // flight and hangs until its own token fires. - var next = cache.GetOrSetAsync(Key(), _ => Task.FromResult("fresh")); + await Task.Delay(TimeSpan.FromMilliseconds(50)); + calls.Should().Be(1, "the replacement waits for actual terminality"); + release.Release(); (await next.WaitAsync(TestTimeout)).Should().Be("fresh"); - never.Release(); + calls.Should().Be(2); + maximumRunning.Should().Be(1, "same-key factories never overlap"); } [Fact] @@ -513,14 +655,18 @@ public async Task A_Caller_Arriving_After_A_Remove_Does_Not_Join_The_Doomed_Flig await entered.Task.WaitAsync(TestTimeout); await cache.RemoveAsync(Key()); - var afterwards = await cache.GetOrSetAsync(Key(), _ => Task.FromResult("after-the-remove")); + var afterwards = cache.GetOrSetAsync( + Key(), _ => Task.FromResult("after-the-remove")); - afterwards.Should().Be("after-the-remove", - "it started after the invalidation, so it reads the source of truth"); + await Task.Delay(TimeSpan.FromMilliseconds(50)); + afterwards.IsCompleted.Should().BeFalse( + "the replacement must not overlap the superseded factory"); release.Release(); (await inFlight).Should().Be("before-the-remove", "the caller already in flight still gets what it asked for"); + (await afterwards).Should().Be("after-the-remove", + "it started after the invalidation, so it reads the source of truth"); } [Fact] @@ -909,32 +1055,24 @@ public async Task An_Entry_Is_Gone_At_Exactly_Its_Expiry_Instant() private static readonly TimeSpan TestTimeout = TimeSpan.FromSeconds(10); [Fact] - public async Task A_Write_Landing_Between_The_Check_And_The_Store_Is_Not_Overwritten() + public async Task A_Write_During_The_Atomic_Miss_Check_Is_Observed() { - // The supersede check and the store are two steps, not one, so a write - // landing between them would be overwritten by the very stale result - // the check exists to reject. Real thread scheduling cannot be aimed at - // a window that narrow, so the write is aimed at it through the seam - // this class already takes for determinism: an IClock whose UtcNow runs - // the write, on the read that sits between the two steps. - // - // The safe resolution is a miss, not the newer value: this caller has - // already overwritten the entry by the time it notices, so it evicts. - // The next reader goes to the source of truth, and a miss is never an - // error — whereas a stale value presented as fresh is the one thing a - // cache must not do quietly. InMemoryCacheService? cache = null; var clock = new WritingClock(Origin, onNthRead: 2, write: () => cache!.SetAsync(Key(), "landed-in-the-window").GetAwaiter().GetResult()); - cache = new InMemoryCacheService(clock); + cache = new InMemoryCacheService(clock, MeterFactory); + var calls = 0; - var produced = await cache.GetOrSetAsync(Key(), _ => Task.FromResult("from-factory")); + var produced = await cache.GetOrSetAsync(Key(), _ => + { + calls++; + return Task.FromResult("from-factory"); + }); - produced.Should().Be("from-factory", "the caller still gets what it asked for"); + produced.Should().Be("landed-in-the-window"); + calls.Should().Be(0, "the write landed before a flight could be published"); clock.Fired.Should().BeTrue("the window was actually hit — otherwise this proves nothing"); - (await cache.GetAsync(Key())).Should().BeNull( - "but the entry this caller had already overwritten is evicted rather " - + "than left holding a value a concurrent write had superseded"); + (await cache.GetAsync(Key())).Should().Be("landed-in-the-window"); } /// @@ -965,6 +1103,6 @@ public DateTimeOffset UtcNow private static (InMemoryCacheService Cache, FixedClock Clock) New() { var clock = new FixedClock(Origin); - return (new InMemoryCacheService(clock), clock); + return (new InMemoryCacheService(clock, MeterFactory), clock); } } diff --git a/backend/tests/LearnStack.Tests.Unit/Infrastructure/Messaging/InProcessEventBusTests.cs b/backend/tests/LearnStack.Tests.Unit/Infrastructure/Messaging/InProcessEventBusTests.cs index c4c702e5..85726faf 100644 --- a/backend/tests/LearnStack.Tests.Unit/Infrastructure/Messaging/InProcessEventBusTests.cs +++ b/backend/tests/LearnStack.Tests.Unit/Infrastructure/Messaging/InProcessEventBusTests.cs @@ -1,4 +1,5 @@ using System.Collections.Concurrent; +using System.Diagnostics; using FluentAssertions; using LearnStack.Infrastructure.Messaging; using LearnStack.SharedKernel.Identifiers; @@ -188,7 +189,7 @@ public async Task The_Consumer_Acts_As_The_System_When_The_Envelope_Names_No_Act } [Fact] - public async Task The_Envelopes_Actor_And_Organization_Reach_The_Handler() + public async Task The_Consumer_Uses_The_System_Actor_And_Preserves_The_Causal_Actor() { var recorder = new Recorder(); var (bus, _) = Build(recorder, services => @@ -200,7 +201,8 @@ public async Task The_Envelopes_Actor_And_Organization_Reach_The_Handler() await bus.PublishAsync(new IntegrationEventEnvelope( NewThing("a"), Trace, OrganizationId: organization, ActorUserId: actor)); - recorder.Actors.Should().ContainSingle().Which.Should().Be(actor); + recorder.Actors.Should().ContainSingle().Which.Should().Be(UserId.SystemActor); + recorder.CausalActors.Should().ContainSingle().Which.Should().Be(actor); recorder.Organizations.Should().ContainSingle().Which.Should().Be(organization); } @@ -305,7 +307,7 @@ public async Task A_Handler_Cancelled_By_A_Foreign_Token_Fails_Rather_Than_Cance // task — and its shutdown path swallows the former. var recorder = new Recorder(); var (bus, _) = Build(recorder, services => - services.AddScoped>(_ => new ForeignCancelHandler())); + services.AddScoped, ForeignCancelHandler>()); var publish = bus.PublishAsync(Envelope(NewThing("a"))); @@ -360,9 +362,7 @@ public async Task The_Publish_Token_Reaches_The_Handler() services.AddScoped, TokenReadingHandler>()); using var cancelled = new CancellationTokenSource(); - var publish = bus.PublishAsync(Envelope(NewThing("a")), cancelled.Token); - await cancelled.CancelAsync(); - await publish; + await bus.PublishAsync(Envelope(NewThing("a")), cancelled.Token); // The token ITSELF, not merely "a cancellable token" — asserting // CanBeCanceled was true of any token at all, so threading a freshly @@ -372,6 +372,27 @@ public async Task The_Publish_Token_Reaches_The_Handler() recorder.HandlerToken.Should().Be(cancelled.Token); } + [Fact] + public async Task Publish_Cancellation_Stops_Later_Subscriptions() + { + var recorder = new Recorder(); + var (bus, _) = Build(recorder, services => + { + services.AddScoped, CancellationWaitingHandler>(); + services.AddScoped, SecondThingHandler>(); + }); + using var cancellation = new CancellationTokenSource(); + + var publish = bus.PublishAsync(Envelope(NewThing("a")), cancellation.Token); + await recorder.HandlerEntered.Task.WaitAsync(Timeout); + await cancellation.CancelAsync(); + + await ((Func)(() => publish)).Should().ThrowAsync(); + publish.IsCanceled.Should().BeTrue(); + recorder.Handled.Should().BeEmpty( + "the later subscription must not start after publish cancellation"); + } + [Fact] public async Task A_Handler_Constructor_Sees_The_Events_Tenant() { @@ -397,12 +418,8 @@ public async Task A_Handler_Constructor_Sees_The_Events_Tenant() [Fact] public async Task A_Handler_That_Cannot_Be_Built_Is_Reported_As_Such() { - // Handler construction happens before the per-handler loop that provides - // isolation — the container materialises the whole array before - // returning any element — so a constructor that throws takes every - // sibling with it and nothing here can contain it. Without the explicit - // report the exception was swallowed, the count came back zero, and a - // broken registration looked exactly like "nobody subscribed". + // Subscription metadata is construction-free. A broken graph therefore + // belongs only to that subscription and cannot deny a healthy sibling. var recorder = new Recorder(); var (bus, _) = Build(recorder, services => { @@ -415,7 +432,8 @@ public async Task A_Handler_That_Cannot_Be_Built_Is_Reported_As_Such() var thrown = await act.Should().ThrowAsync(); thrown.Which.Message.Should().Contain("failed to construct"); thrown.Which.InnerException!.Message.Should().Be("this handler cannot be built"); - recorder.Handled.Should().BeEmpty("no handler for that event could run"); + recorder.Handled.Should().ContainSingle().Which.Should().Be("a", + "the healthy subscription is constructed and invoked independently"); } [Fact] @@ -449,6 +467,49 @@ public async Task The_Dispatch_Scope_Is_Disposed() recorder.Probes.Should().ContainSingle().Which.Disposed.Should().BeTrue(); } + [Fact] + public async Task An_Async_Only_Disposable_Dependency_Is_Disposed_Exactly_Once() + { + var recorder = new Recorder(); + var (bus, _) = Build(recorder, services => + { + services.AddScoped(); + services.AddScoped, AsyncDisposalProbingHandler>(); + }); + + await bus.PublishAsync(Envelope(NewThing("a"))); + + recorder.AsyncProbes.Should().ContainSingle(); + recorder.AsyncProbes.Single().DisposeCalls.Should().Be(1); + } + + [Fact] + public async Task Each_Subscription_Continues_The_Producer_Trace() + { + var stopped = new ConcurrentBag(); + using var listener = new ActivityListener + { + ShouldListenTo = source => source.Name == InProcessEventBus.ActivitySourceName, + Sample = static (ref ActivityCreationOptions _) => + ActivitySamplingResult.AllDataAndRecorded, + ActivityStopped = stopped.Add, + }; + ActivitySource.AddActivityListener(listener); + + var recorder = new Recorder(); + var (bus, _) = Build(recorder, services => + services.AddScoped, ActivityReadingHandler>()); + + await bus.PublishAsync(Envelope(NewThing("a"))); + + stopped.Should().ContainSingle(); + var activity = stopped.Single(); + activity.Kind.Should().Be(ActivityKind.Consumer); + activity.TraceId.ToString().Should().Be("0af7651916cd43dd8448eb211c80319c"); + activity.ParentSpanId.ToString().Should().Be("b7ad6b7169203331"); + recorder.Modules.Should().ContainSingle().Which.Should().Be("unknown"); + } + // ---- obligation: ordering per partition key ------------------------------ [Fact] @@ -482,8 +543,10 @@ public async Task Different_Partition_Keys_Run_Concurrently() using var firstArrived = new SemaphoreSlim(0); using var secondArrived = new SemaphoreSlim(0); var (bus, _) = Build(recorder, services => - services.AddScoped>(_ => - new RendezvousHandler(recorder, firstArrived, secondArrived))); + { + services.AddSingleton(new RendezvousGates(firstArrived, secondArrived)); + services.AddScoped, RendezvousHandler>(); + }); var first = bus.PublishAsync(Envelope(NewThing("a", "key-1"))); var second = bus.PublishAsync(Envelope(NewThing("b", "key-2"))); @@ -550,14 +613,23 @@ private static (IEventBus Bus, ITenantContextAccessor Accessor) Build( services.AddSingleton(accessor); // The production binding, verbatim (CrossCuttingFoundationExtensions): - // the scoped context resolves FROM the accessor. Registering anything + // the transient context forwards to the accessor on every resolution. Registering anything // else here would test a container this application never builds. - services.AddScoped(sp => + services.AddTransient(sp => sp.GetRequiredService().Current ?? UnresolvedTenantContext.Instance); register(services); + var handlers = IntegrationEventHandlerRegistry.FromServiceDescriptors(services); + foreach (var subscription in handlers.All) + { + if (services.All(descriptor => descriptor.ServiceType != subscription.HandlerType)) + { + services.AddScoped(subscription.HandlerType, subscription.HandlerType); + } + } + var provider = services.BuildServiceProvider(); return ( @@ -565,6 +637,7 @@ private static (IEventBus Bus, ITenantContextAccessor Accessor) Build( provider.GetRequiredService(), accessor, new PartitionSerializer(), + handlers, NullLogger.Instance), accessor); } @@ -596,12 +669,21 @@ public sealed class Recorder public ConcurrentQueue Actors { get; } = new(); + public ConcurrentQueue CausalActors { get; } = new(); + public ConcurrentQueue Organizations { get; } = new(); public ConcurrentQueue Scopes { get; } = new(); public ConcurrentQueue Probes { get; } = new(); + public ConcurrentQueue AsyncProbes { get; } = new(); + + public ConcurrentQueue Modules { get; } = new(); + + public TaskCompletionSource HandlerEntered { get; } = + new(TaskCreationOptions.RunContinuationsAsynchronously); + public CancellationToken HandlerToken { get; set; } public int Rendezvoused => _rendezvoused; @@ -690,6 +772,11 @@ public Task HandleAsync(Thing @event, CancellationToken cancellationToken = defa { recorder.Actors.Enqueue(context.UserId!.Value); + if (context.CausalActorUserId is { } causalActor) + { + recorder.CausalActors.Enqueue(causalActor); + } + if (context.OrganizationId is { } organization) { recorder.Organizations.Enqueue(organization); @@ -763,6 +850,29 @@ public Task HandleAsync(Thing @event, CancellationToken cancellationToken = defa } } + public sealed class CancellationWaitingHandler(Recorder recorder) + : IIntegrationEventHandler + { + public async Task HandleAsync( + Thing @event, + CancellationToken cancellationToken = default) + { + recorder.HandlerEntered.TrySetResult(); + await Task.Delay(System.Threading.Timeout.InfiniteTimeSpan, cancellationToken); + } + } + + public sealed class ActivityReadingHandler(Recorder recorder, ITenantContext context) + : IIntegrationEventHandler + { + public Task HandleAsync(Thing @event, CancellationToken cancellationToken = default) + { + recorder.Modules.Enqueue(context.ModuleName!); + Activity.Current.Should().NotBeNull(); + return Task.CompletedTask; + } + } + public sealed class TenantCapturingHandler : IIntegrationEventHandler { private readonly Recorder _recorder; @@ -814,6 +924,28 @@ public Task HandleAsync(Thing @event, CancellationToken cancellationToken = defa } } + public sealed class AsyncDisposalProbe : IAsyncDisposable + { + public int DisposeCalls { get; private set; } + + public ValueTask DisposeAsync() + { + DisposeCalls++; + return ValueTask.CompletedTask; + } + } + + public sealed class AsyncDisposalProbingHandler( + Recorder recorder, + AsyncDisposalProbe probe) : IIntegrationEventHandler + { + public Task HandleAsync(Thing @event, CancellationToken cancellationToken = default) + { + recorder.AsyncProbes.Enqueue(probe); + return Task.CompletedTask; + } + } + public sealed class ThrowingHandler : IIntegrationEventHandler { public Task HandleAsync(Thing @event, CancellationToken cancellationToken = default) => @@ -845,8 +977,9 @@ public async Task HandleAsync(Thing @event, CancellationToken cancellationToken } } - public sealed class RendezvousHandler( - Recorder recorder, SemaphoreSlim first, SemaphoreSlim second) + public sealed record RendezvousGates(SemaphoreSlim First, SemaphoreSlim Second); + + public sealed class RendezvousHandler(Recorder recorder, RendezvousGates gates) : IIntegrationEventHandler { public async Task HandleAsync(Thing @event, CancellationToken cancellationToken = default) @@ -854,8 +987,8 @@ public async Task HandleAsync(Thing @event, CancellationToken cancellationToken // The key decides which gate is this side's, so the two invocations // never pick the same one — a shared counter would be shared across // tests running in parallel. - var mine = @event.PartitionKey == "key-1" ? first : second; - var theirs = ReferenceEquals(mine, first) ? second : first; + var mine = @event.PartitionKey == "key-1" ? gates.First : gates.Second; + var theirs = ReferenceEquals(mine, gates.First) ? gates.Second : gates.First; mine.Release(); (await theirs.WaitAsync(Timeout, CancellationToken.None)).Should().BeTrue( diff --git a/backend/tests/LearnStack.Tests.Unit/SharedKernel/Caching/CacheKeyTests.cs b/backend/tests/LearnStack.Tests.Unit/SharedKernel/Caching/CacheKeyTests.cs index ac285b00..08aa152a 100644 --- a/backend/tests/LearnStack.Tests.Unit/SharedKernel/Caching/CacheKeyTests.cs +++ b/backend/tests/LearnStack.Tests.Unit/SharedKernel/Caching/CacheKeyTests.cs @@ -29,12 +29,10 @@ public void A_Tenant_Key_Carries_All_Three_Segments() } [Fact] - public void A_Platform_Key_Uses_The_Sentinel_Rather_Than_Omitting_The_Segment() + public void The_Host_Map_Key_Uses_The_Platform_Sentinel() { - // "No tenant" and "every tenant" must look different in a key dump, and - // the rule stays one rule. - CacheKey.ForPlatform("hub", "host-map") - .Should().Be("platform:hub:host-map"); + CacheKey.ForHostMapping("school.example.com") + .Should().Be("platform:hub:host-map:school.example.com"); } [Fact] @@ -109,11 +107,35 @@ public void Three_Segments_Are_Not_Enough_If_The_First_One_Is_Not_A_Tenant(strin [Fact] public void The_Platform_Sentinel_Is_A_Tenant_Segment() { - var act = () => CacheKey.EnsureValid("platform:hub:host-map"); + var act = () => CacheKey.EnsureValid("platform:hub:host-map:school.example.com"); act.Should().NotThrow("'every tenant' is spelled, not omitted"); } + [Theory] + [InlineData("platform:tenancy:settings")] + [InlineData("platform:identity:permissions:session")] + [InlineData("platform:hub:host-map")] + [InlineData("platform:hub:host-map:127.0.0.1")] + public void The_Platform_Sentinel_Is_Reserved_For_Normalized_Host_Mappings(string key) + { + var act = () => CacheKey.EnsureValid(key); + + act.Should().Throw(); + } + + [Theory] + [InlineData("School.example.com")] + [InlineData("school.example.com.")] + [InlineData("school.example.com:443")] + [InlineData("127.0.0.1")] + public void A_Host_Mapping_Requires_A_Normalized_Dns_Host(string host) + { + var act = () => CacheKey.ForHostMapping(host); + + act.Should().Throw(); + } + [Fact] public void A_Structured_Logical_Name_Is_Composed_Not_Hand_Joined() { @@ -143,7 +165,7 @@ public void The_Key_Families_Standards_20_Mandates_Are_All_Composable() var families = new[] { - CacheKey.ForPlatform("hub", "host-map", "school.example.com"), + CacheKey.ForHostMapping("school.example.com"), CacheKey.ForTenant(Tenant, "hub", "entitlement"), CacheKey.ForTenant(Tenant, "tenancy", "feature-flags"), CacheKey.ForTenant(Tenant, "identity", "permissions", session.ToString()), diff --git a/backend/tests/LearnStack.Tests.Unit/SharedKernel/Messaging/IntegrationEventContractTests.cs b/backend/tests/LearnStack.Tests.Unit/SharedKernel/Messaging/IntegrationEventContractTests.cs index d9d9e013..79cdbe2a 100644 --- a/backend/tests/LearnStack.Tests.Unit/SharedKernel/Messaging/IntegrationEventContractTests.cs +++ b/backend/tests/LearnStack.Tests.Unit/SharedKernel/Messaging/IntegrationEventContractTests.cs @@ -23,6 +23,7 @@ namespace LearnStack.Tests.Unit.SharedKernel.Messaging; public sealed class IntegrationEventContractTests { private static readonly Guid Tenant = Guid.Parse("018f4d40-0000-7000-8000-00000000000a"); + private const string Trace = "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"; [Theory] [InlineData(nameof(IIntegrationEvent.Topic))] @@ -50,7 +51,7 @@ public void The_Envelope_Carries_The_Events_Own_Channel_And_Ordering_Domain() // exist to prevent, where the transport reads one source and the event // declares another. var @event = NewSample(); - var envelope = new IntegrationEventEnvelope(@event, "trace-1"); + var envelope = new IntegrationEventEnvelope(@event, Trace); envelope.Topic.Should().Be(@event.Topic); envelope.PartitionKey.Should().Be(@event.PartitionKey); @@ -73,7 +74,7 @@ public void PartitionKey_Is_Abstract_So_No_Event_Can_Inherit_A_Default() [InlineData(nameof(IIntegrationEvent.EventId))] [InlineData(nameof(IIntegrationEvent.TenantId))] [InlineData(nameof(IIntegrationEvent.OccurredAt))] - public void The_Envelope_Fields_Are_Required(string member) + public void IntegrationEventBase_Identity_Fields_Are_Required_Members(string member) { // `required` is what makes a half-populated event a compile error at the // producer and a loud JsonException at the reader, rather than an event @@ -88,7 +89,7 @@ public void A_Payload_Written_Through_The_Base_Keeps_Its_Own_Fields() { // The trap the non-generic port creates. Serializing with // IIntegrationEvent as the declared type — which it is at every dispatch - // boundary — emits the four interface members and silently drops + // boundary — emits the five interface members and silently drops // everything the concrete event added: valid JSON, no exception, and the // loss commits inside the transaction that reported success. var @event = NewSample(); @@ -96,7 +97,7 @@ public void A_Payload_Written_Through_The_Base_Keeps_Its_Own_Fields() var naive = JsonSerializer.Serialize(asBase); naive.Should().NotContain(nameof(Sample.LearnerName), - "the declared type is the interface, so only its four members survive"); + "the declared type is the interface, so only its five members survive"); var written = @event.ToPayloadJson(); @@ -133,6 +134,77 @@ public void The_Payload_Options_Do_Not_Rename_Members() IntegrationEventBase.PayloadJsonOptions.PropertyNamingPolicy.Should().BeNull(); } + [Fact] + public void The_Payload_Options_Are_Frozen_Before_Their_First_Use() + { + IntegrationEventBase.PayloadJsonOptions.IsReadOnly.Should().BeTrue(); + + var act = () => IntegrationEventBase.PayloadJsonOptions.WriteIndented = true; + + act.Should().Throw(); + } + + [Fact] + public void The_Envelope_Rejects_A_Null_Event_And_A_Blank_Correlation() + { + var nullEvent = () => new IntegrationEventEnvelope(null!, Trace); + var nullCorrelation = () => new IntegrationEventEnvelope(NewSample(), null!); + var blankCorrelation = () => new IntegrationEventEnvelope(NewSample(), " "); + + nullEvent.Should().Throw(); + nullCorrelation.Should().Throw(); + blankCorrelation.Should().Throw(); + } + + [Fact] + public void The_Envelope_Rejects_Malformed_Event_Identity_And_Trace_Metadata() + { + var invalid = new Action[] + { + () => _ = new IntegrationEventEnvelope( + NewSample() with { EventId = Guid.Empty }, Trace), + () => _ = new IntegrationEventEnvelope( + NewSample() with { TenantId = Guid.Empty }, Trace), + () => _ = new IntegrationEventEnvelope( + NewSample() with { OccurredAt = default }, Trace), + () => _ = new IntegrationEventEnvelope(NewSample(), "not-a-traceparent"), + () => _ = new IntegrationEventEnvelope( + NewSample(), Trace, OrganizationId: Guid.Empty), + () => _ = new IntegrationEventEnvelope( + NewSample(), Trace, CausationId: Guid.Empty), + () => _ = new IntegrationEventEnvelope( + NewInvalidMetadataSample(topic: "", partitionKey: "valid"), Trace), + () => _ = new IntegrationEventEnvelope( + NewInvalidMetadataSample(topic: "learnstack.test.invalid", partitionKey: ""), Trace), + }; + + invalid.Should().AllSatisfy(action => action.Should().Throw()); + } + + [Fact] + public void An_Organization_Scoped_Event_Requires_A_Non_Empty_Organization() + { + var missing = () => new IntegrationEventEnvelope(NewOrganizationSample(), Trace); + var valid = () => new IntegrationEventEnvelope( + NewOrganizationSample(), + Trace, + OrganizationId: Guid.Parse("018f4d40-0000-7000-8000-0000000000c1")); + + missing.Should().Throw(); + valid.Should().NotThrow(); + } + + [Fact] + public void The_Envelope_Rejects_An_Uninitialized_Causal_Actor() + { + var fixture = new UnassignedActorFixture(); + + var act = () => new IntegrationEventEnvelope( + NewSample(), Trace, ActorUserId: fixture.ActorUserId); + + act.Should().Throw(); + } + // ---- the consumer's context --------------------------------------------- [Fact] @@ -142,7 +214,7 @@ public void The_Consumer_Context_Has_The_Shape_A_Handler_Needs() var organization = Guid.Parse("018f4d40-0000-7000-8000-0000000000c1"); var context = EventTenantContext.FromEnvelope(new IntegrationEventEnvelope( - NewSample(), "trace-1", OrganizationId: organization, ActorUserId: actor)); + NewSample(), Trace, OrganizationId: organization, ActorUserId: actor)); // IsResolved false would make TenantContextBehavior short-circuit every // consumer that sends a MediatR command — silently, before its business @@ -150,8 +222,9 @@ public void The_Consumer_Context_Has_The_Shape_A_Handler_Needs() context.IsResolved.Should().BeTrue(); context.TenantId.Should().Be(Tenant); context.OrganizationId.Should().Be(organization); - context.UserId.Should().Be(actor); - context.CorrelationId.Should().Be("trace-1"); + context.UserId.Should().Be(UserId.SystemActor); + context.CausalActorUserId.Should().Be(actor); + context.CorrelationId.Should().Be(Trace); context.ModuleName.Should().BeNull(); } @@ -171,6 +244,24 @@ public void A_Null_Envelope_Is_Refused() LearnerName = "Ada", }; + private static InvalidMetadataSample NewInvalidMetadataSample( + string topic, + string partitionKey) => new() + { + EventId = Guid.Parse("018f4d40-0000-7000-8000-0000000000e2"), + TenantId = Tenant, + OccurredAt = DateTimeOffset.UnixEpoch, + DeclaredTopic = topic, + DeclaredPartitionKey = partitionKey, + }; + + private static OrganizationSample NewOrganizationSample() => new() + { + EventId = Guid.Parse("018f4d40-0000-7000-8000-0000000000e3"), + TenantId = Tenant, + OccurredAt = DateTimeOffset.UnixEpoch, + }; + public sealed record Sample : IntegrationEventBase { public required string LearnerName { get; init; } @@ -182,4 +273,24 @@ public sealed record Sample : IntegrationEventBase // happens to appear through PartitionKey. public override string PartitionKey => "ordering-domain"; } + + private sealed record InvalidMetadataSample : IntegrationEventBase + { + public required string DeclaredTopic { get; init; } + public required string DeclaredPartitionKey { get; init; } + public override string Topic => DeclaredTopic; + public override string PartitionKey => DeclaredPartitionKey; + } + + private sealed record OrganizationSample + : IntegrationEventBase, IOrganizationScopedIntegrationEvent + { + public override string Topic => "learnstack.test.organization-sample"; + public override string PartitionKey => "organization"; + } + + private sealed record UnassignedActorFixture + { + public UserId ActorUserId { get; init; } + } } diff --git a/docs/architecture/01-platform-vision.md b/docs/architecture/01-platform-vision.md index 8bcbc492..e620b9ae 100644 --- a/docs/architecture/01-platform-vision.md +++ b/docs/architecture/01-platform-vision.md @@ -176,7 +176,7 @@ Two things this boundary does **not** change: get more expensive every week — isolation, schema ownership, typed identifiers — from decisions a port makes reversible. `IEventBus` ships in Phase 02a; its Dapr/Kafka adapter ships in Phase 11 when a second process needs to consume an integration event - (ADR-0014 decides *what*, ADR-0035 decides *when*). + (ADR-0038 decides *what*, ADR-0035 decides *when*). - **Versioned publish workflows** for content and courses that affect learners. - **Hub-separated control plane.** Tenant lifecycle, billing, licensing, custom domains, compliance run in a separate codebase (`learnstack-hub`, ADR-0019). LearnStack core @@ -251,7 +251,8 @@ When the foundation is in place, LearnStack should be able to: - ADR-0003 Amendment 1 (Organization scope) + Amendment 3 (corrected RLS template and database role model) — Tenant Isolation. -- ADR-0014 — Adopt Dapr (what), with ADR-0035 deciding when. +- ADR-0038 — Cross-Cutting Port and Event Contracts (including the retained Dapr + choice), with ADR-0035 deciding when. - ADR-0015 — APISIX gateway (what), with ADR-0035 deciding when. - ADR-0017 — Tenant + Organization hierarchy. - ADR-0018 — Tenant-driven customization (supersedes ADR-0011 vertical packs); the diff --git a/docs/architecture/03-module-boundaries.md b/docs/architecture/03-module-boundaries.md index c21f3222..5ebf8954 100644 --- a/docs/architecture/03-module-boundaries.md +++ b/docs/architecture/03-module-boundaries.md @@ -125,7 +125,9 @@ flowchart TB identity -. "mTLS + JWT + HMAC
POST /api/v1/internal/license/verify" .-> hubapi ``` -The dashed arrows are **integration events** (via Dapr pub/sub → Kafka, ADR-0014), **read-model projections**, or **Hub HTTPS contracts** — not direct calls or shared tables. +The dashed arrows are **integration events** (through `IEventBus`; in-process today, +Dapr pub/sub → Kafka after the Phase 11 trigger per ADR-0038), **read-model +projections**, or **Hub HTTPS contracts** — not direct calls or shared tables. ## Backend Modules diff --git a/docs/architecture/04-technical-architecture.md b/docs/architecture/04-technical-architecture.md index 0b9447de..5b9ce3d2 100644 --- a/docs/architecture/04-technical-architecture.md +++ b/docs/architecture/04-technical-architecture.md @@ -8,10 +8,10 @@ | Language | C# | | ORM | Entity Framework Core | | Database | PostgreSQL 18.x (major pinned per [ADR-0031](../decisions/0031-postgresql-major-version.md); shared schema + RLS isolation; ADR-0003) | -| Cache & coordination | **Valkey 8.x via Dapr State Store** (Linux-Foundation BSD-3 fork of Redis 7.2.4 per [ADR-0030](../decisions/0030-redis-compatible-store-valkey.md); [29-dapr-integration.md](29-dapr-integration.md), [ADR-0014](../decisions/0014-adopt-dapr.md)) | -| Pub/Sub | **Apache Kafka via Dapr Pub/Sub** ([29-dapr-integration.md](29-dapr-integration.md), [ADR-0014](../decisions/0014-adopt-dapr.md)) — outbox dispatch target | +| Cache & coordination | **`InMemoryCacheService` now; Valkey 8.x via Dapr State Store after its trigger** (Linux-Foundation BSD-3 fork per [ADR-0030](../decisions/0030-redis-compatible-store-valkey.md); [29-dapr-integration.md](29-dapr-integration.md), [ADR-0038](../decisions/0038-cross-cutting-port-and-event-contracts.md)) | +| Pub/Sub | **`InProcessEventBus` now; Apache Kafka via Dapr Pub/Sub after its trigger** ([29-dapr-integration.md](29-dapr-integration.md), [ADR-0038](../decisions/0038-cross-cutting-port-and-event-contracts.md)) | | Secrets | **HashiCorp Vault via Dapr Secret Store** (or env-var fallback in Dev) | -| Distributed runtime | **Dapr 1.14+** sidecar pattern (pub/sub, state, secrets) | +| Distributed runtime | **Dapr 1.17.7** in the gated local stack; sidecar target for pub/sub, state, and secrets | | Object storage | SeaweedFS (local), S3-compatible (production) | | Background jobs | Hangfire (Postgres storage) | | Search | Meilisearch (initial), OpenSearch (later, if needed). See [ADR 0012](../decisions/0012-search-strategy.md) | diff --git a/docs/architecture/05-mvp-scope.md b/docs/architecture/05-mvp-scope.md index 5f56fe8d..0d5e622f 100644 --- a/docs/architecture/05-mvp-scope.md +++ b/docs/architecture/05-mvp-scope.md @@ -55,10 +55,11 @@ test pass. ## In Scope ### Platform Kernel -- Dapr building blocks wired through `IEventBus` (pub/sub → Kafka), `ICacheService` - (state → Valkey), `ISecretProvider` (secrets → Vault). See +- Cross-cutting ports wired to `InProcessEventBus`, `InMemoryCacheService`, and + `ConfigurationSecretProvider`; their Dapr → Kafka/Valkey/Vault adapters are + demand-gated to Phase 11. See [29-dapr-integration.md](29-dapr-integration.md) and - [ADR-0014](../decisions/0014-adopt-dapr.md). + [ADR-0038](../decisions/0038-cross-cutting-port-and-event-contracts.md). - APISIX gateway in standalone mode (YAML hot-reload) — JWT verification, CORS, rate-limit, custom-host routing. See [30-api-gateway.md](30-api-gateway.md) and [ADR-0015](../decisions/0015-api-gateway-apisix.md). diff --git a/docs/architecture/06-extension-model.md b/docs/architecture/06-extension-model.md index 8e7312b2..8e4b160b 100644 --- a/docs/architecture/06-extension-model.md +++ b/docs/architecture/06-extension-model.md @@ -34,9 +34,9 @@ The core platform talks to external systems through **interfaces** in | Live classroom transport | `ILiveClassProvider` | | Recording egress | `IRecordingEgressProvider` | | Identity provider | (covered by Keycloak baseline; ADR-0004) | -| Pub/Sub | `IEventBus` → Dapr → Kafka (ADR-0014) | -| Cache | `ICacheService` → Dapr → Valkey (ADR-0014) | -| Secret store | `ISecretProvider` → Dapr → Vault (ADR-0014) | +| Pub/Sub | `IEventBus` → in-process now; Dapr → Kafka target (ADR-0038) | +| Cache | `ICacheService` → in-memory now; Dapr → Valkey target (ADR-0038) | +| Secret store | `ISecretProvider` → configuration now; Dapr → Vault target (ADR-0038) | Implementations live in `LearnStack.Infrastructure..` projects. Modules never import provider SDK types. Architecture tests enforce this — same pattern @@ -242,7 +242,8 @@ LearnStack's own modules (`LearnStack.Modules.Identity`, `LearnStack.Modules.Ten - ADR-0011 — Vertical Extension Points (superseded; retained in `docs/decisions/` with Superseded status banner). - ADR-0013 — Page Block Schema Versioning. -- ADR-0014 — Adopt Dapr (provider adapters for cross-cutting infrastructure). +- ADR-0038 — Cross-Cutting Port and Event Contracts (including the retained Dapr + provider-adapter choice). - ADR-0021 — Feature-Based Entitlement (plan-gated features that go beyond customization). - [32-tenant-customization-model.md](32-tenant-customization-model.md) — deep dive with schema, worked examples, sandbox engine, Admin Studio surface. diff --git a/docs/architecture/09-tenant-isolation.md b/docs/architecture/09-tenant-isolation.md index a807fc92..9829655a 100644 --- a/docs/architecture/09-tenant-isolation.md +++ b/docs/architecture/09-tenant-isolation.md @@ -262,7 +262,8 @@ LogContext.PushProperty("CorrelationId", correlationId); - ADR-0003 — Tenant Isolation Defense in Depth (Amendment 1 for organization scope). - ADR-0017 — Tenant + Organization Hierarchy. -- ADR-0014 — Adopt Dapr (cache + state store carry the org-prefixed keys). +- ADR-0038 — Cross-Cutting Port and Event Contracts (all cache adapters validate + tenant- and organization-qualified keys). - ADR-0016 — Audit Log Subsystem (audit rows carry tenant + organization). - [28-platform-tenant-organization.md](28-platform-tenant-organization.md) — conceptual model. diff --git a/docs/architecture/10-cross-module-contracts.md b/docs/architecture/10-cross-module-contracts.md index 1d425ff3..3c5d2cd6 100644 --- a/docs/architecture/10-cross-module-contracts.md +++ b/docs/architecture/10-cross-module-contracts.md @@ -2,10 +2,10 @@ Modules collaborate only through explicit contracts. This keeps the modular monolith extractable and avoids accidental database coupling. -> **2026-05-18 update.** Per [ADR-0010 Amendment 1](../decisions/0010-cross-module-communication.md) -> and [ADR-0014](../decisions/0014-adopt-dapr.md), integration events (Mechanism #3 in this -> document) dispatch via **Dapr pub/sub to Kafka**. The outbox table remains the durable -> producer-side buffer; Dapr is the transport. Topic naming convention: +> **2026-08-26 update.** Per [ADR-0038](../decisions/0038-cross-cutting-port-and-event-contracts.md), +> integration events (Mechanism #3 in this document) dispatch through `IEventBus`: +> `InProcessEventBus` today and Dapr pub/sub → Kafka only after its Phase 11 trigger. +> The outbox table remains the durable producer-side buffer. Topic naming convention: > `learnstack.{module}.{aggregate}`. Consumer-side idempotency via per-module inbox guard > (`IInboxGuard`). Application contracts (Mechanism #1), intra-module domain events > (Mechanism #2), and read-model projections (Mechanism #4) are unchanged. See @@ -104,4 +104,3 @@ If a page block references a deleted or unpublished course: - Importing another module's domain namespace. - Sharing mutable domain entities. - Vertical-specific rules inside core modules. - diff --git a/docs/architecture/15-event-and-outbox.md b/docs/architecture/15-event-and-outbox.md index b17e5d86..fbf0bc8f 100644 --- a/docs/architecture/15-event-and-outbox.md +++ b/docs/architecture/15-event-and-outbox.md @@ -4,7 +4,7 @@ Events allow modules to collaborate without cross-module database coupling. This defines the event types, the outbox pattern, the claim protocol that makes concurrent dispatch safe, and how dispatch reaches subscribers — in process today, through Dapr pub/sub to Kafka when the trigger for that adapter fires (ADR-0010 Amendment 1 + -ADR-0014 + ADR-0035). +ADR-0038 + ADR-0035). ## Decision @@ -37,7 +37,8 @@ every consumer to carry two implementations, one of which is never tested agains other. Everything in this document about consumer obligations applies identically to both transports; the only difference is what carries the bytes between publish and handle. -[ADR-0014](../decisions/0014-adopt-dapr.md) stands as the decision that Dapr is the +[ADR-0038](../decisions/0038-cross-cutting-port-and-event-contracts.md) stands as the +decision that Dapr is the cross-process transport LearnStack uses. [ADR-0035](../decisions/0035-demand-gated-infrastructure.md) decides when it arrives, and the answer is "when a second process exists". @@ -389,11 +390,11 @@ left to each reader of this document. - **Horizontal scalability.** `SKIP LOCKED` plus the lease predicate lets N processors across N pods drain the same table without coordination. -## `IEventBus` and the partition key +## `IEventBus` and `IntegrationEventEnvelope` -The port takes the partition key explicitly. It is not derived inside the transport, -because the transport is the one component that does not know what the event's ordering -domain is: +The port accepts the envelope stored by the outbox. The concrete event declares its +topic and ordering domain; the envelope forwards both and carries the delivery metadata +the event does not own: ```csharp public interface IEventBus @@ -402,14 +403,11 @@ public interface IEventBus } ``` -**Not generic**, per -[ADR-0014 Amendment 2](../decisions/0014-adopt-dapr.md). The outbox processor -deserializes to `object` and publishes through the base interface, so a generic parameter -would bind to `IIntegrationEvent` at the only call site that matters — and a transport -resolving `IIntegrationEventHandler` would then look for -`IIntegrationEventHandler`, which no concrete handler implements. The -publish would reach zero handlers and report success. Both transports resolve handlers by -the event's **runtime** type instead. +The partition key is read from `envelope.PartitionKey`; there is no separate publish +parameter that can disagree with the event. `CorrelationId`, `OrganizationId`, +`CausationId` and `ActorUserId` remain delivery metadata on the envelope. This is the +binding contract in +[ADR-0038](../decisions/0038-cross-cutting-port-and-event-contracts.md). The durable implementation forwards it as Dapr's `partitionKey` metadata, which the Kafka pub/sub component maps onto the Kafka message key: @@ -418,24 +416,20 @@ pub/sub component maps onto the Kafka message key: public sealed class DaprEventBus(DaprClient daprClient) : IEventBus { public Task PublishAsync( - IIntegrationEvent @event, string partitionKey, CancellationToken ct = default) + IntegrationEventEnvelope envelope, CancellationToken ct = default) { - ArgumentException.ThrowIfNullOrWhiteSpace(partitionKey); + ArgumentNullException.ThrowIfNull(envelope); - // Published as the runtime type, not as IIntegrationEvent: the serializer - // writes the members of the type it is given, and handing it the base - // interface produces a payload with none of the event's own fields. return daprClient.PublishEventAsync( "pubsub", - ConventionTopicName(@event), // "learnstack.{module}.{aggregate}" - @event.GetType(), - @event, - new Dictionary { ["partitionKey"] = partitionKey }, + envelope.Topic, + envelope.Event, + new Dictionary + { + ["partitionKey"] = envelope.PartitionKey, + }, ct); } - - private static string ConventionTopicName(IIntegrationEvent @event) - => $"learnstack.{ExtractModule(@event.GetType())}.{ExtractAggregate(@event.GetType())}"; } ``` @@ -451,6 +445,7 @@ Topic naming convention: `learnstack.{module}.{aggregate}`. Examples: - `learnstack.enrollment.enrollment` - `learnstack.classroom.session` - `learnstack.hub.entitlement` (Hub-side) +- `learnstack.hub.custom-domain.activated` (Hub-side four-segment form) - `learnstack.cache.invalidation` (cross-instance L1 cache) ## `InProcessEventBus` @@ -471,6 +466,7 @@ public sealed partial class InProcessEventBus( IServiceScopeFactory scopeFactory, ITenantContextAccessor tenantAccessor, IPartitionSerializer partitions, + IntegrationEventHandlerRegistry handlers, ILogger logger) : IEventBus { public Task PublishAsync( @@ -488,60 +484,47 @@ public sealed partial class InProcessEventBus( private async Task DispatchAsync(IntegrationEventEnvelope envelope, CancellationToken ct) { - // By RUNTIME type. The event is declared as the base interface here, so a - // closed generic over its static type would resolve - // IIntegrationEventHandler — which no concrete - // consumer implements — and the publish would reach zero handlers and - // report success. - var contract = typeof(IIntegrationEventHandler<>).MakeGenericType(envelope.Event.GetType()); - var handle = contract.GetMethod(nameof(IIntegrationEventHandler.HandleAsync))!; - var context = EventTenantContext.FromEnvelope(envelope); - - List? failures = null; - - for (var index = 0; index < HandlerCount(contract); index++) + // The outer save/restore covers the FULL dispatch. Context is set before + // subscription lookup and therefore before any handler can be resolved; + // constructors observe the event tenant rather than the publisher tenant. + var previous = tenantAccessor.Current; + tenantAccessor.Current = EventTenantContext.FromEnvelope(envelope); + + try { - // ONE SCOPE PER HANDLER. Under a broker each subscription gets its - // own; sharing one hands two modules' consumers the same DbContext - // and unit of work, across a boundary the architecture otherwise - // enforces hard. Selected by index because a handler is registered - // against the CONTRACT — its own type is not a service. - await using var scope = scopeFactory.CreateAsyncScope(); - - // Restored into the handler's flow AND into the scope it resolves - // ITenantContext from — the composition root binds the scoped - // context to this accessor. Put back in the finally, or a - // synchronous dispatch leaks a tenant into the caller's flow. - var previous = tenantAccessor.Current; - tenantAccessor.Current = context; - try - { - var handler = scope.ServiceProvider.GetServices(contract).ElementAt(index)!; - - // Through the interface's MethodInfo, NOT `dynamic`: the dynamic - // binder honours accessibility, so an `internal` handler — the - // normal shape for a module's own consumer — fails to bind at - // runtime. Invoke wraps a synchronous throw, so the inner - // exception is rethrown with ExceptionDispatchInfo; a - // TargetInvocationException would tell the error pipeline the - // transport failed when the handler did. - await (Task)handle.Invoke(handler, [envelope.Event, ct])!; - } - catch (Exception ex) - { - // Collected, not rethrown here. Poison-message containment is - // per subscription: letting the first fault escape the loop lets - // one module's broken handler deny every other module the event. - (failures ??= []).Add(ex); - } - finally + var subscriptions = handlers.For(envelope.Event.GetType()); + List? failures = null; + + foreach (var subscription in subscriptions) { - tenantAccessor.Current = previous; + ct.ThrowIfCancellationRequested(); + try + { + // DeliverAsync creates ONE ASYNC SCOPE and resolves exactly + // this subscription's concrete handler. A constructor or + // disposal failure cannot deny a healthy sibling. It also + // starts the subscription's consumer activity and supplies + // its module name through EventTenantContext. + await DeliverAsync(subscription, envelope, ct); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; // shutdown: do not start later subscriptions + } + catch (Exception ex) + { + // Poison-message containment remains per subscription. + (failures ??= []).Add(ex); + } } - } - // One failure rethrown as itself; several as an AggregateException. - } // the handler calls IInboxGuard itself + // One failure rethrown as itself; several as an AggregateException. + } + finally + { + tenantAccessor.Current = previous; + } + } // the handler calls IInboxGuard itself } ``` @@ -590,6 +573,13 @@ the choice. are `NOT NULL` there. Correlation therefore travels from the row to the consumer rather than from whatever context happens to be ambient at dispatch, which is `null` inside the background service the processor is. +- An event that implements `IOrganizationScopedIntegrationEvent` cannot be enveloped + without a non-empty `OrganizationId`; a tenant-wide event deliberately omits the + marker. This keeps an accidentally omitted organization from becoming a resolved + tenant-wide consumer scope. +- A consumer's effective identity is always `UserId.SystemActor`. `ActorUserId` is the + causal human and is preserved separately as `CausalActorUserId`; it never authorizes + or attributes asynchronous writes as that human. - **Tenant context** restored on the consumer side before any business logic runs. The transport delivers the event payload; the consumer scope sets the ambient context from `@event.TenantId` before invoking the handler, so the handler's queries carry the same @@ -700,17 +690,17 @@ separate service later: ## Architecture tests +- `Integration_Event_TopicNames_FollowConvention` — the transport-independent + Packet 5 rule checks every event's declared topic against the strict three-segment + core grammar and Hub-only four-segment form. - `Integration_Events_Inherit_From_IntegrationEventBase` — every type implementing `IIntegrationEvent` extends `IntegrationEventBase` (which carries `EventId`, - `OccurredAt`, `TenantId`). + `OccurredAt`, `TenantId` and declares `Topic` / `PartitionKey`). - `Integration_Event_Handlers_Use_InboxGuard` — every `IIntegrationEventHandler` implementation invokes `IInboxGuard.IsAlreadyProcessedAsync` before processing. -- `Dapr_PubSub_TopicNames_FollowConvention` — string scan ensures every `[Topic]` - attribute argument matches - `^learnstack\.[a-z][a-z0-9-]*\.[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)?$`. The - optional fourth segment is reserved for **Hub-side event-name suffixes** - (`learnstack.hub.custom-domain.activated`, `.deactivated`, `.revoked`). - LearnStack-core topics remain 3-segment (`learnstack.{module}.{aggregate}`). +- `Dapr_PubSub_TopicNames_FollowConvention` — Phase 11 checks that Dapr component + bindings agree with the already-validated topics declared by events. It is not a + current `[Topic]`-attribute scan; no such attribute exists. - `OutboxProcessor_NeverBlocks_OnSingleMessageFailure` — integration test asserts one poisoned message doesn't prevent others in the batch from processing. - `Integration_Event_Handler_Restores_Tenant_Context` — catalogued in @@ -746,7 +736,8 @@ alongside Dapr's own `dapr_component_pubsub_*` metrics. - ADR-0006 (Amendment 1) — Events and Outbox; dispatch transport. - ADR-0010 (Amendment 1) — Cross-Module Communication; outbox dispatch target. -- ADR-0014 — Adopt Dapr (what LearnStack uses for cross-process pub/sub). +- [ADR-0038](../decisions/0038-cross-cutting-port-and-event-contracts.md) — Dapr choice, + event envelope, subscription isolation and cache/event port contracts. - [ADR-0033](../decisions/0033-audit-durability-model.md) — Audit durability model; audit fan-out to external sinks rides this outbox, MUST-class audit does not. - [ADR-0035](../decisions/0035-demand-gated-infrastructure.md) — Demand-gated diff --git a/docs/architecture/21-feature-flags.md b/docs/architecture/21-feature-flags.md index e1232f3d..dfc588c6 100644 --- a/docs/architecture/21-feature-flags.md +++ b/docs/architecture/21-feature-flags.md @@ -160,9 +160,9 @@ Rules: ([ADR-0034](../decisions/0034-hub-contract-surface-invariant.md)). See [ADR-0021](../decisions/0021-feature-based-entitlement.md) and [29-dapr-integration.md](29-dapr-integration.md). -- A short-TTL Valkey cache (60 s) fronts both tables for hot-path reads. Eager - invalidation flows from `learnstack.cache.invalidation` (cross-instance) and from - `learnstack.hub.entitlement` (cross-deployment). +- `ICacheService` fronts both tables for hot-path reads. Today that is the process-local + `InMemoryCacheService`; Phase 11 adds Valkey-backed L2 and cross-instance invalidation + when ADR-0035's replica trigger fires. ## Evaluation @@ -181,11 +181,11 @@ Resolution precedence for `IsEnabledAsync(FeatureKey key, ct)`: genuinely need to read cross-tenant go through a separate `IEntitlementAdminQuery` interface. 2. **If the key's catalog descriptor says `Source = PlanProjected`:** read from - `platform_entitlement_cache.features` (via Valkey cache → Postgres). A missing entry + `platform_entitlement_cache.features` (via `ICacheService` → Postgres). A missing entry resolves to the catalog default. Per-tenant `tenant_feature_flags` are **never** consulted for plan-projected keys. 3. **If the key's catalog descriptor says `Source = TenantFlag`:** read from - `tenant_feature_flags` (via Valkey cache → Postgres). Missing entry → catalog + `tenant_feature_flags` (via `ICacheService` → Postgres). Missing entry → catalog default. 4. **Killswitch overlay** (last word): if the corresponding killswitch is flipped `false` platform-wide, the answer becomes `false` regardless of the per-tenant @@ -282,28 +282,30 @@ Both surfaces are MUST-audit security-events (see not migrated in the DB silently returns the default for every tenant. Renaming is a deprecation cycle, not a refactor. - **Stale entitlement projection.** A tenant upgraded on Hub but whose projection - hasn't refreshed sees the old feature set. Eager invalidation via the Dapr event - keeps the typical refresh within seconds; the 15-min TTL is the upper bound. + hasn't refreshed sees the old feature set. The Phase 02c projection push refreshes + LearnStack directly; Phase 11's Dapr event becomes an additional eager-invalidation + path when its trigger fires. The 15-min L2 TTL is the future upper bound. - **Performance.** Hot paths that read flags per call become DB-bound without the - Valkey cache; the 60s TTL is the default trade-off. + cache; the 60s L1 TTL is the default trade-off. ## Roadmap Touchpoints - **Phase 02a** — `tenant_feature_flags` table created in the Tenancy module; the `FeatureKeys` / `LimitKeys` / `KillswitchKeys` catalogs land here. `IFeatureFlags`, - the Valkey cache, and the architecture tests ship here. + the `ICacheService`-backed L1 cache, and the architecture tests ship here. - **Phase 02c** (parallel Hub Foundation) — `platform_entitlement_cache`, `IEntitlementProvider` with `NullEntitlementProvider` default + `HubEntitlementProvider` + `SignedLicenseKeyEntitlementProvider` - implementations. The Dapr-event-driven projection refresh ships here. + implementations. The HTTPS projection push is the refresh path here. - **Phase 06** — Admin Studio surface for editing per-tenant flag overrides and viewing the entitlement projection. The Studio screen for `platform_entitlement_cache` is **read-only** — actual plan edits happen in the operator portal (`operator-portal`). - **Phase 09** — Audit + observability hooks for both flag writes and entitlement refreshes plug into the audit + analytics pipeline. -- **Phase 11** — Quarterly hygiene review and CI surfacing of stale flags become - operational. +- **Phase 11** — Valkey/Dapr adapters and event-driven cross-instance invalidation land + on ADR-0035's triggers; quarterly hygiene review and CI surfacing of stale flags + become operational. ## References diff --git a/docs/architecture/24-learnstack-hub.md b/docs/architecture/24-learnstack-hub.md index 3fd01c2d..12255a53 100644 --- a/docs/architecture/24-learnstack-hub.md +++ b/docs/architecture/24-learnstack-hub.md @@ -326,16 +326,19 @@ Entitlement is recomputed (and `generation` incremented) on: - `CompliancePolicy` change (any cap added / removed / modified). - `LicenseKey` re-issuance (Self-Hosted). -After recompute, Hub publishes `learnstack.hub.entitlement` integration event via Dapr -pub/sub carrying `{ tenant_id, generation, expires_at }`. LearnStack runtime receives the -event, invalidates `platform_entitlement_cache` for that tenant, re-fetches on next read. +After recompute, Hub pushes the new projection through +`PUT /api/internal/tenants/{id}/entitlements`; LearnStack updates +`platform_entitlement_cache` in that request. When ADR-0035's cross-process trigger fires +in Phase 11, Hub additionally publishes `learnstack.hub.entitlement` via Dapr pub/sub +carrying `{ tenant_id, generation, expires_at }` for eager cross-instance invalidation. ### Cache TTL -LearnStack runtime caches the `Entitlement` for **15 minutes** via `ICacheService` (key +LearnStack runtime caches the `Entitlement` via `ICacheService` (key `{tenant_id}:hub:entitlement` — tenant segment first, per -[Standards 20 § `ICacheService`](../standards/20-infrastructure-stack.md)). Beyond 15 minutes it re-fetches lazily. Eager invalidation -via Dapr pub/sub event (above) makes the cache TTL a worst-case bound, not a typical one. +[Standards 20 § `ICacheService`](../standards/20-infrastructure-stack.md)). The current +L1 TTL is 60 seconds. Phase 11 adds a 15-minute Valkey L2 upper bound and Dapr eager +invalidation; neither is an application path today. ## 5. Sequence diagrams @@ -382,7 +385,7 @@ sequenceDiagram HubAPI->>HubAPI: Recompute Entitlement (gen++) HubAPI->>LSApi: PUT /api/internal/tenants/{id}/entitlements
(new projection) LSApi->>LSApi: Update platform_entitlement_cache; emit cache invalidation - HubAPI->>HubAPI: Publish learnstack.hub.entitlement event via Dapr + HubAPI->>HubAPI: Phase 11+: publish learnstack.hub.entitlement via Dapr HubAPI-->>HubUI: Plan upgraded HubUI-->>Customer: "Plan upgraded; new features active" ``` diff --git a/docs/architecture/29-dapr-integration.md b/docs/architecture/29-dapr-integration.md index a0c7a422..2ea6116a 100644 --- a/docs/architecture/29-dapr-integration.md +++ b/docs/architecture/29-dapr-integration.md @@ -1,16 +1,15 @@ # Dapr Integration -**Derives from:** [ADR-0014](../decisions/0014-adopt-dapr.md), +**Derives from:** [ADR-0038](../decisions/0038-cross-cutting-port-and-event-contracts.md), [ADR-0006](../decisions/0006-events-and-outbox.md), [ADR-0010](../decisions/0010-cross-module-communication.md). > **Read this first.** This document describes Dapr in the present tense as the **target > design**. Per [ADR-0035](../decisions/0035-demand-gated-infrastructure.md) no Dapr -> component is wired today. Of the three ports, only `ISecretProvider` has shipped — -> `ConfigurationSecretProvider`, in Packet 3. `IEventBus` and `ICacheService` land with -> their in-process defaults (`InProcessEventBus`, `InMemoryCacheService`) in -> [Phase 02a Packet 5](../roadmap/phase-02a-kernel-tenancy.md); from then on those -> defaults are the only registrations in every deployment mode. The three Dapr adapters +> component is wired into application code today. All three ports have shipped: +> `IEventBus` and `ICacheService` use the Packet 5 defaults `InProcessEventBus` and +> `InMemoryCacheService`; `ISecretProvider` uses `ConfigurationSecretProvider`. +> Those are the only registrations in every deployment mode. The three Dapr adapters > land in > [Phase 11](../roadmap/phase-11-production-hardening.md) against written triggers — a > second process consuming an integration event, a second application instance, and @@ -51,38 +50,25 @@ flowchart LR Kafka --> OtherDaprd --> OtherApp ``` -The sidecar shares the network namespace of the app pod (Docker compose -`network_mode: "service:learnstack-api"`; Kubernetes via `dapr.io/enabled` annotation). -The app talks to the sidecar via `localhost`, never directly to Kafka / Valkey / Vault. +The diagram is the production pod target: the sidecar shares the app pod's network +namespace via the Kubernetes Dapr annotation. Local development deliberately runs the +.NET host on the workstation and the sidecar in Compose; the exact topology and service +inventory live in [`infra/dapr/README.md`](../../infra/dapr/README.md) and +[`infra/compose/README.md`](../../infra/compose/README.md). Do not duplicate the Compose +service graph here. ## 2. Components -Component YAML files live in `dapr/components/`. They are tracked in git as deployment -artifacts. +Component YAML files live in `infra/dapr/components/` and are tracked deployment +artifacts. The files themselves are the operational source of truth; the summaries +below deliberately do not duplicate their complete metadata. -### `pubsub.yaml` — Kafka pub/sub +### `pubsub-kafka.yaml` — Kafka pub/sub -```yaml -apiVersion: dapr.io/v1alpha1 -kind: Component -metadata: - name: pubsub - namespace: default -spec: - type: pubsub.kafka - version: v1 - metadata: - - name: brokers - value: kafka:29092 - - name: authType - value: none # production: SASL_SSL with TLS + SCRAM creds via Vault - - name: consumeRetryInterval - value: "200ms" - - name: maxMessageBytes - value: "1048576" # 1MB - - name: consumerID - value: "learnstack-api" # one per app id; isolates consumer groups -``` +The committed [`pubsub-kafka.yaml`](../../infra/dapr/components/pubsub-kafka.yaml) +uses component name `pubsub`, the Compose-network broker `kafka:9092`, and consumer +group `learnstack-api`. Production authentication and broker endpoints are deployment +overrides, not a second checked-in copy here. Topics follow the convention `learnstack.{module}.{aggregate}`. Examples: - `learnstack.identity.user` @@ -93,7 +79,7 @@ Topics follow the convention `learnstack.{module}.{aggregate}`. Examples: - `learnstack.hub.entitlement` (Hub-side) - `learnstack.cache.invalidation` (cross-instance L1 cache invalidation) -### `statestore.yaml` — Valkey state store +### `statestore-redis.yaml` — Valkey state store > The component below uses `spec.type: state.redis` and `redisHost` metadata — > these are **Dapr provider-type / RESP-protocol identifiers**, NOT vendor @@ -104,62 +90,30 @@ Topics follow the convention `learnstack.{module}.{aggregate}`. Examples: > reach the RESP-compatible store"; in dev compose the value points at the > `valkey` service (`infra/dapr/components/statestore-redis.yaml`). -```yaml -apiVersion: dapr.io/v1alpha1 -kind: Component -metadata: - name: statestore - namespace: default -spec: - type: state.redis - version: v1 - metadata: - - name: redisHost - value: redis:6379 - - name: redisPassword - secretKeyRef: - name: redis-password - key: redis-password - - name: actorStateStore - value: "false" # we don't use actors -auth: - secretStore: secretstore-vault -``` +The committed +[`statestore-redis.yaml`](../../infra/dapr/components/statestore-redis.yaml) uses +component name `statestore`, points `redisHost` at `valkey:6379`, and explicitly +sets `actorStateStore` to `false`. Its empty development password is replaced by +deployment configuration when the Phase 11 adapter lands. Used as L2 cache. Modules call `ICacheService.GetOrSetAsync(...)`; the implementation wraps state-store calls plus an L1 in-memory cache plus tenant-aware key prefixing. ### `secretstore-vault.yaml` — Vault secret store -```yaml -apiVersion: dapr.io/v1alpha1 -kind: Component -metadata: - name: secretstore-vault - namespace: default -spec: - type: secretstores.hashicorp.vault - version: v1 - metadata: - - name: vaultAddr - value: "https://vault:8200" - - name: vaultToken - value: "${VAULT_TOKEN}" # dev only; production uses AppRole or Kubernetes auth - - name: vaultKVPrefix - value: "learnstack" - - name: vaultKVUsePrefix - value: "true" - - name: enginePath - value: "secret" -``` +The committed +[`secretstore-vault.yaml`](../../infra/dapr/components/secretstore-vault.yaml) uses +component name `secretstore`, the development endpoint `http://vault:8200`, and a +`secretKeyRef` resolved through `envvar-secrets`. It contains no literal token; +production replaces the development token flow with AppRole or Kubernetes auth. Secret path schema: ``` secret/learnstack/postgres connection-string, ssl-cert -secret/learnstack/redis password +secret/learnstack/valkey password secret/learnstack/keycloak base-url, admin-username, admin-password -secret/learnstack/seaweedfs endpoint, access-key, secret-key +secret/learnstack/seaweedfs endpoint, access-key, secret-key secret/learnstack/meilisearch master-key, public-key secret/learnstack/livekit api-key, api-secret, ws-url secret/learnstack/coturn shared-secret @@ -171,34 +125,25 @@ In `Development` the **primary** `ISecretProvider` implementation is `ConfigurationSecretProvider` (reads `IConfiguration`, which already merges environment variables, user secrets and `appsettings.{env}.json`; matches the composition-root table in [20-infrastructure-stack.md § Composition Root and Deployment Mode](../standards/20-infrastructure-stack.md)). -For dev workflows that prefer Dapr-shaped secrets (e.g. exercising the -`DaprSecretProvider` code path locally), an optional -`secretstore-local-file.yaml` reading from `dapr/components/secrets.json` is -**available** but not the default; the composition root picks one based on -`Deployment:Secrets:Provider` config (`env` | `dapr-file`). Both paths produce -the same observable behaviour through `ISecretProvider`. +The committed `secretstore-envvar.yaml` is bootstrap support for the Dapr Vault +component; it does not replace `ISecretProvider` and does not select an application +adapter. -### `secretstore-local-file.yaml` — optional dev variant +### `secretstore-envvar.yaml` — Vault bootstrap support ```yaml apiVersion: dapr.io/v1alpha1 kind: Component metadata: - name: secretstore - namespace: default -scopes: - - environment: Development + name: envvar-secrets spec: - type: secretstores.local.file + type: secretstores.local.env version: v1 - metadata: - - name: secretsFile - value: /components/secrets.json - - name: nestedSeparator - value: "/" ``` -`secrets.json` is git-ignored; a `secrets.json.template` is committed. +It supplies the development Vault token to `secretstore-vault.yaml` through +`secretKeyRef`. `ConfigurationSecretProvider` remains the only application registration +until ADR-0035's Vault trigger fires. ## 3. SharedKernel abstractions @@ -234,8 +179,8 @@ public interface ISecretProvider ``` `IEventBus.PublishAsync` is **not generic** and `ICacheService` has **no -`RemoveByPrefixAsync`**; `CacheOptions` carries **no `Tags`**. All three were settled by -[ADR-0014 Amendment 2](../decisions/0014-adopt-dapr.md) — see +`RemoveByPrefixAsync`**; `CacheOptions` carries **no `Tags`**. All three are governed by +[ADR-0038](../decisions/0038-cross-cutting-port-and-event-contracts.md) — see [15-event-and-outbox.md](15-event-and-outbox.md) for why a generic publish reaches zero handlers at the only call site that matters, and § 4 below for why a prefix removal cannot be honoured across instances. @@ -246,19 +191,21 @@ its shape, so an adapter that also prefixed would emit `{tenant}:{tenant}:{modul [Standards 20 § `ICacheService`](../standards/20-infrastructure-stack.md) fixes the shape; every implementation validates, none rewrites. -Concrete implementations (`DaprEventBus`, `DaprCacheService`, `DaprSecretProvider`) live -in `LearnStack.Infrastructure.{Messaging, Caching, Secrets}`. They are the **only** -Dapr-aware code in the codebase. - -### Development fallback: `InProcessEventBus` - -When the Dapr sidecar is not running, the composition root registers -`InProcessEventBus : IEventBus`. It is a **transport, not a stub**: it resolves -`IIntegrationEventHandler` by the event's runtime type, restores tenant context from -`@event.TenantId` into the handler's scope and puts the publisher's own back afterwards, -leaves `IInboxGuard` deduplication to the handler exactly as the durable path does, and -preserves per-partition-key ordering. A dev path that skipped those is a dev path where -the isolation code is never exercised — see +The target concrete implementations (`DaprEventBus`, `DaprCacheService`, +`DaprSecretProvider`) will live in +`LearnStack.Infrastructure.{Messaging, Caching, Secrets}`. Once added, they are the +only application code permitted to know Dapr types. + +### Current default: `InProcessEventBus` + +Every deployment mode currently registers `InProcessEventBus : IEventBus`, even when a +developer starts the gated sidecar for inspection. It is a **transport, not a stub**: it reads +construction-free subscription metadata, gives each subscription one consumer activity +and one async DI scope, restores tenant and module context before resolving exactly one +concrete `IIntegrationEventHandler`, and restores the publisher's context after the +full dispatch. It leaves `IInboxGuard` deduplication to the handler exactly as the +durable path does and preserves per-partition-key ordering. A default path that skipped +those is a path where the isolation code is never exercised — see [15-event-and-outbox.md](15-event-and-outbox.md), which owns the implementation, and [ADR-0035](../decisions/0035-demand-gated-infrastructure.md), which makes the four obligations a condition of the gating. @@ -312,12 +259,18 @@ internal sealed class DaprCacheService : ICacheService } ``` +**Required parity:** concurrent misses for the same key and requested type are +single-flight. The factory executes once, the first caller owns the TTL, and an +abandoned factory must terminate before a replacement starts. The Dapr adapter must +coalesce misses across its L1 path just as `InMemoryCacheService` does; adding L2 must +not reintroduce a stampede. + ### Why there is no prefix removal, and no invalidation topic An earlier version of this document carried a `RemoveByPrefixAsync` backed by an instance-local `_trackedKeys` dictionary, whose subscriber cleared *prefix-matching* L1 entries on every other pod. That is superseded by -[ADR-0014 Amendment 2](../decisions/0014-adopt-dapr.md). +[ADR-0038 § Cache contract](../decisions/0038-cross-cutting-port-and-event-contracts.md#cache-contract). The `learnstack.cache.invalidation` topic itself survives, and it is worth being precise about what changed: the topic carries a `(tenant_id, cache_key)` payload and evicts **one @@ -343,39 +296,13 @@ topic on the write path. It is a caller-side convention rather than a member of ### Docker Compose (development) -```yaml -services: - learnstack-api: - build: . - ports: ["5100:5000"] - depends_on: - postgres: { condition: service_healthy } - redis: { condition: service_healthy } - kafka: { condition: service_healthy } - vault: { condition: service_healthy } - - learnstack-api-dapr: - image: daprio/daprd:1.14 - network_mode: "service:learnstack-api" - command: - - "./daprd" - - "--app-id=learnstack-api" - - "--app-port=5000" - - "--dapr-http-port=3500" - - "--dapr-grpc-port=50001" - - "--resources-path=/components" - - "--placement-host-address=dapr-placement:50006" - - "--log-level=info" - volumes: - - ./dapr/components:/components:ro - depends_on: - - learnstack-api - - dapr-placement: - image: daprio/placement:1.14 - command: ["./placement", "-port", "50006"] - ports: ["50006:50006"] -``` +The authoritative local topology is the `gated` profile in +[`infra/compose/dev.yml`](../../infra/compose/dev.yml), explained once in +[`infra/compose/README.md`](../../infra/compose/README.md) and +[`infra/dapr/README.md`](../../infra/dapr/README.md). The workstation-hosted API, +`dapr-sidecar-api`, `host.docker.internal:5080`, placement port and pinned images must +not be duplicated here; those operational values change independently of this target +architecture. ### Kubernetes (production) @@ -397,8 +324,9 @@ hot-loads them. ## 6. Resilience policies -`config/resiliency.yaml` (Dapr Resiliency CR) defines retry / circuit-breaker / timeout -policies referenced by component metadata: +Phase 11 will add `infra/dapr/config/resiliency.yaml` as a Dapr Resiliency CR defining +retry / circuit-breaker / timeout policies referenced by component metadata. No such +file is wired today; the following is the target shape: ```yaml apiVersion: dapr.io/v1alpha1 @@ -457,7 +385,7 @@ Metrics (Prometheus): ## 8. Architecture tests -Three blocker-level tests (added in Phase 02): +Three blocker-level tests land with the Dapr adapters in Phase 11: 1. `Dapr_SDK_Types_NotImportedOutsideInfrastructure` — `Dapr.Client.*` types appear only in `LearnStack.Infrastructure.{Caching, Messaging, Secrets}` namespaces. Roslyn-based @@ -496,7 +424,7 @@ If any of these become needed, a new ADR scopes the change. ## References -- ADR-0014 — Adopt Dapr. +- ADR-0038 — Cross-Cutting Port and Event Contracts. - ADR-0006 Amendment 1 — Dapr pub/sub dispatch transport. - ADR-0010 Amendment 1 — Outbox dispatch via Dapr. - [20-infrastructure-stack.md](../standards/20-infrastructure-stack.md) — usage rules. diff --git a/docs/architecture/32-tenant-customization-model.md b/docs/architecture/32-tenant-customization-model.md index 75f70559..14d4e64c 100644 --- a/docs/architecture/32-tenant-customization-model.md +++ b/docs/architecture/32-tenant-customization-model.md @@ -464,7 +464,7 @@ Two rules make this safe: `ICacheService.RemoveByPrefixAsync` contract cannot be honoured across instances by any candidate backend, and it is **removed** in [Phase 02a Packet 5](../roadmap/phase-02a-kernel-tenancy.md) - ([ADR-0014 Amendment 2](../decisions/0014-adopt-dapr.md)). This pattern replaces it, + ([ADR-0038](../decisions/0038-cross-cutting-port-and-event-contracts.md)). This pattern replaces it, and it is a convention here rather than a member of that interface — the counter is durable domain state, not a cache entry. - **Compiled validators are cached separately from definitions**, keyed by an immutable diff --git a/docs/architecture/33-cross-cutting-concerns.md b/docs/architecture/33-cross-cutting-concerns.md index ddb041dd..64c1a92e 100644 --- a/docs/architecture/33-cross-cutting-concerns.md +++ b/docs/architecture/33-cross-cutting-concerns.md @@ -1,7 +1,7 @@ # Cross-Cutting Concerns — Errors, Logs, Traces, Metrics **Derives from:** [ADR-0032](../decisions/0032-exception-handling-logging-and-observability.md), -[ADR-0014](../decisions/0014-adopt-dapr.md), [ADR-0016](../decisions/0016-audit-log-subsystem.md), +[ADR-0038](../decisions/0038-cross-cutting-port-and-event-contracts.md), [ADR-0016](../decisions/0016-audit-log-subsystem.md), [ADR-0020](../decisions/0020-triple-deployment-hybrid-license.md). For the day-to-day rules read [09-error-handling.md](../standards/09-error-handling.md), [10-observability.md](../standards/10-observability.md), and @@ -459,7 +459,7 @@ Two integration points ## References - [ADR-0032 Exception Handling, Logging, and Observability Architecture](../decisions/0032-exception-handling-logging-and-observability.md) -- [ADR-0014 Adopt Dapr](../decisions/0014-adopt-dapr.md) +- [ADR-0038 Cross-Cutting Port and Event Contracts](../decisions/0038-cross-cutting-port-and-event-contracts.md) - [ADR-0016 Audit Log Subsystem](../decisions/0016-audit-log-subsystem.md) - [ADR-0020 Triple Deployment + Hybrid License](../decisions/0020-triple-deployment-hybrid-license.md) - [09-error-handling.md](../standards/09-error-handling.md) diff --git a/docs/decisions/0014-adopt-dapr.md b/docs/decisions/0014-adopt-dapr.md index dbadfd52..75d567bf 100644 --- a/docs/decisions/0014-adopt-dapr.md +++ b/docs/decisions/0014-adopt-dapr.md @@ -2,9 +2,10 @@ ## Status -Accepted (Amendment 1: 2026-08-08 — schedule moved to Phase 11; **Amendment 2: -2026-08-24 — corrects the published `IEventBus` and `ICacheService` signatures**; -see bottom of document) +Superseded by [ADR-0038](0038-cross-cutting-port-and-event-contracts.md) on +2026-08-26. ADR-0038 retains the Dapr technology choice and demand gate while +replacing the cross-cutting port and event-delivery contracts. The dated schedule +amendment below remains as historical context. ## Date @@ -146,14 +147,6 @@ Adopt **Option A**: Dapr for pub/sub + state + secrets. ### Application access pattern -**The `IEventBus` and `ICacheService` signatures below are superseded by** -[Amendment 2](#2026-08-24--amendment-2-two-port-signatures-corrected-before-first-use) -and refined again by -[Amendment 3](#2026-08-25--amendment-3-the-publish-envelope-decided-before-the-first-call-site). -They are left as written because an Accepted ADR's Decision section is not rewritten; -what Packet 5 ships is the amended shape. `ISecretProvider` is unchanged and shipped in -Packet 3. - Every module's domain and application code uses: ```csharp @@ -282,154 +275,6 @@ The default secret provider that shipped in Phase 02a Packet 3 is named `IConfiguration` — which already merges environment variables, user secrets and `appsettings.{env}.json` — rather than process environment variables alone. -### 2026-08-24 — Amendment 2: two port signatures, corrected before first use - -The Decision stands. Dapr remains the cross-process choice for pub/sub, state and -secrets, and application code still reaches all three only through `IEventBus` / -`ICacheService` / `ISecretProvider`. What this amendment corrects is the **published -shape** of two of those interfaces, which Phase 02a Packet 5 is about to ship as code -and can only ship one way. - -**1. `ICacheService.RemoveByPrefixAsync` is removed.** - -The port becomes `GetAsync` / `GetOrSetAsync` / `SetAsync` / `RemoveAsync` and nothing -else. The reference implementation iterates a process-local key set, so keys written by -another instance are never evicted — a contract no candidate backend can honour, and one -whose name promises a global effect while delivering a local one. - -The roadmap offered "removed **or** redesigned to a generation-key pattern". That is not -a fork at the port: the corpus's own definition of the pattern puts the counter in -**durable domain state** — a `customization_generation` column bumped inside the -business transaction and embedded in the key template ([architecture/32 § -8.2](../architecture/32-tenant-customization-model.md)) — which adds no member to this -interface. It also cannot live in the cache: an evicted counter would make previously -abandoned keys addressable again and resurrect stale values. Both branches therefore -remove the method, and the generation-key pattern is recorded as a **caller-side -convention** owned by the consumers that specify it. - -Nothing is lost by the removal: the corpus contains no call site for the method. - -**2. `IEventBus.PublishAsync` takes a partition key, and is not generic.** - -Published here as: - -```csharp -Task PublishAsync(TEvent @event, CancellationToken ct = default) - where TEvent : IIntegrationEvent; -``` - -It becomes: - -```csharp -Task PublishAsync(IIntegrationEvent @event, string partitionKey, CancellationToken ct = default); -``` - -Two corrections in one signature. - -*The partition key* is what -[architecture/15 § The bus](../architecture/15-event-and-outbox.md) and [Phase -02b](../roadmap/phase-02b-events-auth.md) already publish, and it is what lets the -durable transport map onto a Kafka message key and preserve per-aggregate ordering. -Adding a required parameter after the first consumer exists breaks every call site, so -the two shapes cannot be left to be reconciled later. - -*The generic parameter* is removed because the outbox dispatcher deserializes to -`object` and calls through the base interface — -`eventBus.PublishAsync((IIntegrationEvent)eventInstance!, msg.PartitionKey, ct)` at -[architecture/15](../architecture/15-event-and-outbox.md). With a generic port, `TEvent` -binds to `IIntegrationEvent` at that call, so a transport resolving -`IIntegrationEventHandler` looks for -`IIntegrationEventHandler` — which no concrete handler implements. -The result is a publish that dispatches to **zero handlers** and reports success. A -non-generic port makes the runtime-type resolution the transport has to do anyway -explicit, rather than hiding it behind a type parameter that is always erased to the -base interface at the only call site that matters. - -Every other document publishing either signature is corrected in the same change: -`architecture/15`'s three sketches (the interface, `DaprEventBus` and `InProcessEventBus`, -the last of which must resolve handlers by runtime type rather than through a type -parameter), `architecture/32 § 8.2` and the Packet 5 scope paragraph, both of which stop -saying "removed **or** redesigned" now that it is removed. - -### 2026-08-25 — Amendment 3: the publish envelope, decided before the first call site - -The Decision stands, and so does Amendment 2's central correction — `PublishAsync` is -not generic, and it never becomes generic. What Amendment 3 changes is the **shape of -its argument**, which Amendment 2 published as `(IIntegrationEvent @event, string -partitionKey, CancellationToken ct)` and which Packet 5 has now built against. - -**`IEventBus.PublishAsync` takes an envelope.** - -```csharp -Task PublishAsync(IntegrationEventEnvelope envelope, CancellationToken ct = default); - -public sealed record IntegrationEventEnvelope( - IIntegrationEvent Event, - string CorrelationId, - Guid? OrganizationId = null, - Guid? CausationId = null, - UserId? ActorUserId = null) -{ - public string PartitionKey => Event.PartitionKey; - public string Topic => Event.Topic; -} -``` - -> **Refined the same day.** `Topic` was first a parameter on this record, and it should -> not have been. It is a property of the event *type* — two events of one type always go -> to the same channel, and the name is derivable from the type — so a per-delivery -> parameter is the same second-source hazard `PartitionKey` had. It also made the -> catalogued `Integration_Event_TopicNames_FollowConvention` unwritable: that rule reads -> the event declarations, and nothing declared a topic. `Topic` is abstract on -> `IntegrationEventBase`; the envelope reads it. - -Three things forced it, and all three were measured rather than argued. - -**The dispatch metadata had nowhere to travel.** The canonical `outbox_messages` row -([Database Standards](../standards/05-database.md)) requires `topic` and -`correlation_id` as `NOT NULL` and carries `organization_id`, `causation_id` and -`actor_user_id`. None of them belong on the event — they describe the delivery, not the -fact — and the two-parameter signature had no room for them. The transport therefore -read correlation from whatever context happened to be ambient at dispatch, which is -`null` inside the background service the outbox processor is, so the trace chain broke -at exactly the boundary [Observability Standards](../standards/10-observability.md) -requires it to cross. - -**The partition key had two sources and the transport read the wrong one.** Amendment 2 -put it in the signature; `IntegrationEventBase` also declares it. Measured: the shipped -bus never read the event's copy, and every test published an event declaring one key -with a different one passed alongside — green. Ordering is guaranteed per partition key, -so a key that can differ from itself is a guarantee that cannot be stated. The envelope -reads it off the event and cannot disagree with it. - -**A consumer could not write state at all.** `AuditableEntity.MarkCreated` refuses -`default(UserId)` and `Guid.Empty`, and the consumer context supplied neither an actor -nor an organization — so every state-writing handler threw from inside the kernel, and -every organization-scoped read came back empty under the canonical Row Level Security -policy, which fails closed when `app.organization_id` is unset. The envelope carries -both; an absent actor resolves to `UserId.SystemActor`, which is what -[Audit Coverage](../standards/18-audit-coverage.md) means by auditing such work as an -actor of type `system`. - -**Why now.** Amendment 2 wrote the rule this amendment obeys: *adding a required -parameter after the first consumer exists breaks every call site, so the two shapes -cannot be left to be reconciled later.* There is still not one consumer. The envelope is -one type, it maps onto the outbox row Packet 6 creates, and it is the last moment it -costs nothing. - -> The signature published under Amendment 2 above is superseded by this one. It is left -> as written because an Accepted ADR is not rewritten; the non-generic decision it makes -> is unchanged and is the reason the envelope carries the event as `IIntegrationEvent`. - -**One consequence worth stating, because it is a trap the non-generic port creates.** -With `IIntegrationEvent` as the declared type at every dispatch boundary, -`JsonSerializer.Serialize(@event)` emits only the four interface members and silently -drops everything the concrete event added — valid JSON, no exception, and the loss -commits inside the business transaction that reported success. `IntegrationEventBase` -therefore ships `ToPayloadJson()`, which serialises by runtime type, and a named -`PayloadJsonOptions` — because a writer and a reader that disagree on casing -dead-letter every message. - ## References - ADR-0006 — Events and Outbox (status: Accepted after this ADR; previously Proposed). diff --git a/docs/decisions/0022-custom-domain-tls.md b/docs/decisions/0022-custom-domain-tls.md index 4aa08c35..9b1d04d4 100644 --- a/docs/decisions/0022-custom-domain-tls.md +++ b/docs/decisions/0022-custom-domain-tls.md @@ -4,8 +4,7 @@ Accepted — **the certificate-delivery mechanism in Amendment 1 (steps 3 and 4) and in the 2026-05-19 Option B amendment is superseded by -[ADR-0034](0034-hub-contract-surface-invariant.md) (2026-08-08)**, and **the host -cache key's spelling is superseded by the 2026-08-26 amendment below** +[ADR-0034](0034-hub-contract-surface-invariant.md) (2026-08-08)** > **What ADR-0034 changed.** The lifecycle decided here is unchanged: Hub owns > custom-domain administration, DNS-01 and HTTP-01 challenges, Let's Encrypt issuance @@ -381,9 +380,6 @@ public sealed class TenantMiddleware `_hostToTenantResolver` is backed by `ICacheService` (Dapr State / Valkey); cache key `hub:host:{host}` invalidated on `CustomDomainActivatedEvent` / `CustomDomainRevokedEvent`. -> Key spelling superseded — see -> [Amendment: the host cache key](#2026-08-26--amendment-the-host-cache-key-spelling). - ### Public suffix list validation A tenant cannot register `com`, `co.uk`, `gov`, or other public suffix domains. The @@ -450,23 +446,6 @@ runbook live in [27-custom-domain-tls.md](../architecture/27-custom-domain-tls.m ## Amendments -### 2026-08-26 — Amendment: the host cache key spelling - -The decision is unchanged. Only the **spelling** of the cache key in the resolver sketch -above is superseded: it shipped as `platform:hub:host-map:{host}`. - -`CacheKey.EnsureValid` requires the tenant segment first and mandatory, so -`hub:host:{host}` is refused outright. A host lookup is the one key family that -legitimately carries the `platform` sentinel, and it is worth saying why: it answers -"which tenant is this?", so by construction there is no tenant to key it by. Every other -family knows its tenant, and a `platform` sentinel there would be a bug wearing the -sentinel's clothes. - -The canonical shape lives in -[Standards 20 § `ICacheService`](../standards/20-infrastructure-stack.md), which is the -one document that owns it. Nothing else in this decision depends on the spelling. - - ### 2026-05-19 — Cert-and-route propagation is event-driven; Hub does not write LearnStack's K8s state The Decision and the worked example show Hub "writing to @@ -522,6 +501,23 @@ the path is: Architecture test `Cert_PrivateKey_NeverLeavesVault_To_Logs` continues to apply across all modes. +### 2026-08-26 — Amendment: the host cache key spelling + +The decision is unchanged. Only the **spelling** in the resolver sketch above is +clarified: the sketch says `hub:host:{host}`, while the shipped contract is +`platform:hub:host-map:{host}`. + +`CacheKey.EnsureValid` requires the tenant segment first and mandatory, so +`hub:host:{host}` is refused outright. A host lookup is the one key family that +legitimately carries the `platform` sentinel, and it is worth saying why: it answers +"which tenant is this?", so by construction there is no tenant to key it by. Every other +family knows its tenant, and a `platform` sentinel there would be a bug wearing the +sentinel's clothes. + +The canonical shape lives in +[Standards 20 § `ICacheService`](../standards/20-infrastructure-stack.md), which is the +one document that owns it. Nothing else in this decision depends on the spelling. + ## References - ADR-0014 — Adopt Dapr (CustomDomain* events via Dapr pub/sub). diff --git a/docs/decisions/0038-cross-cutting-port-and-event-contracts.md b/docs/decisions/0038-cross-cutting-port-and-event-contracts.md new file mode 100644 index 00000000..41bb36b8 --- /dev/null +++ b/docs/decisions/0038-cross-cutting-port-and-event-contracts.md @@ -0,0 +1,171 @@ +# ADR 0038: Cross-Cutting Port and Event Contracts + +## Status + +Accepted + +Supersedes [ADR-0014](0014-adopt-dapr.md). The Dapr technology choice survives; +the port and delivery contracts are restated here because changing those contracts by +amendment was not permitted by +[Documentation Standards § ADR Amendments](../standards/13-documentation.md#adr-amendments). + +## Date + +2026-08-26 + +## Context + +ADR-0014 selected Dapr pub/sub, state and secret-store building blocks. During Packet 5, +proposed amendments attempted to change its published `IEventBus` and `ICacheService` +decisions. Those edits were contract changes rather than clarifications and are not +retained in the Accepted ADR. Implementation also exposed several ambiguities the +earlier signatures could not express safely: + +- a partition key and topic each had more than one source; +- outbox correlation, organization, causation and actor metadata had no single carrier; +- an organization-scoped event could silently become tenant-wide; +- causal human identity could become the consumer's effective audit principal; +- a mutable serializer policy could produce payloads the canonical reader could not read; +- eager handler enumeration let one broken constructor deny every subscription; +- a generic platform cache-key helper could collapse tenant-owned data into one bucket. + +The technology decision, contract decision and demand gate must be traceable without +depending on a chain of corrections to an Accepted ADR. + +## Decision Drivers + +- One source for every routing and ordering value. +- Fail-closed tenant and organization boundaries. +- Durable outbox metadata that maps directly to dispatch. +- Transport parity without coupling modules to Dapr. +- Per-subscription failure, scope and trace isolation. +- Cache semantics that remain safe when the adapter changes. +- Contracts that are valid before the first producer and consumer ship. + +## Decision + +### Infrastructure choice and demand gate + +LearnStack retains Dapr as the cross-process adapter boundary for: + +| Building block | Production backend | Application port | +|---|---|---| +| Pub/sub | Apache Kafka | `IEventBus` | +| State/cache | Valkey | `ICacheService` | +| Secrets | HashiCorp Vault | `ISecretProvider` | + +Modules never import `DaprClient` or provider SDKs. The default adapters are +`InProcessEventBus`, `InMemoryCacheService` and `ConfigurationSecretProvider` until the +specific triggers in [ADR-0035](0035-demand-gated-infrastructure.md) require the Dapr +adapters in Phase 11. + +### Event contract + +`IEventBus` is non-generic and accepts one validated envelope: + +```csharp +Task PublishAsync( + IntegrationEventEnvelope envelope, + CancellationToken cancellationToken = default); +``` + +The envelope carries the event plus W3C `CorrelationId`, optional `OrganizationId`, +`CausationId` and causal `ActorUserId`. `Topic` and `PartitionKey` are declared once by +the concrete event and forwarded by the envelope. Empty identifiers, default timestamps, +blank routing values and malformed traceparents are rejected at envelope construction. + +An organization-owned event implements `IOrganizationScopedIntegrationEvent`; its +envelope requires a non-empty organization identifier. A tenant-wide event deliberately +omits the marker. A consumer always executes as `UserId.SystemActor`; a human +`ActorUserId` is retained separately as causal audit metadata and never becomes the +consumer's effective principal. + +`IntegrationEventBase.ToPayloadJson()` serializes by runtime type with one named, +read-only `JsonSerializerOptions` instance. Callers cannot substitute a serializer +policy. The same options govern deserialization. + +The in-process transport discovers lightweight subscription metadata at composition. +Each subscription gets one consumer activity, one async DI scope and exactly one handler +construction. Tenant context is established before subscription lookup or resolution. +Constructor, handler and disposal failures are isolated per subscription and collected; +publish-token cancellation stops dispatch before a later subscription starts. A future +Dapr adapter must preserve these observable semantics. + +Only the outbox processor publishes through `IEventBus`. Modules write the outbox inside +their business transaction and may consume through +`IIntegrationEventHandler`; they do not inject `IEventBus` or resolve it through +`IServiceProvider`. + +Topic names use `learnstack.{module}.{aggregate}`. Hub may use the documented +four-segment form `learnstack.hub.{domain}.{event}`. Every segment starts with a lower-case +letter and may then contain lower-case letters, digits or internal hyphens. + +### Cache contract + +`ICacheService` exposes `GetAsync`, `GetOrSetAsync`, `SetAsync` and `RemoveAsync`. +Prefix/tag invalidation is not part of the port; callers that invalidate a set use a +durable generation key. + +Every tenant-owned key begins with its canonical tenant identifier. The `platform` +sentinel is reserved for the normalized Hub host-map family: +`platform:hub:host-map:{normalized-host}`. Callers compose that family through +`CacheKey.ForHostMapping`; there is no generic platform-key factory. + +The in-memory adapter is a process singleton with bounded storage. Concurrent misses for +the same key and requested type are single-flight: the first caller owns the factory and +TTL, waiter cancellation does not cancel other waiters, and an abandoned factory must +actually terminate before a replacement begins. Positive, representable TTLs are +validated before lookup or factory execution. Cache metrics use stable family names and +never full keys, tenant IDs or entity IDs. + +## Considered Options + +### Keep the proposed ADR-0014 amendments as the authority + +Rejected. They would change Decision-section contracts and therefore violate the repository's +ADR immutability rule. Readers also have to reconcile superseding signatures across +multiple amendments. + +### Expose provider SDKs directly + +Rejected. It couples modules to transport/cache/secret providers and breaks the shared +deployment-mode abstraction. + +### Keep metadata as separate publish parameters + +Rejected. Topic and partition key can disagree with the event, and adding another +required outbox field repeatedly breaks every publisher. + +### Treat every event as organization-scoped + +Rejected. Tenant-wide facts are legitimate. An explicit marker makes the narrower scope +required only where the event type declares it. + +## Consequences + +- ADR-0014 is historical; this ADR is the authority for Dapr-facing ports and event + delivery. +- Event producers declare valid event metadata; the outbox processor constructs the + validated envelope. Organization-owned event types implement the scope marker. +- Consumers get per-subscription scope, trace and failure isolation, with system identity + separated from causal identity. +- The Tenancy migration must seed `UserId.SystemActor` + (`00000000-0000-7000-8000-000000000001`) before a persisted consumer can write audit + foreign keys. +- Cache callers cannot create arbitrary platform-wide families. +- Dapr/Valkey adapters in Phase 11 must match the same envelope, single-flight and + observability behavior rather than introducing a second contract. + +## Amendments + +None. + +## References + +- [ADR-0006 — Events and Outbox](0006-events-and-outbox.md) +- [ADR-0010 — Cross-Module Communication](0010-cross-module-communication.md) +- [ADR-0035 — Demand-Gated Infrastructure](0035-demand-gated-infrastructure.md) +- [Event and Outbox Architecture](../architecture/15-event-and-outbox.md) +- [Dapr Integration](../architecture/29-dapr-integration.md) +- [Infrastructure Stack Standards](../standards/20-infrastructure-stack.md) +- [Audit Coverage Standards](../standards/18-audit-coverage.md) diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 47e1e1c2..07e8df1c 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -28,7 +28,7 @@ Accepted ADRs are not rewritten. A new decision is a new ADR, possibly supersedi | 0011 | _Superseded — see below_ | Was: Vertical Extension Points | | 0012 | [Search Strategy](0012-search-strategy.md) | Meilisearch; one instance per env; index-per-(kind, locale); tenant_id as query filter | | 0013 | [Page Block Schema Versioning](0013-page-block-schema-versioning.md) | `(key, schemaVersion)` tuple; immutable schemas; lazy + bulk migration; placeholder on unknown version | -| 0014 | [Adopt Dapr](0014-adopt-dapr.md) | Dapr building blocks for pub/sub (Kafka), state (Valkey), secrets (Vault); abstracted behind SharedKernel interfaces | +| 0014 | _Superseded — see below_ | Was: Adopt Dapr for Cross-Cutting Infrastructure | | 0015 | [API Gateway with APISIX](0015-api-gateway-apisix.md) | APISIX standalone mode; JWT + rate limit + CORS + correlation-id at the edge; defense-in-depth | | 0016 | [Audit Log Subsystem](0016-audit-log-subsystem.md) | **Superseded by [ADR-0033](0033-audit-durability-model.md).** `LearnStack.Modules.Audit`; EF interceptor + `IAuditStateCapture` + `AuditLogBehavior`; partitioned `audit_log` table; retention. Read for context; ADR-0033 carries the binding durability rules | | 0017 | [Tenant + Organization Hierarchy](0017-tenant-organization-hierarchy.md) | Two-level: Tenant → Organization; permission scope Platform / Tenant / Organization (Amendment 1: identity row terminology, 2026-05-19; **Amendment 2: the `Organization` aggregate is declared in `LearnStack.Modules.Tenancy.Domain`, 2026-08-10**) | @@ -49,9 +49,15 @@ Accepted ADRs are not rewritten. A new decision is a new ADR, possibly supersedi | 0035 | [Demand-Gated Infrastructure](0035-demand-gated-infrastructure.md) | The one-way-door test; ports + default implementations ship now, vendor adapters ship on a named trigger in a named phase; uncontracted deployment modes may not decide technical choices | | 0036 | [Trusted Inputs for Tenant and Organization Resolution](0036-tenant-resolution-trusted-inputs.md) | Resolution by **agreement, not priority** — every authoritative signal present is resolved independently and the request proceeds only on their intersection; no request header names a tenant or an organization, one header names a **host** over an authenticated hop and LearnStack still resolves it itself; `TenantContextOrigin` caps a host-only context to the `[PublicSurface]` read set; the platform-admin override leaves the resolution model | | 0037 | [What an Idempotency Key Identifies, Owns, and Replays](0037-idempotency-key-contract.md) | A client-chosen key is a **nonce inside a tenant's key space**, not an identity: `(tenant, key)` addresses the record and a fingerprint over organization, principal, method, path, query and body decides whether replaying it answers the question asked; a fencing token owns the claim; capacity is **admission, not eviction**, so nothing unexpired is ever displaced; the guarantee is at-most-once while a claim is live and at-least-once across process death | +| 0038 | [Cross-Cutting Port and Event Contracts](0038-cross-cutting-port-and-event-contracts.md) | **Supersedes ADR-0014.** Retains Dapr behind demand gates; fixes the event envelope, handler isolation, trace/audit scope and cache contracts | ## Superseded ADRs +- **ADR-0014 — Adopt Dapr for Cross-Cutting Infrastructure** — superseded by + [ADR-0038: Cross-Cutting Port and Event Contracts](0038-cross-cutting-port-and-event-contracts.md) + on 2026-08-26. The Dapr choice and ADR-0035 demand gate survive; ADR-0038 restates + them with the binding event and cache contracts that had incorrectly been changed by + amendments. - **ADR-0011 — Vertical Extension Points** — superseded by [ADR-0018: Tenant-Driven Customization Model](0018-tenant-driven-customization-model.md) on 2026-05-18. The original file is retained in place (`0011-extension-points.md`) with a Superseded diff --git a/docs/glossary.md b/docs/glossary.md index 0b7ebff8..5aaf4995 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -257,7 +257,7 @@ This glossary defines LearnStack-specific terms. When a term is ambiguous across | **`IClock` / `IRandom` / `IGuidFactory`** | The three deterministic-test abstractions in `LearnStack.SharedKernel`. Production code never reads `DateTime.UtcNow`, instantiates `System.Random`, or calls `Guid.NewGuid()` directly — those calls go through the abstractions so tests pin the values via `FixedClock` / `FixedRandom` / `FixedGuidFactory`. Per Standards 02 § Time. | | **`UserId`** | The cross-cutting strongly-typed actor identifier in `LearnStack.SharedKernel.Identifiers` (Vogen `[ValueObject]`). Audit columns on `AuditableEntity` reference users by `UserId` so the "no raw `Guid` on the public surface" rule (Standards 02) holds even though the Identity module lands in Phase 02b. Identity consumes the same type when it ships. | | **`Entity` / `AuditableEntity`** | The two aggregate bases in `LearnStack.SharedKernel.Domain`. `Entity` is the append-only / audit-row base — identity, in-process domain events, identity-based equality with **uninitialized-id + cross-runtime-type guards** so `HashSet`-backed collection navigations, `Distinct()` and `Contains` behave correctly before ids are minted. EF Core's change tracker is not among the reasons — it keys on the primary-key value and tracks by reference, never calling these members. `AuditableEntity` is the mutable base — adds `CreatedAt/By`, `UpdatedAt/By`, `DeletedAt/By`, `Version`, and the `IsDeleted` projection by implementing `ISoftDelete` + `IOptimisticConcurrency`. `MarkCreated` throws on second call; `SoftDelete` also bumps `UpdatedAt` so "last touched" stays monotonic. `AuditEntry` (audit subsystem) inherits `Entity` — never `AuditableEntity` — by architecture-test rule. | -| **`IDomainEvent`** | The marker interface (`: MediatR.INotification`) every in-process domain event implements. Raised from aggregate methods, collected by the unit of work, dispatched in-process by MediatR. The abstract `DomainEvent` base declares `EventId` and `OccurredAt` as `required init` so events are always stamped through `IGuidFactory` / `IClock` at the call site. Distinct from integration events, which cross module boundaries through the outbox + Dapr pub/sub per [ADR-0010](decisions/0010-cross-module-communication.md). | +| **`IDomainEvent`** | The marker interface (`: MediatR.INotification`) every in-process domain event implements. Raised from aggregate methods, collected by the unit of work, dispatched in-process by MediatR. The abstract `DomainEvent` base declares `EventId` and `OccurredAt` as `required init` so events are always stamped through `IGuidFactory` / `IClock` at the call site. Distinct from integration events, which cross module boundaries through the outbox + `IEventBus` (`InProcessEventBus` today; Dapr pub/sub after its trigger) per [ADR-0038](decisions/0038-cross-cutting-port-and-event-contracts.md). | | **`CursorPagination` / `Page` / `PageInfo`** | The cursor-first pagination triple in `LearnStack.SharedKernel.Pagination` matching Standards 04 § Pagination. `CursorPagination(Cursor, Limit)` is the request (default `Limit = 20`, max 100; ctor throws on `Limit <= 0` — kernel-level guard); `Page(Items, PageInfo)` is the response; `PageInfo(NextCursor, PreviousCursor, HasNext, HasPrevious)` carries the opaque cursors the client never parses. | | **`LearnStackVogenDefaults.IdMask`** | The canonical `Conversions` mask every Vogen-emitted ID and value object opts into: `EfCoreValueConverter \| SystemTextJson \| TypeConverter`. Per [ADR-0023](decisions/0023-strongly-typed-id-source-generator.md) every aggregate-root ID writes `[ValueObject(LearnStackVogenDefaults.IdMask)]`. | | **Pipeline Behavior** | A MediatR pipeline behavior — one of the eight canonical steps wrapping every command / query: `Validation → Logging → AuditLog → TenantContext → Authorization → Transaction → OutboxFlush → Handler`. The order is binding per [ADR-0032 § Sub-decision 2](decisions/0032-exception-handling-logging-and-observability.md); the architecture test `MediatR_Pipeline_Order_Matches_Canonical_Sequence` enforces it. | @@ -277,13 +277,13 @@ This glossary defines LearnStack-specific terms. When a term is ambiguous across |------|------------| | **Demand-Gated Building Block** | An infrastructure choice that ships as a **port plus a working default implementation** now, and as a **vendor adapter later**, in a named phase, when a written trigger fires. It qualifies only when all four exist: the port, the default implementation, the owning phase, and the trigger condition. A block missing any of the four is not demand-gated — it is simply missing. Distinguished from a one-way door by the test below. The gated set and each trigger are tabulated in [ADR-0035](decisions/0035-demand-gated-infrastructure.md), which also names its own two exceptions — `audit_log` partitioning, which is schema-internal and so has no port, and LiveKit, which has no default because its absence is a missing product feature rather than a missing implementation. | | **One-Way Door** | A decision that gets more expensive with every week of delay, tested by the question: *if I add this six months from now, will I have to touch code that is already written?* **Yes → one-way door; ship it now** (tenant and organization isolation, the `outbox_messages` table and its ownership, strongly-typed identifiers, the localization schema — each touches every query, migration, or job payload ever written). **No → additive; ship the port now and the adapter on demand.** The test is mechanical, not a matter of taste: tenant isolation's cost grows with the codebase, a Dapr adapter's cost does not. A corollary rule: **a deployment mode or customer segment without a signed contract cannot be the deciding factor in a technical choice** — it may break a tie between otherwise-equal options, nothing more. Per [ADR-0035](decisions/0035-demand-gated-infrastructure.md) and [Engineering Principles](standards/00-principles.md). | -| **Dapr Building Blocks** | The three Dapr abstractions LearnStack uses **when it uses Dapr**: pub/sub (Kafka), state (Valkey), secrets (Vault) per [ADR-0014](decisions/0014-adopt-dapr.md). Service invocation, workflow, bindings, and actors are out of scope. Demand-gated to [Phase 11](roadmap/phase-11-production-hardening.md); ADR-0014 decides *what*, [ADR-0035](decisions/0035-demand-gated-infrastructure.md) decides *when*. | +| **Dapr Building Blocks** | The three Dapr abstractions LearnStack uses **when it uses Dapr**: pub/sub (Kafka), state (Valkey), secrets (Vault) per [ADR-0038](decisions/0038-cross-cutting-port-and-event-contracts.md). Service invocation, workflow, bindings, and actors are out of scope. Demand-gated to [Phase 11](roadmap/phase-11-production-hardening.md); ADR-0038 decides *what*, [ADR-0035](decisions/0035-demand-gated-infrastructure.md) decides *when*. | | **`IEventBus`** | Interface for publishing integration events, taking an `IntegrationEventEnvelope`. `InProcessEventBus` is the only registered implementation until the Dapr adapter's trigger fires — and it is a **first-class transport, not a stub**: same `IIntegrationEventHandler`, same `IInboxGuard`, same tenant-context restoration, same per-partition-key ordering as the durable path. The `OutboxProcessor` is the only sanctioned caller. | -| **`IntegrationEventEnvelope`** | One integration event plus the dispatch metadata the outbox row carries and the event does not: `CorrelationId`, `OrganizationId`, `CausationId`, `ActorUserId`. Its `Topic` and `PartitionKey` are the event's own — both are properties of the event *type* rather than of one delivery, so neither can hold a second answer ([ADR-0014 Amendment 3](decisions/0014-adopt-dapr.md)). Metadata describes the *delivery*; the event describes the *fact*. | +| **`IntegrationEventEnvelope`** | One integration event plus the dispatch metadata the outbox row carries and the event does not: W3C `CorrelationId`, optional `OrganizationId`, `CausationId`, and causal `ActorUserId`. Its `Topic` and `PartitionKey` are the event's own — both are properties of the event *type* rather than of one delivery, so neither can hold a second answer ([ADR-0038](decisions/0038-cross-cutting-port-and-event-contracts.md)). An event implementing `IOrganizationScopedIntegrationEvent` requires a non-empty organization in its envelope. Metadata describes the *delivery*; the event describes the *fact*. | | **`IPartitionSerializer`** | Runs work sequentially within one partition key and concurrently across different ones — the in-process stand-in for what a broker gives you by assigning a partition to one consumer. It exists so the development transport carries the same ordering guarantee as the durable path rather than a weaker one. Queuing work for the key you are already inside is refused rather than deadlocked: the caller that does it is publishing from inside a handler, which [Standards 20](standards/20-infrastructure-stack.md) forbids. | -| **`EventTenantContext`** | The `ITenantContext` a consumer runs under, rebuilt from the envelope by the transport before the handler runs. A consumer executes outside the request that produced the fact, so there is no ambient context to inherit — which is why the tenant travels on the event and the rest on the envelope. Restoring it is what makes the query filters and the RLS policies evaluate against the right scope. | -| **`UserId.SystemActor`** | The fixed, non-empty `UserId` that integration-event consumers, background jobs and other non-request executions write state as — what [Audit Coverage](standards/18-audit-coverage.md) means by an actor of type `system`. Fixed rather than generated because it is a foreign key: the Tenancy migration seeds the matching `users` row so `created_by` resolves. `AuditableEntity.MarkCreated` refuses `default(UserId)` and `Guid.Empty` alike, so without it no consumer could create an aggregate at all. | -| **`ICacheService`** | Interface for cache reads / writes. `InMemoryCacheService` today; a Valkey-backed implementation when more than one instance runs concurrently. Cache keys lead with the tenant segment — `{tenant_id}:{module}:{logical-name}`, or `{tenant_id}:{organization_id}:{module}:{logical-name}` for a value scoped to one organization — composed by `CacheKey` and enforced by `CacheKey.EnsureValid`, because there is no query filter and no RLS policy in front of a dictionary. `RemoveByPrefixAsync` is **removed** ([ADR-0014 Amendment 2](decisions/0014-adopt-dapr.md)) — it iterated an instance-local key set, so keys written by another instance were never evicted. What replaces it is the **generation-key** pattern, which is a caller-side convention rather than a member of this interface: a durable counter bumped inside the business transaction and embedded in the key template. | +| **`EventTenantContext`** | The `ITenantContext` a consumer runs under, rebuilt from the envelope by the transport before handler discovery or resolution. A consumer executes outside the request that produced the fact, so there is no ambient context to inherit. The effective principal is `UserId.SystemActor`; an envelope human remains available separately as `CausalActorUserId`. Restoring the tenant and optional organization is what makes query filters and RLS policies evaluate against the right scope. | +| **`UserId.SystemActor`** | The fixed, non-empty `UserId` (`00000000-0000-7000-8000-000000000001`) that integration-event consumers, background jobs and other non-request executions use as their effective audit principal — what [Audit Coverage](standards/18-audit-coverage.md) means by an actor of type `system`. It must have a matching `users` row before a persisted consumer can write an audit foreign key. The Tenancy schema and that seed are owned by [Phase 02a Packet 6](roadmap/phase-02a-kernel-tenancy.md); neither exists yet. | +| **`ICacheService`** | Interface for cache reads / writes. `InMemoryCacheService` today; a Valkey-backed implementation when more than one instance runs concurrently. Tenant keys are `{tenant_id}:{module}:{logical-name}`, or `{tenant_id}:{organization_id}:{module}:{logical-name}` for organization scope, composed by `CacheKey` and enforced by `CacheKey.EnsureValid`. The only platform-wide family is the normalized host map `platform:hub:host-map:{host}`, composed by `CacheKey.ForHostMapping`; there is no generic platform factory. `RemoveByPrefixAsync` is absent per [ADR-0038](decisions/0038-cross-cutting-port-and-event-contracts.md). Set invalidation uses a caller-owned durable generation key embedded in the key template. | | **`ISecretProvider`** | Interface for secret reads. `ConfigurationSecretProvider` today; the Vault-backed implementation when a production secret must rotate without a redeploy, or more than one operator needs access to production secrets ([ADR-0035](decisions/0035-demand-gated-infrastructure.md) — *not* when a non-development deployment merely exists, which SaaS satisfies on day one). Secret namespace `learnstack/{deployment}/{module}/{key}`. | | **`IEntitlementProvider`** | Interface for the Entitlement Projection source. Implementations: `NullEntitlementProvider` (Development only — all features enabled, no limits), `HubEntitlementProvider` (SaaS / Dedicated, from Phase 02c), `SignedLicenseKeyEntitlementProvider` (Self-Hosted; skeleton from Hub `P02c-6`, hardened in Phase 11). The Hub-backed provider resolves in the normative order `L1 → L2 → platform_entitlement_cache → Hub` and never throws out of a feature-flag check. | | **`IHostToTenantResolver`** | Interface for host → `(tenant_id, organization_id?)` resolution. Reads `platform_host_to_tenant` and **nothing else** — never the Hub, because an anonymous page load must not depend on a control plane being reachable ([ADR-0034](decisions/0034-hub-contract-surface-invariant.md)). | diff --git a/docs/roadmap/phase-02a-kernel-tenancy.md b/docs/roadmap/phase-02a-kernel-tenancy.md index 5c630694..e9644b9e 100644 --- a/docs/roadmap/phase-02a-kernel-tenancy.md +++ b/docs/roadmap/phase-02a-kernel-tenancy.md @@ -355,7 +355,8 @@ projection), `platform_entitlement_cache`, `platform_host_to_tenant`, `idempoten port and an in-memory default that is correct for one instance and wrong for two), and `outbox_messages`. Default-organization seeding at tenant creation. -**Seed the system actor.** `UserId.SystemActor` — a fixed, non-empty id in +**Seed the system actor.** `UserId.SystemActor` — the fixed id +`00000000-0000-7000-8000-000000000001` in `LearnStack.SharedKernel.Identifiers` — is what an integration-event consumer, a background job, or any other non-request execution writes state as, per [Audit Coverage](../standards/18-audit-coverage.md)'s actor-of-type-`system` rule. diff --git a/docs/roadmap/phase-02b-events-auth.md b/docs/roadmap/phase-02b-events-auth.md index 6fdc53ff..1e82f183 100644 --- a/docs/roadmap/phase-02b-events-auth.md +++ b/docs/roadmap/phase-02b-events-auth.md @@ -37,10 +37,10 @@ The Dapr pub/sub and Kafka adapters are **not in this phase**. They are demand-g process needs to consume an integration event". Until that is true, a cross-process broker moves an event from one thread to another thread in the same process, through two network hops and a serialization boundary, for a service the daily loop no longer -starts — Packet 5 moved Kafka, Valkey, Vault, APISIX and the two Dapr containers +starts — Packet 5 moved Kafka, kafka-ui, Valkey, Vault, APISIX and the two Dapr containers behind the `gated` compose profile, taking `make dev` from fourteen services to seven. [ADR-0006 Amendment 1](../decisions/0006-events-and-outbox.md) and -[ADR-0014](../decisions/0014-adopt-dapr.md) remain the decision about **which** transport +[ADR-0038](../decisions/0038-cross-cutting-port-and-event-contracts.md) remain the decisions about **which** transport LearnStack uses when it needs one; ADR-0035 decides **when**, and the answer is not this phase. @@ -84,8 +84,10 @@ What lands in this phase is the consumer side. rows that Row Level Security rejects, or worse, does not. - **Versioned integration event types** in `.Application.Contracts`, inheriting `IntegrationEventBase`, carrying `EventId`, `TenantId`, `OccurredAt` and - `CorrelationId`. The `correlation_id` column holds the **full W3C `traceparent` - string**, not a bare UUID, so a consumer rehydrates the trace with + declaring `Topic` and `PartitionKey`. `CorrelationId`, organization, causation and + causal actor are delivery metadata on `IntegrationEventEnvelope`, copied from the + outbox row. The `correlation_id` column holds the **full W3C `traceparent` string**, + not a bare UUID, so a consumer rehydrates the trace with `ActivityContext.TryParse(row.CorrelationId, traceState: null, out var parentCtx)` and starts its activity from `parentCtx` ([ADR-0032 § Sub-decision 12](../decisions/0032-exception-handling-logging-and-observability.md)). @@ -136,14 +138,15 @@ none, and no publish path sets one. The promise is unenforceable, and a consumer depends on seeing `Created` before `Updated` gets whichever order the transport happens to produce. -This phase specifies it: +Packet 5 has already fixed the port seam; this phase persists and dispatches it: -- `IntegrationEventBase` exposes a **`PartitionKey`** derived from the aggregate - identifier, defaulting to `TenantId` when an event is not aggregate-scoped. A tenant's - events therefore never interleave with another tenant's on a shared partition. -- `IEventBus.PublishAsync` passes the key to the transport. `InProcessEventBus` uses it - to serialize handler invocation per key; the Phase 11 Kafka adapter maps it to the - message key. +- `IntegrationEventBase` declares **`PartitionKey` abstract**. Each event chooses the + aggregate identifier, or explicitly chooses `TenantId` for a tenant-wide fact; there + is no inherited default that serializes a tenant's whole stream by accident. +- `IOutbox.EnqueueAsync` copies that key to the row. `IEventBus.PublishAsync` accepts an + `IntegrationEventEnvelope`, whose key forwards `Event.PartitionKey` rather than + carrying a second value. `InProcessEventBus` serializes handler invocation per key; + the Phase 11 Kafka adapter maps it to the message key. - An architecture test asserts every `IIntegrationEvent` resolves a non-null partition key, so a new event cannot silently opt out of ordering. @@ -177,9 +180,9 @@ The subscriber-side contract lands here: - The dispatcher runs as a recurring job with its pending count and lag surfaced as metrics, so a stalled dispatcher is visible without reading the table. -Cross-instance L1 cache invalidation (`learnstack.cache.invalidation`) is **declared as a -topic and consumed in-process** here, but it has no cross-instance effect until more than -one application instance runs — which is precisely +Cross-instance L1 cache invalidation (`learnstack.cache.invalidation`) lands with the +distributed adapter in Phase 11. Declaring or consuming it in this single-instance phase +would provide no cross-instance effect — which is precisely [ADR-0035](../decisions/0035-demand-gated-infrastructure.md)'s trigger for the distributed `ICacheService` adapter in [Phase 11](phase-11-production-hardening.md). Wiring the subscription now means the Phase 11 adapter has a consumer waiting rather than @@ -318,10 +321,10 @@ New rules this phase introduces, in addition to the Phase 02a set: `Domain` or `Application`. - Outbox writes happen inside the same transaction as the originating domain change. -`Dapr_PubSub_TopicNames_FollowConvention` applies in this phase to the topic-name -resolver that `IEventBus` uses; its `[Topic]`-attribute scan activates with the Dapr -adapter in [Phase 11](phase-11-production-hardening.md). The topic naming convention -`learnstack.{module}.{aggregate}` is transport-independent and is enforced from here. +`Integration_Event_TopicNames_FollowConvention` already enforces each event's declared +topic independently of transport, including the Hub-only four-segment form. +`Dapr_PubSub_TopicNames_FollowConvention` narrows to component bindings and activates +with the Dapr adapter in [Phase 11](phase-11-production-hardening.md). The catalogue in [Architecture Tests Catalogue](../standards/21-architecture-tests-catalogue.md) is the diff --git a/docs/standards/01-architecture-standards.md b/docs/standards/01-architecture-standards.md index a921703a..fce713b0 100644 --- a/docs/standards/01-architecture-standards.md +++ b/docs/standards/01-architecture-standards.md @@ -4,7 +4,7 @@ **Derives from:** [ADR-0002 Initial Architecture](../decisions/0002-initial-architecture.md), [ADR-0010 Cross-Module Communication](../decisions/0010-cross-module-communication.md) (Amendment 1: outbox dispatch via Dapr pub/sub), -[ADR-0014 Adopt Dapr](../decisions/0014-adopt-dapr.md) +[ADR-0038 Cross-Cutting Port and Event Contracts](../decisions/0038-cross-cutting-port-and-event-contracts.md) (scheduled by [ADR-0035 Demand-Gated Infrastructure](../decisions/0035-demand-gated-infrastructure.md)), [ADR-0033 Audit Durability Model](../decisions/0033-audit-durability-model.md) (supersedes [ADR-0016 Audit Log Subsystem](../decisions/0016-audit-log-subsystem.md)), diff --git a/docs/standards/05-database.md b/docs/standards/05-database.md index 332a13b5..47af63dd 100644 --- a/docs/standards/05-database.md +++ b/docs/standards/05-database.md @@ -8,7 +8,7 @@ database role model**), [ADR-0006 Events and Outbox](../decisions/0006-events-and-outbox.md) (Amendment 1: Dapr pub/sub dispatch transport), -[ADR-0014 Adopt Dapr](../decisions/0014-adopt-dapr.md), +[ADR-0038 Cross-Cutting Port and Event Contracts](../decisions/0038-cross-cutting-port-and-event-contracts.md), [ADR-0017 Tenant + Organization Hierarchy](../decisions/0017-tenant-organization-hierarchy.md), [ADR-0031 PostgreSQL — Start on 18.x](../decisions/0031-postgresql-major-version.md). diff --git a/docs/standards/10-observability.md b/docs/standards/10-observability.md index faa3c1a7..25422c0a 100644 --- a/docs/standards/10-observability.md +++ b/docs/standards/10-observability.md @@ -219,8 +219,19 @@ backend treat business rejections as system failures. | `learnstack_classroom_participants_active` | gauge | tenant | | `learnstack_classroom_recording_minutes_total` | counter | tenant | | `learnstack_search_query_duration_seconds` | histogram | tenant | -| `learnstack_cache_hit_total` | counter | cache_name | -| `learnstack_cache_miss_total` | counter | cache_name | +| `learnstack_cache_hit_total` | counter | `cache.name` | +| `learnstack_cache_miss_total` | counter | `cache.name` | +| `learnstack_cache_store_total` | counter | `cache.name` | +| `learnstack_cache_coalesced_total` | counter | `cache.name` | +| `learnstack_cache_eviction_total` | counter | `cache.name`, `reason` | +| `learnstack_cache_factory_duration_seconds` | histogram | `cache.name`, `outcome` | + +Cache `cache.name` is a governed, low-cardinality family from the Standards 20 +inventory (`hub:host-map`, `hub:entitlement`, `identity:permissions`, +`tenancy:feature-flags`, or `tenancy:settings`). 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`. ### Business Metrics diff --git a/docs/standards/11-security.md b/docs/standards/11-security.md index c8909002..cc319161 100644 --- a/docs/standards/11-security.md +++ b/docs/standards/11-security.md @@ -304,7 +304,7 @@ policy is inert. Isolation tests connect as `learnstack_app`; the suite is a rotate without a redeploy, or more than one operator needs access to production secrets — at which point `DaprSecretProvider` → Vault takes over per - [ADR-0014](../decisions/0014-adopt-dapr.md) and + [ADR-0038](../decisions/0038-cross-cutting-port-and-event-contracts.md) and [ADR-0035](../decisions/0035-demand-gated-infrastructure.md). Call sites are identical either way. `.env.example` is checked in; `.env` is gitignored. - Secret namespace: `learnstack/{deployment}/{module}/{key}`. The deployment segment is diff --git a/docs/standards/12-infrastructure.md b/docs/standards/12-infrastructure.md index ba602409..004c898d 100644 --- a/docs/standards/12-infrastructure.md +++ b/docs/standards/12-infrastructure.md @@ -3,7 +3,7 @@ **Status:** Active **Derives from:** [ADR-0002 Initial Architecture](../decisions/0002-initial-architecture.md), [ADR-0005 Live Classroom Media Stack](../decisions/0005-live-classroom-media-stack.md), -[ADR-0014 Adopt Dapr](../decisions/0014-adopt-dapr.md), +[ADR-0038 Cross-Cutting Port and Event Contracts](../decisions/0038-cross-cutting-port-and-event-contracts.md), [ADR-0015 API Gateway: APISIX](../decisions/0015-api-gateway-apisix.md), [ADR-0019 LearnStack Hub](../decisions/0019-learnstack-hub.md), [ADR-0020 Triple Deployment + Hybrid License](../decisions/0020-triple-deployment-hybrid-license.md), @@ -231,9 +231,11 @@ See [10-observability.md](10-observability.md). ## Secrets Management -- All non-development modes use **HashiCorp Vault** accessed via Dapr's secret store - building block ([ADR-0014](../decisions/0014-adopt-dapr.md)). Application code uses - `ISecretProvider`; direct `VaultClient` usage is forbidden. +- The target for non-development modes is **HashiCorp Vault** through Dapr's secret + store building block ([ADR-0038](../decisions/0038-cross-cutting-port-and-event-contracts.md)). + Today every mode resolves `ConfigurationSecretProvider`; the Vault adapter is + demand-gated to Phase 11 by ADR-0035. Application code uses `ISecretProvider` in + either case; direct `VaultClient` usage is forbidden. - Local: `.env` (not committed) — sufficient for `Development` mode. - **Development-only defaults in `infra/compose/*.yml` are not committed secrets.** A `${VAR:-literal}` fallback that only ever reaches a container in `Development` mode is diff --git a/docs/standards/20-infrastructure-stack.md b/docs/standards/20-infrastructure-stack.md index 897f759e..d26bddb5 100644 --- a/docs/standards/20-infrastructure-stack.md +++ b/docs/standards/20-infrastructure-stack.md @@ -1,7 +1,7 @@ # 20 — Infrastructure Stack Standards **Status:** Active -**Derives from:** [ADR-0014 Adopt Dapr](../decisions/0014-adopt-dapr.md), +**Derives from:** [ADR-0038 Cross-Cutting Port and Event Contracts](../decisions/0038-cross-cutting-port-and-event-contracts.md), [ADR-0015 API Gateway: APISIX](../decisions/0015-api-gateway-apisix.md), [ADR-0019 LearnStack Hub](../decisions/0019-learnstack-hub.md), [ADR-0020 Triple Deployment + Hybrid License](../decisions/0020-triple-deployment-hybrid-license.md), @@ -115,7 +115,7 @@ Rules: | Concern | `Development` | `SaaS` | `Dedicated` | `SelfHostedOnline` | `SelfHostedAirGapped` | |---|---|---|---|---|---| -| Event bus | `InProcessEventBus` (MediatR) | `DaprEventBus` → Kafka | `DaprEventBus` → Kafka | `DaprEventBus` → Kafka (single-broker OK) | `DaprEventBus` → Kafka (single-broker OK) | +| Event bus | `InProcessEventBus` | `DaprEventBus` → Kafka | `DaprEventBus` → Kafka | `DaprEventBus` → Kafka (single-broker OK) | `DaprEventBus` → Kafka (single-broker OK) | | Cache | `InMemoryCacheService` | `DaprCacheService` → Valkey | `DaprCacheService` → Valkey | `DaprCacheService` → Valkey | `DaprCacheService` → Valkey | | Secrets | `ConfigurationSecretProvider` | `DaprSecretProvider` → Vault | `DaprSecretProvider` → Vault | `DaprSecretProvider` → Vault | `DaprSecretProvider` → Vault or file | | Entitlement | `NullEntitlementProvider` | `HubEntitlementProvider` | `HubEntitlementProvider` | `HubEntitlementProvider` (phone-home) | `SignedLicenseKeyEntitlementProvider` | @@ -134,10 +134,17 @@ right-hand implementations arrive with their adapters. LearnStack uses three Dapr building blocks: **pub/sub**, **state**, **secrets**. Other building blocks (service invocation, workflow, bindings, actors) are **out of scope** per -ADR-0014 non-goals; do not introduce them without a new ADR. +[ADR-0038](../decisions/0038-cross-cutting-port-and-event-contracts.md); do not +introduce them without a new ADR. ### `IEventBus` (pub/sub) +**Decision authority:** +[ADR-0038 § Event contract](../decisions/0038-cross-cutting-port-and-event-contracts.md#event-contract) +governs the envelope, topic, handler and transport rules below; +[ADR-0035](../decisions/0035-demand-gated-infrastructure.md) governs when the Dapr +adapter replaces `InProcessEventBus`. + - The **only** sanctioned way to publish an integration event is `IEventBus.PublishAsync` from inside the `OutboxProcessor`. Modules never call `IEventBus` directly — they write to the outbox. @@ -157,23 +164,31 @@ ADR-0014 non-goals; do not introduce them without a new ADR. - Hub-side topics use the same `learnstack.hub.*` prefix (`learnstack.hub.entitlement`, `learnstack.hub.custom-domain.activated`). - Consumers implement `IIntegrationEventHandler` and **must** invoke - `IInboxGuard.IsAlreadyProcessedAsync` before any business logic. The architecture - test `Integration_Event_Handlers_Use_InboxGuard` enforces this. + `IInboxGuard.IsAlreadyProcessedAsync` before any business logic. Phase 02b adds the + architecture test `Integration_Event_Handlers_Use_InboxGuard` with the first real + consumers; it is registered in the catalogue today. - Cross-instance L1-cache invalidation rides on `learnstack.cache.invalidation` (a small payload of `(tenant_id, cache_key)`). Modules that maintain L1 caches subscribe here. ### `ICacheService` (state) +**Decision authority:** +[ADR-0038 § Cache contract](../decisions/0038-cross-cutting-port-and-event-contracts.md#cache-contract) +governs key isolation, the four-method port and single-flight behavior; +[ADR-0035](../decisions/0035-demand-gated-infrastructure.md) governs the Valkey/Dapr +adapter trigger. + - All Valkey access goes through `ICacheService`. Direct `IConnectionMultiplexer` / `IDistributedCache` injections are forbidden by the architecture test `Modules_Do_Not_Inject_Valkey_Directly`. - Cache keys are `{tenant_id}:{module}:{logical-name}`, or `{tenant_id}:{organization_id}:{module}:{logical-name}` when the value is scoped to one organization. The `tenant_id` segment comes **first** and is mandatory even when - a value is platform-wide — use the sentinel `"platform"` tenant id rather than - omitting it. Compose with `CacheKey.ForTenant` / `CacheKey.ForOrganization` / - `CacheKey.ForPlatform`; every `ICacheService` implementation calls + a value is platform-wide. The only platform-wide family is the normalized Hub host + map, which uses the sentinel `"platform"`; every other family requires a tenant id. + Compose with `CacheKey.ForTenant` / `CacheKey.ForOrganization` / + `CacheKey.ForHostMapping`; every `ICacheService` implementation calls `CacheKey.EnsureValid`, and none re-prefixes. There is no query filter and no RLS policy in front of a dictionary, so the key is the entire isolation boundary — which is why the shape is validated rather than left to each call site to remember. @@ -207,12 +222,18 @@ drifts: | Family | Composed by | |---|---| -| `platform:hub:host-map:{host}` | `CacheKey.ForPlatform("hub", "host-map", host)` | +| `platform:hub:host-map:{host}` | `CacheKey.ForHostMapping(host)` | | `{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")` | +This table is also the allowlist for the low-cardinality `cache.name` metric label. +An unregistered family is emitted as `other`; full keys and tenant, organization, host, +session, or entity identifiers are never metric labels. Add a new stable family here and +to the adapter's metric-name mapping together. The instrument catalogue is in +[Standards 10 § Metrics](10-observability.md#metrics). + The last-but-one takes a **multi-part** logical name, and so does the host lookup. That is why the factories take one: a caller joining the parts itself would put a separator inside a single segment, which `CacheKey` rejects — so @@ -227,8 +248,8 @@ The host lookup is the **one** family that legitimately carries the `platform` sentinel, and it is worth saying why: it answers "which tenant is this?", so by construction there is no tenant to key it by. Every other family knows its tenant, so a `platform` sentinel there would be a bug wearing the sentinel's clothes — -and `CacheKey.EnsureValid` now refuses that shape outright, rejecting any key -whose sentinel is followed by an identifier segment. +and `CacheKey.EnsureValid` now refuses every platform family except the exact normalized +host-map shape. > An earlier version of this table listed these as `hub:host:{host}`, > `hub:entitlement:{tenant_id}` and `tenant_feature_flags:{tenant_id}` — module @@ -240,7 +261,7 @@ Rules: - **There is no prefix invalidation.** `ICacheService` has no `RemoveByPrefixAsync` — it was removed in [Phase 02a Packet 5](../roadmap/phase-02a-kernel-tenancy.md) per - [ADR-0014 Amendment 2](../decisions/0014-adopt-dapr.md), because the only + [ADR-0038 § Cache contract](../decisions/0038-cross-cutting-port-and-event-contracts.md#cache-contract), because the only implementable form iterated a process-local key set and never evicted what another instance wrote. A key family that needs to invalidate a set it cannot enumerate uses the **generation-key** pattern instead: a durable counter bumped @@ -507,7 +528,7 @@ are the Hub's public API, governed by the Hub repository. ## References -- [ADR-0014 Adopt Dapr](../decisions/0014-adopt-dapr.md) +- [ADR-0038 Cross-Cutting Port and Event Contracts](../decisions/0038-cross-cutting-port-and-event-contracts.md) - [ADR-0015 API Gateway: APISIX](../decisions/0015-api-gateway-apisix.md) - [ADR-0019 LearnStack Hub](../decisions/0019-learnstack-hub.md) - [ADR-0020 Triple Deployment + Hybrid License](../decisions/0020-triple-deployment-hybrid-license.md) diff --git a/docs/standards/21-architecture-tests-catalogue.md b/docs/standards/21-architecture-tests-catalogue.md index 8c6026b4..e89f94ee 100644 --- a/docs/standards/21-architecture-tests-catalogue.md +++ b/docs/standards/21-architecture-tests-catalogue.md @@ -1113,7 +1113,7 @@ Introduced by [Phase 02b](../roadmap/phase-02b-events-auth.md). omits it; the residual assertion is that the value is non-null and non-blank at runtime. `IntegrationEventEnvelope` reads it off the event — it is deliberately **not** threaded through `IEventBus` as a second parameter, which is the source of drift - [ADR-0014 Amendment 3](../decisions/0014-adopt-dapr.md) removed. `InProcessEventBus` + [ADR-0038](../decisions/0038-cross-cutting-port-and-event-contracts.md) removes. `InProcessEventBus` serialises dispatch per key: concurrent across keys, sequential within one. - **Source:** [Phase 02b](../roadmap/phase-02b-events-auth.md); [15-event-and-outbox.md](../architecture/15-event-and-outbox.md). @@ -1168,9 +1168,10 @@ registered, which is the shape of gap this catalogue exists to close. It is ther #### `Modules_Do_Not_Inject_IEventBus_Directly` -- **Asserts:** no type in a module assembly takes `IEventBus` as a constructor parameter - or holds one in a field. The only sanctioned publisher is the `OutboxProcessor`; - modules write to the outbox. +- **Asserts:** no type in a module assembly takes, returns or stores `IEventBus`, and no + module takes or stores `IServiceProvider` as a service-locator escape hatch. Constructor + and method parameters, return types, fields and properties are checked. The only + sanctioned publisher is the `OutboxProcessor`; modules write to the outbox. - **Source:** [20-infrastructure-stack.md § `IEventBus`](20-infrastructure-stack.md); [ADR-0010](../decisions/0010-cross-module-communication.md). - **Type:** xUnit + reflection over module assemblies. **Kind:** structural. @@ -1181,13 +1182,16 @@ registered, which is the shape of gap this catalogue exists to close. It is ther inline. A namespace ban cannot express it: modules legitimately depend on `LearnStack.SharedKernel.Messaging` for `IIntegrationEvent` and `IIntegrationEventHandler`. The module sweep is vacuous until a module ships code, - so the checker is pointed at a deliberate offender in the test assembly first. + so the checker is pointed at direct-injection, method-injection and service-locator + deliberate offenders in the test assembly first. - **Phase:** 02a Packet 5. #### `Integration_Event_TopicNames_FollowConvention` - **Asserts:** every declared integration-event type resolves a topic matching - `learnstack.{module}.{aggregate}` (and `learnstack.hub.*` for Hub-side topics). Reads + `learnstack.{module}.{aggregate}`, plus the Hub-only four-segment form + `learnstack.hub.{domain}.{event}`. Segments start with a lower-case letter, may contain + internal hyphens, and never end in a hyphen. Reads the event declarations, not a broker, so it holds for whichever `IEventBus` implementation is registered. - **Source:** [20-infrastructure-stack.md § `IEventBus`](20-infrastructure-stack.md); diff --git a/infra/compose/README.md b/infra/compose/README.md index 7b13ac84..eabd2ada 100644 --- a/infra/compose/README.md +++ b/infra/compose/README.md @@ -143,17 +143,21 @@ docker compose --env-file .env --profile '*' -f infra/compose/dev.yml down -v ## `e2e.yml` — end-to-end overlay -Layered on top of `dev.yml` to swap durable named volumes for tmpfs, so -every run starts from a clean Postgres / Valkey / SeaweedFS / Meilisearch / Kafka. -Images, ports, and credentials are identical to dev — only the -*operational posture* (data persistence + Mailpit retention) changes. +Layered on top of `dev.yml` to swap durable named volumes for tmpfs. By default, +`make e2e-up` starts the same seven services as `make dev`, so Postgres, SeaweedFS and +Meilisearch start clean; the Valkey and Kafka overrides are conditional because those +services remain behind `COMPOSE_PROFILES=gated`. Images, ports, and credentials are +identical to dev — only the *operational posture* (data persistence + Mailpit retention) +changes. ```bash make e2e-up # tmpfs-backed stack up +COMPOSE_PROFILES=gated make e2e-up # include clean Valkey + Kafka make e2e-down # stop; tmpfs evaporates # Raw equivalent: docker compose --env-file .env -f infra/compose/dev.yml -f infra/compose/e2e.yml up -d +COMPOSE_PROFILES=gated docker compose --env-file .env -f infra/compose/dev.yml -f infra/compose/e2e.yml up -d ``` Phase 06 Playwright + Phase 07 SDK contract tests run against this overlay diff --git a/infra/compose/dev.yml b/infra/compose/dev.yml index 86384e32..dab38389 100644 --- a/infra/compose/dev.yml +++ b/infra/compose/dev.yml @@ -4,7 +4,7 @@ # (PostgreSQL 18 / Valkey / SeaweedFS / Mailpit / Meilisearch), the self-hosted Keycloak # identity provider (two realms), the live-media stack (LiveKit OSS + Coturn), # Kafka (KRaft) + kafka-ui, HashiCorp Vault (-dev mode), the Dapr sidecar + -# placement (pub/sub + state + secrets building blocks per ADR-0014), and the +# placement (pub/sub + state + secrets building blocks per ADR-0038), and the # APISIX gateway in file-driven standalone mode per ADR-0015. The DX # orchestrator (`make` targets) and CI workflow arrive in Phase-01 packets 7-8. # @@ -309,7 +309,7 @@ services: retries: 5 # ---- Eventing (Phase 01 packet 6) ---------------------------------------- - # Kafka in KRaft mode (no ZooKeeper). Backs Dapr pub/sub per ADR-0014. + # Kafka in KRaft mode (no ZooKeeper). Backs Dapr pub/sub per ADR-0038. # Module code never imports `Confluent.Kafka` — only `IEventBus` via Dapr. # # Listener note: only the in-cluster PLAINTEXT://kafka:9092 listener is @@ -418,7 +418,7 @@ services: # ---- Dapr sidecar (Phase 01 packet 6) ------------------------------------ # Placement service required by daprd even though actors are out of scope - # per ADR-0014 non-goals (daprd will not boot without it). + # per ADR-0038 non-goals (daprd will not boot without it). dapr-placement: profiles: ["gated"] image: daprio/placement:1.17.7 diff --git a/infra/dapr/README.md b/infra/dapr/README.md index f1bf5428..b6a78085 100644 --- a/infra/dapr/README.md +++ b/infra/dapr/README.md @@ -1,8 +1,8 @@ # Dapr Sidecar (Dev) Cross-cutting infrastructure runtime per -[ADR-0014 (Adopt Dapr)](../../docs/decisions/0014-adopt-dapr.md). Three -building blocks are adopted; everything else is **out of scope** per ADR-0014 +[ADR-0038](../../docs/decisions/0038-cross-cutting-port-and-event-contracts.md). Three +building blocks are adopted; everything else is **out of scope** per ADR-0038 non-goals. | Building block | Backend (dev) | Component file | Application interface | @@ -17,9 +17,8 @@ Phase ownership (per [phase-02a](../../docs/roadmap/phase-02a-kernel-tenancy.md) - **Phase 02a** declares all three interfaces in `LearnStack.SharedKernel` with default in-process implementations — `InProcessEventBus`, - `InMemoryCacheService`, and `ConfigurationSecretProvider`. Only the third - exists today (`SharedKernel/Secrets/ConfigurationSecretProvider.cs`, Packet 3); - the other two land with their interfaces in Packet 5. Those defaults are the + `InMemoryCacheService`, and `ConfigurationSecretProvider`. All three exist + today and are wired at the composition root. Those defaults are the **only** implementations registered, in every `DeploymentMode`. - **Phase 11** ships the Dapr-backed implementations (`DaprEventBus`, `DaprCacheService`, `DaprSecretProvider`) in `LearnStack.Infrastructure`, on @@ -80,18 +79,18 @@ out of scope — `daprd` won't start without it. ## Application access pattern -Per ADR-0014 + Standards 20, modules **never** import `Dapr.Client`. They +Per ADR-0038 + Standards 20, modules **never** import `Dapr.Client`. They consume: ```csharp -public interface IEventBus { Task PublishAsync(T @event, CancellationToken ct) where T : IIntegrationEvent; } +public interface IEventBus { Task PublishAsync(IntegrationEventEnvelope envelope, CancellationToken ct); } public interface ICacheService { Task GetAsync(string key, CancellationToken ct); /* … */ } public interface ISecretProvider { Task GetSecretAsync(string key, CancellationToken ct); /* … */ } ``` `DaprEventBus`, `DaprCacheService`, `DaprSecretProvider` are the **only** Dapr-aware types in the codebase; they live in `LearnStack.Infrastructure` -and ship in Phase 02a (composition-root selected per `DeploymentMode`). +and ship in Phase 11 on ADR-0035's triggers. Architecture tests `Dapr_SDK_Types_NotImportedOutsideInfrastructure`, `Modules_DoNotReference_DaprPackage`, and @@ -136,9 +135,8 @@ YAML metadata field. ## What does NOT live here - The `IEventBus` / `ICacheService` / `ISecretProvider` implementations — - the **interfaces + Dapr-backed adapters** both ship in **Phase 02a** - (see § Phase ownership above); only the *outbox dispatch path* that - becomes the sanctioned caller of `IEventBus.PublishAsync` is Phase 02b. + the interfaces and default adapters shipped in Phase 02a; Dapr-backed + adapters belong to Phase 11 (see § Phase ownership above). - Outbox dispatcher (`OutboxProcessor` polling + dispatch) — Phase 02b. - Per-module `inbox_messages` table + `IInboxGuard` — Phase 02b. - Production Vault setup (HA mode, auto-unseal, AppRole policies) — Phase 11. diff --git a/infra/dapr/components/pubsub-kafka.yaml b/infra/dapr/components/pubsub-kafka.yaml index 93ca0ffd..da72d4e4 100644 --- a/infra/dapr/components/pubsub-kafka.yaml +++ b/infra/dapr/components/pubsub-kafka.yaml @@ -1,6 +1,6 @@ # Dapr pub/sub component — Kafka backend. -# Per ADR-0014, application code talks to `IEventBus` exclusively; the -# `DaprEventBus` implementation (lands Phase 02b) publishes through this +# Per ADR-0038, application code talks to `IEventBus` exclusively; the +# `DaprEventBus` implementation (lands Phase 11 after ADR-0035's trigger) publishes through this # component. Topic naming follows the `learnstack.{module}.{aggregate}` # convention enforced by `Dapr_PubSub_TopicNames_FollowConvention` # architecture test. diff --git a/infra/dapr/components/statestore-redis.yaml b/infra/dapr/components/statestore-redis.yaml index 29e8a489..f089ed19 100644 --- a/infra/dapr/components/statestore-redis.yaml +++ b/infra/dapr/components/statestore-redis.yaml @@ -12,7 +12,7 @@ # provider-type convention; renaming it would just hide the wire # protocol the component speaks. # -# `actorStateStore: "false"` is INTENTIONAL — per ADR-0014 non-goals, +# `actorStateStore: "false"` is INTENTIONAL — per ADR-0038 non-goals, # Dapr actors are NOT adopted. Flipping this to "true" requires a new # ADR + an architecture-test update. The Dapr docs are explicit that the # flag must be "true" only when the application code consumes the actors From eea34058e27db5dd6569859d47754e727aa10cad Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Thu, 27 Aug 2026 05:02:28 +0300 Subject: [PATCH 18/21] fix(kernel): tell a timed-out caller it timed out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review findings against the current tree. Two changed behaviour; the rest were corpus drift, and three I did not act on. **A service-owned timeout was reported as a cancellation.** `Flight` carries a 30-second factory budget, and when it expired the flight ended with `TrySetCanceled` — so every waiter, including ones whose own token was perfectly healthy, was told it had cancelled. A caller could not tell its own cancellation from the cache giving up, and ASP.NET reads a cancellation as "the client hung up": no body, no captured error, no span, so the timeout an operator needs to see would vanish. It faults with a `TimeoutException` now. The other cancellation source is untouched and still correct — `ReleaseWaiter` cancels when the last waiter leaves, and there is no observer left to mislead. The budget became a constructor parameter so a test can reach the path without waiting out the production value. **A concurrency test could lose the overlap it exists to detect.** `Interlocked.Exchange(ref max, Math.Max(max, current))` reads, computes and writes as three steps, so two threads can both read the same value and the lower result can land last. In a test whose whole point is detecting overlapping factories, that is a guard that passes on broken code. It is a compare-and-swap loop now. **The handler method is resolved once, at registration.** It was looked up per dispatch with a null-forgiving `!`; resolving it when the subscription is built keeps reflection off the delivery path and moves the assertion to startup, where a drifted contract fails immediately instead of on the first event of its type in production. `Modules_Do_Not_Inject_IEventBus_Directly` forbids both `IEventBus` and the `IServiceProvider` escape hatch, but its failure message named only the first — a module caught through the second would have been told why in terms that did not apply. Corpus, all verified against the code first: - `architecture/15`'s `DaprEventBus` published `envelope.Event` with only `partitionKey` metadata, dropping correlation, organization, causation and actor — exactly what ADR-0014 Amendment 3 added the envelope to carry, and what a consumer needs to restore its context. The trace chain would have broken at the broker. - `architecture/29`'s `DaprCacheService` did not implement its own interface: no `GetAsync`, no `RemoveAsync`. Its miss path also had no single-flight, which the shipped default owes and it does not. - `architecture/09` still told modules to write unprefixed keys and let `DaprCacheService.PrefixKey` prefix them. Under ADR-0038 the caller composes and the adapter only validates; an adapter that also prefixed would emit `{tenant}:{tenant}:{module}:{entity}`. - `wire-dapr-pubsub` carried its own copy of the topic regex, and the copy had drifted: it collapsed the two shapes into one optional trailing group, so it accepted `learnstack.identity.user.created`, which the architecture test rejects. The copy is gone; the test is the source of truth, which is what the skill already said it should be. It also described Dapr delivering to `/dapr/subscribe-endpoint` — discovery is `GET /dapr/subscribe`, delivery is a `POST` to the routes it returns, and there is no such endpoint. - `10-cross-module-contracts` stated the topic convention without the Hub four-segment exception the test allows. - The `24-learnstack-hub` sequence diagram branched on "Cache fresh (<15m)" three hundred lines from the section stating the L1 TTL is 60 seconds and that 15 minutes is a Phase 11 L2 bound. - The glossary called L1 `IMemoryCache`; it is `InMemoryCacheService`. - `04-technical-architecture`'s Secrets row was the only one in its table still stating the target as the present, and `12-infrastructure`'s configuration chain said Vault wins today. - `phase-02b` argued against itself: cross-instance invalidation lands with the Phase 11 adapter, then "wiring the subscription now" a sentence later. - `OutboxFlushBehavior`'s TODO named Dapr as the dispatcher. The port is the contract; the transport behind it is a composition-root decision the behavior never sees. - `local-dev-setup` accepted generic Node 20 where `frontend/package.json` sets `>=20.11.0` and CI pins `20.11.0`. - ADR-0022's amendment now spells the host segment as the **normalized** host per ADR-0036 — a raw header would produce several keys for one site. Not acted on: removing `hub:host:{host}` from ADR-0022's Decision outcome, because an Accepted ADR's decision section is not rewritten and the superseding amendment is already marked in place there; and two findings naming `RunFactoryAsync`/`FactoryTimeout` semantics that the current code already satisfies. 729 tests green, 0 warnings under CI=true. Co-Authored-By: Claude Opus 5 (1M context) --- .claude/skills/local-dev-setup/SKILL.md | 4 +- .claude/skills/wire-dapr-pubsub/SKILL.md | 21 ++++--- README.md | 5 +- .../Pipeline/OutboxFlushBehavior.cs | 9 ++- .../Caching/InMemoryCacheService.cs | 38 ++++++++++-- .../Messaging/InProcessEventBus.cs | 2 +- .../IntegrationEventHandlerRegistry.cs | 11 +++- .../Messaging/IntegrationEventSubscription.cs | 14 ++++- .../CrossCuttingFoundationTests.cs | 6 +- .../Caching/InMemoryCacheServiceTests.cs | 59 ++++++++++++++++++- .../architecture/04-technical-architecture.md | 2 +- docs/architecture/09-tenant-isolation.md | 9 ++- .../architecture/10-cross-module-contracts.md | 4 +- docs/architecture/15-event-and-outbox.md | 36 ++++++++--- docs/architecture/24-learnstack-hub.md | 2 +- docs/architecture/29-dapr-integration.md | 29 +++++++++ docs/decisions/0022-custom-domain-tls.md | 7 ++- docs/glossary.md | 2 +- docs/roadmap/phase-02b-events-auth.md | 4 +- docs/standards/12-infrastructure.md | 6 +- 20 files changed, 226 insertions(+), 44 deletions(-) diff --git a/.claude/skills/local-dev-setup/SKILL.md b/.claude/skills/local-dev-setup/SKILL.md index 41b98448..4f34c008 100644 --- a/.claude/skills/local-dev-setup/SKILL.md +++ b/.claude/skills/local-dev-setup/SKILL.md @@ -50,7 +50,7 @@ the backend runs today calls them, so `make dev` starts 7 services and |-------|----------|-------------| | Docker Desktop | Yes | Required for every container. | | .NET 10 SDK | Yes | `dotnet --version` returns `10.0.x`. | -| Node 20+ + pnpm | Yes | For the frontend. | +| Node >=20.11.0 + pnpm | Yes | For the frontend; `frontend/package.json` sets the floor and CI pins `20.11.0`. | | Deployment mode | Yes | `Development` (default) / `SaaS` / `Dedicated` / `SelfHostedOnline` / `SelfHostedAirGapped` (per [Standards 12 § Deployment Modes](../../../docs/standards/12-infrastructure.md)). | | `.env` (gitignored) | Optional | Local overrides; `.env.example` is the source of truth. | @@ -60,7 +60,7 @@ the backend runs today calls them, so `make dev` starts 7 services and ```bash dotnet --version # 10.0.x -node --version # v20+ +node --version # >=20.11.0 pnpm --version docker info >/dev/null && echo "docker OK" ``` diff --git a/.claude/skills/wire-dapr-pubsub/SKILL.md b/.claude/skills/wire-dapr-pubsub/SKILL.md index bad6beaf..33cf3068 100644 --- a/.claude/skills/wire-dapr-pubsub/SKILL.md +++ b/.claude/skills/wire-dapr-pubsub/SKILL.md @@ -68,13 +68,17 @@ Format: `learnstack.{module}.{aggregate}`. Examples: | `learnstack.hub.custom-domain.activated` | Hub → core host mapping update | | `learnstack.cache.invalidation` | Cross-instance L1 cache invalidation | -Architecture test `Integration_Event_TopicNames_FollowConvention` (defined in -[15-event-and-outbox.md § Architecture tests](../../../docs/architecture/15-event-and-outbox.md)) -rejects anything that doesn't match -`^learnstack\.(?:[a-z]|[a-z][a-z0-9-]*[a-z0-9])\.(?:[a-z]|[a-z][a-z0-9-]*[a-z0-9])(?:\.(?:[a-z]|[a-z][a-z0-9-]*[a-z0-9]))?$`, -with the four-segment form accepted only when segment two is `hub`. +Architecture test `Integration_Event_TopicNames_FollowConvention` +(`CrossCuttingFoundationTests`) is the source of truth for the pattern, and this skill +deliberately does **not** restate it. A copy of the regex lived here and had already +drifted: it collapsed the two shapes into one optional trailing group, so it accepted +`learnstack.identity.user.created` — a four-segment core topic the test rejects. Two +things to know, and the test for the rest: -The optional fourth segment exists for **Hub-side event-name suffixes** +- LearnStack-core topics are **three** segments: `learnstack.{module}.{aggregate}`. +- A **fourth** segment is accepted only when the second is `hub`. + +The fourth segment exists for **Hub-side event-name suffixes** (`learnstack.hub.custom-domain.activated`, `learnstack.hub.custom-domain.deactivated`, `learnstack.hub.custom-domain.revoked`). LearnStack-core topics stay 3-segment (`learnstack.{module}.{aggregate}`); the 4-segment shape is reserved for the Hub-side @@ -147,7 +151,10 @@ registry supplies the current subscription metadata. The future Phase 11 subscription pipeline must preserve this behavior: -1. Dapr sidecar delivers HTTP POST to `/dapr/subscribe-endpoint`. +1. The sidecar **discovers** subscriptions with `GET /dapr/subscribe`, which returns + the topic-to-route table; it then **delivers** each event with an HTTP `POST` to the + route that table named. They are two different calls, and there is no endpoint + called `/dapr/subscribe-endpoint`. 2. LearnStack's Phase 11 Dapr adapter deserialises the envelope, restores `TenantContext` from the event and envelope, and resolves the matching `IIntegrationEventHandler` directly from DI. diff --git a/README.md b/README.md index 35c7ed96..3d6bce3d 100644 --- a/README.md +++ b/README.md @@ -93,8 +93,9 @@ make seed # verify health + print demo credentials lives in exactly one file: [Database Standards](docs/standards/05-database.md). - **Foundation ports:** `IEventBus`, `ICacheService`, `ISecretProvider`, - `IEntitlementProvider`, `IHostToTenantResolver` in `LearnStack.SharedKernel`, each - with a working default implementation. Vendor adapters — Dapr + in `LearnStack.SharedKernel`, each with a working default implementation. + `IEntitlementProvider` and `IHostToTenantResolver` are **not** among them — both need + tenancy schema and land with Packets 9 and 7. Vendor adapters — Dapr ([ADR-0038](docs/decisions/0038-cross-cutting-port-and-event-contracts.md)), Kafka, Valkey ([ADR-0030](docs/decisions/0030-redis-compatible-store-valkey.md)), Vault, APISIX ([ADR-0015](docs/decisions/0015-api-gateway-apisix.md)) — are **demand-gated**: each diff --git a/backend/src/LearnStack.Application/Pipeline/OutboxFlushBehavior.cs b/backend/src/LearnStack.Application/Pipeline/OutboxFlushBehavior.cs index 8739c204..e02fb8a7 100644 --- a/backend/src/LearnStack.Application/Pipeline/OutboxFlushBehavior.cs +++ b/backend/src/LearnStack.Application/Pipeline/OutboxFlushBehavior.cs @@ -29,9 +29,12 @@ public Task Handle( ArgumentNullException.ThrowIfNull(next); // TODO(2026-05-21, @platform): Phase 02b — on a success-Result, flush - // IOutbox messages collected during the handler into outbox_messages - // via the unit-of-work seam so Dapr pub/sub dispatches them after - // commit. Per ADR-0006 + ADR-0038 + ADR-0032 § Sub-decision 12. + // IOutbox messages collected during the handler into outbox_messages via + // the unit-of-work seam, so the OutboxProcessor dispatches them through + // IEventBus after commit. The port is the contract; which transport is + // behind it — InProcessEventBus today, the Dapr adapter on its ADR-0035 + // trigger — is a composition-root decision this behavior never sees. + // Per ADR-0006 + ADR-0038 + ADR-0032 § Sub-decision 12. return next(); } diff --git a/backend/src/LearnStack.Infrastructure/Caching/InMemoryCacheService.cs b/backend/src/LearnStack.Infrastructure/Caching/InMemoryCacheService.cs index aa7a1569..e3ff4fff 100644 --- a/backend/src/LearnStack.Infrastructure/Caching/InMemoryCacheService.cs +++ b/backend/src/LearnStack.Infrastructure/Caching/InMemoryCacheService.cs @@ -54,6 +54,7 @@ public sealed class InMemoryCacheService : ICacheService private const int KeyGateCount = 256; + private readonly TimeSpan _factoryTimeout; private readonly IClock _clock; private readonly ConcurrentDictionary _entries = new(StringComparer.Ordinal); private readonly ConcurrentDictionary<(string Key, Type Type), Flight> _inFlight = new(); @@ -71,11 +72,18 @@ public sealed class InMemoryCacheService : ICacheService private long _lastSweepTicks; private long _sequence; - public InMemoryCacheService(IClock clock, IMeterFactory meterFactory) + /// + /// How long a single factory may run before the service gives up on it. + /// Defaults to ; overridable so the timeout path + /// can be exercised without a test waiting out the production budget. + /// + public InMemoryCacheService( + IClock clock, IMeterFactory meterFactory, TimeSpan? factoryTimeout = null) { ArgumentNullException.ThrowIfNull(clock); ArgumentNullException.ThrowIfNull(meterFactory); + _factoryTimeout = factoryTimeout ?? FactoryTimeout; _clock = clock; var meter = meterFactory.Create(new MeterOptions(MeterName)); _hits = meter.CreateCounter(HitCounterName); @@ -174,7 +182,7 @@ public async Task GetOrSetAsync( } else { - flight = new Flight(FactoryTimeout) { Waiters = 1 }; + flight = new Flight(_factoryTimeout) { Waiters = 1 }; _inFlight[registration] = flight; owner = true; } @@ -272,9 +280,31 @@ private async Task RunFactoryAsync( } catch (OperationCanceledException) when (flight.FactoryToken.IsCancellationRequested) { - outcome = "cancelled"; + // Two different things cancel the factory token, and only one of them + // has anyone left to tell. ReleaseWaiter cancels it when the LAST + // waiter leaves — there is no observer by construction, so cancelled + // is the honest terminal state. FactoryTimeout cancels it because the + // service's own budget ran out, and the waiters still holding on have + // healthy tokens of their own: handing them a cancellation says "you + // asked for this" when they did not, and a caller cannot tell the two + // apart. Worse, ASP.NET treats a cancellation as "the client hung up" + // — no body, no captured error, no span — so a timeout the operator + // needs to see would disappear. + var abandoned = flight.Abandoned; + + outcome = abandoned ? "cancelled" : "timeout"; RetireTerminalFlight(registration, flight, key); - flight.TrySetCanceled(); + + if (abandoned) + { + flight.TrySetCanceled(); + } + else + { + flight.TrySetException(new TimeoutException( + $"The cache factory for '{key}' did not complete within " + + $"{_factoryTimeout.TotalSeconds:0.###}s.")); + } } catch (Exception exception) { diff --git a/backend/src/LearnStack.Infrastructure/Messaging/InProcessEventBus.cs b/backend/src/LearnStack.Infrastructure/Messaging/InProcessEventBus.cs index fd416e66..2bae7a25 100644 --- a/backend/src/LearnStack.Infrastructure/Messaging/InProcessEventBus.cs +++ b/backend/src/LearnStack.Infrastructure/Messaging/InProcessEventBus.cs @@ -163,7 +163,7 @@ private async Task DeliverAsync( exception); } - var handle = subscription.ContractType.GetMethod(HandleMethodName)!; + var handle = subscription.Handle; Task delivery; try diff --git a/backend/src/LearnStack.Infrastructure/Messaging/IntegrationEventHandlerRegistry.cs b/backend/src/LearnStack.Infrastructure/Messaging/IntegrationEventHandlerRegistry.cs index 74c4f322..c0a3b533 100644 --- a/backend/src/LearnStack.Infrastructure/Messaging/IntegrationEventHandlerRegistry.cs +++ b/backend/src/LearnStack.Infrastructure/Messaging/IntegrationEventHandlerRegistry.cs @@ -15,6 +15,9 @@ namespace LearnStack.Infrastructure.Messaging; /// public sealed class IntegrationEventHandlerRegistry { + private const string HandleMethodName = + nameof(IIntegrationEventHandler.HandleAsync); + private readonly IReadOnlyDictionary _subscriptions; private IntegrationEventHandlerRegistry(IEnumerable subscriptions) @@ -91,11 +94,17 @@ private static IntegrationEventSubscription CreateSubscription( Type handlerType, Type contract) { var eventType = contract.GetGenericArguments()[0]; + var handle = contract.GetMethod(HandleMethodName) + ?? throw new InvalidOperationException( + $"{contract.FullName} declares no {HandleMethodName}; the " + + "integration-event handler contract has drifted."); + return new IntegrationEventSubscription( eventType, handlerType, contract, - ModuleName(handlerType, eventType)); + ModuleName(handlerType, eventType), + handle); } private static string ModuleName(Type handlerType, Type eventType) diff --git a/backend/src/LearnStack.Infrastructure/Messaging/IntegrationEventSubscription.cs b/backend/src/LearnStack.Infrastructure/Messaging/IntegrationEventSubscription.cs index 5bd44584..00101e27 100644 --- a/backend/src/LearnStack.Infrastructure/Messaging/IntegrationEventSubscription.cs +++ b/backend/src/LearnStack.Infrastructure/Messaging/IntegrationEventSubscription.cs @@ -1,8 +1,20 @@ +using System.Reflection; + namespace LearnStack.Infrastructure.Messaging; /// One concrete handler subscription and its stable module identity. +/// +/// The contract's HandleAsync, resolved once when the subscription is built. +/// +/// +/// Resolved here rather than per dispatch for two reasons. It keeps a reflection +/// lookup off the delivery path, and it moves the assertion that the method +/// exists to startup — where a contract that has drifted fails immediately and +/// visibly, instead of throwing on the first event of its type in production. +/// public sealed record IntegrationEventSubscription( Type EventType, Type HandlerType, Type ContractType, - string ModuleName); + string ModuleName, + MethodInfo Handle); diff --git a/backend/tests/LearnStack.Tests.Architecture/CrossCuttingFoundationTests.cs b/backend/tests/LearnStack.Tests.Architecture/CrossCuttingFoundationTests.cs index b0135cdf..189cae66 100644 --- a/backend/tests/LearnStack.Tests.Architecture/CrossCuttingFoundationTests.cs +++ b/backend/tests/LearnStack.Tests.Architecture/CrossCuttingFoundationTests.cs @@ -434,8 +434,10 @@ public void Modules_Do_Not_Inject_IEventBus_Directly() .ToList(); offenders.Should().BeEmpty( - $"{name} injects IEventBus. Modules write to the outbox; the " - + "OutboxProcessor publishes (Standards 20 § IEventBus)."); + $"{name} reaches IEventBus directly — by injecting it, or through " + + "IServiceProvider, which is the same access with an extra step. " + + "Modules write to the outbox; the OutboxProcessor publishes " + + "(Standards 20 § IEventBus)."); } } diff --git a/backend/tests/LearnStack.Tests.Unit/Infrastructure/Caching/InMemoryCacheServiceTests.cs b/backend/tests/LearnStack.Tests.Unit/Infrastructure/Caching/InMemoryCacheServiceTests.cs index ee768e6c..e4fe446f 100644 --- a/backend/tests/LearnStack.Tests.Unit/Infrastructure/Caching/InMemoryCacheServiceTests.cs +++ b/backend/tests/LearnStack.Tests.Unit/Infrastructure/Caching/InMemoryCacheServiceTests.cs @@ -599,7 +599,7 @@ public async Task An_Abandoned_Factory_Must_Terminate_Before_Its_Replacement_Sta { Interlocked.Increment(ref calls); var current = Interlocked.Increment(ref running); - Interlocked.Exchange(ref maximumRunning, Math.Max(maximumRunning, current)); + RecordMaximum(ref maximumRunning, current); entered.TrySetResult(); await release.WaitAsync(TestTimeout, CancellationToken.None); Interlocked.Decrement(ref running); @@ -617,7 +617,7 @@ public async Task An_Abandoned_Factory_Must_Terminate_Before_Its_Replacement_Sta { Interlocked.Increment(ref calls); var current = Interlocked.Increment(ref running); - Interlocked.Exchange(ref maximumRunning, Math.Max(maximumRunning, current)); + RecordMaximum(ref maximumRunning, current); Interlocked.Decrement(ref running); return Task.FromResult("fresh"); }); @@ -1035,6 +1035,34 @@ public async Task An_Explicitly_Stored_Null_Reads_Back_As_A_Miss() cache.Count.Should().Be(1, "it does occupy a slot, whatever a reader sees"); } + [Fact] + public async Task A_Factory_That_Outlives_Its_Budget_Times_Out_Rather_Than_Cancels() + { + // The service owns a 30s factory budget, and it used to end the flight by + // CANCELLING it — so every waiter, including ones whose own token was + // perfectly healthy, was told "you asked for this" when they had not. + // A caller could not tell its own cancellation from the cache's budget + // expiring, and ASP.NET reads a cancellation as "the client hung up": + // no body, no captured error, no span, so a timeout an operator needs to + // see would vanish. + var cache = new InMemoryCacheService( + new FixedClock(Origin), MeterFactory, factoryTimeout: TimeSpan.FromMilliseconds(80)); + using var never = new SemaphoreSlim(0); + + var act = () => cache.GetOrSetAsync( + Key(), + async token => + { + await never.WaitAsync(TimeSpan.FromMinutes(5), token); + return "never"; + }); + + // A TimeoutException, not an OperationCanceledException: the caller's own + // token was never cancelled, so a cancellation would be a lie about who + // gave up. + await act.Should().ThrowAsync(); + } + // ---- the TTL boundary --------------------------------------------------- [Fact] @@ -1075,6 +1103,33 @@ public async Task A_Write_During_The_Atomic_Miss_Check_Is_Observed() (await cache.GetAsync(Key())).Should().Be("landed-in-the-window"); } + /// + /// Raises to if it is + /// higher, atomically. + /// + /// + /// Interlocked.Exchange(ref max, Math.Max(max, current)) reads, computes + /// and writes as three separate steps: two threads can both read the same + /// value and the lower result can land last, losing a genuine overlap. In a + /// test whose whole point is detecting overlap, that is a guard that passes + /// on broken code. + /// + private static void RecordMaximum(ref int maximum, int candidate) + { + var observed = Volatile.Read(ref maximum); + + while (candidate > observed) + { + var previous = Interlocked.CompareExchange(ref maximum, candidate, observed); + if (previous == observed) + { + return; + } + + observed = previous; + } + } + /// /// A clock that performs a write on one chosen read, to land a concurrent /// operation inside a window too narrow to hit by scheduling. diff --git a/docs/architecture/04-technical-architecture.md b/docs/architecture/04-technical-architecture.md index 5b9ce3d2..a9cf6f45 100644 --- a/docs/architecture/04-technical-architecture.md +++ b/docs/architecture/04-technical-architecture.md @@ -10,7 +10,7 @@ | Database | PostgreSQL 18.x (major pinned per [ADR-0031](../decisions/0031-postgresql-major-version.md); shared schema + RLS isolation; ADR-0003) | | Cache & coordination | **`InMemoryCacheService` now; Valkey 8.x via Dapr State Store after its trigger** (Linux-Foundation BSD-3 fork per [ADR-0030](../decisions/0030-redis-compatible-store-valkey.md); [29-dapr-integration.md](29-dapr-integration.md), [ADR-0038](../decisions/0038-cross-cutting-port-and-event-contracts.md)) | | Pub/Sub | **`InProcessEventBus` now; Apache Kafka via Dapr Pub/Sub after its trigger** ([29-dapr-integration.md](29-dapr-integration.md), [ADR-0038](../decisions/0038-cross-cutting-port-and-event-contracts.md)) | -| Secrets | **HashiCorp Vault via Dapr Secret Store** (or env-var fallback in Dev) | +| Secrets | **`ConfigurationSecretProvider` now; HashiCorp Vault via Dapr Secret Store after its trigger** ([ADR-0035](../decisions/0035-demand-gated-infrastructure.md); every mode resolves the configuration-backed default today) | | Distributed runtime | **Dapr 1.17.7** in the gated local stack; sidecar target for pub/sub, state, and secrets | | Object storage | SeaweedFS (local), S3-compatible (production) | | Background jobs | Hangfire (Postgres storage) | diff --git a/docs/architecture/09-tenant-isolation.md b/docs/architecture/09-tenant-isolation.md index 9829655a..c36b3d7a 100644 --- a/docs/architecture/09-tenant-isolation.md +++ b/docs/architecture/09-tenant-isolation.md @@ -232,8 +232,13 @@ tenants/{tenant_id}/brand/... ← tenant- platform:{module}:{entity}:{id} ← platform-admin operation ``` -`DaprCacheService.PrefixKey` auto-prefixes; modules write keys in the unprefixed form -(`{module}:{entity}:{id}`). +**The caller composes the key; an adapter only validates it.** `CacheKey.ForTenant`, +`CacheKey.ForOrganization` and `CacheKey.ForPlatform` produce the shapes above, and +every `ICacheService` implementation calls `CacheKey.EnsureValid` and rewrites nothing +([ADR-0038](../decisions/0038-cross-cutting-port-and-event-contracts.md)). An adapter +that prefixed as well would emit `{tenant}:{tenant}:{module}:{entity}` — and a module +writing an unprefixed key would be writing one two tenants can both compute, which is +the whole reason the tenant segment is mandatory. ### Search (Meilisearch — ADR-0012) diff --git a/docs/architecture/10-cross-module-contracts.md b/docs/architecture/10-cross-module-contracts.md index 3c5d2cd6..7e67abcb 100644 --- a/docs/architecture/10-cross-module-contracts.md +++ b/docs/architecture/10-cross-module-contracts.md @@ -6,7 +6,9 @@ Modules collaborate only through explicit contracts. This keeps the modular mono > integration events (Mechanism #3 in this document) dispatch through `IEventBus`: > `InProcessEventBus` today and Dapr pub/sub → Kafka only after its Phase 11 trigger. > The outbox table remains the durable producer-side buffer. Topic naming convention: -> `learnstack.{module}.{aggregate}`. Consumer-side idempotency via per-module inbox guard +> `learnstack.{module}.{aggregate}`, with `learnstack.hub.{domain}.{event}` as the one +> exception — a fourth segment is accepted only when the second is `hub`, which is what +> `Integration_Event_TopicNames_FollowConvention` enforces. Consumer-side idempotency via per-module inbox guard > (`IInboxGuard`). Application contracts (Mechanism #1), intra-module domain events > (Mechanism #2), and read-model projections (Mechanism #4) are unchanged. See > [15-event-and-outbox.md](15-event-and-outbox.md) for the full producer/consumer flow. diff --git a/docs/architecture/15-event-and-outbox.md b/docs/architecture/15-event-and-outbox.md index fbf0bc8f..77c23c87 100644 --- a/docs/architecture/15-event-and-outbox.md +++ b/docs/architecture/15-event-and-outbox.md @@ -420,15 +420,35 @@ public sealed class DaprEventBus(DaprClient daprClient) : IEventBus { ArgumentNullException.ThrowIfNull(envelope); + // Every envelope field crosses the wire, not just the partition key. + // Publishing `envelope.Event` with only `partitionKey` metadata drops + // CorrelationId, OrganizationId, CausationId and ActorUserId — which is + // exactly what ADR-0014 Amendment 3 added the envelope to carry, and + // exactly what the consumer needs to restore its tenant context. The + // trace chain would break at the broker instead of at the outbox. + var metadata = new Dictionary + { + ["partitionKey"] = envelope.PartitionKey, + ["cloudevent.traceparent"] = envelope.CorrelationId, + }; + + if (envelope.OrganizationId is { } organization) + { + metadata["organizationId"] = organization.ToString(); + } + + if (envelope.CausationId is { } causation) + { + metadata["causationId"] = causation.ToString(); + } + + if (envelope.ActorUserId is { } actor) + { + metadata["actorUserId"] = actor.Value.ToString(); + } + return daprClient.PublishEventAsync( - "pubsub", - envelope.Topic, - envelope.Event, - new Dictionary - { - ["partitionKey"] = envelope.PartitionKey, - }, - ct); + "pubsub", envelope.Topic, envelope.Event, metadata, ct); } } ``` diff --git a/docs/architecture/24-learnstack-hub.md b/docs/architecture/24-learnstack-hub.md index 12255a53..2226703c 100644 --- a/docs/architecture/24-learnstack-hub.md +++ b/docs/architecture/24-learnstack-hub.md @@ -402,7 +402,7 @@ sequenceDiagram User->>LSApi: "Start recording" command LSApi->>LSApi: IFeatureFlags.IsEnabledAsync(FeatureKeys.ClassroomRecording) LSApi->>Cache: SELECT WHERE tenant_id = X - alt Cache fresh (<15m) + alt Cache fresh (<60s L1) Cache-->>LSApi: Entitlement (gen=42) else Cache stale or miss LSApi->>HubAPI: POST /api/v1/internal/license/verify
(mTLS + JWT + HMAC; tenant_id, feature_key) diff --git a/docs/architecture/29-dapr-integration.md b/docs/architecture/29-dapr-integration.md index 2ea6116a..b7081199 100644 --- a/docs/architecture/29-dapr-integration.md +++ b/docs/architecture/29-dapr-integration.md @@ -50,6 +50,11 @@ flowchart LR Kafka --> OtherDaprd --> OtherApp ``` +**Production pod topology, in text:** the API process and its Dapr sidecar share one +pod. The API talks to the sidecar over localhost; the sidecar talks to Kafka, Valkey and +Vault; and it delivers subscribed events back to the API over HTTP. Nothing in a module +speaks to a broker directly — the ports do. + The diagram is the production pod target: the sidecar shares the app pod's network namespace via the Kubernetes Dapr annotation. Local development deliberately runs the .NET host on the workstation and the sidecar in Compose; the exact topology and service @@ -240,11 +245,35 @@ internal sealed class DaprCacheService : ICacheService return state; } + // Abridged: the shipped `InMemoryCacheService` coalesces concurrent + // misses per (key, requested type) so one factory runs however many + // callers arrive, the first caller owns the TTL, and a replacement waits + // for an abandoned factory to terminate. This adapter owes the same + // contract — the factory is the expensive side, and a cache that lets N + // simultaneous misses each run it turns a cold key into a stampede + // against the dependency it exists to spare. var value = await factory(ct); await SetAsync(key, value, options, ct); return value; } + public async Task GetAsync(string key, CancellationToken ct = default) + { + CacheKey.EnsureValid(key); + + if (_memoryCache.TryGetValue(key, out T? cached) && cached is not null) return cached; + + var (state, etag) = await _dapr.GetStateAndETagAsync(StateStoreName, key, cancellationToken: ct); + return string.IsNullOrEmpty(etag) ? default : state; + } + + public async Task RemoveAsync(string key, CancellationToken ct = default) + { + CacheKey.EnsureValid(key); + _memoryCache.Remove(key); + await _dapr.DeleteStateAsync(StateStoreName, key, cancellationToken: ct); + } + public Task SetAsync(string key, T value, CacheOptions? options = null, CancellationToken ct = default) { CacheKey.EnsureValid(key); diff --git a/docs/decisions/0022-custom-domain-tls.md b/docs/decisions/0022-custom-domain-tls.md index 9b1d04d4..c3efb049 100644 --- a/docs/decisions/0022-custom-domain-tls.md +++ b/docs/decisions/0022-custom-domain-tls.md @@ -505,7 +505,12 @@ across all modes. The decision is unchanged. Only the **spelling** in the resolver sketch above is clarified: the sketch says `hub:host:{host}`, while the shipped contract is -`platform:hub:host-map:{host}`. +`platform:hub:host-map:{normalized-host}` — one canonical form, and the host segment is +the **normalized** host per +[ADR-0036](0036-tenant-resolution-trusted-inputs.md), which has already lowercased it, +punycoded it and stripped the port. A raw `Host` header would produce several keys for +one site, and one carrying `:8443` would be refused outright rather than split into two +segments. `CacheKey.EnsureValid` requires the tenant segment first and mandatory, so `hub:host:{host}` is refused outright. A host lookup is the one key family that diff --git a/docs/glossary.md b/docs/glossary.md index 5aaf4995..b623de5b 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -269,7 +269,7 @@ This glossary defines LearnStack-specific terms. When a term is ambiguous across | **`ITenantContextAccessor`** | The singleton, `AsyncLocal`-backed accessor that cross-cutting infrastructure (`TenantContextSpanProcessor`, Serilog enricher, Sentry enricher) reads to enrich telemetry without inheriting the request-scoped DI lifetime. Populated at scope start by `TenantResolverMiddleware` (HTTP), `HubCorrelationMiddleware` (`/api/internal/*`), Hangfire `JobActivator` (background jobs), and the outbox / inbox handler scope. Modules never write to it. See [ADR-0032 § Sub-decision 10](decisions/0032-exception-handling-logging-and-observability.md). | | **`TenantContextSpanProcessor`** | The `BaseProcessor` registered once at the OTel tracing pipeline; its `OnStart` hook reads from `ITenantContextAccessor` and enriches every span with `tenant.id`, `organization.id`, `user.id`, `module`, `correlation.id` — including spans produced by auto-instrumentation libraries (EF Core, HttpClient, Valkey via Dapr, SeaweedFS S3 SDK, LiveKit). See [ADR-0032 § Sub-decision 10](decisions/0032-exception-handling-logging-and-observability.md). | | **`ProviderException.IsClientError`** | Boolean flag set by adapters when translating upstream 4xx (`true`) or 5xx (`false`) responses. The L1 `IExceptionHandler` reads it to decide whether to Sentry-capture (only 5xx — provider's infra fault) or log-only (4xx — provider's user-error). | -| **L1 / L2 / L3 (cache)** | "L1 cache" is the per-pod in-process `IMemoryCache`; "L2 cache" is the cross-pod Valkey state via Dapr. Both layers are managed through `ICacheService`; do not confuse with error-handling layers. See [20-infrastructure-stack.md § Cache layer cheat sheet](standards/20-infrastructure-stack.md). | +| **L1 / L2 / L3 (cache)** | "L1 cache" is the per-pod in-process layer — `InMemoryCacheService` today; "L2 cache" is the cross-pod Valkey state via Dapr. Both layers are managed through `ICacheService`; do not confuse with error-handling layers. See [20-infrastructure-stack.md § Cache layer cheat sheet](standards/20-infrastructure-stack.md). | ## Foundation Infrastructure diff --git a/docs/roadmap/phase-02b-events-auth.md b/docs/roadmap/phase-02b-events-auth.md index 1e82f183..2f4f081d 100644 --- a/docs/roadmap/phase-02b-events-auth.md +++ b/docs/roadmap/phase-02b-events-auth.md @@ -185,8 +185,8 @@ distributed adapter in Phase 11. Declaring or consuming it in this single-instan would provide no cross-instance effect — which is precisely [ADR-0035](../decisions/0035-demand-gated-infrastructure.md)'s trigger for the distributed `ICacheService` adapter in [Phase 11](phase-11-production-hardening.md). -Wiring the subscription now means the Phase 11 adapter has a consumer waiting rather than -a code path to invent. +This phase therefore neither declares the topic nor consumes it; the adapter that gives +it an effect brings its own subscription. ### Background jobs diff --git a/docs/standards/12-infrastructure.md b/docs/standards/12-infrastructure.md index 004c898d..352c314d 100644 --- a/docs/standards/12-infrastructure.md +++ b/docs/standards/12-infrastructure.md @@ -159,8 +159,10 @@ expression of the secret port rather than a parallel mechanism. ## Configuration - Strongly-typed `IOptions` bound in code. -- Sources, in order: `ISecretProvider` (Vault via Dapr) → env vars → - `appsettings.{env}.json` → `appsettings.json`. Vault wins. +- Sources, in order: `ISecretProvider` → env vars → `appsettings.{env}.json` → + `appsettings.json`; the provider wins. `ISecretProvider` resolves + `ConfigurationSecretProvider` in every mode today, so it reads that same chain — + Vault behind it is the Phase 11 target, not the current path (§ Secrets Management). - No secrets in git. - Secrets stored in Vault for `SaaS` / `Dedicated`; in Vault or a sealed file for `SelfHosted`; in env files (gitignored) for `Development`. From 1b652e847335a5556e7d9ca7b2942ff91bdd2f73 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Thu, 27 Aug 2026 05:24:45 +0300 Subject: [PATCH 19/21] fix(kernel): make the factory budget a deadline, not a token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both findings were valid, and the second one mattered. **The factory timeout only bound factories that already cooperated.** `CancelAfter` cancels a token; it does not stop a factory, and a factory that never observes its token — the ordinary shape for any dependency call that does not thread one — ran to completion regardless. Measured against a 150 ms budget: the caller waited 3,002 ms and was handed the late value. The budget was not a timeout at all for the case that most needs one, and the branch reporting a timeout could not execute, so the previous commit's `TimeoutException` was unreachable on exactly this path. The deadline is raced now. Three things had to hold together, and each has a test that fails when its part is removed: - the caller is answered at the deadline — measured, 152 ms against a 150 ms budget; - the late result is never stored, so a value that arrived after its caller gave up cannot become the cached one; - the flight stays registered until the factory actually terminates, so a replacement cannot run a second factory for the same key beside the first. It waits on the factory rather than on the completion, which is already settled — waiting on the completion would have spun the retry loop hot instead, since a terminal flight satisfies it immediately. That last part is why `Flight` gained `Overrunning`. Marking the flight abandoned alone would have sent every later caller into a retry that returned instantly and looped. **A stale instruction outlived the thing it described.** The previous commit removed this skill's copy of the topic regex — the copy that had drifted and accepted a four-segment core topic — but left the sentence telling authors to keep that regex aligned with the architecture test. It now says not to restate the pattern at all, and why. 730 tests green, 0 warnings under CI=true, 10 consecutive runs stable. Co-Authored-By: Claude Opus 5 (1M context) --- .claude/skills/wire-dapr-pubsub/SKILL.md | 5 +- .../Caching/InMemoryCacheService.cs | 86 ++++++++++++++++++- .../Caching/InMemoryCacheServiceTests.cs | 33 +++++++ 3 files changed, 120 insertions(+), 4 deletions(-) diff --git a/.claude/skills/wire-dapr-pubsub/SKILL.md b/.claude/skills/wire-dapr-pubsub/SKILL.md index 33cf3068..6e1cf06b 100644 --- a/.claude/skills/wire-dapr-pubsub/SKILL.md +++ b/.claude/skills/wire-dapr-pubsub/SKILL.md @@ -82,8 +82,9 @@ The fourth segment exists for **Hub-side event-name suffixes** (`learnstack.hub.custom-domain.activated`, `learnstack.hub.custom-domain.deactivated`, `learnstack.hub.custom-domain.revoked`). LearnStack-core topics stay 3-segment (`learnstack.{module}.{aggregate}`); the 4-segment shape is reserved for the Hub-side -naming exception. Treat the architecture test as the source of truth; keep this -skill's regex aligned with it. +naming exception. Do not restate the pattern here or anywhere else: the architecture +test is the source of truth, and the copy that used to live in this file is what drifted +away from it. ### Step 2: Dapr component YAML diff --git a/backend/src/LearnStack.Infrastructure/Caching/InMemoryCacheService.cs b/backend/src/LearnStack.Infrastructure/Caching/InMemoryCacheService.cs index e3ff4fff..90723221 100644 --- a/backend/src/LearnStack.Infrastructure/Caching/InMemoryCacheService.cs +++ b/backend/src/LearnStack.Infrastructure/Caching/InMemoryCacheService.cs @@ -260,10 +260,50 @@ private async Task RunFactoryAsync( { var started = Stopwatch.GetTimestamp(); var outcome = "success"; + Task? overrunning = null; try { - var produced = await factory(flight.FactoryToken).ConfigureAwait(false); + // Raced against the deadline rather than simply handed the token. + // CancelAfter cancels a TOKEN; it does not stop a factory, and a + // factory that never observes its token — the ordinary shape for any + // dependency call that does not thread one — runs to completion + // regardless. Measured against a 150 ms budget: the caller waited + // 3,002 ms and was handed the late value. The budget was not a + // timeout at all for the case that most needs one. + var running = factory(flight.FactoryToken); + var deadline = Task.Delay(Timeout.Infinite, flight.FactoryToken); + + if (await Task.WhenAny(running, deadline).ConfigureAwait(false) != running) + { + var timedOut = !flight.Abandoned; + + outcome = timedOut ? "timeout" : "cancelled"; + + // Marked abandoned and left REGISTERED: a later caller must not + // start a second factory for this key while this one is still + // running, and it cannot join this flight either, because the + // answer it would get is the timeout. It waits on the factory + // itself and then retries from scratch. + flight.Abandoned = true; + flight.Overrunning = running; + overrunning = running; + + if (timedOut) + { + flight.TrySetException(new TimeoutException( + $"The cache factory for '{key}' did not complete within " + + $"{_factoryTimeout.TotalSeconds:0.###}s.")); + } + else + { + flight.TrySetCanceled(); + } + + return; + } + + var produced = await running.ConfigureAwait(false); lock (KeyGate(key)) { @@ -317,6 +357,36 @@ private async Task RunFactoryAsync( _factoryDuration.Record( Stopwatch.GetElapsedTime(started).TotalSeconds, CacheNameAndOutcomeTags(key, outcome)); + + if (overrunning is null) + { + flight.Dispose(); + } + else + { + // Retirement waits for the real end of the factory. Its result is + // never stored — it answers a question whose caller has already + // been told the answer did not arrive. + _ = RetireWhenFactoryEndsAsync(registration, flight, key, overrunning); + } + } + } + + private async Task RetireWhenFactoryEndsAsync( + (string Key, Type Type) registration, Flight flight, string key, Task overrunning) + { + try + { + await overrunning.ConfigureAwait(false); + } + catch (Exception) + { + // Observed so it never surfaces as an uncorrelated process-wide + // event. Every caller has already been answered. + } + finally + { + RetireTerminalFlight(registration, flight, key); flight.Dispose(); } } @@ -349,7 +419,12 @@ private static async Task WaitForTerminalThenRetryAsync( { try { - await flight.Completion.WaitAsync(cancellationToken).ConfigureAwait(false); + // The FACTORY, when one is still running past its deadline — not the + // completion, which is already terminal and would spin this retry + // loop hot. Waiting for the factory is what keeps a replacement from + // overlapping it. + await (flight.Overrunning ?? flight.Completion) + .WaitAsync(cancellationToken).ConfigureAwait(false); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { @@ -558,6 +633,13 @@ public Flight(TimeSpan timeout) public CancellationToken FactoryToken => _factoryCancellation.Token; public int Waiters { get; set; } public bool Abandoned { get; set; } + + /// + /// The factory still running after this flight became terminal, if any. + /// A replacement for the same key waits on this rather than on + /// , which is already settled. + /// + public Task? Overrunning { get; set; } public bool Superseded { get; set; } public void CancelFactory() => _factoryCancellation.Cancel(); diff --git a/backend/tests/LearnStack.Tests.Unit/Infrastructure/Caching/InMemoryCacheServiceTests.cs b/backend/tests/LearnStack.Tests.Unit/Infrastructure/Caching/InMemoryCacheServiceTests.cs index e4fe446f..c428dadf 100644 --- a/backend/tests/LearnStack.Tests.Unit/Infrastructure/Caching/InMemoryCacheServiceTests.cs +++ b/backend/tests/LearnStack.Tests.Unit/Infrastructure/Caching/InMemoryCacheServiceTests.cs @@ -1063,6 +1063,39 @@ public async Task A_Factory_That_Outlives_Its_Budget_Times_Out_Rather_Than_Cance await act.Should().ThrowAsync(); } + [Fact] + public async Task A_Factory_That_Ignores_Its_Token_Is_Still_Bounded() + { + // The budget used to be a token and nothing else, so it only worked on + // factories that observed it — and a dependency call that does not + // thread one is the ordinary case, not the exotic one. Measured against + // a 150 ms budget: the caller waited 3,002 ms and was handed the late + // value. The deadline is raced now. + var cache = new InMemoryCacheService( + new FixedClock(Origin), MeterFactory, factoryTimeout: TimeSpan.FromMilliseconds(80)); + var runs = 0; + + var overrunning = cache.GetOrSetAsync(Key(), async _ => + { + Interlocked.Increment(ref runs); + await Task.Delay(TimeSpan.FromMilliseconds(600), CancellationToken.None); + return "late"; + }); + + await ((Func)(() => overrunning)).Should().ThrowAsync(); + + // The replacement waits for the overrunning factory rather than starting + // a second one beside it, and is not answered with the first caller's + // timeout for ever. + var replacement = await cache.GetOrSetAsync(Key(), _ => Task.FromResult("fresh")) + .WaitAsync(TestTimeout); + + replacement.Should().Be("fresh"); + runs.Should().Be(1, "no second factory ran alongside the first"); + (await cache.GetAsync(Key())).Should().Be("fresh", + "a result that arrived after its deadline is not cached"); + } + // ---- the TTL boundary --------------------------------------------------- [Fact] From 0679471423ce606b1fd54fa99d6111af02920555 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Thu, 27 Aug 2026 09:51:51 +0300 Subject: [PATCH 20/21] fix(kernel): refuse a factory budget that is not one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `factoryTimeout` reached `Flight` unvalidated, and `CancelAfter` answers the three bad values three different ways — none of them at the wiring that was wrong. Measured: - **negative** throws, but from inside `Flight`'s constructor on the first cache miss, so a misconfigured host starts clean and fails later, once per flight, with a stack pointing into the cache instead of at the registration; - **zero** is accepted and cancels immediately, turning the cache into a permanent `TimeoutException` generator; - **`Timeout.InfiniteTimeSpan`** is accepted and never fires at all — the deadline silently not existing, which is the exact defect the raced budget was added to remove, reached through configuration instead of through a factory that ignores its token. One check at construction covers all three, since `InfiniteTimeSpan` is −1 ms and therefore non-positive. The message names the infinite case explicitly, because that is the one a caller might pass deliberately meaning "no timeout". There is no upper bound to guard: measured, `CancelAfter` accepts spans past `int.MaxValue` milliseconds, including 30 days. Both directions are tested and both mutants die. A third — misspelling the `paramName` — is not a surviving mutant but an invalid one: CA2208 refuses to compile it, which is a stronger guard than a test. 734 tests green, 0 warnings under CI=true. Co-Authored-By: Claude Opus 5 (1M context) --- .../Caching/InMemoryCacheService.cs | 21 ++++++++++++ .../Caching/InMemoryCacheServiceTests.cs | 32 +++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/backend/src/LearnStack.Infrastructure/Caching/InMemoryCacheService.cs b/backend/src/LearnStack.Infrastructure/Caching/InMemoryCacheService.cs index 90723221..b3ce9b82 100644 --- a/backend/src/LearnStack.Infrastructure/Caching/InMemoryCacheService.cs +++ b/backend/src/LearnStack.Infrastructure/Caching/InMemoryCacheService.cs @@ -83,6 +83,27 @@ public InMemoryCacheService( ArgumentNullException.ThrowIfNull(clock); ArgumentNullException.ThrowIfNull(meterFactory); + // Checked here rather than left to CancelAfter, which answers the three + // bad values three different ways and none of them at the wiring that + // was wrong. Measured: a negative span throws — but from inside Flight's + // constructor on the FIRST cache miss, so a misconfigured host starts + // clean and fails later, per flight, with a stack pointing into the + // cache instead of at the registration. Zero is accepted and cancels + // immediately, turning the cache into a permanent TimeoutException + // generator. And Timeout.InfiniteTimeSpan is accepted and never fires at + // all — which is the deadline silently not existing, the exact defect + // the raced budget was added to remove, reached through configuration. + if (factoryTimeout is { } configured && configured <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException( + nameof(factoryTimeout), + configured, + "The cache factory timeout must be a positive, finite span. " + + "Timeout.InfiniteTimeSpan is refused deliberately: a factory " + + "budget that never elapses is not a budget, and a factory that " + + "ignores its cancellation token would then run unbounded."); + } + _factoryTimeout = factoryTimeout ?? FactoryTimeout; _clock = clock; var meter = meterFactory.Create(new MeterOptions(MeterName)); diff --git a/backend/tests/LearnStack.Tests.Unit/Infrastructure/Caching/InMemoryCacheServiceTests.cs b/backend/tests/LearnStack.Tests.Unit/Infrastructure/Caching/InMemoryCacheServiceTests.cs index c428dadf..906dba64 100644 --- a/backend/tests/LearnStack.Tests.Unit/Infrastructure/Caching/InMemoryCacheServiceTests.cs +++ b/backend/tests/LearnStack.Tests.Unit/Infrastructure/Caching/InMemoryCacheServiceTests.cs @@ -1096,6 +1096,38 @@ public async Task A_Factory_That_Ignores_Its_Token_Is_Still_Bounded() "a result that arrived after its deadline is not cached"); } + [Theory] + [InlineData(0, "zero cancels immediately, so every factory times out at once")] + [InlineData(-5000, "a negative span throws from Flight on the first miss, not here")] + [InlineData(-1, "Timeout.InfiniteTimeSpan never fires — a budget that is not one")] + public void A_Timeout_That_Is_Not_A_Timeout_Is_Refused_At_Construction( + int milliseconds, string why) + { + // CancelAfter answers these three differently and none of them at the + // wiring that was wrong: zero is accepted and fires instantly, a negative + // throws from inside the first flight, and InfiniteTimeSpan is accepted + // and never fires at all — the deadline silently not existing, which is + // the defect the raced budget removed, reached through configuration. + var act = () => new InMemoryCacheService( + new FixedClock(Origin), + MeterFactory, + factoryTimeout: TimeSpan.FromMilliseconds(milliseconds)); + + act.Should().Throw(why) + .And.ParamName.Should().Be("factoryTimeout"); + } + + [Fact] + public void A_Positive_Timeout_And_The_Default_Are_Both_Accepted() + { + var explicitly = () => new InMemoryCacheService( + new FixedClock(Origin), MeterFactory, factoryTimeout: TimeSpan.FromMilliseconds(1)); + var byDefault = () => new InMemoryCacheService(new FixedClock(Origin), MeterFactory); + + explicitly.Should().NotThrow(); + byDefault.Should().NotThrow(); + } + // ---- the TTL boundary --------------------------------------------------- [Fact] From 7fba51737b1391894aed24ec807ce83c140e0ee0 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Thu, 27 Aug 2026 14:32:35 +0300 Subject: [PATCH 21/21] fix(kernel): mark the consumer span when a delivery fails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The in-process transport started a consumer activity, logged handler failures at Error, and then let the activity end Unset. An operator filtering the trace backend for errors found a green consumer span sitting next to the error log describing the same delivery, and had no reason to look further. The activity now covers construction, invocation and the await, and records the exception plus SetStatus(Error). Publish-token cancellation stays Unset, for the reason Standards 10 leaves a client disconnect Unset: shutdown is not a failure, and marking it would put one Error span per in-flight subscription into the 100%-sampled error traces every time the host stops. Both branches are mutation-checked. Docs, all self-contradictions inside a single document or list: - 15-event-and-outbox.md and wire-dapr-pubsub handed `envelope.Event` to a generic Dapr publish overload. Its declared type is IIntegrationEvent by ADR-0038's design, so TData infers to the interface and the publish emits five members with every concrete field dropped — the exact loss IntegrationEventBase.ToPayloadJson() documents as measured, two paragraphs above the snippet that reintroduced it. Both now publish ToPayloadJson()'s bytes. - 29-dapr-integration.md claimed the cache implementation prefixes keys; its own section 3 explains why prefixing would emit {tenant}:{tenant}:{module}:{name}. - 12-infrastructure.md stated Vault storage and a Vault watcher as current, four lines under the bullet calling Vault a Phase 11 target. - 05-mvp-scope.md invalidated the entitlement cache on a "Dapr pub/sub event" in the list whose first bullet gates Dapr to Phase 11. - The host-map key family renders as {normalized-host}; ForHostMapping refuses anything else. ADR-0006 and ADR-0010 carry the same stale publish sketch, in ASCII flow diagrams. Accepted ADR bodies are immutable and ADR-0038 already governs the port shape, so they are left alone rather than amended. Co-Authored-By: Claude Opus 5 (1M context) --- .claude/skills/wire-dapr-pubsub/SKILL.md | 26 +++- .../Messaging/InProcessEventBus.cs | 125 +++++++++++------- .../Messaging/InProcessEventBusTests.cs | 73 ++++++++-- docs/architecture/05-mvp-scope.md | 6 +- docs/architecture/15-event-and-outbox.md | 21 ++- docs/architecture/29-dapr-integration.md | 4 +- docs/glossary.md | 2 +- docs/standards/12-infrastructure.md | 8 +- docs/standards/20-infrastructure-stack.md | 4 +- 9 files changed, 197 insertions(+), 72 deletions(-) diff --git a/.claude/skills/wire-dapr-pubsub/SKILL.md b/.claude/skills/wire-dapr-pubsub/SKILL.md index 6e1cf06b..fedc89f4 100644 --- a/.claude/skills/wire-dapr-pubsub/SKILL.md +++ b/.claude/skills/wire-dapr-pubsub/SKILL.md @@ -128,8 +128,17 @@ await db.SaveChangesAsync(ct); The `OutboxProcessor` (BackgroundService) polls `outbox_messages`, constructs an `IntegrationEventEnvelope`, and calls `IEventBus.PublishAsync(envelope)`. -`DaprEventBus.PublishAsync` invokes `DaprClient.PublishEventAsync` with -`envelope.Topic`, `envelope.Event`, and `envelope.PartitionKey` metadata. +`DaprEventBus.PublishAsync` publishes to `envelope.Topic` with the envelope's +delivery fields as metadata — see +[15-event-and-outbox.md § Ordering](../../../docs/architecture/15-event-and-outbox.md) +for the full metadata set, which is more than the partition key. + +**Never hand `envelope.Event` to a generic publish overload.** It is declared +`IIntegrationEvent`, so `TData` is inferred as the interface and the publish emits +its five members with every concrete field silently dropped — valid JSON, no +exception, a fact that arrives empty. The payload is +`((IntegrationEventBase)envelope.Event).ToPayloadJson()`, the same bytes the outbox +row holds. The topic is declared by the event's `Topic` override and checked by the architecture test; the transport never re-derives or renames it. @@ -207,9 +216,13 @@ metadata (`partitionKey`) on the `PublishEventAsync` call: ```csharp // LearnStack.Infrastructure.Messaging.DaprEventBus (Infrastructure only — never -// call DaprClient from a module). -await daprClient.PublishEventAsync( - "pubsub", envelope.Topic, envelope.Event, +// call DaprClient from a module). Abridged: the full metadata set is in +// 15-event-and-outbox.md, and dropping the rest of it breaks the trace chain. +var payload = Encoding.UTF8.GetBytes( + ((IntegrationEventBase)envelope.Event).ToPayloadJson()); + +await daprClient.PublishByteEventAsync( + "pubsub", envelope.Topic, payload, "application/json", metadata: new Dictionary { ["partitionKey"] = envelope.PartitionKey, @@ -254,6 +267,9 @@ dashboard when the adapter lands. Phase 11's Dapr binding test checks the component copy too. - **Direct `DaprClient` / `KafkaProducer` injection.** Both forbidden. Use `IEventBus`. +- **Publishing `envelope.Event` through a generic overload.** The declared type is + the interface, so the concrete event's fields are dropped without an error. Publish + `ToPayloadJson()`'s bytes — Step 3. - **Subscribing in the wrong module.** A subscription declared in the producer module's startup runs *on the producer side*, which is almost always wrong. - **Switching on deployment mode before the trigger.** All modes use diff --git a/backend/src/LearnStack.Infrastructure/Messaging/InProcessEventBus.cs b/backend/src/LearnStack.Infrastructure/Messaging/InProcessEventBus.cs index 2bae7a25..461e9b81 100644 --- a/backend/src/LearnStack.Infrastructure/Messaging/InProcessEventBus.cs +++ b/backend/src/LearnStack.Infrastructure/Messaging/InProcessEventBus.cs @@ -148,60 +148,29 @@ private async Task DeliverAsync( new("learnstack.module", subscription.ModuleName), ]); - await using var scope = scopeFactory.CreateAsyncScope(); - - object handler; + // The activity has to cover construction, invocation AND the await: + // a span that only wraps the happy path ends Unset on every failure, + // so a trace backend shows a green consumer span next to the error + // log that describes the same delivery. try { - handler = scope.ServiceProvider.GetRequiredService(subscription.HandlerType); - } - catch (Exception exception) - { - throw new InvalidOperationException( - $"Integration-event handler {subscription.HandlerType.FullName} " - + "failed to construct.", - exception); + await DeliverToHandlerAsync(subscription, envelope, cancellationToken) + .ConfigureAwait(false); } - - var handle = subscription.Handle; - Task delivery; - - try + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { - delivery = (Task)handle.Invoke(handler, [envelope.Event, cancellationToken])!; - } - catch (TargetInvocationException wrapped) - when (wrapped.InnerException is OperationCanceledException cancellation - && !cancellationToken.IsCancellationRequested) - { - throw new InvalidOperationException( - "An integration-event handler was cancelled by a token other than " - + "the publish token.", - cancellation); - } - catch (TargetInvocationException wrapped) when (wrapped.InnerException is not null) - { - ExceptionDispatchInfo.Capture(wrapped.InnerException).Throw(); + // Publish-token cancellation is shutdown, not a failed delivery. + // Left Unset for the reason Standards 10 leaves a client + // disconnect Unset: nothing failed that anyone should be paged + // about, and marking it Error would put shutdown noise into the + // 100%-sampled error traces. throw; } - - if (delivery is null) - { - throw new InvalidOperationException( - $"{handler.GetType().FullName} returned a null Task from {HandleMethodName}."); - } - - try - { - await delivery.ConfigureAwait(false); - } - catch (OperationCanceledException exception) - when (!cancellationToken.IsCancellationRequested) + catch (Exception exception) { - throw new InvalidOperationException( - "An integration-event handler was cancelled by a token other than " - + "the publish token.", - exception); + activity?.AddException(exception); + activity?.SetStatus(ActivityStatusCode.Error, exception.GetType().Name); + throw; } } finally @@ -210,6 +179,68 @@ private async Task DeliverAsync( } } + private async Task DeliverToHandlerAsync( + IntegrationEventSubscription subscription, + IntegrationEventEnvelope envelope, + CancellationToken cancellationToken) + { + await using var scope = scopeFactory.CreateAsyncScope(); + + object handler; + try + { + handler = scope.ServiceProvider.GetRequiredService(subscription.HandlerType); + } + catch (Exception exception) + { + throw new InvalidOperationException( + $"Integration-event handler {subscription.HandlerType.FullName} " + + "failed to construct.", + exception); + } + + var handle = subscription.Handle; + Task delivery; + + try + { + delivery = (Task)handle.Invoke(handler, [envelope.Event, cancellationToken])!; + } + catch (TargetInvocationException wrapped) + when (wrapped.InnerException is OperationCanceledException cancellation + && !cancellationToken.IsCancellationRequested) + { + throw new InvalidOperationException( + "An integration-event handler was cancelled by a token other than " + + "the publish token.", + cancellation); + } + catch (TargetInvocationException wrapped) when (wrapped.InnerException is not null) + { + ExceptionDispatchInfo.Capture(wrapped.InnerException).Throw(); + throw; + } + + if (delivery is null) + { + throw new InvalidOperationException( + $"{handler.GetType().FullName} returned a null Task from {HandleMethodName}."); + } + + try + { + await delivery.ConfigureAwait(false); + } + catch (OperationCanceledException exception) + when (!cancellationToken.IsCancellationRequested) + { + throw new InvalidOperationException( + "An integration-event handler was cancelled by a token other than " + + "the publish token.", + exception); + } + } + [LoggerMessage( EventId = 1, Level = LogLevel.Error, diff --git a/backend/tests/LearnStack.Tests.Unit/Infrastructure/Messaging/InProcessEventBusTests.cs b/backend/tests/LearnStack.Tests.Unit/Infrastructure/Messaging/InProcessEventBusTests.cs index 85726faf..d79dd21b 100644 --- a/backend/tests/LearnStack.Tests.Unit/Infrastructure/Messaging/InProcessEventBusTests.cs +++ b/backend/tests/LearnStack.Tests.Unit/Infrastructure/Messaging/InProcessEventBusTests.cs @@ -487,14 +487,7 @@ public async Task An_Async_Only_Disposable_Dependency_Is_Disposed_Exactly_Once() public async Task Each_Subscription_Continues_The_Producer_Trace() { var stopped = new ConcurrentBag(); - using var listener = new ActivityListener - { - ShouldListenTo = source => source.Name == InProcessEventBus.ActivitySourceName, - Sample = static (ref ActivityCreationOptions _) => - ActivitySamplingResult.AllDataAndRecorded, - ActivityStopped = stopped.Add, - }; - ActivitySource.AddActivityListener(listener); + using var listener = Listen(stopped); var recorder = new Recorder(); var (bus, _) = Build(recorder, services => @@ -510,6 +503,70 @@ public async Task Each_Subscription_Continues_The_Producer_Trace() recorder.Modules.Should().ContainSingle().Which.Should().Be("unknown"); } + [Fact] + public async Task A_Failed_Delivery_Marks_Its_Consumer_Span() + { + // The span and the log line describe the same delivery, so a span left + // Unset when the handler threw is worse than no span: an operator + // filtering a trace backend for errors finds a green consumer next to + // the error log, and concludes the failure happened somewhere else. + var stopped = new ConcurrentBag(); + using var listener = Listen(stopped); + + var recorder = new Recorder(); + var (bus, _) = Build(recorder, services => + services.AddScoped, ThrowingHandler>()); + + var act = () => bus.PublishAsync(Envelope(NewThing("a"))); + + await act.Should().ThrowAsync(); + var activity = stopped.Should().ContainSingle().Which; + activity.Status.Should().Be(ActivityStatusCode.Error); + activity.StatusDescription.Should().Be(nameof(InvalidOperationException)); + activity.Events.Should().ContainSingle() + .Which.Tags.Should().Contain( + tag => tag.Key == "exception.type" + && (string?)tag.Value == typeof(InvalidOperationException).FullName); + } + + [Fact] + public async Task Publish_Cancellation_Leaves_Its_Consumer_Span_Unset() + { + // The other half: shutdown is not a failure. Marking it Error would put + // one Error span per in-flight subscription into the 100%-sampled error + // traces every time the host stops, which is the same reason Standards + // 10 leaves a client disconnect Unset. + var stopped = new ConcurrentBag(); + using var listener = Listen(stopped); + + var recorder = new Recorder(); + var (bus, _) = Build(recorder, services => + services.AddScoped, CancellationWaitingHandler>()); + using var cancellation = new CancellationTokenSource(); + + var publish = bus.PublishAsync(Envelope(NewThing("a")), cancellation.Token); + await recorder.HandlerEntered.Task.WaitAsync(Timeout); + await cancellation.CancelAsync(); + + await ((Func)(() => publish)).Should().ThrowAsync(); + var activity = stopped.Should().ContainSingle().Which; + activity.Status.Should().Be(ActivityStatusCode.Unset); + activity.Events.Should().BeEmpty(); + } + + private static ActivityListener Listen(ConcurrentBag stopped) + { + var listener = new ActivityListener + { + ShouldListenTo = source => source.Name == InProcessEventBus.ActivitySourceName, + Sample = static (ref ActivityCreationOptions _) => + ActivitySamplingResult.AllDataAndRecorded, + ActivityStopped = stopped.Add, + }; + ActivitySource.AddActivityListener(listener); + return listener; + } + // ---- obligation: ordering per partition key ------------------------------ [Fact] diff --git a/docs/architecture/05-mvp-scope.md b/docs/architecture/05-mvp-scope.md index 0d5e622f..bfd6a9f9 100644 --- a/docs/architecture/05-mvp-scope.md +++ b/docs/architecture/05-mvp-scope.md @@ -67,8 +67,10 @@ test pass. development; Hub-backed and signed-license-key implementations land in Phase 02c. - `IHostToTenantResolver` + `platform_host_to_tenant` projection (host → tenant_id), populated by Hub for SaaS / by config for Self-Hosted. -- `platform_entitlement_cache` projection (15-min TTL, eager-invalidated on - `learnstack.hub.entitlement` Dapr pub/sub event). +- `platform_entitlement_cache` projection (15-min TTL, eager-invalidated on the + `learnstack.hub.entitlement` integration event — carried by `InProcessEventBus` + today, by Dapr pub/sub once Phase 11's trigger fires; the event and its handler are + the same either way). - Architecture tests run **from Day 1** of Phase 02 (not added later as cleanup). ### Tenancy & Organization diff --git a/docs/architecture/15-event-and-outbox.md b/docs/architecture/15-event-and-outbox.md index 77c23c87..d02cd227 100644 --- a/docs/architecture/15-event-and-outbox.md +++ b/docs/architecture/15-event-and-outbox.md @@ -213,7 +213,7 @@ they are available. See [Ordering](#ordering). **The payload is written by `event.ToPayloadJson()`, never by `JsonSerializer.Serialize(@event)`.** `EnqueueAsync` takes `IIntegrationEvent`, so the -declared type at that call is the interface — and serializing through it emits the four +declared type at that call is the interface — and serializing through it emits the five interface members and silently drops everything the concrete event added. Valid JSON, no exception, and the truncated row commits inside the transaction that reported success; the loss surfaces later as a `JsonException` on every dispatch attempt until the message @@ -447,8 +447,23 @@ public sealed class DaprEventBus(DaprClient daprClient) : IEventBus metadata["actorUserId"] = actor.Value.ToString(); } - return daprClient.PublishEventAsync( - "pubsub", envelope.Topic, envelope.Event, metadata, ct); + // The payload is written by ToPayloadJson() here for the same reason it is + // at the outbox row: `envelope.Event` is declared IIntegrationEvent — + // ADR-0038 made the port non-generic on purpose — so a generic + // PublishEventAsync would infer TData from that declared type and publish + // the five interface members with every concrete field silently dropped. + // The cast is safe because Integration_Events_Inherit_From_IntegrationEventBase + // makes it an architecture-test invariant, and it fails loudly if that ever + // stops being true. Publishing the bytes the outbox row already holds also + // means the wire and the row cannot disagree. + var payload = Encoding.UTF8.GetBytes( + ((IntegrationEventBase)envelope.Event).ToPayloadJson()); + + // Confirm the exact byte-publishing overload against the Dapr SDK when the + // adapter lands; what is not negotiable is that it takes pre-serialized + // bytes rather than the interface-typed reference. + return daprClient.PublishByteEventAsync( + "pubsub", envelope.Topic, payload, "application/json", metadata, ct); } } ``` diff --git a/docs/architecture/29-dapr-integration.md b/docs/architecture/29-dapr-integration.md index b7081199..31a45a39 100644 --- a/docs/architecture/29-dapr-integration.md +++ b/docs/architecture/29-dapr-integration.md @@ -102,7 +102,9 @@ sets `actorStateStore` to `false`. Its empty development password is replaced by deployment configuration when the Phase 11 adapter lands. Used as L2 cache. Modules call `ICacheService.GetOrSetAsync(...)`; the implementation -wraps state-store calls plus an L1 in-memory cache plus tenant-aware key prefixing. +wraps state-store calls plus an L1 in-memory cache. It does **not** prefix the key — +callers compose complete keys with `CacheKey`, and every implementation validates what +it is handed rather than rewriting it (§ 3). ### `secretstore-vault.yaml` — Vault secret store diff --git a/docs/glossary.md b/docs/glossary.md index b623de5b..9f6540c8 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -283,7 +283,7 @@ This glossary defines LearnStack-specific terms. When a term is ambiguous across | **`IPartitionSerializer`** | Runs work sequentially within one partition key and concurrently across different ones — the in-process stand-in for what a broker gives you by assigning a partition to one consumer. It exists so the development transport carries the same ordering guarantee as the durable path rather than a weaker one. Queuing work for the key you are already inside is refused rather than deadlocked: the caller that does it is publishing from inside a handler, which [Standards 20](standards/20-infrastructure-stack.md) forbids. | | **`EventTenantContext`** | The `ITenantContext` a consumer runs under, rebuilt from the envelope by the transport before handler discovery or resolution. A consumer executes outside the request that produced the fact, so there is no ambient context to inherit. The effective principal is `UserId.SystemActor`; an envelope human remains available separately as `CausalActorUserId`. Restoring the tenant and optional organization is what makes query filters and RLS policies evaluate against the right scope. | | **`UserId.SystemActor`** | The fixed, non-empty `UserId` (`00000000-0000-7000-8000-000000000001`) that integration-event consumers, background jobs and other non-request executions use as their effective audit principal — what [Audit Coverage](standards/18-audit-coverage.md) means by an actor of type `system`. It must have a matching `users` row before a persisted consumer can write an audit foreign key. The Tenancy schema and that seed are owned by [Phase 02a Packet 6](roadmap/phase-02a-kernel-tenancy.md); neither exists yet. | -| **`ICacheService`** | Interface for cache reads / writes. `InMemoryCacheService` today; a Valkey-backed implementation when more than one instance runs concurrently. Tenant keys are `{tenant_id}:{module}:{logical-name}`, or `{tenant_id}:{organization_id}:{module}:{logical-name}` for organization scope, composed by `CacheKey` and enforced by `CacheKey.EnsureValid`. The only platform-wide family is the normalized host map `platform:hub:host-map:{host}`, composed by `CacheKey.ForHostMapping`; there is no generic platform factory. `RemoveByPrefixAsync` is absent per [ADR-0038](decisions/0038-cross-cutting-port-and-event-contracts.md). Set invalidation uses a caller-owned durable generation key embedded in the key template. | +| **`ICacheService`** | Interface for cache reads / writes. `InMemoryCacheService` today; a Valkey-backed implementation when more than one instance runs concurrently. Tenant keys are `{tenant_id}:{module}:{logical-name}`, or `{tenant_id}:{organization_id}:{module}:{logical-name}` for organization scope, composed by `CacheKey` and enforced by `CacheKey.EnsureValid`. The only platform-wide family is the normalized host map `platform:hub:host-map:{normalized-host}`, composed by `CacheKey.ForHostMapping`; there is no generic platform factory. `RemoveByPrefixAsync` is absent per [ADR-0038](decisions/0038-cross-cutting-port-and-event-contracts.md). Set invalidation uses a caller-owned durable generation key embedded in the key template. | | **`ISecretProvider`** | Interface for secret reads. `ConfigurationSecretProvider` today; the Vault-backed implementation when a production secret must rotate without a redeploy, or more than one operator needs access to production secrets ([ADR-0035](decisions/0035-demand-gated-infrastructure.md) — *not* when a non-development deployment merely exists, which SaaS satisfies on day one). Secret namespace `learnstack/{deployment}/{module}/{key}`. | | **`IEntitlementProvider`** | Interface for the Entitlement Projection source. Implementations: `NullEntitlementProvider` (Development only — all features enabled, no limits), `HubEntitlementProvider` (SaaS / Dedicated, from Phase 02c), `SignedLicenseKeyEntitlementProvider` (Self-Hosted; skeleton from Hub `P02c-6`, hardened in Phase 11). The Hub-backed provider resolves in the normative order `L1 → L2 → platform_entitlement_cache → Hub` and never throws out of a feature-flag check. | | **`IHostToTenantResolver`** | Interface for host → `(tenant_id, organization_id?)` resolution. Reads `platform_host_to_tenant` and **nothing else** — never the Hub, because an anonymous page load must not depend on a control plane being reachable ([ADR-0034](decisions/0034-hub-contract-surface-invariant.md)). | diff --git a/docs/standards/12-infrastructure.md b/docs/standards/12-infrastructure.md index 352c314d..b92ce670 100644 --- a/docs/standards/12-infrastructure.md +++ b/docs/standards/12-infrastructure.md @@ -164,11 +164,13 @@ expression of the secret port rather than a parallel mechanism. `ConfigurationSecretProvider` in every mode today, so it reads that same chain — Vault behind it is the Phase 11 target, not the current path (§ Secrets Management). - No secrets in git. -- Secrets stored in Vault for `SaaS` / `Dedicated`; in Vault or a sealed file for - `SelfHosted`; in env files (gitignored) for `Development`. +- **Phase 11 target state**, once the Vault trigger fires: secrets stored in Vault for + `SaaS` / `Dedicated`; in Vault or a sealed file for `SelfHosted`; in env files + (gitignored) for `Development`. Today every mode reads the configuration chain above. - Production secrets rotated at least every 90 days where rotation is feasible. - `IOptionsMonitor` is used where dynamic refresh is required (e.g. Hub URL, HMAC - secret); a Vault watcher pushes updates. + secret). What pushes an update into it is the **Phase 11** Vault watcher; until then + the monitor refreshes from the configuration chain's own change tokens. ## CI/CD diff --git a/docs/standards/20-infrastructure-stack.md b/docs/standards/20-infrastructure-stack.md index d26bddb5..63e988b9 100644 --- a/docs/standards/20-infrastructure-stack.md +++ b/docs/standards/20-infrastructure-stack.md @@ -210,7 +210,7 @@ different decisions: | Key family | L1 (in-process `IMemoryCache`) | L2 (Dapr state → Valkey) | Eager invalidation event | |---|---|---|---| -| `platform:hub:host-map:{host}` (host → tenant) | 2 min | 15 min | `learnstack.hub.custom-domain.activated/.deactivated` | +| `platform:hub:host-map:{normalized-host}` (host → tenant) | 2 min | 15 min | `learnstack.hub.custom-domain.activated/.deactivated` | | `{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 | generation key — see the rule below | | `{tenant_id}:identity:permissions:{session_id}` | 60 s | session-scoped (no L2) | `learnstack.identity.role` / `.membership` events | @@ -222,7 +222,7 @@ drifts: | Family | Composed by | |---|---| -| `platform:hub:host-map:{host}` | `CacheKey.ForHostMapping(host)` | +| `platform:hub:host-map:{normalized-host}` | `CacheKey.ForHostMapping(normalizedHost)` | | `{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)` |