Files
member-console/docs/plan-management.md
T
cgalo5758 2f1803d1e6 Mark a subscription that delivers nothing with a badge, on the organization page and in the subscriptions list
Section 8 of #212's change, after review: the Billing card's muted line
under the Subscriptions row becomes an amber "Not delivering" badge on
the row's value line, after the state badge, with a title that names
the ladders and the holder ("Billing continues while a grant holds Demo
Ladder."; with several subscriptions "Billing continues on <k> of <n>
subscriptions while ..."). The operator's subscriptions list marks each
displaced subscription's row with the same badge and title, read with
one grouped statement per page and turned into titles by the same steps
the card uses. A failed read on the list says "Failed to load
deliveries." once above the table and marks no row. A paused
subscription no longer counts: it generates no invoice, so "Billing
continues" would be false; it is marked once it is active again.

The grant form's warning now reads "Products marked ⚠ would supersede a
tier a subscription pays for; the subscription keeps billing and
delivers nothing on that ladder until the grant ends." The billing view
it named before can change nothing. A Stripe-paused subscription's tier
is still marked, an accepted edge until #269's change, which pauses a
displaced subscription's billing and rewords the badge and the warning.

Goldens: 16 change, each as item 8.9 lists: the organization page's
JSON goldens rename DisplacementNote to DisplacementTitle, the two
subscriptions-list goldens gain the title field, three HTML goldens
carry the new warning, and grant-over-subscription.html the badge in
place of the line. No other golden changed.

Breaks (scripts/mutation-check, 31 of 31 CAUGHT):
- paused restored
- not_delivering removed from badgeMap
- badge tone
- list badge without title
- card title never set
- card template without badge
- account form never chosen
- n read as displaced count
- subscription holder as grant
- several holders as one
- ladders joined with commas
- sort removed
- ladders not deduplicated
- holders not deduplicated
- status filter dropped (card)
- status filter dropped (list)
- list row in account form
- list row in account form (list vs card)
- list titles never set (the field set to strings.TrimPrefix of the
  title by itself, an empty string, because removing the line leaves
  titles unused and does not compile)
- delivering row given the displaced title
- list template without badge
- list pool not default
- list reads moved into the row loop (emulated: an inline closure calls
  the title read once per row; a helper only the break called would fail
  deadcode)
- list DisplacementError assignment and L4 removed
- O4 template line removed
- O4 line moved under the table
- rows dropped on a failed read
- badge label
- card badge without title
- delivering row given an entry
- old warning restored

Hand capture on the Stripe sandbox (API 2025-02-24.acacia): a member
subscribed to a $20/month tier through Checkout; an operator issued a
complimentary grant of the tier above; an edit to the subscription at
Stripe then reconciled it, and the reconcile logged "reconcile: product
not conferred; another plan holds its ladder". The Billing card and the
subscription's list row each showed Active and Not delivering at 1280
and at the 390 mobile width, with no sideways page scroll; the badge's
title read by script: "Billing continues while a grant holds Demo
Ladder." Captures under test/screens/displaced-subscription-waits/.

The suite on reset databases passed on this section's tree, 4711 tests,
14 skipped; make screens changed no screen this section owns, and every
registered form was captured. The review's last fixes (the list test's
row checks now fail when a row is missing, two comment rewraps, task
wording) came after it; the list test and its five breaks were rerun on
them: pass, 5 of 5 CAUGHT. make lint passes; openspec validate --strict
passes.
2026-10-26 17:38:12 -05:00

40 KiB

title, audience, summary
title audience summary
Plan Management
admin
developer
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. Extending a grant through Manage (a new Valid until) 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_tiers is 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:

  1. 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).
  2. 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.
  3. Published, public, active. Set lifecycle_status = published, and mark the product Active and Public so it appears in the member catalog.
  4. Priced. Add an active price on the product's Prices view.
  5. Stripe-synced. The price must be mapped to a live Stripe price. Creating a price does not sync it automatically — click Sync to Stripe on the purchasability panel (or per-row on the prices table); see stripe.md. Once enqueued, the product shows sync pending until the mapping lands, 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_type is 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

  1. Open the Operator panel and select the Plan Ladders tab.
  2. Enter a unique slug (kebab-case identifier), a display name, and an optional description.
  3. Click Create Ladder.

