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, on themanualcadence (the plan that was granted rather than sold), EXACTLY the term the request carries: a coupon'stierDays, or no expiry at all when it carries none. A manual Activate whose term has already lapsed is refused naming the field to set (SubscriptionContent.TermProblem), never stamped as anActiverecord that covers nothing. It 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 — straight to the payment page, see Buying a plan with a card — for a self-serve plan, Contact us for the rest, and Cancel subscription (into the checkout surface's manage screen) on the plan the viewer holds. The page is a template: title, intro, one card per seeded plan and the footnote render at once, and each card's values (name, price, highlights, the CTA, the notice) bind into/data/storePlans, whichStorePlans.Projectfills when the three bounded reads (live tier nodes, the viewer's subscription, the licence hashes) answer. It used to wait for all three before showing anything. The cards follow the seeds' rank order; a live node that re-ranks a plan changes its values, not its position (pinned byStorePlansTemplateTests). It is also where every checkout refusal is read, on the card of the plan it is about. 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 is extendable and gains the term — that is what bounds every plan #1847 left unbounded. - 🚨 PERMANENCE IS A FACT ON THE RECORD, never the absence of a date. A plan granted on no
clock says so in
SubscriptionContent.Permanent(permanent: true), written by the activation that grants it — the manual cadence carrying no term: atierDays: 0coupon, an operator's open-ended comp.Subscriptions.ExtendsExpiryreads that fact and refuses every finite term on it, so permanence does not depend on redemption ORDER (STAFF then COURSES3M stays permanent; COURSES3M then STAFF becomes permanent). The missing expiry could never carry that meaning: a card plan has none until its first renewal, a never-activated record has none, and every plan #1847 stamped has none — so reading "no date" as "for ever" cannot tell a permanent plan from a bug, and reading it as "extendable" let the next fixed-term coupon shorten a plan somebody had typed as permanent.SubscriptionContent.NextExpiryhonours no term on a permanent plan andTermProblemrefuses none, so the fact outranks any date left on the record. A Cancel ends the permanence with the plan, and a card sale ends it too (a billed plan's expiry is the renewal's business). A record from before 2026-09-15 carries no marker and is therefore not permanent: an operator states permanence on one by re-granting the open-ended plan — the staff coupon, or any activation filed throughRequestActivationon the manual cadence with no term — or by setting the field on the record. The reverse edit, putting a term back on a permanent plan, clearspermanentand setsvalidUntilin the same patch. - 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.NextExpiryhas no period to add on that cadence and returns the term the request carries invalidUntil, AS CARRIED — stamped in the SAME activation write rather than by a second writer racing the watcher's terminal write on the same node. 🚨 Until 2026-09-14 it returnednullunconditionally, so the request wrotevalidUntil = now + 90dand the watcher overwrote it with nothing: every fixed-term coupon activated a PERMANENT plan (MeshWeaver.Plugins#1847, corrected by hand on a production record). Where a grant genuinely should not lapse it says so (tierDays: 0): the request LIFTS any finite term the record still carries (no term is the longest term there is),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. - What a lapse does today. Expiry is enforced at READ time: a lapsed plan covers nothing, so
no NEW package is unlocked on it. Grants it already minted stay until a Cancel (or a
downgrade) sweeps them —
Subscriptions.RevocableAfterLapseis the pure rule that sweep applies, and nothing runs it on a clock. A sweep that closes already-unlocked courses when the term passes is a separate piece of work, not part of the term being honoured. - 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: 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):
- 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;
- 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}); - 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:
- The settle reader could not see a live plan. On a live mesh the record reaches the payment
lane TYPED, and
NodeElementround-tripped it with no string-enum policy, sostatecame out as the ordinal2andReadActivation— which knew only the name"Done"— never settled. It now reads either spelling, andNodeElementwrites enum NAMES (the stored form). - The wait is CORRELATED. The record is one node per viewer and sits at
Doneafter every run it has ever had, so "terminal" is not "mine". The payment lane files each activation with a freshactivationRequestIdand settles only on a terminal write that carries that id with the action cleared — a leftoverDone/Cancelledcan neither pass for success nor for failure. - Exactly once per payment, decided by the record's own control plane. Activation on a billed
cadence is a top-up (that is what a renewal is), so it cannot simply be repeated. Each paid
activation carries its payment's grant key (
checkout:{sessionId}), the control plane — which serialises every run on the record — keeps the keys it has applied (appliedGrantKeys, written in the same terminal write as the extension, bounded to the newest 36), and a request whose key is already there settlesDoneWITHOUT extending ([duplicate] … already grantedon the log). A caller-side "is the plan already live?" read was tried first and rejected in review: it races a run in flight, is fooled by an older live plan, and grants again once the first grant lapsed. - A failure is a failure.
Activeis read only off a successfulDone: a refusal keeps the record's previous status, so an already-subscribed buyer whose new activation was refused must not read as live. A cancellation clears the activation correlation (id and key), so its terminal write can never pass for an activation's settle. The paid order is restamped byPaymentInboxWatcher.PlanOrderStamped— error cleared on a live plan,paidAtkept from the first stamp — and a genuine failure (a refusal, or a request that never settles) still lands its reason.
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.
- A lapse used to say nothing.
Subscriptions.ObserveActivationSettleddropped every unsettled reading, turned a failed READ into the same blank, and answered a lapsed budget with a freshActivationOutcome(false, false, null)— although its own doc comment promised "the last unsettled outcome". A record that never existed, a read that faulted, a request the control plane never picked up, a run stuck inRunningand a settle for another request all logged one identical fallback line, which is why the 2026-09-26 incident carried no cause. Every unsettled reading now carriesActivationOutcome.Reading(Subscriptions.DescribeUnsettled: what it means, then the rawstate/requestedAction/activationRequestId/error), a faulted read carries its exception type and message, and a lapse answers with the LAST reading prefixed by the budget (Subscriptions.Lapsed). The payment lane logs it afterCause:(PaymentInboxWatcher.ActivationCause: a refusal, a lapse's last reading, or a settle that came back without the plan active — never "no reading was recorded" for a settle, which is not a read failure). 🚨 What the ORDER says does not change: a lapse carries noError, so the buyer readsActivationDidNotSettleas before — and an error the record holds from an EARLIER request can never be stamped onto this payment's order. - A record left
Runningwas parked for ever. The control plane runs one request at a time inside ONE activation of the record's hub and skipped any emission whose state wasRunning("a run is in flight"). But an activation that ends mid-run — a pod roll, a recycle, a crash betweenStartand the terminal write — leaves the recordRunningwith its action still set, and the next activation then skipped it on every emission: every later request, a paid checkout and every renewal included, was patched onto a record nothing would run again, and each settle wait lapsed. The watcher now knows whether an emission is the FIRST its activation has seen (SubscriptionContent.ShouldRun(firstSight)/IsAbandonedRun): aRunningrecord on first sight cannot be a run of this activation, so it is resumed ([resumed]on the log, a warning in the portal log) — unless its request carries a problem, in which case it is FAILED, asShouldRunrefuses it, and nothing announces a resumption. Re-running is exactly-once safe — the terminal write is ONE write carrying the extension and the grant key together, so a run that never reached it applied nothing. ARunningrecord seen mid-activation still blocks, so the watcher's ownStartcannot launch a second run.
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.
- Manage billing opens the payment provider's hosted billing portal (for Stripe the Customer
Portal: card, invoices, cancel) through the platform contract (
IPaymentProvider.OpenBillingPortal), named by the customer the checkout recorded (stripeCustomerId, on the record and the order) or, for a plan sold before that was recorded, by its subscription. It is offered only when the provider says it offers a portal. A cancellation made ON the portal ends the plan exactly like the button does: Stripe sendscustomer.subscription.deletedat the period end. - Cancel now asks first, in the framework dialog — what ends, when, and that nothing more is charged — and only the affirmative button goes to the provider.
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)
- Switching plans in place. An upgrade or downgrade means cancelling the Stripe subscription and opening a new one with proration; until that lands, the card, the checkout and the order control plane refuse while a PAID plan is live and say to cancel first (or write to us). A live free plan is not a plan to switch from — it is replaced.
- A purchase superseding a GRANTED paid plan. Refused today (see the table above); whether a card purchase may replace a manual grant, and what happens to the grant's remaining term, is a decision nobody has made yet.
- 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.
Access comes only through a plan (2026-09-30)
Maintainer: "no one should have any access, just through subs and tiers."
- Every signed-in viewer holds a plan.
Subscriptions.Effectiveis the viewer's liveAdmin/Subscriptions/{viewer}record, or — when there is none, it lapsed, or it ranks below — the instance's IMPLICIT plan:Store:InstancePlan(envStore__InstancePlan, a tier id), elsefree. An unknown id reads asfree; a typo never widens a licence. Anonymous holds no plan. - A price-0 package is the free tier, acquired like any tier: the cover's Get → the Subscribe
page →
Subscriptions.Unlockunder the effective plan. Landing on a cover, opening the Store and the Store hub coming up grant nothing. OnlyPreInstalledpacks self-install, and they need no grant. - An implicit-plan unlock writes the
_Accessgrant and theplan-{plugin}-{viewer}ledger row (no unlock record — there is no subscription node to hang it under). - Enterprise / dedicated estates set
Store__InstancePlan: enterprise(ordedicated) on the portal, so every user there is covered by the instance's licence and acquires packages on their own Get. - Free-grant retraction (
Store/Catalog/Source/FreeGrantRetraction.cs, once per Catalog hub activation) findsfree pluginledger rows no other acquisition backs and removes the matching{viewer} — Viewer + Commentergrant, the_Entitlementsmarker and the row. It is a dry run (logs[FreeGrantRetraction] DRY RUN …counts) untilStore__FreeGrantRetraction: Applyis set. Turn it on per instance only once that instance's users are covered by a plan (enterprise estates: setStore__InstancePlanfirst). Deletes notify nobody.