specs/RFC-004-ENTITLEMENTS.md
2026-02-01 07:41:21 +00:00

14 KiB

RFC: Entitlements and Feature System

Status: Implemented Created: 2026-01-15 Authors: Host UK Engineering


Abstract

The Entitlement System controls feature access, usage limits, and tier gating across all Host services. It answers one question: "Can this workspace do this action?"

Workspaces subscribe to Packages that bundle Features. Features are either boolean flags (access gates) or numeric limits (usage caps). Boosts provide temporary or permanent additions to base limits. Usage is tracked, cached, and enforced in real-time.

The system integrates with Commerce for subscription lifecycle and exposes an API for cross-service entitlement checks.


Core Model

Entity Relationships

┌──────────────────────────────────────────────────────────────────┐
│                                                                  │
│   Workspace ───┬─── WorkspacePackage ─── Package ─── Features    │
│                │                                                 │
│                ├─── Boosts (temporary limit additions)           │
│                │                                                 │
│                ├─── UsageRecords (consumption tracking)          │
│                │                                                 │
│                └─── EntitlementLogs (audit trail)                │
│                                                                  │
└──────────────────────────────────────────────────────────────────┘

Workspace

The tenant unit. All entitlement checks happen against a workspace, not a user. Users belong to workspaces; workspaces own entitlements.

// Check if workspace can use a feature
$workspace->can('social.accounts', quantity: 3);

// Record usage
$workspace->recordUsage('ai.credits', quantity: 10);

// Get usage summary
$workspace->getUsageSummary();

Package

A bundle of features with defined limits. Two types:

Type Behaviour
Base Package Only one active per workspace. Upgrading replaces the previous base package.
Add-on Package Stackable. Multiple can be active simultaneously. Limits accumulate.

Database: entitlement_packages

// Package fields
'code'              // Unique identifier (e.g., 'social-creator')
'name'              // Display name
'is_base_package'   // true = only one allowed
'is_stackable'      // true = limits add to base
'monthly_price'     // Pricing
'yearly_price'
'stripe_price_id_monthly'
'stripe_price_id_yearly'

Feature

A capability or limit that can be granted. Three types:

Type Behaviour Example
Boolean On/off access gate tier.apollo, host.social
Limit Numeric cap on usage social.accounts (5), ai.credits (100)
Unlimited No cap (special limit value) Agency tier social posts

Database: entitlement_features

// Feature fields
'code'              // Unique identifier (e.g., 'social.accounts')
'name'              // Display name
'type'              // boolean, limit, unlimited
'reset_type'        // none, monthly, rolling
'rolling_window_days' // For rolling reset (e.g., 30)
'parent_feature_id' // For global pools (see Storage Pool below)

Reset Types

Reset Type Behaviour
None Usage accumulates forever (e.g., account limits)
Monthly Resets at billing cycle start
Rolling Rolling window (e.g., last 30 days)

Hierarchical Features (Global Pools)

Child features share a parent's limit pool. Used for storage allocation across services:

host.storage.total (1000 MB)
├── host.cdn (draws from parent pool)
├── bio.cdn (draws from parent pool)
└── social.cdn (draws from parent pool)

WorkspacePackage

The pivot linking workspaces to packages. Tracks subscription state.

Database: entitlement_workspace_packages

// Status constants
STATUS_ACTIVE     // Package in effect
STATUS_SUSPENDED  // Temporarily disabled (e.g., payment failure)
STATUS_CANCELLED  // Marked for removal
STATUS_EXPIRED    // Past expiry date

// Key fields
'starts_at'             // When package becomes active
'expires_at'            // When package ends
'billing_cycle_anchor'  // For monthly reset calculations
'blesta_service_id'     // External billing system reference

Boost

Temporary or permanent additions to feature limits. Use cases:

  • One-time credit top-ups
  • Promotional extras
  • Cycle-bound bonuses that expire at billing renewal

Database: entitlement_boosts

// Boost types
BOOST_TYPE_ADD_LIMIT  // Add to existing limit
BOOST_TYPE_ENABLE     // Enable a boolean feature
BOOST_TYPE_UNLIMITED  // Grant unlimited access

// Duration types
DURATION_CYCLE_BOUND  // Expires at billing cycle end
DURATION_DURATION     // Expires after set time
DURATION_PERMANENT    // Never expires