Attaching Tiers

  1. From the ladder list, click Manage on the ladder you want to configure.
  2. Select any published product that has an entitlement set (Doc 41: display category never gates tier membership). The list leaves out a product with no entitlement set, because no source can deliver a tier that has none.
  3. 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.

The add decides inside the one transaction that inserts the tier:

  • The product. It reads the product there and refuses one that is not published; a product no row names answers "Product not found.".
  • The holder limit. It counts the organizations holding a live provision of the product and refuses above 200, the limit set from the timed cost of reconciling one holder, so a tier change's exclusive hold on the materialization lock stays near half a second. The add reconciles a holder faster than the removal, so at the limit it holds about 0.15 s; the removal sets the shared limit. The refusal names the count and the limit. When more than half of those organizations hold the product through their org type's default on a ladder, it adds that changing that default with Migrate moves them in the background. A default-sourced provision that a Keep removal left off every ladder counts toward the limit and not toward that sentence, because changing the default would not move it.
  • A held rung. After the tier insert and before any alignment, it reads the holders' pools it has locked. A pool that already holds a live position on the ladder refuses the add, naming the organization and the product it holds; so does a pool that holds the product in more than one live provision, such as an operator grant and a subscription, because an organization holds one position per ladder. The first such pool in ascending pool order is the one named.
  • The record. It writes one act (cause tier_added) naming the ladder and the product, and the alignments record the ladder's name in their reason. A lock wait or a deadlock at any step answers "Another change collided with this one; try again."; every other failure answers "Failed to add tier. Reference: {request id}.", and the log line carries the same request id and the step that failed.

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.

The commit renumbers the ranks and records the reorder as one act in a single step; the organizations of the types it reaches are then moved in the same request as far as its time allows, and the rest are moved by the background drain, as for a default change. The toast says how many were moved and how many are not yet reached. Each organization is moved onto the rank 0 the type has when its move runs, so a reorder that races a default change moves nobody onto a stale rank 0, and an organization already on that rank 0 is settled without a legacy grant. Grandfathered positions are reissued as legacy grants described "Grandfathered: plan-ladder tier reorder (ladder name)".

An organization whose move fails five times is listed below the tiers table on the ladder page, under its reorder, with the sentence for the step that failed and the act's reference, beside Retry. Retry moves those organizations onto the types' current default without writing the ranks again, for the types the reorder still owns; a newer default change or reorder takes over the rest. The grants and plan changes a retried organization receives are recorded under the operator who pressed Retry.

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.

The removal decides inside that transaction, before it takes any pool lock, and the preview reads the same counts and, when either rule refuses, shows the same sentence and offers Discard alone:

  • The holder limit. It counts the organizations holding a live position on the tier and refuses above 200, the same limit as the tier add (see "Attaching Tiers"); the removal's per-holder cost sets it. The refusal names the count and the limit. When more than half of those organizations hold the tier through their org type's default, it adds that changing that default with Migrate moves them in the background, and, for the rank-0 tier of a ladder that has another tier, when every one of the organizations holding it through an org type's default holds it through a type that defaults to this ladder, that reordering this ladder with Migrate does too. The tier card reads the same counts and renders the tier's Preview removal disabled over the limit, with the same sentences as its reason, or with "Failed to load the tier's holders. Reference: {request id}." when it cannot read them.
  • The last tier of a default ladder. The removal reads the ladder's tiers and the org types that default to it, and refuses to remove the only tier of a ladder some org type defaults to under Keep as under Migrate, with the sentence the plain delete gives.
  • The record. The removal writes one act (cause tier_removed, with the disposition when one was asked for) naming the ladder and the product as they were named at that moment, and the grants Migrate's floor restoration creates carry that act. A name that cannot be read refuses the removal. A lock wait or a deadlock at any step answers "Another change collided with this one; try again."; a failure while reconciling a holder answers "Removal failed and nothing was changed: could not …" for the step, ending "Reference: {request id}.", and every failure's log line carries the same request id and the step that failed. Any other failure (the holder count, the tiers, the org types or the names cannot be read, the act cannot be recorded, the tier cannot be deleted or renumbered, the commit fails) answers "Failed to remove the tier; nothing was changed." and logs the step. When no holder is left by the time the commit runs, it answers "{Product} removed from the ladder." with no count.
  • A repeated removal. A removal of a product that is no tier of the ladder and has no holder, such as the second of two removals of one tier, records no act, deletes nothing and answers as a successful removal does, as the delete below does.
  • A preview that cannot read what a guard needs (the tiers or the org types) shows "Failed to preview the removal. Reference: {request id}." in place of the preview, never an enabled commit.

