Skip to main content

D73 — Per-tenant entitlement overrides: tenant_feature_entitlements (the 2nd RLS table) + override→level→default precedence under the jurisdiction gate (RESOLVED)

Architecture decision record, relocated verbatim from the retired single-file docs/tenancy/ decision log. Status, thematic clusters, and how to record a new ADR: the decision log index.

subscriptionify-style per-tenant overrides (docs/tracking/feature-entitlement-overrides.md) ship: a specific tenant can be granted/limited a feature independent of its commercial tier, without a bespoke tier.

Storage — the SECOND tenant-scoped table. tenant_feature_entitlements carries a tenant_id FK → PostgreSQL RLS (forced policy auto-discovered by tenants:rls; isolation proven by the mandatory TenantFeatureEntitlementIsolationTest + tenants:verify-isolation), the same enabled + value JSONB shape as level_feature_entitlements, plus an optional valid_until time-box. A partial unique index (tenant_id, feature_key) WHERE deleted_at IS NULL allows one LIVE override per feature while a revoked (soft-deleted) row does not collide (a fresh grant after a revoke opens a new live row). The catalog stays central; this is the one catalog-side piece that MUST be tenant-scoped (it holds per-tenant data).

Resolution precedence — override → level → catalog default, UNDER the jurisdiction gate. FeatureResolver still checks the jurisdiction gate FIRST (short-circuit); only then does it prefer a live per-tenant override over the tier. An override can therefore NEVER grant a jurisdiction-forbidden feature — compliance still beats commerce (D34). The precedence holds in BOTH resolve() (single) and resolveMany() (batch): the batch path loads the tenant's overrides in ONE whereIn query (TenantEntitlementRepository::overridesFor, the analogue of the batched permitted-set/level-rows/usage reads), so the /me abilities feed stays query-bounded (the AbilitiesResolverTest <10-query guard stays green). Precedence semantics are deliberate: the override read returns a NON-NULL value even for a force-OFF (enabled = false) — a live force-off row beats a tier that would enable the feature — and null ONLY when there is no live (non-soft-deleted, non-expired) row.

Grant API + events. Bus-only operator commands GrantFeatureOverrideCommand / RevokeFeatureOverrideCommand (no route) gate on the Features module's FIRST RBAC permission, features.overrides.manage (web; the module now owns a PermissionProvider registered into the central registry — the first Features permission), resolve the tenant by PUBLIC uuid (→ TENANT_NOT_FOUND when unknown), write through TenantEntitlementRepository inside the tenant's RLS scope, and emit feature.granted / feature.revoked domain events (public uuid payloads). The repository is a sanctioned out-of-bus model reader/writer (the resolver reads overrides through it), added to the CqrsBoundaryTest exact-class carve-out beside FeatureUsageRepository.


← Decision log index