Skip to main content

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 @RequiresFeature gate 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/common FEATURE_FLAGS is the single canonical catalog. hmp-auth-service/src/billing/plans.config.ts KEEPS the per-plan QUOTAS (maxRooms/maxUsers/maxProperties/price) and DELETES the divergent features[] arrays, ALL_FEATURES, PlanFeatureId, and planIncludesFeature. subscription.service.ts:checkFeatureAccess now calls isFeatureInPlan from @hmp/common, with a LEGACY_FEATURE_ALIASES short-code→dotted-code map (pos→pos.basic, analytics→analytics.basic, loyalty→crm.loyalty, api_access→api.access, …) so the informational feature-access endpoint contract is byte-identical. A describe.each parity test proves STARTER/PRO/ENT allowed/denied are identical pre/post.

TASK 2 — real plan resolver (makes tier resolution work): @hmp/common adds a TenantPlanResolver interface + TENANT_PLAN_RESOLVER DI token (feature-flags/tenant-plan-resolver.ts). FeatureFlagService injects it @Optional() and, in the no-Tenant-model branch of getTenantConfig, resolves the REAL plan instead of the hard-coded ENTERPRISE; featureOverrides precedence is unchanged. @hmp/service-clients AuthServiceClient.getTenantPlan(tenantId) implements the resolver against a new auth-service endpoint GET /tenants/:id/plan (returns { plan } from settings.plan, default STARTER, INTERNAL_SERVICE-gated). It is 5-min in-process cached and fail-safe: on any remote error (5xx/404/network) it returns ENTERPRISE and never throws — fail-OPEN so a transient auth outage cannot 503 the platform or 403 a paying tenant out of its own product; FeatureFlagService catches 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 @RequiresFeature gates START ENFORCING BY TIER. The TEST tenants (e2eresort 657761e7-4141-40ac-8d04-2856cf2dd6cf, kn-uat d2a8db39-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 uses isFeatureInPlan, 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/common FEATURE_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's GET /feature-flags/plans catalog (feature-flags.controller.ts:58,135 already derive from it).
  • B (divergent): auth-service plans.config.ts. Two relevant structures:
    • PLAN_FEATURES[plan].features: string[] (:31-61) — short codes pms/frontdesk/housekeeping/pos/channel/reports/crm per 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 from subscription.service.ts:473 (checkFeatureAccess, behind GET /api/billing/.../feature-access).

Divergences​

ConcernCatalog A (@hmp/common)Catalog B (plans.config)
Namingapi.access, crm.loyalty, analytics.basic/advanced, pos.basicapi_access, loyalty, analytics, pos
Granularitysplit (pos.basic/kitchen_display/inventory, analytics.basic/advanced)flat (pos, analytics)
Codes only in Bbooking_engine, kiosk, mobile_app, reports— (no @RequiresFeature consumer anyway)
Codes only in Apartner_portal.*, channel.unlimited, multi_property, pms.* granular—
Plan assignment for STARTERA: 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):​

  1. plans.config.ts — KEEP PLAN_FEATURES quotas only (maxRooms/maxUsers/maxProperties/price — these are NOT in catalog A and are consumed by usage-metering.service.ts:114-198 and trial.service.ts:90-106; do not touch). DELETE the features: string[] arrays, ALL_FEATURES, PlanFeatureId, and planIncludesFeature.
  2. subscription.service.ts:473 — replace planIncludesFeature(subscription.planCode, feature) with isFeatureInPlan(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. checkFeatureAccess semantics are unchanged.
  3. Verify no other importer of the deleted symbols (grep already done: only subscription.service.ts, subscriptions/subscription.service.ts imports getPlanByCode which stays, usage-metering/trial use 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)​

FlagWhere to enforcePlacementNotes / ambiguity
spahmp-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.basichmp-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.advancedthe 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_propertyhmp-property-service POST /properties (properties.controller.ts:36) — gate creation of the Nth propertyservice-layer count check in properties.service.create, NOT a blanket controller gateCannot 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.
whitelabelhmp-auth-service branding write routes: PUT /branding/:tenantId (branding.controller.ts:48), DELETE (:122)method-level @RequiresFeature('whitelabel') on PUT+DELETE onlyDO 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.accesshmp-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 ApiKeyGuardSpans 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.unlimitedhmp-channel-service connections.service.createNOT a controller decorator — needs a new count checkNo 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.enabledhmp-revenue-strategy / partner-portal login + assume-engagement@RequiresFeature('partner_portal.enabled') on partner-login & assume-engagement controllersCurrently 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_enabledpartner invite-creation endpoint@RequiresFeature('partner_portal.invite_creation_enabled') on the invite-create routeSame as above — registered, unenforced. defaultEnabled:true, so enforcing is no-op until someone flips it OFF. Low risk.
partner_portal.pii_consent_enabledpartner engagement PII-consent request route@RequiresFeature(...) on the GUEST_PII_FULL request pathSame; 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 in feature-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). Use FeatureFlagService.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_AVAILABLE with requiredPlan from 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 with COUNT(properties) > 1 (property-service DB)
  • channel.unlimited → tenants with COUNT(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)​

  1. Catalog consolidation (no tenant impact) — §1 change-list in auth-service. @hmp/common UNCHANGED here (it's already canonical); only plans.config.ts + subscription.service.ts edit. Rebuild auth-service only. Ship + verify GET /billing/feature-access still returns identical allowed values. SAFE, can go first/independently.
  2. §0 plan-resolver in FeatureFlagService — add AuthServiceClient.getTenantPlan (new method on existing @hmp/service-clients), inject into FeatureFlagService, use it in the no-Tenant-model branch. @hmp/common minor bump (e.g. 1.x→1.(x+1).0) + @hmp/service-clients minor bump. Rebuild EVERY service that imports FeatureFlagModule (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.
  3. Run impact-sizing queries (§4) + build grandfather snapshot — operator/data step. Write settings.featureOverrides[code]=true for 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 via FeatureFlagService.setOverride.
  4. Wire the new @RequiresFeature gates (§2): spa (wire module first), analytics.basic/advanced, whitelabel, api.access, partner_portal.* (decorators); plus the service-layer count checks for multi_property and channel.unlimited. Each in its owning repo. Rebuild only the touched services.
  5. 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).