---
title: "Pro activation deep-link targeted API Keys, a capability the Pro plan does not have"
date: 2026-07-25
category: logic-errors
module: pro-activation
problem_type: logic_error
component: payments
severity: high
symptoms:
- "Activation wizard step 3 read 'Get your API & MCP keys' and deep-linked to Settings -> API Keys, which renders 'Upgrade to API Starter' for a user who had just paid for Pro"
- "Pro entitlement is apiAccess:false / mcpAccess:true (convex/config/productCatalog.ts:138,149), so the promised capability never existed on that plan"
- "Naively repointing the link at the MCP tab would have traded the upsell for a settings modal with no active panel, because that tab and its panel are both entitlement-gated"
- "A capability read captured into the flow-options closure could go stale before the pointer was clicked, since ProActivationChip replays the captured object"
root_cause: wrong_api
resolution_type: code_fix
related_components: [testing_framework]
tags: [entitlements, pro-activation, deep-link, mcp, capability-gating, stale-closure, test-fidelity]
---
# Pro activation deep-link targeted API Keys, a capability the Pro plan does not have
## Problem
The Pro activation wizard's step-3 "power toolkit" card advertised **"Get your API & MCP keys"** and deep-linked into Settings -> **API Keys**. The Pro plan's feature set is `apiAccess: false, mcpAccess: true`:
```ts
// convex/config/productCatalog.ts:135-150
const PRO_FEATURES: PlanFeatures = {
tier: 1,
apiAccess: false,
// ...
mcpAccess: true,
};
```
So the card sold a capability the plan does not include, and routed the buyer to a panel whose entitlement gate immediately offered them **"Upgrade to API Starter"** — seconds after they paid for Pro. (Issue #5607, fixed in PR #5613.)
## Symptoms
- Step-3 copy promised API keys to a plan with `apiAccess: false`.
- The deep link landed on the API Keys tab, which renders an upgrade CTA for any user without `apiAccess`.
- Both were reached only on the post-purchase path, so the failure was invisible outside a live purchase repro.
## What Didn't Work
**Repointing the deep link at the MCP tab, unconditionally.** This is the obvious one-line fix and it is wrong. `UnifiedSettings` gates *both* the MCP tab button and its panel on the same feature:
```ts
// src/components/UnifiedSettings.ts:518 (tab button)
${hasFeature('mcpAccess') ? `` : ''}
// src/components/UnifiedSettings.ts:560-563 (panel)
${hasFeature('mcpAccess') ? `
...
` : ''}
```
and `open()` assigns the target with no check that it is rendered:
```ts
// src/components/UnifiedSettings.ts:343
if (tab) this.activeTab = tab;
```
An ungated `open('mcp-clients')` therefore swaps a wrong-upsell bug for a *blank modal* bug whenever the feature is absent.
**Gating on the plan key instead of the feature.** Tempting, because the activation flow already runs only for `pro_monthly` / `pro_annual`. But the panel renders on `hasFeature('mcpAccess')`, so a plan-key gate can disagree with the thing it is trying to predict. Gate on the same signal the destination gates on.
**Reading the capability once, when the options object is built.** This survives the interstitial (short-lived, opened immediately post-purchase) but not the finish-setup chip, which replays the *captured* options object much later:
```ts
// src/components/ProActivationChip.ts:134
void openProActivationFlow(options).catch((err) =>
```
A build-time-only check can therefore deep-link into a tab that stopped rendering in the interim.
## Solution
Point the pointer at the capability Pro actually has, gate it on that capability, and re-read the capability at click time:
```ts
// src/app/pro-activation-controller.ts:582-590
openMcpClients: hasFeature('mcpAccess')
? () => {
// Re-check at click time, not just here: the finish-setup chip
// replays this captured options object long after it was built, so
// an entitlement that lapsed in between would otherwise deep-link
// to a tab UnifiedSettings no longer renders.
if (hasFeature('mcpAccess')) ctx.unifiedSettings?.open('mcp-clients');
else ctx.unifiedSettings?.open('settings');
}
: undefined,
```
Leaving the opener `undefined` is the suppression mechanism — `buildPowerExtra` drops any pointer without an opener:
```ts
// src/components/ProActivationInterstitial.ts:1025
if (typeof open !== 'function') return;
```
Copy became "Set up MCP", and the i18n key `components.proActivation.steps.power.pointers.apiKeys` was renamed to `.mcpClients` across all 25 locales.
## Why This Works
The gate and the destination now consult the same predicate, so the pointer exists exactly when the panel it targets exists. The click-time re-read closes the build->click window that the chip's replay opens, and the `settings` fallback is a tab that renders unconditionally — so no path reaches a modal with no active panel.
## Prevention
**1. Gate a deep link on the capability its destination renders on — not on the plan, tier, or role you believe implies it.** Plan-to-capability mappings drift; the destination's own predicate does not. When the target is conditionally rendered, suppressing the entry point beats pointing it somewhere broken.
**2. A capability read that gets captured into a long-lived closure must be re-read at use time.** Build-time and click-time are different moments whenever the options object outlives the interaction that built it. Ask what replays the closure — here it was a "finish setup later" chip.
**3. Do not reason about "legacy rows predating a field" in this repo without checking the read path.** Entitlements read-merge catalog defaults, so a row written before a feature flag existed still resolves the current default:
```ts
// convex/entitlements.ts:50-53
const catalogDefaults = getFeaturesForPlan(entitlement.planKey);
return {
planKey: entitlement.planKey,
features: { ...catalogDefaults, ...entitlement.features },
```
The original fix comment justified its gate with legacy snapshots; that rationale was wrong — the gate's real (narrow) purpose is an explicit per-user override. A comment that misstates *why* a guard exists is how the guard gets deleted later. Note `src/services/entitlements.ts:28-33` still carries the same stale "wait for the next webhook" framing.
**4. Make a test stub mirror production exactly, then let the fixtures fail.** The stub for `hasFeature` used optional chaining (`state.features?.[flag]`) where production indexes directly:
```ts
// src/services/entitlements.ts:156
return Boolean(currentState.features[flag]);
```
Tightening the stub to match immediately threw on two fixtures that built an `EntitlementState` with no `features` — a shape the Convex query never returns. The lenient stub had been quietly reporting `false` for them. A stub more forgiving than production converts fixture drift into silent green.
**5. An assertion that something is *absent* is vacuous unless the harness could have produced it.** The e2e guard
```ts
await expect(page.locator('.pro-activation-pointer[data-pointer="apiKeys"]')).toHaveCount(0);
```
could not fail: the harness never injected `openApiKeys`, and `add()` skips openerless pointers, so a reintroduced pointer would have been dropped by the harness rather than by the code under test. Injecting the retired opener gave it teeth — confirmed by mutation (reintroducing the pointer produced `Received: 1`).
**6. Mutation-verify each new assertion against the specific regression it guards.** Every assertion in this fix was confirmed red before being trusted: reverting the copy, removing the click-time re-check, reintroducing an `apiKeys` pointer, and both unconditional and inverted gate mutations.
## Related
- [Billing state: cancelled-but-paid-through misclassified as lapsed](../logic-errors/billing-state-cancelled-but-paid-through-misclassified-as-lapsed.md) — same failure family: a client-side derivation disagreeing with the server predicate it was meant to mirror.
- [i18n shell namespaces are a byte-budgeted first-paint surface](../conventions/i18n-shell-namespaces-are-byte-budgeted-first-paint-surface.md) — why the 25-locale rename here did not touch `en.shell.json`.
- Follow-ups filed from this work: #5611 (`UnifiedSettings.open()` can leave the modal with no active panel — the general case behind prevention rule 1), #5612 (power step re-sells MCP to users who already connected a client).