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).

Surfaces

Model credit is METERED (#1100)

The first of the plan caps to gain enforcement. includedModelCredit is no longer display-only:

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.

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: Subscribe goes STRAIGHT to the payment page

Clicking Subscribe on a Plans card opens Stripe's hosted payment page. There is no page of ours in between — no confirmation card, no billing form, no licence page. Everything the buyer must have read before paying therefore sits on the CARD, around the link (StorePlans.Offer / StorePlans.SubscribeTerms):

  1. the price, 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;
  2. for a plan with a licence to accept, the clickwrap sentence: by subscribing you accept the licence of this plan, linking its text (Store/Licences/{Tier});
  3. the Subscribe link, /Store/Subscribe?plan={tier}&accept={hash} — the hash of the licence text the card linked to (Subscriptions.SubscribeLink).

/Store/Subscribe (PlanCheckout, a layout area on the store's catalog type; Subscriptions.CheckoutPath is the ONE definition the cards link to, the area is registered from, and the Stripe return URLs are built from) is then a HAND-OFF, not a page: for a signed-in buyer who may buy the plan it creates the plan order and redirects to the checkout URL the order control plane stamps (PlanCheckout.Decide → HandOff). An anonymous buyer is sent straight to sign-in, with the whole link — acceptance included — as the return URL, and lands on the payment page after. The card details and the billing address are taken on Stripe's page (billing_address_collection=required, StripeGateway.SubscriptionSessionFields), prefilled with the email of the buyer's billing profile when they have one.

🚨 Every outcome that is not a hand-off renders the Plans page, with the reason on the card of the plan it is about (StorePlans.View + PlanNotice) — a refusal, a failed order (the control plane's own Error), an abandoned payment (nothing has been charged), an acceptance the link did not carry. The buyer stays where the Subscribe link is, and the link is the retry; nothing is a dead-end card. A return from Stripe is never handed off again (an abandoned payment re-opening the page it was abandoned on is a loop). The two screens the surface keeps are both AFTER a purchase: the payment received notice Stripe returns to, and the MANAGE screen of the plan the viewer holds.

🚨 A buyer who comes back resumes, never re-orders. Because the hand-off happens on arrival, the browser's back button from Stripe, or a second click after abandoning the payment, would otherwise create a second order — which the per-buyer checkout claim rightly refuses while the first is open. The hand-off therefore reads the claim first and, when it names an order for the same plan, the same accepted text, still Created/PendingPayment and younger than 23 hours (Stripe's session lives 24), follows THAT order to its payment page (PlanCheckout.MayResume). An order in flight for a DIFFERENT plan is not resumed: the new order meets the claim and is refused, on the card, with finish or cancel it before starting another.

🚨 Paid, but not activated, is never sold again. The payment webhook stamps a plan order Fulfilled even when the activation fails, with the reason on OrderContent.Error (so a replay repairs it). That buyer holds no live plan, so no refusal fires, and a Fulfilled order used to release the checkout claim, which let a second checkout charge them twice. Now CheckoutClaim.PaidButNotActivated holds the claim whatever its age (CheckoutClaim.MayTakeOver), and the hand-off shows your payment was received, the plan is not activated yet, nothing more will be charged on the card. The repair (a webhook replay, or our team) clears the error and releases the claim.

A billed activation that replaces another tier also sets the record's status to None in the same patch. The watcher's Start()/Fail() carry status over, so an inherited Active would publish the PAID plan, with no expiry, before the activation ran, and for ever if it failed. Only Activated(...) publishes it.

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 four guards

guard what it stops
the licence (OrderContent.AcceptedLicenceHash, PlanLicences) a subscription that starts on terms the buyer never saw. The Plans card states, beside the Subscribe link, that subscribing accepts the plan's licence and links its text (Store/Licences/{Tier}); the link carries the HASH of that text, the hand-off copies it onto the order, and a link carrying none (or one of a text that has since changed) is not handed off — the Plans page asks for the click again, on the card. A card whose licence cannot be read offers no link at all. SubmitPlan re-reads the licence as System BEFORE the claim and refuses when the order carries no acceptance, when the licence cannot be read, or when its text no longer hashes to what was accepted. The acceptance is then written as System to {buyer}/_LicenseAcceptance/plan-{tier} (core's LicenseAcceptance) BEFORE the Stripe session exists, and a write that does not confirm fails the order. Free (Apache-2.0) asks for nothing; the all-access plan is dedicated, and the legacy sme id resolves the same document.
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 card and the hand-off refuse, before any order exists, 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 paid plan. All three read ONE rule, Subscriptions.BlocksPlanPurchase:

the viewer's live record buying Personal / Pro
none, lapsed, or cancelled allowed
free — however it came to be Active 🚨 allowed. There is no card subscription to cancel and nothing to bill twice; the purchase REPLACES the record (below). Measured on the public instance 2026-09-26: a tier: free, status: Active record refused Personal with "Cancel it before subscribing" — cancel what?
the SAME plan refused — it is the manage screen, never a second purchase
another PAID plan on a card refused — a second checkout is a second Stripe subscription, and the buyer pays twice. Switching means cancelling the first at Stripe, which this lane does not guess at
another PAID plan GRANTED by our team (manual — a coupon's term, a comp) refused, for now. Nothing is double-billed, but replacing a grant silently throws away a term somebody decided on (a permanent staff plan, 90 days of a course coupon) — whether a purchase may supersede a grant is undecided, and until it is decided it is a conversation (write to us and we'll move you across)
an UNKNOWN tier (e.g. the legacy sme) refused: refusing costs a message, guessing wrong costs a double charge

Fulfilment replaces the record. checkout.session.completed activates through Subscriptions.RequestActivation → WithActivationRequested on the EXISTING record: the tier, the cadence (monthly) and the Stripe subscription id become the sold plan's, and permanence is cleared (a billed plan's expiry is the renewal's business). 🚨 A sold plan that replaces another tier starts its own clock: the watcher tops a billed activation up from a live expiry (early renewal), so a free record carrying a term would otherwise hand the paid plan that term plus a month — months nobody paid for. A billed activation onto a record of a DIFFERENT tier therefore drops the old validUntil in the same patch, and the paid month starts now; a renewal of the same tier still tops up.

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. See One payment, one grant below for the two rules that keep this honest.
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.

One payment, one grant — and no failure over a live plan

Measured on the public instance on 2026-09-26 (order rbuergi/_Orders/order-e2ab8e9e…, subscription sub_1UJxLY…): the checkout delivery arrived at 15:17:06, the plan went Done/Active at 15:17:09 — and the paid order was stamped "activating the plan did not finish" when the 45 s settle budget ran out. The same checkout was then fulfilled a SECOND time, which filed a second Activate (the record logs two [start] Activate … personal (monthly) runs naming the same order and subscription) and pushed validUntil two months out for one payment. Two defects, one fix set:

A wait that lapses names its cause; a run abandoned mid-flight is resumed

Two residual defects on the same path (MeshWeaver#5758), both in the SETTLE half — the fix set above made the activation exactly-once; these make a lapse diagnosable and stop one cause of it.

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.

Managing the plan — one panel, three places

SubscriptionPanel is the ONE rendering of a plan the viewer holds: the plan and its price, the failed-payment banner, when it renews (or until when a granted plan runs), Manage billing and Cancel. It renders in three places: the viewer's profile page, as its Subscription section (Store/ProfileSections/subscription — a plain UiContribution node with context: Profile, address: Store, area: MyPlan, owner-only; its id is the section id, so the anchor is #profile-section-subscription); the standalone /Store/MyPlan (Subscriptions.ManagePath), which stays a working direct URL; and the checkout's own "your plan" screen. Every link is built by Subscriptions.ManageLink(viewer) and lands on the profile section: the Plans page ("You're on the X plan — manage it"), the post-payment screen (Manage your plan) and the billing portal's return URL. Only a link with no viewer to name (a sign-in return) points at /Store/MyPlan.

A failed charge — past-due, never revoked

invoice.payment_failed marks the record paymentStatus: PastDue (+ paymentFailedAt, the FIRST failure) through Subscriptions.RecordPaymentFailed, and only when the record is live on the failing subscription. 🚨 It is a field BESIDE status, never a new status value: every coverage reader — the paywall and the model-credit meter — grants on status: Active, and a failed charge does not end a period that is paid through validUntil while Stripe retries. The plan page and the Plans page show the banner ("Your payment failed — update your card") with Manage billing; the next invoice.paid (the successful retry of the same invoice, or the next cycle) activates the plan, which clears the mark; a Stripe that gives up sends customer.subscription.deleted, which ends the plan as usual.

One invoice, one period

A renewal carries its invoice as the grant key (invoice:{id}), so a redelivered invoice.paid is recognised by the record's control plane and extends nothing — the same exactly-once rule as the checkout (One payment, one grant above).

Not in this change (deliberately)

Access comes only through a plan (2026-09-30)

Maintainer: "no one should have any access, just through subs and tiers."