Feature-Flag Catalog Consolidation + Enforcement — READ-ONLY DESIGN
Status: design for operator sign-off. No code changed.
PHASE A — IMPLEMENTED (2026-06-26)
Operator-approved Phase A (catalog consolidation + the real plan-resolver) is shipped. No new
@RequiresFeaturegate was activated and nothing was deployed (Phase B + the operator-orchestrated deploy are separate). What landed:TASK 1 — catalog consolidation (auth-service, zero entitlement change):
@hmp/commonFEATURE_FLAGSis the single canonical catalog.hmp-auth-service/src/billing/plans.config.tsKEEPS the per-plan QUOTAS (maxRooms/maxUsers/maxProperties/price) and DELETES the divergentfeatures[]arrays,ALL_FEATURES,PlanFeatureId, andplanIncludesFeature.subscription.service.ts:checkFeatureAccessnow callsisFeatureInPlanfrom@hmp/common, with aLEGACY_FEATURE_ALIASESshort-code→dotted-code map (pos→pos.basic,analytics→analytics.basic,loyalty→crm.loyalty,api_access→api.access, …) so the informationalfeature-accessendpoint contract is byte-identical. Adescribe.eachparity test proves STARTER/PRO/ENT allowed/denied are identical pre/post.TASK 2 — real plan resolver (makes tier resolution work):
@hmp/commonadds aTenantPlanResolverinterface +TENANT_PLAN_RESOLVERDI token (feature-flags/tenant-plan-resolver.ts).FeatureFlagServiceinjects it@Optional()and, in the no-Tenant-model branch ofgetTenantConfig, resolves the REAL plan instead of the hard-codedENTERPRISE;featureOverridesprecedence is unchanged.@hmp/service-clientsAuthServiceClient.getTenantPlan(tenantId)implements the resolver against a new auth-service endpointGET /tenants/:id/plan(returns{ plan }fromsettings.plan, default STARTER, INTERNAL_SERVICE-gated). It is 5-min in-process cached and fail-safe: on any remote error (5xx/404/network) it returnsENTERPRISEand never throws — fail-OPEN so a transient auth outage cannot 503 the platform or 403 a paying tenant out of its own product;FeatureFlagServicecatches as a second safety net (also →ENTERPRISE).The resolver is bound (
{ provide: TENANT_PLAN_RESOLVER, useExisting: AuthServiceClient }) in the four no-Tenant-model FeatureFlagModule consumers (pos / channel / crm / banquet).CRITICAL DEPLOY PREREQUISITE (operator-owned): once this resolver DEPLOYS, the dormant pos/channel/crm/banquet
@RequiresFeaturegates START ENFORCING BY TIER. The TEST tenants (e2eresort657761e7-4141-40ac-8d04-2856cf2dd6cf, kn-uatd2a8db39-4895-4d9d-9b15-964deeca2182) MUST be set to the ENTERPRISE plan BEFORE the resolver reaches TEST, else the green journeys 403. Set TEST tenants ENTERPRISE first, then deploy.Packages:
@hmp/common@1.13.0,@hmp/service-clients@1.37.0. Consumer rebuilds: pos / channel / crm / banquet (all four pinned@hmp/common@^1.13.0+@hmp/service-clients@^1.37.0). auth-service needs NO package bump (the consolidation usesisFeatureInPlan, already shipped).
0. CRITICAL PRE-FINDING (changes the whole enforcement story)
Plan-tier enforcement is structurally broken in every downstream service today, not just for the 10 unenforced flags. FeatureFlagService.getTenantConfig (hmp-shared-packages/.../feature-flag.service.ts:176-178) returns { plan: 'ENTERPRISE' } whenever the service's Prisma schema has no Tenant model. I verified channel/crm/banquet/pos/spa all have NO Tenant model (grep "model Tenant" hmp-*/prisma/schema.prisma → only auth, gateway, notification, property). No service client resolves the plan remotely (no getTenantPlan exists anywhere).
Consequence: the existing "enforced" @RequiresFeature('pos.*'/'channel.basic'/'crm.*'/'banquet') gates in those services only deny when a tenant carries an explicit settings.featureOverrides[code] = false. On plan tier alone they are no-ops. So adding @RequiresFeature('spa') to spa-service (no Tenant model) would always pass = cosmetic.
Therefore enforcement work has a prerequisite: a real plan resolver in FeatureFlagService — inject an AuthServiceClient.getTenantPlan(tenantId) (5-min cached, like other ServiceClients) and use it in the !this.prisma.tenant branch instead of hard-coding ENTERPRISE. Without this, items 2 below are decorative. This is a root-cause fix (per the no-band-aid charter), and it also retroactively makes the already-shipped pos/channel/crm/banquet gates actually enforce by tier — call that out to the operator as additional (intended) commercial impact.
1. Catalog consolidation (SAFE — zero tenant impact)
Two catalogs:
- A (canonical-ish):
@hmp/commonFEATURE_FLAGS—feature-flags.config.ts:13-170. 21 codes, dotted names, per-plan arrays. Already the source for the guard, the service, AND auth-service'sGET /feature-flags/planscatalog (feature-flags.controller.ts:58,135already derive from it). - B (divergent): auth-service
plans.config.ts. Two relevant structures:PLAN_FEATURES[plan].features: string[](:31-61) — short codespms/frontdesk/housekeeping/pos/channel/reports/crmper tier; ENTERPRISE =['*'].ALL_FEATURES(:96-113) — 16 codes incl.analytics,loyalty,kiosk,mobile_app,api_access,whitelabel,booking_engine,banquet,spa.- Consumed by
planIncludesFeature(planCode, feature)(:128-136) → called fromsubscription.service.ts:473(checkFeatureAccess, behindGET /api/billing/.../feature-access).
Divergences
| Concern | Catalog A (@hmp/common) | Catalog B (plans.config) |
|---|---|---|
| Naming | api.access, crm.loyalty, analytics.basic/advanced, pos.basic | api_access, loyalty, analytics, pos |
| Granularity | split (pos.basic/kitchen_display/inventory, analytics.basic/advanced) | flat (pos, analytics) |
| Codes only in B | booking_engine, kiosk, mobile_app, reports | — (no @RequiresFeature consumer anyway) |
| Codes only in A | partner_portal.*, channel.unlimited, multi_property, pms.* granular | — |
| Plan assignment for STARTER | A: STARTER gets only pms.* (ALL_PLANS) | B: ['pms','frontdesk','housekeeping'] — same effective entitlement |
Critically: catalog B is only ever consulted through planIncludesFeature/checkFeatureAccess, which is an informational "can this plan use X" query (returns {allowed, reason}), NOT a hard guard. So migrating it changes a STRING the dashboard shows, not any tenant's runtime access.
Recommendation: @hmp/common FEATURE_FLAGS is THE canonical catalog. Migrate B's consumers onto it. Concrete change-list (all in hmp-auth-service):
plans.config.ts— KEEPPLAN_FEATURESquotas only (maxRooms/maxUsers/maxProperties/price— these are NOT in catalog A and are consumed byusage-metering.service.ts:114-198andtrial.service.ts:90-106; do not touch). DELETE thefeatures: string[]arrays,ALL_FEATURES,PlanFeatureId, andplanIncludesFeature.subscription.service.ts:473— replaceplanIncludesFeature(subscription.planCode, feature)withisFeatureInPlan(feature, subscription.planCode)imported from@hmp/common(feature-flags.config.ts:181). Map any legacy short code a caller passes (pos→pos.basic,analytics→analytics.basic,loyalty→crm.loyalty,api_access→api.access) via a tiny alias table, OR (preferred) update the one dashboard caller to pass canonical dotted codes.checkFeatureAccesssemantics are unchanged.- Verify no other importer of the deleted symbols (grep already done: only
subscription.service.ts,subscriptions/subscription.service.tsimportsgetPlanByCodewhich stays,usage-metering/trialuse quotas which stay).
Net: one catalog, quotas remain in plans.config (orthogonal), entitlement logic 100% from @hmp/common. No tenant's effective entitlement changes because the only behavioral consumer was an informational endpoint and STARTER's plan set is identical between A and B.
2. Enforcement design — per flag (depends on §0 plan-resolver prereq)
| Flag | Where to enforce | Placement | Notes / ambiguity |
|---|---|---|---|
spa | hmp-spa-service 6 controllers (spa/appointments :35, spa/treatments :26, spa/therapists :28, spa/rooms :26, spa/packages :26, spa/activities :28) | class-level @UseGuards(FeatureFlagGuard) + @RequiresFeature('spa') | Must first wire FeatureFlagModule into hmp-spa-service/src/app/app.module.ts (NOT imported today) AND fix §0 (spa has no Tenant model → today returns ENTERPRISE). |
analytics.basic | hmp-analytics-service report surfaces: reports/v2 (reports-v2.controller.ts:68), reports exports (:6), saved-views (:38), dashboard (:46), trust-dashboard (:21), revenue/scorecards (:15), distribution-lists (:31) | class-level @RequiresFeature('analytics.basic') | Leave internal/*, compliance/breach, gstr (statutory) UNGATED. |
analytics.advanced | the predictive/cross-property surfaces: analytics/forecast (forecast.controller.ts:43,47), anomalies (anomaly.controller.ts:28), dashboard/portfolio (portfolio.controller.ts:42, multi-property owner rollup) | class-level @RequiresFeature('analytics.advanced') | No NLQ exists (grep empty) — the audit's "AI/NLQ" maps to forecast+anomaly+portfolio. forecast currently @Roles(...FORECAST_ROLES) "every operational role" — gating it is a real entitlement removal. Confirm with operator that pacing/forecast = Enterprise. |
multi_property | hmp-property-service POST /properties (properties.controller.ts:36) — gate creation of the Nth property | service-layer count check in properties.service.create, NOT a blanket controller gate | Cannot be a class-level @RequiresFeature (that would block the 1st/only property too, breaking STARTER). Design: in create(), COUNT(*) properties WHERE tenantId ; if >= 1 and tenant lacks multi_property (or >= PLAN_FEATURES[plan].maxProperties) → 403 FEATURE_NOT_AVAILABLE. Also gate dashboard/portfolio chain rollup (overlaps analytics.advanced — pick one; recommend multi_property). Property-service HAS a Tenant model but its row is a cross-service stub with possibly-unset settings.plan → use the §0 resolver, not the local stub. |
whitelabel | hmp-auth-service branding write routes: PUT /branding/:tenantId (branding.controller.ts:48), DELETE (:122) | method-level @RequiresFeature('whitelabel') on PUT+DELETE only | DO NOT gate GET /branding/:tenantId (:32, @Public) — login-page branding read. No separate custom-domain CRUD controller exists (custom domain is a Tenant field). auth-service HAS Tenant model → works without §0, but resolver still cleaner. |
api.access | hmp-auth-service api-key surface. Gate key CREATION (POST /api-keys, api-key.controller.ts:32) with @RequiresFeature('api.access'); additionally have ApiKeyGuard (api-keys/api-key.guard.ts) reject keys whose owning tenant lost the flag | @RequiresFeature('api.access') on the create/rotate methods + a flag re-check in ApiKeyGuard | Spans two concerns: issuing keys vs. using them. Gating only creation lets EXISTING keys keep working (good for grandfather). Recommend gate-create-now, flag re-check in guard as a later phase. |
channel.unlimited | hmp-channel-service connections.service.create | NOT a controller decorator — needs a new count check | No OTA connection-count cap exists anywhere (verified). channel.basic already gates the controllers; unlimited is "more than N active connections". Design: in connections.service.create(), COUNT(*) active connections; if >= 2 (basic cap) and tenant lacks channel.unlimited → 403. Flag to operator: this is net-new business logic, not just a decorator; biggest build of the 10. |
partner_portal.enabled | hmp-revenue-strategy / partner-portal login + assume-engagement | @RequiresFeature('partner_portal.enabled') on partner-login & assume-engagement controllers | Currently enforced NOWHERE — the config description claims "partner login returns 503 when OFF" but grep shows the only partner_portal reference in code is a decision signal string (partner-decision.controller.ts:142), not a guard. So the documented 503 behavior does not exist. This is a defaultEnabled:false ALL_PLANS kill-switch (not a tier gate) — distinct from the commercial-impact set; enforcing it just makes the kill-switch real. |
partner_portal.invite_creation_enabled | partner invite-creation endpoint | @RequiresFeature('partner_portal.invite_creation_enabled') on the invite-create route | Same as above — registered, unenforced. defaultEnabled:true, so enforcing is no-op until someone flips it OFF. Low risk. |
partner_portal.pii_consent_enabled | partner engagement PII-consent request route | @RequiresFeature(...) on the GUEST_PII_FULL request path | Same; defaultEnabled:true. |
Ambiguous / cross-service flagged: channel.unlimited (no enforcement point — needs new logic), multi_property (creation-count, not blanket; overlaps portfolio with analytics.advanced), api.access (issue-vs-use split), partner_portal.* (kill-switches, not tier gates — separate from commercial impact).
3. Migration strategy — OPTIONS + recommendation
Enforcing tier gates removes features from tenants below the required tier. Options:
- (a) GRANDFATHER (recommended): before wiring any gate, snapshot each existing tenant's currently-effective features into
settings.featureOverrides[code] = true(the override already wins over plan infeature-flag.service.ts:32-37). New gates then only bite NEW tenants and on plan-DOWN-grades. Zero existing-tenant disruption, no customer comms required, fully reversible (delete the override). UseFeatureFlagService.getEnabledFeatures()per tenant to compute the snapshot. - (b) HARD-CUT: wire gates, below-tier tenants lose access immediately. Needs customer comms + a support spike + likely churn. Only if operator wants immediate revenue pressure.
- (c) PHASED: grace window + in-app upgrade prompts (dashboard already shows
FEATURE_NOT_AVAILABLEwithrequiredPlanfrom the guard —feature-flag.guard.ts:49-54), then flip to hard-cut at T+N days.
Recommendation: (a) Grandfather for the existing book, with gates live for new tenants/downgrades, then optionally layer (c)'s upgrade-prompt UI for an eventual sunset of grandfathered overrides per the operator's commercial timeline. Rationale: live multi-tenant platform; the override mechanism is purpose-built for exactly this; no comms/legal risk; instantly reversible; and it cleanly separates the SAFE catalog consolidation from the commercial decision. The §0 resolver fix + new gates can ship "dark" because every existing tenant carries a grandfather override.
Note: the §0 resolver retroactively activates the EXISTING pos/channel/crm/banquet tier gates too — grandfather snapshot MUST include those codes, or below-tier tenants who use POS today would suddenly 403.
4. Impact-sizing data needed (operator/data step — cannot query prod here)
Query against the auth-service tenants DB (only authoritative source of settings.plan):
-- per-plan tenant counts
SELECT settings->>'plan' AS plan, COUNT(*) FROM tenants WHERE "isActive" GROUP BY 1;
-- below-tier tenants × each gated feature: who would lose what under HARD-CUT
-- For each of the 10 codes, the affected set = active tenants whose plan does NOT
-- include the code AND who have no featureOverrides[code]=true AND who actually USE it.
SELECT id, name, settings->>'plan' AS plan, settings->'featureOverrides' AS overrides
FROM tenants WHERE "isActive";
Then cross-reference usage per code (you can't infer "uses spa" from auth DB alone — must ask the owning service):
spa→ COUNT spa_appointments per tenant (spa-service DB)analytics.advanced→ forecast/anomaly/portfolio hits (analytics DB / access logs)multi_property→ tenants withCOUNT(properties) > 1(property-service DB)channel.unlimited→ tenants withCOUNT(active channel_connections) > 2(channel DB)whitelabel→ tenants with non-default branding rows (auth branding DB)api.access→ tenants with ≥1 active ApiKey (auth api_keys DB)partner_portal.*→ tenants/engagements with active partner engagements (revenue-strategy / crm)
Deliverable to operator: a matrix tenant × {plan, feature, currently-uses?, in-plan?} → the rows where uses && !in-plan && !override are exactly the churn-risk set. This sizing is a prerequisite to choosing (a)/(b)/(c) and to building the grandfather snapshot (the snapshot is literally uses && !in-plan per tenant).
5. Rollout sequencing (safe order)
- Catalog consolidation (no tenant impact) — §1 change-list in auth-service.
@hmp/commonUNCHANGED here (it's already canonical); onlyplans.config.ts+subscription.service.tsedit. Rebuild auth-service only. Ship + verifyGET /billing/feature-accessstill returns identicalallowedvalues. SAFE, can go first/independently. - §0 plan-resolver in
FeatureFlagService— addAuthServiceClient.getTenantPlan(new method on existing@hmp/service-clients), inject intoFeatureFlagService, use it in the no-Tenant-model branch.@hmp/commonminor bump (e.g. 1.x→1.(x+1).0) +@hmp/service-clientsminor bump. Rebuild EVERY service that importsFeatureFlagModule(pos, channel, crm, banquet, + soon spa/analytics/property). Tests-before-fix: assert a STARTER tenant now gets denied on a PRO-only code in a downstream service. - Run impact-sizing queries (§4) + build grandfather snapshot — operator/data step. Write
settings.featureOverrides[code]=truefor every(tenant,code)where the tenant currently uses-but-isn't-in-plan, including the already-enforced pos/channel/crm/banquet codes (or §2 activation will bite them). Idempotent script viaFeatureFlagService.setOverride. - Wire the new
@RequiresFeaturegates (§2): spa (wire module first), analytics.basic/advanced, whitelabel, api.access, partner_portal.* (decorators); plus the service-layer count checks formulti_propertyandchannel.unlimited. Each in its owning repo. Rebuild only the touched services. - Deploy + verify on test env, per-flag: below-tier tenant WITHOUT grandfather override → 403
FEATURE_NOT_AVAILABLE; grandfathered tenant → still 200; in-plan tenant → 200.
Package bumps summary: @hmp/common (step 2, resolver) + @hmp/service-clients (step 2, getTenantPlan). plans.config is auth-local (no package bump). Services to rebuild: step 1 → auth only; step 2 → all FeatureFlagModule consumers; step 4 → spa, analytics, auth (branding/api-keys), property, channel.
CLAUDE.md + platform-registry update required in the same PR per charter (document: §0 resolver, the per-flag gates, the grandfather override convention).