From a5687cd22e009839f78fa0af560cc6002931d4d2 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Thu, 23 Apr 2026 14:53:41 +0100 Subject: [PATCH] Formalize v6 paid-surface classification --- docs/PULSE_PRO.md | 31 +++++++++++++++++++ ...elf-hosted-pro-runtime-truth-2026-04-23.md | 2 +- docs/release-control/v6/internal/status.json | 29 +++++++++-------- .../v6/internal/subsystems/cloud-paid.md | 15 ++++++--- .../__tests__/ProLicensePanel.test.tsx | 4 +-- .../Settings/selfHostedBillingPresentation.ts | 4 +-- .../__tests__/licensePresentation.test.ts | 8 ++--- .../src/utils/licensePresentation.ts | 6 ++-- internal/api/contract_test.go | 1 - internal/cloudcp/portal/frontend/dev.mjs | 8 ++--- .../portal/frontend/src/billing_view.test.ts | 6 ++-- .../conversion/upgrade_reasons_test.go | 5 +++ internal/license/coverage_additional_test.go | 2 +- internal/license/features.go | 7 +++++ pkg/licensing/features.go | 16 ++++++++-- pkg/licensing/features_test.go | 11 ++++++- pkg/licensing/upgrade_reasons.go | 18 +++++------ pkg/licensing/upgrade_test.go | 9 ++++++ .../release_control/subsystem_lookup_test.py | 1 + .../55-self-hosted-upgrade-return.spec.ts | 2 +- ...56-pulse-account-upgrade-bootstrap.spec.ts | 2 +- 21 files changed, 131 insertions(+), 56 deletions(-) diff --git a/docs/PULSE_PRO.md b/docs/PULSE_PRO.md index 81f79070c..5a17a0efe 100644 --- a/docs/PULSE_PRO.md +++ b/docs/PULSE_PRO.md @@ -75,6 +75,37 @@ Migration policy: Support rule: - If any self-hosted v6 install shows a bounded monitored-system cap after activation or migration, treat it as a bug rather than as intended policy. Guest limits still follow the active tier or continuity contract. +## V6 Product Classification + +Pulse keeps some entitlement keys for compatibility, but not every Pro +capability key is a primary v6 product pillar. + +### Build On In v6 + +These are the current self-hosted Pro pillars that Pulse should keep +investing in, surfacing, and marketing: +- Alert-triggered root-cause analysis. +- Safe remediation through Patrol auto-fix and higher-autonomy controls. +- 90-day history. +- Included team/admin extras: Advanced SSO (SAML), RBAC, audit logging, + reporting, and agent profiles. + +### Compatibility-Only In v6 + +These remain valid runtime gates for backwards compatibility, but should not +be elevated into headline Pro marketing or generic upgrade prompts: +- `FeatureKubernetesAI` / `kubernetes_ai` + - Keeps the legacy `/api/ai/kubernetes/analyze` route gate intact. + - Do not present it as a primary Pulse Pro pillar on current v6 surfaces. + +### Legacy / Retired Claims + +These should not appear as current v6 Pro promises unless they are rebuilt +into first-class product surfaces: +- `incident memory` as a standalone feature name +- `scheduled remediations` +- `execution audit trail` + ## Feature Matrix Legend: diff --git a/docs/release-control/v6/internal/records/self-hosted-pro-runtime-truth-2026-04-23.md b/docs/release-control/v6/internal/records/self-hosted-pro-runtime-truth-2026-04-23.md index 415fe0056..3cc0219fc 100644 --- a/docs/release-control/v6/internal/records/self-hosted-pro-runtime-truth-2026-04-23.md +++ b/docs/release-control/v6/internal/records/self-hosted-pro-runtime-truth-2026-04-23.md @@ -58,7 +58,7 @@ The following should not be treated as primary marketed Pro pillars: capability bundle and confirms that Community / Relay / Pro monitored-system limits are all uncapped. 2. `pulse-pro/license-server/public_pricing.go` already markets Pro as - root-cause analysis, safe remediation, longer incident memory, and included + root-cause analysis, safe remediation, 90-day history, and included team extras. 3. `frontend-modern/src/components/Alerts/InvestigateAlertButton.tsx` and `frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts` show diff --git a/docs/release-control/v6/internal/status.json b/docs/release-control/v6/internal/status.json index 66c71120f..59021884f 100644 --- a/docs/release-control/v6/internal/status.json +++ b/docs/release-control/v6/internal/status.json @@ -4334,21 +4334,7 @@ ] } ], - "work_claims": [ - { - "id": "codex-lane-l1", - "agent_id": "codex", - "summary": "Guard forward release signing against accidental trust-root drift", - "target_id": "v6-product-lane-expansion", - "claimed_at": "2026-04-22T18:47:37Z", - "heartbeat_at": "2026-04-22T18:47:37Z", - "expires_at": "2026-04-22T20:47:37Z", - "work_item": { - "kind": "lane", - "id": "L1" - } - } - ], + "work_claims": [], "open_decisions": [], "source_of_truth_file": "docs/release-control/v6/internal/SOURCE_OF_TRUTH.md", "resolved_decisions": [ @@ -4853,6 +4839,19 @@ "L8", "L9" ] + }, + { + "id": "self-hosted-paid-surface-classification", + "summary": "Current v6 self-hosted paid surfaces now classify commercial capabilities explicitly: only the primary Pro pillars (root-cause analysis, safe remediation, 90-day history, and included admin extras) may be marketed in customer-facing copy and upgrade prompts; compatibility-only gates such as `kubernetes_ai` remain valid runtime facts but non-marketed, and legacy claims such as `incident memory`, `scheduled remediations`, and `execution audit trail` stay retired unless rebuilt into first-class product surfaces.", + "kind": "pricing", + "decided_at": "2026-04-23", + "subsystem_ids": [ + "cloud-paid" + ], + "lane_ids": [ + "L3", + "L9" + ] } ] } diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md index efaf9450c..b1d4b9df1 100644 --- a/docs/release-control/v6/internal/subsystems/cloud-paid.md +++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md @@ -261,7 +261,7 @@ Community limit enforcement. pricing contract, and owned upgrade reasons. Customer-facing self-hosted copy must keep the ladder explicit as `Community = monitor`, `Relay = reach Pulse from anywhere`, and `Pro = investigate root cause, apply safe - remediation, and retain longer incident memory`. Team/admin extras such as + remediation, and retain 90-day history`. Team/admin extras such as RBAC, SSO, audit logging, reporting, and agent profiles may remain present, but they are secondary included value and must not displace that operator outcome framing on owned commercial surfaces. @@ -294,7 +294,10 @@ Community limit enforcement. SSO, RBAC, audit logging, reporting, and agent profiles may appear as secondary included extras, while platform-specific compatibility keys such as `kubernetes_ai` must not be elevated into a marquee marketed Pro line - item on the self-hosted Plans surface. + item on the self-hosted Plans surface. Legacy packaging nouns such as + `incident memory`, `scheduled remediations`, and `execution audit trail` + must likewise stay out of current v6 commercial copy unless Pulse ships a + first-class product surface that makes those names truthful again. 26. Keep hosted trial-activation verifier source selection compile-time owned. `pkg/licensing/trial_activation.go`, `pkg/licensing/trial_activation_public_key_override_dev.go`, and @@ -1334,7 +1337,8 @@ the no-cap monitored-system model as well. `ProLicensePanel.tsx`, self-hosted packages as unlimited core monitoring plus plan-specific extras: Community stays free for core monitoring, Relay adds remote access/mobile/push convenience and 14-day history, Pro adds Relay plus AI operations, automation, -advanced administration, and 90-day history, while Pro+ remains legacy +root-cause analysis, safe remediation, advanced administration, and 90-day +history, while Pro+ remains legacy continuity only. Cloud/MSP pricing semantics stay separate, and grandfathered v5 continuity copy remains an explicit boundary policy. That same settings-owned presentation must distinguish between active @@ -1383,7 +1387,10 @@ That same `frontend-modern/src/utils/licensePresentation.ts` owner must treat compatibility-only capability keys such as `kubernetes_ai` as non-marketed technical facts: API/docs surfaces may still name the compatibility route, but customer-facing self-hosted current-plan summaries and unlocked-capability -lists must not surface those keys as marquee Pro value. +lists must not surface those keys as marquee Pro value. The same rule applies +to legacy claims such as `incident memory`: current v6 upgrade notices and +commercial copy must use the canonical 90-day history framing until a distinct +incident-memory product exists. That same plan-section boundary must also defer notice resolution to component runtime. `frontend-modern/src/components/Settings/ProLicensePlanSection.tsx` may not compute trial-ended notices at module scope, because hosted settings diff --git a/frontend-modern/src/components/Settings/__tests__/ProLicensePanel.test.tsx b/frontend-modern/src/components/Settings/__tests__/ProLicensePanel.test.tsx index 499f669f6..425db47eb 100644 --- a/frontend-modern/src/components/Settings/__tests__/ProLicensePanel.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/ProLicensePanel.test.tsx @@ -728,7 +728,7 @@ describe('ProLicensePanel', () => { expect(screen.getByText('Push Notifications')).toBeInTheDocument(); expect(screen.getByText('Extended Metric History')).toBeInTheDocument(); expect(screen.getByText('Multi-Tenant Mode')).toBeInTheDocument(); - expect(screen.queryByText('Kubernetes Analysis')).not.toBeInTheDocument(); + expect(screen.queryByText('Kubernetes AI Analysis (Compatibility)')).not.toBeInTheDocument(); expect(screen.queryByText('Multi-User Mode')).not.toBeInTheDocument(); expect(screen.queryByText('White-Label Branding')).not.toBeInTheDocument(); expect(screen.queryByText('Unlimited Instances')).not.toBeInTheDocument(); @@ -1155,7 +1155,7 @@ describe('ProLicensePanel', () => { ); expect(proLicensePlanSectionSource).not.toContain('Your Pro trial has ended'); expect(proLicensePlanSectionSource).not.toContain( - 'Turn alert noise into root-cause answers, safe fixes, and 90-day incident history.', + 'Turn alert noise into root-cause answers, safe fixes, and 90-day history.', ); expect(selfHostedCommercialRecoverySectionSource).toContain( 'SELF_HOSTED_RECOVERY_PRESENTATION', diff --git a/frontend-modern/src/components/Settings/selfHostedBillingPresentation.ts b/frontend-modern/src/components/Settings/selfHostedBillingPresentation.ts index be01b8d9f..843a3140e 100644 --- a/frontend-modern/src/components/Settings/selfHostedBillingPresentation.ts +++ b/frontend-modern/src/components/Settings/selfHostedBillingPresentation.ts @@ -63,7 +63,7 @@ export const SELF_HOSTED_PRO_BILLING_PRESENTATION: SelfHostedProBillingPresentat 'Pulse waits for the session presentation policy before showing license, billing, or usage details.', planSelectionPromptTitle: 'Compare self-hosted plans', planSelectionPromptBody: - 'Community includes self-hosted monitoring. Look at Relay for secure access from anywhere, or Pulse Pro for root-cause answers, safe remediation, and 90-day incident history.', + 'Community includes self-hosted monitoring. Look at Relay for secure access from anywhere, or Pulse Pro for root-cause answers, safe remediation, and 90-day history.', planSelectionPromptActionLabel: 'Compare plans', purchaseActivatedPlanActionLabel: 'Review plan', purchaseCancelledActionLabel: 'Compare plans', @@ -72,7 +72,7 @@ export const SELF_HOSTED_PRO_BILLING_PRESENTATION: SelfHostedProBillingPresentat purchaseUnavailableActionLabel: 'Try again', trialStartTitle: 'Try Pro for free', trialStartBody: - 'Start a 14-day Pro trial for root-cause analysis, safe auto-fix, and 90-day incident history on this organization.', + 'Start a 14-day Pro trial for root-cause analysis, safe auto-fix, and 90-day history on this organization.', trialStartIdleActionLabel: 'Start 14-day Pro Trial', trialStartPendingActionLabel: 'Starting...', recoverySectionTitle: 'Activation & Recovery', diff --git a/frontend-modern/src/utils/__tests__/licensePresentation.test.ts b/frontend-modern/src/utils/__tests__/licensePresentation.test.ts index 3250ef9d8..1eca1aa14 100644 --- a/frontend-modern/src/utils/__tests__/licensePresentation.test.ts +++ b/frontend-modern/src/utils/__tests__/licensePresentation.test.ts @@ -51,13 +51,13 @@ describe('licensePresentation', () => { expect(getTrialEndedProLicenseNotice()).toEqual({ tone: 'border-red-200 dark:border-red-900 bg-red-50 dark:bg-red-900 text-red-900 dark:text-red-100', title: 'Your Pro trial has ended', - body: 'Upgrade to keep root-cause analysis, safe auto-fix, and 90-day incident history.', + body: 'Upgrade to keep root-cause analysis, safe auto-fix, and 90-day history.', actionLabel: 'View Pro plans', }); expect(getInactiveProUpsellNotice()).toEqual({ tone: 'border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900 text-amber-800 dark:text-amber-200', title: 'Upgrade to Pro', - body: 'Turn alert noise into root-cause answers, safe fixes, and 90-day incident history.', + body: 'Turn alert noise into root-cause answers, safe fixes, and 90-day history.', actionLabel: 'View Pro plans', }); expect(SELF_HOSTED_RECOVERY_PRESENTATION).toMatchObject({ @@ -97,7 +97,7 @@ describe('licensePresentation', () => { 'Pulse waits for the session presentation policy before showing license, billing, or usage details.', planSelectionPromptTitle: 'Compare self-hosted plans', planSelectionPromptBody: - 'Community includes self-hosted monitoring. Look at Relay for secure access from anywhere, or Pulse Pro for root-cause answers, safe remediation, and 90-day incident history.', + 'Community includes self-hosted monitoring. Look at Relay for secure access from anywhere, or Pulse Pro for root-cause answers, safe remediation, and 90-day history.', planSelectionPromptActionLabel: 'Compare plans', purchaseActivatedPlanActionLabel: 'Review plan', purchaseCancelledActionLabel: 'Compare plans', @@ -106,7 +106,7 @@ describe('licensePresentation', () => { purchaseUnavailableActionLabel: 'Try again', trialStartTitle: 'Try Pro for free', trialStartBody: - 'Start a 14-day Pro trial for root-cause analysis, safe auto-fix, and 90-day incident history on this organization.', + 'Start a 14-day Pro trial for root-cause analysis, safe auto-fix, and 90-day history on this organization.', trialStartIdleActionLabel: 'Start 14-day Pro Trial', trialStartPendingActionLabel: 'Starting...', recoverySectionTitle: 'Activation & Recovery', diff --git a/frontend-modern/src/utils/licensePresentation.ts b/frontend-modern/src/utils/licensePresentation.ts index 632926e37..f7e568fc2 100644 --- a/frontend-modern/src/utils/licensePresentation.ts +++ b/frontend-modern/src/utils/licensePresentation.ts @@ -37,7 +37,7 @@ const FEATURE_LABELS: Record = { ai_patrol: 'Pulse Patrol', ai_alerts: 'Pulse Alert Analysis', ai_autofix: 'Patrol Auto-Fix', - kubernetes_ai: 'Kubernetes Analysis', + kubernetes_ai: 'Kubernetes AI Analysis (Compatibility)', update_alerts: 'Update Alerts', sso: 'Basic SSO (OIDC)', advanced_sso: 'Advanced SSO (SAML/Multi-Provider)', @@ -761,14 +761,14 @@ export const getNoActiveProLicenseState = (): LicenseLoadingStateCopy => ({ export const getTrialEndedProLicenseNotice = (): LicenseActionNotice => ({ tone: 'border-red-200 dark:border-red-900 bg-red-50 dark:bg-red-900 text-red-900 dark:text-red-100', title: 'Your Pro trial has ended', - body: 'Upgrade to keep root-cause analysis, safe auto-fix, and 90-day incident history.', + body: 'Upgrade to keep root-cause analysis, safe auto-fix, and 90-day history.', actionLabel: 'View Pro plans', }); export const getInactiveProUpsellNotice = (): LicenseActionNotice => ({ tone: 'border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900 text-amber-800 dark:text-amber-200', title: 'Upgrade to Pro', - body: 'Turn alert noise into root-cause answers, safe fixes, and 90-day incident history.', + body: 'Turn alert noise into root-cause answers, safe fixes, and 90-day history.', actionLabel: 'View Pro plans', }); diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index 019304e29..83f0a90af 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -5591,7 +5591,6 @@ func TestContract_SelfHostedCommunityEntitlementsJSONSnapshot(t *testing.T) { {"key":"long_term_metrics","reason":"Get Relay for 14 days of history, or Pro for 90 days, so you can see what changed before and after an incident.","action_url":"https://pulserelay.pro/pricing?utm_source=pulse\u0026utm_medium=app\u0026utm_campaign=upgrade\u0026feature=long_term_metrics"}, {"key":"ai_autofix","reason":"Upgrade to Pro so Pulse can move from finding issues to applying safe remediation with your approval or in autonomous mode.","action_url":"https://pulserelay.pro/pricing?utm_source=pulse\u0026utm_medium=app\u0026utm_campaign=upgrade\u0026feature=ai_autofix"}, {"key":"ai_alerts","reason":"Upgrade to Pro so alerts arrive with root-cause analysis instead of a stack of symptoms.","action_url":"https://pulserelay.pro/pricing?utm_source=pulse\u0026utm_medium=app\u0026utm_campaign=upgrade\u0026feature=ai_alerts"}, - {"key":"kubernetes_ai","reason":"Upgrade to Pro so Pulse can explain cluster pressure, failing pods, and likely causes without manual Kubernetes triage.","action_url":"https://pulserelay.pro/pricing?utm_source=pulse\u0026utm_medium=app\u0026utm_campaign=upgrade\u0026feature=kubernetes_ai"}, {"key":"rbac","reason":"Upgrade to Pro when more than one operator needs safe access boundaries around infrastructure changes.","action_url":"https://pulserelay.pro/pricing?utm_source=pulse\u0026utm_medium=app\u0026utm_campaign=upgrade\u0026feature=rbac"}, {"key":"agent_profiles","reason":"Upgrade to Pro to standardize agent behavior across systems without reconfiguring every install by hand.","action_url":"https://pulserelay.pro/pricing?utm_source=pulse\u0026utm_medium=app\u0026utm_campaign=upgrade\u0026feature=agent_profiles"}, {"key":"advanced_sso","reason":"Upgrade to Pro to connect your identity provider and keep operator access aligned with your existing auth controls.","action_url":"https://pulserelay.pro/pricing?utm_source=pulse\u0026utm_medium=app\u0026utm_campaign=upgrade\u0026feature=advanced_sso"}, diff --git a/internal/cloudcp/portal/frontend/dev.mjs b/internal/cloudcp/portal/frontend/dev.mjs index d682c7e34..104a04ca8 100644 --- a/internal/cloudcp/portal/frontend/dev.mjs +++ b/internal/cloudcp/portal/frontend/dev.mjs @@ -482,9 +482,9 @@ function routeCommercialAPI(request, response, url, scenario) { if (request.method === 'GET' && route === '/v1/public/pricing-model') { sendJSON(response, 200, { title: 'Simple self-hosted pricing for Pulse', - description: 'Community keeps monitoring free. Relay lets you reach Pulse securely from anywhere. Pro explains what broke, helps fix it safely, and keeps 90 days of incident history.', + description: 'Community keeps monitoring free. Relay lets you reach Pulse securely from anywhere. Pro explains what broke, helps fix it safely, and keeps 90 days of history.', explainer: - 'Community keeps monitoring free. Relay gets Pulse securely from anywhere. Pro adds root-cause analysis, safe remediation, and 90-day incident history.', + 'Community keeps monitoring free. Relay gets Pulse securely from anywhere. Pro adds root-cause analysis, safe remediation, and 90-day history.', plans: [ { badge: 'Recommended', @@ -510,13 +510,13 @@ function routeCommercialAPI(request, response, url, scenario) { title: 'Pro', price: '$8.99', period: '$79/year available too', - blurb: 'The operator tier for root-cause answers, safe remediation, and longer incident memory.', + blurb: 'The operator tier for root-cause answers, safe remediation, and 90-day history.', features: [ { tone: 'check', html: 'Unlimited self-hosted monitoring' }, { tone: 'check', html: 'Everything in Relay' }, { tone: 'check', html: 'Alert-triggered root-cause analysis' }, { tone: 'check', html: 'Patrol auto-fix with approval or autonomous mode' }, - { tone: 'check', html: '90-day incident history' }, + { tone: 'check', html: '90-day history' }, ], buttons: [ { kind: 'checkout', className: 'btn btn-secondary', tier: 'pro', planKey: 'price_pro_monthly', billingCycle: 'monthly', label: 'Buy Monthly' }, diff --git a/internal/cloudcp/portal/frontend/src/billing_view.test.ts b/internal/cloudcp/portal/frontend/src/billing_view.test.ts index 986470d1c..d5c66d979 100644 --- a/internal/cloudcp/portal/frontend/src/billing_view.test.ts +++ b/internal/cloudcp/portal/frontend/src/billing_view.test.ts @@ -240,14 +240,14 @@ describe('services view', function() { title: 'Pricing', description: 'Canonical pricing model', explainer: - 'Community keeps monitoring free. Relay gets Pulse securely from anywhere. Pro adds root-cause analysis, safe remediation, and 90-day incident history.', + 'Community keeps monitoring free. Relay gets Pulse securely from anywhere. Pro adds root-cause analysis, safe remediation, and 90-day history.', plans: [ { tierKicker: 'Pro', title: 'Pro', price: '$8.99', period: '$79/year available too', - blurb: 'The operator tier for root-cause answers, safe remediation, and longer incident memory.', + blurb: 'The operator tier for root-cause answers, safe remediation, and 90-day history.', features: [{ tone: 'check', html: 'Unlimited self-hosted monitoring' }], buttons: [ { @@ -273,7 +273,7 @@ describe('services view', function() { 'Pulse Account keeps checkout tied to the Pulse instance that opened it, so completed Relay or Pro purchases return to the right Plans page automatically.', ); expect(document.getElementById('upgrade-billing-root')?.innerHTML).toContain( - 'Community keeps monitoring free. Relay gets Pulse securely from anywhere. Pro adds root-cause analysis, safe remediation, and 90-day incident history.', + 'Community keeps monitoring free. Relay gets Pulse securely from anywhere. Pro adds root-cause analysis, safe remediation, and 90-day history.', ); expect(document.getElementById('upgrade-billing-root')?.innerHTML).not.toContain('Continue to Plans'); expect(document.getElementById('upgrade-billing-root')?.innerHTML).not.toContain('ppk_live_preview'); diff --git a/internal/license/conversion/upgrade_reasons_test.go b/internal/license/conversion/upgrade_reasons_test.go index 70ad5fb70..a6692f2ec 100644 --- a/internal/license/conversion/upgrade_reasons_test.go +++ b/internal/license/conversion/upgrade_reasons_test.go @@ -118,5 +118,10 @@ func expectedProOnlyFeatureSet() map[string]struct{} { for _, feature := range license.TierFeatures[license.TierFree] { delete(proFeatures, feature) } + for feature := range proFeatures { + if license.IsCompatibilityOnlyFeature(feature) { + delete(proFeatures, feature) + } + } return proFeatures } diff --git a/internal/license/coverage_additional_test.go b/internal/license/coverage_additional_test.go index ad4fdc270..0324b87fd 100644 --- a/internal/license/coverage_additional_test.go +++ b/internal/license/coverage_additional_test.go @@ -80,7 +80,7 @@ func TestGetFeatureDisplayNameCoversKnownFeaturesAndFallback(t *testing.T) { {feature: FeatureAIPatrol, want: "Pulse Patrol (Background Health Checks)"}, {feature: FeatureAIAlerts, want: "Alert Analysis"}, {feature: FeatureAIAutoFix, want: "Pulse Patrol Auto-Fix"}, - {feature: FeatureKubernetesAI, want: "Kubernetes Analysis"}, + {feature: FeatureKubernetesAI, want: "Kubernetes AI Analysis (Compatibility)"}, {feature: FeatureUpdateAlerts, want: "Update Alerts (Container/Package Updates)"}, {feature: FeatureRBAC, want: "Role-Based Access Control (RBAC)"}, {feature: FeatureMultiUser, want: "Multi-User Mode"}, diff --git a/internal/license/features.go b/internal/license/features.go index bf4e0922c..927ea79a1 100644 --- a/internal/license/features.go +++ b/internal/license/features.go @@ -76,3 +76,10 @@ func GetTierDisplayName(tier Tier) string { func GetFeatureDisplayName(feature string) string { return licensing.GetFeatureDisplayName(feature) } + +// IsCompatibilityOnlyFeature reports capability keys that remain valid runtime +// contracts for backwards compatibility but should not be marketed as current +// v6 commercial pillars or generic upgrade prompts. +func IsCompatibilityOnlyFeature(feature string) bool { + return licensing.IsCompatibilityOnlyFeature(feature) +} diff --git a/pkg/licensing/features.go b/pkg/licensing/features.go index 298534e24..e5f6da42f 100644 --- a/pkg/licensing/features.go +++ b/pkg/licensing/features.go @@ -27,7 +27,7 @@ const ( // Pro tier features (everything in Relay, plus:) FeatureAIAlerts = "ai_alerts" // AI analysis when alerts fire FeatureAIAutoFix = "ai_autofix" // Automatic remediation (one-click apply) - FeatureKubernetesAI = "kubernetes_ai" // AI analysis of K8s (NOT basic monitoring) + FeatureKubernetesAI = "kubernetes_ai" // Legacy Kubernetes analysis compatibility gate (NOT basic monitoring) FeatureAgentProfiles = "agent_profiles" // Centralized agent configuration profiles FeatureRBAC = "rbac" // Role-Based Access Control FeatureAuditLogging = "audit_logging" // Persistent audit logs with signing @@ -466,7 +466,7 @@ func GetFeatureDisplayName(feature string) string { case FeatureAIAutoFix: return "Pulse Patrol Auto-Fix" case FeatureKubernetesAI: - return "Kubernetes Analysis" + return "Kubernetes AI Analysis (Compatibility)" case FeatureUpdateAlerts: return "Update Alerts (Container/Package Updates)" case FeatureRBAC: @@ -503,3 +503,15 @@ func GetFeatureDisplayName(feature string) string { return feature } } + +var compatibilityOnlyFeatures = map[string]struct{}{ + FeatureKubernetesAI: {}, +} + +// IsCompatibilityOnlyFeature reports capability keys that remain valid runtime +// contracts for backwards compatibility but should not be marketed as current +// v6 commercial pillars or generic upgrade prompts. +func IsCompatibilityOnlyFeature(feature string) bool { + _, ok := compatibilityOnlyFeatures[feature] + return ok +} diff --git a/pkg/licensing/features_test.go b/pkg/licensing/features_test.go index e9193b101..0a8fd9221 100644 --- a/pkg/licensing/features_test.go +++ b/pkg/licensing/features_test.go @@ -225,7 +225,7 @@ func TestGetFeatureDisplayName(t *testing.T) { {FeatureAIPatrol, "Pulse Patrol (Background Health Checks)"}, {FeatureAIAlerts, "Alert Analysis"}, {FeatureAIAutoFix, "Pulse Patrol Auto-Fix"}, - {FeatureKubernetesAI, "Kubernetes Analysis"}, + {FeatureKubernetesAI, "Kubernetes AI Analysis (Compatibility)"}, {FeatureUpdateAlerts, "Update Alerts (Container/Package Updates)"}, {FeatureRBAC, "Role-Based Access Control (RBAC)"}, {FeatureMultiUser, "Multi-User Mode"}, @@ -255,6 +255,15 @@ func TestGetFeatureDisplayName(t *testing.T) { } } +func TestIsCompatibilityOnlyFeature(t *testing.T) { + if !IsCompatibilityOnlyFeature(FeatureKubernetesAI) { + t.Fatalf("expected %q to remain compatibility-only", FeatureKubernetesAI) + } + if IsCompatibilityOnlyFeature(FeatureAIAlerts) { + t.Fatalf("did not expect %q to be compatibility-only", FeatureAIAlerts) + } +} + func TestTierMonitoredSystemLimits(t *testing.T) { tests := []struct { tier Tier diff --git a/pkg/licensing/upgrade_reasons.go b/pkg/licensing/upgrade_reasons.go index f2b92eab5..160604060 100644 --- a/pkg/licensing/upgrade_reasons.go +++ b/pkg/licensing/upgrade_reasons.go @@ -12,6 +12,8 @@ type ReasonEntry struct { // UpgradeReasonMatrix is the canonical feature-to-upgrade-reason mapping. // Relay features use "Upgrade to Relay" messaging; Pro features use "Upgrade to Pro". +// Compatibility-only capabilities must not appear here as generic marketed +// upgrade reasons. var UpgradeReasonMatrix = []ReasonEntry{ { Feature: FeatureRelay, @@ -49,41 +51,35 @@ var UpgradeReasonMatrix = []ReasonEntry{ ActionURL: UpgradeURLForFeature(FeatureAIAlerts), Priority: 4, }, - { - Feature: FeatureKubernetesAI, - Reason: "Upgrade to Pro so Pulse can explain cluster pressure, failing pods, and likely causes without manual Kubernetes triage.", - ActionURL: UpgradeURLForFeature(FeatureKubernetesAI), - Priority: 5, - }, { Feature: FeatureRBAC, Reason: "Upgrade to Pro when more than one operator needs safe access boundaries around infrastructure changes.", ActionURL: UpgradeURLForFeature(FeatureRBAC), - Priority: 6, + Priority: 5, }, { Feature: FeatureAgentProfiles, Reason: "Upgrade to Pro to standardize agent behavior across systems without reconfiguring every install by hand.", ActionURL: UpgradeURLForFeature(FeatureAgentProfiles), - Priority: 7, + Priority: 6, }, { Feature: FeatureAdvancedSSO, Reason: "Upgrade to Pro to connect your identity provider and keep operator access aligned with your existing auth controls.", ActionURL: UpgradeURLForFeature(FeatureAdvancedSSO), - Priority: 8, + Priority: 7, }, { Feature: FeatureAuditLogging, Reason: "Upgrade to Pro to keep a trustworthy action trail for incident review, accountability, and compliance.", ActionURL: UpgradeURLForFeature(FeatureAuditLogging), - Priority: 9, + Priority: 8, }, { Feature: FeatureAdvancedReporting, Reason: "Upgrade to Pro to turn live infrastructure state into shareable reports without manual screenshot work.", ActionURL: UpgradeURLForFeature(FeatureAdvancedReporting), - Priority: 10, + Priority: 9, }, } diff --git a/pkg/licensing/upgrade_test.go b/pkg/licensing/upgrade_test.go index 62bc9bcd0..dbe54788d 100644 --- a/pkg/licensing/upgrade_test.go +++ b/pkg/licensing/upgrade_test.go @@ -100,3 +100,12 @@ func TestOperatorOutcomeUpgradeReasonsRemainCanonical(t *testing.T) { } } } + +func TestCompatibilityOnlyFeaturesStayOutOfGenericUpgradeReasons(t *testing.T) { + reasons := GenerateUpgradeReasons(nil) + for _, entry := range reasons { + if entry.Feature == FeatureKubernetesAI { + t.Fatalf("compatibility-only feature %q should not produce a generic upgrade reason", entry.Feature) + } + } +} diff --git a/scripts/release_control/subsystem_lookup_test.py b/scripts/release_control/subsystem_lookup_test.py index c28cfcebf..d2fe72a45 100644 --- a/scripts/release_control/subsystem_lookup_test.py +++ b/scripts/release_control/subsystem_lookup_test.py @@ -4557,6 +4557,7 @@ class SubsystemLookupTest(unittest.TestCase): "cloud-msp-stripe-prices", "legacy-grandfathering-eligibility-cutoff", "self-hosted-plans-surface-entitlement-first", + "self-hosted-paid-surface-classification", "self-hosted-core-monitoring-free", "self-hosted-paid-extras-packaging", "stable-release-promotion-model", diff --git a/tests/integration/tests/55-self-hosted-upgrade-return.spec.ts b/tests/integration/tests/55-self-hosted-upgrade-return.spec.ts index d0bd05408..84168a80b 100644 --- a/tests/integration/tests/55-self-hosted-upgrade-return.spec.ts +++ b/tests/integration/tests/55-self-hosted-upgrade-return.spec.ts @@ -421,7 +421,7 @@ async function openMonitoredSystemUpgradeArrival(page: Page) { ); await expect( page.getByText( - "Community includes self-hosted monitoring. Look at Relay for secure access from anywhere, or Pulse Pro for root-cause answers, safe remediation, and 90-day incident history.", + "Community includes self-hosted monitoring. Look at Relay for secure access from anywhere, or Pulse Pro for root-cause answers, safe remediation, and 90-day history.", ), ).toBeVisible(); await expect(page.getByRole("button", { name: "Hide counting rules" })).toHaveCount(0); diff --git a/tests/integration/tests/56-pulse-account-upgrade-bootstrap.spec.ts b/tests/integration/tests/56-pulse-account-upgrade-bootstrap.spec.ts index 22497415f..8c3763c3c 100644 --- a/tests/integration/tests/56-pulse-account-upgrade-bootstrap.spec.ts +++ b/tests/integration/tests/56-pulse-account-upgrade-bootstrap.spec.ts @@ -80,7 +80,7 @@ test.describe("Pulse Account upgrade bootstrap", () => { ).toBeVisible(); await expect( page.getByText( - "Community keeps monitoring free. Relay gets Pulse securely from anywhere. Pro adds root-cause analysis, safe remediation, and 90-day incident history.", + "Community keeps monitoring free. Relay gets Pulse securely from anywhere. Pro adds root-cause analysis, safe remediation, and 90-day history.", ), ).toBeVisible(); await expect(