Keep self-hosted commercial copy opt-in

This commit is contained in:
rcourtman 2026-04-25 15:26:11 +01:00
parent 48e3db142e
commit 0ee95a6a42
49 changed files with 223 additions and 171 deletions

View file

@ -182,8 +182,8 @@ Numeric intervals are **seconds** unless noted otherwise.
| `metricsRetentionHourlyDays` | Hourly metrics retention (days) |
| `metricsRetentionDailyDays` | Daily metrics retention (days) |
| `disableDockerUpdateActions` | Hide Docker update actions in UI |
| `reduceProUpsellNoise` | Reduce proactive Pro prompts (paywalls still appear when accessing gated features) |
| `disableLocalUpgradeMetrics` | Disable local-only upgrade event collection |
| `reduceProUpsellNoise` | Legacy compatibility preference for proactive commercial prompts. Default self-hosted v6 sessions already hide paid prompts unless the user enters an explicit commercial handoff or has an existing entitlement. |
| `disableLocalUpgradeMetrics` | Disable local-only commercial handoff event collection |
| `backendPort` | Legacy (unused) |
| `frontendPort` | Legacy (ignored; use `FRONTEND_PORT`) |
@ -219,7 +219,7 @@ Environment variables take precedence over `system.json`.
| Variable | Description | Default |
| ---------- | ------------- | --------- |
| `PULSE_PUBLIC_URL` | URL for UI links, notifications, and OIDC. For reverse proxies, keep this as the public URL and use `PULSE_AGENT_CONNECT_URL` for agent installs if you need a direct/internal address. | Auto-detected |
| `PULSE_PRO_TRIAL_SIGNUP_URL` | Hosted signup/checkout URL used when users click **Start Free Pro Trial**. Must be absolute `http(s)` URL. | `https://cloud.pulserelay.pro/start-pro-trial?...` |
| `PULSE_PRO_TRIAL_SIGNUP_URL` | Support-only hosted trial signup URL used by explicit commercial handoff flows. Normal self-hosted v6 UI does not surface this path. Must be absolute `http(s)` URL. | `https://cloud.pulserelay.pro/start-pro-trial?...` |
| `PULSE_AGENT_CONNECT_URL` | Dedicated direct URL for agents (overrides `PULSE_PUBLIC_URL` for agent install commands). Alias: `PULSE_AGENT_URL`. | *(unset)* |
| `PULSE_AGENT_CONFIG_SIGNING_KEY` | Base64 Ed25519 private key used to sign remote agent config payloads. | *(unset)* |
| `PULSE_AGENT_CONFIG_PUBLIC_KEYS` | Comma-separated base64 Ed25519 public keys (raw 32-byte or PKIX-encoded) trusted by agents. | *(unset)* |

View file