The plain delete of a tier with no live holders runs the same way, as a removal with no holders, and decides inside the one transaction that deletes:

  • Live holders. A holder is live when its position is active or suspended, the population a removal reconciles. The delete counts them in its own transaction and refuses a tier that has any, so a tier held only by a past-due subscription's suspended position is not deleted under it; the tier page offers Preview removal for such a tier, and the delete a stale page sends is refused with the live-holder sentence. The Active column on the tier page still counts active positions only.
  • The last tier of a default ladder. The tier card renders that tier's Remove (Preview removal when it has live holders) disabled, with this sentence as the reason. When the card cannot read the org types, it renders rank 0's Remove disabled with the failed read as the reason instead, because the card cannot tell whether this rule applies; a holder-limit sentence the card knows takes the place of that failed read. The delete reads the ladder's tiers and the org types that default to it, and refuses to delete the only tier of a ladder some org type defaults to (new signups would land on a ladder with no tier). A read of the org types that fails refuses the delete too; the delete never proceeds for want of an answer.
  • The record. The delete writes one act (cause tier_removed, no disposition) in the same transaction, naming the ladder and the product as they were named at that moment, and then deletes and renumbers. A failure at any step rolls the act back with the rest. A lock wait or a deadlock at any step answers "Another change collided with this one; try again."; every other failure answers "Failed to remove tier. Reference: {request id}.", and the log line carries the same request id and the step that failed.
  • A repeated delete. A delete of a product that is no tier of the ladder, such as the second of two deletes of one tier, writes no act, deletes nothing and answers as a successful delete does.

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 a Retry or a repeated drain pass a no-op; choosing the default the type already has is refused with "Already the default.")
  • outgoing default — organizations holding the previous default (named individually); the commit requires a disposition for them:
    • Grandfather — reissue their current plan as legacy grants 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 + initiate in the audit trail); with a None candidate this ends the position with no replacement (the pool legitimately goes off-ladder)
  • 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 Apply change button lives inside the preview, so the mutation is unreachable without seeing its projection. The commit is short: one transaction under the exclusive materialization lock saves the org type's new default, records the act (who, when, which ladder replaced which) and the org type it reaches, and refuses a missing disposition when the gate finds an organization on the outgoing default. A listing then writes one obligation for each organization of the type that has a default pool, in short batches (an organization without one gets a record instead and is counted as without a default pool), and the drain moves each organization in its own locked transaction against the type's current default, re-classifying in-transaction, so a tier change or a second default change that lands in between never moves an organization against a stale product. The request lists and moves what its budget allows and answers with the counts of what it settled, the organizations not yet reached and the organizations with no default pool; the drain does the rest in the background.

An organization whose move fails is tried again by the drain; after five failures it is listed on the card as not moved, with the sentence for the step that failed and the act's reference, beside Retry. Retry moves those organizations onto the type's current default without committing the default again, and does nothing for an organization that has moved already. The grants and plan changes a retried organization receives are recorded under the operator who pressed Retry. A newer default change for the same type takes over the organizations the older one still owes. An organization that is already on the default is settled as "already at the default" without being touched, so a Retry or a repeated drain pass does no work twice; choosing the default the type already has is refused.

Manual Grant Workflows

Issue Grant

An operator can issue a grant to move a pool to a specific tier:

  1. Open the organization's page from the Organizations list.
  2. In the Grants section, click Issue grant. The panel opens with a stacked form: one field per row, each label above its field.
  3. Choose a Product, optionally a Quantity and a Valid until timestamp, choose a Reason, and optionally add a Note. A product whose grant would supersede a tier a subscription holds, active or suspended, is marked ⚠ in the Product list, and the form's warning says that the subscription keeps billing and delivers nothing on that ladder until the grant ends.
  4. Tick Resumes, whose label sits above its box, when the grant should deliver again after a plan bought or granted over it ends (see Grants that resume). It starts unticked, and it is offered for every product, including the organization type's default product.
  5. Click Issue grant.

