lean_ctx/core/billing/plans.rs
1//! Commercial-plane plans and their entitlements (`billing-plane-v1`, EPIC 13.6).
2//!
3//! Plans describe **additive, opt-in** coordination/hosting/scale/governance
4//! capabilities on the Team/Cloud plane. They never gate a local capability:
5//! `entitlement_allows` returns `true` for every local-always-on feature on
6//! **every** plan, including [`Plan::Free`]. That is the Local-Free Invariant
7//! (RFC §4) expressed in the billing layer and is enforced by the unit tests
8//! plus the conformance test in `tests/local_free_invariant.rs`.
9
10use serde::{Deserialize, Serialize};
11
12use crate::core::server_capabilities::LOCAL_ALWAYS_ON_FEATURES;
13
14/// Sentinel for an unbounded/negotiated quota. Distinct from `0` (which means
15/// *none*), so "no hosted index" (Free) is never rendered as "unlimited".
16pub const UNBOUNDED: u32 = u32::MAX;
17
18/// A commercial-plane plan. The local engine is fully usable with no plan
19/// (equivalent to [`Plan::Free`]); plans only add coordination/scale/governance.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(rename_all = "snake_case")]
22pub enum Plan {
23 /// The default: full local Context OS, no account required. No commercial
24 /// entitlements, no metered ceilings on local use.
25 Free,
26 /// Individual **Supporter**: a *voluntary* recurring subscription that funds
27 /// development. Grants only account-level recognition (a supporter badge) and
28 /// convenience — **never** a local capability, and none of the Team/Cloud
29 /// coordination entitlements. (The `sponsor` alias names its top tier.)
30 Supporter,
31 /// Individual **Pro** ("Personal Cloud"): a *paid*, account-bound subscription
32 /// that adds the `cloud_sync` entitlement (hosted cross-device sync + backup of
33 /// the user's *own* context) on top of supporter recognition. Still **never** a
34 /// local capability and none of the Team/Cloud coordination entitlements; it
35 /// sits additively between Supporter and Team (`supporter ⊂ pro ⊂ team`).
36 Pro,
37 /// Shared team/org coordination: seats, shared knowledge, hosted retrieval.
38 Team,
39 /// Self-serve governance (GL #460/#533): everything in Team plus OIDC SSO
40 /// (`sso_oidc`), a 1-year audit window and higher flat quotas — at a flat
41 /// price, no sales motion. SAML/SCIM stay Enterprise.
42 Business,
43 /// Governance at scale: SSO/SCIM, audit retention, private registries.
44 Enterprise,
45}
46
47impl Plan {
48 /// All plans, in ascending order.
49 #[must_use]
50 pub fn all() -> &'static [Plan] {
51 &[
52 Plan::Free,
53 Plan::Supporter,
54 Plan::Pro,
55 Plan::Team,
56 Plan::Business,
57 Plan::Enterprise,
58 ]
59 }
60
61 /// Stable wire identifier.
62 #[must_use]
63 pub fn as_str(self) -> &'static str {
64 match self {
65 Plan::Free => "free",
66 Plan::Supporter => "supporter",
67 Plan::Pro => "pro",
68 Plan::Team => "team",
69 Plan::Business => "business",
70 Plan::Enterprise => "enterprise",
71 }
72 }
73
74 /// Parse a plan id (case-insensitive). Unknown ids map to [`Plan::Free`] —
75 /// the safe default that never gates anything. `pro` is its own [`Plan::Pro`];
76 /// `supporter`/`sponsor` are the voluntary [`Plan::Supporter`] tier.
77 #[must_use]
78 pub fn parse(s: &str) -> Plan {
79 match s.trim().to_ascii_lowercase().as_str() {
80 "supporter" | "sponsor" => Plan::Supporter,
81 "pro" => Plan::Pro,
82 "team" => Plan::Team,
83 "business" | "biz" => Plan::Business,
84 "enterprise" | "ent" => Plan::Enterprise,
85 _ => Plan::Free,
86 }
87 }
88
89 /// The commercial entitlements this plan grants.
90 #[must_use]
91 pub fn entitlements(self) -> Entitlements {
92 match self {
93 Plan::Free => Entitlements {
94 plan: self,
95 seats: 1,
96 hosted_index_mb: 0,
97 managed_connectors: 0,
98 private_registry: false,
99 sso_oidc: false,
100 sso_scim: false,
101 audit_retention_days: 0,
102 revenue_share: false,
103 supporter: false,
104 cloud_sync: false,
105 },
106 // Supporter is commercially identical to Free for every Team/Cloud
107 // capability (so it can never gate one); it adds only the
108 // account-level `supporter` recognition flag. It does **not** grant
109 // `cloud_sync` — that is the paid Pro tier.
110 Plan::Supporter => Entitlements {
111 plan: self,
112 seats: 1,
113 hosted_index_mb: 0,
114 managed_connectors: 0,
115 private_registry: false,
116 sso_oidc: false,
117 sso_scim: false,
118 audit_retention_days: 0,
119 revenue_share: false,
120 supporter: true,
121 cloud_sync: false,
122 },
123 // Pro = Supporter recognition + the paid Personal-Cloud
124 // capabilities: `cloud_sync` and a 1 GB hosted *personal* index
125 // bucket (GL #392 — encrypted index bundles, cross-device pull).
126 // It carries none of the Team/Cloud coordination entitlements,
127 // so `supporter ⊂ pro ⊂ team` (1 GB ≤ Team's 5 GB).
128 Plan::Pro => Entitlements {
129 plan: self,
130 seats: 1,
131 hosted_index_mb: 1_000,
132 managed_connectors: 0,
133 private_registry: false,
134 sso_oidc: false,
135 sso_scim: false,
136 audit_retention_days: 0,
137 revenue_share: false,
138 supporter: true,
139 cloud_sync: true,
140 },
141 Plan::Team => Entitlements {
142 plan: self,
143 seats: 25,
144 hosted_index_mb: 5_000,
145 managed_connectors: 5,
146 private_registry: true,
147 // Catalog-wise Team has no SSO; orgs that configured OIDC while
148 // it was Team-gated are grandfathered at the enforcement edge
149 // (control plane keeps existing configs working).
150 sso_oidc: false,
151 sso_scim: false,
152 audit_retention_days: 90,
153 revenue_share: true,
154 supporter: true,
155 cloud_sync: true,
156 },
157 // Business (GL #460/#533): self-serve governance at $149/mo flat.
158 // Team's coordination plus OIDC SSO and a 1-year audit window with
159 // doubled flat quotas — without the negotiated Enterprise surface
160 // (SAML/SCIM, unbounded quotas, 10-year audit).
161 Plan::Business => Entitlements {
162 plan: self,
163 seats: 50,
164 hosted_index_mb: 20_000,
165 managed_connectors: 10,
166 private_registry: true,
167 sso_oidc: true,
168 sso_scim: false,
169 audit_retention_days: 365,
170 revenue_share: true,
171 supporter: true,
172 cloud_sync: true,
173 },
174 Plan::Enterprise => Entitlements {
175 plan: self,
176 // `UNBOUNDED` (u32::MAX) == negotiated/unlimited. A plain `0`
177 // means *none* (e.g. Free has no hosted index), so the two are
178 // never conflated.
179 seats: UNBOUNDED,
180 hosted_index_mb: UNBOUNDED,
181 managed_connectors: UNBOUNDED,
182 private_registry: true,
183 sso_oidc: true,
184 sso_scim: true,
185 audit_retention_days: 3650,
186 revenue_share: true,
187 supporter: true,
188 cloud_sync: true,
189 },
190 }
191 }
192}
193
194/// Commercial entitlements for a plan. Every field describes a **Team/Cloud**
195/// capability; none can restrict a local feature. A quota of `0` means *none*;
196/// [`UNBOUNDED`] means unlimited/negotiated (see [`Plan::Enterprise`]).
197#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
198pub struct Entitlements {
199 pub plan: Plan,
200 /// Seats included (`0` = none, [`UNBOUNDED`] = unlimited).
201 pub seats: u32,
202 /// Hosted index size cap in MB (`0` = none, [`UNBOUNDED`] = unlimited).
203 pub hosted_index_mb: u32,
204 /// Number of managed connectors (`0` = none, [`UNBOUNDED`] = unlimited).
205 pub managed_connectors: u32,
206 /// Private extension/persona registry access.
207 pub private_registry: bool,
208 /// Self-serve OIDC SSO for the org (GL #482/#533) — sign-in via the org's
209 /// own IdP, configured without a sales motion. Business and Enterprise.
210 pub sso_oidc: bool,
211 /// SAML SSO + SCIM provisioning (the negotiated Enterprise surface).
212 pub sso_scim: bool,
213 /// Audit log retention window in days (`0` = none).
214 pub audit_retention_days: u32,
215 /// Marketplace revenue-share accounting for authors.
216 pub revenue_share: bool,
217 /// Account-level supporter recognition (the voluntary "Supporter"
218 /// subscription, of which the `sponsor` tier is the top). Drives a supporter
219 /// badge and convenience perks only; it is **not** a local capability and
220 /// never gates anything. `true` for Supporter, Pro, Team and Enterprise
221 /// (each is, at minimum, a paying supporter).
222 pub supporter: bool,
223 /// Hosted **Personal Cloud** sync: cross-device sync + backup of the user's
224 /// *own* context (knowledge, learned shell patterns, CEP scores, gotchas,
225 /// savings history) via the `/api/sync/*` endpoints. A *hosted* service, **not**
226 /// a local capability — the local engine is fully usable without it. `true` for
227 /// the paid Pro tier and, additively, Team and Enterprise.
228 pub cloud_sync: bool,
229}
230
231/// Whether `plan` permits `feature`.
232///
233/// **Local-Free Invariant:** any feature in
234/// [`LOCAL_ALWAYS_ON_FEATURES`] is allowed on *every* plan unconditionally —
235/// the local plane is never gated. Commercial features are allowed per the
236/// plan's [`Entitlements`]. Unknown features default to allowed locally
237/// (fail-open for the user, never fail-closed against the local experience).
238#[must_use]
239pub fn entitlement_allows(plan: Plan, feature: &str) -> bool {
240 if LOCAL_ALWAYS_ON_FEATURES.contains(&feature) {
241 return true;
242 }
243 let e = plan.entitlements();
244 match feature {
245 "private_registry" => e.private_registry,
246 "sso_oidc" => e.sso_oidc,
247 "sso_scim" => e.sso_scim,
248 "revenue_share" => e.revenue_share,
249 "supporter" => e.supporter,
250 "cloud_sync" => e.cloud_sync,
251 "managed_connectors" => e.managed_connectors > 0,
252 "hosted_index" => e.hosted_index_mb > 0,
253 "audit_retention" => e.audit_retention_days > 0,
254 // Any non-commercial, non-enumerated capability is a local concern.
255 _ => true,
256 }
257}
258
259/// The **lowest** plan whose [`Entitlements`] permit `feature`, or `None` when
260/// the feature is not gated at all (allowed on [`Plan::Free`] — i.e. a
261/// local-always-on or unknown/local capability, for which no upgrade is ever
262/// needed). This is the entitlement-aware basis for honest upgrade hints (#346):
263/// it answers "what is the *minimal* plan that unlocks this hosted capability?"
264/// without ever implying a local feature must be paid for.
265#[must_use]
266pub fn min_plan_for(feature: &str) -> Option<Plan> {
267 // Allowed on Free ⇒ never gated. Covers local-always-on and unknown/local
268 // capabilities (which `entitlement_allows` fails open for).
269 if entitlement_allows(Plan::Free, feature) {
270 return None;
271 }
272 // Plans are returned in ascending order, so the first match is the cheapest.
273 Plan::all()
274 .iter()
275 .copied()
276 .find(|p| entitlement_allows(*p, feature))
277}
278
279#[cfg(test)]
280mod tests {
281 use super::*;
282
283 #[test]
284 fn min_plan_for_returns_cheapest_unlocking_plan() {
285 // Hosted capabilities map to their cheapest unlocking tier.
286 assert_eq!(min_plan_for("cloud_sync"), Some(Plan::Pro));
287 assert_eq!(min_plan_for("private_registry"), Some(Plan::Team));
288 assert_eq!(min_plan_for("revenue_share"), Some(Plan::Team));
289 assert_eq!(min_plan_for("sso_oidc"), Some(Plan::Business));
290 assert_eq!(min_plan_for("sso_scim"), Some(Plan::Enterprise));
291 assert_eq!(min_plan_for("supporter"), Some(Plan::Supporter));
292 // Local-always-on and unknown/local features are never gated.
293 assert_eq!(min_plan_for("read"), None);
294 assert_eq!(min_plan_for("some_unknown_local_thing"), None);
295 for feature in LOCAL_ALWAYS_ON_FEATURES {
296 assert_eq!(
297 min_plan_for(feature),
298 None,
299 "local feature '{feature}' must never require a plan"
300 );
301 }
302 }
303
304 #[test]
305 fn plan_roundtrips_through_wire_id() {
306 for p in Plan::all() {
307 assert_eq!(Plan::parse(p.as_str()), *p);
308 }
309 assert_eq!(Plan::parse("TEAM"), Plan::Team);
310 assert_eq!(Plan::parse("garbage"), Plan::Free);
311 // `pro` is now its own plan; `supporter`/`sponsor` are the voluntary tier.
312 assert_eq!(Plan::parse("pro"), Plan::Pro);
313 assert_eq!(Plan::parse("supporter"), Plan::Supporter);
314 assert_eq!(Plan::parse("Sponsor"), Plan::Supporter);
315 assert_eq!(Plan::parse("business"), Plan::Business);
316 assert_eq!(Plan::parse("biz"), Plan::Business);
317 }
318
319 /// GL #533: Business sits strictly between Team and Enterprise — adds
320 /// self-serve OIDC SSO and a 1-year audit window, but never the negotiated
321 /// Enterprise surface (SAML/SCIM, unbounded quotas).
322 #[test]
323 fn business_is_team_plus_self_serve_governance() {
324 let team = Plan::Team.entitlements();
325 let biz = Plan::Business.entitlements();
326 let ent = Plan::Enterprise.entitlements();
327
328 // Strictly more than Team…
329 assert!(biz.seats > team.seats && biz.seats < ent.seats);
330 assert!(biz.hosted_index_mb > team.hosted_index_mb);
331 assert!(biz.managed_connectors > team.managed_connectors);
332 assert!(biz.audit_retention_days > team.audit_retention_days);
333 assert!(biz.audit_retention_days < ent.audit_retention_days);
334
335 // The defining additions: OIDC SSO yes, SAML/SCIM no.
336 assert!(biz.sso_oidc && !biz.sso_scim);
337 assert!(entitlement_allows(Plan::Business, "sso_oidc"));
338 assert!(!entitlement_allows(Plan::Business, "sso_scim"));
339 // Team's catalog has no SSO (existing configs grandfather at the edge).
340 assert!(!team.sso_oidc);
341 // Enterprise keeps both.
342 assert!(ent.sso_oidc && ent.sso_scim);
343
344 // Everything Team has, Business has too.
345 assert!(biz.private_registry && biz.revenue_share);
346 assert!(biz.supporter && biz.cloud_sync);
347 // Local features are never gated on Business either.
348 for feature in LOCAL_ALWAYS_ON_FEATURES {
349 assert!(entitlement_allows(Plan::Business, feature));
350 }
351 }
352
353 #[test]
354 fn supporter_adds_only_recognition_never_a_capability() {
355 let e = Plan::Supporter.entitlements();
356 // The recognition flag is the *only* thing it adds over Free.
357 assert!(e.supporter);
358 assert!(entitlement_allows(Plan::Supporter, "supporter"));
359 assert!(!entitlement_allows(Plan::Free, "supporter"));
360 // Supporter is recognition-only: it does NOT grant the paid cloud_sync.
361 assert!(!e.cloud_sync);
362 assert!(!entitlement_allows(Plan::Supporter, "cloud_sync"));
363 // It carries none of the Team/Cloud coordination entitlements.
364 assert_eq!(e.seats, 1);
365 assert_eq!(e.hosted_index_mb, 0);
366 assert!(!e.private_registry && !e.sso_scim && !e.revenue_share);
367 assert!(!entitlement_allows(Plan::Supporter, "private_registry"));
368 assert!(!entitlement_allows(Plan::Supporter, "sso_scim"));
369 // Local features are never gated on the supporter plane either.
370 for feature in LOCAL_ALWAYS_ON_FEATURES {
371 assert!(entitlement_allows(Plan::Supporter, feature));
372 }
373 }
374
375 #[test]
376 fn local_features_are_allowed_on_every_plan() {
377 // The billing-layer expression of the Local-Free Invariant.
378 for plan in Plan::all() {
379 for feature in LOCAL_ALWAYS_ON_FEATURES {
380 assert!(
381 entitlement_allows(*plan, feature),
382 "local feature '{feature}' must never be gated (plan {plan:?})"
383 );
384 }
385 }
386 }
387
388 #[test]
389 fn free_plan_grants_no_commercial_entitlements() {
390 let e = Plan::Free.entitlements();
391 assert_eq!(e.seats, 1);
392 assert!(!e.private_registry);
393 assert!(!e.sso_scim);
394 assert!(!e.revenue_share);
395 assert!(!e.supporter);
396 assert!(!e.cloud_sync);
397 assert!(!entitlement_allows(Plan::Free, "sso_scim"));
398 assert!(!entitlement_allows(Plan::Free, "private_registry"));
399 assert!(!entitlement_allows(Plan::Free, "cloud_sync"));
400 }
401
402 #[test]
403 fn pro_grants_cloud_sync_plus_recognition_but_no_team_capability() {
404 let e = Plan::Pro.entitlements();
405 // Pro adds the Personal-Cloud capabilities over Free: recognition,
406 // sync, and a personal hosted-index bucket (GL #392).
407 assert!(e.supporter);
408 assert!(e.cloud_sync);
409 assert!(entitlement_allows(Plan::Pro, "cloud_sync"));
410 assert!(entitlement_allows(Plan::Pro, "supporter"));
411 assert_eq!(e.hosted_index_mb, 1_000);
412 assert!(entitlement_allows(Plan::Pro, "hosted_index"));
413 // …and NONE of the Team/Cloud coordination entitlements. The personal
414 // index stays strictly below Team's shared quota (supporter ⊂ pro ⊂ team).
415 assert_eq!(e.seats, 1);
416 assert!(e.hosted_index_mb < Plan::Team.entitlements().hosted_index_mb);
417 assert!(!e.private_registry && !e.sso_scim && !e.revenue_share);
418 assert!(!entitlement_allows(Plan::Pro, "private_registry"));
419 assert!(!entitlement_allows(Plan::Pro, "sso_scim"));
420 // Local features are never gated on Pro either.
421 for feature in LOCAL_ALWAYS_ON_FEATURES {
422 assert!(entitlement_allows(Plan::Pro, feature));
423 }
424 }
425
426 #[test]
427 fn cloud_sync_is_additive_supporter_subset_pro_subset_team() {
428 // free ⊂ supporter (no sync) ⊂ pro ⊂ team ⊂ enterprise (all sync).
429 assert!(!Plan::Free.entitlements().cloud_sync);
430 assert!(!Plan::Supporter.entitlements().cloud_sync);
431 assert!(Plan::Pro.entitlements().cloud_sync);
432 assert!(Plan::Team.entitlements().cloud_sync);
433 assert!(Plan::Enterprise.entitlements().cloud_sync);
434 }
435
436 #[test]
437 fn higher_plans_strictly_add_capabilities() {
438 let team = Plan::Team.entitlements();
439 let ent = Plan::Enterprise.entitlements();
440 assert!(team.private_registry && team.revenue_share);
441 assert!(ent.sso_scim && ent.private_registry);
442 assert!(entitlement_allows(Plan::Enterprise, "sso_scim"));
443 assert!(!entitlement_allows(Plan::Team, "sso_scim"));
444 // `supporter` is monotonic along the chain: every paid plan is a
445 // supporter; only Free is not.
446 assert!(!Plan::Free.entitlements().supporter);
447 assert!(Plan::Supporter.entitlements().supporter);
448 assert!(team.supporter && ent.supporter);
449 }
450
451 /// Cross-repo drift tripwire (GL #462). This catalog is the open SSOT of
452 /// `billing-plane-v1`; it must serialize byte-for-byte to the committed
453 /// golden fixture. The commercial control plane (`lean-ctx-cloud`) vendors
454 /// the identical fixture and pins its mirrored catalog against it, so a
455 /// value drifting on either side (like Pro `hosted_index_mb` 1000 vs 0)
456 /// fails CI loudly instead of silently breaking entitlements.
457 ///
458 /// Legitimate change procedure: update this catalog, regenerate the
459 /// fixture (the assert message prints the expected content on mismatch),
460 /// then copy the file into `lean-ctx-cloud/contracts/`.
461 #[test]
462 fn catalog_matches_golden_fixture() {
463 let catalog: Vec<Entitlements> = Plan::all().iter().map(|p| p.entitlements()).collect();
464 let rendered = serde_json::to_string_pretty(&catalog).expect("catalog serializes") + "\n";
465 // Normalize CRLF: Windows checkouts (autocrlf) hand include_str! a
466 // CRLF fixture while serde renders LF — same convention as the
467 // frozen-hashes gate in tests/contracts_frozen.rs.
468 let golden = include_str!("../../../../docs/contracts/billing-plane-v1-catalog.json")
469 .replace("\r\n", "\n");
470 assert_eq!(
471 rendered, golden,
472 "billing-plane-v1 catalog drifted from docs/contracts/billing-plane-v1-catalog.json \
473 — regenerate the fixture from this catalog and copy it to \
474 lean-ctx-cloud/contracts/billing-plane-v1-catalog.json"
475 );
476 }
477}