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 |
customer.subscription.updated whose previous_attributes names cancel_at_period_end or cancel_at |
records "will not renew" (or clears it when withdrawn) on the plan record — no entitlement change; see Cancelling |
customer.subscription.updated, anything else |
ignored — the renewal flag did not change |
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.
🚨 Once Stripe accepts, the request is RECORDED on the plan record (#3548):
cancelAtPeriodEnd: true and cancelRequestedAt, beside status and never part of it — the plan
stays Active and keeps covering what it covers until validUntil, exactly as a past-due mark does.
Every surface reads it through SubscriptionFact.CancelAtPeriodEnd: the manage panel says "will not
renew — active until …" instead of "renews on …", offers no second Cancel (Manage billing stays,
which is where a cancellation is undone), and the Plans card's link reads Manage your plan. Before,
the confirmation was transient layout state, so a reload said "renews on" again with no trace of the
request anywhere. The write is authorised as the caller (the viewer's own panel, which read the
subscription id from that very record, and Stripe has accepted it) and runs as System, because a
viewer cannot write Admin. Only the record live on that subscription is marked, and
RecordCancelRequested reports whether the record now carries the request: when it was replaced or
ended while Stripe was being asked, nothing is marked and the panel says the cancellation could not be
recorded (reload to see the current plan) instead of confirming it.
🚨 An activation does NOT clear it (#3593). A renewal (invoice.paid) for the period paid
before the cancel can settle after it — still in flight when the plan page's cancellation
succeeded, or delivered late — and the activation cannot tell that stale renewal from one after the
subscriber resumed: both are processed after the cancel. Clearing on every activation erased a
cancellation Stripe still held, and the page said "renews on" again. So Stripe's own renewal flag is
the authority: its "renews again" report (customer.subscription.updated, below) clears the
marker, the ending (customer.subscription.deleted) clears it, and an activation request that puts
ANOTHER subscription behind the record — a new checkout, or a manual grant with none — drops it,
because a marker belongs to the subscription it was recorded on. A renewal of the same subscription
keeps it.
🚨 A cancellation made in the Stripe Customer Portal is recorded the same way (#3548, decided by
the maintainer: subscribe to customer.subscription.updated). Stripe reports the request — and its
withdrawal, when the subscriber changes their mind on the portal — as customer.subscription.updated.
The Stripe provider turns it into the payment contract's renewal-state change
(PaymentSubscriptionEvent.Renewal: CancelsAtPeriodEnd or Renews, the open vocabulary
PaymentSubscriptionRenewal), and the inbox applies it with Subscriptions.RecordRenewalChange:
"will not renew" sets cancelAtPeriodEnd + cancelRequestedAt, "renews again" clears both, and the
panel follows. Three rules keep it honest:
- Only a change counts. Stripe sends
customer.subscription.updatedfor every change to the subscription — a new period, a new card — each stating the current flag. The provider reports a renewal change only when the event'sprevious_attributesnamescancel_at_period_endorcancel_at, so any other update leaves the record untouched. Both fields count as "will not renew", because Stripe has recorded a portal cancellation each way depending on the account's API version. - Ordered by when Stripe says it happened.
renewalChangedAtholds the event time (Stripe'screated) of the last applied change; a change no newer than it is a redelivery or a delayed older delivery and writes nothing, so a replay is a no-op and a late "renews again" cannot undo a newer request. A newer report of the state the record already shows moves only the anchor, with no log line. The plan page's own Cancel sets the anchor too, so Stripe's echo of that click (stamped a moment earlier) writes nothing. - No entitlement changes. As with the plan page's Cancel: the plan stays
ActiveuntilvalidUntil, andcustomer.subscription.deletedends it then.
customer.subscription.updated is therefore one of StripePaymentProvider.RequiredEvents. An
endpoint registered before it was required lacks it, and the payment-path audit names the gap; the
EnsurePaymentEndpoint maintenance task adds it (dry run, then execute: true with confirm set to
the mode), keeping the signing secret — see Payments.
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 is recorded when it is made (customer.subscription.updated, see Cancelling) and ends the plan exactly like the button does: Stripe sendscustomer.subscription.deletedat the period end. The portal session is created on the server after the click, so the panel does not redirect the tab. Once the session exists it shows Open billing page ↗, a link button (ButtonControl.WithOpenInNewTab, renderedtarget="_blank" rel="noopener"). The provider's page opens in a new tab from the buyer's own click, so no pop-up blocker refuses it, and the portal stays open. Following the link resets the panel, so the next Manage billing asks for a fresh, short-lived session (#3547). - 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
plan-{plugin}-{viewer}ledger row FIRST and then the_Accessgrant (no unlock record — there is no subscription node to hang it under, so the ledger row is the grant's provenance; see "Provenance" below). - Enterprise / dedicated estates set
Store__InstancePlan: enterprise(ordedicated) on the portal, so every user there is covered by the instance's licence — and since 2026-10-10 that coverage IS access, with no Get at all (next section). - 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.
A plan that covers a package IS access — nothing is acquired (2026-10-10)
Maintainer: "i should not need to get anything for crying out loud. the sub should give me access", then "we do not want to get literally anything".
The rule (Store/Licensing/Source/PlanAccess.cs). When the INSTANCE's plan
(Store:InstancePlan, a known tier; PlanAccess.InstancePlanOf) covers a package's tier, every
signed-in user reads the package. No click, no hidden Get, no unlock record, no per-viewer grant, no
notification. PlanAccess.ShapeOf(instancePlan, packageTier, audience, now) decides it once per package (a
MALFORMED audience — not a list of strings — stays gated):
| Shape | When | What the gating writes |
|---|---|---|
Gated |
the instance's plan does not cover the tier, there is no instance plan, or the tier is blank or unknown | today's paywall: Public + Anonymous deny on every gated child |
OpenToSignedIn |
covered, no audience | NO Public child deny (the root's Public Viewer grant inherits down); the Anonymous deny stays, so anonymous visitors see the cover only |
Audience |
covered, and the root _Policy names an audience |
the gated shape plus one Viewer grant per audience id at {package}/_Access/audience-…_Access |
Why the gating writes it, rather than the permission fold computing it. The rule's output is
ordinary AccessAssignment rows. The C# permission fold reads them, and so does the PostgreSQL
projection that search and listings filter on (partition_access, the per-schema rebuild). Search
visibility therefore follows the read check, with no second implementation of the rule in SQL. The
instance's plan is a fact about the instance, so the decision is per PACKAGE: no viewer is ever
swept, and nothing is written when somebody opens anything.
Lapse. Every gating pass recomputes the shape (hub start, any real subtree change, an audience edit). An instance whose plan no longer covers the package gets its Public child denies back, and its audience grants are retracted. Configuration changes reach a running portal at its next start.
Audience. The audience is PartitionAccessPolicy.Audience (core) on {package}/_Policy, a
list of strings with the wire name audience. Each entry is a USER id or a GROUP path; group
membership counts transitively (GroupMembership, like the fold). Role ids are not subjects of the
fold and are not supported. The reconcile never edits the audience; when it repairs the policy it
re-writes the audience it read. An administrator's edit triggers a pass
(PlanAccess.AudienceEdit); the reconcile's own policy write never does. With no audience, the
behaviour is plan-only. Explicit grants (purchases, coupons, admin enrolments) still add on top.
The audience caps ALL plan-derived access. A legacy plan unlock (the old Get, or the Subscribe
page's arrival unlock) minted a package-wide {viewer}_Access grant that an audience would not
narrow. Once the instance's plan covers the package those grants are redundant, so the gating
retracts every one that the records mark as plan-derived. The records are the plan-… row in
Admin/Purchases or the subscription's unlock record. A grant that an ETERNAL marker
({package}/_Entitlements/{viewer}: a purchase, a coupon, an admin grant) also backs is never
touched, audience or not, and neither is a hand-made grant with no plan record. On a covered package
Subscriptions.Unlock mints nothing and the paywall offers no plan unlock, so a viewer outside the
audience sees the ordinary paywall and cannot re-acquire around it.
What a covered viewer sees. The cover, the CTA row and the paywall treat a plan-granted viewer
as an owner (PlanAccess.CoveredOrEntitled): straight into the content, "Open", never Get or
Subscribe. "Your plan" on /Store/Plans reads the EFFECTIVE plan and says
"Your plan: Enterprise — this instance's plan." (it used to show nothing for an instance plan).
An uncovered viewer sees exactly the paywall, plans, coupon and contact they saw before.
API for other surfaces (the app launcher).
PlanAccess.Covers(hub, viewer, packageRoot) : IObservable<bool>is the plan-only half. It is true when the viewer's EFFECTIVE plan (their record or the instance's) covers the tier.PlanAccess.Grants(hub, viewer, packageRoot) : IObservable<bool>is the plan grant AS THE READ CHECK SEES IT. It is true when the instance's plan covers the tier and the permission fold actually grants the viewer Read on a gated child of the package. The audience membership (transitive groups included) is the fold's own decision, and before the gating pass has written the shape, or after a failed pass, it answers false: never "Open" over a refused read. It is live, its first answer is bounded, and it fails closed. Anonymous is always false. EVERY gated child must read, with one exception: a package's private segments (privateSegments, the data it processes rather than sells) refuse every entitled viewer by design, so they are not children the grant waits on (PlanAccess.ChildrenThePlanOpens). Until that exclusion a covered package that declares one — Governance does — granted nobody: the cover kept its paywall over content the viewer could open, and a dependency install refused them as "outside its audience".
Provenance: a plan grant is always told from a hand-made one. A grant the plan path mints must
be retractable with the plan, so it needs a record that says the plan minted it. The access model's
own mark only goes halfway: a grant written by the platform carries createdBy = System (the field
core's access-grant notifier reads to tell an entitlement from a person sharing something), which
separates it from a hand-made grant but not a plan unlock from a purchase. The Store's records do
the rest, and the rule is that the record is written BEFORE the grant
(Subscriptions.ProvenanceWrites):
| The grant was minted by | Its record, written first | What reads it |
|---|---|---|
| a purchase, a coupon, an admin grant | the eternal marker {package}/_Entitlements/{viewer} |
never retracted |
| an unlock under a subscription record | the unlock record Admin/Subscriptions/{viewer}/{package} |
the cancellation sweep; the gating |
| an unlock under an IMPLICIT plan | the plan-{package}-{viewer} row in Admin/Purchases, naming the plan |
the gating (coverage and lapse) |
The third row is the one that used to be missing. An implicit plan has no subscription node to file an unlock record under, so the unlock wrote nothing first and appended its ledger row afterwards, best-effort: a failed append was swallowed and left a package-wide grant with no record at all. Such a grant cannot be told from a hand-made one, so nothing retracts it and it outlives an audience restriction and a plan lapse. The ledger row now takes the mandatory first slot; if it cannot be written, no grant is minted.
What retracts an implicit-plan unlock. The gating pass reads the provenance both ways
(PluginGate.SeedGating):
- The instance's plan covers the package — the grant is redundant, and is retracted so plan plus
audience is the single source of plan access (
PlanAccess.RetractsLegacyUnlock). - The plan stopped covering the package — the instance's plan was lowered or removed, or the
package's tier was raised past it. A record-backed unlock is the cancellation sweep's, but an
implicit plan has no subscription record for the sweep to walk, so the gating is the only thing
that can find this grant. It retracts it when the
plan-…row marks it, no unlock record does, no eternal marker backs it, and the viewer's EFFECTIVE plan (their own live record, or the implicit plan) no longer covers the package's tier (PlanAccess.RetractsLapsedPlanUnlock). Every read is targeted and a failed one skips; the marker is re-read at deletion time, as for every retraction.
Both run whenever the package's gating pass runs: on its hub's start (an instance-plan change is a configuration change, so a restart) and on any change in its subtree (a tier change is one).
The grants that predate the rule: MarkPlanGrants. A Store/Maintenance task
(Store/Licensing/Source/PlanGrantBackfill.cs) classifies every package-wide viewer grant
(PlanAccess.ClassifyRootGrant):
| Class | Decided by | A real run |
|---|---|---|
Eternal |
the eternal marker exists (decided first) | leaves it alone |
PlanDerived |
a plan record exists | leaves it alone |
UnmarkedPlanUnlock |
no record; written by System and never edited by a person; exactly the unlock's roles (Viewer, with or without Commenter); created on or after the day the implicit unlock shipped | files the missing plan-… row |
Unclassified |
anything else: a hand-made grant, a wider role, an older grant, a lost stamp | leaves it alone |
The third class is safe to infer because every OTHER System writer of a root grant writes the
eternal marker before it (Entitlements.GrantFrom), so a System grant with no marker is the plan
path's. To run it, an administrator creates a Store/Maintenance node under Admin/Maintenance:
{ "task": "MarkPlanGrants", "package": "Underwriting", "requestedAction": "Run" }
package is optional (blank = every package). That is a dry run: it writes the reviewable
report to {request}/Report — the counts per class, every grant with its class, and every read
that failed — and marks nothing. A real run adds "execute": true, "confirm": "MARK-PLAN-GRANTS".
It is refused while any part of the inventory could not be read (a subset is not the grant set),
re-reads each grant at the moment it marks it, deletes nothing and changes no grant itself. The
mark lands in Admin/Purchases, outside the package's subtree, so it does not wake the package's
gating watcher: the run therefore runs the ordinary gating pass on every package in which a mark
landed, and a pass that fails makes the run's summary a ❌ naming the package. That pass then
treats the marked grant like every other implicit-plan unlock (above): retracted where the
instance's plan covers the package, so a viewer inside the audience keeps reading through the plan,
and retracted where the plan no longer covers it.
Not in this change. A PERSONAL plan above the instance's plan (a Store/Subscription record)
is a fact about one viewer. It still goes through the Subscribe surface's arrival unlock
(Subscriptions.Unlock, revocable on lapse). Expressing it as data, for example as plan groups that
the subscription control plane maintains, is the follow-up that removes that last automatic Get.
On an instance that states NO plan, the free plan every account holds still acquires per viewer.
Opening every free package to every account there is a separate decision.