Entitlements
Entitlements
Entitlement is the seam to identity. ID enforces who you are and your coarse tier; billing enforces spend and quota on the hot path. The division:
- Coarse, stable entitlements (plan tier, big feature flags) are pushed to cbox-id sparingly as token-claim material for cheap edge gating.
- Fine-grained / usage / quota entitlements are not synced into ID tokens
(bloat + staleness). They live in billing and are checked app-side via the
EnforcementAPI.
Projection
EntitlementProjector computes an org's coarse tier from a subscription and
pushes it through the EntitlementWriter port — decoupled from cbox-id, so the
library never hard-depends on the identity package:
interface EntitlementProjector
{
public function project(Subscription $subscription, string $tier, array $features = []): void;
}
NullEntitlementWriter is the no-op default; the real writer is bound by the host
app (cbox-id ships the receiving EntitlementWriter contract). project is scoped
per (org, product) — an org holds many concurrent product entitlements.
EntitlementMeterPolicyResolver is the same module serving the metering hot path
its per-(org, meter) MeterPolicy, deny-by-default.
Missing-entitlement outage audit
A missing entitlement row is a semantic denial (fail closed) — but a cohort of
missing rows is an outage that should be visible, not silently denied one request
at a time. EntitlementAudit sweeps expected-vs-actual entitlements and reports
the gaps:
interface EntitlementAudit
{
public function audit(iterable $targets): AuditReport;
}
DefaultEntitlementAudit compares an ExpectedEntitlements source against what is
projected, classifies each AuditFinding by EntitlementOutageKind, and emits
through EntitlementAuditSignals so operators get a signal. Run it with
php artisan billing:entitlements:audit.
Event-suppressed bulk rollout
When a plan-wide entitlement change rolls out to a large cohort, firing a
per-org cache-bust would storm the cache. So EntitlementRollout splits the cohort
by RolloutPath:
- Orgs without an override are applied in chunks, each chunk one atomic transaction, with no per-org cache-bust — invalidation rides the hot-path cache TTL. A 100k-org plan does not storm the cache.
- Orgs with an override bypass the bulk path: written individually and cache-busted immediately, because their state genuinely diverges.
interface EntitlementRollout
{
public function apply(PlanEntitlementChange $change, iterable $cohort): RolloutReport;
}
chunk_size (entitlement.rollout.chunk_size) tunes rows-per-transaction. Every
application is recorded to a RolloutJournal (DatabaseRolloutJournal, migration
billing_entitlement_rollouts) as RolloutAuditRows for audit.
Testing
InteractsWithEntitlementAudit, InteractsWithEntitlementRollout,
FakeEntitlementWriter, FakeRolloutJournal, and the recording signal/invalidator
fakes drive both paths.