Status: Active Derives from: ADR-0014 Adopt Dapr, ADR-0015 API Gateway: APISIX, ADR-0019 LearnStack Hub, ADR-0020 Triple Deployment + Hybrid License, ADR-0021 Feature-Based Entitlement, ADR-0029 Object Storage — SeaweedFS, ADR-0030 Redis-compatible Store — Valkey, ADR-0031 PostgreSQL — Start on 18.x, ADR-0034 Hub Contract Surface Invariant, ADR-0035 Demand-Gated Infrastructure.
Two audit decisions are referenced here but not owned here, and the split matters
because the two are easy to conflate:
ADR-0033 owns the audit durability
contract — what must commit with what — and
ADR-0028 owns the audit_log
partition lifecycle. This standard only records that partitioning is demand-gated
and that modules never write audit_log directly.
This standard defines how application code uses the foundation infrastructure introduced in the 2026-05-18 redesign: Dapr building blocks, the APISIX gateway, the Hub HTTPS contract surface, the entitlement projection, and the deployment-mode-aware composition root. The broader operational rules (containers, CI/CD, DB ops, observability) live in 12-infrastructure.md; the two standards are complementary, not overlapping.
Read this section before the rest of the document. Most of what follows describes Dapr, Kafka, APISIX and Vault in the present tense. Those are accepted decisions about what LearnStack uses; per ADR-0035 they are not all wired today, and this section says which are.
The discriminator is the one-way-door test (00-principles.md § 16):
If I add this six months from now, will I have to touch code that is already written?
A yes ships now — tenant and organization isolation, the outbox_messages table and
its ownership, strongly-typed identifiers, the localization schema. A no ships as a
port with a working default implementation now, and its vendor adapter lands in a
named phase when a written trigger fires.
| Building block | Port | Registered today | Adapter lands in | Trigger |
|---|---|---|---|---|
| Dapr pub/sub | IEventBus |
InProcessEventBus |
Phase 11 | A second process must consume an integration event |
| Kafka | behind IEventBus |
InProcessEventBus |
Phase 11 | Cross-process volume, replay, or ordering is required |
| Dapr state / Valkey | ICacheService |
InMemoryCacheService |
Phase 11 | More than one application instance runs concurrently |
| Vault | ISecretProvider |
ConfigurationSecretProvider (startup-only, see below) |
Phase 11 | A production secret must rotate without a redeploy, or more than one operator needs access to production secrets |
| APISIX | composition root | ASP.NET middleware | Phase 11 | A non-dev deployment needs edge rate limiting, host routing, or JWT pre-validation |
| Hub entitlement | IEntitlementProvider |
NullEntitlementProvider |
Phase 02c | A tenant must be billed or plan-gated |
| Signed licence key | IEntitlementProvider |
NullEntitlementProvider |
Phase 11 | A Self-Hosted contract is signed |
| Custom-domain TLS automation | IHostToTenantResolver + ITlsCertificateProvider |
platform_host_to_tenant rows managed by configuration |
Phase 11 | A tenant needs its own domain in production |
audit_log partitioning + retention |
schema-internal | Single correct table | Phase 11 | Measured audit_log growth justifies partition maintenance |
| Meilisearch | ITenantSearch |
PostgreSQL full-text search | Phase 09 | Search quality or scale exceeds PostgreSQL FTS |
| LiveKit | ILiveClassProvider |
none — scheduled, not gated; see the exception below | Phase 08c | Live classes become a product requirement |
| Managed video transcoding | IVideoTranscoder |
ffmpeg-backed worker (Phase 04) | Phase 11 | In-house transcode backlog or per-minute cost exceeds the managed alternative |
Rules that follow from this:
- Application code does not change when an adapter lands. Every rule in the rest of this document — cache key shape, topic naming, secret namespace, outbox ownership — is written against the port and holds for the default implementation too. If a rule only makes sense for the vendor adapter, it is a rule about the adapter and belongs in that adapter's section, not in module guidance.
InProcessEventBusis a first-class transport, not a stub. SameIIntegrationEventHandler<T>, sameIInboxGuard, same tenant-context restoration as the durable path. A development path that skips those never exercises the isolation code, and every consumer would end up needing two implementations.- A demand-gated block is not "deferred". It qualifies only with all four of: a port, a working default implementation, an owning phase, and a written trigger. If a trigger fires earlier than its phase, the item moves to the phase where it fired and ADR-0035's table is amended.
- Support claims follow the wiring.
DeploymentModekeeps all five values and the composition root keeps branching on it, but onlyDevelopmentandSaaSare wired end to end before Phase 11.Dedicated,SelfHostedOnlineandSelfHostedAirGappedare prepared seams, not supported deployments, until their integration suites exist.
Every host (LearnStack.Api, worker, background-service host) reads
DeploymentMode at startup. Per
ADR-0020, SelfHosted is
split into two values so the composition root can pick between phone-home and
signed-license-key entitlement providers without runtime branching:
public enum DeploymentMode
{
Development,
SaaS,
Dedicated,
SelfHostedOnline,
SelfHostedAirGapped
}The composition root branches on DeploymentMode to pick provider implementations.
Rules:
- The branching happens exactly once in the composition root. Modules never read
DeploymentModedirectly. If a module needs different behavior in different modes, the answer is two adapter implementations of the same interface registered in the composition root — not a runtimeif. - A failure to pick an implementation (e.g.
Productionmode with noIEntitlementProviderregistered) fails fast at startup, not at first request. - An architecture test
(
Modules_Do_Not_Reference_DeploymentMode) ensures no module assembly references the enum.
| Concern | Development |
SaaS |
Dedicated |
SelfHostedOnline |
SelfHostedAirGapped |
|---|---|---|---|---|---|
| Event bus | InProcessEventBus (MediatR) |
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 |
| Host → tenant | Config / single tenant | Hub-mirrored projection | Hub-mirrored projection | Hub-mirrored projection | Config / .lic claim |
| Phone-home | n/a | enabled | enabled | enabled (daily, 30-day grace) | disabled |
| Error tracking (ADR-0032) | NoOpErrorTracker |
SentryErrorTracker |
SentryErrorTracker |
SentryErrorTracker (optional; NoOp if no DSN) |
LocalFileErrorTracker |
| OTLP exporter target (ADR-0032) | local OTel Collector (dev compose) | central Collector | central Collector | customer-managed Collector | local file /var/learnstack/otel/ |
Reading the table. It is the target wiring, not the current wiring. Every Dapr*
cell resolves to the Development column's default implementation until that block's
trigger fires (§ Demand-Gated Building Blocks). The branch structure is real and
exercised from Phase 02a Packet 5; the
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.
- The only sanctioned way to publish an integration event is
IEventBus.PublishAsyncfrom inside theOutboxProcessor. Modules never callIEventBusdirectly — they write to the outbox. - Topic names follow
learnstack.{module}.{aggregate}(learnstack.identity.user,learnstack.enrollment.enrollment,learnstack.classroom.session). The convention applies toInProcessEventBustoo — 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:Integration_Event_TopicNames_FollowConventionis transport-independent, asserts the convention over the declared event types, and lands withInProcessEventBusin Phase 02a Packet 5;Dapr_PubSub_TopicNames_FollowConventionadditionally checks the Dapr component bindings and lands with that adapter in Phase 11. Both are registered in 21-architecture-tests-catalogue.md. - Hub-side topics use the same
learnstack.hub.*prefix (learnstack.hub.entitlement,learnstack.hub.custom-domain.activated). - Consumers implement
IIntegrationEventHandler<TEvent>and must invokeIInboxGuard.IsAlreadyProcessedAsyncbefore any business logic. The architecture testIntegration_Event_Handlers_Use_InboxGuardenforces this. - 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.
- All Valkey access goes through
ICacheService. DirectIConnectionMultiplexer/IDistributedCacheinjections are forbidden by the architecture testModules_Do_Not_Inject_Valkey_Directly. - Cache keys are
{tenant_id}:{module}:{logical-name}. Thetenant_idprefix is mandatory even when a value is platform-wide — use the sentinel"platform"tenant id rather than omitting the prefix. - 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.
The ICacheService has two TTL knobs (CacheOptions.L1Ttl, CacheOptions.L2Ttl).
The most-referenced read paths follow this layered policy; mismatches across docs
(e.g. "60s cache" vs "15-min TTL") refer to different layers of the same cache, not
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 |
Rules:
- 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 — eager invalidation via Dapr is the typical path; the TTL is the safety net.
- A "60s cache" reference in any other document refers to L1; a "15-min TTL" reference refers to L2. These are not in conflict.
- Secrets are read at startup through
ISecretProviderand bound toIOptions<T>. Runtime re-fetches happen viaIOptionsMonitor<T>with a Vault-driven refresh — never an ad-hocISecretProvider.GetAsynccall inside a hot path. - Until the Vault adapter lands, that refresh path does not exist.
DevelopmentandSaaSboth run onConfigurationSecretProvider, which resolves throughIConfigurationat composition time — including the Sentry DSN, whichErrorTrackingRegistrationreads once during registration. There is no configuration reload and no options rebinding behind it, so every secret is fixed for the lifetime of the process and changing one is a redeploy. That is not an oversight; it is precisely what makes the Vault trigger above legible — "a production secret must rotate without a redeploy" is a condition this arrangement cannot satisfy by construction, so the day it is required is the day the adapter is required. Do not describeIOptionsMonitor<T>refresh as available before then. - The secret namespace is
learnstack/{deployment}/{module}/{key}(e.g.learnstack/saas/notifications/email-provider-api-key). The deployment segment matchesDeploymentMode(lower-case). - No secret may appear in code, in
appsettings.*.jsonchecked into git, or in container env vars. The pre-commit hook scans for high-entropy strings; CI fails on hits.
- APISIX runs in standalone mode (YAML hot-reload, no etcd) per ADR-0015.
- The gateway is the only ingress for tenant-facing traffic. Direct ingress to
LearnStack.Apipods is blocked at the network level. - Plugin chain order, per route:
cors(preflight separated from authenticated cross-origin)jwt-auth(Keycloaklearnstackrealm token verification — defense-in-depth; the API re-verifies internally)limit-req/limit-count(rate limit, per-tenant)proxy-rewrite/request-id(correlation id injection)prometheus(metrics export)
- Hub-facing internal routes (
/api/internal/*) live under a separate APISIX instance (or a separate route set bound to a dedicated SSL object that pinsclient.cato the LearnStack-internal CA — mTLS in APISIX is SSL-object config, not a route plugin) plus a route-levelip-restrictionfor the Hub egress range; the client certificate must be signed by that CA per ADR-0019. The commented/api/internal/*stub ininfra/apisix/apisix.yamldocuments the canonical shape. - Gateway config lives in
infra/apisix/as version-controlled YAML. Hot-reload viaapisix reloadafter a config change; no in-place edit of running configs.
- Every integration event is written to
outbox_messagesin the sameDbContexttransaction as the aggregate that produced it.IOutbox.EnqueueAsyncenrolls in the ambientDbContext; do not open a new transaction. - The shared outbox table is RLS-protected;
OutboxProcessorconnects with thelearnstack_outbox_adminrole that bypasses RLS. OutboxProcessorusesFOR UPDATE SKIP LOCKEDto allow horizontal scaling. Each message has its own transaction; one failure does not roll back the batch.- Retry backoff: 1s, 5s, 30s, 5min, 1h. After max retries (5), the message is
dead-lettered and surfaces via the
OutboxStatusEndpointsadmin API; manual intervention is required. - Consumers are idempotent via per-module
inbox_messagestables. TheIInboxGuard.MarkAsProcessed(eventId, eventTypeName)write enrolls in the consumer's businessDbContextso the inbox marker and the business write commit atomically.
Full deep dive: 15-event-and-outbox.md.
This is the day-to-day reference for the surface;
ADR-0034 is the decision that
governs it, and the Hub-side halves live in the learnstack-hub repository.
The surface is governed by two invariants, not by an endpoint count. The corpus previously said "exactly four", which was never true — ADR-0019's own decision section enumerates six paths — and protecting the number caused real damage: TLS private keys were tunnelled through the entitlement payload to avoid declaring a fifth endpoint, and a host lookup was added to the Hub client without being recorded at all.
- The Hub stores no tenant content. Courses, lessons, learners, enrollments,
classroom sessions, media and content entries live exclusively in LearnStack. The Hub
holds tenant metadata — plan, subscription, licence, custom domain, compliance caps,
aggregated usage. Enforced by
Hub_NeverStores_TenantData(a Hub-schema scan, owned by the Hub repository). - Every LearnStack↔Hub crossing goes through a named adapter —
IEntitlementProvider,IUsageReporter,IHubTenantSync. No other type in the codebase may hold a Hub client. Enforced byHub_Client_Referenced_Only_By_Named_AdaptersandLearnStack_Modules_DoNotReference_Hub.
Adding an endpoint still requires an ADR — not because the count is sacred, but because the surface is a cross-repository contract and both repositories have to agree on it.
Hub → LearnStack — served by the internal listener only, never routed publicly:
| Method | Path | Purpose |
|---|---|---|
POST |
/api/internal/tenants |
Create tenant + default organization |
PUT |
/api/internal/tenants/{id}/entitlements |
Push the entitlement projection |
PUT |
/api/internal/tenants/{id}/status |
Suspend / activate / archive |
DELETE |
/api/internal/tenants/{id} |
Terminate |
GET |
/api/internal/tenants/{id}/usage |
Pull aggregated usage |
PUT |
/api/internal/tenants/{id}/host-mappings |
Push host → (tenant_id, organization_id?) mappings |
LearnStack → Hub:
| Method | Path | Purpose |
|---|---|---|
POST |
/api/v1/internal/license/verify |
Verify / pull the entitlement projection |
POST |
/api/v1/internal/license/refresh |
Scheduled phone-home refresh |
POST |
/api/v1/usage/report |
Report a usage metric (idempotent) |
POST |
/api/v1/internal/tenants/{id}/custom-domains |
Submit a custom domain on behalf of a tenant admin (proxied; IHubTenantSync) |
The Hub's own tenant-facing and operator-facing APIs (/api/v1/tenants/*,
/api/v1/subscriptions/*, /api/v1/webhooks/*) are not part of this surface. They
are the Hub's public API, governed by the Hub repository.
- Every endpoint above carries the full auth chain: mTLS + signed JWT (RS256,
aud=learnstack-internal,exp ≤ 5 min, replay-protectedjti) + HMAC body signature. All three must validate — but they fail at two different layers, and the distinction is not cosmetic./api/internal/*is bound to its own mTLS listener and is never proxied by APISIX (30-api-gateway.md § Topology), so a missing, expired or untrusted client certificate is rejected during the TLS handshake: there is no HTTP request, therefore no status code and no response body. Only the JWT and HMAC checks can return401, and they do so with no detail leak. A document promising401for an mTLS failure is describing a listener that does not exist. See 11-security.md § Hub Contract Surface. - TLS certificates and private keys never travel in the entitlement payload. That
payload is cached in
platform_entitlement_cache, logged, audited and mirrored — every property you do not want a private key to have. Certificate material moves between the Hub-owned and LearnStack-owned secret stores by secret-store replication, referenced from thehost-mappingspayload by path, not by value. - Host resolution never calls the Hub.
IHostToTenantResolverreadsplatform_host_to_tenantand nothing else;IHubClient.LookupHostAsyncdoes not exist. Putting the Hub on the hot path of an anonymous page load means a Hub outage takes tenant marketing sites down — see § Host → Tenant Resolution below. - The Hub URL is read once at startup via
ISecretProviderand bound to anIOptions<HubOptions>instance. InjectIOptionsMonitor<HubOptions>where dynamic refresh is needed. - The projection's wire shape is pinned by a checked-in
entitlement-v1.schema.jsonand a snapshot test in both repositories. A shape change that lands in one repository and not the other is a contract break, and the snapshot tests are what catch it. - The Hub-backed adapters themselves are demand-gated:
NullEntitlementProvideris the registered implementation until a tenant must be billed or plan-gated, at which point the adapters land in Phase 02c (ADR-0035).
platform_entitlement_cacheis read-only from every module. Writes happen only viaIEntitlementProvider.RefreshAsync(called by the Dapr event handler forlearnstack.hub.entitlementand by the periodic 15-min sweep).IFeatureFlags.IsEnabledAsync(FeatureKey)is the only sanctioned read path. Direct SQL againstplatform_entitlement_cacheoutside the Tenancy module's infrastructure is forbidden (architecture testModules_Do_Not_Read_Entitlement_Cache_Directly).- The resolution order is normative
(ADR-0034):
L1 in-process → L2 ICacheService → platform_entitlement_cache → Hub. The durable projection sits between the caches and the Hub precisely so a cold cache during a Hub outage falls through to a stored answer with a recordedgrace_until, rather than throwing out of a feature-flag check. Each feature-key class declares fail-open or fail-closed explicitly. - Cache TTLs: L1 (in-process
IMemoryCache) = 60s; L2 (Dapr state → Valkey) = 15-minute upper bound. Eager invalidation flows from the Dapr event (learnstack.hub.entitlement/learnstack.cache.invalidation); the TTLs are the safety net, not the typical refresh window. - For air-gapped deployments,
SignedLicenseKeyEntitlementProviderreads a signed.licfile and runs the same projection write path; the rest of the system is source-agnostic.
- The
platform_host_to_tenanttable is the only authority forhost → (tenant_id, organization_id?)mapping. It is populated throughPUT /api/internal/tenants/{id}/host-mappingsfor SaaS / Dedicated and by configuration for Self-Hosted. IHostToTenantResolveris the only sanctioned read path, and it readsplatform_host_to_tenantand nothing else. It does not call the Hub — not on a cache miss, not as a fallback, not ever (ADR-0034). Host resolution sits on the hot path of every anonymous public page load; a resolver that calls a control plane converts a Hub outage into a tenant-marketing-site outage.- A cache miss re-reads the table. An unknown host is a 404, not a Hub lookup.
- The frontend edge calls the resolver via a thin API endpoint; the backend uses it directly for inbound request resolution.
- Custom-domain activations on Hub push a new host-mapping set; LearnStack updates
platform_host_to_tenantand invalidates the resolver cache. Once the event-bus adapter lands, the same update also arrives aslearnstack.hub.custom-domain.activated/.deactivated— the push endpoint remains the authority, the event is the invalidation signal.
- Audit capture is wired through the shared
LearnStack.Infrastructure.Auditpipeline. Modules do not write toaudit_logdirectly. - A command/query/event becomes audited by the catalog (MUST/SHOULD/MAY
classification per 18-audit-coverage.md) and the MediatR
AuditLogBehavior; there is no per-module audit code. IAuditStoreis the only sanctioned write path; the architecture testModules_Do_Not_Write_AuditLog_Directlyenforces this.
- Long-running domain work (cohort enrollments, recording transcoding) goes on
Hangfire. Short-running idempotent work goes on
IEventBusconsumers; the two are not interchangeable. - Job names are namespaced
{module}.{job-name}(enrollment.bulk_grant,media.recording_transcode). - Recurring jobs are declared in code and registered at startup; ad-hoc UI scheduling is disabled.
- Hangfire's storage is its own Postgres schema (
hangfire), separate from any module's schema.
- LearnStack monolith → APISIX edge → backend pods (1+).
- Dapr sidecar runs alongside every backend pod.
- Kafka, Valkey, Vault are accessed only via the Dapr sidecar. No direct client libraries for these three in application code.
- Postgres is accessed directly (EF Core); Dapr's state-store sits on Valkey, not Postgres.
- SeaweedFS is accessed via the configured S3-compatible client (no Dapr binding).
- Direct
IConnectionMultiplexer/IDistributedCacheinjection. - Direct
KafkaProducer/ConsumerBuilder/ Confluent.Kafka usage. - Direct
VaultClient/ Vault HTTP API calls. - Direct
Sentry.SentrySdkusage — capture happens viaIErrorTrackingProviderper ADR-0032. - Reading
DeploymentModefrom inside a module. - Calling Hub endpoints from anywhere except the dedicated
IEntitlementProvider/IUsageReporter/IHubTenantSyncadapters. - Resolving a host by calling the Hub.
IHostToTenantResolverreadsplatform_host_to_tenantonly. - Carrying TLS certificates or private keys in the entitlement payload, or in any other payload LearnStack caches, logs, audits or mirrors.
- Writing
outbox_messagesfrom outside theIOutboxinterface. - Writing
audit_logfrom outside theIAuditStoreinterface. - Writing
platform_entitlement_cachefrom outsideIEntitlementProvider.RefreshAsync. - Adding a fifth Dapr building block without a new ADR.
- Adding an endpoint to the Hub contract surface without a new ADR.
- Promoting a demand-gated adapter without recording which trigger fired, and amending ADR-0035's table.
- ADR-0014 Adopt Dapr
- ADR-0015 API Gateway: APISIX
- ADR-0019 LearnStack Hub
- ADR-0020 Triple Deployment + Hybrid License
- ADR-0021 Feature-Based Entitlement
- ADR-0032 Exception Handling, Logging, and Observability Architecture
- ADR-0034 Hub Contract Surface Invariant
- ADR-0035 Demand-Gated Infrastructure
- 29-dapr-integration.md
- 30-api-gateway.md
- 33-cross-cutting-concerns.md
- 24-learnstack-hub.md
- 25-deployment-models.md
- 12-infrastructure.md — operational rules (CI/CD, DB ops, containers, observability).