Stripe as a module — the design

Status: LANDED (Plugins), with one paired core change still owed (see What is still owed).

Until 2026-09-04 the Stripe integration was two .cs files inside Store/Order/Source/ — in-mesh code nodes compiled at runtime into the Store/Order NodeType's assembly. It worked. What it could not do was be addressed: it had no version, no bundle, no page, no framework identity, nothing an operator could pin, and no way to be absent.

It also had four producers. Store/Order/Source is shared= into Store/Catalog, Store/Plugin and Store/Maintenance, so the same gateway and the same HMAC verifier were compiled into four separate collectible assemblies, each minting a distinct type identity for one implementation. A recompile of any of the four minted new ones.

What "a module" turned out to comprise

Worked out from the estate rather than assumed. A module here is two artifacts in two trees, joined by one string, plus the wiring that makes each half reachable:

Half This module What makes it real
compiled src/MeshWeaver.Payments.Stripe/ a plain net10.0 csproj; $(MeshWeaverRoot) project references; no Version= on a PackageReference
entry point StripePaymentsModuleAttribute an assembly-level MeshNodeProviderAttribute. Listing the DLL under Modules:Assemblies IS the activation; there is no IModule interface and no compiled call from any composition root
suite src/MeshWeaver.Payments.Stripe.Test/ the pack lane runs $(dirname project).Test by convention — derived, never declared. Absent is legal; present and red is not
package Stripe/index.json a Store/Plugin node whose content.module names the assembly. This string is the join, and check-modules-published.py refuses a content.module with no {package, module, project} triple in ci.yml
version Stripe/manifest.lock MAJOR.MINOR authored in index.json; the PATCH and the content hash are DERIVED by gen-manifests.py. Never hand-edited
floor content.minMeshVersion the landing floor, gated by check-module-floors.py against every dependency's floor
cover content.body the public page — see below
build lane a modules-floor entry in .github/workflows/ci.yml build: container + accept: targets; the accept set must be IDENTICAL across a call's container entries
composition always-modules + three artifact patterns because in-mesh NodeTypes bind its types — see below

Two things a module explicitly does not carry: its own strings.{en,de}.json (there is no such file anywhere under src/), and native or static assets.

The public page is the package node, not a page

There is no "cover" file and no cover layout area to write. A Store package's cover is content.body on its own root index.json, rendered by the Store/Plugin NodeType's default Content area. Four things make it a public, indexable page:

  1. nodeType: "Store/Plugin" — without it SeoHead.BuildJsonLd skips the JSON-LD block entirely.
  2. name, description, a renderable icon and a category with a CategoryAccent arm. store-presentation.py --check fails on each; Platform is the sanctioned fallback category and is what this package uses, so no accent arm had to be added.
  3. preInstalled: truePackageInstaller.EnsureDeclaredAccess then writes the open shape, so the cover is anonymously readable. That is a precondition, not a nicety: AnonymousGate is fail-closed and SeoResolver returns null for anything it refuses, so a gated page emits no metadata at all — not even a title.
  4. Prose that is not a wall of text. check-covers.py fails any prose paragraph over 400 characters; headings, bullets, tables, raw-HTML hero blocks and @@ embeds are structure and do not count. The ratchet file cover-prose.allow is empty and may only shrink.

Language: the cover is authored content, so it renders as authored (Doc/Architecture/ChromeAndContentLanguage, clause 1). The module's refusal messages are module-owned text and therefore follow the viewer — which is why StripeTexts exists.

🚨 The one-producer rule shaped the boundary

BakeHost.ShippedByHostProblem fails a bake FATAL when a module composed with --module has a simple assembly name the platform host also ships in /app or lists in its surface manifest: two builds of one name in one bake, and every NodeType binding it is DECLINED at adoption with "dependency record mismatch" (MeshWeaver#3175). On the morning this design was written, core CD's plugins-bake was red on exactly that, for MeshWeaver.Markdown.Collaboration (Plugins#1262/#1268, both open).

Three properties keep Stripe out of that state, and each is enforced rather than intended:

Modules:Required — considered, and deliberately NOT declared

The obvious reading is that a portal must not run without this module: four Store NodeTypes bind it, so a portal that lost it has a dead store. Modules:Required exists for exactly that — a missing entry makes /health unhealthy, readiness fails, and the rollout STALLS while pods that still have it keep serving.

It is still the wrong instrument here, for two measured reasons:

What fails-closed instead is the compile status: a NodeType whose module is absent does not silently degrade — it goes to CompilationStatus.Error and parks, which is visible on the type and in the gate. Revisit if a payment outage ever presents as "the store rendered, and nothing could be bought" rather than as a red NodeType.

Moved, not shimmed — and what that forced

The code MOVED. Store/Order/Source/StripeGateway.cs and StripeWebhooks.cs are deleted; nothing in this repo compiles a second copy. A thin in-mesh shim was considered and rejected: a shim that forwards is still a second definition of the type identity, and "source files inside another plugin" is precisely what this change was asked to end.

Moving forced the honest consequence: four in-mesh NodeTypes now bind a module assemblyStore/Order directly, and Store/Catalog, Store/Plugin and Store/Maintenance through shared=@Store/Order/Source. That is the same shape MeshWeaver.Maps already has (four NodeTypes bind it: the three map galleries and Cornerstone/Pricing), and it is handled the same way: the module joins modules-floor, always-modules, and all three of this repo's compose patterns (compile-check's download glob, test-repos' module-artifacts, publish-bake's module-artifacts). The required-bundle loop in ci.yml names it, so a run that failed to build it says which NodeTypes will fail and why, rather than surfacing as CS0246 on content.

