Files
cgalo5758 30b8250cf5 Pause integration schedules when disabled
Resume only app-paused schedules so operator pauses survive restarts.
Apply dormancy handling to Discourse and FedWiki.

Remove the unused pool-scoped grant route, hide internal transition
errors, and update specs and tests to use the canonical grant endpoint.
2026-07-23 01:38:45 -05:00

38 KiB

plan-transitions Specification

Purpose

TBD - created by archiving change plan-management-foundation. Update Purpose after archive.

Requirements

Requirement: Transition primitive is the sole pathway for ladder-position changes

Ladder-position changes SHALL be enacted exclusively by the database conferral primitive — the entitlements.confer, entitlements.end_conferral, and entitlements.align_conferral_shape functions (SECURITY DEFINER, owned by entitlements_owner; the five-function family's full signatures, derivation rules, and enclosure are owned by the entitlement-conferral capability). The application-level entitlements.Transition primitive is retired: no Go-level function SHALL resolve a target delta or itself record a ladder-position change. Operator-driven actions, Stripe webhook handlers, purchases, and system-default issuance SHALL invoke confer / end_conferral / align_conferral_shape — supplying pool, product, and exactly one source, per the entry-point contract owned by entitlement-conferral — rather than invoking Transition or manipulating pool_provisions, pool_provision_ladders, or pool_provision_transitions directly or through any other application-level intermediary.

Scenario: Operator force-transition invokes the conferral primitive

  • WHEN an operator force-transitions a pool to a specific ladder tier via the operator UI
  • THEN the system SHALL resolve the target product and invoke entitlements.confer (or entitlements.end_conferral for a detach) rather than any application-level Transition function
  • AND the system SHALL NOT insert or update pool_provisions, pool_provision_ladders, or pool_provision_transitions rows outside the confer / end_conferral / align_conferral_shape family

Scenario: Stripe webhook invokes the conferral primitive

  • WHEN a Stripe subscription lifecycle event resolves to a ladder-position change (activation, item/product change, or cancellation)
  • THEN the webhook handler SHALL invoke entitlements.confer or entitlements.end_conferral (as appropriate to the event) with the resolved product and subscription source, rather than directly creating or ending provisions or invoking Transition

Scenario: Stripe webhook invokes sync_source_status for suspend/resume only

  • WHEN a Stripe subscription lifecycle event changes only the subscription's delinquency status (for example, entering or recovering from past_due) without altering which product or rank the subscription confers
  • THEN the webhook handler SHALL invoke entitlements.sync_source_status to propagate suspended or active onto the subscription's non-ended provisions
  • AND this call SHALL NOT itself change any pool's ladder position — no pool_provision_ladders row SHALL be created, ended, or reassigned to a different rank as a result of sync_source_status alone

Scenario: Direct provision writes bypassing the primitive are rejected

  • WHEN any application code attempts to INSERT, UPDATE, or DELETE against pool_provisions, pool_provision_ladders, or pool_provision_transitions outside the enclosed function family
  • THEN the write SHALL be rejected by the database privilege system (the enclosure posture owned by the entitlement-conferral capability) rather than succeeding through an ad hoc application code path

Requirement: Transition type resolution

The transition_type recorded for a per-ladder occupancy change SHALL be resolved by comparing the candidate product's rank on that ladder against the incumbent's rank on the same ladder — both drawn from billing.product_conferral_shapes.ladder_ranks, the single read path the primitive consults for ranks:

  • initiate when no non-ended incumbent occupies the ladder rung (from_rank = NULL, to_rank is the candidate's rank)
  • upgrade when the incumbent's rank is lower than the candidate's rank (to_rank > from_rank)
  • downgrade when the incumbent's rank is higher than the candidate's rank, or when the candidate's shape vacates the ladder entirely (to_rank IS NULL or to_rank < from_rank)
  • transfer when the incumbent's rank equals the candidate's rank — reachable only when the incumbent is being superseded by a different source row delivering the same product (rank equality follows from UNIQUE (plan_ladder_id, rank)); never reachable from a same-source retry (idempotent, no transition written) or from a genuine rank change

This resolution is performed entirely inside entitlements.confer — the function's full contract, read-path, and postcondition ordering are owned by the entitlement-conferral capability. No application-level Transition function performs this resolution, or exists to be called; ending an occupancy (the end transition type) is a distinct operation performed by entitlements.end_conferral, not a branch of this rank-comparison resolution.

Scenario: First attachment is recorded as initiate

  • WHEN entitlements.confer conferrs a product occupying a ladder rung to a pool with no non-ended incumbent on that ladder
  • THEN the recorded transition_type SHALL be initiate with from_rank = NULL and to_rank set to the candidate's rank

Scenario: A higher-ranked candidate is recorded as upgrade

  • WHEN confer's candidate rank on a ladder is higher than the rank of the non-ended incumbent it supersedes on that ladder
  • THEN the recorded transition_type SHALL be upgrade with from_rank set to the incumbent's rank and to_rank set to the candidate's rank

Scenario: A lower-ranked candidate is recorded as downgrade

  • WHEN confer's candidate rank on a ladder is lower than the rank of the non-ended incumbent it supersedes on that ladder
  • THEN the recorded transition_type SHALL be downgrade with from_rank set to the incumbent's rank and to_rank set to the candidate's rank

Scenario: Equal-rank cross-source resolution records transfer

  • WHEN confer's candidate rank on a ladder equals the rank of the non-ended incumbent it supersedes, and the candidate and incumbent are the same product delivered by different source rows
  • THEN the recorded transition_type SHALL be transfer with from_rank = to_rank
  • AND the transition SHALL NOT be recorded as upgrade or downgrade

Requirement: End-and-re-apply supersession

When entitlements.confer determines that the candidate supersedes a live-or-suspended incumbent — whether a different product on the same ladder (upgrade/downgrade) or the same product from a different source row at equal rank (transfer) — the whole-bundle ending of the incumbent (every ladder rung it occupied, including rungs the candidate's shape does not reoccupy) and the creation of the new pool_provisions row and its pool_provision_ladders row(s) SHALL both occur inside the single confer invocation, in the same transaction (the enactment order and derivation rules are owned by the entitlement-conferral capability's confer contract). No caller SHALL itself end the incumbent provision and separately issue a create as two independently-sequenced writes; there is no application-level "end, then apply" orchestration for a supersession event.

A conferral naming an explicit lower-ranked target product and a backing source (an operator downgrade decree, a subscription item change) SHALL land the pool on that explicit target product — never rerouted to the org default — because confer always creates the new provision from the caller's supplied product and source, never substituting a different one.

Detach with no replacement target (cancellation) is a distinct operation, entitlements.end_conferral, which ends the incumbent's whole bundle and creates nothing. Re-applying the org's configured default after such a detach is NOT performed by end_conferral itself: it is the caller's responsibility, expressed as a separate call to the auto-provisioning capability's ReapplyDefaultsForPool (which itself calls confer or end_conferral again) when re-application of the default is the desired semantics. A caller that means "cancel to the default" SHALL invoke end_conferral and then, separately, ReapplyDefaultsForPool — it SHALL NOT attempt to encode "downgrade onto the default tier" as a single call.

Scenario: Confer atomically ends the incumbent bundle and creates the new provision

  • WHEN entitlements.confer supersedes a live-or-suspended incumbent occupying one or more ladder rungs
  • THEN within the same function invocation and transaction, the incumbent's pool_provisions row SHALL become status = 'ended' and every pool_provision_ladders row it held SHALL be ended — including rungs the candidate's shape does not reoccupy
  • AND a new pool_provisions row and the candidate's pool_provision_ladders row(s) SHALL be created within that same invocation
  • AND no separate caller-level call sequences the ending and the creation as two writes

Scenario: An explicit downgrade lands on the requested lower tier, not the org default

  • WHEN a caller invokes confer with a specific lower-ranked product and a backing source (Source = S)
  • THEN the pool SHALL be conferred the explicit target product, recorded as a downgrade transition, backed by S
  • AND the org's default plan SHALL NOT be substituted for the caller's explicit target

Scenario: Cancellation to the org default is two calls, not one

  • WHEN a caller means "cancel to the default" (subscription cancellation, grant expiry, operator revoke)
  • THEN the caller SHALL invoke entitlements.end_conferral to end the existing conferral
  • AND, when re-applying the org default is the desired semantics, the caller SHALL separately invoke the auto-provisioning capability's ReapplyDefaultsForPool
  • AND end_conferral itself SHALL NOT re-apply the org default as a side effect of ending

Requirement: Idempotency

entitlements.confer SHALL be idempotent with respect to (pool, product, source): invoking confer again for the same pool, product, and source row, while a non-ended (active or suspended) provision from that source row already confers that product, SHALL be a no-op that returns the incumbent's provision_id and appends no new pool_provisions, pool_provision_ladders, or pool_provision_transitions row. A suspended incumbent counts as occupying for this check identically to active — a replayed call SHALL NOT create a new provision merely because the incumbent is currently suspended.

This idempotence is source-scoped, not merely position-scoped: two non-ended provisions of the same product delivered by different source rows are never idempotent with each other. A same-product conferral from a different source is resolved as supersession (recording transfer) or, for unrelated grant decrees of a position-bearing product, the decree-duplication no-op — both derivations are owned by the entitlement-conferral capability's confer contract, which this requirement defers to rather than restates.

Scenario: Re-invocation from the same source is a no-op

  • WHEN confer is called again with the same pool, product, and source row while the prior provision from that source is active or suspended
  • THEN no new pool_provisions, pool_provision_ladders, or pool_provision_transitions rows SHALL be inserted
  • AND the existing provision SHALL be returned as the noop outcome

Scenario: A suspended incumbent counts as occupying for idempotence

  • WHEN confer is called again with the same pool, product, and source row while the prior provision from that source is status = 'suspended'
  • THEN the call SHALL still return the noop outcome
  • AND no new provision SHALL be created merely because the incumbent is not currently active

Scenario: Cross-source commercial conferral of the same product is not idempotent

  • WHEN confer is called for the same pool and product from a commercial source (a subscription or purchase) other than the source backing the current non-ended incumbent — for example, a paid subscription for the product a system default grant already delivers
  • THEN the call SHALL NOT return noop
  • AND the call SHALL be resolved as supersession, recording a transfer transition for the equal-rank cross-source case, per the entitlement-conferral capability's contract
  • AND the narrower two-unrelated-grant-decrees case (both incumbent and candidate grant-sourced from decrees with no extends_grant_id lineage between them) remains the decree-duplication noop owned by the same contract, not a supersession

Requirement: pool_provision_transitions table

The system SHALL maintain an entitlements.pool_provision_transitions table with fields: transition_id (UUID PK), pool_id (UUID, FK → entitlements.resource_pools, NOT NULL), provision_id (UUID, FK → entitlements.pool_provisions, NOT NULL), provision_ladder_id (UUID, FK → entitlements.pool_provision_ladders.provision_ladder_id), plan_ladder_id (UUID, FK → billing.plan_ladders), from_rank (INTEGER, nullable), to_rank (INTEGER, nullable), transition_type (VARCHAR NOT NULL, one of initiate, upgrade, downgrade, transfer, end), actor_type (VARCHAR NOT NULL, one of operator, system, webhook), actor_id (UUID, nullable), reason (TEXT, nullable), effective_at (TIMESTAMPTZ NOT NULL), created_at (TIMESTAMPTZ NOT NULL, DEFAULT NOW()). Every row written from cutover forward SHALL be position-scoped: plan_ladder_id and provision_ladder_id SHALL both be non-NULL (the provision_ladder_id FK's occupancy-episode semantics are specified in the "pool_provision_transitions rows must reference a real occupancy episode" requirement below); historical off-ladder rows predating this change remain readable and unmodified. initiate, upgrade, and downgrade retain rank-change semantics unchanged: initiate records from_rank = NULL; upgrade requires to_rank > from_rank; downgrade requires to_rank < from_rank or to_rank = NULL (the ladder is vacated). transfer is the taxonomy's added member: it records the equal-rank cross-source case — the candidate and the superseded incumbent are the same product at the same rank, delivered by different source rows (for example, a grant_reason = 'default' grant handed off to a paid subscription for the same product at the same rank, or an extend-as-replace) — and requires from_rank = to_rank, both non-NULL. extend is retired from the domain; extend-as-replace conferrals now record transfer (the enactment is owned by the entitlement-conferral capability's confer contract). The table SHALL enforce CHECK (actor_type != 'operator' OR actor_id IS NOT NULL) and a rank-shape CHECK binding each transition_type to the rank pattern above.

Scenario: Operator transition requires actor_id

  • WHEN an attempt is made to insert a row with actor_type = 'operator' and actor_id = NULL
  • THEN the database SHALL reject the insertion with a CHECK-constraint violation

Scenario: System transition may omit actor_id

  • WHEN a row is inserted with actor_type = 'system' and actor_id = NULL
  • THEN the database SHALL accept the insertion

Scenario: Equal-rank cross-source supersession records transfer

  • WHEN a conferral supersedes an incumbent occupying the same ladder at the same rank, and the candidate and incumbent are the same product delivered by different source rows (for example, a system default grant superseded by a paid subscription for the same product)
  • THEN the recorded transition_type SHALL be transfer with from_rank = to_rank, both non-NULL
  • AND the transition SHALL NOT be recorded as upgrade or downgrade

Scenario: Rank-change transitions retain strict inequality

  • WHEN a pool_provision_transitions row is inserted with transition_type = 'upgrade'
  • THEN the database SHALL reject the row unless to_rank > from_rank
  • AND a row inserted with transition_type = 'downgrade' SHALL be rejected unless to_rank < from_rank or to_rank IS NULL

Scenario: extend is no longer a representable transition_type

  • WHEN an insert attempts transition_type = 'extend'
  • THEN the database SHALL reject the insertion with a CHECK-constraint violation
  • AND the corresponding extend-as-replace conferral SHALL instead record transition_type = 'transfer'

Requirement: Transition recording responsibility

pool_provision_transitions rows SHALL be written exclusively by the five conferral functions — entitlements.confer, entitlements.end_conferral, entitlements.sync_source_status, entitlements.align_conferral_shape, and entitlements.update_conferral_bounds (of which sync_source_status and update_conferral_bounds emit no transition rows, per their own contracts) — under the enclosure that revokes direct DML on pool_provision_transitions from the application writer role (the enclosure mechanics are owned by the entitlement-conferral and per-schema-roles capabilities). No Go-level code, workflow, or handler SHALL insert a pool_provision_transitions row directly; every audit row traces to one of these five function invocations, and nothing else CAN write one under the enclosure. confer records one transition per ladder rung whose occupancy changes in a single invocation (more than one for a multi-ladder product) plus one end transition per rung of any superseded incumbent; end_conferral records one end transition per rung it ends; align_conferral_shape records one initiate or end transition per rung it reconciles. The auto-provisioning capability's ReapplyDefaultsForPool records no transitions of its own — every row it produces is written by the confer or end_conferral call it makes internally.

Scenario: Only the five functions can write a transition row

  • WHEN any application code path attempts to INSERT directly into pool_provision_transitions
  • THEN the write SHALL be rejected by the database privilege system
  • AND the only functions that emit a transition row SHALL be confer, end_conferral, and align_conferral_shape; sync_source_status and update_conferral_bounds hold the same enclosed write privilege but emit no transition rows of their own

Scenario: ReapplyDefaultsForPool writes no transitions of its own

  • WHEN ReapplyDefaultsForPool re-applies the org default by calling confer or end_conferral
  • THEN every pool_provision_transitions row produced SHALL be attributed to that inner confer or end_conferral call
  • AND ReapplyDefaultsForPool itself SHALL NOT insert a transitions row directly

Scenario: Multi-rung conferral records one transition per rung

  • WHEN confer conferred a product occupying two ladder rungs in one invocation
  • THEN two pool_provision_transitions rows SHALL be recorded, one per rung, each independently classified as initiate, upgrade, downgrade, or transfer

Requirement: Canonicalization against subscription_changes

entitlements.pool_provision_transitions is canonical for plan-position history: which tier a pool held, when, who changed it, why. billing.subscription_changes remains canonical for commercial mutations of a subscription: status transitions (trialing, past_due, canceled), period boundaries, amount changes. Subscription-driven ladder attachments SHALL produce rows in both tables — a pool_provision_transitions row for the plan-position event and a subscription_changes row for the commercial event. The GLOSSARY SHALL be updated to document this canonicalization.

Scenario: Subscription activation writes to both tables

  • WHEN a customer.subscription.created webhook is processed and results in an upgrade transition for a pool
  • THEN the billing.subscription_changes table SHALL contain a row with new_status = 'active' (or 'trialing') and the Stripe event ID
  • AND the entitlements.pool_provision_transitions table SHALL contain a row with transition_type = 'upgrade' or 'initiate'

Requirement: View-shaped audit projection

The pool_provision_transitions table SHALL be structured as a view-shaped projection of a future generic audit log. Columns SHALL map to a standard audit shape: resource_type = 'pool_provision', resource_id = provision_id, actor_type, actor_id, action = transition_type, occurred_at = effective_at, recorded_at = created_at, payload = {pool_id, plan_ladder_id, from_rank, to_rank, reason}. When a generic audit.log module graduates from the backlog, this table SHALL be absorbable via a UNION ALL view without semantic rewriting.

Scenario: Columns align with generic audit shape

  • WHEN the schema for pool_provision_transitions is inspected
  • THEN every non-primary-key, non-timestamp column SHALL correspond to a field in the standard audit shape or the scenario-specific payload

Requirement: CreateGrant handler rejects plan-tier products

The general-purpose operator grant-creation handler at POST /partials/operator/grants SHALL reject any grant whose selected product is attached to at least one billing.plan_ladder_tiers row. The response SHALL be an error rendered in the operator partial with a message directing the operator to the per-organization Enrollment tab. The handler SHALL NOT insert a grants or pool_provisions row for plan-tier products.

Scenario: Plan-product grant is rejected from general Grants tab

  • WHEN an operator submits the general grant form with a product that is attached as a tier on any plan ladder
  • THEN the handler SHALL respond with an error partial pointing to the Enrollment tab
  • AND no grants row SHALL be created
  • AND no pool_provisions row SHALL be created
  • AND no pool_provision_ladders or pool_provision_transitions row SHALL be created

Scenario: Non-plan-product grant succeeds from general Grants tab

  • WHEN an operator submits the general grant form with a product whose product_type is addon, usage, or one_time, and which has no plan_ladder_tiers membership
  • THEN the handler SHALL create the grant via the existing path unchanged

Requirement: RevokeGrant handler delegates to the transition primitive for plan-linked provisions

The operator grant-revocation handler at POST /partials/operator/grants/{grantID}/revoke SHALL, for every grant — regardless of whether its live provision holds a ladder attachment — invoke entitlements.end_conferral(grant_id := <the revoked grant>) as its sole enactment step, per the plan-enrollment-administration capability's "Operator grant revocation" requirement (which owns the decree-first sequencing, the double-revoke guard, and the grant_reason = 'default' revocation block). The handler SHALL NOT branch on whether a pool_provision_ladders row exists for the grant's provision, and SHALL NOT fall back to a separate non-ladder revoke path: end_conferral resolves and ends whatever the source's live provision actually holds — on-ladder rungs, off-ladder capability, or nothing at all (the zero-live-target case) — uniformly, with no ladder-membership branching in the handler.

end_conferral records an end transition for each ladder rung the ended provision occupied; a provision with no ladder attachment produces no transition row, and this is the ordinary consequence of the product's shape, not a distinct code path. The prior special-cased distinction between a "downgrade to the default" outcome and an "end" outcome is retired along with the rank-based branching it required: ending a provision never itself re-mints a default grant; re-application of the org default, when desired, is a separate call to ReapplyDefaultsForPool (owned by auto-provisioning), never something end_conferral performs as a side effect of ending.

Scenario: Revocation enacts end_conferral uniformly, with no ladder-membership branch

  • WHEN an operator revokes any grant, whether its live provision holds a ladder attachment or not
  • THEN the handler SHALL invoke entitlements.end_conferral(grant_id := <the grant>) as its sole enactment call
  • AND the handler SHALL NOT inspect pool_provision_ladders to select between two different revoke code paths

Scenario: Ending a provision never re-mints the org default as a side effect

  • WHEN an operator revokes a grant whose provision occupies the org's default tier
  • THEN end_conferral SHALL end the provision and record an end transition for each rung it occupied
  • AND no new grant_reason = 'default' grant SHALL be minted by end_conferral itself
  • AND re-applying the org default, if desired, SHALL require a separate call to the auto-provisioning capability's ReapplyDefaultsForPool

Scenario: Revoking an off-ladder grant records no transition

  • WHEN an operator revokes a grant whose live provision holds no ladder attachment
  • THEN end_conferral SHALL end the provision
  • AND no pool_provision_transitions row SHALL be recorded, because the provision occupied no ladder rung

Requirement: IssueGrant serializes concurrent operators on the same pool

Serialization of concurrent operators targeting the same pool SHALL be provided by entitlements.confer's own pool-row lock (SELECT ... FOR UPDATE on entitlements.resource_pools, acquired inside the function before resolving any ladder state — owned by the entitlement-conferral capability's contract). The operator grant-issuance handler at POST /partials/operator/organizations/{orgID}/grant/create (per the plan-enrollment-administration capability) SHALL NOT itself acquire a row lock on the pool before calling confer; it SHALL rely entirely on the lock confer takes internally. This applies uniformly to every entry point that calls confer or end_conferral — no handler-level lock exists or is needed, because every family member that touches the position machinery takes the identical pool lock as its first act.

Scenario: Concurrent trial-grant issuance on the same pool serializes inside confer

  • WHEN two operators concurrently POST /partials/operator/organizations/{orgID}/grant/create for the same org's pool with different target products
  • THEN the two transactions SHALL serialize on the pool-row lock entitlements.confer acquires internally
  • AND the first transaction SHALL commit with a valid conferral recorded
  • AND the second SHALL observe the first's committed state (a noop if the same product/source, or a correct supersession otherwise)
  • AND no pool_provision_ladders row SHALL exist in a state that violates the GiST exclusion constraint

Scenario: The handler acquires no lock of its own

  • WHEN the operator grant-issuance handler processes a request
  • THEN it SHALL NOT execute a SELECT ... FOR UPDATE against resource_pools itself
  • AND it SHALL rely on the lock entitlements.confer acquires as part of its own contract

Requirement: Transition supports an Extend target for issuing additional grants on the current tier

transition_type = 'extend' SHALL NOT be written by any conferral going forward — the CHECK-constraint domain no longer admits it (see the "pool_provision_transitions table" requirement above) — and extend-as-replace SHALL instead be enacted by the operator grant-extension action creating a new grants row with extends_grant_id set to the prior grant, then invoking entitlements.confer with the new grant as source. Historical rows carrying transition_type = 'extend', written before this change, remain valid, readable, and unmodified.

The application-level entitlements.TransitionTarget.Extend field and the Transition primitive's extend-handling branch are retired along with Transition itself (see "Transition primitive is the sole pathway for ladder-position changes"). The extension action is owned by the plan-enrollment-administration capability's "Operator grant extension is extend-as-replace" requirement. confer recognizes the lineage (the candidate's extends_grant_id names the incumbent's source grant), supersedes the incumbent provision whole, and creates the replacement at the same tier and rank — recording the transition as transfer, never as extend (the entitlement-conferral capability owns this derivation in full).

Scenario: Extend-as-replace records transfer, not extend

  • WHEN an operator extends an existing grant at its current tier
  • THEN confer SHALL supersede the prior provision and create the replacement, recording transition_type = 'transfer'
  • AND no pool_provision_transitions row SHALL be written with transition_type = 'extend'

Scenario: Historical extend rows remain valid

  • WHEN a pool_provision_transitions row written before this change carries transition_type = 'extend'
  • THEN the row SHALL remain readable and SHALL NOT be rewritten or migrated to a different transition_type

Scenario: No TransitionTarget.Extend call path remains

  • WHEN application code needs to issue an extension
  • THEN it SHALL do so via the operator grant-extension action's new-grant-plus-confer call
  • AND no TransitionTarget.Extend field or Transition function invocation SHALL exist as an alternate path

Requirement: Operator Enrollment UI exposes an Extend action

The operator enrollment partial SHALL surface an "Extend current tier" action on pools that already hold an active grant at the displayed tier. The action SHALL POST to /partials/operator/organizations/{orgID}/pools/{poolID}/grant/extend. Enactment SHALL be decree-then-confer, per the plan-enrollment-administration capability's "Operator grant extension is extend-as-replace" requirement: the handler creates a new grants row lineaged via extends_grant_id to the pool's current grant at that tier, then invokes entitlements.confer with the new grant as source, which supersedes the prior provision and records the transition as transfer (never extend). The handler SHALL NOT invoke any application-level Transition function, and SHALL NOT acquire a bespoke pool lock of its own — confer's own pool-row lock (see "IssueGrant serializes concurrent operators on the same pool" above) serializes the call.

Scenario: Operator extends an existing tier from the Enrollment tab

  • WHEN an operator clicks "Extend current tier" on a pool that has an active grant at the displayed tier
  • THEN the handler SHALL create a new grants row with extends_grant_id naming the pool's current grant at that tier
  • AND the handler SHALL invoke entitlements.confer with the new grant as the source
  • AND the response SHALL re-render the enrollment partial with the new transfer row visible in transition history

Requirement: Occupancy episodes are identified by a surrogate provision_ladder_id

Each entitlements.pool_provision_ladders row SHALL be identified by a surrogate primary key, provision_ladder_id (UUID), rather than by the natural key (provision_id, plan_ladder_id). Uniqueness of (provision_id, plan_ladder_id) SHALL be enforced only among live (non-ended) rows, via a partial unique index scoped WHERE status <> 'ended'. A ladder rung a given provision once held and later lost — because the rung was retired from that provision's shape or the provision was superseded and later re-conferred — SHALL be recorded, on reoccupation, as a NEW row carrying a freshly generated provision_ladder_id; it SHALL NEVER be recorded by reopening or reusing a historical row for the same (provision_id, plan_ladder_id) pair.

Scenario: Reoccupying a lost rung creates a new episode

  • WHEN a provision's occupancy of a ladder rung has ended and the same (provision_id, plan_ladder_id) pair is later reconferred
  • THEN a new pool_provision_ladders row SHALL be inserted with a freshly generated provision_ladder_id
  • AND the prior, ended row for that pair SHALL remain unmodified and readable

Scenario: At most one live row per occupancy pair

  • WHEN an insert would create a second non-ended pool_provision_ladders row for the same (provision_id, plan_ladder_id) pair
  • THEN the database SHALL reject the insert via the live-scoped partial unique index

Requirement: pool_provision_transitions rows must reference a real occupancy episode

Every entitlements.pool_provision_transitions row SHALL name the specific occupancy episode it records via a provision_ladder_id foreign key to entitlements.pool_provision_ladders.provision_ladder_id. A transition row SHALL NOT be insertable for an occupancy pair for which no pool_provision_ladders row — live or historical — exists: an audit row can never name an episode that did not exist. Consequently, a transition recording newly gained occupancy (initiate, upgrade, downgrade, or transfer) SHALL be written only after its corresponding pool_provision_ladders row has been inserted; a transition recording lost occupancy (end) SHALL reference the occupancy episode being ended.

Scenario: Transition insert requires an existing occupancy episode

  • WHEN a pool_provision_transitions row is inserted with a provision_ladder_id that does not reference any pool_provision_ladders row
  • THEN the database SHALL reject the insert with a foreign-key violation

Scenario: Incoming-occupancy transitions are recorded after the junction row exists

  • WHEN the conferral primitive records an initiate, upgrade, downgrade, or transfer transition for newly gained occupancy
  • THEN the corresponding pool_provision_ladders row SHALL already exist at the moment the transition row is inserted, satisfying provision_ladder_id's referential requirement

Requirement: Junction rows are logically ended, never deleted

When a pool's occupancy of a ladder rung ends — whether because its provision is superseded or ended, or because a still-live provision's shape no longer includes that rung — the corresponding pool_provision_ladders row SHALL be updated to status = 'ended' with ended_at set. It SHALL NOT be deleted. This settles the "delete (or logically end)" alternative in favor of the logical-end variant as the rule: the provision and its surviving junction rows remain untouched, and the retired rung's occupancy history remains a readable row rather than being erased.

Scenario: Partial ladder retirement logically ends the junction row

  • WHEN a still-live provision's product no longer occupies a ladder rung it previously held (a catalog-evolution shape change)
  • THEN the corresponding pool_provision_ladders row SHALL be updated to status = 'ended' with ended_at set
  • AND the row SHALL NOT be deleted from the table

Scenario: Ended junction rows remain queryable history

  • WHEN a pool_provision_ladders row has status = 'ended'
  • THEN it SHALL remain present in the table and joinable from the pool_provision_transitions rows that reference it

Requirement: Monotonic-end guard on the status-sync trigger

The trigger that propagates a provision's status, activated_at, and ended_at onto its pool_provision_ladders rows SHALL never reopen a junction row whose status already reads ended, even when the parent provision is subsequently updated (for example, resumed from suspension, or its bounds changed). The propagating UPDATE SHALL be scoped to non-ended junction rows only. A rung a provision regains after such an independent ending SHALL be recorded as a fresh occupancy episode — a new pool_provision_ladders row — never as a resurrection of the row that was independently ended.

Scenario: Trigger does not resurrect an independently-ended junction row

  • WHEN a provision's pool_provision_ladders row was individually ended (a rung retired from that provision's shape while the provision itself remained live) and the parent provision is later updated (status change, bounds change, or resumption)
  • THEN the status-sync trigger SHALL NOT revert that junction row's status away from ended

Scenario: Regaining a rung after independent ending is a fresh episode

  • WHEN a provision regains occupancy of a rung it previously held and independently lost
  • THEN the system SHALL insert a new pool_provision_ladders row for that occupancy rather than reactivating the row that was independently ended

Requirement: Suspended incumbents count as occupying for supersession

When determining which provision occupies a ladder rung for supersession purposes, the system SHALL treat a provision whose status is suspended (for example, a past-due subscription) as occupying, identically to active. Superseding a rung SHALL end a suspended incumbent exactly as it would an active one. Supersession SHALL NOT skip a suspended incumbent on the theory that it has already exited the live, exclusion-protected population.

Scenario: A suspended incumbent is superseded, not skipped

  • WHEN a new conferral targets a ladder rung currently held by a suspended provision from a different source
  • THEN the suspended incumbent SHALL be ended (status = 'ended') as part of the supersession
  • AND an end transition SHALL be recorded for the suspended incumbent's occupancy of that rung

Scenario: Occupancy scans include suspended incumbents

  • WHEN a conferral is evaluated against a ladder rung's current occupancy
  • THEN the occupancy scan SHALL include both active and suspended provisions
  • AND the system SHALL NOT confer over a suspended incumbent without first ending it

Requirement: Supersession ends the whole occupied bundle

When a provision is superseded, every ladder rung it occupied SHALL receive an end transition — including rungs the superseding product's own shape does not reoccupy. A provision is an atomic bundle delivered by one commercial source; supersession SHALL NOT leave a truncated remainder of the superseded provision's occupancy unrecorded merely because the new product does not contest every rung the old one held.

Scenario: Uncontested rungs still receive an end transition

  • WHEN a provision occupying two ladder rungs is superseded by a product whose shape occupies only one of those rungs
  • THEN both of the superseded provision's pool_provision_ladders rows SHALL be ended
  • AND an end transition SHALL be recorded for each rung the superseded provision occupied, including the rung the new product does not reoccupy

Scenario: Superseded provision is ended as a whole, not partially

  • WHEN a provision is superseded on any one of the rungs it occupies
  • THEN the superseded provision's status SHALL become ended in its entirety
  • AND no rung it occupied SHALL be left with a live junction row referencing the ended provision