Files
member-console/openspec/specs/plan-ladders/spec.md

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_ladders table SHALL contain a row with ladder_key = 'core', is_active = true, and a generated plan_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-tier to ladder core at rank 0
  • THEN the billing.plan_ladder_tiers table SHALL contain a row linking those IDs with rank = 0

Scenario: Rank uniqueness within a ladder

  • WHEN an attempt is made to add a second tier to the same ladder with a rank already 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_tiers carries product_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 no plan_ladder_tiers row carries product_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_kind for that product SHALL return 'plan'

Scenario: View returns product_type for non-plan products

  • WHEN a product has no billing.plan_ladder_tiers rows and carries product_type = 'addon'
  • THEN billing.product_kinds.product_kind for that product SHALL return 'addon'

Scenario: View returns NULL for draft products with no kind declared

  • WHEN a product has no billing.plan_ladder_tiers rows and carries product_type = NULL
  • THEN billing.product_kinds.product_kind for that product SHALL return NULL

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_status set 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_ladders row for ladder core
  • AND an attempt is made to insert a second row for the same pool and ladder with status = 'active' and an overlapping tstzrange(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_ladders row with status != 'active' (e.g., 'ended') for ladder core
  • 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_ladders row for ladder core
  • AND a row is inserted for the same pool on a different ladder addons with status = '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_provisions row transitions from status = 'active' to status = 'ended' with ended_at = T
  • AND that provision has a corresponding pool_provision_ladders row
  • THEN the trigger SHALL update the ladder row's status to 'ended' and ended_at to T

Scenario: Provision without ladder attachment is unaffected

  • WHEN a status transition occurs for a provision that has no pool_provision_ladders row
  • 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_provisions resolving to the Public Tier product
  • THEN the billing.plan_ladders table SHALL contain the seeded default ladder
  • AND the billing.plan_ladder_tiers table SHALL contain one row linking Public Tier to that ladder at rank = 0
  • AND the Public Tier product's product_type SHALL be NULL and lifecycle_status SHALL be 'published'
  • AND the entitlements.pool_provision_ladders table SHALL contain N active rows (one per affected pool)
  • AND the entitlements.pool_provision_transitions table SHALL contain N initiate rows