11 KiB
plan-ladders Specification
Purpose
TBD - created by archiving change plan-management-foundation. Update Purpose after archive.
Requirements
Requirement: Plan ladder table
The system SHALL maintain a billing.plan_ladders table with fields: plan_ladder_id (UUID PK), ladder_key (VARCHAR, UNIQUE, NOT NULL), name (VARCHAR, NOT NULL), description (TEXT, nullable), is_active (BOOLEAN, NOT NULL, DEFAULT TRUE), created_at (TIMESTAMPTZ, NOT NULL, DEFAULT NOW()), updated_at (TIMESTAMPTZ, NOT NULL, DEFAULT NOW()). A plan ladder represents an ordered progression of product tiers (e.g., Public → Standard) within which at most one active provision per pool is permitted.
Scenario: Create a plan ladder
- WHEN a migration or operator creates a plan ladder with
ladder_key = 'core' - THEN the
billing.plan_ladderstable SHALL contain a row withladder_key = 'core',is_active = true, and a generatedplan_ladder_id
Scenario: Ladder key uniqueness
- WHEN an attempt is made to create a second plan ladder with an existing
ladder_key - THEN the database SHALL reject the insertion with a unique-constraint violation
Requirement: Plan ladder tier table
The system SHALL maintain a billing.plan_ladder_tiers junction table that is the sole and universal representation of product-ladder membership (Doc 31 Amendment #3 — no products.plan_ladder_id column exists). Fields: plan_ladder_id (UUID, FK → billing.plan_ladders, NOT NULL), product_id (UUID, FK → billing.products, NOT NULL), rank (INTEGER, NOT NULL), created_at, updated_at. The PRIMARY KEY SHALL be the composite (plan_ladder_id, product_id). The pair (plan_ladder_id, rank) SHALL be UNIQUE. Higher rank values represent higher tiers.
Scenario: Attach a product as a ladder tier
- WHEN an operator adds product
public-tierto laddercoreat rank0 - THEN the
billing.plan_ladder_tierstable SHALL contain a row linking those IDs withrank = 0
Scenario: Rank uniqueness within a ladder
- WHEN an attempt is made to add a second tier to the same ladder with a
rankalready in use - THEN the database SHALL reject the insertion with a unique-constraint violation
Scenario: Product uniqueness within a ladder
- WHEN an attempt is made to attach the same product to the same ladder twice
- THEN the database SHALL reject the insertion with a primary-key violation on
(plan_ladder_id, product_id)
Requirement: product_type domain narrowing and nullability
Per Doc 31 Amendment #3, billing.products.product_type SHALL be nullable, and its domain SHALL be narrowed to {addon, usage, one_time} (the 'plan' value is retired). A CHECK constraint SHALL enforce product_type IS NULL OR product_type IN ('addon', 'usage', 'one_time'). NULL is load-bearing for two cases: plan products (kind is derived structurally from plan_ladder_tiers presence, so the label column has nothing to say) and draft products that have not yet declared a kind. The invariant "every published non-plan product has a non-NULL product_type" is deliberately NOT schema-enforced; it is an app-layer publication gate (enforcing it at the schema would require a cross-table CHECK against plan_ladder_tiers, which is precisely the posture Amendment #3 avoids).
Scenario: product_type outside narrowed domain is rejected
- WHEN an attempt is made to insert a product with
product_type = 'plan'(or any value not in{addon, usage, one_time}) - THEN the database SHALL reject the insertion with a CHECK-constraint violation
Scenario: NULL product_type is accepted for plan products
- WHEN a product attached to a ladder via
plan_ladder_tierscarriesproduct_type = NULL - THEN the database SHALL accept the row (NULL is permitted; the product is a plan by virtue of its tier membership)
Scenario: NULL product_type is accepted for draft products
- WHEN a product with
lifecycle_status = 'draft'and noplan_ladder_tiersrow carriesproduct_type = NULL - THEN the database SHALL accept the row (publication gating is enforced at the application layer)
Requirement: product_kinds derivation view
The system SHALL maintain a billing.product_kinds VIEW that exposes a derived product_kind for every row in billing.products. The view SHALL return 'plan' when the product has at least one billing.plan_ladder_tiers row, and SHALL otherwise return the value of product_type (which is 'addon', 'usage', 'one_time', or NULL). Consumers that need to ask "is this a plan product?" SHALL read the view rather than the underlying label column.
Scenario: View returns 'plan' for ladder-attached products
- WHEN a product has at least one row in
billing.plan_ladder_tiers - THEN
billing.product_kinds.product_kindfor that product SHALL return'plan'
Scenario: View returns product_type for non-plan products
- WHEN a product has no
billing.plan_ladder_tiersrows and carriesproduct_type = 'addon' - THEN
billing.product_kinds.product_kindfor that product SHALL return'addon'
Scenario: View returns NULL for draft products with no kind declared
- WHEN a product has no
billing.plan_ladder_tiersrows and carriesproduct_type = NULL - THEN
billing.product_kinds.product_kindfor that product SHALL returnNULL
Requirement: lifecycle_status column on products
The system SHALL add a lifecycle_status column to billing.products of type VARCHAR(20) NOT NULL DEFAULT 'draft', enforced by a CHECK constraint lifecycle_status IN ('draft', 'published', 'retired'). The lifecycle dimension is orthogonal to the kind dimension: a product may be a draft plan, a published addon, a retired usage product, etc.
Scenario: New product defaults to draft lifecycle_status
- WHEN an operator inserts a new product without specifying
lifecycle_status - THEN the row SHALL carry
lifecycle_status = 'draft'
Scenario: lifecycle_status outside domain is rejected
- WHEN an attempt is made to insert or update a product with
lifecycle_statusset to a value outside{draft, published, retired} - THEN the database SHALL reject the operation with a CHECK-constraint violation
Requirement: Pool provision ladder attachment with temporal mutual exclusion
The system SHALL maintain an entitlements.pool_provision_ladders table with fields: provision_id (UUID, FK → entitlements.pool_provisions, NOT NULL), plan_ladder_id (UUID, FK → billing.plan_ladders, NOT NULL), pool_id (UUID, FK → entitlements.resource_pools, NOT NULL; denormalized from pool_provisions for the exclusion predicate), status (VARCHAR, NOT NULL; denormalized from pool_provisions.status), activated_at (TIMESTAMPTZ, NOT NULL; denormalized), ended_at (TIMESTAMPTZ, nullable; denormalized), created_at, updated_at. The PRIMARY KEY SHALL be the composite (provision_id, plan_ladder_id). A GiST EXCLUDE constraint SHALL prevent two rows from coexisting on the same (pool_id, plan_ladder_id) with overlapping tstzrange(activated_at, ended_at, '[)') WHERE status = 'active'. This enforces at most one active ladder attachment per pool per ladder at any instant, while permitting historical rows to coexist.
Scenario: Two active attachments on the same ladder are rejected
- WHEN a pool has an active
pool_provision_laddersrow for laddercore - AND an attempt is made to insert a second row for the same pool and ladder with
status = 'active'and an overlappingtstzrange(activated_at, ended_at) - THEN the database SHALL reject the insertion with an exclusion-constraint violation
Scenario: Ended attachment does not block new attachment
- WHEN a pool has a
pool_provision_laddersrow withstatus != 'active'(e.g.,'ended') for laddercore - AND a new row is inserted for the same pool and ladder with
status = 'active'and a non-overlapping time range - THEN the database SHALL accept the insertion (the GiST predicate filters to
status = 'active'rows only)
Scenario: Active attachments on distinct ladders coexist
- WHEN a pool has an active
pool_provision_laddersrow for laddercore - AND a row is inserted for the same pool on a different ladder
addonswithstatus = 'active' - THEN the database SHALL accept the insertion
Requirement: Ladder-attachment sync trigger
The system SHALL enforce denormalization integrity via an AFTER UPDATE trigger on entitlements.pool_provisions that propagates pool_id, status, activated_at, and ended_at from the provision to all corresponding pool_provision_ladders rows. This trigger guarantees the GiST exclusion predicate reflects reality regardless of the code path that mutates the provision.
Scenario: Ending a provision ends its ladder attachment
- WHEN a
pool_provisionsrow transitions fromstatus = 'active'tostatus = 'ended'withended_at = T - AND that provision has a corresponding
pool_provision_laddersrow - THEN the trigger SHALL update the ladder row's
statusto'ended'andended_attoT
Scenario: Provision without ladder attachment is unaffected
- WHEN a status transition occurs for a provision that has no
pool_provision_laddersrow - THEN the trigger SHALL take no action and no error SHALL be raised
Requirement: Public Tier migration backfill
On the migration that introduces plan ladders, the system SHALL seed a default plan ladder, attach the existing Public Tier product to it at rank = 0 (via billing.plan_ladder_tiers), and backfill a pool_provision_ladders row for every currently active default-grant-sourced pool_provisions row whose entitlement set resolves to the Public Tier product. The Public Tier product SHALL be set to product_type = NULL and lifecycle_status = 'published' (kind becomes 'plan' structurally via the product_kinds view once the tier row is inserted). An initial pool_provision_transitions row SHALL be recorded for each backfilled attachment with transition_type = 'initiate', actor_type = 'system', reason indicating M6 ladder backfill.
Scenario: Existing Public Tier pools gain ladder attachment on migration
- WHEN the plan-ladder migration runs against a database with N active default-grant
pool_provisionsresolving to the Public Tier product - THEN the
billing.plan_ladderstable SHALL contain the seeded default ladder - AND the
billing.plan_ladder_tierstable SHALL contain one row linking Public Tier to that ladder atrank = 0 - AND the Public Tier product's
product_typeSHALL beNULLandlifecycle_statusSHALL be'published' - AND the
entitlements.pool_provision_ladderstable SHALL contain N active rows (one per affected pool) - AND the
entitlements.pool_provision_transitionstable SHALL contain Ninitiaterows