Skip to content

Commit a9c3458

Browse files
Gate Team tier: remove self-serve buy CTA (CEO directive: Team not rolled out) (#169)
Per the 2026-06-04 HARD CEO directive, the Team plan ($199 "unlimited") is NOT rolled out and must not be presented as a buyable/self-serve plan until its unlimited-resource delivery is proven built. This DELIBERATELY REVERSES DOG-10 (2026-05-29), which had removed the contact-sales CTA and pointed Team at /app/checkout — that change is overridden and must not be re-applied. - PricingPage: Team card CTA → "Contact sales →" mailto (sales@), no longer routes to /app/checkout on either billing cycle. Gate comment added. - ChangePlanModal: drop `team` from SELECTABLE_TIERS so the in-app upgrade modal never offers Team. A Pro/Growth user now hits the existing "no upgrades — contact support" empty state (the honest sales-assisted exit). - public/llms.txt: Team described as "available soon — not yet self-serve", no longer listed among the public self-serve tiers. - fetch-content.mjs + llmsContract.test.ts: pin the gating wording with a build-time guard + contract test so a content sync can't silently re-market Team as buyable. - Tests reversed (failing-then-passing): PricingPage asserts the Team CTA does NOT contain /app/checkout and surfaces contact-sales; ChangePlanModal asserts `team` is not a selectable target. npm run gate: green (80 files, 1115 passed / 3 skipped). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent bcb0ee1 commit a9c3458

7 files changed

Lines changed: 118 additions & 62 deletions

File tree

public/llms.txt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ The `email` field must parse as a valid RFC 5322 address (validated via Go `mail
8989

9090
## Tiers
9191

92-
The public, self-serve tiers visible at `/pricing` are: Anonymous, Hobby, Pro, and Team.
92+
The public, self-serve tiers visible at `/pricing` are: Anonymous, Hobby, and Pro. Team is not yet a self-serve tier — it is available soon; contact support@instanode.dev for onboarding.
9393
Hobby Plus and Growth exist in `plans.yaml` as upsell-only intermediate tiers (reachable
9494
via dashboard prompts when a Hobby user hits a wall) and are deliberately omitted from
9595
the public tier ladder to keep the customer-facing comparison simple. Both still surface
@@ -100,7 +100,7 @@ on `/api/v1/capabilities` for agent introspection.
100100

101101
> **Upgrading auto-promotes in-flight deployment TTLs.** When a team upgrades to any paid tier (Hobby / Hobby Plus / Pro / Growth / Team), the Razorpay subscription.charged webhook flips the team's `default_deployment_ttl_policy` from `auto_24h` to `permanent` (so every future `POST /deploy/new` defaults to no TTL) AND promotes every existing `auto_24h` non-terminal deploy to permanent (clearing `expires_at`). Per-deploy `ttl_policy='custom'` and `ttl_policy='permanent'` rows are never touched — only the `auto_24h` class is rolled forward. To restore the 24h-default behaviour after an upgrade, `PATCH /api/v1/team/settings {"default_deployment_ttl_policy":"auto_24h"}`.
102102
- **Pro**: $49/mo. 10 GB Postgres, 512 MB Redis, 5 GB Mongo, 50 GB storage, 10 apps.
103-
- **Team**: $199/mo. Unlimited everything (Postgres / Redis / Mongo / queues / storage / deployments / vault entries), 50 custom domains, 90-day backups with self-serve restore, RBAC + audit log. SSO/SAML and a 99.9% SLA are coming soon (not yet available). Self-serve assisted-Razorpay path is rolling out — for now contact support@instanode.dev for onboarding.
103+
- **Team**: available soon — not yet self-serve. Planned at $199/mo with unlimited everything (Postgres / Redis / Mongo / queues / storage / deployments / vault entries), 50 custom domains, 90-day backups with self-serve restore, RBAC + audit log; SSO/SAML and a 99.9% SLA are also planned. Team cannot be purchased or claimed today — contact support@instanode.dev for onboarding.
104104

105105
## Conventions an LLM should follow when scripting against the platform
106106

scripts/fetch-content.mjs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,14 @@ const SYNC_FILES = [
6666
// Markers MUST be kept in sync with the assertions in
6767
// src/lib/llmsContract.test.ts — see rule 18 (registry-iterating
6868
// regression test) and rule 22 (contract changes touch all surfaces).
69-
requireMarkers: ['redeploy=true', '"redeployed":'],
69+
//
70+
// TEAM-GATE (2026-06-04 CEO directive): the 'not yet a self-serve tier'
71+
// marker guards the Team-tier gating copy. If an upstream content-repo
72+
// sync ever drops the gating (reverting Team to a public self-serve
73+
// tier), this PRESERVES the committed public/llms.txt so the build can't
74+
// silently re-market Team as buyable. The content repo currently carries
75+
// the same wording, so the guard is a no-op today.
76+
requireMarkers: ['redeploy=true', '"redeployed":', 'not yet a self-serve tier'],
7077
},
7178
]
7279

src/components/ChangePlanModal.test.tsx

Lines changed: 45 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -91,40 +91,49 @@ describe('ChangePlanModal — target tier rendering', () => {
9191
expect(screen.queryByTestId('change-plan-target-hobby')).toBeNull()
9292
})
9393

94-
it('exposes hobby_plus / pro / team as upgrade targets for a hobby user', () => {
94+
it('exposes hobby_plus / pro as upgrade targets for a hobby user — but NOT team (TEAM-GATE 2026-06-04)', () => {
9595
// FIX-R9 (W11): hobby_plus is now a real plan in api/plans.yaml ($19/mo)
9696
// and must show up between Hobby and Pro in the upgrade picker.
97+
// TEAM-GATE (2026-06-04 CEO directive): `team` is NO LONGER a self-serve
98+
// in-app upgrade target — Team ($199 "unlimited") is not rolled out. A
99+
// user wanting Team goes through sales (the no-upgrades / support exit),
100+
// not this radio. Do NOT re-add the team assertion.
97101
render(<ChangePlanModal currentTier="hobby" onClose={() => {}} />)
98102
expect(screen.queryByTestId('change-plan-target-hobby_plus')).toBeTruthy()
99103
expect(screen.queryByTestId('change-plan-target-pro')).toBeTruthy()
100-
expect(screen.queryByTestId('change-plan-target-team')).toBeTruthy()
104+
expect(screen.queryByTestId('change-plan-target-team')).toBeNull()
101105
})
102106

103-
it('hides growth from the upgrade picker (FIX-190 — no real self-serve growth row)', () => {
107+
it('hides growth AND team from the upgrade picker (sales-only tiers)', () => {
104108
// Growth is operator-only / sales-only — there is no real growth row
105-
// reachable via this modal. Asserted on a hobby user (where every
106-
// higher tier would otherwise have shown) and on the no-upgrade team
107-
// ceiling (covered separately below).
109+
// reachable via this modal. Team is sales-gated per the 2026-06-04 CEO
110+
// directive (TEAM-GATE) until its unlimited-resource delivery is built.
108111
render(<ChangePlanModal currentTier="hobby" onClose={() => {}} />)
109112
expect(screen.queryByTestId('change-plan-target-growth')).toBeNull()
113+
expect(screen.queryByTestId('change-plan-target-team')).toBeNull()
110114
})
111115

112-
it('hides hobby/hobby_plus as downgrades for a pro user — only team remains', () => {
116+
it('shows the no-upgrades empty state for a pro user (only Team is above, and Team is sales-gated)', () => {
117+
// TEAM-GATE (2026-06-04): a Pro user's only tier up is Team, which is no
118+
// longer self-serve. With team removed from SELECTABLE_TIERS, the modal
119+
// has zero valid upgrade targets and falls through to the "highest plan
120+
// available through self-serve — contact support" empty state. This
121+
// REVERSES the prior "only team remains" assertion.
113122
render(<ChangePlanModal currentTier="pro" onClose={() => {}} />)
114-
expect(screen.queryByTestId('change-plan-target-hobby')).toBeNull()
115-
expect(screen.queryByTestId('change-plan-target-hobby_plus')).toBeNull()
116-
expect(screen.queryByTestId('change-plan-target-growth')).toBeNull()
117-
expect(screen.queryByTestId('change-plan-target-team')).toBeTruthy()
123+
expect(screen.queryByTestId('change-plan-target-team')).toBeNull()
124+
expect(screen.getByTestId('change-plan-no-upgrades')).toBeTruthy()
125+
expect((screen.getByTestId('change-plan-confirm') as HTMLButtonElement).disabled).toBe(true)
118126
})
119127

120-
it('shows pro + team but hides hobby as downgrade for a hobby_plus user', () => {
121-
// FIX-R9: hobby_plus users upgrading should see Pro and Team only —
122-
// hobby is below them and growth is not self-serve.
128+
it('shows pro but hides hobby/team for a hobby_plus user (Team is sales-gated)', () => {
129+
// FIX-R9: hobby_plus users upgrading should see Pro — hobby is below
130+
// them, growth is not self-serve, and Team is sales-gated (TEAM-GATE
131+
// 2026-06-04). Pro is the only self-serve upgrade.
123132
render(<ChangePlanModal currentTier="hobby_plus" onClose={() => {}} />)
124133
expect(screen.queryByTestId('change-plan-target-hobby')).toBeNull()
125134
expect(screen.queryByTestId('change-plan-target-hobby_plus')).toBeNull()
126135
expect(screen.queryByTestId('change-plan-target-pro')).toBeTruthy()
127-
expect(screen.queryByTestId('change-plan-target-team')).toBeTruthy()
136+
expect(screen.queryByTestId('change-plan-target-team')).toBeNull()
128137
})
129138

130139
it('renders the no-upgrades empty state with a support link for team users', () => {
@@ -140,19 +149,31 @@ describe('ChangePlanModal — target tier rendering', () => {
140149
expect(link.href).toContain('mailto:support@instanode.dev')
141150
})
142151

143-
it('preselects defaultTargetTier when it is a valid upgrade', () => {
152+
it('preselects defaultTargetTier when it is a valid (self-serve) upgrade', () => {
153+
render(<ChangePlanModal currentTier="hobby" defaultTargetTier="pro" onClose={() => {}} />)
154+
const pro = screen.getByTestId('change-plan-target-pro') as HTMLInputElement
155+
expect(pro.checked).toBe(true)
156+
})
157+
158+
it('drops a sales-gated defaultTargetTier=team and falls back to a valid self-serve upgrade (TEAM-GATE)', () => {
159+
// TEAM-GATE (2026-06-04): a caller (e.g. BillingPage NEXT_CHANGE_PLAN_TIER)
160+
// may still pass defaultTargetTier="team", but team is no longer a
161+
// selectable self-serve upgrade. The modal must drop it and preselect the
162+
// lowest valid upgrade instead of honouring the now-gated team suggestion.
144163
render(<ChangePlanModal currentTier="hobby" defaultTargetTier="team" onClose={() => {}} />)
145-
const team = screen.getByTestId('change-plan-target-team') as HTMLInputElement
146-
expect(team.checked).toBe(true)
164+
expect(screen.queryByTestId('change-plan-target-team')).toBeNull()
165+
// Lowest valid upgrade above Hobby is Hobby Plus.
166+
const hobbyPlus = screen.getByTestId('change-plan-target-hobby_plus') as HTMLInputElement
167+
expect(hobbyPlus.checked).toBe(true)
147168
})
148169

149-
it('drops defaultTargetTier when it would be a downgrade and picks the lowest upgrade', () => {
150-
// Pro user with a defaultTargetTier of hobby → the upgrade list is just
151-
// ['team'], and the modal should preselect team rather than honour the
152-
// (illegal) hobby suggestion.
170+
it('shows the no-upgrades empty state for a pro user even with a defaultTargetTier (TEAM-GATE)', () => {
171+
// Pro user with any defaultTargetTier → the only tier above is Team,
172+
// which is sales-gated, so there are zero valid self-serve upgrades and
173+
// the modal renders the contact-support empty state.
153174
render(<ChangePlanModal currentTier="pro" defaultTargetTier="hobby" onClose={() => {}} />)
154-
const team = screen.getByTestId('change-plan-target-team') as HTMLInputElement
155-
expect(team.checked).toBe(true)
175+
expect(screen.getByTestId('change-plan-no-upgrades')).toBeTruthy()
176+
expect(screen.queryByTestId('change-plan-target-team')).toBeNull()
156177
})
157178
})
158179

src/components/ChangePlanModal.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,15 @@ const TIER_LABEL: Record<ChangePlanTier, string> = {
5353
// the $19/mo step between Hobby and Pro), drop growth — there is no real
5454
// growth row available through self-serve upgrade yet. Customers who need
5555
// growth-tier dedicated infra go through support / sales, not this modal.
56-
const SELECTABLE_TIERS: ChangePlanTier[] = ['hobby', 'hobby_plus', 'pro', 'team']
56+
//
57+
// TEAM-GATE (2026-06-04 CEO directive): `team` is REMOVED from the selectable
58+
// set — Team ($199 "unlimited") is not rolled out and must not be offered as
59+
// a self-serve in-app upgrade until its unlimited-resource delivery is proven
60+
// built. A Pro/Growth user whose only path up is Team now falls through to the
61+
// modal's "no upgrades — contact support" empty state, which is the honest,
62+
// sales-assisted exit. Do NOT re-add 'team' here. Ref:
63+
// docs/sessions/2026-06-04/TEAM-PLAN-GATE-AND-BUILD.md.
64+
const SELECTABLE_TIERS: ChangePlanTier[] = ['hobby', 'hobby_plus', 'pro']
5765

5866
export interface ChangePlanModalProps {
5967
/** The team's current tier — read from useDashboardCtx().me.team.tier on

src/lib/llmsContract.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,18 @@ const CONTRACT_MARKERS: DocsMarker[] = [
5959
file: 'public/llms-full.txt',
6060
mustContain: ['redeploy=true', '"redeployed":', 'no_matching_deployment'],
6161
},
62+
{
63+
// TEAM-GATE (2026-06-04 CEO directive): Team is NOT self-serve and must
64+
// not be described as a public self-serve tier until its unlimited-
65+
// resource delivery is proven built. Pin the gating wording so a docs
66+
// revert (or a content-repo sync that still lists Team as self-serve)
67+
// fails CI. Mirrors the requireMarkers guard in fetch-content.mjs and
68+
// the PricingPage/ChangePlanModal source changes in this PR.
69+
// Ref: docs/sessions/2026-06-04/TEAM-PLAN-GATE-AND-BUILD.md.
70+
field: 'Team tier is sales-assisted, NOT self-serve (CEO gate 2026-06-04)',
71+
file: 'public/llms.txt',
72+
mustContain: ['not yet a self-serve tier'],
73+
},
6274
]
6375

6476
describe('agent docs contract — POST /deploy/new redeploy=true', () => {

src/pages/PricingPage.test.tsx

Lines changed: 27 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -140,14 +140,11 @@ describe('PricingPage — four public tier cards present (M11 regression guard)'
140140
expect(hobbyPlusCells.length).toBe(0)
141141
})
142142

143-
it('paid-tier CTAs are clickable links (not disabled spans) for hobby / pro / team', () => {
143+
it('paid-tier CTAs are clickable links for hobby / pro / team', () => {
144144
renderPage()
145-
// 2026-05-20 DOC-REALITY-DELTA: Team tier launched. DOG-10 (2026-05-29):
146-
// Team CTA flipped from mailto: to self-serve /app/checkout?plan=team
147-
// now that api#168 + dashboard #106 enabled the Razorpay flow. plans.yaml
148-
// has team at $199/mo with every limit -1 (unlimited). If Team flips back
149-
// to "coming soon", it'll be a disabled span and this test fails — the
150-
// regression guard.
145+
// Hobby/Pro are self-serve (/app/checkout). Team is a clickable
146+
// contact-sales mailto link (TEAM-GATE 2026-06-04) — still an <a>
147+
// with an href, just not a checkout one (asserted below).
151148
for (const tier of ['hobby', 'pro', 'team']) {
152149
const cta = screen.getByTestId(`pricing-cta-${tier}`)
153150
// <a> with href, not <span aria-disabled>.
@@ -156,36 +153,44 @@ describe('PricingPage — four public tier cards present (M11 regression guard)'
156153
}
157154
})
158155

159-
it('Team CTA points to /app/checkout?plan=team (DOG-10 self-serve, no mailto) — monthly default', () => {
156+
it('Team CTA does NOT route to /app/checkout — it is contact-sales (TEAM-GATE 2026-06-04)', () => {
157+
// TEAM-GATE (2026-06-04 CEO directive): this DELIBERATELY REVERSES
158+
// DOG-10 (2026-05-29), which had pointed Team at /app/checkout?plan=team.
159+
// Team ($199 "unlimited") is NOT rolled out and must not be sold
160+
// self-serve until its unlimited-resource delivery is proven built.
161+
// The CTA is a sales-assisted mailto, NOT a checkout link. If a future
162+
// change re-points Team at /app/checkout, this test fails — that is the
163+
// regression guard. Ref docs/sessions/2026-06-04/TEAM-PLAN-GATE-AND-BUILD.md.
160164
renderPage()
161-
// DOG-10 (2026-05-29): Team self-serve via /app/checkout?plan=team.
162-
// Default is monthly; the yearly path is asserted via the dedicated
163-
// ?frequency=yearly URL-param test below.
164165
const cta = screen.getByTestId('pricing-cta-team')
165166
const href = cta.getAttribute('href') ?? ''
166-
expect(href).not.toMatch(/^mailto:/)
167-
expect(href).toContain('/app/checkout')
168-
expect(href).toContain('plan=team')
169-
expect(href).toContain('frequency=monthly')
167+
expect(href).not.toContain('/app/checkout')
168+
expect(href).not.toContain('plan=team')
169+
expect(href.startsWith('mailto:')).toBe(true)
170+
// Label surfaces a contact-sales / coming-soon affordance, not "Start team".
171+
expect((cta.textContent ?? '').toLowerCase()).toContain('contact sales')
170172
})
171173

172-
it('Team CTA carries frequency=yearly when ?frequency=yearly URL param is set (DOG-10)', () => {
174+
it('Team CTA stays contact-sales even when ?frequency=yearly is set (no checkout on yearly)', () => {
175+
// The yearly toggle must not unlock a self-serve Team checkout. The
176+
// Team card has no yearly checkout variant, so the CTA falls back to
177+
// the same contact-sales mailto on both cycles.
173178
render(
174179
<MemoryRouter initialEntries={['/pricing?frequency=yearly']}>
175180
<PricingPage />
176181
</MemoryRouter>,
177182
)
178183
const cta = screen.getByTestId('pricing-cta-team')
179184
const href = cta.getAttribute('href') ?? ''
180-
expect(href).not.toMatch(/^mailto:/)
181-
expect(href).toContain('/app/checkout?plan=team&frequency=yearly')
185+
expect(href).not.toContain('/app/checkout')
186+
expect(href.startsWith('mailto:')).toBe(true)
182187
})
183188

184-
it('Team tier shows $199/mo (DOC-REALITY-DELTA 2026-05-20 launch)', () => {
189+
it('Team tier shows $199/mo', () => {
185190
renderPage()
186-
// Team launched per plans.yaml:375 ($199/mo, $1990/yr). Marketing
187-
// surface must mirror the registry. Yearly CTA reuses mailto: until
188-
// assisted-Razorpay flow ships.
191+
// Team price still displays on the comparison surface ($199/mo per
192+
// plans.yaml:375) — the gate removes the self-serve buy path, not the
193+
// tier's visibility.
189194
const body = document.body.textContent ?? ''
190195
expect(body).toContain('$199')
191196
})

src/pages/PricingPage.tsx

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -85,24 +85,27 @@ const TIERS: {
8585
ctaHrefYearly: '/app/checkout?plan=pro&frequency=yearly',
8686
highlighted: true,
8787
},
88-
// Team tier — launched 2026-05-20 (DOC-REALITY-DELTA sweep). Limits are
89-
// unlimited across the board; the upsell vs Pro is dedicated infra +
90-
// 90-day backup retention + SLA + RBAC + SAML. Per api/plans.yaml:375
91-
// ($199/mo, $1990/yr). Razorpay plan IDs are configured server-side.
88+
// Team tier — $199/mo, unlimited across the board; the upsell vs Pro is
89+
// dedicated infra + 90-day backup retention + SLA + RBAC + SAML. Per
90+
// api/plans.yaml:375 ($199/mo, $1990/yr).
9291
{
9392
key: 'team',
9493
name: 'Team',
9594
monthly: { price: '$199', sub: '/ mo' },
9695
// team_yearly: $1990/yr ≈ $165.83/mo (~17% off $199 x 12).
9796
yearly: { price: '$165.83', sub: '/ mo billed yearly', saveLabel: 'save $398/yr' },
98-
cta: 'Start team →',
99-
// Team checkout is self-serve as of api#168 + dashboard #106 (Razorpay
100-
// plan IDs configured server-side). DOG-10: previous mailto CTA leaked
101-
// mid-funnel conversions on the $199/mo AOV path; checkout now matches
102-
// the Hobby/Pro pattern so the marketing "Self-serve at every tier"
103-
// promise above the comparison table is honored.
104-
ctaHrefMonthly: '/app/checkout?plan=team&frequency=monthly',
105-
ctaHrefYearly: '/app/checkout?plan=team&frequency=yearly',
97+
cta: 'Contact sales →',
98+
// TEAM-GATE (2026-06-04 CEO directive): Team is NOT self-serve and must
99+
// not route to /app/checkout until its unlimited-resource delivery is
100+
// proven built. This DELIBERATELY REVERSES DOG-10 (2026-05-29), which
101+
// had flipped Team's CTA from a contact-sales mailto to a self-serve
102+
// /app/checkout?plan=team link. DOG-10's "Self-serve at every tier"
103+
// rationale is overridden: Team is sales-assisted only for now. Do NOT
104+
// re-point this at /app/checkout. Ref:
105+
// docs/sessions/2026-06-04/TEAM-PLAN-GATE-AND-BUILD.md.
106+
// mailto on both cycles — there is no self-serve checkout for Team yet,
107+
// so the yearly toggle reuses the same contact-sales action.
108+
ctaHrefMonthly: 'mailto:sales@instanode.dev?subject=Team%20plan%20enquiry',
106109
},
107110
]
108111

0 commit comments

Comments
 (0)