Subscriptions — plans instead of per-app purchases
The store sells plans. A plan governs which packages a subscriber can open, and how much model spend it includes on the platform's own key (metered — see below; BYOK stays available on every plan, at provider prices, never resold). The space/storage quotas ride on the plan nodes as data for the platform to enforce.
The ladder
Five plans, as Store/Tier nodes at Admin/Tiers/{id} — seeded create-if-absent
(StorePlans.AddTierSeeding), so an operator's re-pricing survives every redeploy:
| id | rank | price | model credit / month | what it is |
|---|---|---|---|---|
free |
0 | CHF 0 | CHF 5 | personal integrations, playground, previews |
personal |
10 | 29/mo · 290/yr | CHF 10 | every general course + the everyday toolkit |
pro |
20 | 99/mo · 990/yr | CHF 40 | professional + builder toolkit, social suite, specialist courses |
dedicated |
25 | 6 000/mo · 60 000/yr | unmetered | your own single-tenant instance in our cluster — unlimited, all-access |
enterprise |
30 | contact us | unmetered | SELF-HOSTED: module licence + deployment + service days |
(learn is a legacy alias for personal — same rank, same label. The credit column is
includedModelCredit on the tier node; "unmetered" is the separate modelCreditUnmetered flag,
never an absent number — see Model credit is METERED below.)
PlanTiers (Store/Licensing) owns the rank rule; a package names its plan in its own
index.json → content.tier (repo-authored, like price). Coverage = subscription rank ≥ package
rank — except dedicated, which is IsAllAccess and covers everything ("no limit on packages"),
while still sitting between pro and enterprise in the lineup.
Which tier a package goes on
Every package root names exactly one of free, personal, pro, enterprise —
validate-repos.py → check_package_tiers fails the PR on a missing or unknown value in every
plugin repo, because the two readers of a blank tier disagree: the Store's coverage rule
(SubscriptionFact.Covers) covers it by NOTHING, while the platform registry
(PlanTierRanks.CoversInstance) reads it as the free baseline. dedicated is a plan, never a
package marker; learn is the retired alias nothing may stamp anew. The rule that decides the
tier is the plan's own pitch, so a package and the Plans card never contradict each other:
| tier | what belongs there | today (2026-09-10, all repos) |
|---|---|---|
free |
the platform itself and what every plan needs (Store, AI, Essentials, Edu, Training, Stripe, Providers, Mcp, Import, Export, Publish, Notifications, the model providers, the view libraries); personal integrations (Apple, Google, iCloud, Home Assistant, maps); playground and showcase (Chess, Role Play, Three Bodies, Northwind, Cornerstone); Hosting.Instance | 45 packages |
personal |
the work integrations (Mail, Teams, Collaboration) and every general course (MeshWeaver.Education) | 12 |
pro |
the professional toolkit (Business Rules, Data Modelling, Analysis, Indexing, Observability, Approvals, Video, Voice, WebSearch), the builder providers (Claude Code, Copilot), CRM, the social suite (SocialMedia, LinkedIn, X, YouTube, Marketing), and the insurance-track courses (Risk Transfer, Swiss Solvency Test) | 20 |
enterprise |
the verticals sold by conversation — the insurance platform (MeshWeaver.Reinsurance: Reinsurance, Claims, Underwriting, Pricing, IFRS 17, SST, …), the Manufacturing vertical, fleet Hosting — and Reinsurance Practice, the course that is worked inside those applications | 18 |
A contactEmail belongs on an enterprise root (sold by conversation) and nowhere else: a
pro root with a sales contact renders "Let's talk" beside a plan that is bought with a card —
two funnels on one card (CRM carried exactly that until 2026-09-10).
A package never requires a package filed ABOVE it. DependencyInstall installs AND entitles
the viewer to every unpriced dependency of what they acquired, and a plan-sold dependency carries
no price and no contact — so until 2026-09-10 a Personal course that requires an Enterprise
application (Reinsurance Practice → Reinsurance, Underwriting, Claims, ReinsuranceDemo) wrote the
learner an entitlement to the Enterprise suite: the paywall's back door. Two guards now hold it
shut, and they are one rule: DependencyInstall.DispositionOf(content, dependentTier) reports a
dependency ranked above its dependent as sold separately at install (the same bucket as a
priced one), and check_package_tiers refuses the declaration at the PR wherever both roots live
in the same repo. A pre-installed root is exempt from the dependency half — its requires orders
provisioning, and onboarding never runs the dependency install for it (Essentials lists
Observability, Mail, Teams, WebSearch, Indexing and Approvals for that reason).
The subscription is a standing key
Admin/Subscriptions/{viewer} is ONE Store/Subscription node: the unforgeable record
(Admin partition — the viewer cannot write it) and the control plane (the Enrollment shape:
patch requestedAction, the watcher reacts, terminal writes clear the action; only a global admin
may ask — the invoker is the framework-stamped author).
- Activate stamps
status: Active+validUntilone CALENDAR cadence period out (early renewal tops up from the current expiry; a lapse restarts from now) — or NO expiry at all on themanualcadence, the plan that was granted rather than sold — and first RECONCILES: a downgrade revokes the unlocks the new tier no longer covers. A terminal state accepts a freshrequestedAction— renewal and cancellation are patches on the same node. Expiry is enforced at READ time (SubscriptionFact.IsActiveAt), which stops NEW unlocks the moment the plan lapses; grants already minted stay until a Cancel (or downgrade) sweeps them. For a card-backed plan that Cancel is filed by Stripe's owncustomer.subscription.deleted— see Buying a plan with a card below. - Covered ≠ granted. MeshWeaver decides reads from
AccessAssignments, so coverage alone opens nothing. On a covered package's paywall the subscriber gets a one-click Get (Subscriptions.Unlock): it writes the unlock record under the subscription node FIRST (the revocation bookkeeping must exist before the access it accounts for), then mints the plugin-wide_AccessViewer grant (PluginGate.Enroll), appends the purchase ledger — and then installs. Install-by-install, exactly theCouponKeysdiscipline; never a fan-out. - 🚨 Deliberately NO eternal entitlement marker. That asymmetry is the business model: a
one-time purchase writes
{plugin}/_Entitlements/{viewer}and lasts forever; a plan unlock does not, so Cancel revokes exactly the grants the plan's unlock records name — sparing any package an eternal marker also covers (Subscriptions.RevocableAfterLapse).
Surfaces
/Storecards lead with the plan ("✓ Included" forfree, else "Learn plan" etc. —StoreCatalogLayoutAreas.StatusSlot), and link/Store/Plans./Store/Plansrenders the tier nodes (seeds as fallback — a pricing page must never be blank), marks the viewer's own plan, and offers the CTA: Subscribe into/Store/Subscribe?plan={id}for a self-serve plan, Contact us for the rest, and Cancel subscription (into the same checkout surface) on the plan the viewer holds. The page is fully localized — the price lines, the quotas, the intro and the footnote all come out ofStoreTexts, where they used to be English literals on a pricing page a German customer is sent to.{plugin}/Subscribeshows the covered subscriber the one-click Get, and everyone else the plan upsell line above the legacy price/coupon/billing flow — which is UNCHANGED: eternal purchases, coupons and keys all keep working, and everything already bought stays owned.
Model credit is METERED (#1100)
The first of the plan caps to gain enforcement. includedModelCredit is no longer display-only:
- What it applies to. Only the PLATFORM's own funded key — the
Provider/OpenRouternode, sold as MeshWeaver OpenRouter. BYOK never consumes credit, however expensive the model, and an org's own shared key at{org}/Provider/OpenRouteris not ours to meter either. The rule is stated once, inMeshWeaver.AI.ModelCreditRule.RequiresCredit, so no surface re-derives it — and it asks "is this the platform's key?", never "is this model expensive?". - Three fields on the tier node, and
nullis never "unlimited".includedModelCreditreads as an allowance of ZERO when absent, and the separate explicitmodelCreditUnmeteredflag is what says a plan is settled outside the meter at all (Dedicated, Enterprise — "actual usage, agreed with the scope").modelCreditGraceFractionbounds spend while the ledger is unreadable. Two of them —modelCreditUnmeteredandmodelCreditGraceFraction, plusallAccess— are RULE fields:TierDefaults.NeedsRuleUpgradereconciles them onto tier nodes seeded before they existed, because seeding is create-if-absent and a rule field that does not travel does not exist. - 🚨
includedModelCreditis OPERATOR-PRICED — live wins — but the fallback has to reach the NODE. The seeding therefore NULL-FILLS it (TierDefaults.NeedsAllowanceFill): an operator's number, zero included, is never touched, but a live node that never carried the field gets its seed's amount written once. Without the fill "falls back to the seed when null" was true only of the Plans card (StorePlans.CreditLine): the METER,ModelCreditGate.AllowanceOf, reads the field straight off the live node and is in the AI package, which cannot bind the Store's seeds — so the card would quote Free's CHF 5 while the gate refused the very first round. A consequence, on purpose: null on the node means UNSET; to price a plan at no credit, write0. - Free is METERED, at CHF 5 (maintainer, 2026-09-02). It is a real allowance on the platform's
key, drawn from the same ledger and bounded by the same tenth-of-allowance grace as the paid
plans — not BYOK-only, and not unmetered. The cheapest plan is where an unmetered platform key
would cost the most, so
freecarryingmodelCreditUnmetered: falseis an invariant with its own test. - The ledger is a RECORD, and it is authoritative.
Admin/ModelCredit/{subscriber}/{period}/ {roundId}— one charge per round, priced ONCE at the moment it was spent (the per-thread_Usagesatellites keep deriving cost on read, which is right for a report and wrong for a balance that must not move under a subscriber when a rate is edited). Keyed by the round, so a retry writes the same node: the write is the guard, not the read before it. UnderAdmin, so the subscriber can neither write it nor read it; every read runs as System. - 🚨 Three answers, not two. Granted / definitively exhausted / undetermined. An undetermined ledger read spends a bounded grace — a fraction of the plan's own allowance, drawn down at the GATE so repeated undetermined reads cannot exceed it in aggregate — and then stops with a message that says we could not confirm your credit, try again, which is deliberately NOT you are out of credit. (Maintainer ruling, 2026-09-01.)
- Exhaustion ⇒ finish, never abort. The gate runs BEFORE a round: a refused round never starts, so nothing is spent and the thread says why. A round that crosses the limit mid-flight completes normally and keeps its output — the tokens are already paid for — and the NEXT round is the one refused.
A currency mismatch between the model's rate and the plan's allowance is never converted by a rate
the meter chose for itself (invented) and never resolved by dropping the odd rows (an under-count,
i.e. money). It folds only at a rate a HUMAN declared — a ModelCreditExchangeRate node at
Admin/ModelCredit/_Rate-{FROM}-{TO} — and where none is declared it still reaches no verdict,
with a log line naming the exact node to create. That refusal is reported ONCE per charge row rather
than on every round (core #3235). The full reasoning — including why the conversion happens when the
period is totalled rather than when the charge is written — is A currency the meter can fold
(AI/ModelCreditCurrency).
A coupon GRANTS a plan
CouponContent.Tier names a PlanTiers id, and since 2026-09-09 it is required:
CouponRedemption.Validate refuses a coupon that names none, ahead of the window and the budget,
on the free redemption and the paid order alike. Redeeming activates the redeemer's
Admin/Subscriptions/{viewer} on that tier — through the ordinary control plane
(Subscriptions.RequestActivation writes requestedAction: Activate; the Store/Subscription
watcher reconciles, stamps and logs), never a second way to become subscribed.
- 🚨 Why the plan, and not packages. A package grant is the ETERNAL
{plugin}/_Entitlements/{viewer}marker — a one-time purchase — so it outlives every window on the coupon that issued it.COURSES3M, a code whose name promises three months, handed out permanent access to a course catalogue for exactly that reason. Model credit is the other half: it comes from the SUBSCRIPTION tier, never from a package, so the widest coupon expressible before this unlocked the whole catalogue and still metered its holder against the free plan's allowance.plugins/scope/grantsAllkeep their honest job — WHERE a code may be entered, and what a held key may reach — and grant nothing on their own. - 🚨 The granted plan ENDS.
CouponContent.TierDaysis the term; blank grantsDefaultTierDays= 90, and0is a deliberately permanent grant that has to be TYPED, so perpetuity is always somebody's decision rather than a field nobody filled in.TierExpiry(at)clamps to the coupon's ownvalidUntil— nothing a coupon issues outlives the coupon — and reaches the record throughRequestActivation(..., validUntil:). - 🚨 It EXTENDS, never shortens.
Subscriptions.ExtendsExpiryapplies a coupon's term only where the live record has no expiry that already reaches further. The record being patched may be a CARD subscription; stamping a coupon's shorter term on it would cancel something the holder is paying for, which is a worse failure than the unbounded grant the term exists to prevent. A record with NO expiry — every grant made before terms existed — is treated as extendable, since refusing would leave exactly that population unbounded for ever. - Both halves are fulfilled by one call,
CouponRedemption.FulfilStandingGrants— the key and the plan, from the free redemption, the order control plane and the Stripe webhook alike. They are the promises a coupon makes BESIDE the package it was redeemed on, and both go wrong the same way when a path forgets one (which already happened once with the key: both PAID paths skipped it). - Cadence
manual, expiry from the COUPON. A granted plan has no billing cycle behind it, soSubscriptionContent.NextExpiryreturns null on that cadence — the expiry comes from the coupon's term instead, stamped in the SAME activation write rather than by a second writer racing the watcher's terminal write on the same node. Where a grant genuinely should not lapse it says so (tierDays: 0),SubscriptionFact.IsActiveAtreads the null expiry as "never lapses, managed manually", and a Cancel is what ends it — which is the shape the dedicated staff accounts were given by hand. - The plan write is best-effort: the package grant is what the redeemer asked for and must stand even if the activation fails (an operator can re-file it), whereas failing the redemption outright would leave them with neither. The free plan is refused — there is nothing to activate.
- The three live coupons were migrated on 2026-09-09:
STAFFand theMW-…pass-partout todedicated/tierDays 0(all-access, permanent — whatgrantsAllpromised, said by the plan), andCOURSES3Mtopersonal/tierDays 90, which is what its own note always said it stood in for. Keys and entitlements already issued under the old shape are untouched.
Buying a plan with a card
Personal and Pro are self-serve (PlanTiers.IsSelfServe — an ALLOW-LIST, deliberately not
"paid and priced": Dedicated carries a published CHF 6 000 price and still provisions a
single-tenant server somebody has to agree to build, and Enterprise is a licence). Everything else
on the ladder is still sold by conversation, and a new plan is not purchasable until it is named in
that rule.
The surface
/Store/Subscribe?plan={tier} (PlanCheckout, a layout area on the store's catalog type;
Subscriptions.CheckoutPath is the ONE definition the Plans cards link to, the area is registered
from, and the Stripe return URLs are built from). The page's ORDER is the promise it makes:
- the plan and the terms in one sentence — CHF 29 per month; we charge your card every month until you cancel, plus what a cancellation does — rendered from the LIVE plan's own price, so a re-priced plan cannot state one number and charge another;
- what the plan includes;
- the billing details —
BillingForm, the framework's node-content editor bound directly to{viewer}/_Billing/Profile, the same section the per-package paywall renders (one definition: the second payment surface would otherwise have re-typed the first one's create-prefill-observe sequence and the two would have drifted, which is #445); - and only then the button. The card itself is never seen here — it is taken on Stripe's own hosted page.
Every line of CHROME comes from StoreTexts (EN + DE), including the renewal date, which is
rendered through the viewer's zone (AccessService.ViewerZoneId → ChromeLocale.LongDate) and
spelled in the resolved locale — a renewal date is the one number on the page a reader plans around,
and a zone-less instant near midnight reads as the wrong day. The plan's highlights are the
exception, deliberately: they are operator-authored prose on the tier node, and the store's rule is
that authored content stays in the language it was written in (chrome is translated, prose is not).
The three guards
| guard | what it stops |
|---|---|
the quote (OrderContent.QuotedAmount) |
the page and the control plane read the same node by the same rule, which makes them agree about the RULE and nothing about TIME. An operator repricing between the render and the click would state CHF 29 and charge CHF 39. What is charged still comes from the trusted node; a quote that no longer matches it is REFUSED — the only outcome that is neither a wrong charge nor a silent one. |
the claim (CheckoutClaim, {buyer}/_Orders/_PlanCheckout) |
two tabs submitting before either completion webhook arrives BOTH read "no active plan" — a read cannot arbitrate a race, a CREATE can. The claim is taken before the Stripe session is created and released by EVIDENCE (the order it names went terminal, or 25 h passed — longer than a Stripe session lives), never by a release step a crash could skip. |
the cadence (Subscriptions.IsSelfServeCadence) |
the order node is the buyer's own, so cadence: annual on a hand-made order would open a yearly subscription through a lane no page states the terms of — and both self-serve plans carry an annual price today. Refused, not normalized: quietly rewriting it would charge a different cadence than the order asked for. |
The page refuses, before the ask, what the order control plane also refuses at submit: a plan that is not self-serve, and a viewer who is already live on a plan (a second checkout would create a second Stripe subscription and bill them twice — switching plans means cancelling the first one at Stripe, which this lane does not guess at).
The order
A plan order is an ordinary Store/Order at {buyer}/_Orders/{id} with planTier + cadence set
instead of pluginPath (OrderContent.IsPlanOrder is the one discriminator), so the whole existing
lane — buyer-owned node, requestedAction: Submit, the watcher, the checkout-URL redirect, the
payment inbox — carries it unchanged. OrderControlPlane.SubmitPlan prices it SERVER-side off
Admin/Tiers/{id} as System and asks Stripe for a mode=subscription session with an inline
recurring price (no Stripe Product/Price administration: a re-priced plan needs a node edit only).
🚨 The price is read shape-tolerantly (Subscriptions.ReadPrice, the Parse pattern) rather
than by binding TierContent, which is compiled only inside Store/Tier — binding it would drag
that source into every consumer of the order layer. And an unreadable tier node refuses: the
Plans page falls back to the shipped seeds because a pricing page must never be blank, but this is a
CHARGE, and quoting a compiled-in number for a plan whose live terms could not be read is how a
customer is billed a price nobody offers any more.
The recurrence
🚨 The facts are stamped TWICE at submit (OrderCheckout.PlanMetadata, stamped by the
payments module's StripeGateway.SubscriptionSessionFields): on the SESSION,
which checkout.session.completed hands back for the first activation, and on the SUBSCRIPTION via
subscription_data[metadata], which every LATER event carries. Without the second copy the first
renewal arrives naming nobody and the plan lapses after one month while the card keeps being
charged.
| Stripe event | what it does here |
|---|---|
checkout.session.completed (plan) |
activates Admin/Subscriptions/{viewer} on the monthly cadence, waits for the control plane to SETTLE it (Subscriptions.ObserveActivationSettled), then stamps the order Fulfilled + stripeSubscriptionId — with the failure reason in OrderContent.Error when the plan did not come out live. RequestActivation only FILES the request, so stamping in the same breath would report a plan we had merely asked for; a fulfilled order carrying an Error is the plan lane's "granted but never installed", and a webhook REPLAY re-runs it instead of no-oping. |
invoice.paid with billing_reason: subscription_cycle |
renews — the same Activate, which extends from the current expiry, so it tops up rather than restarting the clock |
invoice.paid with billing_reason: subscription_create |
ignored. The opening charge is already granted by the checkout event; handling it too would give every new subscriber two months for one payment |
customer.subscription.deleted |
files Cancel, which runs the revocation sweep |
The invoice's copy of the subscription metadata is looked for in several places on purpose —
subscription_details.metadata up to API 2025-02, parent.subscription_details.metadata after it,
and the line item's own. An account's API version is a dashboard setting, so pinning one is not
ours to do; a reader that knew only one shape would stop renewing everybody the day it moved.
A month with no payment therefore lapses on its own: validUntil is not extended, and
IsActiveAt stops new unlocks the moment it passes.
Cancelling
The checkout page is also the MANAGE screen: it says when the next charge falls due and carries the
cancel button. 🚨 The button asks Stripe to stop renewing at the end of the period already paid
for (cancel_at_period_end=true, never an immediate cancellation — the buyer paid for this month),
and the entitlement here is ended only by the customer.subscription.deleted webhook that says
Stripe actually stopped. Cancelling locally on the click would take a paying subscriber's access
away while the card kept being charged, and would leave the two sides disagreeing if Stripe refused.
The cancel control is offered only when the record carries a stripeSubscriptionId. A plan GRANTED
by the team (cadence manual) has no card subscription behind it, and a local cancellation for one
would revoke a customer's access with nothing on the billing side having asked for it.
Not in this change (deliberately)
- Switching plans in place. An upgrade or downgrade means cancelling the Stripe subscription and opening a new one with proration; until that lands, both the checkout and the order control plane refuse while a plan is live and say to cancel first (or write to us).
- Annual self-serve. The cadence travels end to end — the order, the gateway's recurring interval, the activation, the renewal — but the checkout offers monthly only.
- Space/storage quota enforcement.
maxSpacesandstorageGbship as DATA on the tier nodes and are still unenforced; the create-time checks and the usage sampler are platform work. (Model credit used to be listed here too — it is enforced now, see above.) - The remaining-credit WIDGET.
ModelCreditGate.ObserveKeyCreditis the live probe an AI Settings line must bind to; the surface itself has not landed. planTiermarkers on LanguageModel nodes (default model policy per plan) — core work.