// Key fields
'limit_value'        // Amount to add
'consumed_quantity'  // How much has been used
'status'             // active, exhausted, expired, cancelled

How Checking Works

The can() Method

All access checks flow through EntitlementService::can().

public function can(Workspace $workspace, string $featureCode, int $quantity = 1): EntitlementResult

Algorithm:

1. Look up feature by code
2. If feature has parent, use parent's code for pool lookup
3. Sum limits from all active packages + boosts
4. If any source grants unlimited → return allowed (unlimited)
5. Get current usage (respecting reset type)
6. If usage + quantity > limit → deny
7. Otherwise → allow

Example:

// Check before creating social account
$result = $workspace->can('social.accounts');

if ($result->isDenied()) {
    throw new EntitlementException($result->getMessage());
}

// Proceed with creation...

// Record the usage
$workspace->recordUsage('social.accounts');

EntitlementResult

The return value from can(). Provides all context needed for UI feedback.

$result = $workspace->can('ai.credits', quantity: 10);

$result->isAllowed();        // bool
$result->isDenied();         // bool
$result->isUnlimited();      // bool
$result->getMessage();       // Denial reason

$result->limit;              // Total limit (100)
$result->used;               // Current usage (75)
$result->remaining;          // Remaining (25)
$result->getUsagePercentage(); // 75.0
$result->isNearLimit();      // true if > 80%

Caching

Limits and usage are cached for 5 minutes to avoid repeated database queries.

// Cache keys
"entitlement:{workspace_id}:limit:{feature_code}"
"entitlement:{workspace_id}:usage:{feature_code}"

Cache is invalidated when:

  • Package is provisioned, suspended, cancelled, or reactivated
  • Boost is provisioned or expires
  • Usage is recorded

Usage Tracking

Recording Usage

After a gated action succeeds, record the consumption:

$workspace->recordUsage(
    featureCode: 'ai.credits',
    quantity: 10,
    user: $user,           // Optional: who triggered it
    metadata: [            // Optional: context
        'model' => 'claude-3',
        'tokens' => 1500,
    ]
);

Database: entitlement_usage_records

Usage Calculation

Usage is calculated based on the feature's reset type:

Reset Type Query
None All records ever
Monthly Records since billing cycle start
Rolling Records in last N days
// Monthly: Get current cycle start from primary package
$cycleStart = $workspace->workspacePackages()
    ->whereHas('package', fn($q) => $q->where('is_base_package', true))
    ->first()
    ->getCurrentCycleStart();

UsageRecord::getTotalUsage($workspaceId, $featureCode, $cycleStart);

// Rolling: Last 30 days
UsageRecord::getRollingUsage($workspaceId, $featureCode, days: 30);

Usage Summary

For dashboards, get all features with their current state:

$summary = $workspace->getUsageSummary();

// Returns Collection grouped by category:
[
    'social' => [
        ['code' => 'social.accounts', 'limit' => 5, 'used' => 3, ...],
        ['code' => 'social.posts.scheduled', 'limit' => 100, 'used' => 45, ...],
    ],
    'ai' => [
        ['code' => 'ai.credits', 'limit' => 100, 'used' => 75, ...],
    ],
]

Integration Points

Commerce Integration

Subscriptions from Commerce automatically provision/revoke entitlement packages.

Event Flow:

SubscriptionCreated → ProvisionSocialHostSubscription listener
                      → EntitlementService::provisionPackage()

SubscriptionCancelled → Revoke package (immediate or at period end)

SubscriptionRenewed → Update expires_at
                      → Expire cycle-bound boosts
                      → Reset monthly usage (via cycle anchor)

Plan Changes:

$subscriptionService->changePlan(
    $subscription,
    $newPackage,
    prorate: true,      // Calculate credit/charge
    immediate: true     // Apply now vs. period end
);

External Billing (Blesta)

The API supports external billing systems via webhook-style endpoints:

POST   /api/v1/entitlements       → Provision package
POST   /api/v1/entitlements/{id}/suspend
POST   /api/v1/entitlements/{id}/unsuspend
POST   /api/v1/entitlements/{id}/cancel
POST   /api/v1/entitlements/{id}/renew
GET    /api/v1/entitlements/{id}  → Get status

Cross-Service API

External services (BioHost, etc.) check entitlements via API:

