Files
member-console/docs/models/plan-ladders-transitions.md
T
cgalo5758 ad7a219adf Enforce schema and boot invariants
Enforce 10j's verified gaps (schema-hardening change):

- Migration 00010: partial unique indexes for one default pool and one
  primary assignment per workspace, plus CHECKs pinning
  pool/provider/subscription vocabularies and provider lifecycle
  timestamps.
- Workspace creation shares a transactional provisioning function;
  extension validates its target pool; last-tier deletion of a defaulted
  ladder is guarded; signup completes plan-less on a broken ladder.
- Boot asserts integration slug parity and validates declared config
  enums; Stripe invoice amounts are range-checked; domain cancellation
  runs a final evidence probe; rule authoring is additive-only.
2026-08-22 18:02:46 -05:00

13 KiB
Raw Blame History

title, audience, summary
title audience summary
Plan Ladders and Transitions
developer
How plan positions work: ladders and their ranked tiers, which pool occupies which rung, the append-only transition audit, and the conferral functions that are the only legal writers.

Plan Ladders and Transitions

Purpose

A plan ladder is a named, rank-ordered sequence of plan tiers — rank 0 is the entry tier, higher ranks are higher tiers — and this model answers three questions: what ladders exist, who occupies which rung right now, and how every position came to be.

The object graph, once, because everything below leans on it: an organization owns a resource pool (the container for everything conferral delivers; the entitlements model owns pools, and one default pool per organization is the norm). A conferral writes one provision row per funding source into the pool. If the conferred product is a tier on ladders, that provision fans out into occupancy episode rows — one per ladder — and every change to an episode is recorded as a transition row referencing it. So the three position tables are: core.pool_provisions (what is held), core.pool_provision_ladders (which rungs, now), and core.pool_provision_transitions (what happened). The first table is shared with the entitlements model; the other two belong here.

Three lifecycles, never to be confused: the ladder catalog (plan_ladders + plan_ladder_tiers) is editable configuration; occupancy is live, episodic state; the transition audit is append-only history that is never edited. A pool can occupy rungs on several ladders at once (hosting on one axis, support on another) but never two rungs of the same ladder — that exclusivity is the model's central guarantee.

All position writes go through the five conferral functions, each owning one verb: core.confer creates a position (and displaces an incumbent when needed), core.end_conferral ends one, core.sync_source_status suspends and resumes when billing state changes, core.align_conferral_shape reconciles occupancy after the catalog changed shape under a live provision, and core.update_conferral_bounds resizes quantity or time bounds. Application code cannot touch the position tables directly. Supersession is what happens when a new conferral's product shares at least one ladder with a position the pool already holds: the incumbent's whole bundle of rungs ends and the replacement is created, atomically. When a position ends with nothing left, a separate default restoration may re-confer the organization's baseline plan.

Vocabulary reused from the Product Catalog card: product, plan tier, conferral, provision, grant. From the Payments and Billing card: scheduled changes (intent), subscription. A grant decree below means the grant row itself — the durable record that someone decided to give an organization a product; materialization means recomputing the organization's effective entitlements from its live provisions (the entitlements model owns it).

Where this lives

Database objects sit in the core schema; the baseline DDL is internal/db/migrations/00001_init.sql, with later alterations in 0000400006.

