Files
member-console/openspec/specs/entitlements/spec.md
T
cgalo5758 ad7a219adf Enforce schema and boot invariants
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.
2026-08-22 18:02:46 -05:00

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_keys table SHALL contain a fedwiki_sites key with provider = 'fedwiki' and unit = 'site'
  • AND the table SHALL NOT contain a bare sites key

Scenario: Platform-owned key has a NULL provider

  • WHEN a platform-owned resource key (e.g., storage_bytes) exists
  • THEN its provider column SHALL be NULL
  • AND its name SHALL be unprefixed

Scenario: A platform-owned key may be metered

  • WHEN a provider IS NULL key 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.resource form 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, and status = 'active'

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 = true linking the workspace to the organization's default pool

Scenario: Workspace has exactly one primary assignment

  • WHEN a pool assignment with is_primary = true is 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_id targeting an organization
  • THEN the system SHALL create a grant record with status = 'active' and product_id set to the named product
  • AND conferral onto the organization's pool SHALL be enacted by the entitlement-conferral capability's primitive, which resolves entitlement_set_id from products.entitlement_set_id

Scenario: System creates a default grant during auto-provisioning

  • WHEN the system creates a grant with granted_by_person_id = NULL and grant_reason = 'default'
  • THEN the system SHALL create a grant record with status = 'active', granted_by_person_id NULL, and product_id set 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_id NULL
  • THEN the database SHALL reject the insert with a NOT NULL constraint violation
  • AND there SHALL be no entitlement_set_id column on entitlements.grants for the insert to name instead

Scenario: Grant reason is required and constrained to a fixed domain

  • WHEN a grant is created with grant_reason NULL, or with a value outside {manual, default, evaluation, promotional, complimentary, sponsored, board_decision, legacy}
  • THEN the database SHALL reject the insert with a NOT NULL or grant_reason_domain CHECK constraint violation, respectively

Scenario: Grant requires attribution unless system-authored

  • WHEN a grant is created with granted_by_person_id = NULL and grant_reason is not 'default'
  • THEN the database SHALL reject the insert with a default_iff_system_authored CHECK constraint violation

Scenario: Default reason requires no human author

  • WHEN a grant is created with grant_reason = 'default' and granted_by_person_id IS NOT NULL
  • THEN the database SHALL reject the insert with a default_iff_system_authored CHECK constraint violation

Scenario: Grant revocation is a ledger fact independent of delivery

  • WHEN an admin revokes an active grant
  • THEN the grant's status SHALL change to revoked
  • AND the system SHALL enact the ending of any live conferral via the entitlement-conferral capability's end_conferral primitive, resolved by source (the grant), not by any grants.status filter
  • 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_provisions for that grant and inspecting provision status
  • AND the system SHALL NOT filter or interpret grants.status as 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_id set, subscription_id and purchase_id NULL, product_id set to the grant's product_id, entitlement_set_id set to the resolved set, and status = '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_id set, billing_account_id set, grant_id and purchase_id NULL, pool_id set to the organization's default resource pool, product_id set to the subscription item's product, entitlement_set_id resolved from that product, quantity set to the subscription item quantity, and status = 'active'

Scenario: Subscription provision targets default pool

  • WHEN a subscription-based pool provision is created
  • THEN the pool_id SHALL be the organization's resource pool with pool_type = 'default' and is_auto_managed = true

Scenario: Provision quantity multiplies per-unit rules

  • WHEN a pool provision has quantity = 3 and its entitlement set has a resource_per_unit = true rule with resource_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_id SHALL record which of the two products was actually conferred
  • AND this fact SHALL be recoverable even though entitlement_set_id alone does not distinguish the two products

Scenario: Provision requires a product

  • WHEN a pool provision is inserted with product_id NULL
  • THEN the database SHALL reject the insert with a NOT NULL constraint 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 limit rule for sites with resource_value = 5
  • THEN the system SHALL create a numeric_entitlements record with resource_key = 'sites', entitlement_type = 'limit', resource_limit = 5
  • AND the system SHALL create a numeric_entitlement_contributions record linking the provision to the entitlement with contributed_value = 5
  • AND the system SHALL create a numeric_entitlement_usage record with current_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_limit to 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_limit SHALL 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_limit SHALL be recalculated to 5

Scenario: All provisions ended zeroes the limit

  • WHEN all provisions on a pool are ended
  • THEN the numeric_entitlements.resource_limit SHALL be 0
  • AND the numeric_entitlement_usage record SHALL be retained (not deleted)

Scenario: Idempotent materialization

  • WHEN materialize_pool_entitlements is 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_sites entitlement with resource_limit = 5 and current_usage = 2
  • AND a site creation is requested
  • THEN current_usage SHALL be atomically incremented to 3

Scenario: Usage at limit

  • WHEN a workspace's pool has a fedwiki_sites entitlement with resource_limit = 5 and current_usage = 5
  • AND a site creation is requested
  • THEN the increment operation SHALL fail (zero rows updated)
  • AND current_usage SHALL remain 5

Scenario: Reactivation is gated by the same check

  • WHEN a workspace's pool has resource_limit = 1 and current_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_usage by 1 for the fedwiki_sites entitlement 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_usage by 1 for the fedwiki_sites entitlement

Scenario: Purging an already-non-active instance does not double-decrement

  • WHEN a site that is already archived is permanently purged
  • THEN current_usage SHALL be unchanged by the purge

Scenario: Decrement does not go below zero

  • WHEN current_usage is 0 and a decrement is attempted
  • THEN current_usage SHALL remain 0

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.grants row SHALL have extends_grant_id = NULL
  • AND all existing reads of the grants table 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_id pointing 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_id pointing 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_id from 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_id is 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 GetGrantLineage is called with a grant_id whose extends_grant_id is 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 GetGrantLineage is called with C's grant_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 GetGrantLineage is 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 boolean rule for discourse_posting
  • THEN the system SHALL write a boolean_entitlements row for the pool with resource_key = 'discourse_posting' and granted = true

Scenario: OR-aggregation across provisions

  • WHEN two active provisions on a pool both carry a boolean rule for the same resource_key and one provision is ended
  • THEN the row SHALL remain granted = true because 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 = false and 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_entitlements row exists for that pool and key

Scenario: Idempotent boolean materialization

  • WHEN materialization runs multiple times with the same pool state
  • THEN the resulting boolean_entitlements rows 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_posting while 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