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