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.jsoncontent.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, enterprisevalidate-repos.pycheck_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

/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:

  1. the plan and the terms in one sentenceCHF 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. what the plan includes;
  3. the billing detailsBillingForm, 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);
  4. 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.ViewerZoneIdChromeLocale.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)

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