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.