The system:

  • Creates a grant with the chosen reason and the resumption mark, then calls the conferral boundary (internal/entitlements/conferral.go) through the operator handler in internal/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_until when one is set
  • Keeps the ladder for a grant that supersedes a tier a subscription pays for: the subscription keeps billing and delivers nothing there while the grant lasts, and the organization page's Billing card and the subscription's row in the operator's subscriptions list carry a Not delivering badge whose title reads "Billing continues while a grant holds {ladder}."; the subscription delivers again at its next reconcile after the grant ends

While Temporal is not connected, which on a deployment without temporal-host is always, a grant with Valid until is refused before it is written, with "End dates can't be saved while Temporal is not connected." under the field. Manage refuses a new or changed end date the same way and saves a cleared one.

A grant with no valid_until is permanent. A grant with valid_until expires at that moment; when it expires, the pool returns to what it held before (see Downgrades).

Grant Revocation

An operator can revoke an active grant that is live or on hold:

  1. On the organization's page, locate the grant in the Grants ledger (the Active tab lists it).
  2. Click Manage on its row, then Revoke, which sits after Save and Cancel in the card under the row.
  3. Confirm the action. The confirmation names what changes:
    • on an on-hold grant whose badge's hover reads "Resumes when Pro ends": "Revoke Plus for this organization? It no longer resumes when Pro ends."
    • on any other on-hold grant: "Revoke Plus for this organization? It no longer resumes."
    • on a live grant that marked grants wait on: "Revoke Pro for this organization? Plus resumes." (with several, "Plus and Chat Plus resume.")
    • on any other live grant: "Revoke Plus for this organization? It moves to Starter, its org type's default." (Starter being the default ladder's rank-0 product; "It returns to its org type's default." if that product cannot be found) when the revocation leaves the pool with no other source's position on any ladder and the organization type has a default ladder, so the default restoration runs; otherwise the question alone, "Revoke Plus for this organization?".

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, resumes the marked grants the revoked grant held, and then 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. Revoking an on-hold grant ends nothing and resumes nothing; the grant simply never resumes.

Manage

One Manage action changes a grant after issuance, whatever the grant is doing when the operator submits:

  1. On the organization's page, click Manage on the grant's row of the Grants ledger. The row grows by a card, and Manage stays in the row as a cross: the card holds the Note field, the Valid until field and the Resumes checkbox, each with its label above it and showing its stored value, then Save, Cancel and, for a live or on-hold grant, Revoke. Product, quantity and reason cannot change: a different product or quantity is a different grant, and Issue grant writes it over the old one on the same ladder.
  2. Change any of the three fields. An empty Valid until means no end date.
  3. Click Save. Cancel, or the row's Manage, puts the row back as it was without saving.

One row edits at a time. Clicking Manage on another row closes the first one, and anything typed in the first row is discarded, since it was never saved. A refused save keeps the row editing and shows each field's error under its field, in the card. From the large breakpoint up the card is one line; below it the fields stack one per line, with the buttons under the last.

The action posts to /partials/operator/organizations/{orgID}/grants/{grantID}/manage and acts on the head of the grant's lineage (the grant itself, unless another operator extended it after the page rendered). It compares the submitted Valid until with the stored one, to the minute, so a field the operator did not touch never counts as a change:

  • Valid until changed. The action extends the grant. The new grant carries the extended grant's product, quantity and reason, and the note and mark submitted with the form.
    • For a grant that delivers: it creates a new grant chained to the prior grant via extends_grant_id and calls the conferral boundary (internal/entitlements/grant_acts.go, ExtendGrantTx). 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. The change is recorded as a transfer audit row, not an extend row (no extend transition type exists); the row's from_rank equals its to_rank because the tier does not change.
    • For a grant that delivers nothing (on hold, or an unmarked grant a live plan replaced): it writes the successor grant only (ExtendWaitingGrantTx), with the predecessor's product, quantity, valid_from and reason. It confers nothing and writes no transition. The successor takes the predecessor's place and waits as it did, and it is the grant that resumes; the predecessor's row reads Superseded with the hover "Replaced by Plus".
    • When Valid until is set, it schedules GrantExpirationWorkflow keyed off the new grant's ID, so extensions expire on their own clock and don't ride the original grant's expiry.
  • Valid until unchanged. The action writes the mark and the note to the grant in place and nothing else (no new grant, no conferral, no materialization). An untick is always saved. A tick is refused with "Plus can no longer resume, so nothing was saved." when, after the page rendered, the plan it waited on stopped delivering or its valid_until passed, since a mark saved then would never be read.

Every accepted save toasts "Grant saved.", a save that changes nothing included, and re-renders the page so the grants show as they stand. The handler logs the operator, the grant, and each field's old and new value. The action is refused, writing nothing, for a grant of another organization ("Grant not found"), a revoked or expired grant ("This grant is not active (already revoked or expired)."), a default grant ("This grant is the org-type default. Edit the org type's default ladder to change it."), an organization with several active pools, and a changed Valid until on a retired product ("Plus is retired, so the change was refused."). A change of the mark or the note on a retired product's grant is saved. Nothing else is refused because the grant's delivery changed after the page rendered.

Manage is offered on a grant that heads its lineage, is Active, is not a default grant, and is live, on hold, or an unmarked grant a live plan replaced (so an operator can mark it after the fact). The grants index (/operator/grants) carries no Manage, and the delivery blocks on the pool card carry no action: a position a grant backs is managed from that grant's row.

Use this to extend a trial that is about to expire (set a new Valid until), comp an additional cycle during an outage, gift a second period without disturbing the existing audit trail, keep a grant that waits to resume from expiring before its holder ends, or mark a grant to resume.

Grants that resume

A grant marked Resumes delivers again when the plan that replaced it ends: a subscription the organization bought over it, or a grant an operator issued over it (an evaluation of a higher tier, for example). The subscription's end, the grant's revocation or the grant's expiry re-confers the marked grant from its own row, before the default restoration runs. A grant waits on the nearest plan above it that has not ended, so a marked grant behind an evaluation that was itself bought over resumes when the subscription ends, or when the evaluation ends if the evaluation resumed first. The rules live in Plan Ladders and Transitions, invariant 18.

The mark is set at issuance or later through Manage (see Manage).

The rows. The organization's grants ledger and the grants index (/operator/grants) show the same states. A marked grant that waits to deliver again reads On hold, whatever it waits on; what it waits on is the badge's hover text, not a line under the badge. No Delivery cell carries a line under its badge. The hover texts:

Delivery Hover Meaning
On hold Resumes when Pro ends The marked grant resumes when Pro, a plan that delivers now, ends
On hold Waits behind Pro, which is on hold too The plan it waits on is itself a grant that waits
On hold Its product is on no ladder Its product is on no ladder, so it resumes only if the product is on one when its holder ends
On hold Blocked by Forum Another source holds one of its rungs, or another marked grant comes first on a shared ladder
Inactive Will not resume Marked, but the plan it waited on ended without resuming it, or its valid_until has passed
Inactive The default delivers this The organization type's default already delivers the product, so this grant never delivered
Superseded Replaced by Pro A later grant replaced this one

A pointer shows the hover; a screen reader reads the same text after the badge's label. A touch screen does not show it.

The Resumes column, after Delivery, reads Yes or No on every row: the stored mark. The column states the setting and the Delivery badge states the outcome, so a marked grant that can no longer resume reads Yes beside Inactive. A default grant the restoration wrote after a plan ended names that plan under its reason ("after Pro ended"), and its recent-activity line reads "Issued Free (default) after Pro ended". The Active tab lists live and on-hold grants, with the dates Activated and Valid until; the History tab lists every grant with Activated, Ended and Valid until, where Ended reads "—" while the grant delivers. Date-time cells on the organization page show the date over the time, in 24-hour form. The grants index filters by On hold as well. In Tier changes a resumption reads Resumed and an extension reads Extended, and the recent-activity feed on the operator landing page uses the same words ("Resumed: Plus", "Extended: Pro"); a restored default still reads Started.

What the member sees. On a ladder whose current rung a subscription backs, the rung of a marked grant that the subscription's end would resume is the held rung: its card reads "Granted" in place of a price ("Included" is kept for a tier with no price at all). A held rung below the current rung carries Downgrade, which cancels the subscription at the period end. A held rung above the current rung carries no control; Cancel plan on the current card, whose confirmation names the tier that follows, is the way there (see A held rung above the current rung). A rung below both the held rung and the current rung carries no control, and the switch refuses the held rung and those below it. Once the cancellation is scheduled, the held rung carries no control either; Keep plan on the current card is the way back. The cancel confirmation names what follows on each ladder the subscription covers ("You keep Pro until Oct 26, 2026, then move to Plus."; "You keep Plus; billing stops on Oct 26, 2026."; "…then move to Plus on Wiki plans and leave Forum plans."). The free rung's Downgrade, which cancels the subscription, is offered only when cancelling puts that free rung on its ladder, by a resumed grant or by the default restoration; otherwise Cancel plan on the current card is the way to cancel.

A scheduled downgrade. The current card reads "$30.00/month, moves to Plus on Oct 14, 2026", and offers Keep plan and Cancel plan. On a ladder that carries Plus, Plus's own card carries no control while the move is scheduled, since the move is already made; Keep plan is the way back. Every other rung keeps its control. The held rung is the exception: when the scheduled move lands on it, its control stays the held Downgrade, which cancels at the period end and replaces the scheduled downgrade.

A held rung above the current rung

A held rung sits above the current rung only after one sequence of acts, shown here on a ladder Starter → Plus → Pro → Max:

  1. An operator grants Pro.
  2. The member buys Max, which replaces the unmarked Pro grant.
  3. The member schedules a downgrade from Max to Plus.
  4. The operator marks Pro to resume, and Pro goes on hold behind Max.
  5. At renewal the downgrade lands, and the subscription moves to Plus.

A change of tier does not end the subscription, so Pro keeps waiting for the subscription's end, and the console never rewrites a move the member already scheduled. The member therefore pays for Plus while a granted Pro waits above it. Pro's card reads "Granted" and carries no control, and Plus's Cancel plan confirmation reads "You keep Plus until Oct 26, 2026, then move to Pro."

Once the grant is marked, the switch refuses a new downgrade to a rung below both the held rung and the current one, so only a downgrade scheduled before the mark reaches this state. The one other way in is an edit to the subscription's price in the Stripe Dashboard that moves it below a marked grant's tier while the grant waits. Whether the console should prevent or resolve this state is forge issue #159.

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 time-bounded grant expiring. In every case the steps run in this order: the entry point ends the position; then, when that ended at least one provision of the ending plan, it resumes the grants marked to resume that the plan held (ResumeReplacedGrantsTx in internal/entitlements/grant_resumption.go); then it separately calls the guarded default restoration, because ending never re-mints the default by itself (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. A resumed grant also records initiate, and because it occupies a rung, the restoration that follows finds the pool occupied and confers nothing, on any ladder.

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), what happens is decided by the reduction policy of the limit rule that funds the key, tier_reduction_policy on core.entitlement_set_rules (migration 00018, September 2026). The Entitlements card defines the four values and their strength. As built, two behaviours exist:

  • Under force_reduce, the provider acts on the surplus: the FedWiki quota sweep parks the excess sites read-only until active usage fits the new limit (internal/integrations/fedwiki/workflows/reconcile.go), and the member picks which sites stay active subject to the rotation cooldown.
  • Under every other value (clamp, and today also block and defer, which are not yet wired to anything of their own), the surplus is the member's: existing resources are retained, nothing is deleted, suspended, or made read-only; new provisioning is blocked while usage is at or over the limit (the owner's gate refuses new units while the stored figure is at or over the limit); and the member UI shows the over-limit state (e.g. "3 of 1 sites used") with the create action disabled.

When two sets fund the same key on one pool, the sweep reads the governing policy, the strongest of the rules' policies (GetGoverningReductionPolicy, internal/entitlements/queries/entitlement_set_rules.sql). The migration set every existing fedwiki_sites rule to force_reduce, so parking on existing deployments was unchanged by the column's arrival. Storage-based reduction remains future work, forge issue #129.

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, or transfer
  • From / To rank — the ladder position delta (transfer shows 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.