diff --git a/.claude/skills/add-integration-event/SKILL.md b/.claude/skills/add-integration-event/SKILL.md index b28d9145..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 | At minimum: `EventId`, `OccurredAt`, `TenantId`. Optional: `OrganizationId`, `CorrelationId`, `CausationId`, `ActorUserId`. | +| 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 @@ -57,19 +58,41 @@ 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" + + // 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` (shared kernel) provides: -- `EventId` (uuid v7) -- `OccurredAt` (UTC) -- `TenantId` -- Optional `OrganizationId`, `CorrelationId`, `CausationId`, `ActorUserId` +`IntegrationEventBase` (`LearnStack.SharedKernel.Messaging`) supplies exactly +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 +`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. @@ -82,8 +105,9 @@ In the producer's command handler (see ```csharp await outbox.EnqueueAsync(new EnrollmentCreatedIntegrationEventV1 { + EventId = guidFactory.NewUuidV7(), // 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, @@ -100,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} @@ -116,8 +140,10 @@ learnstack.{module}.{aggregate} - `learnstack.classroom.session` - `learnstack.hub.entitlement` (Hub side) -The architecture test `Dapr_PubSub_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 @@ -156,22 +182,43 @@ 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 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 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 @@ -188,8 +235,8 @@ Two tests minimum: - `LearnStack.Tests.Architecture` is green; specifically `Integration_Events_Inherit_From_IntegrationEventBase`, `Integration_Event_Handlers_Use_InboxGuard`, - `Dapr_PubSub_TopicNames_FollowConvention`. -- An integration test confirms the round-trip: handler publishes → outbox row + `Integration_Event_TopicNames_FollowConvention`. +- 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. @@ -203,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 a857ac5e..4f34c008 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, 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 @@ -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, 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 +`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)). @@ -25,8 +30,9 @@ LiveKit / Meilisearch / APISIX — the same components production uses - 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. @@ -44,7 +50,7 @@ LiveKit / Meilisearch / APISIX — the same components production uses |-------|----------|-------------| | 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. | @@ -54,7 +60,7 @@ LiveKit / Meilisearch / APISIX — the same components production uses ```bash dotnet --version # 10.0.x -node --version # v20+ +node --version # >=20.11.0 pnpm --version docker info >/dev/null && echo "docker OK" ``` @@ -62,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 @@ -75,14 +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 # 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, kafka-ui, Vault, APISIX and Dapr ``` `make dev` is `docker compose up -d` plus a status line. It does **not** start @@ -118,6 +126,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. @@ -145,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 @@ -158,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 @@ -178,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 @@ -199,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. | @@ -209,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. @@ -235,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 6b0396f9..fedc89f4 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,17 +68,23 @@ 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 -[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-]*)?$`. +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 third 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 -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 @@ -116,108 +126,106 @@ 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` 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 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. -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. +The future Phase 11 subscription pipeline must preserve this behavior: + +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. 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` 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. - -### Step 6: Cross-instance L1 cache invalidation +`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. -If your module has its own L1 in-memory cache (rare; prefer `ICacheService`), you -must subscribe to `learnstack.cache.invalidation`: +The handler code is the **same** on both transports; the bus is the only +difference. Implement the handler once as +`IIntegrationEventHandler` and expose its assembly to the +registry as shown in Step 4. -```csharp -services.AddDaprSubscription( - topic: "learnstack.cache.invalidation", - pubsubName: "pubsub"); -``` +### Step 6: Cross-instance L1 cache invalidation -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, +// 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"] = enrollment.Id.ToString(), + ["partitionKey"] = envelope.PartitionKey, }); ``` @@ -226,41 +234,47 @@ 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`. +- **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. -- **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 d15e6698..9e50f173 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -109,6 +109,63 @@ 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 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" \ + && 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() { + 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 # file with "source validation failed: source is not a directory" — so a @@ -127,6 +184,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 +215,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 +226,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/.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/.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/**" 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/Makefile b/Makefile index 42e09ba6..cdaa1920 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,35 +64,41 @@ 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, 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, 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" .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). +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" .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/README.md b/README.md index 91349e7c..3d6bce3d 100644 --- a/README.md +++ b/README.md @@ -93,9 +93,10 @@ 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 - ([ADR-0014](docs/decisions/0014-adopt-dapr.md)), Kafka, Valkey + 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 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 9c5dd8cd..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 @@ -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.TryAddTransient(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 @@ -86,10 +98,38 @@ 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); + + // 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(); + + 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; } @@ -194,6 +234,70 @@ 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(), + 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(), + services.GetRequiredService< + LearnStack.Infrastructure.Messaging.IntegrationEventHandlerRegistry>(), + services.GetRequiredService< + Microsoft.Extensions.Logging.ILogger< + LearnStack.Infrastructure.Messaging.InProcessEventBus>>()); + } + /// /// Single composition-root site that picks the /// implementation per diff --git a/backend/src/LearnStack.Application/Pipeline/OutboxFlushBehavior.cs b/backend/src/LearnStack.Application/Pipeline/OutboxFlushBehavior.cs index 68c9c301..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-0014 + 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 new file mode 100644 index 00000000..b3ce9b82 --- /dev/null +++ b/backend/src/LearnStack.Infrastructure/Caching/InMemoryCacheService.cs @@ -0,0 +1,672 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Diagnostics.Metrics; +using LearnStack.SharedKernel.Caching; +using LearnStack.SharedKernel.Time; + +namespace LearnStack.Infrastructure.Caching; + +/// +/// The process-local implementation. +/// +/// +/// +/// 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. +/// +/// +/// 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 : ICacheService +{ + 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 expired entries are reclaimed. + public static readonly TimeSpan SweepInterval = TimeSpan.FromSeconds(1); + + /// + /// 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. + /// + public static readonly TimeSpan FactoryTimeout = TimeSpan.FromSeconds(30); + + /// The hard maximum number of stored entries. + public const int MaxEntries = 10_000; + + /// The low-water mark capacity trimming targets. + public const int TrimTarget = MaxEntries * 9 / 10; + + 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(); + 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; + private long _sequence; + + /// + /// 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); + + // 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)); + _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"); + } + + /// Stored entries, including expired entries awaiting a sweep. + public int Count => _entries.Count; + + /// 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; + Sweep(now); + + if (TryRead(key, now, out T? value)) + { + _hits.Add(1, CacheNameTag(key)); + return Task.FromResult(value); + } + + _misses.Add(1, CacheNameTag(key)); + return Task.FromResult(default); + } + + public async Task GetOrSetAsync( + string key, + Func> factory, + CacheOptions? options = null, + CancellationToken cancellationToken = default) + { + CacheKey.EnsureValid(key); + ArgumentNullException.ThrowIfNull(factory); + cancellationToken.ThrowIfCancellationRequested(); + + var now = _clock.UtcNow; + var ttl = ValidateOptions(options, now); + Sweep(now); + + var registration = (key, typeof(T)); + var recordedMiss = false; + + while (true) + { + 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) + { + now = _clock.UtcNow; + if (TryRead(key, now, out T? cached)) + { + if (!recordedMiss) + { + _hits.Add(1, CacheNameTag(key)); + } + + return cached!; + } + + if (!recordedMiss) + { + _misses.Add(1, CacheNameTag(key)); + recordedMiss = true; + } + + if (_inFlight.TryGetValue(registration, out flight!)) + { + if (flight.Abandoned || flight.Superseded) + { + 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; + } + } + + if (waitForTerminal) + { + await WaitForTerminalThenRetryAsync(flight, cancellationToken) + .ConfigureAwait(false); + continue; + } + + if (owner) + { + _ = RunFactoryAsync(registration, flight, key, factory, ttl); + } + + try + { + return (T)(await flight.Completion.WaitAsync(cancellationToken) + .ConfigureAwait(false))!; + } + finally + { + ReleaseWaiter(registration, flight); + } + } + } + + public Task SetAsync( + string key, + T value, + CacheOptions? options = null, + CancellationToken cancellationToken = default) + { + CacheKey.EnsureValid(key); + cancellationToken.ThrowIfCancellationRequested(); + + var now = _clock.UtcNow; + var ttl = ValidateOptions(options, now); + Sweep(now); + + lock (KeyGate(key)) + { + SupersedeUnderKeyGate(key); + Store(key, value, ttl, now); + } + + return Task.CompletedTask; + } + + 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")); + } + } + + return Task.CompletedTask; + } + + private async Task RunFactoryAsync( + (string Key, Type Type) registration, + Flight flight, + string key, + Func> factory, + TimeSpan ttl) + { + var started = Stopwatch.GetTimestamp(); + var outcome = "success"; + Task? overrunning = null; + + try + { + // 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)) + { + 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) + { + // 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); + + 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) + { + outcome = "faulted"; + RetireTerminalFlight(registration, flight, key); + flight.TrySetException(exception); + } + finally + { + _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(); + } + } + + private void RetireTerminalFlight( + (string Key, Type Type) registration, Flight flight, string key) + { + lock (KeyGate(key)) + { + _inFlight.TryRemove( + new KeyValuePair<(string Key, Type Type), Flight>(registration, flight)); + } + } + + private void ReleaseWaiter((string Key, Type Type) registration, Flight flight) + { + lock (KeyGate(registration.Key)) + { + flight.Waiters--; + if (flight.Waiters == 0 && !flight.Completion.IsCompleted) + { + flight.Abandoned = true; + flight.CancelFactory(); + } + } + } + + private static async Task WaitForTerminalThenRetryAsync( + Flight flight, CancellationToken cancellationToken) + { + try + { + // 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) + { + 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. + } + } + + 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) + { + 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)) + { + pair.Value.Superseded = true; + } + } + } + + 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) + { + Trim(now); + } + } + } + + /// + /// 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 _)) + { + _evictions.Add(1, CacheNameAndReasonTags(pair.Key, "expired")); + } + } + + var excess = _entries.Count - TrimTarget; + if (excess <= 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")); + } + } + } + + /// + /// 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); + + if (ticks >= last && ticks - last < SweepInterval.Ticks) + { + return; + } + + if (Interlocked.CompareExchange(ref _lastSweepTicks, ticks, last) != last) + { + return; + } + + lock (_capacityGate) + { + foreach (var pair in _entries) + { + 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)); + + 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) + { + public bool IsFresh(DateTimeOffset now) => now < ExpiresAt; + } + + private sealed class Flight : IDisposable + { + private readonly CancellationTokenSource _factoryCancellation = new(); + private readonly TaskCompletionSource _completion = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + 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); + } + + public Task Completion => _completion.Task; + 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(); + 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 b693957b..ea9be4ba 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-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. /// /// public sealed class InMemoryIdempotencyStore(IClock clock) : IIdempotencyStore diff --git a/backend/src/LearnStack.Infrastructure/Messaging/InProcessEventBus.cs b/backend/src/LearnStack.Infrastructure/Messaging/InProcessEventBus.cs new file mode 100644 index 00000000..461e9b81 --- /dev/null +++ b/backend/src/LearnStack.Infrastructure/Messaging/InProcessEventBus.cs @@ -0,0 +1,266 @@ +using System.Diagnostics; +using System.Reflection; +using System.Runtime.ExceptionServices; +using LearnStack.SharedKernel.Messaging; +using LearnStack.SharedKernel.Tenancy; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace LearnStack.Infrastructure.Messaging; + +/// The process-local transport. +/// +/// 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); + + if (cancellationToken.IsCancellationRequested) + { + return Task.FromCanceled(cancellationToken); + } + + return partitions.RunSequentiallyFor( + envelope.PartitionKey, + () => DispatchAsync(envelope, cancellationToken)); + } + + private async Task DispatchAsync( + IntegrationEventEnvelope envelope, + CancellationToken cancellationToken) + { + var previous = tenantAccessor.Current; + + // 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); + + try + { + var subscriptions = handlers.For(envelope.Event.GetType()); + if (subscriptions.Count == 0) + { + ReachedNoHandler(logger, envelope.Event.GetType().Name, envelope.Event.EventId); + return; + } + + _ = ActivityContext.TryParse( + envelope.CorrelationId, + traceState: null, + out var parentContext); + + List? failures = null; + + for (var index = 0; index < subscriptions.Count; index++) + { + 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); + } + } + + 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); + } + } + finally + { + tenantAccessor.Current = previous; + } + } + + private async Task DeliverAsync( + IntegrationEventSubscription subscription, + IntegrationEventEnvelope envelope, + ActivityContext parentContext, + CancellationToken cancellationToken) + { + var previous = tenantAccessor.Current; + tenantAccessor.Current = EventTenantContext.FromEnvelope(envelope, subscription.ModuleName); + + try + { + 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), + ]); + + // 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 + { + await DeliverToHandlerAsync(subscription, envelope, cancellationToken) + .ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // 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; + } + catch (Exception exception) + { + activity?.AddException(exception); + activity?.SetStatus(ActivityStatusCode.Error, exception.GetType().Name); + throw; + } + } + finally + { + tenantAccessor.Current = previous; + } + } + + 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, + Message = "Integration event {EventType} ({IntegrationEventId}) failed in handler " + + "{HandlerType} for tenant {TenantId} on partition {PartitionKey}")] + private static partial void HandlerFailed( + ILogger logger, + string eventType, + Guid integrationEventId, + string handlerType, + 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/IntegrationEventHandlerRegistry.cs b/backend/src/LearnStack.Infrastructure/Messaging/IntegrationEventHandlerRegistry.cs new file mode 100644 index 00000000..c0a3b533 --- /dev/null +++ b/backend/src/LearnStack.Infrastructure/Messaging/IntegrationEventHandlerRegistry.cs @@ -0,0 +1,131 @@ +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 const string HandleMethodName = + nameof(IIntegrationEventHandler.HandleAsync); + + 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]; + 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), + handle); + } + + 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..00101e27 --- /dev/null +++ b/backend/src/LearnStack.Infrastructure/Messaging/IntegrationEventSubscription.cs @@ -0,0 +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, + MethodInfo Handle); diff --git a/backend/src/LearnStack.Infrastructure/Messaging/PartitionSerializer.cs b/backend/src/LearnStack.Infrastructure/Messaging/PartitionSerializer.cs new file mode 100644 index 00000000..16441e87 --- /dev/null +++ b/backend/src/LearnStack.Infrastructure/Messaging/PartitionSerializer.cs @@ -0,0 +1,184 @@ +using System.Collections.Concurrent; +using System.Collections.Immutable; +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(); + + /// + /// 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. + /// + /// + /// 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?> _executingKeys = 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. + 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 " + + "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 + // 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; + + queued = previous.ContinueWith( + _ => RunMarked(_executingKeys, partitionKey, 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. + observer = queued.ContinueWith( + static completed => { _ = completed.Exception; }, + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + + _tails[partitionKey] = observer; + } + + // 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); + + return queued; + } + + private static async Task RunMarked( + AsyncLocal?> executingKeys, + string partitionKey, + Func work) + { + var previous = executingKeys.Value; + executingKeys.Value = (previous ?? ImmutableHashSet.Empty).Add(partitionKey); + + try + { + await work().ConfigureAwait(false); + } + finally + { + executingKeys.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 + /// can be asserted directly rather than inferred. + /// + public int TrackedPartitions => _tails.Count; + + private void Retire(string partitionKey, Task observer) + { + lock (_gate) + { + // 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) && ReferenceEquals(tail, observer)) + { + _tails.TryRemove(new KeyValuePair(partitionKey, tail)); + } + } + } +} 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 new file mode 100644 index 00000000..a7df1614 --- /dev/null +++ b/backend/src/LearnStack.SharedKernel/Caching/CacheKey.cs @@ -0,0 +1,266 @@ +namespace LearnStack.SharedKernel.Caching; + +/// +/// Builds and validates the one cache-key shape +/// Standards 20 +/// § Cache admits: {tenant_id}:{module}:{logical-name}, or +/// {tenant_id}:{organization_id}:{module}:{logical-name} for a value scoped +/// to one organization. +/// +/// +/// +/// 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. +/// +/// +/// 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 +{ + /// 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-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, params string[] logicalName) => + Compose(Canonical(tenantId, nameof(tenantId)), 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, params string[] logicalName) => + Compose( + Canonical(tenantId, nameof(tenantId)), + Canonical(organizationId, nameof(organizationId)), + module, + logicalName); + + /// + /// Composes the one platform-wide key family: a normalized host to tenant mapping. + /// + /// + /// 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 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. + /// + /// + /// 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); + var wellFormed = segments.Length >= 3 + && !segments.Any(string.IsNullOrWhiteSpace) + && IsTenantSegment(segments[0]) + && segments.All(IsCanonicalIfIdentifier) + && IsAllowedPlatformFamily(segments); + + if (!wellFormed) + { + 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. 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. + /// + /// + /// 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 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) + && 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 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); + + 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) + { + foreach (var segment in segments) + { + ArgumentException.ThrowIfNullOrWhiteSpace(segment); + } + + // 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)) + { + throw new ArgumentException( + $"A cache-key segment may not contain '{Separator}': '{segment}'."); + } + } + + return string.Join(Separator, segments); + } +} diff --git a/backend/src/LearnStack.SharedKernel/Caching/CacheOptions.cs b/backend/src/LearnStack.SharedKernel/Caching/CacheOptions.cs new file mode 100644 index 00000000..37fb3b7c --- /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-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 +/// 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..04cd019a --- /dev/null +++ b/backend/src/LearnStack.SharedKernel/Caching/ICacheService.cs @@ -0,0 +1,61 @@ +namespace LearnStack.SharedKernel.Caching; + +/// +/// The one cache abstraction, per +/// ADR-0038. +/// Modules never inject a cache client — no +/// IConnectionMultiplexer, no IDistributedCache, no +/// IMemoryCache. +/// +/// +/// +/// 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 +/// 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/src/LearnStack.SharedKernel/Identifiers/UserId.cs b/backend/src/LearnStack.SharedKernel/Identifiers/UserId.cs index 7b99140c..18cb82cd 100644 --- a/backend/src/LearnStack.SharedKernel/Identifiers/UserId.cs +++ b/backend/src/LearnStack.SharedKernel/Identifiers/UserId.cs @@ -19,4 +19,30 @@ 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. + /// 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. + /// + /// + 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 new file mode 100644 index 00000000..16126504 --- /dev/null +++ b/backend/src/LearnStack.SharedKernel/Messaging/IEventBus.cs @@ -0,0 +1,31 @@ +namespace LearnStack.SharedKernel.Messaging; + +/// +/// Publishes an integration event to whichever transport is registered, per +/// ADR-0038. +/// 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 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. +/// +/// +public interface IEventBus +{ + /// Publishes one envelope, ordered against others sharing its partition key. + Task PublishAsync( + IntegrationEventEnvelope envelope, + 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..4f703466 --- /dev/null +++ b/backend/src/LearnStack.SharedKernel/Messaging/IIntegrationEvent.cs @@ -0,0 +1,62 @@ +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 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. + /// + /// + /// 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..98e680a8 --- /dev/null +++ b/backend/src/LearnStack.SharedKernel/Messaging/IIntegrationEventHandler.cs @@ -0,0 +1,57 @@ +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. +/// +/// +/// +/// +/// 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", + 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/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/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..3307d8de --- /dev/null +++ b/backend/src/LearnStack.SharedKernel/Messaging/IntegrationEventBase.cs @@ -0,0 +1,88 @@ +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; + +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 Topic { get; } + + /// + 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-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 + /// 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() => + JsonSerializer.Serialize(this, GetType(), 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; } = CreatePayloadJsonOptions(); + + private static JsonSerializerOptions CreatePayloadJsonOptions() + { + 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 new file mode 100644 index 00000000..d1f3fb74 --- /dev/null +++ b/backend/src/LearnStack.SharedKernel/Messaging/IntegrationEventEnvelope.cs @@ -0,0 +1,161 @@ +using System.Diagnostics; +using LearnStack.SharedKernel.Identifiers; + +namespace LearnStack.SharedKernel.Messaging; + +/// +/// 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. 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-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. +/// +/// +/// The fact being published. +/// +/// 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. +/// +/// 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 +{ + 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. + /// + /// + /// 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; + + /// + /// 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/src/LearnStack.SharedKernel/Tenancy/EventTenantContext.cs b/backend/src/LearnStack.SharedKernel/Tenancy/EventTenantContext.cs new file mode 100644 index 00000000..f692ff86 --- /dev/null +++ b/backend/src/LearnStack.SharedKernel/Tenancy/EventTenantContext.cs @@ -0,0 +1,106 @@ +namespace LearnStack.SharedKernel.Tenancy; + +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Messaging; + +/// +/// 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 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, + Guid? organizationId, + UserId? causalActorUserId, + string? correlationId, + string? moduleName) + { + TenantId = tenantId; + OrganizationId = organizationId; + UserId = Identifiers.UserId.SystemActor; + CausalActorUserId = causalActorUserId; + CorrelationId = correlationId; + ModuleName = moduleName; + } + + /// + public bool IsResolved => true; + + /// + public Guid TenantId { get; } + + /// + /// The organization the fact belongs to, when the envelope names one. + /// + /// + /// 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 { get; } + + /// 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. 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 { get; } + + /// Builds the context a handler for runs under. + public static EventTenantContext FromEnvelope( + IntegrationEventEnvelope envelope, string? moduleName = null) + { + 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( + envelope.Event.TenantId, + envelope.OrganizationId, + 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 ad4e6650..189cae66 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", ]; @@ -312,6 +331,160 @@ 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("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) + { + 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) + { + 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() + { + // 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. + 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"); + 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(UsesForbiddenEventBusAccess) + .Select(t => t.FullName) + .ToList(); + + offenders.Should().BeEmpty( + $"{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)."); + } + } + + private static bool UsesForbiddenEventBusAccess(Type type) + { + var bus = typeof(LearnStack.SharedKernel.Messaging.IEventBus); + 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. + private sealed class DeliberateEventBusInjector(LearnStack.SharedKernel.Messaging.IEventBus bus) + { + 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.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.Integration/CrossCuttingFoundationHttpTests.cs b/backend/tests/LearnStack.Tests.Integration/CrossCuttingFoundationHttpTests.cs index c8e646ce..bae5c890 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,63 @@ 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(); + } + + [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 /// integration test's controllers + MediatR handler + validator into the /// real LearnStack.Api host. Reuses the host's diff --git a/backend/tests/LearnStack.Tests.Integration/DeploymentModeCompositionTests.cs b/backend/tests/LearnStack.Tests.Integration/DeploymentModeCompositionTests.cs new file mode 100644 index 00000000..ba83679c --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/DeploymentModeCompositionTests.cs @@ -0,0 +1,142 @@ +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 LearnStack.SharedKernel.Tenancy; +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"); + } + + [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. + /// + /// + /// 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); + }); + + 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 new file mode 100644 index 00000000..906dba64 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Unit/Infrastructure/Caching/InMemoryCacheServiceTests.cs @@ -0,0 +1,1228 @@ +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-0038. +/// +/// +/// 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 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"); + + private static string Key(Guid tenant = default) => + CacheKey.ForTenant(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(); + } + + [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] + 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"; + } + + // 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(); + + 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"); + 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() + { + // 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_Even_When_The_Clock_Never_Moves() + { + // 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.ForTenant(Tenant, "tenancy", $"k{i:D6}"), i, ttl); + } + + // 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"); + + var newest = InMemoryCacheService.MaxEntries + 500; + (await cache.GetAsync(CacheKey.ForTenant(Tenant, "tenancy", $"k{newest:D6}"))) + .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); + } + + 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); + } + + [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] + 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"); + } + + [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 An_Abandoned_Factory_Must_Terminate_Before_Its_Replacement_Starts() + { + var (cache, _) = New(); + 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); + RecordMaximum(ref maximumRunning, current); + entered.TrySetResult(); + 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(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); + RecordMaximum(ref maximumRunning, current); + Interlocked.Decrement(ref running); + return 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"); + calls.Should().Be(2); + maximumRunning.Should().Be(1, "same-key factories never overlap"); + } + + [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 = cache.GetOrSetAsync( + Key(), _ => Task.FromResult("after-the-remove")); + + 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] + 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() + { + // 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] + 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.ForTenant(Tenant, "tenancy", $"k{i}"), i); + } + + cache.Count.Should().Be(200); + + clock.Advance(InMemoryCacheService.DefaultTtl + InMemoryCacheService.SweepInterval); + 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"); + } + + [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(); + } + + [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"); + } + + [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] + 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); + + [Fact] + public async Task A_Write_During_The_Atomic_Miss_Check_Is_Observed() + { + InMemoryCacheService? cache = null; + var clock = new WritingClock(Origin, onNthRead: 2, write: () => + cache!.SetAsync(Key(), "landed-in-the-window").GetAwaiter().GetResult()); + cache = new InMemoryCacheService(clock, MeterFactory); + var calls = 0; + + var produced = await cache.GetOrSetAsync(Key(), _ => + { + calls++; + return Task.FromResult("from-factory"); + }); + + 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().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. + /// + 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); + 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 new file mode 100644 index 00000000..d79dd21b --- /dev/null +++ b/backend/tests/LearnStack.Tests.Unit/Infrastructure/Messaging/InProcessEventBusTests.cs @@ -0,0 +1,1057 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +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; + +/// +/// 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 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"; + + [Fact] + public async Task A_Handler_Receives_The_Event() + { + var recorder = new Recorder(); + var (bus, _) = Build(recorder, services => + services.AddScoped, ThingHandler>()); + + await bus.PublishAsync(Envelope(NewThing("a"))); + + 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(Envelope(NewThing("a"))); + + 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(new IntegrationEventEnvelope(asBase, Trace)); + + 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(Envelope(NewThing("a"))); + + 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(Envelope(NewThing("a"))); + + 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(Envelope(NewThing("a"))); + + 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.FromEnvelope( + Envelope(NewThing("x") with { TenantId = Guid.NewGuid() })); + accessor.Current = publisher; + + await bus.PublishAsync(Envelope(NewThing("a"))); + + 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(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_Consumer_Uses_The_System_Actor_And_Preserves_The_Causal_Actor() + { + 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"), Trace, OrganizationId: organization, ActorUserId: 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); + } + + [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, ForeignCancelHandler>()); + + var publish = bus.PublishAsync(Envelope(NewThing("a"))); + + var act = () => publish; + await act.Should().ThrowAsync(); + 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(); + + 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 + // 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 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() + { + // 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() + { + // 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 => + { + 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().ContainSingle().Which.Should().Be("a", + "the healthy subscription is constructed and invoked independently"); + } + + [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] + 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(); + } + + [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 = Listen(stopped); + + 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"); + } + + [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] + 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(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"); + 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. + // 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 firstArrived = new SemaphoreSlim(0); + using var secondArrived = new SemaphoreSlim(0); + var (bus, _) = Build(recorder, services => + { + 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"))); + + await Task.WhenAll(first, second).WaitAsync(Timeout); + recorder.Rendezvoused.Should().Be(2, "neither key waited for the other"); + } + + [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(Envelope(NewThing("boom", "same-key"))); + await ((Func)(() => failing)).Should().ThrowAsync(); + + await bus.PublishAsync(Envelope(NewThing("after", "same-key"))).WaitAsync(Timeout); + + recorder.Handled.Should().Contain("after"); + } + + // ---- helpers ------------------------------------------------------------- + + /// + /// 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, 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( + 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); + + // The production binding, verbatim (CrossCuttingFoundationExtensions): + // the transient context forwards to the accessor on every resolution. Registering anything + // else here would test a container this application never builds. + 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 ( + new InProcessEventBus( + provider.GetRequiredService(), + accessor, + new PartitionSerializer(), + handlers, + NullLogger.Instance), + 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(); + + 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; + + 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; } + + 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; } + + /// 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; + } + + 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 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.CausalActorUserId is { } causalActor) + { + recorder.CausalActors.Enqueue(causalActor); + } + + 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 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.HandlerToken = cancellationToken; + return Task.CompletedTask; + } + } + + 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; + + 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) => + 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; } + + 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 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) => + 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 record RendezvousGates(SemaphoreSlim First, SemaphoreSlim Second); + + public sealed class RendezvousHandler(Recorder recorder, RendezvousGates gates) + : IIntegrationEventHandler + { + public async Task HandleAsync(Thing @event, CancellationToken cancellationToken = default) + { + // 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" ? gates.First : gates.Second; + var theirs = ReferenceEquals(mine, gates.First) ? gates.Second : gates.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 new file mode 100644 index 00000000..376899c2 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Unit/Infrastructure/Messaging/PartitionSerializerTests.cs @@ -0,0 +1,457 @@ +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 observed = new ConcurrentQueue(); + var inFlight = 0; + var overlapped = false; + + // 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( + () => + { + 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(1); + Interlocked.Decrement(ref inFlight); + }))); + }, + CancellationToken.None, + TaskCreationOptions.LongRunning, + TaskScheduler.Default).Unwrap()).ToArray(); + + await Task.WhenAll(workers).WaitAsync(Timeout); + + overlapped.Should().BeFalse("no two units for one key ever overlap"); + observed.Should().HaveCount(Threads * Each); + } + + [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. + // 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 firstArrived = new SemaphoreSlim(0); + using var secondArrived = new SemaphoreSlim(0); + + var first = serializer.RunSequentiallyFor("k1", async () => + { + firstArrived.Release(); + (await secondArrived.WaitAsync(Timeout, CancellationToken.None)) + .Should().BeTrue("k2 must not be queued behind k1"); + }); + + var second = serializer.RunSequentiallyFor("k2", async () => + { + secondArrived.Release(); + (await firstArrived.WaitAsync(Timeout, CancellationToken.None)) + .Should().BeTrue("k1 must not be queued behind k2"); + }); + + await Task.WhenAll(first, second).WaitAsync(Timeout); + serializer.TrackedPartitions.Should().Be(0); + } + + [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 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() + { + 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() + { + 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. + // + // 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)) + { + 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(); + + return !mine.IsEmpty; + } + 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); + } +} 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..08aa152a --- /dev/null +++ b/backend/tests/LearnStack.Tests.Unit/SharedKernel/Caching/CacheKeyTests.cs @@ -0,0 +1,359 @@ +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"); + 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() + { + CacheKey.ForTenant(Tenant, "tenancy", "settings") + .Should().Be($"{Tenant}:tenancy:settings"); + } + + [Fact] + public void The_Host_Map_Key_Uses_The_Platform_Sentinel() + { + CacheKey.ForHostMapping("school.example.com") + .Should().Be("platform:hub:host-map:school.example.com"); + } + + [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() + { + CacheKey.ForTenant(Tenant, "tenancy", "settings") + .Should().NotBe(CacheKey.ForTenant(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); + } + + [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: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() + { + // 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.ForHostMapping("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() + { + // 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.ForTenant(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.ForTenant(Tenant, "tenancy:nested", "settings"); + + act.Should().Throw(); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void An_Empty_Component_Is_Refused(string? component) + { + var act = () => CacheKey.ForTenant(Tenant, "tenancy", 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/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..79cdbe2a --- /dev/null +++ b/backend/tests/LearnStack.Tests.Unit/SharedKernel/Messaging/IntegrationEventContractTests.cs @@ -0,0 +1,296 @@ +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"); + private const string Trace = "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"; + + [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 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); + + 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() + { + // 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 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 + // 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 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(); + IIntegrationEvent asBase = @event; + + var naive = JsonSerializer.Serialize(asBase); + naive.Should().NotContain(nameof(Sample.LearnerName), + "the declared type is the interface, so only its five 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(); + } + + [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] + 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(), Trace, 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(UserId.SystemActor); + context.CausalActorUserId.Should().Be(actor); + context.CorrelationId.Should().Be(Trace); + 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", + }; + + 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; } + + 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. + 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..a9cf6f45 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 | -| Secrets | **HashiCorp Vault via Dapr Secret Store** (or env-var fallback in Dev) | -| Distributed runtime | **Dapr 1.14+** sidecar pattern (pub/sub, state, secrets) | +| 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 | **`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) | | 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..bfd6a9f9 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). @@ -66,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/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..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) @@ -262,7 +267,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..7e67abcb 100644 --- a/docs/architecture/10-cross-module-contracts.md +++ b/docs/architecture/10-cross-module-contracts.md @@ -2,11 +2,13 @@ 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: -> `learnstack.{module}.{aggregate}`. Consumer-side idempotency via per-module inbox guard +> **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}`, 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. @@ -104,4 +106,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 e9311eb1..d02cd227 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". @@ -69,7 +70,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 @@ -108,7 +109,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 +191,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 +206,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 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 +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 @@ -313,7 +326,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) @@ -370,37 +390,81 @@ 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 { - Task PublishAsync(TEvent @event, string partitionKey, CancellationToken ct = default) - where TEvent : IIntegrationEvent; + Task PublishAsync(IntegrationEventEnvelope envelope, CancellationToken ct = default); } ``` +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: ```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); - - private static string ConventionTopicName(IIntegrationEvent @event) - => $"learnstack.{ExtractModule(@event.GetType())}.{ExtractAggregate(@event.GetType())}"; + public Task PublishAsync( + IntegrationEventEnvelope envelope, CancellationToken ct = default) + { + 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(); + } + + // 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); + } } ``` @@ -416,6 +480,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` @@ -432,23 +497,90 @@ 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, + IntegrationEventHandlerRegistry handlers, + ILogger logger) : IEventBus { - public Task PublishAsync(TEvent @event, string partitionKey, CancellationToken ct = default) - where TEvent : IIntegrationEvent - => partitions.RunSequentiallyFor(partitionKey, async () => + public Task PublishAsync( + IntegrationEventEnvelope envelope, CancellationToken ct = default) + { + 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)); + } + + private async Task DispatchAsync(IntegrationEventEnvelope envelope, CancellationToken ct) + { + // 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 { - 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 - }); + var subscriptions = handlers.For(envelope.Event.GetType()); + List? failures = null; + + foreach (var subscription in subscriptions) + { + 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. + } + finally + { + tenantAccessor.Current = previous; + } + } // 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 @@ -465,8 +597,24 @@ 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. +- 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 + 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 @@ -480,16 +628,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` | +| 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` 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. +`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: @@ -567,17 +725,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 @@ -613,7 +771,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 7da7e4fb..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` (intra-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 922b38a7..2226703c 100644 --- a/docs/architecture/24-learnstack-hub.md +++ b/docs/architecture/24-learnstack-hub.md @@ -326,15 +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 -`hub:entitlement:{tenant_id}`). 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. +LearnStack runtime caches the `Entitlement` via `ICacheService` (key +`{tenant_id}:hub:entitlement` — tenant segment first, per +[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 @@ -381,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" ``` @@ -398,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 8e21f754..31a45a39 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,30 @@ 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. +**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 +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 +84,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 +95,32 @@ 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. +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 -```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,49 +132,39 @@ 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 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(IntegrationEventEnvelope envelope, CancellationToken ct = default); } -// LearnStack.SharedKernel.Abstractions.Caching +// LearnStack.SharedKernel.Caching public interface ICacheService { Task GetAsync(string key, CancellationToken ct = default); @@ -221,14 +172,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,148 +185,155 @@ public interface ISecretProvider } ``` -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. +`IEventBus.PublishAsync` is **not generic** and `ICacheService` has **no +`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. + +**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. + +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. ## 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 + // 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 Task SetAsync(string key, T value, CacheOptions? options = null, CancellationToken ct = default) + public async Task GetAsync(string key, 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); - var metadata = new Dictionary - { - ["ttlInSeconds"] = ((int)(options?.L2Ttl ?? TimeSpan.FromMinutes(15)).TotalSeconds).ToString() - }; - return _dapr.SaveStateAsync(StateStoreName, prefixed, value, metadata: metadata, cancellationToken: ct); + 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); } - // 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) + public Task SetAsync(string key, T value, CacheOptions? options = null, CancellationToken ct = default) { - var prefixed = PrefixKey(prefix); + CacheKey.EnsureValid(key); + _memoryCache.Set(key, value, options?.L1Ttl ?? TimeSpan.FromMinutes(2)); - // Local removal - foreach (var tracked in _trackedKeys.Keys.Where(k => k.StartsWith(prefixed, StringComparison.Ordinal)).ToList()) + var metadata = new Dictionary { - _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); + ["ttlInSeconds"] = ((int)(options?.L2Ttl ?? TimeSpan.FromMinutes(15)).TotalSeconds).ToString() + }; + return _dapr.SaveStateAsync(StateStoreName, key, value, metadata: metadata, cancellationToken: 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). +**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-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 +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 ### 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) @@ -400,8 +355,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 @@ -460,7 +416,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 @@ -499,7 +455,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 4dd0f48a..14d4e64c 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.ForTenant(tenantId, "customization", logicalName)`, and the +shape is not cosmetic: the tenant segment comes **first**, per +[Standards 20 § `ICacheService`](../standards/20-infrastructure-stack.md), and +`CacheKey.EnsureValid` throws on anything else. An earlier version of this table led +each key with `cust:` — module first — which would have thrown at the first call. + +The generation is folded into the *logical-name* segment rather than added as a fourth +one, because `CacheKey` forbids a `:` inside any single component: a separator that can +appear inside a component makes two different key tuples collide. The same rule applies +to `{key}`, which is tenant-supplied — the caller validates or encodes it before +composing, and a `:` in it is rejected rather than silently widening the key space. + Two rules make this safe: - **A generation counter, not prefix eviction.** Each tenant carries a @@ -450,8 +462,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-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 `(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/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 7e9dcd13..75d567bf 100644 --- a/docs/decisions/0014-adopt-dapr.md +++ b/docs/decisions/0014-adopt-dapr.md @@ -2,7 +2,10 @@ ## Status -Accepted +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 diff --git a/docs/decisions/0022-custom-domain-tls.md b/docs/decisions/0022-custom-domain-tls.md index e4388da2..c3efb049 100644 --- a/docs/decisions/0022-custom-domain-tls.md +++ b/docs/decisions/0022-custom-domain-tls.md @@ -501,6 +501,28 @@ 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:{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 +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 b6249339..9f6540c8 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. | @@ -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 @@ -277,9 +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*. | -| **`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. | +| **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: 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 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:{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/roadmap/phase-02a-kernel-tenancy.md b/docs/roadmap/phase-02a-kernel-tenancy.md index ad3be8c5..e9644b9e 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` @@ -322,11 +323,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 @@ -347,6 +355,15 @@ 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` — 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. +`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 @@ -653,10 +670,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). @@ -1823,3 +1842,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. diff --git a/docs/roadmap/phase-02b-events-auth.md b/docs/roadmap/phase-02b-events-auth.md index 76fcafff..2f4f081d 100644 --- a/docs/roadmap/phase-02b-events-auth.md +++ b/docs/roadmap/phase-02b-events-auth.md @@ -36,9 +36,11 @@ 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 -[ADR-0014](../decisions/0014-adopt-dapr.md) remain the decision about **which** transport +network hops and a serialization boundary, for a service the daily loop no longer +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-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. @@ -82,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)). @@ -134,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. @@ -175,13 +180,13 @@ 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 -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 @@ -316,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/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/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..b92ce670 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), @@ -159,14 +159,18 @@ 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`. +- **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 @@ -231,9 +235,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 e63e171b..63e988b9 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,18 +134,26 @@ 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. - 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); @@ -156,25 +164,42 @@ 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}`. 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. 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. - 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,14 +210,66 @@ 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` (key prefix) | -| 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:{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 | +| `{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:{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)` | +| `{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 +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 — +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 +> 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: +- **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-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 + 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 — @@ -451,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 8b45d54c..e89f94ee 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-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). - **Type:** xUnit + reflection over module assemblies. **Kind:** structural. @@ -1160,18 +1166,49 @@ 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, 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. +- **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 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); [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. @@ -1677,8 +1714,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` diff --git a/infra/compose/README.md b/infra/compose/README.md index e285a6e5..eabd2ada 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,31 +114,50 @@ 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 -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 c59b5f56..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. # @@ -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"] @@ -308,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 @@ -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"] @@ -414,8 +418,9 @@ 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 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 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