The seam that came out of it

The module was made narrower than the code it replaced, because a module that knew about plugins and plans would have dragged Store types across the boundary:

The install-order property, stated rather than discovered

Stripe declares requires: ["Store@^1.0.0"] — it must, because its own root node is typed Store/Plugin, which the Store defines. The reverse edge is deliberately NOT declared: Store requiring Stripe would close a cycle in the package graph, and the Store is the base every package is typed by.

So the ordering guarantee is not the requires graph. It is the seal: a portal adopts a publication in which the Store's NodeType assemblies were baked WITH the module composed, and the module bundle travels in the same sealed set. Content and module arrive together; the portal does not recompile the Store at boot and therefore never observes a window without the module.

The window that does exist is a mesh compiling from source with no adoption — a CI gate mesh, a fresh local install. The gate closes it by composing the bundle (always-modules + the three artifact patterns); a local install closes it by installing the Stripe package, which is what lands the module. MeshWeaver.Maps has had exactly this property since it left the image, which is the reason to state it here rather than treat it as new.

The module IS optional now (#1328)

This section used to say the opposite, and the correction is the point.

For its first days the same four Store NodeTypes that make this a floor module bound its types directlyusing MeshWeaver.Payments.Stripe; in Store/Order, Store/Catalog, Store/Plugin and Store/Maintenance. Delisting the assembly therefore did not degrade a portal to "does not sell": it left NodeTypes that could not compile. Loud (CompilationStatus.Error, a red gate) but not optional — and every consumer that mounted no payments module got exactly that, with the prebuilt assembly declined and the fallback compile failing CS0234, so instances of those types read back with an empty content on pages that had nothing to do with money.

The seam that fixes it is the one this page predicted: an always-present payments abstraction, IPaymentProvider, in the platform — with this module as one implementation, registered through DI by AddStripePaymentServices and resolved by hub.PaymentProvider().

What a deployment that leaves this module out now gets, and what makes each a state rather than a break:

Surface With no provider
checkout (package or plan) refuses with a sentence in the viewer's language — the same shape an unconfigured secret key already produced
cancel a subscription the same refusal
the webhook inbox LEAVES the delivery in place: nothing can authenticate it, which is the recoverable arm, never a rejection (a rejection means a check failed, and no check ran)
PaymentPathAudit NotSelling — out of scope, measured nothing, and explicitly not a claim that payments work

Absence is a null from hub.PaymentProvider(), and that null is the design: it makes "no module mounted" a case a caller answers in code instead of a type it cannot compile against. No reflection probe for the assembly, no try/catch around a payment type, no null dereferenced at first use.

This module stays a floor module in always-modules — but for a different reason than before. It is no longer needed for the content to COMPILE; it is there so the gate's mesh resolves a real provider and the with-a-provider behaviour is exercised at all.

No SDK — stated deliberately

The move to a real .csproj made a Stripe SDK possible for the first time. It was declined:

Revisit if the surface grows beyond Checkout and subscriptions — Connect, disputes or refunds would change the arithmetic.

What is still owed

🚨 Core CD's plugins-bake composes only {AI, Markdown.Collaboration} (measured on run 33853637332). It bakes the whole Plugins content tree, so once the Markdown.Collaboration FATAL clears it will fail CS0234/CS0246 on every NodeType binding a module it does not compose — the four Maps galleries today, and the four Store types from this change. Closing that is a paired core change adding MeshWeaver.Maps and MeshWeaver.Payments.Stripe to plugins-modules's matrix and to plugins-bake's module-artifacts. It must land after this change, because plugins-modules packs from the Plugins checkout and the project has to exist there first.

Stripe setup guide · Payments · Subscriptions · Doc/Architecture/ModuleBuildArchitecture · Doc/Architecture/ModuleVersioning · Doc/Architecture/ChromeAndContentLanguage

Reconnecting…
The server was updated. Reloading the page to pick up the latest version.