18 KiB
title, audience, summary
| title | audience | summary | ||
|---|---|---|---|---|
| Plan Management |
|
An operator's guide to configuring plan ladders, per-org-type default provisioning, issuing/revoking/extending grants, downgrade and clamp behavior, and auditing enrollment history. |
Plan Management
This document describes how operators configure plan ladders, manage automatic provisioning, perform manual transitions, and audit enrollment history.
For how the plan ladder model works underneath these procedures (the object graph, the conferral functions, and the invariants that govern position changes), see the Plan Ladders and Transitions card.
Overview
A plan ladder is a ordered set of plan products. Each organization is enrolled on at most one tier per ladder at a time. Transitions between tiers — upgrades, downgrades, initiations, and ends — are recorded in the transition history for audit.
When to use a plan vs. a regular product
Plans are an opt-in modeling choice for products that follow tier semantics: an organization should be on exactly one tier of a ladder, and moving between tiers means leaving the previous one. If that's not what you want, use a regular product instead.
Use a plan when:
- The product belongs to a graded set (e.g., Free → Standard → Pro) and an organization should hold exactly one at a time.
- You need an audit trail of transitions between tiers (who moved the org, when, why).
- Stripe subscription state (created/upgraded/downgraded/cancelled) should drive the org's tier directly.
Use a regular product (addon, usage, one-time) when:
- You want to grant the same thing multiple times (multiple gift cycles, stacked credits, parallel benefits).
- The product is independent of any tier — it adds to an org's entitlements rather than replacing them.
- You want simple grant/revoke semantics without ladder transitions or supersession.
If you find yourself wanting to grant the same plan twice to the same org, consider whether you need an extend (same tier, new grant) or whether the product should have been modeled as an addon. Plans enforce one-tier-per-ladder by design; addons and usage products do not. The Extend Current Tier action exists precisely for the legitimate case of issuing another grant on the same tier.
Developers: this distinction is enforced at the catalog layer (membership in
plan_ladder_tiersis opt-in per product). See the Plan Ladders and Transitions card for the runtime consequence: conferral only creates ladder-occupancy rows when the conferred product is a tier on a ladder.
Making a plan available for purchase
Creating and publishing a plan product is not enough for a member to buy it. A product becomes purchasable only after it has passed every step on this path:
- Draft → entitlement rules. Create the product and attach an entitlement set whose rules define the quantitative limits the tier grants (e.g.
sites,storage_mb). - On a ladder. Put the product on a plan ladder as a tier. This is what makes it a plan — a product's plan-status is its ladder membership (Doc 31 Amendment #3 / Decision 121). A plan that is on no ladder is not a plan at all, and is invisible in the member catalog. To sell a single standalone plan, give it its own single-tier ladder — that is the idiom, not an off-ladder product.
- Published, public, active. Set
lifecycle_status = published, and mark the product Active and Public so it appears in the member catalog. - Priced. Add an active price on the product's Prices view.
- Stripe-synced. The price must be mapped to a live Stripe price. Creating a price enqueues this sync automatically; until the mapping lands, the product shows sync pending and is not yet purchasable.
When all five hold, the member's plan card renders an enabled buy/upgrade control. Until then the card renders disabled ("Not available for purchase yet").
Reading the purchasability panel
The operator product edit page shows a Purchasability panel that evaluates each step above against the live state of the product and renders a single verdict — ✓ Purchasable or ⚠ Incomplete, naming exactly which preconditions are unmet, with inline guidance for each. The verdict is computed from the same gate the member catalog uses, so the panel never claims a product is purchasable while a member sees it disabled.
The panel also flags the invisible-limbo case: a published product that is neither on a ladder (so it is not a plan) nor assigned a product type. Such a product appears nowhere in the member catalog. Remediate it by putting it on a ladder (to sell it as a plan) or by setting a product type (add-on / usage / one-time) — typed products are purchasable off-ladder.
A NULL
product_typeis correct for two kinds of product: a plan (whose kind is derived structurally from its ladder membership) and a draft (not yet published). A published NULL-type product on no ladder is neither — it is the limbo the panel warns about.
Ladder Configuration
Creating a Ladder
- Open the Operator panel and select the Plan Ladders tab.
- Enter a unique slug (kebab-case identifier), a display name, and an optional description.
- Click Create Ladder.
Attaching Tiers
- From the ladder list, click Manage on the ladder you want to configure.
- Select any published product (Doc 41: display category never gates tier membership).
- Click Add Tier — the tier appends at the bottom (rank self-assigns) and you drag it into position. If the product already carries live provisions, the induction backfill aligns them to the grown shape in the same transaction.
Tiers within a ladder are ordered by rank. Lower ranks are "lower" tiers; higher ranks are "higher" tiers. The typical pattern is:
- Rank 0 — Public / Free tier
- Rank 1 — Standard tier
- Rank 2 — Pro tier
Reordering Tiers (Drop → Preview → Commit)
Tiers are reordered by dragging rows on the ladder detail page — and the drop saves nothing. It renders a server-side preview of the pending order inside the tier card, with Commit and Discard:
- If the pending order keeps rank 0 (or the ladder is no org type's default), the preview says "display order only" and committing just renumbers ranks contiguously (
0..N-1). - If it changes rank 0 of a ladder that is some org type's default, the reorder is a default change wearing a drag gesture: the preview classifies the affected organizations exactly like the org-types card (see Changing the Default below) and the commit requires a disposition — grandfather or migrate — for organizations holding the outgoing rank-0 through the type default. Plan-less pools of those types are initiated onto the new rank 0.
Member positions bind by product, so reordering never moves an existing member between plans; only the display order and the rank-0 provisioning default change.
Removing Tiers
Removing a tier with no live holders is a plain confirm-modal delete (the modal notes when deleting the top tier promotes the next tier to the new-org default). Removing a tier that has live holders is permitted and follows the same drop-preview-commit shape as reordering — clicking Remove renders a server-side preview classifying every holder by the source of their position:
- Held from another source (subscription, operator/legacy grant, purchase): on commit their position on this ladder ends via shape alignment, but delivery continues — entitlements come from the product, not the ladder slot. Nothing is bought, granted, or revoked.
- Held as the org type default: the commit requires a disposition — Keep (same align-shrink; the org keeps these entitlements off-ladder, and the preview says plainly that the current type default will still arrive at the org's next floor restoration, since an off-ladder product cannot block the floor) or Migrate (end the default-sourced position and apply the current default immediately — no later double-baseline). Removing the default ladder's rank-0 tier with Migrate lands holders on the promoted tier.
The commit is atomic: tier deletion, renumbering, and every holder reconciliation run in one transaction — a failure rolls back everything, tier included, so no provision is ever left mismatched with its product's shape.
Deleting a Ladder
A ladder can be deleted only when it has no active attachments across all its tiers. Deleting a ladder also removes all tier memberships.
Auto-Provisioning Policy
Per-Organization-Type Default Plan
Each organization type can have a default plan configured by pointing at a core.plan_ladders row. The default tier is always the rank-0 tier of that ladder, resolved at use-time. When a new organization of that type is created, the rank-0 product of the configured ladder is automatically granted to the organization's default pool.
The dropdown in the Operator panel sources from plan ladders only — every option renders as LadderName — rank-0 product name so the resolved default is visible inline. Products that are not the rank-0 tier of any ladder cannot be set as defaults; the Plan Ladders tab is the single source of truth for which products are eligible.
Changing the Default (Preview → Commit)
Selecting a different default in the dropdown mutates nothing: it immediately renders a server-side preview inside the org type's card, classifying every existing organization of the type:
- would initiate — organizations with no plan; the commit always initiates them onto the new default (the default is a floor — there is no opt-out, because any later plan ending on a skipped pool would restore the default anyway)
- already at the default — default-sourced positions already delivering the candidate's rank-0 product; never touched (this is what makes re-running a commit a no-op)
- outgoing default — organizations holding the previous default (named individually); the commit requires a disposition for them:
- Grandfather — reissue their current plan as
legacygrants attributed to the committing operator (transfer-supersedes the old default position; the legacy grant is an ordinary grant — visible in the grants views, revocable, expirable, so the clause has a sunset lever) - Migrate — end the outgoing position and apply the new default (
end+initiatein the audit trail); with a None candidate this ends the position with no replacement (the pool legitimately goes off-ladder)
- Grandfather — reissue their current plan as
- another source — positions held by operator/legacy grants or subscriptions; never touched
- would fail — organizations that can't be classified (e.g. no default pool), listed with reasons
The Commit default change button lives inside the preview, so the mutation is unreachable without seeing its projection. The commit updates the org type, then enacts each pool in its own locked transaction (re-classifying in-transaction, so preview-to-commit races resolve safely), and reports actual per-bucket counts with a per-org failure list. Failed organizations are skipped; fix the cause and commit again — completed dispositions are not repeated.
Manual Grant Workflows
Issue Grant
An operator can issue a grant to move a pool to a specific tier:
- Open the Operator panel and select the Enrollment tab (or click Enrollment from the Organizations list).
- Select the target organization.
- In the Issue Grant card, choose a plan product, optionally set a valid until timestamp, and provide a reason.
- Click Issue Grant.
The system:
- Creates a grant with
grant_reason = 'manual'(or'trial'ifvalid_untilis set) - Calls the conferral boundary (
internal/entitlements/conferral.go) through the operator handler ininternal/server/operator_enrollment.go: it writes the grant decree, confers the position onto the organization's default pool, then materializes the organization's effective entitlements - Registers a Temporal workflow to expire the grant at
valid_untilonly for trials
A grant with no valid_until is permanent. A grant with valid_until is a time-bounded trial; when it expires, the pool automatically transitions back to the organization's default tier (or detaches if no default is configured).
Grant Revocation
An operator can revoke an active grant:
- On the enrollment page, locate the grant in the Active Grants table.
- Click Revoke.
- Confirm the action.
The system revokes the grant, then calls the conferral boundary (internal/entitlements/conferral.go) through the operator handler in internal/server/operator_enrollment.go: the handler ends the position first, then separately calls the guarded default restoration, which returns the pool to the organization's default tier only when the pool is left with no live position on any ladder.
Extend Current Tier
An operator can issue a new grant on the pool's current tier without changing position:
- On the enrollment page, locate the Extend current tier card (visible only when the pool has an active plan attachment).
- Provide a reason (e.g., "extending trial", "comp cycle").
- Optionally set Expires at — when present, the new grant has a
valid_untiland a Temporal expiration workflow is scheduled. When empty, the extension is open-ended (comp/gift case). - Click Extend tier.
The system:
- Creates a new grant chained to the prior grant via
extends_grant_id - Calls the conferral boundary (
internal/entitlements/conferral.go) through the operator handler ininternal/server/operator_enrollment.go: because the new grant declares that it extends the prior grant, conferral recognizes the same source continuing and creates a new provision and ladder attachment at the same tier, taking over the position from the prior grant - Records the change as a
transferaudit row, not anextendrow (noextendtransition type exists); the row'sfrom_rankequals itsto_rankbecause the tier does not change - Schedules
GrantExpirationWorkflowkeyed off the new grant's ID whenExpires atis set, so trial extensions expire on their own clock and don't ride the original trial's expiry
Use this to extend a trial that is about to expire (set a new Expires at), comp an additional cycle during an outage, or gift a second period without disturbing the existing audit trail.
Downgrades and the tier-reduction policy
A pool moves to a lower tier, or off the ladder entirely, in three cases: a Stripe subscription cancellation (reconciled by internal/fulfillment/reconcile.go, which calls the conferral boundary directly to end the position), an operator Revoke, or a trial grant expiring. In every case, ending the old position and restoring the default are two separate steps, because ending never re-mints the default by itself: each entry point ends the position first, then separately calls the guarded default restoration (ReapplyDefaultsIfVacant in internal/entitlements/reapply_defaults.go), which checks whether the pool is left with no live position on any ladder before acting. When the organization type has a default plan ladder configured and the pool is vacant, the restoration confers that ladder's rank-0 product and records the new position as an initiate, not a downgrade, because it is a fresh position rather than a step down from the one that just ended. When no default is configured, the pool legitimately goes off-ladder and the restoration records nothing further.
What happens to resources already in use
When the new tier's limit is below the member's current usage (e.g. Standard's 16 sites → Public's 1, with 3 sites already created), the system applies the clamp policy:
- Existing resources are retained. Sites already created stay active and usable — nothing is deleted, suspended, or made read-only.
- New provisioning is blocked while usage is at or over the limit. The member cannot create new sites until they delete enough to fall back under the cap (
AtomicIncrementUsageonly succeeds whilecurrent_usage < resource_limit). - The member UI shows the over-limit state (e.g. "3 of 1 sites used") and disables the create action with a clear reason.
clamp is the fixed tier-reduction policy today. The configurable tier_reduction_policy of Decision 125 (block / defer / clamp / force_reduce) is not yet implemented; a force_reduce flow that asks the member to pick which site to keep (others going read-only) and storage-based reduction are tracked as future work in status/issues.md.
Enrollment Audit
Reading Transition History
The enrollment page displays a chronological list of all pool_provision_transitions rows for each pool. Each row shows:
- Type —
initiate,upgrade,downgrade,end, ortransfer - From / To rank — the ladder position delta (
transfershows equal ranks, because a transfer moves the position to a new source at the same tier) - Actor —
system,webhook(Stripe), or the operator's display name - Reason — free-form text or system-generated attribution
- Effective at — when the change took effect
Canonicalization vs. subscription_changes
pool_provision_transitions is the canonical source for plan-position history: what tier a pool held, when, and under whose authority. core.subscription_changes records commercial mutations of the subscription (status changes, amount changes, etc.). A Stripe-driven upgrade is recorded in both tables because they answer different questions.
Corrections
Transition history is read-only. Errors or clarifications should be expressed by performing a new transition with an explanatory reason, not by editing historical rows.