Move LearnStack toward a real commercial and operational platform: package and subscription foundations, external integrations, measurable analytics, and the search engine that the earlier phases have been running without.
Billing exists as a core capability, while provider-specific and tenant-specific pricing rules stay behind adapters and configuration.
This is also the phase where the platform starts being able to answer questions about itself. Every phase up to here produced capability; this one produces the instruments that say whether the capability is being used, what it costs, and whether anyone paid for it.
- Product.
- Plan.
- Price.
- Order.
- Subscription.
- Invoice reference.
- Payment provider account.
- Billing-source entitlement bridge. Phase 09 does not own the
CourseAccessaggregate (Phase 07 does). It owns the producer side: a paidOrderemits anOrderPaidV1integration event through the outbox, carrying the buyer's user id, the granted product (course / program / package), and the tenant context. The Enrollment module consumes it and creates anCourseAccesswithsource = billing. See Phase 07 § Course access for the consumer contract, and ADR-0010 for why this crossing is an integration event rather than a direct call.
The ADR-0018 Amendment (2026-08-08)
draws the genericity boundary here explicitly: a ten-session credit pack or a
"three make-up classes per term" allowance is a platform feature, not tenant
customization data. A TenantContentType JSON Schema can declare a shape; it cannot
declare a ledger that is decremented, refunded, expired and audited.
So the capability is built in code, once, generically — but no phase in this roadmap
builds it. Phase 07 § What CourseAccess is not
and Phase 10 both say so, and this phase agrees: a
ledger that is decremented, refunded, expired and audited is a platform feature needing
its own ADR and its own release, not a field bolted onto an access grant.
What Phase 09 does own is the purchase: a credit-pack Product carries a quantity
and sells through the same Order path as anything else, producing the same
OrderPaidV1. The pack's size, price, expiry window and refund rule are tenant
configuration.
When the ledger is built, three things move together and none of them belongs here: the
balance aggregate, its decrement point at LiveBooking confirmation
(Phase 08b), and its refund rule. Naming them together is the
point — the failure mode is a balance that two modules both believe they own. Until that
ADR exists, a tenant that needs prepaid sessions sells them as a Product and tracks
consumption outside the platform.
- Provider abstraction behind
IPaymentProvider, per the adapter pattern in 06-extension-model.md. - Webhook endpoint convention: signature-verified, idempotent on
(provider, event_id), tenant derived from the stored provider account and never from the payload — the same rules the classroom webhook follows in Phase 08c. - Payment status mapping from provider vocabulary into LearnStack's.
- Tenant-specific provider configuration; provider credentials read through
ISecretProvider, never from tenant rows.
Candidate providers: Stripe, iyzico, PayTR, and a manual / offline provider. The manual provider ships first and is not a placeholder — it is the one every deployment needs, it exercises the whole order lifecycle without a network dependency, and it is what integration tests run against.
- Free course enrollment.
- Paid course access.
- Package / credit-pack purchase.
- Subscription placeholder.
- Coupon / discount placeholder.
- Manual payment approval.
By the time this phase starts, most adapters already exist — email and SMS from Phase 08a, storage from Phase 04, live classroom from Phase 08c. What this phase adds is the registry: one place where a tenant's configured providers, their credentials' secret references, their health, and their per-tenant enablement are visible and manageable.
- Integration registry with per-tenant provider selection and health reporting.
- CRM provider placeholder.
- LTI / xAPI readiness — the export shape, not the certification.
Search has been running on the ITenantSearch port since
Phase 04, backed by PostgreSQL full-text search.
ADR-0035 demand-gates Meilisearch
behind that port, with the trigger "search quality or scale exceeds PostgreSQL FTS".
This phase is where the trigger is expected to fire. PostgreSQL FTS is adequate for
"find the row"; a public course-catalog search box is a different job. It has no typo
tolerance, no prefix or as-you-type matching, and no native faceting, and its Turkish
support is a Snowball stemmer rather than ICU-quality tokenisation of an agglutinative
language. Those are precisely the weaknesses
ADR-0012 cites when it selects Meilisearch, and
a Turkish-serving tenant meets all four the first time a visitor mistypes a course
name.
Two things must be settled before the adapter lands. Both are corrections to the existing design, and both are landed as a dated Amendment to ADR-0012 plus an update to 20-search.md.
Every other data path in LearnStack carries four independent isolation layers
(ADR-0003): request context,
EF query filter, PostgreSQL Row Level Security, and an architecture test. ADR-0012 as
written gives search one — a tenant_id filter composed in application code — and
hands the engine a master key with no tenant scope on it. One forgotten filter, or one
call path that reaches the SDK directly, and the isolation is gone with nothing behind
it. That is a single-layer surface inside a corpus that claims defense in depth
everywhere else.
Two observations sharpen this. First, the PostgreSQL FTS default that search runs on until this phase is protected by RLS like every other table, because its projection tables are ordinary tenant-owned tables. The Meilisearch migration is the moment search isolation gets weaker, not stronger — so the adapter has to bring the missing layer with it. Second, an engine that cannot enforce isolation itself is not automatically disqualified; it just cannot be the only thing enforcing it.
The resolution — search gets four layers too, at different boundaries:
| Layer | Database path | Search path |
|---|---|---|
| Request context | ITenantContext, resolved per request |
ITenantSearch resolves the tenant or throws; it never falls back to platform scope |
| Caller cannot bypass | EF global query filter | The helper composes the tenant_id filter and ANDs caller filters underneath it; callers pass criteria, never filter strings |
| Engine-enforced | PostgreSQL RLS | Meilisearch tenant token — a short-TTL token minted per request from the master key with the tenant's searchRules baked in. The engine refuses out-of-tenant documents even when the query layer is wrong |
| Mechanical | Architecture test | Architecture test banning direct SDK use outside the adapter namespace, plus a startup assertion that every registered index declares tenant_id filterable |
The engine-enforced layer is the one ADR-0012 is missing, and it is the one that turns a code review into a guarantee. Concretely:
- The Meilisearch master key lives behind
ISecretProviderand never leaves the search adapter. No request-handling code holds it. ITenantSearchmints a tenant token per request (or per short-lived cache window) scoped to the current tenant, and queries with that token.IPlatformSearch— the cross-tenant path for legal hold and abuse investigation — is the only caller allowed to use an unscoped key, it is a separate type, and every call writes aplatform-adminaudit entry per 18-audit-coverage.md.- Integration tests assert that a query issued with tenant A's token against a deliberately un-filtered request returns zero of tenant B's documents. The test that matters is the one where the application-layer filter is removed on purpose.
Residual risk, stated rather than hidden: both search layers live inside the same process boundary, so a compromised application process defeats both. RLS has the same property. The layers protect against the realistic failure — a bug, a new call path, a forgotten filter — not against a compromised host, which is 11-security.md's problem.
ADR-0012 rejects index-per-tenant with clear arithmetic: 10,000 tenants × 4 locales ×
4 kinds is 160,000 indexes, and Meilisearch is not built for that cardinality. Then
20-search.md § Tenant Content-Type Kinds reintroduces
exactly that explosion through a different door: a TenantContentType marked
searchable: true "materialises a search kind", producing an index per locale. Kinds
are tenant-declared, so the kind set is unbounded and grows with tenant count — the
rejected topology, arrived at by another route. It also collides: two tenants declaring
the same content-type key would share an index name while having different schemas.
The resolution — the index topology is closed and tenant-independent:
- Built-in kinds are a closed, platform-owned set (
course,content-entry,media,lesson-item). One index per(kind, locale). Adding a kind is a code change and a schema registration, never a tenant row. - Total index count is
|kinds| × |locales|— eight today with two locales — and it does not move when the ten-thousandth tenant signs up. That is the property the ADR wanted and the reason it rejected index-per-tenant. - Tenant-declared searchable content types do not get their own index. They are
documents in the shared
content-entryindex, discriminated by thecontent_type_keyfield the schema already carries, and always underneath the mandatorytenant_idfilter. Two tenants using the same key never collide, because no query ever crosses the tenant filter. - Tenant-declared facets resolve through a flattened, generic facet map —
facet_string.*,facet_number.*,facet_date.*— rather than per-type fields. An English tenant'slevelfacet and a yoga tenant'sdifficultyfacet are the same index attribute with different values, which is precisely ADR-0018's claim expressed in an index. - Facet declarations are capped per tenant, enforced at
TenantContentTyperegistration. Filterable attributes are a shared, finite resource on a shared index; an uncapped declaration is one tenant degrading everyone's search. - Locale stays an index split, unchanged. ADR-0012's reasoning there is sound: ICU tokenisation, stop words and stemming are genuinely per-language, the locale list is small and platform-controlled, and it does not grow with tenants.
- Meilisearch adapter behind
ITenantSearch/IPlatformSearch, with tenant-token minting and the master key behindISecretProvider. - Reindex from the existing projection tables into the Meilisearch topology, with the alias-swap cutover from 20-search.md § Reindex.
- Search query telemetry feeding the analytics module.
- Platform-admin cross-tenant search via
IPlatformSearch, audited. - Drift dashboards and the nightly reconciliation surface.
- The dated ADR-0012 Amendment and the 20-search.md correction described above.
The outbox-driven indexing pipeline is engine-independent and does not change: the same integration events that maintained the FTS projection tables feed the Meilisearch indexer. That is what makes this a swap rather than a rewrite, and it is the payoff for having shipped the port five phases before the engine.
The counters emitted in Phase 08c become reports here. Provider-agnostic metrics:
- Session created, room opened.
- Participant joined, participant left, connection duration.
- Screen share started / stopped.
- Recording started / stopped, recorded minutes.
- Attendance status.
- Downstream bytes and concurrent-participant peak.
- Provider error.
These serve two consumers at once: learning analytics, and the provider cost monitoring that feeds Phase 08c's LiveKit Cloud-versus-self-hosted decision rule. That rule reads its inputs from the reports built here — without them it has no way to fire.
Event groups:
- Learning events.
- Content events.
- Commerce events.
- Admin events.
- Live classroom events.
- System events.
Read models:
- Course completion report.
- Enrollment report.
- Funnel report.
- Active learners.
- Content performance.
- Live session attendance report.
- Classroom usage and cost report.
Analytics read models are projections per ADR-0010 — they are built from integration events, not from cross-module queries into other modules' tables. Every event carries a version suffix from the first one; an unversioned analytics event is a schema that can never change.
LearnStack.Modules.Billing,LearnStack.Modules.AnalyticsandLearnStack.Modules.Integrationsscaffolded — each with{Domain, Application, Application.Contracts, Infrastructure}, itsIModuleregistration, itsdocs/modules/<module>/audit.md, and its permission-catalogue rows.- Billing domain primitives and the
OrderPaidV1producer path. - Payment adapter infrastructure with the manual provider working end to end.
- Credit-pack purchase path, with the absence of an in-platform consumption ledger explicit and the separate ADR/release boundary recorded.
- Integration registry with per-tenant provider configuration and health.
- Meilisearch adapter behind
ITenantSearch, with engine-enforced tenant tokens and the closed index topology. IPlatformSearchcross-tenant search, audited.- Reindex and nightly reconciliation, with drift dashboards.
- Dated ADR-0012 Amendment and 20-search.md correction covering isolation layers and index topology.
- Analytics event ingestion, the read models listed above, and the classroom usage and cost report.
- Product, plan, and price can be created for a tenant.
- The manual payment provider drives an order to paid through the adapter, and a paid
order produces a
CourseAccessin the Enrollment module viaOrderPaidV1. - A credit-pack
Productcan be bought through theOrderpath. This phase does not expose, decrement or refund a remaining-session balance; consumption stays outside the platform until the separate ledger ADR and release described in Scope. - Webhook idempotency is tested: the same provider event delivered twice produces one order state change.
- Search runs on Meilisearch through
ITenantSearchwith per-request tenant tokens. - A query issued with the application-layer tenant filter deliberately removed still returns zero cross-tenant documents, because the engine rejects them. This is the criterion that proves the isolation layer is real.
- Total Meilisearch index count equals
|kinds| × |locales|and does not change when a tenant is added — verified by adding a tenant with three searchable content types and observing no new index. - Platform-admin cross-tenant search runs through
IPlatformSearchand writes aplatform-adminaudit entry per call. - Reindex completes with an alias swap and no search downtime.
- Learning, commerce, and classroom events can be reported, and the classroom cost report produces the inputs Phase 08c's provider decision rule needs.
- Designing too closely around the first payment provider. Provider vocabulary leaks into the domain most easily through status enums and webhook payload shapes. The manual provider shipping first is the mitigation: a domain that models manual payment cleanly is a domain nobody shaped around Stripe.
- Merging billing and enrollment into the same model. The bridge is one integration
event in one direction. If Phase 09 code reads
CourseAccessor Phase 07 code readsOrder, the boundary is gone. - A premature or duplicate credit ledger. A
remaining_sessionscounter in Billing, Enrollment or Scheduling would invent a consumption capability this roadmap does not deliver. The separate ledger ADR must select one authoritative balance aggregate and its booking/refund contract before those writers exist. - Migrating search without the engine-enforced layer. The tempting version of this phase ships the Meilisearch adapter with the existing application-side filter and leaves tenant tokens for later. That version makes tenant isolation strictly weaker than it was on PostgreSQL FTS, which is a regression disguised as a feature.
- Index topology settled by accident. If the adapter is written before the ADR-0012 Amendment lands, whatever the first indexer does becomes the topology, and the tenant-declared-kind explosion arrives by default.
- Designing analytics only for the current dashboard. Read models built backwards from a specific chart cannot answer the next question. Model the events; derive the charts.
- Growing event schemas without versioning. Cheap now, and unfixable once a consumer exists in another process.
- Ignoring classroom usage costs. The cost counters exist from Phase 08c specifically so this phase can report on them; if the reports slip, the self-hosting decision in Phase 08c never gets its inputs and defaults to whatever was configured first.
Phase 10 begins when a tenant can sell something and find something.
Concretely: an order goes from created to paid through the payment adapter and grants an
CourseAccess the learner can act on; a credit-pack Product sells and produces its
order without the platform pretending to track a balance; search runs on Meilisearch
with cross-tenant leakage blocked by the engine and demonstrated under a test that
removes the application-layer filter on purpose; the index count is independent of
tenant count; and the analytics read models — including the classroom cost report —
return real numbers for a tenant that has actually been used.
If the Meilisearch trigger has not fired by the end of this phase — search quality on PostgreSQL FTS is adequate for every live tenant — then the correct exit is to ship the ADR-0012 Amendment and the topology decision, leave the adapter unwritten, and record the trigger as still pending in ADR-0035's table. A demand-gated building block whose demand has not arrived is not an incomplete phase. Writing the adapter anyway, before a tenant needs it, is the failure this roadmap is structured to avoid.