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:
nodeType: "Store/Plugin"— without itSeoHead.BuildJsonLdskips the JSON-LD block entirely.name,description, a renderableiconand acategorywith aCategoryAccentarm.store-presentation.py --checkfails on each;Platformis the sanctioned fallback category and is what this package uses, so no accent arm had to be added.preInstalled: true—PackageInstaller.EnsureDeclaredAccessthen writes the open shape, so the cover is anonymously readable. That is a precondition, not a nicety:AnonymousGateis fail-closed andSeoResolverreturns null for anything it refuses, so a gated page emits no metadata at all — not even a title.- Prose that is not a wall of text.
check-covers.pyfails any prose paragraph over 400 characters; headings, bullets, tables, raw-HTML hero blocks and@@embeds are structure and do not count. The ratchet filecover-prose.allowis 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:
- No portal host reaches the project.
PortalClosureStripeModuleTestwalks the ProjectReference closure of all three image hosts and fails on any chain — a walk, not a grep, because an edge two hops down would put the DLL back in/appwhile a grep still read "absent". Its anti-vacuity twin asserts the same walker DOES find the module from its own test project. - It is not claimed as platform-shipped. The same suite asserts
MeshWeaver.Payments.Stripeis absent fromsrc/platform-shipped.txt. A name listed there is subtracted from the module-owned set, so its bytes would be omitted from the bundle — the mirror defect: not a duplicate, but an assembly reaching a mesh from nowhere at all. - It is a
MeshModuleClosureseed nowhere. UnlikeMeshWeaver.AIandMeshWeaver.Blazor.Chat, this module ships no image seed. A seed is a different layer from/appand is sanctioned (Plugins#1244), but it is one more place bytes come from, and the module is small enough that registry delivery alone is honest.
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:
MeshWeaver.Mapsis the identical shape and is not declared there. It is NodeType-bound, registry-delivered, image-absent and in the same floor lane. Declaring one and not the other would make the list mean two different things.- It would wedge local self-registry installs.
deploy/homebrew/…/values.local.self-registry.yamlblanksModules__Required__0..4precisely because a laptop has no registry to land those modules from. Adding a ninth required entry that only a registry can satisfy would stall exactly the installs that blanking exists to keep running.
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 assembly —
Store/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:
StripeGatewaytakes a reference, a product name, an amount, a currency, two return URLs and a metadata map. It no longer readsSubscriptions.CheckoutPathand no longer composes/{plugin}/Subscribeitself.Store/Order/Source/OrderCheckoutis the new seam: it owns the return URLs (built fromCoverContract.SubscribeAreaandSubscriptions.CheckoutPath— the constants the surfaces are actually registered under) and the metadata (built fromStripeMetadata's constants, which the webhook reader reads back). One file changes when a surface moves.StripeMetadataexists because the producer and the consumer are months apart: a subscription's metadata is stamped at submit and read back on a renewal invoice. A typo is not a compile error and not a test failure — it is a subscriber who lapses with nothing anywhere to say why.
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
directly — using 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:
- The call surface is three form-encoded POSTs. The SDK's value here is version pinning and typed models; the cost is a third-party dependency inside the bundle's package closure, its own HTTP stack and retry policy, and a supply-chain surface on the one code path that moves money.
- An SDK pins an API version. The webhook reader must be tolerant of Stripe moving metadata
between API versions (
subscription_details.metadata→parent.subscription_details.metadata), because the account's API version is a dashboard setting, not ours. A pin hides that problem rather than solving it — and the day it changed, nobody's plan would renew. - The request shapes are asserted field-by-field by this module's own suite, and the HMAC is pinned against an independently computed vector rather than against our own implementation.
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.
Related
Stripe setup guide · Payments ·
Subscriptions · Doc/Architecture/ModuleBuildArchitecture ·
Doc/Architecture/ModuleVersioning · Doc/Architecture/ChromeAndContentLanguage