@ -7,9 +7,9 @@ Pulse is designed to run locally. By default, your monitoring data stays on your
Pulse currently has two usage-data scopes:
1. **Anonymous outbound telemetry** to help me understand active installations, release uptake, and feature use in aggregate.
2. **Local-only upgrade events** to help debug and improve upgrade flows on the instance where they were recorded.
2. **Local-only commercial handoff events** to help debug explicit paid-plan and activation handoffs on the instance where they were recorded.
These are separate scopes on purpose. The outbound telemetry path stays coarse and anonymous. The local-only upgrade-event path stays on the Pulse instance and is not exported to third parties.
These are separate scopes on purpose. The outbound telemetry path stays coarse and anonymous. The local-only commercial-event path stays on the Pulse instance and is not exported to third parties.
### Anonymous outbound telemetry
@ -95,17 +95,17 @@ The telemetry implementation is in [`internal/telemetry/telemetry.go`](../intern
Pulse can make outbound connections when you enable specific features:
- **AI (BYOK)**: when AI features are enabled, Pulse sends only the context required for your request to the provider you configured (OpenAI, Anthropic, etc.). See `docs/AI.md`.
- **AI (BYOK)**: when AI features are configured with your own API key, Pulse sends only the context required for your request directly to the provider you chose (OpenAI, Anthropic, etc.). Requests do not transit Pulse infrastructure. See `docs/AI.md`.
- **AI (Quickstart)**: when the Pulse-hosted Quickstart provider is selected, your chat request — including messages, system prompt, and tool definitions — is forwarded through `license.pulserelay.pro/v1/quickstart/patrol` to the underlying model provider. This path exists so eligible quickstart users can try AI without their own API key; it means requests transit Pulse infrastructure. To keep prompts off Pulse infrastructure, use a BYOK provider instead.
- **Relay / Remote Access**: when relay is enabled, Pulse connects to the configured relay endpoint to enable mobile access. See Settings → Remote Access.
- **Update checks**: Pulse can check for new releases/updates (for example via GitHub release metadata) depending on your deployment and configuration.
### Local-only upgrade events
### Local-only commercial handoff events
Pulse can record local-only usage events such as "paywall viewed" or "trial started" to improve and debug in-app upgrade flows.
Pulse can record local-only usage events such as plan-link views or activation handoff attempts to improve and debug voluntary paid flows.
- These events are stored locally and are not exported to third parties.
- Disable via **Settings → System → General → Disable local-only upgrade events** or set:
- Disable via **Settings → System → General → Disable local-only commercial events** or set:
- `PULSE_DISABLE_LOCAL_UPGRADE_METRICS=true`
If you prefer fewer upgrade prompts, you can also enable:
- **Settings → System → General → Reduce Pro prompts**
The legacy `reduceProUpsellNoise` preference remains for older proactive prompt behavior, but default self-hosted v6 sessions already hide paid prompts unless the user enters an explicit commercial handoff or has an existing entitlement.

View file

@ -302,25 +302,34 @@ must apply manually. Pro users see the same information with an "Apply Fix" butt
Patrol finding is a conversion moment.
### 3. AI Quickstart Credits (25 hosted Patrol runs)
Activated or trial-backed installs get 25 Patrol runs powered by MiniMax 2.5M with no API
key setup. This gives users one bounded Patrol-first activation path. Unactivated Community
installs activate, start a trial, or connect their own key (BYOK, stays free). Upgrading
later unlocks auto-fix, alert analysis, and other paid operations features; it does not
replace BYOK for self-hosted AI runtime. Cost: ~$0.0020.01 per run.
Eligible activated installs get 25 Patrol runs powered by MiniMax 2.5M with no API key setup.
This gives support and commercial handoff flows one bounded Patrol-first activation path.
Ordinary self-hosted Community installs should connect their own key (BYOK, stays free) and
must not be pushed into a trial just to use Patrol. Paid operations features such as auto-fix,
alert analysis, and longer history remain opt-in extras; they do not replace BYOK for
self-hosted AI runtime. Cost: ~$0.002-0.01 per run.
### 4. Relay as impulse buy ($39/yr)
Fills the $0 → Pro gap. Relay is not the automation tier and it is not a capacity tier.
### 4. Relay as optional convenience ($39/yr)
Gives remote-access and mobile users a small paid convenience option without turning core
self-hosted monitoring into a capacity tier.
Remote access + mobile + push notifications + custom URL + 14-day history should make it
an easy purchase for users who want Pulse available outside their LAN.
### 5. Contextual trial triggers
14-day Pro trial offered at moments of maximum desire:
- Patrol finds a fixable issue → "Apply this fix automatically? Start your free trial"
- An alert needs deeper explanation → "Let Pulse investigate this alert? Start your free trial"
- User taps 30-day chart range → "See your full history — start your free trial"
- User tries Relay from free tier → "Monitor from anywhere — upgrade to Relay ($39/yr) or
start a Pro trial"
- 7+ days of active use → proactive "Experience the full power" nudge
### 5. Opt-in commercial discovery
Default self-hosted v6 sessions must not present ordinary users with trial, paywall, or
proactive paid-service prompts. Commercial discovery is allowed only when the user explicitly
opens pricing/activation/recovery/support flows, when the session is hosted/cloud, or when an
existing entitlement requires a clear renewal or recovery path.
High-intent product moments should stay useful without becoming sales surfaces:
- Patrol finds a fixable issue -> show the manual fix and BYOK/provider path first; auto-fix can
remain an opt-in Pro capability where commercial prompts are explicitly allowed.
- An alert needs deeper explanation -> keep free investigation context useful; paid alert
analysis stays an optional, discoverable extra.
- User taps a longer chart range -> explain the local retention state without implying a
monitoring capacity limit.
- User wants remote access or mobile pairing -> point to Relay only inside the explicit Relay
setup/commercial handoff, not as a global nudge.
### 6. No self-hosted monitored-system overflow gate
Self-hosted Community users should not need a temporary monitored-system overflow path because
@ -333,9 +342,10 @@ show exactly which top-level systems Pulse sees, which collection path is being
is included under each system. On self-hosted Community/Relay/Pro, it must not create a false
"X/Y systems" paywall or imply that users need to buy more monitoring room.
### 8. Upsell snooze (7-day, not permanent)
Users can snooze upgrade prompts for 7 days. No permanent mute option. Power users who
use the product heavily are highest-potential converters — don't let them opt out forever.
### 8. No default upsell cadence
There is no proactive self-hosted upsell cadence in v6 GA. If older compatibility settings
mention prompt reduction, treat them as legacy controls; the v6 default is already quiet unless
the user enters an explicit commercial path or has an entitlement state that needs attention.
### 9. Cloud launches with v6
Not behind a waitlist. Real pricing, real signup. Captures convenience buyers who don't
@ -392,7 +402,7 @@ want to self-host.
Unlimited Unlimited Unlimited
monitoring monitoring monitoring
[Get Started] [Buy Relay] [Start Trial]
[Get Started] [Buy Relay] [Choose Pro]
```
Below the table:
@ -499,7 +509,7 @@ explain monitored-system identity:
- [x] Remove self-hosted monitored-system cap pressure from billing and pricing surfaces
- [x] Present the public self-hosted ladder as Community / Relay / Pro
- [ ] Keep ledger and inventory language focused on what Pulse monitors, not paid capacity pressure
- [ ] Keep upgrade prompts focused on Relay convenience, Pro investigation, Pro auto-fix, and history
- [ ] Keep paid prompts out of ordinary self-hosted runtime surfaces; commercial copy belongs in explicit pricing, activation, recovery, hosted, or entitlement-aware paths
### License server / checkout / landing pages (`pulse-pro`)
@ -518,11 +528,10 @@ explain monitored-system identity:
## Review Checkpoints
### 30-day post-launch review
- Free → Relay conversion rate
- Free → Pro trial start rate
- Trial → paid conversion rate
- Free → Relay opt-in purchase rate
- Free → Pro opt-in purchase rate
- Relay → Pro upgrade rate
- Which paywall surfaces fire most (Relay vs alert investigation vs auto-fix vs history)
- Which explicit commercial handoffs are used most (pricing, activation, recovery, hosted)
- Support load per tier
### 60-day post-launch review

View file

@ -46,17 +46,17 @@ Companion drill:
`cd frontend-modern && npx vitest run src/pages/__tests__/HostedSignup.test.tsx src/components/Settings/__tests__/BillingAdminPanel.test.tsx`
`cd tests/integration && PULSE_E2E_USE_LOCAL_BACKEND=1 PULSE_E2E_SKIP_PLAYWRIGHT_INSTALL=1 npm test -- tests/07-trial-signup-return.spec.ts --project=chromium`
`cd tests/integration && PULSE_E2E_USE_LOCAL_BACKEND=1 PULSE_E2E_SKIP_PLAYWRIGHT_INSTALL=1 npm test -- tests/58-self-hosted-trial-rate-limit-ui.spec.ts --project=chromium`
The self-hosted handoff proof must keep the canonical trial-start transport
contract: `POST /api/license/trial/start` returns `409
trial_signup_required` while the hosted-signup retry burst remains open,
then transitions to `429 trial_rate_limited` plus `Retry-After` backoff once
the limiter actually engages. The Pulse Pro browser CTA proof on
`/settings/system/billing` must surface that same canonical `Retry-After`
backoff even when `details.retry_after_seconds` disagrees.
The self-hosted support/explicit-handoff proof must keep the canonical
trial-start transport contract: `POST /api/license/trial/start` returns `409
trial_signup_required` while the hosted-signup retry burst remains open, then
transitions to `429 trial_rate_limited` plus `Retry-After` backoff once the
limiter actually engages. Ordinary self-hosted runtime surfaces must still
suppress trial prompts unless an explicit commercial handoff or entitlement
state allows them.
- Live rehearsal helper:
`python3 scripts/release_control/hosted_signup_billing_replay_rehearsal.py --base-url <hosted-url> --signup-email <email> --org-name <org> ...`
- Manual scenario:
1. Start a hosted signup from the self-hosted trial/upgrade path.
1. Start a hosted signup from an explicit self-hosted support/commercial handoff path.
2. Confirm a missing hosted public URL fails closed before any org or RBAC
tenant is created.
3. Confirm the user is sent to hosted checkout instead of receiving a local
@ -201,10 +201,11 @@ Companion drill:
- Why this is risky:
A technically promotable build is still a broken GA release if the
self-hosted commercial path reads like a bridge product. Public landing copy,
in-app upgrade and trial prompts, checkout, Pulse Account/license-management,
and GA-facing guidance all need to describe the same Community / Relay / Pro
offer without preview-only, legacy-v5, or contradictory framing.
self-hosted commercial path reads like a bridge product or puts paid prompts in
front of ordinary self-hosted users. Public landing copy, explicit in-app
commercial handoffs, checkout, Pulse Account/license-management, and GA-facing
guidance all need to describe the same Community / Relay / Pro offer without
preview-only, legacy-v5, or contradictory framing.
- Primary runtime surfaces:
`pulse-pro/landing-page/index.html`
`pulse-pro/landing-page/README.md`
@ -223,19 +224,19 @@ Companion drill:
2. Confirm the public landing surface presents the actual v6 self-hosted
Community / Relay / Pro ladder and product promise rather than a live v5
bridge with preview-only v6 packaging.
3. Follow a real self-hosted upgrade or trial entry point from the app and
3. Follow a real explicit self-hosted commercial handoff from the app and
confirm the wording, plan identity, and customer expectation match the
public site.
public site, while ordinary runtime pages stay free-first and quiet.
4. Continue through the real checkout and Pulse Account/license-management
path and confirm it stays on the same product story instead of switching
back to legacy Pulse Pro framing or ambiguous plan rules.
5. Review the GA-facing guidance/release communication intended to ship with
promotion and confirm it matches the same package and customer path.
- Pass when:
A new self-hosted customer can move from public understanding to upgrade or
trial entry, checkout/account management, and GA-facing guidance without
encountering contradictory plan copy, preview-only posture, or legacy-v5
bridge messaging.
A new self-hosted customer can move from public understanding to an explicit
paid-plan handoff, checkout/account management, and GA-facing guidance without
encountering proactive in-app trial pressure, contradictory plan copy,
preview-only posture, or legacy-v5 bridge messaging.
- Latest exercised record:
`docs/release-control/v6/internal/records/self-hosted-commercial-ga-coherence-2026-04-20.md`
- Block release if:

View file

@ -423,7 +423,9 @@ Assertion design rules:
returning `409 trial_signup_required` while the hosted-signup retry burst
remains open, then `429 trial_rate_limited` plus `Retry-After` backoff once
that limiter engages; the local runtime may redeem signed trial activations
but must not mint local trial state directly.
but must not mint local trial state directly. This transport is support-only
or explicit-handoff plumbing for self-hosted v6 GA; ordinary self-hosted
runtime surfaces must not promote trial entry.
4. v5 to v6 commercial migration must preserve unresolved paid-license state
and downgrade safety.
5. Cloud and MSP Stripe `price_*` IDs are operational fill-in items, not

View file

@ -8,24 +8,21 @@
1. The latest shipped Pulse v6 prerelease tag is `v6.0.0-rc.2`.
2. That shipped prerelease tag resolves to commit `7b0bdebdff8127228708e84eb836cfd3d7214c08`.
3. The selected remote ref `origin/pulse/v6-release` is still behind the current
local governed branch state, so `Release Dry Run` would exercise stale remote
control-plane metadata instead of the intended candidate.
4. The governed release profile in `docs/release-control/control_plane.json`
3. The governed release profile in `docs/release-control/control_plane.json`
currently declares both `prerelease_branch` and `stable_branch` as
`pulse/v6-release`.
5. The active control-plane target is still `v6-product-lane-expansion`, not
4. The active control-plane target is still `v6-product-lane-expansion`, not
`v6-ga-promotion`.
6. The active local `pulse/v6-release` branch currently reports `VERSION=6.0.0`, so a
5. The active local `pulse/v6-release` branch currently reports `VERSION=6.0.0`, so a
local GA candidate exists on the governed stable line.
7. There is still no governed `Prerelease-to-GA Rehearsal Record` proving a successful
6. There is still no governed `Prerelease-to-GA Rehearsal Record` proving a successful
non-publish `Release Dry Run` for the current `6.0.0` candidate.
8. `docs/releases/RELEASE_NOTES_v6.md` and
7. `docs/releases/RELEASE_NOTES_v6.md` and
`docs/release-control/v6/internal/V5_MAINTENANCE_SUPPORT_POLICY.md` now carry the
currently proposed exact dates for the eventual GA notice:
- `v6` GA date: `2026-04-20`
- `v5` end-of-support date: `2026-07-19`
9. There is still no governed `Release Dry Run` artifact or rehearsal record
8. There is still no governed `Release Dry Run` artifact or rehearsal record
exercising stable inputs for:
- `version=6.0.0`
- `promoted_from_tag=v6.0.0-rc.2`

View file

@ -20,7 +20,7 @@
`v5` track while the `v6` model is retained as preview data.
5. The governed commercial lane (`L2`) had previously been treated as complete
at the RC floor even though GA still lacks a named self-hosted commercial
package pass covering the public site, in-app upgrade/trial path,
package pass covering the public site, explicit in-app commercial handoff,
checkout/account/license-management flow, and GA-facing guidance together.
## Why The Gate Cannot Be Cleared Yet

View file

@ -42,6 +42,30 @@ journey.
- `PULSE_E2E_USE_LOCAL_BACKEND=1 npm --prefix tests/integration test -- tests/58-self-hosted-trial-rate-limit-ui.spec.ts --project=chromium`
- Result: pass in Chromium against a managed local backend.
## Follow-Up Copy Audit
The 2026-04-25 copy audit removed lingering trial-first language from the
ordinary self-hosted quickstart reason, settings privacy labels, shared
self-hosted plan presentation helpers, and current commercial strategy docs.
The trial transport remains available for hosted/support handoffs, but default
self-hosted runtime copy now points users to BYOK Patrol or explicit
commercial/entitlement handoff paths instead of asking them to start a trial.
- `npm --prefix frontend-modern test -- src/components/Settings/__tests__/AISettings.test.tsx src/components/Settings/__tests__/GeneralSettingsPanel.guardrails.test.ts src/utils/__tests__/aiSettingsPresentation.test.ts src/utils/__tests__/systemSettingsPresentation.test.ts src/utils/__tests__/relayPresentation.test.ts src/utils/__tests__/selfHostedPlans.test.ts src/utils/__tests__/licensePresentation.test.ts src/api/__tests__/patrol.test.ts src/pages/__tests__/AIIntelligence.test.tsx`
- Result: pass, 9 files and 86 tests.
- `npm --prefix frontend-modern run type-check`
- Result: pass.
- `go test ./internal/ai -run 'Test.*Quickstart|TestQuickstart|TestService.*Quickstart' -count=1`
- Result: pass.
- `go test ./internal/config -count=1`
- Result: pass.
- `PULSE_E2E_USE_LOCAL_BACKEND=1 PULSE_E2E_SKIP_PLAYWRIGHT_INSTALL=1 npm --prefix tests/integration test -- tests/58-self-hosted-trial-rate-limit-ui.spec.ts --project=chromium`
- Result: pass in Chromium against a managed local backend; the proof now also asserts that `Start Trial`, `free 14-day trial`, and `Open hosted handoff` do not appear in the default self-hosted UI.
- `python3 scripts/release_control/documentation_currentness_test.py`
- Result: pass.
- `python3 scripts/release_control/release_promotion_policy_test.py`
- Result: pass after regenerating the static blocked RC-to-GA record from current branch facts without creating any release, tag, or promotion artifact.
## Known Unrelated State
- Repository-wide frontend format check still reports pre-existing warnings

View file

@ -4027,7 +4027,7 @@
},
{
"id": "self-hosted-commercial-ga-coherence",
"summary": "Confirm the self-hosted GA commercial package is coherent end-to-end: public landing, in-app upgrade/trial path, checkout/account/license-management flows, and GA-facing guidance all describe the same Community / Relay / Pro product without legacy v5 bridge posture or contradictory plan copy.",
"summary": "Confirm the self-hosted GA commercial package is coherent end-to-end: public landing, explicit in-app commercial handoff, checkout/account/license-management flows, and GA-facing guidance all describe the same Community / Relay / Pro product without proactive self-hosted trial pressure, legacy v5 bridge posture, or contradictory plan copy.",
"owner": "project-owner",
"blocking_level": "release-ready",
"minimum_evidence_tier": "real-external-e2e",

View file

@ -1177,6 +1177,10 @@ consume the settings-owned referral copy from
`frontend-modern/src/components/Settings/selfHostedBillingPresentation.ts`
instead of carrying workspace-local commercial guidance or reaching back into
generic commercial presentation helpers from the hosted infrastructure route.
Shared licensing routes under `internal/api/` may retain legacy
`upgrade-metrics` route names for compatibility, but lifecycle-adjacent
settings and install flows must treat them as local commercial handoff events,
not as a reason to reintroduce default self-hosted upgrade prompts.
That canonical /api/auto-register behavior now also includes hostname/IP continuity:
reruns that arrive through a different canonical host form must reuse the same
Pulse-managed node record and token instead of forking duplicate fleet entries.

View file

@ -95,9 +95,10 @@ runtime cost control, and shared AI transport surfaces.
deny fails.
9. Keep self-hosted Patrol quickstart messaging aligned with backend runtime
truth: the governed quickstart contract is Patrol-only first-run
acceleration on activated or trial-backed installs with server-authoritative
run inventory, not a general hosted chat entitlement or a replacement for
BYOK once Patrol leaves the quickstart path.
acceleration on installs with an activated entitlement and
server-authoritative run inventory, not a trial prompt, a general hosted chat
entitlement, or a replacement for BYOK once Patrol leaves the quickstart
path.
10. Keep discovery-analysis prompt bounds and response budgets aligned across
`internal/ai/service.go` and the shared service-discovery prompt builders:
the runtime must reserve enough output tokens for structured discovery JSON,

View file

@ -697,7 +697,7 @@ for logs and response headers.
That same shared settings/licensing contract now also owns the split usage-data
payload model. `frontend-modern/src/api/settings.ts`,
`internal/api/router_routes_licensing.go`, and adjacent settings callers must
keep anonymous outbound telemetry and local-only upgrade events as separate
keep anonymous outbound telemetry and local-only commercial handoff events as separate
browser-visible scopes, and the telemetry preview payload must ship normalized
version identity fields (`version`, `version_raw`, `version_channel`,
`version_build`, `version_is_development`, and

View file

@ -390,8 +390,9 @@ Community limit enforcement.
3. Add or tighten drift tests when a pricing/runtime mismatch is fixed
4. Keep self-hosted pricing and public docs on runtime-backed commercial truth:
Patrol quickstart may be presented only as Patrol-only first-run activation
support backed by the license server, while Relay and Pro remain the
canonical commercial story.
support backed by the license server and an activated entitlement, never as
a default self-hosted trial CTA, while Relay and Pro remain the canonical
commercial story.
5. Treat grandfathered `lifetime` licenses as uncapped commercial entitlements:
they keep the Pro feature set, but they must not inherit monitored-system or
guest caps from recurring Pro contracts anywhere in runtime, issuance, or
@ -2012,6 +2013,10 @@ only for explicit handoff/direct routes, activation or recovery state, hosted
mode, or an already entitled install. Trial checkout plumbing may remain for
support-only or externally initiated flows, but it is not a normal GA
self-hosted app journey.
Shared self-hosted plan presentation helpers must carry that same policy:
Community copy may mention BYOK Patrol and hosted quickstart only as an
activated-entitlement benefit, not as a default self-hosted trial CTA or a
reason to put a paid prompt in front of ordinary users.
That public-demo commercial boundary also owns monitored-system preview
unavailability wording. Browser presentation may keep the unavailable reason
nullable until the formatting edge, but it must normalize the message through

View file

@ -804,7 +804,7 @@ vocabulary. `frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx`,
`frontend-modern/src/components/Settings/useSystemSettingsState.ts`, and
`frontend-modern/src/utils/systemSettingsPresentation.ts` must present one
explicit `Usage data and privacy` model with the separate labels
`Anonymous outbound telemetry` and `Disable local-only upgrade events`, and
`Anonymous outbound telemetry` and `Disable local-only commercial events`, and
must describe telemetry with normalized release identity rather than falling
back to ambiguous `telemetry`, `upgrade metrics`, or raw-version wording.
Shared table, disclosure, and form primitives must also stay explicitly typed

View file

@ -18,7 +18,7 @@
## Purpose
Own Pulse's canonical privacy disclosures, usage-data boundary (anonymous
outbound telemetry plus local-only upgrade events), and the security-facing
outbound telemetry plus local-only commercial handoff events), and the security-facing
settings surfaces that expose authentication posture, token-management
visibility, and privacy controls to operators.
@ -107,7 +107,7 @@ This subsystem now gives `L14` an explicit governed home for privacy guidance
and telemetry disclosures instead of leaving those trust surfaces as lane-level
evidence with no subsystem ownership.
That same governed home now also owns the single "usage data" vocabulary for
anonymous outbound telemetry and local-only upgrade events, so Pulse stops
anonymous outbound telemetry and local-only commercial handoff events, so Pulse stops
describing those two privacy scopes as unrelated systems.
That same operator-reporting boundary now also owns reusable latest-install
adoption baselines. `scripts/telemetry_adoption_report.py` must emit

View file

@ -726,6 +726,11 @@ backend capability fact remains `sessionCapabilities.demoMode`, but storage
and recovery browsers must consume the shared resolved `presentationPolicy`
instead of inferring demo posture from headers, `/api/health`, or hostname
heuristics.
Shared licensing routes under `internal/api/` may retain legacy
`upgrade-metrics` names for local commercial handoff telemetry, but storage and
recovery surfaces must continue to consume the presentation policy instead of
using those route names as a cue to render paid history prompts in ordinary
self-hosted sessions.
That same shared boundary now also owns the one runtime-safe exception:
storage and recovery may inherit demo-safe `/api/license/runtime-capabilities`
reads for capability and history-retention truth, but

View file

@ -182,8 +182,8 @@ Numeric intervals are **seconds** unless noted otherwise.
| `metricsRetentionHourlyDays` | Hourly metrics retention (days) |
| `metricsRetentionDailyDays` | Daily metrics retention (days) |
| `disableDockerUpdateActions` | Hide Docker update actions in UI |
| `reduceProUpsellNoise` | Reduce proactive Pro prompts (paywalls still appear when accessing gated features) |
| `disableLocalUpgradeMetrics` | Disable local-only upgrade event collection |
| `reduceProUpsellNoise` | Legacy compatibility preference for proactive commercial prompts. Default self-hosted v6 sessions already hide paid prompts unless the user enters an explicit commercial handoff or has an existing entitlement. |
| `disableLocalUpgradeMetrics` | Disable local-only commercial handoff event collection |
| `backendPort` | Legacy (unused) |
| `frontendPort` | Legacy (ignored; use `FRONTEND_PORT`) |
@ -219,7 +219,7 @@ Environment variables take precedence over `system.json`.
| Variable | Description | Default |
| ---------- | ------------- | --------- |
| `PULSE_PUBLIC_URL` | URL for UI links, notifications, and OIDC. For reverse proxies, keep this as the public URL and use `PULSE_AGENT_CONNECT_URL` for agent installs if you need a direct/internal address. | Auto-detected |
| `PULSE_PRO_TRIAL_SIGNUP_URL` | Hosted signup/checkout URL used when users click **Start Free Pro Trial**. Must be absolute `http(s)` URL. | `https://cloud.pulserelay.pro/start-pro-trial?...` |
| `PULSE_PRO_TRIAL_SIGNUP_URL` | Support-only hosted trial signup URL used by explicit commercial handoff flows. Normal self-hosted v6 UI does not surface this path. Must be absolute `http(s)` URL. | `https://cloud.pulserelay.pro/start-pro-trial?...` |
| `PULSE_AGENT_CONNECT_URL` | Dedicated direct URL for agents (overrides `PULSE_PUBLIC_URL` for agent install commands). Alias: `PULSE_AGENT_URL`. | *(unset)* |
| `PULSE_AGENT_CONFIG_SIGNING_KEY` | Base64 Ed25519 private key used to sign remote agent config payloads. | *(unset)* |
| `PULSE_AGENT_CONFIG_PUBLIC_KEYS` | Comma-separated base64 Ed25519 public keys (raw 32-byte or PKIX-encoded) trusted by agents. | *(unset)* |

View file

@ -7,9 +7,9 @@ Pulse is designed to run locally. By default, your monitoring data stays on your
Pulse currently has two usage-data scopes:
1. **Anonymous outbound telemetry** to help me understand active installations, release uptake, and feature use in aggregate.
2. **Local-only upgrade events** to help debug and improve upgrade flows on the instance where they were recorded.
2. **Local-only commercial handoff events** to help debug explicit paid-plan and activation handoffs on the instance where they were recorded.
These are separate scopes on purpose. The outbound telemetry path stays coarse and anonymous. The local-only upgrade-event path stays on the Pulse instance and is not exported to third parties.
These are separate scopes on purpose. The outbound telemetry path stays coarse and anonymous. The local-only commercial-event path stays on the Pulse instance and is not exported to third parties.
### Anonymous outbound telemetry
@ -95,17 +95,17 @@ The telemetry implementation is in [`internal/telemetry/telemetry.go`](../intern
Pulse can make outbound connections when you enable specific features:
- **AI (BYOK)**: when AI features are enabled, Pulse sends only the context required for your request to the provider you configured (OpenAI, Anthropic, etc.). See `docs/AI.md`.
- **AI (BYOK)**: when AI features are configured with your own API key, Pulse sends only the context required for your request directly to the provider you chose (OpenAI, Anthropic, etc.). Requests do not transit Pulse infrastructure. See `docs/AI.md`.
- **AI (Quickstart)**: when the Pulse-hosted Quickstart provider is selected, your chat request — including messages, system prompt, and tool definitions — is forwarded through `license.pulserelay.pro/v1/quickstart/patrol` to the underlying model provider. This path exists so eligible quickstart users can try AI without their own API key; it means requests transit Pulse infrastructure. To keep prompts off Pulse infrastructure, use a BYOK provider instead.
- **Relay / Remote Access**: when relay is enabled, Pulse connects to the configured relay endpoint to enable mobile access. See Settings → Remote Access.
- **Update checks**: Pulse can check for new releases/updates (for example via GitHub release metadata) depending on your deployment and configuration.
### Local-only upgrade events
### Local-only commercial handoff events
Pulse can record local-only usage events such as "paywall viewed" or "trial started" to improve and debug in-app upgrade flows.
Pulse can record local-only usage events such as plan-link views or activation handoff attempts to improve and debug voluntary paid flows.
- These events are stored locally and are not exported to third parties.
- Disable via **Settings → System → General → Disable local-only upgrade events** or set:
- Disable via **Settings → System → General → Disable local-only commercial events** or set:
- `PULSE_DISABLE_LOCAL_UPGRADE_METRICS=true`
If you prefer fewer upgrade prompts, you can also enable:
- **Settings → System → General → Reduce Pro prompts**
The legacy `reduceProUpsellNoise` preference remains for older proactive prompt behavior, but default self-hosted v6 sessions already hide paid prompts unless the user enters an explicit commercial handoff or has an existing entitlement.

View file

@ -79,14 +79,14 @@ describe('patrol api', () => {
apiFetchJSONMock.mockResolvedValueOnce({
runtime_state: 'blocked',
blocked_reason:
'Activate this install or start a trial to use Patrol quickstart. Otherwise connect your API key.',
'Connect your API key to use AI Patrol on this install. Hosted quickstart requires an activated entitlement.',
healthy: false,
} as any);
await expect(getPatrolStatus()).resolves.toMatchObject({
runtime_state: 'blocked',
blocked_reason:
'Activate this install or start a trial to use Patrol quickstart. Otherwise connect your API key.',
'Connect your API key to use AI Patrol on this install. Hosted quickstart requires an activated entitlement.',
healthy: false,
});
});
@ -95,7 +95,7 @@ describe('patrol api', () => {
apiFetchJSONMock.mockResolvedValueOnce({
runtime_state: 'blocked',
blocked_reason:
'Activate this install or start a trial to use Patrol quickstart. Otherwise connect your API key.',
'Connect your API key to use AI Patrol on this install. Hosted quickstart requires an activated entitlement.',
healthy: false,
quickstart_credits_total: 0,
quickstart_credits_remaining: 0,
@ -105,7 +105,7 @@ describe('patrol api', () => {
await expect(getPatrolStatus()).resolves.toMatchObject({
runtime_state: 'blocked',
blocked_reason:
'Activate this install or start a trial to use Patrol quickstart. Otherwise connect your API key.',
'Connect your API key to use AI Patrol on this install. Hosted quickstart requires an activated entitlement.',
healthy: false,
quickstart_credits_total: 0,
quickstart_credits_remaining: 0,

View file

@ -3,7 +3,6 @@ import { Dialog } from '@/components/shared/Dialog';
import { SelectionCardGroup } from '@/components/shared/SelectionCardGroup';
import { getAISessionDiffStatusPresentation } from '@/utils/aiSessionDiffPresentation';
import { getAISettingsSetupDialogPresentation } from '@/utils/aiSettingsPresentation';
import { RELAY_ONBOARDING_TRIAL_STARTING_LABEL } from '@/utils/relayPresentation';
import type { FileChange } from '@/api/aiChat';
import type { AIProvider } from '@/types/ai';
import {
@ -122,11 +121,12 @@ export const AISettingsDialogs: Component<AISettingsDialogsProps> = (props) => {
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<p class="text-sm font-medium text-blue-900 dark:text-blue-100">
Start a Pro trial to unlock Patrol quickstart
Use hosted quickstart for Patrol
</p>
<p class="mt-1 text-xs text-blue-800 dark:text-blue-200">
Use the hosted quickstart path first, or skip it and connect your own model
provider below.
Open the hosted quickstart handoff, or skip it and connect your own model
provider below. Hosted quickstart routes prompts through Pulse
infrastructure; BYOK providers go direct.
</p>
</div>
<button
@ -135,7 +135,7 @@ export const AISettingsDialogs: Component<AISettingsDialogsProps> = (props) => {
class="inline-flex min-h-10 sm:min-h-9 items-center justify-center rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
disabled={props.startingTrial()}
>
{props.startingTrial() ? RELAY_ONBOARDING_TRIAL_STARTING_LABEL : 'Start Trial'}
{props.startingTrial() ? 'Opening handoff...' : 'Open hosted handoff'}
</button>
</div>
</div>

View file

@ -210,16 +210,16 @@ export const GeneralSettingsPanel: Component<GeneralSettingsPanelProps> = (props
<div class="flex-1 min-w-0 space-y-1">
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-base-content truncate">
Disable local-only upgrade events
Disable local-only commercial events
</span>
<Show when={props.disableLocalUpgradeMetricsLocked()}>
<EnvironmentLockBadge envVar="PULSE_DISABLE_LOCAL_UPGRADE_METRICS" />
</Show>
</div>
<p class="text-xs text-muted line-clamp-2">
Records local-only upgrade events like "paywall viewed" and "trial started" to help
debug and improve upgrade flows. These events stay on this Pulse instance and are not
exported to third parties.
Records local-only commercial handoff events, such as plan-link views or activation
handoff attempts, to debug voluntary paid flows. These events stay on this Pulse
instance and are not exported to third parties.
</p>
</div>
<Toggle

View file

@ -501,7 +501,7 @@ describe('AISettings quickstart enablement flow', () => {
quickstart_credits_remaining: 0,
quickstart_credits_available: false,
quickstart_blocked_reason:
'Activate this install or start a trial to use Patrol quickstart. Otherwise connect your API key.',
'Connect your API key to use AI Patrol on this install. Hosted quickstart requires an activated entitlement.',
});
renderComponent();
@ -518,10 +518,10 @@ describe('AISettings quickstart enablement flow', () => {
).toBeInTheDocument();
expect(
screen.getAllByText(
'Activate this install or start a trial to use Patrol quickstart. Otherwise connect your API key.',
'Connect your API key to use AI Patrol on this install. Hosted quickstart requires an activated entitlement.',
),
).toHaveLength(2);
expect(screen.getByRole('button', { name: /start trial/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /open hosted handoff/i })).toBeInTheDocument();
});
it('hides trial setup guidance when self-hosted upgrade prompts are disabled', async () => {
@ -534,7 +534,7 @@ describe('AISettings quickstart enablement flow', () => {
quickstart_credits_remaining: 0,
quickstart_credits_available: false,
quickstart_blocked_reason:
'Activate this install or start a trial to use Patrol quickstart. Otherwise connect your API key.',
'Connect your API key to use AI Patrol on this install. Hosted quickstart requires an activated entitlement.',
});
renderComponent();
@ -550,7 +550,7 @@ describe('AISettings quickstart enablement flow', () => {
expect(
screen.getByText('Connect a provider to power Pulse Assistant and Patrol.'),
).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /start trial/i })).not.toBeInTheDocument();
expect(screen.queryByText(/start a trial to use Patrol quickstart/i)).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /open hosted handoff/i })).not.toBeInTheDocument();
expect(screen.queryByText(/Hosted quickstart requires an activated entitlement/i)).not.toBeInTheDocument();
});
});

View file

@ -13,7 +13,7 @@ describe('GeneralSettingsPanel guardrails', () => {
it('keeps telemetry preview and reset controls in the general settings panel', () => {
expect(generalSettingsPanelSource).toContain('Usage data and privacy');
expect(generalSettingsPanelSource).toContain('Disable local-only upgrade events');
expect(generalSettingsPanelSource).toContain('Disable local-only commercial events');
expect(generalSettingsPanelSource).toContain('Preview payload');
expect(generalSettingsPanelSource).toContain('Reset ID');
expect(generalSettingsPanelSource).toContain('Telemetry payload preview');

View file

@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest';
import settingsSource from '../Settings.tsx?raw';
import settingsDialogsSource from '../SettingsDialogs.tsx?raw';
import settingsPageShellSource from '../SettingsPageShell.tsx?raw';
import aiSettingsDialogsSource from '../AISettingsDialogs.tsx?raw';
import generalSettingsPanelSource from '../GeneralSettingsPanel.tsx?raw';
import settingsHeaderMetaSource from '../settingsHeaderMeta.ts?raw';
import settingsNavCatalogSource from '../settingsNavCatalog.ts?raw';
import settingsNavigationHookSource from '../useSettingsNavigation.ts?raw';
@ -25,6 +27,7 @@ import diagnosticsResultsPanelSource from '../DiagnosticsResultsPanel.tsx?raw';
import diagnosticsModelSource from '../diagnosticsModel.ts?raw';
import infrastructureOnboardingPresentationSource from '../../../utils/infrastructureOnboardingPresentation.ts?raw';
import selfHostedBillingPresentationSource from '../selfHostedBillingPresentation.ts?raw';
import systemSettingsPresentationSource from '../../../utils/systemSettingsPresentation.ts?raw';
describe('settings architecture guardrails', () => {
it('keeps Settings on the canonical page shell boundary', () => {
@ -122,6 +125,20 @@ describe('settings architecture guardrails', () => {
);
});
it('keeps default self-hosted commercial copy opt-in from shared settings primitives', () => {
expect(aiSettingsDialogsSource).toContain('Open hosted handoff');
expect(aiSettingsDialogsSource).not.toContain('Start Trial');
expect(aiSettingsDialogsSource).not.toContain('RELAY_ONBOARDING_TRIAL_STARTING_LABEL');
expect(generalSettingsPanelSource).toContain('Disable local-only commercial events');
expect(generalSettingsPanelSource).not.toContain('Disable local-only upgrade events');
expect(systemSettingsPresentationSource).toContain(
'Unable to update local-only commercial events.',
);
expect(systemSettingsPresentationSource).toContain(
'Unable to update commercial prompt preferences.',
);
});
it('keeps infrastructure on a source-manager landing with route-backed dialogs', () => {
expect(infrastructureWorkspaceSource).toContain(
"import { ConnectionEditor } from './ConnectionEditor/ConnectionEditor';",

View file

@ -27,10 +27,6 @@ export interface SelfHostedProBillingPresentation {
purchaseExpiredActionLabel: string;
purchaseFailedActionLabel: string;
purchaseUnavailableActionLabel: string;
trialStartTitle: string;
trialStartBody: string;
trialStartIdleActionLabel: string;
trialStartPendingActionLabel: string;
recoverySectionTitle: string;
recoverySectionDescription: string;
}
@ -67,14 +63,9 @@ export const SELF_HOSTED_PRO_BILLING_PRESENTATION: SelfHostedProBillingPresentat
planSelectionPromptActionLabel: 'Compare plans',
purchaseActivatedPlanActionLabel: 'Review plan',
purchaseCancelledActionLabel: 'Compare plans',
purchaseExpiredActionLabel: 'Restart upgrade',
purchaseExpiredActionLabel: 'Compare plans',
purchaseFailedActionLabel: 'Open recovery',
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 history on this organization.',
trialStartIdleActionLabel: 'Start 14-day Pro Trial',
trialStartPendingActionLabel: 'Starting...',
recoverySectionTitle: 'Activation & Recovery',
recoverySectionDescription:
'Activate a purchased key, recover a previous self-hosted purchase, or clear a local key from this instance.',

View file

@ -339,9 +339,12 @@ export function useSystemSettingsState({
try {
await SettingsAPI.updateSystemSettings({ reduceProUpsellNoise: enabled });
updateReduceProUpsellNoiseSetting(enabled);
notificationStore.success(enabled ? 'Pro prompts reduced' : 'Pro prompts restored', 2000);
notificationStore.success(
enabled ? 'Commercial prompts reduced' : 'Commercial prompt preference restored',
2000,
);
} catch (error) {
logger.error('Failed to update reduce upsell noise setting', error);
logger.error('Failed to update commercial prompt preference', error);
notificationStore.error(
getReduceUpsellNoiseUpdateErrorMessage(error instanceof Error ? error.message : undefined),
);
@ -364,11 +367,11 @@ export function useSystemSettingsState({
await SettingsAPI.updateSystemSettings({ disableLocalUpgradeMetrics: disabled });
updateDisableLocalUpgradeMetricsSetting(disabled);
notificationStore.success(
disabled ? 'Local-only upgrade events disabled' : 'Local-only upgrade events enabled',
disabled ? 'Local-only commercial events disabled' : 'Local-only commercial events enabled',
2000,
);
} catch (error) {
logger.error('Failed to update local-only upgrade events setting', error);
logger.error('Failed to update local-only commercial events setting', error);
notificationStore.error(
getLocalUpgradeMetricsUpdateErrorMessage(
error instanceof Error ? error.message : undefined,

View file

@ -840,7 +840,7 @@ describe('AIIntelligence entitlement gating', () => {
quickstart_credits_total: 25,
quickstart_credits_remaining: 0,
blocked_reason:
'Activate this install or start a trial to use Patrol quickstart. Otherwise connect your API key.',
'Connect your API key to use AI Patrol on this install. Hosted quickstart requires an activated entitlement.',
}),
);
@ -850,7 +850,7 @@ describe('AIIntelligence entitlement gating', () => {
expect(getPatrolStatusMock).toHaveBeenCalled();
expect(
screen.getAllByText(
'Activate this install or start a trial to use Patrol quickstart. Otherwise connect your API key.',
'Connect your API key to use AI Patrol on this install. Hosted quickstart requires an activated entitlement.',
).length,
).toBeGreaterThan(0);
});

View file

@ -68,6 +68,8 @@ describe('systemSettings store', () => {
const privacyDoc = readFileSync(path.join(repoRoot, 'docs', 'PRIVACY.md'), 'utf8');
expect(privacyDoc).toContain('## Usage Data');
expect(privacyDoc).toContain('Local-only commercial handoff events');
expect(privacyDoc).not.toContain('Local-only upgrade events');
expect(privacyDoc).toContain('Every field is listed below with the reason it exists');
expect(privacyDoc).toContain('rows older than **90 days** are purged automatically');
expect(privacyDoc).toContain('uses client IP addresses transiently for abuse/rate limiting');

View file

@ -137,7 +137,7 @@ export async function loadCommercialPosture(force = false): Promise<void> {
}
/**
* Start a Pro trial for the current org, then refresh both runtime and commercial state.
* Start the explicit hosted trial handoff for the current org, then refresh runtime and commercial state.
*/
export async function startProTrial(): Promise<StartProTrialResult> {
if (
@ -212,8 +212,8 @@ export const isPro = createRoot(() =>
);
/**
* Used by non-billing upgrade surfaces that can render a trial CTA unless the
* current posture explicitly denies trial eligibility.
* Used by explicit commercial surfaces that can render a trial CTA unless the
* presentation policy or current posture denies trial eligibility.
*/
export function canOfferCommercialTrial(): boolean {
if (presentationPolicyHidesUpgradePrompts()) return false;
@ -221,8 +221,8 @@ export function canOfferCommercialTrial(): boolean {
}
/**
* Used by surfaces that should only offer trial start when the current
* commercial posture is loaded and not already active/trial-backed.
* Used by explicit commercial surfaces that should only offer trial start when
* the current commercial posture is loaded and not already active/trial-backed.
*/
export function canStartCommercialTrial(): boolean {
if (presentationPolicyHidesUpgradePrompts()) return false;

View file

@ -12,9 +12,9 @@ import type { SystemConfig } from '@/types/config';
// Server-side setting to hide Docker update buttons while still detecting updates
const [disableDockerUpdateActions, setDisableDockerUpdateActions] = createSignal(false);
// Server-side setting to reduce proactive Pro prompts (paywalls still appear when accessing gated features)
// Server-side compatibility setting for proactive commercial prompts
const [reduceProUpsellNoise, setReduceProUpsellNoise] = createSignal(false);
// Server-side setting to disable local-only upgrade UX metrics collection
// Server-side setting to disable local-only commercial handoff event collection
const [disableLocalUpgradeMetrics, setDisableLocalUpgradeMetrics] = createSignal(false);
// Track if settings have been loaded

View file

@ -49,8 +49,8 @@ export interface SystemConfig {
hideLocalLogin?: boolean; // Hide local login form (username/password)
publicURL?: string; // Public URL for email notifications (e.g., http://198.51.100.100:8080)
disableDockerUpdateActions?: boolean; // Hide Docker update buttons while still detecting updates (server-wide)
reduceProUpsellNoise?: boolean; // Hide proactive Pro prompts; paywalls still appear when accessing gated features
disableLocalUpgradeMetrics?: boolean; // Disable local-only upgrade UX metrics collection
reduceProUpsellNoise?: boolean; // Legacy compatibility preference for proactive commercial prompts
disableLocalUpgradeMetrics?: boolean; // Disable local-only commercial handoff event collection
telemetryEnabled?: boolean; // Opt-in anonymous usage telemetry
}

View file

@ -53,7 +53,7 @@ describe('aiSettingsPresentation', () => {
ariaLabel: 'Activate quickstart or connect a provider',
title: 'Activate quickstart or connect a provider',
description:
'Start a trial to unlock Patrol quickstart, or connect your own provider for Pulse Assistant and Patrol.',
'Use an eligible hosted quickstart entitlement, or connect your own provider for Pulse Assistant and Patrol.',
submitLabel: 'Enable Assistant & Patrol',
});
});
@ -104,13 +104,13 @@ describe('aiSettingsPresentation', () => {
quickstartCreditsRemaining: 0,
quickstartCreditsTotal: 0,
quickstartBlockedReason:
'Activate this install or start a trial to use Patrol quickstart. Otherwise connect your API key.',
'Connect your API key to use AI Patrol on this install. Hosted quickstart requires an activated entitlement.',
}),
).toEqual({
containerClassName: 'bg-amber-50 dark:bg-amber-900 text-amber-800 dark:text-amber-200',
dotClassName: 'bg-amber-400',
summary:
'Activate this install or start a trial to use Patrol quickstart. Otherwise connect your API key.',
'Connect your API key to use AI Patrol on this install. Hosted quickstart requires an activated entitlement.',
});
});

View file

@ -101,14 +101,9 @@ describe('licensePresentation', () => {
planSelectionPromptActionLabel: 'Compare plans',
purchaseActivatedPlanActionLabel: 'Review plan',
purchaseCancelledActionLabel: 'Compare plans',
purchaseExpiredActionLabel: 'Restart upgrade',
purchaseExpiredActionLabel: 'Compare plans',
purchaseFailedActionLabel: 'Open recovery',
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 history on this organization.',
trialStartIdleActionLabel: 'Start 14-day Pro Trial',
trialStartPendingActionLabel: 'Starting...',
recoverySectionTitle: 'Activation & Recovery',
recoverySectionDescription:
'Activate a purchased key, recover a previous self-hosted purchase, or clear a local key from this instance.',

View file

@ -13,10 +13,6 @@ import {
RELAY_ONBOARDING_DISCONNECTED_LABEL,
RELAY_ONBOARDING_SETUP_LABEL,
RELAY_ONBOARDING_TITLE,
RELAY_ONBOARDING_TRIAL_LABEL,
RELAY_ONBOARDING_TRIAL_STARTING_LABEL,
RELAY_ONBOARDING_SETUP_WIZARD_TRIAL_LABEL,
RELAY_ONBOARDING_TRIAL_HINT,
RELAY_ONBOARDING_UPGRADE_LABEL,
RELAY_PAIRING_AVAILABILITY_MESSAGE,
RELAY_PAIRING_AVAILABILITY_TITLE,
@ -84,10 +80,6 @@ describe('relayPresentation', () => {
expect(RELAY_ONBOARDING_TITLE).toBe('Pair Your Mobile Device');
expect(RELAY_ONBOARDING_DESCRIPTION).toContain('remote monitoring');
expect(RELAY_ONBOARDING_UPGRADE_LABEL).toBe('Get Relay — $39/yr');
expect(RELAY_ONBOARDING_TRIAL_LABEL).toBe('or start a Pro trial');
expect(RELAY_ONBOARDING_TRIAL_STARTING_LABEL).toBe('Starting trial...');
expect(RELAY_ONBOARDING_SETUP_WIZARD_TRIAL_LABEL).toBe('Start Free Trial & Set Up Mobile');
expect(RELAY_ONBOARDING_TRIAL_HINT).toBe('14-DAY PRO TRIAL · NO CREDIT CARD REQUIRED');
expect(RELAY_ONBOARDING_SETUP_LABEL).toBe('Set Up Relay');
expect(RELAY_ONBOARDING_DISCONNECTED_LABEL).toBe('Relay is currently disconnected.');
});

View file

@ -58,8 +58,7 @@ describe('selfHostedPlans', () => {
includedLabel: 'Included',
freeLabel: 'Free',
buyRelayLabel: 'Buy Relay',
upgradeToProLabel: 'Upgrade to Pro',
startTrialLabel: 'Start Free 14-day Trial',
upgradeToProLabel: 'Choose Pro',
featureComparisonHeading: 'Feature Comparison',
footerLinks: [
{
@ -135,7 +134,7 @@ describe('selfHostedPlans', () => {
expect.arrayContaining([
'Unlimited self-hosted monitoring',
'Pulse Patrol (BYOK)',
'Patrol quickstart after activation or trial: 25 runs, no API key',
'Hosted Patrol quickstart with activated entitlement: 25 runs, no API key',
]),
);
expect(SELF_HOSTED_PLAN_BY_TIER.relay.highlights).toContain('14-day metric history');

View file

@ -83,10 +83,10 @@ describe('systemSettingsPresentation', () => {
'Unable to update container update actions.',
);
expect(getReduceUpsellNoiseUpdateErrorMessage()).toBe(
'Unable to update upgrade guidance preferences.',
'Unable to update commercial prompt preferences.',
);
expect(getLocalUpgradeMetricsUpdateErrorMessage()).toBe(
'Unable to update local-only upgrade events.',
'Unable to update local-only commercial events.',
);
expect(getTelemetryUpdateErrorMessage()).toBe('Unable to update anonymous telemetry.');
expect(getTemperatureMonitoringUpdateErrorMessage()).toBe(

View file

@ -2,7 +2,7 @@ export const AI_QUICKSTART_EXHAUSTED_REASON =
'Quickstart credits exhausted. Connect your API key to continue using Patrol.';
export const AI_QUICKSTART_ACTIVATION_REQUIRED_REASON =
'Activate this install or start a trial to use Patrol quickstart. Otherwise connect your API key.';
'Connect your API key to use AI Patrol on this install. Hosted quickstart requires an activated entitlement.';
export const AI_QUICKSTART_UNAVAILABLE_REASON =
'Quickstart credits require internet access. Connect your API key to use Patrol offline.';

View file

@ -69,7 +69,7 @@ export function getAISettingsSetupDialogPresentation(
ariaLabel: 'Activate quickstart or connect a provider',
title: 'Activate quickstart or connect a provider',
description:
'Start a trial to unlock Patrol quickstart, or connect your own provider for Pulse Assistant and Patrol.',
'Use an eligible hosted quickstart entitlement, or connect your own provider for Pulse Assistant and Patrol.',
submitLabel: 'Enable Assistant & Patrol',
};
case 'provider-required':

View file

@ -39,12 +39,8 @@ export const RELAY_ONBOARDING_TITLE = 'Pair Your Mobile Device';
export const RELAY_ONBOARDING_DESCRIPTION =
'Pulse Relay lets your phone securely connect to this Pulse instance for remote monitoring.';
export const RELAY_ONBOARDING_UPGRADE_LABEL = 'Get Relay — $39/yr';
export const RELAY_ONBOARDING_TRIAL_LABEL = 'or start a Pro trial';
export const RELAY_ONBOARDING_TRIAL_STARTING_LABEL = 'Starting trial...';
export const RELAY_ONBOARDING_SETUP_LABEL = 'Set Up Relay';
export const RELAY_ONBOARDING_DISCONNECTED_LABEL = 'Relay is currently disconnected.';
export const RELAY_ONBOARDING_SETUP_WIZARD_TRIAL_LABEL = 'Start Free Trial & Set Up Mobile';
export const RELAY_ONBOARDING_TRIAL_HINT = '14-DAY PRO TRIAL · NO CREDIT CARD REQUIRED';
export function getRelayDiagnosticClass(severity: 'warning' | 'error'): string {
return severity === 'error'

View file

@ -42,7 +42,6 @@ export interface SelfHostedCommercialPresentation {
freeLabel: string;
buyRelayLabel: string;
upgradeToProLabel: string;
startTrialLabel: string;
featureComparisonHeading: string;
footerLinks: readonly SelfHostedLinkCTA[];
}
@ -99,7 +98,7 @@ export const SELF_HOSTED_PLAN_DEFINITIONS: readonly SelfHostedPlanDefinition[] =
'Unlimited self-hosted monitoring',
'7-day metric history',
'Pulse Patrol (BYOK)',
'Patrol quickstart after activation or trial: 25 runs, no API key',
'Hosted Patrol quickstart with activated entitlement: 25 runs, no API key',
'Update alerts',
'Basic SSO (OIDC)',
'Community support',
@ -183,8 +182,7 @@ export const SELF_HOSTED_COMMERCIAL_PRESENTATION: SelfHostedCommercialPresentati
includedLabel: 'Included',
freeLabel: 'Free',
buyRelayLabel: 'Buy Relay',
upgradeToProLabel: 'Upgrade to Pro',
startTrialLabel: 'Start Free 14-day Trial',
upgradeToProLabel: 'Choose Pro',
featureComparisonHeading: 'Feature Comparison',
footerLinks: [
{

View file

@ -77,11 +77,11 @@ export function getDockerUpdateActionsUpdateErrorMessage(message?: string): stri
}
export function getReduceUpsellNoiseUpdateErrorMessage(message?: string): string {
return message || 'Unable to update upgrade guidance preferences.';
return message || 'Unable to update commercial prompt preferences.';
}
export function getLocalUpgradeMetricsUpdateErrorMessage(message?: string): string {
return message || 'Unable to update local-only upgrade events.';
return message || 'Unable to update local-only commercial events.';
}
export function getTelemetryUpdateErrorMessage(message?: string): string {

View file

@ -90,7 +90,7 @@ const (
const (
patrolQuickstartCreditsExhaustedReason = "Quickstart credits exhausted. Connect your API key to continue using AI Patrol."
patrolQuickstartActivationRequiredReason = "Activate this install or start a trial to use AI Patrol quickstart. Otherwise connect your API key."
patrolQuickstartActivationRequiredReason = "Connect your API key to use AI Patrol on this install. Hosted quickstart requires an activated entitlement."
patrolQuickstartUnavailableReason = "Quickstart credits require internet access. Connect your API key for offline AI Patrol."
)

View file

@ -4,6 +4,7 @@ import (
"context"
"crypto/ed25519"
"encoding/base64"
"strings"
"testing"
"time"
@ -145,6 +146,9 @@ func TestQuickstartReasonExportsRemainCanonical(t *testing.T) {
if got := QuickstartActivationRequiredReason(); got != patrolQuickstartActivationRequiredReason {
t.Fatalf("QuickstartActivationRequiredReason() = %q, want %q", got, patrolQuickstartActivationRequiredReason)
}
if strings.Contains(strings.ToLower(QuickstartActivationRequiredReason()), "trial") {
t.Fatalf("activation-required quickstart reason must stay BYOK-first, got %q", QuickstartActivationRequiredReason())
}
}
func TestPersistentQuickstartCreditManager_LoadsPersistedTokenWithoutBootstrap(t *testing.T) {

View file

@ -45,8 +45,8 @@ func (r *Router) registerOrgLicenseRoutesGroup(orgHandlers *OrgHandlers, rbacHan
r.mux.HandleFunc("POST /api/license/monitored-system-ledger/preview", RequireAuth(r.config, r.handleMonitoredSystemLedgerPreview))
r.mux.HandleFunc("POST /api/license/trial/start", RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, r.licenseHandlers.HandleStartTrial)))
// Local-only upgrade events (legacy route names retain "upgrade-metrics" for compatibility).
// These are local-only signals used to improve in-app upgrade flows; no external export.
// Local-only commercial handoff events (legacy route names retain "upgrade-metrics" for compatibility).
// These are local-only signals used to debug explicit paid-plan handoffs; no external export.
r.mux.HandleFunc("POST /api/upgrade-metrics/events", RequireAuth(r.config, conversionHandlers.HandleRecordEvent))
r.mux.HandleFunc("GET /api/upgrade-metrics/stats", RequireAuth(r.config, conversionHandlers.HandleGetStats))
r.mux.HandleFunc("GET /api/upgrade-metrics/health", RequireAuth(r.config, conversionHandlers.HandleGetHealth))

View file

@ -1049,7 +1049,7 @@ func load(initLogging bool) (*Config, error) {
cfg.DisableLocalUpgradeMetrics = disabled
cfg.EnvOverrides["PULSE_DISABLE_LOCAL_UPGRADE_METRICS"] = true
cfg.EnvOverrides["disableLocalUpgradeMetrics"] = true
log.Info().Bool("disabled", disabled).Msg("Overriding local-only upgrade events setting from environment")
log.Info().Bool("disabled", disabled).Msg("Overriding local-only commercial events setting from environment")
} else {
log.Warn().Str("value", disableLocalUpgradeMetricsStr).Msg("Invalid PULSE_DISABLE_LOCAL_UPGRADE_METRICS value, ignoring")
}

View file

@ -62,6 +62,7 @@ func TestLoad_EnvOverrides(t *testing.T) {
t.Setenv("PULSE_DATA_DIR", tempDir)
t.Setenv("HTTPS_ENABLED", "true")
t.Setenv("PULSE_AUTH_USER", "admin")
t.Setenv("PULSE_DISABLE_LOCAL_UPGRADE_METRICS", "true")
cfg, err := Load()
require.NoError(t, err)
@ -70,6 +71,9 @@ func TestLoad_EnvOverrides(t *testing.T) {
assert.Equal(t, tempDir, cfg.DataPath)
assert.True(t, cfg.HTTPSEnabled)
assert.Equal(t, "admin", cfg.AuthUser)
assert.True(t, cfg.DisableLocalUpgradeMetrics)
assert.True(t, cfg.EnvOverrides["PULSE_DISABLE_LOCAL_UPGRADE_METRICS"])
assert.True(t, cfg.EnvOverrides["disableLocalUpgradeMetrics"])
}
func TestLoad_DotEnv(t *testing.T) {

View file

@ -1372,8 +1372,8 @@ type SystemSettings struct {
DisableDockerUpdateActions bool `json:"disableDockerUpdateActions"` // Hide update buttons while still detecting updates
// UX + privacy knobs (server-wide)
ReduceProUpsellNoise bool `json:"reduceProUpsellNoise,omitempty"` // Hide proactive Pro prompts; paywalls still appear when accessing gated features
DisableLocalUpgradeMetrics bool `json:"disableLocalUpgradeMetrics,omitempty"` // Disable local-only upgrade UX metrics collection (no network calls)
ReduceProUpsellNoise bool `json:"reduceProUpsellNoise,omitempty"` // Legacy compatibility preference for proactive commercial prompts
DisableLocalUpgradeMetrics bool `json:"disableLocalUpgradeMetrics,omitempty"` // Disable local-only commercial handoff metrics collection (no network calls)
// Telemetry (enabled by default, opt-out)
TelemetryEnabled *bool `json:"telemetryEnabled,omitempty"` // Send anonymous usage telemetry (install ID, version, resource counts, feature flags — no PII)

View file

@ -5,7 +5,7 @@ import { ensureAuthenticated } from "./helpers";
const QUICKSTART_EXHAUSTED_REASON =
"Quickstart credits exhausted. Connect your API key to continue using Patrol.";
const QUICKSTART_ACTIVATION_REQUIRED_REASON =
"Activate this install or start a trial to use Patrol quickstart. Otherwise connect your API key.";
"Connect your API key to use AI Patrol on this install. Hosted quickstart requires an activated entitlement.";
const QUICKSTART_OFFLINE_REASON =
"Quickstart credits require internet access. Connect your API key to use Patrol offline.";

View file

@ -98,5 +98,8 @@ test.describe.serial('Self-hosted paid prompt visibility', () => {
await expect(page.getByRole('button', { name: 'Plans & Activation' })).toHaveCount(0);
await expect(page.getByRole('link', { name: /upgrade to pro/i })).toHaveCount(0);
await expect(page.getByRole('button', { name: /start free trial/i })).toHaveCount(0);
await expect(page.getByRole('button', { name: /start trial/i })).toHaveCount(0);
await expect(page.getByText(/free 14-day trial/i)).toHaveCount(0);
await expect(page.getByText(/open hosted handoff/i)).toHaveCount(0);
});
});