Skip to main content

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