GET  /api/v1/entitlements/check
     ?email=user@example.com
     &feature=bio.pages
     &quantity=1

POST /api/v1/entitlements/usage
     { email, feature, quantity, metadata }

GET  /api/v1/entitlements/summary
GET  /api/v1/entitlements/summary/{workspace}

Feature Categories

Features are organised by category for display grouping:

Category Features
tier tier.apollo, tier.hades, tier.nyx, tier.stygian
service host.social, host.bio, host.analytics, host.trust
social social.accounts, social.posts.scheduled, social.workspaces
ai ai.credits, ai.providers.claude, ai.providers.gemini
biolink bio.pages, bio.shortlinks, bio.domains
analytics analytics.sites, analytics.pageviews
storage host.storage.total, host.cdn, bio.cdn, social.cdn
team team.members
api api.requests
support support.mailboxes, support.agents, support.conversations
tools tool.url_shortener, tool.qr_generator, tool.dns_lookup

Audit Logging

All entitlement changes are logged for compliance and debugging.

Database: entitlement_logs

// Log actions
ACTION_PACKAGE_PROVISIONED
ACTION_PACKAGE_SUSPENDED
ACTION_PACKAGE_CANCELLED
ACTION_PACKAGE_REACTIVATED
ACTION_PACKAGE_RENEWED
ACTION_PACKAGE_EXPIRED
ACTION_BOOST_PROVISIONED
ACTION_BOOST_CONSUMED
ACTION_BOOST_EXHAUSTED
ACTION_BOOST_EXPIRED
ACTION_BOOST_CANCELLED
ACTION_USAGE_RECORDED
ACTION_USAGE_DENIED

// Log sources
SOURCE_BLESTA      // External billing
SOURCE_COMMERCE    // Internal commerce
SOURCE_ADMIN       // Manual admin action
SOURCE_SYSTEM      // Automated (e.g., expiry)
SOURCE_API         // API call

Implementation Files

Models

  • app/Mod/Tenant/Models/Feature.php
  • app/Mod/Tenant/Models/Package.php
  • app/Mod/Tenant/Models/WorkspacePackage.php
  • app/Mod/Tenant/Models/Boost.php
  • app/Mod/Tenant/Models/UsageRecord.php
  • app/Mod/Tenant/Models/EntitlementLog.php

Services

  • app/Mod/Tenant/Services/EntitlementService.php - Core logic
  • app/Mod/Tenant/Services/EntitlementResult.php - Result DTO

API

  • app/Mod/Api/Controllers/EntitlementApiController.php

Commerce Integration

  • app/Mod/Commerce/Listeners/ProvisionSocialHostSubscription.php
  • app/Mod/Commerce/Services/SubscriptionService.php

Database

  • entitlement_features - Feature definitions
  • entitlement_packages - Package definitions
  • entitlement_package_features - Package/feature pivot with limits
  • entitlement_workspace_packages - Workspace subscriptions
  • entitlement_boosts - Temporary additions
  • entitlement_usage_records - Consumption tracking
  • entitlement_logs - Audit trail

Seeders

  • app/Mod/Tenant/Database/Seeders/FeatureSeeder.php

Tests

  • app/Mod/Tenant/Tests/Feature/EntitlementServiceTest.php
  • app/Mod/Tenant/Tests/Feature/EntitlementApiTest.php

Usage Examples

Basic Access Check

// In controller or service
$result = $workspace->can('social.accounts');

if ($result->isDenied()) {
    return back()->with('error', $result->getMessage());
}

// Perform action...
$workspace->recordUsage('social.accounts');

With Quantity

// Before bulk import
$result = $workspace->can('social.posts.scheduled', quantity: 50);

if ($result->isDenied()) {
    return "Cannot schedule {$quantity} posts. " .
           "Remaining: {$result->remaining}";
}

Tier Check

// Gate premium features
if ($workspace->isApollo()) {
    // Show Apollo-tier features
}

// Or directly
if ($workspace->can('tier.apollo')->isAllowed()) {
    // ...
}

Usage Dashboard Data

// For billing/usage page
$summary = $workspace->getUsageSummary();
$packages = $entitlements->getActivePackages($workspace);
$boosts = $entitlements->getActiveBoosts($workspace);

Version History

Version Date Changes
1.0 2026-01-15 Initial RFC