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.
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(orentitlements.end_conferralfor a detach) rather than any application-levelTransitionfunction - AND the system SHALL NOT insert or update
pool_provisions,pool_provision_ladders, orpool_provision_transitionsrows outside theconfer/end_conferral/align_conferral_shapefamily
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.conferorentitlements.end_conferral(as appropriate to the event) with the resolved product and subscription source, rather than directly creating or ending provisions or invokingTransition
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_statusto propagatesuspendedoractiveonto the subscription's non-ended provisions - AND this call SHALL NOT itself change any pool's ladder position — no
pool_provision_laddersrow SHALL be created, ended, or reassigned to a different rank as a result ofsync_source_statusalone
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, orpool_provision_transitionsoutside the enclosed function family - THEN the write SHALL be rejected by the database privilege system (the enclosure posture owned by the
entitlement-conferralcapability) 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:
initiatewhen no non-ended incumbent occupies the ladder rung (from_rank = NULL,to_rankis the candidate's rank)upgradewhen the incumbent's rank is lower than the candidate's rank (to_rank > from_rank)downgradewhen the incumbent's rank is higher than the candidate's rank, or when the candidate's shape vacates the ladder entirely (to_rank IS NULLorto_rank < from_rank)transferwhen 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 fromUNIQUE (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.conferconferrs a product occupying a ladder rung to a pool with no non-ended incumbent on that ladder - THEN the recorded
transition_typeSHALL beinitiatewithfrom_rank = NULLandto_rankset 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_typeSHALL beupgradewithfrom_rankset to the incumbent's rank andto_rankset 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_typeSHALL bedowngradewithfrom_rankset to the incumbent's rank andto_rankset 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_typeSHALL betransferwithfrom_rank = to_rank - AND the transition SHALL NOT be recorded as
upgradeordowngrade
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.confersupersedes a live-or-suspended incumbent occupying one or more ladder rungs - THEN within the same function invocation and transaction, the incumbent's
pool_provisionsrow SHALL becomestatus = 'ended'and everypool_provision_laddersrow it held SHALL be ended — including rungs the candidate's shape does not reoccupy - AND a new
pool_provisionsrow and the candidate'spool_provision_laddersrow(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
conferwith a specific lower-ranked product and a backing source (Source = S) - THEN the pool SHALL be conferred the explicit target product, recorded as a
downgradetransition, backed byS - 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_conferralto end the existing conferral - AND, when re-applying the org default is the desired semantics, the caller SHALL separately invoke the
auto-provisioningcapability'sReapplyDefaultsForPool - AND
end_conferralitself 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
conferis called again with the same pool, product, and source row while the prior provision from that source isactiveorsuspended - THEN no new
pool_provisions,pool_provision_ladders, orpool_provision_transitionsrows SHALL be inserted - AND the existing provision SHALL be returned as the
noopoutcome
Scenario: A suspended incumbent counts as occupying for idempotence
- WHEN
conferis called again with the same pool, product, and source row while the prior provision from that source isstatus = 'suspended' - THEN the call SHALL still return the
noopoutcome - 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
conferis 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 systemdefaultgrant already delivers - THEN the call SHALL NOT return
noop - AND the call SHALL be resolved as supersession, recording a
transfertransition for the equal-rank cross-source case, per theentitlement-conferralcapability's contract - AND the narrower two-unrelated-grant-decrees case (both incumbent and candidate grant-sourced from decrees with no
extends_grant_idlineage between them) remains the decree-duplicationnoopowned 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'andactor_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'andactor_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
defaultgrant superseded by a paid subscription for the same product) - THEN the recorded
transition_typeSHALL betransferwithfrom_rank = to_rank, both non-NULL - AND the transition SHALL NOT be recorded as
upgradeordowngrade
Scenario: Rank-change transitions retain strict inequality
- WHEN a
pool_provision_transitionsrow is inserted withtransition_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 unlessto_rank < from_rankorto_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
INSERTdirectly intopool_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, andalign_conferral_shape;sync_source_statusandupdate_conferral_boundshold the same enclosed write privilege but emit no transition rows of their own
Scenario: ReapplyDefaultsForPool writes no transitions of its own
- WHEN
ReapplyDefaultsForPoolre-applies the org default by callingconferorend_conferral - THEN every
pool_provision_transitionsrow produced SHALL be attributed to that innerconferorend_conferralcall - AND
ReapplyDefaultsForPoolitself SHALL NOT insert a transitions row directly
Scenario: Multi-rung conferral records one transition per rung
- WHEN
conferconferred a product occupying two ladder rungs in one invocation - THEN two
pool_provision_transitionsrows SHALL be recorded, one per rung, each independently classified asinitiate,upgrade,downgrade, ortransfer
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.createdwebhook is processed and results in anupgradetransition for a pool - THEN the
billing.subscription_changestable SHALL contain a row withnew_status = 'active'(or'trialing') and the Stripe event ID - AND the
entitlements.pool_provision_transitionstable SHALL contain a row withtransition_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_transitionsis 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
grantsrow SHALL be created - AND no
pool_provisionsrow SHALL be created - AND no
pool_provision_laddersorpool_provision_transitionsrow 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_typeisaddon,usage, orone_time, and which has noplan_ladder_tiersmembership - 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_laddersto 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_conferralSHALL end the provision and record anendtransition for each rung it occupied - AND no new
grant_reason = 'default'grant SHALL be minted byend_conferralitself - AND re-applying the org default, if desired, SHALL require a separate call to the
auto-provisioningcapability'sReapplyDefaultsForPool
Scenario: Revoking an off-ladder grant records no transition
- WHEN an operator revokes a grant whose live provision holds no ladder attachment
- THEN
end_conferralSHALL end the provision - AND no
pool_provision_transitionsrow 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/createfor the same org's pool with different target products - THEN the two transactions SHALL serialize on the pool-row lock
entitlements.conferacquires internally - AND the first transaction SHALL commit with a valid conferral recorded
- AND the second SHALL observe the first's committed state (a
noopif the same product/source, or a correct supersession otherwise) - AND no
pool_provision_laddersrow 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 UPDATEagainstresource_poolsitself - AND it SHALL rely on the lock
entitlements.conferacquires 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
conferSHALL supersede the prior provision and create the replacement, recordingtransition_type = 'transfer' - AND no
pool_provision_transitionsrow SHALL be written withtransition_type = 'extend'
Scenario: Historical extend rows remain valid
- WHEN a
pool_provision_transitionsrow written before this change carriestransition_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-
confercall - AND no
TransitionTarget.Extendfield orTransitionfunction 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
grantsrow withextends_grant_idnaming the pool's current grant at that tier - AND the handler SHALL invoke
entitlements.conferwith the new grant as the source - AND the response SHALL re-render the enrollment partial with the new
transferrow 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_laddersrow SHALL be inserted with a freshly generatedprovision_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-
endedpool_provision_laddersrow 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_transitionsrow is inserted with aprovision_ladder_idthat does not reference anypool_provision_laddersrow - 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, ortransfertransition for newly gained occupancy - THEN the corresponding
pool_provision_laddersrow SHALL already exist at the moment the transition row is inserted, satisfyingprovision_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_laddersrow SHALL be updated tostatus = 'ended'withended_atset - AND the row SHALL NOT be deleted from the table
Scenario: Ended junction rows remain queryable history
- WHEN a
pool_provision_laddersrow hasstatus = 'ended' - THEN it SHALL remain present in the table and joinable from the
pool_provision_transitionsrows 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_laddersrow 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
statusaway fromended
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_laddersrow 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
suspendedprovision from a different source - THEN the suspended incumbent SHALL be ended (
status = 'ended') as part of the supersession - AND an
endtransition 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
activeandsuspendedprovisions - 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_laddersrows SHALL be ended - AND an
endtransition 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
statusSHALL becomeendedin its entirety - AND no rung it occupied SHALL be left with a live junction row referencing the ended provision