Enforce 10j's verified gaps (schema-hardening change): - Migration 00010: partial unique indexes for one default pool and one primary assignment per workspace, plus CHECKs pinning pool/provider/subscription vocabularies and provider lifecycle timestamps. - Workspace creation shares a transactional provisioning function; extension validates its target pool; last-tier deletion of a defaulted ladder is guarded; signup completes plan-less on a broken ladder. - Boot asserts integration slug parity and validates declared config enums; Stripe invoice amounts are range-checked; domain cancellation runs a final evidence probe; rule authoring is additive-only.
27 KiB
entitlements
Purpose
Defines the entitlement system that manages resource pools, grants, provisions, and numeric entitlement limits for organizations and workspaces.
Requirements
Requirement: Resource keys as shared namespace
The system SHALL maintain a resource_keys table where each key is a globally-unique, bare canonical identifier for a resource type. Resource keys SHALL be FK-referenced by entitlement set rules, numeric entitlements, and numeric entitlement usage records; a resource key that does not exist in this table SHALL NOT be usable in any entitlement context.
Each resource key SHALL carry three separable roles: the bare resource_key as the machine identifier and FK target; a nullable provider column used for grouping and filtering; and a display_name as the only user-facing label. The provider column SHALL encode fact-source cardinality — provider = <slug> means usage originates from exactly one provider, while provider IS NULL means usage is pooled across providers or platform-native.
Resource-key names SHALL follow a namespace-by-slug convention: the platform owns the unprefixed namespace (e.g., seats, members, pooled storage_bytes); each provider owns the <provider-slug>_* prefix (e.g., fedwiki_sites). There SHALL be no dotted provider.resource form. Whether a resource key is metered SHALL be independent of provider; a provider IS NULL key MAY be metered.
Scenario: Seed FedWiki resource key under the provider namespace
- WHEN migrations and provider registration have run on a fresh database
- THEN the
resource_keystable SHALL contain afedwiki_siteskey withprovider = 'fedwiki'andunit = 'site' - AND the table SHALL NOT contain a bare
siteskey
Scenario: Platform-owned key has a NULL provider
- WHEN a platform-owned resource key (e.g.,
storage_bytes) exists - THEN its
providercolumn SHALL be NULL - AND its name SHALL be unprefixed
Scenario: A platform-owned key may be metered
- WHEN a
provider IS NULLkey is configured as metered - THEN the system SHALL accept it, because metering is not constrained to
provider IS NOT NULL
Scenario: Display name is the only user-facing label
- WHEN a resource key is rendered in any UI
- THEN the system SHALL display its
display_name - AND the system SHALL NOT render the bare key or any dotted
provider.resourceform to users
Requirement: Resource pools belong to organizations
The system SHALL maintain resource pools as containers for entitlements. Each resource pool SHALL belong to exactly one organization. A pool SHALL have a pool_type (one of default, shared, dedicated), an is_auto_managed flag, and a status (one of active, suspended, archived).
Scenario: Default pool created during auto-provisioning
- WHEN a new organization is auto-provisioned
- THEN the system SHALL create a resource pool with
pool_type = 'default',is_auto_managed = true, andstatus = 'active'
Requirement: Pool assignments link workspaces to pools
The system SHALL maintain pool assignments that link workspaces to resource pools. Each workspace SHALL have exactly one primary pool assignment (is_primary = true). A workspace draws its entitlements from its assigned pool(s).
Scenario: Primary pool assignment created during auto-provisioning
- WHEN a default workspace is auto-provisioned
- THEN the system SHALL create a pool assignment with
is_primary = truelinking the workspace to the organization's default pool
Scenario: Workspace has exactly one primary assignment
- WHEN a pool assignment with
is_primary = trueis created for a workspace - THEN no other pool assignment for that workspace SHALL have
is_primary = true
Requirement: Grants confer product entitlements to organizations
The system SHALL support grants as an administrative or system-initiated access mechanism. A grant SHALL target an organization (via granted_to_org_id). Grants SHALL record who authorized them (granted_by_person_id), a classification (grant_reason), and optional validity bounds (valid_from, valid_until). A grant SHALL have a status of active, expired, or revoked — this status is a pure, append-only lifecycle ledger. It SHALL NEVER be read or filtered to mean "currently delivering": whether a grant's conferral is presently in effect is a fact about pool_provisions, not about grants.status. Callers that need to answer "is this delivering right now" MUST join to pool_provisions (the entitlement-conferral capability owns the mechanics that keep that table current).
granted_by_person_id SHALL be nullable. grant_reason SHALL be NOT NULL and constrained by a CHECK to the fixed domain {manual, default, evaluation, promotional, complimentary, sponsored, board_decision, legacy}. A second CHECK, default_iff_system_authored, SHALL enforce the biconditional (grant_reason = 'default') = (granted_by_person_id IS NULL): a grant is classified 'default' if and only if it has no human author. Both CHECKs require grant_reason to be NOT NULL to hold: under three-valued logic a NULL grant_reason would satisfy an IN (...) domain CHECK and the biconditional permissively, reopening the unattributed/unclassified grant state the domain and biconditional exist to close.
A grant SHALL name a real product: product_id (FK to billing.products) SHALL be NOT NULL. The entitlement_set_id column and the direct-set conferral path are dropped from entitlements.grants — a grant that conferred capabilities via a bare entitlement set had no product, and therefore no ladder answer, no lifecycle status, and no checkable shape. Ad hoc capability sets that previously used the direct-set path are conferred through internal "wrap products," a mechanism owned by the entitlement-conferral capability. The entitlement set a grant confers is always resolved from products.entitlement_set_id at provision-creation time; there is no longer a branch on how a grant names its capabilities.
Scenario: Admin creates a grant naming a product
- WHEN an admin creates a grant with a
product_idtargeting an organization - THEN the system SHALL create a grant record with
status = 'active'andproduct_idset to the named product - AND conferral onto the organization's pool SHALL be enacted by the
entitlement-conferralcapability's primitive, which resolvesentitlement_set_idfromproducts.entitlement_set_id
Scenario: System creates a default grant during auto-provisioning
- WHEN the system creates a grant with
granted_by_person_id = NULLandgrant_reason = 'default' - THEN the system SHALL create a grant record with
status = 'active',granted_by_person_idNULL, andproduct_idset to the default product - AND the grant SHALL follow the same conferral path as admin-created grants
Scenario: Grant requires a real product
- WHEN a grant is created with
product_idNULL - THEN the database SHALL reject the insert with a
NOT NULLconstraint violation - AND there SHALL be no
entitlement_set_idcolumn onentitlements.grantsfor the insert to name instead
Scenario: Grant reason is required and constrained to a fixed domain
- WHEN a grant is created with
grant_reasonNULL, or with a value outside{manual, default, evaluation, promotional, complimentary, sponsored, board_decision, legacy} - THEN the database SHALL reject the insert with a
NOT NULLorgrant_reason_domainCHECK constraint violation, respectively
Scenario: Grant requires attribution unless system-authored
- WHEN a grant is created with
granted_by_person_id = NULLandgrant_reasonis not'default' - THEN the database SHALL reject the insert with a
default_iff_system_authoredCHECK constraint violation
Scenario: Default reason requires no human author
- WHEN a grant is created with
grant_reason = 'default'andgranted_by_person_idIS NOT NULL - THEN the database SHALL reject the insert with a
default_iff_system_authoredCHECK constraint violation
Scenario: Grant revocation is a ledger fact independent of delivery
- WHEN an admin revokes an active grant
- THEN the grant's
statusSHALL change torevoked - AND the system SHALL enact the ending of any live conferral via the
entitlement-conferralcapability'send_conferralprimitive, resolved by source (the grant), not by anygrants.statusfilter - AND
grants.status = 'revoked'SHALL be recorded even when the primitive finds nothing live to end (a decree that never delivered, expired, or was already superseded)
Scenario: Delivery state is answered by provisions, never by grants.status
- WHEN any caller determines whether a grant is "currently delivering" entitlements
- THEN the system SHALL answer by joining to
pool_provisionsfor that grant and inspecting provision status - AND the system SHALL NOT filter or interpret
grants.statusas a proxy for current delivery
Requirement: Pool provisions as uniform interface
The system SHALL create a pool provision record for each grant or subscription. The pool provision SHALL reference the source (via grant_id for grants, or subscription_id and billing_account_id for subscriptions), the target pool (via pool_id), the conferred product (via product_id), and the resolved entitlement set (via entitlement_set_id). Both product_id (FK to billing.products) and entitlement_set_id SHALL be NOT NULL and SHALL be resolved and denormalized at provision-creation time — product_id from grants.product_id for grants, and from the price's product for subscription items and purchases; entitlement_set_id in turn from products.entitlement_set_id. product_id exists to record WHICH product was conferred, disambiguating cases where multiple products share one entitlement_set_id (e.g. monthly/annual price variants, or several ladder tiers sharing a baseline set) — a fact entitlement_set_id alone cannot recover, since the set reference is non-injective by design. The provision's quantity field SHALL determine entitlement multipliers for resource_per_unit rules. The provision's status SHALL be active, suspended, or ended. The per-source live-uniqueness invariants and the position machinery that write these rows are owned by the entitlement-conferral capability; this requirement states only the provision's required shape.
Scenario: Grant creates a pool provision
- WHEN a grant is created targeting an organization
- THEN a pool provision SHALL be created with
grant_idset,subscription_idandpurchase_idNULL,product_idset to the grant'sproduct_id,entitlement_set_idset to the resolved set, andstatus = 'active'
Scenario: Subscription creates a pool provision
- WHEN a subscription is fulfilled (via webhook) for a billing account belonging to an organization
- THEN a pool provision SHALL be created with
subscription_idset,billing_account_idset,grant_idandpurchase_idNULL,pool_idset to the organization's default resource pool,product_idset to the subscription item's product,entitlement_set_idresolved from that product,quantityset to the subscription item quantity, andstatus = 'active'
Scenario: Subscription provision targets default pool
- WHEN a subscription-based pool provision is created
- THEN the
pool_idSHALL be the organization's resource pool withpool_type = 'default'andis_auto_managed = true
Scenario: Provision quantity multiplies per-unit rules
- WHEN a pool provision has
quantity = 3and its entitlement set has aresource_per_unit = truerule withresource_value = 1 - THEN the contributed value for that provision SHALL be
3(1 x 3)
Scenario: Provision product_id disambiguates a shared entitlement set
- WHEN two products (e.g. a monthly and an annual price variant of the same plan, or two ladder tiers sharing a baseline set) share one
entitlement_set_id - AND a pool provision is created conferring one of those products
- THEN the provision's
product_idSHALL record which of the two products was actually conferred - AND this fact SHALL be recoverable even though
entitlement_set_idalone does not distinguish the two products
Scenario: Provision requires a product
- WHEN a pool provision is inserted with
product_idNULL - THEN the database SHALL reject the insert with a
NOT NULLconstraint violation
Requirement: Entitlement materialization on provision changes
The system SHALL re-materialize effective entitlements for a pool whenever any provision on that pool changes status (activated, suspended, or ended). Materialization SHALL aggregate resource_value across all active provisions sharing the same resource_key, respecting resource_per_unit quantity multipliers, and write the results to numeric_entitlements, numeric_entitlement_contributions, and numeric_entitlement_usage.
Scenario: First provision activates on a pool
- WHEN a pool's first provision is activated with an entitlement set that has a
limitrule forsiteswithresource_value = 5 - THEN the system SHALL create a
numeric_entitlementsrecord withresource_key = 'sites',entitlement_type = 'limit',resource_limit = 5 - AND the system SHALL create a
numeric_entitlement_contributionsrecord linking the provision to the entitlement withcontributed_value = 5 - AND the system SHALL create a
numeric_entitlement_usagerecord withcurrent_usage = 0
Scenario: Additive stacking of multiple provisions
- WHEN a second provision is activated on a pool that already has an active provision for the same
resource_key - THEN the system SHALL sum the contributed values and update
numeric_entitlements.resource_limitto the total
Scenario: Provision suspended reduces effective limit
- WHEN a provision is suspended (e.g., subscription goes
past_due) - THEN the system SHALL remove the suspended provision's contribution from the effective limit
- AND
numeric_entitlements.resource_limitSHALL be recalculated from remaining active provisions only
Scenario: Provision ended reduces effective limit
- WHEN a provision contributing 2 sites is ended on a pool that also has a provision contributing 5 sites
- THEN the system SHALL remove the ended provision's contribution
- AND the
numeric_entitlements.resource_limitSHALL be recalculated to5
Scenario: All provisions ended zeroes the limit
- WHEN all provisions on a pool are ended
- THEN the
numeric_entitlements.resource_limitSHALL be0 - AND the
numeric_entitlement_usagerecord SHALL be retained (not deleted)
Scenario: Idempotent materialization
- WHEN
materialize_pool_entitlementsis called multiple times with the same pool state - THEN the resulting entitlements, contributions, and usage records SHALL be identical each time
Requirement: Numeric entitlement usage check
The system SHALL enforce numeric entitlement limits via an atomic check-and-increment operation. The operation SHALL atomically verify that current_usage < resource_limit and increment current_usage in a single UPDATE statement. If the limit would be exceeded, the operation SHALL fail (zero rows updated) without modifying the usage. For a counted resource that has a lifecycle, current_usage SHALL count only instances in their active/counted state; the same atomic check-and-increment SHALL gate every transition that brings an instance into the counted state — initial creation and any later reactivation or restore — not creation alone.
Scenario: Usage within limit
- WHEN a workspace's pool has a
fedwiki_sitesentitlement withresource_limit = 5andcurrent_usage = 2 - AND a site creation is requested
- THEN
current_usageSHALL be atomically incremented to3
Scenario: Usage at limit
- WHEN a workspace's pool has a
fedwiki_sitesentitlement withresource_limit = 5andcurrent_usage = 5 - AND a site creation is requested
- THEN the increment operation SHALL fail (zero rows updated)
- AND
current_usageSHALL remain5
Scenario: Reactivation is gated by the same check
- WHEN a workspace's pool has
resource_limit = 1andcurrent_usage = 1(one active instance) - AND a request is made to return a non-active instance to the active state
- THEN the increment operation SHALL fail (zero rows updated)
- AND the instance SHALL remain non-active
Requirement: Usage decrement on resource deletion
The system SHALL decrement numeric_entitlement_usage.current_usage when a counted resource leaves its active/counted state — whether deleted, or for a lifecycle resource transitioned out of the active state (e.g. to read-only or archived). The decrement SHALL NOT reduce current_usage below 0. A transition between two non-active states (e.g. read-only → archived), or the permanent purge of an already-non-active instance, SHALL NOT change current_usage (it was decremented when the instance first left the active state).
Scenario: Site deletion decrements usage
- WHEN an active site is deleted from a workspace
- THEN the system SHALL decrement
current_usageby 1 for thefedwiki_sitesentitlement on the workspace's pool
Scenario: Transition out of active decrements usage
- WHEN an active site is transitioned to read-only or archived
- THEN the system SHALL decrement
current_usageby 1 for thefedwiki_sitesentitlement
Scenario: Purging an already-non-active instance does not double-decrement
- WHEN a site that is already archived is permanently purged
- THEN
current_usageSHALL be unchanged by the purge
Scenario: Decrement does not go below zero
- WHEN
current_usageis0and a decrement is attempted - THEN
current_usageSHALL remain0
Requirement: Grants record lineage via a self-referential extends_grant_id column
The entitlements.grants table SHALL include a nullable extends_grant_id UUID column with a foreign key reference to entitlements.grants(grant_id). When set, this column indicates that the grant extends a prior grant within the same lifecycle chain — a replacing grant names the grant it supersedes. When NULL, the grant has no parent and is the root of its lineage. Every grant created prior to this requirement SHALL have extends_grant_id = NULL.
A CHECK constraint SHALL prevent self-reference: CHECK (extends_grant_id IS NULL OR extends_grant_id != grant_id). A partial index WHERE extends_grant_id IS NOT NULL SHALL exist on the column to support lineage walks without bloating the index for the common case.
This lineage is consulted by the entitlement-conferral capability's confer primitive to distinguish a genuine extend-as-replace supersession (the replacing grant's extends_grant_id names the incumbent's source grant) from an accidental duplicate decree; the enactment mechanics — including the transfer transition an equal-rank extend-as-replace produces — are specified there, not here.
Scenario: Existing grants have NULL lineage
- WHEN the migration applies on a database with existing grants
- THEN every existing
entitlements.grantsrow SHALL haveextends_grant_id = NULL - AND all existing reads of the
grantstable SHALL succeed unchanged
Scenario: A grant cannot extend itself
- WHEN an INSERT or UPDATE attempts to set
extends_grant_id = grant_id - THEN the database SHALL reject the operation with a CHECK constraint violation
Requirement: Grant lineage is constrained to a single organization
A grant whose extends_grant_id is non-NULL SHALL reference a parent grant that shares the same granted_to_org_id. A BEFORE INSERT OR UPDATE trigger on entitlements.grants SHALL enforce this by raising an exception when the parent's organization differs from the new grant's organization.
Scenario: Same-org lineage permitted
- WHEN a grant is inserted with
extends_grant_idpointing to a grant for the same organization - THEN the insert SHALL succeed
Scenario: Cross-org lineage rejected
- WHEN a grant is inserted with
extends_grant_idpointing to a grant for a different organization - THEN the trigger SHALL raise
extends_grant_id must reference a grant for the same organization - AND the new grant SHALL NOT be inserted
Requirement: Grant lineage is immutable after insert
The extends_grant_id column SHALL be set at INSERT time and SHALL NOT be modifiable by subsequent UPDATEs. A BEFORE UPDATE trigger on entitlements.grants SHALL raise an exception when OLD.extends_grant_id IS DISTINCT FROM NEW.extends_grant_id. This prevents post-hoc creation of cycles in the lineage graph and makes the chain a write-once relationship.
UPDATEs to other columns on a grant with non-NULL extends_grant_id (status changes, revocation fields, description updates) SHALL succeed unaffected.
Scenario: Lineage column cannot be changed after insert
- WHEN an UPDATE attempts to change a grant's
extends_grant_idfrom one value to another (including NULL → non-NULL or non-NULL → NULL) - THEN the trigger SHALL raise
extends_grant_id is immutable after insert - AND the row SHALL remain unchanged
Scenario: Other column updates remain permitted
- WHEN an UPDATE changes a grant's status, revoked_at, revocation_reason, or description without touching
extends_grant_id - THEN the update SHALL succeed regardless of whether
extends_grant_idis NULL or set
Requirement: GetGrantLineage walks the ancestry of a grant
The system SHALL provide a GetGrantLineage query that, given a focal grant_id, returns the focal grant followed by every grant in its ancestry chain (parent, grandparent, …) walked via extends_grant_id. The query SHALL be implemented as a recursive CTE in internal/entitlements/queries/grants.sql and exposed through sqlc-generated code. The query SHALL be available for future consumers but MAY be uncalled when this requirement first lands.
Scenario: Lineage of a root grant returns only itself
- WHEN
GetGrantLineageis called with agrant_idwhoseextends_grant_idis NULL - THEN the result SHALL contain exactly one row: the focal grant
Scenario: Lineage of a chain returns all ancestors
- WHEN grants A, B, C exist such that B extends A and C extends B
- AND
GetGrantLineageis called with C'sgrant_id - THEN the result SHALL contain three rows in chain-walk order: C, B, A
Scenario: Revoked ancestors do not break the walk
- WHEN a grant chain exists and an intermediate ancestor has
status = 'revoked' - AND
GetGrantLineageis called with the chain's most recent grant - THEN the result SHALL include the revoked ancestor and continue past it to its parent
Requirement: Boolean entitlement materialization on provision changes
The system SHALL materialize boolean entitlement rules whenever pool entitlements are re-materialized (the same trigger points as numeric materialization). For each resource_key carried by a boolean rule on any of the pool's provisions, the system SHALL write a boolean_entitlements row whose granted value is true if and only if at least one active provision on the pool carries that boolean rule. Conferral SHALL derive from provision status only; grants.status SHALL NOT be consulted.
Scenario: Boolean rule materializes on provision activation
- WHEN a provision is activated on a pool with an entitlement set containing a
booleanrule fordiscourse_posting - THEN the system SHALL write a
boolean_entitlementsrow for the pool withresource_key = 'discourse_posting'andgranted = true
Scenario: OR-aggregation across provisions
- WHEN two active provisions on a pool both carry a boolean rule for the same
resource_keyand one provision is ended - THEN the row SHALL remain
granted = truebecause an active provision still carries the rule
Scenario: Last carrying provision ends
- WHEN the only active provision carrying a boolean rule is ended or suspended
- THEN the row SHALL be updated to
granted = falseand SHALL NOT be deleted
Scenario: Never-conferred key has no row
- WHEN no provision on a pool has ever carried a boolean rule for a given
resource_key - THEN no
boolean_entitlementsrow exists for that pool and key
Scenario: Idempotent boolean materialization
- WHEN materialization runs multiple times with the same pool state
- THEN the resulting
boolean_entitlementsrows SHALL be identical each time
Requirement: Boolean entitlement read surface
The system SHALL provide queries answering, without callers re-deriving the provisions join: whether a given pool currently has a boolean resource_key granted, and which pools (with their owning organizations) currently have a given boolean resource_key granted.
Scenario: Consumer reads conferral for a key
- WHEN a consumer queries pools granted
discourse_postingwhile two orgs' pools have active provisions carrying that rule and a third org's carrying provision has ended - THEN the query returns exactly the two pools with active carrying provisions, each with its owning organization
Requirement: One default pool per organization is schema-enforced
The database SHALL reject a second resource pool with pool_type = 'default' for the same organization, via a partial unique index on core.resource_pools(org_id) WHERE pool_type = 'default'. The pool_type column SHALL carry a CHECK constraint pinning it to the documented value set, so an unknown pool type cannot evade the index.
Scenario: Second default pool is rejected
- WHEN an insert or update would give an organization a second pool with
pool_type = 'default'(under any slug) - THEN the database rejects it with a unique-violation error
- AND every org-to-default-pool resolution therefore has at most one candidate row
Requirement: One primary pool assignment per workspace is schema-enforced
The database SHALL reject a second is_primary = TRUE assignment for the same workspace, via a partial unique index on core.pool_assignments(workspace_id) WHERE is_primary. The migration introducing the index SHALL first demote any existing duplicate primaries deterministically (keeping the earliest assignment), so it applies cleanly to existing data.
Scenario: Second primary assignment is rejected
- WHEN an insert or update would mark a workspace primary in a second pool
- THEN the database rejects it with a unique-violation error
- AND quota acquisition's primary-assignment subquery therefore matches at most one pool, so one acquisition can never debit two pools' counters