diff --git a/docs/release-control/v6/internal/records/paid-feature-claim-proof-matrix-2026-04-29.md b/docs/release-control/v6/internal/records/paid-feature-claim-proof-matrix-2026-04-29.md new file mode 100644 index 0000000000..30fd68973b --- /dev/null +++ b/docs/release-control/v6/internal/records/paid-feature-claim-proof-matrix-2026-04-29.md @@ -0,0 +1,21 @@ +# Paid Feature Claim Proof Matrix + +Date: 2026-04-29 + +## Decision + +Self-hosted v6 paid claims are release-gated by an executable proof bundle. The bundle verifies that Community, Relay, and Pro claims line up across the canonical licensing contract, history entitlement enforcement, frontend plan copy, public checkout/pricing, download authorization, and Relay runtime entitlement behavior. + +## Proof Command + +```bash +python3 scripts/release_control/paid_feature_claims_proof.py --json +``` + +## Required Coverage + +- Community keeps core monitoring free and does not introduce monitored-system volume caps. +- Relay claims only remote web access, mobile pairing, push notifications, and 14-day metric history. +- Pro preserves Relay capabilities and adds operator extras: root-cause analysis, safe remediation workflows, 90-day metric history, RBAC, audit logging, reporting, SAML SSO, and agent profiles. +- History claims are enforced by the runtime metrics-history API, not only shown in copy. +- Public pricing, checkout, download, and relay-server entitlement behavior remain consistent with those claims. diff --git a/docs/release-control/v6/internal/status.json b/docs/release-control/v6/internal/status.json index 392a6c2b47..db044eb4cc 100644 --- a/docs/release-control/v6/internal/status.json +++ b/docs/release-control/v6/internal/status.json @@ -2512,6 +2512,12 @@ "path": "docs/release-control/v6/internal/records/cloud-msp-price-audit-2026-03-13.md", "kind": "file" }, + { + "repo": "pulse", + "path": "docs/release-control/v6/internal/records/paid-feature-claim-proof-matrix-2026-04-29.md", + "kind": "file", + "evidence_tier": "test-proof" + }, { "repo": "pulse", "path": "docs/release-control/v6/internal/records/self-hosted-commercial-ga-coherence-2026-04-20.md", @@ -2527,6 +2533,12 @@ "path": "docs/release-control/v6/internal/subsystems/cloud-paid.md", "kind": "file" }, + { + "repo": "pulse", + "path": "scripts/release_control/paid_feature_claims_proof.py", + "kind": "file", + "evidence_tier": "test-proof" + }, { "repo": "pulse-pro", "path": "V6_LAUNCH_CHECKLIST.md", @@ -4415,11 +4427,23 @@ "L16" ], "evidence": [ + { + "repo": "pulse", + "path": "docs/release-control/v6/internal/records/paid-feature-claim-proof-matrix-2026-04-29.md", + "kind": "file", + "evidence_tier": "test-proof" + }, { "repo": "pulse", "path": "docs/release-control/v6/internal/records/paid-feature-entitlement-gating-2026-03-12.md", "kind": "file", "evidence_tier": "managed-runtime-exercise" + }, + { + "repo": "pulse", + "path": "scripts/release_control/paid_feature_claims_proof.py", + "kind": "file", + "evidence_tier": "test-proof" } ] }, diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md index 82a667b638..ad06d16e10 100644 --- a/docs/release-control/v6/internal/subsystems/cloud-paid.md +++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md @@ -178,19 +178,27 @@ The canonical operator preview for that cutover now lives in `pulse-pro/scripts/grandfathered_recurring_cutover_preview.py`, and it must use the same active-at-snapshot rule as the live server before anyone sets `PULSE_LICENSE_GRANDFATHERED_RECURRING_SNAPSHOT_AT`. -That same commercial control boundary also owns the server-side checkout funnel -analytics, alerting, digest generation, and admin summary payloads in -`pulse-pro:license-server/checkout_funnel.go`. Alert evaluation there must use -rolling trailing windows for "last 24 hours" and "previous 7 days" semantics -rather than UTC calendar-day buckets, so midnight does not suppress active -checkout regressions or leave the admin summary without the alert state the -server already observed. +The retired server-side checkout funnel analytics, alerting, digest +generation, and admin summary payloads are no longer part of the active v6 +proof floor. `pulse-pro:license-server/v6_checkout.go` may accept legacy +funnel-telemetry fields only as ignored compatibility input for older static +site deploys; active paid-feature proof must stay on checkout creation, +portal-handoff state, pricing/catalog truth, download delivery, and license or +relay entitlement behavior rather than requiring the removed funnel monitoring +files. That same public-pricing boundary also owns the machine-readable v6 pricing validator and launch checklist in `pulse-pro:scripts/validate_public_pricing_model.py` and `pulse-pro:V6_LAUNCH_CHECKLIST.md`. Those operator surfaces must describe self-hosted Pulse as core monitoring included by default, must not reintroduce monitored-system upsell language, and must verify the no-cap release posture rather than legacy Community limit enforcement. +Self-hosted paid-feature claims must also remain covered by the executable +cross-repo proof bundle in `scripts/release_control/paid_feature_claims_proof.py`. +When Community, Relay, or Pro claims change, that proof must continue to verify +the canonical licensing matrix, runtime history entitlement enforcement, +frontend plan presentation, public pricing/checkout/download behavior, and +Relay entitlement acceptance together rather than treating copy, checkout, and +runtime gating as separate unlinked claims. ## Extension Points diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index 47b3481821..6d1f6f8d10 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -1834,7 +1834,6 @@ "internal/hosted/provisioner.go", "pulse-pro:.github/workflows/deploy-v6-preview-demo.yml", "pulse-pro:landing-page/thanks.html", - "pulse-pro:license-server/checkout_funnel.go", "pulse-pro:license-server/grandfathering.go", "pulse-pro:license-server/main.go", "pulse-pro:license-server/self_hosted_feature_catalog.generated.go", @@ -2530,19 +2529,6 @@ "frontend-modern/src/pages/__tests__/RuntimeHome.test.tsx" ] }, - { - "id": "checkout-funnel-analytics", - "label": "checkout funnel analytics proof", - "match_prefixes": [], - "match_files": [ - "pulse-pro:license-server/checkout_funnel.go" - ], - "allow_same_subsystem_tests": false, - "test_prefixes": [], - "exact_files": [ - "pulse-pro:license-server/checkout_funnel_test.go" - ] - }, { "id": "legacy-grandfathering-cutover", "label": "legacy recurring grandfathering cutover proof", diff --git a/frontend-modern/src/utils/__tests__/licensePresentation.test.ts b/frontend-modern/src/utils/__tests__/licensePresentation.test.ts index 7fde084496..ec1e310d26 100644 --- a/frontend-modern/src/utils/__tests__/licensePresentation.test.ts +++ b/frontend-modern/src/utils/__tests__/licensePresentation.test.ts @@ -28,6 +28,7 @@ import { SELF_HOSTED_RECOVERY_PRESENTATION, } from '@/utils/licensePresentation'; import { SELF_HOSTED_PRO_BILLING_PRESENTATION } from '@/components/Settings/selfHostedBillingPresentation'; +import { getSelfHostedPlanEntitlementSummary } from '@/utils/selfHostedPlans'; describe('licensePresentation', () => { it('returns canonical tier labels', () => { @@ -378,6 +379,38 @@ describe('licensePresentation', () => { }); }); + it('sources active self-hosted current-plan summaries from the shared plan contract', () => { + expect( + getSelfHostedCurrentPlanPresentation({ + entitlements: { + tier: 'relay', + subscription_state: 'active', + capabilities: ['relay', 'mobile_app', 'push_notifications'], + limits: [], + upgrade_reasons: [], + }, + displayableCapabilities: [ + 'Pulse Relay (Remote Access)', + 'Mobile App Pairing', + 'Push Notifications', + ], + }).body, + ).toBe(getSelfHostedPlanEntitlementSummary('relay', 'Relay')); + + expect( + getSelfHostedCurrentPlanPresentation({ + entitlements: { + tier: 'pro_plus', + subscription_state: 'active', + capabilities: ['relay', 'ai_autofix'], + limits: [], + upgrade_reasons: [], + }, + displayableCapabilities: ['Pulse Relay (Remote Access)', 'Safe Remediation Workflows'], + }).body, + ).toBe(getSelfHostedPlanEntitlementSummary('pro', 'Legacy Pulse Pro+')); + }); + it('builds restrained higher-tier comparison cards for Community and Relay only', () => { expect( getSelfHostedPlanComparisonPresentation({ diff --git a/frontend-modern/src/utils/__tests__/selfHostedPlans.test.ts b/frontend-modern/src/utils/__tests__/selfHostedPlans.test.ts index fbbc815200..8ca874b11c 100644 --- a/frontend-modern/src/utils/__tests__/selfHostedPlans.test.ts +++ b/frontend-modern/src/utils/__tests__/selfHostedPlans.test.ts @@ -1,12 +1,24 @@ import { describe, expect, it } from 'vitest'; import { + getSelfHostedPlanEntitlementSummary, getSelfHostedPlanDefinitionForBillingTier, SELF_HOSTED_COMMERCIAL_PRESENTATION, SELF_HOSTED_FEATURE_ROWS, SELF_HOSTED_PLAN_BY_TIER, SELF_HOSTED_PLAN_DEFINITIONS, } from '../selfHostedPlans'; +import { + getSelfHostedFeaturesForRole, + SELF_HOSTED_FEATURE_CATALOG, +} from '../selfHostedFeatureCatalog.generated'; + +const primaryPillarClaimLabels = (tier: 'relay' | 'pro', metricHistoryDays: number) => + getSelfHostedFeaturesForRole(tier, 'primary_pillar').map((entry) => + entry.key === 'long_term_metrics' + ? `${metricHistoryDays}-day metric history` + : entry.comparisonName, + ); describe('selfHostedPlans', () => { it('keeps core monitoring aligned as an included self-hosted baseline', () => { @@ -145,6 +157,96 @@ describe('selfHostedPlans', () => { ); }); + it('derives paid self-hosted feature claims from the generated catalog', () => { + expect(SELF_HOSTED_PLAN_BY_TIER.community.entitlementSummary).toBe( + getSelfHostedPlanEntitlementSummary('community'), + ); + expect(SELF_HOSTED_PLAN_BY_TIER.relay.entitlementSummary).toBe( + getSelfHostedPlanEntitlementSummary('relay'), + ); + expect(SELF_HOSTED_PLAN_BY_TIER.pro.entitlementSummary).toBe( + getSelfHostedPlanEntitlementSummary('pro'), + ); + expect(getSelfHostedPlanEntitlementSummary('pro', 'Legacy Pulse Pro+')).toBe( + 'Legacy Pulse Pro+ is active on this instance. Root-cause analysis, safe remediation workflows, 90-day history, and admin/reporting extras are available right now.', + ); + + expect(SELF_HOSTED_PLAN_BY_TIER.relay.entitlementHighlights).toEqual( + primaryPillarClaimLabels('relay', SELF_HOSTED_PLAN_BY_TIER.relay.metricHistoryDays), + ); + expect(SELF_HOSTED_PLAN_BY_TIER.pro.entitlementHighlights).toEqual( + primaryPillarClaimLabels('pro', SELF_HOSTED_PLAN_BY_TIER.pro.metricHistoryDays), + ); + expect(SELF_HOSTED_PLAN_BY_TIER.pro.includedExtras).toEqual( + getSelfHostedFeaturesForRole('pro', 'included_extra').map((entry) => entry.displayName), + ); + + const displayablePaidFeatures = SELF_HOSTED_FEATURE_CATALOG.filter( + (entry) => + entry.displayableInSelfHostedPlan && + !entry.includedIn.community && + (entry.includedIn.relay || entry.includedIn.pro), + ).map((entry) => entry.key); + expect(displayablePaidFeatures).toEqual([ + 'relay', + 'mobile_app', + 'push_notifications', + 'ai_alerts', + 'ai_autofix', + 'long_term_metrics', + 'advanced_sso', + 'rbac', + 'audit_logging', + 'advanced_reporting', + 'agent_profiles', + ]); + expect( + SELF_HOSTED_PLAN_DEFINITIONS.map((plan) => ({ + tier: plan.tier, + historyDays: plan.metricHistoryDays, + extras: plan.billingExtrasSummary, + })), + ).toEqual([ + { tier: 'community', historyDays: 7, extras: 'Patrol, alerts, and OIDC' }, + { + tier: 'relay', + historyDays: 14, + extras: 'Remote web access, mobile pairing, and push', + }, + { tier: 'pro', historyDays: 90, extras: 'Analysis, remediation, and admin controls' }, + ]); + expect(SELF_HOSTED_FEATURE_ROWS.find((row) => row.key === 'history')).toEqual({ + key: 'history', + name: 'Metric History', + community: '7 days', + relay: '14 days', + pro: '90 days', + }); + + const paidClaimText = [ + SELF_HOSTED_COMMERCIAL_PRESENTATION.pageDescription, + SELF_HOSTED_PLAN_BY_TIER.relay.entitlementSummary, + SELF_HOSTED_PLAN_BY_TIER.relay.comparisonSummary, + ...SELF_HOSTED_PLAN_BY_TIER.relay.entitlementHighlights, + ...SELF_HOSTED_PLAN_BY_TIER.relay.highlights, + SELF_HOSTED_PLAN_BY_TIER.pro.entitlementSummary, + SELF_HOSTED_PLAN_BY_TIER.pro.comparisonSummary, + ...SELF_HOSTED_PLAN_BY_TIER.pro.entitlementHighlights, + ...SELF_HOSTED_PLAN_BY_TIER.pro.includedExtras, + ...SELF_HOSTED_PLAN_BY_TIER.pro.highlights, + ].join('\n'); + + for (const hiddenEntry of SELF_HOSTED_FEATURE_CATALOG.filter( + (entry) => !entry.displayableInSelfHostedPlan, + )) { + expect(paidClaimText).not.toContain(hiddenEntry.displayName); + expect(paidClaimText).not.toContain(hiddenEntry.comparisonName); + } + expect(paidClaimText).not.toMatch( + /unlimited|monitoring capacity|guest capacity|hosted quickstart|trial/i, + ); + }); + it('maps current billing tiers back to the shared self-hosted plan definitions', () => { expect(getSelfHostedPlanDefinitionForBillingTier('free')).toBe( SELF_HOSTED_PLAN_BY_TIER.community, diff --git a/frontend-modern/src/utils/licensePresentation.ts b/frontend-modern/src/utils/licensePresentation.ts index 4a8b2cc2d3..b5d435b48e 100644 --- a/frontend-modern/src/utils/licensePresentation.ts +++ b/frontend-modern/src/utils/licensePresentation.ts @@ -14,7 +14,10 @@ import { type MonitoredSystemCapacityStatus, type MonitoredSystemLimitUsageStatus, } from '@/utils/monitoredSystemPresentation'; -import { getSelfHostedPlanDefinitionForBillingTier } from '@/utils/selfHostedPlans'; +import { + getSelfHostedPlanDefinitionForBillingTier, + getSelfHostedPlanEntitlementSummary, +} from '@/utils/selfHostedPlans'; import { getSelfHostedFeatureCatalogEntry, isDisplayableSelfHostedFeatureKey, @@ -417,16 +420,10 @@ const getSelfHostedActivePlanSummary = ( planLabel: string, planDefinition: ReturnType, ): string | null => { - switch (planDefinition?.tier) { - case 'community': - return `${planLabel} is active on this instance. It includes self-hosted monitoring, 7-day metric history, Pulse Patrol (BYOK), and update alerts.`; - case 'relay': - return `${planLabel} is active on this instance. Remote web access, mobile pairing, push notifications, and longer history are available right now.`; - case 'pro': - return `${planLabel} is active on this instance. Root-cause analysis, safe remediation workflows, 90-day history, and admin/reporting extras are available right now.`; - default: - return null; + if (!planDefinition) { + return null; } + return getSelfHostedPlanEntitlementSummary(planDefinition.tier, planLabel); }; export const getSelfHostedPlanComparisonPresentation = ({ diff --git a/frontend-modern/src/utils/selfHostedPlans.ts b/frontend-modern/src/utils/selfHostedPlans.ts index 934f27d46c..5b4c34e2ad 100644 --- a/frontend-modern/src/utils/selfHostedPlans.ts +++ b/frontend-modern/src/utils/selfHostedPlans.ts @@ -68,6 +68,26 @@ const getTierEntitlementHighlights = ( const getTierIncludedExtras = (tier: SelfHostedTierKey): readonly string[] => getSelfHostedFeaturesForRole(tier, 'included_extra').map((entry) => entry.displayName); +const SELF_HOSTED_PLAN_DEFAULT_ENTITLEMENT_LABELS: Record = { + community: 'Community', + relay: 'Relay', + pro: 'Pulse Pro', +}; + +export function getSelfHostedPlanEntitlementSummary( + tier: SelfHostedTierKey, + planLabel = SELF_HOSTED_PLAN_DEFAULT_ENTITLEMENT_LABELS[tier], +): string { + switch (tier) { + case 'community': + return `${planLabel} is active on this instance. It includes self-hosted monitoring, 7-day metric history, Pulse Patrol (BYOK), and update alerts.`; + case 'relay': + return `${planLabel} is active on this instance. Remote web access, mobile pairing, push notifications, and longer history are available right now.`; + case 'pro': + return `${planLabel} is active on this instance. Root-cause analysis, safe remediation workflows, 90-day history, and admin/reporting extras are available right now.`; + } +} + const buildSelfHostedComparisonFeatureRows = (): readonly SelfHostedFeatureRow[] => SELF_HOSTED_FEATURE_CATALOG.filter((entry) => entry.showInComparisonTable).map((entry) => ({ key: entry.key, @@ -85,8 +105,7 @@ export const SELF_HOSTED_PLAN_DEFINITIONS: readonly SelfHostedPlanDefinition[] = subline: 'Core monitoring included', metricHistoryDays: 7, billingExtrasSummary: 'Patrol, alerts, and OIDC', - entitlementSummary: - 'Community is active on this instance. It includes self-hosted monitoring, 7-day metric history, Pulse Patrol (BYOK), and update alerts.', + entitlementSummary: getSelfHostedPlanEntitlementSummary('community'), entitlementHighlights: [ 'Real-time monitoring', '7-day metric history', @@ -112,8 +131,7 @@ export const SELF_HOSTED_PLAN_DEFINITIONS: readonly SelfHostedPlanDefinition[] = subline: 'or $4.99/month', metricHistoryDays: 14, billingExtrasSummary: 'Remote web access, mobile pairing, and push', - entitlementSummary: - 'Relay is active on this instance. Remote web access, mobile pairing, push notifications, and longer history are available right now.', + entitlementSummary: getSelfHostedPlanEntitlementSummary('relay'), entitlementHighlights: getTierEntitlementHighlights('relay', 14), includedExtras: [], comparisonSummary: @@ -134,8 +152,7 @@ export const SELF_HOSTED_PLAN_DEFINITIONS: readonly SelfHostedPlanDefinition[] = subline: 'or $8.99/month', metricHistoryDays: 90, billingExtrasSummary: 'Analysis, remediation, and admin controls', - entitlementSummary: - 'Pulse Pro is active on this instance. Root-cause analysis, safe remediation workflows, 90-day history, and admin/reporting extras are available right now.', + entitlementSummary: getSelfHostedPlanEntitlementSummary('pro'), entitlementHighlights: getTierEntitlementHighlights('pro', 90), includedExtras: getTierIncludedExtras('pro'), comparisonSummary: diff --git a/internal/api/router_version_tenant_metrics_test.go b/internal/api/router_version_tenant_metrics_test.go index f9aa0c4b72..f495b05613 100644 --- a/internal/api/router_version_tenant_metrics_test.go +++ b/internal/api/router_version_tenant_metrics_test.go @@ -179,10 +179,13 @@ func TestHandleMetricsHistory_TierAwareHistoryRanges(t *testing.T) { range_ string want int }{ + {name: "community allows free seven day history", range_: "7d", want: http.StatusOK}, {name: "community blocks relay history", range_: "14d", want: http.StatusPaymentRequired}, {name: "relay allows 14 days", tier: pkglicensing.TierRelay, range_: "14d", want: http.StatusOK}, + {name: "relay allows equivalent hour range", tier: pkglicensing.TierRelay, range_: "336h", want: http.StatusOK}, {name: "relay blocks longer day range", tier: pkglicensing.TierRelay, range_: "15d", want: http.StatusPaymentRequired}, {name: "pro allows 90 days", tier: pkglicensing.TierPro, range_: "90d", want: http.StatusOK}, + {name: "pro blocks longer than 90 days", tier: pkglicensing.TierPro, range_: "91d", want: http.StatusPaymentRequired}, } for _, tt := range tests { diff --git a/pkg/licensing/features_test.go b/pkg/licensing/features_test.go index c8c861d492..098f9ec973 100644 --- a/pkg/licensing/features_test.go +++ b/pkg/licensing/features_test.go @@ -127,6 +127,129 @@ func TestDeriveCapabilitiesFromTier_Sorted(t *testing.T) { } } +func TestSelfHostedPaidFeatureClaimMatrix(t *testing.T) { + tests := []struct { + name string + tier Tier + wantHistoryDays int + wantCoreMonitoringLimit int + wantIncludedCapabilities []string + wantExcludedCapabilities []string + }{ + { + name: "community keeps core monitoring free and does not claim paid extras", + tier: TierFree, + wantHistoryDays: 7, + wantCoreMonitoringLimit: 0, + wantIncludedCapabilities: []string{ + FeatureUpdateAlerts, + FeatureSSO, + FeatureAIPatrol, + }, + wantExcludedCapabilities: []string{ + FeatureRelay, + FeatureMobileApp, + FeaturePushNotifications, + FeatureLongTermMetrics, + FeatureAIAlerts, + FeatureAIAutoFix, + FeatureRBAC, + FeatureAdvancedReporting, + }, + }, + { + name: "relay sells remote access mobile handoff push and fourteen day history", + tier: TierRelay, + wantHistoryDays: 14, + wantCoreMonitoringLimit: 0, + wantIncludedCapabilities: []string{ + FeatureUpdateAlerts, + FeatureSSO, + FeatureAIPatrol, + FeatureRelay, + FeatureMobileApp, + FeaturePushNotifications, + FeatureLongTermMetrics, + }, + wantExcludedCapabilities: []string{ + FeatureAIAlerts, + FeatureAIAutoFix, + FeatureRBAC, + FeatureAdvancedReporting, + FeatureAgentProfiles, + }, + }, + { + name: "pro sells operator extras and preserves relay capabilities", + tier: TierPro, + wantHistoryDays: 90, + wantCoreMonitoringLimit: 0, + wantIncludedCapabilities: []string{ + FeatureRelay, + FeatureMobileApp, + FeaturePushNotifications, + FeatureLongTermMetrics, + FeatureAIAlerts, + FeatureAIAutoFix, + FeatureRBAC, + FeatureAuditLogging, + FeatureAdvancedReporting, + FeatureAgentProfiles, + }, + wantExcludedCapabilities: []string{ + FeatureMultiTenant, + FeatureUnlimited, + }, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + gotHistoryDays, ok := TierHistoryDays[tt.tier] + if !ok { + t.Fatalf("TierHistoryDays missing entry for tier %q", tt.tier) + } + if gotHistoryDays != tt.wantHistoryDays { + t.Fatalf( + "TierHistoryDays[%q] = %d, want %d", + tt.tier, + gotHistoryDays, + tt.wantHistoryDays, + ) + } + + gotMonitoringLimit, ok := TierMonitoredSystemLimits[tt.tier] + if !ok { + t.Fatalf("TierMonitoredSystemLimits missing entry for tier %q", tt.tier) + } + if gotMonitoringLimit != tt.wantCoreMonitoringLimit { + t.Fatalf( + "TierMonitoredSystemLimits[%q] = %d, want %d", + tt.tier, + gotMonitoringLimit, + tt.wantCoreMonitoringLimit, + ) + } + + capabilities := make(map[string]struct{}) + for _, capability := range TierFeatures[tt.tier] { + capabilities[capability] = struct{}{} + } + for _, capability := range tt.wantIncludedCapabilities { + if _, ok := capabilities[capability]; !ok { + t.Fatalf("tier %q missing paid-feature claim capability %q", tt.tier, capability) + } + } + for _, capability := range tt.wantExcludedCapabilities { + if _, ok := capabilities[capability]; ok { + t.Fatalf("tier %q unexpectedly includes capability %q", tt.tier, capability) + } + } + }) + } +} + func TestDeriveEntitlements(t *testing.T) { caps, limits := DeriveEntitlements(TierPro, []string{"custom"}, 50, 100) diff --git a/scripts/release_control/internal/paid_feature_claims_proof.py b/scripts/release_control/internal/paid_feature_claims_proof.py new file mode 100644 index 0000000000..10fa32f43d --- /dev/null +++ b/scripts/release_control/internal/paid_feature_claims_proof.py @@ -0,0 +1,214 @@ +#!/usr/bin/env python3 +"""Run the automated proof bundle for self-hosted paid feature claims.""" + +from __future__ import annotations + +import argparse +import json +import subprocess +from dataclasses import asdict, dataclass +from pathlib import Path + + +@dataclass +class CommandSpec: + name: str + cwd: str + command: list[str] + + +@dataclass +class CommandResult: + name: str + cwd: str + command: list[str] + ok: bool + exit_code: int + detail: str + + +def default_pulse_dir() -> Path: + return Path(__file__).resolve().parents[3] + + +def default_pulse_pro_license_server_dir() -> Path: + return default_pulse_dir().parent / "pulse-pro" / "license-server" + + +def default_pulse_pro_relay_server_dir() -> Path: + return default_pulse_dir().parent / "pulse-pro" / "relay-server" + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Run the automated proof bundle for v6 paid feature claims." + ) + parser.add_argument("--pulse-dir", default=str(default_pulse_dir())) + parser.add_argument("--frontend-dir", help="Optional override for the pulse frontend-modern directory") + parser.add_argument( + "--pulse-pro-license-server-dir", + default=str(default_pulse_pro_license_server_dir()), + ) + parser.add_argument( + "--pulse-pro-relay-server-dir", + default=str(default_pulse_pro_relay_server_dir()), + ) + parser.add_argument("--json", action="store_true", help="Emit JSON instead of human-readable output") + parser.add_argument("--report-out", help="Optional path to write a markdown report") + parser.add_argument( + "--report-title", + default="Paid Feature Claim Automated Proof", + help="Markdown title used when writing --report-out", + ) + return parser.parse_args(argv) + + +def frontend_dir_from_args(args: argparse.Namespace) -> Path: + if args.frontend_dir: + return Path(args.frontend_dir).resolve() + return Path(args.pulse_dir).resolve() / "frontend-modern" + + +def build_command_specs(args: argparse.Namespace) -> list[CommandSpec]: + pulse_dir = Path(args.pulse_dir).resolve() + frontend_dir = frontend_dir_from_args(args) + pulse_pro_license_server_dir = Path(args.pulse_pro_license_server_dir).resolve() + pulse_pro_relay_server_dir = Path(args.pulse_pro_relay_server_dir).resolve() + return [ + CommandSpec( + name="pulse-licensing-paid-feature-contract", + cwd=str(pulse_dir), + command=[ + "go", + "test", + "./pkg/licensing", + "-run", + "^(TestSelfHostedPaidFeatureClaimMatrix|TestTierMonitoredSystemLimits|TestTierHistoryDays|TestTierFeatureInheritance|TestBuildEntitlementPayload_MaxHistoryDays)$", + "-count=1", + ], + ), + CommandSpec( + name="pulse-api-history-entitlement-enforcement", + cwd=str(pulse_dir), + command=[ + "go", + "test", + "./internal/api", + "-run", + "^TestHandleMetricsHistory_TierAwareHistoryRanges$", + "-count=1", + ], + ), + CommandSpec( + name="pulse-frontend-paid-claim-contract", + cwd=str(frontend_dir), + command=[ + "npm", + "test", + "--", + "src/utils/__tests__/selfHostedPlans.test.ts", + "src/utils/__tests__/commercialBillingModel.test.ts", + "src/utils/__tests__/licensePresentation.test.ts", + "src/stores/__tests__/license.test.ts", + "src/components/shared/__tests__/HistoryChart.test.tsx", + "src/components/Settings/__tests__/ProLicensePanel.test.tsx", + "src/components/Settings/__tests__/RelaySettingsPanel.runtime.test.tsx", + ], + ), + CommandSpec( + name="pulse-pro-public-pricing-and-checkout-contract", + cwd=str(pulse_pro_license_server_dir), + command=[ + "go", + "test", + ".", + "-run", + "^(TestHandlePublicPricingModelV6|TestCanonicalTierEntitlementContracts_DoNotMonetizeMonitoringVolume|TestV6PublicProPricingPreservesPaidCapabilityFloor|TestHandleCheckoutSessionCreate|TestResolvePublicCheckoutPlan_LockedSelfHostedTiers|TestHandlePulseProDownloadRequiresAuth|TestHandlePulseProDownloadActivationKeyReturnsSignedURLs|TestHandlePulseProDownloadRejectsFreeLicense)$", + "-count=1", + ], + ), + CommandSpec( + name="pulse-pro-relay-entitlement-runtime", + cwd=str(pulse_pro_relay_server_dir), + command=[ + "go", + "test", + ".", + "-run", + "^(TestValidateLicense_AllProTiers|TestValidateLicense_FreeTierRejected|TestHostedEntitlementValidator_RejectsMissingRelayCapability|TestBridge_V6GrantRegister|TestBridge_V6GrantRejected_CancelledState|TestBridge_V6Grant_FullDataRouting|TestBridge_PushNotificationHandled)$", + "-count=1", + ], + ), + ] + + +def summarize_output(stdout: str, stderr: str) -> str: + stdout_lines = [line.strip() for line in stdout.splitlines() if line.strip()] + stderr_lines = [line.strip() for line in stderr.splitlines() if line.strip()] + for line in reversed(stdout_lines): + if line.startswith("ok ") or line.startswith("PASS ") or " passed " in line: + return line[:237] + "..." if len(line) > 240 else line + lines = stdout_lines or stderr_lines + if not lines: + return "pass" + summary = lines[-1] + if len(summary) > 240: + return summary[:237] + "..." + return summary + + +def run_command(spec: CommandSpec) -> CommandResult: + proc = subprocess.run(spec.command, cwd=spec.cwd, capture_output=True, text=True, check=False) + return CommandResult( + name=spec.name, + cwd=spec.cwd, + command=spec.command, + ok=proc.returncode == 0, + exit_code=proc.returncode, + detail=summarize_output(proc.stdout, proc.stderr), + ) + + +def run_proof(args: argparse.Namespace) -> list[CommandResult]: + return [run_command(spec) for spec in build_command_specs(args)] + + +def render_markdown_report(title: str, results: list[CommandResult]) -> str: + lines = [ + f"# {title}", + "", + "## Scope", + "", + "This proof checks that self-hosted paid claims map to concrete entitlement, API, UI, license-server, and relay-server behavior.", + "", + "## Results", + "", + ] + for result in results: + status = "PASS" if result.ok else "FAIL" + lines.append(f"- `{status}` `{result.name}`") + lines.append(f" - cwd: `{result.cwd}`") + lines.append(f" - command: `{' '.join(result.command)}`") + lines.append(f" - detail: {result.detail}") + return "\n".join(lines) + "\n" + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv) + results = run_proof(args) + if args.report_out: + Path(args.report_out).write_text( + render_markdown_report(args.report_title, results), + encoding="utf-8", + ) + if args.json: + print(json.dumps([asdict(result) for result in results], indent=2)) + else: + for result in results: + status = "PASS" if result.ok else "FAIL" + print(f"{status} {result.name}: {result.detail}") + return 0 if all(result.ok for result in results) else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/release_control/internal/paid_feature_claims_proof_test.py b/scripts/release_control/internal/paid_feature_claims_proof_test.py new file mode 100644 index 0000000000..9dab9934bd --- /dev/null +++ b/scripts/release_control/internal/paid_feature_claims_proof_test.py @@ -0,0 +1,136 @@ +from __future__ import annotations + +import contextlib +import io +import json +import sys +import tempfile +import unittest +from pathlib import Path + + +INTERNAL_DIR = Path(__file__).resolve().parent +if str(INTERNAL_DIR) not in sys.path: + sys.path.insert(0, str(INTERNAL_DIR)) + +import paid_feature_claims_proof as proof + + +def run_main(argv: list[str]) -> tuple[int, str]: + buffer = io.StringIO() + with contextlib.redirect_stdout(buffer): + exit_code = proof.main(argv) + return exit_code, buffer.getvalue() + + +class PaidFeatureClaimsProofTest(unittest.TestCase): + def test_default_paths_resolve_from_internal_script_location(self) -> None: + self.assertEqual(proof.default_pulse_dir(), Path(proof.__file__).resolve().parents[3]) + self.assertEqual( + proof.default_pulse_pro_license_server_dir(), + proof.default_pulse_dir().parent / "pulse-pro" / "license-server", + ) + self.assertEqual( + proof.default_pulse_pro_relay_server_dir(), + proof.default_pulse_dir().parent / "pulse-pro" / "relay-server", + ) + + def test_build_command_specs_cover_paid_claim_layers(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + pulse_dir = Path(tmp) / "pulse" + pulse_dir.mkdir() + frontend_dir = pulse_dir / "frontend-modern" + frontend_dir.mkdir() + pulse_pro_license_server_dir = Path(tmp) / "pulse-pro" / "license-server" + pulse_pro_license_server_dir.mkdir(parents=True) + pulse_pro_relay_server_dir = Path(tmp) / "pulse-pro" / "relay-server" + pulse_pro_relay_server_dir.mkdir(parents=True) + + args = proof.parse_args( + [ + "--pulse-dir", + str(pulse_dir), + "--pulse-pro-license-server-dir", + str(pulse_pro_license_server_dir), + "--pulse-pro-relay-server-dir", + str(pulse_pro_relay_server_dir), + ] + ) + specs = proof.build_command_specs(args) + + self.assertEqual(len(specs), 5) + self.assertEqual(specs[0].cwd, str(pulse_dir.resolve())) + self.assertEqual(specs[2].cwd, str(frontend_dir.resolve())) + self.assertEqual(specs[3].cwd, str(pulse_pro_license_server_dir.resolve())) + self.assertEqual(specs[4].cwd, str(pulse_pro_relay_server_dir.resolve())) + names = {spec.name for spec in specs} + self.assertIn("pulse-licensing-paid-feature-contract", names) + self.assertIn("pulse-api-history-entitlement-enforcement", names) + self.assertIn("pulse-frontend-paid-claim-contract", names) + self.assertIn("pulse-pro-public-pricing-and-checkout-contract", names) + self.assertIn("pulse-pro-relay-entitlement-runtime", names) + + def test_run_command_success_and_failure(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + ok = proof.run_command( + proof.CommandSpec( + name="ok", + cwd=tmp, + command=["python3", "-c", "print('paid proof ok')"], + ) + ) + self.assertTrue(ok.ok) + self.assertEqual(ok.detail, "paid proof ok") + + failed = proof.run_command( + proof.CommandSpec( + name="fail", + cwd=tmp, + command=["python3", "-c", "import sys; sys.stderr.write('boom\\n'); sys.exit(3)"], + ) + ) + self.assertFalse(failed.ok) + self.assertEqual(failed.exit_code, 3) + self.assertEqual(failed.detail, "boom") + + def test_summarize_output_prefers_success_summary_over_expected_error_logs(self) -> None: + stdout = """ + stderr | expected store fallback test + Error: API error + + Test Files 7 passed (7) + Tests 116 passed (116) + """ + stderr = """ + at runSuite (node_modules/@vitest/runner/dist/chunk-hooks.js:1729:8) + """ + + self.assertEqual(proof.summarize_output(stdout, stderr), "Tests 116 passed (116)") + + def test_main_json_and_report(self) -> None: + original_run_proof = proof.run_proof + try: + proof.run_proof = lambda _args: [ # type: ignore[assignment] + proof.CommandResult( + name="fake", + cwd="/tmp", + command=["echo", "fake"], + ok=True, + exit_code=0, + detail="pass", + ) + ] + with tempfile.TemporaryDirectory() as tmp: + report_path = Path(tmp) / "report.md" + exit_code, output = run_main(["--report-out", str(report_path), "--json"]) + self.assertEqual(exit_code, 0) + self.assertEqual(json.loads(output)[0]["name"], "fake") + report = report_path.read_text(encoding="utf-8") + self.assertIn("Paid Feature Claim Automated Proof", report) + self.assertIn("self-hosted paid claims", report) + finally: + proof.run_proof = original_run_proof # type: ignore[assignment] + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/release_control/paid_feature_claims_proof.py b/scripts/release_control/paid_feature_claims_proof.py new file mode 100644 index 0000000000..1dc9aa246d --- /dev/null +++ b/scripts/release_control/paid_feature_claims_proof.py @@ -0,0 +1,17 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +from pathlib import Path +import runpy + + +def main(argv: list[str] | None = None) -> int: + namespace = runpy.run_path( + str(Path(__file__).resolve().parent / "internal" / "paid_feature_claims_proof.py") + ) + return namespace["main"](argv) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/release_control/proof_entrypoints_test.py b/scripts/release_control/proof_entrypoints_test.py index 77c1a730a4..978f138010 100644 --- a/scripts/release_control/proof_entrypoints_test.py +++ b/scripts/release_control/proof_entrypoints_test.py @@ -13,6 +13,7 @@ class ProofEntrypointsTest(unittest.TestCase): entrypoints = [ "commercial_cancellation_reactivation_proof.py", "commercial_cancellation_reactivation_rehearsal.py", + "paid_feature_claims_proof.py", "mobile_relay_auth_approvals_proof.py", "relay_registration_reconnect_drain_proof.py", ]