Skip to main content

D33–D35 — Features / Entitlements engine (RESOLVED)

Cluster entry D33–D35 — the Features/Entitlements engine was authored as one decision entry and is kept whole in this single file. Relocated verbatim from the retired single-file docs/tenancy/ decision log; see the decision log index.

NOTE: the task brief reserved IDs D29–D31, but those were already taken by the money-safety pillars above; this work uses the next free IDs D33–D35.

D33 — Custom Features/Entitlements engine; Pennant stays dormant

We rolled a custom entitlement engine (features + jurisdictions modules) rather than using laravel/pennant. Pennant is kept installed but unused (removing the dep needs approval); this engine supersedes it. Why not Pennant: our model exceeds Pennant's value-resolution scope on every axis — a per-module catalog (FeatureRegistry/FeatureDefinition), a two-layer jurisdiction gate → tier entitlement resolution, typed entitlement values (quota/meter/stepped bands/limit), and usage metering with period resets. We deliberately borrow Pennant's ergonomics (a Features::for($tenant)->active('x') / ->value('x') façade, an @feature Blade directive, a feature:x route middleware, per-module define-style declaration) and subscriptionify's domain model (feature types + consume/canConsume/remainingUsage/release + period resets + lifecycle events). The bus integrates via a RequiresFeature marker (modelled on RequiresFreshMfa) resolved through a Foundation-owned FeatureGuard contract (the Features module binds the impl — Foundation keeps no domain dependency). New ErrorCodes FEATURE_UNAVAILABLE (403) + QUOTA_EXCEEDED (429).

D34 — Jurisdiction gates, tier entitles; level + jurisdiction on Tenant

Resolution applies two gates in order: (1) the tenant's jurisdiction must permit the feature (regulatory/compliance allow-list — jurisdiction_features), then (2) the tenant's level (tier) must grant it + supply the limits/rates/bands (level_feature_entitlements). A jurisdiction denial beats any tier that would enable it (compliance beats commerce). Both the catalog (jurisdictions, levels, entitlements, allow-lists) are central reference data (no RLS), declared in code per module and realized into the DB by an idempotent features:sync (the RBAC-seeder pattern — code-as-source-of-truth). The tenant's tier + region are two string-code columns on the central tenants table (level, jurisdiction) — natural keys, decoupled from sync ordering, registered in Tenant::getCustomColumns() so VirtualColumn keeps them real. Why: it cleanly separates the two real-world axes (what a region permits vs what a tier sells) and keeps the catalog reviewable in code.

D35 — Stepped/banded entitlements + usage metering on RLS-scoped feature_usages

The engine adds a Stepped feature type (neither Pennant nor subscriptionify has it): cumulative volume bands each with their own rate (e.g. first $1M at 0 fee, next $2M at R1, remainder at R2), the band resolved from cumulative usage. Usage is tracked in feature_usages — the ONLY tenant-scoped table in the engine (tenant_id FK → PostgreSQL RLS; proven by a mandatory isolation test + tenants:verify-isolation). consumed is exact Postgres numeric read/written as a decimal STRING — not bigint minor units (D27): it is a cumulative metering measure across heterogeneous units/volumes (not a single-currency ledger balance), so it needs fractional precision without float error; all arithmetic is delegated to Postgres numeric (the dev runtime has no bcmath), PHP only ever compares decimal strings. Quota windows (valid_from/valid_until) reset per period — lazily on next access and eagerly via the scheduled features:roll-periods; meter/ stepped/limit use a perpetual window. consume()/release() emit FeatureConsumed/FeatureReleased. Ceiling concurrency: quota/limit caps are enforced by an atomic conditional UPDATE (UPDATE … SET consumed = consumed + amount WHERE consumed + amount <= cap; 0 rows ⇒ QuotaExceeded) — Postgres re-evaluates the predicate against the write-locked row, so concurrent consumers serialize and can never overshoot. This is the entitlement-engine's equivalent of D29's counter-mutation guarantee (a third valid technique alongside lockForUpdate() / optimistic versioning), and needs no separate SELECT … FOR UPDATE. Window creation is race-safe via a period-aligned deterministic valid_from + createOrFirst against the partial unique index.


← Decision log index