--- 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. - `Transition` is defined in `internal/entitlements/transitions.go`. - It classifies the event (`initiate`/`upgrade`/`downgrade`/`end`), ends the prior active provision, creates the new one, maintains the `pool_provision_ladders` attachment, writes the `pool_provision_transitions` audit row, and delegates to `ReapplyDefaultsForPool` on 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 ``" 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 both `billing` and `entitlements`, adding a dependency layer. - **Don't** write to `pool_provisions.status` directly except for `customer.subscription.updated` intermediate 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 (see `grant-plan-safety.md` gap #1). - **Don't** attempt to keep `billing.subscription_changes` and `entitlements.pool_provision_transitions` in sync by hand. Both get written independently by their respective writers; `subscription_changes` is Stripe-side canonical, `pool_provision_transitions` is enrollment-side canonical. ## Related documents - `docs/grant-plan-safety.md` — incident-memory log for plan-related safety issues (gaps #1–#3 closed by `plan-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).