Piece What it is Where
core.plan_ladders The ladder catalog: unique key, display name, is_active flag, presentation-only sort order 00001_init.sql
core.plan_ladder_tiers Ladder membership: one row places one product on one ladder at one rank; ranks are unique per ladder 00001_init.sql
core.pool_provisions One row per funding source per delivery: what the pool holds, its quantity and time bounds, and a status of active, suspended, or ended (full column story in the entitlements model) 00001_init.sql; 00004_doc41_schema_and_data.sql
core.pool_provision_ladders Occupancy: which provision holds which rung now. Each occupancy episode is its own row with a permanent id — a rung lost and regained is a fresh row, never a reopened one 00001_init.sql; 00004_doc41_schema_and_data.sql
core.pool_provision_transitions The append-only audit: one row per rung per change — initiate, upgrade, downgrade, end, or transfer — with actor attribution and a business-time effective_at 00001_init.sql; 00004_doc41_schema_and_data.sql
core.sync_pool_provision_ladders() The trigger that propagates a provision's status down to its episode rows — except rows already ended, which stay ended 00001_init.sql; 00004_doc41_schema_and_data.sql
The five conferral functions confer, end_conferral, sync_source_status, align_conferral_shape, update_conferral_bounds — verbs glossed in Purpose; the only legal writers of the three position tables 00005_doc41_conferral_functions.sql
core.product_conferral_shapes The view conferral reads for a product's ladder positions and availability facts (introduced in the Product Catalog card) 00004_doc41_schema_and_data.sql
core.org_types.default_plan_ladder_id A column on the organization-types table (every organization has a type; today all are the personal type): it points at the ladder whose rank-0 tier is that type's baseline plan — the only place a default is configured 00001_init.sql
Conferral boundary The one Go file that calls the five functions: typed wrappers, error mapping, and the grant path (write the grant decree, confer it, then materialize) internal/entitlements/conferral.go
Default restoration Resolves the org type's default ladder to its rank-0 product and confers it — plus the floor guard that restores only when the pool has no live occupancy on any ladder internal/entitlements/reapply_defaults.go
Stripe-driven convergence Translates a subscription's remote state into confer / end / suspend-resume calls (see the Payments and Billing card) internal/fulfillment/reconcile.go
Member plan moves Switch, cancel, and the commitment gating layered above conferral internal/fulfillment/plan_change.go
Operator ladder management Ladder and tier CRUD, reorder and removal (with a preview of what happens to pools sitting on affected rank-0 tiers; removing the last tier of a ladder an org type defaults to is refused, because signups land on that ladder's entry tier), and the structural validation page (orphaned products, malformed rank sequences, pools with multiple active positions) internal/server/operator_plan_ladders.go
Operator enrollment Issue, revoke, and extend grants; revoke ends positions then restores the guarded default internal/server/operator_enrollment.go
Org-type default changes Changing a type's default ladder, with a per-pool preview offering two dispositions: grandfather (the pool keeps its old position, re-decreed as a legacy grant) or migrate (end the old position, then floor-guarded restore onto the new default) internal/server/operator_org_types.go
Plan topology The ladders-by-ranks overview grid, with a health strip surfacing the same structural checks as the validation page internal/server/operator_topology.go
Grant expiry GrantExpirationWorkflow sleeps until a grant's expiry and then runs the expiration activity: end the grant's positions, restore the default, rematerialize internal/workflows/entitlements/grant_expiration_workflow.go; activities.go

Invariants

An invariant is a rule the model guarantees everywhere; code that would break one is wrong even if it works locally. Tags mark the enforcement mechanism: [db] — a schema constraint rejects violations; [db-code] — database-side code upholds it; [app] — only Go code upholds it, so every new code path must too.

  1. [db] One active occupant per pool per ladder, at any instant. An exclusion constraint — a database rule that rejects any two rows whose (pool, ladder, active time range) overlap — enforces this outright. Everything else leans on it.
  2. [db] Only the five conferral functions can write the three position tables (pool_provisions, pool_provision_ladders, pool_provision_transitions). The application's database role has no insert, update, or delete on them — only permission to execute the functions.
  3. [db] One live occupancy row per (provision, ladder); a rung lost and later regained is a fresh episode row with its own id.
  4. [db] One live provision per source: per grant, per purchase, and per product within a subscription (a subscription can deliver several products, so uniqueness is per product inside it). A retried conferral cannot double-deliver, and a source that ended can deliver again later. ("Purchase" is the schema's third source arm, reserved for one-time purchases; nothing creates purchase-sourced provisions yet.)
  5. [db-code] Callers never choose a transition type. core.confer derives it from ranks: no previous rank means initiate, a higher rank is an upgrade, a lower one a downgrade, and the same rank is a transfer — which can only happen when a different source takes over the position, because the same source re-conferring is a no-op (invariant 13).
  6. [db] Every new transition row names the occupancy episode it belongs to, and operator-attributed rows must carry the acting person's id.
  7. [db-code] Supersession is whole-bundle and atomic: displacement triggers when the new conferral's product shares at least one ladder with an incumbent position, and then every rung that incumbent held ends — including rungs on ladders the new product is not on — the incumbent's provision ends, and the replacement with all its rungs is created, inside one core.confer call.
  8. [db-code] core.confer only activates in the present: a future activation time is rejected (future intent belongs in scheduled changes), and a backdated one may not be earlier than the activation time of the incumbent it supersedes.
  9. [db-code] An ended episode stays ended. The status-sync trigger propagates a provision's suspension or recovery to its episode rows, but never to rows already ended — so recovering from suspension cannot resurrect a superseded rung.
  10. [app] Cancelling to the default is an end plus a separate restoration, never a downgrade: ending never re-mints the baseline itself; each ending entry point (webhook, operator revoke, grant expiry, org-type migration) separately calls the guarded restoration, and the restored position records "initiate".
  11. [app] Restoration is a floor, not a layer: the default is conferred only when the pool has no live occupancy on any ladder — and a suspended occupancy counts as live, so a past-due subscriber's position is never quietly replaced by the free baseline.
  12. [app] The default is resolved at use time — organization type → default ladder → rank-0 tier — and conferred as a system-authored grant with reason "default". No per-organization default is ever stored.
  13. [db-code] Conferral is idempotent per source: the same source conferring the same product again is a no-op, and a second grant decree for a position the pool already holds is also a no-op — unless the new grant declares it extends the incumbent grant, in which case it takes over the position, recorded as a transfer.
  14. [app] Tier ranks are contiguous from 0 only by authoring discipline: the schema enforces only per-ladder rank uniqueness; the operator UI appends and renumbers to keep 0..N-1, and the validation page and topology health strip report any drift.

Dimensions

These axes are independent; hold them separately:

  • Catalog vs occupancy vs audit — what a ladder is (two editable tables), who holds it now (episodic), what happened (append-only). Three lifecycles across four tables.
  • Rank — position within one ladder; rank 0 is load-bearing as the default tier (invariant 12) yet carries no schema marker distinguishing it.
  • Ladder axis — one pool may occupy many ladders; one product may be a tier on several ladders at once, and conferring it takes all its rungs as one bundle.
  • Source — a position is funded by a subscription, a purchase (reserved, unused today), or a grant. Grants subdivide by reason, and reason changes conferral behavior: the system's "default" reason marks the baseline the floor guard restores, and the "legacy" reason marks grandfathered positions that survive an org-type default change.
  • Provision status vs episode status — a provision is active, suspended, or ended, and its status flows down to its episodes; but an episode can end individually and then no longer follows its parent (invariant 9). Two statuses, held separately.
  • Business time vs wall clockeffective_at is when a change takes effect; created_at is when the row was written. core.confer refuses future activation (invariant 8); the other functions simply always receive "now" from their callers.
  • Commitment layer — the allow/block/fee gating of mid-term moves (defined in the Payments and Billing card) sits above this model: it decides whether Stripe gets mutated; conferral only converges on what Stripe then reports.
  • Display vs semantics — ladder sort order and product display_category are presentation; rank alone drives behavior.