Add default_plan_ladder_id with a forward data migration and update the runtime to resolve the ladder's rank-0 tier at use-time. Regenerate sqlc, update auto-provisioning, ReapplyDefaultsForPool, operator UI and tests; add GetTierByLadderRank and pool/provision query helpers. Add a CSP-safe confirm-action modal and wire operator actions to it. Close plan-sole-writer safety gaps and serialize IssueGrant with a FOR UPDATE pool lock to prevent ladder races.
9.9 KiB
audience
| audience |
|---|
| developers |
Plan Architecture
Audience: Developers working on plan ladders, entitlement provisioning, operator UI, Stripe webhooks, or any feature that touches tier enrollment.
Last updated: 2026-04-24
What "plan" is in this codebase
"Plan" is not a Go package. It is a capability slice that threads through the existing domain modules, following the DB schema split: catalog data lives in billing, runtime enrollment state lives in entitlements. Any feature that mutates tier enrollment has to touch both — by design.
Because plan logic is distributed, the risk is drift: two callers mutating enrollment state in different ways, invariants enforced in one path but not another, audit rows written for some transitions and not others. This document exists to prevent that.
The principle
The sole-writer rule is a consequence of one design choice, not a posture imposed on every product. The choice:
Plan membership is opt-in at the catalog layer. Once a product is opted in, it gets sole-writer semantics at runtime.
A product is "a plan" only if it appears in a plan_ladder_tiers row. That membership is what brings the runtime invariants — one-active-tier-per-ladder, supersession on transition, audit row per change. Products that don't opt in (addons, usage, one-time) keep flat grant/revoke semantics with no ladder logic.
This means we do not privilege a particular commercial topology. A co-op that doesn't use ladders never touches this code path. A co-op that wants stacking grants, parallel enrollments, or gift-style products models them as non-plan products and they are unaffected by anything below. The opinionated machinery only kicks in for things explicitly modeled as tiered.
When a developer or operator finds the rule "in the way," the right question is almost always: should this have been a plan in the first place? If the answer is no, model it as an addon. If the answer is yes, the rule is what's keeping the data integrity intact, and the fix is to extend the primitive (e.g., add an explicit extend transition type) rather than to bypass it.
The sole-writer rule
entitlements.Transition(ctx, tx, pool_id, target, actor) is the sole writer of ladder enrollment state. Every other component either calls it or reads from what it wrote.
Transitionis defined ininternal/entitlements/transitions.go.- It classifies the event (
initiate/upgrade/downgrade/end), ends the prior active provision, creates the new one, maintains thepool_provision_laddersattachment, writes thepool_provision_transitionsaudit row, and delegates toReapplyDefaultsForPoolon downgrade/end. - It is idempotent: re-invoking with the same target when the pool is already at that rank is a no-op.
If you find yourself writing to pool_provisions, pool_provision_ladders, or pool_provision_transitions from anywhere else, you are creating a bug. Stop and route through Transition.
See docs/grant-plan-safety.md for incident prevention detail and the three currently-documented gap cases at the UI boundary.
Where plan code lives
| Layer | Package | Role |
|---|---|---|
| Catalog schema | internal/billing/ |
plan_ladders, plan_ladder_tiers tables + sqlc; product lifecycle |
| Runtime schema | internal/entitlements/ |
pool_provision_ladders, pool_provision_transitions tables + sqlc |
| Sole writer | internal/entitlements/transitions.go |
Transition(pool, target, actor) — only writer of enrollment state |
| Default re-apply | internal/entitlements/reapply_defaults.go |
ReapplyDefaultsForPool; resolves default_plan_ladder_id → rank-0 tier at use-time; NULL-default contract; supersession end-path |
| Auto-provision hook | internal/provisioning/provisioning.go |
Resolves the org type's default_plan_ladder_id rank-0 tier and creates the ladder attachment on org creation |
| Webhook delegation | internal/workflows/stripe/webhook_subscription.go |
customer.subscription.created / .deleted → Transition |
| Trial expiry | internal/workflows/entitlements/grant_expiration_workflow.go + activities.go |
Scheduled Temporal workflow → Transition(End) on valid_until |
| Operator UI | internal/server/operator_plan_ladders.go |
Ladder + tier CRUD; structural-invariant validation view |
| Operator UI | internal/server/operator_org_types.go |
Default-plan-ladder selection (rank-0 tier rendered inline); backfill action |
| Operator UI | internal/server/operator_enrollment.go |
Per-org view; force-transition; trial grant; revoke |
| Member UI | internal/server/member_products.go + internal/embeds/templates/partials/member_entitlements.html |
Read-only "you are on <tier>" extension |
Grant lineage
When a grant supersedes a prior one (extension, comp cycle, sponsored renewal, salvage on cancellation), the new grant's entitlements.grants.extends_grant_id column points back to the previous grant. The chain lives at the grant layer, not at the attachment layer: pool_provision_ladders row identity cycles with each supersession, but grants rows endure. To answer "which grants kept this org on tier X from date A to date B," walk the chain via entitlements.GetGrantLineage(focal_grant_id) (recursive CTE, ancestors-in-walk-order).
Same-org and immutability invariants are enforced by triggers in entitlements.migrations/00010_grant_lineage.sql. The column is set only at grant creation; UPDATEs that change extends_grant_id are rejected. Cross-org chains are rejected. Self-reference is rejected by CHECK.
How to add new plan functionality
Before writing code, classify your change. Pick the row that matches and follow the rule.
| You're adding… | Belongs in | Rule |
|---|---|---|
| A new way to mutate tier position | Call entitlements.Transition from wherever the trigger lives. |
Never write pool_provisions.status, pool_provision_ladders, or pool_provision_transitions directly. |
| A new transition source (e.g., GitHub Sponsors) | Add a call site that invokes Transition with its own actor_type value. |
The classification + audit logic stays inside Transition. |
| A new catalog concept (ladder shape, tier prop) | internal/billing/queries/plan_ladders.sql + sqlc regen. |
Catalog is billing's domain. |
| A new operator action on enrollment | New handler in internal/server/operator_enrollment.go. |
Handler must call Transition, not touch DB directly. |
| A new member-facing tier view | internal/server/member_products.go + a partial template. |
Member UI is read-only for plan state. |
| A new async trigger for transitions | New Temporal activity that calls Transition (mirror grant_expiration_workflow.go). |
Activities open their own tx, call Transition, commit. |
| Validation of structural invariants | internal/server/operator_plan_ladders.go → GetPlanLadderValidation view. |
Surface violations; do not silently correct. |
| Extending an org on its current tier | Call Transition with TransitionTarget{Extend: true} (see operator_enrollment.go:ExtendGrant). |
Extend issues a new grant + provision at the same tier and records transition_type='extend'. |
If your change needs to mutate pool_provisions-related tables outside Transition, that is a signal the primitive is missing a capability — extend Transition rather than bypassing it.
What NOT to do
- Don't extract
internal/plan/as a coordinator package. The sole-writer primitive already concentrates the invariants. A coordinator would move code without reducing surface area, and would have to import bothbillingandentitlements, adding a dependency layer. - Don't write to
pool_provisions.statusdirectly except forcustomer.subscription.updatedintermediate states (past_due,unpaid,trialing) — those are explicitly not ladder transitions (per Decision 5 in the M6 design). - Don't create a second path to issue plan-product grants. Route operator-initiated grants on plan products through the Enrollment tab, which calls
Transition; the general Grants tab is for non-plan products only (seegrant-plan-safety.mdgap #1). - Don't attempt to keep
billing.subscription_changesandentitlements.pool_provision_transitionsin sync by hand. Both get written independently by their respective writers;subscription_changesis Stripe-side canonical,pool_provision_transitionsis enrollment-side canonical.
Related documents
docs/grant-plan-safety.md— incident-memory log for plan-related safety issues (gaps #1–#3 closed byplan-sole-writer-guards).docs/plan-management.md— operator-facing guide to plan configuration and transition workflows.openspec/changes/plan-management-foundation/design.md— design decisions log (end-and-re-apply, NULL-default contract, single-primitive rationale).openspec/changes/plan-management-foundation/specs/plan-transitions/spec.md— spec for the transition capability (treated as a distinct capability at the spec layer even though code is distributed).